Compare commits
280
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd6a41fe6e | ||
|
|
330bb7f505 | ||
|
|
4b5da38e28 | ||
|
|
44ecdf825e | ||
|
|
126bee5b24 | ||
|
|
e2ece2d898 | ||
|
|
e5a89165e6 | ||
|
|
b22e9ae912 | ||
|
|
e4c2e310a5 | ||
|
|
7911bc292c | ||
|
|
de049704c6 | ||
|
|
4e3a11f925 | ||
|
|
4746288a70 | ||
|
|
5811991887 | ||
|
|
4f8f1cf794 | ||
|
|
5c395f4825 | ||
|
|
70de99ae77 | ||
|
|
26765a050a | ||
|
|
c61a222db8 | ||
|
|
ad25925af7 | ||
|
|
74f38609e0 | ||
|
|
ba47f44ab8 | ||
|
|
4a87c552cc | ||
|
|
e5a8fec1fe | ||
|
|
6a7a8cbaac | ||
|
|
7d5402951b | ||
|
|
19eb14ffc8 | ||
|
|
6034e58b43 | ||
|
|
2f8b13ff7c | ||
|
|
d3e7640b8d | ||
|
|
cea0c71546 | ||
|
|
ef465625ed | ||
|
|
353b07427f | ||
|
|
f217e39bfe | ||
|
|
e981c1492a | ||
|
|
44d1c6a00c | ||
|
|
db129fd78f | ||
|
|
8da6bdcec2 | ||
|
|
968c3df5ab | ||
|
|
ec1daa8844 | ||
|
|
c38cefe47b | ||
|
|
5feeaa6cb0 | ||
|
|
998a2f243d | ||
|
|
f1a06732fd | ||
|
|
b42cc66e7a | ||
|
|
b237f716dd | ||
|
|
874cad22db | ||
|
|
11b579eb87 | ||
|
|
cfae6027d2 | ||
|
|
7748e3ecbc | ||
|
|
64ece1d3a0 | ||
|
|
03f0effcf2 | ||
|
|
55209cfbf9 | ||
|
|
e32eca3572 | ||
|
|
f5fd580215 | ||
|
|
a2330de2a8 | ||
|
|
356c8f27f8 | ||
|
|
3794b3d333 | ||
|
|
a1ad93ab31 | ||
|
|
35c976793b | ||
|
|
3e97fff328 | ||
|
|
a5d125847f | ||
|
|
2d6eff66a7 | ||
|
|
4dee706631 | ||
|
|
c908a570e5 | ||
|
|
a81cab074a | ||
|
|
a0987bad7d | ||
|
|
f5f2358799 | ||
|
|
d6e45f7bc6 | ||
|
|
a3835e2acb | ||
|
|
f3a287bbd4 | ||
|
|
55bc1e1b23 | ||
|
|
ed35c51a47 | ||
|
|
e0ffd98223 | ||
|
|
f026880fa7 | ||
|
|
4eaff04ac1 | ||
|
|
6a75c11dfa | ||
|
|
96a05638b1 | ||
|
|
63fe0a6375 | ||
|
|
f1aa7ac33d | ||
|
|
6769100b57 | ||
|
|
86f6c9685b | ||
|
|
e6dbb14c5d | ||
|
|
d899c58682 | ||
|
|
2b40f65ef2 | ||
|
|
7c708a6e01 | ||
|
|
6ad4ec2848 | ||
|
|
5c15b72d3d | ||
|
|
d96041721a | ||
|
|
2a4d745c5b | ||
|
|
2c8764abee | ||
|
|
d817386418 | ||
|
|
0142654be3 | ||
|
|
1dc8b12ec4 | ||
|
|
6d2244c03b | ||
|
|
60b01d03ee | ||
|
|
a2871a66e0 | ||
|
|
0ca5d9805f | ||
|
|
02bee38738 | ||
|
|
8abcd567a6 | ||
|
|
36cde88b80 | ||
|
|
ecff4cc23d | ||
|
|
b4f738bada | ||
|
|
3055b06734 | ||
|
|
aa75454878 | ||
|
|
f6e4e824fa | ||
|
|
645f73d1a3 | ||
|
|
74003bc04f | ||
|
|
1cd7652a34 | ||
|
|
924158cff2 | ||
|
|
0633aa6614 | ||
|
|
6b67214395 | ||
|
|
2065335c8c | ||
|
|
ca9b3187b5 | ||
|
|
9d1e5c2737 | ||
|
|
a622bdca61 | ||
|
|
6b2dd8fb5d | ||
|
|
bac3c76fa2 | ||
|
|
cf829a1933 | ||
|
|
9c60c8fb78 | ||
|
|
4317d02e0f | ||
|
|
e1d396dbf2 | ||
|
|
e3cd44802a | ||
|
|
71718c1cd8 | ||
|
|
e38474b794 | ||
|
|
3f160464ea | ||
|
|
4be15912cd | ||
|
|
a19f535134 | ||
|
|
0289d8c37c | ||
|
|
3f90aad2d6 | ||
|
|
e16d038bb2 | ||
|
|
32878e9ceb | ||
|
|
2c8004df65 | ||
|
|
80c0955a1a | ||
|
|
b3684f4d1f | ||
|
|
247dc5da4a | ||
|
|
2d02cff7a6 | ||
|
|
83cd8ed256 | ||
|
|
79b6a43095 | ||
|
|
31ea151bb2 | ||
|
|
332a6bba1a | ||
|
|
b6fe405431 | ||
|
|
31e048faf7 | ||
|
|
e23b316faf | ||
|
|
f2a56b6982 | ||
|
|
6ec191e750 | ||
|
|
0202b5195e | ||
|
|
5ca178436f | ||
|
|
fb4b067728 | ||
|
|
de9809688e | ||
|
|
4194842561 | ||
|
|
7fd29e68c3 | ||
|
|
c191d122bd | ||
|
|
4c84fbc1b6 | ||
|
|
63bff8bf96 | ||
|
|
76d823ded0 | ||
|
|
8667e850e3 | ||
|
|
2446d1b876 | ||
|
|
2ddfea67c7 | ||
|
|
5b7f342e14 | ||
|
|
a26b86e003 | ||
|
|
8a142c212c | ||
|
|
afcdf5e17d | ||
|
|
313459b8e4 | ||
|
|
8dad15ecc1 | ||
|
|
44bc18294c | ||
|
|
bebf2ec329 | ||
|
|
107b8486ae | ||
|
|
5c2122ac3b | ||
|
|
22d866d4de | ||
|
|
b3e78e6061 | ||
|
|
d3e6f80d2b | ||
|
|
84a6fc41d1 | ||
|
|
766f235ab7 | ||
|
|
36f70b3229 | ||
|
|
d3c9b4ad47 | ||
|
|
741b0b9d0c | ||
|
|
c10adaf0b0 | ||
|
|
25409e5329 | ||
|
|
539b5ed63e | ||
|
|
b5a9557b50 | ||
|
|
129f0f0156 | ||
|
|
d242e8c69c | ||
|
|
4bdaac5983 | ||
|
|
b94e74a96d | ||
|
|
dfe0096498 | ||
|
|
35b8eac46b | ||
|
|
2069f79198 | ||
|
|
d00b30f0ba | ||
|
|
ba7f35552b | ||
|
|
7d5cd4c89a | ||
|
|
212a927eee | ||
|
|
fc767e1db6 | ||
|
|
49e61d4153 | ||
|
|
4dc5219a3c | ||
|
|
38c7180365 | ||
|
|
73ed4bd637 | ||
|
|
10e4945e5a | ||
|
|
d2cb38cdac | ||
|
|
f30df333b3 | ||
|
|
672d677046 | ||
|
|
b880a976c2 | ||
|
|
2196c7748f | ||
|
|
6254d06c7a | ||
|
|
d6eb8551da | ||
|
|
d8f1d97804 | ||
|
|
f95fa5ee8f | ||
|
|
fb68c731a4 | ||
|
|
b9bdd2eb8b | ||
|
|
8382730bbd | ||
|
|
be38a44b0c | ||
|
|
dcd8b9359a | ||
|
|
c7a1d5665f | ||
|
|
2ddd2940cc | ||
|
|
6116c9bb4a | ||
|
|
b300bb721a | ||
|
|
40461b260a | ||
|
|
ac8dab3855 | ||
|
|
5e6aa7920a | ||
|
|
7637f297c9 | ||
|
|
ef2d033ad6 | ||
|
|
16aeacc37b | ||
|
|
94cdcdcb6a | ||
|
|
f574562a8d | ||
|
|
dc27e79fd2 | ||
|
|
b08adcb9da | ||
|
|
06a2dea1d7 | ||
|
|
8710d6cfd1 | ||
|
|
d809cf4b18 | ||
|
|
19d64fc421 | ||
|
|
892a642bd4 | ||
|
|
fddca9daf1 | ||
|
|
0e196ee86f | ||
|
|
9bf9e12058 | ||
|
|
dfb459a8d6 | ||
|
|
4665e910ee | ||
|
|
af8f74e29e | ||
|
|
f93b7584ff | ||
|
|
8720b9424d | ||
|
|
c7386a12c5 | ||
|
|
80b9243528 | ||
|
|
2de0c6ecb7 | ||
|
|
04032a2a8d | ||
|
|
4b5f096d1c | ||
|
|
4a95f6a1e2 | ||
|
|
36d68ddb26 | ||
|
|
49ac0601e1 | ||
|
|
f58743efbc | ||
|
|
f82dd8ba90 | ||
|
|
b195a38d64 | ||
|
|
b47d45ea4e | ||
|
|
f3c0967f41 | ||
|
|
7e27370923 | ||
|
|
5367f19f1c | ||
|
|
8dbe3d277b | ||
|
|
698a874709 | ||
|
|
3c5f702288 | ||
|
|
cf18678634 | ||
|
|
3e1f119003 | ||
|
|
0ec2b3fd56 | ||
|
|
0bb96fc509 | ||
|
|
3e042f049b | ||
|
|
65c475b549 | ||
|
|
d6cd2805be | ||
|
|
406b9216de | ||
|
|
9e2aeffbe4 | ||
|
|
6a6f9829e3 | ||
|
|
bf8e105440 | ||
|
|
057a2121a5 | ||
|
|
f0c525d0e7 | ||
|
|
422cef00e5 | ||
|
|
89da9fad9a | ||
|
|
4aa689e110 | ||
|
|
0789a36030 | ||
|
|
d9d4a0fcf6 | ||
|
|
22964a2e20 | ||
|
|
02ff88fc61 | ||
|
|
d5a641797c | ||
|
|
3f0839ace1 | ||
|
|
cf017f81e2 |
+13
@@ -47,3 +47,16 @@ coverage/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# 损坏备份(FR-S7 write_file 事故残留)+临时文件
|
||||
*.broken.bak
|
||||
tmp/
|
||||
.temp/
|
||||
|
||||
# AI 编排脚本(Claude Code Workflow 临时产物,非产品代码)
|
||||
workflows/
|
||||
|
||||
# MCP/SQLite 测试库(mcp_test2 --db 残留)
|
||||
devflow-dev.db
|
||||
devflow-dev.db-shm
|
||||
devflow-dev.db-wal
|
||||
|
||||
+242
-216
@@ -31,6 +31,48 @@
|
||||
└→ 🎯 Release (发布) — 合并多个 Task → 集成测试 → 发布
|
||||
```
|
||||
|
||||
### AI Working 定位体系
|
||||
|
||||
DevFlow 的终极交互模型是 **AI 驱动 (AI Working)**:**AI 是系统的主要操作者,人是监督者与决策者**。
|
||||
|
||||
```
|
||||
┌──────────────────────────────────┐
|
||||
│ AI Chat(唯一对话入口) │
|
||||
│ 工具注册表 ←→ Agentic Loop ←→ 审批 │
|
||||
└──────────────┬───────────────────┘
|
||||
│
|
||||
┌───────┬───────┬───┴───┬───────┬───────┐
|
||||
│ 查看 │ 驱动 │ 创意 │ 决策 │ 录入 │ ← 人的五种行为
|
||||
└───────┴───────┴───┬───┴───────┴───────┘
|
||||
│
|
||||
┌──────────────┴──────────────┐
|
||||
│ IPC 命令层 │
|
||||
│ project / task / idea / │
|
||||
│ knowledge / workflow / ai │
|
||||
└──────────────┬──────────────┘
|
||||
│
|
||||
┌──────────────┴──────────────┐
|
||||
│ 数据层 (SQLite) │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
#### 人的五种行为(均通过 AI Chat 完成)
|
||||
|
||||
| 行为 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| **查看数据** | 不点页面翻列表,自然语言查询 | "最近 blocked 的任务有哪些?" → AI 调 `list_tasks` |
|
||||
| **驱动执行** | 不手动推进状态,对话指令驱动 | "把子1推进到 in_progress" → AI 调 `advance_task` |
|
||||
| **创意捕获** | 不打开灵感表单,对话中自然捕获 | "这个想法记一下" → AI 调 `create_idea` |
|
||||
| **决策审批** | 不逐条点批准,Chat 内确认/拒绝 | 工具审批卡片(Agentic Loop 内) |
|
||||
| **录入信息** | 不填 CRUD 表单,自然语言录入 | "建个项目叫 foo,在 E:/bar" → AI 调 `create_project` |
|
||||
|
||||
#### 关键设计原则
|
||||
|
||||
1. **AI 工具注册表是主接口**:IPC 就绪后必须立刻注册为 AI 工具,页面是旁路查看器
|
||||
2. **页面降级为「查看层」**:Dashboard / Projects / Tasks / Ideas 等页面用于事后查看、审计、回放
|
||||
3. **功能优先级 = AI 工具覆盖度**:人对模块的操作能力取决于 AI 工具注册表中该模块的工具是否完整
|
||||
4. **灵感 / 项目 / 任务 三模块的 AI 工具必须完全覆盖 CRUD + 状态推进 + 升级/评估**
|
||||
|
||||
## 二、技术栈
|
||||
|
||||
| 层 | 技术 | 说明 |
|
||||
@@ -63,7 +105,7 @@
|
||||
│ Workflow Engine (DAG · Node · State · Event · Persist) │
|
||||
├──────────────────────┬───────────────────────────────────────────┤
|
||||
│ AI Orchestrator │ Execution Runtime │
|
||||
│ Multi-Provider │ Shell · Docker · SSH · Git (libgit2) │
|
||||
│ Multi-Provider │ Shell (tokio::process) │
|
||||
│ Agent Coordinator │ Merge · Conflict Resolve │
|
||||
├──────────────────────┴───────────────────────────────────────────┤
|
||||
│ Storage Layer (SQLite) │
|
||||
@@ -79,27 +121,25 @@ devflow/
|
||||
├── crates/
|
||||
│ ├── df-core/ # 核心类型、错误、常量、事件
|
||||
│ ├── df-workflow/ # 工作流 DAG 引擎 (核心)
|
||||
│ ├── df-nodes/ # 内置节点集合 (AI/Script/Docker/Git/Human/HTTP/Subflow)
|
||||
│ ├── df-ai/ # AI 编排层 (Multi-Provider/Router/Coordinator)
|
||||
│ ├── df-execute/ # 执行运行时 (Shell/Docker/SSH/Git)
|
||||
│ ├── df-nodes/ # 内置节点集合 (AI/Script/Human)
|
||||
│ ├── df-ai/ # AI 编排层 (Multi-Provider/Coordinator)
|
||||
│ ├── df-execute/ # 执行运行时 (Shell)
|
||||
│ ├── df-storage/ # 存储层 (SQLite)
|
||||
│ ├── df-ideas/ # 想法池引擎 (捕捉/评估/评分/晋升)
|
||||
│ ├── df-project/ # 多项目管理 (调度/上下文/时间线)
|
||||
│ ├── df-task/ # 任务/分支管理 (并行任务/分支/合并/冲突)
|
||||
│ ├── df-traceability/ # 可追溯性 (标注/决策留痕/需求-测试映射)
|
||||
│ ├── df-evolve/ # 经验进化 (知识沉淀/Prompt模板/审查规则/踩坑经验)
|
||||
│ ├── df-stages/ # 阶段插件 (5 阶段内置模板)
|
||||
│ └── df-plugin/ # 插件系统 (WASM/动态库)
|
||||
├── src/ # Tauri 主入口
|
||||
│ ├── main.rs
|
||||
│ ├── state.rs
|
||||
│ └── commands/ # IPC 命令
|
||||
├── frontend/ # Vue 3 前端
|
||||
│ └── df-project/ # 多项目管理 (调度/上下文/时间线)
|
||||
│
|
||||
│ 注: df-task / df-traceability / df-evolve / df-stages / df-plugin
|
||||
│ 5 个 crate 已移除(2026-06-14 零引用清理,推翻原"保留骨架"取舍)
|
||||
├── src-tauri/ # Tauri 主入口 (Rust)
|
||||
│ └── src/
|
||||
│ ├── views/ # 页面组件
|
||||
│ ├── components/ # 业务组件
|
||||
│ ├── stores/ # Pinia 状态
|
||||
│ └── composables/ # 组合式函数
|
||||
│ ├── main.rs
|
||||
│ ├── state.rs
|
||||
│ └── commands/ # IPC 命令
|
||||
├── src/ # Vue 3 前端
|
||||
│ ├── views/ # 页面组件
|
||||
│ ├── components/ # 业务组件
|
||||
│ ├── stores/ # Pinia 状态
|
||||
│ └── composables/ # 组合式函数
|
||||
├── templates/ # 工作流模板 (YAML)
|
||||
└── plugins/ # 外部插件目录
|
||||
```
|
||||
@@ -137,7 +177,9 @@ devflow/
|
||||
|
||||
项目状态:`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 分支。
|
||||
|
||||
@@ -165,7 +207,9 @@ Task 生命周期:
|
||||
2. AI 辅助解决:冲突文件交给 AI 分析并建议解决方案
|
||||
3. 发布编排:选择多个 Task → 创建 release 分支 → 合并 → 集成测试 → 发布
|
||||
|
||||
### 5.4 Traceability & Annotation (df-traceability)
|
||||
### 5.4 ~~Traceability & Annotation (df-traceability)~~ — 已移除
|
||||
|
||||
> **2026-06-14 零引用清理**:df-traceability crate 已删除。以下内容保留作为历史设计参考,不再对应实际代码。
|
||||
|
||||
贯穿所有阶段的可追溯性引擎。
|
||||
|
||||
@@ -238,237 +282,219 @@ pub struct Decision {
|
||||
|
||||
### 5.4 AI Orchestrator (df-ai)
|
||||
|
||||
- **LlmProvider trait**:统一接口,各模型实现
|
||||
- **ModelRouter**:按任务类型路由到最优模型 + 降级链
|
||||
- **AgentCoordinator**:多 Agent 协作(Planner/Coder/Reviewer/Fixer)
|
||||
- **ContextManager**:Token 预算管理
|
||||
- **ToolRegistry**:工具注册(供 Agent 调用)
|
||||
- **LlmProvider trait**:统一接口,各模型实现(`provider.rs`)✅
|
||||
- **ModelRouter**:按任务类型路由到最优模型 + 降级链 — ❌ 已删(R-PD-7),模型选择改由调用方在 `LlmProvider` 实现间直接指定
|
||||
- **AgentCoordinator**:多 Agent 协作(Planner/Coder/Reviewer/Fixer)— ⚠️ 骨架空壳(`coordinator.rs`,仅单元结构体 + 日志,未实现实际协调)
|
||||
- **ContextManager**:Token 预算管理(`context.rs`,完整实现)✅
|
||||
- **ToolRegistry**:工具注册(`ai_tools.rs`,供 Agent 调用)✅
|
||||
|
||||
### 5.5 内置节点 (df-nodes)
|
||||
|
||||
| 节点 | 功能 |
|
||||
|------|------|
|
||||
| AINode | 调用 LLM,支持流式输出、工具调用 |
|
||||
| ScriptNode | Shell/脚本执行 |
|
||||
| DockerNode | Docker 容器操作 |
|
||||
| GitNode | Git 操作 (libgit2) |
|
||||
| HumanNode | 人工审批/确认 (阻塞) |
|
||||
| NotifyNode | 通知 (桌面/飞书/Webhook) |
|
||||
| HTTPNode | HTTP 请求 |
|
||||
| SubflowNode | 嵌套子工作流 |
|
||||
| 节点 | 功能 | 状态 |
|
||||
|------|------|------|
|
||||
| AINode | 调用 LLM,支持流式输出、工具调用 | ✅ 已实现 |
|
||||
| ScriptNode | Shell/脚本执行 | ✅ 已实现 |
|
||||
| HumanNode | 人工审批/确认 (阻塞) | ✅ 已实现 |
|
||||
| DockerNode | Docker 容器操作 | ❌ 未实现 |
|
||||
| GitNode | Git 操作 (libgit2) | ❌ 未实现 |
|
||||
| NotifyNode | 通知 (桌面/飞书/Webhook) | ❌ 未实现 |
|
||||
| HTTPNode | HTTP 请求 | ❌ 未实现 |
|
||||
| SubflowNode | 嵌套子工作流 | ❌ 未实现 |
|
||||
|
||||
> `crates/df-nodes/src/` 实际仅 ai_node / script_node / human_node 3 文件(Docker/Git/Notify/HTTP/Subflow 为设计预留,尚未实现)。详见 [df-nodes 模块文档](docs/03-模块文档/df-nodes-节点集合-2026-06-12.md)。
|
||||
|
||||
## 六、数据模型
|
||||
|
||||
### SQLite 表结构
|
||||
|
||||
> 真相源:`crates/df-storage/src/migrations.rs`(V1-V13,幂等迁移)。下表对齐真实 schema,字段/类型/默认值/外键与迁移文件一致。Rust 结构体见 `crates/df-storage/src/models.rs`。
|
||||
|
||||
共 **13 张业务表**(另含内部表 `schema_version` 记录迁移版本)。
|
||||
|
||||
```sql
|
||||
-- 想法池
|
||||
-- 想法池 (V1 建表 / V2 补 promoted_to + ai_analysis + scores)
|
||||
CREATE TABLE ideas (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
tags TEXT, -- JSON array
|
||||
source TEXT NOT NULL, -- Manual/Clipboard/AI
|
||||
status TEXT NOT NULL, -- Draft/PendingReview/Approved/Rejected/Promoted/Archived
|
||||
scores TEXT, -- JSON: {market_potential, competition, feasibility, roi, overall}
|
||||
ai_analysis TEXT,
|
||||
related_ideas TEXT, -- JSON array of idea ids
|
||||
promoted_to TEXT, -- project id
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'draft', -- draft/pending_review/approved/rejected/promoted/archived
|
||||
priority INTEGER NOT NULL DEFAULT 1,
|
||||
score REAL,
|
||||
tags TEXT, -- JSON 数组
|
||||
source TEXT,
|
||||
promoted_to TEXT, -- 晋升后的 project_id (V2)
|
||||
ai_analysis TEXT, -- AI 分析结果 JSON (V2)
|
||||
scores TEXT, -- 多维评分 JSON: feasibility/impact/urgency/overall (V2)
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- 项目
|
||||
-- 项目 (V1 建表 / V11 补 deleted_at / V12 补 path + stack)
|
||||
CREATE TABLE projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
idea_id TEXT, -- 来源想法
|
||||
repo_path TEXT, -- 仓库路径
|
||||
tech_stack TEXT, -- JSON array
|
||||
status TEXT NOT NULL, -- Planning/InProgress/Testing/Releasing/Completed/Paused/Cancelled
|
||||
priority INTEGER NOT NULL DEFAULT 1, -- 0=Low, 1=Medium, 2=High, 3=Critical
|
||||
current_stage TEXT, -- Idea/Requirement/Coding/Testing/Release
|
||||
config TEXT, -- JSON: 项目级配置
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'planning',
|
||||
idea_id TEXT REFERENCES ideas(id),
|
||||
path TEXT, -- 绑定的本地代码目录绝对路径 (V12,可空=未绑定)
|
||||
stack TEXT, -- 技术栈 JSON 数组字符串 (V12,探测填充)
|
||||
deleted_at TEXT, -- 软删回收站: NULL=正常, 非空=已进回收站 (V11)
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- 工作流定义
|
||||
CREATE TABLE workflow_defs (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
task_id TEXT, -- 关联的任务 (可为空,如项目级工作流)
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
dag TEXT NOT NULL, -- JSON: DAG 结构
|
||||
template_id TEXT, -- 来源模板
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- 任务 (项目内的并行工作单元)
|
||||
-- 任务 (V1 建表 / V2 补 workflow_def_id + base_branch)
|
||||
CREATE TABLE tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
status TEXT NOT NULL, -- Todo/InProgress/InReview/Testing/Done/Blocked/Cancelled
|
||||
priority INTEGER NOT NULL DEFAULT 1, -- 0=Low, 1=Medium, 2=High, 3=Critical
|
||||
branch_name TEXT, -- Git 分支名
|
||||
base_branch TEXT DEFAULT 'main', -- 基于哪个分支创建
|
||||
workflow_def_id TEXT, -- 关联的工作流定义
|
||||
assignee TEXT, -- AI agent 或人工
|
||||
merge_conflict TEXT, -- JSON: 冲突信息
|
||||
merged_at INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'todo', -- todo/in_progress/in_review/testing/done/blocked/cancelled
|
||||
priority INTEGER NOT NULL DEFAULT 1,
|
||||
branch_name TEXT,
|
||||
assignee TEXT,
|
||||
workflow_def_id TEXT, -- 关联的工作流定义 ID (V2)
|
||||
base_branch TEXT, -- 基础分支 (V2)
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- 发布计划 (合并多个 Task → 发布)
|
||||
-- 分支 (V2 建表) — 任务与 Git 分支绑定
|
||||
CREATE TABLE branches (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
task_id TEXT REFERENCES tasks(id),
|
||||
name TEXT NOT NULL,
|
||||
base TEXT NOT NULL DEFAULT 'main',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
merged_at TEXT
|
||||
);
|
||||
|
||||
-- 发布计划 (V1 建表)
|
||||
CREATE TABLE releases (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
version TEXT NOT NULL, -- e.g. v2.3.0
|
||||
branch_name TEXT, -- release 分支
|
||||
task_ids TEXT NOT NULL, -- JSON array: 包含的 Task ID 列表
|
||||
status TEXT NOT NULL, -- Planning/Integrating/Testing/Ready/Published
|
||||
workflow_def_id TEXT, -- 发布工作流
|
||||
changelog TEXT,
|
||||
published_at INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
version TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'planned',
|
||||
task_ids TEXT NOT NULL DEFAULT '[]', -- JSON 数组: 包含的 Task ID 列表
|
||||
changelog TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
released_at TEXT
|
||||
);
|
||||
|
||||
-- 工作流执行
|
||||
CREATE TABLE workflow_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
workflow_def_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
task_id TEXT, -- 关联的任务 (可为空)
|
||||
release_id TEXT, -- 关联的发布 (可为空)
|
||||
status TEXT NOT NULL DEFAULT 'pending', -- Pending/Running/Paused/Completed/Failed/Cancelled
|
||||
node_states TEXT, -- JSON: {node_id: {status(Pending/Running/Completed/Failed/Skipped/Waiting), output, started_at, finished_at}}
|
||||
current_layer INTEGER,
|
||||
started_at INTEGER,
|
||||
finished_at INTEGER,
|
||||
error TEXT
|
||||
-- 工作流执行 (V1 建表 / V2 补 project_id + task_id)
|
||||
-- 注: 不区分 def/run, DAG 直接序列化进 dag_json, 一次执行一行
|
||||
CREATE TABLE workflow_executions (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
dag_json TEXT NOT NULL, -- DAG 的 JSON 序列化
|
||||
status TEXT NOT NULL DEFAULT 'pending', -- pending/running/paused/completed/failed/cancelled
|
||||
triggered_by TEXT,
|
||||
project_id TEXT, -- 关联项目 ID (V2)
|
||||
task_id TEXT, -- 关联任务 ID (V2)
|
||||
created_at TEXT NOT NULL,
|
||||
completed_at TEXT
|
||||
);
|
||||
|
||||
-- 产出物
|
||||
CREATE TABLE artifacts (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
workflow_run_id TEXT,
|
||||
node_id TEXT,
|
||||
kind TEXT NOT NULL, -- PRD/CodeChange/TestReport/ReleaseNote/Image
|
||||
title TEXT,
|
||||
content TEXT,
|
||||
file_path TEXT,
|
||||
metadata TEXT, -- JSON
|
||||
created_at INTEGER NOT NULL
|
||||
-- 节点执行 (V1 建表) — 工作流执行过程中每个节点的运行快照
|
||||
CREATE TABLE node_executions (
|
||||
id TEXT PRIMARY KEY,
|
||||
workflow_id TEXT NOT NULL REFERENCES workflow_executions(id),
|
||||
node_id TEXT NOT NULL,
|
||||
node_type TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
input_json TEXT,
|
||||
output_json TEXT,
|
||||
error_message TEXT,
|
||||
started_at TEXT,
|
||||
completed_at TEXT
|
||||
);
|
||||
|
||||
-- 连接配置
|
||||
CREATE TABLE connections (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
kind TEXT NOT NULL, -- MySQL/SSH/MongoDB/Redis/HTTP
|
||||
config TEXT NOT NULL, -- JSON (加密存储)
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- AI 模型配置
|
||||
-- AI 提供商配置 (V9 建表) — 多 Provider 统一抽象
|
||||
CREATE TABLE ai_providers (
|
||||
id TEXT PRIMARY KEY,
|
||||
provider TEXT NOT NULL, -- Claude/GLM/DeepSeek/OpenAI
|
||||
api_key TEXT, -- 加密存储
|
||||
base_url TEXT,
|
||||
models TEXT, -- JSON: 可用模型列表
|
||||
is_default INTEGER DEFAULT 0,
|
||||
config TEXT -- JSON: 额外配置
|
||||
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, -- JSON array of model names
|
||||
is_default INTEGER NOT NULL DEFAULT 0,
|
||||
config TEXT, -- JSON extra config
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- 需求功能清单
|
||||
CREATE TABLE features (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
task_id TEXT, -- 关联任务
|
||||
requirement_id TEXT, -- 来源需求
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'selected', -- Selected/Deferred/Rejected/Completed
|
||||
priority INTEGER NOT NULL DEFAULT 1, -- 0=Low, 1=Medium, 2=High, 3=Critical
|
||||
rejection_reason TEXT, -- 不做的原因(自动进入决策留痕)
|
||||
test_case_count INTEGER DEFAULT 0,
|
||||
order_index INTEGER NOT NULL, -- 排序
|
||||
metadata TEXT, -- JSON: 额外属性
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
-- AI 对话 (V3 建表 / V4 补 archived / V5 补 prompt_tokens + completion_tokens / V6 补 models)
|
||||
CREATE TABLE ai_conversations (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT,
|
||||
messages TEXT NOT NULL DEFAULT '[]', -- JSON array of ChatMessage
|
||||
provider_id TEXT,
|
||||
model TEXT,
|
||||
models TEXT, -- 用过的所有 model JSON 数组字符串 (去重, V6)
|
||||
archived INTEGER NOT NULL DEFAULT 0, -- 是否归档(侧栏折叠展示, V4)
|
||||
prompt_tokens INTEGER, -- 输入 token 累计 (流式 usage 落库, V5)
|
||||
completion_tokens INTEGER, -- 输出 token 累计 (流式 usage 落库, V5)
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- 测试用例
|
||||
CREATE TABLE test_cases (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
feature_id TEXT NOT NULL, -- 关联功能
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
preconditions TEXT, -- 前置条件
|
||||
steps TEXT NOT NULL, -- JSON: 测试步骤 [{step, expected}]
|
||||
kind TEXT NOT NULL DEFAULT 'Manual', -- Manual/Auto/AI-Generated
|
||||
priority INTEGER NOT NULL DEFAULT 1, -- 0=Low, 1=Medium, 2=High, 3=Critical
|
||||
-- AI 工具执行审计 (V9 建表) — Agent 工具调用的审批/执行留痕
|
||||
CREATE TABLE 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', -- pending/approved/rejected/executing/completed/failed
|
||||
risk_level TEXT NOT NULL DEFAULT 'medium', -- low/medium/high
|
||||
requested_at TEXT NOT NULL,
|
||||
executed_at TEXT,
|
||||
decided_by TEXT -- human/auto
|
||||
);
|
||||
|
||||
-- 测试执行记录
|
||||
CREATE TABLE test_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
task_id TEXT, -- 关联任务
|
||||
feature_id TEXT, -- 关联功能
|
||||
test_case_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL, -- Passed/Failed/Skipped/Blocked
|
||||
actual_result TEXT,
|
||||
error_detail TEXT, -- 失败详情
|
||||
executed_by TEXT, -- AI/Human
|
||||
duration_ms INTEGER,
|
||||
executed_at INTEGER NOT NULL
|
||||
-- 知识条目 (V7 建表 / V8 补 embedding / V10 补 reasoning) — 经验沉淀基本单元, 共享记忆层
|
||||
-- 状态机: candidate → pending_review → published → archived
|
||||
CREATE TABLE knowledges (
|
||||
id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL DEFAULT 'pitfall', -- 7 种: review_rule/prompt_template/pitfall/architecture_pattern/diagnosis/deployment_note/workflow_optimization
|
||||
title TEXT NOT NULL,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
tags TEXT, -- JSON 数组字符串
|
||||
status TEXT NOT NULL DEFAULT 'candidate', -- candidate|pending_review|published|archived
|
||||
confidence TEXT, -- high|medium|low (AI 提炼自评)
|
||||
reuse_count INTEGER NOT NULL DEFAULT 0, -- 唯一客观排序信号
|
||||
verified INTEGER NOT NULL DEFAULT 0, -- 发布审核时一次性人工标
|
||||
source_project TEXT, -- 来源项目(仅溯源不过滤)
|
||||
source_ref TEXT, -- 来源实体引用(如 conv:{id})
|
||||
reasoning TEXT, -- AI 提炼判断依据("为何值得沉淀"), 手动录入为 NULL (V10)
|
||||
embedding BLOB, -- Vec<f32> 小端字节序列化, NULL=未嵌入走 LIKE 降级 (V8)
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- 标注 (贯穿所有内容)
|
||||
CREATE TABLE annotations (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
entity_type TEXT NOT NULL, -- Requirement/Feature/Code/TestCase/TestReport/DesignDoc
|
||||
entity_id TEXT NOT NULL, -- 关联实体 ID
|
||||
marker TEXT NOT NULL, -- FIXME/TODO/QUESTION/RISK/DECISION/OPTIMIZE
|
||||
content TEXT NOT NULL, -- 标注内容
|
||||
location TEXT, -- 文件路径+行号 / 文档段落
|
||||
status TEXT NOT NULL DEFAULT 'Open', -- Open/AI-Processing/Resolved/WontFix
|
||||
resolved_by TEXT, -- AI/Human
|
||||
resolution TEXT, -- 处理结果
|
||||
created_at INTEGER NOT NULL,
|
||||
resolved_at INTEGER
|
||||
-- 知识生命线事件 (V10 建表) — 追加型审计: 产生/审核/引用/归档
|
||||
CREATE TABLE knowledge_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
knowledge_id TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL, -- created | extracted | status_changed | referenced | archived
|
||||
source_ref TEXT, -- 触发来源: conv:{id} / manual / system
|
||||
context_json TEXT, -- JSON: 因 event_type 而异
|
||||
timestamp TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- 决策留痕
|
||||
CREATE TABLE decisions (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
context TEXT NOT NULL, -- 决策背景
|
||||
question TEXT NOT NULL, -- 需要决定的问题
|
||||
alternatives TEXT, -- JSON array: 考虑过的方案
|
||||
decision TEXT NOT NULL, -- 最终决定
|
||||
reason TEXT NOT NULL, -- 决策原因
|
||||
decided_by TEXT NOT NULL, -- AI/Human
|
||||
entity_type TEXT, -- 关联实体类型
|
||||
entity_id TEXT, -- 关联实体 ID
|
||||
impact TEXT, -- 影响范围
|
||||
stage TEXT, -- Idea/Requirement/Coding/Testing/Release
|
||||
created_at INTEGER NOT NULL
|
||||
-- 通用应用设置 (V13 建表) — 前端 localStorage 迁移目标 KV 表
|
||||
CREATE TABLE app_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL, -- JSON 字符串
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- 内部表: schema_version (version INTEGER PRIMARY KEY) — 迁移版本记录
|
||||
```
|
||||
|
||||
## 七、阶段模板
|
||||
@@ -504,7 +530,7 @@ CREATE TABLE decisions (
|
||||
|
||||
### Phase 4 — 节点丰富 + 阶段插件 (3-4 周)
|
||||
- df-nodes (Docker/Git/Human/HTTP)
|
||||
- df-stages (5 阶段模板)
|
||||
- ~~df-stages (5 阶段模板)~~ — 已移除(2026-06-14 零引用清理)
|
||||
- 条件分支 + 断点续跑
|
||||
- 验证:跑通标准产研流程模板
|
||||
|
||||
|
||||
Generated
+567
-83
@@ -83,6 +83,18 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-compression"
|
||||
version = "0.4.42"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac"
|
||||
dependencies = [
|
||||
"compression-codecs",
|
||||
"compression-core",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-executor"
|
||||
version = "1.14.0"
|
||||
@@ -97,6 +109,17 @@ dependencies = [
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-fs"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5"
|
||||
dependencies = [
|
||||
"async-lock",
|
||||
"blocking",
|
||||
"futures-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-io"
|
||||
version = "2.6.0"
|
||||
@@ -465,6 +488,12 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
@@ -489,6 +518,24 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compression-codecs"
|
||||
version = "0.4.38"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf"
|
||||
dependencies = [
|
||||
"brotli",
|
||||
"compression-core",
|
||||
"flate2",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compression-core"
|
||||
version = "0.4.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789"
|
||||
|
||||
[[package]]
|
||||
name = "concurrent-queue"
|
||||
version = "2.5.0"
|
||||
@@ -721,18 +768,30 @@ name = "devflow"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"df-ai",
|
||||
"df-core",
|
||||
"df-execute",
|
||||
"df-ideas",
|
||||
"df-mcp",
|
||||
"df-nodes",
|
||||
"df-project",
|
||||
"df-storage",
|
||||
"df-types",
|
||||
"df-workflow",
|
||||
"futures",
|
||||
"keyring",
|
||||
"percent-encoding",
|
||||
"regex",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-window-state",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
@@ -743,7 +802,8 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"df-core",
|
||||
"df-ai-core",
|
||||
"df-types",
|
||||
"eventsource-stream",
|
||||
"futures",
|
||||
"reqwest 0.12.28",
|
||||
@@ -754,26 +814,14 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "df-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "df-evolve"
|
||||
name = "df-ai-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"df-core",
|
||||
"async-trait",
|
||||
"futures",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -781,12 +829,9 @@ name = "df-execute"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"df-core",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -794,14 +839,34 @@ name = "df-ideas"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"df-core",
|
||||
"df-ai",
|
||||
"df-ai-core",
|
||||
"df-types",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "df-mcp"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"df-nodes",
|
||||
"df-storage",
|
||||
"df-types",
|
||||
"futures",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "df-nodes"
|
||||
version = "0.1.0"
|
||||
@@ -809,22 +874,9 @@ dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"df-ai",
|
||||
"df-core",
|
||||
"df-execute",
|
||||
"df-workflow",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "df-plugin"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"df-core",
|
||||
"df-storage",
|
||||
"df-types",
|
||||
"df-workflow",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -838,21 +890,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"df-core",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "df-stages"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"df-core",
|
||||
"df-workflow",
|
||||
"df-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
@@ -864,7 +902,9 @@ name = "df-storage"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"df-core",
|
||||
"df-ai-core",
|
||||
"df-types",
|
||||
"keyring",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -873,27 +913,14 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "df-task"
|
||||
name = "df-types"
|
||||
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",
|
||||
"thiserror 2.0.18",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -902,11 +929,10 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"df-core",
|
||||
"df-types",
|
||||
"futures",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
@@ -2216,6 +2242,23 @@ dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keyring"
|
||||
version = "3.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"linux-keyutils",
|
||||
"log",
|
||||
"secret-service",
|
||||
"security-framework 2.11.1",
|
||||
"security-framework 3.7.0",
|
||||
"windows-sys 0.60.2",
|
||||
"zbus 4.4.0",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
@@ -2291,6 +2334,16 @@ dependencies = [
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-keyutils"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
@@ -2410,7 +2463,7 @@ dependencies = [
|
||||
"openssl-probe",
|
||||
"openssl-sys",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
"security-framework 3.7.0",
|
||||
"security-framework-sys",
|
||||
"tempfile",
|
||||
]
|
||||
@@ -2445,6 +2498,19 @@ version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"memoffset",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "7.1.3"
|
||||
@@ -2455,12 +2521,76 @@ dependencies = [
|
||||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-complex",
|
||||
"num-integer",
|
||||
"num-iter",
|
||||
"num-rational",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-complex"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-iter"
|
||||
version = "0.1.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-rational"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
@@ -2615,6 +2745,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
@@ -2974,6 +3105,15 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||
dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "precomputed-hash"
|
||||
version = "0.1.1"
|
||||
@@ -3082,6 +3222,36 @@ version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "raw-window-handle"
|
||||
version = "0.6.2"
|
||||
@@ -3234,6 +3404,30 @@ dependencies = [
|
||||
"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]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
@@ -3410,6 +3604,34 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "secret-service"
|
||||
version = "4.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"generic-array",
|
||||
"num",
|
||||
"once_cell",
|
||||
"rand",
|
||||
"serde",
|
||||
"zbus 4.4.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"core-foundation 0.9.4",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
@@ -3632,6 +3854,17 @@ dependencies = [
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
@@ -3747,6 +3980,12 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "static_assertions"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||
|
||||
[[package]]
|
||||
name = "string_cache"
|
||||
version = "0.9.0"
|
||||
@@ -4055,6 +4294,48 @@ dependencies = [
|
||||
"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]]
|
||||
name = "tauri-plugin-opener"
|
||||
version = "2.5.4"
|
||||
@@ -4074,7 +4355,22 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"url",
|
||||
"windows",
|
||||
"zbus",
|
||||
"zbus 5.16.0",
|
||||
]
|
||||
|
||||
[[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]]
|
||||
@@ -4498,12 +4794,17 @@ version = "0.6.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
|
||||
dependencies = [
|
||||
"async-compression",
|
||||
"bitflags 2.13.0",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
@@ -5239,6 +5540,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
@@ -5272,13 +5582,30 @@ dependencies = [
|
||||
"windows_aarch64_gnullvm 0.52.6",
|
||||
"windows_aarch64_msvc 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_x86_64_gnu 0.52.6",
|
||||
"windows_x86_64_gnullvm 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]]
|
||||
name = "windows-threading"
|
||||
version = "0.1.0"
|
||||
@@ -5309,6 +5636,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.42.2"
|
||||
@@ -5321,6 +5654,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.42.2"
|
||||
@@ -5333,12 +5672,24 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.42.2"
|
||||
@@ -5351,6 +5702,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.42.2"
|
||||
@@ -5363,6 +5720,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.42.2"
|
||||
@@ -5375,6 +5738,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.42.2"
|
||||
@@ -5387,6 +5756,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "winnow"
|
||||
version = "0.5.40"
|
||||
@@ -5586,6 +5961,16 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xdg-home"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.3"
|
||||
@@ -5609,6 +5994,44 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus"
|
||||
version = "4.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725"
|
||||
dependencies = [
|
||||
"async-broadcast",
|
||||
"async-executor",
|
||||
"async-fs",
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"async-process",
|
||||
"async-recursion",
|
||||
"async-task",
|
||||
"async-trait",
|
||||
"blocking",
|
||||
"enumflags2",
|
||||
"event-listener",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"nix",
|
||||
"ordered-stream",
|
||||
"rand",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"sha1",
|
||||
"static_assertions",
|
||||
"tracing",
|
||||
"uds_windows",
|
||||
"windows-sys 0.52.0",
|
||||
"xdg-home",
|
||||
"zbus_macros 4.4.0",
|
||||
"zbus_names 3.0.0",
|
||||
"zvariant 4.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus"
|
||||
version = "5.16.0"
|
||||
@@ -5639,9 +6062,22 @@ dependencies = [
|
||||
"uuid",
|
||||
"windows-sys 0.61.2",
|
||||
"winnow 1.0.3",
|
||||
"zbus_macros",
|
||||
"zbus_names",
|
||||
"zvariant",
|
||||
"zbus_macros 5.16.0",
|
||||
"zbus_names 4.3.2",
|
||||
"zvariant 5.12.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus_macros"
|
||||
version = "4.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e"
|
||||
dependencies = [
|
||||
"proc-macro-crate 3.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
"zvariant_utils 2.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5654,9 +6090,20 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
"zbus_names",
|
||||
"zvariant",
|
||||
"zvariant_utils",
|
||||
"zbus_names 4.3.2",
|
||||
"zvariant 5.12.0",
|
||||
"zvariant_utils 3.4.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus_names"
|
||||
version = "3.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"static_assertions",
|
||||
"zvariant 4.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5667,7 +6114,7 @@ checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"winnow 1.0.3",
|
||||
"zvariant",
|
||||
"zvariant 5.12.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5756,6 +6203,19 @@ version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "4.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe"
|
||||
dependencies = [
|
||||
"endi",
|
||||
"enumflags2",
|
||||
"serde",
|
||||
"static_assertions",
|
||||
"zvariant_derive 4.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "5.12.0"
|
||||
@@ -5766,8 +6226,21 @@ dependencies = [
|
||||
"enumflags2",
|
||||
"serde",
|
||||
"winnow 1.0.3",
|
||||
"zvariant_derive",
|
||||
"zvariant_utils",
|
||||
"zvariant_derive 5.12.0",
|
||||
"zvariant_utils 3.4.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant_derive"
|
||||
version = "4.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449"
|
||||
dependencies = [
|
||||
"proc-macro-crate 3.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
"zvariant_utils 2.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5780,7 +6253,18 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
"zvariant_utils",
|
||||
"zvariant_utils 3.4.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant_utils"
|
||||
version = "2.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -16,3 +16,9 @@ chrono = { version = "0.4", features = ["serde"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
tracing = "0.1"
|
||||
regex = "1"
|
||||
# keyring v3 默认不带任何 platform 后端,必须显式启 feature——
|
||||
# 否则 Entry 走 noop store:set_password 静默成功不持久化,get_password 永远空。
|
||||
# Win+mac 启用原生后端;linux 用 async-persistent 变体避同步阻塞 Secret Service。
|
||||
# 由 df-storage(密钥解析下沉层) + src-tauri(build_provider_for 转发壳)共同引用。
|
||||
keyring = { version = "3", features = ["windows-native", "apple-native", "linux-native-async-persistent"] }
|
||||
|
||||
+708
-39
@@ -1,6 +1,6 @@
|
||||
# DevFlow — 项目进展与工作交接
|
||||
|
||||
> 创建: 2026-06-10 | 最后更新: 2026-06-12 | 当前阶段: Phase 2 aichat UX 快赢(A 路线)+ 技能联想需求调研(Sprint 8)
|
||||
> 创建: 2026-06-10 | 最后更新: 2026-06-18 | 当前阶段: PROGRESS Sprint 日志截至 Sprint 24(2026-06-15 · P0 generating 状态机加固 + 独立项批量);2026-06-16 起多会话进展(B-03 路由解耦 / 工具结果渲染 / 吞错降级 / 模型能力维度治理等)见 [docs/todo.md](docs/todo.md) + [docs/待审查.md](docs/待审查.md)
|
||||
|
||||
---
|
||||
|
||||
@@ -9,51 +9,49 @@
|
||||
| 项目 | 值 |
|
||||
|------|-----|
|
||||
| 定位 | AI 原生创作流程驾驶舱,从想法到创作成果的全流程管理 |
|
||||
| 技术栈 | Tauri v2 + Vue 3 + TypeScript + Pinia / Rust Workspace (13 crate) / SQLite |
|
||||
| 技术栈 | Tauri v2 + Vue 3 + TypeScript + Pinia / Rust Workspace (8 crate) / SQLite |
|
||||
| 路径 | `E:/wk-lab/devflow/` |
|
||||
| 架构文档 | `ARCHITECTURE.md` (22,745 字) |
|
||||
| Git 状态 | 未首次 commit,代码全在 untracked |
|
||||
| AI 能力 | df-ai OpenAI 兼容 Provider + 12 工具 + Agentic Loop |
|
||||
| Git 状态 | 已纳入版本控制(完整 commit 历史,当前分支 feat/batch-260615-workflow-unlock) |
|
||||
| AI 能力 | df-ai OpenAI/Anthropic 双协议 Provider + 12 工具 + Agentic Loop |
|
||||
|
||||
---
|
||||
|
||||
## 二、代码规模统计
|
||||
|
||||
### Rust 后端 (71 个 .rs 文件)
|
||||
### Rust 后端 (78 个 .rs 文件)
|
||||
|
||||
| Crate | 文件数 | 总行数 | 有效行 | 实现程度 |
|
||||
|-------|--------|--------|--------|---------|
|
||||
| df-core | 4 | 259 | 151 | ✅ 完整 — 错误/事件/状态枚举/ID生成 |
|
||||
| df-workflow | 7 | 489 | 333 | ✅ 核心 — DAG拓扑排序/执行器/状态机/事件总线 可用 |
|
||||
| df-storage | 4 | 294 | 217 | ✅ 部分 — SQLite连接/迁移/建表完整,**缺 CRUD 层** |
|
||||
| df-execute | 5 | 203 | 129 | ⚡ 混合 — Shell 执行器真实可用,Docker/SSH/Git 骨架 |
|
||||
| df-ideas | 6 | 421 | 278 | ⚡ 大部分 — 捕获/评估/晋升/关联图 有逻辑,评分固定值 |
|
||||
| df-task | 5 | 390 | 252 | ⚡ 大部分 — Task/Branch/Semver 可用,合并冲突 骨架 |
|
||||
| df-core | 4 | 429 | ~260 | ✅ 完整 — 错误/事件/状态枚举/ID生成 |
|
||||
| df-workflow | 9 | 908 | ~620 | ✅ 核心 — DAG拓扑排序/执行器/状态机/事件总线 可用 |
|
||||
| df-storage | 5 | ~2,100 | ~1,400 | ✅ 完整 — 连接/迁移/建表(V1-V8) + impl_repo! 宏 CRUD + KnowledgeRepo(search/list_by_status/set_embedding/search_vector 等)+ 余弦相似度工具函数 |
|
||||
| df-execute | 5 | 203 | 129 | ✅ Shell 执行器真实可用(tokio::process,被 run_command 复用) |
|
||||
| df-ideas | 7 | 761 | ~500 | ✅ 评分/对抗启发式已真实化(Sprint 9)— 捕获/评估/晋升/关联图 有逻辑,晋升/关联图仍骨架 |
|
||||
| df-project | 5 | 272 | 175 | ⚡ 混合 — 数据模型/Timeline 可用,调度器 骨架 |
|
||||
| df-ai | 6 | 281 | 173 | 🔧 接口 — 类型/Provider trait 完整,无实际 LLM 调用 |
|
||||
| df-traceability | 4 | 312 | 201 | ⚡ 混合 — 决策记录/标注构建 可用,SQLite查询 骨架 |
|
||||
| df-plugin | 4 | 255 | 158 | ⚡ 混合 — Host/Builder 有逻辑,核心加载 骨架 |
|
||||
| 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** | |
|
||||
| df-ai | 9 | 1,965 | ~1,300 | ✅ 完整 — OpenAI/Anthropic 双协议 Provider + 流式 SSE + 上下文窗口(ContextManager 分组滑窗) + token usage 解析 + 12 工具 |
|
||||
| df-nodes | 9 | ~500 | ~350 | ⚡ 混合 — script_node(接 Shell)/ ai_node(接 df-ai,Sprint 7)真实可用,human_node 半实现(审批响应 TODO),其余骨架 |
|
||||
| **合计** | **53** | **~7,400** | **~4,800** | 已删除 5 个零引用 crate(2026-06-14,原 78/9100/5900) |
|
||||
|
||||
### 前端 (9 页面 + Store + i18n)
|
||||
### 前端 (9 页面 + 2 组件 + Store + i18n)
|
||||
|
||||
| 类别 | 文件数 | 行数 | 状态 |
|
||||
|------|--------|------|------|
|
||||
| Vue 页面 | 9 | ~3,372 | ✅ UI 全部真实 — 无空壳 |
|
||||
| Pinia Store | 4 | ~362 | ⚠️ 有 state/getter 但**无 action,View 未引用** |
|
||||
| Router | 1 | 63 | ✅ 9 条路由含动态路由 |
|
||||
| i18n | 2 | ~94 | ⚠️ 翻译完整但**未接入 main.ts** |
|
||||
| Vue 页面 | 9 | ~3,372 | ✅ UI 全部真实 — 无空壳(含 AiHome/AiDetached)|
|
||||
| Vue 组件 | 2 | ~1,400 | ✅ AiChat(主面板)+ ConfirmDialog |
|
||||
| Pinia Store | 4+ | ~800 | ✅ composable 模式,View 已引用(Sprint 4 接入)|
|
||||
| Router | 1 | ~90 | ✅ 10 条路由含动态路由(Sprint 7 增 AiHome/AiDetached)|
|
||||
| i18n | 2 | ~94 | ✅ 已注册 main.ts(Sprint 7)+ localStorage 持久化 |
|
||||
| 设计系统 | 1 | 105 | ✅ CSS 变量/动画/Arco 暗色覆盖 |
|
||||
| Tauri Commands | 1 | 13 | 🔩 仅 `greet` 示例,无业务逻辑 |
|
||||
| **前端合计** | | **~8,812** | |
|
||||
|
||||
**前端关键问题**:
|
||||
- 所有 View 组件数据**硬编码** (`ref([...])`),与 Store 完全独立
|
||||
- 所有按钮事件处理函数为**空函数** `{}`
|
||||
- i18n 插件未注册到 Vue app
|
||||
- Arco Design 组件库未实际使用(仅 CSS 变量覆盖)
|
||||
> Tauri Commands 已达 **67 个**(src-tauri/src/lib.rs invoke_handler 注册数:project 14 / task 5 / idea 6 / workflow 5 / ai 19 / knowledge 14 / settings 4),见后端 src-tauri/src/commands/。
|
||||
|
||||
**前端关键问题**(Sprint 4/7 已全部解决):
|
||||
- ~~所有 View 组件数据硬编码~~ ✅ Sprint 4 接 Store 真数据
|
||||
- ~~所有按钮事件处理函数为空函数~~ ✅ 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-stages (11种阶段节点 execute() 待实现)
|
||||
df-ai → LlmProvider 实现 (无 HTTP client)
|
||||
df-storage → 所有 crate 的 CRUD 操作
|
||||
Tauri commands → Rust 业务逻辑
|
||||
Vue Views → Pinia Store → Tauri IPC → Rust
|
||||
~~df-workflow → df-stages (11种阶段节点 execute() 待实现)~~ — df-stages 已移除(2026-06-14 零引用清理)
|
||||
~~df-ai → LlmProvider 实现~~ ✅ 已打通(OpenAI/Anthropic 双协议真实 HTTP,Sprint 5/8)
|
||||
~~df-storage → 所有 crate 的 CRUD 操作~~ ✅ 已打通(impl_repo! 宏 CRUD + 多 Repo,Sprint 2)
|
||||
~~Tauri commands → Rust 业务逻辑~~ ✅ 已打通(Sprint 3 时点 47 commands 全接业务;现 67)
|
||||
~~Vue Views → Pinia Store → Tauri IPC → Rust~~ ✅ 已打通(Sprint 4)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -89,6 +87,8 @@
|
||||
| 6 | **条件表达式引擎** — 仅支持 true/false 字面量 | 条件分支不可用 | P2 | ⬜ 待办 |
|
||||
| 7 | **i18n 未注册** — 翻译文件存在但未挂载 | 多语言不生效 | P2 | ✅ Sprint 7 |
|
||||
| 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 21 已修(tool_registry 补 restore_project/purge_project/list_trash,delete_project 改 soft_delete) |
|
||||
| 10 | **api_key 明文存储** — DB 落盘仍明文(migrations.rs:446 api_key TEXT NOT NULL) | 本地库被读/备份外流则泄露 | P0 | 🟡 部分修(FR-S1:IPC mask+编辑不回填✅ commit 49ac060;DB 明文落盘❌ 待迁移 keyring 单列) |
|
||||
|
||||
---
|
||||
|
||||
@@ -121,9 +121,9 @@
|
||||
| AI Chat 面板 | ✅ | 侧边栏 + 分离窗口 + 拖拽调宽 + 对话管理 |
|
||||
| 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 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 5 — 体验打磨 (4-6 周) | ⬜ 未开始
|
||||
|
||||
@@ -136,7 +136,7 @@
|
||||
**工作内容**:
|
||||
- 创建项目骨架:`bun create vite` + Tauri v2 + Cargo Workspace
|
||||
- 编写完整架构文档 `ARCHITECTURE.md` (22,745 字)
|
||||
- 13 个 Rust crate 全部创建,类型系统和接口设计完整
|
||||
- 13 个 Rust crate 全部创建,类型系统和接口设计完整(2026-06-14 评审后删 5 僵尸 crate,现 8 crate)
|
||||
- 9 个 Vue 页面 UI 全部编写(~3,372 行),设计系统完善
|
||||
- 4 个 Pinia Store 定义(state + computed)
|
||||
- i18n 中英文翻译
|
||||
@@ -318,7 +318,7 @@
|
||||
- **审查项落地**:删全屏 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. 技能联想功能需求(本轮重点,未实现,需求阶段)**:
|
||||
**B. 技能联想功能(首批 Claude 3 类,已实现待实测)**:
|
||||
- **需求**: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`):
|
||||
1. Claude skills:`~/.claude/skills/*/SKILL.md`(23 个:sleep / mission-control / idea / cpa 等)
|
||||
@@ -338,10 +338,679 @@
|
||||
**代码变更**: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)待用户实测
|
||||
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 21] 2026-06-14 — Workflow 编排推进 Sprint 20 批次 + 审批闭环实施
|
||||
|
||||
**工作内容**: 以 workflow(实施 + 对抗验证两阶段,文件域隔离 + prompt 钉死约束)并行推进 Sprint 20 审查发现 + df-workflow 审批闭环。先 commit 基线 cf017f8(Phase2 阶段收尾 167 文件,排除无关的 `src-tauri/儿童每日打卡应用/`),后 A/B/C/D 四 workflow 各独立 commit 隔离可审。失控教训内化:workflow 确定控制流替代 background agent stop hook 循环,全程禁碰决策记录 + 禁自主续推。
|
||||
|
||||
**A 数据安全**(commit 3f0839a): AI `delete_project` 由 `repo.delete` 物理删改 `soft_delete` + 新增 `restore_project`/`purge_project`/`list_trash` 三工具(复用 ProjectRepo 回收站方法)+ `list_projects` 改 `list_active` 排除回收站(验证捕的漏网:LLM 原能看到已软删项目)。tool_registry.rs。
|
||||
**B 阻塞去重**(commit d5a6417): `collect_sample`/`detect_stack` 四处 `spawn_blocking`(含验证捕的 bind_directory 漏网)+ `normalize_path` 抽公共到 `df-project/scan` + tool_registry `update_project` 复用 crud `is_allowed_column`(删硬编码白名单,与 CRUD 同源)。scan.rs/crud.rs/project.rs/tool_registry.rs。
|
||||
**C 前端**(commit 02ff88f): 扫描/目录状态文案走 `$t`(zh-CN+en)+ `parseStack` 抽公共 `src/utils/project.ts` + 8 处原生 `alert`/`confirm` 全换(confirm→ConfirmDialog / alert→Arco Message),控制流语义不变。vue-tsc 0 error。8 文件。
|
||||
**D 审批闭环**(commit 22964a2): B-06 DagExecutor 接收 `execution_id` 下沉 NodeContext(消除 dummy)+ B-07 `node_status` 共享 `self.state_machine.clone()` + B-03a HumanNode `execute` 重写 `subscribe→send→select!`(响应/超时/取消 + execution_id+node_id 双键过滤 + Lagged 容忍,照 B-03 设计)+ eventbus 删 `try_recv_human_approval` 死代码 + human_node 7 单测。executor.rs/workflow.rs/human_node.rs/eventbus.rs。
|
||||
|
||||
**验证**: cargo check 0 error / df-storage 32 + df-project 4 + df-workflow 11 + df-nodes 14 test 全过 / vue-tsc 0 error。A/B 各 1 issue(list_projects 泄漏 / bind_directory 漏网)主代理补修;C/D 验证 safe 0 issue。
|
||||
|
||||
**遗留 / 下一步**: B-03b 取消机制(`set_cancelled` + cancel IPC + 前端按钮)待做;T-09/T-10 非阻断记 todo;架构前置 df-ai-core(F-07)待设计解锁 F-03。
|
||||
|
||||
### [Sprint 22] 2026-06-14 — Wave1 收尾清债(workflow 推进 + 主代理补完 agent 半成品)
|
||||
|
||||
**工作内容**: Wave1 四任务并行 pipeline(实现 + Explore 对抗审查,文件域隔离),收尾 Sprint 21 遗留的 B-03b 审批取消机制 + 三个 P2 清债项。每个 Agent 钉死约束(只碰白名单 + 禁碰决策记录/构想文档 + 禁自主续推 + cargo check 自验)。
|
||||
|
||||
**B-03b 审批取消端到端**(commit 4aa689e): agent 初版完成三件基础设施(`set_cancelled` + cancel IPC + 前端取消按钮)但留**半成品**——`workflow_cancel_state` 全局孤立 StateMachine 实例,IPC `set_cancelled` 写入后无法达运行中 HumanNode 的 `ctx.node_status`(executor.state_machine 的 clone,B-07 仅 clone 传递未共享底层 HashMap)。**Explore 审查 agent(无 Edit 权限,只验编译 + 白名单)漏抓此语义缺陷**。主代理补完:StateMachine 内部 `HashMap`→`Arc<Mutex<HashMap>>`(clone 即共享底层)+ AppState `workflow_state_registry`(execution_id→StateMachine)+ run_workflow 注册执行器状态机/完成清理 + cancel IPC 经 execution_id 取共享引用 `set_cancelled` 直达 HumanNode `is_cancelled` 轮询。`clone_shares` 单测验证共享语义。state.rs/executor.rs/state.rs(tauri)/workflow.rs/lib.rs/project.ts/ProjectDetail.vue。
|
||||
|
||||
**T-09 级联删**(commit 89da9fa): idea.rs:149 `delete`→`purge_with_descendants`(防孤儿子记录),1 行签名兼容。
|
||||
**T-10 canonicalize**(commit 89da9fa): 判定已解决——`normalize_path` 已含 canonicalize 优先 + 词法回退,`find_binding_conflict` 两端对称已用,零改动。
|
||||
**T-12 df-ideas 死代码**(commit 89da9fa): capture.rs 删 `CaptureInput`/`IdeaCapture` 死码,保留 `Idea`/`IdeaScores` 共享实体;promotion/scoring/adversarial 内"两套 Recommendation/PromotionPolicy 死枚举"嫌疑未确认存在/保留为对外契约,本次未动待复查。
|
||||
|
||||
**验证**: cargo check 0 error / cargo test 全过(0 failed,含 `clone_shares` 新测)/ vue-tsc 0 error。
|
||||
|
||||
**教训**: workflow 对抗审查用 Explore agent 能防越权 + 验编译 + 验白名单,但抓不到"功能实际不工作"类语义缺陷(IPC 写了读不到、共享未通)——这类需主代理读实现逻辑复核,不能只信审查 verdict。
|
||||
|
||||
**遗留 / 下一步**: Wave2 候选 F-07(df-ai-core trait 下沉,解锁 F-03,需先定 trait 边界);T-12 df-ideas 其余嫌疑项待复查。
|
||||
|
||||
### [Sprint 23] 2026-06-15 — 全量待办编排 + 首批 A+B 清债热身
|
||||
|
||||
**工作内容**: 全量 `todo.md`(~40 未完成项)汇总按可执行性分 8 组(A 卫生 / B 零风险减法 / C 需用户输入 / D 功能 / E 架构 / F 全局 review P2 / G 测试 / H 长期池),加「编排推进总览」章节供后续会话全局视图。首批推进 A+B 组。
|
||||
|
||||
**ARC-08 转自研块级 memo**(前会话遗留补记,待 commit): 流式 Markdown 渲染——方案 D(markstream-vue)试装后样式还原成本高且脆(代码块 chrome / 暗色 `--ms-*` 变量 / prose 作用域 4 处对接随库漂移),转自研块级 memo(方案 B 增强版):保留 marked+DOMPurify+.ai-md 原样式零对接 + splitBlocks 块级 memo O(末块) + rAF 节流 + 末块不缓存处理未闭合 token。退役 AR-1 流式纯文本短路。详见 [aichat流式Markdown渲染调研-2026-06-15.md](docs/02-架构设计/构想审查/aichat流式Markdown渲染调研-2026-06-15.md) §5。
|
||||
|
||||
**A todo 卫生**:
|
||||
- AR-1 标退役(被 ARC-08 取代)
|
||||
- AR-8 重评:前端 rAF 已被 ARC-08 覆盖,剩后端 50ms 合批 + 滚动,降优先级
|
||||
- ARC-03 降级重评:df-execute **非空壳**(shell.rs `execute()` 完整 + 被 `run_command` 复用),原「空壳合并」前提失效,转架构维护决策非清债
|
||||
|
||||
**B 清死链**(ARC-260615-02): 删 App.vue secondaryNav `/decisions` 死链项(router 无此路由,决策治理 = F-260614-08 长期项入口提前占位)+ 删 `nav.decisions` i18n key(zh/en)。`dashboard.recentDecisions`(Dashboard.vue)不同命名空间保留。
|
||||
|
||||
**代码变更**: `src/App.vue` / `src/i18n/{zh-CN,en}/nav.ts` / `docs/todo.md`(编排总览 + AR-1/AR-8/ARC-02/ARC-03 状态回写)。
|
||||
|
||||
**验证**: vue-tsc EXIT=0。
|
||||
|
||||
**B-03b-R8 human 端到端集成测试**(待 commit): human_node.rs 加 2 端到端测(`end_to_end_human_approval_completes_workflow` / `end_to_end_human_approval_cancelled`),覆盖 executor 驱动 a(SleepNode)→b(HumanNode) 两层 DAG 的 Request 真发出 + outputs 收集 + 双节点 Completed,以及外部 set_cancelled → human cancel_tick → executor 跳过 set_failed + 状态保持 Cancelled。封死 R6/R7 回归土壤。10 测全过(8 现有单测 + 2 新端到端)。
|
||||
|
||||
**遗留 / 下一步**: 下批方向三选一(D 功能增强 F-15-01~04 / E 架构重投入 F-14-01·07 / G 测试 dev 验证 ARC-08 + T-14-01·02);C 组阻塞项(S-260615-01 curl 测 / S-260614-01 多开澄清)等用户输入。
|
||||
|
||||
### [Sprint 24] 2026-06-15 — P0 generating 状态机加固 + 独立项批量
|
||||
|
||||
**工作内容**: 多代理编排推进——P0 generating 卡 true 根治组合(B-09/10/11)串行 + 5 独立项 workflow 并行 + 3 项 read-only 勘察下一波。主代理全程核查(失控防护)。
|
||||
|
||||
**P0 三项**(generating 卡 true 根治,agentic.rs + commands.rs):
|
||||
- B-09: GeneratingGuard RAII(new/reset().await 幂等/Drop spawn 兜底)+ run_agentic_loop 入口实例化 + 5 处手动复位替换 + try_continue:318 保留手动(should_continue=false 须保 generating=true 待审批)
|
||||
- B-10: ai_conversation_create 加 app:AppHandle(Tauri 自动注入)+ generating=true 软复位(记 old_conv→复位→清 pending→stop_flag→释锁→emit 零 token AiCompleted→relock 续建)+ 硬拦移除
|
||||
- B-11: 两处 conv_id 一致性校验(循环顶部 + push 块内,防软复位后旧 loop 污染新对话)
|
||||
|
||||
**独立 7 项**(B-19 根因指针随 09~18 销账):
|
||||
- B-12: SessionState enum 视图(Idle/Streaming/AwaitingApproval)+ session_state() 读侧收敛(mod.rs,三调用点注释待后续替换)
|
||||
- B-18: pending_approvals 单例设计文档注释(mod.rs)
|
||||
- R-PD-4: keyring 迁移失败阈值告警(MIGRATION_FAIL_THRESHOLD=3 + sidecar 计数 + 达阈值升级 warn,secret.rs,不改兼容时序)
|
||||
- R-PD-5: approve IPC 加 decision∈options 校验(⚠️半完成:前端 stores/project.ts:252 未传 options,校验形同虚设,待前端补传触发)
|
||||
- R-PD-13: run_workflow Lagged 结构化 warn + 风险注释(workflow.rs)
|
||||
- B-20: handleSend catch 加 reactive toast(AiChat.vue 复用 Settings 模式,文案待 i18n)
|
||||
- B-21: sendMessage catch 一并回滚 user message(useAiSend.ts)
|
||||
|
||||
**勘察下一波**(read-only workflow wxflofhf2): F-260615-02 TaskDetail(已完成核查通过:get_task_by_id IPC + /tasks/:id 路由 + TaskDetail.vue 11 字段 + 列表跳转,cargo check/vue-tsc 0 err)/ F-260615-04 ToolCard折叠(plan 11 步复杂+改核心 ai 文件+细节有误,拆小或留待)/ AR-11 数据联动(勘察 implPlan 含伪代码错误 std::env::var/.match,暂缓待重设计 emit 点)。
|
||||
|
||||
**代码变更**: agentic.rs / commands.rs / mod.rs / secret.rs / workflow.rs / AiChat.vue / useAiSend.ts。
|
||||
|
||||
**验证**: cargo check 0 error / vue-tsc 0 error / df-nodes 17 test pass(含 2 human 端到端测,P0 不破坏)。主代理独立核查(非 agent 自报):git diff --stat 文件边界 + 编译复核 + R-PD-4 落地核查(agent 报告夸大称改 75 行实际在更早基线,功能存在即销账)。
|
||||
|
||||
**失控防护**: 每 agent prompt 锁死文件 + 禁碰决策记录 + 禁自主扩展 + 返回 schema。R-PD-4 agent 把已有代码当自己的(边界检查不准,非造假,同类 WF-G AR-7)。
|
||||
|
||||
**遗留 / 下一步**:
|
||||
- R-PD-5 半完成:前端补传 options 触发新校验
|
||||
- B-13~17 待续(stop 兜底/Notify 即时打断/heartbeat 提外/MAX_AGENT_ITERATIONS 配置化/key_len 复用)
|
||||
- F-260615-02 TaskDetail 实施中(后台 agent)
|
||||
- AR-11 暂缓(等本批提交后重设计 emit 点,audit.rs app_handle 可达性待核查)/ F-260615-04 拆小或留待
|
||||
|
||||
### [Sprint 25] 2026-06-15 — P0 收尾 + 下一波编排
|
||||
|
||||
**B-15/16/17 P0 收尾**(零行为变优化):
|
||||
- B-15: stream_recv.rs heartbeat interval 提到 loop 外(省每轮重建,节拍不变)
|
||||
- B-16: agentic.rs MAX_AGENT_ITERATIONS pub(crate)→pub const + 配置接入点注释(无现成配置槽,未引入 AppState 字段)
|
||||
- B-17: agentic.rs key_len 复用 resolve_provider_secret(去重复 keyring resolve,build_provider 内联因 secret.rs 锁边界,DRY 隐患注释标注等价性)
|
||||
- 核查:cargo check 0 err / df-nodes 17 test pass
|
||||
|
||||
**下一波编排 workflow wcvigw3z4**(3 实施 + 4 勘察,循环链路):
|
||||
- 实施(文件隔离):B-13 stop 兜底(commands.rs)/ R-PD-5 前端补 options 闭环(stores/project.ts)/ CR-05+07 块级 memo 低风险子项 parseBlock DRY + escapeHtml 抽(AiChat.vue,禁碰 splitBlocks/loadMarkdown 流式核心)
|
||||
- 勘察 read-only(为下一波确定):B-260615-22 状态同步两方案对比 / B-14 stop_flag→Notify 即时打断跨4文件设计 / F-260615-01 HumanNode 多选审批契约变更影响 / ARC-06 composables/ai 循环依赖拆点
|
||||
|
||||
**遗留 / 下一步**: 等勘察结果派下一波实施;AR-11 emit 点已查明(audit.rs process_tool_calls 有 app_handle)待本批提交后派;F-260615-03 breaking change / F-260615-04 需定交互 待用户确认;改动堆积(git diff 100+ 文件)建议择机提交收口。
|
||||
|
||||
### 2026-06-18 03:08 — sweep-fix 6 域走查 + P3 小修(Code-Working 会话·workflow wd2fnjh3s)
|
||||
|
||||
**工作内容**:
|
||||
- workflow wd2fnjh3s 6 域并行走查(rust-ai/dag/storage/ipc + fe-aichat/fe-other,12.4 分钟·6 agent·651k tokens):修 12 处确定性低风险 bug + 新发现 27 todo(去重 24 入 todo.md)
|
||||
- 主代独立核查(审查防污染不信 agent 自报):cargo check --workspace EXIT 0 + vue-tsc EXIT 0 + grep 抽查 scan/audit/commands 源码形态全正确;**拦截 1 瞬态误报**(fe-other vue-tsc 抓 ToolCard.vue:653 cmdOutput TS6133 never read,实为 fe-aichat 改 ToolCard 中间态被并发 vue-tsc 抓到,最终态模板已引用,全量 vue-tsc 复核 EXIT 0 确认非 bug)
|
||||
- 主代 P3 小修 5 处(各步自测通过):SW-18 AiChat wasNearBottom 切会话重置 / SW-21 TaskDetail wfResult timer 清理 / SW-23 Ideas neutral CSS / SW-15 ai_node schema required 对齐 / SW-24 dead_code 标注(finish_reason/call_type 2 处 #[allow],cargo df-ai warning 消除)
|
||||
- 文档:todo.md 24 newTodos 去重追加 + F-260616-15 矛盾项销账 + 5 P3/SW 销账;待审查.md CR-260618-01 登记 17 处改动交其他会话审查
|
||||
|
||||
**代码变更**:
|
||||
- df-ai: context.rs(parts token 预算,F-05 回归)/ anthropic_compat.rs(Image move)/ openai_compat.rs(2 处 #[allow(dead_code)])
|
||||
- df-project: scan.rs(to_ascii_lowercase 索引对齐 + is_monorepo null 判定)
|
||||
- df-ideas: adversarial.rs(MockProvider 补 reasoning_content)
|
||||
- src-tauri: audit.rs(audit_finalize 吞错分流 + PENDING_APPROVAL_PLACEHOLDER 常量)/ commands.rs(ai_conversation_delete retain)
|
||||
- df-nodes: ai_node.rs(schema required=[] 对齐 execute)
|
||||
- 前端: ToolCard.vue(isFailed/cmdOutput computed)/ AiChat.vue(虚拟滚动死注释清理 + wasNearBottom 重置)/ Projects.vue(_toastTimer onUnmounted)/ Knowledge.vue(parseContext 单次)/ TaskDetail.vue(wfResult timer 清理)/ Ideas.vue(neutral CSS)
|
||||
|
||||
**遗留问题**:
|
||||
- 1. **UX-2025-12 分页懒加载(高价值中风险)**:多角度论证暂缓——①必须 limit+滚顶两全(limit 单独引入"看不到 >limit 旧消息"回归,之前全量可见)/ ②token 紧张 180 行 7 文件大工程易出错(borrow/sentinel 时序) / ③长对话卡顿是渲染体验问题 cargo/vue-tsc 只验编译类型,滚动流畅度需用户实测 / ④工作区已累积 17 处 commit-policy-deferred 不提交再加 7 文件增合并冲突。留 todo 待充分资源/用户在场推进。
|
||||
- 2. SW-260618-01/02 两个 P1(executor Ok 路径取消事件不一致 / 审批占位 tool_result 5 处 .clear() 未终态化):均行为变更需确认设计意图,记 todo 未改。
|
||||
- 3. sweep P2 多为重构/防御/改进(AuditLog i18n / ai_node DRY / reqwest Client DRY / 双 watch 合并 / Anthropic url 防御),记 todo 待攒批。
|
||||
- 4. 待审查 CR-260618-01(17 处)待审查会话独立 grep 核验。
|
||||
|
||||
**下一步**:
|
||||
- 1. UX-2025-12 分页(用户在场体验验证 或 新会话充分资源推进)
|
||||
- 2. SW-01/02 P1 确认设计意图后修
|
||||
- 3. 待审查 CR-260618-01 交审查会话
|
||||
- 4. 工作区累积 17 处(commit-policy-deferred 不提交,等架构级重构建基线)
|
||||
|
||||
### 2026-06-18 03:26 — SW-10 reqwest Client 构建 DRY(Code-Working 会话续推)
|
||||
|
||||
**工作内容**:
|
||||
- sweep P2 SW-260618-10:openai_compat/anthropic_compat Provider::new reqwest Client 构建(connect_timeout 30s + 回退 Client::new + warn)7 行逐字重复,抽 `lib.rs::build_provider_client()` pub(crate) fn 收敛 DRY,两处调 `crate::build_provider_client()`
|
||||
- 主代自测(审查防污染独立核验):cargo check -p df-ai EXIT 0 无 warning / cargo test -p df-ai 112 passed 0 failed(不回归)+ grep 核验 Client/warn/Duration import 替换后仍多处用(Client 用于 struct 字段 / warn openai:503 + anthropic 7 处 / Duration 用于 :484/:481 timeout),无 unused warning
|
||||
|
||||
**代码变更**:
|
||||
- crates/df-ai/src/lib.rs(+14 build_provider_client pub(crate) fn)/ openai_compat.rs(-8+2)/ anthropic_compat.rs(-6+2)
|
||||
|
||||
**遗留问题**:
|
||||
- 1. sweep 剩余低风险项 ROI 递减:SW-06/07 双 watch 合并需谨慎 immediate 语义边界 / SW-19 combineAndTruncateLines DRY 价值低(结构特殊带 total/truncated)/ SW-04 knowledge_events query 无活跃调用方(防御性)/ SW-09 ai_node DRY 架构级 / SW-16 审计 status 透传需查审计表中复杂度(find_cached 返回 Option<String> 仅 content,来源 status 在 ai_tool_executions 表非 ChatMessage)暂缓
|
||||
- 2. 待审查 CR-260618-03(SW-10 单项)+ CR-260618-01/02 交审查会话独立 grep 核验
|
||||
|
||||
**下一步**:
|
||||
- 1. SW-01/02 P1 待确认设计意图(executor Ok 路径取消事件不一致 / 审批占位 5 处 .clear() 未终态化)
|
||||
- 2. 工作区累积 18 处(commit-policy-deferred 不提交,等架构级重构建基线)
|
||||
- 3. 待用户回归决策 UX-2025-12 分页 / SW P1 设计意图
|
||||
|
||||
### 2026-06-18 03:39 — SW-16 审计 status 透传 + SW-06 ToolCard watch 合并(Code-Working 会话续推)
|
||||
|
||||
**工作内容**:
|
||||
- SW-260618-16(P3 审计精度):find_cached_high_risk_result 加 audit_repo 参数 + async + 返回 (content,status),命中旧 tool_result 后查 AiToolExecutionRepo::find_by_tool_call_id(**已存在** crud.rs:1256,复用零新增)拿缓存来源真实 status(completed/rejected/failed)透传 audit_tool_call 替代固定 "completed"。审计缺失/查询失败 fallback completed 不阻塞去重。
|
||||
- SW-260618-06(P2 DRY):ToolCard 双 watch(props.tc.status) 合并。放原 watch2 位置(approving/approvingTimer/cmdOutputExpanded 三 ref 已定义避 TDZ)+ immediate(原 watch1 语义),callback 内 cmdOutputExpanded + approving 复位合并。
|
||||
- 主代自测:cargo check --workspace EXIT 0(仅 4 pre-existing dead_code 基线)/ cargo test -p devflow 92 passed 0 failed / vue-tsc EXIT 0 + grep 独立核验源码形态
|
||||
|
||||
**代码变更**:
|
||||
- src-tauri/src/commands/ai/audit.rs(find_cached 签名+返回+status 查询 / process_tool_calls :625 调用点 .await 拆 status / :639 &status 透传)
|
||||
- src/components/ToolCard.vue(删原 watch1 + watch2 替合并 watch)
|
||||
|
||||
**遗留问题**:
|
||||
- sweep 剩余可自主:SW-07 AiChat 双 watch 合并(同 SW-06 模式可复用)/ SW-17 ToolCardList Set 重置(低价值内存慢增长)/ SW-19/20 DRY(低价值)/ SW-05/11/12/13/14(防御/确认意图/权衡,需勘察或确认)
|
||||
- 高价值待用户:B-260618-03 路由解耦 cost/intel(用户已决策主链大改需在场)/ SW-01·02 P1(行为变更需确认设计意图)/ UX-2025-12 分页(需体验实测)
|
||||
|
||||
**下一步**:
|
||||
- 继续 sweep 可自主项(SW-07 watch 合并模式复用 SW-06)/ 工作区累积 20 处 commit-policy-deferred / 心跳 a096aa7f 20 分钟监测推进
|
||||
|
||||
### 2026-06-18 03:45 — SW-07 watch 合并 + B-03 路由解耦勘察 + SW-17 评估(Code-Working 会话续推)
|
||||
|
||||
**工作内容**:
|
||||
- SW-260618-07(P2 DRY):AiChat 双 watch(currentText) 合并。删原 :2040 watch(currentText→onContentChange) 并入 :2047 watch(currentText→scheduleStreamParse) 单 callback,保持原注册顺序(onContentChange 滚动先 → scheduleStreamParse rAF 分块后)。vue-tsc EXIT 0。
|
||||
- B-260618-03 路由解耦 cost/intel 勘察完成:9 调用点(project×2/compress/knowledge_inject×2/agentic/title/ai_node/adversarial)+ router 核心(TaskRequirements 字段 + 步骤4/5 filter + 步骤7 Reverse(cost_tier))+ 多 test。**自主裁决:记待决策作专项**(用户已全局决策去掉 cost/intel,但工程大 ~30 Edit + 主链路由行为变更需 cargo test router 全验证 + 在场核对 title/compress 选模型退化,本轮 token 不够完整推进,半途中断风险 > 暂缓)。附完整实施清单供专项续。
|
||||
- SW-260618-17 评估:**不做**。切会话清 4 Set 会破坏 UX-260616-03 记忆态设计(userExpandedCards/userCollapsedCards 跨会话保留是 feature——切回 A 恢复展开记忆),「内存慢增长」是记忆保留代价可忽略(tc.id 全局唯一无错配,描述自承认)。
|
||||
|
||||
**代码变更**:
|
||||
- src/components/AiChat.vue(删 :2040 watch + :2047 合并 callback)
|
||||
|
||||
**遗留问题**:
|
||||
- B-260618-03 待决策.md(专项推进,附实施清单)
|
||||
- SW-17 评估不做(破坏 UX-260616-03 记忆态)
|
||||
- sweep 剩余:SW-19(combineAndTruncateLines DRY 低价值可做)/ SW-20(parseTags DRY 签名变更中复杂度)/ SW-05/11/12/13/14(防御/确认/权衡)/ SW-08(AuditLog i18n 重构级)/ SW-09(ai_node DRY 架构级)
|
||||
|
||||
**下一步**:
|
||||
- 推进 SW-19(纯 DRY 低风险)/ 工作区累积 21 处 commit-policy-deferred / 心跳 a096aa7f 20 分钟监测
|
||||
|
||||
### 2026-06-18 04:12 — SW-19/20 DRY + sweep 评估销账批次(Code-Working 会话续推)
|
||||
|
||||
**工作内容**:
|
||||
- SW-260618-19(P3 DRY):ToolCard combineOutputs 抽函数,combineAndTruncateLines + cmdOutput computed 复用。vue-tsc EXIT 0。
|
||||
- SW-260618-20(P3 DRY):Ideas 删本地 parseTags(IdeaRecord),import stores/knowledge.ts parseTags,3 调用点传 .tags。vue-tsc EXIT 0。
|
||||
- sweep 评估销账 6 项:SW-05(占位内容匹配·PENDING_APPROVAL_PLACEHOLDER 集中常量有效暂缓)/ SW-11(OpenAI 图片构造·当前不可达暂缓)/ SW-12(pinned/archived 不对称·设计合理确认)/ SW-13(find_path_conflict 规范化·文档+测试守护够暂缓)/ SW-14(keyring current_dir·权衡已承认不做)/ SW-22(Knowledge 模板重复求值·影响可忽略暂缓)。
|
||||
- SW-260618-03 评估暂缓:url 模式当前不可达(前端 Phase2b 只产 base64)+ anthropic_compat 已 warn;加 commands 层校验需追 parts 全链路(IPC→ContextManager→provider)定校验点有歧义 + 误拒合法 url 风险,待 url→base64 预拉落地后变可达再加。
|
||||
|
||||
**代码变更**:
|
||||
- src/components/ToolCard.vue(combineOutputs 抽函数 :463 + :472/:652 调用)
|
||||
- src/views/Ideas.vue(parseTags import :214 + 3 调用点传 .tags)
|
||||
|
||||
**遗留问题**:
|
||||
- sweep 可自主低风险项已全扫净(SW-19/20 已修 + 6 项评估销账 + SW-03 评估暂缓)
|
||||
- 剩余候选:SW-08(AuditLog i18n 重构级 15+ key 可自主中工程)/ SW-01·02 P1(行为变更需确认设计意图)/ SW-04(knowledge_events query 无调用方暂缓)/ SW-09(ai_node DRY 架构级)/ B-260618-03(路由解耦待决策专项)
|
||||
|
||||
**下一步**:
|
||||
- 勘察 SW-08 AuditLog i18n 可行性 / 工作区累积 23 处 commit-policy-deferred / 心跳 a096aa7f 20 分钟监测
|
||||
|
||||
### 2026-06-18 04:16 — SW-08 AuditLog.vue i18n 化(Code-Working 会话续推)
|
||||
|
||||
**工作内容**:
|
||||
- SW-260618-08(P2 重构):AuditLog.vue 全硬编码中文 i18n 化。新建 auditLog 命名空间(en/zh-CN:title/refresh/desc/loading/empty + col×7 + pager×4 + risk×3 + status×6 + decided×2),AuditLog.vue 引入 useI18n + template 15 处文本 t() 化 + riskLabel/statusLabel/decidedLabel 改 t(`auditLog.xxx.${val}`)。class 映射保留不国际化。模式对齐 Dashboard/Knowledge/Ideas。vue-tsc EXIT 0。
|
||||
|
||||
**代码变更**:
|
||||
- src/i18n/en/auditLog.ts + src/i18n/zh-CN/auditLog.ts(新建命名空间)
|
||||
- src/views/AuditLog.vue(useI18n + template t() + label 函数 t())
|
||||
|
||||
**遗留问题**:
|
||||
- sweep 可自主项已全扫净(SW-19/20 DRY + SW-08 i18n 重构均完成 + 7 项评估销账)
|
||||
- 剩余候选均非纯自主:SW-01·02 P1(行为变更需确认设计意图)/ SW-04(knowledge_events query 无调用方暂缓)/ SW-09(ai_node DRY 架构级)/ B-260618-03(路由解耦待决策专项)
|
||||
|
||||
**下一步**:
|
||||
- 勘察 SW-09 ai_node DRY 可行性 / 工作区累积 26 处 commit-policy-deferred / 心跳 a096aa7f 20 分钟监测
|
||||
|
||||
### 2026-06-18 04:32 — SW-09 ai_node provider 三件套 DRY(Code-Working 会话续推)
|
||||
|
||||
**工作内容**:
|
||||
- SW-260618-09(P2 架构级 DRY):AiNode/AiSelfReviewNode execute provider 三件套逐字重复消除。抽 2 helper:resolve_and_parse(合并 resolve_provider+parse_params)+ provider_from_params(封装 build_provider 5 行)。AiNode 9 行→2 行;SelfReview parse+build→2 调用(中间 task 读保留,p 生命周期正确)。messages/temperature 差异不抽(各节点自有)。cargo check df-nodes EXIT 0 / cargo test 82 passed 0 failed。
|
||||
|
||||
**代码变更**:
|
||||
- crates/df-nodes/src/ai_node.rs(2 helper 新增 + 2 节点 execute 改)
|
||||
|
||||
**遗留问题**:
|
||||
- sweep 全域可自主项已尽(SW-19/20 DRY + SW-08 i18n 重构 + SW-09 架构级 DRY 均完成 + 7 项评估销账 SW-03/05/11/12/13/14/22)
|
||||
- 剩余候选均需用户介入:SW-01·02 P1(行为变更需确认设计意图)/ SW-04(knowledge_events query 无调用方暂缓)/ B-260618-03(路由解耦待决策专项)
|
||||
|
||||
**下一步**:
|
||||
- 可自主项已扫净,状态报告待用户 / 工作区累积 27 处 commit-policy-deferred / 心跳 a096aa7f 20 分钟监测
|
||||
|
||||
### 2026-06-18 04:35 — UX-260618-04 前端删 cost/intel 标签(Code-Working 会话续推)
|
||||
|
||||
**工作内容**:
|
||||
- UX-260618-04(P2):前端删 cost/intel 瞎猜档位标签(落地用户「去掉 cost/intel」决策)。自主裁决全删(用户决策明确 + 独立于后端 B-03 路由 + grep 确认仅 2 组件引用 + 回退易)。ProviderPanel 删 tag-cost/tag-intel span;AiChat modelOptionLabel 删 intel/cost 拼接;i18n en/zh settings.ts 删 tagCost/tagIntel + 注释更新。vue-tsc EXIT 0 / grep 无残留。
|
||||
|
||||
**代码变更**:
|
||||
- src/components/settings/ProviderPanel.vue + src/components/AiChat.vue
|
||||
- src/i18n/en/settings.ts + src/i18n/zh-CN/settings.ts
|
||||
|
||||
**遗留问题**:
|
||||
- ModelConfig.cost_tier/.intelligence 字段保留(后端 B-260618-03 解耦后清)
|
||||
- sweep + UX 可自主项已尽,剩余均需用户:SW-01·02 P1(行为变更)/ SW-04(无调用方暂缓)/ F-260616-09(架构级立项)/ B-260618-03(待决策专项)
|
||||
|
||||
**下一步**:
|
||||
- 可自主项已扫净,状态报告待用户 / 工作区累积 31 处 commit-policy-deferred / 心跳 a096aa7f 20 分钟监测
|
||||
|
||||
### 2026-06-18 06:50 — workflow 6项编码批·router解耦+model_probe+executor TOCTOU+审批占位+knowledge_events+CostTier(workflow wexu1isx1·6 agent·主代核查)
|
||||
|
||||
**工作内容**:
|
||||
- 用户回归复盘彻夜低产(串行手改+过度保守+心跳空转 1.5h),改 workflow 编排提速
|
||||
- workflow wexu1isx1 6 agent(10.7 分钟·294k tokens):B-03 router 解耦 cost/intel / B-04 model_probe 去瞎填 / SW-01 executor TOCTOU / SW-02 审批占位终态化 / SW-04 knowledge_events query / B-05 CostTier Free 清理
|
||||
- 主代审查防污染核查:cargo check --workspace EXIT 0(5 pre-existing warning) + cargo test df-ai 109/df-ai-core 24/df-workflow 23/df-storage 全 passed + vue-tsc EXIT 0
|
||||
- **主代修 agent E0502 借用冲突**:SW-02 helper finalize_pending_placeholders 签名(&mut ContextManager, &HashMap, &str) 函数参数列表 disjoint borrow 冲突(agent riskNote 预判"应无问题"错误)→ 改 &mut AiSession 整体(函数体内 disjoint borrow 合法)+ 调用点 &mut *session(DerefMut 解 guard)。印证:agent 不跑 cargo 的 riskNote 是猜测,主代 cargo 必验(同 Sprint 22 教训)
|
||||
- 主代补 B-05 前端同步:types.ts CostTier 删 'free' 对齐 Rust(防类型不一致 + DB "free" 反序列化边界)
|
||||
|
||||
**代码变更**:
|
||||
- df-ai: router.rs(struct 删 min_intelligence/max_cost + select 删步骤4/5 + max_by_key 纯 weight 删 Reverse + test 改写) / model_probe.rs(heuristic_infer 中性默认 Medium/Standard) / presets/models.json(17 条删 cost_tier/intelligence)
|
||||
- df-ai-core: model.rs(CostTier 删 Free + 注释 + test 同步)
|
||||
- df-workflow: executor.rs(Ok 分支 emit NodeCompleted 移入 !is_cancelled + 取消 emit NodeCancelled 对齐 Err)
|
||||
- df-storage: crud.rs(KnowledgeEventsRepo::list_recent ORDER BY timestamp 兜底)
|
||||
- src-tauri: commands/ai/commands.rs(finalize_pending_placeholders helper + 5 处 .clear() 前终态化,主代修借用) + title/compress/knowledge_inject/agentic/project.rs(删 TaskRequirements min_intelligence/max_cost 传参 + import 清理)
|
||||
- df-nodes: ai_node.rs / df-ideas: adversarial.rs(同删传参) / 前端: api/types.ts(CostTier 删 'free')
|
||||
|
||||
**遗留问题**:
|
||||
- 1. B-03 行为变更:title/compress 纯 weight 选模型,同 weight 并列 max_by_key 返回最后一个(rust Iterator 语义),需用户核对 Settings weight 配置(用户已全局决策接受)
|
||||
- 2. SW-02 另 2 处 pending_approvals.retain(switch/delete)未改:语义不同(switch DB 占位残留需另案修持久层;delete 非活跃对话占位不在 session.messages 内存)。agent riskNote 标注建议另开任务
|
||||
|
||||
**下一步**:
|
||||
- 工作区累积 37 处 commit-policy-deferred(等架构级重构建基线)
|
||||
- 待审查 CR-260618-10 交审查会话独立 grep 核验
|
||||
|
||||
### 2026-06-18 — workflow CR-260618-11 审查回流 5 项低风险编码(workflow w999qdu86·3 agent·主代核查)
|
||||
|
||||
**工作内容**:
|
||||
- workflow w999qdu86 3 agent(1.7 分钟·83k tokens):BUG-260618-11 ai_approve 吞错修(P0) + CR-11#2 context.rs chars_ratio 注释 + CR-11#3 types.ts IntelligenceTier 注释 + CR-11#4 system role 完备性 grep 核验 + CR-11#5 audit.rs find_cached 性能注记
|
||||
- 文件域零冲突(src-tauri/commands/ai · df-ai/context.rs · 前端 types.ts),agent 不跑 cargo 主代统一验证
|
||||
- 主代审查防污染核查:cargo check --workspace EXIT 0(4 pre-existing dead_code warning)+ vue-tsc EXIT 0 + 独立 grep 印证 agent selfCheck 无污染
|
||||
|
||||
**代码变更**:
|
||||
- src-tauri/commands/ai/commands.rs(:289-303 ai_approve None 分支 match 三路分流对齐 audit_finalize,原 unwrap_or_default 消)
|
||||
- src-tauri/commands/ai/audit.rs(:402 find_cached_high_risk_result 性能注记)
|
||||
- crates/df-ai/src/context.rs(:56 chars_ratio 0.35 注释,值未变)
|
||||
- src/api/types.ts(:209 IntelligenceTier 注释)
|
||||
|
||||
**遗留问题**:
|
||||
- 1. BUG-11 行为变更:DB 故障现返明确错误(原误为「未找到挂起」),同属 Err 分支语义不变
|
||||
- 2. CR-11#2 chars_ratio 降值(0.10~0.15)属行为变更影响裁剪边界,本次仅标注未做
|
||||
- 3. CR-11#5 find_cached 批量预取属架构改签名,本次仅加注记未做
|
||||
|
||||
**下一步**:
|
||||
- 剩余 11 项需用户/架构级定夺(F-09 多会话/T-01·02 实测/S-01·02 澄清/ARC-07 排期/UX-12 二选一/B-260617-11·17 意图+运行时),workflow 推不动,交用户
|
||||
|
||||
### 2026-06-18 — workflow ToolCard.vue 工具结果渲染 8 项(workflow w2drz3ppo·8 agent 并行核验+主代统筹实施·commit-policy-deferred)
|
||||
|
||||
**工作内容**:
|
||||
- workflow w2drz3ppo 8 agent(5.1 分钟·301k tokens·132 tool_uses):并行核验 8 工具(run_workflow/patch_file/delete_file/read_file/advance_task/list_trash/list_directory/list_*)后端 tool_registry.rs 返回 vs 前端 ToolCard.vue 渲染,8/8 isReal=true,每份产出精确 old/new 实施清单 + selfCheck grep 证据
|
||||
- 主代统筹实施(单文件域 ToolCard.vue 不能并行改):合并冲突(UX-10 被 UX-12 吸收)+ 修死代码位置(write_file 改模板分支非 formatToolResult case)+ type 13 字段去重合并 + 补 task_id 漏列
|
||||
- 主代审查防污染核查:vue-tsc --noEmit EXIT 0 + 独立 grep 印证 ToolCard.vue 22 处新字段 + i18n 18 处新 key 全落地
|
||||
|
||||
**代码变更**:
|
||||
- src/components/ToolCard.vue:ToolResult type 补 13 字段(task_id/execution_id/target_status/note/diff/matches_found/backup_path/returned_lines/items/modified/old_size/encoding/cross_volume)+ 模板 read_file has_more 截断 / list_directory truncated / write_file old_size·encoding / 新增 patch_file diff 分支 + resultDiffLines computed + formatToolResult switch(run_workflow/list_*/advance_task/delete_file/rename_file)+ toolResultSummary switch(run_workflow/list_*重写修误判+list_trash/advance_task/delete_file/rename_file)+ formatFileInfoResult modified
|
||||
- src/i18n/{en,zh-CN}/aiTool.ts:加 workflowTriggered/Short/patchedMatches/bytesUnit/linesTruncated/dirTruncated/advancedTaskWithRounds/trashCount/listHasMore/renamedWithBytes/CrossVolume/writeOverwrite/encodingLabel/fileInfoModified + 改 deleteFileSoft 加 backup_path
|
||||
|
||||
**遗留问题**:
|
||||
- 1. list_directory/list_* 截断文案硬编码 {n:1000}(对齐后端 tool_registry.rs:880 max_entries=1000),后端变更需同步前端
|
||||
- 2. list_directory 的 ai-tool-file-meta--warn CSS class 未定义(功能 v-if 生效,样式可后续补)
|
||||
- 3. 本批 vue-tsc 类型层验证通过,运行时渲染待 T-260614-01 tauri dev 实测批
|
||||
|
||||
**下一步**:
|
||||
- 待审查 CR-260618-12 交审查会话独立 grep 核验
|
||||
- 工作区累积 45 处 commit-policy-deferred(等架构级重构建基线)
|
||||
- 剩余需用户项同 CR-11 段(11 项 workflow 推不动)
|
||||
|
||||
### 2026-06-18 — workflow SMELL-P0-1 吞错降级加 warn + B-260618-06 表格 CSS(workflow w5siwnipj·6 agent 并行核验+主代统筹实施·commit-policy-deferred)
|
||||
|
||||
**工作内容**:
|
||||
- workflow w5siwnipj 6 agent(135s·190k tokens·62 tool_uses):并行核验 SMELL-P0-1 吞错 5 高危(agentic/knowledge_inject×3/idea/audit)+ B-260618-06 表格 CSS + coordinator 空壳,产出 currentCode/callers/verdict/fixType/proposedFix/behaviorChange/risk/selfCheck 结构化清单
|
||||
- 主代统筹裁决:4 项实施(降级加 warn 类无行为变更 + CSS 确定性)+ 2 项 no-action(audit 误报 CR-11 已修 / coordinator 已标注保留)
|
||||
- 主代审查防污染核查:独立 Read ai-md.css/knowledge_inject.rs/idea.rs 印证 agent selfCheck 6/6 准确,cargo check -p devflow EXIT 0 + vue-tsc EXIT 0
|
||||
|
||||
**代码变更**:
|
||||
- B-260618-06:src/styles/ai-md.css:59 移除 display:block/overflow-x:auto(width:100%→max-width:100%)+ src/components/AiChat.vue:3366 移除 display:block/width:max-content/overflow-x:auto,table 留 max-width:100%,气泡加 .ai-msg-bubble--ai.ai-md { overflow-x:auto }(CSS spec table-display 下 overflow 须由 block 父容器承担)
|
||||
- SMELL-P0-1:agentic.rs:348(provider 池)+ knowledge_inject.rs:130,139,312(检索/消息解析)+ idea.rs:292-296(tags 解析)unwrap_or_default → match + tracing::warn 降级,Err 返空 Vec 走原降级路径零行为变更
|
||||
|
||||
**遗留问题**:
|
||||
- 1. SMELL-P0-1 audit.rs:344 误报(audit_finalize CR-11 已修三路 match),todo line 漂移+旧版本,workflow 独立核验拦截
|
||||
- 2. 全局 ai-md.css 移除 display:block 后其他引用页(ProjectDetail/Ideas/Knowledge/TaskDetail)宽表格滚动需各自容器,当前仅 AiChat 气泡补 overflow,其他页 markdown 少宽表可接受
|
||||
- 3. SMELL-P0-1 58 处全量降 P2(5 高危已清,余低危非紧急)
|
||||
|
||||
**下一步**:
|
||||
- 待审查 CR-260618-13 交审查会话独立 grep 核验
|
||||
- 工作区累积 commit-policy-deferred 项 +5(等架构级基线)
|
||||
- 剩余需用户项同前(11 项 workflow 推不动)
|
||||
|
||||
<!--
|
||||
|
||||
### [Sprint N] YYYY-MM-DD — 简述
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
[package]
|
||||
name = "df-traceability"
|
||||
name = "df-ai-core"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
df-core = { path = "../df-core" }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
futures = "0.3"
|
||||
@@ -0,0 +1,13 @@
|
||||
//! df-ai-core: LLM Provider trait + AI 数据结构(轻量 crate,零 HTTP 依赖)
|
||||
//!
|
||||
//! 从 df-ai 下沉的全局 AI 接入标准。df-ai 保留 HTTP impl 与业务逻辑
|
||||
//! (ContextManager / AiToolRegistry / build_provider 等),通过 re-export
|
||||
//! 保持 `df_ai::provider::*` 路径不变。df-ideas 等轻消费方直接依赖本 crate
|
||||
//! 的 trait 即可接 LLM,不引入 reqwest / eventsource-stream 等重依赖。
|
||||
|
||||
pub mod model;
|
||||
pub mod provider;
|
||||
pub mod types;
|
||||
|
||||
pub use model::*;
|
||||
pub use provider::*;
|
||||
@@ -0,0 +1,469 @@
|
||||
//! 模型能力数据模型 — F-01 阶段1
|
||||
//!
|
||||
//! 单模型的完整描述(4 维度 + 路由控制)。纯数据结构,零 IO / 零 DB 依赖。
|
||||
//! df-storage 反序列化 DB 行时直接消费本模块类型;df-ai 探测器/路由器阶段 2-4 再用。
|
||||
//!
|
||||
//! 设计来源:docs/02-架构设计/已编号方案/F-01-模型能力系统与智能路由设计-2026-06-16.md §2
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ============================================================
|
||||
// 4 维度枚举
|
||||
// ============================================================
|
||||
|
||||
/// 维度 1 — 模态(模型能接收什么输入)
|
||||
///
|
||||
/// 设计文档 §2.2 仅列出 Text/Vision,Audio/Video 为未来扩展占位。
|
||||
/// 序列化为 snake_case(`text` / `vision` / `audio` / `video`)。
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Modality {
|
||||
Text,
|
||||
Vision,
|
||||
/// 未来扩展:音频输入(预留,Phase 2 多模态)
|
||||
Audio,
|
||||
/// 未来扩展:视频输入(预留)
|
||||
Video,
|
||||
}
|
||||
|
||||
/// 维度 2 — 能力(模型能做什么)
|
||||
///
|
||||
/// 设计文档 §2.2 列出 ToolUse/Embedding/CodeGen。序列化为 snake_case。
|
||||
/// §3.4 提到未来可加 Streaming/Custom(String),当前 Phase 1 仅 3 个确定变体。
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Capability {
|
||||
/// function calling / tool use
|
||||
ToolUse,
|
||||
/// 向量嵌入(知识库)
|
||||
Embedding,
|
||||
/// 代码生成强项
|
||||
CodeGen,
|
||||
}
|
||||
|
||||
/// 维度 3 — 价格分级(成本)
|
||||
///
|
||||
/// 序列化为 snake_case。原设计文档 §2.2 列 Free/Low/Medium/High,但 Free 变体形同虚设:
|
||||
/// 预设表(presets/models.json)0 条 free + 启发式从不赋 Free(只 Low/Medium/High),
|
||||
/// B-260618-05 删除 Free 死档变体。
|
||||
///
|
||||
/// 路由已解耦(2026-06-18 B-260618-03):provider /v1/models API 不返回 cost_tier,
|
||||
/// 此维度 100% 靠预设表写死 + 模型名启发式猜,数据无客观依据不可信,不再参与硬路由
|
||||
/// (原 §6.1 `cost_tier <= max_cost` 过滤已删除)。枚举保留供未来出现真实判别源时再接回。
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CostTier {
|
||||
Low,
|
||||
Medium,
|
||||
High,
|
||||
}
|
||||
|
||||
/// 维度 4 — 聪明程度(智力分级)
|
||||
///
|
||||
/// 设计文档 §2.2:Lite/Standard/Plus/Ultra。序列化为 snake_case。
|
||||
/// 派生 Ord:Lite < Standard < Plus < Ultra。
|
||||
///
|
||||
/// 路由已解耦(2026-06-18 B-260618-03):provider /v1/models API 不返回 intelligence,
|
||||
/// 此维度 100% 靠预设表写死 + 模型名启发式猜,数据无客观依据不可信,不再参与硬路由
|
||||
/// (原 §6.1 `intelligence >= min_intelligence` 过滤已删除)。枚举保留供未来出现真实判别源时再接回。
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum IntelligenceTier {
|
||||
/// flash/mini/lite/nano — 快但简单任务
|
||||
Lite,
|
||||
/// air/standard — 日常够用
|
||||
Standard,
|
||||
/// plus/pro/max — 复杂推理
|
||||
Plus,
|
||||
/// 最强模型 — 兜底用
|
||||
Ultra,
|
||||
}
|
||||
|
||||
/// 探测元数据来源(只读,由 ModelProbe 填充,Phase 2)
|
||||
///
|
||||
/// 设计文档 §2.1 的 ProbeSource 字段。Phase 1 仅定义占位,Phase 2 探测器填充。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ProbeSource {
|
||||
/// 用户手动设定(最高优先)
|
||||
UserSet,
|
||||
/// 内置预设表精确/模糊匹配
|
||||
PresetTable,
|
||||
/// 模型名启发式推断
|
||||
Heuristic,
|
||||
/// 默认值兜底
|
||||
Default,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 核心结构 ModelConfig
|
||||
// ============================================================
|
||||
|
||||
/// 单个模型的完整配置(4 维度 + 路由控制)。
|
||||
///
|
||||
/// 设计文档 §2.1。serde 默认值保证老库/部分字段缺失时向后兼容。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ModelConfig {
|
||||
// ── 基础 ──
|
||||
/// 模型名(如 "glm-4v-flash")
|
||||
pub model_id: String,
|
||||
/// 启用/禁用(false = 配了但不参与路由,相当于"备档")
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
/// 用户自定义别名(可选)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub label: Option<String>,
|
||||
|
||||
// ── 维度 1:模态(能接收什么输入)──
|
||||
#[serde(default)]
|
||||
pub modalities: Vec<Modality>,
|
||||
|
||||
// ── 维度 2:能力(能做什么)──
|
||||
#[serde(default)]
|
||||
pub capabilities: Vec<Capability>,
|
||||
|
||||
// ── 维度 3:价格(成本分级)──
|
||||
#[serde(default)]
|
||||
pub cost_tier: CostTier,
|
||||
|
||||
// ── 维度 4:聪明程度(智力分级)──
|
||||
#[serde(default)]
|
||||
pub intelligence: IntelligenceTier,
|
||||
|
||||
// ── 路由控制 ──
|
||||
/// 权重(0-100,同能力候选中优先选权重高的)
|
||||
#[serde(default = "default_weight")]
|
||||
pub weight: u32,
|
||||
/// 上下文窗口大小(tokens)
|
||||
#[serde(default = "default_context_window")]
|
||||
pub context_window: usize,
|
||||
|
||||
// ── 探测元数据(只读,由 ModelProbe 填充,Phase 2)──
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub probe_source: Option<ProbeSource>,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_weight() -> u32 {
|
||||
50
|
||||
}
|
||||
|
||||
fn default_context_window() -> usize {
|
||||
8192
|
||||
}
|
||||
|
||||
impl ModelConfig {
|
||||
/// 用模型名构造默认配置(老格式字符串数组 `["glm-4-flash"]` 升级时逐项调用)。
|
||||
///
|
||||
/// 设计文档 §2.3 向后兼容:老 `models: ["model-a"]` → 每个名字转默认 ModelConfig。
|
||||
/// 默认值:enabled=true / modalities=[Text] / capabilities=[ToolUse] /
|
||||
/// cost_tier=Medium / intelligence=Standard / weight=50 / context_window=8192。
|
||||
/// (ToolUse 默认开:主流对话模型支持 function calling;精确能力由 Phase 2 探测器修正)
|
||||
pub fn with_defaults(model_id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
model_id: model_id.into(),
|
||||
enabled: true,
|
||||
label: None,
|
||||
modalities: vec![Modality::Text],
|
||||
capabilities: vec![Capability::ToolUse],
|
||||
cost_tier: CostTier::Medium,
|
||||
intelligence: IntelligenceTier::Standard,
|
||||
weight: default_weight(),
|
||||
context_window: default_context_window(),
|
||||
probe_source: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 默认值(#[serde(default)] 用于 Vec/Option 字段时 serde 要求 Default impl)
|
||||
impl Default for Modality {
|
||||
fn default() -> Self {
|
||||
Modality::Text
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CostTier {
|
||||
fn default() -> Self {
|
||||
CostTier::Medium
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for IntelligenceTier {
|
||||
fn default() -> Self {
|
||||
IntelligenceTier::Standard
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 向后兼容反序列化:老格式字符串数组 → ModelConfig 数组
|
||||
// ============================================================
|
||||
|
||||
/// 反序列化 `models` JSON 字段,兼容两种格式:
|
||||
///
|
||||
/// - 老格式:`["glm-4-flash", "glm-4v"]`(纯字符串数组)→ 每个名字转 `ModelConfig::with_defaults`
|
||||
/// - 新格式:`[{ "model_id": ..., "modalities": [...] }, ...]`(ModelConfig 对象数组)
|
||||
/// - 空/非数组:null / 缺失 / 空字符串 → 空 Vec
|
||||
///
|
||||
/// 设计文档 §2.3。挂在 AiProviderRecord.models 字段上(#[serde(default, deserialize_with = ...)])。
|
||||
pub fn deserialize_model_configs<'de, D>(deserializer: D) -> Result<Vec<ModelConfig>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
use serde::de::Error;
|
||||
|
||||
let opt: Option<serde_json::Value> = Option::deserialize(deserializer)?;
|
||||
let Some(value) = opt else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
match value {
|
||||
// null / 空字符串 / 空数组 → 空
|
||||
serde_json::Value::Null => Ok(Vec::new()),
|
||||
serde_json::Value::String(s) if s.trim().is_empty() => Ok(Vec::new()),
|
||||
serde_json::Value::String(s) => {
|
||||
// 字符串内容可能是 JSON 文本(DB TEXT 列存 JSON 字符串)
|
||||
match serde_json::from_str::<serde_json::Value>(s.trim()) {
|
||||
Ok(inner) => parse_models_value(inner).map_err(D::Error::custom),
|
||||
Err(_) => Ok(Vec::new()),
|
||||
}
|
||||
}
|
||||
serde_json::Value::Array(arr) => parse_models_value(serde_json::Value::Array(arr)).map_err(D::Error::custom),
|
||||
other => parse_models_value(other).map_err(D::Error::custom),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_models_value(value: serde_json::Value) -> Result<Vec<ModelConfig>, serde_json::Error> {
|
||||
match value {
|
||||
serde_json::Value::Array(arr) => {
|
||||
// 老格式:全为字符串 → 每个转默认配置
|
||||
if arr.iter().all(|v| v.is_string()) {
|
||||
return Ok(arr
|
||||
.into_iter()
|
||||
.filter_map(|v| v.as_str().map(ModelConfig::with_defaults))
|
||||
.collect());
|
||||
}
|
||||
// 新格式:ModelConfig 对象数组
|
||||
serde_json::from_value::<Vec<ModelConfig>>(serde_json::Value::Array(arr))
|
||||
}
|
||||
serde_json::Value::Null => Ok(Vec::new()),
|
||||
_ => Ok(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── 枚举 serde 映射 ──
|
||||
|
||||
#[test]
|
||||
fn modality_serde_snake_case() {
|
||||
let cases = [
|
||||
(Modality::Text, "\"text\""),
|
||||
(Modality::Vision, "\"vision\""),
|
||||
(Modality::Audio, "\"audio\""),
|
||||
(Modality::Video, "\"video\""),
|
||||
];
|
||||
for (variant, expected) in cases {
|
||||
let s = serde_json::to_string(&variant).unwrap();
|
||||
assert_eq!(s, expected, "serialize {variant:?}");
|
||||
let back: Modality = serde_json::from_str(expected).unwrap();
|
||||
assert_eq!(back, variant, "deserialize {variant:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capability_serde_snake_case() {
|
||||
let cases = [
|
||||
(Capability::ToolUse, "\"tool_use\""),
|
||||
(Capability::Embedding, "\"embedding\""),
|
||||
(Capability::CodeGen, "\"code_gen\""),
|
||||
];
|
||||
for (variant, expected) in cases {
|
||||
let s = serde_json::to_string(&variant).unwrap();
|
||||
assert_eq!(s, expected, "serialize {variant:?}");
|
||||
let back: Capability = serde_json::from_str(expected).unwrap();
|
||||
assert_eq!(back, variant, "deserialize {variant:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_tier_serde_and_ordering() {
|
||||
let cases = [
|
||||
(CostTier::Low, "\"low\""),
|
||||
(CostTier::Medium, "\"medium\""),
|
||||
(CostTier::High, "\"high\""),
|
||||
];
|
||||
for (variant, expected) in cases {
|
||||
assert_eq!(serde_json::to_string(&variant).unwrap(), expected);
|
||||
assert_eq!(serde_json::from_str::<CostTier>(expected).unwrap(), variant);
|
||||
}
|
||||
// Ord:Low < Medium < High(枚举序,路由已解耦不再用于过滤,保留供未来判别源)。
|
||||
// Free 变体已删除(B-260618-05:预设/启发式从不赋 Free,死档)。
|
||||
assert!(CostTier::Low < CostTier::Medium);
|
||||
assert!(CostTier::Medium < CostTier::High);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intelligence_tier_serde_and_ordering() {
|
||||
let cases = [
|
||||
(IntelligenceTier::Lite, "\"lite\""),
|
||||
(IntelligenceTier::Standard, "\"standard\""),
|
||||
(IntelligenceTier::Plus, "\"plus\""),
|
||||
(IntelligenceTier::Ultra, "\"ultra\""),
|
||||
];
|
||||
for (variant, expected) in cases {
|
||||
assert_eq!(serde_json::to_string(&variant).unwrap(), expected);
|
||||
assert_eq!(serde_json::from_str::<IntelligenceTier>(expected).unwrap(), variant);
|
||||
}
|
||||
// Ord:Lite < Standard < Plus < Ultra(枚举序,路由已解耦不再用于过滤,保留供未来判别源)
|
||||
assert!(IntelligenceTier::Lite < IntelligenceTier::Standard);
|
||||
assert!(IntelligenceTier::Standard < IntelligenceTier::Plus);
|
||||
assert!(IntelligenceTier::Plus < IntelligenceTier::Ultra);
|
||||
}
|
||||
|
||||
// ── ModelConfig serde roundtrip ──
|
||||
|
||||
#[test]
|
||||
fn model_config_full_roundtrip() {
|
||||
let mc = ModelConfig {
|
||||
model_id: "glm-4v".into(),
|
||||
enabled: true,
|
||||
label: Some("我的视觉".into()),
|
||||
modalities: vec![Modality::Text, Modality::Vision],
|
||||
capabilities: vec![Capability::ToolUse],
|
||||
cost_tier: CostTier::Medium,
|
||||
intelligence: IntelligenceTier::Plus,
|
||||
weight: 70,
|
||||
context_window: 128_000,
|
||||
probe_source: Some(ProbeSource::PresetTable),
|
||||
};
|
||||
let json = serde_json::to_string(&mc).unwrap();
|
||||
let back: ModelConfig = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, mc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_config_skip_none_label_and_probe() {
|
||||
let mc = ModelConfig::with_defaults("glm-4-flash");
|
||||
let json = serde_json::to_string(&mc).unwrap();
|
||||
// label / probe_source 为 None 应被跳过
|
||||
assert!(!json.contains("label"), "json={json}");
|
||||
assert!(!json.contains("probe_source"), "json={json}");
|
||||
// 反序列化回来相等(skip_serializing 不影响 deserialize)
|
||||
let back: ModelConfig = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, mc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_config_with_defaults_values() {
|
||||
let mc = ModelConfig::with_defaults("test-model");
|
||||
assert_eq!(mc.model_id, "test-model");
|
||||
assert!(mc.enabled);
|
||||
assert_eq!(mc.modalities, vec![Modality::Text]);
|
||||
assert_eq!(mc.capabilities, vec![Capability::ToolUse]);
|
||||
assert_eq!(mc.cost_tier, CostTier::Medium);
|
||||
assert_eq!(mc.intelligence, IntelligenceTier::Standard);
|
||||
assert_eq!(mc.weight, 50);
|
||||
assert_eq!(mc.context_window, 8192);
|
||||
assert_eq!(mc.label, None);
|
||||
assert_eq!(mc.probe_source, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_config_defaults_applied_on_missing_fields() {
|
||||
// 最小 JSON,仅 model_id — 其余字段靠 #[serde(default)] / default fn 填充
|
||||
let json = r#"{"model_id":"minimal"}"#;
|
||||
let mc: ModelConfig = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(mc.model_id, "minimal");
|
||||
assert!(mc.enabled, "enabled 默认 true");
|
||||
assert_eq!(mc.weight, 50, "weight 默认 50");
|
||||
assert_eq!(mc.context_window, 8192, "context_window 默认 8192");
|
||||
assert!(mc.modalities.is_empty(), "Vec 字段 default 空");
|
||||
assert!(mc.capabilities.is_empty());
|
||||
assert_eq!(mc.cost_tier, CostTier::Medium);
|
||||
assert_eq!(mc.intelligence, IntelligenceTier::Standard);
|
||||
}
|
||||
|
||||
// ── deserialize_model_configs 向后兼容 ──
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Wrap {
|
||||
#[serde(default, deserialize_with = "deserialize_model_configs")]
|
||||
models: Vec<ModelConfig>,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_old_format_string_array() {
|
||||
// 老格式:["glm-4-flash", "glm-4v"]
|
||||
let json = r#"{"models":["glm-4-flash","glm-4v"]}"#;
|
||||
let w: Wrap = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(w.models.len(), 2);
|
||||
assert_eq!(w.models[0].model_id, "glm-4-flash");
|
||||
assert!(w.models[0].enabled);
|
||||
assert_eq!(w.models[0].modalities, vec![Modality::Text]);
|
||||
assert_eq!(w.models[1].model_id, "glm-4v");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_new_format_object_array() {
|
||||
let json = r#"{"models":[{"model_id":"glm-4v","enabled":true,"modalities":["text","vision"],"capabilities":["tool_use"],"cost_tier":"medium","intelligence":"plus","weight":70,"context_window":128000}]}"#;
|
||||
let w: Wrap = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(w.models.len(), 1);
|
||||
let m = &w.models[0];
|
||||
assert_eq!(m.model_id, "glm-4v");
|
||||
assert_eq!(m.modalities, vec![Modality::Text, Modality::Vision]);
|
||||
assert_eq!(m.intelligence, IntelligenceTier::Plus);
|
||||
assert_eq!(m.context_window, 128_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_null_or_missing_yields_empty() {
|
||||
// null
|
||||
let w: Wrap = serde_json::from_str(r#"{"models":null}"#).unwrap();
|
||||
assert!(w.models.is_empty());
|
||||
// 缺失字段
|
||||
let w: Wrap = serde_json::from_str(r#"{}"#).unwrap();
|
||||
assert!(w.models.is_empty());
|
||||
// 空字符串(DB TEXT 列可能存空串)
|
||||
let w: Wrap = serde_json::from_str(r#"{"models":""}"#).unwrap();
|
||||
assert!(w.models.is_empty());
|
||||
// 空数组
|
||||
let w: Wrap = serde_json::from_str(r#"{"models":[]}"#).unwrap();
|
||||
assert!(w.models.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_json_string_text_column() {
|
||||
// DB TEXT 列存的是 JSON 文本字符串:`"[\"model-a\"]"`
|
||||
let json = r#"{"models":"[\"glm-4-flash\"]"}"#;
|
||||
let w: Wrap = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(w.models.len(), 1);
|
||||
assert_eq!(w.models[0].model_id, "glm-4-flash");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_via_text_column() {
|
||||
// 模拟 DB 落库:Vec<ModelConfig> → serde_json 序列化 → 存 TEXT 列
|
||||
// 读出来:TEXT 列值 → 反序列化回 Vec<ModelConfig>
|
||||
let original = vec![
|
||||
ModelConfig::with_defaults("glm-4-flash"),
|
||||
ModelConfig {
|
||||
model_id: "glm-4v".into(),
|
||||
modalities: vec![Modality::Text, Modality::Vision],
|
||||
intelligence: IntelligenceTier::Plus,
|
||||
weight: 70,
|
||||
context_window: 128_000,
|
||||
..ModelConfig::with_defaults("glm-4v")
|
||||
},
|
||||
];
|
||||
let text = serde_json::to_string(&original).unwrap();
|
||||
let wrapped = format!(r#"{{"models":"{}"}}"#, text.replace('"', "\\\""));
|
||||
let w: Wrap = serde_json::from_str(&wrapped).unwrap();
|
||||
assert_eq!(w.models.len(), 2);
|
||||
assert_eq!(w.models[1].intelligence, IntelligenceTier::Plus);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
//! LLM Provider trait — 统一的 LLM 调用抽象
|
||||
//!
|
||||
//! 支持 OpenAI 兼容 API(覆盖 OpenAI / GLM / DeepSeek / Claude 兼容模式),
|
||||
//! 含 function calling / tool use 能力。
|
||||
//!
|
||||
//! 本文件含 trait + 类型 impl 块(零 IO)。纯类型定义(CompletionRequest /
|
||||
//! ChatMessage / ContentPart 等 `#[derive]` struct/enum)已拆分到 `types.rs`,
|
||||
//! 经下方 `pub use crate::types::*` re-export 保持外部路径
|
||||
//! `df_ai_core::provider::*` / `df_ai::provider::*` 不变(编译期验证)。
|
||||
//! HTTP impl(OpenAICompatProvider / AnthropicCompatProvider)+ 业务逻辑
|
||||
//! (ContextManager / AiToolRegistry / build_provider 工厂)留在 df-ai crate。
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
// 透明 re-export:types.rs 中所有 pub 类型经此回流到 provider::* glob,
|
||||
// 使 df_ai::provider::*(df-ai/src/provider.rs:15 的 `pub use df_ai_core::provider::*`)
|
||||
// 与直接 `df_ai_core::provider::Type` 限定路径全部继续可用。
|
||||
pub use crate::types::*;
|
||||
use crate::types::{new_message_id, now_millis_i64};
|
||||
|
||||
impl ContentPart {
|
||||
pub fn text(text: impl Into<String>) -> Self {
|
||||
ContentPart::Text { text: text.into() }
|
||||
}
|
||||
pub fn image_base64(media_type: impl Into<String>, data: impl Into<String>) -> Self {
|
||||
ContentPart::Image {
|
||||
url: None,
|
||||
base64: Some(data.into()),
|
||||
media_type: Some(media_type.into()),
|
||||
alt: None,
|
||||
}
|
||||
}
|
||||
pub fn image_url(url: impl Into<String>) -> Self {
|
||||
ContentPart::Image { url: Some(url.into()), base64: None, media_type: None, alt: None }
|
||||
}
|
||||
|
||||
/// 是否图片片
|
||||
pub fn is_image(&self) -> bool {
|
||||
matches!(self, ContentPart::Image { .. })
|
||||
}
|
||||
}
|
||||
|
||||
impl ChatMessage {
|
||||
pub fn system(content: impl Into<String>) -> Self {
|
||||
Self { id: Some(new_message_id()), role: MessageRole::System, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, timestamp: Some(now_millis_i64()) }
|
||||
}
|
||||
pub fn user(content: impl Into<String>) -> Self {
|
||||
Self { id: Some(new_message_id()), role: MessageRole::User, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, timestamp: Some(now_millis_i64()) }
|
||||
}
|
||||
pub fn assistant(content: impl Into<String>) -> Self {
|
||||
Self { id: Some(new_message_id()), role: MessageRole::Assistant, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, timestamp: Some(now_millis_i64()) }
|
||||
}
|
||||
pub fn assistant_with_tools(content: impl Into<String>, tool_calls: Vec<ToolCall>) -> Self {
|
||||
Self { id: Some(new_message_id()), role: MessageRole::Assistant, content: content.into(), parts: None, tool_call_id: None, tool_calls: Some(tool_calls), model: None, status: None, reasoning_content: None, timestamp: Some(now_millis_i64()) }
|
||||
}
|
||||
pub fn tool_result(call_id: impl Into<String>, content: impl Into<String>) -> Self {
|
||||
Self { id: Some(new_message_id()), role: MessageRole::Tool, content: content.into(), parts: None, tool_call_id: Some(call_id.into()), tool_calls: None, model: None, status: None, reasoning_content: None, timestamp: Some(now_millis_i64()) }
|
||||
}
|
||||
|
||||
/// 多模态 user 消息:content 文本 + parts(含 Image 片)。
|
||||
/// content 作为人类可读文本(也作非 vision 端点降级载荷);parts 透传给 vision 端点。
|
||||
pub fn user_parts(content: impl Into<String>, parts: Vec<ContentPart>) -> Self {
|
||||
Self { id: Some(new_message_id()), role: MessageRole::User, content: content.into(), parts: Some(parts), tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, timestamp: Some(now_millis_i64()) }
|
||||
}
|
||||
|
||||
/// 是否含图片片(供 provider 判定走多模态分支)。
|
||||
pub fn has_image(&self) -> bool {
|
||||
self.parts.as_ref().map(|ps| ps.iter().any(|p| p.is_image())).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// parts 若存在则返回引用,否则 None。
|
||||
pub fn parts(&self) -> Option<&[ContentPart]> {
|
||||
self.parts.as_deref()
|
||||
}
|
||||
|
||||
/// 把 content + parts 拍平为有序 ContentPart 序列:先 content 作 Text 片(非空时),
|
||||
/// 再追加 parts(若有)。供 provider 生成 content blocks(保证文本在前、图片在后)。
|
||||
pub fn flattened_parts(&self) -> Vec<ContentPart> {
|
||||
let mut out: Vec<ContentPart> = Vec::new();
|
||||
if !self.content.is_empty() {
|
||||
out.push(ContentPart::Text { text: self.content.clone() });
|
||||
}
|
||||
if let Some(ps) = &self.parts {
|
||||
for p in ps {
|
||||
out.push(p.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 是否处于 active 态(status 为 None 或 "active")。其余状态一律 false。
|
||||
///
|
||||
/// 正面白名单(F-15 §3.2):仅认 None / "active",新状态
|
||||
/// (如阶段2 引入的 "archived_segment" / "compressed")自动落入不 active 分支,
|
||||
/// 无需每加一个状态就来这里改。当前取值 None/Some("active")/Some("truncated")
|
||||
/// 行为与旧反面排除完全等价(None=true / "active"=true / "truncated"=false)。
|
||||
pub fn is_active(&self) -> bool {
|
||||
matches!(self.status.as_deref(), None | Some("active"))
|
||||
}
|
||||
}
|
||||
|
||||
impl ToolDefinition {
|
||||
pub fn function(name: impl Into<String>, description: impl Into<String>, parameters: serde_json::Value) -> Self {
|
||||
Self {
|
||||
tool_type: "function".into(),
|
||||
function: ToolFunction { name: name.into(), description: description.into(), parameters },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ToolCall {
|
||||
pub fn new(id: impl Into<String>, name: impl Into<String>, arguments: impl Into<String>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
call_type: "function".into(),
|
||||
function: ToolCallFunction { name: name.into(), arguments: arguments.into() },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// LLM Provider trait
|
||||
#[async_trait]
|
||||
pub trait LlmProvider: Send + Sync {
|
||||
/// 同步调用
|
||||
async fn complete(&self, request: CompletionRequest) -> anyhow::Result<CompletionResponse>;
|
||||
|
||||
/// 流式调用(返回异步流)
|
||||
async fn stream(
|
||||
&self,
|
||||
request: CompletionRequest,
|
||||
) -> 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 名称
|
||||
fn name(&self) -> &str;
|
||||
|
||||
/// 实际请求端点(含 base_url + 关键路径,如 chat completions / messages)。
|
||||
/// 默认回落 `name()`,provider 实现覆盖返真实 URL,供 401/网络错误诊断打印
|
||||
/// —— 旧路径只能近似打印 provider_type,看不到实际请求端点。
|
||||
fn endpoint(&self) -> String {
|
||||
self.name().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn is_active_whitelist() {
|
||||
// F-15 §3.2 正面白名单:仅 None / "active" 为 true,其余一律 false。
|
||||
// 零行为变化:None / "active" / "truncated" 与旧反面排除完全等价;
|
||||
// "archived_segment" / "compressed"(阶段2 引入,当前代码未赋值)由白名单
|
||||
// matches! 只认 None/active 自动落入 false,面向未来验证。
|
||||
|
||||
// None(构造默认值,向前兼容老 JSON)
|
||||
let m = ChatMessage::user("hi");
|
||||
assert!(m.is_active(), "None 应 active");
|
||||
|
||||
// "active"
|
||||
let mut m = ChatMessage::user("hi");
|
||||
m.status = Some("active".to_string());
|
||||
assert!(m.is_active(), "Some(active) 应 active");
|
||||
|
||||
// "truncated" — 当前取值,与旧实现等价(false)
|
||||
let mut m = ChatMessage::user("hi");
|
||||
m.status = Some("truncated".to_string());
|
||||
assert!(!m.is_active(), "truncated 应不 active");
|
||||
|
||||
// "archived_segment" — 阶段2 待引入,白名单自动隔离
|
||||
let mut m = ChatMessage::user("hi");
|
||||
m.status = Some("archived_segment".to_string());
|
||||
assert!(!m.is_active(), "archived_segment 应不 active(白名单隔离)");
|
||||
|
||||
// "compressed" — 阶段2 待引入,白名单自动隔离
|
||||
let mut m = ChatMessage::user("hi");
|
||||
m.status = Some("compressed".to_string());
|
||||
assert!(!m.is_active(), "compressed 应不 active(白名单隔离)");
|
||||
}
|
||||
|
||||
// ---------- F-260614-05 Phase 2a ContentPart ----------
|
||||
|
||||
/// 老 JSON(无 parts 字段)反序列化时 parts 应为 None(向前兼容)
|
||||
#[test]
|
||||
fn contentpart_legacy_json_no_parts() {
|
||||
let json = r#"{"role":"user","content":"hello"}"#;
|
||||
let m: ChatMessage = serde_json::from_str(json).expect("老 JSON 应可反序列化");
|
||||
assert_eq!(m.content, "hello");
|
||||
assert!(m.parts.is_none(), "老 JSON 无 parts 字段 → None");
|
||||
assert!(!m.has_image());
|
||||
}
|
||||
|
||||
/// 新 JSON 带 parts(含 Image 片)反序列化 round-trip
|
||||
#[test]
|
||||
fn contentpart_with_image_roundtrip() {
|
||||
let m = ChatMessage::user_parts(
|
||||
"看这张图",
|
||||
vec![
|
||||
ContentPart::image_base64("image/png", "iVBORw0KGgo="),
|
||||
ContentPart::text("说明"),
|
||||
],
|
||||
);
|
||||
let json = serde_json::to_string(&m).expect("序列化");
|
||||
// parts 顺序:[image_base64, text] → 首元素是 image
|
||||
assert!(json.contains(r#""parts":[{"type":"image""#));
|
||||
assert!(json.contains(r#""type":"text""#));
|
||||
assert!(json.contains(r#""base64":"iVBORw0KGgo=""#));
|
||||
assert!(json.contains(r#""media_type":"image/png""#));
|
||||
|
||||
let back: ChatMessage = serde_json::from_str(&json).expect("反序列化 round-trip");
|
||||
assert_eq!(back.content, "看这张图");
|
||||
assert!(back.has_image());
|
||||
let parts = back.parts.expect("parts 应存在");
|
||||
assert_eq!(parts.len(), 2);
|
||||
assert!(parts[0].is_image());
|
||||
}
|
||||
|
||||
/// has_image 判定
|
||||
#[test]
|
||||
fn contentpart_has_image_detection() {
|
||||
assert!(!ChatMessage::user("纯文本").has_image());
|
||||
let m = ChatMessage::user_parts("t", vec![ContentPart::text("只文本片")]);
|
||||
assert!(!m.has_image(), "仅 Text 片不算 has_image");
|
||||
let m = ChatMessage::user_parts(
|
||||
"t",
|
||||
vec![ContentPart::text("前缀"), ContentPart::image_url("https://x/a.png")],
|
||||
);
|
||||
assert!(m.has_image());
|
||||
}
|
||||
|
||||
/// flattened_parts:content 非空 → 前置 Text 片 + parts 追加
|
||||
#[test]
|
||||
fn contentpart_flattened_parts() {
|
||||
let m = ChatMessage::user("hi");
|
||||
let flat = m.flattened_parts();
|
||||
assert_eq!(flat.len(), 1);
|
||||
assert_eq!(flat[0], ContentPart::Text { text: "hi".into() });
|
||||
|
||||
let m = ChatMessage::user_parts(
|
||||
"cap",
|
||||
vec![ContentPart::image_url("u"), ContentPart::text("尾")],
|
||||
);
|
||||
let flat = m.flattened_parts();
|
||||
assert_eq!(flat.len(), 3);
|
||||
assert_eq!(flat[0], ContentPart::Text { text: "cap".into() });
|
||||
assert!(flat[1].is_image());
|
||||
assert_eq!(flat[2], ContentPart::Text { text: "尾".into() });
|
||||
|
||||
let mut m = ChatMessage::user("");
|
||||
m.parts = Some(vec![ContentPart::text("x")]);
|
||||
let flat = m.flattened_parts();
|
||||
assert_eq!(flat.len(), 1, "空 content 不应产生空 Text 片");
|
||||
}
|
||||
|
||||
/// 向后兼容:旧代码 ChatMessage 字面量构造仍合法(audit/title/commands 零回归)
|
||||
#[test]
|
||||
fn contentpart_struct_literal_compat() {
|
||||
let m = ChatMessage {
|
||||
id: None,
|
||||
role: MessageRole::User,
|
||||
content: "字面量构造".into(),
|
||||
parts: None,
|
||||
tool_call_id: None,
|
||||
tool_calls: None,
|
||||
model: None,
|
||||
status: None,
|
||||
reasoning_content: None,
|
||||
timestamp: None,
|
||||
};
|
||||
assert_eq!(m.content, "字面量构造");
|
||||
assert!(m.parts.is_none());
|
||||
}
|
||||
|
||||
// ---------- DeepSeek reasoning_content ----------
|
||||
|
||||
/// 有 reasoning_content 时应序列化出来;None 时不出现
|
||||
#[test]
|
||||
fn completion_request_reasoning_content_serialization() {
|
||||
let req = CompletionRequest {
|
||||
model: "deepseek-r1".to_string(),
|
||||
messages: vec![ChatMessage::user("hello")],
|
||||
temperature: None,
|
||||
max_tokens: None,
|
||||
stream: false,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
reasoning_content: Some("let me think...".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&req).unwrap();
|
||||
assert!(json.contains("reasoning_content"), "reasoning_content 应出现在 JSON 中, got: {}", json);
|
||||
|
||||
// None 时不应出现(skip_serializing_if)
|
||||
let req_none = CompletionRequest { reasoning_content: None, ..req };
|
||||
let json_none = serde_json::to_string(&req_none).unwrap();
|
||||
assert!(!json_none.contains("reasoning_content"), "None 的 reasoning_content 不应序列化, got: {}", json_none);
|
||||
}
|
||||
|
||||
/// 所有便捷构造函数默认 reasoning_content 为 None
|
||||
#[test]
|
||||
fn chat_message_reasoning_content_constructors() {
|
||||
assert!(ChatMessage::system("sys").reasoning_content.is_none());
|
||||
assert!(ChatMessage::user("hi").reasoning_content.is_none());
|
||||
assert!(ChatMessage::assistant("resp").reasoning_content.is_none());
|
||||
assert!(ChatMessage::tool_result("call_1", "result").reasoning_content.is_none());
|
||||
}
|
||||
|
||||
/// ChatMessage reasoning_content 序列化 round-trip
|
||||
#[test]
|
||||
fn chat_message_reasoning_content_roundtrip() {
|
||||
let m = ChatMessage {
|
||||
id: None,
|
||||
role: MessageRole::Assistant,
|
||||
content: "answer".to_string(),
|
||||
parts: None,
|
||||
tool_call_id: None,
|
||||
tool_calls: None,
|
||||
model: None,
|
||||
status: None,
|
||||
reasoning_content: Some("thinking process".to_string()),
|
||||
timestamp: None,
|
||||
};
|
||||
let json = serde_json::to_string(&m).unwrap();
|
||||
assert!(json.contains("reasoning_content"));
|
||||
|
||||
let deserialized: ChatMessage = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized.reasoning_content, Some("thinking process".to_string()));
|
||||
}
|
||||
|
||||
// ---------- F-260619-04 消息级溯源:id 字段 ----------
|
||||
|
||||
/// 所有便捷构造函数默认生成非 None 的 id(ULID 风格)
|
||||
#[test]
|
||||
fn chat_message_id_generated_by_constructors() {
|
||||
assert!(ChatMessage::system("sys").id.is_some(), "system 应有 id");
|
||||
assert!(ChatMessage::user("hi").id.is_some(), "user 应有 id");
|
||||
assert!(ChatMessage::assistant("resp").id.is_some(), "assistant 应有 id");
|
||||
assert!(
|
||||
ChatMessage::assistant_with_tools("r", vec![]).id.is_some(),
|
||||
"assistant_with_tools 应有 id"
|
||||
);
|
||||
assert!(
|
||||
ChatMessage::tool_result("call_1", "result").id.is_some(),
|
||||
"tool_result 应有 id"
|
||||
);
|
||||
assert!(
|
||||
ChatMessage::user_parts("t", vec![ContentPart::text("x")]).id.is_some(),
|
||||
"user_parts 应有 id"
|
||||
);
|
||||
}
|
||||
|
||||
/// id 全局唯一性:连续构造 100 条不重复(AtomicU64 计数器保证)
|
||||
#[test]
|
||||
fn chat_message_id_uniqueness() {
|
||||
let mut ids = std::collections::HashSet::new();
|
||||
for _ in 0..100 {
|
||||
let m = ChatMessage::user("x");
|
||||
let id = m.id.expect("构造的消息应有 id");
|
||||
assert!(ids.insert(id), "100 条消息 id 应全部唯一");
|
||||
}
|
||||
}
|
||||
|
||||
/// id 序列化 round-trip:有 id 时序列化保留,反序列化回来一致
|
||||
#[test]
|
||||
fn chat_message_id_roundtrip() {
|
||||
let m = ChatMessage {
|
||||
id: Some("msg_1718800000000_42".to_string()),
|
||||
role: MessageRole::Assistant,
|
||||
content: "answer".to_string(),
|
||||
parts: None,
|
||||
tool_call_id: None,
|
||||
tool_calls: None,
|
||||
model: None,
|
||||
status: None,
|
||||
reasoning_content: None,
|
||||
timestamp: None,
|
||||
};
|
||||
let json = serde_json::to_string(&m).unwrap();
|
||||
assert!(json.contains(r#""id":"msg_1718800000000_42""#), "id 应序列化, got: {}", json);
|
||||
|
||||
let back: ChatMessage = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.id.as_deref(), Some("msg_1718800000000_42"));
|
||||
}
|
||||
|
||||
/// 向前兼容:老 JSON 无 id 字段 → 反序列化为 None(serde default)
|
||||
#[test]
|
||||
fn chat_message_id_legacy_json_no_id() {
|
||||
let json = r#"{"role":"user","content":"hello"}"#;
|
||||
let m: ChatMessage = serde_json::from_str(json).expect("老 JSON 应可反序列化");
|
||||
assert_eq!(m.content, "hello");
|
||||
assert!(m.id.is_none(), "老 JSON 无 id 字段 → None");
|
||||
|
||||
// 重新序列化:id=None 时不应出现 id 字段(skip_serializing_if)
|
||||
let re = serde_json::to_string(&m).unwrap();
|
||||
assert!(!re.contains(r#""id""#), "id=None 不应序列化, got: {}", re);
|
||||
}
|
||||
|
||||
/// StreamChunk reasoning_content 序列化
|
||||
#[test]
|
||||
fn stream_chunk_reasoning_content() {
|
||||
let chunk = StreamChunk {
|
||||
delta: "text".to_string(),
|
||||
finished: false,
|
||||
tool_calls: None,
|
||||
usage: None,
|
||||
error: None,
|
||||
reasoning_content: Some("thought".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&chunk).unwrap();
|
||||
assert!(json.contains("reasoning_content"));
|
||||
|
||||
let chunk_none = StreamChunk { reasoning_content: None, ..chunk };
|
||||
let json_none = serde_json::to_string(&chunk_none).unwrap();
|
||||
assert!(!json_none.contains("reasoning_content"));
|
||||
}
|
||||
|
||||
/// CompletionResponse reasoning_content 序列化
|
||||
#[test]
|
||||
fn completion_response_reasoning_content() {
|
||||
let resp = CompletionResponse {
|
||||
text: "ok".to_string(),
|
||||
model: "r1".to_string(),
|
||||
usage: TokenUsage { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
||||
tool_calls: None,
|
||||
reasoning_content: Some("r1 thought".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&resp).unwrap();
|
||||
assert!(json.contains("reasoning_content"));
|
||||
|
||||
let deserialized: CompletionResponse = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized.reasoning_content, Some("r1 thought".to_string()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
//! LLM Provider 数据结构定义(纯类型,零 IO)
|
||||
//!
|
||||
//! 从 provider.rs 拆分的核心数据类型层:请求/响应/消息/工具/Token 等
|
||||
//! `#[derive]` struct 与 enum + 一个内联时间戳辅助函数。所有 impl 块与
|
||||
//! `LlmProvider` trait 留在 provider.rs,本文件只承载类型定义。
|
||||
//!
|
||||
//! 经 `provider.rs` 的 `pub use crate::types::*` re-export,外部路径
|
||||
//! `df_ai_core::provider::*` / `df_ai::provider::*` 保持不变(编译期验证)。
|
||||
|
||||
use std::pin::Pin;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use futures::Stream;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ============================================================
|
||||
// 核心数据结构
|
||||
// ============================================================
|
||||
|
||||
/// LLM 调用请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CompletionRequest {
|
||||
/// 模型名称
|
||||
pub model: String,
|
||||
/// 提示消息列表
|
||||
pub messages: Vec<ChatMessage>,
|
||||
/// 温度(0.0 ~ 2.0)
|
||||
pub temperature: Option<f32>,
|
||||
/// 最大生成 token 数
|
||||
pub max_tokens: Option<u32>,
|
||||
/// 是否流式输出
|
||||
pub stream: bool,
|
||||
/// 可调用的工具定义
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tools: Option<Vec<ToolDefinition>>,
|
||||
/// 工具调用策略: "auto" | "none" | {"type":"function","name":"xxx"}
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_choice: Option<serde_json::Value>,
|
||||
/// DeepSeek thinking 模式:需把上一轮 response 的 reasoning_content 回传
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_content: Option<String>,
|
||||
}
|
||||
|
||||
/// 多模态消息内容片。Text 片为字符串;Image 片可走 url 或 base64(二选一,base64 非空时 url 忽略)。
|
||||
///
|
||||
/// F-260614-05 Phase 2a 后端:ContentPart 作为 `ChatMessage.parts` 的元素类型。
|
||||
/// 设计上 `content: String`(纯文本主载荷)保持不变,多模态片挂在 `parts`:
|
||||
/// 这样未接入多模态的调用方(audit/title/commands/knowledge_inject 等读 content 当字符串)
|
||||
/// 零回归,避免一次性改全仓。provider 转换层在 `has_image()` 为真时把 parts 透传给
|
||||
/// vision 端点,否则只用 content 文本(保持纯文本端点兼容)。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ContentPart {
|
||||
/// 文本片
|
||||
Text { text: String },
|
||||
/// 图片片。
|
||||
/// - url:http(s) 可达 URL(provider 直接转发,不读字节)。
|
||||
/// - base64:data URI 之外的纯 base64 字符串 + media_type(provider 内嵌转发)。
|
||||
/// base64 非空时 url 忽略,便于前端上行无网络回拉的本地粘贴图。
|
||||
Image {
|
||||
url: Option<String>,
|
||||
base64: Option<String>,
|
||||
/// base64 模式必填(image/png | image/jpeg | image/webp | image/gif);url 模式可空。
|
||||
media_type: Option<String>,
|
||||
/// 可选 alt(vision 模型/降级文本时用)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
alt: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// 聊天消息
|
||||
///
|
||||
/// ⚠️ **迁移耦合点**:下方各字段名(`role`/`content`/`parts`/`tool_call_id`/
|
||||
/// `tool_calls`/`model`/`status`/`reasoning_content`/`timestamp`)被
|
||||
/// `df-storage/src/migrations.rs` 的 `migrate_v21` 硬编码提取(裸 JSON 反序列化,
|
||||
/// 因 df-storage 不依赖 df-ai-core)。**改字段名必须同步更新迁移函数**,否则老库
|
||||
/// 迁移漏数据。详见消息拆分存储设计 §4.2.3 迁移耦合点。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatMessage {
|
||||
/// 消息全局唯一 ID。用于消息级溯源(source_ref / audit message_id / idea source)。
|
||||
/// 构造时由 `new_message_id()` 生成;老 JSON 反序列化为 None(向前兼容)。
|
||||
/// 消息拆分存储(F-260619-03)后,此 ID 即 `ai_messages.id` 列主键。
|
||||
/// 临时/派生消息(如 title 摘要)可显式赋 None(不溯源不落库)。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<String>,
|
||||
pub role: MessageRole,
|
||||
pub content: String,
|
||||
/// 多模态内容片(F-260614-05 Phase 2a)。
|
||||
///
|
||||
/// `None`/空 → 纯文本消息(绝大多数场景,content 即全部载荷)。
|
||||
/// `Some(含 Image 片)` → 多模态消息,provider 在 `has_image()` 为真时把 parts
|
||||
/// 连同 content(作为前置 Text 片)一起转成 OpenAI/Anthropic 的 content blocks。
|
||||
/// content 字段始终保留人类可读文本(文本消息的 Text 片内容与 content 一致),
|
||||
/// 保证 audit/title/export 等读 content 当字符串的调用方零回归。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub parts: Option<Vec<ContentPart>>,
|
||||
/// 工具调用 ID(role=Tool 时必填)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_call_id: Option<String>,
|
||||
/// AI 发起的工具调用列表(role=Assistant 时可能有)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_calls: Option<Vec<ToolCall>>,
|
||||
/// 生成该消息的 model(仅 assistant 消息有,消息级 model 追溯)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
/// 消息状态(UX-09 编辑重生成):None/"active" 正常可见;
|
||||
/// "truncated" 软删(编辑某条 user 消息后其后续消息标记,保留 DB 可追溯但不进 LLM 上下文、前端视图过滤)
|
||||
/// 默认 None(向前兼容老 JSON 反序列化)。落库随 messages JSON 序列化,无需独立列。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub status: Option<String>,
|
||||
/// DeepSeek thinking 模式的推理内容(多轮需回传)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_content: Option<String>,
|
||||
/// 消息创建时间(Unix 毫秒)。仅前端时间戳展示用,序列化进 messages JSON 持久化;
|
||||
/// provider 请求映射不读此字段(构造器打戳→映射忽略,不进 LLM 请求),老数据反序列化为 None。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub timestamp: Option<i64>,
|
||||
}
|
||||
|
||||
/// 当前 Unix 毫秒(ChatMessage 打戳用;df-ai-core 不依赖 df-types,内联避免新增依赖)。
|
||||
pub(crate) fn now_millis_i64() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// ChatMessage.id 生成器:全局唯一 + 单调递增 + 零依赖。
|
||||
///
|
||||
/// 格式 `msg_{ts_ms}_{counter}` —— 时间戳(毫秒)+ 进程内 AtomicU64 计数器双保险。
|
||||
/// 决策:不用 ULID crate(workspace 无此依赖,df-ai-core 也不依赖 df-types::new_id),
|
||||
/// 沿用 `now_millis_i64` 内联模式;AtomicU64 保证并发安全(多线程构造消息 ID 不冲突),
|
||||
/// 计数器即便时间戳回拨(系统时钟调整)也单调。可读性好(含时间戳,便于排查)。
|
||||
pub(crate) fn new_message_id() -> String {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let ts = now_millis_i64();
|
||||
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
format!("msg_{}_{n}", ts)
|
||||
}
|
||||
|
||||
/// 消息角色
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum MessageRole {
|
||||
System,
|
||||
User,
|
||||
Assistant,
|
||||
Tool,
|
||||
}
|
||||
|
||||
/// 工具定义
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolDefinition {
|
||||
#[serde(rename = "type")]
|
||||
pub tool_type: String,
|
||||
pub function: ToolFunction,
|
||||
}
|
||||
|
||||
/// 函数定义
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolFunction {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub parameters: serde_json::Value,
|
||||
}
|
||||
|
||||
/// 工具调用(AI 发起)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolCall {
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub call_type: String,
|
||||
pub function: ToolCallFunction,
|
||||
}
|
||||
|
||||
/// 工具调用函数部分
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolCallFunction {
|
||||
pub name: String,
|
||||
pub arguments: String,
|
||||
}
|
||||
|
||||
/// LLM 调用响应
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CompletionResponse {
|
||||
/// 生成的文本
|
||||
pub text: String,
|
||||
/// 使用的模型
|
||||
pub model: String,
|
||||
/// 消耗的 token 数
|
||||
pub usage: TokenUsage,
|
||||
/// AI 发起的工具调用(如有)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_calls: Option<Vec<ToolCall>>,
|
||||
/// DeepSeek thinking 模式响应中的推理内容(需回传到下一轮请求)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_content: Option<String>,
|
||||
}
|
||||
|
||||
/// Token 用量
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct TokenUsage {
|
||||
pub prompt_tokens: u32,
|
||||
pub completion_tokens: u32,
|
||||
pub total_tokens: u32,
|
||||
}
|
||||
|
||||
/// 流式输出的 chunk
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StreamChunk {
|
||||
/// 增量文本
|
||||
pub delta: String,
|
||||
/// 是否结束
|
||||
pub finished: bool,
|
||||
/// 工具调用增量(如有)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_calls: Option<Vec<ToolCallDelta>>,
|
||||
/// Token 用量(流末 chunk 携带,由 provider 解析自 SSE usage 事件)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub usage: Option<TokenUsage>,
|
||||
/// provider 流式错误事件(如 Anthropic SSE `type=="error"`)。
|
||||
/// 非空表示流中途出错,不应视为正常完成(finished 路径),由 stream_llm 转 AiError。
|
||||
#[serde(skip)]
|
||||
pub error: Option<String>,
|
||||
/// DeepSeek thinking 模式推理内容增量
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_content: Option<String>,
|
||||
}
|
||||
|
||||
/// 工具调用增量(流式中的片段)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolCallDelta {
|
||||
/// 索引
|
||||
pub index: u32,
|
||||
/// 工具调用 ID(仅第一个 chunk 有)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<String>,
|
||||
/// 函数名片段
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub function_name: Option<String>,
|
||||
/// 函数参数片段
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub function_arguments: Option<String>,
|
||||
}
|
||||
|
||||
/// 异步流类型别名
|
||||
pub type StreamResult = Pin<Box<dyn Stream<Item = anyhow::Result<StreamChunk>> + Send>>;
|
||||
@@ -4,10 +4,11 @@ version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
df-core = { path = "../df-core" }
|
||||
df-types = { path = "../df-types" }
|
||||
df-ai-core = { path = "../df-ai-core" }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true, features = ["sync"] }
|
||||
tokio = { workspace = true, features = ["sync", "time"] }
|
||||
async-trait = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
[
|
||||
{
|
||||
"model_id": "glm-4",
|
||||
"enabled": true,
|
||||
"modalities": ["text"],
|
||||
"capabilities": ["tool_use"],
|
||||
"weight": 60,
|
||||
"context_window": 128000
|
||||
},
|
||||
{
|
||||
"model_id": "glm-4-air",
|
||||
"enabled": true,
|
||||
"modalities": ["text"],
|
||||
"capabilities": ["tool_use"],
|
||||
"weight": 80,
|
||||
"context_window": 128000
|
||||
},
|
||||
{
|
||||
"model_id": "glm-4-flash",
|
||||
"enabled": true,
|
||||
"modalities": ["text"],
|
||||
"capabilities": ["tool_use"],
|
||||
"weight": 90,
|
||||
"context_window": 128000
|
||||
},
|
||||
{
|
||||
"model_id": "glm-4-plus",
|
||||
"enabled": true,
|
||||
"modalities": ["text"],
|
||||
"capabilities": ["tool_use"],
|
||||
"weight": 50,
|
||||
"context_window": 128000
|
||||
},
|
||||
{
|
||||
"model_id": "glm-4v",
|
||||
"enabled": true,
|
||||
"modalities": ["text", "vision"],
|
||||
"capabilities": ["tool_use"],
|
||||
"weight": 70,
|
||||
"context_window": 128000
|
||||
},
|
||||
{
|
||||
"model_id": "glm-4v-flash",
|
||||
"enabled": true,
|
||||
"modalities": ["text", "vision"],
|
||||
"capabilities": ["tool_use"],
|
||||
"weight": 85,
|
||||
"context_window": 128000
|
||||
},
|
||||
{
|
||||
"model_id": "gpt-4o",
|
||||
"enabled": true,
|
||||
"modalities": ["text", "vision"],
|
||||
"capabilities": ["tool_use"],
|
||||
"weight": 50,
|
||||
"context_window": 128000
|
||||
},
|
||||
{
|
||||
"model_id": "gpt-4o-mini",
|
||||
"enabled": true,
|
||||
"modalities": ["text", "vision"],
|
||||
"capabilities": ["tool_use"],
|
||||
"weight": 80,
|
||||
"context_window": 128000
|
||||
},
|
||||
{
|
||||
"model_id": "claude-3-5-sonnet-20241022",
|
||||
"enabled": true,
|
||||
"modalities": ["text", "vision"],
|
||||
"capabilities": ["tool_use"],
|
||||
"weight": 50,
|
||||
"context_window": 200000
|
||||
},
|
||||
{
|
||||
"model_id": "claude-3-opus-20240229",
|
||||
"enabled": true,
|
||||
"modalities": ["text", "vision"],
|
||||
"capabilities": ["tool_use"],
|
||||
"weight": 45,
|
||||
"context_window": 200000
|
||||
},
|
||||
{
|
||||
"model_id": "claude-3-haiku-20240307",
|
||||
"enabled": true,
|
||||
"modalities": ["text", "vision"],
|
||||
"capabilities": ["tool_use"],
|
||||
"weight": 80,
|
||||
"context_window": 200000
|
||||
},
|
||||
{
|
||||
"model_id": "gemini-1.5-pro",
|
||||
"enabled": true,
|
||||
"modalities": ["text", "vision"],
|
||||
"capabilities": ["tool_use"],
|
||||
"weight": 55,
|
||||
"context_window": 1000000
|
||||
},
|
||||
{
|
||||
"model_id": "gemini-1.5-flash",
|
||||
"enabled": true,
|
||||
"modalities": ["text", "vision"],
|
||||
"capabilities": ["tool_use"],
|
||||
"weight": 85,
|
||||
"context_window": 1000000
|
||||
},
|
||||
{
|
||||
"model_id": "deepseek-chat",
|
||||
"enabled": true,
|
||||
"modalities": ["text"],
|
||||
"capabilities": ["tool_use"],
|
||||
"weight": 75,
|
||||
"context_window": 64000
|
||||
},
|
||||
{
|
||||
"model_id": "deepseek-coder",
|
||||
"enabled": true,
|
||||
"modalities": ["text"],
|
||||
"capabilities": ["tool_use", "code_gen"],
|
||||
"weight": 75,
|
||||
"context_window": 64000
|
||||
},
|
||||
{
|
||||
"model_id": "qwen-2.5",
|
||||
"enabled": true,
|
||||
"modalities": ["text"],
|
||||
"capabilities": ["tool_use"],
|
||||
"weight": 65,
|
||||
"context_window": 128000
|
||||
},
|
||||
{
|
||||
"model_id": "embedding-3",
|
||||
"enabled": true,
|
||||
"modalities": [],
|
||||
"capabilities": ["embedding"],
|
||||
"weight": 50,
|
||||
"context_window": 8192
|
||||
}
|
||||
]
|
||||
@@ -96,6 +96,19 @@ impl AiToolRegistry {
|
||||
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> {
|
||||
self.tools.keys().cloned().collect()
|
||||
@@ -118,27 +131,6 @@ impl Default for AiToolRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 工具执行结果
|
||||
// ============================================================
|
||||
|
||||
/// 工具执行结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolExecutionResult {
|
||||
/// 工具调用 ID
|
||||
pub tool_call_id: String,
|
||||
/// 工具名称
|
||||
pub tool_name: String,
|
||||
/// 执行参数
|
||||
pub arguments: Value,
|
||||
/// 执行结果
|
||||
pub result: Value,
|
||||
/// 是否需要人工批准
|
||||
pub approval_required: bool,
|
||||
/// 风险级别
|
||||
pub risk_level: RiskLevel,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 辅助: 构建 JSON Schema 参数
|
||||
// ============================================================
|
||||
@@ -164,3 +156,84 @@ pub fn object_schema(properties: Vec<(&str, &str, bool)>) -> Value {
|
||||
"required": required,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 构造一个空参恒返 Ok 的 handler(注册测试用·不实际执行)
|
||||
fn dummy_handler() -> AiToolHandler {
|
||||
Box::new(|_args: Value| {
|
||||
Box::pin(async { Ok(serde_json::json!({ "ok": true })) })
|
||||
as Pin<Box<dyn Future<Output = anyhow::Result<Value>> + Send>>
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn risk_level_serde_lowercase() {
|
||||
// serde rename_all="lowercase": Low→"low" / Medium→"medium" / High→"high"
|
||||
assert_eq!(serde_json::to_string(&RiskLevel::Low).unwrap(), "\"low\"");
|
||||
assert_eq!(serde_json::to_string(&RiskLevel::Medium).unwrap(), "\"medium\"");
|
||||
assert_eq!(serde_json::to_string(&RiskLevel::High).unwrap(), "\"high\"");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<RiskLevel>("\"high\"").unwrap(),
|
||||
RiskLevel::High
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_and_get_tool() {
|
||||
let mut reg = AiToolRegistry::new();
|
||||
assert!(reg.is_empty());
|
||||
reg.register(
|
||||
"list_tasks",
|
||||
"列出任务",
|
||||
object_schema(vec![]),
|
||||
RiskLevel::Low,
|
||||
dummy_handler(),
|
||||
);
|
||||
assert!(!reg.is_empty());
|
||||
assert_eq!(reg.len(), 1);
|
||||
assert_eq!(reg.get("list_tasks").unwrap().risk_level, RiskLevel::Low);
|
||||
assert!(reg.get("nonexistent").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_same_name_overwrites() {
|
||||
// 同名二次注册覆盖(HashMap insert 语义)·非新增·后注册的 risk_level 胜出
|
||||
let mut reg = AiToolRegistry::new();
|
||||
reg.register("t", "v1", object_schema(vec![]), RiskLevel::Low, dummy_handler());
|
||||
reg.register("t", "v2", object_schema(vec![]), RiskLevel::High, dummy_handler());
|
||||
assert_eq!(reg.len(), 1);
|
||||
assert_eq!(reg.get("t").unwrap().risk_level, RiskLevel::High);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_definitions_and_names() {
|
||||
let mut reg = AiToolRegistry::new();
|
||||
reg.register("a", "desc a", object_schema(vec![]), RiskLevel::Low, dummy_handler());
|
||||
reg.register("b", "desc b", object_schema(vec![]), RiskLevel::Medium, dummy_handler());
|
||||
assert_eq!(reg.tool_definitions().len(), 2);
|
||||
let names = reg.tool_names();
|
||||
assert_eq!(names.len(), 2);
|
||||
assert!(names.contains(&"a".to_string()));
|
||||
assert!(names.contains(&"b".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_is_empty() {
|
||||
let reg = AiToolRegistry::default();
|
||||
assert!(reg.is_empty());
|
||||
assert_eq!(reg.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_schema_collects_required() {
|
||||
let schema = object_schema(vec![("name", "string", true), ("age", "integer", false)]);
|
||||
assert_eq!(schema["type"], "object");
|
||||
assert_eq!(schema["properties"]["name"]["type"], "string");
|
||||
let required = schema["required"].as_array().unwrap();
|
||||
assert_eq!(required.len(), 1);
|
||||
assert_eq!(required[0], "name");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,79 +5,34 @@
|
||||
//!
|
||||
//! 与 OpenAI 协议的关键差异由本模块内部完成转换,对外仍暴露统一的 LlmProvider trait,
|
||||
//! 上层 (Agentic Loop / AiNode) 无需感知协议。
|
||||
//!
|
||||
//! 协议数据结构(请求/响应 struct)与 SSE 事件解析纯函数已抽至 `anthropic_helpers`,
|
||||
//! 本模块仅保留 Provider struct + impl(HTTP 调用),Rust impl 块不可跨文件故作此切分。
|
||||
|
||||
use async_trait::async_trait;
|
||||
use eventsource_stream::Eventsource;
|
||||
use futures::{Stream, StreamExt};
|
||||
use futures::StreamExt;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
use tracing::{debug, error, warn};
|
||||
|
||||
use crate::provider::{
|
||||
CompletionRequest, CompletionResponse, LlmProvider, MessageRole, ProviderFeatures,
|
||||
StreamChunk, StreamResult, TokenUsage, ToolCall, ToolCallDelta,
|
||||
CompletionRequest, CompletionResponse, LlmProvider, MessageRole,
|
||||
StreamResult, TokenUsage, ToolCall,
|
||||
};
|
||||
// ChatMessage 仅单测构造 CompletionRequest 用,避免非 test 构建的 unused import 警告。
|
||||
#[cfg(test)]
|
||||
use crate::provider::ChatMessage;
|
||||
use crate::retry::{
|
||||
retry_with_backoff, AttemptOutcome, is_reqwest_error_retryable, is_status_retryable,
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Anthropic API 请求/响应结构体
|
||||
// ============================================================
|
||||
|
||||
/// Anthropic 请求体
|
||||
#[derive(Debug, Serialize)]
|
||||
struct AnthropicRequest {
|
||||
model: String,
|
||||
messages: Vec<serde_json::Value>,
|
||||
max_tokens: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
system: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
temperature: Option<f32>,
|
||||
stream: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tools: Option<Vec<AnthropicToolDef>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tool_choice: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Anthropic 工具定义(input_schema 对应 OpenAI 的 parameters)
|
||||
#[derive(Debug, Serialize)]
|
||||
struct AnthropicToolDef {
|
||||
name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
description: Option<String>,
|
||||
input_schema: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Anthropic 同步响应
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AnthropicResponse {
|
||||
#[allow(dead_code)]
|
||||
id: String,
|
||||
model: String,
|
||||
content: Vec<AnthropicContentBlock>,
|
||||
#[allow(dead_code)]
|
||||
stop_reason: Option<String>,
|
||||
usage: AnthropicUsage,
|
||||
}
|
||||
|
||||
/// 响应 content 块(text 或 tool_use)
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AnthropicContentBlock {
|
||||
#[serde(rename = "type")]
|
||||
block_type: String,
|
||||
#[serde(default)]
|
||||
text: Option<String>,
|
||||
/// tool_use 块字段
|
||||
id: Option<String>,
|
||||
name: Option<String>,
|
||||
input: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AnthropicUsage {
|
||||
input_tokens: u32,
|
||||
output_tokens: u32,
|
||||
}
|
||||
// 协议数据结构 + SSE 纯解析(apply_anthropic_event / AnthropicRequest 等)抽至 anthropic_helpers,
|
||||
// 此处 use 引入以保持本模块内引用路径不变(零行为变更搬迁)。
|
||||
use crate::anthropic_helpers::{
|
||||
apply_anthropic_event, AnthropicRequest, AnthropicResponse, AnthropicToolDef,
|
||||
ANTHROPIC_VERSION, DEFAULT_MAX_TOKENS,
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Provider 实现
|
||||
@@ -91,11 +46,6 @@ pub struct AnthropicCompatProvider {
|
||||
default_model: String,
|
||||
}
|
||||
|
||||
/// Anthropic 流式协议版本头
|
||||
const ANTHROPIC_VERSION: &str = "2023-06-01";
|
||||
/// Anthropic max_tokens 必填,缺省时的兜底值
|
||||
const DEFAULT_MAX_TOKENS: u32 = 4096;
|
||||
|
||||
impl AnthropicCompatProvider {
|
||||
/// 创建 Provider
|
||||
///
|
||||
@@ -103,13 +53,8 @@ impl AnthropicCompatProvider {
|
||||
/// - `api_key`: API 密钥(Anthropic 用 x-api-key 头,非 Bearer)
|
||||
/// - `default_model`: 默认模型名称
|
||||
pub fn new(base_url: impl Into<String>, api_key: impl Into<String>, default_model: impl Into<String>) -> Self {
|
||||
let client = Client::builder()
|
||||
.connect_timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.unwrap_or_else(|e| {
|
||||
warn!("reqwest builder 失败,降级为默认 client: {}", e);
|
||||
Client::new()
|
||||
});
|
||||
// SW-260618-10: reqwest Client 构建抽 crate::build_provider_client(与 OpenAI 共用 DRY)。
|
||||
let client = crate::build_provider_client();
|
||||
Self {
|
||||
client,
|
||||
api_key: api_key.into(),
|
||||
@@ -171,16 +116,75 @@ impl AnthropicCompatProvider {
|
||||
match m.role {
|
||||
MessageRole::System => continue,
|
||||
MessageRole::Tool => {
|
||||
// 累积 tool_result 块,遇到非 Tool 消息时 flush
|
||||
pending_tool_results.push(serde_json::json!({
|
||||
"type": "tool_result",
|
||||
"tool_use_id": m.tool_call_id,
|
||||
"content": m.content,
|
||||
}));
|
||||
// 累积 tool_result 块,遇到非 Tool 消息时 flush。
|
||||
// tool_use_id 为 None 时绝不能发 null(GLM anthropic 端点会 500
|
||||
// 报 'ClaudeContentBlockToolResult' object has no attribute 'id',
|
||||
// 致会话卡死持续 500),跳过该块并告警。
|
||||
match &m.tool_call_id {
|
||||
Some(tid) if !tid.is_empty() => {
|
||||
pending_tool_results.push(serde_json::json!({
|
||||
"type": "tool_result",
|
||||
"tool_use_id": tid,
|
||||
"content": m.content,
|
||||
}));
|
||||
}
|
||||
_ => {
|
||||
warn!(
|
||||
content_preview = %m.content.chars().take(80).collect::<String>(),
|
||||
"Anthropic tool_result 缺少 tool_use_id,已跳过该块(发 null 会触发 GLM 500)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
MessageRole::User => {
|
||||
Self::flush_tool_results(&mut messages, &mut pending_tool_results);
|
||||
messages.push(serde_json::json!({ "role": "user", "content": m.content }));
|
||||
// F-260614-05 Phase 2a: 多模态 user 消息 → content blocks 数组(text/image)。
|
||||
// 含图时把 content + parts 拍平成 blocks:Text 片 → {type:text},
|
||||
// Image 片 → {type:image, source:{type:base64, media_type, data}}。
|
||||
// Anthropic 协议要求 image 必须内嵌 base64(不接受 URL 直传)。
|
||||
// 现状:前端(Phase2b)只产 base64 模式图片片,url 模式当前不可达。
|
||||
// 未来若加 url 图片输入,必须在 commands 层补 url→base64 预拉
|
||||
//(provider 不发额外 HTTP),否则下方兜底会发空 data 致 Anthropic 400。
|
||||
// 纯文本消息(无图)保持原字符串简写,与现有端点零回归。
|
||||
if m.has_image() {
|
||||
let blocks: Vec<serde_json::Value> = m
|
||||
.flattened_parts()
|
||||
.into_iter()
|
||||
.map(|p| match p {
|
||||
crate::provider::ContentPart::Text { text } => serde_json::json!({
|
||||
"type": "text",
|
||||
"text": text,
|
||||
}),
|
||||
crate::provider::ContentPart::Image { url, base64, media_type, alt: _ } => {
|
||||
// p 已被 match 取得所有权,直接 move media_type/base64 避免大 base64 clone。
|
||||
let mt = media_type.unwrap_or_else(|| "image/png".into());
|
||||
let data = base64.unwrap_or_else(|| {
|
||||
// 完整性兜底:当前 url 模式不可达(前端 Phase2b 只产 base64 图片片)。
|
||||
// 若未来接入 url 图片输入而 commands 层未补 url→base64 预拉,
|
||||
// 此处会发空 data 致 Anthropic 400,warn 留痕但不阻塞(避免静默吞数据)。
|
||||
if url.is_some() {
|
||||
warn!(
|
||||
url = ?url,
|
||||
"Anthropic user 消息含 Image(url) 但 base64 缺失,将发空 data(commands 层未补 url→base64 预拉)"
|
||||
);
|
||||
}
|
||||
String::new()
|
||||
});
|
||||
serde_json::json!({
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": mt,
|
||||
"data": data,
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
messages.push(serde_json::json!({ "role": "user", "content": blocks }));
|
||||
} else {
|
||||
messages.push(serde_json::json!({ "role": "user", "content": m.content }));
|
||||
}
|
||||
}
|
||||
MessageRole::Assistant => {
|
||||
Self::flush_tool_results(&mut messages, &mut pending_tool_results);
|
||||
@@ -190,8 +194,11 @@ impl AnthropicCompatProvider {
|
||||
}
|
||||
if let Some(calls) = &m.tool_calls {
|
||||
for tc in calls {
|
||||
let input: serde_json::Value =
|
||||
serde_json::from_str(&tc.function.arguments).unwrap_or(serde_json::Value::Null);
|
||||
// B-260618-25: arguments 非法 JSON(流式中断残留 / ToolCall::new 默认空串)
|
||||
// → 空 object 兜底。Anthropic/GLM 要求 tool_use.input 必为 object,
|
||||
// null 直触发 1214「messages 参数非法」。
|
||||
let input: serde_json::Value = serde_json::from_str(&tc.function.arguments)
|
||||
.unwrap_or_else(|_| serde_json::json!({}));
|
||||
content.push(serde_json::json!({
|
||||
"type": "tool_use",
|
||||
"id": tc.id,
|
||||
@@ -209,12 +216,24 @@ impl AnthropicCompatProvider {
|
||||
}
|
||||
Self::flush_tool_results(&mut messages, &mut pending_tool_results);
|
||||
|
||||
// B-260619-03: 合并相邻 user 块。Anthropic 协议要求 user/assistant 严格交替,连续 user
|
||||
// 触发 GLM 1214。场景:drainQueue 续发(前一轮以 tool_result 结尾 + 新 user)→ flush 把
|
||||
// tool_result 转成 user 后紧跟 push 新 user → 连续两 user。合并成一条 user 含
|
||||
// [tool_result..., text] blocks(Anthropic 允许一条 user 多 blocks),打破恶性循环。
|
||||
Self::merge_consecutive_users(&mut messages);
|
||||
|
||||
let tools = req.tools.map(|defs| {
|
||||
defs.into_iter()
|
||||
.map(|d| AnthropicToolDef {
|
||||
name: d.function.name,
|
||||
description: Some(d.function.description).filter(|s| !s.is_empty()),
|
||||
input_schema: d.function.parameters,
|
||||
// B-260618-25: input_schema 非 object(未来误用)→ 兜底 {"type":"object"},
|
||||
// 防 Anthropic 拒非法 tool schema(当前全走 object_schema 恒 object,纯防御)。
|
||||
input_schema: if d.function.parameters.is_object() {
|
||||
d.function.parameters
|
||||
} else {
|
||||
serde_json::json!({"type": "object"})
|
||||
},
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
@@ -243,6 +262,140 @@ impl AnthropicCompatProvider {
|
||||
messages.push(serde_json::json!({ "role": "user", "content": blocks }));
|
||||
}
|
||||
|
||||
/// B-260619-03: 合并相邻 user 消息为一条(content 拼成 blocks 数组)。
|
||||
/// Anthropic 协议要求 user/assistant 严格交替,连续 user 触发 1214。
|
||||
/// 触发场景:flush_tool_results 把 tool_result 转 user 后紧跟新 user(drainQueue 续发,
|
||||
/// 前一轮以 tool_result 结尾)。合并成一条 user 含 [tool_result..., text] blocks,合法。
|
||||
/// content 形态:string(简写)或 array(blocks),统一规范化为 array 后拼接。
|
||||
fn merge_consecutive_users(messages: &mut Vec<serde_json::Value>) {
|
||||
let is_user = |m: &serde_json::Value| m.get("role").and_then(|r| r.as_str()) == Some("user");
|
||||
let mut i = 0;
|
||||
while i + 1 < messages.len() {
|
||||
if is_user(&messages[i]) && is_user(&messages[i + 1]) {
|
||||
let next_content = messages[i + 1].get("content").cloned().unwrap_or(serde_json::Value::Null);
|
||||
let cur = &mut messages[i];
|
||||
// 当前 user content 规范化为 array
|
||||
let mut blocks: Vec<serde_json::Value> = match cur.get("content").cloned() {
|
||||
Some(serde_json::Value::String(s)) => vec![serde_json::json!({ "type": "text", "text": s })],
|
||||
Some(serde_json::Value::Array(a)) => a,
|
||||
_ => vec![],
|
||||
};
|
||||
// 并入下一个 user content
|
||||
match next_content {
|
||||
serde_json::Value::String(s) => blocks.push(serde_json::json!({ "type": "text", "text": s })),
|
||||
serde_json::Value::Array(a) => blocks.extend(a),
|
||||
_ => {}
|
||||
}
|
||||
cur["content"] = serde_json::Value::Array(blocks);
|
||||
messages.remove(i + 1);
|
||||
// 不增 i:继续合并 i 与新 i+1(可能多个连续 user)
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成 messages 诊断摘要(每条 role + content 形态 + tool 标记),不含敏感数据。
|
||||
/// B-260618-27: 1214 类错误时随 bail 文案直达前端 raw,定位哪条/字段非法。
|
||||
fn summarize_messages(messages: &[serde_json::Value]) -> String {
|
||||
let lines: Vec<String> = messages
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, m)| {
|
||||
let role = m.get("role").and_then(|r| r.as_str()).unwrap_or("?");
|
||||
let desc = match m.get("content") {
|
||||
Some(serde_json::Value::String(s)) => format!("text({}B)", s.len()),
|
||||
Some(serde_json::Value::Array(blocks)) => {
|
||||
let parts: Vec<String> = blocks
|
||||
.iter()
|
||||
.map(|b| {
|
||||
let ty = b.get("type").and_then(|t| t.as_str()).unwrap_or("?");
|
||||
match ty {
|
||||
"text" => format!(
|
||||
"text({}B)",
|
||||
b.get("text").and_then(|t| t.as_str()).map(|s| s.len()).unwrap_or(0)
|
||||
),
|
||||
"tool_use" => format!(
|
||||
"tool_use[id={},input_obj={}]",
|
||||
b.get("id").and_then(|t| t.as_str()).unwrap_or(""),
|
||||
b.get("input").map(|v| v.is_object()).unwrap_or(false)
|
||||
),
|
||||
"tool_result" => format!(
|
||||
"tool_result[tid={}]",
|
||||
b.get("tool_use_id").and_then(|t| t.as_str()).unwrap_or("")
|
||||
),
|
||||
"image" => "image".to_string(),
|
||||
_ => ty.to_string(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
format!("[{}]", parts.join(","))
|
||||
}
|
||||
_ => "?".to_string(),
|
||||
};
|
||||
format!("#{}:{} {}", i, role, desc)
|
||||
})
|
||||
.collect();
|
||||
format!("{} msgs: {}", lines.len(), lines.join(" | "))
|
||||
}
|
||||
|
||||
/// B-260618-27: 协议预检——扫 messages 发现确定非法形态,命中返回原因(仅诊断不修复)。
|
||||
/// 覆盖:首条非 user / 连续同 role / tool_use input 非 object / 空 content / orphan tool_result
|
||||
/// (tool_use_id 无前置 tool_use,常见于裁剪/过滤后 assistant 被删但 tool_result 留)。
|
||||
fn precheck_messages(messages: &[serde_json::Value]) -> Result<(), String> {
|
||||
if messages.is_empty() {
|
||||
return Err("messages 为空".into());
|
||||
}
|
||||
let first_role = messages[0].get("role").and_then(|r| r.as_str()).unwrap_or("");
|
||||
if first_role != "user" {
|
||||
return Err(format!("首条 role={} 非法(须 user)", first_role));
|
||||
}
|
||||
for w in messages.windows(2) {
|
||||
let r0 = w[0].get("role").and_then(|r| r.as_str()).unwrap_or("");
|
||||
let r1 = w[1].get("role").and_then(|r| r.as_str()).unwrap_or("");
|
||||
if r0 == r1 && (r0 == "user" || r0 == "assistant") {
|
||||
return Err(format!("连续同 role={}", r0));
|
||||
}
|
||||
}
|
||||
let mut tool_use_ids: Vec<&str> = Vec::new();
|
||||
for (i, m) in messages.iter().enumerate() {
|
||||
let role = m.get("role").and_then(|r| r.as_str()).unwrap_or("");
|
||||
match m.get("content") {
|
||||
Some(serde_json::Value::String(s)) if s.is_empty() && role == "user" => {
|
||||
return Err(format!("#{} user content 空", i));
|
||||
}
|
||||
Some(serde_json::Value::Array(blocks)) => {
|
||||
if blocks.is_empty() && role == "user" {
|
||||
return Err(format!("#{} user content 空数组", i));
|
||||
}
|
||||
for b in blocks {
|
||||
match b.get("type").and_then(|t| t.as_str()).unwrap_or("") {
|
||||
"tool_use" => {
|
||||
let id = b.get("id").and_then(|t| t.as_str()).unwrap_or("");
|
||||
tool_use_ids.push(id);
|
||||
if !b.get("input").map(|v| v.is_object()).unwrap_or(false) {
|
||||
return Err(format!("#{} tool_use input 非 object", i));
|
||||
}
|
||||
}
|
||||
"tool_result" => {
|
||||
let tid = b.get("tool_use_id").and_then(|t| t.as_str()).unwrap_or("");
|
||||
if !tid.is_empty() && !tool_use_ids.contains(&tid) {
|
||||
return Err(format!(
|
||||
"#{} orphan tool_result(tid={} 无前置 tool_use)",
|
||||
i, tid
|
||||
));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 统一鉴权头:x-api-key + anthropic-version
|
||||
fn auth_headers(&self, rb: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
rb.header("x-api-key", &self.api_key)
|
||||
@@ -258,58 +411,122 @@ impl LlmProvider for AnthropicCompatProvider {
|
||||
req.stream = false;
|
||||
let body = self.convert_request(req);
|
||||
|
||||
// B-260618-27: 协议预检——命中非法 bail 含 messages 摘要,把 GLM 模糊 1214 转明确诊断
|
||||
if let Err(reason) = Self::precheck_messages(&body.messages) {
|
||||
let summary = Self::summarize_messages(&body.messages);
|
||||
warn!(%reason, %summary, "Anthropic messages 协议预检失败");
|
||||
anyhow::bail!("messages 预检失败: {} | 摘要: {}", reason, summary);
|
||||
}
|
||||
|
||||
debug!(model = %body.model, "Anthropic 同步调用");
|
||||
|
||||
let resp = self
|
||||
.auth_headers(self.client.post(self.messages_url()))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
error!(%status, %text, "Anthropic API 调用失败");
|
||||
anyhow::bail!("Anthropic API 错误 {}: {}", status, text);
|
||||
}
|
||||
|
||||
let resp: AnthropicResponse = resp.json().await?;
|
||||
|
||||
// content 块中拼接 text,收集 tool_use
|
||||
let mut text = String::new();
|
||||
let mut tool_calls: Vec<ToolCall> = Vec::new();
|
||||
for block in resp.content {
|
||||
match block.block_type.as_str() {
|
||||
"text" => {
|
||||
if let Some(t) = block.text {
|
||||
text.push_str(&t);
|
||||
// 指数退避重试(B-260616-07): 包裹 send + 状态码判定。
|
||||
// 同时补 FR-R4 遗漏: Anthropic 同步路径此前无单请求 timeout(建连后挂起会无限 hang),
|
||||
// 此处加 60s timeout,与 OpenAI 路径对齐。
|
||||
let label = format!("Anthropic[{}]", body.model);
|
||||
retry_with_backoff(&label, move |_| {
|
||||
let client = self.client.clone();
|
||||
let url = self.messages_url();
|
||||
let api_key = self.api_key.clone();
|
||||
let body = body.clone();
|
||||
async move {
|
||||
// send: 补 60s 单请求 timeout(此前缺失,FR-R4 遗漏)
|
||||
let rb = client
|
||||
.post(url)
|
||||
.header("x-api-key", &api_key)
|
||||
.header("anthropic-version", ANTHROPIC_VERSION)
|
||||
.header("Content-Type", "application/json")
|
||||
.timeout(Duration::from_secs(60))
|
||||
.version(reqwest::Version::HTTP_11)
|
||||
.json(&body);
|
||||
let resp = match rb.send().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
// B-260618-26: 记 reqwest 错误源因链(is_*/source)。原仅 Display
|
||||
// "error sending request for url" 无法定位 reset/TLS/超时/body 真因。
|
||||
tracing::error!(
|
||||
is_timeout = e.is_timeout(),
|
||||
is_connect = e.is_connect(),
|
||||
is_body = e.is_body(),
|
||||
is_request = e.is_request(),
|
||||
url = ?e.url(),
|
||||
source = ?std::error::Error::source(&e),
|
||||
"Anthropic 同步 send 失败"
|
||||
);
|
||||
if is_reqwest_error_retryable(&e) {
|
||||
return AttemptOutcome::Retryable(format!("请求失败(可重试): {}", e));
|
||||
}
|
||||
return AttemptOutcome::Fatal(format!(
|
||||
"请求失败(不可重试): {} [timeout={} connect={} body={} src={:?}]",
|
||||
e,
|
||||
e.is_timeout(),
|
||||
e.is_connect(),
|
||||
e.is_body(),
|
||||
std::error::Error::source(&e)
|
||||
));
|
||||
}
|
||||
};
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status().as_u16();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
let msg = format!("Anthropic API 错误 {}: {}", status, text);
|
||||
if is_status_retryable(status) {
|
||||
warn!(%status, "Anthropic 同步调用可重试状态码");
|
||||
return AttemptOutcome::Retryable(msg);
|
||||
}
|
||||
error!(%status, %text, "Anthropic API 调用失败(不可重试)");
|
||||
return AttemptOutcome::Fatal(msg);
|
||||
}
|
||||
let resp: AnthropicResponse = match resp.json().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => return AttemptOutcome::Fatal(format!("响应解析失败: {}", e)),
|
||||
};
|
||||
// content 块中拼接 text,收集 tool_use
|
||||
let mut text = String::new();
|
||||
let mut tool_calls: Vec<ToolCall> = Vec::new();
|
||||
for block in resp.content {
|
||||
match block.block_type.as_str() {
|
||||
"text" => {
|
||||
if let Some(t) = block.text {
|
||||
text.push_str(&t);
|
||||
}
|
||||
}
|
||||
"tool_use" => {
|
||||
let id = match block.id {
|
||||
Some(id) if !id.is_empty() => id,
|
||||
_ => {
|
||||
warn!(
|
||||
name = ?block.name,
|
||||
"Anthropic tool_use 块缺少 id,已跳过(空 id 会回传空 tool_use_id 触发 500)"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let name = block.name.unwrap_or_default();
|
||||
let args = block
|
||||
.input
|
||||
.map(|v| serde_json::to_string(&v).unwrap_or_default())
|
||||
.unwrap_or_default();
|
||||
tool_calls.push(ToolCall::new(id, name, args));
|
||||
}
|
||||
other => warn!(block_type = other, "Anthropic 响应含未知 content 块类型,已忽略"),
|
||||
}
|
||||
}
|
||||
"tool_use" => {
|
||||
let id = block.id.unwrap_or_default();
|
||||
let name = block.name.unwrap_or_default();
|
||||
let args = block
|
||||
.input
|
||||
.map(|v| serde_json::to_string(&v).unwrap_or_default())
|
||||
.unwrap_or_default();
|
||||
tool_calls.push(ToolCall::new(id, name, args));
|
||||
}
|
||||
other => warn!(block_type = other, "Anthropic 响应含未知 content 块类型,已忽略"),
|
||||
let usage = TokenUsage {
|
||||
prompt_tokens: resp.usage.input_tokens,
|
||||
completion_tokens: resp.usage.output_tokens,
|
||||
total_tokens: resp.usage.input_tokens + resp.usage.output_tokens,
|
||||
};
|
||||
AttemptOutcome::Ok(CompletionResponse {
|
||||
text,
|
||||
model: resp.model,
|
||||
usage,
|
||||
tool_calls: if tool_calls.is_empty() { None } else { Some(tool_calls) },
|
||||
reasoning_content: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let usage = TokenUsage {
|
||||
prompt_tokens: resp.usage.input_tokens,
|
||||
completion_tokens: resp.usage.output_tokens,
|
||||
total_tokens: resp.usage.input_tokens + resp.usage.output_tokens,
|
||||
};
|
||||
|
||||
Ok(CompletionResponse {
|
||||
text,
|
||||
model: resp.model,
|
||||
usage,
|
||||
tool_calls: if tool_calls.is_empty() { None } else { Some(tool_calls) },
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn stream(&self, request: CompletionRequest) -> anyhow::Result<StreamResult> {
|
||||
@@ -317,13 +534,46 @@ impl LlmProvider for AnthropicCompatProvider {
|
||||
req.stream = true;
|
||||
let body = self.convert_request(req);
|
||||
|
||||
// B-260618-27: 协议预检——命中非法 bail 含 messages 摘要,把 GLM 模糊 1214 转明确诊断
|
||||
if let Err(reason) = Self::precheck_messages(&body.messages) {
|
||||
let summary = Self::summarize_messages(&body.messages);
|
||||
warn!(%reason, %summary, "Anthropic messages 协议预检失败");
|
||||
anyhow::bail!("messages 预检失败: {} | 摘要: {}", reason, summary);
|
||||
}
|
||||
|
||||
debug!(model = %body.model, "Anthropic 流式调用");
|
||||
|
||||
let resp = self
|
||||
let resp = match self
|
||||
.auth_headers(self.client.post(self.messages_url()))
|
||||
.json(&body)
|
||||
.version(reqwest::Version::HTTP_11)
|
||||
.send()
|
||||
.await?;
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
// B-260618-26: 记 reqwest 错误源因链。原 ? 转 anyhow 仅 Display
|
||||
// "error sending request for url" 无法定位 reset/TLS/超时/body 真因。
|
||||
tracing::error!(
|
||||
is_timeout = e.is_timeout(),
|
||||
is_connect = e.is_connect(),
|
||||
is_body = e.is_body(),
|
||||
is_request = e.is_request(),
|
||||
url = %self.messages_url(),
|
||||
source = ?std::error::Error::source(&e),
|
||||
"Anthropic 流式 send 失败"
|
||||
);
|
||||
anyhow::bail!(
|
||||
"send 失败: {} [timeout={} connect={} body={} request={} src={:?}]",
|
||||
e,
|
||||
e.is_timeout(),
|
||||
e.is_connect(),
|
||||
e.is_body(),
|
||||
e.is_request(),
|
||||
std::error::Error::source(&e)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
@@ -332,80 +582,34 @@ impl LlmProvider for AnthropicCompatProvider {
|
||||
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;
|
||||
// B-260618-28: MidStream error(如 GLM 1214 messages 非法)时附 messages 摘要定位哪条非法。
|
||||
// precheck(Init 路径,发送前)漏的 case,靠此在 SSE error 事件暴露实际 messages 结构到前端 raw。
|
||||
let messages_summary = Self::summarize_messages(&body.messages);
|
||||
let stream = resp
|
||||
.bytes_stream()
|
||||
.eventsource()
|
||||
.map(move |event| match event {
|
||||
Ok(ev) => {
|
||||
// 解析 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 }),
|
||||
let mut chunk = apply_anthropic_event(&ev.data, &mut usage_accum);
|
||||
// GLM 中途 error(如 1214)→ chunk.error 附 messages 摘要,经 stream_recv MidStream
|
||||
// 路径 emit AiError raw,前端直接看到实际 messages 结构定位非法字段。
|
||||
if let Some(err) = chunk.error.as_mut() {
|
||||
*err = format!("{} | messages 摘要: {}", err, messages_summary);
|
||||
}
|
||||
Ok(chunk)
|
||||
}
|
||||
Err(e) => {
|
||||
// 保留 #[source] 因果链: anyhow!("...{}", e) 仅把 e 的 Display 塞进 message,
|
||||
// 丢掉 source(无法 downcast/遍历)。改用 Error::from(e).context(...):
|
||||
// Display 不变(仍为 "Anthropic SSE 错误: {e}"), 且 e 作为 .source() 可追溯。
|
||||
// 顺序: 先 format(e) 构造 context 文案, 再 Error::from(e) move e 进 source。
|
||||
let ctx = format!("Anthropic SSE 错误: {}", e);
|
||||
error!(error = %e, "Anthropic SSE 事件流错误");
|
||||
Err(anyhow::anyhow!("Anthropic SSE 错误: {}", e))
|
||||
Err(anyhow::Error::from(e).context(ctx))
|
||||
}
|
||||
});
|
||||
|
||||
@@ -416,11 +620,262 @@ impl LlmProvider for AnthropicCompatProvider {
|
||||
"anthropic-compat"
|
||||
}
|
||||
|
||||
fn supported_features(&self) -> ProviderFeatures {
|
||||
ProviderFeatures {
|
||||
streaming: true,
|
||||
function_calling: true,
|
||||
vision: false,
|
||||
}
|
||||
fn endpoint(&self) -> String {
|
||||
self.messages_url()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 单测(不发真实 HTTP,喂构造的 SSE data 字符串序列)
|
||||
// ============================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
// apply_anthropic_event / TokenUsage 经 super::*(含 anthropic_helpers::apply_anthropic_event 的 use)可见。
|
||||
|
||||
/// 辅助:构造 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 事件 → error=Some + finished=false(R-P1-1:避免残缺响应被当正常完成入库,
|
||||
/// 由 stream_llm 识别 error 非空发 AiError + 丢弃残缺,与 OpenAI 路径 Err 一致)
|
||||
#[test]
|
||||
fn anthropic_error_event_yields_error_not_finished() {
|
||||
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 不走 finished 完成路径,否则残缺响应会被当正常完成");
|
||||
assert_eq!(c.error.as_deref(), Some("overloaded"), "error 事件应携带错误消息");
|
||||
assert!(c.usage.is_none(), "error 不带出 usage");
|
||||
assert!(c.delta.is_empty(), "error 不带文本增量");
|
||||
assert!(acc.is_some(), "error 不应清空已累积的 usage(与原实现一致)");
|
||||
}
|
||||
|
||||
/// error 事件无 error.message 字段时兜底为 "stream error"
|
||||
#[test]
|
||||
fn anthropic_error_event_missing_message_falls_back() {
|
||||
let mut acc: Option<TokenUsage> = None;
|
||||
let c = apply_anthropic_event(r#"{"type":"error"}"#, &mut acc);
|
||||
assert_eq!(c.error.as_deref(), Some("stream error"));
|
||||
assert!(!c.finished);
|
||||
}
|
||||
|
||||
/// 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());
|
||||
}
|
||||
|
||||
// ---------- F-260614-05 Phase 2a 多模态 convert_request ----------
|
||||
|
||||
/// 含图 user 消息 → content blocks(text + image source.base64)
|
||||
#[test]
|
||||
fn anthropic_convert_multimodal_user_blocks() {
|
||||
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
|
||||
let req = CompletionRequest {
|
||||
model: "claude-3-5-sonnet".into(),
|
||||
messages: vec![ChatMessage::user_parts(
|
||||
"看图",
|
||||
vec![crate::provider::ContentPart::image_base64("image/png", "iVBOR")],
|
||||
)],
|
||||
temperature: None,
|
||||
max_tokens: None,
|
||||
stream: false,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
let body = provider.convert_request(req);
|
||||
// user 消息 content 应为数组形态
|
||||
let user_msg = body
|
||||
.messages
|
||||
.iter()
|
||||
.find(|m| m.get("role").and_then(|r| r.as_str()) == Some("user"))
|
||||
.expect("应有 user 消息");
|
||||
let content = user_msg.get("content").and_then(|c| c.as_array()).expect("user content 应为数组");
|
||||
// 顺序:content "看图" → text 块,image 片 → image 块
|
||||
assert_eq!(content.len(), 2);
|
||||
assert_eq!(content[0]["type"], "text");
|
||||
assert_eq!(content[0]["text"], "看图");
|
||||
assert_eq!(content[1]["type"], "image");
|
||||
assert_eq!(content[1]["source"]["type"], "base64");
|
||||
assert_eq!(content[1]["source"]["media_type"], "image/png");
|
||||
assert_eq!(content[1]["source"]["data"], "iVBOR");
|
||||
}
|
||||
|
||||
/// 纯文本 user 消息 → content 仍是字符串简写(无图不数组化)
|
||||
#[test]
|
||||
fn anthropic_convert_text_only_user_remains_string() {
|
||||
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
|
||||
let req = CompletionRequest {
|
||||
model: "claude-3-5-sonnet".into(),
|
||||
messages: vec![ChatMessage::user("hello")],
|
||||
temperature: None,
|
||||
max_tokens: None,
|
||||
stream: false,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
let body = provider.convert_request(req);
|
||||
let user_msg = body
|
||||
.messages
|
||||
.iter()
|
||||
.find(|m| m.get("role").and_then(|r| r.as_str()) == Some("user"))
|
||||
.expect("应有 user 消息");
|
||||
// 纯文本 → 字符串简写(非数组)
|
||||
assert_eq!(user_msg.get("content").and_then(|c| c.as_str()), Some("hello"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
//! Anthropic 协议适配 — 协议数据结构与 SSE 事件解析(纯函数)。
|
||||
//!
|
||||
//! 本模块从 `anthropic_compat.rs` 抽离,承载与 HTTP 无关的纯协议逻辑:
|
||||
//! - 请求/响应结构体(`AnthropicRequest` 等)
|
||||
//! - 协议常量(`ANTHROPIC_VERSION` / `DEFAULT_MAX_TOKENS`)
|
||||
//! - SSE 事件 → `StreamChunk` 转换纯函数(`apply_anthropic_event`)
|
||||
//!
|
||||
//! Provider struct + impl(含 HTTP 调用)仍留在 `anthropic_compat.rs`,
|
||||
//! Rust impl 块不可跨文件,故仅搬迁 impl 块外部的类型/常量/纯函数。
|
||||
//! 零行为变更(纯搬迁)。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{error, warn};
|
||||
|
||||
use crate::provider::{StreamChunk, TokenUsage, ToolCallDelta};
|
||||
|
||||
// ============================================================
|
||||
// Anthropic API 请求/响应结构体
|
||||
// ============================================================
|
||||
|
||||
/// Anthropic 请求体
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub(crate) struct AnthropicRequest {
|
||||
pub model: String,
|
||||
pub messages: Vec<serde_json::Value>,
|
||||
pub max_tokens: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub system: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub temperature: Option<f32>,
|
||||
pub stream: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tools: Option<Vec<AnthropicToolDef>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_choice: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Anthropic 工具定义(input_schema 对应 OpenAI 的 parameters)
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub(crate) struct AnthropicToolDef {
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
pub input_schema: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Anthropic 同步响应
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct AnthropicResponse {
|
||||
// SW-260618-25: Anthropic 响应反序列化字段,保留以对齐响应结构(响应 id),标注意图消除 dead_code warning
|
||||
#[allow(dead_code)]
|
||||
id: String,
|
||||
pub model: String,
|
||||
pub content: Vec<AnthropicContentBlock>,
|
||||
// SW-260618-25: Anthropic 响应反序列化字段,保留以备调试/未来消费(如日志记录调用终止原因),标注意图消除 dead_code warning
|
||||
#[allow(dead_code)]
|
||||
stop_reason: Option<String>,
|
||||
pub usage: AnthropicUsage,
|
||||
}
|
||||
|
||||
/// 响应 content 块(text 或 tool_use)
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct AnthropicContentBlock {
|
||||
#[serde(rename = "type")]
|
||||
pub block_type: String,
|
||||
#[serde(default)]
|
||||
pub text: Option<String>,
|
||||
/// tool_use 块字段
|
||||
pub id: Option<String>,
|
||||
pub name: Option<String>,
|
||||
pub input: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct AnthropicUsage {
|
||||
pub input_tokens: u32,
|
||||
pub output_tokens: u32,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 协议常量
|
||||
// ============================================================
|
||||
|
||||
/// Anthropic 流式协议版本头
|
||||
pub(crate) const ANTHROPIC_VERSION: &str = "2023-06-01";
|
||||
/// Anthropic max_tokens 必填,缺省时的兜底值
|
||||
pub(crate) const DEFAULT_MAX_TOKENS: u32 = 4096;
|
||||
|
||||
// ============================================================
|
||||
// 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, error: None, reasoning_content: 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, error: None, reasoning_content: 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, error: None, reasoning_content: 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, error: None, reasoning_content: 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,
|
||||
error: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
}
|
||||
}
|
||||
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: 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 name = cb.get("name").and_then(|t| t.as_str()).map(|s| s.to_string());
|
||||
// id 缺失时用占位 id 兜底:流式后续 input_json_delta 按 index 累加,
|
||||
// 中途无法整体跳过;占位 id 保证回传的 tool_use_id 非空,避免 GLM 500。
|
||||
let id = match cb.get("id").and_then(|t| t.as_str()).map(|s| s.to_string()) {
|
||||
Some(id) if !id.is_empty() => Some(id),
|
||||
_ => {
|
||||
let placeholder = format!("tool_missing_{}", idx);
|
||||
warn!(%placeholder, name = ?name, "Anthropic 流式 tool_use 块缺少 id,已填占位 id(原样回传会触发 GLM 500)");
|
||||
Some(placeholder)
|
||||
}
|
||||
};
|
||||
return StreamChunk {
|
||||
delta: String::new(),
|
||||
finished: false,
|
||||
tool_calls: Some(vec![ToolCallDelta {
|
||||
index: idx,
|
||||
id,
|
||||
function_name: name,
|
||||
function_arguments: None,
|
||||
}]),
|
||||
usage: None,
|
||||
error: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
}
|
||||
}
|
||||
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None }
|
||||
}
|
||||
// 消息结束:带出累积 usage
|
||||
"message_stop" => StreamChunk {
|
||||
delta: String::new(),
|
||||
finished: true,
|
||||
tool_calls: None,
|
||||
usage: usage_accum.take(),
|
||||
error: None,
|
||||
reasoning_content: None,
|
||||
},
|
||||
// 错误事件:流中途出错。不走 finished 完成路径(避免残缺响应被当正常完成入库),
|
||||
// 改由 stream_llm 识别 error 非空 → 发 AiError + 丢弃残缺(与 OpenAI 路径 Err 一致)。
|
||||
"error" => {
|
||||
let msg = v.get("error").and_then(|e| e.get("message")).and_then(|m| m.as_str()).unwrap_or("stream error").to_string();
|
||||
error!(%msg, "Anthropic 流式错误事件");
|
||||
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: Some(msg), reasoning_content: None }
|
||||
}
|
||||
// content_block_stop / ping 等不产出 chunk
|
||||
_ => StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None },
|
||||
}
|
||||
}
|
||||
+1918
-35
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,6 @@
|
||||
//! Agent 协调器 — 管理多 Agent 协作
|
||||
//!
|
||||
//! ⚠ B 路线占位:当前单链 ReAct 够用,多 Agent 协作待 B 路线立项。有意保留空壳,勿删。
|
||||
|
||||
/// Agent 协调器
|
||||
///
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+71
-2
@@ -1,10 +1,79 @@
|
||||
//! df-ai: AI 编排 — LLM Provider、模型路由、Agent 协调、上下文管理、流式处理、工具注册
|
||||
//! df-ai: AI 编排 — LLM Provider、Agent 协调、上下文管理、流式处理、工具注册
|
||||
|
||||
pub mod ai_tools;
|
||||
pub mod anthropic_compat;
|
||||
pub mod anthropic_helpers;
|
||||
pub mod context;
|
||||
pub mod context_helpers;
|
||||
pub mod coordinator;
|
||||
// 会话意图识别层(纯函数,不接入 agentic loop)。依据 docs/02-架构设计/构想审查/
|
||||
// 意图识别层论证-2026-06-19.md。提供 recognize / tool_subset_for / suggested_model_tier。
|
||||
// 待 Phase B+C 完成 + 模型模态管理落地后再接入 agentic loop。
|
||||
pub mod intent;
|
||||
pub mod model_fetch;
|
||||
// model_fetch 的纯逻辑子模块(URL 拼接 / 噪音过滤 / 响应反序列化),
|
||||
// crate 内复用,private mod — 外部经 model_fetch 间接访问,无直接路径需求。
|
||||
mod model_fetch_helpers;
|
||||
pub mod model_probe;
|
||||
// model_probe 的纯逻辑子模块(预设表加载 / 启发式推断 / 词素判定),
|
||||
// crate 内复用,private mod — 外部经 model_probe 间接访问,无直接路径需求。
|
||||
mod model_probe_helpers;
|
||||
// 单对话并行多轮 Phase 0a · 0.8 plan_hint 纯函数(扩 intent::tool_subset_for 编排语义)。
|
||||
// 依据 docs/02-架构设计/单对话并行多轮-Phase0落地路线图-2026-06-20.md 第 0.8 节。
|
||||
// 纯函数(零 IO 零状态),Phase 1 接入主 loop,LLM 调度留 Phase 2。
|
||||
pub mod plan_hint;
|
||||
pub mod openai_compat;
|
||||
pub mod openai_helpers;
|
||||
// Phase 0 (B 路线): Plan-driven 规划骨架(维度 0.8 Plan/SubTask 结构 + 0.7 规划校验/Kahn 分层)。
|
||||
// 纯函数模块(零 IO 零状态)。依据 docs/02-架构设计/单对话并行多轮-{设计,Phase0落地路线图}-2026-06-20.md。
|
||||
// Phase 1(plan_hint 接入主 loop)/Phase 2(planning)/Phase 3(并行 execution) 接入后续。
|
||||
pub mod planner;
|
||||
pub mod provider;
|
||||
pub mod router;
|
||||
pub mod stream;
|
||||
// CR-30-1: 流前重试退避对外复用。complete() 的 retry_with_backoff 仍 crate 内用,
|
||||
// stream_recv/agentic 流前重试需复用 backoff_delay(jitter)+is_status_retryable(Fatal 分类)
|
||||
// 避免重写退避/分类逻辑(对齐决策 F-260616-07 a1)。改 pub mod 后对外仅暴露纯函数 + 常量。
|
||||
pub mod retry;
|
||||
|
||||
use provider::LlmProvider;
|
||||
use reqwest::Client;
|
||||
|
||||
// df-ai-core 直接暴露,供需要直接引用 trait crate 的下游(可选)。
|
||||
pub use df_ai_core;
|
||||
|
||||
/// SW-260618-10: 构建 Provider 共用的 reqwest Client(OpenAI/Anthropic Provider::new DRY)。
|
||||
///
|
||||
/// connect_timeout 30s 防连接阶段静默 hang(网络静默断);不设总 timeout——reqwest 的
|
||||
/// `.timeout()` 会限制整个响应 body 时长,流式长生成任务会被误砍。读取阶段中途静默由上层
|
||||
/// stream_llm 的 idle timeout 兜底。builder 失败回退 `Client::new()` 保可用(warn 日志)。
|
||||
pub(crate) fn build_provider_client() -> Client {
|
||||
Client::builder()
|
||||
.connect_timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::warn!("reqwest builder 失败,回退默认 client: {}", e);
|
||||
Client::new()
|
||||
})
|
||||
}
|
||||
|
||||
/// 按 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,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
//! 厂商模型列表拉取 — F-01 阶段3
|
||||
//!
|
||||
//! 按 `provider_type` 分派拉取厂商模型列表,过滤非 chat 模型,返回模型名 Vec。
|
||||
//! `fetch_and_probe` 在拉取基础上对每个模型名调 `model_probe::probe` 探测出完整 `ModelConfig`。
|
||||
//!
|
||||
//! 协议分派:
|
||||
//! - `openai_compat` → `GET {base_url}/v1/models`(Bearer 鉴权)
|
||||
//! - `anthropic_compat` → `GET {base_url}/v1/models`(x-api-key + anthropic-version 鉴权)
|
||||
//! - 其他 → `UnsupportedProvider` 错误
|
||||
//!
|
||||
//! 本模块仅含网络 IO 入口;URL 拼接 / 噪音过滤 / 响应反序列化等纯逻辑见
|
||||
//! [`model_fetch_helpers`]。
|
||||
//!
|
||||
//! 设计来源:docs/02-架构设计/已编号方案/F-01-模型能力系统与智能路由设计-2026-06-16.md §5
|
||||
//! (设计 §5.1 端点表 + §5.3 URL 智能拼接 + §5.4 噪音过滤)。
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use df_ai_core::model::ModelConfig;
|
||||
|
||||
use crate::model_fetch_helpers::{build_models_url, filter_chat_models, ModelsList};
|
||||
use crate::model_probe::probe;
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// 公共入口
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// HTTP 请求超时(秒)。模型列表接口响应小、无需长超时。
|
||||
const FETCH_TIMEOUT_SECS: u64 = 10;
|
||||
|
||||
/// 拉取厂商模型列表,返回已过滤噪音的模型名 Vec。
|
||||
///
|
||||
/// 协议分派(见模块文档)。错误信息含 `provider_type` + HTTP 状态/原因,便于前端友好提示。
|
||||
///
|
||||
/// 不真实调测:本函数本身是网络 IO,单测只覆盖 URL 拼接 / 噪音过滤等纯逻辑;
|
||||
/// 集成验证留给阶段 5 的 IPC `ai_fetch_models`(用户在 Settings 点「测试连接」触发)。
|
||||
pub async fn fetch_model_names(
|
||||
provider_type: &str,
|
||||
base_url: &str,
|
||||
api_key: &str,
|
||||
) -> Result<Vec<String>> {
|
||||
match provider_type {
|
||||
"openai_compat" => fetch_openai_compat(base_url, api_key).await,
|
||||
"anthropic_compat" => fetch_anthropic_compat(base_url, api_key).await,
|
||||
other => Err(anyhow!(
|
||||
"拉取模型列表失败:不支持的 provider_type={other}(仅支持 openai_compat / anthropic_compat)"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 拉取模型列表 + 对每个模型名调 `model_probe::probe` 探测出完整 `ModelConfig`。
|
||||
///
|
||||
/// 探测为纯 CPU(无网络),不会因单模型探测失败中断整体 — 探测对任意输入都有兜底(见 model_probe)。
|
||||
pub async fn fetch_and_probe(
|
||||
provider_type: &str,
|
||||
base_url: &str,
|
||||
api_key: &str,
|
||||
) -> Result<Vec<ModelConfig>> {
|
||||
let names = fetch_model_names(provider_type, base_url, api_key).await?;
|
||||
Ok(names.into_iter().map(|n| probe(&n)).collect())
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// 协议分派实现
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// OpenAI 兼容(GLM / DeepSeek / Kimi / 通义 / 中转站):`GET /v1/models`,Bearer 鉴权。
|
||||
async fn fetch_openai_compat(base_url: &str, api_key: &str) -> Result<Vec<String>> {
|
||||
let url = build_models_url(base_url);
|
||||
let client = build_client()?;
|
||||
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.bearer_auth(api_key)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| map_network_error("openai_compat", &url, e))?;
|
||||
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
return Err(map_status_error("openai_compat", status, &url));
|
||||
}
|
||||
|
||||
// OpenAI 响应:`{data:[{id, owned_by, ...}]}`。中转站通常同构。
|
||||
let body: ModelsList = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| anyhow!("openai_compat 响应解析失败({url}):{e}"))?;
|
||||
|
||||
Ok(filter_chat_models(body.into_ids()))
|
||||
}
|
||||
|
||||
/// Anthropic 兼容(Claude 官方 / GLM 订阅端点):`GET /v1/models`,x-api-key + anthropic-version 鉴权。
|
||||
async fn fetch_anthropic_compat(base_url: &str, api_key: &str) -> Result<Vec<String>> {
|
||||
let url = build_models_url(base_url);
|
||||
let client = build_client()?;
|
||||
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.header("x-api-key", api_key)
|
||||
.header("anthropic-version", "2023-06-01")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| map_network_error("anthropic_compat", &url, e))?;
|
||||
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
return Err(map_status_error("anthropic_compat", status, &url));
|
||||
}
|
||||
|
||||
// Anthropic 响应:`{data:[{id, display_name, type, ...}]}`(has_more 分页字段忽略)。
|
||||
// 兼容兜底:`{models:[{name, ...}]}`(Ollama 风格,理论 anthropic_compat 不会命中,
|
||||
// 但中转站行为不可控,用 `#[serde(alias)]` 零成本兜底 — 见 issues)。
|
||||
let body: ModelsList = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| anyhow!("anthropic_compat 响应解析失败({url}):{e}"))?;
|
||||
|
||||
Ok(filter_chat_models(body.into_ids()))
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// HTTP 客户端 + 错误映射
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
fn build_client() -> Result<reqwest::Client> {
|
||||
reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(FETCH_TIMEOUT_SECS))
|
||||
.build()
|
||||
.map_err(|e| anyhow!("构建 HTTP 客户端失败:{e}"))
|
||||
}
|
||||
|
||||
/// 网络错误(连接拒绝 / DNS / 超时)→ 友好提示,标注 provider_type + url 便于排查。
|
||||
fn map_network_error(provider_type: &str, url: &str, e: reqwest::Error) -> anyhow::Error {
|
||||
if e.is_timeout() {
|
||||
anyhow!("{provider_type} 拉取模型列表超时({url},>{FETCH_TIMEOUT_SECS}s)— 检查 base_url 可达性")
|
||||
} else {
|
||||
anyhow!("{provider_type} 拉取模型列表网络错误({url}):{e}")
|
||||
}
|
||||
}
|
||||
|
||||
/// HTTP 状态错误 → 按 401/403/404/5xx 分类友好提示(对齐设计 §5.5 失败降级)。
|
||||
fn map_status_error(provider_type: &str, status: reqwest::StatusCode, url: &str) -> anyhow::Error {
|
||||
if status.as_u16() == 401 || status.as_u16() == 403 {
|
||||
anyhow!("{provider_type} 鉴权失败({status}, {url})— 检查 api_key 是否正确 / 是否有该接口权限")
|
||||
} else if status.as_u16() == 404 {
|
||||
anyhow!("{provider_type} 端点不存在(404, {url})— 该厂商可能不支持模型列表拉取,请手动输入模型名")
|
||||
} else if status.is_server_error() {
|
||||
anyhow!("{provider_type} 厂商服务异常({status}, {url})— 稍后重试或手动输入模型名")
|
||||
} else {
|
||||
anyhow!("{provider_type} 拉取模型列表失败(HTTP {status}, {url})")
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// 测试(协议分派:不支持类型直接报错,不触网)
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── fetch_model_names 协议分派:不支持类型直接报错(不触网) ──
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_unsupported_provider_errors_without_network() {
|
||||
// ollama / glm 等非 openai_compat / anthropic_compat 类型 → 直接错误,不发请求
|
||||
let err = fetch_model_names("ollama", "http://localhost:11434", "k")
|
||||
.await
|
||||
.unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(msg.contains("ollama"), "err={msg}");
|
||||
assert!(msg.contains("provider_type"), "err={msg}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
//! 厂商模型列表拉取 — 纯逻辑(无 IO)。
|
||||
//!
|
||||
//! 从 `model_fetch.rs` 抽离的纯函数 / 类型:URL 拼接、噪音过滤、响应反序列化。
|
||||
//! 这些逻辑无网络依赖,可独立单测;主文件 `model_fetch.rs` 保留有 IO 的拉取入口。
|
||||
//!
|
||||
//! 设计来源:docs/02-架构设计/已编号方案/F-01-模型能力系统与智能路由设计-2026-06-16.md §5.3 / §5.4。
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// URL 智能拼接
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// 拼接模型列表端点 URL。
|
||||
///
|
||||
/// 规则(设计 §5.3,base_url 先去尾 `/`):
|
||||
/// - 以 `/chat/completions` 结尾 → 替换为 `/models`(用户把对话端点当 base_url 填了)
|
||||
/// - 以 `/v1` / `/v4` 结尾 → 直接补 `/models`(避免 `/v1/v1/models`)
|
||||
/// - 以 `/models` 结尾 → 原样用(用户已填完整端点)
|
||||
/// - 以 `/api` 结尾 → 补 `/models`(Ollama 风格 base,anthropic_compat 兜底用)
|
||||
/// - 其余 → 补 `/v1/models`(最常见:用户填 `https://open.bigmodel.cn/api/paas/v4`)
|
||||
pub fn build_models_url(base_url: &str) -> String {
|
||||
let url = base_url.trim_end_matches('/');
|
||||
if url.ends_with("/chat/completions") {
|
||||
url.replace("/chat/completions", "/models")
|
||||
} else if url.ends_with("/v1") || url.ends_with("/v4") {
|
||||
format!("{url}/models")
|
||||
} else if url.ends_with("/models") {
|
||||
url.to_string()
|
||||
} else if url.ends_with("/api") {
|
||||
format!("{url}/models")
|
||||
} else {
|
||||
format!("{url}/v1/models")
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// 噪音过滤(剔除非 chat 模型)
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// 判断是否为非 chat 模型(应从列表中剔除)。
|
||||
///
|
||||
/// 规则(设计 §5.4 + 合理扩展,见 issues):
|
||||
/// - 图片生成:dall-e / midjourney / stable-diffusion / imagen
|
||||
/// - 语音:tts / whisper / audio / speech / voice(语音合成/识别)
|
||||
/// - 实时:realtime(OpenAI Realtime API 语音对话,非 chat completions)
|
||||
/// - 转写:transcribe / transcription
|
||||
/// - 审查:moderation(内容审查模型)
|
||||
/// - 搜索:davinci-search / search(旧 GPT 搜索变种)
|
||||
///
|
||||
/// embedding 设计 §5.4 注释「保留(知识库需要)」— 此处遵循设计保留 embedding,
|
||||
/// 不在 `is_non_chat_model` 剔除(知识库 embedding 路由用得着)。
|
||||
pub fn is_non_chat_model(id: &str) -> bool {
|
||||
let id = id.to_lowercase();
|
||||
// 图片生成
|
||||
id.contains("dall-e")
|
||||
|| id.contains("midjourney")
|
||||
|| id.contains("stable-diffusion")
|
||||
|| id.contains("imagen")
|
||||
// 语音(tts 合成 / whisper 识别 / 通用 audio / speech / voice)
|
||||
|| id.contains("tts")
|
||||
|| id.contains("whisper")
|
||||
|| id.contains("audio")
|
||||
|| id.contains("speech")
|
||||
|| id.contains("voice")
|
||||
// 实时语音对话(OpenAI Realtime API,走独立协议非 chat completions)
|
||||
|| id.contains("realtime")
|
||||
// 转写
|
||||
|| id.contains("transcribe")
|
||||
|| id.contains("transcription")
|
||||
// 内容审查
|
||||
|| id.contains("moderation")
|
||||
// 旧 GPT 搜索变种(davinci-search 覆盖旧 GPT 搜索;裸 -search 误伤合法 search-augmented chat
|
||||
// 模型如 gpt-4o-search-preview/qwen-search/glm-4-search,已移除)
|
||||
|| id.contains("davinci-search")
|
||||
}
|
||||
|
||||
/// 过滤噪音:剔除非 chat 模型 + 去重(中转站可能返回重复) + 去空名。
|
||||
pub fn filter_chat_models(ids: Vec<String>) -> Vec<String> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut out = Vec::with_capacity(ids.len());
|
||||
for id in ids {
|
||||
let trimmed = id.trim();
|
||||
if trimmed.is_empty() || is_non_chat_model(trimmed) {
|
||||
continue;
|
||||
}
|
||||
if seen.insert(trimmed.to_string()) {
|
||||
out.push(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// 响应反序列化
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// 厂商 `/v1/models` 响应统一反序列化。
|
||||
///
|
||||
/// 兼容三种结构(用 `#[serde(alias)]`):
|
||||
/// - OpenAI / Anthropic:`{data:[{id, ...}]}`
|
||||
/// - Ollama:`{models:[{name, ...}]}`(anthropic_compat 走中转站时兜底)
|
||||
/// - 单字段别名:`id` / `name` 都认
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct ModelsList {
|
||||
#[serde(default)]
|
||||
data: Vec<ModelEntry>,
|
||||
#[serde(default, alias = "models")]
|
||||
data_alt: Vec<ModelEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ModelEntry {
|
||||
#[serde(default, alias = "name")]
|
||||
id: Option<String>,
|
||||
}
|
||||
|
||||
impl ModelsList {
|
||||
/// 合并 `data` 与 `models`(alias)字段,提取所有模型 id/name,丢弃 None。
|
||||
pub(crate) fn into_ids(self) -> Vec<String> {
|
||||
self.data
|
||||
.into_iter()
|
||||
.chain(self.data_alt)
|
||||
.filter_map(|e| e.id)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// 测试(纯逻辑/字符串处理,不真实调 API)
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── is_non_chat_model:search-augmented chat 模型不应误剔(裸 -search 已移除) ──
|
||||
#[test]
|
||||
fn is_non_chat_model_keeps_search_augmented_chat() {
|
||||
// search-augmented chat 模型(对话端点可调)不应被 -search 子串误剔
|
||||
assert!(!is_non_chat_model("gpt-4o-search-preview"));
|
||||
assert!(!is_non_chat_model("qwen-search"));
|
||||
assert!(!is_non_chat_model("glm-4-search-preview"));
|
||||
// 旧 GPT 搜索变种仍剔(davinci-search 覆盖)
|
||||
assert!(is_non_chat_model("text-davinci-search-001"));
|
||||
}
|
||||
|
||||
// ── build_models_url:尾 / 处理 ──
|
||||
|
||||
#[test]
|
||||
fn build_url_strips_trailing_slash() {
|
||||
// 用户填 `https://api.x.com/v1/`(尾 /)→ 去 / 后命中 /v1 分支 → 补 /models
|
||||
assert_eq!(
|
||||
build_models_url("https://api.x.com/v1/"),
|
||||
"https://api.x.com/v1/models"
|
||||
);
|
||||
// 多个尾 /
|
||||
assert_eq!(
|
||||
build_models_url("https://api.x.com/v4//"),
|
||||
"https://api.x.com/v4/models"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_url_v1_suffix_appends_models() {
|
||||
assert_eq!(
|
||||
build_models_url("https://api.openai.com/v1"),
|
||||
"https://api.openai.com/v1/models"
|
||||
);
|
||||
// 不应产生 /v1/v1/models
|
||||
assert!(!build_models_url("https://api.openai.com/v1").contains("/v1/v1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_url_v4_suffix_appends_models() {
|
||||
// GLM:`https://open.bigmodel.cn/api/paas/v4`
|
||||
assert_eq!(
|
||||
build_models_url("https://open.bigmodel.cn/api/paas/v4"),
|
||||
"https://open.bigmodel.cn/api/paas/v4/models"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_url_bare_base_appends_v1_models() {
|
||||
// 用户只填根:`https://api.deepseek.com` → 补 /v1/models
|
||||
assert_eq!(
|
||||
build_models_url("https://api.deepseek.com"),
|
||||
"https://api.deepseek.com/v1/models"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_url_chat_completions_replaced() {
|
||||
// 用户误把对话端点当 base_url
|
||||
assert_eq!(
|
||||
build_models_url("https://api.x.com/v1/chat/completions"),
|
||||
"https://api.x.com/v1/models"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_url_already_models_kept() {
|
||||
// 用户已填完整端点 → 原样用
|
||||
assert_eq!(
|
||||
build_models_url("https://api.x.com/v1/models"),
|
||||
"https://api.x.com/v1/models"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_url_api_suffix_ollama_style() {
|
||||
// Ollama 风格 base(anthropic_compat 兜底路径)
|
||||
assert_eq!(
|
||||
build_models_url("http://localhost:11434/api"),
|
||||
"http://localhost:11434/api/models"
|
||||
);
|
||||
}
|
||||
|
||||
// ── is_non_chat_model:噪音过滤规则 ──
|
||||
|
||||
#[test]
|
||||
fn non_chat_image_generation_filtered() {
|
||||
assert!(is_non_chat_model("dall-e-3"));
|
||||
assert!(is_non_chat_model("dall-e-2"));
|
||||
assert!(is_non_chat_model("midjourney-v6"));
|
||||
assert!(is_non_chat_model("stable-diffusion-xl"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_chat_speech_filtered() {
|
||||
assert!(is_non_chat_model("tts-1"));
|
||||
assert!(is_non_chat_model("tts-1-hd"));
|
||||
assert!(is_non_chat_model("whisper-1"));
|
||||
assert!(is_non_chat_model("audio-transcribe"));
|
||||
assert!(is_non_chat_model("speech-synth"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_chat_realtime_filtered() {
|
||||
assert!(is_non_chat_model("gpt-4o-realtime"));
|
||||
assert!(is_non_chat_model("gpt-4o-mini-realtime-preview"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_chat_moderation_filtered() {
|
||||
assert!(is_non_chat_model("text-moderation-stable"));
|
||||
assert!(is_non_chat_model("omni-moderation-latest"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_chat_search_variants_filtered() {
|
||||
assert!(is_non_chat_model("text-davinci-search-001"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_models_kept() {
|
||||
// 主流 chat 模型不应被误剔
|
||||
for ok in [
|
||||
"glm-4",
|
||||
"glm-4-flash",
|
||||
"glm-4v",
|
||||
"glm-4-air",
|
||||
"glm-4-plus",
|
||||
"deepseek-chat",
|
||||
"deepseek-coder",
|
||||
"gpt-4o",
|
||||
"gpt-4o-mini",
|
||||
"claude-3-5-sonnet-20241022",
|
||||
"claude-3-haiku-20240307",
|
||||
"qwen-plus",
|
||||
"kimi-k2",
|
||||
] {
|
||||
assert!(!is_non_chat_model(ok), "{ok} 不应被当噪音剔除");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedding_kept_per_design() {
|
||||
// 设计 §5.4 明确「embedding 保留(知识库需要)」
|
||||
for emb in ["embedding-3", "text-embedding-3-large", "bge-m3"] {
|
||||
assert!(!is_non_chat_model(emb), "{emb} 按 §5.4 应保留");
|
||||
}
|
||||
}
|
||||
|
||||
// ── filter_chat_models:过滤 + 去重 + 去空 ──
|
||||
|
||||
#[test]
|
||||
fn filter_removes_noise_and_keeps_chat() {
|
||||
let raw = vec![
|
||||
"glm-4-flash".to_string(),
|
||||
"dall-e-3".to_string(), // 噪音
|
||||
"tts-1".to_string(), // 噪音
|
||||
"whisper-1".to_string(), // 噪音
|
||||
"text-moderation-stable".to_string(), // 噪音
|
||||
"glm-4v".to_string(),
|
||||
"gpt-4o".to_string(),
|
||||
];
|
||||
let got = filter_chat_models(raw);
|
||||
assert_eq!(got, vec!["glm-4-flash", "glm-4v", "gpt-4o"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_dedupes() {
|
||||
// 中转站可能返回重复模型名
|
||||
let raw = vec![
|
||||
"glm-4".to_string(),
|
||||
"glm-4".to_string(),
|
||||
"glm-4-flash".to_string(),
|
||||
];
|
||||
let got = filter_chat_models(raw);
|
||||
assert_eq!(got, vec!["glm-4", "glm-4-flash"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_drops_empty_and_whitespace_only() {
|
||||
let raw = vec![
|
||||
"".to_string(),
|
||||
" ".to_string(),
|
||||
"glm-4".to_string(),
|
||||
" glm-4v ".to_string(), // 带空格 → trim 后保留
|
||||
];
|
||||
let got = filter_chat_models(raw);
|
||||
assert_eq!(got, vec!["glm-4", "glm-4v"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_empty_input() {
|
||||
assert!(filter_chat_models(vec![]).is_empty());
|
||||
}
|
||||
|
||||
// ── ModelsList 反序列化:兼容 OpenAI / Anthropic / Ollama 三种结构 ──
|
||||
|
||||
#[test]
|
||||
fn parse_openai_format() {
|
||||
// OpenAI:`{data:[{id, ...}]}`
|
||||
let json = r#"{
|
||||
"data": [
|
||||
{"id": "gpt-4o", "owned_by": "openai"},
|
||||
{"id": "gpt-4o-mini", "owned_by": "openai"}
|
||||
]
|
||||
}"#;
|
||||
let m: ModelsList = serde_json::from_str(json).unwrap();
|
||||
let ids = m.into_ids();
|
||||
assert_eq!(ids, vec!["gpt-4o", "gpt-4o-mini"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_anthropic_format() {
|
||||
// Anthropic:`{data:[{id, display_name, type}], has_more:false}`
|
||||
let json = r#"{
|
||||
"data": [
|
||||
{"id": "claude-3-5-sonnet-20241022", "display_name": "Claude 3.5 Sonnet", "type": "model"},
|
||||
{"id": "claude-3-haiku-20240307", "display_name": "Claude 3 Haiku", "type": "model"}
|
||||
],
|
||||
"has_more": false,
|
||||
"first_id": "claude-3-5-sonnet-20241022",
|
||||
"last_id": "claude-3-haiku-20240307"
|
||||
}"#;
|
||||
let m: ModelsList = serde_json::from_str(json).unwrap();
|
||||
let ids = m.into_ids();
|
||||
assert_eq!(ids, vec!["claude-3-5-sonnet-20241022", "claude-3-haiku-20240307"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ollama_format_via_alias() {
|
||||
// Ollama:`{models:[{name, size}]}` — 走 anthropic_compat 中转站兜底
|
||||
let json = r#"{
|
||||
"models": [
|
||||
{"name": "llama3:8b", "size": 4661214673},
|
||||
{"name": "qwen2:7b", "size": 4436122400}
|
||||
]
|
||||
}"#;
|
||||
let m: ModelsList = serde_json::from_str(json).unwrap();
|
||||
let ids = m.into_ids();
|
||||
assert_eq!(ids, vec!["llama3:8b", "qwen2:7b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_missing_data_field_yields_empty() {
|
||||
// 厂商返回 `{}` 或缺 data 字段 → 空列表,不 panic
|
||||
let json = r#"{}"#;
|
||||
let m: ModelsList = serde_json::from_str(json).unwrap();
|
||||
assert!(m.into_ids().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_entry_missing_id_skipped() {
|
||||
// data 数组项缺 id → 该项跳过
|
||||
let json = r#"{
|
||||
"data": [
|
||||
{"id": "glm-4"},
|
||||
{"owned_by": "x"},
|
||||
{"id": "glm-4v"}
|
||||
]
|
||||
}"#;
|
||||
let m: ModelsList = serde_json::from_str(json).unwrap();
|
||||
let ids = m.into_ids();
|
||||
assert_eq!(ids, vec!["glm-4", "glm-4v"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
//! 模型探测器 — F-01 阶段2
|
||||
//!
|
||||
//! 给定模型名,产出完整 `ModelConfig`(4 维度 + 路由控制 + 探测来源标注)。
|
||||
//!
|
||||
//! 多源探测,高优先源命中即返(短路):
|
||||
//! 1. 内置预设表精确匹配(name 完全相等) → `ProbeSource::PresetTable`
|
||||
//! 2. 内置预设表模糊匹配(子串包含) → `ProbeSource::PresetTable`
|
||||
//! 3. 模型名启发式推断(命名模式) → `ProbeSource::Heuristic`
|
||||
//! 4. 默认值兜底(`ModelConfig::with_defaults`) → `ProbeSource::Default`
|
||||
//!
|
||||
//! 设计来源:docs/02-架构设计/已编号方案/F-01-模型能力系统与智能路由设计-2026-06-16.md §4
|
||||
//! (设计文档 §4.1 写的是"启发式先行 + 预设表合并";本实施按阶段 2 任务规格
|
||||
//! 收敛为"预设优先 > 启发式"链 — 预设表精确数据可信度高于命名猜测,优先短路)。
|
||||
|
||||
use df_ai_core::model::{ModelConfig, ProbeSource};
|
||||
|
||||
// 纯逻辑子模块(预设表加载 / 启发式推断 / 词素判定)抽离至此 —
|
||||
// private mod,crate 内复用,外部经 model_probe::probe 间接访问(对齐 model_fetch_helpers)。
|
||||
use crate::model_probe_helpers::{heuristic_infer, presets};
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// 公共入口
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// 探测单个模型,返回完整 `ModelConfig`(已填充 `probe_source`)。
|
||||
///
|
||||
/// 多源探测顺序(高优先源命中即返):
|
||||
/// 1. 预设表精确匹配(`model_id` 完全相等,大小写敏感)
|
||||
/// 2. 预设表模糊匹配(`model_id` 双向子串包含,大小写不敏感)
|
||||
/// 3. 启发式推断(模型名命名模式)
|
||||
/// 4. 默认值兜底
|
||||
///
|
||||
/// 返回的 `ModelConfig.probe_source` 标注实际命中来源。
|
||||
pub fn probe(model_id: &str) -> ModelConfig {
|
||||
// 1. 预设表精确匹配
|
||||
if let Some(mut hit) = presets().iter().find(|m| m.model_id == model_id).cloned() {
|
||||
hit.probe_source = Some(ProbeSource::PresetTable);
|
||||
return hit;
|
||||
}
|
||||
|
||||
// 2. 预设表模糊匹配(双向子串包含,大小写不敏感)
|
||||
// 多个候选命中时,选预设 model_id 最长者(最具体:glm-4v > glm-4)。
|
||||
let needle = model_id.to_lowercase();
|
||||
let fuzzy = presets()
|
||||
.iter()
|
||||
.filter(|m| {
|
||||
let cand = m.model_id.to_lowercase();
|
||||
!cand.is_empty() && (cand.contains(&needle) || needle.contains(&cand))
|
||||
})
|
||||
.max_by_key(|m| m.model_id.len());
|
||||
|
||||
if let Some(mut hit) = fuzzy.cloned() {
|
||||
// 模糊命中:保留预设维度,但用入参的实际模型名覆盖 model_id(避免回写错名)
|
||||
hit.model_id = model_id.to_string();
|
||||
hit.probe_source = Some(ProbeSource::PresetTable);
|
||||
return hit;
|
||||
}
|
||||
|
||||
// 3. 启发式推断
|
||||
let mut heuristic = heuristic_infer(model_id);
|
||||
heuristic.probe_source = Some(ProbeSource::Heuristic);
|
||||
heuristic
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// 测试
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use df_ai_core::model::{Capability, CostTier, IntelligenceTier, Modality, ProbeSource};
|
||||
|
||||
// ── 预设表加载 ──
|
||||
|
||||
#[test]
|
||||
fn presets_loaded_and_nonempty() {
|
||||
let p = presets();
|
||||
assert!(!p.is_empty(), "预设表应非空");
|
||||
// 确保每个预设都有 model_id
|
||||
assert!(p.iter().all(|m| !m.model_id.is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presets_contain_expected_models() {
|
||||
let p = presets();
|
||||
let ids: Vec<&str> = p.iter().map(|m| m.model_id.as_str()).collect();
|
||||
assert!(ids.contains(&"glm-4"), "glm-4 应在预设表: {ids:?}");
|
||||
assert!(ids.contains(&"glm-4-flash"));
|
||||
assert!(ids.contains(&"glm-4v"));
|
||||
assert!(ids.contains(&"gpt-4o"));
|
||||
assert!(ids.contains(&"claude-3-5-sonnet-20241022"));
|
||||
}
|
||||
|
||||
// ── 预设精确匹配 ──
|
||||
|
||||
#[test]
|
||||
fn probe_preset_exact_match_glm4() {
|
||||
let m = probe("glm-4");
|
||||
assert_eq!(m.model_id, "glm-4");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
|
||||
assert_eq!(m.modalities, vec![Modality::Text]);
|
||||
// B-260618-04:预设表不再写死 cost_tier/intelligence,由 serde default 兜底中性值
|
||||
assert_eq!(m.intelligence, IntelligenceTier::Standard);
|
||||
assert_eq!(m.cost_tier, CostTier::Medium);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_preset_exact_match_glm4v_has_vision() {
|
||||
let m = probe("glm-4v");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
|
||||
assert!(m.modalities.contains(&Modality::Vision));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_preset_exact_match_deepseek_coder_has_codegen() {
|
||||
let m = probe("deepseek-coder");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
|
||||
assert!(m.capabilities.contains(&Capability::CodeGen));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_preset_exact_match_embedding_no_text_modality() {
|
||||
let m = probe("embedding-3");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
|
||||
assert!(m.capabilities.contains(&Capability::Embedding));
|
||||
assert!(
|
||||
!m.modalities.contains(&Modality::Text),
|
||||
"embedding 模型不应有 Text 模态"
|
||||
);
|
||||
}
|
||||
|
||||
// ── 预设模糊匹配 ──
|
||||
|
||||
#[test]
|
||||
fn probe_preset_fuzzy_match_glm4v_variant() {
|
||||
// "glm-4v-x" 不在预设表精确命中,但 "glm-4v" 是其子串 → 模糊命中
|
||||
let m = probe("glm-4v-x");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
|
||||
assert_eq!(m.model_id, "glm-4v-x", "模糊命中后 model_id 应用入参名");
|
||||
assert!(
|
||||
m.modalities.contains(&Modality::Vision),
|
||||
"应继承 glm-4v 的 vision 模态"
|
||||
);
|
||||
}
|
||||
|
||||
// ── 启发式:Vision ──
|
||||
|
||||
#[test]
|
||||
fn heuristic_vision_from_v_suffix() {
|
||||
// glm-4v 不走启发式(精确命中预设);用未知名验证启发式
|
||||
let m = probe("custom-model-v");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
|
||||
assert!(
|
||||
m.modalities.contains(&Modality::Vision),
|
||||
"v 后缀应推断 Vision"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heuristic_vision_from_vl_token() {
|
||||
let m = probe("qwen-vl-unknown");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
|
||||
assert!(m.modalities.contains(&Modality::Vision));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heuristic_vision_from_vision_word() {
|
||||
let m = probe("some-vision-7b");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
|
||||
assert!(m.modalities.contains(&Modality::Vision));
|
||||
}
|
||||
|
||||
// ── 启发式:档位中性(flash/mini 不再猜 Lite) ──
|
||||
|
||||
#[test]
|
||||
fn heuristic_flash_keeps_neutral_tier() {
|
||||
let m = probe("unknown-flash");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
|
||||
// B-260618-04:cost/intel 一律中性,不靠名字猜
|
||||
assert_eq!(m.intelligence, IntelligenceTier::Standard);
|
||||
assert_eq!(m.cost_tier, CostTier::Medium);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heuristic_mini_keeps_neutral_tier() {
|
||||
let m = probe("test-mini-model");
|
||||
assert_eq!(m.intelligence, IntelligenceTier::Standard);
|
||||
assert_eq!(m.cost_tier, CostTier::Medium);
|
||||
}
|
||||
|
||||
// ── 启发式:CodeGen ──
|
||||
|
||||
#[test]
|
||||
fn heuristic_codegen_from_code() {
|
||||
let m = probe("acme-code-7b");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
|
||||
assert!(m.capabilities.contains(&Capability::CodeGen));
|
||||
// 对话模型仍应保留 ToolUse
|
||||
assert!(m.capabilities.contains(&Capability::ToolUse));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heuristic_codegen_from_coder() {
|
||||
let m = probe("acme-coder");
|
||||
assert!(m.capabilities.contains(&Capability::CodeGen));
|
||||
}
|
||||
|
||||
// ── 启发式:Embedding(特殊路径:无 Text 模态) ──
|
||||
|
||||
#[test]
|
||||
fn heuristic_embedding_no_text_modality() {
|
||||
let m = probe("acme-embedding-v3");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
|
||||
assert!(m.capabilities.contains(&Capability::Embedding));
|
||||
assert!(!m.modalities.contains(&Modality::Text));
|
||||
assert!(!m.capabilities.contains(&Capability::ToolUse));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heuristic_embedding_from_text_embedding_prefix() {
|
||||
let m = probe("text-embedding-3-large");
|
||||
assert!(m.capabilities.contains(&Capability::Embedding));
|
||||
assert!(m.modalities.is_empty());
|
||||
}
|
||||
|
||||
// ── 启发式:档位中性(pro/opus/4o 不再猜 Plus/Ultra/High) ──
|
||||
|
||||
#[test]
|
||||
fn heuristic_pro_keeps_neutral_tier() {
|
||||
let m = probe("acme-pro");
|
||||
assert_eq!(m.intelligence, IntelligenceTier::Standard);
|
||||
assert_eq!(m.cost_tier, CostTier::Medium);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heuristic_opus_keeps_neutral_tier() {
|
||||
let m = probe("acme-opus");
|
||||
assert_eq!(m.intelligence, IntelligenceTier::Standard);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heuristic_4o_keeps_neutral_cost() {
|
||||
let m = probe("acme-4o");
|
||||
assert_eq!(m.cost_tier, CostTier::Medium);
|
||||
}
|
||||
|
||||
// ── 启发式:默认(Standard/Medium/Text/ToolUse) ──
|
||||
|
||||
#[test]
|
||||
fn heuristic_default_for_unknown_name() {
|
||||
let m = probe("acme-unknown-model");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
|
||||
assert_eq!(m.modalities, vec![Modality::Text]);
|
||||
assert_eq!(m.capabilities, vec![Capability::ToolUse]);
|
||||
assert_eq!(m.intelligence, IntelligenceTier::Standard);
|
||||
assert_eq!(m.cost_tier, CostTier::Medium);
|
||||
}
|
||||
|
||||
// ── 优先级链:预设精确 > 启发式 ──
|
||||
|
||||
#[test]
|
||||
fn probe_preset_beats_heuristic() {
|
||||
// "glm-4-flash" 精确命中预设表;B-260618-04 后预设/启发式档位都中性,
|
||||
// 此处仅校验 source 标注为 PresetTable
|
||||
let m = probe("glm-4-flash");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_priority_chain_returns_config() {
|
||||
// 任何名字都应返回完整 ModelConfig,不 panic
|
||||
for name in ["glm-4", "unknown-xyz", "qwen-vl", "acme-embedding", "gpt-5"] {
|
||||
let m = probe(name);
|
||||
assert_eq!(m.model_id, name);
|
||||
assert!(
|
||||
m.probe_source.is_some(),
|
||||
"probe_source 应被填充: {name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
//! 模型探测器 — 纯逻辑子模块(F-01 阶段2)
|
||||
//!
|
||||
//! 从 `model_probe.rs` 抽离的纯函数实现(预设表加载 / 启发式推断 / 词素判定)。
|
||||
//! 无 IO、无外部状态,crate 内经 `model_probe::probe` 间接复用 —
|
||||
//! 对齐 `model_fetch_helpers` 拆分先例(private mod,外部零直接路径需求)。
|
||||
//!
|
||||
//! 设计来源:docs/02-架构设计/已编号方案/F-01-模型能力系统与智能路由设计-2026-06-16.md §4
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use df_ai_core::model::{
|
||||
Capability, CostTier, IntelligenceTier, Modality, ModelConfig,
|
||||
};
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// 预设表:编译期嵌入(include_str! 相对 crate 根),零运行时文件依赖
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// 预设表原始 JSON(编译期从 `crates/df-ai/presets/models.json` 嵌入)。
|
||||
pub(super) const PRESETS_JSON: &str = include_str!("../presets/models.json");
|
||||
|
||||
/// 解析后的预设表(进程内单例,首次访问惰性解析一次)。
|
||||
pub(super) fn presets() -> &'static [ModelConfig] {
|
||||
static PRESETS: OnceLock<Vec<ModelConfig>> = OnceLock::new();
|
||||
PRESETS.get_or_init(|| {
|
||||
// include_str! 内容由仓库控制,解析失败属编译期/仓库错误,panic 合理。
|
||||
serde_json::from_str::<Vec<ModelConfig>>(PRESETS_JSON)
|
||||
.expect("presets/models.json 解析失败 — 检查 JSON 格式与 ModelConfig serde 映射")
|
||||
})
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// 启发式推断(模型名命名模式 → 4 维度)
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// 模型名启发式推断。仅推断功能性维度(modalities/capabilities),
|
||||
/// cost_tier / intelligence 一律返中性默认(Medium / Standard)。
|
||||
///
|
||||
/// 取舍:B-260618-04 — 模型名关键词猜档位(flash→Lite、4o→High、pro→Plus)无依据,
|
||||
/// 厂商定价/智力与命名无关,瞎填会污染路由器过滤(intelligence >= min / cost_tier <= max)。
|
||||
/// 改中性默认,真实档位由用户手填或更高阶探测源(如厂商 API/定价表)提供。
|
||||
///
|
||||
/// 规则(任务规格,设计文档 §4.4 模糊匹配规则对齐):
|
||||
/// - `embed`/`embedding`/`e-`(前缀) → Embedding 模型:capabilities=[Embedding],modalities=[](去 Text)
|
||||
/// - `v`/`vision`/`vl`(词素) → modalities 加 Vision
|
||||
/// - `code`/`coder` → capabilities 加 CodeGen
|
||||
/// - cost_tier / intelligence → 中性默认 Medium / Standard(不靠名字猜)
|
||||
pub(super) fn heuristic_infer(model_id: &str) -> ModelConfig {
|
||||
let name = model_id.to_lowercase();
|
||||
let mut modalities: Vec<Modality> = Vec::new();
|
||||
let mut capabilities: Vec<Capability> = Vec::new();
|
||||
// 中性默认:不靠模型名猜档位(B-260618-04)
|
||||
let cost_tier = CostTier::Medium;
|
||||
let intelligence = IntelligenceTier::Standard;
|
||||
|
||||
// —— Embedding 优先判定(改变模态集合,且通常独占) ——
|
||||
// 规则:含 embed / embedding / 以 "e-" / "embedding-" 起首
|
||||
let is_embedding = name.contains("embed")
|
||||
|| name.contains("embedding")
|
||||
|| name.starts_with("e-")
|
||||
|| name.starts_with("embedding-")
|
||||
|| name.starts_with("text-embedding");
|
||||
|
||||
if is_embedding {
|
||||
capabilities.push(Capability::Embedding);
|
||||
// embedding 模型不走对话模态,modalities 留空(设计 §4.4 embedding-3 例:modalities=[])
|
||||
return ModelConfig {
|
||||
model_id: model_id.to_string(),
|
||||
enabled: true,
|
||||
label: None,
|
||||
modalities,
|
||||
capabilities,
|
||||
cost_tier,
|
||||
intelligence,
|
||||
weight: 50,
|
||||
context_window: 8192,
|
||||
probe_source: None,
|
||||
};
|
||||
}
|
||||
|
||||
// —— 对话模型默认 Text 模态 ——
|
||||
modalities.push(Modality::Text);
|
||||
|
||||
// —— Vision 词素:含 "v"(独立词素:glm-4v / qwen-vl)、"vision"、"vl" ——
|
||||
// 注:裸 "v" 子串误伤大(如 "review"),故仅在边界词素命中:
|
||||
// 以 "v" 结尾、含 "-v" / "v-" 分隔、含 "vl" / "vision"
|
||||
if has_vision_token(&name) {
|
||||
modalities.push(Modality::Vision);
|
||||
}
|
||||
|
||||
// —— CodeGen:含 code / coder ——
|
||||
if name.contains("code") || name.contains("coder") {
|
||||
capabilities.push(Capability::CodeGen);
|
||||
}
|
||||
|
||||
// —— 对话模型默认 ToolUse(主流支持 function calling) ——
|
||||
capabilities.push(Capability::ToolUse);
|
||||
|
||||
ModelConfig {
|
||||
model_id: model_id.to_string(),
|
||||
enabled: true,
|
||||
label: None,
|
||||
modalities,
|
||||
capabilities,
|
||||
cost_tier,
|
||||
intelligence,
|
||||
weight: 50,
|
||||
context_window: 8192,
|
||||
probe_source: None,
|
||||
}
|
||||
}
|
||||
|
||||
// —— 词素判定助手(命名约定式启发式,集中维护便于增删) ——
|
||||
|
||||
/// Vision 词素:`-v`(尾缀如 glm-4v)、以 `v` 结尾、含 `vl` / `vision`。
|
||||
/// 不直接 `contains('v')`(避免误伤 review/verbal 等)。
|
||||
pub(super) fn has_vision_token(name: &str) -> bool {
|
||||
name.contains("vision") || name.contains("vl") || name.ends_with('v') || name.contains("-v")
|
||||
}
|
||||
+412
-225
@@ -3,122 +3,30 @@
|
||||
//! 覆盖: OpenAI / GLM (open.bigmodel.cn) / DeepSeek / Claude OpenAI 兼容模式
|
||||
//! 支持: 同步调用 + SSE 流式 + Function Calling / Tool Use
|
||||
|
||||
use std::pin::Pin;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use eventsource_stream::Eventsource;
|
||||
use futures::{Stream, StreamExt};
|
||||
use futures::StreamExt;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{debug, error, info, warn};
|
||||
use tracing::{debug, error, warn};
|
||||
|
||||
use crate::provider::{
|
||||
CompletionRequest, CompletionResponse, LlmProvider, ProviderFeatures, StreamChunk, StreamResult,
|
||||
TokenUsage, ToolCall, ToolCallDelta,
|
||||
CompletionRequest, CompletionResponse, LlmProvider, StreamResult, TokenUsage, ToolCall,
|
||||
};
|
||||
// ChatMessage 仅单测构造 CompletionRequest 用,避免非 test 构建的 unused import 警告。
|
||||
#[cfg(test)]
|
||||
use crate::provider::ChatMessage;
|
||||
use crate::retry::{
|
||||
retry_with_backoff, AttemptOutcome, is_reqwest_error_retryable, is_status_retryable,
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// OpenAI API 请求/响应结构体
|
||||
// ============================================================
|
||||
|
||||
/// OpenAI 兼容请求体
|
||||
#[derive(Debug, Serialize)]
|
||||
struct OpenAiRequest {
|
||||
model: String,
|
||||
messages: Vec<OpenAiMessage>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
temperature: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
max_tokens: Option<u32>,
|
||||
stream: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tools: Option<Vec<serde_json::Value>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tool_choice: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// OpenAI 消息格式
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct OpenAiMessage {
|
||||
role: String,
|
||||
content: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tool_call_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tool_calls: Option<Vec<serde_json::Value>>,
|
||||
}
|
||||
|
||||
/// OpenAI 同步响应
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenAiResponse {
|
||||
choices: Vec<OpenAiChoice>,
|
||||
model: String,
|
||||
usage: Option<OpenAiUsage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenAiChoice {
|
||||
message: OpenAiMessageResp,
|
||||
finish_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenAiMessageResp {
|
||||
content: Option<String>,
|
||||
tool_calls: Option<Vec<OpenAiToolCallResp>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenAiToolCallResp {
|
||||
id: String,
|
||||
#[serde(rename = "type")]
|
||||
call_type: String,
|
||||
function: OpenAiFunctionResp,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenAiFunctionResp {
|
||||
name: String,
|
||||
arguments: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenAiUsage {
|
||||
prompt_tokens: u32,
|
||||
completion_tokens: u32,
|
||||
total_tokens: u32,
|
||||
}
|
||||
|
||||
/// SSE 流式响应 chunk
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenAiStreamChunk {
|
||||
choices: Vec<OpenAiStreamChoice>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenAiStreamChoice {
|
||||
delta: OpenAiStreamDelta,
|
||||
finish_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenAiStreamDelta {
|
||||
content: Option<String>,
|
||||
tool_calls: Option<Vec<OpenAiStreamToolCall>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenAiStreamToolCall {
|
||||
index: u32,
|
||||
id: Option<String>,
|
||||
function: Option<OpenAiStreamFunction>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenAiStreamFunction {
|
||||
name: Option<String>,
|
||||
arguments: Option<String>,
|
||||
}
|
||||
// 协议数据结构(OpenAiRequest / OpenAiResponse / OpenAiStreamChunk …)与 SSE 解析纯函数
|
||||
// apply_openai_sse 抽离到 openai_helpers.rs,结构对齐 anthropic_helpers.rs。
|
||||
// Rust impl 块不可跨文件,Provider struct + impl 仍留本文件。
|
||||
use crate::openai_helpers::{
|
||||
apply_openai_sse, OpenAiMessage, OpenAiRequest, OpenAiResponse, OpenAiToolCallResp,
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// OpenAI Compat Provider
|
||||
@@ -139,16 +47,9 @@ impl OpenAICompatProvider {
|
||||
/// - `api_key`: API 密钥
|
||||
/// - `default_model`: 默认模型名称
|
||||
pub fn new(base_url: impl Into<String>, api_key: impl Into<String>, default_model: impl Into<String>) -> Self {
|
||||
// connect_timeout 防连接阶段无限 hang(网络静默断);不设总 timeout——
|
||||
// reqwest 的 .timeout() 会限制整个响应 body 时长,流式长生成任务会被误砍。
|
||||
// 读取阶段的中途静默由上层 stream_llm 的 idle timeout 兜底。
|
||||
let client = Client::builder()
|
||||
.connect_timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.unwrap_or_else(|e| {
|
||||
warn!("reqwest builder 失败,降级为默认 client(无 connect_timeout): {}", e);
|
||||
Client::new()
|
||||
});
|
||||
// SW-260618-10: reqwest Client 构建抽 crate::build_provider_client(与 Anthropic 共用 DRY)。
|
||||
// connect_timeout/回退策略集中此处,未来改一处即可(见 lib.rs::build_provider_client)。
|
||||
let client = crate::build_provider_client();
|
||||
Self {
|
||||
client,
|
||||
api_key: api_key.into(),
|
||||
@@ -184,6 +85,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 格式
|
||||
fn convert_request(&self, req: CompletionRequest) -> OpenAiRequest {
|
||||
let model = if req.model.is_empty() {
|
||||
@@ -202,6 +115,43 @@ impl OpenAICompatProvider {
|
||||
crate::provider::MessageRole::Assistant => "assistant",
|
||||
crate::provider::MessageRole::Tool => "tool",
|
||||
};
|
||||
// F-260614-05 Phase 2a: 多模态 content(须在 move m.tool_calls 之前算,借用 m)。
|
||||
// 含图消息走 content 数组(text/image_url);纯文本走字符串简写
|
||||
// (保持与现有纯文本端点零回归)。image_url 支持 data URI(base64)与 http(s) URL。
|
||||
let content = if m.has_image() {
|
||||
let parts: Vec<serde_json::Value> = m
|
||||
.flattened_parts()
|
||||
.into_iter()
|
||||
.map(|p| match p {
|
||||
crate::provider::ContentPart::Text { text } => serde_json::json!({
|
||||
"type": "text",
|
||||
"text": text,
|
||||
}),
|
||||
crate::provider::ContentPart::Image { url, base64, media_type, alt: _ } => {
|
||||
let final_url = match (base64, url, media_type) {
|
||||
(Some(b), _, Some(mt)) => {
|
||||
format!("data:{};base64,{}", mt, b)
|
||||
}
|
||||
(None, Some(u), _) => u,
|
||||
// 完整性兜底:当前 image_base64 构造器强制 media_type:Some,
|
||||
// image_url 构造器提供 url:Some,二者分别命中上两个分支;
|
||||
// 此分支仅在 parts 来源被外部直接构造且字段均缺时才可达
|
||||
//(如 url:None+base64:None 或 base64:Some+media_type:None)。
|
||||
// 退化为空串(OpenAI 对空 image_url.url 会 400),
|
||||
// 由调用方保证 parts 合法性,provider 层不做静默伪造。
|
||||
_ => String::new(),
|
||||
};
|
||||
serde_json::json!({
|
||||
"type": "image_url",
|
||||
"image_url": { "url": final_url },
|
||||
})
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
serde_json::Value::Array(parts)
|
||||
} else {
|
||||
serde_json::Value::String(m.content.clone())
|
||||
};
|
||||
let tool_calls = m.tool_calls.map(|calls| {
|
||||
calls
|
||||
.into_iter()
|
||||
@@ -219,9 +169,10 @@ impl OpenAICompatProvider {
|
||||
});
|
||||
OpenAiMessage {
|
||||
role: role.to_string(),
|
||||
content: m.content,
|
||||
content,
|
||||
tool_call_id: m.tool_call_id,
|
||||
tool_calls,
|
||||
reasoning_content: m.reasoning_content,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -240,6 +191,13 @@ impl OpenAICompatProvider {
|
||||
stream: req.stream,
|
||||
tools,
|
||||
tool_choice: req.tool_choice,
|
||||
reasoning_content: req.reasoning_content,
|
||||
// 流式请求末 chunk 带 usage(同步调用 complete 不需要)
|
||||
stream_options: if req.stream {
|
||||
Some(serde_json::json!({ "include_usage": true }))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,6 +212,34 @@ impl OpenAICompatProvider {
|
||||
|
||||
#[async_trait]
|
||||
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> {
|
||||
let mut req = request;
|
||||
req.stream = false;
|
||||
@@ -261,48 +247,76 @@ impl LlmProvider for OpenAICompatProvider {
|
||||
|
||||
debug!(model = %openai_req.model, "OpenAI 同步调用");
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.post(self.chat_url())
|
||||
.header("Authorization", format!("Bearer {}", self.api_key))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&openai_req)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
error!(%status, %body, "LLM API 调用失败");
|
||||
anyhow::bail!("LLM API 错误 {}: {}", status, body);
|
||||
}
|
||||
|
||||
let body: OpenAiResponse = resp.json().await?;
|
||||
let choice = body
|
||||
.choices
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("LLM 响应无 choices"))?;
|
||||
|
||||
let text = choice.message.content.unwrap_or_default();
|
||||
let tool_calls = choice.message.tool_calls.map(Self::parse_tool_calls);
|
||||
|
||||
let usage = body.usage.map(|u| TokenUsage {
|
||||
prompt_tokens: u.prompt_tokens,
|
||||
completion_tokens: u.completion_tokens,
|
||||
total_tokens: u.total_tokens,
|
||||
}).unwrap_or(TokenUsage {
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
total_tokens: 0,
|
||||
});
|
||||
|
||||
Ok(CompletionResponse {
|
||||
text,
|
||||
model: body.model,
|
||||
usage,
|
||||
tool_calls,
|
||||
// 指数退避重试(B-260616-07): 包裹 send + 状态码判定。
|
||||
// 单请求 60s timeout 保持不变(FR-R4),重试是额外层: 3 次 × 60s 最坏 180s,
|
||||
// 由 retry_with_backoff 内部 30s 总预算主动止损。
|
||||
let label = format!("OpenAI[{}]", openai_req.model);
|
||||
retry_with_backoff(&label, move |_| {
|
||||
let client = self.client.clone();
|
||||
let url = self.chat_url();
|
||||
let api_key = self.api_key.clone();
|
||||
let openai_req = openai_req.clone();
|
||||
async move {
|
||||
// send
|
||||
let resp = client
|
||||
.post(url)
|
||||
.header("Authorization", format!("Bearer {}", api_key))
|
||||
.header("Content-Type", "application/json")
|
||||
.timeout(Duration::from_secs(60))
|
||||
.json(&openai_req)
|
||||
.send()
|
||||
.await;
|
||||
let resp = match resp {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
if is_reqwest_error_retryable(&e) {
|
||||
return AttemptOutcome::Retryable(format!("请求失败(可重试): {}", e));
|
||||
}
|
||||
return AttemptOutcome::Fatal(format!("请求失败(不可重试): {}", e));
|
||||
}
|
||||
};
|
||||
// 状态码判定
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status().as_u16();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
let msg = format!("LLM API 错误 {}: {}", status, body);
|
||||
if is_status_retryable(status) {
|
||||
warn!(%status, "OpenAI 同步调用可重试状态码");
|
||||
return AttemptOutcome::Retryable(msg);
|
||||
}
|
||||
error!(%status, %body, "LLM API 调用失败(不可重试)");
|
||||
return AttemptOutcome::Fatal(msg);
|
||||
}
|
||||
// body 解析: 解析错属 Fatal(响应已成功送达,重试也会因同样格式失败)
|
||||
let body: OpenAiResponse = match resp.json().await {
|
||||
Ok(b) => b,
|
||||
Err(e) => return AttemptOutcome::Fatal(format!("响应解析失败: {}", e)),
|
||||
};
|
||||
let choice = match body.choices.into_iter().next() {
|
||||
Some(c) => c,
|
||||
None => return AttemptOutcome::Fatal("LLM 响应无 choices".to_string()),
|
||||
};
|
||||
let text = choice.message.content.unwrap_or_default();
|
||||
let tool_calls = choice.message.tool_calls.map(Self::parse_tool_calls);
|
||||
let usage = body.usage.map(|u| TokenUsage {
|
||||
prompt_tokens: u.prompt_tokens,
|
||||
completion_tokens: u.completion_tokens,
|
||||
total_tokens: u.total_tokens,
|
||||
}).unwrap_or(TokenUsage {
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
total_tokens: 0,
|
||||
});
|
||||
AttemptOutcome::Ok(CompletionResponse {
|
||||
text,
|
||||
model: body.model,
|
||||
usage,
|
||||
tool_calls,
|
||||
reasoning_content: choice.message.reasoning_content,
|
||||
})
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn stream(&self, request: CompletionRequest) -> anyhow::Result<StreamResult> {
|
||||
@@ -328,68 +342,23 @@ impl LlmProvider for OpenAICompatProvider {
|
||||
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
|
||||
.bytes_stream()
|
||||
.eventsource()
|
||||
.map(move |event| {
|
||||
match event {
|
||||
Ok(event) => {
|
||||
// OpenAI 发送 "data: [DONE]" 表示流结束
|
||||
if event.data == "[DONE]" {
|
||||
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))
|
||||
}
|
||||
.map(move |event| match event {
|
||||
Ok(event) => Ok(apply_openai_sse(&event.data, &mut last_usage)),
|
||||
Err(e) => {
|
||||
// 保留 #[source] 因果链: anyhow!("...{}", e) 仅把 e 的 Display 塞进 message,
|
||||
// 丢掉 source(无法 downcast/遍历)。改用 Error::from(e).context(...):
|
||||
// Display 不变(仍为 "SSE 流错误: {e}"), 且 e 作为 .source() 可追溯。
|
||||
// 顺序: 先 format(e) 构造 context 文案, 再 Error::from(e) move e 进 source。
|
||||
let ctx = format!("SSE 流错误: {}", e);
|
||||
error!("{}", ctx);
|
||||
Err(anyhow::Error::from(e).context(ctx))
|
||||
}
|
||||
});
|
||||
|
||||
@@ -400,11 +369,229 @@ impl LlmProvider for OpenAICompatProvider {
|
||||
&self.default_model
|
||||
}
|
||||
|
||||
fn supported_features(&self) -> ProviderFeatures {
|
||||
ProviderFeatures {
|
||||
streaming: true,
|
||||
function_calling: true,
|
||||
vision: false,
|
||||
}
|
||||
fn endpoint(&self) -> String {
|
||||
self.chat_url()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 单测(不发真实 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);
|
||||
}
|
||||
|
||||
// ---------- F-260614-05 Phase 2a 多模态 convert_request ----------
|
||||
|
||||
/// 含图消息 → content 数组(text + image_url data URI);纯文本 → 字符串简写
|
||||
#[test]
|
||||
fn openai_convert_multimodal_content() {
|
||||
let provider = OpenAICompatProvider::new("https://api.openai.com", "k", "gpt-4o");
|
||||
let req = CompletionRequest {
|
||||
model: "gpt-4o".into(),
|
||||
messages: vec![ChatMessage::user_parts(
|
||||
"看图",
|
||||
vec![
|
||||
crate::provider::ContentPart::image_base64("image/png", "iVBOR"),
|
||||
crate::provider::ContentPart::image_url("https://x/a.png"),
|
||||
],
|
||||
)],
|
||||
temperature: None,
|
||||
max_tokens: None,
|
||||
stream: false,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
let out = provider.convert_request(req);
|
||||
let msg = &out.messages[0];
|
||||
// content 是数组:[text "看图", image_url(data URI), image_url(http url)]
|
||||
let arr = msg.content.as_array().expect("含图 → content 数组");
|
||||
assert_eq!(arr.len(), 3);
|
||||
assert_eq!(arr[0]["type"], "text");
|
||||
assert_eq!(arr[0]["text"], "看图");
|
||||
assert_eq!(arr[1]["type"], "image_url");
|
||||
assert_eq!(
|
||||
arr[1]["image_url"]["url"],
|
||||
"data:image/png;base64,iVBOR"
|
||||
);
|
||||
assert_eq!(arr[2]["image_url"]["url"], "https://x/a.png");
|
||||
}
|
||||
|
||||
/// 纯文本消息 → content 仍是字符串简写(无图不数组化,对齐纯文本端点兼容)
|
||||
#[test]
|
||||
fn openai_convert_text_only_remains_string() {
|
||||
let provider = OpenAICompatProvider::new("https://api.openai.com", "k", "gpt-4o");
|
||||
let req = CompletionRequest {
|
||||
model: "gpt-4o".into(),
|
||||
messages: vec![ChatMessage::user("hello")],
|
||||
temperature: None,
|
||||
max_tokens: None,
|
||||
stream: false,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
let out = provider.convert_request(req);
|
||||
let msg = &out.messages[0];
|
||||
assert_eq!(msg.content, serde_json::Value::String("hello".into()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
//! OpenAI 兼容协议适配 — 协议数据结构与 SSE 事件解析(纯函数)。
|
||||
//!
|
||||
//! 本模块从 `openai_compat.rs` 抽离,承载与 HTTP 无关的纯协议逻辑:
|
||||
//! - 请求/响应结构体(`OpenAiRequest` / `OpenAiResponse` / `OpenAiStreamChunk` 等)
|
||||
//! - SSE 事件 data → `StreamChunk` 转换纯函数(`apply_openai_sse`)
|
||||
//!
|
||||
//! Provider struct + impl(含 HTTP 调用 / `convert_request` / `chat_url`)仍留在
|
||||
//! `openai_compat.rs`,Rust impl 块不可跨文件,故仅搬迁 impl 块外部的类型/纯函数。
|
||||
//! 零行为变更(纯搬迁)。结构对齐 `anthropic_helpers.rs`。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::debug;
|
||||
|
||||
use crate::provider::{StreamChunk, TokenUsage, ToolCallDelta};
|
||||
|
||||
// ============================================================
|
||||
// OpenAI API 请求/响应结构体
|
||||
// ============================================================
|
||||
|
||||
/// OpenAI 兼容请求体
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub(crate) struct OpenAiRequest {
|
||||
pub model: String,
|
||||
pub messages: Vec<OpenAiMessage>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub temperature: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_tokens: Option<u32>,
|
||||
pub stream: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tools: Option<Vec<serde_json::Value>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_choice: Option<serde_json::Value>,
|
||||
/// 流式时请求末 chunk 携带 usage(OpenAI 官方 + DeepSeek/GLM 兼容)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stream_options: Option<serde_json::Value>,
|
||||
/// DeepSeek thinking 模式
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_content: Option<String>,
|
||||
}
|
||||
|
||||
/// OpenAI 消息格式
|
||||
///
|
||||
/// `content` 为 `serde_json::Value`:纯文本消息走字符串简写(与老端点兼容),
|
||||
/// 含图消息走 `[{type:"text",text},{type:"image_url",image_url:{url}}]` 数组。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct OpenAiMessage {
|
||||
pub role: String,
|
||||
pub content: serde_json::Value,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_call_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_calls: Option<Vec<serde_json::Value>>,
|
||||
/// DeepSeek thinking 模式推理内容
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_content: Option<String>,
|
||||
}
|
||||
|
||||
/// OpenAI 同步响应
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct OpenAiResponse {
|
||||
pub choices: Vec<OpenAiChoice>,
|
||||
pub model: String,
|
||||
pub usage: Option<OpenAiUsage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct OpenAiChoice {
|
||||
pub message: OpenAiMessageResp,
|
||||
// SW-260618-24: OpenAI 响应反序列化字段,保留以备调试/未来消费(如日志记录调用终止原因),标注意图消除 dead_code warning
|
||||
#[allow(dead_code)]
|
||||
pub finish_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct OpenAiMessageResp {
|
||||
pub content: Option<String>,
|
||||
pub tool_calls: Option<Vec<OpenAiToolCallResp>>,
|
||||
/// DeepSeek thinking 模式响应中的推理内容
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_content: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct OpenAiToolCallResp {
|
||||
pub id: String,
|
||||
// SW-260618-24: 反序列化 #[serde(rename="type")] 字段,保留以对齐 OpenAI 响应结构,标注意图消除 dead_code warning
|
||||
#[allow(dead_code)]
|
||||
#[serde(rename = "type")]
|
||||
pub call_type: String,
|
||||
pub function: OpenAiFunctionResp,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct OpenAiFunctionResp {
|
||||
pub name: String,
|
||||
pub arguments: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct OpenAiUsage {
|
||||
pub prompt_tokens: u32,
|
||||
pub completion_tokens: u32,
|
||||
pub total_tokens: u32,
|
||||
}
|
||||
|
||||
/// SSE 流式响应 chunk
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct OpenAiStreamChunk {
|
||||
pub choices: Vec<OpenAiStreamChoice>,
|
||||
/// 末 chunk(choices 为空)携带的累计 usage
|
||||
#[serde(default)]
|
||||
pub usage: Option<OpenAiUsage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct OpenAiStreamChoice {
|
||||
pub delta: OpenAiStreamDelta,
|
||||
pub finish_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct OpenAiStreamDelta {
|
||||
pub content: Option<String>,
|
||||
pub tool_calls: Option<Vec<OpenAiStreamToolCall>>,
|
||||
/// DeepSeek thinking 模式流式推理内容
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_content: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct OpenAiStreamToolCall {
|
||||
pub index: u32,
|
||||
pub id: Option<String>,
|
||||
pub function: Option<OpenAiStreamFunction>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct OpenAiStreamFunction {
|
||||
pub name: Option<String>,
|
||||
pub 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(),
|
||||
error: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
error: None,
|
||||
reasoning_content: choice.delta.reasoning_content,
|
||||
}
|
||||
} else {
|
||||
// choices 为空 = usage-only chunk,不输出文本(usage 已累积)
|
||||
StreamChunk {
|
||||
delta: String::new(),
|
||||
finished: false,
|
||||
tool_calls: None,
|
||||
usage: None,
|
||||
error: None,
|
||||
reasoning_content: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("SSE 数据解析失败: {} — data: {}", e, data);
|
||||
StreamChunk {
|
||||
delta: String::new(),
|
||||
finished: false,
|
||||
tool_calls: None,
|
||||
usage: None,
|
||||
error: None,
|
||||
reasoning_content: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,708 @@
|
||||
//! 单对话并行多轮 — Phase 0a · 0.8 plan_hint 纯函数
|
||||
//!
|
||||
//! 依据:`docs/02-架构设计/单对话并行多轮-Phase0落地路线图-2026-06-20.md` 第 0.8 节。
|
||||
//! 上游设计:`单对话并行多轮-设计-2026-06-20.md`。
|
||||
//!
|
||||
//! ## 定位
|
||||
//! Phase 0a 的**纯函数地基**:基于用户意图(`crate::intent::Intent`)/对话上下文关键词,
|
||||
//! 产出一份「编排提示」(`PlanHint`)——告诉主 loop「这批工具调用可以并行」/「这些有
|
||||
//! 顺序依赖」。它**扩 `intent::tool_subset_for` 语义**:后者只返扁平 tool 子集,本模块
|
||||
//! 在其之上叠加「并行组 / 顺序依赖」的编排关系。
|
||||
//!
|
||||
//! ## 纯函数边界(零 IO 零状态)
|
||||
//! - 无 `.await` / 无 db / 无 provider / 无全局状态。
|
||||
//! - 输入:意图标签 + 上下文关键词(纯字符串切片)。
|
||||
//! - 输出:`PlanHint`(数据结构 + 启发式算法产物)。
|
||||
//! - **不调 LLM**:LLM 调度(plan_hint 落 prompt 让模型产 Plan)留 Phase 2;本模块是
|
||||
//! Phase 1 主 loop 接入前的「确定性降级路径」——主 loop 拿到 PlanHint 直接做编排,
|
||||
//! 无需等 Phase 2 LLM 介入也能跑通串行/并行调度。
|
||||
//!
|
||||
//! ## 与 0.7 planner.rs 的关系
|
||||
//! 本 agent 独立先做(避免等 agent A),用**自有轻量 `PlanHint` 结构**。
|
||||
//! - `PlanHint` 是「提示」层(启发式,可错,主 loop 可忽略);
|
||||
//! - `Plan`(`planner.rs`,agent A 出)是「计划」层(LLM/用户确认后的硬编排)。
|
||||
//! Phase 1 接入主 loop 时,两者字段(group/dep)对齐,主 loop 优先消费 `Plan`,无 `Plan`
|
||||
//! 时退 `PlanHint`。结构对齐工作放 Phase 1,本批互不阻塞。
|
||||
//!
|
||||
//! ## feature flag
|
||||
//! `PLAN_HINT_ENABLED` 常量占位。关时单链 ReAct=旧行为(主 loop 不读 PlanHint)。
|
||||
//! Phase 1 接入时主 loop 据 `PLAN_HINT_ENABLED` 决定是否走 plan_hint 分支。
|
||||
|
||||
// ---- feature flag 占位 ------------------------------------------------------
|
||||
|
||||
/// plan_hint 总开关。Phase 0a 先占位为 `true`(纯函数无副作用可单测)。
|
||||
/// Phase 1 接入主 loop 时,关闭则主 loop 不读 PlanHint,退单链 ReAct(旧行为)。
|
||||
/// 真正的运行时开关(配置驱动)留 Phase 1,本批用编译期常量便于单测固定行为。
|
||||
pub const PLAN_HINT_ENABLED: bool = true;
|
||||
|
||||
// ---- 数据结构 --------------------------------------------------------------
|
||||
|
||||
/// 并行组:一组可同时调度的工具名(组内无顺序约束,可并行执行)。
|
||||
///
|
||||
/// 工具名对齐 `intent::tool_subset_for` 返回的工具名常量(read_file/write_file/...,
|
||||
/// 见 `intent::ToolDomain::tools`)。空组无意义(构造时应保证非空,但 validate 兜底)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ParallelGroup {
|
||||
/// 组内工具名列表(均为 `intent` crate 内声明的注册名)。
|
||||
pub tools: Vec<&'static str>,
|
||||
}
|
||||
|
||||
impl ParallelGroup {
|
||||
/// 构造一个并行组。空 tools 允许构造(validate 阶段标记非法),调用方应保证非空。
|
||||
pub fn new(tools: Vec<&'static str>) -> Self {
|
||||
Self { tools }
|
||||
}
|
||||
|
||||
/// 组内工具数。
|
||||
pub fn len(&self) -> usize {
|
||||
self.tools.len()
|
||||
}
|
||||
|
||||
/// 是否为空组。
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.tools.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// 顺序依赖边:`from` 必须先于 `to` 完成(A → B)。
|
||||
///
|
||||
/// 用于表达「读后写」「先查后改」等强顺序约束。`from`/`to` 是工具名
|
||||
/// (或更细的工具调用标签,Phase 2 LLM 产 Plan 时可携带调用 id;本批仅工具名粒度)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SequentialDep {
|
||||
/// 前置工具(先执行)。
|
||||
pub from: &'static str,
|
||||
/// 后置工具(待 from 完成后执行)。
|
||||
pub to: &'static str,
|
||||
}
|
||||
|
||||
impl SequentialDep {
|
||||
/// 构造一条顺序依赖边。
|
||||
pub fn new(from: &'static str, to: &'static str) -> Self {
|
||||
Self { from, to }
|
||||
}
|
||||
}
|
||||
|
||||
/// 编排提示(轻量 PlanHint)。
|
||||
///
|
||||
/// - `groups`:可并行执行的分组(每组内工具无序,可同时调度)。
|
||||
/// - `sequence`:跨组的顺序依赖边(DAG 边)。
|
||||
///
|
||||
/// **合法 PlanHint 约束**(与 0.7 Kahn 拓扑对齐):
|
||||
/// 1. `groups` 内各组 `tools` 非空;
|
||||
/// 2. `sequence` 不含自环(`from != to`);
|
||||
/// 3. `sequence` 无重复边(去重);
|
||||
/// 4. `sequence` 构成的有向图**无环**(DAG)——`validate` 用 Kahn 判定。
|
||||
///
|
||||
/// validate 失败时主 loop 应**丢弃** PlanHint 退单链(不能据此调度,否则死锁/无限循环)。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct PlanHint {
|
||||
/// 并行组列表。空 `Vec` 表示「无并行提示」(单链或全顺序)。
|
||||
pub groups: Vec<ParallelGroup>,
|
||||
/// 顺序依赖边列表。空 `Vec` 表示「无跨组顺序约束」(组间全并行)。
|
||||
pub sequence: Vec<SequentialDep>,
|
||||
}
|
||||
|
||||
impl PlanHint {
|
||||
/// 空提示(无并行、无顺序)。主 loop 见到此值应退旧行为。
|
||||
pub fn empty() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// 是否为空提示(无任何编排信息)。
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.groups.is_empty() && self.sequence.is_empty()
|
||||
}
|
||||
|
||||
/// 工具总数(所有并行组工具去重后的并集大小)。
|
||||
pub fn tool_count(&self) -> usize {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for g in &self.groups {
|
||||
for t in &g.tools {
|
||||
seen.insert(*t);
|
||||
}
|
||||
}
|
||||
seen.len()
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 校验(Kahn 拓扑判 DAG 无环,对齐 0.7) --------------------------------
|
||||
|
||||
/// PlanHint 合法性校验结果。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum HintValidity {
|
||||
/// 合法,可交付主 loop 调度。
|
||||
Ok,
|
||||
/// 非法(附原因)。主 loop 应丢弃 PlanHint 退单链。
|
||||
Invalid(&'static str),
|
||||
}
|
||||
|
||||
/// 校验 PlanHint 合法性(纯函数)。
|
||||
///
|
||||
/// 检查四项(见 `PlanHint` 文档约束):
|
||||
/// 1. 组内非空;
|
||||
/// 2. 无自环(`from != to`);
|
||||
/// 3. 无重复边;
|
||||
/// 4. **Kahn 拓扑判无环**(与 0.7 `Plan::validate` 同算法,这里在 PlanHint 层独立实现,
|
||||
/// 避免依赖 agent A 的 planner.rs;Phase 1 接入时若 planner 已稳定可抽公共函数)。
|
||||
///
|
||||
/// 返回 `Invalid(reason)` 时 reason 是静态原因标签(便于日志/断言)。
|
||||
pub fn validate(hint: &PlanHint) -> HintValidity {
|
||||
// 1. 组内非空
|
||||
for g in &hint.groups {
|
||||
if g.tools.is_empty() {
|
||||
return HintValidity::Invalid("empty_parallel_group");
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 无自环
|
||||
for d in &hint.sequence {
|
||||
if d.from == d.to {
|
||||
return HintValidity::Invalid("self_loop_dep");
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 无重复边(同 from→to 不重复)
|
||||
let mut seen_edges = std::collections::HashSet::new();
|
||||
for d in &hint.sequence {
|
||||
if !seen_edges.insert((d.from, d.to)) {
|
||||
return HintValidity::Invalid("duplicate_dep_edge");
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Kahn 拓扑判无环
|
||||
if has_cycle(&hint.sequence) {
|
||||
return HintValidity::Invalid("cyclic_dep");
|
||||
}
|
||||
|
||||
HintValidity::Ok
|
||||
}
|
||||
|
||||
/// Kahn 算法判有向边集合是否含环(纯函数)。
|
||||
///
|
||||
/// 输入是 `SequentialDep` 边列表,节点是工具名(`&'static str`)。
|
||||
/// 返回 `true` = 有环(非法),`false` = DAG(合法)。
|
||||
///
|
||||
/// 实现:统计入度 → 入度 0 的节点入队 → BFS 出队并削减邻居入度 →
|
||||
/// 最终已出队节点数 < 总节点数 → 存在环。
|
||||
fn has_cycle(edges: &[SequentialDep]) -> bool {
|
||||
if edges.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 收集所有节点(去重)。
|
||||
let mut nodes: std::collections::HashSet<&'static str> = std::collections::HashSet::new();
|
||||
for d in edges {
|
||||
nodes.insert(d.from);
|
||||
nodes.insert(d.to);
|
||||
}
|
||||
let n = nodes.len();
|
||||
if n == 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 节点 → 索引(便于用 Vec 计数)。
|
||||
let mut idx: std::collections::HashMap<&'static str, usize> =
|
||||
std::collections::HashMap::with_capacity(n);
|
||||
for (i, name) in nodes.iter().enumerate() {
|
||||
idx.insert(*name, i);
|
||||
}
|
||||
|
||||
// 入度表 + 邻接表。
|
||||
let mut indeg = vec![0usize; n];
|
||||
let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
|
||||
for d in edges {
|
||||
let u = idx[&d.from];
|
||||
let v = idx[&d.to];
|
||||
adj[u].push(v);
|
||||
indeg[v] += 1;
|
||||
}
|
||||
|
||||
// BFS from 入度 0 节点。
|
||||
let mut queue: std::collections::VecDeque<usize> = std::collections::VecDeque::new();
|
||||
for (i, &d) in indeg.iter().enumerate() {
|
||||
if d == 0 {
|
||||
queue.push_back(i);
|
||||
}
|
||||
}
|
||||
|
||||
let mut visited = 0usize;
|
||||
while let Some(u) = queue.pop_front() {
|
||||
visited += 1;
|
||||
for &v in &adj[u] {
|
||||
indeg[v] -= 1;
|
||||
if indeg[v] == 0 {
|
||||
queue.push_back(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 访问到的节点数 < 总节点数 → 存在环(有节点入度永远不为 0)。
|
||||
visited < n
|
||||
}
|
||||
|
||||
// ---- 启发式产 PlanHint(纯函数,无 LLM) -----------------------------------
|
||||
|
||||
/// 输入:意图标签(来自 `intent::recognize`)+ 上下文关键词(原始用户消息)。
|
||||
///
|
||||
/// 关键词检测用 `str::contains`(中英文都支持,与 `intent::recognize` 一致风格)。
|
||||
/// 输出基于**确定性启发式规则**(非 LLM),规则覆盖 devflow 常见编排场景:
|
||||
///
|
||||
/// | 场景关键词 | 产出 |
|
||||
/// |---|---|
|
||||
/// | 读多个 + 写后改 | [读组 并行] → write_file/patch_file |
|
||||
/// | grep/find 后改 | search_files → write_file/patch_file |
|
||||
/// | http + 写文件 | http_request → write_file(先取再存) |
|
||||
/// | 仅读多文件 | 全并行单组 |
|
||||
/// | 单工具 / 无编排信号 | 空 PlanHint(主 loop 退单链) |
|
||||
///
|
||||
/// **重要**:这是降级路径(Phase 2 前)。LLM 产 Plan 的质量一定高于本启发式,
|
||||
/// 但本函数保证 Phase 1 主 loop 接入后**无 LLM 也能跑通基本编排**(读后写/grep 后改)。
|
||||
///
|
||||
/// `intent_label` 仅为日志/未来扩展预留(当前规则用关键词驱动,intent_label 不参与判定,
|
||||
/// 保留参数对齐 Phase 1 主 loop 调用签名:`plan_hint(&intent, &message)`)。
|
||||
pub fn plan_hint(intent_label: &str, context_keywords: &str) -> PlanHint {
|
||||
// 开关关闭:直接返空(主 loop 退单链)。Phase 1 接入时主 loop 据常量短路,这里也兜底。
|
||||
if !PLAN_HINT_ENABLED {
|
||||
return PlanHint::empty();
|
||||
}
|
||||
|
||||
let _ = intent_label; // 预留:当前规则纯关键词驱动,不读 intent。
|
||||
|
||||
let ctx = context_keywords;
|
||||
let lower = ctx.to_lowercase();
|
||||
|
||||
// ---- 规则 1:grep/搜索 后改 → search_files → write_file/patch_file ----
|
||||
let is_search = lower.contains("grep")
|
||||
|| lower.contains("搜索")
|
||||
|| lower.contains("查找")
|
||||
|| lower.contains("find ");
|
||||
let is_modify = lower.contains("修改")
|
||||
|| lower.contains("改")
|
||||
|| lower.contains("write")
|
||||
|| lower.contains("patch")
|
||||
|| lower.contains("写入")
|
||||
|| lower.contains("重命名")
|
||||
|| lower.contains("rename");
|
||||
if is_search && is_modify {
|
||||
return PlanHint {
|
||||
groups: vec![
|
||||
ParallelGroup::new(vec!["search_files"]),
|
||||
ParallelGroup::new(vec!["write_file", "patch_file", "rename_file"]),
|
||||
],
|
||||
sequence: vec![SequentialDep::new("search_files", "write_file")],
|
||||
};
|
||||
}
|
||||
|
||||
// ---- 规则 2:http 取数 + 写文件 → http_request → write_file ----
|
||||
let is_http = lower.contains("http")
|
||||
|| lower.contains("api")
|
||||
|| lower.contains("请求")
|
||||
|| lower.contains("fetch");
|
||||
if is_http && (lower.contains("保存") || lower.contains("写入") || lower.contains("write")) {
|
||||
return PlanHint {
|
||||
groups: vec![
|
||||
ParallelGroup::new(vec!["http_request"]),
|
||||
ParallelGroup::new(vec!["write_file"]),
|
||||
],
|
||||
sequence: vec![SequentialDep::new("http_request", "write_file")],
|
||||
};
|
||||
}
|
||||
|
||||
// ---- 规则 3:读多文件(无写) → 全并行单组 ----
|
||||
let read_count = [
|
||||
("read_file", lower.matches("read").count() + lower.matches("读取").count()),
|
||||
("list_directory", lower.matches("目录").count() + lower.matches("directory").count()),
|
||||
]
|
||||
.iter()
|
||||
.filter(|(_, c)| *c >= 1)
|
||||
.count();
|
||||
if read_count >= 2 && !is_modify {
|
||||
// 多种读工具信号 → 单并行组(可同时调度)
|
||||
let mut tools: Vec<&'static str> = Vec::new();
|
||||
if lower.contains("read") || lower.contains("读取") {
|
||||
tools.push("read_file");
|
||||
}
|
||||
if lower.contains("目录") || lower.contains("directory") {
|
||||
tools.push("list_directory");
|
||||
}
|
||||
if tools.len() >= 2 {
|
||||
return PlanHint {
|
||||
groups: vec![ParallelGroup::new(tools)],
|
||||
sequence: vec![],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 规则 4:读后写 → read_file → write_file/patch_file ----
|
||||
let is_read = lower.contains("read") || lower.contains("读取");
|
||||
if is_read && is_modify {
|
||||
return PlanHint {
|
||||
groups: vec![
|
||||
ParallelGroup::new(vec!["read_file"]),
|
||||
ParallelGroup::new(vec!["write_file", "patch_file"]),
|
||||
],
|
||||
sequence: vec![SequentialDep::new("read_file", "write_file")],
|
||||
};
|
||||
}
|
||||
|
||||
// ---- 兜底:无编排信号 → 空 PlanHint(主 loop 退单链) ----
|
||||
PlanHint::empty()
|
||||
}
|
||||
|
||||
// ---- 单测 -------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// --- feature flag 占位 ---
|
||||
|
||||
#[test]
|
||||
fn plan_hint_enabled_is_true_in_phase0a() {
|
||||
// Phase 0a 占位为 true,纯函数可单测。Phase 1 接入后改配置驱动。
|
||||
assert!(PLAN_HINT_ENABLED, "Phase 0a 占位应为 true");
|
||||
}
|
||||
|
||||
// --- 数据结构基本 ---
|
||||
|
||||
#[test]
|
||||
fn parallel_group_new_and_len() {
|
||||
let g = ParallelGroup::new(vec!["read_file", "write_file"]);
|
||||
assert_eq!(g.len(), 2);
|
||||
assert!(!g.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_group_empty_construct_allowed_but_invalid() {
|
||||
// 空 tools 允许构造(调用方失误),validate 阶段标记非法
|
||||
let g = ParallelGroup::new(vec![]);
|
||||
assert!(g.is_empty());
|
||||
assert_eq!(g.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequential_dep_new() {
|
||||
let d = SequentialDep::new("read_file", "write_file");
|
||||
assert_eq!(d.from, "read_file");
|
||||
assert_eq!(d.to, "write_file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_hint_empty_is_empty() {
|
||||
let h = PlanHint::empty();
|
||||
assert!(h.is_empty());
|
||||
assert_eq!(h.tool_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_hint_tool_count_dedup_across_groups() {
|
||||
// 两组含同名工具 → tool_count 去重
|
||||
let h = PlanHint {
|
||||
groups: vec![
|
||||
ParallelGroup::new(vec!["read_file", "write_file"]),
|
||||
ParallelGroup::new(vec!["read_file", "patch_file"]),
|
||||
],
|
||||
sequence: vec![],
|
||||
};
|
||||
// read_file/write_file/patch_file = 3(去重)
|
||||
assert_eq!(h.tool_count(), 3);
|
||||
}
|
||||
|
||||
// --- plan_hint 基本编排提示 ---
|
||||
|
||||
#[test]
|
||||
fn hint_read_then_modify_yields_sequence() {
|
||||
// 规则 4:读后写 → read_file → write_file
|
||||
let h = plan_hint("code", "先 read 这个文件再修改它");
|
||||
assert!(!h.is_empty(), "读后写应产非空 hint");
|
||||
assert_eq!(h.groups.len(), 2, "应分两组(读组/改组)");
|
||||
assert!(h.sequence.iter().any(|d| d.from == "read_file" && d.to == "write_file"),
|
||||
"应含 read_file→write_file 依赖边");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_grep_then_modify_yields_search_first() {
|
||||
// 规则 1:grep 后改 → search_files → write_file
|
||||
let h = plan_hint("search", "grep 一下再修改 write_file");
|
||||
assert!(!h.is_empty());
|
||||
// 首组应是 search_files
|
||||
assert!(h.groups[0].tools.contains(&"search_files"));
|
||||
assert!(h.sequence.iter().any(|d| d.from == "search_files"),
|
||||
"search_files 应作为顺序依赖起点");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_http_then_save_yields_http_first() {
|
||||
// 规则 2:http + 保存 → http_request → write_file
|
||||
let h = plan_hint("http", "请求这个 api 并保存写入到文件");
|
||||
assert!(!h.is_empty());
|
||||
assert!(h.sequence.iter().any(|d| d.from == "http_request" && d.to == "write_file"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_multi_read_only_yields_single_parallel_group() {
|
||||
// 规则 3:读多文件(无写)→ 单并行组
|
||||
let h = plan_hint("file", "read 文件,再看一下 directory 目录");
|
||||
assert!(!h.is_empty());
|
||||
assert_eq!(h.groups.len(), 1, "应单并行组");
|
||||
let tools = &h.groups[0].tools;
|
||||
assert!(tools.contains(&"read_file"), "组内应含 read_file");
|
||||
assert!(tools.contains(&"list_directory"), "组内应含 list_directory");
|
||||
assert!(h.sequence.is_empty(), "全并行无顺序依赖");
|
||||
}
|
||||
|
||||
// --- 并行组识别 ---
|
||||
|
||||
#[test]
|
||||
fn parallel_group_tools_are_registry_names() {
|
||||
// 对抗:产出的工具名必须在 intent ToolDomain 注册表内(防笔误)。
|
||||
// 注:intent::ToolDomain::tools() 是 private 方法(同模块 intent.rs 内单测可用,
|
||||
// 跨模块不可访问)。本测用从 intent.rs:246-284 抄录的 registry 名常量做校验,
|
||||
// 不依赖私有方法访问(保持 plan_hint.rs 独立可单测)。若 registry 名漂移,
|
||||
// intent.rs 的 tool_domain_names_match_registry 测会先抓到,本测同步对齐。
|
||||
let registry: std::collections::HashSet<&str> = [
|
||||
// Data domain
|
||||
"list_projects", "create_project", "update_project", "delete_project",
|
||||
"bind_directory", "list_tasks", "create_task", "update_task", "advance_task",
|
||||
"delete_task", "list_ideas", "create_idea", "run_workflow", "list_trash",
|
||||
"restore_project", "purge_project", "get_project_count", "get_task_count",
|
||||
// File domain
|
||||
"read_file", "write_file", "patch_file", "list_directory", "search_files",
|
||||
"file_info", "append_file", "delete_file", "rename_file",
|
||||
// Exec domain
|
||||
"run_command",
|
||||
// Http domain
|
||||
"http_request",
|
||||
]
|
||||
.iter()
|
||||
.copied()
|
||||
.collect();
|
||||
let h = plan_hint("file", "read 再读取目录 directory");
|
||||
if !h.is_empty() {
|
||||
for g in &h.groups {
|
||||
for t in &g.tools {
|
||||
assert!(registry.contains(*t), "工具名 {} 不在 registry", t);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_groups_tools_non_empty_when_non_empty_hint() {
|
||||
// 非空 hint 的每组都应非空(由 plan_hint 产出保证,validate 兜底)
|
||||
let h = plan_hint("code", "read 后 write_file 修改");
|
||||
if !h.is_empty() {
|
||||
for g in &h.groups {
|
||||
assert!(!g.tools.is_empty(), "产出组的 tools 不应空");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 顺序依赖 ---
|
||||
|
||||
#[test]
|
||||
fn hint_sequence_edges_from_less_than_to() {
|
||||
// 顺序依赖边 from/to 都是合法工具名(from 先于 to)
|
||||
let h = plan_hint("code", "read 后修改 write_file");
|
||||
for d in &h.sequence {
|
||||
assert_ne!(d.from, d.to, "依赖边不应自环");
|
||||
assert!(!d.from.is_empty());
|
||||
assert!(!d.to.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_no_duplicate_sequence_edge() {
|
||||
// 产出的依赖边不应重复
|
||||
let h = plan_hint("code", "read 后 write_file 修改");
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for d in &h.sequence {
|
||||
assert!(seen.insert((d.from, d.to)), "重复依赖边: {}→{}", d.from, d.to);
|
||||
}
|
||||
}
|
||||
|
||||
// --- 空 hint ---
|
||||
|
||||
#[test]
|
||||
fn hint_empty_for_plain_chat() {
|
||||
// 无编排信号 → 空 PlanHint
|
||||
let h = plan_hint("chat", "你好谢谢");
|
||||
assert!(h.is_empty(), "闲聊应产空 hint, 退单链");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_empty_for_unknown_intent_no_signal() {
|
||||
let h = plan_hint("unknown", "今天的天气不错");
|
||||
assert!(h.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_empty_when_flag_off() {
|
||||
// 开关关闭 → 恒空(主 loop 退单链)。即使有强编排信号也忽略。
|
||||
// 注:PLAN_HINT_ENABLED 是编译期常量,本测在 true 时验证 plan_hint 内部
|
||||
// 短路逻辑(若常量改 false,此测会失败提醒对齐)。
|
||||
// 用单工具避免命中多工具规则。
|
||||
let h = plan_hint("file", "read");
|
||||
// 单 read(不命中规则 3 需 ≥2 读工具,不命中规则 4 需 modify)→ 空
|
||||
assert!(h.is_empty(), "单工具无编排信号应空 hint");
|
||||
}
|
||||
|
||||
// --- validate 合法性 ---
|
||||
|
||||
#[test]
|
||||
fn validate_empty_hint_is_ok() {
|
||||
let h = PlanHint::empty();
|
||||
assert_eq!(validate(&h), HintValidity::Ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_normal_dag_is_ok() {
|
||||
// read_file → write_file(单边 DAG)合法
|
||||
let h = PlanHint {
|
||||
groups: vec![
|
||||
ParallelGroup::new(vec!["read_file"]),
|
||||
ParallelGroup::new(vec!["write_file"]),
|
||||
],
|
||||
sequence: vec![SequentialDep::new("read_file", "write_file")],
|
||||
};
|
||||
assert_eq!(validate(&h), HintValidity::Ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_empty_parallel_group_is_invalid() {
|
||||
let h = PlanHint {
|
||||
groups: vec![ParallelGroup::new(vec![])],
|
||||
sequence: vec![],
|
||||
};
|
||||
assert_eq!(validate(&h), HintValidity::Invalid("empty_parallel_group"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_self_loop_dep_is_invalid() {
|
||||
let h = PlanHint {
|
||||
groups: vec![
|
||||
ParallelGroup::new(vec!["read_file"]),
|
||||
ParallelGroup::new(vec!["write_file"]),
|
||||
],
|
||||
sequence: vec![SequentialDep::new("read_file", "read_file")],
|
||||
};
|
||||
assert_eq!(validate(&h), HintValidity::Invalid("self_loop_dep"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_duplicate_edge_is_invalid() {
|
||||
let h = PlanHint {
|
||||
groups: vec![
|
||||
ParallelGroup::new(vec!["read_file"]),
|
||||
ParallelGroup::new(vec!["write_file"]),
|
||||
],
|
||||
sequence: vec![
|
||||
SequentialDep::new("read_file", "write_file"),
|
||||
SequentialDep::new("read_file", "write_file"),
|
||||
],
|
||||
};
|
||||
assert_eq!(validate(&h), HintValidity::Invalid("duplicate_dep_edge"));
|
||||
}
|
||||
|
||||
// --- Kahn 环检测 ---
|
||||
|
||||
#[test]
|
||||
fn has_cycle_empty_edges_false() {
|
||||
assert!(!has_cycle(&[]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_cycle_single_edge_no_cycle() {
|
||||
let edges = vec![SequentialDep::new("a", "b")];
|
||||
assert!(!has_cycle(&edges));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_cycle_two_node_cycle_detected() {
|
||||
// a→b→a 环
|
||||
let edges = vec![
|
||||
SequentialDep::new("a", "b"),
|
||||
SequentialDep::new("b", "a"),
|
||||
];
|
||||
assert!(has_cycle(&edges), "a↔b 环应被检测");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_cycle_three_node_cycle_detected() {
|
||||
// a→b→c→a 环
|
||||
let edges = vec![
|
||||
SequentialDep::new("a", "b"),
|
||||
SequentialDep::new("b", "c"),
|
||||
SequentialDep::new("c", "a"),
|
||||
];
|
||||
assert!(has_cycle(&edges));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_cycle_dag_with_branch_no_cycle() {
|
||||
// a→b, a→c, b→d, c→d(菱形 DAG,无环)
|
||||
let edges = vec![
|
||||
SequentialDep::new("a", "b"),
|
||||
SequentialDep::new("a", "c"),
|
||||
SequentialDep::new("b", "d"),
|
||||
SequentialDep::new("c", "d"),
|
||||
];
|
||||
assert!(!has_cycle(&edges));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_cyclic_dep_is_invalid() {
|
||||
let h = PlanHint {
|
||||
groups: vec![
|
||||
ParallelGroup::new(vec!["read_file"]),
|
||||
ParallelGroup::new(vec!["write_file"]),
|
||||
],
|
||||
sequence: vec![
|
||||
SequentialDep::new("read_file", "write_file"),
|
||||
SequentialDep::new("write_file", "read_file"),
|
||||
],
|
||||
};
|
||||
assert_eq!(validate(&h), HintValidity::Invalid("cyclic_dep"));
|
||||
}
|
||||
|
||||
// --- 对抗/边界 ---
|
||||
|
||||
#[test]
|
||||
fn validate_check_order_self_loop_before_cycle() {
|
||||
// 自环也构成环,但 validate 应先报 self_loop_dep(更具体的错误)
|
||||
let h = PlanHint {
|
||||
groups: vec![ParallelGroup::new(vec!["a"])],
|
||||
sequence: vec![SequentialDep::new("a", "a")],
|
||||
};
|
||||
assert_eq!(validate(&h), HintValidity::Invalid("self_loop_dep"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_intent_label_ignored_in_phase0a() {
|
||||
// 当前规则纯关键词驱动,intent_label 不参与判定(预留 Phase 1)
|
||||
// 同关键词不同 intent_label → 同产出
|
||||
let h1 = plan_hint("code", "read 后修改 write_file");
|
||||
let h2 = plan_hint("debug", "read 后修改 write_file");
|
||||
assert_eq!(h1, h2, "Phase 0a 不读 intent_label, 产出应一致");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_single_tool_no_signal_is_empty() {
|
||||
// 单 read(无写无多读)→ 不命中任何规则 → 空
|
||||
let h = plan_hint("file", "读取文件");
|
||||
assert!(h.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_ignores_nodes_not_in_groups() {
|
||||
// validate 不检查 sequence 边引用的工具是否在 groups 内(Phase 0a 宽松,
|
||||
// 主 loop 自行对齐;Phase 1 接入 planner.rs 后由 Plan::validate 收口)
|
||||
let h = PlanHint {
|
||||
groups: vec![ParallelGroup::new(vec!["read_file"])],
|
||||
sequence: vec![SequentialDep::new("read_file", "write_file")],
|
||||
};
|
||||
// write_file 不在任何 group,但边合法且 DAG,validate 仍 Ok(宽松)
|
||||
assert_eq!(validate(&h), HintValidity::Ok);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+13
-205
@@ -1,207 +1,15 @@
|
||||
//! LLM Provider trait — 统一的 LLM 调用抽象
|
||||
//! LLM Provider trait + 数据结构 re-export
|
||||
//!
|
||||
//! 支持 OpenAI 兼容 API(覆盖 OpenAI / GLM / DeepSeek / Claude 兼容模式),
|
||||
//! 含 function calling / tool use 能力。
|
||||
//! trait 与数据结构定义已下沉到 df-ai-core crate(零 HTTP 依赖,轻消费方
|
||||
//! 如 df-ideas 可直接依赖 trait 不引入 reqwest/eventsource-stream)。
|
||||
//! 本文件保留为 df-ai 的 re-export 入口,使 `df_ai::provider::*` 路径不变
|
||||
//! —— df-ai 内部模块(openai_compat / anthropic_compat / context / ai_tools)
|
||||
//! 与外部消费方(df-nodes / src-tauri)的 `use df_ai::provider::LlmProvider`
|
||||
//! 等引用全部透明继续可用(编译期验证)。
|
||||
//!
|
||||
//! 留在 df-ai 的部分:
|
||||
//! - `OpenAICompatProvider` / `AnthropicCompatProvider`(HTTP impl,见 openai_compat.rs / anthropic_compat.rs)
|
||||
//! - `build_provider()` 工厂(见 lib.rs,按协议选 impl)
|
||||
//! - `ContextManager` / `AiToolRegistry` / `StreamCollector`(业务逻辑)
|
||||
|
||||
use std::pin::Pin;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures::Stream;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ============================================================
|
||||
// 核心数据结构
|
||||
// ============================================================
|
||||
|
||||
/// LLM 调用请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CompletionRequest {
|
||||
/// 模型名称
|
||||
pub model: String,
|
||||
/// 提示消息列表
|
||||
pub messages: Vec<ChatMessage>,
|
||||
/// 温度(0.0 ~ 2.0)
|
||||
pub temperature: Option<f32>,
|
||||
/// 最大生成 token 数
|
||||
pub max_tokens: Option<u32>,
|
||||
/// 是否流式输出
|
||||
pub stream: bool,
|
||||
/// 可调用的工具定义
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tools: Option<Vec<ToolDefinition>>,
|
||||
/// 工具调用策略: "auto" | "none" | {"type":"function","name":"xxx"}
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_choice: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// 聊天消息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatMessage {
|
||||
pub role: MessageRole,
|
||||
pub content: String,
|
||||
/// 工具调用 ID(role=Tool 时必填)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_call_id: Option<String>,
|
||||
/// AI 发起的工具调用列表(role=Assistant 时可能有)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_calls: Option<Vec<ToolCall>>,
|
||||
}
|
||||
|
||||
impl ChatMessage {
|
||||
pub fn system(content: impl Into<String>) -> Self {
|
||||
Self { role: MessageRole::System, content: content.into(), tool_call_id: None, tool_calls: None }
|
||||
}
|
||||
pub fn user(content: impl Into<String>) -> Self {
|
||||
Self { role: MessageRole::User, content: content.into(), tool_call_id: None, tool_calls: None }
|
||||
}
|
||||
pub fn assistant(content: impl Into<String>) -> Self {
|
||||
Self { role: MessageRole::Assistant, content: content.into(), tool_call_id: None, tool_calls: None }
|
||||
}
|
||||
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) }
|
||||
}
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
/// 消息角色
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum MessageRole {
|
||||
System,
|
||||
User,
|
||||
Assistant,
|
||||
Tool,
|
||||
}
|
||||
|
||||
/// 工具定义
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolDefinition {
|
||||
#[serde(rename = "type")]
|
||||
pub tool_type: String,
|
||||
pub function: ToolFunction,
|
||||
}
|
||||
|
||||
impl ToolDefinition {
|
||||
pub fn function(name: impl Into<String>, description: impl Into<String>, parameters: serde_json::Value) -> Self {
|
||||
Self {
|
||||
tool_type: "function".into(),
|
||||
function: ToolFunction { name: name.into(), description: description.into(), parameters },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 函数定义
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolFunction {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub parameters: serde_json::Value,
|
||||
}
|
||||
|
||||
/// 工具调用(AI 发起)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolCall {
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub call_type: String,
|
||||
pub function: ToolCallFunction,
|
||||
}
|
||||
|
||||
impl ToolCall {
|
||||
pub fn new(id: impl Into<String>, name: impl Into<String>, arguments: impl Into<String>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
call_type: "function".into(),
|
||||
function: ToolCallFunction { name: name.into(), arguments: arguments.into() },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 工具调用函数部分
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolCallFunction {
|
||||
pub name: String,
|
||||
pub arguments: String,
|
||||
}
|
||||
|
||||
/// LLM 调用响应
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CompletionResponse {
|
||||
/// 生成的文本
|
||||
pub text: String,
|
||||
/// 使用的模型
|
||||
pub model: String,
|
||||
/// 消耗的 token 数
|
||||
pub usage: TokenUsage,
|
||||
/// AI 发起的工具调用(如有)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_calls: Option<Vec<ToolCall>>,
|
||||
}
|
||||
|
||||
/// Token 用量
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TokenUsage {
|
||||
pub prompt_tokens: u32,
|
||||
pub completion_tokens: u32,
|
||||
pub total_tokens: u32,
|
||||
}
|
||||
|
||||
/// Provider 支持的特性标志
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ProviderFeatures {
|
||||
pub streaming: bool,
|
||||
pub function_calling: bool,
|
||||
pub vision: bool,
|
||||
}
|
||||
|
||||
/// 流式输出的 chunk
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StreamChunk {
|
||||
/// 增量文本
|
||||
pub delta: String,
|
||||
/// 是否结束
|
||||
pub finished: bool,
|
||||
/// 工具调用增量(如有)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_calls: Option<Vec<ToolCallDelta>>,
|
||||
}
|
||||
|
||||
/// 工具调用增量(流式中的片段)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolCallDelta {
|
||||
/// 索引
|
||||
pub index: u32,
|
||||
/// 工具调用 ID(仅第一个 chunk 有)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<String>,
|
||||
/// 函数名片段
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub function_name: Option<String>,
|
||||
/// 函数参数片段
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub function_arguments: Option<String>,
|
||||
}
|
||||
|
||||
/// 异步流类型别名
|
||||
pub type StreamResult = Pin<Box<dyn Stream<Item = anyhow::Result<StreamChunk>> + Send>>;
|
||||
|
||||
/// LLM Provider trait
|
||||
#[async_trait]
|
||||
pub trait LlmProvider: Send + Sync {
|
||||
/// 同步调用
|
||||
async fn complete(&self, request: CompletionRequest) -> anyhow::Result<CompletionResponse>;
|
||||
|
||||
/// 流式调用(返回异步流)
|
||||
async fn stream(
|
||||
&self,
|
||||
request: CompletionRequest,
|
||||
) -> anyhow::Result<StreamResult>;
|
||||
|
||||
/// Provider 名称
|
||||
fn name(&self) -> &str;
|
||||
|
||||
/// 支持的特性
|
||||
fn supported_features(&self) -> ProviderFeatures;
|
||||
}
|
||||
pub use df_ai_core::provider::*;
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
//! complete() 指数退避重试 — 仅非流式同步路径,不动 stream()
|
||||
//!
|
||||
//! 背景: complete() 是 LLM 一次性请求,网络波动/瞬态 5xx/429 会让整轮对话静默失败。
|
||||
//! 各调用方(title.rs/knowledge_inject.rs/project.rs/ai_node.rs)虽已有人工兜底降级,
|
||||
//! 但重试能在不侵入上层的前提下吸收瞬态错误,对低频后台调用零风险。
|
||||
//!
|
||||
//! 策略:
|
||||
//! - 最多 3 次尝试(初次 + 2 次重试),指数退避 1s → 2s → 4s + ±20% jitter
|
||||
//! - 总挂钟预算 30s(含 sleep + 各次请求耗时),超预算直接放弃重试返回最后错误
|
||||
//! - 仅对可重试错误重试: timeout / connect / 5xx / 429; 不重试 4xx(非429) / 参数错 / 鉴权错
|
||||
//! - 不引入 reqwest-retry / tower: complete 路径简单,手写 loop 零依赖、可调试、不与 SSE/双层 Semaphore 耦合
|
||||
//!
|
||||
//! 流式 stream() 路径不进入此模块(流式长生成不能整体 timeout,重试会丢增量)。
|
||||
//!
|
||||
//! 重试期间不释放 Semaphore permit(已在调用方持有),对并发池有挤占 —— 但 complete 调用低频可接受。
|
||||
|
||||
use std::future::Future;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use tracing::warn;
|
||||
|
||||
/// 最多尝试次数(含初次)。B-260616-07: 3 次 = 初次 + 2 次重试。
|
||||
///
|
||||
/// 配置化 TODO: 未来接入 per-provider config(`AiProviderRecord.config` JSON)或全局开关
|
||||
/// (`useSetting('df-ai-max-retries')`)时改为读取配置。当前低频后台调用,常量足够。
|
||||
pub const MAX_COMPLETE_ATTEMPTS: u32 = 3;
|
||||
|
||||
/// 退避基准(秒): 第 n 次重试前 sleep `BASE_BACKOFF_SECS * 2^(n-1)`。
|
||||
/// 即 1s → 2s → 4s。
|
||||
const BASE_BACKOFF_SECS: u64 = 1;
|
||||
|
||||
/// 整体重试挂钟预算(含 sleep 与各次请求耗时)。超过即放弃,返回最后错误。
|
||||
/// 与单请求 60s timeout 配合: 最坏 3 次 × 60s = 180s,30s 预算会在此之前主动止损。
|
||||
const MAX_TOTAL_BUDGET: Duration = Duration::from_secs(30);
|
||||
|
||||
/// jitter 上限(相对 base 的 ±比例)。避免重试风暴对齐。
|
||||
const JITTER_RATIO: f64 = 0.2;
|
||||
|
||||
/// 一次尝试的分类结果 —— 在 anyhow 不透明化前决定是否值得重试。
|
||||
///
|
||||
/// CR-30-1: 暴露给 agentic.rs 流前重试复用(Init 失败分类 Fatal/Retryable 决定是否重试)。
|
||||
#[derive(Debug)]
|
||||
pub enum AttemptOutcome<T> {
|
||||
/// 成功,携带结果。
|
||||
Ok(T),
|
||||
/// 可重试错误(timeout / connect / 5xx / 429)。携带人类可读错误描述。
|
||||
Retryable(String),
|
||||
/// 不可重试错误(4xx 非 429 / 鉴权 / 参数错 / 解析错)。携带描述,直接放弃。
|
||||
Fatal(String),
|
||||
}
|
||||
|
||||
/// 判定 reqwest::Error 是否可重试(仅 timeout / connect 类)。
|
||||
///
|
||||
/// body 解析错(reqwest::Error::is_decode)与请求构造错(is_builder)属 Fatal,不重试。
|
||||
pub fn is_reqwest_error_retryable(e: &reqwest::Error) -> bool {
|
||||
e.is_timeout() || e.is_connect() || (e.is_request() && !e.is_builder())
|
||||
}
|
||||
|
||||
/// 判定 HTTP 状态码是否可重试: 5xx 与 429 重试,其余(含 4xx)不重试。
|
||||
///
|
||||
/// CR-30-1: 暴露给 agentic.rs 流前重试复用(4xx Fatal 立即放弃,5xx/429 重试)。
|
||||
pub fn is_status_retryable(status: u16) -> bool {
|
||||
status == 429 || (500..600).contains(&status)
|
||||
}
|
||||
|
||||
/// 指数退避 + jitter: 返回第 `attempt`(1-based)次重试前应 sleep 的时长。
|
||||
/// attempt=1 → ~1s, attempt=2 → ~2s, attempt=3 → ~4s,各 ±20% jitter。
|
||||
///
|
||||
/// jitter 用 SystemTime 纳秒取模生成(无依赖),避免多客户端同步重试风暴。
|
||||
/// 以毫秒粒度计算后向下取整(避免秒级截断把 0.9s 砍成 0)。
|
||||
///
|
||||
/// CR-30-1: 暴露 pub 供 src-tauri/agentic.rs 流前重试复用(对齐决策 F-260616-07 a1
|
||||
/// "复用 retry.rs backoff_delay 退避 1s→2s→4s+jitter"),避免重写退避逻辑。
|
||||
pub fn backoff_delay(attempt: u32) -> Duration {
|
||||
let base_ms = BASE_BACKOFF_SECS.saturating_mul(1u64 << (attempt - 1)) * 1000;
|
||||
// 纳秒 → [0, 2000) 区间,再映射到 [-1.0, +1.0) 比例
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.map(|d| d.subsec_nanos() as u64)
|
||||
.unwrap_or(0);
|
||||
let jitter_ratio = (nanos % 2000) as f64 / 1000.0 - 1.0; // [-1.0, 1.0)
|
||||
let factor = 1.0 + jitter_ratio * JITTER_RATIO; // [0.8, 1.2]
|
||||
let ms = (base_ms as f64 * factor).max(0.0) as u64;
|
||||
Duration::from_millis(ms)
|
||||
}
|
||||
|
||||
/// 对一个返回 `AttemptOutcome` 的异步操作做指数退避重试。
|
||||
///
|
||||
/// `attempt_fn` 每次被调用执行一次尝试并返回分类结果。
|
||||
/// 达到 `MAX_COMPLETE_ATTEMPTS` 或超出 `MAX_TOTAL_BUDGET` 或遇 Fatal 即终止。
|
||||
/// 终止时若仍失败,返回最后一次的 Retryable/Fatal 描述作为 `anyhow::Error`。
|
||||
///
|
||||
/// 调用方约定: `attempt_fn` 内部应保留完整请求重发能力(每次重建 RequestBuilder),
|
||||
/// 因为 reqwest::RequestBuilder 一次 `.send()` 后不可复用。
|
||||
///
|
||||
/// CR-30-1: 仍 pub(crate)(complete() 内部用),流前重试 stream_recv/agentic 不走此函数
|
||||
/// (流式 request 不可整体 retry_with_backoff 包裹,需在 agentic loop 内手写循环复用
|
||||
/// backoff_delay + is_status_retryable)。
|
||||
pub(crate) async fn retry_with_backoff<T, F, Fut>(
|
||||
label: &str,
|
||||
attempt_fn: F,
|
||||
) -> anyhow::Result<T>
|
||||
where
|
||||
F: Fn(u32) -> Fut,
|
||||
Fut: Future<Output = AttemptOutcome<T>>,
|
||||
{
|
||||
let deadline = tokio::time::Instant::now() + MAX_TOTAL_BUDGET;
|
||||
let mut last_err = String::from("未尝试");
|
||||
|
||||
for attempt in 1..=MAX_COMPLETE_ATTEMPTS {
|
||||
match attempt_fn(attempt).await {
|
||||
AttemptOutcome::Ok(v) => return Ok(v),
|
||||
AttemptOutcome::Fatal(msg) => {
|
||||
// 不可重试: 立即放弃,不浪费预算
|
||||
return Err(anyhow::anyhow!("{}", msg));
|
||||
}
|
||||
AttemptOutcome::Retryable(msg) => {
|
||||
last_err = msg;
|
||||
let is_last = attempt == MAX_COMPLETE_ATTEMPTS;
|
||||
if is_last {
|
||||
warn!(
|
||||
label = label,
|
||||
attempt = attempt,
|
||||
"重试已达上限({}次),放弃", MAX_COMPLETE_ATTEMPTS
|
||||
);
|
||||
break;
|
||||
}
|
||||
let now = tokio::time::Instant::now();
|
||||
if now >= deadline {
|
||||
warn!(
|
||||
label = label,
|
||||
attempt = attempt,
|
||||
"重试预算({:?})已耗尽,放弃", MAX_TOTAL_BUDGET
|
||||
);
|
||||
break;
|
||||
}
|
||||
let delay = backoff_delay(attempt).min(deadline.saturating_duration_since(now));
|
||||
warn!(
|
||||
label = label,
|
||||
attempt = attempt,
|
||||
delay_ms = delay.as_millis() as u64,
|
||||
err = %last_err,
|
||||
"可重试错误,退避后重试"
|
||||
);
|
||||
tokio::time::sleep(delay).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow::anyhow!("{} 重试耗尽: {}", label, last_err))
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 单测(纯逻辑,不发 HTTP)
|
||||
// ============================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
fn backoff_is_monotonic_within_jitter() {
|
||||
// base 序列: 1, 2, 4 秒; jitter ±20% 后仍在 [0.8,4.8] 范围
|
||||
for attempt in 1..=3 {
|
||||
let d = backoff_delay(attempt);
|
||||
let base_ms = (BASE_BACKOFF_SECS * (1u64 << (attempt - 1))) * 1000;
|
||||
let lo = (base_ms as f64 * (1.0 - JITTER_RATIO)) as u64;
|
||||
let hi = (base_ms as f64 * (1.0 + JITTER_RATIO)) as u64;
|
||||
assert!(
|
||||
d.as_millis() as u64 >= lo && d.as_millis() as u64 <= hi,
|
||||
"attempt={} delay={:?} 应在 [{},{}]ms",
|
||||
attempt,
|
||||
d,
|
||||
lo,
|
||||
hi
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_classification() {
|
||||
assert!(is_status_retryable(429));
|
||||
assert!(is_status_retryable(500));
|
||||
assert!(is_status_retryable(503));
|
||||
assert!(!is_status_retryable(400));
|
||||
assert!(!is_status_retryable(401));
|
||||
assert!(!is_status_retryable(403));
|
||||
assert!(!is_status_retryable(404));
|
||||
}
|
||||
|
||||
/// 初次成功 → 不重试
|
||||
#[tokio::test]
|
||||
async fn first_attempt_ok() {
|
||||
let calls = Arc::new(AtomicU32::new(0));
|
||||
let calls_clone = calls.clone();
|
||||
let r = retry_with_backoff("test", move |_| {
|
||||
let c = calls_clone.clone();
|
||||
async move {
|
||||
c.fetch_add(1, Ordering::SeqCst);
|
||||
AttemptOutcome::Ok(42u32)
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(r, 42);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
/// 第二次成功 → 恰好 2 次调用
|
||||
#[tokio::test]
|
||||
async fn retry_once_then_ok() {
|
||||
let calls = Arc::new(AtomicU32::new(0));
|
||||
let calls_clone = calls.clone();
|
||||
let r = retry_with_backoff("test", move |_| {
|
||||
let c = calls_clone.clone();
|
||||
async move {
|
||||
let n = c.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
if n == 1 {
|
||||
AttemptOutcome::Retryable("timeout".into())
|
||||
} else {
|
||||
AttemptOutcome::Ok(7u32)
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(r, 7);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
/// 全部 Retryable → 用尽 3 次,返回错误
|
||||
#[tokio::test]
|
||||
async fn all_retryable_exhausts() {
|
||||
let calls = Arc::new(AtomicU32::new(0));
|
||||
let calls_clone = calls.clone();
|
||||
let r = retry_with_backoff("test", move |_| {
|
||||
let c = calls_clone.clone();
|
||||
async move {
|
||||
c.fetch_add(1, Ordering::SeqCst);
|
||||
AttemptOutcome::<u32>::Retryable("503".into())
|
||||
}
|
||||
})
|
||||
.await;
|
||||
assert!(r.is_err());
|
||||
assert!(r.unwrap_err().to_string().contains("重试耗尽"));
|
||||
assert_eq!(calls.load(Ordering::SeqCst), MAX_COMPLETE_ATTEMPTS);
|
||||
}
|
||||
|
||||
/// Fatal 立即放弃,不重试
|
||||
#[tokio::test]
|
||||
async fn fatal_aborts_immediately() {
|
||||
let calls = Arc::new(AtomicU32::new(0));
|
||||
let calls_clone = calls.clone();
|
||||
let r = retry_with_backoff("test", move |_| {
|
||||
let c = calls_clone.clone();
|
||||
async move {
|
||||
c.fetch_add(1, Ordering::SeqCst);
|
||||
AttemptOutcome::<u32>::Fatal("401".into())
|
||||
}
|
||||
})
|
||||
.await;
|
||||
assert!(r.is_err());
|
||||
assert!(r.unwrap_err().to_string().contains("401"));
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1, "Fatal 不应触发重试");
|
||||
}
|
||||
}
|
||||
+355
-39
@@ -1,51 +1,367 @@
|
||||
//! 模型路由 — 根据任务类型选择最优模型
|
||||
//! 模型路由器 — F-01 阶段4
|
||||
//!
|
||||
//! 纯函数核心,零 IO / 零状态。给定 TaskRequirements + 候选池,返回最优 ModelConfig。
|
||||
//! 不接调用点(那是阶段5:agentic.rs / title.rs / knowledge_inject.rs / project.rs /
|
||||
//! df-ideas / df-nodes ai_node.rs)。
|
||||
//!
|
||||
//! 设计来源:docs/02-架构设计/已编号方案/F-01-模型能力系统与智能路由设计-2026-06-16.md §6.1。
|
||||
//!
|
||||
//! ModelRouter 为单元结构,select 是无状态关联函数(对齐任务规格,非设计文档的 `&self` 方法)。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
// 阶段5: 调用点经 `df_ai::router::{Modality, Capability, CostTier, IntelligenceTier}`
|
||||
// 直接 import 维度枚举构造 TaskRequirements(对齐任务规格 import 风格),re-export 避免调用点
|
||||
// 各自从 df_ai_core::model 取(跨 crate 路径冗长)。select/select_model_id 仅借用枚举,无重定义。
|
||||
// 注:CostTier/IntelligenceTier 路由已解耦(2026-06-18 B-260618-03)——provider /v1/models API
|
||||
// 不返回这两维度,数据无客观依据不可信,不参与硬路由;re-export 保留供未来真实判别源。
|
||||
pub use df_ai_core::model::{Capability, CostTier, IntelligenceTier, Modality, ModelConfig};
|
||||
|
||||
/// 任务类型
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TaskType {
|
||||
/// 代码生成
|
||||
CodeGeneration,
|
||||
/// 代码审查
|
||||
CodeReview,
|
||||
/// 文档生成
|
||||
Documentation,
|
||||
/// 分析推理
|
||||
Analysis,
|
||||
/// 摘要总结
|
||||
Summarization,
|
||||
/// 通用对话
|
||||
Chat,
|
||||
}
|
||||
|
||||
/// 模型路由器
|
||||
/// 任务对模型的需求(3 维度)。
|
||||
///
|
||||
/// 根据任务类型、成本、延迟等选择最优模型
|
||||
pub struct ModelRouter {
|
||||
/// 默认模型
|
||||
default_model: String,
|
||||
/// 由调用点构造(阶段5),描述本次调用需要什么模态/能力/上下文,
|
||||
/// 交 ModelRouter::select 在候选池中选最优模型。
|
||||
///
|
||||
/// 路由已解耦(2026-06-18 B-260618-03):原 `min_intelligence`/`max_cost` 两字段删除。
|
||||
/// provider /v1/models API 不返回 cost_tier/intelligence,这两维度 100% 靠预设表写死 +
|
||||
/// 模型名启发式猜,数据无客观依据不可信,不应参与硬路由。枚举(CostTier/IntelligenceTier)
|
||||
/// 保留供未来出现真实判别源时再接回。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TaskRequirements {
|
||||
/// 任务所需的模态集合(全子集匹配:任务所需模态都必须在模型模态里)
|
||||
pub modalities: Vec<Modality>,
|
||||
/// 是否需要工具调用能力(needs_tool_use=true 时候选必须含 Capability::ToolUse)
|
||||
pub needs_tool_use: bool,
|
||||
/// 预估上下文大小(tokens,模型 context_window 必须 >= 此值)
|
||||
pub estimated_context: usize,
|
||||
}
|
||||
|
||||
/// 模型路由器(单元结构,无状态)。
|
||||
///
|
||||
/// select 为关联函数:给定需求 + 候选池,执行过滤链选最优模型。
|
||||
pub struct ModelRouter;
|
||||
|
||||
impl ModelRouter {
|
||||
/// 创建路由器
|
||||
pub fn new(default_model: &str) -> Self {
|
||||
Self {
|
||||
default_model: default_model.to_string(),
|
||||
/// 在候选池中选出最优模型(过滤链)。
|
||||
///
|
||||
/// 步骤:
|
||||
/// 1. enabled — 只选启用的
|
||||
/// 2. 模态匹配 — 任务所需模态全在模型模态里
|
||||
/// 3. 能力匹配 — needs_tool_use 时候选必须含 ToolUse
|
||||
/// 4. 窗口够大 — context_window >= estimated_context
|
||||
/// 5. max_by_key 选最优:纯 weight 主导(权重高者胜)
|
||||
///
|
||||
/// 路由已解耦(2026-06-18 B-260618-03):原「智力达标」/「成本可控」两步删除,
|
||||
/// 原第 7 步排序的 `Reverse(cost_tier)` 同权重选便宜也已删除——排序纯 weight 主导。
|
||||
/// cost_tier/intelligence 数据无客观依据(provider /v1/models 不返回,靠预设表+模型名
|
||||
/// 启发式猜),不参与硬路由。枚举保留供未来真实判别源再接回。
|
||||
pub fn select<'a>(req: &TaskRequirements, pool: &'a [ModelConfig]) -> Option<&'a ModelConfig> {
|
||||
pool.iter()
|
||||
.filter(|m| m.enabled) // 1. 只选启用的
|
||||
.filter(|m| req.modalities.iter().all(|r| m.modalities.contains(r))) // 2. 模态匹配
|
||||
.filter(|m| !req.needs_tool_use || m.capabilities.contains(&Capability::ToolUse)) // 3. 能力匹配
|
||||
.filter(|m| m.context_window >= req.estimated_context) // 4. 窗口够大
|
||||
.max_by_key(|m| m.weight) // 5. 纯 weight 主导
|
||||
}
|
||||
}
|
||||
|
||||
/// 阶段5 调用点 helper — 路由选模型并直接返回 model_id(纯函数)。
|
||||
///
|
||||
/// 给定 TaskRequirements + 候选池,返回最优模型的 `model_id`。
|
||||
/// 调用点用法:`provider.model_configs`(Vec<ModelConfig>)→ `select_model_id(&req, &pool)`
|
||||
/// → `Option<String>`;None 时兜底 `provider.default_model`(行为不变,平滑过渡)。
|
||||
///
|
||||
/// 行为不变保证:
|
||||
/// - 池空(用户未通过 Settings 拉取模型)→ 返回 None → 调用点兜底 default_model
|
||||
/// - 池非空但无候选满足需求 → 返回 None → 兜底 default_model
|
||||
/// - 池非空命中 → 返回 model_id(F-01 路由目标,拉取即启用路由)
|
||||
pub fn select_model_id(req: &TaskRequirements, pool: &[ModelConfig]) -> Option<String> {
|
||||
ModelRouter::select(req, pool).map(|m| m.model_id.clone())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 构造一个全维度可定制的 ModelConfig(默认全过过滤,调用方按需覆盖字段)。
|
||||
fn model(model_id: &str) -> ModelConfig {
|
||||
ModelConfig {
|
||||
model_id: model_id.into(),
|
||||
enabled: true,
|
||||
label: None,
|
||||
modalities: vec![Modality::Text],
|
||||
capabilities: vec![Capability::ToolUse],
|
||||
cost_tier: CostTier::Medium,
|
||||
intelligence: IntelligenceTier::Standard,
|
||||
weight: 50,
|
||||
context_window: 8192,
|
||||
probe_source: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据任务类型路由到合适的模型
|
||||
pub fn route(&self, task_type: &TaskType) -> String {
|
||||
// TODO: 实现基于规则的模型路由
|
||||
match task_type {
|
||||
TaskType::CodeGeneration => self.default_model.clone(),
|
||||
TaskType::CodeReview => self.default_model.clone(),
|
||||
TaskType::Documentation => self.default_model.clone(),
|
||||
TaskType::Analysis => self.default_model.clone(),
|
||||
TaskType::Summarization => self.default_model.clone(),
|
||||
TaskType::Chat => self.default_model.clone(),
|
||||
/// 构造一个宽松需求(默认全过过滤,调用方按需覆盖字段)。
|
||||
fn req() -> TaskRequirements {
|
||||
TaskRequirements {
|
||||
modalities: vec![Modality::Text],
|
||||
needs_tool_use: false,
|
||||
estimated_context: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// ── 阶段5 select_model_id helper ──
|
||||
|
||||
#[test]
|
||||
fn select_model_id_empty_pool_returns_none() {
|
||||
// 池空(用户未拉取模型)→ None,调用点兜底 default_model
|
||||
let pool: Vec<ModelConfig> = vec![];
|
||||
assert!(select_model_id(&req(), &pool).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_model_id_hit_returns_model_id() {
|
||||
// 池非空命中 → 返回 model_id 字符串(非引用)
|
||||
let pool = vec![model("glm-4-flash")];
|
||||
assert_eq!(select_model_id(&req(), &pool).as_deref(), Some("glm-4-flash"));
|
||||
}
|
||||
|
||||
// ── 步骤 1:enabled 过滤 ──
|
||||
|
||||
#[test]
|
||||
fn empty_pool_returns_none() {
|
||||
let pool: Vec<ModelConfig> = vec![];
|
||||
assert!(ModelRouter::select(&req(), &pool).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_disabled_returns_none() {
|
||||
let pool = vec![
|
||||
ModelConfig {
|
||||
enabled: false,
|
||||
..model("a")
|
||||
},
|
||||
ModelConfig {
|
||||
enabled: false,
|
||||
..model("b")
|
||||
},
|
||||
];
|
||||
assert!(ModelRouter::select(&req(), &pool).is_none());
|
||||
}
|
||||
|
||||
// ── 步骤 2:模态匹配 ──
|
||||
|
||||
#[test]
|
||||
fn modality_mismatch_filtered() {
|
||||
// 任务需要 Vision,但池里模型只有 Text
|
||||
let pool = vec![model("text-only")];
|
||||
let r = TaskRequirements {
|
||||
modalities: vec![Modality::Vision],
|
||||
..req()
|
||||
};
|
||||
assert!(ModelRouter::select(&r, &pool).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modality_subset_multimodal_required_filters_text_only() {
|
||||
// 多模态子集匹配:任务需 [Text, Vision](两个模态都得支持),
|
||||
// 模型仅 [Text](缺 Vision)→ 步骤 2 `.all(|r| m.modalities.contains(r))` 不成立,滤掉。
|
||||
// 区别于 modality_mismatch_filtered(单模态 Vision):本测覆盖「任务需多模态全子集」路径。
|
||||
let pool = vec![ModelConfig {
|
||||
modalities: vec![Modality::Text],
|
||||
..model("text-only")
|
||||
}];
|
||||
let r = TaskRequirements {
|
||||
modalities: vec![Modality::Text, Modality::Vision],
|
||||
..req()
|
||||
};
|
||||
assert!(ModelRouter::select(&r, &pool).is_none());
|
||||
|
||||
// 对照:模型补全 Vision 后通过(Text+Vision ⊇ 任务需求 Text+Vision)
|
||||
let pool_ok = vec![ModelConfig {
|
||||
modalities: vec![Modality::Text, Modality::Vision],
|
||||
..model("vision-capable")
|
||||
}];
|
||||
assert_eq!(
|
||||
ModelRouter::select(&r, &pool_ok).unwrap().model_id,
|
||||
"vision-capable"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_false_excluded_from_routing() {
|
||||
// enabled=false 不参与路由:混池里禁用候选 weight 更高(100),
|
||||
// 但应被步骤 1 `.filter(|m| m.enabled)` 滤掉,只选 enabled=true 的低 weight 候选。
|
||||
// 区别于 all_disabled_returns_none(全禁用返 None):本测覆盖「部分禁用」混池场景。
|
||||
let pool = vec![
|
||||
ModelConfig {
|
||||
enabled: false,
|
||||
weight: 100, // 若未被 enabled 过滤,weight 100 会胜
|
||||
..model("disabled-heavy")
|
||||
},
|
||||
ModelConfig {
|
||||
enabled: true,
|
||||
weight: 30,
|
||||
..model("enabled-light")
|
||||
},
|
||||
];
|
||||
assert_eq!(
|
||||
ModelRouter::select(&req(), &pool).unwrap().model_id,
|
||||
"enabled-light"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_candidate_pool_returns_none() {
|
||||
// 空候选池 → None(与 select_model_id_empty_pool_returns_none 对应,
|
||||
// 本测覆盖 select() 关联函数本身而非 helper;语义等价但断言点不同)。
|
||||
let pool: Vec<ModelConfig> = vec![];
|
||||
assert!(ModelRouter::select(&req(), &pool).is_none());
|
||||
}
|
||||
|
||||
// ── 步骤 3:能力匹配(ToolUse) ──
|
||||
|
||||
#[test]
|
||||
fn needs_tool_use_filters_non_tool() {
|
||||
// 任务需要 ToolUse,池里模型 capabilities 无 ToolUse
|
||||
let pool = vec![ModelConfig {
|
||||
capabilities: vec![Capability::Embedding],
|
||||
..model("embed-only")
|
||||
}];
|
||||
let r = TaskRequirements {
|
||||
needs_tool_use: true,
|
||||
..req()
|
||||
};
|
||||
assert!(ModelRouter::select(&r, &pool).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_tool_use_false_allows_non_tool() {
|
||||
// 任务不需 ToolUse,池里模型无 ToolUse 仍入选
|
||||
let pool = vec![ModelConfig {
|
||||
capabilities: vec![Capability::Embedding],
|
||||
..model("embed-only")
|
||||
}];
|
||||
let r = TaskRequirements {
|
||||
needs_tool_use: false,
|
||||
..req()
|
||||
};
|
||||
assert_eq!(
|
||||
ModelRouter::select(&r, &pool).unwrap().model_id,
|
||||
"embed-only"
|
||||
);
|
||||
}
|
||||
|
||||
// ── 步骤 4(原智力/成本过滤已解耦 B-260618-03):窗口够大 ──
|
||||
|
||||
#[test]
|
||||
fn context_window_insufficient() {
|
||||
// 任务预估 100000,池里模型 8192
|
||||
let pool = vec![model("small-window")];
|
||||
let r = TaskRequirements {
|
||||
estimated_context: 100_000,
|
||||
..req()
|
||||
};
|
||||
assert!(ModelRouter::select(&r, &pool).is_none());
|
||||
}
|
||||
|
||||
// ── 步骤 5:max_by_key (纯 weight) ──
|
||||
|
||||
#[test]
|
||||
fn single_match_returns_it() {
|
||||
let pool = vec![model("only-one")];
|
||||
assert_eq!(
|
||||
ModelRouter::select(&req(), &pool).unwrap().model_id,
|
||||
"only-one"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weight_priority_higher_wins() {
|
||||
// 两候选都满足,weight 70 胜 50
|
||||
let pool = vec![
|
||||
ModelConfig {
|
||||
weight: 50,
|
||||
..model("low-weight")
|
||||
},
|
||||
ModelConfig {
|
||||
weight: 70,
|
||||
..model("high-weight")
|
||||
},
|
||||
];
|
||||
assert_eq!(
|
||||
ModelRouter::select(&req(), &pool).unwrap().model_id,
|
||||
"high-weight"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_weight_picks_first_match() {
|
||||
// 同 weight 70,纯 weight 主导(无 cost_tier tie-break):max_by_key 遇并列 key
|
||||
// 返回最后一个(rust Iterator::max_by_key 语义)。验证同 weight 不再按 cost 取舍。
|
||||
let pool = vec![
|
||||
ModelConfig {
|
||||
weight: 70,
|
||||
cost_tier: CostTier::High,
|
||||
..model("expensive")
|
||||
},
|
||||
ModelConfig {
|
||||
weight: 70,
|
||||
cost_tier: CostTier::Low,
|
||||
..model("low-cost")
|
||||
},
|
||||
];
|
||||
assert_eq!(
|
||||
ModelRouter::select(&req(), &pool).unwrap().model_id,
|
||||
"low-cost"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_dimensions_match_picks_best() {
|
||||
// 3+ 候选各维度参差,验证过滤链全过 + max_by_key 纯 weight 选最优。
|
||||
// (B-260618-03:智力/成本过滤已解耦,原步骤 4/5 删除,候选 d 不再因 intelligence 滤掉)
|
||||
//
|
||||
// 候选:
|
||||
// a: weight 60 → 通过全部过滤,key=60
|
||||
// b: weight 80 → 通过,key=80 — weight 最高档(与 c 并列)
|
||||
// c: weight 80 → 通过,key=80 — 同 weight 80,max_by_key 并列返回最后
|
||||
// d: weight 90 → 通过(B-260618-03 后 intelligence 不参与过滤),key=90 — weight 最高,胜
|
||||
// e: enabled=false → 步骤 1 滤掉
|
||||
//
|
||||
// 预期:d 胜(weight 90 最高,不再被 intelligence 滤掉)
|
||||
let pool = vec![
|
||||
ModelConfig {
|
||||
weight: 60,
|
||||
cost_tier: CostTier::Medium,
|
||||
intelligence: IntelligenceTier::Standard,
|
||||
..model("a")
|
||||
},
|
||||
ModelConfig {
|
||||
weight: 80,
|
||||
cost_tier: CostTier::High,
|
||||
intelligence: IntelligenceTier::Plus,
|
||||
..model("b")
|
||||
},
|
||||
ModelConfig {
|
||||
weight: 80,
|
||||
cost_tier: CostTier::Low,
|
||||
intelligence: IntelligenceTier::Plus,
|
||||
..model("c")
|
||||
},
|
||||
ModelConfig {
|
||||
weight: 90,
|
||||
cost_tier: CostTier::Low,
|
||||
intelligence: IntelligenceTier::Lite,
|
||||
..model("d")
|
||||
},
|
||||
ModelConfig {
|
||||
enabled: false,
|
||||
weight: 100,
|
||||
..model("e")
|
||||
},
|
||||
];
|
||||
let r = TaskRequirements {
|
||||
modalities: vec![Modality::Text],
|
||||
needs_tool_use: true,
|
||||
estimated_context: 0,
|
||||
};
|
||||
assert_eq!(ModelRouter::select(&r, &pool).unwrap().model_id, "d");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
//! 流式处理 — LLM 响应的流式输出管理
|
||||
|
||||
use crate::provider::StreamChunk;
|
||||
|
||||
/// 流式响应收集器
|
||||
pub struct StreamCollector {
|
||||
/// 已收集的文本
|
||||
text: String,
|
||||
/// 是否完成
|
||||
finished: bool,
|
||||
}
|
||||
|
||||
impl StreamCollector {
|
||||
/// 创建收集器
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
text: String::new(),
|
||||
finished: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 追加一个 chunk
|
||||
pub fn push(&mut self, chunk: &StreamChunk) {
|
||||
self.text.push_str(&chunk.delta);
|
||||
if chunk.finished {
|
||||
self.finished = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取已收集的文本
|
||||
pub fn text(&self) -> &str {
|
||||
&self.text
|
||||
}
|
||||
|
||||
/// 是否已完成
|
||||
pub fn is_finished(&self) -> bool {
|
||||
self.finished
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for StreamCollector {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
//! 工作流事件定义
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::types::NodeId;
|
||||
|
||||
/// 人工审批响应
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HumanApprovalResponse {
|
||||
pub execution_id: String,
|
||||
pub node_id: NodeId,
|
||||
pub decision: String,
|
||||
pub comment: Option<String>,
|
||||
}
|
||||
|
||||
/// 工作流事件
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum WorkflowEvent {
|
||||
/// 节点开始执行
|
||||
NodeStarted {
|
||||
node_id: NodeId,
|
||||
},
|
||||
/// 节点执行进度更新
|
||||
NodeProgress {
|
||||
node_id: NodeId,
|
||||
progress: f32,
|
||||
message: String,
|
||||
},
|
||||
/// 节点产生输出
|
||||
NodeOutput {
|
||||
node_id: NodeId,
|
||||
output: String,
|
||||
},
|
||||
/// 节点执行完成
|
||||
NodeCompleted {
|
||||
node_id: NodeId,
|
||||
duration_ms: u64,
|
||||
},
|
||||
/// 节点执行失败
|
||||
NodeFailed {
|
||||
node_id: NodeId,
|
||||
error: String,
|
||||
},
|
||||
/// 工作流暂停(等待外部输入)
|
||||
WorkflowPaused {
|
||||
reason: String,
|
||||
waiting_node: NodeId,
|
||||
},
|
||||
/// 工作流执行完成
|
||||
WorkflowCompleted {
|
||||
total_duration_ms: u64,
|
||||
},
|
||||
/// 工作流执行失败
|
||||
WorkflowFailed {
|
||||
error: String,
|
||||
failed_node: NodeId,
|
||||
},
|
||||
/// 人工审批请求
|
||||
HumanApprovalRequest {
|
||||
execution_id: String,
|
||||
node_id: NodeId,
|
||||
title: String,
|
||||
description: String,
|
||||
options: Vec<String>,
|
||||
},
|
||||
/// 人工审批响应
|
||||
HumanApprovalResponse {
|
||||
execution_id: String,
|
||||
node_id: NodeId,
|
||||
decision: String,
|
||||
comment: Option<String>,
|
||||
},
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
//! df-core: 核心类型定义,所有 crate 的基础依赖
|
||||
|
||||
pub mod error;
|
||||
pub mod events;
|
||||
pub mod types;
|
||||
@@ -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,
|
||||
}
|
||||
@@ -4,10 +4,7 @@ version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
df-core = { path = "../df-core" }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
@@ -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 ssh;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Shell 执行器 — 通过 tokio::process 执行 shell 命令
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::process::Stdio;
|
||||
|
||||
/// Shell 命令执行结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -15,6 +16,24 @@ pub struct ShellResult {
|
||||
pub duration_ms: u64,
|
||||
}
|
||||
|
||||
/// Shell 类型选择
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ShellType {
|
||||
/// Windows cmd.exe (默认 Windows)
|
||||
Cmd,
|
||||
/// PowerShell
|
||||
PowerShell,
|
||||
/// Unix sh (默认非 Windows)
|
||||
Sh,
|
||||
}
|
||||
|
||||
impl Default for ShellType {
|
||||
fn default() -> Self {
|
||||
if cfg!(windows) { ShellType::Cmd } else { ShellType::Sh }
|
||||
}
|
||||
}
|
||||
|
||||
/// Shell 命令执行请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ShellRequest {
|
||||
@@ -26,24 +45,54 @@ pub struct ShellRequest {
|
||||
pub env: std::collections::HashMap<String, String>,
|
||||
/// 超时时间(秒),None 表示不超时
|
||||
pub timeout_secs: Option<u64>,
|
||||
/// Shell 类型(默认:Windows→Cmd, 非Windows→Sh)
|
||||
#[serde(default)]
|
||||
pub shell_type: Option<ShellType>,
|
||||
}
|
||||
|
||||
/// 执行 Shell 命令
|
||||
///
|
||||
/// TODO: 完整实现,支持超时、环境变量、工作目录等
|
||||
/// 支持超时(timeout_secs)、环境变量(env)、工作目录(working_dir),
|
||||
/// kill_on_drop(true) 保证超时后子进程不残留,shell_type 可选 Cmd/PowerShell/Sh。
|
||||
pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> {
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
let mut cmd = if cfg!(windows) {
|
||||
let mut c = tokio::process::Command::new("cmd");
|
||||
c.arg("/C").arg(&request.command);
|
||||
c
|
||||
} else {
|
||||
let mut c = tokio::process::Command::new("sh");
|
||||
c.arg("-c").arg(&request.command);
|
||||
c
|
||||
let shell_type = request.shell_type.unwrap_or_default();
|
||||
let mut cmd = match shell_type {
|
||||
ShellType::PowerShell => {
|
||||
let mut c = tokio::process::Command::new("powershell");
|
||||
c.arg("-NoProfile").arg("-Command").arg(&request.command);
|
||||
c.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
c
|
||||
}
|
||||
ShellType::Cmd => {
|
||||
let mut c = tokio::process::Command::new("cmd");
|
||||
c.arg("/C").arg(&request.command);
|
||||
c.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
c
|
||||
}
|
||||
ShellType::Sh => {
|
||||
let mut c = tokio::process::Command::new("sh");
|
||||
c.arg("-c").arg(&request.command);
|
||||
c.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
c
|
||||
}
|
||||
};
|
||||
|
||||
// CR-15-1: kill_on_drop(true) 让 Command 被 drop 时主动 kill 子进程。
|
||||
// 配合 tokio::time::timeout 超时场景:超时 drop future → Command 析构 → kill 子进程,
|
||||
// 不再让超时后的命令变孤儿继续后台跑(长 hang 命令/死循环仍占资源)。
|
||||
// 对齐 tool_registry.rs:514「进程已终止」文案名副其实。tokio 1.52.3 支持。
|
||||
cmd.kill_on_drop(true);
|
||||
|
||||
// B-260619-01: Windows 下创建子进程默认弹控制台窗口(cmd/powershell 黑窗闪现)。
|
||||
// CREATE_NO_WINDOW(0x0800_0000) 标志抑制窗口创建,后台静默执行。
|
||||
// tokio::process::Command 在 Windows 自带 creation_flags 方法(无需 std CommandExt trait)。
|
||||
#[cfg(windows)]
|
||||
{
|
||||
cmd.creation_flags(0x0800_0000);
|
||||
}
|
||||
|
||||
if let Some(dir) = &request.working_dir {
|
||||
cmd.current_dir(dir);
|
||||
}
|
||||
@@ -58,7 +107,7 @@ pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> {
|
||||
cmd.output(),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("命令执行超时: {}秒", secs))??,
|
||||
.map_err(|_| anyhow::anyhow!("命令执行超时({}s): {}", secs, request.command))??,
|
||||
None => cmd.output().await?,
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
//! Shell 执行器基础集成测试。
|
||||
//!
|
||||
//! 覆盖 execute() 五类行为,跨平台(Windows=Cmd, 非 Windows=Sh):
|
||||
//! 1. 成功:exit_code=Some(0) + stdout 非空 + duration_ms>0
|
||||
//! 2. 非零退出:exit_code=Some(非0),返回 Ok(退出码在 ShellResult 内,非 Err)
|
||||
//! 3. 超时:timeout_secs=1 + 长睡命令,返回 Err
|
||||
//! 4. env 注入:env HashMap 传 KEY=VAL,命令回显验证
|
||||
//! 5. working_dir:传临时目录,命令回显当前目录验证
|
||||
//!
|
||||
//! 注:execute 逻辑本身未改动,此文件为零行为变更的纯新增测试。
|
||||
|
||||
use df_execute::shell::{execute, ShellRequest, ShellType};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// 平台默认 ShellType(对齐 shell.rs:31 Default impl:Windows→Cmd, 非 Windows→Sh)
|
||||
fn default_shell() -> ShellType {
|
||||
if cfg!(windows) {
|
||||
ShellType::Cmd
|
||||
} else {
|
||||
ShellType::Sh
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造最小 ShellRequest,默认无超时/无 env/无 working_dir
|
||||
fn req(command: &str) -> ShellRequest {
|
||||
ShellRequest {
|
||||
command: command.to_string(),
|
||||
working_dir: None,
|
||||
env: HashMap::new(),
|
||||
timeout_secs: None,
|
||||
shell_type: Some(default_shell()),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 1. 成功:exit_code=0 + stdout 非空 + duration_ms>0 ----------
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_success_echo() {
|
||||
// 平台无关:Cmd/Sh 都认 echo。Cmd 输出无尾换行,Sh 输出带 \n——只断言非空与包含。
|
||||
let result = execute(req("echo hello_df_execute")).await;
|
||||
assert!(result.is_ok(), "execute 应返回 Ok: {:?}", result.err());
|
||||
let res = result.unwrap();
|
||||
assert_eq!(res.exit_code, Some(0), "成功命令 exit_code 应为 0");
|
||||
assert!(
|
||||
res.stdout.trim().contains("hello_df_execute"),
|
||||
"stdout 应包含回显文本,实际: {:?}",
|
||||
res.stdout
|
||||
);
|
||||
assert!(res.duration_ms > 0, "duration_ms 应 > 0,实际: {}", res.duration_ms);
|
||||
}
|
||||
|
||||
// ---------- 2. 非零退出:exit_code=Some(非0),返回 Ok ----------
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_nonzero_exit() {
|
||||
// 平台无关:false 是 POSIX 内建 + Windows 的 cmd 也内置(返回退出码 1)。
|
||||
// 非 Windows 用 sh -c 'false';Windows 用 cmd /C 'cmd /C exit 1' 更稳——
|
||||
// 这里统一用 false,Windows cmd 内置 false.exe 自 Win10 起可用,失败则 exit_code 非 0 即满足断言。
|
||||
let result = execute(req("exit 1")).await;
|
||||
assert!(result.is_ok(), "非零退出应仍返回 Ok(退出码在 ShellResult 内),实际: {:?}", result);
|
||||
let res = result.unwrap();
|
||||
assert_ne!(res.exit_code, Some(0), "exit 1 的 exit_code 应非 0,实际: {:?}", res.exit_code);
|
||||
}
|
||||
|
||||
// ---------- 3. 超时:timeout_secs=1 + 长睡命令,返回 Err ----------
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_timeout_returns_err() {
|
||||
// 平台无关睡眠命令:Cmd 用 timeout(Windows 内置),Sh 用 sleep。各自分支。
|
||||
let sleep_cmd = if cfg!(windows) {
|
||||
// cmd 的 timeout /T 需按键中断,用 ping 假睡更稳(>1s);但 timeout 内置更简洁,
|
||||
// 这里用 ping -n 5 (≈4s) 跨 Win 版本稳。
|
||||
"ping -n 5 127.0.0.1 > nul".to_string()
|
||||
} else {
|
||||
"sleep 5".to_string()
|
||||
};
|
||||
let request = ShellRequest {
|
||||
command: sleep_cmd,
|
||||
working_dir: None,
|
||||
env: HashMap::new(),
|
||||
timeout_secs: Some(1),
|
||||
shell_type: Some(default_shell()),
|
||||
};
|
||||
let result = execute(request).await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"超时应返回 Err,实际: {:?}",
|
||||
result.as_ref().err()
|
||||
);
|
||||
// 错误信息应含「超时」(对齐 shell.rs:101 文案)
|
||||
let msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
msg.contains("超时") || msg.to_lowercase().contains("timeout"),
|
||||
"错误信息应含超时提示,实际: {}",
|
||||
msg
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- 4. env 注入:env HashMap 传 KEY=VAL,命令回显验证 ----------
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_env_injection() {
|
||||
let mut env = HashMap::new();
|
||||
env.insert("DF_EXECUTE_TEST_KEY".to_string(), "env_value_42".to_string());
|
||||
// 平台无关回显 env 变量:Cmd 用 %VAR%,Sh 用 $VAR
|
||||
let echo_cmd = if cfg!(windows) {
|
||||
"echo %DF_EXECUTE_TEST_KEY%"
|
||||
} else {
|
||||
"echo $DF_EXECUTE_TEST_KEY"
|
||||
};
|
||||
let request = ShellRequest {
|
||||
command: echo_cmd.to_string(),
|
||||
working_dir: None,
|
||||
env,
|
||||
timeout_secs: None,
|
||||
shell_type: Some(default_shell()),
|
||||
};
|
||||
let res = execute(request).await.expect("env 注入命令应返回 Ok");
|
||||
assert_eq!(res.exit_code, Some(0));
|
||||
assert!(
|
||||
res.stdout.trim().contains("env_value_42"),
|
||||
"stdout 应含注入的 env 值,实际: {:?}",
|
||||
res.stdout
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- 5. working_dir:传临时目录,命令回显当前目录验证 ----------
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_working_dir() {
|
||||
// 用 std::env::temp_dir()(标准库,零新增依赖)。生成唯一子目录避免并行测试互相干扰。
|
||||
let unique = format!("df_exec_test_{}", std::process::id());
|
||||
let tmp = std::env::temp_dir().join(&unique);
|
||||
std::fs::create_dir_all(&tmp).expect("创建临时目录失败");
|
||||
// 测试后清理(即便失败也尽量不残留)
|
||||
let tmp_for_cleanup = tmp.clone();
|
||||
|
||||
// 平台无关回显当前目录:Cmd 用 cd(无参数)不靠谱,用 echo %CD%;Sh 用 pwd。
|
||||
let pwd_cmd = if cfg!(windows) { "echo %CD%" } else { "pwd" };
|
||||
let request = ShellRequest {
|
||||
command: pwd_cmd.to_string(),
|
||||
working_dir: Some(tmp.to_string_lossy().to_string()),
|
||||
env: HashMap::new(),
|
||||
timeout_secs: None,
|
||||
shell_type: Some(default_shell()),
|
||||
};
|
||||
let res = execute(request).await.expect("working_dir 命令应返回 Ok");
|
||||
assert_eq!(res.exit_code, Some(0));
|
||||
// 规范化比较(去尾空白/大小写在 Windows 不敏感但路径用户自定义,只断言包含)
|
||||
let stdout_norm = res.stdout.trim().to_lowercase().replace('\\', "/");
|
||||
let tmp_norm = tmp.to_string_lossy().to_lowercase().replace('\\', "/");
|
||||
assert!(
|
||||
stdout_norm.contains(&tmp_norm),
|
||||
"stdout 应含 working_dir 路径,实际: {:?},期望包含: {:?}",
|
||||
res.stdout,
|
||||
tmp.to_string_lossy()
|
||||
);
|
||||
|
||||
// 清理
|
||||
let _ = std::fs::remove_dir_all(&tmp_for_cleanup);
|
||||
}
|
||||
@@ -4,10 +4,17 @@ version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
df-core = { path = "../df-core" }
|
||||
df-types = { path = "../df-types" }
|
||||
df-ai-core = { path = "../df-ai-core" }
|
||||
# F-01 阶段5: df-ideas 对抗评估接入 df_ai::router::select_model_id(纯函数路由)。
|
||||
# 注:df-ai 引入 reqwest/futures/eventsource-stream 重依赖,但 router 模块仅依赖
|
||||
# df-ai-core::model,实际编译期 df-ideas 仅引用 router 符号(零 HTTP 代码路径)。
|
||||
df-ai = { path = "../df-ai" }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
|
||||
+537
-249
@@ -1,206 +1,303 @@
|
||||
//! 对抗式评估系统 — 正反方辩论 + AI 分析师
|
||||
//!
|
||||
//! 双轨实现:
|
||||
//! - **启发式**(默认/降级):基于评分与内容信号生成正反方论点,稳定有区分度。
|
||||
//! - **LLM**(注入 provider 后):调一次 `complete()` 让论点由 LLM 生成,失败自动降级启发式。
|
||||
//!
|
||||
//! 评估来源由 [`EvaluatedBy`] 三态标记:`Llm`(LLM 深度评估)/ `Heuristic`(主动选启发式,
|
||||
//! 无 provider)/ `HeuristicFallback`(LLM 调用失败降级)。前端可据此显示评估深度标签。
|
||||
//!
|
||||
//! LLM prompt 构造与 JSON 解析在 F-260614-03 接入:[`AdversarialEngine::evaluate_with_llm`]
|
||||
//! 构造三角色辩论 prompt(正方/反方/分析师),调一次 `complete()` 要求返回对齐结构的 JSON,
|
||||
//! 解析失败/字段缺失/枚举非法 → `bail` 触发降级([`AdversarialEngine::evaluate`] 已兜底)。
|
||||
//!
|
||||
//! 重构(strategy·自底向上):类型定义 / 常量 / prompt 构造 / JSON 解析等无副作用逻辑抽至
|
||||
//! [`adversarial_helpers`],本文件只保留 [`AdversarialEngine`](有状态引擎 + evaluate 主入口,
|
||||
//! 含 ARC-260618-01-e evaluate_with_llm 一致性待决策逻辑,原样保留)。外部已用路径(
|
||||
//! `df_ideas::adversarial::{AdversarialEval, Recommendation, …}`)经 `pub use` 不变。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use df_core::types::IdeaId;
|
||||
use df_ai_core::model::ModelConfig;
|
||||
use df_ai_core::provider::LlmProvider;
|
||||
use df_types::types::Priority;
|
||||
use crate::capture::Idea;
|
||||
use crate::scoring::IdeaScores;
|
||||
|
||||
/// 对抗评估结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AdversarialEval {
|
||||
pub idea_id: IdeaId,
|
||||
pub positive: Argument,
|
||||
pub negative: Argument,
|
||||
pub analyst: AnalystAnalysis,
|
||||
pub final_score: f64,
|
||||
pub recommendation: Recommendation,
|
||||
}
|
||||
|
||||
/// 正方论点
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Argument {
|
||||
pub thesis: String, // 核心观点
|
||||
pub evidence: Vec<String>, // 证据支持
|
||||
pub reasoning: Vec<String>, // 推理过程
|
||||
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 分析师综合分析
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AnalystAnalysis {
|
||||
pub summary: String, // 综合总结
|
||||
pub strengths: Vec<String>, // 主要优势
|
||||
pub weaknesses: Vec<String>, // 主要劣势
|
||||
pub risks: Vec<String>, // 潜在风险
|
||||
pub opportunities: Vec<String>, // 机会点
|
||||
pub final_assessment: AssessmentLevel, // 最终评估
|
||||
}
|
||||
|
||||
/// 评估等级
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum AssessmentLevel {
|
||||
StrongGo, // 强烈推荐执行
|
||||
Recommended, // 推荐执行
|
||||
Conditional, // 有条件执行
|
||||
Revised, // 需要修改后执行
|
||||
Defer, // 推迟执行
|
||||
Reject, // 不推荐执行
|
||||
}
|
||||
|
||||
/// 最终建议
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum Recommendation {
|
||||
ImmediateAction, // 立即行动
|
||||
Soon, // 尽快行动
|
||||
WithResources, // 配置资源后行动
|
||||
ResearchMore, // 需要更多研究
|
||||
Monitor, // 持续监控
|
||||
Cancel, // 取消想法
|
||||
}
|
||||
// 类型 / 常量 / 纯函数自 adversarial_helpers 引回,并 re-export:外部调用方经
|
||||
// `df_ideas::adversarial::{...}` 路径访问的类型零变更(pub use 同时将符号带入当前
|
||||
// 命名空间供 impl/tests 使用,无需额外 use)。AdversarialEngine 本身定义在本文件。
|
||||
pub use crate::adversarial_helpers::{
|
||||
AdversarialEval, AnalystAnalysis, Argument, AssessmentLevel, EvaluatedBy, Recommendation,
|
||||
};
|
||||
use crate::adversarial_helpers::{
|
||||
SYSTEM_PROMPT, action_hint, assessment_desc, build_adversarial_prompt, parse_llm_eval,
|
||||
priority_label,
|
||||
};
|
||||
|
||||
/// 对抗评估引擎
|
||||
pub struct AdversarialEngine;
|
||||
pub struct AdversarialEngine {
|
||||
/// 可选 LLM provider。Some → 优先 LLM 评估(失败降级启发式);None → 纯启发式。
|
||||
/// 构造注入(与 IdeaPromoter::new(policy) 同一模式),批量评估复用同一 provider。
|
||||
provider: Option<Arc<dyn LlmProvider>>,
|
||||
/// F-01 阶段5: 候选模型池。非空时 evaluate_with_llm 经 select_model_id 路由选模型;
|
||||
/// 空(None provider 或未注入池)→ model 留空由 provider impl 回填自身 default_model
|
||||
/// (与接入前行为一致,平稳过渡)。
|
||||
model_pool: Vec<ModelConfig>,
|
||||
}
|
||||
|
||||
impl AdversarialEngine {
|
||||
/// 执行完整的对抗评估
|
||||
pub async fn evaluate(idea: &Idea) -> Result<AdversarialEval> {
|
||||
// 1. 生成正方观点
|
||||
let positive = Self::generate_positive_argument(idea).await?;
|
||||
/// 注入 LLM provider 构造(provider Some 时走 LLM,调用失败自动降级启发式)
|
||||
pub fn new(provider: Arc<dyn LlmProvider>) -> Self {
|
||||
Self { provider: Some(provider), model_pool: Vec::new() }
|
||||
}
|
||||
|
||||
// 2. 生成反方观点
|
||||
let negative = Self::generate_negative_argument(idea, &positive).await?;
|
||||
/// F-01 阶段5: 注入 provider + 候选模型池构造。池非空时 evaluate_with_llm 走路由。
|
||||
pub fn with_pool(provider: Arc<dyn LlmProvider>, model_pool: Vec<ModelConfig>) -> Self {
|
||||
Self { provider: Some(provider), model_pool }
|
||||
}
|
||||
|
||||
// 3. AI 分析师综合分析
|
||||
let analyst = Self::analyst_analysis(idea, &positive, &negative).await?;
|
||||
/// 纯启发式构造(无 LLM 配置时的默认模式)
|
||||
pub fn heuristic() -> Self {
|
||||
Self { provider: None, model_pool: Vec::new() }
|
||||
}
|
||||
|
||||
// 4. 计算最终分数和建议
|
||||
let (final_score, recommendation) = Self::compute_final_assessment(&analyst);
|
||||
/// 执行完整的对抗评估(内部按 provider 有无调度 LLM / 启发式,失败降级)
|
||||
pub async fn evaluate(&self, idea: &Idea) -> Result<AdversarialEval> {
|
||||
match &self.provider {
|
||||
Some(p) => match self.evaluate_with_llm(idea, p).await {
|
||||
Ok(mut eval) => {
|
||||
eval.evaluated_by = EvaluatedBy::Llm;
|
||||
Ok(eval)
|
||||
}
|
||||
Err(e) => {
|
||||
// LLM 调用失败/超时/格式异常 → 自动降级启发式,保证前端结构完整返回
|
||||
tracing::warn!("LLM 对抗评估失败, 降级到启发式: {e}");
|
||||
let mut eval = self.evaluate_heuristic(idea)?;
|
||||
eval.evaluated_by = EvaluatedBy::HeuristicFallback;
|
||||
Ok(eval)
|
||||
}
|
||||
},
|
||||
None => {
|
||||
let mut eval = self.evaluate_heuristic(idea)?;
|
||||
eval.evaluated_by = EvaluatedBy::Heuristic;
|
||||
Ok(eval)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// LLM 对抗评估(注入 provider 后走此路)。
|
||||
///
|
||||
/// 构造三角色对抗式辩论 prompt(正方/反方/分析师),调一次 `complete()`,要求 LLM
|
||||
/// 返回对齐 [`AdversarialEval`] 结构的 JSON。任一环节失败(HTTP / 非 JSON / 字段
|
||||
/// 缺失 / 枚举非法 / 数值越界无法修正)→ `bail` 由 [`Self::evaluate`] 捕获降级启发式。
|
||||
///
|
||||
/// temperature 取 0.4:低于 0.3 偏机械重复启发式信号,高于 0.5 易发散到无关风险,
|
||||
/// 0.4 在「稳定可复现」与「论点多样性」间取得平衡。
|
||||
///
|
||||
/// ARC-260618-01-e: evaluate_with_llm 返回值一致性未校验(final_score 与
|
||||
/// analyst.final_assessment 自洽性等),待产品决策,当前逻辑原样保留不调整。
|
||||
async fn evaluate_with_llm(&self, idea: &Idea, provider: &Arc<dyn LlmProvider>) -> Result<AdversarialEval> {
|
||||
let prompt = build_adversarial_prompt(idea);
|
||||
// F-01 阶段5: 智能路由 — 对抗评估 TaskRequirements(Standard,无工具)。
|
||||
// 池非空 → select_model_id 选最优 model_id;池空/无匹配 → 留空由 provider impl
|
||||
// 回填自身 default_model(与接入前行为一致,平稳过渡)。
|
||||
let eval_req = df_ai::router::TaskRequirements {
|
||||
modalities: vec![df_ai_core::model::Modality::Text],
|
||||
needs_tool_use: false,
|
||||
estimated_context: 0,
|
||||
};
|
||||
let model = df_ai::router::select_model_id(&eval_req, &self.model_pool).unwrap_or_default();
|
||||
let request = df_ai_core::provider::CompletionRequest {
|
||||
// 路由命中 → 用 model_id;否则留空让 provider impl 回填自身 default_model。
|
||||
// (OpenAICompatProvider::convert_request 在 req.model.is_empty() 时回退 default_model)
|
||||
model,
|
||||
messages: vec![
|
||||
df_ai_core::provider::ChatMessage::system(SYSTEM_PROMPT),
|
||||
df_ai_core::provider::ChatMessage::user(prompt),
|
||||
],
|
||||
temperature: Some(0.4),
|
||||
max_tokens: Some(2048),
|
||||
stream: false,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
|
||||
let resp = provider
|
||||
.complete(request)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("LLM complete 调用失败: {e}"))?;
|
||||
|
||||
// 数值 clamp 由 parse_llm_eval 单点收口(final_score∈[0,10]、confidence∈[0,1],
|
||||
// 均在 parse 内对所有 Ok 路径完成),此处不再重复 clamp(CR-40-1 去冗余)。
|
||||
parse_llm_eval(&resp.text, &idea.id)
|
||||
}
|
||||
|
||||
/// 启发式评估(基于评分与内容信号,稳定有区分度)
|
||||
fn evaluate_heuristic(&self, idea: &Idea) -> Result<AdversarialEval> {
|
||||
// 先做多维评分,作为正反方论点与置信度的依据
|
||||
let scores = crate::scoring::ScoringEngine::compute_default(idea);
|
||||
|
||||
let positive = self.generate_positive_argument(idea, &scores)?;
|
||||
let negative = self.generate_negative_argument(idea, &scores)?;
|
||||
let analyst = self.analyst_analysis(idea, &scores)?;
|
||||
let recommendation = self.recommendation_for(&analyst.final_assessment);
|
||||
|
||||
Ok(AdversarialEval {
|
||||
idea_id: idea.id.clone(),
|
||||
positive,
|
||||
negative,
|
||||
analyst,
|
||||
final_score,
|
||||
final_score: scores.overall,
|
||||
recommendation,
|
||||
// 由 evaluate() 调用方按调度路径覆盖(Heuristic / HeuristicFallback)
|
||||
evaluated_by: EvaluatedBy::Heuristic,
|
||||
})
|
||||
}
|
||||
|
||||
/// 生成正方观点(支持执行)
|
||||
async fn generate_positive_argument(idea: &Idea) -> Result<Argument> {
|
||||
// TODO: 接入 AI 生成正方观点
|
||||
// 当前使用启发式模板
|
||||
/// 生成正方观点(支持执行)— confidence 由可行性 + 影响力驱动
|
||||
/// 注:返回 Result 为后续 LLM 注入失败预留,启发式阶段恒 Ok
|
||||
fn generate_positive_argument(&self, 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;
|
||||
let desc = &idea.description;
|
||||
// 正方置信度:可行性+影响力等权折算到 [0.1, 0.95],满分≈0.95 留质疑余地
|
||||
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 {
|
||||
thesis: format!("{} 具有很高的价值和可行性,应该优先执行", title),
|
||||
evidence: vec![
|
||||
format!("满足业务需求:{}", desc),
|
||||
"投入产出比高".to_string(),
|
||||
"技术实现可行".to_string(),
|
||||
"时间窗口合适".to_string(),
|
||||
],
|
||||
reasoning: vec![
|
||||
"能够解决现有痛点".to_string(),
|
||||
"竞争优势明显".to_string(),
|
||||
"风险可控".to_string(),
|
||||
],
|
||||
confidence: 0.75,
|
||||
thesis: format!("「{}」具备明确价值与可行性,建议优先推进", idea.title),
|
||||
evidence,
|
||||
reasoning,
|
||||
confidence,
|
||||
})
|
||||
}
|
||||
|
||||
/// 生成反方观点(反对或谨慎)
|
||||
async fn generate_negative_argument(idea: &Idea, positive: &Argument) -> Result<CounterArgument> {
|
||||
// TODO: 接入 AI 生成反方观点,考虑正方观点
|
||||
/// 生成反方观点(反对或谨慎)— 论点基于想法实际缺陷,confidence 随风险上升
|
||||
fn generate_negative_argument(&self, idea: &Idea, scores: &IdeaScores) -> Result<Argument> {
|
||||
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 {
|
||||
thesis: format!("{} 需要谨慎评估,存在一定风险", title),
|
||||
evidence: vec![
|
||||
"资源投入较大".to_string(),
|
||||
"市场不确定性高".to_string(),
|
||||
"技术挑战存在".to_string(),
|
||||
"机会成本高".to_string(),
|
||||
],
|
||||
reasoning: vec![
|
||||
"ROI 需要进一步验证".to_string(),
|
||||
"优先级可能过高".to_string(),
|
||||
"存在更优替代方案".to_string(),
|
||||
],
|
||||
confidence: 0.65,
|
||||
let reasoning = vec![
|
||||
format!("资源投入与当前综合评分 {:.1} 需匹配", scores.overall),
|
||||
"ROI 需进一步验证".to_string(),
|
||||
"需评估是否存在更优解".to_string(),
|
||||
];
|
||||
|
||||
Ok(Argument {
|
||||
thesis: format!("「{}」需谨慎评估,存在风险与机会成本", idea.title),
|
||||
evidence,
|
||||
reasoning,
|
||||
confidence,
|
||||
})
|
||||
}
|
||||
|
||||
/// AI 分析师综合分析
|
||||
async fn analyst_analysis(
|
||||
idea: &Idea,
|
||||
positive: &Argument,
|
||||
negative: &CounterArgument,
|
||||
) -> Result<AnalystAnalysis> {
|
||||
// TODO: 接入 AI 进行深度分析
|
||||
/// AI 分析师综合分析 — 评估等级由综合评分决定,优势/劣势按维度动态生成
|
||||
fn analyst_analysis(&self, idea: &Idea, scores: &IdeaScores) -> Result<AnalystAnalysis> {
|
||||
let final_assessment = match scores.overall {
|
||||
x if x >= 7.5 => AssessmentLevel::StrongGo,
|
||||
x if x >= 6.0 => AssessmentLevel::Recommended,
|
||||
x if x >= 4.5 => AssessmentLevel::Conditional,
|
||||
x if x >= 3.0 => AssessmentLevel::Revised,
|
||||
_ => AssessmentLevel::Defer,
|
||||
};
|
||||
|
||||
let positive_strengths = vec![
|
||||
"方向正确,符合业务战略".to_string(),
|
||||
"技术创新性较强".to_string(),
|
||||
"用户价值明确".to_string(),
|
||||
];
|
||||
let mut strengths = Vec::new();
|
||||
if scores.impact >= 6.0 {
|
||||
strengths.push("业务价值明确".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![
|
||||
"资源需求评估不足".to_string(),
|
||||
"风险控制需要加强".to_string(),
|
||||
"时间规划可能过于乐观".to_string(),
|
||||
];
|
||||
let mut weaknesses = Vec::new();
|
||||
if scores.feasibility < 6.0 {
|
||||
weaknesses.push("可行性论证不足".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![
|
||||
"技术实现难度超出预期".to_string(),
|
||||
"市场竞争加剧".to_string(),
|
||||
"用户接受度不确定".to_string(),
|
||||
"技术实现难度可能超出预期".to_string(),
|
||||
"优先级与资源争夺".to_string(),
|
||||
"需求范围蔓延".to_string(),
|
||||
];
|
||||
|
||||
let opportunities = vec![
|
||||
"可能形成新的竞争优势".to_string(),
|
||||
"技术积累价值显著".to_string(),
|
||||
"市场机会窗口良好".to_string(),
|
||||
"可能形成可复用能力".to_string(),
|
||||
"积累技术资产".to_string(),
|
||||
];
|
||||
|
||||
// 基于正反方观点的强度计算
|
||||
let positive_strength = positive.confidence;
|
||||
let negative_strength = negative.confidence;
|
||||
let net_positive = (positive_strength - negative_strength + 1.0) / 2.0;
|
||||
|
||||
let final_assessment = if net_positive > 0.7 {
|
||||
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
|
||||
};
|
||||
let summary = format!(
|
||||
"「{}」综合评分 {:.1}/10,{}。建议{}。",
|
||||
idea.title,
|
||||
scores.overall,
|
||||
assessment_desc(&final_assessment),
|
||||
action_hint(&final_assessment)
|
||||
);
|
||||
|
||||
Ok(AnalystAnalysis {
|
||||
summary: format!(
|
||||
"该想法整体价值评估中等偏上,建议在有条件的情况下执行。主要价值在于{},需要关注{}。",
|
||||
idea.title,
|
||||
if net_positive > 0.5 { "风险控制" } else { "价值验证" }
|
||||
),
|
||||
strengths: positive_strengths,
|
||||
summary,
|
||||
strengths,
|
||||
weaknesses,
|
||||
risks,
|
||||
opportunities,
|
||||
@@ -208,101 +305,292 @@ impl AdversarialEngine {
|
||||
})
|
||||
}
|
||||
|
||||
/// 计算最终评估分数和建议
|
||||
fn compute_final_assessment(analyst: &AnalystAnalysis) -> (f64, Recommendation) {
|
||||
// 基于评估等级映射分数
|
||||
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 {
|
||||
/// 评估等级 → 最终建议
|
||||
fn recommendation_for(&self, level: &AssessmentLevel) -> Recommendation {
|
||||
match level {
|
||||
AssessmentLevel::StrongGo => Recommendation::ImmediateAction,
|
||||
AssessmentLevel::Recommended => Recommendation::Soon,
|
||||
AssessmentLevel::Conditional => Recommendation::WithResources,
|
||||
AssessmentLevel::Revised => Recommendation::ResearchMore,
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::capture::Idea;
|
||||
use crate::scoring::ScoringEngine;
|
||||
use df_types::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::heuristic().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::heuristic().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::heuristic().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::heuristic().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::heuristic().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::heuristic().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::heuristic().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 应含标题");
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// LLM 路径测试(F-260614-03)
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// mock LlmProvider:按构造时给定的响应文本回放,仅供 adversarial 单测。
|
||||
///
|
||||
/// `complete` 返回预设 `text`(或预设错误),`stream` 不被 adversarial 路径调用,
|
||||
/// 返回 Err 即可。不引入 futures 依赖。
|
||||
struct MockProvider {
|
||||
text: String,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl LlmProvider for MockProvider {
|
||||
async fn complete(
|
||||
&self,
|
||||
_request: df_ai_core::provider::CompletionRequest,
|
||||
) -> anyhow::Result<df_ai_core::provider::CompletionResponse> {
|
||||
Ok(df_ai_core::provider::CompletionResponse {
|
||||
text: self.text.clone(),
|
||||
model: "mock-model".to_string(),
|
||||
usage: df_ai_core::provider::TokenUsage::default(),
|
||||
tool_calls: None,
|
||||
// 对抗评估路径不消费推理内容,留 None 即可(CompletionResponse 新增字段,
|
||||
// 老构造点漏填 → 测试编译失败,回归补齐)。
|
||||
reasoning_content: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn stream(
|
||||
&self,
|
||||
_request: df_ai_core::provider::CompletionRequest,
|
||||
) -> anyhow::Result<df_ai_core::provider::StreamResult> {
|
||||
anyhow::bail!("MockProvider 不支持 stream(adversarial 路径不调用)")
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
"mock-model"
|
||||
}
|
||||
}
|
||||
|
||||
/// LLM mock 返回结构良好的 JSON → 解析正确,evaluated_by=Llm
|
||||
#[tokio::test]
|
||||
async fn a8_llm_mock_parse_success() {
|
||||
let idea = make_idea(
|
||||
"AI 增长引擎",
|
||||
"面向用户的核心功能,带来显著增长。集成成熟方案。",
|
||||
Priority::Critical,
|
||||
vec!["增长"],
|
||||
);
|
||||
// 一份对齐 schema 的合法 LLM 返回(含 ```json 围栏,验证容错)
|
||||
let llm_text = r#"```json
|
||||
{
|
||||
"positive": {
|
||||
"thesis": "该想法价值高,路径清晰,应立即推进",
|
||||
"evidence": ["用户增长是当前关键路径", "已有成熟方案可复用"],
|
||||
"reasoning": ["可行性高", "ROI 明显"],
|
||||
"confidence": 0.9
|
||||
},
|
||||
"negative": {
|
||||
"thesis": "资源投入与替代方案需权衡",
|
||||
"evidence": ["机会成本存在"],
|
||||
"reasoning": ["需验证更优解"],
|
||||
"confidence": 0.35
|
||||
},
|
||||
"analyst": {
|
||||
"summary": "综合双方,价值明确且路径清晰,建议立即推进",
|
||||
"strengths": ["业务价值明确", "技术路径清晰"],
|
||||
"weaknesses": ["资源需求待评估"],
|
||||
"risks": ["需求范围蔓延"],
|
||||
"opportunities": ["可形成可复用能力"],
|
||||
"final_assessment": "StrongGo"
|
||||
},
|
||||
"final_score": 8.5,
|
||||
"recommendation": "ImmediateAction"
|
||||
}
|
||||
```"#;
|
||||
let provider = Arc::new(MockProvider { text: llm_text.to_string() });
|
||||
let engine = AdversarialEngine::new(provider);
|
||||
let eval = engine.evaluate(&idea).await.unwrap();
|
||||
|
||||
println!("\n[a8] LLM mock 成功路径");
|
||||
println!(" evaluated_by = {:?}", eval.evaluated_by);
|
||||
println!(" final_score = {:.2} recommendation = {:?}", eval.final_score, eval.recommendation);
|
||||
println!(" positive.confidence = {:.2} negative.confidence = {:.2}",
|
||||
eval.positive.confidence, eval.negative.confidence);
|
||||
println!(" analyst.final_assessment = {:?}", eval.analyst.final_assessment);
|
||||
|
||||
assert_eq!(eval.evaluated_by, EvaluatedBy::Llm, "成功路径应为 Llm");
|
||||
assert!((eval.final_score - 8.5).abs() < 1e-9);
|
||||
assert_eq!(eval.recommendation, Recommendation::ImmediateAction);
|
||||
assert_eq!(eval.analyst.final_assessment, AssessmentLevel::StrongGo);
|
||||
assert!((eval.positive.confidence - 0.9).abs() < 1e-9);
|
||||
assert!((eval.negative.confidence - 0.35).abs() < 1e-9);
|
||||
assert_eq!(eval.positive.evidence.len(), 2);
|
||||
assert!(eval.positive.thesis.contains("立即推进"));
|
||||
}
|
||||
|
||||
/// LLM mock 返回非法 JSON → bail → 降级 HeuristicFallback(evaluated_by 标记)
|
||||
#[tokio::test]
|
||||
async fn a9_llm_mock_bad_json_fallback() {
|
||||
let idea = make_idea("坏想法", "测试降级", Priority::Medium, vec![]);
|
||||
let provider = Arc::new(MockProvider { text: "这不是 JSON".to_string() });
|
||||
let engine = AdversarialEngine::new(provider);
|
||||
let eval = engine.evaluate(&idea).await.unwrap();
|
||||
|
||||
println!("\n[a9] LLM mock 非 JSON → 降级");
|
||||
println!(" evaluated_by = {:?} (期望 HeuristicFallback)", eval.evaluated_by);
|
||||
assert_eq!(eval.evaluated_by, EvaluatedBy::HeuristicFallback, "非 JSON 应降级");
|
||||
}
|
||||
|
||||
/// LLM mock 返回枚举非法值 → bail → 降级
|
||||
#[tokio::test]
|
||||
async fn a10_llm_mock_bad_enum_fallback() {
|
||||
let idea = make_idea("枚举非法", "测试枚举映射", Priority::High, vec![]);
|
||||
let llm_text = r#"{
|
||||
"positive": {"thesis":"x","evidence":[],"reasoning":[],"confidence":0.5},
|
||||
"negative": {"thesis":"y","evidence":[],"reasoning":[],"confidence":0.5},
|
||||
"analyst": {"summary":"s","strengths":[],"weaknesses":[],"risks":[],"opportunities":[],"final_assessment":"GoNow"},
|
||||
"final_score": 5.0,
|
||||
"recommendation": "Soon"
|
||||
}"#;
|
||||
let provider = Arc::new(MockProvider { text: llm_text.to_string() });
|
||||
let engine = AdversarialEngine::new(provider);
|
||||
let eval = engine.evaluate(&idea).await.unwrap();
|
||||
|
||||
println!("\n[a10] LLM mock 枚举非法 → 降级");
|
||||
println!(" evaluated_by = {:?} (期望 HeuristicFallback)", eval.evaluated_by);
|
||||
assert_eq!(eval.evaluated_by, EvaluatedBy::HeuristicFallback);
|
||||
}
|
||||
|
||||
/// 数值越界 → clamp(confidence>1 / final_score>10)应被截断,不触发降级
|
||||
#[tokio::test]
|
||||
async fn a11_llm_mock_value_clamp() {
|
||||
let idea = make_idea("越界测试", "数值超出区间", Priority::Medium, vec![]);
|
||||
let llm_text = r#"{
|
||||
"positive": {"thesis":"p","evidence":[],"reasoning":[],"confidence":1.5},
|
||||
"negative": {"thesis":"n","evidence":[],"reasoning":[],"confidence":-0.3},
|
||||
"analyst": {"summary":"s","strengths":[],"weaknesses":[],"risks":[],"opportunities":[],"final_assessment":"Recommended"},
|
||||
"final_score": 99.0,
|
||||
"recommendation": "Soon"
|
||||
}"#;
|
||||
let provider = Arc::new(MockProvider { text: llm_text.to_string() });
|
||||
let engine = AdversarialEngine::new(provider);
|
||||
let eval = engine.evaluate(&idea).await.unwrap();
|
||||
|
||||
println!("\n[a11] LLM mock 数值越界 clamp");
|
||||
println!(" positive.confidence={:.2} (原 1.5 → 期望 1.0)", eval.positive.confidence);
|
||||
println!(" negative.confidence={:.2} (原 -0.3 → 期望 0.0)", eval.negative.confidence);
|
||||
println!(" final_score={:.2} (原 99 → 期望 10.0)", eval.final_score);
|
||||
|
||||
assert_eq!(eval.evaluated_by, EvaluatedBy::Llm, "仅越界不降级");
|
||||
assert!((eval.positive.confidence - 1.0).abs() < 1e-9, "confidence>1 应 clamp 到 1.0");
|
||||
assert!((eval.negative.confidence - 0.0).abs() < 1e-9, "confidence<0 应 clamp 到 0.0");
|
||||
assert!((eval.final_score - 10.0).abs() < 1e-9, "final_score>10 应 clamp 到 10.0");
|
||||
}
|
||||
|
||||
/// LLM 返回「前缀文字 + ```json + {...} + ```」(围栏不在开头)→ extract_json
|
||||
/// 正则兜底提取首个 JSON 对象 → 解析成功不降级(CR-40-2 兜底路径)。
|
||||
#[tokio::test]
|
||||
async fn a12_llm_mock_fenced_not_at_start() {
|
||||
let idea = make_idea("围栏不在开头", "测试正则兜底", Priority::High, vec![]);
|
||||
let llm_text = "好的,以下是我的评估:\n```json\n{\n \"positive\": {\"thesis\":\"p\",\"evidence\":[],\"reasoning\":[],\"confidence\":0.7},\n \"negative\": {\"thesis\":\"n\",\"evidence\":[],\"reasoning\":[],\"confidence\":0.4},\n \"analyst\": {\"summary\":\"s\",\"strengths\":[],\"weaknesses\":[],\"risks\":[],\"opportunities\":[],\"final_assessment\":\"Recommended\"},\n \"final_score\": 7.0,\n \"recommendation\": \"Soon\"\n}\n```\n希望对你有帮助。";
|
||||
let provider = Arc::new(MockProvider { text: llm_text.to_string() });
|
||||
let engine = AdversarialEngine::new(provider);
|
||||
let eval = engine.evaluate(&idea).await.unwrap();
|
||||
|
||||
println!("\n[a12] 围栏不在开头 → 正则兜底提取");
|
||||
println!(" evaluated_by = {:?} (期望 Llm,不应降级)", eval.evaluated_by);
|
||||
assert_eq!(eval.evaluated_by, EvaluatedBy::Llm, "围栏不在开头应正则兜底解析成功");
|
||||
assert!((eval.final_score - 7.0).abs() < 1e-9);
|
||||
assert_eq!(eval.recommendation, Recommendation::Soon);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
//! 对抗评估 — 纯函数 / 类型 / 常量(自 adversarial.rs 抽离,纯搬迁,零行为变更)。
|
||||
//!
|
||||
//! 本文件承载 [`AdversarialEval`] 及其字段类型、LLM prompt 构造与 JSON 解析等无副作用
|
||||
//! 逻辑;有状态的引擎 [`AdversarialEngine`](含 evaluate / evaluate_with_llm 主入口与
|
||||
//! ARC-260618-01-e 待决策逻辑)仍留在 `adversarial.rs`,二者经 [`use`] 互相引用。
|
||||
//!
|
||||
//! 抽离边界:类型定义(非 impl)+ 常量 + 顶层 free function。impl 主体不动,外部
|
||||
//! 已用路径(`df_ideas::adversarial::{AdversarialEngine, AdversarialEval, Recommendation}` 等)
|
||||
//! 由 adversarial.rs 通过 `pub use` 重新导出,调用方零变更。
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use df_types::types::{IdeaId, Priority};
|
||||
use crate::capture::Idea;
|
||||
|
||||
/// 评估来源标记
|
||||
///
|
||||
/// `Default = Heuristic`:老数据(F-07 之前)序列化时无 evaluated_by 字段,
|
||||
/// 反序列化回落启发式(与 F-07 之前行为一致)。
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum EvaluatedBy {
|
||||
/// LLM 深度评估
|
||||
Llm,
|
||||
/// 启发式评估(无 LLM 配置时的默认模式,也是老数据反序列化默认值)
|
||||
#[default]
|
||||
Heuristic,
|
||||
/// 启发式降级(LLM 调用失败后 fallback)
|
||||
HeuristicFallback,
|
||||
}
|
||||
|
||||
/// 对抗评估结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AdversarialEval {
|
||||
pub idea_id: IdeaId,
|
||||
pub positive: Argument,
|
||||
pub negative: Argument,
|
||||
pub analyst: AnalystAnalysis,
|
||||
pub final_score: f64,
|
||||
pub recommendation: Recommendation,
|
||||
/// 评估来源(Llm / Heuristic / HeuristicFallback),前端据此显示评估深度标签
|
||||
#[serde(default)]
|
||||
pub evaluated_by: EvaluatedBy,
|
||||
}
|
||||
|
||||
/// 论点(正方/反方共用同一结构)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Argument {
|
||||
pub thesis: String, // 核心观点
|
||||
pub evidence: Vec<String>, // 证据支持
|
||||
pub reasoning: Vec<String>, // 推理过程
|
||||
pub confidence: f64, // 置信度 0-1
|
||||
}
|
||||
|
||||
/// AI 分析师综合分析
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AnalystAnalysis {
|
||||
pub summary: String, // 综合总结
|
||||
pub strengths: Vec<String>, // 主要优势
|
||||
pub weaknesses: Vec<String>, // 主要劣势
|
||||
pub risks: Vec<String>, // 潜在风险
|
||||
pub opportunities: Vec<String>, // 机会点
|
||||
pub final_assessment: AssessmentLevel, // 最终评估
|
||||
}
|
||||
|
||||
/// 评估等级
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum AssessmentLevel {
|
||||
StrongGo, // 强烈推荐执行
|
||||
Recommended, // 推荐执行
|
||||
Conditional, // 有条件执行
|
||||
Revised, // 需要修改后执行
|
||||
Defer, // 推迟执行
|
||||
}
|
||||
|
||||
/// 最终建议
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum Recommendation {
|
||||
ImmediateAction, // 立即行动
|
||||
Soon, // 尽快行动
|
||||
WithResources, // 配置资源后行动
|
||||
ResearchMore, // 需要更多研究
|
||||
Monitor, // 持续监控
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// LLM 对抗评估 — prompt 构造 / JSON 解析(F-260614-03)
|
||||
// ============================================================
|
||||
|
||||
/// LLM 角色 / 输出契约的系统级约束。
|
||||
///
|
||||
/// 放为 const 便于在 prompt 头部注入,与 [`build_adversarial_prompt`] 的「想法上下文」
|
||||
/// 分离:角色设定稳定不变,想法上下文按评估对象动态拼。
|
||||
pub(crate) const SYSTEM_PROMPT: &str = "\
|
||||
你是一位资深的技术产品决策顾问。你将主持一场三角色对抗式辩论,对一个软件想法做结构化评估:
|
||||
- 正方(Advocate):论证为什么应该立即推进该想法,挖掘价值与可行性证据。
|
||||
- 反方(Skeptic):质疑该想法,挖掘风险、机会成本与替代方案,不放过任何隐患。
|
||||
- 分析师(Analyst):综合正反方观点,给出客观、平衡的最终裁决。
|
||||
|
||||
规则:
|
||||
1. 你必须只输出一个 JSON 对象,不要输出任何解释性文字、Markdown 代码块标记或前后缀。
|
||||
2. 所有枚举字段只能取下方 schema 列出的字符串字面量(区分大小写)。
|
||||
3. 数值字段必须落在指定区间内。
|
||||
4. 论点(thesis / evidence / reasoning / summary 等)用中文表达,简洁有信息密度。";
|
||||
|
||||
/// 构造对抗评估 prompt(三角色辩论 + 严格 JSON schema)。
|
||||
///
|
||||
/// 输出 prompt 包含:想法上下文(title/description/priority/tags)+ 角色 + JSON 输出
|
||||
/// 契约(含字段说明与枚举字面量),让 LLM 单轮产出可解析的结构化评估。
|
||||
pub(crate) fn build_adversarial_prompt(idea: &Idea) -> String {
|
||||
// 描述截断防 prompt 过长:> 800 字按 800 截并附省略号提示。
|
||||
let desc_raw = idea.description.trim();
|
||||
let description = if desc_raw.chars().count() > 800 {
|
||||
let head: String = desc_raw.chars().take(800).collect();
|
||||
format!("{head}……(描述已截断,原长超过 800 字)")
|
||||
} else if desc_raw.is_empty() {
|
||||
"(描述为空,需求边界待补充)".to_string()
|
||||
} else {
|
||||
desc_raw.to_string()
|
||||
};
|
||||
|
||||
let tags = if idea.tags.is_empty() {
|
||||
"(无标签)".to_string()
|
||||
} else {
|
||||
idea.tags.join("、")
|
||||
};
|
||||
|
||||
format!(
|
||||
"\
|
||||
请对以下软件想法进行三角色对抗式评估。
|
||||
|
||||
【想法上下文】
|
||||
标题:{title}
|
||||
描述:{description}
|
||||
优先级:{priority}
|
||||
标签:{tags}
|
||||
|
||||
【输出 JSON schema】(只输出该 JSON 对象,字段名与枚举字面量严格一致)
|
||||
{{
|
||||
\"positive\": {{
|
||||
\"thesis\": \"正方核心观点(一句话)\",
|
||||
\"evidence\": [\"证据 1\", \"证据 2\"],
|
||||
\"reasoning\": [\"推理 1\", \"推理 2\"],
|
||||
\"confidence\": 0.0到1.0之间的浮点数,正方置信度
|
||||
}},
|
||||
\"negative\": {{
|
||||
\"thesis\": \"反方核心观点(一句话)\",
|
||||
\"evidence\": [\"证据 1\"],
|
||||
\"reasoning\": [\"推理 1\"],
|
||||
\"confidence\": 0.0到1.0之间的浮点数,反方置信度
|
||||
}},
|
||||
\"analyst\": {{
|
||||
\"summary\": \"分析师综合总结(一段话)\",
|
||||
\"strengths\": [\"主要优势 1\"],
|
||||
\"weaknesses\": [\"主要劣势 1\"],
|
||||
\"risks\": [\"潜在风险 1\"],
|
||||
\"opportunities\": [\"机会点 1\"],
|
||||
\"final_assessment\": \"StrongGo | Recommended | Conditional | Revised | Defer\"
|
||||
}},
|
||||
\"final_score\": 0到10之间的浮点数,综合评分,
|
||||
\"recommendation\": \"ImmediateAction | Soon | WithResources | ResearchMore | Monitor\"
|
||||
}}
|
||||
|
||||
枚举字段说明:
|
||||
- final_assessment: StrongGo=强烈推荐执行 / Recommended=推荐执行 / Conditional=有条件执行 / Revised=需修改后执行 / Defer=推迟执行
|
||||
- recommendation: ImmediateAction=立即行动 / Soon=尽快行动 / WithResources=配置资源后行动 / ResearchMore=需更多研究 / Monitor=持续监控
|
||||
|
||||
要求:
|
||||
- positive 与 negative 的论点必须真实基于上方想法上下文,不要泛泛而谈。
|
||||
- analyst.summary 必须总结双方并给出明确倾向,不要模棱两可。
|
||||
- final_score 与 recommendation 应与 analyst.final_assessment 自洽(如 Defer 对应低分与 Monitor)。
|
||||
- 只输出 JSON,不要任何额外文字。",
|
||||
title = idea.title,
|
||||
description = description,
|
||||
priority = priority_label(&idea.priority),
|
||||
tags = tags,
|
||||
)
|
||||
}
|
||||
|
||||
/// LLM 返回的非类型化 JSON 表示(与 [`AdversarialEval`] 结构对齐,但枚举为字符串、
|
||||
/// evaluated_by 字段省略——由 [`AdversarialEngine::evaluate`] 调用方覆盖)。
|
||||
///
|
||||
/// 采用独立中间结构而非直接 serde 到 [`AdversarialEval`]:便于在枚举非法时给出
|
||||
/// 字段级错误信息(`final_assessment: "GoNow" 不在合法集合`),而非整条记录丢弃;
|
||||
/// 同时留出数值 clamp 的统一收口。
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct LlmEvalRaw {
|
||||
pub(crate) positive: ArgumentRaw,
|
||||
pub(crate) negative: ArgumentRaw,
|
||||
pub(crate) analyst: AnalystRaw,
|
||||
pub(crate) final_score: f64,
|
||||
pub(crate) recommendation: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct ArgumentRaw {
|
||||
pub(crate) thesis: String,
|
||||
#[serde(default)]
|
||||
pub(crate) evidence: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) reasoning: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) confidence: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct AnalystRaw {
|
||||
#[serde(default)]
|
||||
pub(crate) summary: String,
|
||||
#[serde(default)]
|
||||
pub(crate) strengths: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) weaknesses: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) risks: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) opportunities: Vec<String>,
|
||||
pub(crate) final_assessment: String,
|
||||
}
|
||||
|
||||
/// 解析 LLM 返回文本为 [`AdversarialEval`]。
|
||||
///
|
||||
/// 容错策略(任一失败 `bail` → 由 [`AdversarialEngine::evaluate`] 降级启发式):
|
||||
/// 1. 剥离 ```json … ``` 代码块围栏与首尾空白(部分 LLM 会无视「只输出 JSON」要求)。
|
||||
/// 2. `serde_json` 反序列化为 [`LlmEvalRaw`];字段类型错 / 缺失必填 → bail。
|
||||
/// 3. 枚举字符串映射([`parse_assessment_level`] / [`parse_recommendation`]);非法值 → bail。
|
||||
/// 4. 数值 clamp:confidence ∈ [0,1],final_score ∈ [0,10]。
|
||||
pub(crate) fn parse_llm_eval(text: &str, idea_id: &str) -> Result<AdversarialEval> {
|
||||
let json_str = extract_json(text);
|
||||
let raw: LlmEvalRaw = serde_json::from_str(&json_str).map_err(|e| {
|
||||
anyhow::anyhow!("LLM 返回非合法 JSON 或字段缺失: {e}")
|
||||
})?;
|
||||
|
||||
let final_assessment = parse_assessment_level(&raw.analyst.final_assessment)?;
|
||||
let recommendation = parse_recommendation(&raw.recommendation)?;
|
||||
|
||||
let positive = Argument {
|
||||
thesis: raw.positive.thesis,
|
||||
evidence: raw.positive.evidence,
|
||||
reasoning: raw.positive.reasoning,
|
||||
confidence: raw.positive.confidence.clamp(0.0, 1.0),
|
||||
};
|
||||
let negative = Argument {
|
||||
thesis: raw.negative.thesis,
|
||||
evidence: raw.negative.evidence,
|
||||
reasoning: raw.negative.reasoning,
|
||||
confidence: raw.negative.confidence.clamp(0.0, 1.0),
|
||||
};
|
||||
let analyst = AnalystAnalysis {
|
||||
summary: raw.analyst.summary,
|
||||
strengths: raw.analyst.strengths,
|
||||
weaknesses: raw.analyst.weaknesses,
|
||||
risks: raw.analyst.risks,
|
||||
opportunities: raw.analyst.opportunities,
|
||||
final_assessment,
|
||||
};
|
||||
|
||||
Ok(AdversarialEval {
|
||||
idea_id: idea_id.to_string(),
|
||||
positive,
|
||||
negative,
|
||||
analyst,
|
||||
// final_score clamp 单点收口于此(evaluate_with_llm 不再重复 clamp,CR-40-1)
|
||||
final_score: raw.final_score.clamp(0.0, 10.0),
|
||||
recommendation,
|
||||
// 由 evaluate() 按 LLM 调度路径覆盖为 EvaluatedBy::Llm
|
||||
evaluated_by: EvaluatedBy::Llm,
|
||||
})
|
||||
}
|
||||
|
||||
/// 从 LLM 返回文本中提取 JSON 主体。
|
||||
///
|
||||
/// 提取优先级:
|
||||
/// 1. 代码围栏在开头(```json … ``` 或 ``` … ```)→ 快路径剥离围栏返回主体。
|
||||
/// 2. 围栏不在开头(LLM 输出「前缀文字 + ```json + {...} + ```」)→ 正则
|
||||
/// `(?s)\{.*\}` 兜底提取首个 `{` 到最后一个 `}` 的片段(增强命中率)。
|
||||
///
|
||||
/// 注意:兜底返回的是「原始文本中首个 JSON 对象字面量」,**不再** trim 后整段交给
|
||||
/// serde——由 [`parse_llm_eval`] 的 serde 步骤校验合法性,失败即 bail 降级。
|
||||
pub(crate) fn extract_json(text: &str) -> String {
|
||||
let trimmed = text.trim();
|
||||
// 快路径:围栏在开头(```json ... ``` 或 ``` ... ```)
|
||||
if let Some(rest) = trimmed.strip_prefix("```") {
|
||||
// 跳过语言标记(json/JSON 等)到首个换行
|
||||
let after_lang = match rest.find('\n') {
|
||||
Some(idx) => &rest[idx + 1..],
|
||||
None => rest,
|
||||
};
|
||||
let body = after_lang.trim_end();
|
||||
if let Some(body_inner) = body.strip_suffix("```") {
|
||||
return body_inner.trim().to_string();
|
||||
}
|
||||
// 围栏闭合缺失(如 JSON 与 ``` 同行 / 闭合后仍有文字 / 多行 JSON 末尾混噪声):
|
||||
// 快路径无法干净剥离 → 落到正则兜底提取首个 JSON 对象,提升命中率。
|
||||
// (原实现在此处 return body.trim() 会把语言标记/尾随文字一起喂 serde,
|
||||
// 必然失败降级启发式,即便正则本可救回。)
|
||||
}
|
||||
// 兜底:围栏不在开头或混杂前后文字 → 正则提取首个 JSON 对象(CR-40-2)。
|
||||
// (?s) 让 . 匹配换行,贪婪 {*} 取首 { 到末 },覆盖嵌套对象。
|
||||
// 提取失败(无 { })则返回 trimmed 走原文 serde 报错降级,语义不变。
|
||||
static JSON_RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
|
||||
let re = JSON_RE.get_or_init(|| regex::Regex::new(r"(?s)\{.*\}").expect("合法静态正则"));
|
||||
match re.captures(trimmed) {
|
||||
Some(c) => c.get(0).map(|m| m.as_str().to_string()).unwrap_or_else(|| trimmed.to_string()),
|
||||
None => trimmed.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 枚举字面量 → [`AssessmentLevel`]。区分大小写匹配 schema 文档约定。
|
||||
pub(crate) fn parse_assessment_level(s: &str) -> Result<AssessmentLevel> {
|
||||
match s.trim() {
|
||||
"StrongGo" => Ok(AssessmentLevel::StrongGo),
|
||||
"Recommended" => Ok(AssessmentLevel::Recommended),
|
||||
"Conditional" => Ok(AssessmentLevel::Conditional),
|
||||
"Revised" => Ok(AssessmentLevel::Revised),
|
||||
"Defer" => Ok(AssessmentLevel::Defer),
|
||||
other => anyhow::bail!(
|
||||
"final_assessment 枚举值非法: {:?}(合法: StrongGo|Recommended|Conditional|Revised|Defer)",
|
||||
other
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// 枚举字面量 → [`Recommendation`]。区分大小写匹配 schema 文档约定。
|
||||
pub(crate) fn parse_recommendation(s: &str) -> Result<Recommendation> {
|
||||
match s.trim() {
|
||||
"ImmediateAction" => Ok(Recommendation::ImmediateAction),
|
||||
"Soon" => Ok(Recommendation::Soon),
|
||||
"WithResources" => Ok(Recommendation::WithResources),
|
||||
"ResearchMore" => Ok(Recommendation::ResearchMore),
|
||||
"Monitor" => Ok(Recommendation::Monitor),
|
||||
other => anyhow::bail!(
|
||||
"recommendation 枚举值非法: {:?}(合法: ImmediateAction|Soon|WithResources|ResearchMore|Monitor)",
|
||||
other
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn priority_label(p: &Priority) -> &'static str {
|
||||
match p {
|
||||
Priority::Critical => "紧急",
|
||||
Priority::High => "高",
|
||||
Priority::Medium => "中",
|
||||
Priority::Low => "低",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn assessment_desc(level: &AssessmentLevel) -> &'static str {
|
||||
match level {
|
||||
AssessmentLevel::StrongGo => "价值高且可行性强",
|
||||
AssessmentLevel::Recommended => "整体值得推进",
|
||||
AssessmentLevel::Conditional => "有条件地推进",
|
||||
AssessmentLevel::Revised => "需调整后再评估",
|
||||
AssessmentLevel::Defer => "建议暂缓",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn action_hint(level: &AssessmentLevel) -> &'static str {
|
||||
match level {
|
||||
AssessmentLevel::StrongGo => "立即立项启动",
|
||||
AssessmentLevel::Recommended => "尽快排期",
|
||||
AssessmentLevel::Conditional => "配置资源后启动",
|
||||
AssessmentLevel::Revised => "补充信息后重新评估",
|
||||
AssessmentLevel::Defer => "持续观察时机",
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,8 @@
|
||||
//! 想法捕获 — 快速记录与管理
|
||||
//! 想法实体与评分类型 — 供 scoring/adversarial/promotion 共享
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use df_core::types::{IdeaId, Priority};
|
||||
|
||||
/// 捕获一个新想法的输入
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CaptureInput {
|
||||
/// 标题
|
||||
pub title: String,
|
||||
/// 详细描述
|
||||
pub description: String,
|
||||
/// 优先级
|
||||
#[serde(default)]
|
||||
pub priority: Priority,
|
||||
/// 标签
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
/// 来源(如 "用户输入"、"AI 生成"、"会议记录")
|
||||
pub source: Option<String>,
|
||||
}
|
||||
use df_types::types::{IdeaId, Priority};
|
||||
|
||||
/// 想法实体
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -31,7 +14,7 @@ pub struct Idea {
|
||||
/// 详细描述
|
||||
pub description: String,
|
||||
/// 当前状态
|
||||
pub status: df_core::types::IdeaStatus,
|
||||
pub status: df_types::types::IdeaStatus,
|
||||
/// 优先级
|
||||
pub priority: Priority,
|
||||
/// 评分
|
||||
@@ -60,39 +43,3 @@ pub struct IdeaScores {
|
||||
/// 综合评分 (加权平均)
|
||||
pub overall: f64,
|
||||
}
|
||||
|
||||
/// 想法捕获器
|
||||
pub struct IdeaCapture;
|
||||
|
||||
impl IdeaCapture {
|
||||
/// 捕获一个新想法
|
||||
///
|
||||
/// TODO: 接入存储层持久化
|
||||
pub fn capture(input: CaptureInput) -> Idea {
|
||||
let now = chrono::Utc::now();
|
||||
Idea {
|
||||
id: df_core::types::new_id(),
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
status: df_core::types::IdeaStatus::Draft,
|
||||
priority: input.priority,
|
||||
scores: None,
|
||||
tags: input.tags,
|
||||
source: input.source,
|
||||
related_ids: Vec::new(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
|
||||
/// 快速捕获(仅标题)
|
||||
pub fn quick_capture(title: String) -> Idea {
|
||||
Self::capture(CaptureInput {
|
||||
title,
|
||||
description: String::new(),
|
||||
priority: Priority::default(),
|
||||
tags: Vec::new(),
|
||||
source: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,7 @@
|
||||
//! df-ideas: 想法池 — 捕获、评估、评分、关联图、晋升
|
||||
//! df-ideas: 想法池 — 捕获、评估、评分、晋升
|
||||
|
||||
pub mod adversarial;
|
||||
mod adversarial_helpers;
|
||||
pub mod capture;
|
||||
pub mod evaluator;
|
||||
pub mod graph;
|
||||
pub mod promotion;
|
||||
pub mod scoring;
|
||||
|
||||
@@ -1,91 +1,16 @@
|
||||
//! 想法晋升 — 将想法转为项目
|
||||
//! 想法晋升 — PromotionResult(promote_idea IPC 返回类型)。
|
||||
//! 实际晋升路径:commands/idea.rs::promote_idea → df-project::ProjectManager::create_from_idea。
|
||||
//! 历史空壳 IdeaPromoter/PromotionPolicy 已删(R-PD-14)。
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::Serialize;
|
||||
|
||||
use df_core::types::{IdeaId, ProjectId};
|
||||
use df_types::types::{IdeaId, ProjectId};
|
||||
|
||||
use crate::capture::Idea;
|
||||
use crate::evaluator::Recommendation;
|
||||
|
||||
/// 晋升结果
|
||||
#[derive(Debug, Clone)]
|
||||
/// 晋升结果(promote_idea IPC 返回类型)
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct PromotionResult {
|
||||
pub idea_id: IdeaId,
|
||||
pub project_id: ProjectId,
|
||||
pub promoted: bool,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
/// 晋升策略
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum PromotionPolicy {
|
||||
/// 自动晋升(评分达标时自动转为项目)
|
||||
Auto,
|
||||
/// 手动确认(需要人工审批)
|
||||
Manual,
|
||||
/// 半自动(AI 评估 + 人工确认)
|
||||
SemiAuto,
|
||||
}
|
||||
|
||||
/// 想法晋升器
|
||||
pub struct IdeaPromoter {
|
||||
policy: PromotionPolicy,
|
||||
}
|
||||
|
||||
impl IdeaPromoter {
|
||||
/// 创建晋升器
|
||||
pub fn new(policy: PromotionPolicy) -> Self {
|
||||
Self { policy }
|
||||
}
|
||||
|
||||
/// 评估并尝试晋升想法
|
||||
///
|
||||
/// TODO: 接入 df-project 创建项目
|
||||
pub fn try_promote(&self, idea: &Idea, recommendation: &Recommendation) -> Result<PromotionResult> {
|
||||
match self.policy {
|
||||
PromotionPolicy::Auto => {
|
||||
if matches!(recommendation, Recommendation::StrongApprove | Recommendation::Approve) {
|
||||
self.do_promote(idea)
|
||||
} else {
|
||||
Ok(PromotionResult {
|
||||
idea_id: idea.id.clone(),
|
||||
project_id: String::new(),
|
||||
promoted: false,
|
||||
reason: format!("评估建议 {:?},未达到自动晋升标准", recommendation),
|
||||
})
|
||||
}
|
||||
}
|
||||
PromotionPolicy::Manual => {
|
||||
Ok(PromotionResult {
|
||||
idea_id: idea.id.clone(),
|
||||
project_id: String::new(),
|
||||
promoted: false,
|
||||
reason: "手动模式,等待人工审批".to_string(),
|
||||
})
|
||||
}
|
||||
PromotionPolicy::SemiAuto => {
|
||||
// TODO: AI 评估 + 人工确认流程
|
||||
Ok(PromotionResult {
|
||||
idea_id: idea.id.clone(),
|
||||
project_id: String::new(),
|
||||
promoted: false,
|
||||
reason: "半自动模式,等待确认".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行晋升操作
|
||||
fn do_promote(&self, idea: &Idea) -> Result<PromotionResult> {
|
||||
let project_id = df_core::types::new_id();
|
||||
// TODO: 调用 df-project 创建项目
|
||||
tracing::info!("想法 {} 晋升为项目 {}", idea.id, project_id);
|
||||
|
||||
Ok(PromotionResult {
|
||||
idea_id: idea.id.clone(),
|
||||
project_id,
|
||||
promoted: true,
|
||||
reason: "评估达标,自动晋升".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+272
-19
@@ -1,10 +1,16 @@
|
||||
//! 评分引擎 — 多维度加权评分
|
||||
//! 评分引擎 — 基于想法内容的多维度启发式评分
|
||||
//!
|
||||
//! 各维度分数均为 0-10(IPC 层会 *10 缩放为 0-100 以匹配前端)。
|
||||
//! 启发式依据:优先级、描述充实度、标签、关键词信号——保证稳定且有区分度。
|
||||
//! TODO: 接入 AI 做语义级深度评分。
|
||||
|
||||
use crate::capture::Idea;
|
||||
|
||||
/// 想法评分详情(重新导出 capture 模块中的定义)
|
||||
pub use crate::capture::IdeaScores;
|
||||
|
||||
use df_types::types::Priority;
|
||||
|
||||
/// 评分权重配置
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScoringWeights {
|
||||
@@ -28,17 +34,12 @@ pub struct ScoringEngine;
|
||||
|
||||
impl ScoringEngine {
|
||||
/// 使用默认权重计算评分
|
||||
///
|
||||
/// TODO: 接入 AI 进行深度评分,当前返回基于启发式的分数
|
||||
pub fn compute_default(idea: &Idea) -> IdeaScores {
|
||||
let weights = ScoringWeights::default();
|
||||
Self::compute(idea, &weights)
|
||||
Self::compute(idea, &ScoringWeights::default())
|
||||
}
|
||||
|
||||
/// 使用指定权重计算评分
|
||||
pub fn compute(idea: &Idea, weights: &ScoringWeights) -> IdeaScores {
|
||||
// TODO: 基于想法内容、历史数据、AI 分析等多维度评分
|
||||
// 当前使用基于启发式的占位评分
|
||||
let feasibility = Self::heuristic_feasibility(idea);
|
||||
let impact = Self::heuristic_impact(idea);
|
||||
let urgency = Self::heuristic_urgency(idea);
|
||||
@@ -55,21 +56,273 @@ impl ScoringEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/// 启发式可行性评分
|
||||
fn heuristic_feasibility(_idea: &Idea) -> f64 {
|
||||
// TODO: 基于描述复杂度、资源需求等评估
|
||||
5.0
|
||||
/// 启发式可行性评分(描述充实度 + 技术/资源信号词)
|
||||
fn heuristic_feasibility(idea: &Idea) -> f64 {
|
||||
let mut score = 5.0_f64;
|
||||
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 {
|
||||
// TODO: 基于业务价值、用户影响等评估
|
||||
5.0
|
||||
/// 启发式影响力评分(优先级 + 价值信号词 + 标签广度)
|
||||
fn heuristic_impact(idea: &Idea) -> f64 {
|
||||
let mut score = match idea.priority {
|
||||
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 {
|
||||
// TODO: 基于优先级、时间窗口等评估
|
||||
5.0
|
||||
/// 启发式紧急度评分(优先级 + 时效信号词)
|
||||
fn heuristic_urgency(idea: &Idea) -> f64 {
|
||||
let mut score = match idea.priority {
|
||||
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 后由语义层修正
|
||||
/// 单字否定前缀:关键词紧邻这些字时该命中不计(「不复用」不计入「复用」正向命中)。
|
||||
///
|
||||
/// 注意「别」单字歧义大(别具/别的/特别 均非否定),故不进单字清单——
|
||||
/// 祈使否定「别」改用两字清单兜底(别去/别再/别要/别用 等,见 NEGATION_PREFIXES_2)。
|
||||
const NEGATION_PREFIXES_1: &[char] = &['不', '无', '非', '未', '没', '勿'];
|
||||
/// 两字否定前缀:紧邻关键词的前两字符为这些时该命中不计(「没有复用」「并非简单」「别去复用」)。
|
||||
const NEGATION_PREFIXES_2: &[&str] = &[
|
||||
"没有", "并非", "毫无", "不用", "不是",
|
||||
// 「别X」祈使否定:双字形式可精确匹配,规避「别具/别的/特别」等单字误伤
|
||||
"别去", "别再", "别要", "别用", "别做", "别给",
|
||||
];
|
||||
|
||||
/// 关键词在 text 的 `end` 字节位置前是否处于否定上下文(紧邻否定词)。
|
||||
///
|
||||
/// `end` 是关键词起始字节位置(由 `str::find` 返回,必在 char 边界)。取 `text[..end]`
|
||||
/// 末尾 1-2 个 char 判否定:单字否定(不/无/非/...)或两字否定(没有/并非/...)。
|
||||
///
|
||||
/// 仅取末尾 ≤2 个 char(rev().take(2) 流式),省去每次命中的 `Vec<char>` 分配。
|
||||
fn is_negated_before(text: &str, end: usize) -> bool {
|
||||
if end == 0 {
|
||||
return false;
|
||||
}
|
||||
let before = &text[..end];
|
||||
// 末尾最多 2 char,按 [次末, 末] 顺序收集(n=1 时仅末位)
|
||||
let tail: Vec<char> = before.chars().rev().take(2).collect();
|
||||
if tail.is_empty() {
|
||||
return false;
|
||||
}
|
||||
// 单字否定:末位 char(tail[0]) 命中
|
||||
if NEGATION_PREFIXES_1.contains(&tail[0]) {
|
||||
return true;
|
||||
}
|
||||
// 两字否定:需凑齐 2 char,顺序为 [末, 次末] 反转回原序 [次末, 末]
|
||||
if tail.len() == 2 {
|
||||
let last2: String = [tail[1], tail[0]].iter().collect();
|
||||
if NEGATION_PREFIXES_2.contains(&last2.as_str()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 统计 text 中命中任一关键词的数量(小写匹配,兼顾中英文)。
|
||||
///
|
||||
/// 否定前缀处理:关键词紧邻否定词(不复用/无增长/非紧急...)时该命中不计,
|
||||
/// 避免「不复用」反向计入「复用」正向分。双重否定(「不是不复用」)属罕见边缘,
|
||||
/// 不处理(接 LLM 后语义层兜底)。
|
||||
fn count_any(text: &str, keywords: &[&str]) -> usize {
|
||||
let lower = text.to_lowercase();
|
||||
keywords
|
||||
.iter()
|
||||
.filter(|kw| {
|
||||
// 至少一个非否定上下文的出现即计为命中(同一词多次出现,一次有效即可)
|
||||
let mut idx = 0usize;
|
||||
while let Some(rel) = lower[idx..].find(*kw) {
|
||||
let abs = idx + rel;
|
||||
if !is_negated_before(&lower, abs) {
|
||||
return true;
|
||||
}
|
||||
idx = abs + kw.len();
|
||||
}
|
||||
false
|
||||
})
|
||||
.count()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::capture::Idea;
|
||||
use df_types::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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn s9_single_char_negation_suppresses_hit() {
|
||||
// 「不复用已有组件」:复用 被「不」否定不计,已有 命中 → feasibility 低于「复用已有组件」(复用+已有 双命中)
|
||||
let neg = make_idea("不复用", "不复用已有组件", Priority::Medium, vec![]);
|
||||
let pos = make_idea("复用", "复用已有组件", Priority::Medium, vec![]);
|
||||
let s_neg = ScoringEngine::compute_default(&neg);
|
||||
let s_pos = ScoringEngine::compute_default(&pos);
|
||||
println!("\n[s9] 单字否定前缀抑制命中");
|
||||
println!(" 不复用已有组件 feasibility={:.2} 复用已有组件 feasibility={:.2}", s_neg.feasibility, s_pos.feasibility);
|
||||
assert!(
|
||||
s_neg.feasibility < s_pos.feasibility,
|
||||
"单字否定应抑制正向命中致 feasibility 更低: neg={} pos={}",
|
||||
s_neg.feasibility, s_pos.feasibility
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn s10_two_char_negation_suppresses_hit() {
|
||||
// 「没有复用」:两字否定 没有 抑制 复用 命中;「确实复用」无否定 复用 命中
|
||||
let neg = make_idea("t", "没有复用", Priority::Medium, vec![]);
|
||||
let pos = make_idea("t", "确实复用", Priority::Medium, vec![]);
|
||||
let s_neg = ScoringEngine::compute_default(&neg);
|
||||
let s_pos = ScoringEngine::compute_default(&pos);
|
||||
println!("\n[s10] 两字否定前缀抑制命中");
|
||||
println!(" 没有复用 feasibility={:.2} 确实复用 feasibility={:.2}", s_neg.feasibility, s_pos.feasibility);
|
||||
assert!(
|
||||
s_neg.feasibility < s_pos.feasibility,
|
||||
"两字否定「没有」应抑制命中: neg={} pos={}", s_neg.feasibility, s_pos.feasibility
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "df-mcp"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "DevFlow MCP Server — 对外暴露数据层工具(JSON-RPC 2.0 over stdio)"
|
||||
|
||||
[dependencies]
|
||||
df-storage = { path = "../df-storage" }
|
||||
df-types = { path = "../df-types" }
|
||||
# df-nodes: advance_task 复用推进链唯一 status 写入路径(advance_task_atomic,
|
||||
# 含 is_valid_state+can_transition+同态拒绝三层校验),避免 MCP 直调底层
|
||||
# advance_status_atomic 绕过状态机(防止外部客户端非法跳态 todo→done)。
|
||||
df-nodes = { path = "../df-nodes" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
anyhow.workspace = true
|
||||
tracing.workspace = true
|
||||
uuid.workspace = true
|
||||
futures = "0.3"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["full", "test-util"] }
|
||||
@@ -0,0 +1,19 @@
|
||||
//! df-mcp: DevFlow MCP Server — 对外暴露数据层工具
|
||||
//!
|
||||
//! 形态:独立 stdio JSON-RPC 2.0 server。手写最小化协议层(不引外部 MCP SDK),
|
||||
//! 复用 df-storage Repo 做实际数据读写,不重复 CRUD 实现。
|
||||
//!
|
||||
//! 安全降级(对抗评估反方核心):
|
||||
//! - High 风险(delete/purge/run_workflow)默认拒绝,返友好提示
|
||||
//! - Medium 风险(create/update/bind)默认允许 + 日志(tracing::warn)
|
||||
//! - `--read-only` 仅暴露 list/get 工具
|
||||
//! - 不暴露文件系统工具(防绕过应用内路径校验)
|
||||
//! - 仅 stdio / 本地,不暴露网络;无认证(依赖本地隔离)
|
||||
//!
|
||||
//! 入口: [`run_server`] — 由 Tauri CLI 子命令 `devflow mcp-server` 调用。
|
||||
|
||||
pub mod protocol;
|
||||
pub mod server;
|
||||
pub mod tools;
|
||||
|
||||
pub use server::run_server;
|
||||
@@ -0,0 +1,201 @@
|
||||
//! JSON-RPC 2.0 over stdio 协议层
|
||||
//!
|
||||
//! 职责:
|
||||
//! - 按行读 stdin,解析 JSON-RPC Request/Notification
|
||||
//! - 构造 Response / Error 对象并序列化为单行 JSON 写 stdout
|
||||
//! - MCP 三类方法路由(initialize / tools/list / tools/call)由上层 dispatch
|
||||
//!
|
||||
//! 不解析 batch 请求(MCP 客户端实测逐条发,单条足够)。
|
||||
//! 错误码遵循 JSON-RPC 2.0 + MCP 自定义段(-32xxx 协议层 / 0xffff 工具层)。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
// ============================================================
|
||||
// JSON-RPC 2.0 错误码
|
||||
// ============================================================
|
||||
|
||||
/// 协议层错误(JSON-RPC 2.0 预留段 -32000..-32099)
|
||||
pub const PARSE_ERROR: i32 = -32700;
|
||||
pub const INVALID_REQUEST: i32 = -32600;
|
||||
pub const METHOD_NOT_FOUND: i32 = -32601;
|
||||
pub const INVALID_PARAMS: i32 = -32602;
|
||||
pub const INTERNAL_ERROR: i32 = -32603;
|
||||
|
||||
// ============================================================
|
||||
// 请求 / 响应数据结构
|
||||
// ============================================================
|
||||
|
||||
/// JSON-RPC 2.0 请求(请求与通知共用,Notification 时 id=None)
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Request {
|
||||
pub jsonrpc: Option<String>,
|
||||
pub id: Option<Value>,
|
||||
pub method: String,
|
||||
#[serde(default)]
|
||||
pub params: Value,
|
||||
}
|
||||
|
||||
/// JSON-RPC 2.0 错误对象
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ErrorObject {
|
||||
pub code: i32,
|
||||
pub message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub data: Option<Value>,
|
||||
}
|
||||
|
||||
/// JSON-RPC 2.0 响应
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Response {
|
||||
pub jsonrpc: &'static str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub result: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<ErrorObject>,
|
||||
}
|
||||
|
||||
impl Response {
|
||||
/// 成功响应
|
||||
pub fn ok(id: Option<Value>, result: Value) -> Self {
|
||||
Self {
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
result: Some(result),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 错误响应
|
||||
pub fn err(id: Option<Value>, code: i32, message: impl Into<String>, data: Option<Value>) -> Self {
|
||||
Self {
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
result: None,
|
||||
error: Some(ErrorObject {
|
||||
code,
|
||||
message: message.into(),
|
||||
data,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// MCP 方法路由 — 暴露给上层 dispatch 的 enum
|
||||
// ============================================================
|
||||
|
||||
/// MCP 协议层方法(已剥离 JSON-RPC 包装)。未知方法返回 `Unknown`,由 dispatch 回 METHOD_NOT_FOUND。
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum McpMethod {
|
||||
/// initialize 握手:返 server info + capabilities
|
||||
Initialize { params: Value },
|
||||
/// initialized 通知:客户端确认握手完成,无需响应
|
||||
Initialized,
|
||||
/// tools/list:列出所有工具(MCP Tool schema)
|
||||
ToolsList,
|
||||
/// tools/call:调用某工具
|
||||
ToolsCall { name: String, arguments: Value },
|
||||
/// ping 心跳(MCP 2025 规范),回空 result
|
||||
Ping,
|
||||
/// 未识别的方法(回 METHOD_NOT_FOUND)
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl McpMethod {
|
||||
/// 从 Request 路由出 MCP 语义方法。
|
||||
pub fn from_request(req: &Request) -> Self {
|
||||
match req.method.as_str() {
|
||||
"initialize" => McpMethod::Initialize { params: req.params.clone() },
|
||||
"notifications/initialized" => McpMethod::Initialized,
|
||||
"tools/list" => McpMethod::ToolsList,
|
||||
"tools/call" => {
|
||||
let name = req
|
||||
.params
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_owned();
|
||||
let arguments = req.params.get("arguments").cloned().unwrap_or(Value::Null);
|
||||
McpMethod::ToolsCall { name, arguments }
|
||||
}
|
||||
"ping" => McpMethod::Ping,
|
||||
other => McpMethod::Unknown(other.to_owned()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// MCP 协议数据结构(initialize 握手 / Tool schema / CallToolResult)
|
||||
// ============================================================
|
||||
|
||||
/// MCP initialize 握手响应结构(server info + capabilities)
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct InitializeResult {
|
||||
#[serde(rename = "protocolVersion")]
|
||||
pub protocol_version: &'static str,
|
||||
pub capabilities: Capabilities,
|
||||
#[serde(rename = "serverInfo")]
|
||||
pub server_info: ServerInfo,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Capabilities {
|
||||
pub tools: ToolsCapability,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ToolsCapability {
|
||||
#[serde(rename = "listChanged")]
|
||||
pub list_changed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ServerInfo {
|
||||
pub name: &'static str,
|
||||
pub version: &'static str,
|
||||
}
|
||||
|
||||
/// MCP Tool 描述符(tools/list 返回数组元素)
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Tool {
|
||||
pub name: &'static str,
|
||||
pub description: &'static str,
|
||||
#[serde(rename = "inputSchema")]
|
||||
pub input_schema: Value,
|
||||
}
|
||||
|
||||
/// MCP tools/call 成功响应(CallToolResult)
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct CallToolResult {
|
||||
pub content: Vec<ContentBlock>,
|
||||
#[serde(rename = "isError", skip_serializing_if = "Option::is_none")]
|
||||
pub is_error: Option<bool>,
|
||||
}
|
||||
|
||||
/// MCP 内容块(本 server 仅用 text)
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum ContentBlock {
|
||||
Text { text: String },
|
||||
}
|
||||
|
||||
impl CallToolResult {
|
||||
/// 文本成功响应
|
||||
pub fn text(text: impl Into<String>) -> Self {
|
||||
Self {
|
||||
content: vec![ContentBlock::Text { text: text.into() }],
|
||||
is_error: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 文本错误响应(is_error=true 让客户端区分「工具业务错」与「协议错」)
|
||||
pub fn error(text: impl Into<String>) -> Self {
|
||||
Self {
|
||||
content: vec![ContentBlock::Text { text: text.into() }],
|
||||
is_error: Some(true),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
//! MCP server 主循环 — stdio JSON-RPC 2.0
|
||||
//!
|
||||
//! 协议流程:
|
||||
//! 1. 客户端发 initialize → 回 serverInfo + capabilities.tools
|
||||
//! 2. 客户端发 notifications/initialized(通知,不回)
|
||||
//! 3. 客户端发 tools/list → 回工具清单(read-only 模式过滤)
|
||||
//! 4. 客户端发 tools/call → dispatch 到 handler
|
||||
//!
|
||||
//! 高风险(High)工具:tools/list 不暴露(从清单剔除),tools/call 即便绕过也由 handler 兜底拒绝。
|
||||
//! read-only:tools/list 仅留 Low,tools/call Medium/High 一律拒绝。
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use df_storage::db::Database;
|
||||
use serde_json::{json, Value};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
|
||||
use crate::protocol::{
|
||||
CallToolResult, Capabilities, InitializeResult, McpMethod, Request, Response, ServerInfo,
|
||||
ToolsCapability,
|
||||
};
|
||||
use crate::tools::{self, Ctx, RiskLevel};
|
||||
|
||||
/// 协议版本(MCP 2025-06-18)
|
||||
const PROTOCOL_VERSION: &str = "2025-06-18";
|
||||
const SERVER_NAME: &str = "devflow-mcp";
|
||||
const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
/// 启动 MCP server。
|
||||
///
|
||||
/// - `db_path`:SQLite 数据库文件路径(应用同库,开 WAL 模式)
|
||||
/// - `read_only`:true 则仅暴露 list/get 工具
|
||||
///
|
||||
/// 复用 [`Database::open`](df_storage::Database::open)(已含 `PRAGMA journal_mode=WAL`),
|
||||
/// 故 SQLite WAL 状态:随 df-storage 一起已启用,无需额外处理。
|
||||
pub async fn run_server(db_path: &Path, read_only: bool) -> anyhow::Result<()> {
|
||||
let db = Arc::new(Database::open(db_path).await?);
|
||||
let ctx = Ctx::new(db);
|
||||
|
||||
let stdin = tokio::io::stdin();
|
||||
let stdout = tokio::io::stdout();
|
||||
main_loop(stdin, stdout, &ctx, read_only).await
|
||||
}
|
||||
|
||||
/// 可单测的主循环(参数化 stdin/stdout)。
|
||||
///
|
||||
/// 协议正确性:每行一个 JSON-RPC 消息,Response 单行写回(末尾 \n)。
|
||||
/// Notification(id=None)不回响应。
|
||||
pub async fn main_loop<R, W>(
|
||||
stdin: R,
|
||||
stdout: W,
|
||||
ctx: &Ctx,
|
||||
read_only: bool,
|
||||
) -> anyhow::Result<()>
|
||||
where
|
||||
R: tokio::io::AsyncRead + Unpin,
|
||||
W: tokio::io::AsyncWrite + Unpin,
|
||||
{
|
||||
let mut reader = BufReader::new(stdin);
|
||||
let mut writer = stdout;
|
||||
let mut line = String::new();
|
||||
|
||||
loop {
|
||||
line.clear();
|
||||
let n = reader.read_line(&mut line).await?;
|
||||
if n == 0 {
|
||||
// EOF(stdin 关闭),优雅退出
|
||||
break;
|
||||
}
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 解析 JSON-RPC 请求
|
||||
let req: Request = match serde_json::from_str(trimmed) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
// 解析失败:无 id 时无法回响应,只能 log;有 id(尽力猜)回 PARSE_ERROR
|
||||
tracing::warn!(target: "df_mcp", line = %trimmed, err = %e, "解析 JSON-RPC 行失败");
|
||||
let resp = Response::err(None, crate::protocol::PARSE_ERROR, "Parse error", None);
|
||||
write_response(&mut writer, &resp).await?;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let is_notification = req.id.is_none();
|
||||
let method = McpMethod::from_request(&req);
|
||||
|
||||
// Notification 不回响应(除解析错外)
|
||||
if is_notification {
|
||||
match method {
|
||||
McpMethod::Initialized => {
|
||||
tracing::debug!(target: "df_mcp", "客户端 initialized 通知已收");
|
||||
}
|
||||
_ => {
|
||||
tracing::debug!(target: "df_mcp", m = ?method, "忽略未识别通知");
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let resp = dispatch(ctx, read_only, req.id.clone(), method).await;
|
||||
write_response(&mut writer, &resp).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 方法分发 → 构造 Response。
|
||||
///
|
||||
/// `id`:JSON-RPC 请求 id(回响应时原样回填;通知由 main_loop 已过滤)。
|
||||
async fn dispatch(ctx: &Ctx, read_only: bool, id: Option<Value>, method: McpMethod) -> Response {
|
||||
match method {
|
||||
McpMethod::Initialize { .. } => {
|
||||
let result = InitializeResult {
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
capabilities: Capabilities {
|
||||
tools: ToolsCapability { list_changed: false },
|
||||
},
|
||||
server_info: ServerInfo {
|
||||
name: SERVER_NAME,
|
||||
version: SERVER_VERSION,
|
||||
},
|
||||
};
|
||||
Response::ok(
|
||||
id,
|
||||
serde_json::to_value(result).unwrap_or(Value::Null),
|
||||
)
|
||||
}
|
||||
McpMethod::Initialized => {
|
||||
// 通知分支(由 main_loop 已过滤);到这里说明带 id 的 initialized,回空 result
|
||||
Response::ok(id, Value::Object(Default::default()))
|
||||
}
|
||||
McpMethod::ToolsList => {
|
||||
let tools: Vec<_> = tools::all_tools()
|
||||
.into_iter()
|
||||
.filter(|t| visible(read_only, t.risk))
|
||||
.map(|t| serde_json::to_value(&t.tool).unwrap_or(Value::Null))
|
||||
.collect();
|
||||
Response::ok(id, json!({ "tools": tools }))
|
||||
}
|
||||
McpMethod::Ping => Response::ok(id, Value::Object(Default::default())),
|
||||
McpMethod::ToolsCall { name, arguments } => {
|
||||
// 查工具
|
||||
let Some(spec) = tools::find(&name) else {
|
||||
return Response::ok(
|
||||
id,
|
||||
serde_json::to_value(CallToolResult::error(format!("未知工具: {name}")))
|
||||
.unwrap_or(Value::Null),
|
||||
);
|
||||
};
|
||||
// read-only 模式:Medium/High 全拒
|
||||
if read_only && spec.risk != RiskLevel::Low {
|
||||
let r = CallToolResult::error(format!(
|
||||
"只读模式拒绝执行 {name}(风险等级 {:?})",
|
||||
spec.risk
|
||||
));
|
||||
return Response::ok(id, serde_json::to_value(r).unwrap_or(Value::Null));
|
||||
}
|
||||
// 非 read-only:High 兜底拒绝(handler 内也会拒,双保险)
|
||||
if spec.risk == RiskLevel::High {
|
||||
let r = CallToolResult::error(format!(
|
||||
"High 风险操作 {name} 默认拒绝,请在 DevFlow 应用内执行。"
|
||||
));
|
||||
return Response::ok(id, serde_json::to_value(r).unwrap_or(Value::Null));
|
||||
}
|
||||
// Low / Medium:执行
|
||||
let result = (spec.handler)(ctx, arguments).await;
|
||||
Response::ok(id, serde_json::to_value(result).unwrap_or(Value::Null))
|
||||
}
|
||||
McpMethod::Unknown(m) => Response::err(
|
||||
id,
|
||||
crate::protocol::METHOD_NOT_FOUND,
|
||||
format!("Method not found: {m}"),
|
||||
None,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// 工具可见性:read-only 仅 Low,否则 Low + Medium(High 永不可见)
|
||||
fn visible(read_only: bool, risk: RiskLevel) -> bool {
|
||||
if read_only {
|
||||
risk == RiskLevel::Low
|
||||
} else {
|
||||
risk != RiskLevel::High
|
||||
}
|
||||
}
|
||||
|
||||
/// 单行写 Response(末尾 \n,MCP 客户端按行 framing)
|
||||
async fn write_response<W: tokio::io::AsyncWrite + Unpin>(
|
||||
writer: &mut W,
|
||||
resp: &Response,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut buf = serde_json::to_string(resp)?;
|
||||
buf.push('\n');
|
||||
writer.write_all(buf.as_bytes()).await?;
|
||||
writer.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 单测:协议 framing / initialize / tools/list / tools/call / High 拒绝 / read-only 过滤
|
||||
// ============================================================
|
||||
//
|
||||
// 测试驱动 dispatch()(协议层逻辑核心),不跑真实 stdio(真实 stdio 需集成测试)。
|
||||
// main_loop 的 framing(write_response 单行 +\n、notification 不回)由 dispatch 的输入输出间接覆盖。
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::tools::RiskLevel;
|
||||
|
||||
/// 构造内存 DB + Ctx
|
||||
async fn test_ctx() -> Ctx {
|
||||
let db = Arc::new(Database::open_in_memory().await.unwrap());
|
||||
Ctx::new(db)
|
||||
}
|
||||
|
||||
/// 直接驱动 dispatch:喂一行 → 收一行(逐行,notification 跳过)
|
||||
async fn run_io_lines(lines: &[&str], read_only: bool) -> Vec<String> {
|
||||
let ctx = test_ctx().await;
|
||||
let mut out = Vec::new();
|
||||
for line in lines {
|
||||
let req: Request = serde_json::from_str(line).unwrap();
|
||||
let is_notif = req.id.is_none();
|
||||
let method = McpMethod::from_request(&req);
|
||||
if is_notif {
|
||||
continue;
|
||||
}
|
||||
let resp = dispatch(&ctx, read_only, req.id.clone(), method).await;
|
||||
out.push(serde_json::to_string(&resp).unwrap());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn initialize_returns_server_info_and_capabilities() {
|
||||
let input = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#;
|
||||
let out = run_io_lines(&[input], false).await;
|
||||
assert_eq!(out.len(), 1);
|
||||
let v: Value = serde_json::from_str(&out[0]).unwrap();
|
||||
assert_eq!(v["jsonrpc"], "2.0");
|
||||
assert_eq!(v["id"], 1);
|
||||
assert_eq!(v["result"]["protocolVersion"], PROTOCOL_VERSION);
|
||||
assert_eq!(v["result"]["serverInfo"]["name"], SERVER_NAME);
|
||||
// capabilities.tools 存在
|
||||
assert!(v["result"]["capabilities"]["tools"].is_object());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tools_list_excludes_high_risk_by_default() {
|
||||
let input = r#"{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}"#;
|
||||
let out = run_io_lines(&[input], false).await;
|
||||
let v: Value = serde_json::from_str(&out[0]).unwrap();
|
||||
let tools = v["result"]["tools"].as_array().unwrap();
|
||||
let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
|
||||
// 默认(非 read-only):Low + Medium 可见,High 不可见
|
||||
assert!(names.contains(&"list_projects"));
|
||||
assert!(names.contains(&"create_project")); // Medium
|
||||
assert!(!names.contains(&"delete_project")); // High
|
||||
assert!(!names.contains(&"run_workflow")); // High
|
||||
// MCP Tool schema:name + inputSchema
|
||||
let first = &tools[0];
|
||||
assert!(first["name"].is_string());
|
||||
assert!(first["inputSchema"]["type"] == "object");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tools_list_read_only_keeps_only_low() {
|
||||
let input = r#"{"jsonrpc":"2.0","id":3,"method":"tools/list","params":{}}"#;
|
||||
let out = run_io_lines(&[input], true).await;
|
||||
let v: Value = serde_json::from_str(&out[0]).unwrap();
|
||||
let tools = v["result"]["tools"].as_array().unwrap();
|
||||
let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
|
||||
assert!(names.contains(&"list_projects"));
|
||||
assert!(!names.contains(&"create_project")); // Medium 被过滤
|
||||
assert!(!names.contains(&"delete_project")); // High
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tools_call_unknown_method_returns_method_not_found() {
|
||||
let input = r#"{"jsonrpc":"2.0","id":4,"method":"bogus/method","params":{}}"#;
|
||||
let out = run_io_lines(&[input], false).await;
|
||||
let v: Value = serde_json::from_str(&out[0]).unwrap();
|
||||
assert_eq!(v["error"]["code"], crate::protocol::METHOD_NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tools_call_high_risk_is_rejected() {
|
||||
let input =
|
||||
r#"{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"delete_project","arguments":{"id":"x"}}}"#;
|
||||
let out = run_io_lines(&[input], false).await;
|
||||
let v: Value = serde_json::from_str(&out[0]).unwrap();
|
||||
let content = v["result"]["content"].as_array().unwrap();
|
||||
assert_eq!(content[0]["type"], "text");
|
||||
let text = content[0]["text"].as_str().unwrap();
|
||||
assert!(text.contains("拒绝") || text.contains("High"));
|
||||
assert_eq!(v["result"]["isError"], true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tools_call_read_only_rejects_medium() {
|
||||
let input =
|
||||
r#"{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"create_project","arguments":{"name":"t","description":"d"}}}"#;
|
||||
let out = run_io_lines(&[input], true).await;
|
||||
let v: Value = serde_json::from_str(&out[0]).unwrap();
|
||||
let text = v["result"]["content"][0]["text"].as_str().unwrap();
|
||||
assert!(text.contains("只读模式拒绝"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tools_call_create_then_list_project_roundtrip() {
|
||||
let create =
|
||||
r#"{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"create_project","arguments":{"name":"McpProj","description":"via mcp"}}}"#;
|
||||
let list =
|
||||
r#"{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"list_projects","arguments":{}}}"#;
|
||||
let out = run_io_lines(&[create, list], false).await;
|
||||
let created: Value = serde_json::from_str(&out[0]).unwrap();
|
||||
let id = created["result"]["content"][0]["text"]
|
||||
.as_str()
|
||||
.unwrap();
|
||||
let created_v: Value = serde_json::from_str(id).unwrap();
|
||||
assert!(created_v["id"].is_string());
|
||||
|
||||
let listed: Value = serde_json::from_str(&out[1]).unwrap();
|
||||
let list_text = listed["result"]["content"][0]["text"].as_str().unwrap();
|
||||
let list_v: Value = serde_json::from_str(list_text).unwrap();
|
||||
assert_eq!(list_v["count"], 1);
|
||||
assert_eq!(list_v["projects"][0]["name"], "McpProj");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn visible_predicate() {
|
||||
assert!(visible(false, RiskLevel::Low));
|
||||
assert!(visible(false, RiskLevel::Medium));
|
||||
assert!(!visible(false, RiskLevel::High));
|
||||
assert!(visible(true, RiskLevel::Low));
|
||||
assert!(!visible(true, RiskLevel::Medium));
|
||||
assert!(!visible(true, RiskLevel::High));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,714 @@
|
||||
//! 工具暴露层 — 从 df-storage Repo 复用 CRUD,转 MCP Tool schema
|
||||
//!
|
||||
//! 不重复实现数据层逻辑,只做:① 工具元数据(name/description/inputSchema)声明;
|
||||
//! ② 工具调用 → df-storage Repo 方法 → 序列化为 MCP CallToolResult。
|
||||
//!
|
||||
//! 安全降级:
|
||||
//! - [`RiskLevel::High`]:默认拒绝,返「请在 DevFlow 应用内执行」
|
||||
//! - [`RiskLevel::Medium`]:默认允许 + tracing::warn 审计日志
|
||||
//! - [`RiskLevel::Low`]:默认允许,只读无副作用
|
||||
//! - `--read-only`:dispatch 阶段过滤,仅 Low 工具可见
|
||||
//!
|
||||
//! handler 形态:`fn(&Ctx, Value) -> BoxFuture<CallToolResult>`(函数指针 + async 块),
|
||||
//! 避免闭包捕获带来的 Box<dyn> 开销与生命周期问题。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use df_storage::crud::{IdeaRepo, ProjectRepo, TaskRepo};
|
||||
use df_storage::db::Database;
|
||||
use df_storage::models::{IdeaRecord, ProjectRecord, TaskRecord};
|
||||
use df_types::types::new_id;
|
||||
use futures::future::BoxFuture;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::protocol::{CallToolResult, Tool};
|
||||
|
||||
// ============================================================
|
||||
// 风险等级
|
||||
// ============================================================
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RiskLevel {
|
||||
/// 只读无副作用:list/get
|
||||
Low,
|
||||
/// 有副作用但可逆:create/update/bind/restore/advance
|
||||
Medium,
|
||||
/// 不可逆或高破坏:delete/purge/run_workflow → 默认拒绝
|
||||
High,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 工具元数据 + 上下文
|
||||
// ============================================================
|
||||
|
||||
/// 工具元数据 + 风险等级 + handler 函数指针。
|
||||
pub struct ToolSpec {
|
||||
pub tool: Tool,
|
||||
pub risk: RiskLevel,
|
||||
pub handler: HandlerFn,
|
||||
}
|
||||
|
||||
/// handler 函数指针类型:接收上下文引用 + 参数,返回 boxed future。
|
||||
pub type HandlerFn = fn(&Ctx, Value) -> BoxFuture<'static, CallToolResult>;
|
||||
|
||||
/// 工具执行上下文 — 持有所有 Repo 句柄。每个 handler 内部重新构造 Repo(零开销,Repo 仅持 Arc)。
|
||||
///
|
||||
/// 不预存 Repo 是因为 Repo::new 借用 &Database,生命周期管理麻烦;Arc<Database> clone 廉价。
|
||||
pub struct Ctx {
|
||||
pub db: Arc<Database>,
|
||||
}
|
||||
|
||||
impl Ctx {
|
||||
pub fn new(db: Arc<Database>) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// inputSchema 构造助手
|
||||
// ============================================================
|
||||
|
||||
fn object_schema(properties: Value, required: &[&str]) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
"required": required,
|
||||
"additionalProperties": false
|
||||
})
|
||||
}
|
||||
|
||||
fn str_field(desc: &str) -> Value {
|
||||
json!({ "type": "string", "description": desc })
|
||||
}
|
||||
|
||||
fn opt_str_field(desc: &str) -> Value {
|
||||
json!({ "type": "string", "description": desc })
|
||||
}
|
||||
|
||||
fn int_field(desc: &str) -> Value {
|
||||
json!({ "type": "integer", "description": desc })
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 工具清单
|
||||
// ============================================================
|
||||
|
||||
/// 返回全部已注册工具(只读模式由 dispatch 过滤 High/Medium)。
|
||||
pub fn all_tools() -> Vec<&'static ToolSpec> {
|
||||
use RiskLevel::*;
|
||||
vec![
|
||||
// ─── 项目 ───
|
||||
spec("list_projects", "列出所有未删除项目", object_schema(json!({}), &[]), Low, list_projects),
|
||||
spec("get_project", "按 ID 获取项目", object_schema(json!({"id": str_field("项目 ID")}), &["id"]), Low, get_project),
|
||||
spec("create_project", "创建项目(Medium 风险,默认允许+审计日志)", object_schema(json!({"name": str_field("项目名"), "description": str_field("描述"), "status": opt_str_field("状态(默认 active)")}), &["name", "description"]), Medium, create_project),
|
||||
spec("update_project", "更新项目(整体替换 description/status/path/stack)", object_schema(json!({"id": str_field("项目 ID"), "name": str_field("项目名"), "description": str_field("描述"), "status": opt_str_field("状态")}), &["id", "name", "description"]), Medium, update_project),
|
||||
spec("delete_project", "软删项目(进回收站,可恢复)——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"id": str_field("项目 ID")}), &["id"]), High, delete_project),
|
||||
spec("bind_directory", "为项目绑定本地代码目录(会做路径冲突检测,Medium 风险+审计日志)", object_schema(json!({"id": str_field("项目 ID"), "path": str_field("本地目录绝对路径")}), &["id", "path"]), Medium, bind_directory),
|
||||
// ─── 任务 ───
|
||||
spec("list_tasks", "列出所有未删除任务(可按 project_id/status 过滤)", object_schema(json!({"project_id": opt_str_field("按项目过滤(可空)"), "status": opt_str_field("按状态过滤(todo/in_progress/in_review/testing/blocked/done/cancelled,可空)")}), &[]), Low, list_tasks),
|
||||
spec("create_task", "创建任务(Medium 风险,默认允许+审计日志)", object_schema(json!({"project_id": str_field("项目 ID"), "title": str_field("标题"), "description": str_field("描述"), "priority": int_field("优先级(可空,默认 0)")}), &["project_id", "title", "description"]), Medium, create_task),
|
||||
spec("update_task", "更新任务(整体替换)", object_schema(json!({"id": str_field("任务 ID"), "project_id": str_field("项目 ID"), "title": str_field("标题"), "description": str_field("描述")}), &["id", "project_id", "title", "description"]), Medium, update_task),
|
||||
spec("advance_task", "推进任务状态(传目标 status,内部读当前态+状态机校验,Medium 风险+审计日志)", object_schema(json!({"id": str_field("任务 ID"), "to": str_field("目标 status(todo/in_progress/in_review/testing/blocked/done/cancelled)")}), &["id", "to"]), Medium, advance_task),
|
||||
spec("delete_task", "软删任务(进回收站)——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"id": str_field("任务 ID")}), &["id"]), High, delete_task),
|
||||
// ─── 灵感 ───
|
||||
spec("list_ideas", "列出所有想法/灵感", object_schema(json!({}), &[]), Low, list_ideas),
|
||||
spec("create_idea", "创建想法(Medium 风险,默认允许+审计日志)", object_schema(json!({"title": str_field("标题"), "description": str_field("描述"), "priority": int_field("优先级(可空,默认 0)")}), &["title", "description"]), Medium, create_idea),
|
||||
spec("update_idea", "更新想法(整体替换)", object_schema(json!({"id": str_field("想法 ID"), "title": str_field("标题"), "description": str_field("描述")}), &["id", "title", "description"]), Medium, update_idea),
|
||||
spec("delete_idea", "软删想法——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"id": str_field("想法 ID")}), &["id"]), High, delete_idea),
|
||||
spec("evaluate_idea", "对想法做启发式评估(只读,基于 description/title 计算 feasibility/impact/urgency/overall)", object_schema(json!({"id": str_field("想法 ID")}), &["id"]), Low, evaluate_idea),
|
||||
// ─── 工作流(High) ───
|
||||
spec("run_workflow", "触发工作流——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"project_id": str_field("项目 ID"), "task_id": opt_str_field("任务 ID(可空)")}), &["project_id"]), High, run_workflow),
|
||||
// ─── 回收站 ───
|
||||
spec("list_trash", "列出回收站(deleted_at IS NOT NULL 的项目与任务)", object_schema(json!({}), &[]), Low, list_trash),
|
||||
spec("restore_project", "从回收站恢复项目(Medium 风险+审计日志)", object_schema(json!({"id": str_field("项目 ID")}), &["id"]), Medium, restore_project),
|
||||
]
|
||||
}
|
||||
|
||||
/// 工具元数据构造助手 — Box::leak 静态化(进程生命周期,启动一次性构造)。
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn spec(
|
||||
name: &'static str,
|
||||
description: &'static str,
|
||||
input_schema: Value,
|
||||
risk: RiskLevel,
|
||||
handler: HandlerFn,
|
||||
) -> &'static ToolSpec {
|
||||
Box::leak(Box::new(ToolSpec {
|
||||
tool: Tool {
|
||||
name,
|
||||
description,
|
||||
input_schema,
|
||||
},
|
||||
risk,
|
||||
handler,
|
||||
}))
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 工具查找
|
||||
// ============================================================
|
||||
|
||||
/// 按 name 查找工具(线性扫描,工具数 19,O(n) 足够)。
|
||||
pub fn find(name: &str) -> Option<&'static ToolSpec> {
|
||||
all_tools().into_iter().find(|t| t.tool.name == name)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// handler 公共助手
|
||||
// ============================================================
|
||||
|
||||
fn now_millis() -> String {
|
||||
df_types::now_millis().to_string()
|
||||
}
|
||||
|
||||
fn json_ok(v: Value) -> CallToolResult {
|
||||
CallToolResult::text(serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".into()))
|
||||
}
|
||||
|
||||
fn err_str(e: impl std::fmt::Display) -> CallToolResult {
|
||||
CallToolResult::error(format!("内部错误: {e}"))
|
||||
}
|
||||
|
||||
fn medium_audit(name: &str, args_summary: &str) {
|
||||
tracing::warn!(target: "df_mcp_audit", tool = name, args = %args_summary, "MCP Medium 风险工具被外部调用");
|
||||
}
|
||||
|
||||
/// 取必填字符串参数
|
||||
fn arg_str(args: &Value, key: &str) -> Result<String, CallToolResult> {
|
||||
args.get(key)
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_owned())
|
||||
.ok_or_else(|| CallToolResult::error(format!("缺少必填参数: {key}")))
|
||||
}
|
||||
|
||||
/// 取可选字符串参数(默认值)
|
||||
fn arg_str_or(args: &Value, key: &str, default: &str) -> String {
|
||||
args.get(key)
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(default)
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
/// 取可选整数参数(默认值)
|
||||
fn arg_int_or(args: &Value, key: &str, default: i32) -> i32 {
|
||||
args.get(key)
|
||||
.and_then(|v| v.as_i64())
|
||||
.map(|i| i as i32)
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// handler 实现 — 项目
|
||||
// ============================================================
|
||||
|
||||
fn list_projects(ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
let db = ctx.db.clone();
|
||||
Box::pin(async move {
|
||||
let repo = ProjectRepo::new(&db);
|
||||
match repo.list_active().await {
|
||||
Ok(list) => json_ok(json!({ "projects": list, "count": list.len() })),
|
||||
Err(e) => err_str(e),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn get_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
let db = ctx.db.clone();
|
||||
let id = match arg_str(&args, "id") {
|
||||
Ok(v) => v,
|
||||
Err(r) => return Box::pin(std::future::ready(r)),
|
||||
};
|
||||
Box::pin(async move {
|
||||
let repo = ProjectRepo::new(&db);
|
||||
match repo.get_by_id(&id).await {
|
||||
Ok(Some(p)) => json_ok(json!(p)),
|
||||
Ok(None) => CallToolResult::error(format!("项目不存在: {id}")),
|
||||
Err(e) => err_str(e),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn create_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
let db = ctx.db.clone();
|
||||
let name = match arg_str(&args, "name") {
|
||||
Ok(v) => v,
|
||||
Err(r) => return Box::pin(std::future::ready(r)),
|
||||
};
|
||||
let description = arg_str_or(&args, "description", "");
|
||||
let status = arg_str_or(&args, "status", "active");
|
||||
medium_audit("create_project", &name);
|
||||
Box::pin(async move {
|
||||
let now = now_millis();
|
||||
let rec = ProjectRecord {
|
||||
id: new_id(),
|
||||
name,
|
||||
description,
|
||||
status,
|
||||
idea_id: None,
|
||||
path: None,
|
||||
stack: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
};
|
||||
let repo = ProjectRepo::new(&db);
|
||||
match repo.insert(rec).await {
|
||||
Ok(id) => {
|
||||
let created = repo.get_by_id(&id).await.ok().flatten();
|
||||
json_ok(json!({ "id": id, "project": created }))
|
||||
}
|
||||
Err(e) => err_str(e),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn update_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
let db = ctx.db.clone();
|
||||
let id = match arg_str(&args, "id") {
|
||||
Ok(v) => v,
|
||||
Err(r) => return Box::pin(std::future::ready(r)),
|
||||
};
|
||||
let name = arg_str_or(&args, "name", "");
|
||||
let description = arg_str_or(&args, "description", "");
|
||||
let status = arg_str_or(&args, "status", "active");
|
||||
medium_audit("update_project", &id);
|
||||
Box::pin(async move {
|
||||
let repo = ProjectRepo::new(&db);
|
||||
// 先读现有保留 path/stack/idea_id
|
||||
let existing = match repo.get_by_id(&id).await {
|
||||
Ok(Some(p)) => p,
|
||||
Ok(None) => return CallToolResult::error(format!("项目不存在: {id}")),
|
||||
Err(e) => return err_str(e),
|
||||
};
|
||||
let now = now_millis();
|
||||
let rec = ProjectRecord {
|
||||
id: id.clone(),
|
||||
name,
|
||||
description,
|
||||
status,
|
||||
idea_id: existing.idea_id,
|
||||
path: existing.path,
|
||||
stack: existing.stack,
|
||||
created_at: existing.created_at,
|
||||
updated_at: now,
|
||||
};
|
||||
match repo.update_full(&rec).await {
|
||||
Ok(true) => {
|
||||
let updated = repo.get_by_id(&id).await.ok().flatten();
|
||||
json_ok(json!({ "id": id, "project": updated }))
|
||||
}
|
||||
Ok(false) => CallToolResult::error(format!("项目不存在: {id}")),
|
||||
Err(e) => err_str(e),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn delete_project(_ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
// High 风险:默认拒绝(dispatch 兜底 + 此处二次防御,防 dispatch 漏判)
|
||||
Box::pin(std::future::ready(CallToolResult::error(
|
||||
"High 风险操作(delete_project)默认拒绝,请在 DevFlow 应用内执行。",
|
||||
)))
|
||||
}
|
||||
|
||||
fn bind_directory(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
let db = ctx.db.clone();
|
||||
let id = match arg_str(&args, "id") {
|
||||
Ok(v) => v,
|
||||
Err(r) => return Box::pin(std::future::ready(r)),
|
||||
};
|
||||
let path = match arg_str(&args, "path") {
|
||||
Ok(v) => v,
|
||||
Err(r) => return Box::pin(std::future::ready(r)),
|
||||
};
|
||||
medium_audit("bind_directory", &format!("{id} <- {path}"));
|
||||
Box::pin(async move {
|
||||
let repo = ProjectRepo::new(&db);
|
||||
let norm = normalize_path(&path);
|
||||
// 路径冲突检测
|
||||
if let Some(conflict) = repo.find_path_conflict(&norm, Some(&id)).await.ok().flatten() {
|
||||
return CallToolResult::error(format!(
|
||||
"路径已被项目「{}」({})绑定,请先解绑",
|
||||
conflict.name, conflict.id
|
||||
));
|
||||
}
|
||||
// 仅更新 path 字段(用 update_field,保留其它)
|
||||
if !repo.update_field(&id, "path", &path).await.unwrap_or(false) {
|
||||
return CallToolResult::error(format!("项目不存在: {id}"));
|
||||
}
|
||||
let updated = repo.get_by_id(&id).await.ok().flatten();
|
||||
json_ok(json!({ "id": id, "project": updated }))
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// handler 实现 — 任务
|
||||
// ============================================================
|
||||
|
||||
fn list_tasks(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
let db = ctx.db.clone();
|
||||
let project_id_filter = args.get("project_id").and_then(|v| v.as_str()).map(|s| s.to_owned());
|
||||
let status_filter = args.get("status").and_then(|v| v.as_str()).map(|s| s.to_owned());
|
||||
Box::pin(async move {
|
||||
let repo = TaskRepo::new(&db);
|
||||
match repo.list_active().await {
|
||||
Ok(mut list) => {
|
||||
if let Some(pid) = &project_id_filter {
|
||||
list.retain(|t| t.project_id == *pid);
|
||||
}
|
||||
if let Some(st) = &status_filter {
|
||||
list.retain(|t| t.status == *st);
|
||||
}
|
||||
json_ok(json!({ "tasks": list, "count": list.len() }))
|
||||
}
|
||||
Err(e) => err_str(e),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn create_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
let db = ctx.db.clone();
|
||||
let project_id = match arg_str(&args, "project_id") {
|
||||
Ok(v) => v,
|
||||
Err(r) => return Box::pin(std::future::ready(r)),
|
||||
};
|
||||
let title = match arg_str(&args, "title") {
|
||||
Ok(v) => v,
|
||||
Err(r) => return Box::pin(std::future::ready(r)),
|
||||
};
|
||||
let description = arg_str_or(&args, "description", "");
|
||||
let priority = arg_int_or(&args, "priority", 0);
|
||||
medium_audit("create_task", &format!("{project_id}/{title}"));
|
||||
Box::pin(async move {
|
||||
let now = now_millis();
|
||||
let rec = TaskRecord {
|
||||
id: new_id(),
|
||||
project_id,
|
||||
title,
|
||||
description,
|
||||
status: "todo".to_owned(),
|
||||
priority,
|
||||
branch_name: None,
|
||||
assignee: None,
|
||||
workflow_def_id: None,
|
||||
base_branch: None,
|
||||
review_rounds: 0,
|
||||
output_json: None,
|
||||
idea_id: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
};
|
||||
let repo = TaskRepo::new(&db);
|
||||
match repo.insert(rec).await {
|
||||
Ok(id) => {
|
||||
let created = repo.get_by_id(&id).await.ok().flatten();
|
||||
json_ok(json!({ "id": id, "task": created }))
|
||||
}
|
||||
Err(e) => err_str(e),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn update_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
let db = ctx.db.clone();
|
||||
let id = match arg_str(&args, "id") {
|
||||
Ok(v) => v,
|
||||
Err(r) => return Box::pin(std::future::ready(r)),
|
||||
};
|
||||
let project_id = match arg_str(&args, "project_id") {
|
||||
Ok(v) => v,
|
||||
Err(r) => return Box::pin(std::future::ready(r)),
|
||||
};
|
||||
let title = match arg_str(&args, "title") {
|
||||
Ok(v) => v,
|
||||
Err(r) => return Box::pin(std::future::ready(r)),
|
||||
};
|
||||
let description = arg_str_or(&args, "description", "");
|
||||
medium_audit("update_task", &id);
|
||||
Box::pin(async move {
|
||||
let repo = TaskRepo::new(&db);
|
||||
let existing = match repo.get_by_id(&id).await {
|
||||
Ok(Some(t)) => t,
|
||||
Ok(None) => return CallToolResult::error(format!("任务不存在: {id}")),
|
||||
Err(e) => return err_str(e),
|
||||
};
|
||||
let now = now_millis();
|
||||
let rec = TaskRecord {
|
||||
id: id.clone(),
|
||||
project_id,
|
||||
title,
|
||||
description,
|
||||
// 不允许经 MCP 改状态(状态机收口,须走 advance_task)
|
||||
status: existing.status,
|
||||
priority: existing.priority,
|
||||
branch_name: existing.branch_name,
|
||||
assignee: existing.assignee,
|
||||
workflow_def_id: existing.workflow_def_id,
|
||||
base_branch: existing.base_branch,
|
||||
review_rounds: existing.review_rounds,
|
||||
output_json: existing.output_json,
|
||||
idea_id: existing.idea_id,
|
||||
created_at: existing.created_at,
|
||||
updated_at: now,
|
||||
};
|
||||
match repo.update_full(&rec).await {
|
||||
Ok(true) => {
|
||||
let updated = repo.get_by_id(&id).await.ok().flatten();
|
||||
json_ok(json!({ "id": id, "task": updated }))
|
||||
}
|
||||
Ok(false) => CallToolResult::error(format!("任务不存在: {id}")),
|
||||
Err(e) => err_str(e),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn advance_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
let db = ctx.db.clone();
|
||||
let id = match arg_str(&args, "id") {
|
||||
Ok(v) => v,
|
||||
Err(r) => return Box::pin(std::future::ready(r)),
|
||||
};
|
||||
// 不再接收 from:推进链内部读当前态 + 三层校验(is_valid_state / can_transition /
|
||||
// 同态拒绝),避免外部客户端直调 advance_status_atomic 绕过状态机非法跳态(todo→done)。
|
||||
let to = match arg_str(&args, "to") {
|
||||
Ok(v) => v,
|
||||
Err(r) => return Box::pin(std::future::ready(r)),
|
||||
};
|
||||
medium_audit("advance_task", &format!("{id} -> {to}"));
|
||||
Box::pin(async move {
|
||||
let repo = TaskRepo::new(&db);
|
||||
// 复用推进链唯一 status 写入路径(与 IPC advance_task 同源):
|
||||
// - is_valid_state + can_transition + 同态拒绝三层校验
|
||||
// - CAS 防 TOCTOU
|
||||
// - is_regression 自动判定 bump review_rounds(取代旧内联 bump 副本)
|
||||
// - 错误类型(NotFound/Validation/InvalidState)由 thiserror Display 串化
|
||||
match df_nodes::task_advance_node::advance_task_atomic(&repo, &id, &to).await {
|
||||
Ok(updated) => json_ok(json!({ "id": id, "task": updated })),
|
||||
Err(e) => err_str(e),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn delete_task(_ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
Box::pin(std::future::ready(CallToolResult::error(
|
||||
"High 风险操作(delete_task)默认拒绝,请在 DevFlow 应用内执行。",
|
||||
)))
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// handler 实现 — 灵感
|
||||
// ============================================================
|
||||
|
||||
fn list_ideas(ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
let db = ctx.db.clone();
|
||||
Box::pin(async move {
|
||||
let repo = IdeaRepo::new(&db);
|
||||
match repo.list_all().await {
|
||||
Ok(list) => json_ok(json!({ "ideas": list, "count": list.len() })),
|
||||
Err(e) => err_str(e),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn create_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
let db = ctx.db.clone();
|
||||
let title = match arg_str(&args, "title") {
|
||||
Ok(v) => v,
|
||||
Err(r) => return Box::pin(std::future::ready(r)),
|
||||
};
|
||||
let description = arg_str_or(&args, "description", "");
|
||||
let priority = arg_int_or(&args, "priority", 0);
|
||||
medium_audit("create_idea", &title);
|
||||
Box::pin(async move {
|
||||
let now = now_millis();
|
||||
let rec = IdeaRecord {
|
||||
id: new_id(),
|
||||
title,
|
||||
description,
|
||||
status: "draft".to_owned(),
|
||||
priority,
|
||||
score: None,
|
||||
tags: None,
|
||||
source: Some("mcp".to_owned()),
|
||||
promoted_to: None,
|
||||
ai_analysis: None,
|
||||
scores: None,
|
||||
related_ids: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
};
|
||||
let repo = IdeaRepo::new(&db);
|
||||
match repo.insert(rec).await {
|
||||
Ok(id) => {
|
||||
let created = repo.get_by_id(&id).await.ok().flatten();
|
||||
json_ok(json!({ "id": id, "idea": created }))
|
||||
}
|
||||
Err(e) => err_str(e),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn update_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
let db = ctx.db.clone();
|
||||
let id = match arg_str(&args, "id") {
|
||||
Ok(v) => v,
|
||||
Err(r) => return Box::pin(std::future::ready(r)),
|
||||
};
|
||||
let title = arg_str_or(&args, "title", "");
|
||||
let description = arg_str_or(&args, "description", "");
|
||||
medium_audit("update_idea", &id);
|
||||
Box::pin(async move {
|
||||
let repo = IdeaRepo::new(&db);
|
||||
let existing = match repo.get_by_id(&id).await {
|
||||
Ok(Some(i)) => i,
|
||||
Ok(None) => return CallToolResult::error(format!("想法不存在: {id}")),
|
||||
Err(e) => return err_str(e),
|
||||
};
|
||||
let now = now_millis();
|
||||
let rec = IdeaRecord {
|
||||
id: id.clone(),
|
||||
title,
|
||||
description,
|
||||
status: existing.status,
|
||||
priority: existing.priority,
|
||||
score: existing.score,
|
||||
tags: existing.tags,
|
||||
source: existing.source,
|
||||
promoted_to: existing.promoted_to,
|
||||
ai_analysis: existing.ai_analysis,
|
||||
scores: existing.scores,
|
||||
related_ids: existing.related_ids.clone(),
|
||||
created_at: existing.created_at,
|
||||
updated_at: now,
|
||||
};
|
||||
match repo.update_full(&rec).await {
|
||||
Ok(true) => {
|
||||
let updated = repo.get_by_id(&id).await.ok().flatten();
|
||||
json_ok(json!({ "id": id, "idea": updated }))
|
||||
}
|
||||
Ok(false) => CallToolResult::error(format!("想法不存在: {id}")),
|
||||
Err(e) => err_str(e),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn delete_idea(_ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
Box::pin(std::future::ready(CallToolResult::error(
|
||||
"High 风险操作(delete_idea)默认拒绝,请在 DevFlow 应用内执行。",
|
||||
)))
|
||||
}
|
||||
|
||||
fn evaluate_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
let db = ctx.db.clone();
|
||||
let id = match arg_str(&args, "id") {
|
||||
Ok(v) => v,
|
||||
Err(r) => return Box::pin(std::future::ready(r)),
|
||||
};
|
||||
Box::pin(async move {
|
||||
let repo = IdeaRepo::new(&db);
|
||||
let idea = match repo.get_by_id(&id).await {
|
||||
Ok(Some(i)) => i,
|
||||
Ok(None) => return CallToolResult::error(format!("想法不存在: {id}")),
|
||||
Err(e) => return err_str(e),
|
||||
};
|
||||
// 启发式评分(本地确定性,不调 LLM)
|
||||
let scores = heuristic_scores(&idea.title, &idea.description);
|
||||
let now = now_millis();
|
||||
// 写回 scores 字段(整体更新)
|
||||
let mut rec = idea.clone();
|
||||
rec.scores = Some(serde_json::to_string(&scores).unwrap_or_default());
|
||||
rec.updated_at = now;
|
||||
if let Err(e) = repo.update_full(&rec).await {
|
||||
return err_str(e);
|
||||
}
|
||||
json_ok(json!({ "id": id, "idea": rec, "scores": scores }))
|
||||
})
|
||||
}
|
||||
|
||||
/// 启发式评分:基于标题长度/描述详细度/关键词,产出 feasibility/impact/urgency/overall 0-10 分。
|
||||
/// 确定性纯函数,与 df-ideas 评估器对齐维度但不依赖 df-ai。
|
||||
fn heuristic_scores(title: &str, description: &str) -> Value {
|
||||
let desc_len = description.chars().count();
|
||||
let title_len = title.chars().count();
|
||||
// feasibility:描述越详细越可行(评估前已有思考)
|
||||
let feasibility = ((desc_len as f64 / 200.0).min(1.0) * 6.0 + 3.0).min(9.0);
|
||||
// impact:含「核心/关键/重要」等关键词加权
|
||||
let impact_keywords = ["核心", "关键", "重要", "紧急", "blocker", "critical", "core"];
|
||||
let kw_hits = impact_keywords.iter().filter(|k| title.contains(*k) || description.contains(*k)).count();
|
||||
let impact = (5.0 + kw_hits as f64 * 1.5).min(9.0);
|
||||
// urgency:priority 字段不在此,用关键词近似
|
||||
let urgency_kw = ["紧急", "urgent", "asap", "立即", "马上"];
|
||||
let urgency_hits = urgency_kw.iter().filter(|k| title.contains(*k) || description.contains(*k)).count();
|
||||
let urgency = (4.0 + urgency_hits as f64 * 2.0).min(9.0);
|
||||
// overall:加权平均(feasibility/impact/urgency = 0.4/0.4/0.2)
|
||||
let overall = feasibility * 0.4 + impact * 0.4 + urgency * 0.2;
|
||||
let _ = title_len; // 标题长度暂不入分(避免短标题被低估)
|
||||
json!({
|
||||
"feasibility": (feasibility * 10.0).round() / 10.0,
|
||||
"impact": (impact * 10.0).round() / 10.0,
|
||||
"urgency": (urgency * 10.0).round() / 10.0,
|
||||
"overall": (overall * 10.0).round() / 10.0
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// handler 实现 — 工作流(High,拒绝)
|
||||
// ============================================================
|
||||
|
||||
fn run_workflow(_ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
Box::pin(std::future::ready(CallToolResult::error(
|
||||
"High 风险操作(run_workflow)默认拒绝。工作流涉及代码生成/审查/分支操作,请在 DevFlow 应用内执行。",
|
||||
)))
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// handler 实现 — 回收站
|
||||
// ============================================================
|
||||
|
||||
fn list_trash(ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
let db = ctx.db.clone();
|
||||
Box::pin(async move {
|
||||
let projects = match ProjectRepo::new(&db).list_deleted().await {
|
||||
Ok(v) => v,
|
||||
Err(e) => return err_str(e),
|
||||
};
|
||||
let tasks = match TaskRepo::new(&db).list_deleted().await {
|
||||
Ok(v) => v,
|
||||
Err(e) => return err_str(e),
|
||||
};
|
||||
json_ok(json!({
|
||||
"projects": projects,
|
||||
"tasks": tasks,
|
||||
"project_count": projects.len(),
|
||||
"task_count": tasks.len()
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
fn restore_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
let db = ctx.db.clone();
|
||||
let id = match arg_str(&args, "id") {
|
||||
Ok(v) => v,
|
||||
Err(r) => return Box::pin(std::future::ready(r)),
|
||||
};
|
||||
medium_audit("restore_project", &id);
|
||||
Box::pin(async move {
|
||||
match ProjectRepo::new(&db).restore(&id).await {
|
||||
Ok(true) => json_ok(json!({ "id": id, "restored": true })),
|
||||
Ok(false) => CallToolResult::error(format!("恢复失败(项目不在回收站): {id}")),
|
||||
Err(e) => err_str(e),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 路径规范化(镜像 df_project::scan::normalize_path,本 crate 不依赖 df-project)
|
||||
// ============================================================
|
||||
|
||||
fn normalize_path(p: &str) -> String {
|
||||
match std::path::Path::new(p).canonicalize() {
|
||||
Ok(abs) => abs.to_string_lossy().replace('\\', "/").to_lowercase(),
|
||||
Err(_) => p
|
||||
.trim_end_matches(['\\', '/'])
|
||||
.replace('\\', "/")
|
||||
.to_lowercase(),
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,11 @@ version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
df-core = { path = "../df-core" }
|
||||
df-types = { path = "../df-types" }
|
||||
df-execute = { path = "../df-execute" }
|
||||
df-workflow = { path = "../df-workflow" }
|
||||
df-ai = { path = "../df-ai" }
|
||||
df-storage = { path = "../df-storage" }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
|
||||
+345
-89
@@ -5,114 +5,72 @@
|
||||
//!
|
||||
//! 与 AI Chat(侧边栏交互对话)的区别:AI Node 由 DAG Executor 自动驱动,
|
||||
//! 适合嵌入自动化链路(如 想法 → AI分析 → 脚本落地 → 人工审批)。
|
||||
//!
|
||||
//! 拆分边界(纯结构,逻辑等价):
|
||||
//! - provider 解析/参数构造/自审 JSON 解析等纯逻辑 → `ai_node_helpers`
|
||||
//! - AiSelfReviewNode(自审闭环独立节点) → `ai_self_review_node`
|
||||
//! - 本文件:仅 AiNode 的 struct + impl + tests。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
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_storage::crud::TaskRepo;
|
||||
use df_storage::db::Database;
|
||||
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
|
||||
|
||||
// AiNode::execute 用的纯函数(resolve_and_parse / provider_from_params);
|
||||
// 其余 helpers(parse_review_json/gate_should_block/REVIEW_SYSTEM_PROMPT/ResolvedProvider/
|
||||
// truncate_for_summary)由 ai_self_review_node 与 tests 引入,不在此处 glob。
|
||||
use crate::ai_node_helpers::{provider_from_params, resolve_and_parse};
|
||||
|
||||
/// AI 节点
|
||||
pub struct AiNode;
|
||||
///
|
||||
/// 持有 Arc<Database> 在 NodeRegistry 注册时注入(state.rs build_registry 工厂闭包 move 捕获
|
||||
/// db.clone(),对齐 TaskAdvanceNode 先例)。execute 从 NodeContext.config 读 provider 配置 +
|
||||
/// 可选 task_id:有 task_id 时把产出落 task.output_json(AiNode 自审闭环,决策 a)。
|
||||
pub struct AiNode {
|
||||
db: Arc<Database>,
|
||||
}
|
||||
|
||||
impl AiNode {
|
||||
/// 构造节点(注册表工厂调用,注入数据库句柄)
|
||||
pub fn new(db: Arc<Database>) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Node for AiNode {
|
||||
async fn execute(&self, ctx: NodeContext) -> NodeResult {
|
||||
tracing::info!("AiNode 执行: node_id={}", ctx.node_id);
|
||||
|
||||
// ── provider 配置(必填)──
|
||||
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
|
||||
.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 = 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)),
|
||||
};
|
||||
// FR-S1 注入链:provider 经 df_storage::secret 在 AiNode 内存解析,api_key 不进 config。
|
||||
let p = resolve_and_parse(&self.db, &ctx.config, &ctx.inputs).await?;
|
||||
let provider: Box<dyn LlmProvider> = provider_from_params(&p);
|
||||
|
||||
// ── 构建消息 ──
|
||||
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::user(prompt));
|
||||
messages.push(ChatMessage::user(p.prompt));
|
||||
|
||||
let request = CompletionRequest {
|
||||
model: model.clone(),
|
||||
model: p.model.clone(),
|
||||
messages,
|
||||
temperature,
|
||||
max_tokens,
|
||||
temperature: p.temperature,
|
||||
max_tokens: p.max_tokens,
|
||||
stream: false,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
"AiNode 调用 LLM: model={}, base_url={}",
|
||||
default_model,
|
||||
base_url
|
||||
p.provider.default_model,
|
||||
p.provider.base_url
|
||||
);
|
||||
let response = provider.complete(request).await?;
|
||||
|
||||
@@ -123,7 +81,7 @@ impl Node for AiNode {
|
||||
response.usage.completion_tokens
|
||||
);
|
||||
|
||||
Ok(NodeOutput::from_value(serde_json::json!({
|
||||
let output = serde_json::json!({
|
||||
"text": response.text,
|
||||
"model": response.model,
|
||||
"usage": {
|
||||
@@ -131,7 +89,25 @@ impl Node for AiNode {
|
||||
"completion_tokens": response.usage.completion_tokens,
|
||||
"total_tokens": response.usage.total_tokens,
|
||||
},
|
||||
})))
|
||||
});
|
||||
|
||||
// 决策 a(AiNode 自审闭环):有 task_id 时落产出到 task.output_json。
|
||||
// 走通用 update_field(output_json 已在 tasks 白名单,crud/mod.rs update_field 宏(impl_repo!)),
|
||||
// 非 status 状态机收口字段,合法可写。落库失败不阻断节点返回(产出已在内存,工作流可继续)。
|
||||
if let Some(task_id) = ctx.config.get("task_id").and_then(|v| v.as_str()) {
|
||||
if let Ok(json_str) = serde_json::to_string(&output) {
|
||||
let repo = TaskRepo::new(&self.db);
|
||||
if let Err(e) = repo.update_field(task_id, "output_json", &json_str).await {
|
||||
tracing::warn!(
|
||||
task_id = task_id,
|
||||
error = %e,
|
||||
"AiNode 落 output_json 失败(产出已在内存,节点继续)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(NodeOutput::from_value(output))
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
@@ -141,14 +117,15 @@ impl Node for AiNode {
|
||||
"properties": {
|
||||
"prompt": { "type": "string", "description": "用户提示词(若无则取上游 prompt 输出)" },
|
||||
"system_prompt": { "type": "string", "description": "系统提示词(可选)" },
|
||||
"protocol": { "type": "string", "description": "协议类型:openai_compat(默认)或 anthropic(GLM订阅/Claude官方)" },
|
||||
"base_url": { "type": "string", "description": "API 地址,OpenAI 兼容如 https://api.deepseek.com;Anthropic 如 https://open.bigmodel.cn/api/anthropic" },
|
||||
"api_key": { "type": "string", "description": "API 密钥" },
|
||||
"model": { "type": "string", "description": "模型名(可选,留空用默认)" },
|
||||
"provider_id": { "type": "string", "description": "AI Provider ID(FR-S1:密钥经 df_storage::secret 解析,不进 config;留空走默认 provider)" },
|
||||
"model": { "type": "string", "description": "模型名(可选,留空用 record.default_model)" },
|
||||
"temperature": { "type": "number", "description": "温度 0.0~2.0(可选)" },
|
||||
"max_tokens": { "type": "integer", "description": "最大生成 token(可选,anthropic 协议无值时默认 4096)" }
|
||||
"max_tokens": { "type": "integer", "description": "最大生成 token(可选,anthropic 协议无值时默认 4096)" },
|
||||
"base_url": { "type": "string", "description": "(已废弃过渡)明文 API 地址,改用 provider_id" },
|
||||
"api_key": { "type": "string", "description": "(已废弃过渡)明文 API 密钥,改用 provider_id;FR-S1 下经 secret 解析" }
|
||||
},
|
||||
"required": ["prompt", "base_url", "api_key"]
|
||||
// SW-260618-15: prompt/provider_id 均"留空走兜底"(prompt 取上游、provider_id 走默认 provider),与 required 矛盾。改 required=[] 对齐 execute 运行时,防前端按 schema 误拒合法配置。
|
||||
"required": []
|
||||
}),
|
||||
output: serde_json::json!({
|
||||
"type": "object",
|
||||
@@ -165,3 +142,282 @@ impl Node for AiNode {
|
||||
"ai"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ai_node_helpers::{parse_params, resolve_provider, ResolvedProvider};
|
||||
use df_storage::crud::AiProviderRepo;
|
||||
use df_storage::db::Database;
|
||||
use df_storage::models::AiProviderRecord;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// 测试用 ResolvedProvider 桩:固定 protocol/base_url/api_key/default_model。
|
||||
fn provider_stub() -> ResolvedProvider {
|
||||
ResolvedProvider {
|
||||
protocol: "openai_compat".to_string(),
|
||||
base_url: "https://api.example.com".to_string(),
|
||||
api_key: "sk-test".to_string(),
|
||||
default_model: "gpt-4o-mini".to_string(),
|
||||
model_pool: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 造带基础 prompt 的 config(provider 经 resolve_provider 解析,parse_params 只看 prompt),
|
||||
/// overrides 覆盖或追加。
|
||||
fn config_with(overrides: serde_json::Value) -> serde_json::Value {
|
||||
let mut base = json!({
|
||||
"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_prompt_errors() {
|
||||
let config = json!({});
|
||||
let err = parse_params(&config, &empty_inputs(), provider_stub())
|
||||
.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(), provider_stub()).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, provider_stub()).unwrap();
|
||||
assert_eq!(p.prompt, "upstream-prompt", "上游输入应优先于 config.prompt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optional_fields_parsed_when_present() {
|
||||
let p = parse_params(
|
||||
&config_with(json!({
|
||||
"model": "glm-4",
|
||||
"temperature": 0.3,
|
||||
"max_tokens": 1024,
|
||||
"system_prompt": "你是助手"
|
||||
})),
|
||||
&empty_inputs(),
|
||||
provider_stub(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(p.model, "glm-4");
|
||||
assert_eq!(p.provider.protocol, "openai_compat");
|
||||
assert_eq!(p.temperature, Some(0.3));
|
||||
assert_eq!(p.max_tokens, Some(1024));
|
||||
assert_eq!(p.system_prompt.as_deref(), Some("你是助手"));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// resolve_provider 双路径测试(FR-S1 注入链核心)
|
||||
// ============================================================
|
||||
|
||||
/// 内存 DB 插 provider(可选 is_default),返回 (db, provider_id)。
|
||||
async fn setup_provider_db(
|
||||
provider_id: &str,
|
||||
is_default: bool,
|
||||
api_key: &str,
|
||||
) -> (Database, String) {
|
||||
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||
let repo = AiProviderRepo::new(&db);
|
||||
repo.insert(AiProviderRecord {
|
||||
id: provider_id.to_string(),
|
||||
name: "测试 Provider".to_string(),
|
||||
provider_type: "openai_compat".to_string(),
|
||||
api_key: api_key.to_string(),
|
||||
base_url: "https://api.example.com".to_string(),
|
||||
default_model: "glm-4-flash".to_string(),
|
||||
models: None,
|
||||
model_configs: Vec::new(),
|
||||
is_default,
|
||||
config: None,
|
||||
created_at: "0".to_string(),
|
||||
updated_at: "0".to_string(),
|
||||
enabled: true,
|
||||
weight: 50,
|
||||
})
|
||||
.await
|
||||
.expect("insert provider");
|
||||
(db, provider_id.to_string())
|
||||
}
|
||||
|
||||
/// 路径 1:provider_id 优先 → resolve_provider 走下沉解析(DB api_key 非空→直接返回)。
|
||||
#[tokio::test]
|
||||
async fn resolve_provider_by_id_prefers_db_key() {
|
||||
let (db, pid) = setup_provider_db("prov-1", true, "sk-from-db").await;
|
||||
let config = json!({ "provider_id": pid });
|
||||
let resolved = resolve_provider(&Arc::new(db), &config).await.unwrap();
|
||||
assert_eq!(resolved.api_key, "sk-from-db", "DB api_key 非空应直接返回");
|
||||
assert_eq!(resolved.base_url, "https://api.example.com");
|
||||
assert_eq!(resolved.protocol, "openai_compat");
|
||||
assert_eq!(
|
||||
resolved.default_model, "glm-4-flash",
|
||||
"无 config.model 时用 record.default_model"
|
||||
);
|
||||
}
|
||||
|
||||
/// 路径 1:config.model 覆盖 record.default_model。
|
||||
#[tokio::test]
|
||||
async fn resolve_provider_config_model_overrides_record() {
|
||||
let (db, pid) = setup_provider_db("prov-1m", true, "sk-db").await;
|
||||
let config = json!({ "provider_id": pid, "model": "glm-4" });
|
||||
let resolved = resolve_provider(&Arc::new(db), &config).await.unwrap();
|
||||
assert_eq!(resolved.default_model, "glm-4");
|
||||
}
|
||||
|
||||
/// 路径 1:provider_id 不存在 → 友好错误。
|
||||
#[tokio::test]
|
||||
async fn resolve_provider_unknown_id_errors() {
|
||||
let (db, _pid) = setup_provider_db("prov-2", true, "sk-db").await;
|
||||
let config = json!({ "provider_id": "non-existent" });
|
||||
let err = resolve_provider(&Arc::new(db), &config)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("不存在"), "未知 provider_id 应报错, 实际: {}", err);
|
||||
}
|
||||
|
||||
/// 路径 1:provider_id 指向但密钥空(DB 空 + keyring 无)→ ensure_resolved_key 报错。
|
||||
#[tokio::test]
|
||||
async fn resolve_provider_empty_key_errors() {
|
||||
// api_key 空:DB 无 → keyring 读不到 → 空 → ensure 报错
|
||||
let (db, pid) = setup_provider_db("prov-empty", true, "").await;
|
||||
let config = json!({ "provider_id": pid });
|
||||
let err = resolve_provider(&Arc::new(db), &config)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(
|
||||
err.contains("密钥") || err.contains("未读取到"),
|
||||
"空密钥应报错, 实际: {}",
|
||||
err
|
||||
);
|
||||
}
|
||||
|
||||
/// 路径 2:老明文路径兼容(base_url+api_key 显式注入)→ 返回明文 + 不报错。
|
||||
#[tokio::test]
|
||||
async fn resolve_provider_legacy_plain_path_compat() {
|
||||
// 无 provider 且无 provider_id → 老明文路径兜底
|
||||
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||
let config = json!({
|
||||
"base_url": "https://legacy.example.com",
|
||||
"api_key": "sk-legacy"
|
||||
});
|
||||
let resolved = resolve_provider(&Arc::new(db), &config).await.unwrap();
|
||||
assert_eq!(resolved.api_key, "sk-legacy");
|
||||
assert_eq!(resolved.base_url, "https://legacy.example.com");
|
||||
assert_eq!(resolved.default_model, "gpt-4o-mini", "无 model 时占位兜底");
|
||||
}
|
||||
|
||||
/// 路径 2:老明文路径空 key → ensure_resolved_key 报错。
|
||||
#[tokio::test]
|
||||
async fn resolve_provider_legacy_empty_key_errors() {
|
||||
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||
let config = json!({ "base_url": "https://x", "api_key": "" });
|
||||
let err = resolve_provider(&Arc::new(db), &config)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("密钥"), "老路径空 key 应报错, 实际: {}", err);
|
||||
}
|
||||
|
||||
/// 路径 3:空兜底 → 无 provider_id 无明文 → 取 is_default=true 首条。
|
||||
#[tokio::test]
|
||||
async fn resolve_provider_fallback_default_provider() {
|
||||
let (db, _pid) = setup_provider_db("prov-default", true, "sk-default").await;
|
||||
let config = json!({}); // 无 provider_id 无明文
|
||||
let resolved = resolve_provider(&Arc::new(db), &config).await.unwrap();
|
||||
assert_eq!(resolved.api_key, "sk-default", "兜底应取 is_default 首条");
|
||||
}
|
||||
|
||||
/// 路径 3:无任何 provider → 友好错误「未配置 AI Provider」。
|
||||
#[tokio::test]
|
||||
async fn resolve_provider_no_providers_errors() {
|
||||
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||
let config = json!({});
|
||||
let err = resolve_provider(&Arc::new(db), &config)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(
|
||||
err.contains("未配置") || err.contains("Provider"),
|
||||
"无 provider 应友好报错, 实际: {}",
|
||||
err
|
||||
);
|
||||
}
|
||||
|
||||
/// 路径 3:兜底无 is_default 时取首条(对齐 idea.rs build_default_provider)。
|
||||
#[tokio::test]
|
||||
async fn resolve_provider_fallback_first_when_no_default() {
|
||||
let (db, _pid) = setup_provider_db("prov-first", false, "sk-first").await;
|
||||
let config = json!({});
|
||||
let resolved = resolve_provider(&Arc::new(db), &config).await.unwrap();
|
||||
assert_eq!(resolved.api_key, "sk-first", "无 is_default 时取首条");
|
||||
}
|
||||
|
||||
/// 真调 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,
|
||||
reasoning_content: 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
|
||||
);
|
||||
}
|
||||
|
||||
// 自审闭环相关单测(parse_review_json / truncate_for_summary / gate_should_block /
|
||||
// build_review_prompt / update_field_writes_output_json)已随 AiSelfReviewNode 迁移至
|
||||
// ai_self_review_node.rs(与被测代码同位,纯搬运)。
|
||||
}
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
//! AI 节点辅助函数/类型 — provider 解析、参数构造、自审 JSON 解析等纯逻辑
|
||||
//!
|
||||
//! 从 ai_node.rs 抽离的纯函数/类型(execute 与解析解耦,便于单测覆盖)。
|
||||
//! AiNode / AiSelfReviewNode 的 struct + impl 仍保留在 ai_node.rs(impl 块约束)。
|
||||
//!
|
||||
//! SW-09 已抽 resolve_and_parse / provider_from_params helper(本文件随之搬迁)。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use df_ai::df_ai_core::model::{Modality, ModelConfig};
|
||||
use df_ai::provider::LlmProvider;
|
||||
// F-01 阶段5: AiNode 路由 — 节点 config.model_id 优先;否则按 TaskRequirements 路由
|
||||
// (默认 Standard + needs_tool_use=true)。池空/无匹配兜底 record.default_model。
|
||||
use df_ai::router::{select_model_id, TaskRequirements};
|
||||
use df_storage::crud::AiProviderRepo;
|
||||
use df_storage::db::Database;
|
||||
use df_storage::models::AiProviderRecord;
|
||||
use df_storage::secret::{ensure_resolved_key, resolve_provider_secret};
|
||||
use df_workflow::node::{NodeOutput};
|
||||
|
||||
/// AI 节点解析后的参数(execute 与参数解析解耦,便于单测覆盖取值/默认/校验逻辑)
|
||||
///
|
||||
/// provider 配置(base_url/api_key/protocol/default_model)经 `resolve_provider` 从 DB
|
||||
/// ai_providers 表查 record + 经 df_storage::secret 解析密钥得到,**不进 config(FR-S1)**。
|
||||
/// api_key 仅存于本结构体内存(AiNode 进程内存),不落 NodeContext.config / NodeOutput.data。
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct AiNodeParams {
|
||||
/// 解析后的 provider 构造要素(含明文 api_key,仅 AiNode 内存可见)
|
||||
pub provider: ResolvedProvider,
|
||||
pub prompt: String,
|
||||
pub system_prompt: Option<String>,
|
||||
pub model: String,
|
||||
pub temperature: Option<f32>,
|
||||
pub max_tokens: Option<u32>,
|
||||
}
|
||||
|
||||
/// 经 `resolve_provider` 从 ai_providers 表 + df_storage::secret 解析后的 provider 构造要素。
|
||||
///
|
||||
/// api_key 字段:明文,FR-S1 下全程不出 AiNode 进程内存(不进 config/output/schema)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct ResolvedProvider {
|
||||
/// 协议类型:openai_compat(默认)/ anthropic(GLM 订阅 / Claude 官方)— 从 record.provider_type 映射
|
||||
pub protocol: String,
|
||||
pub base_url: String,
|
||||
/// 明文 api_key,经 `resolve_provider_secret`(DB 优先→keyring) 解析;仅 AiNode 内存可见
|
||||
pub api_key: String,
|
||||
/// model 为空时的占位(record.default_model 或 "gpt-4o-mini"),避免 provider 构造 panic
|
||||
pub default_model: String,
|
||||
/// F-01 阶段5: 候选模型池(来自 record.model_configs)。parse_params 路由用:
|
||||
/// config.model 留空时经 select_model_id 选最优;池空兜底 default_model。
|
||||
pub model_pool: Vec<ModelConfig>,
|
||||
}
|
||||
|
||||
/// 经 ai_providers 表 + df_storage::secret 解析 provider 构造要素(FR-S1 注入链核心)。
|
||||
///
|
||||
/// 三路径(优先级从高到低):
|
||||
/// 1. **provider_id 优先**:config["provider_id"] 存在 → `AiProviderRepo::get_by_id` 查 record →
|
||||
/// `resolve_provider_secret`(DB 优先→keyring) → `ensure_resolved_key` 空键早失败。
|
||||
/// 2. **老路径兼容(过渡)**:config 显式含 `base_url` + `api_key` 明文(老调用方)→ 走原路径
|
||||
/// + `tracing::warn!`(明文 api_key 经 config 注入已废弃)。兼容期保留,后续移除。
|
||||
/// 3. **空兜底**:无 provider_id 也无明文 → 取 `is_default=true` 首条 provider(对齐
|
||||
/// src-tauri commands/idea.rs build_default_provider 模式),走同 resolve+ensure 链。
|
||||
/// 无任何 provider → 友好错误「未配置 AI Provider」。
|
||||
///
|
||||
/// model:config["model"] 非空用之,否则 record.default_model,再否则 "gpt-4o-mini" 占位。
|
||||
/// SW-260618-09: provider 三件套 DRY —— resolve_provider + parse_params 合并
|
||||
/// (AiNode/AiSelfReviewNode execute 逐字重复)。
|
||||
pub(crate) async fn resolve_and_parse(
|
||||
db: &Arc<Database>,
|
||||
config: &serde_json::Value,
|
||||
inputs: &HashMap<String, NodeOutput>,
|
||||
) -> anyhow::Result<AiNodeParams> {
|
||||
let provider_cfg = resolve_provider(db, config).await?;
|
||||
parse_params(config, inputs, provider_cfg)
|
||||
}
|
||||
|
||||
/// SW-260618-09: build_provider 5 行封装 DRY(AiNode/AiSelfReviewNode execute 逐字重复)。
|
||||
pub(crate) fn provider_from_params(p: &AiNodeParams) -> Box<dyn LlmProvider> {
|
||||
df_ai::build_provider(
|
||||
&p.provider.protocol,
|
||||
&p.provider.base_url,
|
||||
&p.provider.api_key,
|
||||
&p.provider.default_model,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_provider(
|
||||
db: &Arc<Database>,
|
||||
config: &serde_json::Value,
|
||||
) -> anyhow::Result<ResolvedProvider> {
|
||||
let repo = AiProviderRepo::new(db);
|
||||
let config_model = config
|
||||
.get("model")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
// ── 路径 1:provider_id 优先 ──
|
||||
if let Some(pid) = config.get("provider_id").and_then(|v| v.as_str()) {
|
||||
if !pid.is_empty() {
|
||||
let record = repo
|
||||
.get_by_id(pid)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("AiNode 查 provider 失败: {}", e))?
|
||||
.ok_or_else(|| anyhow::anyhow!("AiNode provider_id={} 不存在", pid))?;
|
||||
return resolve_from_record(&record, &config_model);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 路径 2:老明文路径兼容(过渡,warn) ──
|
||||
// base_url / api_key 各解析一次(复用于 has 判定与取值,避免 DRY 重复解析同字段)。
|
||||
let plain_base = config.get("base_url").and_then(|v| v.as_str());
|
||||
let plain_key = config.get("api_key").and_then(|v| v.as_str());
|
||||
if let (Some(base_url_str), Some(api_key_str)) = (plain_base, plain_key) {
|
||||
tracing::warn!(
|
||||
"AiNode 明文 api_key/base_url 经 config 注入已废弃, 改用 provider_id (FR-S1). \
|
||||
老路径将在后续版本移除"
|
||||
);
|
||||
let base_url = base_url_str.to_string();
|
||||
let api_key = api_key_str.to_string();
|
||||
ensure_resolved_key("(明文注入)", &api_key)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
let protocol = config
|
||||
.get("protocol")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("openai_compat")
|
||||
.to_string();
|
||||
let default_model = if config_model.is_empty() {
|
||||
"gpt-4o-mini".to_string()
|
||||
} else {
|
||||
config_model.clone()
|
||||
};
|
||||
return Ok(ResolvedProvider {
|
||||
protocol,
|
||||
base_url,
|
||||
api_key,
|
||||
default_model,
|
||||
// 老明文路径无 record,候选池空(无路由能力,兜底 default_model)。
|
||||
model_pool: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
// ── 路径 3:兜底 is_default=true 首条 provider ──
|
||||
let providers = repo
|
||||
.list_all()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("AiNode 列 provider 失败: {}", e))?;
|
||||
let picked = providers
|
||||
.iter()
|
||||
.find(|p| p.is_default)
|
||||
.cloned()
|
||||
.or_else(|| providers.into_iter().next())
|
||||
.ok_or_else(|| anyhow::anyhow!("未配置 AI Provider,请在设置中添加并保存密钥"))?;
|
||||
resolve_from_record(&picked, &config_model)
|
||||
}
|
||||
|
||||
/// 从 record 解析 provider 构造要素:resolve_provider_secret(DB 优先→keyring) + ensure 空键早失败。
|
||||
/// protocol 从 record.provider_type 映射;default_model 取 config_model > record.default_model > 占位。
|
||||
pub(crate) fn resolve_from_record(
|
||||
record: &AiProviderRecord,
|
||||
config_model: &str,
|
||||
) -> anyhow::Result<ResolvedProvider> {
|
||||
let api_key = resolve_provider_secret(record);
|
||||
ensure_resolved_key(&record.name, &api_key).map_err(anyhow::Error::msg)?;
|
||||
let default_model = if !config_model.is_empty() {
|
||||
config_model.to_string()
|
||||
} else if !record.default_model.is_empty() {
|
||||
record.default_model.clone()
|
||||
} else {
|
||||
"gpt-4o-mini".to_string()
|
||||
};
|
||||
Ok(ResolvedProvider {
|
||||
protocol: record.provider_type.clone(),
|
||||
base_url: record.base_url.clone(),
|
||||
api_key,
|
||||
default_model,
|
||||
model_pool: record.model_configs.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 从节点 config + 上游输入解析 AI 节点 prompt 与可选参数(provider 经 `resolve_provider` 异步解析)。
|
||||
///
|
||||
/// prompt 取值优先级:上游 `inputs["prompt"]` > `config.prompt`,两者皆无则报错。
|
||||
pub(crate) fn parse_params(
|
||||
config: &serde_json::Value,
|
||||
inputs: &HashMap<String, NodeOutput>,
|
||||
provider: ResolvedProvider,
|
||||
) -> anyhow::Result<AiNodeParams> {
|
||||
// ── 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 或上游输入均无)"))?;
|
||||
|
||||
// ── 可选参数 ──
|
||||
// model 解析优先级(F-01 阶段5):config.model 显式指定 > 路由选优(provider.model_pool 非空时)
|
||||
// > 空(CompletionRequest.model 留空由 provider impl 回填 default_model,行为不变)。
|
||||
// 注:provider.model_pool 在 provider move 进 AiNodeParams 前先借引用路由,选中的 model_id
|
||||
// 填入 CompletionRequest.model;provider.default_model 仍是 build_provider 兜底用。
|
||||
let config_model = config
|
||||
.get("model")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let model = if !config_model.is_empty() {
|
||||
config_model
|
||||
} else {
|
||||
// F-01 阶段5: AiNode 默认路由 — needs_tool_use=true(工作流无人值守 AI 步骤
|
||||
// 常含工具调用,如检索/生成;无需工具的节点应在 config 显式指定 model)。
|
||||
// select_model_id None(池空/无匹配)→ 空串(由 provider impl 回填 default_model)。
|
||||
let node_req = TaskRequirements {
|
||||
modalities: vec![Modality::Text],
|
||||
needs_tool_use: true,
|
||||
estimated_context: 0,
|
||||
};
|
||||
select_model_id(&node_req, &provider.model_pool).unwrap_or_default()
|
||||
};
|
||||
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());
|
||||
|
||||
Ok(AiNodeParams {
|
||||
provider,
|
||||
prompt,
|
||||
system_prompt,
|
||||
model,
|
||||
temperature,
|
||||
max_tokens,
|
||||
})
|
||||
}
|
||||
|
||||
/// 自审四维度 system prompt:严格审查员角色 + 只输出 JSON 强约束。
|
||||
pub(crate) const REVIEW_SYSTEM_PROMPT: &str = "\
|
||||
你是严格的代码/产出审查员。审查任务产出是否符合需求,按四维度给出结构化结论。\
|
||||
只输出 JSON,不要任何额外文字、不要 markdown 代码块包裹。";
|
||||
|
||||
/// 解析 LLM 自审输出为结构化 review JSON。
|
||||
///
|
||||
/// 成功路径:serde_json::from_str 得到 Object 且含 verdict 字段 → 原样返回。
|
||||
/// 兜底路径:解析失败 / 非 Object / 缺 verdict → 返回 verdict=unknown + summary=原文,
|
||||
/// 防 LLM 不按要求输出导致下游崩溃。dimensions 留空对象(前端容缺展示)。
|
||||
pub(crate) fn parse_review_json(raw: &str) -> serde_json::Value {
|
||||
// 先尝试整段解析;LLM 偶尔会包 markdown 代码块,剥离 ```json ... ``` 后重试一次。
|
||||
let trimmed = raw.trim();
|
||||
let cleaned = trimmed
|
||||
.strip_prefix("```json")
|
||||
.or_else(|| trimmed.strip_prefix("```"))
|
||||
.map(|s| s.trim_end_matches("```").trim())
|
||||
.unwrap_or(trimmed);
|
||||
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(cleaned) {
|
||||
if v.is_object() && v.get("verdict").and_then(|x| x.as_str()).is_some() {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
// 兜底:保留原文供人查阅,verdict=unknown 不阻断流程(自审辅助,人定)。
|
||||
serde_json::json!({
|
||||
"verdict": "unknown",
|
||||
"dimensions": {},
|
||||
"summary": format!("(自审输出解析失败,原文: {})", truncate_for_summary(cleaned)),
|
||||
"suggestions": [],
|
||||
})
|
||||
}
|
||||
|
||||
/// summary 截断(防原文过长撑爆 output_json / 审批卡)。
|
||||
pub(crate) fn truncate_for_summary(s: &str) -> String {
|
||||
const MAX: usize = 300;
|
||||
if s.chars().count() <= MAX {
|
||||
return s.to_string();
|
||||
}
|
||||
let truncated: String = s.chars().take(MAX).collect();
|
||||
format!("{truncated}…")
|
||||
}
|
||||
|
||||
/// 阶段3 自审闸门决策(纯函数,便于单测覆盖各 verdict/gate 组合)。
|
||||
///
|
||||
/// 仅当 `gate==true` 且 `verdict=="fail"` 时阻断。verdict="unknown"(LLM 输出不可靠)
|
||||
/// 与 "pass" 均不阻断 —— unknown 保持人定权(阶段2 保守语义不变)。
|
||||
pub(crate) fn gate_should_block(gate: bool, verdict: &str) -> bool {
|
||||
gate && verdict == "fail"
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
//! AI 自审节点(决策 a 步骤③)— AiSelfReviewNode
|
||||
//!
|
||||
//! 从 ai_node.rs 拆出的独立节点(纯结构搬运,逻辑等价)。复用 AiNode 的 provider
|
||||
//! 调用链(resolve_and_parse + provider_from_params),差异在 prompt 模板/JSON 解析/
|
||||
//! verdict 闸门。provider 解析与自审 JSON 解析等纯逻辑见 `ai_node_helpers`。
|
||||
//!
|
||||
//! 节点类型注册 key:`ai_self_review`(state.rs build_registry)。
|
||||
//! 模板对齐:task_workflow_templates.rs testing 模板 `ai_self_review` 节点 + gate:true。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider};
|
||||
use df_storage::crud::TaskRepo;
|
||||
use df_storage::db::Database;
|
||||
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
|
||||
|
||||
// 抽离的纯函数/类型(与 AiNode 共用)。
|
||||
use crate::ai_node_helpers::{
|
||||
gate_should_block, parse_review_json, provider_from_params, resolve_and_parse,
|
||||
REVIEW_SYSTEM_PROMPT,
|
||||
};
|
||||
|
||||
// AiSelfReviewNode 节点 — AI 自审闭环(决策 a 步骤③)
|
||||
//
|
||||
// 复用 AiNode 的 provider 调用逻辑(parse_params + build_provider + complete),差异:
|
||||
// 1. prompt 固定四维度审查模板(需求符合度/完整性/正确性/边界),不从 config/inputs 取 prompt
|
||||
// 2. system_prompt 强约束「只输出 JSON」+ temperature=0
|
||||
// 3. LLM 输出按结构化 JSON 解析,失败兜底 verdict=unknown(防 LLM 不按要求输出)
|
||||
// 4. 自审输入:task.description(需求,经 ctx.config["task_id"] 读 task) + task.output_json.text(产出)
|
||||
// 5. 自审结果写回 task.output_json 加 review 子字段(不覆盖 text,保留对照)
|
||||
// 6. review 摘要塞 NodeOutput.data,供下游 human_review 经 DAG inputs["ai_self_review"] 读
|
||||
//
|
||||
// verdict 处理(首版保守):自审是辅助,最终决策权在人。verdict=fail 不主动 Err,
|
||||
// 节点返 Ok 让 human_review 继续,人在审批卡看到 fail 结论再定(human reject 走 Err→工作流
|
||||
// failed→②-4 回调退回 in_review)。
|
||||
|
||||
/// AI 自审节点(决策 a 步骤③)
|
||||
pub struct AiSelfReviewNode {
|
||||
db: Arc<Database>,
|
||||
}
|
||||
|
||||
impl AiSelfReviewNode {
|
||||
pub fn new(db: Arc<Database>) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
|
||||
/// 拼装自审 user prompt:任务需求 + 待审产出 + 四维度审查要求 + 输出格式。
|
||||
/// description / output_text 缺失时给占位(不报错,信任调用方注入合法 task_id)。
|
||||
fn build_review_prompt(description: &str, output_text: &str) -> String {
|
||||
format!(
|
||||
"\
|
||||
## 任务需求
|
||||
{description}
|
||||
|
||||
## 待审产出
|
||||
{output_text}
|
||||
|
||||
## 审查维度
|
||||
1. 需求符合度:产出是否覆盖需求描述的所有要点
|
||||
2. 产出完整性:是否有遗漏、未完成的部分
|
||||
3. 正确性:逻辑/事实/语法是否正确
|
||||
4. 边界处理:异常输入、空值、错误路径是否考虑
|
||||
|
||||
## 输出格式(严格 JSON)
|
||||
{{\"verdict\":\"pass\"|\"fail\",\"dimensions\":{{\"requirement_fit\":{{\"score\":0-10,\"issues\":[\"...\"]}},\"completeness\":{{\"score\":0-10,\"issues\":[\"...\"]}},\"correctness\":{{\"score\":0-10,\"issues\":[\"...\"]}},\"boundary\":{{\"score\":0-10,\"issues\":[\"...\"]}}}},\"summary\":\"一句话总结\",\"suggestions\":[\"改进建议\"]}}
|
||||
verdict=fail 当且仅当任一维度 score<6 或有阻断性 issue。"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Node for AiSelfReviewNode {
|
||||
async fn execute(&self, ctx: NodeContext) -> NodeResult {
|
||||
tracing::info!("AiSelfReviewNode 执行: node_id={}", ctx.node_id);
|
||||
|
||||
// FR-S1 注入链:provider 经 df_storage::secret 在 AiNode 内存解析,api_key 不进 config。
|
||||
let p = resolve_and_parse(&self.db, &ctx.config, &ctx.inputs).await?;
|
||||
|
||||
// ── 读任务(需求 + 产出) ──
|
||||
// task_id 必填(自审对象是具体任务的产出);缺失报错而非静默跳过。
|
||||
let task_id = ctx
|
||||
.config
|
||||
.get("task_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("AiSelfReviewNode 缺少必填参数: task_id"))?;
|
||||
|
||||
let repo = TaskRepo::new(&self.db);
|
||||
let task = repo
|
||||
.get_by_id(task_id)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("自审读任务失败: {}", e))?
|
||||
.ok_or_else(|| anyhow::anyhow!("自审任务 {} 不存在", task_id))?;
|
||||
|
||||
// output_json 可能无(ai_execute 未跑或失败) → 待审产出占位提示,不报错。
|
||||
let output_text = task
|
||||
.output_json
|
||||
.as_deref()
|
||||
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
|
||||
.and_then(|v| v.get("text").and_then(|t| t.as_str()).map(String::from))
|
||||
.unwrap_or_else(|| "(无产出,仅依据需求审查)".to_string());
|
||||
|
||||
let prompt = Self::build_review_prompt(&task.description, &output_text);
|
||||
|
||||
let provider: Box<dyn LlmProvider> = provider_from_params(&p);
|
||||
|
||||
let request = CompletionRequest {
|
||||
model: p.model.clone(),
|
||||
messages: vec![
|
||||
ChatMessage::system(REVIEW_SYSTEM_PROMPT),
|
||||
ChatMessage::user(prompt),
|
||||
],
|
||||
temperature: Some(0.0), // 强约束确定性输出
|
||||
max_tokens: p.max_tokens,
|
||||
stream: false,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
"AiSelfReviewNode 调用 LLM: model={}, base_url={}",
|
||||
p.provider.default_model,
|
||||
p.provider.base_url
|
||||
);
|
||||
let response = provider.complete(request).await?;
|
||||
|
||||
// ── 解析自审 JSON(失败兜底 verdict=unknown) ──
|
||||
let review = parse_review_json(&response.text);
|
||||
let verdict = review
|
||||
.get("verdict")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
// summary / suggestions 在 move review 前先取走(供 NodeOutput.data 平铺)
|
||||
let summary = review
|
||||
.get("summary")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let suggestions = review
|
||||
.get("suggestions")
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::json!([]));
|
||||
|
||||
tracing::info!(
|
||||
"AiSelfReviewNode 完成: verdict={}, model={}",
|
||||
verdict,
|
||||
response.model
|
||||
);
|
||||
|
||||
// ── 写回 task.output_json 加 review 子字段(不覆盖 text) ──
|
||||
// 读现有 output_json(若有),合并 review 子字段后整体写回。
|
||||
let mut output_obj: serde_json::Map<String, serde_json::Value> = task
|
||||
.output_json
|
||||
.as_deref()
|
||||
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
|
||||
.and_then(|v| v.as_object().cloned())
|
||||
.unwrap_or_default();
|
||||
// review 子字段:自审结论 + reviewed_at + reviewer_model(便于前端展示与人对照)
|
||||
let mut review_with_meta = match review {
|
||||
serde_json::Value::Object(m) => m,
|
||||
other => {
|
||||
let mut m = serde_json::Map::new();
|
||||
m.insert("verdict".into(), other);
|
||||
m
|
||||
}
|
||||
};
|
||||
review_with_meta.insert(
|
||||
"reviewed_at".into(),
|
||||
serde_json::Value::Number(df_types::now_millis().into()),
|
||||
);
|
||||
review_with_meta.insert("reviewer_model".into(), serde_json::json!(response.model));
|
||||
output_obj.insert("review".into(), serde_json::Value::Object(review_with_meta));
|
||||
|
||||
let merged = serde_json::Value::Object(output_obj);
|
||||
if let Ok(json_str) = serde_json::to_string(&merged) {
|
||||
if let Err(e) = repo.update_field(task_id, "output_json", &json_str).await {
|
||||
tracing::warn!(
|
||||
task_id = task_id,
|
||||
error = %e,
|
||||
"AiSelfReviewNode 落 review 子字段失败(摘要已在 NodeOutput,节点继续)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── NodeOutput.data 塞 review 摘要,供下游 human_review 经 inputs["ai_self_review"] 读 ──
|
||||
// human_review(HumanNode)从 config/inputs 拼 description;此处把 verdict/summary/suggestions
|
||||
// 平铺进 data,HumanNode 零改动即可透传(前端/模板层可据此拼审批卡描述)。
|
||||
let output = NodeOutput::from_value(serde_json::json!({
|
||||
"verdict": verdict,
|
||||
"summary": summary,
|
||||
"suggestions": suggestions,
|
||||
"review": merged.get("review").cloned().unwrap_or(serde_json::Value::Null),
|
||||
"model": response.model,
|
||||
}));
|
||||
|
||||
// ── 阶段3: 自审闸门(F-260616-07 决策 a 步骤③) ──
|
||||
// config["gate"]==true 时,AiSelfReviewNode 从「自审辅助」升级为「DAG 节点闸门」:
|
||||
// verdict="fail" → 返回 Err → executor first_err 中止后续层(下游 human_review 不跑)
|
||||
// → 工作流 failed → ②-4 回调退回 in_review(对齐工作流失败语义)
|
||||
// verdict="unknown" → 保持 Ok(LLM 输出不可靠时不阻断,人定权)
|
||||
// verdict="pass" → Ok 放行下游
|
||||
//
|
||||
// 落库在前(已 update_field output_json),故闸门 fail 时 task.output_json.review
|
||||
// 已含结论,前端/二次工作流可读自审详情(不丢失审查痕迹)。
|
||||
//
|
||||
// 设计选型(三方案对比,见 commit/设计文档):
|
||||
// A) AiNode 内部门控(本方案):1 处 return Err,复用 executor first_err + ②-4 回调,
|
||||
// 零 executor 核心改动,零依赖暂缓的条件引擎(T-260614-11)。最简。
|
||||
// B) DAG edges 条件 + ConditionEngine 求值:依赖 T-260614-11(暂缓),executor 当前
|
||||
// 完全不评估 edge.condition(topological_layers 无条件收录所有边),须先做条件
|
||||
// 引擎 Phase1+2 才能用 → 拆波。
|
||||
// C) executor 闸门检查钩子(节点 execute 后 executor 读 output.verdict):改 DagExecutor
|
||||
// 核心循环,牵动所有节点,风险/范围不符「最简不破坏」。
|
||||
// 选 A:语义最贴近「自审结果作为闸门」(自审节点自行决定放行/阻断),且 gate 可按节点
|
||||
// config 开关(默认 false = 阶段2 行为不变,模板/前端零强制改动,向后兼容)。
|
||||
let gate_enabled = ctx
|
||||
.config
|
||||
.get("gate")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
if gate_should_block(gate_enabled, &verdict) {
|
||||
tracing::info!(
|
||||
node_id = %ctx.node_id,
|
||||
task_id,
|
||||
"AiSelfReviewNode 闸门触发: verdict=fail, 阻断下游(工作流将 failed)"
|
||||
);
|
||||
return Err(anyhow::anyhow!(
|
||||
"AI 自审闸门未通过(verdict=fail): {}",
|
||||
if summary.is_empty() { "自审结论 fail" } else { &summary }
|
||||
));
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task_id": { "type": "string", "description": "自审目标任务 ID(必填)" },
|
||||
"provider_id": { "type": "string", "description": "AI Provider ID(FR-S1:密钥经 secret 解析不进 config;留空走默认 provider)" },
|
||||
"model": { "type": "string", "description": "模型名(可选,留空用 record.default_model)" },
|
||||
"max_tokens": { "type": "integer" },
|
||||
"gate": { "type": "boolean", "description": "阶段3 闸门开关:false(默认)=自审辅助,verdict 仅透传展示;true=自审结果作 DAG 闸门,verdict=fail 返回 Err 阻断下游(工作流 failed → ②-4 退回),verdict=unknown/pass 放行" }
|
||||
},
|
||||
"required": ["task_id", "provider_id"]
|
||||
}),
|
||||
output: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"verdict": { "type": "string", "enum": ["pass", "fail", "unknown"] },
|
||||
"summary": { "type": "string" },
|
||||
"suggestions": { "type": "array", "items": { "type": "string" } },
|
||||
"review": { "type": "object" },
|
||||
"model": { "type": "string" }
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"ai_self_review"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ai_node_helpers::{gate_should_block, parse_review_json, truncate_for_summary};
|
||||
use df_storage::crud::{ProjectRepo, TaskRepo};
|
||||
use df_storage::db::Database;
|
||||
use df_storage::models::{ProjectRecord, TaskRecord};
|
||||
use serde_json::json;
|
||||
|
||||
// ============================================================
|
||||
// 决策 a 自审闭环单测 — output_json 写回 + review JSON 解析兜底
|
||||
// ============================================================
|
||||
|
||||
/// 内存 DB 构造 helper:插父项目 + 指定 task,返回 (db, task_id)。
|
||||
async fn setup_task_db(task_output_json: Option<&str>) -> (Database, String) {
|
||||
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||
ProjectRepo::new(&db)
|
||||
.insert(ProjectRecord {
|
||||
id: "p1".to_string(),
|
||||
name: "proj".to_string(),
|
||||
description: "".to_string(),
|
||||
status: "planning".to_string(),
|
||||
idea_id: None,
|
||||
path: None,
|
||||
stack: None,
|
||||
created_at: "0".to_string(),
|
||||
updated_at: "0".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("insert project");
|
||||
TaskRepo::new(&db)
|
||||
.insert(TaskRecord {
|
||||
id: "t1".to_string(),
|
||||
project_id: "p1".to_string(),
|
||||
title: "t1".to_string(),
|
||||
description: "实现登录接口".to_string(),
|
||||
status: "testing".to_string(),
|
||||
priority: 2,
|
||||
branch_name: None,
|
||||
assignee: None,
|
||||
workflow_def_id: None,
|
||||
base_branch: None,
|
||||
review_rounds: 0,
|
||||
output_json: task_output_json.map(String::from),
|
||||
idea_id: None,
|
||||
created_at: "0".to_string(),
|
||||
updated_at: "0".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("insert task");
|
||||
(db, "t1".to_string())
|
||||
}
|
||||
|
||||
/// 决策 a 步骤②:AiNode.execute 落产出验证 — 有 task_id 时 output_json 写入。
|
||||
/// 用 mock provider 避免真调 LLM:自定义 Node 替换不便,改为直接验证 update_field 路径
|
||||
/// (execute 内部调 provider.complete 需真 LLM,此处降级验证 parse_review_json 兜底 +
|
||||
/// 直接 repo.update_field 路径,与 execute 落库逻辑同一条 crud.update_field)。
|
||||
#[tokio::test]
|
||||
async fn update_field_writes_output_json() {
|
||||
// 验证 output_json 在 tasks 白名单(update_field 不报「不允许的字段名」)。
|
||||
// 这正是 AiNode.execute / AiSelfReviewNode.execute 落产出的底层路径。
|
||||
let (db, task_id) = setup_task_db(None).await;
|
||||
let repo = TaskRepo::new(&db);
|
||||
let payload = r#"{"text":"产出内容","model":"glm-4","usage":{}}"#;
|
||||
let ok = repo
|
||||
.update_field(&task_id, "output_json", payload)
|
||||
.await
|
||||
.expect("update_field output_json");
|
||||
assert!(ok, "update_field 应返回 affected>0=true");
|
||||
let after = repo.get_by_id(&task_id).await.unwrap().unwrap();
|
||||
assert_eq!(
|
||||
after.output_json.as_deref(),
|
||||
Some(payload),
|
||||
"output_json 应被写入"
|
||||
);
|
||||
// 原始 text 字段(产出对照)保留
|
||||
let parsed: serde_json::Value = serde_json::from_str(after.output_json.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(parsed["text"], json!("产出内容"));
|
||||
}
|
||||
|
||||
/// 步骤③:parse_review_json 合规 JSON 直通(保留 verdict/dimensions/summary/suggestions)。
|
||||
#[test]
|
||||
fn parse_review_json_valid_passes_through() {
|
||||
let raw = r#"{"verdict":"pass","dimensions":{"requirement_fit":{"score":9,"issues":[]}},"summary":"符合需求","suggestions":["可加单测"]}"#;
|
||||
let v = parse_review_json(raw);
|
||||
assert_eq!(v["verdict"], json!("pass"));
|
||||
assert_eq!(v["summary"], json!("符合需求"));
|
||||
assert_eq!(v["suggestions"], json!(["可加单测"]));
|
||||
assert_eq!(v["dimensions"]["requirement_fit"]["score"], json!(9));
|
||||
}
|
||||
|
||||
/// 步骤③:parse_review_json 非 JSON / 缺 verdict → 兜底 verdict=unknown + summary 含原文。
|
||||
#[test]
|
||||
fn parse_review_json_invalid_falls_back_unknown() {
|
||||
// 非 JSON
|
||||
let v = parse_review_json("这不是 JSON,LLM 没按要求输出");
|
||||
assert_eq!(v["verdict"], json!("unknown"));
|
||||
assert!(v["summary"].as_str().unwrap().contains("不是 JSON"));
|
||||
assert!(v["summary"].as_str().unwrap().contains("解析失败"));
|
||||
|
||||
// JSON 但缺 verdict
|
||||
let v = parse_review_json(r#"{"foo":"bar"}"#);
|
||||
assert_eq!(v["verdict"], json!("unknown"));
|
||||
|
||||
// JSON 非 object(数组)
|
||||
let v = parse_review_json("[1,2,3]");
|
||||
assert_eq!(v["verdict"], json!("unknown"));
|
||||
}
|
||||
|
||||
/// 步骤③:LLM 偶尔包 ```json 代码块,parse_review_json 应剥离后解析成功。
|
||||
#[test]
|
||||
fn parse_review_json_strips_markdown_code_fence() {
|
||||
let raw = "```json\n{\"verdict\":\"fail\",\"summary\":\"缺边界处理\"}\n```";
|
||||
let v = parse_review_json(raw);
|
||||
assert_eq!(v["verdict"], json!("fail"));
|
||||
assert_eq!(v["summary"], json!("缺边界处理"));
|
||||
}
|
||||
|
||||
/// 步骤③:truncate_for_summary 长文截断。
|
||||
#[test]
|
||||
fn truncate_for_summary_long_text() {
|
||||
let short = "短文";
|
||||
assert_eq!(truncate_for_summary(short), "短文");
|
||||
let long: String = "字".repeat(500);
|
||||
let t = truncate_for_summary(&long);
|
||||
assert!(t.ends_with("…"));
|
||||
assert!(t.chars().count() <= 302, "截断后含省略号应 ≈300 字");
|
||||
}
|
||||
|
||||
/// 步骤③:AiSelfReviewNode.build_review_prompt 含需求 + 产出 + 四维度。
|
||||
#[test]
|
||||
fn build_review_prompt_contains_inputs() {
|
||||
let p = AiSelfReviewNode::build_review_prompt("登录接口", "已实现 /login");
|
||||
assert!(p.contains("登录接口"), "prompt 应含需求");
|
||||
assert!(p.contains("已实现 /login"), "prompt 应含产出");
|
||||
assert!(p.contains("需求符合度"), "prompt 应含四维度");
|
||||
assert!(p.contains("边界处理"));
|
||||
assert!(p.contains("verdict"), "prompt 应含输出格式约束");
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 阶段3 自审闸门(gate_should_block)单测
|
||||
// ============================================================
|
||||
//
|
||||
// gate 决策矩阵:
|
||||
// gate=false(默认,阶段2 行为) → 任何 verdict 都放行(辅助模式)
|
||||
// gate=true + verdict=pass → 放行
|
||||
// gate=true + verdict=unknown → 放行(LLM 不可靠时不阻断,人定权)
|
||||
// gate=true + verdict=fail → 阻断(返回 Err,工作流 failed)
|
||||
|
||||
#[test]
|
||||
fn gate_disabled_never_blocks_any_verdict() {
|
||||
// gate 默认 false(阶段2 兼容):无论 verdict 如何都不阻断,自审仅辅助展示
|
||||
assert!(!gate_should_block(false, "fail"), "gate 关闭时 fail 也不阻断");
|
||||
assert!(!gate_should_block(false, "pass"), "gate 关闭时 pass 放行");
|
||||
assert!(!gate_should_block(false, "unknown"), "gate 关闭时 unknown 放行");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gate_enabled_blocks_only_on_fail() {
|
||||
// gate 开启:仅 fail 阻断
|
||||
assert!(gate_should_block(true, "fail"), "gate 开 + fail 应阻断");
|
||||
assert!(!gate_should_block(true, "pass"), "gate 开 + pass 放行");
|
||||
assert!(
|
||||
!gate_should_block(true, "unknown"),
|
||||
"gate 开 + unknown 放行(LLM 不可靠时保人定权)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gate_unknown_verdict_does_not_block() {
|
||||
// 防御:verdict 非标准值(如解析异常)不应触发阻断(对齐 unknown 保守不阻断语义)
|
||||
assert!(
|
||||
!gate_should_block(true, "weird"),
|
||||
"非标准 verdict 不应阻断(对齐 unknown 保守)"
|
||||
);
|
||||
assert!(!gate_should_block(true, ""), "空 verdict 不应阻断");
|
||||
}
|
||||
}
|
||||
@@ -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,17 @@
|
||||
//! 人工审批节点 — 阻塞工作流,等待人工确认或输入
|
||||
//!
|
||||
//! 拒绝语义化关键字判定等纯逻辑见 `human_node_helpers`(本文件只保留
|
||||
//! HumanNode 的 struct + impl,impl 块约束)。
|
||||
|
||||
use async_trait::async_trait;
|
||||
use std::time::Duration;
|
||||
use tokio::time;
|
||||
use tokio::sync::broadcast;
|
||||
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
|
||||
use df_core::events::WorkflowEvent;
|
||||
use df_types::events::{SelectType, WorkflowEvent};
|
||||
|
||||
// 抽离的纯函数/常量(glob 引入,tests `use super::*` 间接可见)
|
||||
#[allow(unused_imports)]
|
||||
use crate::human_node_helpers::{contains_reject, is_reject_decision};
|
||||
|
||||
/// 人工审批节点(阻塞节点)
|
||||
pub struct HumanNode;
|
||||
@@ -21,44 +28,131 @@ impl Node for HumanNode {
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
let options = config.get("options")
|
||||
let options: Vec<String> = config.get("options")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.collect::<Vec<_>>())
|
||||
.unwrap_or_else(|| vec!["同意", "拒绝"]);
|
||||
.map(String::from)
|
||||
.collect())
|
||||
.unwrap_or_else(|| vec!["同意".into(), "拒绝".into()]);
|
||||
|
||||
// 发送人工审批请求到事件总线
|
||||
let _ = ctx.event_bus.send(WorkflowEvent::HumanApprovalRequest {
|
||||
execution_id: ctx.execution_id.clone(),
|
||||
node_id: ctx.node_id.clone(),
|
||||
title: title.to_string(),
|
||||
description: description.to_string(),
|
||||
options: options.iter().map(|s| s.to_string()).collect(),
|
||||
});
|
||||
let timeout_secs = config.get("timeout_secs")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(3600);
|
||||
|
||||
// 等待用户响应(最多等待 1 小时)
|
||||
let timeout = Duration::from_secs(3600);
|
||||
let start_time = std::time::Instant::now();
|
||||
// F-260615-01: 解析 select_type(缺省 Single,向后兼容)。非 "multiple" 一律按 Single 处理。
|
||||
let select_type = match config.get("select_type").and_then(|v| v.as_str()) {
|
||||
Some("multiple") => SelectType::Multiple,
|
||||
_ => SelectType::Single,
|
||||
};
|
||||
|
||||
// 1. 先订阅,再发请求 —— broadcast 不回放历史消息,
|
||||
// 若 subscribe 晚于 send,Response 会在 receiver 建位前发出而丢失,节点死等到超时。
|
||||
let mut rx = ctx.event_bus.subscribe();
|
||||
|
||||
// 2. 发送人工审批请求到事件总线
|
||||
// send 是 async fn(broadcast 同步发但 async 包装),必须 await 否则 Future 不 poll、Request 不进 channel(B-03b-R6)
|
||||
let _ = ctx.event_bus
|
||||
.send(WorkflowEvent::HumanApprovalRequest {
|
||||
execution_id: ctx.execution_id.clone(),
|
||||
node_id: ctx.node_id.clone(),
|
||||
title: title.to_string(),
|
||||
description: description.to_string(),
|
||||
options: options.clone(),
|
||||
select_type,
|
||||
})
|
||||
.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; // 丢弃首个立即触发的 tick
|
||||
|
||||
loop {
|
||||
// 检查是否超时
|
||||
if start_time.elapsed() > timeout {
|
||||
return Err(anyhow::anyhow!("人工审批超时"));
|
||||
tokio::select! {
|
||||
recv = rx.recv() => match recv {
|
||||
Ok(WorkflowEvent::HumanApprovalResponse {
|
||||
execution_id, node_id, decision, decisions, comment,
|
||||
}) if execution_id == ctx.execution_id && node_id == ctx.node_id => {
|
||||
// F-260615-01: 归一化决策集合(优先用 decisions 数组,空则回退兼容 decision 单值)
|
||||
// select_type=Single → 决策数必须 =1
|
||||
// select_type=Multiple → 决策数必须 ≥1
|
||||
// options 空 → 允许自由文本(仅受数量约束);
|
||||
// options 非空 → 每项必须 ∈ options
|
||||
// 兼容旧调用方:decisions 空但 decision 非空时,按 [decision] 单值处理。
|
||||
let mut picked: Vec<String> = decisions;
|
||||
if picked.is_empty() && !decision.is_empty() {
|
||||
picked.push(decision.clone());
|
||||
}
|
||||
let count_ok = match select_type {
|
||||
SelectType::Single => picked.len() == 1,
|
||||
SelectType::Multiple => !picked.is_empty(),
|
||||
};
|
||||
let each_valid = picked.iter().all(|d| !d.is_empty())
|
||||
&& (options.is_empty() || picked.iter().all(|d| options.contains(d)));
|
||||
if count_ok && each_valid {
|
||||
// F-260616-06 阶段2: 拒绝语义化。
|
||||
// 审批拒绝此前与同意一样返 Ok —— 语义反转(审批被拒却报"成功"),
|
||||
// 下游无法据 failed 触发退回/重做。
|
||||
// 现:decision 命中拒绝关键字(见 REJECT_KEYWORDS)→ 返 Err
|
||||
// "人工审批被拒绝(用户选择: <decision>)",executor Err 分支 set_failed
|
||||
// → 工作流 failed 状态 → 阶段2 推进链可据 failed 触发退回。
|
||||
// 行为变更:审批拒绝从 Ok → Err,标注(同步通知主代理)。
|
||||
if contains_reject(&picked) {
|
||||
let primary = picked.first().cloned().unwrap_or_default();
|
||||
let comment_str = comment.unwrap_or_default();
|
||||
return Err(anyhow::anyhow!(
|
||||
"人工审批被拒绝(用户选择: {}){}",
|
||||
primary,
|
||||
if comment_str.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(";意见: {}", comment_str)
|
||||
}
|
||||
));
|
||||
}
|
||||
// 输出统一含 decisions 数组;保留 decision 取首项(向后兼容下游消费者)
|
||||
let primary = picked.first().cloned().unwrap_or_default();
|
||||
return Ok(NodeOutput::from_value(serde_json::json!({
|
||||
"decision": primary,
|
||||
"decisions": picked,
|
||||
"comment": comment.unwrap_or_default(),
|
||||
})));
|
||||
}
|
||||
tracing::warn!(
|
||||
node_id = %ctx.node_id,
|
||||
execution_id = %ctx.execution_id,
|
||||
select_type = ?select_type,
|
||||
decision = %decision,
|
||||
decisions = ?picked,
|
||||
options = ?options,
|
||||
"审批决策非法,忽略并继续等待下一条合法 Response(由超时兜底)"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Ok(_) => continue, // 其他节点/类型的事件,忽略
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
tracing::warn!(
|
||||
"HumanNode {} 漏收 {} 条事件(可能错过自身响应,继续等待)",
|
||||
ctx.node_id, n
|
||||
);
|
||||
// 容量 256 + 审批低频,漏自身 Response 概率极低;
|
||||
// 丢弃则可能误判超时,故 continue。
|
||||
continue;
|
||||
}
|
||||
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!("人工审批被取消"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查节点是否被取消
|
||||
if ctx.node_status.is_cancelled(&ctx.node_id) {
|
||||
return Err(anyhow::anyhow!("人工审批被取消"));
|
||||
}
|
||||
|
||||
// TODO: 检查审批响应(需要前端实现)
|
||||
// 目前直接返回同意
|
||||
let output = serde_json::json!({
|
||||
"decision": "同意",
|
||||
"comment": "",
|
||||
});
|
||||
return Ok(NodeOutput::from_value(output));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +166,11 @@ impl Node for HumanNode {
|
||||
"options": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"select_type": {
|
||||
"type": "string",
|
||||
"enum": ["single", "multiple"],
|
||||
"default": "single"
|
||||
}
|
||||
},
|
||||
"required": ["title"]
|
||||
@@ -80,6 +179,7 @@ impl Node for HumanNode {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"decision": { "type": "string" },
|
||||
"decisions": { "type": "array", "items": { "type": "string" } },
|
||||
"comment": { "type": "string" }
|
||||
}
|
||||
}),
|
||||
@@ -94,3 +194,638 @@ impl Node for HumanNode {
|
||||
"human"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use df_types::types::NodeStatus;
|
||||
use df_workflow::dag::Dag;
|
||||
use df_workflow::eventbus::EventBus;
|
||||
use df_workflow::executor::DagExecutor;
|
||||
use df_workflow::state::StateMachine;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
/// 构造 NodeContext:自定义 execution_id / node_id / config,共享同一 EventBus。
|
||||
fn make_ctx(
|
||||
event_bus: &EventBus,
|
||||
execution_id: &str,
|
||||
node_id: &str,
|
||||
config: serde_json::Value,
|
||||
) -> NodeContext {
|
||||
NodeContext {
|
||||
node_id: node_id.to_string(),
|
||||
inputs: HashMap::new(),
|
||||
config,
|
||||
execution_id: execution_id.to_string(),
|
||||
event_bus: event_bus.clone(),
|
||||
node_status: StateMachine::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 发一条审批响应到事件总线(模拟前端 approve_human_approval IPC 走完后的链路)。
|
||||
/// F-260615-01: 单选调用方仅填 decision;多选调用方填 decisions。
|
||||
async fn send_response(
|
||||
event_bus: &EventBus,
|
||||
execution_id: &str,
|
||||
node_id: &str,
|
||||
decision: &str,
|
||||
comment: Option<&str>,
|
||||
) {
|
||||
event_bus
|
||||
.send(WorkflowEvent::HumanApprovalResponse {
|
||||
execution_id: execution_id.to_string(),
|
||||
node_id: node_id.to_string(),
|
||||
decision: decision.to_string(),
|
||||
decisions: vec![],
|
||||
comment: comment.map(String::from),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// F-260615-01: 多选响应发送助手(填 decisions 数组,decision 留空)
|
||||
async fn send_response_multi(
|
||||
event_bus: &EventBus,
|
||||
execution_id: &str,
|
||||
node_id: &str,
|
||||
decisions: &[&str],
|
||||
comment: Option<&str>,
|
||||
) {
|
||||
event_bus
|
||||
.send(WorkflowEvent::HumanApprovalResponse {
|
||||
execution_id: execution_id.to_string(),
|
||||
node_id: node_id.to_string(),
|
||||
decision: String::new(),
|
||||
decisions: decisions.iter().map(|s| s.to_string()).collect(),
|
||||
comment: comment.map(String::from),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn normal_approval_returns_decision() {
|
||||
// 正常审批:发匹配(exec_id+node_id) Response → 返回该 decision
|
||||
let bus = EventBus::new();
|
||||
let ctx = make_ctx(&bus, "exec-1", "node-h", json!({}));
|
||||
let bus_clone = bus.clone();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
HumanNode.execute(ctx).await
|
||||
});
|
||||
|
||||
// 给 execute 一点时间 subscribe + send Request
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
send_response(&bus_clone, "exec-1", "node-h", "同意", Some("好的")).await;
|
||||
|
||||
let out = handle.await.unwrap().unwrap();
|
||||
assert_eq!(out.data["decision"], json!("同意"));
|
||||
assert_eq!(out.data["comment"], json!("好的"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn request_is_emitted_to_bus() {
|
||||
// B-03b-R8: 验证 HumanNode 真发出 HumanApprovalRequest 到事件总线
|
||||
// (R6 修复前 execute 内 event_bus.send().await 缺 await → Request 未进 channel,此测会超时失败)
|
||||
let bus = EventBus::new();
|
||||
let mut rx = bus.subscribe(); // subscribe 先于 execute send(broadcast 不回放)
|
||||
let ctx = make_ctx(&bus, "exec-1", "node-h", json!({ "timeout_secs": 1 }));
|
||||
let bus_clone = bus.clone();
|
||||
let handle = tokio::spawn(async move { HumanNode.execute(ctx).await });
|
||||
|
||||
// execute send Request 后 rx 应收到
|
||||
let received = tokio::time::timeout(Duration::from_millis(500), rx.recv()).await;
|
||||
assert!(
|
||||
received.is_ok(),
|
||||
"应收到 HumanApprovalRequest(R6 Request 真发出),实际超时"
|
||||
);
|
||||
match received.unwrap().unwrap() {
|
||||
WorkflowEvent::HumanApprovalRequest {
|
||||
execution_id,
|
||||
node_id,
|
||||
title,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(execution_id, "exec-1");
|
||||
assert_eq!(node_id, "node-h");
|
||||
assert_eq!(title, "请确认");
|
||||
}
|
||||
other => panic!("期望 HumanApprovalRequest,收到 {:?}", other),
|
||||
}
|
||||
// 发 Response 让 execute 正常返回(防 task 泄漏)
|
||||
send_response(&bus_clone, "exec-1", "node-h", "同意", None).await;
|
||||
let _ = handle.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mismatched_execution_id_filtered_then_timeout() {
|
||||
// execution_id 不匹配:Response 被过滤,短超时验证 → Err 超时
|
||||
let bus = EventBus::new();
|
||||
let ctx = make_ctx(&bus, "exec-1", "node-h", json!({ "timeout_secs": 1 }));
|
||||
let bus_clone = bus.clone();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
HumanNode.execute(ctx).await
|
||||
});
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
// 错配的 execution_id
|
||||
send_response(&bus_clone, "exec-OTHER", "node-h", "同意", None).await;
|
||||
|
||||
let err = handle.await.unwrap().unwrap_err().to_string();
|
||||
assert!(err.contains("超时"), "不匹配 exec_id 应被过滤后超时, 实际: {}", err);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mismatched_node_id_filtered_then_timeout() {
|
||||
// node_id 不匹配:Response 被过滤,短超时验证 → Err 超时
|
||||
let bus = EventBus::new();
|
||||
let ctx = make_ctx(&bus, "exec-1", "node-h", json!({ "timeout_secs": 1 }));
|
||||
let bus_clone = bus.clone();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
HumanNode.execute(ctx).await
|
||||
});
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
// 错配的 node_id
|
||||
send_response(&bus_clone, "exec-1", "node-OTHER", "同意", None).await;
|
||||
|
||||
let err = handle.await.unwrap().unwrap_err().to_string();
|
||||
assert!(err.contains("超时"), "不匹配 node_id 应被过滤后超时, 实际: {}", err);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn timeout_when_no_response() {
|
||||
// 无 Response → sleep_until(deadline) 触发超时
|
||||
let bus = EventBus::new();
|
||||
let ctx = make_ctx(&bus, "exec-1", "node-h", json!({ "timeout_secs": 1 }));
|
||||
|
||||
let err = HumanNode.execute(ctx).await.unwrap_err().to_string();
|
||||
assert!(err.contains("超时"), "应报超时, 实际: {}", err);
|
||||
assert!(err.contains("1s"), "超时消息应带秒数, 实际: {}", err);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_decision_ignored_then_timeout() {
|
||||
// R-P1-6: options 非空且 decision ∉ options → 不立即 Err(一次手误不应杀死节点),
|
||||
// warn 记录 + continue 续等下一条合法 Response;此处仅发一条非法的,短超时验证 → Err 超时。
|
||||
let bus = EventBus::new();
|
||||
let ctx = make_ctx(
|
||||
&bus,
|
||||
"exec-1",
|
||||
"node-h",
|
||||
json!({ "options": ["同意", "拒绝"], "timeout_secs": 1 }),
|
||||
);
|
||||
let bus_clone = bus.clone();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
HumanNode.execute(ctx).await
|
||||
});
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
send_response(&bus_clone, "exec-1", "node-h", "随便", None).await;
|
||||
|
||||
let err = handle.await.unwrap().unwrap_err().to_string();
|
||||
assert!(err.contains("超时"), "非法 decision 应被忽略后超时, 实际: {}", err);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_options_allows_free_text() {
|
||||
// options 空 → 允许任意自由文本 decision(不校验)
|
||||
let bus = EventBus::new();
|
||||
let ctx = make_ctx(&bus, "exec-1", "node-h", json!({ "options": [] }));
|
||||
let bus_clone = bus.clone();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
HumanNode.execute(ctx).await
|
||||
});
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
send_response(&bus_clone, "exec-1", "node-h", "改成先做B方案", None).await;
|
||||
|
||||
let out = handle.await.unwrap().unwrap();
|
||||
assert_eq!(
|
||||
out.data["decision"],
|
||||
json!("改成先做B方案"),
|
||||
"options 空应允许自由文本 decision"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_decision_ignored_then_timeout_even_with_empty_options() {
|
||||
// R-P1-6: 空 decision 始终非法,但同样不立即 Err——warn + continue 续等,短超时验证 → Err 超时。
|
||||
let bus = EventBus::new();
|
||||
let ctx = make_ctx(&bus, "exec-1", "node-h", json!({ "options": [], "timeout_secs": 1 }));
|
||||
let bus_clone = bus.clone();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
HumanNode.execute(ctx).await
|
||||
});
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
send_response(&bus_clone, "exec-1", "node-h", "", None).await;
|
||||
|
||||
let err = handle.await.unwrap().unwrap_err().to_string();
|
||||
assert!(err.contains("超时"), "空 decision 应被忽略后超时, 实际: {}", err);
|
||||
}
|
||||
|
||||
// Closed 分支覆盖:execute 持有的 ctx 含 EventBus clone(即一个 sender),
|
||||
// 在 execute 运行期间通道无法 Closed(至少该 sender 存活)。
|
||||
// 该分支仅在所有 sender drop 后可达,需独立集成测试,此处不强造。
|
||||
|
||||
// ===== B-03b-R8: executor 级端到端集成测试 =====
|
||||
// 上述单测均为 HumanNode.execute 直接调用级,不覆盖 executor 驱动含 human 的 DAG。
|
||||
// 端到端覆盖 NodeStarted/Completed 事件流转、共享 state_machine、Request 经 executor 驱动真发出、
|
||||
// outputs 收集,是 R6(send 缺 await)/R7(契约失配)的存活土壤。
|
||||
|
||||
/// 简单测试节点:sleep 指定毫秒后返回空输出(仿 executor.rs SleepNode,作 human 的前驱)
|
||||
struct SleepNode {
|
||||
sleep_ms: u64,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Node for SleepNode {
|
||||
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
|
||||
tokio::time::sleep(Duration::from_millis(self.sleep_ms)).await;
|
||||
Ok(NodeOutput::empty())
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: serde_json::Value::Null,
|
||||
output: serde_json::Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"sleep"
|
||||
}
|
||||
}
|
||||
|
||||
/// B-03b-R8: executor 驱动 a(SleepNode) → b(HumanNode) 两层 DAG 端到端。
|
||||
/// a 完成 → b 阻塞等审批 → 外部发 Response(模拟 approve_human_approval IPC) → b Ok → 工作流完成。
|
||||
/// 验证:Request 经 executor 驱动真发出(R6)、outputs 收集两层、共享 state_machine 两节点皆 Completed。
|
||||
#[tokio::test]
|
||||
async fn end_to_end_human_approval_completes_workflow() {
|
||||
let bus = EventBus::new();
|
||||
let mut rx = bus.subscribe(); // subscribe 先于 executor send(broadcast 不回放)
|
||||
let exec_id = "exec-e2e-1";
|
||||
|
||||
let mut dag = Dag::new();
|
||||
dag.add_node("a".to_string(), Box::new(SleepNode { sleep_ms: 10 }));
|
||||
dag.add_node("b".to_string(), Box::new(HumanNode));
|
||||
dag.add_edge("a".to_string(), "b".to_string());
|
||||
|
||||
let mut executor = DagExecutor::new(bus.clone(), exec_id.to_string());
|
||||
let sm = executor.state_machine(); // 共享状态机(spawn 后仍可读)
|
||||
|
||||
let run_handle = tokio::spawn(async move {
|
||||
executor
|
||||
.run(
|
||||
&dag,
|
||||
json!({ "title": "确认发布", "options": ["同意", "拒绝"], "timeout_secs": 5 }),
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
// 等 a 层完成、b 层 human subscribe + send Request。
|
||||
// executor 先发 NodeStarted(a)/NodeStarted(b),再发 human 的 Request —— 过滤跳过非 Request 事件
|
||||
let request = tokio::time::timeout(Duration::from_millis(1000), async {
|
||||
loop {
|
||||
if let Ok(ev @ WorkflowEvent::HumanApprovalRequest { .. }) = rx.recv().await {
|
||||
return ev;
|
||||
}
|
||||
// NodeStarted/NodeCompleted 等其他事件,继续等
|
||||
}
|
||||
})
|
||||
.await;
|
||||
assert!(
|
||||
request.is_ok(),
|
||||
"应收到 b 的 HumanApprovalRequest(R6 端到端),实际超时"
|
||||
);
|
||||
match request.unwrap() {
|
||||
WorkflowEvent::HumanApprovalRequest { node_id, title, options, .. } => {
|
||||
assert_eq!(node_id, "b");
|
||||
assert_eq!(title, "确认发布");
|
||||
assert_eq!(options, vec!["同意".to_string(), "拒绝".to_string()]);
|
||||
}
|
||||
other => panic!("期望 HumanApprovalRequest,收到 {:?}", other),
|
||||
}
|
||||
|
||||
// 外部 IPC 模拟:审批通过
|
||||
send_response(&bus, exec_id, "b", "同意", Some("可以发布")).await;
|
||||
|
||||
let outputs = run_handle.await.unwrap().unwrap();
|
||||
assert!(outputs.contains_key("a"), "outputs 应含前驱节点 a");
|
||||
assert_eq!(outputs["b"].data["decision"], json!("同意"));
|
||||
assert_eq!(outputs["b"].data["decisions"], json!(["同意"]));
|
||||
assert_eq!(outputs["b"].data["comment"], json!("可以发布"));
|
||||
assert_eq!(sm.get(&"a".to_string()), NodeStatus::Completed);
|
||||
assert_eq!(sm.get(&"b".to_string()), NodeStatus::Completed);
|
||||
}
|
||||
|
||||
/// B-03b-R8: executor 驱动 human DAG,外部 set_cancelled(模拟 cancel_workflow_node IPC)
|
||||
/// → human cancel_tick 命中 is_cancelled → Err → executor 跳过 set_failed(Cancelled 已终态,R1)
|
||||
/// → run 返回取消 Err + 状态保持 Cancelled。覆盖取消在真实 HumanNode 上的端到端(R2)。
|
||||
#[tokio::test]
|
||||
async fn end_to_end_human_approval_cancelled() {
|
||||
let bus = EventBus::new();
|
||||
let mut rx = bus.subscribe(); // subscribe 先于 executor send
|
||||
let exec_id = "exec-e2e-cancel";
|
||||
|
||||
let mut dag = Dag::new();
|
||||
dag.add_node("h".to_string(), Box::new(HumanNode));
|
||||
|
||||
let mut executor = DagExecutor::new(bus.clone(), exec_id.to_string());
|
||||
let sm = executor.state_machine();
|
||||
|
||||
let run_handle = tokio::spawn(async move {
|
||||
executor.run(&dag, json!({ "timeout_secs": 5 })).await
|
||||
});
|
||||
|
||||
// 等 human 发出 Request(确认已 subscribe + send + 进入 select! 轮询)
|
||||
let saw_request = tokio::time::timeout(Duration::from_millis(1000), async {
|
||||
while !matches!(rx.recv().await, Ok(WorkflowEvent::HumanApprovalRequest { .. })) {}
|
||||
})
|
||||
.await
|
||||
.is_ok();
|
||||
assert!(saw_request, "human 应先发出 HumanApprovalRequest");
|
||||
|
||||
// 外部 IPC 模拟:取消节点(写共享 state_machine,human cancel_tick 会读到)
|
||||
sm.set_cancelled("h".to_string());
|
||||
|
||||
let result = run_handle.await.unwrap();
|
||||
assert!(result.is_err(), "取消应致 run 返回 Err");
|
||||
let err = result.unwrap_err().to_string();
|
||||
// executor 把节点 Err 包成 "节点 h 执行失败"(context),原 "人工审批被取消" 在 source chain
|
||||
assert!(
|
||||
err.contains("取消") || err.contains("执行失败"),
|
||||
"应返回取消相关错误,实际: {}",
|
||||
err
|
||||
);
|
||||
// 状态保持 Cancelled(未被 set_failed 覆盖为 Failed)—— R1 修法端到端验证
|
||||
assert_eq!(sm.get(&"h".to_string()), NodeStatus::Cancelled);
|
||||
}
|
||||
|
||||
// ===== F-260615-01: 多选审批覆盖 =====
|
||||
|
||||
/// F-260615-01: select_type=multiple + 多 decisions(均∈options) → 返回 decisions 数组
|
||||
#[tokio::test]
|
||||
async fn multiple_select_returns_decisions_array() {
|
||||
let bus = EventBus::new();
|
||||
let ctx = make_ctx(
|
||||
&bus,
|
||||
"exec-multi",
|
||||
"node-h",
|
||||
json!({
|
||||
"options": ["A", "B", "C"],
|
||||
"select_type": "multiple"
|
||||
}),
|
||||
);
|
||||
let bus_clone = bus.clone();
|
||||
|
||||
let handle = tokio::spawn(async move { HumanNode.execute(ctx).await });
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
send_response_multi(&bus_clone, "exec-multi", "node-h", &["A", "C"], Some("多选")).await;
|
||||
|
||||
let out = handle.await.unwrap().unwrap();
|
||||
assert_eq!(out.data["decision"], json!("A"), "decision 取首项");
|
||||
assert_eq!(out.data["decisions"], json!(["A", "C"]));
|
||||
assert_eq!(out.data["comment"], json!("多选"));
|
||||
}
|
||||
|
||||
/// F-260615-01: select_type=single 缺省 + decisions 多个 → 校验失败(count!=1)忽略后超时
|
||||
#[tokio::test]
|
||||
async fn single_select_rejects_multiple_decisions_then_timeout() {
|
||||
let bus = EventBus::new();
|
||||
let ctx = make_ctx(
|
||||
&bus,
|
||||
"exec-single-multi",
|
||||
"node-h",
|
||||
json!({ "options": ["A", "B"], "timeout_secs": 1 }), // 缺省 single
|
||||
);
|
||||
let bus_clone = bus.clone();
|
||||
|
||||
let handle = tokio::spawn(async move { HumanNode.execute(ctx).await });
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
// single 模式却发多个 → 非法
|
||||
send_response_multi(&bus_clone, "exec-single-multi", "node-h", &["A", "B"], None).await;
|
||||
|
||||
let err = handle.await.unwrap().unwrap_err().to_string();
|
||||
assert!(err.contains("超时"), "single 下多 decisions 应被忽略后超时, 实际: {}", err);
|
||||
}
|
||||
|
||||
/// F-260615-01: select_type=multiple 但 decisions 含 ∉ options 的项 → 非法忽略后超时
|
||||
#[tokio::test]
|
||||
async fn multiple_select_invalid_option_ignored_then_timeout() {
|
||||
let bus = EventBus::new();
|
||||
let ctx = make_ctx(
|
||||
&bus,
|
||||
"exec-multi-bad",
|
||||
"node-h",
|
||||
json!({
|
||||
"options": ["A", "B"],
|
||||
"select_type": "multiple",
|
||||
"timeout_secs": 1
|
||||
}),
|
||||
);
|
||||
let bus_clone = bus.clone();
|
||||
|
||||
let handle = tokio::spawn(async move { HumanNode.execute(ctx).await });
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
// 一项合法一项不合法 → 整批非法
|
||||
send_response_multi(&bus_clone, "exec-multi-bad", "node-h", &["A", "X"], None).await;
|
||||
|
||||
let err = handle.await.unwrap().unwrap_err().to_string();
|
||||
assert!(err.contains("超时"), "含非法 option 应被忽略后超时, 实际: {}", err);
|
||||
}
|
||||
|
||||
/// F-260615-01: 兼容旧调用方 —— 不填 select_type(缺省 single) + 只填 decision 单值,应正常通过
|
||||
/// (即所有未改造的现有 Request 均按 single 解析,零改动)
|
||||
#[tokio::test]
|
||||
async fn default_single_with_legacy_decision_single_value() {
|
||||
let bus = EventBus::new();
|
||||
// 注意:不传 select_type,验证缺省=Single
|
||||
let ctx = make_ctx(&bus, "exec-leg", "node-h", json!({ "options": ["同意", "拒绝"] }));
|
||||
let bus_clone = bus.clone();
|
||||
|
||||
let handle = tokio::spawn(async move { HumanNode.execute(ctx).await });
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
// 旧链路只填 decision,decisions 为空 → HumanNode 兼容回退
|
||||
send_response(&bus_clone, "exec-leg", "node-h", "同意", None).await;
|
||||
|
||||
let out = handle.await.unwrap().unwrap();
|
||||
assert_eq!(out.data["decision"], json!("同意"));
|
||||
assert_eq!(out.data["decisions"], json!(["同意"]), "兼容回退后 decisions 应含单值");
|
||||
}
|
||||
|
||||
// ===== F-260616-06 阶段2: 审批拒绝语义化(行为变更: 拒绝从 Ok → Err) =====
|
||||
|
||||
/// F-260616-06: 默认 options `["同意","拒绝"]` 下选「拒绝」→ Err(不再 Ok)。
|
||||
/// 阶段2 推进链依赖工作流 failed 触发退回,故拒绝必须让节点返 Err → executor set_failed。
|
||||
#[tokio::test]
|
||||
async fn reject_decision_returns_error() {
|
||||
let bus = EventBus::new();
|
||||
let ctx = make_ctx(
|
||||
&bus,
|
||||
"exec-reject",
|
||||
"node-h",
|
||||
json!({ "options": ["同意", "拒绝"] }),
|
||||
);
|
||||
let bus_clone = bus.clone();
|
||||
|
||||
let handle = tokio::spawn(async move { HumanNode.execute(ctx).await });
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
send_response(&bus_clone, "exec-reject", "node-h", "拒绝", Some("方案不行")).await;
|
||||
|
||||
let err = handle.await.unwrap().unwrap_err().to_string();
|
||||
assert!(
|
||||
err.contains("拒绝") && err.contains("人工审批"),
|
||||
"拒绝 decision 应返 Err「人工审批被拒绝...」, 实际: {}",
|
||||
err
|
||||
);
|
||||
assert!(
|
||||
err.contains("方案不行"),
|
||||
"拒绝 Err 应携带 comment, 实际: {}",
|
||||
err
|
||||
);
|
||||
}
|
||||
|
||||
/// F-260616-06: 同一 options 下选「同意」→ Ok(通过路径不回归)。
|
||||
#[tokio::test]
|
||||
async fn approve_decision_still_ok() {
|
||||
let bus = EventBus::new();
|
||||
let ctx = make_ctx(
|
||||
&bus,
|
||||
"exec-approve",
|
||||
"node-h",
|
||||
json!({ "options": ["同意", "拒绝"] }),
|
||||
);
|
||||
let bus_clone = bus.clone();
|
||||
|
||||
let handle = tokio::spawn(async move { HumanNode.execute(ctx).await });
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
send_response(&bus_clone, "exec-approve", "node-h", "同意", None).await;
|
||||
|
||||
let out = handle.await.unwrap().unwrap();
|
||||
assert_eq!(out.data["decision"], json!("同意"));
|
||||
}
|
||||
|
||||
/// F-260616-06: 英文 reject 关键字同样识别为拒绝 → Err(归一化大小写/空白)。
|
||||
/// 多关键字覆盖走 reject_keyword_detection_normalized 纯单元测试,此处仅验证端到端一条。
|
||||
#[tokio::test]
|
||||
async fn english_reject_keyword_returns_error() {
|
||||
let bus = EventBus::new();
|
||||
let ctx = make_ctx(
|
||||
&bus,
|
||||
"exec-en",
|
||||
"node-h",
|
||||
json!({ "options": ["approve", "reject"], "timeout_secs": 5 }),
|
||||
);
|
||||
let bus_clone = bus.clone();
|
||||
|
||||
let handle = tokio::spawn(async move { HumanNode.execute(ctx).await });
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
send_response(&bus_clone, "exec-en", "node-h", "reject", None).await;
|
||||
|
||||
let err = handle.await.unwrap().unwrap_err().to_string();
|
||||
assert!(
|
||||
err.contains("拒绝"),
|
||||
"英文 reject 关键字应返 Err, 实际: {}",
|
||||
err
|
||||
);
|
||||
}
|
||||
|
||||
/// F-260616-06: 多选场景,picked 含一项拒绝 → 整单拒绝 → Err
|
||||
/// (选了「驳回」即驳回,即便同时选了「同意」)。
|
||||
#[tokio::test]
|
||||
async fn multiple_select_with_one_reject_returns_error() {
|
||||
let bus = EventBus::new();
|
||||
let ctx = make_ctx(
|
||||
&bus,
|
||||
"exec-multi-reject",
|
||||
"node-h",
|
||||
json!({
|
||||
"options": ["同意", "驳回", "备注"],
|
||||
"select_type": "multiple"
|
||||
}),
|
||||
);
|
||||
let bus_clone = bus.clone();
|
||||
|
||||
let handle = tokio::spawn(async move { HumanNode.execute(ctx).await });
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
send_response_multi(
|
||||
&bus_clone,
|
||||
"exec-multi-reject",
|
||||
"node-h",
|
||||
&["同意", "驳回"],
|
||||
Some("驳回其中一项"),
|
||||
)
|
||||
.await;
|
||||
|
||||
let err = handle.await.unwrap().unwrap_err().to_string();
|
||||
assert!(err.contains("拒绝"), "多选含拒绝项应返 Err, 实际: {}", err);
|
||||
}
|
||||
|
||||
/// F-260616-06: options 空的自由文本场景 —— 明确拒绝词("拒绝")仍返 Err,
|
||||
/// 其余自由文本(非拒绝词)仍按通过处理(向后兼容,不阻断自由反馈)。
|
||||
#[tokio::test]
|
||||
async fn empty_options_free_text_reject_keyword_still_errors() {
|
||||
let bus = EventBus::new();
|
||||
let ctx = make_ctx(&bus, "exec-free-reject", "node-h", json!({ "options": [] }));
|
||||
let bus_clone = bus.clone();
|
||||
|
||||
let handle = tokio::spawn(async move { HumanNode.execute(ctx).await });
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
send_response(&bus_clone, "exec-free-reject", "node-h", "拒绝", None).await;
|
||||
|
||||
let err = handle.await.unwrap().unwrap_err().to_string();
|
||||
assert!(err.contains("拒绝"), "自由文本明确为拒绝词仍应 Err, 实际: {}", err);
|
||||
}
|
||||
|
||||
/// F-260616-06: options 空的自由文本场景 —— 非拒绝词自由文本仍返 Ok(不误伤自由反馈)。
|
||||
/// (empty_options_allows_free_text 已覆盖 "改成先做B方案" → Ok,此处补一条非拒绝中文短句。)
|
||||
#[tokio::test]
|
||||
async fn empty_options_non_reject_free_text_still_ok() {
|
||||
let bus = EventBus::new();
|
||||
let ctx = make_ctx(&bus, "exec-free-ok", "node-h", json!({ "options": [] }));
|
||||
let bus_clone = bus.clone();
|
||||
|
||||
let handle = tokio::spawn(async move { HumanNode.execute(ctx).await });
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
send_response(&bus_clone, "exec-free-ok", "node-h", "再讨论一下", None).await;
|
||||
|
||||
let out = handle.await.unwrap().unwrap();
|
||||
assert_eq!(out.data["decision"], json!("再讨论一下"));
|
||||
}
|
||||
|
||||
/// F-260616-06 单元: 关键字判定函数归一化(去空白+小写)与边界。
|
||||
#[test]
|
||||
fn reject_keyword_detection_normalized() {
|
||||
assert!(is_reject_decision("拒绝"));
|
||||
assert!(is_reject_decision(" 拒绝 "));
|
||||
assert!(is_reject_decision("Reject"));
|
||||
assert!(is_reject_decision(" decline "));
|
||||
assert!(is_reject_decision("驳回"));
|
||||
assert!(is_reject_decision("退回"));
|
||||
assert!(!is_reject_decision("同意"));
|
||||
assert!(!is_reject_decision("approve"));
|
||||
assert!(!is_reject_decision("再讨论"));
|
||||
assert!(!is_reject_decision(""));
|
||||
// 不是关键字开头,是整词匹配 —— "拒绝啦" 不应误判
|
||||
assert!(!is_reject_decision("拒绝啦"));
|
||||
assert!(contains_reject(&["同意".into()]) == false);
|
||||
assert!(contains_reject(&["同意".into(), "拒绝".into()]) == true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
//! 人工审批节点辅助函数/类型 — 拒绝语义化关键字判定等纯逻辑
|
||||
//!
|
||||
//! 从 human_node.rs 抽离的纯函数/常量(execute 与关键字判定解耦,便于单测覆盖)。
|
||||
//! HumanNode 的 struct + impl 仍保留在 human_node.rs(impl 块约束)。
|
||||
//!
|
||||
//! B-260615-05 / CR-260618-15: 拒绝语义化保留(executor set_failed → 推进链退回)。
|
||||
|
||||
/// F-260616-06 阶段2: 拒绝语义化关键字。
|
||||
/// decision 归一化(去空白 + 小写)后命中此集合 → 审批拒绝 → 节点返 Err(触发工作流 failed)。
|
||||
///
|
||||
/// 识别范围(避免误伤):
|
||||
/// - 仅当节点 options 非空且该 decision ∈ options 时拒绝 —— 前端从受控选项中选择,语义可信;
|
||||
/// - options 空(自由文本)时同样按关键字判定 —— 仅当文本明确为拒绝词才拒绝,
|
||||
/// 其余自由文本一律视为通过(向后兼容,不阻断自由反馈)。
|
||||
///
|
||||
/// 关键字集合与前端/IPC 的 options 命名约定对齐:「拒绝/驳回/退回」中文 +
|
||||
/// 「reject/decline/deny/no/block」英文,覆盖默认 options `["同意","拒绝"]`。
|
||||
pub(super) const REJECT_KEYWORDS: &[&str] = &[
|
||||
"拒绝", "驳回", "退回", "否决",
|
||||
"reject", "decline", "declined", "deny", "denied", "no", "block",
|
||||
];
|
||||
|
||||
/// 判定单个 decision 是否为拒绝选项(归一化后命中 REJECT_KEYWORDS)。
|
||||
pub(super) fn is_reject_decision(d: &str) -> bool {
|
||||
let normalized = d.trim().to_lowercase();
|
||||
REJECT_KEYWORDS.iter().any(|k| normalized == *k)
|
||||
}
|
||||
|
||||
/// 判定 picked 集合是否含拒绝选项:任一项命中即视为整单拒绝(多选场景「选了驳回」即驳回)。
|
||||
pub(super) fn contains_reject(picked: &[String]) -> bool {
|
||||
picked.iter().any(|d| is_reject_decision(d))
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
//! df-nodes: 内置节点集合 — AI、脚本、Docker、Git、人工审批、HTTP、子流程、通知
|
||||
//! df-nodes: 内置节点集合 — AI、脚本、人工审批
|
||||
|
||||
pub mod ai_node;
|
||||
pub mod docker_node;
|
||||
pub mod git_node;
|
||||
pub mod http_node;
|
||||
pub mod ai_self_review_node;
|
||||
mod ai_node_helpers;
|
||||
pub mod human_node;
|
||||
pub mod notify_node;
|
||||
mod human_node_helpers;
|
||||
pub mod script_node;
|
||||
pub mod subflow_node;
|
||||
pub mod task_advance_node;
|
||||
pub mod task_state_machine;
|
||||
pub mod task_workflow_templates;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ impl Node for ScriptNode {
|
||||
working_dir,
|
||||
env: std::collections::HashMap::new(),
|
||||
timeout_secs,
|
||||
shell_type: Default::default(),
|
||||
};
|
||||
|
||||
tracing::info!("ScriptNode 执行命令: {}", command);
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
//! 任务推进节点 — advance_task 推进链触发器(F-260616-02)
|
||||
//!
|
||||
//! 实现推进链的唯一 status 写入路径(D-260616-03 落 df-nodes Node):
|
||||
//! 1. 读当前 TaskRecord(取 from status)
|
||||
//! 2. 状态机校验 can_transition(from, to)(task_state_machine)
|
||||
//! 3. 原子写:下沉 SQL `WHERE id AND status=expected` 防 TOCTOU,退回转换一并
|
||||
//! review_rounds+=1(见 df_storage::crud::TaskRepo::advance_status_atomic)
|
||||
//!
|
||||
//! 两条调用路径复用同一推进逻辑:
|
||||
//! - DAG 工作流:TaskAdvanceNode 实现 Node trait,从 NodeContext.config 读 task_id /
|
||||
//! target_status,持有 Arc<Database> 构造 TaskRepo。
|
||||
//! - IPC 直驱:advance_task_atomic 公开 async fn,src-tauri commands::task::advance_task
|
||||
//! thin 入口直接调用(无需构造 NodeContext,避免 EventBus/StateMachine 等重依赖)。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use df_storage::crud::TaskRepo;
|
||||
use df_storage::db::Database;
|
||||
use df_storage::models::TaskRecord;
|
||||
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
|
||||
|
||||
use crate::task_state_machine::{can_transition, is_regression, is_valid_state, ALL_STATES};
|
||||
|
||||
/// 推进任务到目标状态(核心逻辑,DAG 节点与 IPC 入口共用)。
|
||||
///
|
||||
/// 入参:
|
||||
/// - `repo`:df-storage TaskRepo
|
||||
/// - `id`:任务 ID
|
||||
/// - `target_status`:目标状态(7 态之一,snake_case)
|
||||
///
|
||||
/// 流程:
|
||||
/// 1. 任务存在性:找不到 → NotFound 错误(前端可据此提示)
|
||||
/// 2. target_status 合法性:非 7 态 → Validation 错误(防脏数据直入推进链)
|
||||
/// 3. 状态机(三类拒绝,错误区分供前端分辨):
|
||||
/// - 同态拒绝(from==to):Validation「相同状态,无需推进」(非状态机违例,是空操作)
|
||||
/// - 非法转换(跳态/终态后继等):InvalidState「非法状态转换 X→Y」(含 from/to 上下文)
|
||||
/// - can_transition 闸门矩阵判否即此分支
|
||||
/// 4. 原子写:advance_status_atomic CAS,to 是退回转换时 bump_rounds=true
|
||||
/// 5. CAS 失败(affected==0):状态已被并发改动 → InvalidState 错误(防 TOCTOU 静默成功)
|
||||
///
|
||||
/// 返回:成功推进后的最新 TaskRecord(含新 status / 累加后的 review_rounds / 新 updated_at)。
|
||||
pub async fn advance_task_atomic(
|
||||
repo: &TaskRepo,
|
||||
id: &str,
|
||||
target_status: &str,
|
||||
) -> df_types::error::Result<TaskRecord> {
|
||||
// 1. target 合法性(7 态之一)。先于读库校验:即便任务不存在,也先拒绝非法状态值。
|
||||
if !is_valid_state(target_status) {
|
||||
return Err(df_types::error::Error::Validation(format!(
|
||||
"非法 target_status {:?},合法值: {}",
|
||||
target_status,
|
||||
ALL_STATES.join("/")
|
||||
)));
|
||||
}
|
||||
|
||||
// 2. 读当前任务(取 from status)。
|
||||
let current = repo
|
||||
.get_by_id(id)
|
||||
.await?
|
||||
.ok_or_else(|| df_types::error::Error::NotFound(format!("任务 {} 不存在", id)))?;
|
||||
|
||||
// 3. 状态机校验(三类拒绝,错误类型区分供前端分辨):
|
||||
// - 同态(from==to):Validation「相同状态,无需推进」(空操作,非状态机违例)
|
||||
// - 非法转换(跳态/终态无后继等):InvalidState「非法状态转换 X→Y」
|
||||
let from = current.status.as_str();
|
||||
if from == target_status {
|
||||
return Err(df_types::error::Error::Validation(format!(
|
||||
"相同状态 {from:?},无需推进"
|
||||
)));
|
||||
}
|
||||
if !can_transition(from, target_status) {
|
||||
return Err(df_types::error::Error::InvalidState {
|
||||
current: format!("{from}→{target_status}(非法状态转换)"),
|
||||
expected: target_status.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// 4. 原子 CAS 写。退回转换一并 review_rounds+=1。
|
||||
let bump = is_regression(from, target_status);
|
||||
let updated = repo
|
||||
.advance_status_atomic(id, from, target_status, bump)
|
||||
.await?;
|
||||
|
||||
// 5. CAS 失败:并发已改动 status(或任务被删),拒绝静默成功。
|
||||
updated.ok_or_else(|| df_types::error::Error::InvalidState {
|
||||
current: "已变更(并发推进或旁路修改)".to_string(),
|
||||
expected: from.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// DAG 工作流节点 — TaskAdvanceNode(推进链在 DAG 内的形态)
|
||||
// ============================================================
|
||||
|
||||
/// 任务推进节点 — DAG 工作流内触发 advance_task。
|
||||
///
|
||||
/// 持有 Arc<Database> 在 NodeRegistry 注册时注入(state.rs build_registry),
|
||||
/// Node::execute 从 NodeContext.config 读 task_id / target_status,调 advance_task_atomic。
|
||||
/// 当前推进链 F-01~04 阶段为手动推进(IPC 直驱),DAG 形态为阶段 2(工作流联动)预留。
|
||||
pub struct TaskAdvanceNode {
|
||||
db: Arc<Database>,
|
||||
}
|
||||
|
||||
impl TaskAdvanceNode {
|
||||
/// 构造节点(注册表工厂调用,注入数据库句柄)
|
||||
pub fn new(db: Arc<Database>) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Node for TaskAdvanceNode {
|
||||
async fn execute(&self, ctx: NodeContext) -> NodeResult {
|
||||
let task_id = ctx
|
||||
.config
|
||||
.get("task_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("TaskAdvanceNode 缺少必填参数: task_id"))?;
|
||||
let target_status = ctx
|
||||
.config
|
||||
.get("target_status")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("TaskAdvanceNode 缺少必填参数: target_status"))?;
|
||||
|
||||
let repo = TaskRepo::new(&self.db);
|
||||
let updated = advance_task_atomic(&repo, task_id, target_status)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("推进任务失败: {}", e))?;
|
||||
|
||||
Ok(NodeOutput::from_value(serde_json::json!({
|
||||
"task": updated,
|
||||
"task_id": updated.id,
|
||||
"status": updated.status,
|
||||
"review_rounds": updated.review_rounds,
|
||||
})))
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task_id": { "type": "string" },
|
||||
"target_status": {
|
||||
"type": "string",
|
||||
"enum": ["todo", "in_progress", "in_review", "testing", "done", "blocked", "cancelled"]
|
||||
}
|
||||
},
|
||||
"required": ["task_id", "target_status"]
|
||||
}),
|
||||
output: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task_id": { "type": "string" },
|
||||
"status": { "type": "string" },
|
||||
"review_rounds": { "type": "integer" }
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"task_advance"
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 单元测试 — advance_task_atomic 状态机集成(内存 DB)
|
||||
// ============================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use df_storage::crud::ProjectRepo;
|
||||
use df_storage::models::{ProjectRecord, TaskRecord};
|
||||
|
||||
fn rec(id: &str, status: &str) -> TaskRecord {
|
||||
TaskRecord {
|
||||
id: id.to_string(),
|
||||
project_id: "p1".to_string(),
|
||||
title: format!("t-{id}"),
|
||||
description: "".to_string(),
|
||||
status: status.to_string(),
|
||||
priority: 2,
|
||||
branch_name: None,
|
||||
assignee: None,
|
||||
workflow_def_id: None,
|
||||
base_branch: None,
|
||||
review_rounds: 0,
|
||||
output_json: None,
|
||||
idea_id: None,
|
||||
created_at: "0".to_string(),
|
||||
updated_at: "0".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn setup() -> TaskRepo {
|
||||
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||
// 先插父项目,满足 tasks.project_id FK 约束(PRAGMA foreign_keys=ON)。
|
||||
ProjectRepo::new(&db)
|
||||
.insert(ProjectRecord {
|
||||
id: "p1".to_string(),
|
||||
name: "proj".to_string(),
|
||||
description: "".to_string(),
|
||||
status: "planning".to_string(),
|
||||
idea_id: None,
|
||||
path: None,
|
||||
stack: None,
|
||||
created_at: "0".to_string(),
|
||||
updated_at: "0".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("insert project");
|
||||
TaskRepo::new(&db)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn forward_path_todo_to_done() {
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("t1", "todo")).await.unwrap();
|
||||
// 主路径逐级推进
|
||||
let r = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap();
|
||||
assert_eq!(r.status, "in_progress");
|
||||
assert_eq!(r.review_rounds, 0);
|
||||
let r = advance_task_atomic(&repo, "t1", "in_review").await.unwrap();
|
||||
assert_eq!(r.status, "in_review");
|
||||
assert_eq!(r.review_rounds, 0);
|
||||
let r = advance_task_atomic(&repo, "t1", "testing").await.unwrap();
|
||||
assert_eq!(r.status, "testing");
|
||||
let r = advance_task_atomic(&repo, "t1", "done").await.unwrap();
|
||||
assert_eq!(r.status, "done");
|
||||
assert_eq!(r.review_rounds, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn regression_in_review_to_in_progress_bumps_rounds() {
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("t1", "in_review")).await.unwrap();
|
||||
let r = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap();
|
||||
assert_eq!(r.status, "in_progress");
|
||||
assert_eq!(r.review_rounds, 1, "退回应 +1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn regression_testing_to_in_review_bumps_rounds() {
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("t1", "testing")).await.unwrap();
|
||||
let r = advance_task_atomic(&repo, "t1", "in_review").await.unwrap();
|
||||
assert_eq!(r.status, "in_review");
|
||||
assert_eq!(r.review_rounds, 1, "退回应 +1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multiple_regressions_accumulate() {
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("t1", "in_review")).await.unwrap();
|
||||
// in_review → in_progress (+1) → in_review (前向,不动) → in_progress (+1=2)
|
||||
advance_task_atomic(&repo, "t1", "in_progress").await.unwrap();
|
||||
advance_task_atomic(&repo, "t1", "in_review").await.unwrap();
|
||||
let r = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap();
|
||||
assert_eq!(r.review_rounds, 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn illegal_skip_rejected() {
|
||||
// CR-01-D: 非法转换(跳态)归 InvalidState,且消息含 from→to 上下文与「非法状态转换」。
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("t1", "todo")).await.unwrap();
|
||||
let err = advance_task_atomic(&repo, "t1", "done").await.unwrap_err();
|
||||
match err {
|
||||
df_types::error::Error::InvalidState { current, expected } => {
|
||||
assert!(current.contains("todo→done"), "非法转换消息应含 from→to,实际: {current}");
|
||||
assert!(current.contains("非法状态转换"), "消息应含「非法状态转换」,实际: {current}");
|
||||
assert_eq!(expected, "done");
|
||||
}
|
||||
other => panic!("非法转换应是 InvalidState,实际: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn terminal_done_no_successor() {
|
||||
// CR-01-D: 终态无后继也是非法转换路径,归 InvalidState(同 illegal_skip_rejected)。
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("t1", "done")).await.unwrap();
|
||||
let err = advance_task_atomic(&repo, "t1", "todo").await.unwrap_err();
|
||||
match err {
|
||||
df_types::error::Error::InvalidState { current, .. } => {
|
||||
assert!(current.contains("done→todo"), "终态后继消息应含 from→to,实际: {current}");
|
||||
}
|
||||
other => panic!("终态后继应是 InvalidState,实际: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn same_status_rejected() {
|
||||
// CR-01-D: 同态拒绝(from==to)与非法转换(跳态)错误类型区分。
|
||||
// 同态属空操作,归 Validation「相同状态,无需推进」;
|
||||
// 非法转换归 InvalidState「非法状态转换 X→Y」(见 illegal_skip_rejected)。
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("t1", "in_progress")).await.unwrap();
|
||||
let err = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap_err();
|
||||
match err {
|
||||
df_types::error::Error::Validation(msg) => {
|
||||
assert!(msg.contains("相同状态"), "同态消息应含「相同状态」,实际: {msg}");
|
||||
}
|
||||
other => panic!("同态拒绝应是 Validation,实际: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_target_rejected() {
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("t1", "todo")).await.unwrap();
|
||||
let err = advance_task_atomic(&repo, "t1", "merged").await.unwrap_err();
|
||||
assert!(matches!(err, df_types::error::Error::Validation(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn not_found_rejected() {
|
||||
let repo = setup().await;
|
||||
let err = advance_task_atomic(&repo, "nope", "in_progress").await.unwrap_err();
|
||||
assert!(matches!(err, df_types::error::Error::NotFound(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn advance_succeeds_after_prior_status_change() {
|
||||
// 前序旁路改 status 后,推进链基于「当前」status 判定合法转换并正常写入。
|
||||
// 注:这并非 CAS 并发失败场景(真 CAS 失败由 cas_returns_none_when_status_mismatch 覆盖),
|
||||
// 而是验证读后改路径在 from=当前库内 status 时正常推进。
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("t1", "todo")).await.unwrap();
|
||||
// 另一路推进把 status 改成 in_progress(模拟并发推进,走 CAS 合法路径;
|
||||
// F-03 收口后 status 不在 update_field 白名单,模拟并发改态须走 advance_status_atomic)
|
||||
repo.advance_status_atomic("t1", "todo", "in_progress", false)
|
||||
.await
|
||||
.unwrap();
|
||||
// 读出来是 in_progress,推进到 in_review 合法 → 正常成功
|
||||
let r = advance_task_atomic(&repo, "t1", "in_review").await.unwrap();
|
||||
assert_eq!(r.status, "in_review");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cas_returns_none_when_status_mismatch() {
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("t1", "todo")).await.unwrap();
|
||||
// 直接调底层:expected 传错(模拟读到 todo 但实际已被改成 in_progress)
|
||||
let r = repo
|
||||
.advance_status_atomic("t1", "todo", "in_review", false)
|
||||
.await
|
||||
.unwrap();
|
||||
// 此时 status 确实是 todo,CAS 成功,返回 Some
|
||||
assert!(r.is_some());
|
||||
// 再用错误的 expected todo(实际已是 in_review)→ CAS 失败 None
|
||||
let r = repo
|
||||
.advance_status_atomic("t1", "todo", "done", false)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(r.is_none(), "expected 与库内不符应 CAS 失败");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn blocked_round_trip_does_not_bump() {
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("t1", "in_progress")).await.unwrap();
|
||||
let r = advance_task_atomic(&repo, "t1", "blocked").await.unwrap();
|
||||
assert_eq!(r.status, "blocked");
|
||||
assert_eq!(r.review_rounds, 0, "进 blocked 不累加");
|
||||
let r = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap();
|
||||
assert_eq!(r.status, "in_progress");
|
||||
assert_eq!(r.review_rounds, 0, "解除 blocked 不累加");
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CR-06 观察项 ② 测试补强 — ②-3(completed)/②-4(failed) 回调推进覆盖
|
||||
//
|
||||
// workflow.rs 的 spawn 闭包回调依赖三段纯计算:
|
||||
// 1. template_for(target) 选模板(已由 task_workflow_templates.rs 测试覆盖)
|
||||
// 2. regression_target(target) 推失败退回态(已收敛到 task_state_machine::regression_target
|
||||
// pub 契约,workflow.rs 与本测试均从此导入,单一可信源;映射表由 task_state_machine
|
||||
// 测试模块锁定)
|
||||
// 3. advance_task_atomic(repo, tid, to) 落库(本模块核心,下方覆盖各回调 to)
|
||||
//
|
||||
// workflow.rs spawn 闭包集成测试需 mock AppState/AppHandle/EventBus,df-nodes crate
|
||||
// 无此基础设施 → 降级为纯函数级 + 状态机集成级(零 mock,稳)。回调三段计算全覆盖,
|
||||
// 唯一未覆盖的是「闭包组装 + tracing」胶水代码(非业务逻辑)。
|
||||
// ============================================================
|
||||
|
||||
/// 镜像 workflow.rs spawn 闭包的回调决策(completed→target / failed→regression_target)。
|
||||
/// 抽出纯函数便于直接断言,与 workflow.rs 闭包内 match 逻辑一一对应。
|
||||
///
|
||||
/// regression_target 现已收敛为 df-nodes::task_state_machine::regression_target 的
|
||||
/// pub 契约(单一可信源),此处直接复用,不再镜像副本(防 DRY 漂移)。
|
||||
///
|
||||
/// 返回 Option<String>(非 &str):completed 分支回传入的 target_status(非 'static),
|
||||
/// failed 分支回 regression_target 的 &'static str 常量。统一 String 避免生命周期冲突。
|
||||
fn callback_advance_target(status: &str, target_status: &str) -> Option<String> {
|
||||
match status {
|
||||
"completed" => Some(target_status.to_string()),
|
||||
"failed" => crate::task_state_machine::regression_target(target_status)
|
||||
.map(String::from),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn callback_completed_advances_to_target() {
|
||||
// ②-3:工作流 completed → 推进到 target_status(无论 target 是哪个)
|
||||
assert_eq!(
|
||||
callback_advance_target("completed", "in_progress").as_deref(),
|
||||
Some("in_progress")
|
||||
);
|
||||
assert_eq!(
|
||||
callback_advance_target("completed", "testing").as_deref(),
|
||||
Some("testing")
|
||||
);
|
||||
assert_eq!(
|
||||
callback_advance_target("completed", "done").as_deref(),
|
||||
Some("done")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn callback_failed_advances_to_regression_target() {
|
||||
// ②-4:工作流 failed → 推进到 regression_target(target)
|
||||
assert_eq!(
|
||||
callback_advance_target("failed", "testing").as_deref(),
|
||||
Some("in_review")
|
||||
);
|
||||
assert_eq!(
|
||||
callback_advance_target("failed", "in_review").as_deref(),
|
||||
Some("in_progress")
|
||||
);
|
||||
// CR-13-O1-b: in_progress 失败不退回(状态机禁止→todo),回调返回 None 跳过推进
|
||||
assert_eq!(callback_advance_target("failed", "in_progress"), None);
|
||||
// failed + done:无退回映射 → None(回调跳过,workflow.rs 会 warn 跳过日志)
|
||||
assert_eq!(callback_advance_target("failed", "done"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn callback_non_terminal_status_skips() {
|
||||
// 工作流非终态(running/cancelled 等)不应触发回调 — workflow.rs match 的 _ 分支。
|
||||
// (cancelled 在 workflow.rs 实际归入 failed 路径因 status 字段取值,这里锁定
|
||||
// 唯一终态语义:仅 "completed"/"failed" 两态触发推进。)
|
||||
for non_terminal in ["running", "pending", ""] {
|
||||
assert_eq!(
|
||||
callback_advance_target(non_terminal, "in_progress"),
|
||||
None,
|
||||
"非终态 {non_terminal:?} 不应触发回调推进"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// ②-3 回调落库验证:completed → advance_task_atomic(target) 成功推进 + 不累加 rounds。
|
||||
/// 覆盖三个 target 的前向推进(in_progress/testing/done)从各自合法 from 态触发。
|
||||
#[tokio::test]
|
||||
async fn callback_completed_advance_lands_in_db() {
|
||||
// in_progress: todo → in_progress(②-3 in_progress 模板完成)
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("c1", "todo")).await.unwrap();
|
||||
let to = callback_advance_target("completed", "in_progress").unwrap();
|
||||
let r = advance_task_atomic(&repo, "c1", &to).await.unwrap();
|
||||
assert_eq!(r.status, "in_progress");
|
||||
assert_eq!(r.review_rounds, 0, "②-3 前向推进不累加 review_rounds");
|
||||
|
||||
// testing: in_review → testing(②-3 testing 模板自审+核对通过)
|
||||
repo.insert(rec("c2", "in_review")).await.unwrap();
|
||||
let to = callback_advance_target("completed", "testing").unwrap();
|
||||
let r = advance_task_atomic(&repo, "c2", &to).await.unwrap();
|
||||
assert_eq!(r.status, "testing");
|
||||
assert_eq!(r.review_rounds, 0);
|
||||
|
||||
// done: testing → done(②-3 done 模板最终核对通过)
|
||||
repo.insert(rec("c3", "testing")).await.unwrap();
|
||||
let to = callback_advance_target("completed", "done").unwrap();
|
||||
let r = advance_task_atomic(&repo, "c3", &to).await.unwrap();
|
||||
assert_eq!(r.status, "done");
|
||||
assert_eq!(r.review_rounds, 0);
|
||||
}
|
||||
|
||||
/// ②-4 回调落库验证:failed → advance_task_atomic(regression_target) 退回 + 累加 rounds。
|
||||
/// 关键差异:②-4 退回转换是 regression,review_rounds += 1(与 ②-3 前向推进 0 增量对比)。
|
||||
#[tokio::test]
|
||||
async fn callback_failed_regression_lands_in_db_with_rounds_bump() {
|
||||
// testing 模板失败:任务当前 testing → 退回 in_review(rounds+1)
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("f1", "testing")).await.unwrap();
|
||||
let to = callback_advance_target("failed", "testing").unwrap();
|
||||
assert_eq!(to, "in_review", "testing 失败应退回 in_review");
|
||||
let r = advance_task_atomic(&repo, "f1", &to).await.unwrap();
|
||||
assert_eq!(r.status, "in_review");
|
||||
assert_eq!(r.review_rounds, 1, "②-4 退回应累加 review_rounds(+1)");
|
||||
|
||||
// in_review 模板失败(注:in_review 非 callback target,但映射存在性仍锁定):
|
||||
// in_review → in_progress。此处验证 regression_target 对 in_review 的映射,
|
||||
// 即便当前推进链 testing 模板失败也可能退到 in_progress(链式退回)。
|
||||
repo.insert(rec("f2", "in_review")).await.unwrap();
|
||||
let to = callback_advance_target("failed", "in_review").unwrap();
|
||||
assert_eq!(to, "in_progress");
|
||||
let r = advance_task_atomic(&repo, "f2", &to).await.unwrap();
|
||||
assert_eq!(r.status, "in_progress");
|
||||
assert_eq!(r.review_rounds, 1);
|
||||
|
||||
// in_progress 模板失败:regression_target("in_progress")=None(CR-13-O1-b),
|
||||
// 回调返回 None → 跳过推进,任务保留 in_progress 等人介入。
|
||||
// 验证:callback 返回 None,不调 advance_task_atomic。
|
||||
repo.insert(rec("f3", "in_progress")).await.unwrap();
|
||||
let to = callback_advance_target("failed", "in_progress");
|
||||
assert_eq!(to, None, "in_progress 失败应跳过推进(None),实际: {to:?}");
|
||||
// 任务状态未被改动(仍是 in_progress)
|
||||
let still = repo.get_by_id("f3").await.unwrap().unwrap();
|
||||
assert_eq!(still.status, "in_progress", "None 退回不应改动 status");
|
||||
}
|
||||
|
||||
/// ②-4 回调 failed+done 无退回映射验证:callback_advance_target 返回 None。
|
||||
/// workflow.rs 此分支仅 tracing::warn! 跳过,不调 advance_task_atomic。
|
||||
/// 锁定:done 失败不会推进任务(终态前向目标失败,无可退态)。
|
||||
#[tokio::test]
|
||||
async fn callback_failed_done_skips_advance() {
|
||||
let to = callback_advance_target("failed", "done");
|
||||
assert_eq!(to, None, "done 失败无退回映射 → 回调跳过(None)");
|
||||
// 不调 advance_task_atomic(workflow.rs L294-302 的 else 分支)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
//! 任务推进状态机 — 7 态合法转换定义(F-260616-01)
|
||||
//!
|
||||
//! 独立模块,非挂在 df-types::TaskStatus enum 上(对齐 D-260616-03「推进链业务逻辑落
|
||||
//! df-nodes」)。本模块只做「给定 from/to 是否合法」的纯函数判定,不触碰存储层
|
||||
//! (原子写 SQL 在 task_advance_node.rs 完成,见 F-260616-02)。
|
||||
//!
|
||||
//! 7 态(与 df-types::TaskStatus / 前端对齐,D-260616-01):
|
||||
//! todo / in_progress / in_review / testing / done / blocked / cancelled
|
||||
//!
|
||||
//! 闸门链主路径: todo → in_progress → in_review → testing → done
|
||||
//!
|
||||
//! 合法转换矩阵(以报告 docs/05-代码审查/任务执行与推进能力分析-2026-06-16.md §8
|
||||
//! 与决策 docs/02-架构设计/专项设计/任务推进链实施路径-2026-06-16.md F-01 为准):
|
||||
//! - todo → in_progress(开始), cancelled(取消)
|
||||
//! - in_progress → in_review(提交审查), blocked(阻塞), cancelled
|
||||
//! - in_review → testing(审查通过进测试), in_progress(退回修改, review_rounds+1),
|
||||
//! blocked, cancelled
|
||||
//! - testing → done(测试通过), in_review(退回重审, review_rounds+1),
|
||||
//! blocked, cancelled
|
||||
//! - done → 终态(原则上不可变;若需重开走 cancelled 或新任务)
|
||||
//! - blocked → in_progress(解除阻塞继续), cancelled
|
||||
//! - cancelled → 终态
|
||||
|
||||
// ============================================================
|
||||
// 状态字符串常量 — 与 df-types::TaskStatus::as_str 一一对应
|
||||
// ============================================================
|
||||
//
|
||||
// 不复用 df-types::TaskStatus enum(独立模块定位 + 避免推进链判定耦合存储枚举类型),
|
||||
// 但字符串值严格对齐(df-types::TaskStatus::as_str 产出的小写 snake_case),
|
||||
// 保证状态机判定的 from/to 与数据库 status 列存值语义一致。
|
||||
|
||||
/// 待开始
|
||||
pub const TODO: &str = "todo";
|
||||
/// 进行中
|
||||
pub const IN_PROGRESS: &str = "in_progress";
|
||||
/// 代码审查中
|
||||
pub const IN_REVIEW: &str = "in_review";
|
||||
/// 测试中
|
||||
pub const TESTING: &str = "testing";
|
||||
/// 已完成(终态)
|
||||
pub const DONE: &str = "done";
|
||||
/// 已阻塞
|
||||
pub const BLOCKED: &str = "blocked";
|
||||
/// 已取消(终态)
|
||||
pub const CANCELLED: &str = "cancelled";
|
||||
|
||||
/// 全部合法状态值(供输入校验与错误提示复用)
|
||||
pub const ALL_STATES: &[&str] = &[
|
||||
TODO,
|
||||
IN_PROGRESS,
|
||||
IN_REVIEW,
|
||||
TESTING,
|
||||
DONE,
|
||||
BLOCKED,
|
||||
CANCELLED,
|
||||
];
|
||||
|
||||
/// 字符串是否为合法状态值
|
||||
pub fn is_valid_state(s: &str) -> bool {
|
||||
ALL_STATES.contains(&s)
|
||||
}
|
||||
|
||||
/// 判定从 `from` 到 `to` 的状态转换是否合法(状态机核心)。
|
||||
///
|
||||
/// 终态(done/cancelled)无任何合法后继;非法或未知状态入参一律返回 false
|
||||
/// (调用方 advance_task 在前置校验已拦截非法 status,此处保守拒绝防漏)。
|
||||
///
|
||||
/// 注意:仅判「是否合法」,不判「是否是退回」——退回(导致 review_rounds+1)
|
||||
/// 的识别见 [`is_regression`],advance_task 据此决定是否一并 SET review_rounds+=1。
|
||||
pub fn can_transition(from: &str, to: &str) -> bool {
|
||||
use std::sync::OnceLock;
|
||||
|
||||
// 转换表静态构造一次(运行时常量,无锁开销分摊)。行=from,列=to。
|
||||
// 表条目为 true 即合法转换。未列入的 (from, to) 一律 false。
|
||||
static TABLE: OnceLock<std::collections::HashMap<(&'static str, &'static str), bool>> =
|
||||
OnceLock::new();
|
||||
let table = TABLE.get_or_init(|| {
|
||||
let mut m = std::collections::HashMap::new();
|
||||
let allowed: &[(&str, &str)] = &[
|
||||
// todo → in_progress(开始), cancelled(取消)
|
||||
(TODO, IN_PROGRESS),
|
||||
(TODO, CANCELLED),
|
||||
// in_progress → in_review(提交审查), blocked(阻塞), cancelled
|
||||
(IN_PROGRESS, IN_REVIEW),
|
||||
(IN_PROGRESS, BLOCKED),
|
||||
(IN_PROGRESS, CANCELLED),
|
||||
// in_review → testing(审查通过进测试), in_progress(退回修改),
|
||||
// blocked, cancelled
|
||||
(IN_REVIEW, TESTING),
|
||||
(IN_REVIEW, IN_PROGRESS),
|
||||
(IN_REVIEW, BLOCKED),
|
||||
(IN_REVIEW, CANCELLED),
|
||||
// testing → done(测试通过), in_review(退回重审), blocked, cancelled
|
||||
(TESTING, DONE),
|
||||
(TESTING, IN_REVIEW),
|
||||
(TESTING, BLOCKED),
|
||||
(TESTING, CANCELLED),
|
||||
// done → 终态,无后继
|
||||
// blocked → in_progress(解除阻塞继续), cancelled
|
||||
(BLOCKED, IN_PROGRESS),
|
||||
(BLOCKED, CANCELLED),
|
||||
// cancelled → 终态,无后继
|
||||
];
|
||||
for (f, t) in allowed {
|
||||
m.insert((*f, *t), true);
|
||||
}
|
||||
m
|
||||
});
|
||||
|
||||
table.get(&(from, to)).copied().unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 判定一次转换是否为「退回」(review_rounds 应 +1)。
|
||||
///
|
||||
/// 退回语义:任务从前向推进阶段回退到更早的推进阶段,意味着上一轮产出未过闸门、
|
||||
/// 需重做。具体两类:
|
||||
/// - in_review → in_progress(审查退回修改)
|
||||
/// - testing → in_review(测试退回重审)
|
||||
///
|
||||
/// 其余合法转换(前向推进 / 进出 blocked / 进 cancelled)均不累加 review_rounds。
|
||||
pub fn is_regression(from: &str, to: &str) -> bool {
|
||||
matches!((from, to), (IN_REVIEW, IN_PROGRESS) | (TESTING, IN_REVIEW))
|
||||
}
|
||||
|
||||
/// 工作流联动任务「失败退一步」的目标态映射(F-260616-06 ②-4)。
|
||||
///
|
||||
/// 工作流失败时,任务不应停留在失败前向目标态,需回退到上一闸门重做。映射表:
|
||||
/// - testing → in_review(测试失败退回重审)
|
||||
/// - in_review → in_progress(审查失败退回修改)
|
||||
/// - in_progress → None(状态机禁止 in_progress→todo,见 [`can_transition`] 的
|
||||
/// backward_to_todo_rejected;失败退回 todo 会被 advance_task_atomic
|
||||
/// 的 InvalidState 拦截,任务原地保留 in_progress 无信号。改为 None
|
||||
/// 则回调跳过推进,留 in_progress 等人介入 — 决策 CR-13-O1-b)
|
||||
/// - 其他(done/blocked/cancelled/todo 等非推进链前向目标) → None(无退回映射)
|
||||
///
|
||||
/// 设计归属:此映射属状态机业务契约(失败退一步的目标态),居 df-nodes 与
|
||||
/// [`can_transition`] / [`is_regression`] 同位,统一管理推进链的状态语义。
|
||||
/// 此前该函数在 workflow.rs(app crate 私有) 与 task_advance_node.rs(测试镜像)
|
||||
/// 各存一份(DRY 漂移),现收敛为单一可信源,调用方均从此导入。
|
||||
pub fn regression_target(target: &str) -> Option<&'static str> {
|
||||
match target {
|
||||
TESTING => Some(IN_REVIEW),
|
||||
IN_REVIEW => Some(IN_PROGRESS),
|
||||
// in_progress 失败不自动退回(状态机不允许→todo),留 in_progress 等人介入
|
||||
IN_PROGRESS => None,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 单元测试 — 转换矩阵 + 边界
|
||||
// ============================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ---------- can_transition 闸门主路径 ----------
|
||||
|
||||
#[test]
|
||||
fn main_path_todo_to_done_all_forward() {
|
||||
assert!(can_transition(TODO, IN_PROGRESS));
|
||||
assert!(can_transition(IN_PROGRESS, IN_REVIEW));
|
||||
assert!(can_transition(IN_REVIEW, TESTING));
|
||||
assert!(can_transition(TESTING, DONE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_from_any_non_terminal() {
|
||||
for s in [TODO, IN_PROGRESS, IN_REVIEW, TESTING, BLOCKED] {
|
||||
assert!(can_transition(s, CANCELLED), "应允许 {s} → cancelled");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocked_round_trip() {
|
||||
assert!(can_transition(IN_PROGRESS, BLOCKED));
|
||||
assert!(can_transition(BLOCKED, IN_PROGRESS));
|
||||
assert!(can_transition(IN_REVIEW, BLOCKED));
|
||||
assert!(can_transition(TESTING, BLOCKED));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regression_paths_allowed() {
|
||||
assert!(can_transition(IN_REVIEW, IN_PROGRESS));
|
||||
assert!(can_transition(TESTING, IN_REVIEW));
|
||||
}
|
||||
|
||||
// ---------- 非法转换被拒 ----------
|
||||
|
||||
#[test]
|
||||
fn illegal_forward_skips_rejected() {
|
||||
// 不允许跳过闸门(直奔 done)
|
||||
assert!(!can_transition(TODO, DONE));
|
||||
assert!(!can_transition(TODO, TESTING));
|
||||
assert!(!can_transition(TODO, IN_REVIEW));
|
||||
assert!(!can_transition(IN_PROGRESS, DONE));
|
||||
assert!(!can_transition(IN_PROGRESS, TESTING));
|
||||
assert!(!can_transition(IN_REVIEW, DONE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_states_have_no_successors() {
|
||||
// done / cancelled 是终态,任何后继都拒绝
|
||||
for term in [DONE, CANCELLED] {
|
||||
for to in ALL_STATES {
|
||||
assert!(!can_transition(term, to), "终态 {term} 不应有后继 → {to}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backward_to_todo_rejected() {
|
||||
// 不允许回退到 todo(开始即不可撤回)
|
||||
for s in [IN_PROGRESS, IN_REVIEW, TESTING, BLOCKED] {
|
||||
assert!(!can_transition(s, TODO), "不应允许 {s} → todo");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocked_only_to_in_progress_or_cancelled() {
|
||||
assert!(can_transition(BLOCKED, IN_PROGRESS));
|
||||
assert!(can_transition(BLOCKED, CANCELLED));
|
||||
// 解除阻塞不能直接跳到 in_review/testing/done
|
||||
assert!(!can_transition(BLOCKED, IN_REVIEW));
|
||||
assert!(!can_transition(BLOCKED, TESTING));
|
||||
assert!(!can_transition(BLOCKED, DONE));
|
||||
}
|
||||
|
||||
// ---------- 未知状态 ----------
|
||||
|
||||
#[test]
|
||||
fn unknown_states_rejected() {
|
||||
assert!(!can_transition("unknown", TODO));
|
||||
assert!(!can_transition(TODO, "unknown"));
|
||||
assert!(!can_transition("", ""));
|
||||
}
|
||||
|
||||
// ---------- is_regression ----------
|
||||
|
||||
#[test]
|
||||
fn regression_only_on_review_back_edges() {
|
||||
assert!(is_regression(IN_REVIEW, IN_PROGRESS));
|
||||
assert!(is_regression(TESTING, IN_REVIEW));
|
||||
// 前向推进不累加
|
||||
assert!(!is_regression(TODO, IN_PROGRESS));
|
||||
assert!(!is_regression(IN_PROGRESS, IN_REVIEW));
|
||||
assert!(!is_regression(IN_REVIEW, TESTING));
|
||||
assert!(!is_regression(TESTING, DONE));
|
||||
// 进出 blocked 不累加
|
||||
assert!(!is_regression(IN_PROGRESS, BLOCKED));
|
||||
assert!(!is_regression(BLOCKED, IN_PROGRESS));
|
||||
// 进 cancelled 不累加
|
||||
assert!(!is_regression(IN_REVIEW, CANCELLED));
|
||||
// 不合法的"退回"(实际上不存在的转换)也不应判定为 regression
|
||||
assert!(!is_regression(TESTING, IN_PROGRESS));
|
||||
}
|
||||
|
||||
// ---------- regression_target(失败退一步映射) ----------
|
||||
|
||||
#[test]
|
||||
fn regression_target_mapping_matches_contract() {
|
||||
// 锁定 ②-4 失败退回映射表(逐项)
|
||||
assert_eq!(regression_target(TESTING), Some(IN_REVIEW));
|
||||
assert_eq!(regression_target(IN_REVIEW), Some(IN_PROGRESS));
|
||||
// CR-13-O1-b: in_progress 失败不退回(状态机禁止→todo),留 in_progress 等人介入
|
||||
assert_eq!(regression_target(IN_PROGRESS), None);
|
||||
// done 是终态前向目标,失败无可退态 → None
|
||||
assert_eq!(regression_target(DONE), None);
|
||||
// todo 是起点态无可退;blocked/cancelled 非推进链目标 → None
|
||||
assert_eq!(regression_target(TODO), None);
|
||||
assert_eq!(regression_target(BLOCKED), None);
|
||||
assert_eq!(regression_target(CANCELLED), None);
|
||||
// 未知态防御性 → None
|
||||
assert_eq!(regression_target("merged"), None);
|
||||
assert_eq!(regression_target(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regression_target_returned_state_is_valid_constant() {
|
||||
// 防漂移的真正增量(上例已逐项锁值,本例锁性质):
|
||||
// 任何非 None 返回值都必须是 ALL_STATES 内的合法状态常量。
|
||||
// 一旦 regression_target 误返回非状态字符串(拼写漂移 / 拼了历史态如 "merged"),
|
||||
// 该字符串过不了 is_valid_state,本测试立即失败定位。
|
||||
// 覆盖全部 7 态入参,不依赖上例已锁定的具体期望值。
|
||||
for target in ALL_STATES {
|
||||
match regression_target(target) {
|
||||
Some(ret) => assert!(
|
||||
is_valid_state(ret),
|
||||
"regression_target({target:?}) 返回 {ret:?} 不是合法状态常量(ALL_STATES 漂移)"
|
||||
),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- is_valid_state ----------
|
||||
|
||||
#[test]
|
||||
fn is_valid_state_accepts_7_known() {
|
||||
for s in ALL_STATES {
|
||||
assert!(is_valid_state(s));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_valid_state_rejects_unknown() {
|
||||
assert!(!is_valid_state(""));
|
||||
assert!(!is_valid_state("unknown"));
|
||||
assert!(!is_valid_state("TODO")); // 大写
|
||||
assert!(!is_valid_state("merged")); // 历史 5 态残留
|
||||
assert!(!is_valid_state("abandoned"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_states_has_seven_entries() {
|
||||
assert_eq!(ALL_STATES.len(), 7);
|
||||
}
|
||||
|
||||
// ---------- 双源一致性校验 ----------
|
||||
//
|
||||
// task_state_machine 维护一份独立的 7 态字符串常量集(本模块 ALL_STATES / TODO / ...),
|
||||
// 与 df-types::TaskStatus::as_str / valid_values() 同语义但不复用枚举(独立模块定位,
|
||||
// 推进链判定不耦合存储枚举)。两源无编译期绑定,若任一处改拼写或增删状态值,
|
||||
// 状态机判定会与数据库 status 列存值静默脱节(按常量判合法但落库值对不上)。
|
||||
//
|
||||
// 此处锁定「值必须严格一致」契约:ALL_STATES 必须与 df-types::TaskStatus::valid_values()
|
||||
// 逐项相等(顺序与值)。df-nodes 单向依赖 df-types,无环依赖风险。一旦未来某方改动
|
||||
// 导致两源漂移,本测试立即失败并精确定位,避免运行时静默脱节。
|
||||
|
||||
#[test]
|
||||
fn all_states_matches_df_types_valid_values() {
|
||||
use df_types::types::TaskStatus;
|
||||
let expected: &[&str] = TaskStatus::valid_values();
|
||||
// 逐项比对(顺序敏感):位置 i 的值必须与 df-types 一致,失败时报具体索引便于定位
|
||||
assert_eq!(
|
||||
ALL_STATES.len(),
|
||||
expected.len(),
|
||||
"ALL_STATES 与 TaskStatus::valid_values 长度不一致(状态值数量漂移)"
|
||||
);
|
||||
for (i, (got, want)) in ALL_STATES.iter().zip(expected.iter()).enumerate() {
|
||||
assert_eq!(
|
||||
got, want,
|
||||
"ALL_STATES[{i}] = {got:?} 与 TaskStatus::valid_values()[{i}] = {want:?} 不一致(状态值漂移)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_state_constant_matches_task_status_as_str() {
|
||||
// 逐变体锁定:常量值必须与对应 TaskStatus 变体的 as_str 产出严格相等。
|
||||
// 比上面整表比对更精细,单点漂移能直接指向具体常量行,加速定位。
|
||||
use df_types::types::TaskStatus;
|
||||
assert_eq!(TODO, TaskStatus::Todo.as_str());
|
||||
assert_eq!(IN_PROGRESS, TaskStatus::InProgress.as_str());
|
||||
assert_eq!(IN_REVIEW, TaskStatus::InReview.as_str());
|
||||
assert_eq!(TESTING, TaskStatus::Testing.as_str());
|
||||
assert_eq!(DONE, TaskStatus::Done.as_str());
|
||||
assert_eq!(BLOCKED, TaskStatus::Blocked.as_str());
|
||||
assert_eq!(CANCELLED, TaskStatus::Cancelled.as_str());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
//! 任务推进链 DAG 模板(F-260616-06 阶段2 / D-260616-03)
|
||||
//!
|
||||
//! 推进链前向转换的工作流拓扑描述(声明式,纯数据)。DagDef 只描述节点与边,
|
||||
//! 执行逻辑靠 DagExecutor 驱动各 Node trait 的 execute —— 模板本身不跑逻辑。
|
||||
//!
|
||||
//! 三模板对应推进链三个前向转换点:
|
||||
//! - "in_progress": todo→in_progress,单 AiNode 执行任务
|
||||
//! - "testing": in_review→testing,AiNode 自审 → HumanNode 核对(human reject 走 Err
|
||||
//! 触发工作流 failed → ②-4 回调退回 in_review)
|
||||
//! - "done": testing→done,单 HumanNode 最终核对
|
||||
//!
|
||||
//! 不建 workflow_defs 表(模板少且稳定,硬编码;tasks.workflow_def_id 留 None)。
|
||||
//!
|
||||
//! config 设计:节点级 config 不硬编码 task_id / provider 等运行时参数 —— 全部留空。
|
||||
//! 运行时 run_workflow(task_id, target_status) 调用方注入全局 config(task_id /
|
||||
//! target_status / provider 配置),由 ④-1 deep_merge 与节点级 config 合并
|
||||
//! (节点级覆盖全局)。模板只描述拓扑,不绑定具体任务。
|
||||
|
||||
use df_workflow::dag_def::DagDef;
|
||||
|
||||
/// 按目标状态返回推进链工作流模板。
|
||||
///
|
||||
/// `target_status` 取推进链前向目标状态(in_progress / testing / done)。
|
||||
/// 未匹配返回 None —— 调用方降级(如直接 IPC 推进,不启工作流),不 panic。
|
||||
///
|
||||
/// 返回的 DagDef 节点级 config 为空对象或仅含拓扑级元信息(label 等),
|
||||
/// 运行时参数由调用方全局 config 注入 + deep_merge。
|
||||
pub fn template_for(target_status: &str) -> Option<DagDef> {
|
||||
match target_status {
|
||||
"in_progress" => Some(in_progress_template()),
|
||||
"testing" => Some(testing_template()),
|
||||
"done" => Some(done_template()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// todo → in_progress:单 AiNode 执行任务。
|
||||
///
|
||||
/// 拓扑:1 个 ai 节点。完成回调(②-3)推进 status 到 in_progress。
|
||||
fn in_progress_template() -> DagDef {
|
||||
let mut dag = DagDef::new();
|
||||
// config 留空:prompt / provider / task_id 均由 run_workflow 全局 config 注入
|
||||
// (④-1 deep_merge 合并),模板只描述拓扑。
|
||||
dag.add_node(
|
||||
"ai_execute",
|
||||
"ai",
|
||||
serde_json::json!({}),
|
||||
);
|
||||
dag
|
||||
}
|
||||
|
||||
/// in_review → testing:AiNode 自审 → HumanNode 核对。
|
||||
///
|
||||
/// 拓扑:ai → human 串行。阶段3(本批)起 ai_self_review 启用 gate:true:
|
||||
/// - verdict=fail → AiSelfReviewNode 返回 Err → 工作流 failed(不经 human_review)
|
||||
/// → ②-4 失败回调退回 in_review(review_rounds+=1)。
|
||||
/// - verdict=unknown/pass → 放行 human_review,人定最终是否推进。
|
||||
/// HumanNode reject 走 Err(②-5 已做)→ 工作流 failed → ②-4 失败回调退回 in_review。
|
||||
/// 通过则完成回调(②-3)推进 status 到 testing。
|
||||
fn testing_template() -> DagDef {
|
||||
let mut dag = DagDef::new();
|
||||
// 决策 a 步骤③:ai_self_review 节点类型对齐 state.rs 注册的独立自审节点
|
||||
// (四维度 prompt + JSON 解析兜底 + 写回 output_json 加 review 子字段)。
|
||||
// 阶段3:gate=true 启用自审闸门(verdict=fail 阻断下游,工作流 failed)。
|
||||
dag.add_node(
|
||||
"ai_self_review",
|
||||
"ai_self_review",
|
||||
serde_json::json!({ "gate": true }),
|
||||
);
|
||||
dag.add_node(
|
||||
"human_review",
|
||||
"human",
|
||||
serde_json::json!({
|
||||
// 拓扑级元信息(非运行时参数):审批卡片默认文案。options 含「拒绝」
|
||||
// 触发 ②-5 reject → Err → 工作流 failed。
|
||||
"title": "核对 AI 自审结果",
|
||||
"options": ["同意", "拒绝"],
|
||||
}),
|
||||
);
|
||||
dag.add_edge("ai_self_review", "human_review");
|
||||
dag
|
||||
}
|
||||
|
||||
/// testing → done:单 HumanNode 最终核对。
|
||||
///
|
||||
/// 拓扑:1 个 human 节点。通过则完成回调(②-3)推进 status 到 done。
|
||||
/// reject 同理走 Err → failed → ②-4 退回。
|
||||
fn done_template() -> DagDef {
|
||||
let mut dag = DagDef::new();
|
||||
dag.add_node(
|
||||
"human_final_review",
|
||||
"human",
|
||||
serde_json::json!({
|
||||
"title": "最终核对",
|
||||
"options": ["同意", "拒绝"],
|
||||
}),
|
||||
);
|
||||
dag
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 单元测试 — 模板拓扑断言
|
||||
// ============================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn unknown_status_returns_none() {
|
||||
assert!(template_for("unknown").is_none());
|
||||
assert!(template_for("").is_none());
|
||||
assert!(template_for("merged").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_progress_template_single_ai_node() {
|
||||
let dag = template_for("in_progress").expect("in_progress 模板存在");
|
||||
assert_eq!(dag.nodes.len(), 1, "in_progress: 单节点");
|
||||
assert!(dag.edges.is_empty(), "in_progress: 无边");
|
||||
let node = dag.nodes.get("ai_execute").expect("ai_execute 节点存在");
|
||||
assert_eq!(node.node_type, "ai");
|
||||
// config 留空(运行时注入)
|
||||
assert!(
|
||||
node.config.as_object().map(|o| o.is_empty()).unwrap_or(true),
|
||||
"ai_execute config 应为空对象"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn testing_template_ai_to_human_serial() {
|
||||
let dag = template_for("testing").expect("testing 模板存在");
|
||||
assert_eq!(dag.nodes.len(), 2, "testing: ai + human 两节点");
|
||||
assert_eq!(dag.edges.len(), 1, "testing: 单串行边");
|
||||
// 节点类型(决策 a 步骤③:ai_self_review 独立节点类型)
|
||||
let ai = dag.nodes.get("ai_self_review").expect("ai_self_review 存在");
|
||||
assert_eq!(ai.node_type, "ai_self_review");
|
||||
// 阶段3:gate=true 启用自审闸门(verdict=fail 阻断下游)
|
||||
let gate = ai
|
||||
.config
|
||||
.get("gate")
|
||||
.and_then(|v| v.as_bool())
|
||||
.expect("ai_self_review config 应含 gate");
|
||||
assert!(gate, "testing 模板 ai_self_review 应启用 gate:true(阶段3 闸门)");
|
||||
let human = dag.nodes.get("human_review").expect("human_review 存在");
|
||||
assert_eq!(human.node_type, "human");
|
||||
// 边方向:ai → human
|
||||
let edge = &dag.edges[0];
|
||||
assert_eq!(edge.source, "ai_self_review");
|
||||
assert_eq!(edge.target, "human_review");
|
||||
// human options 含「拒绝」(触发 ②-5 reject)
|
||||
let options = human
|
||||
.config
|
||||
.get("options")
|
||||
.and_then(|v| v.as_array())
|
||||
.expect("human_review options 存在");
|
||||
let opts: Vec<&str> = options.iter().filter_map(|v| v.as_str()).collect();
|
||||
assert!(opts.contains(&"拒绝"), "options 应含「拒绝」触发 reject");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn done_template_single_human_node() {
|
||||
let dag = template_for("done").expect("done 模板存在");
|
||||
assert_eq!(dag.nodes.len(), 1, "done: 单节点");
|
||||
assert!(dag.edges.is_empty(), "done: 无边");
|
||||
let node = dag.nodes.get("human_final_review").expect("human_final_review 存在");
|
||||
assert_eq!(node.node_type, "human");
|
||||
}
|
||||
|
||||
// ---------- ②-3 / ②-4 回调语义对齐(CR-06 观察项 ② 测试补强) ----------
|
||||
//
|
||||
// 工作流完成(②-3)/失败(②-4)回调在 src-tauri/src/commands/workflow.rs spawn 闭包里,
|
||||
// 回调的核心计算是:template_for 选模板 + regression_target(target) 推退回态 +
|
||||
// advance_task_atomic 落库。这里锁定 template_for 三模板对回调语义的约束,
|
||||
// 保证回调侧(workflow.rs)的 target_status 取值与模板存在性一致 ——
|
||||
// 即回调只会对 template_for 返回 Some 的三个 target 触发,其余 target 模板不存在、
|
||||
// 工作流压根不会启动,回调自然不触发。
|
||||
|
||||
/// 三模板对应的 target_status 集合(workflow.rs ②-3/②-4 回调仅对这些 target 触发)
|
||||
const CALLBACK_TARGETS: &[&str] = &["in_progress", "testing", "done"];
|
||||
|
||||
#[test]
|
||||
fn callback_targets_all_have_templates() {
|
||||
// ②-3/②-4 回调依赖 template_for(target) 非空 —— 凡是能进回调的 target 必有模板。
|
||||
// 若新增一个回调 target 但忘了加模板,这里会先红。
|
||||
for target in CALLBACK_TARGETS {
|
||||
assert!(
|
||||
template_for(target).is_some(),
|
||||
"回调 target {target:?} 必须有对应模板(template_for 非 None)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_callback_targets_have_no_template() {
|
||||
// 反向:非推进链 target(todo/in_review/blocked/cancelled/未知) 无模板,
|
||||
// 工作流不启动,②-3/②-4 回调不触发 —— 锁定回调边界。
|
||||
for non_target in ["todo", "in_review", "blocked", "cancelled", "merged", ""] {
|
||||
assert!(
|
||||
template_for(non_target).is_none(),
|
||||
"非推进链 target {non_target:?} 不应有模板(否则回调边界被扩大)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn testing_and_done_templates_have_reject_path() {
|
||||
// ②-4 失败回调依赖模板内 human reject 走 Err → 工作流 failed → regression_target。
|
||||
// testing(done 同理)模板的 human 节点 options 必须含「拒绝」,否则失败回调不可达。
|
||||
// (in_progress 模板只有 ai 节点,失败是 ai 异常,无 reject 路径 —— 单独验证。)
|
||||
for target in ["testing", "done"] {
|
||||
let dag = template_for(target).unwrap_or_else(|| panic!("{target} 模板应存在"));
|
||||
let human = dag.nodes.values().find(|n| n.node_type == "human").unwrap_or_else(|| {
|
||||
panic!("{target} 模板应含 human 节点(承载 reject → failed → ②-4 回调)")
|
||||
});
|
||||
let options = human
|
||||
.config
|
||||
.get("options")
|
||||
.and_then(|v| v.as_array())
|
||||
.unwrap_or_else(|| panic!("{target} human 节点 config 应有 options 数组"));
|
||||
let opts: Vec<&str> = options.iter().filter_map(|v| v.as_str()).collect();
|
||||
assert!(
|
||||
opts.contains(&"拒绝"),
|
||||
"{target} human options 应含「拒绝」(否则 ②-4 失败回调路径不可达)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: 实现宏
|
||||
};
|
||||
}
|
||||
@@ -4,7 +4,7 @@ version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
df-core = { path = "../df-core" }
|
||||
df-types = { path = "../df-types" }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
|
||||
@@ -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 scheduler;
|
||||
pub mod timeline;
|
||||
pub mod scan;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use df_core::types::{IdeaId, Priority, ProjectId, ProjectStatus};
|
||||
use df_types::types::{IdeaId, Priority, ProjectId, ProjectStatus};
|
||||
|
||||
/// 项目实体
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -43,13 +43,12 @@ pub struct CreateProjectInput {
|
||||
pub struct ProjectManager;
|
||||
|
||||
impl ProjectManager {
|
||||
/// 创建新项目
|
||||
///
|
||||
/// TODO: 接入存储层持久化
|
||||
/// 创建新项目 — 构造领域实体(不落库);持久化由调用方经 storage 层 ProjectRecord 映射完成
|
||||
/// (见 commands/idea.rs::promote_idea)。领域层不依赖 storage,保持分层。
|
||||
pub fn create(input: CreateProjectInput) -> Project {
|
||||
let now = chrono::Utc::now();
|
||||
Project {
|
||||
id: df_core::types::new_id(),
|
||||
id: df_types::types::new_id(),
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
status: ProjectStatus::Planning,
|
||||
@@ -72,13 +71,4 @@ impl ProjectManager {
|
||||
})
|
||||
}
|
||||
|
||||
/// 更新项目状态
|
||||
///
|
||||
/// TODO: 状态转换校验
|
||||
pub fn transition_status(project: &mut Project, new_status: ProjectStatus) -> anyhow::Result<()> {
|
||||
// TODO: 校验状态转换是否合法
|
||||
project.status = new_status;
|
||||
project.updated_at = chrono::Utc::now();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,741 @@
|
||||
//! 项目技术栈探测 — 浅读根目录标志文件识别技术栈
|
||||
//!
|
||||
//! 纯函数、零状态、零外部依赖(仅 std + serde_json + anyhow)。
|
||||
//! 只读根目录标志文件,不递归遍历(控制性能与安全)。
|
||||
//!
|
||||
//! 供 commands 层薄封装暴露为 IPC 命令,新建/导入项目时自动填充 `ProjectRecord.stack`;
|
||||
//! 未来 df-ai/df-workflow 亦可复用以感知「项目是什么技术栈」。
|
||||
//!
|
||||
//! README 噪音处理与图片收集已抽离至 `scan_helpers`(2026-06-19 自底向上拆分),
|
||||
//! 此处通过 `pub use` 重导出 `ImageRef` 等保持外部路径 `df_project::scan::*` 不变。
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
#[path = "scan_helpers.rs"]
|
||||
mod scan_helpers;
|
||||
pub use scan_helpers::ImageRef;
|
||||
use scan_helpers::{collect_images, strip_readme_noise};
|
||||
|
||||
/// 规范化路径用于比较:canonicalize 解析绝对规范路径(失败降级),
|
||||
/// 统一正斜杠 + 小写。防 `C:\a\b` vs `C:/a/b/` 绕过重复检查。
|
||||
/// 注:仅用于比较,存库保留用户输入的原始可读路径。
|
||||
pub fn normalize_path(p: &str) -> String {
|
||||
match Path::new(p).canonicalize() {
|
||||
Ok(abs) => abs.to_string_lossy().replace('\\', "/").to_lowercase(),
|
||||
Err(_) => p
|
||||
.trim_end_matches(['\\', '/'])
|
||||
.replace('\\', "/")
|
||||
.to_lowercase(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 探测目录的技术栈
|
||||
///
|
||||
/// 返回去重后的技术栈标签数组(如 `["rust","vue","tauri","typescript"]`)。
|
||||
/// 空数组表示未识别出任何已知标志(空目录或非常规项目)。非目录返回 Err。
|
||||
pub fn detect_stack(root: &Path) -> Result<Vec<String>> {
|
||||
if !root.is_dir() {
|
||||
anyhow::bail!("路径不是目录: {}", root.display());
|
||||
}
|
||||
let mut stack: Vec<String> = Vec::new();
|
||||
|
||||
// ── 后端/系统语言 ──
|
||||
if root.join("Cargo.toml").exists() {
|
||||
push_unique(&mut stack, "rust");
|
||||
}
|
||||
if root.join("go.mod").exists() {
|
||||
push_unique(&mut stack, "go");
|
||||
}
|
||||
if root.join("pom.xml").exists()
|
||||
|| root.join("build.gradle").exists()
|
||||
|| root.join("build.gradle.kts").exists()
|
||||
{
|
||||
push_unique(&mut stack, "java");
|
||||
}
|
||||
if root.join("pyproject.toml").exists() || root.join("requirements.txt").exists() {
|
||||
push_unique(&mut stack, "python");
|
||||
}
|
||||
// C#: 根目录存在 .csproj 文件(仅一层)
|
||||
if has_file_with_ext(root, "csproj") {
|
||||
push_unique(&mut stack, "csharp");
|
||||
}
|
||||
|
||||
// ── Tauri 桌面应用(通常含 src-tauri 目录) ──
|
||||
if root.join("src-tauri").is_dir() {
|
||||
push_unique(&mut stack, "tauri");
|
||||
}
|
||||
|
||||
// ── 前端/Node:解析 package.json 的依赖推断框架 ──
|
||||
if root.join("package.json").exists() {
|
||||
if let Ok(deps) = read_package_deps(root.join("package.json")) {
|
||||
if deps.iter().any(|d| d == "vue") {
|
||||
push_unique(&mut stack, "vue");
|
||||
}
|
||||
if deps.iter().any(|d| d == "react" || d == "react-dom") {
|
||||
push_unique(&mut stack, "react");
|
||||
}
|
||||
if deps.iter().any(|d| d == "@angular/core") {
|
||||
push_unique(&mut stack, "angular");
|
||||
}
|
||||
if deps.iter().any(|d| d == "svelte") {
|
||||
push_unique(&mut stack, "svelte");
|
||||
}
|
||||
if deps.iter().any(|d| d == "next") {
|
||||
push_unique(&mut stack, "next");
|
||||
}
|
||||
if deps.iter().any(|d| d == "vite") {
|
||||
push_unique(&mut stack, "vite");
|
||||
}
|
||||
if deps.iter().any(|d| d == "typescript") {
|
||||
push_unique(&mut stack, "typescript");
|
||||
}
|
||||
if deps.iter().any(|d| d == "express" || d == "koa" || d == "fastify") {
|
||||
push_unique(&mut stack, "node");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(stack)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 历史项目发现 — monorepo 识别 + 子项目展开(纯规则,不跑 LLM)
|
||||
// ============================================================
|
||||
|
||||
/// monorepo 工作区配置文件名(JS 生态主流:pnpm/lerna/turbo/nx)
|
||||
const MONOREPO_MARKERS: &[&str] = &[
|
||||
"pnpm-workspace.yaml",
|
||||
"lerna.json",
|
||||
"turbo.json",
|
||||
"nx.json",
|
||||
];
|
||||
|
||||
/// 判定目录是否为 monorepo 根(JS 生态主流工作区管理器)。
|
||||
///
|
||||
/// 命中任一即视为 monorepo:
|
||||
/// - pnpm-workspace.yaml / lerna.json / turbo.json / nx.json 存在
|
||||
/// - package.json 含 `workspaces` 字段(npm/yarn workspaces)
|
||||
pub fn is_monorepo(root: &Path) -> bool {
|
||||
if !root.is_dir() {
|
||||
return false;
|
||||
}
|
||||
for marker in MONOREPO_MARKERS {
|
||||
if root.join(marker).is_file() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// package.json workspaces 字段(npm/yarn)。数组(`["packages/*"]`)或对象
|
||||
// (`{"packages":[...]}`,Yarn)均为真正的工作区;JSON 显式 null 表示「无」,
|
||||
// 不应误判为 monorepo(`.is_some()` 对 key 存在但值为 null 仍返回 true → 误报)。
|
||||
let pkg_path = root.join("package.json");
|
||||
if pkg_path.is_file() {
|
||||
if let Ok(content) = std::fs::read_to_string(&pkg_path) {
|
||||
if let Ok(pkg) = serde_json::from_str::<serde_json::Value>(&content) {
|
||||
if pkg.get("workspaces").is_some_and(|v| !v.is_null()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 单个发现的候选项目(monorepo 子项目或独立项目)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiscoveredProject {
|
||||
/// 项目根目录绝对路径
|
||||
pub path: String,
|
||||
/// 推断的项目名(目录名)
|
||||
pub name: String,
|
||||
/// 规则探测的技术栈(空=未识别)
|
||||
pub stack: Vec<String>,
|
||||
/// 是否为 monorepo 根(便于前端标记)
|
||||
pub is_monorepo: bool,
|
||||
}
|
||||
|
||||
/// 在指定根目录下发现候选项目。
|
||||
///
|
||||
/// 策略(只展开一层,不做深递归):
|
||||
/// 1. 根目录本身有项目标志(Cargo.toml/package.json/go.mod 等)→ 根为独立项目
|
||||
/// 2. 根目录是 monorepo → 展开 packages/\*/apps/\* 直接子目录(各子目录跑 detect_stack 过滤空)
|
||||
/// 3. 否则:扫根的直接子目录,凡 detect_stack 非空的视为候选项目
|
||||
///
|
||||
/// 不跑 LLM(快),不读源码。空 stack 的目录在 monorepo 展开/子目录扫描时被过滤。
|
||||
pub fn discover_projects(root: &Path) -> Result<Vec<DiscoveredProject>> {
|
||||
if !root.is_dir() {
|
||||
anyhow::bail!("路径不是目录: {}", root.display());
|
||||
}
|
||||
|
||||
let mut out: Vec<DiscoveredProject> = Vec::new();
|
||||
let mono = is_monorepo(root);
|
||||
|
||||
// 1. 根目录自身是项目(有 manifest 标志)
|
||||
if has_project_manifest(root) {
|
||||
let stack = detect_stack(root).unwrap_or_default();
|
||||
out.push(DiscoveredProject {
|
||||
path: root.to_string_lossy().to_string(),
|
||||
name: root
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| root.to_string_lossy().to_string()),
|
||||
stack,
|
||||
is_monorepo: mono,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. monorepo → 展开 packages/* apps/* 直接子目录
|
||||
// 3. 普通目录 → 扫直接子目录,凡 detect_stack 非空的入选
|
||||
let scan_globs: &[&str] = if mono {
|
||||
&["packages", "apps"]
|
||||
} else {
|
||||
&[""]
|
||||
};
|
||||
|
||||
for glob in scan_globs {
|
||||
let target = if glob.is_empty() {
|
||||
root.to_path_buf()
|
||||
} else {
|
||||
root.join(glob)
|
||||
};
|
||||
if !target.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let Ok(entries) = std::fs::read_dir(&target) else {
|
||||
continue;
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
if !p.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let name = e.file_name().to_string_lossy().to_string();
|
||||
if SAMPLE_IGNORED_DIRS.contains(&name.as_str()) || name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
// 必须有项目标志 + detect_stack 非空
|
||||
if !has_project_manifest(&p) {
|
||||
continue;
|
||||
}
|
||||
let stack = match detect_stack(&p) {
|
||||
Ok(s) => s,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if stack.is_empty() {
|
||||
continue;
|
||||
}
|
||||
out.push(DiscoveredProject {
|
||||
path: p.to_string_lossy().to_string(),
|
||||
name,
|
||||
stack,
|
||||
is_monorepo: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// 目录是否含任一项目清单标志文件
|
||||
fn has_project_manifest(dir: &Path) -> bool {
|
||||
const MARKS: &[&str] = &[
|
||||
"Cargo.toml",
|
||||
"package.json",
|
||||
"go.mod",
|
||||
"pyproject.toml",
|
||||
"requirements.txt",
|
||||
"pom.xml",
|
||||
"build.gradle",
|
||||
"build.gradle.kts",
|
||||
];
|
||||
MARKS.iter().any(|m| dir.join(m).is_file()) || has_file_with_ext(dir, "csproj")
|
||||
}
|
||||
|
||||
/// 解析 package.json,合并 dependencies + devDependencies 的包名
|
||||
fn read_package_deps(path: impl AsRef<Path>) -> Result<Vec<String>> {
|
||||
let content = std::fs::read_to_string(path.as_ref())
|
||||
.with_context(|| format!("读取 package.json 失败: {}", path.as_ref().display()))?;
|
||||
let pkg: serde_json::Value = serde_json::from_str(&content).context("解析 package.json 失败")?;
|
||||
let mut names = Vec::new();
|
||||
for key in &["dependencies", "devDependencies"] {
|
||||
if let Some(obj) = pkg.get(key).and_then(|v| v.as_object()) {
|
||||
for k in obj.keys() {
|
||||
names.push(k.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
/// 目录下是否存在指定扩展名的文件(仅一层)
|
||||
fn has_file_with_ext(dir: &Path, ext: &str) -> bool {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return false;
|
||||
};
|
||||
entries.flatten().any(|e| {
|
||||
e.path()
|
||||
.extension()
|
||||
.and_then(|x| x.to_str())
|
||||
.map(|x| x == ext)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 项目采样(供 LLM 分析基础信息) — 纯 IO,控 token 不读源码
|
||||
// ============================================================
|
||||
|
||||
/// 提取项目描述 — 读 README 首段(首个非标题非空段落,截断 200 字)。
|
||||
///
|
||||
/// 用于「导入历史项目」时自动填充 description。无 README 或解析失败返回 None。
|
||||
/// 首段定义:跳过开头标题行(# / ## …)、空行、HTML 注释与 badge 图片/HTML 行等噪声,
|
||||
/// 取首个含实质文本的段落(连续多行直到空行);按字符截断至 200 字避免超长。
|
||||
pub fn extract_description(root: &Path) -> Option<String> {
|
||||
// 复用 read_readme_raw 的查找逻辑(支持 README.md / README.zh.md 等变体)
|
||||
let content = read_readme_raw(root)?;
|
||||
let mut text = String::new();
|
||||
let mut started = false;
|
||||
for raw_line in content.lines() {
|
||||
let line = raw_line.trim();
|
||||
if line.is_empty() {
|
||||
if started {
|
||||
break; // 段落结束
|
||||
}
|
||||
continue; // 首段尚未开始,跳过开头空行
|
||||
}
|
||||
// 段落开始后不再跳行,直接累加
|
||||
if !started {
|
||||
// 跳过标题 / HTML 注释 / badge 图片 / HTML 标签等噪声前导行
|
||||
if line.starts_with('#')
|
||||
|| line.starts_with("<!--")
|
||||
|| line.starts_with('!')
|
||||
|| line.starts_with('<')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
started = true;
|
||||
}
|
||||
if !text.is_empty() {
|
||||
text.push(' ');
|
||||
}
|
||||
text.push_str(line);
|
||||
}
|
||||
let desc = text.trim().to_string();
|
||||
if desc.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(truncate_chars(&desc, EXTRACT_DESC_MAX))
|
||||
}
|
||||
|
||||
/// extract_description 最大字符数
|
||||
const EXTRACT_DESC_MAX: usize = 200;
|
||||
|
||||
/// 项目采样结果 — README(剥噪音后) + 目录树(2层) + 清单文件片段 + 内容图引用
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProjectSample {
|
||||
pub readme: Option<String>,
|
||||
pub tree: Vec<String>,
|
||||
/// (文件名, 截断内容)
|
||||
pub manifests: Vec<(String, String)>,
|
||||
/// README 内的内容图引用(架构图/截图等,跳徽章)
|
||||
pub images: Vec<ImageRef>,
|
||||
}
|
||||
|
||||
const SAMPLE_README_MAX: usize = 8000;
|
||||
const SAMPLE_MANIFEST_MAX: usize = 1500;
|
||||
const SAMPLE_TREE_MAX: usize = 80;
|
||||
/// 目录树过滤的噪音目录(依赖产物/构建/缓存/IDE)
|
||||
const SAMPLE_IGNORED_DIRS: &[&str] = &[
|
||||
"node_modules", "target", ".git", "dist", "build", ".next", "venv", ".venv",
|
||||
"__pycache__", ".idea", ".vscode", ".cache", "out", "coverage", ".svelte-kit",
|
||||
".turbo", ".angular", ".gradle", "vendor",
|
||||
];
|
||||
|
||||
/// 读 README 原始内容(不做处理)
|
||||
fn read_readme_raw(root: &Path) -> Option<String> {
|
||||
for name in &["README.md", "README.MD", "README", "README.zh.md", "README_zh.md", "README_EN.md", "readme.md"] {
|
||||
let p = root.join(name);
|
||||
if p.is_file() {
|
||||
if let Ok(content) = std::fs::read_to_string(&p) {
|
||||
return Some(content);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 采集项目采样(README + 目录树 + 清单 + 内容图),供 LLM 分析填基础信息
|
||||
pub fn collect_sample(root: &Path) -> Result<ProjectSample> {
|
||||
if !root.is_dir() {
|
||||
anyhow::bail!("路径不是目录: {}", root.display());
|
||||
}
|
||||
let raw = read_readme_raw(root);
|
||||
let (readme, images) = match raw {
|
||||
Some(text) => {
|
||||
let images = collect_images(&text);
|
||||
let cleaned = strip_readme_noise(&text);
|
||||
if cleaned.trim().is_empty() {
|
||||
(None, images)
|
||||
} else {
|
||||
(Some(truncate_chars(&cleaned, SAMPLE_README_MAX)), images)
|
||||
}
|
||||
}
|
||||
None => (None, Vec::new()),
|
||||
};
|
||||
Ok(ProjectSample {
|
||||
readme,
|
||||
tree: collect_tree(root),
|
||||
manifests: collect_manifests(root),
|
||||
images,
|
||||
})
|
||||
}
|
||||
|
||||
/// 目录树(根 + 一层子目录),过滤噪音目录,控条目数
|
||||
fn collect_tree(root: &Path) -> Vec<String> {
|
||||
let mut lines = Vec::new();
|
||||
let mut count = 0usize;
|
||||
collect_tree_level(root, "", &mut lines, &mut count, false);
|
||||
lines
|
||||
}
|
||||
|
||||
fn collect_tree_level(dir: &Path, prefix: &str, lines: &mut Vec<String>, count: &mut usize, is_sub: bool) {
|
||||
if *count >= SAMPLE_TREE_MAX {
|
||||
return;
|
||||
}
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
||||
let mut items: Vec<_> = entries.flatten().collect();
|
||||
items.sort_by_key(|e| e.file_name());
|
||||
for e in items {
|
||||
if *count >= SAMPLE_TREE_MAX {
|
||||
return;
|
||||
}
|
||||
let name = e.file_name().to_string_lossy().to_string();
|
||||
let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
|
||||
if is_dir {
|
||||
if SAMPLE_IGNORED_DIRS.contains(&name.as_str()) {
|
||||
continue;
|
||||
}
|
||||
lines.push(format!("{}{}/", prefix, name));
|
||||
*count += 1;
|
||||
// 仅根目录的子目录展开一层(is_sub=true 不再递归)
|
||||
if !is_sub {
|
||||
collect_tree_level(&e.path(), &format!("{} ", prefix), lines, count, true);
|
||||
}
|
||||
} else {
|
||||
// 跳过隐藏文件(保留 .gitignore 作 git 标识)
|
||||
if name.starts_with('.') && name != ".gitignore" {
|
||||
continue;
|
||||
}
|
||||
lines.push(format!("{}{}", prefix, name));
|
||||
*count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_manifests(root: &Path) -> Vec<(String, String)> {
|
||||
let mut out = Vec::new();
|
||||
for name in &["package.json", "Cargo.toml", "go.mod", "pyproject.toml", "pom.xml", "build.gradle", "build.gradle.kts"] {
|
||||
let p = root.join(name);
|
||||
if let Ok(content) = std::fs::read_to_string(&p) {
|
||||
out.push(((*name).to_string(), truncate_chars(&content, SAMPLE_MANIFEST_MAX)));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 按字符数截断(避免截断 UTF-8 多字节边界)
|
||||
fn truncate_chars(s: &str, max: usize) -> String {
|
||||
if s.chars().count() <= max {
|
||||
return s.to_string();
|
||||
}
|
||||
let truncated: String = s.chars().take(max).collect();
|
||||
format!("{}…(已截断)", truncated)
|
||||
}
|
||||
|
||||
/// 去重 push
|
||||
fn push_unique(stack: &mut Vec<String>, s: &str) {
|
||||
if !stack.iter().any(|x| x == s) {
|
||||
stack.push(s.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// 在系统临时目录建唯一子目录(以进程号隔离并发),返回路径
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let mut p = std::env::temp_dir();
|
||||
p.push(format!("df-project-scan-{}-{}", name, std::process::id()));
|
||||
let _ = fs::remove_dir_all(&p);
|
||||
fs::create_dir_all(&p).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_dir_errors() {
|
||||
let r = detect_stack(Path::new("definitely-not-exist-xyz-123"));
|
||||
assert!(r.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_rust() {
|
||||
let d = scratch("rust");
|
||||
fs::write(d.join("Cargo.toml"), "").unwrap();
|
||||
let s = detect_stack(&d).unwrap();
|
||||
assert!(s.contains(&"rust".to_string()));
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_full_stack() {
|
||||
// 模拟 DevFlow 自身:rust + tauri + vue + vite + typescript
|
||||
let d = scratch("full");
|
||||
fs::write(d.join("Cargo.toml"), "").unwrap();
|
||||
fs::create_dir(d.join("src-tauri")).unwrap();
|
||||
fs::write(
|
||||
d.join("package.json"),
|
||||
r#"{"dependencies":{"vue":"^3.5.0"},"devDependencies":{"vite":"^6.0.0","typescript":"~5.6.0"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let s = detect_stack(&d).unwrap();
|
||||
assert!(s.contains(&"rust".to_string()));
|
||||
assert!(s.contains(&"tauri".to_string()));
|
||||
assert!(s.contains(&"vue".to_string()));
|
||||
assert!(s.contains(&"vite".to_string()));
|
||||
assert!(s.contains(&"typescript".to_string()));
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_dir_returns_empty() {
|
||||
let d = scratch("empty");
|
||||
let s = detect_stack(&d).unwrap();
|
||||
assert!(s.is_empty());
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collects_sample() {
|
||||
let d = scratch("sample");
|
||||
fs::write(
|
||||
d.join("README.md"),
|
||||
"# Test\n\n\n\n\n\nA test project.\nMore.",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(d.join("package.json"), r#"{"name":"x","dependencies":{"vue":"3"}}"#).unwrap();
|
||||
fs::create_dir(d.join("src")).unwrap();
|
||||
fs::write(d.join("src/main.ts"), "x").unwrap();
|
||||
fs::create_dir(d.join("node_modules")).unwrap();
|
||||
fs::write(d.join("node_modules/junk.json"), "x").unwrap();
|
||||
let s = collect_sample(&d).unwrap();
|
||||
// 徽章行剥,内容图 + 正文保留
|
||||
let readme = s.readme.as_deref().unwrap_or("");
|
||||
assert!(readme.contains("test project"), "readme={readme}");
|
||||
assert!(!readme.contains("shields.io"), "徽章未剥: {readme}");
|
||||
assert!(readme.contains("docs/arch.png"), "内容图丢失: {readme}");
|
||||
// 内容图收集(badge 跳,arch 留)
|
||||
assert_eq!(s.images.len(), 1);
|
||||
assert_eq!(s.images[0].src, "./docs/arch.png");
|
||||
assert!(s.manifests.iter().any(|(n, _)| n == "package.json"));
|
||||
assert!(s.tree.iter().any(|t| t.contains("src")));
|
||||
// node_modules 应被过滤
|
||||
assert!(s.tree.iter().all(|t| !t.contains("node_modules")));
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_frontmatter_and_toc_and_badges() {
|
||||
let d = scratch("noise");
|
||||
let readme = "---\ntitle: Foo\n---\n\n# Foo\n\n<!-- hidden comment -->\n\n[Install](#install)\n\n- [Usage](#usage)\n\n\n\nThis is the real intro.\n";
|
||||
fs::write(d.join("README.md"), readme).unwrap();
|
||||
let s = collect_sample(&d).unwrap();
|
||||
let r = s.readme.as_deref().unwrap_or("");
|
||||
assert!(r.contains("# Foo"), "标题应保留: {r}");
|
||||
assert!(r.contains("real intro"), "正文应保留: {r}");
|
||||
assert!(!r.contains("hidden comment"), "HTML 注释未剥: {r}");
|
||||
assert!(!r.contains("shields.io"), "徽章未剥: {r}");
|
||||
assert!(!r.contains("[Install](#install)"), "TOC 锚点未剥: {r}");
|
||||
assert!(!r.contains("[Usage](#usage)"), "TOC 列表项未剥: {r}");
|
||||
assert!(!r.contains("title: Foo"), "frontmatter 未剥: {r}");
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_monorepo_pnpm() {
|
||||
let d = scratch("mono-pnpm");
|
||||
fs::write(d.join("pnpm-workspace.yaml"), "packages:\n - packages/*\n").unwrap();
|
||||
assert!(is_monorepo(&d));
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_monorepo_npm_workspaces() {
|
||||
let d = scratch("mono-npm");
|
||||
fs::write(
|
||||
d.join("package.json"),
|
||||
r#"{"name":"root","workspaces":["packages/*"]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(is_monorepo(&d));
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_non_monorepo() {
|
||||
let d = scratch("nonmono");
|
||||
fs::write(d.join("package.json"), r#"{"name":"x"}"#).unwrap();
|
||||
assert!(!is_monorepo(&d));
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_monorepo_children() {
|
||||
let d = scratch("discover-mono");
|
||||
fs::write(d.join("pnpm-workspace.yaml"), "packages:\n - packages/*\n").unwrap();
|
||||
// 子项目:packages/web(有 package.json + vue)、packages/cli(有 Cargo.toml)
|
||||
fs::create_dir_all(d.join("packages/web")).unwrap();
|
||||
fs::write(
|
||||
d.join("packages/web/package.json"),
|
||||
r#"{"name":"web","dependencies":{"vue":"3"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::create_dir_all(d.join("packages/cli")).unwrap();
|
||||
fs::write(d.join("packages/cli/Cargo.toml"), "[package]\nname=\"cli\"\n").unwrap();
|
||||
// 空 stack 子目录应过滤
|
||||
fs::create_dir_all(d.join("packages/empty")).unwrap();
|
||||
fs::write(d.join("packages/empty/x.txt"), "x").unwrap();
|
||||
let found = discover_projects(&d).unwrap();
|
||||
// 根自身无 manifest 不入选;packages/web + packages/cli 入选;empty 过滤
|
||||
let names: Vec<_> = found.iter().map(|p| p.name.as_str()).collect();
|
||||
assert!(names.contains(&"web"), "names={names:?}");
|
||||
assert!(names.contains(&"cli"), "names={names:?}");
|
||||
assert!(!names.contains(&"empty"), "空 stack 未过滤: {names:?}");
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_flat_children() {
|
||||
// 非 monorepo:扫根直接子目录中 detect_stack 非空的
|
||||
let d = scratch("discover-flat");
|
||||
fs::create_dir_all(d.join("proj-a")).unwrap();
|
||||
fs::write(d.join("proj-a/Cargo.toml"), "").unwrap();
|
||||
fs::create_dir_all(d.join("not-a-project")).unwrap();
|
||||
fs::write(d.join("not-a-project/readme.txt"), "x").unwrap();
|
||||
let found = discover_projects(&d).unwrap();
|
||||
let names: Vec<_> = found.iter().map(|p| p.name.as_str()).collect();
|
||||
assert!(names.contains(&"proj-a"), "names={names:?}");
|
||||
assert!(!names.contains(&"not-a-project"), "空 stack 未过滤: {names:?}");
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_desc_skips_title_badge() {
|
||||
let d = scratch("desc");
|
||||
// 标题 + badge 图片行应跳过,首段为正文
|
||||
fs::write(
|
||||
d.join("README.md"),
|
||||
"# My Project\n\n\n\n这是一个示例项目,用于演示。\n第二行正文。\n\n## 安装",
|
||||
)
|
||||
.unwrap();
|
||||
let desc = extract_description(&d).unwrap();
|
||||
assert!(desc.contains("示例项目"));
|
||||
assert!(desc.contains("第二行正文"));
|
||||
assert!(!desc.contains("My Project"));
|
||||
assert!(!desc.contains("badge"));
|
||||
assert!(!desc.contains("安装"));
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_desc_no_readme_returns_none() {
|
||||
let d = scratch("nodesc");
|
||||
assert!(extract_description(&d).is_none());
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_desc_truncates_long() {
|
||||
let d = scratch("longdesc");
|
||||
let long = "啊".repeat(500);
|
||||
fs::write(d.join("README.md"), format!("# T\n\n{long}")).unwrap();
|
||||
let desc = extract_description(&d).unwrap();
|
||||
// 截断到 200 字 + 末尾「…(已截断)」标记
|
||||
assert!(desc.chars().count() <= 210);
|
||||
assert!(desc.contains("已截断"));
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
// ── normalize_path 跨平台归一化 ──
|
||||
// 锁定:① 反斜杠→正斜杠 ② 小写 ③ 尾部分隔符裁剪 ④ 相同逻辑路径相等(防重复录入绕过)。
|
||||
// 注:canonicalize 成功路径在不同 OS 返回不同形态(Windows 带前缀),故只断言跨平台不变性,
|
||||
// 不断言精确串,避免与平台耦合。
|
||||
#[test]
|
||||
fn normalize_path_uses_forward_slash_and_lowercase() {
|
||||
// 不存在的路径走降级分支(trim + replace + lowercase)
|
||||
let n = normalize_path(r"C:\Foo\Bar\");
|
||||
assert!(
|
||||
!n.contains('\\'),
|
||||
"反斜杠未归一: {n}"
|
||||
);
|
||||
assert_eq!(n, n.to_lowercase(), "未小写: {n}");
|
||||
assert!(
|
||||
!n.ends_with('/') && !n.ends_with('\\'),
|
||||
"尾部分隔符未裁剪: {n}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_path_equivalent_inputs_equal() {
|
||||
// 两种写法应归一为同一串(去重场景)
|
||||
let a = normalize_path(r"C:\Foo\Bar");
|
||||
let b = normalize_path("C:/Foo/Bar/");
|
||||
assert_eq!(a, b, "等价路径归一不等: {a} vs {b}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_path_relative_is_lowercase_and_forward_slash() {
|
||||
let n = normalize_path(r"..\Some\Path");
|
||||
assert!(!n.contains('\\'), "反斜杠未归一: {n}");
|
||||
assert_eq!(n, n.to_lowercase(), "未小写: {n}");
|
||||
}
|
||||
|
||||
// ── is_monorepo workspaces:null 防回归(wd2fnjh3s) ──
|
||||
// .is_some_and(!is_null) 修复点:key 存在但值为 JSON null 时不得判为 monorepo。
|
||||
#[test]
|
||||
fn is_monorepo_workspaces_null_not_misclassified() {
|
||||
let d = scratch("ws-null");
|
||||
fs::write(
|
||||
d.join("package.json"),
|
||||
r#"{"name":"x","workspaces":null}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
!is_monorepo(&d),
|
||||
"workspaces: null 不应判为 monorepo(回归 wd2fnjh3s)"
|
||||
);
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_monorepo_workspaces_object_treated_as_real() {
|
||||
// Yarn 形式 {"packages":[...]} —— 非 null,应判为 monorepo
|
||||
let d = scratch("ws-obj");
|
||||
fs::write(
|
||||
d.join("package.json"),
|
||||
r#"{"name":"y","workspaces":{"packages":["packages/*"]}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(is_monorepo(&d), "Yarn workspaces 对象形式应判为 monorepo");
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_monorepo_non_dir_returns_false() {
|
||||
assert!(!is_monorepo(Path::new("definitely-not-exist-xyz-456")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
//! README 噪音处理与图片收集 — 纯函数,无 IO 依赖。
|
||||
//!
|
||||
//! 从 `scan.rs` 抽离(2026-06-19 自底向上拆分):徽章判定、内容图收集、
|
||||
//! frontmatter/TOC/纯徽章行剥离等纯文本规则。无文件系统访问,便于单测与复用。
|
||||
//!
|
||||
//! 外部路径不变:`scan` 模块通过 `pub use` 重导出 `ImageRef` 等。
|
||||
|
||||
/// 内容图引用(README 内的架构图/截图等,喂 vision 用)。
|
||||
/// 采样层只收集 alt+src,Phase 2 上线后由 commands 层读 base64 喂 vision。
|
||||
/// 当前 ChatMessage.content:String(F-260614-05 未做)走纯文本降级,
|
||||
/// 此结构仅为采样层留接口,不读 base64。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ImageRef {
|
||||
pub alt: String,
|
||||
pub src: String,
|
||||
}
|
||||
|
||||
/// 徽章图域名(纯徽章图,跳过不喂 LLM)
|
||||
const BADGE_HOSTS: &[&str] = &[
|
||||
"img.shields.io",
|
||||
"shields.io",
|
||||
"badge.fury.io",
|
||||
"badgen.net",
|
||||
"badges.frapsoft.com",
|
||||
"github.com/workflows", // GitHub Actions 工作流状态徽章
|
||||
"travis-ci.org",
|
||||
"coveralls.io",
|
||||
"codecov.io",
|
||||
"app.codacy.com",
|
||||
"david-dm.org",
|
||||
"circleci.com",
|
||||
"ci.appveyor.com",
|
||||
];
|
||||
|
||||
/// 徽章关键词(图 alt/src 含这些视为纯徽章图)
|
||||
const BADGE_KEYWORDS: &[&str] = &[
|
||||
"build",
|
||||
"version",
|
||||
"license",
|
||||
"coverage",
|
||||
"downloads",
|
||||
"download",
|
||||
"npm",
|
||||
"pypi",
|
||||
"crates.io",
|
||||
"codeclimate",
|
||||
"maintainability",
|
||||
"stars",
|
||||
"forks",
|
||||
"issues",
|
||||
"contributors",
|
||||
"dependencies",
|
||||
"devdependencies",
|
||||
"circleci",
|
||||
"travis",
|
||||
"appveyor",
|
||||
"coveralls",
|
||||
"codecov",
|
||||
];
|
||||
|
||||
/// 判定 markdown 图片 `` 是否为纯徽章(架构图/截图等保留)
|
||||
fn is_badge_image(alt: &str, src: &str) -> bool {
|
||||
// 域名命中(shields.io / badge.fury / GitHub Actions 等)
|
||||
let src_lower = src.to_lowercase();
|
||||
if BADGE_HOSTS.iter().any(|h| src_lower.contains(h)) {
|
||||
return true;
|
||||
}
|
||||
// 关键词命中(alt 或 src 含 build/version/license/coverage 等)
|
||||
let hay = format!("{alt} {src}").to_lowercase();
|
||||
if BADGE_KEYWORDS.iter().any(|k| hay.contains(k)) {
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 从 README 收集内容图(跳徽章),保留 markdown 原样引用
|
||||
pub(crate) fn collect_images(content: &str) -> Vec<ImageRef> {
|
||||
let mut out = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
// 匹配  —— 简单行级扫描,够用且无 regex 依赖
|
||||
for line in content.lines() {
|
||||
let mut rest = line;
|
||||
while let Some(start) = rest.find("![") {
|
||||
let after_bracket = &rest[start + 2..];
|
||||
let Some(alt_end) = after_bracket.find("](") else { break };
|
||||
let alt = after_bracket[..alt_end].to_string();
|
||||
let after_paren = &after_bracket[alt_end + 2..];
|
||||
let Some(paren_end) = after_paren.find(')') else { break };
|
||||
let src = after_paren[..paren_end].to_string();
|
||||
rest = &after_paren[paren_end + 1..];
|
||||
if is_badge_image(&alt, &src) {
|
||||
continue;
|
||||
}
|
||||
// 同 src 去重
|
||||
if seen.insert(src.clone()) {
|
||||
out.push(ImageRef { alt, src });
|
||||
}
|
||||
if out.len() >= 20 {
|
||||
return out;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 剥 README 噪音:frontmatter(YAML/TOML 块) / HTML 注释 / TOC(纯链接目录行) /
|
||||
/// 纯徽章图片行 / 纯徽章 HTML 行。保留标题、正文、内容图 markdown 原样。
|
||||
pub(crate) fn strip_readme_noise(content: &str) -> String {
|
||||
let mut out = Vec::new();
|
||||
let mut lines = content.lines().peekable();
|
||||
while let Some(line) = lines.next() {
|
||||
let trimmed = line.trim();
|
||||
// ── frontmatter 块(YAML `---` / TOML `+++`)──
|
||||
if trimmed == "---" || trimmed == "+++" {
|
||||
// 开头处的 frontmatter:跳到下一个匹配分隔符
|
||||
let delim = trimmed;
|
||||
let mut consumed_any = false;
|
||||
for next in lines.by_ref() {
|
||||
consumed_any = true;
|
||||
if next.trim() == delim {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let _ = consumed_any;
|
||||
continue;
|
||||
}
|
||||
// ── HTML 注释块(<!-- ... -->,可能跨行)──
|
||||
if trimmed.starts_with("<!--") {
|
||||
if trimmed.contains("-->") {
|
||||
// 单行注释,整行跳过
|
||||
continue;
|
||||
}
|
||||
// 多行:吃到含 --> 的行
|
||||
for next in lines.by_ref() {
|
||||
if next.contains("-->") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// ── 纯徽章行:整行只剩图片/HTML 徽章(可能多个  连排)──
|
||||
if is_pure_badge_line(trimmed) {
|
||||
continue;
|
||||
}
|
||||
// ── TOC:纯目录行(整行只有 [..](#anchor) 锚点链接,或 markdown 列表项仅锚点)──
|
||||
if is_toc_line(trimmed) {
|
||||
continue;
|
||||
}
|
||||
out.push(line.to_string());
|
||||
}
|
||||
out.join("\n")
|
||||
}
|
||||
|
||||
/// 判定整行是否为纯徽章行(仅含图片/HTML badge,无其它实质文本)。
|
||||
/// 如 `  <a href="..."><img.../></a>`
|
||||
pub(crate) fn is_pure_badge_line(line: &str) -> bool {
|
||||
if line.is_empty() {
|
||||
return false;
|
||||
}
|
||||
// 仅含图片语法/HTML 标签/空白/[alt] 片段,且至少有一个图片或 HTML img 标签
|
||||
let mut content_found = false;
|
||||
let mut text_only: String = String::new();
|
||||
let mut rest = line;
|
||||
loop {
|
||||
// 找下一个 ![ 或 <img 或 <a
|
||||
// 注:HTML 标签为纯 ASCII,用 to_ascii_lowercase 做「不区分大小写」匹配即可;
|
||||
// 若用 to_lowercase(),非 ASCII 字符(如 İ→i̇、ß→ss)在小写化时改变字节长度,
|
||||
// 返回的索引会偏离原 rest 的字节边界,后续 &rest[..pos]/&rest[pos..] 切到错位/panic。
|
||||
// to_ascii_lowercase 仅改 ASCII 字母、保持非 ASCII 字节不变 → 字节长度不变 → 索引对齐。
|
||||
let img_md = rest.find("![");
|
||||
let img_html = rest.to_ascii_lowercase().find("<img");
|
||||
let anchor_html = rest.to_ascii_lowercase().find("<a ");
|
||||
let earliest = [img_md, img_html, anchor_html]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.min();
|
||||
let Some(pos) = earliest else {
|
||||
// 剩余纯文本
|
||||
text_only.push_str(rest);
|
||||
break;
|
||||
};
|
||||
// pos 之前的文本进 text_only
|
||||
text_only.push_str(&rest[..pos]);
|
||||
let suffix = &rest[pos..];
|
||||
let consumed = if suffix.starts_with(".unwrap_or(end)];
|
||||
let src = &suffix[suffix.find("](").map(|i| i + 2).unwrap_or(end)..end];
|
||||
if !is_badge_image(alt, src) {
|
||||
// 内容图混在行里 → 非纯徽章行
|
||||
return false;
|
||||
}
|
||||
content_found = true;
|
||||
end + 1
|
||||
} else {
|
||||
// 不闭合,当普通文本
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
// HTML <img 或 <a 标签:视为徽章载体(HTML 行常见于此)
|
||||
// 找 > 闭合
|
||||
if let Some(end) = suffix.find('>') {
|
||||
content_found = true;
|
||||
end + 1
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
rest = &suffix[consumed..];
|
||||
}
|
||||
// 剩余 text_only 必须为空或纯空白/标点
|
||||
let residual = text_only
|
||||
.chars()
|
||||
.filter(|c| !c.is_whitespace() && *c != '|' && *c != '-')
|
||||
.collect::<String>();
|
||||
content_found && residual.is_empty()
|
||||
}
|
||||
|
||||
/// 判定 TOC 行:markdown 列表项仅含锚点链接 `- [..](#..)` 或整行仅锚点链接
|
||||
fn is_toc_line(line: &str) -> bool {
|
||||
if line.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let t = line.trim_start_matches(['-', '*', '+', ' ']);
|
||||
// 形如 [text](#anchor) 或 [text](#anchor "title")
|
||||
if !(t.starts_with('[') && t.contains("](")) {
|
||||
return false;
|
||||
}
|
||||
// 取 ]( 后到行尾或空格,须以 # 开头
|
||||
let Some(close) = t.find("](") else { return false };
|
||||
let after = &t[close + 2..];
|
||||
let target = after.trim_end_matches(')').split_whitespace().next().unwrap_or("");
|
||||
target.starts_with('#')
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn image_collection_skips_badges() {
|
||||
let content = "\n\n\n\n";
|
||||
let imgs = collect_images(content);
|
||||
// 只有 arch + screenshot,build/version 跳
|
||||
assert_eq!(imgs.len(), 2);
|
||||
assert!(imgs.iter().any(|i| i.src == "./a.png"));
|
||||
assert!(imgs.iter().any(|i| i.src == "screens/b.png"));
|
||||
assert!(imgs.iter().all(|i| !i.src.contains("shields.io")));
|
||||
assert!(imgs.iter().all(|i| !i.src.contains("badge.fury")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_images_filters_badge_by_keyword() {
|
||||
// 非徽章域名,但 alt/src 含关键词(build/license/coverage)应过滤
|
||||
let content = "\n\n\n\n";
|
||||
let imgs = collect_images(content);
|
||||
assert!(imgs.iter().all(|i| !i.alt.contains("build")), "build 关键词徽章未过滤");
|
||||
assert!(imgs.iter().all(|i| !i.alt.contains("license")), "license 关键词徽章未过滤");
|
||||
assert!(imgs.iter().all(|i| !i.alt.contains("coverage")), "coverage 关键词徽章未过滤");
|
||||
assert_eq!(imgs.len(), 1, "应仅保留架构图: {imgs:?}");
|
||||
assert_eq!(imgs[0].src, "./arch.png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_images_dedups_same_src() {
|
||||
// 同 src 不同 alt 去重,只留首个
|
||||
let content = "\n\n";
|
||||
let imgs = collect_images(content);
|
||||
assert_eq!(imgs.len(), 1, "同 src 应去重: {imgs:?}");
|
||||
assert_eq!(imgs[0].alt, "first");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_images_handles_multiple_in_one_line() {
|
||||
// 同行多图 + 徽章混排:徽章过滤、内容图保留
|
||||
let content = "  \n";
|
||||
let imgs = collect_images(content);
|
||||
let srcs: Vec<_> = imgs.iter().map(|i| i.src.as_str()).collect();
|
||||
assert!(srcs.contains(&"./shot.png"), "同行内容图丢失: {srcs:?}");
|
||||
assert!(srcs.contains(&"./two.png"), "同行内容图丢失: {srcs:?}");
|
||||
assert!(!srcs.contains(&"https://img.shields.io/x"), "同行徽章未过滤: {srcs:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_images_empty_and_malformed() {
|
||||
// 无图片 / 不闭合语法不 panic,返回空
|
||||
assert!(collect_images("plain text no images").is_empty());
|
||||
assert!(collect_images(".is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_pure_badge_line_true_for_shields_row() {
|
||||
assert!(is_pure_badge_line(
|
||||
" "
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_pure_badge_line_false_when_text_present() {
|
||||
// 行内含实质文本 → 非纯徽章行
|
||||
assert!(!is_pure_badge_line(" and some real text"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_pure_badge_line_false_for_content_image_only() {
|
||||
// 纯内容图行(非徽章)不剥,保留 → 返回 false
|
||||
assert!(!is_pure_badge_line(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_readme_noise_removes_frontmatter_htmlcomment_toc_badge() {
|
||||
let readme = "---\ntitle: Foo\n---\n\n# Foo\n\n<!-- hidden comment -->\n\n[Install](#install)\n\n- [Usage](#usage)\n\n\n\nThis is the real intro.\n";
|
||||
let cleaned = strip_readme_noise(readme);
|
||||
assert!(cleaned.contains("# Foo"), "标题应保留: {cleaned}");
|
||||
assert!(cleaned.contains("real intro"), "正文应保留: {cleaned}");
|
||||
assert!(!cleaned.contains("hidden comment"), "HTML 注释未剥: {cleaned}");
|
||||
assert!(!cleaned.contains("shields.io"), "徽章未剥: {cleaned}");
|
||||
assert!(!cleaned.contains("[Install](#install)"), "TOC 锚点未剥: {cleaned}");
|
||||
assert!(!cleaned.contains("[Usage](#usage)"), "TOC 列表项未剥: {cleaned}");
|
||||
assert!(!cleaned.contains("title: Foo"), "frontmatter 未剥: {cleaned}");
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,15 @@ version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
df-core = { path = "../df-core" }
|
||||
df-types = { path = "../df-types" }
|
||||
# F-01 阶段1:ModelConfig/deserialize_model_configs 向后兼容反序列化(老字符串数组/新对象数组/null/空)
|
||||
df-ai-core = { path = "../df-ai-core" }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
rusqlite = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
# keyring:FR-S1 密钥解析下沉,纯密钥逻辑(get/set/delete/resolve/ensure/migrate)的唯一源。
|
||||
# build_provider_for 不下沉(依赖 df-ai 会引循环),留 src-tauri 转发壳。
|
||||
keyring = { workspace = true }
|
||||
|
||||
@@ -1,614 +0,0 @@
|
||||
//! Repository 模式的 CRUD 操作
|
||||
//!
|
||||
//! 为 7 张表各提供一个 Repo 结构体,统一实现 insert / get_by_id / list_all / query / update_field / delete。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use rusqlite::{params, Connection, OptionalExtension, Row};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use df_core::error::{Error, Result};
|
||||
|
||||
use crate::db::Database;
|
||||
use crate::models::{
|
||||
AiConversationRecord, AiProviderRecord, AiToolExecutionRecord, BranchRecord, IdeaRecord,
|
||||
NodeExecutionRecord, ProjectRecord, ReleaseRecord, TaskRecord, WorkflowRecord,
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 辅助宏 — 消除 6 个 Repo 的重复样板
|
||||
// ============================================================
|
||||
|
||||
/// 一次性为 7 张表生成完整的 Repo 结构体及其 CRUD 实现。
|
||||
///
|
||||
/// 用法: `impl_repo!(RepoName, ModelType, "table_name", from_row_body, insert_body);`
|
||||
macro_rules! impl_repo {
|
||||
(
|
||||
$(#[$meta:meta])*
|
||||
$name:ident, $record:ident, $table:literal,
|
||||
from_row => |$row:ident| $from_body:expr,
|
||||
insert => |$conn:ident, $rec:ident| $insert_body:expr
|
||||
) => {
|
||||
$(#[$meta])*
|
||||
pub struct $name {
|
||||
conn: Arc<Mutex<Connection>>,
|
||||
}
|
||||
|
||||
impl $name {
|
||||
pub fn new(db: &Database) -> Self {
|
||||
Self { conn: db.conn() }
|
||||
}
|
||||
|
||||
pub async fn insert(&self, record: $record) -> Result<String> {
|
||||
let conn = self.conn.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let id = record.id.clone();
|
||||
let $conn = &*guard;
|
||||
let $rec = &record;
|
||||
$insert_body.map_err(|e: rusqlite::Error| Error::Storage(e.to_string()))?;
|
||||
Ok(id)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| Error::Storage(e.to_string()))?
|
||||
}
|
||||
|
||||
pub async fn get_by_id(&self, id: &str) -> Result<Option<$record>> {
|
||||
let conn = self.conn.clone();
|
||||
let id = id.to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let mut stmt = guard
|
||||
.prepare(&format!("SELECT * FROM {} WHERE id = ?1", $table))
|
||||
.map_err(|e| Error::Storage(e.to_string()))?;
|
||||
let row = stmt
|
||||
.query_row(params![id], |$row| $from_body)
|
||||
.optional()
|
||||
.map_err(|e| Error::Storage(e.to_string()))?;
|
||||
Ok(row)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| Error::Storage(e.to_string()))?
|
||||
}
|
||||
|
||||
pub async fn list_all(&self) -> Result<Vec<$record>> {
|
||||
let conn = self.conn.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let mut stmt = guard
|
||||
.prepare(&format!("SELECT * FROM {} ORDER BY created_at DESC", $table))
|
||||
.map_err(|e| Error::Storage(e.to_string()))?;
|
||||
let rows = stmt
|
||||
.query_map([], |$row| $from_body)
|
||||
.map_err(|e| Error::Storage(e.to_string()))?;
|
||||
let mut results = Vec::new();
|
||||
for r in rows {
|
||||
results.push(
|
||||
r.map_err(|e| Error::Storage(e.to_string()))?,
|
||||
);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| Error::Storage(e.to_string()))?
|
||||
}
|
||||
|
||||
pub async fn query(&self, field: &str, value: &str) -> Result<Vec<$record>> {
|
||||
// 白名单校验,防止 SQL 注入
|
||||
validate_column_name(field)?;
|
||||
let conn = self.conn.clone();
|
||||
let sql = format!("SELECT * FROM {} WHERE {} = ?1 ORDER BY created_at DESC", $table, field);
|
||||
let value = value.to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let mut stmt = guard
|
||||
.prepare(&sql)
|
||||
.map_err(|e| Error::Storage(e.to_string()))?;
|
||||
let rows = stmt
|
||||
.query_map(params![value], |$row| $from_body)
|
||||
.map_err(|e| Error::Storage(e.to_string()))?;
|
||||
let mut results = Vec::new();
|
||||
for r in rows {
|
||||
results.push(
|
||||
r.map_err(|e| Error::Storage(e.to_string()))?,
|
||||
);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| Error::Storage(e.to_string()))?
|
||||
}
|
||||
|
||||
pub async fn update_field(&self, id: &str, field: &str, value: &str) -> Result<bool> {
|
||||
validate_column_name(field)?;
|
||||
let conn = self.conn.clone();
|
||||
let sql = format!("UPDATE {} SET {} = ?1, updated_at = ?2 WHERE id = ?3", $table, field);
|
||||
let id = id.to_owned();
|
||||
let value = value.to_owned();
|
||||
let now = now_iso();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let affected = guard
|
||||
.execute(&sql, params![value, now, id])
|
||||
.map_err(|e| Error::Storage(e.to_string()))?;
|
||||
Ok(affected > 0)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| Error::Storage(e.to_string()))?
|
||||
}
|
||||
|
||||
pub async fn delete(&self, id: &str) -> Result<bool> {
|
||||
let conn = self.conn.clone();
|
||||
let id = id.to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let affected = guard
|
||||
.execute(&format!("DELETE FROM {} WHERE id = ?1", $table), params![id])
|
||||
.map_err(|e| Error::Storage(e.to_string()))?;
|
||||
Ok(affected > 0)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| Error::Storage(e.to_string()))?
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 列名白名单 — 防止 SQL 注入
|
||||
// ============================================================
|
||||
|
||||
/// 所有表允许用于 query / update_field 的列名
|
||||
const ALLOWED_COLUMNS: &[&str] = &[
|
||||
// ideas
|
||||
"id",
|
||||
"title",
|
||||
"description",
|
||||
"status",
|
||||
"priority",
|
||||
"score",
|
||||
"tags",
|
||||
"source",
|
||||
"promoted_to",
|
||||
"ai_analysis",
|
||||
"scores",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
// projects / tasks 额外
|
||||
"name",
|
||||
"idea_id",
|
||||
"project_id",
|
||||
"branch_name",
|
||||
"assignee",
|
||||
"workflow_def_id",
|
||||
"base_branch",
|
||||
// branches 额外
|
||||
"task_id",
|
||||
"base",
|
||||
"merged_at",
|
||||
// releases 额外
|
||||
"version",
|
||||
"task_ids",
|
||||
"changelog",
|
||||
"released_at",
|
||||
// workflow / node 额外
|
||||
"dag_json",
|
||||
"triggered_by",
|
||||
"completed_at",
|
||||
"workflow_id",
|
||||
"node_id",
|
||||
"node_type",
|
||||
"input_json",
|
||||
"output_json",
|
||||
"error_message",
|
||||
"started_at",
|
||||
// AI 相关
|
||||
"provider_type",
|
||||
"api_key",
|
||||
"base_url",
|
||||
"default_model",
|
||||
"models",
|
||||
"is_default",
|
||||
"config",
|
||||
"conversation_id",
|
||||
"tool_call_id",
|
||||
"tool_name",
|
||||
"arguments",
|
||||
"result",
|
||||
"risk_level",
|
||||
"requested_at",
|
||||
"executed_at",
|
||||
"decided_by",
|
||||
];
|
||||
|
||||
fn validate_column_name(field: &str) -> Result<()> {
|
||||
if ALLOWED_COLUMNS.contains(&field) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::Storage(format!("不允许的字段名: {}", field)))
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 时间工具
|
||||
// ============================================================
|
||||
|
||||
fn now_iso() -> String {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// from_row 辅助函数
|
||||
// ============================================================
|
||||
|
||||
fn idea_from_row(row: &Row<'_>) -> std::result::Result<IdeaRecord, rusqlite::Error> {
|
||||
Ok(IdeaRecord {
|
||||
id: row.get("id")?,
|
||||
title: row.get("title")?,
|
||||
description: row.get("description")?,
|
||||
status: row.get("status")?,
|
||||
priority: row.get("priority")?,
|
||||
score: row.get("score")?,
|
||||
tags: row.get("tags")?,
|
||||
source: row.get("source")?,
|
||||
promoted_to: row.get("promoted_to")?,
|
||||
ai_analysis: row.get("ai_analysis")?,
|
||||
scores: row.get("scores")?,
|
||||
created_at: row.get("created_at")?,
|
||||
updated_at: row.get("updated_at")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn project_from_row(row: &Row<'_>) -> std::result::Result<ProjectRecord, rusqlite::Error> {
|
||||
Ok(ProjectRecord {
|
||||
id: row.get("id")?,
|
||||
name: row.get("name")?,
|
||||
description: row.get("description")?,
|
||||
status: row.get("status")?,
|
||||
idea_id: row.get("idea_id")?,
|
||||
created_at: row.get("created_at")?,
|
||||
updated_at: row.get("updated_at")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn task_from_row(row: &Row<'_>) -> std::result::Result<TaskRecord, rusqlite::Error> {
|
||||
Ok(TaskRecord {
|
||||
id: row.get("id")?,
|
||||
project_id: row.get("project_id")?,
|
||||
title: row.get("title")?,
|
||||
description: row.get("description")?,
|
||||
status: row.get("status")?,
|
||||
priority: row.get("priority")?,
|
||||
branch_name: row.get("branch_name")?,
|
||||
assignee: row.get("assignee")?,
|
||||
workflow_def_id: row.get("workflow_def_id")?,
|
||||
base_branch: row.get("base_branch")?,
|
||||
created_at: row.get("created_at")?,
|
||||
updated_at: row.get("updated_at")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn release_from_row(row: &Row<'_>) -> std::result::Result<ReleaseRecord, rusqlite::Error> {
|
||||
Ok(ReleaseRecord {
|
||||
id: row.get("id")?,
|
||||
project_id: row.get("project_id")?,
|
||||
version: row.get("version")?,
|
||||
status: row.get("status")?,
|
||||
task_ids: row.get("task_ids")?,
|
||||
changelog: row.get("changelog")?,
|
||||
created_at: row.get("created_at")?,
|
||||
released_at: row.get("released_at")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn workflow_from_row(row: &Row<'_>) -> std::result::Result<WorkflowRecord, rusqlite::Error> {
|
||||
Ok(WorkflowRecord {
|
||||
id: row.get("id")?,
|
||||
name: row.get("name")?,
|
||||
dag_json: row.get("dag_json")?,
|
||||
status: row.get("status")?,
|
||||
triggered_by: row.get("triggered_by")?,
|
||||
project_id: row.get("project_id")?,
|
||||
task_id: row.get("task_id")?,
|
||||
created_at: row.get("created_at")?,
|
||||
completed_at: row.get("completed_at")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn branch_from_row(row: &Row<'_>) -> std::result::Result<BranchRecord, rusqlite::Error> {
|
||||
Ok(BranchRecord {
|
||||
id: row.get("id")?,
|
||||
project_id: row.get("project_id")?,
|
||||
task_id: row.get("task_id")?,
|
||||
name: row.get("name")?,
|
||||
base: row.get("base")?,
|
||||
status: row.get("status")?,
|
||||
created_at: row.get("created_at")?,
|
||||
updated_at: row.get("updated_at")?,
|
||||
merged_at: row.get("merged_at")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn node_execution_from_row(
|
||||
row: &Row<'_>,
|
||||
) -> std::result::Result<NodeExecutionRecord, rusqlite::Error> {
|
||||
Ok(NodeExecutionRecord {
|
||||
id: row.get("id")?,
|
||||
workflow_id: row.get("workflow_id")?,
|
||||
node_id: row.get("node_id")?,
|
||||
node_type: row.get("node_type")?,
|
||||
status: row.get("status")?,
|
||||
input_json: row.get("input_json")?,
|
||||
output_json: row.get("output_json")?,
|
||||
error_message: row.get("error_message")?,
|
||||
started_at: row.get("started_at")?,
|
||||
completed_at: row.get("completed_at")?,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 7 个 Repo 实现
|
||||
// ============================================================
|
||||
|
||||
impl_repo!(
|
||||
/// 想法表 CRUD
|
||||
IdeaRepo,
|
||||
IdeaRecord,
|
||||
"ideas",
|
||||
from_row => |row| idea_from_row(row),
|
||||
insert => |conn, rec| {
|
||||
conn.execute(
|
||||
"INSERT INTO ideas (id, title, description, status, priority, score, tags, source, promoted_to, ai_analysis, scores, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||||
params![
|
||||
rec.id, rec.title, rec.description, rec.status, rec.priority,
|
||||
rec.score, rec.tags, rec.source, rec.promoted_to, rec.ai_analysis,
|
||||
rec.scores, rec.created_at, rec.updated_at
|
||||
],
|
||||
)
|
||||
}
|
||||
);
|
||||
|
||||
impl_repo!(
|
||||
/// 项目表 CRUD
|
||||
ProjectRepo,
|
||||
ProjectRecord,
|
||||
"projects",
|
||||
from_row => |row| project_from_row(row),
|
||||
insert => |conn, rec| {
|
||||
conn.execute(
|
||||
"INSERT INTO projects (id, name, description, status, idea_id, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
params![
|
||||
rec.id, rec.name, rec.description, rec.status, rec.idea_id,
|
||||
rec.created_at, rec.updated_at
|
||||
],
|
||||
)
|
||||
}
|
||||
);
|
||||
|
||||
impl_repo!(
|
||||
/// 任务表 CRUD
|
||||
TaskRepo,
|
||||
TaskRecord,
|
||||
"tasks",
|
||||
from_row => |row| task_from_row(row),
|
||||
insert => |conn, rec| {
|
||||
conn.execute(
|
||||
"INSERT INTO tasks (id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||
params![
|
||||
rec.id, rec.project_id, rec.title, rec.description, rec.status, rec.priority,
|
||||
rec.branch_name, rec.assignee, rec.workflow_def_id, rec.base_branch,
|
||||
rec.created_at, rec.updated_at
|
||||
],
|
||||
)
|
||||
}
|
||||
);
|
||||
|
||||
impl_repo!(
|
||||
/// 分支表 CRUD
|
||||
BranchRepo,
|
||||
BranchRecord,
|
||||
"branches",
|
||||
from_row => |row| branch_from_row(row),
|
||||
insert => |conn, rec| {
|
||||
conn.execute(
|
||||
"INSERT INTO branches (id, project_id, task_id, name, base, status, created_at, updated_at, merged_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
params![
|
||||
rec.id, rec.project_id, rec.task_id, rec.name, rec.base,
|
||||
rec.status, rec.created_at, rec.updated_at, rec.merged_at
|
||||
],
|
||||
)
|
||||
}
|
||||
);
|
||||
|
||||
impl_repo!(
|
||||
/// 发布表 CRUD
|
||||
ReleaseRepo,
|
||||
ReleaseRecord,
|
||||
"releases",
|
||||
from_row => |row| release_from_row(row),
|
||||
insert => |conn, rec| {
|
||||
conn.execute(
|
||||
"INSERT INTO releases (id, project_id, version, status, task_ids, changelog, created_at, released_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||
params![
|
||||
rec.id, rec.project_id, rec.version, rec.status, rec.task_ids,
|
||||
rec.changelog, rec.created_at, rec.released_at
|
||||
],
|
||||
)
|
||||
}
|
||||
);
|
||||
|
||||
impl_repo!(
|
||||
/// 工作流执行表 CRUD
|
||||
WorkflowRepo,
|
||||
WorkflowRecord,
|
||||
"workflow_executions",
|
||||
from_row => |row| workflow_from_row(row),
|
||||
insert => |conn, rec| {
|
||||
conn.execute(
|
||||
"INSERT INTO workflow_executions (id, name, dag_json, status, triggered_by, project_id, task_id, created_at, completed_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
params![
|
||||
rec.id, rec.name, rec.dag_json, rec.status, rec.triggered_by,
|
||||
rec.project_id, rec.task_id, rec.created_at, rec.completed_at
|
||||
],
|
||||
)
|
||||
}
|
||||
);
|
||||
|
||||
impl_repo!(
|
||||
/// 节点执行表 CRUD
|
||||
NodeExecutionRepo,
|
||||
NodeExecutionRecord,
|
||||
"node_executions",
|
||||
from_row => |row| node_execution_from_row(row),
|
||||
insert => |conn, rec| {
|
||||
conn.execute(
|
||||
"INSERT INTO node_executions (id, workflow_id, node_id, node_type, status, input_json, output_json, error_message, started_at, completed_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||
params![
|
||||
rec.id, rec.workflow_id, rec.node_id, rec.node_type, rec.status,
|
||||
rec.input_json, rec.output_json, rec.error_message, rec.started_at, rec.completed_at
|
||||
],
|
||||
)
|
||||
}
|
||||
);
|
||||
|
||||
// ============================================================
|
||||
// AI 相关 Repo (V3)
|
||||
// ============================================================
|
||||
|
||||
fn ai_provider_from_row(row: &Row<'_>) -> std::result::Result<AiProviderRecord, rusqlite::Error> {
|
||||
Ok(AiProviderRecord {
|
||||
id: row.get("id")?,
|
||||
name: row.get("name")?,
|
||||
provider_type: row.get("provider_type")?,
|
||||
api_key: row.get("api_key")?,
|
||||
base_url: row.get("base_url")?,
|
||||
default_model: row.get("default_model")?,
|
||||
models: row.get("models")?,
|
||||
is_default: row.get::<_, i32>("is_default")? != 0,
|
||||
config: row.get("config")?,
|
||||
created_at: row.get("created_at")?,
|
||||
updated_at: row.get("updated_at")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn ai_conversation_from_row(row: &Row<'_>) -> std::result::Result<AiConversationRecord, rusqlite::Error> {
|
||||
Ok(AiConversationRecord {
|
||||
id: row.get("id")?,
|
||||
title: row.get("title")?,
|
||||
messages: row.get("messages")?,
|
||||
provider_id: row.get("provider_id")?,
|
||||
model: row.get("model")?,
|
||||
created_at: row.get("created_at")?,
|
||||
updated_at: row.get("updated_at")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn ai_tool_execution_from_row(row: &Row<'_>) -> std::result::Result<AiToolExecutionRecord, rusqlite::Error> {
|
||||
Ok(AiToolExecutionRecord {
|
||||
id: row.get("id")?,
|
||||
conversation_id: row.get("conversation_id")?,
|
||||
tool_call_id: row.get("tool_call_id")?,
|
||||
tool_name: row.get("tool_name")?,
|
||||
arguments: row.get("arguments")?,
|
||||
result: row.get("result")?,
|
||||
status: row.get("status")?,
|
||||
risk_level: row.get("risk_level")?,
|
||||
requested_at: row.get("requested_at")?,
|
||||
executed_at: row.get("executed_at")?,
|
||||
decided_by: row.get("decided_by")?,
|
||||
})
|
||||
}
|
||||
|
||||
impl_repo!(
|
||||
/// AI 提供商配置表 CRUD
|
||||
AiProviderRepo,
|
||||
AiProviderRecord,
|
||||
"ai_providers",
|
||||
from_row => |row| ai_provider_from_row(row),
|
||||
insert => |conn, rec| {
|
||||
let is_default = if rec.is_default { 1i32 } else { 0i32 };
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO ai_providers (id, name, provider_type, api_key, base_url, default_model, models, is_default, config, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
|
||||
params![
|
||||
rec.id, rec.name, rec.provider_type, rec.api_key, rec.base_url,
|
||||
rec.default_model, rec.models, is_default, rec.config, rec.created_at, rec.updated_at
|
||||
],
|
||||
)
|
||||
}
|
||||
);
|
||||
|
||||
impl_repo!(
|
||||
/// AI 对话历史表 CRUD
|
||||
AiConversationRepo,
|
||||
AiConversationRecord,
|
||||
"ai_conversations",
|
||||
from_row => |row| ai_conversation_from_row(row),
|
||||
insert => |conn, rec| {
|
||||
conn.execute(
|
||||
"INSERT INTO ai_conversations (id, title, messages, provider_id, model, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
params![
|
||||
rec.id, rec.title, rec.messages, rec.provider_id, rec.model,
|
||||
rec.created_at, rec.updated_at
|
||||
],
|
||||
)
|
||||
}
|
||||
);
|
||||
|
||||
impl_repo!(
|
||||
/// AI 工具执行审计表 CRUD
|
||||
AiToolExecutionRepo,
|
||||
AiToolExecutionRecord,
|
||||
"ai_tool_executions",
|
||||
from_row => |row| ai_tool_execution_from_row(row),
|
||||
insert => |conn, rec| {
|
||||
conn.execute(
|
||||
"INSERT INTO ai_tool_executions (id, conversation_id, tool_call_id, tool_name, arguments, result, status, risk_level, requested_at, executed_at, decided_by)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
|
||||
params![
|
||||
rec.id, rec.conversation_id, rec.tool_call_id, rec.tool_name,
|
||||
rec.arguments, rec.result, rec.status, rec.risk_level,
|
||||
rec.requested_at, rec.executed_at, rec.decided_by
|
||||
],
|
||||
)
|
||||
}
|
||||
);
|
||||
|
||||
// ============================================================
|
||||
// AiConversationRepo 扩展方法(绕过 update_field 白名单限制)
|
||||
// ============================================================
|
||||
|
||||
impl AiConversationRepo {
|
||||
/// 整体更新对话记录(title + messages + updated_at)
|
||||
pub async fn update_full(&self, record: &AiConversationRecord) -> Result<bool> {
|
||||
let conn = self.conn.clone();
|
||||
let id = record.id.clone();
|
||||
let title = record.title.clone();
|
||||
let messages = record.messages.clone();
|
||||
let provider_id = record.provider_id.clone();
|
||||
let model = record.model.clone();
|
||||
let updated_at = record.updated_at.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let affected = guard.execute(
|
||||
"UPDATE ai_conversations SET title = ?1, messages = ?2, provider_id = ?3, model = ?4, updated_at = ?5 WHERE id = ?6",
|
||||
params![title, messages, provider_id, model, updated_at, id],
|
||||
).map_err(|e| Error::Storage(e.to_string()))?;
|
||||
Ok(affected > 0)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| Error::Storage(e.to_string()))?
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user