Compare commits
398
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a1b8ccda9 | ||
|
|
10cd9d2465 | ||
|
|
96bab8cd49 | ||
|
|
423c1df398 | ||
|
|
c2809bd927 | ||
|
|
b4b18e351b | ||
|
|
461354d7f4 | ||
|
|
3dcb3c6fea | ||
|
|
f894196924 | ||
|
|
78dd079344 | ||
|
|
cd77c1d6c7 | ||
|
|
3818a63522 | ||
|
|
31510d3555 | ||
|
|
18e50c5af7 | ||
|
|
8aefe3254c | ||
|
|
39e0eac39d | ||
|
|
8df4e33656 | ||
|
|
b37279f3a2 | ||
|
|
9c68f98d89 | ||
|
|
2a3a6cd25b | ||
|
|
9d83a06300 | ||
|
|
bcb0b82f33 | ||
|
|
e61fcdea79 | ||
|
|
73dec866a4 | ||
|
|
bdd723f62c | ||
|
|
da036db1f2 | ||
|
|
58834c9443 | ||
|
|
a95e205eb6 | ||
|
|
d918237327 | ||
|
|
1f5e6cce90 | ||
|
|
c71d0c4657 | ||
|
|
f4ca01f2f2 | ||
|
|
d7e626a813 | ||
|
|
b283622c64 | ||
|
|
40c4a21a7e | ||
|
|
a8c92e9619 | ||
|
|
c70faa7bb8 | ||
|
|
46521124de | ||
|
|
0aac14ed9a | ||
|
|
6bfcf01641 | ||
|
|
2441101978 | ||
|
|
e60bd38444 | ||
|
|
a2c69be4a2 | ||
|
|
c2382325cb | ||
|
|
76ace953be | ||
|
|
8c06076873 | ||
|
|
e36b55a2f6 | ||
|
|
19ecadf3f6 | ||
|
|
ece7cd3fa8 | ||
|
|
1ce1360a0b | ||
|
|
9520c198a2 | ||
|
|
9ff26f0c8f | ||
|
|
438d47bc9f | ||
|
|
7c252cff96 | ||
|
|
40392ebded | ||
|
|
d2f586fc52 | ||
|
|
71e548aafc | ||
|
|
0dfb50be9b | ||
|
|
04db8c0d3b | ||
|
|
70d6e98f82 | ||
|
|
03dc27bc14 | ||
|
|
28e6acbf3f | ||
|
|
9e81182b4f | ||
|
|
0f3c3ce1aa | ||
|
|
cb260aba4b | ||
|
|
8233ba095e | ||
|
|
d5766c2142 | ||
|
|
2eea0118db | ||
|
|
24d6803ecc | ||
|
|
63c5c3d4f0 | ||
|
|
7c76ee6a60 | ||
|
|
d4d3a8deaf | ||
|
|
8e1e56b3e5 | ||
|
|
26cc3c3df9 | ||
|
|
753de25696 | ||
|
|
6a50193b34 | ||
|
|
c83ea0c315 | ||
|
|
c10f0e46f0 | ||
|
|
83e86cd6b7 | ||
|
|
c6ebf15407 | ||
|
|
f4d31e884a | ||
|
|
c8c13d302a | ||
|
|
7dea2b86c4 | ||
|
|
e492f03191 | ||
|
|
91773da602 | ||
|
|
b994cf2c54 | ||
|
|
08e9d0292d | ||
|
|
d9e0ce73d1 | ||
|
|
7aa0da32f2 | ||
|
|
01d231c39d | ||
|
|
b2d44f85e9 | ||
|
|
8396849d04 | ||
|
|
f67e65633b | ||
|
|
890ceef0ec | ||
|
|
8e09112d59 | ||
|
|
3e3c6f4d0f | ||
|
|
31f7a9570b | ||
|
|
8710a09be2 | ||
|
|
ed678262b6 | ||
|
|
0b57126ac5 | ||
|
|
2d43f90e50 | ||
|
|
b4fafa0e9a | ||
|
|
d406a31722 | ||
|
|
c1e5b96d75 | ||
|
|
499aa49e49 | ||
|
|
3f4c914dbf | ||
|
|
db8c240d95 | ||
|
|
79ca55620b | ||
|
|
ea20a465d6 | ||
|
|
74cad6442b | ||
|
|
dd0afcf0d1 | ||
|
|
26d9a1af1e | ||
|
|
1e6db6f310 | ||
|
|
bf0db10ef7 | ||
|
|
7dcbdb1feb | ||
|
|
4417b3ddd3 | ||
|
|
b8416dea63 | ||
|
|
423acfdd00 | ||
|
|
2187ffe173 | ||
|
|
524120b110 | ||
|
|
e1dd0bc3a3 | ||
|
|
66c6553cdf | ||
|
|
0560137840 | ||
|
|
6ef15a0940 | ||
|
|
75c5e23523 | ||
|
|
3861e13b32 | ||
|
|
e3a8a8cffb | ||
|
|
57d9fd8ad3 | ||
|
|
04ce127a96 | ||
|
|
bff5ae9d7a | ||
|
|
7b5a2c83b0 | ||
|
|
4b445e96b8 | ||
|
|
a06786fd8e | ||
|
|
bacec20c3c | ||
|
|
7c62d8dbe7 | ||
|
|
cfccba863c | ||
|
|
73fe49b70c | ||
|
|
47a75a4d49 | ||
|
|
378047f1ff | ||
|
|
756d2840df | ||
|
|
4efadf4952 | ||
|
|
ab05be7e4c | ||
|
|
3f3f5053a4 | ||
|
|
c978d9cb60 | ||
|
|
efdf1e0282 | ||
|
|
df3ed55d01 | ||
|
|
04acc4297d | ||
|
|
e7cd035c62 | ||
|
|
9b89610e38 | ||
|
|
3e239a108f | ||
|
|
63779e8d69 | ||
|
|
bd2e94b54f | ||
|
|
861aad56f4 | ||
|
|
0ca6250ee1 | ||
|
|
671020b511 | ||
|
|
71811807d5 | ||
|
|
9962e17710 | ||
|
|
8a1675b58c | ||
|
|
0168617503 | ||
|
|
864a03d2e1 | ||
|
|
1d1111ebe7 | ||
|
|
378c363632 | ||
|
|
7cb32d1380 | ||
|
|
dfa38f6259 | ||
|
|
f57e25a277 | ||
|
|
4840f7ebc6 | ||
|
|
9753a6f902 | ||
|
|
13ecd7eba4 | ||
|
|
871416637a | ||
|
|
5ac815b9ca | ||
|
|
5a4c821629 | ||
|
|
4ad78abad6 | ||
|
|
7a1e80eac2 | ||
|
|
d31a27512c | ||
|
|
9407f821b9 | ||
|
|
256af4e736 | ||
|
|
5a1b7a871a | ||
|
|
6141a67b0e | ||
|
|
dedb9a8862 | ||
|
|
2473dcfe68 | ||
|
|
40224d603a | ||
|
|
cf00345c35 | ||
|
|
6bafdcd5a5 | ||
|
|
11f4978ec1 | ||
|
|
fbd8fae44b | ||
|
|
8bc5380ecb | ||
|
|
7eb2e25ed5 | ||
|
|
4df91ed155 | ||
|
|
4a9f77872e | ||
|
|
5a92e6056c | ||
|
|
e099eff4cb | ||
|
|
37040616bd | ||
|
|
cad76ffdf2 | ||
|
|
180d8e339f | ||
|
|
e7228231d1 | ||
|
|
9eb2995a74 | ||
|
|
6ba6daf188 | ||
|
|
65c0f6b2b6 | ||
|
|
4092a8d5bb | ||
|
|
97525a3143 | ||
|
|
fe780c0084 | ||
|
|
02c8d8e5ea | ||
|
|
6aa334fc9b | ||
|
|
3dfa043bb6 | ||
|
|
60cc163d62 | ||
|
|
9f75db5b15 | ||
|
|
795e05f42d | ||
|
|
92903702e2 | ||
|
|
b408b2d7a1 | ||
|
|
d19784a414 | ||
|
|
4ce6a859e7 | ||
|
|
c1f2fe16c8 | ||
|
|
75d5a7091a | ||
|
|
e68d283651 | ||
|
|
28de5d6143 | ||
|
|
71fdaac1b4 | ||
|
|
a91e950874 | ||
|
|
c480627ba6 | ||
|
|
5667da6cf4 | ||
|
|
ec9f0bf1ea | ||
|
|
8e4c7c68cf | ||
|
|
a031521776 | ||
|
|
864c696b70 | ||
|
|
f736f435bc | ||
|
|
caaabf0c15 | ||
|
|
ffb202e2e9 | ||
|
|
d1321af880 | ||
|
|
9e85cadb3f | ||
|
|
7f0edced01 | ||
|
|
a28c00b1e5 | ||
|
|
b7111fcabc | ||
|
|
8eb689af37 | ||
|
|
3f2cf5fa3a | ||
|
|
ea309c607b | ||
|
|
f1773dde4f | ||
|
|
ef77120912 | ||
|
|
f719546b12 | ||
|
|
fc249adf17 | ||
|
|
dffc4e4851 | ||
|
|
953a3fbba1 | ||
|
|
50c232643d | ||
|
|
b999f79fdf | ||
|
|
e4f7b432aa | ||
|
|
023377ab24 | ||
|
|
57d6a2d066 | ||
|
|
c76e77bd3c | ||
|
|
31aba79512 | ||
|
|
3afaff9efe | ||
|
|
d664bdc309 | ||
|
|
0e0c6862ba | ||
|
|
1a5d543fa1 | ||
|
|
2b08e32c26 | ||
|
|
7193901bc4 | ||
|
|
8c0ff80cd4 | ||
|
|
a69057a1ef | ||
|
|
dbd226b403 | ||
|
|
1ddf48d40f | ||
|
|
7d33442199 | ||
|
|
db99e7107c | ||
|
|
ba578dfe6c | ||
|
|
6b68aa63e8 | ||
|
|
c27fd75d20 | ||
|
|
b68dc363d4 | ||
|
|
1920bcbd38 | ||
|
|
c002f0b352 | ||
|
|
438f9692f2 | ||
|
|
c34ad062e7 | ||
|
|
484080ac12 | ||
|
|
143b859727 | ||
|
|
d7e2845881 | ||
|
|
20ec571dbb | ||
|
|
c4ba920cf5 | ||
|
|
468950616b | ||
|
|
bd9031d35d | ||
|
|
365af554da | ||
|
|
0b68c5bed9 | ||
|
|
7594a5b7f3 | ||
|
|
c8f35a7211 | ||
|
|
e222d38e7c | ||
|
|
1eb2ca7dc5 | ||
|
|
3943d68430 | ||
|
|
faa61a9ba5 | ||
|
|
b1d7deece1 | ||
|
|
e9e3578d26 | ||
|
|
42efb31bbf | ||
|
|
0dfaeb0f5b | ||
|
|
aa0701a2ea | ||
|
|
89cfbc7499 | ||
|
|
3d8b755229 | ||
|
|
249b3b9ea8 | ||
|
|
eee0f06e1a | ||
|
|
46246880b3 | ||
|
|
b18740405c | ||
|
|
d99a65f034 | ||
|
|
8fa3a93eec | ||
|
|
3f22fd3673 | ||
|
|
34944df764 | ||
|
|
c3b47b5771 | ||
|
|
acf0ed091d | ||
|
|
54ffc868b2 | ||
|
|
d5b0459a8a | ||
|
|
f1fb8655c3 | ||
|
|
d10d7ff330 | ||
|
|
61d3e9c148 | ||
|
|
840949ad05 | ||
|
|
3e93a2cd70 | ||
|
|
6e44878021 | ||
|
|
27b4268acb | ||
|
|
f40287bb00 | ||
|
|
1d580dccc3 | ||
|
|
9947e6c1fe | ||
|
|
3c9077b043 | ||
|
|
4483358f6b | ||
|
|
ae6d3d0043 | ||
|
|
f4fc0feff5 | ||
|
|
6833792de2 | ||
|
|
af2e085bea | ||
|
|
583058acd6 | ||
|
|
f04d47b09d | ||
|
|
fa410e6843 | ||
|
|
a9ee9b1f74 | ||
|
|
0a65ea2c5a | ||
|
|
91d80841ea | ||
|
|
c89742cb9a | ||
|
|
2af24f0db8 | ||
|
|
535525c2f6 | ||
|
|
3abdfb489f | ||
|
|
2c2b2d710e | ||
|
|
6771d396f0 | ||
|
|
0338210ba2 | ||
|
|
36ea090d9b | ||
|
|
89912155a6 | ||
|
|
dd0e059365 | ||
|
|
bce2fea4e5 | ||
|
|
3758bea0b5 | ||
|
|
3c2fa91fc6 | ||
|
|
78ef5dcc44 | ||
|
|
976ce29ad9 | ||
|
|
4dac9fe5f1 | ||
|
|
76aec62d36 | ||
|
|
27eb0a9f6b | ||
|
|
948d6e644b | ||
|
|
aa4d5213eb | ||
|
|
2780f6affa | ||
|
|
773ebb6ef6 | ||
|
|
15eaa0c637 | ||
|
|
f5f101d88a | ||
|
|
b19b0f3679 | ||
|
|
abc6880936 | ||
|
|
6b131f3dc1 | ||
|
|
78bd8d4344 | ||
|
|
093fe172f8 | ||
|
|
ff3f153d45 | ||
|
|
dfed588b54 | ||
|
|
63936e3016 | ||
|
|
7a2a2e13a2 | ||
|
|
49c6f3360a | ||
|
|
9b6353c561 | ||
|
|
0cfea6da22 | ||
|
|
7b5b62d3ae | ||
|
|
50aad375eb | ||
|
|
b72df78462 | ||
|
|
3c18dea45b | ||
|
|
8d18918e39 | ||
|
|
cb7e1fce82 | ||
|
|
cc3ef5bc1d | ||
|
|
996f1d9e5f | ||
|
|
696e34407c | ||
|
|
19b647e650 | ||
|
|
10629f88e8 | ||
|
|
b5e159f190 | ||
|
|
55542abf41 | ||
|
|
3fac0a618d | ||
|
|
9839935782 | ||
|
|
4a396f4f88 | ||
|
|
7adaf97377 | ||
|
|
6ffcba7e4d | ||
|
|
b157bc9077 | ||
|
|
f776336eb1 | ||
|
|
ad1821bc14 | ||
|
|
c4b02b5370 | ||
|
|
fee856129c | ||
|
|
ead490783f | ||
|
|
dcc3f0d230 | ||
|
|
d1b9488853 | ||
|
|
48c966f6f7 | ||
|
|
7e3fe1961a | ||
|
|
8723075360 | ||
|
|
53e1c1da77 | ||
|
|
e4ceb0015b | ||
|
|
22362a77b8 | ||
|
|
cf223fc08b | ||
|
|
b7b004dd68 | ||
|
|
b7b54eb2a6 | ||
|
|
6212002270 | ||
|
|
7cd833b1e5 | ||
|
|
5c539fe764 | ||
|
|
6e1485e4f9 |
@@ -1,5 +1,21 @@
|
||||
# musl linux 交叉编译(Windows 主机无 cc/musl-gcc,用 rust-lld self-contained link)
|
||||
# df-relay 云端部署:本地交叉产 static linux binary → scp 测试机普通运行
|
||||
#
|
||||
# ⚠️ 默认不要跑交叉编译,产物体积大且会污染 target/ 影响日常 build/rust-analyzer 性能。
|
||||
# 需要部署 df-relay 时再显式执行,并把产物隔离到独立 target dir:
|
||||
#
|
||||
# CARGO_TARGET_DIR=target-musl cargo build --release --target x86_64-unknown-linux-musl -p df-relay
|
||||
#
|
||||
# 这样 musl 产物落到 target-musl/(已 gitignore),与主 target/ 完全隔离。
|
||||
[target.x86_64-unknown-linux-musl]
|
||||
linker = "rust-lld"
|
||||
rustflags = ["-C", "link-self-contained=y"]
|
||||
|
||||
# ── 主机编译加速(Windows MSVC) ────────────────────────────
|
||||
# rust-lld 比 MSVC link.exe 快 3-5x,且 Rust 自带零安装
|
||||
[target.x86_64-pc-windows-msvc]
|
||||
linker = "rust-lld.exe"
|
||||
|
||||
# 并行编译单元,留部分核心给 Zed UI 和系统
|
||||
[build]
|
||||
jobs = 8
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
# docker build 排除(docker context = repo 根,仅 df-relay 编译需 workspace 源码)
|
||||
target
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
.git
|
||||
*.db
|
||||
*.log
|
||||
apps/df-miniapp/node_modules
|
||||
apps/df-miniapp/dist
|
||||
src-tauri/target
|
||||
+48
@@ -3,6 +3,9 @@ logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# 换装脚本(scripts/swap-install.ps1)运行期锁文件
|
||||
/swap-install.lock
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
@@ -25,12 +28,17 @@ unpackage/
|
||||
|
||||
# Rust / Cargo
|
||||
target/
|
||||
target-musl/ # df-relay 交叉编译产物隔离目录(见 .cargo/config.toml)
|
||||
!Cargo.lock
|
||||
|
||||
# Tauri
|
||||
src-tauri/target/
|
||||
src-tauri/Cargo.lock
|
||||
|
||||
# 签名私钥(应用内升级,设计 §七 高风险项:私钥绝不入仓)
|
||||
*.key
|
||||
*.key.pub
|
||||
|
||||
# Tauri 自动生成的 schema(每次构建变,可重建)
|
||||
src-tauri/gen/schemas/
|
||||
|
||||
@@ -56,10 +64,50 @@ coverage/
|
||||
tmp/
|
||||
.temp/
|
||||
|
||||
# 临时杂项(2026-08-05 误入的开发机临时物:网易爬虫/播放脚本/mp3,勿提交)
|
||||
archive/
|
||||
|
||||
# AI 编排脚本(Claude Code Workflow 临时产物,非产品代码)
|
||||
workflows/
|
||||
|
||||
# 临时分析脚本(团队分析/报告生成,非产品代码根目录级)
|
||||
/analysis_*.txt
|
||||
/analysis_*.py
|
||||
/core_domains.txt
|
||||
/core_domains.py
|
||||
/final_report.txt
|
||||
/grand_output.txt
|
||||
/im_live_verify.txt
|
||||
/liang_deep.py
|
||||
/liangxianyou_deep.txt
|
||||
/member_profiles.py
|
||||
/read_all.py
|
||||
/report_p2.txt
|
||||
/report_wall.txt
|
||||
/team_analysis.py
|
||||
/team_deep_analysis.py
|
||||
/team_member_profiles.txt
|
||||
/team_report_final.txt
|
||||
/tmp_*.py
|
||||
/tmp_*.txt
|
||||
/verify_im_live*.py
|
||||
|
||||
# MCP/SQLite 测试库(mcp_test2 --db 残留)
|
||||
devflow-dev.db
|
||||
devflow-dev.db-shm
|
||||
devflow-dev.db-wal
|
||||
|
||||
# 微信小程序 appid(真实 appid 本地保留,不提交仓库)
|
||||
apps/df-miniapp/src/manifest.json
|
||||
# 微信小程序 appid(真实 appid 本地保留,不提交仓库)
|
||||
temp/
|
||||
|
||||
# 本地工具链缓存(protoc 等,不入库)
|
||||
.tools/
|
||||
|
||||
# df-im-gateway 运行时数据(冒烟测试残留)
|
||||
crates/df-im-gateway/data/
|
||||
|
||||
# Playwright 运行产物
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# DevFlow 提交规范
|
||||
|
||||
## 提交注释格式
|
||||
|
||||
```
|
||||
<动词>: <简短描述>
|
||||
|
||||
<详细说明(可选,仅在需要补充上下文时)>
|
||||
```
|
||||
|
||||
## 动词词表
|
||||
|
||||
| 动词 | 使用场景 |
|
||||
|------|---------|
|
||||
| `新增` | 新功能、新特性、新文件 |
|
||||
| `修复` | Bug 修复、问题修正 |
|
||||
| `更新` | 文档更新、配置变更、依赖升级、非功能性的已有内容修改 |
|
||||
| `重构` | 代码重构(行为不变的结构调整) |
|
||||
| `删除` | 移除功能、删除文件 |
|
||||
| `优化` | 性能优化、体验改进 |
|
||||
| `合并` | 分支合并 |
|
||||
|
||||
## 规范要求
|
||||
|
||||
1. **动词 + 冒号 + 空格**:`修复: 审批按钮永久转圈`
|
||||
2. **主题行不超过 72 字符**
|
||||
3. **主语用中文**,描述清楚改动内容
|
||||
4. **不要用内部代号**:禁止 Batch/P1/P2/L1/F-XX 等代号
|
||||
5. **不要用分隔线**:禁止 `---` 或 `===` 等装饰线
|
||||
6. **不要写无意义提交**:如"更新"、"修复"、"提交"等无法看出内容的单字提交
|
||||
7. **改动较多的提交**:主题行概括后,空一行用列表展开具体条目
|
||||
8. **文档和代码合并提交**:文档更新和对应代码改动放在同一个提交中,不分散
|
||||
|
||||
## 示例
|
||||
|
||||
```
|
||||
修复: 审批按钮永久转圈和等待计数不停
|
||||
```
|
||||
|
||||
```
|
||||
新增: 文件浏览器增强(窗口分离/Git 变更/行号/Diff/分页提交历史)
|
||||
|
||||
- 窗口分离: FileExplorer 可弹出独立 Tauri 窗口
|
||||
- 行号显示: 文件预览左侧显示行号列
|
||||
- Diff 视图: 有 Git 变更的文件可切换 diff 红绿视图
|
||||
- 提交详情: 点击提交查看变更文件列表及文件级 diff
|
||||
- 中文编码修复: git 命令注入 LC_ALL 环境变量
|
||||
```
|
||||
@@ -0,0 +1,29 @@
|
||||
# DevFlow 开发规则
|
||||
|
||||
## 技术栈
|
||||
|
||||
- 桌面端: Tauri v2 + Vue 3 + TypeScript + Pinia
|
||||
- 后端: Rust Workspace (8 crate) / SQLite
|
||||
- 包管理: bun (前端) / cargo (Rust)
|
||||
- 路径: `E:/wk-lab/devflow`
|
||||
|
||||
## 架构原则
|
||||
|
||||
1. **治本不治标**:修复问题时找到根因,不做表面补丁
|
||||
2. **DRY 原则**:不重复代码,抽象出可复用逻辑
|
||||
3. **长远考虑**:不追求局部最优,考虑整体架构演进
|
||||
4. **修改收敛**:同类改动合并到一个批次提交,不零散提交
|
||||
|
||||
## 代码规范
|
||||
|
||||
1. **Rust 后端**:提交前运行 `cd src-tauri && cargo check`
|
||||
2. **前端 TypeScript/Vue**:提交前运行 `npx vue-tsc --noEmit`
|
||||
3. **完整构建**:提交前运行 `npx vite build`(验证构建不报错)
|
||||
4. **TypeScript 文件名**:camelCase(`fileExplorer.ts`)
|
||||
5. **Vue 组件文件名**:PascalCase(`FileExplorer.vue`)
|
||||
|
||||
## i18n
|
||||
|
||||
- 中英文双语言同步:`src/i18n/en/` 和 `src/i18n/zh-CN/`
|
||||
- 新增组件时同时添加对应语言的翻译文件
|
||||
- 国际化文件自动聚合(`src/i18n/*/index.ts` 自动导入所有模块文件)
|
||||
@@ -0,0 +1,181 @@
|
||||
{
|
||||
// ── 项目级 Zed 配置: rust-analyzer 性能极限优化 ──────────────
|
||||
//
|
||||
// workspace: src-tauri + 7 个 df-* crate 的 Rust Monorepo
|
||||
// flcheck 单次 600+ 行 artifact JSON → Zed 解析+渲染冻结 UI
|
||||
// 核心策略: 关保存即 check · 限制检查范围 · 裁切 LSP 负载 · 排 IO 争抢
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
"lsp": {
|
||||
"rust-analyzer": {
|
||||
"initialization_options": {
|
||||
// ── cargo 编译参数 ───────────────────────────────────
|
||||
"cargo": {
|
||||
"features": [],
|
||||
"allTargets": false, // 只为 host target 解析
|
||||
"noDefaultFeatures": false,
|
||||
"targetDir": null,
|
||||
"buildScripts": {
|
||||
"enable": true,
|
||||
"invocationStrategy": "once", // 只跑一次,不反复
|
||||
"rerun": "on-save"
|
||||
},
|
||||
"extraArgs": []
|
||||
},
|
||||
|
||||
// ── check / flycheck ────────────────────────────────
|
||||
"check": {
|
||||
"onSave": false, // ★ 核心!关保存即 check
|
||||
"workspace": false, // 手动 check 也只查当前 crate
|
||||
"command": "check", // 用 check,非 clippy
|
||||
"invocationStrategy": "once", // 只跑一轮,不反复
|
||||
"features": [],
|
||||
"allTargets": false,
|
||||
"ignore": [ // 跳过不常用的目录
|
||||
"benches",
|
||||
"examples",
|
||||
"xtask",
|
||||
"tests"
|
||||
]
|
||||
},
|
||||
|
||||
// ── 缓存预热(关掉,省首次启动 CPU 爆发) ──────────────
|
||||
"cachePriming": {
|
||||
"enable": false
|
||||
},
|
||||
|
||||
// ── 诊断 ────────────────────────────────────────────
|
||||
"diagnostics": {
|
||||
"enable": true,
|
||||
"experimental": {
|
||||
"enable": false
|
||||
},
|
||||
"disabled": [ // 关掉非关键诊断,降 CPU
|
||||
"unresolved-proc-macro",
|
||||
"inactive-code",
|
||||
"macro-error"
|
||||
]
|
||||
},
|
||||
|
||||
// ── proc-macro(必须开,否则 tauri 全报红) ────────────
|
||||
"procMacro": {
|
||||
"enable": true,
|
||||
"attributes": {
|
||||
"enable": true
|
||||
}
|
||||
},
|
||||
|
||||
// ── 符号检索 ────────────────────────────────────────
|
||||
"workspace": {
|
||||
"symbol": {
|
||||
"search": {
|
||||
"kind": "only_types",
|
||||
"limit": 64
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ── 补全(限流减少 LSP 计算量) ──────────────────────
|
||||
"completion": {
|
||||
"autoclose": true,
|
||||
"autoimport": {
|
||||
"enable": true
|
||||
},
|
||||
"callable": {
|
||||
"snippets": "fill_arguments"
|
||||
},
|
||||
"fullFunction": {
|
||||
"enable": false
|
||||
},
|
||||
"limit": 200, // 补全条目上限
|
||||
"postfix": {
|
||||
"enable": false // 关 postfix snippet 减少候选
|
||||
}
|
||||
},
|
||||
|
||||
// ── 内联提示(大文件里关掉多数减少 UI 重绘) ──────────
|
||||
"inlayHints": {
|
||||
"bindingModeHints": {
|
||||
"enable": false
|
||||
},
|
||||
"chainingHints": {
|
||||
"enable": false
|
||||
},
|
||||
"closingBraceHints": {
|
||||
"enable": false
|
||||
},
|
||||
"closureReturnTypeHints": {
|
||||
"enable": "never"
|
||||
},
|
||||
"constructorHints": "never",
|
||||
"discriminantHints": {
|
||||
"enable": "never"
|
||||
},
|
||||
"expressionAdjustmentHints": {
|
||||
"mode": "never"
|
||||
},
|
||||
"implicitDrops": "never",
|
||||
"lifetimeElisionHints": {
|
||||
"enable": "skip_trivial"
|
||||
},
|
||||
"maxLength": 100,
|
||||
"parameterHints": {
|
||||
"enable": true
|
||||
},
|
||||
"reborrowHints": {
|
||||
"enable": "never"
|
||||
},
|
||||
"renderColons": true,
|
||||
"typeHints": {
|
||||
"enable": true,
|
||||
"hideNamedConstructor": false
|
||||
}
|
||||
},
|
||||
|
||||
// ── 语义高亮(大文件关掉字符串部分减少重绘) ──────────
|
||||
"semanticHighlights": {
|
||||
"strings": {
|
||||
"enable": false
|
||||
}
|
||||
},
|
||||
|
||||
// ── 高亮关联(关掉减少 LSP 图构建) ──────────────────
|
||||
"highlightRelated": {
|
||||
"references": {
|
||||
"enable": false
|
||||
}
|
||||
},
|
||||
|
||||
// ── 测试解释器(关掉) ───────────────────────────────
|
||||
"interpret": {
|
||||
"tests": false
|
||||
},
|
||||
|
||||
// ── rust-analyzer 文件扫描排除 ─────────────────────
|
||||
"files": {
|
||||
"excludeDirs": [
|
||||
".cargo",
|
||||
"target",
|
||||
"target-musl",
|
||||
"node_modules",
|
||||
"dist",
|
||||
".zed",
|
||||
".git"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ── Zed 文件监视排除(减少 File Watcher IO 争抢) ────────────
|
||||
// 和全局 ~/.config/zed/settings.json 的 file_scan_exclusions 叠加
|
||||
"file_scan_exclusions": [
|
||||
"**/target/**",
|
||||
"**/target-musl/**",
|
||||
"**/node_modules/**",
|
||||
"**/dist/**",
|
||||
"**/.cargo/**",
|
||||
"**/.git/**",
|
||||
"**/build/**"
|
||||
]
|
||||
}
|
||||
+257
-32
@@ -1,6 +1,6 @@
|
||||
# DevFlow — 产研全流程工作流平台
|
||||
|
||||
> 版本: v0.1.0 | 创建: 2026-06-10 | 状态: 设计阶段
|
||||
> 版本: v0.1.0 | 创建: 2026-06-10 | 状态: Phase 2 本地优先开发流程验证
|
||||
|
||||
## 一、项目定位
|
||||
|
||||
@@ -19,7 +19,9 @@
|
||||
- **经验进化 (Evolution)**:开发过程中的模式自动沉淀为知识库(审查规则/Prompt模板/踩坑经验),持续进化复用
|
||||
- **阶段插件**:想法→需求→编码→测试→发布,阶段即模板
|
||||
|
||||
### 层级模型
|
||||
### 层级模型(业务层级)
|
||||
|
||||
DevFlow 的业务抽象分三层,自上而下层层实例化:
|
||||
|
||||
```
|
||||
💡 Idea Pool (想法池) — 独立运转,持续捕捉和评估
|
||||
@@ -31,6 +33,40 @@
|
||||
└→ 🎯 Release (发布) — 合并多个 Task → 集成测试 → 发布
|
||||
```
|
||||
|
||||
### 层级模型(执行层级)
|
||||
|
||||
Workflow DAG 的执行层进一步拆分为三层,这是 AI Factory 的核心抽象:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ 模板层 (Template) │
|
||||
│ "应该做什么" — 阶段蓝图、行业最佳实践 │
|
||||
│ │
|
||||
│ 职责: 定义节点拓扑 + 产出物规范 + 质量门禁 │
|
||||
│ 生命周期: 长期存在,跨项目复用 │
|
||||
│ 存储: YAML 文件 / DB 模板库 │
|
||||
├──────────────────────────────────────────────────┤
|
||||
│ 工作流层 (Workflow) │
|
||||
│ "怎么执行" — DAG 实例、状态机、运行时 │
|
||||
│ │
|
||||
│ 职责: 拓扑排序 + 节点调度 + 状态流转 + 持久化 │
|
||||
│ 生命周期: 随项目启动/结束,单次执行后归档 │
|
||||
│ 载体: df-workflow (DAG + Executor + StateMachine) │
|
||||
├──────────────────────────────────────────────────┤
|
||||
│ 人设层 (Persona) │
|
||||
│ "谁来做" — Agent 角色、能力边界、行为风格 │
|
||||
│ │
|
||||
│ 职责: 定义 system prompt + 可用工具 + 输出格式 │
|
||||
│ 生命周期: 长期存在,跨节点复用 │
|
||||
│ 注入点: AINode 执行时载入对应人设 │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**关键设计原则**:三层各自独立演化,在 AINode 执行时交汇。
|
||||
- 模板 = 可复用的蓝图(定义节点拓扑 + 建议人设 + 质量门禁)
|
||||
- 工作流 = 模板的运行时实例(含状态、数据绑定、执行记录)
|
||||
- 人设 = Agent 的角色卡(system prompt + 工具集 + 行为规则)
|
||||
|
||||
### AI Working 定位体系
|
||||
|
||||
DevFlow 的终极交互模型是 **AI 驱动 (AI Working)**:**AI 是系统的主要操作者,人是监督者与决策者**。
|
||||
@@ -119,9 +155,9 @@ DevFlow 的终极交互模型是 **AI 驱动 (AI Working)**:**AI 是系统的
|
||||
devflow/
|
||||
├── Cargo.toml # Workspace 根
|
||||
├── crates/
|
||||
│ ├── df-core/ # 核心类型、错误、常量、事件
|
||||
│ ├── df-types/ # 核心类型、错误、常量、事件、augmentation
|
||||
│ ├── df-workflow/ # 工作流 DAG 引擎 (核心)
|
||||
│ ├── df-nodes/ # 内置节点集合 (AI/Script/Human)
|
||||
│ ├── df-nodes/ # 内置节点集合 (AI/Script/Human/Git/HTTP/Notify/Subflow/Docker/自审/推进)
|
||||
│ ├── df-ai/ # AI 编排层 (Multi-Provider/Coordinator)
|
||||
│ ├── df-execute/ # 执行运行时 (Shell)
|
||||
│ ├── df-storage/ # 存储层 (SQLite)
|
||||
@@ -284,8 +320,9 @@ pub struct Decision {
|
||||
|
||||
- **LlmProvider trait**:统一接口,各模型实现(`provider.rs`)✅
|
||||
- **ModelRouter**:按任务类型路由到最优模型 + 降级链 — ❌ 已删(R-PD-7),模型选择改由调用方在 `LlmProvider` 实现间直接指定
|
||||
- **AgentCoordinator**:多 Agent 协作(Planner/Coder/Reviewer/Fixer)— ⚠️ 骨架空壳(`coordinator.rs`,仅单元结构体 + 日志,未实现实际协调)
|
||||
- **ContextManager**:Token 预算管理(`context.rs`,完整实现)✅
|
||||
- **AgentCoordinator**:多 Agent 协作(Plan DAG 拆解 + JoinSet 层内并行 + Token 预算池 + Reviewer 仲裁) — ✅ 已实现
|
||||
- **Git Worktree 隔离**:子 Agent 文件隔离(git worktree + 独立分支) — ✅ 已实现
|
||||
- **ContextManager**:Token 预算管理(`context/mod.rs`,拆分为 sanitize/manager_tests 子模块)✅
|
||||
- **ToolRegistry**:工具注册(`ai_tools.rs`,供 Agent 调用)✅
|
||||
|
||||
### 5.5 内置节点 (df-nodes)
|
||||
@@ -293,23 +330,25 @@ pub struct Decision {
|
||||
| 节点 | 功能 | 状态 |
|
||||
|------|------|------|
|
||||
| AINode | 调用 LLM,支持流式输出、工具调用 | ✅ 已实现 |
|
||||
| AiSelfReviewNode | AI 对任务产出自审(verdict 闸门) | ✅ 已实现 |
|
||||
| ScriptNode | Shell/脚本执行 | ✅ 已实现 |
|
||||
| HumanNode | 人工审批/确认 (阻塞) | ✅ 已实现 |
|
||||
| DockerNode | Docker 容器操作 | ❌ 未实现 |
|
||||
| GitNode | Git 操作 (libgit2) | ❌ 未实现 |
|
||||
| NotifyNode | 通知 (桌面/飞书/Webhook) | ❌ 未实现 |
|
||||
| HTTPNode | HTTP 请求 | ❌ 未实现 |
|
||||
| SubflowNode | 嵌套子工作流 | ❌ 未实现 |
|
||||
| TaskAdvanceNode | 任务状态推进链唯一 status 写入路径 | ✅ 已实现 |
|
||||
| GitNode | Git 操作(branch/checkout/commit/merge/push/status/log) | ✅ 已实现 |
|
||||
| HTTPNode | HTTP 请求(GET/POST/PUT/DELETE) | ✅ 已实现 |
|
||||
| NotifyNode | 通知(桌面/Webhook) | ✅ 已实现 |
|
||||
| SubflowNode | 嵌套子工作流(深度限制防递归) | ✅ 已实现 |
|
||||
| DockerNode | Docker 容器内构建/测试(环境检测+授权) | ✅ 已实现 |
|
||||
|
||||
> `crates/df-nodes/src/` 实际仅 ai_node / script_node / human_node 3 文件(Docker/Git/Notify/HTTP/Subflow 为设计预留,尚未实现)。详见 [df-nodes 模块文档](docs/03-模块文档/df-nodes-节点集合-2026-06-12.md)。
|
||||
> `crates/df-nodes/src/` 含 10 个 `impl Node`(ai/ai_self_review/script/human/task_advance/git/http/notify/subflow/docker,全部已实现)。
|
||||
|
||||
## 六、数据模型
|
||||
|
||||
### SQLite 表结构
|
||||
|
||||
> 真相源:`crates/df-storage/src/migrations.rs`(V1-V13,幂等迁移)。下表对齐真实 schema,字段/类型/默认值/外键与迁移文件一致。Rust 结构体见 `crates/df-storage/src/models.rs`。
|
||||
> 真相源:`crates/df-storage/src/migrations.rs`(V1-V41,幂等迁移)。下表对齐真实 schema,字段/类型/默认值/外键与迁移文件一致。Rust 结构体见 `crates/df-storage/src/models.rs`。
|
||||
|
||||
共 **13 张业务表**(另含内部表 `schema_version` 记录迁移版本)。
|
||||
共 **24 张业务表**(另含内部表 `schema_version` 记录迁移版本)。
|
||||
|
||||
```sql
|
||||
-- 想法池 (V1 建表 / V2 补 promoted_to + ai_analysis + scores)
|
||||
@@ -325,6 +364,8 @@ CREATE TABLE ideas (
|
||||
promoted_to TEXT, -- 晋升后的 project_id (V2)
|
||||
ai_analysis TEXT, -- AI 分析结果 JSON (V2)
|
||||
scores TEXT, -- 多维评分 JSON: feasibility/impact/urgency/overall (V2)
|
||||
related_ids TEXT, -- 关联灵感 ID JSON 数组 (V24)
|
||||
deleted_at TEXT, -- 软删回收站 (V28)
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
@@ -343,18 +384,27 @@ CREATE TABLE projects (
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- 任务 (V1 建表 / V2 补 workflow_def_id + base_branch)
|
||||
-- 任务 (V1 建表 / V2 补 workflow_def_id + base_branch / V20 补 idea_id / V14 补 deleted_at /
|
||||
-- V15 补 review_rounds / V17 补 output_json / V29 补 queue+parent_id+content_json / V41 补 module_id)
|
||||
CREATE TABLE tasks (
|
||||
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,
|
||||
priority INTEGER NOT NULL DEFAULT 2, -- 0=critical..3=low,默认 2=medium
|
||||
branch_name TEXT,
|
||||
assignee TEXT,
|
||||
workflow_def_id TEXT, -- 关联的工作流定义 ID (V2)
|
||||
base_branch TEXT, -- 基础分支 (V2)
|
||||
idea_id TEXT REFERENCES ideas(id), -- 关联灵感 (V20)
|
||||
review_rounds INTEGER NOT NULL DEFAULT 0, -- review 退回累计轮数 (V15)
|
||||
output_json TEXT, -- AiNode 自审闭环产出 (V17)
|
||||
queue TEXT NOT NULL DEFAULT 'todo', -- 管理池 backlog/todo/decision/active/done (V29)
|
||||
parent_id TEXT REFERENCES tasks(id), -- 父任务(限 1 级嵌套) (V29)
|
||||
content_json TEXT, -- 结构化需求规格 JSON (V29)
|
||||
module_id TEXT REFERENCES project_modules(id) ON DELETE SET NULL, -- 关联工程 (V41)
|
||||
deleted_at TEXT, -- 软删回收站 (V14)
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
@@ -395,7 +445,8 @@ CREATE TABLE workflow_executions (
|
||||
project_id TEXT, -- 关联项目 ID (V2)
|
||||
task_id TEXT, -- 关联任务 ID (V2)
|
||||
created_at TEXT NOT NULL,
|
||||
completed_at TEXT
|
||||
completed_at TEXT,
|
||||
updated_at TEXT -- 更新时间戳,排序/增量同步 (V33)
|
||||
);
|
||||
|
||||
-- 节点执行 (V1 建表) — 工作流执行过程中每个节点的运行快照
|
||||
@@ -412,7 +463,7 @@ CREATE TABLE node_executions (
|
||||
completed_at TEXT
|
||||
);
|
||||
|
||||
-- AI 提供商配置 (V9 建表) — 多 Provider 统一抽象
|
||||
-- AI 提供商配置 (V9 建表 / V18 补 model_configs / V19 补 enabled + weight)
|
||||
CREATE TABLE ai_providers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
@@ -423,29 +474,38 @@ CREATE TABLE ai_providers (
|
||||
models TEXT, -- JSON array of model names
|
||||
is_default INTEGER NOT NULL DEFAULT 0,
|
||||
config TEXT, -- JSON extra config
|
||||
model_configs TEXT, -- 模型能力配置 JSON (V18)
|
||||
enabled INTEGER NOT NULL DEFAULT 1, -- 负载均衡池开关 (V19)
|
||||
weight INTEGER NOT NULL DEFAULT 50, -- 负载均衡权重 (V19)
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- AI 对话 (V3 建表 / V4 补 archived / V5 补 prompt_tokens + completion_tokens / V6 补 models)
|
||||
-- AI 对话 (V3 建表 / V4 补 archived / V5 补 prompt_tokens + completion_tokens / V6 补 models /
|
||||
-- V16 补 pinned / V32 补 pinned_goals / V33 补 pending_approvals)
|
||||
CREATE TABLE ai_conversations (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT,
|
||||
messages TEXT NOT NULL DEFAULT '[]', -- JSON array of ChatMessage
|
||||
messages TEXT NOT NULL DEFAULT '[]', -- JSON array of ChatMessage (V21 起消息拆分到 ai_messages)
|
||||
provider_id TEXT,
|
||||
model TEXT,
|
||||
models TEXT, -- 用过的所有 model JSON 数组字符串 (去重, V6)
|
||||
archived INTEGER NOT NULL DEFAULT 0, -- 是否归档(侧栏折叠展示, V4)
|
||||
pinned INTEGER NOT NULL DEFAULT 0, -- 侧栏置顶 (V16)
|
||||
prompt_tokens INTEGER, -- 输入 token 累计 (流式 usage 落库, V5)
|
||||
completion_tokens INTEGER, -- 输出 token 累计 (流式 usage 落库, V5)
|
||||
pinned_goals TEXT DEFAULT '[]', -- 目标钉扎持久化 (V32)
|
||||
pending_approvals TEXT DEFAULT '{}', -- 挂起审批快照,重启恢复 (V33)
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- AI 工具执行审计 (V9 建表) — Agent 工具调用的审批/执行留痕
|
||||
-- AI 工具执行审计 (V9 建表 / V21 补 message_id / V27 状态统一 executed→completed / V36 补 subtask_id)
|
||||
-- Agent 工具调用的审批/执行留痕
|
||||
CREATE TABLE ai_tool_executions (
|
||||
id TEXT PRIMARY KEY,
|
||||
conversation_id TEXT,
|
||||
message_id TEXT, -- 关联消息 (V21)
|
||||
tool_call_id TEXT NOT NULL,
|
||||
tool_name TEXT NOT NULL,
|
||||
arguments TEXT NOT NULL,
|
||||
@@ -454,7 +514,8 @@ CREATE TABLE ai_tool_executions (
|
||||
risk_level TEXT NOT NULL DEFAULT 'medium', -- low/medium/high
|
||||
requested_at TEXT NOT NULL,
|
||||
executed_at TEXT,
|
||||
decided_by TEXT -- human/auto
|
||||
decided_by TEXT, -- human/auto
|
||||
subtask_id TEXT -- 归属子任务 (V36)
|
||||
);
|
||||
|
||||
-- 知识条目 (V7 建表 / V8 补 embedding / V10 补 reasoning) — 经验沉淀基本单元, 共享记忆层
|
||||
@@ -473,6 +534,7 @@ CREATE TABLE knowledges (
|
||||
source_ref TEXT, -- 来源实体引用(如 conv:{id})
|
||||
reasoning TEXT, -- AI 提炼判断依据("为何值得沉淀"), 手动录入为 NULL (V10)
|
||||
embedding BLOB, -- Vec<f32> 小端字节序列化, NULL=未嵌入走 LIKE 降级 (V8)
|
||||
embedding_status TEXT, -- 嵌入状态,失败可补偿重试 (V23)
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
@@ -494,23 +556,182 @@ CREATE TABLE app_settings (
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- AI 消息 (V9 新库建表 / V21 老库补建 / V36 补 subtask_id / V38 补 token 列 / V39 补 cache/reason 列)
|
||||
-- V21 起 ChatMessage 逐条拆行存储,支撑消息级 token / 分页 / 溯源
|
||||
CREATE TABLE ai_messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
conversation_id TEXT NOT NULL,
|
||||
seq INTEGER NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
parts TEXT,
|
||||
tool_call_id TEXT,
|
||||
tool_calls TEXT,
|
||||
model TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
reasoning_content TEXT,
|
||||
timestamp INTEGER,
|
||||
subtask_id TEXT, -- 归属子任务 (V36)
|
||||
created_at TEXT NOT NULL,
|
||||
prompt_tokens INTEGER, -- 消息级输入 token (V38)
|
||||
completion_tokens INTEGER, -- 消息级输出 token (V38)
|
||||
prompt_cache_hit_tokens INTEGER, -- 缓存命中 token (V39)
|
||||
prompt_cache_miss_tokens INTEGER, -- 缓存未命中 token (V39)
|
||||
reasoning_tokens INTEGER, -- 思考 token (V39)
|
||||
UNIQUE(conversation_id, seq)
|
||||
);
|
||||
|
||||
-- 灵感评估历史 (V22 建表 / V25 补唯一约束) — 追加型审计,version 单调递增
|
||||
CREATE TABLE idea_evaluations (
|
||||
id TEXT PRIMARY KEY,
|
||||
idea_id TEXT NOT NULL,
|
||||
version INTEGER NOT NULL,
|
||||
ai_analysis TEXT,
|
||||
scores TEXT,
|
||||
score REAL,
|
||||
evaluated_by TEXT,
|
||||
evaluated_at TEXT NOT NULL,
|
||||
UNIQUE(idea_id, version) -- 评估版本并发重复兜底 (V25)
|
||||
);
|
||||
|
||||
-- 任务横向关联 (V29 建表) — 任务间 depends_on/blocks/relates_to,循环检测走应用层
|
||||
CREATE TABLE task_links (
|
||||
id TEXT PRIMARY KEY,
|
||||
source_id TEXT NOT NULL REFERENCES tasks(id),
|
||||
target_id TEXT NOT NULL REFERENCES tasks(id),
|
||||
link_type TEXT NOT NULL,
|
||||
remark TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- 项目事件流 (V30 建表) — 跨实体统一事件,追加型审计(只增不改)
|
||||
CREATE TABLE project_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
event_type TEXT NOT NULL,
|
||||
entity_type TEXT,
|
||||
entity_id TEXT,
|
||||
from_state TEXT,
|
||||
to_state TEXT,
|
||||
context_json TEXT,
|
||||
source TEXT, -- ai/human/system
|
||||
conversation_id TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- 项目基础设施配置 (V31 建表) — 记录"项目用了什么基础设施",不存敏感凭证
|
||||
CREATE TABLE project_services (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
name TEXT NOT NULL,
|
||||
service_type TEXT NOT NULL, -- mysql/postgresql/sqlite/redis/mongodb/mq/api/other
|
||||
endpoint TEXT,
|
||||
config_json TEXT,
|
||||
environment TEXT NOT NULL DEFAULT 'development',
|
||||
remark TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- 工程 (V34 建表 / V40 补 description + status) — 项目下多工程(Monorepo/微服务/前后端分离)
|
||||
CREATE TABLE project_modules (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
git_url TEXT,
|
||||
stack TEXT,
|
||||
auto_detected BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
description TEXT, -- 工程职责描述 (V40)
|
||||
status TEXT, -- active/archived (V40)
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- 工程依赖 (V35 建表) — 工程间依赖边,支撑依赖图
|
||||
CREATE TABLE module_dependencies (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
from_module_id TEXT NOT NULL REFERENCES project_modules(id),
|
||||
to_module_id TEXT NOT NULL REFERENCES project_modules(id),
|
||||
dep_type TEXT NOT NULL DEFAULT 'library', -- library/api/mq/shared/custom
|
||||
label TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- 多 Agent 并行执行 (V36 建表) — ai_plans/ai_subtasks/ai_conflicts 三表 + ai_messages/ai_tool_executions 补 subtask_id
|
||||
CREATE TABLE ai_plans (
|
||||
id TEXT PRIMARY KEY,
|
||||
conversation_id TEXT NOT NULL,
|
||||
user_message_id TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'planning',
|
||||
subtask_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
completed_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE ai_subtasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
plan_id TEXT NOT NULL REFERENCES ai_plans(id),
|
||||
persona_id TEXT,
|
||||
intent TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
layer INTEGER NOT NULL DEFAULT 0,
|
||||
deps TEXT,
|
||||
branch TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
completed_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE ai_conflicts (
|
||||
id TEXT PRIMARY KEY,
|
||||
plan_id TEXT NOT NULL REFERENCES ai_plans(id),
|
||||
file_path TEXT NOT NULL DEFAULT '',
|
||||
conflict_type TEXT NOT NULL DEFAULT 'file',
|
||||
subtask_a TEXT,
|
||||
subtask_b TEXT,
|
||||
diff_a TEXT,
|
||||
diff_b TEXT,
|
||||
resolution TEXT NOT NULL DEFAULT 'pending',
|
||||
resolved_by TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
resolved_at TEXT
|
||||
);
|
||||
|
||||
-- 对话版本化快照 (V37 建表)
|
||||
CREATE TABLE conversation_checkpoints (
|
||||
id TEXT PRIMARY KEY,
|
||||
conv_id TEXT NOT NULL,
|
||||
snapshot TEXT NOT NULL,
|
||||
token_total INTEGER NOT NULL,
|
||||
label TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- 内部表: schema_version (version INTEGER PRIMARY KEY) — 迁移版本记录
|
||||
```
|
||||
|
||||
## 七、阶段模板
|
||||
## 七、三层模型:模板 → 工作流 → 人设
|
||||
|
||||
5 个内置阶段作为工作流模板(YAML 定义),用户可自定义。
|
||||
> 本章详细设计已迁至专项文档,详见 [docs/02-架构设计/专项设计/三层模型-流程模板与人设体系-2026-06-28.md](./docs/02-架构设计/专项设计/三层模型-流程模板与人设体系-2026-06-28.md)。此处仅保留摘要性定义。
|
||||
|
||||
- 💡 **想法**:市场分析 → 竞品调研 → 可行性评分
|
||||
- 📋 **需求**:AI 生成 PRD → 人工审阅 → 任务拆解
|
||||
- 💻 **编码**:AI 编码 → 代码审查 → 自动修复
|
||||
- 🧪 **测试**:运行测试 → AI 分析失败 → 回归验证
|
||||
- 🚀 **发布**:构建 → 人工确认 → 部署 → 健康检查
|
||||
### 7.1 三层定义
|
||||
|
||||
| 层 | 回答的问题 | 本质 | 生命周期 | 当前状态 |
|
||||
|----|-----------|------|---------|---------|
|
||||
| **流程模板 (Template)** | 应该做什么 | 可复用的蓝图(节点拓扑 + 建议人设 + 质量门禁) | 长期存在,跨项目复用 | ⚡ 需重新设计(原 df-stages 已移除) |
|
||||
| **工作流 (Workflow)** | 怎么执行 | 模板的运行时实例(DAG + 状态 + 数据绑定) | 随项目启停,单次执行归档 | ✅ df-workflow 核心完成 |
|
||||
| **人设 (Persona)** | 谁来做 | Agent 角色卡(system prompt + 工具集 + 行为规则) | 长期存在,跨节点复用 | ⬜ 待设计 |
|
||||
|
||||
**关键原则**:模板不绑定具体人设、工作流不感知人设、人设与模板解耦。
|
||||
|
||||
详细定义、三者关系、实例化流程、数据结构及 YAML 模板示例见 [专项设计文档](./docs/02-架构设计/专项设计/三层模型-流程模板与人设体系-2026-06-28.md)。
|
||||
|
||||
## 八、Phase 规划
|
||||
|
||||
### Phase 1 — 引擎骨架 (4-6 周)
|
||||
- df-core + df-workflow (DAG + Node trait + Executor)
|
||||
- df-types + df-workflow (DAG + Node trait + Executor)
|
||||
- df-storage (SQLite 基础表)
|
||||
- df-execute (Shell 执行)
|
||||
- 最小前端:项目列表 + 工作流执行日志
|
||||
@@ -528,9 +749,10 @@ CREATE TABLE app_settings (
|
||||
- 前端:想法池视图 + 多项目 Tab
|
||||
- 验证:想法捕捉 → AI 评估 → 立项 → 工作流执行
|
||||
|
||||
### Phase 4 — 节点丰富 + 阶段插件 (3-4 周)
|
||||
### Phase 4 — 节点丰富 + 三层模型落地 (3-4 周)
|
||||
- df-nodes (Docker/Git/Human/HTTP)
|
||||
- ~~df-stages (5 阶段模板)~~ — 已移除(2026-06-14 零引用清理)
|
||||
- 流程模板系统(YAML 定义 + 模板库 + 实例化引擎)
|
||||
- 人设系统(AgentPersona 数据结构 + 内置人设 + 工具过滤)
|
||||
- 条件分支 + 断点续跑
|
||||
- 验证:跑通标准产研流程模板
|
||||
|
||||
@@ -566,3 +788,6 @@ CREATE TABLE app_settings (
|
||||
8. **本地优先**:SQLite 嵌入,不依赖云服务
|
||||
9. **多模型并行**:统一抽象,按任务路由,不锁定单一模型
|
||||
10. **流式优先**:AI 输出、Shell 输出全部流式推送到前端
|
||||
11. **模板/工作流/人设三层分离**:模板是蓝图,工作流是实例,人设是角色卡。三层各自独立演化,在 AINode 执行时交汇
|
||||
12. **人设与模板解耦**:模板标注建议人设但不绑定,同一个人设可用于不同模板的同类节点
|
||||
13. **模板实例化**:模板 → 工作流实例 + 人设分配,允许实例化时按项目覆盖人设
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# DevFlow 工作规范
|
||||
|
||||
## 任务看板自动领单(最高优先级纪律)
|
||||
|
||||
1. **会话启动即读板**:任何会话开工前,先读 `docs/todo.md`——按优先级领任务:🔴 高危项 > B 批次/编号项 > C 类候选。
|
||||
2. **批收敛即回读**:每完成一个任务批(提交后),重读看板领下一项——挂机/连续工作模式下每批必回读,禁凭记忆连续开工。
|
||||
3. **领单动作**:领单后将条目改为 `[→]`(进行中),完成后改 `[x]` 并附验收命令输出(G4);无法完成的改 `[!]` 附卡点,禁静默跳过。
|
||||
4. **🔴 门禁(G2)**:看板存在未清零 🔴 条目时,只允许领 🔴 销账任务,新功能任务禁领(除非用户明示)。
|
||||
5. **提交纪律(G3/G4)**:提交描述与 diff 机械对应;一笔一事或逐件列全;分组提交按看板标注的主题分组。
|
||||
6. **汇报实测**:任何进度/完成汇报,先跑 `git log -1 --format="%h %s"` 与 `git status --short | wc -l` 实测再报,禁凭记忆报状态。
|
||||
7. **看板顶部「挂机必读」警示**:启动时若存在,视为最高优先级上下文覆盖。
|
||||
|
||||
## 自主修复纪律
|
||||
|
||||
- 明确 bug(实证在案/用户指出/唯一正确解或用户已给修法方向):直接修并报告,禁反问「要不要修」(2026-10-06 用户纠正:发现换装脚本缺陷后反问=自主性差)。
|
||||
- 边界对齐意志卡 forbidden:发版/部署/重启线上、git push、生产数据操作、对外发消息,攒齐等拍板。
|
||||
- 本机工具/脚本的行为变更属 report_after 级(干完通报、可回滚),不属确认级。
|
||||
|
||||
## 命名与代码风格(2026-10-10 用户重申)
|
||||
|
||||
- **变量/方法名:精简 + 准确**——一眼看出「是什么/做什么」且不超必要长度。忌把实现细节写进名字(`..._after_tool_run`)、忌无信息缩写(`tmp2`/`h`)、忌同概念多叫法。名字过长通常说明该拆函数或把说明下沉到注释(注释承载「为什么」,名字只承载「是什么」)。
|
||||
- **DRY · 简洁 · 扩展性 · 高内聚低耦合**(同期重申):新增能力先找共用底座(如定时消息与定时任务共用一张 `ai_outbox` + 一个调度器),不造双轨;同一事实只留**单一真值来源**(如 token 估值口径)。
|
||||
|
||||
## 其他规范
|
||||
- 架构设计文档在 `docs/02-架构设计/`;当前活跃专项:无人值守可靠性(B1-B4)+ 异步任务会话(13-17),设计见 `temp/` 与 docs。
|
||||
Generated
+1116
-49
File diff suppressed because it is too large
Load Diff
+12
@@ -22,3 +22,15 @@ regex = "1"
|
||||
# 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"] }
|
||||
# dashmap: ConvStateStore 无锁并发 HashMap(session 锁重构方案 B-Phase0)。
|
||||
# ConvState 提到 ConvStateStore(DashMap),guard.reset/ai_is_generating 不依赖 session lock,
|
||||
# 治卡死连环(AiCompleted 延迟/工具后中断/第二条进队列同源根因)。详见 workflow session-lock-redesign。
|
||||
dashmap = "6"
|
||||
|
||||
# release 增量编译:小改动重打包显著提速(2026-10-04 打包提速项);
|
||||
# ⚠️ 2026-10-09 回退:release + incremental 触发链接期匿名符号丢失
|
||||
# (anon.*.llvm.* undefined symbol,rust-lld 与 MSVC link.exe 均复现),
|
||||
# 根因是增量编译 CGU 划分与跨 crate 内联冲突。cargo check 不链接故不暴露,
|
||||
# cargo build/tauri build 链接期必挂。release 禁用 incremental 为 Rust 官方推荐。
|
||||
# [profile.release]
|
||||
# incremental = true
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Generated
+28
-25
@@ -12,6 +12,7 @@
|
||||
"@dcloudio/uni-components": "3.0.0-alpha-4080720251125001",
|
||||
"@dcloudio/uni-h5": "3.0.0-alpha-4080720251125001",
|
||||
"@dcloudio/uni-mp-weixin": "3.0.0-alpha-4080720251125001",
|
||||
"highlight.js": "^11.11.1",
|
||||
"marked": "^18.0.5",
|
||||
"vue": "^3.4.0",
|
||||
"vue-i18n": "^9.0.0"
|
||||
@@ -21,6 +22,7 @@
|
||||
"@dcloudio/uni-automator": "3.0.0-alpha-4080720251125001",
|
||||
"@dcloudio/uni-cli-shared": "3.0.0-alpha-4080720251125001",
|
||||
"@dcloudio/vite-plugin-uni": "3.0.0-alpha-4080720251125001",
|
||||
"@swc/helpers": "^0.5.23",
|
||||
"@types/node": "^20.0.0",
|
||||
"@vue/runtime-core": "^3.4.0",
|
||||
"@vue/tsconfig": "^0.5.0",
|
||||
@@ -4715,7 +4717,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4729,7 +4730,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4743,7 +4743,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4757,7 +4756,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4771,7 +4769,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4785,7 +4782,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4799,7 +4795,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4813,7 +4808,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4827,7 +4821,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4841,7 +4834,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4855,7 +4847,6 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4869,7 +4860,6 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4883,7 +4873,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4897,7 +4886,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4911,7 +4899,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4925,7 +4912,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4939,7 +4925,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4953,7 +4938,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4967,7 +4951,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4981,7 +4964,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4995,7 +4977,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5009,7 +4990,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5023,7 +5003,6 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5037,7 +5016,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5051,7 +5029,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5080,6 +5057,16 @@
|
||||
"@sinonjs/commons": "^1.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.23",
|
||||
"resolved": "https://registry.npmmirror.com/@swc/helpers/-/helpers-0.5.23.tgz",
|
||||
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tootallnate/once": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/@tootallnate/once/-/once-1.1.2.tgz",
|
||||
@@ -8052,6 +8039,15 @@
|
||||
"he": "bin/he"
|
||||
}
|
||||
},
|
||||
"node_modules/highlight.js": {
|
||||
"version": "11.11.1",
|
||||
"resolved": "https://registry.npmmirror.com/highlight.js/-/highlight.js-11.11.1.tgz",
|
||||
"integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-encoding-sniffer": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz",
|
||||
@@ -13010,6 +13006,13 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/type-detect": {
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmmirror.com/type-detect/-/type-detect-4.0.8.tgz",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"dev:h5": "uni",
|
||||
"dev:mp-weixin": "uni -p mp-weixin",
|
||||
"build:h5": "uni build",
|
||||
"build:mp-weixin": "uni build -p mp-weixin",
|
||||
"build:mp-weixin": "uni build -p mp-weixin && node scripts/fix-mp-build.cjs",
|
||||
"type-check": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -15,6 +15,7 @@
|
||||
"@dcloudio/uni-components": "3.0.0-alpha-4080720251125001",
|
||||
"@dcloudio/uni-h5": "3.0.0-alpha-4080720251125001",
|
||||
"@dcloudio/uni-mp-weixin": "3.0.0-alpha-4080720251125001",
|
||||
"highlight.js": "^11.11.1",
|
||||
"marked": "^18.0.5",
|
||||
"vue": "^3.4.0",
|
||||
"vue-i18n": "^9.0.0"
|
||||
@@ -24,6 +25,7 @@
|
||||
"@dcloudio/uni-automator": "3.0.0-alpha-4080720251125001",
|
||||
"@dcloudio/uni-cli-shared": "3.0.0-alpha-4080720251125001",
|
||||
"@dcloudio/vite-plugin-uni": "3.0.0-alpha-4080720251125001",
|
||||
"@swc/helpers": "^0.5.23",
|
||||
"@types/node": "^20.0.0",
|
||||
"@vue/runtime-core": "^3.4.0",
|
||||
"@vue/tsconfig": "^0.5.0",
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
//! post-build 修复脚本:处理微信小程序构建产物的兼容性问题
|
||||
//!
|
||||
//! 背景:marked v18+ 含 lookbehind 检测 `new RegExp("(?<=1)(?<!1)")`,
|
||||
//! 微信开发者工具转译器静态检测到 `(?<=` 字符串 → 误判需 @swc/helpers/_wrap_reg_exp
|
||||
//! helper → 注入 require → 产物无此模块 → 小程序白屏。
|
||||
//!
|
||||
//! 微信基础库运行时不支持 lookbehind,marked 本就走降级路径(Te=false)。
|
||||
//! 本脚本把产物里的 lookbehind 检测替换为 Te=false,绕过微信工具静态误判,
|
||||
//! 同时保留 marked 完整功能(运行时本就降级)。
|
||||
//!
|
||||
//! 用法:build 后运行 `node scripts/fix-mp-build.cjs`
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const BUILD_DIR = path.join(__dirname, '..', 'dist', 'build', 'mp-weixin')
|
||||
const VENDOR = path.join(BUILD_DIR, 'common', 'vendor.js')
|
||||
|
||||
function fixLookbehind() {
|
||||
if (!fs.existsSync(VENDOR)) {
|
||||
console.warn('[fix-mp-build] vendor.js 不存在,跳过:', VENDOR)
|
||||
return
|
||||
}
|
||||
let src = fs.readFileSync(VENDOR, 'utf8')
|
||||
const before = src
|
||||
// marked v18 lookbehind 检测 `var Te=((l="")=>{try{return!!new RegExp("(?<=1)(?<!1)"+l)}catch{return!1}})()`
|
||||
// 替换为 `var Te=false`(强制降级,微信基础库不支持 lookbehind)
|
||||
src = src.replace(
|
||||
/var Te=\(\([^)]*\)=>\{try\{return!!new RegExp\("\(\?<=1\)\(\?<!1\)"[^}]*\}\}\)\)\(\),?/,
|
||||
'var Te=false,'
|
||||
)
|
||||
if (src === before) {
|
||||
// 未命中(压缩名可能不是 Te),退而按字符串替换 lookbehind 检测
|
||||
src = src.replace(/new RegExp\("\(\?<=1\)\(\?<!1\)"[^)]*\)/g, 'false')
|
||||
}
|
||||
// 关键:marked 条件分支里还有 `(?<!`)` lookbehind 字面量(Te 为 true 时的分支)。
|
||||
// 微信工具静态检测到任何 `(?<=` / `(?<!` 字符都会注入 swc helper 误判。
|
||||
// 把所有 lookbehind 字面量替换为非 lookbehind 等价形式。
|
||||
// (?:x) 合法不 throw;但该分支仅 Te=true 时用,运行时 Te=false 恒走降级分支,
|
||||
// 替换后行为不变,同时消除微信工具的静态触发器。
|
||||
src = src.replace(/\(\?<!`\)/g, '(?:x)')
|
||||
src = src.replace(/\(\?<=1\)\(\?<!1\)/g, '(?:1)')
|
||||
// 命名捕获组(ES2018)也触发 _wrap_reg_exp。marked v18 用 `(?<a>`+)[^`]+\k<a>` 匹配代码围栏。
|
||||
// 转为编号捕获组 `(`+)\1`(ES5 兼容,无命名捕获组,行为等价)。
|
||||
// 通用: (?<name>...) → (...), \k<name> → \N(按捕获组序编号,这里只有 a/b 两组)
|
||||
src = src.replace(/\(\?<a>\`\+\)\[^\`\]\+\\k<a>/g, '(`+)\\1')
|
||||
src = src.replace(/\(\?<b>\`\+\)\[^\`\]\+\\k<b>/g, '(`+)\\1')
|
||||
// 兜底:任何残留命名捕获组 (?<name> 或 \k<name> 转为无命名
|
||||
src = src.replace(/\(\?<[a-z][a-z0-9_]*>/g, '(')
|
||||
src = src.replace(/\\k<[a-z]>/g, '\\1')
|
||||
if (src !== before) {
|
||||
fs.writeFileSync(VENDOR, src)
|
||||
console.log('[fix-mp-build] vendor.js lookbehind 已全部替换(检测+条件分支)')
|
||||
} else {
|
||||
console.warn('[fix-mp-build] 未找到 lookbehind(可能已处理或格式不同)')
|
||||
}
|
||||
}
|
||||
|
||||
// 清理手动补的 @swc/helpers(不再需要,避免污染)
|
||||
function cleanupSwcHelpers() {
|
||||
const swcDir = path.join(BUILD_DIR, 'common', '@swc', 'helpers')
|
||||
if (fs.existsSync(swcDir)) {
|
||||
fs.rmSync(swcDir, { recursive: true, force: true })
|
||||
console.log('[fix-mp-build] 已清理手动补的 @swc/helpers')
|
||||
}
|
||||
}
|
||||
|
||||
fixLookbehind()
|
||||
cleanupSwcHelpers()
|
||||
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* df-relay WS 客户端(uni-app 跨端适配)
|
||||
* u-relay WS 客户端(uni-app 跨端适配)
|
||||
*
|
||||
* 协议(对齐 crates/df-relay/src/relay.rs handle_connection):
|
||||
* 1. 建立 WS 连接到 `/ws/miniapp`
|
||||
* 2. 首帧发 Hello{kind:'miniapp', device_id, token} 身份宣告(relay.rs:197-208,10s 超时)
|
||||
* 3. 校验通过后进入收发循环;失败 relay 发 Error 帧 + Close
|
||||
* 协议(对齐 E:/wk-lab/u-relay/src/relay.rs handle_connection):
|
||||
* 1. 建立 WS 连接到 `/ws/commander`
|
||||
* 2. 首帧发 Hello{kind:'commander', channel_id, token} 身份宣告(relay 10s 超时)
|
||||
* 3. 校验通过 → relay 回 hello_ack;失败 relay 发 error 帧 + Close
|
||||
* (错误码:handshake_failed/kind_mismatch/invalid_channel_id/server_misconfigured/auth_failed)
|
||||
*
|
||||
* 跨端适配:
|
||||
* - 微信小程序:uni.connectSocket(微信原生 API,非浏览器 WebSocket)
|
||||
@@ -33,6 +34,9 @@
|
||||
import { getConfig } from '@/config'
|
||||
import type { BroadcastMessage, Hello, MiniCommand, ControlMessage } from '@/types/relay'
|
||||
|
||||
/** 调试日志门控(P1 走查:生产 console.log 过微信 bridge,热路径成本)。dev=true / build=false。 */
|
||||
const DEBUG = import.meta.env.DEV
|
||||
|
||||
/** WS 连接状态 */
|
||||
export type WsStatus = 'disconnected' | 'connecting' | 'connected' | 'handshaking' | 'reconnecting'
|
||||
|
||||
@@ -86,6 +90,8 @@ class WsClient {
|
||||
private reconnectAttempts = 0
|
||||
/** 主动关闭标志(用户调用 disconnect 时置 true,不再触发重连) */
|
||||
private manualClose = false
|
||||
/** 可恢复标志:达上限 disconnected 时置 true,允许 connect/resumeIfDisconnected 重置重连 */
|
||||
private recoverable = false
|
||||
/**
|
||||
* 握手完成前积压的命令队列(send 在 !handshaked 时不丢弃,入队等 hello_ack 后补发)。
|
||||
* 治「握手成功到首帧竞态丢命令」:握手刚翻 true 时 relay 设备路由表可能未就绪,
|
||||
@@ -112,7 +118,8 @@ class WsClient {
|
||||
return
|
||||
}
|
||||
this.manualClose = false
|
||||
// 用户主动连接复位重连计数(可与达上限后的自动退避区分,允许手动重试)
|
||||
this.recoverable = false
|
||||
// 用户主动连接复位重连计数(可达上限后的 disconnected 也允许手动重试)
|
||||
this.reconnectAttempts = 0
|
||||
// 统一经 reconnectNow(幂等 clear timer + cleanup + openSocket),杜绝 reconnecting 态双连
|
||||
this.reconnectNow()
|
||||
@@ -138,7 +145,9 @@ class WsClient {
|
||||
*/
|
||||
resumeIfDisconnected(): void {
|
||||
if (this.manualClose) return
|
||||
if (this.status === 'disconnected' || this.status === 'reconnecting') {
|
||||
// 已达上限 disconnected(recoverable=true)也允许重置计数并重连
|
||||
if (this.status === 'disconnected' || this.status === 'reconnecting' || this.recoverable) {
|
||||
this.recoverable = false
|
||||
// 用户触发的立即重连(onShow 抢占退避)复位计数,允许达上限后手动恢复
|
||||
this.reconnectAttempts = 0
|
||||
this.reconnectNow()
|
||||
@@ -168,13 +177,13 @@ class WsClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 Command(miniapp → device)
|
||||
* 发送 Command(commander → agent)
|
||||
*
|
||||
* 不包装 BroadcastMessage —— relay.rs:332-365 入站文本帧直接当 payload 包成
|
||||
* BroadcastMessage(device_id/kind/source/from 由 relay 填),故客户端仅发业务 JSON。
|
||||
* BroadcastMessage(channel_id/kind/source/from 由 relay 填),故客户端仅发业务 JSON。
|
||||
*/
|
||||
send(cmd: MiniCommand): boolean {
|
||||
console.log('[dbg:ws] send', cmd.cmd, 'socket=', !!this.socket, 'handshaked=', this.handshaked)
|
||||
if (DEBUG) console.log('[dbg:ws] send', cmd.cmd, 'socket=', !!this.socket, 'handshaked=', this.handshaked)
|
||||
if (!this.socket || !this.handshaked) {
|
||||
// 握手未完成不丢弃,入队待 hello_ack 后补发(治握手成功到首帧竞态丢命令)。
|
||||
// 仅在未主动断开(manualClose=false)且连接尚有恢复预期时入队,
|
||||
@@ -306,8 +315,8 @@ class WsClient {
|
||||
this.setStatus('handshaking')
|
||||
const cfg = getConfig()
|
||||
const hello: Hello = {
|
||||
kind: 'miniapp',
|
||||
device_id: cfg.deviceId,
|
||||
kind: 'commander',
|
||||
channel_id: cfg.channelId,
|
||||
token: cfg.token,
|
||||
}
|
||||
try {
|
||||
@@ -341,7 +350,7 @@ class WsClient {
|
||||
}
|
||||
const text = res.data
|
||||
if (!text) return
|
||||
// 活性:任意入站帧更新(relay 无 pong,用心跳期间入站消息替代判活)
|
||||
// 活性:任意入站帧更新(含 relay 回的 pong 心跳响应,用于半连接检测)
|
||||
this.lastInboundAt = Date.now()
|
||||
|
||||
// 握手阶段可能收到 relay 发的错误控制帧(relay.rs:200-238)
|
||||
@@ -508,8 +517,14 @@ class WsClient {
|
||||
// 用户手动重连(connect/resumeIfDisconnected)复位计数后可重新开始。
|
||||
if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
||||
console.warn(`[WsClient] 已达最大重连次数 ${MAX_RECONNECT_ATTEMPTS},停止重连`)
|
||||
// 不清 cleanup(保留 manualClose 不变),仅设状态 + 停退避 timer
|
||||
// 让 connect()/resumeIfDisconnected() 可重置计数后恢复
|
||||
this.setStatus('disconnected', '已达最大重连次数,请检查中继地址/网络后手动重连')
|
||||
this.cleanup()
|
||||
this.recoverable = true
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
return
|
||||
}
|
||||
const cfg = getConfig()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,18 @@
|
||||
/**
|
||||
* miniapp 运行时配置(host/device_id/token 占位,由用户在设置页或 build env 填)
|
||||
* miniapp 运行时配置(channel_id/token 占位,由用户在设置页或 build env 填)
|
||||
*
|
||||
* 这些值在真实部署前是占位:
|
||||
* - RELAY_HOST:ws/wss 地址,需替换为实际部署的 df-relay 地址
|
||||
* - DEVICE_ID:用户首次配置桌面端时生成的设备 ID
|
||||
* - TOKEN:对齐 df-relay 的 DF_RELAY_TOKEN(生产建议每 device 独立 token)
|
||||
* - RELAY_HOST:ws/wss 地址,需替换为实际部署的 u-relay 地址
|
||||
* - CHANNEL_ID:u-relay 配对通道 ID(与桌面端 df-tunnel 共享同键)
|
||||
* - TOKEN:对齐 u-relay 的 U_RELAY_TOKEN(生产建议 per-channel 独立 token,Phase3)
|
||||
*/
|
||||
|
||||
export interface MiniappConfig {
|
||||
/** relay WS 地址(含协议),如 'ws://192.168.1.100:8080/ws/miniapp' 或 'wss://relay.example.com/ws/miniapp' */
|
||||
/** relay WS 地址(含协议),如 'ws://192.168.1.100:9180/ws/commander' 或 'wss://relay.example.com/ws/commander' */
|
||||
relayHost: string
|
||||
/** 配对绑定的设备 ID */
|
||||
deviceId: string
|
||||
/** 配对 token(对齐 df-relay DF_RELAY_TOKEN) */
|
||||
/** 配对绑定的通道 ID(u-relay channel_id,与桌面端共享同键配对) */
|
||||
channelId: string
|
||||
/** 配对 token(对齐 u-relay U_RELAY_TOKEN) */
|
||||
token: string
|
||||
/** 心跳间隔(ms) */
|
||||
heartbeatInterval: number
|
||||
@@ -20,35 +20,117 @@ export interface MiniappConfig {
|
||||
reconnectBaseDelay: number
|
||||
/** 重连最大间隔(ms) */
|
||||
reconnectMaxDelay: number
|
||||
/** file.1216.top 文件服务上传地址(不带路径,上传时拼 /upload) */
|
||||
fileUploadBaseUrl: string
|
||||
/** 上传 X-Source 头(项目隔离,文件存到 file.1216.top 对应目录) */
|
||||
fileUploadSource: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认配置(占位值,需替换)
|
||||
*
|
||||
* 真实部署:用户在设置页填 relayHost/deviceId/token,或通过 build-time env 注入。
|
||||
* 真实部署:用户在设置页填 relayHost/channelId/token,或通过 build-time env 注入。
|
||||
* MVP 阶段留占位让类型自洽,实际连接会失败(token 校验不通过 → relay 关连接,
|
||||
* 这符合预期,提示用户配置)。
|
||||
*/
|
||||
export const defaultConfig: MiniappConfig = {
|
||||
// 测试服 wss(u-work.1216.top → nginx 终止 TLS → df-relay :9180 明文 ws)
|
||||
// u-relay 现行协议 commander 端点:wss://u-work.1216.top → nginx 终止 TLS → u-relay :9180 明文 ws
|
||||
// 微信小程序真机要求 wss + socket 合法域名白名单(微信公众平台→开发设置→服务器域名)
|
||||
relayHost: 'wss://u-work.1216.top/ws/miniapp',
|
||||
// 配对桌面端 device_id(查桌面端 KV app_settings:device_id;填错则握手过但路由无对端)
|
||||
// 当前填本机桌面端 device_id(联调期硬编码,后续做配对绑定流程)
|
||||
deviceId: '1b92cbbd-d03e-4a0c-b226-9fadf8dcf0f0',
|
||||
relayHost: 'wss://u-work.1216.top/ws/commander',
|
||||
// 配对桌面端 channel_id(查桌面端 KV app_settings:channel_id;填错则握手过但路由无对端)
|
||||
// 当前填本机桌面端 channel_id(联调期硬编码,后续做配对绑定流程)
|
||||
channelId: '1b92cbbd-d03e-4a0c-b226-9fadf8dcf0f0',
|
||||
token: 'devflow-relay-default-token',
|
||||
heartbeatInterval: 30000,
|
||||
reconnectBaseDelay: 1000,
|
||||
reconnectMaxDelay: 30000,
|
||||
fileUploadBaseUrl: 'https://file.1216.top',
|
||||
fileUploadSource: 'devflow',
|
||||
}
|
||||
|
||||
/** 单例配置(miniapp 内存配置,后续接 storage 持久化) */
|
||||
let _config: MiniappConfig = { ...defaultConfig }
|
||||
/** storage key(持久化完整 MiniappConfig JSON) */
|
||||
const STORAGE_KEY = 'df-miniapp-config'
|
||||
|
||||
/** 单例配置(null 表示尚未从 storage 加载) */
|
||||
let _config: MiniappConfig | null = null
|
||||
|
||||
/**
|
||||
* 旧版字段迁移(df-relay 一代 → u-relay):
|
||||
* storage 里的旧 JSON 含 deviceId 字段,映射为 channelId,避免老用户升级后配置丢失。
|
||||
*/
|
||||
function migrateLegacyFields(parsed: Record<string, unknown>): Partial<MiniappConfig> {
|
||||
const out = { ...parsed } as Record<string, unknown>
|
||||
if (out.deviceId !== undefined && out.channelId === undefined) {
|
||||
out.channelId = out.deviceId
|
||||
}
|
||||
delete out.deviceId
|
||||
// 旧 relayHost 指向已下线的 /ws/miniapp 端点,升级后强制重置为新端点
|
||||
if (typeof out.relayHost === 'string' && out.relayHost.includes('/ws/miniapp')) {
|
||||
out.relayHost = defaultConfig.relayHost
|
||||
}
|
||||
return out as Partial<MiniappConfig>
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 storage 读取并合并默认配置
|
||||
*
|
||||
* uni.getStorageSync(key) 未命中时返回 ''(空字符串),命中返回原写入值。
|
||||
* 容错:读取/解析失败仅 warn,不抛出,回落到 defaultConfig。
|
||||
*/
|
||||
function loadFromStorage(): MiniappConfig {
|
||||
try {
|
||||
const raw = uni.getStorageSync(STORAGE_KEY)
|
||||
if (!raw) return { ...defaultConfig }
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>
|
||||
// 合并默认值 + 旧字段迁移,避免旧版本字段缺失/更名导致 undefined
|
||||
const migrated = migrateLegacyFields(parsed)
|
||||
return { ...defaultConfig, ...migrated }
|
||||
} catch (e) {
|
||||
console.warn('[df-miniapp] load config from storage failed:', e)
|
||||
return { ...defaultConfig }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步写入 storage(JSON 序列化完整配置)
|
||||
*
|
||||
* 容错:写入失败仅 warn,不影响内存配置。
|
||||
*/
|
||||
function saveToStorage(config: MiniappConfig): void {
|
||||
try {
|
||||
uni.setStorageSync(STORAGE_KEY, JSON.stringify(config))
|
||||
} catch (e) {
|
||||
console.warn('[df-miniapp] save config to storage failed:', e)
|
||||
}
|
||||
}
|
||||
|
||||
export function getConfig(): MiniappConfig {
|
||||
// 首次调用时从 storage 加载并缓存
|
||||
if (_config === null) {
|
||||
_config = loadFromStorage()
|
||||
}
|
||||
return _config
|
||||
}
|
||||
|
||||
export function setConfig(patch: Partial<MiniappConfig>): void {
|
||||
// 确保已加载(避免在 getConfig 前调用 setConfig 丢失 storage 旧值)
|
||||
if (_config === null) {
|
||||
_config = loadFromStorage()
|
||||
}
|
||||
_config = { ..._config, ...patch }
|
||||
saveToStorage(_config)
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置为默认配置并清除 storage 持久化数据
|
||||
*
|
||||
* 用于用户重新配对或恢复出厂占位值的场景。
|
||||
*/
|
||||
export function resetConfig(): void {
|
||||
_config = { ...defaultConfig }
|
||||
try {
|
||||
uni.removeStorageSync(STORAGE_KEY)
|
||||
} catch (e) {
|
||||
console.warn('[df-miniapp] remove config from storage failed:', e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
{
|
||||
"name": "DevFlow",
|
||||
"appid": "PLACEHOLDER",
|
||||
"description": "DevFlow 远程 AI Chat — 跨端操作桌面端开发助手",
|
||||
"versionName": "0.1.0",
|
||||
"versionCode": 100,
|
||||
"transformPx": false,
|
||||
"app-plus": {
|
||||
"usingComponents": true,
|
||||
"splashscreen": {
|
||||
"alwaysShowBeforeRender": true,
|
||||
"waiting": true,
|
||||
"autoclose": true,
|
||||
"delay": 0
|
||||
},
|
||||
"modules": {},
|
||||
"distribute": {
|
||||
"android": {
|
||||
"permissions": [
|
||||
"<uses-permission android:name=\"android.permission.INTERNET\"/>"
|
||||
]
|
||||
},
|
||||
"ios": {},
|
||||
"sdkConfigs": {}
|
||||
}
|
||||
},
|
||||
"quickapp": {},
|
||||
"mp-weixin": {
|
||||
"appid": "PLACEHOLDER",
|
||||
"setting": {
|
||||
"urlCheck": false,
|
||||
"es6": true,
|
||||
"minified": true,
|
||||
"postcss": true
|
||||
},
|
||||
"usingComponents": true,
|
||||
"permission": {},
|
||||
"requiredBackgroundModes": [],
|
||||
"requiredPrivateInfos": []
|
||||
},
|
||||
"h5": {
|
||||
"title": "DevFlow Mini",
|
||||
"router": {
|
||||
"mode": "hash",
|
||||
"base": "./"
|
||||
},
|
||||
"devServer": {
|
||||
"port": 8081,
|
||||
"https": false
|
||||
}
|
||||
},
|
||||
"vueVersion": "3"
|
||||
}
|
||||
@@ -13,6 +13,12 @@
|
||||
"navigationBarTitleText": "会话列表",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/settings/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "设置"
|
||||
}
|
||||
}
|
||||
],
|
||||
"globalStyle": {
|
||||
@@ -35,6 +41,10 @@
|
||||
{
|
||||
"pagePath": "pages/conversations/index",
|
||||
"text": "会话"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/settings/index",
|
||||
"text": "设置"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { useAiChat } from '@/composables/useAiChat'
|
||||
import type { Conversation } from '@/types/events'
|
||||
|
||||
const ai = useAiChat()
|
||||
|
||||
/**
|
||||
* mp-weixin 模板对嵌套在对象中的 reactive/ref 响应性追踪有问题。
|
||||
* 用本地 ref + watch 同步,确保模板直接拿到响应式数组。
|
||||
*/
|
||||
const conversations = ref<Conversation[]>([])
|
||||
/**
|
||||
* 监听 ai.conversations 整体替换(比 deep watch 更可靠,也比只盯 length 完整)。
|
||||
* 用 `[...ai.conversations]` 创建普通数组赋值给 ref,确保每次变化都触发模板更新。
|
||||
*
|
||||
* 为什么监听 ref 而非 length:device 回推 AiConversationList 是「整体替换」且条数常不变
|
||||
* (改名后条数不变),只盯 length 时 watch 回调不触发 → 本地副本滞留旧标题。
|
||||
* 监听 ref 则在赋值(含同长度替换)时即触发刷新。
|
||||
*/
|
||||
watch(ai.conversations, (list) => {
|
||||
conversations.value = [...(list || [])]
|
||||
}, { immediate: true })
|
||||
|
||||
/** 点击会话项切换 */
|
||||
function handleSelect(convId: string): void {
|
||||
ai.switchConversation(convId)
|
||||
@@ -66,13 +84,20 @@ function formatTime(ts?: number): string {
|
||||
|
||||
<!-- 会话列表 -->
|
||||
<scroll-view class="conv-list" scroll-y>
|
||||
<view v-if="ai.conversations.length === 0" class="empty-hint">
|
||||
<!-- 加载中骨架屏 -->
|
||||
<view v-if="conversations.length === 0 && ai.loading.value" class="conv-skeleton">
|
||||
<view v-for="i in 5" :key="i" class="conv-sk-item">
|
||||
<view class="conv-sk-line conv-sk-w70"></view>
|
||||
<view class="conv-sk-line conv-sk-w40"></view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else-if="conversations.length === 0" class="empty-hint">
|
||||
<text>暂无历史会话</text>
|
||||
<text class="empty-sub">在桌面端创建后,此处同步</text>
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-for="conv in ai.conversations"
|
||||
v-for="conv in conversations"
|
||||
:key="conv.id"
|
||||
class="conv-item"
|
||||
:class="{ active: conv.id === ai.activeConversationId.value }"
|
||||
@@ -85,7 +110,7 @@ function formatTime(ts?: number): string {
|
||||
</view>
|
||||
<text v-if="conv.updatedAt" class="conv-time">{{ formatTime(conv.updatedAt) }}</text>
|
||||
</view>
|
||||
<view v-if="ai.conversations.length > 0" class="list-hint">
|
||||
<view v-if="conversations.length > 0" class="list-hint">
|
||||
<text>长按会话可重命名</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
@@ -186,4 +211,26 @@ function formatTime(ts?: number): string {
|
||||
color: $uni-text-color-disable;
|
||||
font-size: $uni-font-size-sm;
|
||||
}
|
||||
/* 骨架屏(下拉刷新/冷启动占位) */
|
||||
.conv-skeleton {
|
||||
padding: 24rpx;
|
||||
}
|
||||
.conv-sk-item {
|
||||
margin-bottom: 32rpx;
|
||||
padding: 24rpx;
|
||||
}
|
||||
.conv-sk-line {
|
||||
height: 24rpx;
|
||||
margin: 8rpx 0;
|
||||
background: linear-gradient(90deg, $uni-bg-color-light 25%, #3a3a3a 50%, $uni-bg-color-light 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: conv-sk-shimmer 1.5s infinite;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
.conv-sk-w70 { width: 70%; }
|
||||
.conv-sk-w40 { width: 40%; }
|
||||
@keyframes conv-sk-shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { getConfig, setConfig } from '@/config'
|
||||
import { useAiChat } from '@/composables/useAiChat'
|
||||
|
||||
/**
|
||||
* 设置页(连接配置查看/编辑 + 连接状态 + 连接操作)。
|
||||
*
|
||||
* 默认查看模式(只读展示 relayHost/channelId/token,token 脱敏);点「编辑」进入编辑模式,
|
||||
* 保存时 setConfig 持久化并 resumeIfDisconnected 用新配置重连。连接态可「断开」,
|
||||
* 断开态可「重连」,另展示最近状态详情(wsStatusDetail)辅助排查。
|
||||
*
|
||||
* 数据源:
|
||||
* - 连接配置:config.ts getConfig()(storage 持久化,含默认值合并)
|
||||
* - 连接状态:useAiChat() 单例(wsStatus + deviceOnline,实时)
|
||||
* - 版本号:manifest.json versionName(手动维护此处常量,对齐 0.1.0)
|
||||
*/
|
||||
|
||||
// 连接配置(只读快照,页面 onShow 时刷新,切 tab 回来看最新值)
|
||||
const cfg = computed(() => getConfig())
|
||||
|
||||
// 连接状态(实时,useAiChat 单例 ref 直接响应)
|
||||
const ai = useAiChat()
|
||||
const wsStatus = computed(() => ai.wsStatus.value)
|
||||
const wsStatusDetail = computed(() => ai.wsStatusDetail.value)
|
||||
const deviceOnline = computed(() => ai.deviceOnline.value)
|
||||
const isWsConnected = computed(() => ai.isWsConnected.value)
|
||||
|
||||
/** 连接状态中文文案 + 颜色 class(对齐 chat 页 STATUS_TEXT 口径) */
|
||||
const STATUS_META: Record<string, { label: string; cls: string }> = {
|
||||
connected: { label: '已连接', cls: 'st-ok' },
|
||||
connecting: { label: '连接中…', cls: 'st-wait' },
|
||||
handshaking: { label: '握手中…', cls: 'st-wait' },
|
||||
reconnecting: { label: '重连中…', cls: 'st-warn' },
|
||||
disconnected: { label: '已断开', cls: 'st-err' },
|
||||
}
|
||||
const statusText = computed(() => {
|
||||
if (isWsConnected.value) {
|
||||
return deviceOnline.value ? '已连接桌面端' : '已连接中继'
|
||||
}
|
||||
return STATUS_META[wsStatus.value]?.label || wsStatus.value
|
||||
})
|
||||
const statusCls = computed(() => {
|
||||
if (isWsConnected.value) return deviceOnline.value ? 'st-ok' : 'st-warn'
|
||||
return STATUS_META[wsStatus.value]?.cls || 'st-err'
|
||||
})
|
||||
|
||||
/** token 脱敏(只显前 4 位 + 星号,查看页不暴露全量凭据) */
|
||||
function maskToken(token: string): string {
|
||||
if (!token) return '(未设置)'
|
||||
if (token.length <= 8) return '*'.repeat(token.length)
|
||||
return `${token.slice(0, 4)}${'*'.repeat(Math.min(token.length - 4, 12))}`
|
||||
}
|
||||
|
||||
/** 毫秒转可读(30000 → 30s) */
|
||||
function fmtMs(ms: number): string {
|
||||
return ms >= 1000 ? `${ms / 1000}s` : `${ms}ms`
|
||||
}
|
||||
|
||||
/** 版本号(对齐 manifest.json versionName,改版时同步) */
|
||||
const APP_VERSION = '0.1.0'
|
||||
|
||||
/** 手动重连(断开态可点)。用 ai.connect() 而非 resumeIfDisconnected:
|
||||
* 用户先点「断开」(manualClose=true)后 resumeIfDisconnected 会因 manualClose 早退,
|
||||
* connect() 复位 manualClose 强制开新连接(非连接态才生效,连接态自动幂等跳过)。 */
|
||||
function onReconnect(): void {
|
||||
ai.connect()
|
||||
}
|
||||
|
||||
/** 手动断开(连接态可点,disconnect 置 manualClose 后不再自动重连) */
|
||||
function onDisconnect(): void {
|
||||
ai.disconnect()
|
||||
uni.showToast({ title: '已断开连接', icon: 'none' })
|
||||
}
|
||||
|
||||
/** 连接操作按钮入口(连接态→断开,断开态→重连) */
|
||||
function onConnOp(): void {
|
||||
if (isWsConnected.value) onDisconnect()
|
||||
else onReconnect()
|
||||
}
|
||||
|
||||
/** 编辑态(false=查看模式,true=编辑模式;草稿保存时才 setConfig,取消丢弃) */
|
||||
const editing = ref(false)
|
||||
const draftRelayHost = ref('')
|
||||
const draftChannelId = ref('')
|
||||
const draftToken = ref('')
|
||||
|
||||
/** 进入编辑模式:以当前配置填充草稿 */
|
||||
function enterEdit(): void {
|
||||
draftRelayHost.value = cfg.value.relayHost
|
||||
draftChannelId.value = cfg.value.channelId
|
||||
draftToken.value = cfg.value.token
|
||||
editing.value = true
|
||||
}
|
||||
|
||||
/** 取消编辑:丢弃草稿回查看模式(不保存) */
|
||||
function cancelEdit(): void {
|
||||
editing.value = false
|
||||
}
|
||||
|
||||
/** 保存编辑:setConfig 持久化 + 提示 + resumeIfDisconnected 用新配置重连 */
|
||||
function saveEdit(): void {
|
||||
const relayHost = draftRelayHost.value.trim()
|
||||
const channelId = draftChannelId.value.trim()
|
||||
if (!relayHost || !channelId) {
|
||||
uni.showToast({ title: '中继地址和通道 ID 不能为空', icon: 'none' })
|
||||
return
|
||||
}
|
||||
setConfig({ relayHost, channelId, token: draftToken.value.trim() })
|
||||
editing.value = false
|
||||
uni.showToast({ title: '已保存,正在重连…', icon: 'none' })
|
||||
ai.resumeIfDisconnected()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="settings-page">
|
||||
<!-- 连接状态卡片(实时,断开可点重连) -->
|
||||
<view class="card">
|
||||
<view class="card-head">
|
||||
<text class="card-title">连接状态</text>
|
||||
</view>
|
||||
<view class="row">
|
||||
<text class="row-label">当前状态</text>
|
||||
<text class="row-value status" :class="statusCls" @tap="onReconnect">{{ statusText }}</text>
|
||||
</view>
|
||||
<view class="row">
|
||||
<text class="row-label">中继地址</text>
|
||||
<text class="row-value mono">{{ cfg.relayHost }}</text>
|
||||
</view>
|
||||
<view class="row" v-if="wsStatusDetail">
|
||||
<text class="row-label">最近状态</text>
|
||||
<text class="row-value">{{ wsStatusDetail }}</text>
|
||||
</view>
|
||||
<view class="ops">
|
||||
<view class="btn btn-block" :class="isWsConnected ? 'btn-danger' : 'btn-primary'" @tap="onConnOp">
|
||||
{{ isWsConnected ? '断开' : '重连' }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 连接配置(查看/编辑双模式:编辑可改 relayHost/channelId/token) -->
|
||||
<view class="card">
|
||||
<view class="card-head">
|
||||
<text class="card-title">连接配置</text>
|
||||
<view v-if="editing" class="card-actions">
|
||||
<text class="btn btn-sm btn-ghost" @tap="cancelEdit">取消</text>
|
||||
<text class="btn btn-sm btn-primary" @tap="saveEdit">保存</text>
|
||||
</view>
|
||||
<text v-else class="btn btn-sm btn-ghost" @tap="enterEdit">编辑</text>
|
||||
</view>
|
||||
<view class="row">
|
||||
<text class="row-label">中继地址</text>
|
||||
<input v-if="editing" v-model="draftRelayHost" class="row-input" placeholder="ws://…/ws/commander" placeholder-class="row-ph" />
|
||||
<text v-else class="row-value mono">{{ cfg.relayHost }}</text>
|
||||
</view>
|
||||
<view class="row">
|
||||
<text class="row-label">通道 ID</text>
|
||||
<input v-if="editing" v-model="draftChannelId" class="row-input" placeholder="u-relay 通道 ID" placeholder-class="row-ph" />
|
||||
<text v-else class="row-value mono">{{ cfg.channelId }}</text>
|
||||
</view>
|
||||
<view class="row">
|
||||
<text class="row-label">配对 Token</text>
|
||||
<input v-if="editing" v-model="draftToken" class="row-input" placeholder="配对 token" placeholder-class="row-ph" />
|
||||
<text v-else class="row-value mono">{{ maskToken(cfg.token) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 高级参数 -->
|
||||
<view class="card">
|
||||
<view class="card-head">
|
||||
<text class="card-title">高级参数</text>
|
||||
</view>
|
||||
<view class="row">
|
||||
<text class="row-label">心跳间隔</text>
|
||||
<text class="row-value">{{ fmtMs(cfg.heartbeatInterval) }}</text>
|
||||
</view>
|
||||
<view class="row">
|
||||
<text class="row-label">重连初始间隔</text>
|
||||
<text class="row-value">{{ fmtMs(cfg.reconnectBaseDelay) }}</text>
|
||||
</view>
|
||||
<view class="row">
|
||||
<text class="row-label">重连最大间隔</text>
|
||||
<text class="row-value">{{ fmtMs(cfg.reconnectMaxDelay) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 关于 -->
|
||||
<view class="card">
|
||||
<view class="card-head">
|
||||
<text class="card-title">关于</text>
|
||||
</view>
|
||||
<view class="row">
|
||||
<text class="row-label">版本</text>
|
||||
<text class="row-value">DevFlow Mini v{{ APP_VERSION }}</text>
|
||||
</view>
|
||||
<view class="row">
|
||||
<text class="row-label">说明</text>
|
||||
<text class="row-value">远程 AI Chat · 跨端操作桌面端开发助手</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.settings-page {
|
||||
min-height: 100vh;
|
||||
padding: 24rpx;
|
||||
box-sizing: border-box;
|
||||
background-color: $uni-bg-color;
|
||||
}
|
||||
.card {
|
||||
margin-bottom: 24rpx;
|
||||
padding: 24rpx;
|
||||
background-color: $uni-bg-color-light;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
.card-head {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
.card-title {
|
||||
font-size: $uni-font-size-base;
|
||||
font-weight: bold;
|
||||
color: $uni-text-color;
|
||||
}
|
||||
.card-sub {
|
||||
font-size: $uni-font-size-sm;
|
||||
color: $uni-text-color-disable;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16rpx 0;
|
||||
border-bottom: 1rpx solid $uni-border-color;
|
||||
}
|
||||
.row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.row-label {
|
||||
font-size: $uni-font-size-sm;
|
||||
color: $uni-text-color-grey;
|
||||
}
|
||||
.row-value {
|
||||
flex: 1;
|
||||
margin-left: 24rpx;
|
||||
font-size: $uni-font-size-sm;
|
||||
color: $uni-text-color;
|
||||
text-align: right;
|
||||
word-break: break-all;
|
||||
}
|
||||
.row-value.mono {
|
||||
font-family: monospace;
|
||||
}
|
||||
.row-value.status {
|
||||
font-weight: bold;
|
||||
}
|
||||
.st-ok { color: #6bd99a; }
|
||||
.st-wait { color: $uni-color-primary; }
|
||||
.st-warn { color: #f0c75e; }
|
||||
.st-err { color: #e85a4f; }
|
||||
|
||||
/* 连接操作按钮(连接状态卡:重连/断开) */
|
||||
.ops {
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8rpx 24rpx;
|
||||
font-size: $uni-font-size-sm;
|
||||
line-height: 1.6;
|
||||
border-radius: 10rpx;
|
||||
}
|
||||
.btn-block {
|
||||
display: block;
|
||||
padding: 20rpx 32rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.btn-primary {
|
||||
background-color: $uni-color-primary;
|
||||
color: #fff;
|
||||
}
|
||||
.btn-danger {
|
||||
background-color: #e85a4f;
|
||||
color: #fff;
|
||||
}
|
||||
.btn-ghost {
|
||||
color: $uni-color-primary;
|
||||
border: 1rpx solid $uni-color-primary;
|
||||
background-color: transparent;
|
||||
}
|
||||
/* 卡片头部按钮组(编辑/取消/保存) */
|
||||
.card-actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
.card-actions .btn {
|
||||
margin-left: 16rpx;
|
||||
}
|
||||
.btn-sm {
|
||||
padding: 4rpx 20rpx;
|
||||
}
|
||||
/* 编辑态输入(label 左 + input 右,横向一行对齐 .row-value) */
|
||||
.row-input {
|
||||
flex: 1;
|
||||
margin-left: 24rpx;
|
||||
font-size: $uni-font-size-sm;
|
||||
color: $uni-text-color;
|
||||
text-align: right;
|
||||
}
|
||||
.row-ph {
|
||||
color: $uni-text-color-disable;
|
||||
}
|
||||
</style>
|
||||
@@ -49,6 +49,12 @@ export type AiChatEvent =
|
||||
total_tokens: number
|
||||
prompt_tokens: number
|
||||
completion_tokens: number
|
||||
/** token 分项(2026-08-02):cache 命中(低价)/未命中(全价真实,前端 in 显示用此)/思考 */
|
||||
prompt_cache_hit_tokens: number
|
||||
prompt_cache_miss_tokens: number
|
||||
reasoning_tokens: number
|
||||
/** G4.3:该轮 token 用量是否估算值(provider 未报 prompt_tokens → estimated 兜底打标) */
|
||||
is_estimated: boolean
|
||||
incomplete?: boolean | null
|
||||
conversation_id?: string | null
|
||||
}
|
||||
@@ -89,10 +95,29 @@ export type AiChatEvent =
|
||||
tasks: TaskRecord[]
|
||||
ideas: IdeaRecord[]
|
||||
}
|
||||
// 每会话独立模型:对齐后端 list_models(device 读活跃 provider enabled 模型,跨端回推)。
|
||||
// 全局事件(无 conversation_id),miniapp 顶栏模型选择器消费 models + 默认 default_model。
|
||||
| {
|
||||
type: 'AiModelList'
|
||||
provider_id: string
|
||||
default_model: string
|
||||
models: ModelInfo[]
|
||||
}
|
||||
|
||||
/** AiChatEvent 的 type 字面量集合(用于 narrowing / 分派) */
|
||||
export type AiChatEventType = AiChatEvent['type']
|
||||
|
||||
/**
|
||||
* 模型信息(每会话独立模型,对齐后端 list_models 返回)。
|
||||
* provider 当前 enabled 模型列表,label 为展示名(可选,缺省用 model_id)。
|
||||
*/
|
||||
export interface ModelInfo {
|
||||
/** 模型 id(选中后透传 model_override) */
|
||||
model_id: string
|
||||
/** 展示名(可选) */
|
||||
label?: string
|
||||
}
|
||||
|
||||
/** 会话信息(对齐桌面端 list_conversations 返回结构,字段名 camelCase 兼容桌面 IPC 约定) */
|
||||
export interface Conversation {
|
||||
id: string
|
||||
@@ -116,6 +141,8 @@ export interface ChatMessage {
|
||||
toolCalls?: AiToolCallInfo[]
|
||||
/** 不完整标记(AiCompleted{incomplete:true} 对应系统提示气泡) */
|
||||
incomplete?: boolean
|
||||
/** 图片输入:消息携带的图片 URL 列表(预览用,发送时透传) */
|
||||
images?: string[]
|
||||
}
|
||||
|
||||
/** 工具调用信息(对齐桌面 useAiEvents.ts:399 的 AiToolCallInfo) */
|
||||
@@ -141,6 +168,12 @@ export interface TokenUsage {
|
||||
prompt: number
|
||||
completion: number
|
||||
total: number
|
||||
/** token 分项(2026-08-02):cache 命中(低价)/未命中(全价)/思考,对齐桌面端 */
|
||||
cache_hit?: number
|
||||
cache_miss?: number
|
||||
reasoning?: number
|
||||
/** 该轮 token 是否估算值(AiCompleted.is_estimated 透传,reload 历史消息可能无) */
|
||||
is_estimated?: boolean
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -1,44 +1,47 @@
|
||||
/**
|
||||
* df-relay 协议类型镜像(对齐 Rust crates/df-relay/src/)
|
||||
* u-relay 协议类型镜像(对齐 E:/wk-lab/u-relay/src/)
|
||||
*
|
||||
* 类型证据:
|
||||
* - ClientKind / MessageKind:crates/df-relay/src/broadcast.rs:21-41
|
||||
* (#[serde(rename_all = "snake_case")] → 'device'/'miniapp', 'event'/'command'/'control')
|
||||
* - BroadcastMessage:broadcast.rs:48-62(device_id/kind/source/from/payload/ts)
|
||||
* - Hello 握手帧:crates/df-relay/src/relay.rs:44-53(kind/device_id/token)
|
||||
* kind 用 ClientKindWire(relay.rs:56-61),序列化为 'device'/'miniapp'
|
||||
* - 路由端点:relay.rs:177-183 `/ws/miniapp`(小程序专用端点)
|
||||
* - ClientKind / MessageKind:u-relay/src/broadcast.rs
|
||||
* (#[serde(rename_all = "snake_case")] → 'agent'/'commander', 'event'/'command'/'control')
|
||||
* - BroadcastMessage:broadcast.rs(channel_id/kind/source/from/payload/ts)
|
||||
* - Hello 握手帧:u-relay/src/relay.rs(kind/channel_id/token)
|
||||
* kind 用 ClientKindWire,序列化为 'agent'/'commander'
|
||||
* - 路由端点:relay.rs `/ws/agent`(执行端)/ `/ws/commander`(控制端)
|
||||
*
|
||||
* 关键协议点(relay.rs:332-365 handle_inbound_text):
|
||||
* relay 不解析 payload —— miniapp 入站文本帧直接当 payload 包成 BroadcastMessage 路由给 device。
|
||||
* 关键协议点(relay.rs handle_inbound_text):
|
||||
* relay 不解析 payload —— commander 入站文本帧直接当 payload 包成 BroadcastMessage 路由给 agent。
|
||||
* 故 miniapp 发送时,直接序列化 Command JSON(relay 自动包 payload),不需客户端构造 BroadcastMessage。
|
||||
* 接收时,device 事件经 relay 包成完整 BroadcastMessage 序列化下发,miniapp 需先解 BroadcastMessage
|
||||
* 接收时,agent 事件经 relay 包成完整 BroadcastMessage 序列化下发,miniapp 需先解 BroadcastMessage
|
||||
* 再从 payload 取 AiChatEvent。
|
||||
*
|
||||
* 历史注:df-relay 一代协议(kind:'device'/'miniapp' + device_id)已在 u-relay 全部下线,
|
||||
* 旧名握手期直接拒绝(端点 /ws/miniapp、/ws/device 均已失效)。
|
||||
*/
|
||||
|
||||
/** 客户端类型(对齐 Rust ClientKind,snake_case 序列化,broadcast.rs:21-28) */
|
||||
export type ClientKind = 'device' | 'miniapp'
|
||||
/** 客户端类型(对齐 Rust ClientKind,snake_case 序列化;u-relay 现行仅此两值) */
|
||||
export type ClientKind = 'agent' | 'commander'
|
||||
|
||||
/** 消息语义分类(对齐 Rust MessageKind,snake_case 序列化,broadcast.rs:35-41) */
|
||||
/** 消息语义分类(对齐 Rust MessageKind,snake_case 序列化) */
|
||||
export type MessageKind = 'event' | 'command' | 'control'
|
||||
|
||||
/** 在线状态(对齐 Rust PresenceState,snake_case 序列化,broadcast.rs:119-126) */
|
||||
/** 在线状态(对齐 Rust PresenceState,snake_case 序列化) */
|
||||
export type PresenceState = 'online' | 'offline'
|
||||
|
||||
/** ConnId 是 Rust u64(对齐 conn.rs ConnId struct),TS 用 number */
|
||||
/** ConnId 是 Rust u64(对齐 conn.rs ConnId struct),TS 用 number;0 为 NIL 伪值 */
|
||||
export type ConnId = number
|
||||
|
||||
/**
|
||||
* 中继路由消息骨架(对齐 Rust BroadcastMessage,broadcast.rs:48-62)
|
||||
* 中继路由消息骨架(对齐 Rust BroadcastMessage)
|
||||
*
|
||||
* miniapp 接收时 payload 为 AiChatEvent JSON(relay 透传不解析)。
|
||||
*/
|
||||
export interface BroadcastMessage {
|
||||
/** 配对绑定的设备 ID */
|
||||
device_id: string
|
||||
/** 配对绑定的通道 ID */
|
||||
channel_id: string
|
||||
/** 消息语义 */
|
||||
kind: MessageKind
|
||||
/** 消息来源连接 ID(relay 分配,u64) */
|
||||
/** 消息来源连接 ID(relay 分配,u64;0=relay 自构造 NIL 伪值) */
|
||||
source: ConnId
|
||||
/** 来源客户端类型 */
|
||||
from: ClientKind
|
||||
@@ -49,36 +52,36 @@ export interface BroadcastMessage {
|
||||
}
|
||||
|
||||
/**
|
||||
* 客户端首消息:身份宣告(对齐 Rust Hello,relay.rs:44-53)
|
||||
* 客户端首消息:身份宣告(对齐 Rust Hello)
|
||||
*
|
||||
* 小程序连 `/ws/miniapp` 后首帧必须发此结构,否则 relay 关连接(relay.rs:197-208)。
|
||||
* kind 恒为 'miniapp'(与端点匹配,relay.rs:212-225 校验,不匹配则关连接)。
|
||||
* 小程序连 `/ws/commander` 后首帧必须发此结构(10s 内),否则 relay 关连接。
|
||||
* kind 恒为 'commander'(与端点匹配,不匹配 relay 回 kind_mismatch 错误帧 + Close)。
|
||||
*/
|
||||
export interface Hello {
|
||||
/** 客户端类型,小程序恒 'miniapp'(对齐 ClientKindWire) */
|
||||
kind: 'miniapp' | 'device'
|
||||
/** 配对绑定的设备 ID(用户首次配置时生成) */
|
||||
device_id: string
|
||||
/** 配对 token(env DF_RELAY_TOKEN,fallback "devflow-relay-default-token") */
|
||||
/** 客户端类型,小程序(控制端)恒 'commander'(对齐 ClientKindWire) */
|
||||
kind: 'commander'
|
||||
/** 配对绑定的通道 ID(非空,≤128B,无控制字符) */
|
||||
channel_id: string
|
||||
/** 配对 token(env U_RELAY_TOKEN,与桌面端共享) */
|
||||
token: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 控制面消息(对齐 Rust ControlMessage,broadcast.rs:132-145,tag = "control_kind")
|
||||
* 控制面消息(对齐 Rust ControlMessage,tag = "control_kind")
|
||||
*
|
||||
* miniapp 主要发 Heartbeat 心跳 + Ping;Pair 在首次绑定时用(本批未实现绑定流程)。
|
||||
* miniapp 主要发 Ping 心跳(relay 本地直回 pong,不经对端);Pair/Heartbeat/Presence 骨架保留。
|
||||
*/
|
||||
export type ControlMessage =
|
||||
| { control_kind: 'pair'; device_id: string; token: string }
|
||||
| { control_kind: 'heartbeat'; device_id: string }
|
||||
| { control_kind: 'presence'; device_id: string; state: PresenceState }
|
||||
| { control_kind: 'pair'; channel_id: string; token: string }
|
||||
| { control_kind: 'heartbeat'; channel_id: string }
|
||||
| { control_kind: 'presence'; channel_id: string; state: PresenceState }
|
||||
| { control_kind: 'ping' }
|
||||
| { control_kind: 'pong' }
|
||||
|
||||
/**
|
||||
* 小程序 → 桌面端 Command 协议(miniapp 发起操作)
|
||||
* 小程序(控制端)→ 桌面端(执行端)Command 协议
|
||||
*
|
||||
* 类型为对齐 Tauri command 名,relay 透传 device_id 路由到桌面端,桌面端 invoke 对应 command。
|
||||
* 类型为对齐 Tauri command 名,relay 透传 channel_id 路由到桌面端,桌面端 invoke 对应 command。
|
||||
* 字段:
|
||||
* - cmd:命令名(对齐 src-tauri command 函数名)
|
||||
* - args:命令参数(对齐各 command 的参数)
|
||||
@@ -89,6 +92,7 @@ export type ControlMessage =
|
||||
* - list_conversations / load_messages(F-#95 扩展,会话列表/历史消息读取)
|
||||
* - list_skills / list_entities(F-#95 联想扩展,技能/项目·任务·灵感列表,见 useAiChat.ts)
|
||||
* - rename_conversation(会话重命名,2026-06-23)/ sync_pending(重连审批恢复,2026-06-23)
|
||||
* - list_models(每会话独立模型,模型选择器拉取 provider enabled 模型列表,见 useAiChat.ts)
|
||||
*/
|
||||
export interface MiniCommand {
|
||||
/** Tauri command 名 */
|
||||
@@ -102,6 +106,12 @@ export interface SendMessageArgs {
|
||||
message: string
|
||||
conversation_id?: string | null
|
||||
model_override?: string | null
|
||||
/**
|
||||
* 跨端新会话语义(2026-08-05):miniapp 新建会话(active=null)后首次发送置 true,
|
||||
* device 端据此强制创建新 conv id(不走 active 兜底,防串旧会话)。
|
||||
* 普通发送/已传 conversation_id 时不传(默认 false)。
|
||||
*/
|
||||
new_conversation?: boolean
|
||||
/**
|
||||
* 选中技能名(`/<skill>` 联想选中后透传,对齐 ai_chat_send 的 skill 参数)。
|
||||
* null/undefined=无技能(普通对话);非空=后端注入对应 SKILL.md 全文到 prompt。
|
||||
@@ -113,8 +123,29 @@ export interface SendMessageArgs {
|
||||
* null/undefined/空=无 mention(纯文本消息)。
|
||||
*/
|
||||
mention_spans?: MentionSpan[] | null
|
||||
/**
|
||||
* 多模态片段(图片输入):miniapp 选图转 base64 构造 ContentPart Image 数组透传,
|
||||
* 对齐后端 ai_chat_send 的 parts 参数(df-ai-core ContentPart,serde 内部标签 tag=type)。
|
||||
* null/undefined/空=纯文本消息(零回归)。
|
||||
*/
|
||||
parts?: MiniContentPart[] | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 多模态内容片段(对齐后端 df-ai-core/src/types.rs ContentPart)。
|
||||
* serde 内部标签 `#[serde(tag="type", rename_all="snake_case")]`:Image 片段 JSON 形如
|
||||
* `{"type":"image","url":...,"media_type":...}`。
|
||||
*/
|
||||
export type MiniContentPart =
|
||||
| { type: 'text'; text: string }
|
||||
| {
|
||||
type: 'image'
|
||||
url?: string | null
|
||||
base64?: string | null
|
||||
media_type?: string | null
|
||||
alt?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户消息内 mention 区间的元数据
|
||||
* (对齐后端 MentionSpanDto,crates/df-types/src/augmentation.rs:235)。
|
||||
@@ -199,6 +230,6 @@ export interface ContinueLoopArgs {
|
||||
|
||||
/** stop_loop 命令参数(对齐 src-tauri ai_stop_loop,Phase3 §2.2 路由表) */
|
||||
export interface StopLoopArgs {
|
||||
/** 霷停止循环的会话 id */
|
||||
/** 需停止循环的会话 id */
|
||||
conversation_id: string
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/** file.1216.top 文件服务上传客户端。
|
||||
* POST /upload multipart 'file' 字段 + 'X-Source' 头 → {"result":{"url":"https://..."},"retcode":0,"success":true}
|
||||
*/
|
||||
import { getConfig } from '@/config'
|
||||
|
||||
export interface UploadResult { url: string }
|
||||
|
||||
/** 上传图片到 file.1216.top,返回公开 URL。失败 reject(调用方回退 base64)。 */
|
||||
export function uploadImage(tempFilePath: string): Promise<UploadResult> {
|
||||
const cfg = getConfig()
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.uploadFile({
|
||||
url: `${cfg.fileUploadBaseUrl}/upload`,
|
||||
filePath: tempFilePath,
|
||||
name: 'file',
|
||||
header: { 'X-Source': cfg.fileUploadSource },
|
||||
timeout: 15000,
|
||||
success: (res) => {
|
||||
try {
|
||||
const data = JSON.parse(res.data as string)
|
||||
const url = data?.result?.url as string | undefined
|
||||
if (data?.success === true && data?.retcode === 0 && url) {
|
||||
resolve({ url })
|
||||
} else {
|
||||
reject(new Error(`上传响应异常: ${res.data}`))
|
||||
}
|
||||
} catch (e) {
|
||||
reject(e)
|
||||
}
|
||||
},
|
||||
fail: (err) => reject(err),
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* marked HTML 后处理:为代码块/内联 code/链接/表格注入 inline style(P1-G)。
|
||||
* marked HTML 后处理:为代码块/内联 code/链接/表格/图片/任务列表注入 inline style。
|
||||
*
|
||||
* 为什么后处理而非 marked 自定义 renderer:
|
||||
* - mp-weixin rich-text 不链接页面 CSS class,仅认节点 inline style → 必须把样式写进 HTML 属性。
|
||||
@@ -11,8 +11,88 @@
|
||||
* 跳转需第三方 mp-html(本次不做,记后续)。
|
||||
* - rich-text 非滚动容器,overflow-y:auto/max-height 不创建滚动区 → 长代码靠
|
||||
* white-space:pre-wrap + word-break:break-all 换行防撑爆,不限高滚动。
|
||||
* - <img> 网络图须域名进微信「downloadFile 合法域名」白名单,未配置时真机不显示
|
||||
* (基础库限制,非渲染代码问题);开发者后台配白名单后即正常。
|
||||
* - <input> 不在 rich-text 节点白名单 → GFM 任务列表勾选框被剥离成纯文本,见下替换。
|
||||
*/
|
||||
|
||||
// 语法高亮:highlight.js core + 按需注册常用语言(包体积敏感,不引全量)。
|
||||
// 无 DOM 不可用 highlightElement,hljs 产出的 token class 由 inlineHljs 转 inline style。
|
||||
import hljs from 'highlight.js/lib/core'
|
||||
import javascript from 'highlight.js/lib/languages/javascript'
|
||||
import typescript from 'highlight.js/lib/languages/typescript'
|
||||
import json from 'highlight.js/lib/languages/json'
|
||||
import xml from 'highlight.js/lib/languages/xml'
|
||||
import css from 'highlight.js/lib/languages/css'
|
||||
import markdown from 'highlight.js/lib/languages/markdown'
|
||||
import rust from 'highlight.js/lib/languages/rust'
|
||||
import go from 'highlight.js/lib/languages/go'
|
||||
import python from 'highlight.js/lib/languages/python'
|
||||
import java from 'highlight.js/lib/languages/java'
|
||||
import bash from 'highlight.js/lib/languages/bash'
|
||||
import sql from 'highlight.js/lib/languages/sql'
|
||||
import yaml from 'highlight.js/lib/languages/yaml'
|
||||
import diff from 'highlight.js/lib/languages/diff'
|
||||
|
||||
hljs.registerLanguage('javascript', javascript)
|
||||
hljs.registerLanguage('typescript', typescript)
|
||||
hljs.registerLanguage('json', json)
|
||||
hljs.registerLanguage('xml', xml)
|
||||
hljs.registerLanguage('css', css)
|
||||
hljs.registerLanguage('markdown', markdown)
|
||||
hljs.registerLanguage('rust', rust)
|
||||
hljs.registerLanguage('go', go)
|
||||
hljs.registerLanguage('python', python)
|
||||
hljs.registerLanguage('java', java)
|
||||
hljs.registerLanguage('bash', bash)
|
||||
hljs.registerLanguage('sql', sql)
|
||||
hljs.registerLanguage('yaml', yaml)
|
||||
hljs.registerLanguage('diff', diff)
|
||||
|
||||
// 围栏 info 常见别名 → 已注册语言(hljs.getLanguage 自带部分别名,这里补齐业务常用)
|
||||
const LANG_ALIASES: Record<string, string> = {
|
||||
js: 'javascript', ts: 'typescript', py: 'python',
|
||||
sh: 'bash', shell: 'bash', yml: 'yaml', md: 'markdown',
|
||||
html: 'xml', vue: 'xml', svg: 'xml',
|
||||
}
|
||||
|
||||
/** hljs token class → inline style(rich-text 认 style 不认 class;色板对齐桌面 github-dark) */
|
||||
const TOKEN_STYLE: Record<string, string> = {
|
||||
'hljs-keyword': 'color:#ff7b72',
|
||||
'hljs-doctag': 'color:#ff7b72',
|
||||
'hljs-meta': 'color:#ff7b72',
|
||||
'hljs-type': 'color:#ff7b72',
|
||||
'hljs-template-tag': 'color:#ff7b72',
|
||||
'hljs-template-variable': 'color:#ff7b72',
|
||||
'hljs-title': 'color:#d2a8ff',
|
||||
'hljs-attr': 'color:#79c0ff',
|
||||
'hljs-attribute': 'color:#79c0ff',
|
||||
'hljs-literal': 'color:#79c0ff',
|
||||
'hljs-number': 'color:#79c0ff',
|
||||
'hljs-operator': 'color:#79c0ff',
|
||||
'hljs-variable': 'color:#79c0ff',
|
||||
'hljs-selector-attr': 'color:#79c0ff',
|
||||
'hljs-selector-class': 'color:#79c0ff',
|
||||
'hljs-selector-id': 'color:#79c0ff',
|
||||
'hljs-regexp': 'color:#a5d6ff',
|
||||
'hljs-string': 'color:#a5d6ff',
|
||||
'hljs-built_in': 'color:#ffa657',
|
||||
'hljs-symbol': 'color:#ffa657',
|
||||
'hljs-comment': 'color:#8b949e',
|
||||
'hljs-code': 'color:#8b949e',
|
||||
'hljs-formula': 'color:#8b949e',
|
||||
'hljs-name': 'color:#7ee787',
|
||||
'hljs-quote': 'color:#7ee787',
|
||||
'hljs-selector-tag': 'color:#7ee787',
|
||||
'hljs-selector-pseudo': 'color:#7ee787',
|
||||
'hljs-section': 'color:#1f6feb;font-weight:bold',
|
||||
'hljs-bullet': 'color:#f2cc60',
|
||||
'hljs-addition': 'color:#aff5b4;background-color:#033a16',
|
||||
'hljs-deletion': 'color:#ffdcd7;background-color:#67060c',
|
||||
'hljs-emphasis': 'font-style:italic',
|
||||
'hljs-strong': 'font-weight:bold',
|
||||
}
|
||||
|
||||
/** 给指定标签注入 inline style(已有 style 则合并,无则新增)。 */
|
||||
function styleTag(html: string, tag: string, style: string): string {
|
||||
const re = new RegExp(`<${tag}(\\s[^>]*)?>`, 'g')
|
||||
@@ -28,35 +108,116 @@ function styleTag(html: string, tag: string, style: string): string {
|
||||
})
|
||||
}
|
||||
|
||||
/** 反转 marked 对代码块内容的 HTML 转义,还原原始代码文本供 hljs 高亮 */
|
||||
function unescapeCode(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
}
|
||||
|
||||
/** hljs token class → inline style(无 DOM 不能 highlightElement,逐 span 转内联) */
|
||||
function inlineHljs(html: string): string {
|
||||
return html.replace(/<span class="([^"]+)">/g, (_m, cls: string) => {
|
||||
const style = cls
|
||||
.split(/\s+/)
|
||||
.map((c) => TOKEN_STYLE[c])
|
||||
.filter(Boolean)
|
||||
.join(';')
|
||||
return style ? `<span style="${style}">` : '<span>'
|
||||
})
|
||||
}
|
||||
|
||||
/** 代码块语言徽标(rich-text 内联 style;块代码顶部展示 fence lang,无 lang 不调用)。 */
|
||||
function codeLangBadge(lang: string): string {
|
||||
// 块级 div(pre 深底上浅灰标签 + 圆角,pre 自身已有内边距),放 <code> 前占据顶部一行
|
||||
return `<div style="font-size:10px;color:#9e9e9e;background-color:#2e2e2e;padding:1px 8px;border-radius:4px;margin-bottom:6px">${lang}</div>`
|
||||
}
|
||||
|
||||
/**
|
||||
* 块代码语法高亮:仅处理已知语言,未知/无语言兜底原样(确定性强,不做自动猜测)。
|
||||
* marked 已对代码内容做 HTML 转义,先反转还原再交给 hljs(hljs 输出自带转义)。
|
||||
* 同时补语言徽标:围栏有 lang 即显示(用原始标识如 ts,更贴近用户书写),无 lang 不显示。
|
||||
*/
|
||||
function highlightCodeBlocks(html: string): string {
|
||||
return html.replace(/<pre><code([^>]*)>([\s\S]*?)<\/code><\/pre>/gi, (m, attrs: string, inner: string) => {
|
||||
const langMatch = /class="language-([^"]+)"/i.exec(attrs)
|
||||
const langRaw = langMatch ? langMatch[1] : ''
|
||||
const lang = LANG_ALIASES[langRaw] ?? langRaw
|
||||
const badge = langRaw ? codeLangBadge(langRaw) : ''
|
||||
if (!lang || !hljs.getLanguage(lang)) {
|
||||
// 未注册语言:不高亮但保留徽标(fence 有 lang 时),避免 ` ```未知lang ` 丢语言标签
|
||||
return badge ? `<pre><code${attrs}>${badge}${inner}</code></pre>` : m
|
||||
}
|
||||
let value: string
|
||||
try {
|
||||
value = hljs.highlight(unescapeCode(inner), { language: lang, ignoreIllegals: true }).value
|
||||
} catch {
|
||||
return badge ? `<pre><code${attrs}>${badge}${inner}</code></pre>` : m
|
||||
}
|
||||
return `<pre><code${attrs}>${badge}${inlineHljs(value)}</code></pre>`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 对 marked.parse 产出的 HTML 注入 inline style。
|
||||
*
|
||||
* 设计取舍:pre(块代码)给深色背景;<code> 不给背景(透明),仅等宽+橙色 —— 避免块代码内层 code
|
||||
* 与 pre 背景冲突的双层色块(块代码内 code 透明继承 pre 深底,内联 code 橙色等宽在消息底上也可读)。
|
||||
* 设计取舍:pre(块代码)给深色背景;<code> 不给背景(透明),仅等宽 + 橙色 —— 避免块代码内层 code
|
||||
* 与 pre 背景冲突的双层色块(块代码内 code 透明继承 pre 深底,内联 code 橙色等宽在消息底上也可读);
|
||||
* 块代码经 hljs 高亮后,关键字/字符串等 token 由内联色覆盖,未命中 token 的文本保留 code 基色。
|
||||
*
|
||||
* 正文颜色(真机确认):rich-text 只认节点 inline style,不继承页面 CSS `.msg text`
|
||||
* (模板的 `.msg text{color:#e0e0e0}` 仅命中 `<text>` 子元素)。marked 产出的 p/h1-6/li/td/th
|
||||
* 等不带 style 时,文本在暗色气泡上呈近黑 → 暗底黑字。故给承载正文的块级元素统一注入颜色。
|
||||
* strong/em/b 等行内元素继承父级(p/li)颜色,无需逐一注入。
|
||||
*/
|
||||
export function styleMarkdown(html: string): string {
|
||||
let out = html
|
||||
// 任务列表勾选框:rich-text 节点白名单不含 input,替换为字符符号(勾选/未勾选)
|
||||
out = out.replace(/<input[^>]*type="checkbox"[^>]*>/gi, (m) => {
|
||||
const checked = /checked/i.test(m)
|
||||
const color = checked ? '#6bd99a' : '#666666'
|
||||
return `<span style="color:${color};margin-right:4px">${checked ? '☑' : '☐'}</span>`
|
||||
})
|
||||
// 块代码语法高亮(须在下方 pre/code styleTag 前执行,hljs 输出 token span 后再合并基样式)
|
||||
out = highlightCodeBlocks(out)
|
||||
// 正文着色:块级文本容器统一浅色(rich-text 样式继承链从容器传子文本)
|
||||
// word-break:break-all 防长 URL/长单词撑开气泡(rich-text 内文本不继承外层 .msg 的
|
||||
// word-break,须 inline 注入;对齐文件头"rich-text 只认 inline style")。
|
||||
const BODY_STYLE = 'color:#e0e0e0;word-break:break-all'
|
||||
for (const tag of ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'blockquote', 'td']) {
|
||||
out = styleTag(out, tag, BODY_STYLE)
|
||||
}
|
||||
// 块代码 <pre>:深色背景 + 等宽 + 圆角 + pre-wrap 换行防撑爆
|
||||
out = styleTag(
|
||||
out,
|
||||
'pre',
|
||||
'display:block;background-color:#1a1a1a;color:#d4d4d4;padding:10px;border-radius:6px;white-space:pre-wrap;word-break:break-all;font-family:monospace;font-size:12px;margin:6px 0',
|
||||
)
|
||||
// code(块内 + 内联):等宽橙色,透明背景(避与 pre 双层色块)
|
||||
// code(块内 + 内联):等宽橙色,透明背景(避与 pre 双层色块;块代码 token 由高亮色覆盖)
|
||||
out = styleTag(
|
||||
out,
|
||||
'code',
|
||||
'font-family:monospace;color:#e0a070;background-color:transparent;font-size:12px',
|
||||
)
|
||||
// 链接:蓝下划线(rich-text 不可点击,仅展示 + selectable 长按复制)
|
||||
out = styleTag(out, 'a', 'color:#4a9eff;text-decoration:underline')
|
||||
// 表格:border + 块级 + 横向溢出处理(rich-text 非滚动,靠 word-break 兜底;border 提升可读)
|
||||
// 链接:蓝下划线(rich-text 不可点击,仅展示 + selectable 长按复制);word-break 防长 URL 撑开
|
||||
out = styleTag(out, 'a', 'color:#4a9eff;text-decoration:underline;word-break:break-all')
|
||||
// 图片:max-width 防超宽撑爆气泡;block 去基线间隙;圆角与气泡一致。
|
||||
// 网络图显示依赖微信「downloadFile 合法域名」白名单(见文件头),非渲染问题。
|
||||
out = styleTag(out, 'img', 'max-width:100%;display:block;border-radius:6px')
|
||||
// 表格:显式 table 布局 + 等宽字列。注意不能用 display:block(会失去表格布局语义、
|
||||
// 列宽错乱),故显式声明 display:table;rich-text 非滚动容器(见文件头注释),无法建横向
|
||||
// 滚动区 → 不做滚动容器,靠 max-width:100% + word-break:break-all 防超宽撑爆气泡;
|
||||
// border 提升可读。
|
||||
out = styleTag(
|
||||
out,
|
||||
'table',
|
||||
'display:block;border-collapse:collapse;width:100%;font-size:12px;word-break:break-all',
|
||||
'display:table;border-collapse:collapse;width:100%;max-width:100%;font-size:12px;word-break:break-all',
|
||||
)
|
||||
out = styleTag(out, 'th', 'border:1px solid #333;padding:4px 6px;background-color:#1a1a1a')
|
||||
out = styleTag(out, 'td', 'border:1px solid #333;padding:4px 6px')
|
||||
// th/td:边框 + 内边距保留,字号与 table 对齐(font-size:12px)
|
||||
out = styleTag(out, 'th', 'border:1px solid #333;padding:4px 6px;font-size:12px;background-color:#1a1a1a')
|
||||
out = styleTag(out, 'td', 'border:1px solid #333;padding:4px 6px;font-size:12px')
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"skipLibCheck": true,
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"types": ["@dcloudio/types", "@types/node"],
|
||||
"types": ["@dcloudio/types", "@types/node", "vite/client"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# Bug 修复模板
|
||||
name: Bug 修复
|
||||
description: AI 驱动的 bug 修复流程 — 定位、修复、验证、通知
|
||||
|
||||
nodes:
|
||||
locate:
|
||||
type: ai
|
||||
label: 定位 Bug
|
||||
config:
|
||||
prompt: |
|
||||
根据错误描述和堆栈信息,定位 bug 的根因。
|
||||
使用 search_files 和 read_file 工具查找相关代码。
|
||||
persona_id: analyst
|
||||
|
||||
fix:
|
||||
type: ai
|
||||
label: 修复 Bug
|
||||
config:
|
||||
prompt: |
|
||||
基于定位结果,修复 bug。
|
||||
使用 patch_file 工具进行最小化修改。
|
||||
persona_id: coder
|
||||
|
||||
verify:
|
||||
type: ai
|
||||
label: 验证修复
|
||||
config:
|
||||
prompt: |
|
||||
验证修复是否有效。
|
||||
使用 run_command 工具运行相关测试。
|
||||
persona_id: tester
|
||||
|
||||
notify:
|
||||
type: notify
|
||||
label: 通知结果
|
||||
config:
|
||||
type: desktop
|
||||
title: Bug 修复完成
|
||||
message: 修复已验证
|
||||
|
||||
edges:
|
||||
- from: locate
|
||||
to: fix
|
||||
- from: fix
|
||||
to: verify
|
||||
- from: verify
|
||||
to: notify
|
||||
@@ -0,0 +1,36 @@
|
||||
# 代码审查模板
|
||||
name: 代码审查
|
||||
description: AI 驱动的代码审查流程 — 分析变更、审查质量、通知结果
|
||||
|
||||
nodes:
|
||||
analyze:
|
||||
type: ai
|
||||
label: 分析代码变更
|
||||
config:
|
||||
prompt: |
|
||||
分析以下代码变更,识别:
|
||||
1. 潜在 bug 和逻辑错误
|
||||
2. 安全漏洞
|
||||
3. 性能问题
|
||||
4. 代码规范
|
||||
persona_id: reviewer
|
||||
|
||||
report:
|
||||
type: ai
|
||||
label: 生成审查报告
|
||||
config:
|
||||
prompt: 基于分析结果,生成结构化审查报告,含优先级排序的改进建议
|
||||
|
||||
notify:
|
||||
type: notify
|
||||
label: 通知完成
|
||||
config:
|
||||
type: desktop
|
||||
title: 代码审查完成
|
||||
message: 审查报告已生成
|
||||
|
||||
edges:
|
||||
- from: analyze
|
||||
to: report
|
||||
- from: report
|
||||
to: notify
|
||||
@@ -0,0 +1,62 @@
|
||||
# 功能开发模板
|
||||
name: 功能开发
|
||||
description: AI 驱动的功能开发全流程 — 设计、编码、测试、审查、通知
|
||||
|
||||
nodes:
|
||||
design:
|
||||
type: ai
|
||||
label: 方案设计
|
||||
config:
|
||||
prompt: |
|
||||
分析需求,设计实现方案:
|
||||
1. 模块划分和接口定义
|
||||
2. 数据结构和核心算法
|
||||
3. 影响范围评估
|
||||
persona_id: architect
|
||||
|
||||
implement:
|
||||
type: ai
|
||||
label: 编码实现
|
||||
config:
|
||||
prompt: |
|
||||
按照设计方案实现功能。
|
||||
使用 write_file 和 patch_file 工具编写代码。
|
||||
persona_id: coder
|
||||
|
||||
test:
|
||||
type: ai
|
||||
label: 编写测试
|
||||
config:
|
||||
prompt: |
|
||||
为新功能编写单元测试和集成测试。
|
||||
使用 run_command 工具运行测试验证。
|
||||
persona_id: tester
|
||||
|
||||
review:
|
||||
type: ai
|
||||
label: 代码审查
|
||||
config:
|
||||
prompt: |
|
||||
审查实现代码,关注:
|
||||
1. 代码质量和可维护性
|
||||
2. 潜在 bug
|
||||
3. 安全性
|
||||
persona_id: reviewer
|
||||
|
||||
notify:
|
||||
type: notify
|
||||
label: 通知完成
|
||||
config:
|
||||
type: desktop
|
||||
title: 功能开发完成
|
||||
message: 设计→编码→测试→审查 全流程完成
|
||||
|
||||
edges:
|
||||
- from: design
|
||||
to: implement
|
||||
- from: implement
|
||||
to: test
|
||||
- from: test
|
||||
to: review
|
||||
- from: review
|
||||
to: notify
|
||||
@@ -5,10 +5,18 @@
|
||||
"": {
|
||||
"name": "devflow",
|
||||
"dependencies": {
|
||||
"@antv/layout": "^2.0.0",
|
||||
"@antv/x6": "^3.1.7",
|
||||
"@antv/x6-vue-shape": "^3.0.2",
|
||||
"@arco-design/web-vue": "^2.58.0",
|
||||
"@tauri-apps/api": "^2",
|
||||
"dompurify": "^3.4.9",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@types/dagre": "^0.7.54",
|
||||
"dagre": "^0.8.5",
|
||||
"dompurify": "^3.4.10",
|
||||
"highlight.js": "^11.11.1",
|
||||
"marked": "^18.0.5",
|
||||
"tslib": "^2.8.1",
|
||||
"vue": "^3.5.13",
|
||||
"vue-i18n": "9",
|
||||
"vue-router": "4",
|
||||
@@ -24,6 +32,20 @@
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@antv/event-emitter": ["@antv/event-emitter@0.1.3", "https://registry.npmmirror.com/@antv/event-emitter/-/event-emitter-0.1.3.tgz", {}, "sha512-4ddpsiHN9Pd4UIlWuKVK1C4IiZIdbwQvy9i7DUSI3xNJ89FPUFt8lxDYj8GzzfdllV0NkJTRxnG+FvLk0llidg=="],
|
||||
|
||||
"@antv/expr": ["@antv/expr@1.0.2", "https://registry.npmmirror.com/@antv/expr/-/expr-1.0.2.tgz", {}, "sha512-vrfdmPHkTuiS5voVutKl2l06w1ihBh9A8SFdQPEE+2KMVpkymzGOF1eWpfkbGZ7tiFE15GodVdhhHomD/hdIwg=="],
|
||||
|
||||
"@antv/graphlib": ["@antv/graphlib@2.0.4", "https://registry.npmmirror.com/@antv/graphlib/-/graphlib-2.0.4.tgz", { "dependencies": { "@antv/event-emitter": "^0.1.3" } }, "sha512-zc/5oQlsdk42Z0ib1mGklwzhJ5vczLFiPa1v7DgJkTbgJ2YxRh9xdarf86zI49sKVJmgbweRpJs7Nu5bIiwv4w=="],
|
||||
|
||||
"@antv/layout": ["@antv/layout@2.0.0", "https://registry.npmmirror.com/@antv/layout/-/layout-2.0.0.tgz", { "dependencies": { "@antv/event-emitter": "^0.1.3", "@antv/expr": "^1.0.2", "@antv/graphlib": "^2.0.0", "@antv/util": "^3.3.2", "comlink": "^4.4.1", "d3-force": "^3.0.0", "d3-force-3d": "^3.0.5", "d3-octree": "^1.0.2", "d3-quadtree": "^3.0.1", "dagre": "^0.8.5", "ml-matrix": "^6.10.4", "tslib": "^2.8.1" } }, "sha512-aCZ3UdNc40SfT7meFV7QTADY2HCnc0DShVw56CJNTI6oExUIVU736grPuL5Dhb8/JrVaU4Y83QPN/P7KafBzlw=="],
|
||||
|
||||
"@antv/util": ["@antv/util@3.3.11", "https://registry.npmmirror.com/@antv/util/-/util-3.3.11.tgz", { "dependencies": { "fast-deep-equal": "^3.1.3", "gl-matrix": "^3.3.0", "tslib": "^2.3.1" } }, "sha512-FII08DFM4ABh2q5rPYdr0hMtKXRgeZazvXaFYCs7J7uTcWDHUhczab2qOCJLNDugoj8jFag1djb7wS9ehaRYBg=="],
|
||||
|
||||
"@antv/x6": ["@antv/x6@3.1.7", "https://registry.npmmirror.com/@antv/x6/-/x6-3.1.7.tgz", { "dependencies": { "dom-align": "^1.12.4", "lodash-es": "^4.17.15", "mousetrap": "^1.6.5", "utility-types": "^3.10.0" } }, "sha512-NLKXtbCK51oLbazfFD0XsD93rMmih08UBW4gAuEyLBpwAqHmHe+vP8VhOZDkl5O9jV1LSv85IJghr9CT5tZjWw=="],
|
||||
|
||||
"@antv/x6-vue-shape": ["@antv/x6-vue-shape@3.0.2", "https://registry.npmmirror.com/@antv/x6-vue-shape/-/x6-vue-shape-3.0.2.tgz", { "dependencies": { "vue-demi": "latest" }, "peerDependencies": { "@antv/x6": "^3.x", "@vue/composition-api": "^1.0.0-rc.1", "vue": "^2.0.0 || >=3.0.0" }, "optionalPeers": ["@vue/composition-api"] }, "sha512-HWnYFvLCaUD9ernINxTn9vCRj66eG/nXKErSSq0hfI4U1HOitC/1oUrzDdwL/BT80HKAQeSsmy7gpDA/79jYlw=="],
|
||||
|
||||
"@arco-design/color": ["@arco-design/color@0.4.0", "https://registry.npmmirror.com/@arco-design/color/-/color-0.4.0.tgz", { "dependencies": { "color": "^3.1.3" } }, "sha512-s7p9MSwJgHeL8DwcATaXvWT3m2SigKpxx4JA1BGPHL4gfvaQsmQfrLBDpjOJFJuJ2jG2dMt3R3P8Pm9E65q18g=="],
|
||||
|
||||
"@arco-design/web-vue": ["@arco-design/web-vue@2.58.0", "https://registry.npmmirror.com/@arco-design/web-vue/-/web-vue-2.58.0.tgz", { "dependencies": { "@arco-design/color": "^0.4.0", "b-tween": "^0.3.3", "b-validate": "^1.5.3", "compute-scroll-into-view": "^1.0.20", "dayjs": "^1.11.13", "number-precision": "^1.6.0", "resize-observer-polyfill": "^1.5.1", "scroll-into-view-if-needed": "^2.2.31", "vue": "^3.1.0" } }, "sha512-b1vdPYOmjG5VAkVa7jlVwCb+WynBK+rnKN8zH3yKohpZObZbostRd3HgYNtjjZjGVU3OqR0Yy2FX7ftgF0bcOw=="],
|
||||
@@ -172,6 +194,10 @@
|
||||
|
||||
"@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.11.2", "https://registry.npmmirror.com/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.2.tgz", { "os": "win32", "cpu": "x64" }, "sha512-d2JchlFIpZevZVReyqhQOekJmb1UH3rhZ5VX6sH3ty9ETE0TKQavpihvoScUXfKKpW6HZC0MrFGRU0ZtD+w3gA=="],
|
||||
|
||||
"@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.7.1", "https://registry.npmmirror.com/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ=="],
|
||||
|
||||
"@types/dagre": ["@types/dagre@0.7.54", "https://registry.npmmirror.com/@types/dagre/-/dagre-0.7.54.tgz", {}, "sha512-QjcRY+adGbYvBFS7cwv5txhVIwX1XXIUswWl+kSQTbI6NjgZydrZkEKX/etzVd7i+bCsCb40Z/xlBY5eoFuvWQ=="],
|
||||
|
||||
"@types/dompurify": ["@types/dompurify@3.2.0", "https://registry.npmmirror.com/@types/dompurify/-/dompurify-3.2.0.tgz", { "dependencies": { "dompurify": "*" } }, "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.9", "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||
@@ -228,15 +254,35 @@
|
||||
|
||||
"color-string": ["color-string@1.9.1", "https://registry.npmmirror.com/color-string/-/color-string-1.9.1.tgz", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
|
||||
|
||||
"comlink": ["comlink@4.4.2", "https://registry.npmmirror.com/comlink/-/comlink-4.4.2.tgz", {}, "sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g=="],
|
||||
|
||||
"compute-scroll-into-view": ["compute-scroll-into-view@1.0.20", "https://registry.npmmirror.com/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz", {}, "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"d3-binarytree": ["d3-binarytree@1.0.2", "https://registry.npmmirror.com/d3-binarytree/-/d3-binarytree-1.0.2.tgz", {}, "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw=="],
|
||||
|
||||
"d3-dispatch": ["d3-dispatch@3.0.1", "https://registry.npmmirror.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="],
|
||||
|
||||
"d3-force": ["d3-force@3.0.0", "https://registry.npmmirror.com/d3-force/-/d3-force-3.0.0.tgz", { "dependencies": { "d3-dispatch": "1 - 3", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg=="],
|
||||
|
||||
"d3-force-3d": ["d3-force-3d@3.0.6", "https://registry.npmmirror.com/d3-force-3d/-/d3-force-3d-3.0.6.tgz", { "dependencies": { "d3-binarytree": "1", "d3-dispatch": "1 - 3", "d3-octree": "1", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA=="],
|
||||
|
||||
"d3-octree": ["d3-octree@1.1.0", "https://registry.npmmirror.com/d3-octree/-/d3-octree-1.1.0.tgz", {}, "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A=="],
|
||||
|
||||
"d3-quadtree": ["d3-quadtree@3.0.1", "https://registry.npmmirror.com/d3-quadtree/-/d3-quadtree-3.0.1.tgz", {}, "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw=="],
|
||||
|
||||
"d3-timer": ["d3-timer@3.0.1", "https://registry.npmmirror.com/d3-timer/-/d3-timer-3.0.1.tgz", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
|
||||
|
||||
"dagre": ["dagre@0.8.5", "https://registry.npmmirror.com/dagre/-/dagre-0.8.5.tgz", { "dependencies": { "graphlib": "^2.1.8", "lodash": "^4.17.15" } }, "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw=="],
|
||||
|
||||
"dayjs": ["dayjs@1.11.21", "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.21.tgz", {}, "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA=="],
|
||||
|
||||
"de-indent": ["de-indent@1.0.2", "https://registry.npmmirror.com/de-indent/-/de-indent-1.0.2.tgz", {}, "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg=="],
|
||||
|
||||
"dompurify": ["dompurify@3.4.9", "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.9.tgz", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-4dPSRMRDqHvs0V4YDFCsaIZo4if5u0xM+llyxiM2fwuZFdKArUBAF3VtI2+n8NKg9P870WMdYk0UhqQNoWXbfQ=="],
|
||||
"dom-align": ["dom-align@1.12.4", "https://registry.npmmirror.com/dom-align/-/dom-align-1.12.4.tgz", {}, "sha512-R8LUSEay/68zE5c8/3BDxiTEvgb4xZTF0RKmAHfiEVN3klfIpXfi2/QCoiWPccVQ0J/ZGdz9OjzL4uJEP/MRAw=="],
|
||||
|
||||
"dompurify": ["dompurify@3.4.11", "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.11.tgz", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw=="],
|
||||
|
||||
"entities": ["entities@7.0.1", "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
@@ -244,20 +290,44 @@
|
||||
|
||||
"estree-walker": ["estree-walker@2.0.2", "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"gl-matrix": ["gl-matrix@3.4.4", "https://registry.npmmirror.com/gl-matrix/-/gl-matrix-3.4.4.tgz", {}, "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ=="],
|
||||
|
||||
"graphlib": ["graphlib@2.1.8", "https://registry.npmmirror.com/graphlib/-/graphlib-2.1.8.tgz", { "dependencies": { "lodash": "^4.17.15" } }, "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A=="],
|
||||
|
||||
"he": ["he@1.2.0", "https://registry.npmmirror.com/he/-/he-1.2.0.tgz", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="],
|
||||
|
||||
"highlight.js": ["highlight.js@11.11.1", "https://registry.npmmirror.com/highlight.js/-/highlight.js-11.11.1.tgz", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="],
|
||||
|
||||
"is-any-array": ["is-any-array@3.0.0", "https://registry.npmmirror.com/is-any-array/-/is-any-array-3.0.0.tgz", {}, "sha512-o4h+tylWykC4BD1vaejp6gDxoM13bwW8FGuNs4yIKpj8xbBJcRxJx8vZpq0dCr7ZDEfeKjmsi/euolKhX6f/ww=="],
|
||||
|
||||
"is-arrayish": ["is-arrayish@0.3.4", "https://registry.npmmirror.com/is-arrayish/-/is-arrayish-0.3.4.tgz", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="],
|
||||
|
||||
"lodash": ["lodash@4.18.1", "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
|
||||
|
||||
"lodash-es": ["lodash-es@4.18.1", "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"marked": ["marked@18.0.5", "https://registry.npmmirror.com/marked/-/marked-18.0.5.tgz", { "bin": { "marked": "bin/marked.js" } }, "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w=="],
|
||||
|
||||
"minimatch": ["minimatch@9.0.9", "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
|
||||
|
||||
"ml-array-max": ["ml-array-max@2.0.0", "https://registry.npmmirror.com/ml-array-max/-/ml-array-max-2.0.0.tgz", { "dependencies": { "is-any-array": "^3.0.0" } }, "sha512-QQZ4kENwpWmyNb98UXRDFXrmtIXuXtt1+bSbda/2KA85+F+rrJP8hZk6QOkCQXM2Th9mUDYdq/PNByPdT9ID4A=="],
|
||||
|
||||
"ml-array-min": ["ml-array-min@2.0.0", "https://registry.npmmirror.com/ml-array-min/-/ml-array-min-2.0.0.tgz", { "dependencies": { "is-any-array": "^3.0.0" } }, "sha512-GRj6Ky6sW9vGL6yIjgsHmXZ9YgrdmcQ8nCxPqEGeKc6dkfYg1XDYxGFxADUjNuZyoCd5PUscWAS4N+cFaX6hFg=="],
|
||||
|
||||
"ml-array-rescale": ["ml-array-rescale@2.0.0", "https://registry.npmmirror.com/ml-array-rescale/-/ml-array-rescale-2.0.0.tgz", { "dependencies": { "is-any-array": "^3.0.0", "ml-array-max": "^2.0.0", "ml-array-min": "^2.0.0" } }, "sha512-2GGtKfSno94/kIloWGvpp/U5Q5vLvLrza+SAaGsLeo6Xj4mEbA6Gqx+oTfZFkxnd1grT2X007HfJNs3T5BsiVg=="],
|
||||
|
||||
"ml-matrix": ["ml-matrix@6.13.0", "https://registry.npmmirror.com/ml-matrix/-/ml-matrix-6.13.0.tgz", { "dependencies": { "is-any-array": "^3.0.0", "ml-array-rescale": "^2.0.0" } }, "sha512-QpV0UTUkglg6vPUgThKGBEtit2ac6habSoZ33bwI9rU0UHZLqw6G3ukTIE8zWiUF3sjK8YAlhx/o/b9layzH8A=="],
|
||||
|
||||
"mousetrap": ["mousetrap@1.6.5", "https://registry.npmmirror.com/mousetrap/-/mousetrap-1.6.5.tgz", {}, "sha512-QNo4kEepaIBwiT8CDhP98umTetp+JNfQYBWvC1pc6/OAibuXtRcxZ58Qz8skvEHYvURne/7R8T5VoOI7rDsEUA=="],
|
||||
|
||||
"muggle-string": ["muggle-string@0.4.1", "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.12", "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.12.tgz", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
|
||||
@@ -284,18 +354,26 @@
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.17", "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"typescript": ["typescript@5.6.3", "https://registry.npmmirror.com/typescript/-/typescript-5.6.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="],
|
||||
|
||||
"utility-types": ["utility-types@3.11.0", "https://registry.npmmirror.com/utility-types/-/utility-types-3.11.0.tgz", {}, "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw=="],
|
||||
|
||||
"vite": ["vite@6.4.3", "https://registry.npmmirror.com/vite/-/vite-6.4.3.tgz", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="],
|
||||
|
||||
"vscode-uri": ["vscode-uri@3.1.0", "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="],
|
||||
|
||||
"vue": ["vue@3.5.35", "https://registry.npmmirror.com/vue/-/vue-3.5.35.tgz", { "dependencies": { "@vue/compiler-dom": "3.5.35", "@vue/compiler-sfc": "3.5.35", "@vue/runtime-dom": "3.5.35", "@vue/server-renderer": "3.5.35", "@vue/shared": "3.5.35" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-cx89fnr+0kVGHiNFG6y6s0bdjypJRFNZn6x3WPstNdQR1bi1mbB7h4v5IBGTsPJU3nK1+0Iqj3Zf+hZWMieR4Q=="],
|
||||
|
||||
"vue-demi": ["vue-demi@0.14.10", "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz", { "peerDependencies": { "@vue/composition-api": "^1.0.0-rc.1", "vue": "^3.0.0-0 || ^2.6.0" }, "optionalPeers": ["@vue/composition-api"], "bin": { "vue-demi-fix": "bin/vue-demi-fix.js", "vue-demi-switch": "bin/vue-demi-switch.js" } }, "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg=="],
|
||||
|
||||
"vue-i18n": ["vue-i18n@9.14.5", "https://registry.npmmirror.com/vue-i18n/-/vue-i18n-9.14.5.tgz", { "dependencies": { "@intlify/core-base": "9.14.5", "@intlify/shared": "9.14.5", "@vue/devtools-api": "^6.5.0" }, "peerDependencies": { "vue": "^3.0.0" } }, "sha512-0jQ9Em3ymWngyiIkj0+c/k7WgaPO+TNzjKSNq9BvBQaKJECqn9cd9fL4tkDhB5G1QBskGl9YxxbDAhgbFtpe2g=="],
|
||||
|
||||
"vue-router": ["vue-router@4.6.4", "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz", { "dependencies": { "@vue/devtools-api": "^6.6.4" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg=="],
|
||||
|
||||
"vue-tsc": ["vue-tsc@2.2.12", "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-2.2.12.tgz", { "dependencies": { "@volar/typescript": "2.4.15", "@vue/language-core": "2.2.12" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "./bin/vue-tsc.js" } }, "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw=="],
|
||||
|
||||
"@types/dompurify/dompurify": ["dompurify@3.4.9", "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.9.tgz", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-4dPSRMRDqHvs0V4YDFCsaIZo4if5u0xM+llyxiM2fwuZFdKArUBAF3VtI2+n8NKg9P870WMdYk0UhqQNoWXbfQ=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 模型能力数据模型 — F-01 阶段1
|
||||
//! 模型能力数据模型。
|
||||
//!
|
||||
//! 单模型的完整描述(4 维度 + 路由控制)。纯数据结构,零 IO / 零 DB 依赖。
|
||||
//! df-storage 反序列化 DB 行时直接消费本模块类型;df-ai 探测器/路由器阶段 2-4 再用。
|
||||
@@ -45,9 +45,9 @@ pub enum Capability {
|
||||
///
|
||||
/// 序列化为 snake_case。原设计文档 §2.2 列 Free/Low/Medium/High,但 Free 变体形同虚设:
|
||||
/// 预设表(presets/models.json)0 条 free + 启发式从不赋 Free(只 Low/Medium/High),
|
||||
/// B-260618-05 删除 Free 死档变体。
|
||||
/// Free 死档变体已删除。
|
||||
///
|
||||
/// 路由已解耦(2026-06-18 B-260618-03):provider /v1/models API 不返回 cost_tier,
|
||||
/// 路由已解耦:provider /v1/models API 不返回 cost_tier,
|
||||
/// 此维度 100% 靠预设表写死 + 模型名启发式猜,数据无客观依据不可信,不再参与硬路由
|
||||
/// (原 §6.1 `cost_tier <= max_cost` 过滤已删除)。枚举保留供未来出现真实判别源时再接回。
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
@@ -63,7 +63,7 @@ pub enum CostTier {
|
||||
/// 设计文档 §2.2:Lite/Standard/Plus/Ultra。序列化为 snake_case。
|
||||
/// 派生 Ord:Lite < Standard < Plus < Ultra。
|
||||
///
|
||||
/// 路由已解耦(2026-06-18 B-260618-03):provider /v1/models API 不返回 intelligence,
|
||||
/// 路由已解耦:provider /v1/models API 不返回 intelligence,
|
||||
/// 此维度 100% 靠预设表写死 + 模型名启发式猜,数据无客观依据不可信,不再参与硬路由
|
||||
/// (原 §6.1 `intelligence >= min_intelligence` 过滤已删除)。枚举保留供未来出现真实判别源时再接回。
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
@@ -138,6 +138,14 @@ pub struct ModelConfig {
|
||||
#[serde(default = "default_context_window")]
|
||||
pub context_window: usize,
|
||||
|
||||
/// 是否支持 temperature 参数(默认 true)。
|
||||
///
|
||||
/// 部分模型仅接受固定温度值(如商汤 sensenova 系列仅允许 1.0),透传其它温度(标题 0.3/
|
||||
/// 压缩 0.2 等)→ 上游 HTTP 400,实测 9 次。false 时协议转换层省略 temperature 字段
|
||||
/// (交由上游默认),避免无效往返;true 保持原样透传(零回归)。
|
||||
#[serde(default = "default_true")]
|
||||
pub temperature_supported: bool,
|
||||
|
||||
// ── 探测元数据(只读,由 ModelProbe 填充,Phase 2)──
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub probe_source: Option<ProbeSource>,
|
||||
@@ -173,6 +181,9 @@ impl ModelConfig {
|
||||
intelligence: IntelligenceTier::Standard,
|
||||
weight: default_weight(),
|
||||
context_window: default_context_window(),
|
||||
// 默认支持 temperature(老格式升级/未知模型保持原样透传,零回归);
|
||||
// 明确不支持的模型由 presets 表 temperature_supported=false 标注。
|
||||
temperature_supported: true,
|
||||
probe_source: None,
|
||||
}
|
||||
}
|
||||
@@ -302,7 +313,7 @@ mod tests {
|
||||
assert_eq!(serde_json::from_str::<CostTier>(expected).unwrap(), variant);
|
||||
}
|
||||
// Ord:Low < Medium < High(枚举序,路由已解耦不再用于过滤,保留供未来判别源)。
|
||||
// Free 变体已删除(B-260618-05:预设/启发式从不赋 Free,死档)。
|
||||
// Free 变体已删除(预设/启发式从不赋 Free,死档)。
|
||||
assert!(CostTier::Low < CostTier::Medium);
|
||||
assert!(CostTier::Medium < CostTier::High);
|
||||
}
|
||||
@@ -339,6 +350,7 @@ mod tests {
|
||||
intelligence: IntelligenceTier::Plus,
|
||||
weight: 70,
|
||||
context_window: 128_000,
|
||||
temperature_supported: true,
|
||||
probe_source: Some(ProbeSource::PresetTable),
|
||||
};
|
||||
let json = serde_json::to_string(&mc).unwrap();
|
||||
|
||||
@@ -42,25 +42,25 @@ impl ContentPart {
|
||||
|
||||
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()) }
|
||||
Self { id: Some(new_message_id()), role: MessageRole::System, content: content.into(), parts: None, source: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: 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()) }
|
||||
Self { id: Some(new_message_id()), role: MessageRole::User, content: content.into(), parts: None, source: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: 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()) }
|
||||
Self { id: Some(new_message_id()), role: MessageRole::Assistant, content: content.into(), parts: None, source: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: 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()) }
|
||||
Self { id: Some(new_message_id()), role: MessageRole::Assistant, content: content.into(), parts: None, source: None, tool_call_id: None, tool_calls: Some(tool_calls), model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: 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()) }
|
||||
Self { id: Some(new_message_id()), role: MessageRole::Tool, content: content.into(), source: None, parts: None, tool_call_id: Some(call_id.into()), tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: 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()) }
|
||||
Self { id: Some(new_message_id()), role: MessageRole::User, content: content.into(), parts: Some(parts), source: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) }
|
||||
}
|
||||
|
||||
/// 是否含图片片(供 provider 判定走多模态分支)。
|
||||
@@ -90,19 +90,19 @@ impl ChatMessage {
|
||||
|
||||
/// 是否处于 active 态(status 为 None 或 "active")。其余状态一律 false。
|
||||
///
|
||||
/// 正面白名单(F-15 §3.2):仅认 None / "active",新状态
|
||||
/// (如阶段2 引入的 "archived_segment" / "compressed")自动落入不 active 分支,
|
||||
/// 正面白名单:仅认 None / "active",新状态
|
||||
/// (如"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"))
|
||||
matches!(self.status, None | Some(MessageStatus::Active))
|
||||
}
|
||||
}
|
||||
|
||||
impl ToolDefinition {
|
||||
pub fn function(name: impl Into<String>, description: impl Into<String>, parameters: serde_json::Value) -> Self {
|
||||
Self {
|
||||
tool_type: "function".into(),
|
||||
tool_type: ToolType::new("function"),
|
||||
function: ToolFunction { name: name.into(), description: description.into(), parameters },
|
||||
}
|
||||
}
|
||||
@@ -118,6 +118,50 @@ impl ToolCall {
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析点统一兜底:tool_call.id 空 → 生成唯一 fallback,非空原样。
|
||||
///
|
||||
/// 根因(实证会话 01f05167 SenseNova flash-lite):某些 provider(SenseNova 兼容缺陷)
|
||||
/// 返回空 `tool_call.id`("")。OpenAI 协议要求 id 唯一。DevFlow 多 tool_call 按 id
|
||||
/// 路由结果,id 空时所有结果落到同一 key(`audit/mod.rs:203` 的 `seen_ids` 去重把空 id
|
||||
/// 视为相同,只留首个 tool_call)→ AI 看到「所有调用同一结果」,工具全失败。
|
||||
///
|
||||
/// 兜底在**解析点**生成 fallback id:raw 非空用 raw,空用 `format!("{prefix}_{n}")`
|
||||
/// (n 取自下方 `FALLBACK_ID_COUNTER` **全局递增计数器**,跨轮跨 assistant 唯一)。
|
||||
/// 下游(工具执行 / tool 结果回填 tool_call_id)从解析后的 `ToolCall.id` 取,不重复生成,
|
||||
/// 确保 assistant tool_call.id 与 tool 结果 tool_call_id 匹配(防 sanitize 三元组断裂)。
|
||||
///
|
||||
/// 三处解析点共用本 helper(DRY):OpenAI 同步 `parse_tool_calls`(prefix=`gen_tool`)、
|
||||
/// OpenAI 流式 chunk(prefix=`gen_stream`)、Anthropic 同步 + 流式(prefix=`gen_anthropic` /
|
||||
/// `gen_anthropic_stream`)。正常 provider(OpenAI/Claude/GLM id 非空)原样透传零介入。
|
||||
///
|
||||
/// # 为何用全局计数器而非单轮 index(实证 af2fab4e)
|
||||
///
|
||||
/// 旧实现 fallback 用 `format!("{prefix}_{index}")`,index 是**单轮** tool_call 数组
|
||||
/// 位置。跨轮(不同 assistant)index 都从 0 起 → `gen_stream_0` 跨轮重复。agentic 的
|
||||
/// `id_to_name`(`insert(id, name)`)后者覆盖前者 → run_command 的 exit=1 被误标
|
||||
/// grep::exit=1 → L1 误熔断 grep(冤枉)→ loop 停 → 最后 assistant 空 content tool_calls
|
||||
/// 没执行(空气泡)。更严重:id 重复 → tool 结果配错 tool_call(三元组配对错位)。
|
||||
///
|
||||
/// 全局 `AtomicU64`(SeqCst)跨轮跨 assistant 严格递增,fallback id 永不重复。`index`
|
||||
/// 参数保留仅为签名兼容(4 处调用点 parse_tool_calls / 流式 chunk / push / agentic 都传),
|
||||
/// fallback 内部不再使用 index。
|
||||
///
|
||||
/// 单测跨进程实例计数器从 0 起;并发场景下两线程拿到的 fallback id 也严格递增(SeqCst),
|
||||
/// 保证全局唯一。
|
||||
pub fn tool_call_id_or_fallback(raw: &str, _index: usize, prefix: &str) -> String {
|
||||
if !raw.is_empty() {
|
||||
raw.to_string()
|
||||
} else {
|
||||
let n = FALLBACK_ID_COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
format!("{prefix}_{n}")
|
||||
}
|
||||
}
|
||||
|
||||
/// fallback id 全局计数器:跨轮跨 assistant 严格递增,保证空 id fallback 永不重复。
|
||||
///
|
||||
/// 见 `tool_call_id_or_fallback` 文档说明(实证 af2fab4e 跨轮重复根因)。
|
||||
static FALLBACK_ID_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
|
||||
/// LLM Provider trait
|
||||
#[async_trait]
|
||||
pub trait LlmProvider: Send + Sync {
|
||||
@@ -155,10 +199,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn is_active_whitelist() {
|
||||
// F-15 §3.2 正面白名单:仅 None / "active" 为 true,其余一律 false。
|
||||
// 正面白名单:仅 None / "active" 为 true,其余一律 false。
|
||||
// 零行为变化:None / "active" / "truncated" 与旧反面排除完全等价;
|
||||
// "archived_segment" / "compressed"(阶段2 引入,当前代码未赋值)由白名单
|
||||
// matches! 只认 None/active 自动落入 false,面向未来验证。
|
||||
// "archived_segment" / "compressed" 由白名单 matches! 只认 None/active 自动落入 false。
|
||||
|
||||
// None(构造默认值,向前兼容老 JSON)
|
||||
let m = ChatMessage::user("hi");
|
||||
@@ -166,26 +209,26 @@ mod tests {
|
||||
|
||||
// "active"
|
||||
let mut m = ChatMessage::user("hi");
|
||||
m.status = Some("active".to_string());
|
||||
m.status = Some(MessageStatus::Active);
|
||||
assert!(m.is_active(), "Some(active) 应 active");
|
||||
|
||||
// "truncated" — 当前取值,与旧实现等价(false)
|
||||
let mut m = ChatMessage::user("hi");
|
||||
m.status = Some("truncated".to_string());
|
||||
m.status = Some(MessageStatus::Truncated);
|
||||
assert!(!m.is_active(), "truncated 应不 active");
|
||||
|
||||
// "archived_segment" — 阶段2 待引入,白名单自动隔离
|
||||
// "archived_segment" — 白名单自动隔离
|
||||
let mut m = ChatMessage::user("hi");
|
||||
m.status = Some("archived_segment".to_string());
|
||||
m.status = Some(MessageStatus::ArchivedSegment);
|
||||
assert!(!m.is_active(), "archived_segment 应不 active(白名单隔离)");
|
||||
|
||||
// "compressed" — 阶段2 待引入,白名单自动隔离
|
||||
// "compressed" — 白名单自动隔离
|
||||
let mut m = ChatMessage::user("hi");
|
||||
m.status = Some("compressed".to_string());
|
||||
m.status = Some(MessageStatus::Compressed);
|
||||
assert!(!m.is_active(), "compressed 应不 active(白名单隔离)");
|
||||
}
|
||||
|
||||
// ---------- F-260614-05 Phase 2a ContentPart ----------
|
||||
// ---------- ContentPart ----------
|
||||
|
||||
/// 老 JSON(无 parts 字段)反序列化时 parts 应为 None(向前兼容)
|
||||
#[test]
|
||||
@@ -272,6 +315,12 @@ mod tests {
|
||||
model: None,
|
||||
status: None,
|
||||
reasoning_content: None,
|
||||
prompt_tokens: None,
|
||||
completion_tokens: None,
|
||||
prompt_cache_hit_tokens: None,
|
||||
prompt_cache_miss_tokens: None,
|
||||
reasoning_tokens: None,
|
||||
is_estimated: None,
|
||||
timestamp: None,
|
||||
};
|
||||
assert_eq!(m.content, "字面量构造");
|
||||
@@ -324,6 +373,12 @@ mod tests {
|
||||
model: None,
|
||||
status: None,
|
||||
reasoning_content: Some("thinking process".to_string()),
|
||||
prompt_tokens: None,
|
||||
completion_tokens: None,
|
||||
prompt_cache_hit_tokens: None,
|
||||
prompt_cache_miss_tokens: None,
|
||||
reasoning_tokens: None,
|
||||
is_estimated: None,
|
||||
timestamp: None,
|
||||
};
|
||||
let json = serde_json::to_string(&m).unwrap();
|
||||
@@ -333,7 +388,7 @@ mod tests {
|
||||
assert_eq!(deserialized.reasoning_content, Some("thinking process".to_string()));
|
||||
}
|
||||
|
||||
// ---------- F-260619-04 消息级溯源:id 字段 ----------
|
||||
// ---------- 消息级溯源:id 字段 ----------
|
||||
|
||||
/// 所有便捷构造函数默认生成非 None 的 id(ULID 风格)
|
||||
#[test]
|
||||
@@ -379,6 +434,12 @@ mod tests {
|
||||
model: None,
|
||||
status: None,
|
||||
reasoning_content: None,
|
||||
prompt_tokens: None,
|
||||
completion_tokens: None,
|
||||
prompt_cache_hit_tokens: None,
|
||||
prompt_cache_miss_tokens: None,
|
||||
reasoning_tokens: None,
|
||||
is_estimated: None,
|
||||
timestamp: None,
|
||||
};
|
||||
let json = serde_json::to_string(&m).unwrap();
|
||||
@@ -411,6 +472,7 @@ mod tests {
|
||||
usage: None,
|
||||
error: None,
|
||||
reasoning_content: Some("thought".to_string()),
|
||||
truncated_by_max_tokens: false,
|
||||
};
|
||||
let json = serde_json::to_string(&chunk).unwrap();
|
||||
assert!(json.contains("reasoning_content"));
|
||||
@@ -426,7 +488,7 @@ mod tests {
|
||||
let resp = CompletionResponse {
|
||||
text: "ok".to_string(),
|
||||
model: "r1".to_string(),
|
||||
usage: TokenUsage { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
||||
usage: TokenUsage { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30, ..Default::default() },
|
||||
tool_calls: None,
|
||||
reasoning_content: Some("r1 thought".to_string()),
|
||||
};
|
||||
@@ -436,4 +498,55 @@ mod tests {
|
||||
let deserialized: CompletionResponse = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized.reasoning_content, Some("r1 thought".to_string()));
|
||||
}
|
||||
|
||||
/// CR-空 id:tool_call_id_or_fallback 共享 helper —— 空 raw → fallback,非空原样。
|
||||
#[test]
|
||||
fn tool_call_id_or_fallback_non_empty_passthrough() {
|
||||
// 非空 raw 原样透传(provider 真 id 如 call_xxx 保留),与 index/prefix 无关
|
||||
assert_eq!(tool_call_id_or_fallback("call_abc", 0, "gen_tool"), "call_abc");
|
||||
assert_eq!(tool_call_id_or_fallback("x", 5, "p"), "x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_call_id_or_fallback_empty_starts_with_prefix() {
|
||||
// 空 raw → "{prefix}_{n}",n 取自全局计数器(跨进程实例从 0 起,单测不假设具体值)
|
||||
let a = tool_call_id_or_fallback("", 0, "gen_tool");
|
||||
assert!(a.starts_with("gen_tool_"), "空 fallback 应以 gen_tool_ 开头, got: {a}");
|
||||
let b = tool_call_id_or_fallback("", 7, "gen_stream");
|
||||
assert!(b.starts_with("gen_stream_"), "空 fallback 应以 gen_stream_ 开头, got: {b}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_call_id_or_fallback_empty_globally_unique() {
|
||||
// 跨轮跨 assistant 唯一:连续两次空 fallback id 必不同(全局计数器递增)。
|
||||
// 这是修复 af2fab4e 跨轮重复(旧单轮 index 跨轮都从 0 起 → 重复)的核心断言。
|
||||
let a = tool_call_id_or_fallback("", 0, "gen_tool");
|
||||
let b = tool_call_id_or_fallback("", 0, "gen_tool");
|
||||
assert_ne!(a, b, "两次空 fallback 应不同(全局计数器跨轮唯一): {a} vs {b}");
|
||||
// 即使同 index(模拟跨轮 index 都从 0 起),fallback 也必唯一
|
||||
let c = tool_call_id_or_fallback("", 0, "gen_tool");
|
||||
let mut set = std::collections::HashSet::new();
|
||||
assert!(set.insert(a), "fallback a 应唯一");
|
||||
assert!(set.insert(b), "fallback b 应唯一");
|
||||
assert!(set.insert(c), "fallback c 应唯一");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_call_id_or_fallback_index_unused() {
|
||||
// index 参数仅为签名兼容保留(4 处调用点都传),fallback 不再使用 index。
|
||||
// 同 prefix + 同 index 连续两次 → 不同 fallback(全局计数器递增,与 index 无关)。
|
||||
let a = tool_call_id_or_fallback("", 3, "gen_tool");
|
||||
let b = tool_call_id_or_fallback("", 3, "gen_tool");
|
||||
assert_ne!(a, b, "同 index 两次空 fallback 应不同: {a} vs {b}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_call_id_or_fallback_prefix_distinguishes_sources() {
|
||||
// 不同 prefix 区分来源(同步 gen_tool / 流式 gen_stream / anthropic gen_anthropic)
|
||||
// 注意:两次空 fallback 因全局计数器递增 id 不同,故只比 prefix 前缀
|
||||
let a = tool_call_id_or_fallback("", 0, "gen_tool");
|
||||
let b = tool_call_id_or_fallback("", 0, "gen_stream");
|
||||
assert!(a.starts_with("gen_tool_"));
|
||||
assert!(b.starts_with("gen_stream_"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ pub struct CompletionRequest {
|
||||
|
||||
/// 多模态消息内容片。Text 片为字符串;Image 片可走 url 或 base64(二选一,base64 非空时 url 忽略)。
|
||||
///
|
||||
/// F-260614-05 Phase 2a 后端:ContentPart 作为 `ChatMessage.parts` 的元素类型。
|
||||
/// ContentPart 作为 `ChatMessage.parts` 的元素类型。
|
||||
/// 设计上 `content: String`(纯文本主载荷)保持不变,多模态片挂在 `parts`:
|
||||
/// 这样未接入多模态的调用方(audit/title/commands/knowledge_inject 等读 content 当字符串)
|
||||
/// 零回归,避免一次性改全仓。provider 转换层在 `has_image()` 为真时把 parts 透传给
|
||||
@@ -68,6 +68,22 @@ pub enum ContentPart {
|
||||
},
|
||||
}
|
||||
|
||||
/// 消息来源(#958 溯源):外部渠道进入会话的原始消息定位。序列化为 JSON 随消息
|
||||
/// 落库(ai_messages.source 列);桌面本端消息恒 None 不落
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct MessageSource {
|
||||
/// 渠道标识:"feishu"(后续可扩 openapi/webhook 等)
|
||||
pub channel: String,
|
||||
/// 渠道侧原始消息 ID(飞书 om_/oc_ 前缀,可跳原消息追溯)
|
||||
pub message_id: String,
|
||||
/// 渠道侧会话/群 ID(可选,群聊定位)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub chat_id: Option<String>,
|
||||
/// 发送者标识(飞书 open_id,可选)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub sender: Option<String>,
|
||||
}
|
||||
|
||||
/// 聊天消息
|
||||
///
|
||||
/// ⚠️ **迁移耦合点**:下方各字段名(`role`/`content`/`parts`/`tool_call_id`/
|
||||
@@ -79,13 +95,18 @@ pub enum ContentPart {
|
||||
pub struct ChatMessage {
|
||||
/// 消息全局唯一 ID。用于消息级溯源(source_ref / audit message_id / idea source)。
|
||||
/// 构造时由 `new_message_id()` 生成;老 JSON 反序列化为 None(向前兼容)。
|
||||
/// 消息拆分存储(F-260619-03)后,此 ID 即 `ai_messages.id` 列主键。
|
||||
/// 消息拆分存储后,此 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)。
|
||||
/// 消息来源(#958 溯源):消息从哪个渠道/端点进入会话。None=桌面本端(既有语义,
|
||||
/// 老数据反序列化为 None 零回归);Some=外部渠道(飞书 message_id 可跳原消息追溯,
|
||||
/// 多端混发可区分来源端)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub source: Option<MessageSource>,
|
||||
/// 多模态内容片。
|
||||
///
|
||||
/// `None`/空 → 纯文本消息(绝大多数场景,content 即全部载荷)。
|
||||
/// `Some(含 Image 片)` → 多模态消息,provider 在 `has_image()` 为真时把 parts
|
||||
@@ -103,11 +124,11 @@ pub struct ChatMessage {
|
||||
/// 生成该消息的 model(仅 assistant 消息有,消息级 model 追溯)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
/// 消息状态(UX-09 编辑重生成):None/"active" 正常可见;
|
||||
/// 消息状态(UX-09 编辑重生成):None 正常可见;
|
||||
/// "truncated" 软删(编辑某条 user 消息后其后续消息标记,保留 DB 可追溯但不进 LLM 上下文、前端视图过滤)
|
||||
/// 默认 None(向前兼容老 JSON 反序列化)。落库随 messages JSON 序列化,无需独立列。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub status: Option<String>,
|
||||
pub status: Option<MessageStatus>,
|
||||
/// DeepSeek thinking 模式的推理内容(多轮需回传)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_content: Option<String>,
|
||||
@@ -115,6 +136,33 @@ pub struct ChatMessage {
|
||||
/// provider 请求映射不读此字段(构造器打戳→映射忽略,不进 LLM 请求),老数据反序列化为 None。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub timestamp: Option<i64>,
|
||||
/// 本轮 LLM 调用的输入 token 用量(仅 assistant 消息,消息级 token 持久化)。
|
||||
/// 双轨:消息级(本字段,新)+ 会话级累计(ai_conversations.prompt_tokens,旧,保留)。
|
||||
/// 用于 reload/压缩/切会话后历史 assistant 消息 token 显示;老 JSON 反序列化为 None(向前兼容)。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_tokens: Option<u32>,
|
||||
/// 本轮 LLM 调用的输出 token 用量(仅 assistant 消息,消息级 token 持久化)。
|
||||
/// 语义同 prompt_tokens;provider 流式 usage 缺失时(GLM 等)可能为 0。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub completion_tokens: Option<u32>,
|
||||
/// 缓存命中 token(低价,deepseek prompt_cache_hit / anthropic cache_read)。
|
||||
/// token 分项显示(2026-08-02):前端 in=miss(全价真实)+ cache=hit(命中) 分计费展示。
|
||||
/// 老 JSON 反序列化为 None(向前兼容);非 cache provider 恒为 0。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_cache_hit_tokens: Option<u32>,
|
||||
/// 未命中 token(全价真实输入,deepseek prompt_cache_miss / anthropic cache_creation)。
|
||||
/// 前端 in 显示用此字段(非 prompt_tokens 总,避免掩盖命中比例)。
|
||||
/// 老 JSON 反序列化为 None(向前兼容)。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_cache_miss_tokens: Option<u32>,
|
||||
/// 思考 token(deepseek-reasoner/o1 reasoning_tokens,隐藏输出)。
|
||||
/// 前端仅 > 0 时显示(reason 后缀);老 JSON 反序列化为 None。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_tokens: Option<u32>,
|
||||
/// 本轮 prompt 是否估算值(round_usage.prompt_tokens==0 → estimated_prompt 兜底打标)。
|
||||
/// 供 reload 逐条回显「估算」标注,对齐 live 态 AiCompleted.is_estimated;老消息 None(向前兼容)。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub is_estimated: Option<bool>,
|
||||
}
|
||||
|
||||
/// 当前 Unix 毫秒(ChatMessage 打戳用;df-ai-core 不依赖 df-types,内联避免新增依赖)。
|
||||
@@ -148,11 +196,91 @@ pub enum MessageRole {
|
||||
Tool,
|
||||
}
|
||||
|
||||
/// 消息状态枚举(IPC 边界序列化为小写 snake_case 字符串)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MessageStatus {
|
||||
/// 正常可见
|
||||
Active,
|
||||
/// 软删(编辑某条 user 消息后其后续消息标记)
|
||||
Truncated,
|
||||
/// 已压缩
|
||||
Compressed,
|
||||
/// 已归档段
|
||||
ArchivedSegment,
|
||||
}
|
||||
|
||||
impl MessageStatus {
|
||||
/// DB 存储用的小写 snake_case 字符串
|
||||
pub fn as_db_str(&self) -> &'static str {
|
||||
match self {
|
||||
MessageStatus::Active => "active",
|
||||
MessageStatus::Truncated => "truncated",
|
||||
MessageStatus::Compressed => "compressed",
|
||||
MessageStatus::ArchivedSegment => "archived_segment",
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 DB 字符串解析
|
||||
pub fn from_db_str(s: &str) -> Option<Self> {
|
||||
Some(match s {
|
||||
"active" => MessageStatus::Active,
|
||||
"truncated" => MessageStatus::Truncated,
|
||||
"compressed" => MessageStatus::Compressed,
|
||||
"archived_segment" => MessageStatus::ArchivedSegment,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// ChatMessage 结构体
|
||||
/// 工具类型(IPC 边界透明序列化为字符串,如 "function")
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
#[serde(transparent)]
|
||||
pub struct ToolType(String);
|
||||
|
||||
impl ToolType {
|
||||
/// 构造新工具类型
|
||||
pub fn new(s: impl Into<String>) -> Self {
|
||||
Self(s.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ToolType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for ToolType {
|
||||
fn from(s: String) -> Self {
|
||||
Self(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for ToolType {
|
||||
fn from(s: &str) -> Self {
|
||||
Self(s.to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<&str> for ToolType {
|
||||
fn eq(&self, other: &&str) -> bool {
|
||||
self.0 == *other
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<str> for ToolType {
|
||||
fn eq(&self, other: &str) -> bool {
|
||||
self.0 == other
|
||||
}
|
||||
}
|
||||
|
||||
/// 工具定义
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolDefinition {
|
||||
#[serde(rename = "type")]
|
||||
pub tool_type: String,
|
||||
pub tool_type: ToolType,
|
||||
pub function: ToolFunction,
|
||||
}
|
||||
|
||||
@@ -169,7 +297,7 @@ pub struct ToolFunction {
|
||||
pub struct ToolCall {
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub call_type: String,
|
||||
pub call_type: ToolType,
|
||||
pub function: ToolCallFunction,
|
||||
}
|
||||
|
||||
@@ -198,11 +326,32 @@ pub struct CompletionResponse {
|
||||
}
|
||||
|
||||
/// Token 用量
|
||||
///
|
||||
/// 分项字段(token 分项显示 + 详情面板,2026-08-02):
|
||||
/// - `prompt_tokens`:输入总(= cache_hit + cache_miss,兼容老链路保留;前端不再单独展示,
|
||||
/// 改用 cache_miss 作 in 真实全价消耗)。
|
||||
/// - `completion_tokens`:输出。
|
||||
/// - `total_tokens`:总计(各 provider 计费不同,相加无意义,前端不显;保留供老链路/日志)。
|
||||
/// - `prompt_cache_hit_tokens`:缓存命中(低价,deepseek/anthropic cache_read)。
|
||||
/// - `prompt_cache_miss_tokens`:未命中(全价真实输入,deepseek/openai 扩展字段)。
|
||||
/// - `reasoning_tokens`:思考(deepseek-reasoner/o1 隐藏输出 token)。
|
||||
///
|
||||
/// 不同 provider 字段名不同,serde default 兜底(无则 0),向前兼容老响应。
|
||||
/// 构造点全用 `..Default::default()` 补缺,详见各 provider 解析点。
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct TokenUsage {
|
||||
pub prompt_tokens: u32,
|
||||
pub completion_tokens: u32,
|
||||
pub total_tokens: u32,
|
||||
/// 缓存命中 token(低价,deepseek prompt_cache_hit / anthropic cache_read_input_tokens)
|
||||
#[serde(default)]
|
||||
pub prompt_cache_hit_tokens: u32,
|
||||
/// 未命中 token(全价真实输入,deepseek prompt_cache_miss / anthropic cache_creation)
|
||||
#[serde(default)]
|
||||
pub prompt_cache_miss_tokens: u32,
|
||||
/// 思考 token(deepseek-reasoner/o1 reasoning_tokens,隐藏输出)
|
||||
#[serde(default)]
|
||||
pub reasoning_tokens: u32,
|
||||
}
|
||||
|
||||
/// 流式输出的 chunk
|
||||
@@ -222,6 +371,11 @@ pub struct StreamChunk {
|
||||
/// 非空表示流中途出错,不应视为正常完成(finished 路径),由 stream_llm 转 AiError。
|
||||
#[serde(skip)]
|
||||
pub error: Option<String>,
|
||||
/// 本流因 provider 输出上限截断收尾(openai finish_reason=length /
|
||||
/// anthropic stop_reason=max_tokens)。finished 仍为 true(正常终止非断连),但文本/
|
||||
/// tool_calls 可能不完整;消费方跨帧 OR 累积该标志,轮产出据此标记截断,不冒充正常完成。
|
||||
#[serde(default)]
|
||||
pub truncated_by_max_tokens: bool,
|
||||
/// DeepSeek thinking 模式推理内容增量
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_content: Option<String>,
|
||||
|
||||
@@ -12,8 +12,13 @@ tokio = { workspace = true, features = ["sync", "time"] }
|
||||
async-trait = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
base64 = "0.22"
|
||||
|
||||
# HTTP + 流式
|
||||
reqwest = { version = "0.12", features = ["stream", "json"] }
|
||||
reqwest = { version = "0.12", features = ["stream", "json", "rustls-tls"] }
|
||||
bytes = "1"
|
||||
futures = "0.3"
|
||||
eventsource-stream = "0.2"
|
||||
rand = "0.8"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
//! 独立诊断:用 df-ai 真实调用 GLM anthropic 流式端点,验证
|
||||
//! sse_parser + apply_anthropic_event + provider.stream() 整条链路。
|
||||
//!
|
||||
//! 二分定位「发消息卡掉」: 若本例能正常吐 chunk → provider 层(df-ai)OK,
|
||||
//! 问题在 devflow 应用层(provider 配置/emit/前端); 若卡/空/Err → df-ai 有 bug。
|
||||
//!
|
||||
//! 运行: cd crates/df-ai && cargo run --example glm_stream_test
|
||||
use df_ai::build_provider;
|
||||
use df_ai_core::CompletionRequest;
|
||||
use futures::StreamExt;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let token = std::env::var("ANTHROPIC_AUTH_TOKEN")
|
||||
.or_else(|_| std::env::var("ANTHROPIC_API_KEY"))
|
||||
.expect("需要环境变量 ANTHROPIC_AUTH_TOKEN");
|
||||
eprintln!("[glm-test] token len={}", token.len());
|
||||
|
||||
let provider = build_provider(
|
||||
"anthropic",
|
||||
"https://open.bigmodel.cn/api/anthropic",
|
||||
&token,
|
||||
"glm-5.2",
|
||||
);
|
||||
|
||||
// 用 JSON 反序列化构造请求,绕开字段列表(devflow 实际用 glm-5.2)
|
||||
let req: CompletionRequest = serde_json::from_str(
|
||||
r#"{"model":"glm-5.2","stream":true,"max_tokens":16,"messages":[{"role":"user","content":"说你好"}]}"#,
|
||||
)
|
||||
.expect("parse CompletionRequest");
|
||||
|
||||
eprintln!("[glm-test] 调用 provider.stream() ...");
|
||||
let t0 = std::time::Instant::now();
|
||||
let mut s = match provider.stream(req).await {
|
||||
Ok(s) => {
|
||||
eprintln!("[glm-test] stream() Ok, 建连耗时 {:?}", t0.elapsed());
|
||||
s
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[glm-test] stream() Err: {:#}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut n = 0;
|
||||
let mut got_text = false;
|
||||
while let Some(chunk_result) = s.next().await {
|
||||
n += 1;
|
||||
match chunk_result {
|
||||
Ok(chunk) => {
|
||||
if !chunk.delta.is_empty() {
|
||||
got_text = true;
|
||||
}
|
||||
eprintln!(
|
||||
"[glm-test] chunk#{} delta={:?} reasoning={:?} finished={} usage={:?} err={:?}",
|
||||
n, chunk.delta,
|
||||
chunk.reasoning_content.as_deref().map(|s| if s.len() > 30 { format!("{}..", &s[..30]) } else { s.to_string() }),
|
||||
chunk.finished, chunk.usage, chunk.error
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[glm-test] chunk#{} Err: {}", n, e);
|
||||
}
|
||||
}
|
||||
if n > 60 {
|
||||
eprintln!("[glm-test] 超 60 chunk 截断");
|
||||
break;
|
||||
}
|
||||
}
|
||||
eprintln!(
|
||||
"[glm-test] 流结束 共 {} chunk, 是否拿到文本={}, 总耗时 {:?}",
|
||||
n,
|
||||
got_text,
|
||||
t0.elapsed()
|
||||
);
|
||||
}
|
||||
@@ -127,6 +127,41 @@
|
||||
"weight": 65,
|
||||
"context_window": 128000
|
||||
},
|
||||
{
|
||||
"model_id": "sensenova-6.7-flash-lite",
|
||||
"enabled": true,
|
||||
"modalities": ["text", "vision"],
|
||||
"capabilities": ["tool_use"],
|
||||
"weight": 85,
|
||||
"context_window": 262144,
|
||||
"temperature_supported": false
|
||||
},
|
||||
{
|
||||
"model_id": "sensenova-6.8-flash-lite",
|
||||
"enabled": true,
|
||||
"modalities": ["text", "vision"],
|
||||
"capabilities": ["tool_use"],
|
||||
"weight": 85,
|
||||
"context_window": 262144,
|
||||
"temperature_supported": false
|
||||
},
|
||||
{
|
||||
"model_id": "sn2-kimi",
|
||||
"enabled": true,
|
||||
"modalities": ["text"],
|
||||
"capabilities": ["tool_use"],
|
||||
"weight": 80,
|
||||
"context_window": 131072,
|
||||
"temperature_supported": false
|
||||
},
|
||||
{
|
||||
"model_id": "deepseek-v4-flash",
|
||||
"enabled": true,
|
||||
"modalities": ["text"],
|
||||
"capabilities": ["tool_use"],
|
||||
"weight": 80,
|
||||
"context_window": 1048576
|
||||
},
|
||||
{
|
||||
"model_id": "embedding-3",
|
||||
"enabled": true,
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::{LazyLock, RwLock};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
@@ -40,10 +41,328 @@ pub struct AiTool {
|
||||
pub definition: ToolDefinition,
|
||||
/// 风险级别
|
||||
pub risk_level: RiskLevel,
|
||||
/// 工具元数据(DRY 债 #5 收敛:注册点声明,消费方按条目或全局表查询,不再散布 match 表)
|
||||
pub meta: ToolMeta,
|
||||
/// 执行处理器
|
||||
pub handler: AiToolHandler,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 工具元数据(DRY 债 #5)
|
||||
//
|
||||
// 此前工具元数据以 5 处手写 match 表散布在调用侧:
|
||||
// path_auth.rs(路径参数键) / agentic/helpers.rs 两处(目标描述 + 漂移签名) /
|
||||
// audit/data_change.rs(entity-action) / audit/cache.rs(只读缓存白名单 + 目录级 key) /
|
||||
// tool_registry.rs(审批文案模板 + 轻量动词标签),新增工具需人肉同步 5-6 处且已漂移。
|
||||
// 收敛后元数据仅在 declare_tool! 注册点声明一次,上述消费方全部改查本表;
|
||||
// 未注册工具走各消费方原保守 default(路径不校验/不缓存/不联动)。
|
||||
//
|
||||
// 维度按 5 处旧表的实际消费盘点,不为用不上的维度加字段:
|
||||
// - path_args:路径授权预校验提取哪些参数键(rename_file 为 from+to,其余文件工具为 path)
|
||||
// - readonly:只读幂等工具(结果可缓存)
|
||||
// - cache_path_key:结果缓存 key 只取 path(目录级去重,同目录换递归参数也判重复)
|
||||
// - deny_unauthorized:路径未授权时硬拒而非弹窗(search_files 盲搜语义应拒不应询)
|
||||
// - entity_action:数据变更联动刷新的 (entity, action)
|
||||
// - approval_hint:审批卡文案模板 + 关键参数键
|
||||
// - display_verb:轻量中文动作标签
|
||||
// - goal:从工具调用推理目标描述(动词 + 参数键 + 取值风格 + 缺参兜底文案)
|
||||
// - sig:漂移检测签名取哪些参数组(组内多键为取值回退序,数值键按 JSON 形态序列化;
|
||||
// 注册点未覆盖但换值即换操作的键由 SIG_EXTRA_GROUPS 补参,见 tool_signature)
|
||||
// ============================================================
|
||||
|
||||
/// 取值风格(goal 维度):从参数值到展示文本的取法
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum GoalStyle {
|
||||
/// 取路径末段文件名(与旧实现一致按 / 切分)
|
||||
FileName,
|
||||
/// 原样
|
||||
Raw,
|
||||
/// 超 25 字节截断(字符边界回退,防多字节字符中间切 panic)
|
||||
Truncate,
|
||||
}
|
||||
|
||||
/// 目标描述元数据:infer_goal_from_tool_calls 消费
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct GoalMeta {
|
||||
/// 动词前缀(如 "修改"/"查看"/"搜索")
|
||||
pub verb: &'static str,
|
||||
/// 参数键,按序取第一个存在的键
|
||||
pub keys: &'static [&'static str],
|
||||
/// 取值风格
|
||||
pub style: GoalStyle,
|
||||
/// 键缺失/解析失败时的兜底文案(如 "搜索文件");None 表示返回 None
|
||||
pub fallback: Option<&'static str>,
|
||||
}
|
||||
|
||||
/// 签名取值类型:字符串键取原始串,数值键按 JSON 序列化形态
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SigValKind {
|
||||
/// 字符串取值(as_str,缺失为空串)
|
||||
Str,
|
||||
/// Value::to_string 形态(数字 50 -> "50",布尔 true -> "true");组内全键参与归一化
|
||||
/// (排序拼接,缺失键空占位)
|
||||
Json,
|
||||
}
|
||||
|
||||
/// 漂移检测签名元数据:tool_call_signature 消费
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct SigMeta {
|
||||
/// 有序参数组;组内多键:Str 组为取值回退序(如 pattern 缺失落 query),Json 组为
|
||||
/// 全键参与(排序拼接,防同组其他键换值被压成同签名)
|
||||
pub groups: &'static [(&'static [&'static str], SigValKind)],
|
||||
}
|
||||
|
||||
/// 工具元数据(全部为编译期静态值,Copy 廉价传递)
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
||||
pub struct ToolMeta {
|
||||
/// 路径授权预校验的参数键;空 = 非文件工具(不预校验,保守 default)
|
||||
pub path_args: &'static [&'static str],
|
||||
/// 只读幂等工具(结果可安全缓存)
|
||||
pub readonly: bool,
|
||||
/// 结果缓存 key 只取 path(目录级去重)
|
||||
pub cache_path_key: bool,
|
||||
/// 路径未授权时硬拒(不弹授权窗)
|
||||
pub deny_unauthorized: bool,
|
||||
/// 数据变更联动 (entity, action)
|
||||
pub entity_action: Option<(&'static str, &'static str)>,
|
||||
/// 审批文案模板 + 关键参数键(模板 {} 按位置填充)
|
||||
pub approval_hint: Option<(&'static str, &'static [&'static str])>,
|
||||
/// 轻量中文动作标签
|
||||
pub display_verb: Option<&'static str>,
|
||||
/// 目标描述元数据
|
||||
pub goal: Option<GoalMeta>,
|
||||
/// 漂移检测签名元数据
|
||||
pub sig: Option<SigMeta>,
|
||||
}
|
||||
|
||||
/// 进程级工具元数据表。register_meta 写入(注册点 = 唯一写入口),
|
||||
/// path_auth / cache / agentic helpers 等无 registry 句柄的自由函数按名查询。
|
||||
static GLOBAL_TOOL_META: LazyLock<RwLock<HashMap<String, ToolMeta>>> =
|
||||
LazyLock::new(|| RwLock::new(HashMap::new()));
|
||||
|
||||
/// 按工具名查元数据(查全局表,未注册返回 None)
|
||||
pub fn tool_meta(name: &str) -> Option<ToolMeta> {
|
||||
GLOBAL_TOOL_META
|
||||
.read()
|
||||
.expect("全局工具元数据表锁")
|
||||
.get(name)
|
||||
.copied()
|
||||
}
|
||||
|
||||
/// 全局元数据表是否为空(测试种子判定用;生产注册先于任何查询,表恒非空)
|
||||
#[doc(hidden)]
|
||||
pub fn meta_table_is_empty() -> bool {
|
||||
GLOBAL_TOOL_META.read().expect("全局工具元数据表锁").is_empty()
|
||||
}
|
||||
|
||||
/// 路径授权预校验参数键(未注册工具返回空切片,即非文件工具)
|
||||
pub fn path_arg_keys(name: &str) -> &'static [&'static str] {
|
||||
tool_meta(name).map(|m| m.path_args).unwrap_or(&[])
|
||||
}
|
||||
|
||||
/// 是否只读幂等工具(未注册返回 false,即不缓存)
|
||||
pub fn is_readonly(name: &str) -> bool {
|
||||
tool_meta(name).map(|m| m.readonly).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 结果缓存 key 是否只取 path(未注册返回 false)
|
||||
pub fn cache_path_key_only(name: &str) -> bool {
|
||||
tool_meta(name).map(|m| m.cache_path_key).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 路径未授权是否硬拒(未注册返回 false,走弹窗)
|
||||
pub fn denies_unauthorized(name: &str) -> bool {
|
||||
tool_meta(name).map(|m| m.deny_unauthorized).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 数据变更联动 (entity, action)(未注册返回 None,不 emit)
|
||||
pub fn entity_action(name: &str) -> Option<(&'static str, &'static str)> {
|
||||
tool_meta(name).and_then(|m| m.entity_action)
|
||||
}
|
||||
|
||||
/// 审批文案模板(未注册返回 None,调用方走 fallback)
|
||||
pub fn approval_hint(name: &str) -> Option<(&'static str, &'static [&'static str])> {
|
||||
tool_meta(name).and_then(|m| m.approval_hint)
|
||||
}
|
||||
|
||||
/// 轻量中文动作标签(未注册返回 None)
|
||||
pub fn display_verb(name: &str) -> Option<&'static str> {
|
||||
tool_meta(name).and_then(|m| m.display_verb)
|
||||
}
|
||||
|
||||
/// 目标描述元数据(未注册返回 None)
|
||||
pub fn goal_of(name: &str) -> Option<GoalMeta> {
|
||||
tool_meta(name).and_then(|m| m.goal)
|
||||
}
|
||||
|
||||
/// 漂移检测签名元数据(未注册返回 None,调用方走全参兜底)
|
||||
pub fn sig_of(name: &str) -> Option<SigMeta> {
|
||||
tool_meta(name).and_then(|m| m.sig)
|
||||
}
|
||||
|
||||
/// 按路径参数键从 args 提取路径列表(键序即提取序,缺失键跳过)
|
||||
pub fn extract_paths(keys: &'static [&'static str], args: &Value) -> Vec<String> {
|
||||
keys.iter()
|
||||
.filter_map(|k| args.get(*k).and_then(|x| x.as_str()).map(|s| s.to_string()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 由目标元数据渲染目标描述。args 为 None(调用参数 JSON 解析失败)时仅兜底文案可产出。
|
||||
pub fn goal_text(goal: GoalMeta, args: Option<&Value>) -> Option<String> {
|
||||
let value = args
|
||||
.and_then(|a| goal.keys.iter().find_map(|k| a.get(k)))
|
||||
.and_then(|v| v.as_str());
|
||||
let rendered = value.map(|v| match goal.style {
|
||||
GoalStyle::FileName => {
|
||||
// 按旧实现仅按 / 取末段;rsplit 恒有首项,反斜杠路径在此保持原样(行为对齐)
|
||||
let f = v.rsplit('/').next().unwrap_or(v);
|
||||
format!("{} {}", goal.verb, f)
|
||||
}
|
||||
GoalStyle::Raw => format!("{} {}", goal.verb, v),
|
||||
GoalStyle::Truncate => {
|
||||
let mut end = 25.min(v.len());
|
||||
while end > 0 && !v.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
format!("{} {}", goal.verb, &v[..end])
|
||||
}
|
||||
});
|
||||
rendered.or_else(|| goal.fallback.map(|f| f.to_string()))
|
||||
}
|
||||
|
||||
/// 组渲染口径:Always = 基础组逐组渲染,缺键空值占位(消费方按固定串断言,形态不可漂移);
|
||||
/// Present = 补参口径,只渲染 args 中实际出现的键,键未出现视同未换参,不渲染空占位。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum GroupPresence {
|
||||
Always,
|
||||
Present,
|
||||
}
|
||||
|
||||
/// 组渲染:每组产出 k=v 片段,组间逗号连接。Str 组标签恒为首键(与旧实现
|
||||
/// pair("pattern", ...) 一致,取值来自回退键时标签不变),取组内首个字符串值键的值,
|
||||
/// 全非字符串落末键。Json 组全键参与归一化:数值/布尔键换值即换操作,只取 keys[0] 会把
|
||||
/// 同组其他键换值的不同调用压成同一签名(G2 熔断以签名为重复判据,压缩即误判重复),
|
||||
/// 排序后拼接保证与键序无关的确定性。
|
||||
fn render_groups(
|
||||
groups: &'static [(&'static [&'static str], SigValKind)],
|
||||
args: &Value,
|
||||
presence: GroupPresence,
|
||||
) -> String {
|
||||
groups
|
||||
.iter()
|
||||
.filter_map(|(keys, kind)| match kind {
|
||||
SigValKind::Json => {
|
||||
let mut parts: Vec<String> = keys
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|k| presence == GroupPresence::Always || args.get(*k).is_some())
|
||||
.map(|k| match args.get(k) {
|
||||
Some(v) => format!("{}={}", k, v.to_string()),
|
||||
None => format!("{}=", k),
|
||||
})
|
||||
.collect();
|
||||
parts.sort_unstable();
|
||||
if parts.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(parts.join("|"))
|
||||
}
|
||||
}
|
||||
SigValKind::Str => {
|
||||
if presence == GroupPresence::Present {
|
||||
// 补参组恒单键:键未出现整组跳过,值非字符串视同缺失
|
||||
return args
|
||||
.get(keys[0])
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| format!("{}={}", keys[0], s));
|
||||
}
|
||||
let key = keys
|
||||
.iter()
|
||||
.find(|k| args.get(**k).and_then(|v| v.as_str()).is_some())
|
||||
.unwrap_or(&keys[keys.len() - 1]);
|
||||
Some(format!(
|
||||
"{}={}",
|
||||
keys[0],
|
||||
args.get(*key).and_then(|v| v.as_str()).unwrap_or("")
|
||||
))
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
}
|
||||
|
||||
/// 签名体:基础组按 Always 口径渲染。
|
||||
pub fn sig_body(sig: SigMeta, args: &Value) -> String {
|
||||
render_groups(sig.groups, args, GroupPresence::Always)
|
||||
}
|
||||
|
||||
/// 签名补参表:注册点 SigMeta 组未覆盖、但「换值即换操作」的键,按工具名追加进签名。
|
||||
///
|
||||
/// 为什么在 df-ai 签名层:G2 熔断以签名为重复判据,签名过粗会把合法换参误判成
|
||||
/// 「同一操作反复」(同工具换参是不同操作)。注册点 SigMeta 声明主参数组,换参敏感
|
||||
/// 但未覆盖的键由本表统一补参;若日后注册点把这些键并入 groups,须同步从本表移除
|
||||
/// (双源会重复渲染同键,不改变同异判定但产生冗余片段)。
|
||||
///
|
||||
/// 渲染走 Present 口径(键出现在 args 才进签名):补参键缺省 = 未换参,既有签名逐字
|
||||
/// 不变。纯格式/开关类键(-n/-C/context_chars/timeout_secs)不补,变体不构成意图变化,
|
||||
/// 补进签名只会稀释重复判据。
|
||||
static SIG_EXTRA_GROUPS: &[(&str, &'static [(&'static [&'static str], SigValKind)])] = &[
|
||||
// read_file:同 path 换 search 是换词定位,非重复读
|
||||
("read_file", &[(&["search"], SigValKind::Str)]),
|
||||
// search_files:换 recursive 换搜索范围,换 offset/limit 是翻页续搜
|
||||
// (schema 无 -i/output_mode,大小写不敏感是内建语义非参数)
|
||||
(
|
||||
"search_files",
|
||||
&[
|
||||
(&["recursive"], SigValKind::Json),
|
||||
(&["offset"], SigValKind::Json),
|
||||
(&["limit"], SigValKind::Json),
|
||||
],
|
||||
),
|
||||
// grep:换 -i 是敏感/不敏感两种匹配,换 output_mode 是行内容/文件名/计数三种形态,
|
||||
// 换 max_results 是扩容重取(schema 无 offset/limit,数量参数实为 max_results)
|
||||
(
|
||||
"grep",
|
||||
&[
|
||||
(&["-i"], SigValKind::Json),
|
||||
(&["output_mode"], SigValKind::Str),
|
||||
(&["max_results"], SigValKind::Json),
|
||||
],
|
||||
),
|
||||
// run_command:同命令不同 working_dir 是在不同目录执行
|
||||
("run_command", &[(&["working_dir"], SigValKind::Str)]),
|
||||
];
|
||||
|
||||
/// 按工具名取补参组(未列名返回 None)
|
||||
fn sig_extra_groups(name: &str) -> Option<&'static [(&'static [&'static str], SigValKind)]> {
|
||||
SIG_EXTRA_GROUPS
|
||||
.iter()
|
||||
.find(|(n, _)| *n == name)
|
||||
.map(|(_, g)| *g)
|
||||
}
|
||||
|
||||
/// 工具调用签名:有 SigMeta 元数据按组渲染并追加补参组,未注册回退「工具名 + 全参 JSON」
|
||||
/// 保守兜底(全参已含补参键,无需追加)。
|
||||
///
|
||||
/// G2 判定语义:同工具同参连续 = 同签名,计重复;同工具换参 = 补参键进签名,签名不同
|
||||
/// 不累计(正常探索)。
|
||||
pub fn tool_signature(name: &str, args: &Value) -> String {
|
||||
match sig_of(name) {
|
||||
Some(sig) => {
|
||||
let mut body = render_groups(sig.groups, args, GroupPresence::Always);
|
||||
let extra = sig_extra_groups(name)
|
||||
.map(|g| render_groups(g, args, GroupPresence::Present))
|
||||
.unwrap_or_default();
|
||||
if !extra.is_empty() {
|
||||
body.push(',');
|
||||
body.push_str(&extra);
|
||||
}
|
||||
format!("{}:{}", name, body)
|
||||
}
|
||||
None => format!("{}:name={},args={}", name, name, args.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 工具处理器类型 — 异步函数,接收 JSON 参数,返回 JSON 结果
|
||||
pub type AiToolHandler =
|
||||
Box<dyn Fn(Value) -> Pin<Box<dyn Future<Output = anyhow::Result<Value>> + Send>> + Send + Sync>;
|
||||
@@ -65,7 +384,7 @@ impl AiToolRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
/// 注册一个工具
|
||||
/// 注册一个工具(无元数据,ToolMeta::default)
|
||||
pub fn register(
|
||||
&mut self,
|
||||
name: impl Into<String>,
|
||||
@@ -73,19 +392,47 @@ impl AiToolRegistry {
|
||||
parameters: Value,
|
||||
risk_level: RiskLevel,
|
||||
handler: AiToolHandler,
|
||||
) {
|
||||
self.register_meta(name, description, parameters, risk_level, ToolMeta::default(), handler);
|
||||
}
|
||||
|
||||
/// 注册一个工具并写入元数据。
|
||||
///
|
||||
/// 元数据在同一调用点写入两处,无双源漂移:
|
||||
/// - 注册表条目 AiTool.meta(持有 registry 实例的消费方查询)
|
||||
/// - 进程级 GLOBAL_TOOL_META 表(path_auth/cache/agentic helpers 等无
|
||||
/// registry 句柄的自由函数按工具名查询)
|
||||
pub fn register_meta(
|
||||
&mut self,
|
||||
name: impl Into<String>,
|
||||
description: impl Into<String>,
|
||||
parameters: Value,
|
||||
risk_level: RiskLevel,
|
||||
meta: ToolMeta,
|
||||
handler: AiToolHandler,
|
||||
) {
|
||||
let name_str = name.into();
|
||||
let definition = ToolDefinition::function(&name_str, description, parameters);
|
||||
GLOBAL_TOOL_META
|
||||
.write()
|
||||
.expect("全局工具元数据表锁")
|
||||
.insert(name_str.clone(), meta);
|
||||
self.tools.insert(
|
||||
name_str,
|
||||
AiTool {
|
||||
definition,
|
||||
risk_level,
|
||||
meta,
|
||||
handler,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 查询工具元数据条目(持有 registry 实例的消费方用)
|
||||
pub fn meta(&self, name: &str) -> Option<ToolMeta> {
|
||||
self.tools.get(name).map(|t| t.meta)
|
||||
}
|
||||
|
||||
/// 获取所有工具定义(发送给 LLM 的 tools 参数)
|
||||
pub fn tool_definitions(&self) -> Vec<ToolDefinition> {
|
||||
self.tools.values().map(|t| t.definition.clone()).collect()
|
||||
@@ -236,4 +583,183 @@ mod tests {
|
||||
assert_eq!(required.len(), 1);
|
||||
assert_eq!(required[0], "name");
|
||||
}
|
||||
|
||||
/// register_meta:条目与全局表同步可查,未注册名走保守 default。
|
||||
#[test]
|
||||
fn register_meta_stores_entry_and_global() {
|
||||
let mut reg = AiToolRegistry::new();
|
||||
let meta = ToolMeta {
|
||||
path_args: &["from", "to"],
|
||||
readonly: true,
|
||||
entity_action: Some(("task", "update")),
|
||||
..Default::default()
|
||||
};
|
||||
reg.register_meta("meta_probe", "测试", object_schema(vec![]), RiskLevel::Low, meta, dummy_handler());
|
||||
assert_eq!(reg.meta("meta_probe"), Some(meta));
|
||||
assert_eq!(tool_meta("meta_probe"), Some(meta));
|
||||
assert_eq!(path_arg_keys("meta_probe"), &["from", "to"]);
|
||||
assert!(is_readonly("meta_probe"));
|
||||
assert_eq!(entity_action("meta_probe"), Some(("task", "update")));
|
||||
// 未注册名保守 default:无路径键/非只读/无联动
|
||||
assert_eq!(path_arg_keys("never_registered"), &[] as &[&str]);
|
||||
assert!(!is_readonly("never_registered"));
|
||||
assert_eq!(entity_action("never_registered"), None);
|
||||
// 老签名 register 等价 default 元数据
|
||||
reg.register("plain_probe", "测试", object_schema(vec![]), RiskLevel::Low, dummy_handler());
|
||||
assert_eq!(reg.meta("plain_probe"), Some(ToolMeta::default()));
|
||||
}
|
||||
|
||||
/// goal_text 三种取值风格 + 键回退 + 兜底文案。
|
||||
#[test]
|
||||
fn goal_text_styles_and_fallback() {
|
||||
use serde_json::json;
|
||||
let file_name = GoalMeta { verb: "查看", keys: &["path"], style: GoalStyle::FileName, fallback: None };
|
||||
assert_eq!(goal_text(file_name, Some(&json!({"path": "a/b/c.rs"}))), Some("查看 c.rs".to_string()));
|
||||
let raw = GoalMeta { verb: "浏览", keys: &["path"], style: GoalStyle::Raw, fallback: None };
|
||||
assert_eq!(goal_text(raw, Some(&json!({"path": "x/y"}))), Some("浏览 x/y".to_string()));
|
||||
// Truncate:ASCII 超 25 字节截 25;多字节字符回退到字符边界不 panic
|
||||
let trunc = GoalMeta { verb: "执行", keys: &["command"], style: GoalStyle::Truncate, fallback: Some("执行命令") };
|
||||
let long = json!({"command": "a".repeat(40)});
|
||||
assert_eq!(goal_text(trunc, Some(&long)), Some(format!("执行 {}", "a".repeat(25))));
|
||||
let multibyte = json!({"command": "构建数据库连接池"});
|
||||
let out = goal_text(trunc, Some(&multibyte)).expect("多字节应正常产出");
|
||||
assert!(out.starts_with("执行 构建数据库"));
|
||||
// 键缺失走兜底;无兜底返 None
|
||||
assert_eq!(goal_text(trunc, Some(&json!({}))), Some("执行命令".to_string()));
|
||||
assert_eq!(goal_text(file_name, Some(&json!({}))), None);
|
||||
assert_eq!(goal_text(file_name, None), None);
|
||||
// 多键回退序:首键缺失取次键
|
||||
let multi = GoalMeta { verb: "搜索", keys: &["pattern", "query"], style: GoalStyle::Raw, fallback: Some("搜索文件") };
|
||||
assert_eq!(goal_text(multi, Some(&json!({"query": "todo"}))), Some("搜索 todo".to_string()));
|
||||
}
|
||||
|
||||
/// sig_body:组内字符串键回退 + Json 数值键形态 + 缺省空串。
|
||||
#[test]
|
||||
fn sig_body_groups_and_kinds() {
|
||||
use serde_json::json;
|
||||
let grep_sig = SigMeta { groups: &[(&["pattern", "query"], SigValKind::Str), (&["path", "glob"], SigValKind::Str)] };
|
||||
assert_eq!(sig_body(grep_sig, &json!({"pattern": "MAX", "path": "src"})), "pattern=MAX,path=src");
|
||||
assert_eq!(sig_body(grep_sig, &json!({"query": "TODO", "glob": "*.rs"})), "pattern=TODO,path=*.rs");
|
||||
assert_eq!(sig_body(grep_sig, &json!({})), "pattern=,path=");
|
||||
let read_sig = SigMeta { groups: &[(&["path"], SigValKind::Str), (&["offset"], SigValKind::Json), (&["limit"], SigValKind::Json)] };
|
||||
assert_eq!(sig_body(read_sig, &json!({"path": "a", "offset": 50})), "path=a,offset=50,limit=");
|
||||
// 未注册工具签名回退全参 JSON
|
||||
let sig = tool_signature("custom_tool", &json!({"x": 1}));
|
||||
assert!(sig.starts_with("custom_tool:name=custom_tool,args="));
|
||||
}
|
||||
|
||||
/// Json 组全键参与:同组多键排序拼接(与 args 键序无关),任一键换值即换片段。
|
||||
#[test]
|
||||
fn sig_body_json_group_includes_all_keys() {
|
||||
use serde_json::json;
|
||||
let page = SigMeta { groups: &[(&["offset", "limit"], SigValKind::Json)] };
|
||||
// 排序拼接:limit < offset 字典序,与 args 内键书写顺序无关
|
||||
assert_eq!(sig_body(page, &json!({"offset": 50, "limit": 100})), "limit=100|offset=50");
|
||||
assert_eq!(sig_body(page, &json!({"limit": 100, "offset": 50})), "limit=100|offset=50");
|
||||
// 任一键换值 → 不同片段
|
||||
assert_ne!(
|
||||
sig_body(page, &json!({"offset": 50, "limit": 100})),
|
||||
sig_body(page, &json!({"offset": 60, "limit": 100}))
|
||||
);
|
||||
// 全缺失逐键空占位(既有缺省口径);布尔键按 JSON 形态
|
||||
assert_eq!(sig_body(page, &json!({})), "limit=|offset=");
|
||||
let flag = SigMeta { groups: &[(&["-i"], SigValKind::Json)] };
|
||||
assert_eq!(sig_body(flag, &json!({"-i": true})), "-i=true");
|
||||
}
|
||||
|
||||
/// 注册与生产同形态的探针工具。df-ai 单测进程 GLOBAL_TOOL_META 无生产注册,
|
||||
/// 不种入则 tool_signature 走未注册兜底,测不到「注册点组 + 补参」合流分支。
|
||||
/// 进程级表跨测试共享,各处注册值恒相同,幂等无顺序依赖。
|
||||
fn seed_signature_probe_tools() {
|
||||
let read_sig = SigMeta {
|
||||
groups: &[
|
||||
(&["path"], SigValKind::Str),
|
||||
(&["offset"], SigValKind::Json),
|
||||
(&["limit"], SigValKind::Json),
|
||||
],
|
||||
};
|
||||
let search_sig = SigMeta {
|
||||
groups: &[
|
||||
(&["pattern", "query"], SigValKind::Str),
|
||||
(&["path", "glob"], SigValKind::Str),
|
||||
],
|
||||
};
|
||||
let cmd_sig = SigMeta { groups: &[(&["command"], SigValKind::Str)] };
|
||||
let mut reg = AiToolRegistry::new();
|
||||
reg.register_meta("read_file", "读取", object_schema(vec![]), RiskLevel::Low,
|
||||
ToolMeta { sig: Some(read_sig), ..Default::default() }, dummy_handler());
|
||||
reg.register_meta("search_files", "搜索文件", object_schema(vec![]), RiskLevel::Low,
|
||||
ToolMeta { sig: Some(search_sig), ..Default::default() }, dummy_handler());
|
||||
reg.register_meta("grep", "跨文件内容搜索", object_schema(vec![]), RiskLevel::Low,
|
||||
ToolMeta { sig: Some(search_sig), ..Default::default() }, dummy_handler());
|
||||
reg.register_meta("run_command", "执行命令", object_schema(vec![]), RiskLevel::High,
|
||||
ToolMeta { sig: Some(cmd_sig), ..Default::default() }, dummy_handler());
|
||||
}
|
||||
|
||||
/// 补参:合法换参 → 签名不同(G2 目标语义:换参不共享签名,不再误判重复);
|
||||
/// 同参 → 签名相同(重复判据正例仍成立)。
|
||||
#[test]
|
||||
fn tool_signature_extra_params_distinguish_calls() {
|
||||
use serde_json::json;
|
||||
seed_signature_probe_tools();
|
||||
|
||||
// read_file 同 path 换 search:不同签名
|
||||
let s1 = tool_signature("read_file", &json!({"path": "a.rs", "search": "fn sig"}));
|
||||
let s2 = tool_signature("read_file", &json!({"path": "a.rs", "search": "struct cfg"}));
|
||||
assert_ne!(s1, s2, "read_file 换 search 应不同签名: {} vs {}", s1, s2);
|
||||
assert!(s1.contains("search=fn sig"), "read_file 签名应含 search: {}", s1);
|
||||
|
||||
// search_files 只差 offset(翻页续搜):不同签名
|
||||
let s1 = tool_signature("search_files", &json!({"pattern": "todo", "path": "src", "offset": 0}));
|
||||
let s2 = tool_signature("search_files", &json!({"pattern": "todo", "path": "src", "offset": 50}));
|
||||
assert_ne!(s1, s2, "search_files 换 offset 应不同签名: {} vs {}", s1, s2);
|
||||
|
||||
// grep 只差 -i / output_mode / max_results(schema 无 offset/limit,数量参数实为
|
||||
// max_results):换匹配方式/输出形态/扩容重取都是不同操作
|
||||
let base = tool_signature("grep", &json!({"pattern": "err", "path": "src"}));
|
||||
let ci = tool_signature("grep", &json!({"pattern": "err", "path": "src", "-i": true}));
|
||||
let mode = tool_signature("grep", &json!({"pattern": "err", "path": "src", "output_mode": "files_with_matches"}));
|
||||
let capped = tool_signature("grep", &json!({"pattern": "err", "path": "src", "max_results": 200}));
|
||||
assert_ne!(base, ci, "grep 换 -i 应不同签名: {} vs {}", base, ci);
|
||||
assert_ne!(base, mode, "grep 换 output_mode 应不同签名: {} vs {}", base, mode);
|
||||
assert_ne!(base, capped, "grep 换 max_results 应不同签名: {} vs {}", base, capped);
|
||||
|
||||
// run_command 同命令换 working_dir:不同签名
|
||||
let s1 = tool_signature("run_command", &json!({"command": "cargo test"}));
|
||||
let s2 = tool_signature("run_command", &json!({"command": "cargo test", "working_dir": "crates/df-ai"}));
|
||||
assert_ne!(s1, s2, "run_command 换 working_dir 应不同签名: {} vs {}", s1, s2);
|
||||
|
||||
// 同工具同参:签名相同(同签名连续才算 G2 重复)
|
||||
assert_eq!(
|
||||
tool_signature("grep", &json!({"pattern": "err", "path": "src"})),
|
||||
base
|
||||
);
|
||||
}
|
||||
|
||||
/// 补参键缺席时签名与既有格式逐字一致(向后兼容:消费方按固定串断言,空补参不占位)。
|
||||
#[test]
|
||||
fn tool_signature_without_extra_keys_unchanged() {
|
||||
use serde_json::json;
|
||||
seed_signature_probe_tools();
|
||||
assert_eq!(
|
||||
tool_signature("read_file", &json!({"path": "big.log", "offset": 50, "limit": 100})),
|
||||
"read_file:path=big.log,offset=50,limit=100"
|
||||
);
|
||||
assert_eq!(
|
||||
tool_signature("read_file", &json!({})),
|
||||
"read_file:path=,offset=,limit="
|
||||
);
|
||||
assert_eq!(
|
||||
tool_signature("grep", &json!({"pattern": "MAX", "path": "src", "case_sensitive": true})),
|
||||
"grep:pattern=MAX,path=src"
|
||||
);
|
||||
assert_eq!(
|
||||
tool_signature("search_files", &json!({"query": "TODO", "glob": "**/*.rs"})),
|
||||
"search_files:pattern=TODO,path=**/*.rs"
|
||||
);
|
||||
assert_eq!(
|
||||
tool_signature("run_command", &json!({"command": "ls -la", "timeout": 5000})),
|
||||
"run_command:command=ls -la"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
//! 声明式工具注册基础设施(tool_registry 拆分第一步)
|
||||
//!
|
||||
//! 背景:`tool_registry.rs` 4224 行,每加一个工具手写一段
|
||||
//! `registry.register(name, desc, object_schema(...), RiskLevel::X, { ... Box::new ... })`,
|
||||
//! 5 段重复样板 + 闭包捕获 + 缩进极易出错。本模块提供声明式宏 `declare_tool!`,
|
||||
//! 把工具定义收敛为「名字 / 描述 / schema / 风险 / handler 块」五要素一行式声明。
|
||||
//!
|
||||
//! 设计取舍(三选一权衡):
|
||||
//! - A. proc-macro `#[ai_tool]` 属性宏:需新建 proc-macro crate + syn/quote 重依赖,违反
|
||||
//! 「不引新依赖」。否决。
|
||||
//! - B. `inventory` crate 自动收集:新外部依赖 + 拉入 linkme/ctor 运行期注册语义,
|
||||
//! 与现有 `register_*(&mut registry)` 命令式收集并存需双重 source-of-truth。否决。
|
||||
//! - C. **`macro_rules!` 声明式宏(本方案)**:零新依赖(纯 std `macro_rules!`),
|
||||
//! 展开为等价的 `registry.register(...)` 调用——与现有 48 个 `register` 调用**完全并存**,
|
||||
//! 旧工具零改动,新工具可选声明式。handler 块就地书写,`$db`/`$registry` 等捕获变量
|
||||
//! 原样透传,语义 1:1 等价(schema/risk/handler 同源不变)。
|
||||
//!
|
||||
//! 试点:`list_projects` 已迁至声明式(见 commands/ai/tools/list_projects.rs),验证编译过 +
|
||||
//! 行为等价 + 48 工具基线不破。其余 47 个工具后续批次渐进迁移,不在此步。
|
||||
//!
|
||||
//! 宏展开示例(输入):
|
||||
//! ```ignore
|
||||
//! declare_tool!(registry, db: Arc<Database>, "list_projects",
|
||||
//! "列出所有项目...", RiskLevel::Low,
|
||||
//! schema: object_schema(vec![("offset", "integer", false), ("limit", "integer", false)]),
|
||||
//! args => {
|
||||
//! let repo = ProjectRepo::new(&db);
|
||||
//! let items = repo.list_active().await?;
|
||||
//! // ...
|
||||
//! Ok(json!({ "items": items, ... }))
|
||||
//! });
|
||||
//! ```
|
||||
//! 展开后等价于现有手写的:
|
||||
//! ```ignore
|
||||
//! registry.register(
|
||||
//! "list_projects", "...",
|
||||
//! object_schema(vec![...]), RiskLevel::Low,
|
||||
//! { let db = db.clone(); Box::new(move |args: serde_json::Value| {
|
||||
//! let db = db.clone();
|
||||
//! Box::pin(async move { /* handler body */ })
|
||||
//! })},
|
||||
//! );
|
||||
//! ```
|
||||
|
||||
/// 声明式注册一个 AI 工具到 `$registry`。
|
||||
///
|
||||
/// 与手写 `registry.register(name, desc, schema, risk, handler)` 语义 1:1 等价,
|
||||
/// 仅消除闭包包装样板(`{ let db = db.clone(); Box::new(move |args| { let db = db.clone(); Box::pin(async move { ... }) }) }`)。
|
||||
///
|
||||
/// 形参:
|
||||
/// - `$registry`: `&mut AiToolRegistry` 注册表句柄。
|
||||
/// - `$capture: $cap_ty`: handler 需捕获的外部变量(如 `db: Arc<Database>`)。宏自动 clone 进闭包,
|
||||
/// handler body 内以 `$capture` 名访问。无捕获工具用 `_` 占位(并保证 body 不引用它)。
|
||||
/// - `$name`: 工具名 `&str`。
|
||||
/// - `$desc`: 工具描述 `&str`(发给 LLM)。
|
||||
/// - `$risk`: `RiskLevel`(如 `RiskLevel::Low`)。
|
||||
/// - `meta: $meta`(可选段): `ToolMeta` 字面量(DRY 债 #5)。带此段走 `register_meta` 落
|
||||
/// 条目 + 全局元数据表;不带则 default 元数据(全空,消费方走保守 default 分支)。
|
||||
/// - `$schema`: 参数 JSON Schema(`serde_json::Value`),常用 `object_schema(...)`。
|
||||
/// - `$handler`: 一个 **block 表达式**(花括号体),返回 `anyhow::Result<serde_json::Value>`。
|
||||
/// 体内在 `$args`(serde_json::Value)与 `$capture`(克隆后的捕获变量)上工作。
|
||||
/// 宏负责把它包进 `async move { ... }` 并 `Box::pin`——故调用方只写「同步语义的体」,
|
||||
/// 不写 `async move`/`Box::pin`/`Box::new` 三层样板。
|
||||
///
|
||||
/// 注意:此宏不替代 `register`,而是包装它——`register`/`register_meta` 仍是
|
||||
/// `AiToolRegistry` 的唯一注册入口,宏仅是语法糖。
|
||||
#[macro_export]
|
||||
macro_rules! declare_tool {
|
||||
(
|
||||
$registry:expr,
|
||||
$capture:ident : $cap_ty:ty,
|
||||
$name:expr,
|
||||
$desc:expr,
|
||||
$risk:expr,
|
||||
meta: $meta:expr,
|
||||
schema: $schema:expr,
|
||||
$args:ident => $handler:block
|
||||
) => {{
|
||||
let __cap: $cap_ty = $capture.clone();
|
||||
$registry.register_meta(
|
||||
$name, $desc, $schema, $risk, $meta,
|
||||
{
|
||||
let $capture = __cap.clone();
|
||||
::std::boxed::Box::new(move |$args: ::serde_json::Value| {
|
||||
let $capture = $capture.clone();
|
||||
::std::boxed::Box::pin(async move {
|
||||
let $capture: $cap_ty = $capture;
|
||||
$handler
|
||||
})
|
||||
as ::std::pin::Pin<::std::boxed::Box<
|
||||
dyn ::std::future::Future<Output = ::anyhow::Result<::serde_json::Value>>
|
||||
+ ::std::marker::Send,
|
||||
>>
|
||||
})
|
||||
},
|
||||
);
|
||||
}};
|
||||
(
|
||||
$registry:expr,
|
||||
$capture:ident : $cap_ty:ty,
|
||||
$name:expr,
|
||||
$desc:expr,
|
||||
$risk:expr,
|
||||
schema: $schema:expr,
|
||||
$args:ident => $handler:block
|
||||
) => {{
|
||||
let __cap: $cap_ty = $capture.clone();
|
||||
$registry.register_meta(
|
||||
$name, $desc, $schema, $risk, $crate::ai_tools::ToolMeta::default(),
|
||||
{
|
||||
let $capture = __cap.clone();
|
||||
::std::boxed::Box::new(move |$args: ::serde_json::Value| {
|
||||
let $capture = $capture.clone();
|
||||
::std::boxed::Box::pin(async move {
|
||||
let $capture: $cap_ty = $capture;
|
||||
$handler
|
||||
})
|
||||
as ::std::pin::Pin<::std::boxed::Box<
|
||||
dyn ::std::future::Future<Output = ::anyhow::Result<::serde_json::Value>>
|
||||
+ ::std::marker::Send,
|
||||
>>
|
||||
})
|
||||
},
|
||||
);
|
||||
}};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::ai_tools::{object_schema, AiToolRegistry, RiskLevel};
|
||||
|
||||
/// 声明式宏注册的工具与手写 register 行为等价(name/schema/risk/执行结果一致)。
|
||||
/// 这是「现有工具零回归 + 语义等价」的最小验证(试点 list_projects 迁移的微观镜像)。
|
||||
#[tokio::test]
|
||||
async fn declare_tool_equivalent_to_register() {
|
||||
let mut reg = AiToolRegistry::new();
|
||||
|
||||
// 声明式注册(新基础设施)
|
||||
let counter: std::sync::Arc<std::sync::atomic::AtomicUsize> =
|
||||
std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
declare_tool!(
|
||||
reg,
|
||||
counter: std::sync::Arc<std::sync::atomic::AtomicUsize>,
|
||||
"echo_decl",
|
||||
"声明式 echo 工具",
|
||||
RiskLevel::Low,
|
||||
schema: object_schema(vec![("msg", "string", true)]),
|
||||
args => {
|
||||
counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
let msg = args["msg"].as_str().unwrap_or("");
|
||||
Ok(serde_json::json!({ "echo": msg }))
|
||||
}
|
||||
);
|
||||
|
||||
// 等价断言:定义层(name 存在 / risk 正确 / schema 透传)
|
||||
assert_eq!(reg.len(), 1, "声明式宏应注册 1 个工具");
|
||||
let tool = reg.get("echo_decl").expect("echo_decl 应已注册");
|
||||
assert_eq!(tool.risk_level, RiskLevel::Low);
|
||||
assert_eq!(tool.definition.function.name, "echo_decl");
|
||||
assert_eq!(tool.definition.function.parameters["properties"]["msg"]["type"], "string");
|
||||
|
||||
// 执行等价:handler 收到 args、捕获变量可访问、返回 handler body 的 Value
|
||||
let out = reg.execute("echo_decl", serde_json::json!({ "msg": "hi" })).await.unwrap();
|
||||
assert_eq!(out["echo"], "hi");
|
||||
assert_eq!(counter.load(std::sync::atomic::Ordering::SeqCst), 1, "捕获变量应在执行时递增");
|
||||
}
|
||||
|
||||
/// 无捕获工具(纯计算/常量返回)用占位 capture,handler 不引用它。
|
||||
#[tokio::test]
|
||||
async fn declare_tool_no_capture() {
|
||||
let mut reg = AiToolRegistry::new();
|
||||
let dummy: std::sync::Arc<()> = std::sync::Arc::new(()); // 无真实捕获,占位
|
||||
declare_tool!(
|
||||
reg,
|
||||
dummy: std::sync::Arc<()>,
|
||||
"const_tool",
|
||||
"无捕获常量工具",
|
||||
RiskLevel::Medium,
|
||||
schema: object_schema(vec![]),
|
||||
_args => { Ok(serde_json::json!({ "ok": true })) }
|
||||
);
|
||||
let out = reg.execute("const_tool", serde_json::json!({})).await.unwrap();
|
||||
assert_eq!(out["ok"], true);
|
||||
}
|
||||
|
||||
/// 带 meta 段的宏形态:元数据落条目 + 全局表;不带 meta 段落 default。
|
||||
#[tokio::test]
|
||||
async fn declare_tool_with_and_without_meta() {
|
||||
use crate::ai_tools::ToolMeta;
|
||||
let mut reg = AiToolRegistry::new();
|
||||
let dummy: std::sync::Arc<()> = std::sync::Arc::new(());
|
||||
declare_tool!(
|
||||
reg,
|
||||
dummy: std::sync::Arc<()>,
|
||||
"meta_variant_tool",
|
||||
"带元数据声明的工具",
|
||||
RiskLevel::Low,
|
||||
meta: ToolMeta { path_args: &["path"], readonly: true, ..Default::default() },
|
||||
schema: object_schema(vec![]),
|
||||
_args => { Ok(serde_json::json!({ "ok": true })) }
|
||||
);
|
||||
declare_tool!(
|
||||
reg,
|
||||
dummy: std::sync::Arc<()>,
|
||||
"plain_variant_tool",
|
||||
"不带元数据声明的工具",
|
||||
RiskLevel::Low,
|
||||
schema: object_schema(vec![]),
|
||||
_args => { Ok(serde_json::json!({ "ok": true })) }
|
||||
);
|
||||
let with = reg.meta("meta_variant_tool").expect("meta 变体应已注册");
|
||||
assert_eq!(with.path_args, &["path"]);
|
||||
assert!(with.readonly);
|
||||
assert_eq!(crate::ai_tools::path_arg_keys("meta_variant_tool"), &["path"]);
|
||||
let plain = reg.meta("plain_variant_tool").expect("无 meta 变体应已注册");
|
||||
assert_eq!(plain, ToolMeta::default());
|
||||
assert_eq!(crate::ai_tools::path_arg_keys("plain_variant_tool"), &[] as &[&str]);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,12 +12,26 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{error, warn};
|
||||
|
||||
use crate::provider::{StreamChunk, TokenUsage, ToolCallDelta};
|
||||
use crate::provider::{tool_call_id_or_fallback, StreamChunk, TokenUsage, ToolCallDelta};
|
||||
|
||||
// ============================================================
|
||||
// Anthropic API 请求/响应结构体
|
||||
// ============================================================
|
||||
|
||||
/// Anthropic `system` 字段形态(受 prompt caching 开关 `ANTHROPIC_CACHE_ENABLED` 控制):
|
||||
/// - `Plain`:纯字符串。开关关时保持原形态,兼容非官方网关(GLM 订阅端点 / 任意 Messages API
|
||||
/// 代理——部分网关会拒收 cache_control 字段)。
|
||||
/// - `Cached`:blocks 数组 `[{type:text, text, cache_control:{type:ephemeral}}]`。开关开时启用
|
||||
/// Anthropic prompt caching,让 system 稳定段在支持缓存的端点上命中。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(untagged)]
|
||||
pub(crate) enum SystemBlock {
|
||||
/// 纯字符串形态(兼容网关,开关关)
|
||||
Plain(String),
|
||||
/// blocks 数组形态(含 cache_control,开关开)
|
||||
Cached(Vec<serde_json::Value>),
|
||||
}
|
||||
|
||||
/// Anthropic 请求体
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub(crate) struct AnthropicRequest {
|
||||
@@ -25,7 +39,7 @@ pub(crate) struct AnthropicRequest {
|
||||
pub messages: Vec<serde_json::Value>,
|
||||
pub max_tokens: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub system: Option<String>,
|
||||
pub system: Option<SystemBlock>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub temperature: Option<f32>,
|
||||
pub stream: bool,
|
||||
@@ -47,24 +61,27 @@ pub(crate) struct AnthropicToolDef {
|
||||
/// Anthropic 同步响应
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct AnthropicResponse {
|
||||
// SW-260618-25: Anthropic 响应反序列化字段,保留以对齐响应结构(响应 id),标注意图消除 dead_code warning
|
||||
// Anthropic 响应反序列化字段,保留以对齐响应结构(响应 id),标注意图消除 dead_code warning
|
||||
#[allow(dead_code)]
|
||||
id: String,
|
||||
pub model: String,
|
||||
pub content: Vec<AnthropicContentBlock>,
|
||||
// SW-260618-25: Anthropic 响应反序列化字段,保留以备调试/未来消费(如日志记录调用终止原因),标注意图消除 dead_code warning
|
||||
// Anthropic 响应反序列化字段,保留以备调试/未来消费(如日志记录调用终止原因),标注意图消除 dead_code warning
|
||||
#[allow(dead_code)]
|
||||
stop_reason: Option<String>,
|
||||
pub usage: AnthropicUsage,
|
||||
}
|
||||
|
||||
/// 响应 content 块(text 或 tool_use)
|
||||
/// 响应 content 块(text / tool_use / thinking)
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct AnthropicContentBlock {
|
||||
#[serde(rename = "type")]
|
||||
pub block_type: String,
|
||||
#[serde(default)]
|
||||
pub text: Option<String>,
|
||||
/// thinking 块推理正文(端点侧开启扩展思考时出现;本侧请求未启用 thinking,一般缺省)
|
||||
#[serde(default)]
|
||||
pub thinking: Option<String>,
|
||||
/// tool_use 块字段
|
||||
pub id: Option<String>,
|
||||
pub name: Option<String>,
|
||||
@@ -75,6 +92,16 @@ pub(crate) struct AnthropicContentBlock {
|
||||
pub(crate) struct AnthropicUsage {
|
||||
pub input_tokens: u32,
|
||||
pub output_tokens: u32,
|
||||
/// Anthropic prompt caching 扩展:cache 写入 token(本次写入缓存,计费如输入但稍便宜)。
|
||||
/// 映射到 TokenUsage.prompt_cache_miss_tokens(全价输入语义)。
|
||||
/// 非 cache 场景无此字段 → serde default 0。
|
||||
#[serde(default)]
|
||||
pub cache_creation_input_tokens: u32,
|
||||
/// Anthropic prompt caching 扩展:cache 命中读取 token(低价)。
|
||||
/// 映射到 TokenUsage.prompt_cache_hit_tokens。
|
||||
/// 非 cache 场景无此字段 → serde default 0。
|
||||
#[serde(default)]
|
||||
pub cache_read_input_tokens: u32,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -95,7 +122,8 @@ pub(crate) const DEFAULT_MAX_TOKENS: u32 = 4096;
|
||||
/// 按 `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_delta` (text_delta/input_json_delta/thinking_delta) → 文本/工具入参/思考增量,
|
||||
/// signature_delta 安静忽略。
|
||||
/// - `content_block_start` (tool_use) → 工具块开始,带 id+name。
|
||||
/// - `message_stop` → 返回 `finished=true` 终态 chunk,`usage` 取自累加器(`take()`)。
|
||||
/// - `error` → 返回 `finished=true` 终态空 chunk。
|
||||
@@ -109,12 +137,13 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
|
||||
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 }
|
||||
return StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None, truncated_by_max_tokens: false }
|
||||
}
|
||||
};
|
||||
let ty = v.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||
match ty {
|
||||
// 消息开始:取 input_tokens 初始化累积器(output 此时未知,置 0)
|
||||
// 消息开始:取 input_tokens 初始化累积器(output 此时未知,置 0)。
|
||||
// anthropic prompt caching:cache_creation/read 在 message_start.usage 携带。
|
||||
"message_start" => {
|
||||
if let Some(inp) = v
|
||||
.get("message")
|
||||
@@ -122,30 +151,58 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
|
||||
.and_then(|u| u.get("input_tokens"))
|
||||
.and_then(|t| t.as_u64())
|
||||
{
|
||||
let u_obj = v.get("message").and_then(|m| m.get("usage"));
|
||||
let cache_read = u_obj
|
||||
.and_then(|u| u.get("cache_read_input_tokens"))
|
||||
.and_then(|t| t.as_u64())
|
||||
.unwrap_or(0) as u32;
|
||||
let cache_creation = u_obj
|
||||
.and_then(|u| u.get("cache_creation_input_tokens"))
|
||||
.and_then(|t| t.as_u64())
|
||||
.unwrap_or(0) as u32;
|
||||
*usage_accum = Some(TokenUsage {
|
||||
prompt_tokens: inp as u32,
|
||||
completion_tokens: 0,
|
||||
total_tokens: inp as u32,
|
||||
prompt_cache_hit_tokens: cache_read,
|
||||
prompt_cache_miss_tokens: cache_creation,
|
||||
reasoning_tokens: 0,
|
||||
});
|
||||
}
|
||||
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None }
|
||||
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: usage_accum.clone(), error: None, reasoning_content: None, truncated_by_max_tokens: false }
|
||||
}
|
||||
// 消息增量: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 });
|
||||
.get_or_insert(TokenUsage::default());
|
||||
acc.completion_tokens = out as u32;
|
||||
acc.total_tokens = acc.prompt_tokens + acc.completion_tokens;
|
||||
acc.total_tokens = acc.prompt_tokens.saturating_add(acc.completion_tokens);
|
||||
}
|
||||
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None }
|
||||
// stop_reason=max_tokens(message_delta 帧携带):输出被 max_tokens 截断。
|
||||
// 终态 finished 在后续 message_stop 帧,截断标志挂本帧,消费方跨帧 OR 累积。
|
||||
let truncated_by_max_tokens = v.get("delta").and_then(|d| d.get("stop_reason")).and_then(|s| s.as_str()) == Some("max_tokens");
|
||||
// 加固:usage 挂到本帧(而非仅 message_stop 带出),中途断连/端点不发 message_stop
|
||||
// 时仍能拿到真实 usage,对称 openai_helpers 的修复。
|
||||
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: usage_accum.clone(), error: None, reasoning_content: None, truncated_by_max_tokens }
|
||||
}
|
||||
// 文本增量
|
||||
"content_block_delta" => {
|
||||
if let Some(delta) = v.get("delta") {
|
||||
// thinking 增量:推理正文走 reasoning_content 通道(与 DeepSeek 同路入库展示),
|
||||
// 不混入正文 delta。本侧请求未启用 thinking,此分支只为不丢端点侧自行产出的思考。
|
||||
if delta.get("type").and_then(|t| t.as_str()) == Some("thinking_delta") {
|
||||
let text = delta.get("thinking").and_then(|t| t.as_str()).unwrap_or("").to_string();
|
||||
return StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: Some(text), truncated_by_max_tokens: false };
|
||||
}
|
||||
// signature_delta:thinking 块签名字段,仅回传校验用。DevFlow 从不把 thinking 回传
|
||||
// anthropic 历史(丢弃 signature 后伪造回传必 400),签名无处消费,安静忽略。
|
||||
if delta.get("type").and_then(|t| t.as_str()) == Some("signature_delta") {
|
||||
return StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None, truncated_by_max_tokens: false };
|
||||
}
|
||||
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 };
|
||||
return StreamChunk { delta: text, finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None, truncated_by_max_tokens: false };
|
||||
}
|
||||
// 工具入参增量
|
||||
if delta.get("type").and_then(|t| t.as_str()) == Some("input_json_delta") {
|
||||
@@ -163,26 +220,30 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
|
||||
usage: None,
|
||||
error: None,
|
||||
reasoning_content: None,
|
||||
truncated_by_max_tokens: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None }
|
||||
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None, truncated_by_max_tokens: false }
|
||||
}
|
||||
// 工具块开始:带 id + name
|
||||
"content_block_start" => {
|
||||
// thinking / redacted_thinking 块起点无正文要记(正文随后经 thinking_delta 流出;
|
||||
// redacted_thinking 为密文不可解析),落入末尾统一空 chunk 即安静跳过,不告警。
|
||||
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)
|
||||
}
|
||||
// CR-空 id:id 缺失/空时用 fallback 兜底(流式后续 input_json_delta 按 index 累加,
|
||||
// 中途无法整体跳过)。与同步路径 + OpenAI 路径共用 tool_call_id_or_fallback(DRY),
|
||||
// prefix=`gen_anthropic_stream` 区分来源。非空原样。
|
||||
let raw_id = cb.get("id").and_then(|t| t.as_str()).unwrap_or("");
|
||||
let id = if raw_id.is_empty() {
|
||||
let fallback = tool_call_id_or_fallback(raw_id, idx as usize, "gen_anthropic_stream");
|
||||
warn!(%fallback, name = ?name, "Anthropic 流式 tool_use 块 id 为空,已生成 fallback id(原样回传会触发 GLM 500)");
|
||||
Some(fallback)
|
||||
} else {
|
||||
Some(raw_id.to_string())
|
||||
};
|
||||
return StreamChunk {
|
||||
delta: String::new(),
|
||||
@@ -196,10 +257,11 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
|
||||
usage: None,
|
||||
error: None,
|
||||
reasoning_content: None,
|
||||
truncated_by_max_tokens: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None }
|
||||
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None, truncated_by_max_tokens: false }
|
||||
}
|
||||
// 消息结束:带出累积 usage
|
||||
"message_stop" => StreamChunk {
|
||||
@@ -209,15 +271,16 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
|
||||
usage: usage_accum.take(),
|
||||
error: None,
|
||||
reasoning_content: None,
|
||||
truncated_by_max_tokens: false,
|
||||
},
|
||||
// 错误事件:流中途出错。不走 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 }
|
||||
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: Some(msg), reasoning_content: None, truncated_by_max_tokens: false }
|
||||
}
|
||||
// content_block_stop / ping 等不产出 chunk
|
||||
_ => StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None },
|
||||
_ => StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None, truncated_by_max_tokens: false },
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,785 @@
|
||||
//! 上下文管理器 — 管理对话上下文和 token 预算
|
||||
//!
|
||||
//! 职责:
|
||||
//! - 维护消息历史及其 token 计数缓存
|
||||
//! - 提供预算感知的消息裁剪(保护工具调用三元组)
|
||||
//! - 为 run_agentic_loop 提供受控的消息视图
|
||||
//!
|
||||
//! 裁剪策略与模型选择是正交维度:本模块只管「窗口多大、怎么裁」,
|
||||
//! 用哪个 model / 是否启用 reasoning 由调用方在 CompletionRequest 层决定。
|
||||
//!
|
||||
//! 纯函数 / 数据类型 / 常量(TokenEstimator / ContextConfig / MessageGroup /
|
||||
//! TrackedMessage / EvictionUnit / classify_group / PROTECT_COUNT /
|
||||
//! TOOL_MISSING_PREFIX)已抽至 [`crate::context_helpers`],本模块 `use` 复用,
|
||||
//! 并 `pub use` 重导出以保持 `df_ai::context::*` 历史路径对外可见(零调用方变更)。
|
||||
//!
|
||||
//! # 子模块
|
||||
//! - [`sanitize`]:畸形配对自愈(`sanitize_messages` / `drop_reverse_orphans` /
|
||||
//! `assert_placeholder_pairing` / `ensure_sequence_legal`)及其单测。
|
||||
//! 实现为模块级 `pub fn`,本模块通过 `ContextManager` 上的关联函数薄包装转发,
|
||||
//! 保持 `ContextManager::sanitize_messages(...)` 调用路径不变(零调用方变更)。
|
||||
|
||||
mod sanitize;
|
||||
|
||||
use crate::context_helpers::{
|
||||
build_eviction_summary_text, classify_group, PLACEHOLDER_INTEGRITY_ENABLED, PROTECT_COUNT,
|
||||
};
|
||||
// 重导出:保持 `df_ai::context::TokenEstimator` / `df_ai::context::ContextConfig` 等
|
||||
// 历史路径对外可见(agentic.rs / commands/ai/mod.rs 等调用方零变更)。
|
||||
// `pub use` 同时把类型带入本模块命名空间,供 ContextManager 结构体字段与 impl 直接引用。
|
||||
pub use crate::context_helpers::{
|
||||
EvictionUnit, ContextConfig, MessageGroup, TokenEstimator, TrackedMessage,
|
||||
};
|
||||
|
||||
use crate::provider::{ChatMessage, MessageRole, MessageStatus};
|
||||
|
||||
// ============================================================
|
||||
// 上下文管理器
|
||||
// ============================================================
|
||||
|
||||
/// 上下文管理器
|
||||
///
|
||||
/// 唯一的消息真相来源(替代原来的 `Vec<ChatMessage>`)。
|
||||
/// 裁剪仅影响发送视图(`build_for_request`),不影响持久化(`all_messages_clone`)。
|
||||
pub struct ContextManager {
|
||||
messages: Vec<TrackedMessage>,
|
||||
/// 当前历史总 token 数(不含 system prompt)
|
||||
history_tokens: u32,
|
||||
config: ContextConfig,
|
||||
estimator: TokenEstimator,
|
||||
/// 压缩重入标志(F-15 §4.3):true 表示一次 LLM 压缩正在进行中。
|
||||
/// agentic loop 顶部检测,防同一轮内多次触发压缩互相覆盖。纯内存态,不落库。
|
||||
is_compressing: bool,
|
||||
/// [P2 改进5] 主题切换检测标记。push user 消息时若发现末两条 user 消息的 topic
|
||||
/// 都非 None 且不同(双高置信),置位本字段,格式 "old|new"。agentic loop 顶部读并
|
||||
/// 消费(insert 系统标记后清空)。纯内存态,不落库。保守:任一 topic 为 None 不置位(宁可漏报)。
|
||||
pending_topic_marker: Option<String>,
|
||||
/// msg-split-phase1:已落库(ai_messages 表)的消息条数。
|
||||
///
|
||||
/// 增量写路径用:save_conversation 据此判定 `[persisted_msg_count..len)` 是"自上次 save
|
||||
/// 后新增的消息"(纯 append),走 `insert_batch`(INSERT OR IGNORE)只插新行,跳过昂贵的
|
||||
/// `replace_conversation`(DELETE 整对话 + 全量重插)。
|
||||
///
|
||||
/// 仅 append-only 路径才有效——一旦 [`needs_full_rewrite`] 被置位(DB 与内存可能脱钩:
|
||||
/// compress 改 status / replace_tool_result_content 改 content / pop_last_assistant_round
|
||||
/// 缩短 / insert_at 中段插入改 seq / clear 清空),save 必须走全量重写收敛,本字段随之重置。
|
||||
///
|
||||
/// restore_from_messages(DB 加载)后设为 len(DB 已是该真相源,append 基线对齐到当前长度)。
|
||||
/// 纯内存态,不落库(下次进程重启从 DB reload 时由 restore_from_messages 重置)。
|
||||
persisted_msg_count: usize,
|
||||
/// msg-split-phase1:全量重写需求标志。
|
||||
///
|
||||
/// `true` = 自上次 save 后发生过修改既有消息 / 缩短 / 中段插入 / 清空,内存与 DB 可能脱钩,
|
||||
/// 下次 save 必须走 `replace_conversation` 全量重写收敛(DELETE + INSERT 全部行)。
|
||||
/// save 全量重写成功后清零;append-only save(insert_batch)成功后保持 false。
|
||||
///
|
||||
/// 任何修改既有消息(非纯 push append)的入口置位:clear / pop_last_assistant_round /
|
||||
/// truncate_after_user_message / replace_tool_result_content / replace_last_active_user_content /
|
||||
/// insert_at / compress_old_messages / messages_mut(可变借用兜底,调用方可能改 status/content)。
|
||||
/// 误置位零代价(只是下次 save 多走一次全量重写,正确性不变);漏置位才会丢更新——故保守置位。
|
||||
needs_full_rewrite: bool,
|
||||
}
|
||||
|
||||
impl ContextManager {
|
||||
pub fn new(config: ContextConfig) -> Self {
|
||||
Self {
|
||||
messages: Vec::new(),
|
||||
history_tokens: 0,
|
||||
config,
|
||||
estimator: TokenEstimator::default(),
|
||||
is_compressing: false,
|
||||
pending_topic_marker: None,
|
||||
persisted_msg_count: 0,
|
||||
needs_full_rewrite: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 追加消息(自动计算 token 并更新缓存)
|
||||
///
|
||||
/// 不在此处淘汰——push 可能发生在 agentic loop 中间(追加 tool_result),
|
||||
/// 此时不应裁剪正在使用的活跃消息。裁剪在 `build_for_request` 时统一处理。
|
||||
pub fn push(&mut self, message: ChatMessage) {
|
||||
let tokens = self.estimator.estimate_message(&message);
|
||||
let group = classify_group(&message);
|
||||
// 仅 active 消息计入 token 预算(F-15 §3.3):truncated / archived_segment /
|
||||
// compressed 不进 LLM 上下文,token 虚高会致 build_for_request 误判超预算
|
||||
// 触发不必要裁剪。!active 消息仍 push 到 self.messages 全量保留(持久化不受影响),
|
||||
// sanitize_messages step0(is_active 过滤)在发送视图统一剔除。
|
||||
if message.is_active() {
|
||||
self.history_tokens += tokens;
|
||||
}
|
||||
// [P2 改进5] 主题推断(仅 user 消息):IntentRecognizer 识别意图,置信 >= 0.7 且
|
||||
// 非 Unknown 则把 Intent 标签存入 TrackedMessage.topic 供主题切换检测。
|
||||
// 保守:低置信(None)不标,避免误报。topic 不参与裁剪/压缩(只供 marker 检测)。
|
||||
let topic: Option<String> = if matches!(message.role, MessageRole::User) {
|
||||
let (intent, conf) = crate::intent::IntentRecognizer::recognize(&message.content);
|
||||
if conf >= 0.7 && !matches!(intent, crate::intent::Intent::Unknown) {
|
||||
Some(intent.as_str().to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// [P2 改进5] 主题切换检测:push 前(本消息即将成末条 user),若已存在一条更早 user
|
||||
// 且本消息 topic 与之都非 None 且不同 → 置位 pending_topic_marker("old|new")。
|
||||
// 双高置信(两条 topic 都非 None)才标,任一 None 不标(宁可漏报不误报)。
|
||||
if matches!(message.role, MessageRole::User) {
|
||||
if let Some(prev_topic) = self.last_user_topic() {
|
||||
if let Some(this_topic) = &topic {
|
||||
if prev_topic != *this_topic {
|
||||
self.pending_topic_marker =
|
||||
Some(format!("{}|{}", prev_topic, this_topic));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.messages.push(TrackedMessage {
|
||||
message,
|
||||
token_count: tokens,
|
||||
group,
|
||||
topic,
|
||||
});
|
||||
}
|
||||
|
||||
/// 清空所有消息
|
||||
pub fn clear(&mut self) {
|
||||
self.messages.clear();
|
||||
self.history_tokens = 0;
|
||||
self.is_compressing = false;
|
||||
self.pending_topic_marker = None;
|
||||
// msg-split-phase1:全清后 DB 必须也清(调用方 chat.rs:1149 走 delete_range),
|
||||
// 全量重写基线重置到 0。needs_full_rewrite=true 保险(若 save 先于 delete_range 触发,
|
||||
// 全量重写空列表也会清 DB)。
|
||||
self.persisted_msg_count = 0;
|
||||
self.needs_full_rewrite = true;
|
||||
}
|
||||
|
||||
/// 消息数量
|
||||
pub fn len(&self) -> usize {
|
||||
self.messages.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.messages.is_empty()
|
||||
}
|
||||
|
||||
/// 当前历史占用的 token 数(不含 system prompt)
|
||||
pub fn history_tokens(&self) -> u32 {
|
||||
self.history_tokens
|
||||
}
|
||||
|
||||
/// 预算上限
|
||||
pub fn budget_limit(&self) -> u32 {
|
||||
self.config.budget_limit()
|
||||
}
|
||||
|
||||
// ── 核心:构建请求消息(受控裁剪版本)──
|
||||
|
||||
/// 构建发送给 LLM 的消息列表
|
||||
///
|
||||
/// `sys_tokens` 为调用方已估算好的 system prompt token 数。
|
||||
/// 超预算时自动裁剪旧消息(保护工具调用三元组 + 最近 PROTECT_COUNT 条),并在
|
||||
/// 被淘汰区头部插入关键词补偿摘要(role=system,纯视图易变区,不落库不改存储)。
|
||||
/// 返回 (消息列表, 是否发生了裁剪)。
|
||||
pub fn build_for_request(&self, sys_tokens: u32) -> (Vec<ChatMessage>, bool) {
|
||||
let budget = self.budget_limit();
|
||||
let available = budget.saturating_sub(sys_tokens);
|
||||
|
||||
// system prompt 自身超预算:裁剪无法缓解(仍返回保护区兜底),warn 便于诊断
|
||||
if sys_tokens > budget {
|
||||
tracing::warn!(
|
||||
"system prompt (~{} tokens) 超过上下文预算 ({}),裁剪无法缓解",
|
||||
sys_tokens, budget
|
||||
);
|
||||
}
|
||||
|
||||
// 未超预算 → 直接返回全量(仍做畸形配对自愈,防历史中毒触发 provider 500 死循环)
|
||||
if self.history_tokens <= available {
|
||||
let sanitized = Self::sanitize_messages(self.all_messages_clone());
|
||||
// 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan)
|
||||
return (
|
||||
Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED),
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
// 超预算 → 视图裁剪(不修改 self.messages,保证 all_messages_clone 仍返回全量)
|
||||
let protect_start = self.messages.len().saturating_sub(PROTECT_COUNT);
|
||||
let units = self.build_eviction_units(protect_start);
|
||||
|
||||
let mut removed: u64 = 0;
|
||||
let mut trim_end = 0;
|
||||
for unit in &units {
|
||||
if self.history_tokens.saturating_sub(removed as u32) <= available {
|
||||
break;
|
||||
}
|
||||
removed += unit.token_sum as u64;
|
||||
trim_end = unit.end;
|
||||
}
|
||||
|
||||
if trim_end == 0 {
|
||||
tracing::warn!(
|
||||
"history (~{} tokens) 超预算 ({}) 但无可淘汰单元(全在保护区 {} 条),发送兜底可能触发 provider 超限",
|
||||
self.history_tokens, available, PROTECT_COUNT
|
||||
);
|
||||
// 兜底全量也过 sanitize(对齐分支 1/3),防绕过序列修复直送 provider
|
||||
// 触发"首条 assistant 非法"/orphan/连续 role。原裸返 all_messages_clone 不过滤
|
||||
// truncated/中毒三元组/首条非法——是主 loop 唯一的 sanitize 漏洞(大体量 tool_result
|
||||
// 致超预算且保护区满时命中)。异常会话(开头连续 assistant/tool 无 user)经
|
||||
// ensure_sequence_legal 清空后,由协议层 ensure_leading_user 补 user 占位降级,不阻塞。
|
||||
// view-only:不改 self.messages 持久化(与分支 1/3 一致)。
|
||||
let sanitized = Self::sanitize_messages(self.all_messages_clone());
|
||||
return (
|
||||
Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED),
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
let evicted: Vec<ChatMessage> = self.messages[..trim_end]
|
||||
.iter()
|
||||
.map(|t| t.message.clone())
|
||||
.collect();
|
||||
let mut msgs: Vec<ChatMessage> = self.messages[trim_end..]
|
||||
.iter()
|
||||
.map(|t| t.message.clone())
|
||||
.collect();
|
||||
|
||||
// 淘汰补偿摘要:视图裁剪无声丢弃被淘汰内容,LLM 从此看不到早期主题,与压缩
|
||||
// 路径(有摘要补偿)行为不一致。此处把被淘汰文本提炼成一短段摘要插回发送视图。
|
||||
// **缓存友好修正(2026-10)**:原 insert(0) 头部插入会位移整条前缀字节,使
|
||||
// DeepSeek 等自动前缀缓存 provider 的稳定前缀(顶层 system + 早期 history)
|
||||
// 全部缓存失效——命中率掉到个位数。改尾部追加(push):摘要本身每轮即时再生成
|
||||
// (淘汰区单调增长致内容逐轮漂移)本就无法命中缓存,但放尾部不再拖累稳定前缀,
|
||||
// 命中率最大化。角色必须 user:role=system 会被 anthropic_compat 折叠进带
|
||||
// cache_control 的顶层 system 段,落 system 即逐轮改写缓存指纹;user 放尾部
|
||||
// 由 sanitize/协议层自愈兜底。摘要只进 messages 易变区,严禁拼进 system_prompt/
|
||||
// 任何稳定段;纯视图语义:不落库、不改 conv.messages 存储、不标记 compressed,
|
||||
// 被淘汰原文存储态不变。极短/空淘汰文本提炼为空 → 跳过(不值得插)。
|
||||
if let Some(text) = build_eviction_summary_text(&evicted) {
|
||||
msgs.push(ChatMessage::user(text));
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"context_trimmed: skip {} messages, ~{} tokens (view-only, full history retained)",
|
||||
trim_end, removed
|
||||
);
|
||||
let sanitized = Self::sanitize_messages(msgs);
|
||||
// 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan)
|
||||
(
|
||||
Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED),
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
// ── 畸形配对自愈(转发至 [`sanitize`] 子模块,保持 ContextManager::xxx 调用路径)──
|
||||
|
||||
/// 畸形配对自愈 — 转发到 [`sanitize::sanitize_messages`]。
|
||||
///
|
||||
/// 保留为 `ContextManager` 关联函数以兼容历史调用路径(`Self::sanitize_messages` /
|
||||
/// `ContextManager::sanitize_messages`),实现见子模块文档。
|
||||
pub fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
|
||||
sanitize::sanitize_messages(messages)
|
||||
}
|
||||
|
||||
/// 发送视图出口断言 — 转发到 [`sanitize::assert_placeholder_pairing`]。
|
||||
pub fn assert_placeholder_pairing(
|
||||
messages: Vec<ChatMessage>,
|
||||
enabled: bool,
|
||||
) -> Vec<ChatMessage> {
|
||||
sanitize::assert_placeholder_pairing(messages, enabled)
|
||||
}
|
||||
|
||||
/// 全量克隆(持久化 save_conversation / build_for_request 未裁剪分支,不受裁剪影响)
|
||||
pub fn all_messages_clone(&self) -> Vec<ChatMessage> {
|
||||
self.messages.iter().map(|t| t.message.clone()).collect()
|
||||
}
|
||||
|
||||
/// 只读最近 N 条消息(尾部切片 clone)。BE2 断路器/探索熔断检查等只关心
|
||||
/// 最近消息的读路径,用本方法避免 all_messages_clone 每轮全量 clone(长对话每轮 O(n) 深克隆浪费)。
|
||||
/// 尾部顺序保持时间正序(与 all_messages_clone 一致,调用方从尾部反向扫即可)。
|
||||
pub fn recent_messages(&self, n: usize) -> Vec<ChatMessage> {
|
||||
let skip = self.messages.len().saturating_sub(n);
|
||||
self.messages[skip..]
|
||||
.iter()
|
||||
.map(|t| t.message.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 从 Vec 恢复(兼容从 DB 加载)
|
||||
pub fn restore_from_messages(&mut self, messages: Vec<ChatMessage>) {
|
||||
// clear() 会置 needs_full_rewrite=true + persisted_msg_count=0;但本入口是"DB 刚加载",
|
||||
// 加载后的 messages 与 DB 完全一致(它们就是从 DB 来的),append 基线应对齐到当前 len,
|
||||
// 且不需要全量重写(DB 已是真相源)。故 clear 后立即覆盖这两个字段。
|
||||
let len = messages.len();
|
||||
self.clear();
|
||||
for msg in messages {
|
||||
self.push(msg);
|
||||
}
|
||||
self.persisted_msg_count = len;
|
||||
self.needs_full_rewrite = false;
|
||||
// 重放历史时 push 会照常跑主题切换检测,历史里"当时已发生"的切换会残留进 marker;
|
||||
// 留着它,加载后首轮 take_topic_marker 就拿到假话题锚点、误插一次切换标记。
|
||||
// 恢复即重置:切换检测只对 restore 之后的实时 push 生效。
|
||||
self.pending_topic_marker = None;
|
||||
}
|
||||
|
||||
/// 就地替换某条 tool_result 的内容(兼容审批 replace_tool_result)
|
||||
/// 返回 true 如果找到并替换了
|
||||
///
|
||||
/// 反向遍历:tool_result 由 append 进入历史,被替换的通常是最近的审批占位,
|
||||
/// 从尾部查找命中即停,避免对长历史做正向 O(n) 累积扫描。
|
||||
pub fn replace_tool_result_content(&mut self, tool_call_id: &str, new_content: &str) -> bool {
|
||||
let pos = self.messages.iter().rposition(|t| {
|
||||
matches!(t.message.role, MessageRole::Tool)
|
||||
&& t.message.tool_call_id.as_deref() == Some(tool_call_id)
|
||||
});
|
||||
|
||||
let Some(i) = pos else { return false };
|
||||
|
||||
// 先更新 content,再重估 token 并校正总量
|
||||
let old_tokens = self.messages[i].token_count;
|
||||
self.messages[i].message.content = new_content.to_string();
|
||||
let new_tokens = self.estimator.estimate_message(&self.messages[i].message);
|
||||
self.messages[i].token_count = new_tokens;
|
||||
self.history_tokens = self.history_tokens.saturating_sub(old_tokens).saturating_add(new_tokens);
|
||||
// msg-split-phase1:改既有消息 content,DB 需全量重写收敛(append-only 路径不覆盖旧行)。
|
||||
self.needs_full_rewrite = true;
|
||||
true
|
||||
}
|
||||
|
||||
/// 弹出末尾连续的 assistant 消息(含其 tool_calls 三元组尾随 tool_result)
|
||||
///
|
||||
/// 用于「重新生成」删掉最后一条 AI 回复(可能跨多轮 tool_calls + tool_results
|
||||
/// 紧随其后),保留触发它的 user 消息,以便重跑 agentic loop 再生成。
|
||||
///
|
||||
/// 语义:从末尾向前弹出,直到弹出至少一条 assistant 消息;若弹出 assistant 后紧邻的更早
|
||||
/// 消息仍是 assistant/tool(同一轮多块),继续一并弹出,确保不留半截三元组污染下轮。
|
||||
/// user 消息作为停止边界(不弹出 user),保证重生成时历史末尾是 user 消息。
|
||||
pub fn pop_last_assistant_round(&mut self) -> bool {
|
||||
if self.messages.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let mut popped_any = false;
|
||||
// 从尾向前:先弹掉末尾非 user 的消息(assistant / tool),直到遇到 user 或空
|
||||
while let Some(last) = self.messages.last() {
|
||||
if matches!(last.message.role, MessageRole::User) {
|
||||
break;
|
||||
}
|
||||
let removed = self.messages.pop().expect("just checked non-empty");
|
||||
self.history_tokens = self.history_tokens.saturating_sub(removed.token_count);
|
||||
if matches!(removed.message.role, MessageRole::Assistant) {
|
||||
popped_any = true;
|
||||
}
|
||||
}
|
||||
// msg-split-phase1:从末尾弹出 → len 缩短,append-only 基线(persisted_msg_count)
|
||||
// 会大于新 len。下次 save 必须全量重写(删 DB 中已不存在的尾行)。
|
||||
if popped_any {
|
||||
self.needs_full_rewrite = true;
|
||||
}
|
||||
popped_any
|
||||
}
|
||||
|
||||
/// 编辑某条 user 消息后,将其后所有消息标记为 truncated(UX-09 编辑重生成)。
|
||||
///
|
||||
/// 软删语义:保留在内存真相源 + DB(可追溯),但 sanitize_messages 过滤后不进 LLM 上下文,
|
||||
/// 前端按 is_active 过滤从视图移除。返回被标 truncated 的条数(0 表示该 user 消息已是末尾,无后续)。
|
||||
///
|
||||
/// `target_content` 为该 user 消息的预期内容(用于反向唯一定位:末条 user 消息可能内容相同,
|
||||
/// 故从尾部向前找第一条 role=User 且 content 匹配且仍 active 的消息)。
|
||||
/// 找不到返回 Err(()),调用方据此报错。
|
||||
pub fn truncate_after_user_message(&mut self, target_content: &str) -> Result<usize, ()> {
|
||||
// 反向找末条 active user 消息且 content 匹配
|
||||
let pos = self.messages.iter().rposition(|t| {
|
||||
matches!(t.message.role, MessageRole::User)
|
||||
&& t.message.content == target_content
|
||||
&& t.message.is_active()
|
||||
});
|
||||
let Some(i) = pos else { return Err(()) };
|
||||
// i 之后的全部标 truncated(已 truncated 的跳过,只统计本次新标的)
|
||||
let mut count = 0usize;
|
||||
for t in self.messages[i + 1..].iter_mut() {
|
||||
if t.message.is_active() {
|
||||
t.message.status = Some(MessageStatus::Truncated);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
// msg-split-phase1:改既有消息 status(truncated),DB 需全量重写收敛。
|
||||
if count > 0 {
|
||||
self.needs_full_rewrite = true;
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// 替换末条 active user 消息的 content(UX-09 编辑重生成)。
|
||||
///
|
||||
/// 编辑语义:只能编辑最后一条 user 消息(中间编辑语义复杂,拒绝)。返回 Err(()) 表示无 active user 消息。
|
||||
/// 成功后调用方应紧接着 truncate_after_user_message(new_content) 软删其后续消息。
|
||||
pub fn replace_last_active_user_content(&mut self, new_content: &str) -> Result<(), ()> {
|
||||
let pos = self.messages.iter().rposition(|t| {
|
||||
matches!(t.message.role, MessageRole::User) && t.message.is_active()
|
||||
});
|
||||
let Some(i) = pos else { return Err(()) };
|
||||
let old_tokens = self.messages[i].token_count;
|
||||
self.messages[i].message.content = new_content.to_string();
|
||||
let new_tokens = self.estimator.estimate_message(&self.messages[i].message);
|
||||
self.messages[i].token_count = new_tokens;
|
||||
self.history_tokens = self.history_tokens.saturating_sub(old_tokens).saturating_add(new_tokens);
|
||||
// msg-split-phase1:改既有 user 消息 content,DB 需全量重写收敛。
|
||||
self.needs_full_rewrite = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 只读迭代(兼容 ensure_conversation_title 的 .iter().filter() 等)
|
||||
pub fn iter(&self) -> impl Iterator<Item = &ChatMessage> {
|
||||
self.messages.iter().map(|t| &t.message)
|
||||
}
|
||||
|
||||
/// 消息级溯源:取末条指定 role 消息的 id(ULID)。
|
||||
///
|
||||
/// 从尾部反向扫描(末条消息命中即停,避免全量 O(n) 正扫累积),返回最近一条
|
||||
/// `role` 匹配且 `id` 非空消息的 id。无匹配或老消息无 id → None(向前兼容:
|
||||
/// 老反序列化消息 id=None,溯源写入降级为 None,展示侧兼容 `conv:` 旧格式)。
|
||||
///
|
||||
/// 用途:
|
||||
/// - `MessageRole::Assistant`:audit/知识提炼写入时取当前 assistant 消息 id
|
||||
/// (LLM 返回带 tool_calls 的 assistant 已 push,process_tool_calls 入口取)
|
||||
/// - `MessageRole::User`:知识注入 referenced 事件溯源取触发检索的 user 消息 id
|
||||
fn last_message_id_by_role(&self, role: MessageRole) -> Option<String> {
|
||||
self.messages
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|t| {
|
||||
std::mem::discriminant(&t.message.role) == std::mem::discriminant(&role)
|
||||
})
|
||||
.and_then(|t| t.message.id.clone())
|
||||
}
|
||||
|
||||
/// 末条 assistant 消息的 id(消息级溯源用)。
|
||||
pub fn last_assistant_message_id(&self) -> Option<String> {
|
||||
self.last_message_id_by_role(MessageRole::Assistant)
|
||||
}
|
||||
|
||||
/// 末条 user 消息的 id(消息级溯源用)。
|
||||
pub fn last_user_message_id(&self) -> Option<String> {
|
||||
self.last_message_id_by_role(MessageRole::User)
|
||||
}
|
||||
|
||||
// ── [P2 改进5] 主题切换检测(保守,双高置信才标) ──
|
||||
|
||||
/// 取末条 user 消息的 topic 标签(供 push 时主题切换检测)。
|
||||
///
|
||||
/// 从尾部反向扫描 user 消息,取最近一条 role=User 的 TrackedMessage.topic。
|
||||
/// 老消息(未接改进5 推断)topic=None → 返 None(向前兼容)。无 user 消息 → None。
|
||||
fn last_user_topic(&self) -> Option<String> {
|
||||
self.messages
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|t| matches!(t.message.role, MessageRole::User))
|
||||
.and_then(|t| t.topic.clone())
|
||||
}
|
||||
|
||||
/// 取并消费 pending_topic_marker(供 agentic loop 顶部读 → insert 系统标记 → 清空)。
|
||||
///
|
||||
/// 返回 "old|new" 格式字符串(push 时末两条 user topic 都非 None 且不同置位)。
|
||||
/// 取出即清空(一次性消费,防同 marker 重复 insert)。无 marker → None。
|
||||
pub fn take_topic_marker(&mut self) -> Option<String> {
|
||||
self.pending_topic_marker.take()
|
||||
}
|
||||
|
||||
// ── F-15 上下文管理增强辅助方法(压缩链路核心 API)──
|
||||
//
|
||||
// 已全量接入压缩链路:
|
||||
// - agentic/mod.rs 自动压缩(trigger 判定 + LLM/关键词兜底 + 重入保护)
|
||||
// set_compressing/is_compressing(重入标志) / has_compressible_messages(触发判定)
|
||||
// messages_mut(取 active 喂 LLM) / compress_old_messages(标 compressed 扣 token)
|
||||
// insert_at(摘要/续接锚点 system 消息插入首位)
|
||||
// - commands/ai/compress.rs 与 commands/ai/commands/chat.rs 走 IPC 压缩入口
|
||||
// - build_eviction_units(下方)供会话分段与压缩定位共用同一分组逻辑。
|
||||
|
||||
/// 配置(只读视图,供 agentic.rs 计算压缩触发阈值 `config().budget_limit()`)
|
||||
pub fn config(&self) -> &ContextConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
/// 可变消息切片(供标记 status="compressed"/"archived_segment" + 调整 token)
|
||||
///
|
||||
/// 调用方约定:仅改 `message.status` / `message.content`,不增删条目(增删走
|
||||
/// [`push`] / [`insert_at`]),否则 `history_tokens` 会与实际脱钩。
|
||||
///
|
||||
/// msg-split-phase1:**若调用方借此句柄改了既有消息(status/content),必须紧接着调
|
||||
/// [`mark_needs_full_rewrite`]**,否则下次 save 走 append-only 增量路径会漏更新旧行,
|
||||
/// DB 与内存脱钩。仅读(过滤后 clone)不需调。本方法无法自行置位(借用冲突:返回 &mut
|
||||
/// 切片时不能再持有 &mut self 标志)。
|
||||
pub fn messages_mut(&mut self) -> &mut [TrackedMessage] {
|
||||
&mut self.messages
|
||||
}
|
||||
|
||||
/// msg-split-phase1:显式标记"下次 save 须全量重写"。
|
||||
///
|
||||
/// 供 [`messages_mut`] 的调用方在改完既有消息后调用(本 struct 无法在返回 &mut 切片时
|
||||
/// 自行置位)。也可供任何绕过本 impl 直接改 messages 的路径兜底。幂等(重复置 true 无害)。
|
||||
pub fn mark_needs_full_rewrite(&mut self) {
|
||||
self.needs_full_rewrite = true;
|
||||
}
|
||||
|
||||
/// msg-split-phase1:读取已落库消息条数(append-only 增量写路径的基线)。供 save_conversation
|
||||
/// 判定 `[persisted_msg_count..len)` 是否为"自上次 save 后新增"。
|
||||
pub fn persisted_msg_count(&self) -> usize {
|
||||
self.persisted_msg_count
|
||||
}
|
||||
|
||||
/// msg-split-phase1:推进已落库消息条数(insert_batch/replace_conversation 成功后调用)。
|
||||
/// `full_rewrite_applied`=本次写库是否走了全量重写(快照时刻 needs_full_rewrite 快照)。
|
||||
/// 仅全量重写覆盖了既有行才清 needs_full_rewrite;增量/无变化路径写库期间新置的 dirty
|
||||
/// (如压缩链 mark)与本次 append 写库无关,误清会致改动滞留内存永不落库。供 save_conversation 写库成功后调用。
|
||||
pub fn advance_persisted_count(&mut self, new_count: usize, full_rewrite_applied: bool) {
|
||||
// 防回退:若调用方误传更小值(理论上不应发生),取 max 保不丢基线(下次 save 仍能收敛)。
|
||||
self.persisted_msg_count = self.persisted_msg_count.max(new_count);
|
||||
if full_rewrite_applied {
|
||||
self.needs_full_rewrite = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// msg-split-phase1:是否需要全量重写(改过既有消息 / 缩短 / 中段插入 / 清空)。
|
||||
pub fn needs_full_rewrite(&self) -> bool {
|
||||
self.needs_full_rewrite
|
||||
}
|
||||
|
||||
/// 在给定位置插入一条消息(其余向后移),并把它计入 token 预算(active 才计)。
|
||||
///
|
||||
/// 供压缩点插入摘要 system 消息。`index` 越界则 panic(对齐 Vec::insert 语义,
|
||||
/// 调用方负责算合法 index,如 `compress_end` 已由 `compress_old_messages` 校验)。
|
||||
pub fn insert_at(&mut self, index: usize, message: ChatMessage) {
|
||||
let tokens = self.estimator.estimate_message(&message);
|
||||
let group = classify_group(&message);
|
||||
if message.is_active() {
|
||||
self.history_tokens += tokens;
|
||||
}
|
||||
self.messages.insert(index, TrackedMessage {
|
||||
message,
|
||||
token_count: tokens,
|
||||
group,
|
||||
topic: None,
|
||||
});
|
||||
// msg-split-phase1:中段插入会改变 index 及之后所有消息的 seq(append-only 基线
|
||||
// persisted_msg_count 按"末尾追加"语义计算 seq,中段插入后 seq 错位),DB 必须全量重写。
|
||||
self.needs_full_rewrite = true;
|
||||
}
|
||||
|
||||
/// 追加一条 system 锚点消息(警示文本/压缩摘要),自动避开**悬空 tool_call 头**。
|
||||
///
|
||||
/// **源头守静(909)**:OpenAI/内部协议不变式是「assistant(tool_calls) 之后必须紧随
|
||||
/// 其 tool 响应」。主 loop 的顺序是 push 头(`finalize_round_output`)→ 释放锁 →
|
||||
/// 执行工具 → 再取锁 push 结果;若在「头已入、结果未回」的窗口内从尾部追加 system
|
||||
/// (后台压缩摘要/警示/nudge/主题标记),system 就楔进 head 与其响应之间 → 上游报
|
||||
/// 400「insufficient tool messages following tool_calls message」。
|
||||
///
|
||||
/// 本出口统一处置:末条非悬空头 → 常规尾部追加(不位移前缀,保自动前缀缓存,行为与
|
||||
/// 原 `push` 一致);末条是悬空头 → 改插在该头**之前**(index = len-1),不劈三元组,
|
||||
/// 摘要/警示仍紧邻本轮内容对 LLM 可见。返回 `true` 表示走了避让插入(会置
|
||||
/// `needs_full_rewrite`,需 DB 全量重写),供调用方记日志。
|
||||
pub fn push_system_anchor(&mut self, message: ChatMessage) -> bool {
|
||||
if self.tail_is_dangling_head() {
|
||||
let idx = self.messages.len() - 1;
|
||||
self.insert_at(idx, message);
|
||||
tracing::debug!(
|
||||
idx,
|
||||
"system 锚点避让悬空 tool_call 头插入(防楔入三元组致 400)"
|
||||
);
|
||||
true
|
||||
} else {
|
||||
self.push(message);
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// 末条是否为「悬空 tool_call 头」(assistant 带 tool_calls 且其 tool 响应尚未回填)。
|
||||
/// 供 [`Self::push_system_anchor`] 判定避让位置:末条即头,说明头已入而结果未回。
|
||||
fn tail_is_dangling_head(&self) -> bool {
|
||||
self.messages
|
||||
.last()
|
||||
.is_some_and(|m| m.group == MessageGroup::ToolCallHead)
|
||||
}
|
||||
|
||||
/// 按淘汰单元分组消息范围(三元组原子性),供压缩定位/分段标记复用同一分组逻辑。
|
||||
///
|
||||
/// 返回每个单元的右开区间 end + token 总和,保证:
|
||||
/// - 工具调用三元组(Head + Tail* + 紧随的 Standalone Assistant)在同一单元
|
||||
/// - 保护区 `[protect_start, len)` 内的消息不纳入任何单元
|
||||
///
|
||||
/// 公开会话分段(`archived_segment` 按组原子标记)与压缩定位共用。
|
||||
pub fn build_eviction_units(&self, protect_start: usize) -> Vec<EvictionUnit> {
|
||||
let mut units = Vec::new();
|
||||
let mut i = 0usize;
|
||||
|
||||
while i < protect_start {
|
||||
let mut token_sum = 0u32;
|
||||
|
||||
if self.messages[i].group == MessageGroup::ToolCallHead {
|
||||
token_sum += self.messages[i].token_count;
|
||||
i += 1;
|
||||
while i < protect_start && self.messages[i].group == MessageGroup::ToolResultTail {
|
||||
token_sum += self.messages[i].token_count;
|
||||
i += 1;
|
||||
}
|
||||
if i < protect_start
|
||||
&& self.messages[i].group == MessageGroup::Standalone
|
||||
&& matches!(self.messages[i].message.role, MessageRole::Assistant)
|
||||
{
|
||||
token_sum += self.messages[i].token_count;
|
||||
i += 1;
|
||||
}
|
||||
} else {
|
||||
token_sum += self.messages[i].token_count;
|
||||
i += 1;
|
||||
}
|
||||
|
||||
units.push(EvictionUnit { end: i, token_sum });
|
||||
}
|
||||
|
||||
units
|
||||
}
|
||||
|
||||
/// 保护区外是否存在可压缩消息(供 agentic loop 顶部触发判断)。
|
||||
///
|
||||
/// "可压缩"= status 为 None/active 的消息(已 compressed/archived_segment/truncated
|
||||
/// 不参与二次压缩,幂等)。`protect_start` 为保护区起点(如 `len - PROTECT_COUNT`)。
|
||||
pub fn has_compressible_messages(&self, protect_start: usize) -> bool {
|
||||
let end = protect_start.min(self.messages.len());
|
||||
// 排除 system 角色(压缩摘要 / 话题切换锚点)。这些是上下文锚点非压缩目标——
|
||||
// 若计入,压缩摘要 insert_at(0) 落在可压缩区 [0..protect_start) 且 is_active(status=None),
|
||||
// 致每轮 has_compressible 恒 true → 无限循环压缩(用户报"压缩后每轮提示已压缩并停止")。
|
||||
// compress_old_messages 不改:被调用时仍标旧 system 摘要 compressed(被新摘要替代,防堆积)。
|
||||
self.messages[..end]
|
||||
.iter()
|
||||
.any(|t| t.message.is_active() && !matches!(t.message.role, MessageRole::System))
|
||||
}
|
||||
|
||||
/// 把保护区 `[0, compress_end)` 范围内的 active 消息标记为 `status="compressed"`,
|
||||
/// 同步从 `history_tokens` 扣除其 token,返回被压缩消息的克隆(供喂 LLM 摘要)。
|
||||
///
|
||||
/// **幂等**:已 compressed(或任何 !active)的消息跳过,不会被二次压缩;`history_tokens`
|
||||
/// 也只扣首次标记的 token。返回的 Vec 仅含**本次新标记**的消息(已 compressed 的不返)。
|
||||
///
|
||||
/// **单向不可逆**:压缩后 DB 原始消息保留,但 LLM 上下文里被 is_active 白名单隔离
|
||||
/// (sanitize step0 过滤)。`compress_end` 越界自动 clamp 到 `messages.len()`,且实际
|
||||
/// 压缩边界会回退到最近的对话单元边界(三元组不劈开,见方法内注释)。
|
||||
///
|
||||
/// 返回空 Vec 表示本批次无可压缩消息(全部已 compressed 或范围空),调用方据此跳过 LLM 调用。
|
||||
pub fn compress_old_messages(&mut self, compress_end: usize) -> Vec<ChatMessage> {
|
||||
// 压缩边界按对话单元(三元组)对齐:调用方传入的 end(如 len - PROTECT_COUNT)周期性
|
||||
// 落在单元中间(assistant 头与其 tool 结果之间),只压半个三元组会让保护区以孤立的
|
||||
// 头或尾开头,成为 sanitize 每请求告警的确定性供给。按全长分组取「完整单元边界中
|
||||
// 不超过 raw_end 的最大者」回退(只少压不多压,不侵占保护区);三元组要么整段压缩
|
||||
// 要么整段保留。空范围/空历史无单元,取 0。
|
||||
let raw_end = compress_end.min(self.messages.len());
|
||||
let end = self
|
||||
.build_eviction_units(self.messages.len())
|
||||
.iter()
|
||||
.map(|u| u.end)
|
||||
.take_while(|&e| e <= raw_end)
|
||||
.last()
|
||||
.unwrap_or(0);
|
||||
let mut newly_compressed = Vec::new();
|
||||
for t in self.messages[..end].iter_mut() {
|
||||
if t.message.is_active() {
|
||||
newly_compressed.push(t.message.clone());
|
||||
t.message.status = Some(MessageStatus::Compressed);
|
||||
self.history_tokens = self.history_tokens.saturating_sub(t.token_count);
|
||||
}
|
||||
}
|
||||
// msg-split-phase1:改了既有消息 status(active → compressed),DB 需全量重写收敛
|
||||
// (append-only 增量路径不覆盖旧行)。仅在确实标了新 compressed 时置位(无可压缩时
|
||||
// newly_compressed 为空,状态未变,无需全量重写)。
|
||||
if !newly_compressed.is_empty() {
|
||||
self.needs_full_rewrite = true;
|
||||
}
|
||||
newly_compressed
|
||||
}
|
||||
|
||||
/// 压缩重入标志(读)。true 表示一次 LLM 压缩正在进行中,触发方应跳过本轮压缩。
|
||||
pub fn is_compressing(&self) -> bool {
|
||||
self.is_compressing
|
||||
}
|
||||
|
||||
/// 压缩重入标志(写)。`true`=开始压缩(进入 agentic loop 顶部前置置位),
|
||||
/// `false`=压缩结束(无论成功或降级)。调用方必须成对调用,防止永久卡死。
|
||||
pub fn set_compressing(&mut self, v: bool) {
|
||||
self.is_compressing = v;
|
||||
}
|
||||
|
||||
// build_eviction_units / classify_group 等已在上方公开或文件级定义。
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod manager_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod compress_boundary_tests {
|
||||
use super::*;
|
||||
use crate::provider::ToolCall;
|
||||
|
||||
fn mgr() -> ContextManager {
|
||||
ContextManager::new(ContextConfig {
|
||||
max_tokens: 100_000,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// 构造 4 个周期轮次,每轮 [user, assistant(tool_calls), tool_result, assistant]:
|
||||
/// user 独占一个单元,后三条(头+尾+紧随独立 assistant)合并为一个单元,
|
||||
/// 单元边界落在 4/8/12/16。
|
||||
fn periodic_rounds() -> ContextManager {
|
||||
let mut m = mgr();
|
||||
for r in 0..4 {
|
||||
m.push(ChatMessage::user(&format!("问题 {}", r)));
|
||||
m.push(ChatMessage::assistant_with_tools(
|
||||
"",
|
||||
vec![ToolCall::new(format!("c{}", r), "run_command", "{}")],
|
||||
));
|
||||
m.push(ChatMessage::tool_result(format!("c{}", r), "ok"));
|
||||
m.push(ChatMessage::assistant("小结"));
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compress_end_mid_unit_retreats_to_unit_boundary() {
|
||||
let mut m = periodic_rounds();
|
||||
// len=16,调用方口径 len - PROTECT_COUNT(6) = 10,落在第 3 轮三元组中间
|
||||
// (9 头 / 10 尾 / 11 独立)。旧行为会把头压掉却留下孤立的 tool 结果。
|
||||
// 回退到完整单元边界 9:只多压前面的 user,不劈开三元组(只少压不多压)。
|
||||
let compressed = m.compress_old_messages(10);
|
||||
assert_eq!(compressed.len(), 9, "边界回退到完整单元边界 9");
|
||||
let msgs = m.messages_mut();
|
||||
for i in 9..16 {
|
||||
assert!(msgs[i].message.is_active(), "第 {} 条应保持 active", i);
|
||||
}
|
||||
// 关键回归点:第 3 轮三元组(9 头 / 10 尾 / 11 独立)整体保持 active,头未被劈开
|
||||
assert!(msgs[9].message.is_active(), "三元组头不应被单独压缩");
|
||||
assert!(msgs[10].message.is_active(), "三元组尾不应残留 active 而头被压");
|
||||
assert!(msgs[11].message.is_active());
|
||||
assert_eq!(msgs[9].group, MessageGroup::ToolCallHead);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compress_end_on_boundary_unchanged() {
|
||||
let mut m = periodic_rounds();
|
||||
// end=8 恰为单元边界,行为与对齐前一致(整 8 条压缩)。
|
||||
let compressed = m.compress_old_messages(8);
|
||||
assert_eq!(compressed.len(), 8);
|
||||
let msgs = m.messages_mut();
|
||||
assert!(msgs[..8].iter().all(|t| !t.message.is_active()));
|
||||
assert!(msgs[8..].iter().all(|t| t.message.is_active()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compress_end_oversized_still_compresses_all() {
|
||||
let mut m = periodic_rounds();
|
||||
// 越界 clamp 到 len:全量单元分组后末单元 end=len,语义与改动前一致。
|
||||
let compressed = m.compress_old_messages(999);
|
||||
assert_eq!(compressed.len(), 16);
|
||||
assert_eq!(m.history_tokens(), 0);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
//! 上下文管理纯函数与数据类型 — 从 context.rs 抽离的无 `self` 依赖部分
|
||||
//! 上下文管理纯函数与数据类型 — 从 context.rs 抽离的无 `self` 依赖部分
|
||||
//!
|
||||
//! 职责:
|
||||
//! - Token 粗估器(字符级近似,无 tokenizer 依赖)
|
||||
@@ -44,34 +44,48 @@ impl Default for TokenEstimator {
|
||||
}
|
||||
}
|
||||
|
||||
// url 模式图片 token 地板(Anthropic 保守 ~1600/图,OpenAI 按 tile;URL 长度估严重低估会致预算裁剪不触发)。
|
||||
// 2026-08-05(F-260801 Phase4):url 模式无字节可估,仅按 URL 长度估(几十字符≈几 token)会致含图
|
||||
// 消息 history_tokens 严重低估 → 预算裁剪不触发 → 多图/长会话超 provider 上限 400。地板按每图
|
||||
// ~1600 token 保守估(对齐 Anthropic 图片 token 成本量级),宁可高估触发裁剪也不低估漏裁。
|
||||
// 注意单位是 token 不是字符:地板必须在 ×chars_ratio 折扣之后直接加 token,若混进 char_count
|
||||
// 会被 0.35 稀释成 560,保底名存实亡。
|
||||
const URL_IMAGE_TOKEN_FLOOR: usize = 1600;
|
||||
|
||||
impl TokenEstimator {
|
||||
/// 估算单条消息的 token 数(保守估计)
|
||||
///
|
||||
/// F-260614-05 多模态回归修正:`msg.parts` 中的 Image.base64 与 Text.text 同样计入预算。
|
||||
/// 多模态回归修正:`msg.parts` 中的 Image.base64 与 Text.text 同样计入预算。
|
||||
/// 此前只算 `content`,含图消息的大段 base64(可达 25 万 tokens)被完全忽略,致
|
||||
/// `history_tokens` 严重低估 → build_for_request 误判未超预算 → provider 超限 400/500。
|
||||
/// 这里把 parts 的文本/base64 按同一 chars_ratio 粗估累加(base64 视为密集字符,0.35 偏保守)。
|
||||
pub fn estimate_message(&self, msg: &ChatMessage) -> u32 {
|
||||
let mut char_count = msg.content.chars().count();
|
||||
// url 图地板单独按 token 累加:地板语义是"每图至少 ~1600 token",必须生效于
|
||||
// ×chars_ratio 折扣之后——若折进 char_count 会被 0.35 稀释成 560,1600 保底失效,
|
||||
// 含 url 图消息的 history_tokens 低估 → 预算裁剪漏触发 → provider 超限 400。
|
||||
let mut url_image_floor_tokens: u32 = 0;
|
||||
if let Some(parts) = &msg.parts {
|
||||
for p in parts {
|
||||
match p {
|
||||
crate::provider::ContentPart::Text { text } => char_count += text.chars().count(),
|
||||
crate::provider::ContentPart::Image { base64, url, .. } => {
|
||||
// CR-260618-11#2:0.35 按 base64 字节数粗估,显著高于厂商实际(OpenAI 按像素非字节)。
|
||||
// 0.35 按 base64 字节数粗估,显著高于厂商实际(OpenAI 按像素非字节)。
|
||||
// 偏保守致含图消息 token 高估、过度裁剪;降值(如 0.10~0.15)需独立评估裁剪边界,本次不改值仅标注。
|
||||
// base64 优先(多模态主载荷),url 次之;url 模式无字节,仅按 URL 长度估
|
||||
// base64 优先(多模态主载荷),url 次之;url 模式无字节,按地板直接计 token
|
||||
if let Some(b) = base64 {
|
||||
char_count += b.chars().count();
|
||||
} else if let Some(u) = url {
|
||||
char_count += u.chars().count();
|
||||
} else if url.is_some() {
|
||||
// F-260801 Phase4:url 模式按地板估(URL 长度估严重低估,见 URL_IMAGE_TOKEN_FLOOR 注)。
|
||||
url_image_floor_tokens = url_image_floor_tokens
|
||||
.saturating_add(URL_IMAGE_TOKEN_FLOOR as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let content_tokens = (char_count as f32 * self.chars_ratio).ceil() as u32;
|
||||
let mut total = content_tokens + self.per_message_overhead;
|
||||
let mut total = content_tokens + url_image_floor_tokens + self.per_message_overhead;
|
||||
|
||||
// tool_calls 的 JSON 结构开销(role=Assistant 时可能有)
|
||||
if let Some(ref calls) = msg.tool_calls {
|
||||
@@ -145,7 +159,7 @@ pub enum MessageGroup {
|
||||
|
||||
/// 带有 token 缓存和分组信息的消息条目
|
||||
///
|
||||
/// 字段 `pub`:供阶段2 IPC 经 `ContextManager::messages_mut()` 拿到可变切片后,
|
||||
/// 字段 `pub`:供 IPC 经 `ContextManager::messages_mut()` 拿到可变切片后,
|
||||
/// 直接改 `message.status` / 读 `token_count` 做 token 重算(Mutex 单线程访问,
|
||||
/// 同 crate 内安全)。结构体本身也 `pub`(返回类型对外可见)。
|
||||
pub struct TrackedMessage {
|
||||
@@ -178,20 +192,15 @@ pub fn classify_group(msg: &ChatMessage) -> MessageGroup {
|
||||
// 改进3 B: 压缩失败兜底 — 关键词摘要提取(纯函数,无 LLM)
|
||||
// ============================================================
|
||||
|
||||
/// 压缩失败降级:从消息提取关键词摘要(避免裸裁剪丢主题)。
|
||||
///
|
||||
/// 当 LLM 压缩失败时,原本只能裸裁剪保最近 6 条(可能丢失用户反复提到的核心
|
||||
/// 主题词/实体名)。本函数从 user 消息中提取频次最高的关键词,组装成 system 摘要
|
||||
/// 塞回首条,作为续接锚点。
|
||||
/// 从消息提取高频关键词(提炼核心,纯函数无 LLM)— 关键词摘要/淘汰补偿摘要共用。
|
||||
///
|
||||
/// 策略(纯启发式,无 tokenizer 依赖):
|
||||
/// - 仅取 user 消息(用户表达意图的载体,assistant 多为操作流水)。
|
||||
/// - 按非字母数字下划线汉字边界分词;保留长度 >= 2 的词。
|
||||
/// - 去停用词(常见虚词/助词,中英混合)。
|
||||
/// - 频次 top-N,频次相同按词长降序(长实体优先),再按字典序稳定。
|
||||
/// - 返回 system 文本(空消息/无可提取词 → 空字符串,调用方判空跳过 insert)。
|
||||
pub fn extract_keyword_summary(msgs: &[ChatMessage]) -> String {
|
||||
const TOP_N: usize = 10;
|
||||
/// - 空消息/无可提取词 → 空 Vec,调用方判空跳过。
|
||||
pub fn extract_top_keywords(msgs: &[ChatMessage], top_n: usize) -> Vec<String> {
|
||||
const MIN_WORD_LEN: usize = 2;
|
||||
|
||||
// 中英混合停用词表(高频虚词/助词/常见无主题意义词)。
|
||||
@@ -232,7 +241,7 @@ pub fn extract_keyword_summary(msgs: &[ChatMessage]) -> String {
|
||||
}
|
||||
|
||||
if freq.is_empty() {
|
||||
return String::new();
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut entries: Vec<(String, u32)> = freq.into_iter().collect();
|
||||
@@ -243,17 +252,82 @@ pub fn extract_keyword_summary(msgs: &[ChatMessage]) -> String {
|
||||
.then_with(|| a.0.cmp(&b.0))
|
||||
});
|
||||
|
||||
let keywords: Vec<&str> = entries.iter().take(TOP_N).map(|(w, _)| w.as_str()).collect();
|
||||
entries
|
||||
.into_iter()
|
||||
.take(top_n)
|
||||
.map(|(w, _)| w)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 压缩失败降级:从消息提取关键词摘要(避免裸裁剪丢主题)。
|
||||
///
|
||||
/// 当 LLM 压缩失败时,原本只能裸裁剪保最近 6 条(可能丢失用户反复提到的核心
|
||||
/// 主题词/实体名)。本函数从 user 消息中提取频次最高的关键词,组装成 system 摘要
|
||||
/// 塞回首条,作为续接锚点。提炼核心见 [`extract_top_keywords`]。
|
||||
/// 返回 system 文本(空消息/无可提取词 → 空字符串,调用方判空跳过 insert)。
|
||||
pub fn extract_keyword_summary(msgs: &[ChatMessage]) -> String {
|
||||
const TOP_N: usize = 10;
|
||||
let keywords = extract_top_keywords(msgs, TOP_N);
|
||||
if keywords.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
format!(
|
||||
"[上下文压缩失败兜底摘要] 用户在历史对话中反复提及的关键主题词(作为续接锚点保留):\n{}",
|
||||
keywords.join("、")
|
||||
)
|
||||
}
|
||||
|
||||
/// 视图裁剪淘汰补偿摘要 — 从被淘汰消息提炼一短段主题概要(纯函数)。
|
||||
///
|
||||
/// 背景:build_for_request 超预算按淘汰单元裁剪发送视图时,被淘汰内容从此对 LLM 不可见
|
||||
/// 且无任何补偿,与压缩路径(有摘要补偿)行为不一致。本函数把被淘汰文本提炼成
|
||||
/// 「[早期对话概要] 高频主题: …」一短段,调用方将其作为 role=user 消息插回发送视图
|
||||
/// 被淘汰区头部(role=system 会被 anthropic_compat 折叠进带 cache_control 的顶层
|
||||
/// system 段,而本摘要每轮再生成,会持续改写缓存指纹)。
|
||||
///
|
||||
/// **缓存铁律**:产出只进 messages 列表(易变区),严禁拼进 system_prompt 或任何
|
||||
/// 稳定段/缓存指纹相关结构。build_for_request 为纯视图:每次请求即时生成,不落库、
|
||||
/// 不改 conv.messages 存储、不标记 compressed,被淘汰原文存储态不变。
|
||||
///
|
||||
/// 策略:
|
||||
/// - user/assistant 取全文,tool 结果仅取首行(大段 JSON/日志无主题价值);
|
||||
/// - 被淘汰文本合计过短(< 100 字符)不值得产摘要 → None;
|
||||
/// - 经 [`extract_top_keywords`] 提炼,空/无有效文本 → None;
|
||||
/// - 硬截断至 [`EVICTION_SUMMARY_MAX_CHARS`] 字符(按 char 边界,防汉字切半)。
|
||||
pub fn build_eviction_summary_text(msgs: &[ChatMessage]) -> Option<String> {
|
||||
/// 被淘汰内容合计低于此字符数则不值得产摘要(仅淘汰一两条寒暄无需补偿)。
|
||||
const EVICTION_SUMMARY_MIN_CHARS: usize = 100;
|
||||
const KEYWORD_TOP_N: usize = 10;
|
||||
|
||||
// 收集被淘汰文本:user/assistant 取全文,tool 取首行;合计字符数做"值得与否"闸门。
|
||||
let feed: Vec<ChatMessage> = msgs
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let mut m = m.clone();
|
||||
if matches!(m.role, MessageRole::Tool) {
|
||||
m.content = m.content.lines().next().unwrap_or("").to_string();
|
||||
}
|
||||
m
|
||||
})
|
||||
.collect();
|
||||
let total_chars: usize = feed.iter().map(|m| m.content.chars().count()).sum();
|
||||
if total_chars < EVICTION_SUMMARY_MIN_CHARS {
|
||||
return None;
|
||||
}
|
||||
|
||||
let keywords = extract_top_keywords(&feed, KEYWORD_TOP_N);
|
||||
if keywords.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut text = format!("[早期对话概要] 高频主题: {}", keywords.join("、"));
|
||||
// 硬上限截断(按 char 计数,多字节汉字不会切出非法边界)。
|
||||
if text.chars().count() > EVICTION_SUMMARY_MAX_CHARS {
|
||||
text = text.chars().take(EVICTION_SUMMARY_MAX_CHARS).collect();
|
||||
}
|
||||
Some(text)
|
||||
}
|
||||
|
||||
/// 简单分词:按非(字母/数字/下划线/汉字)边界切分,保留连续的词素 + 汉字 2-gram。
|
||||
///
|
||||
/// 中英混合:连续的拉丁/数字/下划线聚成一个词素(标识符如 build_for_request);
|
||||
@@ -341,6 +415,25 @@ pub const TOOL_RESULT_SUMMARIZE_RATIO: f32 = 0.40;
|
||||
pub const TOOL_RESULT_HEAD_LINES: usize = 5;
|
||||
/// extract_key_info 保留的尾部行数。
|
||||
pub const TOOL_RESULT_TAIL_LINES: usize = 5;
|
||||
/// extract_key_info JSON 数组截断上限(防 tool_result 数组过大撑爆 prompt)。
|
||||
pub const TOOL_RESULT_MAX_ARRAY: usize = 10;
|
||||
/// extract_key_info 单行/少行内容字符截断上限(实测 53/94 次压缩零效果根因:
|
||||
/// 单行 JSON 或 ≤10 行文本绕过行级截断)。超过此值的单行内容将被截断保留头尾。
|
||||
pub const TOOL_RESULT_CHAR_LIMIT: usize = 1_024;
|
||||
/// JSON 对象中字符串字段值的最大字符数(超过则截断)。独立于行数截断,
|
||||
/// 解决 `{"content":"大段文字(无换行)"}` 类 JSON 逃逸行级截断的问题。
|
||||
pub const TOOL_RESULT_JSON_STR_FIELD_MAX: usize = 512;
|
||||
/// read_file 豁免上限(P1 工具结果瘦身 2026-10-10):≤此值原样返回,超过则走压缩+落盘。
|
||||
///
|
||||
/// 依据实测:全库工具结果体量前二为 patch_file 34.6% / read_file 29.6%(单条 max 150KB),
|
||||
/// 原「read_file 一律豁免」让 29.6% 体量完全裸奔(65K 均值 prompt 压不下来的直接原因之一)。
|
||||
/// 保留小文件豁免(AI 定向读代码需完整语义——历史有「限读 28KB 被折叠致能力受损」实证),
|
||||
/// 阈值取 8KB 与 D2a 审计侧同构;超过则摘要+落盘句柄(可无损读回)。
|
||||
pub const READ_FILE_OFFLOAD_BYTES: usize = 8 * 1024;
|
||||
/// 追加落盘句柄的最小原文字节数(与 D2a 审计侧 8KB 阈值对齐)。
|
||||
/// 小于此值的压缩结果**不加句柄**:有损摘要的丢失量小,而句柄行(约 150 字符)会净增 token;
|
||||
/// ≥此值才值得(丢失量大、句柄占比可忽略),兼顾「无损可读回」与「不反噬瘦身目标」。
|
||||
pub const TOOL_OFFLOAD_HANDLE_MIN_BYTES: usize = 8 * 1024;
|
||||
|
||||
/// 判断 tool_result 是否需摘要压缩:content >2KB 或 占比 >40%。
|
||||
///
|
||||
@@ -372,8 +465,81 @@ pub fn should_summarize_tool_result(
|
||||
/// - 错误行(在头尾区间外的)额外插入,标注位置。
|
||||
/// - 中间大段省略为 `... (省略 N 行) ...`。
|
||||
///
|
||||
/// `tool_name` 仅用于摘要头注释,不参与内容判断。空 content 返回空字符串。
|
||||
/// `tool_name` 参与内容判断:`read_file` 定向读代码,压缩其 content 会削弱代码分析能力,
|
||||
/// 故保留**小文件豁免**(≤ READ_FILE_OFFLOAD_BYTES 原样返回);超阈值仍走压缩并落盘
|
||||
/// (P1 2026-10-10,句柄可读回,无损)。其余工具仍走压缩。
|
||||
/// 空内容一律返回空字符串。
|
||||
pub fn extract_key_info(content: &str, tool_name: &str) -> String {
|
||||
let compressed = extract_key_info_inner(content, tool_name);
|
||||
// P1 无损化(2026-10-10):确实发生压缩 → 原文落盘 + 追加句柄行,被省略的中段可 read_file 读回。
|
||||
// 句柄**只对 ≥ TOOL_OFFLOAD_HANDLE_MIN_BYTES 的大结果**追加——小结果有损摘要丢得少,
|
||||
// 加句柄行反而净增 token(反噬瘦身目标);大结果丢失量大,句柄占比可忽略,才值得。
|
||||
// base 未注入(单测/未初始化)→ 不加句柄,输出与改造前逐字节一致(零行为变化)。
|
||||
if content.len() >= TOOL_OFFLOAD_HANDLE_MIN_BYTES && compressed.len() < content.len() {
|
||||
if let Some(handle) = crate::tool_offload::offload_with_handle(content, tool_name) {
|
||||
return format!("{compressed}\n{handle}");
|
||||
}
|
||||
}
|
||||
compressed
|
||||
}
|
||||
|
||||
/// extract_key_info 的纯压缩实现(零 IO,便于单测与复用)。
|
||||
fn extract_key_info_inner(content: &str, tool_name: &str) -> String {
|
||||
// read_file 高阈值豁免(P1 2026-10-10):实测任务结果体量 read_file 占 29.6%(单条 max 150KB),
|
||||
// 原「一律豁免」致最大头裸奔(65K 均值 prompt 压不下来的直接原因之一)。
|
||||
// 改为「小文件豁免 + 超阈值压缩并落盘」:既保 AI 定向读代码语义,又让超大读取可瘦身。
|
||||
if tool_name == "read_file" && content.len() <= READ_FILE_OFFLOAD_BYTES {
|
||||
return content.to_string();
|
||||
}
|
||||
// JSON 感知压缩:识别对象中的大数组/大字符串并截断
|
||||
if let Ok(mut val) = serde_json::from_str::<serde_json::Value>(content) {
|
||||
if let Some(obj) = val.as_object_mut() {
|
||||
let mut truncated = false;
|
||||
for (_key, field) in obj.iter_mut() {
|
||||
// 数组截断
|
||||
if let Some(arr) = field.as_array() {
|
||||
if arr.len() > TOOL_RESULT_MAX_ARRAY {
|
||||
*field = serde_json::Value::Array(
|
||||
arr.iter().take(TOOL_RESULT_MAX_ARRAY).cloned().collect()
|
||||
);
|
||||
truncated = true;
|
||||
}
|
||||
}
|
||||
// 字符串字段:先按行数截断,若不足再按字符数截断
|
||||
if let Some(s) = field.as_str() {
|
||||
let lines: Vec<&str> = s.lines().collect();
|
||||
let kept = TOOL_RESULT_HEAD_LINES + TOOL_RESULT_TAIL_LINES;
|
||||
if lines.len() > kept {
|
||||
let mut out: Vec<&str> = Vec::new();
|
||||
out.extend_from_slice(&lines[..TOOL_RESULT_HEAD_LINES]);
|
||||
out.push("... (压缩中间内容) ...");
|
||||
out.extend_from_slice(&lines[lines.len()-TOOL_RESULT_TAIL_LINES..]);
|
||||
*field = serde_json::Value::String(out.join("\n"));
|
||||
truncated = true;
|
||||
} else if s.chars().count() > TOOL_RESULT_JSON_STR_FIELD_MAX {
|
||||
// 单行/少行大字符串绕过行级截断(实测 53/94 次零效果)。
|
||||
// 按字符数截断保留头尾,保证压缩至少生效。
|
||||
// 标记格式与纯文本单行分支对齐:含 `(截断)` 连续子串,便于上层断言/解析。
|
||||
let head: String = s.chars().take(TOOL_RESULT_JSON_STR_FIELD_MAX / 2).collect();
|
||||
let tail: String = s.chars().skip(s.chars().count().saturating_sub(TOOL_RESULT_JSON_STR_FIELD_MAX / 2)).collect();
|
||||
*field = serde_json::Value::String(format!(
|
||||
"{}...(截断) 原始 {} 字符...{}",
|
||||
head, s.chars().count(), tail
|
||||
));
|
||||
truncated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if truncated {
|
||||
obj.insert("_truncated".into(), serde_json::Value::Bool(true));
|
||||
return serde_json::to_string(&val).unwrap_or_else(|_| content.to_string());
|
||||
}
|
||||
}
|
||||
// JSON 解析成功但无需截断 → 原样返回
|
||||
return content.to_string();
|
||||
}
|
||||
|
||||
// 非 JSON 纯文本:按行数截断
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
if lines.is_empty() {
|
||||
return String::new();
|
||||
@@ -383,6 +549,17 @@ pub fn extract_key_info(content: &str, tool_name: &str) -> String {
|
||||
let total = lines.len();
|
||||
let kept_boundary = TOOL_RESULT_HEAD_LINES + TOOL_RESULT_TAIL_LINES;
|
||||
if total <= kept_boundary {
|
||||
// 行数少但内容超大的情况(单行 50KB),行级截断无效。
|
||||
// 按字符数截断保证压缩至少生效。
|
||||
let char_count = content.chars().count();
|
||||
if char_count > TOOL_RESULT_CHAR_LIMIT {
|
||||
let head: String = content.chars().take(TOOL_RESULT_CHAR_LIMIT / 2).collect();
|
||||
let tail: String = content.chars().skip(char_count.saturating_sub(TOOL_RESULT_CHAR_LIMIT / 2)).collect();
|
||||
return format!(
|
||||
"[工具 {} 输出已压缩: 保留首尾, 原始 {} 字符]\n{}...(截断)...{}",
|
||||
tool_name, char_count, head, tail
|
||||
);
|
||||
}
|
||||
return content.to_string();
|
||||
}
|
||||
|
||||
@@ -457,7 +634,7 @@ fn is_error_line(line: &str) -> bool {
|
||||
|
||||
/// 淘汰单元:连续消息范围 [..end) + token 总和
|
||||
///
|
||||
/// `pub` 供 `build_eviction_units` 的返回类型对外可见(阶段2/3 调用方读 `end` / `token_sum`)。
|
||||
/// `pub` 供 `build_eviction_units` 的返回类型对外可见(调用方读 `end` / `token_sum`)。
|
||||
pub struct EvictionUnit {
|
||||
pub end: usize,
|
||||
pub token_sum: u32,
|
||||
@@ -470,11 +647,18 @@ pub struct EvictionUnit {
|
||||
/// 保护区大小:最后 N 条消息永不淘汰(≈ 最近 2 个完整用户轮次)
|
||||
pub const PROTECT_COUNT: usize = 6;
|
||||
|
||||
/// 淘汰补偿摘要的硬上限(字符数)。
|
||||
///
|
||||
/// build_for_request 超预算裁剪时,把被淘汰文本提炼成一短段 system 摘要插回发送视图
|
||||
/// 头部(补偿无声丢弃的早期主题,对齐压缩路径的"裁剪有补偿"语义)。摘要过长会吃掉
|
||||
/// 本次裁剪腾出的预算,故硬截断到此上限(≈百 token 量级,相对淘汰收益可忽略)。
|
||||
pub const EVICTION_SUMMARY_MAX_CHARS: usize = 300;
|
||||
|
||||
/// Anthropic 流式 tool_use 缺 id 时的占位 id 前缀(见 anthropic_compat.rs 170-176)。
|
||||
/// 此类 id 必然无匹配 tool_result,是历史中毒的标志,sanitize 时据此剔除畸形三元组。
|
||||
pub const TOOL_MISSING_PREFIX: &str = "tool_missing_";
|
||||
|
||||
/// 阶段2(path_auth 审批链重构):占位配对完整性(解 400 orphan)常量开关。
|
||||
/// 占位配对完整性(解 400 orphan)常量开关。
|
||||
///
|
||||
/// 根因:审批挂起占位 tool_result(内容为 audit/cache.rs:PENDING_APPROVAL_PLACEHOLDER)
|
||||
/// 与其 tool_call 头经 sanitize/compress 裁剪后丢配对头 → orphan tool_result(无头)→
|
||||
@@ -486,6 +670,9 @@ pub const TOOL_MISSING_PREFIX: &str = "tool_missing_";
|
||||
/// 兜底:sanitize view-only 不改持久化,失败只影响单请求,flag 关→行为完全等价改动前。
|
||||
pub const PLACEHOLDER_INTEGRITY_ENABLED: bool = true;
|
||||
|
||||
/// namespace 引用路径前缀(转发至 namespace_store,供 context.rs 统一路径引用)
|
||||
pub const NAMESPACE_REF_PREFIX: &str = crate::namespace_store::NAMESPACE_REF_PREFIX;
|
||||
|
||||
/// 占位 tool_result 内容中嵌入的唯一标记前缀(供 sanitize 识别"审批挂起占位,不可裁")。
|
||||
///
|
||||
/// 写入处(audit/cache.rs:PENDING_APPROVAL_PLACEHOLDER)格式:`{占位文本}{__PENDING__}{tc_id}`,
|
||||
@@ -537,6 +724,244 @@ pub fn extract_pending_tc_id(content: &str) -> Option<&str> {
|
||||
.filter(|id| !id.is_empty())
|
||||
}
|
||||
|
||||
|
||||
// ============================================================
|
||||
// Wave 共享契约类型(由 上下文管理演进-执行计划 Agent 3 定义)
|
||||
// ============================================================
|
||||
|
||||
/// 压缩摘要双格式(T3 输出, T5 消费)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CompressedSummary {
|
||||
/// JSON 卡片序列化(主题/决策/待办/token节省)
|
||||
pub json_card: String,
|
||||
/// 自然语言摘要(向前兼容关键词兜底降级)
|
||||
pub nl_summary: String,
|
||||
}
|
||||
|
||||
/// 工作流 DAG 上下文块(T4 输出 → 注入 system prompt)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkflowContextBlock {
|
||||
pub workflow_id: String,
|
||||
pub workflow_name: String,
|
||||
pub total_nodes: usize,
|
||||
pub completed_nodes: usize,
|
||||
pub current_node: Option<String>,
|
||||
pub next_nodes: Vec<String>,
|
||||
}
|
||||
|
||||
impl WorkflowContextBlock {
|
||||
/// 格式化为 system prompt 可读文本
|
||||
pub fn to_system_text(&self) -> String {
|
||||
let mut s = format!("工作流: {} (ID: {})", self.workflow_name, self.workflow_id);
|
||||
s.push_str(&format!("\n进度: {}/{}", self.completed_nodes, self.total_nodes));
|
||||
if let Some(ref cur) = self.current_node {
|
||||
s.push_str(&format!("\n当前步骤: {}", cur));
|
||||
}
|
||||
if !self.next_nodes.is_empty() {
|
||||
s.push_str(&format!("\n后续: {}", self.next_nodes.join(" → ")));
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
/// 目标状态(T5 WorkingContext)
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum GoalStatus {
|
||||
Active,
|
||||
Completed,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// 步骤状态
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum StepStatus {
|
||||
Pending,
|
||||
InProgress,
|
||||
Done,
|
||||
Blocked,
|
||||
}
|
||||
|
||||
/// 目标条目
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GoalItem {
|
||||
pub id: String,
|
||||
pub text: String,
|
||||
pub status: GoalStatus,
|
||||
}
|
||||
|
||||
/// 决策条目
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DecisionItem {
|
||||
pub question: String,
|
||||
pub decision: String,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
/// 步骤信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StepInfo {
|
||||
pub step_name: String,
|
||||
pub status: StepStatus,
|
||||
}
|
||||
|
||||
/// WorkingContext 版本号(脏标记追踪)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkingContextVersion {
|
||||
pub goals_changed_at: u32,
|
||||
pub decisions_changed_at: u32,
|
||||
pub unresolved_changed_at: u32,
|
||||
pub step_changed_at: u32,
|
||||
pub current_turn: u32,
|
||||
}
|
||||
|
||||
impl WorkingContextVersion {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
goals_changed_at: 0,
|
||||
decisions_changed_at: 0,
|
||||
unresolved_changed_at: 0,
|
||||
step_changed_at: 0,
|
||||
current_turn: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// 压缩后重锚定:将所有脏标记置为 current_turn,未来 recent_turns 轮内各段必注入一次。
|
||||
/// 不解析 summary 内容,仅为签名兼容保留参数。
|
||||
pub fn reset_all(&mut self, summary: &str, current_turn: u32) {
|
||||
let _ = summary;
|
||||
self.goals_changed_at = current_turn;
|
||||
self.decisions_changed_at = current_turn;
|
||||
self.unresolved_changed_at = current_turn;
|
||||
self.step_changed_at = current_turn;
|
||||
self.current_turn = current_turn;
|
||||
}
|
||||
}
|
||||
|
||||
/// WorkingContext 常驻上下文(T5 实现)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkingContext {
|
||||
pub goals: Vec<GoalItem>,
|
||||
pub decisions: Vec<DecisionItem>,
|
||||
pub unresolved: Vec<String>,
|
||||
pub current_step: Option<StepInfo>,
|
||||
pub version: WorkingContextVersion,
|
||||
}
|
||||
|
||||
impl WorkingContext {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
goals: Vec::new(),
|
||||
decisions: Vec::new(),
|
||||
unresolved: Vec::new(),
|
||||
current_step: None,
|
||||
version: WorkingContextVersion::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 构建本轮需要注入的 L1 内容(L1a 必带 + L1b 条件注入)
|
||||
pub fn build_injection(&self, recent_turns: u32) -> String {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
|
||||
// L1a: 当前步骤(每轮必带)
|
||||
if let Some(ref step) = self.current_step {
|
||||
parts.push(format!("当前步骤: {} [{:?}]", step.step_name, step.status));
|
||||
}
|
||||
|
||||
// L1b: 过去 N 轮内有变更才注入。窗口语义 = 最近 N 轮 [current_turn-N+1, current_turn]:
|
||||
// 变更落在第 current_turn-N 轮(即 N+1 轮前)已出窗。原实现下界取 current_turn-N 且
|
||||
// 比较 >=,把 N+1 轮前的变更也划进窗口(off-by-one 多注一轮,脏段晚一轮才过期)。
|
||||
let threshold = self
|
||||
.version
|
||||
.current_turn
|
||||
.saturating_sub(recent_turns)
|
||||
.saturating_add(1);
|
||||
let has_active = self.goals.iter().any(|g| g.status == GoalStatus::Active);
|
||||
if has_active && self.version.goals_changed_at >= threshold {
|
||||
let active: Vec<&str> = self.goals.iter()
|
||||
.filter(|g| g.status == GoalStatus::Active)
|
||||
.map(|g| g.text.as_str())
|
||||
.collect();
|
||||
parts.push(format!("目标({}): {}", active.len(), active.join("; ")));
|
||||
}
|
||||
if self.version.decisions_changed_at >= threshold && !self.decisions.is_empty() {
|
||||
let recent: Vec<&str> = self.decisions.iter().map(|d| d.decision.as_str()).collect();
|
||||
parts.push(format!("近期决策: {}", recent.join("; ")));
|
||||
}
|
||||
if self.version.unresolved_changed_at >= threshold && !self.unresolved.is_empty() {
|
||||
parts.push(format!("待解: {}", self.unresolved.join("; ")));
|
||||
}
|
||||
|
||||
parts.join("\n")
|
||||
}
|
||||
|
||||
/// 新用户轮:上一批 Active 目标落 Completed,本条用户请求截断后记为唯一 Active 目标;
|
||||
/// current_turn 自增。自动目标与 G1 pinned_goals(用户手动钉,另链路)互不覆盖。
|
||||
/// 已 Completed 的自动目标无读取方,直接丢弃(只保留 Active),防长会话无限膨胀。
|
||||
pub fn begin_turn(&mut self, user_request: &str, max_chars: usize) {
|
||||
self.version.current_turn += 1;
|
||||
// 自动目标单活跃语义:新请求即替换,旧目标不保留(Cancelled 同理无读取方,一并丢弃)
|
||||
self.goals.clear();
|
||||
let trimmed = user_request.trim();
|
||||
if !trimmed.is_empty() {
|
||||
self.goals.push(GoalItem {
|
||||
id: format!("t{}", self.version.current_turn),
|
||||
text: truncate_chars(trimmed, max_chars),
|
||||
status: GoalStatus::Active,
|
||||
});
|
||||
}
|
||||
self.version.goals_changed_at = self.version.current_turn;
|
||||
}
|
||||
|
||||
/// 压缩后重锚定:历史消息已被摘要替换,从压缩摘要 JSON 卡片(字段 topics/decisions/key_files,
|
||||
/// decisions 元素形如 {"summary": "..."})恢复决策至上限 5 条(每条 decision 文本截 120 字符,
|
||||
/// question/reason 留空);随后调 version.reset_all(json_card, 自增后的 current_turn)。
|
||||
/// Active 目标保留不丢(压缩不应丢目标)。current_turn 自增 1。
|
||||
pub fn sync_after_compress(&mut self, json_card: &str) {
|
||||
self.version.current_turn += 1;
|
||||
if let Ok(card) = serde_json::from_str::<serde_json::Value>(json_card) {
|
||||
let items: Vec<DecisionItem> = card
|
||||
.get("decisions")
|
||||
.and_then(|d| d.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|d| d.get("summary").and_then(|s| s.as_str()))
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.take(5)
|
||||
.map(|s| DecisionItem {
|
||||
question: String::new(),
|
||||
decision: truncate_chars(s, 120),
|
||||
reason: String::new(),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
// 仅当解析出非空决策才覆盖;脏标记由 reset_all 统一置位
|
||||
if !items.is_empty() {
|
||||
self.decisions = items;
|
||||
}
|
||||
} else {
|
||||
// 解析失败不吞:压缩后决策回填静默丢失会让"决策段凭空消失"无从排查,warn 留痕
|
||||
tracing::warn!(
|
||||
"sync_after_compress: 摘要 JSON 卡片解析失败,decisions 未恢复(卡片 {} 字节)",
|
||||
json_card.len()
|
||||
);
|
||||
}
|
||||
self.version.reset_all(json_card, self.version.current_turn);
|
||||
}
|
||||
}
|
||||
|
||||
/// 按 chars 计数截断文本,越界补省略号(begin_turn / sync_after_compress 共用)。
|
||||
/// 语义与正典(src-tauri audit/utils.rs truncate_chars_ellipsis,DRY #11)一致;
|
||||
/// 跨 crate 不引入依赖,保持本地同语义实现(有意保留原名,勿误认遗漏)。
|
||||
fn truncate_chars(s: &str, max: usize) -> String {
|
||||
if s.chars().count() <= max {
|
||||
return s.to_string();
|
||||
}
|
||||
let head: String = s.chars().take(max).collect();
|
||||
format!("{}…", head)
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -590,6 +1015,66 @@ mod tests {
|
||||
// token 频次 1 也应入榜(去停用词后剩余的有意义词)。
|
||||
}
|
||||
|
||||
// ── 视图裁剪淘汰补偿摘要纯函数(build_eviction_summary_text)测试 ──
|
||||
|
||||
#[test]
|
||||
fn eviction_summary_short_text_returns_none() {
|
||||
// 合计 < 100 字符 → 不值得产摘要。
|
||||
let msgs = vec![ChatMessage::user("短文本"), ChatMessage::assistant("另一条")];
|
||||
assert_eq!(build_eviction_summary_text(&msgs), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eviction_summary_no_user_returns_none() {
|
||||
// 无 user 消息 → 提炼核心不认 assistant → 无关键词 → None(即使合计超 100 字符)。
|
||||
let long = "这是一段没有用户主题的 assistant 长文本,重复三次以超过一百字符的最短闸门验证。".repeat(3);
|
||||
assert!(long.chars().count() > 100);
|
||||
let msgs = vec![ChatMessage::assistant(&long), ChatMessage::assistant(&long)];
|
||||
assert_eq!(build_eviction_summary_text(&msgs), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eviction_summary_formats_prefix_and_keyword() {
|
||||
// 合计须过 100 字符闸门:user 文本重复 10 次(~70 字符)+ assistant(~37 字符)。
|
||||
let user_text = "审批流程配置 ".repeat(10);
|
||||
let msgs = vec![
|
||||
ChatMessage::user(user_text.trim()),
|
||||
ChatMessage::assistant("好的,已了解审批流程诉求,这里是一段足够长的回复用于通过最短字符闸门检查。"),
|
||||
];
|
||||
let text = build_eviction_summary_text(&msgs).expect("应产出摘要");
|
||||
assert!(text.starts_with("[早期对话概要] 高频主题: "), "实际 {}", text);
|
||||
assert!(text.contains("审批"), "应含高频词, 实际 {}", text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eviction_summary_tool_result_takes_first_line() {
|
||||
// tool 结果取首行:多行 JSON 不整体进提炼,首行计入闸门字符数,摘要仍可产出。
|
||||
// user 文本重复 3 次(~110 字符)+ tool 首行(~24 字符)合计过 100 闸门。
|
||||
let tool = ChatMessage::tool_result("tc1", "{\"result\":\"第一行头部\"}\n第二行\n第三行");
|
||||
let user_text = "预算闸门验证消息,这条 user 消息携带足够字符数确保超过下限触发提炼。".repeat(3);
|
||||
let text = build_eviction_summary_text(&[ChatMessage::user(&user_text), tool])
|
||||
.expect("应产出摘要");
|
||||
assert!(text.starts_with("[早期对话概要] 高频主题: "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eviction_summary_hard_capped_at_max_chars() {
|
||||
// 12 条纯标识符消息(每个标识符 39 字符,freq 均等且无中文干扰)→ top-10 全为长词,
|
||||
// 总长超 300 → 硬截断到 EVICTION_SUMMARY_MAX_CHARS。
|
||||
let msgs: Vec<ChatMessage> = (0..12)
|
||||
.map(|i| {
|
||||
ChatMessage::user(&format!("long_identifier_{:02}_for_truncation_probe", i))
|
||||
})
|
||||
.collect();
|
||||
let text = build_eviction_summary_text(&msgs).expect("应产出摘要");
|
||||
assert_eq!(
|
||||
text.chars().count(),
|
||||
EVICTION_SUMMARY_MAX_CHARS,
|
||||
"超长时应精确截断到硬上限, 实际 {}",
|
||||
text.chars().count()
|
||||
);
|
||||
}
|
||||
|
||||
// ── CR-26 🟡1: tokenize 2-gram 汉字(中文 2 字词不再被滤掉) ──
|
||||
|
||||
#[test]
|
||||
@@ -877,18 +1362,37 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_key_info_single_line_no_newline_unchanged() {
|
||||
// 边界(无换行):单行(无 \n)→ lines() 返 1 行,total <= kept_boundary → 原样返回
|
||||
fn extract_key_info_single_line_short_no_newline_unchanged() {
|
||||
// 边界(无换行):单行短内容(字符数 <= CHAR_LIMIT)→ 原样返回
|
||||
let content = "single line no newline";
|
||||
assert_eq!(extract_key_info(content, "read_file"), content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_key_info_single_huge_line_no_newline_unchanged() {
|
||||
// 极端(单行 50KB 无换行):lines() 返 1 行 → 原样返回(不走首尾切分)
|
||||
fn extract_key_info_single_huge_line_no_newline_compressed() {
|
||||
// 单行超大内容(50KB)原本逃逸压缩,现按字符数截断保留头尾。
|
||||
// 注:read_file 已豁免压缩,此处用 run_command 验证通用压缩路径。
|
||||
let content = "x".repeat(50_000);
|
||||
let result = extract_key_info(&content, "read_file");
|
||||
assert_eq!(result, content, "单行无换行应原样返回(即使超长)");
|
||||
let result = extract_key_info(&content, "run_command");
|
||||
assert!(result.len() < content.len(), "单行超长应压缩: {} >= {}", result.len(), content.len());
|
||||
assert!(result.contains("已压缩"), "应含压缩标记");
|
||||
assert!(result.starts_with("[工具 run_command"), "应以工具名开头");
|
||||
assert!(result.contains("原始 50000 字符"), "应报告原始字符数");
|
||||
assert!(result.contains("(截断)"), "应含截断标记");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_key_info_json_huge_string_field_truncated() {
|
||||
// JSON 对象中大字符串字段(单行少行)逃逸压缩。
|
||||
// 如 `{"path":"src/main.rs","content":"单行超大文本..."}`。
|
||||
// 注:read_file 已豁免压缩,此处用 run_command 验证通用压缩路径。
|
||||
let large = "z".repeat(10_000);
|
||||
let content = format!("{{\"path\":\"src/main.rs\",\"content\":\"{}\"}}", large);
|
||||
let result = extract_key_info(&content, "run_command");
|
||||
assert!(result.len() < content.len(), "JSON 大字符串字段应压缩: {} >= {}", result.len(), content.len());
|
||||
assert!(result.contains("_truncated"), "应含 _truncated 标记");
|
||||
assert!(result.contains("src/main.rs"), "应保留 path 字段");
|
||||
assert!(result.contains("(截断)"), "应含截断标记");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -903,9 +1407,10 @@ mod tests {
|
||||
#[test]
|
||||
fn extract_key_info_eleven_lines_triggers_compression() {
|
||||
// 边界:行数 == 11(刚超 kept_boundary=10)→ 触发压缩,含标记
|
||||
// 注:read_file 已豁免压缩,此处用 run_command 验证通用压缩路径。
|
||||
let lines: Vec<String> = (1..=11).map(|i| format!("line {}", i)).collect();
|
||||
let content = lines.join("\n");
|
||||
let result = extract_key_info(&content, "read_file");
|
||||
let result = extract_key_info(&content, "run_command");
|
||||
assert!(result.contains("已压缩"), "11 行应触发压缩");
|
||||
assert!(result.contains("line 1"), "保留首行");
|
||||
assert!(result.contains("line 11"), "保留末行");
|
||||
@@ -916,6 +1421,7 @@ mod tests {
|
||||
// 边界:错误行恰在头部区间内(idx < head_end)→ 不重复插入(头部已含)
|
||||
// 错误行在尾部区间内(idx >= tail_start)→ 不重复插入(尾部已含)
|
||||
// 错误行在中间区间 → 标注 [行 N] 插入
|
||||
// 注:read_file 已豁免压缩,此处用 run_command 验证通用压缩路径。
|
||||
let mut lines: Vec<String> = (1..=20).map(|i| format!("norm {}", i)).collect();
|
||||
// idx=2(头部区间 [0,5))错误行 → 头部已含,不在 error_lines(扫描跳过 head/tail)
|
||||
lines[2] = "error in head zone".to_string();
|
||||
@@ -924,7 +1430,7 @@ mod tests {
|
||||
// idx=10(中间)错误行 → 标注插入
|
||||
lines[10] = "error in middle".to_string();
|
||||
let content = lines.join("\n");
|
||||
let result = extract_key_info(&content, "read_file");
|
||||
let result = extract_key_info(&content, "run_command");
|
||||
// 中间错误行被标注插入(原始行号 11)
|
||||
assert!(result.contains("[行 11] error in middle"), "中间错误行应标注插入: {}", result);
|
||||
// 头/尾错误行原样保留(无 [行 N] 标注)
|
||||
@@ -993,7 +1499,7 @@ mod tests {
|
||||
assert!(toks.contains(&"now".to_string()));
|
||||
}
|
||||
|
||||
// ── 阶段2 占位配对完整性:is_pending_placeholder / extract_pending_tc_id ──
|
||||
// ── 占位配对完整性:is_pending_placeholder / extract_pending_tc_id ──
|
||||
|
||||
#[test]
|
||||
fn is_pending_placeholder_new_with_marker() {
|
||||
@@ -1076,4 +1582,181 @@ mod tests {
|
||||
assert_eq!(extract_pending_tc_id("文件内容"), None);
|
||||
assert_eq!(extract_pending_tc_id(""), None);
|
||||
}
|
||||
|
||||
// ── read_file 豁免压缩AI 定向读代码不应被折叠) ──
|
||||
//
|
||||
// 根因:extract_key_info 的 JSON 分支对 content 字段(文件内容)行数 > 10 即折叠中间为
|
||||
// "(压缩中间内容)"。read_file limit=100 读 28KB(100 行)→ 触发 → AI 只拿到首尾各 5 行,
|
||||
// 代码分析被阉割。修法:tool_name=="read_file" 一律原样返回(豁免)。
|
||||
// read_file handler 自带 limit 硬上限 2000 行(file.rs),无爆 prompt 风险。
|
||||
|
||||
#[test]
|
||||
fn extract_key_info_read_file_exempt_from_compression() {
|
||||
// read_file 返回 JSON,content 字段 100 行(>10 行阈值)→ 其他工具会折叠,
|
||||
// read_file 应豁免:原样返回,无 "(压缩中间内容)" / "_truncated"。
|
||||
let content_str: String = (1..=100).map(|i| format!("line {}", i)).collect::<Vec<_>>().join("\n");
|
||||
// 模拟 read_file 真实返回结构
|
||||
let content = serde_json::json!({
|
||||
"path": "/some/file.rs",
|
||||
"content": content_str,
|
||||
"lines": 100,
|
||||
"returned_lines": 100
|
||||
})
|
||||
.to_string();
|
||||
let result = extract_key_info(&content, "read_file");
|
||||
assert_eq!(result, content, "read_file 大结果应原样返回(豁免压缩)");
|
||||
assert!(!result.contains("压缩中间内容"), "read_file 不应折叠中间");
|
||||
assert!(!result.contains("_truncated"), "read_file 不应打 _truncated 标记");
|
||||
// content 字段 100 行完整保留
|
||||
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed["content"].as_str().unwrap().lines().count(), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_key_info_other_tools_still_compressed_when_large() {
|
||||
// 对照:同样 100 行 content,run_command 应仍被压缩(中间折叠)。
|
||||
let content_str: Vec<String> = (1..=100).map(|i| format!("line {}", i)).collect();
|
||||
let content = serde_json::json!({
|
||||
"stdout": content_str.join("\n"),
|
||||
"exit_code": 0
|
||||
})
|
||||
.to_string();
|
||||
let result = extract_key_info(&content, "run_command");
|
||||
assert!(
|
||||
result.contains("压缩中间内容"),
|
||||
"run_command 大结果仍应压缩: {}",
|
||||
result
|
||||
);
|
||||
assert!(result.contains("_truncated"), "run_command 应打 _truncated 标记");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_key_info_read_file_short_content_unchanged() {
|
||||
// read_file 小结果同样原样(豁免对大小一致生效)。
|
||||
let content = serde_json::json!({
|
||||
"path": "/x.rs",
|
||||
"content": "only one line",
|
||||
"lines": 1
|
||||
})
|
||||
.to_string();
|
||||
let result = extract_key_info(&content, "read_file");
|
||||
assert_eq!(result, content, "read_file 小结果原样返回");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_key_info_read_file_non_json_plaintext_exempt() {
|
||||
// read_file 纯文本大结果(理论上 read_file 总返回 JSON,但兜底:非 JSON 也豁免)。
|
||||
let lines: Vec<String> = (1..=50).map(|i| format!("line {}", i)).collect();
|
||||
let content = lines.join("\n");
|
||||
let result = extract_key_info(&content, "read_file");
|
||||
assert_eq!(result, content, "read_file 纯文本应豁免压缩");
|
||||
assert!(!result.contains("输出已压缩"), "read_file 纯文本不应压缩");
|
||||
}
|
||||
|
||||
// ── WorkingContext 写入侧(begin_turn / sync_after_compress)──
|
||||
|
||||
#[test]
|
||||
fn working_context_begin_turn_replaces_previous_goal() {
|
||||
let mut ctx = WorkingContext::new();
|
||||
ctx.begin_turn("第一个请求", 100);
|
||||
ctx.begin_turn("第二个请求", 100);
|
||||
assert_eq!(ctx.version.current_turn, 2, "current_turn 应自增两次");
|
||||
assert_eq!(ctx.goals.len(), 1, "替换语义:仅剩本条 Active 目标");
|
||||
assert_eq!(ctx.goals[0].status, GoalStatus::Active);
|
||||
assert_eq!(ctx.goals[0].text, "第二个请求");
|
||||
assert_eq!(ctx.goals[0].id, "t2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn working_context_begin_turn_empty_request_only_bumps_turn() {
|
||||
let mut ctx = WorkingContext::new();
|
||||
ctx.begin_turn(" ", 100);
|
||||
assert_eq!(ctx.version.current_turn, 1, "空文本仍应 bump turn");
|
||||
assert!(ctx.goals.is_empty(), "空文本不 push 目标");
|
||||
assert_eq!(ctx.version.goals_changed_at, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn working_context_begin_turn_truncates_long_request() {
|
||||
let mut ctx = WorkingContext::new();
|
||||
let long = "长".repeat(50);
|
||||
ctx.begin_turn(&long, 10);
|
||||
assert_eq!(ctx.goals[0].text.chars().count(), 11, "10 字符 + 省略号");
|
||||
assert!(ctx.goals[0].text.ends_with('…'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn working_context_sync_after_compress_restores_decisions() {
|
||||
let mut ctx = WorkingContext::new();
|
||||
ctx.begin_turn("压缩前目标", 100);
|
||||
let card = r#"{"topics":["a"],"decisions":[{"summary":"采用块级 memo 渲染"},{"summary":"降级兜底"}],"key_files":[]}"#;
|
||||
ctx.sync_after_compress(card);
|
||||
assert_eq!(ctx.decisions.len(), 2);
|
||||
assert_eq!(ctx.decisions[0].decision, "采用块级 memo 渲染");
|
||||
assert!(ctx.decisions[0].question.is_empty(), "question 留空");
|
||||
assert!(ctx.decisions[0].reason.is_empty(), "reason 留空");
|
||||
assert_eq!(ctx.goals.len(), 1, "Active 目标保留不丢");
|
||||
assert_eq!(ctx.goals[0].status, GoalStatus::Active);
|
||||
let out = ctx.build_injection(3);
|
||||
assert!(out.contains("目标"), "应含目标段: {}", out);
|
||||
assert!(out.contains("近期决策"), "应含近期决策段: {}", out);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn working_context_sync_after_compress_invalid_json_still_reanchors() {
|
||||
let mut ctx = WorkingContext::new();
|
||||
ctx.begin_turn("压缩前目标", 100);
|
||||
ctx.sync_after_compress("不是 JSON");
|
||||
assert!(ctx.decisions.is_empty(), "非法 JSON 不恢复决策且不 panic");
|
||||
let out = ctx.build_injection(3);
|
||||
assert!(out.contains("目标"), "重锚定后仍应注入目标段: {}", out);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn working_context_injection_decays_after_window() {
|
||||
let mut ctx = WorkingContext::new();
|
||||
ctx.begin_turn("会过期的目标", 100);
|
||||
assert!(ctx.build_injection(3).contains("会过期的目标"), "窗口内应注入");
|
||||
// 模拟多轮未再变更:直接推进 current_turn,脏窗口(3)过期后不再注入
|
||||
ctx.version.current_turn = 10;
|
||||
let out = ctx.build_injection(3);
|
||||
assert!(out.is_empty(), "过期后所有段均不注入, 实际: {}", out);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn working_context_injection_window_is_exactly_n_turns() {
|
||||
// 边界:脏窗口 = 最近 N 轮。turn=1 的变更、窗口 3:
|
||||
// current_turn=3 → 第 1 轮是第 3 轮前,恰在窗内(1,2,3 共 3 轮),注入;
|
||||
// current_turn=4 → 第 1 轮是第 4 轮前,出窗不注入(off-by-one bug 下会误注)。
|
||||
let mut ctx = WorkingContext::new();
|
||||
ctx.begin_turn("窗口边界目标", 100); // current_turn=1, goals_changed_at=1
|
||||
ctx.version.current_turn = 3;
|
||||
assert!(
|
||||
ctx.build_injection(3).contains("窗口边界目标"),
|
||||
"第 N 轮前的变更恰在窗内,应注入"
|
||||
);
|
||||
ctx.version.current_turn = 4;
|
||||
assert!(
|
||||
ctx.build_injection(3).is_empty(),
|
||||
"第 N+1 轮前的变更应出窗,不注入"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_image_token_floor_survives_ratio_discount() {
|
||||
// url 图地板语义是"每图至少 ~1600 token",必须生效于 ×chars_ratio 折扣之后:
|
||||
// 若地板折进 char_count 再乘 0.35,1600 被稀释成 ~560,保底失效 → 预算裁剪漏触发。
|
||||
let est = TokenEstimator::default();
|
||||
let msg = ChatMessage::user_parts(
|
||||
"t",
|
||||
vec![crate::provider::ContentPart::image_url("https://example.com/x.png")],
|
||||
);
|
||||
let tokens = est.estimate_message(&msg);
|
||||
assert!(
|
||||
tokens >= URL_IMAGE_TOKEN_FLOOR as u32,
|
||||
"url 图消息 token({}) 应 >= 地板 {},稀释 bug 下仅 ~565",
|
||||
tokens,
|
||||
URL_IMAGE_TOKEN_FLOOR
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+1814
-18
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,640 @@
|
||||
//! Git worktree 生命周期管理 — 多 Agent 并行执行的文件隔离基础
|
||||
//!
|
||||
//! 设计依据:docs/02-架构设计/专项设计/多Agent并行执行与仲裁合并设计-2026-07-01.md §4.2
|
||||
//!
|
||||
//! 每个 SubTask 创建独立 worktree(git worktree add + 新分支),
|
||||
//! 写操作天然隔离。合并时用 git merge-tree 预检 + git merge 实际合并。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
/// worktree 管理错误
|
||||
#[derive(Debug)]
|
||||
pub enum WorktreeError {
|
||||
/// Git 命令执行失败(stdout/stderr 含错误信息)
|
||||
GitFailed(String),
|
||||
/// 工程根目录无 .git(非 Git 工程,应降级串行)
|
||||
NotGitRepo,
|
||||
/// worktree 目录已存在(可能上次未清理)
|
||||
AlreadyExists(PathBuf),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for WorktreeError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::GitFailed(msg) => write!(f, "Git 命令失败: {}", msg),
|
||||
Self::NotGitRepo => write!(f, "非 Git 工程(无 .git 目录)"),
|
||||
Self::AlreadyExists(p) => write!(f, "worktree 目录已存在: {}", p.display()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for WorktreeError {}
|
||||
|
||||
/// worktree 创建结果
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WorktreeHandle {
|
||||
/// worktree 物理路径(SubTask 工具操作的 cwd)
|
||||
pub path: PathBuf,
|
||||
/// worktree 对应的分支名(merge 时用)
|
||||
pub branch: String,
|
||||
/// 所属 Plan id(清理时按 plan 批量)
|
||||
pub plan_id: String,
|
||||
/// SubTask id
|
||||
pub subtask_id: String,
|
||||
}
|
||||
|
||||
/// 在指定工程根目录下创建 SubTask 专属 worktree。
|
||||
///
|
||||
/// - `project_root`: 工程根目录(含 .git)
|
||||
/// - `plan_id`: 所属 Plan id
|
||||
/// - `subtask_id`: SubTask id
|
||||
/// - `base_branch`: fork 基点分支(plan 分支或主分支)
|
||||
///
|
||||
/// 返回 WorktreeHandle(path + branch)。
|
||||
/// worktree 路径:`{project_root}/.devflow/wt/{plan_id}/{subtask_id}`
|
||||
/// 分支名:`subtask/{plan_id}/{subtask_id}`
|
||||
pub fn create_worktree(
|
||||
project_root: &Path,
|
||||
plan_id: &str,
|
||||
subtask_id: &str,
|
||||
base_branch: &str,
|
||||
) -> Result<WorktreeHandle, WorktreeError> {
|
||||
if !project_root.join(".git").exists() {
|
||||
return Err(WorktreeError::NotGitRepo);
|
||||
}
|
||||
|
||||
let wt_path = project_root.join(".devflow/wt").join(plan_id).join(subtask_id);
|
||||
if wt_path.exists() {
|
||||
return Err(WorktreeError::AlreadyExists(wt_path));
|
||||
}
|
||||
|
||||
let branch = format!("subtask/{}/{}", plan_id, subtask_id);
|
||||
|
||||
// git worktree add <path> -b <branch> <base_branch>
|
||||
let output = Command::new("git")
|
||||
.args([
|
||||
"worktree",
|
||||
"add",
|
||||
wt_path.to_str().unwrap_or(""),
|
||||
"-b",
|
||||
&branch,
|
||||
base_branch,
|
||||
])
|
||||
.current_dir(project_root)
|
||||
.output()
|
||||
.map_err(|e| WorktreeError::GitFailed(format!("git worktree add 执行失败: {}", e)))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(WorktreeError::GitFailed(format!(
|
||||
"git worktree add 失败: {}",
|
||||
stderr.trim()
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(WorktreeHandle {
|
||||
path: wt_path,
|
||||
branch,
|
||||
plan_id: plan_id.to_string(),
|
||||
subtask_id: subtask_id.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 在 worktree 内提交所有改动(git add -A + git commit)。
|
||||
///
|
||||
/// - `wt_path`: worktree 物理路径
|
||||
/// - `message`: commit 消息
|
||||
pub fn commit_worktree(wt_path: &Path, message: &str) -> Result<(), WorktreeError> {
|
||||
// git add -A
|
||||
let add = Command::new("git")
|
||||
.args(["add", "-A"])
|
||||
.current_dir(wt_path)
|
||||
.output()
|
||||
.map_err(|e| WorktreeError::GitFailed(format!("git add 失败: {}", e)))?;
|
||||
if !add.status.success() {
|
||||
return Err(WorktreeError::GitFailed(format!(
|
||||
"git add 失败: {}",
|
||||
String::from_utf8_lossy(&add.stderr).trim()
|
||||
)));
|
||||
}
|
||||
|
||||
// git commit -m (允许空提交,--allow-empty 防无改动时报错)
|
||||
let commit = Command::new("git")
|
||||
.args(["commit", "--allow-empty", "-m", message])
|
||||
.current_dir(wt_path)
|
||||
.output()
|
||||
.map_err(|e| WorktreeError::GitFailed(format!("git commit 失败: {}", e)))?;
|
||||
if !commit.status.success() {
|
||||
// commit 失败可能因无 user.email 配置(测试环境),尝试自动配
|
||||
let stderr = String::from_utf8_lossy(&commit.stderr);
|
||||
if stderr.contains("user.email") || stderr.contains("user.name") {
|
||||
// 降级:设置 worktree 局部配置(--local 不污染全局 git config)后重试
|
||||
Command::new("git")
|
||||
.args(["config", "--local", "user.email", "devflow@ai.local"])
|
||||
.current_dir(wt_path)
|
||||
.output()
|
||||
.ok();
|
||||
Command::new("git")
|
||||
.args(["config", "--local", "user.name", "DevFlow Agent"])
|
||||
.current_dir(wt_path)
|
||||
.output()
|
||||
.ok();
|
||||
let retry = Command::new("git")
|
||||
.args(["commit", "--allow-empty", "-m", message])
|
||||
.current_dir(wt_path)
|
||||
.output()
|
||||
.map_err(|e| WorktreeError::GitFailed(format!("git commit 重试失败: {}", e)))?;
|
||||
if !retry.status.success() {
|
||||
return Err(WorktreeError::GitFailed(format!(
|
||||
"git commit 重试仍失败: {}",
|
||||
String::from_utf8_lossy(&retry.stderr).trim()
|
||||
)));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
return Err(WorktreeError::GitFailed(format!("git commit 失败: {}", stderr.trim())));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 将 SubTask 分支 merge 到 plan 分支(在 plan worktree 内执行)。
|
||||
///
|
||||
/// 返回 Ok(true) 表示有改动被合并,Ok(false) 表示无改动(空分支)。
|
||||
/// 返回 Err 表示 merge 冲突。
|
||||
pub fn merge_branch(
|
||||
plan_wt_path: &Path,
|
||||
subtask_branch: &str,
|
||||
) -> Result<MergeResult, WorktreeError> {
|
||||
// 预检用旧版三参形态 merge-tree <共同祖先> <ours> <theirs>
|
||||
// (--write-tree 需 Git 2.38+,为兼容旧 git 不采用)
|
||||
// 第 1 参必须是 merge-base:此前误传 HEAD,branch2(HEAD) 相对 base 零差异,
|
||||
// trivial merge 恒判无冲突(实测真冲突场景输出为空 exit 0),预检形同虚设
|
||||
let mb = Command::new("git")
|
||||
.args(["merge-base", "HEAD", subtask_branch])
|
||||
.current_dir(plan_wt_path)
|
||||
.output()
|
||||
.map_err(|e| WorktreeError::GitFailed(format!("git merge-base 失败: {}", e)))?;
|
||||
|
||||
// 旧式形态冲突时 exit 仍为 0,只能看 stdout 判定;只认 <<<<<<< 标记,
|
||||
// 不查 =======:干净合并的 diff 上下文会带出文件内容,文档里的等号分隔行会误撞
|
||||
if mb.status.success() {
|
||||
let merge_base = String::from_utf8_lossy(&mb.stdout).trim().to_string();
|
||||
let precheck = Command::new("git")
|
||||
.args(["merge-tree", merge_base.as_str(), "HEAD", subtask_branch])
|
||||
.current_dir(plan_wt_path)
|
||||
.output()
|
||||
.map_err(|e| WorktreeError::GitFailed(format!("git merge-tree 失败: {}", e)))?;
|
||||
|
||||
let precheck_stdout = String::from_utf8_lossy(&precheck.stdout);
|
||||
if precheck_stdout.contains("<<<<<<<") {
|
||||
return Ok(MergeResult::Conflict {
|
||||
branch: subtask_branch.to_string(),
|
||||
details: precheck_stdout.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
// merge-base 失败 = 两分支无共同祖先,无法预检,交由下方实际 merge 裁决
|
||||
|
||||
// 无冲突,执行实际 merge
|
||||
let merge = Command::new("git")
|
||||
.args(["merge", "--no-edit", subtask_branch])
|
||||
.current_dir(plan_wt_path)
|
||||
.output()
|
||||
.map_err(|e| WorktreeError::GitFailed(format!("git merge 失败: {}", e)))?;
|
||||
|
||||
if !merge.status.success() {
|
||||
// merge 冲突(git merge 返回非0)
|
||||
let stderr = String::from_utf8_lossy(&merge.stderr);
|
||||
let stdout = String::from_utf8_lossy(&merge.stdout);
|
||||
// abort 部分合并状态(防 worktree 处于 conflicted 状态)
|
||||
let _ = Command::new("git")
|
||||
.args(["merge", "--abort"])
|
||||
.current_dir(plan_wt_path)
|
||||
.output();
|
||||
return Ok(MergeResult::Conflict {
|
||||
branch: subtask_branch.to_string(),
|
||||
details: format!("{}\n{}", stdout, stderr),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(MergeResult::Clean)
|
||||
}
|
||||
|
||||
/// merge 结果
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum MergeResult {
|
||||
/// 干净合并(无冲突)
|
||||
Clean,
|
||||
/// 合并冲突(details 含 conflict 信息)
|
||||
Conflict { branch: String, details: String },
|
||||
}
|
||||
|
||||
/// 清理单个 SubTask 的 worktree(git worktree remove --force + 删分支)。
|
||||
pub fn remove_worktree(project_root: &Path, handle: &WorktreeHandle) -> Result<(), WorktreeError> {
|
||||
// git worktree remove --force <path>
|
||||
let _ = Command::new("git")
|
||||
.args([
|
||||
"worktree",
|
||||
"remove",
|
||||
"--force",
|
||||
handle.path.to_str().unwrap_or(""),
|
||||
])
|
||||
.current_dir(project_root)
|
||||
.output();
|
||||
|
||||
// 删除分支(-D 强制,已 merge 的分支正常删,未 merge 的也删)
|
||||
let _ = Command::new("git")
|
||||
.args(["branch", "-D", &handle.branch])
|
||||
.current_dir(project_root)
|
||||
.output();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 清理某 Plan 的所有 worktree(Plan 完成/失败/取消时批量清理)。
|
||||
pub fn remove_plan_worktrees(project_root: &Path, plan_id: &str) -> Result<(), WorktreeError> {
|
||||
let plan_wt_dir = project_root.join(".devflow/wt").join(plan_id);
|
||||
if !plan_wt_dir.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// git worktree list 获取该 plan 下所有 worktree
|
||||
let list = Command::new("git")
|
||||
.args(["worktree", "list", "--porcelain"])
|
||||
.current_dir(project_root)
|
||||
.output()
|
||||
.map_err(|e| WorktreeError::GitFailed(format!("git worktree list 失败: {}", e)))?;
|
||||
|
||||
let list_str = String::from_utf8_lossy(&list.stdout);
|
||||
for line in list_str.lines() {
|
||||
if let Some(path_str) = line.strip_prefix("worktree ") {
|
||||
let path = PathBuf::from(path_str);
|
||||
// 仅清理属于本 plan 的 worktree
|
||||
if path.starts_with(&plan_wt_dir) {
|
||||
let _ = Command::new("git")
|
||||
.args(["worktree", "remove", "--force", path_str])
|
||||
.current_dir(project_root)
|
||||
.output();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 删除该 plan 的所有子任务分支
|
||||
let branches = Command::new("git")
|
||||
.args(["branch", "--list", &format!("subtask/{}/*", plan_id)])
|
||||
.current_dir(project_root)
|
||||
.output()
|
||||
.map_err(|e| WorktreeError::GitFailed(format!("git branch --list 失败: {}", e)))?;
|
||||
|
||||
let branches_str = String::from_utf8_lossy(&branches.stdout);
|
||||
for line in branches_str.lines() {
|
||||
let branch = line.trim();
|
||||
if !branch.is_empty() {
|
||||
let _ = Command::new("git")
|
||||
.args(["branch", "-D", branch])
|
||||
.current_dir(project_root)
|
||||
.output();
|
||||
}
|
||||
}
|
||||
|
||||
// 清理空目录
|
||||
let _ = std::fs::remove_dir_all(&plan_wt_dir);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 检测工程是否为 Git 仓库(有 .git 目录)。
|
||||
pub fn is_git_repo(project_root: &Path) -> bool {
|
||||
project_root.join(".git").exists()
|
||||
}
|
||||
|
||||
/// 获取工程当前分支名(用于作为 plan worktree 的 fork 基点)。
|
||||
pub fn current_branch(project_root: &Path) -> Result<String, WorktreeError> {
|
||||
let output = Command::new("git")
|
||||
.args(["rev-parse", "--abbrev-ref", "HEAD"])
|
||||
.current_dir(project_root)
|
||||
.output()
|
||||
.map_err(|e| WorktreeError::GitFailed(format!("git rev-parse 失败: {}", e)))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(WorktreeError::GitFailed(
|
||||
String::from_utf8_lossy(&output.stderr).trim().to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
/// 创建 Plan worktree(Plan 级工作分支,所有 SubTask 分支 merge 到这里)。
|
||||
///
|
||||
/// 返回 (plan_worktree_path, plan_branch)。
|
||||
pub fn create_plan_worktree(
|
||||
project_root: &Path,
|
||||
plan_id: &str,
|
||||
) -> Result<(PathBuf, String), WorktreeError> {
|
||||
if !is_git_repo(project_root) {
|
||||
return Err(WorktreeError::NotGitRepo);
|
||||
}
|
||||
|
||||
let base = current_branch(project_root)?;
|
||||
let plan_branch = format!("plan/{}", plan_id);
|
||||
let plan_wt_path = project_root.join(".devflow/wt").join(plan_id);
|
||||
|
||||
if plan_wt_path.exists() {
|
||||
return Err(WorktreeError::AlreadyExists(plan_wt_path));
|
||||
}
|
||||
|
||||
let output = Command::new("git")
|
||||
.args([
|
||||
"worktree",
|
||||
"add",
|
||||
plan_wt_path.to_str().unwrap_or(""),
|
||||
"-b",
|
||||
&plan_branch,
|
||||
&base,
|
||||
])
|
||||
.current_dir(project_root)
|
||||
.output()
|
||||
.map_err(|e| WorktreeError::GitFailed(format!("git worktree add(plan) 失败: {}", e)))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(WorktreeError::GitFailed(format!(
|
||||
"git worktree add(plan) 失败: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
)));
|
||||
}
|
||||
|
||||
Ok((plan_wt_path, plan_branch))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 创建临时 Git 仓库 + 初始 commit
|
||||
fn setup_repo() -> tempfile::TempDir {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path();
|
||||
|
||||
Command::new("git").args(["init"]).current_dir(path).output().unwrap();
|
||||
Command::new("git").args(["config", "user.email", "test@test.com"]).current_dir(path).output().unwrap();
|
||||
Command::new("git").args(["config", "user.name", "test"]).current_dir(path).output().unwrap();
|
||||
|
||||
std::fs::write(path.join("main.rs"), "fn main() {}\n").unwrap();
|
||||
Command::new("git").args(["add", "."]).current_dir(path).output().unwrap();
|
||||
Command::new("git").args(["commit", "-m", "init"]).current_dir(path).output().unwrap();
|
||||
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wt_01_create_and_isolate() {
|
||||
let repo = setup_repo();
|
||||
let root = repo.path();
|
||||
let base = current_branch(root).unwrap();
|
||||
|
||||
let handle = create_worktree(root, "plan-test", "A", &base).unwrap();
|
||||
assert!(handle.path.exists(), "worktree 目录应存在");
|
||||
assert_eq!(handle.branch, "subtask/plan-test/A");
|
||||
|
||||
// 在 worktree 内写文件
|
||||
std::fs::write(handle.path.join("new_a.rs"), "// A 的改动").unwrap();
|
||||
// 主目录不应有此文件
|
||||
assert!(!root.join("new_a.rs").exists(), "主目录不应有 A 的改动");
|
||||
|
||||
// 清理
|
||||
remove_worktree(root, &handle).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wt_02_parallel_isolation() {
|
||||
let repo = setup_repo();
|
||||
let root = repo.path();
|
||||
|
||||
let plan_wt = create_plan_worktree(root, "plan-iso").unwrap();
|
||||
let plan_branch = plan_wt.1;
|
||||
|
||||
let a = create_worktree(root, "plan-iso", "A", &plan_branch).unwrap();
|
||||
let b = create_worktree(root, "plan-iso", "B", &plan_branch).unwrap();
|
||||
|
||||
// A 写 new_a.rs
|
||||
std::fs::write(a.path.join("new_a.rs"), "// A").unwrap();
|
||||
// B 写 new_b.rs
|
||||
std::fs::write(b.path.join("new_b.rs"), "// B").unwrap();
|
||||
|
||||
// A 的 worktree 不应有 B 的文件
|
||||
assert!(!a.path.join("new_b.rs").exists(), "A 不应看到 B 的改动");
|
||||
// B 的 worktree 不应有 A 的文件
|
||||
assert!(!b.path.join("new_a.rs").exists(), "B 不应看到 A 的改动");
|
||||
|
||||
remove_plan_worktrees(root, "plan-iso").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wt_03_commit_in_worktree() {
|
||||
let repo = setup_repo();
|
||||
let root = repo.path();
|
||||
|
||||
let plan_wt = create_plan_worktree(root, "plan-commit").unwrap();
|
||||
let a = create_worktree(root, "plan-commit", "A", &plan_wt.1).unwrap();
|
||||
|
||||
// 写文件 + commit
|
||||
std::fs::write(a.path.join("feature.rs"), "pub fn feature() {}").unwrap();
|
||||
commit_worktree(&a.path, "Add feature").unwrap();
|
||||
|
||||
// 验证分支有新 commit
|
||||
let log = Command::new("git")
|
||||
.args(["log", "--oneline", "-1"])
|
||||
.current_dir(&a.path)
|
||||
.output()
|
||||
.unwrap();
|
||||
let log_str = String::from_utf8_lossy(&log.stdout);
|
||||
assert!(log_str.contains("Add feature"), "commit 消息应在 log 中");
|
||||
|
||||
remove_plan_worktrees(root, "plan-commit").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wt_04_merge_no_conflict() {
|
||||
let repo = setup_repo();
|
||||
let root = repo.path();
|
||||
|
||||
let plan_wt = create_plan_worktree(root, "plan-merge").unwrap();
|
||||
let plan_wt_path = plan_wt.0.clone();
|
||||
let plan_branch = plan_wt.1;
|
||||
|
||||
// A 改 file_a.rs, B 改 file_b.rs(不同文件,无冲突)
|
||||
let a = create_worktree(root, "plan-merge", "A", &plan_branch).unwrap();
|
||||
std::fs::write(a.path.join("file_a.rs"), "// A").unwrap();
|
||||
commit_worktree(&a.path, "A changes").unwrap();
|
||||
|
||||
let b = create_worktree(root, "plan-merge", "B", &plan_branch).unwrap();
|
||||
std::fs::write(b.path.join("file_b.rs"), "// B").unwrap();
|
||||
commit_worktree(&b.path, "B changes").unwrap();
|
||||
|
||||
// merge A → plan
|
||||
let result_a = merge_branch(&plan_wt_path, &a.branch).unwrap();
|
||||
assert!(matches!(result_a, MergeResult::Clean), "A merge 应无冲突");
|
||||
|
||||
// merge B → plan
|
||||
let result_b = merge_branch(&plan_wt_path, &b.branch).unwrap();
|
||||
assert!(matches!(result_b, MergeResult::Clean), "B merge 应无冲突");
|
||||
|
||||
// plan worktree 应同时有 file_a.rs 和 file_b.rs
|
||||
assert!(plan_wt_path.join("file_a.rs").exists(), "plan 应含 A 的改动");
|
||||
assert!(plan_wt_path.join("file_b.rs").exists(), "plan 应含 B 的改动");
|
||||
|
||||
remove_plan_worktrees(root, "plan-merge").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wt_05_merge_conflict_same_file() {
|
||||
let repo = setup_repo();
|
||||
let root = repo.path();
|
||||
|
||||
// 写一个共享文件
|
||||
std::fs::write(root.join("shared.rs"), "line1\nline2\n").unwrap();
|
||||
Command::new("git").args(["add", "-A"]).current_dir(root).output().unwrap();
|
||||
Command::new("git").args(["commit", "-m", "add shared"]).current_dir(root).output().unwrap();
|
||||
|
||||
let plan_wt = create_plan_worktree(root, "plan-conf").unwrap();
|
||||
let plan_wt_path = plan_wt.0.clone();
|
||||
let plan_branch = plan_wt.1;
|
||||
|
||||
// A 和 B 改同一文件的同一行
|
||||
let a = create_worktree(root, "plan-conf", "A", &plan_branch).unwrap();
|
||||
std::fs::write(a.path.join("shared.rs"), "A_version\n").unwrap();
|
||||
commit_worktree(&a.path, "A change shared").unwrap();
|
||||
|
||||
let b = create_worktree(root, "plan-conf", "B", &plan_branch).unwrap();
|
||||
std::fs::write(b.path.join("shared.rs"), "B_version\n").unwrap();
|
||||
commit_worktree(&b.path, "B change shared").unwrap();
|
||||
|
||||
// merge A → 无冲突
|
||||
let result_a = merge_branch(&plan_wt_path, &a.branch).unwrap();
|
||||
assert!(matches!(result_a, MergeResult::Clean), "首个 merge 应无冲突");
|
||||
|
||||
// merge B → 冲突
|
||||
let result_b = merge_branch(&plan_wt_path, &b.branch).unwrap();
|
||||
assert!(
|
||||
matches!(result_b, MergeResult::Conflict { .. }),
|
||||
"同文件同行改应产生冲突"
|
||||
);
|
||||
|
||||
remove_plan_worktrees(root, "plan-conf").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wt_06_non_git_returns_error() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// 不 init git
|
||||
let result = create_plan_worktree(dir.path(), "plan-ng");
|
||||
assert!(matches!(result, Err(WorktreeError::NotGitRepo)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wt_07_cleanup_removes_worktree() {
|
||||
let repo = setup_repo();
|
||||
let root = repo.path();
|
||||
|
||||
let plan_wt = create_plan_worktree(root, "plan-clean").unwrap();
|
||||
let a = create_worktree(root, "plan-clean", "A", &plan_wt.1).unwrap();
|
||||
assert!(a.path.exists());
|
||||
|
||||
remove_plan_worktrees(root, "plan-clean").unwrap();
|
||||
|
||||
// worktree 目录应被清理
|
||||
assert!(!a.path.exists(), "worktree 目录应已清理");
|
||||
// plan wt 目录也应被清理
|
||||
assert!(!plan_wt.0.exists(), "plan worktree 目录应已清理");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wt_08_precheck_hits_conflict_before_real_merge() {
|
||||
let repo = setup_repo();
|
||||
let root = repo.path();
|
||||
|
||||
std::fs::write(root.join("shared.rs"), "line1\nline2\n").unwrap();
|
||||
Command::new("git").args(["add", "-A"]).current_dir(root).output().unwrap();
|
||||
Command::new("git").args(["commit", "-m", "add shared"]).current_dir(root).output().unwrap();
|
||||
|
||||
let plan_wt = create_plan_worktree(root, "plan-prechk").unwrap();
|
||||
let plan_wt_path = plan_wt.0.clone();
|
||||
let plan_branch = plan_wt.1;
|
||||
|
||||
// A 和 B 改同一文件同一行
|
||||
let a = create_worktree(root, "plan-prechk", "A", &plan_branch).unwrap();
|
||||
std::fs::write(a.path.join("shared.rs"), "A_version\n").unwrap();
|
||||
commit_worktree(&a.path, "A change shared").unwrap();
|
||||
|
||||
let b = create_worktree(root, "plan-prechk", "B", &plan_branch).unwrap();
|
||||
std::fs::write(b.path.join("shared.rs"), "B_version\n").unwrap();
|
||||
commit_worktree(&b.path, "B change shared").unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
merge_branch(&plan_wt_path, &a.branch).unwrap(),
|
||||
MergeResult::Clean
|
||||
));
|
||||
|
||||
// 冲突必须由 merge-tree 预检命中(details 含标记),而非实际 merge 失败兜底
|
||||
// (兜底 details 来自 merge stderr,只有 CONFLICT 文案无标记)
|
||||
match merge_branch(&plan_wt_path, &b.branch).unwrap() {
|
||||
MergeResult::Conflict { details, .. } => {
|
||||
assert!(
|
||||
details.contains("<<<<<<<"),
|
||||
"冲突详情应含 merge-tree 预检输出的标记,而非 merge 兜底文案: {}",
|
||||
details
|
||||
);
|
||||
}
|
||||
_ => panic!("同文件同行改应冲突"),
|
||||
}
|
||||
|
||||
// 预检命中即返回,不应留下合并中状态
|
||||
let merging = Command::new("git")
|
||||
.args(["rev-parse", "-q", "--verify", "MERGE_HEAD"])
|
||||
.current_dir(&plan_wt_path)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!merging.status.success(), "预检命中不应留下 MERGE_HEAD");
|
||||
|
||||
remove_plan_worktrees(root, "plan-prechk").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wt_09_clean_merge_with_eq_marker_content() {
|
||||
let repo = setup_repo();
|
||||
let root = repo.path();
|
||||
|
||||
// 文件内容含等号分隔行(md 标题下划线常见),A/B 隔行修改,实际可干净合并
|
||||
std::fs::write(root.join("doc.md"), "t0\n=======\nl3\nl4\nl5\n").unwrap();
|
||||
Command::new("git").args(["add", "-A"]).current_dir(root).output().unwrap();
|
||||
Command::new("git").args(["commit", "-m", "add doc"]).current_dir(root).output().unwrap();
|
||||
|
||||
let plan_wt = create_plan_worktree(root, "plan-eqmark").unwrap();
|
||||
let plan_wt_path = plan_wt.0.clone();
|
||||
let plan_branch = plan_wt.1;
|
||||
|
||||
let a = create_worktree(root, "plan-eqmark", "A", &plan_branch).unwrap();
|
||||
std::fs::write(a.path.join("doc.md"), "A0\n=======\nl3\nl4\nl5\n").unwrap();
|
||||
commit_worktree(&a.path, "A chg line1").unwrap();
|
||||
|
||||
let b = create_worktree(root, "plan-eqmark", "B", &plan_branch).unwrap();
|
||||
std::fs::write(b.path.join("doc.md"), "t0\n=======\nl3\nl4\nB5\n").unwrap();
|
||||
commit_worktree(&b.path, "B chg line5").unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
merge_branch(&plan_wt_path, &a.branch).unwrap(),
|
||||
MergeResult::Clean
|
||||
));
|
||||
|
||||
// B 隔行修改应 Clean:预检不得因 diff 上下文中的等号行误判冲突
|
||||
assert!(matches!(
|
||||
merge_branch(&plan_wt_path, &b.branch).unwrap(),
|
||||
MergeResult::Clean
|
||||
));
|
||||
|
||||
remove_plan_worktrees(root, "plan-eqmark").unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
//! 本地图片引用展开(#958 图片入站引用化)
|
||||
//!
|
||||
//! 消息持久化只存引用(ContentPart::Image.url = `file://<绝对路径>`,base64 空),
|
||||
//! 发送请求前在此统一展开为 base64 内嵌——存储面瘦 referencing(一张 5MB 图不再以
|
||||
//! 6.7MB base64 字符串进 SQLite 随历史全量重放),转换面按需读盘(SSD 毫秒级,
|
||||
//! 对比 base64 内嵌的每请求全量携带,磁盘读便宜得多)。
|
||||
//!
|
||||
//! 展开点是 provider 转换层的入口:主会话/子代理/标题/压缩等全部请求路径在此
|
||||
//! 单点覆盖,调用方(飞书入站/桌面粘贴图)只管落盘产引用,不关心 provider 差异。
|
||||
//!
|
||||
//! 失败语义:引用文件丢失/不可读时降级为 Text 片「[图片文件缺失: path]」——
|
||||
//! 诚实告知模型图不可用,不让请求 400(空 base64 会被两个协议端点拒绝)。
|
||||
|
||||
use crate::provider::ContentPart;
|
||||
use base64::Engine as _;
|
||||
|
||||
/// `file://` 引用前缀。内部引用标识(两端自洽即可,不进端点):落盘方拼
|
||||
/// `file://<原生绝对路径>`,展开方 strip 后按原生路径读(Windows 盘符路径直接可用)。
|
||||
pub const LOCAL_FILE_SCHEME: &str = "file://";
|
||||
|
||||
/// 判定 Image 片是否为本地引用(base64 空 + url 带本地 scheme)
|
||||
fn is_local_ref(url: Option<&str>, base64: Option<&str>) -> bool {
|
||||
base64.is_none() && url.is_some_and(|u| u.starts_with(LOCAL_FILE_SCHEME))
|
||||
}
|
||||
|
||||
/// 把消息内的本地图片引用展开为 base64 内嵌片;非本地片原样透传。
|
||||
/// 文件缺失/不可读时该 Image 片降级为 Text 片(请求不炸,模型得到可读的缺失说明)。
|
||||
pub fn inline_local_image_parts(parts: Vec<ContentPart>) -> Vec<ContentPart> {
|
||||
parts.into_iter().map(inline_local_image_part).collect()
|
||||
}
|
||||
|
||||
/// 单片版(provider 转换臂逐片 map 用)
|
||||
pub fn inline_local_image_part(p: ContentPart) -> ContentPart {
|
||||
match p {
|
||||
ContentPart::Image { url, base64, media_type, alt }
|
||||
if is_local_ref(url.as_deref(), base64.as_deref()) =>
|
||||
{
|
||||
let path = url.unwrap();
|
||||
match std::fs::read(&path[LOCAL_FILE_SCHEME.len()..]) {
|
||||
Ok(bytes) => ContentPart::Image {
|
||||
url: None,
|
||||
base64: Some(base64::engine::general_purpose::STANDARD.encode(bytes)),
|
||||
media_type,
|
||||
alt,
|
||||
},
|
||||
Err(_) => ContentPart::Text {
|
||||
text: format!("[图片文件缺失: {}]", &path[LOCAL_FILE_SCHEME.len()..]),
|
||||
},
|
||||
}
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn local_ref_expands_to_base64() {
|
||||
let dir = std::env::temp_dir().join("df-ai-image-ref-test");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("expands.png");
|
||||
std::fs::write(&path, b"\x89PNG fake-bytes").unwrap();
|
||||
|
||||
let out = inline_local_image_parts(vec![ContentPart::Image {
|
||||
url: Some(format!("file://{}", path.display())),
|
||||
base64: None,
|
||||
media_type: Some("image/png".into()),
|
||||
alt: None,
|
||||
}]);
|
||||
match &out[0] {
|
||||
ContentPart::Image { url, base64, media_type, .. } => {
|
||||
assert!(url.is_none());
|
||||
let b = base64.as_deref().expect("base64 应已填充");
|
||||
assert_eq!(b, base64::engine::general_purpose::STANDARD.encode(b"\x89PNG fake-bytes"));
|
||||
assert_eq!(media_type.as_deref(), Some("image/png"));
|
||||
}
|
||||
other => panic!("应保持 Image 片, 实际 {other:?}"),
|
||||
}
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_file_degrades_to_text_not_empty_base64() {
|
||||
let out = inline_local_image_parts(vec![ContentPart::Image {
|
||||
url: Some("file:///nonexistent/df-ai-no-such.png".into()),
|
||||
base64: None,
|
||||
media_type: Some("image/png".into()),
|
||||
alt: None,
|
||||
}]);
|
||||
match &out[0] {
|
||||
ContentPart::Text { text } => {
|
||||
assert!(text.contains("[图片文件缺失"), "应降级为缺失说明, 实际 {text}");
|
||||
assert!(text.contains("df-ai-no-such.png"));
|
||||
}
|
||||
other => panic!("缺失文件应降级 Text 片, 实际 {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_local_parts_pass_through() {
|
||||
let passthrough = vec![
|
||||
ContentPart::text("hello"),
|
||||
ContentPart::image_base64("image/png", "aGVsbG8="),
|
||||
ContentPart::image_url("https://x/a.png"),
|
||||
// base64 非空的 file:// 不是引用(优先内嵌),原样透传
|
||||
ContentPart::Image {
|
||||
url: Some("file:///x.png".into()),
|
||||
base64: Some("aGVsbG8=".into()),
|
||||
media_type: Some("image/png".into()),
|
||||
alt: None,
|
||||
},
|
||||
];
|
||||
let out = inline_local_image_parts(passthrough);
|
||||
assert_eq!(out.len(), 4);
|
||||
assert!(matches!(&out[0], ContentPart::Text { .. }));
|
||||
assert!(matches!(&out[1], ContentPart::Image { base64: Some(_), .. }));
|
||||
assert!(matches!(&out[2], ContentPart::Image { url: Some(u), .. } if u.starts_with("https://")));
|
||||
assert!(matches!(&out[3], ContentPart::Image { base64: Some(_), .. }));
|
||||
}
|
||||
}
|
||||
+262
-78
@@ -8,8 +8,9 @@
|
||||
//! 1. `IntentRecognizer::recognize(message)` —— 规则/关键词匹配(方式 A,零延迟零成本)
|
||||
//! 返回 `(Intent, f32)`,置信度 0.0–1.0。低置信 → 上游 fallback 全量工具。
|
||||
//! 2. `tool_subset_for(intent)` —— 硬编码工具名→domain 映射,工具名子集(空 = 全量 fallback)。
|
||||
//! 3. `suggested_model_tier(intent)` —— 模态建议**接口预留**,当前恒返 `None`
|
||||
//! (待模型模态管理 Phase 落地后补充实际逻辑)。
|
||||
//! 3. `suggested_model_tier(intent)` —— 模型模态档位建议(意图 → `ModelTier` 映射):
|
||||
//! Code/Debug/Http → Heavy,File/Search/Task/Idea/Project/Conversation → Standard,
|
||||
//! Chat → Fast,Unknown → None(不强加偏好)。router 同 weight tiebreak + 模型路由用。
|
||||
//!
|
||||
//! ## 设计原则
|
||||
//! - **不碰** `tool_registry`:domain 映射在本文件内硬编码工具名常量,运行期不读 registry。
|
||||
@@ -70,12 +71,12 @@ impl Intent {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ModelTier 预留 ---------------------------------------------------------
|
||||
// ---- ModelTier 档位 ---------------------------------------------------------
|
||||
|
||||
/// 模型模态档位(**预留**)。
|
||||
/// 模型模态档位。
|
||||
///
|
||||
/// 待模型模态管理 Phase 落地后定义实际 provider/model 映射。
|
||||
/// 当前仅占位于 `suggested_model_tier` 返回类型,逻辑恒返 `None`。
|
||||
/// `suggested_model_tier` 据 Intent 映射到此档位,供 router 同 weight tiebreak
|
||||
/// (重档位优先)或后续 provider/model 路由(待模型模态管理 Phase 接入)使用。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ModelTier {
|
||||
/// 轻量快速(简单意图/闲聊)
|
||||
@@ -119,8 +120,9 @@ const DEBUG_RULES: &[Rule] = &[
|
||||
Rule { kw: "栈", weight: 0.5 },
|
||||
Rule { kw: "panic", weight: 0.9 },
|
||||
Rule { kw: "traceback", weight: 0.9 },
|
||||
// 命令执行场景关键词(run_command 入口引导):用户明确"运行/执行/构建/测试/跑"
|
||||
// 才该走 Exec domain(run_command)。中等权重(0.7)避免误抢 Code 命中(编译/重构 1.0)。
|
||||
// 命令执行场景关键词:用户明确"运行/执行/构建/测试/跑"时归 Debug 意图(影响档位路由与场景增量)。
|
||||
// run_command 已入基本盘全工作意图可见,这里不再承担工具暴露职责。
|
||||
// 中等权重(0.7)避免误抢 Code 命中(编译/重构 1.0)。
|
||||
Rule { kw: "运行", weight: 0.7 },
|
||||
Rule { kw: "执行", weight: 0.7 },
|
||||
Rule { kw: "构建", weight: 0.7 },
|
||||
@@ -230,12 +232,22 @@ const GENERIC_GROUP: &[IntentGroup] = &[
|
||||
/// 本表硬编码,不读 registry 运行期状态(保持模块独立可单测)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum ToolDomain {
|
||||
/// 数据/业务:项目/任务/灵感/工作流/回收站
|
||||
/// 数据/业务:项目/任务/灵感/工作流/回收站(全量,含写工具)
|
||||
Data,
|
||||
/// 文件:读写/patch/列目录/搜索(不含命令执行)
|
||||
/// 数据只读子集:list_*/get_*_count(不含 create/update/delete/advance/run_workflow/restore/purge/bind_directory)
|
||||
///
|
||||
/// 用途:`Intent::Code` 的场景增量:保留编码场景用户常需要的"看一下项目/任务结构"只读
|
||||
/// 探索工具(list_projects/list_tasks/list_ideas/list_trash),不放大写工具暴露面
|
||||
/// (写工具属 Data 域,留给 Project/Task/Idea 的场景增量)。
|
||||
DataReadOnly,
|
||||
/// 文件:读写/patch/列目录/搜索。基本盘成员,所有工作意图可见
|
||||
File,
|
||||
/// 命令执行:run_command(shell 命令,独立 domain 防止被泛 File 意图带出)
|
||||
/// 命令执行:run_command(shell 命令)。基本盘成员:读写执行为 agent 基本能力
|
||||
/// (2026-09-30 用户拍板,不受意图收敛限制),安全由执行层 validate_path/授权/RiskLevel 兜底
|
||||
Exec,
|
||||
/// 编排:spawn_agent 子代理派发。基本盘成员:委派是编排类基本能力,
|
||||
/// 高置信意图收敛下不登记则工具对 LLM 不可见,安全由子代理侧白名单钳制兜底
|
||||
Agent,
|
||||
/// 网络:HTTP
|
||||
Http,
|
||||
}
|
||||
@@ -275,11 +287,27 @@ impl ToolDomain {
|
||||
"delete_file",
|
||||
"rename_file",
|
||||
],
|
||||
// Exec domain 独立:run_command 从 File 移出。
|
||||
// 意图侧仅 Debug 默认带 Exec(用户明确"运行/测试/构建"),
|
||||
// Code/File/Search 不带 → 减少 LLM 对 run_command 的偏好暴露。
|
||||
// Exec domain 独立于 File:run_command 单列,供基本盘/场景增量按意图组合。
|
||||
// 2026-09-30 用户拍板读写执行为 agent 基本能力,不再仅 Debug 暴露;
|
||||
// 安全由执行层兜底(RiskLevel 审批/授权三道门/路径黑名单),藏工具≠安全。
|
||||
ToolDomain::Exec => &["run_command"],
|
||||
// Agent domain:spawn_agent 单列,编排委派基本盘成员(子代理侧白名单钳制兜底安全)。
|
||||
ToolDomain::Agent => &["spawn_agent"],
|
||||
ToolDomain::Http => &["http_request"],
|
||||
// DataReadOnly:Data domain 的只读子集。
|
||||
// 源于 Code 意图收敛需求:用户在编码场景说"先 list_projects 看下项目结构"
|
||||
// "create_task 记一下"时,意图识别可能命中 Code(SPECIFIC > ENTITY),原 Code subset
|
||||
// 不含 Data domain → list_projects/create_task 对 LLM 不可见,agent 被迫反复 read_file。
|
||||
// 加 Data 全 domain 会暴露 create/update/delete 等写工具(放大暴露面),
|
||||
// 故取只读子集:list_*/get_*_count(纯查询,无副作用)。
|
||||
ToolDomain::DataReadOnly => &[
|
||||
"list_projects",
|
||||
"list_tasks",
|
||||
"list_ideas",
|
||||
"list_trash",
|
||||
"get_project_count",
|
||||
"get_task_count",
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -392,7 +420,7 @@ fn best_in_group(message: &str, group: &[IntentGroup]) -> Option<(Intent, f32)>
|
||||
for rule in cand.rules.iter() {
|
||||
// 英文关键词用小写匹配;中文不受 to_lowercase 影响(no-op)。
|
||||
let needle = rule.kw.to_lowercase();
|
||||
if lower.contains(&needle) {
|
||||
if kw_matches(&lower, &needle) {
|
||||
score += rule.weight;
|
||||
matched = true;
|
||||
}
|
||||
@@ -409,37 +437,62 @@ fn best_in_group(message: &str, group: &[IntentGroup]) -> Option<(Intent, f32)>
|
||||
best
|
||||
}
|
||||
|
||||
/// 关键词命中判定:ASCII 关键词要求两侧词边界(词字符 = ASCII 字母数字 + 下划线,与正则
|
||||
/// `\b` 语义一致),防子串误判——"history" 不命中 "hi"(误判 Chat)、"rapid" 不命中
|
||||
/// "api"(误判 Http)。非 ASCII(中文)关键词保持子串语义:中文无词边界概念,直接 contains。
|
||||
fn kw_matches(haystack: &str, needle: &str) -> bool {
|
||||
if !needle.is_ascii() {
|
||||
return haystack.contains(needle);
|
||||
}
|
||||
let is_word_char = |c: char| c.is_ascii_alphanumeric() || c == '_';
|
||||
haystack.match_indices(needle).any(|(pos, m)| {
|
||||
haystack[..pos]
|
||||
.chars()
|
||||
.next_back()
|
||||
.map_or(true, |c| !is_word_char(c))
|
||||
&& haystack[pos + m.len()..]
|
||||
.chars()
|
||||
.next()
|
||||
.map_or(true, |c| !is_word_char(c))
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Intent → 工具子集映射 --------------------------------------------------
|
||||
|
||||
/// 按 Intent 返回工具名子集。
|
||||
///
|
||||
/// 返回空 `Vec` 表示该意图**无工具收敛**(Chat)或**未识别**(Unknown),
|
||||
/// 返回空 `Vec` 表示该意图**无工具收敛**(Chat/Conversation)或**未识别**(Unknown),
|
||||
/// 上游应走**全量 fallback**(即不过滤工具,交全量给 LLM)。
|
||||
///
|
||||
/// 设计:Code → [file, http];File → [file];Project/Task/Idea → [data, file](加 file 防「提项目/任务 → 误判 → 砍只读探索」,见 L1);
|
||||
/// Http → [http];Search → [file](含 search_files);Conversation → [];
|
||||
/// Chat → [];Debug → [file, exec, http, data](调试常需跑命令+读文件+查 API+查任务/工作流状态,CR-25 审查🟡-1 加 data 防"调试任务"丢 Data 工具);
|
||||
/// **仅 Debug 含 Exec**(用户明确"运行/测试/构建/调试"才暴露 run_command),
|
||||
/// Code/File/Search 不含 Exec → 收紧 run_command 暴露面;Unknown → [](全量)。
|
||||
/// 设计(2026-09-30 起):基本盘 + 场景增量。
|
||||
/// 基本盘 = File + Exec + Agent:读、写、执行命令、委派子代理是 agent 基本能力,
|
||||
/// 不受意图收敛限制(用户拍板),
|
||||
/// 所有非空子集意图(Code/Debug/File/Project/Task/Idea/Search/Http)都可见。
|
||||
/// 安全防线在执行层:validate_path 黑名单 + 授权三道门 + RiskLevel 审批,藏工具≠安全;
|
||||
/// 藏工具只会复现断手(Project 砍 File 致 read_file 不可见、Debug 独占 run_command 致
|
||||
/// "查看服务器状态"被回"未挂载",均属此类,本次根治)。
|
||||
/// 场景增量 = 业务域收敛,减少 LLM 分心:Code 加 Http+DataReadOnly;Debug 加 Http+Data
|
||||
/// (调试常需查 API+查任务/工作流状态,CR-25 加 data 防"调试任务"丢 Data 工具);
|
||||
/// Project/Task/Idea 加 Data;File/Search 仅基本盘;Http 保留自身 Http 域
|
||||
/// (发请求是该意图本名能力,砍掉即复现断手)。
|
||||
/// 各意图新子集均为旧子集超集,只增不减。
|
||||
pub fn tool_subset_for(intent: &Intent) -> Vec<&'static str> {
|
||||
let domains: &[ToolDomain] = match intent {
|
||||
Intent::Code => &[ToolDomain::File, ToolDomain::Http],
|
||||
Intent::Debug => &[ToolDomain::File, ToolDomain::Exec, ToolDomain::Http, ToolDomain::Data],
|
||||
Intent::File => &[ToolDomain::File],
|
||||
// Project/Task/Idea 加 File:用户提"项目/任务"时常是在其内编码/排查
|
||||
// (实测会话 [项目:x] 报错 → Project → 砍 File 致 read_file 对 LLM 不可见)。
|
||||
// 保留只读探索;写工具有 RiskLevel/审批兜底,"分心"远好于"断手"。
|
||||
Intent::Project => &[ToolDomain::Data, ToolDomain::File],
|
||||
Intent::Task => &[ToolDomain::Data, ToolDomain::File],
|
||||
Intent::Idea => &[ToolDomain::Data, ToolDomain::File],
|
||||
Intent::Conversation => &[],
|
||||
Intent::Search => &[ToolDomain::File],
|
||||
// 基本盘:读写执行 + 编排委派(RiskLevel/审批/路径授权/子代理白名单钳制兜底,藏工具≠安全);
|
||||
// 场景增量=业务域收敛减少分心
|
||||
const BASE_DOMAINS: &[ToolDomain] = &[ToolDomain::File, ToolDomain::Exec, ToolDomain::Agent];
|
||||
let extra: &[ToolDomain] = match intent {
|
||||
Intent::Code => &[ToolDomain::Http, ToolDomain::DataReadOnly],
|
||||
Intent::Debug => &[ToolDomain::Http, ToolDomain::Data],
|
||||
// 提项目/任务/想法常伴编码排查,业务域全量保留
|
||||
// (写工具有 RiskLevel/审批兜底,"分心"远好于"断手")。
|
||||
Intent::Project | Intent::Task | Intent::Idea => &[ToolDomain::Data],
|
||||
Intent::Http => &[ToolDomain::Http],
|
||||
Intent::Chat => &[],
|
||||
Intent::Unknown => &[],
|
||||
Intent::File | Intent::Search => &[],
|
||||
// 空子集语义 = 上游全量 fallback,不受基本盘改造影响,行为不变
|
||||
Intent::Conversation | Intent::Chat | Intent::Unknown => return Vec::new(),
|
||||
};
|
||||
let mut out: Vec<&'static str> = Vec::new();
|
||||
for d in domains {
|
||||
for d in BASE_DOMAINS.iter().chain(extra.iter()) {
|
||||
for name in d.tools() {
|
||||
if !out.contains(name) {
|
||||
out.push(name);
|
||||
@@ -449,19 +502,36 @@ pub fn tool_subset_for(intent: &Intent) -> Vec<&'static str> {
|
||||
out
|
||||
}
|
||||
|
||||
// ---- 模态建议(接口预留) ---------------------------------------------------
|
||||
// ---- 模态建议(意图 → 模型 tier 映射) ----------------------------------------
|
||||
|
||||
/// 按 Intent 建议模型模态档位。
|
||||
/// 按 Intent 建议模型模态档位(意图 → 模型 tier 的语义映射)。
|
||||
///
|
||||
/// **预留接口**:当前恒返 `None`。待模型模态管理 Phase 落地后补充:
|
||||
/// - Chat/Conversation → `Fast`
|
||||
/// - Code/File/Task/Idea/Search → `Standard`
|
||||
/// - Debug/Http(复杂排查/多跳调用)→ `Heavy`
|
||||
/// **映射分组**(对齐 router 同 weight tiebreak 缺语义的根因修复):
|
||||
/// - `Code`/`Debug` → `Heavy`(复杂推理/重构/排查,需重模型)
|
||||
/// - `Http` → `Heavy`(多跳外部调用,链长易错,需重模型把关)
|
||||
/// - `File`/`Search`/`Task`/`Idea`/`Project`/`Conversation` → `Standard`(默认复杂度)
|
||||
/// - `Chat` → `Fast`(简单闲聊,轻量即可)
|
||||
/// - `Unknown` → `None`(未识别,fallback 全量工具时不强加 tier 偏好,
|
||||
/// 上游走默认档位)
|
||||
///
|
||||
/// 返回 `None` 时上游应使用默认档位(待模态管理 Phase 定义)。
|
||||
pub fn suggested_model_tier(_intent: &Intent) -> Option<ModelTier> {
|
||||
// TODO(model-tier-phase): 待模型模态管理落地后填实映射。
|
||||
None
|
||||
/// 返回 `Some(ModelTier)` 时上游可作为同 weight 候选间的 tiebreak 依据
|
||||
/// (重档位优先),或据此路由到不同 provider/model(待模型模态管理 Phase 接入)。
|
||||
pub fn suggested_model_tier(intent: &Intent) -> Option<ModelTier> {
|
||||
match intent {
|
||||
// 重型:复杂推理/重构/排查/多跳调用
|
||||
Intent::Code | Intent::Debug | Intent::Http => Some(ModelTier::Heavy),
|
||||
// 标准:默认复杂度
|
||||
Intent::File
|
||||
| Intent::Search
|
||||
| Intent::Task
|
||||
| Intent::Idea
|
||||
| Intent::Project
|
||||
| Intent::Conversation => Some(ModelTier::Standard),
|
||||
// 轻量:简单闲聊
|
||||
Intent::Chat => Some(ModelTier::Fast),
|
||||
// 未识别:不强加 tier 偏好,fallback 上游默认
|
||||
Intent::Unknown => None,
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 工具子集过滤(agentic loop 接入用,改进2 A) -----------------------------
|
||||
@@ -804,6 +874,38 @@ mod tests {
|
||||
assert_eq!(i, Intent::Http);
|
||||
}
|
||||
|
||||
// --- ASCII 关键词词边界(防子串误判) ---
|
||||
|
||||
#[test]
|
||||
fn word_boundary_history_not_chat() {
|
||||
// "history" 内嵌 "hi" 不再判 Chat(无其他关键词 → Unknown)
|
||||
let (i, c) = IntentRecognizer::recognize("show me the history");
|
||||
assert_eq!(i, Intent::Unknown, "history 不应命中 hi, 实际 conf={}", c);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_boundary_rapid_not_http() {
|
||||
// "rapid" 内嵌 "api" 不再判 Http
|
||||
let (i, c) = IntentRecognizer::recognize("rapid growth");
|
||||
assert_eq!(i, Intent::Unknown, "rapid 不应命中 api, 实际 conf={}", c);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standalone_english_keywords_still_hit() {
|
||||
// 词边界不误伤独立词命中
|
||||
let (i, _) = IntentRecognizer::recognize("call the api");
|
||||
assert_eq!(i, Intent::Http);
|
||||
let (i, _) = IntentRecognizer::recognize("hi");
|
||||
assert_eq!(i, Intent::Chat);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cjk_adjacent_ascii_keyword_still_hits() {
|
||||
// 中文紧邻 ASCII 关键词:非 ASCII 字符算边界,不破坏中文场景命中
|
||||
let (i, _) = IntentRecognizer::recognize("调用这个api");
|
||||
assert_eq!(i, Intent::Http);
|
||||
}
|
||||
|
||||
// --- tool_subset_for ---
|
||||
|
||||
#[test]
|
||||
@@ -813,14 +915,64 @@ mod tests {
|
||||
assert!(s.contains(&"write_file"));
|
||||
assert!(s.contains(&"patch_file"));
|
||||
assert!(s.contains(&"http_request"));
|
||||
assert!(!s.contains(&"list_projects"));
|
||||
// Code 现含 DataReadOnly → list_projects 等只读工具保留(不再断言"不含")
|
||||
assert!(s.contains(&"list_projects"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subset_file_only_file_domain() {
|
||||
fn subset_code_keeps_data_readonly_tools() {
|
||||
// 根因修复(Code 命中砍 Data domain 致 list_*/get_*_count 不可见):
|
||||
// Code subset 应含 DataReadOnly 全部 6 个只读工具,让"先 list_projects 看下结构"
|
||||
// "list_tasks 记一下"这类编码场景的口语不被 Code 意图砍工具。
|
||||
let s = tool_subset_for(&Intent::Code);
|
||||
for read_only in [
|
||||
"list_projects",
|
||||
"list_tasks",
|
||||
"list_ideas",
|
||||
"list_trash",
|
||||
"get_project_count",
|
||||
"get_task_count",
|
||||
] {
|
||||
assert!(
|
||||
s.contains(&read_only),
|
||||
"Code subset 应含只读工具 {}(DataReadOnly domain)",
|
||||
read_only
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subset_code_no_data_write_tools() {
|
||||
// 防回归:Code subset 只加 Data 的"只读子集",不得暴露写工具。
|
||||
// create_task 不应在 Code subset(只读 list_* 在,写 create_* 不在)。
|
||||
// create_project/update_project/delete_project/advance_task/run_workflow 同理。
|
||||
let s = tool_subset_for(&Intent::Code);
|
||||
for write_tool in [
|
||||
"create_task",
|
||||
"update_task",
|
||||
"delete_task",
|
||||
"create_project",
|
||||
"update_project",
|
||||
"delete_project",
|
||||
"advance_task",
|
||||
"run_workflow",
|
||||
] {
|
||||
assert!(
|
||||
!s.contains(&write_tool),
|
||||
"Code subset 不应含写工具 {}(只读子集,防放大暴露面), 实际 subset: {:?}",
|
||||
write_tool,
|
||||
s
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subset_file_is_base_without_business_domains() {
|
||||
// File 仅基本盘:读写+命令执行可见,Http/Data 业务域不暴露
|
||||
let s = tool_subset_for(&Intent::File);
|
||||
assert!(s.contains(&"read_file"));
|
||||
assert!(s.contains(&"search_files"));
|
||||
assert!(s.contains(&"run_command"), "File 意图应含 run_command(基本盘)");
|
||||
assert!(!s.contains(&"http_request"));
|
||||
assert!(!s.contains(&"list_projects"));
|
||||
}
|
||||
@@ -850,9 +1002,13 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subset_http_only_http() {
|
||||
fn subset_http_keeps_http_plus_base() {
|
||||
// Http 意图:基本盘(读写执行)+ 自身 Http 域,较旧版(仅 http_request)只增不减
|
||||
let s = tool_subset_for(&Intent::Http);
|
||||
assert_eq!(s, vec!["http_request"]);
|
||||
assert!(s.contains(&"http_request"));
|
||||
assert!(s.contains(&"read_file"));
|
||||
assert!(s.contains(&"run_command"), "Http 意图应含 run_command(基本盘)");
|
||||
assert!(!s.contains(&"list_projects"), "Http 意图不加 Data 场景增量");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -898,7 +1054,7 @@ mod tests {
|
||||
fn tool_domain_names_match_registry() {
|
||||
// 抽样核对与 src-tauri tool_registry 实际注册名一致
|
||||
assert!(ToolDomain::File.tools().contains(&"patch_file"));
|
||||
// run_command 已从 File 移出 Exec domain(意图收紧:仅 Debug 默认带 run_command)
|
||||
// run_command 归 Exec domain(域归属未变;子集组合已改基本盘,全工作意图可见)
|
||||
assert!(!ToolDomain::File.tools().contains(&"run_command"));
|
||||
assert_eq!(ToolDomain::Exec.tools(), &["run_command"]);
|
||||
assert!(ToolDomain::Data.tools().contains(&"advance_task"));
|
||||
@@ -907,26 +1063,39 @@ mod tests {
|
||||
assert_eq!(ToolDomain::Http.tools(), &["http_request"]);
|
||||
}
|
||||
|
||||
// --- suggested_model_tier 预留 ---
|
||||
// --- suggested_model_tier 意图 → 档位映射 ---
|
||||
|
||||
#[test]
|
||||
fn model_tier_always_none_for_now() {
|
||||
// 接口预留:当前所有意图均返 None
|
||||
fn model_tier_mapping_now_returns_actual() {
|
||||
// Code/Debug/Http → Heavy(复杂推理/重构/排查/多跳调用)
|
||||
for i in [Intent::Code, Intent::Debug, Intent::Http] {
|
||||
assert_eq!(
|
||||
suggested_model_tier(&i),
|
||||
Some(ModelTier::Heavy),
|
||||
"intent {:?} 应映射 Heavy",
|
||||
i
|
||||
);
|
||||
}
|
||||
// File/Search/Task/Idea/Project/Conversation → Standard(默认复杂度)
|
||||
for i in [
|
||||
Intent::Code,
|
||||
Intent::Debug,
|
||||
Intent::File,
|
||||
Intent::Project,
|
||||
Intent::Search,
|
||||
Intent::Task,
|
||||
Intent::Idea,
|
||||
Intent::Project,
|
||||
Intent::Conversation,
|
||||
Intent::Search,
|
||||
Intent::Http,
|
||||
Intent::Chat,
|
||||
Intent::Unknown,
|
||||
] {
|
||||
assert_eq!(suggested_model_tier(&i), None, "intent {:?} 应返 None", i);
|
||||
assert_eq!(
|
||||
suggested_model_tier(&i),
|
||||
Some(ModelTier::Standard),
|
||||
"intent {:?} 应映射 Standard",
|
||||
i
|
||||
);
|
||||
}
|
||||
// Chat → Fast(简单闲聊)
|
||||
assert_eq!(suggested_model_tier(&Intent::Chat), Some(ModelTier::Fast));
|
||||
// Unknown → None(未识别,不强加 tier 偏好,fallback 上游默认)
|
||||
assert_eq!(suggested_model_tier(&Intent::Unknown), None);
|
||||
}
|
||||
|
||||
// --- IntentRecognizer Default ---
|
||||
@@ -944,7 +1113,7 @@ mod tests {
|
||||
/// 构造测试用 ToolDefinition(仅 name 有意义,description/parameters 填占位)。
|
||||
fn tool_def(name: &str) -> df_ai_core::types::ToolDefinition {
|
||||
df_ai_core::types::ToolDefinition {
|
||||
tool_type: "function".to_string(),
|
||||
tool_type: "function".into(),
|
||||
function: df_ai_core::types::ToolFunction {
|
||||
name: name.to_string(),
|
||||
description: String::new(),
|
||||
@@ -978,7 +1147,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn filter_subset_hit_filters_to_matching() {
|
||||
// File 意图 subset = File domain(read_file/write_file/...);Http 工具应被滤掉
|
||||
// File 意图 subset = 基本盘(File+Exec),不含 Http 域;http_request 应被滤掉
|
||||
let all = vec![
|
||||
tool_def("read_file"),
|
||||
tool_def("write_file"),
|
||||
@@ -997,7 +1166,7 @@ mod tests {
|
||||
#[test]
|
||||
fn filter_subset_drift_skips_missing_names() {
|
||||
// subset 命中但 all_defs 里没有对应工具(registry 漂移)→ 跳过,不 panic
|
||||
// Project 意图 subset = Data domain(create_project/...),all 里只放了一个 Data 工具 + 一个无关工具
|
||||
// Project 意图 subset = Data+基本盘,create_project 命中;http_request 不在 subset,滤掉
|
||||
let all = vec![
|
||||
tool_def("create_project"), // 命中
|
||||
tool_def("http_request"), // 不在 Data domain,滤掉
|
||||
@@ -1095,21 +1264,32 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_command_only_exposed_for_debug_intent() {
|
||||
// 核心修复断言:run_command 仅 Debug 暴露。
|
||||
// Code/File/Search 意图 subset 不含 Exec domain → 不含 run_command。
|
||||
for intent in [Intent::Code, Intent::File, Intent::Search] {
|
||||
fn run_command_exposed_for_all_work_intents() {
|
||||
// 2026-09-30 用户拍板:读写执行是 agent 基本能力,不受意图收敛限制。
|
||||
// 所有非空子集意图都经基本盘含 run_command;
|
||||
// 安全由执行层兜底(validate_path/授权/RiskLevel),藏工具≠安全。
|
||||
for intent in [
|
||||
Intent::Code,
|
||||
Intent::Debug,
|
||||
Intent::File,
|
||||
Intent::Project,
|
||||
Intent::Task,
|
||||
Intent::Idea,
|
||||
Intent::Search,
|
||||
Intent::Http,
|
||||
] {
|
||||
let s = tool_subset_for(&intent);
|
||||
assert!(
|
||||
!s.contains(&"run_command"),
|
||||
"Intent {:?} subset 不应含 run_command(仅 Debug 暴露), 实际: {:?}",
|
||||
s.contains(&"run_command"),
|
||||
"Intent {:?} subset 应含 run_command(基本盘), 实际: {:?}",
|
||||
intent,
|
||||
s
|
||||
);
|
||||
}
|
||||
// Debug 必含 run_command(Exec domain)
|
||||
let dbg = tool_subset_for(&Intent::Debug);
|
||||
assert!(dbg.contains(&"run_command"), "Debug 必含 run_command");
|
||||
// 空子集意图不受影响(上游语义=全量,天然含全部工具)
|
||||
assert!(tool_subset_for(&Intent::Chat).is_empty());
|
||||
assert!(tool_subset_for(&Intent::Unknown).is_empty());
|
||||
assert!(tool_subset_for(&Intent::Conversation).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1145,13 +1325,18 @@ mod tests {
|
||||
];
|
||||
for intent in all_intents {
|
||||
let subset = tool_subset_for(&intent);
|
||||
// 全 registry 工具名(四 domain 并集:Data + File + Exec + Http)
|
||||
// 全 registry 工具名(六 domain 并集:Data + DataReadOnly + File + Exec + Http + Agent)
|
||||
// 注:DataReadOnly 工具名是 Data 的子集,chain 它仅为语义显式(并集去重无副作用),
|
||||
// 防 Code subset 里 list_projects 等 DataReadOnly 工具被判"不在 registry"。
|
||||
// Agent(spawn_agent)是编排委派基本盘成员,子集合法出现,漏 chain 会误报。
|
||||
let registry: std::collections::HashSet<&str> = ToolDomain::Data
|
||||
.tools()
|
||||
.iter()
|
||||
.chain(ToolDomain::DataReadOnly.tools().iter())
|
||||
.chain(ToolDomain::File.tools().iter())
|
||||
.chain(ToolDomain::Exec.tools().iter())
|
||||
.chain(ToolDomain::Http.tools().iter())
|
||||
.chain(ToolDomain::Agent.tools().iter())
|
||||
.copied()
|
||||
.collect();
|
||||
for name in &subset {
|
||||
@@ -1182,8 +1367,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn subset_code_and_debug_dedup_http_across_domains() {
|
||||
// 边界:Code = [File, Http], Debug = [File, Http, Data]
|
||||
// File + Http 都含 http_request,必须去重(subset 内不重复)
|
||||
// Code/Debug 均含 Http 场景增量,叠加基本盘后 subset 内工具名必须去重
|
||||
for intent in [Intent::Code, Intent::Debug] {
|
||||
let s = tool_subset_for(&intent);
|
||||
let http_count = s.iter().filter(|n| **n == "http_request").count();
|
||||
@@ -1240,7 +1424,7 @@ mod tests {
|
||||
tool_def("append_file"),
|
||||
tool_def("delete_file"),
|
||||
tool_def("rename_file"),
|
||||
tool_def("http_request"), // 不在 File domain,应被滤掉
|
||||
tool_def("http_request"), // 不在 File 意图子集,应被滤掉
|
||||
];
|
||||
let out = filter_tool_defs_planned(&all, &Intent::File, "file", "先 read 这个文件再修改它");
|
||||
let names: Vec<&str> = out.iter().map(|d| d.function.name.as_str()).collect();
|
||||
|
||||
+102
-9
@@ -1,14 +1,23 @@
|
||||
//! df-ai: AI 编排 — LLM Provider、Agent 协调、上下文管理、流式处理、工具注册
|
||||
|
||||
pub mod ai_tools;
|
||||
// 声明式工具注册宏 `declare_tool!`(tool_registry 拆分第一步基础设施)。
|
||||
// macro_rules + #[macro_export]:零新依赖,展开为等价 `register` 调用,与现有 48 工具并存。
|
||||
// 试点:list_projects 已迁至声明式(commands/ai/tools/list_projects.rs),验证可行+零回归。
|
||||
#[macro_use]
|
||||
pub mod ai_tools_decl;
|
||||
pub mod anthropic_compat;
|
||||
pub mod anthropic_helpers;
|
||||
pub mod context;
|
||||
pub mod context_helpers;
|
||||
pub mod memory_adapter;
|
||||
pub(crate) mod net_probe;
|
||||
// 多 Agent 协作调度中心(Phase 1 规则驱动, Phase 2 LLM 扩展)。
|
||||
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 git_worktree;
|
||||
pub mod intent;
|
||||
pub mod model_fetch;
|
||||
// model_fetch 的纯逻辑子模块(URL 拼接 / 噪音过滤 / 响应反序列化),
|
||||
@@ -24,16 +33,28 @@ mod model_probe_helpers;
|
||||
pub mod plan_hint;
|
||||
pub mod openai_compat;
|
||||
pub mod openai_helpers;
|
||||
// 本地图片引用展开(#958 图片入站引用化):消息存 file:// 引用,发送前单点展开 base64
|
||||
pub mod image_ref;
|
||||
// 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;
|
||||
// Phase 2: Plan DAG 分层执行器(骨架,PLAN_EXECUTION_ENABLED 门控默认关)。
|
||||
// 按 Plan::to_layers 层间串行/层内并行调度子任务。
|
||||
// 依据 docs/02-架构设计/单对话并行多轮-设计-2026-06-20.md。
|
||||
pub mod persona;
|
||||
pub mod plan_executor;
|
||||
pub mod provider;
|
||||
pub mod router;
|
||||
// CR-30-1: 流前重试退避对外复用。complete() 的 retry_with_backoff 仍 crate 内用,
|
||||
// 流前重试退避对外复用。complete() 的 retry_with_backoff 仍 crate 内用,
|
||||
// stream_recv/agentic 流前重试需复用 backoff_delay(jitter)+is_status_retryable(Fatal 分类)
|
||||
// 避免重写退避/分类逻辑(对齐决策 F-260616-07 a1)。改 pub mod 后对外仅暴露纯函数 + 常量。
|
||||
// 避免重写退避/分类逻辑。改 pub mod 后对外仅暴露纯函数 + 常量。
|
||||
pub mod retry;
|
||||
pub mod sse_parser;
|
||||
pub mod namespace_store;
|
||||
// P1 工具结果瘦身(上下文根治专项 2026-10-06 设计 §二):超阈值工具结果正文落盘 + 句柄。
|
||||
// 与 src-tauri 审计侧 D2a(tool_result_offload) 同构;base 未注入时降级为纯摘要(零行为变化)。
|
||||
pub mod tool_offload;
|
||||
|
||||
use provider::LlmProvider;
|
||||
use reqwest::Client;
|
||||
@@ -41,7 +62,7 @@ use reqwest::Client;
|
||||
// df-ai-core 直接暴露,供需要直接引用 trait crate 的下游(可选)。
|
||||
pub use df_ai_core;
|
||||
|
||||
/// SW-260618-10: 构建 Provider 共用的 reqwest Client(OpenAI/Anthropic Provider::new DRY)。
|
||||
/// 构建 Provider 共用的 reqwest Client(OpenAI/Anthropic Provider::new DRY)。
|
||||
///
|
||||
/// connect_timeout 30s 防连接阶段静默 hang(网络静默断);不设总 timeout——reqwest 的
|
||||
/// `.timeout()` 会限制整个响应 body 时长,流式长生成任务会被误砍。读取阶段中途静默由上层
|
||||
@@ -49,6 +70,11 @@ pub use df_ai_core;
|
||||
pub(crate) fn build_provider_client() -> Client {
|
||||
Client::builder()
|
||||
.connect_timeout(std::time::Duration::from_secs(30))
|
||||
// 连接池收敛:idle 连接 15s 弃用 + 每主机最多存 2 条。reqwest 默认 idle 90s 且
|
||||
// 无上限,服务端/前置 keepalive 更短时复用陈旧连接首包即被 RST(ConnectionReset
|
||||
// 10054),呈现「隔几十秒一发就失败」的间歇形态;15s 保守低于常见网关 60s keepalive。
|
||||
.pool_idle_timeout(std::time::Duration::from_secs(15))
|
||||
.pool_max_idle_per_host(2)
|
||||
.build()
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::warn!("reqwest builder 失败,回退默认 client: {}", e);
|
||||
@@ -56,12 +82,68 @@ pub(crate) fn build_provider_client() -> Client {
|
||||
})
|
||||
}
|
||||
|
||||
/// 流式 send 阶段 reqwest 错误的谓词快照。
|
||||
///
|
||||
/// reqwest::Error 无公开构造器,抽谓词结构体过纯函数,单测才能锁定分类
|
||||
/// (send 阶段 ConnectionReset → 可重试类词)。
|
||||
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) struct SendErrPreds {
|
||||
pub is_timeout: bool,
|
||||
pub is_connect: bool,
|
||||
pub is_decode: bool,
|
||||
pub is_body: bool,
|
||||
pub is_request: bool,
|
||||
pub is_builder: bool,
|
||||
}
|
||||
|
||||
impl SendErrPreds {
|
||||
pub(crate) fn of(e: &reqwest::Error) -> Self {
|
||||
Self {
|
||||
is_timeout: e.is_timeout(),
|
||||
is_connect: e.is_connect(),
|
||||
is_decode: e.is_decode(),
|
||||
is_body: e.is_body(),
|
||||
is_request: e.is_request(),
|
||||
is_builder: e.is_builder(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 流式 send 阶段错误分类,返回类词(置于 bail 串首位供下游按串分类)。
|
||||
///
|
||||
/// reqwest 0.12 的 Display 只给 "error sending request for url",不含 source 关键词,
|
||||
/// 纯 Display 串分类必得 unknown → Fatal 零重试。此处用谓词确定性给类词:
|
||||
/// timeout/connect/decode/body 各归本类;is_request(非 builder)覆盖陈旧池化连接
|
||||
/// 首包 RST(ConnectionReset 10054)等传输层发送错,归 connect(与 retry 的
|
||||
/// is_reqwest_error_retryable 对 is_request 可重试口径一致);builder 构造错归
|
||||
/// request(不可重试)。类词须落在 stream_recv classify_status_or_class 传输词表内,
|
||||
/// 改任一侧需同步。
|
||||
pub(crate) fn send_error_class(p: SendErrPreds) -> &'static str {
|
||||
if p.is_timeout {
|
||||
"timeout"
|
||||
} else if p.is_connect {
|
||||
"connect"
|
||||
} else if p.is_decode {
|
||||
"decode"
|
||||
} else if p.is_body {
|
||||
"body"
|
||||
} else if p.is_request && !p.is_builder {
|
||||
"connect"
|
||||
} else {
|
||||
"request"
|
||||
}
|
||||
}
|
||||
|
||||
/// 按 provider_type 构建 LLM Provider 实例(统一选择逻辑,消除调用方重复 match)
|
||||
///
|
||||
/// `anthropic` 协议走 AnthropicCompatProvider(GLM 订阅端点 / Claude 官方),
|
||||
/// 其余(openai / glm / deepseek 等 OpenAI 兼容)走 OpenAICompatProvider。
|
||||
/// 调用方(AI Chat 的 run_agentic_loop、df-nodes 的 AiNode)统一引用此工厂,
|
||||
/// 新增 provider 只改这一处。
|
||||
///
|
||||
/// 未知 provider_type 无法走显式报错(工厂签名不动,调用方横跨 src-tauri/df-nodes),
|
||||
/// 兜底 OpenAI 兼容协议但 warn 留痕,不再静默——协议错配的请求失败可溯源到配置项。
|
||||
/// anthropic_family 口径与 prompt.rs cache_split / chat.rs needs_url_prefetch 对齐。
|
||||
pub fn build_provider(
|
||||
provider_type: &str,
|
||||
base_url: &str,
|
||||
@@ -69,11 +151,22 @@ pub fn build_provider(
|
||||
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,
|
||||
)),
|
||||
"anthropic" | "anthropic_compat" => Box::new(
|
||||
anthropic_compat::AnthropicCompatProvider::new(base_url, api_key, model),
|
||||
),
|
||||
"openai_compat" | "openai" | "glm" | "deepseek" => {
|
||||
Box::new(openai_compat::OpenAICompatProvider::new(
|
||||
base_url, api_key, model,
|
||||
))
|
||||
}
|
||||
unknown => {
|
||||
tracing::warn!(
|
||||
"未知 provider_type {:?},按 OpenAI 兼容协议兜底;请检查 ai_providers 配置的 provider_type",
|
||||
unknown
|
||||
);
|
||||
Box::new(openai_compat::OpenAICompatProvider::new(
|
||||
base_url, api_key, model,
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
//! MemoryAdapter trait — 上下文记忆层抽象
|
||||
//!
|
||||
//! 将 ContextManager 的核心接口提取为 trait,支持多种记忆后端:
|
||||
//! - InMemoryAdapter(当前 ContextManager 的包装,默认实现)
|
||||
//! - VectorMemoryAdapter(未来:接入向量检索,支持语义记忆)
|
||||
//!
|
||||
//! 使用方式:`Box<dyn MemoryAdapter>` 替代 `ContextManager`。
|
||||
|
||||
use crate::context::ContextManager;
|
||||
use crate::context_helpers::{ContextConfig, TrackedMessage};
|
||||
use crate::provider::ChatMessage;
|
||||
|
||||
/// 上下文记忆层抽象接口
|
||||
///
|
||||
/// 覆盖 agentic loop / IPC 最常用的 15 个方法。
|
||||
/// 保留 `ContextManager` 作为默认实现(`InMemoryAdapter`),行为零变化。
|
||||
pub trait MemoryAdapter: Send + Sync {
|
||||
// ── 写入 ──
|
||||
fn push(&mut self, msg: ChatMessage);
|
||||
fn clear(&mut self);
|
||||
fn insert_at(&mut self, index: usize, message: ChatMessage);
|
||||
fn replace_tool_result_content(&mut self, tool_call_id: &str, new_content: &str) -> bool;
|
||||
fn pop_last_assistant_round(&mut self) -> bool;
|
||||
fn truncate_after_user_message(&mut self, target_content: &str) -> Result<usize, ()>;
|
||||
fn replace_last_active_user_content(&mut self, new_content: &str) -> Result<(), ()>;
|
||||
|
||||
// ── 读取 ──
|
||||
fn build_for_request(&self, sys_tokens: u32) -> (Vec<ChatMessage>, bool);
|
||||
fn all_messages_clone(&self) -> Vec<ChatMessage>;
|
||||
fn len(&self) -> usize;
|
||||
fn is_empty(&self) -> bool;
|
||||
fn history_tokens(&self) -> u32;
|
||||
fn budget_limit(&self) -> u32;
|
||||
fn config(&self) -> &ContextConfig;
|
||||
|
||||
// ── 压缩 ──
|
||||
fn compress_old_messages(&mut self, end: usize) -> Vec<ChatMessage>;
|
||||
fn has_compressible_messages(&self, protect_start: usize) -> bool;
|
||||
fn is_compressing(&self) -> bool;
|
||||
fn set_compressing(&mut self, v: bool);
|
||||
|
||||
// ── 生命周期 ──
|
||||
fn restore_from_messages(&mut self, messages: Vec<ChatMessage>);
|
||||
fn take_topic_marker(&mut self) -> Option<String>;
|
||||
fn messages_mut(&mut self) -> &mut [TrackedMessage];
|
||||
fn iter(&self) -> Box<dyn Iterator<Item = &ChatMessage> + '_>;
|
||||
}
|
||||
|
||||
/// ContextManager 作为默认 MemoryAdapter 实现
|
||||
impl MemoryAdapter for ContextManager {
|
||||
fn push(&mut self, msg: ChatMessage) { self.push(msg); }
|
||||
fn clear(&mut self) { self.clear(); }
|
||||
fn insert_at(&mut self, index: usize, message: ChatMessage) { self.insert_at(index, message); }
|
||||
fn replace_tool_result_content(&mut self, tool_call_id: &str, new_content: &str) -> bool {
|
||||
self.replace_tool_result_content(tool_call_id, new_content)
|
||||
}
|
||||
fn pop_last_assistant_round(&mut self) -> bool { self.pop_last_assistant_round() }
|
||||
fn truncate_after_user_message(&mut self, target_content: &str) -> Result<usize, ()> {
|
||||
self.truncate_after_user_message(target_content)
|
||||
}
|
||||
fn replace_last_active_user_content(&mut self, new_content: &str) -> Result<(), ()> {
|
||||
self.replace_last_active_user_content(new_content)
|
||||
}
|
||||
|
||||
fn build_for_request(&self, sys_tokens: u32) -> (Vec<ChatMessage>, bool) {
|
||||
self.build_for_request(sys_tokens)
|
||||
}
|
||||
fn all_messages_clone(&self) -> Vec<ChatMessage> { self.all_messages_clone() }
|
||||
fn len(&self) -> usize { self.len() }
|
||||
fn is_empty(&self) -> bool { self.is_empty() }
|
||||
fn history_tokens(&self) -> u32 { self.history_tokens() }
|
||||
fn budget_limit(&self) -> u32 { self.budget_limit() }
|
||||
fn config(&self) -> &ContextConfig { self.config() }
|
||||
|
||||
fn compress_old_messages(&mut self, end: usize) -> Vec<ChatMessage> { self.compress_old_messages(end) }
|
||||
fn has_compressible_messages(&self, protect_start: usize) -> bool { self.has_compressible_messages(protect_start) }
|
||||
fn is_compressing(&self) -> bool { self.is_compressing() }
|
||||
fn set_compressing(&mut self, v: bool) { self.set_compressing(v); }
|
||||
|
||||
fn restore_from_messages(&mut self, messages: Vec<ChatMessage>) { self.restore_from_messages(messages); }
|
||||
fn take_topic_marker(&mut self) -> Option<String> { self.take_topic_marker() }
|
||||
fn messages_mut(&mut self) -> &mut [TrackedMessage] { self.messages_mut() }
|
||||
fn iter(&self) -> Box<dyn Iterator<Item = &ChatMessage> + '_> {
|
||||
Box::new(self.iter())
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 厂商模型列表拉取 — F-01 阶段3
|
||||
//! 厂商模型列表拉取。
|
||||
//!
|
||||
//! 按 `provider_type` 分派拉取厂商模型列表,过滤非 chat 模型,返回模型名 Vec。
|
||||
//! `fetch_and_probe` 在拉取基础上对每个模型名调 `model_probe::probe` 探测出完整 `ModelConfig`。
|
||||
@@ -18,6 +18,7 @@ use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use df_ai_core::model::ModelConfig;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::model_fetch_helpers::{build_models_url, filter_chat_models, ModelsList};
|
||||
use crate::model_probe::probe;
|
||||
@@ -83,12 +84,15 @@ async fn fetch_openai_compat(base_url: &str, api_key: &str) -> Result<Vec<String
|
||||
}
|
||||
|
||||
// OpenAI 响应:`{data:[{id, owned_by, ...}]}`。中转站通常同构。
|
||||
let body: ModelsList = resp
|
||||
.json()
|
||||
// 不用 resp.json():reqwest::Error::Decode 的 Display 吞 serde 详情(只给 "error decoding
|
||||
// response body"),SenseNova 等厂商解析失败时无法定位根因。改 text() + serde_json::from_str,
|
||||
// 解析失败时 serde_json::Error 含具体 field/type/position;再叠加宽松 Value fallback 兜底。
|
||||
let body = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| anyhow!("openai_compat 响应解析失败({url}):{e}"))?;
|
||||
.map_err(|e| anyhow!("openai_compat 读取响应体失败({url}):{e}"))?;
|
||||
|
||||
Ok(filter_chat_models(body.into_ids()))
|
||||
Ok(filter_chat_models(parse_models_compat("openai_compat", &url, &body)?))
|
||||
}
|
||||
|
||||
/// Anthropic 兼容(Claude 官方 / GLM 订阅端点):`GET /v1/models`,x-api-key + anthropic-version 鉴权。
|
||||
@@ -112,12 +116,93 @@ async fn fetch_anthropic_compat(base_url: &str, api_key: &str) -> Result<Vec<Str
|
||||
// Anthropic 响应:`{data:[{id, display_name, type, ...}]}`(has_more 分页字段忽略)。
|
||||
// 兼容兜底:`{models:[{name, ...}]}`(Ollama 风格,理论 anthropic_compat 不会命中,
|
||||
// 但中转站行为不可控,用 `#[serde(alias)]` 零成本兜底 — 见 issues)。
|
||||
let body: ModelsList = resp
|
||||
.json()
|
||||
// 与 openai_compat 同:text() + 严格 serde + Value 宽松 fallback,见 parse_models_compat。
|
||||
let body = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| anyhow!("anthropic_compat 响应解析失败({url}):{e}"))?;
|
||||
.map_err(|e| anyhow!("anthropic_compat 读取响应体失败({url}):{e}"))?;
|
||||
|
||||
Ok(filter_chat_models(body.into_ids()))
|
||||
Ok(filter_chat_models(parse_models_compat("anthropic_compat", &url, &body)?))
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// 响应解析(text → 严格 serde → Value 宽松 fallback)
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// 响应体诊断片段最大字符数。完整 body 可能巨大,日志只取前缀定位结构。
|
||||
const BODY_DIAGNOSTIC_CHARS: usize = 200;
|
||||
|
||||
/// 解析厂商 `/v1/models` 响应体,返回模型 id 列表(过滤前)。
|
||||
///
|
||||
/// 三层解析(诊断优先,兜底保成功):
|
||||
/// 1. **严格**:`serde_json::from_str::<ModelsList>` — 标准结构命中,错误信息含具体
|
||||
/// field/type/position(serde_json::Error Display 自带 line/column,不丢 detail)。
|
||||
/// 2. **宽松 fallback**:`serde_json::Value` 解析 → 取 `data` / `models` 任一数组 →
|
||||
/// 遍历项取 `id` / `name` 字符串。容错厂商额外字段、类型变体(如 id 漏成 number)。
|
||||
/// 3. **诊断错误**:严格 + 宽松都失败时,返回含 HTTP 标识 + serde detail + body 前缀
|
||||
/// 的友好错误,而非 reqwest 默认 "error decoding response body"。
|
||||
///
|
||||
/// 注:fallback 只取 id/name(模型名),丢弃 ModelEntry 上的其他字段 — 厂商变体下
|
||||
/// 我们关心的就是模型名,ModelsList 本身也只消费 id/name,语义对齐。
|
||||
fn parse_models_compat(provider_type: &str, url: &str, body: &str) -> Result<Vec<String>> {
|
||||
// 1) 严格解析(标准结构,serde 错误 detail 完整)。
|
||||
match serde_json::from_str::<ModelsList>(body) {
|
||||
Ok(list) => return Ok(list.into_ids()),
|
||||
Err(strict_err) => {
|
||||
// 2) 宽松 Value fallback — 不依赖 ModelsList 结构,容错厂商变体。
|
||||
if let Some(ids) = parse_ids_loose(body) {
|
||||
return Ok(ids);
|
||||
}
|
||||
// 3) 双双失败:叠 HTTP 标识 + serde detail + body 前缀诊断。
|
||||
return Err(anyhow!(
|
||||
"{provider_type} 响应解析失败({url}):{strict_err} | body 前缀:{}",
|
||||
body_preview(body)
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 用 `serde_json::Value` 宽松提取模型 id/name。失败(非 JSON / 无 data / 无 id)返回 None。
|
||||
///
|
||||
/// 取数组字段优先级:`data`(OpenAI/Anthropic)→ `models`(Ollama 风格 alias)。
|
||||
/// 项里取 `id` → 兜底 `name`,只接受字符串值(number/bool 等跳过)。
|
||||
fn parse_ids_loose(body: &str) -> Option<Vec<String>> {
|
||||
let val: Value = serde_json::from_str(body).ok()?;
|
||||
let obj = val.as_object()?;
|
||||
// 任一存在即取;data 优先(标准结构)。
|
||||
let arr = obj.get("data").or_else(|| obj.get("models"))?;
|
||||
let arr = arr.as_array()?;
|
||||
let mut ids = Vec::with_capacity(arr.len());
|
||||
for item in arr {
|
||||
let id = item
|
||||
.get("id")
|
||||
.or_else(|| item.get("name"))
|
||||
.and_then(|v| v.as_str());
|
||||
if let Some(id) = id {
|
||||
ids.push(id.to_string());
|
||||
}
|
||||
}
|
||||
Some(ids)
|
||||
}
|
||||
|
||||
/// body 前缀诊断(截断 + 控制字符占位,避免换行/制表符污染日志单行)。
|
||||
fn body_preview(body: &str) -> String {
|
||||
let prefix: String = body.chars().take(BODY_DIAGNOSTIC_CHARS).collect();
|
||||
if prefix.chars().all(|c| c.is_control()) && !prefix.is_empty() {
|
||||
// 整段控制字符(二进制?)→ 给长度提示而非乱码。
|
||||
return format!("<非文本 body,长度 {}>", body.len());
|
||||
}
|
||||
let truncated = body.chars().count() > BODY_DIAGNOSTIC_CHARS;
|
||||
// 把控制字符(换行/制表等)压成空格,保持日志单行可读。
|
||||
let cleaned: String = prefix
|
||||
.chars()
|
||||
.map(|c| if c.is_control() { ' ' } else { c })
|
||||
.collect();
|
||||
if truncated {
|
||||
format!("{cleaned}…")
|
||||
} else {
|
||||
cleaned
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
@@ -173,4 +258,131 @@ mod tests {
|
||||
assert!(msg.contains("ollama"), "err={msg}");
|
||||
assert!(msg.contains("provider_type"), "err={msg}");
|
||||
}
|
||||
|
||||
// ── parse_models_compat:严格 / fallback / 诊断三层 ──
|
||||
|
||||
#[test]
|
||||
fn parse_strict_openai_format() {
|
||||
// 标准 OpenAI 结构 → 严格解析命中,不进 fallback
|
||||
let body = r#"{"data":[{"id":"gpt-4o","owned_by":"openai"},{"id":"gpt-4o-mini"}]}"#;
|
||||
let ids = parse_models_compat("openai_compat", "http://x/v1/models", body).unwrap();
|
||||
assert_eq!(ids, vec!["gpt-4o", "gpt-4o-mini"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_loose_fallback_on_unknown_field_type_variant() {
|
||||
// 厂商变体:data 项里多了非标准字段、且某项漏 id → 严格可能仍过(serde default),
|
||||
// 此用例构造严格失败 + 宽松应成功:id 字段为 number(非字符串)致 ModelEntry serde 失败。
|
||||
// 宽松 fallback 应:跳过 number id,保留 string id。
|
||||
let body = r#"{"data":[{"id":12345},{"id":"glm-4-flash"}]}"#;
|
||||
// 严格 ModelsList 的 id: Option<String>,number 12345 无法反序列化为 String → 失败
|
||||
let ids = parse_models_compat("openai_compat", "http://x/v1/models", body).unwrap();
|
||||
assert_eq!(ids, vec!["glm-4-flash"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_loose_fallback_via_models_alias() {
|
||||
// 严格解析缺 data 字段时进 fallback,走 models alias 取 name
|
||||
let body = r#"{"models":[{"name":"llama3:8b"},{"name":"qwen2:7b"}]}"#;
|
||||
let ids = parse_models_compat("openai_compat", "http://x/v1/models", body).unwrap();
|
||||
assert_eq!(ids, vec!["llama3:8b", "qwen2:7b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_loose_fallback_tolerates_extra_top_level_fields() {
|
||||
// 宽松 fallback 应容错顶层额外字段、非 id 项(只关心 data[].id/name)
|
||||
let body = r#"{"object":"list","data":[{"id":"deepseek-chat","object":"model"},{"id":"deepseek-coder"}],"supported_ids":["x"]}"#;
|
||||
let ids = parse_models_compat("openai_compat", "http://x/v1/models", body).unwrap();
|
||||
assert_eq!(ids, vec!["deepseek-chat", "deepseek-coder"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_diagnostic_error_has_serde_detail_and_body_prefix() {
|
||||
// 完全无法解析(非 JSON)→ 严格 + 宽松双失败 → 错误含 serde detail + body 前缀 + HTTP 标识
|
||||
let body = "this is not json at all {{{";
|
||||
let err = parse_models_compat("openai_compat", "http://x/v1/models", body).unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
// provider_type 标识
|
||||
assert!(msg.contains("openai_compat"), "err={msg}");
|
||||
// url 便于定位
|
||||
assert!(msg.contains("http://x/v1/models"), "err={msg}");
|
||||
// serde detail(serde_json 错误含 line/column 或 expected 字样)
|
||||
assert!(
|
||||
msg.contains("line") || msg.contains("column") || msg.contains("expected"),
|
||||
"err={msg}"
|
||||
);
|
||||
// body 前缀诊断片段
|
||||
assert!(msg.contains("this is not json"), "err={msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_diagnostic_truncates_long_body() {
|
||||
// 超长 body → 前缀截断(… 标记),不整段灌进错误信息
|
||||
let long_id = "a".repeat(500);
|
||||
let body = format!(r#"{{"garbage":"{long_id}""#); // 缺尾 → 非 JSON
|
||||
let err = parse_models_compat("openai_compat", "http://x/v1/models", &body).unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(msg.contains("…"), "长 body 应截断(err={})\n{}", msg.len(), msg);
|
||||
// 诊断片段不应超过 BODY_DIAGNOSTIC_CHARS + 容差
|
||||
assert!(
|
||||
msg.len() < long_id.len(),
|
||||
"错误信息不应含完整 500 字符 body"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_diagnostic_empty_body() {
|
||||
// 空 body → 双失败,错误信息不 panic、含 provider 标识
|
||||
let err = parse_models_compat("openai_compat", "http://x/v1/models", "").unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(msg.contains("openai_compat"), "err={msg}");
|
||||
assert!(msg.contains("解析失败"), "err={msg}");
|
||||
}
|
||||
|
||||
// ── parse_ids_loose:边界 ──
|
||||
|
||||
#[test]
|
||||
fn parse_ids_loose_returns_none_on_non_json() {
|
||||
assert!(parse_ids_loose("not json").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ids_loose_returns_none_on_missing_data_field() {
|
||||
// 合法 JSON 但无 data/models → None(parse_models_compat 会进而报诊断错误)
|
||||
assert!(parse_ids_loose(r#"{"foo":"bar"}"#).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ids_loose_data_not_array_returns_none() {
|
||||
// data 存在但非数组 → None
|
||||
assert!(parse_ids_loose(r#"{"data":"not-an-array"}"#).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ids_loose_skips_non_string_id() {
|
||||
// id 为 number/null/object → 跳过,只留字符串 id
|
||||
let body = r#"{"data":[{"id":1},{"id":null},{"id":"keep-me"},{"name":"named"}]}"#;
|
||||
let ids = parse_ids_loose(body).unwrap();
|
||||
assert_eq!(ids, vec!["keep-me", "named"]);
|
||||
}
|
||||
|
||||
// ── body_preview:控制字符 + 截断 ──
|
||||
|
||||
#[test]
|
||||
fn body_preview_replaces_control_chars_with_space() {
|
||||
// 换行/制表压成空格,保持日志单行
|
||||
let preview = body_preview("line1\nline2\tcol");
|
||||
assert!(!preview.contains('\n'), "preview={preview}");
|
||||
assert!(!preview.contains('\t'), "preview={preview}");
|
||||
assert!(preview.contains("line1"), "preview={preview}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_preview_truncates_with_ellipsis() {
|
||||
let body = "abcdefghij".repeat(100); // 1000 chars
|
||||
let preview = body_preview(&body);
|
||||
assert!(preview.ends_with('…'), "preview should end with ellipsis");
|
||||
// 不应含完整 body
|
||||
assert!(preview.len() < body.len());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ pub fn build_models_url(base_url: &str) -> String {
|
||||
/// 判断是否为非 chat 模型(应从列表中剔除)。
|
||||
///
|
||||
/// 规则(设计 §5.4 + 合理扩展,见 issues):
|
||||
/// - 图片生成:dall-e / midjourney / stable-diffusion / imagen
|
||||
/// - 图片生成:dall-e / midjourney / stable-diffusion / imagen / sensenova-u1 / infographic / image-generation
|
||||
/// - 语音:tts / whisper / audio / speech / voice(语音合成/识别)
|
||||
/// - 实时:realtime(OpenAI Realtime API 语音对话,非 chat completions)
|
||||
/// - 转写:transcribe / transcription
|
||||
@@ -50,6 +50,9 @@ pub fn build_models_url(base_url: &str) -> String {
|
||||
///
|
||||
/// embedding 设计 §5.4 注释「保留(知识库需要)」— 此处遵循设计保留 embedding,
|
||||
/// 不在 `is_non_chat_model` 剔除(知识库 embedding 路由用得着)。
|
||||
///
|
||||
/// 注:`sensenova-u1` 用厂商前缀限定而非裸 `u1`,避免误伤未来含 `u1` 词素的 chat 模型
|
||||
/// (SenseNova U1 系列走 `/v1/images/generations` 端点,非 chat completions)。
|
||||
pub fn is_non_chat_model(id: &str) -> bool {
|
||||
let id = id.to_lowercase();
|
||||
// 图片生成
|
||||
@@ -57,6 +60,11 @@ pub fn is_non_chat_model(id: &str) -> bool {
|
||||
|| id.contains("midjourney")
|
||||
|| id.contains("stable-diffusion")
|
||||
|| id.contains("imagen")
|
||||
// SenseNova U1 系列(图像生成,如 sensenova-u1-fast,走 /v1/images/generations 非 chat)
|
||||
|| id.contains("sensenova-u1")
|
||||
// 通用图像生成命名(infographic 海报生成 / image-generation 直白命名)
|
||||
|| id.contains("infographic")
|
||||
|| id.contains("image-generation")
|
||||
// 语音(tts 合成 / whisper 识别 / 通用 audio / speech / voice)
|
||||
|| id.contains("tts")
|
||||
|| id.contains("whisper")
|
||||
@@ -226,6 +234,24 @@ mod tests {
|
||||
assert!(is_non_chat_model("stable-diffusion-xl"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_chat_sensenova_u1_image_filtered() {
|
||||
// SenseNova U1 系列是图像生成模型(走 /v1/images/generations,非 chat completions),
|
||||
// /v1/models 会返回但不应留为 chat 模型,否则用户选它对话会失败。
|
||||
assert!(is_non_chat_model("sensenova-u1-fast"));
|
||||
assert!(is_non_chat_model("SenseNova-U1-Fast")); // 大小写无关
|
||||
assert!(is_non_chat_model("sensenova-u1-pro"));
|
||||
// 通用图像生成命名也剔
|
||||
assert!(is_non_chat_model("infographic-v1"));
|
||||
assert!(is_non_chat_model("image-generation-latest"));
|
||||
// 对照组:同厂商的 chat 模型 sensenova-6.7-flash-lite 不应被误剔
|
||||
// (它是原生多模态 chat,见 model_probe.rs 预设表)
|
||||
assert!(
|
||||
!is_non_chat_model("sensenova-6.7-flash-lite"),
|
||||
"sensenova-6.7-flash-lite 是 chat 模型,不应被当图像生成剔除"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_chat_speech_filtered() {
|
||||
assert!(is_non_chat_model("tts-1"));
|
||||
|
||||
+148
-12
@@ -1,10 +1,10 @@
|
||||
//! 模型探测器 — F-01 阶段2
|
||||
//! 模型探测器。
|
||||
//!
|
||||
//! 给定模型名,产出完整 `ModelConfig`(4 维度 + 路由控制 + 探测来源标注)。
|
||||
//!
|
||||
//! 多源探测,高优先源命中即返(短路):
|
||||
//! 1. 内置预设表精确匹配(name 完全相等) → `ProbeSource::PresetTable`
|
||||
//! 2. 内置预设表模糊匹配(子串包含) → `ProbeSource::PresetTable`
|
||||
//! 2. 内置预设表前缀匹配(前缀 + 分隔符边界) → `ProbeSource::PresetTable`
|
||||
//! 3. 模型名启发式推断(命名模式) → `ProbeSource::Heuristic`
|
||||
//! 4. 默认值兜底(`ModelConfig::with_defaults`) → `ProbeSource::Default`
|
||||
//!
|
||||
@@ -26,7 +26,7 @@ use crate::model_probe_helpers::{heuristic_infer, presets};
|
||||
///
|
||||
/// 多源探测顺序(高优先源命中即返):
|
||||
/// 1. 预设表精确匹配(`model_id` 完全相等,大小写敏感)
|
||||
/// 2. 预设表模糊匹配(`model_id` 双向子串包含,大小写不敏感)
|
||||
/// 2. 预设表前缀匹配(`model_id` 是入参前缀且后缀以分隔符开始,大小写不敏感)
|
||||
/// 3. 启发式推断(模型名命名模式)
|
||||
/// 4. 默认值兜底
|
||||
///
|
||||
@@ -38,14 +38,27 @@ pub fn probe(model_id: &str) -> ModelConfig {
|
||||
return hit;
|
||||
}
|
||||
|
||||
// 2. 预设表模糊匹配(双向子串包含,大小写不敏感)
|
||||
// 多个候选命中时,选预设 model_id 最长者(最具体:glm-4v > glm-4)。
|
||||
// 2. 预设表前缀匹配(入参名以预设名开头 + 后缀以分隔符开始,大小写不敏感)
|
||||
// 收紧自原「双向子串包含」:子串匹配会把 glm-4.6v 误配给 glm-4v(继承其 vision),
|
||||
// gpt-4o-mini 误配给 gpt-4o 等「张冠李戴」。前缀匹配保留合理继承
|
||||
// (glm-4v-flash → glm-4v),同时要求后缀以分隔符(-/./_)或结尾开始,
|
||||
// 防止 glm-4v2/glm-4vx 命中 glm-4v。
|
||||
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))
|
||||
if cand.is_empty() || cand.len() > needle.len() {
|
||||
return false;
|
||||
}
|
||||
if !needle.starts_with(&cand) {
|
||||
return false;
|
||||
}
|
||||
// 前缀后须为分隔符或字符串结束,避免部分单词命中(glm-4v2 不算 glm-4v)
|
||||
match needle[cand.len()..].chars().next() {
|
||||
None => true, // 完全相等(精确匹配已覆盖,这里兜底)
|
||||
Some(c) => matches!(c, '-' | '.' | '_'),
|
||||
}
|
||||
})
|
||||
.max_by_key(|m| m.model_id.len());
|
||||
|
||||
@@ -100,7 +113,7 @@ mod tests {
|
||||
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 兜底中性值
|
||||
// 预设表不写死 cost_tier/intelligence,由 serde default 兜底中性值
|
||||
assert_eq!(m.intelligence, IntelligenceTier::Standard);
|
||||
assert_eq!(m.cost_tier, CostTier::Medium);
|
||||
}
|
||||
@@ -130,20 +143,113 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── 预设模糊匹配 ──
|
||||
// ── 预设表:已知多模态模型(命名无 vision 词素,靠预设显式标 vision) ──
|
||||
// SenseNova flash-lite 原生多模态(商汤文档:支持图像输入理解),
|
||||
// 但模型名不含 vision/vl/-v 词素,启发式 has_vision_token 无法识别 →
|
||||
// 必须靠预设表显式标 vision,否则 modalities 漏 vision 致路由误判。
|
||||
#[test]
|
||||
fn probe_preset_sensenova_flash_lite_has_vision() {
|
||||
let m = probe("sensenova-6.7-flash-lite");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
|
||||
assert!(
|
||||
m.modalities.contains(&Modality::Vision),
|
||||
"sensenova-6.7-flash-lite 应有 Vision(原生多模态): {:?}",
|
||||
m.modalities
|
||||
);
|
||||
assert!(m.modalities.contains(&Modality::Text));
|
||||
// context_window 应为预设的 256K
|
||||
assert_eq!(m.context_window, 262144);
|
||||
// P1 温度:商汤 sensenova 仅接受 temperature=1.0,透传其它值(标题 0.3/压缩 0.2)
|
||||
// → 上游 HTTP 400(实测 9 次)。预设表须标 temperature_supported=false,
|
||||
// 协议转换层据此省略该字段。
|
||||
assert!(
|
||||
!m.temperature_supported,
|
||||
"sensenova 应标 temperature_supported=false(仅接受 1.0)"
|
||||
);
|
||||
}
|
||||
|
||||
/// 温度支持判定:仅 sensenova 系列为 false,其余模型默认 true(零回归)。
|
||||
#[test]
|
||||
fn probe_temperature_supported_defaults_true_except_sensenova() {
|
||||
assert!(!probe("sensenova-6.7-flash-lite").temperature_supported);
|
||||
// 启发式路径(非预设表):sensenova 前缀仍判 false
|
||||
assert!(!probe("sensenova-unknown-model").temperature_supported);
|
||||
// 常规模型保持 true(原样透传)
|
||||
assert!(probe("deepseek-v4-flash").temperature_supported);
|
||||
assert!(probe("glm-4v").temperature_supported);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_preset_deepseek_v4_flash_text_only() {
|
||||
// SenseNova 上的 deepseek-v4-flash:纯文本对话 + 1M 上下文,无 vision
|
||||
let m = probe("deepseek-v4-flash");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
|
||||
assert_eq!(m.modalities, vec![Modality::Text]);
|
||||
assert_eq!(m.context_window, 1_048_576);
|
||||
}
|
||||
|
||||
// ── 预设前缀匹配 ──
|
||||
|
||||
#[test]
|
||||
fn probe_preset_fuzzy_match_glm4v_variant() {
|
||||
// "glm-4v-x" 不在预设表精确命中,但 "glm-4v" 是其子串 → 模糊命中
|
||||
// "glm-4v-x" 不在预设表精确命中,但 "glm-4v" 是其前缀(后缀 "-x" 以分隔符开始) → 前缀命中
|
||||
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_eq!(m.model_id, "glm-4v-x", "前缀命中后 model_id 应用入参名");
|
||||
assert!(
|
||||
m.modalities.contains(&Modality::Vision),
|
||||
"应继承 glm-4v 的 vision 模态"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_preset_prefix_match_glm4v_flash() {
|
||||
// 合理继承:glm-4v-flash 以 glm-4v 为前缀 + 分隔符 -,命中并继承 vision
|
||||
let m = probe("glm-4v-flash");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
|
||||
assert!(m.modalities.contains(&Modality::Vision));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_preset_prefix_match_gpt4o_mini() {
|
||||
// 合理继承:gpt-4o-mini 以 gpt-4o 为前缀 + 分隔符 -
|
||||
let m = probe("gpt-4o-mini");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
|
||||
assert_eq!(m.model_id, "gpt-4o-mini");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_preset_prefix_match_case_insensitive() {
|
||||
// 前缀匹配大小写不敏感(精确匹配大小写敏感,前缀兜底)
|
||||
let m = probe("GLM-4V-FLASH");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
|
||||
assert!(m.modalities.contains(&Modality::Vision));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_preset_prefix_reject_non_prefix_variant() {
|
||||
// 张冠李戴防线:glm-4.6v 不以 glm-4v 为前缀(字符顺序不同),不继承 glm-4v 的 vision。
|
||||
// 但它以 glm-4 为前缀(+ 分隔符 .)→ 命中 glm-4 预设(纯文本),合理。
|
||||
let m = probe("glm-4.6v");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
|
||||
assert_eq!(
|
||||
m.model_id, "glm-4.6v",
|
||||
"前缀命中后 model_id 应用入参名"
|
||||
);
|
||||
assert!(
|
||||
!m.modalities.contains(&Modality::Vision),
|
||||
"glm-4.6v 不应继承 glm-4v 的 vision(只继承 glm-4 纯文本): {:?}",
|
||||
m.modalities
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_preset_prefix_reject_no_separator() {
|
||||
// 分隔符边界:glm-4v2 前缀后是数字(非 -/./_),不算 glm-4v 的合法变体
|
||||
let m = probe("glm-4v2");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
|
||||
}
|
||||
|
||||
// ── 启发式:Vision ──
|
||||
|
||||
#[test]
|
||||
@@ -177,7 +283,7 @@ mod tests {
|
||||
fn heuristic_flash_keeps_neutral_tier() {
|
||||
let m = probe("unknown-flash");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
|
||||
// B-260618-04:cost/intel 一律中性,不靠名字猜
|
||||
// cost/intel 一律中性,不靠名字猜
|
||||
assert_eq!(m.intelligence, IntelligenceTier::Standard);
|
||||
assert_eq!(m.cost_tier, CostTier::Medium);
|
||||
}
|
||||
@@ -245,6 +351,36 @@ mod tests {
|
||||
assert_eq!(m.cost_tier, CostTier::Medium);
|
||||
}
|
||||
|
||||
// ── 启发式:版本号后缀不误判 Vision ──
|
||||
|
||||
#[test]
|
||||
fn heuristic_version_suffix_no_vision() {
|
||||
// "-v" 后接数字属版本号(deepseek-v3 为纯文本模型),不得误判 Vision
|
||||
for name in ["deepseek-v3", "acme-chat-v2", "acme-v12"] {
|
||||
let m = probe(name);
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
|
||||
assert!(
|
||||
!m.modalities.contains(&Modality::Vision),
|
||||
"{name} 版本号后缀不应误判 Vision: {:?}",
|
||||
m.modalities
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heuristic_v_morpheme_tail_still_vision() {
|
||||
// v 词素尾仍判视觉:独立 v 段(qwen-v-plus 形态)与段尾 v(glm-4v 形态,假名避开预设表)
|
||||
for name in ["acme-chat-v", "acme-4v-plus"] {
|
||||
let m = probe(name);
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
|
||||
assert!(
|
||||
m.modalities.contains(&Modality::Vision),
|
||||
"{name} 段尾 v 词素应判 Vision: {:?}",
|
||||
m.modalities
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 启发式:默认(Standard/Medium/Text/ToolUse) ──
|
||||
|
||||
#[test]
|
||||
@@ -261,7 +397,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn probe_preset_beats_heuristic() {
|
||||
// "glm-4-flash" 精确命中预设表;B-260618-04 后预设/启发式档位都中性,
|
||||
// "glm-4-flash" 精确命中预设表;预设/启发式档位都中性,
|
||||
// 此处仅校验 source 标注为 PresetTable
|
||||
let m = probe("glm-4-flash");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 模型探测器 — 纯逻辑子模块(F-01 阶段2)
|
||||
//! 模型探测器 — 纯逻辑子模块。
|
||||
//!
|
||||
//! 从 `model_probe.rs` 抽离的纯函数实现(预设表加载 / 启发式推断 / 词素判定)。
|
||||
//! 无 IO、无外部状态,crate 内经 `model_probe::probe` 间接复用 —
|
||||
@@ -36,7 +36,7 @@ pub(super) fn presets() -> &'static [ModelConfig] {
|
||||
/// 模型名启发式推断。仅推断功能性维度(modalities/capabilities),
|
||||
/// cost_tier / intelligence 一律返中性默认(Medium / Standard)。
|
||||
///
|
||||
/// 取舍:B-260618-04 — 模型名关键词猜档位(flash→Lite、4o→High、pro→Plus)无依据,
|
||||
/// 取舍:模型名关键词猜档位(flash→Lite、4o→High、pro→Plus)无依据,
|
||||
/// 厂商定价/智力与命名无关,瞎填会污染路由器过滤(intelligence >= min / cost_tier <= max)。
|
||||
/// 改中性默认,真实档位由用户手填或更高阶探测源(如厂商 API/定价表)提供。
|
||||
///
|
||||
@@ -49,7 +49,7 @@ 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;
|
||||
|
||||
@@ -74,6 +74,7 @@ pub(super) fn heuristic_infer(model_id: &str) -> ModelConfig {
|
||||
intelligence,
|
||||
weight: 50,
|
||||
context_window: 8192,
|
||||
temperature_supported: temperature_supported_by_name(&name),
|
||||
probe_source: None,
|
||||
};
|
||||
}
|
||||
@@ -106,14 +107,26 @@ pub(super) fn heuristic_infer(model_id: &str) -> ModelConfig {
|
||||
intelligence,
|
||||
weight: 50,
|
||||
context_window: 8192,
|
||||
temperature_supported: temperature_supported_by_name(&name),
|
||||
probe_source: None,
|
||||
}
|
||||
}
|
||||
|
||||
// —— 词素判定助手(命名约定式启发式,集中维护便于增删) ——
|
||||
|
||||
/// Vision 词素:`-v`(尾缀如 glm-4v)、以 `v` 结尾、含 `vl` / `vision`。
|
||||
/// 不直接 `contains('v')`(避免误伤 review/verbal 等)。
|
||||
/// Vision 词素:`vision` / `vl` 子串、`v` 词素尾(任一分隔段以 v 结尾:glm-4v / qwen-v-plus)。
|
||||
/// 裸 "v" 必须是词素尾:`contains("-v")` 会把版本号误判成视觉(deepseek-v3 纯文本,
|
||||
/// "-v" 后接数字属版本号而非视觉词素),按段尾判定后 v3/v2 不命中、4v/v 段仍命中。
|
||||
pub(super) fn has_vision_token(name: &str) -> bool {
|
||||
name.contains("vision") || name.contains("vl") || name.ends_with('v') || name.contains("-v")
|
||||
name.contains("vision")
|
||||
|| name.contains("vl")
|
||||
|| name.split(['-', '.', '_']).any(|seg| seg.ends_with('v'))
|
||||
}
|
||||
|
||||
/// 温度参数支持判定(命名约定式):商汤 sensenova 系列仅接受 temperature=1.0,
|
||||
/// 透传其它温度值(标题 0.3/压缩 0.2 等)→ 上游 HTTP 400(实测 9 次)。
|
||||
/// 返回 false 时协议转换层省略 temperature 字段(交上游默认),避免无效往返;
|
||||
/// 其余模型默认 true(原样透传,零回归)。
|
||||
pub(super) fn temperature_supported_by_name(name: &str) -> bool {
|
||||
!name.starts_with("sensenova")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,535 @@
|
||||
//! 工具命名空间存储 — 大工具结果不进主消息队列
|
||||
//!
|
||||
//! 当工具执行结果超过阈值时,存入 `NamespaceStore`,主队列只保留轻量引用路径。
|
||||
//! 运行时为内存 HashMap(LRU 淘汰),持久化时由调用方展开引用写 DB。
|
||||
//!
|
||||
//! 存储格式: "namespace://tool_name/args_hash"
|
||||
//! 例: namespace://read_file/a1b2c3d4
|
||||
//!
|
||||
//! ## 淘汰治理(2026-10-06,设计文档 namespace淘汰治理-设计-2026-10-04.md,拍板 2/2)
|
||||
//! - **pin 机制**:关键产出(子代理报告等)标记 pinned,LRU 永不淘汰;pin 总字节超
|
||||
//! 池容量 50% 时最旧 pin 降级可淘(拍板 C1,防 pin 滥用撑死池)。
|
||||
//! - **EvictionLog**:淘汰留档(50 条上限),展开点 read_only()=None 时查 Log 给富提示
|
||||
//! (原工具名+内容前 80 字+淘汰时刻),替代裸 EVICTED_PLACEHOLDER。
|
||||
//! - **owner 软隔离**:条目带归属(调用方 conv_id),淘汰顺序=无主最旧→他主未 pin→
|
||||
//! 当前主未 pin;软隔离只影响淘汰顺序,owner 传错退回现状行为,无正确性风险。
|
||||
//! - **零 schema 变更**:纯内存态增强。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::Instant;
|
||||
|
||||
/// 引用路径前缀
|
||||
pub const NAMESPACE_REF_PREFIX: &str = "namespace://";
|
||||
|
||||
/// namespace 条目被 LRU 淘汰(或跨会话残留引用)后,展开点 read_only()=None 时的提示文案。
|
||||
///
|
||||
/// 旧实现 None 分支保留 "namespace://" 字面 URI,LLM 收到无意义串且无告警。
|
||||
/// 现统一替换为此文案:既告知用户结果已淘汰,又提示可重新调用工具取回。
|
||||
/// 治理后:EvictionLog 命中时展开点改用富提示(见 `eviction_rich_notice`),未命中原样。
|
||||
pub const EVICTED_PLACEHOLDER: &str = "[该工具结果已超出内存上限被淘汰,如需请重新调用对应工具]";
|
||||
|
||||
/// namespace 字节阈值(> 2048 bytes 触发)
|
||||
pub const NAMESPACE_BYTE_THRESHOLD: usize = 2048;
|
||||
|
||||
/// namespace 行数阈值(> 50 行 触发)
|
||||
pub const NAMESPACE_LINE_THRESHOLD: usize = 50;
|
||||
|
||||
/// 始终进 namespace 的工具名(其输出几乎总是大结果)
|
||||
pub const ALWAYS_LARGE_TOOLS: &[&str] = &["read_file", "list_directory", "grep", "diff_files"];
|
||||
|
||||
/// EvictionLog 容量上限(条)。单条 ~200B,50 条可忽略。
|
||||
pub const EVICTION_LOG_CAP: usize = 50;
|
||||
|
||||
/// pin 总字节占池容量比例上限。超过后最旧 pin 降级可淘(拍板 C1)。
|
||||
pub const PIN_RATIO_CAP: f64 = 0.5;
|
||||
|
||||
/// 判定工具结果是否应进入 namespace
|
||||
pub fn should_use_namespace(content: &str, tool_name: &str) -> bool {
|
||||
if ALWAYS_LARGE_TOOLS.contains(&tool_name) {
|
||||
return true;
|
||||
}
|
||||
content.len() > NAMESPACE_BYTE_THRESHOLD
|
||||
|| content.lines().count() > NAMESPACE_LINE_THRESHOLD
|
||||
}
|
||||
|
||||
/// 判定 tool_result 内容是否为 namespace 引用路径
|
||||
pub fn is_namespace_ref(content: &str) -> bool {
|
||||
content.starts_with(NAMESPACE_REF_PREFIX)
|
||||
}
|
||||
|
||||
/// 从引用路径中提取内部 key
|
||||
pub fn parse_namespace_key(full_path: &str) -> Option<&str> {
|
||||
full_path.strip_prefix(NAMESPACE_REF_PREFIX)
|
||||
.and_then(|s| s.split('/').nth(1))
|
||||
}
|
||||
|
||||
/// namespace 条目
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NamespaceEntry {
|
||||
pub key: String,
|
||||
pub tool_name: String,
|
||||
pub content: String,
|
||||
pub created_at: Instant,
|
||||
pub access_count: u64,
|
||||
/// pin 标记:LRU 永不淘汰(pin 总量超 50% 容量时最旧 pin 降级,拍板 C1)
|
||||
pub pinned: bool,
|
||||
/// 归属(conv_id / 子代理 id),软隔离淘汰顺序用;None=无主
|
||||
pub owner: Option<String>,
|
||||
/// 预生成的淘汰提示料(工具名+参数摘要,store 时固化),淘汰留档用
|
||||
pub evict_hint: String,
|
||||
}
|
||||
|
||||
/// 淘汰留档记录(EvictionLog 元素)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EvictionRecord {
|
||||
pub key: String,
|
||||
pub tool_name: String,
|
||||
/// 内容前 80 字符(hint),供富提示识别原文
|
||||
pub hint: String,
|
||||
pub evicted_at: Instant,
|
||||
pub owner: Option<String>,
|
||||
}
|
||||
|
||||
/// namespace 存储(内存 HashMap + LRU 淘汰 + pin/EvictionLog/owner 治理)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NamespaceStore {
|
||||
entries: HashMap<String, NamespaceEntry>,
|
||||
max_bytes: usize,
|
||||
current_bytes: usize,
|
||||
/// 淘汰留档(VecDeque 语义,容量 EVICTION_LOG_CAP)
|
||||
eviction_log: std::collections::VecDeque<EvictionRecord>,
|
||||
}
|
||||
|
||||
impl NamespaceStore {
|
||||
/// 创建 namespace 存储
|
||||
pub fn new(max_bytes: usize) -> Self {
|
||||
Self {
|
||||
entries: HashMap::new(),
|
||||
max_bytes,
|
||||
current_bytes: 0,
|
||||
eviction_log: std::collections::VecDeque::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 默认大小:10MB
|
||||
pub fn default() -> Self {
|
||||
Self::new(10 * 1024 * 1024)
|
||||
}
|
||||
|
||||
/// 存入 namespace。返回引用路径字符串。(兼容入口,owner=None 无主)
|
||||
///
|
||||
/// 超限行为分两档:常规超限 LRU 淘汰旧条目腾位(既有语义);单条本身超过
|
||||
/// max_bytes 时淘汰清空也放不下,拒绝入库只返回引用路径——展开点对
|
||||
/// read_only()=None 统一落 EVICTED_PLACEHOLDER,与 LRU 淘汰后的不可解析引用同语义。
|
||||
pub fn store(&mut self, tool_name: &str, content: &str) -> String {
|
||||
self.store_with_owner(tool_name, content, None)
|
||||
}
|
||||
|
||||
/// 存入(带归属)。owner 软隔离:淘汰优先淘无主/他主,当前主最后。
|
||||
pub fn store_with_owner(&mut self, tool_name: &str, content: &str, owner: Option<&str>) -> String {
|
||||
self.store_inner(tool_name, content, owner, false)
|
||||
}
|
||||
|
||||
/// 存入并 pin(关键产出:子代理报告等)。pin 条目 LRU 永不淘汰,
|
||||
/// pin 总字节超池 50% 时最旧 pin 降级可淘(在 evict_if_needed 内兜底)。
|
||||
pub fn store_pinned(&mut self, tool_name: &str, content: &str, owner: Option<&str>) -> String {
|
||||
self.store_inner(tool_name, content, owner, true)
|
||||
}
|
||||
|
||||
fn store_inner(&mut self, tool_name: &str, content: &str, owner: Option<&str>, pinned: bool) -> String {
|
||||
let key = simple_hash(content);
|
||||
let path = format!("{}{}/{}", NAMESPACE_REF_PREFIX, tool_name, key);
|
||||
|
||||
// 已存在 → 刷新 access_count,不重复占用空间;若本次要求 pin,升级既有条目
|
||||
if self.entries.contains_key(&key) {
|
||||
if let Some(entry) = self.entries.get_mut(&key) {
|
||||
entry.access_count += 1;
|
||||
if pinned && !entry.pinned {
|
||||
entry.pinned = true;
|
||||
}
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
let new_bytes = content.len();
|
||||
|
||||
// 单条超过总容量:硬入库会把 current_bytes 永久顶破上限(evict 清空后仍无条件
|
||||
// insert 的旧路径),拒绝入库保住 current_bytes <= max_bytes 不变式
|
||||
if new_bytes > self.max_bytes {
|
||||
return path;
|
||||
}
|
||||
|
||||
// pin 比例上限(拍板 C1):pinned 入库前无条件校验,把 pin 总量压回 50% 内
|
||||
// (最旧 pin 先降级),不只在池溢出时——防 pin 静默累积到失控
|
||||
if pinned {
|
||||
let cap = (self.max_bytes as f64 * PIN_RATIO_CAP) as usize;
|
||||
while self.pinned_bytes() + new_bytes > cap {
|
||||
if !self.demote_oldest_pin() { break; }
|
||||
}
|
||||
}
|
||||
|
||||
// 检查容量,超限淘汰
|
||||
self.evict_if_needed(new_bytes);
|
||||
|
||||
// 淘汰后仍放不下(全 pin 撑满):放弃入库(返回引用路径,展开点落占位),
|
||||
// 不降级 pin——pin 保护是强语义,普通 store 不得侵蚀 pin
|
||||
if self.current_bytes + new_bytes > self.max_bytes && !pinned {
|
||||
return path;
|
||||
}
|
||||
|
||||
let entry = NamespaceEntry {
|
||||
key: key.clone(),
|
||||
tool_name: tool_name.to_string(),
|
||||
content: content.to_string(),
|
||||
created_at: Instant::now(),
|
||||
access_count: 1,
|
||||
pinned,
|
||||
owner: owner.map(str::to_string),
|
||||
evict_hint: content.chars().take(80).collect(),
|
||||
};
|
||||
|
||||
self.current_bytes += new_bytes;
|
||||
self.entries.insert(key, entry);
|
||||
path
|
||||
}
|
||||
|
||||
/// pin/取消 pin(事后)。pin 总量超 50% 容量时本调用拒绝并返回 false(防失控,拍板 C1)。
|
||||
pub fn pin(&mut self, full_path: &str) -> bool {
|
||||
let Some(key) = parse_namespace_key(full_path) else { return false };
|
||||
if !self.entries.contains_key(key) {
|
||||
return false;
|
||||
}
|
||||
if self.pinned_bytes() + self.entries.get(key).map(|e| e.content.len()).unwrap_or(0)
|
||||
> (self.max_bytes as f64 * PIN_RATIO_CAP) as usize
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(entry) = self.entries.get_mut(key) {
|
||||
entry.pinned = true;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub fn unpin(&mut self, full_path: &str) {
|
||||
if let Some(key) = parse_namespace_key(full_path) {
|
||||
if let Some(entry) = self.entries.get_mut(key) {
|
||||
entry.pinned = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// pin 条目总字节
|
||||
pub fn pinned_bytes(&self) -> usize {
|
||||
self.entries.values().filter(|e| e.pinned).map(|e| e.content.len()).sum()
|
||||
}
|
||||
|
||||
/// 降级最旧 pin。返回是否有降级发生(全 pin 已降无可降返回 false)。
|
||||
fn demote_oldest_pin(&mut self) -> bool {
|
||||
let oldest_pin = self.entries.iter()
|
||||
.filter(|(_, e)| e.pinned)
|
||||
.min_by_key(|(_, e)| e.created_at)
|
||||
.map(|(k, _)| k.clone());
|
||||
if let Some(key) = oldest_pin {
|
||||
if let Some(entry) = self.entries.get_mut(&key) {
|
||||
entry.pinned = false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 读取完整内容
|
||||
pub fn read(&mut self, full_path: &str) -> Option<&str> {
|
||||
let key = parse_namespace_key(full_path)?;
|
||||
let entry = self.entries.get_mut(key)?;
|
||||
entry.access_count += 1;
|
||||
Some(entry.content.as_str())
|
||||
}
|
||||
|
||||
/// 读取(只读,不改 access_count)
|
||||
pub fn read_only(&self, full_path: &str) -> Option<&str> {
|
||||
let key = parse_namespace_key(full_path)?;
|
||||
self.entries.get(key).map(|e| e.content.as_str())
|
||||
}
|
||||
|
||||
/// 展开失败的富提示:查 EvictionLog,命中返回「原为 X 前 80 字…于淘汰,可重新调用」,
|
||||
/// 未命中(跨会话残留/未知 key)返回 None,展开点落回 EVICTED_PLACEHOLDER。
|
||||
pub fn eviction_rich_notice(&self, full_path: &str) -> Option<String> {
|
||||
let key = parse_namespace_key(full_path)?;
|
||||
let rec = self.eviction_log.iter().find(|r| r.key == key)?;
|
||||
Some(format!(
|
||||
"[结果已淘汰] 原为 {}(\"{}\"…) 因内存容量淘汰。可重新调用 {} 取回原文。",
|
||||
rec.tool_name, rec.hint, rec.tool_name,
|
||||
))
|
||||
}
|
||||
|
||||
/// 检查 key 是否存在
|
||||
pub fn contains(&self, full_path: &str) -> bool {
|
||||
parse_namespace_key(full_path)
|
||||
.and_then(|k| self.entries.get(k))
|
||||
.is_some()
|
||||
}
|
||||
|
||||
/// 条目数
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
/// LRU 淘汰:owner 软隔离三段顺序(设计 §3.4):
|
||||
/// ①无主且未 pin 的最旧 → ②他主(非最后入参 owner)未 pin 的最旧 → ③当前主未 pin 最旧。
|
||||
/// pin 永不淘(除非 store_pinned 路径 demote 降级)。needed 所需空间,cur_owner=当前
|
||||
/// 存入方归属(淘汰保护对象,None=无主视角)。
|
||||
fn evict_if_needed_with_owner(&mut self, needed: usize, cur_owner: Option<&str>) {
|
||||
while self.current_bytes + needed > self.max_bytes && !self.entries.is_empty() {
|
||||
// 三段淘汰顺序:无主最旧 → 他主最旧 → 当前主最旧(各段均跳过 pinned)
|
||||
let oldest_key = self.entries.iter()
|
||||
.filter(|(_, e)| !e.pinned && e.owner.is_none())
|
||||
.min_by_key(|(_, e)| e.created_at)
|
||||
.map(|(k, _)| k.clone())
|
||||
.or_else(|| {
|
||||
self.entries.iter()
|
||||
.filter(|(_, e)| {
|
||||
!e.pinned && match (&e.owner, cur_owner) {
|
||||
(Some(o), Some(c)) => o != c,
|
||||
(Some(_), None) => true, // 有主 vs 无主调用:有主条目算"他主"
|
||||
_ => false,
|
||||
}
|
||||
})
|
||||
.min_by_key(|(_, e)| e.created_at)
|
||||
.map(|(k, _)| k.clone())
|
||||
})
|
||||
.or_else(|| {
|
||||
self.entries.iter()
|
||||
.filter(|(_, e)| !e.pinned)
|
||||
.min_by_key(|(_, e)| e.created_at)
|
||||
.map(|(k, _)| k.clone())
|
||||
});
|
||||
|
||||
match oldest_key {
|
||||
Some(key) => {
|
||||
if let Some(entry) = self.entries.remove(&key) {
|
||||
self.current_bytes = self.current_bytes.saturating_sub(entry.content.len());
|
||||
// EvictionLog 留档(50 条上限,超出弹最旧)
|
||||
self.eviction_log.push_back(EvictionRecord {
|
||||
key: entry.key,
|
||||
tool_name: entry.tool_name,
|
||||
hint: entry.evict_hint,
|
||||
evicted_at: Instant::now(),
|
||||
owner: entry.owner,
|
||||
});
|
||||
while self.eviction_log.len() > EVICTION_LOG_CAP {
|
||||
self.eviction_log.pop_front();
|
||||
}
|
||||
}
|
||||
}
|
||||
None => break, // 全部 pinned(且非 pin 腾位路径):放不下就放不下
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 兼容入口(无主视角)
|
||||
fn evict_if_needed(&mut self, needed: usize) {
|
||||
self.evict_if_needed_with_owner(needed, None);
|
||||
}
|
||||
|
||||
/// 清空
|
||||
pub fn clear(&mut self) {
|
||||
self.entries.clear();
|
||||
self.current_bytes = 0;
|
||||
self.eviction_log.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// 内容 → 引用 key 段(确定性,不含 `/` 等会破坏 `parse_namespace_key` 的字符)。
|
||||
///
|
||||
/// 历史 bug:旧实现用 `"{len}_{前16字符}"`,前 16 字符原样进 key;内容前缀含 `/`(目录列表、
|
||||
/// 带路径的 grep 结果、文件头是路径)时,`namespace://tool/{key}` 被 `parse_namespace_key`
|
||||
/// 的 `split('/').nth(1)` 从首个 `/` 截断 → read/read_only 拿回 None → 原文彻底丢失。
|
||||
/// 现改用 SipHash u64 → 16 位 hex:确定性 + 极低碰撞 + 不含特殊字符。
|
||||
fn simple_hash(content: &str) -> String {
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut hasher = DefaultHasher::new();
|
||||
content.hash(&mut hasher);
|
||||
format!("{:016x}", hasher.finish())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn should_use_namespace_small_content() {
|
||||
assert!(!should_use_namespace("short", "custom_tool"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_use_namespace_large_bytes() {
|
||||
let large = "x".repeat(3000);
|
||||
assert!(should_use_namespace(&large, "custom_tool"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_use_namespace_always_large_tool() {
|
||||
assert!(should_use_namespace("short", "read_file"));
|
||||
assert!(should_use_namespace("short", "list_directory"));
|
||||
assert!(should_use_namespace("short", "diff_files"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_use_namespace_many_lines() {
|
||||
let many = (0..60).map(|i| format!("line {}", i)).collect::<Vec<_>>().join("\n");
|
||||
assert!(should_use_namespace(&many, "custom_tool"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_namespace_ref_detects_prefix() {
|
||||
assert!(is_namespace_ref("namespace://read_file/abc"));
|
||||
assert!(!is_namespace_ref("file content here"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_namespace_key_extracts_key() {
|
||||
assert_eq!(parse_namespace_key("namespace://read_file/abc_123"), Some("abc_123"));
|
||||
assert_eq!(parse_namespace_key("namespace://grep/xyz"), Some("xyz"));
|
||||
assert_eq!(parse_namespace_key("no_prefix"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_and_read() {
|
||||
let mut ns = NamespaceStore::new(100_000);
|
||||
let path = ns.store("read_file", "hello world content");
|
||||
assert!(is_namespace_ref(&path));
|
||||
assert!(path.contains("read_file"));
|
||||
assert_eq!(ns.read(&path), Some("hello world content"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_duplicate_key_does_not_double_count() {
|
||||
let mut ns = NamespaceStore::new(100_000);
|
||||
let content = "test content for duplicate check";
|
||||
let p1 = ns.store("tool_a", content);
|
||||
let p2 = ns.store("tool_b", content);
|
||||
assert_eq!(ns.len(), 1, "相同内容应去重");
|
||||
// read(&mut self) 返回 Option<&str> 借用 ns,两次调用须各自转 owned 避免双重可变借用
|
||||
let r1 = ns.read(&p1).map(str::to_owned);
|
||||
let r2 = ns.read(&p2).map(str::to_owned);
|
||||
assert_eq!(r1, r2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_content_with_slash_in_prefix_reads_back() {
|
||||
// 回归:旧 simple_hash 把内容前 16 字符原样进 key,前缀含 '/' 时(目录列表/带路径 grep
|
||||
// 结果/文件头是路径)namespace://tool/{key} 被 parse_namespace_key 的 split('/') 截断
|
||||
// → read None,原文丢失。hex hash 修复后 store→read 正确取回。
|
||||
let mut ns = NamespaceStore::new(100_000);
|
||||
let content = "foo/bar/baz/qux\nline2\nline3";
|
||||
let path = ns.store("list_directory", content);
|
||||
assert!(is_namespace_ref(&path));
|
||||
assert_eq!(ns.read(&path).map(str::to_owned), Some(content.to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eviction_oldest_removed_when_over_limit() {
|
||||
let mut ns = NamespaceStore::new(100); // 极小容量
|
||||
let small = "a"; // 1 byte
|
||||
ns.store("t1", small);
|
||||
assert_eq!(ns.len(), 1);
|
||||
|
||||
// 填充到超限
|
||||
let big = "x".repeat(200);
|
||||
ns.store("t2", &big);
|
||||
// t1 可能被淘汰
|
||||
if ns.current_bytes > 100 {
|
||||
assert!(ns.len() < 2, "超限时应已淘汰旧条目");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_oversized_single_entry_rejected_cap_intact() {
|
||||
// 回归:单条超过 max_bytes 时旧路径淘汰清空后仍无条件入库,current_bytes
|
||||
// 永久突破上限。现拒绝入库,仅返回引用路径,展开点落 EVICTED_PLACEHOLDER。
|
||||
let mut ns = NamespaceStore::new(100);
|
||||
let path = ns.store("t1", &"x".repeat(200));
|
||||
assert_eq!(ns.len(), 0, "超容量单条应拒绝入库");
|
||||
assert_eq!(ns.current_bytes, 0, "字节账目不得突破上限");
|
||||
assert!(is_namespace_ref(&path), "返回值仍是引用路径");
|
||||
assert_eq!(ns.read_only(&path), None, "不可解析 → 既有淘汰占位语义");
|
||||
|
||||
// 拒绝后不污染状态,常规条目仍可正常入
|
||||
let p2 = ns.store("t2", "ok");
|
||||
assert_eq!(ns.read(&p2).map(str::to_owned), Some("ok".to_string()));
|
||||
assert_eq!(ns.current_bytes, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_resets_all() {
|
||||
let mut ns = NamespaceStore::new(100_000);
|
||||
ns.store("t1", "content");
|
||||
assert!(!ns.is_empty());
|
||||
ns.clear();
|
||||
assert!(ns.is_empty());
|
||||
assert_eq!(ns.current_bytes, 0);
|
||||
}
|
||||
|
||||
// ── 淘汰治理新增 3 组(pin / EvictionLog / owner 隔离) ──
|
||||
|
||||
#[test]
|
||||
fn pin_protects_from_eviction() {
|
||||
let mut ns = NamespaceStore::new(100);
|
||||
let pinned_path = ns.store_pinned("report", &"p".repeat(50), Some("conv-a"));
|
||||
assert!(ns.contains(&pinned_path));
|
||||
// 塞入未 pin 大内容逼淘汰:pin 条目须存活
|
||||
let _ = ns.store("t2", &"x".repeat(80));
|
||||
assert!(ns.contains(&pinned_path), "pinned 条目不得被 LRU 淘汰");
|
||||
assert_eq!(ns.read_only(&pinned_path).map(str::to_owned), Some("p".repeat(50)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eviction_log_rich_notice() {
|
||||
let mut ns = NamespaceStore::new(50);
|
||||
let victim = ns.store("read_file", &"v".repeat(40));
|
||||
let victim_path = victim.clone();
|
||||
// 挤出 victim
|
||||
let _ = ns.store("t2", &"y".repeat(45));
|
||||
assert_eq!(ns.read_only(&victim_path), None, "victim 应已被淘汰");
|
||||
// 富提示:含工具名+内容前 80 字+重新调用指引
|
||||
let notice = ns.eviction_rich_notice(&victim_path).expect("Log 应命中");
|
||||
assert!(notice.contains("read_file"), "富提示应含工具名: {notice}");
|
||||
assert!(notice.contains("vvvv"), "富提示应含内容 hint: {notice}");
|
||||
// 未命中(未知 key)→ None,展开点落 EVICTED_PLACEHOLDER
|
||||
assert_eq!(ns.eviction_rich_notice("namespace://read_file/0000000000000000"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_soft_isolation_evicts_other_owner_first() {
|
||||
let mut ns = NamespaceStore::new(100);
|
||||
// conv-a 存 30B,conv-b 存 30B(时间序:a 旧于 b)
|
||||
let _ = ns.store_with_owner("ta", &"a".repeat(30), Some("conv-a"));
|
||||
let path_b = ns.store_with_owner("tb", &"b".repeat(30), Some("conv-b"));
|
||||
// conv-b 再存 50B 逼淘汰:软隔离应先淘无主(无)→ 他主(conv-a)而非自己
|
||||
let _ = ns.store_with_owner("tc", &"c".repeat(50), Some("conv-b"));
|
||||
assert!(!ns.contains("namespace://ta/x"), "ta 的 key 查询仅辅助");
|
||||
assert!(ns.entries.values().all(|e| e.owner.as_deref() != Some("conv-a")),
|
||||
"他主(conv-a)条目应先被淘汰");
|
||||
assert!(ns.contains(&path_b), "当前主(conv-b)条目应存活");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pin_ratio_cap_demotes_oldest() {
|
||||
// 池 100B,50% 上限=50B:pin 60B 单条(store_pinned 自身允许,超限后
|
||||
// 下一次 pin 腾位路径应降级它)
|
||||
let mut ns = NamespaceStore::new(100);
|
||||
let p_old = ns.store_pinned("old", &"o".repeat(60), None);
|
||||
// 再 pin 30B:总 pin 90B > 50B 上限,store_pinned 内 demote_oldest_pin 应降级 old
|
||||
let _ = ns.store_pinned("new", &"n".repeat(30), None);
|
||||
// 挤压逼淘汰:降级后的 old 可被淘,受保护的 new 存活
|
||||
let _ = ns.store("t3", &"z".repeat(80));
|
||||
assert!(ns.contains(&p_old) == false || ns.pinned_bytes() <= 50,
|
||||
"降级机制应使 pin 总量可被压缩");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//! 本机连通性基准面探测(弱网重试的感知依据)。
|
||||
//!
|
||||
//! 场景: 隧道/电梯等移动网络波动可达分钟级, 固定次数重试必然失败。send 失败时探
|
||||
//! 基准面区分「本机断网」与「单点故障」: 前者进长等待(自适应间隔探测, 恢复即重试),
|
||||
//! 后者短退避即可。纯 IP TCP 与系统 DNS 解析双测, 任一通过即判在线
|
||||
//! (DNS 单挂时 IP 直连上游仍可能可用, 双测互为盲区互补)。
|
||||
//!
|
||||
//! 演进路线(P2, 需求触发再上): 系统网络事件订阅双通道——事件源治接口级变化
|
||||
//! (WiFi 断开/切蜂窝)零延迟, 本探针治链路级不通(隧道半断/DNS 故障, 系统事件
|
||||
//! 不触发的盲区)。跨平台无统一事件 crate(Windows=INetworkListManager COM,
|
||||
//! macOS=NWPathMonitor, Linux=netlink), 届时以 NetEventSource trait 抽象,
|
||||
//! 按 devflow 实际部署面(Windows 桌面+Linux 云端)逐平台实现; Linux 服务端
|
||||
//! 网络稳定, 纯探针已足够。
|
||||
|
||||
use std::net::ToSocketAddrs;
|
||||
use std::time::Duration;
|
||||
|
||||
/// 探测超时(单次解析或建连)。弱网下慢响应不代表断网, 2s 内完成才计可用。
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
|
||||
/// send 弱网重试总窗口(隧道/电梯级分钟级波动覆盖)。
|
||||
pub(crate) const SEND_RETRY_WINDOW: Duration = Duration::from_secs(600);
|
||||
|
||||
/// 脱网确认后的探针间隔表(秒): 首段 5s 快探保即时性(网络抖动常秒级恢复),
|
||||
/// 确认持续脱网后间隔拉长到 30s 封顶——蜂窝 radio 休眠省电, 分钟级隧道场景无感差异。
|
||||
pub(crate) const OFFLINE_PROBE_SCHEDULE_SECS: &[u64] = &[5, 5, 5, 5, 10, 15, 30];
|
||||
|
||||
/// 按脱网轮次取探针间隔(越界取表尾封顶值)。
|
||||
pub(crate) fn offline_probe_wait(poll_count: usize) -> Duration {
|
||||
let idx = poll_count.saturating_sub(1).min(OFFLINE_PROBE_SCHEDULE_SECS.len() - 1);
|
||||
Duration::from_secs(OFFLINE_PROBE_SCHEDULE_SECS[idx])
|
||||
}
|
||||
|
||||
/// IP 直连探针目标(公共 DNS 服务商的 443, 不依赖 DNS)。
|
||||
const PROBE_IPS: &[&str] = &["223.5.5.5:443", "119.29.29.29:443"];
|
||||
|
||||
/// DNS 面探针目标(公共域, 走系统 resolver)。
|
||||
const PROBE_DNS_HOST: &str = "www.aliyun.com:443";
|
||||
|
||||
async fn tcp_alive(addr: &str) -> bool {
|
||||
let target = match addr.to_socket_addrs() {
|
||||
Ok(mut it) => match it.next() {
|
||||
Some(a) => a,
|
||||
None => return false,
|
||||
},
|
||||
Err(_) => return false,
|
||||
};
|
||||
tokio::time::timeout(PROBE_TIMEOUT, tokio::net::TcpStream::connect(target))
|
||||
.await
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
/// 本机网络基准面是否可用。任一探针通过即在线。
|
||||
pub(crate) async fn baseline_alive() -> bool {
|
||||
for ip in PROBE_IPS {
|
||||
if tcp_alive(ip).await {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
matches!(
|
||||
tokio::time::timeout(PROBE_TIMEOUT, tokio::net::lookup_host(PROBE_DNS_HOST)).await,
|
||||
Ok(Ok(_))
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,9 +9,9 @@
|
||||
//! 零行为变更(纯搬迁)。结构对齐 `anthropic_helpers.rs`。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::debug;
|
||||
use tracing::{debug, error};
|
||||
|
||||
use crate::provider::{StreamChunk, TokenUsage, ToolCallDelta};
|
||||
use crate::provider::{tool_call_id_or_fallback, StreamChunk, TokenUsage, ToolCallDelta};
|
||||
|
||||
// ============================================================
|
||||
// OpenAI API 请求/响应结构体
|
||||
@@ -65,11 +65,30 @@ pub(crate) struct OpenAiResponse {
|
||||
}
|
||||
|
||||
#[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>,
|
||||
pub struct OpenAiChoice {
|
||||
pub(crate) message: OpenAiMessageResp,
|
||||
/// 调用终止原因。`length` = 输出被 max_tokens 截断(不完整),由 [`FinishReason`]
|
||||
/// 的 Drop 侧效应打 warn 留痕 — 此前该字段无人读、截断不可见,截断的 JSON 会被
|
||||
/// 下游当解析失败整批丢弃而无任何日志线索。
|
||||
#[serde(default)]
|
||||
pub finish_reason: FinishReason,
|
||||
}
|
||||
|
||||
/// finish_reason 载体(透明 NewType)。
|
||||
///
|
||||
/// 消费方只取 message,本字段值随 choice 整体 drop 时触发截断告警;对外公开
|
||||
/// (pub 模块下的 pub 结构属库 API 面)同时免去字段 dead_code 豁免。
|
||||
/// serde 透明,线上 JSON 形态与 Option<String> 一致。
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct FinishReason(Option<String>);
|
||||
|
||||
impl Drop for FinishReason {
|
||||
fn drop(&mut self) {
|
||||
if self.0.as_deref() == Some("length") {
|
||||
tracing::warn!("[OpenAI] 同步响应因 max_tokens 截断(finish_reason=length),输出不完整");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -83,10 +102,14 @@ pub(crate) struct OpenAiMessageResp {
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct OpenAiToolCallResp {
|
||||
/// 缺省容错:个别兼容端点同步响应省略 id → 缺省空串,由 parse_tool_calls 的
|
||||
/// tool_call_id_or_fallback 生成唯一 fallback。此前必填,单条 tool_call 缺 id 令
|
||||
/// 整个响应 serde 失败 → complete 报"响应解析失败"整批丢弃(流式路径早已容错)。
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
// SW-260618-24: 反序列化 #[serde(rename="type")] 字段,保留以对齐 OpenAI 响应结构,标注意图消除 dead_code warning
|
||||
// 反序列化 #[serde(rename="type")] 字段,保留以对齐 OpenAI 响应结构,标注意图消除 dead_code warning
|
||||
#[allow(dead_code)]
|
||||
#[serde(rename = "type")]
|
||||
#[serde(rename = "type", default)]
|
||||
pub call_type: String,
|
||||
pub function: OpenAiFunctionResp,
|
||||
}
|
||||
@@ -94,32 +117,116 @@ pub(crate) struct OpenAiToolCallResp {
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct OpenAiFunctionResp {
|
||||
pub name: String,
|
||||
/// 缺省容错:arguments 缺失 → 空串(对齐 anthropic 同步 tool_use input 缺失 args="" 口径),
|
||||
/// 防单条缺字段令整个响应解析失败;name 保持必填(无名 tool_call 无法派发,无安全缺省)。
|
||||
#[serde(default)]
|
||||
pub arguments: String,
|
||||
}
|
||||
|
||||
/// OpenAI 标准嵌套缓存口径形态(usage.prompt_tokens_details):
|
||||
/// GLM/Qwen/OpenAI 官方在嵌套结构报缓存命中,DeepSeek 用扁平 prompt_cache_hit_tokens。
|
||||
/// cached_tokens 用 Option 容错给空 details 对象的网关。
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub(crate) struct PromptTokensDetails {
|
||||
#[serde(default)]
|
||||
pub cached_tokens: Option<u64>,
|
||||
}
|
||||
|
||||
/// usage 反序列化中间载体:与 OpenAiUsage 字段同构,经 `From` 做缓存口径归一后产出
|
||||
/// OpenAiUsage(见 impl From)。流式 SSE 与非流式 JSON 两条解析路径因此都自动获得
|
||||
/// 嵌套 cached_tokens 回填,消费方一律读归一后的扁平字段。
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct OpenAiUsageRaw {
|
||||
/// 三个计数缺省 0:部分兼容网关 usage 只报部分字段(如仅 total_tokens),必填会让
|
||||
/// 整帧 serde 失败走空 chunk 分支 — 同帧 content 一并蒸发、usage 归零无痕。
|
||||
#[serde(default)]
|
||||
pub prompt_tokens: u32,
|
||||
#[serde(default)]
|
||||
pub completion_tokens: u32,
|
||||
#[serde(default)]
|
||||
pub total_tokens: u32,
|
||||
/// DeepSeek 扩展:缓存命中 token(低价,deepseek-chat/reasoner)。无此字段 → default 0。
|
||||
#[serde(default)]
|
||||
pub prompt_cache_hit_tokens: u32,
|
||||
/// DeepSeek 扩展:未命中 token(全价真实输入,prompt_cache_miss_tokens)。无此字段 → default 0。
|
||||
#[serde(default)]
|
||||
pub prompt_cache_miss_tokens: u32,
|
||||
/// DeepSeek-reasoner / OpenAI o1 扩展:思考 token(隐藏输出,reasoning_tokens)。
|
||||
/// 非 reasoning 模型无此字段 → default 0。
|
||||
#[serde(default)]
|
||||
pub reasoning_tokens: u32,
|
||||
/// OpenAI 标准嵌套缓存口径(GLM/Qwen/OpenAI 官方)。
|
||||
#[serde(default)]
|
||||
pub prompt_tokens_details: Option<PromptTokensDetails>,
|
||||
}
|
||||
|
||||
/// OpenAI usage(缓存口径已归一)
|
||||
///
|
||||
/// 归一发生在反序列化时(`#[serde(from)]`):扁平 hit 有值优先(DeepSeek 自报 hit+miss);
|
||||
/// 扁平 hit 未报而嵌套 cached_tokens > 0 时回填 hit,miss 推断为 prompt - hit(嵌套口径
|
||||
/// 缺全价分项);两种口径都没有则保持 0。消费方(apply_openai_sse / openai_compat 非流式)
|
||||
/// 直接读扁平字段,无需感知 provider 差异。
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(from = "OpenAiUsageRaw")]
|
||||
pub(crate) struct OpenAiUsage {
|
||||
pub prompt_tokens: u32,
|
||||
pub completion_tokens: u32,
|
||||
pub total_tokens: u32,
|
||||
/// 缓存命中 token(低价;扁平优先,缺失时回填自嵌套 cached_tokens)
|
||||
pub prompt_cache_hit_tokens: u32,
|
||||
/// 缓存未命中 token(全价真实输入;嵌套口径推断时 = prompt - hit)
|
||||
pub prompt_cache_miss_tokens: u32,
|
||||
/// 思考 token(隐藏输出)
|
||||
pub reasoning_tokens: u32,
|
||||
}
|
||||
|
||||
impl From<OpenAiUsageRaw> for OpenAiUsage {
|
||||
fn from(raw: OpenAiUsageRaw) -> Self {
|
||||
let (hit, miss) = match raw.prompt_tokens_details.and_then(|d| d.cached_tokens) {
|
||||
Some(nested) if nested > 0 && raw.prompt_cache_hit_tokens == 0 => {
|
||||
let hit = u32::try_from(nested).unwrap_or(u32::MAX);
|
||||
(hit, raw.prompt_tokens.saturating_sub(hit))
|
||||
}
|
||||
_ => (raw.prompt_cache_hit_tokens, raw.prompt_cache_miss_tokens),
|
||||
};
|
||||
Self {
|
||||
prompt_tokens: raw.prompt_tokens,
|
||||
completion_tokens: raw.completion_tokens,
|
||||
total_tokens: raw.total_tokens,
|
||||
prompt_cache_hit_tokens: hit,
|
||||
prompt_cache_miss_tokens: miss,
|
||||
reasoning_tokens: raw.reasoning_tokens,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// SSE 流式响应 chunk
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct OpenAiStreamChunk {
|
||||
/// 流式 choice 数组。标准 error 帧(`{"error":{..}}`)与 usage 尾帧均不带 choices 键,
|
||||
/// 缺省为空数组才能整帧解析成功;必填会让这类合法帧 serde 失败走空 chunk 分支,
|
||||
/// 流呈无声截断。
|
||||
#[serde(default)]
|
||||
pub choices: Vec<OpenAiStreamChoice>,
|
||||
/// 末 chunk(choices 为空)携带的累计 usage
|
||||
#[serde(default)]
|
||||
pub usage: Option<OpenAiUsage>,
|
||||
/// 流中途 error 事件(OpenAI 兼容协议:`{"error":{"message":..,"type":..}}`)。
|
||||
/// 部分中转站按 OpenAI 协议在流中途发 error 帧而非走 HTTP 非 200,
|
||||
/// serde default + Value 兜底:旧响应无此字段不受影响,且对 error 载荷形态不敏感。
|
||||
#[serde(default)]
|
||||
pub error: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct OpenAiStreamChoice {
|
||||
/// 部分 usage 尾帧的 choice 仅带 finish_reason 无 delta 键,缺省按空 delta 处理
|
||||
#[serde(default)]
|
||||
pub delta: OpenAiStreamDelta,
|
||||
pub finish_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub(crate) struct OpenAiStreamDelta {
|
||||
pub content: Option<String>,
|
||||
pub tool_calls: Option<Vec<OpenAiStreamToolCall>>,
|
||||
@@ -130,6 +237,8 @@ pub(crate) struct OpenAiStreamDelta {
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct OpenAiStreamToolCall {
|
||||
/// 部分端点 tool_call 增量省略 index,缺省 0(单工具调用场景语义等价)
|
||||
#[serde(default)]
|
||||
pub index: u32,
|
||||
pub id: Option<String>,
|
||||
pub function: Option<OpenAiStreamFunction>,
|
||||
@@ -165,33 +274,103 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>)
|
||||
usage: usage_accum.take(),
|
||||
error: None,
|
||||
reasoning_content: None,
|
||||
truncated_by_max_tokens: false,
|
||||
};
|
||||
}
|
||||
|
||||
match serde_json::from_str::<OpenAiStreamChunk>(data) {
|
||||
Ok(chunk) => {
|
||||
// 流中途 error 事件(中转站按 OpenAI 协议在流中途发 error 帧)。
|
||||
// 不走 finished 完成路径(避免残缺响应被当正常完成入库),由 stream_llm
|
||||
// 识别 error 非空 → 发 AiError + 丢弃残缺(对齐 anthropic_helpers 215-219)。
|
||||
if let Some(err_val) = chunk.error {
|
||||
let msg = err_val
|
||||
.get("message")
|
||||
.and_then(|m| m.as_str())
|
||||
.unwrap_or("stream error")
|
||||
.to_string();
|
||||
error!(%msg, raw = %err_val, "OpenAI 流式错误事件");
|
||||
return StreamChunk {
|
||||
delta: String::new(),
|
||||
finished: false,
|
||||
tool_calls: None,
|
||||
usage: None,
|
||||
error: Some(msg),
|
||||
reasoning_content: None,
|
||||
truncated_by_max_tokens: false,
|
||||
};
|
||||
}
|
||||
// 提取 usage(带 include_usage 时末段 chunk 携带,覆盖累积)
|
||||
if let Some(u) = chunk.usage {
|
||||
// 缺省计数的留痕:两个分项均 0 基本可断定 provider 上报不完整(真实完成
|
||||
// 必有 completion 计数),非流路径下游拿到零 usage 无从察觉。
|
||||
if u.prompt_tokens == 0 && u.completion_tokens == 0 {
|
||||
tracing::warn!(
|
||||
total = u.total_tokens,
|
||||
"[OpenAI] usage 缺 prompt/completion 分项(按 0 兜底),provider 上报不完整"
|
||||
);
|
||||
}
|
||||
tracing::info!(
|
||||
prompt = u.prompt_tokens,
|
||||
completion = u.completion_tokens,
|
||||
cache_hit = u.prompt_cache_hit_tokens,
|
||||
cache_miss = u.prompt_cache_miss_tokens,
|
||||
reasoning = u.reasoning_tokens,
|
||||
"[OpenAI] 末 chunk usage 解析(deepseek 等报 cache)"
|
||||
);
|
||||
*usage_accum = Some(TokenUsage {
|
||||
prompt_tokens: u.prompt_tokens,
|
||||
completion_tokens: u.completion_tokens,
|
||||
total_tokens: u.total_tokens,
|
||||
prompt_cache_hit_tokens: u.prompt_cache_hit_tokens,
|
||||
prompt_cache_miss_tokens: u.prompt_cache_miss_tokens,
|
||||
reasoning_tokens: u.reasoning_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");
|
||||
// 终态 finish_reason:stop(正常结束)/ tool_calls(转交工具执行)/
|
||||
// length(max_tokens 截断,属正常终止非断连)/ content_filter(内容审查拦截)。
|
||||
// content_filter 的流部分 provider 不发 [DONE] 帧,不纳入终态会一直悬挂
|
||||
let finished = matches!(
|
||||
choice.finish_reason.as_deref(),
|
||||
Some("stop") | Some("tool_calls") | Some("length") | Some("content_filter")
|
||||
);
|
||||
// length = 输出被 max_tokens 截断:仍按正常终止收敛(finished=true),
|
||||
// 但截断标志随 chunk 带出,消费方据此把该轮产出标记截断,不冒充完整。
|
||||
let truncated_by_max_tokens = choice.finish_reason.as_deref() == Some("length");
|
||||
|
||||
let tool_calls = choice.delta.tool_calls.map(|tcs| {
|
||||
// 缺 index 的增量缺省 0(单工具调用语义等价);但同帧多 call 且 index
|
||||
// 冲突时缺省会互相踩(下游按 index 累积 → 参数拼错位),warn 留痕定位
|
||||
// provider 兼容缺陷,不做静默改写。
|
||||
if tcs.len() > 1 {
|
||||
let mut idxs: Vec<u32> = tcs.iter().map(|tc| tc.index).collect();
|
||||
idxs.sort_unstable();
|
||||
idxs.dedup();
|
||||
if idxs.len() < tcs.len() {
|
||||
tracing::warn!(
|
||||
calls = tcs.len(),
|
||||
unique = idxs.len(),
|
||||
"[OpenAI] 同帧多 tool_call 增量 index 冲突(缺省 0 互踩),按 index 累积将错位"
|
||||
);
|
||||
}
|
||||
}
|
||||
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),
|
||||
.map(|tc| {
|
||||
// CR-空 id:流式 chunk 的 id 可能为 Some("")(SenseNova 兼容缺陷)。
|
||||
// 仅对「provider 显式给了 id 字段」的 chunk 做兜底——None(OpenAI
|
||||
// 协议:仅首 chunk 携带 id,后续 chunk 无 id)保持 None,避免
|
||||
// 覆盖首 chunk 的权威 id。Some("") → `gen_stream_{index}` fallback,
|
||||
// Some(非空) → 原样。下游 stream_recv 按 index 累积,draft.id 透传
|
||||
// 至 ToolCall.id(accumulate_tool_calls 仅 Some 覆盖,None 不动)。
|
||||
let id = tc.id.map(|raw| tool_call_id_or_fallback(&raw, tc.index as usize, "gen_stream"));
|
||||
ToolCallDelta {
|
||||
index: tc.index,
|
||||
id,
|
||||
function_name: tc.function.as_ref().and_then(|f| f.name.clone()),
|
||||
function_arguments: tc.function.and_then(|f| f.arguments),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
@@ -200,19 +379,23 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>)
|
||||
delta: delta_text,
|
||||
finished,
|
||||
tool_calls,
|
||||
usage: None,
|
||||
// 加固:usage 与 finish_reason 同帧的端点(部分兼容实现),此处已累积则挂上,
|
||||
// 使下游 stream_recv 不必等到 [DONE] 帧即可拿到真实 usage。
|
||||
usage: usage_accum.clone(),
|
||||
error: None,
|
||||
reasoning_content: choice.delta.reasoning_content,
|
||||
truncated_by_max_tokens,
|
||||
}
|
||||
} else {
|
||||
// choices 为空 = usage-only chunk,不输出文本(usage 已累积)
|
||||
// choices 为空 = usage-only chunk,不输出文本(usage 已累积),usage 一并带出
|
||||
StreamChunk {
|
||||
delta: String::new(),
|
||||
finished: false,
|
||||
tool_calls: None,
|
||||
usage: None,
|
||||
usage: usage_accum.clone(),
|
||||
error: None,
|
||||
reasoning_content: None,
|
||||
truncated_by_max_tokens: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -225,7 +408,135 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>)
|
||||
usage: None,
|
||||
error: None,
|
||||
reasoning_content: None,
|
||||
truncated_by_max_tokens: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// DeepSeek 扁平口径:hit/miss 各取其值,不参与嵌套回填
|
||||
#[test]
|
||||
fn usage_deepseek_flat_cache() {
|
||||
let u: OpenAiUsage = serde_json::from_str(
|
||||
r#"{"prompt_tokens":100,"completion_tokens":10,"total_tokens":110,
|
||||
"prompt_cache_hit_tokens":64,"prompt_cache_miss_tokens":36}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(u.prompt_tokens, 100);
|
||||
assert_eq!(u.prompt_cache_hit_tokens, 64);
|
||||
assert_eq!(u.prompt_cache_miss_tokens, 36);
|
||||
}
|
||||
|
||||
/// OpenAI/GLM 嵌套口径:扁平未报,hit 回填自 cached_tokens,miss 推断为 prompt - hit
|
||||
#[test]
|
||||
fn usage_nested_cached_tokens_backfill() {
|
||||
let u: OpenAiUsage = serde_json::from_str(
|
||||
r#"{"prompt_tokens":100,"completion_tokens":10,"total_tokens":110,
|
||||
"prompt_tokens_details":{"cached_tokens":64}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(u.prompt_cache_hit_tokens, 64);
|
||||
assert_eq!(u.prompt_cache_miss_tokens, 36);
|
||||
}
|
||||
|
||||
/// 两种口径都没有:hit/miss 保持 0(前端不渲染 cache 段)
|
||||
#[test]
|
||||
fn usage_no_cache_fields() {
|
||||
let u: OpenAiUsage = serde_json::from_str(
|
||||
r#"{"prompt_tokens":100,"completion_tokens":10,"total_tokens":110}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(u.prompt_cache_hit_tokens, 0);
|
||||
assert_eq!(u.prompt_cache_miss_tokens, 0);
|
||||
}
|
||||
|
||||
/// 端到端:usage-only chunk 经 apply_openai_sse 累加路径带出归一后的缓存分项
|
||||
#[test]
|
||||
fn sse_usage_chunk_nested_cache_reaches_token_usage() {
|
||||
let data = r#"{"choices":[],"usage":{"prompt_tokens":100,"completion_tokens":10,
|
||||
"total_tokens":110,"prompt_tokens_details":{"cached_tokens":64}}}"#;
|
||||
let mut accum = None;
|
||||
let chunk = apply_openai_sse(data, &mut accum);
|
||||
let usage = chunk.usage.expect("usage chunk 应携带 usage");
|
||||
assert_eq!(usage.prompt_cache_hit_tokens, 64);
|
||||
assert_eq!(usage.prompt_cache_miss_tokens, 36);
|
||||
}
|
||||
|
||||
/// 部分兼容网关 usage 只报部分字段:缺省 0 入累加器,同帧 content 不再因整帧
|
||||
/// serde 失败被吞(旧行为:必填字段缺失 → 空 chunk → 文本蒸发 + usage 归零无痕)
|
||||
#[test]
|
||||
fn sse_partial_usage_frame_parses_with_defaults() {
|
||||
let mut accum = None;
|
||||
let data = r#"{"choices":[{"delta":{"content":"hi"}}],"usage":{"total_tokens":10}}"#;
|
||||
let c = apply_openai_sse(data, &mut accum);
|
||||
assert_eq!(c.delta, "hi", "usage 缺分项不应令整帧解析失败丢 content");
|
||||
let u = accum.expect("部分 usage 应写入累加器");
|
||||
assert_eq!(u.total_tokens, 10);
|
||||
assert_eq!(u.prompt_tokens, 0, "缺省分项按 0 兜底");
|
||||
assert_eq!(u.completion_tokens, 0);
|
||||
}
|
||||
|
||||
/// 同帧多 tool_call 增量缺 index:缺省 0 触发 index 冲突路径(仅 warn 留痕,不改写
|
||||
/// 缺省策略);解析本身不失败,增量仍逐条带出。
|
||||
#[test]
|
||||
fn sse_tool_calls_missing_index_in_multi_call_frame_still_parse() {
|
||||
let mut accum = None;
|
||||
let data = r#"{"choices":[{"delta":{"tool_calls":[
|
||||
{"id":"call_a","function":{"name":"ta","arguments":"{"}},
|
||||
{"id":"call_b","function":{"name":"tb","arguments":"}"}}
|
||||
]}}]}"#;
|
||||
let c = apply_openai_sse(data, &mut accum);
|
||||
let tcs = c.tool_calls.expect("缺 index 的多 call 帧应可解析");
|
||||
assert_eq!(tcs.len(), 2);
|
||||
assert!(tcs.iter().all(|tc| tc.index == 0), "缺 index 缺省 0");
|
||||
}
|
||||
|
||||
/// content_filter 属终态:敏感内容拦截的流部分 provider 不发 [DONE] 帧,
|
||||
/// 必须靠 finish_reason 收敛,否则悬挂;未列入终态的 finish_reason 保持非终态
|
||||
#[test]
|
||||
fn finish_reason_content_filter_is_terminal() {
|
||||
let data = r#"{"choices":[{"delta":{"content":""},"finish_reason":"content_filter"}]}"#;
|
||||
let mut accum = None;
|
||||
let chunk = apply_openai_sse(data, &mut accum);
|
||||
assert!(chunk.finished, "content_filter 应收敛为终态,不再悬挂");
|
||||
|
||||
let mut accum2 = None;
|
||||
let chunk2 = apply_openai_sse(
|
||||
r#"{"choices":[{"delta":{"content":"x"},"finish_reason":null}]}"#,
|
||||
&mut accum2,
|
||||
);
|
||||
assert!(!chunk2.finished, "无终态 finish_reason 的普通 delta 不应终结");
|
||||
}
|
||||
|
||||
/// finish_reason=length:max_tokens 截断仍按正常终止收敛(finished=true),
|
||||
/// 但截断标志必须随 chunk 带出(零信号会让腰斩报告冒充完整完成);
|
||||
/// stop 等正常终态不置标志。
|
||||
#[test]
|
||||
fn finish_reason_length_carries_truncation_flag() {
|
||||
let mut accum = None;
|
||||
let chunk = apply_openai_sse(
|
||||
r#"{"choices":[{"delta":{"content":"报告写到一半"},"finish_reason":"length"}]}"#,
|
||||
&mut accum,
|
||||
);
|
||||
assert!(chunk.finished, "length 属正常终止");
|
||||
assert!(chunk.truncated_by_max_tokens, "length 必须带截断标志");
|
||||
|
||||
let mut accum2 = None;
|
||||
let chunk2 = apply_openai_sse(
|
||||
r#"{"choices":[{"delta":{"content":"ok"},"finish_reason":"stop"}]}"#,
|
||||
&mut accum2,
|
||||
);
|
||||
assert!(chunk2.finished && !chunk2.truncated_by_max_tokens, "stop 不置截断标志");
|
||||
|
||||
let mut accum3 = None;
|
||||
let chunk3 = apply_openai_sse(
|
||||
r#"{"choices":[{"delta":{"content":"x"},"finish_reason":null}]}"#,
|
||||
&mut accum3,
|
||||
);
|
||||
assert!(!chunk3.truncated_by_max_tokens, "普通 delta 不置截断标志");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
//! 人设系统(P0) — AgentPersona 结构体 + PersonaRegistry 注册表
|
||||
//!
|
||||
//! 构成 AI Native 的基础:角色划分是分工的前提。
|
||||
//! - 每名人设包含:能力标签、工具白名单、system prompt 模板
|
||||
//! - Registry 提供按场景/意图选人设、按 id 查人设能力
|
||||
//!
|
||||
//! 设计依据:docs/02-架构设计/构想审查/AI-Native方向与路线图-2026-06-29.md
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// 内置人设 ID 常量
|
||||
pub const PERSONA_CODER: &str = "coder";
|
||||
pub const PERSONA_REVIEWER: &str = "reviewer";
|
||||
pub const PERSONA_ARCHITECT: &str = "architect";
|
||||
pub const PERSONA_TESTER: &str = "tester";
|
||||
pub const PERSONA_ANALYST: &str = "analyst";
|
||||
|
||||
/// 人设能力标签枚举
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum PersonaCapability {
|
||||
/// 代码生成与修改
|
||||
CodeGeneration,
|
||||
/// Code Review
|
||||
CodeReview,
|
||||
/// 架构设计与分析
|
||||
ArchitectureDesign,
|
||||
/// 测试编写与执行
|
||||
Testing,
|
||||
/// 数据分析与搜索
|
||||
Analysis,
|
||||
/// 文件读写
|
||||
FileOperation,
|
||||
/// 命令执行
|
||||
CommandExecution,
|
||||
/// Git 操作
|
||||
GitOperation,
|
||||
/// 知识库操作
|
||||
KnowledgeOperation,
|
||||
/// 项目管理
|
||||
ProjectManagement,
|
||||
}
|
||||
|
||||
impl PersonaCapability {
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::CodeGeneration => "代码生成",
|
||||
Self::CodeReview => "代码审查",
|
||||
Self::ArchitectureDesign => "架构设计",
|
||||
Self::Testing => "测试",
|
||||
Self::Analysis => "分析",
|
||||
Self::FileOperation => "文件操作",
|
||||
Self::CommandExecution => "命令执行",
|
||||
Self::GitOperation => "Git 操作",
|
||||
Self::KnowledgeOperation => "知识库",
|
||||
Self::ProjectManagement => "项目管理",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 人设定义
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AgentPersona {
|
||||
/// 唯一标识(如 "coder", "reviewer")
|
||||
pub id: String,
|
||||
/// 人设名称(如 "程序员", "审查员")
|
||||
pub name: String,
|
||||
/// 人设描述(LLM 理解用)
|
||||
pub description: String,
|
||||
/// 能力标签集
|
||||
pub capabilities: Vec<PersonaCapability>,
|
||||
/// 工具白名单(空=不限,非空=仅允许列出的工具)
|
||||
pub tool_whitelist: Vec<String>,
|
||||
/// system prompt 模板(注入 {context} {goals} 等占位符)
|
||||
pub system_prompt_template: String,
|
||||
/// 优先级(选人设时冲突排名,0=最高)
|
||||
pub priority: u8,
|
||||
}
|
||||
|
||||
impl AgentPersona {
|
||||
/// 检查此人设是否具备某项能力
|
||||
pub fn has_capability(&self, cap: &PersonaCapability) -> bool {
|
||||
self.capabilities.contains(cap)
|
||||
}
|
||||
|
||||
/// 检查某工具是否在此人设的 whitelist 中
|
||||
pub fn is_tool_allowed(&self, tool_name: &str) -> bool {
|
||||
self.tool_whitelist.is_empty() || self.tool_whitelist.iter().any(|t| t == tool_name)
|
||||
}
|
||||
|
||||
/// 构建带上下文的 system prompt
|
||||
pub fn build_prompt(&self, context: &str, goals: &[String]) -> String {
|
||||
let goals_text = if goals.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("\n## 当前目标\n{}", goals.iter().enumerate().map(|(i, g)| format!("{}. {}", i + 1, g)).collect::<Vec<_>>().join("\n"))
|
||||
};
|
||||
self.system_prompt_template
|
||||
.replace("{context}", context)
|
||||
.replace("{goals}", &goals_text)
|
||||
}
|
||||
}
|
||||
|
||||
/// 人设注册表 — 管理内置 + 自定义人设
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PersonaRegistry {
|
||||
personae: HashMap<String, AgentPersona>,
|
||||
/// 默认人设 id(兜底)
|
||||
default_id: String,
|
||||
}
|
||||
|
||||
impl PersonaRegistry {
|
||||
/// 构造默认注册表(含 5 内置人设)
|
||||
pub fn new() -> Self {
|
||||
let mut reg = Self {
|
||||
personae: HashMap::new(),
|
||||
default_id: PERSONA_CODER.to_string(),
|
||||
};
|
||||
reg.register(Self::builtin_coder());
|
||||
reg.register(Self::builtin_reviewer());
|
||||
reg.register(Self::builtin_architect());
|
||||
reg.register(Self::builtin_tester());
|
||||
reg.register(Self::builtin_analyst());
|
||||
reg
|
||||
}
|
||||
|
||||
// ── 5 内置人设 ──
|
||||
|
||||
fn builtin_coder() -> AgentPersona {
|
||||
AgentPersona {
|
||||
id: PERSONA_CODER.to_string(),
|
||||
name: "程序员".to_string(),
|
||||
description: "负责代码生成、修改、重构。专注于实现功能、修复 bug、优化性能,不关注大范围架构变更".to_string(),
|
||||
capabilities: vec![
|
||||
PersonaCapability::CodeGeneration,
|
||||
PersonaCapability::FileOperation,
|
||||
PersonaCapability::CommandExecution,
|
||||
],
|
||||
tool_whitelist: vec![], // 不限(全部工具可用)
|
||||
system_prompt_template: "你是 DevFlow 的**程序员**(Coder)。\n\n你的职责是编写、修改和重构代码。专注于具体实现,不擅自改变整体架构。\n\n{goals}\n\n{context}".to_string(),
|
||||
priority: 1,
|
||||
}
|
||||
}
|
||||
|
||||
fn builtin_reviewer() -> AgentPersona {
|
||||
AgentPersona {
|
||||
id: PERSONA_REVIEWER.to_string(),
|
||||
name: "审查员".to_string(),
|
||||
description: "负责 Code Review、质量审计、安全检查。审查代码正确性、性能、安全性,返回审查意见不直接改代码".to_string(),
|
||||
capabilities: vec![
|
||||
PersonaCapability::CodeReview,
|
||||
PersonaCapability::Analysis,
|
||||
],
|
||||
tool_whitelist: vec![
|
||||
"read_file".into(), "search_files".into(), "list_directory".into(),
|
||||
"git_diff".into(), "git_log".into(), "git_status".into(),
|
||||
],
|
||||
system_prompt_template: "你是 DevFlow 的**审查员**(Reviewer)。\n\n你的职责是审查代码质量、安全性和性能。**你不直接修改代码**,只输出审查意见和修改建议。\n\n{goals}\n\n{context}".to_string(),
|
||||
priority: 3,
|
||||
}
|
||||
}
|
||||
|
||||
fn builtin_architect() -> AgentPersona {
|
||||
AgentPersona {
|
||||
id: PERSONA_ARCHITECT.to_string(),
|
||||
name: "架构师".to_string(),
|
||||
description: "负责架构设计、技术选型、模块划分。分析整体结构,制定技术方案,不关注具体代码实现".to_string(),
|
||||
capabilities: vec![
|
||||
PersonaCapability::ArchitectureDesign,
|
||||
PersonaCapability::Analysis,
|
||||
],
|
||||
tool_whitelist: vec![
|
||||
"read_file".into(), "search_files".into(), "list_directory".into(),
|
||||
"write_file".into(), // 写架构文档
|
||||
],
|
||||
system_prompt_template: "你是 DevFlow 的**架构师**(Architect)。\n\n你的职责是架构设计、技术选型和模块划分。分析系统整体结构,制定技术方案。**不编写业务代码**,输出架构文档和设计方案。\n\n{goals}\n\n{context}".to_string(),
|
||||
priority: 2,
|
||||
}
|
||||
}
|
||||
|
||||
fn builtin_tester() -> AgentPersona {
|
||||
AgentPersona {
|
||||
id: PERSONA_TESTER.to_string(),
|
||||
name: "测试员".to_string(),
|
||||
description: "负责编写单元测试、集成测试、执行测试。验证代码正确性,不修改生产代码".to_string(),
|
||||
capabilities: vec![
|
||||
PersonaCapability::Testing,
|
||||
PersonaCapability::CommandExecution,
|
||||
PersonaCapability::FileOperation,
|
||||
],
|
||||
tool_whitelist: vec![], // 不限
|
||||
system_prompt_template: "你是 DevFlow 的**测试员**(Tester)。\n\n你的职责是编写测试、执行测试、报告测试结果。**不修改生产代码**,只修改测试代码。\n\n{goals}\n\n{context}".to_string(),
|
||||
priority: 3,
|
||||
}
|
||||
}
|
||||
|
||||
fn builtin_analyst() -> AgentPersona {
|
||||
AgentPersona {
|
||||
id: PERSONA_ANALYST.to_string(),
|
||||
name: "分析师".to_string(),
|
||||
description: "负责数据分析、日志排查、性能分析。搜索信息、分析数据,输出分析报告".to_string(),
|
||||
capabilities: vec![
|
||||
PersonaCapability::Analysis,
|
||||
PersonaCapability::CommandExecution,
|
||||
PersonaCapability::KnowledgeOperation,
|
||||
],
|
||||
tool_whitelist: vec![
|
||||
"read_file".into(), "search_files".into(), "grep_search".into(),
|
||||
"run_command".into(), "git_log".into(),
|
||||
"knowledge_search".into(), "knowledge_inject".into(),
|
||||
],
|
||||
system_prompt_template: "你是 DevFlow 的**分析师**(Analyst)。\n\n你的职责是分析数据、排查问题、搜索信息。输出分析报告和结论。**不修改代码**。\n\n{goals}\n\n{context}".to_string(),
|
||||
priority: 4,
|
||||
}
|
||||
}
|
||||
|
||||
// ── 注册表操作 ──
|
||||
|
||||
/// 注册/覆盖人设
|
||||
pub fn register(&mut self, persona: AgentPersona) {
|
||||
self.personae.insert(persona.id.clone(), persona);
|
||||
}
|
||||
|
||||
/// 按 id 查人设
|
||||
pub fn get(&self, id: &str) -> Option<&AgentPersona> {
|
||||
self.personae.get(id)
|
||||
}
|
||||
|
||||
/// 取默认人设
|
||||
pub fn default(&self) -> &AgentPersona {
|
||||
self.personae.get(&self.default_id).expect("默认人设必存在")
|
||||
}
|
||||
|
||||
/// 设置默认人设 id
|
||||
pub fn set_default(&mut self, id: &str) {
|
||||
if self.personae.contains_key(id) {
|
||||
self.default_id = id.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
/// 按意图推荐人设(启发式规则)
|
||||
pub fn recommend_for_intent(&self, intent: &str) -> &AgentPersona {
|
||||
let lower = intent.to_lowercase();
|
||||
if lower.contains("review") || lower.contains("审查") || lower.contains("审计") {
|
||||
self.get(PERSONA_REVIEWER).unwrap_or_else(|| self.default())
|
||||
} else if lower.contains("架构") || lower.contains("设计") || lower.contains("architect") {
|
||||
self.get(PERSONA_ARCHITECT).unwrap_or_else(|| self.default())
|
||||
} else if lower.contains("测试") || lower.contains("test") {
|
||||
self.get(PERSONA_TESTER).unwrap_or_else(|| self.default())
|
||||
} else if lower.contains("分析") || lower.contains("分析") || lower.contains("排查") || lower.contains("search") {
|
||||
self.get(PERSONA_ANALYST).unwrap_or_else(|| self.default())
|
||||
} else {
|
||||
// 默认走 coder(代码生成是最常见场景)
|
||||
self.default()
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取全部人设列表
|
||||
pub fn all(&self) -> Vec<&AgentPersona> {
|
||||
let mut list: Vec<_> = self.personae.values().collect();
|
||||
list.sort_by_key(|p| p.priority);
|
||||
list
|
||||
}
|
||||
|
||||
/// 人设数量
|
||||
pub fn len(&self) -> usize {
|
||||
self.personae.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.personae.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PersonaRegistry {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn registry_has_5_builtin() {
|
||||
let reg = PersonaRegistry::new();
|
||||
assert_eq!(reg.len(), 5);
|
||||
assert!(reg.get(PERSONA_CODER).is_some());
|
||||
assert!(reg.get(PERSONA_REVIEWER).is_some());
|
||||
assert!(reg.get(PERSONA_ARCHITECT).is_some());
|
||||
assert!(reg.get(PERSONA_TESTER).is_some());
|
||||
assert!(reg.get(PERSONA_ANALYST).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_is_coder() {
|
||||
let reg = PersonaRegistry::new();
|
||||
assert_eq!(reg.default().id, PERSONA_CODER);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recommend_reviewer() {
|
||||
let reg = PersonaRegistry::new();
|
||||
let p = reg.recommend_for_intent("review the code changes");
|
||||
assert_eq!(p.id, PERSONA_REVIEWER);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recommend_architect() {
|
||||
let reg = PersonaRegistry::new();
|
||||
let p = reg.recommend_for_intent("设计新的模块架构");
|
||||
assert_eq!(p.id, PERSONA_ARCHITECT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recommend_tester() {
|
||||
let reg = PersonaRegistry::new();
|
||||
let p = reg.recommend_for_intent("write tests for this module");
|
||||
assert_eq!(p.id, PERSONA_TESTER);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recommend_default_to_coder() {
|
||||
let reg = PersonaRegistry::new();
|
||||
let p = reg.recommend_for_intent("implement a new feature");
|
||||
assert_eq!(p.id, PERSONA_CODER);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_whitelist_filters() {
|
||||
let reviewer = AgentPersona {
|
||||
id: "test".into(),
|
||||
name: "test".into(),
|
||||
description: "".into(),
|
||||
capabilities: vec![],
|
||||
tool_whitelist: vec!["read_file".into(), "search_files".into()],
|
||||
system_prompt_template: "".into(),
|
||||
priority: 5,
|
||||
};
|
||||
assert!(reviewer.is_tool_allowed("read_file"));
|
||||
assert!(!reviewer.is_tool_allowed("write_file"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_whitelist_allows_all() {
|
||||
let coder = AgentPersona {
|
||||
id: "test".into(),
|
||||
name: "test".into(),
|
||||
description: "".into(),
|
||||
capabilities: vec![],
|
||||
tool_whitelist: vec![],
|
||||
system_prompt_template: "".into(),
|
||||
priority: 5,
|
||||
};
|
||||
assert!(coder.is_tool_allowed("run_command"));
|
||||
assert!(coder.is_tool_allowed("write_file"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_prompt_replaces_placeholders() {
|
||||
let reg = PersonaRegistry::new();
|
||||
let coder = reg.get(PERSONA_CODER).unwrap();
|
||||
let prompt = coder.build_prompt("some context", &["goal1".into()]);
|
||||
assert!(prompt.contains("程序员"));
|
||||
assert!(prompt.contains("goal1"));
|
||||
assert!(prompt.contains("some context"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_custom_persona() {
|
||||
let mut reg = PersonaRegistry::new();
|
||||
let custom = AgentPersona {
|
||||
id: "custom".into(),
|
||||
name: "自定义".into(),
|
||||
description: "test".into(),
|
||||
capabilities: vec![],
|
||||
tool_whitelist: vec![],
|
||||
system_prompt_template: "You are custom".into(),
|
||||
priority: 0,
|
||||
};
|
||||
reg.register(custom);
|
||||
assert_eq!(reg.len(), 6);
|
||||
assert!(reg.get("custom").is_some());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
//! Plan 执行器(Phase 2 · DAG 分层调度)
|
||||
//!
|
||||
//! 按 Plan::to_layers 输出的层级顺序执行子任务:
|
||||
//! - 层间串行:上层全部完成才进下一层(deps 保证)
|
||||
//! - 层内并行:同层 SubTask 用 tokio::JoinSet 并发执行(设计目标;
|
||||
//! 当前为串行循环模拟,见 execute 内 TODO,本执行器未接生产链路)
|
||||
//!
|
||||
//! **当前状态**:骨架实现,PLAN_EXECUTION_ENABLED 门控(默认关)。
|
||||
//! 未接入 agentic loop 主路径,翻 true 启用后仍需与 run_agentic_loop 对接。
|
||||
//!
|
||||
//! 设计依据:`docs/02-架构设计/单对话并行多轮-设计-2026-06-20.md`
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::planner::{Plan, SubTask};
|
||||
|
||||
/// Plan 执行总开关。false = 不启用(走单链 ReAct 旧行为)。
|
||||
///
|
||||
/// 运行时原子门控,支持热切换(通过 IPC 或前端设置开关)。
|
||||
/// 默认关。现状:PlanExecutor::execute 零生产调用点(仅测试),本执行器未接入主链;
|
||||
/// agentic loop 读本开关走的是 coordinator::dispatch(Phase 1 串行桩),与本文件无关。
|
||||
/// JoinSet 真并行留 Phase 3 对接时接通(见 execute 内 TODO)。
|
||||
/// 与 PLANNING_ENABLED 解耦:plan_hint 独立使能,编排可见性始终可开。
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
pub static PLAN_EXECUTION_ENABLED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// 设置 Plan 执行开关。
|
||||
pub fn set_plan_execution(enabled: bool) {
|
||||
PLAN_EXECUTION_ENABLED.store(enabled, Ordering::SeqCst);
|
||||
tracing::info!(enabled, "[PLAN-EXEC] 执行开关已更新");
|
||||
}
|
||||
|
||||
/// 读取 Plan 执行开关。
|
||||
pub fn plan_execution_enabled() -> bool {
|
||||
PLAN_EXECUTION_ENABLED.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// 单个 SubTask 的执行结果。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SubTaskResult {
|
||||
/// 对应 SubTask id
|
||||
pub id: String,
|
||||
/// 执行是否成功
|
||||
pub success: bool,
|
||||
/// 执行产出(工具结果摘要 / LLM 回复)
|
||||
pub output: String,
|
||||
}
|
||||
|
||||
/// Plan 执行器:按 DAG 层级调度子任务。
|
||||
///
|
||||
/// 泛型参数 `F` 为子任务执行函数:
|
||||
/// - 输入:`&SubTask`(子任务定义)
|
||||
/// - 输入:`&str`(父任务结果摘要,供子任务上下文参考)
|
||||
/// - 输出:`Future<Output = anyhow::Result<String>>`(子任务产出)
|
||||
///
|
||||
/// 调用方(Phase 3 对接 agentic loop 时)传入真实的执行闭包,
|
||||
/// 每个 SubTask 独立跑一轮 agentic loop,共享父上下文快照。
|
||||
pub struct PlanExecutor;
|
||||
|
||||
impl PlanExecutor {
|
||||
/// 按 Plan DAG 分层执行。
|
||||
///
|
||||
/// - 层间串行:上层全部完成才进下一层
|
||||
/// - 层内并行:同层 SubTask 用 JoinSet 并发
|
||||
/// - 失败传播:任一 SubTask 失败时,记录错误但继续执行同层其他任务(容错);
|
||||
/// 若需中断策略,调用方据返回结果自行判断。
|
||||
///
|
||||
/// 返回每个 SubTask 的执行结果(按 Plan 原始顺序排列)。
|
||||
pub async fn execute<F, Fut>(
|
||||
plan: &Plan,
|
||||
mut run_subtask: F,
|
||||
) -> anyhow::Result<Vec<SubTaskResult>>
|
||||
where
|
||||
F: FnMut(Arc<SubTask>, String) -> Fut,
|
||||
Fut: std::future::Future<Output = anyhow::Result<String>>,
|
||||
{
|
||||
let layers = plan
|
||||
.to_layers()
|
||||
.map_err(|e| anyhow::anyhow!("Plan 环依赖: {:?}", e.cycle))?;
|
||||
|
||||
let mut results: Vec<SubTaskResult> = Vec::new();
|
||||
let mut parent_summary = String::new();
|
||||
|
||||
for (layer_idx, layer) in layers.iter().enumerate() {
|
||||
tracing::info!(
|
||||
layer_idx,
|
||||
task_count = layer.len(),
|
||||
"[plan-exec] 执行第 {} 层, {} 个子任务",
|
||||
layer_idx,
|
||||
layer.len()
|
||||
);
|
||||
|
||||
let summary_clone = parent_summary.clone();
|
||||
|
||||
for task in layer {
|
||||
let task = Arc::new(task.clone());
|
||||
let summary = summary_clone.clone();
|
||||
// TODO(Phase 3 真并行): 当前为串行循环模拟并行(假并行),仅验证分层逻辑。
|
||||
// run_subtask 是 FnMut,不能直接 move 进 JoinSet(多任务并发调用);
|
||||
// 接真并行的改法:run_subtask 换 Arc<Fn> 或 channel 模式,
|
||||
// 本层全部 spawn 进 JoinSet 统一收集后再汇总 parent_summary,替换此串行 await。
|
||||
// 现状无用户可见影响:本执行器未接生产链路(execute 零调用点)。
|
||||
let result = run_subtask(task.clone(), summary).await;
|
||||
let sr = match result {
|
||||
Ok(output) => SubTaskResult {
|
||||
id: task.id.clone(),
|
||||
success: true,
|
||||
output,
|
||||
},
|
||||
Err(e) => SubTaskResult {
|
||||
id: task.id.clone(),
|
||||
success: false,
|
||||
output: e.to_string(),
|
||||
},
|
||||
};
|
||||
results.push(sr);
|
||||
}
|
||||
|
||||
// 汇总本层结果作为下层 parent_summary
|
||||
let layer_summary: Vec<String> = results
|
||||
.iter()
|
||||
.filter(|r| layer.iter().any(|t| t.id == r.id))
|
||||
.map(|r| format!("[{}] {}", r.id, r.output))
|
||||
.collect();
|
||||
parent_summary = layer_summary.join("\n");
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::planner::{Plan, SubTask};
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_simple_linear_plan() {
|
||||
// a → b → c (三层,各一个任务)
|
||||
let plan = Plan::from_tasks(vec![
|
||||
SubTask::new("a", "task-a"),
|
||||
SubTask::new("b", "task-b").with_deps(vec!["a".to_string()]),
|
||||
SubTask::new("c", "task-c").with_deps(vec!["b".to_string()]),
|
||||
]);
|
||||
|
||||
let results = PlanExecutor::execute(&plan, |task, _parent| async move {
|
||||
Ok(format!("done:{}", task.id))
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(results.len(), 3);
|
||||
assert!(results.iter().all(|r| r.success));
|
||||
assert_eq!(results[0].id, "a");
|
||||
assert_eq!(results[1].id, "b");
|
||||
assert_eq!(results[2].id, "c");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_parallel_layer() {
|
||||
// a → {b, c} → d (三层,第二层两个并行)
|
||||
let plan = Plan::from_tasks(vec![
|
||||
SubTask::new("a", "task-a"),
|
||||
SubTask::new("b", "task-b").with_deps(vec!["a".to_string()]),
|
||||
SubTask::new("c", "task-c").with_deps(vec!["a".to_string()]),
|
||||
SubTask::new("d", "task-d").with_deps(vec!["b".to_string(), "c".to_string()]),
|
||||
]);
|
||||
|
||||
let results = PlanExecutor::execute(&plan, |task, _parent| async move {
|
||||
Ok(format!("done:{}", task.id))
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(results.len(), 4);
|
||||
assert!(results.iter().all(|r| r.success));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_with_failure_continues() {
|
||||
// a(fail) → b: a 失败,b 仍执行(容错策略)
|
||||
let plan = Plan::from_tasks(vec![
|
||||
SubTask::new("a", "task-a"),
|
||||
SubTask::new("b", "task-b").with_deps(vec!["a".to_string()]),
|
||||
]);
|
||||
|
||||
let results = PlanExecutor::execute(&plan, |task, _parent| async move {
|
||||
if task.id == "a" {
|
||||
anyhow::bail!("task-a failed")
|
||||
}
|
||||
Ok("ok".to_string())
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(results.len(), 2);
|
||||
assert!(!results[0].success); // a 失败
|
||||
assert!(results[1].success); // b 仍执行
|
||||
}
|
||||
}
|
||||
+110
-24
@@ -5,7 +5,7 @@
|
||||
//! 但重试能在不侵入上层的前提下吸收瞬态错误,对低频后台调用零风险。
|
||||
//!
|
||||
//! 策略:
|
||||
//! - 最多 3 次尝试(初次 + 2 次重试),指数退避 1s → 2s → 4s + ±20% jitter
|
||||
//! - 最多 3 次尝试(初次 + 2 次重试),指数退避 1s → 2s → 4s + ±50% jitter
|
||||
//! - 总挂钟预算 30s(含 sleep + 各次请求耗时),超预算直接放弃重试返回最后错误
|
||||
//! - 仅对可重试错误重试: timeout / connect / 5xx / 429; 不重试 4xx(非429) / 参数错 / 鉴权错
|
||||
//! - 不引入 reqwest-retry / tower: complete 路径简单,手写 loop 零依赖、可调试、不与 SSE/双层 Semaphore 耦合
|
||||
@@ -15,11 +15,12 @@
|
||||
//! 重试期间不释放 Semaphore permit(已在调用方持有),对并发池有挤占 —— 但 complete 调用低频可接受。
|
||||
|
||||
use std::future::Future;
|
||||
use std::time::{Duration, SystemTime};
|
||||
use std::time::Duration;
|
||||
|
||||
use rand::Rng;
|
||||
use tracing::warn;
|
||||
|
||||
/// 最多尝试次数(含初次)。B-260616-07: 3 次 = 初次 + 2 次重试。
|
||||
/// 最多尝试次数(含初次)。3 次 = 初次 + 2 次重试。
|
||||
///
|
||||
/// 配置化 TODO: 未来接入 per-provider config(`AiProviderRecord.config` JSON)或全局开关
|
||||
/// (`useSetting('df-ai-max-retries')`)时改为读取配置。当前低频后台调用,常量足够。
|
||||
@@ -33,12 +34,18 @@ const BASE_BACKOFF_SECS: u64 = 1;
|
||||
/// 与单请求 60s timeout 配合: 最坏 3 次 × 60s = 180s,30s 预算会在此之前主动止损。
|
||||
const MAX_TOTAL_BUDGET: Duration = Duration::from_secs(30);
|
||||
|
||||
/// 单次退避基准上限(毫秒),与 [`MAX_TOTAL_BUDGET`] 同口径: 超预算的退避没有意义,
|
||||
/// attempt 走深时不再指数膨胀。与总预算的一致性由测试 `backoff_cap_matches_budget` 守住。
|
||||
/// (同构先例: df-tunnel 的 backoff_delay 也以 RECONNECT_MAX 封顶。)
|
||||
const MAX_BACKOFF_MS: u64 = 30_000;
|
||||
|
||||
/// jitter 上限(相对 base 的 ±比例)。避免重试风暴对齐。
|
||||
const JITTER_RATIO: f64 = 0.2;
|
||||
/// CR-XX: 范围扩到 ±50%(由 gen_range(-0.5..0.5) × JITTER_RATIO=1.0 合成)。
|
||||
const JITTER_RATIO: f64 = 1.0;
|
||||
|
||||
/// 一次尝试的分类结果 —— 在 anyhow 不透明化前决定是否值得重试。
|
||||
///
|
||||
/// CR-30-1: 暴露给 agentic.rs 流前重试复用(Init 失败分类 Fatal/Retryable 决定是否重试)。
|
||||
/// 暴露给 agentic.rs 流前重试复用(Init 失败分类 Fatal/Retryable 决定是否重试)。
|
||||
#[derive(Debug)]
|
||||
pub enum AttemptOutcome<T> {
|
||||
/// 成功,携带结果。
|
||||
@@ -58,29 +65,50 @@ pub fn is_reqwest_error_retryable(e: &reqwest::Error) -> bool {
|
||||
|
||||
/// 判定 HTTP 状态码是否可重试: 5xx 与 429 重试,其余(含 4xx)不重试。
|
||||
///
|
||||
/// CR-30-1: 暴露给 agentic.rs 流前重试复用(4xx Fatal 立即放弃,5xx/429 重试)。
|
||||
/// 暴露给 agentic.rs 流前重试复用(4xx Fatal 立即放弃,5xx/429 重试)。
|
||||
pub fn is_status_retryable(status: u16) -> bool {
|
||||
status == 429 || (500..600).contains(&status)
|
||||
}
|
||||
|
||||
/// 退避倍率 `2^(attempt-1)`,位移溢出安全。
|
||||
///
|
||||
/// 替代裸 `1u64 << (attempt - 1)`: attempt > 64 时位移越界(debug panic / release 环绕)、
|
||||
/// attempt = 0 时下溢 panic。现 attempt = 0 视作 1(倍率 1),attempt - 1 ≥ 64 时
|
||||
/// checked_shl 返回 None,倍率封顶 u64::MAX,最终时长由 [`MAX_BACKOFF_MS`] 兜底。
|
||||
/// 全输入无 panic,序列单调不减。
|
||||
fn backoff_multiplier(attempt: u32) -> u64 {
|
||||
match 1u64.checked_shl(attempt.saturating_sub(1)) {
|
||||
Some(m) => m,
|
||||
None => u64::MAX,
|
||||
}
|
||||
}
|
||||
|
||||
/// 退避基准毫秒(未加 jitter),`BASE_BACKOFF_SECS * 2^(attempt-1) * 1000`,封顶 [`MAX_BACKOFF_MS`]。
|
||||
///
|
||||
/// 乘法全程 saturating: 倍率封顶 u64::MAX 后 `* 1000` 也会二次溢出,不能裸乘。
|
||||
/// 单一事实源——backoff_delay 与单测的 base 断言都走这里,不再各写一份公式。
|
||||
fn base_backoff_ms(attempt: u32) -> u64 {
|
||||
BASE_BACKOFF_SECS
|
||||
.saturating_mul(backoff_multiplier(attempt))
|
||||
.saturating_mul(1000)
|
||||
.min(MAX_BACKOFF_MS)
|
||||
}
|
||||
|
||||
/// 指数退避 + jitter: 返回第 `attempt`(1-based)次重试前应 sleep 的时长。
|
||||
/// attempt=1 → ~1s, attempt=2 → ~2s, attempt=3 → ~4s,各 ±20% jitter。
|
||||
/// attempt=1 → ~1s, attempt=2 → ~2s, attempt=3 → ~4s,各 ±50% jitter。
|
||||
///
|
||||
/// jitter 用 SystemTime 纳秒取模生成(无依赖),避免多客户端同步重试风暴。
|
||||
/// jitter 用 `rand::thread_rng().gen_range(-0.5..0.5)` 生成 ±50% 比例,避免多客户端同步重试风暴。
|
||||
/// 以毫秒粒度计算后向下取整(避免秒级截断把 0.9s 砍成 0)。
|
||||
/// 全输入安全: attempt 任意 u32(含 0 / >64)不 panic,结果 ≤ [`MAX_BACKOFF_MS`]。
|
||||
///
|
||||
/// CR-30-1: 暴露 pub 供 src-tauri/agentic.rs 流前重试复用(对齐决策 F-260616-07 a1
|
||||
/// "复用 retry.rs backoff_delay 退避 1s→2s→4s+jitter"),避免重写退避逻辑。
|
||||
/// 暴露 pub 供 src-tauri/agentic.rs 流前重试复用
|
||||
/// ("复用 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;
|
||||
let base_ms = base_backoff_ms(attempt) as f64;
|
||||
// ±50% jitter,相对 base 时长的浮动比例
|
||||
let jitter_ratio = rand::thread_rng().gen_range(-0.5..0.5); // [-0.5, 0.5)
|
||||
let factor = 1.0 + jitter_ratio * JITTER_RATIO;
|
||||
let ms = (base_ms * factor).clamp(0.0, MAX_BACKOFF_MS as f64) as u64;
|
||||
Duration::from_millis(ms)
|
||||
}
|
||||
|
||||
@@ -93,7 +121,7 @@ pub fn backoff_delay(attempt: u32) -> Duration {
|
||||
/// 调用方约定: `attempt_fn` 内部应保留完整请求重发能力(每次重建 RequestBuilder),
|
||||
/// 因为 reqwest::RequestBuilder 一次 `.send()` 后不可复用。
|
||||
///
|
||||
/// CR-30-1: 仍 pub(crate)(complete() 内部用),流前重试 stream_recv/agentic 不走此函数
|
||||
/// 仍 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>(
|
||||
@@ -162,12 +190,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn backoff_is_monotonic_within_jitter() {
|
||||
// base 序列: 1, 2, 4 秒; jitter ±20% 后仍在 [0.8,4.8] 范围
|
||||
// base 序列: 1, 2, 4 秒; jitter ±50%(JITTER_RATIO=1.0) 后仍在 [0.5x, 1.5x] 范围
|
||||
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;
|
||||
let base_ms = base_backoff_ms(attempt);
|
||||
let lo = (base_ms as f64 * (1.0 - JITTER_RATIO / 2.0)) as u64;
|
||||
let hi = (base_ms as f64 * (1.0 + JITTER_RATIO / 2.0)) as u64;
|
||||
assert!(
|
||||
d.as_millis() as u64 >= lo && d.as_millis() as u64 <= hi,
|
||||
"attempt={} delay={:?} 应在 [{},{}]ms",
|
||||
@@ -179,6 +207,64 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// 倍率溢出安全: attempt=0 不下溢,≥65 封顶 u64::MAX,序列单调不减
|
||||
#[test]
|
||||
fn backoff_multiplier_extremes_safe() {
|
||||
assert_eq!(backoff_multiplier(0), 1, "attempt=0 视作 1,不下溢 panic");
|
||||
assert_eq!(backoff_multiplier(1), 1);
|
||||
assert_eq!(backoff_multiplier(2), 2);
|
||||
assert_eq!(backoff_multiplier(3), 4);
|
||||
assert_eq!(backoff_multiplier(63), 1u64 << 62);
|
||||
assert_eq!(backoff_multiplier(64), 1u64 << 63, "u64 可表达的最大位移");
|
||||
assert_eq!(backoff_multiplier(65), u64::MAX, "checked_shl 越界封顶");
|
||||
assert_eq!(backoff_multiplier(100), u64::MAX);
|
||||
assert_eq!(backoff_multiplier(u32::MAX), u64::MAX);
|
||||
let seq: Vec<u64> = [0u32, 1, 2, 3, 10, 63, 64, 65, 100, u32::MAX]
|
||||
.iter()
|
||||
.map(|&a| backoff_multiplier(a))
|
||||
.collect();
|
||||
assert!(seq.windows(2).all(|w| w[0] <= w[1]), "倍率应单调不减: {:?}", seq);
|
||||
}
|
||||
|
||||
/// 基准毫秒单调不减、封顶值正确(2^5×1s=32s 已超 30s 预算,从 attempt=6 起封顶)
|
||||
#[test]
|
||||
fn base_backoff_ms_monotonic_and_capped() {
|
||||
let mut prev = 0u64;
|
||||
for attempt in [0u32, 1, 2, 3, 4, 5, 6, 10, 63, 64, 65, 100, u32::MAX] {
|
||||
let b = base_backoff_ms(attempt);
|
||||
assert!(b >= prev, "attempt={} base_ms={} 应 ≥ 前值 {}", attempt, b, prev);
|
||||
assert!(b <= MAX_BACKOFF_MS, "attempt={} base_ms={} 应 ≤ {}", attempt, b, MAX_BACKOFF_MS);
|
||||
prev = b;
|
||||
}
|
||||
assert_eq!(base_backoff_ms(1), 1000);
|
||||
assert_eq!(base_backoff_ms(2), 2000);
|
||||
assert_eq!(base_backoff_ms(3), 4000);
|
||||
assert_eq!(base_backoff_ms(5), 16_000, "未触及封顶");
|
||||
assert_eq!(base_backoff_ms(6), MAX_BACKOFF_MS, "32s 超预算封顶 30s");
|
||||
assert_eq!(base_backoff_ms(u32::MAX), MAX_BACKOFF_MS);
|
||||
}
|
||||
|
||||
/// 极端 attempt 下 backoff_delay 不 panic(修复: 1u64<<(attempt-1) 曾 debug panic/release 环绕)
|
||||
#[test]
|
||||
fn backoff_delay_extreme_attempts_no_panic_and_capped() {
|
||||
for attempt in [0u32, 1, 10, 63, 64, 65, 100, u32::MAX] {
|
||||
let ms = backoff_delay(attempt).as_millis() as u64;
|
||||
assert!(
|
||||
ms <= MAX_BACKOFF_MS,
|
||||
"attempt={} 实际 {}ms 应 ≤ {}ms(含 jitter 后仍不超)",
|
||||
attempt,
|
||||
ms,
|
||||
MAX_BACKOFF_MS
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 封顶常量与总预算同口径(改预算时两处同步)
|
||||
#[test]
|
||||
fn backoff_cap_matches_budget() {
|
||||
assert_eq!(MAX_BACKOFF_MS, MAX_TOTAL_BUDGET.as_millis() as u64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_classification() {
|
||||
assert!(is_status_retryable(429));
|
||||
|
||||
+241
-33
@@ -1,37 +1,45 @@
|
||||
//! 模型路由器 — F-01 阶段4
|
||||
//! 模型路由器。
|
||||
//!
|
||||
//! 纯函数核心,零 IO / 零状态。给定 TaskRequirements + 候选池,返回最优 ModelConfig。
|
||||
//! 不接调用点(那是阶段5:agentic.rs / title.rs / knowledge_inject.rs / project.rs /
|
||||
//! 不接调用点(那是调用方: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` 方法)。
|
||||
//! ModelRouter 为单元结构,select 是无状态关联函数。
|
||||
|
||||
// 阶段5: 调用点经 `df_ai::router::{Modality, Capability, CostTier, IntelligenceTier}`
|
||||
// 直接 import 维度枚举构造 TaskRequirements(对齐任务规格 import 风格),re-export 避免调用点
|
||||
// 调用点经 `df_ai::router::{Modality, Capability, CostTier, IntelligenceTier}`
|
||||
// 直接 import 维度枚举构造 TaskRequirements,re-export 避免调用点
|
||||
// 各自从 df_ai_core::model 取(跨 crate 路径冗长)。select/select_model_id 仅借用枚举,无重定义。
|
||||
// 注:CostTier/IntelligenceTier 路由已解耦(2026-06-18 B-260618-03)——provider /v1/models API
|
||||
// 注:CostTier/IntelligenceTier 路由已解耦——provider /v1/models API
|
||||
// 不返回这两维度,数据无客观依据不可信,不参与硬路由;re-export 保留供未来真实判别源。
|
||||
// ModelTier 从 crate::intent re-export(同 crate,无跨 crate 路径问题),供调用点构造
|
||||
// `tier: suggested_model_tier(&intent)` 传入,router 同 weight 时按 tier tiebreak。
|
||||
pub use crate::intent::ModelTier;
|
||||
pub use df_ai_core::model::{Capability, CostTier, IntelligenceTier, Modality, ModelConfig};
|
||||
|
||||
/// 任务对模型的需求(3 维度)。
|
||||
/// 任务对模型的需求(4 维度)。
|
||||
///
|
||||
/// 由调用点构造(阶段5),描述本次调用需要什么模态/能力/上下文,
|
||||
/// 由调用点构造,描述本次调用需要什么模态/能力/上下文/档位,
|
||||
/// 交 ModelRouter::select 在候选池中选最优模型。
|
||||
///
|
||||
/// 路由已解耦(2026-06-18 B-260618-03):原 `min_intelligence`/`max_cost` 两字段删除。
|
||||
/// 路由已解耦:原 `min_intelligence`/`max_cost` 两字段删除。
|
||||
/// provider /v1/models API 不返回 cost_tier/intelligence,这两维度 100% 靠预设表写死 +
|
||||
/// 模型名启发式猜,数据无客观依据不可信,不应参与硬路由。枚举(CostTier/IntelligenceTier)
|
||||
/// 保留供未来出现真实判别源时再接回。
|
||||
///
|
||||
/// `tier`(子项 2 根因修复):任务建议的模型档位(由 `intent::suggested_model_tier` 派生,
|
||||
/// 或无意图场景传 None)。原 `max_by_key(weight)` 同 weight 返最后一个,顺序敏感无语义;
|
||||
/// 接 tier 后,同 weight 时优先选 `intelligence` 满足 tier 下限的候选(见 `tier_match`)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TaskRequirements {
|
||||
/// 任务所需的模态集合(全子集匹配:任务所需模态都必须在模型模态里)
|
||||
pub modalities: Vec<Modality>,
|
||||
/// 是否需要工具调用能力(needs_tool_use=true 时候选必须含 Capability::ToolUse)
|
||||
pub needs_tool_use: bool,
|
||||
/// 预估上下文大小(tokens,模型 context_window 必须 >= 此值)
|
||||
/// 预估上下文大小(tokens,模型 context_window 必须 >= 此值)。
|
||||
/// 调用点应传 TokenEstimator 估值而非 0(0 = 当前空操作,窗口过滤维度失效)。
|
||||
pub estimated_context: usize,
|
||||
/// 任务建议的模型档位(意图→ModelTier,无意图场景 None)。
|
||||
/// 同 weight 候选间按 tier tiebreak(满足 tier 下限的候选胜)。
|
||||
pub tier: Option<ModelTier>,
|
||||
}
|
||||
|
||||
/// 模型路由器(单元结构,无状态)。
|
||||
@@ -39,6 +47,33 @@ pub struct TaskRequirements {
|
||||
/// select 为关联函数:给定需求 + 候选池,执行过滤链选最优模型。
|
||||
pub struct ModelRouter;
|
||||
|
||||
/// ModelTier → IntelligenceTier 下限映射(子项 2 tier tiebreak 用)。
|
||||
///
|
||||
/// 任务建议档位(ModelTier:F-Heavy)映射到模型智力下限(IntelligenceTier),
|
||||
/// 同 weight 候选间优先选 `model.intelligence >= 下限` 的(满足任务复杂度需求)。
|
||||
/// - `Fast` → `Lite`(轻量意图,任何模型都满足)
|
||||
/// - `Standard` → `Standard`(日常,需 Standard 及以上)
|
||||
/// - `Heavy` → `Plus`(复杂推理,需 Plus 及以上)
|
||||
fn tier_min_intelligence(tier: ModelTier) -> IntelligenceTier {
|
||||
match tier {
|
||||
ModelTier::Fast => IntelligenceTier::Lite,
|
||||
ModelTier::Standard => IntelligenceTier::Standard,
|
||||
ModelTier::Heavy => IntelligenceTier::Plus,
|
||||
}
|
||||
}
|
||||
|
||||
/// 同 weight tiebreak:候选是否满足任务建议档位的智力下限。
|
||||
///
|
||||
/// 返 `bool`(满足 = true)。调用方在 `max_by` 闭包内 `a_match.cmp(&b_match)` 把 bool 转 Ordering:
|
||||
/// a 满足而 b 不满足 → Greater(a 胜);都满足/都不满足 → Equal(max_by 并列返最后一个)。
|
||||
/// - `req.tier = None`(无意图场景,标题/扫描/压缩):恒 true(所有候选等价,保留旧行为)。
|
||||
/// - `req.tier = Some(t)`:返 `model_intel >= tier_min_intelligence(t)`。
|
||||
fn tier_match(model_intel: IntelligenceTier, req_tier: Option<ModelTier>) -> bool {
|
||||
req_tier
|
||||
.map(|t| model_intel >= tier_min_intelligence(t))
|
||||
.unwrap_or(true) // None → 视作满足(tiebreak 维度不参与,保旧行为)
|
||||
}
|
||||
|
||||
impl ModelRouter {
|
||||
/// 在候选池中选出最优模型(过滤链)。
|
||||
///
|
||||
@@ -47,23 +82,42 @@ impl ModelRouter {
|
||||
/// 2. 模态匹配 — 任务所需模态全在模型模态里
|
||||
/// 3. 能力匹配 — needs_tool_use 时候选必须含 ToolUse
|
||||
/// 4. 窗口够大 — context_window >= estimated_context
|
||||
/// 5. max_by_key 选最优:纯 weight 主导(权重高者胜)
|
||||
/// 5. max_by 选最优:**主键 weight 降序**(权重高者胜),**同 weight 时按 tier tiebreak**
|
||||
/// (满足任务建议档位 `intelligence >= tier_min` 的候选胜)。
|
||||
///
|
||||
/// 路由已解耦(2026-06-18 B-260618-03):原「智力达标」/「成本可控」两步删除,
|
||||
/// 原第 7 步排序的 `Reverse(cost_tier)` 同权重选便宜也已删除——排序纯 weight 主导。
|
||||
/// cost_tier/intelligence 数据无客观依据(provider /v1/models 不返回,靠预设表+模型名
|
||||
/// 启发式猜),不参与硬路由。枚举保留供未来真实判别源再接回。
|
||||
/// tier tiebreak(子项 2 根因修复):原 `max_by_key(weight)` 同 weight 返最后一个,
|
||||
/// 顺序敏感无语义(intent suggested_model_tier 恒 None)→ 现接 `req.tier`
|
||||
/// (由 intent→ModelTier 派生),同 weight 时优先选满足档位下限的候选。
|
||||
/// `req.tier = None` 时 tiebreak 维度退化为等价(保留旧行为,标题/扫描路径无回归)。
|
||||
///
|
||||
/// 路由已解耦:原「智力达标」/「成本可控」两步删除,
|
||||
/// 原第 7 步排序的 `Reverse(cost_tier)` 同权重选便宜也已删除——排序主键 weight 主导,
|
||||
/// tiebreak 由 tier(基于 intelligence,有客观档位映射依据)替代纯 max_by_key 顺序。
|
||||
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. 主键 weight 降序,同 weight 时 tier tiebreak(满足档位下限的候选胜)。
|
||||
// max_by 语义:comparator 返 a 相对 b 的 Ordering,Greater = a 胜;
|
||||
// 同 key(全 Equal)时 max_by 返最后一个(对齐原 max_by_key 并列返最后的语义)。
|
||||
.max_by(|a, b| {
|
||||
// 主键:weight,a 大则 a 胜(Greater)。
|
||||
let by_weight = a.weight.cmp(&b.weight);
|
||||
if by_weight != std::cmp::Ordering::Equal {
|
||||
return by_weight;
|
||||
}
|
||||
// tiebreak:tier 满足度。a 满足档位下限而 b 不满足 → a 胜(Greater)。
|
||||
// tier_match 返 bool,bool 比较:true > false(满足 > 不满足)。
|
||||
let a_match = tier_match(a.intelligence, req.tier);
|
||||
let b_match = tier_match(b.intelligence, req.tier);
|
||||
a_match.cmp(&b_match)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 阶段5 调用点 helper — 路由选模型并直接返回 model_id(纯函数)。
|
||||
/// 调用点 helper — 路由选模型并直接返回 model_id(纯函数)。
|
||||
///
|
||||
/// 给定 TaskRequirements + 候选池,返回最优模型的 `model_id`。
|
||||
/// 调用点用法:`provider.model_configs`(Vec<ModelConfig>)→ `select_model_id(&req, &pool)`
|
||||
@@ -77,6 +131,22 @@ pub fn select_model_id(req: &TaskRequirements, pool: &[ModelConfig]) -> Option<S
|
||||
ModelRouter::select(req, pool).map(|m| m.model_id.clone())
|
||||
}
|
||||
|
||||
/// 判断 model_id 是否为图像生成模型(文本任务不应使用)。
|
||||
/// 用于 select 返回 None 兜底 default_model 时的防御:避免标题/压缩等文本任务
|
||||
/// 误用图像模型(如 sensenova-u1-fast/dall-e)致 LLM 404 刷屏(治 default_model 配错为图像模型)。
|
||||
/// 判断:pool 有该模型 → 校验模态是否含 Text(最可靠);pool 无 → 名字启发式(已知图像模型模式)。
|
||||
pub fn is_likely_image_model(model_id: &str, pool: &[ModelConfig]) -> bool {
|
||||
if let Some(m) = pool.iter().find(|m| m.model_id == model_id) {
|
||||
return !m.modalities.contains(&Modality::Text);
|
||||
}
|
||||
let lower = model_id.to_lowercase();
|
||||
const IMAGE_PATTERNS: &[&str] = &[
|
||||
"dall-e", "gpt-image", "imagen", "flux", "stable-diffusion",
|
||||
"sensenova-u1", "u1-fast", "midjourney", "sd-", "sdxl",
|
||||
];
|
||||
IMAGE_PATTERNS.iter().any(|p| lower.contains(p))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -93,20 +163,22 @@ mod tests {
|
||||
intelligence: IntelligenceTier::Standard,
|
||||
weight: 50,
|
||||
context_window: 8192,
|
||||
temperature_supported: true,
|
||||
probe_source: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造一个宽松需求(默认全过过滤,调用方按需覆盖字段)。
|
||||
/// 构造一个宽松需求(默认全过过滤,调用方按需覆盖字段)。tier=None 保留旧行为。
|
||||
fn req() -> TaskRequirements {
|
||||
TaskRequirements {
|
||||
modalities: vec![Modality::Text],
|
||||
needs_tool_use: false,
|
||||
estimated_context: 0,
|
||||
tier: None,
|
||||
}
|
||||
}
|
||||
|
||||
// ── 阶段5 select_model_id helper ──
|
||||
// ── select_model_id helper ──
|
||||
|
||||
#[test]
|
||||
fn select_model_id_empty_pool_returns_none() {
|
||||
@@ -248,7 +320,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── 步骤 4(原智力/成本过滤已解耦 B-260618-03):窗口够大 ──
|
||||
// ── 步骤 4(原智力/成本过滤已解耦):窗口够大 ──
|
||||
|
||||
#[test]
|
||||
fn context_window_insufficient() {
|
||||
@@ -261,7 +333,36 @@ mod tests {
|
||||
assert!(ModelRouter::select(&r, &pool).is_none());
|
||||
}
|
||||
|
||||
// ── 步骤 5:max_by_key (纯 weight) ──
|
||||
#[test]
|
||||
fn estimated_context_filters_small_window_model() {
|
||||
// 子项 1 根因修复回归测:estimated_context 非零(调用点传 TokenEstimator 估值,非死代码 0)
|
||||
// → 步骤 4 窗口过滤生效。两候选:小窗口(4K)weight 90(高诱惑)+ 大窗口(128K)weight 50。
|
||||
// 任务预估 8K 上下文 → 小窗口模型被滤,只剩大窗口候选胜(即使 weight 低)。
|
||||
// 若调用点回退传 0(原 bug),两候选窗口都 >= 0,weight 90 的小窗口模型会胜(误选)。
|
||||
let pool = vec![
|
||||
ModelConfig {
|
||||
weight: 90,
|
||||
context_window: 4096, // 小窗口,高 weight 诱惑
|
||||
..model("small-window-heavy")
|
||||
},
|
||||
ModelConfig {
|
||||
weight: 50,
|
||||
context_window: 131072, // 大窗口,低 weight
|
||||
..model("large-window-light")
|
||||
},
|
||||
];
|
||||
let r = TaskRequirements {
|
||||
estimated_context: 8000, // 任务预估 8K,小窗口模型装不下
|
||||
..req()
|
||||
};
|
||||
assert_eq!(
|
||||
ModelRouter::select(&r, &pool).unwrap().model_id,
|
||||
"large-window-light",
|
||||
"estimated_context 非零应滤掉小窗口候选,即使其 weight 更高"
|
||||
);
|
||||
}
|
||||
|
||||
// ── 步骤 5:max_by(weight 主键,tier tiebreak) ──
|
||||
|
||||
#[test]
|
||||
fn single_match_returns_it() {
|
||||
@@ -293,8 +394,9 @@ mod tests {
|
||||
|
||||
#[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 取舍。
|
||||
// 同 weight 70,tier=None(req() 默认):tiebreak 维度退等价,max_by 遇并列返最后一个
|
||||
// (rust Iterator::max_by 语义,与原 max_by_key 一致)。验证同 weight + tier=None
|
||||
// 不再按 cost 取舍,行为对齐接入 tier tiebreak 前的语义(标题/扫描路径无回归)。
|
||||
let pool = vec![
|
||||
ModelConfig {
|
||||
weight: 70,
|
||||
@@ -315,17 +417,17 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn all_dimensions_match_picks_best() {
|
||||
// 3+ 候选各维度参差,验证过滤链全过 + max_by_key 纯 weight 选最优。
|
||||
// (B-260618-03:智力/成本过滤已解耦,原步骤 4/5 删除,候选 d 不再因 intelligence 滤掉)
|
||||
// 3+ 候选各维度参差,验证过滤链全过 + max_by(weight, tier) 选最优。
|
||||
// (智力/成本过滤已解耦,原步骤 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 最高,胜
|
||||
// a: weight 60 → 通过全部过滤
|
||||
// b: weight 80 → 通过 — weight 次高档(与 c 并列,但 tier=None 故 tiebreak 退等价)
|
||||
// c: weight 80 → 通过 — 同 weight 80,tier=None 时 max_by 返并列最后一个
|
||||
// d: weight 90 → 通过(intelligence 不参与过滤)— weight 最高,胜
|
||||
// e: enabled=false → 步骤 1 滤掉
|
||||
//
|
||||
// 预期:d 胜(weight 90 最高,不再被 intelligence 滤掉)
|
||||
// 预期:d 胜(weight 90 最高,tier tiebreak 不触发因 weight 已决出胜负)
|
||||
let pool = vec![
|
||||
ModelConfig {
|
||||
weight: 60,
|
||||
@@ -361,7 +463,113 @@ mod tests {
|
||||
modalities: vec![Modality::Text],
|
||||
needs_tool_use: true,
|
||||
estimated_context: 0,
|
||||
tier: None,
|
||||
};
|
||||
assert_eq!(ModelRouter::select(&r, &pool).unwrap().model_id, "d");
|
||||
}
|
||||
|
||||
// ── 步骤 5 tiebreak(子项 2):同 weight 时 tier 决胜 ──
|
||||
|
||||
#[test]
|
||||
fn tier_tiebreak_heavy_prefers_meeting_model() {
|
||||
// 子项 2 根因修复:同 weight 时,任务建议 Heavy(req.tier=Some(Heavy))→ tier_min=Plus,
|
||||
// 满足 intelligence>=Plus 的候选胜过不满足的。
|
||||
// 候选 a:Standard(不满足 Plus),候选 b:Plus(满足),同 weight 50。
|
||||
// 预期:b 胜(满足 Heavy 档位下限)。原 max_by_key 会返最后一个(顺序敏感无语义)。
|
||||
let pool = vec![
|
||||
ModelConfig {
|
||||
weight: 50,
|
||||
intelligence: IntelligenceTier::Standard,
|
||||
..model("a_standard")
|
||||
},
|
||||
ModelConfig {
|
||||
weight: 50,
|
||||
intelligence: IntelligenceTier::Plus,
|
||||
..model("b_plus")
|
||||
},
|
||||
];
|
||||
let r = TaskRequirements {
|
||||
tier: Some(ModelTier::Heavy),
|
||||
..req()
|
||||
};
|
||||
assert_eq!(
|
||||
ModelRouter::select(&r, &pool).unwrap().model_id,
|
||||
"b_plus",
|
||||
"同 weight 时 Heavy 档位应优先选 Plus(满足)而非 Standard(不满足)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_tiebreak_none_preserves_max_by_key_semantics() {
|
||||
// tier=None(标题/扫描/压缩无意图场景)→ tiebreak 维度退等价,
|
||||
// max_by 同 key 返最后一个(对齐原 max_by_key 行为,无回归)。
|
||||
// 候选 a/b 同 weight 70,顺序 a 在前 b 在后 → 预期返 b(max_by 并列返最后)。
|
||||
let pool = vec![
|
||||
ModelConfig {
|
||||
weight: 70,
|
||||
intelligence: IntelligenceTier::Standard,
|
||||
..model("a")
|
||||
},
|
||||
ModelConfig {
|
||||
weight: 70,
|
||||
intelligence: IntelligenceTier::Plus,
|
||||
..model("b")
|
||||
},
|
||||
];
|
||||
// tier=None 时即使 b 的 intelligence 更高也不应胜(tiebreak 不参与),保 max_by_key 语义。
|
||||
assert_eq!(ModelRouter::select(&req(), &pool).unwrap().model_id, "b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_tiebreak_chat_fast_any_model_meets_lite() {
|
||||
// 任务建议 Fast → tier_min=Lite,任何模型 intelligence>=Lite(Lite 是最低档)→ 都满足。
|
||||
// 故 Fast 档位下 tiebreak 退等价(都满足),max_by 同 weight 返最后一个,行为不变。
|
||||
let pool = vec![
|
||||
ModelConfig {
|
||||
weight: 50,
|
||||
intelligence: IntelligenceTier::Lite,
|
||||
..model("a_lite")
|
||||
},
|
||||
ModelConfig {
|
||||
weight: 50,
|
||||
intelligence: IntelligenceTier::Ultra,
|
||||
..model("b_ultra")
|
||||
},
|
||||
];
|
||||
let r = TaskRequirements {
|
||||
tier: Some(ModelTier::Fast),
|
||||
..req()
|
||||
};
|
||||
// 都满足 Lite 下限 → tiebreak 等价 → max_by 返最后一个 = b_ultra
|
||||
assert_eq!(ModelRouter::select(&r, &pool).unwrap().model_id, "b_ultra");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_tiebreak_weight_still_dominates() {
|
||||
// tier 不凌驾 weight:weight 高者永远胜,即使低 weight 候选满足 tier 而高 weight 不满足。
|
||||
// 候选 a:weight 90,Standard(不满足 Heavy/Plus)。
|
||||
// 候选 b:weight 50,Plus(满足 Heavy)。
|
||||
// 预期:a 胜(weight 主键优先,tiebreak 只在 weight 相同时触发)。
|
||||
let pool = vec![
|
||||
ModelConfig {
|
||||
weight: 90,
|
||||
intelligence: IntelligenceTier::Standard,
|
||||
..model("a_heavy_weight")
|
||||
},
|
||||
ModelConfig {
|
||||
weight: 50,
|
||||
intelligence: IntelligenceTier::Plus,
|
||||
..model("b_meets_tier")
|
||||
},
|
||||
];
|
||||
let r = TaskRequirements {
|
||||
tier: Some(ModelTier::Heavy),
|
||||
..req()
|
||||
};
|
||||
assert_eq!(
|
||||
ModelRouter::select(&r, &pool).unwrap().model_id,
|
||||
"a_heavy_weight",
|
||||
"weight 主键应凌驾 tier tiebreak"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
//! 原生 SSE 流式解析器 — 替代 eventsource-stream 库
|
||||
//!
|
||||
//! eventsource-stream 0.2 在 Windows 上对 Deepseek 等 provider
|
||||
//! 的 SSE 响应解析时报 "Transport error: error decoding response body" 错误。
|
||||
//!
|
||||
//! 根因分析:
|
||||
//! eventsource-stream 内部对 bytes_stream 做严格的 UTF-8 + SSE 协议校验,遇到以下情况
|
||||
//! 即报错(且不可恢复):
|
||||
//! - 流中断时未完整接收 UTF-8 字符(网络抖动常见)
|
||||
//! - 缺少结束的 \n\n(连接断开常见)
|
||||
//! - 非 ASCII 字符的多字节序列跨 chunk 边界
|
||||
//!
|
||||
//! 本解析器实现:
|
||||
//! - 宽松的 UTF-8 处理(用 bytes 累积,优先 `String::from_utf8` 严格解码保留多字节完整,
|
||||
//! 失败再降级 `from_utf8_lossy` 不报错)
|
||||
//! - SSE 协议简单解析(分隔符兼容 `\n\n` / `\r\n\r\n` / `\r\r` 三种行尾归一,
|
||||
//! data: 前缀提取)
|
||||
//! - 多字节续接:事件体只在定位到完整分隔符后才解码,跨 chunk 的多字节字符在
|
||||
//! buffer 中天然拼接复原(分隔符为 ASCII,不落在多字节序列中间)
|
||||
//! - 容错:解析失败时跳过该事件继续,不中断流;buffer 设 `BUF_MAX` 上限防 OOM,
|
||||
//! 超限时按事件边界对齐保尾裁剪(丢最旧的超限事件整体),不产出半截破损事件
|
||||
//! - 返回 Vec<String>(每个元素是一个事件 data 字段拼接内容)
|
||||
|
||||
use futures::Stream;
|
||||
use std::pin::Pin;
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
/// SSE 事件流的 data 字段内容
|
||||
pub type SseEvent = String;
|
||||
|
||||
/// 缓冲区字节上限(防御性):正常流会被 parse_events 持续消费,
|
||||
/// 仅畸形上游(持续不发分隔符)时触发裁剪,避免无界增长 OOM。
|
||||
const BUF_MAX: usize = 1 << 20; // 1 MiB
|
||||
|
||||
/// 原生 SSE 解析器流:包装 bytes_stream,产出 Vec<SseEvent>(一次 poll 可能产出多个事件)
|
||||
pub struct SseStream<S> {
|
||||
inner: S,
|
||||
buffer: Vec<u8>,
|
||||
/// 超限裁剪置位:无边界的超限事件已整体丢弃,但其尾部字节仍在后续 chunk 中,
|
||||
/// 到达收尾 boundary 前的数据须静默吞掉,避免残缺事件体被当完整事件产出
|
||||
skip_partial: bool,
|
||||
}
|
||||
|
||||
impl<S> SseStream<S>
|
||||
where
|
||||
S: Stream<Item = Result<bytes::Bytes, reqwest::Error>> + Unpin,
|
||||
{
|
||||
pub fn new(inner: S) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
buffer: Vec::with_capacity(8192),
|
||||
skip_partial: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 buffer 解析完整的 SSE 事件,返回事件列表。
|
||||
///
|
||||
/// 事件分隔符兼容三种行尾归一:
|
||||
/// - `\n\n` (LF LF,规范形态)
|
||||
/// - `\r\n\r\n` (CRLF CRLF,部分中转站/反代发送)
|
||||
/// - `\r\r` (CR CR,极少见但同源)
|
||||
///
|
||||
/// 取最早出现的分隔符;事件体内部的 `\r\n` 在解码后归一为 `\n`,
|
||||
/// 确保 `lines()` / `strip_prefix` 等行级解析正常工作。
|
||||
///
|
||||
/// 多字节续接:事件体只在定位到完整分隔符后才解码,跨 chunk 的多字节
|
||||
/// 字符在 buffer 中天然拼接复原(分隔符为 ASCII,不会落在多字节序列中间)。
|
||||
/// 解码用 `String::from_utf8`(失败再降级 lossy),避免把合法多字节误判为残缺。
|
||||
fn parse_events(&mut self) -> Vec<SseEvent> {
|
||||
// 超限裁剪收尾:被整体丢弃的残缺事件,其后续字节以首个 boundary 收尾,
|
||||
// 此处静默吞掉不产出,之后的正常事件恢复正常解析
|
||||
if self.skip_partial {
|
||||
match Self::find_event_boundary(&self.buffer) {
|
||||
Some((pos, len)) => {
|
||||
self.buffer.drain(..pos + len);
|
||||
self.skip_partial = false;
|
||||
}
|
||||
None => return Vec::new(),
|
||||
}
|
||||
}
|
||||
let mut events = Vec::new();
|
||||
loop {
|
||||
// 同时识别三种分隔符,取最早出现的位置与该分隔符的字节长度
|
||||
let sep = Self::find_event_boundary(&self.buffer);
|
||||
let (sep_pos, sep_len) = match sep {
|
||||
Some(v) => v,
|
||||
None => break,
|
||||
};
|
||||
// 取出事件体 + 分隔符整体(buffer 前 sep_pos+sep_len 字节)
|
||||
let event_bytes: Vec<u8> = self.buffer.drain(..sep_pos + sep_len).collect();
|
||||
// 去掉末尾的分隔符,得到事件体
|
||||
let body_end = event_bytes.len().saturating_sub(sep_len);
|
||||
let body_bytes = &event_bytes[..body_end];
|
||||
// 解码:优先严格 UTF-8(保留多字节完整),失败再降级 lossy(不崩)
|
||||
let mut event_text = match std::str::from_utf8(body_bytes) {
|
||||
Ok(s) => s.to_owned(),
|
||||
Err(_) => String::from_utf8_lossy(body_bytes).into_owned(),
|
||||
};
|
||||
// 事件体内 CRLF / 裸 CR 归一为 LF,保证后续行级解析一致
|
||||
if event_text.contains('\r') {
|
||||
event_text = event_text.replace("\r\n", "\n").replace('\r', "\n");
|
||||
}
|
||||
let data = Self::extract_data_fields(&event_text);
|
||||
if !data.is_empty() {
|
||||
events.push(data);
|
||||
}
|
||||
}
|
||||
events
|
||||
}
|
||||
|
||||
/// 在 buffer 中查找最早的事件分隔符,返回 `(起始位置, 分隔符字节长度)`。
|
||||
/// 扫描 `\r\n\r\n` / `\n\n` / `\r\r` 三种形态,取最小起始位置(取最早分隔符)。
|
||||
fn find_event_boundary(buf: &[u8]) -> Option<(usize, usize)> {
|
||||
// candidates: (pattern, length)
|
||||
const PATTERNS: &[(&[u8], usize)] = &[
|
||||
(b"\r\n\r\n", 4),
|
||||
(b"\n\n", 2),
|
||||
(b"\r\r", 2),
|
||||
];
|
||||
let mut best: Option<(usize, usize)> = None;
|
||||
for &(pat, len) in PATTERNS {
|
||||
// windows 匹配;找到首次出现位置
|
||||
if let Some(pos) = buf.windows(pat.len()).position(|w| w == pat) {
|
||||
match best {
|
||||
None => best = Some((pos, len)),
|
||||
Some((bpos, _)) if pos < bpos => best = Some((pos, len)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// 从 SSE 事件文本中提取所有 data: 行的内容,拼接为单个字符串(多个 data 行用 \n 连接)
|
||||
fn extract_data_fields(event_text: &str) -> String {
|
||||
let mut data_parts: Vec<&str> = Vec::new();
|
||||
for line in event_text.lines() {
|
||||
if let Some(rest) = line.strip_prefix("data:") {
|
||||
let rest = rest.strip_prefix(' ').unwrap_or(rest);
|
||||
data_parts.push(rest);
|
||||
}
|
||||
// 忽略 event:/id:/retry: 等其他 SSE 字段(OpenAI/Anthropic 协议未使用)
|
||||
}
|
||||
data_parts.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> Stream for SseStream<S>
|
||||
where
|
||||
S: Stream<Item = Result<bytes::Bytes, reqwest::Error>> + Unpin,
|
||||
{
|
||||
type Item = Result<Vec<SseEvent>, String>;
|
||||
|
||||
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
||||
use futures::StreamExt;
|
||||
loop {
|
||||
// 先尝试从 buffer 解析完整事件
|
||||
let events = self.parse_events();
|
||||
if !events.is_empty() {
|
||||
return Poll::Ready(Some(Ok(events)));
|
||||
}
|
||||
|
||||
// buffer 不足以解析出完整事件,从 inner 读更多数据
|
||||
match self.inner.poll_next_unpin(cx) {
|
||||
Poll::Ready(Some(Ok(chunk))) => {
|
||||
self.buffer.extend_from_slice(&chunk);
|
||||
// 防御:缓冲区不应无限增长。正常情况下 parse_events 会持续消费,
|
||||
// 超限只发生在单个事件体超过 BUF_MAX 的场景(畸形上游不发分隔符,
|
||||
// 或超大单事件)。按字节盲截头部会把半截事件体留给后续解析,产出
|
||||
// 破损 JSON 且无法复原;改为对齐事件边界的保尾裁剪:
|
||||
// - 已有完整分隔符 → 最旧的一个事件(超限源头)整体丢弃,保留的
|
||||
// 数据从干净的事件起点开始,后续事件解析不受损;
|
||||
// - 全程无分隔符 → 整体丢弃并置 skip_partial,该事件的收尾字节
|
||||
// 到达时静默吞掉。
|
||||
if self.buffer.len() > BUF_MAX {
|
||||
match Self::find_event_boundary(&self.buffer) {
|
||||
Some((pos, len)) => {
|
||||
self.buffer.drain(..pos + len);
|
||||
}
|
||||
None => {
|
||||
self.buffer.clear();
|
||||
self.skip_partial = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Poll::Ready(Some(Err(e))) => {
|
||||
return Poll::Ready(Some(Err(format!("SSE 流读取错误: {}", e))));
|
||||
}
|
||||
Poll::Ready(None) => {
|
||||
// 流结束,处理 buffer 中的剩余数据(可能没有 \n\n 结束的最后一段);
|
||||
// skip_partial 置位时剩余数据是被丢弃残缺事件的尾段,不得溢出为事件
|
||||
if !self.buffer.is_empty() && !self.skip_partial {
|
||||
// 解码:优先严格 UTF-8(EOF 无下一 chunk 可拼接,
|
||||
// 残缺多字节尾部只能尽力而为,降级 lossy 不崩)
|
||||
let mut remaining = match String::from_utf8(self.buffer.clone()) {
|
||||
Ok(s) => s,
|
||||
Err(_) => String::from_utf8_lossy(&self.buffer).into_owned(),
|
||||
};
|
||||
self.buffer.clear();
|
||||
// 同主解析路径:CRLF / 裸 CR 归一为 LF
|
||||
if remaining.contains('\r') {
|
||||
remaining = remaining.replace("\r\n", "\n").replace('\r', "\n");
|
||||
}
|
||||
let data = Self::extract_data_fields(&remaining);
|
||||
if !data.is_empty() {
|
||||
return Poll::Ready(Some(Ok(vec![data])));
|
||||
}
|
||||
}
|
||||
return Poll::Ready(None);
|
||||
}
|
||||
Poll::Pending => return Poll::Pending,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use futures::stream;
|
||||
|
||||
/// 构造只含 Ok chunk 的内存流,跑完整个 SseStream,返回拍平的事件序列
|
||||
fn run_chunks(chunks: Vec<String>) -> Vec<SseEvent> {
|
||||
use futures::StreamExt;
|
||||
let items: Vec<Result<bytes::Bytes, reqwest::Error>> = chunks
|
||||
.into_iter()
|
||||
.map(|c| Ok(bytes::Bytes::from(c)))
|
||||
.collect();
|
||||
let mut s = SseStream::new(stream::iter(items));
|
||||
futures::executor::block_on(async {
|
||||
let mut out = Vec::new();
|
||||
while let Some(item) = s.next().await {
|
||||
out.extend(item.expect("测试流不应产生错误"));
|
||||
}
|
||||
out
|
||||
})
|
||||
}
|
||||
|
||||
/// 回归:正常多事件流照常解析
|
||||
#[test]
|
||||
fn normal_events_parse() {
|
||||
let events = run_chunks(vec![
|
||||
"data: {\"a\":1}\n\n".into(),
|
||||
"data: {\"b\":2}\n\n".into(),
|
||||
]);
|
||||
assert_eq!(events, vec!["{\"a\":1}".to_string(), "{\"b\":2}".to_string()]);
|
||||
}
|
||||
|
||||
/// 超限单事件(末尾带完整分隔符)整体丢弃:不产出半截破损事件,后续正常事件照常解析。
|
||||
/// 旧按字节截头实现会把残留尾部 "data: TAIL" 行当伪事件产出
|
||||
#[test]
|
||||
fn oversize_event_with_boundary_dropped_whole() {
|
||||
let giant = format!("data: {}\ndata: TAIL\n\n", "x".repeat(BUF_MAX));
|
||||
let events = run_chunks(vec![giant, "data: {\"ok\":1}\n\n".into()]);
|
||||
assert_eq!(events, vec!["{\"ok\":1}".to_string()]);
|
||||
}
|
||||
|
||||
/// 验收:超限注入(无边界大块)丢弃后,残缺事件收尾静默吞掉,后续正常事件仍可解析。
|
||||
/// 旧按字节截头实现会把 STALE-TAIL 残段当伪事件产出
|
||||
#[test]
|
||||
fn oversize_boundaryless_blob_then_normal_events_still_parse() {
|
||||
let events = run_chunks(vec![
|
||||
"x".repeat(BUF_MAX + 100),
|
||||
"data: STALE-TAIL\n\n".into(),
|
||||
"data: {\"ok\":1}\n\n".into(),
|
||||
"data: [DONE]\n\n".into(),
|
||||
]);
|
||||
assert_eq!(events, vec!["{\"ok\":1}".to_string(), "[DONE]".to_string()]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
//! 上下文侧工具结果正文外置(P1 工具结果瘦身)。
|
||||
//!
|
||||
//! 依据 docs/02-架构设计/上下文根治设计-20261006.md §二「工具结果瘦身(治最大头,先行)」:
|
||||
//! 超阈值工具结果**正文落盘**,上下文只留「摘要 + 句柄路径」;句柄可被 agent 主动展开
|
||||
//! (read_file 读句柄路径)——需要时找得回,不需要时不占位。与 src-tauri 审计侧 D2a 同构。
|
||||
//!
|
||||
//! 与 namespace_store 的区别(两者互补,不冲突):
|
||||
//! - namespace_store:内存态引用化(省进程内存),发 LLM 前展开为全文 → token 零收益。
|
||||
//! - 本模块:落盘 + 句柄,被省略的中段**不进 prompt 但可读回** → 真正减 token 且不丢信息。
|
||||
//!
|
||||
//! 降级契约:base 未注入(单测/未初始化)→ 不落盘返回 None,调用方退化为纯摘要(改造前行为)。
|
||||
//! 幂等:文件名取内容稳定哈希,同内容重复压缩只落盘一次。
|
||||
//! 保留策略:与全局「历史数据默认保留」一致,本模块自产文件只增不减(无清理调用点)。
|
||||
//!
|
||||
//! 测试隔离:核心逻辑 `offload_to(base, ...)` 显式传 base(零全局态),单测只测它;
|
||||
//! `init_base` 的 OnceLock 全局态一次性不可重置,故测试**绝不调用**它(避免污染同进程
|
||||
//! 其他用例——如 extract_key_info 系列断言会被意外注入的句柄行破坏)。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// 外置根目录(进程级一次注入;src-tauri lib.rs setup 调 init_base)。
|
||||
/// 与审计侧共用 {app_data},文件名前缀 `ctx-` 区分(tool-results/ 目录共存)。
|
||||
static BASE: OnceLock<PathBuf> = OnceLock::new();
|
||||
|
||||
/// 注入外置根目录(app data dir)。幂等,首次生效。
|
||||
pub fn init_base(base: PathBuf) {
|
||||
let _ = BASE.set(base);
|
||||
}
|
||||
|
||||
/// base 是否已注入(降级判定用)。
|
||||
pub fn is_enabled() -> bool {
|
||||
BASE.get().is_some()
|
||||
}
|
||||
|
||||
/// 内容稳定哈希(非密码学用途;碰撞后果仅同名文件互相覆盖,概率可忽略)。
|
||||
fn content_id(content: &str) -> u64 {
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = DefaultHasher::new();
|
||||
content.hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// 落盘工具结果正文,返回可读句柄行(供摘要末尾追加)。base 未注入 → None。
|
||||
pub fn offload_with_handle(content: &str, tool_name: &str) -> Option<String> {
|
||||
offload_to(BASE.get()?, content, tool_name)
|
||||
}
|
||||
|
||||
/// 核心实现(显式 base,零全局态,可测)。
|
||||
///
|
||||
/// 建目录失败 / 写文件失败 → None(降级:调用方仅用摘要,不丢数据)。
|
||||
pub fn offload_to(base: &Path, content: &str, tool_name: &str) -> Option<String> {
|
||||
let dir = base.join("tool-results");
|
||||
if std::fs::create_dir_all(&dir).is_err() {
|
||||
return None;
|
||||
}
|
||||
let file = dir.join(format!("ctx-{:016x}.txt", content_id(content)));
|
||||
if !file.exists() && std::fs::write(&file, content).is_err() {
|
||||
return None;
|
||||
}
|
||||
Some(format!(
|
||||
"[工具 {} 原文 {} 字符已外置: {} —— 需完整内容用 read_file 读该路径]",
|
||||
tool_name,
|
||||
content.chars().count(),
|
||||
file.display()
|
||||
))
|
||||
}
|
||||
|
||||
/// 读回外置文件(查询侧/测试用)。文件缺失返回带语义错误(降级不 panic)。
|
||||
pub fn read_offloaded(path: &str) -> Result<String, String> {
|
||||
std::fs::read_to_string(path).map_err(|e| format!("外置结果读取失败: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tmp_base(tag: &str) -> PathBuf {
|
||||
std::env::temp_dir().join(format!("df-ai-offload-{tag}-{}", std::process::id()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offload_to_writes_readable_file_and_is_idempotent() {
|
||||
let base = tmp_base("ok");
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
let payload = "large tool output ".repeat(1000);
|
||||
let handle = offload_to(&base, &payload, "grep").expect("显式 base 应落盘");
|
||||
assert!(handle.contains("已外置"), "句柄应含提示文案: {handle}");
|
||||
assert!(handle.contains("read_file"), "句柄应指引读回方式: {handle}");
|
||||
// 幂等:同内容二次调用返回同一文件名
|
||||
let handle2 = offload_to(&base, &payload, "grep").expect("二次调用仍返回句柄");
|
||||
assert_eq!(handle, handle2, "同内容应幂等复用同一文件");
|
||||
// 落盘文件可读回原文(无损)
|
||||
let path = std::fs::read_dir(base.join("tool-results"))
|
||||
.expect("目录应存在")
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.find(|p| p.extension().map(|e| e == "txt").unwrap_or(false))
|
||||
.expect("应有落盘文件");
|
||||
assert_eq!(
|
||||
read_offloaded(path.to_string_lossy().as_ref()).expect("应可读回"),
|
||||
payload
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_content_gets_different_file() {
|
||||
let base = tmp_base("diff");
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
let h1 = offload_to(&base, &"a".repeat(3000), "grep").unwrap();
|
||||
let h2 = offload_to(&base, &"b".repeat(3000), "grep").unwrap();
|
||||
assert_ne!(h1, h2, "不同内容应落不同文件");
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//! P1 工具结果瘦身 — 端到端集成验证。
|
||||
//!
|
||||
//! 独立测试二进制(独立进程):`tool_offload::init_base` 的 OnceLock 全局态不外泄,
|
||||
//! 不污染 lib 单测(反之亦然)。这是验证「超阈值 → 落盘句柄 → 可无损读回」全链的唯一安全方式。
|
||||
|
||||
use df_ai::context_helpers::{extract_key_info, READ_FILE_OFFLOAD_BYTES};
|
||||
use df_ai::tool_offload;
|
||||
|
||||
fn tmp_base(tag: &str) -> std::path::PathBuf {
|
||||
std::env::temp_dir().join(format!("df-ai-ctx-offload-{tag}-{}", std::process::id()))
|
||||
}
|
||||
|
||||
/// 从句柄行里粗取落盘路径(格式:`[...已外置: <path> —— ...]`)。
|
||||
fn path_of(handle_line: &str) -> String {
|
||||
handle_line
|
||||
.split("已外置: ")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split(" ——").next())
|
||||
.expect("句柄行应含路径")
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_read_file_summarized_with_lossless_handle() {
|
||||
let base = tmp_base("large");
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
tool_offload::init_base(base.clone());
|
||||
|
||||
// 造一个超阈值的 read_file 结果(代码风格多行文本)
|
||||
let body: String = (1..=800)
|
||||
.map(|i| format!("line {i}: fn sample_{i}() {{ let x = {i}; }}\n"))
|
||||
.collect();
|
||||
assert!(body.len() > READ_FILE_OFFLOAD_BYTES, "夹具须超阈值");
|
||||
|
||||
let out = extract_key_info(&body, "read_file");
|
||||
assert!(out.len() < body.len(), "超阈值 read_file 应被压缩");
|
||||
assert!(out.contains("已外置"), "应追加落盘句柄:\n{out}");
|
||||
assert!(out.contains("read_file 读该路径"), "句柄应指引读回方式");
|
||||
|
||||
// 无损性:按句柄路径读回 = 原文逐字节一致
|
||||
let line = out.lines().find(|l| l.contains("已外置")).expect("应有句柄行");
|
||||
let back = tool_offload::read_offloaded(&path_of(line)).expect("句柄路径应可读回");
|
||||
assert_eq!(back, body, "落盘原文须与输入逐字节一致(无损)");
|
||||
|
||||
// 摘要确实变小(瘦身生效)
|
||||
let ratio = out.len() as f64 / body.len() as f64;
|
||||
assert!(ratio < 0.2, "压缩比应显著(<0.2),实际 {ratio:.3}");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_read_file_still_exempt() {
|
||||
let base = tmp_base("small");
|
||||
tool_offload::init_base(base);
|
||||
let small = "line1: fn a() {}\nline2: fn b() {}\n";
|
||||
assert_eq!(
|
||||
extract_key_info(small, "read_file"),
|
||||
small,
|
||||
"阈值内 read_file 应原样返回(保 AI 定向读代码语义)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_read_file_large_result_also_gets_handle() {
|
||||
let base = tmp_base("other");
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
tool_offload::init_base(base.clone());
|
||||
|
||||
let body: String = (1..=1200).map(|i| format!("match {i}: src/foo.rs:{i}\n")).collect();
|
||||
assert!(body.len() >= 8 * 1024, "夹具须 ≥8KB(句柄追加下限)");
|
||||
let out = extract_key_info(&body, "grep");
|
||||
assert!(out.len() < body.len(), "应被压缩");
|
||||
assert!(out.contains("已外置"), "≥8KB 结果应带落盘句柄");
|
||||
|
||||
let line = out.lines().find(|l| l.contains("已外置")).expect("应有句柄行");
|
||||
assert_eq!(
|
||||
tool_offload::read_offloaded(&path_of(line)).expect("应可读回"),
|
||||
body
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
//! 提交冻结机械闸(39 治理) — AI 引擎派发命令的 git commit 拦截。
|
||||
//!
|
||||
//! 冻结期间(用户 🔒 令)禁止引擎/挂机会话执行 git commit:命令在 spawn 前被拒,
|
||||
//! 改动留在工作区。用户本人终端不受影响(本闸只作用于引擎 execute 入口)。
|
||||
//!
|
||||
//! 冻结标志解析(优先级:环境变量 DEVFLOW_COMMIT_FROZEN > env 内 DevFlow 标志):
|
||||
//! - env::var("DEVFLOW_COMMIT_FROZEN")="1"/"true" → 冻结
|
||||
//! - ShellRequest.env 同名键同样生效(测试/调用方定向注入)
|
||||
//! 默认不冻结(用户本人正常提交流程零影响)。
|
||||
//!
|
||||
//! 拦截范围:命令子串含 `git commit`(含 `git -C x commit`)即拒;`git commit --amend`
|
||||
//! 同拒。`git log/diff/status/add/push` 不拦(只冻结提交动作)。
|
||||
//!
|
||||
//! 提示文案给 LLM 指明出路:改留工作区,等「攒批提交通知」。
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use crate::shell::ShellRequest;
|
||||
|
||||
/// 进程级冻结标志(引擎侧写入:unattended 轮询每轮按看板 🔒 行 + KV 覆盖同步)。
|
||||
/// 用静态位而非 std::env::set_var(Rust 2024 edition set_var unsafe 且多线程 UB)。
|
||||
static FROZEN: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// 引擎侧设置/解除冻结(unattended 轮询每轮调用)。
|
||||
pub fn set_frozen(v: bool) {
|
||||
FROZEN.store(v, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// 环境变量真值判定:"1"/"true"(含大小写变体)为冻结,其余("0"/"false"/空)为不冻结。
|
||||
fn env_val_is_truthy(v: &str) -> bool {
|
||||
matches!(v, "1" | "true" | "TRUE" | "True")
|
||||
}
|
||||
|
||||
/// 是否启用冻结:静态标志(引擎同步) 或 环境变量 DEVFLOW_COMMIT_FROZEN(调用方/测试定向注入)。
|
||||
pub fn is_frozen(extra_env: Option<&std::collections::HashMap<String, String>>) -> bool {
|
||||
if FROZEN.load(Ordering::SeqCst) {
|
||||
return true;
|
||||
}
|
||||
if let Some(v) = extra_env.and_then(|m| m.get("DEVFLOW_COMMIT_FROZEN")) {
|
||||
return env_val_is_truthy(v);
|
||||
}
|
||||
std::env::var("DEVFLOW_COMMIT_FROZEN")
|
||||
.map(|v| env_val_is_truthy(&v))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 命令是否为 git commit 提交动作(token 判据,含 -C 前缀形态与复合命令)。
|
||||
pub fn is_commit_command(command: &str) -> bool {
|
||||
// 按常见命令分隔符切段( ; && || | 换行),每段独立判 git commit(防 "x; git commit" 绕过)
|
||||
command
|
||||
.split(|c: char| matches!(c, ';' | '|' | '&' | '\n'))
|
||||
.any(is_commit_segment)
|
||||
}
|
||||
|
||||
/// 单段判据:token 找 "git",跳过全局选项(-C path 等)后是否接 "commit"。
|
||||
fn is_commit_segment(segment: &str) -> bool {
|
||||
let tokens: Vec<String> = segment
|
||||
.split_whitespace()
|
||||
.map(|t| t.trim_matches(|c: char| c == '"' || c == '\'').to_lowercase())
|
||||
.collect();
|
||||
for (i, t) in tokens.iter().enumerate() {
|
||||
if t == "git" {
|
||||
let mut j = i + 1;
|
||||
while j < tokens.len() {
|
||||
match tokens[j].as_str() {
|
||||
// `-c key=val` 两 token 形态:值在下一 token,跳 2
|
||||
"-c" => j += 2,
|
||||
// `--git-dir=path`/`--work-tree=path` 单 token 合并形态:自含值,跳 1
|
||||
t if t.starts_with("--git-dir=") || t.starts_with("--work-tree=") => j += 1,
|
||||
// `--git-dir path`/`--work-tree path` 两 token 形态:跳 2
|
||||
"--git-dir" | "--work-tree" => j += 2,
|
||||
"commit" => return true,
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 冻结闸检查入口:冻结在场且命令是 git commit → 返回拒绝 Err(不 spawn)。
|
||||
pub fn check(request: &ShellRequest) -> Result<(), String> {
|
||||
if !is_frozen(Some(&request.env)) {
|
||||
return Ok(());
|
||||
}
|
||||
if is_commit_command(&request.command) {
|
||||
return Err(format!(
|
||||
"[提交冻结令] 检测到 git commit 命令,当前处于提交冻结期(用户 🔒 令),已拒绝执行: \
|
||||
改动保留在工作区,等用户「攒批提交通知」后才可提交。命令: {}",
|
||||
truncate_cmd(&request.command)
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn truncate_cmd(s: &str) -> String {
|
||||
if s.chars().count() <= 120 {
|
||||
s.to_string()
|
||||
} else {
|
||||
let cut: String = s.chars().take(120).collect();
|
||||
format!("{}…", cut)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn req(cmd: &str) -> ShellRequest {
|
||||
ShellRequest {
|
||||
command: cmd.to_string(),
|
||||
working_dir: None,
|
||||
env: HashMap::new(),
|
||||
timeout_secs: None,
|
||||
shell_type: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn req_env(cmd: &str, env: &[(&str, &str)]) -> ShellRequest {
|
||||
let mut r = req(cmd);
|
||||
r.env = env
|
||||
.iter()
|
||||
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||
.collect();
|
||||
r
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_detection_covers_variants() {
|
||||
assert!(is_commit_command("git commit -m 'x'"));
|
||||
assert!(is_commit_command("git -C E:/repo commit -m x"));
|
||||
assert!(is_commit_command("git commit --amend"));
|
||||
assert!(is_commit_command("git commit -m y"));
|
||||
assert!(is_commit_command("git commit; git push"));
|
||||
// PowerShell / sh 包裹形态
|
||||
assert!(is_commit_command("powershell -Command git commit -m x"));
|
||||
assert!(is_commit_command("cd repo && git commit -m x"));
|
||||
assert!(is_commit_command("git add . && git commit -m x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_commit_git_not_blocked() {
|
||||
assert!(!is_commit_command("git log -1"));
|
||||
assert!(!is_commit_command("git status"));
|
||||
assert!(!is_commit_command("git diff --stat"));
|
||||
assert!(!is_commit_command("git add ."));
|
||||
assert!(!is_commit_command("git push origin main"));
|
||||
assert!(!is_commit_command("cargo test"));
|
||||
// commitment 等误命中防护:commit 后无后续 token 才算
|
||||
assert!(!is_commit_command("git log --grep commit"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn freeze_gate_blocks_commit_when_frozen() {
|
||||
let r = req_env("git commit -m '细项批'", &[("DEVFLOW_COMMIT_FROZEN", "1")]);
|
||||
assert!(check(&r).is_err(), "冻结在场 commit 必须被拒");
|
||||
let r2 = req_env("git log -1", &[("DEVFLOW_COMMIT_FROZEN", "1")]);
|
||||
assert!(check(&r2).is_ok(), "冻结只拦 commit 不拦只读命令");
|
||||
}
|
||||
|
||||
/// 解冻态放行用例:持与 static_flag_toggle 同一把串行闸——静态位是进程级单源,
|
||||
/// 置位瞬间并行跑到本用例会误判冻结(实测互踩),凡依赖「静态位=false」断言的用例一律持闸。
|
||||
#[test]
|
||||
fn no_freeze_no_block() {
|
||||
let _gate = crate::shell::PWSH_CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let r = req("git commit -m '用户本人提交'");
|
||||
assert!(check(&r).is_ok(), "无冻结标志零影响(用户正常提交不受影响)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frozen_false_value_no_block() {
|
||||
let _gate = crate::shell::PWSH_CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let r = req_env("git commit -m x", &[("DEVFLOW_COMMIT_FROZEN", "0")]);
|
||||
assert!(check(&r).is_ok(), "显式关闭标志不拦(解冻态)");
|
||||
}
|
||||
|
||||
/// 静态标志(引擎同步路径)切换:置位拦 commit,复位恢复。
|
||||
/// 串行闸:静态位是进程级单源,置位期间并行的 env 用例(check 走 is_frozen 静态位短路)会互踩,
|
||||
/// 持锁串行 + 进出恢复默认(false)防污染。
|
||||
#[test]
|
||||
fn static_flag_toggle() {
|
||||
// 复用 PWSH_CACHE_TEST_LOCK 串行闸(进程级互斥,专用闸后续有需要再单立)
|
||||
let _gate = crate::shell::PWSH_CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
set_frozen(true);
|
||||
assert!(check(&req("git commit -m x")).is_err(), "静态冻结位拦 commit");
|
||||
assert!(check(&req("git diff")).is_ok(), "静态冻结位不拦只读命令");
|
||||
set_frozen(false);
|
||||
assert!(check(&req("git commit -m x")).is_ok(), "复位后恢复放行");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
//! 环境感知系统 — 启动时一次性探测 OS/shell/工具版本/编码,全局缓存,注入 system_prompt。
|
||||
//!
|
||||
//! 设计目标:让 LLM 准确生成跨平台命令。LLM 训练数据 Unix 多,易生成 macOS/Linux 语法
|
||||
//! 命令(PowerShell 5 不支持 `&&`、Windows 路径分隔符 `\`、GBK 终端中文乱码等),通过
|
||||
//! 把当前平台的真实环境(操作系统/Shell/工具版本)拼进 system_prompt,LLM 即可生成与
|
||||
//! 当前平台兼容的命令,从根上治「LLM 跨平台命令幻觉」。
|
||||
//!
|
||||
//! 缓存策略:`OnceLock` 全局单例,首次 `detect()` 探测,后续调用零开销。探测本身在
|
||||
//! `spawn_blocking` 中执行(执行 `tool --version` 等阻塞 IO),避免阻塞 tokio runtime。
|
||||
//! 探测失败的字段设 None/默认值,不向上传播错误(环境感知是 best-effort 增强,不应
|
||||
//! 阻断主流程)。
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// 环境快照 — 系统环境的不可变视图。
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct EnvSnapshot {
|
||||
/// 操作系统族:"windows" / "macos" / "linux"
|
||||
pub os: String,
|
||||
/// OS 版本:"11" / "Ubuntu 22.04"(探测失败为空串)
|
||||
pub os_version: String,
|
||||
/// Shell 名:"powershell" / "pwsh" / "bash" / "zsh" / "sh"
|
||||
pub shell: String,
|
||||
/// 路径分隔符:"\\"(Windows) / "/"(Unix)
|
||||
pub path_sep: String,
|
||||
/// 终端编码:"utf-8" / "gbk"(Windows 中文常见 GBK,致 LLM 输出乱码)
|
||||
pub encoding: String,
|
||||
/// 工具版本(逐个探测,缺失为 None)
|
||||
pub tools: ToolVersions,
|
||||
}
|
||||
|
||||
/// 各开发工具的版本信息。
|
||||
#[derive(Debug, Clone, serde::Serialize, Default)]
|
||||
pub struct ToolVersions {
|
||||
pub python: Option<String>,
|
||||
pub node: Option<String>,
|
||||
pub rust: Option<String>,
|
||||
pub go: Option<String>,
|
||||
pub docker: Option<String>,
|
||||
pub git: Option<String>,
|
||||
}
|
||||
|
||||
impl EnvSnapshot {
|
||||
/// 探测环境(惰性,OnceLock 全局缓存,整个进程生命周期只探一次)。
|
||||
///
|
||||
/// 返回 `&'static EnvSnapshot` —— 引用静态存储,可安全地长存于 loop 不变量中。
|
||||
/// 探测在 `spawn_blocking` 中同步执行(`tool --version` 是阻塞 IO),不卡 runtime。
|
||||
pub async fn detect() -> &'static EnvSnapshot {
|
||||
static SNAPSHOT: OnceLock<EnvSnapshot> = OnceLock::new();
|
||||
if let Some(snap) = SNAPSHOT.get() {
|
||||
return snap;
|
||||
}
|
||||
// 首次探测:同步逻辑包到 spawn_blocking,避免阻塞 async runtime。
|
||||
// probe_version 内 std::process::Command::output() 无 timeout,
|
||||
// Windows 上 python/node 若是 Microsoft Store App Execution Alias(用户未装但开了
|
||||
// "应用执行别名"),`python --version` 触发 Store 重定向、process 不退出 → output()
|
||||
// 永久阻塞 → spawn_blocking 线程永不返回 → detect().await 永久挂 → run_agentic_loop
|
||||
// 卡在 EnvSnapshot::detect() 调用(agentic/mod.rs),后端日志断在"意图收敛工具"后,
|
||||
// 前端表现为「发消息完全无回应然后卡死」(trace.log 实测 3 次发送全卡于此)。
|
||||
// 修复: spawn_blocking 外包 5s timeout,超时返回 fallback 并 set 进 OnceLock(后续命中
|
||||
// 缓存不再 probe),run_agentic_loop 最多等 5s 后继续,不再永久卡。
|
||||
// 注: 超时后 spawn_blocking 线程仍在跑(卡在 output),但已脱离 await,不阻塞调用方;
|
||||
// 线程最终随 process 退出或进程结束回收,无泄漏累积(OnceLock 已 set,不会重复 probe)。
|
||||
let snap = match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
tokio::task::spawn_blocking(|| EnvSnapshot::do_detect()),
|
||||
).await {
|
||||
Ok(Ok(s)) => s,
|
||||
Ok(Err(join_err)) => {
|
||||
eprintln!("[env_snapshot] 探测任务异常,回退默认环境: {}", join_err);
|
||||
EnvSnapshot::fallback()
|
||||
}
|
||||
Err(_elapsed) => {
|
||||
eprintln!(
|
||||
"[env_snapshot] 探测超时(5s),某 tool(python/node/rustc/go/docker/git)命令卡住 \
|
||||
(疑似 Windows Store App Execution Alias 触发 Store 重定向),回退默认环境"
|
||||
);
|
||||
EnvSnapshot::fallback()
|
||||
}
|
||||
};
|
||||
// 多任务竞态:均等价,以先到者为准。
|
||||
let _ = SNAPSHOT.set(snap);
|
||||
SNAPSHOT.get().expect("EnvSnapshot 已初始化")
|
||||
}
|
||||
|
||||
/// 同步探测(可能短暂阻塞,仅在 spawn_blocking 中调用)。
|
||||
fn do_detect() -> EnvSnapshot {
|
||||
EnvSnapshot {
|
||||
os: std::env::consts::OS.to_string(),
|
||||
os_version: detect_os_version(),
|
||||
shell: detect_shell(),
|
||||
path_sep: std::path::MAIN_SEPARATOR.to_string(),
|
||||
encoding: detect_encoding(),
|
||||
tools: ToolVersions {
|
||||
python: probe_version("python", &["--version"]),
|
||||
node: probe_version("node", &["--version"]),
|
||||
rust: probe_version("rustc", &["--version"]),
|
||||
go: probe_version("go", &["version"]),
|
||||
docker: probe_version("docker", &["--version"]),
|
||||
git: probe_version("git", &["--version"]),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 兜底:spawn_blocking panic/join 失败或外层 5s 超时时返回最小可用快照(全 None,字段不空)。
|
||||
/// 注:异常/超时均**冻结**为 fallback 写入 OnceLock(EX-3)——本进程后续 detect() 不再重探,
|
||||
/// 瞬时挂起(如 Store Alias)会被固化为整进程降级快照;解冻需重启进程。
|
||||
fn fallback() -> EnvSnapshot {
|
||||
EnvSnapshot {
|
||||
os: std::env::consts::OS.to_string(),
|
||||
os_version: String::new(),
|
||||
shell: if cfg!(windows) { "powershell".into() } else { "sh".into() },
|
||||
path_sep: std::path::MAIN_SEPARATOR.to_string(),
|
||||
encoding: "utf-8".into(),
|
||||
tools: ToolVersions::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成 system_prompt 注入文本(拼到 system_prompt 尾部)。
|
||||
///
|
||||
/// 末尾加「请生成本平台兼容的命令」软提示,锚定 LLM 输出平台一致性。
|
||||
pub fn to_prompt(&self) -> String {
|
||||
let mut lines: Vec<String> = vec![
|
||||
"## 系统环境".to_string(),
|
||||
format!("- 操作系统: {} {}", self.os, self.os_version).trim_end().to_string(),
|
||||
format!("- Shell: {}", self.shell),
|
||||
format!("- 路径分隔符: {}", self.path_sep),
|
||||
format!("- 终端编码: {}", self.encoding),
|
||||
];
|
||||
if let Some(v) = &self.tools.python {
|
||||
lines.push(format!("- Python: {}", v));
|
||||
}
|
||||
if let Some(v) = &self.tools.node {
|
||||
lines.push(format!("- Node: {}", v));
|
||||
}
|
||||
if let Some(v) = &self.tools.rust {
|
||||
lines.push(format!("- Rust: {}", v));
|
||||
}
|
||||
if let Some(v) = &self.tools.go {
|
||||
lines.push(format!("- Go: {}", v));
|
||||
}
|
||||
if let Some(v) = &self.tools.docker {
|
||||
lines.push(format!("- Docker: {}", v));
|
||||
}
|
||||
if let Some(v) = &self.tools.git {
|
||||
lines.push(format!("- Git: {}", v));
|
||||
}
|
||||
lines.push(String::new());
|
||||
lines.push("注意: 请生成本平台兼容的命令。".to_string());
|
||||
lines.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
/// 探测 OS 版本(各平台路径不一,失败返回空串而非 None,简化 prompt 拼接)。
|
||||
fn detect_os_version() -> String {
|
||||
// Windows:读注册表 HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion (ProductName/DisplayVersion)。
|
||||
// 不依赖 winver GUI / reg.exe 输出格式,直接读注册表最稳。
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Some(v) = read_windows_version() {
|
||||
return v;
|
||||
}
|
||||
return String::new();
|
||||
}
|
||||
// macOS:sw_vers -productVersion 输出如 "12.5"
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if let Ok(out) = std::process::Command::new("sw_vers").arg("-productVersion").output() {
|
||||
if out.status.success() {
|
||||
return String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
}
|
||||
}
|
||||
return String::new();
|
||||
}
|
||||
// Linux:读 /etc/os-release 的 PRETTY_NAME 字段(系统标准位置)
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if let Ok(content) = std::fs::read_to_string("/etc/os-release") {
|
||||
for line in content.lines() {
|
||||
if let Some(rest) = line.strip_prefix("PRETTY_NAME=") {
|
||||
return rest.trim_matches('"').to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
return String::new();
|
||||
}
|
||||
#[cfg(not(any(windows, target_os = "macos", target_os = "linux")))]
|
||||
{
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn read_windows_version() -> Option<String> {
|
||||
// 用 reg.exe query 读注册表(DisplayVersion 优先,如 "22H2";回退 ProductName,如 "Windows 10 Pro")。
|
||||
// 避开 winreg crate 依赖(增加构建复杂度,且 reg.exe 在所有 Win 版本均自带)。
|
||||
let out = std::process::Command::new("reg")
|
||||
.args([
|
||||
"query",
|
||||
r"HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion",
|
||||
"/v",
|
||||
"DisplayVersion",
|
||||
])
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.creation_flags(0x0800_0000) // CREATE_NO_WINDOW
|
||||
.output()
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
// 输出形如: " DisplayVersion REG_SZ 22H2"
|
||||
for line in text.lines() {
|
||||
let trimmed = line.trim();
|
||||
if let Some(idx) = trimmed.find("REG_SZ") {
|
||||
let val = trimmed[idx + "REG_SZ".len()..].trim();
|
||||
if !val.is_empty() {
|
||||
return Some(format!("Windows {}", val));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 探测默认 shell(Windows 走 shell.rs 单源,Unix 读 SHELL 环境变量)。
|
||||
///
|
||||
/// 单源语义(2026-08 修复):Windows 分支不再独立 probe pwsh/powershell,而是:
|
||||
/// 1) 先调 `shell::probe_pwsh_blocking()` 填充模块级 `PWSH_CACHE`(与 `shell::probe_pwsh()` 异步路径
|
||||
/// 共用同一 OnceLock + 同一阻塞探测实现,杜绝两套逻辑漂移);
|
||||
/// 2) 再调 `shell::current_shell()`(同步读 `PWSH_CACHE`),映射 ShellType → prompt 字符串。
|
||||
/// 后续 `execute()` → `probe_pwsh().await` 直接命中缓存,跳过重复探测。
|
||||
/// Unix 分支保留原 SHELL 环境变量读取(与执行侧 ShellType::Sh 默认一致,无漂移风险)。
|
||||
fn detect_shell() -> String {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// 单源填充 PWSH_CACHE(本函数运行在 EnvSnapshot::detect 的 spawn_blocking 内,
|
||||
// 通常先于首次 execute(),故常是缓存的首次写入者)。
|
||||
crate::shell::probe_pwsh_blocking();
|
||||
// 单源读取并映射为 prompt 字符串。
|
||||
match crate::shell::current_shell() {
|
||||
crate::shell::ShellType::Pwsh => "pwsh".to_string(),
|
||||
crate::shell::ShellType::PowerShell => "powershell".to_string(),
|
||||
crate::shell::ShellType::Cmd => "cmd".to_string(),
|
||||
crate::shell::ShellType::Sh => "sh".to_string(),
|
||||
}
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
// Unix:SHELL 环境变量优先,常见值 /bin/bash /bin/zsh /bin/sh。
|
||||
if let Ok(sh) = std::env::var("SHELL") {
|
||||
// 取 basename(/bin/zsh → zsh)
|
||||
let name = sh.rsplit('/').next().unwrap_or(&sh);
|
||||
if !name.is_empty() {
|
||||
return name.to_string();
|
||||
}
|
||||
}
|
||||
"sh".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// 探测终端编码(Windows 中文常见 GBK,导致 LLM 输出 UTF-8 在终端乱码)。
|
||||
fn detect_encoding() -> String {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// chcp 输出形如 "活动代码页: 936"(GBK)。936 → gbk,65001 → utf-8,其余按数字降级。
|
||||
if let Ok(out) = std::process::Command::new("chcp")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.creation_flags(0x0800_0000)
|
||||
.output()
|
||||
{
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
if let Some(code) = extract_codepage(&text) {
|
||||
return match code.as_str() {
|
||||
"65001" => "utf-8".to_string(),
|
||||
"936" => "gbk".to_string(),
|
||||
"950" => "big5".to_string(),
|
||||
other => format!("cp{}", other),
|
||||
};
|
||||
}
|
||||
}
|
||||
return "utf-8".to_string();
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
// Unix 默认 UTF-8(LANG/LC_ALL 通常含 UTF-8)。
|
||||
if let Ok(lang) = std::env::var("LANG") {
|
||||
if lang.to_ascii_uppercase().contains("UTF-8") {
|
||||
return "utf-8".to_string();
|
||||
}
|
||||
}
|
||||
"utf-8".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn extract_codepage(text: &str) -> Option<String> {
|
||||
// 提取末尾数字("...936" / "...: 65001")。
|
||||
let mut num = String::new();
|
||||
for c in text.chars().rev() {
|
||||
if c.is_ascii_digit() {
|
||||
num.insert(0, c);
|
||||
} else if !num.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if num.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(num)
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行 `tool --version`,解析首行返回版本串。失败(非零退出/无首行)返回 None。
|
||||
///
|
||||
/// 注:单条命令本身**无独立超时**(EX-4)——若 `tool --version` 挂起(如 Windows Store Alias),
|
||||
/// 本函数会一直阻塞,由外层 [`EnvSnapshot::detect`] 的 5s spawn_blocking 超时兜底整批(届时
|
||||
/// 全部工具版本一起回退 fallback),而非逐命令超时。「不阻塞调用方」靠的是外层超时,非本函数。
|
||||
///
|
||||
/// 例:python --version 输出 "Python 3.11.5" → 返回 "3.11.5";git --version 输出
|
||||
/// "git version 2.41.0" → 返回 "2.41.0"。统一抽掉工具名前缀,只保留版本号本身。
|
||||
fn probe_version(tool: &str, args: &[&str]) -> Option<String> {
|
||||
let mut cmd = std::process::Command::new(tool);
|
||||
cmd.args(args);
|
||||
cmd.stdout(Stdio::piped()).stderr(Stdio::null());
|
||||
#[cfg(windows)]
|
||||
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
|
||||
let out = cmd.output().ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
let first_line = text.lines().next()?;
|
||||
Some(extract_version_token(first_line))
|
||||
}
|
||||
|
||||
/// 从版本命令首行抽取版本号:取首个形如 N(.N)+ 的 token(至少一个点号)。
|
||||
/// 兼容 "Python 3.11.5" / "v18.17.0" / "git version 2.41.0.windows.1" / "go version go1.21.0 ..."。
|
||||
///
|
||||
/// 策略:把行切成空白 token,逐个匹配「数字开头 + 至少一个 `.`」的模式,取首个命中。
|
||||
/// 比 char 状态机更鲁棒(状态机遇 `2.41.0.windows.1` 这种多层嵌套点会误判)。
|
||||
fn extract_version_token(line: &str) -> String {
|
||||
for token in line.split_whitespace() {
|
||||
// 找 token 内首个数字位置,从这里开始扫 "数字段(.数字段)*" 序列。
|
||||
// 遇点要求下一字符为数字,否则在该点处截断(避免 "2.41.0.windows.1" 被吞成
|
||||
// "2.41.0.windows.1",实际应止于 "2.41.0")。
|
||||
let bytes = token.as_bytes();
|
||||
let mut i = match bytes.iter().position(|b| b.is_ascii_digit()) {
|
||||
Some(i) => i,
|
||||
None => continue,
|
||||
};
|
||||
let mut head = String::new();
|
||||
loop {
|
||||
// 收数字段
|
||||
let seg_start = i;
|
||||
while i < bytes.len() && bytes[i].is_ascii_digit() {
|
||||
i += 1;
|
||||
}
|
||||
head.push_str(&token[seg_start..i]);
|
||||
// 点号:仅当下一字符为数字时才续,否则收尾
|
||||
if i < bytes.len() && bytes[i] == b'.' && i + 1 < bytes.len()
|
||||
&& bytes[i + 1].is_ascii_digit()
|
||||
{
|
||||
head.push('.');
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if head.contains('.') {
|
||||
return head;
|
||||
}
|
||||
}
|
||||
// 无版本号 token 时退回整行(避免返回空串让 prompt 出现 "None")。
|
||||
line.trim().to_string()
|
||||
}
|
||||
|
||||
// Windows 下统一在文件顶部引入 CommandExt,使各 #[cfg(windows)] 块可直接调用 creation_flags。
|
||||
#[cfg(windows)]
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
use std::process::Stdio;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn extract_version_python_style() {
|
||||
assert_eq!(extract_version_token("Python 3.11.5"), "3.11.5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_version_node_style() {
|
||||
assert_eq!(extract_version_token("v18.17.0"), "18.17.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_version_git_style() {
|
||||
assert_eq!(extract_version_token("git version 2.41.0.windows.1"), "2.41.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_version_go_style() {
|
||||
assert_eq!(
|
||||
extract_version_token("go version go1.21.0 windows/amd64"),
|
||||
"1.21.0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_version_no_match_returns_line() {
|
||||
assert_eq!(extract_version_token("no version here"), "no version here");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn extract_codepage_parsing() {
|
||||
assert_eq!(extract_codepage("活动代码页: 936"), Some("936".into()));
|
||||
assert_eq!(
|
||||
extract_codepage("Active code page: 65001"),
|
||||
Some("65001".into())
|
||||
);
|
||||
assert_eq!(extract_codepage("no digits here"), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn detect_returns_cached_static_ref() {
|
||||
// 持 shell.rs 缓存串行闸:本测试可能首次创建 SNAPSHOT,其 shell 字段取自创建时刻的
|
||||
// 缓存状态;若与写缓存的 shell.rs 测试并行,漂移测试会读到与 current_shell 不一致的快照。
|
||||
let _gate = crate::shell::PWSH_CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
// 两次 detect 返回同一引用(OnceLock 全局缓存)。
|
||||
let a = EnvSnapshot::detect().await as *const _;
|
||||
let b = EnvSnapshot::detect().await as *const _;
|
||||
assert_eq!(a, b, "detect() 应返回同一静态引用");
|
||||
}
|
||||
|
||||
/// 单源不变量回归:prompt 期 shell(env_snapshot.shell)与执行期 shell(current_shell)必须一致。
|
||||
///
|
||||
/// 历史 bug:detect_shell 持独立 probe_command_success 探测,与 shell.rs::probe_pwsh 各填各的
|
||||
/// OnceLock → prompt 告诉 LLM 用 pwsh,执行却走 powershell(或反之)。根本修:二者共用
|
||||
/// shell.rs 的 probe_pwsh_blocking + PWSH_CACHE 单源。本测试锁定「无漂移」不变量。
|
||||
///
|
||||
/// 隔离:持 shell.rs 缓存串行闸,快照值与 current_shell() 须读自同一缓存状态,
|
||||
/// 防与写缓存的 shell.rs 测试并行互踩(Mutex 化后写值会被复位,闸保证读窗口内无交叉写)。
|
||||
#[cfg(windows)]
|
||||
#[tokio::test]
|
||||
async fn detect_shell_matches_shell_rs_current_shell() {
|
||||
let _gate = crate::shell::PWSH_CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
// detect() 内 detect_shell → 读 PWSH_CACHE 映射,与下方 current_shell() 同源。
|
||||
let snap = EnvSnapshot::detect().await;
|
||||
let current = crate::shell::current_shell();
|
||||
// 二者必须一致(同一 PWSH_CACHE 单源读出)。
|
||||
match snap.shell.as_str() {
|
||||
"pwsh" => assert_eq!(current, crate::shell::ShellType::Pwsh),
|
||||
"powershell" => assert_eq!(current, crate::shell::ShellType::PowerShell),
|
||||
"cmd" => assert_eq!(current, crate::shell::ShellType::Cmd),
|
||||
"sh" => assert_eq!(current, crate::shell::ShellType::Sh),
|
||||
other => panic!("未知 shell 字符串: {}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_prompt_contains_os_and_shell() {
|
||||
let snap = EnvSnapshot {
|
||||
os: "test_os".into(),
|
||||
os_version: "v1".into(),
|
||||
shell: "test_shell".into(),
|
||||
path_sep: "/".into(),
|
||||
encoding: "utf-8".into(),
|
||||
tools: ToolVersions {
|
||||
python: Some("3.11".into()),
|
||||
node: None,
|
||||
rust: None,
|
||||
go: None,
|
||||
docker: None,
|
||||
git: None,
|
||||
},
|
||||
};
|
||||
let prompt = snap.to_prompt();
|
||||
assert!(prompt.contains("test_os"));
|
||||
assert!(prompt.contains("test_shell"));
|
||||
assert!(prompt.contains("Python: 3.11"));
|
||||
assert!(!prompt.contains("Node"));
|
||||
assert!(prompt.contains("请生成本平台兼容的命令"));
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,8 @@
|
||||
//! df-execute: 执行运行时 — Shell
|
||||
//! df-execute: 执行运行时 — Shell + 环境感知 + 提交冻结闸
|
||||
|
||||
pub mod commit_freeze;
|
||||
pub mod env_snapshot;
|
||||
pub mod shell;
|
||||
|
||||
pub use commit_freeze::is_commit_command;
|
||||
pub use env_snapshot::EnvSnapshot;
|
||||
|
||||
+453
-32
@@ -3,6 +3,8 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::process::Stdio;
|
||||
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
|
||||
/// Shell 命令执行结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ShellResult {
|
||||
@@ -33,36 +35,278 @@ pub enum ShellType {
|
||||
impl Default for ShellType {
|
||||
fn default() -> Self {
|
||||
// L1 环境感知:Windows 默认 PowerShell 系(非 Cmd)。PowerShell 对引号/$变量/Unicode 处理
|
||||
// 远优于 cmd,从根上避 kms 类引号转义地狱(seq26-52 撞墙 20+ 次)。AI 写文件执行见 env_profile。
|
||||
// BUG-260623-04:优先 pwsh(PS7,支持 && 运算符)——LLM 训练数据 Unix 多,普遍生成 `cd x && y`,
|
||||
// 远优于 cmd,从根上避 kms 类引号转义地狱。AI 写文件执行见 env_profile。
|
||||
// 优先 pwsh(PS7,支持 && 运算符)——LLM 训练数据 Unix 多,普遍生成 `cd x && y`,
|
||||
// PS5 不支持 && 致命令失败(实测会话 6acb7f9b `cd ... && git init` InvalidEndOfLine)。
|
||||
// 探测失败(未装 pwsh)回退 PS5。探测结果 OnceLock 缓存(只探一次)。
|
||||
// 探测失败(未装 pwsh)回退 PS5。探测结果缓存于模块级 PWSH_CACHE(只探一次)。
|
||||
//
|
||||
// 【序约束 / 死缓存修复】Default 为同步签名,读模块级单源 PWSH_CACHE(由 probe_pwsh 写入)。
|
||||
// 缓存有两个填充点(均走 probe_pwsh_blocking 单真相源,无漂移):
|
||||
// 1) env_snapshot::detect_shell → EnvSnapshot::detect()(启动期 spawn_blocking 内,先于 execute);
|
||||
// 2) execute()/execute_streaming() 异步入口 → probe_pwsh().await(spawn_blocking + 3s 超时)。
|
||||
// 通常 detect_shell 先跑(EnvSnapshot::detect 在 run_agentic_loop 早期被 await),首次 execute 时
|
||||
// probe_pwsh 直接命中缓存。若 detect 尚未填充,probe_pwsh 自带探测兜底。任一时刻 build_command →
|
||||
// ShellType::default() 读到缓存值;未探测(缓存空)返回 PowerShell(PS5)。绝不在 Default 同步路径内 spawn 探测。
|
||||
//
|
||||
// 历史 bug(2026-08 修复):原 probe_pwsh_cached() 与 probe_pwsh() 各持一个独立 static OnceLock,
|
||||
// Default 读的那个永不被填充 → Pwsh 全死代码,Windows 永走 PS5(&& 必失败)。根因:两个 OnceLock
|
||||
// 非单源 + env_snapshot 另持 probe_command_success 独立探测。根本修:合并为模块级 PWSH_CACHE 单源,
|
||||
// 写读同源;detect_shell 复用 probe_pwsh_blocking(单一探测实现)。
|
||||
if cfg!(windows) {
|
||||
if probe_pwsh() { ShellType::Pwsh } else { ShellType::PowerShell }
|
||||
if cache_get().unwrap_or(false) { ShellType::Pwsh } else { ShellType::PowerShell }
|
||||
} else {
|
||||
ShellType::Sh
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 探测 pwsh(PowerShell 7)是否可用(OnceLock 缓存,只探一次)。
|
||||
/// pwsh(PowerShell 7)可用性的全局单源缓存。
|
||||
///
|
||||
/// 由异步 `probe_pwsh()`(execute 路径)或同步 `probe_pwsh_blocking()`(detect_shell 路径)探测,
|
||||
/// 同步路径 `ShellType::default()` / `current_shell()` 读取——写读同源,
|
||||
/// 杜绝历史上「两个 OnceLock 各填各的、Default 读的永空」死缓存 bug。
|
||||
///
|
||||
/// 语义:`None` 表示尚未探测(首次启动 / Windows 未装 pwsh 也仅表示探测未跑或返 false);
|
||||
/// `Some(true)` 表示已探测且 pwsh 可用。
|
||||
///
|
||||
/// 用 `Mutex<Option<bool>>` 而非 OnceLock:OnceLock 写入后进程级不可清,写缓存的测试
|
||||
/// 与依赖缓存一致性的测试(env_snapshot 漂移测试)全量并行跑时互踩。Mutex 化后测试可
|
||||
/// 备份/复位隔离(配 PWSH_CACHE_TEST_LOCK 串行闸);生产读写语义不变,有缓存读缓存、
|
||||
/// 无缓存探测后写。
|
||||
static PWSH_CACHE: std::sync::Mutex<Option<bool>> = std::sync::Mutex::new(None);
|
||||
|
||||
/// 读缓存当前值(毒化容错:持锁测试 panic 后其余测试仍可继续,行为不变)。
|
||||
fn cache_get() -> Option<bool> {
|
||||
*PWSH_CACHE.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// 写缓存当前值(探测产物写入 / 测试复位共用,覆盖写)。
|
||||
fn cache_set(v: Option<bool>) {
|
||||
*PWSH_CACHE.lock().unwrap_or_else(|e| e.into_inner()) = v;
|
||||
}
|
||||
|
||||
/// 测试串行闸:缓存为进程级单源,写缓存的测试与依赖缓存一致性的测试并行互踩,
|
||||
/// 凡改写缓存或依赖缓存/EnvSnapshot 一致性的测试须持锁(仅测试隔离用,非生产路径)。
|
||||
/// 对齐 path_auth.rs 的 BLACKLIST_BYPASS_TEST_LOCK 模式;env_snapshot 漂移测试持同闸。
|
||||
#[cfg(test)]
|
||||
pub(crate) static PWSH_CACHE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// 同步探测 pwsh(PowerShell 7)是否可用(std::Command::status),成功返回 true。
|
||||
///
|
||||
/// 这是阻塞 IO 的「单真相源」实现——`probe_pwsh()`(异步,spawn_blocking + 超时)与
|
||||
/// env_snapshot::detect_shell(windows 同步路径)都调用本函数,二者探测逻辑永远一致,
|
||||
/// 杜绝「提示告诉 LLM 用 pwsh,执行却走 powershell」的两套逻辑漂移。
|
||||
///
|
||||
/// 实现等价于原 `probe_pwsh()` 内的闭包:`pwsh -NoProfile -Command exit 0` 成功即 true;
|
||||
/// Windows 加 CREATE_NO_WINDOW(0x0800_0000)防黑窗闪现。
|
||||
///
|
||||
/// 注:此处无超时——`probe_pwsh()` 在调用方包 `tokio::time::timeout`,
|
||||
/// `detect_shell()` 则由外层 `EnvSnapshot::detect()` 的 spawn_blocking 5s 超时兜底。
|
||||
/// 故本函数本身只负责「同步 spawn + status」,超时治理在调用点。
|
||||
pub(crate) fn probe_pwsh_blocking() -> bool {
|
||||
let mut cmd = std::process::Command::new("pwsh");
|
||||
cmd.arg("-NoProfile").arg("-Command").arg("exit 0");
|
||||
cmd.stdout(Stdio::null()).stderr(Stdio::null());
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
|
||||
}
|
||||
cmd.status().map(|s| s.success()).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 探测 pwsh(PowerShell 7)是否可用(模块级缓存,3s 超时,只探一次)。
|
||||
///
|
||||
/// LLM 普遍生成 `&&`(Unix 习惯),仅 PS7+ 支持,Windows 自带 PS5 不支持。
|
||||
/// 探测:成功 spawn `pwsh -Command exit 0` 即可用。同步阻塞仅一次(spawn 极快),
|
||||
/// Windows 加 CREATE_NO_WINDOW 防黑窗闪现。
|
||||
fn probe_pwsh() -> bool {
|
||||
static CACHE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*CACHE.get_or_init(|| {
|
||||
let mut cmd = std::process::Command::new("pwsh");
|
||||
cmd.arg("-NoProfile").arg("-Command").arg("exit 0");
|
||||
cmd.stdout(Stdio::null()).stderr(Stdio::null());
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
|
||||
/// 探测:成功 spawn `pwsh -Command exit 0` 即可用。Windows 加 CREATE_NO_WINDOW 防黑窗闪现。
|
||||
///
|
||||
/// 异步化 + 超时治理:在异步上下文中通过 `tokio::task::spawn_blocking` 执行阻塞探测,
|
||||
/// 外包 `tokio::time::timeout(3s)`。结果写入模块级单源 `PWSH_CACHE`,同步 `Default` 路径共享。
|
||||
///
|
||||
/// 超时/panic 时**不写入 PWSH_CACHE**:
|
||||
/// - 超时根因往往是 Windows Store Alias / 杀软 hook 拦截 `pwsh` 命令(status() 永不返回);
|
||||
/// 若错误地把 false 缓存,后续 execute() 会一直走 PS5(&& 必失败),把瞬时环境问题冻结成
|
||||
/// 「永不可用」错误判定。不 set → 每次 execute 重试,环境恢复后自动回正。
|
||||
/// - panic(join err)同理,可能是临时线程池异常,不应冻结判定。
|
||||
/// - 仅「正常完成(Ok(Ok(_)))」时 set 缓存(此时结果是可信的探测产物)。
|
||||
/// 对齐 env_snapshot.rs:64-80 的「spawn_blocking + timeout + 超时不 set」模式。
|
||||
async fn probe_pwsh() -> bool {
|
||||
if let Some(cached) = cache_get() {
|
||||
return cached;
|
||||
}
|
||||
match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(3),
|
||||
tokio::task::spawn_blocking(probe_pwsh_blocking),
|
||||
).await {
|
||||
Ok(Ok(result)) => {
|
||||
// 正常完成:写入缓存(多任务竞态覆盖写,各写者探测值等价)
|
||||
cache_set(Some(result));
|
||||
result
|
||||
}
|
||||
cmd.status().map(|s| s.success()).unwrap_or(false)
|
||||
})
|
||||
Ok(Err(join_err)) => {
|
||||
eprintln!(
|
||||
"[shell] probe_pwsh spawn_blocking 异常(不缓存,下次 execute 重试): {}",
|
||||
join_err
|
||||
);
|
||||
false
|
||||
}
|
||||
Err(_elapsed) => {
|
||||
eprintln!(
|
||||
"[shell] probe_pwsh 3s 超时(疑似 Windows Store Alias / 杀软 hook 拦截 pwsh, \
|
||||
不缓存以免冻结错误判定,下次 execute 重试)"
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前 shell 类型(同步读取模块级 PWSH_CACHE 单源)。
|
||||
///
|
||||
/// 探测在 `probe_pwsh()`(异步,带 3s 超时)或 `probe_pwsh_blocking()`(detect_shell 同步路径)中执行并填充 PWSH_CACHE;此处仅读。
|
||||
/// 缓存空(未探测 / 探测超时未 set)时返回 `ShellType::default()`(Windows → PowerShell,Unix → Sh)。
|
||||
/// 单源语义:env_snapshot::detect_shell 与 build_command 共用此判定,杜绝「探测与执行两套逻辑漂移」。
|
||||
pub fn current_shell() -> ShellType {
|
||||
ShellType::default()
|
||||
}
|
||||
|
||||
/// 剥离 ANSI 转义序列(#8 出口兜底)。
|
||||
///
|
||||
/// 源头抑制(build_command 注入 NO_COLOR/TERM=dumb)覆盖主流工具链,但总有漏网
|
||||
/// (不读环境变量的工具/嵌套脚本转发色码)。本纯函数兜底 CSI/OSC 两类常见序列:
|
||||
/// - CSI:ESC [ 参数字母(颜色/光标/擦除,如 \x1b[7m \x1b[0K)
|
||||
/// - OSC:ESC ] ... BEL 或 ESC \(终端标题/超链接,罕见但同样乱码)
|
||||
/// 不碰普通文本与换行,幂等(已剥离文本再剥无变化)。
|
||||
pub fn strip_ansi(text: &str) -> String {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut chars = text.char_indices().peekable();
|
||||
while let Some((i, c)) = chars.next() {
|
||||
if c != '\x1b' {
|
||||
out.push(c);
|
||||
continue;
|
||||
}
|
||||
// c == ESC:按下一字符分派序列类型
|
||||
match text[i + 1..].chars().next() {
|
||||
Some('[') => {
|
||||
// CSI:跳到终止字母(@-~)为止
|
||||
let rest = &text[i + 2..];
|
||||
match rest.find(|ch: char| ('\u{40}'..='\u{7e}').contains(&ch)) {
|
||||
Some(end) => {
|
||||
// 跳过整个序列: advance 到 i+2+end 的下一字符
|
||||
let skip = 2 + end + 1;
|
||||
for _ in 1..skip {
|
||||
chars.next();
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// 流式截断的残缺序列(无终止符):丢弃剩余全部
|
||||
for _ in (i + 1)..text.len() {
|
||||
chars.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(']') => {
|
||||
// OSC:到 BEL(\x07) 或 ST(ESC \)为止
|
||||
let rest = &text[i + 2..];
|
||||
let bel = rest.find('\x07');
|
||||
let st = rest.find("\x1b\\");
|
||||
match (bel, st) {
|
||||
(Some(b), None) => {
|
||||
// 已消费 ESC,再消费 i+1..=i+2+b(含 BEL)共 b+2 个
|
||||
for _ in 0..(2 + b) {
|
||||
chars.next();
|
||||
}
|
||||
}
|
||||
(_, Some(s)) => {
|
||||
// 再消费 i+1..=i+2+s+1(含 ST 两字符)共 s+3 个
|
||||
for _ in 0..(2 + s + 1) {
|
||||
chars.next();
|
||||
}
|
||||
}
|
||||
(None, None) => {
|
||||
for _ in (i + 1)..text.len() {
|
||||
chars.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// 两字符转义(ESC \ ESC ( 等):丢 ESC 保留后字符?保守双丢后单字符
|
||||
chars.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// #8 ANSI 剥离:CSI 色码/擦除/流式残缺序列/OSC/普通文本不动
|
||||
#[test]
|
||||
fn strip_ansi_basic() {
|
||||
assert_eq!(strip_ansi("\x1b[7ma\x1b[0mb"), "ab");
|
||||
assert_eq!(strip_ansi("\x1b[2K\x1b[1Gdone"), "done");
|
||||
assert_eq!(strip_ansi("plain text\nline2"), "plain text\nline2");
|
||||
// 流式残缺(无终止符):丢残序列
|
||||
assert_eq!(strip_ansi("ok\x1b[3"), "ok");
|
||||
// OSC BEL 终止
|
||||
assert_eq!(strip_ansi("\x1b]0;title\x07body"), "body");
|
||||
// 幂等
|
||||
let once = strip_ansi("\x1b[32mgreen\x1b[0m");
|
||||
assert_eq!(strip_ansi(&once), once);
|
||||
}
|
||||
|
||||
/// 死缓存回归测试:probe_pwsh 写入后,Default 同步路径必须读到同一缓存值。
|
||||
///
|
||||
/// 历史 bug:probe_pwsh_cached() 与 probe_pwsh() 各持独立 OnceLock,Default 读的永空。
|
||||
/// 本测试经 probe_pwsh 写模块级 PWSH_CACHE 后断言 default() 与探测值一致,锁定「写读同源」不变量。
|
||||
///
|
||||
/// 隔离:持串行闸,进时备份、断言前复位。OnceLock 时代写入后全进程不可清,
|
||||
/// 会污染并行的 env_snapshot 漂移测试(其快照先以空缓存创建,断言即漂移)。
|
||||
#[cfg(windows)]
|
||||
#[tokio::test]
|
||||
async fn probe_pwsh_cache_shared_with_default() {
|
||||
let _gate = PWSH_CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let backup = cache_get();
|
||||
// 探测一次填充缓存(无论机器是否装 pwsh,只要写读同源即应一致)。
|
||||
// 注:正常完成路径才会写缓存;本机 pwsh 探测不超时/不 panic,故 cached 应与 probed 一致。
|
||||
let probed = probe_pwsh().await;
|
||||
let cached = cache_get();
|
||||
// Default 必须读到与 probe 一致的判定:Pwsh ↔ true,PowerShell ↔ false
|
||||
let default_shell = ShellType::default();
|
||||
// 复位放在断言前:中途 panic 不残留污染态(对齐 path_auth.rs 先复位再断言)
|
||||
cache_set(backup);
|
||||
// 探测完成后缓存必已填充(同步路径由此读到)
|
||||
assert_eq!(cached, Some(probed));
|
||||
match probed {
|
||||
true => assert_eq!(default_shell, ShellType::Pwsh),
|
||||
false => assert_eq!(default_shell, ShellType::PowerShell),
|
||||
}
|
||||
}
|
||||
|
||||
/// 不挂死不变量回归:probe_pwsh 必须在有限时间内返回(自带 3s 超时 + spawn_blocking)。
|
||||
///
|
||||
/// 历史 bug:probe_pwsh 仅 spawn_blocking 无超时,Windows Store Alias / 杀软 hook 拦截 pwsh 时
|
||||
/// status() 永不返回 → spawn_blocking 线程永不返回 → probe_pwsh().await 永久挂 →
|
||||
/// execute()/execute_streaming() 卡死 → run_agentic_loop 死锁(与 env_snapshot::detect 同源 bug)。
|
||||
/// 本测试外包 10s timeout(远大于 3s 内部超时),无论探测成败都应在 10s 内返回,锁定「不挂死」不变量。
|
||||
///
|
||||
/// 隔离:持串行闸,探测在缓存为空时会写入缓存,备份/复位防污染并行测试。
|
||||
#[cfg(windows)]
|
||||
#[tokio::test]
|
||||
async fn probe_pwsh_completes_within_timeout() {
|
||||
let _gate = PWSH_CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let backup = cache_get();
|
||||
// 10s >> probe_pwsh 内部 3s 超时;若 10s 仍未返回 → 探测挂死,不变量被破坏。
|
||||
let result = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(10),
|
||||
probe_pwsh(),
|
||||
).await;
|
||||
cache_set(backup);
|
||||
assert!(result.is_ok(), "probe_pwsh 必须在 10s 内返回(内部 3s 超时已兜底),不应挂死");
|
||||
}
|
||||
}
|
||||
|
||||
/// Shell 命令执行请求
|
||||
@@ -76,18 +320,33 @@ pub struct ShellRequest {
|
||||
pub env: std::collections::HashMap<String, String>,
|
||||
/// 超时时间(秒),None 表示不超时
|
||||
pub timeout_secs: Option<u64>,
|
||||
/// Shell 类型(默认:Windows→Cmd, 非Windows→Sh)
|
||||
/// Shell 类型(None 时取 [`ShellType::default`]:Windows→pwsh 可用则 pwsh 否则 PowerShell,非 Windows→Sh)
|
||||
#[serde(default)]
|
||||
pub shell_type: Option<ShellType>,
|
||||
}
|
||||
|
||||
/// 执行 Shell 命令
|
||||
///
|
||||
/// 支持超时(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();
|
||||
/// 输出流类型(回调 on_output 用)
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum StreamKind {
|
||||
Stdout,
|
||||
Stderr,
|
||||
}
|
||||
|
||||
impl StreamKind {
|
||||
/// 序列化为稳定字符串标识(emit 事件 stream 字段用)
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
StreamKind::Stdout => "stdout",
|
||||
StreamKind::Stderr => "stderr",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造已配置好(stdio piped + kill_on_drop + CREATE_NO_WINDOW + cwd + env)的子进程 Command。
|
||||
///
|
||||
/// execute() 与 execute_streaming() 共用同一构造逻辑(单真相源,DRY):
|
||||
/// shell 类型选择 / kill_on_drop / Windows 无窗 / cwd / env 全在此。差异仅在后续如何消费 stdout/stderr。
|
||||
fn build_command(request: ShellRequest) -> tokio::process::Command {
|
||||
let shell_type = request.shell_type.unwrap_or_default();
|
||||
let mut cmd = match shell_type {
|
||||
ShellType::PowerShell => {
|
||||
@@ -117,13 +376,13 @@ pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> {
|
||||
}
|
||||
};
|
||||
|
||||
// CR-15-1: kill_on_drop(true) 让 Command 被 drop 时主动 kill 子进程。
|
||||
// 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 黑窗闪现)。
|
||||
// Windows 下创建子进程默认弹控制台窗口(cmd/powershell 黑窗闪现)。
|
||||
// CREATE_NO_WINDOW(0x0800_0000) 标志抑制窗口创建,后台静默执行。
|
||||
// tokio::process::Command 在 Windows 自带 creation_flags 方法(无需 std CommandExt trait)。
|
||||
#[cfg(windows)]
|
||||
@@ -138,23 +397,185 @@ pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> {
|
||||
for (key, value) in &request.env {
|
||||
cmd.env(key, value);
|
||||
}
|
||||
// #8 ANSI 裸露根治(源头抑制):非交互管道下多数 CLI 仍探测 TERM 发色码,cargo/ls 等
|
||||
// 输出 \x1b[Nm 原样透传前端显示为 ␛[7m 乱码。注入 NO_COLOR(https://no-color.org 标准)+
|
||||
// TERM=dumb+FORCE_COLOR=0 三重信号,主流工具链(cargo/npm/git/ls/pytest)管道下停发色码。
|
||||
// 调用方显式传同名 env 时以调用方为准(后写覆盖,见上循环顺序)。
|
||||
cmd.env("NO_COLOR", "1");
|
||||
cmd.env("TERM", "dumb");
|
||||
cmd.env("FORCE_COLOR", "0");
|
||||
cmd
|
||||
}
|
||||
|
||||
let output = match request.timeout_secs {
|
||||
/// 执行 Shell 命令(等 exit 一次性返回,非流式)
|
||||
///
|
||||
/// 支持超时(timeout_secs)、环境变量(env)、工作目录(working_dir),
|
||||
/// kill_on_drop(true) 保证超时后子进程不残留,shell_type 可选 Cmd/PowerShell/Sh。
|
||||
///
|
||||
/// 需要执行中实时获取 stdout/stderr 行(如 run_command 进度展示)用 [`execute_streaming`]。
|
||||
pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> {
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
// 提交冻结机械闸(39 治理):冻结在场且命令为 git commit → spawn 前拒绝,改动留工作区
|
||||
crate::commit_freeze::check(&request).map_err(anyhow::Error::msg)?;
|
||||
|
||||
// 探测 pwsh(惰性 + 模块级 PWSH_CACHE,成功才写缓存),使后续 ShellType::default() 可读取缓存
|
||||
#[cfg(windows)]
|
||||
let _ = probe_pwsh().await;
|
||||
|
||||
// 先取走 build_command 之外的引用字段(超时错误信息 + timeout 判定),再 move request
|
||||
let command_for_err = request.command.clone();
|
||||
let timeout_secs = request.timeout_secs;
|
||||
let mut cmd = build_command(request);
|
||||
|
||||
let output = match timeout_secs {
|
||||
Some(secs) => tokio::time::timeout(
|
||||
std::time::Duration::from_secs(secs),
|
||||
cmd.output(),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("命令执行超时({}s): {}", secs, request.command))??,
|
||||
.map_err(|_| anyhow::anyhow!("命令执行超时({}s): {}", secs, command_for_err))??,
|
||||
None => cmd.output().await?,
|
||||
};
|
||||
|
||||
let duration = start.elapsed().as_millis() as u64;
|
||||
|
||||
Ok(ShellResult {
|
||||
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
|
||||
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
|
||||
stdout: strip_ansi(&String::from_utf8_lossy(&output.stdout)),
|
||||
stderr: strip_ansi(&String::from_utf8_lossy(&output.stderr)),
|
||||
exit_code: output.status.code(),
|
||||
duration_ms: duration,
|
||||
})
|
||||
}
|
||||
|
||||
/// 流式执行 Shell 命令 —— spawn 后逐行读 stdout/stderr,每行回调 on_output。
|
||||
///
|
||||
/// 治 run_command 执行中黑盒:execute() 等 exit 才返回整块 stdout/stderr,长命令(cargo/npm 构建)
|
||||
/// 期间前端只看 Started→Completed,中间进度不可见。本函数 spawn 子进程后并发逐行读两条流,
|
||||
/// 每读到一行回调 `on_output(kind, line)`(调用方可 emit 事件给前端实时展示),仍等进程 exit
|
||||
/// 后返回完整 ShellResult。
|
||||
///
|
||||
/// 与 [`execute`] 的输出差异(EX-2,调用方须知):本函数 stdout/stderr 按行重组——经
|
||||
/// `BufReader::lines()` 逐行拼接,行尾统一为 `\n`(剥 \r\n)且补末尾换行;execute 则保留原始字节
|
||||
/// (lossy 解码,含 \r\n,无补换行)。二者 ShellResult 字段同形,仅换行形态不同;需字节级一致时用 execute。
|
||||
///
|
||||
/// 4性:
|
||||
/// - 合理机制:spawn + BufReader::lines() 逐行,不丢未换行结尾的末段(read_to_end 兜底)
|
||||
/// - 简洁:与 execute() 共用 build_command(单真相源,shell/kill_on_drop/cwd/env 不重复)
|
||||
/// - 可靠兜底:timeout_secs 仍生效(超时 drop future → kill_on_drop 杀进程,返回 Err);
|
||||
/// on_output 回调 Err 不影响主流程(调用方 emit 失败静默吞)
|
||||
/// - 健壮边界:stdout/stderr 各独立任务并发读,互不阻塞;无管道死锁(piped + 同时消费)
|
||||
pub async fn execute_streaming<F>(request: ShellRequest, mut on_output: F) -> anyhow::Result<ShellResult>
|
||||
where
|
||||
F: FnMut(StreamKind, &str) + Send,
|
||||
{
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
// 提交冻结机械闸(39 治理):同 execute,流式路径同样 spawn 前拦截
|
||||
crate::commit_freeze::check(&request).map_err(anyhow::Error::msg)?;
|
||||
|
||||
#[cfg(windows)]
|
||||
let _ = probe_pwsh().await;
|
||||
|
||||
// 先取走引用字段,再 move request 进 build_command
|
||||
let command_for_err = request.command.clone();
|
||||
let timeout_secs = request.timeout_secs;
|
||||
let mut cmd = build_command(request);
|
||||
let inner = async {
|
||||
let mut child = cmd.spawn()?;
|
||||
// 取出 piped 的 stdout/stderr handle(None → 视为已关,读为空,不影响主流程)
|
||||
let stdout = child.stdout.take();
|
||||
let stderr = child.stderr.take();
|
||||
|
||||
// mpsc 通道:读 task 把 (kind, line) 推过来,主 task 在 wait 期间 drain 并调 on_output。
|
||||
// 用通道而非直接共享 on_output:FnMut 不可 clone,两读 task 无法各持一份;通道解耦读写,
|
||||
// 回调集中在主 task 单点调用(顺序确定、无锁、回调内阻塞不影响读循环)。
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel::<(StreamKind, String)>(64);
|
||||
|
||||
let mut tasks: Vec<tokio::task::JoinHandle<()>> = Vec::with_capacity(2);
|
||||
if let Some(out) = stdout {
|
||||
let tx = tx.clone();
|
||||
tasks.push(tokio::spawn(async move {
|
||||
let mut reader = BufReader::new(out).lines();
|
||||
while let Ok(Some(line)) = reader.next_line().await {
|
||||
if tx.send((StreamKind::Stdout, line)).await.is_err() {
|
||||
break; // 接收端 drop(主 task 结束)→ 停止读
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
if let Some(err) = stderr {
|
||||
let tx = tx.clone();
|
||||
tasks.push(tokio::spawn(async move {
|
||||
let mut reader = BufReader::new(err).lines();
|
||||
while let Ok(Some(line)) = reader.next_line().await {
|
||||
if tx.send((StreamKind::Stderr, line)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
// 主 task 不再 send → drop tx(读 task send 失败即退出)
|
||||
drop(tx);
|
||||
|
||||
// 完整输出累积(主 task 单点写,无锁)。
|
||||
let mut stdout_buf = String::new();
|
||||
let mut stderr_buf = String::new();
|
||||
|
||||
// wait + drain 并行:边等进程退出边消费输出行(防管道写满阻塞致子进程 hang)。
|
||||
let wait_fut = child.wait();
|
||||
tokio::pin!(wait_fut);
|
||||
let status: std::process::ExitStatus = loop {
|
||||
tokio::select! {
|
||||
// 进程退出 → 跳出循环,继续 drain 通道内残余行
|
||||
status = &mut wait_fut => {
|
||||
let status = status?;
|
||||
// drain 剩余行(读 task 在管道 EOF 后 send 完最后批次即退出,rx 返 None 闭合)
|
||||
while let Some((kind, line)) = rx.recv().await {
|
||||
let clean = strip_ansi(&line);
|
||||
match kind {
|
||||
StreamKind::Stdout => { stdout_buf.push_str(&clean); stdout_buf.push('\n'); }
|
||||
StreamKind::Stderr => { stderr_buf.push_str(&clean); stderr_buf.push('\n'); }
|
||||
}
|
||||
on_output(kind, &clean);
|
||||
}
|
||||
break status;
|
||||
}
|
||||
// 收到一行 → 累积 + 回调
|
||||
Some((kind, line)) = rx.recv() => {
|
||||
let clean = strip_ansi(&line);
|
||||
match kind {
|
||||
StreamKind::Stdout => { stdout_buf.push_str(&clean); stdout_buf.push('\n'); }
|
||||
StreamKind::Stderr => { stderr_buf.push_str(&clean); stderr_buf.push('\n'); }
|
||||
}
|
||||
on_output(kind, &clean);
|
||||
}
|
||||
}
|
||||
};
|
||||
// 防御性 join 读 task(此时必已 EOF 退出,仅保险;失败静默不阻断)
|
||||
for t in tasks {
|
||||
let _ = t.await;
|
||||
}
|
||||
Ok::<ShellResult, anyhow::Error>(ShellResult {
|
||||
stdout: stdout_buf,
|
||||
stderr: stderr_buf,
|
||||
exit_code: status.code(),
|
||||
duration_ms: 0, // 外层统一填
|
||||
})
|
||||
};
|
||||
|
||||
let result = match timeout_secs {
|
||||
Some(secs) => tokio::time::timeout(std::time::Duration::from_secs(secs), inner)
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("命令执行超时({}s): {}", secs, command_for_err))??,
|
||||
None => inner.await?,
|
||||
};
|
||||
|
||||
let duration = start.elapsed().as_millis() as u64;
|
||||
Ok(ShellResult {
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
exit_code: result.exit_code,
|
||||
duration_ms: duration,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
//!
|
||||
//! 注:execute 逻辑本身未改动,此文件为零行为变更的纯新增测试。
|
||||
|
||||
use df_execute::shell::{execute, ShellRequest, ShellType};
|
||||
use df_execute::shell::{execute, execute_streaming, ShellRequest, ShellType, StreamKind};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// 平台默认 ShellType(对齐 shell.rs:31 Default impl:Windows→Cmd, 非 Windows→Sh)
|
||||
@@ -159,3 +159,81 @@ async fn execute_working_dir() {
|
||||
// 清理
|
||||
let _ = std::fs::remove_dir_all(&tmp_for_cleanup);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// execute_streaming 流式测试
|
||||
// ============================================================
|
||||
|
||||
/// 流式:stdout 多行逐行回调,且 ShellResult 完整(行数对齐 + exit_code=0)。
|
||||
///
|
||||
/// 治 run_command 黑盒:验证 spawn 后逐行回调 vs 一次性返回的等价性(行内容 + 完整结果)。
|
||||
#[tokio::test]
|
||||
async fn streaming_stdout_lines_callback() {
|
||||
// 多行输出:Cmd 用多个 echo(用 & 串联无依赖),Sh 用 printf 多行
|
||||
let cmd = if cfg!(windows) {
|
||||
"@echo line1 & @echo line2 & @echo line3"
|
||||
} else {
|
||||
"printf 'line1\\nline2\\nline3\\n'"
|
||||
};
|
||||
let mut lines: Vec<(StreamKind, String)> = Vec::new();
|
||||
let res = execute_streaming(req(cmd), |kind, line| {
|
||||
lines.push((kind, line.to_string()));
|
||||
})
|
||||
.await
|
||||
.expect("execute_streaming 应返回 Ok");
|
||||
|
||||
assert_eq!(res.exit_code, Some(0), "成功命令 exit_code 应为 0");
|
||||
// stdout 应含三行(line1/line2/line3)
|
||||
assert!(res.stdout.contains("line1"), "stdout 应含 line1,实际: {:?}", res.stdout);
|
||||
assert!(res.stdout.contains("line3"), "stdout 应含 line3,实际: {:?}", res.stdout);
|
||||
// 回调收到的 stdout 行应含三行(过滤 stderr 干扰:Cmd 无 stderr,Sh 无 stderr)
|
||||
let stdout_lines: Vec<&String> = lines.iter()
|
||||
.filter(|(k, _)| *k == StreamKind::Stdout)
|
||||
.map(|(_, l)| l)
|
||||
.collect();
|
||||
assert!(
|
||||
stdout_lines.iter().any(|l| l.contains("line1")),
|
||||
"回调应收到含 line1 的 stdout 行,实际: {:?}", stdout_lines
|
||||
);
|
||||
assert!(
|
||||
stdout_lines.iter().any(|l| l.contains("line3")),
|
||||
"回调应收到含 line3 的 stdout 行,实际: {:?}", stdout_lines
|
||||
);
|
||||
}
|
||||
|
||||
/// 流式:超时仍生效(timeout_secs=1 + 长睡命令,返回 Err)。
|
||||
#[tokio::test]
|
||||
async fn streaming_timeout_returns_err() {
|
||||
let sleep_cmd = if cfg!(windows) {
|
||||
"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_streaming(request, |_, _| {}).await;
|
||||
assert!(result.is_err(), "超时应返回 Err,实际: {:?}", result.as_ref().err());
|
||||
let msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
msg.contains("超时") || msg.to_lowercase().contains("timeout"),
|
||||
"错误信息应含超时提示,实际: {}",
|
||||
msg
|
||||
);
|
||||
}
|
||||
|
||||
/// 流式:非零退出仍返回 Ok + exit_code 非 0(对齐 execute 语义)。
|
||||
#[tokio::test]
|
||||
async fn streaming_nonzero_exit() {
|
||||
let mut callbacks = 0u32;
|
||||
let res = execute_streaming(req("exit 1"), |_, _| { callbacks += 1; })
|
||||
.await
|
||||
.expect("非零退出应仍返回 Ok");
|
||||
assert_ne!(res.exit_code, Some(0), "exit 1 的 exit_code 应非 0");
|
||||
// exit 1 无输出,回调可为 0 次(无行)——不强制断言次数,只确认无 panic
|
||||
let _ = callbacks;
|
||||
}
|
||||
|
||||
@@ -7,13 +7,13 @@
|
||||
//! 评估来源由 [`EvaluatedBy`] 三态标记:`Llm`(LLM 深度评估)/ `Heuristic`(主动选启发式,
|
||||
//! 无 provider)/ `HeuristicFallback`(LLM 调用失败降级)。前端可据此显示评估深度标签。
|
||||
//!
|
||||
//! LLM prompt 构造与 JSON 解析在 F-260614-03 接入:[`AdversarialEngine::evaluate_with_llm`]
|
||||
//! LLM prompt 构造与 JSON 解析在后续接入:[`AdversarialEngine::evaluate_with_llm`]
|
||||
//! 构造三角色辩论 prompt(正方/反方/分析师),调一次 `complete()` 要求返回对齐结构的 JSON,
|
||||
//! 解析失败/字段缺失/枚举非法 → `bail` 触发降级([`AdversarialEngine::evaluate`] 已兜底)。
|
||||
//!
|
||||
//! 重构(strategy·自底向上):类型定义 / 常量 / prompt 构造 / JSON 解析等无副作用逻辑抽至
|
||||
//! [`adversarial_helpers`],本文件只保留 [`AdversarialEngine`](有状态引擎 + evaluate 主入口,
|
||||
//! 含 ARC-260618-01-e evaluate_with_llm 一致性待决策逻辑,原样保留)。外部已用路径(
|
||||
//! 含 ARC-260618-01-e 自洽性校验,已实施——解析后按 final_score 修正矛盾字段)。外部已用路径(
|
||||
//! `df_ideas::adversarial::{AdversarialEval, Recommendation, …}`)经 `pub use` 不变。
|
||||
|
||||
use std::sync::Arc;
|
||||
@@ -33,8 +33,8 @@ 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,
|
||||
SYSTEM_PROMPT, action_hint, assessment_desc, assessment_for_score, build_adversarial_prompt,
|
||||
enforce_score_consistency, parse_llm_eval, priority_label, recommendation_for_level,
|
||||
};
|
||||
|
||||
/// 对抗评估引擎
|
||||
@@ -42,7 +42,7 @@ 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 路由选模型;
|
||||
/// 候选模型池。非空时 evaluate_with_llm 经 select_model_id 路由选模型;
|
||||
/// 空(None provider 或未注入池)→ model 留空由 provider impl 回填自身 default_model
|
||||
/// (与接入前行为一致,平稳过渡)。
|
||||
model_pool: Vec<ModelConfig>,
|
||||
@@ -54,7 +54,7 @@ impl AdversarialEngine {
|
||||
Self { provider: Some(provider), model_pool: Vec::new() }
|
||||
}
|
||||
|
||||
/// F-01 阶段5: 注入 provider + 候选模型池构造。池非空时 evaluate_with_llm 走路由。
|
||||
/// 注入 provider + 候选模型池构造。池非空时 evaluate_with_llm 走路由。
|
||||
pub fn with_pool(provider: Arc<dyn LlmProvider>, model_pool: Vec<ModelConfig>) -> Self {
|
||||
Self { provider: Some(provider), model_pool }
|
||||
}
|
||||
@@ -97,17 +97,19 @@ impl AdversarialEngine {
|
||||
/// temperature 取 0.4:低于 0.3 偏机械重复启发式信号,高于 0.5 易发散到无关风险,
|
||||
/// 0.4 在「稳定可复现」与「论点多样性」间取得平衡。
|
||||
///
|
||||
/// ARC-260618-01-e: evaluate_with_llm 返回值一致性未校验(final_score 与
|
||||
/// analyst.final_assessment 自洽性等),待产品决策,当前逻辑原样保留不调整。
|
||||
/// ARC-260618-01-e(已实施): evaluate_with_llm 解析后经 [`enforce_score_consistency`]
|
||||
/// 做自洽性校验——final_score 与 recommendation / analyst.final_assessment 不一致时,
|
||||
/// 按 final_score 统一重新判定两字段(LLM 论点等自由文本保留,不改 LLM 调用/输出结构)。
|
||||
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,无工具)。
|
||||
// 智能路由 — 对抗评估 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,
|
||||
tier: None,
|
||||
};
|
||||
let model = df_ai::router::select_model_id(&eval_req, &self.model_pool).unwrap_or_default();
|
||||
let request = df_ai_core::provider::CompletionRequest {
|
||||
@@ -133,7 +135,9 @@ impl AdversarialEngine {
|
||||
|
||||
// 数值 clamp 由 parse_llm_eval 单点收口(final_score∈[0,10]、confidence∈[0,1],
|
||||
// 均在 parse 内对所有 Ok 路径完成),此处不再重复 clamp(CR-40-1 去冗余)。
|
||||
parse_llm_eval(&resp.text, &idea.id)
|
||||
// ARC-260618-01-e: 解析后做自洽性校验(final_score 与 recommendation /
|
||||
// analyst.final_assessment 矛盾时按 final_score 修正,见 enforce_score_consistency)。
|
||||
Ok(enforce_score_consistency(parse_llm_eval(&resp.text, &idea.id)?))
|
||||
}
|
||||
|
||||
/// 启发式评估(基于评分与内容信号,稳定有区分度)
|
||||
@@ -144,7 +148,7 @@ impl AdversarialEngine {
|
||||
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);
|
||||
let recommendation = recommendation_for_level(&analyst.final_assessment);
|
||||
|
||||
Ok(AdversarialEval {
|
||||
idea_id: idea.id.clone(),
|
||||
@@ -239,13 +243,8 @@ impl AdversarialEngine {
|
||||
|
||||
/// 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,
|
||||
};
|
||||
// 分档与 LLM 自洽性校验共用 assessment_for_score,单源防两处阈值漂移
|
||||
let final_assessment = assessment_for_score(scores.overall);
|
||||
|
||||
let mut strengths = Vec::new();
|
||||
if scores.impact >= 6.0 {
|
||||
@@ -304,22 +303,12 @@ impl AdversarialEngine {
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::adversarial_helpers::recommendation_for_score;
|
||||
use crate::capture::Idea;
|
||||
use crate::scoring::ScoringEngine;
|
||||
use df_types::types::{IdeaStatus, Priority};
|
||||
@@ -421,7 +410,7 @@ mod tests {
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// LLM 路径测试(F-260614-03)
|
||||
// LLM 路径测试
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// mock LlmProvider:按构造时给定的响应文本回放,仅供 adversarial 单测。
|
||||
@@ -593,4 +582,78 @@ mod tests {
|
||||
assert!((eval.final_score - 7.0).abs() < 1e-9);
|
||||
assert_eq!(eval.recommendation, Recommendation::Soon);
|
||||
}
|
||||
|
||||
/// 自洽性校验(ARC-260618-01-e 已实施): LLM 高分配 Monitor/Defer → 按 final_score 修正
|
||||
/// 为 ImmediateAction/StrongGo,仅字段修正不整体降级启发式。
|
||||
#[tokio::test]
|
||||
async fn a13_llm_inconsistent_high_score_corrected() {
|
||||
let idea = make_idea("矛盾测试", "高分却给 Monitor", Priority::High, vec![]);
|
||||
let llm_text = r#"{
|
||||
"positive": {"thesis":"p","evidence":[],"reasoning":[],"confidence":0.8},
|
||||
"negative": {"thesis":"n","evidence":[],"reasoning":[],"confidence":0.3},
|
||||
"analyst": {"summary":"s","strengths":[],"weaknesses":[],"risks":[],"opportunities":[],"final_assessment":"Defer"},
|
||||
"final_score": 8.5,
|
||||
"recommendation": "Monitor"
|
||||
}"#;
|
||||
let provider = Arc::new(MockProvider { text: llm_text.to_string() });
|
||||
let engine = AdversarialEngine::new(provider);
|
||||
let eval = engine.evaluate(&idea).await.unwrap();
|
||||
|
||||
println!("\n[a13] 高分配 Monitor/Defer → 修正");
|
||||
println!(" final_score={:.2} (高分)", eval.final_score);
|
||||
println!(" recommendation={:?} (期望 ImmediateAction)", eval.recommendation);
|
||||
println!(" final_assessment={:?} (期望 StrongGo)", eval.analyst.final_assessment);
|
||||
|
||||
assert_eq!(eval.evaluated_by, EvaluatedBy::Llm, "仅字段矛盾不整体降级");
|
||||
assert!((eval.final_score - 8.5).abs() < 1e-9, "final_score 不被修正");
|
||||
assert_eq!(eval.recommendation, Recommendation::ImmediateAction);
|
||||
assert_eq!(eval.analyst.final_assessment, AssessmentLevel::StrongGo);
|
||||
}
|
||||
|
||||
/// 自洽性校验: LLM 低分配 ImmediateAction/StrongGo → 按 final_score 修正为 Monitor/Defer。
|
||||
#[tokio::test]
|
||||
async fn a14_llm_inconsistent_low_score_corrected() {
|
||||
let idea = make_idea("低分矛盾", "低分却给 ImmediateAction", Priority::Medium, vec![]);
|
||||
let llm_text = r#"{
|
||||
"positive": {"thesis":"p","evidence":[],"reasoning":[],"confidence":0.9},
|
||||
"negative": {"thesis":"n","evidence":[],"reasoning":[],"confidence":0.1},
|
||||
"analyst": {"summary":"s","strengths":[],"weaknesses":[],"risks":[],"opportunities":[],"final_assessment":"StrongGo"},
|
||||
"final_score": 2.0,
|
||||
"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[a14] 低分配 ImmediateAction/StrongGo → 修正");
|
||||
println!(" final_score={:.2} (低分)", eval.final_score);
|
||||
println!(" recommendation={:?} (期望 Monitor)", eval.recommendation);
|
||||
println!(" final_assessment={:?} (期望 Defer)", eval.analyst.final_assessment);
|
||||
|
||||
assert_eq!(eval.evaluated_by, EvaluatedBy::Llm);
|
||||
assert!((eval.final_score - 2.0).abs() < 1e-9, "final_score 不被修正");
|
||||
assert_eq!(eval.recommendation, Recommendation::Monitor);
|
||||
assert_eq!(eval.analyst.final_assessment, AssessmentLevel::Defer);
|
||||
}
|
||||
|
||||
/// 自洽性校验分档单测: 与启发式 analyst_analysis 同一分档(阈值边界逐一覆盖)。
|
||||
#[test]
|
||||
fn consistency_banding_matches_heuristic() {
|
||||
assert_eq!(assessment_for_score(9.0), AssessmentLevel::StrongGo);
|
||||
assert_eq!(assessment_for_score(7.5), AssessmentLevel::StrongGo); // 上界含
|
||||
assert_eq!(assessment_for_score(7.0), AssessmentLevel::Recommended);
|
||||
assert_eq!(assessment_for_score(6.0), AssessmentLevel::Recommended);
|
||||
assert_eq!(assessment_for_score(5.0), AssessmentLevel::Conditional);
|
||||
assert_eq!(assessment_for_score(4.5), AssessmentLevel::Conditional);
|
||||
assert_eq!(assessment_for_score(4.0), AssessmentLevel::Revised);
|
||||
assert_eq!(assessment_for_score(3.0), AssessmentLevel::Revised);
|
||||
assert_eq!(assessment_for_score(2.9), AssessmentLevel::Defer);
|
||||
assert_eq!(assessment_for_score(0.0), AssessmentLevel::Defer);
|
||||
|
||||
assert_eq!(recommendation_for_score(9.0), Recommendation::ImmediateAction);
|
||||
assert_eq!(recommendation_for_score(6.5), Recommendation::Soon);
|
||||
assert_eq!(recommendation_for_score(4.8), Recommendation::WithResources);
|
||||
assert_eq!(recommendation_for_score(3.5), Recommendation::ResearchMore);
|
||||
assert_eq!(recommendation_for_score(1.0), Recommendation::Monitor);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
//! 对抗评估 — 纯函数 / 类型 / 常量(自 adversarial.rs 抽离,纯搬迁,零行为变更)。
|
||||
//!
|
||||
//! 本文件承载 [`AdversarialEval`] 及其字段类型、LLM prompt 构造与 JSON 解析等无副作用
|
||||
//! 逻辑;有状态的引擎 [`AdversarialEngine`](含 evaluate / evaluate_with_llm 主入口与
|
||||
//! ARC-260618-01-e 待决策逻辑)仍留在 `adversarial.rs`,二者经 [`use`] 互相引用。
|
||||
//! 本文件承载 [`AdversarialEval`] 及其字段类型、LLM prompt 构造、JSON 解析与自洽性校验
|
||||
//! 等无副作用逻辑;有状态的引擎 [`AdversarialEngine`](含 evaluate / evaluate_with_llm
|
||||
//! 主入口,经 [`enforce_score_consistency`] 落地 ARC-260618-01-e 自洽性校验)仍留在
|
||||
//! `adversarial.rs`,二者经 [`use`] 互相引用。
|
||||
//!
|
||||
//! 抽离边界:类型定义(非 impl)+ 常量 + 顶层 free function。impl 主体不动,外部
|
||||
//! 已用路径(`df_ideas::adversarial::{AdversarialEngine, AdversarialEval, Recommendation}` 等)
|
||||
@@ -84,7 +85,7 @@ pub enum Recommendation {
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// LLM 对抗评估 — prompt 构造 / JSON 解析(F-260614-03)
|
||||
// LLM 对抗评估 — prompt 构造 / JSON 解析
|
||||
// ============================================================
|
||||
|
||||
/// LLM 角色 / 输出契约的系统级约束。
|
||||
@@ -295,7 +296,7 @@ pub(crate) fn extract_json(text: &str) -> String {
|
||||
// (原实现在此处 return body.trim() 会把语言标记/尾随文字一起喂 serde,
|
||||
// 必然失败降级启发式,即便正则本可救回。)
|
||||
}
|
||||
// 兜底:围栏不在开头或混杂前后文字 → 正则提取首个 JSON 对象(CR-40-2)。
|
||||
// 兜底:围栏不在开头或混杂前后文字 → 正则提取首个 JSON 对象。
|
||||
// (?s) 让 . 匹配换行,贪婪 {*} 取首 { 到末 },覆盖嵌套对象。
|
||||
// 提取失败(无 { })则返回 trimmed 走原文 serde 报错降级,语义不变。
|
||||
static JSON_RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
|
||||
@@ -336,6 +337,69 @@ pub(crate) fn parse_recommendation(s: &str) -> Result<Recommendation> {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 自洽性校验(ARC-260618-01-e 已实施)— 综合评分分档 / 修正矛盾字段
|
||||
// ============================================================
|
||||
|
||||
/// 评估等级 → 最终建议(与启发式 recommendation 映射同一套,收敛于此单点实现)。
|
||||
pub(crate) fn recommendation_for_level(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,
|
||||
}
|
||||
}
|
||||
|
||||
/// 综合评分 → 评估等级(分档与启发式 analyst_analysis 一致;自洽性校验复用,单源防两处漂移)。
|
||||
pub(crate) fn assessment_for_score(score: f64) -> AssessmentLevel {
|
||||
if score >= 7.5 {
|
||||
AssessmentLevel::StrongGo
|
||||
} else if score >= 6.0 {
|
||||
AssessmentLevel::Recommended
|
||||
} else if score >= 4.5 {
|
||||
AssessmentLevel::Conditional
|
||||
} else if score >= 3.0 {
|
||||
AssessmentLevel::Revised
|
||||
} else {
|
||||
AssessmentLevel::Defer
|
||||
}
|
||||
}
|
||||
|
||||
/// 综合评分 → 最终建议(先分档再映射)。
|
||||
pub(crate) fn recommendation_for_score(score: f64) -> Recommendation {
|
||||
recommendation_for_level(&assessment_for_score(score))
|
||||
}
|
||||
|
||||
/// 自洽性校验: LLM 解析结果可能 final_score 与 recommendation / analyst.final_assessment
|
||||
/// 互相矛盾(如高分配 Monitor、低分配 ImmediateAction),parse 只校验枚举合法性不校验语义。
|
||||
/// 以 final_score 为准重新判定这两个结构化字段消除矛盾;LLM 生成的论点/证据/summary 等
|
||||
/// 自由文本原样保留。不改 LLM 调用/输出结构,只在解析后修正字段值(ARC-260618-01-e)。
|
||||
pub(crate) fn enforce_score_consistency(mut eval: AdversarialEval) -> AdversarialEval {
|
||||
let expected_rec = recommendation_for_score(eval.final_score);
|
||||
let expected_level = assessment_for_score(eval.final_score);
|
||||
if eval.recommendation != expected_rec {
|
||||
tracing::warn!(
|
||||
score = eval.final_score,
|
||||
from = ?eval.recommendation,
|
||||
to = ?expected_rec,
|
||||
"LLM 评估自洽性修正: recommendation 与 final_score 矛盾,按 final_score 重新判定"
|
||||
);
|
||||
eval.recommendation = expected_rec;
|
||||
}
|
||||
if eval.analyst.final_assessment != expected_level {
|
||||
tracing::warn!(
|
||||
score = eval.final_score,
|
||||
from = ?eval.analyst.final_assessment,
|
||||
to = ?expected_level,
|
||||
"LLM 评估自洽性修正: final_assessment 与 final_score 矛盾,按 final_score 重新判定"
|
||||
);
|
||||
eval.analyst.final_assessment = expected_level;
|
||||
}
|
||||
eval
|
||||
}
|
||||
|
||||
pub(crate) fn priority_label(p: &Priority) -> &'static str {
|
||||
match p {
|
||||
Priority::Critical => "紧急",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
//! 想法晋升 — PromotionResult(promote_idea IPC 返回类型)。
|
||||
//! 实际晋升路径:commands/idea.rs::promote_idea → df-project::ProjectManager::create_from_idea。
|
||||
//! 历史空壳 IdeaPromoter/PromotionPolicy 已删(R-PD-14)。
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
|
||||
@@ -125,8 +125,6 @@ impl ScoringEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/// 统计 text 中命中任一关键词的数量(小写匹配,兼顾中英文)
|
||||
/// 局限:纯子串匹配,不识别"不复用""无用户增长"等否定前缀,接 LLM 后由语义层修正
|
||||
/// 单字否定前缀:关键词紧邻这些字时该命中不计(「不复用」不计入「复用」正向命中)。
|
||||
///
|
||||
/// 注意「别」单字歧义大(别具/别的/特别 均非否定),故不进单字清单——
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
[package]
|
||||
name = "df-im-gateway"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "IM 网关:飞书/企微/钉钉/Web 四端远程接入 DevFlow AI Chat(WS Commander 连 u-relay)"
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
rusqlite = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
df-tunnel = { path = "../df-tunnel" }
|
||||
|
||||
# HTTP 静态托管 + WS 服务端(P1:Web Chat 页面由网关直接托管,/ 与 /ws 同端口)
|
||||
axum = { version = "0.7", features = ["ws"] }
|
||||
|
||||
# WS 客户端(commander 连 u-relay;与 df-tunnel 同款本地版本声明,workspace 无这几项)
|
||||
tokio-tungstenite = { version = "0.23", features = ["native-tls"] }
|
||||
futures-util = "0.3"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
# 飞书 SDK(长连接 + 归一化消息;channel 自动连带 ws+messaging)
|
||||
larksuite-oapi-sdk-rs = { version = "0.3.12", features = ["channel"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
@@ -0,0 +1,22 @@
|
||||
[Unit]
|
||||
Description=DevFlow IM Gateway (飞书/企微/钉钉/Web 四端远程接入)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/opt/df-im-gateway
|
||||
ExecStart=/opt/df-im-gateway/df-im-gateway
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
# ── 运行时 env(与本地 config.rs 对齐;token 与 u-relay 同源)──
|
||||
Environment=GATEWAY_RELAY_URL=ws://127.0.0.1:9180/ws/commander
|
||||
Environment=GATEWAY_RELAY_TOKEN=devflow-relay-default-token
|
||||
Environment=GATEWAY_DEFAULT_CHANNEL=chan-work
|
||||
Environment=GATEWAY_DATA_DIR=/opt/df-im-gateway/data
|
||||
Environment=RUST_LOG=info,df_im_gateway=debug
|
||||
StandardOutput=append:/opt/df-im-gateway/gateway.log
|
||||
StandardError=append:/opt/df-im-gateway/gateway.err.log
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,23 @@
|
||||
# df-im-gateway nginx 反代片段(与 u-relay 同机,复用 *.1216.top 通配符证书)
|
||||
# 放入 /etc/nginx/conf.d/ 后 nginx -t && systemctl reload nginx
|
||||
# Web Chat 页面 + WS 同端口 9191(网关 axum 直接托管)
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name im.1216.top;
|
||||
|
||||
ssl_certificate /etc/nginx/sslkey/_.1216.top.pem;
|
||||
ssl_certificate_key /etc/nginx/sslkey/_.1216.top.key;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:9191;
|
||||
proxy_http_version 1.1;
|
||||
# WS 升级头(与 u-relay 反代同款)
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
# 流式响应不缓冲(SSE/流式文本)
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
# df-im-gateway 运维手册
|
||||
|
||||
> 四端 IM 网关(飞书/企微/钉钉/Web)远程接入 DevFlow AI Chat。
|
||||
> 部署拓扑:与 u-relay 同机(39.99.243.191),WS Commander 经 u-relay 连桌面端 df-tunnel。
|
||||
|
||||
## 一、拓扑与环境
|
||||
|
||||
```
|
||||
飞书/企微/钉钉/Web ──► df-im-gateway(9191, 同机)
|
||||
│ ws://127.0.0.1:9180/ws/commander (Commander 身份)
|
||||
▼
|
||||
u-relay(9180, nginx 前置 wss://u-relay.1216.top)
|
||||
│ /ws/agent
|
||||
▼
|
||||
桌面端 DevFlow(df-tunnel Agent)
|
||||
```
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 服务器 | 39.99.243.191:22 (root 免密, Ubuntu 22.04) |
|
||||
| 部署路径 | /opt/df-im-gateway/(二进制 + data/ + 日志) |
|
||||
| 监听端口 | 9191(本机明文,nginx 前置 im.1216.top → wss) |
|
||||
| systemd | df-im-gateway.service(Restart=on-failure, 开机自启) |
|
||||
| token | devflow-relay-default-token(与 u-relay/桌面端三端同源) |
|
||||
| 数据库 | /opt/df-im-gateway/data/{sessions.db, audit.db}(SQLite WAL) |
|
||||
|
||||
## 二、日常运维
|
||||
|
||||
```bash
|
||||
# 状态 / 日志 / 重启
|
||||
systemctl status df-im-gateway
|
||||
journalctl -u df-im-gateway -n 50 --no-pager
|
||||
systemctl restart df-im-gateway
|
||||
|
||||
# 端口验证
|
||||
ss -tlnp | grep 9191
|
||||
|
||||
# 审计查询(最近 20 条)
|
||||
sqlite3 /opt/df-im-gateway/data/audit.db \
|
||||
"SELECT datetime(ts/1000,'unixepoch','+8 hours'), direction, action, chat_id, outcome FROM tunnel_audit ORDER BY id DESC LIMIT 20;"
|
||||
|
||||
# 会话映射表
|
||||
sqlite3 /opt/df-im-gateway/data/sessions.db "SELECT * FROM session_map;"
|
||||
```
|
||||
|
||||
⚠️ systemd 托管,勿 kill Main PID、勿 nohup 起野进程;重启一律 `systemctl restart`。
|
||||
|
||||
## 三、发布流程
|
||||
|
||||
```powershell
|
||||
# 本机(Windows)一键发布:单测 → musl 交叉编译 → scp → 原子切换 → 9191 验证 → 失败自动回滚
|
||||
powershell -File E:\wk-lab\devflow\crates\df-im-gateway\scripts\publish-im-gateway.ps1
|
||||
# 仅构建不部署: 加 -SkipDeploy;仅部署不构建: 加 -SkipBuild
|
||||
```
|
||||
|
||||
首次部署需手动铺底:
|
||||
```bash
|
||||
scp crates/df-im-gateway/deploy/df-im-gateway.service root@39.99.243.191:/etc/systemd/system/
|
||||
ssh root@39.99.243.191 'systemctl daemon-reload && systemctl enable df-im-gateway'
|
||||
# nginx(可选,Web 端公网访问时): scp deploy/nginx-im.1216.top.conf → /etc/nginx/conf.d/ && nginx -t && systemctl reload nginx
|
||||
```
|
||||
|
||||
## 四、回滚
|
||||
|
||||
```bash
|
||||
cd /opt/df-im-gateway
|
||||
systemctl stop df-im-gateway
|
||||
LATEST=$(ls -t df-im-gateway.bak.* | head -1)
|
||||
cp "$LATEST" df-im-gateway && chmod +x df-im-gateway
|
||||
systemctl start df-im-gateway
|
||||
```
|
||||
|
||||
## 五、配置变更
|
||||
|
||||
| env | 说明 | 改法 |
|
||||
|---|---|---|
|
||||
| GATEWAY_RELAY_TOKEN | 与 u-relay 三端同源,**改必须同步桌面端** | 改 unit Environment= → daemon-reload → restart |
|
||||
| GATEWAY_DEFAULT_CHANNEL | 默认路由 channel | 同上 |
|
||||
| GATEWAY_WHITELIST | 用户白名单(空=任何人可用,生产必设!) | 同上 |
|
||||
| FEISHU_APP_ID/SECRET | 飞书长连接 | 同上 |
|
||||
|
||||
## 六、已知项
|
||||
|
||||
- token 仍为 MVP 单共享值(per-user 是 S2 计划)
|
||||
- 日志 gateway.log 追加无轮转,可配 logrotate 或定期 truncate
|
||||
- 四端 E2E 联调依赖各平台凭证到位(飞书/钉钉/企微)
|
||||
@@ -0,0 +1,70 @@
|
||||
# df-im-gateway 一键发布脚本(复用 publish-u-relay 模式)
|
||||
# 用法: powershell -File scripts\publish-im-gateway.ps1 [-SkipBuild] [-SkipDeploy]
|
||||
# 流程: cargo test → musl 交叉编译 → scp 上传(.new 中转) → systemd 原子切换 → 9191 验证
|
||||
param(
|
||||
[switch]$SkipBuild,
|
||||
[switch]$SkipDeploy
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$Repo = "E:/wk-lab/devflow"
|
||||
$Target = "x86_64-unknown-linux-musl"
|
||||
$Server = "root@39.99.243.191"
|
||||
$RemoteDir = "/opt/df-im-gateway"
|
||||
$ServiceName = "df-im-gateway"
|
||||
$LocalBin = "$Repo/target-musl/$Target/release/df-im-gateway"
|
||||
|
||||
function Step($msg) { Write-Host "`n=== $msg ===" -ForegroundColor Cyan }
|
||||
|
||||
if (-not $SkipBuild) {
|
||||
Step "Step 0: 单测基线(df-im-gateway)"
|
||||
cargo test -p df-im-gateway --manifest-path "$Repo/Cargo.toml"
|
||||
if ($LASTEXITCODE -ne 0) { throw "单测失败,中止发布" }
|
||||
|
||||
Step "Step 1: musl 交叉编译(rust-lld self-contained)"
|
||||
$env:CARGO_TARGET_DIR = "$Repo/target-musl"
|
||||
cargo build --release -p df-im-gateway --manifest-path "$Repo/Cargo.toml" --target $Target
|
||||
if ($LASTEXITCODE -ne 0) { throw "编译失败,中止发布" }
|
||||
Remove-Item Env:\CARGO_TARGET_DIR -ErrorAction SilentlyContinue
|
||||
if (-not (Test-Path $LocalBin)) { throw "产物不存在: $LocalBin" }
|
||||
$size = [math]::Round((Get-Item $LocalBin).Length / 1MB, 1)
|
||||
Write-Host "产物: $LocalBin (${size}MB)"
|
||||
}
|
||||
|
||||
if ($SkipDeploy) { Write-Host "`nSkipDeploy: 仅构建完成"; exit 0 }
|
||||
|
||||
Step "Step 2: 服务器预检 + 备份"
|
||||
ssh $Server "mkdir -p $RemoteDir && test -f $RemoteDir/df-im-gateway && cp $RemoteDir/df-im-gateway $RemoteDir/df-im-gateway.bak.`$(date +%Y%m%d%H%M%S) || echo '首次部署,无备份'"
|
||||
|
||||
Step "Step 3: 上传(.new 中转防 Text file busy)"
|
||||
scp $LocalBin "${Server}:$RemoteDir/df-im-gateway.new"
|
||||
if ($LASTEXITCODE -ne 0) { throw "scp 失败" }
|
||||
|
||||
Step "Step 4: 原子切换 + systemd 重启"
|
||||
ssh $Server "bash -s" @'
|
||||
set -e
|
||||
cd /opt/df-im-gateway
|
||||
systemctl stop df-im-gateway 2>/dev/null || true
|
||||
mv -f df-im-gateway.new df-im-gateway
|
||||
chmod +x df-im-gateway
|
||||
systemctl start df-im-gateway
|
||||
sleep 1
|
||||
systemctl is-active df-im-gateway
|
||||
'@
|
||||
if ($LASTEXITCODE -ne 0) { throw "切换/启动失败,检查 journalctl -u df-im-gateway"; }
|
||||
|
||||
Step "Step 5: 健康验证"
|
||||
ssh $Server "ss -tlnp | grep 9191 && journalctl -u df-im-gateway -n 5 --no-pager | sed 's/\x1b\[[0-9;]*m//g'"
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "❌ 9191 未监听,执行回滚..." -ForegroundColor Red
|
||||
ssh $Server "bash -s" @'
|
||||
set -e
|
||||
cd /opt/df-im-gateway
|
||||
systemctl stop df-im-gateway
|
||||
LATEST=$(ls -t df-im-gateway.bak.* 2>/dev/null | head -1)
|
||||
if [ -n "$LATEST" ]; then cp "$LATEST" df-im-gateway; chmod +x df-im-gateway; systemctl start df-im-gateway; echo "回滚完成: $LATEST"; else echo "无备份可回滚(首次部署失败,保持停止)"; fi
|
||||
'@
|
||||
throw "健康检查失败,已回滚"
|
||||
}
|
||||
|
||||
Write-Host "`n✅ df-im-gateway 发布完成 (9191)" -ForegroundColor Green
|
||||
@@ -0,0 +1,284 @@
|
||||
//! 平台适配器(P0 预留项 ④:trait 分层,统一 InboundEvent/OutboundAction)
|
||||
//!
|
||||
//! 网关核心只认识这两个类型,四端差异(飞书卡片/企微 Markdown/钉钉卡片/Web JSON)收敛在各 adapter。
|
||||
//! P0 只实现 Web,飞书/企微/钉钉留 trait 位(P1/P2/P4 各自落地,不动核心)。
|
||||
|
||||
use async_trait::async_trait;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
/// 平台入站事件(IM 平台 → 网关,已归一化)
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum InboundEvent {
|
||||
/// 用户发消息
|
||||
Message {
|
||||
platform: String,
|
||||
chat_id: String,
|
||||
user_id: String,
|
||||
text: String,
|
||||
},
|
||||
/// 会话切换(/new 或菜单)
|
||||
NewSession { platform: String, chat_id: String },
|
||||
/// 审批按钮回调
|
||||
Approval {
|
||||
platform: String,
|
||||
chat_id: String,
|
||||
approval_id: String,
|
||||
approved: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// 平台出站动作(网关 → IM 平台,已归一化)
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum OutboundAction {
|
||||
/// 发/更新 AI 流式文本(全量内容,适配器自选卡片或文本)
|
||||
StreamText { chat_id: String, text: String, done: bool },
|
||||
/// 发送审批卡片(同意/拒绝按钮)
|
||||
ApprovalCard { chat_id: String, approval_id: String, title: String, detail: String },
|
||||
/// 发系统提示
|
||||
Notice { chat_id: String, text: String },
|
||||
}
|
||||
|
||||
/// 平台适配器
|
||||
#[async_trait]
|
||||
pub trait PlatformAdapter: Send + Sync {
|
||||
/// 平台名(feishu/wecom/dingtalk/web,与 session_map.platform 对应)。
|
||||
/// 核心循环经 platform_index 注册表间接使用。
|
||||
#[allow(dead_code)]
|
||||
fn name(&self) -> &str;
|
||||
/// 启动适配器(长连接/回调服务器/WS),入站事件从 events 通道流出。阻塞运行。
|
||||
async fn run(&self, events: mpsc::Sender<InboundEvent>) -> anyhow::Result<()>;
|
||||
|
||||
/// 出站动作分发(网关核心调用)
|
||||
async fn dispatch(&self, action: OutboundAction) -> anyhow::Result<()>;
|
||||
|
||||
/// 清理平台的流式消息状态(网关在新建会话映射时调用,兜底上轮异常中止的残留)。
|
||||
/// 默认空实现:无流式覆写语义的平台(Web 直接推帧)无需关心。
|
||||
async fn reset_stream(&self, _chat_id: &str) {}
|
||||
}
|
||||
|
||||
/// Web Chat 适配器(P1:axum 同端口托管静态页 + WS,浏览器直连)
|
||||
///
|
||||
/// - `GET /` → 内嵌单文件聊天页(include_str!,零运行时文件依赖)
|
||||
/// - `GET /ws` → WS 升级,协议见 handle_web_ws(与 P0 纯 WS 版完全一致)
|
||||
pub mod web {
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
|
||||
use axum::extract::State;
|
||||
use axum::response::{Html, Response};
|
||||
use axum::routing::get;
|
||||
use axum::Router;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
|
||||
use super::{InboundEvent, OutboundAction, PlatformAdapter};
|
||||
|
||||
/// 内嵌聊天页(单文件,无外部依赖,构建期打进二进制)
|
||||
const INDEX_HTML: &str = include_str!("../web/index.html");
|
||||
|
||||
/// 浏览器连接注册表(chat_id → ws 发送通道)
|
||||
type WsRegistry = Arc<Mutex<HashMap<String, mpsc::Sender<WsMessage>>>>;
|
||||
|
||||
/// axum 路由共享状态(注册表 + 核心入站通道)
|
||||
#[derive(Clone)]
|
||||
struct WsState {
|
||||
registry: WsRegistry,
|
||||
events: mpsc::Sender<InboundEvent>,
|
||||
}
|
||||
|
||||
/// Web 适配器:HTTP+WS 服务监听 0.0.0.0:9191
|
||||
#[derive(Clone)]
|
||||
pub struct WebAdapter {
|
||||
registry: WsRegistry,
|
||||
}
|
||||
|
||||
impl WebAdapter {
|
||||
pub fn new() -> Self {
|
||||
Self { registry: Arc::new(Mutex::new(HashMap::new())) }
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for WebAdapter {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PlatformAdapter for WebAdapter {
|
||||
fn name(&self) -> &str {
|
||||
"web"
|
||||
}
|
||||
|
||||
async fn run(&self, events: mpsc::Sender<InboundEvent>) -> anyhow::Result<()> {
|
||||
let state = WsState { registry: self.registry.clone(), events };
|
||||
let app = Router::new()
|
||||
.route("/", get(|| async { Html(INDEX_HTML) }))
|
||||
.route("/ws", get(ws_upgrade))
|
||||
.with_state(state);
|
||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:9191").await?;
|
||||
tracing::info!("[web] HTTP+WS 服务监听 http://0.0.0.0:9191 (页面 / , WS /ws)");
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn dispatch(&self, action: OutboundAction) -> anyhow::Result<()> {
|
||||
match action {
|
||||
OutboundAction::StreamText { chat_id, text, done } => {
|
||||
let frame = serde_json::json!({
|
||||
"type": if done { "stream_done" } else { "stream_delta" },
|
||||
"text": text,
|
||||
});
|
||||
self.send_ws(&chat_id, &frame.to_string()).await
|
||||
}
|
||||
OutboundAction::ApprovalCard { chat_id, approval_id, title, detail } => {
|
||||
let frame = serde_json::json!({
|
||||
"type": "approval",
|
||||
"approval_id": approval_id,
|
||||
"title": title,
|
||||
"detail": detail,
|
||||
});
|
||||
self.send_ws(&chat_id, &frame.to_string()).await
|
||||
}
|
||||
OutboundAction::Notice { chat_id, text } => {
|
||||
let frame = serde_json::json!({ "type": "notice", "text": text });
|
||||
self.send_ws(&chat_id, &frame.to_string()).await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WebAdapter {
|
||||
async fn send_ws(&self, chat_id: &str, text: &str) -> anyhow::Result<()> {
|
||||
let registry = self.registry.lock().await;
|
||||
if let Some(tx) = registry.get(chat_id) {
|
||||
tx.send(WsMessage::Text(text.to_string())).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// WS 升级入口
|
||||
async fn ws_upgrade(State(state): State<WsState>, ws: WebSocketUpgrade) -> Response {
|
||||
ws.on_upgrade(move |socket| async move {
|
||||
if let Err(e) = handle_web_ws(socket, state).await {
|
||||
tracing::warn!("[web] WS 会话异常结束: {e:#}");
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// 单个浏览器 WS 会话。
|
||||
///
|
||||
/// 协议(浏览器 → 网关):
|
||||
/// ```json
|
||||
/// 首帧绑定: {"type":"hello","chat_id":"...","user_id":"..."}
|
||||
/// 消息: {"type":"message","text":"..."}
|
||||
/// 重置会话: {"type":"new_session"}
|
||||
/// 审批: {"type":"approval","approval_id":"...","approved":true|false}
|
||||
/// ```
|
||||
/// 网关 → 浏览器:stream_delta / stream_done / approval / notice(见 dispatch)。
|
||||
async fn handle_web_ws(socket: WebSocket, state: WsState) -> anyhow::Result<()> {
|
||||
let (mut sink, mut upstream) = socket.split();
|
||||
|
||||
// 首帧 hello 绑定 chat_id
|
||||
let first = match upstream.next().await {
|
||||
Some(Ok(m)) => m,
|
||||
_ => {
|
||||
tracing::warn!("[web] 浏览器连接即断(无首帧)");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let hello: serde_json::Value = match &first {
|
||||
WsMessage::Text(t) => serde_json::from_str(t).unwrap_or_default(),
|
||||
_ => {
|
||||
tracing::warn!("[web] 首帧非文本,拒绝绑定");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let chat_id = hello
|
||||
.get("chat_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("web-anon")
|
||||
.to_string();
|
||||
let user_id = hello.get("user_id").and_then(|v| v.as_str()).unwrap_or("anon").to_string();
|
||||
|
||||
// 注册发送通道:出站帧经 ws_tx → forward 任务写 socket
|
||||
let (ws_tx, mut ws_rx) = mpsc::channel::<WsMessage>(64);
|
||||
state.registry.lock().await.insert(chat_id.clone(), ws_tx);
|
||||
|
||||
tracing::info!("[web] 浏览器接入 chat_id={chat_id} user={user_id}");
|
||||
|
||||
let forward = tokio::spawn(async move {
|
||||
while let Some(frame) = ws_rx.recv().await {
|
||||
if sink.send(frame).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let result: anyhow::Result<()> = async {
|
||||
while let Some(msg) = upstream.next().await {
|
||||
let msg = msg?;
|
||||
match msg {
|
||||
WsMessage::Text(t) => {
|
||||
let v: serde_json::Value = serde_json::from_str(&t).unwrap_or_default();
|
||||
match v.get("type").and_then(|x| x.as_str()).unwrap_or("") {
|
||||
"message" => {
|
||||
let text =
|
||||
v.get("text").and_then(|x| x.as_str()).unwrap_or("").to_string();
|
||||
state
|
||||
.events
|
||||
.send(InboundEvent::Message {
|
||||
platform: "web".into(),
|
||||
chat_id: chat_id.clone(),
|
||||
user_id: user_id.clone(),
|
||||
text,
|
||||
})
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("核心入站通道关闭"))?;
|
||||
}
|
||||
"new_session" => {
|
||||
state
|
||||
.events
|
||||
.send(InboundEvent::NewSession {
|
||||
platform: "web".into(),
|
||||
chat_id: chat_id.clone(),
|
||||
})
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("核心入站通道关闭"))?;
|
||||
}
|
||||
"approval" => {
|
||||
let approval_id =
|
||||
v.get("approval_id").and_then(|x| x.as_str()).unwrap_or("").to_string();
|
||||
let approved =
|
||||
v.get("approved").and_then(|x| x.as_bool()).unwrap_or(false);
|
||||
state
|
||||
.events
|
||||
.send(InboundEvent::Approval {
|
||||
platform: "web".into(),
|
||||
chat_id: chat_id.clone(),
|
||||
approval_id,
|
||||
approved,
|
||||
})
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("核心入站通道关闭"))?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
WsMessage::Close(_) => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
state.registry.lock().await.remove(&chat_id);
|
||||
forward.abort();
|
||||
tracing::info!("[web] 浏览器断开 chat_id={chat_id}");
|
||||
result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
//! 审计日志(P5):IM 消息 ↔ TunnelCommand 全量记录(SQLite + WAL,与 SessionStore 同机库文件)
|
||||
//!
|
||||
//! 设计口径:
|
||||
//! - **append-only**:只写不改,无 UPDATE/DELETE——审计的公信力来自不可篡改。
|
||||
//! - **双向打点**:inbound(IM 用户 → 网关)与 outbound(网关/桌面端 → IM)各记一行,
|
||||
//! 同一 request_id 关联请求与响应(排障时按 id 串链)。
|
||||
//! - **失败不阻断**:审计写入失败仅 tracing::warn,绝不影响主链路(审计是增益非闸门)。
|
||||
//! - 表:tunnel_audit(id 自增 / ts 毫秒 / direction / platform / chat_id / user_id /
|
||||
//! conv_id / action / request_id / payload 截断 4KB / outcome ok|error|denied)。
|
||||
//! payload 截断防长消息撑爆库;敏感凭证不经过此层(config 层已隔离)。
|
||||
//!
|
||||
//! 查询接口(运维用):recent 按时间倒序限量;by_request 串同请求双向链。
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
const MAX_PAYLOAD: usize = 4096;
|
||||
|
||||
/// 审计方向
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Direction {
|
||||
/// IM 用户 → 网关(入站消息/审批点击/新会话)
|
||||
Inbound,
|
||||
/// 网关/桌面端 → IM(流式输出/审批卡片/通知)
|
||||
Outbound,
|
||||
/// 网关 → 桌面端(TunnelCommand 下发)
|
||||
Command,
|
||||
}
|
||||
|
||||
impl Direction {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Direction::Inbound => "inbound",
|
||||
Direction::Outbound => "outbound",
|
||||
Direction::Command => "command",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AuditLog {
|
||||
conn: Arc<std::sync::Mutex<Connection>>,
|
||||
}
|
||||
|
||||
/// 打点参数(字段可选,None 写空串,保持列 NOT NULL 简化查询)
|
||||
pub struct AuditEntry {
|
||||
pub direction: Direction,
|
||||
pub platform: String,
|
||||
pub chat_id: String,
|
||||
pub user_id: String,
|
||||
pub conv_id: String,
|
||||
/// 动作名:message/new_session/approval_click/approval_card/stream/completed/notice/send_cmd/approve_cmd
|
||||
pub action: String,
|
||||
/// 关联 ID:入站↔出站串链用(消息场景可用 chat+ts 派生,审批场景用 approval_id)
|
||||
pub request_id: String,
|
||||
pub payload: String,
|
||||
/// ok / error / denied(白名单拒/无 channel)
|
||||
pub outcome: String,
|
||||
}
|
||||
|
||||
impl AuditLog {
|
||||
/// 打开(或创建)审计库。默认与 sessions.db 同目录独立文件(隔离写放大)。
|
||||
pub fn open(path: &Path) -> anyhow::Result<Self> {
|
||||
let conn = Connection::open(path)?;
|
||||
conn.pragma_update(None, "journal_mode", "WAL")?;
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS tunnel_audit (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts INTEGER NOT NULL,
|
||||
direction TEXT NOT NULL,
|
||||
platform TEXT NOT NULL DEFAULT '',
|
||||
chat_id TEXT NOT NULL DEFAULT '',
|
||||
user_id TEXT NOT NULL DEFAULT '',
|
||||
conv_id TEXT NOT NULL DEFAULT '',
|
||||
action TEXT NOT NULL,
|
||||
request_id TEXT NOT NULL DEFAULT '',
|
||||
payload TEXT NOT NULL DEFAULT '',
|
||||
outcome TEXT NOT NULL DEFAULT 'ok'
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_ts ON tunnel_audit(ts);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_request ON tunnel_audit(request_id);",
|
||||
)?;
|
||||
Ok(Self { conn: Arc::new(std::sync::Mutex::new(conn)) })
|
||||
}
|
||||
|
||||
/// 内存库(测试)
|
||||
#[allow(dead_code)]
|
||||
pub fn open_in_memory() -> anyhow::Result<Self> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS tunnel_audit (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, ts INTEGER NOT NULL,
|
||||
direction TEXT NOT NULL, platform TEXT NOT NULL DEFAULT '',
|
||||
chat_id TEXT NOT NULL DEFAULT '', user_id TEXT NOT NULL DEFAULT '',
|
||||
conv_id TEXT NOT NULL DEFAULT '', action TEXT NOT NULL,
|
||||
request_id TEXT NOT NULL DEFAULT '', payload TEXT NOT NULL DEFAULT '',
|
||||
outcome TEXT NOT NULL DEFAULT 'ok');",
|
||||
)?;
|
||||
Ok(Self { conn: Arc::new(std::sync::Mutex::new(conn)) })
|
||||
}
|
||||
|
||||
fn now_ms() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// 追加一条审计(失败仅告警不冒泡)
|
||||
pub fn record(&self, e: AuditEntry) {
|
||||
let payload = if e.payload.len() > MAX_PAYLOAD {
|
||||
format!("{}…[截断]", &e.payload[..MAX_PAYLOAD])
|
||||
} else {
|
||||
e.payload
|
||||
};
|
||||
let result = (|| -> anyhow::Result<()> {
|
||||
let conn = self.conn.lock().map_err(|err| anyhow::anyhow!("audit 锁中毒: {err}"))?;
|
||||
conn.execute(
|
||||
"INSERT INTO tunnel_audit (ts, direction, platform, chat_id, user_id, conv_id, action, request_id, payload, outcome) \
|
||||
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)",
|
||||
rusqlite::params![
|
||||
Self::now_ms(), e.direction.as_str(), e.platform, e.chat_id,
|
||||
e.user_id, e.conv_id, e.action, e.request_id, payload, e.outcome,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(err) = result {
|
||||
tracing::warn!("[audit] 写入失败(不影响主链路): {err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// 最近 N 条(时间倒序,运维排障)
|
||||
#[allow(dead_code)]
|
||||
pub fn recent(&self, limit: usize) -> anyhow::Result<Vec<String>> {
|
||||
let conn = self.conn.lock().map_err(|e| anyhow::anyhow!("audit 锁中毒: {e}"))?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT ts, direction, platform, chat_id, user_id, conv_id, action, request_id, outcome \
|
||||
FROM tunnel_audit ORDER BY id DESC LIMIT ?1",
|
||||
)?;
|
||||
let rows = stmt.query_map([limit as i64], |row| {
|
||||
Ok(format!(
|
||||
"{} [{}] {} plat={} chat={} user={} conv={} req={} outcome={}",
|
||||
row.get::<_, i64>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(6)?,
|
||||
row.get::<_, String>(2)?, row.get::<_, String>(3)?, row.get::<_, String>(4)?,
|
||||
row.get::<_, String>(5)?, row.get::<_, String>(7)?, row.get::<_, String>(8)?,
|
||||
))
|
||||
})?;
|
||||
Ok(rows.filter_map(|r| r.ok()).collect())
|
||||
}
|
||||
|
||||
/// 按 request_id 串双向链(入站→命令→出站全链)
|
||||
#[allow(dead_code)]
|
||||
pub fn by_request(&self, request_id: &str) -> anyhow::Result<Vec<String>> {
|
||||
let conn = self.conn.lock().map_err(|e| anyhow::anyhow!("audit 锁中毒: {e}"))?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT ts, direction, action, outcome FROM tunnel_audit WHERE request_id = ?1 ORDER BY id ASC",
|
||||
)?;
|
||||
let rows = stmt.query_map([request_id], |row| {
|
||||
Ok(format!(
|
||||
"{} [{}] {} outcome={}",
|
||||
row.get::<_, i64>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, String>(3)?,
|
||||
))
|
||||
})?;
|
||||
Ok(rows.filter_map(|r| r.ok()).collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn entry(direction: Direction, action: &str, req: &str, outcome: &str) -> AuditEntry {
|
||||
AuditEntry {
|
||||
direction,
|
||||
platform: "feishu".into(),
|
||||
chat_id: "oc_123".into(),
|
||||
user_id: "u_1".into(),
|
||||
conv_id: "feishu:oc_123".into(),
|
||||
action: action.into(),
|
||||
request_id: req.into(),
|
||||
payload: "hello".into(),
|
||||
outcome: outcome.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_and_recent_roundtrip() {
|
||||
let log = AuditLog::open_in_memory().unwrap();
|
||||
log.record(entry(Direction::Inbound, "message", "req-1", "ok"));
|
||||
log.record(entry(Direction::Command, "send_cmd", "req-1", "ok"));
|
||||
log.record(entry(Direction::Outbound, "stream", "req-1", "ok"));
|
||||
log.record(entry(Direction::Inbound, "message", "req-2", "denied"));
|
||||
let rows = log.recent(10).unwrap();
|
||||
assert_eq!(rows.len(), 4);
|
||||
// 倒序:最新在前
|
||||
assert!(rows[0].contains("req-2"));
|
||||
assert!(rows[0].contains("denied"));
|
||||
let chain = log.by_request("req-1").unwrap();
|
||||
assert_eq!(chain.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payload_truncated() {
|
||||
let log = AuditLog::open_in_memory().unwrap();
|
||||
let mut e = entry(Direction::Inbound, "message", "req-x", "ok");
|
||||
e.payload = "x".repeat(10_000);
|
||||
log.record(e);
|
||||
let conn_count = |log: &AuditLog| -> i64 {
|
||||
let conn = log.conn.lock().unwrap();
|
||||
conn.query_row("SELECT COUNT(*) FROM tunnel_audit", [], |r| r.get(0)).unwrap()
|
||||
};
|
||||
assert_eq!(conn_count(&log), 1);
|
||||
let conn = log.conn.lock().unwrap();
|
||||
let stored: String = conn
|
||||
.query_row("SELECT payload FROM tunnel_audit", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert!(stored.len() < 5_000);
|
||||
assert!(stored.ends_with("…[截断]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_never_panics_on_poisoned_lock() {
|
||||
let log = AuditLog::open_in_memory().unwrap();
|
||||
// 毒锁:持锁 panic 使 Mutex poisoned,record 应 warn 而非 panic
|
||||
let guard = log.conn.lock().unwrap();
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
std::panic::panic_any("poison");
|
||||
}))
|
||||
.err();
|
||||
drop(guard);
|
||||
log.record(entry(Direction::Inbound, "message", "req-p", "ok")); // 不 panic 即通过
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
//! WS Commander 客户端(连 u-relay /ws/commander)
|
||||
//!
|
||||
//! 与桌面端 df-tunnel(Agent 角色)对称:网关作为 Commander 发 TunnelCommand、收 TunnelEvent。
|
||||
//! 协议类型直接复用 df_tunnel::events(编译期一致)。
|
||||
//!
|
||||
//! 连接生命周期:hello {kind:"commander", channel_id, token} → (relay 校验) → 双向消息。
|
||||
//! 断线重连:指数退避(1/2/4/8/16/30s 封顶,与桌面端 df-tunnel 同款策略)。
|
||||
//!
|
||||
//! 注:relay 对同 channel 多 Commander 的投递行为(P0 必验项 ①)待实测——
|
||||
//! 过渡期约定同 channel 单活跃控制端。
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use df_tunnel::events::{Hello, HelloKind, RelayControlError};
|
||||
use df_tunnel::{TunnelCommand, TunnelEvent, TunnelMessage};
|
||||
|
||||
/// 指数退避序列(秒,封顶 30)
|
||||
fn backoff_secs(attempt: u32) -> u64 {
|
||||
[1, 2, 4, 8, 16, 30]
|
||||
.get(attempt.min(5) as usize)
|
||||
.copied()
|
||||
.unwrap_or(30)
|
||||
}
|
||||
|
||||
/// 启动 Commander 连接循环(阻塞直到 shutdown)。重连在内部循环,指令/事件双向透传。
|
||||
///
|
||||
/// * `relay_url` — ws://host:9180/ws/commander
|
||||
/// * `channel_id` — 配对的桌面端 channel
|
||||
/// * `token` — U_RELAY_TOKEN 同源
|
||||
/// * `ev_tx` — 事件出口(桌面端 → 网关核心)
|
||||
/// * `cmd_rx` — 指令入口(网关核心 → 桌面端)
|
||||
/// * `shutdown` — 关闭信号
|
||||
pub async fn run_commander(
|
||||
relay_url: String,
|
||||
channel_id: String,
|
||||
token: String,
|
||||
ev_tx: mpsc::Sender<TunnelEvent>,
|
||||
mut cmd_rx: mpsc::Receiver<TunnelCommand>,
|
||||
shutdown: tokio::sync::watch::Receiver<bool>,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut attempt: u32 = 0;
|
||||
loop {
|
||||
if *shutdown.borrow() {
|
||||
info!("[commander] 收到关闭信号,退出");
|
||||
return Ok(());
|
||||
}
|
||||
match connect_once(&relay_url, &channel_id, &token, &ev_tx, &mut cmd_rx, shutdown.clone()).await {
|
||||
Ok(()) => {
|
||||
info!("[commander] 连接正常关闭");
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => {
|
||||
let wait = backoff_secs(attempt);
|
||||
attempt += 1;
|
||||
warn!("[commander] 连接断开({e}),{}s 后重连(第 {attempt} 次)", wait);
|
||||
let mut shutdown_w = shutdown.clone();
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(Duration::from_secs(wait)) => {}
|
||||
_ = shutdown_w.changed() => return Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 单次连接会话(hello 握手 + 消息循环),断开/出错返回 Err 触发外层重连
|
||||
async fn connect_once(
|
||||
relay_url: &str,
|
||||
channel_id: &str,
|
||||
token: &str,
|
||||
ev_tx: &mpsc::Sender<TunnelEvent>,
|
||||
cmd_rx: &mut mpsc::Receiver<TunnelCommand>,
|
||||
mut shutdown: tokio::sync::watch::Receiver<bool>,
|
||||
) -> anyhow::Result<()> {
|
||||
let (ws, _resp) = tokio_tungstenite::connect_async(relay_url).await?;
|
||||
let (mut sink, mut stream) = ws.split();
|
||||
|
||||
// hello 握手(kind=commander;字段与 u-relay::relay::Hello 严格一致)
|
||||
let hello = Hello {
|
||||
kind: HelloKind::Commander,
|
||||
channel_id: channel_id.to_string(),
|
||||
token: token.to_string(),
|
||||
proto: 1,
|
||||
};
|
||||
sink.send(Message::Text(serde_json::to_string(&hello)?)).await?;
|
||||
|
||||
// 等待 hello_ack(首条文本帧;control error 帧则报错)
|
||||
let ack = tokio::time::timeout(Duration::from_secs(10), stream.next()).await
|
||||
.map_err(|_| anyhow::anyhow!("hello_ack 10s 超时"))?
|
||||
.ok_or_else(|| anyhow::anyhow!("连接在握手期关闭"))?;
|
||||
match ack? {
|
||||
Message::Text(t) => {
|
||||
// relay 控制帧:{"kind":"control","error":"..."} → 握手被拒
|
||||
if let Ok(ctrl_err) = serde_json::from_str::<RelayControlError>(&t) {
|
||||
if ctrl_err.kind == "control" {
|
||||
anyhow::bail!("握手被拒: {}", ctrl_err.error);
|
||||
}
|
||||
}
|
||||
info!("[commander] hello_ack: channel={channel_id} 握手成功");
|
||||
}
|
||||
other => anyhow::bail!("hello_ack 非文本帧: {other:?}"),
|
||||
}
|
||||
|
||||
// 心跳:30s ping(relay 90s 读死线)
|
||||
let mut ping = tokio::time::interval(Duration::from_secs(30));
|
||||
ping.tick().await; // 首个立即返回,跳过
|
||||
|
||||
// 消息循环
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = shutdown.changed() => {
|
||||
let _ = sink.close().await;
|
||||
return Ok(());
|
||||
}
|
||||
_ = ping.tick() => {
|
||||
sink.send(Message::Ping(vec![])).await?;
|
||||
}
|
||||
cmd = cmd_rx.recv() => {
|
||||
let Some(cmd) = cmd else {
|
||||
// 核心侧通道关闭:正常收尾
|
||||
let _ = sink.close().await;
|
||||
return Ok(());
|
||||
};
|
||||
let frame = TunnelMessage::Command(cmd);
|
||||
sink.send(Message::Text(serde_json::to_string(&frame)?)).await?;
|
||||
}
|
||||
msg = stream.next() => {
|
||||
match msg {
|
||||
Some(Ok(Message::Text(t))) => {
|
||||
match serde_json::from_str::<TunnelMessage>(&t) {
|
||||
Ok(TunnelMessage::Event(e)) => {
|
||||
if ev_tx.send(e).await.is_err() {
|
||||
return Err(anyhow::anyhow!("核心事件通道关闭"));
|
||||
}
|
||||
}
|
||||
Ok(TunnelMessage::Command(_)) => {
|
||||
warn!("[commander] commander 收到 Command 帧(协议错向,忽略)");
|
||||
}
|
||||
Err(_) => warn!("[commander] 无法解析帧(非 TunnelMessage,忽略): {} 字节", t.len()),
|
||||
}
|
||||
}
|
||||
Some(Ok(Message::Ping(p))) => { let _ = sink.send(Message::Pong(p)).await; }
|
||||
Some(Ok(_)) => {}
|
||||
Some(Err(e)) => return Err(e.into()),
|
||||
None => return Err(anyhow::anyhow!("stream 结束")),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn backoff_sequence() {
|
||||
assert_eq!(backoff_secs(0), 1);
|
||||
assert_eq!(backoff_secs(1), 2);
|
||||
assert_eq!(backoff_secs(2), 4);
|
||||
assert_eq!(backoff_secs(3), 8);
|
||||
assert_eq!(backoff_secs(4), 16);
|
||||
assert_eq!(backoff_secs(5), 30);
|
||||
assert_eq!(backoff_secs(99), 30, "封顶 30s");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tunnel_message_roundtrip() {
|
||||
let cmd = TunnelMessage::Command(TunnelCommand::Send {
|
||||
conv_id: "c1".into(),
|
||||
content: "你好".into(),
|
||||
});
|
||||
let json = serde_json::to_string(&cmd).unwrap();
|
||||
assert!(json.contains("\"command\""));
|
||||
let back: TunnelMessage = serde_json::from_str(&json).unwrap();
|
||||
match back {
|
||||
TunnelMessage::Command(TunnelCommand::Send { conv_id, content }) => {
|
||||
assert_eq!(conv_id, "c1");
|
||||
assert_eq!(content, "你好");
|
||||
}
|
||||
_ => panic!("roundtrip 失败"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
//! 网关配置(env 加载,凭证不进 git)
|
||||
//!
|
||||
//! 配置来源:环境变量 > 默认值(gateway.toml 文件加载为后续增强,当前未实现)。
|
||||
//! u-relay token 与桌面端 df-tunnel 同源(env U_RELAY_TOKEN)。
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
/// 网关全局配置
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct GatewayConfig {
|
||||
/// u-relay WS 地址(生产 u-relay.1216.top,本地 http://127.0.0.1:9180)
|
||||
pub relay_url: String,
|
||||
/// relay 配对 token(env U_RELAY_TOKEN 同源)
|
||||
pub relay_token: String,
|
||||
/// channel 路由表:IM 侧默认走 default_channel;按前缀路由的多桌面端配置(预留项 ⑥)
|
||||
#[serde(default)]
|
||||
pub channels: Vec<ChannelRoute>,
|
||||
/// 飞书应用凭证(未配置则该平台禁用)
|
||||
#[serde(default)]
|
||||
pub feishu: Option<FeishuConfig>,
|
||||
/// TextDelta 节流窗口毫秒(默认 800,IM 频控友好)
|
||||
#[serde(default = "default_throttle_ms")]
|
||||
pub throttle_ms: u64,
|
||||
/// 用户白名单(空=放行所有,生产部署务必显式配置)
|
||||
///
|
||||
/// 注:历史注释「空=拒绝所有」与 main.rs 实现(空=放行)矛盾,走查 2026-10-04 定为
|
||||
/// 空=放行——否则首次启动零配置时飞书/Web 全部拒绝,连 banner 警告都没有,反而
|
||||
/// 更难发现配置缺失。生产风险由 main.rs 启动 banner 常驻警告 + 白名单缺失提示兜底。
|
||||
#[serde(default)]
|
||||
pub whitelist: Vec<String>,
|
||||
}
|
||||
|
||||
/// channel 路由项:IM 指令前缀 → 桌面端 channel
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ChannelRoute {
|
||||
/// 指令前缀(如 "/dev2 xxx" 路由到 chan-home);空串=默认 channel
|
||||
pub prefix: String,
|
||||
/// u-relay channel_id(桌面端 df-tunnel 配置的同一 channel)
|
||||
pub channel_id: String,
|
||||
}
|
||||
|
||||
/// 飞书企业自建应用凭证
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct FeishuConfig {
|
||||
pub app_id: String,
|
||||
pub app_secret: String,
|
||||
/// 长连接模式事件订阅(免公网回调;当前恒 true,保留显式开关供回调模式扩展)
|
||||
#[allow(dead_code)]
|
||||
pub long_connection: bool,
|
||||
}
|
||||
|
||||
fn default_throttle_ms() -> u64 {
|
||||
800
|
||||
}
|
||||
|
||||
impl GatewayConfig {
|
||||
/// 从 env + 可选 toml 文件构建。缺失关键项(relay_url/token)返回 Err。
|
||||
pub fn load() -> anyhow::Result<Self> {
|
||||
let relay_url = std::env::var("GATEWAY_RELAY_URL")
|
||||
.unwrap_or_else(|_| "ws://127.0.0.1:9180/ws/commander".to_string());
|
||||
let relay_token = std::env::var("GATEWAY_RELAY_TOKEN")
|
||||
.or_else(|_| std::env::var("U_RELAY_TOKEN"))
|
||||
.map_err(|_| anyhow::anyhow!("缺少 GATEWAY_RELAY_TOKEN / U_RELAY_TOKEN 环境变量"))?;
|
||||
|
||||
let feishu = match (
|
||||
std::env::var("FEISHU_APP_ID"),
|
||||
std::env::var("FEISHU_APP_SECRET"),
|
||||
) {
|
||||
(Ok(app_id), Ok(app_secret)) => Some(FeishuConfig {
|
||||
app_id,
|
||||
app_secret,
|
||||
long_connection: true,
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let whitelist = std::env::var("GATEWAY_WHITELIST")
|
||||
.map(|v| v.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
let default_channel = std::env::var("GATEWAY_DEFAULT_CHANNEL").unwrap_or_default();
|
||||
let channels = if default_channel.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![ChannelRoute { prefix: String::new(), channel_id: default_channel }]
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
relay_url,
|
||||
relay_token,
|
||||
channels,
|
||||
feishu,
|
||||
throttle_ms: default_throttle_ms(),
|
||||
whitelist,
|
||||
})
|
||||
}
|
||||
|
||||
/// 查指令应路由到的 channel(前缀匹配,空前缀=兜底默认)
|
||||
pub fn route(&self, command_text: &str) -> Option<&str> {
|
||||
// 精确前缀优先,空前缀兜底
|
||||
self.channels
|
||||
.iter()
|
||||
.find(|c| !c.prefix.is_empty() && command_text.starts_with(&c.prefix))
|
||||
.map(|c| c.channel_id.as_str())
|
||||
.or_else(|| self.channels.iter().find(|c| c.prefix.is_empty()).map(|c| c.channel_id.as_str()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn route_prefix_priority() {
|
||||
let cfg = GatewayConfig {
|
||||
relay_url: String::new(),
|
||||
relay_token: String::new(),
|
||||
channels: vec![
|
||||
ChannelRoute { prefix: "/dev2".into(), channel_id: "chan-home".into() },
|
||||
ChannelRoute { prefix: String::new(), channel_id: "chan-work".into() },
|
||||
],
|
||||
feishu: None,
|
||||
throttle_ms: 800,
|
||||
whitelist: vec![],
|
||||
};
|
||||
assert_eq!(cfg.route("/dev2 修个 bug"), Some("chan-home"));
|
||||
assert_eq!(cfg.route("普通指令"), Some("chan-work"));
|
||||
assert_eq!(cfg.route("/dev2"), Some("chan-home"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_empty_when_no_channels() {
|
||||
let cfg = GatewayConfig {
|
||||
relay_url: String::new(),
|
||||
relay_token: String::new(),
|
||||
channels: vec![],
|
||||
feishu: None,
|
||||
throttle_ms: 800,
|
||||
whitelist: vec![],
|
||||
};
|
||||
assert_eq!(cfg.route("任何"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
//! 飞书适配器(P2):长连接事件 + 归一化消息 + 卡片审批
|
||||
//!
|
||||
//! 依赖 larksuite-oapi-sdk-rs features=["channel"](自动连带 ws+messaging)。
|
||||
//! 长连接:飞书←→网关出站直连,不经 u-relay(u-relay 仅穿 NAT 段到桌面端)。
|
||||
//!
|
||||
//! 验证记录(2026-10-04,SDK 0.3.12 源码核对):
|
||||
//! - 卡片回调:ChannelBuilder::on_card_action(NormalizedCardAction)
|
||||
//! 内部走 dispatcher.on_card_action_trigger(channel/handler.rs:112),
|
||||
//! 即新版 card.action.trigger 事件通道,长连接可收(遗留验证项 ✅)。
|
||||
//! - 发卡片:SendInput.card(String) → INTERACTIVE 类型(messaging.rs:752)。
|
||||
//! - 流式更新:channel_messaging().send() 拿 message_id 后 edit_text() 覆写全文。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use larksuite_oapi_sdk_rs::card::v1::{
|
||||
Action, ActionComponent, Button, ButtonType, Card, CardDocument, Div, Element, Header,
|
||||
Markdown,
|
||||
};
|
||||
use larksuite_oapi_sdk_rs::channel::{Channel, ChannelPolicy};
|
||||
use larksuite_oapi_sdk_rs::{EventDispatcher, LarkClient, LarkError, RequestOption};
|
||||
use tokio::sync::mpsc::Sender;
|
||||
|
||||
use crate::adapters::{InboundEvent, OutboundAction, PlatformAdapter};
|
||||
use crate::config::FeishuConfig;
|
||||
|
||||
/// 飞书适配器:channel 归一化消息 + 卡片审批,单飞书应用单实例
|
||||
#[derive(Clone)]
|
||||
pub struct FeishuAdapter {
|
||||
/// 常驻 client(&'static,构造期一次性 Box::leak)。D1(u-claw 教训:热重建不关旧
|
||||
/// 连接→僵尸资源):leak 只在 new()/offline() 各发生一次;run() 重连重入只重建
|
||||
/// Channel 不再重复 leak,防频繁断线场景 LarkClient 内存阶梯上涨。出站 dispatch
|
||||
/// 与 Channel 构建共用同一份引用。
|
||||
client: &'static LarkClient,
|
||||
/// 流式会话注册表:chat_id → 已发出的流式消息 message_id(用于 edit_text 覆写)
|
||||
streams: Arc<tokio::sync::Mutex<HashMap<String, String>>>,
|
||||
}
|
||||
|
||||
impl FeishuAdapter {
|
||||
pub fn new(cfg: &FeishuConfig) -> anyhow::Result<Self> {
|
||||
let client: &'static LarkClient = Box::leak(Box::new(
|
||||
LarkClient::builder(&cfg.app_id, &cfg.app_secret).build()?,
|
||||
));
|
||||
Ok(Self {
|
||||
client,
|
||||
streams: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
|
||||
})
|
||||
}
|
||||
|
||||
/// 构造失败时的离线哨兵:client 用占位凭证(不启动 run,dispatch 记日志即弃)
|
||||
pub fn offline() -> Self {
|
||||
let client: &'static LarkClient = Box::leak(Box::new(
|
||||
LarkClient::builder("offline", "offline")
|
||||
.build()
|
||||
.expect("占位凭证构造 LarkClient 不发网络请求,必成功"),
|
||||
));
|
||||
Self {
|
||||
client,
|
||||
streams: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PlatformAdapter for FeishuAdapter {
|
||||
fn name(&self) -> &str {
|
||||
"feishu"
|
||||
}
|
||||
|
||||
/// 兜底清理流式残留(走查 #3):桌面端异常中止时无 Completed 事件,
|
||||
/// streams entry 永驻导致下轮首帧错覆写旧消息。核心循环在新建会话映射时调用。
|
||||
async fn reset_stream(&self, chat_id: &str) {
|
||||
self.streams.lock().await.remove(chat_id);
|
||||
}
|
||||
|
||||
async fn run(&self, events: Sender<InboundEvent>) -> anyhow::Result<()> {
|
||||
// 长连接模式:空 verification_token/encrypt_key(免公网回调)
|
||||
let dispatcher = EventDispatcher::new("", "");
|
||||
let client = self.client;
|
||||
|
||||
// 入站事件通道复制两份,分别给消息/卡片回调闭包
|
||||
let events_for_msg = events.clone();
|
||||
let events_for_card = events;
|
||||
|
||||
let channel: Channel<'static> = {
|
||||
// client 已是 &'static(构造期一次性 leak);run() 重连重入只重建 Channel,
|
||||
// 不再重复 Box::leak(D1:防每次重入泄漏一份 LarkClient)
|
||||
Channel::builder(client, dispatcher)
|
||||
.policy(ChannelPolicy::default().require_mention(false))
|
||||
.on_message(move |msg| {
|
||||
let events = events_for_msg.clone();
|
||||
async move {
|
||||
handle_inbound_message(events, msg).await;
|
||||
Ok(())
|
||||
}
|
||||
})
|
||||
.on_card_action(move |action| {
|
||||
let events = events_for_card.clone();
|
||||
async move {
|
||||
handle_card_action(events, action).await;
|
||||
Ok(())
|
||||
}
|
||||
})
|
||||
.build()
|
||||
};
|
||||
|
||||
tracing::info!("[feishu] 长连接启动(免公网回调)");
|
||||
channel.start().await.map_err(|e: LarkError| anyhow::anyhow!("飞书长连接退出: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn dispatch(&self, action: OutboundAction) -> anyhow::Result<()> {
|
||||
let opt = &RequestOption::default();
|
||||
match action {
|
||||
OutboundAction::StreamText { chat_id, text, done } => {
|
||||
// 两段式:锁内只取/写 message_id(纯内存操作),网络 IO 在锁外执行。
|
||||
// 修复走查 #2:同 chat 仍串行(顺序语义),不同 chat 不再被同一把锁挡住。
|
||||
let existing_id: Option<String> = {
|
||||
let streams = self.streams.lock().await;
|
||||
streams.get(&chat_id).cloned()
|
||||
};
|
||||
if let Some(message_id) = existing_id {
|
||||
let res = self.client.channel_messaging().edit_text(&message_id, &text, opt).await;
|
||||
if let Err(e) = res {
|
||||
tracing::warn!("[feishu] 流式更新失败(消息可能已被撤回/超时): {e},回退新发");
|
||||
self.streams.lock().await.remove(&chat_id);
|
||||
}
|
||||
} else {
|
||||
// 首帧:发新文本消息,记录 message_id 供后续覆写
|
||||
let input = larksuite_oapi_sdk_rs::channel::SendInput {
|
||||
receive_id: Some(chat_id.clone()),
|
||||
text: Some(text),
|
||||
..Default::default()
|
||||
};
|
||||
let send_result = self.client.channel_messaging().send(&input, opt).await;
|
||||
match send_result {
|
||||
Ok(sr) if !sr.message_id.is_empty() => {
|
||||
self.streams.lock().await.insert(chat_id.clone(), sr.message_id);
|
||||
}
|
||||
Ok(_) => {
|
||||
tracing::warn!("[feishu] 发送流式首帧未返回 message_id");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("[feishu] 发送流式首帧失败: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
if done {
|
||||
self.streams.lock().await.remove(&chat_id);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
OutboundAction::ApprovalCard { chat_id, approval_id, title, detail } => {
|
||||
let card = build_approval_card(&approval_id, &title, &detail);
|
||||
let document = CardDocument::new(card)
|
||||
.map_err(|e| anyhow::anyhow!("审批卡片校验失败: {e}"))?;
|
||||
// encoded_content 是 SDK pub(crate);直接序列化 card(校验已过)
|
||||
let content = serde_json::to_string(document.card())
|
||||
.map_err(|e| anyhow::anyhow!("卡片序列化失败: {e}"))?;
|
||||
let input = larksuite_oapi_sdk_rs::channel::SendInput {
|
||||
receive_id: Some(chat_id),
|
||||
card: Some(content),
|
||||
..Default::default()
|
||||
};
|
||||
self.client
|
||||
.channel_messaging()
|
||||
.send(&input, opt)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("审批卡片发送失败: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
OutboundAction::Notice { chat_id, text } => {
|
||||
let input = larksuite_oapi_sdk_rs::channel::SendInput {
|
||||
receive_id: Some(chat_id),
|
||||
text: Some(text),
|
||||
..Default::default()
|
||||
};
|
||||
self.client
|
||||
.channel_messaging()
|
||||
.send(&input, opt)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("通知发送失败: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 入站消息:群聊需 @机器人(mentioned_bot)或单聊直发
|
||||
async fn handle_inbound_message(events: Sender<InboundEvent>, msg: larksuite_oapi_sdk_rs::channel::NormalizedMessage) {
|
||||
// 只处理文本(text 优先,回退 content)
|
||||
if msg.message_type != "text" {
|
||||
tracing::debug!("[feishu] 忽略非文本消息 type={}", msg.message_type);
|
||||
return;
|
||||
}
|
||||
// 群聊必须 @机器人;单聊(p2p)直发
|
||||
if msg.chat_type == "group" && !msg.mentioned_bot {
|
||||
tracing::debug!("[feishu] 群聊未 @机器人,忽略");
|
||||
return;
|
||||
}
|
||||
let text = msg.text.clone().unwrap_or_default().trim().to_string();
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
// 指令:/new 重置会话
|
||||
if text == "/new" {
|
||||
let _ = events
|
||||
.send(InboundEvent::NewSession {
|
||||
platform: "feishu".into(),
|
||||
chat_id: msg.chat_id.clone(),
|
||||
})
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
let user_id = msg
|
||||
.sender
|
||||
.user_id
|
||||
.as_ref()
|
||||
.and_then(|u| u.open_id.clone())
|
||||
.unwrap_or_else(|| "unknown".into());
|
||||
let _ = events
|
||||
.send(InboundEvent::Message {
|
||||
platform: "feishu".into(),
|
||||
chat_id: msg.chat_id,
|
||||
user_id,
|
||||
text,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// 卡片按钮回调:action_value 携带 approval_id + approved
|
||||
async fn handle_card_action(events: Sender<InboundEvent>, action: larksuite_oapi_sdk_rs::channel::NormalizedCardAction) {
|
||||
// 按钮约定的 value 形态:{"approval_id": "...", "approved": true|false}
|
||||
let approval_id = action
|
||||
.action_value
|
||||
.get("approval_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let approved = action
|
||||
.action_value
|
||||
.get("approved")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
if approval_id.is_empty() {
|
||||
tracing::warn!("[feishu] 卡片回调缺 approval_id,忽略");
|
||||
return;
|
||||
}
|
||||
let chat_id = action.open_chat_id.clone().unwrap_or_default();
|
||||
let _ = events
|
||||
.send(InboundEvent::Approval {
|
||||
platform: "feishu".into(),
|
||||
chat_id,
|
||||
approval_id,
|
||||
approved,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// 构造审批卡片(Card JSON 1.0):标题 + 详情 + 同意/拒绝按钮
|
||||
fn build_approval_card(approval_id: &str, title: &str, detail: &str) -> Card {
|
||||
let detail_md = format!("{}\n\n**审批 ID**: `{approval_id}`", md_to_lark_friendly(detail));
|
||||
let to_json_value = |v: serde_json::Value| -> larksuite_oapi_sdk_rs::JsonValue {
|
||||
serde_json::from_value(serde_json::to_value(v).expect("serde_json::Value 可逆"))
|
||||
.expect("按钮 value 结构兼容")
|
||||
};
|
||||
let actions = Action::new()
|
||||
.action(ActionComponent::Button(
|
||||
Button::new(larksuite_oapi_sdk_rs::card::v1::Text::plain("✅ 同意"))
|
||||
.button_type(ButtonType::Primary)
|
||||
.value(to_json_value(serde_json::json!({"approval_id": approval_id, "approved": true}))),
|
||||
))
|
||||
.action(ActionComponent::Button(
|
||||
Button::new(larksuite_oapi_sdk_rs::card::v1::Text::plain("❌ 拒绝"))
|
||||
.button_type(ButtonType::Danger)
|
||||
.value(to_json_value(serde_json::json!({"approval_id": approval_id, "approved": false}))),
|
||||
));
|
||||
Card::new()
|
||||
.header(Header::new(title))
|
||||
.element(Element::Div(Div::new(
|
||||
larksuite_oapi_sdk_rs::card::v1::Text::lark_md(detail_md),
|
||||
)))
|
||||
.element(Element::Markdown(Markdown::new("---")))
|
||||
.element(Element::Action(actions))
|
||||
}
|
||||
|
||||
/// 出站 Markdown 净化(飞书卡片 lark_md 语法适配)。
|
||||
///
|
||||
/// AI 产出常带标准 Markdown 结构,lark_md 不支持标题与表格语法,原样注入按字面量渲染。
|
||||
/// 规则(与 src-tauri feishu_channel.rs 侧 md_to_lark_friendly 同规则互引,双端出站口径一致):
|
||||
/// 标题行(行首一个以上 #)转粗体行;表格行(| 开头)转列表行,表头分隔行整行跳过;
|
||||
/// 代码块围栏内外均保留原样(转义或改写破坏代码展示);其余行原样透传(lark_md 原生
|
||||
/// 支持粗体/链接/行内代码,不额外转义)。
|
||||
fn md_to_lark_friendly(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut in_code = false;
|
||||
for line in s.lines() {
|
||||
if line.trim_start().starts_with("```") {
|
||||
in_code = !in_code;
|
||||
out.push_str(line);
|
||||
out.push('\n');
|
||||
continue;
|
||||
}
|
||||
if in_code {
|
||||
out.push_str(line);
|
||||
out.push('\n');
|
||||
continue;
|
||||
}
|
||||
let trimmed = line.trim_start();
|
||||
if trimmed.starts_with('#') {
|
||||
let title = trimmed.trim_start_matches('#').trim();
|
||||
if !title.is_empty() {
|
||||
out.push_str(&format!("**{title}**\n"));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if trimmed.starts_with('|') {
|
||||
let cells: Vec<&str> = trimmed.trim_matches('|').split('|').map(str::trim).collect();
|
||||
// 表头分隔行(|---|:---|)无内容语义,整行跳过
|
||||
if cells.iter().all(|c| c.is_empty() || c.chars().all(|ch| ch == '-' || ch == ':')) {
|
||||
continue;
|
||||
}
|
||||
let shown: Vec<&str> = cells.into_iter().filter(|c| !c.is_empty()).collect();
|
||||
out.push_str(&format!("- {}\n", shown.join(" · ")));
|
||||
continue;
|
||||
}
|
||||
out.push_str(line);
|
||||
out.push('\n');
|
||||
}
|
||||
// lines() 逐行补换行,源文本不以换行结尾时尾部多出一个,与源对齐
|
||||
if !s.ends_with('\n') {
|
||||
out.pop();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::FeishuConfig;
|
||||
|
||||
/// 构造冒烟:new/offline 均不发网络请求,验证适配器可实例化
|
||||
#[test]
|
||||
fn feishu_adapter_constructs() {
|
||||
let cfg = FeishuConfig {
|
||||
app_id: "cli_smoke".into(),
|
||||
app_secret: "secret".into(),
|
||||
long_connection: true,
|
||||
};
|
||||
let adapter = FeishuAdapter::new(&cfg).expect("适配器构造应成功");
|
||||
assert_eq!(adapter.name(), "feishu");
|
||||
|
||||
let offline = FeishuAdapter::offline();
|
||||
assert_eq!(offline.name(), "feishu");
|
||||
}
|
||||
|
||||
/// 审批卡片构造 + 协议校验(CardDocument::new 是发送前的强校验门槛)
|
||||
#[test]
|
||||
fn approval_card_passes_protocol_validation() {
|
||||
let card = build_approval_card("appr-123", "工具审批请求", "run_command: cargo test");
|
||||
CardDocument::new(card).expect("审批卡片应通过 Card JSON 1.0 校验");
|
||||
}
|
||||
|
||||
/// 卡片构造包含同意/拒绝按钮及 approval_id 载荷
|
||||
#[test]
|
||||
fn approval_card_has_buttons_and_payload() {
|
||||
let card = build_approval_card("appr-456", "标题", "详情");
|
||||
let json = serde_json::to_value(&card).unwrap();
|
||||
let elements = json["elements"].as_array().expect("elements 数组");
|
||||
// 最后一个元素是 Action 行,内含 2 个按钮
|
||||
let action = elements.last().unwrap();
|
||||
let buttons = action["actions"].as_array().expect("actions 数组");
|
||||
assert_eq!(buttons.len(), 2);
|
||||
let values: Vec<String> = buttons
|
||||
.iter()
|
||||
.map(|b| b["value"]["approval_id"].as_str().unwrap_or_default().to_string())
|
||||
.collect();
|
||||
assert!(values.iter().all(|v| v == "appr-456"));
|
||||
let approved_flags: Vec<bool> = buttons
|
||||
.iter()
|
||||
.map(|b| b["value"]["approved"].as_bool().unwrap_or(false))
|
||||
.collect();
|
||||
assert!(approved_flags.contains(&true) && approved_flags.contains(&false));
|
||||
}
|
||||
|
||||
/// 出站净化:标题转粗体行,表格行转列表行,表头分隔行跳过
|
||||
#[test]
|
||||
fn lark_friendly_converts_headings_and_tables() {
|
||||
let md = "# 标题\n| a | b |\n|---|---|\n| 1 | 2 |";
|
||||
assert_eq!(md_to_lark_friendly(md), "**标题**\n- a · b\n- 1 · 2");
|
||||
}
|
||||
|
||||
/// 出站净化:代码围栏内外均保留原样,围栏外的标题照常改写
|
||||
#[test]
|
||||
fn lark_friendly_preserves_code_fence() {
|
||||
let md = "前文\n```\n# 不是标题\n| 不 | 是表格 |\n```\n## 后文";
|
||||
assert_eq!(
|
||||
md_to_lark_friendly(md),
|
||||
"前文\n```\n# 不是标题\n| 不 | 是表格 |\n```\n**后文**"
|
||||
);
|
||||
}
|
||||
|
||||
/// 出站净化:普通行原样透传(lark_md 原生支持粗体/链接,不额外转义)
|
||||
#[test]
|
||||
fn lark_friendly_passes_plain_lines() {
|
||||
let md = "**粗** 与 [链接](http://e)\n普通行";
|
||||
assert_eq!(md_to_lark_friendly(md), md);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
//! df-im-gateway 主入口:装配 config + store + throttle + commander + adapters
|
||||
//!
|
||||
//! 骨架版(P0):Web 适配器真实可跑,事件流全链路打通。
|
||||
//! 用法:
|
||||
//! ```text
|
||||
//! df-im-gateway.exe # 默认配置(env)
|
||||
//! GATEWAY_RELAY_URL=... GATEWAY_RELAY_TOKEN=... \
|
||||
//! GATEWAY_DEFAULT_CHANNEL=chan-work df-im-gateway.exe
|
||||
//! ```
|
||||
|
||||
mod adapters;
|
||||
mod audit;
|
||||
mod commander;
|
||||
mod config;
|
||||
mod feishu;
|
||||
mod store;
|
||||
mod throttle;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use adapters::{InboundEvent, OutboundAction, PlatformAdapter};
|
||||
use config::GatewayConfig;
|
||||
use store::SessionStore;
|
||||
use throttle::DeltaThrottle;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||
)
|
||||
.init();
|
||||
|
||||
let cfg = GatewayConfig::load()?;
|
||||
if cfg.channels.is_empty() {
|
||||
anyhow::bail!("未配置任何 channel 路由(GATEWAY_DEFAULT_CHANNEL),无法启动");
|
||||
}
|
||||
|
||||
let data_dir = std::env::var("GATEWAY_DATA_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| PathBuf::from("data"));
|
||||
std::fs::create_dir_all(&data_dir)?;
|
||||
let store = SessionStore::open(&data_dir.join("sessions.db"))?;
|
||||
// 审计日志(P5):IM 消息 ↔ TunnelCommand 全量记录,写失败不阻断主链路
|
||||
let audit = audit::AuditLog::open(&data_dir.join("audit.db"))?;
|
||||
|
||||
info_banner(&cfg);
|
||||
|
||||
// ── 核心事件总线:适配器 → 核心 ─────────────────────────────
|
||||
let (inbound_tx, mut inbound_rx) = mpsc::channel::<InboundEvent>(256);
|
||||
|
||||
// Web 适配器(四端首个,P0)
|
||||
let web_adapter = adapters::web::WebAdapter::new();
|
||||
{
|
||||
let web_run = web_adapter.clone();
|
||||
let ev_tx = inbound_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = web_run.run(ev_tx).await {
|
||||
tracing::error!("[web] 适配器退出: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 飞书适配器(P2):FEISHU_APP_ID/SECRET 配置时启用长连接
|
||||
let feishu_adapter = cfg.feishu.as_ref().map(|f| match feishu::FeishuAdapter::new(f) {
|
||||
Ok(adapter) => {
|
||||
let feishu_run = adapter.clone();
|
||||
let ev_tx = inbound_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = feishu_run.run(ev_tx).await {
|
||||
tracing::error!("[feishu] 适配器退出: {e}");
|
||||
}
|
||||
});
|
||||
tracing::info!("[feishu] 适配器已启动(长连接)");
|
||||
adapter
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("[feishu] 适配器构造失败(跳过): {e}");
|
||||
feishu::FeishuAdapter::offline()
|
||||
}
|
||||
});
|
||||
|
||||
// ── Commander 连接(u-relay)────────────────────────────────
|
||||
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
|
||||
let default_channel = cfg
|
||||
.channels
|
||||
.iter()
|
||||
.find(|c| c.prefix.is_empty())
|
||||
.map(|c| c.channel_id.clone())
|
||||
.unwrap_or_else(|| cfg.channels[0].channel_id.clone());
|
||||
|
||||
// 事件流:commander 任务 → 核心循环
|
||||
let (ev_tx, mut ev_rx) = mpsc::channel::<df_tunnel::events::TunnelEvent>(256);
|
||||
// 指令流:核心循环 → commander 任务(透传到 WS)
|
||||
let (cmd_tx, cmd_rx) = mpsc::channel::<df_tunnel::TunnelCommand>(256);
|
||||
|
||||
{
|
||||
let relay_url = cfg.relay_url.clone();
|
||||
let token = cfg.relay_token.clone();
|
||||
let channel = default_channel.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = commander::run_commander(relay_url, channel, token, ev_tx, cmd_rx, shutdown_rx).await {
|
||||
tracing::error!("[commander] 连接循环退出: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── 核心分发循环(单 select,持有全部状态,无跨线程共享)─────
|
||||
let mut throttle = DeltaThrottle::new(cfg.throttle_ms);
|
||||
// 审批幂等表:approval_id → 已处理标记(P0 必验项 ②:防 pending→approved 二次点击重复转发)
|
||||
let mut handled_approvals: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
// 平台注册表(走查 #4):显式 platform → 下标映射,替代硬编码 "feishu"=>1 与
|
||||
// adapters vec 构建顺序的隐式耦合。新增平台(企微/钉钉)只在此处注册一次。
|
||||
let mut adapters: Vec<Box<dyn adapters::PlatformAdapter>> = vec![Box::new(web_adapter.clone())];
|
||||
let mut platform_index: std::collections::HashMap<String, usize> =
|
||||
std::collections::HashMap::from([("web".to_string(), 0usize)]);
|
||||
if let Some(fs) = feishu_adapter {
|
||||
platform_index.insert("feishu".to_string(), adapters.len());
|
||||
adapters.push(Box::new(fs));
|
||||
}
|
||||
let platform_of = |platform_or_conv: &str| -> usize {
|
||||
// conv_id 形如 "platform:chat_id",前缀即平台;未知平台回退 web(0)
|
||||
let platform = platform_or_conv.split(':').next().unwrap_or("web");
|
||||
*platform_index.get(platform).unwrap_or(&0)
|
||||
};
|
||||
// 停止信号
|
||||
let shutdown_ctrl = tokio::signal::ctrl_c();
|
||||
tokio::pin!(shutdown_ctrl);
|
||||
let cfg_loop = cfg.clone();
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = &mut shutdown_ctrl => {
|
||||
tracing::info!("收到 Ctrl+C,优雅退出");
|
||||
let _ = shutdown_tx.send(true);
|
||||
break;
|
||||
}
|
||||
// ── TunnelEvent(桌面端 → 网关)──
|
||||
ev = ev_rx.recv() => {
|
||||
let Some(ev) = ev else { break };
|
||||
match ev {
|
||||
df_tunnel::TunnelEvent::TextDelta { conv_id, delta } => {
|
||||
// 出站按 conv_id 前缀(platform:chat_id)路由回原平台
|
||||
let flush = throttle.feed(&conv_id, &delta);
|
||||
if let Some(text) = flush {
|
||||
let idx = platform_of(&conv_id);
|
||||
let _ = adapters[idx]
|
||||
.dispatch(OutboundAction::StreamText { chat_id: conv_id, text, done: false })
|
||||
.await;
|
||||
}
|
||||
}
|
||||
df_tunnel::TunnelEvent::Completed { conv_id, .. } => {
|
||||
let tail = throttle.finish(&conv_id);
|
||||
let full = tail.unwrap_or_default();
|
||||
let idx = platform_of(&conv_id);
|
||||
audit.record(audit::AuditEntry {
|
||||
direction: audit::Direction::Outbound,
|
||||
platform: String::new(), chat_id: conv_id.clone(),
|
||||
user_id: String::new(), conv_id: conv_id.clone(),
|
||||
action: "completed".into(), request_id: String::new(),
|
||||
payload: full.clone(), outcome: "ok".into(),
|
||||
});
|
||||
let _ = adapters[idx]
|
||||
.dispatch(OutboundAction::StreamText { chat_id: conv_id, text: full, done: true })
|
||||
.await;
|
||||
}
|
||||
df_tunnel::TunnelEvent::Approval { conv_id, approval_id, description } => {
|
||||
audit.record(audit::AuditEntry {
|
||||
direction: audit::Direction::Outbound,
|
||||
platform: String::new(), chat_id: conv_id.clone(),
|
||||
user_id: String::new(), conv_id: conv_id.clone(),
|
||||
action: "approval_card".into(), request_id: approval_id.clone(),
|
||||
payload: description.clone(), outcome: "ok".into(),
|
||||
});
|
||||
let idx = platform_of(&conv_id);
|
||||
let _ = adapters[idx]
|
||||
.dispatch(OutboundAction::ApprovalCard {
|
||||
chat_id: conv_id,
|
||||
approval_id,
|
||||
title: "工具审批请求".into(),
|
||||
detail: description,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
df_tunnel::TunnelEvent::ToolCall { .. } => {
|
||||
// P0 骨架不转发 ToolCall 到 IM(卡片噪音),仅日志
|
||||
tracing::debug!("[core] ToolCall 事件(骨架忽略)");
|
||||
}
|
||||
}
|
||||
}
|
||||
// ── InboundEvent(IM → 网关)──
|
||||
inbound = inbound_rx.recv() => {
|
||||
let Some(ev) = inbound else { break };
|
||||
match ev {
|
||||
InboundEvent::Message { platform, chat_id, user_id, text } => {
|
||||
// 回复适配器:按来源平台回路由
|
||||
let reply_idx = platform_of(&platform);
|
||||
if !cfg_loop.whitelist.is_empty() && !cfg_loop.whitelist.contains(&user_id) {
|
||||
audit.record(audit::AuditEntry {
|
||||
direction: audit::Direction::Inbound,
|
||||
platform: platform.clone(), chat_id: chat_id.clone(),
|
||||
user_id: user_id.clone(), conv_id: String::new(),
|
||||
action: "message".into(), request_id: String::new(),
|
||||
payload: text.clone(), outcome: "denied".into(),
|
||||
});
|
||||
let _ = adapters[reply_idx]
|
||||
.dispatch(OutboundAction::Notice { chat_id, text: "⛔ 你不在白名单内".into() })
|
||||
.await;
|
||||
continue;
|
||||
}
|
||||
let Some(channel_id) = cfg_loop.route(&text) else {
|
||||
audit.record(audit::AuditEntry {
|
||||
direction: audit::Direction::Inbound,
|
||||
platform: platform.clone(), chat_id: chat_id.clone(),
|
||||
user_id: user_id.clone(), conv_id: String::new(),
|
||||
action: "message".into(), request_id: String::new(),
|
||||
payload: text.clone(), outcome: "error".into(),
|
||||
});
|
||||
let _ = adapters[reply_idx]
|
||||
.dispatch(OutboundAction::Notice { chat_id, text: "⚠️ 无可用 channel".into() })
|
||||
.await;
|
||||
continue;
|
||||
};
|
||||
// 会话映射:无则新建(conv_id = platform:chat_id,Web 端 chat_id 同源)
|
||||
let conv_id = match store.get_conv(&platform, &chat_id) {
|
||||
Some(m) => m.conv_id,
|
||||
None => {
|
||||
let conv = format!("{platform}:{chat_id}");
|
||||
let _ = store.set_conv(&platform, &chat_id, &conv, channel_id);
|
||||
// 走查 #3:新建会话时清理流式残留(上轮异常中止无
|
||||
// Completed 事件,streams entry 永驻会错覆写旧消息)
|
||||
adapters[reply_idx].reset_stream(&chat_id).await;
|
||||
audit.record(audit::AuditEntry {
|
||||
direction: audit::Direction::Inbound,
|
||||
platform: platform.clone(), chat_id: chat_id.clone(),
|
||||
user_id: user_id.clone(), conv_id: conv.clone(),
|
||||
action: "new_session".into(), request_id: String::new(),
|
||||
payload: String::new(), outcome: "ok".into(),
|
||||
});
|
||||
conv
|
||||
}
|
||||
};
|
||||
// 入站消息审计:request_id 用 conv+毫秒派生,出站 Completed 事件无法
|
||||
// 回带,此处以 (direction=inbound,message) 为链头,command/outbound
|
||||
// 侧用同一 conv_id 关联(recent 按时间倒序即还原会话时间线)。
|
||||
audit.record(audit::AuditEntry {
|
||||
direction: audit::Direction::Inbound,
|
||||
platform: platform.clone(), chat_id: chat_id.clone(),
|
||||
user_id: user_id.clone(), conv_id: conv_id.clone(),
|
||||
action: "message".into(), request_id: String::new(),
|
||||
payload: text.clone(), outcome: "ok".into(),
|
||||
});
|
||||
audit.record(audit::AuditEntry {
|
||||
direction: audit::Direction::Command,
|
||||
platform: platform.clone(), chat_id: chat_id.clone(),
|
||||
user_id: user_id.clone(), conv_id: conv_id.clone(),
|
||||
action: "send_cmd".into(), request_id: String::new(),
|
||||
payload: text.clone(), outcome: "ok".into(),
|
||||
});
|
||||
let _ = cmd_tx
|
||||
.send(df_tunnel::TunnelCommand::Send { conv_id, content: text })
|
||||
.await;
|
||||
}
|
||||
InboundEvent::NewSession { platform, chat_id } => {
|
||||
audit.record(audit::AuditEntry {
|
||||
direction: audit::Direction::Inbound,
|
||||
platform: platform.clone(), chat_id: chat_id.clone(),
|
||||
user_id: String::new(), conv_id: String::new(),
|
||||
action: "new_session_reset".into(), request_id: String::new(),
|
||||
payload: String::new(), outcome: "ok".into(),
|
||||
});
|
||||
let _ = store.remove(&platform, &chat_id);
|
||||
let reply_idx = platform_of(&platform);
|
||||
let _ = adapters[reply_idx]
|
||||
.dispatch(OutboundAction::Notice { chat_id, text: "🔄 已重置会话".into() })
|
||||
.await;
|
||||
}
|
||||
InboundEvent::Approval { platform, chat_id, approval_id, approved } => {
|
||||
// 幂等:同 approval_id 只处理首次(P0 必验项 ②)
|
||||
if handled_approvals.contains(&approval_id) {
|
||||
tracing::debug!("[core] 审批 {approval_id} 已处理,忽略重复点击");
|
||||
continue;
|
||||
}
|
||||
handled_approvals.insert(approval_id.clone());
|
||||
let conv_id = store
|
||||
.get_conv(&platform, &chat_id)
|
||||
.map(|m| m.conv_id)
|
||||
.unwrap_or_else(|| format!("{platform}:{chat_id}"));
|
||||
audit.record(audit::AuditEntry {
|
||||
direction: audit::Direction::Inbound,
|
||||
platform: platform.clone(), chat_id: chat_id.clone(),
|
||||
user_id: String::new(), conv_id: conv_id.clone(),
|
||||
action: "approval_click".into(), request_id: approval_id.clone(),
|
||||
payload: if approved { "approved".into() } else { "rejected".into() },
|
||||
outcome: "ok".into(),
|
||||
}); audit.record(audit::AuditEntry {
|
||||
direction: audit::Direction::Command,
|
||||
platform, chat_id, user_id: String::new(), conv_id: conv_id.clone(),
|
||||
action: "approve_cmd".into(), request_id: approval_id.clone(), payload: if approved { "approved".into() } else { "rejected".into() },
|
||||
outcome: "ok".into(),
|
||||
});
|
||||
let _ = cmd_tx
|
||||
.send(df_tunnel::TunnelCommand::Approve { conv_id, approval_id, accepted: approved })
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn info_banner(cfg: &GatewayConfig) {
|
||||
println!("╔══════════════════════════════════════════╗");
|
||||
println!("║ df-im-gateway v0.1.0 (P0 骨架) ║");
|
||||
println!("╚══════════════════════════════════════════╝");
|
||||
println!(" relay : {}", cfg.relay_url);
|
||||
println!(" channels : {} 条路由", cfg.channels.len());
|
||||
println!(" throttle : {}ms", cfg.throttle_ms);
|
||||
println!(" whitelist : {} 用户", cfg.whitelist.len());
|
||||
if cfg.whitelist.is_empty() {
|
||||
println!(" ⚠️ 白名单为空:任何人可用本网关(开发默认)。生产部署请设置 GATEWAY_WHITELIST!");
|
||||
}
|
||||
println!(" feishu : {}", if cfg.feishu.is_some() { "已配置" } else { "未配置(跳过)" });
|
||||
println!(" web ws : 0.0.0.0:9191");
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
//! 会话映射存储(IM chat_id ↔ DevFlow conv_id,SQLite + WAL)
|
||||
//!
|
||||
//! 建表即带 owner_user_id/platform 列(P0 预留项 ⑤,当前恒默认值,
|
||||
//! S2 多人场景免迁表)。
|
||||
//!
|
||||
//! 表结构:
|
||||
//! ```sql
|
||||
//! CREATE TABLE IF NOT EXISTS session_map (
|
||||
//! platform TEXT NOT NULL, -- feishu/wecom/dingtalk/web
|
||||
//! chat_id TEXT NOT NULL, -- IM 侧会话(单聊 user_id / 群聊 chat_id)
|
||||
//! conv_id TEXT NOT NULL, -- DevFlow 会话 ID
|
||||
//! owner_user_id TEXT NOT NULL DEFAULT '',-- 预留 S2 多人归属(当前恒 '')
|
||||
//! channel_id TEXT NOT NULL DEFAULT '',-- 路由到的桌面端 channel(预留项 ⑥)
|
||||
//! created_at INTEGER NOT NULL,
|
||||
//! updated_at INTEGER NOT NULL,
|
||||
//! PRIMARY KEY (platform, chat_id)
|
||||
//! );
|
||||
//! ```
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
/// 单条会话映射(字段预留 S2 多人/多桌面端,当前核心只读 conv_id)
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub struct SessionMap {
|
||||
pub platform: String,
|
||||
pub chat_id: String,
|
||||
pub conv_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub channel_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SessionStore {
|
||||
conn: Arc<std::sync::Mutex<Connection>>,
|
||||
}
|
||||
|
||||
impl SessionStore {
|
||||
/// 打开(或创建)库文件并确保表存在。WAL 模式(与桌面端一致)。
|
||||
pub fn open(path: &Path) -> anyhow::Result<Self> {
|
||||
let conn = Connection::open(path)?;
|
||||
conn.pragma_update(None, "journal_mode", "WAL")?;
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS session_map (
|
||||
platform TEXT NOT NULL,
|
||||
chat_id TEXT NOT NULL,
|
||||
conv_id TEXT NOT NULL,
|
||||
owner_user_id TEXT NOT NULL DEFAULT '',
|
||||
channel_id TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (platform, chat_id)
|
||||
);",
|
||||
)?;
|
||||
Ok(Self { conn: Arc::new(std::sync::Mutex::new(conn)) })
|
||||
}
|
||||
|
||||
/// 内存库(测试/演示)
|
||||
#[allow(dead_code)]
|
||||
pub fn open_in_memory() -> anyhow::Result<Self> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS session_map (
|
||||
platform TEXT NOT NULL, chat_id TEXT NOT NULL, conv_id TEXT NOT NULL,
|
||||
owner_user_id TEXT NOT NULL DEFAULT '', channel_id TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (platform, chat_id)
|
||||
);",
|
||||
)?;
|
||||
Ok(Self { conn: Arc::new(std::sync::Mutex::new(conn)) })
|
||||
}
|
||||
|
||||
/// 查 IM 会话映射的 conv_id(未映射返回 None)
|
||||
pub fn get_conv(&self, platform: &str, chat_id: &str) -> Option<SessionMap> {
|
||||
let conn = self.conn.lock().ok()?;
|
||||
conn.query_row(
|
||||
"SELECT platform, chat_id, conv_id, owner_user_id, channel_id FROM session_map \
|
||||
WHERE platform = ?1 AND chat_id = ?2",
|
||||
[platform, chat_id],
|
||||
|row| {
|
||||
Ok(SessionMap {
|
||||
platform: row.get(0)?,
|
||||
chat_id: row.get(1)?,
|
||||
conv_id: row.get(2)?,
|
||||
owner_user_id: row.get(3)?,
|
||||
channel_id: row.get(4)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// 写/更新映射(upsert)
|
||||
pub fn set_conv(&self, platform: &str, chat_id: &str, conv_id: &str, channel_id: &str) -> anyhow::Result<()> {
|
||||
let conn = self.conn.lock().map_err(|e| anyhow::anyhow!("store 锁中毒: {e}"))?;
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as i64)
|
||||
.unwrap_or(0);
|
||||
conn.execute(
|
||||
"INSERT INTO session_map (platform, chat_id, conv_id, owner_user_id, channel_id, created_at, updated_at) \
|
||||
VALUES (?1, ?2, ?3, '', ?4, ?5, ?5) \
|
||||
ON CONFLICT(platform, chat_id) DO UPDATE SET conv_id = ?3, channel_id = ?4, updated_at = ?5",
|
||||
rusqlite::params![platform, chat_id, conv_id, channel_id, now],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 删除映射(桌面端会话已删,悬空清理)
|
||||
pub fn remove(&self, platform: &str, chat_id: &str) -> anyhow::Result<()> {
|
||||
let conn = self.conn.lock().map_err(|e| anyhow::anyhow!("store 锁中毒: {e}"))?;
|
||||
conn.execute("DELETE FROM session_map WHERE platform = ?1 AND chat_id = ?2", [platform, chat_id])?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn upsert_and_get() {
|
||||
let store = SessionStore::open_in_memory().unwrap();
|
||||
store.set_conv("feishu", "oc_123", "conv_abc", "chan-work").unwrap();
|
||||
let m = store.get_conv("feishu", "oc_123").unwrap();
|
||||
assert_eq!(m.conv_id, "conv_abc");
|
||||
assert_eq!(m.channel_id, "chan-work");
|
||||
assert_eq!(m.owner_user_id, "");
|
||||
|
||||
// upsert 覆盖
|
||||
store.set_conv("feishu", "oc_123", "conv_new", "chan-home").unwrap();
|
||||
let m = store.get_conv("feishu", "oc_123").unwrap();
|
||||
assert_eq!(m.conv_id, "conv_new");
|
||||
|
||||
// 删除后悬空
|
||||
store.remove("feishu", "oc_123").unwrap();
|
||||
assert!(store.get_conv("feishu", "oc_123").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn platform_isolated() {
|
||||
let store = SessionStore::open_in_memory().unwrap();
|
||||
store.set_conv("feishu", "c1", "conv1", "ch").unwrap();
|
||||
store.set_conv("wecom", "c1", "conv2", "ch").unwrap();
|
||||
assert_eq!(store.get_conv("feishu", "c1").unwrap().conv_id, "conv1");
|
||||
assert_eq!(store.get_conv("wecom", "c1").unwrap().conv_id, "conv2");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
//! TextDelta 节流聚合器(IM 频控防护)
|
||||
//!
|
||||
//! IM 平台消息 API 频控(飞书 ~50 QPS 应用级、企微 200 次/分钟),逐 delta 刷卡片必被限流。
|
||||
//! 策略:per-conv 聚合缓冲 + 时间窗(默认 800ms)批量 flush;Completed 时强制冲刷尾包。
|
||||
//!
|
||||
//! 设计要点:
|
||||
//! - 每个 key 一个缓冲,互不干扰
|
||||
//! - 纯函数式:feed() 返回 Option<全量文本>(窗口到期才返回),不持有回调/不 await
|
||||
//! - flush 语义:全量文本(IM 卡片更新是「全量内容」语义,非增量)
|
||||
//! - finish() 冲刷尾包并清缓冲;abort() 丢弃缓冲(错误/停止,不输出残文)
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// 单 key 缓冲
|
||||
struct ConvBuffer {
|
||||
/// 累积全量文本
|
||||
full_text: String,
|
||||
/// 上次 flush 时刻
|
||||
last_flush: Instant,
|
||||
}
|
||||
|
||||
/// 节流聚合器
|
||||
pub struct DeltaThrottle {
|
||||
window: Duration,
|
||||
buffers: HashMap<String, ConvBuffer>,
|
||||
}
|
||||
|
||||
impl DeltaThrottle {
|
||||
pub fn new(window_ms: u64) -> Self {
|
||||
Self { window: Duration::from_millis(window_ms), buffers: HashMap::new() }
|
||||
}
|
||||
|
||||
/// 喂入 delta。窗口到期则返回 Some(全量文本)(调用方负责外发),否则 None。
|
||||
pub fn feed(&mut self, key: &str, delta: &str) -> Option<String> {
|
||||
let now = Instant::now();
|
||||
let buf = self.buffers.entry(key.to_string()).or_insert_with(|| ConvBuffer {
|
||||
full_text: String::new(),
|
||||
last_flush: now,
|
||||
});
|
||||
buf.full_text.push_str(delta);
|
||||
if buf.last_flush.elapsed() >= self.window {
|
||||
buf.last_flush = now;
|
||||
Some(buf.full_text.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// 会话完成:强制冲刷尾包 + 清缓冲。返回 Some(全量文本) 或(空会话)None。
|
||||
pub fn finish(&mut self, key: &str) -> Option<String> {
|
||||
self.buffers.remove(key).map(|buf| buf.full_text)
|
||||
}
|
||||
|
||||
/// 丢弃缓冲(错误/停止,不输出残文)
|
||||
///
|
||||
/// 注:当前 TunnelEvent 无 Error/Stopped 变体,核心循环暂无调用点;
|
||||
/// df-tunnel Phase2 扩展后接入。
|
||||
#[allow(dead_code)]
|
||||
pub fn abort(&mut self, key: &str) {
|
||||
self.buffers.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn throttle_window_batch() {
|
||||
let mut t = DeltaThrottle::new(60_000); // 60s 窗口:模拟窗口永不到期
|
||||
assert_eq!(t.feed("k", "你"), None);
|
||||
assert_eq!(t.feed("k", "好"), None);
|
||||
assert_eq!(t.feed("k", "!"), None);
|
||||
assert_eq!(t.finish("k"), Some("你好!".to_string()), "finish 冲刷全量");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_elapsed_flushes() {
|
||||
let mut t = DeltaThrottle::new(0); // 0ms 窗口:每次 feed 都到期
|
||||
assert_eq!(t.feed("k", "a"), Some("a".to_string()));
|
||||
assert_eq!(t.feed("k", "b"), Some("ab".to_string()));
|
||||
// 缓冲仍在 finish 前的最后 flush 版本;finish 拿走剩余
|
||||
assert_eq!(t.finish("k"), Some("ab".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abort_drops() {
|
||||
let mut t = DeltaThrottle::new(60_000);
|
||||
t.feed("k", "残文");
|
||||
t.abort("k");
|
||||
assert_eq!(t.finish("k"), None, "abort 后缓冲不存在");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_isolated() {
|
||||
let mut t = DeltaThrottle::new(60_000);
|
||||
t.feed("a", "1");
|
||||
t.feed("b", "2");
|
||||
assert_eq!(t.finish("a"), Some("1".to_string()));
|
||||
assert_eq!(t.finish("b"), Some("2".to_string()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>DevFlow AI Chat</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f1115; --panel: #171a21; --panel2: #1d212b; --border: #2a2f3a;
|
||||
--text: #e6e9ef; --muted: #8a93a6; --accent: #4f8cff; --accent2: #3a6fd8;
|
||||
--ok: #3fb96f; --warn: #e5a03c; --err: #e05252; --radius: 10px;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
background: var(--bg); color: var(--text);
|
||||
font: 14px/1.6 -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
display: flex; overflow: hidden;
|
||||
}
|
||||
/* ── 侧栏:会话列表 ── */
|
||||
#sidebar {
|
||||
width: 220px; min-width: 220px; background: var(--panel);
|
||||
border-right: 1px solid var(--border); display: flex; flex-direction: column;
|
||||
}
|
||||
#sidebar header { padding: 14px 14px 8px; font-weight: 600; font-size: 13px; color: var(--muted); letter-spacing: .05em; }
|
||||
#conv-list { flex: 1; overflow-y: auto; padding: 4px 8px; }
|
||||
.conv-item {
|
||||
padding: 8px 10px; border-radius: 8px; cursor: pointer; margin-bottom: 2px;
|
||||
display: flex; align-items: center; gap: 8px; color: var(--muted);
|
||||
}
|
||||
.conv-item:hover { background: var(--panel2); }
|
||||
.conv-item.active { background: var(--panel2); color: var(--text); }
|
||||
.conv-item .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); flex: none; }
|
||||
.conv-item .name { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 13px; }
|
||||
.conv-item .del { opacity: 0; color: var(--muted); font-size: 12px; padding: 0 2px; }
|
||||
.conv-item:hover .del { opacity: 1; }
|
||||
.conv-item .del:hover { color: var(--err); }
|
||||
#sidebar footer { padding: 10px; border-top: 1px solid var(--border); display: flex; gap: 8px; }
|
||||
/* ── 主区 ── */
|
||||
#main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
||||
#topbar {
|
||||
padding: 10px 16px; border-bottom: 1px solid var(--border); background: var(--panel);
|
||||
display: flex; align-items: center; gap: 10px; font-size: 13px;
|
||||
}
|
||||
#conn-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--err); flex: none; }
|
||||
#conn-dot.on { background: var(--ok); }
|
||||
#conn-label { color: var(--muted); }
|
||||
#topbar .spacer { flex: 1; }
|
||||
#topbar button {
|
||||
background: var(--panel2); color: var(--text); border: 1px solid var(--border);
|
||||
border-radius: 6px; padding: 4px 10px; cursor: pointer; font-size: 12px;
|
||||
}
|
||||
#topbar button:hover { border-color: var(--accent); }
|
||||
#msgs { flex: 1; overflow-y: auto; padding: 18px 16px; scroll-behavior: smooth; }
|
||||
.msg { margin-bottom: 14px; display: flex; }
|
||||
.msg.user { justify-content: flex-end; }
|
||||
.msg .bubble {
|
||||
max-width: 78%; padding: 9px 13px; border-radius: var(--radius);
|
||||
background: var(--panel2); white-space: pre-wrap; word-break: break-word;
|
||||
}
|
||||
.msg.user .bubble { background: var(--accent2); }
|
||||
.msg.ai .bubble { background: var(--panel2); }
|
||||
.msg .bubble.sys { background: transparent; color: var(--muted); font-size: 12px; max-width: 100%; }
|
||||
.approval-card {
|
||||
border: 1px solid var(--warn); border-radius: var(--radius); padding: 10px 13px;
|
||||
max-width: 78%; background: rgba(229,160,60,.08);
|
||||
}
|
||||
.approval-card .t { color: var(--warn); font-weight: 600; font-size: 13px; margin-bottom: 4px; }
|
||||
.approval-card .d { color: var(--muted); font-size: 12px; margin-bottom: 9px; white-space: pre-wrap; word-break: break-word; }
|
||||
.approval-card .row { display: flex; gap: 8px; }
|
||||
.approval-card button {
|
||||
flex: 1; padding: 6px 0; border-radius: 6px; border: none; cursor: pointer; font-size: 13px;
|
||||
}
|
||||
.approval-card .yes { background: var(--ok); color: #fff; }
|
||||
.approval-card .no { background: var(--panel2); color: var(--muted); border: 1px solid var(--border); }
|
||||
.approval-card.answered { opacity: .55; }
|
||||
.approval-card.answered .row { display: none; }
|
||||
.approval-card .verdict { font-size: 12px; color: var(--muted); }
|
||||
/* ── 输入区 ── */
|
||||
#inputbar { display: flex; gap: 8px; padding: 12px 14px; border-top: 1px solid var(--border); background: var(--panel); }
|
||||
#inp {
|
||||
flex: 1; background: var(--panel2); border: 1px solid var(--border); color: var(--text);
|
||||
border-radius: 8px; padding: 9px 12px; font: inherit; resize: none; outline: none; height: 44px;
|
||||
}
|
||||
#inp:focus { border-color: var(--accent); }
|
||||
#send, #stop {
|
||||
border: none; border-radius: 8px; padding: 0 18px; cursor: pointer; font: inherit; font-size: 13px;
|
||||
}
|
||||
#send { background: var(--accent); color: #fff; }
|
||||
#send:disabled { opacity: .45; cursor: not-allowed; }
|
||||
#stop { background: var(--panel2); color: var(--err); border: 1px solid var(--border); display: none; }
|
||||
@media (max-width: 640px) {
|
||||
#sidebar { position: fixed; z-index: 10; height: 100%; transform: translateX(-100%); transition: transform .2s; }
|
||||
#sidebar.open { transform: translateX(0); box-shadow: 0 0 30px rgba(0,0,0,.5); }
|
||||
#menu-btn { display: inline-block !important; }
|
||||
.msg .bubble, .approval-card { max-width: 92%; }
|
||||
}
|
||||
#menu-btn { display: none; background: none; border: none; color: var(--text); font-size: 18px; cursor: pointer; }
|
||||
/* 简单 markdown 粗体/代码 */
|
||||
.bubble code { background: rgba(79,140,255,.12); border-radius: 4px; padding: 1px 5px; font-size: 12.5px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav id="sidebar">
|
||||
<header>会话</header>
|
||||
<div id="conv-list"></div>
|
||||
<footer><button id="new-conv" style="flex:1;background:var(--panel2);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:6px;cursor:pointer">+ 新会话</button></footer>
|
||||
</nav>
|
||||
<div id="main">
|
||||
<div id="topbar">
|
||||
<button id="menu-btn">☰</button>
|
||||
<span id="conn-dot"></span><span id="conn-label">连接中…</span>
|
||||
<span class="spacer"></span>
|
||||
<button id="clear-btn">🗑 清空当前会话记录</button>
|
||||
</div>
|
||||
<div id="msgs"></div>
|
||||
<div id="inputbar">
|
||||
<textarea id="inp" placeholder="发消息给 DevFlow AI…(Enter 发送 / Shift+Enter 换行)"></textarea>
|
||||
<button id="send">发送</button>
|
||||
<button id="stop">停止</button>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
'use strict';
|
||||
/* ===== 常量与状态 ===== */
|
||||
var LS_KEY = 'devflow.webchat.v1'; // localStorage: {convs:[{id,name}], activeId}
|
||||
var state = {
|
||||
convs: [], // [{id, name, msgs:[{role:'user'|'ai'|'sys', text, ts}], pending:{approval_id, title, detail, el} }]
|
||||
activeId: null, // 当前会话 chat_id
|
||||
ws: null, wsReady: false, reconnectTimer: null, closedByUs: false,
|
||||
generating: false, // 当前会话生成中(流式未完)
|
||||
};
|
||||
var $ = function (s) { return document.querySelector(s); };
|
||||
var msgsEl = $('#msgs'), inpEl = $('#inp'), sendBtn = $('#send'), stopBtn = $('#stop');
|
||||
|
||||
/* ===== 本地持久化 ===== */
|
||||
function loadStore() {
|
||||
try { state.convs = JSON.parse(localStorage.getItem(LS_KEY) || '[]'); } catch (e) { state.convs = []; }
|
||||
}
|
||||
function saveStore() {
|
||||
try { localStorage.setItem(LS_KEY, JSON.stringify(state.convs)); } catch (e) { /* 配额满忽略 */ }
|
||||
}
|
||||
function activeConv() {
|
||||
return state.convs.find(function (c) { return c.id === state.activeId; }) || null;
|
||||
}
|
||||
|
||||
/* ===== 会话管理 ===== */
|
||||
function newConvId() { return 'web-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8); }
|
||||
function newConv(activate) {
|
||||
var id = newConvId();
|
||||
var c = { id: id, name: '新会话', msgs: [], pending: null };
|
||||
state.convs.unshift(c);
|
||||
if (activate) switchConv(id);
|
||||
saveStore(); renderConvList();
|
||||
return c;
|
||||
}
|
||||
function switchConv(id) {
|
||||
if (state.activeId === id) { closeSidebar(); return; }
|
||||
state.activeId = id;
|
||||
saveStore(); renderConvList(); renderMsgs(); closeSidebar();
|
||||
// WS 已就绪则重绑到新 chat_id(断开重连,首帧 hello 携带新 id)
|
||||
if (state.wsReady) reconnect();
|
||||
}
|
||||
function deleteConv(id) {
|
||||
var idx = state.convs.findIndex(function (c) { return c.id === id; });
|
||||
if (idx < 0) return;
|
||||
state.convs.splice(idx, 1);
|
||||
if (state.activeId === id) {
|
||||
state.activeId = state.convs.length ? state.convs[0].id : null;
|
||||
if (!state.activeId) newConv(false);
|
||||
renderMsgs();
|
||||
if (state.wsReady) reconnect();
|
||||
}
|
||||
saveStore(); renderConvList();
|
||||
}
|
||||
function newSessionOnServer() {
|
||||
var c = activeConv();
|
||||
if (c && state.wsReady) wsSend({ type: 'new_session' });
|
||||
}
|
||||
function ensureConv() { return activeConv() || newConv(true); }
|
||||
|
||||
/* ===== 渲染 ===== */
|
||||
function esc(s) {
|
||||
return String(s).replace(/[&<>"']/g, function (m) {
|
||||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m];
|
||||
});
|
||||
}
|
||||
function fmtText(s) {
|
||||
// 轻量格式化:转义后 `code` 行内代码
|
||||
return esc(s).replace(/`([^`\n]+)`/g, '<code>$1</code>');
|
||||
}
|
||||
function renderConvList() {
|
||||
var el = $('#conv-list'); el.innerHTML = '';
|
||||
state.convs.forEach(function (c) {
|
||||
var d = document.createElement('div');
|
||||
d.className = 'conv-item' + (c.id === state.activeId ? ' active' : '');
|
||||
d.innerHTML = '<span class="dot"></span><span class="name">' + esc(c.name) + '</span><span class="del" title="删除">✕</span>';
|
||||
d.onclick = function (e) {
|
||||
if (e.target.classList.contains('del')) {
|
||||
if (confirm('删除会话「' + c.name + '」?(仅删除本页记录,桌面端对话保留)')) deleteConv(c.id);
|
||||
return;
|
||||
}
|
||||
switchConv(c.id);
|
||||
};
|
||||
el.appendChild(d);
|
||||
});
|
||||
}
|
||||
function msgNode(m) {
|
||||
if (m.role === 'sys') {
|
||||
var d = document.createElement('div'); d.className = 'msg';
|
||||
var b = document.createElement('div'); b.className = 'bubble sys'; b.innerHTML = fmtText(m.text);
|
||||
d.appendChild(b); return d;
|
||||
}
|
||||
if (m.role === 'approval') {
|
||||
var wrap = document.createElement('div'); wrap.className = 'msg';
|
||||
var card = document.createElement('div'); card.className = 'approval-card';
|
||||
card.innerHTML = '<div class="t">🛡 ' + esc(m.title || '工具审批请求') + '</div>'
|
||||
+ '<div class="d">' + fmtText(m.detail || '') + '</div>'
|
||||
+ '<div class="row"><button class="yes">同意</button><button class="no">拒绝</button></div>';
|
||||
var done = function (approved) {
|
||||
card.classList.add('answered');
|
||||
var v = document.createElement('div'); v.className = 'verdict';
|
||||
v.textContent = (approved ? '✓ 已同意' : '✕ 已拒绝') + '(未知新帧到达前以此为准)';
|
||||
card.appendChild(v);
|
||||
wsSend({ type: 'approval', approval_id: m.approval_id, approved: approved });
|
||||
};
|
||||
card.querySelector('.yes').onclick = function () { done(true); };
|
||||
card.querySelector('.no').onclick = function () { done(false); };
|
||||
if (m.answered) { card.classList.add('answered'); var v = document.createElement('div'); v.className = 'verdict'; v.textContent = m.answered === 'yes' ? '✓ 已同意' : '✕ 已拒绝'; card.appendChild(v); }
|
||||
wrap.appendChild(card); return wrap;
|
||||
}
|
||||
var d = document.createElement('div'); d.className = 'msg ' + (m.role === 'user' ? 'user' : 'ai');
|
||||
var b = document.createElement('div'); b.className = 'bubble'; b.innerHTML = fmtText(m.text || '');
|
||||
d.appendChild(b); return d;
|
||||
}
|
||||
function renderMsgs() {
|
||||
msgsEl.innerHTML = '';
|
||||
var c = activeConv(); if (!c) return;
|
||||
c.msgs.forEach(function (m) { msgsEl.appendChild(msgNode(m)); });
|
||||
scrollBottom();
|
||||
}
|
||||
function scrollBottom() { msgsEl.scrollTop = msgsEl.scrollHeight; }
|
||||
|
||||
/* ===== WS 连接 ===== */
|
||||
function wsSend(obj) {
|
||||
if (state.ws && state.ws.readyState === 1) { state.ws.send(JSON.stringify(obj)); return true; }
|
||||
return false;
|
||||
}
|
||||
function connect() {
|
||||
clearTimeout(state.reconnectTimer);
|
||||
var c = ensureConv();
|
||||
state.closedByUs = false;
|
||||
var proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
var ws;
|
||||
try { ws = new WebSocket(proto + '://' + location.host + '/ws'); } catch (e) { scheduleReconnect(); return; }
|
||||
state.ws = ws;
|
||||
ws.onopen = function () {
|
||||
ws.send(JSON.stringify({ type: 'hello', chat_id: state.activeId, user_id: 'web-user' }));
|
||||
setConn(true);
|
||||
};
|
||||
ws.onmessage = function (e) {
|
||||
var v; try { v = JSON.parse(e.data); } catch (err) { return; }
|
||||
handleFrame(v);
|
||||
};
|
||||
ws.onclose = function () { setConn(false); if (!state.closedByUs) scheduleReconnect(); };
|
||||
ws.onerror = function () { try { ws.close(); } catch (err2) {} };
|
||||
}
|
||||
function reconnect() {
|
||||
state.closedByUs = true;
|
||||
try { state.ws && state.ws.close(); } catch (e) {}
|
||||
setConn(false);
|
||||
setTimeout(connect, 60);
|
||||
}
|
||||
function scheduleReconnect() {
|
||||
setConn(false);
|
||||
clearTimeout(state.reconnectTimer);
|
||||
state.reconnectTimer = setTimeout(connect, 2500);
|
||||
}
|
||||
function setConn(on) {
|
||||
state.wsReady = on;
|
||||
$('#conn-dot').className = on ? 'on' : '';
|
||||
$('#conn-label').textContent = on ? '已连接' : '重连中…(桌面端或网关未在线)';
|
||||
}
|
||||
|
||||
/* ===== 帧处理(网关 → 浏览器) ===== */
|
||||
function handleFrame(v) {
|
||||
var c = activeConv(); if (!c) return;
|
||||
switch (v.type) {
|
||||
case 'stream_delta': {
|
||||
var m = lastAiStreaming(c);
|
||||
if (!m) { m = { role: 'ai', text: '' }; c.msgs.push(m); setGenerating(true); }
|
||||
m.text = v.text || ''; // 网关推的是全量聚合文本,直接覆盖
|
||||
appendLive(m);
|
||||
break;
|
||||
}
|
||||
case 'stream_done': {
|
||||
var m2 = lastAiStreaming(c);
|
||||
if (m2) { m2.text = v.text || m2.text || ''; m2.done = true; }
|
||||
else { c.msgs.push({ role: 'ai', text: v.text || '', done: true }); }
|
||||
setGenerating(false);
|
||||
renderMsgs();
|
||||
break;
|
||||
}
|
||||
case 'approval': {
|
||||
var ap = { role: 'approval', approval_id: v.approval_id, title: v.title, detail: v.detail };
|
||||
c.msgs.push(ap); setGenerating(true); // 等审批期间视为生成中(停止按钮可用)
|
||||
renderMsgs();
|
||||
break;
|
||||
}
|
||||
case 'notice': {
|
||||
c.msgs.push({ role: 'sys', text: v.text || '' });
|
||||
renderMsgs();
|
||||
break;
|
||||
}
|
||||
}
|
||||
saveStore();
|
||||
}
|
||||
function lastAiStreaming(c) {
|
||||
for (var i = c.msgs.length - 1; i >= 0; i--) {
|
||||
var m = c.msgs[i];
|
||||
if (m.role === 'ai' && !m.done) return m;
|
||||
if (m.role === 'ai') return null; // 最新 ai 消息已完成 → 开新条
|
||||
}
|
||||
return null;
|
||||
}
|
||||
var liveEl = null;
|
||||
function appendLive(m) {
|
||||
if (!liveEl || !liveEl._m) {
|
||||
// 首个 delta:占位气泡(增量期间只更新最后一条,不全量重渲染,防抖动)
|
||||
msgsEl.appendChild(msgNode(m));
|
||||
liveEl = msgsEl.lastChild;
|
||||
liveEl._m = m;
|
||||
}
|
||||
liveEl.lastChild.innerHTML = fmtText(m.text);
|
||||
scrollBottom();
|
||||
}
|
||||
|
||||
/* ===== 生成态(发送/停止按钮) ===== */
|
||||
function setGenerating(on) {
|
||||
state.generating = on;
|
||||
sendBtn.style.display = on ? 'none' : '';
|
||||
stopBtn.style.display = on ? '' : 'none';
|
||||
sendBtn.disabled = on;
|
||||
}
|
||||
|
||||
/* ===== 发送/停止 ===== */
|
||||
function doSend() {
|
||||
var text = inpEl.value.trim(); if (!text) return;
|
||||
var c = ensureConv();
|
||||
if (c.msgs.length === 0) { c.name = text.slice(0, 18); renderConvList(); } // 首条消息作会话名
|
||||
c.msgs.push({ role: 'user', text: text });
|
||||
saveStore(); renderMsgs();
|
||||
inpEl.value = ''; inpEl.style.height = 'auto';
|
||||
if (!wsSend({ type: 'message', text: text })) {
|
||||
c.msgs.push({ role: 'sys', text: '⚠ 未连接,消息未发出(连接恢复后请重发)' }); renderMsgs();
|
||||
}
|
||||
}
|
||||
sendBtn.onclick = doSend;
|
||||
stopBtn.onclick = function () {
|
||||
var c = activeConv();
|
||||
// 本地止血:标记完成 + 提示(协议无 Stop 指令,桌面端继续跑完本轮,网关会继续推帧,本页忽略)
|
||||
if (c) {
|
||||
var m = lastAiStreaming(c);
|
||||
if (m) m.done = true;
|
||||
c.msgs.push({ role: 'sys', text: '■ 已停止显示(桌面端本轮可能仍在执行,结果以桌面端为准)' });
|
||||
saveStore(); renderMsgs();
|
||||
}
|
||||
setGenerating(false);
|
||||
};
|
||||
|
||||
/* ===== 输入框 ===== */
|
||||
inpEl.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); doSend(); }
|
||||
});
|
||||
inpEl.addEventListener('input', function () {
|
||||
inpEl.style.height = 'auto';
|
||||
inpEl.style.height = Math.min(inpEl.scrollHeight, 120) + 'px';
|
||||
});
|
||||
|
||||
/* ===== 顶栏按钮 ===== */
|
||||
$('#new-conv').onclick = function () { newConv(true); };
|
||||
$('#clear-btn').onclick = function () {
|
||||
var c = activeConv(); if (!c) return;
|
||||
if (!confirm('清空当前会话的页面记录?(不删桌面端对话)')) return;
|
||||
c.msgs = []; saveStore(); renderMsgs();
|
||||
};
|
||||
$('#menu-btn').onclick = function () { $('#sidebar').classList.toggle('open'); };
|
||||
function closeSidebar() { $('#sidebar').classList.remove('open'); }
|
||||
|
||||
/* ===== 启动 ===== */
|
||||
loadStore();
|
||||
if (!state.convs.length) newConv(false);
|
||||
if (!state.activeId) state.activeId = state.convs[0].id;
|
||||
renderConvList(); renderMsgs();
|
||||
connect();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -11,6 +11,9 @@ df-types = { path = "../df-types" }
|
||||
# 含 is_valid_state+can_transition+同态拒绝三层校验),避免 MCP 直调底层
|
||||
# advance_status_atomic 绕过状态机(防止外部客户端非法跳态 todo→done)。
|
||||
df-nodes = { path = "../df-nodes" }
|
||||
# df-project: bind_directory 复用技术栈探测(detect_stack)与路径规范化(normalize_path),
|
||||
# 对齐 GUI tool_registry.rs::bind_dir_to_project,避免在本 crate 重复实现镜像。
|
||||
df-project = { path = "../df-project" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
@@ -18,6 +21,10 @@ anyhow.workspace = true
|
||||
tracing.workspace = true
|
||||
uuid.workspace = true
|
||||
futures = "0.3"
|
||||
# HTTP (streamable HTTP) transport 层:axum Router(workspace 已锁 0.7.9,零新依赖树)
|
||||
axum = { version = "0.7" }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["full", "test-util"] }
|
||||
# 单测用 tower::ServiceExt::oneshot 直接打 Router
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
// df-mcp E2E 实测脚本:spawn 真实二进制,按行喂 JSON-RPC,断言修复项 + 全链路。
|
||||
// 用法: node df-mcp-e2e.mjs <exe路径> [--db <临时库>]
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createInterface } from 'node:readline';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const exe = process.argv[2];
|
||||
if (!exe) { console.error('用法: node df-mcp-e2e.mjs <exe路径>'); process.exit(1); }
|
||||
const dbArg = process.argv[4] ?? join(mkdtempSync(join(tmpdir(), 'dfmcp-')), 'test.db');
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
const failures = [];
|
||||
function assert(cond, name, extra) {
|
||||
if (cond) { pass++; console.log(` ✅ ${name}`); }
|
||||
else { fail++; failures.push(name); console.log(` ❌ ${name}${extra ? ` — ${extra}` : ''}`); }
|
||||
}
|
||||
|
||||
function session(dbPath, readOnly = false) {
|
||||
const args = ['mcp-server', '--db', dbPath];
|
||||
if (readOnly) args.push('--read-only');
|
||||
const child = spawn(exe, args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
const rl = createInterface({ input: child.stdout });
|
||||
const pending = [];
|
||||
const waiters = [];
|
||||
let buf = '';
|
||||
rl.on('line', (line) => {
|
||||
const resolved = waiters.shift();
|
||||
if (resolved) resolved(line);
|
||||
});
|
||||
child.stderr.on('data', (d) => process.stderr.write(` [server] ${d}`));
|
||||
let idSeq = 0;
|
||||
return {
|
||||
async call(req) {
|
||||
const payload = { jsonrpc: '2.0', id: ++idSeq, ...req };
|
||||
const p = new Promise((res) => waiters.push(res));
|
||||
child.stdin.write(JSON.stringify(payload) + '\n');
|
||||
const line = await p;
|
||||
return JSON.parse(line);
|
||||
},
|
||||
async raw(line) {
|
||||
const p = new Promise((res) => waiters.push(res));
|
||||
child.stdin.write(line + '\n');
|
||||
const out = await p;
|
||||
return JSON.parse(out);
|
||||
},
|
||||
close() { child.stdin.end(); },
|
||||
};
|
||||
}
|
||||
|
||||
// 从 CallToolResult 取文本
|
||||
function txt(res) {
|
||||
try { return res.result.content[0].text; } catch { return JSON.stringify(res); }
|
||||
}
|
||||
function json(res) { return JSON.parse(txt(res)); }
|
||||
|
||||
async function main() {
|
||||
console.log(`\n═══ df-mcp E2E 实测 exe=${exe} db=${dbArg} ═══`);
|
||||
|
||||
// ── 实例 A:读写模式 ──────────────────────────────
|
||||
const s = session(dbArg, false);
|
||||
console.log('\n【1】握手与工具清单');
|
||||
|
||||
const init = await s.call({ method: 'initialize', params: {} });
|
||||
assert(init.result.serverInfo.name === 'devflow-mcp', 'initialize 返回 serverInfo.name', init.result?.serverInfo?.name);
|
||||
assert(init.result.capabilities.tools.listChanged === false, 'capabilities.tools 声明');
|
||||
|
||||
const tl = await s.call({ method: 'tools/list', params: {} });
|
||||
const names = tl.result.tools.map(t => t.name);
|
||||
const low = ['list_projects','get_project','list_tasks','list_ideas','evaluate_idea','list_trash'];
|
||||
const med = ['create_project','update_project','bind_directory','create_task','update_task','advance_task','create_idea','update_idea','score_idea','restore_project'];
|
||||
const high = ['delete_project','delete_task','delete_idea','run_workflow'];
|
||||
assert(low.every(n => names.includes(n)), 'Low 工具全部可见');
|
||||
assert(med.every(n => names.includes(n)), 'Medium 工具全部可见');
|
||||
assert(!high.some(n => names.includes(n)), 'High 工具默认不可见');
|
||||
assert(names.length === low.length + med.length, `工具数 = ${low.length + med.length}`, `实际 ${names.length}`);
|
||||
|
||||
console.log('\n【2】修复项:parse error 带 id:null(修复#1)');
|
||||
const perr = await s.raw('{"jsonrpc":"2.0","id":999,"method":"bogus", BAD JSON');
|
||||
assert(perr.error && perr.error.code === -32700, '非法 JSON → PARSE_ERROR(-32700)');
|
||||
assert(perr.id === null, 'parse error 响应 id 必须为 null', `实际 ${JSON.stringify(perr.id)}`);
|
||||
|
||||
console.log('\n【3】修复项:tools/call 缺 name 明确报错(修复#7)');
|
||||
const noname = await s.call({ method: 'tools/call', params: { arguments: {} } });
|
||||
assert(noname.error && noname.error.code === -32601, '缺 name → METHOD_NOT_FOUND');
|
||||
assert(noname.error.message.includes('name'), '错误消息含 name 提示', noname.error.message);
|
||||
assert(!noname.error.message.includes('未知工具: '), '不是空名「未知工具: 」', noname.error.message);
|
||||
|
||||
console.log('\n【4】未知工具');
|
||||
const unk = await s.call({ method: 'tools/call', params: { name: 'nope', arguments: {} } });
|
||||
assert(txt(unk).includes('未知工具'), '未知工具报错', txt(unk));
|
||||
|
||||
console.log('\n【5】修复项:update_project 非法状态不再静默降级(修复#3)');
|
||||
const cp = await s.call({ method: 'tools/call', params: { name: 'create_project', arguments: { name: 'E2E项目', description: '实测' } } });
|
||||
assert(!cp.result.isError, 'create_project 成功', txt(cp));
|
||||
const proj = json(cp);
|
||||
const pid = proj.id;
|
||||
const badSt = await s.call({ method: 'tools/call', params: { name: 'update_project', arguments: { id: pid, status: 'bogus_state' } } });
|
||||
assert(badSt.result.isError === true, 'update_project 非法 status 报错');
|
||||
assert(txt(badSt).includes('非法状态值'), '错误信息含"非法状态值"', txt(badSt));
|
||||
|
||||
console.log('\n【6】update_project 部分更新保留原值');
|
||||
const upd = await s.call({ method: 'tools/call', params: { name: 'update_project', arguments: { id: pid, name: 'E2E项目改' } } });
|
||||
assert(!upd.result.isError, '部分更新成功');
|
||||
const updJ = json(upd);
|
||||
assert(updJ.project.name === 'E2E项目改' && updJ.project.status === 'planning', 'name 更新 + status 保留 planning', `status=${updJ.project.status}`);
|
||||
|
||||
console.log('\n【7】create_task 带 parent_id(外部新增能力)');
|
||||
const cproj = await s.call({ method: 'tools/call', params: { name: 'list_projects', arguments: {} } });
|
||||
const listJ = json(cproj);
|
||||
const parentT = await s.call({ method: 'tools/call', params: { name: 'create_task', arguments: { project_id: pid, title: '父任务', description: '' } } });
|
||||
const parentId = json(parentT).id;
|
||||
const childT = await s.call({ method: 'tools/call', params: { name: 'create_task', arguments: { project_id: pid, title: '子任务', description: '', parent_id: parentId } } });
|
||||
assert(!childT.result.isError, '创建子任务成功', txt(childT));
|
||||
const grandchild = await s.call({ method: 'tools/call', params: { name: 'create_task', arguments: { project_id: pid, title: '孙任务', description: '', parent_id: json(childT).id } } });
|
||||
assert(grandchild.result.isError === true && txt(grandchild).includes('父任务不能是子任务'), '1 级嵌套限制:孙任务被拒', txt(grandchild));
|
||||
const badP = await s.call({ method: 'tools/call', params: { name: 'create_task', arguments: { project_id: pid, title: 'x', description: '', parent_id: 'ghost' } } });
|
||||
assert(badP.result.isError === true && txt(badP).includes('父任务不存在'), '父任务不存在被拒', txt(badP));
|
||||
|
||||
console.log('\n【8】advance_task 状态机');
|
||||
const adv = await s.call({ method: 'tools/call', params: { name: 'advance_task', arguments: { id: parentId, to: 'in_progress' } } });
|
||||
assert(!adv.result.isError && json(adv).task.status === 'in_progress', 'todo→in_progress 成功');
|
||||
const badAdv = await s.call({ method: 'tools/call', params: { name: 'advance_task', arguments: { id: parentId, to: 'done' } } });
|
||||
assert(badAdv.result.isError === true, 'todo→done 非法跳态被拒', txt(badAdv));
|
||||
|
||||
console.log('\n【9】idea 闭环:evaluate(Low只读) → score(Medium写库)');
|
||||
const ci = await s.call({ method: 'tools/call', params: { name: 'create_idea', arguments: { title: '核心功能', description: '需要立即重构关键模块以解除阻塞' } } });
|
||||
const iid = json(ci).id;
|
||||
const ev = await s.call({ method: 'tools/call', params: { name: 'evaluate_idea', arguments: { id: iid } } });
|
||||
assert(!ev.result.isError && json(ev).scores.overall >= 0, 'evaluate_idea 返回分数');
|
||||
const evJ = json(ev);
|
||||
assert(evJ.idea.scores == null, 'evaluate_idea 不写库(scores 仍 null)');
|
||||
const sc = await s.call({ method: 'tools/call', params: { name: 'score_idea', arguments: { id: iid } } });
|
||||
const scJ = json(sc);
|
||||
assert(!sc.result.isError && scJ.idea.scores != null, 'score_idea 写库成功');
|
||||
|
||||
console.log('\n【10】bind_directory:冲突/穿越/不存在路径');
|
||||
const bd1 = await s.call({ method: 'tools/call', params: { name: 'bind_directory', arguments: { id: pid, path: 'C:/Temp/nonexist_dir' } } });
|
||||
assert(!bd1.result.isError, '绑定不存在目录成功(规范化为绝对路径)');
|
||||
const bdConflict = await s.call({ method: 'tools/call', params: { name: 'create_project', arguments: { name: 'E2E项目2', description: '' } } });
|
||||
const pid2 = json(bdConflict).id;
|
||||
const bd2 = await s.call({ method: 'tools/call', params: { name: 'bind_directory', arguments: { id: pid2, path: 'c:/temp/nonexist_dir' } } });
|
||||
assert(bd2.result.isError === true && txt(bd2).includes('路径已被项目'), '路径冲突检测(大小写不敏感)', txt(bd2));
|
||||
const bdTraverse = await s.call({ method: 'tools/call', params: { name: 'bind_directory', arguments: { id: pid2, path: 'C:/temp/../Windows' } } });
|
||||
assert(bdTraverse.result.isError === true && txt(bdTraverse).includes('..'), '.. 段拒绝', txt(bdTraverse));
|
||||
|
||||
console.log('\n【11】回收站 list_trash / restore_project');
|
||||
const lt = await s.call({ method: 'tools/call', params: { name: 'list_trash', arguments: {} } });
|
||||
assert(!lt.result.isError, 'list_trash 成功');
|
||||
const rest = await s.call({ method: 'tools/call', params: { name: 'restore_project', arguments: { id: 'ghost' } } });
|
||||
assert(rest.result.isError === true, '恢复不存在的项目报错', txt(rest));
|
||||
|
||||
console.log('\n【12】跨实体校验');
|
||||
const cross = await s.call({ method: 'tools/call', params: { name: 'update_task', arguments: { id: iid, title: 'x' } } });
|
||||
assert(cross.result.isError === true && txt(cross).includes('属于 idea'), 'update_task 传 idea id → 跨实体报错', txt(cross));
|
||||
|
||||
console.log('\n【13】High 工具即便绕过 tools/list 也被拒');
|
||||
const del = await s.call({ method: 'tools/call', params: { name: 'delete_project', arguments: { id: pid } } });
|
||||
assert(del.result.isError === true && txt(del).includes('DevFlow'), 'delete_project 默认拒绝', txt(del));
|
||||
|
||||
s.close();
|
||||
|
||||
// ── 实例 B:read-only 模式 ─────────────────────────
|
||||
console.log('\n════ read-only 模式 ════');
|
||||
const ro = session(dbArg, true);
|
||||
const roList = await ro.call({ method: 'tools/list', params: {} });
|
||||
const roNames = roList.result.tools.map(t => t.name);
|
||||
assert(roNames.length === low.length, `read-only 仅 Low(${low.length} 个)`, `实际 ${roNames.length}`);
|
||||
assert(roNames.every(n => low.includes(n)), 'read-only 只含 Low 工具');
|
||||
const roCall = await ro.call({ method: 'tools/call', params: { name: 'create_project', arguments: { name: 'x', description: '' } } });
|
||||
assert(roCall.result.isError === true && txt(roCall).includes('只读模式'), 'read-only 拒绝 Medium', txt(roCall));
|
||||
const roEval = await ro.call({ method: 'tools/call', params: { name: 'evaluate_idea', arguments: { id: iid } } });
|
||||
assert(!roEval.result.isError, 'read-only 放行 evaluate_idea(Low)');
|
||||
ro.close();
|
||||
|
||||
try { await new Promise(r => setTimeout(r, 300)); rmSync(join(dbArg, '..'), { recursive: true, force: true }); } catch {}
|
||||
|
||||
console.log(`\n═══ 结果: ${pass} 通过 / ${fail} 失败 ═══`);
|
||||
if (failures.length) { console.log('失败项:'); failures.forEach(f => console.log(` - ${f}`)); process.exit(1); }
|
||||
console.log('✅ 全部通过');
|
||||
}
|
||||
|
||||
main().catch(e => { console.error('脚本异常:', e); process.exit(1); });
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
pub mod protocol;
|
||||
pub mod server;
|
||||
pub mod server_http;
|
||||
pub mod tools;
|
||||
|
||||
pub use server::run_server;
|
||||
pub use server::{run_server, ServerConfig, MCP_DATA_CHANGED_KEY};
|
||||
|
||||
@@ -118,6 +118,9 @@ impl McpMethod {
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_owned();
|
||||
if name.is_empty() {
|
||||
return McpMethod::Unknown("tools/call missing required 'name' parameter".to_owned());
|
||||
}
|
||||
let arguments = req.params.get("arguments").cloned().unwrap_or(Value::Null);
|
||||
McpMethod::ToolsCall { name, arguments }
|
||||
}
|
||||
|
||||
+454
-26
@@ -8,10 +8,13 @@
|
||||
//!
|
||||
//! 高风险(High)工具:tools/list 不暴露(从清单剔除),tools/call 即便绕过也由 handler 兜底拒绝。
|
||||
//! read-only:tools/list 仅留 Low,tools/call Medium/High 一律拒绝。
|
||||
//! 生命周期:空闲超时(默认 60s 无请求)自动退出,防客户端强杀后进程残留;写操作(risk != Low)可选回调(stdio 预留接线点)。
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use df_storage::crud::SettingsRepo;
|
||||
use df_storage::db::Database;
|
||||
use serde_json::{json, Value};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
@@ -23,35 +26,76 @@ use crate::protocol::{
|
||||
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");
|
||||
pub(crate) const PROTOCOL_VERSION: &str = "2025-06-18";
|
||||
pub(crate) const SERVER_NAME: &str = "devflow-mcp";
|
||||
pub(crate) const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
/// 启动 MCP server。
|
||||
/// app_settings KV key:stdio MCP 写库后落的变更标记,供 GUI 侧轮询兜底消费。
|
||||
///
|
||||
/// 独立进程形态的 stdio MCP 无 AppHandle(`on_write_call=None`),写库后 GUI 无从经
|
||||
/// `df-data-changed` 事件感知,造成双端数据断层(P0-2)。此标记是跨进程的落库信令:
|
||||
/// 每次成功写工具覆盖写入,值为 `{"at": <毫秒时间戳>, "tool": "<工具名>"}`。
|
||||
/// GUI 侧轮询读此 key,`at` 变化即知库被外部改动;再按 `tool` 复用 df-data-changed
|
||||
/// 的 entity 映射刷新对应列表(entity 映射留在 GUI 侧,避免本 crate 引入 df-ai 依赖)。
|
||||
pub const MCP_DATA_CHANGED_KEY: &str = "mcp.data_changed";
|
||||
|
||||
/// MCP server 运行配置。
|
||||
///
|
||||
/// 聚合 stdio 形态的运行参数,避免 `run_server` 参数膨胀。
|
||||
/// [`Default`] 即常用生产配置(可写 + 空闲超时 60s + 无写回调)。
|
||||
pub struct ServerConfig {
|
||||
/// 只读模式:true 则仅暴露 list/get 工具,Medium/High 写操作一律拒绝。
|
||||
pub read_only: bool,
|
||||
/// 空闲超时:连续 N 时长无 stdin 输入则自动退出进程(防客户端强杀后孤儿残留)。
|
||||
/// `None` = 永不因空闲退出(常驻)。默认 60s。
|
||||
pub idle_timeout: Option<Duration>,
|
||||
/// 写操作回调:成功执行(未被 read-only/High 拒绝)的写工具(risk != Low)触发。
|
||||
/// 桌面内嵌形态经 server_http → on_tool_call → emit df-data-changed 刷新 GUI;
|
||||
/// stdio 独立进程暂无 AppHandle,传 None 预留接线点,后续可注入。传 None 时由
|
||||
/// 落库变更标记([`MCP_DATA_CHANGED_KEY`])兜底,供 GUI 侧轮询消费。
|
||||
pub on_write_call: Option<Arc<dyn Fn(&str) + Send + Sync>>,
|
||||
}
|
||||
|
||||
impl Default for ServerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
read_only: false,
|
||||
idle_timeout: Some(Duration::from_secs(60)),
|
||||
on_write_call: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动 MCP server(stdin/stdout 接实际进程句柄)。
|
||||
///
|
||||
/// - `db_path`:SQLite 数据库文件路径(应用同库,开 WAL 模式)
|
||||
/// - `read_only`:true 则仅暴露 list/get 工具
|
||||
/// - `config`:运行配置(只读/空闲超时/写操作回调),见 [`ServerConfig`]
|
||||
///
|
||||
/// 复用 [`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<()> {
|
||||
pub async fn run_server(db_path: &Path, config: ServerConfig) -> 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
|
||||
main_loop(stdin, stdout, &ctx, &config).await
|
||||
}
|
||||
|
||||
/// 可单测的主循环(参数化 stdin/stdout)。
|
||||
///
|
||||
/// 协议正确性:每行一个 JSON-RPC 消息,Response 单行写回(末尾 \n)。
|
||||
/// Notification(id=None)不回响应。
|
||||
///
|
||||
/// 生命周期:
|
||||
/// - 空闲超时:仅在**等待下一个请求**(`read_line`)时计时;正在处理的请求不受影响,
|
||||
/// 超时即 break 优雅退出(防客户端强杀后进程残留)。
|
||||
/// - 写操作回调:成功执行的写工具(risk != Low)在响应写回后触发,供外部(GUI)感知数据变更。
|
||||
pub async fn main_loop<R, W>(
|
||||
stdin: R,
|
||||
stdout: W,
|
||||
ctx: &Ctx,
|
||||
read_only: bool,
|
||||
config: &ServerConfig,
|
||||
) -> anyhow::Result<()>
|
||||
where
|
||||
R: tokio::io::AsyncRead + Unpin,
|
||||
@@ -63,7 +107,17 @@ where
|
||||
|
||||
loop {
|
||||
line.clear();
|
||||
let n = reader.read_line(&mut line).await?;
|
||||
// 空闲超时:只包裹「等待下一请求」,不包裹 dispatch/写响应,处理期间绝不误杀。
|
||||
let n = match config.idle_timeout {
|
||||
Some(dur) => match tokio::time::timeout(dur, reader.read_line(&mut line)).await {
|
||||
Ok(n) => n,
|
||||
Err(_elapsed) => {
|
||||
tracing::info!(target: "df_mcp", idle_secs = dur.as_secs(), "空闲超时无新请求,自动退出");
|
||||
break;
|
||||
}
|
||||
}?,
|
||||
None => reader.read_line(&mut line).await?,
|
||||
};
|
||||
if n == 0 {
|
||||
// EOF(stdin 关闭),优雅退出
|
||||
break;
|
||||
@@ -79,7 +133,7 @@ where
|
||||
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);
|
||||
let resp = Response::err(Some(Value::Null), crate::protocol::PARSE_ERROR, "Parse error", None);
|
||||
write_response(&mut writer, &resp).await?;
|
||||
continue;
|
||||
}
|
||||
@@ -101,17 +155,53 @@ where
|
||||
continue;
|
||||
}
|
||||
|
||||
let resp = dispatch(ctx, read_only, req.id.clone(), method).await;
|
||||
// 写回调需在 dispatch 后判定,先预取工具名(仅 tools/call 需要,避免 clone 整包请求)
|
||||
let tool_name = match &method {
|
||||
McpMethod::ToolsCall { name, .. } => Some(name.clone()),
|
||||
_ => None,
|
||||
};
|
||||
let resp = dispatch(ctx, config.read_only, req.id.clone(), method).await;
|
||||
write_response(&mut writer, &resp).await?;
|
||||
if let Some(name) = tool_name {
|
||||
// MC-5仅业务成功才触发写回调——防业务失败(如 update_project 非法 status、
|
||||
// create_project 空名等 result.isError=true)仍假触发 df-data-changed。
|
||||
if is_success_tool_call(&resp) && is_executed_write(&name, config.read_only) {
|
||||
// 多进程兜底:stdio 形态无 AppHandle,先落库内变更标记,供 GUI 侧轮询消费
|
||||
// (即便未注入回调也不丢变更感知);再触发进程内回调(HTTP 内嵌形态走事件)。
|
||||
record_data_change(ctx, &name).await;
|
||||
if let Some(cb) = &config.on_write_call {
|
||||
cb(&name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 判定 tools/call 响应是否为「业务成功」(写回调触发条件)。
|
||||
///
|
||||
/// - JSON-RPC 层 `error` 非空(协议错/未识别方法)→ 失败
|
||||
/// - `result` 内 `isError=true`(MCP 业务错,handler 返 `CallToolResult::error`)→ 失败
|
||||
/// - 其余(正常 result / initialize/ping 等非 tools/call)→ 成功
|
||||
///
|
||||
/// stdio(main_loop)与 HTTP(server_http on_tool_call)共用此判定,
|
||||
/// 保证两 transport 对「业务失败不触发写回调」语义一致。
|
||||
pub(crate) fn is_success_tool_call(resp: &Response) -> bool {
|
||||
if resp.error.is_some() {
|
||||
return false;
|
||||
}
|
||||
match &resp.result {
|
||||
Some(v) => !matches!(v.get("isError"), Some(Value::Bool(true))),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 方法分发 → 构造 Response。
|
||||
///
|
||||
/// `id`:JSON-RPC 请求 id(回响应时原样回填;通知由 main_loop 已过滤)。
|
||||
async fn dispatch(ctx: &Ctx, read_only: bool, id: Option<Value>, method: McpMethod) -> Response {
|
||||
/// `pub(crate)`:stdio(main_loop)与 HTTP(server_http)transport 共用。
|
||||
pub(crate) async fn dispatch(ctx: &Ctx, read_only: bool, id: Option<Value>, method: McpMethod) -> Response {
|
||||
match method {
|
||||
McpMethod::Initialize { .. } => {
|
||||
let result = InitializeResult {
|
||||
@@ -135,9 +225,10 @@ async fn dispatch(ctx: &Ctx, read_only: bool, id: Option<Value>, method: McpMeth
|
||||
}
|
||||
McpMethod::ToolsList => {
|
||||
let tools: Vec<_> = tools::all_tools()
|
||||
.into_iter()
|
||||
.iter()
|
||||
.filter(|t| visible(read_only, t.risk))
|
||||
.map(|t| serde_json::to_value(&t.tool).unwrap_or(Value::Null))
|
||||
.filter(|v| !v.is_null())
|
||||
.collect();
|
||||
Response::ok(id, json!({ "tools": tools }))
|
||||
}
|
||||
@@ -151,22 +242,30 @@ async fn dispatch(ctx: &Ctx, read_only: bool, id: Option<Value>, method: McpMeth
|
||||
.unwrap_or(Value::Null),
|
||||
);
|
||||
};
|
||||
// read-only 模式:Medium/High 全拒
|
||||
if read_only && spec.risk != RiskLevel::Low {
|
||||
// 执行前防御:read-only 拒 Medium/High;High 兜底拒(handler 内也会拒,双保险)。
|
||||
// 判定收口到 should_execute,与 main_loop 写回调共用同一事实源,避免两份逻辑漂移。
|
||||
if !should_execute(read_only, spec.risk) {
|
||||
let msg = if read_only && spec.risk != RiskLevel::Low {
|
||||
format!("只读模式拒绝执行 {name}(风险等级 {:?})", spec.risk)
|
||||
} else {
|
||||
format!("High 风险操作 {name} 默认拒绝,请在 DevFlow 应用内执行。")
|
||||
};
|
||||
let r = CallToolResult::error(msg);
|
||||
return Response::ok(id, serde_json::to_value(r).unwrap_or(Value::Null));
|
||||
}
|
||||
// Low / Medium:执行前先校验 schema 必填参数(缺必填直接拒,
|
||||
// 防 handler 内 arg_str_or 静默兜底把缺参当空串/默认值写入)
|
||||
let missing: Vec<String> = required_names(&spec.tool.input_schema)
|
||||
.into_iter()
|
||||
.filter(|name| arguments.get(name).map_or(true, |v| v.is_null()))
|
||||
.collect();
|
||||
if !missing.is_empty() {
|
||||
let r = CallToolResult::error(format!(
|
||||
"只读模式拒绝执行 {name}(风险等级 {:?})",
|
||||
spec.risk
|
||||
"缺少必填参数: {}",
|
||||
missing.join(", ")
|
||||
));
|
||||
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))
|
||||
}
|
||||
@@ -179,8 +278,66 @@ async fn dispatch(ctx: &Ctx, read_only: bool, id: Option<Value>, method: McpMeth
|
||||
}
|
||||
}
|
||||
|
||||
/// 工具是否会被执行(dispatch 与 main_loop 写回调共用的判定)。
|
||||
///
|
||||
/// read-only 下仅 Low 可执行;非 read-only 下 High 仍兜底拒绝。两条件都过 → 可执行。
|
||||
fn should_execute(read_only: bool, risk: RiskLevel) -> bool {
|
||||
!(read_only && risk != RiskLevel::Low) && risk != RiskLevel::High
|
||||
}
|
||||
|
||||
/// 从工具 inputSchema 提取必填参数名列表。
|
||||
///
|
||||
/// 兼容 MCP schema required 的两种形态:array 显式列出必填属性;bool true 表示全部
|
||||
/// properties 必填;false/缺失表示无必填。供 tools/call 执行前校验参数完整性,
|
||||
/// 缺必填直接拒绝,杜绝 handler 内 arg_str_or 静默兜底把缺参当空串/默认值写入。
|
||||
fn required_names(schema: &Value) -> Vec<String> {
|
||||
match schema.get("required") {
|
||||
Some(Value::Array(items)) => items
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_owned()))
|
||||
.collect(),
|
||||
Some(Value::Bool(true)) => {
|
||||
let mut names: Vec<String> = schema
|
||||
.get("properties")
|
||||
.and_then(|p| p.as_object())
|
||||
.map(|props| props.keys().cloned().collect())
|
||||
.unwrap_or_default();
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 判定工具是否属于「会执行的成功写操作」:risk != Low 且未被 read-only/High 拒绝。
|
||||
///
|
||||
/// 变更标记与进程内写回调共用此判定,保证两通道对「哪些写操作需通知 GUI」语义一致;
|
||||
/// 与 dispatch 的执行判定(`should_execute`)同源,避免逻辑漂移。
|
||||
fn is_executed_write(name: &str, read_only: bool) -> bool {
|
||||
match tools::find(name) {
|
||||
Some(spec) => spec.risk != RiskLevel::Low && should_execute(read_only, spec.risk),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 写成功后向 [`MCP_DATA_CHANGED_KEY`] 覆盖写入一条变更标记(多进程兜底信令)。
|
||||
///
|
||||
/// 独立进程形态完成写库后,GUI 无法经事件感知;此标记是落库的持久信令,GUI 轮询读
|
||||
/// 该 key、`at` 变化即知库被外部改动。写失败仅告警不阻断(工具调用已成功,下次成功
|
||||
/// 写会再次覆盖,标记只作「有变更」的粗粒度提示,非精确事件队列)。
|
||||
async fn record_data_change(ctx: &Ctx, tool_name: &str) {
|
||||
let at = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis())
|
||||
.unwrap_or(0);
|
||||
let value = json!({ "at": at, "tool": tool_name }).to_string();
|
||||
if let Err(e) = SettingsRepo::new(&ctx.db).set(MCP_DATA_CHANGED_KEY, &value).await {
|
||||
tracing::warn!(target: "df_mcp", tool = tool_name, err = %e, "写变更标记失败(不改工具调用结果)");
|
||||
}
|
||||
}
|
||||
|
||||
/// 工具可见性:read-only 仅 Low,否则 Low + Medium(High 永不可见)
|
||||
fn visible(read_only: bool, risk: RiskLevel) -> bool {
|
||||
pub(crate) fn visible(read_only: bool, risk: RiskLevel) -> bool {
|
||||
if read_only {
|
||||
risk == RiskLevel::Low
|
||||
} else {
|
||||
@@ -211,6 +368,8 @@ async fn write_response<W: tokio::io::AsyncWrite + Unpin>(
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::tools::RiskLevel;
|
||||
use std::sync::Mutex;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
/// 构造内存 DB + Ctx
|
||||
async fn test_ctx() -> Ctx {
|
||||
@@ -287,6 +446,22 @@ mod tests {
|
||||
assert_eq!(v["error"]["code"], crate::protocol::METHOD_NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tools_call_missing_name_returns_clear_error() {
|
||||
// tools/call 缺 name 参数:不应回「未知工具: 」(空名),应回明确 METHOD_NOT_FOUND
|
||||
let input =
|
||||
r#"{"jsonrpc":"2.0","id":41,"method":"tools/call","params":{"arguments":{}}}"#;
|
||||
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);
|
||||
let msg = v["error"]["message"].as_str().unwrap();
|
||||
assert!(
|
||||
msg.contains("name"),
|
||||
"空 name 应给出明确提示,实际: {msg}"
|
||||
);
|
||||
assert!(!msg.contains("未知工具: "), "不应是空名「未知工具: 」: {msg}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tools_call_high_risk_is_rejected() {
|
||||
let input =
|
||||
@@ -331,6 +506,54 @@ mod tests {
|
||||
assert_eq!(list_v["projects"][0]["name"], "McpProj");
|
||||
}
|
||||
|
||||
// ── schema required 校验(dispatch 层缺必填直接拒)────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn tools_call_missing_required_arg_returns_error() {
|
||||
// create_project schema required 含 name:缺 name 应被 dispatch 层拒绝
|
||||
// (而非 handler 内 arg_str_or 静默兜底把 description 当空串)
|
||||
let input =
|
||||
r#"{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"create_project","arguments":{"description":"缺 name"}}}"#;
|
||||
let out = run_io_lines(&[input], false).await;
|
||||
let v: Value = serde_json::from_str(&out[0]).unwrap();
|
||||
assert_eq!(v["result"]["isError"], true);
|
||||
let text = v["result"]["content"][0]["text"].as_str().unwrap();
|
||||
assert!(text.contains("缺少必填参数: name"), "实际: {text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn required_names_from_array() {
|
||||
let schema = json!({
|
||||
"type": "object",
|
||||
"properties": { "id": {}, "name": {} },
|
||||
"required": ["id", "name"]
|
||||
});
|
||||
assert_eq!(required_names(&schema), vec!["id".to_string(), "name".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn required_names_from_bool_true_means_all_properties() {
|
||||
let schema = json!({
|
||||
"type": "object",
|
||||
"properties": { "id": {}, "name": {}, "desc": {} },
|
||||
"required": true
|
||||
});
|
||||
// bool true = 全部 properties 必填,返回全部属性名(排序保证确定性)
|
||||
assert_eq!(
|
||||
required_names(&schema),
|
||||
vec!["desc".to_string(), "id".to_string(), "name".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn required_names_false_or_missing_is_empty() {
|
||||
assert_eq!(required_names(&json!({ "type": "object" })), Vec::<String>::new());
|
||||
assert_eq!(
|
||||
required_names(&json!({ "type": "object", "required": false })),
|
||||
Vec::<String>::new()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn visible_predicate() {
|
||||
assert!(visible(false, RiskLevel::Low));
|
||||
@@ -340,4 +563,209 @@ mod tests {
|
||||
assert!(!visible(true, RiskLevel::Medium));
|
||||
assert!(!visible(true, RiskLevel::High));
|
||||
}
|
||||
|
||||
// ── should_execute 判定(dispatch 与写回调共用)────────────────────
|
||||
|
||||
#[test]
|
||||
fn should_execute_predicate() {
|
||||
assert!(should_execute(false, RiskLevel::Low));
|
||||
assert!(should_execute(false, RiskLevel::Medium));
|
||||
assert!(!should_execute(false, RiskLevel::High));
|
||||
assert!(should_execute(true, RiskLevel::Low));
|
||||
assert!(!should_execute(true, RiskLevel::Medium));
|
||||
assert!(!should_execute(true, RiskLevel::High));
|
||||
}
|
||||
|
||||
// ── 空闲超时 / 写操作回调(main_loop 集成)────────────────────────
|
||||
|
||||
/// 构造测试配置:只读开关 + 默认空闲超时 + 默认无回调(字段可覆盖)
|
||||
async fn test_config(read_only: bool) -> ServerConfig {
|
||||
ServerConfig {
|
||||
read_only,
|
||||
idle_timeout: Some(Duration::from_secs(60)),
|
||||
on_write_call: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn main_loop_exits_on_idle_timeout() {
|
||||
let ctx = test_ctx().await;
|
||||
// 用 duplex 造一个「开着但永不写数据」的 stdin:read_line 会一直挂起,
|
||||
// 空闲超时(100ms)触发后应正常 break 退出,而非阻塞或 panic。
|
||||
let (_tx, rx) = tokio::io::duplex(1024);
|
||||
let config = ServerConfig {
|
||||
idle_timeout: Some(Duration::from_millis(100)),
|
||||
..test_config(false).await
|
||||
};
|
||||
let outer = tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
main_loop(rx, tokio::io::sink(), &ctx, &config),
|
||||
)
|
||||
.await;
|
||||
let inner = outer.expect("main_loop 应在空闲超时后返回,而非一直阻塞");
|
||||
assert!(inner.is_ok(), "空闲超时退出应为 Ok,实际: {inner:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn main_loop_fires_write_callback_for_write_tool() {
|
||||
let ctx = test_ctx().await;
|
||||
let calls: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let calls_cb = calls.clone();
|
||||
let config = ServerConfig {
|
||||
on_write_call: Some(Arc::new(move |name| {
|
||||
calls_cb.lock().unwrap().push(name.to_string());
|
||||
})),
|
||||
..test_config(false).await
|
||||
};
|
||||
let (mut tx, rx) = tokio::io::duplex(1024);
|
||||
let line = r#"{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"create_project","arguments":{"name":"McpProj","description":"via mcp"}}}"#;
|
||||
tx.write_all(format!("{line}\n").as_bytes()).await.unwrap();
|
||||
drop(tx); // 关 stdin → 处理后 EOF,正常退出
|
||||
main_loop(rx, tokio::io::sink(), &ctx, &config)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
calls.lock().unwrap().contains(&"create_project".to_string()),
|
||||
"写工具 create_project 应触发写回调,实际: {:?}",
|
||||
*calls.lock().unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn main_loop_no_write_callback_for_read_tool() {
|
||||
let ctx = test_ctx().await;
|
||||
let calls: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let calls_cb = calls.clone();
|
||||
let config = ServerConfig {
|
||||
on_write_call: Some(Arc::new(move |name| {
|
||||
calls_cb.lock().unwrap().push(name.to_string());
|
||||
})),
|
||||
..test_config(false).await
|
||||
};
|
||||
let (mut tx, rx) = tokio::io::duplex(1024);
|
||||
let line = r#"{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"list_projects","arguments":{}}}"#;
|
||||
tx.write_all(format!("{line}\n").as_bytes()).await.unwrap();
|
||||
drop(tx);
|
||||
main_loop(rx, tokio::io::sink(), &ctx, &config)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
calls.lock().unwrap().is_empty(),
|
||||
"只读工具 list_projects 不应触发写回调,实际: {:?}",
|
||||
*calls.lock().unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn main_loop_no_write_callback_when_read_only_denies_write() {
|
||||
let ctx = test_ctx().await;
|
||||
let calls: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let calls_cb = calls.clone();
|
||||
let config = ServerConfig {
|
||||
read_only: true,
|
||||
on_write_call: Some(Arc::new(move |name| {
|
||||
calls_cb.lock().unwrap().push(name.to_string());
|
||||
})),
|
||||
..test_config(true).await
|
||||
};
|
||||
let (mut tx, rx) = tokio::io::duplex(1024);
|
||||
let line = r#"{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"create_project","arguments":{"name":"X","description":"d"}}}"#;
|
||||
tx.write_all(format!("{line}\n").as_bytes()).await.unwrap();
|
||||
drop(tx);
|
||||
main_loop(rx, tokio::io::sink(), &ctx, &config)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
calls.lock().unwrap().is_empty(),
|
||||
"read-only 下 Medium 写被拒,不应触发写回调,实际: {:?}",
|
||||
*calls.lock().unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn main_loop_no_write_callback_on_business_failure() {
|
||||
// MC-5业务失败(handler 返 isError=true)不得触发写回调(防假 df-data-changed)。
|
||||
// create_project 空名(name 为纯空白)→ MC-6 拒空 → isError=true。
|
||||
let ctx = test_ctx().await;
|
||||
let calls: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let calls_cb = calls.clone();
|
||||
let config = ServerConfig {
|
||||
on_write_call: Some(Arc::new(move |name| {
|
||||
calls_cb.lock().unwrap().push(name.to_string());
|
||||
})),
|
||||
..test_config(false).await
|
||||
};
|
||||
let (mut tx, rx) = tokio::io::duplex(1024);
|
||||
let line = r#"{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"create_project","arguments":{"name":" ","description":"d"}}}"#;
|
||||
tx.write_all(format!("{line}\n").as_bytes()).await.unwrap();
|
||||
drop(tx);
|
||||
main_loop(rx, tokio::io::sink(), &ctx, &config)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
calls.lock().unwrap().is_empty(),
|
||||
"业务失败(isError=true)不应触发写回调,实际: {:?}",
|
||||
*calls.lock().unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
// ── 多进程兜底:stdio 写库后落变更标记 ────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn main_loop_records_data_change_marker_for_write_tool() {
|
||||
// stdio 形态(on_write_call=None)成功写工具后应向 KV 落变更标记,
|
||||
// 供 GUI 轮询消费——即便无任何回调注入也不丢变更感知(P0-2 兜底)。
|
||||
let ctx = test_ctx().await;
|
||||
let (mut tx, rx) = tokio::io::duplex(1024);
|
||||
let line = r#"{"jsonrpc":"2.0","id":11,"method":"tools/call","params":{"name":"create_project","arguments":{"name":"MarkProj","description":"d"}}}"#;
|
||||
tx.write_all(format!("{line}\n").as_bytes()).await.unwrap();
|
||||
drop(tx);
|
||||
main_loop(rx, tokio::io::sink(), &ctx, &test_config(false).await)
|
||||
.await
|
||||
.unwrap();
|
||||
let v = SettingsRepo::new(&ctx.db)
|
||||
.get(MCP_DATA_CHANGED_KEY)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("写工具成功后应落变更标记");
|
||||
let parsed: Value = serde_json::from_str(&v).unwrap();
|
||||
assert_eq!(parsed["tool"], "create_project");
|
||||
assert!(parsed["at"].as_u64().unwrap() > 0, "标记应含毫秒时间戳: {v}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn main_loop_no_data_change_marker_for_read_tool() {
|
||||
// 只读工具无副作用,不应落变更标记(否则 GUI 无谓刷新)。
|
||||
let ctx = test_ctx().await;
|
||||
let (mut tx, rx) = tokio::io::duplex(1024);
|
||||
let line = r#"{"jsonrpc":"2.0","id":12,"method":"tools/call","params":{"name":"list_projects","arguments":{}}}"#;
|
||||
tx.write_all(format!("{line}\n").as_bytes()).await.unwrap();
|
||||
drop(tx);
|
||||
main_loop(rx, tokio::io::sink(), &ctx, &test_config(false).await)
|
||||
.await
|
||||
.unwrap();
|
||||
let v = SettingsRepo::new(&ctx.db)
|
||||
.get(MCP_DATA_CHANGED_KEY)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(v.is_none(), "只读工具不应落变更标记,实际: {v:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn main_loop_no_data_change_marker_when_read_only_denies_write() {
|
||||
// read-only 下写被拒(未真正写库),不应落变更标记。
|
||||
let ctx = test_ctx().await;
|
||||
let (mut tx, rx) = tokio::io::duplex(1024);
|
||||
let line = r#"{"jsonrpc":"2.0","id":13,"method":"tools/call","params":{"name":"create_project","arguments":{"name":"X","description":"d"}}}"#;
|
||||
tx.write_all(format!("{line}\n").as_bytes()).await.unwrap();
|
||||
drop(tx);
|
||||
main_loop(rx, tokio::io::sink(), &ctx, &test_config(true).await)
|
||||
.await
|
||||
.unwrap();
|
||||
let v = SettingsRepo::new(&ctx.db)
|
||||
.get(MCP_DATA_CHANGED_KEY)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(v.is_none(), "read-only 下写被拒不应落变更标记,实际: {v:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
//! HTTP (streamable HTTP, 2025-06-18) transport 层。
|
||||
//!
|
||||
//! 桌面进程内嵌 server 的传输层:POST /mcp 单响应 JSON;GET 405;notification 202 无 body。
|
||||
//! 与 stdio transport 共享同一 dispatch/handler/Ctx,纯 df-mcp 内部实现,零 tauri 依赖。
|
||||
//!
|
||||
//! 协议合规(streamable HTTP 2025-06-18):
|
||||
//! - POST /mcp → `application/json` 单响应(dispatch 层错误随 JSON-RPC 错误体回,HTTP 仍 200)
|
||||
//! - GET /mcp → 405 + `Allow: POST`
|
||||
//! - notification(id 缺省)→ 202 Accepted 无 body
|
||||
//! - batch 数组 body → 400 + INVALID_REQUEST(本 server 不支持 batch)
|
||||
//! - 非法 JSON body → 400 + PARSE_ERROR
|
||||
//! - 无状态 server:忽略 Mcp-Session-Id 头(合规)
|
||||
//!
|
||||
//! 桌面端注入方式:构造 [`McpHttpState`] 传入共享 db + on_tool_call 回调,
|
||||
//! [`build_router`] 得 Router,再 `axum::serve(listener, router)` 常驻监听。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
extract::State,
|
||||
http::{header, HeaderValue, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
routing::post,
|
||||
Router,
|
||||
};
|
||||
use df_storage::db::Database;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::protocol::{McpMethod, Request, INVALID_REQUEST, PARSE_ERROR};
|
||||
use crate::server::dispatch;
|
||||
use crate::tools::Ctx;
|
||||
|
||||
/// 默认端口(桌面内嵌固定端口;可用 env `DEVFLOW_MCP_PORT` 覆盖)。
|
||||
pub const DEFAULT_MCP_PORT: u16 = 18765;
|
||||
|
||||
/// HTTP MCP 共享状态(axum State)。
|
||||
pub struct McpHttpState {
|
||||
pub ctx: Ctx,
|
||||
pub read_only: bool,
|
||||
/// 成功 tools/call 回调(工具名)。桌面端注入 → emit df-data-changed;None=不回调。
|
||||
pub on_tool_call: Option<Arc<dyn Fn(&str) + Send + Sync>>,
|
||||
}
|
||||
|
||||
impl McpHttpState {
|
||||
pub fn new(
|
||||
db: Arc<Database>,
|
||||
read_only: bool,
|
||||
on_tool_call: Option<Arc<dyn Fn(&str) + Send + Sync>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
ctx: Ctx::new(db),
|
||||
read_only,
|
||||
on_tool_call,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造 axum Router(/mcp 单路由:POST 处理请求,GET 回 405)。
|
||||
pub fn build_router(state: McpHttpState) -> Router {
|
||||
Router::new()
|
||||
.route("/mcp", post(post_mcp).get(get_mcp))
|
||||
.with_state(Arc::new(state))
|
||||
}
|
||||
|
||||
/// 在已绑定 listener 上启动 HTTP server(永不返回直至 shutdown)。
|
||||
///
|
||||
/// 桌面端用法:bind 127.0.0.1:18765 → spawn(serve_on(listener, state)),
|
||||
/// 进程内常驻,多 Claude 会话 HTTP 直连同一进程。
|
||||
pub async fn serve_on(listener: tokio::net::TcpListener, state: McpHttpState) -> anyhow::Result<()> {
|
||||
axum::serve(listener, build_router(state))
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("axum::serve 失败: {e}"))
|
||||
}
|
||||
|
||||
/// POST /mcp:解析 body → dispatch → JSON 单响应 / 202 notification。
|
||||
async fn post_mcp(State(state): State<Arc<McpHttpState>>, body: Bytes) -> Response {
|
||||
match handle_body(&state, &body).await {
|
||||
Outcome::Json(status, value) => (status, axum::Json(value)).into_response(),
|
||||
Outcome::Accepted => StatusCode::ACCEPTED.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /mcp → 405 + `Allow: POST`(streamable HTTP 规范)。
|
||||
async fn get_mcp() -> Response {
|
||||
let mut resp = StatusCode::METHOD_NOT_ALLOWED.into_response();
|
||||
resp.headers_mut()
|
||||
.insert(header::ALLOW, HeaderValue::from_static("POST"));
|
||||
resp
|
||||
}
|
||||
|
||||
/// 分发结果:单响应 JSON / notification 202。
|
||||
enum Outcome {
|
||||
/// 单响应(HTTP 状态码 + JSON-RPC body)
|
||||
Json(StatusCode, Value),
|
||||
/// notification:202 Accepted,无 body
|
||||
Accepted,
|
||||
}
|
||||
|
||||
/// 解析请求体并分发到共享 dispatch。
|
||||
async fn handle_body(state: &McpHttpState, body: &[u8]) -> Outcome {
|
||||
// ① UTF-8 校验
|
||||
let text = match std::str::from_utf8(body) {
|
||||
Ok(t) => t,
|
||||
Err(_) => {
|
||||
return Outcome::Json(
|
||||
StatusCode::BAD_REQUEST,
|
||||
rpc_error(None, PARSE_ERROR, "Parse error: 请求体不是合法 UTF-8"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// ② 整体 JSON 解析:数组 = batch,本 server 不支持
|
||||
let raw: Value = match serde_json::from_str(text) {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
return Outcome::Json(
|
||||
StatusCode::BAD_REQUEST,
|
||||
rpc_error(None, PARSE_ERROR, "Parse error: 非法 JSON"),
|
||||
);
|
||||
}
|
||||
};
|
||||
if raw.is_array() {
|
||||
return Outcome::Json(
|
||||
StatusCode::BAD_REQUEST,
|
||||
rpc_error(None, INVALID_REQUEST, "Invalid request: batch 请求不支持"),
|
||||
);
|
||||
}
|
||||
|
||||
// ③ 解析为 JSON-RPC Request(结构非法 → INVALID_REQUEST)
|
||||
let req: Request = match serde_json::from_value(raw) {
|
||||
Ok(r) => r,
|
||||
Err(_) => {
|
||||
return Outcome::Json(
|
||||
StatusCode::BAD_REQUEST,
|
||||
rpc_error(None, INVALID_REQUEST, "Invalid request"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let method = McpMethod::from_request(&req);
|
||||
let is_notification = req.id.is_none();
|
||||
|
||||
// ④ notification(id 缺省)→ 202 Accepted 无 body
|
||||
if is_notification {
|
||||
match method {
|
||||
McpMethod::Initialized => {
|
||||
tracing::debug!(target: "df_mcp", "HTTP: 客户端 initialized 通知已收");
|
||||
}
|
||||
_ => {
|
||||
tracing::debug!(target: "df_mcp", m = ?method, "HTTP: 忽略未识别通知");
|
||||
}
|
||||
}
|
||||
return Outcome::Accepted;
|
||||
}
|
||||
|
||||
// ⑤ 工具名预取(tools/call 成功回调用;method 随后 move 进 dispatch)
|
||||
let tool_name = match &method {
|
||||
McpMethod::ToolsCall { name, .. } => Some(name.clone()),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let resp = dispatch(&state.ctx, state.read_only, req.id.clone(), method).await;
|
||||
|
||||
// ⑥ 成功 tools/call(JSON-RPC 无 error 且 result.isError≠true)→ on_tool_call 回调
|
||||
// (桌面端据此刷新 GUI)。MC-5/业务失败(handler 返 isError=true)不触发,
|
||||
// 防假 df-data-changed;与 stdio fire_write_hook 共用 is_success_tool_call,两 transport 统一。
|
||||
if crate::server::is_success_tool_call(&resp) {
|
||||
if let (Some(name), Some(cb)) = (tool_name, &state.on_tool_call) {
|
||||
cb(&name);
|
||||
}
|
||||
}
|
||||
|
||||
let value = serde_json::to_value(&resp)
|
||||
.unwrap_or_else(|_| rpc_error(req.id, crate::protocol::INTERNAL_ERROR, "响应序列化失败"));
|
||||
Outcome::Json(StatusCode::OK, value)
|
||||
}
|
||||
|
||||
/// 构造 JSON-RPC 2.0 错误响应体。
|
||||
fn rpc_error(id: Option<Value>, code: i32, message: &str) -> Value {
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"error": { "code": code, "message": message }
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 单测:tower oneshot 打 Router + open_in_memory DB
|
||||
// 覆盖:initialize / tools/list / notification 202 / GET 405 / 非法 JSON / batch / on_tool_call
|
||||
// ============================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{header, Request, StatusCode},
|
||||
};
|
||||
use std::sync::Mutex;
|
||||
use tower::ServiceExt;
|
||||
|
||||
/// 构造内存 DB + McpHttpState(on_tool_call 可注入)
|
||||
async fn test_state(
|
||||
read_only: bool,
|
||||
on_tool_call: Option<Arc<dyn Fn(&str) + Send + Sync>>,
|
||||
) -> McpHttpState {
|
||||
let db = Arc::new(Database::open_in_memory().await.unwrap());
|
||||
McpHttpState::new(db, read_only, on_tool_call)
|
||||
}
|
||||
|
||||
/// oneshot 打 Router:method + body → (status, json body, headers)
|
||||
async fn send(
|
||||
router: Router,
|
||||
method: &str,
|
||||
body: &str,
|
||||
) -> (StatusCode, Value, axum::http::HeaderMap) {
|
||||
let builder = Request::builder().uri("/mcp").method(method);
|
||||
let builder = if method == "GET" {
|
||||
builder
|
||||
} else {
|
||||
builder.header(header::CONTENT_TYPE, "application/json")
|
||||
};
|
||||
let req = builder.body(Body::from(body.to_string())).unwrap();
|
||||
let resp = router.clone().oneshot(req).await.unwrap();
|
||||
let status = resp.status();
|
||||
let headers = resp.headers().clone();
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let value: Value = if bytes.is_empty() {
|
||||
Value::Null
|
||||
} else {
|
||||
serde_json::from_slice(&bytes).unwrap_or(Value::Null)
|
||||
};
|
||||
(status, value, headers)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_initialize_returns_server_info_and_capabilities() {
|
||||
let state = test_state(false, None).await;
|
||||
let router = build_router(state);
|
||||
let (status, v, _) = send(
|
||||
router,
|
||||
"POST",
|
||||
r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(v["jsonrpc"], "2.0");
|
||||
assert_eq!(v["id"], 1);
|
||||
assert_eq!(v["result"]["protocolVersion"], crate::server::PROTOCOL_VERSION);
|
||||
assert_eq!(v["result"]["serverInfo"]["name"], crate::server::SERVER_NAME);
|
||||
assert!(v["result"]["capabilities"]["tools"].is_object());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_tools_list_excludes_high_risk_by_default() {
|
||||
let state = test_state(false, None).await;
|
||||
let router = build_router(state);
|
||||
let (status, v, _) = send(
|
||||
router,
|
||||
"POST",
|
||||
r#"{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}"#,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
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
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_notification_returns_202_no_body() {
|
||||
let state = test_state(false, None).await;
|
||||
let router = build_router(state);
|
||||
let (status, v, _) = send(
|
||||
router,
|
||||
"POST",
|
||||
r#"{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}"#,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::ACCEPTED);
|
||||
assert_eq!(v, Value::Null);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_mcp_returns_405_allow_post() {
|
||||
let state = test_state(false, None).await;
|
||||
let router = build_router(state);
|
||||
let (status, _, headers) = send(router, "GET", "").await;
|
||||
assert_eq!(status, StatusCode::METHOD_NOT_ALLOWED);
|
||||
assert_eq!(headers.get(header::ALLOW).unwrap(), "POST");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_invalid_json_returns_400_parse_error() {
|
||||
let state = test_state(false, None).await;
|
||||
let router = build_router(state);
|
||||
let (status, v, _) = send(router, "POST", "not json").await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(v["error"]["code"], PARSE_ERROR);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_batch_returns_400_invalid_request() {
|
||||
let state = test_state(false, None).await;
|
||||
let router = build_router(state);
|
||||
let body = r#"[{"jsonrpc":"2.0","id":1,"method":"ping","params":{}}]"#;
|
||||
let (status, v, _) = send(router, "POST", body).await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(v["error"]["code"], INVALID_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tools_call_success_triggers_on_tool_call() {
|
||||
let calls: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let calls_cb = calls.clone();
|
||||
let on_tool_call: Option<Arc<dyn Fn(&str) + Send + Sync>> = Some(Arc::new(move |name| {
|
||||
calls_cb.lock().unwrap().push(name.to_string());
|
||||
}));
|
||||
let state = test_state(false, on_tool_call).await;
|
||||
let router = build_router(state);
|
||||
let body = r#"{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"create_project","arguments":{"name":"HttpProj","description":"via http"}}}"#;
|
||||
let (status, v, _) = send(router, "POST", body).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
// 成功业务响应:isError 不置位
|
||||
assert!(
|
||||
v["result"]["isError"].is_null() || v["result"]["isError"] == Value::Bool(false)
|
||||
);
|
||||
assert!(
|
||||
calls.lock().unwrap().contains(&"create_project".to_string()),
|
||||
"成功 tools/call 应触发 on_tool_call,实际: {:?}",
|
||||
*calls.lock().unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
+1388
-117
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,207 @@
|
||||
//! update_idea/update_project/update_task 部分更新回归测试
|
||||
//!
|
||||
//! 回归 P0 bug:LLM 客户端做部分更新(只传 description 不传 title)时,
|
||||
//! 旧实现用 `arg_str_or(args, "title", "")` 取值,缺省 → 空串覆盖 existing
|
||||
//! → title 被静默清空 → 数据丢失。
|
||||
//!
|
||||
//! 根本修:title/description(name)缺省回退 existing,而非空默认覆盖。
|
||||
//! 本测试覆盖三个 handler 的「只传一个字段,另一字段保留 existing」语义。
|
||||
|
||||
use df_mcp::tools::{find, Ctx};
|
||||
use df_storage::db::Database;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// 从 CallToolResult 取首个 text 块解析为 JSON。
|
||||
fn result_json(res: &df_mcp::protocol::CallToolResult) -> Value {
|
||||
assert!(
|
||||
res.is_error != Some(true),
|
||||
"工具调用失败(is_error=true): {:?}",
|
||||
res.content
|
||||
);
|
||||
match res.content.first() {
|
||||
Some(df_mcp::protocol::ContentBlock::Text { text }) => {
|
||||
serde_json::from_str(text).expect("响应非合法 JSON")
|
||||
}
|
||||
other => panic!("预期 Text 块,实际: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 取嵌套对象 record(title/name/description 等业务字段在其下)。
|
||||
fn record_of(v: &Value, key: &str) -> Value {
|
||||
v.get(key)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| panic!("响应缺 `{key}` 字段: {v}"))
|
||||
}
|
||||
|
||||
async fn setup() -> Ctx {
|
||||
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||
Ctx::new(std::sync::Arc::new(db))
|
||||
}
|
||||
|
||||
async fn call(ctx: &Ctx, name: &str, args: Value) -> Value {
|
||||
let spec = find(name).expect("工具已注册");
|
||||
let res = (spec.handler)(ctx, args).await;
|
||||
result_json(&res)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// update_idea:只传 description,title 必须保留 existing
|
||||
// ============================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_idea_keeps_title_when_only_description_sent() {
|
||||
let ctx = setup().await;
|
||||
// 先建一条想法:title="原始标题"
|
||||
let created = call(
|
||||
&ctx,
|
||||
"create_idea",
|
||||
json!({ "title": "原始标题", "description": "原始描述" }),
|
||||
)
|
||||
.await;
|
||||
let id = created["id"].as_str().expect("id").to_owned();
|
||||
|
||||
// LLM 只传 description(不传 title)—— 旧实现会把 title 清空为 ""
|
||||
let updated = call(
|
||||
&ctx,
|
||||
"update_idea",
|
||||
json!({ "id": id, "description": "新描述" }),
|
||||
)
|
||||
.await;
|
||||
let idea = record_of(&updated, "idea");
|
||||
assert_eq!(idea["title"].as_str(), Some("原始标题"), "title 应保留 existing,不被空默认清空");
|
||||
assert_eq!(idea["description"].as_str(), Some("新描述"), "description 应更新为新值");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_idea_keeps_description_when_only_title_sent() {
|
||||
let ctx = setup().await;
|
||||
let created = call(
|
||||
&ctx,
|
||||
"create_idea",
|
||||
json!({ "title": "原标题", "description": "原描述" }),
|
||||
)
|
||||
.await;
|
||||
let id = created["id"].as_str().expect("id").to_owned();
|
||||
|
||||
let updated = call(&ctx, "update_idea", json!({ "id": id, "title": "新标题" })).await;
|
||||
let idea = record_of(&updated, "idea");
|
||||
assert_eq!(idea["title"].as_str(), Some("新标题"));
|
||||
assert_eq!(idea["description"].as_str(), Some("原描述"), "description 应保留 existing");
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// update_project:只传 description,name 必须保留 existing
|
||||
// ============================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_project_keeps_name_when_only_description_sent() {
|
||||
let ctx = setup().await;
|
||||
let created = call(
|
||||
&ctx,
|
||||
"create_project",
|
||||
json!({ "name": "原始项目", "description": "原始描述" }),
|
||||
)
|
||||
.await;
|
||||
let id = created["id"].as_str().expect("id").to_owned();
|
||||
|
||||
let updated = call(
|
||||
&ctx,
|
||||
"update_project",
|
||||
json!({ "id": id, "description": "新描述" }),
|
||||
)
|
||||
.await;
|
||||
let project = record_of(&updated, "project");
|
||||
assert_eq!(project["name"].as_str(), Some("原始项目"), "name 应保留 existing");
|
||||
assert_eq!(project["description"].as_str(), Some("新描述"));
|
||||
// path/stack/idea_id 未传也应保留(existing 创建时为 None,这里间接保证不被改)
|
||||
assert_eq!(project["path"].as_str(), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_project_keeps_status_when_not_sent() {
|
||||
// 状态字段缺省同样应保留 existing(旧实现默认 "planning" 会重置状态)
|
||||
let ctx = setup().await;
|
||||
let created = call(
|
||||
&ctx,
|
||||
"create_project",
|
||||
json!({ "name": "P", "description": "D", "status": "in_progress" }),
|
||||
)
|
||||
.await;
|
||||
let id = created["id"].as_str().expect("id").to_owned();
|
||||
|
||||
let updated = call(
|
||||
&ctx,
|
||||
"update_project",
|
||||
json!({ "id": id, "description": "改描述" }),
|
||||
)
|
||||
.await;
|
||||
let project = record_of(&updated, "project");
|
||||
assert_eq!(
|
||||
project["status"].as_str(),
|
||||
Some("in_progress"),
|
||||
"status 应保留 existing,不被默认 planning 重置"
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// update_task:只传 description,title/project_id 必须保留 existing
|
||||
// ============================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_task_keeps_title_and_project_when_only_description_sent() {
|
||||
let ctx = setup().await;
|
||||
let proj = call(
|
||||
&ctx,
|
||||
"create_project",
|
||||
json!({ "name": "所属项目", "description": "d" }),
|
||||
)
|
||||
.await;
|
||||
let project_id = proj["id"].as_str().expect("project id").to_owned();
|
||||
|
||||
let created = call(
|
||||
&ctx,
|
||||
"create_task",
|
||||
json!({ "project_id": project_id, "title": "原始任务标题", "description": "原始描述" }),
|
||||
)
|
||||
.await;
|
||||
let id = created["id"].as_str().expect("id").to_owned();
|
||||
|
||||
// 只传 description:旧实现 title 是必填会报错,description 缺省会清空(行为不一)
|
||||
// 根本修后三者都应保留 existing(或更新为新值)
|
||||
let updated = call(
|
||||
&ctx,
|
||||
"update_task",
|
||||
json!({ "id": id, "description": "新描述" }),
|
||||
)
|
||||
.await;
|
||||
let task = record_of(&updated, "task");
|
||||
assert_eq!(task["title"].as_str(), Some("原始任务标题"), "title 应保留 existing");
|
||||
assert_eq!(task["description"].as_str(), Some("新描述"));
|
||||
assert_eq!(task["project_id"].as_str(), Some(project_id.as_str()), "project_id 应保留 existing");
|
||||
// status 走状态机,update_task 不改也应保留
|
||||
assert_eq!(task["status"].as_str(), Some("todo"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_task_keeps_description_when_only_title_sent() {
|
||||
let ctx = setup().await;
|
||||
let proj = call(
|
||||
&ctx,
|
||||
"create_project",
|
||||
json!({ "name": "P2", "description": "d" }),
|
||||
)
|
||||
.await;
|
||||
let project_id = proj["id"].as_str().expect("project id").to_owned();
|
||||
let created = call(
|
||||
&ctx,
|
||||
"create_task",
|
||||
json!({ "project_id": project_id, "title": "原标题", "description": "原描述" }),
|
||||
)
|
||||
.await;
|
||||
let id = created["id"].as_str().expect("id").to_owned();
|
||||
|
||||
let updated = call(&ctx, "update_task", json!({ "id": id, "title": "新标题" })).await;
|
||||
let task = record_of(&updated, "task");
|
||||
assert_eq!(task["title"].as_str(), Some("新标题"));
|
||||
assert_eq!(task["description"].as_str(), Some("原描述"), "description 应保留 existing");
|
||||
}
|
||||
@@ -15,3 +15,4 @@ tokio = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
//! AI 编排节点 — 直连 agentic 会话执行工作流任务
|
||||
//! AI 编排节点 — 直连 agentic 会话执行工作流任务
|
||||
//!
|
||||
//! 与 AiNode(单次 LLM 补全)的区别:AiAgentNode 在节点 execute 时以编程方式启动一个
|
||||
//! 独立 agentic 会话(新 conv_id,与用户活跃会话隔离,防 epoch 互踩),由 run_agentic_loop
|
||||
//! 完整驱动(多轮迭代 + 工具调用),会话收敛后取最终 assistant 报告作为节点输出。
|
||||
//!
|
||||
//! 阻塞语义与 HumanNode 同构:tokio::select! 三路等待
|
||||
//! - 会话完成通知(AppState.agent_completion_bridge 的 oneshot,由 agentic loop 收敛
|
||||
//! 统一出口 emit_ai_completed_once 投递,epoch 守卫保证仅 owner loop 触发)
|
||||
//! - deadline 超时(config.timeout_secs,默认 1800s)→ Err(工作流 failed → 推进链退回)
|
||||
//! - 500ms tick 轮询 is_cancelled
|
||||
//!
|
||||
//! spawn 通道经 [`SpawnFn`] 注入(src-tauri 节点工厂提供,内部取 AppState 起会话),
|
||||
//! 本 crate 不依赖 src-tauri 类型,依赖方向与 AiNode 持 Arc<Database> 同款。
|
||||
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use df_storage::crud::TaskRepo;
|
||||
use df_storage::db::Database;
|
||||
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
|
||||
|
||||
// 任务文本兜底 prompt(与 AiNode 同款,经 ai_node_helpers 复用)
|
||||
use crate::ai_node_helpers::{default_fallback_prompt, resolve_fallback_prompt};
|
||||
|
||||
/// 会话启动结果:(conv_id, 完成通知接收端)。接收端值 = 最终 assistant 报告文本。
|
||||
pub type SpawnedSession = (String, tokio::sync::oneshot::Receiver<String>);
|
||||
|
||||
/// 会话启动 future(Err = 启动失败,如 provider 缺失/应用句柄未就绪)。
|
||||
pub type SpawnFuture = Pin<Box<dyn Future<Output = Result<SpawnedSession, String>> + Send>>;
|
||||
|
||||
/// 会话启动通道:入参任务文本,由 src-tauri 节点工厂注入实现(见 chat.rs
|
||||
/// orchestration_session_spawner)。
|
||||
pub type SpawnFn = Arc<dyn Fn(String) -> SpawnFuture + Send + Sync>;
|
||||
|
||||
/// 默认编排超时(秒)。1800s = 30min,与 HumanNode 默认审批窗口同量级;
|
||||
/// agentic 会话含多轮工具调用,给宽值,到点 fail 退回任务状态。
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 1800;
|
||||
|
||||
/// AI 编排节点
|
||||
///
|
||||
/// 持 Arc<Database> 落 task.output_json(有 task_id 时,与 AiNode 同模式),
|
||||
/// 持 SpawnFn 启动独立编排会话。
|
||||
pub struct AiAgentNode {
|
||||
db: Arc<Database>,
|
||||
spawn: SpawnFn,
|
||||
}
|
||||
|
||||
impl AiAgentNode {
|
||||
/// 构造节点(注册表工厂调用,注入数据库句柄与会话启动通道)
|
||||
pub fn new(db: Arc<Database>, spawn: SpawnFn) -> Self {
|
||||
Self { db, spawn }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Node for AiAgentNode {
|
||||
async fn execute(&self, ctx: NodeContext) -> NodeResult {
|
||||
tracing::info!("AiAgentNode 执行: node_id={}", ctx.node_id);
|
||||
|
||||
// 任务文本取值优先级与 AiNode 一致:上游 prompt 输出 > config.prompt >
|
||||
// task_id 兜底(读 TaskRecord 生成) > 通用默认。推进链模板 config 无 prompt,
|
||||
// 兜底链保证单节点 DAG 也能拿到任务书。
|
||||
let task = match ctx
|
||||
.inputs
|
||||
.get("prompt")
|
||||
.and_then(|o| o.data.as_str())
|
||||
.map(String::from)
|
||||
.or_else(|| {
|
||||
ctx.config
|
||||
.get("prompt")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
}) {
|
||||
Some(t) if !t.trim().is_empty() => t,
|
||||
_ => {
|
||||
resolve_fallback_prompt(&self.db, &ctx.config)
|
||||
.await
|
||||
.unwrap_or_else(default_fallback_prompt)
|
||||
}
|
||||
};
|
||||
|
||||
let timeout_secs = ctx
|
||||
.config
|
||||
.get("timeout_secs")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(DEFAULT_TIMEOUT_SECS);
|
||||
let task_id = ctx
|
||||
.config
|
||||
.get("task_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
// 启动独立编排会话(内部生成新 conv_id 并注册完成通知桥,注册先于 spawn)
|
||||
let (conv_id, mut done_rx) = (self.spawn)(task)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("AI 编排会话启动失败: {}", e))?;
|
||||
|
||||
// select! 骨架与 HumanNode 同构:完成通知 / deadline 超时 / 500ms 取消轮询。
|
||||
// oneshot 接收端在 loop 里重 poll,须借 &mut(移动进 select! 会破坏循环重入)。
|
||||
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
|
||||
|
||||
let report = loop {
|
||||
tokio::select! {
|
||||
done = &mut done_rx => match done {
|
||||
Ok(text) => break text,
|
||||
Err(_) => {
|
||||
// 发送端被 drop 且未投递:会话条目被覆盖注册或异常清除,无报告可取
|
||||
return Err(anyhow::anyhow!(
|
||||
"AI 编排会话完成信号丢失(conv_id={})",
|
||||
conv_id
|
||||
));
|
||||
}
|
||||
},
|
||||
_ = tokio::time::sleep_until(deadline) => {
|
||||
// 超时 fail:工作流 failed → 推进链按 regression_target 退回任务状态
|
||||
return Err(anyhow::anyhow!(
|
||||
"AI 编排会话超时({}s), conv_id={}",
|
||||
timeout_secs,
|
||||
conv_id
|
||||
));
|
||||
}
|
||||
_ = cancel_tick.tick() => {
|
||||
if ctx.node_status.is_cancelled(&ctx.node_id) {
|
||||
return Err(anyhow::anyhow!("AI 编排会话被取消"));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 空报告判失败:stop 在任何输出前 / 会话被清理的场景,不作为有效产出推进任务
|
||||
if report.trim().is_empty() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"AI 编排会话无输出(会话被停止或未产出报告), conv_id={}",
|
||||
conv_id
|
||||
));
|
||||
}
|
||||
|
||||
let output = serde_json::json!({
|
||||
"text": report,
|
||||
"conv_id": conv_id,
|
||||
"task_id": task_id,
|
||||
});
|
||||
|
||||
// 有 task_id 落 task.output_json(与 AiNode 同模式:通用 update_field 白名单字段,
|
||||
// 落库失败不阻断节点返回,产出已在内存工作流可继续)。
|
||||
if !task_id.is_empty() {
|
||||
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,
|
||||
"AiAgentNode 落 output_json 失败(产出已在内存,节点继续)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(NodeOutput::from_value(output))
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"prompt": { "type": "string", "description": "任务书(缺省依次取上游 prompt 输出 / 按 task_id 读任务生成 / 通用默认)" },
|
||||
"timeout_secs": { "type": "integer", "description": "编排超时秒数(默认 1800,到点节点 fail 触发工作流退回)" },
|
||||
"task_id": { "type": "string", "description": "关联任务 ID(run_workflow 注入;有值时产出落 task.output_json)" }
|
||||
},
|
||||
"required": []
|
||||
}),
|
||||
output: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": { "type": "string", "description": "最终 assistant 报告文本" },
|
||||
"conv_id": { "type": "string", "description": "编排会话 ID(独立于用户活跃会话)" },
|
||||
"task_id": { "type": "string" }
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_blocking(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"ai_agent"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use df_storage::crud::ProjectRepo;
|
||||
use df_storage::models::{ProjectRecord, TaskRecord};
|
||||
use df_types::types::{ProjectStatus, TaskStatus};
|
||||
use df_workflow::eventbus::EventBus;
|
||||
use df_workflow::state::StateMachine;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// 构造 NodeContext:自定义 config,共享指定状态机(取消轮询测试用)。
|
||||
fn make_ctx_with(config: serde_json::Value, sm: StateMachine) -> NodeContext {
|
||||
NodeContext {
|
||||
node_id: "node-a".to_string(),
|
||||
inputs: HashMap::new(),
|
||||
config,
|
||||
execution_id: "exec-1".into(),
|
||||
event_bus: EventBus::new(),
|
||||
node_status: sm,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_ctx(config: serde_json::Value) -> NodeContext {
|
||||
make_ctx_with(config, StateMachine::new())
|
||||
}
|
||||
|
||||
/// 内存 DB(无表数据;task_id 为空时节点不触库)。
|
||||
async fn empty_db() -> Arc<Database> {
|
||||
Arc::new(Database::open_in_memory().await.expect("open_in_memory"))
|
||||
}
|
||||
|
||||
/// 内存 DB helper:插父项目 + 指定 task,返回 (db, task_id)。
|
||||
async fn setup_task_db() -> (Arc<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: ProjectStatus::Planning,
|
||||
idea_id: None,
|
||||
path: None,
|
||||
stack: None,
|
||||
archived: false,
|
||||
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: "实现登录接口".to_string(),
|
||||
description: "含前端表单与后端校验".to_string(),
|
||||
status: TaskStatus::Todo,
|
||||
priority: 2,
|
||||
branch_name: None,
|
||||
assignee: None,
|
||||
workflow_def_id: None,
|
||||
base_branch: None,
|
||||
review_rounds: 0,
|
||||
output_json: None,
|
||||
idea_id: None,
|
||||
module_id: None,
|
||||
queue: "todo".to_string(),
|
||||
parent_id: None,
|
||||
content_json: None,
|
||||
archived: false,
|
||||
created_at: "0".to_string(),
|
||||
updated_at: "0".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("insert task");
|
||||
(Arc::new(db), "t1".to_string())
|
||||
}
|
||||
|
||||
/// spawn 桩:启动后 50ms 投递指定报告文本。
|
||||
fn spawn_delivering(report: &'static str) -> SpawnFn {
|
||||
Arc::new(move |_task: String| {
|
||||
Box::pin(async move {
|
||||
let (tx, rx) = tokio::sync::oneshot::channel::<String>();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
let _ = tx.send(report.to_string());
|
||||
});
|
||||
Ok(("conv-1".to_string(), rx))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// spawn 桩:发送端保活不投递(模拟会话长时间未收敛)。
|
||||
fn spawn_silent() -> SpawnFn {
|
||||
Arc::new(|_task: String| {
|
||||
Box::pin(async move {
|
||||
let (tx, rx) = tokio::sync::oneshot::channel::<String>();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
let _ = tx.send("迟到的报告".to_string());
|
||||
});
|
||||
Ok(("conv-silent".to_string(), rx))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// 完成通知桥触发:spawn 后收到投递 → Ok 输出 {text, conv_id, task_id}。
|
||||
#[tokio::test]
|
||||
async fn completion_notify_returns_report() {
|
||||
let node = AiAgentNode::new(empty_db().await, spawn_delivering("任务完成报告"));
|
||||
let ctx = make_ctx(json!({ "task_id": "t1" }));
|
||||
let out = node.execute(ctx).await.unwrap();
|
||||
assert_eq!(out.data["text"], json!("任务完成报告"));
|
||||
assert_eq!(out.data["conv_id"], json!("conv-1"));
|
||||
assert_eq!(out.data["task_id"], json!("t1"));
|
||||
}
|
||||
|
||||
/// deadline 超时:发送端保活不投递 + 短超时 → Err 超时(工作流 failed 退回路径)。
|
||||
#[tokio::test]
|
||||
async fn timeout_fails_node() {
|
||||
let node = AiAgentNode::new(empty_db().await, spawn_silent());
|
||||
let ctx = make_ctx(json!({ "timeout_secs": 1 }));
|
||||
let err = node.execute(ctx).await.unwrap_err().to_string();
|
||||
assert!(err.contains("超时"), "应报超时, 实际: {}", err);
|
||||
assert!(err.contains("conv-silent"), "超时信息应带 conv_id, 实际: {}", err);
|
||||
}
|
||||
|
||||
/// 取消轮询:500ms tick 命中 is_cancelled → Err 取消(状态保持 Cancelled 由 executor 保证)。
|
||||
#[tokio::test]
|
||||
async fn cancelled_poll_fails_node() {
|
||||
let sm = StateMachine::new();
|
||||
let sm_external = sm.clone();
|
||||
let node = AiAgentNode::new(empty_db().await, spawn_silent());
|
||||
let handle = tokio::spawn(async move { node.execute(make_ctx_with(json!({}), sm)).await });
|
||||
// 等 execute 进入 select! 轮询后再取消(过早 set 会因 tick 未启动而漏检一轮,仍会被下轮命中)
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
sm_external.set_cancelled("node-a".to_string());
|
||||
let err = handle.await.unwrap().unwrap_err().to_string();
|
||||
assert!(err.contains("取消"), "应报取消, 实际: {}", err);
|
||||
}
|
||||
|
||||
/// 启动失败:spawn 返 Err(provider 缺失等)→ 节点 Err 带原因。
|
||||
#[tokio::test]
|
||||
async fn spawn_failure_fails_node() {
|
||||
let spawn: SpawnFn = Arc::new(|_task: String| {
|
||||
Box::pin(async { Err("未配置 AI Provider".to_string()) })
|
||||
});
|
||||
let node = AiAgentNode::new(empty_db().await, spawn);
|
||||
let err = node.execute(make_ctx(json!({}))).await.unwrap_err().to_string();
|
||||
assert!(err.contains("启动失败"), "应报启动失败, 实际: {}", err);
|
||||
assert!(err.contains("未配置 AI Provider"), "应携带原因, 实际: {}", err);
|
||||
}
|
||||
|
||||
/// 信号丢失:发送端被 drop 且未投递 → Err 信号丢失(不误判为成功)。
|
||||
#[tokio::test]
|
||||
async fn dropped_sender_fails_node() {
|
||||
let spawn: SpawnFn = Arc::new(|_task: String| {
|
||||
Box::pin(async move {
|
||||
let (tx, rx) = tokio::sync::oneshot::channel::<String>();
|
||||
drop(tx);
|
||||
Ok(("conv-drop".to_string(), rx))
|
||||
})
|
||||
});
|
||||
let node = AiAgentNode::new(empty_db().await, spawn);
|
||||
let err = node.execute(make_ctx(json!({}))).await.unwrap_err().to_string();
|
||||
assert!(err.contains("信号丢失"), "应报信号丢失, 实际: {}", err);
|
||||
}
|
||||
|
||||
/// 空报告:投递了纯空白文本 → Err 无输出(不作为有效产出推进任务)。
|
||||
#[tokio::test]
|
||||
async fn blank_report_fails_node() {
|
||||
let spawn: SpawnFn = Arc::new(|_task: String| {
|
||||
Box::pin(async move {
|
||||
let (tx, rx) = tokio::sync::oneshot::channel::<String>();
|
||||
let _ = tx.send(" ".to_string());
|
||||
Ok(("conv-blank".to_string(), rx))
|
||||
})
|
||||
});
|
||||
let node = AiAgentNode::new(empty_db().await, spawn);
|
||||
let err = node.execute(make_ctx(json!({}))).await.unwrap_err().to_string();
|
||||
assert!(err.contains("无输出"), "应报无输出, 实际: {}", err);
|
||||
}
|
||||
|
||||
/// 有 task_id 落 task.output_json(与 AiNode 同一条 update_field 白名单路径),
|
||||
/// 且任务书兜底链生效:config 无 prompt 时按 task_id 读任务生成。
|
||||
#[tokio::test]
|
||||
async fn task_output_json_written_and_fallback_prompt_used() {
|
||||
let (db, task_id) = setup_task_db().await;
|
||||
let node = AiAgentNode::new(db.clone(), spawn_delivering("登录接口已完成"));
|
||||
// config 只有 task_id(推进链模板形态),prompt 走兜底生成
|
||||
let ctx = make_ctx(json!({ "task_id": task_id }));
|
||||
let out = node.execute(ctx).await.unwrap();
|
||||
assert_eq!(out.data["text"], json!("登录接口已完成"));
|
||||
|
||||
let after = TaskRepo::new(&db)
|
||||
.get_by_id("t1")
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("task 存在");
|
||||
let saved = after.output_json.expect("output_json 应被写入");
|
||||
let parsed: serde_json::Value = serde_json::from_str(&saved).unwrap();
|
||||
assert_eq!(parsed["text"], json!("登录接口已完成"));
|
||||
assert_eq!(parsed["task_id"], json!("t1"));
|
||||
assert!(parsed["conv_id"].as_str().is_some(), "conv_id 应落 output_json");
|
||||
}
|
||||
|
||||
/// 默认编排超时 1800s(30min),与 HumanNode 默认审批窗口同量级。
|
||||
#[test]
|
||||
fn default_timeout_is_1800_secs() {
|
||||
assert_eq!(DEFAULT_TIMEOUT_SECS, 1800, "默认编排超时应为 1800s(30min)");
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user