新增: Phase2 阶段收尾(Sprint 1-20)

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

详见 PROGRESS.md。src-tauri/儿童每日打卡应用/ 与本项目无关,已排除。
This commit is contained in:
2026-06-14 14:08:20 +08:00
parent 98393b4908
commit cf017f81e2
167 changed files with 19549 additions and 6886 deletions

View File

@@ -84,12 +84,10 @@ devflow/
│ ├── df-execute/ # 执行运行时 (Shell/Docker/SSH/Git) │ ├── df-execute/ # 执行运行时 (Shell/Docker/SSH/Git)
│ ├── df-storage/ # 存储层 (SQLite) │ ├── df-storage/ # 存储层 (SQLite)
│ ├── df-ideas/ # 想法池引擎 (捕捉/评估/评分/晋升) │ ├── df-ideas/ # 想法池引擎 (捕捉/评估/评分/晋升)
── df-project/ # 多项目管理 (调度/上下文/时间线) ── df-project/ # 多项目管理 (调度/上下文/时间线)
├── df-task/ # 任务/分支管理 (并行任务/分支/合并/冲突)
├── df-traceability/ # 可追溯性 (标注/决策留痕/需求-测试映射) 注: df-task / df-traceability / df-evolve / df-stages / df-plugin
├── df-evolve/ # 经验进化 (知识沉淀/Prompt模板/审查规则/踩坑经验) 5 个 crate 已移除2026-06-14 零引用清理,推翻原"保留骨架"取舍)
│ ├── df-stages/ # 阶段插件 (5 阶段内置模板)
│ └── df-plugin/ # 插件系统 (WASM/动态库)
├── src/ # Tauri 主入口 ├── src/ # Tauri 主入口
│ ├── main.rs │ ├── main.rs
│ ├── state.rs │ ├── state.rs
@@ -137,7 +135,9 @@ devflow/
项目状态:`Planning / InProgress / Testing / Releasing / Completed / Paused / Cancelled` 项目状态:`Planning / InProgress / Testing / Releasing / Completed / Paused / Cancelled`
### 5.3.1 Task & Branch Manager (df-task) ### 5.3.1 ~~Task & Branch Manager (df-task)~~ — 已移除
> **2026-06-14 零引用清理**df-task crate 已删除。以下内容保留作为历史设计参考,不再对应实际代码。
项目内部的并行任务管理,每个任务绑定一个 Git 分支。 项目内部的并行任务管理,每个任务绑定一个 Git 分支。
@@ -165,7 +165,9 @@ Task 生命周期:
2. AI 辅助解决:冲突文件交给 AI 分析并建议解决方案 2. AI 辅助解决:冲突文件交给 AI 分析并建议解决方案
3. 发布编排:选择多个 Task → 创建 release 分支 → 合并 → 集成测试 → 发布 3. 发布编排:选择多个 Task → 创建 release 分支 → 合并 → 集成测试 → 发布
### 5.4 Traceability & Annotation (df-traceability) ### 5.4 ~~Traceability & Annotation (df-traceability)~~ — 已移除
> **2026-06-14 零引用清理**df-traceability crate 已删除。以下内容保留作为历史设计参考,不再对应实际代码。
贯穿所有阶段的可追溯性引擎。 贯穿所有阶段的可追溯性引擎。
@@ -504,7 +506,7 @@ CREATE TABLE decisions (
### Phase 4 — 节点丰富 + 阶段插件 (3-4 周) ### Phase 4 — 节点丰富 + 阶段插件 (3-4 周)
- df-nodes (Docker/Git/Human/HTTP) - df-nodes (Docker/Git/Human/HTTP)
- df-stages (5 阶段模板) - ~~df-stages (5 阶段模板)~~ — 已移除2026-06-14 零引用清理)
- 条件分支 + 断点续跑 - 条件分支 + 断点续跑
- 验证:跑通标准产研流程模板 - 验证:跑通标准产研流程模板

227
Cargo.lock generated
View File

@@ -721,10 +721,13 @@ name = "devflow"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono",
"df-ai", "df-ai",
"df-core", "df-core",
"df-execute", "df-execute",
"df-ideas",
"df-nodes", "df-nodes",
"df-project",
"df-storage", "df-storage",
"df-workflow", "df-workflow",
"futures", "futures",
@@ -732,7 +735,9 @@ dependencies = [
"serde_json", "serde_json",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-opener", "tauri-plugin-opener",
"tauri-plugin-window-state",
"tokio", "tokio",
"tracing", "tracing",
] ]
@@ -764,18 +769,6 @@ dependencies = [
"uuid", "uuid",
] ]
[[package]]
name = "df-evolve"
version = "0.1.0"
dependencies = [
"anyhow",
"chrono",
"df-core",
"serde",
"serde_json",
"tracing",
]
[[package]] [[package]]
name = "df-execute" name = "df-execute"
version = "0.1.0" version = "0.1.0"
@@ -818,20 +811,6 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "df-plugin"
version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"df-core",
"df-workflow",
"serde",
"serde_json",
"tokio",
"tracing",
]
[[package]] [[package]]
name = "df-project" name = "df-project"
version = "0.1.0" version = "0.1.0"
@@ -845,20 +824,6 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "df-stages"
version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"df-core",
"df-workflow",
"serde",
"serde_json",
"tokio",
"tracing",
]
[[package]] [[package]]
name = "df-storage" name = "df-storage"
version = "0.1.0" version = "0.1.0"
@@ -872,30 +837,6 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "df-task"
version = "0.1.0"
dependencies = [
"anyhow",
"chrono",
"df-core",
"serde",
"serde_json",
"tokio",
"tracing",
]
[[package]]
name = "df-traceability"
version = "0.1.0"
dependencies = [
"anyhow",
"chrono",
"df-core",
"serde",
"serde_json",
]
[[package]] [[package]]
name = "df-workflow" name = "df-workflow"
version = "0.1.0" version = "0.1.0"
@@ -2615,6 +2556,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [ dependencies = [
"bitflags 2.13.0", "bitflags 2.13.0",
"block2", "block2",
"libc",
"objc2", "objc2",
"objc2-core-foundation", "objc2-core-foundation",
] ]
@@ -3234,6 +3176,30 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "rfd"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
dependencies = [
"block2",
"dispatch2",
"glib-sys",
"gobject-sys",
"gtk-sys",
"js-sys",
"log",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"raw-window-handle",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows-sys 0.60.2",
]
[[package]] [[package]]
name = "ring" name = "ring"
version = "0.17.14" version = "0.17.14"
@@ -4055,6 +4021,48 @@ dependencies = [
"walkdir", "walkdir",
] ]
[[package]]
name = "tauri-plugin-dialog"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884"
dependencies = [
"log",
"raw-window-handle",
"rfd",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.18",
"url",
]
[[package]]
name = "tauri-plugin-fs"
version = "2.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371"
dependencies = [
"anyhow",
"dunce",
"glob",
"log",
"objc2-foundation",
"percent-encoding",
"schemars 0.8.22",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.18",
"toml 1.1.2+spec-1.1.0",
"url",
]
[[package]] [[package]]
name = "tauri-plugin-opener" name = "tauri-plugin-opener"
version = "2.5.4" version = "2.5.4"
@@ -4077,6 +4085,21 @@ dependencies = [
"zbus", "zbus",
] ]
[[package]]
name = "tauri-plugin-window-state"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73736611e14142408d15353e21e3cca2f12a3cfb523ad0ce85999b6d2ef1a704"
dependencies = [
"bitflags 2.13.0",
"log",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
]
[[package]] [[package]]
name = "tauri-runtime" name = "tauri-runtime"
version = "2.11.2" version = "2.11.2"
@@ -5239,6 +5262,15 @@ dependencies = [
"windows-targets 0.52.6", "windows-targets 0.52.6",
] ]
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets 0.53.5",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.61.2" version = "0.61.2"
@@ -5272,13 +5304,30 @@ dependencies = [
"windows_aarch64_gnullvm 0.52.6", "windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6", "windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6", "windows_i686_gnu 0.52.6",
"windows_i686_gnullvm", "windows_i686_gnullvm 0.52.6",
"windows_i686_msvc 0.52.6", "windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6", "windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6", "windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6", "windows_x86_64_msvc 0.52.6",
] ]
[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link 0.2.1",
"windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1",
"windows_i686_gnullvm 0.53.1",
"windows_i686_msvc 0.53.1",
"windows_x86_64_gnu 0.53.1",
"windows_x86_64_gnullvm 0.53.1",
"windows_x86_64_msvc 0.53.1",
]
[[package]] [[package]]
name = "windows-threading" name = "windows-threading"
version = "0.1.0" version = "0.1.0"
@@ -5309,6 +5358,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]] [[package]]
name = "windows_aarch64_msvc" name = "windows_aarch64_msvc"
version = "0.42.2" version = "0.42.2"
@@ -5321,6 +5376,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]] [[package]]
name = "windows_i686_gnu" name = "windows_i686_gnu"
version = "0.42.2" version = "0.42.2"
@@ -5333,12 +5394,24 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]] [[package]]
name = "windows_i686_gnullvm" name = "windows_i686_gnullvm"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]] [[package]]
name = "windows_i686_msvc" name = "windows_i686_msvc"
version = "0.42.2" version = "0.42.2"
@@ -5351,6 +5424,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]] [[package]]
name = "windows_x86_64_gnu" name = "windows_x86_64_gnu"
version = "0.42.2" version = "0.42.2"
@@ -5363,6 +5442,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]] [[package]]
name = "windows_x86_64_gnullvm" name = "windows_x86_64_gnullvm"
version = "0.42.2" version = "0.42.2"
@@ -5375,6 +5460,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]] [[package]]
name = "windows_x86_64_msvc" name = "windows_x86_64_msvc"
version = "0.42.2" version = "0.42.2"
@@ -5387,6 +5478,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]] [[package]]
name = "winnow" name = "winnow"
version = "0.5.40" version = "0.5.40"

View File

@@ -1,6 +1,6 @@
# DevFlow — 项目进展与工作交接 # DevFlow — 项目进展与工作交接
> 创建: 2026-06-10 | 最后更新: 2026-06-12 | 当前阶段: Phase 2 aichat UX 快赢A 路线)+ 技能联想需求调研Sprint 8 > 创建: 2026-06-10 | 最后更新: 2026-06-14 | 当前阶段: list_directory 防爆 + localStorage→SQLite 统一持久化 + token/发送 bug 修复(Sprint 19) → 项目管理模块代码审查 + 全局核对(Sprint 20未改代码)
--- ---
@@ -13,47 +13,45 @@
| 路径 | `E:/wk-lab/devflow/` | | 路径 | `E:/wk-lab/devflow/` |
| 架构文档 | `ARCHITECTURE.md` (22,745 字) | | 架构文档 | `ARCHITECTURE.md` (22,745 字) |
| Git 状态 | 未首次 commit代码全在 untracked | | Git 状态 | 未首次 commit代码全在 untracked |
| AI 能力 | df-ai OpenAI 兼容 Provider + 12 工具 + Agentic Loop | | AI 能力 | df-ai OpenAI/Anthropic 双协议 Provider + 12 工具 + Agentic Loop |
--- ---
## 二、代码规模统计 ## 二、代码规模统计
### Rust 后端 (71 个 .rs 文件) ### Rust 后端 (78 个 .rs 文件)
| Crate | 文件数 | 总行数 | 有效行 | 实现程度 | | Crate | 文件数 | 总行数 | 有效行 | 实现程度 |
|-------|--------|--------|--------|---------| |-------|--------|--------|--------|---------|
| df-core | 4 | 259 | 151 | ✅ 完整 — 错误/事件/状态枚举/ID生成 | | df-core | 4 | 429 | ~260 | ✅ 完整 — 错误/事件/状态枚举/ID生成 |
| df-workflow | 7 | 489 | 333 | ✅ 核心 — DAG拓扑排序/执行器/状态机/事件总线 可用 | | df-workflow | 9 | 908 | ~620 | ✅ 核心 — DAG拓扑排序/执行器/状态机/事件总线 可用 |
| df-storage | 4 | 294 | 217 | ✅ 部分SQLite连接/迁移/建表完整,**缺 CRUD 层** | | df-storage | 5 | ~2,100 | ~1,400 | ✅ 完整 — 连接/迁移/建表(V1-V8) + impl_repo! 宏 CRUD + KnowledgeReposearch/list_by_status/set_embedding/search_vector 等)+ 余弦相似度工具函数 |
| df-execute | 5 | 203 | 129 | ⚡ 混合 — Shell 执行器真实可用Docker/SSH/Git 骨架 | | df-execute | 5 | 203 | 129 | ⚡ 混合 — Shell 执行器真实可用Docker/SSH/Git 骨架 |
| df-ideas | 6 | 421 | 278 | ⚡ 大部分 — 捕获/评估/晋升/关联图 有逻辑,评分固定值 | | df-ideas | 7 | 761 | ~500 | ✅ 评分/对抗启发式已真实化Sprint 9— 捕获/评估/晋升/关联图 有逻辑,晋升/关联图仍骨架 |
| df-task | 5 | 390 | 252 | ⚡ 大部分 — Task/Branch/Semver 可用,合并冲突 骨架 |
| df-project | 5 | 272 | 175 | ⚡ 混合 — 数据模型/Timeline 可用,调度器 骨架 | | df-project | 5 | 272 | 175 | ⚡ 混合 — 数据模型/Timeline 可用,调度器 骨架 |
| df-ai | 6 | 281 | 173 | 🔧 接口 — 类型/Provider trait 完整,无实际 LLM 调用 | | df-ai | 9 | 1,965 | ~1,300 | ✅ 完整 — OpenAI/Anthropic 双协议 Provider + 流式 SSE + 上下文窗口(ContextManager 分组滑窗) + token usage 解析 + 12 工具 |
| df-traceability | 4 | 312 | 201 | ⚡ 混合 — 决策记录/标注构建 可用SQLite查询 骨架 | | df-nodes | 9 | ~500 | ~350 | ⚡ 混合 — script_node接 Shell/ ai_node接 df-aiSprint 7真实可用human_node 半实现(审批响应 TODO其余骨架 |
| df-plugin | 4 | 255 | 158 | ⚡ 混合 — Host/Builder 有逻辑,核心加载 骨架 | | **合计** | **53** | **~7,400** | **~4,800** | 已删除 5 个零引用 crate2026-06-14原 78/9100/5900 |
| df-evolve | 6 | 301 | 172 | ⚡ 混合 — Knowledge/PromptTemplate 可用,引擎/提取器 骨架 |
| df-nodes | 9 | 344 | 277 | ⚡ 混合 — script_node 真实可用(接 Shellhuman_node 半实现(审批响应 TODO其余 6 种骨架 |
| df-stages | 6 | 287 | 209 | 🔩 全骨架 — 11个阶段节点 Schema 完整execute() 全空 |
| **合计** | **71** | **~4,108** | **~2,625** | |
### 前端 (9 页面 + Store + i18n) ### 前端 (9 页面 + 2 组件 + Store + i18n)
| 类别 | 文件数 | 行数 | 状态 | | 类别 | 文件数 | 行数 | 状态 |
|------|--------|------|------| |------|--------|------|------|
| Vue 页面 | 9 | ~3,372 | ✅ UI 全部真实 — 无空壳 | | Vue 页面 | 9 | ~3,372 | ✅ UI 全部真实 — 无空壳(含 AiHome/AiDetached|
| Pinia Store | 4 | ~362 | ⚠️ 有 state/getter 但**无 actionView 未引用** | | Vue 组件 | 2 | ~1,400 | ✅ AiChat主面板+ ConfirmDialog |
| Router | 1 | 63 | ✅ 9 条路由含动态路由 | | Pinia Store | 4+ | ~800 | ✅ composable 模式View 已引用Sprint 4 接入)|
| i18n | 2 | ~94 | ⚠️ 翻译完整但**未接入 main.ts** | | Router | 1 | ~90 | ✅ 10 条路由含动态路由Sprint 7 增 AiHome/AiDetached|
| i18n | 2 | ~94 | ✅ 已注册 main.tsSprint 7+ localStorage 持久化 |
| 设计系统 | 1 | 105 | ✅ CSS 变量/动画/Arco 暗色覆盖 | | 设计系统 | 1 | 105 | ✅ CSS 变量/动画/Arco 暗色覆盖 |
| Tauri Commands | 1 | 13 | 🔩 仅 `greet` 示例,无业务逻辑 | | **前端合计** | | **~8,812** | |
**前端关键问题** > Tauri Commands 已达 **57 个**project/task/idea/workflow + AI 对话/工具/审批/Provider/技能全功能 + 知识库 11 个),见后端 src-tauri/src/commands/。
- 所有 View 组件数据**硬编码** (`ref([...])`),与 Store 完全独立
- 所有按钮事件处理函数为**空函数** `{}` **前端关键问题**Sprint 4/7 已全部解决):
- i18n 插件未注册到 Vue app - ~~所有 View 组件数据硬编码~~ ✅ Sprint 4 接 Store 真数据
- Arco Design 组件库未实际使用(仅 CSS 变量覆盖) - ~~所有按钮事件处理函数为空函数~~ ✅ Sprint 4 接 Store action
- ~~i18n 插件未注册~~ ✅ Sprint 7 注册 + $t() 全 view 改造
- ~~Arco Design 组件库未实际使用~~ ✅ 已用(表单/弹窗/消息等CSS 变量覆盖暗色
--- ---
@@ -68,11 +66,11 @@
待打通路径(骨架就位,需填充): 待打通路径(骨架就位,需填充):
df-workflow → df-nodes (8种节点 execute() 待实现) df-workflow → df-nodes (8种节点 execute() 待实现)
df-workflow → df-stages (11种阶段节点 execute() 待实现) ~~df-workflow → df-stages (11种阶段节点 execute() 待实现)~~ — df-stages 已移除2026-06-14 零引用清理)
df-ai → LlmProvider 实现 (无 HTTP client) ~~df-ai → LlmProvider 实现~~ ✅ 已打通OpenAI/Anthropic 双协议真实 HTTPSprint 5/8
df-storage → 所有 crate 的 CRUD 操作 ~~df-storage → 所有 crate 的 CRUD 操作~~ ✅ 已打通impl_repo! 宏 CRUD + 多 RepoSprint 2
Tauri commands → Rust 业务逻辑 ~~Tauri commands → Rust 业务逻辑~~ ✅ 已打通47 commands 全接业务Sprint 3
Vue Views → Pinia Store → Tauri IPC → Rust ~~Vue Views → Pinia Store → Tauri IPC → Rust~~ ✅ 已打通Sprint 4
``` ```
--- ---
@@ -89,6 +87,7 @@
| 6 | **条件表达式引擎** — 仅支持 true/false 字面量 | 条件分支不可用 | P2 | ⬜ 待办 | | 6 | **条件表达式引擎** — 仅支持 true/false 字面量 | 条件分支不可用 | P2 | ⬜ 待办 |
| 7 | **i18n 未注册** — 翻译文件存在但未挂载 | 多语言不生效 | P2 | ✅ Sprint 7 | | 7 | **i18n 未注册** — 翻译文件存在但未挂载 | 多语言不生效 | P2 | ✅ Sprint 7 |
| 8 | **未 git commit** — 代码全 untracked | 无版本基线 | P0 | ✅ Sprint 2 | | 8 | **未 git commit** — 代码全 untracked | 无版本基线 | P0 | ✅ Sprint 2 |
| 9 | **AI 删项目绕过软删** — tool_registry:263 `repo.delete` 物理删IPC 走 `soft_delete` 进回收站AI 层缺 restore/purge/list_trash 工具(仅 project 表有回收站设计,故最该修) | AI 对话删项目 = 数据丢失不可恢复,与 UI 删语义割裂 | P1 | ⬜ Sprint 20 待修批1 |
--- ---
@@ -121,9 +120,9 @@
| AI Chat 面板 | ✅ | 侧边栏 + 分离窗口 + 拖拽调宽 + 对话管理 | | AI Chat 面板 | ✅ | 侧边栏 + 分离窗口 + 拖拽调宽 + 对话管理 |
| Agentic Loop | ✅ | 多轮工具调用循环max 10 轮)+ 中/高风险人工审批 | | Agentic Loop | ✅ | 多轮工具调用循环max 10 轮)+ 中/高风险人工审批 |
| AI Node 实现 | ✅ | df-nodes 接 df-aiconfig 驱动 providerbase_url/api_key/model/prompt→ OpenAICompatProvider.complete → 输出 text/model/usage注册 "ai" 节点;未端到端实测 | | AI Node 实现 | ✅ | df-nodes 接 df-aiconfig 驱动 providerbase_url/api_key/model/prompt→ OpenAICompatProvider.complete → 输出 text/model/usage注册 "ai" 节点;未端到端实测 |
| **AI Chat LLM 优化** | 🚧 P0 完成 | 任务 #43P0 可靠性(超时/断连/停止生成P1 token 控制usage采集/滑动窗口/摘要)⬜ | | | **AI Chat LLM 优化** | ✅ ABCD 完成 | 任务 #43P0 可靠性P1 上下文窗口ContextManager 分组滑窗)✅ + 并行化(工具 join_all / save 后台)✅;**Sprint 14 流式 token 用量记录usage 解析 + 累加落库 + 展示 + Settings 开关)✅**Part C 并发 Semaphore双层 + 运行时可调)+ Part D Settings UI ✅Sprint 16Review 两轮 8 修复 ✅ | |
### Phase 3 — 想法池 + 多项目 (3-4 周) | ⬜ 未开始 ### Phase 3 — 想法池 + 多项目 (3-4 周) | 🚧 进行中 — Sprint 9 评估闭环已通(启发式评分 + 对抗评估接 IPC晋升前端层闭环多项目/关联图待续Sprint 12 全面审查修 5 项前端 bug置信度条/状态选择/删除确认等),死代码组缓另会话重审
### Phase 4 — 节点丰富 + 阶段插件 (3-4 周) | ⬜ 未开始 ### Phase 4 — 节点丰富 + 阶段插件 (3-4 周) | ⬜ 未开始
### Phase 5 — 体验打磨 (4-6 周) | ⬜ 未开始 ### Phase 5 — 体验打磨 (4-6 周) | ⬜ 未开始
@@ -318,7 +317,7 @@
- **审查项落地**:删全屏 Tool Approval Modal保留行内卡片审批、错误友好化`friendlyError` 正则映射 404/401/timeout/network → 中文提示 + `isError` 红色气泡)、智能滚动(`isNearBottom` < 80px 阈值、provider bar 点击切换、未配 provider 空状态引导、删死 class - **审查项落地**:删全屏 Tool Approval Modal保留行内卡片审批、错误友好化`friendlyError` 正则映射 404/401/timeout/network → 中文提示 + `isError` 红色气泡)、智能滚动(`isNearBottom` < 80px 阈值、provider bar 点击切换、未配 provider 空状态引导、删死 class
- **B 路线(规划式多智能体)单独立项**:填 `coordinator.rs` + `conditions.rs` 空壳,详见 memory `aichat-decision-capability` - **B 路线(规划式多智能体)单独立项**:填 `coordinator.rs` + `conditions.rs` 空壳,详见 memory `aichat-decision-capability`
**B. 技能联想功能需求(本轮重点,未实现,需求阶段** **B. 技能联想功能(首批 Claude 3 类,已实现待实测**
- **需求**aichat 输入框输入 `/` → 联想列出本机 Claude 技能SKILL.md按输入过滤选中插入 `/name`;发送时后端读 SKILL.md 全文注入 system prompt / context - **需求**aichat 输入框输入 `/` → 联想列出本机 Claude 技能SKILL.md按输入过滤选中插入 `/name`;发送时后端读 SKILL.md 全文注入 system prompt / context
- **首批数据源 = Claude 3 类**SKILL.md frontmatter 统一 `name` / `description` / `user_invocable` / `metadata.argument-hint` / `triggers[]`,只取 `user_invocable: true` - **首批数据源 = Claude 3 类**SKILL.md frontmatter 统一 `name` / `description` / `user_invocable` / `metadata.argument-hint` / `triggers[]`,只取 `user_invocable: true`
1. Claude skills`~/.claude/skills/*/SKILL.md`23 个sleep / mission-control / idea / cpa 等) 1. Claude skills`~/.claude/skills/*/SKILL.md`23 个sleep / mission-control / idea / cpa 等)
@@ -338,10 +337,347 @@
**代码变更**A 路线 — `crates/df-ai/src/{anthropic_compat.rs, lib.rs}``src-tauri/src/commands/ai.rs``crates/df-nodes/src/ai_node.rs``src/views/Settings.vue``src/stores/ai.ts``src/components/AiChat.vue``src/api/types.ts` **代码变更**A 路线 — `crates/df-ai/src/{anthropic_compat.rs, lib.rs}``src-tauri/src/commands/ai.rs``crates/df-nodes/src/ai_node.rs``src/views/Settings.vue``src/stores/ai.ts``src/components/AiChat.vue``src/api/types.ts`
**遗留问题 / 下一步** **遗留问题 / 下一步**
1. **技能联想B实现**需求已记,首批聚焦 Claude 3 类,等排期(工作量中:后端扫文件 + IPC、前端联想浮层 1. **技能联想B实现待实测**`ai_list_skills` IPC扫 skills/commands/plugins 三类 + frontmatter 解析 + name 去重)+ `ai_chat_send``skill` 参数读 SKILL.md 注入 system prompt + 前端 `/` 浮层(↑↓ 导航 / Enter·Tab 选中 / chip 显示 / Esc 关。cargo check ✓7 warning 既有)/ bun build ✓4.68s)。实测:重启 dev → AiChat 输入 `/` → 选技能 → 发送
2. **A 路线剩余场景实测**:切对话不中断(场景 2/3、错误友好化场景 4待用户实测 2. **A 路线剩余场景实测**:切对话不中断(场景 2/3、错误友好化场景 4待用户实测
3. **B 路线(规划式能力)**coordinator / conditions 空壳,单独立项 3. **B 路线(规划式能力)**coordinator / conditions 空壳,单独立项
### [Sprint 9] 2026-06-12 — 灵感模块评估闭环(启发式评分 + 对抗评估接 IPC
**工作内容**
- **df-ideas 修复与真实化**:① 修 adversarial.rs E0308 类型错(`AdversarialEval.negative: Argument` vs `generate_negative_argument``CounterArgument` 直接赋值df-ideas 实际编译不过——前文档"cargo check 通过"为误记)→ 删 CounterArgument、正反方归一 Argument② scoring.rs 三维固定 5.0 → 内容启发式priority / 描述充实度 / tags / 中英关键词clamp 0-10③ adversarial.rs 正反方论点/evidence 基于真实 idea 内容生成confidence 由评分驱动final_score=综合评分AssessmentLevel 由 overall 映射
- **devflow 接 df-ideas**`src-tauri/Cargo.toml` +df-ideas +chrono 依赖df-ideas 此前为孤儿 cratedevflow 未依赖、零 IPC 接入)
- **evaluate_idea IPC**commands/idea.rs取 IdeaRecord → record_to_idea 转 df-ideas::Idea → ScoringEngine + AdversarialEngine.evaluate → 组装前端扁平结构(对齐 Ideas.vue AdversarialEval interface→ 写回 scores中文维度 0-100/score0-100/ai_analysis/status=pending_review → 返回更新记录lib.rs 注册
- **前端接真实评估**api/idea.ts +evaluatestores/project.ts +evaluateIdea actionIdeas.vue 删 mockEval 调真实 + evaluating loading 态
- **审核修 2 bug**:① recommendation 大小写(后端原 "With Resources" 与前端 assessmentLabel 全小写 map key 不匹配致中文标签不显示)→ 全小写空格;② assessmentClass 生成的 class 与 CSS 类名不一致致 badge 无色 → map 映射到 .immediate/.soon/.conditional/.revised/.defer/.cancel
**代码变更**df-ideas/{scoring,adversarial}.rs、src-tauri/{Cargo.toml,commands/idea.rs,lib.rs}、前端 {api/idea.ts,stores/project.ts,views/Ideas.vue}8 文件)
**验证**cargo check -p df-ideas ✓、cargo check -p devflow ✓、bun run build ✓vue-tsc + vite 3.3s
**遗留问题 / 下一步**
1. 对抗评估仍启发式,未接 df-ai LlmProvider设计LLM 生成正反方论点 + 启发式 fallback
2. promotion.rs do_promote 仍 TODO晋升走前端 createProject 闭环,不经 df-ideas/promotion
3. evaluate_idea 用 list_all().find() + 5 次 update_fieldIdeaRepo 无 get/多字段更新,量小可接受)
4. IdeaGraph 关联图零接入(自动关联发现/聚类待续)
5. scores JSON 含「综合」维度也被雷达图渲染一行(小瑕疵)
### [Sprint 10] 2026-06-12 — AiChat 工具卡片自动折叠(卡片级折叠 + 新内容追加自动收起)
**工作内容**
- **需求**对话中工具调用卡片List Projects / List Tasks / Create Idea / List Ideas / 读取文件等)完成后仍整卡铺开,多步调用时界面臃肿。目标——展示更友好紧凑:所有卡片可点击 header 展开/收起,新内容追加时旧卡片自动收起为单行 header类似 ChatGPT/Cursor 旧 tool call 折叠)
- **卡片级折叠**(区别于 read_file 已有的内容级折叠):`.ai-tool-card` 内 body 区(骨架屏/审批/拒绝/file/dir/write/通用结果)包进 `.ai-tool-body``v-show` 控制显隐header 加 `@click` + ▸ 箭头指示器;新增 `expandedCards` Set + `toggleCardExpand` + `cardCollapseClass`,与既有 `expandedTools`read_file 预览级)职责分离不互相干扰
- **自动收起**deep watch `store.state.messages` + 轻量 JSON snapshot diff 检测新内容(新消息 / toolCall 状态变化 / 文本增长)→ 清除旧 completed/rejected 的展开态,保留 running/pending 活跃卡片;`isFirst` guard 防首次加载/切对话误触发
- **running/pending_approval 强制展开**(需看骨架屏与审批按钮);`v-show` 保留 DOM 避免重挂载
**代码变更**`src/components/AiChat.vue`template header/body 包裹 + script 状态与 watcher + style 折叠态),单文件 7 处改动
**验证**vue-tsc --noEmit ✓(运行时待 `bun run tauri dev` 实测多步工具调用折叠效果)
**遗留问题 / 下一步**
1. ~~审查项 ①~~ ✅ 已落地2026-06-12`isRunningOrPending(status)``shouldKeepOpen(tc)``running` / `pending_approval` / `rejected` / `write_file` 强制展开(短结果不折叠、原因/路径不丢失),仅 `read_file` / `list_directory` / 通用 JSON 大体量结果折叠。vue-tsc --noEmit ✓。决策详见 [功能决策记录 - 短结果保持展开](docs/02-架构设计/功能决策记录.md)
2. 实测验证tauri dev 发多步工具调用消息,观察"执行中展开→完成→新内容追加自动收起→点击 header 重展开"
### [Sprint 11] 2026-06-13 — AI Chat LLM 优化(上下文窗口管理 + 并行化)
**任务 #43 续**Part A + B 落地Part C/D 待续)
**工作内容**
**Part A: 上下文窗口管理df-ai/context.rs + ai.rs 接线)** — 长对话无限增长致 `context_length_exceeded` 死锁
- **A1 ContextManager**`crates/df-ai/src/context.rs` 新增 ~350 行TokenEstimator 字符粗估chars×0.35 + per_message 4 + per_tool_call 30零依赖保守 ±15%+ ContextConfigmax_tokens 128k / output_reserve 8192 / safety 0.85+ 分组滑动窗口淘汰——淘汰单元保工具调用三元组原子性ToolCallHead + ToolResultTail* + 紧随文本 Assistant 同进同出),保护区最后 6 条永不裁裁剪仅影响发送视图build_for_request 返裁剪 Vec不影响持久化all_messages_clone 返全量。5 单测全绿(短对话无裁剪 / 超预算裁剪保保护区 / 三元组原子性双分支 / replace 重估 token / restore 重建缓存)
- **A2 ai.rs 接线**~15 处AiSession.messages 类型 `Vec<ChatMessage>``ContextManager`;核心 run_agentic_loop 用 `build_for_request(sys_tokens)` 替代全量 `.clone()`save/title 用 `all_messages_clone()`switch 用 `restore_from_messages()``replace_tool_result` 收敛为 ContextManager 方法DRY删自由函数
**Part B: 并行化改造ai.rs** — 串行瓶颈 + Completed 事件阻塞
- **B1 process_tool_calls 并行化**Low 风险工具 `futures::future::join_all` 并行 execute + 即时 emit Completed结果串行回填 session.messagesN 个独立工具 ≈ 最慢一个耗时,原 N 倍串行Med/High 仍串行审批门控。tool_result push 顺序变化无语义影响LLM 按 tool_call_id 关联,不看绝对位置)
- **B2 正常完成后台化**save+extract+title 打包同一 `tauri::async_runtime::spawn`(非裸 spawn save——保 maybe_spawn_extraction 读已落库消息的顺序依赖Completed 事件即时下发
- **B3 标题后台化**`ensure_conversation_title` 签名收 `provider_config` 克隆进 task 自建 provider`&dyn` 非 'static 无法 move`spawn_ensure_title` 后台(含 2 处 stop 路径)
**关键取舍**:①打包 spawn 而非裸 spawn save保 extract 顺序依赖);②并发 upsert 低概率丢少量 token 可接受(非功能错误);③停止路径保持同步 save避免 stop 后即发新消息的并发 upsert 竞态丢 token
**代码变更**`crates/df-ai/src/context.rs`(新增)、`src-tauri/src/commands/ai.rs`(核心改造)、`docs/02-架构设计/功能决策记录.md`8 条新决策Part A 2 + Part B 3 + 既有的)
**验证**cargo check -p df-ai ✓5 单测绿、cargo check -p devflow ✓6 既有 warning无 error无单测回归
**遗留问题 / 下一步**
1. **Part C/D 未做**todo #48-52 已建)—— Part C 双层 Semaphoreglobal 默认 3 / per_conv 默认 2+ 3 LLM 调用点stream_llm / 标题 / 提炼)加 permit + `ai_set_concurrency_config` commandPart D Settings.vue 两 setting-row + localStorage 持久化 + onMounted 同步
2. **实测验证**P1tauri dev 验证长对话裁剪行为 + 多工具并行耗时 + Completed 事件即时性
### [Sprint 12] 2026-06-13 — 灵感模块全面审查 + A 类问题修复
**工作内容**
- **df-ideas 灵感模块全面审查**(前端 Ideas.vue / api / store + IPC idea.rs + crate 7 文件 + df-project/manager + df-storage/crud按「界面展示 / 交互 / 功能可用性 / 合理性」+「功能架构 / 代码逻辑 / 代码架构」六维逐条核对,共 12 项发现🔴5 🟡5 ⚪2。复核确认 12 项全部真实存在、无误判无虚报。
- **修复 A 类 5 项**(明确知道怎么做、零争议,全在 Ideas.vue
- ① 置信度进度条不显示填充(`::after` 无 width + 锚点跑到 `.debate-column`)→ 拆 `.confidence-bar`(灰轨)+ `.confidence-fill`(填充层,正绿反红),删失效 `::after`
- ② 状态 select 改选不生效v-model 绑只读 computed 致写失败、回弹并写回旧 status→ 弃 v-model`:value` + `@change``event.target.value`
- ③ 删 `filterIdeas` 空函数及其 `@input` 绑定filteredIdeas 本是 computed 自动响应)
- ④ 删除想法加 `confirm()` 二次确认
- ⑥ 对抗评估标题加「启发式」tag诚实标注当前为启发式非 LLM避免「对抗辩论」措辞误导
- **决策**:死代码/双套抽象组⑦⑧⑨⑩缓到另一会话届时重新审核问题真实性B 类(⑤⑥根治⑪⑫)方案未定,待定方向后再推。
**代码变更**`src/views/Ideas.vue`(单文件 9 处:模板 5 + script 3 + CSS 2 段)
**验证**`vue-tsc --noEmit` ✓(零错误零输出)
**遗留问题 / 下一步**
1. **B 类(有必要但方案未定)**:⑤ 立项引导(三种改法改变产品流程语义:放宽显示条件 / 保持 approved 门槛加提示 / 评估达标自动 approved/ ⑥ 对抗评估接 df-ai LlmProvider 根治(大工程,单独立项;当前 UI「启发式」标记为临时绕过/ ⑪ 状态常量集中(跨 Rust+TS 双侧IdeaStatus 枚举已有但 IdeaRecord 用 String 落库)/ ⑫ 实体↔Record 映射(评估为合理分层,建议不改)
2. **缓组(另会话重审真实性后再动)**:⑦ df-ideas 死代码空壳capture/graph/evaluator/promotionIdeaGraph 关联图是已知下一步增强需求,删/留需定)/ ⑧ 两套 Recommendation 枚举 + 两套评估入口并存adversarial 6 变体实用 vs evaluator 5 变体仅死代码引用)/ ⑨ PromotionPolicy 三档死枚举 / ⑩ Idea 实体构造分散record_to_idea 与 IdeaCapture::capture 字段重叠)
3. **实测验证**tauri dev 看置信度条彩色填充 / 状态切换生效 / 删除弹确认 / 评估区「启发式」标
### [Sprint 13] 2026-06-13 — 文档记录治理体系(路由规范 + decision-record skill + 降频自检 hook
**背景**:功能决策/需求散落于对话、PROGRESS、模块文档缺统一真相源与触发机制。建立「单一真相源 + 自动自检」治理体系。
**工作内容**
- **文档记录规范**`docs/02-架构设计/文档记录规范.md` 新建核心原则SSOT / 不复制只引用 / 决策与流水分离 / 先主后辅)+ 文档职责矩阵(每类信息唯一主文档)+ 内容路由表 + 更新顺序 + 唯一性记录与检测(记前 grep 查重 / 单向引用 / 定期扫描)+ 与 skill 关系 + 治理体系实现决策hook 设计取舍)
- **功能决策记录扩展**`docs/02-架构设计/功能决策记录.md`):约定节加需求条目说明;末尾新增「需求与待办」总览(📋 待做需求表 + 需求澄清)——散落各决策条目 📐/🚧 状态的待办汇集为单一清单
- **decision-record skill 增强**`~/.claude/skills/decision-record/SKILL.md`):除「为什么这么定」决策,增记需求细节(待做/规格/澄清);维护规则(记前查重 / 演进 `→` 标注 / 定期压缩防爆炸);原则补「更新优先于新增」
- **降频自检 Stop hook**`~/.claude/hooks/dr-check.sh` + `settings.json` Stop 配置):累计 ≥10 轮 或 (>1 轮 且 距上次 ≥10 分钟) 才注入自检提示触发 skill纯脚本计数无 API替代每轮自检省 ~90% token`stop_hook_active` guard 防循环;状态按项目隔离(`~/.claude/.dr-state/`,不污染 git。设计取舍详见 [文档记录规范 - 治理体系实现决策](docs/02-架构设计/文档记录规范.md)
- **修 `docs/INDEX.md` 误登记**PROGRESS 实际在根级,移除 `07-项目管理/` 树下的重复登记行
**代码变更**:非产品代码——`docs/02-架构设计/{文档记录规范.md,功能决策记录.md,INDEX.md}``~/.claude/{skills/decision-record/SKILL.md,hooks/dr-check.sh,settings.json}`
**验证**hook 逻辑实测(首战静默初始化 / 2-9 轮静默计数 / 第 10 轮或 ≥10 分钟触发 / 防循环 guard无 jq 依赖纯脚本
**遗留问题 / 下一步**
1. hook 实际长期触发频率待观察10 轮 / 10 分钟阈值是否合理,按需调)
2. 决策爆炸后执行首次压缩(> 300 行 / 老 Sprint > 3 / 单域 > 10 条触发归档或拆域)
### [Sprint 14] 2026-06-13 — 流式 token 用量记录 + Provider 默认态持久化
**工作内容**
**A. 流式 Token 用量获取 + 记录 + 展示 + 设置开关**(跨 df-ai/df-storage/src-tauri/前端11 文件)— `stream_llm` 全程丢弃 token 计数、`AiCompleted.total_tokens` 恒 0 的旧疾根治Sprint 6 P1 token 控制中「usage 采集用户叫停」项重启落地):
- **Provider 层 usage 解析**`StreamChunk``usage: Option<TokenUsage>`OpenAI 开 `stream_options:{include_usage:true}` 末 chunk 取累计 usageAnthropic `message_start`/`message_delta` 累积output_tokens 当累计值覆盖,非增量)
- **累加落库**`ai_conversations``prompt_tokens`/`completion_tokens`V5 迁移);`save_conversation` upsert 走累加模式(读旧值叠加,非覆盖——保审批暂停→恢复跨 loop 实例总用量正确);`run_agentic_loop` 局部累加器每轮叠加
- **前端展示 + 开关**store 双状态 `lastTokenUsage`(单次)+ `convTokenTotal`(对话累计,切对话从 DB 加载AI 气泡底部 token 条(默认关闭,`df-show-token-usage` 开关放 Settings
- **model 记录**:消息级 model + 对话级 models 数组C+B`default_model` 配置值补填不覆盖
**B. Provider 默认态持久化is_default 重启丢失 bug 修复)** — 「设为默认」重启后丢失、两条记录都显示「设为默认」按钮的 bug 根治:
- 根因:`is_default` 字段恒写 `false` + `ai_set_provider` 只改内存 session 不落库 → 重启 session 清零默认全丢
- 修复:`ai_set_provider` 互斥落库(目标 true/其余 false仅写变化记录`ai_save_provider` 新建首个自动默认;`ai_list_providers` 直返 DB 值;`active_provider_id` 降为运行时缓存
**代码变更**A — df-ai/{provider,openai_compat,anthropic_compat}.rs、df-storage/{models,crud,migrations}.rs、src-tauri/commands/ai.rs、前端 {types,stores/ai,components/AiChat,views/Settings}11 文件B — src-tauri/commands/ai.rs3 函数)
**验证**cargo check --workspace ✓0 error6 既有 warning、vue-tsc --noEmit ✓。**未 tauri dev 运行时实测**
**遗留问题 / 下一步**
1. tauri dev 实测token 落库 / 切换对话总量加载 / 重启保默认 / 首个自动默认
2. `ai_save_provider` get_by_id 双调用review 🟡①,冗余往返,待合并)
3. Settings 页面 UI 重构Part D用户说稍后
4. model UI 展示(暂缓)
### [Sprint 15] 2026-06-13 — 知识库 Tier 1 全栈打通 + Phase 5.5 向量检索
**工作内容**
**Tier 1 知识库双向闭环AI Chat ↔ 知识库,全栈打通)** — candidate→published 状态机 + AI 提炼 + 手动录入 + 知识注入:
**A. 存储层df-storageV7/V8 迁移)**
- V7 建表 `knowledges`id/kind/title/content/tags/status/confidence/reuse_count/verified/source_project/source_ref/created_at/updated_at3 索引
- V8 幂等补列 `embedding BLOB`PRAGMA table_info 探测,同 v4/v5/v6 模式)
- `KnowledgeRecord`models.rs`embedding: Option<Vec<u8>>`
- `KnowledgeRepo`crud.rs`search()`LIKE 双分支,按 kind 可选过滤)/ `list_by_status()`CASE WHEN confidence 语义排序)/ `increment_reuse_count()`SQL 原子 +1/ `top_used()`;向量扩展:`set_embedding()` / `search_vector()`(纯 Rust 余弦批量比较skip 维度不匹配);工具函数:`f32s_to_blob` / `blob_to_f32s` / `cosine_similarity`
**B. 领域层df-evolve**
- `KnowledgeStatus` 枚举Candidate/PendingReview/Published/Archivedsnake_case`from_str`/`as_str`
- `Confidence` 枚举High/Medium/Low同上
- `Knowledge` struct移除 `effectiveness` 字段,时间字段统一 String 毫秒(对齐全部既有 model
- `pattern.rs`:清理死参数 `result_quality: f32`
**C. IPC 层src-tauri/commands/knowledge.rs 新建11 个 Command**
- `knowledge_{list,get,search,create,update_status,record_reuse,list_candidates,archive,get_config,save_config,extract_now}`
- 状态转换合法矩阵硬编码candidate→{pending_review|published|archived}; pending_review→{published|archived}; published→{archived}
- `knowledge_archive`(软删除,匹配 `ai_conversation_archive` 语义)
- 配置读写:`knowledge_{get,save}_config`(读写 `AppState.knowledge_config: Arc<Mutex<KnowledgeConfig>>`
- 手动提炼:`knowledge_extract_now`
**D. AI 集成ai.rs知识注入 + 提炼)**
- `build_knowledge_context()``auto_inject` 开关 → `hybrid_search()` top-3 → `increment_reuse_count` fire-and-forget → 注入 system prompt 头部
- `extract_knowledge_from_conversation()`:后台 spawnmin_messages≥4 守卫 → 取最后 6 条消息 → LLM JSON 提炼 → 逐条写 candidate提炼失败仅 warn 不阻断
- `maybe_spawn_extraction()`agentic loop 两处正常退出统一调用(`OnComplete` 默认模式)
**Phase 5.5 — 向量检索Settings 开关,默认关闭)**
- `LlmProvider::embed()` trait 方法:默认返回 Erropenai_compat 实现 POST `/v1/embeddings`(按 index 排序)
- `KnowledgeConfig` 加三字段:`vector_enabled`(默认 false/ `embedding_provider_id`(排除 anthropic/ `embedding_model`
- `generate_embedding()`:截断 8000 字 → build_provider → embed()
- `spawn_embedding_for_knowledge()`:知识发布时 fire-and-forget 生成嵌入(不在 candidate 阶段浪费调用)
- `hybrid_search()`三层降级链——开关关→LIKE; embed 失败→LIKE; 正常→双信号排序(同时命中>仅 LIKE>仅向量 cos≥0.3);不引入 sqlite-vec规避 Windows MSVC C 扩展编译风险)
- `Settings.vue`:语义检索开关 + v-if 联动 embedding provider 下拉 + 模型名输入
**E. 前端TypeScript + Vue**
- `types.ts``KnowledgeRecord` / `CreateKnowledgeInput` / `KnowledgeConfig`(含 vector 三字段)
- `api/knowledge.ts`新建11 个 invoke 封装)
- `stores/knowledge.ts`完全重写getter 模式,消除全部 15 条 mock + localStorage
- `Knowledge.vue`(完全重写:知识库/审核收件箱双 Tab + 状态徽章 + confidence 指示器 + 新增对话框 + 候选 badge
- `App.vue`:侧栏候选 badge`candidateCount` refstore loadCandidates 后更新)
**F. 配置设计**AppState 内存 + IPC非 Tauri config 插件):
- `KnowledgeConfig`state.rsauto_extract / trigger_mode / min_messages / idle_timeout_ms / auto_inject / vector_enabled / embedding_provider_id / embedding_model
**代码变更**df-storagemodels.rs/crud.rs/migrations.rs/ df-evolveknowledge.rs/pattern.rs/ src-tauristate.rs + commands/knowledge.rs 新建 + commands/ai.rs + lib.rs/ 前端api/types.ts + api/knowledge.ts 新建 + api/index.ts + stores/knowledge.ts + views/Knowledge.vue + views/Settings.vue + App.vue。约 1,400 行 Rust + 700 行 TS。
**验证**cargo check 0 error ✓vue-tsc 0 error ✓。**未 tauri dev 运行时实测**
**遗留问题 / 下一步**
1. tauri dev 实测 10 项功能验证清单(手动录入 / 审核流 / 归档 / 检索注入 / reuse_count / AI 提炼 OnComplete / AI 提炼 Manual / 配置修改 / 注入开关 / 矛盾场景)
2. 向量检索待实测(需配 embedding provider + 开启开关 + 发布 ≥1 条知识后验证混合排序)
3. Tier 2 待做ai_node prompt 注入 / 工作流 NodeFailed→pitfall / 决策记录→architecture_pattern前置 traceability 持久化)
4. 知识库 API 对外暴露MCP Shell 封装,外部工具 Claude Code/CodeX/Cursor 使用)
### [Sprint 16] 2026-06-13 — AI Chat LLM 优化收尾(Part C/D + Review 修复) + AI Node 单测
**工作内容**
- **Part C LLM 并发控制**`state.rs` 新增 `LlmConcurrency`(双层 `Arc<Mutex<Arc<Semaphore>>>` — 全局默认 3 / 单对话默认 2,运行时替换内层 Arc 重建);`ai.rs` 3 个叶子 LLM 调用点(stream_llm / generate_title / extract_knowledge)加 permit,acquire 顺序固定 global→per_conv 防死锁,stream 后显式 `drop` 不占工具执行槽;新增 `ai_set_concurrency_config` command(运行时调并发上限);`lib.rs` 注册
- **Part D Settings UI**`Settings.vue` 通用设置区加「全局最大并发 / 单对话并发」两 setting-row + 前端 clamp(1-10 / 1-5)+ debounce 300ms + localStorage 持久化 + onMounted 启动同步 + onUnmounted 清 timer
- **Review 两轮 8 修复**:首轮 6(① per_conv 单例语义注释 / ② save-extract 并发注释改正 / ③ permit 显式 drop / ④ Settings clamp / ⑤ 合并 to_chat_messages 进 all_messages_clone / ⑥ system prompt 超 budget warn)+ 复审 2(后端 set_concurrency 补 clamp 边界对齐前端 / onUnmounted 清 debounce timer)
- **AI Node 单测**`ai_node.rs` 重构抽 `parse_params` 纯函数(execute 参数解析剥离,单测不 mock LLM 零网络)+ 7 单测(缺参×3 / prompt 上游优先回退 / config 取值 / 可选默认值 / 显式参数解析)
- **任务收尾**#43/#44 → completed(代码 + 单测全绿,GUI 实测转跟踪);新增 #54(GUI 动态实测跟踪任务)
**代码变更**
- `crates/df-ai/src/context.rs`(Review ⑤⑥ — 合并 to_chat_messages / system prompt 超 budget warn)
- `src-tauri/src/state.rs`(LlmConcurrency 双层 Semaphore + per_conv 单例语义注释)
- `src-tauri/src/commands/ai.rs`(permit×3 + drop / process_tool_calls join_all 并行 / save 后台 spawn / ai_set_concurrency_config command + clamp)
- `src-tauri/src/lib.rs`(注册 ai_set_concurrency_config)
- `src/views/Settings.vue`(并发 UI + clamp + debounce + onUnmounted)
- `crates/df-nodes/src/ai_node.rs`(parse_params 重构 + 7 单测)
- `docs/02-架构设计/功能决策记录.md`(新增「AI Node」域记 parse_params 决策 + per_conv 退化条目)
**验证**cargo check 0 error ✓ / df-ai context 5 单测 ✓ / df-nodes ai_node 7 单测 ✓ / vue-tsc 0 error ✓。**未 tauri dev 运行时实测**(转 #54 跟踪)
**遗留问题 / 下一步**
1. **#54 GUI 动态实测**(用户跑 tauri dev,Claude 核对日志):长对话裁剪(context_trimmed 日志 + DB 全量)/ 多工具并行(Low join_all 时序)/ save 异步不阻塞 Completed / 双层 Semaphore 限流(多对话撞 RPM)/ Settings 并发即时生效 + 刷新保持 / DAG ai 节点真调 LLM
2. ~~AI Node 可补 GLM 集成测试~~ ✅ 已补并通过:`glm_live_complete`(`#[ignore]` + env var)真调 GLM glm-4-flash 返回"通过",usage 解析正确(provider 调用层验证;GUI DAG 触发仍属 #54)
### [Sprint 17] 2026-06-13 — 模块文档↔代码一致性审查(4 文档纠错补全)
**工作内容**:
- **审查范围**:docs/03 模块文档(df-ai / df-storage / df-workflow)+ docs/01 技术文档(SQLite-CRUD 模式)共 4 份 vs 对应 crate 代码,三代理并行审查
- **🔴 硬伤 28 全修**:df-ai(LlmProvider trait 签名 / 删 OpenAI 虚构 idle-timeout + finished_received / ContextManager 方法名 replace_tool_result_content + build_for_request 返回元组 / Token 公式 / Anthropic usage take() / 工具归属)/ df-storage(V1 表名 / Repo 表名 releases·workflow_executions / search published 过滤 / increment_reuse_count updated_at / cosine epsilon / blob 截断)/ SQLite-CRUD 整篇过时(表 6→11 / CRUD 待→已 / 时间 INTEGER→TEXT / 写返回 ()→String·bool / 连接池→单连接 / schema.rs→migrations.rs)/ df-workflow(Node trait 按值 + 默认 false + node_type / 文件名 conditions·eventbus / 虚构事件区分已发未发)
- **⚪ 虚构 7 全清**:df-workflow(阻塞节点「等待人工操作」标未实现 / 持久化快照删改仅内存态 / 断点续跑补无暂停恢复机制)/ df-ai(conditions.rs 不存在,清状态表 + 决策缺口表虚构引用 / AiToolRegistry「12 工具」夸大改基础设施)
- **🟡 核心 8 + 碎琐 10 补全**:df-ai(apply_openai_sse / apply_anthropic_event 纯函数 / ContextManager 6 pub 方法 / Anthropic 事件全集 / 文件结构 router·stream / URL 三分支 / finish=length / max_tokens 4096 / PROTECT_COUNT / budget 公式)/ df-workflow(NodeRegistry / DagDef / StateMachine / Dag API / NodeContext·NodeOutput 字段 / EventBus API / 条件引擎默认放行 warn)/ df-storage(list_non_archived / set_archived 例外对照)
**代码变更**:无(纯文档纠错,代码未动)
**验证**:每项修正均经代理 Read 代码复核签名/SQL/行为后改;git diff 4 文档落地(df-ai 443 / df-storage 134 / SQLite-CRUD 56 / df-workflow 122 行变动)
**遗留问题 / 下一步**:
1. 4 文档已与代码实质一致;后续若改 df-ai/df-storage/df-workflow 公开接口,需同步对应模块文档(文档治理:模块文档随代码变)
2. df-evolve(knowledge.rs / pattern.rs)本轮未审 — docs/03 无对应模块文档,若补可另起审查
### [Sprint 18] 2026-06-13 — 知识生命线(详情页 + 溯源 + 引用 + 生命周期)
**工作内容**:
- **根因**:知识库 candidate 卡片点不开详情、不能编辑、看不到"为什么产生";reasoning 字段被 prompt 要求但写库丢弃(bug);increment_reuse_count 只 +1 不记去向;无状态变更历史。用户要求呈现知识完整生命周期:产生→审核→引用→归档,且约束异步不阻塞、代码简洁易扩展。
**A. 存储层(df-storage,V10 迁移)**:
- V10:幂等补 `knowledges.reasoning TEXT` 列 + 新建 `knowledge_events` 审计表(id/knowledge_id/event_type/source_ref/context_json/timestamp + 3 索引)
- `KnowledgeRecord``reasoning: Option<String>`;新增 `KnowledgeEventRecord`(6 字段)
- `KnowledgeEventsRepo`(impl_repo! 宏 7 方法 + 扩展 `list_by_knowledge`/`list_by_knowledge_type`);5 处显式 SELECT 列列举 + from_row + insert/update body 全线接 reasoning
**B. 后端埋点(src-tauri)**:
- **新建 `commands/knowledge_timeline.rs`** — `KnowledgeTimeline` 独立记录器(持 Arc<Database>),便捷方法 record_extracted/referenced/status_change/created,内部 `fire()` 吞错误 fire-and-forget。各业务流程只调一行,不内联写 SQL(代码简洁约束)
- `extract_knowledge_from_conversation`:回填 reasoning(修 bug)+ 查对话标题 + insert 后记 extracted 事件
- `build_knowledge_context`:加 conv_id 参数 + spawn 块内 increment_reuse_count 后记 referenced 事件(conv_id+query);调用点上移 conv_id 快照
- `knowledge_create/update_status`:记 created/status_changed 事件(归档复用 status_changed,删 dead code EVENT_ARCHIVED/record_archived)
- 3 新 IPC command:`knowledge_get_detail`(聚合 knowledge+events)/`knowledge_update`(部分更新编辑)/`knowledge_events`(查生命线);AppState 加 `knowledge_events` Repo;lib.rs 注册
**C. 前端**:
- types.ts:KnowledgeRecord 加 reasoning + 新增 KnowledgeEventRecord/KnowledgeDetailPayload/UpdateKnowledgeInput
- api/store:各加 getDetail/update/getEvents
- **Knowledge.vue 全面重构**:Ideas 式左右分栏(左卡片列表 @click 选中 / 右详情四分区 — ①基本信息+编辑 ②溯源(产生方式/来源对话/AI reasoning) ③引用记录(复用次数+最近 N 条) ④生命周期时间线)。可编辑 title/content/tags/confidence
**关键决策**(详见 docs/02-架构设计/功能决策记录.md):① 独立 events 表非 JSON 嵌主表(引用数百次防行膨胀);② 独立 KnowledgeTimeline 记录器(不散落埋点);③ 归档复用 status_changed(防冗余);④ reasoning 回填(修 prompt 要求但丢弃的 bug)
**代码变更**:df-storage(migrations/models/crud)+ src-tauri(state/knowledge_timeline 新建/ai/knowledge/lib/mod)+ 前端(types/api/knowledge.ts/stores/knowledge.ts/Knowledge.vue)= 12 文件
**验证**:cargo check --workspace ✓(仅既有警告)/ vue-tsc --noEmit ✓ 零错误 / cargo test -p df-storage ✓ 21 单测全过。**未 tauri dev 运行时实测**
**遗留问题 / 下一步**:
1. **#54 GUI 实测**:AI 对话结束→提炼→收件箱→点详情看 reasoning+来源;发布→生命线 status_changed;新对话注入→referenced 事件;编辑 candidate→保存
2. 老库历史 candidate 无 reasoning/events,详情页降级展示(手动录入/无溯源)——属预期兼容
3. knowledge_events 纯追加表,长期可清理 >90 天 referenced 事件(本次不做)
### [Sprint 19] 2026-06-14 — list_directory 防爆 + localStorage→SQLite 统一持久化 + token/发送 bug 修复
**工作内容**:用户报多个 AiChat bug + 提需求:list_directory 扫出 13782 项塞爆对话;token 显示"0 in";发送卡住/留框;LLM 并发上限太小;localStorage 散落要统一入 SQLite。同时用户并行主导 ai.rs→ai/ + ai.ts→composables 模块拆分(我观察配合,不接管)。
**A. list_directory 递归防爆(Part A)**:
- `list_dir_recursive`(ai/tool_registry.rs)加噪音目录剪枝(`.git`/`node_modules`/`target`/`dist`/`build`/`.next`/`.cache`/`__pycache__`/`.venv`/`venv`/`.idea`)——**列出但不深入内部**;硬上限 1000 条 + `truncated` 标志;默认 max_depth 3→2;加 `skip_noise_dirs` 参数(默认 true,AI 看编译产物 dist/target 时传 false)
- 根因:AI 广扫项目根传 `recursive:true`,噪音目录铺平致 13782 项塞进对话 message(UI 卡 + token 爆)
**B. localStorage → SQLite 统一持久化(Part B)**:
- 新建 `app_settings` KV 表(V13 迁移)+ 手写 `SettingsRepo`(get/set/get_all/delete,不走 `impl_repo!` 宏因 KV 无固定 schema);4 IPC(settings_get/set/get_all/delete);AppState 加 settings 字段
- 前端 `appSettings` store(reactive 缓存 + 300ms debounce + `useSetting` ref)+ api 封装
- **11 key 迁移**:敏感 `df-connections` + UI 偏好(theme/language/ai-width/ai-ui/token/concurrency)+ `df-ai-active-conv`;**例外 `df-ai-gen`/`df-ai-text`**(流式临时快照,高频写留 localStorage)。App.vue onMounted 一次性迁移旧 localStorage
**C. Bug 修复**:
- **token prompt=0**:GLM 流式 usage 带 completion 不带 prompt → 加估算兜底(provider 返回 0 时按请求消息 `TokenEstimator::estimate_message` 求和)
- **发送卡住/留框**:`sendMessage` IPC 失败时无 catch → streaming 永真(光标卡死)+ handleSend 回填输入(消息已显示);改 try/catch 回滚 streaming + 清空气泡占位
- **LLM 并发上限**:三层(模板 max / JS Math.min / 后端 .clamp)全去上限,保留 min=1 + `per-conv ≤ global` 约束
**D. token 暴增诊断 + 清理**:
- 查 SQLite 实数据:对话 e4365d47 累积 in=115万 / out=2万 / 消息体 1.6MB
- 根因:**单条 list_directory 工具结果 1.2MB**(13782 项,Part A 修复前产生)落库后每轮重发 + save 累加 → 暴增;token 估算准,膨胀是真(非估算锅)
- 删除污染对话(`ai_conversations` 1 行 + `ai_tool_executions` 123 行)
**E. 模块拆分(用户主导,本会话发生,我配合不接管)**:
- `ai.rs`(2651 行)→ `ai/` 11 子模块(agentic/audit/commands/conversation/knowledge_inject/mod/prompt/skills/stream_recv/title/tool_registry),旧 ai.rs 删
- `stores/ai.ts`(749 行)→ 薄壳 + `composables/ai/*`(events/stream/send/conversations/window/panel)
- `df-evolve` crate 移除(workspace/代码零引用)
- 踩坑(glob 重导出命令宏符号 / super 路径失效 / 0 字节残留触发 E0761)已记入经验记录.md
**关键决策**(详见功能决策记录 / 经验记录):① list_directory 剪枝 + skip_noise 保留访问能力(定点查/强制递归两路通);② KV 表统一持久化复用 SettingsRepo(兑现经验记录预言);③ token 估算兜底(provider 不报时按消息估);④ 发送失败回滚 streaming;⑤ 并发去上限保 per-conv≤global
**代码变更**:src-tauri(`commands/ai/` 新模块 + `commands/settings.rs` + state.rs + lib.rs)+ crates/df-storage(migrations V13 + crud SettingsRepo)+ 前端(api/settings.ts + stores/appSettings.ts + App.vue/Settings.vue/composables/ai/* + i18n)≈ 25 文件
**验证**:cargo check ✓ / vue-tsc ✓ 零错 / tauri dev 启动 ✓ 运行;token/发送/并发/list_directory 已实测
**遗留 / 下一步**:
1. **Fix 1(待用户最终定)**:工具结果入库前截断 50KB(堵 `read_file` 1MB 洞 + 防御);Fix 2(放宽旧工具结果裁剪)因 Part A 已杀主源,延后不做
2. Settings.vue(1037 行)拆 panel 子组件——早前建议,用户已做 ai.rs,此件待评估
3. 诊断日志清理(handleEvent/approveToolCall 早期调试 console.log 残留)
### [Sprint 20] 2026-06-14 — 项目管理模块代码审查 + 全局核对3-agent review 验证)
**工作内容**: 应用户要求审查「项目管理 / 项目导入 / 生命周期可用性」(后端 commands/project.rs + ai/tool_registry.rs + df-project/scan.rs + 前端 Projects.vue / ProjectDetail.vue。先 3-agent 并行 review 出 15 项发现,剔幻觉 2 项read_readme 命名误判 / conflict.name null 过度防御)后剩 13 项;再应要求**全局核对**grep 全仓库 6 维度)确认每项发现是孤例还是系统性,保证全局成立有效。**本次仅审查 + 核对,未改代码。**
**验证结论**: 13/13 发现**事实全部成立**无幻觉。4 处行号偏差(① 249→263 / ⑤ 113→136 / ⑫ 130→145 / ③ 未标→2712 处范围需补(⑧ 实 8 处非 3 处 / ③ 漏 relocate:212
**🔴 必须修复3**:
1. **AI delete_project 硬删绕过软删**tool_registry.rs:263 `repo.delete` 物理 DELETE vs IPC project.rs:120 `soft_delete` 进回收站)—— 同一操作两种后果AI 对话删 = 数据丢失不可恢复UI 删 = 可恢复。**数据安全缺口**
2. **AI 缺回收站生命周期工具**restore/purge/list_deleted 全无)—— ProjectRepo 有完整方法crud.rs:572/591/610/634+ IPC 全注册project.rs:125-145AI 层零覆盖,割裂工作流
3. **collect_sample 同步 IO 阻塞 async**project.rs:271 在 async fn 内直接同步调 df-project::scan—— 对比 scan_project_stack:182用了 spawn_blocking不一致
**🟡 建议改进6**: ④ normalize_path 两份重复project.rs:154 + tool_registry.rs:180/ ⑤ update 白名单双份不同步tool_registry:136 硬编码 vs crud.rs:287 allowed_columnsidea_id 字段不同步)/ ⑥ AI 扫描 + 目录状态文案硬编码未走 $tProjects.vue:33/195 + ProjectDetail.vue:93/ ⑦ parseStack 两份重复Projects.vue:143 + ProjectDetail.vue:321/ ⑧ 原生 alert/confirm全局 8 处非仅 ProjectDetail 3 处Ideas.vue:378/391 同病)/ ⑨ collect_sample 无文件大小预检scan.rs:151/205 read 全文后截断)
**⚪ 可选3**: ⑩ LLM 无超时project.rs:297 占 llm_concurrency permit 无限 await/ ⑪ JSON 提取取首尾花括号project.rs:368-369 多 JSON 场景错)/ ⑫ create_project 无 path 参数tool_registry:145 需 create+bind 两步)
**全局核对关键定性**6 维度 grep 全仓库):
- **① 是 project 特例但最该修**: soft_delete/回收站机制**全仓库仅 project 一张表有**task/idea/branch/release/workflow 本就物理删,无语义不一致);正因 project 唯一设计回收站AI 硬删直接击穿其数据安全设计
- **⑧ 系统性**: 全仓库 8 处原生弹窗ProjectDetail 4 + Projects 2 + Ideas 2非局部
- **③ 范围补报**: 不只 :271relocate_project_path:212 同步 detect_stack 同样阻塞3 处未包 spawn_blocking vs 1 处包了
- **src-tauri 层自身干净**: `std::fs` 同步调用零匹配,阻塞全在跨 crate 调同步的 df-project::scan
- **④⑤⑦ 精准**: 全仓库确为两份/一处无更多副本validate_path/resolve_workspace_path 是安全校验,职责不同非 normalize 重复
**未改代码**(待用户定推进批次)
**遗留 / 下一步(修复批次建议)**:
- **批1 数据安全**: ①AI delete 改 soft_delete+ ②(补 restore/purge/list_trash 三工具,复用 ProjectRepo 对应方法)
- **批2 阻塞 + 去重**: ③(:270/:271/:212 三处统一 spawn_blocking+ ④(抽公共 normalize_path 到 df-project/df-core+ ⑤tool_registry 复用 allowed_columns_for
- **批3 前端**: ⑥(补 i18n key+ ⑦(抽 src/utils/project.ts+ ⑧(全局 8 处 alert/confirm 换 Arco Modal
- ⚪ 可选: ⑨⑩⑪⑫ 按需
---
### [设计] 2026-06-14 — B-03 人工审批响应机制设计
**工作内容**df-workflow HumanNode 审批响应机制设计B-260614-03。勘察确认链路基础设施 90% 已通EventBus broadcast / `HumanApprovalResponse` 事件 / `approve_human_approval` IPC / 前端 store 已接通),唯一缺口为 `human_node.rs:55` 发完请求直接 return "同意"。设计:订阅时序铁律(`subscribe()` 先于 `send(Request)`+ `tokio::select!` 循环(响应 / 超时 / 取消)+ execution_id+node_id 双键过滤 + Lagged 容忍。澄清 B-06/B-07 前置真实影响B-06=并发隔离非单流功能前置B-07=取消必要非充分),拆 **B-03a**(响应等待 + 超时)/ **B-03b**(取消机制:`set_cancelled` + cancel IPC + 前端按钮)。
**产出**:完整设计文档 [B-03-人工审批响应机制.md](./docs/02-架构设计/B-03-人工审批响应机制.md)功能决策记录加「工作流人工审批节点B-03」摘要章节。
**下一步**B-06execution_id 下沉)/ B-07共享 StateMachine前置修复 → 落 B-03a 实现 → B-03b 取消机制补全。
<!-- <!--
### [Sprint N] YYYY-MM-DD — 简述 ### [Sprint N] YYYY-MM-DD — 简述

View File

@@ -96,6 +96,19 @@ impl AiToolRegistry {
self.tools.get(name) self.tools.get(name)
} }
/// 执行指定工具 — handler 是唯一执行路径schema+risk+实现同源,消除双轨)
pub async fn execute(
&self,
name: &str,
args: serde_json::Value,
) -> anyhow::Result<serde_json::Value> {
let tool = self
.tools
.get(name)
.ok_or_else(|| anyhow::anyhow!("未知工具: {}", name))?;
(tool.handler)(args).await
}
/// 获取所有已注册工具名称 /// 获取所有已注册工具名称
pub fn tool_names(&self) -> Vec<String> { pub fn tool_names(&self) -> Vec<String> {
self.tools.keys().cloned().collect() self.tools.keys().cloned().collect()

View File

@@ -8,7 +8,7 @@
use async_trait::async_trait; use async_trait::async_trait;
use eventsource_stream::Eventsource; use eventsource_stream::Eventsource;
use futures::{Stream, StreamExt}; use futures::StreamExt;
use reqwest::Client; use reqwest::Client;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tracing::{debug, error, warn}; use tracing::{debug, error, warn};
@@ -79,6 +79,126 @@ struct AnthropicUsage {
output_tokens: u32, output_tokens: u32,
} }
// ============================================================
// SSE 解析纯函数(与 HTTP 解耦,便于单测)
// ============================================================
/// 将一条 Anthropic Messages SSE 事件 data 解析为 StreamChunk并按需更新 usage 累加器。
///
/// 按 `type` 字段分发:
/// - `message_start` → 用 `message.usage.input_tokens` 初始化累加器output 置 0
/// - `message_delta` → **output_tokens 是累计值(非增量)**,直接覆盖 `completion_tokens` 并重算 `total`。
/// - `content_block_delta` (text_delta/input_json_delta) → 文本/工具入参增量。
/// - `content_block_start` (tool_use) → 工具块开始,带 id+name。
/// - `message_stop` → 返回 `finished=true` 终态 chunk`usage` 取自累加器(`take()`)。
/// - `error` → 返回 `finished=true` 终态空 chunk。
/// - 其它content_block_stop / ping 等)→ 空 chunk。
///
/// 等价性content_block / message_stop / error 等事件分支与原 stream() 闭包逐字一致;
/// usage 透传message_stop 终态 take() 带出、message_delta 的 output_tokens 按累计值覆盖)
/// 为本次新增能力,对应 StreamChunk 新增的 usage 字段。
pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUsage>) -> StreamChunk {
// 解析 data 中的 JSON按 type 字段决定如何转 StreamChunk
let v: serde_json::Value = match serde_json::from_str(data) {
Ok(v) => v,
Err(_) => {
return StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None }
}
};
let ty = v.get("type").and_then(|t| t.as_str()).unwrap_or("");
match ty {
// 消息开始:取 input_tokens 初始化累积器output 此时未知,置 0
"message_start" => {
if let Some(inp) = v
.get("message")
.and_then(|m| m.get("usage"))
.and_then(|u| u.get("input_tokens"))
.and_then(|t| t.as_u64())
{
*usage_accum = Some(TokenUsage {
prompt_tokens: inp as u32,
completion_tokens: 0,
total_tokens: inp as u32,
});
}
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None }
}
// 消息增量output_tokens 是累计值(非增量),直接覆盖 completion + 重算 total
"message_delta" => {
if let Some(out) = v.get("usage").and_then(|u| u.get("output_tokens")).and_then(|t| t.as_u64()) {
let acc = usage_accum
.get_or_insert(TokenUsage { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 });
acc.completion_tokens = out as u32;
acc.total_tokens = acc.prompt_tokens + acc.completion_tokens;
}
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None }
}
// 文本增量
"content_block_delta" => {
if let Some(delta) = v.get("delta") {
if delta.get("type").and_then(|t| t.as_str()) == Some("text_delta") {
let text = delta.get("text").and_then(|t| t.as_str()).unwrap_or("").to_string();
return StreamChunk { delta: text, finished: false, tool_calls: None, usage: None };
}
// 工具入参增量
if delta.get("type").and_then(|t| t.as_str()) == Some("input_json_delta") {
let partial = delta.get("partial_json").and_then(|t| t.as_str()).unwrap_or("").to_string();
let idx = v.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as u32;
return StreamChunk {
delta: String::new(),
finished: false,
tool_calls: Some(vec![ToolCallDelta {
index: idx,
id: None,
function_name: None,
function_arguments: Some(partial),
}]),
usage: None,
};
}
}
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None }
}
// 工具块开始:带 id + name
"content_block_start" => {
if let Some(cb) = v.get("content_block") {
if cb.get("type").and_then(|t| t.as_str()) == Some("tool_use") {
let idx = v.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as u32;
let id = cb.get("id").and_then(|t| t.as_str()).map(|s| s.to_string());
let name = cb.get("name").and_then(|t| t.as_str()).map(|s| s.to_string());
return StreamChunk {
delta: String::new(),
finished: false,
tool_calls: Some(vec![ToolCallDelta {
index: idx,
id,
function_name: name,
function_arguments: None,
}]),
usage: None,
};
}
}
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None }
}
// 消息结束:带出累积 usage
"message_stop" => StreamChunk {
delta: String::new(),
finished: true,
tool_calls: None,
usage: usage_accum.take(),
},
// 错误事件
"error" => {
let msg = v.get("error").and_then(|e| e.get("message")).and_then(|m| m.as_str()).unwrap_or("stream error");
error!(%msg, "Anthropic 流式错误事件");
StreamChunk { delta: String::new(), finished: true, tool_calls: None, usage: None }
}
// content_block_stop / ping 等不产出 chunk
_ => StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None },
}
}
// ============================================================ // ============================================================
// Provider 实现 // Provider 实现
// ============================================================ // ============================================================
@@ -332,77 +452,15 @@ impl LlmProvider for AnthropicCompatProvider {
anyhow::bail!("Anthropic 流式 API 错误 {}: {}", status, text); anyhow::bail!("Anthropic 流式 API 错误 {}: {}", status, text);
} }
// 流式解析eventsource 逐事件处理,按 type 字段分发转 StreamChunk // 流式解析eventsource 逐事件处理,按 type 字段分发转 StreamChunk
// 事件解析/usage 累积逻辑抽到 apply_anthropic_event 纯函数,便于单测;此处闭包只负责传 data。
// usage 累积message_start 给 input_tokensmessage_delta 给累计 output_tokens非增量message_stop 带出。
let mut usage_accum: Option<TokenUsage> = None;
let stream = resp let stream = resp
.bytes_stream() .bytes_stream()
.eventsource() .eventsource()
.map(move |event| match event { .map(move |event| match event {
Ok(ev) => { Ok(ev) => Ok(apply_anthropic_event(&ev.data, &mut usage_accum)),
// 解析 data 中的 JSON按 type 字段决定如何转 StreamChunk
let v: serde_json::Value = match serde_json::from_str(&ev.data) {
Ok(v) => v,
Err(_) => return Ok(StreamChunk { delta: String::new(), finished: false, tool_calls: None }),
};
let ty = v.get("type").and_then(|t| t.as_str()).unwrap_or("");
match ty {
// 文本增量
"content_block_delta" => {
if let Some(delta) = v.get("delta") {
if delta.get("type").and_then(|t| t.as_str()) == Some("text_delta") {
let text = delta.get("text").and_then(|t| t.as_str()).unwrap_or("").to_string();
return Ok(StreamChunk { delta: text, finished: false, tool_calls: None });
}
// 工具入参增量
if delta.get("type").and_then(|t| t.as_str()) == Some("input_json_delta") {
let partial = delta.get("partial_json").and_then(|t| t.as_str()).unwrap_or("").to_string();
let idx = v.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as u32;
return Ok(StreamChunk {
delta: String::new(),
finished: false,
tool_calls: Some(vec![ToolCallDelta {
index: idx,
id: None,
function_name: None,
function_arguments: Some(partial),
}]),
});
}
}
Ok(StreamChunk { delta: String::new(), finished: false, tool_calls: None })
}
// 工具块开始:带 id + name
"content_block_start" => {
if let Some(cb) = v.get("content_block") {
if cb.get("type").and_then(|t| t.as_str()) == Some("tool_use") {
let idx = v.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as u32;
let id = cb.get("id").and_then(|t| t.as_str()).map(|s| s.to_string());
let name = cb.get("name").and_then(|t| t.as_str()).map(|s| s.to_string());
return Ok(StreamChunk {
delta: String::new(),
finished: false,
tool_calls: Some(vec![ToolCallDelta {
index: idx,
id,
function_name: name,
function_arguments: None,
}]),
});
}
}
Ok(StreamChunk { delta: String::new(), finished: false, tool_calls: None })
}
// 消息结束
"message_stop" => Ok(StreamChunk { delta: String::new(), finished: true, tool_calls: None }),
// 错误事件
"error" => {
let msg = v.get("error").and_then(|e| e.get("message")).and_then(|m| m.as_str()).unwrap_or("stream error");
error!(%msg, "Anthropic 流式错误事件");
Ok(StreamChunk { delta: String::new(), finished: true, tool_calls: None })
}
// message_start / content_block_stop / message_delta 等不产出 chunk
_ => Ok(StreamChunk { delta: String::new(), finished: false, tool_calls: None }),
}
}
Err(e) => { Err(e) => {
error!(error = %e, "Anthropic SSE 事件流错误"); error!(error = %e, "Anthropic SSE 事件流错误");
Err(anyhow::anyhow!("Anthropic SSE 错误: {}", e)) Err(anyhow::anyhow!("Anthropic SSE 错误: {}", e))
@@ -424,3 +482,184 @@ impl LlmProvider for AnthropicCompatProvider {
} }
} }
} }
// ============================================================
// 单测(不发真实 HTTP喂构造的 SSE data 字符串序列)
// ============================================================
#[cfg(test)]
mod tests {
use super::*;
/// 辅助:构造 message_start 事件
fn message_start(input_tokens: u32) -> String {
format!(
r#"{{"type":"message_start","message":{{"usage":{{"input_tokens":{},"output_tokens":0}}}}}}"#,
input_tokens
)
}
/// 辅助:构造 message_delta 事件output_tokens 为累计值)
fn message_delta(output_tokens: u32) -> String {
format!(
r#"{{"type":"message_delta","delta":{{"stop_reason":"end_turn"}},"usage":{{"output_tokens":{}}}}}"#,
output_tokens
)
}
/// 辅助:构造文本增量 content_block_delta
fn text_delta(text: &str) -> String {
format!(
r#"{{"type":"content_block_delta","index":0,"delta":{{"type":"text_delta","text":"{}"}}}}"#,
text
)
}
/// 辅助:构造 message_stop 事件
fn message_stop() -> &'static str {
r#"{"type":"message_stop"}"#
}
/// 完整流message_start 初始化 input + 多次 message_delta 累计覆盖 output + message_stop 带出
#[test]
fn anthropic_full_stream_accumulates_usage() {
let mut acc: Option<TokenUsage> = None;
// 1) message_startinput=42output=0
let c = apply_anthropic_event(&message_start(42), &mut acc);
assert!(!c.finished);
assert!(c.usage.is_none());
let a = acc.as_ref().expect("message_start 应初始化累加器");
assert_eq!(a.prompt_tokens, 42);
assert_eq!(a.completion_tokens, 0);
assert_eq!(a.total_tokens, 42);
// 2) 文本增量不影响 usage
let c = apply_anthropic_event(&text_delta("Hello"), &mut acc);
assert_eq!(c.delta, "Hello");
assert!(!c.finished);
assert_eq!(acc.as_ref().unwrap().completion_tokens, 0, "文本增量不应改 output");
// 3) message_deltaoutput_tokens=10累计值覆盖
let c = apply_anthropic_event(&message_delta(10), &mut acc);
assert!(!c.finished);
let a = acc.as_ref().unwrap();
assert_eq!(a.prompt_tokens, 42, "input 保持");
assert_eq!(a.completion_tokens, 10, "output 被覆盖为累计值");
assert_eq!(a.total_tokens, 52, "total 重算 = input+output");
// 4) 再次 message_deltaoutput_tokens=30更大累计值再覆盖
let _ = apply_anthropic_event(&message_delta(30), &mut acc);
let a = acc.as_ref().unwrap();
assert_eq!(a.completion_tokens, 30, "后续累计值覆盖前值");
assert_eq!(a.total_tokens, 72);
// 5) message_stop带出累积 usagefinished=true累加器清空
let c = apply_anthropic_event(message_stop(), &mut acc);
assert!(c.finished);
let u = c.usage.expect("message_stop 应带出累积 usage");
assert_eq!(u.prompt_tokens, 42);
assert_eq!(u.completion_tokens, 30);
assert_eq!(u.total_tokens, 72);
assert!(acc.is_none(), "take() 后累加器应清空");
}
/// message_delta 在没有 message_start 时也能补全累加器get_or_insert 兜底)
#[test]
fn anthropic_message_delta_without_start_uses_default_input() {
let mut acc: Option<TokenUsage> = None;
let _ = apply_anthropic_event(&message_delta(15), &mut acc);
let a = acc.as_ref().unwrap();
assert_eq!(a.prompt_tokens, 0, "无 message_start 时 input 兜底为 0");
assert_eq!(a.completion_tokens, 15);
assert_eq!(a.total_tokens, 15);
}
/// message_delta 的 output_tokens 必须是累计覆盖(非累加):连续两个 delta 5 和 8结果应是 8 不是 13
#[test]
fn anthropic_message_delta_output_is_cumulative_not_incremental() {
let mut acc: Option<TokenUsage> = None;
apply_anthropic_event(&message_start(100), &mut acc);
apply_anthropic_event(&message_delta(5), &mut acc);
apply_anthropic_event(&message_delta(8), &mut acc);
let c = apply_anthropic_event(message_stop(), &mut acc);
let u = c.usage.unwrap();
assert_eq!(u.completion_tokens, 8, "output_tokens 是累计值,覆盖而非累加");
assert_eq!(u.total_tokens, 108);
}
/// 无 usage 字段的流message_stop 时 usage 为 None
#[test]
fn anthropic_message_stop_without_any_usage() {
let mut acc: Option<TokenUsage> = None;
let _ = apply_anthropic_event(&text_delta("hi"), &mut acc);
assert!(acc.is_none(), "文本增量不初始化累加器");
let c = apply_anthropic_event(message_stop(), &mut acc);
assert!(c.finished);
assert!(c.usage.is_none(), "无 usage 时 message_stop usage 为 None");
}
/// content_block_start (tool_use) 带 id+name
#[test]
fn anthropic_content_block_start_tool_use() {
let mut acc: Option<TokenUsage> = None;
let data = r#"{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"tool_1","name":"get_weather"}}"#;
let c = apply_anthropic_event(data, &mut acc);
assert!(acc.is_none(), "content_block_start 不动 usage");
let tcs = c.tool_calls.expect("应有 tool_calls");
assert_eq!(tcs.len(), 1);
assert_eq!(tcs[0].index, 1);
assert_eq!(tcs[0].id.as_deref(), Some("tool_1"));
assert_eq!(tcs[0].function_name.as_deref(), Some("get_weather"));
assert!(tcs[0].function_arguments.is_none());
assert!(!c.finished);
}
/// content_block_delta (input_json_delta) → 工具入参增量
#[test]
fn anthropic_content_block_delta_input_json() {
let mut acc: Option<TokenUsage> = None;
let data = r#"{"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"{\"q\":"}}"#;
let c = apply_anthropic_event(data, &mut acc);
let tcs = c.tool_calls.expect("应有 tool_calls 增量");
assert_eq!(tcs[0].index, 2);
assert_eq!(tcs[0].function_arguments.as_deref(), Some("{\"q\":"));
assert!(tcs[0].id.is_none());
assert_eq!(c.delta, "");
assert!(!c.finished);
}
/// error 事件 → finished=true 终态空 chunk
#[test]
fn anthropic_error_event_finishes_stream() {
let mut acc: Option<TokenUsage> = None;
apply_anthropic_event(&message_start(10), &mut acc);
let c = apply_anthropic_event(r#"{"type":"error","error":{"message":"overloaded"}}"#, &mut acc);
assert!(c.finished, "error 应终止流");
assert!(c.usage.is_none(), "error 不带出 usage");
assert!(acc.is_some(), "error 不应清空已累积的 usage与原实现一致");
}
/// ping / content_block_stop 等事件 → 空且不 finished
#[test]
fn anthropic_ping_and_block_stop_yield_empty_chunk() {
let mut acc: Option<TokenUsage> = None;
let c = apply_anthropic_event(r#"{"type":"ping"}"#, &mut acc);
assert!(!c.finished);
assert_eq!(c.delta, "");
assert!(acc.is_none());
let c = apply_anthropic_event(r#"{"type":"content_block_stop","index":0}"#, &mut acc);
assert!(!c.finished);
assert_eq!(c.delta, "");
}
/// 非法 JSON → 空 chunk不 panic
#[test]
fn anthropic_malformed_json_yields_empty_chunk() {
let mut acc: Option<TokenUsage> = None;
let c = apply_anthropic_event("not json", &mut acc);
assert!(!c.finished);
assert_eq!(c.delta, "");
assert!(acc.is_none());
}
}

View File

@@ -1,59 +1,511 @@
//! 上下文管理器 — 管理对话上下文和 token 预算 //! 上下文管理器 — 管理对话上下文和 token 预算
//!
use std::collections::VecDeque; //! 职责:
//! - 维护消息历史及其 token 计数缓存
//! - 提供预算感知的消息裁剪(保护工具调用三元组)
//! - 为 run_agentic_loop 提供受控的消息视图
//!
//! 裁剪策略与模型选择是正交维度:本模块只管「窗口多大、怎么裁」,
//! 用哪个 model / 是否启用 reasoning 由调用方在 CompletionRequest 层决定。
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::provider::ChatMessage; use crate::provider::{ChatMessage, MessageRole};
// ============================================================
// Token 估算器(零依赖粗估)
// ============================================================
/// Token 粗估器 — 字符级近似计数,无 tokenizer 依赖
///
/// 用于发送前预算控制,误差 ±15% 完全可接受(保守估计,宁可多算)。
#[derive(Debug, Clone)]
pub struct TokenEstimator {
/// 字符 → token 转换系数(默认 0.35,即 ~2.8 字符/token中英混合偏保守
pub chars_ratio: f32,
/// 每条消息固定开销role 标记 + 格式)
pub per_message_overhead: u32,
/// 每个 tool_call 的额外开销name + arguments JSON 结构)
pub per_tool_call_overhead: u32,
}
impl Default for TokenEstimator {
fn default() -> Self {
Self {
chars_ratio: 0.35,
per_message_overhead: 4,
per_tool_call_overhead: 30,
}
}
}
impl TokenEstimator {
/// 估算单条消息的 token 数(保守估计)
pub fn estimate_message(&self, msg: &ChatMessage) -> u32 {
let content_tokens = (msg.content.chars().count() as f32 * self.chars_ratio).ceil() as u32;
let mut total = content_tokens + self.per_message_overhead;
// tool_calls 的 JSON 结构开销role=Assistant 时可能有)
if let Some(ref calls) = msg.tool_calls {
for call in calls {
total += self.per_tool_call_overhead;
total += (call.function.name.chars().count() as f32 * self.chars_ratio).ceil() as u32;
total += (call.function.arguments.chars().count() as f32 * self.chars_ratio).ceil() as u32;
}
}
// tool_call_id 开销role=Tool 时有)
if msg.tool_call_id.is_some() {
total += 3;
}
total
}
/// 估算纯文本字符串的 token 数(用于 system prompt
pub fn estimate_text(&self, text: &str) -> u32 {
(text.chars().count() as f32 * self.chars_ratio).ceil() as u32
}
}
// ============================================================
// 上下文窗口配置
// ============================================================
/// 上下文窗口配置 /// 上下文窗口配置
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContextConfig { pub struct ContextConfig {
/// 最大 token 数 /// 窗口上限 token 数(默认 128_000
pub max_tokens: u32, pub max_tokens: u32,
/// 保留的系统提示 token 数 /// 输出预留 token 数(窗口中留给模型生成的部分,默认 8_192
pub system_reserve: u32, pub output_reserve: u32,
/// 安全系数 0.0~1.0(默认 0.85,留 15% 余量)
pub safety_ratio: f32,
} }
impl Default for ContextConfig { impl Default for ContextConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
max_tokens: 128_000, max_tokens: 128_000,
system_reserve: 4_000, output_reserve: 8_192,
safety_ratio: 0.85,
} }
} }
} }
impl ContextConfig {
/// 预算上限 = (max_tokens - output_reserve) × safety_ratio
pub fn budget_limit(&self) -> u32 {
(self.max_tokens.saturating_sub(self.output_reserve) as f32 * self.safety_ratio) as u32
}
}
// ============================================================
// 消息分组(淘汰时保持工具调用三元组原子性)
// ============================================================
/// 消息在逻辑上的分组标签,用于淘汰时保持原子性
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MessageGroup {
/// 普通 User / Assistant 文本消息(可独立淘汰)
Standalone,
/// Assistant 带 tool_calls是三元组的头
ToolCallHead,
/// Tool 结果消息,是三元组的尾
ToolResultTail,
}
/// 带有 token 缓存和分组信息的消息条目
struct TrackedMessage {
message: ChatMessage,
token_count: u32,
group: MessageGroup,
}
fn classify_group(msg: &ChatMessage) -> MessageGroup {
match msg.role {
MessageRole::Tool => MessageGroup::ToolResultTail,
MessageRole::Assistant => {
if msg.tool_calls.as_ref().is_some_and(|c| !c.is_empty()) {
MessageGroup::ToolCallHead
} else {
MessageGroup::Standalone
}
}
_ => MessageGroup::Standalone,
}
}
// ============================================================
// 上下文管理器
// ============================================================
/// 上下文管理器 /// 上下文管理器
///
/// 唯一的消息真相来源(替代原来的 `Vec<ChatMessage>`)。
/// 裁剪仅影响发送视图(`build_for_request`),不影响持久化(`all_messages_clone`)。
pub struct ContextManager { pub struct ContextManager {
/// 消息历史 messages: Vec<TrackedMessage>,
messages: VecDeque<ChatMessage>, /// 当前历史总 token 数(不含 system prompt
/// 配置 history_tokens: u32,
config: ContextConfig, config: ContextConfig,
estimator: TokenEstimator,
} }
/// 保护区大小:最后 N 条消息永不淘汰(≈ 最近 2 个完整用户轮次)
const PROTECT_COUNT: usize = 6;
impl ContextManager { impl ContextManager {
/// 创建上下文管理器
pub fn new(config: ContextConfig) -> Self { pub fn new(config: ContextConfig) -> Self {
Self { Self {
messages: VecDeque::new(), messages: Vec::new(),
history_tokens: 0,
config, config,
estimator: TokenEstimator::default(),
} }
} }
/// 加消息 /// 加消息(自动计算 token 并更新缓存)
///
/// 不在此处淘汰——push 可能发生在 agentic loop 中间(追加 tool_result
/// 此时不应裁剪正在使用的活跃消息。裁剪在 `build_for_request` 时统一处理。
pub fn push(&mut self, message: ChatMessage) { pub fn push(&mut self, message: ChatMessage) {
self.messages.push_back(message); let tokens = self.estimator.estimate_message(&message);
// TODO: 当超过 token 预算时,淘汰旧消息 let group = classify_group(&message);
self.history_tokens += tokens;
self.messages.push(TrackedMessage {
message,
token_count: tokens,
group,
});
} }
/// 获取当前消息列表 /// 清空所有消息
pub fn messages(&self) -> &VecDeque<ChatMessage> {
&self.messages
}
/// 清空上下文
pub fn clear(&mut self) { pub fn clear(&mut self) {
self.messages.clear(); self.messages.clear();
self.history_tokens = 0;
}
/// 消息数量
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
);
}
// 未超预算 → 直接返回全量
if self.history_tokens <= available {
return (self.all_messages_clone(), 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
);
return (self.all_messages_clone(), 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
);
(msgs, true)
}
/// 全量克隆(持久化 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 如果找到并替换了
pub fn replace_tool_result_content(&mut self, tool_call_id: &str, new_content: &str) -> bool {
let pos = self.messages.iter().position(|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
}
/// 只读迭代(兼容 ensure_conversation_title 的 .iter().filter() 等)
pub fn iter(&self) -> impl Iterator<Item = &ChatMessage> {
self.messages.iter().map(|t| &t.message)
}
// ── 内部方法 ──
/// 构建淘汰单元列表
///
/// 每个单元是连续消息范围 [start, end),保证:
/// - 工具调用三元组ToolCallHead + ToolResultTail* + 紧随的文本 Assistant在同一单元
/// - 保护区内的消息不纳入任何单元
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 {
// 收集完整三元组Head + 后续所有 ToolResultTail + 紧随的文本 Assistant
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;
}
// 紧随的 Standalone Assistant工具调用的最终文本回复
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 {
// Standalone / ToolResultTail理论上孤立 Tail 不该出现,按单条处理)
token_sum += self.messages[i].token_count;
i += 1;
}
units.push(EvictionUnit { end: i, token_sum });
}
units
}
}
/// 淘汰单元:连续消息范围 [..end) + token 总和
struct EvictionUnit {
end: usize,
token_sum: u32,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::provider::ToolCall;
fn cfg(max_tokens: u32) -> ContextConfig {
ContextConfig {
max_tokens,
output_reserve: 0,
safety_ratio: 1.0,
}
}
#[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));
for i in 0..20 {
mgr.push(ChatMessage::user(&format!("这是第 {} 条较长的消息用于撑爆预算", i)));
}
let (msgs, trimmed) = mgr.build_for_request(0);
assert!(trimmed, "超预算应触发裁剪");
assert!(msgs.len() < 20, "应裁掉部分旧消息, 实际 {}", msgs.len());
// 保护区:最新一条必保留
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 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::user("第二条撑爆预算的长消息"));
let (msgs, trimmed) = mgr.build_for_request(0);
assert!(!trimmed, "无可淘汰单元应返回 false(兜底)");
assert_eq!(msgs.len(), 2, "兜底返回全部保护区消息");
}
#[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()
);
} }
} }

View File

@@ -1,4 +1,6 @@
//! Agent 协调器 — 管理多 Agent 协作 //! Agent 协调器 — 管理多 Agent 协作
//!
//! ⚠ B 路线占位:当前单链 ReAct 够用,多 Agent 协作待 B 路线立项。有意保留空壳,勿删。
/// Agent 协调器 /// Agent 协调器
/// ///

View File

@@ -8,3 +8,27 @@ pub mod openai_compat;
pub mod provider; pub mod provider;
pub mod router; pub mod router;
pub mod stream; pub mod stream;
use provider::LlmProvider;
/// 按 provider_type 构建 LLM Provider 实例(统一选择逻辑,消除调用方重复 match
///
/// `anthropic` 协议走 AnthropicCompatProviderGLM 订阅端点 / Claude 官方),
/// 其余openai / glm / deepseek 等 OpenAI 兼容)走 OpenAICompatProvider。
/// 调用方AI Chat 的 run_agentic_loop、df-nodes 的 AiNode统一引用此工厂
/// 新增 provider 只改这一处。
pub fn build_provider(
provider_type: &str,
base_url: &str,
api_key: &str,
model: &str,
) -> Box<dyn LlmProvider> {
match provider_type {
"anthropic" => Box::new(anthropic_compat::AnthropicCompatProvider::new(
base_url, api_key, model,
)),
_ => Box::new(openai_compat::OpenAICompatProvider::new(
base_url, api_key, model,
)),
}
}

View File

@@ -3,14 +3,12 @@
//! 覆盖: OpenAI / GLM (open.bigmodel.cn) / DeepSeek / Claude OpenAI 兼容模式 //! 覆盖: OpenAI / GLM (open.bigmodel.cn) / DeepSeek / Claude OpenAI 兼容模式
//! 支持: 同步调用 + SSE 流式 + Function Calling / Tool Use //! 支持: 同步调用 + SSE 流式 + Function Calling / Tool Use
use std::pin::Pin;
use async_trait::async_trait; use async_trait::async_trait;
use eventsource_stream::Eventsource; use eventsource_stream::Eventsource;
use futures::{Stream, StreamExt}; use futures::StreamExt;
use reqwest::Client; use reqwest::Client;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tracing::{debug, error, info, warn}; use tracing::{debug, error, warn};
use crate::provider::{ use crate::provider::{
CompletionRequest, CompletionResponse, LlmProvider, ProviderFeatures, StreamChunk, StreamResult, CompletionRequest, CompletionResponse, LlmProvider, ProviderFeatures, StreamChunk, StreamResult,
@@ -35,6 +33,9 @@ struct OpenAiRequest {
tools: Option<Vec<serde_json::Value>>, tools: Option<Vec<serde_json::Value>>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
tool_choice: Option<serde_json::Value>, tool_choice: Option<serde_json::Value>,
/// 流式时请求末 chunk 携带 usageOpenAI 官方 + DeepSeek/GLM 兼容)
#[serde(skip_serializing_if = "Option::is_none")]
stream_options: Option<serde_json::Value>,
} }
/// OpenAI 消息格式 /// OpenAI 消息格式
@@ -93,6 +94,9 @@ struct OpenAiUsage {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct OpenAiStreamChunk { struct OpenAiStreamChunk {
choices: Vec<OpenAiStreamChoice>, choices: Vec<OpenAiStreamChoice>,
/// 末 chunkchoices 为空)携带的累计 usage
#[serde(default)]
usage: Option<OpenAiUsage>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -120,6 +124,87 @@ struct OpenAiStreamFunction {
arguments: Option<String>, arguments: Option<String>,
} }
// ============================================================
// SSE 解析纯函数(与 HTTP 解耦,便于单测)
// ============================================================
/// 将一条 OpenAI 兼容 SSE 事件 data 解析为 StreamChunk并按需更新 usage 累加器。
///
/// - `[DONE]` → 返回 `finished=true` 的终态 chunk`usage` 取自累加器(`take()`)。
/// - 普通文本/工具增量 chunk → 返回对应 `StreamChunk`usage 字段恒为 Noneusage 仅在终态带出)。
/// - usage`stream_options.include_usage` 时末段或 usage-only chunk 携带)→ 覆盖累加器(覆盖语义保对)。
/// - 解析失败 → 返回空 chunk与原内联实现一致
///
/// 等价性delta / tool_calls / finished / 解析失败等分支与原 stream() 闭包逐字一致;
/// usage 透传([DONE] 终态 take() 带出、usage chunk 覆盖累加器)为本次新增能力,
/// 对应 StreamChunk 新增的 usage 字段 + 请求体新增 stream_options.include_usage。
pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>) -> StreamChunk {
// OpenAI 发送 "data: [DONE]" 表示流结束,带出累积 usage
if data == "[DONE]" {
return StreamChunk {
delta: String::new(),
finished: true,
tool_calls: None,
usage: usage_accum.take(),
};
}
match serde_json::from_str::<OpenAiStreamChunk>(data) {
Ok(chunk) => {
// 提取 usage带 include_usage 时末段 chunk 携带,覆盖累积)
if let Some(u) = chunk.usage {
*usage_accum = Some(TokenUsage {
prompt_tokens: u.prompt_tokens,
completion_tokens: u.completion_tokens,
total_tokens: u.total_tokens,
});
}
if let Some(choice) = chunk.choices.into_iter().next() {
let delta_text = choice.delta.content.unwrap_or_default();
// "length" = max_tokens 截断,属正常终止(非断连),纳入 finished
let finished = choice.finish_reason.as_deref() == Some("stop")
|| choice.finish_reason.as_deref() == Some("tool_calls")
|| choice.finish_reason.as_deref() == Some("length");
let tool_calls = choice.delta.tool_calls.map(|tcs| {
tcs.into_iter()
.map(|tc| ToolCallDelta {
index: tc.index,
id: tc.id,
function_name: tc.function.as_ref().and_then(|f| f.name.clone()),
function_arguments: tc.function.and_then(|f| f.arguments),
})
.collect()
});
StreamChunk {
delta: delta_text,
finished,
tool_calls,
usage: None,
}
} else {
// choices 为空 = usage-only chunk不输出文本usage 已累积)
StreamChunk {
delta: String::new(),
finished: false,
tool_calls: None,
usage: None,
}
}
}
Err(e) => {
debug!("SSE 数据解析失败: {} — data: {}", e, data);
StreamChunk {
delta: String::new(),
finished: false,
tool_calls: None,
usage: None,
}
}
}
}
// ============================================================ // ============================================================
// OpenAI Compat Provider // OpenAI Compat Provider
// ============================================================ // ============================================================
@@ -184,6 +269,18 @@ impl OpenAICompatProvider {
} }
} }
/// 构建 embeddings API URL与 chat_url 同套智能拼接规则)
fn embed_url(&self) -> String {
let base = self.base_url.trim_end_matches('/');
if base.ends_with("/embeddings") {
return base.to_string();
}
if Self::ends_with_version(base) {
return format!("{}/embeddings", base);
}
format!("{}/v1/embeddings", base)
}
/// 将通用请求转换为 OpenAI 格式 /// 将通用请求转换为 OpenAI 格式
fn convert_request(&self, req: CompletionRequest) -> OpenAiRequest { fn convert_request(&self, req: CompletionRequest) -> OpenAiRequest {
let model = if req.model.is_empty() { let model = if req.model.is_empty() {
@@ -240,6 +337,12 @@ impl OpenAICompatProvider {
stream: req.stream, stream: req.stream,
tools, tools,
tool_choice: req.tool_choice, tool_choice: req.tool_choice,
// 流式请求末 chunk 带 usage同步调用 complete 不需要)
stream_options: if req.stream {
Some(serde_json::json!({ "include_usage": true }))
} else {
None
},
} }
} }
@@ -254,6 +357,34 @@ impl OpenAICompatProvider {
#[async_trait] #[async_trait]
impl LlmProvider for OpenAICompatProvider { impl LlmProvider for OpenAICompatProvider {
/// 文本嵌入: POST /v1/embeddings(OpenAI 兼容,智谱/阿里百炼/OpenAI 通用)
async fn embed(&self, model: &str, texts: Vec<String>) -> anyhow::Result<Vec<Vec<f32>>> {
#[derive(serde::Deserialize)]
struct EmbedData { embedding: Vec<f32>, index: usize }
#[derive(serde::Deserialize)]
struct EmbedResponse { data: Vec<EmbedData> }
let resp = self
.client
.post(self.embed_url())
.header("Authorization", format!("Bearer {}", self.api_key))
.header("Content-Type", "application/json")
.json(&serde_json::json!({ "model": model, "input": texts }))
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("Embedding API 错误 {}: {}", status, body);
}
let mut body: EmbedResponse = resp.json().await?;
// 按 index 排序保证与输入顺序一致(API 不保证返回顺序)
body.data.sort_by_key(|d| d.index);
Ok(body.data.into_iter().map(|d| d.embedding).collect())
}
async fn complete(&self, request: CompletionRequest) -> anyhow::Result<CompletionResponse> { async fn complete(&self, request: CompletionRequest) -> anyhow::Result<CompletionResponse> {
let mut req = request; let mut req = request;
req.stream = false; req.stream = false;
@@ -328,68 +459,18 @@ impl LlmProvider for OpenAICompatProvider {
anyhow::bail!("LLM 流式 API 错误 {}: {}", status, body); anyhow::bail!("LLM 流式 API 错误 {}: {}", status, body);
} }
// 累积流式 usage开 include_usage 后,末段正常 chunkfinish_reason及额外 usage-only chunkchoices=[])都带 usage。
// usage 解析/累积逻辑抽到 apply_openai_sse 纯函数,便于单测;此处闭包只负责传 data 与传递 last_usage。
let mut last_usage: Option<TokenUsage> = None;
let stream = resp let stream = resp
.bytes_stream() .bytes_stream()
.eventsource() .eventsource()
.map(move |event| { .map(move |event| match event {
match event { Ok(event) => Ok(apply_openai_sse(&event.data, &mut last_usage)),
Ok(event) => { Err(e) => {
// OpenAI 发送 "data: [DONE]" 表示流结束 error!("SSE 流错误: {}", e);
if event.data == "[DONE]" { Err(anyhow::anyhow!("SSE 流错误: {}", e))
return Ok(StreamChunk {
delta: String::new(),
finished: true,
tool_calls: None,
});
}
match serde_json::from_str::<OpenAiStreamChunk>(&event.data) {
Ok(chunk) => {
if let Some(choice) = chunk.choices.into_iter().next() {
let delta_text = choice.delta.content.unwrap_or_default();
// "length" = max_tokens 截断,属正常终止(非断连),纳入 finished
let finished = choice.finish_reason.as_deref() == Some("stop")
|| choice.finish_reason.as_deref() == Some("tool_calls")
|| choice.finish_reason.as_deref() == Some("length");
let tool_calls = choice.delta.tool_calls.map(|tcs| {
tcs.into_iter()
.map(|tc| ToolCallDelta {
index: tc.index,
id: tc.id,
function_name: tc.function.as_ref().and_then(|f| f.name.clone()),
function_arguments: tc.function.and_then(|f| f.arguments),
})
.collect()
});
Ok(StreamChunk {
delta: delta_text,
finished,
tool_calls,
})
} else {
Ok(StreamChunk {
delta: String::new(),
finished: false,
tool_calls: None,
})
}
}
Err(e) => {
debug!("SSE 数据解析失败: {} — data: {}", e, event.data);
Ok(StreamChunk {
delta: String::new(),
finished: false,
tool_calls: None,
})
}
}
}
Err(e) => {
error!("SSE 流错误: {}", e);
Err(anyhow::anyhow!("SSE 流错误: {}", e))
}
} }
}); });
@@ -408,3 +489,169 @@ impl LlmProvider for OpenAICompatProvider {
} }
} }
} }
// ============================================================
// 单测(不发真实 HTTP喂构造的 SSE data 字符串序列)
// ============================================================
#[cfg(test)]
mod tests {
use super::*;
/// 辅助:构造普通文本 delta chunk 的 SSE data
fn text_chunk(content: &str, finish_reason: Option<&str>) -> String {
let fr = match finish_reason {
Some(r) => format!(", \"finish_reason\": \"{}\"", r),
None => String::from(", \"finish_reason\": null"),
};
format!(
r#"{{"choices":[{{"delta":{{"content":"{}"}}{}}}]}}"#,
content, fr
)
}
/// 辅助:构造带 usage 的 chunkchoices 为空 → usage-only 末 chunk对应 include_usage
fn usage_only_chunk(prompt: u32, completion: u32) -> String {
format!(
r#"{{"choices":[],"usage":{{"prompt_tokens":{},"completion_tokens":{},"total_tokens":{}}}}}"#,
prompt,
completion,
prompt + completion
)
}
/// 辅助:构造既有 content 又带 usage 的末段 chunk部分兼容端点会把 usage 挂到正常末 chunk 上)
fn text_chunk_with_usage(content: &str, finish_reason: &str, prompt: u32, completion: u32) -> String {
format!(
r#"{{"choices":[{{"delta":{{"content":"{}"}},"finish_reason":"{}"}}],"usage":{{"prompt_tokens":{},"completion_tokens":{},"total_tokens":{}}}}}"#,
content,
finish_reason,
prompt,
completion,
prompt + completion
)
}
/// 多 chunk 文本流后,末 chunk 携带 usageinclude_usage 覆盖语义)
#[test]
fn openai_sse_multi_chunk_with_terminal_usage() {
let mut acc: Option<TokenUsage> = None;
// 1) 首个文本增量,无 usage
let c = apply_openai_sse(&text_chunk("Hello", None), &mut acc);
assert_eq!(c.delta, "Hello");
assert!(!c.finished);
assert!(c.usage.is_none());
assert!(acc.is_none(), "无 usage 的 chunk 不应改累加器");
// 2) 第二个文本增量
let c = apply_openai_sse(&text_chunk(" world", None), &mut acc);
assert_eq!(c.delta, " world");
assert!(!c.finished);
assert!(acc.is_none());
// 3) 末段正常 chunk 带 finish_reason=stop仍是文本 delta不带 usage
let c = apply_openai_sse(&text_chunk("", Some("stop")), &mut acc);
assert!(c.finished);
assert_eq!(c.delta, "");
assert!(acc.is_none(), "此 chunk 无 usage 字段,累加器仍为 None");
// 4) usage-only chunkchoices=[])携带累计 usage → 覆盖累加器
let c = apply_openai_sse(&usage_only_chunk(12, 34), &mut acc);
assert!(!c.finished);
assert!(c.usage.is_none(), "非 [DONE] chunk 不带出 usage");
let acc = acc.expect("累加器应已被 usage-only chunk 覆盖写入");
assert_eq!(acc.prompt_tokens, 12);
assert_eq!(acc.completion_tokens, 34);
assert_eq!(acc.total_tokens, 46);
}
/// usage 挂在正常末段 chunk含 finish_reason而非独立 usage-only chunk
#[test]
fn openai_sse_usage_on_terminal_text_chunk() {
let mut acc: Option<TokenUsage> = None;
let c = apply_openai_sse(&text_chunk_with_usage("", "stop", 100, 200), &mut acc);
assert!(c.finished);
assert!(c.usage.is_none(), "非 [DONE] 不带出 usage仅覆盖累加器");
let acc = acc.expect("末段 chunk 的 usage 应已覆盖累加器");
assert_eq!(acc.prompt_tokens, 100);
assert_eq!(acc.completion_tokens, 200);
assert_eq!(acc.total_tokens, 300);
}
/// [DONE] 时 take() 带出累积 usage且取走后累加器清空
#[test]
fn openai_sse_done_takes_accumulated_usage() {
let mut acc: Option<TokenUsage> = None;
apply_openai_sse(&text_chunk("x", None), &mut acc);
apply_openai_sse(&usage_only_chunk(5, 7), &mut acc);
let c = apply_openai_sse("[DONE]", &mut acc);
assert!(c.finished);
let u = c.usage.expect("[DONE] 应带出累积 usage");
assert_eq!(u.prompt_tokens, 5);
assert_eq!(u.completion_tokens, 7);
assert_eq!(u.total_tokens, 12);
assert!(acc.is_none(), "take() 后累加器应清空");
}
/// 无 usage 的流:[DONE] 时 usage 字段为 None
#[test]
fn openai_sse_done_without_usage() {
let mut acc: Option<TokenUsage> = None;
apply_openai_sse(&text_chunk("hi", None), &mut acc);
let c = apply_openai_sse("[DONE]", &mut acc);
assert!(c.finished);
assert!(c.usage.is_none(), "全程无 usage 时 [DONE] usage 应为 None");
assert!(acc.is_none());
}
/// 后续 usage chunk 覆盖先前 usage多轮 / 重发场景)
#[test]
fn openai_sse_later_usage_overrides_earlier() {
let mut acc: Option<TokenUsage> = None;
apply_openai_sse(&usage_only_chunk(1, 1), &mut acc);
apply_openai_sse(&usage_only_chunk(50, 60), &mut acc);
let c = apply_openai_sse("[DONE]", &mut acc);
let u = c.usage.unwrap();
assert_eq!(u.prompt_tokens, 50, "末 usage 应覆盖前值");
assert_eq!(u.completion_tokens, 60);
assert_eq!(u.total_tokens, 110);
}
/// finish_reason=lengthmax_tokens 截断)按正常终止处理
#[test]
fn openai_sse_length_finish_reason_treated_as_finished() {
let mut acc: Option<TokenUsage> = None;
let c = apply_openai_sse(&text_chunk("...", Some("length")), &mut acc);
assert!(c.finished, "length 应视为正常终止");
assert!(acc.is_none());
}
/// 非法 JSON data → 返回空 chunk不 panic、不改累加器
#[test]
fn openai_sse_malformed_json_yields_empty_chunk() {
let mut acc: Option<TokenUsage> = None;
let c = apply_openai_sse("not a json", &mut acc);
assert_eq!(c.delta, "");
assert!(!c.finished);
assert!(c.usage.is_none());
assert!(acc.is_none());
}
/// tool_calls 增量解析
#[test]
fn openai_sse_tool_call_delta() {
let mut acc: Option<TokenUsage> = None;
let data = r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"get_weather","arguments":"{\"q\":"}}]}}]}"#;
let c = apply_openai_sse(data, &mut acc);
assert!(acc.is_none());
let tcs = c.tool_calls.expect("应有 tool_calls 增量");
assert_eq!(tcs.len(), 1);
assert_eq!(tcs[0].index, 0);
assert_eq!(tcs[0].id.as_deref(), Some("call_1"));
assert_eq!(tcs[0].function_name.as_deref(), Some("get_weather"));
assert_eq!(tcs[0].function_arguments.as_deref(), Some("{\"q\":"));
assert!(!c.finished);
}
}

View File

@@ -45,23 +45,26 @@ pub struct ChatMessage {
/// AI 发起的工具调用列表role=Assistant 时可能有) /// AI 发起的工具调用列表role=Assistant 时可能有)
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCall>>, pub tool_calls: Option<Vec<ToolCall>>,
/// 生成该消息的 model仅 assistant 消息有,消息级 model 追溯)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
} }
impl ChatMessage { impl ChatMessage {
pub fn system(content: impl Into<String>) -> Self { pub fn system(content: impl Into<String>) -> Self {
Self { role: MessageRole::System, content: content.into(), tool_call_id: None, tool_calls: None } Self { role: MessageRole::System, content: content.into(), tool_call_id: None, tool_calls: None, model: None }
} }
pub fn user(content: impl Into<String>) -> Self { pub fn user(content: impl Into<String>) -> Self {
Self { role: MessageRole::User, content: content.into(), tool_call_id: None, tool_calls: None } Self { role: MessageRole::User, content: content.into(), tool_call_id: None, tool_calls: None, model: None }
} }
pub fn assistant(content: impl Into<String>) -> Self { pub fn assistant(content: impl Into<String>) -> Self {
Self { role: MessageRole::Assistant, content: content.into(), tool_call_id: None, tool_calls: None } Self { role: MessageRole::Assistant, content: content.into(), tool_call_id: None, tool_calls: None, model: None }
} }
pub fn assistant_with_tools(content: impl Into<String>, tool_calls: Vec<ToolCall>) -> Self { pub fn assistant_with_tools(content: impl Into<String>, tool_calls: Vec<ToolCall>) -> Self {
Self { role: MessageRole::Assistant, content: content.into(), tool_call_id: None, tool_calls: Some(tool_calls) } Self { role: MessageRole::Assistant, content: content.into(), tool_call_id: None, tool_calls: Some(tool_calls), model: None }
} }
pub fn tool_result(call_id: impl Into<String>, content: impl Into<String>) -> Self { pub fn tool_result(call_id: impl Into<String>, content: impl Into<String>) -> Self {
Self { role: MessageRole::Tool, content: content.into(), tool_call_id: Some(call_id.into()), tool_calls: None } Self { role: MessageRole::Tool, content: content.into(), tool_call_id: Some(call_id.into()), tool_calls: None, model: None }
} }
} }
@@ -141,7 +144,7 @@ pub struct CompletionResponse {
} }
/// Token 用量 /// Token 用量
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TokenUsage { pub struct TokenUsage {
pub prompt_tokens: u32, pub prompt_tokens: u32,
pub completion_tokens: u32, pub completion_tokens: u32,
@@ -166,6 +169,9 @@ pub struct StreamChunk {
/// 工具调用增量(如有) /// 工具调用增量(如有)
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCallDelta>>, pub tool_calls: Option<Vec<ToolCallDelta>>,
/// Token 用量(流末 chunk 携带,由 provider 解析自 SSE usage 事件)
#[serde(skip_serializing_if = "Option::is_none")]
pub usage: Option<TokenUsage>,
} }
/// 工具调用增量(流式中的片段) /// 工具调用增量(流式中的片段)
@@ -199,6 +205,14 @@ pub trait LlmProvider: Send + Sync {
request: CompletionRequest, request: CompletionRequest,
) -> anyhow::Result<StreamResult>; ) -> anyhow::Result<StreamResult>;
/// 文本嵌入:批量文本 → 语义向量(供知识库向量检索)
///
/// 默认实现返回 Err(协议不支持)。OpenAI 兼容协议覆盖实现(/v1/embeddings);
/// Anthropic 无 embedding API,保持默认。
async fn embed(&self, _model: &str, _texts: Vec<String>) -> anyhow::Result<Vec<Vec<f32>>> {
anyhow::bail!("该 Provider 不支持 embedding({})", self.name())
}
/// Provider 名称 /// Provider 名称
fn name(&self) -> &str; fn name(&self) -> &str;

View File

@@ -1,12 +0,0 @@
[package]
name = "df-evolve"
version = "0.1.0"
edition = "2021"
[dependencies]
df-core = { path = "../df-core" }
serde = { workspace = true }
serde_json = { workspace = true }
chrono = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }

View File

@@ -1,58 +0,0 @@
//! 进化引擎:知识沉淀的核心闭环
//!
//! 使用 → 沉淀 → 复用 → 改进 → 再沉淀
use crate::knowledge::{Knowledge, KnowledgeKind, KnowledgeStore};
use crate::pattern::PatternExtractor;
/// 进化引擎
pub struct EvolveEngine {
extractor: PatternExtractor,
}
impl EvolveEngine {
pub fn new() -> Self {
Self {
extractor: PatternExtractor,
}
}
/// 自动扫描项目事件,提取可沉淀的知识
///
/// 触发时机:
/// - 工作流节点完成后
/// - 代码审查完成后
/// - Bug 修复完成后
/// - 发布完成后
pub async fn evolve_from_events(&self, _events: &[serde_json::Value]) -> Vec<Knowledge> {
let mut new_knowledge = Vec::new();
// TODO: 遍历事件,分类处理
// 1. 审查事件 → 提取审查规则
// 2. Bug 修复事件 → 提取诊断知识
// 3. 发布事件 → 提取部署经验
// 4. Prompt 事件 → 提取 Prompt 模板
new_knowledge
}
/// 查询当前任务相关的知识(供 AI 节点使用)
///
/// AI 在执行任务前可以查询知识库,获取相关经验和规则
pub fn query_relevant(
&self,
_context: &str,
_kind: Option<&KnowledgeKind>,
) -> Vec<Knowledge> {
// TODO: 语义搜索知识库
KnowledgeStore::search(_context, _kind, 5)
}
/// 验证知识的有效性(定期执行)
///
/// 检查知识是否仍然适用(依赖版本是否过时、规则是否仍有意义等)
pub async fn validate_knowledge(&self) -> Vec<String> {
// TODO: 遍历知识库,标记过时的知识
vec![]
}
}

View File

@@ -1,98 +0,0 @@
//! 知识条目:经验沉淀的基本单元
use df_core::types::ProjectId;
use serde::{Deserialize, Serialize};
/// 知识类型
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum KnowledgeKind {
/// 代码审查规则(如"禁止在循环中创建连接"
ReviewRule,
/// 有效的 Prompt 模板
PromptTemplate,
/// 踩坑经验
Pitfall,
/// 架构模式
ArchitecturePattern,
/// 诊断知识Bug 根因分析)
Diagnosis,
/// 部署经验
DeploymentNote,
/// 工作流优化建议
WorkflowOptimization,
}
/// 知识条目
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Knowledge {
pub id: String,
pub kind: KnowledgeKind,
/// 标题
pub title: String,
/// 内容
pub content: String,
/// 标签
pub tags: Vec<String>,
/// 来源项目
pub source_project: Option<ProjectId>,
/// 来源实体(如某次审查、某个 Bug 修复)
pub source_ref: Option<String>,
/// 被复用次数
pub reuse_count: usize,
/// 效果评分 (0-100由用户或 AI 评估)
pub effectiveness: Option<f32>,
/// 是否已验证有效
pub verified: bool,
pub created_at: i64,
pub updated_at: i64,
}
impl Knowledge {
pub fn new(kind: KnowledgeKind, title: String, content: String) -> Self {
let now = chrono::Utc::now().timestamp();
Self {
id: df_core::types::new_id(),
kind,
title,
content,
tags: vec![],
source_project: None,
source_ref: None,
reuse_count: 0,
effectiveness: None,
verified: false,
created_at: now,
updated_at: now,
}
}
/// 记录一次复用
pub fn record_reuse(&mut self) {
self.reuse_count += 1;
self.updated_at = chrono::Utc::now().timestamp();
}
}
/// 知识库(内存索引,持久化到 SQLite
pub struct KnowledgeStore;
impl KnowledgeStore {
/// 搜索相关知识
pub fn search(_query: &str, _kind: Option<&KnowledgeKind>, _limit: usize) -> Vec<Knowledge> {
// TODO: SQLite 全文搜索或向量搜索
vec![]
}
/// 获取最常用的知识
pub fn top_used(_limit: usize) -> Vec<Knowledge> {
// TODO: 按 reuse_count 降序
vec![]
}
/// 保存知识条目
pub fn save(_knowledge: &Knowledge) -> anyhow::Result<()> {
// TODO: SQLite INSERT/UPDATE
Ok(())
}
}

View File

@@ -1,12 +0,0 @@
//! 经验进化引擎:从开发过程中自动沉淀知识,持续进化复用
//!
//! 核心闭环:使用 → 沉淀 → 复用 → 改进 → 再沉淀
pub mod knowledge;
pub mod pattern;
pub mod prompt_template;
pub mod review_rule;
pub mod evolve_engine;
pub use evolve_engine::EvolveEngine;
pub use knowledge::{Knowledge, KnowledgeKind, KnowledgeStore};

View File

@@ -1,47 +0,0 @@
//! 模式提取器:从开发过程中自动识别可沉淀的模式
use crate::knowledge::{Knowledge, KnowledgeKind};
/// 模式提取器
///
/// 自动从以下场景中识别可沉淀的模式:
/// - 代码审查 → 审查规则
/// - Bug 修复 → 诊断知识
/// - 发布流程 → 部署经验
/// - Prompt 调优 → Prompt 模板
pub struct PatternExtractor;
impl PatternExtractor {
/// 从代码审查结果中提取审查规则
///
/// 如果同一类问题在多次审查中重复出现,自动沉淀为规则
pub fn extract_review_rule(
_findings: &[serde_json::Value],
_occurrence_threshold: usize,
) -> Option<Knowledge> {
// TODO:
// 1. 分析 findings 的共性
// 2. 如果出现次数 >= threshold生成规则
// 3. 去重(与已有规则比较)
None
}
/// 从 Bug 修复过程中提取诊断知识
pub fn extract_diagnosis(
_bug_description: &str,
_root_cause: &str,
_fix_description: &str,
) -> Option<Knowledge> {
// TODO: AI 总结为可复用的诊断知识
None
}
/// 从成功的 Prompt 中提取模板
pub fn extract_prompt_template(
_prompt: &str,
_result_quality: f32,
) -> Option<Knowledge> {
// TODO: 如果 result_quality > 0.8,提取为模板
None
}
}

View File

@@ -1,41 +0,0 @@
//! Prompt 模板管理AI 交互经验的沉淀与复用
use serde::{Deserialize, Serialize};
/// Prompt 模板
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PromptTemplate {
pub id: String,
/// 模板名称
pub name: String,
/// 模板内容(支持 {variable} 占位符)
pub template: String,
/// 变量说明
pub variables: Vec<TemplateVariable>,
/// 适用场景
pub applicable_scenarios: Vec<String>,
/// 效果评分
pub avg_score: f32,
/// 使用次数
pub use_count: usize,
}
/// 模板变量
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TemplateVariable {
pub name: String,
pub description: String,
pub default_value: Option<String>,
pub required: bool,
}
impl PromptTemplate {
/// 渲染模板(替换变量)
pub fn render(&self, vars: &std::collections::HashMap<String, String>) -> String {
let mut result = self.template.clone();
for (key, value) in vars {
result = result.replace(&format!("{{{}}}", key), value);
}
result
}
}

View File

@@ -1,45 +0,0 @@
//! 审查规则:从历史审查经验中沉淀的代码审查规则
use serde::{Deserialize, Serialize};
/// 审查规则
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReviewRule {
pub id: String,
/// 规则标题
pub title: String,
/// 规则描述
pub description: String,
/// 严重级别
pub severity: RuleSeverity,
/// 适用的语言/框架
pub scope: Vec<String>,
/// 检查方式(正则/AST/AI
pub check_method: CheckMethod,
/// 发现次数(历史累计)
pub found_count: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RuleSeverity {
/// 必须修复
MustFix,
/// 建议改进
ShouldFix,
/// 可选优化
NiceToHave,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CheckMethod {
/// 正则匹配
Regex,
/// AST 分析
Ast,
/// AI 判断
AiAnalysis,
/// 人工判断
Manual,
}

View File

@@ -1,46 +0,0 @@
//! Docker 执行器 — 在容器中运行任务
use serde::{Deserialize, Serialize};
/// Docker 容器执行请求
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DockerRequest {
/// 镜像名称
pub image: String,
/// 容器内执行的命令
pub command: Option<String>,
/// 环境变量
pub env: std::collections::HashMap<String, String>,
/// 挂载卷
pub volumes: Vec<VolumeMount>,
/// 是否在执行后自动删除容器
pub auto_remove: bool,
}
/// 卷挂载
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VolumeMount {
pub host_path: String,
pub container_path: String,
pub read_only: bool,
}
/// Docker 执行结果
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DockerResult {
pub stdout: String,
pub stderr: String,
pub exit_code: Option<i32>,
}
/// 在 Docker 容器中执行命令
///
/// TODO: 实现 Docker API 调用或 CLI 包装
pub async fn execute(_request: DockerRequest) -> anyhow::Result<DockerResult> {
tracing::info!("Docker 执行: TODO");
Ok(DockerResult {
stdout: String::new(),
stderr: String::new(),
exit_code: None,
})
}

View File

@@ -1,47 +0,0 @@
//! Git 操作 — 克隆、提交、推送、合并等
use serde::{Deserialize, Serialize};
/// Git 操作类型
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GitAction {
Clone,
Commit,
Push,
Pull,
Merge,
Checkout,
CreateBranch,
}
/// Git 操作请求
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitRequest {
/// 操作类型
pub action: GitAction,
/// 仓库路径(本地路径或远程 URL
pub repo: String,
/// 分支名
pub branch: Option<String>,
/// 提交消息
pub message: Option<String>,
}
/// Git 操作结果
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitResult {
pub success: bool,
pub message: String,
}
/// 执行 Git 操作
///
/// TODO: 实现完整的 Git 操作(可包装 git CLI 或使用 git2 crate
pub async fn execute(_request: GitRequest) -> anyhow::Result<GitResult> {
tracing::info!("Git 操作: TODO");
Ok(GitResult {
success: true,
message: "TODO: 未实现".to_string(),
})
}

View File

@@ -1,6 +1,3 @@
//! df-execute: 执行运行时 — Shell、Docker、SSH、Git 操作 //! df-execute: 执行运行时 — Shell
pub mod docker;
pub mod git_ops;
pub mod shell; pub mod shell;
pub mod ssh;

View File

@@ -1,38 +0,0 @@
//! SSH 执行器 — 远程命令执行
use serde::{Deserialize, Serialize};
/// SSH 执行请求
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SshRequest {
/// 主机地址
pub host: String,
/// 端口
pub port: u16,
/// 用户名
pub user: String,
/// 要执行的命令
pub command: String,
/// 超时时间(秒)
pub timeout_secs: Option<u64>,
}
/// SSH 执行结果
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SshResult {
pub stdout: String,
pub stderr: String,
pub exit_code: Option<i32>,
}
/// 通过 SSH 执行远程命令
///
/// TODO: 实现SSH连接可用 ssh2 crate 或包装 ssh 命令)
pub async fn execute(_request: SshRequest) -> anyhow::Result<SshResult> {
tracing::info!("SSH 执行: TODO");
Ok(SshResult {
stdout: String::new(),
stderr: String::new(),
exit_code: None,
})
}

View File

@@ -1,11 +1,14 @@
//! 对抗式评估系统 — 正反方辩论 + AI 分析师 //! 对抗式评估系统 — 正反方辩论 + AI 分析师
//!
//! 当前为基于评分与内容信号的启发式实现(稳定、有区分度)。
//! TODO: 接入 df-ai LlmProvider 让正反方论点由 LLM 生成,启发式降级为 fallback。
use anyhow::Result; use anyhow::Result;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use df_core::types::IdeaId; use df_core::types::{IdeaId, Priority};
use crate::capture::Idea; use crate::capture::Idea;
use crate::scoring::IdeaScores;
/// 对抗评估结果 /// 对抗评估结果
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -18,7 +21,7 @@ pub struct AdversarialEval {
pub recommendation: Recommendation, pub recommendation: Recommendation,
} }
/// 正方论点 /// 论点(正方/反方共用同一结构)
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Argument { pub struct Argument {
pub thesis: String, // 核心观点 pub thesis: String, // 核心观点
@@ -27,15 +30,6 @@ pub struct Argument {
pub confidence: f64, // 置信度 0-1 pub confidence: f64, // 置信度 0-1
} }
/// 反方论点
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CounterArgument {
pub thesis: String, // 反对观点
pub evidence: Vec<String>, // 反对证据
pub reasoning: Vec<String>, // 反驳推理
pub confidence: f64, // 置信度 0-1
}
/// AI 分析师综合分析 /// AI 分析师综合分析
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnalystAnalysis { pub struct AnalystAnalysis {
@@ -55,7 +49,6 @@ pub enum AssessmentLevel {
Conditional, // 有条件执行 Conditional, // 有条件执行
Revised, // 需要修改后执行 Revised, // 需要修改后执行
Defer, // 推迟执行 Defer, // 推迟执行
Reject, // 不推荐执行
} }
/// 最终建议 /// 最终建议
@@ -66,7 +59,6 @@ pub enum Recommendation {
WithResources, // 配置资源后行动 WithResources, // 配置资源后行动
ResearchMore, // 需要更多研究 ResearchMore, // 需要更多研究
Monitor, // 持续监控 Monitor, // 持续监控
Cancel, // 取消想法
} }
/// 对抗评估引擎 /// 对抗评估引擎
@@ -74,133 +66,166 @@ pub struct AdversarialEngine;
impl AdversarialEngine { impl AdversarialEngine {
/// 执行完整的对抗评估 /// 执行完整的对抗评估
#[allow(clippy::unused_async)] // 签名保留 async,待接 LLM 注入异步调用
pub async fn evaluate(idea: &Idea) -> Result<AdversarialEval> { pub async fn evaluate(idea: &Idea) -> Result<AdversarialEval> {
// 1. 生成正方观点 // 先做多维评分,作为正反方论点与置信度的依据
let positive = Self::generate_positive_argument(idea).await?; let scores = crate::scoring::ScoringEngine::compute_default(idea);
// 2. 生成反方观点 let positive = Self::generate_positive_argument(idea, &scores)?;
let negative = Self::generate_negative_argument(idea, &positive).await?; let negative = Self::generate_negative_argument(idea, &scores)?;
let analyst = Self::analyst_analysis(idea, &scores)?;
// 3. AI 分析师综合分析 let recommendation = Self::recommendation_for(&analyst.final_assessment);
let analyst = Self::analyst_analysis(idea, &positive, &negative).await?;
// 4. 计算最终分数和建议
let (final_score, recommendation) = Self::compute_final_assessment(&analyst);
Ok(AdversarialEval { Ok(AdversarialEval {
idea_id: idea.id.clone(), idea_id: idea.id.clone(),
positive, positive,
negative, negative,
analyst, analyst,
final_score, final_score: scores.overall,
recommendation, recommendation,
}) })
} }
/// 生成正方观点(支持执行) /// 生成正方观点(支持执行)— confidence 由可行性 + 影响力驱动
async fn generate_positive_argument(idea: &Idea) -> Result<Argument> { /// 注:返回 Result 为后续 LLM 注入失败预留,启发式阶段恒 Ok
// TODO: 接入 AI 生成正方观点 fn generate_positive_argument(idea: &Idea, scores: &IdeaScores) -> Result<Argument> {
// 当前使用启发式模板 let desc = idea.description.trim();
let mut evidence = Vec::new();
evidence.push(format!("优先级:{}", priority_label(&idea.priority)));
if desc.is_empty() {
evidence.push("需求待补充(建议补全描述)".to_string());
} else {
let head: String = desc.chars().take(60).collect();
evidence.push(format!("明确需求:{}", head));
}
if idea.tags.is_empty() {
evidence.push("关联领域待界定".to_string());
} else {
evidence.push(format!("关联领域:{}", idea.tags.join("")));
}
if scores.impact >= 7.0 {
evidence.push("业务价值显著,影响面较广".to_string());
}
let title = &idea.title; // 正方置信度:可行性+影响力等权折算到 [0.1, 0.95],满分≈0.95 留质疑余地
let desc = &idea.description; let confidence =
((scores.feasibility * 0.5 + scores.impact * 0.5) / 10.0).clamp(0.1, 0.95);
let reasoning = vec![
format!("可行性评分 {:.1}/10路径相对清晰", scores.feasibility),
format!("影响力评分 {:.1}/10预期回报可观", scores.impact),
"整体风险可控,适合推进".to_string(),
];
Ok(Argument { Ok(Argument {
thesis: format!("{} 具有很高的价值可行性,应该优先执行", title), thesis: format!("{}」具备明确价值可行性,建议优先推进", idea.title),
evidence: vec![ evidence,
format!("满足业务需求:{}", desc), reasoning,
"投入产出比高".to_string(), confidence,
"技术实现可行".to_string(),
"时间窗口合适".to_string(),
],
reasoning: vec![
"能够解决现有痛点".to_string(),
"竞争优势明显".to_string(),
"风险可控".to_string(),
],
confidence: 0.75,
}) })
} }
/// 生成反方观点(反对或谨慎) /// 生成反方观点(反对或谨慎)— 论点基于想法实际缺陷confidence 随风险上升
async fn generate_negative_argument(idea: &Idea, positive: &Argument) -> Result<CounterArgument> { fn generate_negative_argument(idea: &Idea, scores: &IdeaScores) -> Result<Argument> {
// TODO: 接入 AI 生成反方观点,考虑正方观点 let desc = idea.description.trim();
let mut evidence = Vec::new();
if desc.is_empty() {
evidence.push("描述过于简略,需求边界不清".to_string());
} else if desc.chars().count() < 50 {
evidence.push("描述偏短,实现细节尚未论证".to_string());
}
if idea.tags.is_empty() {
evidence.push("缺少标签,影响范围未界定".to_string());
}
if scores.feasibility < 6.0 {
evidence.push(format!("可行性 {:.1}/10 偏低,实现路径存疑", scores.feasibility));
}
if matches!(idea.priority, Priority::Low) {
evidence.push("优先级偏低,可能非当前关键路径".to_string());
}
if evidence.is_empty() {
evidence.push("机会成本需权衡,可能存在更优替代方案".to_string());
}
let title = &idea.title; // 反方强度:feasibility 每降 1 分 +0.04,impact 每降 1 分 +0.03,基线 0.25(满分也保留最低质疑),clamp [0.1, 0.9]
let confidence = ((10.0 - scores.feasibility) * 0.04 + (10.0 - scores.impact) * 0.03 + 0.25)
.clamp(0.1, 0.9);
Ok(CounterArgument { let reasoning = vec![
thesis: format!("{} 需要谨慎评估,存在一定风险", title), format!("资源投入与当前综合评分 {:.1} 需匹配", scores.overall),
evidence: vec![ "ROI 需进一步验证".to_string(),
"资源投入较大".to_string(), "需评估是否存在更优解".to_string(),
"市场不确定性高".to_string(), ];
"技术挑战存在".to_string(),
"机会成本高".to_string(), Ok(Argument {
], thesis: format!("「{}」需谨慎评估,存在风险与机会成本", idea.title),
reasoning: vec![ evidence,
"ROI 需要进一步验证".to_string(), reasoning,
"优先级可能过高".to_string(), confidence,
"存在更优替代方案".to_string(),
],
confidence: 0.65,
}) })
} }
/// AI 分析师综合分析 /// AI 分析师综合分析 — 评估等级由综合评分决定,优势/劣势按维度动态生成
async fn analyst_analysis( fn analyst_analysis(idea: &Idea, scores: &IdeaScores) -> Result<AnalystAnalysis> {
idea: &Idea, let final_assessment = match scores.overall {
positive: &Argument, x if x >= 7.5 => AssessmentLevel::StrongGo,
negative: &CounterArgument, x if x >= 6.0 => AssessmentLevel::Recommended,
) -> Result<AnalystAnalysis> { x if x >= 4.5 => AssessmentLevel::Conditional,
// TODO: 接入 AI 进行深度分析 x if x >= 3.0 => AssessmentLevel::Revised,
_ => AssessmentLevel::Defer,
};
let positive_strengths = vec![ let mut strengths = Vec::new();
"方向正确,符合业务战略".to_string(), if scores.impact >= 6.0 {
"技术创新性较强".to_string(), strengths.push("业务价值明确".to_string());
"用户价值明确".to_string(), }
]; if scores.feasibility >= 6.0 {
strengths.push("技术路径清晰".to_string());
}
if scores.urgency >= 7.0 {
strengths.push("时间窗口合适".to_string());
}
if strengths.is_empty() {
strengths.push("方向值得探索".to_string());
}
let weaknesses = vec![ let mut weaknesses = Vec::new();
"资源需求评估不足".to_string(), if scores.feasibility < 6.0 {
"风险控制需要加强".to_string(), weaknesses.push("可行性论证不足".to_string());
"时间规划可能过于乐观".to_string(), }
]; if idea.description.trim().is_empty() {
weaknesses.push("需求描述缺失".to_string());
}
if scores.urgency < 4.0 {
weaknesses.push("紧急度偏低,易被搁置".to_string());
}
if weaknesses.is_empty() {
weaknesses.push("资源需求待评估".to_string());
}
// 启发式占位:固定风险模板,与具体想法无关,接 LLM 后改动态生成
let risks = vec![ let risks = vec![
"技术实现难度超出预期".to_string(), "技术实现难度可能超出预期".to_string(),
"市场竞争加剧".to_string(), "优先级与资源争夺".to_string(),
"用户接受度不确定".to_string(), "需求范围蔓延".to_string(),
]; ];
let opportunities = vec![ let opportunities = vec![
"可能形成新的竞争优势".to_string(), "可能形成可复用能力".to_string(),
"技术积累价值显著".to_string(), "积累技术资产".to_string(),
"市场机会窗口良好".to_string(),
]; ];
// 基于正反方观点的强度计算 let summary = format!(
let positive_strength = positive.confidence; "{}」综合评分 {:.1}/10{}。建议{}",
let negative_strength = negative.confidence; idea.title,
let net_positive = (positive_strength - negative_strength + 1.0) / 2.0; scores.overall,
assessment_desc(&final_assessment),
let final_assessment = if net_positive > 0.7 { action_hint(&final_assessment)
AssessmentLevel::StrongGo );
} else if net_positive > 0.5 {
AssessmentLevel::Recommended
} else if net_positive > 0.3 {
AssessmentLevel::Conditional
} else if net_positive > 0.1 {
AssessmentLevel::Revised
} else {
AssessmentLevel::Defer
};
Ok(AnalystAnalysis { Ok(AnalystAnalysis {
summary: format!( summary,
"该想法整体价值评估中等偏上,建议在有条件的情况下执行。主要价值在于{},需要关注{}。", strengths,
idea.title,
if net_positive > 0.5 { "风险控制" } else { "价值验证" }
),
strengths: positive_strengths,
weaknesses, weaknesses,
risks, risks,
opportunities, opportunities,
@@ -208,101 +233,148 @@ impl AdversarialEngine {
}) })
} }
/// 计算最终评估分数和建议 /// 评估等级 → 最终建议
fn compute_final_assessment(analyst: &AnalystAnalysis) -> (f64, Recommendation) { fn recommendation_for(level: &AssessmentLevel) -> Recommendation {
// 基于评估等级映射分数 match level {
let base_score = match analyst.final_assessment {
AssessmentLevel::StrongGo => 8.5,
AssessmentLevel::Recommended => 7.0,
AssessmentLevel::Conditional => 5.5,
AssessmentLevel::Revised => 4.0,
AssessmentLevel::Defer => 2.5,
AssessmentLevel::Reject => 1.0,
};
// 根据优劣势微调分数
let strength_count = analyst.strengths.len() as f64;
let weakness_count = analyst.weaknesses.len() as f64;
let score_adjustment = (strength_count - weakness_count) * 0.3;
let final_score = (base_score + score_adjustment).clamp(0.0, 10.0);
let recommendation = match analyst.final_assessment {
AssessmentLevel::StrongGo => Recommendation::ImmediateAction, AssessmentLevel::StrongGo => Recommendation::ImmediateAction,
AssessmentLevel::Recommended => Recommendation::Soon, AssessmentLevel::Recommended => Recommendation::Soon,
AssessmentLevel::Conditional => Recommendation::WithResources, AssessmentLevel::Conditional => Recommendation::WithResources,
AssessmentLevel::Revised => Recommendation::ResearchMore, AssessmentLevel::Revised => Recommendation::ResearchMore,
AssessmentLevel::Defer => Recommendation::Monitor, AssessmentLevel::Defer => Recommendation::Monitor,
AssessmentLevel::Reject => Recommendation::Cancel,
};
(final_score, recommendation)
}
}
/// 评估结果展示格式
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EvalDisplay {
pub idea_title: String,
pub positive_strength: f64,
pub negative_strength: f64,
pub net_sentiment: f64, // -1 到 1正为正面
pub assessment_level: String,
pub key_takeaways: Vec<String>,
pub action_items: Vec<String>,
}
impl From<AdversarialEval> for EvalDisplay {
fn from(eval: AdversarialEval) -> Self {
let net_sentiment = (eval.positive.confidence - eval.negative.confidence) as f64;
let key_takeaways = vec![
format!("优势:{}", eval.analyst.strengths.join("")),
format!("风险:{}", eval.analyst.risks.join("")),
format!("建议:{:?}", eval.recommendation),
];
let action_items = match eval.recommendation {
Recommendation::ImmediateAction => vec![
"立即组建项目团队".to_string(),
"制定详细执行计划".to_string(),
"分配必要资源".to_string(),
],
Recommendation::Soon => vec![
"下周启动项目".to_string(),
"准备资源需求".to_string(),
"制定时间表".to_string(),
],
Recommendation::WithResources => vec![
"确认资源预算".to_string(),
"评估ROI".to_string(),
"制定风险预案".to_string(),
],
Recommendation::ResearchMore => vec![
"进行市场调研".to_string(),
"收集用户反馈".to_string(),
"验证技术可行性".to_string(),
],
Recommendation::Monitor => vec![
"持续跟踪相关指标".to_string(),
"定期评估进展".to_string(),
"等待更好的时机".to_string(),
],
Recommendation::Cancel => vec![
"记录归档原因".to_string(),
"释放相关资源".to_string(),
"提取经验教训".to_string(),
],
};
EvalDisplay {
idea_title: eval.positive.thesis.split(' ').take(3).collect::<Vec<_>>().join(" "),
positive_strength: eval.positive.confidence,
negative_strength: eval.negative.confidence,
net_sentiment,
assessment_level: format!("{:?}", eval.analyst.final_assessment),
key_takeaways,
action_items,
} }
} }
} }
fn priority_label(p: &Priority) -> &'static str {
match p {
Priority::Critical => "紧急",
Priority::High => "",
Priority::Medium => "",
Priority::Low => "",
}
}
fn assessment_desc(level: &AssessmentLevel) -> &'static str {
match level {
AssessmentLevel::StrongGo => "价值高且可行性强",
AssessmentLevel::Recommended => "整体值得推进",
AssessmentLevel::Conditional => "有条件地推进",
AssessmentLevel::Revised => "需调整后再评估",
AssessmentLevel::Defer => "建议暂缓",
}
}
fn action_hint(level: &AssessmentLevel) -> &'static str {
match level {
AssessmentLevel::StrongGo => "立即立项启动",
AssessmentLevel::Recommended => "尽快排期",
AssessmentLevel::Conditional => "配置资源后启动",
AssessmentLevel::Revised => "补充信息后重新评估",
AssessmentLevel::Defer => "持续观察时机",
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::capture::Idea;
use crate::scoring::ScoringEngine;
use df_core::types::{IdeaStatus, Priority};
fn make_idea(title: &str, desc: &str, priority: Priority, tags: Vec<&str>) -> Idea {
Idea {
id: "test-id".to_string(),
title: title.to_string(),
description: desc.to_string(),
status: IdeaStatus::Draft,
priority,
scores: None,
tags: tags.into_iter().map(String::from).collect(),
source: None,
related_ids: Vec::new(),
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
}
}
#[tokio::test]
async fn a1_high_score_immediate_action() {
let desc = "面向用户的核心功能,带来显著增长,大幅提升效率。集成成熟方案,复用已有组件。".repeat(3);
let idea = make_idea("AI增长引擎", &desc, Priority::Critical, vec!["增长", "核心"]);
let scores = ScoringEngine::compute_default(&idea);
let eval = AdversarialEngine::evaluate(&idea).await.unwrap();
println!("\n[a1] 高分想法 → 期望 ImmediateAction");
println!(" scores: feas={:.2} impact={:.2} urg={:.2} overall={:.2}", scores.feasibility, scores.impact, scores.urgency, scores.overall);
println!(" eval: final_score={:.2} recommendation={:?}", eval.final_score, eval.recommendation);
println!(" 正方 confidence={:.2} 反方 confidence={:.2}", eval.positive.confidence, eval.negative.confidence);
assert!(eval.final_score >= 7.5, "final_score 应≥7.5, 实际 {:.2}", eval.final_score);
assert_eq!(eval.recommendation, Recommendation::ImmediateAction);
}
#[tokio::test]
async fn a2_mid_score_soon() {
let desc = "面向用户的功能,集成已有方案,提升体验".to_string();
let idea = make_idea("体验优化", &desc, Priority::Medium, vec!["体验"]);
let scores = ScoringEngine::compute_default(&idea);
let eval = AdversarialEngine::evaluate(&idea).await.unwrap();
println!("\n[a2] 中分想法 → 期望 Soon");
println!(" scores overall={:.2} eval final_score={:.2} recommendation={:?}", scores.overall, eval.final_score, eval.recommendation);
assert_eq!(eval.recommendation, Recommendation::Soon);
}
#[tokio::test]
async fn a3_low_score_monitor() {
let desc = "重构迁移大规模分布式重写从零全新架构高并发底层".to_string();
let idea = make_idea("过度工程", &desc, Priority::Low, vec![]);
let scores = ScoringEngine::compute_default(&idea);
let eval = AdversarialEngine::evaluate(&idea).await.unwrap();
println!("\n[a3] 低分想法 → 期望 Monitor");
println!(" scores overall={:.2} eval final_score={:.2} recommendation={:?}", scores.overall, eval.final_score, eval.recommendation);
assert!(eval.final_score < 3.0, "final_score 应<3.0, 实际 {:.2}", eval.final_score);
assert_eq!(eval.recommendation, Recommendation::Monitor);
}
#[tokio::test]
async fn a4_confidence_ranges() {
let idea = make_idea("普通想法", "一般描述", Priority::Medium, vec!["标签"]);
let eval = AdversarialEngine::evaluate(&idea).await.unwrap();
println!("\n[a4] confidence 区间校验");
println!(" 正方={:.2} (应∈[0.1, 0.95]) 反方={:.2} (应∈[0.1, 0.9])", eval.positive.confidence, eval.negative.confidence);
assert!(eval.positive.confidence >= 0.1 && eval.positive.confidence <= 0.95);
assert!(eval.negative.confidence >= 0.1 && eval.negative.confidence <= 0.9);
}
#[tokio::test]
async fn a5_positive_thesis_contains_title() {
let idea = make_idea("独家创意", "描述内容", Priority::High, vec![]);
let eval = AdversarialEngine::evaluate(&idea).await.unwrap();
println!("\n[a5] 正方论点含标题");
println!(" thesis: {}", eval.positive.thesis);
assert!(eval.positive.thesis.contains("独家创意"), "正方 thesis 应含标题");
}
#[tokio::test]
async fn a6_negative_evidence_nonempty() {
let idea = make_idea("待质疑想法", "", Priority::Low, vec![]);
let eval = AdversarialEngine::evaluate(&idea).await.unwrap();
println!("\n[a6] 反方证据非空 ({} 条)", eval.negative.evidence.len());
for (i, e) in eval.negative.evidence.iter().enumerate() {
println!(" 证据{}: {}", i + 1, e);
}
assert!(!eval.negative.evidence.is_empty(), "反方 evidence 不应为空");
}
#[tokio::test]
async fn a7_final_score_consistency() {
let desc = "面向用户的核心功能".to_string();
let idea = make_idea("一致性测试", &desc, Priority::High, vec!["核心"]);
let scores = ScoringEngine::compute_default(&idea);
let eval = AdversarialEngine::evaluate(&idea).await.unwrap();
println!("\n[a7] final_score == scores.overall 一致性");
println!(" scores.overall={:.2} eval.final_score={:.2}", scores.overall, eval.final_score);
println!(" analyst.summary: {}", eval.analyst.summary);
assert!((eval.final_score - scores.overall).abs() < 0.001, "final_score 应等于 overall");
assert!(eval.analyst.summary.contains("一致性测试"), "summary 应含标题");
}
}

View File

@@ -1,79 +0,0 @@
//! 想法评估器 — 对想法进行多维度评估
use anyhow::Result;
use df_core::types::IdeaId;
use crate::adversarial::{AdversarialEngine, AdversarialEval};
use crate::capture::Idea;
use crate::scoring::IdeaScores;
/// 评估维度
#[derive(Debug, Clone, Copy)]
pub enum EvalDimension {
/// 可行性
Feasibility,
/// 影响力
Impact,
/// 紧急度
Urgency,
}
/// 评估结果
#[derive(Debug, Clone)]
pub struct EvalResult {
pub idea_id: IdeaId,
pub scores: IdeaScores,
pub recommendation: Recommendation,
pub comments: Vec<String>,
}
/// 评估建议
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Recommendation {
/// 强烈推荐立即执行
StrongApprove,
/// 推荐执行
Approve,
/// 需要更多信息
NeedsInfo,
/// 建议推迟
Defer,
/// 不推荐
Reject,
}
/// 想法评估器
pub struct IdeaEvaluator;
impl IdeaEvaluator {
/// 评估一个想法 - 使用对抗式评估
pub async fn evaluate_adversarial(idea: &Idea) -> Result<AdversarialEval> {
AdversarialEngine::evaluate(idea).await
}
/// 评估一个想法 - 保持向后兼容
pub fn evaluate(idea: &Idea) -> Result<EvalResult> {
// 使用简单评分作为后备
let scores = crate::scoring::ScoringEngine::compute_default(idea);
let recommendation = if scores.overall >= 8.0 {
Recommendation::StrongApprove
} else if scores.overall >= 6.0 {
Recommendation::Approve
} else if scores.overall >= 4.0 {
Recommendation::NeedsInfo
} else if scores.overall >= 2.0 {
Recommendation::Defer
} else {
Recommendation::Reject
};
Ok(EvalResult {
idea_id: idea.id.clone(),
scores,
recommendation,
comments: Vec::new(),
})
}
}

View File

@@ -1,76 +0,0 @@
//! 想法关联图 — 管理想法之间的关系
use std::collections::HashMap;
use df_core::types::IdeaId;
/// 想法之间的关系类型
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RelationKind {
/// 相似(语义相近)
Similar,
/// 依赖A 依赖 B
DependsOn,
/// 衍生A 衍生自 B
DerivedFrom,
/// 互补A 和 B 可以互补)
Complementary,
}
/// 想法关系边
#[derive(Debug, Clone)]
pub struct Relation {
pub source_id: IdeaId,
pub target_id: IdeaId,
pub kind: RelationKind,
pub strength: f64, // 0.0 ~ 1.0
}
/// 想法关联图
pub struct IdeaGraph {
/// 邻接表idea_id -> 相关关系列表)
edges: HashMap<IdeaId, Vec<Relation>>,
}
impl IdeaGraph {
/// 创建空图
pub fn new() -> Self {
Self {
edges: HashMap::new(),
}
}
/// 添加关系
pub fn add_relation(&mut self, source_id: IdeaId, target_id: IdeaId, kind: RelationKind, strength: f64) {
let relation = Relation {
source_id: source_id.clone(),
target_id: target_id.clone(),
kind,
strength,
};
self.edges.entry(source_id).or_default().push(relation.clone());
self.edges.entry(target_id).or_default().push(relation);
}
/// 获取与指定想法相关的所有关系
pub fn get_relations(&self, idea_id: &IdeaId) -> Vec<&Relation> {
self.edges.get(idea_id).map(|r| r.iter().collect()).unwrap_or_default()
}
/// 查找相似想法
pub fn find_similar(&self, idea_id: &IdeaId) -> Vec<&Relation> {
self.get_relations(idea_id)
.into_iter()
.filter(|r| r.kind == RelationKind::Similar)
.collect()
}
// TODO: 基于向量相似度的自动关联发现
// TODO: 图遍历、聚类算法
}
impl Default for IdeaGraph {
fn default() -> Self {
Self::new()
}
}

View File

@@ -1,8 +1,6 @@
//! df-ideas: 想法池 — 捕获、评估、评分、关联图、晋升 //! df-ideas: 想法池 — 捕获、评估、评分、晋升
pub mod adversarial; pub mod adversarial;
pub mod capture; pub mod capture;
pub mod evaluator;
pub mod graph;
pub mod promotion; pub mod promotion;
pub mod scoring; pub mod scoring;

View File

@@ -1,14 +1,15 @@
//! 想法晋升 — 将想法转为项目 //! 想法晋升 — 将想法转为项目
use anyhow::Result; use anyhow::Result;
use serde::Serialize;
use df_core::types::{IdeaId, ProjectId}; use df_core::types::{IdeaId, ProjectId};
use crate::adversarial::Recommendation;
use crate::capture::Idea; use crate::capture::Idea;
use crate::evaluator::Recommendation;
/// 晋升结果 /// 晋升结果
#[derive(Debug, Clone)] #[derive(Debug, Clone, Serialize)]
pub struct PromotionResult { pub struct PromotionResult {
pub idea_id: IdeaId, pub idea_id: IdeaId,
pub project_id: ProjectId, pub project_id: ProjectId,
@@ -44,7 +45,7 @@ impl IdeaPromoter {
pub fn try_promote(&self, idea: &Idea, recommendation: &Recommendation) -> Result<PromotionResult> { pub fn try_promote(&self, idea: &Idea, recommendation: &Recommendation) -> Result<PromotionResult> {
match self.policy { match self.policy {
PromotionPolicy::Auto => { PromotionPolicy::Auto => {
if matches!(recommendation, Recommendation::StrongApprove | Recommendation::Approve) { if matches!(recommendation, Recommendation::ImmediateAction | Recommendation::Soon) {
self.do_promote(idea) self.do_promote(idea)
} else { } else {
Ok(PromotionResult { Ok(PromotionResult {

View File

@@ -1,10 +1,16 @@
//! 评分引擎 — 多维度加权评分 //! 评分引擎 — 基于想法内容的多维度启发式评分
//!
//! 各维度分数均为 0-10IPC 层会 *10 缩放为 0-100 以匹配前端)。
//! 启发式依据:优先级、描述充实度、标签、关键词信号——保证稳定且有区分度。
//! TODO: 接入 AI 做语义级深度评分。
use crate::capture::Idea; use crate::capture::Idea;
/// 想法评分详情(重新导出 capture 模块中的定义) /// 想法评分详情(重新导出 capture 模块中的定义)
pub use crate::capture::IdeaScores; pub use crate::capture::IdeaScores;
use df_core::types::Priority;
/// 评分权重配置 /// 评分权重配置
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ScoringWeights { pub struct ScoringWeights {
@@ -28,17 +34,12 @@ pub struct ScoringEngine;
impl ScoringEngine { impl ScoringEngine {
/// 使用默认权重计算评分 /// 使用默认权重计算评分
///
/// TODO: 接入 AI 进行深度评分,当前返回基于启发式的分数
pub fn compute_default(idea: &Idea) -> IdeaScores { pub fn compute_default(idea: &Idea) -> IdeaScores {
let weights = ScoringWeights::default(); Self::compute(idea, &ScoringWeights::default())
Self::compute(idea, &weights)
} }
/// 使用指定权重计算评分 /// 使用指定权重计算评分
pub fn compute(idea: &Idea, weights: &ScoringWeights) -> IdeaScores { pub fn compute(idea: &Idea, weights: &ScoringWeights) -> IdeaScores {
// TODO: 基于想法内容、历史数据、AI 分析等多维度评分
// 当前使用基于启发式的占位评分
let feasibility = Self::heuristic_feasibility(idea); let feasibility = Self::heuristic_feasibility(idea);
let impact = Self::heuristic_impact(idea); let impact = Self::heuristic_impact(idea);
let urgency = Self::heuristic_urgency(idea); let urgency = Self::heuristic_urgency(idea);
@@ -55,21 +56,181 @@ impl ScoringEngine {
} }
} }
/// 启发式可行性评分 /// 启发式可行性评分(描述充实度 + 技术/资源信号词)
fn heuristic_feasibility(_idea: &Idea) -> f64 { fn heuristic_feasibility(idea: &Idea) -> f64 {
// TODO: 基于描述复杂度、资源需求等评估 let mut score = 5.0_f64;
5.0 let desc = idea.description.trim();
if !desc.is_empty() {
score += 1.5;
}
let len = desc.chars().count();
if (50..=500).contains(&len) {
score += 1.0;
} else if len > 500 {
// 过长描述通常意味着实现复杂度上升
score -= 0.5;
}
// 可行性正向信号
let pos = count_any(desc, &[
"复用", "已有", "简单", "集成", "支持", "成熟", "基于", "现成", "脚手架", "模板",
]);
score += (pos as f64) * 0.5;
// 复杂度负向信号
let neg = count_any(desc, &[
"重构", "迁移", "大规模", "分布式", "重写", "从零", "全新架构", "高并发", "底层",
]);
score -= (neg as f64) * 0.6;
score.clamp(0.0, 10.0)
} }
/// 启发式影响力评分 /// 启发式影响力评分(优先级 + 价值信号词 + 标签广度)
fn heuristic_impact(_idea: &Idea) -> f64 { fn heuristic_impact(idea: &Idea) -> f64 {
// TODO: 基于业务价值、用户影响等评估 let mut score = match idea.priority {
5.0 Priority::Critical => 8.0,
Priority::High => 6.5,
Priority::Medium => 5.0,
Priority::Low => 3.5,
};
if !idea.tags.is_empty() {
score += 0.5;
// 标签越多影响面越广,上限 +1.0
score += (idea.tags.len().min(4) as f64) * 0.25;
}
let desc = idea.description.trim();
let value_hits = count_any(desc, &[
"用户", "增长", "收入", "效率", "体验", "核心", "关键", "痛点", "竞品", "留存",
]);
score += (value_hits as f64) * 0.5;
if desc.chars().count() > 100 {
score += 0.5;
}
score.clamp(0.0, 10.0)
} }
/// 启发式紧急度评分 /// 启发式紧急度评分(优先级 + 时效信号词)
fn heuristic_urgency(_idea: &Idea) -> f64 { fn heuristic_urgency(idea: &Idea) -> f64 {
// TODO: 基于优先级、时间窗口等评估 let mut score = match idea.priority {
5.0 Priority::Critical => 9.0,
Priority::High => 7.0,
Priority::Medium => 5.0,
Priority::Low => 3.0,
};
let desc = idea.description.trim();
let time_hits = count_any(desc, &[
"立即", "马上", "紧急", "尽快", "本周", "上线", "deadline", "截止", "先行", "阻塞",
]);
score += (time_hits as f64) * 0.5;
score.clamp(0.0, 10.0)
}
}
/// 统计 text 中命中任一关键词的数量(小写匹配,兼顾中英文)
/// 局限:纯子串匹配,不识别"不复用""无用户增长"等否定前缀,接 LLM 后由语义层修正
fn count_any(text: &str, keywords: &[&str]) -> usize {
let lower = text.to_lowercase();
keywords.iter().filter(|kw| lower.contains(*kw)).count()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::capture::Idea;
use df_core::types::{IdeaStatus, Priority};
/// 辅助工厂:构造测试用 Idea时间/ID 用默认值,不影响评分)
fn make_idea(title: &str, desc: &str, priority: Priority, tags: Vec<&str>) -> Idea {
Idea {
id: "test-id".to_string(),
title: title.to_string(),
description: desc.to_string(),
status: IdeaStatus::Draft,
priority,
scores: None,
tags: tags.into_iter().map(String::from).collect(),
source: None,
related_ids: Vec::new(),
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
}
}
#[test]
fn s1_empty_idea_baseline() {
let idea = make_idea("测试想法", "", Priority::Medium, vec![]);
let s = ScoringEngine::compute_default(&idea);
println!("\n[s1] 空想法 (Medium / 无描述 / 无标签)");
println!(" 可行性={:.2} 影响力={:.2} 紧急度={:.2} 综合={:.2}", s.feasibility, s.impact, s.urgency, s.overall);
assert!((s.overall - 5.0).abs() < 0.01, "空想法 overall 应为 5.0, 实际 {:.2}", s.overall);
}
#[test]
fn s2_high_priority_value_desc() {
let desc = "面向用户的核心功能,带来显著增长,大幅提升效率。集成成熟方案,复用已有组件,快速交付价值。".repeat(3);
let idea = make_idea("增长引擎", &desc, Priority::Critical, vec!["增长", "核心"]);
let s = ScoringEngine::compute_default(&idea);
println!("\n[s2] 高优先级 + 价值描述 (Critical / ~120字 / 含价值词)");
println!(" 可行性={:.2} 影响力={:.2} 紧急度={:.2} 综合={:.2}", s.feasibility, s.impact, s.urgency, s.overall);
assert!(s.impact >= 7.0, "impact 应≥7.0, 实际 {:.2}", s.impact);
assert!(s.urgency >= 8.0, "urgency 应≥8.0, 实际 {:.2}", s.urgency);
}
#[test]
fn s3_low_priority_short_desc() {
let idea = make_idea("小优化", "一句话", Priority::Low, vec![]);
let s = ScoringEngine::compute_default(&idea);
println!("\n[s3] 低优先级 + 短描述 (Low / 3字)");
println!(" 可行性={:.2} 影响力={:.2} 紧急度={:.2} 综合={:.2}", s.feasibility, s.impact, s.urgency, s.overall);
assert!((s.overall - 4.6).abs() < 0.01, "overall 应为 4.6, 实际 {:.2}", s.overall);
}
#[test]
fn s4_feasibility_positive_signals() {
let idea = make_idea("复用方案", "复用已有组件,简单集成现成脚手架", Priority::Medium, vec![]);
let s = ScoringEngine::compute_default(&idea);
println!("\n[s4] 可行性正向信号 (含 复用/已有/简单/集成/现成/脚手架)");
println!(" 可行性={:.2} (预期 9.5)", s.feasibility);
assert!((s.feasibility - 9.5).abs() < 0.01, "正向信号 feasibility 应为 9.5, 实际 {:.2}", s.feasibility);
}
#[test]
fn s5_feasibility_negative_signals() {
let idea = make_idea("大重构", "大规模重构迁移,分布式重写从零开始", Priority::Medium, vec![]);
let s = ScoringEngine::compute_default(&idea);
println!("\n[s5] 可行性负向信号 (含 大规模/重构/迁移/分布式/重写/从零)");
println!(" 可行性={:.2} (预期 ≤5.0)", s.feasibility);
assert!(s.feasibility <= 5.0, "负向信号 feasibility 应≤5.0, 实际 {:.2}", s.feasibility);
}
#[test]
fn s6_custom_weights() {
let idea = make_idea("高可行低影响", "复用已有简单集成现成", Priority::Low, vec![]);
let custom = ScoringWeights { feasibility: 0.7, impact: 0.2, urgency: 0.1 };
let s_custom = ScoringEngine::compute(&idea, &custom);
let s_default = ScoringEngine::compute_default(&idea);
let manual = s_custom.feasibility * 0.7 + s_custom.impact * 0.2 + s_custom.urgency * 0.1;
println!("\n[s6] 自定义权重 (feas:0.7 / impact:0.2 / urg:0.1)");
println!(" 自定义综合={:.2} 默认综合={:.2} 手算加权={:.2}", s_custom.overall, s_default.overall, manual);
assert!((s_custom.overall - manual).abs() < 0.01, "overall 应等于手算加权");
assert!(s_custom.overall > s_default.overall, "高 feas 配高权重应让综合更高");
}
#[test]
fn s7_clamp_upper_bound() {
let desc = "复用已有简单集成现成成熟基于脚手架模板支持".repeat(20);
let idea = make_idea("满分想法", &desc, Priority::Critical, vec!["a", "b", "c", "d"]);
let s = ScoringEngine::compute_default(&idea);
println!("\n[s7] clamp 上限 (堆正向词 + 超长描述 + Critical)");
println!(" 可行性={:.2} 影响力={:.2} 紧急度={:.2} 综合={:.2}", s.feasibility, s.impact, s.urgency, s.overall);
assert!(s.feasibility <= 10.0 && s.impact <= 10.0 && s.urgency <= 10.0, "所有维度应≤10");
}
#[test]
fn s8_clamp_lower_bound() {
let desc = "重构迁移大规模分布式重写从零全新架构高并发底层".repeat(20);
let idea = make_idea("灾难想法", &desc, Priority::Low, vec![]);
let s = ScoringEngine::compute_default(&idea);
println!("\n[s8] clamp 下限 (堆负向词 + Low)");
println!(" 可行性={:.2} 影响力={:.2} 紧急度={:.2} 综合={:.2}", s.feasibility, s.impact, s.urgency, s.overall);
assert!(s.feasibility >= 0.0 && s.impact >= 0.0 && s.urgency >= 0.0, "所有维度应≥0");
} }
} }

View File

@@ -6,12 +6,104 @@
//! 与 AI Chat侧边栏交互对话的区别AI Node 由 DAG Executor 自动驱动, //! 与 AI Chat侧边栏交互对话的区别AI Node 由 DAG Executor 自动驱动,
//! 适合嵌入自动化链路(如 想法 → AI分析 → 脚本落地 → 人工审批)。 //! 适合嵌入自动化链路(如 想法 → AI分析 → 脚本落地 → 人工审批)。
use std::collections::HashMap;
use async_trait::async_trait; use async_trait::async_trait;
use df_ai::anthropic_compat::AnthropicCompatProvider;
use df_ai::openai_compat::OpenAICompatProvider;
use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider}; use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider};
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema}; use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
/// AI 节点解析后的参数execute 与参数解析解耦,便于单测覆盖取值/默认/校验逻辑)
#[derive(Debug)]
struct AiNodeParams {
base_url: String,
api_key: String,
prompt: String,
system_prompt: Option<String>,
model: String,
temperature: Option<f32>,
max_tokens: Option<u32>,
/// 协议类型openai_compat默认/ anthropicGLM 订阅 / Claude 官方)
protocol: String,
/// model 为空时的占位,避免 provider 构造 panic
default_model: String,
}
/// 从节点 config + 上游输入解析 AI 节点参数
///
/// prompt 取值优先级:上游 `inputs["prompt"]` > `config.prompt`,两者皆无则报错。
/// model 为空时 default_model 兜底为 "gpt-4o-mini"。protocol 默认 openai_compat。
fn parse_params(
config: &serde_json::Value,
inputs: &HashMap<String, NodeOutput>,
) -> anyhow::Result<AiNodeParams> {
// ── provider 配置(必填)──
let base_url = config
.get("base_url")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("AiNode 缺少必填参数: base_url"))?
.to_string();
let api_key = config
.get("api_key")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("AiNode 缺少必填参数: api_key"))?
.to_string();
// ── prompt必填优先取上游节点 "prompt" 输出,回退 config.prompt ──
let prompt = inputs
.get("prompt")
.and_then(|o| o.data.as_str())
.map(|s| s.to_string())
.or_else(|| {
config
.get("prompt")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
})
.ok_or_else(|| anyhow::anyhow!("AiNode 缺少必填参数: promptconfig 或上游输入均无)"))?;
// ── 可选参数 ──
let model = config
.get("model")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let temperature = config
.get("temperature")
.and_then(|v| v.as_f64())
.map(|f| f as f32);
let max_tokens = config
.get("max_tokens")
.and_then(|v| v.as_u64())
.map(|n| n as u32);
let system_prompt = config
.get("system_prompt")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let protocol = config
.get("protocol")
.and_then(|v| v.as_str())
.unwrap_or("openai_compat")
.to_string();
// default_model留空时给一个占位避免 provider 构造 panic
let default_model = if model.is_empty() {
"gpt-4o-mini".to_string()
} else {
model.clone()
};
Ok(AiNodeParams {
base_url,
api_key,
prompt,
system_prompt,
model,
temperature,
max_tokens,
protocol,
default_model,
})
}
/// AI 节点 /// AI 节点
pub struct AiNode; pub struct AiNode;
@@ -20,90 +112,23 @@ impl Node for AiNode {
async fn execute(&self, ctx: NodeContext) -> NodeResult { async fn execute(&self, ctx: NodeContext) -> NodeResult {
tracing::info!("AiNode 执行: node_id={}", ctx.node_id); tracing::info!("AiNode 执行: node_id={}", ctx.node_id);
// ── provider 配置(必填)── let p = parse_params(&ctx.config, &ctx.inputs)?;
let base_url = ctx
.config
.get("base_url")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("AiNode 缺少必填参数: base_url"))?
.to_string();
let api_key = ctx let provider: Box<dyn LlmProvider> =
.config df_ai::build_provider(&p.protocol, &p.base_url, &p.api_key, &p.default_model);
.get("api_key")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("AiNode 缺少必填参数: api_key"))?
.to_string();
// ── prompt必填优先取上游节点 "prompt" 输出,回退 config.prompt ──
let prompt = ctx
.inputs
.get("prompt")
.and_then(|o| o.data.as_str())
.map(|s| s.to_string())
.or_else(|| {
ctx.config
.get("prompt")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
})
.ok_or_else(|| anyhow::anyhow!("AiNode 缺少必填参数: promptconfig 或上游输入均无)"))?;
// ── 可选参数 ──
let model = ctx
.config
.get("model")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let temperature = ctx
.config
.get("temperature")
.and_then(|v| v.as_f64())
.map(|f| f as f32);
let max_tokens = ctx
.config
.get("max_tokens")
.and_then(|v| v.as_u64())
.map(|n| n as u32);
let system_prompt = ctx
.config
.get("system_prompt")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
// 协议类型:默认 openai_compat可设 anthropicGLM 订阅端点 / Claude 官方)
let protocol = ctx
.config
.get("protocol")
.and_then(|v| v.as_str())
.unwrap_or("openai_compat")
.to_string();
// default_model留空时给一个占位避免 provider 构造 panic
let default_model = if model.is_empty() {
"gpt-4o-mini".to_string()
} else {
model.clone()
};
let provider: Box<dyn LlmProvider> = match protocol.as_str() {
"anthropic" => Box::new(AnthropicCompatProvider::new(&base_url, &api_key, &default_model)),
_ => Box::new(OpenAICompatProvider::new(&base_url, &api_key, &default_model)),
};
// ── 构建消息 ── // ── 构建消息 ──
let mut messages = Vec::with_capacity(2); let mut messages = Vec::with_capacity(2);
if let Some(sys) = system_prompt { if let Some(sys) = p.system_prompt {
messages.push(ChatMessage::system(sys)); messages.push(ChatMessage::system(sys));
} }
messages.push(ChatMessage::user(prompt)); messages.push(ChatMessage::user(p.prompt));
let request = CompletionRequest { let request = CompletionRequest {
model: model.clone(), model: p.model.clone(),
messages, messages,
temperature, temperature: p.temperature,
max_tokens, max_tokens: p.max_tokens,
stream: false, stream: false,
tools: None, tools: None,
tool_choice: None, tool_choice: None,
@@ -111,8 +136,8 @@ impl Node for AiNode {
tracing::info!( tracing::info!(
"AiNode 调用 LLM: model={}, base_url={}", "AiNode 调用 LLM: model={}, base_url={}",
default_model, p.default_model,
base_url p.base_url
); );
let response = provider.complete(request).await?; let response = provider.complete(request).await?;
@@ -148,7 +173,7 @@ impl Node for AiNode {
"temperature": { "type": "number", "description": "温度 0.0~2.0(可选)" }, "temperature": { "type": "number", "description": "温度 0.0~2.0(可选)" },
"max_tokens": { "type": "integer", "description": "最大生成 token可选anthropic 协议无值时默认 4096" } "max_tokens": { "type": "integer", "description": "最大生成 token可选anthropic 协议无值时默认 4096" }
}, },
"required": ["prompt", "base_url", "api_key"] "required": ["base_url", "api_key"]
}), }),
output: serde_json::json!({ output: serde_json::json!({
"type": "object", "type": "object",
@@ -165,3 +190,137 @@ impl Node for AiNode {
"ai" "ai"
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
/// 造带基础三字段(base_url/api_key/prompt)的 config,overrides 覆盖或追加
fn config_with(overrides: serde_json::Value) -> serde_json::Value {
let mut base = json!({
"base_url": "https://api.example.com",
"api_key": "sk-test",
"prompt": "config-prompt"
});
if let (serde_json::Value::Object(b), serde_json::Value::Object(o)) = (&mut base, overrides) {
for (k, v) in o {
b.insert(k, v);
}
}
base
}
fn empty_inputs() -> HashMap<String, NodeOutput> {
HashMap::new()
}
#[test]
fn missing_base_url_errors() {
let config = json!({ "api_key": "k", "prompt": "p" });
let err = parse_params(&config, &empty_inputs()).unwrap_err().to_string();
assert!(err.contains("base_url"), "缺 base_url 应报错, 实际: {}", err);
}
#[test]
fn missing_api_key_errors() {
let config = json!({ "base_url": "http://x", "prompt": "p" });
let err = parse_params(&config, &empty_inputs()).unwrap_err().to_string();
assert!(err.contains("api_key"), "缺 api_key 应报错, 实际: {}", err);
}
#[test]
fn missing_prompt_errors() {
let config = json!({ "base_url": "http://x", "api_key": "k" });
let err = parse_params(&config, &empty_inputs()).unwrap_err().to_string();
assert!(err.contains("prompt"), "缺 prompt 应报错, 实际: {}", err);
}
#[test]
fn prompt_from_config_when_no_upstream() {
let p = parse_params(&config_with(json!({})), &empty_inputs()).unwrap();
assert_eq!(p.prompt, "config-prompt");
}
#[test]
fn prompt_prefers_upstream_input_over_config() {
let mut inputs = empty_inputs();
inputs.insert(
"prompt".to_string(),
NodeOutput::from_value(json!("upstream-prompt")),
);
let p = parse_params(&config_with(json!({})), &inputs).unwrap();
assert_eq!(p.prompt, "upstream-prompt", "上游输入应优先于 config.prompt");
}
#[test]
fn defaults_when_optional_fields_missing() {
let p = parse_params(&config_with(json!({})), &empty_inputs()).unwrap();
assert_eq!(p.model, "");
assert_eq!(p.default_model, "gpt-4o-mini", "model 空时 default_model 兜底");
assert_eq!(p.protocol, "openai_compat", "protocol 默认 openai_compat");
assert_eq!(p.temperature, None);
assert_eq!(p.max_tokens, None);
assert_eq!(p.system_prompt, None);
}
#[test]
fn optional_fields_parsed_when_present() {
let p = parse_params(
&config_with(json!({
"model": "glm-4",
"protocol": "anthropic",
"temperature": 0.3,
"max_tokens": 1024,
"system_prompt": "你是助手"
})),
&empty_inputs(),
)
.unwrap();
assert_eq!(p.model, "glm-4");
assert_eq!(p.default_model, "glm-4", "model 非空时 default_model = model");
assert_eq!(p.protocol, "anthropic");
assert_eq!(p.temperature, Some(0.3));
assert_eq!(p.max_tokens, Some(1024));
assert_eq!(p.system_prompt.as_deref(), Some("你是助手"));
}
/// 真调 GLM 验证 provider 调用层parse_params 已由上方单测覆盖,此处补 complete 端到端)
///
/// `#[ignore]`:需真实 GLM 配置env var默认不跑。
/// 跑法:`GLM_BASE_URL=... GLM_API_KEY=... GLM_MODEL=glm-4-flash \
/// cargo test -p df-nodes --lib glm_live_complete -- --ignored --nocapture`
/// env var 缺失 → 跳过(非失败)。
#[ignore = "需真实 GLM 配置(env var)"]
#[tokio::test]
async fn glm_live_complete() {
let base_url = std::env::var("GLM_BASE_URL").ok().filter(|s| !s.is_empty());
let api_key = std::env::var("GLM_API_KEY").ok().filter(|s| !s.is_empty());
let (base_url, api_key) = match (base_url, api_key) {
(Some(b), Some(k)) => (b, k),
_ => {
eprintln!("跳过: 未设 GLM_BASE_URL / GLM_API_KEY env var");
return;
}
};
let model = std::env::var("GLM_MODEL").unwrap_or_else(|_| "glm-4-flash".to_string());
let provider: Box<dyn LlmProvider> =
df_ai::build_provider("openai_compat", &base_url, &api_key, &model);
let request = CompletionRequest {
model: model.clone(),
messages: vec![ChatMessage::user("只回复两个字:通过")],
temperature: Some(0.0),
max_tokens: Some(16),
stream: false,
tools: None,
tool_choice: None,
};
let response = provider.complete(request).await.expect("GLM 调用失败");
assert!(!response.text.is_empty(), "GLM 返回空文本");
println!(
"GLM 响应: model={}, text={}, usage={:?}",
response.model, response.text, response.usage
);
}
}

View File

@@ -1,41 +0,0 @@
//! Docker 节点 — 在容器中执行任务
use async_trait::async_trait;
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
/// Docker 节点
pub struct DockerNode;
#[async_trait]
impl Node for DockerNode {
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
// TODO: 接入 df-execute 的 Docker 执行器
tracing::info!("DockerNode 执行: 在容器中运行");
Ok(NodeOutput::empty())
}
fn schema(&self) -> NodeSchema {
NodeSchema {
params: serde_json::json!({
"type": "object",
"properties": {
"image": { "type": "string" },
"command": { "type": "string" },
"env": { "type": "object" }
},
"required": ["image"]
}),
output: serde_json::json!({
"type": "object",
"properties": {
"stdout": { "type": "string" },
"exit_code": { "type": "integer" }
}
}),
}
}
fn node_type(&self) -> &str {
"docker"
}
}

View File

@@ -1,41 +0,0 @@
//! Git 节点 — 执行 Git 操作(克隆、提交、推送、合并等)
use async_trait::async_trait;
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
/// Git 节点
pub struct GitNode;
#[async_trait]
impl Node for GitNode {
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
// TODO: 接入 df-execute 的 Git 操作
tracing::info!("GitNode 执行: Git 操作");
Ok(NodeOutput::empty())
}
fn schema(&self) -> NodeSchema {
NodeSchema {
params: serde_json::json!({
"type": "object",
"properties": {
"action": { "type": "string", "enum": ["clone", "commit", "push", "merge", "checkout"] },
"repo": { "type": "string" },
"branch": { "type": "string" }
},
"required": ["action"]
}),
output: serde_json::json!({
"type": "object",
"properties": {
"success": { "type": "boolean" },
"message": { "type": "string" }
}
}),
}
}
fn node_type(&self) -> &str {
"git"
}
}

View File

@@ -1,43 +0,0 @@
//! HTTP 节点 — 发起 HTTP 请求
use async_trait::async_trait;
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
/// HTTP 节点
pub struct HttpNode;
#[async_trait]
impl Node for HttpNode {
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
// TODO: 实现HTTP请求逻辑
tracing::info!("HttpNode 执行: 发送 HTTP 请求");
Ok(NodeOutput::empty())
}
fn schema(&self) -> NodeSchema {
NodeSchema {
params: serde_json::json!({
"type": "object",
"properties": {
"method": { "type": "string", "enum": ["GET", "POST", "PUT", "DELETE"] },
"url": { "type": "string" },
"headers": { "type": "object" },
"body": {}
},
"required": ["method", "url"]
}),
output: serde_json::json!({
"type": "object",
"properties": {
"status": { "type": "integer" },
"body": {},
"headers": { "type": "object" }
}
}),
}
}
fn node_type(&self) -> &str {
"http"
}
}

View File

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

View File

@@ -1,41 +0,0 @@
//! 通知节点 — 发送通知(邮件、飞书、钉钉等)
use async_trait::async_trait;
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
/// 通知节点
pub struct NotifyNode;
#[async_trait]
impl Node for NotifyNode {
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
// TODO: 实现通知发送逻辑
tracing::info!("NotifyNode 执行: 发送通知");
Ok(NodeOutput::empty())
}
fn schema(&self) -> NodeSchema {
NodeSchema {
params: serde_json::json!({
"type": "object",
"properties": {
"channel": { "type": "string", "enum": ["email", "feishu", "dingtalk", "webhook"] },
"to": { "type": "string" },
"title": { "type": "string" },
"body": { "type": "string" }
},
"required": ["channel", "to", "body"]
}),
output: serde_json::json!({
"type": "object",
"properties": {
"success": { "type": "boolean" }
}
}),
}
}
fn node_type(&self) -> &str {
"notify"
}
}

View File

@@ -1,39 +0,0 @@
//! 子流程节点 — 嵌套执行另一个工作流
use async_trait::async_trait;
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
/// 子流程节点
pub struct SubflowNode;
#[async_trait]
impl Node for SubflowNode {
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
// TODO: 实现子工作流加载与执行
tracing::info!("SubflowNode 执行: 启动子工作流");
Ok(NodeOutput::empty())
}
fn schema(&self) -> NodeSchema {
NodeSchema {
params: serde_json::json!({
"type": "object",
"properties": {
"workflow_id": { "type": "string" },
"inputs": { "type": "object" }
},
"required": ["workflow_id"]
}),
output: serde_json::json!({
"type": "object",
"properties": {
"outputs": { "type": "object" }
}
}),
}
}
fn node_type(&self) -> &str {
"subflow"
}
}

View File

@@ -1,14 +0,0 @@
[package]
name = "df-plugin"
version = "0.1.0"
edition = "2021"
[dependencies]
df-core = { path = "../df-core" }
df-workflow = { path = "../df-workflow" }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
async-trait = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }

View File

@@ -1,66 +0,0 @@
//! 插件宿主环境 — 管理插件生命周期与沙箱
use std::collections::HashMap;
use df_core::types::PluginId;
use df_workflow::node::Node;
use crate::loader::{LoadedPlugin, PluginLoader, PluginMetadata};
/// 插件宿主环境
pub struct PluginHost {
/// 插件加载器
loader: PluginLoader,
/// 已加载的插件
plugins: HashMap<PluginId, LoadedPlugin>,
}
impl PluginHost {
/// 创建宿主环境
pub fn new(loader: PluginLoader) -> Self {
Self {
loader,
plugins: HashMap::new(),
}
}
/// 初始化:加载所有插件
pub async fn initialize(&mut self) -> anyhow::Result<()> {
self.plugins = self.loader.load_all()?;
tracing::info!("插件宿主环境初始化完成,加载了 {} 个插件", self.plugins.len());
Ok(())
}
/// 获取所有已注册的节点
pub fn all_nodes(&self) -> Vec<(&PluginId, &Box<dyn Node>)> {
let mut nodes = Vec::new();
for (plugin_id, plugin) in &self.plugins {
for node in &plugin.nodes {
nodes.push((plugin_id, node));
}
}
nodes
}
/// 获取指定插件的元数据
pub fn get_metadata(&self, plugin_id: &PluginId) -> Option<&PluginMetadata> {
self.plugins.get(plugin_id).map(|p| &p.metadata)
}
/// 卸载指定插件
///
/// TODO: 实现插件卸载(清理资源、取消注册节点等)
pub fn unload(&mut self, plugin_id: &PluginId) -> anyhow::Result<()> {
if let Some(plugin) = self.plugins.remove(plugin_id) {
tracing::info!("插件 {} 已卸载", plugin.metadata.name);
}
Ok(())
}
/// 关闭宿主环境,卸载所有插件
pub fn shutdown(&mut self) {
let count = self.plugins.len();
self.plugins.clear();
tracing::info!("插件宿主环境关闭,卸载了 {} 个插件", count);
}
}

View File

@@ -1,5 +0,0 @@
//! df-plugin: 插件系统 — 插件加载、宿主环境、SDK
pub mod host;
pub mod loader;
pub mod sdk;

View File

@@ -1,94 +0,0 @@
//! 插件加载器 — 发现、加载、注册插件
use std::collections::HashMap;
use std::path::PathBuf;
use df_core::types::PluginId;
use df_workflow::node::Node;
/// 插件元数据
#[derive(Debug, Clone)]
pub struct PluginMetadata {
/// 插件 ID
pub id: PluginId,
/// 插件名称
pub name: String,
/// 版本
pub version: String,
/// 描述
pub description: String,
/// 作者
pub author: Option<String>,
/// 入口文件路径
pub entry_path: PathBuf,
}
/// 已加载的插件
pub struct LoadedPlugin {
pub metadata: PluginMetadata,
pub nodes: Vec<Box<dyn Node>>,
}
/// 插件加载器
pub struct PluginLoader {
/// 插件搜索目录
search_dirs: Vec<PathBuf>,
}
impl PluginLoader {
/// 创建插件加载器
pub fn new() -> Self {
Self {
search_dirs: Vec::new(),
}
}
/// 添加插件搜索目录
pub fn add_search_dir(&mut self, dir: PathBuf) {
self.search_dirs.push(dir);
}
/// 扫描并发现所有可用插件
///
/// TODO: 实现文件系统扫描、manifest 解析
pub fn discover(&self) -> anyhow::Result<Vec<PluginMetadata>> {
tracing::info!("扫描插件目录: {:?}", self.search_dirs);
// TODO: 实现插件发现逻辑
Ok(Vec::new())
}
/// 加载指定插件
///
/// TODO: 实现动态库加载dlopen/LoadLibrary或 WASM 加载
pub fn load(&self, _metadata: &PluginMetadata) -> anyhow::Result<LoadedPlugin> {
// TODO: 实现插件加载
tracing::warn!("插件加载尚未实现");
Err(anyhow::anyhow!("插件加载尚未实现"))
}
/// 批量加载所有发现的插件
pub fn load_all(&self) -> anyhow::Result<HashMap<PluginId, LoadedPlugin>> {
let metadata_list = self.discover()?;
let mut loaded = HashMap::new();
for meta in metadata_list {
match self.load(&meta) {
Ok(plugin) => {
tracing::info!("插件 {} v{} 加载成功", meta.name, meta.version);
loaded.insert(meta.id.clone(), plugin);
}
Err(e) => {
tracing::error!("插件 {} 加载失败: {}", meta.name, e);
}
}
}
Ok(loaded)
}
}
impl Default for PluginLoader {
fn default() -> Self {
Self::new()
}
}

View File

@@ -1,90 +0,0 @@
//! 插件 SDK — 插件开发者使用的工具与trait
use async_trait::async_trait;
use df_workflow::node::Node;
/// 插件入口 trait — 所有插件必须实现
#[async_trait]
pub trait Plugin: Send + Sync {
/// 插件 ID
fn id(&self) -> &str;
/// 插件名称
fn name(&self) -> &str;
/// 插件版本
fn version(&self) -> &str;
/// 注册的所有节点
fn nodes(&self) -> Vec<Box<dyn Node>>;
/// 插件初始化(可选)
async fn initialize(&self) -> anyhow::Result<()> {
Ok(())
}
/// 插件销毁(可选)
async fn shutdown(&self) -> anyhow::Result<()> {
Ok(())
}
}
/// 插件构建器 — 简化插件定义
pub struct PluginBuilder {
id: String,
name: String,
version: String,
nodes: Vec<Box<dyn Node>>,
}
impl PluginBuilder {
/// 创建插件构建器
pub fn new(id: impl Into<String>, name: impl Into<String>, version: impl Into<String>) -> Self {
Self {
id: id.into(),
name: name.into(),
version: version.into(),
nodes: Vec::new(),
}
}
/// 注册节点
pub fn node(mut self, node: Box<dyn Node>) -> Self {
self.nodes.push(node);
self
}
/// 获取插件 ID
pub fn id(&self) -> &str {
&self.id
}
/// 获取插件名称
pub fn name(&self) -> &str {
&self.name
}
/// 获取插件版本
pub fn version(&self) -> &str {
&self.version
}
/// 构建并获取所有节点
pub fn build(self) -> Vec<Box<dyn Node>> {
self.nodes
}
}
/// 声明插件的宏(简化版)
///
/// TODO: 实现完整的声明宏
/// 使用示例:
/// ```
/// declare_plugin!("my-plugin", "My Plugin", "0.1.0");
/// ```
#[macro_export]
macro_rules! declare_plugin {
($id:expr, $name:expr, $version:expr) => {
// TODO: 实现宏
};
}

View File

@@ -1,52 +0,0 @@
//! 项目上下文 — 项目运行时的环境与配置信息
use serde::{Deserialize, Serialize};
use df_core::types::ProjectId;
/// 项目上下文
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectContext {
/// 项目 ID
pub project_id: ProjectId,
/// 项目根目录(本地文件系统路径)
pub root_path: Option<String>,
/// Git 仓库 URL
pub repo_url: Option<String>,
/// 当前分支
pub current_branch: Option<String>,
/// 环境变量
pub env_vars: std::collections::HashMap<String, String>,
/// 技术栈
pub tech_stack: Vec<String>,
/// AI 上下文(项目相关的 AI 记忆)
pub ai_context: Option<AiContext>,
}
/// AI 上下文信息
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AiContext {
/// 项目摘要
pub summary: String,
/// 架构描述
pub architecture: Option<String>,
/// 关键决策记录
pub decisions: Vec<String>,
/// 最近修改摘要
pub recent_changes: Vec<String>,
}
impl ProjectContext {
/// 创建空上下文
pub fn new(project_id: ProjectId) -> Self {
Self {
project_id,
root_path: None,
repo_url: None,
current_branch: None,
env_vars: std::collections::HashMap::new(),
tech_stack: Vec::new(),
ai_context: None,
}
}
}

View File

@@ -1,6 +1,4 @@
//! df-project: 项目管理 — 项目创建、调度、上下文、时间线 //! df-project: 项目管理 — 项目创建、技术栈探测
pub mod context;
pub mod manager; pub mod manager;
pub mod scheduler; pub mod scan;
pub mod timeline;

View File

@@ -0,0 +1,303 @@
//! 项目技术栈探测 — 浅读根目录标志文件识别技术栈
//!
//! 纯函数、零状态、零外部依赖(仅 std + serde_json + anyhow)。
//! 只读根目录标志文件,不递归遍历(控制性能与安全)。
//!
//! 供 commands 层薄封装暴露为 IPC 命令,新建/导入项目时自动填充 `ProjectRecord.stack`;
//! 未来 df-ai/df-workflow 亦可复用以感知「项目是什么技术栈」。
use std::path::Path;
use anyhow::{Context, Result};
/// 探测目录的技术栈
///
/// 返回去重后的技术栈标签数组(如 `["rust","vue","tauri","typescript"]`)。
/// 空数组表示未识别出任何已知标志(空目录或非常规项目)。非目录返回 Err。
pub fn detect_stack(root: &Path) -> Result<Vec<String>> {
if !root.is_dir() {
anyhow::bail!("路径不是目录: {}", root.display());
}
let mut stack: Vec<String> = Vec::new();
// ── 后端/系统语言 ──
if root.join("Cargo.toml").exists() {
push_unique(&mut stack, "rust");
}
if root.join("go.mod").exists() {
push_unique(&mut stack, "go");
}
if root.join("pom.xml").exists()
|| root.join("build.gradle").exists()
|| root.join("build.gradle.kts").exists()
{
push_unique(&mut stack, "java");
}
if root.join("pyproject.toml").exists() || root.join("requirements.txt").exists() {
push_unique(&mut stack, "python");
}
// C#: 根目录存在 .csproj 文件(仅一层)
if has_file_with_ext(root, "csproj") {
push_unique(&mut stack, "csharp");
}
// ── Tauri 桌面应用(通常含 src-tauri 目录) ──
if root.join("src-tauri").is_dir() {
push_unique(&mut stack, "tauri");
}
// ── 前端/Node:解析 package.json 的依赖推断框架 ──
if root.join("package.json").exists() {
if let Ok(deps) = read_package_deps(root.join("package.json")) {
if deps.iter().any(|d| d == "vue") {
push_unique(&mut stack, "vue");
}
if deps.iter().any(|d| d == "react" || d == "react-dom") {
push_unique(&mut stack, "react");
}
if deps.iter().any(|d| d == "@angular/core") {
push_unique(&mut stack, "angular");
}
if deps.iter().any(|d| d == "svelte") {
push_unique(&mut stack, "svelte");
}
if deps.iter().any(|d| d == "next") {
push_unique(&mut stack, "next");
}
if deps.iter().any(|d| d == "vite") {
push_unique(&mut stack, "vite");
}
if deps.iter().any(|d| d == "typescript") {
push_unique(&mut stack, "typescript");
}
if deps.iter().any(|d| d == "express" || d == "koa" || d == "fastify") {
push_unique(&mut stack, "node");
}
}
}
Ok(stack)
}
/// 解析 package.json,合并 dependencies + devDependencies 的包名
fn read_package_deps(path: impl AsRef<Path>) -> Result<Vec<String>> {
let content = std::fs::read_to_string(path.as_ref())
.with_context(|| format!("读取 package.json 失败: {}", path.as_ref().display()))?;
let pkg: serde_json::Value = serde_json::from_str(&content).context("解析 package.json 失败")?;
let mut names = Vec::new();
for key in &["dependencies", "devDependencies"] {
if let Some(obj) = pkg.get(key).and_then(|v| v.as_object()) {
for k in obj.keys() {
names.push(k.clone());
}
}
}
Ok(names)
}
/// 目录下是否存在指定扩展名的文件(仅一层)
fn has_file_with_ext(dir: &Path, ext: &str) -> bool {
let Ok(entries) = std::fs::read_dir(dir) else {
return false;
};
entries.flatten().any(|e| {
e.path()
.extension()
.and_then(|x| x.to_str())
.map(|x| x == ext)
.unwrap_or(false)
})
}
// ============================================================
// 项目采样(供 LLM 分析基础信息) — 纯 IO,控 token 不读源码
// ============================================================
/// 项目采样结果 — README 首段 + 目录树(2层) + 清单文件片段
#[derive(Debug, Clone)]
pub struct ProjectSample {
pub readme: Option<String>,
pub tree: Vec<String>,
/// (文件名, 截断内容)
pub manifests: Vec<(String, String)>,
}
const SAMPLE_README_MAX: usize = 2000;
const SAMPLE_MANIFEST_MAX: usize = 1500;
const SAMPLE_TREE_MAX: usize = 80;
/// 目录树过滤的噪音目录(依赖产物/构建/缓存/IDE)
const SAMPLE_IGNORED_DIRS: &[&str] = &[
"node_modules", "target", ".git", "dist", "build", ".next", "venv", ".venv",
"__pycache__", ".idea", ".vscode", ".cache", "out", "coverage", ".svelte-kit",
".turbo", ".angular", ".gradle", "vendor",
];
/// 采集项目采样(README + 目录树 + 清单),供 LLM 分析填基础信息
pub fn collect_sample(root: &Path) -> Result<ProjectSample> {
if !root.is_dir() {
anyhow::bail!("路径不是目录: {}", root.display());
}
Ok(ProjectSample {
readme: read_readme(root),
tree: collect_tree(root),
manifests: collect_manifests(root),
})
}
fn read_readme(root: &Path) -> Option<String> {
for name in &["README.md", "README.MD", "README", "README.zh.md", "README_zh.md", "README_EN.md", "readme.md"] {
let p = root.join(name);
if p.is_file() {
if let Ok(content) = std::fs::read_to_string(&p) {
return Some(truncate_chars(&content, SAMPLE_README_MAX));
}
}
}
None
}
/// 目录树(根 + 一层子目录),过滤噪音目录,控条目数
fn collect_tree(root: &Path) -> Vec<String> {
let mut lines = Vec::new();
let mut count = 0usize;
collect_tree_level(root, "", &mut lines, &mut count, false);
lines
}
fn collect_tree_level(dir: &Path, prefix: &str, lines: &mut Vec<String>, count: &mut usize, is_sub: bool) {
if *count >= SAMPLE_TREE_MAX {
return;
}
let Ok(entries) = std::fs::read_dir(dir) else { return };
let mut items: Vec<_> = entries.flatten().collect();
items.sort_by_key(|e| e.file_name());
for e in items {
if *count >= SAMPLE_TREE_MAX {
return;
}
let name = e.file_name().to_string_lossy().to_string();
let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
if is_dir {
if SAMPLE_IGNORED_DIRS.contains(&name.as_str()) {
continue;
}
lines.push(format!("{}{}/", prefix, name));
*count += 1;
// 仅根目录的子目录展开一层(is_sub=true 不再递归)
if !is_sub {
collect_tree_level(&e.path(), &format!("{} ", prefix), lines, count, true);
}
} else {
// 跳过隐藏文件(保留 .gitignore 作 git 标识)
if name.starts_with('.') && name != ".gitignore" {
continue;
}
lines.push(format!("{}{}", prefix, name));
*count += 1;
}
}
}
fn collect_manifests(root: &Path) -> Vec<(String, String)> {
let mut out = Vec::new();
for name in &["package.json", "Cargo.toml", "go.mod", "pyproject.toml", "pom.xml", "build.gradle", "build.gradle.kts"] {
let p = root.join(name);
if let Ok(content) = std::fs::read_to_string(&p) {
out.push(((*name).to_string(), truncate_chars(&content, SAMPLE_MANIFEST_MAX)));
}
}
out
}
/// 按字符数截断(避免截断 UTF-8 多字节边界)
fn truncate_chars(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
let truncated: String = s.chars().take(max).collect();
format!("{}…(已截断)", truncated)
}
/// 去重 push
fn push_unique(stack: &mut Vec<String>, s: &str) {
if !stack.iter().any(|x| x == s) {
stack.push(s.to_string());
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::PathBuf;
/// 在系统临时目录建唯一子目录(以进程号隔离并发),返回路径
fn scratch(name: &str) -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!("df-project-scan-{}-{}", name, std::process::id()));
let _ = fs::remove_dir_all(&p);
fs::create_dir_all(&p).unwrap();
p
}
#[test]
fn non_dir_errors() {
let r = detect_stack(Path::new("definitely-not-exist-xyz-123"));
assert!(r.is_err());
}
#[test]
fn detects_rust() {
let d = scratch("rust");
fs::write(d.join("Cargo.toml"), "").unwrap();
let s = detect_stack(&d).unwrap();
assert!(s.contains(&"rust".to_string()));
fs::remove_dir_all(&d).ok();
}
#[test]
fn detects_full_stack() {
// 模拟 DevFlow 自身:rust + tauri + vue + vite + typescript
let d = scratch("full");
fs::write(d.join("Cargo.toml"), "").unwrap();
fs::create_dir(d.join("src-tauri")).unwrap();
fs::write(
d.join("package.json"),
r#"{"dependencies":{"vue":"^3.5.0"},"devDependencies":{"vite":"^6.0.0","typescript":"~5.6.0"}}"#,
)
.unwrap();
let s = detect_stack(&d).unwrap();
assert!(s.contains(&"rust".to_string()));
assert!(s.contains(&"tauri".to_string()));
assert!(s.contains(&"vue".to_string()));
assert!(s.contains(&"vite".to_string()));
assert!(s.contains(&"typescript".to_string()));
fs::remove_dir_all(&d).ok();
}
#[test]
fn empty_dir_returns_empty() {
let d = scratch("empty");
let s = detect_stack(&d).unwrap();
assert!(s.is_empty());
fs::remove_dir_all(&d).ok();
}
#[test]
fn collects_sample() {
let d = scratch("sample");
fs::write(d.join("README.md"), "# Test\nA test project.\nMore.").unwrap();
fs::write(d.join("package.json"), r#"{"name":"x","dependencies":{"vue":"3"}}"#).unwrap();
fs::create_dir(d.join("src")).unwrap();
fs::write(d.join("src/main.ts"), "x").unwrap();
fs::create_dir(d.join("node_modules")).unwrap();
fs::write(d.join("node_modules/junk.json"), "x").unwrap();
let s = collect_sample(&d).unwrap();
assert!(s.readme.as_deref().unwrap_or("").contains("test project"));
assert!(s.manifests.iter().any(|(n, _)| n == "package.json"));
assert!(s.tree.iter().any(|t| t.contains("src")));
// node_modules 应被过滤
assert!(s.tree.iter().all(|t| !t.contains("node_modules")));
fs::remove_dir_all(&d).ok();
}
}

View File

@@ -1,66 +0,0 @@
//! 项目调度器 — 自动化任务分配与调度
use df_core::types::{ProjectId, TaskId};
/// 调度策略
#[derive(Debug, Clone, Copy)]
pub enum SchedulingStrategy {
/// FIFO先进先出
Fifo,
/// 按优先级调度
Priority,
/// 最短任务优先
ShortestFirst,
/// AI 智能调度
AiDriven,
}
/// 调度决策
#[derive(Debug, Clone)]
pub struct SchedulingDecision {
pub project_id: ProjectId,
pub task_order: Vec<TaskId>,
pub strategy: SchedulingStrategy,
}
/// 项目调度器
pub struct ProjectScheduler {
strategy: SchedulingStrategy,
}
impl ProjectScheduler {
/// 创建调度器
pub fn new(strategy: SchedulingStrategy) -> Self {
Self { strategy }
}
/// 为项目生成调度计划
///
/// TODO: 实现基于策略的调度算法
pub fn schedule(&self, project_id: &ProjectId) -> anyhow::Result<SchedulingDecision> {
match self.strategy {
SchedulingStrategy::Fifo => {
// TODO: 按创建时间排序
tracing::info!("FIFO 调度,项目: {}", project_id);
}
SchedulingStrategy::Priority => {
// TODO: 按优先级排序
tracing::info!("优先级调度,项目: {}", project_id);
}
SchedulingStrategy::ShortestFirst => {
// TODO: 按预估工作量排序
tracing::info!("最短任务优先调度,项目: {}", project_id);
}
SchedulingStrategy::AiDriven => {
// TODO: 接入 AI 进行智能调度
tracing::info!("AI 智能调度,项目: {}", project_id);
}
}
Ok(SchedulingDecision {
project_id: project_id.clone(),
task_order: Vec::new(),
strategy: self.strategy,
})
}
}

View File

@@ -1,64 +0,0 @@
//! 项目时间线 — 里程碑与进度追踪
use serde::{Deserialize, Serialize};
use df_core::types::ProjectId;
/// 里程碑
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Milestone {
/// 唯一 ID
pub id: String,
/// 所属项目 ID
pub project_id: ProjectId,
/// 里程碑名称
pub name: String,
/// 描述
pub description: String,
/// 计划完成时间
pub due_date: Option<chrono::DateTime<chrono::Utc>>,
/// 实际完成时间
pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
/// 进度百分比 (0-100)
pub progress: u8,
/// 是否已完成
pub completed: bool,
}
/// 项目时间线
pub struct Timeline {
pub project_id: ProjectId,
pub milestones: Vec<Milestone>,
}
impl Timeline {
/// 创建空时间线
pub fn new(project_id: ProjectId) -> Self {
Self {
project_id,
milestones: Vec::new(),
}
}
/// 添加里程碑
pub fn add_milestone(&mut self, milestone: Milestone) {
self.milestones.push(milestone);
}
/// 计算整体进度
pub fn overall_progress(&self) -> f64 {
if self.milestones.is_empty() {
return 0.0;
}
let total: f64 = self.milestones.iter().map(|m| m.progress as f64).sum();
total / self.milestones.len() as f64
}
/// 获取下一个待完成的里程碑
pub fn next_milestone(&self) -> Option<&Milestone> {
self.milestones
.iter()
.filter(|m| !m.completed)
.min_by_key(|m| m.due_date)
}
}

View File

@@ -1,14 +0,0 @@
[package]
name = "df-stages"
version = "0.1.0"
edition = "2021"
[dependencies]
df-core = { path = "../df-core" }
df-workflow = { path = "../df-workflow" }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
async-trait = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }

View File

@@ -1,50 +0,0 @@
//! 编码阶段 — 代码生成与审查阶段模板
use async_trait::async_trait;
use df_workflow::node::{Node, NodeContext, NodeResult, NodeSchema};
/// 代码生成节点
pub struct CodeGenNode;
#[async_trait]
impl Node for CodeGenNode {
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
// TODO: 调用 AI 生成代码
tracing::info!("代码生成节点执行");
Ok(df_workflow::node::NodeOutput::empty())
}
fn schema(&self) -> NodeSchema {
NodeSchema {
params: serde_json::json!({"type": "object"}),
output: serde_json::json!({"type": "object"}),
}
}
fn node_type(&self) -> &str {
"stage.coding.codegen"
}
}
/// 代码审查节点
pub struct CodeReviewNode;
#[async_trait]
impl Node for CodeReviewNode {
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
// TODO: 调用 AI 审查代码
tracing::info!("代码审查节点执行");
Ok(df_workflow::node::NodeOutput::empty())
}
fn schema(&self) -> NodeSchema {
NodeSchema {
params: serde_json::json!({"type": "object"}),
output: serde_json::json!({"type": "object"}),
}
}
fn node_type(&self) -> &str {
"stage.coding.review"
}
}

View File

@@ -1,57 +0,0 @@
//! 想法阶段 — 想法捕获与评估阶段模板
use async_trait::async_trait;
use df_workflow::node::{Node, NodeContext, NodeResult, NodeSchema};
/// 想法捕获节点
pub struct IdeaCaptureNode;
#[async_trait]
impl Node for IdeaCaptureNode {
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
// TODO: 调用 df-ideas 的捕获功能
tracing::info!("想法捕获节点执行");
Ok(df_workflow::node::NodeOutput::empty())
}
fn schema(&self) -> NodeSchema {
NodeSchema {
params: serde_json::json!({
"type": "object",
"properties": {
"title": { "type": "string" },
"description": { "type": "string" }
},
"required": ["title"]
}),
output: serde_json::json!({"type": "object"}),
}
}
fn node_type(&self) -> &str {
"stage.idea.capture"
}
}
/// 想法评估节点
pub struct IdeaEvalNode;
#[async_trait]
impl Node for IdeaEvalNode {
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
// TODO: 调用 df-ideas 的评估功能
tracing::info!("想法评估节点执行");
Ok(df_workflow::node::NodeOutput::empty())
}
fn schema(&self) -> NodeSchema {
NodeSchema {
params: serde_json::json!({"type": "object"}),
output: serde_json::json!({"type": "object"}),
}
}
fn node_type(&self) -> &str {
"stage.idea.eval"
}
}

View File

@@ -1,7 +0,0 @@
//! df-stages: 阶段插件 — 开发流程各阶段的节点模板注册
pub mod coding;
pub mod idea;
pub mod release;
pub mod requirement;
pub mod testing;

View File

@@ -1,73 +0,0 @@
//! 发布阶段 — 构建、部署与发布阶段模板
use async_trait::async_trait;
use df_workflow::node::{Node, NodeContext, NodeResult, NodeSchema};
/// 构建节点
pub struct BuildNode;
#[async_trait]
impl Node for BuildNode {
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
// TODO: 调用 df-execute 执行构建命令
tracing::info!("构建节点执行");
Ok(df_workflow::node::NodeOutput::empty())
}
fn schema(&self) -> NodeSchema {
NodeSchema {
params: serde_json::json!({"type": "object"}),
output: serde_json::json!({"type": "object"}),
}
}
fn node_type(&self) -> &str {
"stage.release.build"
}
}
/// 部署节点
pub struct DeployNode;
#[async_trait]
impl Node for DeployNode {
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
// TODO: 调用 df-execute 执行部署命令
tracing::info!("部署节点执行");
Ok(df_workflow::node::NodeOutput::empty())
}
fn schema(&self) -> NodeSchema {
NodeSchema {
params: serde_json::json!({"type": "object"}),
output: serde_json::json!({"type": "object"}),
}
}
fn node_type(&self) -> &str {
"stage.release.deploy"
}
}
/// 发布公告节点
pub struct ReleaseNotesNode;
#[async_trait]
impl Node for ReleaseNotesNode {
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
// TODO: 调用 AI 生成发布公告
tracing::info!("发布公告节点执行");
Ok(df_workflow::node::NodeOutput::empty())
}
fn schema(&self) -> NodeSchema {
NodeSchema {
params: serde_json::json!({"type": "object"}),
output: serde_json::json!({"type": "object"}),
}
}
fn node_type(&self) -> &str {
"stage.release.notes"
}
}

View File

@@ -1,50 +0,0 @@
//! 需求阶段 — 需求分析与文档生成阶段模板
use async_trait::async_trait;
use df_workflow::node::{Node, NodeContext, NodeResult, NodeSchema};
/// 需求分析节点
pub struct RequirementAnalysisNode;
#[async_trait]
impl Node for RequirementAnalysisNode {
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
// TODO: 调用 AI 分析需求
tracing::info!("需求分析节点执行");
Ok(df_workflow::node::NodeOutput::empty())
}
fn schema(&self) -> NodeSchema {
NodeSchema {
params: serde_json::json!({"type": "object"}),
output: serde_json::json!({"type": "object"}),
}
}
fn node_type(&self) -> &str {
"stage.requirement.analysis"
}
}
/// 需求文档生成节点
pub struct RequirementDocNode;
#[async_trait]
impl Node for RequirementDocNode {
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
// TODO: 调用 AI 生成需求文档
tracing::info!("需求文档生成节点执行");
Ok(df_workflow::node::NodeOutput::empty())
}
fn schema(&self) -> NodeSchema {
NodeSchema {
params: serde_json::json!({"type": "object"}),
output: serde_json::json!({"type": "object"}),
}
}
fn node_type(&self) -> &str {
"stage.requirement.doc"
}
}

View File

@@ -1,50 +0,0 @@
//! 测试阶段 — 测试生成与执行阶段模板
use async_trait::async_trait;
use df_workflow::node::{Node, NodeContext, NodeResult, NodeSchema};
/// 测试用例生成节点
pub struct TestGenNode;
#[async_trait]
impl Node for TestGenNode {
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
// TODO: 调用 AI 生成测试用例
tracing::info!("测试用例生成节点执行");
Ok(df_workflow::node::NodeOutput::empty())
}
fn schema(&self) -> NodeSchema {
NodeSchema {
params: serde_json::json!({"type": "object"}),
output: serde_json::json!({"type": "object"}),
}
}
fn node_type(&self) -> &str {
"stage.testing.testgen"
}
}
/// 测试执行节点
pub struct TestRunNode;
#[async_trait]
impl Node for TestRunNode {
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
// TODO: 调用 df-execute 执行测试命令
tracing::info!("测试执行节点执行");
Ok(df_workflow::node::NodeOutput::empty())
}
fn schema(&self) -> NodeSchema {
NodeSchema {
params: serde_json::json!({"type": "object"}),
output: serde_json::json!({"type": "object"}),
}
}
fn node_type(&self) -> &str {
"stage.testing.run"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,19 @@ use anyhow::Result;
use rusqlite::Connection; use rusqlite::Connection;
/// 当前迁移版本 /// 当前迁移版本
const MIGRATION_VERSION: i32 = 2; const MIGRATION_VERSION: i32 = 12;
/// 检测表是否存在指定列(幂等迁移通用辅助)
fn column_exists(conn: &Connection, table: &str, col: &str) -> bool {
let Ok(mut stmt) = conn.prepare(&format!("PRAGMA table_info({table})")) else { return false };
let Ok(rows) = stmt.query_map([], |r| r.get::<_, String>(1)) else { return false };
for r in rows {
if let Ok(name) = r {
if name == col { return true; }
}
}
false
}
/// 执行所有迁移 /// 执行所有迁移
pub fn run(conn: &Connection) -> Result<()> { pub fn run(conn: &Connection) -> Result<()> {
@@ -31,8 +43,49 @@ pub fn run(conn: &Connection) -> Result<()> {
migrate_v2(conn)?; migrate_v2(conn)?;
} }
// 未来迁移在此扩展: if current_version < 3 {
// if current_version < 3 { migrate_v3(conn)?; } migrate_v3(conn)?;
}
if current_version < 4 {
migrate_v4(conn)?;
}
if current_version < 5 {
migrate_v5(conn)?;
}
if current_version < 6 {
migrate_v6(conn)?;
}
if current_version < 7 {
migrate_v7(conn)?;
}
if current_version < 8 {
migrate_v8(conn)?;
}
if current_version < 9 {
migrate_v9(conn)?;
}
if current_version < 10 {
migrate_v10(conn)?;
}
if current_version < 11 {
migrate_v11(conn)?;
}
if current_version < 12 {
migrate_v12(conn)?;
}
if current_version < 13 {
migrate_v13(conn)?;
}
Ok(()) Ok(())
} }
@@ -53,6 +106,170 @@ fn migrate_v2(conn: &Connection) -> Result<()> {
Ok(()) Ok(())
} }
/// V3: AI 对话表补建(新库) + 归档标记列(新老库统一)
fn migrate_v3(conn: &Connection) -> Result<()> {
conn.execute_batch(V3_SQL)?;
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [3])?;
tracing::info!("迁移 v3 完成");
Ok(())
}
/// V4: 幂等补 ai_conversations.archived 列
///
/// 修复历史缺陷:早期 v3 迁移仅写入版本号 3,ALTER ADD COLUMN archived 未实际生效,
/// 导致 schema_version=3 但 ai_conversations 缺列,from_row 读 archived 报错,
/// list_all 失败 → 前端历史会话不显示 + 新对话 insert 失败。
/// 因 run() 按 `current_version < 3` 跳过 v3,该列无法靠 v3 自补。
/// 此处用 PRAGMA 探测列存在性,缺失才 ALTER,对新库/老库/坏库均安全。
fn migrate_v4(conn: &Connection) -> Result<()> {
let has_archived = column_exists(conn, "ai_conversations", "archived");
if !has_archived {
conn.execute(
"ALTER TABLE ai_conversations ADD COLUMN archived INTEGER NOT NULL DEFAULT 0",
[],
)?;
tracing::info!("v4: 补建 ai_conversations.archived 列");
}
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [4])?;
tracing::info!("迁移 v4 完成");
Ok(())
}
/// V5: 幂等补 ai_conversations.prompt_tokens / completion_tokens 列
///
/// 流式 token 用量记录:对话级累计 input/output token(由 save_conversation 写入)。
/// 用 PRAGMA 探测列存在性,缺失才 ALTER,对新库/老库/坏库均安全(同 v4 模式)。
fn migrate_v5(conn: &Connection) -> Result<()> {
if !column_exists(conn, "ai_conversations", "prompt_tokens") {
conn.execute("ALTER TABLE ai_conversations ADD COLUMN prompt_tokens INTEGER", [])?;
tracing::info!("v5: 补建 ai_conversations.prompt_tokens 列");
}
if !column_exists(conn, "ai_conversations", "completion_tokens") {
conn.execute("ALTER TABLE ai_conversations ADD COLUMN completion_tokens INTEGER", [])?;
tracing::info!("v5: 补建 ai_conversations.completion_tokens 列");
}
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [5])?;
tracing::info!("迁移 v5 完成");
Ok(())
}
/// V6: 幂等补 ai_conversations.models 列
///
/// 对话级多 model 记录:JSON 数组字符串(去重存对话用过的所有 model)。
/// 用 PRAGMA 探测列存在性,缺失才 ALTER(同 v4/v5 模式)。
fn migrate_v6(conn: &Connection) -> Result<()> {
let has_models = column_exists(conn, "ai_conversations", "models");
if !has_models {
conn.execute("ALTER TABLE ai_conversations ADD COLUMN models TEXT", [])?;
tracing::info!("v6: 补建 ai_conversations.models 列");
}
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [6])?;
tracing::info!("迁移 v6 完成");
Ok(())
}
/// V7: 知识库表 — 经验沉淀的基本单元(共享记忆层)
///
/// 状态机: candidate → pending_review → published → archived
/// AI 只产 candidate,人工门控发布;reuse_count 是唯一客观排序信号。
/// effectiveness 列不建(决策撤销人工评分)。时间字段用毫秒字符串(同既有 model 约定)。
fn migrate_v7(conn: &Connection) -> Result<()> {
conn.execute_batch(V7_SQL)?;
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [7])?;
tracing::info!("迁移 v7 完成");
Ok(())
}
/// V8: 幂等补 knowledges.embedding 列(向量检索)
///
/// 存 Vec<f32> 的小端字节序列化 BLOB。NULL = 未嵌入(走 LIKE 降级)。
/// 用 PRAGMA 探测列存在性,缺失才 ALTER(同 v4/v5/v6 模式)。
fn migrate_v8(conn: &Connection) -> Result<()> {
let has_embedding = column_exists(conn, "knowledges", "embedding");
if !has_embedding {
conn.execute("ALTER TABLE knowledges ADD COLUMN embedding BLOB", [])?;
tracing::info!("v8: 补建 knowledges.embedding 列");
}
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [8])?;
tracing::info!("迁移 v8 完成");
Ok(())
}
/// V9: 幂等补建 ai_providers + ai_tool_executions 表
///
/// 历史遗漏:这两张表从未写入迁移文件(V1-V8 均未包含),
/// 旧库可能通过其他方式已建,新库缺失导致 save_provider 等操作报 SQL 错误。
/// 用 CREATE TABLE IF NOT EXISTS 幂等,已有表不受影响。
fn migrate_v9(conn: &Connection) -> Result<()> {
conn.execute_batch(V9_SQL)?;
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [9])?;
tracing::info!("迁移 v9 完成");
Ok(())
}
/// V10: 知识生命线 — 补 knowledges.reasoning 列 + 新建 knowledge_events 事件表
///
/// - reasoning: AI 提炼时给出的"为何值得沉淀"判断依据(此前 prompt 要求但写库丢弃,
/// 此处补列修复;老库行默认 NULL,前端降级展示"手动录入/无依据")。幂等(PRAGMA 探测)。
/// - knowledge_events: 追加型审计表,记录产生/审核/引用/归档四类事件,支撑生命线视图。
/// 独立表(非 JSON 嵌主表): 一条知识可被引用数百次,JSON 嵌入致行膨胀+更新竞争。
fn migrate_v10(conn: &Connection) -> Result<()> {
if !column_exists(conn, "knowledges", "reasoning") {
conn.execute("ALTER TABLE knowledges ADD COLUMN reasoning TEXT", [])?;
tracing::info!("v10: 补建 knowledges.reasoning 列");
}
conn.execute_batch(V10_SQL)?;
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [10])?;
tracing::info!("迁移 v10 完成");
Ok(())
}
/// V11: 幂等补 projects.deleted_at 列(软删回收站)
///
/// 删除项目改为软删:deleted_at NULL=正常,非空=已进回收站(可恢复)。
/// ProjectRecord 不带该字段,纯靠 SQL WHERE deleted_at IS NULL 过滤;
/// 子表(tasks/releases/branches)不动,FK 仍满足,项目数据完整保留待恢复。
/// 用 PRAGMA 探测列存在性,缺失才 ALTER(同 v4/v5/v6/v8/v10 模式)。
fn migrate_v11(conn: &Connection) -> Result<()> {
if !column_exists(conn, "projects", "deleted_at") {
conn.execute("ALTER TABLE projects ADD COLUMN deleted_at TEXT", [])?;
tracing::info!("v11: 补建 projects.deleted_at 列(软删回收站)");
}
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [11])?;
tracing::info!("迁移 v11 完成");
Ok(())
}
/// V12: 幂等补 projects.path / projects.stack 列(项目绑定真实代码目录)
///
/// 项目与磁盘代码库脱钩是项目管理核心缺失:此版补 path(绑定目录绝对路径) +
/// stack(技术栈 JSON 数组字符串),为「绑定目录 + 探测技术栈」打地基,
/// 第二步「导入历史项目」直接复用。两列均 nullable,老项目 path/stack=NULL 天然兼容。
/// 用 PRAGMA 探测列存在性,缺失才 ALTER(同 v4/v5/v6/v8/v10/v11 模式)。
fn migrate_v12(conn: &Connection) -> Result<()> {
if !column_exists(conn, "projects", "path") {
conn.execute("ALTER TABLE projects ADD COLUMN path TEXT", [])?;
tracing::info!("v12: 补建 projects.path 列(绑定代码目录)");
}
if !column_exists(conn, "projects", "stack") {
conn.execute("ALTER TABLE projects ADD COLUMN stack TEXT", [])?;
tracing::info!("v12: 补建 projects.stack 列(技术栈)");
}
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [12])?;
tracing::info!("迁移 v12 完成");
Ok(())
}
/// V13: 通用应用设置 KV 表(前端 localStorage 迁移目标)
///
/// 存主题/语言/AI 偏好/连接配置等,`value` 为 JSON 字符串。CREATE TABLE IF NOT EXISTS 幂等。
fn migrate_v13(conn: &Connection) -> Result<()> {
conn.execute_batch(V13_SQL)?;
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [13])?;
tracing::info!("迁移 v13 完成");
Ok(())
}
/// V1 建表 SQL /// V1 建表 SQL
const V1_SQL: &str = " const V1_SQL: &str = "
-- 想法表 -- 想法表
@@ -172,3 +389,116 @@ CREATE TABLE IF NOT EXISTS branches (
CREATE INDEX IF NOT EXISTS idx_branches_project_id ON branches(project_id); CREATE INDEX IF NOT EXISTS idx_branches_project_id ON branches(project_id);
CREATE INDEX IF NOT EXISTS idx_branches_task_id ON branches(task_id); CREATE INDEX IF NOT EXISTS idx_branches_task_id ON branches(task_id);
"; ";
/// V3 迁移 SQL — AI 对话表补建(新库首次创建;老库 IF NOT EXISTS 跳过)
///
/// 注:archived 列不在此处 ALTER —— 由 v4 迁移幂等补建。
/// (历史 v3 曾写入版本号但 ALTER 未生效,统一交 v4 用 PRAGMA 探测修复)
const V3_SQL: &str = "
CREATE TABLE IF NOT EXISTS ai_conversations (
id TEXT PRIMARY KEY,
title TEXT,
messages TEXT NOT NULL DEFAULT '[]',
provider_id TEXT,
model TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
";
/// V7 建表 SQL — 知识库表
///
/// kind: 7 种 KnowledgeKind snake_case(review_rule/prompt_template/pitfall/
/// architecture_pattern/diagnosis/deployment_note/workflow_optimization)
/// status: candidate|pending_review|published|archived
/// confidence: high|medium|low(AI 提炼自评,可空)
/// verified: 发布审核时一次性人工标(INTEGER 0/1)
/// reuse_count: 检索命中自动 +1(唯一客观排序信号)
/// source_project/source_ref: 来源溯源(不过滤,仅展示)
const V7_SQL: &str = "
CREATE TABLE IF NOT EXISTS knowledges (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL DEFAULT 'pitfall',
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
tags TEXT,
status TEXT NOT NULL DEFAULT 'candidate',
confidence TEXT,
reuse_count INTEGER NOT NULL DEFAULT 0,
verified INTEGER NOT NULL DEFAULT 0,
source_project TEXT,
source_ref TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_knowledges_status ON knowledges(status);
CREATE INDEX IF NOT EXISTS idx_knowledges_kind ON knowledges(kind);
CREATE INDEX IF NOT EXISTS idx_knowledges_reuse_count ON knowledges(reuse_count DESC);
";
/// V9 建表 SQL — AI Provider 配置 + 工具执行审计
///
/// 历史遗漏补建:ai_providers(AI 提供商配置) + ai_tool_executions(工具调用审计记录)。
/// CREATE TABLE IF NOT EXISTS 保证老库(已有表)和新库(缺表)均安全。
const V9_SQL: &str = "
CREATE TABLE IF NOT EXISTS ai_providers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
provider_type TEXT NOT NULL DEFAULT 'openai_compat',
api_key TEXT NOT NULL,
base_url TEXT NOT NULL,
default_model TEXT NOT NULL,
models TEXT,
is_default INTEGER NOT NULL DEFAULT 0,
config TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS ai_tool_executions (
id TEXT PRIMARY KEY,
conversation_id TEXT,
tool_call_id TEXT NOT NULL,
tool_name TEXT NOT NULL,
arguments TEXT NOT NULL,
result TEXT,
status TEXT NOT NULL DEFAULT 'pending',
risk_level TEXT NOT NULL DEFAULT 'medium',
requested_at TEXT NOT NULL,
executed_at TEXT,
decided_by TEXT
);
";
/// V10 建表 SQL — 知识生命线事件表
///
/// 追加型审计表(只增不改),记录知识产生/审核/引用/归档四类事件,支撑生命线视图。
/// event_type: created | extracted | status_changed | referenced | archived
/// context_json: 因 event_type 而异的上下文(如引用事件的 conv_id+query)。
const V10_SQL: &str = "
CREATE TABLE IF NOT EXISTS knowledge_events (
id TEXT PRIMARY KEY,
knowledge_id TEXT NOT NULL,
event_type TEXT NOT NULL,
source_ref TEXT,
context_json TEXT,
timestamp TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_knowledge_events_kid ON knowledge_events(knowledge_id);
CREATE INDEX IF NOT EXISTS idx_knowledge_events_type ON knowledge_events(event_type);
CREATE INDEX IF NOT EXISTS idx_knowledge_events_kid_type ON knowledge_events(knowledge_id, event_type);
";
/// V13 建表 SQL — 通用应用设置 KV 表
///
/// 前端 localStorage 迁移目标:key/value(JSON 字符串)+ updated_at。
/// CREATE TABLE IF NOT EXISTS 幂等(新库建、老库已有则跳过)。
const V13_SQL: &str = "
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
);
";

View File

@@ -36,6 +36,10 @@ pub struct ProjectRecord {
pub description: String, pub description: String,
pub status: String, pub status: String,
pub idea_id: Option<String>, pub idea_id: Option<String>,
/// 绑定的本地代码目录(绝对路径,可空=未绑定,第二步导入历史项目时复用)
pub path: Option<String>,
/// 技术栈 JSON 数组字符串(如 ["rust","vue","tauri"],由探测填充,可空)
pub stack: Option<String>,
pub created_at: String, pub created_at: String,
pub updated_at: String, pub updated_at: String,
} }
@@ -149,6 +153,10 @@ pub struct AiConversationRecord {
pub messages: String, // JSON array of ChatMessage pub messages: String, // JSON array of ChatMessage
pub provider_id: Option<String>, pub provider_id: Option<String>,
pub model: Option<String>, pub model: Option<String>,
pub models: Option<String>, // 用过的所有 model(JSON 数组字符串,去重)
pub archived: bool, // 是否归档(侧栏折叠展示)
pub prompt_tokens: Option<i64>, // 输入 token 累计(流式 usage 落库)
pub completion_tokens: Option<i64>, // 输出 token 累计(流式 usage 落库)
pub created_at: String, pub created_at: String,
pub updated_at: String, pub updated_at: String,
} }
@@ -168,3 +176,37 @@ pub struct AiToolExecutionRecord {
pub executed_at: Option<String>, pub executed_at: Option<String>,
pub decided_by: Option<String>, // human/auto pub decided_by: Option<String>, // human/auto
} }
// ============================================================
// 知识库模型 (V7)
// ============================================================
/// 知识条目记录(经验沉淀基本单元,共享记忆层)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KnowledgeRecord {
pub id: String,
pub kind: String, // KnowledgeKind snake_case(7 种)
pub title: String,
pub content: String,
pub tags: Option<String>, // JSON 数组字符串
pub status: String, // candidate|pending_review|published|archived
pub confidence: Option<String>, // high|medium|low(AI 提炼自评,可空)
pub reuse_count: i32, // 唯一客观排序信号
pub verified: bool, // 发布审核时一次性人工标
pub source_project: Option<String>, // 来源项目(仅溯源不过滤)
pub source_ref: Option<String>, // 来源实体引用(如 conv:{id})
pub reasoning: Option<String>, // AI 提炼判断依据("为何值得沉淀"),手动录入为 None
pub created_at: String,
pub updated_at: String,
}
/// 知识生命线事件记录(追加型审计:产生/审核/引用/归档)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KnowledgeEventRecord {
pub id: String,
pub knowledge_id: String,
pub event_type: String, // created | extracted | status_changed | referenced | archived
pub source_ref: Option<String>, // 触发来源: conv:{id} / manual / system
pub context_json: Option<String>, // JSON: 因 event_type 而异
pub timestamp: String,
}

View File

@@ -0,0 +1,271 @@
//! ProjectRepo 软删回收站 + 级联清理 的集成测试。
//!
//! 覆盖三类行为:
//! 1. purge_with_descendants — 事务级联删 branches→releases→tasks→projects。
//! 2. soft_delete / list_active / list_deleted / restore — 回收站生命周期。
//! 3. allowed_columns_for 按表隔离 — update_field 误传跨表列名在白名单阶段被拒。
use df_storage::crud::{BranchRepo, ProjectRepo, ReleaseRepo, TaskRepo};
use df_storage::db::Database;
use df_storage::models::{BranchRecord, ProjectRecord, ReleaseRecord, TaskRecord};
// ---------- fixtures ----------
fn now_ts() -> String {
"1700000000000".to_string()
}
fn project(id: &str) -> ProjectRecord {
ProjectRecord {
id: id.to_string(),
name: format!("proj-{id}"),
description: "desc".to_string(),
status: "active".to_string(),
idea_id: None,
path: None,
stack: None,
created_at: now_ts(),
updated_at: now_ts(),
}
}
fn task(id: &str, project_id: &str) -> TaskRecord {
TaskRecord {
id: id.to_string(),
project_id: project_id.to_string(),
title: format!("task-{id}"),
description: "desc".to_string(),
status: "todo".to_string(),
priority: 1,
branch_name: None,
assignee: None,
workflow_def_id: None,
base_branch: None,
created_at: now_ts(),
updated_at: now_ts(),
}
}
fn release(id: &str, project_id: &str) -> ReleaseRecord {
ReleaseRecord {
id: id.to_string(),
project_id: project_id.to_string(),
version: "0.1.0".to_string(),
status: "draft".to_string(),
task_ids: "[]".to_string(),
changelog: None,
created_at: now_ts(),
released_at: None,
}
}
fn branch(id: &str, project_id: &str) -> BranchRecord {
BranchRecord {
id: id.to_string(),
project_id: project_id.to_string(),
task_id: None,
name: format!("br-{id}"),
base: "main".to_string(),
status: "open".to_string(),
created_at: now_ts(),
updated_at: now_ts(),
merged_at: None,
}
}
async fn setup() -> (ProjectRepo, TaskRepo, ReleaseRepo, BranchRepo) {
let db = Database::open_in_memory().await.expect("open_in_memory");
(
ProjectRepo::new(&db),
TaskRepo::new(&db),
ReleaseRepo::new(&db),
BranchRepo::new(&db),
)
}
// ============================================================
// 1. purge_with_descendants — 级联删除
// ============================================================
#[tokio::test]
async fn purge_with_descendants_removes_project_and_all_children() {
let (projects, tasks, releases, branches) = setup().await;
projects.insert(project("p1")).await.unwrap();
tasks.insert(task("t1", "p1")).await.unwrap();
tasks.insert(task("t2", "p1")).await.unwrap();
releases.insert(release("r1", "p1")).await.unwrap();
branches.insert(branch("b1", "p1")).await.unwrap();
branches.insert(branch("b2", "p1")).await.unwrap();
// 另一个项目的子记录不应被波及(只删 p1 的)
projects.insert(project("p2")).await.unwrap();
tasks.insert(task("t3", "p2")).await.unwrap();
let affected = projects.purge_with_descendants("p1").await.unwrap();
assert!(affected, "应命中 p1");
// 四表对应记录全消失
assert!(projects.get_by_id("p1").await.unwrap().is_none());
assert!(tasks.get_by_id("t1").await.unwrap().is_none());
assert!(tasks.get_by_id("t2").await.unwrap().is_none());
assert!(releases.get_by_id("r1").await.unwrap().is_none());
assert!(branches.get_by_id("b1").await.unwrap().is_none());
assert!(branches.get_by_id("b2").await.unwrap().is_none());
// 邻居项目 p2 完好
assert!(projects.get_by_id("p2").await.unwrap().is_some());
assert!(tasks.get_by_id("t3").await.unwrap().is_some());
}
#[tokio::test]
async fn purge_with_descendants_unknown_id_returns_false() {
let (projects, _t, _r, _b) = setup().await;
let affected = projects.purge_with_descendants("ghost").await.unwrap();
assert!(!affected, "不存在的 id 不命中");
}
#[tokio::test]
async fn purge_with_descendants_is_atomic_on_missing_child_table_row() {
// 事务性验证:即使 project 存在但被删语句之间无冲突,purge 应整体成功或整体不变。
// 这里验证 commit 成功路径下子表清空、父表删除;反向证明未部分提交(若部分提交,
// projects 会在事务中途被删但 branches 残留 —— 通过重新 purge 验证幂等空操作)。
let (projects, tasks, _releases, branches) = setup().await;
projects.insert(project("p1")).await.unwrap();
tasks.insert(task("t1", "p1")).await.unwrap();
branches.insert(branch("b1", "p1")).await.unwrap();
assert!(projects.purge_with_descendants("p1").await.unwrap());
// 事务已提交:父表与全部子表一致清空(不会出现父删子留)
assert!(projects.get_by_id("p1").await.unwrap().is_none());
assert!(tasks.get_by_id("t1").await.unwrap().is_none());
assert!(branches.get_by_id("b1").await.unwrap().is_none());
// 再次 purge 已不存在的 id → false,且无副作用
assert!(!projects.purge_with_descendants("p1").await.unwrap());
}
// ============================================================
// 2. soft_delete / list_active / list_deleted / restore
// ============================================================
#[tokio::test]
async fn soft_delete_moves_project_out_of_active_into_deleted() {
let (projects, _t, _r, _b) = setup().await;
projects.insert(project("p1")).await.unwrap();
projects.insert(project("p2")).await.unwrap();
let ok = projects.soft_delete("p1").await.unwrap();
assert!(ok, "软删 p1 应命中");
let active = projects.list_active().await.unwrap();
let active_ids: Vec<_> = active.iter().map(|p| p.id.as_str()).collect();
assert!(!active_ids.contains(&"p1"), "p1 不应在 active");
assert!(active_ids.contains(&"p2"), "p2 仍在 active");
let deleted = projects.list_deleted().await.unwrap();
let deleted_ids: Vec<_> = deleted.iter().map(|p| p.id.as_str()).collect();
assert!(deleted_ids.contains(&"p1"), "p1 应在 deleted");
assert!(!deleted_ids.contains(&"p2"), "p2 不在 deleted");
}
#[tokio::test]
async fn restore_moves_project_back_into_active() {
let (projects, _t, _r, _b) = setup().await;
projects.insert(project("p1")).await.unwrap();
projects.soft_delete("p1").await.unwrap();
let ok = projects.restore("p1").await.unwrap();
assert!(ok, "恢复 p1 应命中");
let active_ids: Vec<_> = projects
.list_active()
.await
.unwrap()
.into_iter()
.map(|p| p.id)
.collect();
assert!(active_ids.contains(&"p1".to_string()), "p1 恢复后回到 active");
let deleted_ids: Vec<_> = projects
.list_deleted()
.await
.unwrap()
.into_iter()
.map(|p| p.id)
.collect();
assert!(!deleted_ids.contains(&"p1".to_string()), "p1 恢复后离开 deleted");
}
#[tokio::test]
async fn soft_delete_on_already_deleted_returns_false() {
// WHERE deleted_at IS NULL 守卫:已删项二次软删不命中
let (projects, _t, _r, _b) = setup().await;
projects.insert(project("p1")).await.unwrap();
assert!(projects.soft_delete("p1").await.unwrap());
// 第二次对已删项 → false(守卫生效)
assert!(
!projects.soft_delete("p1").await.unwrap(),
"已删项二次软删应返回 false"
);
}
#[tokio::test]
async fn soft_delete_unknown_id_returns_false() {
let (projects, _t, _r, _b) = setup().await;
assert!(!projects.soft_delete("ghost").await.unwrap());
}
#[tokio::test]
async fn restore_unknown_id_returns_false() {
let (projects, _t, _r, _b) = setup().await;
assert!(!projects.restore("ghost").await.unwrap());
}
// ============================================================
// 3. allowed_columns_for — 按表隔离
// ============================================================
#[tokio::test]
async fn update_field_rejects_cross_table_column_tasks_name() {
// tasks 表白名单无 "name"(那是 projects/branches 的列)→ Err
let (projects, tasks, _r, _b) = setup().await;
projects.insert(project("p1")).await.unwrap();
tasks.insert(task("t1", "p1")).await.unwrap();
let res = tasks.update_field("t1", "name", "x").await;
assert!(res.is_err(), "tasks 表无 name 列,应被白名单拒绝");
// 对照:tasks 的合法列 title 通过
let ok = tasks.update_field("t1", "title", "renamed").await.unwrap();
assert!(ok);
let rec = tasks.get_by_id("t1").await.unwrap().unwrap();
assert_eq!(rec.title, "renamed");
}
#[tokio::test]
async fn update_field_allows_tasks_status() {
let (projects, tasks, _r, _b) = setup().await;
projects.insert(project("p1")).await.unwrap();
tasks.insert(task("t1", "p1")).await.unwrap();
let ok = tasks.update_field("t1", "status", "done").await.unwrap();
assert!(ok);
let rec = tasks.get_by_id("t1").await.unwrap().unwrap();
assert_eq!(rec.status, "done");
}
#[tokio::test]
async fn update_field_rejects_projects_deleted_at() {
// deleted_at 是专用路径列(soft_delete/restore),不进通用白名单 → 拒绝
let (projects, _t, _r, _b) = setup().await;
projects.insert(project("p1")).await.unwrap();
let res = projects.update_field("p1", "deleted_at", "123").await;
assert!(
res.is_err(),
"deleted_at 不在 projects 白名单,应被拒(专用路径才能写)"
);
}

View File

@@ -1,13 +0,0 @@
[package]
name = "df-task"
version = "0.1.0"
edition = "2021"
[dependencies]
df-core = { path = "../df-core" }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
anyhow = { workspace = true }
chrono = { workspace = true }
tracing = { workspace = true }

View File

@@ -1,74 +0,0 @@
//! 分支管理 — Git 分支的创建、跟踪与生命周期
use serde::{Deserialize, Serialize};
use df_core::types::{BranchId, ProjectId, TaskId};
/// 分支状态
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BranchStatus {
/// 活跃开发中
Active,
/// 待合并
ReadyToMerge,
/// 已合并
Merged,
/// 已删除
Deleted,
}
/// 分支实体
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Branch {
/// 唯一 ID
pub id: BranchId,
/// 分支名称
pub name: String,
/// 所属项目 ID
pub project_id: ProjectId,
/// 关联任务 ID
pub task_id: Option<TaskId>,
/// 基于的分支(通常是 main
pub base_branch: String,
/// 当前状态
pub status: BranchStatus,
/// 创建时间
pub created_at: chrono::DateTime<chrono::Utc>,
}
/// 分支管理器
pub struct BranchManager;
impl BranchManager {
/// 为任务创建分支
///
/// TODO: 接入 df-execute 的 git_ops 执行实际的 Git 操作
pub fn create_for_task(
task_id: &TaskId,
project_id: &ProjectId,
base_branch: &str,
) -> Branch {
// 生成分支名称task/{task_id 前缀}
let short_id = &task_id[..8.min(task_id.len())];
let branch_name = format!("task/{}", short_id);
Branch {
id: df_core::types::new_id(),
name: branch_name,
project_id: project_id.clone(),
task_id: Some(task_id.clone()),
base_branch: base_branch.to_string(),
status: BranchStatus::Active,
created_at: chrono::Utc::now(),
}
}
/// 列出项目的所有活跃分支
///
/// TODO: 接入 df-execute 的 git_ops
pub fn list_active(_project_id: &ProjectId) -> Vec<Branch> {
// TODO: 实现
Vec::new()
}
}

View File

@@ -1,6 +0,0 @@
//! df-task: 任务/分支管理 — 任务 CRUD、分支管理、合并协调、发布计划
pub mod branch;
pub mod merge;
pub mod release;
pub mod task;

View File

@@ -1,91 +0,0 @@
//! 合并协调器 — 分支合并、冲突检测、AI 辅助解决
use df_core::types::{BranchId, TaskId};
/// 合并状态
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MergeStatus {
/// 待合并
Pending,
/// 自动合并中
AutoMerging,
/// 存在冲突,等待解决
Conflicted,
/// 已解决冲突
Resolved,
/// 合并完成
Completed,
/// 合并失败
Failed,
}
/// 合并请求
#[derive(Debug, Clone)]
pub struct MergeRequest {
pub id: String,
pub source_branch: BranchId,
pub target_branch: String,
pub task_id: Option<TaskId>,
pub status: MergeStatus,
pub conflicts: Vec<Conflict>,
}
/// 冲突信息
#[derive(Debug, Clone)]
pub struct Conflict {
pub file_path: String,
pub conflict_type: ConflictType,
pub description: String,
pub auto_resolvable: bool,
}
/// 冲突类型
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConflictType {
/// 文件级别冲突(双方修改了同一文件)
FileModified,
/// 删除/修改冲突
DeleteModify,
/// 二进制文件冲突
Binary,
}
/// 合并协调器
pub struct MergeCoordinator;
impl MergeCoordinator {
/// 创建合并请求
///
/// TODO: 接入 Git 操作执行实际合并
pub fn create_request(
source_branch: BranchId,
target_branch: String,
task_id: Option<TaskId>,
) -> MergeRequest {
MergeRequest {
id: df_core::types::new_id(),
source_branch,
target_branch,
task_id,
status: MergeStatus::Pending,
conflicts: Vec::new(),
}
}
/// 检测冲突
///
/// TODO: 调用 git merge --no-commit --no-ff 检测
pub fn detect_conflicts(_merge_request: &MergeRequest) -> Vec<Conflict> {
// TODO: 实现冲突检测
Vec::new()
}
/// AI 辅助解决冲突
///
/// TODO: 调用 df-ai 的 LLM 分析冲突并提供解决方案
pub fn ai_resolve_conflict(_conflict: &Conflict) -> anyhow::Result<String> {
// TODO: 实现 AI 辅助冲突解决
tracing::warn!("AI 冲突解决尚未实现");
Ok(String::new())
}
}

View File

@@ -1,132 +0,0 @@
//! 发布计划 — 多任务合并、版本号管理、发布流程
use serde::{Deserialize, Serialize};
use df_core::types::{ProjectId, ReleaseId, TaskId};
/// 发布状态
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ReleaseStatus {
/// 计划中
Planned,
/// 准备中(收集已完成的任务)
Preparing,
/// 构建中
Building,
/// 测试中
Testing,
/// 待发布
Ready,
/// 发布中
Releasing,
/// 已发布
Released,
/// 已回滚
RolledBack,
/// 已取消
Cancelled,
}
/// 版本号
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SemanticVersion {
pub major: u32,
pub minor: u32,
pub patch: u32,
pub pre_release: Option<String>,
}
impl std::fmt::Display for SemanticVersion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.pre_release {
Some(pre) => write!(f, "{}.{}.{}-{}", self.major, self.minor, self.patch, pre),
None => write!(f, "{}.{}.{}", self.major, self.minor, self.patch),
}
}
}
/// 发布计划
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Release {
/// 唯一 ID
pub id: ReleaseId,
/// 所属项目 ID
pub project_id: ProjectId,
/// 版本号
pub version: SemanticVersion,
/// 当前状态
pub status: ReleaseStatus,
/// 包含的任务 ID 列表
pub task_ids: Vec<TaskId>,
/// 变更日志
pub changelog: Option<String>,
/// 创建时间
pub created_at: chrono::DateTime<chrono::Utc>,
/// 发布时间
pub released_at: Option<chrono::DateTime<chrono::Utc>>,
}
/// 版本号递增类型
#[derive(Debug, Clone, Copy)]
pub enum VersionBump {
Major,
Minor,
Patch,
}
/// 发布管理器
pub struct ReleaseManager;
impl ReleaseManager {
/// 创建发布计划
pub fn create(
project_id: &ProjectId,
version: SemanticVersion,
task_ids: Vec<TaskId>,
) -> Release {
Release {
id: df_core::types::new_id(),
project_id: project_id.clone(),
version,
status: ReleaseStatus::Planned,
task_ids,
changelog: None,
created_at: chrono::Utc::now(),
released_at: None,
}
}
/// 自动生成变更日志
///
/// TODO: 从 Git 提交历史、任务描述中生成
pub fn generate_changelog(_release: &mut Release) -> anyhow::Result<()> {
// TODO: 实现
tracing::warn!("自动变更日志生成尚未实现");
Ok(())
}
/// 自动递增版本号
pub fn bump_version(current: &SemanticVersion, bump: VersionBump) -> SemanticVersion {
match bump {
VersionBump::Major => SemanticVersion {
major: current.major + 1,
minor: 0,
patch: 0,
pre_release: None,
},
VersionBump::Minor => SemanticVersion {
major: current.major,
minor: current.minor + 1,
patch: 0,
pre_release: None,
},
VersionBump::Patch => SemanticVersion {
major: current.major,
minor: current.minor,
patch: current.patch + 1,
pre_release: None,
},
}
}
}

View File

@@ -1,87 +0,0 @@
//! 任务管理 — 任务实体与 CRUD
use serde::{Deserialize, Serialize};
use df_core::types::{BranchId, Priority, ProjectId, TaskId, TaskStatus};
/// 任务实体
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Task {
/// 唯一 ID
pub id: TaskId,
/// 所属项目 ID
pub project_id: ProjectId,
/// 任务标题
pub title: String,
/// 任务描述
pub description: String,
/// 当前状态
pub status: TaskStatus,
/// 优先级
pub priority: Priority,
/// 关联分支名称
pub branch_name: Option<String>,
/// 关联分支 ID
pub branch_id: Option<BranchId>,
/// 指派人
pub assignee: Option<String>,
/// 标签
pub tags: Vec<String>,
/// 预估工时(小时)
pub estimate_hours: Option<f64>,
/// 实际工时(小时)
pub actual_hours: Option<f64>,
/// 创建时间
pub created_at: chrono::DateTime<chrono::Utc>,
/// 更新时间
pub updated_at: chrono::DateTime<chrono::Utc>,
}
/// 创建任务的输入
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateTaskInput {
pub project_id: ProjectId,
pub title: String,
pub description: String,
#[serde(default)]
pub priority: Priority,
pub assignee: Option<String>,
pub tags: Vec<String>,
pub estimate_hours: Option<f64>,
}
/// 任务管理器
pub struct TaskManager;
impl TaskManager {
/// 创建新任务
///
/// TODO: 接入存储层持久化
pub fn create(input: CreateTaskInput) -> Task {
let now = chrono::Utc::now();
Task {
id: df_core::types::new_id(),
project_id: input.project_id,
title: input.title,
description: input.description,
status: TaskStatus::Todo,
priority: input.priority,
branch_name: None,
branch_id: None,
assignee: input.assignee,
tags: input.tags,
estimate_hours: input.estimate_hours,
actual_hours: None,
created_at: now,
updated_at: now,
}
}
/// 更新任务状态
pub fn transition_status(task: &mut Task, new_status: TaskStatus) -> anyhow::Result<()> {
// TODO: 校验状态转换合法性
task.status = new_status;
task.updated_at = chrono::Utc::now();
Ok(())
}
}

View File

@@ -1,11 +0,0 @@
[package]
name = "df-traceability"
version = "0.1.0"
edition = "2021"
[dependencies]
df-core = { path = "../df-core" }
serde = { workspace = true }
serde_json = { workspace = true }
chrono = { workspace = true }
anyhow = { workspace = true }

View File

@@ -1,130 +0,0 @@
//! 标注系统FIXME/TODO/QUESTION 等标记,批量收集后交给 AI 处理
use df_core::types::ProjectId;
use serde::{Deserialize, Serialize};
/// 标注标记类型
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AnnotationMarker {
/// 需要修复
Fixme,
/// 待办事项
Todo,
/// 疑问待确认
Question,
/// 风险标记
Risk,
/// 决策标记
Decision,
/// 优化建议
Optimize,
}
/// 标注状态
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AnnotationStatus {
/// 未处理
Open,
/// AI 处理中
AiProcessing,
/// 已解决
Resolved,
/// 不处理
WontFix,
}
/// 标注可附加的实体类型
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EntityType {
Requirement,
Feature,
Code,
TestCase,
TestReport,
DesignDoc,
}
/// 标注
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Annotation {
pub id: String,
pub project_id: ProjectId,
/// 附加的实体类型
pub entity_type: EntityType,
/// 附加的实体 ID
pub entity_id: String,
/// 标记类型
pub marker: AnnotationMarker,
/// 标注内容
pub content: String,
/// 位置(文件路径+行号 / 文档段落)
pub location: Option<String>,
/// 状态
pub status: AnnotationStatus,
/// 处理者
pub resolved_by: Option<String>,
/// 处理结果
pub resolution: Option<String>,
pub created_at: i64,
pub resolved_at: Option<i64>,
}
impl Annotation {
pub fn new(
project_id: ProjectId,
entity_type: EntityType,
entity_id: String,
marker: AnnotationMarker,
content: String,
) -> Self {
Self {
id: df_core::types::new_id(),
project_id,
entity_type,
entity_id,
marker,
content,
location: None,
status: AnnotationStatus::Open,
resolved_by: None,
resolution: None,
created_at: chrono::Utc::now().timestamp(),
resolved_at: None,
}
}
}
/// 标注批量处理器
pub struct AnnotationBatchProcessor;
impl AnnotationBatchProcessor {
/// 收集项目中所有未处理的标注
pub fn collect_open_annotations(_project_id: &ProjectId) -> Vec<Annotation> {
// TODO: 从 SQLite 查询所有 status = Open 的标注
vec![]
}
/// 按类型分组
pub fn group_by_marker(annotations: &[Annotation]) -> std::collections::HashMap<AnnotationMarker, Vec<&Annotation>> {
let mut groups: std::collections::HashMap<AnnotationMarker, Vec<&Annotation>> = std::collections::HashMap::new();
for ann in annotations {
groups.entry(ann.marker.clone()).or_default().push(ann);
}
groups
}
/// 将分组后的标注交给 AI 批量处理
///
/// AI 会逐条处理每个标注,更新内容和状态
pub async fn batch_process(_annotations: &[Annotation]) -> anyhow::Result<Vec<Annotation>> {
// TODO: 调用 df-ai 编排器,构建批量处理 prompt
// 1. 按类型分组
// 2. 每组构建一个 AI 请求
// 3. AI 返回处理结果
// 4. 更新标注状态和 resolution
Ok(vec![])
}
}

View File

@@ -1,95 +0,0 @@
//! 决策留痕:所有关键决策的记录、检索和审计
use df_core::types::{DecisionId, ProjectId};
use serde::{Deserialize, Serialize};
/// 决策记录
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Decision {
pub id: DecisionId,
pub project_id: ProjectId,
/// 决策背景
pub context: String,
/// 需要决定的问题
pub question: String,
/// 考虑过的方案
pub alternatives: Vec<String>,
/// 最终决定
pub decision: String,
/// 决策原因
pub reason: String,
/// 决策者
pub decided_by: DecidedBy,
/// 关联实体类型
pub entity_type: Option<String>,
/// 关联实体 ID
pub entity_id: Option<String>,
/// 影响范围
pub impact: Option<String>,
/// 所处阶段
pub stage: Option<String>,
pub created_at: i64,
}
/// 决策者
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DecidedBy {
/// AI 自动决策
Ai,
/// 人工决策
Human,
/// AI 建议后人工确认
AiSuggested,
}
/// 决策日志
pub struct DecisionJournal;
impl DecisionJournal {
/// 记录新决策
pub fn record(
project_id: ProjectId,
context: String,
question: String,
alternatives: Vec<String>,
decision: String,
reason: String,
decided_by: DecidedBy,
) -> Decision {
Decision {
id: df_core::types::new_id(),
project_id,
context,
question,
alternatives,
decision,
reason,
decided_by,
entity_type: None,
entity_id: None,
impact: None,
stage: None,
created_at: chrono::Utc::now().timestamp(),
}
}
/// 查询项目的决策历史
pub fn query_by_project(_project_id: &ProjectId) -> Vec<Decision> {
// TODO: SQLite 查询
vec![]
}
/// 按阶段筛选
pub fn filter_by_stage<'a>(decisions: &'a [Decision], stage: &str) -> Vec<&'a Decision> {
decisions.iter().filter(|d| d.stage.as_deref() == Some(stage)).collect()
}
/// 生成决策时间线
///
/// 按时间顺序展示项目的所有决策,用于复盘和审计
pub fn timeline(_project_id: &ProjectId) -> Vec<Decision> {
// TODO: 查询并按 created_at 排序
vec![]
}
}

View File

@@ -1,9 +0,0 @@
//! 可追溯性引擎:标注系统、决策留痕、需求-测试映射
pub mod annotation;
pub mod decision;
pub mod traceability;
pub use annotation::{Annotation, AnnotationMarker, AnnotationStatus};
pub use decision::{Decision, DecisionJournal};
pub use traceability::TraceabilityMatrix;

View File

@@ -1,78 +0,0 @@
//! 需求-功能-测试可追溯矩阵
//!
//! 建立需求 → 功能 → 测试用例 → 测试报告 的双向追溯关系
use df_core::types::ProjectId;
use serde::{Deserialize, Serialize};
/// 追溯关系
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TraceLink {
/// 上游实体类型 (Requirement / Feature)
pub source_type: String,
pub source_id: String,
/// 下游实体类型 (Feature / TestCase / TestRun)
pub target_type: String,
pub target_id: String,
}
/// 覆盖率统计
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CoverageReport {
/// 功能总数
pub total_features: usize,
/// 已选中功能数
pub selected_features: usize,
/// 有测试用例的功能数
pub features_with_tests: usize,
/// 测试覆盖率百分比
pub coverage_percent: f32,
/// 测试通过率
pub pass_rate: f32,
}
/// 可追溯矩阵
pub struct TraceabilityMatrix;
impl TraceabilityMatrix {
/// 创建追溯关系
pub fn link(
source_type: &str,
source_id: &str,
target_type: &str,
target_id: &str,
) -> TraceLink {
TraceLink {
source_type: source_type.to_string(),
source_id: source_id.to_string(),
target_type: target_type.to_string(),
target_id: target_id.to_string(),
}
}
/// 查询功能的完整追溯链
///
/// Feature → [TestCases] → [TestRuns] → [TestReports]
pub fn trace_feature(_feature_id: &str) -> Vec<TraceLink> {
// TODO: SQLite 查询完整链路
vec![]
}
/// 生成覆盖率报告
pub fn coverage_report(_project_id: &ProjectId) -> CoverageReport {
// TODO: 统计功能-测试用例覆盖情况
CoverageReport {
total_features: 0,
selected_features: 0,
features_with_tests: 0,
coverage_percent: 0.0,
pass_rate: 0.0,
}
}
/// 查找未覆盖的功能(有功能但无测试用例)
pub fn uncovered_features(_project_id: &ProjectId) -> Vec<String> {
// TODO: 找出没有关联测试用例的功能
vec![]
}
}

View File

@@ -10,9 +10,10 @@ pub struct ConditionEngine;
impl ConditionEngine { impl ConditionEngine {
/// 求值条件表达式 /// 求值条件表达式
/// ///
/// TODO: 实现完整的表达式解析,当前仅支持简单的 JSON Path 比较 /// 当前仅支持 "true"/"false" 字面量。未识别的表达式 **默认 false**(保守拒绝),
/// 而非默认 true——条件分支写错或引擎未实现时不应该静默放行。
/// TODO: 实现完整的表达式解析JSON Path / 数值比较 / contains / 逻辑组合)。
pub fn evaluate(expr: &str, _context: &Value) -> anyhow::Result<bool> { pub fn evaluate(expr: &str, _context: &Value) -> anyhow::Result<bool> {
// 骨架:简单的 true/false 字面量
let trimmed = expr.trim(); let trimmed = expr.trim();
if trimmed == "true" { if trimmed == "true" {
return Ok(true); return Ok(true);
@@ -27,7 +28,70 @@ impl ConditionEngine {
// - "$.tags contains 'ai'" — 包含检查 // - "$.tags contains 'ai'" — 包含检查
// - "and/or/not" — 逻辑组合 // - "and/or/not" — 逻辑组合
tracing::warn!("条件表达式引擎尚未完整实现表达式: {}", expr); tracing::warn!("条件表达式引擎尚未完整实现,表达式未识别默认 false: {}", expr);
Ok(true) Ok(false)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn ctx() -> Value {
// 当前实现未使用 context但保持传入以对齐签名
json!({})
}
#[test]
fn test_true_literal_returns_true() {
assert_eq!(ConditionEngine::evaluate("true", &ctx()).unwrap(), true);
}
#[test]
fn test_false_literal_returns_false() {
assert_eq!(ConditionEngine::evaluate("false", &ctx()).unwrap(), false);
}
#[test]
fn test_unsupported_expression_defaults_to_false() {
// 尚未实现的语法:表达式非 true/false 字面量时默认 false(保守拒绝,不静默放行)
assert_eq!(
ConditionEngine::evaluate("$.status == 'completed'", &ctx()).unwrap(),
false
);
}
#[test]
fn test_whitespace_is_trimmed() {
// trimmed() 去除首尾空白后再与字面量比较
assert_eq!(
ConditionEngine::evaluate(" true ", &ctx()).unwrap(),
true
);
assert_eq!(
ConditionEngine::evaluate("\tfalse\n", &ctx()).unwrap(),
false
);
}
#[test]
fn test_empty_string_defaults_to_false() {
assert_eq!(ConditionEngine::evaluate("", &ctx()).unwrap(), false);
assert_eq!(ConditionEngine::evaluate(" ", &ctx()).unwrap(), false);
}
#[test]
fn test_case_sensitive_not_matched() {
// 字面量比较区分大小写:True/False 既非 "true" 也非 "false",默认 false
assert_eq!(ConditionEngine::evaluate("True", &ctx()).unwrap(), false);
assert_eq!(ConditionEngine::evaluate("FALSE", &ctx()).unwrap(), false);
}
#[test]
fn test_arbitrary_string_defaults_to_false() {
assert_eq!(ConditionEngine::evaluate("yes", &ctx()).unwrap(), false);
assert_eq!(ConditionEngine::evaluate("1", &ctx()).unwrap(), false);
assert_eq!(ConditionEngine::evaluate("completed", &ctx()).unwrap(), false);
} }
} }

View File

@@ -74,14 +74,6 @@ impl NodeRegistry {
} }
} }
impl Default for NodeRegistry { // 不实现 Default:原 Default 注册了一个会 panic 的 script 工厂(unimplemented!),
fn default() -> Self { // 违反项目铁律「无 panic——所有占位代码返回空/默认值」。所有调用方应显式 new() +
let mut registry = Self::new(); // 手动 register 真实节点(如 state.rs::build_registry 的做法)。
registry.register("script", |_config| {
// ScriptNode 的工厂 — 需要 df-nodes 依赖后才可用
// 这里返回一个占位实现,实际项目中由 df-nodes crate 注册
unimplemented!("ScriptNode 需要通过 df-nodes 注册")
});
registry
}
}

30
dev-tools.bat Normal file
View File

@@ -0,0 +1,30 @@
@echo off
echo DevFlow 开发工具
echo.
echo 1. 启动开发服务器
echo 2. 停止开发服务器
echo 3. 重启开发服务器
echo 4. 查看运行的 Vite 进程
echo 5. 退出
echo.
set /p choice=请选择操作 (1-5):
if "%choice%"=="1" (
echo 启动开发服务器...
npm run dev
) else if "%choice%"=="2" (
echo 停止开发服务器...
npm run dev:stop
) else if "%choice%"=="3" (
echo 重启开发服务器...
npm run dev:restart
) else if "%choice%"=="4" (
echo 查看运行的 Vite 进程...
tasklist | findstr "vite.exe"
) else if "%choice%"=="5" (
exit /b 0
) else (
echo 无效选择,请重新运行
)
pause

View File

@@ -10,29 +10,70 @@ DevFlow 使用 SQLite (rusqlite) 作为本地存储引擎。df-storage 负责 SQ
## 当前状态 ## 当前状态
- SQLite 连接: 已实现 - SQLite 连接管理: 已实现`Arc<Mutex<Connection>>` 单连接,非连接池)
- Schema 迁移 (6 张表 + 4 索引): 已实现 - Schema 迁移 (V1-V9 累计 11 张表 + 9 索引): 已实现
- CRUD 层: **待实施** - CRUD 层: 已实现(`impl_repo!` 宏 + 11 个 Repo + 向量检索)
## 设计要点 ## 设计要点
### 实施内容 ### 实施内容
1. **泛型 CRUD trait** — 定义统一的 `Repository<T>` 接口 1. **`impl_repo!` 宏 CRUD** — 一次性为各表生成 insert / get_by_id / list_all / query / update_field / update_full / delete当前 11 个 Repo非 trait 抽象)
2. **SQL 构建**参数化查询,防止 SQL 注入 2. **参数化查询**`?1`/`?2` 占位符 + `params![]` 绑定,防止 SQL 注入
3. **事务支持**跨表操作的原子性保证 3. **列名白名单校验**`ALLOWED_COLUMNS` 校验 query / update_field 的动态列名
4. **批量操作**`insert_batch` / `update_batch` 4. **`update_full(record)`** — 整体更新全可变字段(保留 id 与 created_at的原子写
5. **查询构建器**条件查询、分页、排序 5. **向量检索**KnowledgeRepo 余弦相似度 top-N纯 Rust数据量 <50k 暴力遍历)
### 仍待实施
1. **泛型 `Repository<T>` trait** — 当前用宏非 trait无统一接口抽象
2. **事务支持** — 跨表操作的原子性保证(当前单连接 + Mutex无显式事务
3. **批量操作**`insert_batch` / `update_batch`
### 约定 ### 约定
- ID 字段统一使用 `TEXT` (UUID v4) - ID 字段统一使用 `TEXT` (UUID v4)
- 时间字段使用 `INTEGER` (Unix timestamp) - 时间字段使用 `TEXT` (毫秒时间戳字符串,`now_millis_str()` 返回 String)
- JSON 字段使用 `TEXT` 存储 JSON 字符串 - JSON 字段使用 `TEXT` 存储 JSON 字符串
- 所有写操作返回 `Result<(), df_core::error::Error>` - 所有写操作统一返回 `df_core::error::Error` 错误类型insert 返 `Result<String>`idupdate/delete 返 `Result<bool>`(是否影响行)
## update_field 的强制时间戳约束
`impl_repo!` 宏为各 Repo 统一生成的 `update_field(id, field, value)` 生成 SQL:
```sql
UPDATE {table} SET {field} = ?1, updated_at = ?2 WHERE id = ?3
```
**总是连带刷新 updated_at = now**,不可关闭。
### 适用与不适用
- 适用:内容更新(标题、状态、描述等)——这些变更本就该反映为新近修改时间。
- 不适用:纯元数据标记切换(如归档 archived)。归档是元数据,不应改对话的"最近活跃时间",否则侧栏相对时间会跳变为"刚刚"。
### 例外模式:专用方法走原生 SQL
对元数据类切换,新增专用方法绕过 update_field,直接写原生 SQL 只动目标列。例如 AiConversationRepo::set_archived:
```sql
UPDATE ai_conversations SET archived = ?1 WHERE id = ?2
```
不带 updated_at。
同类专用原生 SQL 方法(均不调 update_field),按「是否刷 updated_at」分两类:
- **不刷**(与 set_archived 同策略,只改目标列):
- `KnowledgeRepo::set_embedding``UPDATE knowledges SET embedding = ?1 WHERE id = ?2`(向量写库,非业务修改)
- **刷**(策略相反,刷新时间反映复用行为):
- `KnowledgeRepo::increment_reuse_count``UPDATE knowledges SET reuse_count = reuse_count + 1, updated_at = ?1 WHERE id = ?2`(原子自增 + 刷时间,因复用确属"近期活跃")
> 取舍:是否刷 updated_at 取决于语义——元数据/内部字段切换不刷(避免污染相对时间),反映业务行为变更的刷。
### 相关机制
- `update_full(record)`:整体更新全可变字段,保留 id 与 created_at,用于 save_conversation 落库对话内容(此时确实要刷 updated_at)。
- `ALLOWED_COLUMNS` 白名单:update_field / query 的列名经白名单校验防注入。set_archived 因列名硬编码无需白名单。
## 相关文件 ## 相关文件
- `crates/df-storage/src/lib.rs` — 存储层入口 - `crates/df-storage/src/lib.rs` — 存储层入口
- `crates/df-storage/src/schema.rs` — Schema 定义与迁移 - `crates/df-storage/src/migrations.rs` — Schema 定义与迁移
- `crates/df-core/src/error.rs` — 统一错误类型 - `crates/df-core/src/error.rs` — 统一错误类型

View File

@@ -0,0 +1,248 @@
# B-03 人工审批响应机制设计
> **真相源**(本文档唯一展开完整设计)。功能决策记录仅放摘要 + 指针。
>
> 背景B-260614-03 — df-workflow `HumanNode` 假实现(`human_node.rs:55` 注释"等待审批"但首次迭代直接 return "同意")。
> 状态:📐 **设计完成,未实施** | 创建2026-06-14 | 来源:多代理探索
> 依赖B-260614-06(execution_id 硬编码)、B-260614-07(每节点全新空 StateMachine)
---
## 一、背景与问题
`HumanNode` 是工作流中唯一的阻塞节点,用于在 DAG 执行链路上插入人工确认门控(如"发布前确认""删除前确认")。当前实现 `crates/df-nodes/src/human_node.rs` 已正确发送 `WorkflowEvent::HumanApprovalRequest` 到事件总线,但**紧接着直接 `return NodeOutput { decision: "同意" }`**,从不等待前端审批响应。这导致:
1. 人工审批门控形同虚设——工作流永远按"同意"放行,无人工拦截能力。
2.`ai.rs``ai_approve` 严谨审批链路(Low 自动 / Medium+High 暂停等审批)矛盾——同一项目两套审批机制,一严谨一形同虚设。
3. 前端 `approve_human_approval` IPC、`HumanApprovalResponse` 事件、`stores/project.ts` 监听链路均已接通,却被 HumanNode 的假返回架空。
## 二、现状勘察:链路 90% 已通,缺口仅 1 处
经代码勘察,端到端审批响应链路的基础设施**已全部就位**,唯一缺口在 HumanNode 本身。
| 组件 | 位置 | 状态 |
|------|------|------|
| `WorkflowEvent::HumanApprovalRequest` / `HumanApprovalResponse` 事件 | `df-core/src/events.rs:60-73` | ✅ 已定义 |
| `EventBus`(tokio broadcastcapacity 256`subscribe()`) | `df-workflow/src/eventbus.rs` | ✅ 可用 |
| `approve_human_approval` IPC(前端响应回总线) | `src-tauri/src/commands/workflow.rs:161` | ✅ 已实现 |
| `AppState.event_bus` 单一全局总线 | `src-tauri/src/state.rs:149` | ✅ run_workflow 与 NodeContext 共享同一 sender |
| 前端监听 `workflow-event` + 捕获 Request + 调 IPC | `src/stores/project.ts:214, 228` | ✅ 已接通 |
| run_workflow 转发**所有**事件(含 Request/Response)到前端 | `src-tauri/src/commands/workflow.rs:83` | ✅ |
| **HumanNode.execute 订阅 Response 等待审批** | `crates/df-nodes/src/human_node.rs:55` | ❌ **缺口:发完直接 return** |
### 端到端路径验证
```
HumanNode.execute
→ ctx.event_bus.send(HumanApprovalRequest) # 同一 broadcast bus
→ run_workflow 转发器(独立 receiver) emit "workflow-event" 到前端
→ 前端 stores/project.ts 捕获 Request存 pendingApproval渲染审批 UI
→ 用户点"同意/拒绝"
→ invoke('approve_human_approval', { execution_id, node_id, decision, comment })
→ workflow.rs:161 构造 HumanApprovalResponsestate.event_bus.send(Response)
→ 同一 broadcast bus
→ HumanNode 的 receiver 收到 Response ✓
→ 过滤 execution_id + node_id 命中 → 返回 NodeOutput
```
`AppState.event_bus``run_workflow`(`workflow.rs:100``state.event_bus.clone()` 传入 `DagExecutor::new`)与 `NodeContext.event_bus`(`executor.rs:77` 传入 `self.event_bus.clone()`)之间共享同一 `broadcast::Sender`(Clone 仅复制 sender 句柄,底层通道同一)。`approve_human_approval` 发往 `state.event_bus`,即发往 HumanNode 订阅的同一通道。**路径闭环成立**。
## 三、B-06 / B-07 前置依赖的真实影响
todo.md 标 B-03 依赖 B-06/B-07。核对后**分级澄清**,避免误解为硬阻塞:
### B-06(execution_id 硬编码 "dummy-execution-id")
- **单工作流场景**B-03 **照常工作**。Request/Response 两端都取 `ctx.execution_id`(当前 = "dummy"),过滤匹配。
- **多工作流并发场景**:所有 execution_id 相同,跨工作流的 Response 会错配到同 node_id 的别的工作流实例 → **必须 B-06 修复**(execution_id 从 run_workflow 已生成的真 ID 下沉到 executor 再到 NodeContext)才能正确隔离。
- **结论**B-06 是**并发正确性**前置非单流功能性前置。B-03 实现完成后,单工作流可用;并发安全等 B-06。
### B-07(每节点全新空 StateMachine)
- HumanNode 取消检查 `ctx.node_status.is_cancelled(&ctx.node_id)` 恒 false(空状态机 `get()` 返回 `Pending`)。
- **即使 B-07 修复**(共享 `self.state_machine`),取消仍不生效——因为 `StateMachine`(`state.rs`)**无 `set_cancelled` 方法**,也无 `cancel_workflow_node` IPC、无前端取消按钮。
- **结论**B-07 是取消机制的**必要非充分**条件。取消要真正端到端生效,还需另补三件(见第七节)。B-03 的响应等待 + 超时核心功能不依赖 B-07。
## 四、核心机制设计
`human_node.rs::execute` 改为:**先订阅 → 发请求 → `select!` 循环等响应**。
### 4.1 订阅时序铁律
tokio `broadcast` 通道**不回放历史消息**——`subscribe()` 调用之后发送的消息才进入该 receiver 的队列。因此必须:
```
subscribe() ← 必须先于 send(Request)
send(Request)
select! { rx.recv() | timeout | cancel }
```
若顺序颠倒(subscribe 在 send 之后)HumanNode 的 receiver 在 Response 发出时尚不存在Response 丢失HumanNode 死等到超时。
### 4.2 execute 实现骨架
```rust
async fn execute(&self, ctx: NodeContext) -> NodeResult {
let config = ctx.config.as_object().cloned().unwrap_or_default();
let title = config.get("title").and_then(|v| v.as_str()).unwrap_or("请确认");
let description = config.get("description").and_then(|v| v.as_str()).unwrap_or("");
let options: Vec<String> = config.get("options")
.and_then(|v| v.as_array())
.map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_else(|| vec!["同意".into(), "拒绝".into()]);
let timeout_secs = config.get("timeout_secs").and_then(|v| v.as_u64()).unwrap_or(3600);
// 1. 先订阅再发请求broadcast 不回放历史)
let mut rx = ctx.event_bus.subscribe();
// 2. 发审批请求
ctx.event_bus.send(WorkflowEvent::HumanApprovalRequest {
execution_id: ctx.execution_id.clone(),
node_id: ctx.node_id.clone(),
title: title.into(),
description: description.into(),
options: options.clone(),
}).await;
// 3. select! 循环Response / 超时 / 取消
let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_secs);
let mut cancel_tick = tokio::time::interval(Duration::from_millis(500));
cancel_tick.tick().await; // 丢弃首个立即触发
loop {
tokio::select! {
recv = rx.recv() => match recv {
Ok(WorkflowEvent::HumanApprovalResponse {
execution_id, node_id, decision, comment
}) if execution_id == ctx.execution_id && node_id == ctx.node_id => {
// decision 合法性校验
if !decision.is_empty() && (options.is_empty() || options.contains(&decision)) {
return Ok(NodeOutput::from_value(serde_json::json!({
"decision": decision,
"comment": comment.unwrap_or_default(),
})));
}
return Err(anyhow::anyhow!("审批决策非法: {}", decision));
}
Ok(_) => continue, // 其他节点/类型的事件,忽略
Err(broadcast::error::RecvError::Lagged(n)) => {
tracing::warn!("HumanNode {} 漏收 {} 条事件(可能错过自身响应,继续)", ctx.node_id, n);
continue; // 风险:若恰好漏收自身 Response本节点将等到超时
}
Err(broadcast::error::RecvError::Closed) => {
return Err(anyhow::anyhow!("事件总线关闭,审批无法完成"));
}
},
_ = tokio::time::sleep_until(deadline) => {
return Err(anyhow::anyhow!("人工审批超时({}s)", timeout_secs));
}
_ = cancel_tick.tick() => {
if ctx.node_status.is_cancelled(&ctx.node_id) {
return Err(anyhow::anyhow!("人工审批被取消"));
}
}
}
}
}
```
### 4.3 决策点
| 决策 | 取值 | 原因 |
|------|------|------|
| `options` 校验 | 空数组时不校验(允许自由文本决策);非空时强制 `decision ∈ options` | 空数组语义 = 自由文本审批;非空 = 枚举选项,非法值应报错而非静默放行 |
| `Lagged` 处理 | 警告日志 + continue | capacity 256 + 审批低频,漏自身 Response 概率极低;丢弃则误判超时更糟 |
| 超时来源 | 配置 `timeout_secs`,默认 3600s | 保留现状默认,支持节点级配置(如"删除确认"给更长超时) |
| 取消检查频率 | 500ms interval 轮询 `is_cancelled` | 当前无主动取消信号机制,轮询是 B-07 修复前的过渡B-07 + `set_cancelled` 后仍需轮询(除非引入 `Notify`) |
| 过滤键 | execution_id + node_id 双键 | node_id 单键不够(跨工作流可能重复)execution_id 单键不够(同工作流同层多 HumanNode) |
## 五、关键时序
```
HumanNode.execute run_workflow 转发器 前端 store approve_human_approval
│ │ │ │
│ subscribe() (rx 建位) │ │ │
│ send(Request) ──broadcast──┤ │ │
│ ├─emit workflow-event──→│ │
│ │ │ pendingApproval=… │
│ (select! 阻塞等 rx) │ │ (UI 渲染审批卡片) │
│ │ │ 用户点"同意" │
│ │ │──── invoke ──────────┤
│ │ │ │ send(Response)
│ │ │ │ └─broadcast─┐
│ rx.recv() = Response ✓ ←──┼───────────────────────┼──────────────────────┼──────────────┘
│ 过滤 exec_id+node_id 命中 │ │ │
│ return NodeOutput │ │ │
```
**说明**`run_workflow` 转发器是独立的 broadcast receiver它收到 Response 后会再 emit 一次到前端(`workflow.rs:83` 无差别转发所有事件)。这是**无害 echo**——前端 store 在调 IPC 后已本地清 `pendingApproval`,重复的 Response 事件不影响状态。
## 六、并发边界
| 场景 | 处理 |
|------|------|
| 同层多个 HumanNode 并行 | 各自独立 receiver各收全量 Response`node_id` 过滤互不干扰(execution_id 同层相同,隔离靠 node_id) |
| 跨工作流并发 HumanNode | node_id 可能重复,**必须 B-06 真 execution_id 隔离**;未修前并发场景有错配风险 |
| 前端未渲染审批 UI | 现有 store 已接通捕获 RequestUI 组件渲染属前端独立工作B-03 后端不阻塞 |
| 前端审批后转发器 echo Response | 无害store 已清 pendingApproval |
| 事件总线容量 | capacity 256审批事件低频正常不触发 Lagged |
| 审批超时无响应 | `select!``sleep_until(deadline)` 分支返回 Err节点置 Failed工作流中止后续层 |
## 七、取消机制范围界定(B-03a / B-03b 拆分)
取消要端到端生效,当前缺三件,均不在 B-03 响应等待核心内:
1. **`StateMachine::set_cancelled()` 方法** — `state.rs` 当前只有 `is_cancelled` 查询,无对应 setter(`set_waiting`/`set_skipped` 不经转换校验Cancelled 同理可加)
2. **`cancel_workflow_node` IPC** — 前端触发取消的入口(当前无)
3. **前端取消按钮 + 调 IPC** — UI 触发点
**建议拆分**
| 子任务 | 范围 | 依赖 |
|--------|------|------|
| **B-03a** | HumanNode 响应等待 + 超时(本设计第四节) | 无硬依赖,单工作流即可用 |
| **B-03b** | 取消机制:`set_cancelled` + cancel IPC + 前端按钮 | B-07(共享 StateMachine) + 上述三件 |
B-03a 不依赖 B-07 即可工作(取消分支恒 false等价无取消功能不残)。todo.md 原文"B-06/B-07 修了 is_cancelled 才有意义"应理解为B-07 是取消的必要前提,但取消本身需独立补全(归入 B-03b)。
## 八、改动清单
| 文件 | 改动 | 风险 |
|------|------|------|
| `crates/df-nodes/src/human_node.rs` | 重写 `execute`subscribe → send → `select!` 循环 | 低,单文件,无外部接口变更 |
| `crates/df-workflow/src/eventbus.rs`(可选) | 删 `try_recv_human_approval` 死代码 TODO(`eventbus.rs:49-53`,零调用) | 低,零调用方 |
**不改动**`df-core/events.rs` / `df-workflow/state.rs` / `src-tauri/commands/workflow.rs` IPC / 前端 store —— 全部基础设施复用,零侵入。
### B-03b 额外改动(取消机制,后续)
| 文件 | 改动 |
|------|------|
| `crates/df-workflow/src/state.rs` | 加 `set_cancelled` 方法(不经转换校验,同 `set_waiting`) |
| `crates/df-workflow/src/executor.rs` | B-07`NodeContext.node_status``self.state_machine.clone()` 而非 `StateMachine::new()` |
| `src-tauri/src/commands/workflow.rs` | 新增 `cancel_workflow_node` IPC |
| `src/stores/project.ts` | 审批 UI 加"取消"按钮,调 cancel IPC |
## 九、测试设计
| 用例 | 方法 | 期望 |
|------|------|------|
| 正常审批:发匹配 Response → 收到决策 | 构造 EventBus + NodeContextspawn execute另起 task 发匹配(exec_id+node_id) Response | 返回 NodeOutput.decision = 发送的 decision |
| execution_id 不匹配Response 被过滤 | 发不匹配 execution_id 的 Response | 节点继续阻塞,短超时验证 → Err "超时" |
| node_id 不匹配Response 被过滤 | 发不匹配 node_id 的 Response | 同上 |
| 超时:无 Response | `timeout_secs=1`,不发 Response | Err "人工审批超时(1s)" |
| decision 非法options 内无该决策 | 配置 options=["同意","拒绝"],发 decision="随便" | Err "审批决策非法: 随便" |
| options 空允许自由文本 | 配置 options=[],发任意 decision | 返回该 decision(不校验) |
| broadcast 关闭 → Err | drop 所有 sender 后 recv | Err "事件总线关闭" |
| Lagged 容忍(可选) | 构造小容量 bus 灌满跳过,验证不 panic | warn 日志 + 继续 |
**取消分支测试**(B-03b):依赖 `set_cancelled` + B-07本阶段跳过或 mock `is_cancelled` 返回 true 验证分支可达。
---
**相关**
- 功能决策记录「工作流人工审批节点(B-03)」— 设计摘要
- `docs/todo.md` B-260614-03 — 任务看板
- `crates/df-nodes/src/human_node.rs` — 实施位置
- `crates/df-workflow/src/eventbus.rs` — EventBus 基础设施
- `src-tauri/src/commands/workflow.rs:161``approve_human_approval` IPC

View File

@@ -0,0 +1,407 @@
# 功能决策记录 — 归档
> 从 [功能决策记录.md](./功能决策记录.md) 归档的条目——纯实现流水、老 Sprint 决策、UX 微调、已被取代或合并的细节。这些条目在「3 个月回看是否仍影响系统/功能设计理解」判断下已不再需要常驻主文档,但完整保留以备回溯。
>
> 创建2026-06-14 | 性质:归档只读,不再维护更新
## 归档判断标准
- 一次性代码审查流水(甄别落地、骨架删除、列表查询过滤)
- UX 微调(工具卡片折叠、对话滚动、错误友好化、空白屏修复)
- 实现细节参数解析抽函数、token 记录策略、model 追溯)
- 老 Sprint 决策已被新设计取代或合并
> 其中**架构级结论**已提炼一句留在主文档对应小节,归档条目为细节展开。
---
## 一、AI Chat 上下文窗口与并发控制 [任务 #43 系列]
> 主文档保留一句架构结论:**ContextManager 类型替换为 messages 真相源 + 双层 Semaphore 并发控制(全局 3 / 单对话 2**。以下为实现细节归档。
### ContextManager 接线方式:类型替换而非 Wrapper [任务 #43]
- **决策**`AiSession.messages` 字段类型从 `Vec<ChatMessage>` 直接改为 `ContextManager`,后者成为消息的唯一持有者(内含 `Vec<TrackedMessage>` + token 缓存 + 裁剪能力)。不引入 Wrapper 包装层。
- **原因/取舍**Wrapper 方案下 Vec 是真相、ContextManager 是临时视图——每次 `build_for_request` 都要 clone 给 ContextManagertoken 缓存永远滞后一轮或每次重建(双重复制)。类型替换让 push 即刻更新 token 计数、零额外 clone、单一数据源。代价是 `save_conversation` 等需全量的场景要显式调 `all_messages_clone()`多一行但语义明确。13 处操作点中 6 处签名不变push/clear、4 处微调clone→all_messages_clone/iter、2 处需重写。
- **状态**:✅ 已落地2026-06-13cargo check + vue-tsc 通过,待 tauri dev 实测)
### Token 计数方案:字符粗估零依赖 [任务 #43]
- **决策**:用 `chars_count × 0.35` 粗估单条消息 token 数(~2.8 字符/token每条消息 +4 token 固定开销role 标记),每个 tool_call +30 tokenJSON 结构)。不引入 tiktoken-rs 或任何 tokenizer 依赖。
- **原因/取舍**:用途是「发送前判断是否超限」做预算控制,误差 ±15% 完全可接受tiktoken-rs 引入 BPE 数据文件约 5MB对 Tauri 桌面应用打包不友好provider 返回的 usage 可用于事后校准闭环暂不实施(原 calibrate 方法未接线Review 时已删,留待未来按 provider usage 重做。chars_ratio=0.35 对中英混合文本偏保守(纯英文 ~0.25,纯中文 ~0.5-0.7),宁可多算不少算。
- **状态**:✅ context.rs 已落地2026-06-13校准闭环暂缓
### 淘汰算法:分组滑动窗口 + 三元组保护 [任务 #43]
- **决策**超预算时从最旧消息开始按「淘汰单元」丢弃。Standalone 消息单独成单元;`Assistant(tool_calls) + Tool(result)* + Assistant(final_text)` 工具调用三元组作为原子整体要么全保留要么全丢弃。最后 6 条消息≈2 个完整用户轮次)设为保护区永不淘汰。
- **原因/取舍**:工具调用三元组若拆散会导致 LLM 看到工具调用但找不到对应结果(或反之),产生幻觉重复调用。保护区防止丢失即时上下文。替代方案是直接按消息数截断(简单但破坏三元组),或按 token 截断到某位置(可能从三元组中间切断)。分组滑动窗口在安全性和信息保留间取平衡。
- **状态**:✅ 已落地2026-06-13
### 裁剪时机build_for_request 时裁剪push 不触发 [任务 #43]
- **决策**token 预算检查和消息裁剪仅在 `build_for_request()` 构建请求时执行。`push()` 只做追加+计缓存,不做任何淘汰。
- **原因/取舍**Agentic Loop 一轮执行中会多次 pushassistant 回复 → tool_result → 可能再 assistant这些属于当前活跃轮次的消息绝不能被中途裁剪掉。只在「即将发给 LLM」这个时间点评估并裁剪旧消息语义清晰且安全。
- **状态**:✅ 已落地2026-06-13
### 持久化策略裁剪仅影响发送视图DB 存全量 [任务 #43]
- **决策**`all_messages_clone()` 返回全量未裁剪消息用于 save_conversation 落库;`build_for_request()` 返回裁剪后版本发给 LLM。两者解耦。
- **原因/取舍**:用户切换对话回来期望看到完整历史,不应因自动裁剪而永久丢失。裁剪是临时的「发送时压缩」类似 gzip。未来若要做永久摘要压缩将旧对话提炼为 summary 消息插入),那是独立 feature 不影响此设计。
- **状态**:✅ 已落地2026-06-13
### 多工具调用并行化join_all 无上限 [任务 #43]
- **决策**`process_tool_calls` 中 Low 风险工具收集后用 `futures::future::join_all` 并行执行Med/High 审批工具仍串行(需用户交互)。工具执行本身不限并发数(本地操作)。
- **原因/取舍**LLM 一次返回 N 个独立工具调用(如同时读 3 个文件)时,串行执行 = O(N×T),并行 = O(T)。N=3 时节省 ~600ms/轮。工具执行是本地 I/Oread_file/grep 等),无外部限流风险故不加 Semaphore。仅 LLM 调用受并发控制。
- **状态**:✅ 已落地2026-06-13
### LLM 并发控制:双层 Semaphore [任务 #43]
- **决策**AppState 新增 `LlmConcurrency`(封装两个双层 `Arc<Mutex<Arc<Semaphore>>>`——全局并发默认 3 / 单对话默认 2。permit 在 3 个叶子 LLM 调用点 acquire`run_agentic_loop` 内 stream_llm 前、`generate_title_via_llm``extract_knowledge_from_conversation`),作用域结束自动释放;工具执行不受控。`LlmConcurrency: Clone`(两 Arccheap作为参数串到底spawn 函数收 by-value move、叶子收 `&ref`。acquire 顺序固定 global→per_conv所有调用点一致防死锁。
- **原因/取舍**:多对话场景下同时跑 2-3 个对话可能撞 provider RPM 限制导致 429。双层控制全局防总并发失控单对话防单对话独占标题生成 + 主循环 + 提炼并发)。用 `Arc<Mutex<Arc<Semaphore>>>` 双层包装而非裸 `Arc<Semaphore>`——tokio Semaphore permits 构造时固定不可增减,替换内层 Arc 即重建,已持有旧 permit 不受影响。permit 放叶子调用点(最接近真实 HTTP 调用)而非 command 入口,限流粒度精准且不阻塞非 LLM 路径。
- **状态**:✅ 已落地2026-06-13
### per_conv 实为应用级单一信号量(非 per-conv map[任务 #43 / Review 修正]
- **决策**`LlmConcurrency.per_conv` 字段命名暗示「单对话」并发,但实现是应用级**单一** `Semaphore`(非 `HashMap<conv_id, Semaphore>`)。当前不修实现,仅在 `state.rs` 结构体 doc 标注真实语义 + 未来重构路径。
- **原因/取舍**`AiSession``Arc<Mutex<AiSession>>` 单例,且 `generating` 互斥保证同一时刻仅一个对话的 `run_agentic_loop` 在跑。故 per_conv 实际退化为「单对话内并发」(主循环 stream_llm + 标题生成 + 知识提炼三者受限流约束)——命名虽宽泛但当前语义恰好正确,非 bug。改 HashMap 是过度设计(单例会话下多 map 项永不被并发访问)。风险留待未来:若支持多对话并发 loopper_conv 需随之改 `HashMap<conv_id, Semaphore>` 才名副其实。揭示 `LlmConcurrency``AiSession` 单例的隐式耦合——本次 review 最有价值的发现。
- **状态**:✅ 注释留痕2026-06-13 Review 修正state.rs 结构体 doc 标注;实现未改,非 bug多对话路线时重构
### 裁剪策略与模型选择正交 [任务 #43 / 架构边界]
- **决策**:上下文窗口管理的 `ContextConfig` 不含 `mode`/模型选择字段。「高精度/低精度对话」(深度思考/reasoning_effort/模型选择)属于 LLM 调用层参数(`CompletionRequest` 层面与裁剪策略sliding_window / 未来 summarization是**正交维度**,不混入 ContextManager。未来摘要压缩作为独立模块实现。
- **原因/取舍**避免把不同层面的控制拧到一个配置对象里——ContextConfig 只管「窗口多大、怎么裁」,模型/推理模式由调用方在构建 `CompletionRequest` 时决定。职责单一,后续扩展任一维度不影响另一侧。
- **状态**:✅ 架构边界已划定2026-06-13 讨论确认)
### save_conversation 异步化 [任务 #43]
- **决策**:循环结束时 spawn 异步落库,先释放 `generating=false` + emit `AiCompleted`,不阻塞前端收到完成事件。
- **原因/取舍**:当前 save_conversation 同步执行upsert + JSON 序列化),阻塞 Completed 事件几十~几百毫秒。落库失败不影响已完成的结果展示,异步化提升用户感知响应速度。代价是进程崩溃时最后一轮可能未落库(概率极低且下次启动可从 LLM provider 侧无法恢复 anyway
- **状态**:✅ 已落地2026-06-13
### build_for_request 只传 token 数值,不传 system_prompt 文本 [任务 #43 / Review 修正]
- **决策**`build_for_request(sys_tokens: u32) -> (Vec<ChatMessage>, bool)` 只接收 system prompt 的预估 token 数,不接收 `&str` 文本。system_prompt 的 token 估算在调用方(`ai.rs`)完成。
- **原因/取舍**职责单一——ContextManager 负责裁剪和消息管理,不需要知道 system prompt 的文本内容。避免每次调用传递可能很长的字符串(含知识库+技能注入后可达几千字符),且 `build_for_request` 内部不混入估算逻辑。
- **状态**:✅ 已落地2026-06-13
### join_all 不对 tool_calls 做 sort保留原始 index [任务 #43 / Review 修正]
- **决策**:收集 Low 风险工具时保留原始 `(index, draft)` 元组,不额外 `sort_unstable_by_key`。LLM 返回的 tool_calls 已按 index 有序sort 是多余且可能打乱语义顺序。
- **原因/取舍**`futures::join_all` 保证结果顺序与输入一致,只要输入有序输出就有序。去掉 sort 减少一次 O(n log n) 且避免意外重排。回填 session.messages 时按原始 tool_call_id 匹配即可,不依赖数组位置。
- **状态**:✅ 已落地2026-06-13
### build_for_request 视图裁剪,不 mutate self.messages [任务 #43 / Review 修正]
- **决策**`build_for_request(&self)` 改不可变借用,超预算时构造裁剪**视图**返回(`self.messages[trim_end..]` clone`drain` 修改自身。删除原会 `self.messages.drain(0..trim_end)``trim_to_budget` 方法。
- **原因/取舍**:原 mutate 实现违背「裁剪仅影响发送视图」契约——`drain``all_messages_clone()`save_conversation 数据源)也返回裁剪版,长对话每轮丢历史、累积性数据丢失。视图裁剪每轮 build 重算 trim_endO(n) 遍历淘汰单元n 通常 <50 可忽略),换全量持久化不被破坏。
- **状态**:✅ context.rs 已落地2026-06-13 Review 修正5 单测通过)
### AiSession.messages 类型替换为 ContextManager + 13 处接线 [任务 #43 / Part A 第二块]
- **决策**`AiSession.messages``Vec<ChatMessage>` 直接替换为 `ContextManager`(类型替换,非 wrapper 包装层。13 处操作点适配push/clear/len/iter 签名天然兼容零改动switch 对话改 `restore_from_messages(Vec)``replace_tool_result` 自由函数删除改走 `ContextManager::replace_tool_result_content` 方法DRY 收敛,方法已含 token 重估save_conversation 与 ensure_conversation_title 改 `all_messages_clone()` 取全量。
- **原因/取舍**:类型替换而非 wrapper 层——消息真相源唯一,避免 Vec + ContextManager 双存导致状态分裂ContextManager 方法签名刻意与原 Vec 操作对齐push/clear/len/iter13 处中 6 处零改动,改动面最小。核心改造点 run_agentic_loop 由 `session.messages.clone()` 全量塞入改为 `build_for_request(sys_tokens)`:调用方用 `TokenEstimator::estimate_text(&system_prompt)` 估算 system prompt tokenContextManager 只接收数值不接触 prompt 文本。
- **状态**:✅ ai.rs 已落地2026-06-13cargo check 通过 + df-ai context 5 单测绿)
### 裁剪触发时不 emit 前端事件 [任务 #43 / Part A]
- **决策**`build_for_request` 返回的 `trimmed: bool` 暂忽略(`let (history_msgs, _trimmed) = ...`),裁剪发生时不向前端 emit 任何事件。
- **原因/取舍**:裁剪是无损优化——内存 `all_messages_clone` 与 DB 持久化均保留全量历史,仅发送给 LLM 的视图裁掉旧消息。计划原拟用 `AiError` 通知前端,但 AiError 语义是「错误」,裁剪不是错误会误导用户以为出错;前端无需感知裁剪(对 UX 透明)。未来若需「已压缩早期历史」提示条,新增专用事件(如 AiContextTrimmed而非复用 AiError。
- **状态**:✅ ai.rs 已落地2026-06-13
### B1 Low 风险工具 join_all 并行 + 串行回填 [任务 #43 / Part B]
- **决策**`process_tool_calls` 重写——批量发 Started 后Low 风险工具 `futures::future::join_all` 并行 execute闭包内完成即 emit Completed/Error不持 session 锁),`join_all` 返回后串行 push tool_result + audit持锁。Med/High 审批逻辑不变。
- **原因/取舍**:原 for 循环逐个串行 executeN 个独立 Low 工具 = N 倍等待;并行化总耗时 ≈ 最慢一个。execute + emit 放闭包内完成即通知前端体感逐个出结果push/audit 必须持 session 锁故留 join_all 后串行。`join_all` 保序——结果顺序 = 输入顺序 = tc_list sort 后的原始 index 顺序tool_result 回填不乱序。
- **状态**:✅ ai.rs 已落地2026-06-13
### B1 附注tool_result push 顺序变化无语义影响 [任务 #43 / Part B]
- **决策**Med/High 占位 tool_result 先于 Low 结果 push分类阶段先处理审批占位Low 结果 join_all 后回填),与原「按 tc_list 交错顺序 push」不同。
- **原因/取舍**LLM 按 `tool_call_id` 关联 tool_result不看消息绝对位置连续 tool_result 都是 ContextManager 的 ToolResultTail、归同一三元组顺序不影响语义。两阶段占位先行 + 结果后填)比交错处理实现简单。
- **状态**:✅ ai.rs 已落地2026-06-13
### B2+B3 正常完成save/title/extract 打包后台 spawn [任务 #43 / Part B]
- **决策**`run_agentic_loop` 正常完成段,把 save_conversation + maybe_spawn_extraction + ensure_conversation_title 三者打包进**同一** `tauri::async_runtime::spawn` 后台 task主流程只做 `generating=false` + emit Completed。stop 路径2 处save 保持同步 awaittitle 改 `spawn_ensure_title` 后台。
- **原因/取舍**:① 计划原拟裸 spawn save`maybe_spawn_extraction` 明示「需在 save 之后(读已落库消息)」——裸 spawn save 与 extract 各自独立 task 顺序不保证extract 可能读到旧 DB打包同一 task 内 `save → extract → title` 串行 await 保顺序。② stop 路径 save 保持同步stop 后用户可能立刻发新消息触发新 loop两 loop 的 save 并发 upsert 会竞态(读旧值叠加丢 token正常完成段同风险但频次低、最多丢少量 token 累加(非功能错误),可接受。③ `ensure_conversation_title` 签名从 `provider: &dyn LlmProvider` 改收 `provider_config: &AiProviderRecord`、内部自建 provider——`&dyn` 非 'static 无法 move 进 spawn收 config 克隆进 task 后自建。
- **状态**:✅ ai.rs 已落地2026-06-13并发场景待实测
### Semaphore 重建采用「软收敛」策略 [任务 #43]
- **决策**:用户在 Settings 调整并发上限时,通过 `*semaphore = Arc::new(Semaphore::new(n))` 替换整个 Arc 内部值。已持有旧 permit 的任务不受影响,新请求走新限制。缩并发时实际并发 = 旧持有数 + 新上限(软收敛非硬切断)。
- **原因/取舍**tokio Semaphore 的 permits 数只能在构造时设定,运行时无法增减,这是标准限制。替代方案(如用 Mutex+计数器手动实现复杂度高且易出错。「软收敛」行为可接受——用户调低并发后进行中的请求不会被中断只是新请求受控待旧请求释放后新限制完全生效。UI 可提示「已有 N 个进行中请求」改善体验。
- **状态**:✅ 已落地2026-06-13
### protect_count=6 保护最近约 2 个用户轮次 [任务 #43]
- **决策**:淘汰算法保护最后 6 条消息不纳入淘汰单元。依据:每轮典型产生 User + Assistant[±tools] + Tool* ≈ 2~5 条6 条 ≈ 覆盖 2 个完整轮次。
- **原因/取舍**固定数值简单可靠。不改为动态轮次检测增加复杂度且轮次边界模糊——Assistant 纯文本 vs 带 tools 的 Assistant 消息算同一轮还是不同轮。6 是保守值,宁可多保几条也不要误裁活跃上下文。未来可根据实测调整。
- **状态**:✅ 已落地2026-06-13
### syncConcurrencyConfig 加 debounce 防快速连续 IPC [任务 #43]
- **决策**Settings 页面修改并发数值后,通过 debounce~300ms延迟调用 `ai_set_concurrency_config` IPC防止快速连续拖动滑块/按键时频繁重建 Semaphore。
- **原因/取舍**`@change` 在 input[type=number] 上只在失焦时触发已比 `@input`但用户可能快速点「保存」或连续调整两个值。Semaphore 重建虽轻量Arc::new但不该无节制地做。手写简易 debounce~5 行)即可,不需引入 lodash-es 依赖。
- **状态**:✅ 已落地2026-06-13
### spawn 异步 save_conversation 加 warn 日志 [任务 #43]
- **决策**`tauri::async_runtime::spawn(save_conversation_inner)` 内部用 `if let Err(e) = ... .await` 捕获错误并 `tracing::warn!` 记录,不静默吞掉。
- **原因/取舍**spawn 的 task 错误默认被 tokio 静默丢弃,调试时完全看不到落库失败。加一行 warn 零成本出问题时能从日志定位。不影响用户体验warn 不是 error
- **状态**:✅ 已落地2026-06-13
---
## 二、AI Chat 工具卡片折叠 [Sprint 10 + 2026-06-13]
> 纯 UX 微调,全部归档。
### 双层折叠:卡片级 + 内容级分离
- **决策**`expandedCards`(整卡 body 显隐)与 `expandedTools`read_file 代码预览级)两套独立状态。
- **原因**:卡片折叠和文件内容预览是两个维度,合并会互相干扰。
- **状态**:✅ Sprint 10
### running/pending_approval 强制展开
- **决策**:执行中、待审批卡片不可折叠,始终展开。
- **原因**:用户需看到骨架屏(执行中)和审批按钮(待操作)。
- **状态**:✅ Sprint 10
### 新内容追加自动收起旧卡
- **决策**deep watch `messages` + 轻量 JSON snapshot diff 检测新内容(新消息 / toolCall 状态变化 / 文本增长)→ 清除旧 completed/rejected 展开态,保留活跃卡。
- **原因**:多步调用时旧结果折叠为单行 header界面紧凑类 ChatGPT/Cursor
- **状态**:✅ Sprint 10
### 首次加载 / 切对话不触发收起
- **决策**`isFirst` guard首次 snapshot 赋值后直接 return。
- **原因**:防切换对话或初次加载时把当前可见卡片误折叠。
- **状态**:✅ Sprint 10
### 短结果保持展开(审查①)
- **决策**`rejected`(拒绝原因)/ `write_file`(写入路径)短结果用 `shouldKeepOpen(tc)` 强制展开,仅 `read_file`/`list_directory`/通用 JSON 大体量结果折叠。
- **原因**:短结果折叠无紧凑收益,反隐藏关键信息(拒绝原因/写入路径)。
- **状态**:✅ Sprint 10
### 卡片宽度对齐气泡(审查②)
- **决策**:工具卡片 `max-width: 90%`,与 AI 文本气泡(`.ai-msg-bubble` max-width 90%)一致,不撑满 content 区。
- **原因**:卡片原默认 stretch 撑满 100%、气泡 90%视觉宽度不一致list projects 等卡片比回复气泡宽一截)。选限制卡片对齐气泡(非撑满气泡),保留气泡式留白;数据卡片 90% content 宽通常够展示文件树/代码(横向 `overflow-x:auto` 兜底)。
- **状态**:✅ 2026-06-13
### 折叠态 header 显示结果摘要 [2026-06-13]
- **决策**completed 工具卡片在 header 的 `.ai-tool-sub` 槽位(原仅 running 显示「执行中...」)追加结果摘要 `toolResultSummary(tc)`按工具返回结构提取一句话list_tasks→「12 项任务」、list_projects→「5 个项目」、list_ideas→「8 条想法」、create_project→「已创建xxx」、create_idea/task→「已创建title」、update_project→「已更新 status」、delete_project→「已删除」、run_workflow→「请到工作流页面运行」。`.ai-tool-sub` 加 ellipsis 截断防长标题撑破。
- **原因**list_tasks/list_projects/list_ideas/run_workflow 等工具无路径参数(不像 read_file/list_directory header 带路径),折叠态 header 仅剩工具名,光秃秃、信息密度低。摘要放 header非 body使折叠态也能一眼看到核心信息不必先展开。list_* 返回裸数组取 `.length`create 取 `.name`project/`.title`idea/task——字段不对称是后端 model 既定,摘要层各自适配。
- **状态**:✅ 2026-06-13
### 工具调用前 AI 总结文字去气泡 [2026-13](已回滚)
- **决策**AI 消息同时含文字 content 和 toolCalls 时,文字气泡加 `.ai-msg-bubble--plain`(去背景/边框/padding/圆角/max-width变紧凑纯文本紧贴卡片纯文字消息无工具卡片保留完整气泡。→ **已回滚**:恢复所有 AI 消息统一气泡。
- **原因**:原想条件去气泡兼顾紧凑与长回复可读性。→ 回滚原因:用户实测后强调「所有 AI 消息都应在气泡里」,条件去气泡造成两种 AI 消息外观(纯文字有气泡、工具总结无气泡)不一致、突兀。**紧凑不该靠去气泡实现**——牺牲一致性换紧凑得不偿失。未来若要紧凑走「气泡与卡片视觉一体」(卡片纳入气泡 / 连一体块 / 仅压间距)。
- **状态**:✅ 2026-06-13 落地 → 🔄 2026-06-13 回滚(一致性优先,布局方案待用户再定)
### 工具卡片区拆子组件ToolCard + ToolCardList [2026-06-13]
- **决策**:工具卡片区从 AiChat.vue原 1983 行)拆为两个子组件——`ToolCard.vue`单卡纯展示props: `tc`/`isExpanded`/`isContentExpanded`emits: `toggle`/`expand-content`/`approve`+ `ToolCardList.vue`(列表容器,持有折叠态 `expandedCards`/`expandedTools``defineExpose``collapseInactive`。AiChat.vue 仅 `import` + `ref` 持有列表 + 转发 `@approve``store.approveToolCall`
- **原因/取舍**:工具卡片占 AiChat.vue ~650 行(模板 107 + script 150 + CSS 392是高修改频率区折叠/摘要/时间轴等 UI 调整频发。拆出后卡片相关变更隔离在两文件1983 行主文件不再因 UI 调整反复动。auto-collapse 桥接选 `expose`+`ref`(方案 B而非 prop+emit / provide+inject——前者是 Vue3 标准模式、子组件自治折叠态、父级只调一个 `collapseInactive(activeIds)`。附带ToolCard 内用 `parsed` computed 缓存 `parseResult`(原模板 6 次重复 `JSON.parse`,每次 patch 都重解析);气泡与卡片的 `max-width:90%` 提取为全局 `--df-msg-max-width` 变量(原跨文件靠注释维系对齐,改一处忘一处即错位,变量化单一来源)。**深化(可维护性)**ToolCard.vue 再拆双 `<script>` 块——14 纯函数 + `ToolResult` 类型提模块顶层(只建一次,`setup` 聚焦 props/parsed`parseResult` `any``ToolResult|null`(防模板字段名打错编译期不报),`toolDisplayName` 去 i18n fallbackpath 几乎总有,属过度防御)。取舍:为可维护性/易迭代,不为微性能(省闭包可忽略)。
- **状态**:✅ 2026-06-13 落地(重构 Step 1-5 完成AiChat.vue 净减 ~650 行)
---
## 三、AI Chat Token 用量与模型记录 [2026-06-13]
> 主文档保留一句设计结论:**Token 分账本——对话 / 工作流节点 / 标题生成三处独立记账**。其余实现细节归档。
### 流式 token 落库走累加模式(非覆盖)
> 已移入 [经验记录.md](./经验记录.md)「约定」分组。
### Anthropic 流式 output_tokens 当累计值直接覆盖
> 已移入 [经验记录.md](./经验记录.md)「约定」分组。
### Token 展示默认关闭 + Settings 开关 [2026-06-13]
- **决策**AI 气泡底部 token 条默认不显示,加 `df-show-token-usage` 开关放 Settings 通用设置区,默认 `false`
- **原因**token 是计量信息非核心;用户对「头顶信号」敏感度不一,日常默认隐藏减干扰,需要时开。开关关闭时 store 不写 `lastTokenUsage`、DOM 不渲染,零视觉干扰。
- **状态**:✅ 2026-06-13
### 前端 token 双状态(单次 + 对话累计)[2026-06-13]
- **决策**store 维护 `lastTokenUsage`(最近一条回复)+ `convTokenTotal`(当前对话累计)两个状态,而非单一状态。
- **原因**:单状态切对话会错乱——实时值(当前回复产生的 tokenvs 历史总值(切回老对话应从 DB 读累计)混一起。双状态各司其职:切换时 `convTokenTotal` 从 summary 加载、`lastTokenUsage` 清空。
- **状态**:✅ 2026-06-13
### model 记录策略:取配置值 + 补填不覆盖 [2026-06-13]
- **决策**:记录的 model 取 `provider_config.default_model`(非 stream 响应解析);`save_conversation` insert 时写、update 时仅 `rec.model` 为 None 才补填。
- **原因**:① stream chunk 未必带 model 字段;`default_model` 是确定配置值且即请求所用(`request.model` 就用它发),配置名比响应里的具体版本号(如 `gpt-4o-2024-xx`)对用户更有意义。② model 字段 DB 早存在但老代码 insert 一直填 None补填兼容历史对话下次活动自动回填已有值不覆盖。
- **状态**:✅ 2026-06-13
### token 分账本:对话 / 工作流节点 / 标题生成 [2026-06-13]
> 此为设计结论,**已在主文档保留一句**:三处分属不同成本中心,混进 `ai_conversations` 会让对话 token 失真。
- **决策**:① 对话主流程 token`run_agentic_loop` 流式)落 `ai_conversations`;② 工作流 AI 节点 token 进 `NodeOutput.usage`(写 `node_executions.output_json`),不进对话表;③ 标题生成 token 刻意不记(`generate_title_via_llm``resp.usage` 丢弃)。
- **原因**:三处分属不同成本中心——工作流节点消耗属工作流执行成本,与对话 token 是两个账本,混进 `ai_conversations` 会让对话 token 失真标题生成是系统附带行为max 30 token量小计入对话总量会让用户困惑「只问一句怎么这么多 token」。非流式 `complete()` 的 token Provider 层已返回,仅消费侧按账本分流。
- **状态**:✅ 2026-06-13
### model UI 展示暂缓,留后续升级 [2026-06-13]
- **决策**model 已落库(`ai_conversations.model` + 前端 `AiConversationSummary.model` 字段就绪),但**暂不在对话旁展示**,留作后续升级项。
- **原因**:数据通路已打通(后端写值、前端字段在),展示是纯前端增量、随时可加;当前先把 token 用量展示做稳model 展示留到后续 UI 升级(如对话头部 / token 条旁)统一考虑,避免零散加。
- **状态**:📐 待实施UI 展示,数据已就绪)
### model 追溯:消息级 + 对话级多值C+B[2026-06-13]
- **决策**:消息级 `ChatMessage``model` 字段(每条 assistant 消息记生成它的 model+ 对话级加 `models` 字段JSON 数组,去重存对话用过的所有 model`model` 单值字段保留存首个(兼容已就绪的前端字段 + 补填不覆盖策略不动)。
- **演进**[2026-06-13 初版 📋] 现状为对话级单值 + 补填不覆盖,中途切换只留首个、消息级无字段无法追溯 → [2026-06-13 同日落地] 用户拍板 C+B。A对话级覆盖记最近未选——中途历史仍丢纯 C 不便快速看「用过哪些」,加 B对话级聚合互补。
- **原因**:「每一条对话都能有效记录」需消息级追溯(每条 assistant 消息知道谁生成);对话级 models 聚合便于不解析整条 messages 就知用过哪些(中途切换场景全覆盖)。`model` 单值保留是渐进兼容(前端 `AiConversationSummary.model` 已就绪,展示首个),不强删避免迁移破坏。
- **落地细节**:① `ChatMessage.model: Option<String>`serde default 兼容历史消息);② `run_agentic_loop` 构造 assistant 消息时 `msg.model = Some(provider_config.default_model)`;③ `save_conversation` update 路径 models 去重追加(读旧 JSON 数组 + 新值去重、insert 初始化 `[model]`;④ V6 迁移加 `models` 列;⑤ 前端 `AiMessage.model?` + `Summary.models?`switchConversation 解析历史消息 model。展示仍暂缓见上条
- **记录时机澄清 → [2026-06-13]**models 只追加**实际生成过内容**(本轮 `stream_llm` 跑过、产生 assistant 消息)的 save 调用,不记「配置但未实际用于生成」的 model。需求来自用户澄清「对话集应该不是切换过都留住吧只有实际发送过消息的才留」——首轮即 stop无 stream路径传 `Some(model)` 会把未生成的 model 误写进 models 数组。正确做法:`run_agentic_loop` 入口 `stop_flag` 路径(未 streamsave 传 `None`;仅 stream 后的退出路径(正常完成/stream 后 stop/审批暂停)传 `Some(model)`
- **状态**:✅ 2026-06-13编译/类型双过,未 tauri dev 实测) | ✅ 记录时机已落地2026-06-13入口 stop 路径 save 改传 `None`cargo check + vue-tsc 双过
---
## 四、AI Chat 对话与滚动 UX
> 滚动/错误友好化/空白屏修复/markdown 缓存为 UX 微调,归档。审批「删全屏 Modal 保行内」为设计决策,**已留在主文档**。
### 智能滚动:`isNearBottom` < 80px
- **决策**:仅当用户在底部 80px 内才自动滚动;上滑时不强制拉回。
- **原因**:用户回看历史时被强制拉回底部体验差。
- **状态**:✅ Sprint 8
- **演进** [2026-06-13]:上滑看历史时新消息/流式不滚 → 补「回到底部」浮动按钮。`showBackToBottom`(内容溢出 >100px 且不在底时显)+ `onMessagesScroll` 刷新 + `onContentChange`(在底则滚、上滑则只刷按钮不强制拉回)。沿用「不打断回看」原则,补一键回底入口。
### 错误友好化:`friendlyError` 正则映射
- **决策**404/401/timeout/network 正则映射为中文提示 + `isError` 红色气泡。
- **原因**:原始错误信息对用户不友好。
- **状态**:✅ Sprint 8
### 审批卡片显示参数内容 + 防双击 [2026-06-13]
- **决策**审批卡片pending_approval在按钮上方渲染工具参数键值对`toolArgsEntries` 遍历 args`formatArgValue` 截断超长)+ 风险提示reason`AiToolCallInfo``reason?` 字段AiApprovalRequired 回填。点击批准/拒绝后 store 乐观置 `status='running'` + 按钮 `:disabled="status!=='pending_approval'"`,等后端事件转 completed/rejectedIPC 失败回滚 pending_approval。
- **原因/取舍**:原审批卡片只有「批准/拒绝」按钮 + 工具名update_project/run_workflow用户**盲批**——参数不可见等于放行未知操作。双击无防护则连发 IPC后端 `pending_approvals.remove()` 有幂等不重复执行,但第二次报错弹窗)。乐观置 running 即时禁用按钮 + 回显参数,审批从「盲批」变「知情决策」。
- **状态**:✅ 2026-06-13
### 空白屏修复:防 FOUC
- **决策**`index.html` 加主题防闪烁脚本(渲染前置 `data-theme`+ `__APP_T0` 启动埋点 + body 背景色。
- **原因**Vite 启动慢122s期间 webview 白屏;前置主题脚本消除 FOUC。
- **状态**:✅ Sprint 7
### 流式 markdown 渲染加结果缓存 [2026-06-13]
- **决策**`renderMd``Map<text, html>` 缓存(限 200 项超出清空);`mdReady` 翻转marked+DOMPurify 加载完成)时清缓存重渲。
- **原因/取舍**`v-html="renderMd(...)"` 每次响应式触发(折叠/状态变化/新 delta都跑 marked.parse + DOMPurify.sanitize 全文。历史消息文本不变却重复全量解析O(n) × 触发次数)。缓存后历史消息命中 O(1);流式 currentText 中间态各缓存一项≤200 后清)。**未做**「流式中降级纯文本」——会让流式无格式、完成后突变格式,体验差;缓存方案保留流式格式且消除重复解析。
- **状态**:✅ 2026-06-13
---
## 五、AI Chat 对话管理 [Sprint 10]
### 侧边栏时间分组日历日桶today/yesterday/earlier
- **决策**:活跃对话按日历日分桶(今天/昨天/更早),归档对话单列可折叠区,而非滚动 24h 或纯时间倒序线。
- **原因**:用户心智「今天的对话」指当天而非过去 24 小时;归档是冷区,单列折叠减少对活跃区的视觉干扰。
- **状态**:✅ Sprint 10
### 归档分组默认折叠 + 持久化
- **决策**`archivedCollapsed` 默认 `true`(折叠),折叠态写入 `df-ai-ui` 持久化。
- **原因**:归档为冷区,默认折叠让活跃对话优先可见;持久化记住用户展开偏好,不每次重启都收回。
- **状态**:✅ Sprint 10
### 归档绕过 update_fieldset_archived 走原始 SQL
- **决策**:归档用专用 `set_archived` 方法直接 `UPDATE ai_conversations SET archived = ?1`,不复用 `update_field` 宏。
- **原因**`update_field` 对任何字段操作都强制连带 `SET updated_at = now`,归档是元数据切换不应改对话时间——否则归档后侧栏时间全跳成「刚刚」,破坏时间分组排序。归档与内容更新是两类操作,时间戳语义须区分。
- **状态**:✅ Sprint 10
### 恢复活跃对话加守卫 `!state.activeConversationId`
- **决策**`loadConversations` 自动恢复上次活跃对话时加守卫仅首次加载、ID 仍有效、当前无活跃对话才恢复)。
- **原因**:若用户已手动 new/switch恢复逻辑不应覆盖其当前意图守卫保证恢复只在「冷启动无状态」时介入。
- **状态**:✅ Sprint 10
---
## 六、AI Node工作流节点[任务 #44]
### AI Node 参数解析抽纯函数,单测不 mock LLM [任务 #44]
- **决策**`AiNode::execute` 内的参数解析config + 上游 inputs → `AiNodeParams`)剥离成独立纯函数 `parse_params(config, inputs)`execute 调用后再 `build_provider` + `complete``AiNodeParams``#[derive(Debug)]`unwrap_err 断言需要)。
- **原因/取舍**execute 原本 162 行硬编码 `build_provider`,直接单测会触真 HTTP——需 mock `LlmProvider` trait + 构造完整 `NodeContext`event_bus/node_status/NodeId重且脆弱。抽纯函数只接 `config` + `inputs`execute 实际用到的),参数解析/缺参报错/prompt 上游优先回退/默认值model 空→`gpt-4o-mini`、protocol→`openai_compat`)全可单测,零 mock 零网络。`complete` 调用正确性归 df-ai provider 层测试职责分层。副作用execute 瘦身parse 与 LLM 调用分离,可读性提升。
- **状态**:✅ 已落地2026-06-137 纯函数单测全过缺参×3 / prompt 取值×2 / 默认值 / 显式参数GLM 真调集成测试 `glm_live_complete``#[ignore]`+env var通过glm-4-flash 返回「通过」/usage 正确GUI DAG 触发实测转 → #54 跟踪)
### AI Node 参数边界值契约:空 model 走 provider 兜底 / prompt 双源 [任务 #44]
- **决策**config 无 model 时 `parse_params` 给空 `model` + `default_model="gpt-4o-mini"` 占位字段;`build_provider``default_model`(构造需非空),`CompletionRequest.model` 忠实传空串,由 provider `convert_request``if req.model.is_empty() { self.default_model }`兜底。schema `required``["base_url","api_key"]`,不含 `prompt`prompt 双源:上游 `inputs["prompt"]` > `config.prompt`)。
- **原因/取舍**:① `model`/`default_model` 双字段分离「忠实值」与「构造兜底」——provider 构造要非空 model空则后续无兜底锚点故 default_model 占位防 panic但 request 仍传真实 model让 provider 兜底单点收敛(两 provider convert_request 统一 `is_empty→default_model`),避免 AiNode 自猜默认与 provider 不一致。② prompt 双源支持「prompt 由上游节点产出」(如 read_file→ai 分析schema required 含 prompt 会误拦此合法配置。
- **状态**:✅ 已落地2026-06-13两 provider convert_request 兜底经 review 核实自洽schema required 改后 7 单测全过)
---
## 七、代码审查甄别2026-06-13 一次性审查流水)
> 全模块代码审查后的甄别落地。原则性结论(「真实 bug 修、简单清理做、规模不到位的优化先不动」)**已在主文档「需求澄清」保留**。以下是具体落地条目。
### df-evolve 骨架物理删除(连带三件套)
- **决策**:删 `KnowledgeStore` + `PatternExtractor` + `EvolveEngine` 三件全 TODO 空壳;同步删 df-nodes 5 个空节点docker/git/http/notify/subflow
- **原因/取舍**KnowledgeStore 被 EvolveEngine 唯一引用、PatternExtractor 同理——删前两者必连带删 EvolveEngine编译依赖原「删 2 个」甄别后扩为三件套。src-tauri Cargo.toml 声明依赖 df-evolve 但源码零 `use df_evolve`,整坨孤立骨架。`Knowledge`/`PromptTemplate`/`ReviewRule` 领域类型保留(有实现 + 测试Tier 1 Service 复用)。
- **状态**:✅ 2026-06-13cargo check 通过)
### 列表查询加过滤(内存策略)
- **决策**`ai_conversation_list` 加 limit默认 50+ include_archived默认 falseAI 工具 `list_projects`/`list_tasks`/`list_ideas` 闭包加 `truncate(50)``build_system_prompt` 项目注入 `take(20)``list_ideas` 命令加 status 可选过滤。均用内存 filter/take 或现成 `query`,不加新 SQL 方法。
- **原因/取舍**dev 阶段数据量小,内存过滤零触碰通用 `impl_repo!` 宏(改宏风险高)。数据真到成千上万再加 Repo 的 `list_limited` SQL 方法。`list_ideas` 复用现成 `query("status", v)` 走白名单,零改 Repo。前端 API 加可选参数,旧无参调用兼容(后端默认值兜底)。
- **状态**:✅ 2026-06-13
### node_executions「全表 list」甄别为伪命题
- **决策**审查提出的「node_executions 全表 list 改按 execution_id 过滤」**不成立**——全仓仅 state.rs 注册 NodeExecutionRepo无任何 list/query 命令对外暴露只写不读cargo `dead_code` warning 印证)。
- **原因**:不存在「废弃全表 list」的对象。若未来前端要看某次工作流执行的节点明细需**新增** `list_node_executions(execution_id)` 命令,属新功能非清理。
- **状态**:📐 待需求驱动新增
### ALLOWED_COLUMNS 从全局共享演进为按表隔离
> 已移入 [经验记录.md](./经验记录.md)「约定」分组。
---
## 八、状态持久化UX 偏好部分)
### UI 布局localStorage + 模块级恢复
- **决策**面板布局panelOpen/maximized/sidebarOpen/archivedCollapsed`df-ai-ui``restoreUiState()` 放模块顶层state 单例定义后)执行一次,而非 `useAiStore()` 内部。
- **原因**`state` 是模块级单例,`useAiStore()` 被 App.vue/AiChat.vue/AiDetached.vue 多处调用——放内部会重复执行恢复、覆盖用户当次操作。模块级只跑一次才对。
- **状态**:✅ Sprint 10
> 注:窗口位置/大小用 tauri-plugin-window-state、detached/docked 不持久化两条**已在主文档保留**(属设计决策)。
---
## 九、i18n实现细节
### i18n 模块必须命名空间化导出
> 已移入 [经验记录.md](./经验记录.md)「踩坑」分组。
### 状态枚举 i18nconstants 存 keyview 包 $t
- **决策**`constants/project.ts``PROJECT_STATUS_LABELS`/`TASK_STATUS_LABELS` 值从中文文案改存 i18n key`planning: 'projects.status.planning'``projectStatusLabel`/`taskStatusLabel` 返回 keyview 显示处包 `$t(projectStatusLabel(x))``PRIORITY_LABELS`P0/P1是代号非文案不动。
- **原因**constants 是纯数据/结构层,不应含展示文案(违反分层);文案归 localeconstants 管映射结构。
- **状态**:✅ 已落地
---
**相关文档**
- [功能决策记录](./功能决策记录.md) — 需求规格 + 设计决策规格(当前真相源)
- [经验记录](./经验记录.md) — 踩坑/约定/技巧/bug 排查教训

View File

@@ -0,0 +1,472 @@
# 功能决策记录
> 日常开发中对各功能做的**需求规格 + 设计决策规格**(功能粒度,补 [Phase 1 架构决策](./Phase1架构决策.md) 之下的实现层选择)。聚焦「要做什么 / 为什么这么定」,便于日后回溯。
>
> 创建2026-06-12 | 范围Sprint 510 | 维护:随开发追加
## 约定
**判断标准**3 个月后回看,这条是否仍影响对系统/功能设计的理解?是 → 留本文档;否 → 分流。
**本文档只记两类**
- **设计决策规格**(✅ 已落地 / 🚧 待实测 / 📐 设计未实施)——「为什么这么定」。三要素:决策 → 原因/取舍 → 状态。来源标 `[Sprint N]``[日期]`
- **需求规格 / 待办**(📋)——「要做什么 / 为什么需要」。与 PROGRESS 流水区分:这里记「要做什么 / 为什么需要」PROGRESS 记「做了啥」。
**经验性内容**(踩坑 / 约定 / 技巧 / bug 排查教训)→ [经验记录.md](./经验记录.md)。
**老条 / 纯流水 / UX 微调 / 已被取代** → [功能决策记录-归档.md](./功能决策记录-归档.md)。
记录规则见 [文档记录规范](./文档记录规范.md)。
## 人机协同设计基准
### AI coding 下的「过度设计」判断基准 [2026-06-13]
- **决策**:项目为一人开发 + 全程 AI coding人定方向/审查AI 实现),代码被 AI 反复读写。权衡设计时采用新基准——**AI 反复读写的代码,「结构清晰」和「隐性耦合显式化」权重高于传统判断**;但「过度抽象」(多层 trait/Builder/工厂)仍不做,因 AI 读简单直白代码 > 读层层抽象。
- **原因/取舍**:① AI 每次理解代码靠注释和结构比人更依赖1600 行单文件(如 AiChat.vue消耗大量 context拆分反而降低 AI 理解成本;② 隐性依赖(如分离窗口 localStorage 跨 webview人凭经验避开、AI 易踩坑。故:组件拆分从「不做」升为「值得做」;隐性耦合早修或显式标注。③ 规模没到的优化list_all LIMIT、白名单拆表仍按数据量客观判断不因 AI coding 而变。
- **状态**:📐 基准原则(指导后续取舍)
## AI Chat 可靠性
### 流式可靠性三重保险 [Sprint 6 + 2026-06-13]
- **决策**:① reqwest Client 加 `connect_timeout(30s)`**不设请求总 timeout**;② `stream_llm``tokio::time::timeout(120s)` 包**单个** `stream.next()`idle 间隔),不包整个流;③ 前端 stores/ai.ts 加 streaming watchdog——发送启动 60s 计时,收到 AiTextDelta/工具事件/审批结果重置AiApprovalRequired 暂停审批等待不计AiCompleted/AiError 清除60s 无活跃事件则置 streaming=false + push 错误「响应中断」。
- **原因/取舍**:连接阶段防无限 hang总 timeout 会误砍流式长生成任务流式可持续数分钟。idle 120s 防「连上后中途静默」无限 hang只卡单 chunk 间隔,不卡总时长。前端 60s 独立计时(短于后端 120s双保险——后端任何路径漏发收尾崩溃/事件丢失/agent loop 异常退出)前端永久 streaming=true 卡死。审批等待暂停(不计超时)避免误判用户思考。
- **边界**审批组件未渲染见「需求与待办·审批可见性缺口」AiApprovalRequired 暂停后永久卡watchdog 救不了,需审批可见性兜底。
- **状态**:✅ Sprint 6 + 2026-06-13
### 断连丢弃残缺响应
- **决策**:维护 `finished_received` 标志;流尽未收到 finished 信号 → emit AiError 并**丢弃残缺响应,不当完整入库**。
- **原因**:防脏历史污染对话记录(半截回复入库后无法续接)。
- **状态**:✅ Sprint 6
### 停止生成:保留已生成文本
- **决策**`AiSession.stop_flag: Arc<AtomicBool>` + 多检查点响应;停止后**保留已生成文本**。
- **原因**:用户主动停止 ≠ 丢弃成果已输出内容有价值。idle无输出时最多等 120sP2 可用 `tokio::sync::Notify` 优化为绝对即时)。
- **状态**:✅ Sprint 6运行时待实测
### 切对话:从「拒绝切换」演进到「不中断路由」
- **决策**Sprint 6 生成中**拒绝切换**(防 `active_conversation_id` 被改致旧 loop 串台写库)→ Sprint 8 改为**后台对话按 `conversation_id` 路由,生成中可切换不打断**。
- **原因**:拒绝切换体验差;后端给所有 event 加 `conversation_id` + spawn 前快照 conv_id + 前端按 id 路由(后台对话事件不污染当前视图),既不串台又不打断。
- **状态**:✅ Sprint 8部分场景待实测
## AI Chat 工具调用与审批
### Agentic Loop 最多 10 轮
- **决策**`run_agentic_loop` 上限 10 轮(`MAX_AGENT_ITERATIONS`)。
- **原因**:防失控循环;单链 ReAct 10 轮覆盖绝大多数任务。超出需规划式B 路线)。
- **状态**:✅ Sprint 5
### 风险门控Low 自动 / Medium+High 审批
- **决策**:工具按 `RiskLevel` 分级Low 自动执行Medium/High 暂停等人工审批。
- **原因**:读操作放行,写/删操作把关——可靠性 vs 效率的平衡点。
- **状态**:✅ Sprint 5
### `tool_calls` 按 index 排序
- **决策**assistant 消息与 tool_result 两处均按 `index` 排序。
- **原因**:消除 HashMap 迭代乱序致多工具结果错位。
- **状态**:✅ Sprint 6
### 路径校验:拒 `..` 遍历 + 扩敏感目录
- **决策**:正斜杠→反斜杠规范化 + 拒 `..` 路径遍历 + 扩 `.aws`/`.gnupg`
- **原因**最小加固防越权读写根治级workspace 白名单 + canonicalize待边界明确后再做。
- **状态**:✅ Sprint 6边界加固待续
### list_directory 递归防爆:噪音目录剪枝 + 条目上限 + skip_noise_dirs 开关 [2026-06-14]
- **决策**`list_dir_recursive` 递归时跳过噪音目录(`.git`/`node_modules`/`target`/`dist`/`build`/`.next`/`.cache`/`__pycache__`/`.venv`/`venv`/`.idea`)——**列出但不深入内部**;硬上限 1000 条 + `truncated` 标志;默认 `max_depth` 3→2`skip_noise_dirs` 参数(默认 `true`)。
- **原因**AI 广扫项目根传 `recursive:true` 时,`.git`/`node_modules`/`target` 铺平致 13782 项塞进对话 messageUI 卡 + token 爆)。剪枝防爆炸,但保留访问能力:① 噪音目录仍列出(看得见存在 + 大小);② 想看内部时 `list_directory` 直接指向该目录depth=0 起算),或传 `skip_noise_dirs:false` 强制递归进去(仍受 1000 上限 + truncated 保护,适合看编译产物 dist / 运行结果 target 做比对。1000 上限 + truncated 让"想全扫"退化为"分层定点查",不丢信息。
- **状态**:✅ 2026-06-14 落地cargo check 通过)
### `max_tokens` 8192 + `length` 算 finished
- **决策**max_tokens 4096→8192`finish_reason="length"`(截断)纳入 finished。
- **原因**:大任务输出撞 4096 上限被误判断连、丢弃整段响应8192 贴合实际,截断视为正常完成。
- **状态**:✅ Sprint 6
### 审批:删全屏 Modal 保留行内卡片 [Sprint 8]
- **决策**:移除全屏 Tool Approval Modal保留工具卡片内联审批按钮。
- **原因**:全屏 Modal 打断对话流,行内审批更轻量。
- **状态**:✅ Sprint 8
## AI Chat Provider 协议
### 按 `provider_type` 路由 OpenAI / Anthropic
- **决策**:新增 `anthropic_compat.rs` 实现 Anthropic Messages API`provider_type` 分发到 OpenAICompat 或 AnthropicCompat分发处用 `Box<dyn LlmProvider>` trait object。
- **原因**GLM 等订阅端点走 Anthropic 协议(`x-api-key` + 顶层 `system` + 必填 `max_tokens` + SSE content_block统一 Provider trait 屏蔽差异,上层 Agentic Loop / AiNode 零改动(不感知协议)。
- **状态**:✅ Sprint 8用户实测对话流式 OK
### 端点 URL 三段智能拼接
- **决策**`messages_url()` 按 base_url 末段判断——已含 `/v1/messages` 直用;以 `/v1` 结尾补 `/messages`;仅域名(如 `…/api/anthropic``api.anthropic.com`)补 `/v1/messages`
- **原因**GLM 订阅端点 `open.bigmodel.cn/api/anthropic` 与 Claude 官方 `api.anthropic.com` 约定不同(前者无 `/v1`后者需补不强制用户填全路径降低配置门槛。GLM 端点已实测。
- **状态**:✅ Sprint 8
### `max_tokens` 必填兜底 4096 / tool_result 连续合并为一条 user
- **决策**:① Anthropic 协议 `max_tokens` 必填(协议无默认),`DEFAULT_MAX_TOKENS=4096` 兜底OpenAI 协议 max_tokens 可选,缺则报错);② 连续多条 `role=Tool`tool_result累积遇非 Tool 消息 flush 为单条 user 消息含多个 `tool_result` 块。
- **原因**:① 统一兜底避免上层每个调用点都要传值(与 OpenAI Provider 的 8192 上限独立,此处仅缺省兜底)。② Anthropic 要求 tool_result 必须在 user 角色内;多工具并发结果合并为一条 user 而非一对一,贴合协议「一回合一组结果」语义,减少消息碎片。
- **状态**:✅ Sprint 8
### 默认标识is_default 落库为真相源 [Sprint 10 → 2026-06-13]
- **决策**`ai_set_provider` 互斥写 DB目标 `is_default=true`、其余 `false`,仅写变化记录);`ai_save_provider` 新建时若全表尚无默认则自动设为默认(首个);`ai_list_providers` 直接返 DB 值。`session.active_provider_id` 降为运行时缓存,由 `set_provider` 同步,重启清零不影响——`get_active_provider` 兜底取 DB `is_default`
- **原因/取舍**Sprint 10 原 active 作真相源 → 致「重启默认丢失」bugactive 是内存态重启清零,而 `is_default` 字段恒写 false重启后无默认可恢复。`is_default` 字段本为持久化默认而存在回归本职最自然session 持久化需额外存储,重复造轮子。互斥写库保证「唯一默认」语义。
- **边界**:互斥写库逐条 `update_full` **不加事务**——repo 未暴露事务接口;桌面单用户无并发触发,失败即报错、重试自愈。多用户/高并发场景需给 repo 补 `execute_transaction`
- **状态**:✅ 2026-06-13 落地(重启保默认 / 互斥写库 / 首个自动默认实测待补)
### 📋 delete_provider IPC 缺失(前端假删除)[Sprint 10]
- **需求**Settings 删 Provider 当前只前端 filter 移除,不调后端(无 `ai_provider_delete` 命令),重启后配置回归。
- **原因**交互欺骗——用户以为删除成功DB 实际未动。需补 `ai_provider_delete` IPC`lib.rs` 注册 + `ai.rs` 实现 + 前端真调),并加二次确认。
- **状态**:📐 待实施 → ✅ Sprint 10 已实现(`ai_delete_provider` 命令 + 删默认清 active + 自建 `confirmDialog` 二次确认)
## 模型能力与路由Model Capability & Auto-Routing
### 能力声明:复用 `ai_providers.models` JSON 字段,不建新表 [2026-06-13]
- **决策**:每个 Provider 下可选模型的能力声明(模态/功能/成本等级)存入已有 `ai_providers.models`JSON 数组),**不新建独立表**。新增 `crates/df-ai/src/model_capability.rs` 定义 `ModelCapability`name / modalities / functions / max_tokens / cost_tier+ `TaskRequirements` + `Modality` / `CostTier` 枚举。
- **原因/取舍**:模型能力是 Provider 配置的内在属性,非独立实体——无生命周期管理、无跨表 JOIN 需求JSON 嵌入单行足够Provider 通常 1-5 个);`models` 列已在 V9 建表且全链路预留,复用零 schema 变更;独立表需外键/级联/JOIN对桌面应用过度工程备份迁移友好配置自包含一行。代价无法 SQL 查询「所有有 vision 的模型」,但此查询当前和近期均不需要。
- **状态**:📐 设计未实施Phase 1数据模型 + 场景级路由)
### 路由层:纯函数 ModelRouter重写现有骨架 [2026-06-13]
- **决策**:重写 `crates/df-ai/src/router.rs` 已有但空的 `ModelRouter`——从 `TaskRequirements` + Provider model_pool → 按硬性要求筛选候选 → 按 cost_tier 升序取最便宜。路由是同步纯函数(无 I/O、无全局可变态可单测。7 个 LLM 调用点统一接入。`override_model` 字段支持用户显式指定(聊天手动切)和工作流节点 config.model节点作者指定两种覆盖优先级最高。
- **原因**:路由是确定性计算(静态配置→模型名),无需 async/service 化;纯函数可单测;统一入口避免散装 if-else。
- **各调用点路由策略**:主对话 `chat(has_image)`→Standard/Premium标题生成 `title_generation()`→Economy 最便宜;知识提炼 `knowledge_extraction()`→Economy/Standard工作流 AiNode `workflow_node()` + override=config.model→节点指定优先**Embedding (×2) 不走路由器**(独立路径,不同模型类)。
- **向后兼容**`models=None`(老记录)→ model_pool 空 → 所有 route 返回 default_model → 行为不变。
- **状态**:📐 设计未实施Phase 1
### Embedding 不进通用路由器 [2026-06-13]
- **决策**Embedding 模型不走 ModelRouter继续走独立路径`KnowledgeConfig.embedding_model` + `embedding_provider_id`)。
- **原因**Embedding 是完全不同的模型类——API 不同(`/v1/embeddings` vs `/v1/chat/completions`)、用途不同(向量化 vs 生成)、通常更小更专用。混入通用模型池会混淆用户并增加路由分支复杂度。
- **状态**:📐 设计未实施Phase 1 确认不动 embedding 路径)
### 分阶段实施路线 [2026-06-13]
- **Phase 1**(本次):数据模型 + ModelRouter 重写 + 7 调用点接入 + Settings 模型池编辑 UI + AiChat 模型下拉。ChatMessage.content 保持 String 不改。
- **Phase 2**后续多模态消息——ChatMessage.content: String → Vec<ContentPart>(Text/Image);前端粘贴/拖拽图片vision 模型自动路由。
- **Phase 3**后续Agent 内智能路由——Agentic Loop 每轮按子任务构造不同 TaskRequirements成本预算控制模型级联降级跨 Provider 搜索。
- **状态**:📐 设计未实施
### 📋 模型能力系统 — 完整改动文件清单 [2026-06-13]
- **后端 Rust**`crates/df-ai/src/model_capability.rs`**新增** ModelCapability / TaskRequirements / Modality / CostTier/ `router.rs`**重写** ModelRouter 匹配逻辑)/ `lib.rs`re-export/ `df-storage/src/migrations.rs`V10 版本号推进,无需 ALTER/ `src-tauri/src/commands/ai.rs`7 调用点加 routerai_save_provider 加 models 参数;新增 ai_set_chat_model_override
- **前端 TS/Vue**`src/api/types.ts`(新增 ModelCapability / Modality / FunctionCapabilities / CostTier 类型)/ `src/api/ai.ts`saveProvider 加 models 参数;新增 setChatModelOverride()/ `src/stores/ai.ts`availableModels / activeModelOverride 状态 + setModelOverride action/ `src/views/Settings.vue`Provider 表单增加模型池编辑区)
- **状态**:📐 待实施Phase 1 全量清单)
## 灵感模块(评估闭环)
### 启发式评分维度
- **决策**:三维固定 5.0 → 内容启发式priority / 描述充实度 / tags / 中英关键词clamp 0-10。
- **原因**:固定值无区分度;启发式基于 idea 内容给差异化评分。
- **状态**:✅ Sprint 9启发式未接 LLM
### 对抗评估:启发式 fallback 待接 LLM
- **决策**:正反方论点/evidence 基于真实 idea 内容生成confidence 由评分驱动;未接 df-ai LlmProvider。UI 诚实标注——评估区标题加「启发式」黄标(`.eval-mode-tag`),对齐实现深度避免名实不符,接 LLM 后摘除。
- **原因**先打通评估闭环LLM 生成论点 + 启发式 fallback 为后续增强。
- **→ 接入方式已定**:走全局 AI trait 下沉(见 [crate 治理 — AI trait 下沉拆 df-ai-core](#ai-trait-下沉拆-df-ai-core-轻层确立全局-ai-接入标准-2026-06-14-))。`evaluate()``LlmProvider` 注入参数,启发式降级为 fallbackF-03 原 A/B/C 选型据此收敛为「全局统一 trait」。
- **状态**:📐 设计未实施LLM 接入)
### 前后端标签对齐
- **决策**`recommendation` 全小写空格(后端原 "With Resources" 不匹配前端 map key`assessmentClass` 映射到 CSS 类名(`.immediate`/`.soon`/...)。
- **原因**:大小写/类名不一致致中文标签不显示、badge 无色。
- **状态**:✅ Sprint 9
### 评分 0-10crate→ 0-100前端IPC 缩放 + 多字段写回用单事务 [Sprint 9/10]
- **决策**:① crate 内 IdeaScores 维持 0-10IPC 层 evaluate_idea 组装 scores JSON 时 *10 缩放为 0-100并用中文维度键可行性/影响力/紧急度/综合)。② evaluate_idea / promote_idea 写回想法用 `update_full`(单事务覆盖整条记录),放弃多次 `update_field`
- **原因**:① 前端雷达图直接当百分比渲染、零前端改动crate 内 0-10 符合评分直觉IPC 层做单位适配。② 多次 update_field 各自独立连接中途失败致数据半成品update_full 原子。
- **状态**:🚧 Sprint 9/10编译/构建通过,未 tauri dev 实测)
## 想法立项promotion
### 复用 df-project 领域层crate do_promote 留纯决策 TODO [Sprint 10]
- **决策**`promote_idea` IPC 复用 `df_project::manager::ProjectManager::create_from_idea` 构造项目实体 + 映射 ProjectRecord 持久化 + update_full 回写想法df-ideas crate 内 `IdeaPromoter/do_promote` 保留纯决策 TODO不真正创建项目。
- **原因**crate 不依赖 df-storage/df-project避免循环依赖、保持可单测手动立项无需 Auto/Manual/SemiAuto 策略判断(用户点即确认),副作用放 IPC 组合,与 evaluate_idea 同模式crate 纯决策留待自动/半自动晋升场景复用。
- **状态**:🚧 Sprint 10编译/构建通过,未 tauri dev 实测)
### promoted_to 非空拒绝重复立项 [Sprint 10]
- **决策**promote_idea 取想法后校验 promoted_to 已存在则返回错误,不创建新项目。
- **原因**:防同一想法多次点「立项」生成多个项目;幂等保护。
- **状态**:🚧 Sprint 10
## 项目管理(删除 / 回收站)
### 项目删除软删回收站deleted_at + 应用层级联),非物理删 [2026-06-13]
- **决策**:删项目改为**软删**——`projects``deleted_at TEXT`V11 迁移),`delete_project``deleted_at`(进回收站,可恢复);`list_projects` 过滤 `deleted_at IS NULL`;回收站(`list_deleted_projects`)可「恢复」(清 deleted_at或「彻底删除」`purge_project` 事务级联物理删 branches→releases→tasks→projects不可逆。子表软删时不动FK 仍满足,项目数据完整保留待恢复。
- **原因/取舍**:① 用户要求「可恢复」——纯 CASCADE 物理删不可逆,误删难挽回;软删 + 回收站给反悔余地。② SQLite `ALTER TABLE` 改不了已有表 FK 约束,给老库 projects 加 `ON DELETE CASCADE` 须重建表(高风险),故不走 DDL 级联,改**应用层级联**purge 时事务内顺序删子表),语义等价且可测、不依赖 `PRAGMA foreign_keys`。③ 软删只标记 projects 行、子表不动——恢复时项目连同历史任务/分支/发布完整还原。④ 两级风险分级:日常软删可逆 / 回收站 purge 二次确认后物理删不可逆。
- **边界**`ProjectRecord` 不带 `deleted_at` 字段,纯靠 SQL `WHERE deleted_at IS NULL` 过滤models/types 零变更。`ai.rs build_system_prompt` 同步改用 `list_active`(防软删项目泄漏进 AI 上下文)。`soft_delete`/`restore` 守卫对称,重复操作幂等。
- **状态**:✅ 2026-06-13 落地V11 迁移 + ProjectRepo 5 方法 + 3 IPC 命令 + 回收站 modal + 删除入口cargo check + vue-tsc + 11 integration test 全绿)
## 项目管理(目录绑定 / 技术栈探测)
### 项目绑定真实代码目录:扩 ProjectRecord + df-project scan [2026-06-13]
- **决策**`ProjectRecord``path`(绑定目录绝对路径)+ `stack`(技术栈 JSON 数组字符串两字段V12 迁移nullable技术栈探测逻辑放**新建 `df-project/src/scan.rs::detect_stack`**(纯函数,浅读根目录标志文件识别 rust/go/python/java/csharp/vue/react/angular/svelte/next/vite/typescript/node/tauricommands 层薄封装 4 命令(`scan_project_stack`/`check_path_binding`/`relocate_project_path`/`check_path_exists``check/relocate``canonicalize` 规范化路径防绕过重复检查。新建项目可选绑定目录→自动探测栈,详情页支持重定位 + 目录失联检测 + 防重复绑定。
- **原因/取舍**:① **扩 df-storage ProjectRecord 而非激活 df-project ProjectContext 空壳**——`ProjectContext` 虽早设计 `root_path`/`tech_stack`/`repo_url`/`ai_context` 字段,但 `manager.rs` 标注 TODO 从未接存储/运行时;激活需新建 `project_contexts` 表 + 填充暂不需要字段,第一步过重,守 YAGNI。ProjectRecord 是实际运行链路,直接扩最快见效。② **scan 放 df-project 而非 commands 层**——`ProjectContext.tech_stack` 本就是 df-project 职责字段scan 是其天然能力且可被 df-ai/df-workflow 复用,放 commands 变一次性代码。③ **migration nullable**——老项目 path/stack=NULL 零影响。④ **一致性原则**:先在「新建流」验证,第二步「导入历史项目」复用同一套 scan/relocate/checkBinding。
- **边界**`path` 规范化canonicalize仅用于比较存库保留用户输入的原始可读路径。程序化创建项目想法晋升、AI 工具path/stack = None不绑定目录`df-project``ProjectContext` 刻意未激活ai_context/repo_url 等暂留空)。
- **状态**:✅ 2026-06-13 落地V12 迁移 + detect_stack + 4 IPC 命令 + 选目录/查重/卡片栈 + 重定位/目录状态cargo build + scan 4 单测 + vue-tsc 全绿)。📋 导入历史项目(第二步):复用 scan/relocate/checkBinding加 monorepo 子目录识别 + README 首段抽 description + 批量。
### 导入/绑定项目 AI 扫描填信息:规则探测兜底 + LLM 增强,采样与 LLM 调用分层 [2026-06-14]
- **决策**:导入/绑定项目时规则 `detect_stack`(快/免费/准)必跑兜底 + LLM 分析采样README+目录树+清单,不读源码)产出 description 摘要与 stack 细化LLM 失败降级纯规则。采样逻辑放 df-project纯 IOLLM 调用放 commands 层。
- **原因/取舍**:① 规则兜底保证 LLM 不稳定时仍有基础信息LLM 只补规则搞不定的摘要。② 采样与 LLM 分层——采样纯 IO 属 df-project 职责可复用LLM 调用依赖 df-ai放 commands 使 df-project 保持无 LLM 依赖(防循环)。③ 不读源码控 token+隐私。④ 结果预览让用户把关防 LLM 瞎编。
- **状态**:✅ 2026-06-14 落地scan_project_with_ai 命令 + 前端 AI 扫描预览;编译/单测/类型全绿)。
### AI 工具绑定目录bind_directory 专用工具 + 工具层白名单同步 + prompt 禁冒充 [2026-06-14]
- **决策**:① update_project 工具白名单补 path/stack同步 db 新字段);② 新增 bind_directory 专用工具(绑定目录不走通用 update③ 系统 prompt 加约束:工具失败须明说,禁用替代操作冒充原意图成功。
- **原因/取舍**:① review AI 对话发现 update_project(path) 被工具层白名单拒db 字段加了但工具层漏同步AI 转而改写 description 却回复「已记录」冒充绑定成功误导用户。② 专用工具语义清晰,防 AI 走通用 update 捷径冒充。③ prompt 约束防单链 ReAct「自我圆场」幻觉失败时用替代谎报成功
- **状态**:✅ 2026-06-14 落地(白名单同步 / bind_directory / prompt 中英约束;编译全绿)。📋 待清:工具层白名单与 crud 白名单双份去重(详见经验记录)。
## 知识库df-evolve / 共享记忆层)
> 核心定位与设计决策。详细字段/参数级决策见各条目。
### 定位 + 被动 Service 退化 [2026-06-13]
- **决策**知识库df-evolve定位为**整个 DevFlow 的共享记忆层**——每个模块idea/task/workflow/review/chat既是知识生产者也是消费者而非孤立展示功能页。df-evolve 褫夺「自动进化引擎」角色Sprint 2 对抗论证已砍,自用阶段 ROI 低/过度工程),**退化为被动 Service 层**,只暴露 `search/retrieve/record_reuse/feedback/save` 供各模块调用;被动 Service 复用 df-ai 检索做消费侧EventBus 做事件驱动提示(非自动抓取)。
- **原因**:手脑(各业务模块)分离无学习能力;知识库做「肌肉记忆」中枢系统才越用越懂你。砍的是自动挖矿,非知识库本身。
- **状态**:📐 设计未实施(用户拍板定位)
### 沉淀审核机制知识状态机AI 只产草稿 [2026-06-13]
- **决策**:知识加 `status` 字段,状态机 `candidate → pending_review → published → archived`**AI 提炼的知识一律进 candidate绝不直接入正式库**;草稿进「待审核收件箱」,人工逐条编辑(内容/分类/标签)→ 发布或丢弃。`verified` = 发布审核时一次性人工标intake 决断动作,非 ongoing 评分)。沿用 IdeaRecord 的 `pending_review` 状态机模式。
- **原因**沉淀必须有人工把关用户要求「人工能够编辑或调整必须有这些过程」——AI 提议、人裁决、系统如实记,非黑箱自动学习。
- **状态**:📐 设计未实施(用户明确要求加审核机制)
### AI 提炼产出:字段集 + 置信度(唯一指标)+ 查重 [2026-06-13]
- **决策**AI 提炼一条 candidate 时产出——**内容字段**`kind`分类AI 判定 7 类之一)、`title``content``tags``source_ref`(原始证据片段);**质量指标**`confidence`High/Medium/LowAI 自评,**唯一质量指标**);提炼时另做**查重**(比对 published 库,重复则不产/标合并,非存储字段)。**克制边界**:质量指标只留 confidence——不加 generality/specificity/novelty 等维度(过工程化 + 多耗 token适用范围并入 `tags` 不单列 scope 字段。
- **原因**`kind``source_ref` 是 AI 必填但易漏的两项confidence 服务降噪 + 审核分诊 + 透明,但**不绕过人审门**high 也不自动发布)。
- **状态**:📐 设计未实施
### 透明化provenance 溯源 + 收件箱 + 注入告知 [2026-06-13]
- **决策**:① 每条知识标来源(哪次 Chat/task/review 产出 + 原始片段),可跳回——复用现有 `source_project` + `source_ref` 字段;② 「待审核收件箱」作明确信息渠道;③ 复用时显式告知本次注入了哪几条、为什么命中。
- **原因**用户要求「透明化让人们有很好的信息获取渠道」——不黑箱。provenance 字段现有模型已有,零新增成本。
- **状态**:📐 设计未实施
### 克制原则:宁缺毋滥,小步迭代 [2026-06-13]
- **决策**:① **检索注入保守**——精确匹配(标签/关键词)优先,语义模糊匹配**后做**top-N 限 1-3 条,置信不够一条都不塞;② **关联不自动推断**——IdeaGraph 自动聚类/关联发现**先不做**,只支持人工标注;③ **沉淀不主动监听全量事件**——仅「一键沉淀」或事件提示后「确认」才产 candidate**从小到大**——先 AI Chat 单点双向跑通验证手感,再串 review/idea。
- **原因**:用户要求「尽可能克制,不要做大胆的连接,从小到大」——贯彻 Sprint 2「scope 砍 60%」精神到知识库,避免重蹈「自动进化」过度工程覆辙。
- **状态**:📐 设计未实施(用户明确要求克制)
### 指标客观化reuse_count 唯一信号,撤销 effectiveness 人工评分 [2026-06-13]
- **决策**:知识排名/淘汰**只用 `reuse_count` 一个客观信号**(检索注入自动 +1**撤销 `effectiveness` 的人工 👍/👎 评分**主观、有摩擦、信号不准淘汰改客观——reuse_count=0 且超 N 天未用 → 提示归档。
- **演进**初版三指标reuse_count + effectiveness + verified共同排序权重 → 同日修正:用户指出 👍/👎 是「人为、主观、非准确」的非必要干预,撤销。「用过 ≠ 有用」的质量顾虑改由两层客观兜底——① intake 审核门(一次性决断)② 发现噪音直接删。召回不准根因在标签/搜索质量,靠 intake 打准标签解决。
- **状态**:📐 设计未实施
### 📋 分层落地 Tier 1/2/3 + 来源/去向审查 [2026-06-13]
- **需求**:知识库联动分三层——**Tier 1必做**AI Chat ↔ 知识库双向 + 手动录入(沉淀 + 检索注入 + reuse_count + 审核收件箱 + 状态机,**无人工评分****Tier 2串创作流带前置**ai_node 检索 prompt_template、工作流 NodeFailed → pitfall**仅失败时**)、决策记录 → architecture_pattern**前置:先补 df-traceability 持久化****Tier 3**evolve_engine 自动挖事件。
- **审查(来源)**:① **/review 源移出**——devflow 无代码审查功能df-stages/coding.rs 审查节点是 TODO 空壳);② **想法评估源存疑**——对抗论点是「一次性结论」非可复用知识;③ **决策记录源标前置**——`DecisionJournal` 所有 SQLite 查询 TODO 未持久化;④ **工作流源限定失败时**——运行日志≠提炼知识。
- **审查(去向)****Chat 轴是唯一 Tier 1 就绪消费端**(检索→注入对话/提示词ai_node prompt 注入属 Tier 2工作流无主动消费仅被动产 pitfall决策溯源消费半残。→ 来源/去向双收敛到 Chat 轴。
- **状态**:📐 待实施(先做 Tier 1Chat + 手动录入)
### 对外暴露MCP Server 双向协议 [2026-06-13]
- **决策**:知识库对外 API 采用 **MCP Server** 形式暴露,**双向**(读+写。Tier 1 先做 DevFlow 内部闭环Tauri IPC**命令层设计完全对齐 MCP 语义**Tier 1+ 套 MCP server封装已有 command。MCP 暴露:① **Resource (读)**list/search/get**Tool (写)**create_candidate**Tool (计数)**record_reuse。
- **原因**:① Claude Code 原生吃 MCP——本机主力工具零集成成本② Cursor 也支持 MCP**双向价值**:外部工具(尤其 Claude Code 做代码审查/重构时是高质量知识来源——审查结论→review_rule、踩坑经历→pitfall接 MCP 自动回流知识库等于开「第二来源入口」。克制Tier 1 不实现 MCP 本身,但 6 个核心命令search/list/get/create/update_status/record_reuse全部按可暴露设计。
- **状态**:📐 设计未实施Tier 1+ 事项)
### 矛盾知识处理:纯标签+内容自述source_project 仅溯源 [2026-06-13]
- **决策**:矛盾知识**不建冲突关系表、不加 scope 字段、不做 access control**。消歧靠 tags`["Go","微服务"]` vs `["Go","单体"]`+ content 自述适用范围 + source_project 仅作来源溯源展示不参与检索过滤。AI 提炼 prompt 加约束:「适用范围有限制必须在 content 或 tags 中标注」。零数据结构变更。
- **原因/取舍**:矛盾是少数场景,为 minority 建关系系统是过度工程source_project 若做绑定/过滤会提高维护门槛 + 降低通用知识复用率;检索 top-N≤3 返回时内容本身场景描述足够消费者判断。
- **状态**:📐 设计未实施
### AI 提炼触发 + 知识注入:可配置 [2026-06-13]
- **决策**:① AI 提炼**默认自动触发**(后台 detached task4 个配置项:`auto_extract`default true/ `trigger_mode`on_complete|on_idle|manual_onlydefault on_complete/ `min_messages`default 4/ `idle_timeout_ms`default 30000。② Chat system prompt 知识注入**加开关**`auto_inject: bool`default true关闭时首行返回空字符串零开销
- **原因/取舍**手动按钮依赖用户记得点→遗忘→空库死循环自动触发保证持续流入候选。但用户控制欲不同——4 配置项覆盖从「全自动」到「全手动」。每次 complete() <500 input/<200 output token可关零成本。注入开关给用户「先积累再开启 / 调试不被干扰」的控制权。
- **状态**:📐 提炼设计未实施 / ✅ 注入已实施Tier 1
### 检索方案LIKE + top-N向量检索提前到 Tier 1Phase 5.5[2026-06-13]
- **决策**Tier 1 检索用 SQLite `title/content LIKE '%query%'` + `ORDER BY reuse_count DESC LIMIT 3`;收件箱排序用 `CASE WHEN confidence 'high'→3/'medium'→2/'low'→1`(非纯 TEXT 字典序)。**向量检索从 Tier 2 提前到 Tier 1 同步实施**Phase 5.5),加 **Settings 开关**`vector_enabled`,默认 false
- **原因/取舍**:知识库核心消费场景是 AI 自动注入(拿用户自然语言 query 检索非关键词精确搜索——「部署后白屏」匹配不到「Nginx SPA 路由」,纯 LIKE 语义盲区从第一天就存在。开关化解「本地优先/零依赖」哲学冲突:默认关闭纯 LIKE 零外部调用,开启后才走 embed API。
- **实施细节**:① `LlmProvider` trait 加 `embed()`OpenAICompat 实现Anthropic 不支持);② V8 幂等补 `embedding BLOB`f32 小端序列化NULL=未嵌入走 LIKE**嵌入时机=发布时**candidate 不浪费 embedpublished 才参与检索);④ 纯 Rust 余弦(<50k 条暴力遍历够用,不引 sqlite-vec 避免 Windows C 扩展编译风险);⑤ 三层降级链开关关→LIKE开但 provider 缺/embed 失败→自动回 LIKE正常→混合检索双信号>LIKE 单>向量单cos≥0.3 滤噪)。
- **状态**:✅ Tier 1 LIKE + 向量混合检索Phase 5.5)均已实施,编译通过待实测
### 知识生命线:独立 knowledge_events 表(非 JSON 嵌主表)[2026-06-13]
- **决策**:知识产生/审核/引用/归档四类审计事件存**独立 `knowledge_events` 表**V10 迁移),而非塞进 `knowledges.context_json` 字段。
- **原因/取舍**:事件是追加型(只增不改删),语义与主表 CRUD 完全不同一条知识可被引用数百次JSON 嵌主表致行膨胀 + 写更新竞争(每次引用都重写整行)。独立表可建 `(knowledge_id,event_type)` 复合索引事件表写失败只丢审计、不影响知识本身fire-and-forget 隔离)。未来加新事件类型只加一行 insert不动主表 schema。
- **状态**:✅ 已实施V10 迁移 + KnowledgeEventsRepo + 前端生命线时间线)
### 📋 知识详情页 + 编辑能力candidate 审核闭环)[2026-06-13]
- **需求**:卡片点不开详情、不能编辑、看不到「为什么产生」。详情页需呈现完整生命线 + candidate 可编辑修正后发布。
- **决策**Knowledge.vue 重构为 Ideas 式左右分栏(左卡片列表 @click 选中 / 右详情面板四分区:①基本信息 ②溯源 ③引用记录 ④生命周期时间线);可编辑字段 title/content/tags/confidence/reasoning 走 `knowledge_update`(部分更新)。
- **原因/取舍**candidate 编辑是审核闭环刚需——AI 提炼必有水分/措辞瑕疵,只能原样发布或整条拒绝会让审核空转。详情复用 Ideas 已验证的 master-detail 模式。published 编辑不做(有归档+重提炼替代)。
- **状态**:✅ 已实施(编译+vue-tsc+df-storage 21 单测全绿GUI 实测待 #54
## AI Chat 上下文窗口与并发控制(架构结论)
> 实现细节见 [归档文档](./功能决策记录-归档.md)「AI Chat 上下文窗口与并发控制」。
### 设计决策 [2026-06-13]
- **ContextManager 类型替换为 messages 真相源**`AiSession.messages``Vec<ChatMessage>` 改为 `ContextManager`(非 wrapper 包装层),消息真相源唯一,避免 Vec + ContextManager 双存导致状态分裂。裁剪仅影响发送视图(`build_for_request` 返回裁剪版,`all_messages_clone` 返全量落库)。
- **淘汰算法:分组滑动窗口 + 三元组保护**`Assistant(tool_calls) + Tool(result)* + Assistant(final_text)` 工具调用三元组作为原子整体;最后 6 条≈2 个完整用户轮次)设为保护区永不淘汰。
- **Token 计数零依赖**`chars_count × 0.35` 粗估(误差 ±15% 可接受),不引入 tiktoken-rs5MB BPE 数据文件对 Tauri 打包不友好)。
- **双层 Semaphore 并发控制**AppState `LlmConcurrency`——全局并发默认 3 / 单对话默认 2permit 在 3 个叶子 LLM 调用点 acquire`run_agentic_loop` stream_llm 前、`generate_title_via_llm``extract_knowledge_from_conversation`),工具执行不受控。`per_conv` 实为应用级单一信号量(因 AiSession 单例 + generating 互斥,命名宽泛但当前语义正确,多对话路线时改 HashMap。Semaphore 重建用「软收敛」策略(替换内层 Arc旧 permit 不受影响)。
- **裁剪策略与模型选择正交**ContextConfig 不含 mode/模型选择字段;「高精度/低精度对话」属 LLM 调用层参数,与裁剪策略是正交维度。
- **状态**:✅ 已落地2026-06-13cargo check + vue-tsc 通过,待 tauri dev 实测)
## i18n
### legacy:false + zh-CN 默认 + locale 拆分 + glob 聚合 [Sprint 7 + 2026-06-13]
- **决策**:① `legacy:false` / `globalInjection` / zh-CN 默认 + en fallback / `localStorage df-language` 持久化。② `zh-CN.ts`/`en.ts` 单文件 → `zh-CN/*.ts` + `en/*.ts` 按模块拆分nav/dashboard/ai/common/settings/ideas/knowledge/projects/projectDetail/tasks/aiChat/aiTool`index.ts``import.meta.glob('./*.ts', { eager, import: 'default' })` 自动聚合(排除 index 自身)。新增模块文件即生效,不改 index。
- **原因**:① Composition API 模式;默认中文贴合自用,英文兜底。② 全量 i18n 接入8 view ~640 处中文)用多代理并行,模块隔离零冲突(每代理建自己模块 + 改自己 view不动共享 index比单文件扩 key多代理改同一 `zh-CN.ts` 冲突更适合并行。glob eager 运行时聚合,动态新增模块即拾取。
- **状态**:✅ Sprint 7 + 2026-06-13curl 验证 vite 正确展开 globvue-tsc PASS
### 状态枚举 i18nconstants 存 keyview 包 $t
- **决策**`constants/project.ts``PROJECT_STATUS_LABELS`/`TASK_STATUS_LABELS` 值从中文文案改存 i18n key`planning: 'projects.status.planning'``projectStatusLabel`/`taskStatusLabel` 返回 keyview 显示处包 `$t(projectStatusLabel(x))``PRIORITY_LABELS`P0/P1是代号非文案不动。
- **原因**constants 是纯数据/结构层,不应含展示文案(违反分层);文案归 localeconstants 管映射结构。
- **状态**:✅ 已落地
### 📋 项目 status 字段语义混乱(生命周期 vs 开发阶段)
- **现象**:后端 `projects.status DEFAULT 'active'` + `list_active`/软删除active/deleted 生命周期),但前端 `PROJECT_STATUS_LABELS` 是 planning/in_progress/paused/completed/cancelled开发阶段两套塞一个 status 字段。DB 实际只有 `status='active'`(新建默认,阶段值从没产生),前端 map 不认 → 显示英文 "active"。
- **治标**:补 `projects.status.active`(🚀进行中),不再显示英文。→ 根本未除。
- **治理方向**:① 后端支持阶段流转planning→in_progress…② 前端 map 对齐后端真实值active/deleted/archived③ 拆双字段status 生命周期 + stage 开发阶段)。
- **状态**:📐 待治理(治标已落地,根本病根未除)
## 状态持久化
### 窗口位置/大小:用 tauri-plugin-window-state纯 Rust 层)
- **决策**:窗口位置/大小/最大化用 `tauri-plugin-window-state` 插件Rust 层自动接管),而非前端 localStorage + setPosition/restore 方案。
- **原因**:插件自动覆盖主窗口 + 动态创建的 `ai-detached` 子窗口,零前端代码、零竞态;前端方案需手动同步且对子窗口生命周期处理复杂。需配套 `window-state:default` capability 权限。
- **状态**:✅ Sprint 10
### detached/docked 不持久化
- **决策**UI 布局持久化,但 `detached`/`docked` 重启后强制回 `false`,不随 `df-ai-ui` 落盘。
- **原因**:重启后分离窗口必然不存在,若恢复为 `true` 会让 UI 状态指向不存在的窗口按钮失灵、panelOpen 错乱)。这两个态是运行时临时态,不属可恢复布局。
- **状态**:✅ Sprint 10
> UI 布局 localStorage + 模块级恢复的细节见 [归档文档](./功能决策记录-归档.md)。
## 应用启动 / 数据库配置
### Dev 与 Build 拆分独立数据库 [2026-06-13]
- **决策**`lib.rs` 启动时按 `cfg!(debug_assertions)` 选 DB 文件名——debugDev 模式)用 `devflow-dev.db`releaseBuild 模式)用 `devflow.db`,两库同处 `app_data_dir()``top.1216.devflow`)下,靠文件名区分。
- **原因/取舍**原启动代码无编译模式分支Dev 与 Build 共用一个 `devflow.db`——Dev 频繁改动/清空会污染 Build 侧真实运行数据。拆分后 Dev 库可随意折腾Build 库长期保留作运行效果基线。**文件名区分而非子目录**——改动最小lib.rs 一行 if两库平铺同目录便于备份/查看。**现有 `devflow.db` 文件名未变归 Build**零迁移零数据丢失Dev 首次启动自动建空库。
- **边界**`app_data_dir()``tauri.conf.json``identifier` 决定、与编译模式无关故拆分前两种模式确读同一文件。docs/使用手册备份命令 `cp devflow.db` 仍正确(备份 Build 真实数据)。
- **状态**:✅ 2026-06-13 落地(`lib.rs:24`
## UI 反馈与弹层
### toast/confirm 自建,不引 Arco / 不用 window.confirm [Sprint 10]
- **决策**Settings 页轻量提示与删除确认用自建 `toast`(顶部 fixed3s 自动消失)+ `confirmDialog`(遮罩 + 卡片Promise 化),而非引入 Arco Message/Modal 或原生 `window.confirm`
- **原因**:① `@arco-design/web-vue` 虽在依赖但 `main.ts``app.use` 注册,引 Message/Modal 要补全局注册 + 样式加载,过重违反做减法;② `window.confirm` 在 Tauri webview2 带「来自 localhost:端口」来源信息,无法去除,体验差。自建零依赖、样式可控(主题色)、`await confirmDialog()` 语义贴近原生 confirm。
- **演进** [2026-06-13]AiChat 删对话需确认 → 第二处复用落地。抽成 `src/components/ConfirmDialog.vue``visible`/`msg`/`dangerLabel` props + `@result` emit。按钮样式内联自包含不依赖外部 `.btn-*`——因 Settings 是 `scoped`,组件拿不到其内定义的 `.btn-danger`。选 SFC 组件而非 `useConfirm()` composable模板/遮罩/Transition 动画/CSS 才是真正重复主体Promise 封装留在调用方(~8 行)。
- **状态**:✅ Sprint 10
## 技能 / 联想
### 首批 Claude 3 类 + path 去重
- **决策**:技能联想首批数据源 = Claude skills / commands / plugins 三类SKILL.md frontmatter按 path 去重(`cache/``marketplaces/` 重复)。
- **原因**frontmatter 格式统一(`name`/`description`/`user_invocable`可统一解析Codex frontmatter 一致后续可扩展openclaw 属 agent 选择层不纳入。
- **状态**:✅ Sprint 8待实测
## 决策治理产品化评估2026-06-13
> 审视 DevFlow 是否应把决策治理(记录/锚点/完成度/自检/漂移)做成产品功能。机制设计详见 [规格契约自检机制.md](./规格契约自检机制.md)。
### 5 痛点产品内未覆盖,真实运转的寄生 Claude Code 层 [2026-06-13]
- **决策**DevFlow 产品内对决策治理 5 痛点「设计满格、代码两极」——df-traceability 死代码(无表/无 IPC/无前端);契约锚点/AI 自检/漂移检测=规格契约自检机制.md 纯设计稿0 行代码);完成度无聚合视图。唯一真实运转的(功能决策记录.md + decision-record skill + dr-check hook寄生在 Claude Code 协作层,未沉淀进产品。
- **原因/取舍**:没用 Claude Code 的用户DevFlow 给不了任何决策治理能力。这套能力寄生在协作工具上,核心价值未进产品。
- **状态**:📐 待产品定位决策
### df-traceability 是锚点雏形Sprint 2 被砍(死代码可复活)[2026-06-13]
- **决策**`crates/df-traceability/``Annotation.location`(文件路径+行号)= 规格契约自检机制设计的「代码锚点」雏形,`Decision` struct 精确对应决策记录痛点。但 Sprint 2 对抗论证时被砍/降级,此后无表、无 IPCquery 方法全 `vec![]`
- **原因/取舍**:非显然关联——文档层设计的活契约+锚点机制,本质是产品外部用更轻方式重发明被砍的 df-traceability 轮子。是否复活取决于产品定位抉择Sprint 2 砍的理由(优先级低/过度设计)现需重新评估。
- **状态**:📐 待评估
### 产品化推荐路径 C 混合,完成度驾驶舱起步 [2026-06-13]
- **决策**三路径——A 全产品化(复活 df-traceability 全栈+spec 自检 AI成本大/重蹈 Sprint 2 覆辙风险B 纯寄生(承认是 Claude Code 协作层,只优化 skill/hook产品核心价值存疑**C 混合推荐——产品做数据底座decisions 表+完成度聚合+视图AI 验证/漂移留协作层**。最小起步:只做完成度驾驶舱(痛点 3
- **原因/取舍**C 分离「确定的数据层」与「不确定的智能层」,先做确定的低风险项。完成度驾驶舱起步:①最痛(记不住做了/没做②技术已存在tasks/ideas 有 status缺聚合 IPC+Dashboard 视图)③立刻可见④验证真会用再扩(避免 Sprint 2 式膨胀)。
- **状态**:📐 待用户拍板DevFlow 要否成为「决策治理/完成度驾驶舱」产品)
## crate 治理 / 模块结构2026-06-14
> 跨 crate 的删留与拆分决策。涉及 df-evolve 领域保留决策的推翻、coordinator 空壳的去留、ai.rs god file 的拆分方式。
### 删除 5 个零引用 crate推翻 df-evolve 领域保留决策)[2026-06-14]
- **决策**:整删 5 个 crate——`df-evolve` / `df-plugin` / `df-stages` / `df-task` / `df-traceability`。**推翻既有「df-evolve 领域类型保留」决策**:连同 `Knowledge`/`PromptTemplate`/`ReviewRule` 领域类型一起整删,知识库领域统一走 `df_storage::models::KnowledgeRecord`,不再维护独立领域模型层。
- **原因/取舍**
- **零引用铁证**:全仓跨 crate 引用为 0——`src-tauri/src` 下 0 处 `use`,其他 crate `Cargo.toml` 不依赖,仅 `src-tauri/Cargo.toml` 声明 `df-evolve` 但源码零用。整坨孤立骨架。
- **推翻归档决策**`功能决策记录-归档.md:355` 原记「`Knowledge`/`PromptTemplate`/`ReviewRule` 领域类型有意保留待 Tier 1 Service 复用」——本次决定**放弃 Tier 1 独立领域模型路线**。理由:① 知识库 Tier 1 已在 `KnowledgeRecord`df-storage上落地 LIKE + 向量混合检索 + 状态机,实际运转的领域模型就是 `KnowledgeRecord`df-evolve 的领域类型成为「理想但悬空」的另一套定义,重复且误导;② 维护两套领域类型是「未来可能复用」的预期成本 vs 「现在重复定义 + 误导性地雷」的实际危害,用户选消灭后者。
- **df-task::Task 一并删**`Task`(含 `branch_id`/`tags`/`estimate_hours` 等比 `TaskRecord` 更丰富的字段)作为「理想任务领域模型」长期悬空零引用,任务领域统一用 `df_storage::models::TaskRecord`
- **df-traceability 整删**:原被记为「锚点雏形可复活」(见上方「决策治理产品化评估」)。本次决定删除——如未来真需锚点机制,重新评估而非保留死码。死码「可复活」是一种伪期权,实际价值是误导后续维护者以为它在运转。
- **df-plugin / df-stages 属过早设计**df-pluginWASM/动态库、df-stages11 阶段节点 `execute()` 全空壳未启动即删与「人机协同设计基准」中「AI 反复读写的代码不养空壳」一致。
- **影响**:① `src-tauri/Cargo.toml` 需移除 `df-evolve` 依赖声明;② 「决策治理产品化评估」中 df-traceability 相关条目(锚点雏形 / 完成度驾驶舱数据底座)状态需重新标注为「无现存代码可复用」;③ 知识库功能域(`## 知识库`)所有决策继续适用,但实现载体明确为 `KnowledgeRecord` 而非 df-evolve 领域类型。
- **状态**:✅ 2026-06-14 落地(用户拍板删 5 crate + 领域类型)
### coordinator.rs 保留B 路线占位空壳,不删)[2026-06-14]
- **决策**`crates/df-ai/src/coordinator.rs``AgentCoordinator`(多 agent 协作占位空壳,`run()` 返回硬编码 TODO**保留不删**加注释标注「B 路线待立项」。
- **原因/取舍**:与 aichat 升级 A/B 路线拆分一致——A 路线先做 UX 快赢已进行B 路线单独立项补多 agent 协作决策能力。coordinator 是 B 路线的入口锚点,**有意保留占位**而非删除:① B 路线立项时有现成挂载点trait + 结构骨架),不必从零设计;② 与上述 5 crate 删除不矛盾——5 crate 是「无路线图占位的纯死码」coordinator 是「有明确后续路线B 路线)的占位」,二者判断标准不同。区别在「是否绑定明确的演进路线」。
- **状态**:📐 B 路线待立项(保留空壳 + 加注释)
### ai.rs 拆分:低风险子 module 而非下沉 crate [2026-06-14]
- **决策**`src-tauri/src/commands/ai.rs`2663 行 god file拆成 `commands/ai/` 子 module 目录11 个文件mod + commands + agentic + stream_recv + conversation + title + audit + skills + prompt + tool_registry + knowledge_inject`pub use` 保持 `state.rs`/`lib.rs`/`knowledge.rs` 引用路径零改动。**采用低风险子 module 方案而非下沉到 df-ai crate**。
- **原因/取舍**:① 2663 行单文件消耗大量 AI context每次理解靠结构与「人机协同设计基准」中「组件拆分从『不做』升为『值得做』」一致。② **选子 module 不选下沉 crate**——下沉到 df-ai 需动 crate 依赖图df-ai 反向依赖 src-tauri 的 state/types引入循环依赖风险 + 跨 crate 重构成本;子 module 仅在同 crate 内切目录,`pub use` 保持对外 API 不变,零引用路径改动,重构面最小。③ 子 module 仍能达成「职责单一」的 AI 可读性目标,与下沉 crate 收益相当但风险低一个数量级。
- **实施验证2026-06-14 落地)**
- 11 文件合计 2833 行,最大 knowledge_inject.rs 557 行含测试commands.rs 529 行17 个 IPC 命令),其余均 < 400 行。
- 路径契约全保:`commands::ai::{AiSession, build_ai_tool_registry, restore_pending_approvals, spawn_embedding_for_knowledge, trigger_extraction_now}` + 17 个 invoke 命令state.rs/lib.rs/knowledge.rs/commands/mod.rs **零改动**
- `cargo check` 0 error`cargo test ai::` 19 passed。剩 3 warning 全为预存非本次引入。
- 顺带修 bug`ai_conversation_list``models` 字段从 JSON 字符串直塞改为解析为 `Vec<String>` 数组下发(前端期望数组,原代码传字符串)。
- **状态**:✅ 2026-06-14 落地11 子 module + glob 重导出 + models bug 修复cargo check/test 通过)
### 前端 ai.ts 拆分路线Astore 留 state 单例 + composable不引入 Pinia [2026-06-14]
- **决策**`src/stores/ai.ts`758 行 god store拆成 store 骨架(留 reactive state 单例 + 模块级私有变量)+ `src/composables/ai/` 下 6 个 composable`useAiEvents`/`useAiStream`/`useAiSend`/`useAiConversations`/`useAiWindow`/`useAiPanel`)。`useAiStore()` 统一入口展开所有 composable 方法,**返回 shape 不变,组件零改动**。
- **原因/取舍**
1. **与项目既有 store 风格一致**——project/knowledge/settings 全是「手写 reactive + 工厂函数」模式,引入 Pinia 会破坏一致性、增加心智负担。
2. **组件零改动**——AiChat.vue/AiDetached.vue 等用 `const { state, sendMessage } = useAiStore()` 解构,保持 `useAiStore` 返回 shape 不变即可。
3. **选路线 Acomposable 挪逻辑、store 留 state而非路线 BPinia 多 store**——后者要改 19 个 state 字段归属和所有组件 import风险远大于收益。
4. **与后端 ai.rs 子 module 拆分配对**——前后端 god file/god store 同步拆解采用各自生态的惯用拆法Rust 子 module / Vue composable不强求统一模式。
- **影响**:确立项目 store 架构约定(手写 reactive + composable不用 Pinia影响所有未来 store 设计。
- **状态**:🚧 执行中workflow w20yb6n6b 编排vue-tsc 自验证)
### AI trait 下沉:拆 df-ai-core 轻层,确立全局 AI 接入标准 [2026-06-14 📐]
- **决策**:将 `LlmProvider` trait + AI 数据类型(`ChatMessage` / `CompletionRequest` / `ToolDefinition` 等)从 `df-ai` 拆出,下沉到新轻量 crate `df-ai-core`(零 http 依赖);`df-ai` 保留为「实现 + `ModelRouter` + provider 工厂」hub持有 `reqwest`/`openai_compat`/`anthropic_compat`);所有消费 crate`df-ideas` 接对抗评估、未来 `df-knowledge`/`df-workflow` 等)**只依赖 `df-ai-core` 的 trait不直接依赖 `df-ai`**;真实 provider 由 `src-tauri` 最上层装配注入。**F-260614-03 及后续所有 AI 接入点统一照此,不再 per-module 自定义 trait。**
- **原因/取舍**(纯 ai-coding 工作模式下重算 F-03 原 A/B/C 选型):
1. **砍「人审查的契约面」而非「打字量」**——一份全局 trait = 一份契约给人过目 + 规格契约 self-check 锚一点per-module 自定义 trait原 A= N 份发散契约,审查负担与漂移风险同涨。纯 ai-coding 下接线/mock 全由 AI 吸收,故 A 的「适配器成本」、B 的「mock 成本」论点作废。
2. **保住纯逻辑 crate 零摩擦自检**——`df-ideas`/`df-storage` 只依赖 trait 不拖 `reqwest`,规格契约机制 A/B 测试自动跑无 http 依赖;裸 Bdf-ideas 直接依赖 df-ai会污染纯逻辑 crate、自检摩擦上升。
3. **与现有 roadmap 对齐**——`ModelRouter`F-260614-01、多 Provider 负载均衡池F-260614-04、provider 工厂(已落地)本就在 df-ai 内走「gateway」方向消费方选型应顺此而非另起 N 个 trait。
4. **无环且与 ai.rs 拆分不冲突**`df-ai-core`trait+类型)← `df-ai`(impl) / `df-ideas`(use trait)`src-tauri` 装配。「ai.rs 子 module 不下沉 crate」规避的是 src-tauri→df-ai 反向依赖;本决策是 df-ai→df-ai-core 正向拆分,方向相反、互不矛盾。
- **否决项**:纯 Aper-module trait= 全局 N 份发散契约,反模式;裸 Bdf-ideas 直接依赖 df-ai= 纯逻辑 crate 被 reqwest 污染、自检摩擦上升。
- **退路**:若不愿加新 cratetrait 可放 `df-core`语义稍糙——LLM 非领域类型,但零新 crate可接受
- **状态**:📐 设计决策已定2026-06-14未实施。落地链① 新建 `df-ai-core` + 迁 trait/类型;② `df-ai` 改依赖 `df-ai-core` + 留实现;③ `df-ideas``df-ai-core` 依赖、`AdversarialEngine::evaluate` 加 provider 注入参数;④ src-tauri 装配真实 provider。
## 工作流人工审批节点B-03
### HumanNode 审批响应机制subscribe→send→select! 广播过滤等待 [2026-06-14 📐]
- **决策**df-workflow `HumanNode.execute` 改为「先 `subscribe()` → 发 `HumanApprovalRequest``tokio::select!` 循环等 `HumanApprovalResponse`」,按 `execution_id + node_id` 双键过滤命中后返回 NodeOutput`select!` 三分支 = 响应 / 超时(配置 `timeout_secs` 默认 3600s/ 取消500ms 轮询 `is_cancelled`)。复用既有 `EventBus`(broadcast) / `HumanApprovalResponse` 事件 / `approve_human_approval` IPC / 前端 store——零新增基础设施仅改 HumanNode 一处。
- **原因/取舍**
1. **订阅时序铁律**tokio broadcast 不回放历史,必须 `subscribe()` 先于 `send(Request)`,否则 receiver 错过 Response 死等超时。
2. **双键过滤**node_id 单键不够跨工作流可能重复、execution_id 单键不够(同层多 HumanNode双键才完备。
3. **Lagged 容忍**capacity 256 + 审批低频,漏自身 Response 概率极低;`continue` 优于丢弃(丢弃误判超时更糟)。
4. **decision 强制校验**options 非空时强制 `decision ∈ options`非法值报错而非静默放行——审批门控不能被脏输入绕过options 空时允许自由文本。
5. **B-06/B-07 是并发隔离/取消的前置,非单流功能前置**:单工作流 B-03 照常工作;并发安全等 B-06execution_id 下沉);取消机制需 B-07 + `set_cancelled` + cancel IPC单列 **B-03b**。B-03a响应等待 + 超时)不依赖 B-07。
- **边界**:取消分支在 B-07 + `set_cancelled` 补齐前恒 false等价无取消功能不残跨工作流并发 HumanNode 在 B-06 修前有 Response 错配风险。
- **状态**:📐 设计完成2026-06-14未实施。详见 [B-03-人工审批响应机制.md](./B-03-人工审批响应机制.md)。
## 需求与待办
> 汇集散落于各决策条目状态(📐/🚧)的待办 + 新增需求细节 + 需求澄清。单一清单,避免遗漏。
### 📋 待做需求
| 需求 | 功能域 | 来源 | 优先级 |
|---|---|---|---|
| Sprint 9/10 多项编译过未 tauri dev 实测(评分 IPC 缩放 / update_full / promote_idea / Store getter | 灵感/立项/Store | Sprint 910 🚧 | P1 |
| 切对话不中断路由:部分场景运行时实测 | AI Chat 可靠性 | Sprint 8 🚧 | P1 |
| 技能联想「使用」:首批 3 类联想已做,联想后实际触发/执行技能未实现 | 技能/联想 | Sprint 8 | P2 |
| 灵感对抗评估接 LLM论点/evidence 由 df-ai LlmProvider 生成(现启发式 fallback | 灵感模块 | Sprint 9 📐 | P2 |
| 知识库 Tier 1AI Chat ↔ 知识库双向闭环(沉淀+检索注入+reuse_count+审核收件箱+状态机+provenance 溯源+克制检索,无人工评分) | 知识库 | 2026-06-13 ✅ 已实施(Sprint 15) | P1 |
| 路径校验根治workspace 白名单 + canonicalize现仅拒 `..` + 敏感目录) | 工具调用 | Sprint 6 | P2 |
| 停止生成 idle 即时优化:`tokio::sync::Notify` 替代 120s 轮询 | AI Chat 可靠性 | Sprint 6 | P3 |
| 多 Provider 负载均衡池(备用模型/多账号聚合,全局容量=min(各 provider 上限之和, global_cap) | AI Chat 并发控制 | 2026-06-13 📐 | P2 |
| 裁剪/压缩消息按需召回Query Function + 分层存储: TrimRecord 追踪被移除范围 → DB 全量归档按需检索 → 精准注入 build_for_request触发方式待定:自动/手动/语义检索) | 上下文窗口管理 | 2026-06-13 📐 | P3 |
| ✅ IPC参数驼峰/蛇形不对齐误报澄清Tauri v2 自动将前端 camelCase 参数名转后端 snake_case`approve({toolCallId})` / `setConcurrencyConfig({globalLimit})` 实际正确、功能正常——无需修 | AI Chat | 2026-06-13 审查误报 | — |
| 🔴 df-workflow ConditionEngine 默认 true所有未识别条件表达式均通过工作流条件分支形同虚设`Ok(false)``Err` 一行可修 | 工作流引擎 | 2026-06-13 代码审查 | P0 |
| 🔴 df-workflow DagExecutor execution_id 硬编码 "dummy-execution-id":所有执行 ID 相同,追踪/审计失效 | 工作流引擎 | 2026-06-13 代码审查 | P1 |
| 📐 df-workflow HumanNode 假实现execute 注释"等待审批"但首次迭代直接 return "同意" — **设计完成 [B-03-人工审批响应机制.md](./B-03-人工审批响应机制.md),待实施**(依赖 B-06 并发隔离 / B-07 取消前置) | 工作流引擎 | 2026-06-13 多代理探索 → 2026-06-14 设计 | P0 |
| 🔴 df-workflow NodeRegistry::default() 的 script 工厂 unimplemented! panic用 default() 构建注册表 + 跑 script 节点即崩溃进程(非优雅 Err | 工作流引擎 | 2026-06-13 多代理探索 | P0 |
| 🔴 df-workflow executor 每节点拿全新空 StateMachineself.state_machine 从不传入 NodeContextHumanNode is_cancelled 恒 false取消机制失效 | 工作流引擎 | 2026-06-13 多代理探索 | P1 |
| 🟡 promote_idea 两步写非事务INSERT project 成功后若 UPDATE idea 失败,项目存在但想法状态未变,补偿删除可修 | 灵感/立项 | 2026-06-13 代码审查 | P1 |
| 🔴 分离窗口detached跨窗口状态失效用 localStorage 传递生成态快照df-ai-gen/textTauri 多 webview 不共享 localStorage 致静默失效;用户点 X 关闭(非 closeDetachedWindow后主窗口 `detached` 永真卡死reattachPanel 死代码未接线)。需改 Tauri 全局 emit/listen 同步 + 窗口销毁事件复位 | AI Chat 分离窗口 | 2026-06-13 代码审查 | P1 |
| 模型能力声明与自动路由系统 Phase 1ModelCapability 数据模型 + ModelRouter 重写 + 7 调用点接入 + Settings 模型池编辑 UI + AiChat 模型下拉。核心:按任务需求(模态/功能/成本)自动匹配合适模型,不再所有场景共用 default_model | 模型能力与路由 | 2026-06-13 📐 设计完成 | P1 |
| 模型能力系统 Phase 2多模态消息支持——ChatMessage.content: String → Vec<ContentPart>(Text/Image);前端粘贴/拖拽图片vision 模型自动路由 | 模型能力与路由 | 2026-06-13 📐 | P2 |
| 模型能力系统 Phase 3Agent 内智能路由——Agentic Loop 每轮按子任务构造不同 TaskRequirements成本预算控制模型级联降级跨 Provider 搜索 | 模型能力与路由 | 2026-06-13 📐 | P3 |
| 📋 待澄清「显示多开」:用户报"设置里勾选'显示多开'但 AiChat 未显示"。全 src grep `多开\|多窗口\|multi\|multiInstance` 零命中Settings.vue `settings` 对象仅 8 字段无此项。AiChat 唯一相关的是常驻「分离窗口」按钮(不受设置控制)。疑似:① 用户指分离窗口按钮(本就常驻不需设置);② 看的是打包旧版本界面;③ 想新增"允许分离窗口"设置开关。待用户截图/确认位置再定 | AI Chat | 2026-06-13 需求澄清 | — |
| 🔴 待审批持久化根治(重启恢复)未生效——两处逻辑断裂致恢复链路跑不通:① `ai_conversation_switch` 无条件 `pending_approvals.clear()` 清空 `restore_pending_approvals`(init)重建的内存 HashMap`ai_pending_tool_calls`/`ai_approve` 均依赖内存态 → 重启后前端 `switchConversation` 触发 clear → 审批卡片查空永不显示、审批报"未找到挂起的审批";② `ai_approve` 的 recovered 守卫跳过 `save_conversation`(注释称"防空 messages 污染老对话"前提不成立——switch 时 `restore_from_messages` 已载完整历史,审批时 messages 非空 → 执行的工具结果不落库,重启后 toolCard 显示 completed 但 result 仍是占位"需要用户审批,等待确认"。修复方向pending 恢复链路改查 DB`ai_tool_executions` WHERE status='pending' 持久化真相源)绕过内存 clear`ai_approve` 内存 miss 时 fallback DB 单条重建再执行recovered 审批通过后正常 save。可顺带删 `restore_pending_approvals`DB 即真相源)。阻断用户"功能逻辑层面解决"诉求——现"根治"实为表面修复 | AI Chat 审批持久化 | 2026-06-13 /review 审查①② | P0 |
| 📋 审批可见性缺口pendingApprovals 无兜底渲染→卡死 [2026-06-13]AI 发起 Med/High 工具审批AiApprovalRequired后暂停等审批不发 delta前端审批唯一出口是 ToolCard 的 pending_approval 内联卡片(靠 findToolCall 置 tc.status但 state.pendingApprovals 数组有数据却零渲染AiChat.vue 仅 @approve 转发,无 pendingApprovals 模板)。若 tc 卡片未显示审批,用户看不到审批按钮 → AI 永久等 → 文字停卡死。待修A. AiChat.vue 加 pendingApprovals 醒目渲染(顶部条/浮层)兜底审批可见性;或 B. 运行时确认 tc 卡片是否渲染。配套watchdog 在 AiApprovalRequired 暂停,审批没弹则 watchdog 盲点,需加"审批超时未响应"提示 | AI Chat 审批 | 2026-06-13 | 📋 A/B 待定 |
| 📋 node_executions 全表 list当前只写不读若未来前端要看某次工作流执行的节点明细需**新增** `list_node_executions(execution_id)` 命令 | 工作流引擎 | 2026-06-13 代码审查 | 📐 待需求驱动 |
### 📋 需求澄清
- **「决策」术语边界**2026-06-12devflow 语境「决策/决策需求点」= 日常开发功能细节取舍(为什么这么定),**非** aichat 决策能力升级B 路线 coordinator/conditions。后者属架构层记 Phase2计划/模块文档,不混入本文档。
- **代码审查甄别原则**2026-06-13审查发现问题时按「运行时失败/数据损坏 → 简单清理 → 记录不动 → 不做」四档甄别。当前项目规模下list_all 无 LIMIT、ALLOWED_COLUMNS 不分表、bool→int 重复等属「记录不动」——个人工具表不超千行,加分页/拆白名单是过度设计,维护成本 >> 收益。原则:**真实 bug 修、简单清理做、规模不到位的优化先不动**,保持全局简洁和扩展容易。
**相关文档**
- [Phase 1 架构决策](./Phase1架构决策.md) — 架构级决策ADR
- [经验记录](./经验记录.md) — 踩坑/约定/技巧/bug 排查教训
- [功能决策记录-归档](./功能决策记录-归档.md) — 纯流水/老 Sprint/UX 微调/已被取代
- `PROGRESS.md` — 各 Sprint 工作流水与遗留
- [Phase 2 计划](../07-项目管理/Phase2计划.md)

View File

@@ -0,0 +1,167 @@
# 文档记录规范
> 写文档 / 更新文档时的**路由规则**:记到哪、优先写哪、怎么避免重复。
> 创建:2026-06-12 | 维护:文档结构变化时同步
---
## 一、核心原则
1. **单一真相源(SSOT)**:每类信息只在一个主文档展开,别同一内容抄多处。
2. **不复制,只引用**:他处需要时加 `[详情](链接)`,不抄正文。
3. **决策与流水分离**:决策记「为什么这么定」,流水记「做了啥」。别混。
4. **先主后辅**:同一变更涉及多处 → 先写真相源,再在引用处加链接。
5. **决策记录范围收紧 — 只记人定事实,不记大模型分析结论**(2026-06-14,📐 基准原则):
- 决策记录**只记**与大模型能力无关的人定事实 — 业务需求规格 / 人定技术选型 / 人的设计取舍。
- **不记**大模型分析/推断结论(性能瓶颈 / 根因 / 最优架构 / 排查结果) — 这些按需让当时的模型即时产出,不沉淀。
- 原因:大模型分析结论受当前模型能力天花板约束,模型逐月变强,今天的「最优分析」明天会被更强模型超越 → 记录过时;沉淀 = 固化次优解,阻碍未来用更强模型即时得出更优解。即使埋点/实测「验证」了,也不改其受能力天花板约束、会随模型升级被超越的本质。
---
## 二、文档职责矩阵(真相源)
| 文档 | 唯一职责(记什么) | 不记什么 |
|---|---|---|
| `PROGRESS.md`(根级) | 工作流水:Sprint 做了啥 / 遗留 / 下一步 | 决策原因、实现细节、需求规格 |
| `ARCHITECTURE.md` | 系统架构全貌:crate 结构 / 数据模型 / Phase 规划 | 功能点取舍、Sprint 流水 |
| `02-架构设计/Phase1架构决策.md` | 架构级选型(ADR,系统级) | 功能实现层取舍 |
| `02-架构设计/功能决策记录.md` | 功能**需求规格 + 设计决策规格**(为什么这么定 + 要做什么) | 流水、架构级选型、经验性内容 |
| `02-架构设计/经验记录.md` | 经验性内容(踩坑/约定/技巧/bug 排查教训) | 决策、需求、流水 |
| `02-架构设计/功能决策记录-归档.md` | 纯流水/老 Sprint/UX 微调/已被取代(归档只读) | (不再维护更新) |
| `03-模块文档/*.md` | 各 crate 实现细节(单模块内) | 跨模块决策、流水 |
| `04-功能迭代/DEVFLOW-N.*.md` | 功能开发过程记录(一次性,开发期) | 持续维护的决策 |
| `05-代码审查/*.md` | 审查报告与发现 | (若成决策 → 转记功能决策记录) |
| `06-前端开发/*.md` | 前端规范 / 迁移指南 | 后端实现 |
| `07-项目管理/*.md` | Phase 计划 / 任务清单 | 实现流水(那是 PROGRESS) |
| `08-用户指南/*.md` | 用户手册 / 配置 / FAQ | 内部实现细节 |
| `01-技术文档/*.md` | 技术专题研究(CRUD 模式、IPC 模式) | 业务功能 |
---
## 三、内容路由表(写东西先查这个)
| 你要记的内容 | 主文档(优先写) | 按需交叉引用 |
|---|---|---|
| 本 Sprint 做了啥 / 遗留 | `PROGRESS.md` | `04-功能迭代/`(详过程) |
| 为什么这么实现(选 A 不选 B) | `功能决策记录.md` | 模块文档、PROGRESS |
| 踩坑 / 约定 / 技巧 / bug 排查教训 | `经验记录.md` | 功能决策记录 |
| 架构级选型 | `Phase1架构决策.md` / `ARCHITECTURE.md` | — |
| 新需求 / 待办 / 功能规格 | `功能决策记录.md`(需求维度) | `Phase2计划.md` |
| 对话中需求澄清(原以为 X 实为 Y) | `功能决策记录.md`(需求澄清) | — |
| 老 Sprint 决策 / UX 微调 / 已被取代 | `功能决策记录-归档.md` | (归档只读,不再维护) |
| 单模块实现细节 | `03-模块文档/<对应>.md` | — |
| 代码审查发现 | `05-代码审查/` | 转决策 → `功能决策记录.md` |
| 前端规范变更 | `06-前端开发/` | — |
| 用户操作说明 | `08-用户指南/` | — |
---
## 四、更新顺序(同一变更涉及多处)
1. **真相源先写完整**(按路由表的主文档)
2. **PROGRESS 记一笔 + 链接**(流水 + 指向详情)
3. **引用处加交叉链接**,不抄正文
**例**:做了「shouldKeepOpen 折叠」
- 真相源:`功能决策记录.md` 写决策 / 原因 / 状态 ✅
- 流水:`PROGRESS.md` 记「审查①已落地」+ 链接到功能决策记录
- **不**在模块文档 / ARCHITECTURE 重复抄决策正文
---
## 五、唯一性记录与检测(防散乱)
**核心要求:每类信息一个主文档,不散乱、不重复。** 文档治理底线。
### 唯一真相源
见「二、文档职责矩阵」——每类信息的唯一主文档。
### 检测方法
**1. 记前查重(每次记录时)**
- 记决策/需求前,先 grep 查该点是否已存在:
```bash
grep -rl "<关键词>" docs/ PROGRESS.md ARCHITECTURE.md
```
- 已存在 → 更新原条,不新增(见 decision-record skill「维护:查重」)
**2. 交叉引用单向(禁双向复制)**
- 主文档(真相源)展开内容,引用方只放 `[详情](链接)`
- ❌ A 写决策正文,B 又抄一遍 → ✅ B 只链接 A
**3. 配合交接文档(PROGRESS)**
- PROGRESS 是**交接文档**,只记「做了啥 + 链接」,不展开决策/需求正文
- 决策正文 → `功能决策记录.md`;需求 → `功能决策记录` 的「需求与待办」
- 交接路径:读 PROGRESS 知进度 → 读 `功能决策记录` 知「为什么 + 要做什么」→ 读模块文档知「怎么实现」
**4. 定期唯一性扫描(防积累散乱)**
- 时机:文档结构变化 / 新增文档 / 每个 Sprint 末
- 方法:对关键决策点跨文档 grep,确认只在主文档展开
```bash
grep -rl "shouldKeepOpen\|connect_timeout" docs/ PROGRESS.md
```
- 发现散乱 → 合并到主文档,他处改链接
### 不散乱红线
- 同一决策**不**同时进 `功能决策记录` 和 `Phase1架构决策`(功能层 vs 架构层二选一)
- 同一需求**不**同时在 `功能决策记录·需求与待办` 和 `Phase2计划` 展开(一处为主,一处链接)
- PROGRESS**不**抄决策正文,只记「做了 + 链接」
---
## 六、与 decision-record skill 的关系
`decision-record` skill 触发时,按本规范路由:
- **决策 / 需求** → `功能决策记录.md`(主,真相源)
- skill 执行后 → `PROGRESS.md` 加一笔流水 + 链接(可选,重大决策才加)
Stop hook 触发 skill 时同理,不另立记录位置。
---
## 七、治理体系实现决策(hook 设计)
本规范 + `decision-record` skill + 降频 Stop hook 构成文档治理体系。hook 设计取舍:
### 降频 Stop hook(替代 PreCompact / 每轮自检)
- **决策**:用 `~/.claude/hooks/dr-check.sh`(settings.json 配 Stop hook)按阈值注入自检提示,触发 `decision-record`。
- **原因**:PreCompact hook **只读输入、无法注入 prompt** 触发 skill;Stop hook 支持 `additionalContext` 注入。每轮 Stop 自检消耗大且打断;降频用纯脚本计数**无 API**,省 ~90% token。
- **状态**:✅ 2026-06-12
### 触发阈值:≥20 轮 或 (≥2 轮 且 ≥20 分钟)
- **决策**:累计 ≥20 轮,或 (>1 轮 且 距上次 ≥20 分钟) 才触发;首次运行静默初始化(计时,本轮不触发)。
- **原因**:10 轮约一个功能点推进周期;10 分钟兜底防长对话漏记;兼顾及时与不打扰。
- → 2026-06-14 阈值翻倍(10→20 轮 / 10→20 分钟)。原阈值触发过频,多数自检轮次无实质决策;翻倍减半打扰。✅ 落地(dr-check.sh)
### 防循环:stop_hook_active guard
- **决策**:hook 检测输入 `stop_hook_active=true` → 直接 `exit 0` 放行。
- **原因**:Stop hook 注入 additionalContext 会触发主 Claude 继续 → 再次 Stop → 无限循环;guard 放行第二轮(因 hook 继续的)。
- **状态**:✅
### 状态按项目隔离
- **决策**:轮次计数 + 上次触发时间戳存 `~/.claude/.dr-state/<项目key>.rounds|.last`(键由路径转义),不落项目目录。
- **原因**:多项目独立计数不串;不污染 git 仓库。
- **状态**:✅
### 决策记录执行子代理化(2026-06-14)
- **决策**:stop hook 自检触发后,记录动作 spawn 后台子代理执行(项目 `decision-recorder` 子代理,位于 `devflow/.claude/agents/`),主代理不亲自 grep/写文档。
- **原因**:避免记录动作(grep/读写文档)污染主对话上下文、打断主流程;记录规范固化进子代理 system prompt,主代理只传决策内容。
- **状态**:✅ 落地(dr-check.sh 的 CTX 已改为指令 spawn 子代理)
---
## 八、待修(文档不一致)
- ~~`docs/INDEX.md` 在 `07-项目管理/` 树下登记了 `PROGRESS.md`,但实际 PROGRESS 只在根级,`07-项目管理/` 下无此文件~~ → ✅ 已修(2026-06-12):移除该行,PROGRESS 统一指向根级。
---
**相关文档**:
- `docs/INDEX.md` — 文档导航
- `docs/02-架构设计/功能决策记录.md` — 需求规格 + 设计决策规格(本规范的主要应用对象)
- `docs/02-架构设计/经验记录.md` — 踩坑/约定/技巧/bug 排查教训
- `docs/02-架构设计/功能决策记录-归档.md` — 归档只读(纯流水/老 Sprint/UX 微调)
- `PROGRESS.md` — 工作流水

View File

@@ -0,0 +1,140 @@
# 经验记录
> DevFlow 开发中沉淀的**经验性内容**——踩坑、约定、技巧、bug 排查教训。聚焦「这个坑怎么踩的 / 这个约定为什么这么定 / 这个 bug 怎么定位的」,区别于 [功能决策记录](./功能决策记录.md)(记需求规格 + 设计决策规格)。
>
> 创建2026-06-14从功能决策记录.md 分流出经验性条目) | 维护:随开发追加
## 约定
- 按类型分组:**踩坑**(隐性坑/反直觉)/ **约定**(代码实现约定 / 命名约定)/ **技巧**(具体技巧/配置)/ **bug 排查**bug 定位过程与教训)。
- 每条标题标 `[来源日期]` + `[Sprint]`(如有),便于回溯原上下文。
- 三要素:**现象/决策** → **原因/根因****状态/教训**
- 与功能决策记录区分:这里记「怎么实现的细节坑」,不记「为什么这么设计」。
---
## 一、踩坑
### i18n 模块必须命名空间化导出(扁平导出会断 $t + 键覆盖)[2026-06-14]
- **现象**:左侧菜单显示 `'nav.tasks'`(原样键名);`dashboard` 整页显示 key 名;`AiChat``$t('ai.assistant')` 失效。
- **决策**:每个 i18n 模块文件 `export default { 命名空间: {...} }`(如 `nav.ts``{ nav: {...} }`**禁止扁平导出顶层词条**。模板查询走 `$t('命名空间.key')`
- **根因**`index.ts` 聚合是 `Object.assign` 扁平合并各模块顶层 key见「locale 拆分 + glob 聚合」决策)。扁平导出导致两个 bug`$t('nav.tasks')``messages.nav` 不存在,原样显示键名;② 扁平键(如 nav 的 `ideas`/`projects`/`tasks`/`knowledge`与同名命名空间模块ideas.ts/projects.ts/...)按文件名字母序互相覆盖。本次 nav.ts 扁平导出导致 4 个键被覆盖。
- **状态/教训**:✅ 系统性修复,共 4 模块扁平已全部改嵌套zh/en 8 文件nav / common8 文件 25 处 $t 引用)/ dashboard / ai。原本正确嵌套ideas/projects/tasks/settings/knowledge/projectDetail/aiChat。**教训**:扁平导出是体系性 bug 非单点。排查「$t 显示原样键名」时应**优先怀疑模块导出结构(扁平 vs 嵌套)**,而非 SSR / locale 初始化。
- **绕路纠错**:曾误判根因为 SSR实际 Tauri 纯客户端无 SSR→ nav 走 `getNavTranslations` 硬编码 map + displayText 绕路 → 清除绕路恢复标准 `$t`
---
## 二、约定
### Anthropic 流式 output_tokens 当累计值直接覆盖 / 流式 token 落库走累加模式 [2026-06-13]
- **决策**:① `message_delta` 事件的 output_tokens 直接覆盖 completion_tokens**不像 prompt 那样累加**;② `save_conversation` upsert 路径 token 读旧值叠加(非覆盖);`run_agentic_loop` 局部累加器每轮叠加、退出时一次性传 save。
- **原因**:① Anthropic 协议在 `message_delta` 返回的是**累计** output_tokens截至当前总量非增量当增量处理会重复计算。OpenAI 则是末 chunk 一次性给全量——两协议语义不同,各自处理。② 审批暂停→恢复 spawn 全新 `run_agentic_loop` 实例,新 loop 局部累加器从 0 起;若覆盖写会丢旧 loop 已落库的 token。累加保证跨 loop 实例的对话总用量正确。
- **状态**:✅ 2026-06-13两协议各自语义处理 + 跨 loop 累加保对话总量)
### db 字段加列须同步四处migration + crud 白名单 + AI 工具层白名单 + 工具描述 [2026-06-14]
- **现象**AI 对话让 AI 绑定目录update_project(path) 报「不允许更新字段 'path'」,但 db schema 和 crud 白名单都已有 path。
- **根因**可更新字段有两套独立白名单——crud.rs::allowed_columns_forDB 层)+ ai.rs 工具闭包硬编码 matchAI 工具层)。加 path/stack 时只同步 DB 层漏 AI 工具层,两层不一致。
- **教训**:加 Record 可变字段同步四处migration + crud 白名单 + ai.rs 工具白名单 + 工具描述。排查「DB 有字段但工具报不允许」直查 ai.rs 硬编码。架构债:白名单双份去重。
### Tauri 命令文件拆子 module命令函数必须 glob `pub use *`,不能逐个显式 [2026-06-14]
- **现象**:把含 `#[tauri::command]` 的单文件(如 ai.rs拆成 `ai/` 子 module 时mod.rs 用 `pub use self::commands::{ai_chat_send, ...}` 逐个显式重导出 17 个命令,`cargo check` 报 40 个 E0433`cannot find __cmd__ai_chat_send in ai` / `cannot find __tauri_command_name_ai_chat_send in ai`
- **根因**`#[tauri::command]` 宏不只生成命令函数本身,还用 `paste!` 宏拼接生成一组同模块定义的内部符号(`__cmd__xxx``__tauri_command_name_xxx`)。`generate_handler!` 解析 `commands::ai::ai_chat_send` 时会查找 `commands::ai::__cmd__ai_chat_send`。逐个 `pub use self::commands::{ai_chat_send}` 只拉函数本身,**拉不到这些 `__cmd__` 内部符号**(即使它们在原模块是 pub 的)。
- **教训**拆命令文件时mod.rs 重导出命令必须用 `pub use self::commands::*;`glob 把宏生成的全部符号一起拉到上层路径),不能用逐个显式。非命令 pub 项(如 `build_ai_tool_registry`/`restore_pending_approvals`)可逐个显式。后续若拆 idea.rs/project.rs/task.rs 等其他含命令的大文件,同此模式。
- **状态**:✅ 2026-06-14 验证ai.rs 拆 11 子 moduleglob 重导出后 cargo check 0 error
### 跨层模块拆分:`super::xxx` 路径失效需改全限定 [2026-06-14]
- **现象**ai.rscommands 直接子模块)拆到 `ai/xxx.rs`commands 孙模块6 个子文件 `use super::now_millis` 全报 E0425 unresolved import。
- **根因**`super` 指向当前模块的父——ai.rs 时 `super` = `commands``now_millis` 定义处);拆到 `ai/xxx.rs``super` = `commands::ai``now_millis` 在祖父模块 `commands`
- **教训**:拆层后所有 `super::xxx` 引用需重审。父模块的 helper`now_millis`)改全限定 `crate::commands::now_millis` 最稳(不依赖层级)。或拆层前把 helper 下沉到子 mod.rs 内 `use` 一次,子文件用 `super::xxx`
- **状态**:✅ 2026-06-14 验证(批量改 `crate::commands::now_millis`6 文件 20+ 处)
### 删文件后被 linter/工具重建为 0 字节触发 E0761 [2026-06-14]
- **现象**`rm commands/ai.rs` 后某 linter/hook 又建了 0 字节的 ai.rs触发 `E0761: file for module ai found at both ai.rs and ai/mod.rs`,且 Rust 优先选空文件导致后续 40 个 `cannot find __cmd__xxx`(与 glob 重导出坑叠加,表象一致根因不同)。
- **教训**:拆分时删原文件后**立即 ls 验证不存在**再跑 cargo check避免空文件 + 目录并存的 E0761 与命令宏符号坑混淆。E0761 出现先查是否有 0 字节残留文件。
- **状态**:✅ 2026-06-14 验证(删空 ai.rs 后通过)
### ALLOWED_COLUMNS 从全局共享演进为按表隔离 [2026-06-13]
- **决策**`crud.rs` 列名白名单从单一全局 `ALLOWED_COLUMNS` 改为 `allowed_columns_for(table)` 按表 match`validate_column_name(field, table)` 接收表名;宏 `query`/`update_field``$table`。专用更新路径列knowledges.embedding 走 set_embedding、projects.deleted_at 走 soft_delete/restore排除出白名单。
- **演进原因**:原原则(见功能决策记录需求澄清「代码审查甄别原则」)基于「全局白名单够防注入」。本轮多代理代码审查发现**真实 bug**:全局白名单**误含 ideas 表没有的 `reasoning` 列**reasoning 属 knowledges/V10`update_idea("reasoning")` 会 validate 通过但 SQLite 报 `no such column`——错误从「白名单拒绝」退化成「底层 SQL 错」且语义错。按表隔离既修此 bugideas 白名单不含 reasoning又防未来跨表字段update_task 误传 projects 的 `name` 在校验阶段拒绝,非靠 SQL 兜底)。
- **代价/取舍**12 表 × N 列的 match 冗长,但数据驱动、可读、一次写对。**规模判断不变**(仍不加分页/不拆 LIMIT仅白名单从「全局防注入」升级为「按表防注入 + 防跨表字段」。
- **状态**:✅ 2026-06-13 落地cargo check + df-storage 32 test 全绿,含 `update_field_rejects_cross_table_column_tasks_name` 用例验证跨表字段被拒)
### 配置存储SQLite/AppState Arc<Mutex>,非 Tauri app config [2026-06-13]
- **决策**KnowledgeConfig提取+注入共 5 项)**存 AppState 内存**`knowledge_config: Arc<Mutex<KnowledgeConfig>>`),前后端通过 `knowledge_get_config`/`knowledge_save_config` IPC 读写;**不引入 tauri-plugin-store**。
- **演进**[2026-06-13 初版设计] 写「存 Tauri app config」 → [2026-06-13 审查修正] 代码实证项目 Cargo.toml 仅 opener+window_state 两插件,**从未用过 config/store 机制**现有设置走两条路SQLite 存 provider / localStorage 存 UI 偏好) → [2026-06-14] **`SettingsRepo` 兑现本条预言**`app_settings` KV 表V13 迁移)+ 手写 `SettingsRepo`(get/set/get_all/delete不走 `impl_repo!` 宏因 KV 无固定 schema)。localStorage 11 key 迁移启动:敏感 `df-connections` + UI 偏好(theme/language/ai-width/ai-ui/token/concurrency) + `df-ai-active-conv`;例外 `df-ai-gen`/`df-ai-text`(流式临时快照,每个 delta 写一次SQLite 高频写拖慢流式,留 localStorage
- **原因**AI Provider 配置已是 SQLite+Repo+IPC 模式,知识库行为配置(后端行为,非 UI 偏好)对齐同模式最一致。引入 tauri-plugin-store 是全新基础设施依赖,与既有 DB 路线割裂。AppState Arc<Mutex> 内存持有 + IPC 读写,启动时 `default()` 初始化Tier 1 未持久化到 DB进程重启回默认——够用因这是行为偏好非数据。未来要持久化时复用同一套 SettingsRepo 即可。
- **状态**:✅ 已实施Tier 1
### 知识删除语义knowledge_archive 软删除(命名统一)[2026-06-13]
- **决策**:知识删除 command 命名 `knowledge_archive`(执行 `UPDATE status='archived'`**不叫 knowledge_delete**。匹配 `ai_conversation_archive` 先例;主列表 `knowledge_list(status=None)` 默认 `AND status!='archived'` 过滤。
- **原因/取舍**idea/task/project 的 `delete_xxx` 都是硬删DELETE FROM若 knowledge 也叫 delete 却做归档API 语义混淆(调用方期望数据消失,实际还在 DB。conversation 模块已有正确先例archive 命名表示软删除)。软删除复用 archived 状态,数据保留可追溯,列表默认过滤保证用户感知「已删除」。状态机 published→archived 也走同一路径。
- **状态**:✅ 已实施Tier 1
### Store 状态字段用 getter 替代引用快照 [Sprint 10]
- **决策**`useProjectStore()` 返回对象的状态字段projects/tasks/ideas/workflowExecutions/liveEvents/loading/error改 getter 实时读 state而非 `ideas: state.ideas` 引用快照。
- **原因**:引用快照在 `loadIdeas()` 等重新赋值 state.ideas 后,返回对象的 ideas 属性不更新刷新后视图空需切菜单再切回才显示getter 每次读 state响应链成立。computedstats/pendingApproval在 reactive 内仍自动解包,各视图用法零改动。
- **状态**:🚧 Sprint 10编译/构建通过,未 tauri dev 实测,根因通杀 Projects/Tasks/Dashboard
---
## 三、技巧
### migrate_v4PRAGMA table_info 探测列存在性 [Sprint 10]
- **决策**v4 加 `archived` 列时,用 `PRAGMA table_info` 幂等探测列是否已存在,而非仅依赖 `schema_version` 版本号 gate。
- **原因**:历史坏库 `schema_version` 值混乱(早期迁移异常致版本号与实际 schema 不符),版本号不可靠;直接探列存在性最稳——已存在则跳过,不存在则补建,幂等可重入。
- **状态**:✅ Sprint 10
### Vite 端口 `strictPort: true` 不自动迁移 [Sprint 1]
- **决策**`vite.config.ts``port: 1420` + `strictPort: true`,端口被占时**直接报错退出**而非自动 +1 迁移;`tauri.conf.json``devUrl` 写死 `http://localhost:1420`
- **原因**Tauri webview 启动时按 `devUrl` 加载前端,若 Vite 因冲突静默迁移到 1421 而 devUrl 仍是 1420 → 白屏/连不上,错误难定位(易误判为前端代码 bug`strictPort` 让端口冲突当场炸出定位明确。代价1420 被占需手动杀进程但换取「devUrl 与实际端口必一致」的不变量。
- **状态**:✅ Sprint 1本次会话核对1420 vs 2661 反复折腾后回退到 1420即此耦合的直接体现
---
## 四、bug 排查
### ai_tool_executions 审计回写失效Med/High 审批后卡 pending[#54 实测]
- **现象**:用户审批 Med/High 工具后执行成功(副作用落库,如 create_project→projects 有记录),但 `ai_tool_executions``status=pending / decided_by=None / executed_at=None / result=None`审计未闭环。Low 工具正常(`decided_by=auto` 完整)。
- **根因(代码层定位)**`crud.rs:103``impl_repo!` 生成的通用 `query` 硬编码 `ORDER BY created_at DESC`,但 `ai_tool_executions` 表**无 `created_at` 列** → `audit_finalize``query("tool_call_id", x)` SQL 报 `no such column: created_at``.unwrap_or_default()` 吞错返回空 → `if let Some(rec)` 为 None → **永不回写**。Low 工具不走 query`process_tool_calls` Low 分支直接 `audit_tool_call` insert 完整记录)故不受影响。
- **架构隐患**:通用 `query``ORDER BY created_at` 假设所有表都有该列——`ai_tool_executions`(及潜在其他无 `created_at` 的表)任何 `query()` 调用都静默失败;`unwrap_or_default` 吞 SQL 错误放大隐患。
- **修复**:✅ 已落地2026-06-13。采用方向①`crud.rs``AiToolExecutionRepo` 加专用 `find_by_tool_call_id`(裸 SQL `ORDER BY requested_at DESC LIMIT 1`,绕过宏的 `created_at` 假设);`ai.rs audit_finalize` 改用之,查不到记录改 `tracing::warn`(不再 `unwrap_or_default` 静默吞错)。
- → 未改宏(方向②影响 7+ 表)/ 未加列(方向③需迁移):隐患仅 `ai_tool_executions` 一处暴露,局部修最小影响。
-**架构隐患仍存(未根治)**:通用 `query`/`list_all` 宏对无 `created_at` 的表(`ai_tool_executions`/`node_executions`/`workflow_executions`)调用仍静默失败。当前仅 `ai_tool_executions``query` 调用且已绕开,余者暂无 `query` 调用点。未来新增调用时,要么该表登记 `created_at`,要么宏做容错。
- **教训**:宏生成的通用方法对表 schema 的隐式假设(这里「所有表都有 created_at」是隐蔽的系统性风险`unwrap_or_default()` 吞错误让 bug 隐形——关键路径慎用。
### reasoning 字段回填(修 bugprompt 要求但写库丢弃)[2026-06-13]
- **现象/决策**`KnowledgeRecord``reasoning: Option<String>`V10 ALTER`extract_knowledge_from_conversation` 解析 LLM JSON 的 `reasoning` 字段写入主表;前端详情溯源区展示「🤖 AI 判断依据」。
- **根因**`EXTRACTION_SYSTEM_PROMPT` 早已要求 LLM 输出 `reasoning: "为何值得沉淀"`但提炼循环ai.rs 旧版)只取 kind/title/content/tags/confidence**reasoning 被 LLM 产出却遭代码丢弃**——是信息链断裂的 bug非缺功能。审核员光看 content 结论,缺 AI 判断依据(尤其 confidence=low 的弱信号更靠 reasoning 解释为何还提炼)。回填后溯源完整。
- **状态**:✅ 已实施reasoning 存主表 + extracted 事件 context.reasoning 双写,前端优先取主表降级取事件)
- **教训**LLM 输出字段与代码消费字段须对账——prompt 要求 LLM 产出的字段,代码侧漏消费是常见隐性 bug。
### prompt_tokens=0深挖证伪非代码 bug疑 GLM 订阅端点 message_start 缺 input_tokens[#54 实测发现]
- **现象**`ai_conversations.prompt_tokens=0`completion=1496 正常。GLM-订阅anthropic 协议1 对话 24 消息,所有 assistant 消息 `usage=None`
- **深挖结论(→ 修正初判)**初判「anthropic_compat usage 解析漏 input_tokens待修」**证伪**。逐段验证:
1. `anthropic_compat` message_start 取 `input_tokens→prompt_tokens` **有单测**input=42 过);
2. `stream_llm`857`final_usage=chunk.usage.clone()` 累积对;
3. `ai.rs:699` `tokens.add` 链路对。
代码按标准 Anthropic 协议解析正确。`inp as u32`Some→值None→0completion 有值说明 message_delta 的 output GLM 返回了,**prompt=0 = GLM 订阅端点 message_start 疑未返回 `usage.input_tokens`**(协议非标)。**勿改 anthropic_compat**(改了 = 误改正确实现)。
- **状态**:📐 待修(误判)→ 🚫 非代码 bug。待抓 GLM 订阅 SSE 原文确认 input_tokens 在哪个事件/字段(临时打 message_start/message_delta 的 usage JSON 日志,测完删);若确认端点缺则属 provider 兼容性待办,非解析 bug。
- **教训**bug 定位优先用单测/逐段验证证伪代码层假设,不要急着改「看似正确」的实现。深挖证伪避免了一次误改。
---
**相关文档**
- [功能决策记录](./功能决策记录.md) — 需求规格 + 设计决策规格
- [功能决策记录-归档](./功能决策记录-归档.md) — 纯流水 / 老 Sprint / UX 微调 / 已被取代

View File

@@ -0,0 +1,359 @@
# 规格契约自检机制
> 创建2026-06-13 | 阶段:设计定稿,待落地
> 性质:设计说明 + 可执行规格基准。后续 `spec-verifier` agent、`/spec-check` skill、自检 hook 均从本文档推导。
---
## 0. 背景与问题
全程 AI coding 下,开发节奏快(实测 ~4 Sprint/天),产生两个痛点:
1. **规格无锚点 → 漂移 → 不敢当契约用**:写下的 spec 没人验证,与代码逐渐脱节,最终失去参考价值。
2. **done/todo/decision 散落 → 记不住**:做了什么、没做什么、做了哪些决策,事后查不清。
**错误方向**:新建独立的「需求规格」文档。静态 spec 必漂移;本项目无外部契约/验收需求spec 的核心价值(沟通契约/验收基准)不成立;独立文档违反 SSOT成为第三处真相源。
**正确方向****活契约living contract**——契约跟决策一起演进,由 AI 自检维持与代码一致。不新建文档,改造现有功能决策记录。
---
## 1. 核心方向:活契约
契约不单独成文,而是挂在功能决策记录的每条决策上。一条 `✅ 已落地` 的决策,就是一条当前生效的契约。
```
决策记录(活契约载体)
├─ 决策三要素:决策 / 原因 / 状态
├─ 代码锚点:让契约可被验证(机制 A
└─ 状态字段:聚合出完成度(机制 B
AI 自检维持契约与代码一致(机制 C/D/E
```
---
## 2. 机制 A代码锚点防漂移
`✅ 已落地` 的决策加一个**代码锚点**。锚点以**符号名 + grep 关键词为主锚**(跨修改稳定),**行号为辅锚**最近定位可漂AI 自愈)。规格真相留在代码里,文档只留索引 + 意图。
### 形态
```markdown
### connect_timeout 不设总 timeout [Sprint 6]
- 决策reqwest Client 加 connect_timeout(30s),不设总 timeout
- 原因:连接阶段防无限 hang总 timeout 会误砍流式长生成任务
- 状态:✅ 已落地
- 锚点:符号 `Client::new` | grep `connect_timeout` @ client.rs:142行可漂AI 自愈)
- 自检PostEdit 触发锚点一致性 | 上次2026-06-13 ✅
```
- **锚点**:符号 + grep 为主真相行号为辅快照。人补一次AI 维护行号。
- **自检行**AI 验证后回写时间戳与结果,人扫一眼即知近期是否验过。
### 主辅分明:为什么行号不是主体
| 锚组成部分 | 稳定性 | 角色 |
|------------|--------|------|
| 符号名(函数/类/常量) | 高(重构才改) | 主锚 |
| grep 关键词 | 中高 | 主锚(定位调用点) |
| 行号 | 低(加删几行就漂) | 辅锚(最近定位,可漂) |
代码高频修改下,行号必然漂;行号作主体 = 锚点必然失效。符号 + grep 才是跨修改稳定的真相。
### 三层抗漂
1. **行号漂(最常见)**grep 不受影响重新定位。AI 自愈(行号漂但 grep 在附近 → 自动修);机制未跑时 grep 命中也能秒级定位。人无感。
2. **符号/grep 漂(罕见,如重命名)**:必伴随决策变更 → grep 失效 = 正确的报警信号,触发决策同步(命中第 5 节"信息不足"上报条件)。
3. **功能删除**grep 全失效 → 报警 → 人确认废弃或误删。
维护靠 AI 不靠人:人只在新增决策时补一次锚点;之后行号漂由 AI 在自检环节自愈,人改代码时无需动锚点。
### 状态阀门:锚点只绑稳定态
锚点验证只对相对稳定的契约有效。剧烈重构期契约本身不稳,强行锚是噪音。
| 状态 | 是否锚 | 原因 |
|------|--------|------|
| `✅ 已落地` | 锚定 | 稳定,可验证 |
| `🚧 待实测` | 不锚/暂锚 | 不稳定,重构中 |
| `📐 设计未实施` | 不锚 | 未实现,无代码可锚 |
重构完成、代码稳了,`🚧→✅` 再锚定。状态字段是防锚点失效的阀门。
---
## 3. 机制 B完成度聚合表
完成情况已编码在状态字段里(✅/🚧/📐)。缺的是按状态聚合的视图。在功能决策记录头部维护一张索引表:
```markdown
## 完成度总览
| 状态 | 数量 | 代表条目 |
|------|------|----------|
| ✅ 已落地 | 38 | connect_timeout、provider 路由、知识库 Tier 分层 |
| 🚧 待实测 | 5 | 审计回写、… |
| 📐 设计未实施TODO | 12 | 向量检索、count_any 否定检测、IdeaPromoter 接线 |
```
- `✅` = 做了什么;`📐` = 没做什么;决策条目 = 做了哪些决策。三问一表答完。
- 增量维护:新增决策更新计数;`📐→✅` 迁移挪列。按状态聚合,不按时间,比 PROGRESS 流水好查。
---
## 4. AI 自检:三级验证
| 层级 | 验证内容 | 可靠性 | 谁验 |
|------|----------|--------|------|
| **L1 锚点存在性** | 文件:行 + grep 关键词命中 | ✅ 高(确定性) | 主代理 grep |
| **L2 取值一致性** | 具体数值/标志是否如 spec 所述 | ⚠️ 中(范围窄,误读低) | 主代理读码 |
| **L3 行为契约** | 代码逻辑是否遵守 spec 意图 | ❌ 低(主观) | 子代理最小上下文 |
**核心贡献**AI 把脆弱的行号锚点变成自愈的语义锚点——
- 行号漂但 grep 关键词在附近 N 行 → **AI 自动修正锚点行号**(可逆,自处理)。
- 关键词消失 → **真报警**,语义变了,需人决策。
---
## 5. 分流规则AI 能做 vs 人必须做
目标:让 AI 机械吞掉确定性/低风险/可逆的 80%,只把真正需要人脑的推到人面前。
### 5.1 两轴判定矩阵
| | 客观唯一(确定) | 主观/多解 |
|---|---|---|
| **只读/可逆** | ✅ AI 全权自处理 | ⚠️ AI 给候选 → 人定 |
| **有后果/不可逆** | ⚠️ 报告 → 人定 | 🔴 必须人定 |
### 5.2 两条机械判定
**AI 自处理(不报人)的充要条件**`确定性 = 客观 AND 动作 = 可逆`
(锚点行号自愈、数值核对一致、自检时间戳回写。)
**必须上报人的条件(任一命中即报)**
1. **主观**——验证答案不唯一(行为契约、意图符合性)。
2. **有后果**——动作不可逆(改决策语义、标记契约被破坏、回滚代码)。
3. **信息不足**——AI 无法判定(锚点关键词消失,但不知是否故意改的)。
### 5.3 兜底安全阀
规则未覆盖的场景,**默认上报人**,不擅自自处理。宁可多报,不可漏报关键。
### 5.4 高后果判定(决定是否触发子代理)
| 判为高后果(满足任一) | 例 |
|------------------------|-----|
| 数据完整性 | 写库、迁移、状态机流转 |
| 并发安全 | 锁、共享状态、异步竞态 |
| 安全 | 鉴权、注入、凭据处理 |
| 外部契约 | API/协议、第三方对接 |
| 不可逆操作 | 删除、覆盖、发布 |
高后果决策即使主代理自检报绿,仍触发子代理第二意见。
---
## 6. 反馈规格:五字段决策单元
上报给人的每条,必须是**可点的闭合决策**不是要调查的谜题。AI 把上下文打包进去,人只回答 yes/no 或选 A/B。
```markdown
🔴 [决策名] connect_timeout 不设总 timeout
锚点client.rs:142 | 实际:行号漂至 158且 grep "timeout" 消失
证据:预期 .connect_timeout(30s) 无 .timeout() | 实际代码已加 .timeout(60s)
为何上报:命中「信息不足」——无法判定是故意改回总 timeout还是误改
候选A. 故意改 → 更新决策记录(状态/原因)
B. 误改 → 回滚代码AI 推断 B 更可能:总 timeout 会误砍流式)
需你定A 还是 B
```
「为何上报」显式化分流规则,使判定可审计。
---
## 7. 子代理隔离验证L3 / 高后果层)
### 7.1 为什么用 agent 不用 skill
| | Skill | Agent |
|---|---|---|
| 上下文 | 复用主对话,**不隔离** | 独立窗口,**隔离** |
| 偏误 | 主代理带作者偏误执行(白搭) | 消除作者偏误 |
主代理验证有结构性确认偏误:决策是它记的、代码是它改的,倾向支持自己对。**隔离验证必须 agent。**
### 7.2 零上下文 → 最小必要上下文
完全零上下文是双刃剑:子代理无领域知识会误判(局外人偏误)。正确形态是**给事实,不给立场**——子代理是陪审员,只看证据下判断。
**卷宗格式**(由编排方构造,传入 agent
```
断言:此函数应"丢弃残缺响应,不入库"
证据:<精确代码片段>
任务:判断代码行为是否符合断言
```
**不给**:决策原因字段、对话历史、是否刚改的、当初怎么定的。
### 7.3 分歧才报人
子代理不替代人,是在「上报人」前加第二意见:
```
主代理自检(带上下文判一次)
├─ L1/L2 确定性 → 自处理
└─ L3/高后果 → 起 spec-verifier agent最小上下文判一次
├─ 两代理一致(都绿/都红)→ 按结论走
└─ 两代理分歧 → 🔴 报人(分歧暴露主观性,只有人能定)
```
人的事件面从「所有主观项」压缩到「主观项中的分歧项」。
---
## 8. 触发环节:何时拉起 Agent
起 agent ⟺ 命中下列环节之一 AND 验证项是主观层(L3)或高后果。
| 环节 | 时机 | 起 agent? | 验证范围 | 频率控制 |
|------|------|-----------|----------|----------|
| **A 改代码** | PostEdit hook改到挂锚文件 | 改到高后果锚点才起L1/L2 主代理自验 | 仅被改那条契约 | 每次相关编辑,单条 |
| **B 回合结束** | Stop hook降频 | 本回合涉及的高后果/主观项 | 本回合动过的 | 抽样≥10 轮/≥10 min |
| **C 主动审计** | 手动 `/spec-check` | 全部 L3/高后果 | 所有 ✅ 决策 | 人触发,全量并行 |
| **D 记录决策** | decision-record 标 ✅/演进时 | 新记或 📐→✅ 的高后果项 | 该单条 | 每次 ✅ 迁移 |
- **A 最值钱**:在「可能制造漂移的时刻」拦截,单条,便宜。优先级最高。
- **B 兜底**catch A 漏的(一处改多处)。
- **C 体检**:清历史漂移,最贵,人触发。
- **D 防脱节**:决策记了但代码没跟上,✅ 迁移时必验。
全程 AI coding 下A/B 自动跑零摩擦C 人按需D 跟 decision-record 自然触发。无需人记「该验证了」。
---
## 9. 三层架构与组件骨架
```
hook时机→ /spec-check skill编排→ spec-verifier agent隔离验证
PostEdit/Stop 读记录+分流+封装卷宗 最小上下文判定
L1/L2 自处理 L3/高后果
收集+分歧上报 返回:判定+置信+分歧点
```
### spec-verifier agent`.claude/agents/spec-verifier.md`
```
你是独立契约验证者。只依据调用方给你的【断言+证据】判断。
不假设意图,不参考对话历史,不信任任何"应该是什么"的预设。
输出:判定(符合/违反/无法判定)+ 置信度 + 关键分歧点(一句话)。
无法判定时必须明说,禁止凑结论。
```
### /spec-check skill`.claude/skills/spec-check/`
```
1. 读功能决策记录,提取所有 ✅ 条目(锚点+断言)
2. 分流L1/L2确定性→ 自己 grep 验,自处理
L3/高后果(主观)→ 调 spec-verifier agent传断言+代码片段,不传决策原因)
3. 主代理自己也判一次 L3带上下文
4. 比对:分歧项 → 按五字段格式化上报;一致项 → 按结论走
5. 锚点行号漂移 → 自愈(可逆,自处理)
```
基建复用devflow 已在用 hookStop 降频)+ skill/review、decision-record。三层机制全是同构基建不引入新依赖。
---
## 10. 落地顺序
1. **本文档定稿**(当前)——后续所有实现的规格基准。
2. **功能决策记录瘦身 + 补锚点**——删微决策膨胀730→~300给 ✅ 条目补代码锚点。
3. **主代理自检 hook**——PostEdit环节 A+ Stop 降频(环节 B覆盖 L1/L2 确定性层。
4. **spec-verifier agent + /spec-check skill**——覆盖 L3/高后果四环节A/B/C/D主观层。
5. **分歧上报机制**——五字段决策单元,接入 Stop hook 通知。
---
## 11. 用户操作指南
> 本节是人视角的操作手册。机制细节见 2-9 节,这里只讲「你做什么」。
### 心智模型3 按钮 + 1 屏
整个机制里,人只做三件事,看一块屏。其余全是 AI 自动。
| | 人的动作 | 时机 |
|---|----------|------|
| 🔘 记决策 | 做取舍时,让 AI 用 decision-record 记下(决策/原因/状态) | 每次开发有取舍 |
| 🔘 裁决分歧 | AI 上报时,在候选里选 A 或 B | 子代理与主代理打架时(偶发) |
| 🔘 跑体检 | 执行 `/spec-check` 全量扫描 | 大版本前 / 重构后 |
| 🖥 完成度表 | 翻功能决策记录头部「完成度总览」表 | 想看进度时 |
### 人 vs AI 分工
| 动作 | 归属 | 频率 |
|------|------|------|
| 做开发取舍(选 A 不选 B | 人 | 每次开发 |
| 记决策 + 补锚点 | AI 做,人确认 | 决策落地时 |
| 维护锚点行号(自愈) | AI | 自动 |
| 验证代码符合契约 | AI | 自动 |
| 起 hook / 子代理自检 | AI | 自动 |
| 裁决 AI 分歧 | 人 | 上报时 |
| 查进度 | 人(看表) | 随时 |
人只做两件:**记决策 + 裁决分歧**。验证、维护、检查全归 AI。
### 看的入口与时机
| 想知道 | 看哪里 | 时机 |
|--------|--------|------|
| 做了/没做/做了哪些决策 | 功能决策记录头部「完成度总览」表 | 随时 |
| AI 发现的契约冲突 | 上报条目(五字段:锚点/证据/为何上报/候选/需你定) | 被动收(偶发) |
| 全量漂移体检 | `/spec-check` 红项报告 | 主动(大版本前) |
### 做的节奏
- **记决策**:开发中一有取舍,当场记。齿轮转起来的起点,零额外成本。
- **裁决**:收到上报 → 选 A/B → AI 执行。
- **体检**:每 Sprint 末或重构后跑一次 `/spec-check`,清历史漂移。
- **迭代机制**:规则不准(误报/漏报)→ 改本文档第 5 节分流规则。机制文档是活的。
### 一天的工作流
```
开发中做取舍 ──→ 🔘记决策AI 补锚点,人不管)
│ AI 后台hook 自检 / 子代理验证 / 行号自愈
AI 打架?─是─→ 🔘裁决(选 A/B
│否
想看进度 ────→ 🖥翻完成度表
大版本前 ────→ 🔘跑 /spec-check 体检
```
### 当前态:能做什么
机制尚未落地(落地链 ②-⑤)。当前能力边界:
| 能力 | 现在 | 建成后 |
|------|------|--------|
| 🔘 记决策 | ✅ 已有decision-record | ✅ |
| 🖥 完成度表 | ❌ 需先补锚点 + 建表(②) | ✅ |
| 🔘 裁决上报 | ❌ 需 ③④⑤ | ✅ |
| 🔘 /spec-check | ❌ 需 ④ | ✅ |
解锁其余能力的起点是落地链 ②:补锚点 + 建完成度表。
---
## 12. 诚实边界
- **AI 自检降低漂移,不消除。** 行为级契约仍需人盯。别因「AI 验过」就放心改语义。
- **同一 AI 的盲区贯穿写与验。** 全程 AI coding 下,当初记录漏掉的约束,验证时 AI 也想不到查。关键决策的 spec人过一眼。
- **一致 ≠ 正确。** 两代理一致时仍可能共享同一盲区spec 本身写错,两代理按错的理解一致)。一致只代表「无分歧可上报」。
- **selective 用子代理。** 全用 = 成本爆炸。只 L3 + 高后果。

View File

@@ -1,298 +1,234 @@
# df-ai - AI 集成模块 # df-ai AI 集成模块
> Provider 抽象层与流式响应处理 > 创建: 2026-06-10 | 最后更新: 2026-06-13
## 📋 模块概览 ---
`df-ai` 负责 AI 功能的核心集成,支持多个 AI Provider提供统一的接口和流式响应处理。 ## 概述
### 主要特性 `df-ai` 是 DevFlow 的 AI 核心层,提供 LLM Provider 抽象、双协议实现OpenAI 兼容 + Anthropic、流式 SSE 解析、工具调用、上下文窗口管理和 embedding 支持。
- 多 Provider 支持OpenAI、Anthropic、DeepSeek
- 流式响应处理
- 工具调用支持
- 错误处理和重试机制
## 🏗️ 架构设计 ---
### 核心组件 ## 当前状态
```rust
// Provider trait 定义
pub trait AIProvider: Send + Sync {
async fn chat_completion(&self, request: ChatRequest) -> Result<ChatResponse>;
async fn create_embedding(&self, text: &str) -> Result<Vec<f32>>;
}
// 流式响应处理 | 能力 | 状态 |
pub struct StreamProcessor { |------|------|
event_sender: mpsc::UnboundedSender<StreamEvent>, | LlmProvider trait | ✅ |
} | OpenAI 兼容 Provider流式/非流式/embed| ✅ |
| Anthropic Provider流式| ✅ Sprint 8 |
| ContextManager分组滑窗| ✅ Sprint 11 |
| embed() 向量生成 | ✅ Sprint 15 |
| AiToolRegistry基础设施| ✅ Sprint 512 工具注册在 commands/ai.rs|
| coordinator | ⬜ 空壳B 路线待填)|
// 统一的 AI 服务 ---
pub struct AIService {
providers: HashMap<String, Box<dyn AIProvider>>, ## 文件结构
default_provider: String,
} ```
crates/df-ai/src/
├── lib.rs — 公共导出
├── provider.rs — LlmProvider trait含 embed 默认实现)
├── openai_compat.rs — OpenAI 兼容实现chat + embed
├── anthropic_compat.rs — Anthropic Messages API 实现Sprint 8
├── context.rs — ContextManager 分组滑动窗口Sprint 11
├── ai_tools.rs — AiToolRegistry + 12 工具定义
├── coordinator.rs — AgentCoordinator 空壳B 路线)
├── router.rs — ModelRouter 模型路由空壳route() 按 TaskType 选模型,当前全返回 default_model
└── stream.rs — StreamCollector 流式辅助(累积 chunk.delta 文本 + 跟踪 finished 标志)
``` ```
### Provider 实现
- **OpenAIProvider**: OpenAI GPT 系列模型
- **AnthropicProvider**: Claude 系列模型
- **DeepSeekProvider**: DeepSeek 模型
## 🔧 使用方法 ---
## LlmProvider Trait
### 基础聊天
```rust ```rust
use df_ai::AIService; pub type StreamResult = Pin<Box<dyn Stream<Item = anyhow::Result<StreamChunk>> + Send>>;
let ai_service = AIService::new(config); #[async_trait]
let request = ChatRequest { pub trait LlmProvider: Send + Sync {
model: "gpt-4".to_string(), // 非流式完整响应(用于标题生成/知识提炼)
messages: vec![Message { async fn complete(&self, request: CompletionRequest)
role: "user".to_string(), -> anyhow::Result<CompletionResponse>;
content: "Hello, world!".to_string(),
}],
};
let response = ai_service.chat_completion(request).await?; // 流式 SSE主对话
``` async fn stream(&self, request: CompletionRequest)
-> anyhow::Result<StreamResult>;
### 流式响应 // 向量生成(默认 bailopenai_compat 覆盖实现)
```rust async fn embed(&self, _model: &str, _texts: Vec<String>)
use df_ai::stream_chat; -> anyhow::Result<Vec<Vec<f32>>> {
anyhow::bail!("该 Provider 不支持 embedding({})", self.name())
let (mut receiver, mut stream) = stream_chat(&ai_service, request).await?;
while let Some(event) = receiver.recv().await {
match event {
StreamEvent::Content(chunk) => {
print!("{}", chunk);
}
StreamEvent::Done => {
println!("\n完成");
}
StreamEvent::Error(e) => {
eprintln!("错误: {}", e);
}
} }
// Provider 名称(必填)
fn name(&self) -> &str;
// 支持的特性必填streaming / function_calling / vision
fn supported_features(&self) -> ProviderFeatures;
} }
``` ```
### 工具调用
```rust
let request = ChatRequest {
model: "gpt-4".to_string(),
messages: vec![Message {
role: "user".to_string(),
content: "创建一个文件".to_string(),
}],
tools: vec![Tool {
r#type: "function".to_string(),
function: FunctionDef {
name: "create_file".to_string(),
description: "创建文件".to_string(),
parameters: Parameters {
r#type: "object".to_string(),
properties: serde_json::json!({
"path": {"type": "string"},
"content": {"type": "string"}
}),
required: vec!["path".to_string()],
},
},
}],
tool_choice: "auto".to_string(),
};
```
## ⚙️ 配置
### Provider 配置
```yaml
# config/ai.yaml
providers:
openai:
api_key: ${OPENAI_API_KEY}
base_url: "https://api.openai.com/v1"
model: "gpt-4"
max_tokens: 4000
temperature: 0.7
anthropic:
api_key: ${ANTHROPIC_API_KEY}
model: "claude-3-sonnet-20240229"
max_tokens: 4000
temperature: 0.7
deepseek:
api_key: ${DEEPSEEK_API_KEY}
model: "deepseek-chat"
max_tokens: 4000
temperature: 0.7
default_provider: "openai"
```
### 环境变量
```bash
export OPENAI_API_KEY="sk-your-key"
export ANTHROPIC_API_KEY="sk-ant-key"
export DEEPSEEK_API_KEY="your-key"
```
## 🔄 错误处理
### 错误类型
```rust
pub enum AIError {
APIError(String), // API 调用失败
Timeout, // 请求超时
RateLimit, // 达到速率限制
InvalidResponse, // 响应格式错误
ProviderNotFound, // Provider 不存在
ConfigurationError, // 配置错误
}
```
### 重试机制
```rust
let config = RetryConfig {
max_attempts: 3,
backoff: ExponentialBackoff::from_millis(1000),
retryable_errors: vec![
AIError::Timeout,
AIError::RateLimit,
],
};
let response = ai_service.chat_with_retry(request, &config).await?;
```
## 📊 性能优化
### 缓存机制
```rust
pub struct CachedAIService {
inner: AIService,
cache: Arc<Mutex<HashMap<String, ChatResponse>>>,
}
// 缓存键生成
fn cache_key(request: &ChatRequest) -> String {
format!("{:?}-{:?}", request.model, request.messages)
}
```
### 连接池
```rust
pub struct ConnectionPool {
connections: HashMap<String, Vec<Client>>,
max_connections: usize,
}
pub async fn get_client(&self, provider: &str) -> Result<Client> {
// 从连接池获取或创建新连接
}
```
## 🔍 监控与日志
### 请求追踪
```rust
pub struct RequestTracer {
request_id: String,
start_time: Instant,
metrics: RequestMetrics,
}
impl RequestTracer {
pub fn log_request(&self, provider: &str, duration: Duration) {
metrics.record_request(provider, duration);
}
}
```
### 指标收集
```rust
pub struct RequestMetrics {
total_requests: AtomicU64,
successful_requests: AtomicU64,
failed_requests: AtomicU64,
average_duration: AtomicDuration,
}
```
## 🧪 测试
### 单元测试
```rust
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_chat_completion() {
let ai_service = AIService::new(test_config());
let request = test_request();
let response = ai_service.chat_completion(request).await;
assert!(response.is_ok());
}
}
```
### 集成测试
```rust
#[tokio::test]
async fn test_multiple_providers() {
let providers = vec!["openai", "anthropic"];
for provider in providers {
let ai_service = AIService::new(config_for_provider(provider));
let response = test_chat(&ai_service).await;
assert!(response.is_ok(), "Provider {} failed", provider);
}
}
```
## 🚨 最佳实践
### 1. 错误处理
```rust
// ✅ 正确
match ai_service.chat_completion(request).await {
Ok(response) => handle_response(response),
Err(AIError::RateLimit) => wait_and_retry(),
Err(e) => log_error_and_notify(e),
}
// ❌ 错误 - 忽略错误
let _ = ai_service.chat_completion(request).await;
```
### 2. 资源管理
```rust
// ✅ 正确 - 使用连接池
let client = connection_pool.get_client("openai").await?;
// ❌ 错误 - 每次创建新连接
let client = Client::new(config);
```
### 3. 并发控制
```rust
// ✅ 正确 - 使用信号量
let semaphore = Arc::new(Semaphore::new(10));
let permit = semaphore.acquire().await?;
let response = ai_service.chat_completion(request).await;
// ❌ 错误 - 无限制并发
let handles: Vec<_> = requests.into_iter().map(|req| {
tokio::spawn(ai_service.chat_completion(req))
}).collect();
```
--- ---
**相关文档**: ## OpenAI 兼容 Provideropenai_compat.rs
- [df-storage - 存储层](./df-storage-存储层.md)
- [df-workflow - 工作流引擎](./df-workflow-工作流引擎.md) 支持 OpenAI / DeepSeek / GLM / 本地 Ollama 等任意 OpenAI 兼容端点。
- [df-nodes - 节点集合](./df-nodes-节点集合.md)
| 特性 | 实现 |
|------|------|
| base_url 智能拼接 | `chat_url()` 三分支:已含 `/chat/completions` 直用;以 `/v<数字>` 结尾(如 `/api/paas/v4`,由 `ends_with_version` 判定)补 `/chat/completions`;仅域名(如 `api.openai.com`)补 `/v1/chat/completions` |
| 流式 | `stream: true` + SSE 逐 chunk 解析(`apply_openai_sse` 纯函数)|
| 工具调用 | tool_calls 按 index 排序(消 HashMap 迭代乱序)|
| usage 解析 | `stream_options: {include_usage: true}`,末 chunk 读累计值 |
| finish 判定 | 逐 chunk 判 `finish_reason``stop`/`tool_calls`/`length` 三者同等视为正常 finished`length` = max_tokens 截断,属正常终止而非断连)|
| embed | POST `/v1/embeddings`,响应按 index 排序返回 `Vec<Vec<f32>>` |
| connect timeout | `connect_timeout(30s)`,不设总 timeout避免误砍流式长任务|
> idle timeout120s与断连丢弃finished_received属上层 `stream_llm`src-tauri/commands/ai.rs的职责不在本 Provider 层。
SSE 解析抽成 `pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>) -> StreamChunk` 纯函数,与 HTTP/eventsource 解耦,便于单测(喂构造 data 字符串验证 `[DONE]`/usage 覆盖/tool_calls 等分支)。
---
## Anthropic Provideranthropic_compat.rsSprint 8
| 特性 | 实现 |
|------|------|
| 认证 | `x-api-key` + `anthropic-version: 2023-06-01` |
| 消息格式 | 顶层 `system` 字段,`max_tokens` 必填(请求缺省时兜底 `DEFAULT_MAX_TOKENS = 4096`|
| 流式 SSE | event 类型解析(全集见下)|
| 工具调用 | `content_block` type=tool_use |
| usage | `message_start` 初始化累加器(input_tokens)、`message_delta` 覆盖 completion(output_tokens 为累计值,非增量)、`message_stop``take()` 带出累积 usage |
| embed | 不支持Anthropic 无 embed API继承 trait 默认 Err |
SSE 事件全集(按 `type` 字段分发):
| event type | 处理 |
|------------|------|
| `message_start` | 用 `message.usage.input_tokens` 初始化累加器output 置 0|
| `message_delta` | `usage.output_tokens` 是累计值(非增量),直接覆盖 completion 并重算 total |
| `content_block_delta`(text_delta) | 文本增量 |
| `content_block_delta`(input_json_delta) | 工具入参增量(带 index|
| `content_block_start`(tool_use) | 工具块开始,带 id + name |
| `message_stop` | 返回 `finished=true` 终态 chunkusage 经 `take()` 带出 |
| `error` | 返回 `finished=true` 终态空 chunk不清空累加器|
| 其它content_block_stop / ping| 空 chunk |
SSE 解析抽成 `pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUsage>) -> StreamChunk` 纯函数,与 HTTP/eventsource 解耦,便于单测(喂构造 data 字符串验证事件分支与 usage 累积)。
支持 GLM 订阅端点(`https://open.bigmodel.cn/api/anthropic`)。
---
## ContextManager 分组滑动窗口context.rsSprint 11
解决长对话无限增长导致 `context_length_exceeded` 死锁。
### 核心设计
- **TokenEstimator**:字符粗估,零依赖,保守 ±15%。单条公式 = `content(chars×0.35 ceil)` + `per_message 4` + 每个 `tool_call(per_tool_call 30 + name/arguments 各按 chars×0.35 ceil)` + 有 `tool_call_id``+3`
- **ContextConfig**max_tokens 128k / output_reserve 8192 / safety 0.85;预算公式 `(max_tokens output_reserve) × safety_ratio`,由 `ContextConfig::budget_limit()` 提供(`ContextManager::budget_limit()` 仅转发它)
- **分组淘汰单元**工具调用三元组ToolCallHead + ToolResultTail* + 紧随文本 Assistant原子性同进同出防上下文语义断裂
- **保护区**`PROTECT_COUNT = 6`(编译期常量,≈ 最近 2 个完整用户轮次),最后 6 条消息永不裁
- **裁剪范围**:仅影响发送视图(`build_for_request`),全量历史(`all_messages_clone`)用于持久化
### 关键方法
| 方法 | 用途 |
|------|------|
| `push(message: ChatMessage)` | 追加消息并计 token、更新 `history_tokens` 缓存push 不裁剪,裁剪统一在 build_for_request|
| `clear()` | 清空消息历史并重置 token 计数 |
| `len() -> usize` | 历史消息条数 |
| `is_empty() -> bool` | 历史是否为空 |
| `history_tokens() -> u32` | 当前历史累计 token不含 system prompt|
| `budget_limit() -> u32` | 上下文预算上限,转发 `config.budget_limit()` = (max_tokens output_reserve) × safety_ratio |
| `iter() -> impl Iterator<Item = &ChatMessage>` | 只读迭代历史消息(如标题生成 filter|
| `build_for_request(sys_tokens) -> (Vec<ChatMessage>, bool)` | 返回裁剪后的消息列表 + 是否发生裁剪(用于 LLM 调用)|
| `all_messages_clone()` | 返回全量消息(用于 save_conversation / 标题生成)|
| `restore_from_messages(msgs)` | 切换对话时重建 ContextManager 缓存 |
| `replace_tool_result_content(tool_call_id, new_content) -> bool` | 原子更新工具结果内容(审批通过/拒绝时回填),返回是否找到并替换 |
---
## AI 工具注册
> 归属:`ai_tools.rs` 仅提供基础设施(`RiskLevel` / `AiTool` / `AiToolRegistry`12 个工具的具体定义与注册在 `src-tauri/src/commands/ai.rs::build_ai_tool_registry`handler 即唯一执行路径schema+risk+实现同源)。
12 个内置工具,按风险分级:
| 风险 | 工具 |
|------|------|
| Low自动执行| list_projects / list_tasks / list_ideas / read_file / list_directory |
| Medium需审批| update_project / create_project / create_task / create_idea / write_file |
| High需审批| delete_project / run_workflow |
工具执行结果写 `ai_tool_executions` 表(审计日志)。
---
## 知识库集成Sprint 15逻辑在 src-tauri/commands/ai.rs
### 知识注入
`build_knowledge_context(state, query, config) -> String`
- `config.auto_inject == false` → 立即返回空(零开销)
- `hybrid_search()` top-3LIKE 或 LIKE+向量混合)
- 每条命中调 `increment_reuse_count`fire-and-forget
- 格式化为 markdown注入 system prompt 头部
### 知识提炼
`extract_knowledge_from_conversation(db, conv_id, provider_cfg)`
- 后台 spawn`tauri::async_runtime::spawn`),提炼失败仅 warn 不阻断
- 取最后 6 条 user/assistant 消息 → LLM JSON 输出(强制 JSON schema
- parse 失败整批丢弃;成功则逐条写 candidate
`maybe_spawn_extraction(...)`agentic loop 两处正常退出路径统一调用。
### 向量 embeddingPhase 5.5
`generate_embedding(state, text, config) -> Option<Vec<f32>>`
- 截断 8000 字 → 找 `embedding_provider_id` 对应 provider → `embed()`
- 失败返回 None降级 LIKE
`hybrid_search(state, query, limit, config)`:三层降级链:
1. `vector_enabled == false` → 纯 LIKE
2. embed 调用失败 → 纯 LIKE
3. 正常 → 双信号合并排序(同时 LIKE+向量 cos≥0.3 > 仅 LIKE > 仅向量 cos≥0.3
---
## LLM 并发控制state.rsSprint 11 Part C
`LlmConcurrency`:双层 Semaphore运行时可调。
| Semaphore | 默认 permits | 限流对象 |
|-----------|------------|---------|
| global | 3 | 全部 LLM 调用stream_llm / 标题 / 提炼)|
| per_conv | 2 | 单对话并发 |
本地工具执行(`tools.execute`)不限流,无外部成本。
---
## 决策能力缺口B 路线,待立项)
| 能力 | 现状 | 需补 |
|------|------|------|
| Planning任务规划| 缺失 | 意图 → LLM 规划子任务 DAG |
| Coordinator多 agent| `coordinator.rs::run()` 全 TODO | 实现多 agent 协作拆 DAG |
| Conditions条件分支| 缺失df-ai 无实现,条件求值属 df-workflow crate| JSON Path / 比较 / and-or-not |
| Reflection自纠| 缺失 | 执行后自检 / 重试 |
详见 [Phase 2 计划 - 决策能力升级](../07-项目管理/Phase2计划.md)。
---
## 相关文档
- [df-storage 存储层](./df-storage-存储层.md)
- [df-workflow 工作流引擎](./df-workflow-工作流引擎.md)
- [df-nodes 节点集合](./df-nodes-节点集合.md)

View File

@@ -0,0 +1,135 @@
# 知识库模块
> 创建: 2026-06-13 | 阶段: Tier 1 已实现
---
## 概述
知识库是 DevFlow 的"共享记忆层",被动积累 AI 对话中产生的可复用经验供后续对话注入使用。外部工具Claude Code / CodeX / Cursor可通过相同 IPC 接口读写,内外零差异。
---
## 实现状态
| 功能 | 状态 |
|------|------|
| candidate→published 状态机 | ✅ Tier 1 |
| 手动录入Knowledge.vue| ✅ Tier 1 |
| AI 自动提炼(对话完成后)| ✅ Tier 1 |
| LIKE 关键词检索 + 注入 system prompt | ✅ Tier 1 |
| 审核收件箱(人工门控)| ✅ Tier 1 |
| 向量 embedding + 混合检索 | ✅ Phase 5.5(开关控制,默认关)|
| ai_node prompt 注入 | ⬜ Tier 2 |
| MCP 对外 API | ⬜ Tier 2 |
---
## 状态机
```
candidate ──→ pending_review ──→ published ──→ archived
│ │ │
└────────────────┴───────────────┘
(可直接到 archived)
```
- AI 提炼只产 **candidate**,绝不自动 published人工门控
- `knowledge_archive`软删除status=archived不物理删除
---
## 知识类型KnowledgeKind
7 种:`pitfall`(踩坑)/ `review_rule`(审查规则)/ `prompt_template`Prompt 模板)/ `architecture_pattern`(架构模式)/ `diagnosis`(诊断知识)/ `deployment_note`(部署经验)/ `workflow_optimization`(工作流优化)
---
## IPC 命令11 个)
| Command | 说明 |
|---------|------|
| `knowledge_list(status?)` | 全量列表,默认排除 archived |
| `knowledge_get(id)` | 单条查询 |
| `knowledge_search(query, kind?, limit?)` | LIKE 检索top-N≤3 |
| `knowledge_create(input)` | 创建status=candidate|
| `knowledge_update_status(id, status)` | 状态转换(含合法矩阵校验)|
| `knowledge_record_reuse(id)` | reuse_count +1 |
| `knowledge_list_candidates()` | 审核收件箱(按 confidence 排序)|
| `knowledge_archive(id)` | 软删除 |
| `knowledge_get_config()` | 读取 KnowledgeConfig |
| `knowledge_save_config(config)` | 保存 KnowledgeConfig |
| `knowledge_extract_now()` | 手动触发提炼ManualOnly 模式)|
---
## KnowledgeConfig
```rust
pub struct KnowledgeConfig {
pub auto_extract: bool, // 提炼总开关,默认 true
pub trigger_mode: ExtractTrigger, // on_complete | on_idle | manual_only
pub min_messages: u32, // 守卫:最少消息数,默认 4
pub idle_timeout_ms: u64, // 闲置触发超时,默认 30000
pub auto_inject: bool, // 聊天注入开关,默认 true
pub vector_enabled: bool, // 向量检索开关,默认 false
pub embedding_provider_id: Option<String>, // 仅 openai_compat 类型
pub embedding_model: Option<String>,
}
```
存储:`AppState.knowledge_config: Arc<Mutex<KnowledgeConfig>>`(内存,重启恢复默认值)。
---
## 检索与注入
### LIKE 检索(默认)
`search(query, kind, limit=3)``WHERE title LIKE ? OR content LIKE ?``ORDER BY reuse_count DESC`
### 混合检索vector_enabled=true
三层降级链:
1. 开关关 → 纯 LIKE
2. embed 调用失败 → 纯 LIKE
3. 正常 → 双信号排序(同时命中 LIKE+向量 cos≥0.3 > 仅 LIKE > 仅向量 cos≥0.3
嵌入时机:知识**发布时**(不在 candidate 阶段浪费 embed 调用),`spawn_embedding_for_knowledge` fire-and-forget。
### 注入位置
system prompt 头部([知识库上下文] --- [技能指令] --- [原始 system prompt]),仅 auto_inject=true 时生效。
---
## AI 自动提炼流程
1. `run_agentic_loop` 正常退出 → `maybe_spawn_extraction()` 守卫检查
2. 守卫:`auto_extract=true` + `messages.len() >= min_messages`
3. `tauri::async_runtime::spawn` 后台执行,不 await不阻断聊天
4. 取最后 6 条 user/assistant 消息 → 构造 JSON schema prompt → LLM `complete()`
5. `serde_json::from_str<Vec<ExtractedItem>>` 解析失败整批丢弃warn 不报错)
6. 逐条写 `knowledges`status=candidatesource_ref="conv:{id}"
---
## 矛盾知识处理
不做结构层消歧,在内容和 tags 中自述限制范围。检索时两条知识都可能返回,由 LLM 上下文理解取舍。
---
## 外部工具访问(规划)
Tier 1+ 目标MCP Shell 封装(`mcp-server` 转发 IPC外部工具使用逻辑与内部零差异
- 外部写入:走 candidate → 人工审核流程
- 外部读取:`knowledge_search` / `knowledge_list`published
---
## 相关文档
- [df-storage 存储层](./df-storage-存储层.md) — knowledges 表结构 + 向量工具函数
- [df-ai AI 集成模块](./df-ai-AI集成模块.md) — hybrid_search / generate_embedding / extract
- [功能决策记录](../02-架构设计/功能决策记录.md) — 检索方案演进决策

View File

@@ -1,52 +1,140 @@
# df-storage 存储层 # df-storage 存储层
> 创建: 2026-06-10 | 状态: 初稿 > 创建: 2026-06-10 | 最后更新: 2026-06-13
--- ---
## 概述 ## 概述
df-storage 是 DevFlow 的数据持久化层,基于 SQLite (rusqlite)负责连接管理、Schema 迁移和 CRUD 操作。 df-storage 是 DevFlow 的数据持久化层,基于 SQLite (rusqlite)负责连接管理、Schema 迁移V1-V8和 CRUD 操作。全部 Repo 由 `impl_repo!` 宏自动生成。
---
## 当前状态 ## 当前状态
| 功能 | 状态 | | 功能 | 状态 |
|------|------| |------|------|
| SQLite 连接管理 | ✅ 已实现 | | SQLite 连接管理 | ✅ |
| Schema 迁移 (6 张表) | ✅ 已实现 | | Schema 迁移 V1-V8 | ✅ |
| CRUD 操作 | ⬜ 待实施 | | impl_repo! 宏 CRUD | ✅ |
| 事务支持 | ⬜ 待实施 | | KnowledgeRepo含向量| ✅ Sprint 15 |
| 事务支持 | ⬜ 按需 |
## 数据表 ---
Phase 1 已创建的 6 张核心表: ## 数据表V1-V8 迁移历史)
1. **ideas** — 想法池 | 版本 | 新增/变更 |
2. **projects** — 项目 |------|-----------|
3. **tasks** — 任务 | V1 | ideas / projects / tasks / releases / workflow_executions / node_executions6 张基础表 + 4 索引)|
4. **workflow_defs** — 工作流定义 | V2 | ideas 加 promoted_to/ai_analysis/scorestasks 加 workflow_def_id/base_branchworkflow_executions 加 project_id/task_id新建 branches 表(含 2 索引)|
5. **workflow_runs** — 工作流执行 | V3 | ai_providers / ai_conversations / ai_tool_executionsAI 功能 3 张表)|
6. **artifacts** — 产出物 | V4 | 幂等补列ai_conversations.archivedPRAGMA 探测,兼容坏库)|
| V5 | 幂等补列ai_conversations.prompt_tokens / completion_tokens / model / modelsToken 用量)|
| V6 | 幂等补列ai_conversations.skill技能注入|
| V7 | 新建 **knowledges**Sprint 15|
| V8 | 幂等补列knowledges.embedding BLOBPhase 5.5 向量检索)|
完整表结构见 `ARCHITECTURE.md` 数据模型章节。 ### knowledges 表V7
## 依赖关系
```sql
CREATE TABLE IF NOT EXISTS knowledges (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL DEFAULT 'pitfall',
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
tags TEXT, -- JSON array string
status TEXT NOT NULL DEFAULT 'candidate',
confidence TEXT, -- 'high'|'medium'|'low'
reuse_count INTEGER NOT NULL DEFAULT 0,
verified INTEGER NOT NULL DEFAULT 0, -- 0/1
source_project TEXT,
source_ref TEXT,
created_at TEXT NOT NULL, -- 毫秒字符串
updated_at TEXT NOT NULL,
embedding BLOB -- V8 补列f32 little-endian
);
CREATE INDEX IF NOT EXISTS idx_knowledges_status ON knowledges(status);
CREATE INDEX IF NOT EXISTS idx_knowledges_kind ON knowledges(kind);
CREATE INDEX IF NOT EXISTS idx_knowledges_reuse_count ON knowledges(reuse_count DESC);
``` ```
df-core (错误类型、ID 生成)
← df-storage ---
## impl_repo! 宏
自动生成以下方法:`insert` / `get_by_id` / `list_all` / `query` / `update_field` / `update_full` / `delete`
列名白名单ALLOWED_COLUMNS防 SQL 注入,各 Repo 声明各自允许的列。
### 已注册 Repo 列表
| Repo | 表 |
|------|----|
| IdeaRepo | ideas |
| ProjectRepo | projects |
| TaskRepo | tasks |
| ReleaseRepo | releases |
| WorkflowRepo | workflow_executions |
| NodeExecutionRepo | node_executions |
| BranchRepo | branches |
| AiProviderRepo | ai_providers |
| AiConversationRepo | ai_conversations |
| AiToolExecutionRepo | ai_tool_executions |
| **KnowledgeRepo** | **knowledges** |
---
## KnowledgeRepo 自定义方法Sprint 15
| 方法 | 说明 |
|------|------|
| `search(query, kind?, limit)` | LIKE 双分支(有/无 kind 过滤),均含 `WHERE status='published'``ORDER BY reuse_count DESC LIMIT ?`top-N≤3 用于注入 |
| `list_by_status(status)` | CASE WHEN confidence 语义排序High→Medium→Low用于审核收件箱 |
| `increment_reuse_count(id)` | `UPDATE SET reuse_count = reuse_count + 1, updated_at = ?`SQL 原子操作,连带刷 updated_at|
| `top_used(limit)` | published 按 reuse_count DESC热门列表 |
| `set_embedding(id, &[f32])` | UPDATE embedding BLOBf32 little-endian|
| `search_vector(query_vec, limit)` | SELECT published + embedding IS NOT NULL → 纯 Rust 余弦批量比较skip 维度不匹配 |
| `list_non_archived()` | `WHERE status != 'archived'` 全量CASE confidence 语义排序high>medium>low次 created_at DESC → `Vec<KnowledgeRecord>` |
### 向量工具函数
```rust
fn f32s_to_blob(v: &[f32]) -> Vec<u8> // f32 → little-endian bytes
fn blob_to_f32s(b: &[u8]) -> Vec<f32> // bytes → f32chunks_exact(4),尾部非 4 倍数残字节截断丢弃)
fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 // 点积 / (‖a‖‖b‖ + 1e-8 防零除),零向量返回有限值
``` ```
> **不引入 sqlite-vec**:规避 Windows MSVC 下编译 C 扩展的风险。纯 Rust 实现,零外部 C 依赖。
---
## 迁移幂等设计
迁移机制:`schema_version` 表记录当前版本,`MIGRATION_VERSION` 常量为目标版本,`run()``current_version < N` 顺序应用各版本。
### v4 解法PRAGMA 探测列存在性
关键列补建不依赖版本号,用 `PRAGMA table_info(<table>)` 探测实际 schema缺列才 `ALTER TABLE ADD COLUMN`
- 对新库列已由建表带入、老库DDL 正常生效)、坏库(版本号已写入但 DDL 漏生效)三种情况都安全幂等。
- V5/V6/V8 均复用此模式。
---
## 文件结构 ## 文件结构
``` ```
crates/df-storage/src/ crates/df-storage/src/
├── lib.rs — 模块入口,导出公共 API ├── lib.rs — 模块入口,导出公共 API
├── connection.rs — SQLite 连接管理 ├── db.rs — SQLite 连接管理Database struct
├── schema.rs Schema 定义与迁移 ├── migrations.rs — V1-V8 迁移逻辑MIGRATION_VERSION=8
── crud.rs — CRUD 操作 (待创建) ── models.rs — 全部 *Record struct含 KnowledgeRecord
└── crud.rs — impl_repo! 宏 + 全部 Repo含 KnowledgeRepo
``` ```
---
## 相关文档 ## 相关文档
- [SQLite CRUD 模式](../01-技术文档/SQLite-CRUD模式.md) - [SQLite CRUD 模式](../01-技术文档/SQLite-CRUD模式.md)

View File

@@ -15,43 +15,171 @@ df-workflow 是 DevFlow 的核心引擎,负责 DAG 定义、拓扑排序、节
| DAG 数据结构 | ✅ 已实现 | | DAG 数据结构 | ✅ 已实现 |
| 拓扑排序 | ✅ 已实现 | | 拓扑排序 | ✅ 已实现 |
| DagExecutor (顺序执行) | ✅ 已实现 | | DagExecutor (顺序执行) | ✅ 已实现 |
| 同层节点并行执行 | ⬜ 有 TODO 注释 | | 同层节点并行执行 | ✅ 已实现 |
| Node trait 定义 | ✅ 已实现 | | Node trait 定义 | ✅ 已实现 |
| 状态机 (WorkflowRunStatus) | ✅ 已实现 | | 状态机 (WorkflowRunStatus) | ✅ 已实现 |
| EventBus (broadcast) | ✅ 已实现 | | EventBus (broadcast) | ✅ 已实现 |
| 条件表达式引擎 | ⚡ 仅支持 true/false | | 条件表达式引擎 | ⚡ 仅支持 true/false |
| 断点续跑 | ⬜ 待实施 | | 断点续跑 | ⬜ 待实施(引擎整体无暂停/恢复/快照机制,缺乏底层基础设施支撑) |
## 核心设计 ## 核心设计
### Node trait ### Node trait
```rust ```rust
#[async_trait]
pub trait Node: Send + Sync { pub trait Node: Send + Sync {
fn execute(&self, ctx: &NodeContext) -> Result<NodeOutput>; async fn execute(&self, ctx: NodeContext) -> NodeResult;
fn schema(&self) -> NodeSchema; fn schema(&self) -> NodeSchema;
fn is_blocking(&self) -> bool { true } fn is_blocking(&self) -> bool { false }
fn node_type(&self) -> &str;
} }
// NodeResult = anyhow::Result<NodeOutput> 的别名
// ctx 按值传递(非引用),由 executor 在每层构建
``` ```
### NodeContext / NodeOutputnode.rs
执行上下文与输出结构,由 executor 在每层为每个节点构建。
**NodeContext 字段**:
| 字段 | 类型 | 职责 |
|------|------|------|
| `node_id` | `NodeId` | 当前节点 ID |
| `inputs` | `HashMap<String, NodeOutput>` | 上游节点输出,key 为上游节点 ID |
| `config` | `serde_json::Value` | 节点配置参数 |
| `execution_id` | `String` | 工作流执行 ID |
| `event_bus` | `EventBus` | 事件总线(可 Clone,广播状态变更) |
| `node_status` | `StateMachine` | 节点状态机(用于检查取消状态) |
**NodeOutput 字段与构造器**:
| 成员 | 签名 | 说明 |
|------|------|------|
| `data` | `serde_json::Value` | 输出数据 |
| `metadata` | `HashMap<String, String>` | 输出元数据 |
| `empty()` | `() -> Self` | 空输出(`data = Null`,空 metadata) |
| `from_value(data)` | `(Value) -> Self` | 从 JSON 值构造(空 metadata) |
> `NodeOutput` derive `Debug/Clone/Serialize/Deserialize`;`NodeContext` 仅 `Debug/Clone`(`StateMachine` 非 Serialize)。
### 节点注册机制NodeRegistry
节点工厂注册表,据 `DagDef.node_type` 字符串创建 `Box<dyn Node>` 实例,桥接可序列化定义与运行时 trait object。
| 方法 | 签名 | 职责 |
|------|------|------|
| `new` | `() -> Self` | 创建空注册表 |
| `register` | `(&mut self, type_name: &str, factory: F)` | 注册一个节点工厂(`F: Fn(&Value) -> Box<dyn Node>` |
| `create` | `(&self, type_name: &str, config: &Value) -> Result<Box<dyn Node>>` | 按类型名 + 配置创建节点实例,未注册则报错 |
| `build_dag` | `(&self, def: &DagDef) -> Result<Dag>` | 从 `DagDef` 构建完整运行时 `Dag`(建节点 + 加边,自动分发条件边) |
| `is_registered` | `(&self, type_name: &str) -> bool` | 检查类型是否已注册 |
| `registered_types` | `(&self) -> Vec<&str>` | 列出所有已注册类型名 |
> `Default` 实现仅注册占位 `script` 工厂(`unimplemented!`),实际 `ScriptNode` 由 `df-nodes` crate 注册。
### DAG 执行流程 ### DAG 执行流程
``` ```
1. 接收 WorkflowDef (DAG 定义) 1. 接收 WorkflowDef (DAG 定义)
2. 拓扑排序 → 得到执行层 (layers) 2. 拓扑排序 → 得到执行层 (layers)
3. 逐层执行: 3. 逐层执行:
- 同层节点并行 (TODO) - 同层节点并行`futures::future::join_all`,已实现)
- 阻塞节点等待人工操作 - 阻塞/非阻塞节点当前同等异步执行(`is_blocking` 未被 executor 消费,人工等待逻辑规划中、当前未实现)
- 非阻塞节点异步完成
4. 状态变更通过 EventBus 广播 4. 状态变更通过 EventBus 广播
5. 每个节点完成后持久化快照 5. 节点完成后仅更新内存态(`StateMachine` + `outputs` HashMap无持久化快照规划中
``` ```
### DAG 定义序列化dag_def.rs
区分两层表示:运行时 `Dag``Box<dyn Node>`trait object不可序列化可持久化的 `DagDef` / `NodeDef` / `EdgeDef``#[derive(Serialize, Deserialize)]`,用于模板与存盘。`NodeRegistry::build_dag` 负责从 `DagDef` 还原运行时 `Dag`
| 方法 | 签名 | 职责 |
|------|------|------|
| `DagDef::new` | `() -> Self` | 空定义 |
| `DagDef::add_node` | `(&mut self, id, node_type, config: Value)` | 加节点定义label 默认 None |
| `DagDef::add_edge` | `(&mut self, source, target)` | 加普通边condition=None |
| `add_edge_with_condition` | `(&mut self, source, target, condition)` | 加带条件表达式的边 |
| `from_dag_edges` | `(&dag: &Dag) -> Self` | 从运行时 `Dag` 反推定义;**注意**只能还原边的 condition 与节点的 `node_type``config` 一律填 `Value::Null`(无法从 trait object 反推) |
### 事件类型 ### 事件类型
- `WorkflowStarted` / `WorkflowCompleted` / `WorkflowFailed` 事件枚举定义在 `df-core::events::WorkflowEvent``DagExecutor` 通过 `EventBus::send` 广播。
**执行器实际广播的executor.rs**
- `NodeStarted` / `NodeCompleted` / `NodeFailed` - `NodeStarted` / `NodeCompleted` / `NodeFailed`
- `WorkflowPaused` / `WorkflowResumed` - `WorkflowCompleted`
**枚举已定义但执行器当前未触发**`df-core::events` 中存在DagExecutor 不发):
- `NodeProgress`(节点进度)
- `NodeOutput`(节点输出流)
- `WorkflowPaused`(暂停等待外部输入)
- `WorkflowFailed`(工作流失败,带 failed_node
- `HumanApprovalRequest` / `HumanApprovalResponse`(人工审批)
> 注:枚举中无 `WorkflowStarted` / `WorkflowResumed`,旧文档所述为误。
### EventBuseventbus.rs
基于 `tokio::sync::broadcast` 的发布/订阅,内部持 `broadcast::Sender<WorkflowEvent>`
| 方法/成员 | 签名 | 职责 |
|------|------|------|
| `DEFAULT_CAPACITY` | `const usize = 256` | 默认通道容量 |
| `new` | `() -> Self` | 用默认容量建总线 |
| `with_capacity` | `(usize) -> Self` | 指定容量建总线 |
| `send` | `(&self, WorkflowEvent) -> ()` | 广播事件(忽略接收者已关闭错误,异步) |
| `subscribe` | `(&self) -> broadcast::Receiver<WorkflowEvent>` | 订阅事件流 |
| `emit_human_approval_request` | `(&self, WorkflowEvent) -> Result<usize, SendError>` | 发送人工审批请求(返回接收者计数) |
| `try_recv_human_approval` | `(&self, execution_id, node_id) -> Option<HumanApprovalResponse>` | **TODO 占位**:当前恒返回 `None`,审批响应存储/检索未实现,需配合前端 |
| `Default` / `Clone` | — | `Default``new`;`Clone` 复刻 `sender`(broadcast sender 可 clone,共享通道) |
> `emit_human_approval_request` 与 `try_recv_human_approval` 为人工审批占位接口,后者**未实现**,执行器当前不消费审批响应(对应 `is_blocking` 等待逻辑亦未落地)。
### 状态机state.rs
`StateMachine` 维护 `HashMap<NodeId, NodeStatus>`,校验节点状态转换,非法转换返回错误。
**合法转换链**`is_legal``Pending → Running``Running → Completed``Running → Failed`。其余均拒绝。
| 方法 | 签名 | 职责 |
|------|------|------|
| `new` / `get` | `... -> Self` / `(&NodeId) -> NodeStatus` | 创建;取状态(缺失默认 `Pending` |
| `set_running` | `(&mut self, NodeId) -> Result<()>` | `Pending → Running` |
| `set_completed` | `(&mut self, NodeId) -> Result<()>` | `Running → Completed` |
| `set_failed` | `(&mut self, NodeId) -> Result<()>` | `Running → Failed` |
| `set_waiting` | `(&mut self, NodeId)` | 设为 `Waiting`**绕过转换校验**(直接 set |
| `set_skipped` | `(&mut self, NodeId)` | 设为 `Skipped`**绕过转换校验** |
| `is_cancelled` | `(&NodeId) -> bool` | 是否为 `Cancelled`(同样无对应 setter外部直接 set |
| `snapshot` | `() -> &HashMap<NodeId, NodeStatus>` | 全量状态快照引用 |
> `Waiting` / `Skipped` / `Cancelled` 三态暂未纳入 `is_legal` 校验链,对应的 `set_*` 直接 `insert`,可从任意态跳转。
### Dag 对外 APIdag.rs
| 方法 | 签名 | 职责 |
|------|------|------|
| `new` | `() -> Self` | 空 DAG |
| `add_node` | `(&mut self, id: NodeId, node: Box<dyn Node>)` | 加节点 |
| `add_edge` | `(&mut self, source, target)` | 加普通边condition=None |
| `add_edge_with_condition` | `(&mut self, source, target, condition: String)` | 加带条件边 |
| `predecessors` | `(&NodeId) -> Vec<NodeId>` | 上游节点 ID |
| `successors` | `(&NodeId) -> Vec<NodeId>` | 下游节点 ID |
| `topological_layers` | `() -> Result<Vec<Vec<NodeId>>>` | BFS 分层拓扑排序,同层可并行;**检测到环时报 `Workflow` 错误**"DAG 中存在环" |
`Edge { source, target, condition: Option<String> }` — condition 为可选条件表达式,由 `conditions.rs` 求值。
### 条件表达式引擎conditions.rs
`ConditionEngine::evaluate(expr: &str, context: &Value) -> Result<bool>` — 仅支持 `"true"` / `"false"` 字面量(区分大小写,先 `trim()` 去空白)。
**默认放行(安全风险)**:空串、`"True"`/`"FALSE"` 等大小写不匹配字面量、以及任意非 `true`/`false` 字面量(如 `"yes"``"1"``"$.status == 'completed'"`)均回退为 `Ok(true)``tracing::warn!`。即**条件不匹配时放行而非阻断**,DAG 边全通 —— 无法据上游输出做条件分支。
> 现状:JSON Path、比较运算、`contains`、逻辑组合均未实现(`context` 参数当前未被使用,仅占位对齐签名)。详见 [B 路线决策接入需求](#🔮-决策能力接入需求b-路线)。
## 依赖关系 ## 依赖关系
@@ -66,15 +194,33 @@ df-core (类型、事件、错误)
``` ```
crates/df-workflow/src/ crates/df-workflow/src/
├── lib.rs — 模块入口 ├── lib.rs — 模块入口
├── dag.rs — DAG 数据结构与拓扑排序 ├── dag.rs — DAG 数据结构与拓扑排序
├── executor.rs — DagExecutor ├── dag_def.rs — 可序列化 DAG 定义DagDef/NodeDef/EdgeDef
├── node.rs Node trait + NodeContext/Output ├── executor.rs — DagExecutor
├── state.rs — 状态机 ├── node.rs — Node trait + NodeContext/Output
├── event.rs — EventBus ├── state.rs 状态机
── condition.rs — 条件表达式引擎 ── eventbus.rs EventBusbroadcast
├── registry.rs — NodeRegistry节点类型注册表
└── conditions.rs — 条件表达式引擎
``` ```
## 🔮 决策能力接入需求B 路线)
> 2026-06-12 记录。executor 分层并行已具骨架conditions 为最大空壳。
AI ChatB 路线)从单链 ReAct 升级为规划式协作时,本引擎需补两处:
1. **`condition.rs::ConditionEngine::evaluate()`** —— 当前只认 `"true"` / `"false"` 字面量,默认 `Ok(true)`DAG 边全通,无法据 AI 输出做条件分支。需补:
- JSON Path 取值(从上游节点输出读字段)
- 比较运算(`==` `!=` `>` `<` `>=` `<=`
- `contains` / 字符串匹配
- `and` / `or` / `not` 组合
2. **executor 分层并行接入 agentic loop** —— `topological_layers` + `join_all` 已实现(测试 `test_same_layer_runs_in_parallel`),但仅喂静态 DAG。需让 `run_agentic_loop` 内动态生成的 DAG 走这条并行通道,而非单轮串行 `process_tool_calls`
详见 [Phase 2 计划 - 决策能力升级](../07-项目管理/Phase2计划.md)。
## 相关文档 ## 相关文档
- [df-nodes 节点集合](./df-nodes-节点集合.md) - [df-nodes 节点集合](./df-nodes-节点集合.md)

View File

@@ -57,6 +57,69 @@
2. **数据导出**:备份和分享 2. **数据导出**:备份和分享
3. **插件系统**:扩展功能 3. **插件系统**:扩展功能
## 🗂️ 知识库Tier 1 已完成Sprint 15
> 2026-06-13 完成。
**已落地**
- candidate→published→archived 状态机 + 人工门控AI 只产 candidate
- AI 自动提炼(对话完成后 spawnmin_messages≥4 守卫)
- LIKE 关键词检索 + system prompt 注入auto_inject 开关)
- 手动录入Knowledge.vue+ 审核收件箱(按 confidence 排序)
- Phase 5.5:向量 embedding + 混合检索Settings 开关默认关openai_compat embed
- 11 个 IPC Command设计对齐 MCP 语义
**Tier 2 待做**
- ai_node prompt 注入(节点级知识上下文)
- 工作流 NodeFailed→pitfall 自动沉淀
- 决策记录→architecture_pattern前置 traceability 持久化)
- MCP Shell 封装(外部工具 Claude Code/CodeX/Cursor 访问)
详见 [知识库模块文档](../03-模块文档/df-knowledge-知识库.md)。
---
## 🧠 决策能力升级B 路线)
> 2026-06-12 记录。单独立项A 路线UX 快赢)完成并验证后启动。
**现状**AI Chat 为单链 ReAct —— `run_agentic_loop``src-tauri/src/commands/ai.rs`)最多 10 轮串行执行工具调用无规划、无协作、无条件分支、无自纠。仅风险门控Low 自动 / Medium+High 审批)做得较完整。
**目标链路**:用户意图 → LLM 规划子任务 → coordinator 拆 DAG → executor 分层并行执行 → conditions 按 AI 输出做条件路由 → reflection 自纠。
**需求点**
1. **Planning任务规划**:意图 → LLM 先规划子任务 DAG再执行
2. **Coordinator多 agent 协作)**:填 `crates/df-ai/src/coordinator.rs::AgentCoordinator::run()`(当前全 TODO返回硬编码 `"TODO: Agent 协作结果"`
3. **Conditions条件分支**:填 `crates/df-workflow/src/conditions.rs::ConditionEngine::evaluate()`(当前只认 `true`/`false` 字面量,缺 JSON Path / 比较 / contains / and-or-not默认 `Ok(true)` 致 DAG 边全通)
4. **分层并行接入**`executor.rs` 已有 `topological_layers` + `join_all`(测试 `test_same_layer_runs_in_parallel`),但仅喂静态 DAGagentic loop 未接入
5. **Reflection自纠**:缺失,需执行后自检 / 重试机制
**启动前置**:重读 coordinator.rs / conditions.rs 确认仍为空壳(期间可能有变动)。
## 🧩 aichat 后续需求点(路线总览)
> 2026-06-12 记录。汇总 aichat「决策能力 / 技能联想」两类后续需求,与上方 B 路线互补,便于排期。
### A/B 路线拆分
- **A 线 — UX 快赢(进行中/部分已完成)**:对话管理(时间分组 + 归档折叠 + 重启恢复对话/窗口)、技能指令注入等体验改进,低成本快速落地。
- **B 线 — 决策能力补强(单独立项)**:当前 AI 会话是单链 ReActcoordinator / conditions 等为空壳,无规划式智能。需补多步规划、条件分支、任务分解等决策能力(详见上方「🧠 决策能力升级B 路线)」)。
### 决策能力现状(待 B 线解决)
- **单链 ReAct**LLM 流式生成 → 工具调用 → 结果回传 → 循环(最多 `MAX_AGENT_ITERATIONS=10` 轮)。
- **缺**:无前置规划、无条件编排、无多路径裁决。能力天花板受限于单轮 tool-use 循环。
- **佐证**`run_agentic_loop``src-tauri/src/commands/ai.rs`)串行执行工具,`coordinator.rs::run()` 返回硬编码 TODO`conditions.rs::evaluate()` 默认 `Ok(true)` 致 DAG 边全通。
### 技能 / 联想需求
- **目标**:输入 `/` 时联想本机 Claude 技能skills / commands / plugins 三类)并选用。
- **现状**
- 后端:扫描三类来源(`~/.claude/skills/*/SKILL.md` + `~/.claude/commands/*.md` + `~/.claude/plugins/marketplaces/**/skills/*/SKILL.md`+ SKILL.md 全文注入 system prompt 已实现(`ai_list_skills` / `read_skill_content`)。
- 前端:`/` 联想浮层 UI 已初步,待完善匹配排序与参数提示。
- **定位**:与 B 路线(决策能力)独立,属技能注入体系;可作为独立小需求排期,工作量中(后端扫文件 + IPC、前端联想浮层
- **后续可扩展**Codex`~/.codex/vendor_imports/skills`frontmatter 与 Claude 一致可统一解析纳入openclaw 属 agent 选择层、不纳入。
## 📊 关键指标 ## 📊 关键指标
### 使用指标 ### 使用指标

View File

@@ -1,7 +1,7 @@
# DevFlow 文档索引 # DevFlow 文档索引
> 创建: 2026-06-10 | 当前阶段: Phase 2 本地优先开发流程验证 > 创建: 2026-06-10 | 当前阶段: Phase 2 本地优先开发流程验证
> 更新: 2026-06-11 | 清理 60% 功能,聚焦核心链路 > 更新: 2026-06-13 | 新增规格契约自检机制(活契约 + AI 自检)
--- ---
@@ -21,13 +21,20 @@ docs/
│ ├── 业务系统设计.md # 业务系统设计 │ ├── 业务系统设计.md # 业务系统设计
│ ├── 前后端类型对齐.md # Rust/TS 类型对齐规范 │ ├── 前后端类型对齐.md # Rust/TS 类型对齐规范
│ ├── 对抗论证裁决报告.md # 60% 功能清理决策 │ ├── 对抗论证裁决报告.md # 60% 功能清理决策
── 产品定位调整.md # 新定位:本地优先个人开发流程驾驶舱 ── 产品定位调整.md # 新定位:本地优先个人开发流程驾驶舱
│ ├── 功能决策记录.md # 需求规格 + 设计决策规格(为什么这么定 + 要做什么)
│ ├── 经验记录.md # 踩坑/约定/技巧/bug 排查教训
│ ├── 功能决策记录-归档.md # 归档只读(纯流水/老 Sprint/UX 微调/已被取代)
│ ├── 文档记录规范.md # 写文档路由:记哪/优先级/去重SSOT
│ ├── 规格契约自检机制.md # 活契约 + AI 自检 + 子代理验证agent/skill/hook 基准)
│ └── B-03-人工审批响应机制.md # HumanNode 审批响应subscribe→send→select! 广播过滤等待
├── 03-模块文档/ # 各功能模块实现文档 ├── 03-模块文档/ # 各功能模块实现文档
│ ├── df-storage-存储层.md # 存储层概览 │ ├── df-storage-存储层.md # 存储层概览
│ ├── df-workflow-工作流引擎.md # 工作流引擎概览 │ ├── df-workflow-工作流引擎.md # 工作流引擎概览
│ ├── df-nodes-节点集合.md # 8 种节点概览 │ ├── df-nodes-节点集合.md # 8 种节点概览
│ ├── df-ai-AI集成模块.md # AI Provider 集成 │ ├── df-ai-AI集成模块.md # AI Provider 集成OpenAI/Anthropic/embed/ContextManager
── 想法探索-对抗式评估.md # 想法池对抗评估设计 ── 想法探索-对抗式评估.md # 想法池对抗评估设计
│ └── df-knowledge-知识库.md # 知识库 Tier 1候选→发布状态机 + 检索注入 + 向量)
├── 04-功能迭代/ # 功能开发过程记录 ├── 04-功能迭代/ # 功能开发过程记录
│ ├── DEVFLOW-1.CRUD层实施.md # CRUD 层实施记录 │ ├── DEVFLOW-1.CRUD层实施.md # CRUD 层实施记录
│ ├── DEVFLOW-2.IPC桥接实施.md # IPC 桥接实施记录 │ ├── DEVFLOW-2.IPC桥接实施.md # IPC 桥接实施记录
@@ -42,8 +49,7 @@ docs/
│ └── 组件设计规范.md # Vue 3 组件设计规范 │ └── 组件设计规范.md # Vue 3 组件设计规范
├── 07-项目管理/ # 项目状态、功能清单、版本管理 ├── 07-项目管理/ # 项目状态、功能清单、版本管理
│ ├── Phase1任务清单.md # Phase 1 任务清单 │ ├── Phase1任务清单.md # Phase 1 任务清单
── Phase2计划.md # Phase 2 开发计划 ── Phase2计划.md # Phase 2 开发计划
│ └── PROGRESS.md # 项目进展与交接记录
└── 08-用户指南/ # 用户手册、配置指南 └── 08-用户指南/ # 用户手册、配置指南
├── 快速上手.md # 5分钟快速上手 ├── 快速上手.md # 5分钟快速上手
├── 配置指南.md # AI 配置、Git 集成 ├── 配置指南.md # AI 配置、Git 集成

94
docs/todo.md Normal file
View File

@@ -0,0 +1,94 @@
# DevFlow 工作看板
> 来源:`docs/02-架构设计/功能决策记录.md`「需求与待办」+ `PROGRESS.md` 各 Sprint 遗留2026-06-14 汇总去重 + 代码核对修正。
> 互操作:执行走 mission-control回写 mission_id审查走 cr发布走 publish-*。
> 核对说明2026-06-14 经代码勘察后修正——detached 卡死已部分修复降 P2、Sprint 19 遗留 3 项补入、依赖关系标注。
---
## 交接状态2026-06-14
**代码健康度**`cargo test --workspace` 全过、`npx vue-tsc --noEmit` 0 error主代理独立验证非 mission 自报)。
**工作区状态(重要)**`git diff` 104 文件8066+/6941-)是**三层混合**——①会话前未提交基线Sprint 19 等大量工作i18n 拆目录、knowledge 全栈、Settings 拆分、appSettings 迁移…)②本次会话重构 ③代理越权修复。**接手前务必 `git diff` 通览区分**,勿整体当作单一改动提交。
**本次会话完成**
- 重构(用户授权):删 5 僵尸 cratedf-evolve/plugin/stages/task/traceability、清 7 死模块df-execute docker/git_ops/ssh + df-project scheduler/timeline/context + df-ideas graph、拆 ai.rs→`commands/ai/` 11 文件、拆 ai.ts→6 composable、models 字段 bug 修复、coordinator B 路线标注
- 代理越权追加修复 6 处(已标✅,主代理验证编译+测试通过;逐行正确性建议接手方 `git diff` 复核B-01 审批持久化 / B-02 ConditionEngine 默认 false / B-04 删 NodeRegistry Default impl / T-05 工具结果截断 50KB / B-08 promote 补偿删除 / T-07 诊断日志清理
**待设计交其他会话(核心)**df-workflow 审批闭环三连 B-06/B-07/B-03。**✅ B-03 设计已完成**接手会话2026-06-14见 [B-03-人工审批响应机制.md](./02-架构设计/B-03-人工审批响应机制.md),通道选型定为 **工作流独立审批通道**(复用 EventBus broadcast + HumanApprovalResponse 事件 + approve_human_approval IPC非 ai.rs AiApprovalRequired——后者是 AI Chat 工具审批路径,与工作流节点审批是两条独立链路)。拆 B-03a响应等待 + 超时,不依赖 B-07/ B-03b取消机制。**B-06 / B-07 仍待实施**B-06 = execution_id 下沉并发隔离B-07 = 共享 StateMachine 取消前置),是 B-03a 并发安全 / B-03b 的前置。
**失控代理教训**:本次会话派的后台拆分代理在 stop hook 循环里失控,越权改代码/文档(先斩后奏)。接手方若再派 agent注意约束其不碰决策记录用户已要求手动触发+ 限定单任务不自主续推。
---
## 待办
### P0 — 阻断性 bug
- [x] B-260614-01 — ~~待审批持久化根治(重启恢复)未生效~~ ✅ mission:T-260614-01 已修复commands.rs:444 clear→retain 保其他对话 pendingai_approve 两处 if !recovered 守卫移除cargo check 0 err / 19 test pass(06-14)
- [x] B-260614-02 — ~~df-workflow ConditionEngine 默认 true~~ ✅ mission:T-260614-02 已修复conditions.rs:31 `Ok(true)``Ok(false)` 保守拒绝5 个原断言错误行为的测试同步改断言df-workflow 7 test pass(06-14)
- [x] B-260614-04 — ~~df-workflow NodeRegistry::default() script 工厂 unimplemented!~~ ✅ mission:T-260614-03 已修复(删除整个 Default impl——零调用方 + 违反铁律state.rs build_registry 已用 new() + 手动注册真实 ScriptNode(06-14)
### P0 — 阻断性 bugdf-workflow 审批闭环依赖链B-06/B-07 → B-03
- [ ] B-260614-06 — **[P0→前置]** df-workflow DagExecutor execution_id 硬编码 "dummy-execution-id" — `executor.rs:76` 所有执行 ID 相同,追踪/审计失效。改为从外部传入run_workflow IPC 已生成真 execution_id 但未下沉到 executor — source:代码审查 (06-14)
- [ ] B-260614-07 — **[P0→前置]** df-workflow executor 每节点拿全新空 StateMachine — `executor.rs:78` 每节点 `StateMachine::new()`self.state_machine 从不传入 NodeContextHumanNode is_cancelled 恒 false。改为共享 self.state_machine — source:多代理探索 (06-14)
- [ ] B-260614-03 — **[P0→依赖 B-06/B-07]** df-workflow HumanNode 假实现 — `human_node.rs:55` 注释"等待审批"但首次迭代直接 return "同意",与 ai.rs 严谨审批链路矛盾。**📐 设计完成 [B-03-人工审批响应机制.md](./02-架构设计/B-03-人工审批响应机制.md)**subscribe→send→select! 广播过滤execution_id+node_id 双键;拆 B-03a 响应等待/超时 + B-03b 取消机制;通道选型 = **工作流独立审批通道**复用 EventBus / HumanApprovalResponse 事件 / approve_human_approval IPC非 ai.rs AiApprovalRequired。核对修正原注B-06 = 并发隔离前置非单流功能前置B-07 = 取消必要非充分,**B-03a 不依赖 B-07** — source:多代理探索 + 设计 (06-14)
### P1 — 重要缺陷
- [x] B-260614-08 — ~~promote_idea 两步写非事务~~ ✅ mission:T-260614-05 已修复idea.rs 第二步 update_full 失败时补偿删除已建 projectRepository 不支持跨 repo 共享事务对象,选补偿删除非真事务,改动最小;附 logging(06-14)
- [ ] T-260614-01 — **[P1]** Sprint 9/10/14/15/16/18 多项未 tauri dev 实测 — 评分 IPC 缩放 / update_full / promote_idea / Store getter / token 落库 / 知识库 Tier 1 全栈 / LLM 并发 Semaphore / 知识生命线(#54 跟踪)— source:Sprint 9-18 (06-14)
- [ ] T-260614-02 — **[P1]** 切对话不中断路由部分场景运行时实测A 路线场景 2/3 — source:Sprint 8 (06-14)
### P1 — 设计完成待实施
- [ ] F-260614-01 — **[P1]** 模型能力系统 Phase 1 — ModelCapability 数据模型 + ModelRouter 重写 + 7 调用点接入 + Settings 模型池编辑 UI + AiChat 模型下拉。按任务需求(模态/功能/成本)自动匹配合适模型,不再所有场景共用 default_model — source:📐 设计完成 (06-14)
### P1 — Sprint 19 遗留
- [x] T-260614-05 — ~~工具结果入库前截断 50KB~~ ✅ mission:T-260614-04 已修复conversation.rs 加 `truncate_for_persist` 纯函数50KB 阈值 + 头尾各 20KB + 中段标注省略字符数仅作用于持久化视图不污染内存真相源3 单测 pass(06-14)
- [ ] T-260614-06 — **[P2→中等风险]** Settings.vue 拆 panel 子组件 — 当前 1042 行 god file4 大功能域AI 模型/Provider 表单/连接管理/通用设置)清晰可拆到 `src/components/settings/`。**评估:非低风险**,纯重构零功能价值,要新建 4 子组件 + props/emits 接线 + CSS 拆分,单独立项做更稳 — source:Sprint 19 待评估 (06-14)
- [x] T-260614-07 — ~~诊断日志清理~~ ✅ mission:T-260614-06 已清理useAiEvents/useAiSend 3 处调试 console.log 直接删AiChat.vue/main.ts 3 处启动计时改 console.debug 保留诊断能力但不污染 consolevue-tsc 0 errsrc/ console.log 0 残留)(06-14)
### 待澄清 / A-B 待定
- [ ] S-260614-01 — 「显示多开」需求待澄清 — 用户报"设置勾选显示多开但 AiChat 未显示",全 src grep 零命中,疑似旧版本/指分离窗口/想新增开关,待用户截图确认 (06-14)
- [ ] S-260614-02 — 审批可见性 A/B 待定 — B-01 修复后 pending_approvals 内存态不再被 switch 清空,前端 `ai_pending_tool_calls` 查询有数据,但 `state.pendingApprovals` 在 AiChat.vue 是否有兜底渲染仍需实测确认。A. 加兜底渲染 / B. 实测 tc 卡片是否渲染 (06-14)
### P2 — 不阻断缺陷 / 增强
- [ ] B-260614-05 — **[P2→降级]** 分离窗口detached跨窗口状态 — **核对修正**reattachPanel 已接线(不再死代码)+ `tauri://destroyed` 监听已复位状态,"detached 永真卡死"已修复;剩余 localStorage `df-ai-gen`/`df-ai-text` 是 Sprint 19 **有意保留**(流式临时快照高频写),非 bug。仅在出现新场景失效时再评估改全局 emit/listen — source:代码审查 + Sprint 19 (06-14)
- [ ] T-260614-03 — 工具层白名单与 crud 白名单双份去重(架构债) — source:经验记录 (06-14)
- [ ] F-260614-02 — 技能联想「使用」 — 首批 3 类联想已做,联想后实际触发/执行技能未实现 (06-14)
- [ ] F-260614-03 — **[需设计]** 灵感对抗评估接 LLM — 核对 adversarial.rs`evaluate()` 纯启发式,接 LLM 需改签名注入 provider。3 种注入方式待定A.trait 解耦df-ideas 定义 IdeaAnalyzer trait推荐/ B.df-ideas 直接依赖 df-ai / C.closure。属架构决策非直接做 (06-14)
- [x] T-260614-04 — ~~路径校验根治~~ ✅ 已完成resolve_workspace_path 加 canonicalize 防 symlink 逃逸 + 词法 starts_with 兜底仅校验、返回词法路径保持前端友好cargo check 0 err / 22 test pass(06-14)
- [ ] F-260614-04 — 多 Provider 负载均衡池 — 备用模型/多账号聚合,全局容量=min(各 provider 上限之和, global_cap) (06-14)
- [ ] F-260614-05 — 模型能力系统 Phase 2 — 多模态消息支持ChatMessage.content: String → Vec<ContentPart>(Text/Image);前端粘贴/拖拽图片vision 模型自动路由 (06-14)
- [ ] F-260614-06 — 导入历史项目scan 第二步) — 复用 scan/relocate/checkBinding加 monorepo 子目录识别 + README 首段抽 description + 批量 (06-14)
## 已完成
### 2026-06-14
- [x] R-260614-01 ai.rs 拆 11 子 modulecommands/ai/+ glob 重导出保路径 + models bug 修复 — cargo check 0 error / 19 test passed
- [x] B-260614-01 待审批持久化根治 — mission:T-260614-01
- [x] B-260614-02 df-workflow ConditionEngine 默认 true→false — mission:T-260614-02
- [x] B-260614-04 NodeRegistry unimplemented!→删 Default impl — mission:T-260614-03
- [x] T-260614-05 工具结果入库前截断 50KB含 3 单测)— mission:T-260614-04
- [x] B-260614-08 promote_idea 补偿删除保最终一致性 — mission:T-260614-05
- [x] T-260614-07 诊断日志清理3 删 + 3 改 debug— mission:T-260614-06
- [x] D-260614-01 B-03 人工审批响应机制**设计**完成 — 新建 [B-03-人工审批响应机制.md](./02-架构设计/B-03-人工审批响应机制.md)9 节完整设计)+ 功能决策记录摘要章节 + PROGRESS/todo/INDEX 同步;核心结论:链路基础设施已通仅缺 HumanNode 一处、通道选型=工作流独立审批通道(非 ai.rs AiApprovalRequired、拆 B-03a响应等待+超时,不依赖 B-07/B-03b取消机制**实施待 B-06/B-07 前置**
## Bug
P0/P1 bug 见上方「待办」分类,此处不重复)
## 长期 / 待需求驱动(不进看板主线)
- 裁剪/压缩消息按需召回Query Function + 分层存储)
- 停止生成 idle 即时优化(`tokio::sync::Notify` 替代 120s 轮询)
- 模型能力系统 Phase 3Agent 内智能路由 + 成本预算 + 模型级联)
- `node_executions` 全表 list 命令(当前只写不读)
- `do_promote` crate 层 TODOpromotion.rs现走前端闭环

1993
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,8 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"dev:stop": "taskkill /f /im vite.exe",
"dev:restart": "npm run dev:stop && npm run dev",
"build": "vue-tsc --noEmit && vite build", "build": "vue-tsc --noEmit && vite build",
"preview": "vite preview", "preview": "vite preview",
"tauri": "tauri" "tauri": "tauri"
@@ -12,6 +14,7 @@
"dependencies": { "dependencies": {
"@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",
"dompurify": "^3.4.9", "dompurify": "^3.4.9",
"marked": "^18.0.5", "marked": "^18.0.5",
"vue": "^3.5.13", "vue": "^3.5.13",

View File

@@ -14,12 +14,15 @@ tauri-build = { version = "2", features = [] }
[dependencies] [dependencies]
tauri = { version = "2", features = [] } tauri = { version = "2", features = [] }
tauri-plugin-dialog = "2"
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
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
anyhow.workspace = true anyhow.workspace = true
tracing.workspace = true tracing.workspace = true
chrono.workspace = true
# 后端 crate # 后端 crate
df-core = { path = "../crates/df-core" } df-core = { path = "../crates/df-core" }
@@ -28,4 +31,6 @@ df-workflow = { path = "../crates/df-workflow" }
df-nodes = { path = "../crates/df-nodes" } df-nodes = { path = "../crates/df-nodes" }
df-execute = { path = "../crates/df-execute" } df-execute = { path = "../crates/df-execute" }
df-ai = { path = "../crates/df-ai" } df-ai = { path = "../crates/df-ai" }
df-ideas = { path = "../crates/df-ideas" }
df-project = { path = "../crates/df-project" }
futures = "0.3" futures = "0.3"

View File

@@ -15,6 +15,8 @@
"core:window:allow-set-size", "core:window:allow-set-size",
"core:window:allow-outer-position", "core:window:allow-outer-position",
"core:window:allow-inner-size", "core:window:allow-inner-size",
"core:webview:allow-create-webview-window" "core:webview:allow-create-webview-window",
"dialog:default",
"window-state:default"
] ]
} }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,271 @@
//! Agentic 循环 — 流式接收 → 工具执行 → 结果回传 LLM → 循环
use std::sync::Arc;
use std::sync::atomic::Ordering;
use tauri::{AppHandle, Emitter};
use tokio::sync::Mutex;
use df_ai::ai_tools::AiToolRegistry;
use df_ai::context::TokenEstimator;
use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider};
use df_storage::db::Database;
use df_storage::models::AiProviderRecord;
use crate::state::{AppState, LlmConcurrency};
use super::conversation::{save_conversation, TokenAccumulator};
use super::knowledge_inject::maybe_spawn_extraction;
use super::prompt::{build_system_prompt, get_active_provider};
use super::stream_recv::stream_llm;
use super::title::{ensure_conversation_title, spawn_ensure_title};
use super::audit::process_tool_calls;
use super::{AiChatEvent, AiSession};
/// Agentic 循环最大迭代次数
pub(crate) const MAX_AGENT_ITERATIONS: usize = 10;
/// Agentic 循环:流式接收 → 工具执行 → 结果回传 LLM → 循环
///
/// 退出条件:
/// - LLM 只返回文本(无 tool_calls→ 正常结束
/// - 有工具需要审批 → 暂停循环generating 保持 true等 ai_approve 恢复
/// - 达到最大迭代次数 → 正常结束
pub(crate) async fn run_agentic_loop(
session_arc: Arc<Mutex<AiSession>>,
tools_arc: Arc<AiToolRegistry>,
db: Arc<Database>,
app_handle: AppHandle,
provider_config: AiProviderRecord,
system_prompt: String,
conv_id: String,
knowledge_config: crate::state::KnowledgeConfig,
llm_concurrency: LlmConcurrency,
) {
let provider: Box<dyn LlmProvider> = df_ai::build_provider(
&provider_config.provider_type,
&provider_config.base_url,
&provider_config.api_key,
&provider_config.default_model,
);
let tool_defs = tools_arc.tool_definitions();
// 停止信号副本stream_llm 与每轮迭代共享读取,避免重复加锁
let stop_flag = session_arc.lock().await.stop_flag.clone();
// token 累加器:loop 生命周期内各轮叠加,退出时传 save_conversation(累加模式落库)
let mut tokens = TokenAccumulator::default();
for iteration in 0..MAX_AGENT_ITERATIONS {
// 用户请求停止 → 收尾退出(已生成文本已在上一轮入库)
if stop_flag.load(Ordering::SeqCst) {
let usage = df_ai::provider::TokenUsage {
prompt_tokens: tokens.prompt(),
completion_tokens: tokens.completion(),
total_tokens: tokens.total(),
};
// 入口 stop:本轮可能尚未 stream(首轮即停),不记 model——避免把未实际生成的 model 写入 models 数组
save_conversation(&session_arc, &db, &conv_id, Some(&usage), None).await;
// 标题生成后台化:不阻塞 Completed emit失败有 extract_title 兜底)
spawn_ensure_title(&provider_config, &db, &conv_id, &app_handle, &session_arc, &llm_concurrency);
let mut session = session_arc.lock().await;
session.generating = false;
// generating 复位后再 emit Completed保证前端收事件时后端已可接下一条(发送队列续发不被"正在生成中"拒绝)
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage.total_tokens, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), conversation_id: Some(conv_id.clone()) });
return;
}
// 新一轮通知前端(第二轮起),前端需新建 assistant 消息
if iteration > 0 {
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiAgentRound {
round: (iteration + 1) as u32,
conversation_id: Some(conv_id.clone()),
});
}
// 构建请求消息(超预算时自动裁剪旧消息,保护工具调用三元组 + 最近 6 条)
let messages = {
let session = session_arc.lock().await;
let sys_tokens = TokenEstimator::default().estimate_text(&system_prompt);
let (history_msgs, _trimmed) = session.messages.build_for_request(sys_tokens);
let mut msgs = vec![ChatMessage::system(&system_prompt)];
msgs.extend(history_msgs);
msgs
};
// 预估输入 token(兜底:部分 provider 如 GLM 流式 usage 不报 prompt_tokens,后段用它补)
let estimated_prompt: u32 = {
let est = TokenEstimator::default();
messages.iter().map(|m| est.estimate_message(m)).sum()
};
let request = CompletionRequest {
model: provider_config.default_model.clone(),
messages,
temperature: Some(0.7),
max_tokens: Some(8192),
stream: true,
tools: if tool_defs.is_empty() { None } else { Some(tool_defs.clone()) },
tool_choice: None,
};
// LLM 并发限流(全局 + 单对话双层),仅覆盖 stream_llm 调用本身;
// 工具执行(process_tool_calls)是本地操作无 RPM 成本,permit 在 stream 后立即释放避免占槽
let _global_permit = llm_concurrency.acquire_global().await;
let _per_conv_permit = llm_concurrency.acquire_per_conv().await;
// 流式接收(内部处理 idle timeout / 断连检测 / 停止信号)
let (full_text, tool_calls_acc, round_usage) = match stream_llm(&*provider, request, &app_handle, &stop_flag, &conv_id).await {
Some(result) => result,
None => {
// 错误已在 stream_llm 中 emit直接结束
let mut session = session_arc.lock().await;
session.generating = false;
return;
}
};
// stream 结束立即释放 permit,后续工具执行不受限流(本地操作无 RPM 成本)
drop(_global_permit);
drop(_per_conv_permit);
// 累加本轮 token:provider 流式 usage 的 prompt_tokens 为 0 时(GLM 等),用预估输入兜底
let round_prompt = if round_usage.prompt_tokens == 0 { estimated_prompt } else { round_usage.prompt_tokens };
tokens.add(round_prompt, round_usage.completion_tokens);
// 追加 assistant 消息到历史
let has_tool_calls = !tool_calls_acc.is_empty();
{
let mut session = session_arc.lock().await;
if has_tool_calls {
let mut order: Vec<u32> = tool_calls_acc.keys().copied().collect();
order.sort_unstable();
let ai_tool_calls: Vec<df_ai::provider::ToolCall> = order.iter()
.map(|i| {
let draft = &tool_calls_acc[i];
df_ai::provider::ToolCall::new(&draft.id, &draft.name, &draft.args)
})
.collect();
let mut msg = ChatMessage::assistant_with_tools(&full_text, ai_tool_calls);
msg.model = Some(provider_config.default_model.clone());
session.messages.push(msg);
} else if !full_text.is_empty() {
let mut msg = ChatMessage::assistant(&full_text);
msg.model = Some(provider_config.default_model.clone());
session.messages.push(msg);
}
}
// 停止信号:已生成文本入库后退出,不再执行后续工具调用
if stop_flag.load(Ordering::SeqCst) {
let usage = df_ai::provider::TokenUsage {
prompt_tokens: tokens.prompt(),
completion_tokens: tokens.completion(),
total_tokens: tokens.total(),
};
save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&provider_config.default_model)).await;
// 标题生成后台化:不阻塞 Completed emit失败有 extract_title 兜底)
spawn_ensure_title(&provider_config, &db, &conv_id, &app_handle, &session_arc, &llm_concurrency);
let mut session = session_arc.lock().await;
session.generating = false;
// generating 复位后再 emit Completed保证前端收事件时后端已可接下一条(发送队列续发不被"正在生成中"拒绝)
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage.total_tokens, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), conversation_id: Some(conv_id.clone()) });
return;
}
// 无工具调用 → 最终文本响应,循环结束
if !has_tool_calls { break; }
// 处理工具调用Low 自动执行 / Medium+High 待审批)
let pending_count = {
let mut session = session_arc.lock().await;
process_tool_calls(&mut session, tool_calls_acc, &tools_arc, &db, &app_handle, &conv_id).await
};
// 有待审批 → 暂停循环,等待用户审批后通过 ai_approve → try_continue_agent_loop 恢复
if pending_count > 0 {
let usage = df_ai::provider::TokenUsage {
prompt_tokens: tokens.prompt(),
completion_tokens: tokens.completion(),
total_tokens: tokens.total(),
};
save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&provider_config.default_model)).await;
return; // generating 保持 true
}
// 全部自动执行完成 → 继续下一轮
}
// 正常完成
let usage = df_ai::provider::TokenUsage {
prompt_tokens: tokens.prompt(),
completion_tokens: tokens.completion(),
total_tokens: tokens.total(),
};
// 落库 + 标题 + 知识提炼打包后台化:不阻塞 generating 复位与 Completed 事件
// save 先行(extract/title 都读已落库消息);extract 内部 fire-and-forget,与 title 可能并发
// (均受 per_conv 信号量约束,读写不同字段互不干扰)
// 并发取舍:与新对话新 loop 的 save 存在低概率并发 upsert最多丢少量 token 累加(非功能错误,可接受)
let usage_total = usage.total_tokens;
{
let session_arc = session_arc.clone();
let db = db.clone();
let conv_id = conv_id.clone();
let provider_config = provider_config.clone();
let knowledge_config = knowledge_config.clone();
let app_handle = app_handle.clone();
let llm_concurrency = llm_concurrency.clone();
tauri::async_runtime::spawn(async move {
save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&provider_config.default_model)).await;
// 知识提炼:需读已落库的对话消息,故在 save 之后
if let Err(e) = maybe_spawn_extraction(&session_arc, &db, &conv_id, &provider_config, &knowledge_config, llm_concurrency.clone()).await {
tracing::warn!("知识提炼触发失败(非阻断): {}", e);
}
ensure_conversation_title(&provider_config, &db, &conv_id, &app_handle, &session_arc, llm_concurrency).await;
});
}
let mut session = session_arc.lock().await;
session.generating = false;
// generating 复位后再 emit Completed落库/标题/提炼已在后台,前端立即感知完成
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage_total, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), conversation_id: Some(conv_id.clone()) });
}
/// 检查是否所有待审批已处理,如果是则恢复 agentic 循环
pub(crate) async fn try_continue_agent_loop(app: &AppHandle, state: &AppState) {
let should_continue = {
let session = state.ai_session.lock().await;
session.generating && session.pending_approvals.is_empty()
};
if !should_continue { return; }
let provider_config = match get_active_provider(state).await {
Ok(p) => p,
Err(_) => return,
};
let (lang, conv_id) = {
let session = state.ai_session.lock().await;
let lang = session.agent_language.clone().unwrap_or_else(|| "zh-CN".to_string());
let conv_id = session.active_conversation_id.clone().unwrap_or_default();
(lang, conv_id)
};
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();
// 恢复循环前通知前端新建 assistant 消息:审批(通过/拒绝)后新一轮文本
// 不应追加到发起工具调用的旧消息,用 AiAgentRound 隔开
let _ = app.emit("ai-chat-event", AiChatEvent::AiAgentRound {
round: 0,
conversation_id: Some(conv_id.clone()),
});
tauri::async_runtime::spawn(async move {
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency).await;
});
}

View File

@@ -0,0 +1,238 @@
//! 工具调用审计 + pending 审批恢复 + 工具调用处理
use std::collections::HashMap;
use std::sync::Arc;
use tauri::{AppHandle, Emitter};
use df_ai::ai_tools::{AiToolRegistry, RiskLevel};
use df_ai::provider::ChatMessage;
use df_storage::crud::AiToolExecutionRepo;
use df_storage::db::Database;
use df_storage::models::AiToolExecutionRecord;
use df_core::types::new_id;
use crate::state::AppState;
use crate::commands::now_millis;
use super::{AiChatEvent, AiSession, PendingApproval, ToolCallDraft};
/// RiskLevel → 审计记录字符串low/medium/high
pub(crate) fn risk_str(r: RiskLevel) -> &'static str {
match r {
RiskLevel::Low => "low",
RiskLevel::Medium => "medium",
RiskLevel::High => "high",
}
}
/// 审计记录字符串 → RiskLevel启动重建 pending_approvals 用,未知串返回 None 跳过)
pub(crate) fn risk_from_str(s: &str) -> Option<RiskLevel> {
match s {
"low" => Some(RiskLevel::Low),
"medium" => Some(RiskLevel::Medium),
"high" => Some(RiskLevel::High),
_ => None,
}
}
/// 启动恢复:从审计表重建 pending_approvals(重启前未审批的工具调用,内存态已丢)
///
/// pending_approvals 是 AiSession 内存 HashMap,重启必丢。ai_tool_executions 表已存
/// status=pending 的行(持久化真相源),此处读回重建内存态,使重启后待审批不丢。
/// 前端经 ai_pending_tool_calls 查询 + switchConversation 恢复 toolCard 的 pending_approval 态。
pub async fn restore_pending_approvals(state: &AppState) {
let pending = match state.ai_tool_executions.list_pending().await {
Ok(v) => v,
Err(e) => {
tracing::warn!("启动恢复 pending 审批失败(非阻断): {}", e);
return;
}
};
if pending.is_empty() {
return;
}
let mut session = state.ai_session.lock().await;
for rec in pending {
let args: serde_json::Value = serde_json::from_str(&rec.arguments).unwrap_or_default();
let Some(risk) = risk_from_str(&rec.risk_level) else { continue };
session.pending_approvals.insert(
rec.tool_call_id.clone(),
PendingApproval {
tool_call_id: rec.tool_call_id,
tool_name: rec.tool_name,
arguments: args,
risk_level: risk,
conversation_id: rec.conversation_id,
recovered: true,
},
);
}
tracing::info!("启动恢复: {} 条 pending 工具审批重建到内存", session.pending_approvals.len());
}
/// 写一条工具执行审计记录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>,
) {
let executed_at = if decided_by.is_some() { Some(now_millis()) } else { None };
let _ = repo
.insert(AiToolExecutionRecord {
id: new_id(),
conversation_id: Some(conv_id.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;
}
/// 审批后更新审计记录状态(按 tool_call_id 定位 pending 记录,回填 status/decided_by=human/executed_at/result
///
/// 走专用 find_by_tool_call_id —— 通用 query 宏硬编码 ORDER BY created_at DESC
/// 而 ai_tool_executions 无该列,调用会报 "no such column: created_at" 被 unwrap_or_default 吞掉,
/// 导致审批后审计记录永久卡 pending。
pub(crate) async fn audit_finalize(state: &AppState, tool_call_id: &str, status: &str, result: Option<String>) {
let Some(mut rec) = state
.ai_tool_executions
.find_by_tool_call_id(tool_call_id)
.await
.unwrap_or_default()
else {
tracing::warn!("audit_finalize: 未找到 tool_call_id={} 的审计记录", tool_call_id);
return;
};
rec.status = status.to_string();
rec.decided_by = Some("human".to_string());
rec.executed_at = Some(now_millis());
if let Some(r) = result {
rec.result = Some(r);
}
let _ = state.ai_tool_executions.update_full(&rec).await;
}
/// 处理流式接收的工具调用Low 风险并行执行join_allMed/High 进审批门控
/// 返回待审批的工具数量0 = 全部自动执行完成)
pub(crate) async fn process_tool_calls(
session: &mut AiSession,
tool_calls_acc: HashMap<u32, ToolCallDraft>,
tools_arc: &Arc<AiToolRegistry>,
db: &Arc<Database>,
app_handle: &AppHandle,
conv_id: &str,
) -> usize {
let mut tc_list: Vec<_> = tool_calls_acc.into_iter().collect();
tc_list.sort_unstable_by_key(|(i, _)| *i);
let mut pending_count = 0usize;
let audit_repo = AiToolExecutionRepo::new(db);
// 解析 args + 批量发 Started前端骨架按原始 index 顺序展示)
let drafts: Vec<(u32, ToolCallDraft, serde_json::Value)> = tc_list.into_iter()
.map(|(idx, draft)| {
let args = serde_json::from_str(&draft.args).unwrap_or(serde_json::Value::Object(Default::default()));
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiToolCallStarted {
id: draft.id.clone(),
name: draft.name.clone(),
args: args.clone(),
conversation_id: Some(conv_id.to_string()),
});
(idx, draft, args)
})
.collect();
// 分类Low 收集并行执行Med/High 立即进审批门控push 占位 tool_result
let mut low_risk: Vec<(ToolCallDraft, serde_json::Value)> = Vec::new();
for (_, draft, args) in drafts {
let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
match risk_level {
RiskLevel::Low => low_risk.push((draft, args)),
RiskLevel::Medium | RiskLevel::High => {
pending_count += 1;
session.pending_approvals.insert(draft.id.clone(), PendingApproval {
tool_call_id: draft.id.clone(),
tool_name: draft.name.clone(),
arguments: args.clone(),
risk_level,
conversation_id: Some(conv_id.to_string()),
recovered: false,
});
session.messages.push(ChatMessage::tool_result(&draft.id, "需要用户审批,等待确认"));
let reason = match risk_level {
RiskLevel::High => "高风险操作,必须人工批准".to_string(),
_ => "创建操作,请确认是否执行".to_string(),
};
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiApprovalRequired {
id: draft.id.clone(),
name: draft.name.clone(),
args: args.clone(),
reason,
conversation_id: Some(conv_id.to_string()),
});
audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "pending", risk_level, None, None).await;
}
}
}
// Low 风险并行执行execute + 即时 emit 在闭包内(不持 session 锁),
// push tool_result / audit 在 join_all 后串行回填(持锁,与 Med/High 占位拼接)。
// join_all 保序——结果顺序 = low_risk 输入顺序 = tc_list 原始 index 顺序,不额外 sort
if !low_risk.is_empty() {
let results: Vec<(ToolCallDraft, Result<String, String>)> =
futures::future::join_all(low_risk.into_iter().map(|(draft, args)| {
let tools = tools_arc.clone();
let app_clone = app_handle.clone();
let conv_clone = conv_id.to_string();
async move {
let result = tools.execute(&draft.name, args).await;
match result {
Ok(val) => {
let _ = app_clone.emit("ai-chat-event", AiChatEvent::AiToolCallCompleted {
id: draft.id.clone(),
result: val.clone(),
conversation_id: Some(conv_clone),
});
(draft, Ok(val.to_string()))
}
Err(e) => {
let _ = app_clone.emit("ai-chat-event", AiChatEvent::AiError {
error: format!("工具 {} 执行失败: {}", draft.name, e),
conversation_id: Some(conv_clone),
});
(draft, Err(format!("错误: {}", e)))
}
}
}
})).await;
// 串行回填 tool_result + 审计(持 session 锁)
for (draft, outcome) in results {
let (status, content) = match outcome {
Ok(c) => ("completed", c),
Err(c) => ("failed", c),
};
session.messages.push(ChatMessage::tool_result(&draft.id, content.clone()));
audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, status, RiskLevel::Low, Some(content), Some("auto")).await;
}
}
pending_count
}

View File

@@ -0,0 +1,531 @@
//! 所有 `#[tauri::command]` IPC 函数 — 由 mod.rs 重导出供 invoke_handler 引用
use std::sync::atomic::Ordering;
use serde::Serialize;
use tauri::{AppHandle, Emitter, State};
use df_ai::provider::ChatMessage;
use df_core::types::new_id;
use df_storage::models::AiProviderRecord;
use crate::state::AppState;
use crate::commands::now_millis;
use super::agentic::{run_agentic_loop, try_continue_agent_loop};
use super::audit::audit_finalize;
use super::conversation::save_conversation;
use super::knowledge_inject::build_knowledge_context;
use super::prompt::build_system_prompt;
use super::skills::{read_skill_content, SkillInfo, skills_cached};
use super::AiChatEvent;
// ============================================================
// 发送 / 审批 / 控制
// ============================================================
/// 发送消息并获取流式 AI 响应
///
/// 非阻塞:立即返回 "ok",通过 ai-chat-event 事件流式推送
#[tauri::command]
pub async fn ai_chat_send(
app: AppHandle,
state: State<'_, AppState>,
message: String,
language: Option<String>,
skill: Option<String>,
) -> Result<String, String> {
// 获取活跃提供商(只读,失败可直接返回,不影响生成标志)
let provider_config = super::prompt::get_active_provider(&state).await?;
// 原子检查并占用生成标志,防止并发双发;同步追加用户消息,按需自动创建对话
{
let mut session = state.ai_session.lock().await;
if session.generating {
return Err("AI 正在生成中,请等待完成".to_string());
}
session.generating = true;
session.stop_flag.store(false, Ordering::SeqCst);
session.agent_language = language.clone();
session.messages.push(ChatMessage::user(&message));
// 首次发送时生成对话 id(懒创建:不立即落库,避免空对话残留;
// 实际记录由 save_conversation 在生成内容后 upsert 写入)
if session.active_conversation_id.is_none() {
let conv_id = new_id();
session.active_conversation_id = Some(conv_id);
session.active_conv_created_at = Some(now_millis());
}
}
// 获取工具定义(预取仅用于触发注册表初始化,实际 tool_defs 在 agentic loop 内部按需获取)
let _tool_defs = state.ai_tools.tool_definitions();
let lang = language.unwrap_or_else(|| "zh-CN".to_string());
let mut system_prompt = build_system_prompt(&state, &lang).await;
// 技能注入:读 SKILL.md 全文拼到 system prompt 前作为指令
if let Some(ref skill_name) = skill {
if let Some(content) = read_skill_content(skill_name) {
system_prompt = format!("# 技能指令: {}\n\n{}\n\n---\n{}", skill_name, content, system_prompt);
}
}
// 快照当前对话 ID,供知识注入溯源 + spawn 后台 loop(不受切换影响)
let conv_id = {
let session = state.ai_session.lock().await;
session.active_conversation_id.clone().unwrap_or_default()
};
// 知识注入:检索相关知识拼到 system prompt 前(可配置开关 auto_inject,默认开)
// 最终顺序:[知识库上下文] --- [技能指令] --- [原始 system prompt]
{
let config = state.knowledge_config.lock().await.clone();
let knowledge_context = build_knowledge_context(&state, &conv_id, &message, &config).await;
if !knowledge_context.is_empty() {
system_prompt = format!("{}\n\n---\n{}", knowledge_context, system_prompt);
}
}
// 在后台任务中执行流式调用
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();
tauri::async_runtime::spawn(async move {
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency).await;
});
Ok("ok".to_string())
}
/// 批准/拒绝挂起的工具调用
#[tauri::command]
pub async fn ai_approve(
app: AppHandle,
state: State<'_, AppState>,
tool_call_id: String,
approved: bool,
) -> Result<String, String> {
let mut session = state.ai_session.lock().await;
let approval = session
.pending_approvals
.remove(&tool_call_id)
.ok_or_else(|| format!("未找到挂起的审批: {}", tool_call_id))?;
// recovered 字段保留读取(标记重启恢复来源,未来扩展用),本次修复移除 if !recovered 落库守卫。
let _recovered = approval.recovered;
if !approved {
// 替换占位 tool_result 为拒绝结果
session.messages.replace_tool_result_content(&tool_call_id, "用户拒绝了此操作");
let conv_id = approval.conversation_id.clone();
let _ = app.emit("ai-chat-event", AiChatEvent::AiApprovalResult {
id: tool_call_id.clone(),
approved: false,
conversation_id: conv_id.clone(),
});
drop(session);
// 拒绝结果立即落库(含 recovered 积压审批)——switch 时已 restore_from_messages 载完整历史,
// messages 非空,save 不会污染老对话;原 if !recovered 守卫前提不成立已移除。
if let Some(ref cid) = conv_id {
save_conversation(&state.ai_session, &state.db, cid, None, None).await;
}
// 审计:拒绝(决策者=human
audit_finalize(&state, &tool_call_id, "rejected", None).await;
// 所有待审批处理完毕后恢复 agentic 循环
try_continue_agent_loop(&app, &state).await;
return Ok("rejected".to_string());
}
// 执行工具(通过真实 repo 调用)
let args = approval.arguments.clone();
let id = tool_call_id.clone();
let conv_id = approval.conversation_id.clone();
drop(session); // 释放锁后再执行
let exec_result = state.ai_tools.execute(&approval.tool_name, args.clone()).await;
// 工具失败不 return Err把错误包成 tool_result落库 + emit completed + 续循环全走通。
// 否则前端 approveToolCall 的 catch 会回滚 pending_approval审批按钮卡死无法消除。
let (audit_status, result_val) = match &exec_result {
Ok(val) => ("executed", val.clone()),
Err(e) => ("failed", serde_json::Value::String(e.to_string())),
};
// 审计:人工审批后无论成败回填(决策者=human
audit_finalize(&state, &tool_call_id, audit_status, Some(result_val.to_string())).await;
// 重新获取锁,替换占位 tool_result 为真实结果失败时为错误信息LLM 据此决定下一步)
let mut session = state.ai_session.lock().await;
session.messages.replace_tool_result_content(&id, &result_val.to_string());
let _ = app.emit("ai-chat-event", AiChatEvent::AiToolCallCompleted {
id: id.clone(),
result: result_val.clone(),
conversation_id: conv_id.clone(),
});
let _ = app.emit("ai-chat-event", AiChatEvent::AiApprovalResult {
id,
approved: true,
conversation_id: conv_id.clone(),
});
drop(session);
// 审批执行结果立即落库,不依赖后续 agentic loop(避免 loop 异常退出时丢失真实结果)
// 含 recovered 积压审批——switch 时已 restore_from_messages 载完整历史,messages 非空,
// save 不污染老对话;原 if !recovered 守卫前提不成立已移除。
if let Some(ref cid) = conv_id {
save_conversation(&state.ai_session, &state.db, cid, None, None).await;
}
// 所有待审批处理完毕后恢复 agentic 循环(recovered 无 live loop,try_continue 因 generating=false 自然不续)
try_continue_agent_loop(&app, &state).await;
Ok("executed".to_string())
}
/// 待审批工具调用信息(前端恢复 toolCard pending_approval 态用)
#[derive(Debug, Serialize)]
pub struct PendingToolCallInfo {
pub tool_call_id: String,
pub conversation_id: Option<String>,
}
/// 查询某对话积压的待审批工具(前端 switchConversation 后恢复 toolCard 的 pending_approval 态)
#[tauri::command]
pub async fn ai_pending_tool_calls(
state: State<'_, AppState>,
conv_id: String,
) -> Result<Vec<PendingToolCallInfo>, String> {
let session = state.ai_session.lock().await;
let list = session
.pending_approvals
.values()
.filter(|a| a.conversation_id.as_deref() == Some(conv_id.as_str()))
.map(|a| PendingToolCallInfo {
tool_call_id: a.tool_call_id.clone(),
conversation_id: a.conversation_id.clone(),
})
.collect();
Ok(list)
}
/// 清空对话历史
#[tauri::command]
pub async fn ai_chat_clear(state: State<'_, AppState>) -> Result<(), String> {
let mut session = state.ai_session.lock().await;
session.messages.clear();
session.pending_approvals.clear();
Ok(())
}
/// 停止当前 AI 生成
///
/// 两种场景:
/// - loop 正在流式生成:置 stop_flagstream_llm / 循环检查点尽快退出并 emit AiCompleted
/// - 有挂起审批loop 已 return 等待中stop_flag 无人读取,直接清审批 + 复位 generating
/// 否则停止按钮表面无反应、会话卡在 generating=true
#[tauri::command]
pub async fn ai_chat_stop(state: State<'_, AppState>, app: AppHandle) -> Result<(), String> {
let mut session = state.ai_session.lock().await;
if !session.generating {
return Ok(());
}
if !session.pending_approvals.is_empty() {
// 审批等待态loop 已退出,直接清理让会话立即可用
session.pending_approvals.clear();
session.generating = false;
session.stop_flag.store(true, Ordering::SeqCst); // 双保险:防 try_continue 误判重启
let conv_id = session.active_conversation_id.clone();
drop(session);
let _ = app.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: 0, prompt_tokens: 0, completion_tokens: 0, conversation_id: conv_id });
return Ok(());
}
// 流式生成中:置位让 loop 自行收尾
session.stop_flag.store(true, Ordering::SeqCst);
Ok(())
}
// ============================================================
// 提供商管理
// ============================================================
/// 列出所有已配置的 AI 提供商is_default 真相源为 DB重启不丢
#[tauri::command]
pub async fn ai_list_providers(state: State<'_, AppState>) -> Result<Vec<AiProviderRecord>, String> {
state.ai_providers.list_all().await.map_err(|e| e.to_string())
}
/// 保存/更新 AI 提供商配置
#[tauri::command]
pub async fn ai_save_provider(
state: State<'_, AppState>,
id: Option<String>,
name: String,
base_url: String,
api_key: String,
default_model: String,
provider_type: String,
) -> Result<String, String> {
// 编辑已有提供商时保留原 created_at避免被覆盖
let created_at = match &id {
Some(pid) => state.ai_providers.get_by_id(pid).await
.map_err(|e| e.to_string())?
.map(|p| p.created_at)
.unwrap_or_else(now_millis),
None => now_millis(),
};
// is_default编辑保留原值新建时若全表尚无默认则设为默认首个自动默认避免无默认可用
let is_default = match &id {
Some(pid) => state.ai_providers.get_by_id(pid).await
.map_err(|e| e.to_string())?
.map(|p| p.is_default)
.unwrap_or(false),
None => !state.ai_providers.list_all().await
.map_err(|e| e.to_string())?
.iter().any(|p| p.is_default),
};
let record = AiProviderRecord {
id: id.unwrap_or_else(new_id),
name,
provider_type: if provider_type.is_empty() { "openai_compat".to_string() } else { provider_type },
api_key,
base_url,
default_model,
models: None,
is_default,
config: None,
created_at,
updated_at: now_millis(),
};
let id = record.id.clone();
state
.ai_providers
.insert(record)
.await
.map_err(|e| e.to_string())?;
Ok(id)
}
/// 设置活跃提供商(互斥落库:目标置默认、其余清默认,重启不丢)
#[tauri::command]
pub async fn ai_set_provider(
state: State<'_, AppState>,
provider_id: String,
) -> Result<(), String> {
// 验证提供商存在
let provider = state
.ai_providers
.get_by_id(&provider_id)
.await
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("提供商不存在: {}", provider_id))?;
// 互斥写 DB目标 is_default=true其余=false。仅写变化的记录。
let providers = state.ai_providers.list_all().await.map_err(|e| e.to_string())?;
for p in &providers {
let should = p.id == provider_id;
if p.is_default != should {
let mut updated = p.clone();
updated.is_default = should;
updated.updated_at = now_millis();
state.ai_providers.update_full(&updated).await.map_err(|e| e.to_string())?;
}
}
let mut session = state.ai_session.lock().await;
session.active_provider_id = Some(provider.id);
Ok(())
}
/// 删除 AI 提供商
#[tauri::command]
pub async fn ai_delete_provider(
state: State<'_, AppState>,
provider_id: String,
) -> Result<(), String> {
state.ai_providers.delete(&provider_id).await.map_err(|e| e.to_string())?;
// 删除的若是当前默认,清空 active 指向,避免悬空
let mut session = state.ai_session.lock().await;
if session.active_provider_id.as_deref() == Some(&provider_id) {
session.active_provider_id = None;
}
Ok(())
}
// ============================================================
// 对话管理
// ============================================================
/// 创建新对话
#[tauri::command]
pub async fn ai_conversation_create(state: State<'_, AppState>) -> Result<serde_json::Value, String> {
// 懒创建:仅生成 id 存内存,不落库;避免新建后不发消息产生空记录。
// 首条消息发送后由 save_conversation upsert 写入。
let id = new_id();
let now = now_millis();
let mut session = state.ai_session.lock().await;
session.active_conversation_id = Some(id.clone());
session.active_conv_created_at = Some(now);
session.messages.clear();
session.pending_approvals.clear();
Ok(serde_json::json!({ "id": id }))
}
/// 列出对话(仅摘要,不含 messages 全文)
///
/// limit 默认 50 防数据膨胀include_archived 默认 false归档对话默认隐藏
#[tauri::command]
pub async fn ai_conversation_list(
state: State<'_, AppState>,
limit: Option<usize>,
include_archived: Option<bool>,
) -> Result<Vec<serde_json::Value>, String> {
let limit = limit.unwrap_or(50);
let include_archived = include_archived.unwrap_or(false);
let records = state.ai_conversations.list_all().await.map_err(|e| e.to_string())?;
// list_all 已按 created_at DESC(最新在前);默认排除归档 + 截断 limit
let summaries: Vec<serde_json::Value> = records.iter()
.filter(|r| include_archived || !r.archived)
.take(limit)
.map(|r| {
// 修复 models 字段类型 bug:r.models 是 JSON 字符串,前端期望数组
let models: Vec<String> = r.models.as_deref()
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or_default();
serde_json::json!({
"id": r.id,
"title": r.title,
"provider_id": r.provider_id,
"model": r.model,
"models": models,
"archived": r.archived,
"prompt_tokens": r.prompt_tokens,
"completion_tokens": r.completion_tokens,
"created_at": r.created_at,
"updated_at": r.updated_at,
})
}).collect();
Ok(summaries)
}
/// 切换到指定对话(从 DB 加载 messages 到内存 + 返回 messages 给前端)
#[tauri::command]
pub async fn ai_conversation_switch(
state: State<'_, AppState>,
conversation_id: String,
) -> Result<serde_json::Value, String> {
let record = state.ai_conversations.get_by_id(&conversation_id).await
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("对话不存在: {}", conversation_id))?;
let messages: Vec<ChatMessage> = serde_json::from_str(&record.messages)
.map_err(|e| format!("解析消息失败: {}", e))?;
let messages_json = record.messages.clone();
let title = record.title.clone();
let mut session = state.ai_session.lock().await;
// 生成中允许只读切换:返回目标对话的 messages 供前端展示,但不修改 session 状态
// 后台 loop 持有快照的 conv_id不受 active_conversation_id 变更影响
if session.generating {
return Ok(serde_json::json!({
"id": record.id,
"title": title,
"messages": messages_json,
"readonly": true,
}));
}
session.active_conversation_id = Some(conversation_id.clone());
session.messages.restore_from_messages(messages);
// 仅清空目标对话自身的 pending_approvals,保留其他对话的(防 init 重建的内存 HashMap 被清空,
// 重启恢复链路:restore_pending_approvals(init 重建) → switchConversation(此处不清目标对话的)
// → ai_pending_tool_calls 查询 → ai_approve 落库)
session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(&conversation_id));
Ok(serde_json::json!({
"id": record.id,
"title": title,
"messages": messages_json,
}))
}
/// 删除对话
#[tauri::command]
pub async fn ai_conversation_delete(
state: State<'_, AppState>,
conversation_id: String,
) -> Result<(), String> {
state.ai_conversations.delete(&conversation_id).await.map_err(|e| e.to_string())?;
let mut session = state.ai_session.lock().await;
if session.active_conversation_id.as_deref() == Some(&conversation_id) {
session.active_conversation_id = None;
session.messages.clear();
session.pending_approvals.clear();
}
Ok(())
}
/// 重命名对话标题
#[tauri::command]
pub async fn ai_conversation_rename(
state: State<'_, AppState>,
conversation_id: String,
title: String,
) -> Result<(), String> {
let title = title.trim().to_string();
if title.is_empty() {
return Err("标题不能为空".to_string());
}
state.ai_conversations.update_field(&conversation_id, "title", &title)
.await.map_err(|e| e.to_string())?;
Ok(())
}
/// 归档/取消归档对话(归档后在侧栏折叠分组展示)
#[tauri::command]
pub async fn ai_conversation_archive(
state: State<'_, AppState>,
conversation_id: String,
archived: bool,
) -> Result<(), String> {
state.ai_conversations
.set_archived(&conversation_id, archived)
.await
.map_err(|e| e.to_string())?;
Ok(())
}
/// 列出本机 Claude 技能skills + commands + plugins 三类),供前端 `/` 联想
#[tauri::command]
pub async fn ai_list_skills() -> Result<Vec<SkillInfo>, String> {
// 命中进程内缓存,命中后仅 clone不重复扫盘
Ok(skills_cached().clone())
}
/// 设置 LLM 调用并发上限(运行时调整,立即生效)
///
/// 软收敛:缩并发时已持有旧 permit 的任务继续执行不受影响,待其释放后新限制完全生效。
/// None 表示该层不变(前端可单独调一层)。值下限为 1。
#[tauri::command]
pub async fn ai_set_concurrency_config(
state: State<'_, AppState>,
global_limit: Option<u32>,
per_conv_limit: Option<u32>,
) -> Result<(), String> {
// 下限 1,无上限;同时给 global 时约束 per-conv 不超过 global
if let Some(g) = global_limit {
state.llm_concurrency.set_global(g.max(1) as usize).await;
}
if let Some(p) = per_conv_limit {
let mut p = p.max(1);
if let Some(g) = global_limit {
p = p.min(g.max(1));
}
state.llm_concurrency.set_per_conv(p as usize).await;
}
Ok(())
}

View File

@@ -0,0 +1,269 @@
//! 对话持久化 + Token 累加器
use std::sync::Arc;
use tokio::sync::Mutex;
use df_storage::crud::AiConversationRepo;
use df_storage::db::Database;
use crate::commands::now_millis;
use super::AiSession;
/// Token 用量累加器(agent loop 生命周期内各轮叠加)
///
/// 纯结构 + 方法:抽自 run_agentic_loop 的 `total_prompt`/`total_completion` 双计数器,
/// 保证多轮累加、None 起始、跨 loop 实例叠加语义一致且可单测。
#[derive(Debug, Clone, Default)]
pub(crate) struct TokenAccumulator {
prompt: u32,
completion: u32,
}
impl TokenAccumulator {
/// 叠加一轮用量(round_usage 为本轮流式末 chunk 的累计用量)
pub(crate) fn add(&mut self, prompt: u32, completion: u32) {
self.prompt += prompt;
self.completion += completion;
}
pub(crate) fn prompt(&self) -> u32 {
self.prompt
}
pub(crate) fn completion(&self) -> u32 {
self.completion
}
pub(crate) fn total(&self) -> u32 {
self.prompt + self.completion
}
}
/// 把单轮增量叠加到 DB 的 Option<i64> 字段(读旧值+增量,跨 loop 实例防覆盖)
///
/// 纯函数:抽自 save_conversation 的 token 累加逻辑,None 起始当作 0。
pub(crate) fn accumulate_tokens(old: Option<i64>, add: u32) -> Option<i64> {
Some(old.unwrap_or(0) + add as i64)
}
/// 持久化截断阈值:超过此长度的消息 content 落库前截断头尾各保 HEAD/TAIL 字符。
///
/// 防 read_file 1MB 洞 / list_directory 大体量结果落库后每轮重发累积致 token 暴增
/// (Sprint 19 实测单对话 in=115万 / 消息体 1.6MB)。仅作用于持久化视图,不污染内存真相源。
pub(crate) const TRUNCATE_THRESHOLD: usize = 50_000;
const TRUNCATE_HEAD: usize = 20_000;
const TRUNCATE_TAIL: usize = 20_000;
/// 落库前对超长 content 做截断(保留头尾各 ~20KB + 中段标注省略字符数)。
/// 50KB 阈值以下原样返回(零开销);按字符而非字节切避免 UTF-8 切坏中文。
pub(crate) fn truncate_for_persist(content: &str) -> String {
let chars: Vec<char> = content.chars().collect();
if chars.len() <= TRUNCATE_THRESHOLD {
return content.to_string();
}
let head: String = chars.iter().take(TRUNCATE_HEAD).collect();
let tail: String = chars[chars.len() - TRUNCATE_TAIL..].iter().collect();
let omitted = chars.len() - TRUNCATE_HEAD - TRUNCATE_TAIL;
format!(
"{}\n\n[...省略 {} 字符(已截断,完整内容仅在内存态可读)...]\n\n{}",
head, omitted, tail
)
}
/// 保存对话到数据库(按 conv_id 写库,不受 active_conversation_id 切换影响)
///
/// 写 messages + updated_at + 累加 token 用量 + 首次落库的 model标题由 ensure_conversation_title 单独生成。
/// token 走累加模式:upsert 读旧值叠加,保证审批暂停→恢复跨 loop 实例不覆盖丢失。
/// model 仅首次落库写入 + 旧记录缺值时补填(不覆盖历史已存值,兼容本次改造前的老对话)。
pub(crate) async fn save_conversation(
session_arc: &Arc<Mutex<AiSession>>,
db: &Arc<Database>,
conv_id: &str,
usage: Option<&df_ai::provider::TokenUsage>,
model: Option<&str>,
) {
// 取 messages + 懒创建首次落库所需的 provider_id/created_at
// 工具结果(content)超 50KB 时截断头尾各 ~20KB + 中段标注,防大体量结果(read_file 1MB 洞 /
// list_directory 13782 项)落库后每轮重发累积致 token 暴增。仅影响持久化视图,不污染
// 内存真相源(ContextManager)——build_for_request 仍读全量 messages。
let (messages_json, provider_id, created_at) = {
let session = session_arc.lock().await;
let mut msgs = session.messages.all_messages_clone();
for m in &mut msgs {
m.content = truncate_for_persist(&m.content);
}
(
serde_json::to_string(&msgs).unwrap_or_else(|_| "[]".to_string()),
session.active_provider_id.clone(),
session.active_conv_created_at.clone(),
)
};
let conv_repo = AiConversationRepo::new(db);
match conv_repo.get_by_id(conv_id).await {
Ok(Some(mut rec)) => {
// 已落库:更新 messages + updated_at;token 累加(读旧值+新值,跨 loop 实例防覆盖)
rec.messages = messages_json;
rec.updated_at = now_millis();
if let Some(u) = usage {
rec.prompt_tokens = accumulate_tokens(rec.prompt_tokens, u.prompt_tokens);
rec.completion_tokens = accumulate_tokens(rec.completion_tokens, u.completion_tokens);
}
// model: 旧记录缺值时补填(不覆盖已有);models: 去重追加用过的所有 model(JSON 数组)
if let Some(m) = model {
if rec.model.is_none() { rec.model = Some(m.to_string()); }
let mut list: Vec<String> = rec.models
.as_deref()
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or_default();
if !list.iter().any(|x| x == m) { list.push(m.to_string()); }
rec.models = Some(serde_json::to_string(&list).unwrap_or_else(|_| "[]".to_string()));
}
if let Err(e) = conv_repo.update_full(&rec).await {
tracing::warn!("更新对话失败 {conv_id}: {e}");
}
}
Ok(None) => {
// 懒创建首次落库(此为空对话不落库的落库点:走到这里 messages 必非空)
let now = now_millis();
let rec = df_storage::models::AiConversationRecord {
id: conv_id.to_string(),
title: None,
messages: messages_json,
provider_id,
model: model.map(|m| m.to_string()),
models: model.map(|m| serde_json::to_string(&[m]).unwrap_or_else(|_| "[]".to_string())),
archived: false,
prompt_tokens: usage.map(|u| u.prompt_tokens as i64),
completion_tokens: usage.map(|u| u.completion_tokens as i64),
created_at: created_at.unwrap_or_else(|| now.clone()),
updated_at: now,
};
if let Err(e) = conv_repo.insert(rec).await {
tracing::warn!("落库对话失败 {conv_id}: {e}");
}
}
Err(e) => tracing::warn!("读取对话 {conv_id} 失败: {e}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
// ---------- TokenAccumulator + accumulate_tokens ----------
#[test]
fn accumulator_starts_zero() {
let acc = TokenAccumulator::default();
assert_eq!(acc.prompt(), 0);
assert_eq!(acc.completion(), 0);
assert_eq!(acc.total(), 0);
}
#[test]
fn accumulator_single_add() {
let mut acc = TokenAccumulator::default();
acc.add(100, 50);
assert_eq!(acc.prompt(), 100);
assert_eq!(acc.completion(), 50);
assert_eq!(acc.total(), 150);
}
#[test]
fn accumulator_multi_round_accumulation() {
// 多轮累加(模拟 agent loop 多次迭代)
let mut acc = TokenAccumulator::default();
acc.add(100, 20); // 轮1
acc.add(200, 40); // 轮2
acc.add(50, 10); // 轮3
assert_eq!(acc.prompt(), 350);
assert_eq!(acc.completion(), 70);
assert_eq!(acc.total(), 420);
}
#[test]
fn accumulator_add_zero_is_noop() {
let mut acc = TokenAccumulator::default();
acc.add(10, 5);
acc.add(0, 0);
assert_eq!(acc.total(), 15);
}
#[test]
fn accumulate_tokens_from_none() {
// 新记录(None 起始)落库
assert_eq!(accumulate_tokens(None, 100), Some(100));
assert_eq!(accumulate_tokens(None, 0), Some(0));
}
#[test]
fn accumulate_tokens_adds_to_existing() {
// 跨 loop 实例叠加:旧值 + 新增不覆盖
assert_eq!(accumulate_tokens(Some(500), 100), Some(600));
assert_eq!(accumulate_tokens(Some(0), 42), Some(42));
}
#[test]
fn accumulate_tokens_multi_round_db_simulation() {
// 模拟 save_conversation 多次落库累加(审批暂停→恢复跨 loop)
let mut field: Option<i64> = None;
field = accumulate_tokens(field, 100); // 首次
field = accumulate_tokens(field, 200); // 二次
field = accumulate_tokens(field, 50); // 三次
assert_eq!(field, Some(350));
}
#[test]
fn accumulator_and_db_accumulate_are_consistent() {
// loop 内 TokenAccumulator 与落库 accumulate_tokens 总量语义一致
let mut acc = TokenAccumulator::default();
let mut db_prompt: Option<i64> = None;
let mut db_completion: Option<i64> = None;
for (p, c) in [(100u32, 20u32), (200, 40), (50, 10)] {
acc.add(p, c);
db_prompt = accumulate_tokens(db_prompt, p);
db_completion = accumulate_tokens(db_completion, c);
}
assert_eq!(acc.prompt() as i64, db_prompt.unwrap());
assert_eq!(acc.completion() as i64, db_completion.unwrap());
}
// ---------- truncate_for_persist ----------
#[test]
fn truncate_short_content_unchanged() {
// 阈值以下原样返回
assert_eq!(truncate_for_persist("hello"), "hello");
assert_eq!(truncate_for_persist(""), "");
let near_limit: String = "a".repeat(TRUNCATE_THRESHOLD);
assert_eq!(truncate_for_persist(&near_limit).len(), TRUNCATE_THRESHOLD);
}
#[test]
fn truncate_long_content_keeps_head_and_tail() {
// 超阈值:保留头尾各 TRUNCATE_HEAD/TAIL 字符 + 中段标注
let long: String = "x".repeat(TRUNCATE_THRESHOLD + 1000);
let result = truncate_for_persist(&long);
// 头尾各 20k 字符应在结果中
assert!(result.starts_with(&"x".repeat(TRUNCATE_HEAD)));
assert!(result.ends_with(&"x".repeat(TRUNCATE_TAIL)));
// 中段标注存在 + 标注省略字符数(中段 = 总长 - 头 - 尾 = 51000 - 20000 - 20000 = 11000)
assert!(result.contains("已截断"));
assert!(result.contains("省略 11000 字符"));
// 结果总长应远小于原长(20k 头 + 20k 尾 + 标注)
assert!(result.chars().count() < TRUNCATE_THRESHOLD + 1000);
}
#[test]
fn truncate_preserves_utf8_chinese() {
// 按字符切不切坏 UTF-8 中文
let chinese: String = "".repeat(TRUNCATE_THRESHOLD + 500);
let result = truncate_for_persist(&chinese);
assert!(result.starts_with('中'));
assert!(result.ends_with('中'));
assert!(result.contains("已截断"));
}
}

View File

@@ -0,0 +1,557 @@
//! 知识库集成 — 注入 + 提炼(嵌入生成 / 混合检索 / 上下文构建 / 对话提炼)
use std::sync::Arc;
use tokio::sync::Mutex;
use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider, MessageRole};
use df_storage::crud::{AiConversationRepo, KnowledgeRepo};
use df_storage::db::Database;
use df_storage::models::{AiProviderRecord, KnowledgeRecord};
use df_core::types::new_id;
use crate::state::{AppState, ExtractTrigger, LlmConcurrency};
use super::{AiSession};
/// 按配置构建 embedding provider + model。None = 配置缺失/provider 不存在。
async fn resolve_embed_provider(
state: &AppState,
config: &crate::state::KnowledgeConfig,
) -> Option<(Box<dyn LlmProvider>, String)> {
let id = config.embedding_provider_id.as_ref()?;
let model = config.embedding_model.clone().unwrap_or_else(|| "embedding-3".to_string());
let rec = match state.ai_providers.get_by_id(id).await {
Ok(Some(r)) => r,
_ => {
tracing::warn!("embedding provider 不存在: {}", id);
return None;
}
};
Some((
df_ai::build_provider(&rec.provider_type, &rec.base_url, &rec.api_key, &rec.default_model),
model,
))
}
/// 生成文本嵌入(向量检索用)
///
/// 用配置指定的 embedding provider(必须 openai_compat 类型),失败返回 None(降级 LIKE)。
async fn generate_embedding(
state: &AppState,
text: &str,
config: &crate::state::KnowledgeConfig,
) -> Option<Vec<f32>> {
let (provider, model) = resolve_embed_provider(state, config).await?;
// 截断防超 token 上限(8192 token ≈ 8000 中文字)
let input: String = text.chars().take(8000).collect();
match provider.embed(&model, vec![input]).await {
Ok(mut vecs) if !vecs.is_empty() => Some(vecs.remove(0)),
Ok(_) => None,
Err(e) => {
tracing::warn!("embedding 生成失败(降级 LIKE): {}", e);
None
}
}
}
/// 知识条目发布时后台生成嵌入(fire-and-forget,失败仅 log)
///
/// 由 knowledge_update_status(发布路径)调用。vector_enabled 关闭时直接跳过。
pub async fn spawn_embedding_for_knowledge(
state: &AppState,
record: &df_storage::models::KnowledgeRecord,
) {
let config = state.knowledge_config.lock().await.clone();
if !config.vector_enabled {
return;
}
let Some((provider, model)) = resolve_embed_provider(state, &config).await else { return };
let text = format!("{} {}", record.title, record.content);
let id = record.id.clone();
let db = state.db.clone();
tauri::async_runtime::spawn(async move {
let input: String = text.chars().take(8000).collect();
match provider.embed(&model, vec![input]).await {
Ok(vecs) if !vecs.is_empty() => {
let repo = KnowledgeRepo::new(&db);
if let Err(e) = repo.set_embedding(&id, &vecs[0]).await {
tracing::warn!("嵌入写入失败(非阻断): {}", e);
} else {
tracing::info!("知识嵌入完成: {}", id);
}
}
Ok(_) => {}
Err(e) => tracing::warn!("知识嵌入生成失败(非阻断,走 LIKE 降级): {}", e),
}
});
}
/// 混合检索: LIKE 关键词 + 向量语义,合并去重加权
///
/// 双信号(两路都命中)排最前,LIKE 单信号次之,向量单信号第三。
/// vector_enabled 关闭或 embed 失败时纯 LIKE(零外部依赖降级)。
async fn hybrid_search(
state: &AppState,
query: &str,
limit: usize,
config: &crate::state::KnowledgeConfig,
) -> Vec<df_storage::models::KnowledgeRecord> {
let keyword_results = state.knowledge.search(query, None, limit).await.unwrap_or_default();
if !config.vector_enabled {
return keyword_results;
}
let query_vec = match generate_embedding(state, query, config).await {
Some(v) => v,
None => return keyword_results, // embed 失败降级
};
let vector_results = state.knowledge.search_vector(&query_vec, limit).await.unwrap_or_default();
merge_hybrid_results(keyword_results, vector_results, limit)
}
/// 混合检索三层合并去重(纯函数,抽自 hybrid_search)
///
/// 排序:双信号(LIKE + 向量均命中)> 仅 LIKE 单信号 > 仅向量单信号(且 cos≥0.3)。
/// cos<0.3 的向量单信号结果丢弃防噪音;limit 截断;按 id 去重。
pub(crate) fn merge_hybrid_results(
keyword_results: Vec<KnowledgeRecord>,
vector_results: Vec<(KnowledgeRecord, f32)>,
limit: usize,
) -> Vec<KnowledgeRecord> {
// 合并去重: 双信号 > LIKE 单信号 > 向量单信号(相似度<0.3 的向量结果丢弃防噪音)
let keyword_ids: std::collections::HashSet<String> = keyword_results.iter().map(|r| r.id.clone()).collect();
let mut merged = Vec::new();
let mut seen = std::collections::HashSet::new();
// 1. 双信号
for (rec, score) in &vector_results {
if keyword_ids.contains(&rec.id) && seen.insert(rec.id.clone()) {
tracing::debug!("混合检索双信号: {} (cos={:.2})", rec.title, score);
merged.push(rec.clone());
}
}
// 2. LIKE 单信号
for rec in &keyword_results {
if seen.insert(rec.id.clone()) {
merged.push(rec.clone());
}
}
// 3. 向量单信号(过滤低相似度)
for (rec, score) in &vector_results {
if *score >= 0.3 && seen.insert(rec.id.clone()) {
merged.push(rec.clone());
}
}
merged.truncate(limit);
merged
}
/// 构建知识库上下文片段,拼入 Chat system prompt
///
/// 流程: 开关检查 → 混合检索 top-3(克制) → 命中条目 reuse_count +1 + 记录引用事件(fire-and-forget) → markdown 格式化
/// 关闭时返回空串(零开销);无结果返回空串。
pub(crate) async fn build_knowledge_context(
state: &AppState,
conv_id: &str,
query: &str,
config: &crate::state::KnowledgeConfig,
) -> String {
if !config.auto_inject {
return String::new();
}
let results = hybrid_search(state, query, 3, config).await;
if results.is_empty() {
return String::new();
}
// 命中条目:复用计数 +1 + 记录引用事件(fire-and-forget,单个 spawn 任务批量处理)
let db = state.db.clone();
let ids: Vec<String> = results.iter().map(|r| r.id.clone()).collect();
let conv_id = conv_id.to_string();
let query_clone = query.to_string();
tauri::async_runtime::spawn(async move {
let repo = KnowledgeRepo::new(&db);
let timeline = crate::commands::knowledge_timeline::KnowledgeTimeline::new(&db);
for id in &ids {
if let Err(e) = repo.increment_reuse_count(id).await {
tracing::warn!("reuse_count +1 失败(非阻断): {}", e);
}
timeline.record_referenced(id, &conv_id, &query_clone).await;
}
});
let mut out = String::from("## 相关知识库\n");
for r in &results {
let kind = r.kind.clone();
let title = r.title.clone();
// 截断 content 防膨胀(注入侧最多 500 字符)
let snippet: String = r.content.chars().take(500).collect();
out.push_str(&format!("- [{}] {}: {} (复用 {} 次)\n", kind, title, snippet, r.reuse_count));
}
out
}
/// 判断是否应触发提炼,满足则后台 spawn 提炼 task(非阻断)
///
/// 守卫: auto_extract 开 + trigger_mode == OnComplete + 消息数 ≥ min_messages
pub(crate) async fn maybe_spawn_extraction(
session_arc: &Arc<Mutex<AiSession>>,
db: &Arc<Database>,
conv_id: &str,
provider_config: &AiProviderRecord,
config: &crate::state::KnowledgeConfig,
llm_concurrency: LlmConcurrency,
) -> anyhow::Result<()> {
if !config.auto_extract {
return Ok(());
}
if config.trigger_mode != ExtractTrigger::OnComplete {
return Ok(());
}
// 消息数守卫(总消息数,含 system/assistant/tool)
let msg_count = session_arc.lock().await.messages.len();
if (msg_count as u32) < config.min_messages {
return Ok(());
}
let db = db.clone();
let conv_id = conv_id.to_string();
let provider_config = provider_config.clone();
let llm_concurrency = llm_concurrency.clone();
tauri::async_runtime::spawn(async move {
if let Err(e) = extract_knowledge_from_conversation(&db, &conv_id, &provider_config, &llm_concurrency).await {
tracing::warn!("知识提取失败(非阻断): {}", e);
}
});
Ok(())
}
/// 手动触发提炼(ManualOnly 模式 / 前端按钮调用)
///
/// fire-and-forget:立即返回,后台执行 LLM 提炼(避免 IPC 长时间阻塞)。
pub async fn trigger_extraction_now(state: &AppState) -> Result<bool, String> {
let conv_id = {
let session = state.ai_session.lock().await;
session.active_conversation_id.clone()
};
let conv_id = conv_id.ok_or_else(|| "当前无活跃对话".to_string())?;
let provider_config = super::prompt::get_active_provider(state).await.map_err(|e| e.to_string())?;
let db = state.db.clone();
let llm_concurrency = state.llm_concurrency.clone();
tauri::async_runtime::spawn(async move {
if let Err(e) = extract_knowledge_from_conversation(&db, &conv_id, &provider_config, &llm_concurrency).await {
tracing::warn!("手动提炼失败(非阻断): {}", e);
}
});
Ok(true)
}
/// 知识提炼提示词 — 强制 JSON 输出,含矛盾知识约束
const EXTRACTION_SYSTEM_PROMPT: &str = "你是知识提炼引擎,从 AI 对话中识别可复用的经验。\
只提取真正通用、可被未来对话复用的知识,过滤一次性闲聊/项目特定的临时内容。\n\n\
输出严格的 JSON 数组(不要 markdown 代码块包裹),每个元素 schema:\n\
{\"kind\": \"review_rule|prompt_template|pitfall|architecture_pattern|diagnosis|deployment_note|workflow_optimization\", \
\"title\": \"简短标题\", \"content\": \"完整可复用内容\", \
\"tags\": [\"标签\"], \"confidence\": \"high|medium|low\", \"reasoning\": \"为何值得沉淀\"}\n\n\
规则:\n\
1. 如果适用范围有限制(如仅适用特定语言/框架/场景),必须在 content 或 tags 中明确标注\n\
2. confidence: high=对话中可直接观察的明确模式, medium=合理推断, low=推测性弱信号\n\
3. 无可提炼内容时返回空数组 []\n\
4. 输出纯 JSON,无任何额外文字";
/// 从对话中提炼知识,产出 candidate 写入知识库
///
/// 流程: 读对话消息 → 过滤 user/assistant 取最后 6 条 → LLM 提炼(强制 JSON) → 解析 → 批量插入 candidate
async fn extract_knowledge_from_conversation(
db: &Arc<Database>,
conv_id: &str,
provider_config: &AiProviderRecord,
llm_concurrency: &LlmConcurrency,
) -> anyhow::Result<()> {
let conv_repo = AiConversationRepo::new(db);
let conv = conv_repo
.get_by_id(conv_id)
.await?
.ok_or_else(|| anyhow::anyhow!("对话不存在: {}", conv_id))?;
// 对话标题(生命线溯源用,空标题降级为占位,避免字节切片风险)
let conv_title = conv
.title
.clone()
.filter(|t| !t.trim().is_empty())
.unwrap_or_else(|| "未命名对话".to_string());
let messages: Vec<ChatMessage> = serde_json::from_str(&conv.messages).unwrap_or_default();
// 过滤 user/assistant,取最后 6 条
let recent: Vec<&ChatMessage> = messages
.iter()
.filter(|m| matches!(m.role, MessageRole::User | MessageRole::Assistant))
.rev()
.take(6)
.collect();
if recent.len() < 4 {
return Ok(()); // 太短,不值得提炼
}
// 构造提炼消息: system 指令 + 对话内容(user 角色)
let mut conv_text = String::new();
for m in recent.iter().rev() {
let role = match m.role {
MessageRole::User => "用户",
MessageRole::Assistant => "助手",
_ => continue,
};
conv_text.push_str(&format!("[{}]: {}\n\n", role, m.content));
}
let extract_messages = vec![
ChatMessage::system(EXTRACTION_SYSTEM_PROMPT),
ChatMessage::user(&format!("请从以下对话中提炼可复用知识:\n\n{}", conv_text)),
];
let request = CompletionRequest {
model: provider_config.default_model.clone(),
messages: extract_messages,
temperature: Some(0.3),
max_tokens: Some(2048),
stream: false,
tools: None,
tool_choice: None,
};
let provider: Box<dyn LlmProvider> = df_ai::build_provider(
&provider_config.provider_type,
&provider_config.base_url,
&provider_config.api_key,
&provider_config.default_model,
);
// LLM 并发限流(知识提炼属独立调用,纳入双层 Semaphore
let _global_permit = llm_concurrency.acquire_global().await;
let _per_conv_permit = llm_concurrency.acquire_per_conv().await;
let resp = provider.complete(request).await?;
let raw = resp.text.trim();
// 容错:剥离可能的 ```json ... ``` 包裹
let json_str = strip_code_fence(raw);
let items: Vec<serde_json::Value> = match serde_json::from_str(json_str) {
Ok(v) => v,
Err(e) => {
tracing::warn!("知识提炼 JSON 解析失败,整批丢弃(非阻断): {} | 原始: {}", e, raw);
return Ok(());
}
};
let knowledge_repo = KnowledgeRepo::new(db);
let timeline = crate::commands::knowledge_timeline::KnowledgeTimeline::new(db);
let mut inserted = 0;
for item in &items {
let kind = match item.get("kind").and_then(|v| v.as_str()) {
Some(k) => k.to_string(),
None => continue,
};
let title = item.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string();
let content = item.get("content").and_then(|v| v.as_str()).unwrap_or("").to_string();
if title.is_empty() || content.is_empty() {
continue;
}
let tags = item.get("tags").map(|v| serde_json::to_string(v).unwrap_or_else(|_| "[]".into()));
let confidence = item.get("confidence").and_then(|v| v.as_str()).map(|s| s.to_string());
// 回填 AI 判断依据(prompt 要求的 reasoning 字段,此前被丢弃)
let reasoning = item.get("reasoning").and_then(|v| v.as_str()).map(|s| s.to_string());
let now = crate::commands::now_millis();
let record = KnowledgeRecord {
id: new_id(),
kind,
title,
content,
tags,
status: "candidate".to_string(),
confidence,
reuse_count: 0,
verified: false,
source_project: None,
source_ref: Some(format!("conv:{}", conv_id)),
reasoning: reasoning.clone(),
created_at: now.clone(),
updated_at: now,
};
match knowledge_repo.insert(record.clone()).await {
Ok(_) => {
inserted += 1;
tracing::info!(
"AI 提炼知识候选: {} [confidence={}]",
record.title,
record.confidence.as_deref().unwrap_or("?")
);
// 生命线:AI 提炼产生(fire-and-forget)
timeline
.record_extracted(
&record.id,
conv_id,
&conv_title,
reasoning.as_deref().unwrap_or(""),
)
.await;
}
Err(e) => tracing::warn!("知识候选插入失败(非阻断): {}", e),
}
}
if inserted > 0 {
tracing::info!("知识提炼完成: 对话 {} 产出 {} 条 candidate", conv_id, inserted);
}
Ok(())
}
/// 剥离 LLM 输出可能的 ```json ... ``` 代码块包裹
fn strip_code_fence(s: &str) -> &str {
let s = s.trim();
if let Some(rest) = s.strip_prefix("```json") {
return rest.trim().trim_end_matches("```").trim();
}
if let Some(rest) = s.strip_prefix("```") {
return rest.trim().trim_end_matches("```").trim();
}
s
}
#[cfg(test)]
mod tests {
use super::*;
use df_storage::models::KnowledgeRecord;
// ---------- merge_hybrid_results ----------
fn kr(id: &str, title: &str) -> KnowledgeRecord {
KnowledgeRecord {
id: id.to_string(),
kind: "snippet".to_string(),
title: title.to_string(),
content: String::new(),
tags: None,
status: "published".to_string(),
confidence: None,
reuse_count: 0,
verified: false,
source_project: None,
source_ref: None,
reasoning: None,
created_at: "2026-01-01".to_string(),
updated_at: "2026-01-01".to_string(),
}
}
#[test]
fn merge_empty_inputs() {
let out = merge_hybrid_results(vec![], vec![], 5);
assert!(out.is_empty());
}
#[test]
fn merge_dual_signal_ranks_first() {
// r1 同时命中双信号 → 应排在首位
let kw = vec![kr("r1", "kw1"), kr("r2", "kw2")];
let vec_results = vec![(kr("r1", "kw1-vec"), 0.8)];
let out = merge_hybrid_results(kw, vec_results, 5);
assert_eq!(out.len(), 2);
assert_eq!(out[0].id, "r1", "双信号 r1 必须排首");
assert_eq!(out[1].id, "r2");
}
#[test]
fn merge_keyword_only_after_dual() {
// r2 仅 LIKE,应在双信号之后
let kw = vec![kr("only-kw", "kw-only")];
let vec_results = vec![(kr("dual", "dual-vec"), 0.7)];
// dual 不在 kw 集合 → 非双信号,走向量单信号(0.7≥0.3)
let out = merge_hybrid_results(kw, vec_results, 5);
// 顺序:无双信号 → LIKE 单信号(only-kw)→ 向量单信号(dual)
assert_eq!(out.len(), 2);
assert_eq!(out[0].id, "only-kw");
assert_eq!(out[1].id, "dual");
}
#[test]
fn merge_vector_threshold_filters_below_03() {
// cos=0.29 < 0.3 → 向量单信号结果被滤掉
let kw = vec![];
let vec_results = vec![(kr("low", "low-vec"), 0.29)];
let out = merge_hybrid_results(kw, vec_results, 5);
assert!(out.is_empty(), "cos=0.29 应被过滤");
}
#[test]
fn merge_vector_threshold_keeps_at_031() {
// cos=0.31 ≥ 0.3 → 保留
let kw = vec![];
let vec_results = vec![(kr("ok", "ok-vec"), 0.31)];
let out = merge_hybrid_results(kw, vec_results, 5);
assert_eq!(out.len(), 1);
assert_eq!(out[0].id, "ok");
}
#[test]
fn merge_vector_threshold_boundary_exact_03() {
// 边界:cos 恰好 0.3 → 保留(>= 比较)
let kw = vec![];
let vec_results = vec![(kr("edge", "edge-vec"), 0.3)];
let out = merge_hybrid_results(kw, vec_results, 5);
assert_eq!(out.len(), 1, "cos=0.3 边界应保留(>= 比较)");
assert_eq!(out[0].id, "edge");
}
#[test]
fn merge_truncates_to_limit() {
// limit 截断
let kw: Vec<KnowledgeRecord> = (0..10).map(|i| kr(&format!("k{i}"), "t")).collect();
let out = merge_hybrid_results(kw, vec![], 3);
assert_eq!(out.len(), 3);
}
#[test]
fn merge_dedups_across_signals() {
// 同一 id 多路命中只出现一次(双信号路径优先)
let kw = vec![kr("dup", "dup-kw")];
let vec_results = vec![(kr("dup", "dup-vec"), 0.9), (kr("v2", "v2-vec"), 0.5)];
let out = merge_hybrid_results(kw, vec_results, 5);
assert_eq!(out.len(), 2, "dup 去重只出现一次");
assert_eq!(out[0].id, "dup", "dup 双信号排首");
assert_eq!(out[1].id, "v2");
}
#[test]
fn merge_dual_signal_not_duplicated_in_keyword_pass() {
// 双信号记录已被 seen 标记,LIKE 单信号遍历时不会重复入列
let kw = vec![kr("both", "both-kw"), kr("kwonly", "kwo")];
let vec_results = vec![(kr("both", "both-vec"), 0.6)];
let out = merge_hybrid_results(kw, vec_results, 5);
let both_count = out.iter().filter(|r| r.id == "both").count();
assert_eq!(both_count, 1);
assert_eq!(out.len(), 2);
}
#[test]
fn merge_all_three_signal_types_present() {
// 三类信号齐全:dual(双)+ kw-only(LIKE)+ vec-only(向量)
let kw = vec![kr("dual", "d-kw"), kr("kwonly", "k-kw")];
let vec_results = vec![
(kr("dual", "d-vec"), 0.85),
(kr("veconly", "v-vec"), 0.45),
];
let out = merge_hybrid_results(kw, vec_results, 10);
assert_eq!(out.len(), 3);
// 排序:dual → kwonly → veconly
assert_eq!(out[0].id, "dual");
assert_eq!(out[1].id, "kwonly");
assert_eq!(out[2].id, "veconly");
}
#[test]
fn merge_limit_truncates_after_sorting() {
// 截断发生在排序之后:limit=1 时即便有双信号也只留首条
let kw = vec![kr("kw1", "k1")];
let vec_results = vec![(kr("dual", "dv"), 0.9)];
// dual 不在 kw,故无双信号;顺序 kw1 → dual
let out = merge_hybrid_results(kw, vec_results, 1);
assert_eq!(out.len(), 1);
assert_eq!(out[0].id, "kw1");
}
}

View File

@@ -0,0 +1,146 @@
//! AI 聊天命令模块 — 流式对话、工具调用、审批门控、提供商管理
//!
//! 由原单文件 ai.rs(2663 行)按职责拆分为 11 个子模块。
//!
//! 模块布局:
//! - [`commands`] — 所有 `#[tauri::command]` IPC 函数
//! - [`agentic`] — run_agentic_loop / try_continue_agent_loop / MAX_AGENT_ITERATIONS
//! - [`stream_recv`] — stream_llm 流式接收
//! - [`conversation`] — save_conversation / TokenAccumulator / accumulate_tokens
//! - [`title`] — 对话标题生成
//! - [`audit`] — 工具调用审计 + pending 审批恢复 + 工具调用处理
//! - [`skills`] — 本机 Claude 技能扫描
//! - [`prompt`] — 系统提示词构建
//! - [`tool_registry`] — AI 工具注册表构建 + 文件路径校验
//! - [`knowledge_inject`] — 知识库注入 + 提炼
//!
//! 事件协议:通过 app.emit("ai-chat-event", payload) 流式推送到前端
//! - AiTextDelta: 流式文本片段
//! - AiToolCallStarted/Completed: 工具调用生命周期
//! - AiApprovalRequired: 需要人工审批
//! - AiCompleted/AiError: 完成/错误
pub mod agentic;
pub mod audit;
pub mod commands;
pub mod conversation;
pub mod knowledge_inject;
pub mod prompt;
pub mod skills;
pub mod stream_recv;
pub mod title;
pub mod tool_registry;
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use serde::Serialize;
use df_ai::ai_tools::RiskLevel;
use df_ai::context::ContextManager;
use df_ai::context::ContextConfig;
// ============================================================
// 重导出 — 保路径不变(state.rs / lib.rs / knowledge.rs 在用)
// ============================================================
// commands 子模块(glob 重导出) — `#[tauri::command]` 宏生成的命令函数 +
// 内部符号(__cmd__xxx / __tauri_command_name_xxx)同源同模块定义,
// glob 把它们全部拉到 commands::ai 路径,使 generate_handler! 能解析到。
// 静默 unused_imports:glob 重导出用于跨模块路径解析,本文件不引用这些符号。
#[allow(unused_imports)]
pub use self::commands::*;
// 子模块的非命令 pub 项,逐个保路径
#[allow(unused_imports)]
pub use self::audit::restore_pending_approvals;
#[allow(unused_imports)]
pub use self::knowledge_inject::{spawn_embedding_for_knowledge, trigger_extraction_now};
#[allow(unused_imports)]
pub use self::tool_registry::build_ai_tool_registry;
// ============================================================
// 事件载荷类型(放 mod.rs,各子文件经 use super::* 拿到)
// ============================================================
/// AI 聊天事件(推送到前端)
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type")]
pub enum AiChatEvent {
/// 流式文本片段
AiTextDelta { delta: String, conversation_id: Option<String> },
/// 工具调用开始
AiToolCallStarted { id: String, name: String, args: serde_json::Value, conversation_id: Option<String> },
/// 工具调用完成
AiToolCallCompleted { id: String, result: serde_json::Value, conversation_id: Option<String> },
/// 需要人工审批
AiApprovalRequired { id: String, name: String, args: serde_json::Value, reason: String, conversation_id: Option<String> },
/// 审批结果
AiApprovalResult { id: String, approved: bool, conversation_id: Option<String> },
/// AI 响应完成
AiCompleted { total_tokens: u32, prompt_tokens: u32, completion_tokens: u32, conversation_id: Option<String> },
/// 错误
AiError { error: String, conversation_id: Option<String> },
/// Agent 循环新一轮(前端需新建 assistant 消息)
AiAgentRound { round: u32, conversation_id: Option<String> },
}
// ============================================================
// 会话状态(放 mod.rs,各子文件经 use super::* 拿到)
// ============================================================
/// AI 会话内状态Mutex 保护)
pub struct AiSession {
/// 对话历史ContextManager唯一消息真相源裁剪仅影响发送视图不影响持久化
pub messages: ContextManager,
/// 当前提供商 ID
pub active_provider_id: Option<String>,
/// 当前活跃对话 ID
pub active_conversation_id: Option<String>,
/// 活跃对话创建时间(懒创建:首条消息落库前仅存内存,upsert 时用作 created_at)
pub active_conv_created_at: Option<String>,
/// 挂起的审批tool_call_id → 审批信息)
pub pending_approvals: HashMap<String, PendingApproval>,
/// 是否正在生成
pub generating: bool,
/// 当前 agent 循环的语言设置(用于审批后恢复循环)
pub agent_language: Option<String>,
/// 停止信号ai_chat_stop 置位agentic loop / stream_llm 检测后尽快退出
pub stop_flag: Arc<AtomicBool>,
}
impl AiSession {
pub fn new() -> Self {
Self {
messages: ContextManager::new(ContextConfig::default()),
active_provider_id: None,
active_conversation_id: None,
active_conv_created_at: None,
pending_approvals: HashMap::new(),
generating: false,
agent_language: None,
stop_flag: Arc::new(AtomicBool::new(false)),
}
}
}
/// 待审批的工具调用
#[derive(Debug, Clone)]
pub struct PendingApproval {
pub tool_call_id: String,
pub tool_name: String,
pub arguments: serde_json::Value,
pub risk_level: RiskLevel,
pub conversation_id: Option<String>,
/// 重启恢复的积压审批:无 live loop 持有 session.messages,审批后不 save(防空 messages 污染老对话)、不续跑
pub recovered: bool,
}
/// 工具调用草稿(流式收集时的临时结构)
#[derive(Debug, Clone, Default)]
pub(crate) struct ToolCallDraft {
pub(crate) id: String,
pub(crate) name: String,
pub(crate) args: String,
}

View File

@@ -0,0 +1,86 @@
//! 系统提示词构建 + 活跃提供商获取
use df_storage::models::AiProviderRecord;
use crate::state::AppState;
/// 获取当前活跃提供商配置
pub(crate) async fn get_active_provider(state: &AppState) -> Result<AiProviderRecord, String> {
let session = state.ai_session.lock().await;
if let Some(ref pid) = session.active_provider_id {
let provider = state
.ai_providers
.get_by_id(pid)
.await
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("活跃提供商不存在: {}", pid))?;
Ok(provider)
} else {
// 查找默认提供商
drop(session);
let providers = state
.ai_providers
.list_all()
.await
.map_err(|e| e.to_string())?;
let default = providers.iter().find(|p| p.is_default).cloned();
default
.or_else(|| providers.into_iter().next())
.ok_or_else(|| "未配置 AI 提供商,请先在设置中添加".to_string())
}
}
/// 按语言返回系统提示词的 (固定前缀, 项目上下文标题)
fn system_prompt_parts(lang: &str) -> (&'static str, &'static str) {
match lang {
"en" => (
"You are DevFlow's AI assistant. You help users manage projects, tasks, ideas, and workflows.\n\
Please respond in English.\n\n\
## Capabilities\n\
You can perform the following actions via tool calls:\n\
- Create/query projects, tasks, and ideas\n\
- Run workflows\n\
- Read file contents, list directories, create/write files\n\n\
## Guidelines\n\
- Briefly explain your intent before executing actions\n\
- Ask for clarification if the user's intent is unclear\n\
- Prefer using tools to complete actions rather than just describing steps\n\
- When a tool call fails, clearly tell the user it failed and why. Never disguise a fallback action as the original intent's success (e.g. don't write to description to fake a directory binding), and never falsely report success\n",
"\n## Current Projects\n",
),
_ => (
"你是 DevFlow 的 AI 助手。你帮助用户管理项目、任务、想法和工作流。\n\
必须使用简体中文回复,禁止使用繁体中文字符。\n\n\
## 当前能力\n\
你可以通过工具调用执行以下操作:\n\
- 创建/查询项目、任务、想法\n\
- 运行工作流\n\
- 读取文件内容、列出目录、创建/写入文件\n\n\
## 行为准则\n\
- 执行操作前简要说明你的意图\n\
- 如果不确定用户意图,先提问\n\
- 优先使用工具完成操作,而不是只描述步骤\n\
- 工具调用失败时必须明确告知用户失败原因,严禁用替代操作冒充原意图成功(如绑定目录失败不得改写描述冒充已绑定),也绝不谎报成功\n",
"\n## 当前项目\n",
),
}
}
/// 构建系统提示词(固定前缀 + 当前项目上下文)
pub(crate) async fn build_system_prompt(state: &AppState, lang: &str) -> String {
let (prefix, ctx_label) = system_prompt_parts(lang);
let mut prompt = String::from(prefix);
// 附加当前数据上下文
if let Ok(projects) = state.projects.list_active().await {
if !projects.is_empty() {
prompt.push_str(ctx_label);
// system prompt 前缀克制:仅最近 20 个项目,防 context 膨胀
for p in projects.iter().take(20) {
prompt.push_str(&format!("- {} ({}): {}\n", p.name, p.status, p.description));
}
}
}
prompt
}

View File

@@ -0,0 +1,169 @@
//! 本机 Claude 技能扫描skills / commands / plugins 三类)
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use serde::Serialize;
/// 技能元信息(前端 `/` 联想 + 后端注入用)
#[derive(Debug, Clone, Serialize)]
pub struct SkillInfo {
pub name: String,
pub description: String,
pub argument_hint: Option<String>,
/// skill | command | plugin
pub source: String,
/// SKILL.md 绝对路径(注入时读全文)
pub path: String,
}
/// ~/.claude 目录跨平台USERPROFILE / HOME
fn claude_home() -> Option<PathBuf> {
std::env::var_os("USERPROFILE")
.or_else(|| std::env::var_os("HOME"))
.map(PathBuf::from)
.map(|h| h.join(".claude"))
}
/// 剥离 YAML 标量值两侧的引号(`"..."` / `'...'`),简易 frontmatter 解析用
fn unquote(s: &str) -> &str {
s.strip_prefix('"')
.and_then(|x| x.strip_suffix('"'))
.or_else(|| s.strip_prefix('\'').and_then(|x| x.strip_suffix('\'')))
.unwrap_or(s)
}
/// 解析 markdown frontmatter 的 name / description / argument-hint / user_invocable
/// (简易,按行匹配,容错缩进与 CRLF仅扫描 frontmatter 区段)
fn parse_frontmatter(md: &str) -> Option<(String, String, Option<String>, bool)> {
let mut lines = md.lines();
if lines.next()?.trim() != "---" {
return None;
}
let mut name = None;
let mut desc = None;
let mut hint = None;
let mut invocable = true;
for line in lines {
if line.trim() == "---" {
break;
}
let l = line.trim_start();
if let Some(v) = l.strip_prefix("name:") {
name = Some(unquote(v.trim()).to_string());
} else if let Some(v) = l.strip_prefix("description:") {
desc = Some(unquote(v.trim()).to_string());
} else if let Some(v) = l.strip_prefix("argument-hint:") {
hint = Some(unquote(v.trim()).to_string());
} else if let Some(v) = l.strip_prefix("user_invocable:") {
invocable = v.trim() != "false";
}
}
name.map(|n| (n, desc.unwrap_or_default(), hint, invocable))
}
/// 解析单个 SKILL.md / command md 为 SkillInfo排除 user_invocable: false
fn parse_skill_file(path: &Path, source: &str) -> Option<SkillInfo> {
let md = fs::read_to_string(path).ok()?;
let (name, description, argument_hint, invocable) = parse_frontmatter(&md).unwrap_or_else(|| {
// 无 frontmatter部分 commands用文件名兜底默认可调用
let stem = path
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
(stem, String::new(), None, true)
});
if !invocable {
return None;
}
Some(SkillInfo {
name,
description,
argument_hint,
source: source.to_string(),
path: path.to_string_lossy().to_string(),
})
}
/// 递归收集目录下所有 SKILL.md用于 plugins/marketplaces 多层嵌套)
fn collect_skill_files(dir: &Path, out: &mut Vec<PathBuf>) {
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
let p = entry.path();
if p.is_dir() {
// 跳过依赖/版本目录,避免递归爆炸
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
if name == "node_modules" || name == ".git" {
continue;
}
collect_skill_files(&p, out);
} else if p.file_name().and_then(|n| n.to_str()) == Some("SKILL.md") {
out.push(p);
}
}
}
}
/// 扫描三类来源,按 name 去重skills 优先 > commands > plugins
fn scan_skills() -> Vec<SkillInfo> {
let home = match claude_home() {
Some(h) => h,
None => return Vec::new(),
};
let mut skills = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
// 1. ~/.claude/skills/*/SKILL.md
if let Ok(entries) = fs::read_dir(home.join("skills")) {
for entry in entries.flatten() {
if let Some(info) = parse_skill_file(&entry.path().join("SKILL.md"), "skill") {
if seen.insert(info.name.clone()) {
skills.push(info);
}
}
}
}
// 2. ~/.claude/commands/*.md
if let Ok(entries) = fs::read_dir(home.join("commands")) {
for entry in entries.flatten() {
let p = entry.path();
if p.extension().and_then(|e| e.to_str()) == Some("md") {
if let Some(info) = parse_skill_file(&p, "command") {
if seen.insert(info.name.clone()) {
skills.push(info);
}
}
}
}
}
// 3. ~/.claude/plugins/marketplaces/**/skills/*/SKILL.md递归cache 不在此路径下)
let mut files = Vec::new();
collect_skill_files(&home.join("plugins").join("marketplaces"), &mut files);
for f in files {
if let Some(info) = parse_skill_file(&f, "plugin") {
if seen.insert(info.name.clone()) {
skills.push(info);
}
}
}
skills
}
/// 技能扫描结果缓存(进程内;新增/改动技能需重启生效)
pub(crate) fn skills_cached() -> &'static Vec<SkillInfo> {
static SKILLS: OnceLock<Vec<SkillInfo>> = OnceLock::new();
SKILLS.get_or_init(scan_skills)
}
/// 按 name 读取技能全文(注入 system prompt扫描走缓存仅读单个 SKILL.md
pub(crate) fn read_skill_content(name: &str) -> Option<String> {
skills_cached()
.iter()
.find(|s| s.name == name)
.and_then(|s| fs::read_to_string(&s.path).ok())
}

View File

@@ -0,0 +1,115 @@
//! 流式接收 LLM 响应
use std::collections::HashMap;
use std::sync::atomic::AtomicBool;
use std::time::Duration;
use tauri::{AppHandle, Emitter};
use futures::StreamExt;
use df_ai::provider::{CompletionRequest, LlmProvider};
use super::{AiChatEvent, ToolCallDraft};
/// 流式接收 LLM 响应,返回 (完整文本, 工具调用草稿)
///
/// 三类异常处理:
/// - idle timeout120s 无 chunk判定连接静默断emit AiError 返回 None
/// - 流尽但从未收到 finished 信号判定异常中断emit AiError 返回 None丢弃残缺不当完整入库
/// - 用户停止stop_flagbreak 返回 Some(已收文本),由调用方入库展示后退出
pub(crate) async fn stream_llm(
provider: &dyn LlmProvider,
request: CompletionRequest,
app_handle: &AppHandle,
stop_flag: &AtomicBool,
conv_id: &str,
) -> Option<(String, HashMap<u32, ToolCallDraft>, df_ai::provider::TokenUsage)> {
/// 流式读取空闲超时:超过此时长无任何 chunk 即判定连接已断
const STREAM_IDLE_TIMEOUT: Duration = Duration::from_secs(120);
match provider.stream(request).await {
Ok(mut stream) => {
let mut full_text = String::new();
let mut tool_calls_acc: HashMap<u32, ToolCallDraft> = HashMap::new();
let mut finished_received = false;
let mut stopped = false;
let mut final_usage: Option<df_ai::provider::TokenUsage> = None;
loop {
// 用户主动停止:保留已收文本退出
if stop_flag.load(std::sync::atomic::Ordering::SeqCst) {
stopped = true;
break;
}
// idle timeout 防"连接存活但中途静默"无限 hang
match tokio::time::timeout(STREAM_IDLE_TIMEOUT, stream.next()).await {
Err(_elapsed) => {
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
error: "流式响应超时120 秒无数据,连接可能已断开)".to_string(),
conversation_id: Some(conv_id.to_string()),
});
return None;
}
Ok(None) => break, // 流正常结束
Ok(Some(chunk_result)) => match chunk_result {
Ok(chunk) => {
if !chunk.delta.is_empty() {
full_text.push_str(&chunk.delta);
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiTextDelta {
delta: chunk.delta,
conversation_id: Some(conv_id.to_string()),
});
}
if let Some(tc_deltas) = &chunk.tool_calls {
for tc_delta in tc_deltas {
let draft = tool_calls_acc.entry(tc_delta.index).or_default();
if let Some(id) = &tc_delta.id { draft.id = id.clone(); }
if let Some(name) = &tc_delta.function_name { draft.name.push_str(name); }
if let Some(args) = &tc_delta.function_arguments { draft.args.push_str(args); }
}
}
if let Some(u) = &chunk.usage {
final_usage = Some(u.clone());
}
if chunk.finished {
finished_received = true;
break;
}
}
Err(e) => {
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
error: e.to_string(),
conversation_id: Some(conv_id.to_string()),
});
return None;
}
},
}
}
// 用户停止:已生成文本(可能残缺)交调用方入库展示
if stopped {
return Some((full_text, tool_calls_acc, final_usage.unwrap_or_default()));
}
// 断连检测:流尽但从未收到 finished 信号 = 异常中断,丢弃残缺不当完整入库
if !finished_received && (!full_text.is_empty() || !tool_calls_acc.is_empty()) {
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
error: "流式响应意外中断(未收到完成信号,已丢弃残缺响应)".to_string(),
conversation_id: Some(conv_id.to_string()),
});
return None;
}
Some((full_text, tool_calls_acc, final_usage.unwrap_or_default()))
}
Err(e) => {
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
error: format!("AI 调用失败: {}", e),
conversation_id: Some(conv_id.to_string()),
});
None
}
}
}

View File

@@ -0,0 +1,140 @@
//! 对话标题生成
use std::sync::Arc;
use tauri::{AppHandle, Emitter};
use tokio::sync::Mutex;
use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider, MessageRole};
use df_storage::crud::AiConversationRepo;
use df_storage::db::Database;
use df_storage::models::AiProviderRecord;
use crate::state::LlmConcurrency;
use super::AiSession;
/// 对话完成后按需生成智能标题(仅 title 为空时触发一次,不覆盖用户改名)
///
/// - 已有 title用户改名或已生成→ 跳过
/// - 否则调 LLM 非流式总结生成 ≤15 字标题LLM 失败回退 extract_title 截断
/// - 完成后 emit ai-conversation-changed 通知前端侧栏刷新标题
pub(crate) async fn ensure_conversation_title(
provider_config: &AiProviderRecord,
db: &Arc<Database>,
conv_id: &str,
app_handle: &AppHandle,
session_arc: &Arc<Mutex<AiSession>>,
llm_concurrency: LlmConcurrency,
) {
let conv_repo = AiConversationRepo::new(db);
// 已有标题(用户改名或已生成)→ 不覆盖
if let Ok(Some(rec)) = conv_repo.get_by_id(conv_id).await {
if rec.title.is_some() {
return;
}
}
// 取对话文本(仅 user/assistant跳过 tool 噪音),取前 6 条供 LLM 总结
let (summary_msgs, all_msgs) = {
let session = session_arc.lock().await;
let summary: Vec<ChatMessage> = session.messages.iter()
.filter(|m| matches!(m.role, MessageRole::User | MessageRole::Assistant))
.take(6)
.map(|m| ChatMessage {
role: m.role.clone(),
content: m.content.clone(),
tool_call_id: None,
tool_calls: None,
model: None,
})
.collect();
(summary, session.messages.all_messages_clone())
};
if summary_msgs.is_empty() {
return;
}
// 标题生成是独立一次 LLM 调用,自建 provider便于后台 spawn不借主 loop 的 &dyn LlmProvider
let provider: Box<dyn LlmProvider> = df_ai::build_provider(
&provider_config.provider_type,
&provider_config.base_url,
&provider_config.api_key,
&provider_config.default_model,
);
let title = match generate_title_via_llm(&*provider, &provider_config.default_model, summary_msgs, &llm_concurrency).await {
Some(t) => t,
None => extract_title(&all_msgs).unwrap_or_else(|| "新对话".to_string()),
};
let _ = conv_repo.update_field(conv_id, "title", &title).await;
let _ = app_handle.emit("ai-conversation-changed", ());
}
/// 后台生成对话标题(不阻塞主流程;失败有 extract_title 兜底)
pub(crate) fn spawn_ensure_title(
provider_config: &AiProviderRecord,
db: &Arc<Database>,
conv_id: &str,
app_handle: &AppHandle,
session_arc: &Arc<Mutex<AiSession>>,
llm_concurrency: &LlmConcurrency,
) {
let provider_config = provider_config.clone();
let db = db.clone();
let conv_id = conv_id.to_string();
let app_handle = app_handle.clone();
let session_arc = session_arc.clone();
let llm_concurrency = llm_concurrency.clone();
tauri::async_runtime::spawn(async move {
ensure_conversation_title(&provider_config, &db, &conv_id, &app_handle, &session_arc, llm_concurrency).await;
});
}
/// 调 LLM 非流式生成对话标题
async fn generate_title_via_llm(
provider: &dyn LlmProvider,
model: &str,
msgs: Vec<ChatMessage>,
llm_concurrency: &LlmConcurrency,
) -> Option<String> {
let mut prompt = vec![ChatMessage::system(
"你是标题生成器。根据用户与助手的对话生成一个简短中文标题。要求不超过15字纯文本不加引号不加书名号不加标点不加 emoji只输出标题本身不要任何前缀或解释。"
)];
prompt.extend(msgs);
let request = CompletionRequest {
model: model.to_string(),
messages: prompt,
temperature: Some(0.3),
max_tokens: Some(30),
stream: false,
tools: None,
tool_choice: None,
};
// LLM 并发限流(标题生成属独立调用,纳入双层 Semaphore
let _global_permit = llm_concurrency.acquire_global().await;
let _per_conv_permit = llm_concurrency.acquire_per_conv().await;
let resp = provider.complete(request).await.ok()?;
Some(clean_title(&resp.text))
}
/// 清理 LLM 返回的标题:去首尾引号/书名号/空白/末尾标点,截 15 字,空则兜底"新对话"
fn clean_title(raw: &str) -> String {
let t = raw.trim()
.trim_matches(|c: char| matches!(c, '"' | '\'' | '「' | '」' | '《' | '》' | ' ' | '\n' | '\r'));
let t = t.trim_end_matches(|c: char| matches!(c, '。' | '.' | ',' | '' | '' | '!' | '' | '?' | ':' | ''));
let cleaned: String = t.chars().take(15).collect();
if cleaned.is_empty() { "新对话".to_string() } else { cleaned }
}
/// 从消息历史中提取对话标题(取第一条用户消息前 30 字)
pub(crate) fn extract_title(messages: &[ChatMessage]) -> Option<String> {
messages.iter()
.find(|m| matches!(m.role, MessageRole::User))
.map(|m| {
let mut chars = m.content.chars();
let t: String = chars.by_ref().take(30).collect();
if chars.next().is_some() { format!("{}...", t) } else { t }
})
}

View File

@@ -0,0 +1,394 @@
//! AI 工具注册表构建 + 文件路径校验
use std::path::{Path, PathBuf};
use std::sync::Arc;
use df_ai::ai_tools::{AiToolRegistry, RiskLevel};
use df_storage::db::Database;
use df_storage::models::{ProjectRecord, TaskRecord, IdeaRecord};
use df_core::types::new_id;
use crate::commands::now_millis;
/// 验证文件路径:禁止访问系统敏感目录
fn validate_path(path: &str) -> anyhow::Result<()> {
// 规范化为反斜杠LLM 可能传正斜杠绕过黑名单Windows tokio::fs 两种分隔符都吃)
let normalized = path.replace('/', "\\");
let lower = normalized.to_lowercase();
if lower.contains("..") {
anyhow::bail!("禁止路径遍历 (..)");
}
if lower.contains("\\.ssh")
|| lower.contains("\\.aws")
|| lower.contains("\\.gnupg")
|| lower.contains("\\appdata\\")
|| lower.contains("\\programdata\\")
|| lower.contains("\\windows\\")
|| lower.contains("\\system32\\")
{
anyhow::bail!("禁止访问敏感系统目录");
}
Ok(())
}
/// workspace 根目录(项目根 = src-tauri 上两级,编译期固定)
fn workspace_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(|p| p.parent())
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."))
}
/// 解析文件工具路径:相对路径锚定 workspace_root禁止越出项目目录
///
/// 双层校验:
/// 1. 词法层 starts_with(root)——对不存在路径(write_file 新建文件)兜底防越界
/// 2. canonicalize 层——对存在路径解析符号链接,防 workspace 内 symlink 指向外部的逃逸
/// 仅校验,返回词法 resolved(不含 \\?\ 前缀),保证 read_file 返回的 path 对前端友好
fn resolve_workspace_path(path: &str) -> anyhow::Result<PathBuf> {
validate_path(path)?;
let root = workspace_root();
let resolved = if Path::new(path).is_absolute() {
PathBuf::from(path)
} else {
root.join(path)
};
// 词法层:防明显越界(不存在路径的兜底)
if !resolved.starts_with(&root) {
anyhow::bail!("禁止访问项目目录之外: {}", path);
}
// canonicalize 层:存在路径解析 symlink,防经符号链接逃逸出 workspace
if resolved.exists() {
let canon_root = root.canonicalize()?;
let canon_resolved = resolved.canonicalize()?;
if !canon_resolved.starts_with(&canon_root) {
anyhow::bail!("禁止访问项目目录之外(符号链接逃逸): {}", path);
}
}
Ok(resolved)
}
/// 构建 AI 工具注册表 — handler 即唯一执行路径schema+risk+实现同源,消除双轨)
///
/// CRUD 工具闭包捕获 `db` Arc 重建 Repo文件系统工具复用 resolve_workspace_path /
/// list_dir_recursive。新增工具只改这里一处定义与实现同源编译期保证一致。
pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
let mut registry = AiToolRegistry::new();
// ── 只读 (Low) ──
registry.register(
"list_projects", "列出所有项目返回项目列表ID、名称、状态、描述",
df_ai::ai_tools::object_schema(vec![]), RiskLevel::Low,
{ let db = db.clone(); Box::new(move |_args: serde_json::Value| {
let db = db.clone();
Box::pin(async move {
let repo = df_storage::crud::ProjectRepo::new(&db);
let mut items = repo.list_all().await?;
items.truncate(50); // 防 LLM context 膨胀
Ok(serde_json::to_value(items)?)
})
})},
);
registry.register(
"list_tasks", "列出任务,可按 project_id 筛选",
df_ai::ai_tools::object_schema(vec![("project_id", "string", false)]), RiskLevel::Low,
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
let db = db.clone();
Box::pin(async move {
let repo = df_storage::crud::TaskRepo::new(&db);
let mut tasks = if let Some(pid) = args.get("project_id").and_then(|v| v.as_str()) {
repo.query("project_id", pid).await?
} else {
repo.list_all().await?
};
tasks.truncate(50); // 防 LLM context 膨胀
Ok(serde_json::to_value(tasks)?)
})
})},
);
registry.register(
"list_ideas", "列出所有想法",
df_ai::ai_tools::object_schema(vec![]), RiskLevel::Low,
{ let db = db.clone(); Box::new(move |_args: serde_json::Value| {
let db = db.clone();
Box::pin(async move {
let repo = df_storage::crud::IdeaRepo::new(&db);
let mut items = repo.list_all().await?;
items.truncate(50); // 防 LLM context 膨胀
Ok(serde_json::to_value(items)?)
})
})},
);
// ── 创建 (Medium) ──
registry.register(
"update_project", "更新项目的指定字段name/status/description/path/stack需要提供项目 ID、字段名和新值。绑定代码目录推荐改用 bind_directory",
df_ai::ai_tools::object_schema(vec![("id", "string", true), ("field", "string", true), ("value", "string", true)]),
RiskLevel::Medium,
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
let db = db.clone();
Box::pin(async move {
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
let field = args["field"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 field"))?;
let value = args["value"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 value"))?;
match field { "name" | "status" | "description" | "path" | "stack" => {}, _ => anyhow::bail!("不允许更新字段 '{}'", field) }
let repo = df_storage::crud::ProjectRepo::new(&db);
repo.update_field(id, field, value).await?;
Ok(serde_json::json!({ "id": id, "field": field, "updated": true }))
})
})},
);
registry.register(
"create_project", "创建新项目",
df_ai::ai_tools::object_schema(vec![("name", "string", true), ("description", "string", false)]),
RiskLevel::Medium,
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
let db = db.clone();
Box::pin(async move {
let name = args["name"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 name 参数"))?;
let description = args["description"].as_str().unwrap_or("");
let repo = df_storage::crud::ProjectRepo::new(&db);
let record = ProjectRecord {
id: new_id(), name: name.to_string(), description: description.to_string(),
status: "planning".to_string(), idea_id: None,
path: None, stack: None,
created_at: now_millis(), updated_at: now_millis(),
};
let id = record.id.clone();
repo.insert(record).await?;
Ok(serde_json::json!({ "id": id, "name": name, "status": "planning" }))
})
})},
);
registry.register(
"bind_directory", "为项目绑定代码目录(自动探测技术栈,防重复绑定)",
df_ai::ai_tools::object_schema(vec![("id", "string", true), ("path", "string", true)]),
RiskLevel::Medium,
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
let db = db.clone();
Box::pin(async move {
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id 参数"))?;
let path = args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?;
let dir = std::path::Path::new(path);
if !dir.is_dir() {
anyhow::bail!("目录不存在: {path}");
}
let repo = df_storage::crud::ProjectRepo::new(&db);
// 防重复:canonicalize 规范化比较,防路径写法差异绕过
let normalize = |s: &str| -> String {
std::path::Path::new(s)
.canonicalize()
.map(|a| a.to_string_lossy().replace('\\', "/").to_lowercase())
.unwrap_or_else(|_| s.trim_end_matches(['\\', '/']).replace('\\', "/").to_lowercase())
};
let target = normalize(path);
let projects = repo.list_active().await?;
for proj in &projects {
if proj.id != id {
if let Some(pp) = &proj.path {
if normalize(pp) == target {
anyhow::bail!("目录已被项目「{}」绑定", proj.name);
}
}
}
}
// 探测技术栈
let stack = df_project::scan::detect_stack(dir)?;
let stack_json = serde_json::to_string(&stack)?;
repo.update_field(id, "path", path).await?;
repo.update_field(id, "stack", &stack_json).await?;
Ok(serde_json::json!({ "id": id, "path": path, "stack": stack, "bound": true }))
})
})},
);
registry.register(
"create_task", "在指定项目下创建新任务",
df_ai::ai_tools::object_schema(vec![("project_id", "string", true), ("title", "string", true), ("description", "string", false), ("priority", "integer", false)]),
RiskLevel::Medium,
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
let db = db.clone();
Box::pin(async move {
let project_id = args["project_id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 project_id"))?;
let title = args["title"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 title"))?;
let repo = df_storage::crud::TaskRepo::new(&db);
let record = TaskRecord {
id: new_id(), project_id: project_id.to_string(), title: title.to_string(),
description: args["description"].as_str().unwrap_or("").to_string(),
status: "todo".to_string(), priority: args["priority"].as_i64().unwrap_or(2) as i32,
branch_name: None, assignee: None, workflow_def_id: None, base_branch: None,
created_at: now_millis(), updated_at: now_millis(),
};
let id = record.id.clone();
repo.insert(record).await?;
Ok(serde_json::json!({ "id": id, "title": title, "status": "todo" }))
})
})},
);
registry.register(
"create_idea", "捕获一个新想法",
df_ai::ai_tools::object_schema(vec![("title", "string", true), ("description", "string", false), ("tags", "string", false), ("source", "string", false)]),
RiskLevel::Medium,
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
let db = db.clone();
Box::pin(async move {
let title = args["title"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 title"))?;
let repo = df_storage::crud::IdeaRepo::new(&db);
let record = IdeaRecord {
id: new_id(), title: title.to_string(),
description: args["description"].as_str().unwrap_or("").to_string(),
status: "draft".to_string(), priority: args["priority"].as_i64().unwrap_or(1) as i32,
score: None, tags: args["tags"].as_str().map(|s| s.to_string()),
source: args["source"].as_str().map(|s| s.to_string()),
promoted_to: None, ai_analysis: None, scores: None,
created_at: now_millis(), updated_at: now_millis(),
};
let id = record.id.clone();
repo.insert(record).await?;
Ok(serde_json::json!({ "id": id, "title": title, "status": "draft" }))
})
})},
);
// ── 高风险 (High) ──
registry.register(
"delete_project", "删除项目及其所有关联数据",
df_ai::ai_tools::object_schema(vec![("id", "string", true)]), RiskLevel::High,
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
let db = db.clone();
Box::pin(async move {
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
let repo = df_storage::crud::ProjectRepo::new(&db);
let deleted = repo.delete(id).await?;
Ok(serde_json::json!({ "deleted": deleted, "id": id }))
})
})},
);
registry.register(
"run_workflow", "运行指定的工作流 DAG",
df_ai::ai_tools::object_schema(vec![("name", "string", true), ("dag", "object", true)]), RiskLevel::High,
Box::new(|_args: serde_json::Value| Box::pin(async move {
// run_workflow 需完整 DAG 执行,返回提示由前端触发
Ok(serde_json::json!({ "note": "请通过工作流页面运行工作流", "tool": "run_workflow" }))
})),
);
// ── 文件系统 ──
registry.register(
"read_file", "读取文件内容,返回文本内容。支持 offset 和 limit 参数分页读取大文件",
df_ai::ai_tools::object_schema(vec![("path", "string", true), ("offset", "integer", false), ("limit", "integer", false)]),
RiskLevel::Low,
Box::new(|args: serde_json::Value| Box::pin(async move {
let resolved = resolve_workspace_path(
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
)?;
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
let metadata = tokio::fs::metadata(path).await
.map_err(|e| anyhow::anyhow!("无法访问文件 {}: {}", path, e))?;
if metadata.len() > 1_048_576 {
anyhow::bail!("文件超过 1MB 限制 ({} 字节)", metadata.len());
}
let content = tokio::fs::read_to_string(path).await
.map_err(|e| anyhow::anyhow!("读取文件失败: {}", e))?;
let result = if let Some(offset) = args["offset"].as_u64() {
let lines: Vec<&str> = content.lines().collect();
let skip = offset as usize;
let limit = args["limit"].as_u64().unwrap_or(200) as usize;
lines.into_iter().skip(skip).take(limit).collect::<Vec<&str>>().join("\n")
} else {
content.clone()
};
let line_count = content.lines().count();
Ok(serde_json::json!({ "path": path, "content": result, "size": metadata.len(), "lines": line_count }))
})),
);
registry.register(
"list_directory", "列出目录内容,返回文件和子目录列表(名称、类型、大小)",
df_ai::ai_tools::object_schema(vec![("path", "string", true), ("recursive", "boolean", false), ("skip_noise_dirs", "boolean", false)]),
RiskLevel::Low,
Box::new(|args: serde_json::Value| Box::pin(async move {
let resolved = resolve_workspace_path(
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
)?;
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
let recursive = args["recursive"].as_bool().unwrap_or(false);
let skip_noise = args["skip_noise_dirs"].as_bool().unwrap_or(true);
let mut entries = Vec::new();
let truncated = list_dir_recursive(path, recursive, 0, 2, 1000, skip_noise, &mut entries).await?;
Ok(serde_json::json!({ "path": path, "entries": entries, "truncated": truncated }))
})),
);
registry.register(
"write_file", "写入或创建文件,自动创建不存在的父目录",
df_ai::ai_tools::object_schema(vec![("path", "string", true), ("content", "string", true)]),
RiskLevel::Medium,
Box::new(|args: serde_json::Value| Box::pin(async move {
let resolved = resolve_workspace_path(
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
)?;
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
let content = args["content"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 content 参数"))?;
if let Some(parent) = std::path::Path::new(path).parent() {
tokio::fs::create_dir_all(parent).await
.map_err(|e| anyhow::anyhow!("创建目录失败: {}", e))?;
}
tokio::fs::write(path, content).await
.map_err(|e| anyhow::anyhow!("写入文件失败: {}", e))?;
Ok(serde_json::json!({ "path": path, "bytes_written": content.len() }))
})),
);
registry
}
/// 递归列出目录内容(最多 max_depth 层,最多 max_entries 条)
///
/// - 噪音目录(`.git`/`node_modules`/`target` 等)会被列出(显示存在),但不深入其内部
/// - 达 `max_entries` 即停止,返回 `Ok(true)` 表示被截断
fn list_dir_recursive<'a>(
path: &'a str,
recursive: bool,
depth: usize,
max_depth: usize,
max_entries: usize,
skip_noise: bool,
result: &'a mut Vec<serde_json::Value>,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Result<bool>> + Send + 'a>> {
Box::pin(async move {
let mut dir = tokio::fs::read_dir(path).await
.map_err(|e| anyhow::anyhow!("无法读取目录 {}: {}", path, e))?;
while let Some(entry) = dir.next_entry().await? {
if result.len() >= max_entries {
return Ok(true); // 达上限截断
}
let name = entry.file_name().to_string_lossy().to_string();
let metadata = entry.metadata().await?;
let is_dir = metadata.is_dir();
result.push(serde_json::json!({
"name": name,
"type": if is_dir { "directory" } else { "file" },
"size": metadata.len(),
"depth": depth,
}));
// 仅递归非噪音目录(skip_noise=true 时跳过 .git/node_modules/target 等)
if recursive && is_dir && depth < max_depth && !(skip_noise && is_noise_dir(&name)) {
let child_path = std::path::Path::new(path).join(&name).to_string_lossy().into_owned();
let truncated = list_dir_recursive(&child_path, true, depth + 1, max_depth, max_entries, skip_noise, result).await?;
if truncated {
return Ok(true);
}
}
}
Ok(false)
})
}
/// 判断是否为不应深入递归的噪音目录(构建产物/依赖/缓存等)
fn is_noise_dir(name: &str) -> bool {
const NOISE_DIRS: &[&str] = &[
".git", "node_modules", "target", "dist", "build",
".next", ".cache", "__pycache__", ".venv", "venv", ".idea",
];
NOISE_DIRS.contains(&name)
}

View File

@@ -3,8 +3,9 @@
use serde::Deserialize; use serde::Deserialize;
use tauri::State; use tauri::State;
use df_core::types::new_id; use df_core::types::{new_id, Priority};
use df_storage::models::IdeaRecord; use df_ideas::capture::Idea;
use df_storage::models::{IdeaRecord, ProjectRecord};
use crate::state::AppState; use crate::state::AppState;
@@ -27,10 +28,16 @@ fn default_priority() -> i32 {
1 1
} }
/// 列出全部想法 /// 列出想法,可选按 status 过滤(指定状态走 query 走白名单索引列,否则全量)
#[tauri::command] #[tauri::command]
pub async fn list_ideas(state: State<'_, AppState>) -> Result<Vec<IdeaRecord>, String> { pub async fn list_ideas(
state.ideas.list_all().await.map_err(|e| e.to_string()) state: State<'_, AppState>,
status: Option<String>,
) -> Result<Vec<IdeaRecord>, String> {
match status {
Some(s) => state.ideas.query("status", &s).await.map_err(|e| e.to_string()),
None => state.ideas.list_all().await.map_err(|e| e.to_string()),
}
} }
/// 创建想法,返回完整记录 /// 创建想法,返回完整记录
@@ -83,3 +90,216 @@ pub async fn update_idea(
pub async fn delete_idea(state: State<'_, AppState>, id: String) -> Result<bool, String> { pub async fn delete_idea(state: State<'_, AppState>, id: String) -> Result<bool, String> {
state.ideas.delete(&id).await.map_err(|e| e.to_string()) state.ideas.delete(&id).await.map_err(|e| e.to_string())
} }
/// 将想法晋升为项目 — 复用 df-project 领域逻辑创建项目,回写想法 status=promoted/promoted_to
#[tauri::command]
pub async fn promote_idea(
state: State<'_, AppState>,
id: String,
) -> Result<df_ideas::promotion::PromotionResult, String> {
let record = state
.ideas
.get_by_id(&id)
.await
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("想法不存在: {id}"))?;
if record.promoted_to.is_some() {
return Err(format!("想法已立项: {}", record.promoted_to.unwrap()));
}
// 复用 df-project 领域逻辑构造项目实体create_from_idea
let project = df_project::manager::ProjectManager::create_from_idea(
record.title.clone(),
record.description.clone(),
id.clone(),
);
let project_id = project.id.clone();
let now = now_millis();
let project_record = ProjectRecord {
id: project_id.clone(),
name: project.name,
description: project.description,
status: "planning".to_string(),
idea_id: Some(id.clone()),
path: None,
stack: None,
created_at: now.clone(),
updated_at: now.clone(),
};
state
.projects
.insert(project_record)
.await
.map_err(|e| e.to_string())?;
// 回写想法status=promoted + promoted_toupdate_full 单事务覆盖可变字段)
// 补偿删除:第二步失败时回滚第一步已建的 project,保证最终一致性(非原子,但防项目存留而
// 想法状态未变的数据不一致)。Repository 方法各自持锁不支持跨 repo 共享事务对象,故选补偿
// 删除而非真事务(改动最小,工程投入产出比最高)。
let updated = IdeaRecord {
status: "promoted".to_string(),
promoted_to: Some(project_id.clone()),
updated_at: now,
..record
};
if let Err(e) = state.ideas.update_full(&updated).await {
// 回写失败:补偿删除已建项目,避免悬空项目(idea.promoted_to 仍空,可重试立项)
tracing::error!("想法 {id} 回写失败,补偿删除已建项目 {project_id}: {e}");
if let Err(del_err) = state.projects.delete(&project_id).await {
tracing::error!("补偿删除项目 {project_id} 也失败(需人工清理): {del_err}");
}
return Err(format!("想法立项回写失败(已回滚项目创建): {}", e));
}
Ok(df_ideas::promotion::PromotionResult {
idea_id: id,
project_id: project_id,
promoted: true,
reason: "手动立项".to_string(),
})
}
// ============================================================
// 想法评估 — 多维评分 + 对抗式评估
// ============================================================
/// 评估想法:多维评分 + 对抗式评估,结果写回 scores/score/ai_analysis状态置 pending_review返回更新后的记录
#[tauri::command]
pub async fn evaluate_idea(
state: State<'_, AppState>,
id: String,
) -> Result<IdeaRecord, String> {
// 取出想法
let record = state
.ideas
.get_by_id(&id)
.await
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("想法不存在: {id}"))?;
let idea = record_to_idea(&record);
// 多维评分0-10IPC 层 *10 缩放为 0-100
let scores = df_ideas::scoring::ScoringEngine::compute_default(&idea);
// 对抗式评估
let eval = df_ideas::adversarial::AdversarialEngine::evaluate(&idea)
.await
.map_err(|e| e.to_string())?;
// 组装前端扁平结构(与 Ideas.vue 的 AdversarialEval interface 对齐)
let positive_strength = eval.positive.confidence;
let negative_strength = eval.negative.confidence;
let net_sentiment = positive_strength - negative_strength;
let recommendation = recommendation_str(&eval.recommendation).to_string();
let final_score = eval.final_score;
let analyst_summary = eval.analyst.summary.clone();
let action_items = action_items_for(&eval.recommendation);
let positive = serde_json::json!({
"thesis": eval.positive.thesis,
"evidence": eval.positive.evidence,
});
let negative = serde_json::json!({
"thesis": eval.negative.thesis,
"evidence": eval.negative.evidence,
});
let ai_analysis = serde_json::json!({
"positive_strength": positive_strength,
"negative_strength": negative_strength,
"net_sentiment": net_sentiment,
"recommendation": recommendation,
"final_score": final_score,
"summary": analyst_summary,
"action_items": action_items,
"positive": positive,
"negative": negative,
"analyst": { "summary": analyst_summary },
})
.to_string();
// scores JSON中文维度 key + 0-100 值(前端雷达图直接当百分比用)
let scores_json = serde_json::json!({
"可行性": (scores.feasibility * 10.0).round() as i64,
"影响力": (scores.impact * 10.0).round() as i64,
"紧急度": (scores.urgency * 10.0).round() as i64,
"综合": (scores.overall * 10.0).round() as i64,
})
.to_string();
let score_value = (scores.overall * 10.0).round() as i64;
// 构造完整记录后单次原子写回update_full 保留 id 与 created_at
let updated = IdeaRecord {
scores: Some(scores_json),
ai_analysis: Some(ai_analysis),
score: Some(score_value as f64),
status: "pending_review".to_string(),
updated_at: now_millis(),
..record
};
state
.ideas
.update_full(&updated)
.await
.map_err(|e| e.to_string())?;
Ok(updated)
}
/// IdeaRecord → df_ideas::Idea评估用status/time 不影响评分)
fn record_to_idea(record: &IdeaRecord) -> Idea {
let tags: Vec<String> = record
.tags
.as_deref()
.and_then(|t| serde_json::from_str(t).ok())
.unwrap_or_default();
Idea {
id: record.id.clone(),
title: record.title.clone(),
description: record.description.clone(),
status: df_core::types::IdeaStatus::Draft,
priority: priority_from_i32(record.priority),
scores: None,
tags,
source: record.source.clone(),
related_ids: Vec::new(),
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
}
}
/// i32 优先级 → Priority 枚举(与 df-core 枚举值一致Low=0/Medium=1/High=2/Critical=3
fn priority_from_i32(p: i32) -> Priority {
match p {
0 => Priority::Low,
2 => Priority::High,
x if x >= 3 => Priority::Critical,
_ => Priority::Medium,
}
}
/// Recommendation → 前端 assessmentLabel 期望的全小写空格分隔(匹配 map key
fn recommendation_str(r: &df_ideas::adversarial::Recommendation) -> &'static str {
use df_ideas::adversarial::Recommendation::*;
match r {
ImmediateAction => "immediate action",
Soon => "soon",
WithResources => "with resources",
ResearchMore => "research more",
Monitor => "monitor",
}
}
/// 行动建议 — 按推荐等级返回
fn action_items_for(r: &df_ideas::adversarial::Recommendation) -> Vec<String> {
use df_ideas::adversarial::Recommendation::*;
match r {
ImmediateAction => vec!["立即组建项目团队".into(), "制定详细执行计划".into(), "分配必要资源".into()],
Soon => vec!["下周启动项目".into(), "准备资源需求".into(), "制定时间表".into()],
WithResources => vec!["确认资源预算".into(), "评估 ROI".into(), "制定风险预案".into()],
ResearchMore => vec!["进行市场调研".into(), "收集用户反馈".into(), "验证技术可行性".into()],
Monitor => vec!["持续跟踪相关指标".into(), "定期评估进展".into(), "等待更好时机".into()],
}
}

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