新增: 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:
@@ -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
227
Cargo.lock
generated
@@ -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"
|
||||||
|
|||||||
406
PROGRESS.md
406
PROGRESS.md
@@ -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 + KnowledgeRepo(search/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-ai,Sprint 7)真实可用,human_node 半实现(审批响应 TODO),其余骨架 |
|
||||||
| df-plugin | 4 | 255 | 158 | ⚡ 混合 — Host/Builder 有逻辑,核心加载 骨架 |
|
| **合计** | **53** | **~7,400** | **~4,800** | 已删除 5 个零引用 crate(2026-06-14,原 78/9100/5900) |
|
||||||
| df-evolve | 6 | 301 | 172 | ⚡ 混合 — Knowledge/PromptTemplate 可用,引擎/提取器 骨架 |
|
|
||||||
| df-nodes | 9 | 344 | 277 | ⚡ 混合 — script_node 真实可用(接 Shell),human_node 半实现(审批响应 TODO),其余 6 种骨架 |
|
|
||||||
| df-stages | 6 | 287 | 209 | 🔩 全骨架 — 11个阶段节点 Schema 完整,execute() 全空 |
|
|
||||||
| **合计** | **71** | **~4,108** | **~2,625** | |
|
|
||||||
|
|
||||||
### 前端 (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 但**无 action,View 未引用** |
|
| 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.ts(Sprint 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 双协议真实 HTTP,Sprint 5/8)
|
||||||
df-storage → 所有 crate 的 CRUD 操作
|
~~df-storage → 所有 crate 的 CRUD 操作~~ ✅ 已打通(impl_repo! 宏 CRUD + 多 Repo,Sprint 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-ai:config 驱动 provider(base_url/api_key/model/prompt)→ OpenAICompatProvider.complete → 输出 text/model/usage;注册 "ai" 节点;未端到端实测 |
|
| AI Node 实现 | ✅ | df-nodes 接 df-ai:config 驱动 provider(base_url/api_key/model/prompt)→ OpenAICompatProvider.complete → 输出 text/model/usage;注册 "ai" 节点;未端到端实测 |
|
||||||
| **AI Chat LLM 优化** | 🚧 P0 完成 | 任务 #43:P0 可靠性(超时/断连/停止生成)✅;P1 token 控制(usage采集/滑动窗口/摘要)⬜ | |
|
| **AI Chat LLM 优化** | ✅ ABCD 完成 | 任务 #43:P0 可靠性 ✅;P1 上下文窗口(ContextManager 分组滑窗)✅ + 并行化(工具 join_all / save 后台)✅;**Sprint 14 流式 token 用量记录(usage 解析 + 累加落库 + 展示 + Settings 开关)✅**;Part C 并发 Semaphore(双层 + 运行时可调)+ Part D Settings UI ✅(Sprint 16);Review 两轮 8 修复 ✅ | |
|
||||||
|
|
||||||
### Phase 3 — 想法池 + 多项目 (3-4 周) | ⬜ 未开始
|
### Phase 3 — 想法池 + 多项目 (3-4 周) | 🚧 进行中 — Sprint 9 评估闭环已通(启发式评分 + 对抗评估接 IPC),晋升前端层闭环,多项目/关联图待续;Sprint 12 全面审查修 5 项前端 bug(置信度条/状态选择/删除确认等),死代码组缓另会话重审
|
||||||
### Phase 4 — 节点丰富 + 阶段插件 (3-4 周) | ⬜ 未开始
|
### Phase 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 此前为孤儿 crate,devflow 未依赖、零 IPC 接入)
|
||||||
|
- **evaluate_idea IPC**(commands/idea.rs):取 IdeaRecord → record_to_idea 转 df-ideas::Idea → ScoringEngine + AdversarialEngine.evaluate → 组装前端扁平结构(对齐 Ideas.vue AdversarialEval interface)→ 写回 scores(中文维度 0-100)/score(0-100)/ai_analysis/status=pending_review → 返回更新记录;lib.rs 注册
|
||||||
|
- **前端接真实评估**:api/idea.ts +evaluate;stores/project.ts +evaluateIdea action;Ideas.vue 删 mockEval 调真实 + evaluating loading 态
|
||||||
|
- **审核修 2 bug**:① recommendation 大小写(后端原 "With Resources" 与前端 assessmentLabel 全小写 map key 不匹配致中文标签不显示)→ 全小写空格;② assessmentClass 生成的 class 与 CSS 类名不一致致 badge 无色 → map 映射到 .immediate/.soon/.conditional/.revised/.defer/.cancel
|
||||||
|
|
||||||
|
**代码变更**:df-ideas/{scoring,adversarial}.rs、src-tauri/{Cargo.toml,commands/idea.rs,lib.rs}、前端 {api/idea.ts,stores/project.ts,views/Ideas.vue}(8 文件)
|
||||||
|
|
||||||
|
**验证**:cargo check -p df-ideas ✓、cargo check -p devflow ✓、bun run build ✓(vue-tsc + vite 3.3s)
|
||||||
|
|
||||||
|
**遗留问题 / 下一步**:
|
||||||
|
1. 对抗评估仍启发式,未接 df-ai LlmProvider(设计:LLM 生成正反方论点 + 启发式 fallback)
|
||||||
|
2. promotion.rs do_promote 仍 TODO(晋升走前端 createProject 闭环,不经 df-ideas/promotion)
|
||||||
|
3. evaluate_idea 用 list_all().find() + 5 次 update_field(IdeaRepo 无 get/多字段更新,量小可接受)
|
||||||
|
4. IdeaGraph 关联图零接入(自动关联发现/聚类待续)
|
||||||
|
5. scores JSON 含「综合」维度也被雷达图渲染一行(小瑕疵)
|
||||||
|
|
||||||
|
### [Sprint 10] 2026-06-12 — AiChat 工具卡片自动折叠(卡片级折叠 + 新内容追加自动收起)
|
||||||
|
|
||||||
|
**工作内容**:
|
||||||
|
- **需求**:对话中工具调用卡片(List Projects / List Tasks / Create Idea / List Ideas / 读取文件等)完成后仍整卡铺开,多步调用时界面臃肿。目标——展示更友好紧凑:所有卡片可点击 header 展开/收起,新内容追加时旧卡片自动收起为单行 header(类似 ChatGPT/Cursor 旧 tool call 折叠)
|
||||||
|
- **卡片级折叠**(区别于 read_file 已有的内容级折叠):`.ai-tool-card` 内 body 区(骨架屏/审批/拒绝/file/dir/write/通用结果)包进 `.ai-tool-body`,`v-show` 控制显隐;header 加 `@click` + ▸ 箭头指示器;新增 `expandedCards` Set + `toggleCardExpand` + `cardCollapseClass`,与既有 `expandedTools`(read_file 预览级)职责分离不互相干扰
|
||||||
|
- **自动收起**:deep watch `store.state.messages` + 轻量 JSON snapshot diff 检测新内容(新消息 / toolCall 状态变化 / 文本增长)→ 清除旧 completed/rejected 的展开态,保留 running/pending 活跃卡片;`isFirst` guard 防首次加载/切对话误触发
|
||||||
|
- **running/pending_approval 强制展开**(需看骨架屏与审批按钮);`v-show` 保留 DOM 避免重挂载
|
||||||
|
|
||||||
|
**代码变更**:`src/components/AiChat.vue`(template header/body 包裹 + script 状态与 watcher + style 折叠态),单文件 7 处改动
|
||||||
|
|
||||||
|
**验证**:vue-tsc --noEmit ✓(运行时待 `bun run tauri dev` 实测多步工具调用折叠效果)
|
||||||
|
|
||||||
|
**遗留问题 / 下一步**:
|
||||||
|
1. ~~审查项 ①~~ ✅ 已落地(2026-06-12):`isRunningOrPending(status)` → `shouldKeepOpen(tc)`,`running` / `pending_approval` / `rejected` / `write_file` 强制展开(短结果不折叠、原因/路径不丢失),仅 `read_file` / `list_directory` / 通用 JSON 大体量结果折叠。vue-tsc --noEmit ✓。决策详见 [功能决策记录 - 短结果保持展开](docs/02-架构设计/功能决策记录.md)
|
||||||
|
2. 实测验证:tauri dev 发多步工具调用消息,观察"执行中展开→完成→新内容追加自动收起→点击 header 重展开"
|
||||||
|
|
||||||
|
### [Sprint 11] 2026-06-13 — AI Chat LLM 优化(上下文窗口管理 + 并行化)
|
||||||
|
|
||||||
|
**任务 #43 续**(Part A + B 落地,Part C/D 待续)
|
||||||
|
|
||||||
|
**工作内容**:
|
||||||
|
|
||||||
|
**Part A: 上下文窗口管理(df-ai/context.rs + ai.rs 接线)** — 长对话无限增长致 `context_length_exceeded` 死锁
|
||||||
|
- **A1 ContextManager**(`crates/df-ai/src/context.rs` 新增 ~350 行):TokenEstimator 字符粗估(chars×0.35 + per_message 4 + per_tool_call 30,零依赖,保守 ±15%)+ ContextConfig(max_tokens 128k / output_reserve 8192 / safety 0.85)+ 分组滑动窗口淘汰——淘汰单元保工具调用三元组原子性(ToolCallHead + ToolResultTail* + 紧随文本 Assistant 同进同出),保护区最后 6 条永不裁;裁剪仅影响发送视图(build_for_request 返裁剪 Vec),不影响持久化(all_messages_clone 返全量)。5 单测全绿(短对话无裁剪 / 超预算裁剪保保护区 / 三元组原子性双分支 / replace 重估 token / restore 重建缓存)
|
||||||
|
- **A2 ai.rs 接线**(~15 处):AiSession.messages 类型 `Vec<ChatMessage>` → `ContextManager`;核心 run_agentic_loop 用 `build_for_request(sys_tokens)` 替代全量 `.clone()`;save/title 用 `all_messages_clone()`;switch 用 `restore_from_messages()`;`replace_tool_result` 收敛为 ContextManager 方法(DRY,删自由函数)
|
||||||
|
|
||||||
|
**Part B: 并行化改造(ai.rs)** — 串行瓶颈 + Completed 事件阻塞
|
||||||
|
- **B1 process_tool_calls 并行化**:Low 风险工具 `futures::future::join_all` 并行 execute + 即时 emit Completed,结果串行回填 session.messages(N 个独立工具 ≈ 最慢一个耗时,原 N 倍串行);Med/High 仍串行审批门控。tool_result push 顺序变化无语义影响(LLM 按 tool_call_id 关联,不看绝对位置)
|
||||||
|
- **B2 正常完成后台化**:save+extract+title 打包同一 `tauri::async_runtime::spawn`(非裸 spawn save——保 maybe_spawn_extraction 读已落库消息的顺序依赖);Completed 事件即时下发
|
||||||
|
- **B3 标题后台化**:`ensure_conversation_title` 签名收 `provider_config` 克隆进 task 自建 provider(`&dyn` 非 'static 无法 move),`spawn_ensure_title` 后台(含 2 处 stop 路径)
|
||||||
|
|
||||||
|
**关键取舍**:①打包 spawn 而非裸 spawn save(保 extract 顺序依赖);②并发 upsert 低概率丢少量 token 可接受(非功能错误);③停止路径保持同步 save(避免 stop 后即发新消息的并发 upsert 竞态丢 token)
|
||||||
|
|
||||||
|
**代码变更**:`crates/df-ai/src/context.rs`(新增)、`src-tauri/src/commands/ai.rs`(核心改造)、`docs/02-架构设计/功能决策记录.md`(8 条新决策:Part A 2 + Part B 3 + 既有的)
|
||||||
|
|
||||||
|
**验证**:cargo check -p df-ai ✓(5 单测绿)、cargo check -p devflow ✓(6 既有 warning,无 error,无单测回归)
|
||||||
|
|
||||||
|
**遗留问题 / 下一步**:
|
||||||
|
1. **Part C/D 未做**(todo #48-52 已建)—— Part C 双层 Semaphore(global 默认 3 / per_conv 默认 2)+ 3 LLM 调用点(stream_llm / 标题 / 提炼)加 permit + `ai_set_concurrency_config` command;Part D Settings.vue 两 setting-row + localStorage 持久化 + onMounted 同步
|
||||||
|
2. **实测验证**(P1):tauri dev 验证长对话裁剪行为 + 多工具并行耗时 + Completed 事件即时性
|
||||||
|
|
||||||
|
### [Sprint 12] 2026-06-13 — 灵感模块全面审查 + A 类问题修复
|
||||||
|
|
||||||
|
**工作内容**:
|
||||||
|
|
||||||
|
- **df-ideas 灵感模块全面审查**(前端 Ideas.vue / api / store + IPC idea.rs + crate 7 文件 + df-project/manager + df-storage/crud):按「界面展示 / 交互 / 功能可用性 / 合理性」+「功能架构 / 代码逻辑 / 代码架构」六维逐条核对,共 12 项发现(🔴5 🟡5 ⚪2)。复核确认 12 项全部真实存在、无误判无虚报。
|
||||||
|
- **修复 A 类 5 项**(明确知道怎么做、零争议,全在 Ideas.vue):
|
||||||
|
- ① 置信度进度条不显示填充(`::after` 无 width + 锚点跑到 `.debate-column`)→ 拆 `.confidence-bar`(灰轨)+ `.confidence-fill`(填充层,正绿反红),删失效 `::after`
|
||||||
|
- ② 状态 select 改选不生效(v-model 绑只读 computed 致写失败、回弹并写回旧 status)→ 弃 v-model,改 `:value` + `@change` 读 `event.target.value`
|
||||||
|
- ③ 删 `filterIdeas` 空函数及其 `@input` 绑定(filteredIdeas 本是 computed 自动响应)
|
||||||
|
- ④ 删除想法加 `confirm()` 二次确认
|
||||||
|
- ⑥ 对抗评估标题加「启发式」tag(诚实标注当前为启发式非 LLM,避免「对抗辩论」措辞误导)
|
||||||
|
- **决策**:死代码/双套抽象组(⑦⑧⑨⑩)缓到另一会话,届时重新审核问题真实性;B 类(⑤⑥根治⑪⑫)方案未定,待定方向后再推。
|
||||||
|
|
||||||
|
**代码变更**:`src/views/Ideas.vue`(单文件 9 处:模板 5 + script 3 + CSS 2 段)
|
||||||
|
|
||||||
|
**验证**:`vue-tsc --noEmit` ✓(零错误零输出)
|
||||||
|
|
||||||
|
**遗留问题 / 下一步**:
|
||||||
|
1. **B 类(有必要但方案未定)**:⑤ 立项引导(三种改法改变产品流程语义:放宽显示条件 / 保持 approved 门槛加提示 / 评估达标自动 approved)/ ⑥ 对抗评估接 df-ai LlmProvider 根治(大工程,单独立项;当前 UI「启发式」标记为临时绕过)/ ⑪ 状态常量集中(跨 Rust+TS 双侧,IdeaStatus 枚举已有但 IdeaRecord 用 String 落库)/ ⑫ 实体↔Record 映射(评估为合理分层,建议不改)
|
||||||
|
2. **缓组(另会话重审真实性后再动)**:⑦ df-ideas 死代码空壳(capture/graph/evaluator/promotion;IdeaGraph 关联图是已知下一步增强需求,删/留需定)/ ⑧ 两套 Recommendation 枚举 + 两套评估入口并存(adversarial 6 变体实用 vs evaluator 5 变体仅死代码引用)/ ⑨ PromotionPolicy 三档死枚举 / ⑩ Idea 实体构造分散(record_to_idea 与 IdeaCapture::capture 字段重叠)
|
||||||
|
3. **实测验证**:tauri dev 看置信度条彩色填充 / 状态切换生效 / 删除弹确认 / 评估区「启发式」标
|
||||||
|
|
||||||
|
### [Sprint 13] 2026-06-13 — 文档记录治理体系(路由规范 + decision-record skill + 降频自检 hook)
|
||||||
|
|
||||||
|
**背景**:功能决策/需求散落于对话、PROGRESS、模块文档,缺统一真相源与触发机制。建立「单一真相源 + 自动自检」治理体系。
|
||||||
|
|
||||||
|
**工作内容**:
|
||||||
|
- **文档记录规范**(`docs/02-架构设计/文档记录规范.md` 新建):核心原则(SSOT / 不复制只引用 / 决策与流水分离 / 先主后辅)+ 文档职责矩阵(每类信息唯一主文档)+ 内容路由表 + 更新顺序 + 唯一性记录与检测(记前 grep 查重 / 单向引用 / 定期扫描)+ 与 skill 关系 + 治理体系实现决策(hook 设计取舍)
|
||||||
|
- **功能决策记录扩展**(`docs/02-架构设计/功能决策记录.md`):约定节加需求条目说明;末尾新增「需求与待办」总览(📋 待做需求表 + 需求澄清)——散落各决策条目 📐/🚧 状态的待办汇集为单一清单
|
||||||
|
- **decision-record skill 增强**(`~/.claude/skills/decision-record/SKILL.md`):除「为什么这么定」决策,增记需求细节(待做/规格/澄清);维护规则(记前查重 / 演进 `→` 标注 / 定期压缩防爆炸);原则补「更新优先于新增」
|
||||||
|
- **降频自检 Stop hook**(`~/.claude/hooks/dr-check.sh` + `settings.json` Stop 配置):累计 ≥10 轮 或 (>1 轮 且 距上次 ≥10 分钟) 才注入自检提示触发 skill;纯脚本计数无 API(替代每轮自检,省 ~90% token);`stop_hook_active` guard 防循环;状态按项目隔离(`~/.claude/.dr-state/`,不污染 git)。设计取舍详见 [文档记录规范 - 治理体系实现决策](docs/02-架构设计/文档记录规范.md)
|
||||||
|
- **修 `docs/INDEX.md` 误登记**:PROGRESS 实际在根级,移除 `07-项目管理/` 树下的重复登记行
|
||||||
|
|
||||||
|
**代码变更**:非产品代码——`docs/02-架构设计/{文档记录规范.md,功能决策记录.md,INDEX.md}`、`~/.claude/{skills/decision-record/SKILL.md,hooks/dr-check.sh,settings.json}`
|
||||||
|
|
||||||
|
**验证**:hook 逻辑实测(首战静默初始化 / 2-9 轮静默计数 / 第 10 轮或 ≥10 分钟触发 / 防循环 guard);无 jq 依赖纯脚本
|
||||||
|
|
||||||
|
**遗留问题 / 下一步**:
|
||||||
|
1. hook 实际长期触发频率待观察(10 轮 / 10 分钟阈值是否合理,按需调)
|
||||||
|
2. 决策爆炸后执行首次压缩(> 300 行 / 老 Sprint > 3 / 单域 > 10 条触发归档或拆域)
|
||||||
|
|
||||||
|
### [Sprint 14] 2026-06-13 — 流式 token 用量记录 + Provider 默认态持久化
|
||||||
|
|
||||||
|
**工作内容**:
|
||||||
|
|
||||||
|
**A. 流式 Token 用量获取 + 记录 + 展示 + 设置开关**(跨 df-ai/df-storage/src-tauri/前端,11 文件)— `stream_llm` 全程丢弃 token 计数、`AiCompleted.total_tokens` 恒 0 的旧疾根治(Sprint 6 P1 token 控制中「usage 采集用户叫停」项重启落地):
|
||||||
|
- **Provider 层 usage 解析**:`StreamChunk` 加 `usage: Option<TokenUsage>`;OpenAI 开 `stream_options:{include_usage:true}` 末 chunk 取累计 usage;Anthropic `message_start`/`message_delta` 累积(output_tokens 当累计值覆盖,非增量)
|
||||||
|
- **累加落库**:`ai_conversations` 加 `prompt_tokens`/`completion_tokens` 列(V5 迁移);`save_conversation` upsert 走累加模式(读旧值叠加,非覆盖——保审批暂停→恢复跨 loop 实例总用量正确);`run_agentic_loop` 局部累加器每轮叠加
|
||||||
|
- **前端展示 + 开关**:store 双状态 `lastTokenUsage`(单次)+ `convTokenTotal`(对话累计,切对话从 DB 加载);AI 气泡底部 token 条(默认关闭,`df-show-token-usage` 开关放 Settings)
|
||||||
|
- **model 记录**:消息级 model + 对话级 models 数组(C+B),取 `default_model` 配置值补填不覆盖
|
||||||
|
|
||||||
|
**B. Provider 默认态持久化(is_default 重启丢失 bug 修复)** — 「设为默认」重启后丢失、两条记录都显示「设为默认」按钮的 bug 根治:
|
||||||
|
- 根因:`is_default` 字段恒写 `false` + `ai_set_provider` 只改内存 session 不落库 → 重启 session 清零默认全丢
|
||||||
|
- 修复:`ai_set_provider` 互斥落库(目标 true/其余 false,仅写变化记录);`ai_save_provider` 新建首个自动默认;`ai_list_providers` 直返 DB 值;`active_provider_id` 降为运行时缓存
|
||||||
|
|
||||||
|
**代码变更**:A — df-ai/{provider,openai_compat,anthropic_compat}.rs、df-storage/{models,crud,migrations}.rs、src-tauri/commands/ai.rs、前端 {types,stores/ai,components/AiChat,views/Settings}(11 文件);B — src-tauri/commands/ai.rs(3 函数)
|
||||||
|
|
||||||
|
**验证**:cargo check --workspace ✓(0 error,6 既有 warning)、vue-tsc --noEmit ✓。**未 tauri dev 运行时实测**
|
||||||
|
|
||||||
|
**遗留问题 / 下一步**:
|
||||||
|
1. tauri dev 实测:token 落库 / 切换对话总量加载 / 重启保默认 / 首个自动默认
|
||||||
|
2. `ai_save_provider` get_by_id 双调用(review 🟡①,冗余往返,待合并)
|
||||||
|
3. Settings 页面 UI 重构(Part D,用户说稍后)
|
||||||
|
4. model UI 展示(暂缓)
|
||||||
|
|
||||||
|
### [Sprint 15] 2026-06-13 — 知识库 Tier 1 全栈打通 + Phase 5.5 向量检索
|
||||||
|
|
||||||
|
**工作内容**:
|
||||||
|
|
||||||
|
**Tier 1 知识库双向闭环(AI Chat ↔ 知识库,全栈打通)** — candidate→published 状态机 + AI 提炼 + 手动录入 + 知识注入:
|
||||||
|
|
||||||
|
**A. 存储层(df-storage,V7/V8 迁移)**:
|
||||||
|
- V7 建表 `knowledges`(id/kind/title/content/tags/status/confidence/reuse_count/verified/source_project/source_ref/created_at/updated_at),3 索引
|
||||||
|
- V8 幂等补列 `embedding BLOB`(PRAGMA table_info 探测,同 v4/v5/v6 模式)
|
||||||
|
- `KnowledgeRecord`(models.rs),含 `embedding: Option<Vec<u8>>`
|
||||||
|
- `KnowledgeRepo`(crud.rs):`search()`(LIKE 双分支,按 kind 可选过滤)/ `list_by_status()`(CASE WHEN confidence 语义排序)/ `increment_reuse_count()`(SQL 原子 +1)/ `top_used()`;向量扩展:`set_embedding()` / `search_vector()`(纯 Rust 余弦批量比较,skip 维度不匹配);工具函数:`f32s_to_blob` / `blob_to_f32s` / `cosine_similarity`
|
||||||
|
|
||||||
|
**B. 领域层(df-evolve)**:
|
||||||
|
- `KnowledgeStatus` 枚举(Candidate/PendingReview/Published/Archived,snake_case,`from_str`/`as_str`)
|
||||||
|
- `Confidence` 枚举(High/Medium/Low,同上)
|
||||||
|
- `Knowledge` struct:移除 `effectiveness` 字段,时间字段统一 String 毫秒(对齐全部既有 model)
|
||||||
|
- `pattern.rs`:清理死参数 `result_quality: f32`
|
||||||
|
|
||||||
|
**C. IPC 层(src-tauri/commands/knowledge.rs 新建,11 个 Command)**:
|
||||||
|
- `knowledge_{list,get,search,create,update_status,record_reuse,list_candidates,archive,get_config,save_config,extract_now}`
|
||||||
|
- 状态转换合法矩阵硬编码:candidate→{pending_review|published|archived}; pending_review→{published|archived}; published→{archived}
|
||||||
|
- `knowledge_archive`(软删除,匹配 `ai_conversation_archive` 语义)
|
||||||
|
- 配置读写:`knowledge_{get,save}_config`(读写 `AppState.knowledge_config: Arc<Mutex<KnowledgeConfig>>`)
|
||||||
|
- 手动提炼:`knowledge_extract_now`
|
||||||
|
|
||||||
|
**D. AI 集成(ai.rs,知识注入 + 提炼)**:
|
||||||
|
- `build_knowledge_context()`:`auto_inject` 开关 → `hybrid_search()` top-3 → `increment_reuse_count` fire-and-forget → 注入 system prompt 头部
|
||||||
|
- `extract_knowledge_from_conversation()`:后台 spawn,min_messages≥4 守卫 → 取最后 6 条消息 → LLM JSON 提炼 → 逐条写 candidate;提炼失败仅 warn 不阻断
|
||||||
|
- `maybe_spawn_extraction()`:agentic loop 两处正常退出统一调用(`OnComplete` 默认模式)
|
||||||
|
|
||||||
|
**Phase 5.5 — 向量检索(Settings 开关,默认关闭)**:
|
||||||
|
- `LlmProvider::embed()` trait 方法:默认返回 Err,openai_compat 实现 POST `/v1/embeddings`(按 index 排序)
|
||||||
|
- `KnowledgeConfig` 加三字段:`vector_enabled`(默认 false)/ `embedding_provider_id`(排除 anthropic)/ `embedding_model`
|
||||||
|
- `generate_embedding()`:截断 8000 字 → build_provider → embed()
|
||||||
|
- `spawn_embedding_for_knowledge()`:知识发布时 fire-and-forget 生成嵌入(不在 candidate 阶段浪费调用)
|
||||||
|
- `hybrid_search()`:三层降级链——开关关→LIKE; embed 失败→LIKE; 正常→双信号排序(同时命中>仅 LIKE>仅向量 cos≥0.3);不引入 sqlite-vec(规避 Windows MSVC C 扩展编译风险)
|
||||||
|
- `Settings.vue`:语义检索开关 + v-if 联动 embedding provider 下拉 + 模型名输入
|
||||||
|
|
||||||
|
**E. 前端(TypeScript + Vue)**:
|
||||||
|
- `types.ts`:`KnowledgeRecord` / `CreateKnowledgeInput` / `KnowledgeConfig`(含 vector 三字段)
|
||||||
|
- `api/knowledge.ts`(新建,11 个 invoke 封装)
|
||||||
|
- `stores/knowledge.ts`(完全重写,getter 模式,消除全部 15 条 mock + localStorage)
|
||||||
|
- `Knowledge.vue`(完全重写:知识库/审核收件箱双 Tab + 状态徽章 + confidence 指示器 + 新增对话框 + 候选 badge)
|
||||||
|
- `App.vue`:侧栏候选 badge(`candidateCount` ref,store loadCandidates 后更新)
|
||||||
|
|
||||||
|
**F. 配置设计**(AppState 内存 + IPC,非 Tauri config 插件):
|
||||||
|
- `KnowledgeConfig`(state.rs):auto_extract / trigger_mode / min_messages / idle_timeout_ms / auto_inject / vector_enabled / embedding_provider_id / embedding_model
|
||||||
|
|
||||||
|
**代码变更**:df-storage(models.rs/crud.rs/migrations.rs)/ df-evolve(knowledge.rs/pattern.rs)/ src-tauri(state.rs + commands/knowledge.rs 新建 + commands/ai.rs + lib.rs)/ 前端(api/types.ts + api/knowledge.ts 新建 + api/index.ts + stores/knowledge.ts + views/Knowledge.vue + views/Settings.vue + App.vue)。约 1,400 行 Rust + 700 行 TS。
|
||||||
|
|
||||||
|
**验证**:cargo check 0 error ✓,vue-tsc 0 error ✓。**未 tauri dev 运行时实测**
|
||||||
|
|
||||||
|
**遗留问题 / 下一步**:
|
||||||
|
1. tauri dev 实测 10 项功能验证清单(手动录入 / 审核流 / 归档 / 检索注入 / reuse_count / AI 提炼 OnComplete / AI 提炼 Manual / 配置修改 / 注入开关 / 矛盾场景)
|
||||||
|
2. 向量检索待实测(需配 embedding provider + 开启开关 + 发布 ≥1 条知识后验证混合排序)
|
||||||
|
3. Tier 2 待做:ai_node prompt 注入 / 工作流 NodeFailed→pitfall / 决策记录→architecture_pattern(前置 traceability 持久化)
|
||||||
|
4. 知识库 API 对外暴露(MCP Shell 封装,外部工具 Claude Code/CodeX/Cursor 使用)
|
||||||
|
|
||||||
|
### [Sprint 16] 2026-06-13 — AI Chat LLM 优化收尾(Part C/D + Review 修复) + AI Node 单测
|
||||||
|
|
||||||
|
**工作内容**:
|
||||||
|
- **Part C LLM 并发控制**:`state.rs` 新增 `LlmConcurrency`(双层 `Arc<Mutex<Arc<Semaphore>>>` — 全局默认 3 / 单对话默认 2,运行时替换内层 Arc 重建);`ai.rs` 3 个叶子 LLM 调用点(stream_llm / generate_title / extract_knowledge)加 permit,acquire 顺序固定 global→per_conv 防死锁,stream 后显式 `drop` 不占工具执行槽;新增 `ai_set_concurrency_config` command(运行时调并发上限);`lib.rs` 注册
|
||||||
|
- **Part D Settings UI**:`Settings.vue` 通用设置区加「全局最大并发 / 单对话并发」两 setting-row + 前端 clamp(1-10 / 1-5)+ debounce 300ms + localStorage 持久化 + onMounted 启动同步 + onUnmounted 清 timer
|
||||||
|
- **Review 两轮 8 修复**:首轮 6(① per_conv 单例语义注释 / ② save-extract 并发注释改正 / ③ permit 显式 drop / ④ Settings clamp / ⑤ 合并 to_chat_messages 进 all_messages_clone / ⑥ system prompt 超 budget warn)+ 复审 2(后端 set_concurrency 补 clamp 边界对齐前端 / onUnmounted 清 debounce timer)
|
||||||
|
- **AI Node 单测**:`ai_node.rs` 重构抽 `parse_params` 纯函数(execute 参数解析剥离,单测不 mock LLM 零网络)+ 7 单测(缺参×3 / prompt 上游优先回退 / config 取值 / 可选默认值 / 显式参数解析)
|
||||||
|
- **任务收尾**:#43/#44 → completed(代码 + 单测全绿,GUI 实测转跟踪);新增 #54(GUI 动态实测跟踪任务)
|
||||||
|
|
||||||
|
**代码变更**:
|
||||||
|
- `crates/df-ai/src/context.rs`(Review ⑤⑥ — 合并 to_chat_messages / system prompt 超 budget warn)
|
||||||
|
- `src-tauri/src/state.rs`(LlmConcurrency 双层 Semaphore + per_conv 单例语义注释)
|
||||||
|
- `src-tauri/src/commands/ai.rs`(permit×3 + drop / process_tool_calls join_all 并行 / save 后台 spawn / ai_set_concurrency_config command + clamp)
|
||||||
|
- `src-tauri/src/lib.rs`(注册 ai_set_concurrency_config)
|
||||||
|
- `src/views/Settings.vue`(并发 UI + clamp + debounce + onUnmounted)
|
||||||
|
- `crates/df-nodes/src/ai_node.rs`(parse_params 重构 + 7 单测)
|
||||||
|
- `docs/02-架构设计/功能决策记录.md`(新增「AI Node」域记 parse_params 决策 + per_conv 退化条目)
|
||||||
|
|
||||||
|
**验证**:cargo check 0 error ✓ / df-ai context 5 单测 ✓ / df-nodes ai_node 7 单测 ✓ / vue-tsc 0 error ✓。**未 tauri dev 运行时实测**(转 #54 跟踪)
|
||||||
|
|
||||||
|
**遗留问题 / 下一步**:
|
||||||
|
1. **#54 GUI 动态实测**(用户跑 tauri dev,Claude 核对日志):长对话裁剪(context_trimmed 日志 + DB 全量)/ 多工具并行(Low join_all 时序)/ save 异步不阻塞 Completed / 双层 Semaphore 限流(多对话撞 RPM)/ Settings 并发即时生效 + 刷新保持 / DAG ai 节点真调 LLM
|
||||||
|
2. ~~AI Node 可补 GLM 集成测试~~ ✅ 已补并通过:`glm_live_complete`(`#[ignore]` + env var)真调 GLM glm-4-flash 返回"通过",usage 解析正确(provider 调用层验证;GUI DAG 触发仍属 #54)
|
||||||
|
|
||||||
|
### [Sprint 17] 2026-06-13 — 模块文档↔代码一致性审查(4 文档纠错补全)
|
||||||
|
|
||||||
|
**工作内容**:
|
||||||
|
- **审查范围**:docs/03 模块文档(df-ai / df-storage / df-workflow)+ docs/01 技术文档(SQLite-CRUD 模式)共 4 份 vs 对应 crate 代码,三代理并行审查
|
||||||
|
- **🔴 硬伤 28 全修**:df-ai(LlmProvider trait 签名 / 删 OpenAI 虚构 idle-timeout + finished_received / ContextManager 方法名 replace_tool_result_content + build_for_request 返回元组 / Token 公式 / Anthropic usage take() / 工具归属)/ df-storage(V1 表名 / Repo 表名 releases·workflow_executions / search published 过滤 / increment_reuse_count updated_at / cosine epsilon / blob 截断)/ SQLite-CRUD 整篇过时(表 6→11 / CRUD 待→已 / 时间 INTEGER→TEXT / 写返回 ()→String·bool / 连接池→单连接 / schema.rs→migrations.rs)/ df-workflow(Node trait 按值 + 默认 false + node_type / 文件名 conditions·eventbus / 虚构事件区分已发未发)
|
||||||
|
- **⚪ 虚构 7 全清**:df-workflow(阻塞节点「等待人工操作」标未实现 / 持久化快照删改仅内存态 / 断点续跑补无暂停恢复机制)/ df-ai(conditions.rs 不存在,清状态表 + 决策缺口表虚构引用 / AiToolRegistry「12 工具」夸大改基础设施)
|
||||||
|
- **🟡 核心 8 + 碎琐 10 补全**:df-ai(apply_openai_sse / apply_anthropic_event 纯函数 / ContextManager 6 pub 方法 / Anthropic 事件全集 / 文件结构 router·stream / URL 三分支 / finish=length / max_tokens 4096 / PROTECT_COUNT / budget 公式)/ df-workflow(NodeRegistry / DagDef / StateMachine / Dag API / NodeContext·NodeOutput 字段 / EventBus API / 条件引擎默认放行 warn)/ df-storage(list_non_archived / set_archived 例外对照)
|
||||||
|
|
||||||
|
**代码变更**:无(纯文档纠错,代码未动)
|
||||||
|
|
||||||
|
**验证**:每项修正均经代理 Read 代码复核签名/SQL/行为后改;git diff 4 文档落地(df-ai 443 / df-storage 134 / SQLite-CRUD 56 / df-workflow 122 行变动)
|
||||||
|
|
||||||
|
**遗留问题 / 下一步**:
|
||||||
|
1. 4 文档已与代码实质一致;后续若改 df-ai/df-storage/df-workflow 公开接口,需同步对应模块文档(文档治理:模块文档随代码变)
|
||||||
|
2. df-evolve(knowledge.rs / pattern.rs)本轮未审 — docs/03 无对应模块文档,若补可另起审查
|
||||||
|
|
||||||
|
### [Sprint 18] 2026-06-13 — 知识生命线(详情页 + 溯源 + 引用 + 生命周期)
|
||||||
|
|
||||||
|
**工作内容**:
|
||||||
|
- **根因**:知识库 candidate 卡片点不开详情、不能编辑、看不到"为什么产生";reasoning 字段被 prompt 要求但写库丢弃(bug);increment_reuse_count 只 +1 不记去向;无状态变更历史。用户要求呈现知识完整生命周期:产生→审核→引用→归档,且约束异步不阻塞、代码简洁易扩展。
|
||||||
|
|
||||||
|
**A. 存储层(df-storage,V10 迁移)**:
|
||||||
|
- V10:幂等补 `knowledges.reasoning TEXT` 列 + 新建 `knowledge_events` 审计表(id/knowledge_id/event_type/source_ref/context_json/timestamp + 3 索引)
|
||||||
|
- `KnowledgeRecord` 加 `reasoning: Option<String>`;新增 `KnowledgeEventRecord`(6 字段)
|
||||||
|
- `KnowledgeEventsRepo`(impl_repo! 宏 7 方法 + 扩展 `list_by_knowledge`/`list_by_knowledge_type`);5 处显式 SELECT 列列举 + from_row + insert/update body 全线接 reasoning
|
||||||
|
|
||||||
|
**B. 后端埋点(src-tauri)**:
|
||||||
|
- **新建 `commands/knowledge_timeline.rs`** — `KnowledgeTimeline` 独立记录器(持 Arc<Database>),便捷方法 record_extracted/referenced/status_change/created,内部 `fire()` 吞错误 fire-and-forget。各业务流程只调一行,不内联写 SQL(代码简洁约束)
|
||||||
|
- `extract_knowledge_from_conversation`:回填 reasoning(修 bug)+ 查对话标题 + insert 后记 extracted 事件
|
||||||
|
- `build_knowledge_context`:加 conv_id 参数 + spawn 块内 increment_reuse_count 后记 referenced 事件(conv_id+query);调用点上移 conv_id 快照
|
||||||
|
- `knowledge_create/update_status`:记 created/status_changed 事件(归档复用 status_changed,删 dead code EVENT_ARCHIVED/record_archived)
|
||||||
|
- 3 新 IPC command:`knowledge_get_detail`(聚合 knowledge+events)/`knowledge_update`(部分更新编辑)/`knowledge_events`(查生命线);AppState 加 `knowledge_events` Repo;lib.rs 注册
|
||||||
|
|
||||||
|
**C. 前端**:
|
||||||
|
- types.ts:KnowledgeRecord 加 reasoning + 新增 KnowledgeEventRecord/KnowledgeDetailPayload/UpdateKnowledgeInput
|
||||||
|
- api/store:各加 getDetail/update/getEvents
|
||||||
|
- **Knowledge.vue 全面重构**:Ideas 式左右分栏(左卡片列表 @click 选中 / 右详情四分区 — ①基本信息+编辑 ②溯源(产生方式/来源对话/AI reasoning) ③引用记录(复用次数+最近 N 条) ④生命周期时间线)。可编辑 title/content/tags/confidence
|
||||||
|
|
||||||
|
**关键决策**(详见 docs/02-架构设计/功能决策记录.md):① 独立 events 表非 JSON 嵌主表(引用数百次防行膨胀);② 独立 KnowledgeTimeline 记录器(不散落埋点);③ 归档复用 status_changed(防冗余);④ reasoning 回填(修 prompt 要求但丢弃的 bug)
|
||||||
|
|
||||||
|
**代码变更**:df-storage(migrations/models/crud)+ src-tauri(state/knowledge_timeline 新建/ai/knowledge/lib/mod)+ 前端(types/api/knowledge.ts/stores/knowledge.ts/Knowledge.vue)= 12 文件
|
||||||
|
|
||||||
|
**验证**:cargo check --workspace ✓(仅既有警告)/ vue-tsc --noEmit ✓ 零错误 / cargo test -p df-storage ✓ 21 单测全过。**未 tauri dev 运行时实测**
|
||||||
|
|
||||||
|
**遗留问题 / 下一步**:
|
||||||
|
1. **#54 GUI 实测**:AI 对话结束→提炼→收件箱→点详情看 reasoning+来源;发布→生命线 status_changed;新对话注入→referenced 事件;编辑 candidate→保存
|
||||||
|
2. 老库历史 candidate 无 reasoning/events,详情页降级展示(手动录入/无溯源)——属预期兼容
|
||||||
|
3. knowledge_events 纯追加表,长期可清理 >90 天 referenced 事件(本次不做)
|
||||||
|
|
||||||
|
### [Sprint 19] 2026-06-14 — list_directory 防爆 + localStorage→SQLite 统一持久化 + token/发送 bug 修复
|
||||||
|
|
||||||
|
**工作内容**:用户报多个 AiChat bug + 提需求:list_directory 扫出 13782 项塞爆对话;token 显示"0 in";发送卡住/留框;LLM 并发上限太小;localStorage 散落要统一入 SQLite。同时用户并行主导 ai.rs→ai/ + ai.ts→composables 模块拆分(我观察配合,不接管)。
|
||||||
|
|
||||||
|
**A. list_directory 递归防爆(Part A)**:
|
||||||
|
- `list_dir_recursive`(ai/tool_registry.rs)加噪音目录剪枝(`.git`/`node_modules`/`target`/`dist`/`build`/`.next`/`.cache`/`__pycache__`/`.venv`/`venv`/`.idea`)——**列出但不深入内部**;硬上限 1000 条 + `truncated` 标志;默认 max_depth 3→2;加 `skip_noise_dirs` 参数(默认 true,AI 看编译产物 dist/target 时传 false)
|
||||||
|
- 根因:AI 广扫项目根传 `recursive:true`,噪音目录铺平致 13782 项塞进对话 message(UI 卡 + token 爆)
|
||||||
|
|
||||||
|
**B. localStorage → SQLite 统一持久化(Part B)**:
|
||||||
|
- 新建 `app_settings` KV 表(V13 迁移)+ 手写 `SettingsRepo`(get/set/get_all/delete,不走 `impl_repo!` 宏因 KV 无固定 schema);4 IPC(settings_get/set/get_all/delete);AppState 加 settings 字段
|
||||||
|
- 前端 `appSettings` store(reactive 缓存 + 300ms debounce + `useSetting` ref)+ api 封装
|
||||||
|
- **11 key 迁移**:敏感 `df-connections` + UI 偏好(theme/language/ai-width/ai-ui/token/concurrency)+ `df-ai-active-conv`;**例外 `df-ai-gen`/`df-ai-text`**(流式临时快照,高频写留 localStorage)。App.vue onMounted 一次性迁移旧 localStorage
|
||||||
|
|
||||||
|
**C. Bug 修复**:
|
||||||
|
- **token prompt=0**:GLM 流式 usage 带 completion 不带 prompt → 加估算兜底(provider 返回 0 时按请求消息 `TokenEstimator::estimate_message` 求和)
|
||||||
|
- **发送卡住/留框**:`sendMessage` IPC 失败时无 catch → streaming 永真(光标卡死)+ handleSend 回填输入(消息已显示);改 try/catch 回滚 streaming + 清空气泡占位
|
||||||
|
- **LLM 并发上限**:三层(模板 max / JS Math.min / 后端 .clamp)全去上限,保留 min=1 + `per-conv ≤ global` 约束
|
||||||
|
|
||||||
|
**D. token 暴增诊断 + 清理**:
|
||||||
|
- 查 SQLite 实数据:对话 e4365d47 累积 in=115万 / out=2万 / 消息体 1.6MB
|
||||||
|
- 根因:**单条 list_directory 工具结果 1.2MB**(13782 项,Part A 修复前产生)落库后每轮重发 + save 累加 → 暴增;token 估算准,膨胀是真(非估算锅)
|
||||||
|
- 删除污染对话(`ai_conversations` 1 行 + `ai_tool_executions` 123 行)
|
||||||
|
|
||||||
|
**E. 模块拆分(用户主导,本会话发生,我配合不接管)**:
|
||||||
|
- `ai.rs`(2651 行)→ `ai/` 11 子模块(agentic/audit/commands/conversation/knowledge_inject/mod/prompt/skills/stream_recv/title/tool_registry),旧 ai.rs 删
|
||||||
|
- `stores/ai.ts`(749 行)→ 薄壳 + `composables/ai/*`(events/stream/send/conversations/window/panel)
|
||||||
|
- `df-evolve` crate 移除(workspace/代码零引用)
|
||||||
|
- 踩坑(glob 重导出命令宏符号 / super 路径失效 / 0 字节残留触发 E0761)已记入经验记录.md
|
||||||
|
|
||||||
|
**关键决策**(详见功能决策记录 / 经验记录):① list_directory 剪枝 + skip_noise 保留访问能力(定点查/强制递归两路通);② KV 表统一持久化复用 SettingsRepo(兑现经验记录预言);③ token 估算兜底(provider 不报时按消息估);④ 发送失败回滚 streaming;⑤ 并发去上限保 per-conv≤global
|
||||||
|
|
||||||
|
**代码变更**:src-tauri(`commands/ai/` 新模块 + `commands/settings.rs` + state.rs + lib.rs)+ crates/df-storage(migrations V13 + crud SettingsRepo)+ 前端(api/settings.ts + stores/appSettings.ts + App.vue/Settings.vue/composables/ai/* + i18n)≈ 25 文件
|
||||||
|
|
||||||
|
**验证**:cargo check ✓ / vue-tsc ✓ 零错 / tauri dev 启动 ✓ 运行;token/发送/并发/list_directory 已实测
|
||||||
|
|
||||||
|
**遗留 / 下一步**:
|
||||||
|
1. **Fix 1(待用户最终定)**:工具结果入库前截断 50KB(堵 `read_file` 1MB 洞 + 防御);Fix 2(放宽旧工具结果裁剪)因 Part A 已杀主源,延后不做
|
||||||
|
2. Settings.vue(1037 行)拆 panel 子组件——早前建议,用户已做 ai.rs,此件待评估
|
||||||
|
3. 诊断日志清理(handleEvent/approveToolCall 早期调试 console.log 残留)
|
||||||
|
|
||||||
|
### [Sprint 20] 2026-06-14 — 项目管理模块代码审查 + 全局核对(3-agent review 验证)
|
||||||
|
|
||||||
|
**工作内容**: 应用户要求审查「项目管理 / 项目导入 / 生命周期可用性」(后端 commands/project.rs + ai/tool_registry.rs + df-project/scan.rs + 前端 Projects.vue / ProjectDetail.vue)。先 3-agent 并行 review 出 15 项发现,剔幻觉 2 项(read_readme 命名误判 / conflict.name null 过度防御)后剩 13 项;再应要求**全局核对**(grep 全仓库 6 维度)确认每项发现是孤例还是系统性,保证全局成立有效。**本次仅审查 + 核对,未改代码。**
|
||||||
|
|
||||||
|
**验证结论**: 13/13 发现**事实全部成立**,无幻觉。4 处行号偏差(① 249→263 / ⑤ 113→136 / ⑫ 130→145 / ③ 未标→271);2 处范围需补(⑧ 实 8 处非 3 处 / ③ 漏 relocate:212)。
|
||||||
|
|
||||||
|
**🔴 必须修复(3)**:
|
||||||
|
1. **AI delete_project 硬删绕过软删**(tool_registry.rs:263 `repo.delete` 物理 DELETE vs IPC project.rs:120 `soft_delete` 进回收站)—— 同一操作两种后果,AI 对话删 = 数据丢失不可恢复,UI 删 = 可恢复。**数据安全缺口**
|
||||||
|
2. **AI 缺回收站生命周期工具**(restore/purge/list_deleted 全无)—— ProjectRepo 有完整方法(crud.rs:572/591/610/634)+ IPC 全注册(project.rs:125-145),AI 层零覆盖,割裂工作流
|
||||||
|
3. **collect_sample 同步 IO 阻塞 async**(project.rs:271 在 async fn 内直接同步调 df-project::scan)—— 对比 scan_project_stack(:182)用了 spawn_blocking,不一致
|
||||||
|
|
||||||
|
**🟡 建议改进(6)**: ④ normalize_path 两份重复(project.rs:154 + tool_registry.rs:180)/ ⑤ update 白名单双份不同步(tool_registry:136 硬编码 vs crud.rs:287 allowed_columns,idea_id 字段不同步)/ ⑥ AI 扫描 + 目录状态文案硬编码未走 $t(Projects.vue:33/195 + ProjectDetail.vue:93)/ ⑦ parseStack 两份重复(Projects.vue:143 + ProjectDetail.vue:321)/ ⑧ 原生 alert/confirm(全局 8 处非仅 ProjectDetail 3 处,Ideas.vue:378/391 同病)/ ⑨ collect_sample 无文件大小预检(scan.rs:151/205 read 全文后截断)
|
||||||
|
|
||||||
|
**⚪ 可选(3)**: ⑩ LLM 无超时(project.rs:297 占 llm_concurrency permit 无限 await)/ ⑪ JSON 提取取首尾花括号(project.rs:368-369 多 JSON 场景错)/ ⑫ create_project 无 path 参数(tool_registry:145 需 create+bind 两步)
|
||||||
|
|
||||||
|
**全局核对关键定性**(6 维度 grep 全仓库):
|
||||||
|
- **① 是 project 特例但最该修**: soft_delete/回收站机制**全仓库仅 project 一张表有**(task/idea/branch/release/workflow 本就物理删,无语义不一致);正因 project 唯一设计回收站,AI 硬删直接击穿其数据安全设计
|
||||||
|
- **⑧ 系统性**: 全仓库 8 处原生弹窗(ProjectDetail 4 + Projects 2 + Ideas 2),非局部
|
||||||
|
- **③ 范围补报**: 不只 :271,relocate_project_path:212 同步 detect_stack 同样阻塞,3 处未包 spawn_blocking vs 1 处包了
|
||||||
|
- **src-tauri 层自身干净**: `std::fs` 同步调用零匹配,阻塞全在跨 crate 调同步的 df-project::scan
|
||||||
|
- **④⑤⑦ 精准**: 全仓库确为两份/一处,无更多副本;validate_path/resolve_workspace_path 是安全校验,职责不同非 normalize 重复
|
||||||
|
|
||||||
|
**未改代码**(待用户定推进批次)
|
||||||
|
|
||||||
|
**遗留 / 下一步(修复批次建议)**:
|
||||||
|
- **批1 数据安全**: ①(AI delete 改 soft_delete)+ ②(补 restore/purge/list_trash 三工具,复用 ProjectRepo 对应方法)
|
||||||
|
- **批2 阻塞 + 去重**: ③(:270/:271/:212 三处统一 spawn_blocking)+ ④(抽公共 normalize_path 到 df-project/df-core)+ ⑤(tool_registry 复用 allowed_columns_for)
|
||||||
|
- **批3 前端**: ⑥(补 i18n key)+ ⑦(抽 src/utils/project.ts)+ ⑧(全局 8 处 alert/confirm 换 Arco Modal)
|
||||||
|
- ⚪ 可选: ⑨⑩⑪⑫ 按需
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [设计] 2026-06-14 — B-03 人工审批响应机制设计
|
||||||
|
|
||||||
|
**工作内容**:df-workflow HumanNode 审批响应机制设计(B-260614-03)。勘察确认链路基础设施 90% 已通(EventBus broadcast / `HumanApprovalResponse` 事件 / `approve_human_approval` IPC / 前端 store 已接通),唯一缺口为 `human_node.rs:55` 发完请求直接 return "同意"。设计:订阅时序铁律(`subscribe()` 先于 `send(Request)`)+ `tokio::select!` 循环(响应 / 超时 / 取消)+ execution_id+node_id 双键过滤 + Lagged 容忍。澄清 B-06/B-07 前置真实影响(B-06=并发隔离,非单流功能前置;B-07=取消必要非充分),拆 **B-03a**(响应等待 + 超时)/ **B-03b**(取消机制:`set_cancelled` + cancel IPC + 前端按钮)。
|
||||||
|
|
||||||
|
**产出**:完整设计文档 [B-03-人工审批响应机制.md](./docs/02-架构设计/B-03-人工审批响应机制.md);功能决策记录加「工作流人工审批节点(B-03)」摘要章节。
|
||||||
|
|
||||||
|
**下一步**:B-06(execution_id 下沉)/ B-07(共享 StateMachine)前置修复 → 落 B-03a 实现 → B-03b 取消机制补全。
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
|
|
||||||
### [Sprint N] YYYY-MM-DD — 简述
|
### [Sprint N] YYYY-MM-DD — 简述
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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_tokens,message_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_start:input=42,output=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_delta:output_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_delta:output_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:带出累积 usage,finished=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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
//! Agent 协调器 — 管理多 Agent 协作
|
//! Agent 协调器 — 管理多 Agent 协作
|
||||||
|
//!
|
||||||
|
//! ⚠ B 路线占位:当前单链 ReAct 够用,多 Agent 协作待 B 路线立项。有意保留空壳,勿删。
|
||||||
|
|
||||||
/// Agent 协调器
|
/// Agent 协调器
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -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` 协议走 AnthropicCompatProvider(GLM 订阅端点 / Claude 官方),
|
||||||
|
/// 其余(openai / glm / deepseek 等 OpenAI 兼容)走 OpenAICompatProvider。
|
||||||
|
/// 调用方(AI Chat 的 run_agentic_loop、df-nodes 的 AiNode)统一引用此工厂,
|
||||||
|
/// 新增 provider 只改这一处。
|
||||||
|
pub fn build_provider(
|
||||||
|
provider_type: &str,
|
||||||
|
base_url: &str,
|
||||||
|
api_key: &str,
|
||||||
|
model: &str,
|
||||||
|
) -> Box<dyn LlmProvider> {
|
||||||
|
match provider_type {
|
||||||
|
"anthropic" => Box::new(anthropic_compat::AnthropicCompatProvider::new(
|
||||||
|
base_url, api_key, model,
|
||||||
|
)),
|
||||||
|
_ => Box::new(openai_compat::OpenAICompatProvider::new(
|
||||||
|
base_url, api_key, model,
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 携带 usage(OpenAI 官方 + 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>,
|
||||||
|
/// 末 chunk(choices 为空)携带的累计 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 字段恒为 None(usage 仅在终态带出)。
|
||||||
|
/// - usage(`stream_options.include_usage` 时末段或 usage-only chunk 携带)→ 覆盖累加器(覆盖语义保对)。
|
||||||
|
/// - 解析失败 → 返回空 chunk(与原内联实现一致)。
|
||||||
|
///
|
||||||
|
/// 等价性:delta / tool_calls / finished / 解析失败等分支与原 stream() 闭包逐字一致;
|
||||||
|
/// usage 透传([DONE] 终态 take() 带出、usage chunk 覆盖累加器)为本次新增能力,
|
||||||
|
/// 对应 StreamChunk 新增的 usage 字段 + 请求体新增 stream_options.include_usage。
|
||||||
|
pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>) -> StreamChunk {
|
||||||
|
// OpenAI 发送 "data: [DONE]" 表示流结束,带出累积 usage
|
||||||
|
if data == "[DONE]" {
|
||||||
|
return StreamChunk {
|
||||||
|
delta: String::new(),
|
||||||
|
finished: true,
|
||||||
|
tool_calls: None,
|
||||||
|
usage: usage_accum.take(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 后,末段正常 chunk(finish_reason)及额外 usage-only chunk(choices=[])都带 usage。
|
||||||
|
// usage 解析/累积逻辑抽到 apply_openai_sse 纯函数,便于单测;此处闭包只负责传 data 与传递 last_usage。
|
||||||
|
let mut last_usage: Option<TokenUsage> = None;
|
||||||
|
|
||||||
let stream = resp
|
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 的 chunk(choices 为空 → usage-only 末 chunk,对应 include_usage)
|
||||||
|
fn usage_only_chunk(prompt: u32, completion: u32) -> String {
|
||||||
|
format!(
|
||||||
|
r#"{{"choices":[],"usage":{{"prompt_tokens":{},"completion_tokens":{},"total_tokens":{}}}}}"#,
|
||||||
|
prompt,
|
||||||
|
completion,
|
||||||
|
prompt + completion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 辅助:构造既有 content 又带 usage 的末段 chunk(部分兼容端点会把 usage 挂到正常末 chunk 上)
|
||||||
|
fn text_chunk_with_usage(content: &str, finish_reason: &str, prompt: u32, completion: u32) -> String {
|
||||||
|
format!(
|
||||||
|
r#"{{"choices":[{{"delta":{{"content":"{}"}},"finish_reason":"{}"}}],"usage":{{"prompt_tokens":{},"completion_tokens":{},"total_tokens":{}}}}}"#,
|
||||||
|
content,
|
||||||
|
finish_reason,
|
||||||
|
prompt,
|
||||||
|
completion,
|
||||||
|
prompt + completion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 多 chunk 文本流后,末 chunk 携带 usage(include_usage 覆盖语义)
|
||||||
|
#[test]
|
||||||
|
fn openai_sse_multi_chunk_with_terminal_usage() {
|
||||||
|
let mut acc: Option<TokenUsage> = None;
|
||||||
|
|
||||||
|
// 1) 首个文本增量,无 usage
|
||||||
|
let c = apply_openai_sse(&text_chunk("Hello", None), &mut acc);
|
||||||
|
assert_eq!(c.delta, "Hello");
|
||||||
|
assert!(!c.finished);
|
||||||
|
assert!(c.usage.is_none());
|
||||||
|
assert!(acc.is_none(), "无 usage 的 chunk 不应改累加器");
|
||||||
|
|
||||||
|
// 2) 第二个文本增量
|
||||||
|
let c = apply_openai_sse(&text_chunk(" world", None), &mut acc);
|
||||||
|
assert_eq!(c.delta, " world");
|
||||||
|
assert!(!c.finished);
|
||||||
|
assert!(acc.is_none());
|
||||||
|
|
||||||
|
// 3) 末段正常 chunk 带 finish_reason=stop(仍是文本 delta,不带 usage)
|
||||||
|
let c = apply_openai_sse(&text_chunk("", Some("stop")), &mut acc);
|
||||||
|
assert!(c.finished);
|
||||||
|
assert_eq!(c.delta, "");
|
||||||
|
assert!(acc.is_none(), "此 chunk 无 usage 字段,累加器仍为 None");
|
||||||
|
|
||||||
|
// 4) usage-only chunk(choices=[])携带累计 usage → 覆盖累加器
|
||||||
|
let c = apply_openai_sse(&usage_only_chunk(12, 34), &mut acc);
|
||||||
|
assert!(!c.finished);
|
||||||
|
assert!(c.usage.is_none(), "非 [DONE] chunk 不带出 usage");
|
||||||
|
let acc = acc.expect("累加器应已被 usage-only chunk 覆盖写入");
|
||||||
|
assert_eq!(acc.prompt_tokens, 12);
|
||||||
|
assert_eq!(acc.completion_tokens, 34);
|
||||||
|
assert_eq!(acc.total_tokens, 46);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// usage 挂在正常末段 chunk(含 finish_reason)上,而非独立 usage-only chunk
|
||||||
|
#[test]
|
||||||
|
fn openai_sse_usage_on_terminal_text_chunk() {
|
||||||
|
let mut acc: Option<TokenUsage> = None;
|
||||||
|
let c = apply_openai_sse(&text_chunk_with_usage("", "stop", 100, 200), &mut acc);
|
||||||
|
assert!(c.finished);
|
||||||
|
assert!(c.usage.is_none(), "非 [DONE] 不带出 usage,仅覆盖累加器");
|
||||||
|
let acc = acc.expect("末段 chunk 的 usage 应已覆盖累加器");
|
||||||
|
assert_eq!(acc.prompt_tokens, 100);
|
||||||
|
assert_eq!(acc.completion_tokens, 200);
|
||||||
|
assert_eq!(acc.total_tokens, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [DONE] 时 take() 带出累积 usage,且取走后累加器清空
|
||||||
|
#[test]
|
||||||
|
fn openai_sse_done_takes_accumulated_usage() {
|
||||||
|
let mut acc: Option<TokenUsage> = None;
|
||||||
|
apply_openai_sse(&text_chunk("x", None), &mut acc);
|
||||||
|
apply_openai_sse(&usage_only_chunk(5, 7), &mut acc);
|
||||||
|
|
||||||
|
let c = apply_openai_sse("[DONE]", &mut acc);
|
||||||
|
assert!(c.finished);
|
||||||
|
let u = c.usage.expect("[DONE] 应带出累积 usage");
|
||||||
|
assert_eq!(u.prompt_tokens, 5);
|
||||||
|
assert_eq!(u.completion_tokens, 7);
|
||||||
|
assert_eq!(u.total_tokens, 12);
|
||||||
|
assert!(acc.is_none(), "take() 后累加器应清空");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 无 usage 的流:[DONE] 时 usage 字段为 None
|
||||||
|
#[test]
|
||||||
|
fn openai_sse_done_without_usage() {
|
||||||
|
let mut acc: Option<TokenUsage> = None;
|
||||||
|
apply_openai_sse(&text_chunk("hi", None), &mut acc);
|
||||||
|
let c = apply_openai_sse("[DONE]", &mut acc);
|
||||||
|
assert!(c.finished);
|
||||||
|
assert!(c.usage.is_none(), "全程无 usage 时 [DONE] usage 应为 None");
|
||||||
|
assert!(acc.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 后续 usage chunk 覆盖先前 usage(多轮 / 重发场景)
|
||||||
|
#[test]
|
||||||
|
fn openai_sse_later_usage_overrides_earlier() {
|
||||||
|
let mut acc: Option<TokenUsage> = None;
|
||||||
|
apply_openai_sse(&usage_only_chunk(1, 1), &mut acc);
|
||||||
|
apply_openai_sse(&usage_only_chunk(50, 60), &mut acc);
|
||||||
|
let c = apply_openai_sse("[DONE]", &mut acc);
|
||||||
|
let u = c.usage.unwrap();
|
||||||
|
assert_eq!(u.prompt_tokens, 50, "末 usage 应覆盖前值");
|
||||||
|
assert_eq!(u.completion_tokens, 60);
|
||||||
|
assert_eq!(u.total_tokens, 110);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// finish_reason=length(max_tokens 截断)按正常终止处理
|
||||||
|
#[test]
|
||||||
|
fn openai_sse_length_finish_reason_treated_as_finished() {
|
||||||
|
let mut acc: Option<TokenUsage> = None;
|
||||||
|
let c = apply_openai_sse(&text_chunk("...", Some("length")), &mut acc);
|
||||||
|
assert!(c.finished, "length 应视为正常终止");
|
||||||
|
assert!(acc.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 非法 JSON data → 返回空 chunk,不 panic、不改累加器
|
||||||
|
#[test]
|
||||||
|
fn openai_sse_malformed_json_yields_empty_chunk() {
|
||||||
|
let mut acc: Option<TokenUsage> = None;
|
||||||
|
let c = apply_openai_sse("not a json", &mut acc);
|
||||||
|
assert_eq!(c.delta, "");
|
||||||
|
assert!(!c.finished);
|
||||||
|
assert!(c.usage.is_none());
|
||||||
|
assert!(acc.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// tool_calls 增量解析
|
||||||
|
#[test]
|
||||||
|
fn openai_sse_tool_call_delta() {
|
||||||
|
let mut acc: Option<TokenUsage> = None;
|
||||||
|
let data = r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"get_weather","arguments":"{\"q\":"}}]}}]}"#;
|
||||||
|
let c = apply_openai_sse(data, &mut acc);
|
||||||
|
assert!(acc.is_none());
|
||||||
|
let tcs = c.tool_calls.expect("应有 tool_calls 增量");
|
||||||
|
assert_eq!(tcs.len(), 1);
|
||||||
|
assert_eq!(tcs[0].index, 0);
|
||||||
|
assert_eq!(tcs[0].id.as_deref(), Some("call_1"));
|
||||||
|
assert_eq!(tcs[0].function_name.as_deref(), Some("get_weather"));
|
||||||
|
assert_eq!(tcs[0].function_arguments.as_deref(), Some("{\"q\":"));
|
||||||
|
assert!(!c.finished);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
@@ -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![]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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};
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
//! Git 操作 — 克隆、提交、推送、合并等
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
/// Git 操作类型
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum GitAction {
|
|
||||||
Clone,
|
|
||||||
Commit,
|
|
||||||
Push,
|
|
||||||
Pull,
|
|
||||||
Merge,
|
|
||||||
Checkout,
|
|
||||||
CreateBranch,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Git 操作请求
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct GitRequest {
|
|
||||||
/// 操作类型
|
|
||||||
pub action: GitAction,
|
|
||||||
/// 仓库路径(本地路径或远程 URL)
|
|
||||||
pub repo: String,
|
|
||||||
/// 分支名
|
|
||||||
pub branch: Option<String>,
|
|
||||||
/// 提交消息
|
|
||||||
pub message: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Git 操作结果
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct GitResult {
|
|
||||||
pub success: bool,
|
|
||||||
pub message: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 执行 Git 操作
|
|
||||||
///
|
|
||||||
/// TODO: 实现完整的 Git 操作(可包装 git CLI 或使用 git2 crate)
|
|
||||||
pub async fn execute(_request: GitRequest) -> anyhow::Result<GitResult> {
|
|
||||||
tracing::info!("Git 操作: TODO");
|
|
||||||
Ok(GitResult {
|
|
||||||
success: true,
|
|
||||||
message: "TODO: 未实现".to_string(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,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;
|
|
||||||
|
|||||||
@@ -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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -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 应含标题");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
//! 评分引擎 — 多维度加权评分
|
//! 评分引擎 — 基于想法内容的多维度启发式评分
|
||||||
|
//!
|
||||||
|
//! 各维度分数均为 0-10(IPC 层会 *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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(默认)/ anthropic(GLM 订阅 / 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 缺少必填参数: prompt(config 或上游输入均无)"))?;
|
||||||
|
|
||||||
|
// ── 可选参数 ──
|
||||||
|
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 缺少必填参数: prompt(config 或上游输入均无)"))?;
|
|
||||||
|
|
||||||
// ── 可选参数 ──
|
|
||||||
let model = ctx
|
|
||||||
.config
|
|
||||||
.get("model")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
let temperature = ctx
|
|
||||||
.config
|
|
||||||
.get("temperature")
|
|
||||||
.and_then(|v| v.as_f64())
|
|
||||||
.map(|f| f as f32);
|
|
||||||
let max_tokens = ctx
|
|
||||||
.config
|
|
||||||
.get("max_tokens")
|
|
||||||
.and_then(|v| v.as_u64())
|
|
||||||
.map(|n| n as u32);
|
|
||||||
let system_prompt = ctx
|
|
||||||
.config
|
|
||||||
.get("system_prompt")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(|s| s.to_string());
|
|
||||||
|
|
||||||
// 协议类型:默认 openai_compat,可设 anthropic(GLM 订阅端点 / Claude 官方)
|
|
||||||
let protocol = ctx
|
|
||||||
.config
|
|
||||||
.get("protocol")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("openai_compat")
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
// default_model:留空时给一个占位,避免 provider 构造 panic
|
|
||||||
let default_model = if model.is_empty() {
|
|
||||||
"gpt-4o-mini".to_string()
|
|
||||||
} else {
|
|
||||||
model.clone()
|
|
||||||
};
|
|
||||||
|
|
||||||
let provider: Box<dyn LlmProvider> = match protocol.as_str() {
|
|
||||||
"anthropic" => Box::new(AnthropicCompatProvider::new(&base_url, &api_key, &default_model)),
|
|
||||||
_ => Box::new(OpenAICompatProvider::new(&base_url, &api_key, &default_model)),
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── 构建消息 ──
|
// ── 构建消息 ──
|
||||||
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 }
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
//! df-plugin: 插件系统 — 插件加载、宿主环境、SDK
|
|
||||||
|
|
||||||
pub mod host;
|
|
||||||
pub mod loader;
|
|
||||||
pub mod sdk;
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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: 实现宏
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
|
|||||||
303
crates/df-project/src/scan.rs
Normal file
303
crates/df-project/src/scan.rs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 }
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
//! df-stages: 阶段插件 — 开发流程各阶段的节点模板注册
|
|
||||||
|
|
||||||
pub mod coding;
|
|
||||||
pub mod idea;
|
|
||||||
pub mod release;
|
|
||||||
pub mod requirement;
|
|
||||||
pub mod testing;
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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
|
||||||
|
);
|
||||||
|
";
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
|||||||
271
crates/df-storage/tests/project_soft_delete.rs
Normal file
271
crates/df-storage/tests/project_soft_delete.rs
Normal 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 白名单,应被拒(专用路径才能写)"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 }
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
//! df-task: 任务/分支管理 — 任务 CRUD、分支管理、合并协调、发布计划
|
|
||||||
|
|
||||||
pub mod branch;
|
|
||||||
pub mod merge;
|
|
||||||
pub mod release;
|
|
||||||
pub mod task;
|
|
||||||
@@ -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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 }
|
|
||||||
@@ -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![])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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![]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
@@ -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![]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
30
dev-tools.bat
Normal 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
|
||||||
@@ -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>`(id),update/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` — 统一错误类型
|
||||||
|
|||||||
248
docs/02-架构设计/B-03-人工审批响应机制.md
Normal file
248
docs/02-架构设计/B-03-人工审批响应机制.md
Normal 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 broadcast,capacity 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 构造 HumanApprovalResponse,state.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 已接通捕获 Request;UI 组件渲染属前端独立工作,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 + NodeContext,spawn 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
|
||||||
407
docs/02-架构设计/功能决策记录-归档.md
Normal file
407
docs/02-架构设计/功能决策记录-归档.md
Normal 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 给 ContextManager,token 缓存永远滞后一轮或每次重建(双重复制)。类型替换让 push 即刻更新 token 计数、零额外 clone、单一数据源。代价是 `save_conversation` 等需全量的场景要显式调 `all_messages_clone()`(多一行但语义明确)。13 处操作点中 6 处签名不变(push/clear)、4 处微调(clone→all_messages_clone/iter)、2 处需重写。
|
||||||
|
- **状态**:✅ 已落地(2026-06-13,cargo check + vue-tsc 通过,待 tauri dev 实测)
|
||||||
|
|
||||||
|
### Token 计数方案:字符粗估零依赖 [任务 #43]
|
||||||
|
|
||||||
|
- **决策**:用 `chars_count × 0.35` 粗估单条消息 token 数(~2.8 字符/token),每条消息 +4 token 固定开销(role 标记),每个 tool_call +30 token(JSON 结构)。不引入 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 一轮执行中会多次 push(assistant 回复 → 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/O(read_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`(两 Arc,cheap)作为参数串到底,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 项永不被并发访问)。风险留待未来:若支持多对话并发 loop,per_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_end(O(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/iter),13 处中 6 处零改动,改动面最小。核心改造点 run_agentic_loop 由 `session.messages.clone()` 全量塞入改为 `build_for_request(sys_tokens)`:调用方用 `TokenEstimator::estimate_text(&system_prompt)` 估算 system prompt token,ContextManager 只接收数值不接触 prompt 文本。
|
||||||
|
- **状态**:✅ ai.rs 已落地(2026-06-13,cargo 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 循环逐个串行 execute,N 个独立 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 保持同步 await,title 改 `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 fallback(path 几乎总有,属过度防御)。取舍:为可维护性/易迭代,不为微性能(省闭包可忽略)。
|
||||||
|
- **状态**:✅ 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`(当前对话累计)两个状态,而非单一状态。
|
||||||
|
- **原因**:单状态切对话会错乱——实时值(当前回复产生的 token)vs 历史总值(切回老对话应从 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` 路径(未 stream)save 传 `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/rejected,IPC 失败回滚 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_field(set_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-13,7 纯函数单测全过:缺参×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-13(cargo check 通过)
|
||||||
|
|
||||||
|
### 列表查询加过滤(内存策略)
|
||||||
|
|
||||||
|
- **决策**:`ai_conversation_list` 加 limit(默认 50)+ include_archived(默认 false);AI 工具 `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)「踩坑」分组。
|
||||||
|
|
||||||
|
### 状态枚举 i18n:constants 存 key,view 包 $t
|
||||||
|
|
||||||
|
- **决策**:`constants/project.ts` 的 `PROJECT_STATUS_LABELS`/`TASK_STATUS_LABELS` 值从中文文案改存 i18n key(`planning: 'projects.status.planning'`);`projectStatusLabel`/`taskStatusLabel` 返回 key;view 显示处包 `$t(projectStatusLabel(x))`。`PRIORITY_LABELS`(P0/P1)是代号非文案,不动。
|
||||||
|
- **原因**:constants 是纯数据/结构层,不应含展示文案(违反分层);文案归 locale,constants 管映射结构。
|
||||||
|
- **状态**:✅ 已落地
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**相关文档**:
|
||||||
|
- [功能决策记录](./功能决策记录.md) — 需求规格 + 设计决策规格(当前真相源)
|
||||||
|
- [经验记录](./经验记录.md) — 踩坑/约定/技巧/bug 排查教训
|
||||||
472
docs/02-架构设计/功能决策记录.md
Normal file
472
docs/02-架构设计/功能决策记录.md
Normal file
@@ -0,0 +1,472 @@
|
|||||||
|
# 功能决策记录
|
||||||
|
|
||||||
|
> 日常开发中对各功能做的**需求规格 + 设计决策规格**(功能粒度,补 [Phase 1 架构决策](./Phase1架构决策.md) 之下的实现层选择)。聚焦「要做什么 / 为什么这么定」,便于日后回溯。
|
||||||
|
>
|
||||||
|
> 创建:2026-06-12 | 范围:Sprint 5–10 | 维护:随开发追加
|
||||||
|
|
||||||
|
## 约定
|
||||||
|
|
||||||
|
**判断标准**: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(无输出)时最多等 120s(P2 可用 `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 项塞进对话 message(UI 卡 + 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 作真相源 → 致「重启默认丢失」bug:active 是内存态重启清零,而 `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 调用点加 router;ai_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` 注入参数,启发式降级为 fallback;F-03 原 A/B/C 选型据此收敛为「全局统一 trait」。
|
||||||
|
- **状态**:📐 设计未实施(LLM 接入)
|
||||||
|
|
||||||
|
### 前后端标签对齐
|
||||||
|
- **决策**:`recommendation` 全小写空格(后端原 "With Resources" 不匹配前端 map key);`assessmentClass` 映射到 CSS 类名(`.immediate`/`.soon`/...)。
|
||||||
|
- **原因**:大小写/类名不一致致中文标签不显示、badge 无色。
|
||||||
|
- **状态**:✅ Sprint 9
|
||||||
|
|
||||||
|
### 评分 0-10(crate)→ 0-100(前端)IPC 缩放 + 多字段写回用单事务 [Sprint 9/10]
|
||||||
|
- **决策**:① crate 内 IdeaScores 维持 0-10;IPC 层 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/tauri);commands 层薄封装 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(纯 IO),LLM 调用放 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/Low,AI 自评,**唯一质量指标**);提炼时另做**查重**(比对 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 1:Chat + 手动录入)
|
||||||
|
|
||||||
|
### 对外暴露: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 task),4 个配置项:`auto_extract`(default true)/ `trigger_mode`(on_complete|on_idle|manual_only,default 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 1(Phase 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 不浪费 embed,published 才参与检索);④ 纯 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-rs(5MB BPE 数据文件对 Tauri 打包不友好)。
|
||||||
|
- **双层 Semaphore 并发控制**:AppState `LlmConcurrency`——全局并发默认 3 / 单对话默认 2;permit 在 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-13,cargo 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-13(curl 验证 vite 正确展开 glob,vue-tsc PASS)
|
||||||
|
|
||||||
|
### 状态枚举 i18n:constants 存 key,view 包 $t
|
||||||
|
- **决策**:`constants/project.ts` 的 `PROJECT_STATUS_LABELS`/`TASK_STATUS_LABELS` 值从中文文案改存 i18n key(`planning: 'projects.status.planning'`);`projectStatusLabel`/`taskStatusLabel` 返回 key;view 显示处包 `$t(projectStatusLabel(x))`。`PRIORITY_LABELS`(P0/P1)是代号非文案,不动。
|
||||||
|
- **原因**:constants 是纯数据/结构层,不应含展示文案(违反分层);文案归 locale,constants 管映射结构。
|
||||||
|
- **状态**:✅ 已落地
|
||||||
|
|
||||||
|
### 📋 项目 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 文件名——debug(Dev 模式)用 `devflow-dev.db`,release(Build 模式)用 `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`(顶部 fixed,3s 自动消失)+ `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 对抗论证时被砍/降级,此后无表、无 IPC,query 方法全 `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-plugin(WASM/动态库)、df-stages(11 阶段节点 `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 拆分:路线A(store 留 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. **选路线 A(composable 挪逻辑、store 留 state)而非路线 B(Pinia 多 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 依赖;裸 B(df-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 正向拆分,方向相反、互不矛盾。
|
||||||
|
- **否决项**:纯 A(per-module trait)= 全局 N 份发散契约,反模式;裸 B(df-ideas 直接依赖 df-ai)= 纯逻辑 crate 被 reqwest 污染、自检摩擦上升。
|
||||||
|
- **退路**:若不愿加新 crate,trait 可放 `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-06(execution_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 9–10 🚧 | P1 |
|
||||||
|
| 切对话不中断路由:部分场景运行时实测 | AI Chat 可靠性 | Sprint 8 🚧 | P1 |
|
||||||
|
| 技能联想「使用」:首批 3 类联想已做,联想后实际触发/执行技能未实现 | 技能/联想 | Sprint 8 | P2 |
|
||||||
|
| 灵感对抗评估接 LLM:论点/evidence 由 df-ai LlmProvider 生成(现启发式 fallback) | 灵感模块 | Sprint 9 📐 | P2 |
|
||||||
|
| 知识库 Tier 1:AI 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 每节点拿全新空 StateMachine:self.state_machine 从不传入 NodeContext,HumanNode is_cancelled 恒 false,取消机制失效 | 工作流引擎 | 2026-06-13 多代理探索 | P1 |
|
||||||
|
| 🟡 promote_idea 两步写非事务:INSERT project 成功后若 UPDATE idea 失败,项目存在但想法状态未变,补偿删除可修 | 灵感/立项 | 2026-06-13 代码审查 | P1 |
|
||||||
|
| 🔴 分离窗口(detached)跨窗口状态失效:用 localStorage 传递生成态快照(df-ai-gen/text),Tauri 多 webview 不共享 localStorage 致静默失效;用户点 X 关闭(非 closeDetachedWindow)后主窗口 `detached` 永真卡死(reattachPanel 死代码未接线)。需改 Tauri 全局 emit/listen 同步 + 窗口销毁事件复位 | AI Chat 分离窗口 | 2026-06-13 代码审查 | P1 |
|
||||||
|
| 模型能力声明与自动路由系统 Phase 1:ModelCapability 数据模型 + 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 3:Agent 内智能路由——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-12):devflow 语境「决策/决策需求点」= 日常开发功能细节取舍(为什么这么定),**非** 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)
|
||||||
167
docs/02-架构设计/文档记录规范.md
Normal file
167
docs/02-架构设计/文档记录规范.md
Normal 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` — 工作流水
|
||||||
140
docs/02-架构设计/经验记录.md
Normal file
140
docs/02-架构设计/经验记录.md
Normal 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 / common(8 文件 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_for(DB 层)+ ai.rs 工具闭包硬编码 match(AI 工具层)。加 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 子 module,glob 重导出后 cargo check 0 error)
|
||||||
|
|
||||||
|
### 跨层模块拆分:`super::xxx` 路径失效需改全限定 [2026-06-14]
|
||||||
|
|
||||||
|
- **现象**:ai.rs(commands 直接子模块)拆到 `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 错」且语义错。按表隔离既修此 bug(ideas 白名单不含 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,响应链成立。computed(stats/pendingApproval)在 reactive 内仍自动解包,各视图用法零改动。
|
||||||
|
- **状态**:🚧 Sprint 10(编译/构建通过,未 tauri dev 实测,根因通杀 Projects/Tasks/Dashboard)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、技巧
|
||||||
|
|
||||||
|
### migrate_v4:PRAGMA 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 字段回填(修 bug:prompt 要求但写库丢弃)[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→0):completion 有值说明 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 微调 / 已被取代
|
||||||
359
docs/02-架构设计/规格契约自检机制.md
Normal file
359
docs/02-架构设计/规格契约自检机制.md
Normal 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 已在用 hook(Stop 降频)+ 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 + 高后果。
|
||||||
@@ -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 5(12 工具注册在 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>;
|
||||||
|
|
||||||
### 流式响应
|
// 向量生成(默认 bail,openai_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 兼容 Provider(openai_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 timeout(120s)与断连丢弃(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 Provider(anthropic_compat.rs,Sprint 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` 终态 chunk,usage 经 `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.rs,Sprint 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-3(LIKE 或 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 两处正常退出路径统一调用。
|
||||||
|
|
||||||
|
### 向量 embedding(Phase 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.rs,Sprint 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)
|
||||||
|
|||||||
135
docs/03-模块文档/df-knowledge-知识库.md
Normal file
135
docs/03-模块文档/df-knowledge-知识库.md
Normal 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=candidate,source_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) — 检索方案演进决策
|
||||||
@@ -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_executions(6 张基础表 + 4 索引)|
|
||||||
4. **workflow_defs** — 工作流定义
|
| V2 | ideas 加 promoted_to/ai_analysis/scores;tasks 加 workflow_def_id/base_branch;workflow_executions 加 project_id/task_id;新建 branches 表(含 2 索引)|
|
||||||
5. **workflow_runs** — 工作流执行
|
| V3 | ai_providers / ai_conversations / ai_tool_executions(AI 功能 3 张表)|
|
||||||
6. **artifacts** — 产出物
|
| V4 | 幂等补列:ai_conversations.archived(PRAGMA 探测,兼容坏库)|
|
||||||
|
| V5 | 幂等补列:ai_conversations.prompt_tokens / completion_tokens / model / models(Token 用量)|
|
||||||
|
| V6 | 幂等补列:ai_conversations.skill(技能注入)|
|
||||||
|
| V7 | 新建 **knowledges** 表(Sprint 15)|
|
||||||
|
| V8 | 幂等补列:knowledges.embedding BLOB(Phase 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 BLOB(f32 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 → f32(chunks_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)
|
||||||
|
|||||||
@@ -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 / NodeOutput(node.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`,旧文档所述为误。
|
||||||
|
|
||||||
|
### EventBus(eventbus.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 对外 API(dag.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 — EventBus(broadcast)
|
||||||
|
├── registry.rs — NodeRegistry(节点类型注册表)
|
||||||
|
└── conditions.rs — 条件表达式引擎
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 🔮 决策能力接入需求(B 路线)
|
||||||
|
|
||||||
|
> 2026-06-12 记录。executor 分层并行已具骨架,conditions 为最大空壳。
|
||||||
|
|
||||||
|
AI Chat(B 路线)从单链 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)
|
||||||
|
|||||||
@@ -57,6 +57,69 @@
|
|||||||
2. **数据导出**:备份和分享
|
2. **数据导出**:备份和分享
|
||||||
3. **插件系统**:扩展功能
|
3. **插件系统**:扩展功能
|
||||||
|
|
||||||
|
## 🗂️ 知识库(Tier 1 已完成,Sprint 15)
|
||||||
|
|
||||||
|
> 2026-06-13 完成。
|
||||||
|
|
||||||
|
**已落地**:
|
||||||
|
- candidate→published→archived 状态机 + 人工门控(AI 只产 candidate)
|
||||||
|
- AI 自动提炼(对话完成后 spawn,min_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`),但仅喂静态 DAG,agentic loop 未接入
|
||||||
|
5. **Reflection(自纠)**:缺失,需执行后自检 / 重试机制
|
||||||
|
|
||||||
|
**启动前置**:重读 coordinator.rs / conditions.rs 确认仍为空壳(期间可能有变动)。
|
||||||
|
|
||||||
|
## 🧩 aichat 后续需求点(路线总览)
|
||||||
|
|
||||||
|
> 2026-06-12 记录。汇总 aichat「决策能力 / 技能联想」两类后续需求,与上方 B 路线互补,便于排期。
|
||||||
|
|
||||||
|
### A/B 路线拆分
|
||||||
|
|
||||||
|
- **A 线 — UX 快赢(进行中/部分已完成)**:对话管理(时间分组 + 归档折叠 + 重启恢复对话/窗口)、技能指令注入等体验改进,低成本快速落地。
|
||||||
|
- **B 线 — 决策能力补强(单独立项)**:当前 AI 会话是单链 ReAct,coordinator / 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 选择层、不纳入。
|
||||||
|
|
||||||
## 📊 关键指标
|
## 📊 关键指标
|
||||||
|
|
||||||
### 使用指标
|
### 使用指标
|
||||||
|
|||||||
@@ -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
94
docs/todo.md
Normal 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 僵尸 crate(df-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 保其他对话 pending;ai_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 — 阻断性 bug(df-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 从不传入 NodeContext,HumanNode 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 失败时补偿删除已建 project;Repository 不支持跨 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 file,4 大功能域(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 保留诊断能力但不污染 console;vue-tsc 0 err,src/ 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 子 module(commands/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 3(Agent 内智能路由 + 成本预算 + 模型级联)
|
||||||
|
- `node_executions` 全表 list 命令(当前只写不读)
|
||||||
|
- `do_promote` crate 层 TODO(promotion.rs,现走前端闭环)
|
||||||
1993
package-lock.json
generated
Normal file
1993
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
271
src-tauri/src/commands/ai/agentic.rs
Normal file
271
src-tauri/src/commands/ai/agentic.rs
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
238
src-tauri/src/commands/ai/audit.rs
Normal file
238
src-tauri/src/commands/ai/audit.rs
Normal 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_all),Med/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
|
||||||
|
}
|
||||||
531
src-tauri/src/commands/ai/commands.rs
Normal file
531
src-tauri/src/commands/ai/commands.rs
Normal 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_flag,stream_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(())
|
||||||
|
}
|
||||||
269
src-tauri/src/commands/ai/conversation.rs
Normal file
269
src-tauri/src/commands/ai/conversation.rs
Normal 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("已截断"));
|
||||||
|
}
|
||||||
|
}
|
||||||
557
src-tauri/src/commands/ai/knowledge_inject.rs
Normal file
557
src-tauri/src/commands/ai/knowledge_inject.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
146
src-tauri/src/commands/ai/mod.rs
Normal file
146
src-tauri/src/commands/ai/mod.rs
Normal 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,
|
||||||
|
}
|
||||||
86
src-tauri/src/commands/ai/prompt.rs
Normal file
86
src-tauri/src/commands/ai/prompt.rs
Normal 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
|
||||||
|
}
|
||||||
169
src-tauri/src/commands/ai/skills.rs
Normal file
169
src-tauri/src/commands/ai/skills.rs
Normal 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())
|
||||||
|
}
|
||||||
115
src-tauri/src/commands/ai/stream_recv.rs
Normal file
115
src-tauri/src/commands/ai/stream_recv.rs
Normal 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 timeout(120s 无 chunk):判定连接静默断,emit AiError 返回 None
|
||||||
|
/// - 流尽但从未收到 finished 信号:判定异常中断,emit AiError 返回 None(丢弃残缺,不当完整入库)
|
||||||
|
/// - 用户停止(stop_flag):break 返回 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
140
src-tauri/src/commands/ai/title.rs
Normal file
140
src-tauri/src/commands/ai/title.rs
Normal 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 }
|
||||||
|
})
|
||||||
|
}
|
||||||
394
src-tauri/src/commands/ai/tool_registry.rs
Normal file
394
src-tauri/src/commands/ai/tool_registry.rs
Normal 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)
|
||||||
|
}
|
||||||
@@ -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_to(update_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-10,IPC 层 *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
Reference in New Issue
Block a user