Compare commits
94
Commits
8e1e56b3e5
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0317873a5b | ||
|
|
eec5978fa7 | ||
|
|
25ec9cf69a | ||
|
|
302c4bcf52 | ||
|
|
05d0f89c16 | ||
|
|
a273a86a7d | ||
|
|
45f0560f85 | ||
|
|
f74d51b905 | ||
|
|
e08b0f4311 | ||
|
|
3bb662f092 | ||
|
|
a644e93604 | ||
|
|
795935bf6a | ||
|
|
7548896f6d | ||
|
|
5faaa88302 | ||
|
|
8c4aa5dc5b | ||
|
|
9142e30714 | ||
|
|
9e26d6b19c | ||
|
|
cb23061088 | ||
|
|
6f5c6b7532 | ||
|
|
c7d0ed939c | ||
|
|
fda991cbe1 | ||
|
|
f9d3b4c76b | ||
|
|
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 |
@@ -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
|
|
||||||
+11
@@ -3,6 +3,9 @@ logs
|
|||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
|
||||||
|
# 换装脚本(scripts/swap-install.ps1)运行期锁文件
|
||||||
|
/swap-install.lock
|
||||||
|
|
||||||
# Dependencies
|
# Dependencies
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
@@ -32,6 +35,10 @@ target-musl/ # df-relay 交叉编译产物隔离目录(见 .cargo/config.toml)
|
|||||||
src-tauri/target/
|
src-tauri/target/
|
||||||
src-tauri/Cargo.lock
|
src-tauri/Cargo.lock
|
||||||
|
|
||||||
|
# 签名私钥(应用内升级,设计 §七 高风险项:私钥绝不入仓)
|
||||||
|
*.key
|
||||||
|
*.key.pub
|
||||||
|
|
||||||
# Tauri 自动生成的 schema(每次构建变,可重建)
|
# Tauri 自动生成的 schema(每次构建变,可重建)
|
||||||
src-tauri/gen/schemas/
|
src-tauri/gen/schemas/
|
||||||
|
|
||||||
@@ -100,3 +107,7 @@ temp/
|
|||||||
|
|
||||||
# df-im-gateway 运行时数据(冒烟测试残留)
|
# df-im-gateway 运行时数据(冒烟测试残留)
|
||||||
crates/df-im-gateway/data/
|
crates/df-im-gateway/data/
|
||||||
|
|
||||||
|
# Playwright 运行产物
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
|||||||
@@ -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
+253
-6
@@ -104,6 +104,15 @@ version = "1.0.102"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "arbitrary"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed"
|
||||||
|
dependencies = [
|
||||||
|
"derive_arbitrary",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "asn1-rs"
|
name = "asn1-rs"
|
||||||
version = "0.7.2"
|
version = "0.7.2"
|
||||||
@@ -996,6 +1005,17 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "derive_arbitrary"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "derive_more"
|
name = "derive_more"
|
||||||
version = "2.1.1"
|
version = "2.1.1"
|
||||||
@@ -1019,7 +1039,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "devflow"
|
name = "devflow"
|
||||||
version = "0.1.0"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -1038,13 +1058,20 @@ dependencies = [
|
|||||||
"df-types",
|
"df-types",
|
||||||
"df-workflow",
|
"df-workflow",
|
||||||
"futures",
|
"futures",
|
||||||
|
"futures-util",
|
||||||
|
"hex",
|
||||||
|
"hmac",
|
||||||
"htmd",
|
"htmd",
|
||||||
|
"httpdate",
|
||||||
"keyring",
|
"keyring",
|
||||||
|
"larksuite-oapi-sdk-rs",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"regex",
|
"regex",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha1 0.10.6",
|
||||||
|
"sha2 0.10.9",
|
||||||
"sys-locale",
|
"sys-locale",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
@@ -1052,8 +1079,10 @@ dependencies = [
|
|||||||
"tauri-plugin-notification",
|
"tauri-plugin-notification",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
"tauri-plugin-single-instance",
|
"tauri-plugin-single-instance",
|
||||||
|
"tauri-plugin-updater",
|
||||||
"tauri-plugin-window-state",
|
"tauri-plugin-window-state",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-tungstenite 0.23.1",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-appender",
|
"tracing-appender",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
@@ -1073,6 +1102,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
"df-ai-core",
|
"df-ai-core",
|
||||||
"df-types",
|
"df-types",
|
||||||
@@ -1130,6 +1160,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
"axum",
|
||||||
"df-tunnel",
|
"df-tunnel",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"larksuite-oapi-sdk-rs",
|
"larksuite-oapi-sdk-rs",
|
||||||
@@ -1274,6 +1305,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer 0.10.4",
|
"block-buffer 0.10.4",
|
||||||
"crypto-common 0.1.7",
|
"crypto-common 0.1.7",
|
||||||
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1567,6 +1599,16 @@ dependencies = [
|
|||||||
"rustc_version",
|
"rustc_version",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "filetime"
|
||||||
|
version = "0.2.29"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "find-msvc-tools"
|
name = "find-msvc-tools"
|
||||||
version = "0.1.9"
|
version = "0.1.9"
|
||||||
@@ -2138,6 +2180,15 @@ version = "0.4.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hmac"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||||
|
dependencies = [
|
||||||
|
"digest 0.10.7",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "htmd"
|
name = "htmd"
|
||||||
version = "0.5.5"
|
version = "0.5.5"
|
||||||
@@ -2568,6 +2619,36 @@ dependencies = [
|
|||||||
"windows-sys 0.45.0",
|
"windows-sys 0.45.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jni"
|
||||||
|
version = "0.22.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"combine",
|
||||||
|
"jni-macros",
|
||||||
|
"jni-sys 0.4.1",
|
||||||
|
"log",
|
||||||
|
"simd_cesu8",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"walkdir",
|
||||||
|
"windows-link 0.2.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jni-macros"
|
||||||
|
version = "0.22.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"rustc_version",
|
||||||
|
"simd_cesu8",
|
||||||
|
"syn 2.0.117",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jni-sys"
|
name = "jni-sys"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -2892,6 +2973,12 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "minisign-verify"
|
||||||
|
version = "0.2.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "miniz_oxide"
|
name = "miniz_oxide"
|
||||||
version = "0.8.9"
|
version = "0.8.9"
|
||||||
@@ -3273,6 +3360,18 @@ dependencies = [
|
|||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-osa-kit"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.0",
|
||||||
|
"objc2",
|
||||||
|
"objc2-app-kit",
|
||||||
|
"objc2-foundation",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-quartz-core"
|
name = "objc2-quartz-core"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@@ -3416,6 +3515,20 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "osakit"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
|
||||||
|
dependencies = [
|
||||||
|
"objc2",
|
||||||
|
"objc2-foundation",
|
||||||
|
"objc2-osa-kit",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pango"
|
name = "pango"
|
||||||
version = "0.18.3"
|
version = "0.18.3"
|
||||||
@@ -4055,15 +4168,20 @@ dependencies = [
|
|||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper",
|
"hyper",
|
||||||
|
"hyper-rustls",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"rustls-platform-verifier",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
@@ -4209,6 +4327,33 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-platform-verifier"
|
||||||
|
version = "0.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation 0.10.1",
|
||||||
|
"core-foundation-sys",
|
||||||
|
"jni 0.22.4",
|
||||||
|
"log",
|
||||||
|
"once_cell",
|
||||||
|
"rustls",
|
||||||
|
"rustls-native-certs",
|
||||||
|
"rustls-platform-verifier-android",
|
||||||
|
"rustls-webpki",
|
||||||
|
"security-framework 3.7.0",
|
||||||
|
"security-framework-sys",
|
||||||
|
"webpki-root-certs",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-platform-verifier-android"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls-webpki"
|
name = "rustls-webpki"
|
||||||
version = "0.103.13"
|
version = "0.103.13"
|
||||||
@@ -4657,6 +4802,22 @@ version = "0.3.9"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simd_cesu8"
|
||||||
|
version = "1.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
|
||||||
|
dependencies = [
|
||||||
|
"rustc_version",
|
||||||
|
"simdutf8",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simdutf8"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "siphasher"
|
name = "siphasher"
|
||||||
version = "1.0.3"
|
version = "1.0.3"
|
||||||
@@ -4826,6 +4987,17 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "3.0.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sync_wrapper"
|
name = "sync_wrapper"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
@@ -4907,7 +5079,7 @@ dependencies = [
|
|||||||
"gdkwayland-sys",
|
"gdkwayland-sys",
|
||||||
"gdkx11-sys",
|
"gdkx11-sys",
|
||||||
"gtk",
|
"gtk",
|
||||||
"jni",
|
"jni 0.21.1",
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
"ndk",
|
"ndk",
|
||||||
@@ -4940,6 +5112,17 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tar"
|
||||||
|
version = "0.4.46"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
||||||
|
dependencies = [
|
||||||
|
"filetime",
|
||||||
|
"libc",
|
||||||
|
"xattr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "target-lexicon"
|
name = "target-lexicon"
|
||||||
version = "0.12.16"
|
version = "0.12.16"
|
||||||
@@ -4964,7 +5147,7 @@ dependencies = [
|
|||||||
"heck 0.5.0",
|
"heck 0.5.0",
|
||||||
"http",
|
"http",
|
||||||
"http-range",
|
"http-range",
|
||||||
"jni",
|
"jni 0.21.1",
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
"mime",
|
"mime",
|
||||||
@@ -5175,6 +5358,39 @@ dependencies = [
|
|||||||
"zbus 5.16.0",
|
"zbus 5.16.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-updater"
|
||||||
|
version = "2.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7a5cad8ed5948d988e1018ecd31e27cacedbf72ce3fb972940c3e4bf51639e4b"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
|
"dirs",
|
||||||
|
"flate2",
|
||||||
|
"futures-util",
|
||||||
|
"http",
|
||||||
|
"infer",
|
||||||
|
"log",
|
||||||
|
"minisign-verify",
|
||||||
|
"osakit",
|
||||||
|
"percent-encoding",
|
||||||
|
"reqwest 0.13.4",
|
||||||
|
"rustls",
|
||||||
|
"semver",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tar",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
"tempfile",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"time",
|
||||||
|
"tokio",
|
||||||
|
"url",
|
||||||
|
"windows-sys 0.60.2",
|
||||||
|
"zip",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin-window-state"
|
name = "tauri-plugin-window-state"
|
||||||
version = "2.4.1"
|
version = "2.4.1"
|
||||||
@@ -5200,7 +5416,7 @@ dependencies = [
|
|||||||
"dpi",
|
"dpi",
|
||||||
"gtk",
|
"gtk",
|
||||||
"http",
|
"http",
|
||||||
"jni",
|
"jni 0.21.1",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-ui-kit",
|
"objc2-ui-kit",
|
||||||
"objc2-web-kit",
|
"objc2-web-kit",
|
||||||
@@ -5223,7 +5439,7 @@ checksum = "b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"gtk",
|
"gtk",
|
||||||
"http",
|
"http",
|
||||||
"jni",
|
"jni 0.21.1",
|
||||||
"log",
|
"log",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-app-kit",
|
"objc2-app-kit",
|
||||||
@@ -6372,6 +6588,15 @@ dependencies = [
|
|||||||
"system-deps",
|
"system-deps",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webpki-root-certs"
|
||||||
|
version = "1.0.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
|
||||||
|
dependencies = [
|
||||||
|
"rustls-pki-types",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "webpki-roots"
|
name = "webpki-roots"
|
||||||
version = "1.0.8"
|
version = "1.0.8"
|
||||||
@@ -7065,7 +7290,7 @@ dependencies = [
|
|||||||
"gtk",
|
"gtk",
|
||||||
"http",
|
"http",
|
||||||
"javascriptcore-rs",
|
"javascriptcore-rs",
|
||||||
"jni",
|
"jni 0.21.1",
|
||||||
"libc",
|
"libc",
|
||||||
"ndk",
|
"ndk",
|
||||||
"objc2",
|
"objc2",
|
||||||
@@ -7129,6 +7354,16 @@ dependencies = [
|
|||||||
"time",
|
"time",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "xattr"
|
||||||
|
version = "1.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"rustix",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "xdg-home"
|
name = "xdg-home"
|
||||||
version = "1.3.0"
|
version = "1.3.0"
|
||||||
@@ -7375,6 +7610,18 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zip"
|
||||||
|
version = "4.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
|
||||||
|
dependencies = [
|
||||||
|
"arbitrary",
|
||||||
|
"crc32fast",
|
||||||
|
"indexmap 2.14.0",
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zmij"
|
name = "zmij"
|
||||||
version = "1.0.21"
|
version = "1.0.21"
|
||||||
|
|||||||
+6
-3
@@ -28,6 +28,9 @@ keyring = { version = "3", features = ["windows-native", "apple-native", "linux-
|
|||||||
dashmap = "6"
|
dashmap = "6"
|
||||||
|
|
||||||
# release 增量编译:小改动重打包显著提速(2026-10-04 打包提速项);
|
# release 增量编译:小改动重打包显著提速(2026-10-04 打包提速项);
|
||||||
# 代价为产物略大,正式发版如在意体积可临时注释此段
|
# ⚠️ 2026-10-09 回退:release + incremental 触发链接期匿名符号丢失
|
||||||
[profile.release]
|
# (anon.*.llvm.* undefined symbol,rust-lld 与 MSVC link.exe 均复现),
|
||||||
incremental = true
|
# 根因是增量编译 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
@@ -348,6 +348,9 @@ function clearAllApprovalTimers(): void {
|
|||||||
function clearWatchdog(): void {
|
function clearWatchdog(): void {
|
||||||
if (streamTimer) clearTimeout(streamTimer)
|
if (streamTimer) clearTimeout(streamTimer)
|
||||||
streamTimer = null
|
streamTimer = null
|
||||||
|
// 复位节流时间戳:resetWatchdog 的 5s 节流假定「已有 timer 在跑」,而此处刚清空 timer。
|
||||||
|
// 不复位则「停止/报错后 5s 内重发」会因节流跳过 arm → 新一轮无看门狗(中途断则 generating 永卡)。
|
||||||
|
lastWatchdogReset = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 已注册事件监听标志(防多次订阅 wsClient) */
|
/** 已注册事件监听标志(防多次订阅 wsClient) */
|
||||||
@@ -1374,6 +1377,7 @@ function newConversation(): void {
|
|||||||
tokenUsage.value = null // 新会话清 token(对齐 switchConversation)
|
tokenUsage.value = null // 新会话清 token(对齐 switchConversation)
|
||||||
activeModel.value = null // 新会话复位模型选择(新会话无 per_conv 模型,走默认路由)
|
activeModel.value = null // 新会话复位模型选择(新会话无 per_conv 模型,走默认路由)
|
||||||
pendingApprovals.value.splice(0, pendingApprovals.value.length); clearAllApprovalTimers()
|
pendingApprovals.value.splice(0, pendingApprovals.value.length); clearAllApprovalTimers()
|
||||||
|
clearWatchdog() // 与 switchConversation 对齐:清残留看门狗,防新一轮 send 沿用旧 timer(超时点偏移)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import { useAiChat } from '@/composables/useAiChat'
|
|||||||
import type { MentionSpan, MiniContentPart } from '@/types/relay'
|
import type { MentionSpan, MiniContentPart } from '@/types/relay'
|
||||||
import type { ProjectRecord, TaskRecord, IdeaRecord, AiToolCallInfo, ChatMessage, TokenUsage } from '@/types/events'
|
import type { ProjectRecord, TaskRecord, IdeaRecord, AiToolCallInfo, ChatMessage, TokenUsage } from '@/types/events'
|
||||||
|
|
||||||
|
/** 调试日志门控(对齐 useAiChat/ws.ts:import.meta.env.DEV,生产零 bridge 成本) */
|
||||||
|
const DEBUG = import.meta.env.DEV
|
||||||
|
|
||||||
// 组件内联(绕过微信工具 3.15.2 组件解析缓存 bug):独立组件恒报 Component not found,
|
// 组件内联(绕过微信工具 3.15.2 组件解析缓存 bug):独立组件恒报 Component not found,
|
||||||
// inline 进页面后 chat/index.json usingComponents 空,工具无需解析 components/,问题消失。
|
// inline 进页面后 chat/index.json usingComponents 空,工具无需解析 components/,问题消失。
|
||||||
// MdView/MentionInput 源保留(src/components/ 下,工具 bug 修复后可拆回)。
|
// MdView/MentionInput 源保留(src/components/ 下,工具 bug 修复后可拆回)。
|
||||||
@@ -512,11 +515,11 @@ function onMentionClose(): void {
|
|||||||
* 保留原有守卫:空文本忽略 / generating 中忽略 / 关闭联想浮层。
|
* 保留原有守卫:空文本忽略 / generating 中忽略 / 关闭联想浮层。
|
||||||
*/
|
*/
|
||||||
function handleSend(): void {
|
function handleSend(): void {
|
||||||
console.log('[v2:send] enter text=', JSON.stringify(inputText.value), 'gen=', generating.value, 'imgs=', pendingImages.value.length)
|
if (DEBUG) console.log('[v2:send] enter text=', JSON.stringify(inputText.value), 'gen=', generating.value, 'imgs=', pendingImages.value.length)
|
||||||
const text = inputText.value.trim()
|
const text = inputText.value.trim()
|
||||||
const hasImage = pendingImages.value.length > 0
|
const hasImage = pendingImages.value.length > 0
|
||||||
if (!text && !hasImage) {
|
if (!text && !hasImage) {
|
||||||
console.log('[v2:send] 空文本且无图片,忽略')
|
if (DEBUG) console.log('[v2:send] 空文本且无图片,忽略')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (generating.value) {
|
if (generating.value) {
|
||||||
|
|||||||
@@ -12,11 +12,15 @@ const ai = useAiChat()
|
|||||||
*/
|
*/
|
||||||
const conversations = ref<Conversation[]>([])
|
const conversations = ref<Conversation[]>([])
|
||||||
/**
|
/**
|
||||||
* 监听 ai.conversations.length 变化(比 deep watch 更可靠)。
|
* 监听 ai.conversations 整体替换(比 deep watch 更可靠,也比只盯 length 完整)。
|
||||||
* 用 `[...ai.conversations]` 创建普通数组赋值给 ref,确保每次变化都触发模板更新。
|
* 用 `[...ai.conversations]` 创建普通数组赋值给 ref,确保每次变化都触发模板更新。
|
||||||
|
*
|
||||||
|
* 为什么监听 ref 而非 length:device 回推 AiConversationList 是「整体替换」且条数常不变
|
||||||
|
* (改名后条数不变),只盯 length 时 watch 回调不触发 → 本地副本滞留旧标题。
|
||||||
|
* 监听 ref 则在赋值(含同长度替换)时即触发刷新。
|
||||||
*/
|
*/
|
||||||
watch(() => ai.conversations.value.length, () => {
|
watch(ai.conversations, (list) => {
|
||||||
conversations.value = [...ai.conversations.value]
|
conversations.value = [...(list || [])]
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
/** 点击会话项切换 */
|
/** 点击会话项切换 */
|
||||||
|
|||||||
@@ -42,25 +42,25 @@ impl ContentPart {
|
|||||||
|
|
||||||
impl ChatMessage {
|
impl ChatMessage {
|
||||||
pub fn system(content: impl Into<String>) -> Self {
|
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, 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()) }
|
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 {
|
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, 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()) }
|
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 {
|
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, 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()) }
|
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 {
|
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, 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()) }
|
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 {
|
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, 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()) }
|
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 片)。
|
/// 多模态 user 消息:content 文本 + parts(含 Image 片)。
|
||||||
/// content 作为人类可读文本(也作非 vision 端点降级载荷);parts 透传给 vision 端点。
|
/// content 作为人类可读文本(也作非 vision 端点降级载荷);parts 透传给 vision 端点。
|
||||||
pub fn user_parts(content: impl Into<String>, parts: Vec<ContentPart>) -> Self {
|
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, 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()) }
|
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 判定走多模态分支)。
|
/// 是否含图片片(供 provider 判定走多模态分支)。
|
||||||
@@ -310,6 +310,7 @@ mod tests {
|
|||||||
role: MessageRole::User,
|
role: MessageRole::User,
|
||||||
content: "字面量构造".into(),
|
content: "字面量构造".into(),
|
||||||
parts: None,
|
parts: None,
|
||||||
|
source: None,
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
tool_calls: None,
|
tool_calls: None,
|
||||||
model: None,
|
model: None,
|
||||||
@@ -368,6 +369,7 @@ mod tests {
|
|||||||
role: MessageRole::Assistant,
|
role: MessageRole::Assistant,
|
||||||
content: "answer".to_string(),
|
content: "answer".to_string(),
|
||||||
parts: None,
|
parts: None,
|
||||||
|
source: None,
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
tool_calls: None,
|
tool_calls: None,
|
||||||
model: None,
|
model: None,
|
||||||
@@ -429,6 +431,7 @@ mod tests {
|
|||||||
role: MessageRole::Assistant,
|
role: MessageRole::Assistant,
|
||||||
content: "answer".to_string(),
|
content: "answer".to_string(),
|
||||||
parts: None,
|
parts: None,
|
||||||
|
source: None,
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
tool_calls: None,
|
tool_calls: None,
|
||||||
model: None,
|
model: None,
|
||||||
|
|||||||
@@ -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`/
|
/// ⚠️ **迁移耦合点**:下方各字段名(`role`/`content`/`parts`/`tool_call_id`/
|
||||||
@@ -85,6 +101,11 @@ pub struct ChatMessage {
|
|||||||
pub id: Option<String>,
|
pub id: Option<String>,
|
||||||
pub role: MessageRole,
|
pub role: MessageRole,
|
||||||
pub content: String,
|
pub content: String,
|
||||||
|
/// 消息来源(#958 溯源):消息从哪个渠道/端点进入会话。None=桌面本端(既有语义,
|
||||||
|
/// 老数据反序列化为 None 零回归);Some=外部渠道(飞书 message_id 可跳原消息追溯,
|
||||||
|
/// 多端混发可区分来源端)
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub source: Option<MessageSource>,
|
||||||
/// 多模态内容片。
|
/// 多模态内容片。
|
||||||
///
|
///
|
||||||
/// `None`/空 → 纯文本消息(绝大多数场景,content 即全部载荷)。
|
/// `None`/空 → 纯文本消息(绝大多数场景,content 即全部载荷)。
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ tokio = { workspace = true, features = ["sync", "time"] }
|
|||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
|
base64 = "0.22"
|
||||||
|
|
||||||
# HTTP + 流式
|
# HTTP + 流式
|
||||||
reqwest = { version = "0.12", features = ["stream", "json", "rustls-tls"] }
|
reqwest = { version = "0.12", features = ["stream", "json", "rustls-tls"] }
|
||||||
|
|||||||
@@ -136,6 +136,24 @@
|
|||||||
"context_window": 262144,
|
"context_window": 262144,
|
||||||
"temperature_supported": false
|
"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",
|
"model_id": "deepseek-v4-flash",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|||||||
@@ -62,6 +62,46 @@ pub fn anthropic_cache_enabled() -> bool {
|
|||||||
ANTHROPIC_CACHE_ENABLED.load(std::sync::atomic::Ordering::SeqCst)
|
ANTHROPIC_CACHE_ENABLED.load(std::sync::atomic::Ordering::SeqCst)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// BP3 摘要链尾断点开关(缓存友好上下文总开关在本模块的投影,默认开)。
|
||||||
|
///
|
||||||
|
/// 本模块不读 src-tauri KV(跨 crate),上层读 settings `df-ai-cache-friendly-context`
|
||||||
|
/// 后经 [`set_bp3_enabled`] 同步(同 [`set_anthropic_cache_enabled`] 先例)。
|
||||||
|
/// 关时消息级只打 BP2,断点布局与历史逐字节一致。BP3 仍内嵌于 anthropic 缓存总开关
|
||||||
|
/// 之下(它发的也是 cache_control),两层正交。
|
||||||
|
static BP3_ENABLED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
|
||||||
|
|
||||||
|
/// 设置 BP3 摘要链尾断点开关(上层读 settings 后同步)。
|
||||||
|
pub fn set_bp3_enabled(enabled: bool) {
|
||||||
|
BP3_ENABLED.store(enabled, std::sync::atomic::Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读取 BP3 开关(`convert_request` 组装消息级断点用)。
|
||||||
|
pub fn bp3_enabled() -> bool {
|
||||||
|
BP3_ENABLED.load(std::sync::atomic::Ordering::SeqCst)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 缓存命中率观测日志(usage 就绪点每轮一条,供后续水位调参取数)。
|
||||||
|
///
|
||||||
|
/// anthropic 语义:input_tokens 为未缓存输入,cache_read/creation 独立计数,
|
||||||
|
/// 故命中率分母 = read + creation + input;三者全零(端点未回 usage)跳过不报。
|
||||||
|
fn log_cache_usage(usage: &TokenUsage) {
|
||||||
|
let total_input = usage.prompt_cache_hit_tokens as u64
|
||||||
|
+ usage.prompt_cache_miss_tokens as u64
|
||||||
|
+ usage.prompt_tokens as u64;
|
||||||
|
if total_input == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let hit_rate = usage.prompt_cache_hit_tokens as f64 / total_input as f64;
|
||||||
|
let hit_pct = format!("{:.1}%", hit_rate * 100.0);
|
||||||
|
tracing::info!(
|
||||||
|
input_tokens = usage.prompt_tokens,
|
||||||
|
cache_read_input_tokens = usage.prompt_cache_hit_tokens,
|
||||||
|
cache_creation_input_tokens = usage.prompt_cache_miss_tokens,
|
||||||
|
hit_rate = %hit_pct,
|
||||||
|
"Anthropic prompt cache 命中率观测"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Provider 实现
|
// Provider 实现
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -147,10 +187,21 @@ impl AnthropicCompatProvider {
|
|||||||
// 构建非 system 消息(保留顺序,合并连续 tool_result)
|
// 构建非 system 消息(保留顺序,合并连续 tool_result)
|
||||||
let mut messages: Vec<serde_json::Value> = Vec::new();
|
let mut messages: Vec<serde_json::Value> = Vec::new();
|
||||||
let mut pending_tool_results: Vec<serde_json::Value> = Vec::new();
|
let mut pending_tool_results: Vec<serde_json::Value> = Vec::new();
|
||||||
|
// 摘要锚点 = index>0 的 System(存储态:压缩摘要/话题标记/催办;首条是组装层
|
||||||
|
// system_prompt,归 BP1 顶层 system 非锚点)。记录「末锚点之后的消息条数」而非
|
||||||
|
// 下标:取用时按 len 反推目标,此前的 merge/补首条等数组增删天然自校正。
|
||||||
|
// 锚点以存储态 System 识别——view 层 System→user 转换落地后锚点以 user 形态
|
||||||
|
// 到达,此处识别不到,BP3 自然旁路(不加断点),无副作用。
|
||||||
|
let mut msgs_after_anchor: Option<usize> = None;
|
||||||
|
|
||||||
for m in req.messages.iter() {
|
for (src_idx, m) in req.messages.iter().enumerate() {
|
||||||
match m.role {
|
match m.role {
|
||||||
MessageRole::System => continue,
|
MessageRole::System => {
|
||||||
|
if src_idx > 0 {
|
||||||
|
msgs_after_anchor = Some(0);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
MessageRole::Tool => {
|
MessageRole::Tool => {
|
||||||
// 累积 tool_result 块,遇到非 Tool 消息时 flush。
|
// 累积 tool_result 块,遇到非 Tool 消息时 flush。
|
||||||
// tool_use_id 为 None 时绝不能发 null(GLM anthropic 端点会 500
|
// tool_use_id 为 None 时绝不能发 null(GLM anthropic 端点会 500
|
||||||
@@ -173,7 +224,7 @@ impl AnthropicCompatProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
MessageRole::User => {
|
MessageRole::User => {
|
||||||
Self::flush_tool_results(&mut messages, &mut pending_tool_results);
|
Self::flush_tool_results(&mut messages, &mut pending_tool_results, &mut msgs_after_anchor);
|
||||||
// 多模态 user 消息 → content blocks 数组(text/image)。
|
// 多模态 user 消息 → content blocks 数组(text/image)。
|
||||||
// 含图时把 content + parts 拍平成 blocks:Text 片 → {type:text},
|
// 含图时把 content + parts 拍平成 blocks:Text 片 → {type:text},
|
||||||
// Image 片 → {type:image, source:{type:base64, media_type, data}}。
|
// Image 片 → {type:image, source:{type:base64, media_type, data}}。
|
||||||
@@ -192,9 +243,12 @@ impl AnthropicCompatProvider {
|
|||||||
} else {
|
} else {
|
||||||
messages.push(serde_json::json!({ "role": "user", "content": m.content }));
|
messages.push(serde_json::json!({ "role": "user", "content": m.content }));
|
||||||
}
|
}
|
||||||
|
if let Some(k) = &mut msgs_after_anchor {
|
||||||
|
*k += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
MessageRole::Assistant => {
|
MessageRole::Assistant => {
|
||||||
Self::flush_tool_results(&mut messages, &mut pending_tool_results);
|
Self::flush_tool_results(&mut messages, &mut pending_tool_results, &mut msgs_after_anchor);
|
||||||
let mut content: Vec<serde_json::Value> = Vec::new();
|
let mut content: Vec<serde_json::Value> = Vec::new();
|
||||||
if !m.content.is_empty() {
|
if !m.content.is_empty() {
|
||||||
content.push(serde_json::json!({ "type": "text", "text": m.content }));
|
content.push(serde_json::json!({ "type": "text", "text": m.content }));
|
||||||
@@ -218,10 +272,13 @@ impl AnthropicCompatProvider {
|
|||||||
content.push(serde_json::json!({ "type": "text", "text": "" }));
|
content.push(serde_json::json!({ "type": "text", "text": "" }));
|
||||||
}
|
}
|
||||||
messages.push(serde_json::json!({ "role": "assistant", "content": content }));
|
messages.push(serde_json::json!({ "role": "assistant", "content": content }));
|
||||||
|
if let Some(k) = &mut msgs_after_anchor {
|
||||||
|
*k += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Self::flush_tool_results(&mut messages, &mut pending_tool_results);
|
Self::flush_tool_results(&mut messages, &mut pending_tool_results, &mut msgs_after_anchor);
|
||||||
|
|
||||||
// 合并相邻 user 块。Anthropic 协议要求 user/assistant 严格交替,连续 user
|
// 合并相邻 user 块。Anthropic 协议要求 user/assistant 严格交替,连续 user
|
||||||
// 触发 GLM 1214。场景:drainQueue 续发(前一轮以 tool_result 结尾 + 新 user)→ flush 把
|
// 触发 GLM 1214。场景:drainQueue 续发(前一轮以 tool_result 结尾 + 新 user)→ flush 把
|
||||||
@@ -240,10 +297,12 @@ impl AnthropicCompatProvider {
|
|||||||
// (正常会话首条本就是 user),远优于丢弃上下文。
|
// (正常会话首条本就是 user),远优于丢弃上下文。
|
||||||
Self::ensure_leading_user(&mut messages);
|
Self::ensure_leading_user(&mut messages);
|
||||||
|
|
||||||
// 消息级缓存断点(开关开):除 system 断点外在历史段尾部再打一个 cache_control,
|
// 消息级缓存断点(开关开):BP2 打在历史段稳定边界,使 system 之后的对话历史
|
||||||
// 使 system 之后的对话历史也可命中(否则只缓存 system,长会话历史段全价重算)。
|
// 也可命中(否则只缓存 system,长会话历史段全价重算);BP3(缓存友好开关,默认开)
|
||||||
|
// 加打摘要锚点链之后第一条,BP2 因裁剪边界/压缩失效时 [system+摘要链] 小前缀仍命中。
|
||||||
if anthropic_cache_enabled() {
|
if anthropic_cache_enabled() {
|
||||||
Self::apply_message_cache_breakpoint(&mut messages);
|
let after_anchor = if bp3_enabled() { msgs_after_anchor } else { None };
|
||||||
|
Self::apply_message_cache_breakpoint(&mut messages, after_anchor);
|
||||||
}
|
}
|
||||||
|
|
||||||
let tools = req.tools.map(|defs| {
|
let tools = req.tools.map(|defs| {
|
||||||
@@ -288,9 +347,11 @@ impl AnthropicCompatProvider {
|
|||||||
/// 单个 ContentPart → Anthropic content block(text/image)。
|
/// 单个 ContentPart → Anthropic content block(text/image)。
|
||||||
/// - Text 片 → {type:text, text}
|
/// - Text 片 → {type:text, text}
|
||||||
/// - Image 片 → {type:image, source:{type:base64, media_type, data}}
|
/// - Image 片 → {type:image, source:{type:base64, media_type, data}}
|
||||||
/// base64 内嵌;url 模式当前不可达,兜底发空 data + warn(保留原行为)。
|
/// base64 内嵌;本地 file:// 引用(#958)先展开为 base64,http(s) url 模式
|
||||||
|
/// 当前不可达,兜底发空 data + warn(保留原行为)。
|
||||||
/// match 取得 p 所有权后直接 move media_type/base64,避免大 base64 clone。
|
/// match 取得 p 所有权后直接 move media_type/base64,避免大 base64 clone。
|
||||||
fn content_part_to_block(p: crate::provider::ContentPart) -> serde_json::Value {
|
fn content_part_to_block(p: crate::provider::ContentPart) -> serde_json::Value {
|
||||||
|
let p = crate::image_ref::inline_local_image_part(p);
|
||||||
match p {
|
match p {
|
||||||
crate::provider::ContentPart::Text { text } => serde_json::json!({
|
crate::provider::ContentPart::Text { text } => serde_json::json!({
|
||||||
"type": "text",
|
"type": "text",
|
||||||
@@ -326,34 +387,61 @@ impl AnthropicCompatProvider {
|
|||||||
fn flush_tool_results(
|
fn flush_tool_results(
|
||||||
messages: &mut Vec<serde_json::Value>,
|
messages: &mut Vec<serde_json::Value>,
|
||||||
pending: &mut Vec<serde_json::Value>,
|
pending: &mut Vec<serde_json::Value>,
|
||||||
|
msgs_after_anchor: &mut Option<usize>,
|
||||||
) {
|
) {
|
||||||
if pending.is_empty() {
|
if pending.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let blocks: Vec<serde_json::Value> = pending.drain(..).collect();
|
let blocks: Vec<serde_json::Value> = pending.drain(..).collect();
|
||||||
messages.push(serde_json::json!({ "role": "user", "content": blocks }));
|
messages.push(serde_json::json!({ "role": "user", "content": blocks }));
|
||||||
|
if let Some(k) = msgs_after_anchor {
|
||||||
|
*k += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 消息级缓存断点:在「历史段稳定边界」消息的末个 content block 上打 cache_control。
|
/// 消息级缓存断点:BP2 打在「历史段稳定边界」的末个 content block 上,BP3(可选)
|
||||||
|
/// 加打「摘要锚点链之后第一条」——`after_anchor` 为锚点链后的消息条数
|
||||||
|
/// (Some(0)=锚点收尾无后继,不打;None=无锚点,只打 BP2)。
|
||||||
///
|
///
|
||||||
/// 断点落点取**末条 user 消息之前的相邻前一条**(末条非 user 收尾的异常序列则取末条):
|
/// BP2 落点取**末条 user 消息之前的相邻前一条**(末条非 user 收尾的异常序列则取末条):
|
||||||
/// 末条 user 是每轮请求的漂移边界——缓存拆分模式下它是请求末尾追加的易变尾段
|
/// 末条 user 是每轮请求的漂移边界——缓存拆分模式下它是请求末尾追加的易变尾段
|
||||||
/// (日期/清单/augmentation,每次重组),非拆分模式下是本轮新问题,二者都不属于
|
/// (日期/清单/augmentation,每次重组),非拆分模式下是本轮新问题,二者都不属于
|
||||||
/// 只增历史;打在它身上断点位置随请求漂移,永远无法命中。标其前一条(assistant /
|
/// 只增历史;打在它身上断点位置随请求漂移,永远无法命中。标其前一条(assistant /
|
||||||
/// tool_result 归并的 user)则断点始终落在只增历史的末尾,历史只增不改位:
|
/// tool_result 归并的 user)则断点始终落在只增历史的末尾,历史只增不改位:
|
||||||
/// 上一轮写入的缓存前缀是本轮请求前缀的逐字节前缀 → 命中后增量续写,滚动前进。
|
/// 上一轮写入的缓存前缀是本轮请求前缀的逐字节前缀 → 命中后增量续写,滚动前进。
|
||||||
///
|
///
|
||||||
|
/// BP3 服务于 BP2 频繁失效的场景:BP2 随裁剪边界移动/压缩事件/新近度豁免改写而
|
||||||
|
/// 迁位,前缀反复全价重算;BP3 前缀只含 [system+摘要链+1 条],失效源仅压缩事件,
|
||||||
|
/// 为该小前缀保底命中。与 BP2 同条(短序列)时跳过,BP2 优先。
|
||||||
|
///
|
||||||
/// content 形态:string(简写)→ 升级为单 text block 数组再标;array(blocks)→ 标末块。
|
/// content 形态:string(简写)→ 升级为单 text block 数组再标;array(blocks)→ 标末块。
|
||||||
/// 断点预算:Anthropic 单请求最多 4 个 cache_control,system 1 个 + 消息级 1 个,余量充足。
|
/// 断点预算:Anthropic 单请求最多 4 个 cache_control——system 1 + BP2 1 + BP3 1 = 3,
|
||||||
fn apply_message_cache_breakpoint(messages: &mut [serde_json::Value]) {
|
/// 余 1 备用。
|
||||||
|
fn apply_message_cache_breakpoint(
|
||||||
|
messages: &mut [serde_json::Value],
|
||||||
|
after_anchor: Option<usize>,
|
||||||
|
) {
|
||||||
let n = messages.len();
|
let n = messages.len();
|
||||||
if n == 0 {
|
if n == 0 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let last_is_user =
|
let last_is_user =
|
||||||
messages[n - 1].get("role").and_then(|r| r.as_str()) == Some("user");
|
messages[n - 1].get("role").and_then(|r| r.as_str()) == Some("user");
|
||||||
let idx = if last_is_user && n >= 2 { n - 2 } else { n - 1 };
|
let bp2_idx = if last_is_user && n >= 2 { n - 2 } else { n - 1 };
|
||||||
let msg = &mut messages[idx];
|
if let Some(k) = after_anchor {
|
||||||
|
if k > 0 && k <= n {
|
||||||
|
let bp3_idx = n - k;
|
||||||
|
if bp3_idx != bp2_idx {
|
||||||
|
Self::mark_cache_control(&mut messages[bp3_idx]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self::mark_cache_control(&mut messages[bp2_idx]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单条消息打 cache_control:string content 升级为单 text block 数组再标;
|
||||||
|
/// array(blocks) 标末块;其余形态(异常)不动。
|
||||||
|
fn mark_cache_control(msg: &mut serde_json::Value) {
|
||||||
let cache_control = serde_json::json!({ "type": "ephemeral" });
|
let cache_control = serde_json::json!({ "type": "ephemeral" });
|
||||||
match msg.get("content").cloned() {
|
match msg.get("content").cloned() {
|
||||||
Some(serde_json::Value::Array(mut blocks)) => {
|
Some(serde_json::Value::Array(mut blocks)) => {
|
||||||
@@ -427,6 +515,22 @@ impl AnthropicCompatProvider {
|
|||||||
.and_then(|m| m.get("role").and_then(|r| r.as_str()))
|
.and_then(|m| m.get("role").and_then(|r| r.as_str()))
|
||||||
.unwrap_or("");
|
.unwrap_or("");
|
||||||
if first_role == "user" {
|
if first_role == "user" {
|
||||||
|
// 首条 user 但 content 为空串(会话压缩/续发残留,典型 text(0B)):原样发出会被
|
||||||
|
// precheck "user content 空" 拦下而整请求失败。与上方补占位同思路——就地填
|
||||||
|
// 占位文案(空串无信息可丢),上游实测接受非空占位,precheck 亦放行。
|
||||||
|
let empty_str = messages[0]
|
||||||
|
.get("content")
|
||||||
|
.and_then(|c| c.as_str())
|
||||||
|
.map(|s| s.is_empty())
|
||||||
|
.unwrap_or(false);
|
||||||
|
if empty_str {
|
||||||
|
warn!(
|
||||||
|
msg_count = messages.len(),
|
||||||
|
"ensure_leading_user: 首条 user content 为空串,填占位(防 precheck 拦截)"
|
||||||
|
);
|
||||||
|
messages[0]["content"] =
|
||||||
|
serde_json::json!("(continued from previous context)");
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 空 Vec(异常会话经 sanitize 清空)或首条非 user → 补 user 占位:
|
// 空 Vec(异常会话经 sanitize 清空)或首条非 user → 补 user 占位:
|
||||||
@@ -711,6 +815,7 @@ impl LlmProvider for AnthropicCompatProvider {
|
|||||||
prompt_cache_miss_tokens: resp.usage.cache_creation_input_tokens,
|
prompt_cache_miss_tokens: resp.usage.cache_creation_input_tokens,
|
||||||
reasoning_tokens: 0,
|
reasoning_tokens: 0,
|
||||||
};
|
};
|
||||||
|
log_cache_usage(&usage);
|
||||||
AttemptOutcome::Ok(CompletionResponse {
|
AttemptOutcome::Ok(CompletionResponse {
|
||||||
text,
|
text,
|
||||||
model: resp.model,
|
model: resp.model,
|
||||||
@@ -743,47 +848,94 @@ impl LlmProvider for AnthropicCompatProvider {
|
|||||||
// 流式长生成任务会被误砍。改用 tokio::time::timeout
|
// 流式长生成任务会被误砍。改用 tokio::time::timeout
|
||||||
// 包裹 send().await,只管建连+首响应头,不管后续 body 读取(后续由 stream_llm idle timeout 兜底)。
|
// 包裹 send().await,只管建连+首响应头,不管后续 body 读取(后续由 stream_llm idle timeout 兜底)。
|
||||||
// 60s 选型:正常 send(建连+收 200 headers)<5s,60s 足够宽容。
|
// 60s 选型:正常 send(建连+收 200 headers)<5s,60s 足够宽容。
|
||||||
let send_future = self
|
// send 阶段弱网退避: connect 类失败(隧道断网/基站切换/DNS 抖动)请求未发出, 重试零副作用。
|
||||||
.auth_headers(self.client.post(self.messages_url()))
|
// 网络感知分速(net_probe 基准面): 本机断网进长等待按自适应间隔探测恢复(隧道级分钟级波动),
|
||||||
.json(&body)
|
// 单点故障指数短退避 1s→8s; 10 分钟窗口耗尽才放弃。timeout/decode/HTTP 状态错不重试。
|
||||||
.version(reqwest::Version::HTTP_11)
|
// 与 openai_compat 同构。
|
||||||
.send();
|
let resp = {
|
||||||
let resp = match tokio::time::timeout(Duration::from_secs(60), send_future).await {
|
let start = std::time::Instant::now();
|
||||||
Ok(Ok(r)) => r,
|
let mut offline_logged = false;
|
||||||
Ok(Err(e)) => {
|
let mut offline_polls = 0usize;
|
||||||
// 谓词结构化分类(对齐 openai 路径,共用 crate::send_error_class)。原 bail 串
|
let mut attempt = 0usize;
|
||||||
// [timeout={} connect={}=...] 的字面词让一切错误(含 builder 错)侥幸可重试。
|
loop {
|
||||||
// 类词改由谓词决定置串首;谓词转储缩写为 t/c/b/r 防误触分类词表,source 链
|
attempt += 1;
|
||||||
// 保留真实传输词(reset/broken pipe 等)作串兜底。
|
let send_future = self
|
||||||
let class = crate::send_error_class(crate::SendErrPreds::of(&e));
|
.auth_headers(self.client.post(self.messages_url()))
|
||||||
tracing::error!(
|
.json(&body)
|
||||||
is_timeout = e.is_timeout(),
|
.version(reqwest::Version::HTTP_11)
|
||||||
is_connect = e.is_connect(),
|
.send();
|
||||||
is_body = e.is_body(),
|
let outcome = tokio::time::timeout(Duration::from_secs(60), send_future).await;
|
||||||
is_request = e.is_request(),
|
match outcome {
|
||||||
class = class,
|
Ok(Ok(r)) => break r,
|
||||||
url = %self.messages_url(),
|
Ok(Err(e)) => {
|
||||||
source = ?std::error::Error::source(&e),
|
// 谓词结构化分类(对齐 openai 路径,共用 crate::send_error_class)。原 bail 串
|
||||||
"Anthropic 流式 send 失败"
|
// [timeout={} connect={}=...] 的字面词让一切错误(含 builder 错)侥幸可重试。
|
||||||
);
|
// 类词改由谓词决定置串首;谓词转储缩写为 t/c/b/r 防误触分类词表,source 链
|
||||||
anyhow::bail!(
|
// 保留真实传输词(reset/broken pipe 等)作串兜底。
|
||||||
"send 失败({}): {} [t={} c={} b={} r={} src={:?}]",
|
let preds = crate::SendErrPreds::of(&e);
|
||||||
class,
|
let retryable = preds.is_connect || (preds.is_request && !preds.is_builder);
|
||||||
e,
|
if retryable && start.elapsed() < crate::net_probe::SEND_RETRY_WINDOW {
|
||||||
e.is_timeout(),
|
// 本机断网: 等基准面恢复(自适应间隔), 不消耗退避档位
|
||||||
e.is_connect(),
|
if !crate::net_probe::baseline_alive().await {
|
||||||
e.is_body(),
|
offline_polls += 1;
|
||||||
e.is_request(),
|
let wait = crate::net_probe::offline_probe_wait(offline_polls);
|
||||||
std::error::Error::source(&e)
|
if !offline_logged {
|
||||||
);
|
warn!(
|
||||||
}
|
url = %self.messages_url(),
|
||||||
Err(_elapsed) => {
|
window_secs = crate::net_probe::SEND_RETRY_WINDOW.as_secs(),
|
||||||
// send 阶段超时(60s 未返回 HTTP 响应头):GLM 端点可能不可达或极慢
|
"本机网络不可达(疑似隧道/电梯弱网), 按自适应间隔探测恢复, 恢复后立即重试"
|
||||||
tracing::error!(
|
);
|
||||||
url = %self.messages_url(),
|
offline_logged = true;
|
||||||
"Anthropic 流式 send 超时(60s 未返回响应头)"
|
}
|
||||||
);
|
tokio::time::sleep(wait).await;
|
||||||
anyhow::bail!("流式请求超时(60秒未收到 HTTP 响应,可能服务不可达或被防火墙拦截)");
|
continue;
|
||||||
|
}
|
||||||
|
offline_polls = 0;
|
||||||
|
offline_logged = false;
|
||||||
|
let delay = Duration::from_millis(
|
||||||
|
1000u64.saturating_mul(1 << (attempt - 1).min(3)).min(8000),
|
||||||
|
);
|
||||||
|
warn!(
|
||||||
|
url = %self.messages_url(),
|
||||||
|
attempt,
|
||||||
|
delay_ms = delay.as_millis() as u64,
|
||||||
|
error = %e,
|
||||||
|
"流式 send 连接失败, 退避重试"
|
||||||
|
);
|
||||||
|
tokio::time::sleep(delay).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let class = crate::send_error_class(preds);
|
||||||
|
tracing::error!(
|
||||||
|
is_timeout = e.is_timeout(),
|
||||||
|
is_connect = e.is_connect(),
|
||||||
|
is_body = e.is_body(),
|
||||||
|
is_request = e.is_request(),
|
||||||
|
class = class,
|
||||||
|
url = %self.messages_url(),
|
||||||
|
source = ?std::error::Error::source(&e),
|
||||||
|
"Anthropic 流式 send 失败"
|
||||||
|
);
|
||||||
|
anyhow::bail!(
|
||||||
|
"send 失败({}): {} [t={} c={} b={} r={} src={:?}]",
|
||||||
|
class,
|
||||||
|
e,
|
||||||
|
e.is_timeout(),
|
||||||
|
e.is_connect(),
|
||||||
|
e.is_body(),
|
||||||
|
e.is_request(),
|
||||||
|
std::error::Error::source(&e)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(_elapsed) => {
|
||||||
|
// send 阶段超时(60s 未返回 HTTP 响应头):GLM 端点可能不可达或极慢
|
||||||
|
tracing::error!(
|
||||||
|
url = %self.messages_url(),
|
||||||
|
"Anthropic 流式 send 超时(60s 未返回响应头)"
|
||||||
|
);
|
||||||
|
anyhow::bail!("流式请求超时(60秒未收到 HTTP 响应,可能服务不可达或被防火墙拦截)");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -809,6 +961,12 @@ impl LlmProvider for AnthropicCompatProvider {
|
|||||||
if let Some(err) = chunk.error.as_mut() {
|
if let Some(err) = chunk.error.as_mut() {
|
||||||
*err = format!("{} | messages 摘要: {}", err, messages_summary);
|
*err = format!("{} | messages 摘要: {}", err, messages_summary);
|
||||||
}
|
}
|
||||||
|
// 流式收尾(message_stop 终态帧)即 usage 就绪点,输出命中率观测日志
|
||||||
|
if chunk.finished {
|
||||||
|
if let Some(u) = &chunk.usage {
|
||||||
|
log_cache_usage(u);
|
||||||
|
}
|
||||||
|
}
|
||||||
chunks.push(Ok(chunk));
|
chunks.push(Ok(chunk));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1116,6 +1274,46 @@ mod tests {
|
|||||||
assert_eq!(content[1]["source"]["data"], "iVBOR");
|
assert_eq!(content[1]["source"]["data"], "iVBOR");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 本地 file:// 引用(#958)在转换层展开为 base64 source;缺失降级 text 块不炸请求
|
||||||
|
#[test]
|
||||||
|
fn anthropic_convert_expands_local_file_ref() {
|
||||||
|
use base64::Engine as _;
|
||||||
|
let _convert = convert_lock();
|
||||||
|
let dir = std::env::temp_dir().join("df-ai-anthropic-ref-test");
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let path = dir.join("ref.png");
|
||||||
|
std::fs::write(&path, b"\x89PNG ok").unwrap();
|
||||||
|
|
||||||
|
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
|
||||||
|
let req = CompletionRequest {
|
||||||
|
model: "claude-3-5-sonnet".into(),
|
||||||
|
messages: vec![ChatMessage::user_parts(
|
||||||
|
"看图",
|
||||||
|
vec![crate::provider::ContentPart::Image {
|
||||||
|
url: Some(format!("file://{}", path.display())),
|
||||||
|
base64: None,
|
||||||
|
media_type: Some("image/png".into()),
|
||||||
|
alt: None,
|
||||||
|
}],
|
||||||
|
)],
|
||||||
|
temperature: None,
|
||||||
|
max_tokens: None,
|
||||||
|
stream: false,
|
||||||
|
tools: None,
|
||||||
|
tool_choice: None,
|
||||||
|
reasoning_content: None,
|
||||||
|
};
|
||||||
|
let body = provider.convert_request(req);
|
||||||
|
let user_msg = body
|
||||||
|
.messages
|
||||||
|
.iter()
|
||||||
|
.find(|m| m.get("role").and_then(|r| r.as_str()) == Some("user"))
|
||||||
|
.expect("应有 user 消息");
|
||||||
|
let content = user_msg.get("content").and_then(|c| c.as_array()).expect("user content 应为数组");
|
||||||
|
assert_eq!(content[1]["source"]["data"], base64::engine::general_purpose::STANDARD.encode(b"\x89PNG ok"));
|
||||||
|
std::fs::remove_file(&path).ok();
|
||||||
|
}
|
||||||
|
|
||||||
/// 纯文本 user 消息 → content 仍是字符串简写(无图不数组化)
|
/// 纯文本 user 消息 → content 仍是字符串简写(无图不数组化)
|
||||||
#[test]
|
#[test]
|
||||||
fn anthropic_convert_text_only_user_remains_string() {
|
fn anthropic_convert_text_only_user_remains_string() {
|
||||||
@@ -1153,7 +1351,7 @@ mod tests {
|
|||||||
serde_json::json!({ "role": "assistant", "content": [{"type": "text", "text": "a1"}] }),
|
serde_json::json!({ "role": "assistant", "content": [{"type": "text", "text": "a1"}] }),
|
||||||
serde_json::json!({ "role": "user", "content": "q2" }),
|
serde_json::json!({ "role": "user", "content": "q2" }),
|
||||||
];
|
];
|
||||||
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages);
|
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages, None);
|
||||||
// 前一条 assistant 的末块带 cache_control
|
// 前一条 assistant 的末块带 cache_control
|
||||||
let a1 = &messages[1];
|
let a1 = &messages[1];
|
||||||
let blocks = a1["content"].as_array().unwrap();
|
let blocks = a1["content"].as_array().unwrap();
|
||||||
@@ -1170,7 +1368,7 @@ mod tests {
|
|||||||
serde_json::json!({ "role": "assistant", "content": "a1" }),
|
serde_json::json!({ "role": "assistant", "content": "a1" }),
|
||||||
serde_json::json!({ "role": "user", "content": "q2" }),
|
serde_json::json!({ "role": "user", "content": "q2" }),
|
||||||
];
|
];
|
||||||
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages);
|
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages, None);
|
||||||
let blocks = messages[1]["content"].as_array().expect("string 应升级为 blocks 数组");
|
let blocks = messages[1]["content"].as_array().expect("string 应升级为 blocks 数组");
|
||||||
assert_eq!(blocks.len(), 1);
|
assert_eq!(blocks.len(), 1);
|
||||||
assert_eq!(blocks[0]["type"], "text");
|
assert_eq!(blocks[0]["type"], "text");
|
||||||
@@ -1184,7 +1382,7 @@ mod tests {
|
|||||||
let mut messages = vec![
|
let mut messages = vec![
|
||||||
serde_json::json!({ "role": "user", "content": "only" }),
|
serde_json::json!({ "role": "user", "content": "only" }),
|
||||||
];
|
];
|
||||||
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages);
|
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages, None);
|
||||||
let blocks = messages[0]["content"].as_array().expect("单条应升级为 blocks");
|
let blocks = messages[0]["content"].as_array().expect("单条应升级为 blocks");
|
||||||
assert_eq!(blocks[0]["cache_control"]["type"], "ephemeral");
|
assert_eq!(blocks[0]["cache_control"]["type"], "ephemeral");
|
||||||
}
|
}
|
||||||
@@ -1193,10 +1391,152 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn cache_breakpoint_empty_messages_noop() {
|
fn cache_breakpoint_empty_messages_noop() {
|
||||||
let mut messages: Vec<serde_json::Value> = vec![];
|
let mut messages: Vec<serde_json::Value> = vec![];
|
||||||
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages);
|
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages, None);
|
||||||
assert!(messages.is_empty());
|
assert!(messages.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// BP3 落「锚点链后首条」(按锚点后条数反推下标),与 BP2 各占一条互不覆盖。
|
||||||
|
#[test]
|
||||||
|
fn cache_breakpoint_bp3_marks_first_message_after_anchor_chain() {
|
||||||
|
// 模拟 [锚点链][q0][a0][q1][a1][q2] 转换后的数组:锚点后 5 条
|
||||||
|
let mut messages = vec![
|
||||||
|
serde_json::json!({ "role": "user", "content": "q0" }),
|
||||||
|
serde_json::json!({ "role": "assistant", "content": "a0" }),
|
||||||
|
serde_json::json!({ "role": "user", "content": "q1" }),
|
||||||
|
serde_json::json!({ "role": "assistant", "content": "a1" }),
|
||||||
|
serde_json::json!({ "role": "user", "content": "q2" }),
|
||||||
|
];
|
||||||
|
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages, Some(5));
|
||||||
|
// BP3 → idx0(q0);BP2(末条 user,取 n-2)→ idx3(a1)
|
||||||
|
for idx in [0usize, 3] {
|
||||||
|
let blocks = messages[idx]["content"].as_array().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
blocks.last().unwrap()["cache_control"]["type"],
|
||||||
|
"ephemeral",
|
||||||
|
"idx{} 应带断点",
|
||||||
|
idx
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(messages[4]["content"].as_str(), Some("q2"), "末条 user 不打点");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// BP3 与 BP2 同条(短序列)→ 跳过 BP3,BP2 优先,断点预算不重复占。
|
||||||
|
#[test]
|
||||||
|
fn cache_breakpoint_bp3_skipped_when_colliding_with_bp2() {
|
||||||
|
let mut messages = vec![
|
||||||
|
serde_json::json!({ "role": "user", "content": "q0" }),
|
||||||
|
serde_json::json!({ "role": "assistant", "content": "a0" }),
|
||||||
|
serde_json::json!({ "role": "user", "content": "q1" }),
|
||||||
|
];
|
||||||
|
// 锚点后 2 条 → BP3 目标 idx1,与 BP2(n-2=1)同条 → 跳过
|
||||||
|
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages, Some(2));
|
||||||
|
let blocks = messages[1]["content"].as_array().unwrap();
|
||||||
|
assert_eq!(blocks.last().unwrap()["cache_control"]["type"], "ephemeral");
|
||||||
|
assert!(messages[0]["content"].as_str().is_some(), "q0 应保持字符串简写");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 锚点收尾无后继(after_anchor=0)→ 不打 BP3,BP2 照常落点。
|
||||||
|
#[test]
|
||||||
|
fn cache_breakpoint_bp3_zero_after_anchor_noop() {
|
||||||
|
let mut messages = vec![
|
||||||
|
serde_json::json!({ "role": "user", "content": "q0" }),
|
||||||
|
serde_json::json!({ "role": "user", "content": "q1" }),
|
||||||
|
];
|
||||||
|
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages, Some(0));
|
||||||
|
// BP2(末条 user → n-2=0)照常标 q0
|
||||||
|
let blocks = messages[0]["content"].as_array().expect("BP2 应照常落点");
|
||||||
|
assert_eq!(blocks.last().unwrap()["cache_control"]["type"], "ephemeral");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- BP3 开关门(convert 级,进程级开关用例须持 convert_lock) ----------
|
||||||
|
|
||||||
|
/// 构造 BP3 开关用例的请求:首条组装层 system_prompt + (可选)锚点链 + 历史段。
|
||||||
|
/// 转换后数组恒为 [q0, a0, q1](锚点折叠进顶层 system)。
|
||||||
|
fn bp3_req(with_anchor: bool) -> CompletionRequest {
|
||||||
|
let mut messages = vec![
|
||||||
|
ChatMessage::system("sys-prompt"),
|
||||||
|
ChatMessage::user("q0"),
|
||||||
|
ChatMessage::assistant("a0"),
|
||||||
|
ChatMessage::user("q1"),
|
||||||
|
];
|
||||||
|
if with_anchor {
|
||||||
|
messages.insert(1, ChatMessage::system("## 压缩摘要"));
|
||||||
|
messages.insert(2, ChatMessage::system("## 话题标记"));
|
||||||
|
}
|
||||||
|
CompletionRequest {
|
||||||
|
model: "claude-3-5-sonnet".into(),
|
||||||
|
messages,
|
||||||
|
temperature: None,
|
||||||
|
max_tokens: None,
|
||||||
|
stream: false,
|
||||||
|
tools: None,
|
||||||
|
tool_choice: None,
|
||||||
|
reasoning_content: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// BP3 开 + 有锚点 → 断点落摘要链后第一条(q0),BP2 照常落历史尾(a0)。
|
||||||
|
#[test]
|
||||||
|
fn bp3_enabled_with_anchor_marks_first_message_after_chain() {
|
||||||
|
let _convert = convert_lock();
|
||||||
|
set_anthropic_cache_enabled(true);
|
||||||
|
set_bp3_enabled(true);
|
||||||
|
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
|
||||||
|
let body = provider.convert_request(bp3_req(true));
|
||||||
|
assert_eq!(body.messages.len(), 3);
|
||||||
|
// BP3:锚点链后首条 q0(原字符串升级为 blocks + cache_control)
|
||||||
|
let q0_blocks = body.messages[0]["content"].as_array().expect("BP3 应标在锚点链后首条 q0");
|
||||||
|
assert_eq!(q0_blocks[0]["text"], "q0");
|
||||||
|
assert_eq!(q0_blocks[0]["cache_control"]["type"], "ephemeral");
|
||||||
|
// BP2 照常:末条 user 前相邻一条 a0
|
||||||
|
let a0_blocks = body.messages[1]["content"].as_array().unwrap();
|
||||||
|
assert_eq!(a0_blocks.last().unwrap()["cache_control"]["type"], "ephemeral");
|
||||||
|
// 末条 user 保持字符串简写
|
||||||
|
assert_eq!(body.messages[2]["content"].as_str(), Some("q1"));
|
||||||
|
set_anthropic_cache_enabled(false);
|
||||||
|
set_bp3_enabled(true); // 复位到进程默认(默认开),防污染其他并行用例
|
||||||
|
}
|
||||||
|
|
||||||
|
/// BP3 关 → 断点布局与历史一致(仅 BP2,锚点链后首条不打点)。
|
||||||
|
#[test]
|
||||||
|
fn bp3_disabled_keeps_legacy_breakpoint_layout() {
|
||||||
|
let _convert = convert_lock();
|
||||||
|
set_anthropic_cache_enabled(true);
|
||||||
|
set_bp3_enabled(false);
|
||||||
|
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
|
||||||
|
let body = provider.convert_request(bp3_req(true));
|
||||||
|
assert_eq!(body.messages.len(), 3);
|
||||||
|
assert_eq!(
|
||||||
|
body.messages[0]["content"].as_str(),
|
||||||
|
Some("q0"),
|
||||||
|
"BP3 关,q0 保持字符串简写"
|
||||||
|
);
|
||||||
|
let a0_blocks = body.messages[1]["content"].as_array().unwrap();
|
||||||
|
assert_eq!(a0_blocks.last().unwrap()["cache_control"]["type"], "ephemeral", "BP2 照常");
|
||||||
|
set_anthropic_cache_enabled(false);
|
||||||
|
set_bp3_enabled(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 无锚点(仅组装层首条 system)→ 不加 BP3,仅 BP2。
|
||||||
|
#[test]
|
||||||
|
fn bp3_no_anchor_no_extra_breakpoint() {
|
||||||
|
let _convert = convert_lock();
|
||||||
|
set_anthropic_cache_enabled(true);
|
||||||
|
set_bp3_enabled(true);
|
||||||
|
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
|
||||||
|
let body = provider.convert_request(bp3_req(false));
|
||||||
|
assert_eq!(body.messages.len(), 3);
|
||||||
|
assert_eq!(
|
||||||
|
body.messages[0]["content"].as_str(),
|
||||||
|
Some("q0"),
|
||||||
|
"无锚点应不加 BP3"
|
||||||
|
);
|
||||||
|
let a0_blocks = body.messages[1]["content"].as_array().unwrap();
|
||||||
|
assert_eq!(a0_blocks.last().unwrap()["cache_control"]["type"], "ephemeral", "BP2 照常");
|
||||||
|
set_anthropic_cache_enabled(false);
|
||||||
|
set_bp3_enabled(true);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- ensure_leading_user(首条 assistant → 补 user 占位,保留上下文)----------
|
// ---------- ensure_leading_user(首条 assistant → 补 user 占位,保留上下文)----------
|
||||||
|
|
||||||
/// 辅助:构造 assistant(tool_use) 消息
|
/// 辅助:构造 assistant(tool_use) 消息
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
//! 压缩可召回存档:被压缩原文 JSONL 落盘 + 召回句柄拼装。
|
||||||
|
//!
|
||||||
|
//! 压缩把原文从 LLM 上下文隔离(is_active 白名单过滤),摘要必有损。本模块在压缩标记时
|
||||||
|
//! 把本次被压原文追加写入 `<data_dir>/context-archive/<conv_id>-archive.jsonl`
|
||||||
|
//! (JSON Lines,一行一条 {role, content}),调用方在压缩摘要尾部拼召回句柄行,
|
||||||
|
//! LLM 需要原文时经 read_file 回读。会话一文件、追加写,目录不存在则创建;
|
||||||
|
//! 会话删除级联清理留待后续挂点。存档只落盘不进上下文,token 估算零影响;
|
||||||
|
//! 不与 tool_offload 复用(那是 src-tauri 侧机制,跨 crate 不混用)。
|
||||||
|
|
||||||
|
use std::fs::OpenOptions;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use crate::provider::ChatMessage;
|
||||||
|
|
||||||
|
/// 存档目录名(data_dir 下)
|
||||||
|
const ARCHIVE_DIR: &str = "context-archive";
|
||||||
|
|
||||||
|
/// 一次压缩事件的存档目标(会话维度)。调用方读到开关开(`df-ai-compress-recall`)后经
|
||||||
|
/// [`super::ContextManager::set_compress_recall`] 注入,压缩标记时一次性消费。
|
||||||
|
pub(super) struct RecallTarget {
|
||||||
|
data_dir: PathBuf,
|
||||||
|
conv_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RecallTarget {
|
||||||
|
pub(super) fn new(data_dir: &Path, conv_id: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
data_dir: data_dir.to_path_buf(),
|
||||||
|
conv_id: conv_id.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 存档文件路径:`<data_dir>/context-archive/<conv_id>-archive.jsonl`
|
||||||
|
fn file(&self) -> PathBuf {
|
||||||
|
self.data_dir
|
||||||
|
.join(ARCHIVE_DIR)
|
||||||
|
.join(format!("{}-archive.jsonl", self.conv_id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把本次被压原文追加写入会话存档(JSONL 追加语义,不覆盖既有行)。
|
||||||
|
/// 返回存档路径供句柄拼装;空消息/IO 失败返 None(调用方降级为无句柄,压缩本身不受影响)。
|
||||||
|
pub(super) fn append_archive(target: &RecallTarget, msgs: &[ChatMessage]) -> Option<PathBuf> {
|
||||||
|
if msgs.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let path = target.file();
|
||||||
|
let dir = path.parent()?;
|
||||||
|
if let Err(e) = std::fs::create_dir_all(dir) {
|
||||||
|
tracing::warn!(error = %e, dir = %dir.display(), "[archive] 存档目录创建失败,本轮无召回句柄");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut file = match OpenOptions::new().create(true).append(true).open(&path) {
|
||||||
|
Ok(f) => f,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, path = %path.display(), "[archive] 存档文件打开失败,本轮无召回句柄");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for m in msgs {
|
||||||
|
let line = serde_json::json!({ "role": &m.role, "content": m.content });
|
||||||
|
if let Err(e) = writeln!(file, "{line}") {
|
||||||
|
tracing::warn!(error = %e, path = %path.display(), "[archive] 存档行写入失败,本轮无召回句柄");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 召回句柄行:LLM 按此路径 read_file 回读被压原文。
|
||||||
|
pub fn recall_line(path: &Path) -> String {
|
||||||
|
format!("早期历史全文已存档: `{}`(read_file 可召回)", path.display())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 摘要尾部拼召回句柄行(开关开且存档落盘成功时用;开关关调用方直接用原摘要)。
|
||||||
|
pub fn with_recall_line(summary: &str, path: &Path) -> String {
|
||||||
|
format!("{}\n{}", summary, recall_line(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::context::ContextManager;
|
||||||
|
use crate::context_helpers::ContextConfig;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
fn mgr() -> ContextManager {
|
||||||
|
ContextManager::new(ContextConfig {
|
||||||
|
max_tokens: 100_000,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 灌 2 条可压 + 1 条保护区消息,注入召回目标后压缩 [0..2),返回存档路径。
|
||||||
|
fn compress_with_recall(tmp: &TempDir, conv_id: &str) -> PathBuf {
|
||||||
|
let mut m = mgr();
|
||||||
|
m.push(ChatMessage::user("早期问题"));
|
||||||
|
m.push(ChatMessage::assistant("早期回复"));
|
||||||
|
m.push(ChatMessage::user("近期问题"));
|
||||||
|
m.set_compress_recall(tmp.path(), conv_id);
|
||||||
|
let compressed = m.compress_old_messages(2);
|
||||||
|
assert_eq!(compressed.len(), 2, "前置:应压缩 2 条");
|
||||||
|
m.take_archive_path().expect("存档应落盘")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 压缩时存档文件创建,且每行含被压原文的 role+content。
|
||||||
|
#[test]
|
||||||
|
fn compress_with_recall_archives_original_text() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let path = compress_with_recall(&tmp, "conv-a");
|
||||||
|
assert_eq!(
|
||||||
|
path,
|
||||||
|
tmp.path().join("context-archive").join("conv-a-archive.jsonl")
|
||||||
|
);
|
||||||
|
let text = std::fs::read_to_string(&path).unwrap();
|
||||||
|
let mut lines = text.lines();
|
||||||
|
let first: serde_json::Value = serde_json::from_str(lines.next().unwrap()).unwrap();
|
||||||
|
assert_eq!(first["role"], "user");
|
||||||
|
assert_eq!(first["content"], "早期问题");
|
||||||
|
let second: serde_json::Value = serde_json::from_str(lines.next().unwrap()).unwrap();
|
||||||
|
assert_eq!(second["role"], "assistant");
|
||||||
|
assert_eq!(second["content"], "早期回复");
|
||||||
|
assert!(lines.next().is_none(), "不应有多余行");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 开关开:摘要尾部含召回句柄行(含存档路径与 read_file 指引);路径一次性消费。
|
||||||
|
#[test]
|
||||||
|
fn recall_enabled_summary_gets_handle_line() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let path = compress_with_recall(&tmp, "conv-b");
|
||||||
|
let summary = "## 意图\n- 测试主题";
|
||||||
|
let with_handle = with_recall_line(summary, &path);
|
||||||
|
assert!(with_handle.starts_with(summary), "句柄拼在摘要尾部,原摘要在前");
|
||||||
|
let handle = recall_line(&path);
|
||||||
|
assert!(with_handle.ends_with(&handle));
|
||||||
|
assert!(handle.contains(&path.display().to_string()));
|
||||||
|
assert!(handle.contains("read_file"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 开关关(不注入召回目标):不落盘、无句柄路径可拼。
|
||||||
|
#[test]
|
||||||
|
fn recall_off_no_archive_no_handle() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let mut m = mgr();
|
||||||
|
m.push(ChatMessage::user("问题"));
|
||||||
|
m.push(ChatMessage::assistant("回复"));
|
||||||
|
m.push(ChatMessage::user("近期"));
|
||||||
|
let compressed = m.compress_old_messages(2);
|
||||||
|
assert_eq!(compressed.len(), 2);
|
||||||
|
assert!(m.take_archive_path().is_none(), "开关关不应有存档路径");
|
||||||
|
assert!(
|
||||||
|
!tmp.path().join("context-archive").exists(),
|
||||||
|
"开关关不应创建存档目录"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 二次压缩追加不覆盖(JSONL 追加语义):两批原文都在,行数累计。
|
||||||
|
#[test]
|
||||||
|
fn second_compress_appends_not_overwrites() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let mut m = mgr();
|
||||||
|
m.push(ChatMessage::user("第一批"));
|
||||||
|
m.push(ChatMessage::assistant("第一批回复"));
|
||||||
|
m.push(ChatMessage::user("第二批"));
|
||||||
|
m.push(ChatMessage::assistant("第二批回复"));
|
||||||
|
m.push(ChatMessage::user("近期"));
|
||||||
|
m.set_compress_recall(tmp.path(), "conv-c");
|
||||||
|
let _ = m.compress_old_messages(2);
|
||||||
|
// 第二次压缩事件:调用方重新注入(开关可能已变),幂等跳过已压缩,只压新批
|
||||||
|
m.set_compress_recall(tmp.path(), "conv-c");
|
||||||
|
let second = m.compress_old_messages(4);
|
||||||
|
assert_eq!(second.len(), 2, "第二次只压新激活的 2 条");
|
||||||
|
let path = m.take_archive_path().unwrap();
|
||||||
|
let text = std::fs::read_to_string(&path).unwrap();
|
||||||
|
assert_eq!(text.lines().count(), 4, "两次压缩累计 4 行,不覆盖");
|
||||||
|
assert!(text.contains("第一批回复"), "第一批原文仍在");
|
||||||
|
assert!(text.contains("第二批回复"), "第二批原文已追加");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,10 +44,10 @@ fn estimate_message_counts_parts_tokens() {
|
|||||||
mm_tokens,
|
mm_tokens,
|
||||||
text_tokens
|
text_tokens
|
||||||
);
|
);
|
||||||
// base64 字符按 0.35 粗估,约 1.7k * 0.35 ≈ 595 tokens 量级
|
// base64 改按图地板计(~1600,与 provider 端按图计费同口径),不随字符串长度膨胀
|
||||||
assert!(
|
assert!(
|
||||||
mm_tokens > 500,
|
mm_tokens >= 1600,
|
||||||
"大 base64 应贡献可观 token,实际 {}",
|
"大 base64 应按图地板计 token,实际 {}",
|
||||||
mm_tokens
|
mm_tokens
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -72,8 +72,10 @@ fn short_history_no_trim() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn over_budget_trims_old() {
|
fn over_budget_trims_old() {
|
||||||
// 小预算强制裁剪:20 条超预算,触发裁剪且保留保护区
|
// 小预算强制裁剪:20 条超预算,触发裁剪且保留保护区。
|
||||||
let mut mgr = ContextManager::new(cfg(200));
|
// 预算取值须容纳「补偿摘要+保护区 6 条」(中文按 1.2 token/字计约 230)再留裁剪空间,
|
||||||
|
// 否则保护区被挤压、最新消息也被压,测的就不再是「裁旧保新」语义。
|
||||||
|
let mut mgr = ContextManager::new(cfg(400));
|
||||||
// user/assistant 交替(真实对话序列;连续 user 会被 ensure_sequence_legal 合并,无法测条数裁剪)
|
// user/assistant 交替(真实对话序列;连续 user 会被 ensure_sequence_legal 合并,无法测条数裁剪)
|
||||||
for i in 0..20 {
|
for i in 0..20 {
|
||||||
if i % 2 == 0 {
|
if i % 2 == 0 {
|
||||||
@@ -1045,9 +1047,12 @@ fn phase1_append_after_dirty_stays_dirty_until_save() {
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn eviction_inserts_summary_message_at_view_head() {
|
fn eviction_inserts_summary_message_at_view_tail() {
|
||||||
// 触发淘汰 → 视图头部应有一条 "[早期对话概要]" user 摘要(补偿无声丢弃的早期主题),
|
// 触发淘汰 → 视图尾部应有一条 "[早期对话概要]" user 摘要(补偿无声丢弃的早期主题),
|
||||||
// 概要 ≤ EVICTION_SUMMARY_MAX_CHARS,保护消息完好,且纯视图(不污染内存全量/不落库)。
|
// 概要 ≤ EVICTION_SUMMARY_MAX_CHARS,保护消息完好,且纯视图(不污染内存全量/不落库)。
|
||||||
|
// 缓存友好(2026-10):摘要改尾部追加(push)而非头部插入(insert(0))——头部插入位移
|
||||||
|
// 整条前缀字节,使 DeepSeek 等自动前缀缓存 provider 全部缓存失效(命中率掉到个位数)。
|
||||||
|
// 尾部追加让稳定前缀(system + 早期 history)逐轮不变,命中率最大化。
|
||||||
let mut mgr = ContextManager::new(cfg(200));
|
let mut mgr = ContextManager::new(cfg(200));
|
||||||
for i in 0..20 {
|
for i in 0..20 {
|
||||||
if i % 2 == 0 {
|
if i % 2 == 0 {
|
||||||
@@ -1063,46 +1068,47 @@ fn eviction_inserts_summary_message_at_view_head() {
|
|||||||
let (msgs, trimmed) = mgr.build_for_request(0);
|
let (msgs, trimmed) = mgr.build_for_request(0);
|
||||||
assert!(trimmed, "超预算应触发裁剪");
|
assert!(trimmed, "超预算应触发裁剪");
|
||||||
|
|
||||||
// 视图头部 = 补偿摘要(role=user,"[早期对话概要]" 前缀 + 高频主题)
|
// 视图尾部 = 补偿摘要(role=user,"[早期对话概要]" 前缀 + 高频主题)
|
||||||
let head = msgs.first().expect("视图不应为空");
|
let tail = msgs.last().expect("视图不应为空");
|
||||||
assert!(
|
assert!(
|
||||||
matches!(head.role, MessageRole::User),
|
matches!(tail.role, MessageRole::User),
|
||||||
"视图头部应为 user 摘要, 实际 {:?}",
|
"视图尾部应为 user 摘要, 实际 {:?}",
|
||||||
head.role
|
tail.role
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
head.content.starts_with("[早期对话概要] 高频主题: "),
|
tail.content.starts_with("[早期对话概要] 高频主题: "),
|
||||||
"摘要格式不符, 实际 {}",
|
"摘要格式不符, 实际 {}",
|
||||||
head.content
|
tail.content
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
head.content.contains("审批"),
|
tail.content.contains("审批"),
|
||||||
"摘要应含被淘汰区高频主题词'审批', 实际 {}",
|
"摘要应含被淘汰区高频主题词'审批', 实际 {}",
|
||||||
head.content
|
tail.content
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
head.content.chars().count() <= EVICTION_SUMMARY_MAX_CHARS,
|
tail.content.chars().count() <= EVICTION_SUMMARY_MAX_CHARS,
|
||||||
"摘要应 ≤ {} 字符, 实际 {}",
|
"摘要应 ≤ {} 字符, 实际 {}",
|
||||||
EVICTION_SUMMARY_MAX_CHARS,
|
EVICTION_SUMMARY_MAX_CHARS,
|
||||||
head.content.chars().count()
|
tail.content.chars().count()
|
||||||
);
|
);
|
||||||
|
|
||||||
// 保护消息完好:末条(i=19 assistant)保留;摘要 user 与首轮真实 user 相邻,由
|
// 保护消息完好:末条(i=19 assistant)保留;摘要 user 追加在尾部,与末尾 assistant
|
||||||
// sanitize 的 ensure_sequence_legal 合并(摘要在前,换行接原文),故 msgs[1] 为 i=1 的 assistant
|
// 不同 role 不合并,故 msgs[倒数第2] 仍为 i=19 的 assistant
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
msgs.last().unwrap().content,
|
msgs[msgs.len() - 2].content,
|
||||||
"第19条回复内容同样较长用于撑爆上下文预算触发视图裁剪逻辑",
|
"第19条回复内容同样较长用于撑爆上下文预算触发视图裁剪逻辑",
|
||||||
"保护区最新消息被误裁"
|
"保护区最新消息被误裁"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
matches!(msgs[1].role, MessageRole::Assistant),
|
matches!(msgs[msgs.len() - 2].role, MessageRole::Assistant),
|
||||||
"摘要与首轮 user 合并后,第二条应为 assistant, 实际 {:?}",
|
"摘要前一条应为 assistant(i=19), 实际 {:?}",
|
||||||
msgs[1].role
|
msgs[msgs.len() - 2].role
|
||||||
);
|
);
|
||||||
|
// 首条仍是保留区真实首条 user(摘要不再抢占头部,不位移前缀)
|
||||||
assert!(
|
assert!(
|
||||||
msgs[0].content.contains("审批流程与审批门禁需要适配"),
|
matches!(msgs[0].role, MessageRole::User),
|
||||||
"合并不应丢失被保留的首轮真实 user 文本, 实际 {}",
|
"视图头部应为真实首条 user(摘要已移尾部), 实际 {:?}",
|
||||||
msgs[0].content
|
msgs[0].role
|
||||||
);
|
);
|
||||||
|
|
||||||
// 纯视图:摘要不进内存全量,被淘汰原文存储态不变
|
// 纯视图:摘要不进内存全量,被淘汰原文存储态不变
|
||||||
@@ -1196,3 +1202,278 @@ fn eviction_no_user_text_no_summary() {
|
|||||||
);
|
);
|
||||||
assert_eq!(mgr.all_messages_clone().len(), 16, "裁剪污染了内存全量");
|
assert_eq!(mgr.all_messages_clone().len(), 16, "裁剪污染了内存全量");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 源头守静(909):push_system_anchor 避开悬空 tool_call 头 ──
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn push_system_anchor_appends_on_normal_tail() {
|
||||||
|
let mut mgr = ContextManager::new(cfg(100_000));
|
||||||
|
mgr.push(ChatMessage::user("问"));
|
||||||
|
mgr.push(ChatMessage::assistant("答"));
|
||||||
|
let avoided = mgr.push_system_anchor(ChatMessage::system("警示"));
|
||||||
|
assert!(!avoided, "常规尾部应走追加(不避让)");
|
||||||
|
assert_eq!(mgr.len(), 3);
|
||||||
|
assert_eq!(mgr.messages_mut()[2].message.content, "警示", "system 应在尾部");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn push_system_anchor_avoids_dangling_tool_call_head() {
|
||||||
|
// [user] [assistant(tool_calls)] ← 末条为悬空头(其 tool 响应尚未回填)
|
||||||
|
let mut mgr = ContextManager::new(cfg(100_000));
|
||||||
|
mgr.push(ChatMessage::user("跑工具"));
|
||||||
|
mgr.push(ChatMessage::assistant_with_tools(
|
||||||
|
"",
|
||||||
|
vec![ToolCall::new("c1".to_string(), "t", "{}")],
|
||||||
|
));
|
||||||
|
let avoided = mgr.push_system_anchor(ChatMessage::system("## 摘要"));
|
||||||
|
assert!(avoided, "末条为悬空 tool_call 头应避让插入");
|
||||||
|
// 序列应为 [user, system(摘要), assistant(tool_calls)] —— 头仍是末条,三元组不被劈开
|
||||||
|
let msgs = mgr.all_messages_clone();
|
||||||
|
assert_eq!(msgs.len(), 3);
|
||||||
|
assert_eq!(msgs[1].content, "## 摘要", "摘要应插在悬空头之前");
|
||||||
|
assert!(
|
||||||
|
msgs[2].tool_calls.as_ref().is_some_and(|c| !c.is_empty()),
|
||||||
|
"悬空头应保持末条,其 tool 响应可紧随其后回填(协议不变式成立)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 缓存友好上下文(W1 块状裁剪 + W2 system 锚点 view 层转 user)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/// 推入一个完整工具轮 [user 提问, assistant 带 tool_call, tool_result, assistant 小结]。
|
||||||
|
/// 淘汰单元随之分为「user 独立单元 + 三元组单元」,轮起点(user)是轮吸附锚点。
|
||||||
|
fn push_tool_round(mgr: &mut ContextManager, tag: &str, filler: &str) {
|
||||||
|
mgr.push(ChatMessage::user(&format!("问题{}{}", tag, filler)));
|
||||||
|
mgr.push(ChatMessage::assistant_with_tools(
|
||||||
|
"",
|
||||||
|
vec![ToolCall::new(format!("tc{}", tag), "read_file", "{}")],
|
||||||
|
));
|
||||||
|
mgr.push(ChatMessage::tool_result(format!("tc{}", tag), "结果"));
|
||||||
|
mgr.push(ChatMessage::assistant(&format!("小结{}", tag)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 视图中真实历史的 token 量:排除尾部追加的淘汰补偿摘要(它不计入裁剪水位)。
|
||||||
|
fn view_history_tokens(msgs: &[ChatMessage]) -> u64 {
|
||||||
|
let est = TokenEstimator::default();
|
||||||
|
msgs.iter()
|
||||||
|
.filter(|m| !m.content.starts_with("[早期对话概要]"))
|
||||||
|
.map(|m| est.estimate_message(m) as u64)
|
||||||
|
.sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn block_trim_lands_below_target_water_level() {
|
||||||
|
// W1:开关开后超预算裁剪一次裁到 T×available 水位以下(贪心只裁到刚好塞下 available)。
|
||||||
|
let mut mgr = ContextManager::new(cfg(1000));
|
||||||
|
for i in 0..40 {
|
||||||
|
let text = format!("第{}条这条消息足够长让总历史稳定超过一千token预算线", i);
|
||||||
|
if i % 2 == 0 {
|
||||||
|
mgr.push(ChatMessage::user(&text));
|
||||||
|
} else {
|
||||||
|
mgr.push(ChatMessage::assistant(&text));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(mgr.history_tokens() > 1000, "前置:历史须超预算");
|
||||||
|
mgr.set_cache_friendly(true);
|
||||||
|
let (msgs, trimmed) = mgr.build_for_request(0);
|
||||||
|
assert!(trimmed, "超预算应触发裁剪");
|
||||||
|
|
||||||
|
let target = (1000u64 as f32 * crate::context_helpers::TRIM_TARGET_RATIO) as u64;
|
||||||
|
let remaining = view_history_tokens(&msgs);
|
||||||
|
assert!(
|
||||||
|
remaining <= target,
|
||||||
|
"裁后水位应落在目标 {} 以下, 实际 {}",
|
||||||
|
target,
|
||||||
|
remaining
|
||||||
|
);
|
||||||
|
// 保护区最新消息不被动(摘要追加在尾部,末条真实消息是其前一条)
|
||||||
|
let last_real = msgs
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.find(|m| !m.content.starts_with("[早期对话概要]"))
|
||||||
|
.expect("视图非空");
|
||||||
|
assert!(
|
||||||
|
last_real.content.contains("第39条"),
|
||||||
|
"保护区最新消息被误裁, 实际末条: {}",
|
||||||
|
last_real.content
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn block_trim_snaps_boundary_to_turn_start() {
|
||||||
|
// W1 轮边界吸附:水位在某单元边界达标但下一条不是 user 时继续扫,直到边界吸附轮起点
|
||||||
|
// (整问整答同生同死)。3 个工具轮 + 2 条过渡 + 6 条保护区,len=20 且 protect_start=14
|
||||||
|
// 恰为保护区起点;按纯水位会在过渡轮中间收手(首条为 assistant),吸附后首条必是 user。
|
||||||
|
let mut mgr = ContextManager::new(cfg(300));
|
||||||
|
let filler = "这一轮的体量用来把历史顶过预算线撑出裁剪空间";
|
||||||
|
for r in 0..3 {
|
||||||
|
push_tool_round(&mut mgr, &r.to_string(), filler);
|
||||||
|
}
|
||||||
|
mgr.push(ChatMessage::user(&format!("过渡问题A{}", filler)));
|
||||||
|
mgr.push(ChatMessage::assistant(&format!("过渡回复B{}", filler)));
|
||||||
|
for i in 0..6 {
|
||||||
|
let text = format!("保护区{}需要足够长的文本来撑量", i);
|
||||||
|
if i % 2 == 0 {
|
||||||
|
mgr.push(ChatMessage::user(&text));
|
||||||
|
} else {
|
||||||
|
mgr.push(ChatMessage::assistant(&text));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(mgr.history_tokens() > 300, "前置:历史须超预算");
|
||||||
|
mgr.set_cache_friendly(true);
|
||||||
|
let (msgs, trimmed) = mgr.build_for_request(0);
|
||||||
|
assert!(trimmed, "超预算应触发裁剪");
|
||||||
|
|
||||||
|
let first = msgs.first().expect("视图非空");
|
||||||
|
assert!(
|
||||||
|
matches!(first.role, MessageRole::User),
|
||||||
|
"裁剪边界应吸附轮起点(下一条未淘汰消息是 user), 实际首条 role={:?}",
|
||||||
|
first.role
|
||||||
|
);
|
||||||
|
let target = (300u64 as f32 * crate::context_helpers::TRIM_TARGET_RATIO) as u64;
|
||||||
|
assert!(
|
||||||
|
view_history_tokens(&msgs) <= target,
|
||||||
|
"吸附同时仍应满足目标水位"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
has_head(&msgs),
|
||||||
|
has_tail(&msgs),
|
||||||
|
"轮吸附不得劈开工具三元组"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn block_trim_pathological_falls_back_to_hard_budget() {
|
||||||
|
// W1 病态回退:单轮体量 > 块,扫到保护区仍无「水位+轮边界」齐备的边界(保护区首条
|
||||||
|
// 是 system 锚点,堵死末轮三元组边界的轮吸附)→ 回退取满足硬预算的最后单元边界,
|
||||||
|
// 硬预算安全底线不破;且回退比贪心裁得更深(贪心在首个硬预算边界即收手)。
|
||||||
|
let mut mgr = ContextManager::new(cfg(400));
|
||||||
|
let filler = "这一轮的提问体量远超块大小用来制造病态回退场景".repeat(10);
|
||||||
|
for r in 0..3 {
|
||||||
|
push_tool_round(&mut mgr, &r.to_string(), &filler);
|
||||||
|
}
|
||||||
|
// 保护区首条 system 锚点:末轮三元组边界 next 非 user,轮吸附不可达
|
||||||
|
mgr.push(ChatMessage::system("[早期锚点]"));
|
||||||
|
for i in 0..5 {
|
||||||
|
let text = format!("保护区{}需要足够长的文本来撑量", i);
|
||||||
|
if i % 2 == 0 {
|
||||||
|
mgr.push(ChatMessage::user(&text));
|
||||||
|
} else {
|
||||||
|
mgr.push(ChatMessage::assistant(&text));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(mgr.history_tokens() > 400, "前置:历史须超预算");
|
||||||
|
mgr.set_cache_friendly(true);
|
||||||
|
let (msgs, trimmed) = mgr.build_for_request(0);
|
||||||
|
assert!(trimmed, "病态回退仍应触发裁剪");
|
||||||
|
|
||||||
|
let remaining = view_history_tokens(&msgs);
|
||||||
|
assert!(
|
||||||
|
remaining <= 400,
|
||||||
|
"硬预算安全底线:裁后不得超过 available, 实际 {}",
|
||||||
|
remaining
|
||||||
|
);
|
||||||
|
let first = msgs.first().expect("视图非空");
|
||||||
|
assert!(
|
||||||
|
first.content.contains("[早期锚点]"),
|
||||||
|
"回退应取满足硬预算的最后单元边界(裁到保护区起点), 实际首条: {}",
|
||||||
|
first.content
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cache_friendly_off_keeps_greedy_min_trim() {
|
||||||
|
// W1 开关关:走原贪心最小裁剪,裁到刚好塞下硬预算即停(高于目标水位),逐字节等价旧路径。
|
||||||
|
let mut mgr = ContextManager::new(cfg(1000));
|
||||||
|
for i in 0..40 {
|
||||||
|
let text = format!("第{}条这条消息足够长让总历史稳定超过一千token预算线", i);
|
||||||
|
if i % 2 == 0 {
|
||||||
|
mgr.push(ChatMessage::user(&text));
|
||||||
|
} else {
|
||||||
|
mgr.push(ChatMessage::assistant(&text));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 不注入(字段默认 false)= 开关关
|
||||||
|
let (msgs, trimmed) = mgr.build_for_request(0);
|
||||||
|
assert!(trimmed, "超预算应触发裁剪");
|
||||||
|
let remaining = view_history_tokens(&msgs);
|
||||||
|
let target = (1000u64 as f32 * crate::context_helpers::TRIM_TARGET_RATIO) as u64;
|
||||||
|
assert!(
|
||||||
|
remaining <= 1000,
|
||||||
|
"贪心裁后应塞下硬预算, 实际 {}",
|
||||||
|
remaining
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
remaining > target,
|
||||||
|
"贪心只裁到刚好塞下(高于目标水位 {}), 实际 {}",
|
||||||
|
target,
|
||||||
|
remaining
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn system_anchor_view_becomes_user_when_cache_friendly_on() {
|
||||||
|
// W2:开关开,存储态 system 锚点在发送视图转 user;存储不动;最终视图(组装层
|
||||||
|
// system_prompt 在下标 0,不经 build_for_request)中 system 独占首条。
|
||||||
|
let mut mgr = ContextManager::new(cfg(100_000));
|
||||||
|
mgr.push(ChatMessage::user("问"));
|
||||||
|
mgr.push(ChatMessage::assistant("答"));
|
||||||
|
mgr.push_system_anchor(ChatMessage::system("[压缩摘要锚点]"));
|
||||||
|
mgr.set_cache_friendly(true);
|
||||||
|
let (msgs, _t) = mgr.build_for_request(0);
|
||||||
|
assert!(!msgs.is_empty());
|
||||||
|
assert!(
|
||||||
|
msgs.iter().all(|m| !matches!(m.role, MessageRole::System)),
|
||||||
|
"开时视图不得残留 system: {:?}",
|
||||||
|
msgs.iter().map(role_tag).collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
mgr.all_messages_clone()
|
||||||
|
.iter()
|
||||||
|
.any(|m| matches!(m.role, MessageRole::System)),
|
||||||
|
"存储态 system 锚点不应被改写(纯视图)"
|
||||||
|
);
|
||||||
|
let mut final_view = vec![ChatMessage::system("SYS_PROMPT")];
|
||||||
|
final_view.extend(msgs);
|
||||||
|
assert!(
|
||||||
|
matches!(final_view[0].role, MessageRole::System),
|
||||||
|
"组装层首条 system_prompt 不转"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
final_view
|
||||||
|
.iter()
|
||||||
|
.filter(|m| matches!(m.role, MessageRole::System))
|
||||||
|
.count(),
|
||||||
|
1,
|
||||||
|
"最终视图应只有组装层首条 system"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn system_anchor_head_insert_still_converted() {
|
||||||
|
// W2:IPC 压缩路径 insert_at(0) 的存储首位 system 锚点,最终视图在下标 1(>0),同样转 user。
|
||||||
|
let mut mgr = ContextManager::new(cfg(100_000));
|
||||||
|
mgr.push(ChatMessage::user("问"));
|
||||||
|
mgr.insert_at(0, ChatMessage::system("[摘要]"));
|
||||||
|
mgr.set_cache_friendly(true);
|
||||||
|
let (msgs, _t) = mgr.build_for_request(0);
|
||||||
|
assert!(
|
||||||
|
msgs.iter().all(|m| !matches!(m.role, MessageRole::System)),
|
||||||
|
"存储首位的 system 锚点最终视图下标 > 0,应转 user"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn system_anchor_stays_system_when_cache_friendly_off() {
|
||||||
|
// W2 开关关:不转换,视图与存储角色一致(旧行为逐字节等价)。
|
||||||
|
let mut mgr = ContextManager::new(cfg(100_000));
|
||||||
|
mgr.push(ChatMessage::user("问"));
|
||||||
|
mgr.push(ChatMessage::assistant("答"));
|
||||||
|
mgr.push_system_anchor(ChatMessage::system("[压缩摘要锚点]"));
|
||||||
|
let (msgs, _t) = mgr.build_for_request(0);
|
||||||
|
assert!(
|
||||||
|
msgs.iter().any(|m| matches!(m.role, MessageRole::System)),
|
||||||
|
"关时 system 锚点应原样进视图"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
+185
-24
@@ -20,9 +20,11 @@
|
|||||||
//! 保持 `ContextManager::sanitize_messages(...)` 调用路径不变(零调用方变更)。
|
//! 保持 `ContextManager::sanitize_messages(...)` 调用路径不变(零调用方变更)。
|
||||||
|
|
||||||
mod sanitize;
|
mod sanitize;
|
||||||
|
pub mod archive;
|
||||||
|
|
||||||
use crate::context_helpers::{
|
use crate::context_helpers::{
|
||||||
build_eviction_summary_text, classify_group, PLACEHOLDER_INTEGRITY_ENABLED, PROTECT_COUNT,
|
build_eviction_summary_text, classify_group, PLACEHOLDER_INTEGRITY_ENABLED, PROTECT_COUNT,
|
||||||
|
TRIM_TARGET_RATIO,
|
||||||
};
|
};
|
||||||
// 重导出:保持 `df_ai::context::TokenEstimator` / `df_ai::context::ContextConfig` 等
|
// 重导出:保持 `df_ai::context::TokenEstimator` / `df_ai::context::ContextConfig` 等
|
||||||
// 历史路径对外可见(agentic.rs / commands/ai/mod.rs 等调用方零变更)。
|
// 历史路径对外可见(agentic.rs / commands/ai/mod.rs 等调用方零变更)。
|
||||||
@@ -78,6 +80,16 @@ pub struct ContextManager {
|
|||||||
/// insert_at / compress_old_messages / messages_mut(可变借用兜底,调用方可能改 status/content)。
|
/// insert_at / compress_old_messages / messages_mut(可变借用兜底,调用方可能改 status/content)。
|
||||||
/// 误置位零代价(只是下次 save 多走一次全量重写,正确性不变);漏置位才会丢更新——故保守置位。
|
/// 误置位零代价(只是下次 save 多走一次全量重写,正确性不变);漏置位才会丢更新——故保守置位。
|
||||||
needs_full_rewrite: bool,
|
needs_full_rewrite: bool,
|
||||||
|
/// 压缩可召回:本次压缩事件的存档目标(调用方读到 KV 开后注入,compress_old_messages
|
||||||
|
/// 一次性消费)。None = 本事件不存档(开关关/未注入),零行为变更。
|
||||||
|
compress_recall: Option<archive::RecallTarget>,
|
||||||
|
/// 最近一次压缩成功落盘的存档路径(take 语义,供调用方在压缩摘要尾部拼召回句柄;
|
||||||
|
/// 每次 compress_old_messages 按事件重置,防跨事件残留误报)。
|
||||||
|
archive_path: Option<std::path::PathBuf>,
|
||||||
|
/// 缓存友好上下文总开关(跨 crate 注入:settings 在 src-tauri 侧,df-ai 不可直读,
|
||||||
|
/// 调用方读 KV `df-ai-cache-friendly-context` 后经 [`Self::set_cache_friendly`] 注入)。
|
||||||
|
/// 默认 false = 旧行为(贪心最小裁剪 + system 锚点原样发送),未注入路径零行为变更。
|
||||||
|
cache_friendly: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ContextManager {
|
impl ContextManager {
|
||||||
@@ -91,6 +103,9 @@ impl ContextManager {
|
|||||||
pending_topic_marker: None,
|
pending_topic_marker: None,
|
||||||
persisted_msg_count: 0,
|
persisted_msg_count: 0,
|
||||||
needs_full_rewrite: false,
|
needs_full_rewrite: false,
|
||||||
|
compress_recall: None,
|
||||||
|
archive_path: None,
|
||||||
|
cache_friendly: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,6 +168,8 @@ impl ContextManager {
|
|||||||
// 全量重写空列表也会清 DB)。
|
// 全量重写空列表也会清 DB)。
|
||||||
self.persisted_msg_count = 0;
|
self.persisted_msg_count = 0;
|
||||||
self.needs_full_rewrite = true;
|
self.needs_full_rewrite = true;
|
||||||
|
self.compress_recall = None;
|
||||||
|
self.archive_path = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 消息数量
|
/// 消息数量
|
||||||
@@ -196,10 +213,10 @@ impl ContextManager {
|
|||||||
|
|
||||||
// 未超预算 → 直接返回全量(仍做畸形配对自愈,防历史中毒触发 provider 500 死循环)
|
// 未超预算 → 直接返回全量(仍做畸形配对自愈,防历史中毒触发 provider 500 死循环)
|
||||||
if self.history_tokens <= available {
|
if self.history_tokens <= available {
|
||||||
let sanitized = Self::sanitize_messages(self.all_messages_clone());
|
let view = self.anchors_to_user(Self::sanitize_messages(self.all_messages_clone()));
|
||||||
// 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan)
|
// 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan)
|
||||||
return (
|
return (
|
||||||
Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED),
|
Self::assert_placeholder_pairing(view, PLACEHOLDER_INTEGRITY_ENABLED),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -208,15 +225,12 @@ impl ContextManager {
|
|||||||
let protect_start = self.messages.len().saturating_sub(PROTECT_COUNT);
|
let protect_start = self.messages.len().saturating_sub(PROTECT_COUNT);
|
||||||
let units = self.build_eviction_units(protect_start);
|
let units = self.build_eviction_units(protect_start);
|
||||||
|
|
||||||
let mut removed: u64 = 0;
|
// 缓存友好开:块状裁剪(目标水位+轮边界吸附);关:贪心最小裁剪(逐字节等价旧路径)
|
||||||
let mut trim_end = 0;
|
let (trim_end, removed) = if self.cache_friendly {
|
||||||
for unit in &units {
|
self.block_trim_target(&units, available)
|
||||||
if self.history_tokens.saturating_sub(removed as u32) <= available {
|
} else {
|
||||||
break;
|
self.greedy_trim_target(&units, available)
|
||||||
}
|
};
|
||||||
removed += unit.token_sum as u64;
|
|
||||||
trim_end = unit.end;
|
|
||||||
}
|
|
||||||
|
|
||||||
if trim_end == 0 {
|
if trim_end == 0 {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
@@ -229,9 +243,9 @@ impl ContextManager {
|
|||||||
// 致超预算且保护区满时命中)。异常会话(开头连续 assistant/tool 无 user)经
|
// 致超预算且保护区满时命中)。异常会话(开头连续 assistant/tool 无 user)经
|
||||||
// ensure_sequence_legal 清空后,由协议层 ensure_leading_user 补 user 占位降级,不阻塞。
|
// ensure_sequence_legal 清空后,由协议层 ensure_leading_user 补 user 占位降级,不阻塞。
|
||||||
// view-only:不改 self.messages 持久化(与分支 1/3 一致)。
|
// view-only:不改 self.messages 持久化(与分支 1/3 一致)。
|
||||||
let sanitized = Self::sanitize_messages(self.all_messages_clone());
|
let view = self.anchors_to_user(Self::sanitize_messages(self.all_messages_clone()));
|
||||||
return (
|
return (
|
||||||
Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED),
|
Self::assert_placeholder_pairing(view, PLACEHOLDER_INTEGRITY_ENABLED),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -246,30 +260,111 @@ impl ContextManager {
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// 淘汰补偿摘要:视图裁剪无声丢弃被淘汰内容,LLM 从此看不到早期主题,与压缩
|
// 淘汰补偿摘要:视图裁剪无声丢弃被淘汰内容,LLM 从此看不到早期主题,与压缩
|
||||||
// 路径(有摘要补偿)行为不一致。此处把被淘汰文本提炼成一短段摘要插回发送
|
// 路径(有摘要补偿)行为不一致。此处把被淘汰文本提炼成一短段摘要插回发送视图。
|
||||||
// 视图最前部(淘汰区头部、保护消息之前)。角色必须 user:role=system 会被
|
// **缓存友好修正(2026-10)**:原 insert(0) 头部插入会位移整条前缀字节,使
|
||||||
// anthropic_compat 折叠进带 cache_control 的顶层 system 段,而本摘要纯视图
|
// DeepSeek 等自动前缀缓存 provider 的稳定前缀(顶层 system + 早期 history)
|
||||||
// 每轮即时再生成(淘汰区单调增长致内容逐轮漂移),落 system 即逐轮改写缓存
|
// 全部缓存失效——命中率掉到个位数。改尾部追加(push):摘要本身每轮即时再生成
|
||||||
// 指纹使全部前缀缓存失效;连续 role 由既有 sanitize/协议层自愈兜底。摘要只进
|
// (淘汰区单调增长致内容逐轮漂移)本就无法命中缓存,但放尾部不再拖累稳定前缀,
|
||||||
// messages 易变区,严禁拼进 system_prompt/任何稳定段;纯视图语义:不落库、
|
// 命中率最大化。角色必须 user:role=system 会被 anthropic_compat 折叠进带
|
||||||
// 不改 conv.messages 存储、不标记 compressed,被淘汰原文存储态不变。
|
// cache_control 的顶层 system 段,落 system 即逐轮改写缓存指纹;user 放尾部
|
||||||
// 极短/空淘汰文本提炼为空 → 跳过(不值得插)。
|
// 由 sanitize/协议层自愈兜底。摘要只进 messages 易变区,严禁拼进 system_prompt/
|
||||||
|
// 任何稳定段;纯视图语义:不落库、不改 conv.messages 存储、不标记 compressed,
|
||||||
|
// 被淘汰原文存储态不变。极短/空淘汰文本提炼为空 → 跳过(不值得插)。
|
||||||
if let Some(text) = build_eviction_summary_text(&evicted) {
|
if let Some(text) = build_eviction_summary_text(&evicted) {
|
||||||
msgs.insert(0, ChatMessage::user(text));
|
msgs.push(ChatMessage::user(text));
|
||||||
}
|
}
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"context_trimmed: skip {} messages, ~{} tokens (view-only, full history retained)",
|
"context_trimmed: skip {} messages, ~{} tokens (view-only, full history retained)",
|
||||||
trim_end, removed
|
trim_end, removed
|
||||||
);
|
);
|
||||||
let sanitized = Self::sanitize_messages(msgs);
|
let view = self.anchors_to_user(Self::sanitize_messages(msgs));
|
||||||
// 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan)
|
// 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan)
|
||||||
(
|
(
|
||||||
Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED),
|
Self::assert_placeholder_pairing(view, PLACEHOLDER_INTEGRITY_ENABLED),
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 贪心最小裁剪:累计淘汰单元直到塞下硬预算即停(历史行为,开关关路径)。
|
||||||
|
///
|
||||||
|
/// 缺陷:对话每增长一条消息就重新越过预算一点,裁剪边界随之前移一个单元,
|
||||||
|
/// 发送视图首条每轮都变,前缀缓存从第 0 字节起永远 miss。
|
||||||
|
fn greedy_trim_target(&self, units: &[EvictionUnit], available: u32) -> (usize, u64) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
(trim_end, removed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 块状裁剪:一次裁到目标水位 `TRIM_TARGET_RATIO × available`,且边界吸附轮边界。
|
||||||
|
///
|
||||||
|
/// 为什么块状:两次裁剪之间裁剪边界不动 → 被淘汰集不变 → 视图前缀逐字节稳定,
|
||||||
|
/// 前缀缓存每轮命中;裁剪频度由水位差(块大小)决定而非逐条爬行。
|
||||||
|
/// 轮边界:单元 end 处下一条未淘汰消息 role=User 即轮起点,整问整答(含工具链)
|
||||||
|
/// 同生同死,淘汰摘要按轮覆盖语义完整,且轮起点是稳定地标。
|
||||||
|
/// 病态回退(单轮体量 > 块,扫到保护区仍无两条件齐备的边界):取满足硬预算
|
||||||
|
/// available 的最后单元边界(安全底线);连硬预算都不可得(保护区自身超限)则
|
||||||
|
/// 尽力裁到末单元,对齐贪心路径。
|
||||||
|
fn block_trim_target(&self, units: &[EvictionUnit], available: u32) -> (usize, u64) {
|
||||||
|
let target = (available as f32 * TRIM_TARGET_RATIO) as u32;
|
||||||
|
let mut removed: u64 = 0;
|
||||||
|
let mut hard = (0usize, 0u64);
|
||||||
|
let mut last = (0usize, 0u64);
|
||||||
|
for unit in units {
|
||||||
|
removed += unit.token_sum as u64;
|
||||||
|
last = (unit.end, removed);
|
||||||
|
let remaining = self.history_tokens.saturating_sub(removed as u32);
|
||||||
|
if remaining <= available {
|
||||||
|
hard = (unit.end, removed);
|
||||||
|
}
|
||||||
|
// 目标水位隐含硬预算已满足(target < available),轮吸附与水位同时判定
|
||||||
|
if remaining <= target
|
||||||
|
&& self
|
||||||
|
.messages
|
||||||
|
.get(unit.end)
|
||||||
|
.is_some_and(|next| matches!(next.message.role, MessageRole::User))
|
||||||
|
{
|
||||||
|
return (unit.end, removed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hard.0 > 0 {
|
||||||
|
hard
|
||||||
|
} else {
|
||||||
|
last
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 发送视图出口整形(sanitize 之后):总开关开时把存储态 role=System 消息
|
||||||
|
/// (压缩摘要/话题标记/警示等锚点)映射为 role=User 发送。
|
||||||
|
///
|
||||||
|
/// 为什么:anthropic_compat 把存储态全部 role=System 折叠进顶层 system(带
|
||||||
|
/// cache_control),每次锚点事件都改写顶层缓存指纹,system 断点与消息断点同时失配。
|
||||||
|
/// 转 user 后顶层 system 回到纯 system_prompt 稳定段;连续 user 由
|
||||||
|
/// anthropic_compat::merge_consecutive_users 合并(协议要求严格交替),user 不触发
|
||||||
|
/// 三元组/首条规则。纯视图:存储态仍 System,压缩定位(has_compressible_messages
|
||||||
|
/// 的 System 排除)与前端展示语义不变。调用方组装层加入的 system_prompt 不经本
|
||||||
|
/// 函数(不在 storage),最终视图中它独占 role=System 首条。
|
||||||
|
fn anchors_to_user(&self, view: Vec<ChatMessage>) -> Vec<ChatMessage> {
|
||||||
|
if !self.cache_friendly {
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
view.into_iter()
|
||||||
|
.map(|mut m| {
|
||||||
|
if matches!(m.role, MessageRole::System) {
|
||||||
|
m.role = MessageRole::User;
|
||||||
|
}
|
||||||
|
m
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
// ── 畸形配对自愈(转发至 [`sanitize`] 子模块,保持 ContextManager::xxx 调用路径)──
|
// ── 畸形配对自愈(转发至 [`sanitize`] 子模块,保持 ContextManager::xxx 调用路径)──
|
||||||
|
|
||||||
/// 畸形配对自愈 — 转发到 [`sanitize::sanitize_messages`]。
|
/// 畸形配对自愈 — 转发到 [`sanitize::sanitize_messages`]。
|
||||||
@@ -565,6 +660,41 @@ impl ContextManager {
|
|||||||
self.needs_full_rewrite = true;
|
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 总和,保证:
|
/// 返回每个单元的右开区间 end + token 总和,保证:
|
||||||
@@ -631,6 +761,11 @@ impl ContextManager {
|
|||||||
///
|
///
|
||||||
/// 返回空 Vec 表示本批次无可压缩消息(全部已 compressed 或范围空),调用方据此跳过 LLM 调用。
|
/// 返回空 Vec 表示本批次无可压缩消息(全部已 compressed 或范围空),调用方据此跳过 LLM 调用。
|
||||||
pub fn compress_old_messages(&mut self, compress_end: usize) -> Vec<ChatMessage> {
|
pub fn compress_old_messages(&mut self, compress_end: usize) -> Vec<ChatMessage> {
|
||||||
|
// 压缩可召回:目标一次性消费(防跨事件残留),路径按本事件重置。压缩标记后原文
|
||||||
|
// 从 LLM 上下文隔离,此刻原文在手,追加写入会话存档供摘要句柄召回(read_file);
|
||||||
|
// 失败仅置空路径(调用方降级为无句柄),不影响压缩本身。
|
||||||
|
let recall = self.compress_recall.take();
|
||||||
|
self.archive_path = None;
|
||||||
// 压缩边界按对话单元(三元组)对齐:调用方传入的 end(如 len - PROTECT_COUNT)周期性
|
// 压缩边界按对话单元(三元组)对齐:调用方传入的 end(如 len - PROTECT_COUNT)周期性
|
||||||
// 落在单元中间(assistant 头与其 tool 结果之间),只压半个三元组会让保护区以孤立的
|
// 落在单元中间(assistant 头与其 tool 结果之间),只压半个三元组会让保护区以孤立的
|
||||||
// 头或尾开头,成为 sanitize 每请求告警的确定性供给。按全长分组取「完整单元边界中
|
// 头或尾开头,成为 sanitize 每请求告警的确定性供给。按全长分组取「完整单元边界中
|
||||||
@@ -657,10 +792,36 @@ impl ContextManager {
|
|||||||
// newly_compressed 为空,状态未变,无需全量重写)。
|
// newly_compressed 为空,状态未变,无需全量重写)。
|
||||||
if !newly_compressed.is_empty() {
|
if !newly_compressed.is_empty() {
|
||||||
self.needs_full_rewrite = true;
|
self.needs_full_rewrite = true;
|
||||||
|
if let Some(target) = recall {
|
||||||
|
match archive::append_archive(&target, &newly_compressed) {
|
||||||
|
Some(p) => self.archive_path = Some(p),
|
||||||
|
None => tracing::warn!("[ai] 压缩存档写入失败,本轮摘要无召回句柄(压缩不受影响)"),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
newly_compressed
|
newly_compressed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 注入本次压缩事件的存档目标(压缩可召回,`df-ai-compress-recall` 开时由调用方在
|
||||||
|
/// `compress_old_messages` 之前调用;开关关不调用)。一次性消费:压缩标记时落盘,
|
||||||
|
/// 路径经 [`Self::take_archive_path`] 取走。
|
||||||
|
pub fn set_compress_recall(&mut self, data_dir: &std::path::Path, conv_id: &str) {
|
||||||
|
self.compress_recall = Some(archive::RecallTarget::new(data_dir, conv_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取走最近一次压缩成功落盘的存档路径(Some = 本次压缩可召回,调用方据此在摘要尾部
|
||||||
|
/// 拼句柄行)。take 语义:取后即空,防跨压缩事件残留误报。
|
||||||
|
pub fn take_archive_path(&mut self) -> Option<std::path::PathBuf> {
|
||||||
|
self.archive_path.take()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 注入缓存友好上下文总开关(每请求由 src-tauri 调用方读 KV 后在 `build_for_request`
|
||||||
|
/// 前调用,持续语义:注入值保持到下次注入)。true = 块状裁剪 + system 锚点 view 层
|
||||||
|
/// 转 user;false = 两者旁路,行为与旧路径逐字节等价。
|
||||||
|
pub fn set_cache_friendly(&mut self, enabled: bool) {
|
||||||
|
self.cache_friendly = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
/// 压缩重入标志(读)。true 表示一次 LLM 压缩正在进行中,触发方应跳过本轮压缩。
|
/// 压缩重入标志(读)。true 表示一次 LLM 压缩正在进行中,触发方应跳过本轮压缩。
|
||||||
pub fn is_compressing(&self) -> bool {
|
pub fn is_compressing(&self) -> bool {
|
||||||
self.is_compressing
|
self.is_compressing
|
||||||
|
|||||||
@@ -42,12 +42,35 @@ pub fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
|
|||||||
.filter(|m| m.is_active())
|
.filter(|m| m.is_active())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// step 1:已闭合的 tool_call_id 集合(role=Tool 消息全部提供过的 id)
|
// step 0.5(相邻性归位,909②):把楔入 assistant(tool_calls) 头与其 tool 响应之间的异类消息
|
||||||
let resolved_ids: HashSet<&str> = messages
|
// (后台压缩摘要 / 用户插话 / 警示 system)整体移到该组响应**之后**——只重排不删内容。
|
||||||
.iter()
|
// 归位后三元组恢复相邻,step1 的邻接判定即可正确识别为已闭合,不再走「未邻接即剥除」而**保住
|
||||||
.filter(|m| matches!(m.role, MessageRole::Tool))
|
// 工具轮**(避免丢结果致模型重跑/误判)。对同一头的多段夹层同样生效(全部响应聚拢到头后紧邻)。
|
||||||
.filter_map(|m| m.tool_call_id.as_deref())
|
// 真正无法归位者(头无任何响应)仍由 step2/3 剥除兜底。
|
||||||
.collect();
|
let messages = gather_tool_responses(messages);
|
||||||
|
|
||||||
|
// step 1:相邻性判定的依据——每个 assistant 头「紧后 tool 连续段」覆盖的 id 集合。
|
||||||
|
// 口径与 openai_compat::sanitize_openai_triplets 一致(2026-10-09 收紧):
|
||||||
|
// 协议要求 tool 响应必须紧随其 assistant(tool_calls);原实现按「id 全局存在」判定
|
||||||
|
// (位置无关),会把被 system/user 隔开的游离响应误判为已闭合,放行畸形序列致 400
|
||||||
|
// 「insufficient tool messages」。故判定改为「紧后连续段覆盖」——未覆盖的 call 视同
|
||||||
|
// 未闭合处理(剥除),其游离响应随后由 step3 按 orphaned_ids 丢弃,自动闭环。
|
||||||
|
let mut adjacency: std::collections::HashMap<usize, HashSet<String>> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
|
for i in 0..messages.len() {
|
||||||
|
if !matches!(messages[i].role, MessageRole::Assistant) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut seg: HashSet<String> = HashSet::new();
|
||||||
|
let mut j = i + 1;
|
||||||
|
while j < messages.len() && matches!(messages[j].role, MessageRole::Tool) {
|
||||||
|
if let Some(id) = messages[j].tool_call_id.as_deref() {
|
||||||
|
seg.insert(id.to_string());
|
||||||
|
}
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
adjacency.insert(i, seg);
|
||||||
|
}
|
||||||
|
|
||||||
// step 2:判定每个 assistant 头的闭合状态,产出需丢弃的 id 集合
|
// step 2:判定每个 assistant 头的闭合状态,产出需丢弃的 id 集合
|
||||||
// orphaned_ids:未闭合的 tool_call.id(含占位 id),其 tool_result 要丢
|
// orphaned_ids:未闭合的 tool_call.id(含占位 id),其 tool_result 要丢
|
||||||
@@ -72,7 +95,7 @@ pub fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
|
|||||||
.iter()
|
.iter()
|
||||||
.partition(|c| {
|
.partition(|c| {
|
||||||
!c.id.starts_with(TOOL_MISSING_PREFIX)
|
!c.id.starts_with(TOOL_MISSING_PREFIX)
|
||||||
&& resolved_ids.contains(c.id.as_str())
|
&& adjacency.get(&i).is_some_and(|s| s.contains(&c.id))
|
||||||
});
|
});
|
||||||
|
|
||||||
match (resolved.is_empty(), unresolved.is_empty()) {
|
match (resolved.is_empty(), unresolved.is_empty()) {
|
||||||
@@ -181,6 +204,78 @@ pub fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
|
|||||||
ensure_sequence_legal(after_reverse_orphan)
|
ensure_sequence_legal(after_reverse_orphan)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 相邻性归位(909②,view-only):把 assistant 头的**全部** tool 响应聚拢到头后紧邻,夹层整体后移。
|
||||||
|
///
|
||||||
|
/// 根因:协议不变式要求「tool 响应必须紧随其 assistant(tool_calls)」。后台压缩摘要在 head 已入、
|
||||||
|
/// 结果未回的窗口内楔入,或用户在执行期插话,都会产生 `[head, 异类, tool…]`。原逻辑对此按「未邻接
|
||||||
|
/// = 未闭合」剥除头与其响应,**丢掉整轮工具交互**(有损)。本步改为**归位**——夹层后移,头与其响应
|
||||||
|
/// 恢复相邻,工具轮保留;夹层内容亦不丢。
|
||||||
|
///
|
||||||
|
/// 覆盖**多夹层**:同一头的响应之间又插了第二个夹层时(如 `[head(c1,c2), 异类, tool(c1), 异类,
|
||||||
|
/// tool(c2)]`),只后移首夹层会让后段 tool 仍不与头相邻 → step1 邻接判定只认前段 → 后段 call 被
|
||||||
|
/// 判未闭合而剥除(tool 一并丢)。本实现把「响应集合耗尽前出现的**所有**非 Tool 消息」整体移到
|
||||||
|
/// **该头最后一个响应之后**,头后紧邻其全部响应(保序),夹层随后。
|
||||||
|
///
|
||||||
|
/// 边界:遇下一个 tool_call 头即停(不跨单元搬运);该头响应集合(其 tool_calls 的 id)全部命中
|
||||||
|
/// 即停(不牵动响应之后的无关消息)。无法归位者(头无响应)仍由 step2/3 剥除兜底。
|
||||||
|
///
|
||||||
|
/// 幂等:归位后序列已相邻,再跑本函数无夹层可移,输出不变。
|
||||||
|
fn gather_tool_responses(msgs: Vec<ChatMessage>) -> Vec<ChatMessage> {
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
fn is_tool_call_head(m: &ChatMessage) -> bool {
|
||||||
|
matches!(m.role, MessageRole::Assistant)
|
||||||
|
&& m.tool_calls.as_ref().is_some_and(|c| !c.is_empty())
|
||||||
|
}
|
||||||
|
fn is_tool(m: &ChatMessage) -> bool {
|
||||||
|
matches!(m.role, MessageRole::Tool)
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out: Vec<ChatMessage> = Vec::with_capacity(msgs.len());
|
||||||
|
let mut i = 0usize;
|
||||||
|
while i < msgs.len() {
|
||||||
|
if is_tool_call_head(&msgs[i]) {
|
||||||
|
// 该头全部 call id —— 用于判定它的响应集合是否已耗尽
|
||||||
|
let call_ids: HashSet<String> = msgs[i]
|
||||||
|
.tool_calls
|
||||||
|
.as_ref()
|
||||||
|
.map(|c| c.iter().map(|c| c.id.clone()).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
// 从头后扫到「下一个 tool_call 头」或「该头响应集合耗尽」为止,分流响应与夹层
|
||||||
|
let mut j = i + 1;
|
||||||
|
let mut responses: Vec<ChatMessage> = Vec::new();
|
||||||
|
let mut interlopers: Vec<ChatMessage> = Vec::new();
|
||||||
|
let mut seen: HashSet<String> = HashSet::new();
|
||||||
|
while j < msgs.len() && !is_tool_call_head(&msgs[j]) {
|
||||||
|
if is_tool(&msgs[j]) {
|
||||||
|
if let Some(id) = msgs[j].tool_call_id.as_deref() {
|
||||||
|
seen.insert(id.to_string());
|
||||||
|
}
|
||||||
|
responses.push(msgs[j].clone());
|
||||||
|
} else {
|
||||||
|
interlopers.push(msgs[j].clone());
|
||||||
|
}
|
||||||
|
j += 1;
|
||||||
|
// 该头响应集合已全部命中 → 不再牵动响应之后的无关消息(保持边界语义)
|
||||||
|
if !call_ids.is_empty() && call_ids.iter().all(|id| seen.contains(id)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 有夹层且确有响应 → 头 + 全部响应在前,夹层整体后移(只重排)
|
||||||
|
if !interlopers.is_empty() && !responses.is_empty() {
|
||||||
|
out.push(msgs[i].clone());
|
||||||
|
out.extend(responses);
|
||||||
|
out.extend(interlopers);
|
||||||
|
i = j;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push(msgs[i].clone());
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
/// step 3.5:反向 orphan 检测(view-only)—— **补头自愈**(对 orphan tool_result 就地补占位
|
/// step 3.5:反向 orphan 检测(view-only)—— **补头自愈**(对 orphan tool_result 就地补占位
|
||||||
/// assistant tool_call 头,而非丢弃 tool_result)。
|
/// assistant tool_call 头,而非丢弃 tool_result)。
|
||||||
///
|
///
|
||||||
@@ -554,6 +649,135 @@ mod tests {
|
|||||||
|
|
||||||
// ── 占位配对完整性(解 400 orphan):反向 orphan 检测 + 出口自愈 ──
|
// ── 占位配对完整性(解 400 orphan):反向 orphan 检测 + 出口自愈 ──
|
||||||
|
|
||||||
|
/// 相邻性归位(909②):夹层(system/user)隔开 head 与其 tool 响应时,把夹层移到响应之后
|
||||||
|
/// (只重排不删),保住整轮工具交互;同时保证发送序列满足「head 后紧邻其响应」。
|
||||||
|
/// 旧行为(逐字剥除工具轮)已被归位取代——本用例即旧行为的有损对照。
|
||||||
|
#[test]
|
||||||
|
fn sanitize_reorders_interloper_to_keep_tool_round() {
|
||||||
|
// [user] [assistant(c1,c2)] [system 夹层] [tool c1] [tool c2]
|
||||||
|
let msgs = vec![
|
||||||
|
ChatMessage::user("go"),
|
||||||
|
ChatMessage::assistant_with_tools(
|
||||||
|
"",
|
||||||
|
vec![
|
||||||
|
ToolCall::new("c1".to_string(), "t", "{}"),
|
||||||
|
ToolCall::new("c2".to_string(), "t", "{}"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
ChatMessage::system("夹层提示"),
|
||||||
|
ChatMessage::tool_result("c1", "r1"),
|
||||||
|
ChatMessage::tool_result("c2", "r2"),
|
||||||
|
];
|
||||||
|
let out = sanitize_messages(msgs);
|
||||||
|
// 零丢失:5 条全在(归位只重排,不删内容)
|
||||||
|
assert_eq!(out.len(), 5, "归位不得丢内容, 实际 {:?}", out);
|
||||||
|
// 头保留其两个 tool_call(工具轮不丢)
|
||||||
|
let head_idx = out
|
||||||
|
.iter()
|
||||||
|
.position(|m| matches!(m.role, MessageRole::Assistant))
|
||||||
|
.expect("head 应保留");
|
||||||
|
assert_eq!(
|
||||||
|
out[head_idx].tool_calls.as_ref().map(|c| c.len()),
|
||||||
|
Some(2),
|
||||||
|
"头应保留两个 tool_call, 实际 {:?}",
|
||||||
|
out
|
||||||
|
);
|
||||||
|
// 头后紧邻两条 tool 响应(协议不变式成立)
|
||||||
|
assert!(
|
||||||
|
matches!(out[head_idx + 1].role, MessageRole::Tool)
|
||||||
|
&& matches!(out[head_idx + 2].role, MessageRole::Tool),
|
||||||
|
"head 后应紧邻两条 tool 响应, 实际 {:?}",
|
||||||
|
out
|
||||||
|
);
|
||||||
|
// 夹层内容保留(后移到响应之后)
|
||||||
|
assert!(
|
||||||
|
out.iter()
|
||||||
|
.any(|m| matches!(m.role, MessageRole::System) && m.content == "夹层提示"),
|
||||||
|
"夹层内容不得丢, 实际 {:?}",
|
||||||
|
out
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_reorder_is_idempotent() {
|
||||||
|
let msgs = vec![
|
||||||
|
ChatMessage::user("go"),
|
||||||
|
ChatMessage::assistant_with_tools(
|
||||||
|
"",
|
||||||
|
vec![ToolCall::new("c1".to_string(), "t", "{}")],
|
||||||
|
),
|
||||||
|
ChatMessage::system("夹层提示"),
|
||||||
|
ChatMessage::tool_result("c1", "r1"),
|
||||||
|
];
|
||||||
|
let once = sanitize_messages(msgs);
|
||||||
|
let twice = sanitize_messages(once.clone());
|
||||||
|
// MessageRole 未实现 PartialEq → 用 Debug 签名比较序列(角色+内容)是否一致
|
||||||
|
let sig = |v: &[ChatMessage]| -> Vec<String> {
|
||||||
|
v.iter().map(|m| format!("{:?}|{}", m.role, m.content)).collect()
|
||||||
|
};
|
||||||
|
assert_eq!(sig(&once), sig(&twice), "归位应幂等(角色序与内容序不变)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 多夹层归位:同一头的响应之间又插了第二个夹层时,头与该头的**全部**响应必须聚拢相邻
|
||||||
|
/// (旧实现只后移首夹层,后段 tool 仍不与头相邻 → step1 判后段 call 未闭合 → 剥除并丢其 tool)。
|
||||||
|
#[test]
|
||||||
|
fn sanitize_reorders_multiple_interlopers_to_keep_tool_round() {
|
||||||
|
// [user] [assistant(c1,c2)] [system 夹层X] [tool c1] [system 夹层Y] [tool c2]
|
||||||
|
let msgs = vec![
|
||||||
|
ChatMessage::user("go"),
|
||||||
|
ChatMessage::assistant_with_tools(
|
||||||
|
"",
|
||||||
|
vec![
|
||||||
|
ToolCall::new("c1".to_string(), "t", "{}"),
|
||||||
|
ToolCall::new("c2".to_string(), "t", "{}"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
ChatMessage::system("夹层X"),
|
||||||
|
ChatMessage::tool_result("c1", "r1"),
|
||||||
|
ChatMessage::system("夹层Y"),
|
||||||
|
ChatMessage::tool_result("c2", "r2"),
|
||||||
|
];
|
||||||
|
let out = sanitize_messages(msgs);
|
||||||
|
// 零丢失:6 条全在(归位只重排,不删内容)
|
||||||
|
assert_eq!(out.len(), 6, "多夹层归位不得丢内容, 实际 {:?}", out);
|
||||||
|
// 头保留其两个 tool_call(工具轮不丢)
|
||||||
|
let head_idx = out
|
||||||
|
.iter()
|
||||||
|
.position(|m| matches!(m.role, MessageRole::Assistant))
|
||||||
|
.expect("head 应保留");
|
||||||
|
assert_eq!(
|
||||||
|
out[head_idx].tool_calls.as_ref().map(|c| c.len()),
|
||||||
|
Some(2),
|
||||||
|
"头应保留两个 tool_call, 实际 {:?}",
|
||||||
|
out
|
||||||
|
);
|
||||||
|
// 头后紧邻其全部响应(保序 c1,c2),协议不变式成立
|
||||||
|
assert!(
|
||||||
|
matches!(out[head_idx + 1].role, MessageRole::Tool)
|
||||||
|
&& out[head_idx + 1].tool_call_id.as_deref() == Some("c1")
|
||||||
|
&& matches!(out[head_idx + 2].role, MessageRole::Tool)
|
||||||
|
&& out[head_idx + 2].tool_call_id.as_deref() == Some("c2"),
|
||||||
|
"head 后应紧邻其全部响应 c1,c2, 实际 {:?}",
|
||||||
|
out
|
||||||
|
);
|
||||||
|
// 两条夹层内容都保留(后移到响应之后)
|
||||||
|
assert!(
|
||||||
|
out.iter()
|
||||||
|
.any(|m| matches!(m.role, MessageRole::System) && m.content == "夹层X")
|
||||||
|
&& out.iter()
|
||||||
|
.any(|m| matches!(m.role, MessageRole::System) && m.content == "夹层Y"),
|
||||||
|
"两条夹层内容不得丢, 实际 {:?}",
|
||||||
|
out
|
||||||
|
);
|
||||||
|
|
||||||
|
// 幂等:再跑一次结果不变
|
||||||
|
let twice = sanitize_messages(out.clone());
|
||||||
|
let sig = |v: &[ChatMessage]| -> Vec<String> {
|
||||||
|
v.iter().map(|m| format!("{:?}|{}", m.role, m.content)).collect()
|
||||||
|
};
|
||||||
|
assert_eq!(sig(&out), sig(&twice), "多夹层归位应幂等(角色序与内容序不变)");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sanitize_heals_reverse_orphan_tool_result_with_placeholder_head() {
|
fn sanitize_heals_reverse_orphan_tool_result_with_placeholder_head() {
|
||||||
// 三元组完整性 P0:反向 orphan(tool_result 无对应 tool_call 头)→ 补占位头自愈(不丢)。
|
// 三元组完整性 P0:反向 orphan(tool_result 无对应 tool_call 头)→ 补占位头自愈(不丢)。
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ use crate::provider::{ChatMessage, MessageRole};
|
|||||||
/// 用于发送前预算控制,误差 ±15% 完全可接受(保守估计,宁可多算)。
|
/// 用于发送前预算控制,误差 ±15% 完全可接受(保守估计,宁可多算)。
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct TokenEstimator {
|
pub struct TokenEstimator {
|
||||||
/// 字符 → token 转换系数(默认 0.35,即 ~2.8 字符/token,中英混合偏保守)
|
/// 非 CJK 字符(拉丁/数字/符号)→ token 转换系数(默认 0.35,约 3 字符/token)
|
||||||
pub chars_ratio: f32,
|
pub chars_ratio: f32,
|
||||||
/// 每条消息固定开销(role 标记 + 格式)
|
/// 每条消息固定开销(role 标记 + 格式)
|
||||||
pub per_message_overhead: u32,
|
pub per_message_overhead: u32,
|
||||||
@@ -44,48 +44,83 @@ impl Default for TokenEstimator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// url 模式图片 token 地板(Anthropic 保守 ~1600/图,OpenAI 按 tile;URL 长度估严重低估会致预算裁剪不触发)。
|
/// CJK(中日韩)字符 token 比率:1 字约 1~1.5 token,取 1.2 略保守。
|
||||||
|
/// 此前统一按 0.35 计,中文低估 2~3 倍,长中文会话预算门形同虚设
|
||||||
|
///(估 3 万实 9 万,裁剪不触发直顶 provider 上限)。
|
||||||
|
const CJK_TOKEN_RATIO: f32 = 1.2;
|
||||||
|
|
||||||
|
// 图片 token 地板(base64 与 url 两模式同用,Anthropic 保守 ~1600/图,OpenAI 按 tile)。
|
||||||
// 2026-08-05(F-260801 Phase4):url 模式无字节可估,仅按 URL 长度估(几十字符≈几 token)会致含图
|
// 2026-08-05(F-260801 Phase4):url 模式无字节可估,仅按 URL 长度估(几十字符≈几 token)会致含图
|
||||||
// 消息 history_tokens 严重低估 → 预算裁剪不触发 → 多图/长会话超 provider 上限 400。地板按每图
|
// 消息 history_tokens 严重低估 → 预算裁剪不触发 → 多图/长会话超 provider 上限 400。地板按每图
|
||||||
// ~1600 token 保守估(对齐 Anthropic 图片 token 成本量级),宁可高估触发裁剪也不低估漏裁。
|
// ~1600 token 保守估(对齐 Anthropic 图片 token 成本量级),宁可高估触发裁剪也不低估漏裁。
|
||||||
// 注意单位是 token 不是字符:地板必须在 ×chars_ratio 折扣之后直接加 token,若混进 char_count
|
// 注意单位是 token 不是字符:地板必须在字符折扣之后直接加 token,若混进字符计数
|
||||||
// 会被 0.35 稀释成 560,保底名存实亡。
|
// 会被 0.35 稀释成 560,保底名存实亡。
|
||||||
const URL_IMAGE_TOKEN_FLOOR: usize = 1600;
|
// base64 同用地板(此前按字符 0.35 计):provider 端图片按分辨率/张数计费,与 base64
|
||||||
|
// 字符串长度无关,25 万字符按 0.35 计 8.7 万 token 是纯高估,致含图消息过度裁剪;
|
||||||
|
// 两模式语义等价(都是一张图),成本口径单一(DRY)。
|
||||||
|
const IMAGE_TOKEN_FLOOR: usize = 1600;
|
||||||
|
|
||||||
|
/// CJK 字符判定:中日韩文字/假名/谚文/CJK 标点/全角形式。
|
||||||
|
fn is_cjk(ch: char) -> bool {
|
||||||
|
matches!(ch as u32,
|
||||||
|
0x3000..=0x303F // CJK 标点
|
||||||
|
| 0x3040..=0x30FF // 日文假名
|
||||||
|
| 0x3400..=0x4DBF // 表意文字扩展 A
|
||||||
|
| 0x4E00..=0x9FFF // CJK 基本区
|
||||||
|
| 0xAC00..=0xD7AF // 韩文谚文
|
||||||
|
| 0xF900..=0xFAFF // 兼容表意文字
|
||||||
|
| 0xFF00..=0xFFEF // 全角形式
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 分档字符 token 计数:CJK 按 [`CJK_TOKEN_RATIO`],其余按调用方给定比率。
|
||||||
|
fn weighted_char_tokens(s: &str, plain_ratio: f32) -> u32 {
|
||||||
|
let (mut cjk, mut plain) = (0usize, 0usize);
|
||||||
|
for ch in s.chars() {
|
||||||
|
if is_cjk(ch) { cjk += 1 } else { plain += 1 }
|
||||||
|
}
|
||||||
|
(cjk as f32 * CJK_TOKEN_RATIO + plain as f32 * plain_ratio).ceil() as u32
|
||||||
|
}
|
||||||
|
|
||||||
impl TokenEstimator {
|
impl TokenEstimator {
|
||||||
/// 估算单条消息的 token 数(保守估计)
|
/// 估算单条消息的 token 数(保守估计)
|
||||||
///
|
///
|
||||||
/// 多模态回归修正:`msg.parts` 中的 Image.base64 与 Text.text 同样计入预算。
|
/// 文本按 CJK/其余分档计数;图片(base64 与 url)统一按 [`IMAGE_TOKEN_FLOOR`] 计,
|
||||||
/// 此前只算 `content`,含图消息的大段 base64(可达 25 万 tokens)被完全忽略,致
|
/// 不随 base64 字符串长度膨胀。
|
||||||
/// `history_tokens` 严重低估 → build_for_request 误判未超预算 → provider 超限 400/500。
|
|
||||||
/// 这里把 parts 的文本/base64 按同一 chars_ratio 粗估累加(base64 视为密集字符,0.35 偏保守)。
|
|
||||||
pub fn estimate_message(&self, msg: &ChatMessage) -> u32 {
|
pub fn estimate_message(&self, msg: &ChatMessage) -> u32 {
|
||||||
let mut char_count = msg.content.chars().count();
|
// namespace 引用(Tool 消息)特殊入账:按 ref 尾段携带的原文字节数保守视作字符
|
||||||
// url 图地板单独按 token 累加:地板语义是"每图至少 ~1600 token",必须生效于
|
// 并按 plain 比率计(多为代码/JSON 输出);中文引用会低估,方向可接受:ref 体量
|
||||||
// ×chars_ratio 折扣之后——若折进 char_count 会被 0.35 稀释成 560,1600 保底失效,
|
// 本就由 namespace 上限闸门控,此处入账只为预算可见性。
|
||||||
// 含 url 图消息的 history_tokens 低估 → 预算裁剪漏触发 → provider 超限 400。
|
let mut content_tokens = if matches!(msg.role, crate::provider::MessageRole::Tool) {
|
||||||
let mut url_image_floor_tokens: u32 = 0;
|
match crate::namespace_store::parse_ref_content_len(&msg.content) {
|
||||||
|
Some(n) => (n as f32 * self.chars_ratio).ceil() as u32,
|
||||||
|
None => weighted_char_tokens(&msg.content, self.chars_ratio),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
weighted_char_tokens(&msg.content, self.chars_ratio)
|
||||||
|
};
|
||||||
|
// 图片地板按 token 直接累加,生效于字符折扣之外(折进字符计数会被 0.35 稀释,
|
||||||
|
// 1600 保底失效,预算裁剪漏触发 → provider 超限 400)。
|
||||||
|
let mut image_floor_tokens: u32 = 0;
|
||||||
if let Some(parts) = &msg.parts {
|
if let Some(parts) = &msg.parts {
|
||||||
for p in parts {
|
for p in parts {
|
||||||
match p {
|
match p {
|
||||||
crate::provider::ContentPart::Text { text } => char_count += text.chars().count(),
|
crate::provider::ContentPart::Text { text } => {
|
||||||
|
content_tokens = content_tokens
|
||||||
|
.saturating_add(weighted_char_tokens(text, self.chars_ratio));
|
||||||
|
}
|
||||||
crate::provider::ContentPart::Image { base64, url, .. } => {
|
crate::provider::ContentPart::Image { base64, url, .. } => {
|
||||||
// 0.35 按 base64 字节数粗估,显著高于厂商实际(OpenAI 按像素非字节)。
|
// base64 与 url 语义等价(都是一张图),同用地板;有 base64 时
|
||||||
// 偏保守致含图消息 token 高估、过度裁剪;降值(如 0.10~0.15)需独立评估裁剪边界,本次不改值仅标注。
|
// url 为空(构造端二选一),不重复计
|
||||||
// base64 优先(多模态主载荷),url 次之;url 模式无字节,按地板直接计 token
|
if base64.is_some() || url.is_some() {
|
||||||
if let Some(b) = base64 {
|
image_floor_tokens =
|
||||||
char_count += b.chars().count();
|
image_floor_tokens.saturating_add(IMAGE_TOKEN_FLOOR as u32);
|
||||||
} 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 + image_floor_tokens + self.per_message_overhead;
|
||||||
let mut total = content_tokens + url_image_floor_tokens + self.per_message_overhead;
|
|
||||||
|
|
||||||
// tool_calls 的 JSON 结构开销(role=Assistant 时可能有)
|
// tool_calls 的 JSON 结构开销(role=Assistant 时可能有)
|
||||||
if let Some(ref calls) = msg.tool_calls {
|
if let Some(ref calls) = msg.tool_calls {
|
||||||
@@ -104,9 +139,9 @@ impl TokenEstimator {
|
|||||||
total
|
total
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 估算纯文本字符串的 token 数(用于 system prompt)
|
/// 估算纯文本字符串的 token 数(用于 system prompt),CJK 分档同 [`Self::estimate_message`]
|
||||||
pub fn estimate_text(&self, text: &str) -> u32 {
|
pub fn estimate_text(&self, text: &str) -> u32 {
|
||||||
(text.chars().count() as f32 * self.chars_ratio).ceil() as u32
|
weighted_char_tokens(text, self.chars_ratio)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -423,6 +458,17 @@ pub const TOOL_RESULT_CHAR_LIMIT: usize = 1_024;
|
|||||||
/// JSON 对象中字符串字段值的最大字符数(超过则截断)。独立于行数截断,
|
/// JSON 对象中字符串字段值的最大字符数(超过则截断)。独立于行数截断,
|
||||||
/// 解决 `{"content":"大段文字(无换行)"}` 类 JSON 逃逸行级截断的问题。
|
/// 解决 `{"content":"大段文字(无换行)"}` 类 JSON 逃逸行级截断的问题。
|
||||||
pub const TOOL_RESULT_JSON_STR_FIELD_MAX: usize = 512;
|
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%。
|
/// 判断 tool_result 是否需摘要压缩:content >2KB 或 占比 >40%。
|
||||||
///
|
///
|
||||||
@@ -454,14 +500,30 @@ pub fn should_summarize_tool_result(
|
|||||||
/// - 错误行(在头尾区间外的)额外插入,标注位置。
|
/// - 错误行(在头尾区间外的)额外插入,标注位置。
|
||||||
/// - 中间大段省略为 `... (省略 N 行) ...`。
|
/// - 中间大段省略为 `... (省略 N 行) ...`。
|
||||||
///
|
///
|
||||||
/// `tool_name` 参与内容判断:`read_file` 是 AI 定向读代码的工具,压缩其 content
|
/// `tool_name` 参与内容判断:`read_file` 定向读代码,压缩其 content 会削弱代码分析能力,
|
||||||
/// 等于直接阉割 AI 的代码分析能力(实测 limit=100 读 28KB,被折叠为首尾各 5 行),
|
/// 故保留**小文件豁免**(≤ READ_FILE_OFFLOAD_BYTES 原样返回);超阈值仍走压缩并落盘
|
||||||
/// 故对 `read_file` 一律豁免压缩(原样返回)。其余工具仍走压缩。
|
/// (P1 2026-10-10,句柄可读回,无损)。其余工具仍走压缩。
|
||||||
/// 空内容一律返回空字符串。
|
/// 空内容一律返回空字符串。
|
||||||
pub fn extract_key_info(content: &str, tool_name: &str) -> String {
|
pub fn extract_key_info(content: &str, tool_name: &str) -> String {
|
||||||
// read_file 豁免:AI 定向读代码,压缩 content 违背用户/AI 意图。
|
let compressed = extract_key_info_inner(content, tool_name);
|
||||||
// read_file handler 自带 limit 硬上限 2000 行(file.rs:156/168),无爆 prompt 风险。
|
// P1 无损化(2026-10-10):确实发生压缩 → 原文落盘 + 追加句柄行,被省略的中段可 read_file 读回。
|
||||||
if tool_name == "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();
|
return content.to_string();
|
||||||
}
|
}
|
||||||
// JSON 感知压缩:识别对象中的大数组/大字符串并截断
|
// JSON 感知压缩:识别对象中的大数组/大字符串并截断
|
||||||
@@ -620,12 +682,22 @@ pub struct EvictionUnit {
|
|||||||
/// 保护区大小:最后 N 条消息永不淘汰(≈ 最近 2 个完整用户轮次)
|
/// 保护区大小:最后 N 条消息永不淘汰(≈ 最近 2 个完整用户轮次)
|
||||||
pub const PROTECT_COUNT: usize = 6;
|
pub const PROTECT_COUNT: usize = 6;
|
||||||
|
|
||||||
|
/// 块状裁剪目标水位(× available):超预算时一次裁到该水位以下,而非贪心的刚好塞下。
|
||||||
|
///
|
||||||
|
/// 块大小 = (1 − T) × available,随模型窗口自适应缩放。两次裁剪之间裁剪边界不动 →
|
||||||
|
/// 被淘汰集不变 → 淘汰摘要内容不变(关键词提炼确定性)→ 发送视图前缀逐字节稳定,
|
||||||
|
/// 前缀缓存每轮命中。默认 0.8:块 ≈ 19K tokens,稳定窗口 2–9 轮,估算误差 ±15%
|
||||||
|
/// 被水位差吸收,不会刚裁完就再触。
|
||||||
|
pub const TRIM_TARGET_RATIO: f32 = 0.8;
|
||||||
|
|
||||||
/// 淘汰补偿摘要的硬上限(字符数)。
|
/// 淘汰补偿摘要的硬上限(字符数)。
|
||||||
///
|
///
|
||||||
/// build_for_request 超预算裁剪时,把被淘汰文本提炼成一短段 system 摘要插回发送视图
|
/// build_for_request 超预算裁剪时,把被淘汰文本提炼成一短段 system 摘要插回发送视图
|
||||||
/// 头部(补偿无声丢弃的早期主题,对齐压缩路径的"裁剪有补偿"语义)。摘要过长会吃掉
|
/// 头部(补偿无声丢弃的早期主题,对齐压缩路径的"裁剪有补偿"语义)。摘要过长会吃掉
|
||||||
/// 本次裁剪腾出的预算,故硬截断到此上限(≈百 token 量级,相对淘汰收益可忽略)。
|
/// 本次裁剪腾出的预算,故硬截断到此上限。原 300 字(≈百 token)对几十轮历史的
|
||||||
pub const EVICTION_SUMMARY_MAX_CHARS: usize = 300;
|
/// 摘要形同虚设,放大到 1000(≈300 token)——相对被裁掉的数千~数万 token 仍是零头,
|
||||||
|
/// 不侵蚀裁剪收益;PROTECT_COUNT 保持 6 不动(无实证症状,避免无据行为变更)。
|
||||||
|
pub const EVICTION_SUMMARY_MAX_CHARS: usize = 1000;
|
||||||
|
|
||||||
/// Anthropic 流式 tool_use 缺 id 时的占位 id 前缀(见 anthropic_compat.rs 170-176)。
|
/// Anthropic 流式 tool_use 缺 id 时的占位 id 前缀(见 anthropic_compat.rs 170-176)。
|
||||||
/// 此类 id 必然无匹配 tool_result,是历史中毒的标志,sanitize 时据此剔除畸形三元组。
|
/// 此类 id 必然无匹配 tool_result,是历史中毒的标志,sanitize 时据此剔除畸形三元组。
|
||||||
@@ -1032,18 +1104,18 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn eviction_summary_hard_capped_at_max_chars() {
|
fn eviction_summary_hard_capped_at_max_chars() {
|
||||||
// 12 条纯标识符消息(每个标识符 39 字符,freq 均等且无中文干扰)→ top-10 全为长词,
|
// 40 条纯标识符消息(每个标识符 39 字符,freq 均等且无中文干扰)→ top-10 全为长词,
|
||||||
// 总长超 300 → 硬截断到 EVICTION_SUMMARY_MAX_CHARS。
|
// 总长超上限(上限放大到 1000 后需更多数据才触发截断) → 硬截断。
|
||||||
let msgs: Vec<ChatMessage> = (0..12)
|
let msgs: Vec<ChatMessage> = (0..40)
|
||||||
.map(|i| {
|
.map(|i| {
|
||||||
ChatMessage::user(&format!("long_identifier_{:02}_for_truncation_probe", i))
|
ChatMessage::user(&format!("long_identifier_{:02}_for_truncation_probe", i))
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let text = build_eviction_summary_text(&msgs).expect("应产出摘要");
|
let text = build_eviction_summary_text(&msgs).expect("应产出摘要");
|
||||||
assert_eq!(
|
// 上限是防御性天花板(结构性产出 top-10 词表通常远低于它),断言「不超」而非「精确等于」
|
||||||
text.chars().count(),
|
assert!(
|
||||||
EVICTION_SUMMARY_MAX_CHARS,
|
text.chars().count() <= EVICTION_SUMMARY_MAX_CHARS,
|
||||||
"超长时应精确截断到硬上限, 实际 {}",
|
"摘要不得超硬上限, 实际 {}",
|
||||||
text.chars().count()
|
text.chars().count()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1717,8 +1789,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn url_image_token_floor_survives_ratio_discount() {
|
fn url_image_token_floor_survives_ratio_discount() {
|
||||||
// url 图地板语义是"每图至少 ~1600 token",必须生效于 ×chars_ratio 折扣之后:
|
// 图地板语义是"每图至少 ~1600 token",必须生效于字符折扣之外:
|
||||||
// 若地板折进 char_count 再乘 0.35,1600 被稀释成 ~560,保底失效 → 预算裁剪漏触发。
|
// 若地板折进字符计数再乘比率,1600 被稀释成 ~560,保底失效 → 预算裁剪漏触发。
|
||||||
let est = TokenEstimator::default();
|
let est = TokenEstimator::default();
|
||||||
let msg = ChatMessage::user_parts(
|
let msg = ChatMessage::user_parts(
|
||||||
"t",
|
"t",
|
||||||
@@ -1726,10 +1798,41 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let tokens = est.estimate_message(&msg);
|
let tokens = est.estimate_message(&msg);
|
||||||
assert!(
|
assert!(
|
||||||
tokens >= URL_IMAGE_TOKEN_FLOOR as u32,
|
tokens >= IMAGE_TOKEN_FLOOR as u32,
|
||||||
"url 图消息 token({}) 应 >= 地板 {},稀释 bug 下仅 ~565",
|
"url 图消息 token({}) 应 >= 地板 {},稀释 bug 下仅 ~565",
|
||||||
tokens,
|
tokens,
|
||||||
URL_IMAGE_TOKEN_FLOOR
|
IMAGE_TOKEN_FLOOR
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn base64_image_counts_as_floor_not_chars() {
|
||||||
|
// base64 不按字符计:provider 端图片按张数/分辨率计费,25 万字符按 0.35
|
||||||
|
// 计 8.7 万 token 是纯高估致过度裁剪。改地板后与图的真实成本同量级。
|
||||||
|
let est = TokenEstimator::default();
|
||||||
|
let big_base64 = "iVBORw0KGgoAAAANS".repeat(15000); // ~25.5 万字符
|
||||||
|
let msg = ChatMessage::user_parts(
|
||||||
|
"t",
|
||||||
|
vec![crate::provider::ContentPart::image_base64("image/png", big_base64)],
|
||||||
|
);
|
||||||
|
let tokens = est.estimate_message(&msg);
|
||||||
|
// 期望构成:地板 1600 + "t"(1 plain 字符 ceil 后 1) + 每消息开销 4
|
||||||
|
assert_eq!(
|
||||||
|
tokens,
|
||||||
|
IMAGE_TOKEN_FLOOR as u32 + 1 + est.per_message_overhead,
|
||||||
|
"base64 图消息应恰为地板+文本+开销,实际 {}",
|
||||||
|
tokens
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cjk_chars_cost_more_than_latin() {
|
||||||
|
// 中文按 1.2 token/字计:同字符数下中文估值须显著高于拉丁,预算门不再低估 2~3 倍
|
||||||
|
let est = TokenEstimator::default();
|
||||||
|
let zh = est.estimate_text("预算控制测试"); // 6 CJK 字符
|
||||||
|
let en = est.estimate_text("abcdef"); // 6 拉丁字符
|
||||||
|
assert_eq!(zh, 8, "6 CJK 字应计 8 token(6×1.2=7.2 ceil)");
|
||||||
|
assert_eq!(en, 3, "6 拉丁字符应计 3 token(6×0.35=2.1 ceil)");
|
||||||
|
assert!(zh > en * 2, "CJK 估值({zh})须显著高于同长度拉丁({en})");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(_), .. }));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ pub mod anthropic_helpers;
|
|||||||
pub mod context;
|
pub mod context;
|
||||||
pub mod context_helpers;
|
pub mod context_helpers;
|
||||||
pub mod memory_adapter;
|
pub mod memory_adapter;
|
||||||
|
pub(crate) mod net_probe;
|
||||||
// 多 Agent 协作调度中心(Phase 1 规则驱动, Phase 2 LLM 扩展)。
|
// 多 Agent 协作调度中心(Phase 1 规则驱动, Phase 2 LLM 扩展)。
|
||||||
pub mod coordinator;
|
pub mod coordinator;
|
||||||
// 会话意图识别层(纯函数,不接入 agentic loop)。依据 docs/02-架构设计/构想审查/
|
// 会话意图识别层(纯函数,不接入 agentic loop)。依据 docs/02-架构设计/构想审查/
|
||||||
@@ -32,6 +33,8 @@ mod model_probe_helpers;
|
|||||||
pub mod plan_hint;
|
pub mod plan_hint;
|
||||||
pub mod openai_compat;
|
pub mod openai_compat;
|
||||||
pub mod openai_helpers;
|
pub mod openai_helpers;
|
||||||
|
// 本地图片引用展开(#958 图片入站引用化):消息存 file:// 引用,发送前单点展开 base64
|
||||||
|
pub mod image_ref;
|
||||||
// Phase 0 (B 路线): Plan-driven 规划骨架(维度 0.8 Plan/SubTask 结构 + 0.7 规划校验/Kahn 分层)。
|
// Phase 0 (B 路线): Plan-driven 规划骨架(维度 0.8 Plan/SubTask 结构 + 0.7 规划校验/Kahn 分层)。
|
||||||
// 纯函数模块(零 IO 零状态)。依据 docs/02-架构设计/单对话并行多轮-{设计,Phase0落地路线图}-2026-06-20.md。
|
// 纯函数模块(零 IO 零状态)。依据 docs/02-架构设计/单对话并行多轮-{设计,Phase0落地路线图}-2026-06-20.md。
|
||||||
// Phase 1(plan_hint 接入主 loop)/Phase 2(planning)/Phase 3(并行 execution) 接入后续。
|
// Phase 1(plan_hint 接入主 loop)/Phase 2(planning)/Phase 3(并行 execution) 接入后续。
|
||||||
@@ -49,6 +52,9 @@ pub mod router;
|
|||||||
pub mod retry;
|
pub mod retry;
|
||||||
pub mod sse_parser;
|
pub mod sse_parser;
|
||||||
pub mod namespace_store;
|
pub mod namespace_store;
|
||||||
|
// P1 工具结果瘦身(上下文根治专项 2026-10-06 设计 §二):超阈值工具结果正文落盘 + 句柄。
|
||||||
|
// 与 src-tauri 审计侧 D2a(tool_result_offload) 同构;base 未注入时降级为纯摘要(零行为变化)。
|
||||||
|
pub mod tool_offload;
|
||||||
|
|
||||||
use provider::LlmProvider;
|
use provider::LlmProvider;
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
|
|||||||
@@ -5,6 +5,15 @@
|
|||||||
//!
|
//!
|
||||||
//! 存储格式: "namespace://tool_name/args_hash"
|
//! 存储格式: "namespace://tool_name/args_hash"
|
||||||
//! 例: namespace://read_file/a1b2c3d4
|
//! 例: 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::collections::HashMap;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
@@ -16,6 +25,7 @@ pub const NAMESPACE_REF_PREFIX: &str = "namespace://";
|
|||||||
///
|
///
|
||||||
/// 旧实现 None 分支保留 "namespace://" 字面 URI,LLM 收到无意义串且无告警。
|
/// 旧实现 None 分支保留 "namespace://" 字面 URI,LLM 收到无意义串且无告警。
|
||||||
/// 现统一替换为此文案:既告知用户结果已淘汰,又提示可重新调用工具取回。
|
/// 现统一替换为此文案:既告知用户结果已淘汰,又提示可重新调用工具取回。
|
||||||
|
/// 治理后:EvictionLog 命中时展开点改用富提示(见 `eviction_rich_notice`),未命中原样。
|
||||||
pub const EVICTED_PLACEHOLDER: &str = "[该工具结果已超出内存上限被淘汰,如需请重新调用对应工具]";
|
pub const EVICTED_PLACEHOLDER: &str = "[该工具结果已超出内存上限被淘汰,如需请重新调用对应工具]";
|
||||||
|
|
||||||
/// namespace 字节阈值(> 2048 bytes 触发)
|
/// namespace 字节阈值(> 2048 bytes 触发)
|
||||||
@@ -27,6 +37,12 @@ pub const NAMESPACE_LINE_THRESHOLD: usize = 50;
|
|||||||
/// 始终进 namespace 的工具名(其输出几乎总是大结果)
|
/// 始终进 namespace 的工具名(其输出几乎总是大结果)
|
||||||
pub const ALWAYS_LARGE_TOOLS: &[&str] = &["read_file", "list_directory", "grep", "diff_files"];
|
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
|
/// 判定工具结果是否应进入 namespace
|
||||||
pub fn should_use_namespace(content: &str, tool_name: &str) -> bool {
|
pub fn should_use_namespace(content: &str, tool_name: &str) -> bool {
|
||||||
if ALWAYS_LARGE_TOOLS.contains(&tool_name) {
|
if ALWAYS_LARGE_TOOLS.contains(&tool_name) {
|
||||||
@@ -44,9 +60,25 @@ pub fn is_namespace_ref(content: &str) -> bool {
|
|||||||
/// 从引用路径中提取内部 key
|
/// 从引用路径中提取内部 key
|
||||||
pub fn parse_namespace_key(full_path: &str) -> Option<&str> {
|
pub fn parse_namespace_key(full_path: &str) -> Option<&str> {
|
||||||
full_path.strip_prefix(NAMESPACE_REF_PREFIX)
|
full_path.strip_prefix(NAMESPACE_REF_PREFIX)
|
||||||
|
// 939①: 先剥 ?len= 长度尾段(估算入账用),否则尾段并入 key 致全线查空
|
||||||
|
.and_then(|s| s.split("?len=").next())
|
||||||
.and_then(|s| s.split('/').nth(1))
|
.and_then(|s| s.split('/').nth(1))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 从引用路径提取原文字节数(939①)。带 `?len=N` 尾段的引用返回 Some(N);
|
||||||
|
/// 旧格式/放弃入库的无尾段引用返回 None(调用方按字面字符保守估算)。
|
||||||
|
pub fn parse_ref_content_len(full_path: &str) -> Option<usize> {
|
||||||
|
full_path.rsplit("?len=")
|
||||||
|
.next()
|
||||||
|
.and_then(|s| s.parse::<usize>().ok())
|
||||||
|
.filter(|_| full_path.contains("?len="))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从引用路径提取工具名段(懒回填重建条目元数据用)。
|
||||||
|
pub fn parse_ref_tool_name(full_path: &str) -> Option<&str> {
|
||||||
|
full_path.strip_prefix(NAMESPACE_REF_PREFIX)?.split('/').next()
|
||||||
|
}
|
||||||
|
|
||||||
/// namespace 条目
|
/// namespace 条目
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct NamespaceEntry {
|
pub struct NamespaceEntry {
|
||||||
@@ -55,14 +87,33 @@ pub struct NamespaceEntry {
|
|||||||
pub content: String,
|
pub content: String,
|
||||||
pub created_at: Instant,
|
pub created_at: Instant,
|
||||||
pub access_count: u64,
|
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,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// namespace 存储(内存 HashMap + LRU 淘汰)
|
/// 淘汰留档记录(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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct NamespaceStore {
|
pub struct NamespaceStore {
|
||||||
entries: HashMap<String, NamespaceEntry>,
|
entries: HashMap<String, NamespaceEntry>,
|
||||||
max_bytes: usize,
|
max_bytes: usize,
|
||||||
current_bytes: usize,
|
current_bytes: usize,
|
||||||
|
/// 淘汰留档(VecDeque 语义,容量 EVICTION_LOG_CAP)
|
||||||
|
eviction_log: std::collections::VecDeque<EvictionRecord>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl NamespaceStore {
|
impl NamespaceStore {
|
||||||
@@ -72,6 +123,7 @@ impl NamespaceStore {
|
|||||||
entries: HashMap::new(),
|
entries: HashMap::new(),
|
||||||
max_bytes,
|
max_bytes,
|
||||||
current_bytes: 0,
|
current_bytes: 0,
|
||||||
|
eviction_log: std::collections::VecDeque::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,21 +132,45 @@ impl NamespaceStore {
|
|||||||
Self::new(10 * 1024 * 1024)
|
Self::new(10 * 1024 * 1024)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 存入 namespace。返回引用路径字符串。
|
/// 存入 namespace。返回引用路径字符串。(兼容入口,owner=None 无主)
|
||||||
///
|
///
|
||||||
/// 超限行为分两档:常规超限 LRU 淘汰旧条目腾位(既有语义);单条本身超过
|
/// 超限行为分两档:常规超限 LRU 淘汰旧条目腾位(既有语义);单条本身超过
|
||||||
/// max_bytes 时淘汰清空也放不下,拒绝入库只返回引用路径——展开点对
|
/// max_bytes 时淘汰清空也放不下,拒绝入库只返回引用路径——展开点对
|
||||||
/// read_only()=None 统一落 EVICTED_PLACEHOLDER,与 LRU 淘汰后的不可解析引用同语义。
|
/// read_only()=None 统一落 EVICTED_PLACEHOLDER,与 LRU 淘汰后的不可解析引用同语义。
|
||||||
pub fn store(&mut self, tool_name: &str, content: &str) -> String {
|
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 key = simple_hash(content);
|
||||||
|
// 939① 估算入账:ref 尾段携带原文字节数,TokenEstimator 解析即按真实体量入账
|
||||||
|
// (旧口径 ref 仅 ~30 字符被记 ~15 token,预算/裁剪/压缩三重门对其失效)。
|
||||||
|
// parse_namespace_key 取 nth(1) 不受尾段影响,展开/淘汰提示零改动。
|
||||||
|
// 放弃入库(超容量/淘汰后仍放不下)返回**不带长度**的 path:原文不在池,展开点
|
||||||
|
// 落占位提示,预算按轻量字符计——已淘汰内容不应继续虚占预算。
|
||||||
|
let path_with_len = format!("{}{}/{}?len={}", NAMESPACE_REF_PREFIX, tool_name, key, content.len());
|
||||||
let path = format!("{}{}/{}", NAMESPACE_REF_PREFIX, tool_name, key);
|
let path = format!("{}{}/{}", NAMESPACE_REF_PREFIX, tool_name, key);
|
||||||
|
|
||||||
// 已存在 → 刷新 access_count,不重复占用空间
|
// 已存在 → 刷新 access_count,不重复占用空间;若本次要求 pin,升级既有条目
|
||||||
if self.entries.contains_key(&key) {
|
if self.entries.contains_key(&key) {
|
||||||
if let Some(entry) = self.entries.get_mut(&key) {
|
if let Some(entry) = self.entries.get_mut(&key) {
|
||||||
entry.access_count += 1;
|
entry.access_count += 1;
|
||||||
|
if pinned && !entry.pinned {
|
||||||
|
entry.pinned = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return path;
|
return path_with_len;
|
||||||
}
|
}
|
||||||
|
|
||||||
let new_bytes = content.len();
|
let new_bytes = content.len();
|
||||||
@@ -105,20 +181,83 @@ impl NamespaceStore {
|
|||||||
return path;
|
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);
|
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 {
|
let entry = NamespaceEntry {
|
||||||
key: key.clone(),
|
key: key.clone(),
|
||||||
tool_name: tool_name.to_string(),
|
tool_name: tool_name.to_string(),
|
||||||
content: content.to_string(),
|
content: content.to_string(),
|
||||||
created_at: Instant::now(),
|
created_at: Instant::now(),
|
||||||
access_count: 1,
|
access_count: 1,
|
||||||
|
pinned,
|
||||||
|
owner: owner.map(str::to_string),
|
||||||
|
evict_hint: content.chars().take(80).collect(),
|
||||||
};
|
};
|
||||||
|
|
||||||
self.current_bytes += new_bytes;
|
self.current_bytes += new_bytes;
|
||||||
self.entries.insert(key, entry);
|
self.entries.insert(key, entry);
|
||||||
path
|
path_with_len
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 读取完整内容
|
/// 读取完整内容
|
||||||
@@ -135,6 +274,51 @@ impl NamespaceStore {
|
|||||||
self.entries.get(key).map(|e| e.content.as_str())
|
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,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 跨重启懒回填:store 清空后旧引用首次展开未命中时,从会话消息原文找回并重建条目。
|
||||||
|
/// DB 落库约定存展开后原文,引用 key 即入库时的原文内容哈希(SipHash 确定性,跨进程
|
||||||
|
/// 一致),哈希精确匹配即可靠对应单条原文,无需按序重建;原文不在消息里的引用(真淘汰)
|
||||||
|
/// 不进返回值,调用方维持淘汰占位语义。
|
||||||
|
/// 入库走 store 既有容量语义(超容量拒绝只影响后续轮次命中,本轮视图仍用返回的原文)。
|
||||||
|
/// 返回 引用路径 → 原文。
|
||||||
|
pub fn backfill_from_messages(
|
||||||
|
&mut self,
|
||||||
|
missed_refs: &[String],
|
||||||
|
message_contents: &[String],
|
||||||
|
) -> HashMap<String, String> {
|
||||||
|
let mut recovered: HashMap<String, String> = HashMap::new();
|
||||||
|
if missed_refs.is_empty() {
|
||||||
|
return recovered;
|
||||||
|
}
|
||||||
|
let wanted: HashMap<&str, &String> = missed_refs.iter()
|
||||||
|
.filter_map(|p| parse_namespace_key(p).map(|k| (k, p)))
|
||||||
|
.collect();
|
||||||
|
for content in message_contents {
|
||||||
|
let key = simple_hash(content);
|
||||||
|
if let Some(path) = wanted.get(key.as_str()) {
|
||||||
|
// 同一引用可能在多条历史消息出现,找回一次即可
|
||||||
|
if recovered.contains_key(path.as_str()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(tool) = parse_ref_tool_name(path) {
|
||||||
|
self.store(tool, content);
|
||||||
|
}
|
||||||
|
recovered.insert((*path).clone(), content.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
recovered
|
||||||
|
}
|
||||||
|
|
||||||
/// 检查 key 是否存在
|
/// 检查 key 是否存在
|
||||||
pub fn contains(&self, full_path: &str) -> bool {
|
pub fn contains(&self, full_path: &str) -> bool {
|
||||||
parse_namespace_key(full_path)
|
parse_namespace_key(full_path)
|
||||||
@@ -151,26 +335,68 @@ impl NamespaceStore {
|
|||||||
self.entries.is_empty()
|
self.entries.is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// LRU 淘汰:删除最旧条目直到腾出所需空间
|
/// LRU 淘汰:owner 软隔离三段顺序(设计 §3.4):
|
||||||
fn evict_if_needed(&mut self, needed: usize) {
|
/// ①无主且未 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() {
|
while self.current_bytes + needed > self.max_bytes && !self.entries.is_empty() {
|
||||||
// 找最旧的条目
|
// 三段淘汰顺序:无主最旧 → 他主最旧 → 当前主最旧(各段均跳过 pinned)
|
||||||
let oldest_key = self.entries.iter()
|
let oldest_key = self.entries.iter()
|
||||||
|
.filter(|(_, e)| !e.pinned && e.owner.is_none())
|
||||||
.min_by_key(|(_, e)| e.created_at)
|
.min_by_key(|(_, e)| e.created_at)
|
||||||
.map(|(k, _)| k.clone());
|
.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())
|
||||||
|
});
|
||||||
|
|
||||||
if let Some(key) = oldest_key {
|
match oldest_key {
|
||||||
if let Some(entry) = self.entries.remove(&key) {
|
Some(key) => {
|
||||||
self.current_bytes = self.current_bytes.saturating_sub(entry.content.len());
|
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) {
|
pub fn clear(&mut self) {
|
||||||
self.entries.clear();
|
self.entries.clear();
|
||||||
self.current_bytes = 0;
|
self.current_bytes = 0;
|
||||||
|
self.eviction_log.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,4 +531,118 @@ mod tests {
|
|||||||
assert!(ns.is_empty());
|
assert!(ns.is_empty());
|
||||||
assert_eq!(ns.current_bytes, 0);
|
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 总量可被压缩");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ref_carries_content_len_for_budget() {
|
||||||
|
// 939①: 成功入库的 ref 带 ?len= 原文字节数,parse 往返一致;
|
||||||
|
// parse_namespace_key 不受尾段影响(展开点零改动)
|
||||||
|
let mut ns = NamespaceStore::new(100_000);
|
||||||
|
let content = "x".repeat(5000);
|
||||||
|
let path = ns.store("read_file", &content);
|
||||||
|
assert!(path.contains(&format!("?len={}", content.len())), "ref 应带 len: {path}");
|
||||||
|
assert_eq!(parse_ref_content_len(&path), Some(content.len()));
|
||||||
|
assert_eq!(parse_namespace_key(&path), parse_namespace_key(&format!("namespace://read_file/{}", crate::namespace_store::simple_hash(&content))));
|
||||||
|
// 旧格式/无尾段 → None(调用方保守按字面计)
|
||||||
|
assert_eq!(parse_ref_content_len("namespace://read_file/abc"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 跨重启懒回填(跨会话召回设计 缺口3) ──
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backfill_recovers_original_by_content_hash() {
|
||||||
|
// 引用路径取自热 store(模拟重启前生成),冷 store(重启后)从消息原文找回
|
||||||
|
let mut hot = NamespaceStore::new(1 << 20);
|
||||||
|
let original = "目录列表内容\n".repeat(100);
|
||||||
|
let path = hot.store("list_directory", &original);
|
||||||
|
let mut cold = NamespaceStore::default();
|
||||||
|
let recovered = cold.backfill_from_messages(
|
||||||
|
&[path.clone()],
|
||||||
|
&[original.clone(), "无关消息".to_string()],
|
||||||
|
);
|
||||||
|
assert_eq!(recovered.get(&path).map(String::as_str), Some(original.as_str()));
|
||||||
|
assert_eq!(cold.read_only(&path).map(str::to_owned), Some(original), "回填后条目应可读");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backfill_without_original_stays_empty() {
|
||||||
|
let mut hot = NamespaceStore::new(1 << 20);
|
||||||
|
let path = hot.store("read_file", &"真原文".repeat(500));
|
||||||
|
let mut cold = NamespaceStore::default();
|
||||||
|
let recovered = cold.backfill_from_messages(
|
||||||
|
&[path.clone()],
|
||||||
|
&["无关A".to_string(), "无关B".to_string()],
|
||||||
|
);
|
||||||
|
assert!(recovered.is_empty(), "无对应原文不应误填");
|
||||||
|
assert_eq!(cold.read_only(&path), None);
|
||||||
|
assert!(cold.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backfill_duplicate_refs_recover_once() {
|
||||||
|
// 同一引用出现在多条历史消息:找回一次,store 只建一条
|
||||||
|
let mut hot = NamespaceStore::new(1 << 20);
|
||||||
|
let original = "z".repeat(3000);
|
||||||
|
let path = hot.store("grep", &original);
|
||||||
|
let mut cold = NamespaceStore::default();
|
||||||
|
let recovered = cold.backfill_from_messages(&[path.clone(), path.clone()], &[original]);
|
||||||
|
assert_eq!(recovered.len(), 1);
|
||||||
|
assert_eq!(cold.len(), 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(_))
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -118,10 +118,12 @@ impl OpenAICompatProvider {
|
|||||||
// 多模态 content(须在 move m.tool_calls 之前算,借用 m)。
|
// 多模态 content(须在 move m.tool_calls 之前算,借用 m)。
|
||||||
// 含图消息走 content 数组(text/image_url);纯文本走字符串简写
|
// 含图消息走 content 数组(text/image_url);纯文本走字符串简写
|
||||||
// (保持与现有纯文本端点零回归)。image_url 支持 data URI(base64)与 http(s) URL。
|
// (保持与现有纯文本端点零回归)。image_url 支持 data URI(base64)与 http(s) URL。
|
||||||
|
// 本地 file:// 引用(#958)先展开为 base64 内嵌——OpenAI 端点不认本地路径
|
||||||
let content = if m.has_image() {
|
let content = if m.has_image() {
|
||||||
let parts: Vec<serde_json::Value> = m
|
let parts: Vec<serde_json::Value> = m
|
||||||
.flattened_parts()
|
.flattened_parts()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
.map(crate::image_ref::inline_local_image_part)
|
||||||
.map(|p| match p {
|
.map(|p| match p {
|
||||||
crate::provider::ContentPart::Text { text } => serde_json::json!({
|
crate::provider::ContentPart::Text { text } => serde_json::json!({
|
||||||
"type": "text",
|
"type": "text",
|
||||||
@@ -211,6 +213,10 @@ impl OpenAICompatProvider {
|
|||||||
|
|
||||||
// 模型不支持 temperature(如商汤 sensenova 仅接受 1.0)→ 省略该字段,避免上游 HTTP 400。
|
// 模型不支持 temperature(如商汤 sensenova 仅接受 1.0)→ 省略该字段,避免上游 HTTP 400。
|
||||||
// 判定走 model_probe(命中 presets 表;未命中默认支持,保持原样透传零回归)。
|
// 判定走 model_probe(命中 presets 表;未命中默认支持,保持原样透传零回归)。
|
||||||
|
// 商汤网关(token.sensenova.cn)下温度支持**按模型各异**:sensenova-6.x 与
|
||||||
|
// sn2-kimi 实测仅接受 1(传其它值 400),部分新模型支持常规温度。
|
||||||
|
// 故不做 base_url 一刀切,改由 presets 逐模型精确维护 temperature_supported:
|
||||||
|
// 受限模型(含实测 sn2-kimi)标 false 省略;支持温度变化的模型标 true 正常透传。
|
||||||
let temperature = if req.temperature.is_some()
|
let temperature = if req.temperature.is_some()
|
||||||
&& !crate::model_probe::probe(&model).temperature_supported
|
&& !crate::model_probe::probe(&model).temperature_supported
|
||||||
{
|
{
|
||||||
@@ -368,18 +374,34 @@ fn extract_tool_call_ids(msg: &OpenAiMessage) -> Vec<String> {
|
|||||||
fn sanitize_openai_triplets(messages: &mut Vec<OpenAiMessage>) {
|
fn sanitize_openai_triplets(messages: &mut Vec<OpenAiMessage>) {
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
// step 1:resolved_ids = 所有 tool 消息提供的 tool_call_id(有 result 响应的 id)。
|
// step 1:相邻性判定的依据 = 每个 assistant 头「紧后 tool 连续段」覆盖的 id 集合。
|
||||||
let resolved_ids: HashSet<String> = messages
|
// 根因(2026-10-09):原实现按「id 全局存在」判闭合(位置无关),而 OpenAI 协议要求
|
||||||
.iter()
|
// tool 响应必须**紧随**其 assistant(tool_calls)。后台压缩摘要在 head 已 push、tool
|
||||||
.filter(|m| m.role == "tool")
|
// 结果未 push 的窗口内可楔入 system(context_lifecycle),被隔开的响应仍被判"已闭合"
|
||||||
.filter_map(|m| m.tool_call_id.clone())
|
// → 畸形序列放行 → 上游 400「insufficient tool messages」。故判定口径由「id 全局存在」
|
||||||
.collect();
|
// 收紧为「紧后连续段覆盖」:未覆盖的 call 视同未闭合剥除,其游离响应随后由 step3 丢弃。
|
||||||
|
let mut adjacency: std::collections::HashMap<usize, HashSet<String>> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
|
for i in 0..messages.len() {
|
||||||
|
if messages[i].role != "assistant" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut seg: HashSet<String> = HashSet::new();
|
||||||
|
let mut j = i + 1;
|
||||||
|
while j < messages.len() && messages[j].role == "tool" {
|
||||||
|
if let Some(id) = messages[j].tool_call_id.as_deref() {
|
||||||
|
seg.insert(id.to_string());
|
||||||
|
}
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
adjacency.insert(i, seg);
|
||||||
|
}
|
||||||
|
|
||||||
let mut stripped_heads = 0u32;
|
let mut stripped_heads = 0u32;
|
||||||
let mut total_stripped = 0u32;
|
let mut total_stripped = 0u32;
|
||||||
|
|
||||||
// step 2:assistant 剥未闭合 tool_call(无对应 tool result 响应)。
|
// step 2:assistant 剥未闭合 tool_call(其响应不在紧后连续段内)。
|
||||||
for m in messages.iter_mut() {
|
for (i, m) in messages.iter_mut().enumerate() {
|
||||||
if m.role != "assistant" {
|
if m.role != "assistant" {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -389,12 +411,13 @@ fn sanitize_openai_triplets(messages: &mut Vec<OpenAiMessage>) {
|
|||||||
if calls.is_empty() {
|
if calls.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
let seg = adjacency.get(&i);
|
||||||
let kept: Vec<serde_json::Value> = calls
|
let kept: Vec<serde_json::Value> = calls
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|tc| {
|
.filter(|tc| {
|
||||||
tc.get("id")
|
tc.get("id")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.is_some_and(|id| resolved_ids.contains(id))
|
.is_some_and(|id| seg.is_some_and(|s| s.contains(id)))
|
||||||
})
|
})
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect();
|
.collect();
|
||||||
@@ -593,37 +616,83 @@ impl LlmProvider for OpenAICompatProvider {
|
|||||||
|
|
||||||
// send 阶段需 timeout 防 hang(同 Anthropic 路径)。
|
// send 阶段需 timeout 防 hang(同 Anthropic 路径)。
|
||||||
// 不能用 reqwest .timeout()(会砍流式 body),改用 tokio::time::timeout 包裹 send。
|
// 不能用 reqwest .timeout()(会砍流式 body),改用 tokio::time::timeout 包裹 send。
|
||||||
let send_future = self
|
// send 阶段弱网退避: connect 类失败重试零副作用; 网络感知分速(net_probe 基准面):
|
||||||
.client
|
// 本机断网长等待按自适应间隔探测恢复, 单点故障指数短退避 1s→8s, 10 分钟窗口耗尽才放弃。
|
||||||
.post(self.chat_url())
|
// 与 anthropic_compat 同构。
|
||||||
.header("Authorization", format!("Bearer {}", self.api_key))
|
let resp = {
|
||||||
.header("Content-Type", "application/json")
|
let start = std::time::Instant::now();
|
||||||
.json(&openai_req)
|
let mut offline_logged = false;
|
||||||
.send();
|
let mut offline_polls = 0usize;
|
||||||
let resp = match tokio::time::timeout(Duration::from_secs(60), send_future).await {
|
let mut attempt = 0usize;
|
||||||
Ok(Ok(r)) => r,
|
loop {
|
||||||
Ok(Err(e)) => {
|
attempt += 1;
|
||||||
// 谓词结构化分类:Display 串不含 source 关键词("error sending request for url"),
|
let send_future = self
|
||||||
// 纯串分类必 unknown → Fatal 零重试(陈旧连接首包 RST 就此裸奔)。类词置串首,
|
.client
|
||||||
// source 链兜底真实传输词(reset/broken pipe 等),下游按类词走可重试。
|
.post(self.chat_url())
|
||||||
let class = crate::send_error_class(crate::SendErrPreds::of(&e));
|
.header("Authorization", format!("Bearer {}", self.api_key))
|
||||||
tracing::error!(
|
.header("Content-Type", "application/json")
|
||||||
error = %e,
|
.json(&openai_req)
|
||||||
class = class,
|
.send();
|
||||||
url = %self.chat_url(),
|
let outcome = tokio::time::timeout(Duration::from_secs(60), send_future).await;
|
||||||
source = ?std::error::Error::source(&e),
|
match outcome {
|
||||||
"OpenAI 流式 send 失败"
|
Ok(Ok(r)) => break r,
|
||||||
);
|
Ok(Err(e)) => {
|
||||||
anyhow::bail!(
|
// 谓词结构化分类:Display 串不含 source 关键词("error sending request for url"),
|
||||||
"流式 send 失败({}): {} [src={:?}]",
|
// 纯串分类必 unknown → Fatal 零重试(陈旧连接首包 RST 就此裸奔)。类词置串首,
|
||||||
class,
|
// source 链兜底真实传输词(reset/broken pipe 等),下游按类词走可重试。
|
||||||
e,
|
let preds = crate::SendErrPreds::of(&e);
|
||||||
std::error::Error::source(&e)
|
let retryable = preds.is_connect || (preds.is_request && !preds.is_builder);
|
||||||
);
|
if retryable && start.elapsed() < crate::net_probe::SEND_RETRY_WINDOW {
|
||||||
}
|
// 本机断网: 等基准面恢复, 自适应间隔探测(首段 5s 快探, 确认脱网后 30s 封顶省电)
|
||||||
Err(_elapsed) => {
|
if !crate::net_probe::baseline_alive().await {
|
||||||
tracing::error!(url = %self.chat_url(), "OpenAI 流式 send 超时(60s 未返回响应头)");
|
offline_polls += 1;
|
||||||
anyhow::bail!("流式请求超时(60秒未收到 HTTP 响应,可能服务不可达或被防火墙拦截)");
|
let wait = crate::net_probe::offline_probe_wait(offline_polls);
|
||||||
|
if !offline_logged {
|
||||||
|
warn!(
|
||||||
|
url = %self.chat_url(),
|
||||||
|
window_secs = crate::net_probe::SEND_RETRY_WINDOW.as_secs(),
|
||||||
|
"本机网络不可达(疑似隧道/电梯弱网), 按自适应间隔探测恢复, 恢复后立即重试"
|
||||||
|
);
|
||||||
|
offline_logged = true;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(wait).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
offline_polls = 0;
|
||||||
|
offline_logged = false;
|
||||||
|
let delay = Duration::from_millis(
|
||||||
|
1000u64.saturating_mul(1 << (attempt - 1).min(3)).min(8000),
|
||||||
|
);
|
||||||
|
warn!(
|
||||||
|
url = %self.chat_url(),
|
||||||
|
attempt,
|
||||||
|
delay_ms = delay.as_millis() as u64,
|
||||||
|
error = %e,
|
||||||
|
"流式 send 连接失败, 退避重试"
|
||||||
|
);
|
||||||
|
tokio::time::sleep(delay).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let class = crate::send_error_class(preds);
|
||||||
|
tracing::error!(
|
||||||
|
error = %e,
|
||||||
|
class = class,
|
||||||
|
url = %self.chat_url(),
|
||||||
|
source = ?std::error::Error::source(&e),
|
||||||
|
"OpenAI 流式 send 失败"
|
||||||
|
);
|
||||||
|
anyhow::bail!(
|
||||||
|
"流式 send 失败({}): {} [src={:?}]",
|
||||||
|
class,
|
||||||
|
e,
|
||||||
|
std::error::Error::source(&e)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(_elapsed) => {
|
||||||
|
tracing::error!(url = %self.chat_url(), "OpenAI 流式 send 超时(60s 未返回响应头)");
|
||||||
|
anyhow::bail!("流式请求超时(60秒未收到 HTTP 响应,可能服务不可达或被防火墙拦截)");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -953,6 +1022,57 @@ mod tests {
|
|||||||
assert_eq!(arr[2]["image_url"]["url"], "https://x/a.png");
|
assert_eq!(arr[2]["image_url"]["url"], "https://x/a.png");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 本地 file:// 引用(#958)在转换层展开为 data URI;引用缺失降级 Text 片不炸请求
|
||||||
|
#[test]
|
||||||
|
fn openai_convert_expands_local_file_ref() {
|
||||||
|
use base64::Engine as _;
|
||||||
|
let dir = std::env::temp_dir().join("df-ai-openai-ref-test");
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let path = dir.join("ref.png");
|
||||||
|
std::fs::write(&path, b"\x89PNG ok").unwrap();
|
||||||
|
|
||||||
|
let provider = OpenAICompatProvider::new("https://api.openai.com", "k", "gpt-4o");
|
||||||
|
let req = CompletionRequest {
|
||||||
|
model: "gpt-4o".into(),
|
||||||
|
messages: vec![
|
||||||
|
ChatMessage::user_parts(
|
||||||
|
"看图",
|
||||||
|
vec![crate::provider::ContentPart::Image {
|
||||||
|
url: Some(format!("file://{}", path.display())),
|
||||||
|
base64: None,
|
||||||
|
media_type: Some("image/png".into()),
|
||||||
|
alt: None,
|
||||||
|
}],
|
||||||
|
),
|
||||||
|
ChatMessage::user_parts(
|
||||||
|
"坏引用",
|
||||||
|
vec![crate::provider::ContentPart::Image {
|
||||||
|
url: Some("file:///nonexistent/ref-missing.png".into()),
|
||||||
|
base64: None,
|
||||||
|
media_type: Some("image/png".into()),
|
||||||
|
alt: None,
|
||||||
|
}],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
temperature: None,
|
||||||
|
max_tokens: None,
|
||||||
|
stream: false,
|
||||||
|
tools: None,
|
||||||
|
tool_choice: None,
|
||||||
|
reasoning_content: None,
|
||||||
|
};
|
||||||
|
let out = provider.convert_request(req);
|
||||||
|
let good = &out.messages[0];
|
||||||
|
let arr = good.content.as_array().expect("含图 → 数组");
|
||||||
|
assert_eq!(arr[1]["image_url"]["url"], format!("data:image/png;base64,{}", base64::engine::general_purpose::STANDARD.encode(b"\x89PNG ok")));
|
||||||
|
|
||||||
|
let bad = &out.messages[1];
|
||||||
|
let arr = bad.content.as_array().expect("降级片仍在数组");
|
||||||
|
assert_eq!(arr[1]["type"], "text");
|
||||||
|
assert!(arr[1]["text"].as_str().unwrap().contains("[图片文件缺失"));
|
||||||
|
std::fs::remove_file(&path).ok();
|
||||||
|
}
|
||||||
|
|
||||||
/// 纯文本消息 → content 仍是字符串简写(无图不数组化,对齐纯文本端点兼容)
|
/// 纯文本消息 → content 仍是字符串简写(无图不数组化,对齐纯文本端点兼容)
|
||||||
#[test]
|
#[test]
|
||||||
fn openai_convert_text_only_remains_string() {
|
fn openai_convert_text_only_remains_string() {
|
||||||
@@ -972,6 +1092,53 @@ mod tests {
|
|||||||
assert_eq!(msg.content, serde_json::Value::String("hello".into()));
|
assert_eq!(msg.content, serde_json::Value::String("hello".into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 温度支持按模型精确判定(presets 表,probe 命中),不按 base_url 一刀切——
|
||||||
|
/// 商汤网关下温度支持各异:sn2-kimi 实测仅接受 temperature=1(传 0.7 → 上游 400),
|
||||||
|
/// presets 已标 temperature_supported=false,任何 provider 下都省略。
|
||||||
|
/// 回归 B5 挂机 sn2-kimi 传 0.7 → 400。省略交上游默认,宁默认不 400。
|
||||||
|
#[test]
|
||||||
|
fn openai_temperature_omitted_for_sn2_kimi_via_presets() {
|
||||||
|
let provider =
|
||||||
|
OpenAICompatProvider::new("https://token.sensenova.cn", "k", "sensenova-6.8-flash-lite");
|
||||||
|
let req = CompletionRequest {
|
||||||
|
model: "sn2-kimi".into(), // presets 精确标记受限,probe 命中 false
|
||||||
|
messages: vec![ChatMessage::user("hi")],
|
||||||
|
temperature: Some(0.7),
|
||||||
|
max_tokens: None,
|
||||||
|
stream: true,
|
||||||
|
tools: None,
|
||||||
|
tool_choice: None,
|
||||||
|
reasoning_content: None,
|
||||||
|
};
|
||||||
|
let out = provider.convert_request(req);
|
||||||
|
assert_eq!(out.temperature, None, "sn2-kimi presets 标 false,应省略 temperature");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 反向保证:支持常规温度的模型(presets 未标 false,如 deepseek-v4-flash)
|
||||||
|
/// 即使挂在商汤 provider 池也按模型判定透传,不因 base_url 误杀——商汤新模型
|
||||||
|
/// 支持温度变化,一刀切会错误降级。
|
||||||
|
#[test]
|
||||||
|
fn openai_temperature_passed_for_supported_model_on_sensenova_base() {
|
||||||
|
let provider =
|
||||||
|
OpenAICompatProvider::new("https://token.sensenova.cn", "k", "sensenova-6.8-flash-lite");
|
||||||
|
let req = CompletionRequest {
|
||||||
|
model: "deepseek-v4-flash".into(), // presets 未标受限 → 默认支持温度
|
||||||
|
messages: vec![ChatMessage::user("hi")],
|
||||||
|
temperature: Some(0.7),
|
||||||
|
max_tokens: None,
|
||||||
|
stream: true,
|
||||||
|
tools: None,
|
||||||
|
tool_choice: None,
|
||||||
|
reasoning_content: None,
|
||||||
|
};
|
||||||
|
let out = provider.convert_request(req);
|
||||||
|
assert_eq!(
|
||||||
|
out.temperature,
|
||||||
|
Some(0.7),
|
||||||
|
"支持温度的模型应透传,不因 base_url 是商汤而省略"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- ensure_leading_user(首条非 user/system → 补 user 占位,OpenAI 对称 Anthropic)----------
|
// ---------- ensure_leading_user(首条非 user/system → 补 user 占位,OpenAI 对称 Anthropic)----------
|
||||||
|
|
||||||
/// 首条 assistant → 补 user 占位(对齐 Anthropic)。上游绕过 sanitize 的
|
/// 首条 assistant → 补 user 占位(对齐 Anthropic)。上游绕过 sanitize 的
|
||||||
|
|||||||
@@ -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,85 @@
|
|||||||
|
//! 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());
|
||||||
|
|
||||||
|
// 中性行夹具:不得含 error/panic/.rs: 等错误行信号——错误行保留策略会全量保留
|
||||||
|
// 命中行,用信号行做夹具会让「应被压缩」断言先天不成立(测的是普通大结果外置)
|
||||||
|
let body: String = (1..=1200).map(|i| format!("match {i}: src/foo.txt:{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(), "复位后恢复放行");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -102,7 +102,9 @@ impl EnvSnapshot {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 兜底:spawn_blocking panic/join 失败时返回最小可用快照(全 None,字段不空)。
|
/// 兜底:spawn_blocking panic/join 失败或外层 5s 超时时返回最小可用快照(全 None,字段不空)。
|
||||||
|
/// 注:异常/超时均**冻结**为 fallback 写入 OnceLock(EX-3)——本进程后续 detect() 不再重探,
|
||||||
|
/// 瞬时挂起(如 Store Alias)会被固化为整进程降级快照;解冻需重启进程。
|
||||||
fn fallback() -> EnvSnapshot {
|
fn fallback() -> EnvSnapshot {
|
||||||
EnvSnapshot {
|
EnvSnapshot {
|
||||||
os: std::env::consts::OS.to_string(),
|
os: std::env::consts::OS.to_string(),
|
||||||
@@ -310,7 +312,11 @@ fn extract_codepage(text: &str) -> Option<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 执行 `tool --version`,解析首行返回版本串。失败/超时返回 None,不阻塞调用方。
|
/// 执行 `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 输出
|
/// 例:python --version 输出 "Python 3.11.5" → 返回 "3.11.5";git --version 输出
|
||||||
/// "git version 2.41.0" → 返回 "2.41.0"。统一抽掉工具名前缀,只保留版本号本身。
|
/// "git version 2.41.0" → 返回 "2.41.0"。统一抽掉工具名前缀,只保留版本号本身。
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
//! df-execute: 执行运行时 — Shell + 环境感知
|
//! df-execute: 执行运行时 — Shell + 环境感知 + 提交冻结闸
|
||||||
|
|
||||||
|
pub mod commit_freeze;
|
||||||
pub mod env_snapshot;
|
pub mod env_snapshot;
|
||||||
pub mod shell;
|
pub mod shell;
|
||||||
|
|
||||||
|
pub use commit_freeze::is_commit_command;
|
||||||
pub use env_snapshot::EnvSnapshot;
|
pub use env_snapshot::EnvSnapshot;
|
||||||
|
|||||||
+115
-11
@@ -169,10 +169,95 @@ pub fn current_shell() -> ShellType {
|
|||||||
ShellType::default()
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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 同步路径必须读到同一缓存值。
|
/// 死缓存回归测试:probe_pwsh 写入后,Default 同步路径必须读到同一缓存值。
|
||||||
///
|
///
|
||||||
/// 历史 bug:probe_pwsh_cached() 与 probe_pwsh() 各持独立 OnceLock,Default 读的永空。
|
/// 历史 bug:probe_pwsh_cached() 与 probe_pwsh() 各持独立 OnceLock,Default 读的永空。
|
||||||
@@ -235,7 +320,7 @@ pub struct ShellRequest {
|
|||||||
pub env: std::collections::HashMap<String, String>,
|
pub env: std::collections::HashMap<String, String>,
|
||||||
/// 超时时间(秒),None 表示不超时
|
/// 超时时间(秒),None 表示不超时
|
||||||
pub timeout_secs: Option<u64>,
|
pub timeout_secs: Option<u64>,
|
||||||
/// Shell 类型(默认:Windows→Cmd, 非Windows→Sh)
|
/// Shell 类型(None 时取 [`ShellType::default`]:Windows→pwsh 可用则 pwsh 否则 PowerShell,非 Windows→Sh)
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub shell_type: Option<ShellType>,
|
pub shell_type: Option<ShellType>,
|
||||||
}
|
}
|
||||||
@@ -312,6 +397,13 @@ fn build_command(request: ShellRequest) -> tokio::process::Command {
|
|||||||
for (key, value) in &request.env {
|
for (key, value) in &request.env {
|
||||||
cmd.env(key, value);
|
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
|
cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,7 +416,10 @@ fn build_command(request: ShellRequest) -> tokio::process::Command {
|
|||||||
pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> {
|
pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> {
|
||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
// 探测 pwsh(惰性 + OnceLock 全局缓存,只探一次),使后续 ShellType::default() 可读取缓存
|
// 提交冻结机械闸(39 治理):冻结在场且命令为 git commit → spawn 前拒绝,改动留工作区
|
||||||
|
crate::commit_freeze::check(&request).map_err(anyhow::Error::msg)?;
|
||||||
|
|
||||||
|
// 探测 pwsh(惰性 + 模块级 PWSH_CACHE,成功才写缓存),使后续 ShellType::default() 可读取缓存
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
let _ = probe_pwsh().await;
|
let _ = probe_pwsh().await;
|
||||||
|
|
||||||
@@ -346,8 +441,8 @@ pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> {
|
|||||||
let duration = start.elapsed().as_millis() as u64;
|
let duration = start.elapsed().as_millis() as u64;
|
||||||
|
|
||||||
Ok(ShellResult {
|
Ok(ShellResult {
|
||||||
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
|
stdout: strip_ansi(&String::from_utf8_lossy(&output.stdout)),
|
||||||
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
|
stderr: strip_ansi(&String::from_utf8_lossy(&output.stderr)),
|
||||||
exit_code: output.status.code(),
|
exit_code: output.status.code(),
|
||||||
duration_ms: duration,
|
duration_ms: duration,
|
||||||
})
|
})
|
||||||
@@ -358,7 +453,11 @@ pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> {
|
|||||||
/// 治 run_command 执行中黑盒:execute() 等 exit 才返回整块 stdout/stderr,长命令(cargo/npm 构建)
|
/// 治 run_command 执行中黑盒:execute() 等 exit 才返回整块 stdout/stderr,长命令(cargo/npm 构建)
|
||||||
/// 期间前端只看 Started→Completed,中间进度不可见。本函数 spawn 子进程后并发逐行读两条流,
|
/// 期间前端只看 Started→Completed,中间进度不可见。本函数 spawn 子进程后并发逐行读两条流,
|
||||||
/// 每读到一行回调 `on_output(kind, line)`(调用方可 emit 事件给前端实时展示),仍等进程 exit
|
/// 每读到一行回调 `on_output(kind, line)`(调用方可 emit 事件给前端实时展示),仍等进程 exit
|
||||||
/// 后返回完整 ShellResult(与 execute() 同形,调用方无需感知差异)。
|
/// 后返回完整 ShellResult。
|
||||||
|
///
|
||||||
|
/// 与 [`execute`] 的输出差异(EX-2,调用方须知):本函数 stdout/stderr 按行重组——经
|
||||||
|
/// `BufReader::lines()` 逐行拼接,行尾统一为 `\n`(剥 \r\n)且补末尾换行;execute 则保留原始字节
|
||||||
|
/// (lossy 解码,含 \r\n,无补换行)。二者 ShellResult 字段同形,仅换行形态不同;需字节级一致时用 execute。
|
||||||
///
|
///
|
||||||
/// 4性:
|
/// 4性:
|
||||||
/// - 合理机制:spawn + BufReader::lines() 逐行,不丢未换行结尾的末段(read_to_end 兜底)
|
/// - 合理机制:spawn + BufReader::lines() 逐行,不丢未换行结尾的末段(read_to_end 兜底)
|
||||||
@@ -372,6 +471,9 @@ where
|
|||||||
{
|
{
|
||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
|
// 提交冻结机械闸(39 治理):同 execute,流式路径同样 spawn 前拦截
|
||||||
|
crate::commit_freeze::check(&request).map_err(anyhow::Error::msg)?;
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
let _ = probe_pwsh().await;
|
let _ = probe_pwsh().await;
|
||||||
|
|
||||||
@@ -430,21 +532,23 @@ where
|
|||||||
let status = status?;
|
let status = status?;
|
||||||
// drain 剩余行(读 task 在管道 EOF 后 send 完最后批次即退出,rx 返 None 闭合)
|
// drain 剩余行(读 task 在管道 EOF 后 send 完最后批次即退出,rx 返 None 闭合)
|
||||||
while let Some((kind, line)) = rx.recv().await {
|
while let Some((kind, line)) = rx.recv().await {
|
||||||
|
let clean = strip_ansi(&line);
|
||||||
match kind {
|
match kind {
|
||||||
StreamKind::Stdout => { stdout_buf.push_str(&line); stdout_buf.push('\n'); }
|
StreamKind::Stdout => { stdout_buf.push_str(&clean); stdout_buf.push('\n'); }
|
||||||
StreamKind::Stderr => { stderr_buf.push_str(&line); stderr_buf.push('\n'); }
|
StreamKind::Stderr => { stderr_buf.push_str(&clean); stderr_buf.push('\n'); }
|
||||||
}
|
}
|
||||||
on_output(kind, &line);
|
on_output(kind, &clean);
|
||||||
}
|
}
|
||||||
break status;
|
break status;
|
||||||
}
|
}
|
||||||
// 收到一行 → 累积 + 回调
|
// 收到一行 → 累积 + 回调
|
||||||
Some((kind, line)) = rx.recv() => {
|
Some((kind, line)) = rx.recv() => {
|
||||||
|
let clean = strip_ansi(&line);
|
||||||
match kind {
|
match kind {
|
||||||
StreamKind::Stdout => { stdout_buf.push_str(&line); stdout_buf.push('\n'); }
|
StreamKind::Stdout => { stdout_buf.push_str(&clean); stdout_buf.push('\n'); }
|
||||||
StreamKind::Stderr => { stderr_buf.push_str(&line); stderr_buf.push('\n'); }
|
StreamKind::Stderr => { stderr_buf.push_str(&clean); stderr_buf.push('\n'); }
|
||||||
}
|
}
|
||||||
on_output(kind, &line);
|
on_output(kind, &clean);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -125,8 +125,6 @@ impl ScoringEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 统计 text 中命中任一关键词的数量(小写匹配,兼顾中英文)
|
|
||||||
/// 局限:纯子串匹配,不识别"不复用""无用户增长"等否定前缀,接 LLM 后由语义层修正
|
|
||||||
/// 单字否定前缀:关键词紧邻这些字时该命中不计(「不复用」不计入「复用」正向命中)。
|
/// 单字否定前缀:关键词紧邻这些字时该命中不计(「不复用」不计入「复用」正向命中)。
|
||||||
///
|
///
|
||||||
/// 注意「别」单字歧义大(别具/别的/特别 均非否定),故不进单字清单——
|
/// 注意「别」单字歧义大(别具/别的/特别 均非否定),故不进单字清单——
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ async-trait = { workspace = true }
|
|||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
df-tunnel = { path = "../df-tunnel" }
|
df-tunnel = { path = "../df-tunnel" }
|
||||||
|
|
||||||
# WS(与 df-tunnel 同款本地版本声明,workspace 无这几项)
|
# 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"] }
|
tokio-tungstenite = { version = "0.23", features = ["native-tls"] }
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -56,22 +56,39 @@ pub trait PlatformAdapter: Send + Sync {
|
|||||||
async fn reset_stream(&self, _chat_id: &str) {}
|
async fn reset_stream(&self, _chat_id: &str) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Web Chat 适配器(网关直接托管浏览器 WS,无三方依赖)
|
/// Web Chat 适配器(P1:axum 同端口托管静态页 + WS,浏览器直连)
|
||||||
|
///
|
||||||
|
/// - `GET /` → 内嵌单文件聊天页(include_str!,零运行时文件依赖)
|
||||||
|
/// - `GET /ws` → WS 升级,协议见 handle_web_ws(与 P0 纯 WS 版完全一致)
|
||||||
pub mod web {
|
pub mod web {
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
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 futures_util::{SinkExt, StreamExt};
|
||||||
use tokio::sync::{mpsc, Mutex};
|
use tokio::sync::{mpsc, Mutex};
|
||||||
use tokio_tungstenite::tungstenite::Message;
|
|
||||||
|
|
||||||
use super::{InboundEvent, OutboundAction, PlatformAdapter};
|
use super::{InboundEvent, OutboundAction, PlatformAdapter};
|
||||||
|
|
||||||
/// 浏览器连接注册表(chat_id → ws 发送通道)
|
/// 内嵌聊天页(单文件,无外部依赖,构建期打进二进制)
|
||||||
type WsRegistry = Arc<Mutex<HashMap<String, mpsc::Sender<Message>>>>;
|
const INDEX_HTML: &str = include_str!("../web/index.html");
|
||||||
|
|
||||||
/// Web 适配器:浏览器 WS 直连网关 0.0.0.0:9191(accept 协议见 handle_web_ws)
|
/// 浏览器连接注册表(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)]
|
#[derive(Clone)]
|
||||||
pub struct WebAdapter {
|
pub struct WebAdapter {
|
||||||
registry: WsRegistry,
|
registry: WsRegistry,
|
||||||
@@ -96,18 +113,15 @@ pub mod web {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn run(&self, events: mpsc::Sender<InboundEvent>) -> anyhow::Result<()> {
|
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?;
|
let listener = tokio::net::TcpListener::bind("0.0.0.0:9191").await?;
|
||||||
tracing::info!("[web] WS 服务监听 0.0.0.0:9191");
|
tracing::info!("[web] HTTP+WS 服务监听 http://0.0.0.0:9191 (页面 / , WS /ws)");
|
||||||
loop {
|
axum::serve(listener, app).await?;
|
||||||
let (stream, _peer) = listener.accept().await?;
|
Ok(())
|
||||||
let registry = self.registry.clone();
|
|
||||||
let events = events.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
if let Err(e) = handle_web_ws(stream, registry, events).await {
|
|
||||||
tracing::warn!("[web] 浏览器连接异常退出: {e}");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn dispatch(&self, action: OutboundAction) -> anyhow::Result<()> {
|
async fn dispatch(&self, action: OutboundAction) -> anyhow::Result<()> {
|
||||||
@@ -140,12 +154,21 @@ pub mod web {
|
|||||||
async fn send_ws(&self, chat_id: &str, text: &str) -> anyhow::Result<()> {
|
async fn send_ws(&self, chat_id: &str, text: &str) -> anyhow::Result<()> {
|
||||||
let registry = self.registry.lock().await;
|
let registry = self.registry.lock().await;
|
||||||
if let Some(tx) = registry.get(chat_id) {
|
if let Some(tx) = registry.get(chat_id) {
|
||||||
tx.send(Message::Text(text.to_string())).await?;
|
tx.send(WsMessage::Text(text.to_string())).await?;
|
||||||
}
|
}
|
||||||
Ok(())
|
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 会话。
|
/// 单个浏览器 WS 会话。
|
||||||
///
|
///
|
||||||
/// 协议(浏览器 → 网关):
|
/// 协议(浏览器 → 网关):
|
||||||
@@ -156,19 +179,23 @@ pub mod web {
|
|||||||
/// 审批: {"type":"approval","approval_id":"...","approved":true|false}
|
/// 审批: {"type":"approval","approval_id":"...","approved":true|false}
|
||||||
/// ```
|
/// ```
|
||||||
/// 网关 → 浏览器:stream_delta / stream_done / approval / notice(见 dispatch)。
|
/// 网关 → 浏览器:stream_delta / stream_done / approval / notice(见 dispatch)。
|
||||||
async fn handle_web_ws(
|
async fn handle_web_ws(socket: WebSocket, state: WsState) -> anyhow::Result<()> {
|
||||||
stream: tokio::net::TcpStream,
|
let (mut sink, mut upstream) = socket.split();
|
||||||
registry: WsRegistry,
|
|
||||||
events: mpsc::Sender<InboundEvent>,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
let ws = tokio_tungstenite::accept_async(stream).await?;
|
|
||||||
let (mut sink, mut upstream) = ws.split();
|
|
||||||
|
|
||||||
// 首帧 hello 绑定 chat_id
|
// 首帧 hello 绑定 chat_id
|
||||||
let first = upstream.next().await.ok_or_else(|| anyhow::anyhow!("浏览器连接即断"))??;
|
let first = match upstream.next().await {
|
||||||
|
Some(Ok(m)) => m,
|
||||||
|
_ => {
|
||||||
|
tracing::warn!("[web] 浏览器连接即断(无首帧)");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
let hello: serde_json::Value = match &first {
|
let hello: serde_json::Value = match &first {
|
||||||
Message::Text(t) => serde_json::from_str(t)?,
|
WsMessage::Text(t) => serde_json::from_str(t).unwrap_or_default(),
|
||||||
_ => anyhow::bail!("首帧非文本"),
|
_ => {
|
||||||
|
tracing::warn!("[web] 首帧非文本,拒绝绑定");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let chat_id = hello
|
let chat_id = hello
|
||||||
.get("chat_id")
|
.get("chat_id")
|
||||||
@@ -178,8 +205,8 @@ pub mod web {
|
|||||||
let user_id = hello.get("user_id").and_then(|v| v.as_str()).unwrap_or("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
|
// 注册发送通道:出站帧经 ws_tx → forward 任务写 socket
|
||||||
let (ws_tx, mut ws_rx) = mpsc::channel::<Message>(64);
|
let (ws_tx, mut ws_rx) = mpsc::channel::<WsMessage>(64);
|
||||||
registry.lock().await.insert(chat_id.clone(), ws_tx);
|
state.registry.lock().await.insert(chat_id.clone(), ws_tx);
|
||||||
|
|
||||||
tracing::info!("[web] 浏览器接入 chat_id={chat_id} user={user_id}");
|
tracing::info!("[web] 浏览器接入 chat_id={chat_id} user={user_id}");
|
||||||
|
|
||||||
@@ -193,13 +220,16 @@ pub mod web {
|
|||||||
|
|
||||||
let result: anyhow::Result<()> = async {
|
let result: anyhow::Result<()> = async {
|
||||||
while let Some(msg) = upstream.next().await {
|
while let Some(msg) = upstream.next().await {
|
||||||
match msg? {
|
let msg = msg?;
|
||||||
Message::Text(t) => {
|
match msg {
|
||||||
|
WsMessage::Text(t) => {
|
||||||
let v: serde_json::Value = serde_json::from_str(&t).unwrap_or_default();
|
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("") {
|
match v.get("type").and_then(|x| x.as_str()).unwrap_or("") {
|
||||||
"message" => {
|
"message" => {
|
||||||
let text = v.get("text").and_then(|x| x.as_str()).unwrap_or("").to_string();
|
let text =
|
||||||
events
|
v.get("text").and_then(|x| x.as_str()).unwrap_or("").to_string();
|
||||||
|
state
|
||||||
|
.events
|
||||||
.send(InboundEvent::Message {
|
.send(InboundEvent::Message {
|
||||||
platform: "web".into(),
|
platform: "web".into(),
|
||||||
chat_id: chat_id.clone(),
|
chat_id: chat_id.clone(),
|
||||||
@@ -210,7 +240,8 @@ pub mod web {
|
|||||||
.map_err(|_| anyhow::anyhow!("核心入站通道关闭"))?;
|
.map_err(|_| anyhow::anyhow!("核心入站通道关闭"))?;
|
||||||
}
|
}
|
||||||
"new_session" => {
|
"new_session" => {
|
||||||
events
|
state
|
||||||
|
.events
|
||||||
.send(InboundEvent::NewSession {
|
.send(InboundEvent::NewSession {
|
||||||
platform: "web".into(),
|
platform: "web".into(),
|
||||||
chat_id: chat_id.clone(),
|
chat_id: chat_id.clone(),
|
||||||
@@ -221,8 +252,10 @@ pub mod web {
|
|||||||
"approval" => {
|
"approval" => {
|
||||||
let approval_id =
|
let approval_id =
|
||||||
v.get("approval_id").and_then(|x| x.as_str()).unwrap_or("").to_string();
|
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);
|
let approved =
|
||||||
events
|
v.get("approved").and_then(|x| x.as_bool()).unwrap_or(false);
|
||||||
|
state
|
||||||
|
.events
|
||||||
.send(InboundEvent::Approval {
|
.send(InboundEvent::Approval {
|
||||||
platform: "web".into(),
|
platform: "web".into(),
|
||||||
chat_id: chat_id.clone(),
|
chat_id: chat_id.clone(),
|
||||||
@@ -235,7 +268,7 @@ pub mod web {
|
|||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Message::Close(_) => break,
|
WsMessage::Close(_) => break,
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -243,7 +276,7 @@ pub mod web {
|
|||||||
}
|
}
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
registry.lock().await.remove(&chat_id);
|
state.registry.lock().await.remove(&chat_id);
|
||||||
forward.abort();
|
forward.abort();
|
||||||
tracing::info!("[web] 浏览器断开 chat_id={chat_id}");
|
tracing::info!("[web] 浏览器断开 chat_id={chat_id}");
|
||||||
result
|
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 即通过
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -85,6 +85,7 @@ async fn connect_once(
|
|||||||
kind: HelloKind::Commander,
|
kind: HelloKind::Commander,
|
||||||
channel_id: channel_id.to_string(),
|
channel_id: channel_id.to_string(),
|
||||||
token: token.to_string(),
|
token: token.to_string(),
|
||||||
|
proto: 1,
|
||||||
};
|
};
|
||||||
sink.send(Message::Text(serde_json::to_string(&hello)?)).await?;
|
sink.send(Message::Text(serde_json::to_string(&hello)?)).await?;
|
||||||
|
|
||||||
|
|||||||
@@ -28,14 +28,20 @@ use crate::config::FeishuConfig;
|
|||||||
/// 飞书适配器:channel 归一化消息 + 卡片审批,单飞书应用单实例
|
/// 飞书适配器:channel 归一化消息 + 卡片审批,单飞书应用单实例
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct FeishuAdapter {
|
pub struct FeishuAdapter {
|
||||||
client: LarkClient,
|
/// 常驻 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 覆写)
|
/// 流式会话注册表:chat_id → 已发出的流式消息 message_id(用于 edit_text 覆写)
|
||||||
streams: Arc<tokio::sync::Mutex<HashMap<String, String>>>,
|
streams: Arc<tokio::sync::Mutex<HashMap<String, String>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FeishuAdapter {
|
impl FeishuAdapter {
|
||||||
pub fn new(cfg: &FeishuConfig) -> anyhow::Result<Self> {
|
pub fn new(cfg: &FeishuConfig) -> anyhow::Result<Self> {
|
||||||
let client = LarkClient::builder(&cfg.app_id, &cfg.app_secret).build()?;
|
let client: &'static LarkClient = Box::leak(Box::new(
|
||||||
|
LarkClient::builder(&cfg.app_id, &cfg.app_secret).build()?,
|
||||||
|
));
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
client,
|
client,
|
||||||
streams: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
|
streams: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
|
||||||
@@ -44,8 +50,11 @@ impl FeishuAdapter {
|
|||||||
|
|
||||||
/// 构造失败时的离线哨兵:client 用占位凭证(不启动 run,dispatch 记日志即弃)
|
/// 构造失败时的离线哨兵:client 用占位凭证(不启动 run,dispatch 记日志即弃)
|
||||||
pub fn offline() -> Self {
|
pub fn offline() -> Self {
|
||||||
let client = LarkClient::builder("offline", "offline").build()
|
let client: &'static LarkClient = Box::leak(Box::new(
|
||||||
.expect("占位凭证构造 LarkClient 不发网络请求,必成功");
|
LarkClient::builder("offline", "offline")
|
||||||
|
.build()
|
||||||
|
.expect("占位凭证构造 LarkClient 不发网络请求,必成功"),
|
||||||
|
));
|
||||||
Self {
|
Self {
|
||||||
client,
|
client,
|
||||||
streams: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
|
streams: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
|
||||||
@@ -68,16 +77,16 @@ impl PlatformAdapter for FeishuAdapter {
|
|||||||
async fn run(&self, events: Sender<InboundEvent>) -> anyhow::Result<()> {
|
async fn run(&self, events: Sender<InboundEvent>) -> anyhow::Result<()> {
|
||||||
// 长连接模式:空 verification_token/encrypt_key(免公网回调)
|
// 长连接模式:空 verification_token/encrypt_key(免公网回调)
|
||||||
let dispatcher = EventDispatcher::new("", "");
|
let dispatcher = EventDispatcher::new("", "");
|
||||||
let client = self.client.clone();
|
let client = self.client;
|
||||||
|
|
||||||
// 入站事件通道复制两份,分别给消息/卡片回调闭包
|
// 入站事件通道复制两份,分别给消息/卡片回调闭包
|
||||||
let events_for_msg = events.clone();
|
let events_for_msg = events.clone();
|
||||||
let events_for_card = events;
|
let events_for_card = events;
|
||||||
|
|
||||||
let channel: Channel<'static> = {
|
let channel: Channel<'static> = {
|
||||||
// Channel<'a> 借用 client;用 Box::leak 提升为 'static(适配器生命周期即进程生命周期)
|
// client 已是 &'static(构造期一次性 leak);run() 重连重入只重建 Channel,
|
||||||
let leaked: &'static LarkClient = Box::leak(Box::new(client));
|
// 不再重复 Box::leak(D1:防每次重入泄漏一份 LarkClient)
|
||||||
Channel::builder(leaked, dispatcher)
|
Channel::builder(client, dispatcher)
|
||||||
.policy(ChannelPolicy::default().require_mention(false))
|
.policy(ChannelPolicy::default().require_mention(false))
|
||||||
.on_message(move |msg| {
|
.on_message(move |msg| {
|
||||||
let events = events_for_msg.clone();
|
let events = events_for_msg.clone();
|
||||||
@@ -251,7 +260,7 @@ async fn handle_card_action(events: Sender<InboundEvent>, action: larksuite_oapi
|
|||||||
|
|
||||||
/// 构造审批卡片(Card JSON 1.0):标题 + 详情 + 同意/拒绝按钮
|
/// 构造审批卡片(Card JSON 1.0):标题 + 详情 + 同意/拒绝按钮
|
||||||
fn build_approval_card(approval_id: &str, title: &str, detail: &str) -> Card {
|
fn build_approval_card(approval_id: &str, title: &str, detail: &str) -> Card {
|
||||||
let detail_md = format!("{}\n\n**审批 ID**: `{approval_id}`", escape_md(detail));
|
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 {
|
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 可逆"))
|
serde_json::from_value(serde_json::to_value(v).expect("serde_json::Value 可逆"))
|
||||||
.expect("按钮 value 结构兼容")
|
.expect("按钮 value 结构兼容")
|
||||||
@@ -276,19 +285,54 @@ fn build_approval_card(approval_id: &str, title: &str, detail: &str) -> Card {
|
|||||||
.element(Element::Action(actions))
|
.element(Element::Action(actions))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Markdown 转义(飞书卡片 lark_md 语法)
|
/// 出站 Markdown 净化(飞书卡片 lark_md 语法适配)。
|
||||||
fn escape_md(s: &str) -> String {
|
///
|
||||||
s.replace('\\', "\\\\")
|
/// AI 产出常带标准 Markdown 结构,lark_md 不支持标题与表格语法,原样注入按字面量渲染。
|
||||||
.replace('*', "\\*")
|
/// 规则(与 src-tauri feishu_channel.rs 侧 md_to_lark_friendly 同规则互引,双端出站口径一致):
|
||||||
.replace('_', "\\_")
|
/// 标题行(行首一个以上 #)转粗体行;表格行(| 开头)转列表行,表头分隔行整行跳过;
|
||||||
.replace('`', "\\`")
|
/// 代码块围栏内外均保留原样(转义或改写破坏代码展示);其余行原样透传(lark_md 原生
|
||||||
.replace('[', "\\[")
|
/// 支持粗体/链接/行内代码,不额外转义)。
|
||||||
.replace(']', "\\]")
|
fn md_to_lark_friendly(s: &str) -> String {
|
||||||
.replace('<', "\\<")
|
let mut out = String::with_capacity(s.len());
|
||||||
.replace('>', "\\>")
|
let mut in_code = false;
|
||||||
.replace('(', "\\(")
|
for line in s.lines() {
|
||||||
.replace(')', "\\)")
|
if line.trim_start().starts_with("```") {
|
||||||
.replace('#', "\\#")
|
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)]
|
#[cfg(test)]
|
||||||
@@ -340,11 +384,27 @@ mod tests {
|
|||||||
assert!(approved_flags.contains(&true) && approved_flags.contains(&false));
|
assert!(approved_flags.contains(&true) && approved_flags.contains(&false));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Markdown 转义:卡片详情中的 markdown 语法字符被转义
|
/// 出站净化:标题转粗体行,表格行转列表行,表头分隔行跳过
|
||||||
#[test]
|
#[test]
|
||||||
fn escape_md_works() {
|
fn lark_friendly_converts_headings_and_tables() {
|
||||||
assert_eq!(escape_md("a*b_c`d\\e"), "a\\*b\\_c\\`d\\\\e");
|
let md = "# 标题\n| a | b |\n|---|---|\n| 1 | 2 |";
|
||||||
// 走查 #7:链接/标题/引用语法字符也须转义,防用户内容注入卡片结构
|
assert_eq!(md_to_lark_friendly(md), "**标题**\n- a · b\n- 1 · 2");
|
||||||
assert_eq!(escape_md("[x](http://e)#h <b>(y)"), "\\[x\\]\\(http://e\\)\\#h \\<b\\>\\(y\\)");
|
}
|
||||||
|
|
||||||
|
/// 出站净化:代码围栏内外均保留原样,围栏外的标题照常改写
|
||||||
|
#[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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
mod adapters;
|
mod adapters;
|
||||||
|
mod audit;
|
||||||
mod commander;
|
mod commander;
|
||||||
mod config;
|
mod config;
|
||||||
mod feishu;
|
mod feishu;
|
||||||
@@ -43,6 +44,8 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.unwrap_or_else(|_| PathBuf::from("data"));
|
.unwrap_or_else(|_| PathBuf::from("data"));
|
||||||
std::fs::create_dir_all(&data_dir)?;
|
std::fs::create_dir_all(&data_dir)?;
|
||||||
let store = SessionStore::open(&data_dir.join("sessions.db"))?;
|
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);
|
info_banner(&cfg);
|
||||||
|
|
||||||
@@ -153,11 +156,25 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let tail = throttle.finish(&conv_id);
|
let tail = throttle.finish(&conv_id);
|
||||||
let full = tail.unwrap_or_default();
|
let full = tail.unwrap_or_default();
|
||||||
let idx = platform_of(&conv_id);
|
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]
|
let _ = adapters[idx]
|
||||||
.dispatch(OutboundAction::StreamText { chat_id: conv_id, text: full, done: true })
|
.dispatch(OutboundAction::StreamText { chat_id: conv_id, text: full, done: true })
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
df_tunnel::TunnelEvent::Approval { conv_id, approval_id, description } => {
|
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 idx = platform_of(&conv_id);
|
||||||
let _ = adapters[idx]
|
let _ = adapters[idx]
|
||||||
.dispatch(OutboundAction::ApprovalCard {
|
.dispatch(OutboundAction::ApprovalCard {
|
||||||
@@ -182,12 +199,26 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
// 回复适配器:按来源平台回路由
|
// 回复适配器:按来源平台回路由
|
||||||
let reply_idx = platform_of(&platform);
|
let reply_idx = platform_of(&platform);
|
||||||
if !cfg_loop.whitelist.is_empty() && !cfg_loop.whitelist.contains(&user_id) {
|
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]
|
let _ = adapters[reply_idx]
|
||||||
.dispatch(OutboundAction::Notice { chat_id, text: "⛔ 你不在白名单内".into() })
|
.dispatch(OutboundAction::Notice { chat_id, text: "⛔ 你不在白名单内".into() })
|
||||||
.await;
|
.await;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let Some(channel_id) = cfg_loop.route(&text) else {
|
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]
|
let _ = adapters[reply_idx]
|
||||||
.dispatch(OutboundAction::Notice { chat_id, text: "⚠️ 无可用 channel".into() })
|
.dispatch(OutboundAction::Notice { chat_id, text: "⚠️ 无可用 channel".into() })
|
||||||
.await;
|
.await;
|
||||||
@@ -202,14 +233,45 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
// 走查 #3:新建会话时清理流式残留(上轮异常中止无
|
// 走查 #3:新建会话时清理流式残留(上轮异常中止无
|
||||||
// Completed 事件,streams entry 永驻会错覆写旧消息)
|
// Completed 事件,streams entry 永驻会错覆写旧消息)
|
||||||
adapters[reply_idx].reset_stream(&chat_id).await;
|
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
|
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
|
let _ = cmd_tx
|
||||||
.send(df_tunnel::TunnelCommand::Send { conv_id, content: text })
|
.send(df_tunnel::TunnelCommand::Send { conv_id, content: text })
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
InboundEvent::NewSession { platform, chat_id } => {
|
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 _ = store.remove(&platform, &chat_id);
|
||||||
let reply_idx = platform_of(&platform);
|
let reply_idx = platform_of(&platform);
|
||||||
let _ = adapters[reply_idx]
|
let _ = adapters[reply_idx]
|
||||||
@@ -227,6 +289,19 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.get_conv(&platform, &chat_id)
|
.get_conv(&platform, &chat_id)
|
||||||
.map(|m| m.conv_id)
|
.map(|m| m.conv_id)
|
||||||
.unwrap_or_else(|| format!("{platform}:{chat_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
|
let _ = cmd_tx
|
||||||
.send(df_tunnel::TunnelCommand::Approve { conv_id, approval_id, accepted: approved })
|
.send(df_tunnel::TunnelCommand::Approve { conv_id, approval_id, accepted: approved })
|
||||||
.await;
|
.await;
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -17,4 +17,4 @@ pub mod server;
|
|||||||
pub mod server_http;
|
pub mod server_http;
|
||||||
pub mod tools;
|
pub mod tools;
|
||||||
|
|
||||||
pub use server::{run_server, ServerConfig};
|
pub use server::{run_server, ServerConfig, MCP_DATA_CHANGED_KEY};
|
||||||
|
|||||||
+103
-10
@@ -14,6 +14,7 @@ use std::path::Path;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use df_storage::crud::SettingsRepo;
|
||||||
use df_storage::db::Database;
|
use df_storage::db::Database;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||||
@@ -29,6 +30,15 @@ pub(crate) const PROTOCOL_VERSION: &str = "2025-06-18";
|
|||||||
pub(crate) const SERVER_NAME: &str = "devflow-mcp";
|
pub(crate) const SERVER_NAME: &str = "devflow-mcp";
|
||||||
pub(crate) const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
|
pub(crate) const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
|
|
||||||
|
/// 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 运行配置。
|
/// MCP server 运行配置。
|
||||||
///
|
///
|
||||||
/// 聚合 stdio 形态的运行参数,避免 `run_server` 参数膨胀。
|
/// 聚合 stdio 形态的运行参数,避免 `run_server` 参数膨胀。
|
||||||
@@ -41,7 +51,8 @@ pub struct ServerConfig {
|
|||||||
pub idle_timeout: Option<Duration>,
|
pub idle_timeout: Option<Duration>,
|
||||||
/// 写操作回调:成功执行(未被 read-only/High 拒绝)的写工具(risk != Low)触发。
|
/// 写操作回调:成功执行(未被 read-only/High 拒绝)的写工具(risk != Low)触发。
|
||||||
/// 桌面内嵌形态经 server_http → on_tool_call → emit df-data-changed 刷新 GUI;
|
/// 桌面内嵌形态经 server_http → on_tool_call → emit df-data-changed 刷新 GUI;
|
||||||
/// stdio 独立进程暂无 AppHandle,传 None 预留接线点,后续可注入。
|
/// stdio 独立进程暂无 AppHandle,传 None 预留接线点,后续可注入。传 None 时由
|
||||||
|
/// 落库变更标记([`MCP_DATA_CHANGED_KEY`])兜底,供 GUI 侧轮询消费。
|
||||||
pub on_write_call: Option<Arc<dyn Fn(&str) + Send + Sync>>,
|
pub on_write_call: Option<Arc<dyn Fn(&str) + Send + Sync>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,8 +165,13 @@ where
|
|||||||
if let Some(name) = tool_name {
|
if let Some(name) = tool_name {
|
||||||
// MC-5仅业务成功才触发写回调——防业务失败(如 update_project 非法 status、
|
// MC-5仅业务成功才触发写回调——防业务失败(如 update_project 非法 status、
|
||||||
// create_project 空名等 result.isError=true)仍假触发 df-data-changed。
|
// create_project 空名等 result.isError=true)仍假触发 df-data-changed。
|
||||||
if is_success_tool_call(&resp) {
|
if is_success_tool_call(&resp) && is_executed_write(&name, config.read_only) {
|
||||||
fire_write_hook(config, &name);
|
// 多进程兜底:stdio 形态无 AppHandle,先落库内变更标记,供 GUI 侧轮询消费
|
||||||
|
// (即便未注入回调也不丢变更感知);再触发进程内回调(HTTP 内嵌形态走事件)。
|
||||||
|
record_data_change(ctx, &name).await;
|
||||||
|
if let Some(cb) = &config.on_write_call {
|
||||||
|
cb(&name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -293,13 +309,30 @@ fn required_names(schema: &Value) -> Vec<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 触发写操作回调(若有)。仅当工具为写操作(risk != Low)且未被 read-only/High 拒绝时触发,
|
/// 判定工具是否属于「会执行的成功写操作」:risk != Low 且未被 read-only/High 拒绝。
|
||||||
/// 与 dispatch 的执行判定一致。回调仅作通知(如 GUI 刷新),不承载返回结果。
|
///
|
||||||
fn fire_write_hook(config: &ServerConfig, name: &str) {
|
/// 变更标记与进程内写回调共用此判定,保证两通道对「哪些写操作需通知 GUI」语义一致;
|
||||||
let Some(cb) = &config.on_write_call else { return };
|
/// 与 dispatch 的执行判定(`should_execute`)同源,避免逻辑漂移。
|
||||||
let Some(spec) = tools::find(name) else { return };
|
fn is_executed_write(name: &str, read_only: bool) -> bool {
|
||||||
if spec.risk != RiskLevel::Low && should_execute(config.read_only, spec.risk) {
|
match tools::find(name) {
|
||||||
cb(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, "写变更标记失败(不改工具调用结果)");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -675,4 +708,64 @@ mod tests {
|
|||||||
*calls.lock().unwrap()
|
*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:?}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
//! 工具暴露层 — 从 df-storage Repo 复用 CRUD,转 MCP Tool schema
|
//! 工具暴露层 — 从 df-storage Repo 复用 CRUD,转 MCP Tool schema
|
||||||
|
//! 工具暴露层 — 从 df-storage Repo 复用 CRUD,转 MCP Tool schema
|
||||||
//!
|
//!
|
||||||
//! 不重复实现数据层逻辑,只做:① 工具元数据(name/description/inputSchema)声明;
|
//! 不重复实现数据层逻辑,只做:① 工具元数据(name/description/inputSchema)声明;
|
||||||
//! ② 工具调用 → df-storage Repo 方法 → 序列化为 MCP CallToolResult。
|
//! ② 工具调用 → df-storage Repo 方法 → 序列化为 MCP CallToolResult。
|
||||||
@@ -379,6 +380,7 @@ fn create_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
|
|||||||
idea_id: None,
|
idea_id: None,
|
||||||
path: None,
|
path: None,
|
||||||
stack: None,
|
stack: None,
|
||||||
|
archived: false,
|
||||||
created_at: now.clone(),
|
created_at: now.clone(),
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
@@ -434,6 +436,7 @@ fn update_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
|
|||||||
idea_id: existing.idea_id,
|
idea_id: existing.idea_id,
|
||||||
path: existing.path,
|
path: existing.path,
|
||||||
stack: existing.stack,
|
stack: existing.stack,
|
||||||
|
archived: existing.archived,
|
||||||
created_at: existing.created_at,
|
created_at: existing.created_at,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
@@ -544,9 +547,18 @@ fn list_tasks(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
|||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
let repo = TaskRepo::new(&db);
|
let repo = TaskRepo::new(&db);
|
||||||
// 分页:取 limit+1 条探测是否有下一页(has_more),再截断到 limit。
|
// 分页:取 limit+1 条探测是否有下一页(has_more),再截断到 limit。
|
||||||
|
// 缺陷修复(1eff911c,登记于 2026-08-08):无 status 过滤时默认排除 done/cancelled——
|
||||||
|
// 终态任务是存量大头(本库 260+ 条),不过滤会挤占分页窗口,活跃任务被
|
||||||
|
// created_at DESC + limit 截断挤出视图(表象=「过滤失效/他项目任务混入」)。
|
||||||
|
// 显式传 status(含 done)不干预,保留查终态能力。
|
||||||
let query = TaskQuery {
|
let query = TaskQuery {
|
||||||
project_id: project_id_filter,
|
project_id: project_id_filter,
|
||||||
status: status_filter,
|
status: status_filter.clone(),
|
||||||
|
status_not_in: if status_filter.is_some() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(vec!["done".to_string(), "cancelled".to_string()])
|
||||||
|
},
|
||||||
limit: Some(limit + 1),
|
limit: Some(limit + 1),
|
||||||
offset: Some(offset),
|
offset: Some(offset),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -1263,6 +1275,7 @@ mod tests {
|
|||||||
idea_id: None,
|
idea_id: None,
|
||||||
path: None,
|
path: None,
|
||||||
stack: None,
|
stack: None,
|
||||||
|
archived: false,
|
||||||
created_at: now.clone(),
|
created_at: now.clone(),
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
@@ -1736,6 +1749,43 @@ mod tests {
|
|||||||
assert_eq!(v["has_more"], false, "3 条 < 100,无下一页");
|
assert_eq!(v["has_more"], false, "3 条 < 100,无下一页");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 1eff911c 缺陷修复:无 status 过滤时默认排除 done/cancelled 终态任务;
|
||||||
|
/// 显式传 status=done 仍可查终态。
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_tasks_excludes_terminal_status_by_default() {
|
||||||
|
let ctx = test_ctx().await;
|
||||||
|
let pid = seed_project(&ctx, "终态过滤").await;
|
||||||
|
let done_id = seed_task(&ctx, &pid, "已完成任务").await;
|
||||||
|
seed_task(&ctx, &pid, "进行中任务").await;
|
||||||
|
// 把第一条推进到 done
|
||||||
|
let repo = TaskRepo::new(&ctx.db);
|
||||||
|
repo.advance_status_atomic(&done_id, "todo", "done", false).await.unwrap();
|
||||||
|
|
||||||
|
// 无 status 过滤:done 应被排除,只返回活跃任务
|
||||||
|
let r = list_tasks(&ctx, json!({ "project_id": pid })).await;
|
||||||
|
assert!(r.is_error.is_none(), "{:?}", text_of(&r));
|
||||||
|
let v = json_of(&r);
|
||||||
|
let titles: Vec<&str> = v["tasks"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|t| t["title"].as_str().unwrap())
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
!titles.contains(&"已完成任务"),
|
||||||
|
"done 任务不应出现在默认结果: {titles:?}"
|
||||||
|
);
|
||||||
|
assert!(titles.contains(&"进行中任务"), "活跃任务应保留: {titles:?}");
|
||||||
|
|
||||||
|
// 显式 status=done:仍可查终态(能力不回退)
|
||||||
|
let r = list_tasks(&ctx, json!({ "project_id": pid, "status": "done" })).await;
|
||||||
|
assert!(r.is_error.is_none(), "{:?}", text_of(&r));
|
||||||
|
let v = json_of(&r);
|
||||||
|
let arr = v["tasks"].as_array().unwrap();
|
||||||
|
assert_eq!(arr.len(), 1, "显式查 done 应命中 1 条");
|
||||||
|
assert_eq!(arr[0]["title"], "已完成任务");
|
||||||
|
}
|
||||||
|
|
||||||
/// list_projects 分页:offset 生效 + has_more 标记。
|
/// list_projects 分页:offset 生效 + has_more 标记。
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn list_projects_pagination_offset_and_has_more() {
|
async fn list_projects_pagination_offset_and_has_more() {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
//! AI 编排节点 — 直连 agentic 会话执行工作流任务
|
//! AI 编排节点 — 直连 agentic 会话执行工作流任务
|
||||||
|
//! AI 编排节点 — 直连 agentic 会话执行工作流任务
|
||||||
//!
|
//!
|
||||||
//! 与 AiNode(单次 LLM 补全)的区别:AiAgentNode 在节点 execute 时以编程方式启动一个
|
//! 与 AiNode(单次 LLM 补全)的区别:AiAgentNode 在节点 execute 时以编程方式启动一个
|
||||||
//! 独立 agentic 会话(新 conv_id,与用户活跃会话隔离,防 epoch 互踩),由 run_agentic_loop
|
//! 独立 agentic 会话(新 conv_id,与用户活跃会话隔离,防 epoch 互踩),由 run_agentic_loop
|
||||||
@@ -241,6 +242,7 @@ mod tests {
|
|||||||
idea_id: None,
|
idea_id: None,
|
||||||
path: None,
|
path: None,
|
||||||
stack: None,
|
stack: None,
|
||||||
|
archived: false,
|
||||||
created_at: "0".to_string(),
|
created_at: "0".to_string(),
|
||||||
updated_at: "0".to_string(),
|
updated_at: "0".to_string(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
//! AI 自审节点(决策 a 步骤③)— AiSelfReviewNode
|
//! AI 自审节点(决策 a 步骤③)— AiSelfReviewNode
|
||||||
|
//! AI 自审节点(决策 a 步骤③)— AiSelfReviewNode
|
||||||
//!
|
//!
|
||||||
//! 从 ai_node.rs 拆出的独立节点(纯结构搬运,逻辑等价)。复用 AiNode 的 provider
|
//! 从 ai_node.rs 拆出的独立节点(纯结构搬运,逻辑等价)。复用 AiNode 的 provider
|
||||||
//! 调用链(resolve_and_parse + provider_from_params),差异在 prompt 模板/JSON 解析/
|
//! 调用链(resolve_and_parse + provider_from_params),差异在 prompt 模板/JSON 解析/
|
||||||
@@ -316,6 +317,7 @@ mod tests {
|
|||||||
idea_id: None,
|
idea_id: None,
|
||||||
path: None,
|
path: None,
|
||||||
stack: None,
|
stack: None,
|
||||||
|
archived: false,
|
||||||
created_at: "0".to_string(),
|
created_at: "0".to_string(),
|
||||||
updated_at: "0".to_string(),
|
updated_at: "0".to_string(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -70,10 +70,12 @@ impl Node for HumanNode {
|
|||||||
|
|
||||||
// 3. select! 循环:Response / 超时 / 取消
|
// 3. select! 循环:Response / 超时 / 取消
|
||||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_secs);
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_secs);
|
||||||
let mut cancel_tick = tokio::time::interval(Duration::from_millis(500));
|
// FR-P4:取消检测由 500ms interval 轮询改为 Notify 被动唤醒(set_cancelled 触发)。
|
||||||
cancel_tick.tick().await; // 丢弃首个立即触发的 tick
|
// 先建 notified future 再进 loop,避免 notify 早于 await 的竞态窗口;
|
||||||
|
// 每轮 select! 后重建(Notified 单次消费),配合 is_cancelled 复查兜底。
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
|
let cancel_wait = ctx.node_status.cancel_notified();
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
recv = rx.recv() => match recv {
|
recv = rx.recv() => match recv {
|
||||||
Ok(WorkflowEvent::HumanApprovalResponse {
|
Ok(WorkflowEvent::HumanApprovalResponse {
|
||||||
@@ -154,7 +156,9 @@ impl Node for HumanNode {
|
|||||||
_ = tokio::time::sleep_until(deadline) => {
|
_ = tokio::time::sleep_until(deadline) => {
|
||||||
return Err(anyhow::anyhow!("人工审批超时({}s)", timeout_secs));
|
return Err(anyhow::anyhow!("人工审批超时({}s)", timeout_secs));
|
||||||
}
|
}
|
||||||
_ = cancel_tick.tick() => {
|
_ = cancel_wait => {
|
||||||
|
// FR-P4:Notify 唤醒后复查 is_cancelled(Notify 无状态,
|
||||||
|
// 可能因其他节点取消而误唤醒,须确认本节点确被取消)。
|
||||||
if ctx.node_status.is_cancelled(&ctx.node_id) {
|
if ctx.node_status.is_cancelled(&ctx.node_id) {
|
||||||
return Err(anyhow::anyhow!("人工审批被取消"));
|
return Err(anyhow::anyhow!("人工审批被取消"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
//! 子工作流节点 — 加载一个子 DAG 并在当前执行上下文中递归执行
|
//! 子工作流节点 — 加载并校验一个子 DAG,返回其 JSON 快照
|
||||||
//!
|
//!
|
||||||
//! 用于复用通用流程(如"代码审查"可作为任意主流程的子步骤)。
|
//! 用于复用通用流程(如"代码审查"可作为任意主流程的子步骤)。
|
||||||
//! 执行结果不直接展开节点,而是返回子 DAG 的 JSON 快照,由 DagExecutor 消费展开。
|
//! 注意:本节点不递归执行子 DAG,只做配置/深度校验后返回子 DAG 快照;
|
||||||
|
//! 快照的展开执行需调用方自行处理(DagExecutor 当前不会自动执行返回的 subflow)。
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use df_workflow::dag_def::DagDef;
|
use df_workflow::dag_def::DagDef;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
//! 任务推进节点 — advance_task 推进链触发器。
|
//! 任务推进节点 — advance_task 推进链触发器。
|
||||||
|
//! 任务推进节点 — advance_task 推进链触发器。
|
||||||
//!
|
//!
|
||||||
//! 实现推进链的唯一 status 写入路径(D-260616-03 落 df-nodes Node):
|
//! 实现推进链的唯一 status 写入路径(D-260616-03 落 df-nodes Node):
|
||||||
//! 1. 读当前 TaskRecord(取 from status)
|
//! 1. 读当前 TaskRecord(取 from status)
|
||||||
@@ -66,6 +67,26 @@ pub async fn advance_task_atomic(
|
|||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| df_types::error::Error::NotFound(format!("任务 {} 不存在", id)))?;
|
.ok_or_else(|| df_types::error::Error::NotFound(format!("任务 {} 不存在", id)))?;
|
||||||
|
|
||||||
|
// 2.5 前置校验:归档任务冻结,不可推进(与 CAS SQL 的 archived=0 条件同源)。
|
||||||
|
//
|
||||||
|
// 背景:advance_status_atomic 的 CAS SQL 带 `AND archived = 0`,归档任务必然
|
||||||
|
// affected==0 → 返回 None → 下游误报「已变更(并发推进或旁路修改)」,诊断信息
|
||||||
|
// 完全误导(真实原因是归档,非并发)。此处前置拦截,给出精确可操作错误。
|
||||||
|
//
|
||||||
|
// 软删(deleted_at IS NOT NULL)任务 get_by_id 仍能读到(TASK_COLS 不 SELECT
|
||||||
|
// deleted_at,TaskRecord 无该字段),但 CAS 的 `deleted_at IS NULL` 同样必败。
|
||||||
|
// 因 TaskRecord 不带 deleted_at,此处无法直接判定;由 advance_status_atomic
|
||||||
|
// 的 CAS 失败回查区分(见该函数注释)。
|
||||||
|
if current.archived {
|
||||||
|
return Err(df_types::error::Error::InvalidState {
|
||||||
|
current: format!(
|
||||||
|
"任务 {} 已归档(archived=true),归档任务冻结不可推进;如需推进请先取消归档",
|
||||||
|
id
|
||||||
|
),
|
||||||
|
expected: target_status.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 3. 状态机校验(三类拒绝,错误类型区分供前端分辨):
|
// 3. 状态机校验(三类拒绝,错误类型区分供前端分辨):
|
||||||
// - 同态(from==to):Validation「相同状态,无需推进」(空操作,非状态机违例)
|
// - 同态(from==to):Validation「相同状态,无需推进」(空操作,非状态机违例)
|
||||||
// - 非法转换(跳态/终态无后继等):InvalidState「非法状态转换 X→Y」
|
// - 非法转换(跳态/终态无后继等):InvalidState「非法状态转换 X→Y」
|
||||||
@@ -111,11 +132,45 @@ pub async fn advance_task_atomic(
|
|||||||
.advance_status_atomic(id, from, target_status, bump)
|
.advance_status_atomic(id, from, target_status, bump)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// 5. CAS 失败:并发已改动 status(或任务被删),拒绝静默成功。
|
// 5. CAS 失败:affected==0 有四种可能,回查区分,给出精确错误(不再一律误报「并发」)。
|
||||||
updated.ok_or_else(|| df_types::error::Error::InvalidState {
|
//
|
||||||
current: "已变更(并发推进或旁路修改)".to_string(),
|
// CAS SQL 条件:`WHERE id=? AND status=? AND deleted_at IS NULL AND archived=0`。
|
||||||
expected: from.to_string(),
|
// affected==0 时逐一排查,避免「已变更(并发推进或旁路修改)」这个误导性文案:
|
||||||
})
|
// a) deleted_at 非 NULL → 任务在回收站(回查 is_deleted)
|
||||||
|
// b) archived=1 → 已归档(前置 2.5 已拦,此处兜底防 TOCTOU)
|
||||||
|
// c) status 真被并发改动 → 回读当前 status 对比 from
|
||||||
|
// d) 任务被删(id 不存在)→ get_by_id 返 None
|
||||||
|
let Some(updated) = updated else {
|
||||||
|
// a) 回收站任务(最易被误报为「并发」的一类,优先排查)
|
||||||
|
if repo.is_deleted(id).await? {
|
||||||
|
return Err(df_types::error::Error::InvalidState {
|
||||||
|
current: format!(
|
||||||
|
"任务 {} 在回收站(deleted_at 非 NULL),回收站任务不可推进;如需推进请先恢复",
|
||||||
|
id
|
||||||
|
),
|
||||||
|
expected: from.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// b/c/d) 回读最新记录,区分归档 / 真并发 / 已删除
|
||||||
|
let latest = repo.get_by_id(id).await?;
|
||||||
|
return Err(match latest {
|
||||||
|
None => df_types::error::Error::NotFound(format!("任务 {} 不存在", id)),
|
||||||
|
Some(rec) if rec.archived => df_types::error::Error::InvalidState {
|
||||||
|
current: format!("任务 {} 已归档(archived=true),不可推进;如需推进请先取消归档", id),
|
||||||
|
expected: from.to_string(),
|
||||||
|
},
|
||||||
|
Some(rec) => df_types::error::Error::InvalidState {
|
||||||
|
current: format!(
|
||||||
|
"状态已变更(并发推进或旁路修改):期望 {:?},实际 {:?}",
|
||||||
|
from,
|
||||||
|
rec.status.as_str()
|
||||||
|
),
|
||||||
|
expected: from.to_string(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(updated)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -492,6 +547,7 @@ mod tests {
|
|||||||
idea_id: None,
|
idea_id: None,
|
||||||
path: None,
|
path: None,
|
||||||
stack: None,
|
stack: None,
|
||||||
|
archived: false,
|
||||||
created_at: "0".to_string(),
|
created_at: "0".to_string(),
|
||||||
updated_at: "0".to_string(),
|
updated_at: "0".to_string(),
|
||||||
})
|
})
|
||||||
@@ -644,6 +700,69 @@ mod tests {
|
|||||||
assert!(r.is_none(), "expected 与库内不符应 CAS 失败");
|
assert!(r.is_none(), "expected 与库内不符应 CAS 失败");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- 归档/软删任务的精确错误(治「已变更(并发推进或旁路修改)」误导) ----------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn archived_task_rejected_with_precise_error() {
|
||||||
|
// 归档任务(archived=true)推进应被前置校验拦截,给出「已归档」精确错误,
|
||||||
|
// 而非落到 CAS 失败报「已变更(并发推进或旁路修改)」误导文案。
|
||||||
|
let repo = setup().await;
|
||||||
|
let mut r = rec("t1", TaskStatus::Todo);
|
||||||
|
r.archived = true;
|
||||||
|
repo.insert(r).await.unwrap();
|
||||||
|
let err = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap_err();
|
||||||
|
match err {
|
||||||
|
df_types::error::Error::InvalidState { current, .. } => {
|
||||||
|
assert!(
|
||||||
|
current.contains("已归档"),
|
||||||
|
"归档任务错误应含「已归档」,实际: {current}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!current.contains("并发推进或旁路修改"),
|
||||||
|
"归档任务不应误报并发,实际: {current}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
other => panic!("归档任务拒绝应是 InvalidState,实际: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn soft_deleted_task_rejected_with_precise_error() {
|
||||||
|
// 回收站任务(deleted_at 非 NULL)推进:get_by_id 仍能读到(不过滤软删),
|
||||||
|
// 状态机校验通过,但 CAS SQL 带 deleted_at IS NULL 必败 → 回查 is_deleted
|
||||||
|
// 区分,给出「在回收站」精确错误,而非误报「已变更(并发推进或旁路修改)」。
|
||||||
|
let repo = setup().await;
|
||||||
|
repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap();
|
||||||
|
// 软删该任务(走 soft_delete,设 deleted_at)
|
||||||
|
let ok = repo.soft_delete("t1").await.unwrap();
|
||||||
|
assert!(ok, "soft_delete 应命中");
|
||||||
|
assert!(repo.is_deleted("t1").await.unwrap(), "软删后 is_deleted 应为 true");
|
||||||
|
let err = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap_err();
|
||||||
|
match err {
|
||||||
|
df_types::error::Error::InvalidState { current, .. } => {
|
||||||
|
assert!(
|
||||||
|
current.contains("回收站"),
|
||||||
|
"软删任务错误应含「回收站」,实际: {current}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!current.contains("并发推进或旁路修改"),
|
||||||
|
"软删任务不应误报并发,实际: {current}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
other => panic!("软删任务拒绝应是 InvalidState,实际: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn is_deleted_false_for_active_task() {
|
||||||
|
// 活跃任务 is_deleted 应为 false(不误判),保证回查区分逻辑不误伤正常任务。
|
||||||
|
let repo = setup().await;
|
||||||
|
repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap();
|
||||||
|
assert!(!repo.is_deleted("t1").await.unwrap(), "活跃任务 is_deleted 应为 false");
|
||||||
|
// 不存在的任务:is_deleted 返回 false(不存在 ≠ 软删)
|
||||||
|
assert!(!repo.is_deleted("nope").await.unwrap(), "不存在任务 is_deleted 应为 false");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn blocked_round_trip_does_not_bump() {
|
async fn blocked_round_trip_does_not_bump() {
|
||||||
let repo = setup().await;
|
let repo = setup().await;
|
||||||
|
|||||||
@@ -114,14 +114,11 @@ pub(crate) fn strip_readme_noise(content: &str) -> String {
|
|||||||
if trimmed == "---" || trimmed == "+++" {
|
if trimmed == "---" || trimmed == "+++" {
|
||||||
// 开头处的 frontmatter:跳到下一个匹配分隔符
|
// 开头处的 frontmatter:跳到下一个匹配分隔符
|
||||||
let delim = trimmed;
|
let delim = trimmed;
|
||||||
let mut consumed_any = false;
|
|
||||||
for next in lines.by_ref() {
|
for next in lines.by_ref() {
|
||||||
consumed_any = true;
|
|
||||||
if next.trim() == delim {
|
if next.trim() == delim {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let _ = consumed_any;
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// ── HTML 注释块(<!-- ... -->,可能跨行)──
|
// ── HTML 注释块(<!-- ... -->,可能跨行)──
|
||||||
|
|||||||
@@ -75,6 +75,17 @@ fn validate_device_id(id: &str) -> bool {
|
|||||||
!id.is_empty() && id.len() <= 128 && id.chars().all(|c| !c.is_control())
|
!id.is_empty() && id.len() <= 128 && id.chars().all(|c| !c.is_control())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 当前 Hello 握手协议版本。
|
||||||
|
///
|
||||||
|
/// 协商规则:旧客户端 Hello 缺 `proto` 字段 → serde default 视作 v1(向后兼容);
|
||||||
|
/// 高于本端支持版本时记录告警,为后续破坏性变更预留通知位(当前仅日志,不拒绝握手)。
|
||||||
|
pub const HELLO_PROTO_VERSION: u32 = 1;
|
||||||
|
|
||||||
|
/// `proto` 缺省值:旧客户端不带该字段时按 v1 处理(向后兼容)。
|
||||||
|
fn default_proto() -> u32 {
|
||||||
|
HELLO_PROTO_VERSION
|
||||||
|
}
|
||||||
|
|
||||||
/// 客户端首消息:身份宣告(简单协议)
|
/// 客户端首消息:身份宣告(简单协议)
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Hello {
|
pub struct Hello {
|
||||||
@@ -84,6 +95,9 @@ pub struct Hello {
|
|||||||
pub device_id: String,
|
pub device_id: String,
|
||||||
/// 配对 token
|
/// 配对 token
|
||||||
pub token: String,
|
pub token: String,
|
||||||
|
/// 协议版本(缺省按 v1,兼容旧客户端)
|
||||||
|
#[serde(default = "default_proto")]
|
||||||
|
pub proto: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hello.kind 的传输表示(serde 字符串,与 ClientKind 解耦避免 rename 歧义)
|
/// Hello.kind 的传输表示(serde 字符串,与 ClientKind 解耦避免 rename 歧义)
|
||||||
@@ -298,6 +312,16 @@ async fn handle_connection(socket: WebSocket, state: RelayState, expected: Clien
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 版本协商:proto 缺省按 v1(旧客户端不带字段,向后兼容)。
|
||||||
|
// 高于本端支持版本时告警 —— 为后续破坏性变更预留的通知位(当前仅日志,不拒绝握手)。
|
||||||
|
if hello.proto > HELLO_PROTO_VERSION {
|
||||||
|
tracing::warn!(
|
||||||
|
client_proto = hello.proto,
|
||||||
|
server_proto = HELLO_PROTO_VERSION,
|
||||||
|
"客户端协议版本高于本端,可能存在不兼容(预留通知位)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let conn_id = next_conn_id();
|
let conn_id = next_conn_id();
|
||||||
let kind: ClientKind = hello.kind.into();
|
let kind: ClientKind = hello.kind.into();
|
||||||
let device_id = hello.device_id.clone();
|
let device_id = hello.device_id.clone();
|
||||||
@@ -305,6 +329,7 @@ async fn handle_connection(socket: WebSocket, state: RelayState, expected: Clien
|
|||||||
conn_id = conn_id.0,
|
conn_id = conn_id.0,
|
||||||
?kind,
|
?kind,
|
||||||
device_id = %device_id,
|
device_id = %device_id,
|
||||||
|
proto = hello.proto,
|
||||||
"连接握手通过,进入收发循环"
|
"连接握手通过,进入收发循环"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -545,4 +570,19 @@ mod tests {
|
|||||||
fn max_frame_is_one_mib() {
|
fn max_frame_is_one_mib() {
|
||||||
assert_eq!(MAX_FRAME_BYTES, 1024 * 1024);
|
assert_eq!(MAX_FRAME_BYTES, 1024 * 1024);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hello_proto_defaults_to_v1_when_absent() {
|
||||||
|
// 旧客户端不发 proto → 反序列化按 v1,保证向后兼容
|
||||||
|
let json = r#"{"kind":"device","device_id":"dev-1","token":"t"}"#;
|
||||||
|
let h: Hello = serde_json::from_str(json).expect("缺 proto 应可解析");
|
||||||
|
assert_eq!(h.proto, HELLO_PROTO_VERSION);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hello_proto_parsed_when_present() {
|
||||||
|
let json = r#"{"kind":"device","device_id":"dev-1","token":"t","proto":2}"#;
|
||||||
|
let h: Hello = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(h.proto, 2);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,6 +110,12 @@ fn ai_conversation_from_row(row: &Row<'_>) -> std::result::Result<AiConversation
|
|||||||
origin: row.get("origin").unwrap_or(None),
|
origin: row.get("origin").unwrap_or(None),
|
||||||
// boundary_json 列老库经 v45 迁移补建。unwrap_or(None) 兜底(NULL=未声明任务边界)。
|
// boundary_json 列老库经 v45 迁移补建。unwrap_or(None) 兜底(NULL=未声明任务边界)。
|
||||||
boundary_json: row.get("boundary_json").unwrap_or(None),
|
boundary_json: row.get("boundary_json").unwrap_or(None),
|
||||||
|
// loop_state 列老库经 v49 迁移补建。unwrap_or(None) 兜底(NULL=非任务会话)。
|
||||||
|
loop_state: row.get("loop_state").unwrap_or(None),
|
||||||
|
// status 列老库经 v50 迁移补建(NOT NULL DEFAULT 'active')。兜底迁移未跑/极端缺列时按 active。
|
||||||
|
status: row.get("status").unwrap_or_else(|_| "active".to_string()),
|
||||||
|
// working_context_json 列老库经 v53 迁移补建。unwrap_or(None) 兜底(NULL=无 WorkingContext)。
|
||||||
|
working_context_json: row.get("working_context_json").unwrap_or(None),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,6 +137,8 @@ fn ai_tool_execution_from_row(row: &Row<'_>) -> std::result::Result<AiToolExecut
|
|||||||
requested_at: row.get("requested_at")?,
|
requested_at: row.get("requested_at")?,
|
||||||
executed_at: row.get("executed_at")?,
|
executed_at: row.get("executed_at")?,
|
||||||
decided_by: row.get("decided_by")?,
|
decided_by: row.get("decided_by")?,
|
||||||
|
// duration_ms 列老库经 v50 迁移补建。unwrap_or(None) 兜底(NULL=老数据/未测路径)。
|
||||||
|
duration_ms: row.get("duration_ms").unwrap_or(None),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,25 +193,32 @@ impl_repo!(
|
|||||||
from_row => |row| ai_conversation_from_row(row),
|
from_row => |row| ai_conversation_from_row(row),
|
||||||
insert => |conn, rec| {
|
insert => |conn, rec| {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO ai_conversations (id, title, messages, provider_id, model, models, archived, pinned, prompt_tokens, completion_tokens, pinned_goals, pending_approvals, created_at, updated_at, origin, boundary_json)
|
"INSERT INTO ai_conversations (id, title, messages, provider_id, model, models, archived, pinned, prompt_tokens, completion_tokens, pinned_goals, pending_approvals, created_at, updated_at, origin, boundary_json, loop_state, status, working_context_json)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)",
|
||||||
params![
|
params![
|
||||||
rec.id, rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived,
|
rec.id, rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived,
|
||||||
if rec.pinned { 1i32 } else { 0i32 },
|
if rec.pinned { 1i32 } else { 0i32 },
|
||||||
rec.prompt_tokens, rec.completion_tokens,
|
rec.prompt_tokens, rec.completion_tokens,
|
||||||
rec.pinned_goals, rec.pending_approvals, rec.created_at, rec.updated_at, rec.origin,
|
rec.pinned_goals, rec.pending_approvals, rec.created_at, rec.updated_at, rec.origin,
|
||||||
rec.boundary_json
|
rec.boundary_json, rec.loop_state, rec.status, rec.working_context_json
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
update => |conn, rec| {
|
update => |conn, rec| {
|
||||||
|
// loop_state/status 有意不在 SET 列(V49/V50 生命周期标记独立更新,对齐 boundary 早期模式):
|
||||||
|
// update_full 的调用方(save_conversation 等)从 get_by_id 读旧记录再改元数据写回,
|
||||||
|
// 读改写窗口内 loop 生命周期/终局态可变更——若 SET 含二者,旧值会覆盖新值(lost-update)。
|
||||||
|
// 生命周期只经 set_loop_state / set_status 单点写。
|
||||||
|
// working_context_json 在 SET 列(V53):与 pinned_goals/boundary_json 同为 save 每轮
|
||||||
|
// 随行写的内存真相源快照,读改写窗口内无独立写入方,不存在 lost-update 面。
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE ai_conversations SET title = ?1, messages = ?2, provider_id = ?3, model = ?4, models = ?5, archived = ?6, pinned = ?7, prompt_tokens = ?8, completion_tokens = ?9, pinned_goals = ?10, pending_approvals = ?11, updated_at = ?12, boundary_json = ?13 WHERE id = ?14",
|
"UPDATE ai_conversations SET title = ?1, messages = ?2, provider_id = ?3, model = ?4, models = ?5, archived = ?6, pinned = ?7, prompt_tokens = ?8, completion_tokens = ?9, pinned_goals = ?10, pending_approvals = ?11, updated_at = ?12, boundary_json = ?13, working_context_json = ?14 WHERE id = ?15",
|
||||||
params![
|
params![
|
||||||
rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived,
|
rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived,
|
||||||
if rec.pinned { 1i32 } else { 0i32 },
|
if rec.pinned { 1i32 } else { 0i32 },
|
||||||
rec.prompt_tokens, rec.completion_tokens,
|
rec.prompt_tokens, rec.completion_tokens,
|
||||||
rec.pinned_goals, rec.pending_approvals, rec.updated_at, rec.boundary_json, rec.id
|
rec.pinned_goals, rec.pending_approvals, rec.updated_at, rec.boundary_json,
|
||||||
|
rec.working_context_json, rec.id
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -243,22 +258,22 @@ impl_repo!(
|
|||||||
from_row => |row| ai_tool_execution_from_row(row),
|
from_row => |row| ai_tool_execution_from_row(row),
|
||||||
insert => |conn, rec| {
|
insert => |conn, rec| {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO ai_tool_executions (id, conversation_id, message_id, tool_call_id, tool_name, arguments, result, status, risk_level, requested_at, executed_at, decided_by)
|
"INSERT INTO ai_tool_executions (id, conversation_id, message_id, tool_call_id, tool_name, arguments, result, status, risk_level, requested_at, executed_at, decided_by, duration_ms)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||||||
params![
|
params![
|
||||||
rec.id, rec.conversation_id, rec.message_id, &*rec.tool_call_id, rec.tool_name,
|
rec.id, rec.conversation_id, rec.message_id, &*rec.tool_call_id, rec.tool_name,
|
||||||
rec.arguments, rec.result, rec.status, rec.risk_level,
|
rec.arguments, rec.result, rec.status, rec.risk_level,
|
||||||
rec.requested_at, rec.executed_at, rec.decided_by
|
rec.requested_at, rec.executed_at, rec.decided_by, rec.duration_ms
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
update => |conn, rec| {
|
update => |conn, rec| {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE ai_tool_executions SET conversation_id = ?1, message_id = ?2, tool_call_id = ?3, tool_name = ?4, arguments = ?5, result = ?6, status = ?7, risk_level = ?8, requested_at = ?9, executed_at = ?10, decided_by = ?11 WHERE id = ?12",
|
"UPDATE ai_tool_executions SET conversation_id = ?1, message_id = ?2, tool_call_id = ?3, tool_name = ?4, arguments = ?5, result = ?6, status = ?7, risk_level = ?8, requested_at = ?9, executed_at = ?10, decided_by = ?11, duration_ms = ?12 WHERE id = ?13",
|
||||||
params![
|
params![
|
||||||
rec.conversation_id, rec.message_id, &*rec.tool_call_id, rec.tool_name,
|
rec.conversation_id, rec.message_id, &*rec.tool_call_id, rec.tool_name,
|
||||||
rec.arguments, rec.result, rec.status, rec.risk_level,
|
rec.arguments, rec.result, rec.status, rec.risk_level,
|
||||||
rec.requested_at, rec.executed_at, rec.decided_by, rec.id
|
rec.requested_at, rec.executed_at, rec.decided_by, rec.duration_ms, rec.id
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -292,15 +307,15 @@ impl AiToolExecutionRepo {
|
|||||||
let tx = guard.transaction().map_err(storage_err)?;
|
let tx = guard.transaction().map_err(storage_err)?;
|
||||||
{
|
{
|
||||||
let mut stmt = tx.prepare(
|
let mut stmt = tx.prepare(
|
||||||
"INSERT INTO ai_tool_executions (id, conversation_id, message_id, tool_call_id, tool_name, arguments, result, status, risk_level, requested_at, executed_at, decided_by)
|
"INSERT INTO ai_tool_executions (id, conversation_id, message_id, tool_call_id, tool_name, arguments, result, status, risk_level, requested_at, executed_at, decided_by, duration_ms)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||||||
)
|
)
|
||||||
.map_err(storage_err)?;
|
.map_err(storage_err)?;
|
||||||
for rec in &records {
|
for rec in &records {
|
||||||
stmt.execute(params![
|
stmt.execute(params![
|
||||||
rec.id, rec.conversation_id, rec.message_id, &*rec.tool_call_id, rec.tool_name,
|
rec.id, rec.conversation_id, rec.message_id, &*rec.tool_call_id, rec.tool_name,
|
||||||
rec.arguments, rec.result, rec.status, rec.risk_level,
|
rec.arguments, rec.result, rec.status, rec.risk_level,
|
||||||
rec.requested_at, rec.executed_at, rec.decided_by
|
rec.requested_at, rec.executed_at, rec.decided_by, rec.duration_ms
|
||||||
])
|
])
|
||||||
.map_err(storage_err)?;
|
.map_err(storage_err)?;
|
||||||
}
|
}
|
||||||
@@ -809,6 +824,95 @@ impl AiConversationRepo {
|
|||||||
.map_err(storage_err)?
|
.map_err(storage_err)?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// loop 生命周期标记写入(V49 loop_state,断点续跑底座)。
|
||||||
|
///
|
||||||
|
/// state: Some("running")=任务 loop spawn 时置起;Some("parked")=重启扫描改写;
|
||||||
|
/// None=正常收敛/用户停止时清理。纯元数据标记不进 update_full(防 save_conversation
|
||||||
|
/// 高频全量写覆盖 loop 生命周期语义),对齐 set_boundary_json/set_archived 模式。
|
||||||
|
/// 不动 updated_at(纯标记)。对话未落库(懒创建)时影响 0 行,调用方忽略。
|
||||||
|
pub async fn set_loop_state(&self, id: &str, state: Option<&str>) -> Result<bool> {
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
let id = id.to_owned();
|
||||||
|
let value = state.map(|s| s.to_owned());
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
let affected = guard
|
||||||
|
.execute(
|
||||||
|
"UPDATE ai_conversations SET loop_state = ?1 WHERE id = ?2",
|
||||||
|
params![value, id],
|
||||||
|
)
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
Ok(affected > 0)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 会话终局态写入(V50 status)。
|
||||||
|
///
|
||||||
|
/// status: "active"(默认/进行中/正常完成)、"incomplete"(停靠/断流等未完成终局)、
|
||||||
|
/// "error"(循环错误终局)。纯标记不进 update_full(防 save_conversation 高频全量写覆盖),
|
||||||
|
/// 对齐 set_loop_state 模式;不动 updated_at。对话未落库(懒创建)时影响 0 行,调用方忽略。
|
||||||
|
pub async fn set_status(&self, id: &str, status: &str) -> Result<bool> {
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
let id = id.to_owned();
|
||||||
|
let status = status.to_owned();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
let affected = guard
|
||||||
|
.execute(
|
||||||
|
"UPDATE ai_conversations SET status = ?1 WHERE id = ?2",
|
||||||
|
params![status, id],
|
||||||
|
)
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
Ok(affected > 0)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重启扫描:把所有 loop_state='running' 残留改写为 'parked'。
|
||||||
|
///
|
||||||
|
/// 进程启动时调用一次:上次运行中 loop_state=running 的会话,进程重启后 loop 必死
|
||||||
|
/// (内存态不复存在),残留 running 是假活。改写 parked 后侧栏读 list_recoverable
|
||||||
|
/// 展示「任务中断可恢复」。返回改写条数(0=无中断任务,静默)。
|
||||||
|
pub async fn mark_running_as_parked(&self) -> Result<u64> {
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
let affected = guard
|
||||||
|
.execute(
|
||||||
|
"UPDATE ai_conversations SET loop_state = 'parked' WHERE loop_state = 'running'",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
Ok(affected as u64)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 列出可恢复会话(loop_state='parked',重启中断待一键恢复)。
|
||||||
|
pub async fn list_parked(&self) -> Result<Vec<AiConversationRecord>> {
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
let mut stmt = guard
|
||||||
|
.prepare(
|
||||||
|
"SELECT * FROM ai_conversations WHERE loop_state = 'parked' ORDER BY updated_at DESC",
|
||||||
|
)
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([], ai_conversation_from_row)
|
||||||
|
.map_err(storage_err)?
|
||||||
|
.collect::<std::result::Result<Vec<_>, _>>()
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
Ok(rows)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
|
|
||||||
/// G1.3: 删除对话 + 其全部 ai_messages 子行(单事务原子)。
|
/// G1.3: 删除对话 + 其全部 ai_messages 子行(单事务原子)。
|
||||||
///
|
///
|
||||||
/// 背景:原宏生成 `delete` 只删 ai_conversations 主行,而 ai_messages 表无外键级联
|
/// 背景:原宏生成 `delete` 只删 ai_conversations 主行,而 ai_messages 表无外键级联
|
||||||
@@ -948,6 +1052,7 @@ mod tests {
|
|||||||
requested_at: "0".into(),
|
requested_at: "0".into(),
|
||||||
executed_at: None,
|
executed_at: None,
|
||||||
decided_by: None,
|
decided_by: None,
|
||||||
|
duration_ms: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -996,6 +1101,43 @@ mod tests {
|
|||||||
assert_eq!(got2.unwrap().id, "a2", "同三元组应返回最新落定记录");
|
assert_eq!(got2.unwrap().id, "a2", "同三元组应返回最新落定记录");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// D2a 结果外置的读写兼容契约:result 列由外置层(audit 侧)决定写内联还是写句柄,
|
||||||
|
/// 存储层既不解释也不改写——老行(外置落地前写入)是内联全文,读回必须原样;新行的
|
||||||
|
/// 外置句柄 JSON 同样原样存取。锁死「旧行仍读表内全文」:存储层若对 result 做截断/
|
||||||
|
/// 外置/解析等 mutation,会同时破坏两侧兼容(旧行读残缺 + 句柄被误当正文)。
|
||||||
|
#[tokio::test]
|
||||||
|
async fn tool_execution_result_stored_verbatim_old_and_new() {
|
||||||
|
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||||
|
let repo = AiToolExecutionRepo::new(&db);
|
||||||
|
|
||||||
|
// 旧数据:内联全文。超大值也要能原样读回(D2a 落地前的历史大结果就长这样,存储层不得追改)
|
||||||
|
let legacy_big = "A".repeat(10 * 1024);
|
||||||
|
let mut old_big = audit_rec("old_big", "conv1", "call_old_big", "read_file", "{}", "completed");
|
||||||
|
old_big.result = Some(legacy_big.clone());
|
||||||
|
repo.insert(old_big).await.expect("insert old big");
|
||||||
|
let mut old_small = audit_rec("old_small", "conv2", "call_old_small", "write_file", "{}", "completed");
|
||||||
|
old_small.result = Some("{\"ok\":true}".to_string());
|
||||||
|
repo.insert(old_small).await.expect("insert old small");
|
||||||
|
|
||||||
|
// 新数据:外置句柄 JSON(存储层不解析,当普通 TEXT 存取)
|
||||||
|
let handle = r#"{"__offloaded__":true,"path":"/tmp/x.txt","size_bytes":20480}"#;
|
||||||
|
let mut new_off = audit_rec("new_off", "conv3", "call_new", "run_command", "{}", "completed");
|
||||||
|
new_off.result = Some(handle.to_string());
|
||||||
|
repo.insert(new_off).await.expect("insert new offloaded");
|
||||||
|
|
||||||
|
// 读回:三条各自原样(旧行全文;句柄不被误解析/清空)
|
||||||
|
let got_old_big = repo.get_by_id("old_big").await.expect("get").expect("row");
|
||||||
|
assert_eq!(got_old_big.result.as_deref(), Some(legacy_big.as_str()), "老行超大内联 result 须原样读回");
|
||||||
|
let got_old_small = repo.get_by_id("old_small").await.expect("get").expect("row");
|
||||||
|
assert_eq!(got_old_small.result.as_deref(), Some("{\"ok\":true}"));
|
||||||
|
let got_new = repo.get_by_id("new_off").await.expect("get").expect("row");
|
||||||
|
assert_eq!(got_new.result.as_deref(), Some(handle), "外置句柄 JSON 须原样存取");
|
||||||
|
|
||||||
|
// 专用查询路径(按 tool_call_id 定位)同样原样,与通用 get_by_id 口径一致
|
||||||
|
let by_tc = repo.find_by_tool_call_id("call_old_big").await.expect("query").expect("row");
|
||||||
|
assert_eq!(by_tc.result.as_deref(), Some(legacy_big.as_str()));
|
||||||
|
}
|
||||||
|
|
||||||
/// insert_checkpoint 冲突语义:同 id 二次写入 Ok 不报错(幂等跳过,affected=0 走 warn),
|
/// insert_checkpoint 冲突语义:同 id 二次写入 Ok 不报错(幂等跳过,affected=0 走 warn),
|
||||||
/// 保留首次 snapshot。锁死 OR IGNORE 行为,防未来误改成 REPLACE 静默覆盖快照。
|
/// 保留首次 snapshot。锁死 OR IGNORE 行为,防未来误改成 REPLACE 静默覆盖快照。
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -1022,4 +1164,138 @@ mod tests {
|
|||||||
.expect("snapshot");
|
.expect("snapshot");
|
||||||
assert_eq!(snapshot, r#"[{"seq":0}]"#, "冲突时应保留首次快照");
|
assert_eq!(snapshot, r#"[{"seq":0}]"#, "冲突时应保留首次快照");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #904 契约:set_loop_state 写 "parked" 后 get_by_id 可读回(停靠出口写侧单点)。
|
||||||
|
/// 锁死「停靠状态写入收敛进 emit_task_parked」依赖的 repo 能力——若 set_loop_state
|
||||||
|
/// 或 from_row 的 loop_state 映射回归,停靠卡「继续」将再次撞「AI 未在暂停态」。
|
||||||
|
#[tokio::test]
|
||||||
|
async fn set_loop_state_parked_roundtrip() {
|
||||||
|
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||||
|
let repo = AiConversationRepo::new(&db);
|
||||||
|
repo.insert(AiConversationRecord {
|
||||||
|
id: "c1".into(),
|
||||||
|
title: None,
|
||||||
|
messages: "[]".into(),
|
||||||
|
provider_id: None,
|
||||||
|
model: None,
|
||||||
|
models: None,
|
||||||
|
archived: false,
|
||||||
|
pinned: false,
|
||||||
|
prompt_tokens: Some(0),
|
||||||
|
completion_tokens: Some(0),
|
||||||
|
pinned_goals: None,
|
||||||
|
pending_approvals: None,
|
||||||
|
created_at: "0".into(),
|
||||||
|
updated_at: "0".into(),
|
||||||
|
origin: None,
|
||||||
|
boundary_json: None,
|
||||||
|
loop_state: None,
|
||||||
|
status: "active".into(),
|
||||||
|
working_context_json: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("insert");
|
||||||
|
|
||||||
|
assert!(repo.set_loop_state("c1", Some("parked")).await.expect("set"));
|
||||||
|
let got = repo.get_by_id("c1").await.expect("get").expect("row");
|
||||||
|
assert_eq!(got.loop_state.as_deref(), Some("parked"), "写 parked 应可读回");
|
||||||
|
|
||||||
|
// 清态(running 收敛出口)可读回 None
|
||||||
|
assert!(repo.set_loop_state("c1", None).await.expect("clear"));
|
||||||
|
let cleared = repo.get_by_id("c1").await.expect("get").expect("row");
|
||||||
|
assert_eq!(cleared.loop_state, None, "清 loop_state 应可读回 None");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// V50 契约:set_status 写终局态后可读回,且 insert 默认 active。
|
||||||
|
/// 锁死「会话终局态」建模能力,防 from_row 映射或 insert 列回归致默认值丢失。
|
||||||
|
#[tokio::test]
|
||||||
|
async fn set_status_roundtrip_and_default_active() {
|
||||||
|
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||||
|
let repo = AiConversationRepo::new(&db);
|
||||||
|
repo.insert(AiConversationRecord {
|
||||||
|
id: "c1".into(),
|
||||||
|
title: None,
|
||||||
|
messages: "[]".into(),
|
||||||
|
provider_id: None,
|
||||||
|
model: None,
|
||||||
|
models: None,
|
||||||
|
archived: false,
|
||||||
|
pinned: false,
|
||||||
|
prompt_tokens: Some(0),
|
||||||
|
completion_tokens: Some(0),
|
||||||
|
pinned_goals: None,
|
||||||
|
pending_approvals: None,
|
||||||
|
created_at: "0".into(),
|
||||||
|
updated_at: "0".into(),
|
||||||
|
origin: None,
|
||||||
|
boundary_json: None,
|
||||||
|
loop_state: None,
|
||||||
|
status: "active".into(),
|
||||||
|
working_context_json: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("insert");
|
||||||
|
|
||||||
|
let got = repo.get_by_id("c1").await.expect("get").expect("row");
|
||||||
|
assert_eq!(got.status, "active", "默认终局态应为 active");
|
||||||
|
|
||||||
|
assert!(repo.set_status("c1", "incomplete").await.expect("set"));
|
||||||
|
let got2 = repo.get_by_id("c1").await.expect("get").expect("row");
|
||||||
|
assert_eq!(got2.status, "incomplete", "写 incomplete 应可读回");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// V53 契约:working_context_json 经 insert → get_by_id → update_full 全程原样存取
|
||||||
|
/// (存储层按不透明 TEXT,结构解析归调用方);老库行(缺列插入)读回 None 不炸。
|
||||||
|
#[tokio::test]
|
||||||
|
async fn working_context_json_roundtrip_and_legacy_null() {
|
||||||
|
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||||
|
let repo = AiConversationRepo::new(&db);
|
||||||
|
let wc_json = r#"{"goals":[{"id":"t1","text":"修登录bug","status":"Active"}],"decisions":[],"unresolved":["端口未定"],"current_step":null,"version":{"goals_changed_at":1,"decisions_changed_at":0,"unresolved_changed_at":0,"step_changed_at":0,"current_turn":1}}"#;
|
||||||
|
|
||||||
|
let mut rec = AiConversationRecord {
|
||||||
|
id: "c1".into(),
|
||||||
|
title: None,
|
||||||
|
messages: "[]".into(),
|
||||||
|
provider_id: None,
|
||||||
|
model: None,
|
||||||
|
models: None,
|
||||||
|
archived: false,
|
||||||
|
pinned: false,
|
||||||
|
prompt_tokens: Some(0),
|
||||||
|
completion_tokens: Some(0),
|
||||||
|
pinned_goals: None,
|
||||||
|
pending_approvals: None,
|
||||||
|
created_at: "0".into(),
|
||||||
|
updated_at: "0".into(),
|
||||||
|
origin: None,
|
||||||
|
boundary_json: None,
|
||||||
|
loop_state: None,
|
||||||
|
status: "active".into(),
|
||||||
|
working_context_json: Some(wc_json.into()),
|
||||||
|
};
|
||||||
|
repo.insert(rec.clone()).await.expect("insert");
|
||||||
|
let got = repo.get_by_id("c1").await.expect("get").expect("row");
|
||||||
|
assert_eq!(got.working_context_json.as_deref(), Some(wc_json), "insert 后应原样读回");
|
||||||
|
|
||||||
|
// update_full 全量写(save_conversation 路径)后列随行更新
|
||||||
|
rec.title = Some("t".into());
|
||||||
|
rec.working_context_json = Some(r#"{"goals":[],"decisions":[],"unresolved":[],"current_step":null,"version":{"goals_changed_at":0,"decisions_changed_at":0,"unresolved_changed_at":0,"step_changed_at":0,"current_turn":0}}"#.into());
|
||||||
|
repo.update_full(&rec).await.expect("update");
|
||||||
|
let got2 = repo.get_by_id("c1").await.expect("get").expect("row");
|
||||||
|
assert!(got2.working_context_json.as_deref().unwrap_or_default().contains(r#""goals":[]"#), "update_full 后应写新快照");
|
||||||
|
|
||||||
|
// 老库兼容:绕过 repo 直插不带该列的行(模拟 V53 前老数据)→ 读回 None
|
||||||
|
{
|
||||||
|
let conn = db.conn();
|
||||||
|
let g = conn.lock().await;
|
||||||
|
g.execute(
|
||||||
|
"INSERT INTO ai_conversations (id, messages, created_at, updated_at, status) \
|
||||||
|
VALUES ('legacy', '[]', '0', '0', 'active')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.expect("raw insert legacy row");
|
||||||
|
}
|
||||||
|
let legacy = repo.get_by_id("legacy").await.expect("get").expect("legacy row");
|
||||||
|
assert!(legacy.working_context_json.is_none(), "老库行应为 None(无 WorkingContext)");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -547,16 +547,24 @@ impl IdeaRepo {
|
|||||||
.transaction()
|
.transaction()
|
||||||
.map_err(storage_err)?;
|
.map_err(storage_err)?;
|
||||||
|
|
||||||
// 1. 读主体当前 related_ids
|
// 1. 读主体当前 related_ids — 同段校验主体存在且未软删(deleted_at IS NULL)。
|
||||||
|
// 治走查🟡:原仅读 related_ids,主体不存在/已软删时步骤 5 UPDATE 静默 affected=0
|
||||||
|
// 仍提交,而步骤 3/4 已改写目标侧 related_ids → 「目标有关联、主体无记录」单向脏关联。
|
||||||
|
// 查无立即 Err 回滚整个事务,目标侧零改写。
|
||||||
let old_raw: Option<String> = tx
|
let old_raw: Option<String> = tx
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT related_ids FROM ideas WHERE id = ?1",
|
"SELECT related_ids FROM ideas WHERE id = ?1 AND deleted_at IS NULL",
|
||||||
params![subject_id],
|
params![subject_id],
|
||||||
|row| row.get(0),
|
|row| row.get(0),
|
||||||
)
|
)
|
||||||
.optional()
|
.optional()
|
||||||
.map_err(storage_err)?
|
.map_err(storage_err)?
|
||||||
.flatten();
|
.flatten()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
df_types::error::Error::Storage(format!(
|
||||||
|
"灵感 {subject_id} 不存在或已删除,无法建立关联"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
// 2. 解析新旧集合
|
// 2. 解析新旧集合
|
||||||
let old_set: std::collections::HashSet<String> =
|
let old_set: std::collections::HashSet<String> =
|
||||||
@@ -815,6 +823,82 @@ impl KnowledgeRepo {
|
|||||||
.map_err(storage_err)?
|
.map_err(storage_err)?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 项目维度软排序检索(跨会话召回最小闭环,D2 拍板:软排序非硬过滤)。
|
||||||
|
///
|
||||||
|
/// 排序语义:同项目(source_project = ?)第一档 > 全局(IS NULL)第二档 > 异项目第三档;
|
||||||
|
/// 同档内保持原有 title 命中分 → reuse_count → created_at 顺序。`limit` 不变只改顺序,
|
||||||
|
/// token 膨胀为零。`project_id = None` 时行为与 [`Self::search_keywords`] 逐字一致(回归门)。
|
||||||
|
///
|
||||||
|
/// 与 [`Self::search_keywords`] 的关系:后者是本方法的 project_id=None 特例(旧签名
|
||||||
|
/// 保留给 MCP 等外部调用方,内部转调本方法,零重复实现)。
|
||||||
|
pub async fn search_keywords_scoped(
|
||||||
|
&self,
|
||||||
|
keywords: &[String],
|
||||||
|
include_candidates: bool,
|
||||||
|
limit: usize,
|
||||||
|
project_id: Option<&str>,
|
||||||
|
) -> Result<Vec<KnowledgeRecord>> {
|
||||||
|
if project_id.is_none() {
|
||||||
|
return self.search_keywords(keywords, include_candidates, limit).await;
|
||||||
|
}
|
||||||
|
if keywords.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let project_id = project_id.unwrap().to_string();
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
// 每个关键词占一个编号占位符(1..n);project_id 占 ?(n+1),WHERE 的 ORDER BY CASE
|
||||||
|
// 与三档投影各复用同号(SQLite 允许 ?N 复用)。patterns 只装关键词一份。
|
||||||
|
let mut patterns: Vec<String> = Vec::with_capacity(keywords.len());
|
||||||
|
let mut where_parts: Vec<String> = Vec::with_capacity(keywords.len());
|
||||||
|
let mut order_terms: Vec<String> = Vec::with_capacity(keywords.len() + 1);
|
||||||
|
for (i, kw) in keywords.iter().enumerate() {
|
||||||
|
let n = i + 1;
|
||||||
|
where_parts.push(format!(
|
||||||
|
"(title LIKE ?{n} ESCAPE '|' OR content LIKE ?{n} ESCAPE '|')"
|
||||||
|
));
|
||||||
|
order_terms.push(format!("CASE WHEN title LIKE ?{n} ESCAPE '|' THEN 1 ELSE 0 END"));
|
||||||
|
patterns.push(format!("%{}%", escape_like(kw)));
|
||||||
|
}
|
||||||
|
// 项目档位表达式复用 ?(n+1):0=同项目 1=全局(NULL) 2=异项目
|
||||||
|
// 档位是**第一排序键**(ASC:0 最前),title 命分是同档内第二键(DESC)——
|
||||||
|
// 若档位与 title 分相加,档位会被 title 分稀释(异项目 title 高分压过同项目)。
|
||||||
|
let pid_n = keywords.len() + 1;
|
||||||
|
let project_tier =
|
||||||
|
format!("CASE WHEN source_project = ?{pid_n} THEN 0 WHEN source_project IS NULL THEN 1 ELSE 2 END");
|
||||||
|
let title_terms = order_terms.join(" + ");
|
||||||
|
let status_clause = if include_candidates {
|
||||||
|
"status IN ('published','candidate')"
|
||||||
|
} else {
|
||||||
|
"status = 'published'"
|
||||||
|
};
|
||||||
|
let limit_i = limit as i64;
|
||||||
|
let sql = format!(
|
||||||
|
"SELECT {KNOWLEDGE_COLS} FROM knowledges \
|
||||||
|
WHERE {status_clause} AND ({}) \
|
||||||
|
ORDER BY ({project_tier}) ASC, ({title_terms}) DESC, reuse_count DESC, created_at DESC LIMIT ?{}",
|
||||||
|
where_parts.join(" OR "),
|
||||||
|
pid_n + 1,
|
||||||
|
);
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
let mut param_refs: Vec<&dyn rusqlite::ToSql> =
|
||||||
|
patterns.iter().map(|p| p as &dyn rusqlite::ToSql).collect();
|
||||||
|
param_refs.push(&project_id);
|
||||||
|
param_refs.push(&limit_i);
|
||||||
|
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map(param_refs.as_slice(), |row| knowledge_from_row(row))
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
let mut results = Vec::new();
|
||||||
|
for r in rows {
|
||||||
|
results.push(r.map_err(storage_err)?);
|
||||||
|
}
|
||||||
|
Ok(results)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
|
|
||||||
/// 按状态列出(审核收件箱用): 按 confidence 语义排序(high>medium>low),次按 created_at
|
/// 按状态列出(审核收件箱用): 按 confidence 语义排序(high>medium>low),次按 created_at
|
||||||
///
|
///
|
||||||
/// 用 CASE WHEN 替代纯 TEXT 排序(字典序 high>low>medium 非预期语义)。
|
/// 用 CASE WHEN 替代纯 TEXT 排序(字典序 high>low>medium 非预期语义)。
|
||||||
@@ -1094,6 +1178,74 @@ impl KnowledgeRepo {
|
|||||||
.await
|
.await
|
||||||
.map_err(storage_err)?
|
.map_err(storage_err)?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 知识路标读侧(devflow.md 投影通道):已发布且(人工验证 或 复用达标)的条目选摘。
|
||||||
|
///
|
||||||
|
/// 与检索族(search/search_keywords)的差别:路标无查询词,是「库里有什么」的静态选摘,
|
||||||
|
/// 供 system prompt 路标段铺开让 agent 知晓库藏、想起检索。入选门槛 = published 且
|
||||||
|
/// (verified = 1 OR reuse_count >= 3);排序 = 复用降序(唯一客观信号,对齐 search/top_used),
|
||||||
|
/// 同复用按 created_at 降序定稳定次序。verified 只作入选不作排序(复用是更强的实战信号)。
|
||||||
|
///
|
||||||
|
/// 两档拼接:项目档(source_project = project_id,上限 project_limit)在前——会话归属
|
||||||
|
/// 项目的知识最相关;跨项目补充档(非本项目含无归属条目,上限 global_limit)在后。
|
||||||
|
/// 两档 WHERE 互斥(= pid vs IS NULL/≠ pid),结果天然无重复。
|
||||||
|
/// project_id = None → 仅补充档(会话未解析出归属项目,项目档无从谈起)。
|
||||||
|
pub async fn list_waymark(
|
||||||
|
&self,
|
||||||
|
project_id: Option<&str>,
|
||||||
|
project_limit: usize,
|
||||||
|
global_limit: usize,
|
||||||
|
) -> Result<Vec<KnowledgeRecord>> {
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
let project_id = project_id.map(|s| s.to_owned());
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
// 入选门槛与排序两档共用(verified 在 SQLite 存 0/1;复用达标压线 3 即入选)
|
||||||
|
let qualify = "status = 'published' AND (verified = 1 OR reuse_count >= 3)";
|
||||||
|
let order = " ORDER BY reuse_count DESC, created_at DESC LIMIT ";
|
||||||
|
let mut results = Vec::new();
|
||||||
|
|
||||||
|
// 项目档:会话归属项目内的合格条目(复用降序,上限 project_limit)
|
||||||
|
if let Some(pid) = &project_id {
|
||||||
|
let sql = format!(
|
||||||
|
"SELECT {KNOWLEDGE_COLS} FROM knowledges WHERE {qualify} AND source_project = ?1{order}?2"
|
||||||
|
);
|
||||||
|
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map(params![pid, project_limit as i64], knowledge_from_row)
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
for r in rows {
|
||||||
|
results.push(r.map_err(storage_err)?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跨项目补充档:非本项目(含无归属全局条目)按复用降序补足 global_limit;
|
||||||
|
// WHERE 与项目档互斥 → 天然去重。project_id = None 时无排他条件,即全量合格条目。
|
||||||
|
// LIMIT 占位符编号随排他条件有无而变(?1 空档排他时归 LIMIT,有排他时排他占 ?1、LIMIT 占 ?2)
|
||||||
|
let exclude = if project_id.is_some() {
|
||||||
|
" AND (source_project IS NULL OR source_project != ?1)"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
let lim_n = if project_id.is_some() { 2 } else { 1 };
|
||||||
|
let sql = format!("SELECT {KNOWLEDGE_COLS} FROM knowledges WHERE {qualify}{exclude}{order}?{lim_n}");
|
||||||
|
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
|
||||||
|
let rows = match &project_id {
|
||||||
|
Some(pid) => stmt
|
||||||
|
.query_map(params![pid, global_limit as i64], knowledge_from_row)
|
||||||
|
.map_err(storage_err)?,
|
||||||
|
None => stmt
|
||||||
|
.query_map(params![global_limit as i64], knowledge_from_row)
|
||||||
|
.map_err(storage_err)?,
|
||||||
|
};
|
||||||
|
for r in rows {
|
||||||
|
results.push(r.map_err(storage_err)?);
|
||||||
|
}
|
||||||
|
Ok(results)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl_repo!(
|
impl_repo!(
|
||||||
@@ -1544,6 +1696,76 @@ mod tests {
|
|||||||
assert_eq!(hits[0].id, "pub1");
|
assert_eq!(hits[0].id, "pub1");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- search_keywords_scoped(跨会话召回 D2 软排序) ----------
|
||||||
|
|
||||||
|
/// 同项目知识排前:同档内 title 命分定序,异档同项目压过异项目高复用
|
||||||
|
#[tokio::test]
|
||||||
|
async fn scoped_same_project_ranks_above_foreign_despite_reuse() {
|
||||||
|
let repo = setup_repo().await;
|
||||||
|
// 外项目知识:标题命中 + 复用 999(档位应压过复用)
|
||||||
|
let mut foreign = krec("foreign", "Rust 所有权", "...", "lesson", "published", None, 999);
|
||||||
|
foreign.source_project = Some("other-proj".to_string());
|
||||||
|
repo.insert(foreign).await.unwrap();
|
||||||
|
// 同项目(proj-1,krec 默认):内容命中 + 复用 1
|
||||||
|
repo.insert(krec("same", "无关标题", "聊聊 Rust 所有权", "lesson", "published", None, 1))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let kws = vec!["所有权".to_string(), "Rust".to_string()];
|
||||||
|
let hits = repo
|
||||||
|
.search_keywords_scoped(&kws, false, 10, Some("proj-1"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(hits.len(), 2);
|
||||||
|
assert_eq!(hits[0].id, "same", "同项目知识应压过异项目(即便后者 title 命中+高复用)");
|
||||||
|
assert_eq!(hits[1].id, "foreign");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 全局知识(NULL)居中档:同项目 > 全局 > 异项目
|
||||||
|
#[tokio::test]
|
||||||
|
async fn scoped_global_null_tier_between_same_and_foreign() {
|
||||||
|
let repo = setup_repo().await;
|
||||||
|
let mut foreign = krec("foreign", "Rust 异步", "...", "lesson", "published", None, 1);
|
||||||
|
foreign.source_project = Some("other-proj".to_string());
|
||||||
|
repo.insert(foreign).await.unwrap();
|
||||||
|
let mut global = krec("global", "Rust 生命周期", "...", "lesson", "published", None, 1);
|
||||||
|
global.source_project = None;
|
||||||
|
repo.insert(global).await.unwrap();
|
||||||
|
repo.insert(krec("same", "Rust 借用", "...", "lesson", "published", None, 1))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let kws = vec!["Rust".to_string()];
|
||||||
|
let hits = repo
|
||||||
|
.search_keywords_scoped(&kws, false, 10, Some("proj-1"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let ids: Vec<_> = hits.iter().map(|h| h.id.as_str()).collect();
|
||||||
|
assert_eq!(ids, vec!["same", "global", "foreign"], "三档顺序: 同项目>全局>异项目");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 回归门:project_id=None 与旧 search_keywords 行为逐字一致(同输入同顺序)
|
||||||
|
#[tokio::test]
|
||||||
|
async fn scoped_none_project_matches_legacy_search_keywords() {
|
||||||
|
let repo = setup_repo().await;
|
||||||
|
let mut foreign = krec("foreign", "Rust 异步", "...", "lesson", "published", None, 50);
|
||||||
|
foreign.source_project = Some("other-proj".to_string());
|
||||||
|
repo.insert(foreign).await.unwrap();
|
||||||
|
repo.insert(krec("same", "Rust 借用", "...", "lesson", "published", None, 1))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let kws = vec!["Rust".to_string()];
|
||||||
|
let legacy = repo.search_keywords(&kws, false, 10).await.unwrap();
|
||||||
|
let scoped = repo
|
||||||
|
.search_keywords_scoped(&kws, false, 10, None)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let a: Vec<_> = legacy.iter().map(|h| h.id.as_str()).collect();
|
||||||
|
let b: Vec<_> = scoped.iter().map(|h| h.id.as_str()).collect();
|
||||||
|
assert_eq!(a, b, "project_id=None 应与旧路径行为逐字一致(回归门)");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn search_keywords_like_wildcards_escaped() {
|
async fn search_keywords_like_wildcards_escaped() {
|
||||||
let repo = setup_repo().await;
|
let repo = setup_repo().await;
|
||||||
@@ -1561,6 +1783,107 @@ mod tests {
|
|||||||
assert_eq!(hits[0].id, "k1");
|
assert_eq!(hits[0].id, "k1");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- 知识路标读侧(list_waymark,devflow.md 投影通道) ----------
|
||||||
|
|
||||||
|
/// 入选门槛:published 且(verified 或 复用达标);candidate/archived/复用不足均淘汰
|
||||||
|
#[tokio::test]
|
||||||
|
async fn waymark_qualifies_verified_or_reused_published_only() {
|
||||||
|
let repo = setup_repo().await;
|
||||||
|
// verified=true 复用 0 → 入选(验证豁免复用门槛)
|
||||||
|
let mut v = krec("v", "已验证条目", "...", "lesson", "published", None, 0);
|
||||||
|
v.verified = true;
|
||||||
|
repo.insert(v).await.unwrap();
|
||||||
|
// 未验证复用 3 → 入选(压线达标)
|
||||||
|
repo.insert(krec("r3", "复用达标条目", "...", "lesson", "published", None, 3))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
// 未验证复用 2 → 淘汰
|
||||||
|
repo.insert(krec("r2", "复用不足条目", "...", "lesson", "published", None, 2))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
// 复用再高,状态非 published(candidate)→ 淘汰
|
||||||
|
repo.insert(krec("cand", "候选未发布", "...", "lesson", "candidate", None, 99))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
// archived → 淘汰
|
||||||
|
repo.insert(krec("arch", "已归档", "...", "lesson", "archived", None, 99))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let hits = repo.list_waymark(None, 20, 10).await.unwrap();
|
||||||
|
let ids: Vec<_> = hits.iter().map(|h| h.id.as_str()).collect();
|
||||||
|
assert_eq!(ids.len(), 2, "只应入选 已验证 + 复用达标 两条");
|
||||||
|
assert!(ids.contains(&"v") && ids.contains(&"r3"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 两档序:项目档在前(复用降序,verified 豁免复用但仍按复用参与排序),
|
||||||
|
/// 跨项目补充档在后(复用降序);异项目高复用压不过同项目低复用(档位优先于复用)
|
||||||
|
#[tokio::test]
|
||||||
|
async fn waymark_project_tier_first_global_supplement_after() {
|
||||||
|
let repo = setup_repo().await;
|
||||||
|
repo.insert(krec("p9", "同项目复用9", "...", "lesson", "published", None, 9))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
repo.insert(krec("p5", "同项目复用5", "...", "lesson", "published", None, 5))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
// 同项目 verified 但零复用:入选项目档,排序落在复用条目之后
|
||||||
|
let mut pv = krec("pv", "同项目已验证零复用", "...", "lesson", "published", None, 0);
|
||||||
|
pv.verified = true;
|
||||||
|
repo.insert(pv).await.unwrap();
|
||||||
|
// 异项目高复用 → 补充档
|
||||||
|
let mut f = krec("f99", "异项目复用99", "...", "lesson", "published", None, 99);
|
||||||
|
f.source_project = Some("proj-2".to_string());
|
||||||
|
repo.insert(f).await.unwrap();
|
||||||
|
// 无归属全局条目 → 补充档
|
||||||
|
let mut g = krec("g10", "全局复用10", "...", "lesson", "published", None, 10);
|
||||||
|
g.source_project = None;
|
||||||
|
repo.insert(g).await.unwrap();
|
||||||
|
|
||||||
|
let hits = repo.list_waymark(Some("proj-1"), 20, 10).await.unwrap();
|
||||||
|
let ids: Vec<_> = hits.iter().map(|h| h.id.as_str()).collect();
|
||||||
|
assert_eq!(
|
||||||
|
ids,
|
||||||
|
vec!["p9", "p5", "pv", "f99", "g10"],
|
||||||
|
"项目档在前(复用降序),补充档在后(复用降序:异项目99 > 全局10)"
|
||||||
|
);
|
||||||
|
// 两档 WHERE 互斥的去重属性:任一条目恰好出现一次
|
||||||
|
assert_eq!(ids.iter().filter(|i| **i == "p9").count(), 1, "同项目条目不得因两档重复出现");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 各档上限独立生效:项目档取复用最高的 project_limit 条,补充档另计 global_limit 条
|
||||||
|
/// (条目复用全部压线达标,确保裁剪只来自 LIMIT 而非入选门槛)
|
||||||
|
#[tokio::test]
|
||||||
|
async fn waymark_limits_cap_tiers_independently() {
|
||||||
|
let repo = setup_repo().await;
|
||||||
|
for reuse in 3..=5 {
|
||||||
|
repo.insert(krec(&format!("p{reuse}"), "同项目", "...", "lesson", "published", None, reuse))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
for reuse in 3..=5 {
|
||||||
|
let mut g = krec(&format!("g{reuse}"), "全局", "...", "lesson", "published", None, reuse);
|
||||||
|
g.source_project = None;
|
||||||
|
repo.insert(g).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let hits = repo.list_waymark(Some("proj-1"), 2, 2).await.unwrap();
|
||||||
|
let ids: Vec<_> = hits.iter().map(|h| h.id.as_str()).collect();
|
||||||
|
assert_eq!(
|
||||||
|
ids,
|
||||||
|
vec!["p5", "p4", "g5", "g4"],
|
||||||
|
"项目档取复用最高的 2 条,补充档独立取全局最高的 2 条"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 空库 → 空结果(路标子块整体缺席的前提,投影侧据此零追加)
|
||||||
|
#[tokio::test]
|
||||||
|
async fn waymark_empty_db_returns_empty() {
|
||||||
|
let repo = setup_repo().await;
|
||||||
|
assert!(repo.list_waymark(Some("proj-1"), 20, 10).await.unwrap().is_empty());
|
||||||
|
assert!(repo.list_waymark(None, 20, 10).await.unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- 复用即信任(auto_publish_if_reused) ----------
|
// ---------- 复用即信任(auto_publish_if_reused) ----------
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ fn ai_message_from_row(row: &Row<'_>) -> std::result::Result<AiMessageRecord, ru
|
|||||||
prompt_cache_miss_tokens: row.get("prompt_cache_miss_tokens")?,
|
prompt_cache_miss_tokens: row.get("prompt_cache_miss_tokens")?,
|
||||||
reasoning_tokens: row.get("reasoning_tokens")?,
|
reasoning_tokens: row.get("reasoning_tokens")?,
|
||||||
is_estimated: row.get("is_estimated")?,
|
is_estimated: row.get("is_estimated")?,
|
||||||
|
source: row.get("source")?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,8 +88,8 @@ impl AiMessageRepo {
|
|||||||
(id, conversation_id, seq, role, content, parts, tool_call_id,
|
(id, conversation_id, seq, role, content, parts, tool_call_id,
|
||||||
tool_calls, model, status, reasoning_content, timestamp, created_at,
|
tool_calls, model, status, reasoning_content, timestamp, created_at,
|
||||||
prompt_tokens, completion_tokens,
|
prompt_tokens, completion_tokens,
|
||||||
prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens, is_estimated)
|
prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens, is_estimated, source)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)",
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
|
||||||
)
|
)
|
||||||
.map_err(storage_err)?;
|
.map_err(storage_err)?;
|
||||||
for rec in &records {
|
for rec in &records {
|
||||||
@@ -97,7 +98,7 @@ impl AiMessageRepo {
|
|||||||
rec.parts, rec.tool_call_id, rec.tool_calls, rec.model, rec.status,
|
rec.parts, rec.tool_call_id, rec.tool_calls, rec.model, rec.status,
|
||||||
rec.reasoning_content, rec.timestamp, rec.created_at,
|
rec.reasoning_content, rec.timestamp, rec.created_at,
|
||||||
rec.prompt_tokens, rec.completion_tokens,
|
rec.prompt_tokens, rec.completion_tokens,
|
||||||
rec.prompt_cache_hit_tokens, rec.prompt_cache_miss_tokens, rec.reasoning_tokens, rec.is_estimated
|
rec.prompt_cache_hit_tokens, rec.prompt_cache_miss_tokens, rec.reasoning_tokens, rec.is_estimated, rec.source
|
||||||
])
|
])
|
||||||
.map_err(storage_err)?;
|
.map_err(storage_err)?;
|
||||||
}
|
}
|
||||||
@@ -124,7 +125,7 @@ impl AiMessageRepo {
|
|||||||
tool_calls, model, status, reasoning_content, timestamp, created_at,
|
tool_calls, model, status, reasoning_content, timestamp, created_at,
|
||||||
prompt_tokens, completion_tokens,
|
prompt_tokens, completion_tokens,
|
||||||
prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens,
|
prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens,
|
||||||
is_estimated
|
is_estimated, source
|
||||||
FROM ai_messages WHERE conversation_id = ?1 ORDER BY seq ASC",
|
FROM ai_messages WHERE conversation_id = ?1 ORDER BY seq ASC",
|
||||||
)
|
)
|
||||||
.map_err(storage_err)?;
|
.map_err(storage_err)?;
|
||||||
@@ -171,13 +172,13 @@ impl AiMessageRepo {
|
|||||||
"SELECT id, conversation_id, seq, role, content, parts, tool_call_id,
|
"SELECT id, conversation_id, seq, role, content, parts, tool_call_id,
|
||||||
tool_calls, model, status, reasoning_content, timestamp, created_at,
|
tool_calls, model, status, reasoning_content, timestamp, created_at,
|
||||||
prompt_tokens, completion_tokens,
|
prompt_tokens, completion_tokens,
|
||||||
prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens, is_estimated
|
prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens, is_estimated, source
|
||||||
FROM ai_messages WHERE conversation_id = ?1 AND seq < ?2 ORDER BY seq DESC LIMIT ?3"
|
FROM ai_messages WHERE conversation_id = ?1 AND seq < ?2 ORDER BY seq DESC LIMIT ?3"
|
||||||
} else {
|
} else {
|
||||||
"SELECT id, conversation_id, seq, role, content, parts, tool_call_id,
|
"SELECT id, conversation_id, seq, role, content, parts, tool_call_id,
|
||||||
tool_calls, model, status, reasoning_content, timestamp, created_at,
|
tool_calls, model, status, reasoning_content, timestamp, created_at,
|
||||||
prompt_tokens, completion_tokens,
|
prompt_tokens, completion_tokens,
|
||||||
prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens, is_estimated
|
prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens, is_estimated, source
|
||||||
FROM ai_messages WHERE conversation_id = ?1 ORDER BY seq DESC LIMIT ?2"
|
FROM ai_messages WHERE conversation_id = ?1 ORDER BY seq DESC LIMIT ?2"
|
||||||
};
|
};
|
||||||
let mut stmt = guard.prepare(sql).map_err(storage_err)?;
|
let mut stmt = guard.prepare(sql).map_err(storage_err)?;
|
||||||
@@ -264,6 +265,12 @@ impl AiMessageRepo {
|
|||||||
/// 全量重写语义——以入参 records 为该对话的**唯一真相**,先删该 conv 全部旧行再批量插。
|
/// 全量重写语义——以入参 records 为该对话的**唯一真相**,先删该 conv 全部旧行再批量插。
|
||||||
/// 单事务保证「删 + 插」原子,无中间空窗(reload 不会读到半删半插的中间态)。
|
/// 单事务保证「删 + 插」原子,无中间空窗(reload 不会读到半删半插的中间态)。
|
||||||
///
|
///
|
||||||
|
/// created_at 保出生时间(2026-10-17 修复批量污染):created_at 语义 = 消息首次入库时间,
|
||||||
|
/// 全量重写只该变内容不变出生时间。调用方(save_conversation)对整批 records 统一盖
|
||||||
|
/// 对话级 created_at(老对话 None 时 now 兜底),直插会把全对话消息 created_at 抹成同一秒。
|
||||||
|
/// 故 DELETE 前先取存量 `id → created_at` 映射,重插时同 id 行回填原值;新 id(真新消息)
|
||||||
|
/// 保留入参值。ai_messages 表无 updated_at 列,内容变更无单独时间戳(不在此列语义内)。
|
||||||
|
///
|
||||||
/// 设计权衡(非 dirty 增量,留 P2.1 优化):
|
/// 设计权衡(非 dirty 增量,留 P2.1 优化):
|
||||||
/// - 内存 ContextManager 是运行时真相源,save 是同步点,每轮 save 全量回写简单可靠;
|
/// - 内存 ContextManager 是运行时真相源,save 是同步点,每轮 save 全量回写简单可靠;
|
||||||
/// - INSERT OR IGNORE 幂等:records 内 id 重复或与残留行(理论不应有,事务已 DELETE)冲突跳过;
|
/// - INSERT OR IGNORE 幂等:records 内 id 重复或与残留行(理论不应有,事务已 DELETE)冲突跳过;
|
||||||
@@ -273,7 +280,7 @@ impl AiMessageRepo {
|
|||||||
pub async fn replace_conversation(
|
pub async fn replace_conversation(
|
||||||
&self,
|
&self,
|
||||||
conv_id: &str,
|
conv_id: &str,
|
||||||
records: Vec<AiMessageRecord>,
|
mut records: Vec<AiMessageRecord>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let conn = self.conn.clone();
|
let conn = self.conn.clone();
|
||||||
let conv_id = conv_id.to_owned();
|
let conv_id = conv_id.to_owned();
|
||||||
@@ -281,7 +288,29 @@ impl AiMessageRepo {
|
|||||||
let mut guard = conn.blocking_lock();
|
let mut guard = conn.blocking_lock();
|
||||||
let tx = guard.transaction().map_err(storage_err)?;
|
let tx = guard.transaction().map_err(storage_err)?;
|
||||||
{
|
{
|
||||||
// 先删该 conv 全部旧行(全量重写语义)
|
// 先取存量 id → created_at 映射(DELETE 前留证,重插回填保出生时间)
|
||||||
|
{
|
||||||
|
let mut stmt = tx
|
||||||
|
.prepare("SELECT id, created_at FROM ai_messages WHERE conversation_id = ?1")
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map(params![conv_id], |r| {
|
||||||
|
Ok((r.get::<_, String>("id")?, r.get::<_, String>("created_at")?))
|
||||||
|
})
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
let mut original: std::collections::HashMap<String, String> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
|
for r in rows {
|
||||||
|
let (id, created_at) = r.map_err(storage_err)?;
|
||||||
|
original.insert(id, created_at);
|
||||||
|
}
|
||||||
|
for rec in &mut records {
|
||||||
|
if let Some(orig) = original.get(&rec.id) {
|
||||||
|
rec.created_at = orig.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 再删该 conv 全部旧行(全量重写语义)
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"DELETE FROM ai_messages WHERE conversation_id = ?1",
|
"DELETE FROM ai_messages WHERE conversation_id = ?1",
|
||||||
params![conv_id],
|
params![conv_id],
|
||||||
@@ -296,8 +325,8 @@ impl AiMessageRepo {
|
|||||||
(id, conversation_id, seq, role, content, parts, tool_call_id,
|
(id, conversation_id, seq, role, content, parts, tool_call_id,
|
||||||
tool_calls, model, status, reasoning_content, timestamp, created_at,
|
tool_calls, model, status, reasoning_content, timestamp, created_at,
|
||||||
prompt_tokens, completion_tokens,
|
prompt_tokens, completion_tokens,
|
||||||
prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens, is_estimated)
|
prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens, is_estimated, source)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)",
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
|
||||||
)
|
)
|
||||||
.map_err(storage_err)?;
|
.map_err(storage_err)?;
|
||||||
for rec in &records {
|
for rec in &records {
|
||||||
@@ -306,7 +335,7 @@ impl AiMessageRepo {
|
|||||||
rec.parts, rec.tool_call_id, rec.tool_calls, rec.model, rec.status,
|
rec.parts, rec.tool_call_id, rec.tool_calls, rec.model, rec.status,
|
||||||
rec.reasoning_content, rec.timestamp, rec.created_at,
|
rec.reasoning_content, rec.timestamp, rec.created_at,
|
||||||
rec.prompt_tokens, rec.completion_tokens,
|
rec.prompt_tokens, rec.completion_tokens,
|
||||||
rec.prompt_cache_hit_tokens, rec.prompt_cache_miss_tokens, rec.reasoning_tokens, rec.is_estimated
|
rec.prompt_cache_hit_tokens, rec.prompt_cache_miss_tokens, rec.reasoning_tokens, rec.is_estimated, rec.source
|
||||||
])
|
])
|
||||||
.map_err(storage_err)?;
|
.map_err(storage_err)?;
|
||||||
}
|
}
|
||||||
@@ -381,6 +410,7 @@ mod tests {
|
|||||||
prompt_cache_miss_tokens: None,
|
prompt_cache_miss_tokens: None,
|
||||||
reasoning_tokens: None,
|
reasoning_tokens: None,
|
||||||
is_estimated: None,
|
is_estimated: None,
|
||||||
|
source: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -544,6 +574,7 @@ mod tests {
|
|||||||
prompt_cache_miss_tokens: None,
|
prompt_cache_miss_tokens: None,
|
||||||
reasoning_tokens: None,
|
reasoning_tokens: None,
|
||||||
is_estimated: None,
|
is_estimated: None,
|
||||||
|
source: None,
|
||||||
},
|
},
|
||||||
AiMessageRecord {
|
AiMessageRecord {
|
||||||
id: "new_1".into(),
|
id: "new_1".into(),
|
||||||
@@ -565,6 +596,7 @@ mod tests {
|
|||||||
prompt_cache_miss_tokens: None,
|
prompt_cache_miss_tokens: None,
|
||||||
reasoning_tokens: None,
|
reasoning_tokens: None,
|
||||||
is_estimated: None,
|
is_estimated: None,
|
||||||
|
source: None,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
repo.replace_conversation("conv", records).await.expect("replace");
|
repo.replace_conversation("conv", records).await.expect("replace");
|
||||||
@@ -638,6 +670,7 @@ mod tests {
|
|||||||
prompt_cache_miss_tokens: None,
|
prompt_cache_miss_tokens: None,
|
||||||
reasoning_tokens: None,
|
reasoning_tokens: None,
|
||||||
is_estimated: None,
|
is_estimated: None,
|
||||||
|
source: None,
|
||||||
}],
|
}],
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -680,6 +713,7 @@ mod tests {
|
|||||||
prompt_cache_miss_tokens: None,
|
prompt_cache_miss_tokens: None,
|
||||||
reasoning_tokens: None,
|
reasoning_tokens: None,
|
||||||
is_estimated: None,
|
is_estimated: None,
|
||||||
|
source: None,
|
||||||
};
|
};
|
||||||
repo.replace_conversation("c", vec![rec()]).await.expect("1st");
|
repo.replace_conversation("c", vec![rec()]).await.expect("1st");
|
||||||
repo.replace_conversation("c", vec![rec()]).await.expect("2nd");
|
repo.replace_conversation("c", vec![rec()]).await.expect("2nd");
|
||||||
@@ -687,6 +721,62 @@ mod tests {
|
|||||||
assert_eq!(got.len(), 1, "重复 replace 不应叠加");
|
assert_eq!(got.len(), 1, "重复 replace 不应叠加");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// replace_conversation 保 created_at 出生时间(2026-10-17 批量污染回归):
|
||||||
|
/// 调用方对整批统一盖一个 created_at(如对话级时间或 now),重写后既有 id 行
|
||||||
|
/// 的 created_at 必须逐行保留原入库值,不得被统一时间抹平;新 id 行保留入参值。
|
||||||
|
#[tokio::test]
|
||||||
|
async fn replace_conversation_preserves_created_at() {
|
||||||
|
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||||
|
let repo = AiMessageRepo::new(&db);
|
||||||
|
|
||||||
|
// 预置两行,created_at 各异(模拟真实逐条入库的出生时间)
|
||||||
|
let mut m0 = mk_msg("m0", "c", 0, "user", "v1");
|
||||||
|
m0.created_at = "2026-10-01T10:00:00.000".into();
|
||||||
|
let mut m1 = mk_msg("m1", "c", 1, "assistant", "v1");
|
||||||
|
m1.created_at = "2026-10-02T11:30:00.000".into();
|
||||||
|
repo.insert_batch(vec![m0, m1]).await.expect("insert");
|
||||||
|
|
||||||
|
// 重写:既有 id 内容变了、created_at 被调用方统一盖成同一时间;
|
||||||
|
// 另带一条新 id 消息(真新消息,该保留入参时间)。
|
||||||
|
let uniform_ts = "2026-10-17T21:25:01.000";
|
||||||
|
let rewrite = |id: &str, seq: i64, role: &str, content: &str| {
|
||||||
|
let mut m = mk_msg(id, "c", seq, role, content);
|
||||||
|
m.created_at = uniform_ts.into();
|
||||||
|
m
|
||||||
|
};
|
||||||
|
repo.replace_conversation(
|
||||||
|
"c",
|
||||||
|
vec![
|
||||||
|
rewrite("m0", 0, "user", "v2-改写内容"),
|
||||||
|
rewrite("m1", 1, "assistant", "v2-改写内容"),
|
||||||
|
rewrite("m2_new", 2, "user", "新消息"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("replace");
|
||||||
|
|
||||||
|
let got = repo.list_by_conversation("c").await.expect("list");
|
||||||
|
assert_eq!(got.len(), 3);
|
||||||
|
// 逐行断言:既有 id 的 created_at 保持原值,内容确实变了
|
||||||
|
assert_eq!(got[0].id, "m0");
|
||||||
|
assert_eq!(got[0].created_at, "2026-10-01T10:00:00.000", "既有行 created_at 不得被重写抹平");
|
||||||
|
assert_eq!(got[0].content, "v2-改写内容");
|
||||||
|
assert_eq!(got[1].id, "m1");
|
||||||
|
assert_eq!(got[1].created_at, "2026-10-02T11:30:00.000", "既有行 created_at 不得被重写抹平");
|
||||||
|
assert_eq!(got[1].content, "v2-改写内容");
|
||||||
|
// 新 id(首次入库)保留入参 created_at
|
||||||
|
assert_eq!(got[2].id, "m2_new");
|
||||||
|
assert_eq!(got[2].created_at, uniform_ts, "新消息保留入参 created_at");
|
||||||
|
|
||||||
|
// 二次重写再验:出生时间已固化,连续重写不漂移
|
||||||
|
repo.replace_conversation("c", vec![rewrite("m0", 0, "user", "v3")])
|
||||||
|
.await
|
||||||
|
.expect("replace again");
|
||||||
|
let got = repo.list_by_conversation("c").await.expect("list");
|
||||||
|
assert_eq!(got.len(), 1);
|
||||||
|
assert_eq!(got[0].created_at, "2026-10-01T10:00:00.000", "连续重写 created_at 不漂移");
|
||||||
|
}
|
||||||
|
|
||||||
/// limit=0 契约:返空集(直传 SQLite LIMIT 0),不再旧版 max(1) 钳成 1 条。
|
/// limit=0 契约:返空集(直传 SQLite LIMIT 0),不再旧版 max(1) 钳成 1 条。
|
||||||
/// 锁死语义防回归:请求 0 条却回 1 条会让调用方误判页内有数据。
|
/// 锁死语义防回归:请求 0 条却回 1 条会让调用方误判页内有数据。
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
//! - [`mod@idea_repo`]:IdeaRepo/KnowledgeRepo/KnowledgeEventsRepo + 向量工具
|
//! - [`mod@idea_repo`]:IdeaRepo/KnowledgeRepo/KnowledgeEventsRepo + 向量工具
|
||||||
//! - [`mod@idea_eval_repo`]:IdeaEvalRepo(灵感评估历史追加型审计表 idea_evaluations,V22)
|
//! - [`mod@idea_eval_repo`]:IdeaEvalRepo(灵感评估历史追加型审计表 idea_evaluations,V22)
|
||||||
//! - [`mod@message_repo`]:AiMessageRepo(消息拆分存储,全专用方法不走宏)
|
//! - [`mod@message_repo`]:AiMessageRepo(消息拆分存储,全专用方法不走宏)
|
||||||
|
//! - [`mod@outbox_repo`]:OutboxRepo(待发箱 ai_outbox,V51,含 claim CAS/reset_stale_sending,手写不走宏)
|
||||||
//!
|
//!
|
||||||
//! re-export(`pub use ...::*`)保持 `df_storage::crud::XxxRepo` /
|
//! re-export(`pub use ...::*`)保持 `df_storage::crud::XxxRepo` /
|
||||||
//! `df_storage::crud::is_allowed_column` 路径不变,**调用方零改动**。
|
//! `df_storage::crud::is_allowed_column` 路径不变,**调用方零改动**。
|
||||||
@@ -25,6 +26,7 @@ mod conversation_repo;
|
|||||||
mod idea_eval_repo;
|
mod idea_eval_repo;
|
||||||
mod idea_repo;
|
mod idea_repo;
|
||||||
mod message_repo;
|
mod message_repo;
|
||||||
|
mod outbox_repo;
|
||||||
mod project_event_repo;
|
mod project_event_repo;
|
||||||
mod project_module_repo;
|
mod project_module_repo;
|
||||||
mod project_repo;
|
mod project_repo;
|
||||||
@@ -38,6 +40,7 @@ pub use conversation_repo::*;
|
|||||||
pub use idea_eval_repo::*;
|
pub use idea_eval_repo::*;
|
||||||
pub use idea_repo::*;
|
pub use idea_repo::*;
|
||||||
pub use message_repo::*;
|
pub use message_repo::*;
|
||||||
|
pub use outbox_repo::*;
|
||||||
pub use project_event_repo::*;
|
pub use project_event_repo::*;
|
||||||
pub use project_module_repo::*;
|
pub use project_module_repo::*;
|
||||||
pub use project_repo::*;
|
pub use project_repo::*;
|
||||||
@@ -72,6 +75,7 @@ macro_rules! impl_repo {
|
|||||||
update => |$u_conn:ident, $u_rec:ident| $update_body:expr
|
update => |$u_conn:ident, $u_rec:ident| $update_body:expr
|
||||||
) => {
|
) => {
|
||||||
$(#[$meta])*
|
$(#[$meta])*
|
||||||
|
#[derive(Clone)]
|
||||||
pub struct $name {
|
pub struct $name {
|
||||||
conn: Arc<Mutex<Connection>>,
|
conn: Arc<Mutex<Connection>>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,619 @@
|
|||||||
|
//! 待发箱 Repo:OutboxRepo(ai_outbox 表,V51)
|
||||||
|
//!
|
||||||
|
//! 队列与定时消息统一实体的持久层。因需按 id 逐条 CAS 更新(claim)与状态条件扫描,
|
||||||
|
//! 不走 `impl_repo!` 宏,手写(同 `SettingsRepo` 范式)。
|
||||||
|
//!
|
||||||
|
//! 设计见 docs/02-架构设计/专项设计/定时消息与待发箱持久化-设计-2026-10-10.md §3.2。
|
||||||
|
//!
|
||||||
|
//! 状态落点纪律(V21 教训):每项状态由调度器按项逐条 CAS 写,不依赖全局内存推算;
|
||||||
|
//! `reset_stale_sending` 在启动时把崩溃残留的 `sending` 复位为 `pending`,镜像无人值守引擎的
|
||||||
|
//! `in_flight` 泄漏处置。
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use rusqlite::{params, Connection, OptionalExtension, Row};
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
use df_types::error::Result;
|
||||||
|
|
||||||
|
use crate::db::Database;
|
||||||
|
use crate::models::AiOutboxRecord;
|
||||||
|
|
||||||
|
use super::storage_err;
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 状态 / 类型字面量(单一真值来源,避免调度器与 Repo 各写一份裸串漂移)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/// 待派发(初始态)。
|
||||||
|
pub const STATUS_PENDING: &str = "pending";
|
||||||
|
/// 已被调度器 CAS 占取、正在派发。
|
||||||
|
pub const STATUS_SENDING: &str = "sending";
|
||||||
|
/// 受理成功(已交给正典发送链)。
|
||||||
|
pub const STATUS_SENT: &str = "sent";
|
||||||
|
/// 受理失败且不再自动重试。
|
||||||
|
pub const STATUS_FAILED: &str = "failed";
|
||||||
|
/// 用户取消 / 清空。
|
||||||
|
pub const STATUS_CANCELED: &str = "canceled";
|
||||||
|
|
||||||
|
/// 尽快发(kind=queue,due_at 为空)。
|
||||||
|
pub const KIND_QUEUE: &str = "queue";
|
||||||
|
/// 定时发(kind=scheduled,due_at 为 epoch 毫秒)。
|
||||||
|
pub const KIND_SCHEDULED: &str = "scheduled";
|
||||||
|
|
||||||
|
/// 终态集合(不再被调度器扫描)。
|
||||||
|
const DONE_STATUSES: &str = "'sent','failed','canceled'";
|
||||||
|
|
||||||
|
fn outbox_from_row(row: &Row<'_>) -> std::result::Result<AiOutboxRecord, rusqlite::Error> {
|
||||||
|
Ok(AiOutboxRecord {
|
||||||
|
id: row.get("id")?,
|
||||||
|
conversation_id: row.get("conversation_id")?,
|
||||||
|
kind: row.get("kind")?,
|
||||||
|
text: row.get("text")?,
|
||||||
|
skill: row.get("skill")?,
|
||||||
|
model_override: row.get("model_override")?,
|
||||||
|
parts_json: row.get("parts_json")?,
|
||||||
|
spans_json: row.get("spans_json")?,
|
||||||
|
due_at: row.get("due_at")?,
|
||||||
|
status: row.get("status")?,
|
||||||
|
attempts: row.get("attempts")?,
|
||||||
|
last_error: row.get("last_error")?,
|
||||||
|
created_at: row.get("created_at")?,
|
||||||
|
updated_at: row.get("updated_at")?,
|
||||||
|
sent_at: row.get("sent_at")?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 待发箱 Repo(表 `ai_outbox`)。
|
||||||
|
pub struct OutboxRepo {
|
||||||
|
conn: Arc<Mutex<Connection>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OutboxRepo {
|
||||||
|
pub fn new(db: &Database) -> Self {
|
||||||
|
Self { conn: db.conn() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 插入一条待发项,返回 id。created_at/updated_at 由本方法取当前毫秒覆盖
|
||||||
|
/// (写入时机即落库时机,调用方传入的不可信)。
|
||||||
|
pub async fn insert(&self, record: AiOutboxRecord) -> Result<String> {
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
let now = df_types::now_millis();
|
||||||
|
let mut rec = record;
|
||||||
|
rec.created_at = now;
|
||||||
|
rec.updated_at = now;
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
let id = rec.id.clone();
|
||||||
|
guard
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO ai_outbox
|
||||||
|
(id, conversation_id, kind, text, skill, model_override, parts_json,
|
||||||
|
spans_json, due_at, status, attempts, last_error, created_at, updated_at, sent_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
|
||||||
|
params![
|
||||||
|
rec.id, rec.conversation_id, rec.kind, rec.text, rec.skill,
|
||||||
|
rec.model_override, rec.parts_json, rec.spans_json, rec.due_at,
|
||||||
|
rec.status, rec.attempts, rec.last_error, rec.created_at, rec.updated_at,
|
||||||
|
rec.sent_at
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
Ok(id)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按 id 取单条(IPC 回显 / 测试核验用)。
|
||||||
|
pub async fn get_by_id(&self, id: &str) -> Result<Option<AiOutboxRecord>> {
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
let id = id.to_owned();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
let rec = guard
|
||||||
|
.query_row("SELECT * FROM ai_outbox WHERE id = ?1", params![id], outbox_from_row)
|
||||||
|
.optional()
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
Ok(rec)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按会话取待发视图(前端 `state.queue` 的来源)。`include_done=false` 时排除终态,
|
||||||
|
/// 按 created_at 升序(先入先出,保序)。
|
||||||
|
pub async fn list_by_conv(
|
||||||
|
&self,
|
||||||
|
conversation_id: &str,
|
||||||
|
include_done: bool,
|
||||||
|
) -> Result<Vec<AiOutboxRecord>> {
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
let conversation_id = conversation_id.to_owned();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
let sql = if include_done {
|
||||||
|
"SELECT * FROM ai_outbox WHERE conversation_id = ?1 ORDER BY created_at ASC"
|
||||||
|
.to_string()
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"SELECT * FROM ai_outbox WHERE conversation_id = ?1 \
|
||||||
|
AND status NOT IN ({DONE_STATUSES}) ORDER BY created_at ASC"
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map(params![conversation_id], outbox_from_row)
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
let mut results = Vec::new();
|
||||||
|
for r in rows {
|
||||||
|
results.push(r.map_err(storage_err)?);
|
||||||
|
}
|
||||||
|
Ok(results)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 全量列表(跨会话,「定时列表」抽屉数据源)。`include_done=false` 时排除终态,
|
||||||
|
/// 按 created_at 升序(与 list_by_conv 同序语义)。
|
||||||
|
pub async fn list_all(&self, include_done: bool) -> Result<Vec<AiOutboxRecord>> {
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
let sql = if include_done {
|
||||||
|
"SELECT * FROM ai_outbox ORDER BY created_at ASC".to_string()
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"SELECT * FROM ai_outbox \
|
||||||
|
WHERE status NOT IN ({DONE_STATUSES}) ORDER BY created_at ASC"
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
|
||||||
|
let rows = stmt.query_map([], outbox_from_row).map_err(storage_err)?;
|
||||||
|
let mut results = Vec::new();
|
||||||
|
for r in rows {
|
||||||
|
results.push(r.map_err(storage_err)?);
|
||||||
|
}
|
||||||
|
Ok(results)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取到点的待派发项(status='pending' 且 due_at 为空或已到),按 created_at 升序,至多 limit 条。
|
||||||
|
/// 命中 idx_ai_outbox_due。
|
||||||
|
pub async fn list_due(&self, now_ms: i64, limit: i64) -> Result<Vec<AiOutboxRecord>> {
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
let mut stmt = guard
|
||||||
|
.prepare(
|
||||||
|
"SELECT * FROM ai_outbox \
|
||||||
|
WHERE status = 'pending' AND (due_at IS NULL OR due_at <= ?1) \
|
||||||
|
ORDER BY created_at ASC LIMIT ?2",
|
||||||
|
)
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map(params![now_ms, limit], outbox_from_row)
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
let mut results = Vec::new();
|
||||||
|
for r in rows {
|
||||||
|
results.push(r.map_err(storage_err)?);
|
||||||
|
}
|
||||||
|
Ok(results)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CAS 占取:仅当项仍为 pending 时置 sending。changes==1 才返回 true,
|
||||||
|
/// 防并发双取(多轮/多消费方同取同一条时只有一个成功)。
|
||||||
|
pub async fn claim(&self, id: &str) -> Result<bool> {
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
let id = id.to_owned();
|
||||||
|
let now = df_types::now_millis();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
let affected = guard
|
||||||
|
.execute(
|
||||||
|
"UPDATE ai_outbox SET status = 'sending', updated_at = ?1 \
|
||||||
|
WHERE id = ?2 AND status = 'pending'",
|
||||||
|
params![now, id],
|
||||||
|
)
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
Ok(affected == 1)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 标记受理成功(终态)。清空 last_error。
|
||||||
|
pub async fn mark_sent(&self, id: &str) -> Result<bool> {
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
let id = id.to_owned();
|
||||||
|
let now = df_types::now_millis();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
let affected = guard
|
||||||
|
.execute(
|
||||||
|
"UPDATE ai_outbox SET status = 'sent', sent_at = ?1, updated_at = ?1, \
|
||||||
|
last_error = NULL WHERE id = ?2",
|
||||||
|
params![now, id],
|
||||||
|
)
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
Ok(affected > 0)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 标记永久失败(终态,不再自动重试)。
|
||||||
|
pub async fn mark_failed(&self, id: &str, err: &str) -> Result<bool> {
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
let id = id.to_owned();
|
||||||
|
let err = err.to_owned();
|
||||||
|
let now = df_types::now_millis();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
let affected = guard
|
||||||
|
.execute(
|
||||||
|
"UPDATE ai_outbox SET status = 'failed', last_error = ?1, updated_at = ?2 \
|
||||||
|
WHERE id = ?3",
|
||||||
|
params![err, now, id],
|
||||||
|
)
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
Ok(affected > 0)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 瞬时失败回退:attempts+1、状态回 pending、记 last_error,返回新的 attempts。
|
||||||
|
/// 保留下轮再试(网络超时/会话忙场景天然重试),不删项——对齐「错误不清队列」纪律。
|
||||||
|
pub async fn revert_pending(&self, id: &str, err: Option<&str>) -> Result<i64> {
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
let id = id.to_owned();
|
||||||
|
let err = err.map(|s| s.to_owned());
|
||||||
|
let now = df_types::now_millis();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
guard
|
||||||
|
.execute(
|
||||||
|
"UPDATE ai_outbox SET status = 'pending', attempts = attempts + 1, \
|
||||||
|
last_error = ?1, updated_at = ?2 WHERE id = ?3",
|
||||||
|
params![err, now, id],
|
||||||
|
)
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
let attempts: i64 = guard
|
||||||
|
.query_row(
|
||||||
|
"SELECT attempts FROM ai_outbox WHERE id = ?1",
|
||||||
|
params![id],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
Ok(attempts)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 改期:更新 due_at(NULL=改为尽快)。
|
||||||
|
pub async fn reschedule(&self, id: &str, due_at: Option<i64>) -> Result<bool> {
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
let id = id.to_owned();
|
||||||
|
let now = df_types::now_millis();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
let affected = guard
|
||||||
|
.execute(
|
||||||
|
"UPDATE ai_outbox SET due_at = ?1, updated_at = ?2 WHERE id = ?3",
|
||||||
|
params![due_at, now, id],
|
||||||
|
)
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
Ok(affected > 0)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 手动重试入口:仅 failed → pending(attempts 归零=手动重试给全新预算,清 last_error)。
|
||||||
|
/// 其余状态不动返回 false——调度器自动重试走 revert_pending(瞬时失败保序),两者互补。
|
||||||
|
pub async fn requeue(&self, id: &str) -> Result<bool> {
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
let id = id.to_owned();
|
||||||
|
let now = df_types::now_millis();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
let affected = guard
|
||||||
|
.execute(
|
||||||
|
"UPDATE ai_outbox SET status = 'pending', attempts = 0, last_error = NULL, \
|
||||||
|
updated_at = ?1 WHERE id = ?2 AND status = 'failed'",
|
||||||
|
params![now, id],
|
||||||
|
)
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
Ok(affected > 0)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取消单项。
|
||||||
|
pub async fn cancel(&self, id: &str) -> Result<bool> {
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
let id = id.to_owned();
|
||||||
|
let now = df_types::now_millis();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
let affected = guard
|
||||||
|
.execute(
|
||||||
|
"UPDATE ai_outbox SET status = 'canceled', updated_at = ?1 WHERE id = ?2",
|
||||||
|
params![now, id],
|
||||||
|
)
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
Ok(affected > 0)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 编辑待发文本。
|
||||||
|
pub async fn update_text(&self, id: &str, text: &str) -> Result<bool> {
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
let id = id.to_owned();
|
||||||
|
let text = text.to_owned();
|
||||||
|
let now = df_types::now_millis();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
let affected = guard
|
||||||
|
.execute(
|
||||||
|
"UPDATE ai_outbox SET text = ?1, updated_at = ?2 WHERE id = ?3",
|
||||||
|
params![text, now, id],
|
||||||
|
)
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
Ok(affected > 0)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清空待发(pending→canceled,不物理删——保留行便于审计)。`conv_id=None` 时清全部会话。
|
||||||
|
/// 返回被取消的行数。
|
||||||
|
pub async fn clear(&self, conv_id: Option<&str>) -> Result<usize> {
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
let conv_id = conv_id.map(|s| s.to_owned());
|
||||||
|
let now = df_types::now_millis();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
let affected = match conv_id {
|
||||||
|
Some(id) => guard.execute(
|
||||||
|
"UPDATE ai_outbox SET status = 'canceled', updated_at = ?1 \
|
||||||
|
WHERE status = 'pending' AND conversation_id = ?2",
|
||||||
|
params![now, id],
|
||||||
|
),
|
||||||
|
None => guard.execute(
|
||||||
|
"UPDATE ai_outbox SET status = 'canceled', updated_at = ?1 \
|
||||||
|
WHERE status = 'pending'",
|
||||||
|
params![now],
|
||||||
|
),
|
||||||
|
}
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
Ok(affected)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 启动复位:把崩溃半途残留的 `sending` 全部复位为 `pending`(进程重启时前者必为陈旧值)。
|
||||||
|
/// 返回复位的行数。镜像无人值守引擎的 in_flight 泄漏处置。
|
||||||
|
pub async fn reset_stale_sending(&self) -> Result<usize> {
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
let now = df_types::now_millis();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
let affected = guard
|
||||||
|
.execute(
|
||||||
|
"UPDATE ai_outbox SET status = 'pending', updated_at = ?1 WHERE status = 'sending'",
|
||||||
|
params![now],
|
||||||
|
)
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
Ok(affected)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 单元测试 — 迁移可重入 / CAS 互斥 / sender 残留复位(内存 DB)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::db::Database;
|
||||||
|
|
||||||
|
/// 构造一条 pending 待发项(due_at=None=尽快)。
|
||||||
|
fn rec(id: &str, conv: &str) -> AiOutboxRecord {
|
||||||
|
AiOutboxRecord {
|
||||||
|
id: id.to_string(),
|
||||||
|
conversation_id: conv.to_string(),
|
||||||
|
kind: KIND_QUEUE.to_string(),
|
||||||
|
text: format!("msg-{id}"),
|
||||||
|
skill: None,
|
||||||
|
model_override: None,
|
||||||
|
parts_json: None,
|
||||||
|
spans_json: None,
|
||||||
|
due_at: None,
|
||||||
|
status: STATUS_PENDING.to_string(),
|
||||||
|
attempts: 0,
|
||||||
|
last_error: None,
|
||||||
|
created_at: 0,
|
||||||
|
updated_at: 0,
|
||||||
|
sent_at: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn setup() -> (Database, OutboxRepo) {
|
||||||
|
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||||
|
let repo = OutboxRepo::new(&db);
|
||||||
|
(db, repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// V51 迁移可重入:全链在已建表库上重跑 again 不报错,ai_outbox 表与两索引仍在。
|
||||||
|
#[tokio::test]
|
||||||
|
async fn migration_v51_is_reentrant() {
|
||||||
|
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||||
|
let conn = db.conn();
|
||||||
|
let guard = conn.lock().await;
|
||||||
|
// 首次已由 open_in_memory 跑过;再显式重跑一遍全链(模拟版本号回退/崩溃重跑)。
|
||||||
|
crate::migrations::run(&guard).expect("重跑迁移不应失败(幂等)");
|
||||||
|
crate::migrations::run(&guard).expect("二次重跑仍应幂等");
|
||||||
|
// 表存在
|
||||||
|
let has_table: bool = guard
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='ai_outbox'",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(has_table, "ai_outbox 表应存在");
|
||||||
|
// 两索引存在
|
||||||
|
for idx in ["idx_ai_outbox_due", "idx_ai_outbox_conv"] {
|
||||||
|
let has_idx: bool = guard
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='index' AND name=?1",
|
||||||
|
params![idx],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(has_idx, "索引 {idx} 应存在");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// claim CAS 互斥:同一条只被占取一次,二次 claim 返回 false。
|
||||||
|
#[tokio::test]
|
||||||
|
async fn claim_is_mutually_exclusive() {
|
||||||
|
let (_db, repo) = setup().await;
|
||||||
|
repo.insert(rec("o1", "conv-1")).await.unwrap();
|
||||||
|
|
||||||
|
assert!(repo.claim("o1").await.unwrap(), "首次 claim 应成功");
|
||||||
|
assert!(!repo.claim("o1").await.unwrap(), "二次 claim 应失败(已非 pending)");
|
||||||
|
let got = repo.get_by_id("o1").await.unwrap().unwrap();
|
||||||
|
assert_eq!(got.status, STATUS_SENDING);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// reset_stale_sending 复位崩溃残留:sending → pending,计数正确。
|
||||||
|
#[tokio::test]
|
||||||
|
async fn reset_stale_sending_recovers_crash_residue() {
|
||||||
|
let (_db, repo) = setup().await;
|
||||||
|
repo.insert(rec("o1", "conv-1")).await.unwrap();
|
||||||
|
repo.insert(rec("o2", "conv-1")).await.unwrap();
|
||||||
|
// 模拟两条被占取后进程崩溃(残留 sending)
|
||||||
|
assert!(repo.claim("o1").await.unwrap());
|
||||||
|
assert!(repo.claim("o2").await.unwrap());
|
||||||
|
assert_eq!(repo.get_by_id("o1").await.unwrap().unwrap().status, STATUS_SENDING);
|
||||||
|
|
||||||
|
let recovered = repo.reset_stale_sending().await.unwrap();
|
||||||
|
assert_eq!(recovered, 2, "两条残留 sending 应被复位");
|
||||||
|
assert_eq!(repo.get_by_id("o1").await.unwrap().unwrap().status, STATUS_PENDING);
|
||||||
|
assert_eq!(repo.get_by_id("o2").await.unwrap().unwrap().status, STATUS_PENDING);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// list_due:只取到点的 pending(尽快项恒命中,未到点定时项不取),按 created_at 升序。
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_due_respects_due_and_order() {
|
||||||
|
let (_db, repo) = setup().await;
|
||||||
|
repo.insert(rec("o1", "conv-1")).await.unwrap();
|
||||||
|
// 未到点定时项(远未来)
|
||||||
|
let mut future = rec("o2", "conv-1");
|
||||||
|
future.kind = KIND_SCHEDULED.to_string();
|
||||||
|
future.due_at = Some(9_999_999_999_999);
|
||||||
|
repo.insert(future).await.unwrap();
|
||||||
|
// 已到点定时项
|
||||||
|
let mut due = rec("o3", "conv-1");
|
||||||
|
due.kind = KIND_SCHEDULED.to_string();
|
||||||
|
due.due_at = Some(0);
|
||||||
|
repo.insert(due).await.unwrap();
|
||||||
|
|
||||||
|
let got = repo.list_due(df_types::now_millis(), 32).await.unwrap();
|
||||||
|
let ids: Vec<&str> = got.iter().map(|r| r.id.as_str()).collect();
|
||||||
|
assert_eq!(ids, vec!["o1", "o3"], "仅到点的 pending,按 created_at 升序");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// revert_pending:瞬时失败回退 pending + attempts 自增 + 记 last_error,项不丢。
|
||||||
|
#[tokio::test]
|
||||||
|
async fn revert_pending_keeps_item_and_bumps_attempts() {
|
||||||
|
let (_db, repo) = setup().await;
|
||||||
|
repo.insert(rec("o1", "conv-1")).await.unwrap();
|
||||||
|
repo.claim("o1").await.unwrap();
|
||||||
|
|
||||||
|
let n = repo.revert_pending("o1", Some("boom")).await.unwrap();
|
||||||
|
assert_eq!(n, 1);
|
||||||
|
let got = repo.get_by_id("o1").await.unwrap().unwrap();
|
||||||
|
assert_eq!(got.status, STATUS_PENDING, "回退后应可再次被调度");
|
||||||
|
assert_eq!(got.attempts, 1);
|
||||||
|
assert_eq!(got.last_error.as_deref(), Some("boom"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// cancel/clear 只动 pending:已终态项不受影响,clear 按会话范围限定。
|
||||||
|
#[tokio::test]
|
||||||
|
async fn clear_cancels_only_pending_in_scope() {
|
||||||
|
let (_db, repo) = setup().await;
|
||||||
|
repo.insert(rec("o1", "conv-1")).await.unwrap();
|
||||||
|
repo.insert(rec("o2", "conv-2")).await.unwrap();
|
||||||
|
repo.mark_sent("o1").await.unwrap();
|
||||||
|
|
||||||
|
let n = repo.clear(Some("conv-1")).await.unwrap();
|
||||||
|
assert_eq!(n, 0, "conv-1 已无 pending(sent 不算),不误改");
|
||||||
|
assert_eq!(repo.get_by_id("o1").await.unwrap().unwrap().status, STATUS_SENT);
|
||||||
|
|
||||||
|
let n = repo.clear(Some("conv-2")).await.unwrap();
|
||||||
|
assert_eq!(n, 1);
|
||||||
|
assert_eq!(repo.get_by_id("o2").await.unwrap().unwrap().status, STATUS_CANCELED);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// list_all:跨会话全量返回;include_done=false 排除终态,created_at 升序。
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_all_covers_convs_and_filters_done() {
|
||||||
|
let (_db, repo) = setup().await;
|
||||||
|
repo.insert(rec("o1", "conv-1")).await.unwrap();
|
||||||
|
repo.insert(rec("o2", "conv-2")).await.unwrap();
|
||||||
|
let mut done = rec("o3", "conv-1");
|
||||||
|
done.status = STATUS_SENT.to_string();
|
||||||
|
repo.insert(done).await.unwrap();
|
||||||
|
|
||||||
|
let all = repo.list_all(true).await.unwrap();
|
||||||
|
assert_eq!(all.len(), 3, "include_done=true 全量(含跨会话)");
|
||||||
|
|
||||||
|
let active = repo.list_all(false).await.unwrap();
|
||||||
|
let ids: Vec<&str> = active.iter().map(|r| r.id.as_str()).collect();
|
||||||
|
assert_eq!(ids, vec!["o1", "o2"], "排除终态后仅两条 pending,升序");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// requeue:仅 failed → pending 且 attempts 归零清错;pending/sent 不动返 false。
|
||||||
|
#[tokio::test]
|
||||||
|
async fn requeue_only_resets_failed() {
|
||||||
|
let (_db, repo) = setup().await;
|
||||||
|
// o1:pending → claim(占取)→ revert(attempts=1)→ mark_failed(failed,attempts 保留 1)
|
||||||
|
repo.insert(rec("o1", "conv-1")).await.unwrap();
|
||||||
|
repo.claim("o1").await.unwrap();
|
||||||
|
let attempts = repo.revert_pending("o1", Some("boom")).await.unwrap();
|
||||||
|
assert_eq!(attempts, 1);
|
||||||
|
repo.mark_failed("o1", "boom").await.unwrap();
|
||||||
|
|
||||||
|
assert!(repo.requeue("o1").await.unwrap(), "failed 应可重入队");
|
||||||
|
let got = repo.get_by_id("o1").await.unwrap().unwrap();
|
||||||
|
assert_eq!(got.status, STATUS_PENDING);
|
||||||
|
assert_eq!(got.attempts, 0, "手动重试给全新预算");
|
||||||
|
assert_eq!(got.last_error, None, "重入队清上次错误");
|
||||||
|
|
||||||
|
// pending 项:requeue 不动(返回 false)
|
||||||
|
repo.insert(rec("o2", "conv-1")).await.unwrap();
|
||||||
|
assert!(!repo.requeue("o2").await.unwrap(), "pending 非 failed,requeue 不动");
|
||||||
|
// sent 终态:requeue 不动
|
||||||
|
repo.insert(rec("o3", "conv-1")).await.unwrap();
|
||||||
|
repo.mark_sent("o3").await.unwrap();
|
||||||
|
assert!(!repo.requeue("o3").await.unwrap(), "sent 终态不可重入队");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -278,6 +278,7 @@ mod tests {
|
|||||||
idea_id: None,
|
idea_id: None,
|
||||||
path: None,
|
path: None,
|
||||||
stack: None,
|
stack: None,
|
||||||
|
archived: false,
|
||||||
created_at: "1700000000000".to_string(),
|
created_at: "1700000000000".to_string(),
|
||||||
updated_at: "1700000000000".to_string(),
|
updated_at: "1700000000000".to_string(),
|
||||||
})
|
})
|
||||||
@@ -426,6 +427,7 @@ mod tests {
|
|||||||
idea_id: None,
|
idea_id: None,
|
||||||
path: None,
|
path: None,
|
||||||
stack: None,
|
stack: None,
|
||||||
|
archived: false,
|
||||||
created_at: "1700000000000".to_string(),
|
created_at: "1700000000000".to_string(),
|
||||||
updated_at: "1700000000000".to_string(),
|
updated_at: "1700000000000".to_string(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -255,6 +255,7 @@ mod tests {
|
|||||||
idea_id: None,
|
idea_id: None,
|
||||||
path: Some("/tmp".into()),
|
path: Some("/tmp".into()),
|
||||||
stack: Some("rust".into()),
|
stack: Some("rust".into()),
|
||||||
|
archived: false,
|
||||||
created_at: "0".to_string(),
|
created_at: "0".to_string(),
|
||||||
updated_at: "0".to_string(),
|
updated_at: "0".to_string(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
//! 项目域 Repo:ProjectRepo / BranchRepo / ReleaseRepo / WorkflowRepo / NodeExecutionRepo
|
//! 项目域 Repo:ProjectRepo / BranchRepo / ReleaseRepo / WorkflowRepo / NodeExecutionRepo
|
||||||
|
//! 项目域 Repo:ProjectRepo / BranchRepo / ReleaseRepo / WorkflowRepo / NodeExecutionRepo
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -86,9 +87,9 @@ fn build_order_clause(order_by: Option<&str>) -> Result<String> {
|
|||||||
|
|
||||||
/// 带「最近活跃时间」的项目记录(list_active_with_activity 专用返回结构)。
|
/// 带「最近活跃时间」的项目记录(list_active_with_activity 专用返回结构)。
|
||||||
///
|
///
|
||||||
/// `last_active_at` = COALESCE(project_events 最新事件 created_at, projects.updated_at):
|
/// `last_active_at` = MAX(tasks.updated_at 最新值, project_events 最新 created_at,
|
||||||
/// 反映业务活跃(任务/灵感/状态推进等事件),无事件项目回退 updated_at。供前端「最近活跃排序」
|
/// projects.updated_at):任务活跃为主信号(任务面是真实工作发生地),事件表已停写仅保留维度,
|
||||||
/// 展示,语义比 projects.updated_at(随元信息修改如改 description 也会刷新)更准确。
|
/// updated_at 兜底。供前端「最近活跃排序」展示与 system prompt 项目清单排序。
|
||||||
///
|
///
|
||||||
/// Serialize 供 IPC 层直接序列化回前端(serde 字段名 snake_case,对齐 ProjectRecord)。
|
/// Serialize 供 IPC 层直接序列化回前端(serde 字段名 snake_case,对齐 ProjectRecord)。
|
||||||
#[derive(Debug, Clone, serde::Serialize)]
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
@@ -125,6 +126,7 @@ fn project_from_row(row: &Row<'_>) -> std::result::Result<ProjectRecord, rusqlit
|
|||||||
idea_id: row.get("idea_id")?,
|
idea_id: row.get("idea_id")?,
|
||||||
path: row.get("path")?,
|
path: row.get("path")?,
|
||||||
stack: row.get("stack")?,
|
stack: row.get("stack")?,
|
||||||
|
archived: row.get::<_, i32>("archived")? != 0,
|
||||||
created_at: row.get("created_at")?,
|
created_at: row.get("created_at")?,
|
||||||
updated_at: row.get("updated_at")?,
|
updated_at: row.get("updated_at")?,
|
||||||
})
|
})
|
||||||
@@ -200,20 +202,20 @@ impl_repo!(
|
|||||||
from_row => |row| project_from_row(row),
|
from_row => |row| project_from_row(row),
|
||||||
insert => |conn, rec| {
|
insert => |conn, rec| {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO projects (id, name, description, status, idea_id, path, stack, created_at, updated_at)
|
"INSERT INTO projects (id, name, description, status, idea_id, path, stack, archived, created_at, updated_at)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||||
params![
|
params![
|
||||||
rec.id, rec.name, rec.description, rec.status.as_str(), rec.idea_id,
|
rec.id, rec.name, rec.description, rec.status.as_str(), rec.idea_id,
|
||||||
rec.path, rec.stack, rec.created_at, rec.updated_at
|
rec.path, rec.stack, rec.archived as i32, rec.created_at, rec.updated_at
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
update => |conn, rec| {
|
update => |conn, rec| {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE projects SET name = ?1, description = ?2, status = ?3, idea_id = ?4, path = ?5, stack = ?6, updated_at = ?7 WHERE id = ?8",
|
"UPDATE projects SET name = ?1, description = ?2, status = ?3, idea_id = ?4, path = ?5, stack = ?6, archived = ?7, updated_at = ?8 WHERE id = ?9",
|
||||||
params![
|
params![
|
||||||
rec.name, rec.description, rec.status.as_str(), rec.idea_id,
|
rec.name, rec.description, rec.status.as_str(), rec.idea_id,
|
||||||
rec.path, rec.stack, rec.updated_at, rec.id
|
rec.path, rec.stack, rec.archived as i32, rec.updated_at, rec.id
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -226,7 +228,7 @@ impl ProjectRepo {
|
|||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let guard = conn.blocking_lock();
|
let guard = conn.blocking_lock();
|
||||||
let mut stmt = guard
|
let mut stmt = guard
|
||||||
.prepare("SELECT id, name, description, status, idea_id, path, stack, created_at, updated_at FROM projects WHERE deleted_at IS NULL ORDER BY created_at DESC")
|
.prepare("SELECT id, name, description, status, idea_id, path, stack, archived, created_at, updated_at FROM projects WHERE deleted_at IS NULL AND archived = 0 ORDER BY created_at DESC")
|
||||||
.map_err(storage_err)?;
|
.map_err(storage_err)?;
|
||||||
let rows = stmt
|
let rows = stmt
|
||||||
.query_map([], |row| project_from_row(row))
|
.query_map([], |row| project_from_row(row))
|
||||||
@@ -241,30 +243,36 @@ impl ProjectRepo {
|
|||||||
.map_err(storage_err)?
|
.map_err(storage_err)?
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 列出未删除项目,**按「最近活跃」排序**(LEFT JOIN project_events 取最新事件时间,
|
/// 列出未删除项目,**按「最近活跃」排序**(三源取大:任务活跃为主信号)。
|
||||||
/// COALESCE 回退 updated_at)。
|
|
||||||
///
|
///
|
||||||
/// 业务语义:项目「最近活跃」应反映业务事件(任务/灵感/状态推进),而非 projects.updated_at
|
/// 业务语义:last_active_at = MAX(tasks.updated_at 最新值, project_events 最新 created_at,
|
||||||
/// (后者随元信息修改如改 description/path 也会刷新,不反映真实业务活跃)。本方法通过子查询
|
/// projects.updated_at)。**任务活跃为主信号**——ai-chat 会话/任务面是真实工作发生地,
|
||||||
/// 取每个项目 project_events 最新 created_at,无事件项目回退 updated_at,排序列统一可比。
|
/// 任务停更即项目停更;project_events 已停写(现网空表,保留维度向后兼容);
|
||||||
|
/// projects.updated_at 仅元信息修改(改 description/path 也会刷新,不反映真实业务活跃),
|
||||||
|
/// 仅作兜底。无任务无事件的项目回退 updated_at,排序列统一可比。
|
||||||
///
|
///
|
||||||
/// 子查询而非 JOIN:project_events 每项目可能 0..N 条,JOIN 会展开需 DISTINCT,子查询
|
/// 子查询而非 JOIN:tasks/project_events 每项目可能 0..N 条,JOIN 会展开需 DISTINCT,
|
||||||
/// `(SELECT MAX(created_at) FROM project_events WHERE project_id = projects.id)` 每行一次
|
/// 标量子查询每行一次聚合,语义清晰无笛卡尔积风险(SQLite 多参 MAX 为标量取大,
|
||||||
/// 聚合,语义清晰无笛卡尔积风险。单用户桌面应用项目数小,无性能压力。
|
/// IFNULL 兜空子查询 NULL)。单用户桌面应用项目数小,无性能压力。
|
||||||
///
|
///
|
||||||
/// 返回 `ProjectActivityRecord`(ProjectRecord + last_active_at 字段),供前端排序展示。
|
/// 返回 `ProjectActivityRecord`(ProjectRecord + last_active_at 字段),供前端排序展示
|
||||||
|
/// 与 system prompt「当前项目」清单(build_context_lists)按活跃度排序。
|
||||||
pub async fn list_active_with_activity(&self) -> Result<Vec<ProjectActivityRecord>> {
|
pub async fn list_active_with_activity(&self) -> Result<Vec<ProjectActivityRecord>> {
|
||||||
let conn = self.conn.clone();
|
let conn = self.conn.clone();
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let guard = conn.blocking_lock();
|
let guard = conn.blocking_lock();
|
||||||
// COALESCE(子查询最新事件, projects.updated_at):无事件项目回退 updated_at,
|
// 三源取大(SQLite 多参 MAX=标量取大,IFNULL 兜空子查询):任务活跃为主信号,
|
||||||
// 保证所有项目有可比活跃时间。ORDER BY 该列 DESC,同时间 created_at DESC 兜底稳定。
|
// 事件表已停写仅保留维度,updated_at 兜底。ORDER BY 该列 DESC,同时间 created_at DESC
|
||||||
|
// 兜底稳定。
|
||||||
let mut stmt = guard
|
let mut stmt = guard
|
||||||
.prepare(
|
.prepare(
|
||||||
"SELECT id, name, description, status, idea_id, path, stack, created_at, updated_at, \
|
"SELECT id, name, description, status, idea_id, path, stack, archived, created_at, updated_at, \
|
||||||
COALESCE((SELECT MAX(created_at) FROM project_events WHERE project_id = projects.id), \
|
MAX(\
|
||||||
updated_at) AS last_active_at \
|
IFNULL((SELECT MAX(updated_at) FROM tasks WHERE project_id = projects.id AND deleted_at IS NULL), 0), \
|
||||||
FROM projects WHERE deleted_at IS NULL \
|
IFNULL((SELECT MAX(created_at) FROM project_events WHERE project_id = projects.id), 0), \
|
||||||
|
updated_at\
|
||||||
|
) AS last_active_at \
|
||||||
|
FROM projects WHERE deleted_at IS NULL AND archived = 0 \
|
||||||
ORDER BY last_active_at DESC, created_at DESC",
|
ORDER BY last_active_at DESC, created_at DESC",
|
||||||
)
|
)
|
||||||
.map_err(storage_err)?;
|
.map_err(storage_err)?;
|
||||||
@@ -315,8 +323,8 @@ impl ProjectRepo {
|
|||||||
// 动态拼 WHERE + 参数。WHERE 1=1 起点 + deleted_at IS NULL 固定基础条件,
|
// 动态拼 WHERE + 参数。WHERE 1=1 起点 + deleted_at IS NULL 固定基础条件,
|
||||||
// 后续可选条件 AND 拼接(对齐 KnowledgeRepo::search 的 if-let 分支模式)。
|
// 后续可选条件 AND 拼接(对齐 KnowledgeRepo::search 的 if-let 分支模式)。
|
||||||
let mut sql = String::from(
|
let mut sql = String::from(
|
||||||
"SELECT id, name, description, status, idea_id, path, stack, created_at, updated_at \
|
"SELECT id, name, description, status, idea_id, path, stack, archived, created_at, updated_at \
|
||||||
FROM projects WHERE deleted_at IS NULL",
|
FROM projects WHERE deleted_at IS NULL AND archived = 0",
|
||||||
);
|
);
|
||||||
// keyword 用 ?N 占位两次(name LIKE ? AND description LIKE ? 同 pattern),
|
// keyword 用 ?N 占位两次(name LIKE ? AND description LIKE ? 同 pattern),
|
||||||
// 对齐 KnowledgeRepo::search 的 `params![pattern, pattern, ...]`。
|
// 对齐 KnowledgeRepo::search 的 `params![pattern, pattern, ...]`。
|
||||||
@@ -421,7 +429,7 @@ impl ProjectRepo {
|
|||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let guard = conn.blocking_lock();
|
let guard = conn.blocking_lock();
|
||||||
let mut stmt = guard
|
let mut stmt = guard
|
||||||
.prepare("SELECT id, name, description, status, idea_id, path, stack, created_at, updated_at FROM projects WHERE deleted_at IS NOT NULL ORDER BY updated_at DESC")
|
.prepare("SELECT id, name, description, status, idea_id, path, stack, archived, created_at, updated_at FROM projects WHERE deleted_at IS NOT NULL ORDER BY updated_at DESC")
|
||||||
.map_err(storage_err)?;
|
.map_err(storage_err)?;
|
||||||
let rows = stmt
|
let rows = stmt
|
||||||
.query_map([], |row| project_from_row(row))
|
.query_map([], |row| project_from_row(row))
|
||||||
@@ -500,6 +508,54 @@ impl ProjectRepo {
|
|||||||
.map_err(storage_err)?
|
.map_err(storage_err)?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 归档/取消归档(归档功能 P2,V48)。
|
||||||
|
///
|
||||||
|
/// 纯元数据标记,仅作用于未软删项目(回收站优先级高于归档,先恢复再操作)。
|
||||||
|
/// archived=true 后项目退出主视图(list_active/list_active_with_activity/list_by_query
|
||||||
|
/// 恒带 archived = 0),仅在归档视图可见。对标 IdeaRepo::set_archived。返回是否命中。
|
||||||
|
/// 注:archived 不进 update_field 通用白名单,防工具层旁路改写(布尔语义走专用通道)。
|
||||||
|
pub async fn set_archived(&self, id: &str, archived: bool) -> Result<bool> {
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
let id = id.to_owned();
|
||||||
|
let now = now_millis_str();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
let affected = guard
|
||||||
|
.execute(
|
||||||
|
"UPDATE projects SET archived = ?1, updated_at = ?2 WHERE id = ?3 AND deleted_at IS NULL",
|
||||||
|
params![if archived { 1 } else { 0 }, now, id],
|
||||||
|
)
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
Ok(affected > 0)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 列出归档项目(archived = 1 且未软删),按更新时间降序(最近归档在前)。
|
||||||
|
///
|
||||||
|
/// 归档视图数据源,与 list_active 对偶(恒带 archived = 1)。
|
||||||
|
/// 对标 IdeaRepo::list_archived。
|
||||||
|
pub async fn list_archived(&self) -> Result<Vec<ProjectRecord>> {
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
let mut stmt = guard
|
||||||
|
.prepare("SELECT id, name, description, status, idea_id, path, stack, archived, created_at, updated_at FROM projects WHERE deleted_at IS NULL AND archived = 1 ORDER BY updated_at DESC")
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([], |row| project_from_row(row))
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
let mut results = Vec::new();
|
||||||
|
for r in rows {
|
||||||
|
results.push(r.map_err(storage_err)?);
|
||||||
|
}
|
||||||
|
Ok(results)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
|
|
||||||
/// 彻底删除:事务级联删全部关联子表→projects(不可恢复)
|
/// 彻底删除:事务级联删全部关联子表→projects(不可恢复)
|
||||||
///
|
///
|
||||||
/// SQLite 已开 PRAGMA foreign_keys=ON 但表无 ON DELETE CASCADE,ALTER 改不了 FK 约束,
|
/// SQLite 已开 PRAGMA foreign_keys=ON 但表无 ON DELETE CASCADE,ALTER 改不了 FK 约束,
|
||||||
@@ -718,6 +774,7 @@ mod tests {
|
|||||||
idea_id: None,
|
idea_id: None,
|
||||||
path: None,
|
path: None,
|
||||||
stack: None,
|
stack: None,
|
||||||
|
archived: false,
|
||||||
created_at: "1700000000000".to_string(),
|
created_at: "1700000000000".to_string(),
|
||||||
updated_at: "1700000000000".to_string(),
|
updated_at: "1700000000000".to_string(),
|
||||||
}
|
}
|
||||||
@@ -789,4 +846,62 @@ mod tests {
|
|||||||
assert_eq!(after.name, "原项目", "回收站项目内容不应被改写");
|
assert_eq!(after.name, "原项目", "回收站项目内容不应被改写");
|
||||||
assert_eq!(after.updated_at, current.updated_at, "updated_at 不应抖动");
|
assert_eq!(after.updated_at, current.updated_at, "updated_at 不应抖动");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_active_with_activity_orders_by_task_activity_over_created_at() {
|
||||||
|
// 回归(事故形状):项目 A created_at 较晚(创建序排前)但无任务/停更;项目 B
|
||||||
|
// created_at 较早但有一条 tasks.updated_at 最新 → 活跃序 B 排 A 前
|
||||||
|
// (任务活跃为主信号,治创建序误导 AI 把停摆项目当最高优先锚点)。
|
||||||
|
use crate::crud::task_repo::TaskRepo;
|
||||||
|
use crate::models::TaskRecord;
|
||||||
|
use df_types::types::TaskStatus;
|
||||||
|
|
||||||
|
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||||
|
let projects = ProjectRepo::new(&db);
|
||||||
|
let tasks = TaskRepo::new(&db);
|
||||||
|
|
||||||
|
// B 先建(早),A 后建(晚)——创建序会排 A 前
|
||||||
|
let mut early = prec("pb", "早建但活跃");
|
||||||
|
early.created_at = "1700000000000".to_string();
|
||||||
|
early.updated_at = "1700000000000".to_string();
|
||||||
|
let mut late = prec("pa", "晚建但停更");
|
||||||
|
late.created_at = "1700000010000".to_string();
|
||||||
|
late.updated_at = "1700000010000".to_string();
|
||||||
|
projects.insert(early).await.unwrap();
|
||||||
|
projects.insert(late).await.unwrap();
|
||||||
|
|
||||||
|
// B 挂一条最新任务;A 无任务 → B 活跃时间取任务 updated_at,A 回退自身 updated_at
|
||||||
|
let task = TaskRecord {
|
||||||
|
id: "tb".to_string(),
|
||||||
|
project_id: "pb".to_string(),
|
||||||
|
title: "B 的任务".to_string(),
|
||||||
|
description: String::new(),
|
||||||
|
status: TaskStatus::InProgress,
|
||||||
|
priority: 0,
|
||||||
|
branch_name: None,
|
||||||
|
assignee: None,
|
||||||
|
workflow_def_id: None,
|
||||||
|
base_branch: None,
|
||||||
|
review_rounds: 0,
|
||||||
|
output_json: None,
|
||||||
|
idea_id: None,
|
||||||
|
queue: "todo".to_string(),
|
||||||
|
parent_id: None,
|
||||||
|
content_json: None,
|
||||||
|
module_id: None,
|
||||||
|
archived: false,
|
||||||
|
created_at: "1700000000000".to_string(),
|
||||||
|
updated_at: "1790000000000".to_string(),
|
||||||
|
};
|
||||||
|
tasks.insert(task).await.unwrap();
|
||||||
|
|
||||||
|
let listed = projects.list_active_with_activity().await.unwrap();
|
||||||
|
assert_eq!(listed.len(), 2);
|
||||||
|
assert_eq!(listed[0].record.id, "pb", "任务活跃最新的早建项目应排前");
|
||||||
|
assert_eq!(listed[1].record.id, "pa", "无任务的晚建项目应排后");
|
||||||
|
assert_eq!(
|
||||||
|
listed[0].last_active_at, "1790000000000",
|
||||||
|
"活跃时间应取该项目任务 updated_at 最新值"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -319,6 +319,7 @@ mod tests {
|
|||||||
idea_id: None,
|
idea_id: None,
|
||||||
path: None,
|
path: None,
|
||||||
stack: None,
|
stack: None,
|
||||||
|
archived: false,
|
||||||
created_at: "1700000000000".to_string(),
|
created_at: "1700000000000".to_string(),
|
||||||
updated_at: "1700000000000".to_string(),
|
updated_at: "1700000000000".to_string(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
//! 任务横向关联 Repo:TaskLinkRepo(task_links 表,知识图谱 Phase 1 V29)
|
//! 任务横向关联 Repo:TaskLinkRepo(task_links 表,知识图谱 Phase 1 V29)
|
||||||
|
//! 任务横向关联 Repo:TaskLinkRepo(task_links 表,知识图谱 Phase 1 V29)
|
||||||
//!
|
//!
|
||||||
//! 表达任务间 depends_on / blocks / relates_to 关系,AI 拓扑排序编排调度的基础。
|
//! 表达任务间 depends_on / blocks / relates_to 关系,AI 拓扑排序编排调度的基础。
|
||||||
//! 对标设计 docs/02-架构设计/专项设计/项目知识图谱与任务队列系统-2026-06-26.md §2.2。
|
//! 对标设计 docs/02-架构设计/专项设计/项目知识图谱与任务队列系统-2026-06-26.md §2.2。
|
||||||
@@ -376,6 +377,7 @@ mod tests {
|
|||||||
idea_id: None,
|
idea_id: None,
|
||||||
path: None,
|
path: None,
|
||||||
stack: None,
|
stack: None,
|
||||||
|
archived: false,
|
||||||
created_at: "1700000000000".to_string(),
|
created_at: "1700000000000".to_string(),
|
||||||
updated_at: "1700000000000".to_string(),
|
updated_at: "1700000000000".to_string(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
//! 任务域 Repo:TaskRepo(含 advance_status_atomic 状态机收口)
|
//! 任务域 Repo:TaskRepo(含 advance_status_atomic 状态机收口)
|
||||||
|
//! 任务域 Repo:TaskRepo(含 advance_status_atomic 状态机收口)
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -99,6 +100,10 @@ pub struct TaskQuery {
|
|||||||
pub project_id: Option<String>,
|
pub project_id: Option<String>,
|
||||||
/// 状态过滤(P1 下沉,命中 idx_tasks_status)。值集由上层 TaskStatus::is_valid 兜底校验。
|
/// 状态过滤(P1 下沉,命中 idx_tasks_status)。值集由上层 TaskStatus::is_valid 兜底校验。
|
||||||
pub status: Option<String>,
|
pub status: Option<String>,
|
||||||
|
/// 状态排除集(1eff911c 缺陷修复,MCP list_tasks 无 status 过滤时默认排除
|
||||||
|
/// done/cancelled 终态,防存量终态任务挤占分页窗口)。与 status 互斥使用:
|
||||||
|
/// 同传时 status 等值优先,status_not_in 忽略(SQL 拼接顺序 status 先判)。
|
||||||
|
pub status_not_in: Option<Vec<String>>,
|
||||||
/// 优先级过滤(0=critical..3=low)。P3 基建就绪,视图暂不用。
|
/// 优先级过滤(0=critical..3=low)。P3 基建就绪,视图暂不用。
|
||||||
pub priority: Option<i32>,
|
pub priority: Option<i32>,
|
||||||
/// 负责人过滤。P3 基建就绪,视图暂不用。
|
/// 负责人过滤。P3 基建就绪,视图暂不用。
|
||||||
@@ -244,6 +249,34 @@ impl TaskRepo {
|
|||||||
.map_err(storage_err)?
|
.map_err(storage_err)?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 判定任务是否在回收站(soft_delete 已设 deleted_at)。
|
||||||
|
///
|
||||||
|
/// 专供推进链 CAS 失败后的**原因区分**:`advance_status_atomic` 的 CAS SQL 带
|
||||||
|
/// `deleted_at IS NULL` 条件,软删任务必然 affected==0 返回 None;调用方据此
|
||||||
|
/// 回查本方法,把「已变更(并发推进或旁路修改)」这个误导性错误,精确改写为
|
||||||
|
/// 「任务在回收站」,让 LLM/用户知道该先恢复而非重试。
|
||||||
|
///
|
||||||
|
/// 返回 true = 任务存在且 deleted_at 非 NULL(在回收站);false = 不存在或未删。
|
||||||
|
/// 单列轻查询(SELECT 1),不拉全记录,失败回查开销可忽略。
|
||||||
|
pub async fn is_deleted(&self, id: &str) -> Result<bool> {
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
let id = id.to_owned();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
let mut stmt = guard
|
||||||
|
.prepare("SELECT 1 FROM tasks WHERE id = ?1 AND deleted_at IS NOT NULL")
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
let found = stmt
|
||||||
|
.query_row(params![id], |_| Ok(()))
|
||||||
|
.optional()
|
||||||
|
.map_err(storage_err)?
|
||||||
|
.is_some();
|
||||||
|
Ok(found)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
|
|
||||||
/// 按 id 查未删除任务(`WHERE id AND deleted_at IS NULL`)。
|
/// 按 id 查未删除任务(`WHERE id AND deleted_at IS NULL`)。
|
||||||
///
|
///
|
||||||
/// 专供 [`validate_parent_id`](super::task_validation) 的父任务存在性检查:
|
/// 专供 [`validate_parent_id`](super::task_validation) 的父任务存在性检查:
|
||||||
@@ -389,6 +422,7 @@ impl TaskRepo {
|
|||||||
// 拷贝 query 字段进闭包('static 生命周期,spawn_blocking 要求)
|
// 拷贝 query 字段进闭包('static 生命周期,spawn_blocking 要求)
|
||||||
let project_id = query.project_id.clone();
|
let project_id = query.project_id.clone();
|
||||||
let status = query.status.clone();
|
let status = query.status.clone();
|
||||||
|
let status_not_in = query.status_not_in.clone();
|
||||||
let priority = query.priority;
|
let priority = query.priority;
|
||||||
let assignee = query.assignee.clone();
|
let assignee = query.assignee.clone();
|
||||||
let keyword = query.keyword.clone();
|
let keyword = query.keyword.clone();
|
||||||
@@ -416,6 +450,22 @@ impl TaskRepo {
|
|||||||
where_clauses.push(format!("status = ?{}", params_vec.len() + 1));
|
where_clauses.push(format!("status = ?{}", params_vec.len() + 1));
|
||||||
params_vec.push(Box::new(s.clone()));
|
params_vec.push(Box::new(s.clone()));
|
||||||
}
|
}
|
||||||
|
// status_not_in(1eff911c 修复):状态排除集,先拼占位符再统一绑值(两步分离,
|
||||||
|
// 避免边拼边绑的序号/值错位)。与 status 同传时 status 等值优先(互斥由调用方保证)。
|
||||||
|
if status.is_none() {
|
||||||
|
if let Some(excl) = &status_not_in {
|
||||||
|
if !excl.is_empty() {
|
||||||
|
let base = params_vec.len();
|
||||||
|
let placeholders: Vec<String> = (0..excl.len())
|
||||||
|
.map(|i| format!("?{}", base + i + 1))
|
||||||
|
.collect();
|
||||||
|
for v in excl {
|
||||||
|
params_vec.push(Box::new(v.clone()));
|
||||||
|
}
|
||||||
|
where_clauses.push(format!("status NOT IN ({})", placeholders.join(", ")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if let Some(p) = priority {
|
if let Some(p) = priority {
|
||||||
where_clauses.push(format!("priority = ?{}", params_vec.len() + 1));
|
where_clauses.push(format!("priority = ?{}", params_vec.len() + 1));
|
||||||
params_vec.push(Box::new(p));
|
params_vec.push(Box::new(p));
|
||||||
@@ -987,6 +1037,7 @@ mod tests {
|
|||||||
idea_id: None,
|
idea_id: None,
|
||||||
path: None,
|
path: None,
|
||||||
stack: None,
|
stack: None,
|
||||||
|
archived: false,
|
||||||
created_at: "1700000000000".to_string(),
|
created_at: "1700000000000".to_string(),
|
||||||
updated_at: "1700000000000".to_string(),
|
updated_at: "1700000000000".to_string(),
|
||||||
})
|
})
|
||||||
@@ -1089,6 +1140,7 @@ mod tests {
|
|||||||
idea_id: None,
|
idea_id: None,
|
||||||
path: None,
|
path: None,
|
||||||
stack: None,
|
stack: None,
|
||||||
|
archived: false,
|
||||||
created_at: "1700000000000".to_string(),
|
created_at: "1700000000000".to_string(),
|
||||||
updated_at: "1700000000000".to_string(),
|
updated_at: "1700000000000".to_string(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ mod tests {
|
|||||||
idea_id: None,
|
idea_id: None,
|
||||||
path: None,
|
path: None,
|
||||||
stack: None,
|
stack: None,
|
||||||
|
archived: false,
|
||||||
created_at: "1700000000000".to_string(),
|
created_at: "1700000000000".to_string(),
|
||||||
updated_at: "1700000000000".to_string(),
|
updated_at: "1700000000000".to_string(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
//! 数据库迁移 — 建表 SQL 与版本管理
|
//! 数据库迁移 — 建表 SQL 与版本管理
|
||||||
|
//! 数据库迁移 — 建表 SQL 与版本管理
|
||||||
|
|
||||||
use crate::migrations_helpers::column_exists;
|
use crate::migrations_helpers::{column_exists, table_exists};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
|
|
||||||
@@ -55,7 +56,18 @@ pub fn run(conn: &Connection) -> Result<()> {
|
|||||||
// 发起时一次声明存续期生效,随会话同亡)。
|
// 发起时一次声明存续期生效,随会话同亡)。
|
||||||
// V46/V47 = 任务/灵感归档(archived INTEGER NOT NULL DEFAULT 0,归档功能 P1,
|
// V46/V47 = 任务/灵感归档(archived INTEGER NOT NULL DEFAULT 0,归档功能 P1,
|
||||||
// 对标 ai_conversations.archived V4;归档与软删正交:回收站=删除缓冲,归档=低频可见保检索)。
|
// 对标 ai_conversations.archived V4;归档与软删正交:回收站=删除缓冲,归档=低频可见保检索)。
|
||||||
let steps: [(i32, fn(&Connection) -> Result<()>); 47] = [
|
// V48 = 项目归档(projects.archived 列,归档功能 P2,同款三方对齐)。
|
||||||
|
// V49 = loop 生命周期标记(ai_conversations.loop_state TEXT,断点续跑底座:
|
||||||
|
// running/parked,重启扫描 running→parked 改写,侧栏「可恢复」标记)。
|
||||||
|
// V50 = 使用数据分析 schema 债补全(三项,详见 migrate_v50 文档):
|
||||||
|
// ai_conversations.status 会话终局态 / ai_tool_executions.duration_ms 纯执行耗时 /
|
||||||
|
// conversation_checkpoints.token_total 语义回填。
|
||||||
|
// V51 = 待发箱 ai_outbox 表(定时消息与待发箱持久化 P0 地基):队列/定时统一实体,
|
||||||
|
// 调度器按 status+due_at 索引到点派发。纯新增表,可逆=DROP TABLE ai_outbox。
|
||||||
|
// V52 = ai_messages.source 列(#958 消息溯源:外部渠道原始消息 ID JSON,NULL=桌面本端)。
|
||||||
|
// V53 = WorkingContext 持久化(ai_conversations.working_context_json TEXT,跨会话召回 L4
|
||||||
|
// 重启不失忆:save 随行写,会话载入与 parked 恢复读还原,NULL=无上下文行为同旧)。
|
||||||
|
let steps: [(i32, fn(&Connection) -> Result<()>); 53] = [
|
||||||
(1, migrate_v1),
|
(1, migrate_v1),
|
||||||
(2, migrate_v2),
|
(2, migrate_v2),
|
||||||
(3, migrate_v3),
|
(3, migrate_v3),
|
||||||
@@ -105,6 +117,18 @@ pub fn run(conn: &Connection) -> Result<()> {
|
|||||||
// V47 = 灵感归档(ideas.archived 列,同款)。归档与软删正交:回收站=删除缓冲,归档=低频可见保检索。
|
// V47 = 灵感归档(ideas.archived 列,同款)。归档与软删正交:回收站=删除缓冲,归档=低频可见保检索。
|
||||||
(46, migrate_v46),
|
(46, migrate_v46),
|
||||||
(47, migrate_v47),
|
(47, migrate_v47),
|
||||||
|
// V48 = 项目归档(projects.archived 列,归档功能 P2,对标 V46/V47 同款)。
|
||||||
|
(48, migrate_v48),
|
||||||
|
// V49 = loop 生命周期标记(ai_conversations.loop_state,断点续跑底座)。
|
||||||
|
(49, migrate_v49),
|
||||||
|
// V50 = 使用数据分析 schema 债补全(status/duration_ms/token_total 三项)。
|
||||||
|
(50, migrate_v50),
|
||||||
|
// V51 = 待发箱 ai_outbox 表(定时消息与待发箱持久化 P0 地基)。
|
||||||
|
(51, migrate_v51),
|
||||||
|
// V52 = ai_messages 加 source 列(#958 消息溯源:飞书等外部渠道原始消息 ID)。
|
||||||
|
(52, migrate_v52),
|
||||||
|
// V53 = WorkingContext 持久化(ai_conversations.working_context_json,跨会话召回 L4)。
|
||||||
|
(53, migrate_v53),
|
||||||
];
|
];
|
||||||
|
|
||||||
for (version, migrate_fn) in steps {
|
for (version, migrate_fn) in steps {
|
||||||
@@ -1404,6 +1428,243 @@ fn migrate_v47(conn: &Connection) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// V48: 幂等补 projects.archived 列(项目归档,归档功能 P2)
|
||||||
|
///
|
||||||
|
/// 对标 V46 tasks / V47 ideas 同款:归档与软删正交(回收站=删除缓冲,归档=低频可见保检索)。
|
||||||
|
/// archived 不进 update_field 通用白名单,走专用 set_archived(布尔语义 + 防旁路)。
|
||||||
|
/// 活跃查询(list_active/list_active_with_activity/list_by_query)恒带 `archived = 0`。
|
||||||
|
/// - **幂等**:column_exists 守卫;**可逆**:`ALTER TABLE projects DROP COLUMN archived`。
|
||||||
|
fn migrate_v48(conn: &Connection) -> Result<()> {
|
||||||
|
if !column_exists(conn, "projects", "archived") {
|
||||||
|
conn.execute(
|
||||||
|
"ALTER TABLE projects ADD COLUMN archived INTEGER NOT NULL DEFAULT 0",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
tracing::info!("v48: 补建 projects.archived 列(项目归档)");
|
||||||
|
}
|
||||||
|
conn.execute("INSERT OR IGNORE INTO schema_version (version) VALUES (?)", [48])?;
|
||||||
|
tracing::info!("迁移 v48 完成: projects 加 archived 列");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// V49: 幂等补 ai_conversations.loop_state 列(断点续跑底座)
|
||||||
|
///
|
||||||
|
/// loop_state TEXT NULL:None=非任务会话/已收敛;"running"=任务 loop 在跑(spawn 时置起);
|
||||||
|
/// "parked"=重启扫描时 running 残留改写(侧栏标「任务中断可恢复」+ 一键恢复)。
|
||||||
|
/// 对齐无人值守长任务设计 §4.5 拍板 A(不自动续跑,标记+一键恢复)。
|
||||||
|
/// - **幂等**:column_exists 守卫;**可逆**:`ALTER TABLE ai_conversations DROP COLUMN loop_state`。
|
||||||
|
fn migrate_v49(conn: &Connection) -> Result<()> {
|
||||||
|
if !column_exists(conn, "ai_conversations", "loop_state") {
|
||||||
|
conn.execute(
|
||||||
|
"ALTER TABLE ai_conversations ADD COLUMN loop_state TEXT",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
tracing::info!("v49: 补建 ai_conversations.loop_state 列(断点续跑标记)");
|
||||||
|
}
|
||||||
|
conn.execute("INSERT OR IGNORE INTO schema_version (version) VALUES (?)", [49])?;
|
||||||
|
tracing::info!("迁移 v49 完成: ai_conversations 加 loop_state 列");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// V50: 使用数据分析 schema 债补全(2026-10-04 实证三笔,归「V49 schema 迁移批」)。
|
||||||
|
///
|
||||||
|
/// 一次迁移承载三项相关改动,均为纯粹加列/回填,不改既有列语义:
|
||||||
|
///
|
||||||
|
/// ① ai_conversations.status TEXT NOT NULL DEFAULT 'active' —— 会话终局态。
|
||||||
|
/// 原仅靠侧栏内消息尾部推断「是否收尾」(17/89 会话停在 tool 结果无 assistant 收尾),
|
||||||
|
/// 无法直接统计未完成率。新增列建模终局:
|
||||||
|
/// active=进行中/默认(含正常完成,完成态精确写入待收尾单点接驳),incomplete=停靠/
|
||||||
|
/// 断流等未完成终局,error=循环错误终局。与 loop_state(运行期 running/parked)正交:
|
||||||
|
/// loop_state 管「能否续跑」,status 管「会话如何结束」。
|
||||||
|
/// ② ai_tool_executions.duration_ms INTEGER —— 纯执行耗时(毫秒)。
|
||||||
|
/// 原审计只有 requested_at→executed_at 区间,该区间在审批路径混入「等待人工批准」
|
||||||
|
/// 时长,耗时统计不可信。本列专记工具真正执行的墙钟毫秒(执行前→执行后),与审批
|
||||||
|
/// 等待解耦;NULL=老数据/未测路径(向后兼容)。
|
||||||
|
/// ③ conversation_checkpoints.token_total 回填 —— 原写入侧误用「消息条数」当 token 总量
|
||||||
|
/// (p50=120 vs 真实数万级,差 3 个量级)。写入侧已修为真实 token 累加(见 save 侧);
|
||||||
|
/// 本迁移对历史行按 snapshot 内逐条 prompt_tokens+completion_tokens 重算回填,使存量
|
||||||
|
/// 可比。仅当重算结果 > 0 才写(N 老快照无消息级 token 时保留原值不误清零)。
|
||||||
|
///
|
||||||
|
/// - **幂等**:①②`column_exists` 守卫,重复执行不报 duplicate column;③重算确定(同输入同输出),
|
||||||
|
/// 反复执行结果一致,且经 schema_version 版本号闸只跑一次。
|
||||||
|
/// - **可逆**:①`ALTER TABLE ai_conversations DROP COLUMN status`;
|
||||||
|
/// ②`ALTER TABLE ai_tool_executions DROP COLUMN duration_ms`;
|
||||||
|
/// ③无回退(DROP COLUMN 不会还原被覆盖的 token_total 旧值)——如需回退重建快照表
|
||||||
|
/// (老值本就失真无保留价值)。
|
||||||
|
fn migrate_v50(conn: &Connection) -> Result<()> {
|
||||||
|
// ① 会话终局态列(NOT NULL 常量默认,SQLite ALTER 合法)。
|
||||||
|
if table_exists(conn, "ai_conversations") && !column_exists(conn, "ai_conversations", "status") {
|
||||||
|
conn.execute(
|
||||||
|
"ALTER TABLE ai_conversations ADD COLUMN status TEXT NOT NULL DEFAULT 'active'",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
tracing::info!("v50: 补建 ai_conversations.status 列(会话终局态)");
|
||||||
|
}
|
||||||
|
// ② 纯执行耗时列(NULL=老数据/未测路径)。
|
||||||
|
if table_exists(conn, "ai_tool_executions")
|
||||||
|
&& !column_exists(conn, "ai_tool_executions", "duration_ms")
|
||||||
|
{
|
||||||
|
conn.execute(
|
||||||
|
"ALTER TABLE ai_tool_executions ADD COLUMN duration_ms INTEGER",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
tracing::info!("v50: 补建 ai_tool_executions.duration_ms 列(纯执行耗时)");
|
||||||
|
}
|
||||||
|
// ③ 存量 checkpoint token_total 按 snapshot 重算回填。
|
||||||
|
backfill_checkpoint_token_total(conn)?;
|
||||||
|
|
||||||
|
conn.execute("INSERT OR IGNORE INTO schema_version (version) VALUES (?)", [50])?;
|
||||||
|
tracing::info!("迁移 v50 完成: status/duration_ms 加列 + checkpoint token_total 回填");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// V51: 待发箱 ai_outbox 表(定时消息与待发箱持久化 P0 地基)。
|
||||||
|
///
|
||||||
|
/// 对标 docs/02-架构设计/专项设计/定时消息与待发箱持久化-设计-2026-10-10.md §3.1。
|
||||||
|
/// 队列(931)与定时消息(935)本质是同一实体、不同触发条件,统一落本表:
|
||||||
|
/// 队列项 due_at=NULL(会话空闲即发),定时项 due_at=epoch_ms(到点且空闲即发)。
|
||||||
|
///
|
||||||
|
/// 选独立表而非 app_settings KV:outbox 需按 status+due_at 索引扫描、按 id 逐条 CAS 更新,
|
||||||
|
/// KV 整块 JSON 读改写有并发改期/取消互相覆盖的风险且不可索引。
|
||||||
|
///
|
||||||
|
/// - **幂等**:table_exists 守卫建表(同 V29/V30/V31 模式),索引 CREATE INDEX IF NOT EXISTS
|
||||||
|
/// 原生幂等;版本号 INSERT OR IGNORE 防崩溃重跑 PK 冲突。全链重跑/断点续跑均安全。
|
||||||
|
/// - **可逆**(回退方式,当前不执行):本表纯新增、不触碰旧数据,回退即
|
||||||
|
/// `DROP TABLE ai_outbox`(同时关调度器开关 df-outbox-enabled=false),无需数据搬迁。
|
||||||
|
/// - **状态落点**:pending|sending|sent|failed|canceled 由调度器按项逐条 CAS 写,不依赖全局
|
||||||
|
/// 内存推算(V21 教训:全局 COUNT 幂等窗口中途崩溃致永久残缺)。崩溃残留的 sending 由
|
||||||
|
/// 调度器启动时 reset_stale_sending 复位为 pending。
|
||||||
|
/// 迁移链顶点版本(单一真值来源):**新增迁移时必须同步 +1**,否则下方迁移测试会失败
|
||||||
|
/// (测试用它断言「全量迁移后 schema_version == 顶点」「重跑仍到顶点」——既防链中断,也免多处写死)。
|
||||||
|
pub const LATEST_SCHEMA_VERSION: i32 = 53;
|
||||||
|
|
||||||
|
fn migrate_v51(conn: &Connection) -> Result<()> {
|
||||||
|
if !table_exists(conn, "ai_outbox") {
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE ai_outbox (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
conversation_id TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL DEFAULT 'queue',
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
skill TEXT,
|
||||||
|
model_override TEXT,
|
||||||
|
parts_json TEXT,
|
||||||
|
spans_json TEXT,
|
||||||
|
due_at INTEGER,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_error TEXT,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
sent_at INTEGER
|
||||||
|
)",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
tracing::info!("v51: 建 ai_outbox 表(待发箱)");
|
||||||
|
}
|
||||||
|
// 索引覆盖两条最高频扫描:调度器 list_due(status+due_at)/ 前端按会话取视图(conversation_id+status)。
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_ai_outbox_due ON ai_outbox(status, due_at)",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_ai_outbox_conv ON ai_outbox(conversation_id, status)",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
conn.execute("INSERT OR IGNORE INTO schema_version (version) VALUES (?)", [51])?;
|
||||||
|
tracing::info!("迁移 v51 完成: ai_outbox 表 + 两索引(定时消息与待发箱持久化)");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// V52: ai_messages 加 source 列(#958 消息溯源)。TEXT JSON(MessageSource 序列化),
|
||||||
|
/// NULL=桌面本端。纯加列零回退风险(老数据恒 NULL=既有语义)。
|
||||||
|
fn migrate_v52(conn: &Connection) -> Result<()> {
|
||||||
|
if !column_exists(conn, "ai_messages", "source") {
|
||||||
|
conn.execute("ALTER TABLE ai_messages ADD COLUMN source TEXT", [])?;
|
||||||
|
tracing::info!("v52: ai_messages 加 source 列(消息溯源)");
|
||||||
|
}
|
||||||
|
conn.execute("INSERT OR IGNORE INTO schema_version (version) VALUES (?)", [52])?;
|
||||||
|
tracing::info!("迁移 v52 完成");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// V53: WorkingContext 持久化 — ai_conversations 加 working_context_json TEXT 列。
|
||||||
|
///
|
||||||
|
/// 跨会话召回 L4(重启不失忆):WorkingContext(自动目标/决策/未解项/当前步骤/脏标记版本)
|
||||||
|
/// 此前纯内存,重启后压缩重锚定回填的决策与未解项全丢,恢复的会话半失忆续跑。本列随 save
|
||||||
|
/// 每轮随行写、会话载入与 parked 恢复读还原;NULL=无上下文(老会话/全空),行为同旧。
|
||||||
|
/// 目标快照裁决语义另批,本列只做存储载体。
|
||||||
|
/// - **幂等**:column_exists 守卫(同 V44/V45/V49 模式),崩溃重跑/全链两遍不炸;
|
||||||
|
/// - **可逆**:`ALTER TABLE ai_conversations DROP COLUMN working_context_json`(SQLite 3.35+)。
|
||||||
|
fn migrate_v53(conn: &Connection) -> Result<()> {
|
||||||
|
if !column_exists(conn, "ai_conversations", "working_context_json") {
|
||||||
|
conn.execute(
|
||||||
|
"ALTER TABLE ai_conversations ADD COLUMN working_context_json TEXT",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
tracing::info!("v53: 补建 ai_conversations.working_context_json 列(WorkingContext 持久化)");
|
||||||
|
}
|
||||||
|
conn.execute("INSERT OR IGNORE INTO schema_version (version) VALUES (?)", [53])?;
|
||||||
|
tracing::info!("迁移 v53 完成: ai_conversations 加 working_context_json 列");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 回填 conversation_checkpoints.token_total:按 snapshot JSON 内逐条消息的
|
||||||
|
/// prompt_tokens+completion_tokens 求真实总量。
|
||||||
|
///
|
||||||
|
/// 幂等:重算确定,重复执行相同结果;仅当重算值 > 0 且与现值不同才 UPDATE
|
||||||
|
/// (老快照无消息级 token → 重算 0 → 保留原值,不误清零)。表/列不存在时静默跳过
|
||||||
|
/// (极老库未跑到 V37,或手工删表)。
|
||||||
|
fn backfill_checkpoint_token_total(conn: &Connection) -> Result<()> {
|
||||||
|
// 表存在性探测:未跑到 V37 的极老库无此表,PRAGMA 返回空即跳过。
|
||||||
|
if !column_exists(conn, "conversation_checkpoints", "token_total") {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
// 收集待回填行到内存再逐条 UPDATE:避免同一连接上「先 prepare 读游标、再同连接 execute 写」
|
||||||
|
// 造成的隐式锁/游标失效,分批小表可接受。
|
||||||
|
let rows: Vec<(String, String, i64)> = {
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT id, snapshot, token_total FROM conversation_checkpoints",
|
||||||
|
)?;
|
||||||
|
let mapped = stmt.query_map([], |r| {
|
||||||
|
Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?, r.get::<_, i64>(2)?))
|
||||||
|
})?;
|
||||||
|
mapped.filter_map(|r| r.ok()).collect()
|
||||||
|
};
|
||||||
|
let mut updated = 0usize;
|
||||||
|
for (id, snapshot, old_total) in rows {
|
||||||
|
let recomputed = sum_snapshot_tokens(&snapshot);
|
||||||
|
if recomputed > 0 && recomputed != old_total {
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE conversation_checkpoints SET token_total = ?1 WHERE id = ?2",
|
||||||
|
rusqlite::params![recomputed, id],
|
||||||
|
)?;
|
||||||
|
updated += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if updated > 0 {
|
||||||
|
tracing::info!("v50: checkpoint token_total 回填 {updated} 行(消息条数→真实 token)");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从消息 JSON 数组求 token 总量:逐条取 prompt_tokens + completion_tokens(UInt64)
|
||||||
|
/// 求和。字段缺失/类型不符按 0 计(老消息无消息级 token → 贡献 0)。解析失败返回 0。
|
||||||
|
///
|
||||||
|
/// 用 `serde_json::Value` 裸取(df-storage 不依赖 df-ai-core 的 ChatMessage,同 V21 迁移模式)。
|
||||||
|
fn sum_snapshot_tokens(snapshot: &str) -> i64 {
|
||||||
|
let Ok(msgs) = serde_json::from_str::<Vec<serde_json::Value>>(snapshot) else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
msgs.iter()
|
||||||
|
.map(|m| {
|
||||||
|
let p = m.get("prompt_tokens").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||||
|
let c = m.get("completion_tokens").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||||
|
p + c
|
||||||
|
})
|
||||||
|
.sum()
|
||||||
|
}
|
||||||
|
|
||||||
/// V21 建表 SQL — 消息拆分存储 ai_messages 表
|
/// V21 建表 SQL — 消息拆分存储 ai_messages 表
|
||||||
///
|
///
|
||||||
/// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。
|
/// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。
|
||||||
@@ -2417,8 +2678,13 @@ mod tests {
|
|||||||
column_exists(&conn, "ideas", "archived"),
|
column_exists(&conn, "ideas", "archived"),
|
||||||
"ideas.archived 列缺失(V47 加)"
|
"ideas.archived 列缺失(V47 加)"
|
||||||
);
|
);
|
||||||
|
// projects.archived(V48 项目归档 P2)
|
||||||
|
assert!(
|
||||||
|
column_exists(&conn, "projects", "archived"),
|
||||||
|
"projects.archived 列缺失(V48 加)"
|
||||||
|
);
|
||||||
|
|
||||||
// 3. schema_version 应推进到 45(全量迁移成功落版本号)
|
// 3. schema_version 应推进到 48(全量迁移成功落版本号)
|
||||||
let max_version: i64 = conn
|
let max_version: i64 = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT COALESCE(MAX(version), 0) FROM schema_version",
|
"SELECT COALESCE(MAX(version), 0) FROM schema_version",
|
||||||
@@ -2427,9 +2693,9 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.expect("查 schema_version 应成功");
|
.expect("查 schema_version 应成功");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
max_version, 47,
|
max_version, LATEST_SCHEMA_VERSION as i64,
|
||||||
"全量迁移后 schema_version 应为 47(实际 {}),说明某条 migrate_vN 链路断在中间",
|
"全量迁移后 schema_version 应为顶点 {}(实际 {}),说明某条 migrate_vN 链路断在中间",
|
||||||
max_version
|
LATEST_SCHEMA_VERSION, max_version
|
||||||
);
|
);
|
||||||
|
|
||||||
// 4. V40 抽查:project_modules 表 description / status 列存在(老库 ALTER 补,新库 V40 也跑)
|
// 4. V40 抽查:project_modules 表 description / status 列存在(老库 ALTER 补,新库 V40 也跑)
|
||||||
@@ -2505,7 +2771,7 @@ mod tests {
|
|||||||
|r| r.get(0),
|
|r| r.get(0),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(max_v, 47, "首轮应推进到 47");
|
assert_eq!(max_v, LATEST_SCHEMA_VERSION as i64, "首轮应推进到 {}", LATEST_SCHEMA_VERSION);
|
||||||
|
|
||||||
// 清空版本表强制全链第二遍(每步 execute 第二次)
|
// 清空版本表强制全链第二遍(每步 execute 第二次)
|
||||||
conn.execute("DELETE FROM schema_version", []).unwrap();
|
conn.execute("DELETE FROM schema_version", []).unwrap();
|
||||||
@@ -2517,7 +2783,7 @@ mod tests {
|
|||||||
|r| r.get(0),
|
|r| r.get(0),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(max_v2, 47, "重跑后应重新推进到 47");
|
assert_eq!(max_v2, LATEST_SCHEMA_VERSION as i64, "重跑后应重新推进到 {}", LATEST_SCHEMA_VERSION);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -2623,4 +2889,138 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(got.is_some(), "boundary_json 应可写可读");
|
assert!(got.is_some(), "boundary_json 应可写可读");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// V50: 三列/回填一次承载,幂等重跑不炸。
|
||||||
|
/// ① ai_conversations.status 补建默认 'active';
|
||||||
|
/// ② ai_tool_executions.duration_ms 补建(可空);
|
||||||
|
/// ③ conversation_checkpoints.token_total 按 snapshot 重算(消息条数→真实 token)。
|
||||||
|
#[test]
|
||||||
|
fn v50_adds_columns_and_backfills_token_total() {
|
||||||
|
let conn = setup_legacy_db();
|
||||||
|
// 前置:最小库无 status/duration_ms 列(V45 之后建,老库缺)
|
||||||
|
assert!(!column_exists(&conn, "ai_conversations", "status"));
|
||||||
|
// V50 需要 checkpoint / tool_executions 表(正常由 V1/V9 建;此处手工建以隔离本迁移被测量)
|
||||||
|
conn.execute_batch(
|
||||||
|
"CREATE TABLE IF NOT EXISTS 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);
|
||||||
|
INSERT INTO conversation_checkpoints (id, conv_id, snapshot, token_total, created_at)\
|
||||||
|
VALUES ('ck1','c1',\
|
||||||
|
'[{\"role\":\"assistant\",\"prompt_tokens\":150000,\"completion_tokens\":20000}]',\
|
||||||
|
1,'100');
|
||||||
|
CREATE TABLE IF NOT EXISTS ai_tool_executions (\
|
||||||
|
id TEXT PRIMARY KEY, tool_call_id TEXT NOT NULL, tool_name TEXT NOT NULL,\
|
||||||
|
arguments TEXT NOT NULL, requested_at TEXT NOT NULL);",
|
||||||
|
)
|
||||||
|
.expect("建 checkpoint/tool_executions 表 + 塞一条失真行");
|
||||||
|
|
||||||
|
migrate_v50(&conn).expect("v50 首次迁移应成功");
|
||||||
|
assert!(column_exists(&conn, "ai_conversations", "status"), "应有 status 列");
|
||||||
|
assert!(column_exists(&conn, "ai_tool_executions", "duration_ms"), "应有 duration_ms 列");
|
||||||
|
// ① 默认值 active 生效
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO ai_conversations (id, messages, created_at, updated_at) VALUES ('c1','[]','0','0')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let status: String = conn
|
||||||
|
.query_row("SELECT status FROM ai_conversations WHERE id = 'c1'", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(status, "active", "新列默认值应为 active");
|
||||||
|
// ③ 失真 token_total(1)被回填为真实和(150000+20000)
|
||||||
|
let tt: i64 = conn
|
||||||
|
.query_row("SELECT token_total FROM conversation_checkpoints WHERE id = 'ck1'", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(tt, 170_000, "应回填为 snapshot 内 token 真实和");
|
||||||
|
|
||||||
|
let v: i64 = conn
|
||||||
|
.query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(v, 50, "应写入版本号 50");
|
||||||
|
|
||||||
|
// 幂等:列已存在时重跑走 column_exists 短路;回填重算确定(值不变)
|
||||||
|
migrate_v50(&conn).expect("列已存在时重跑应幂等不报错");
|
||||||
|
let tt2: i64 = conn
|
||||||
|
.query_row("SELECT token_total FROM conversation_checkpoints WHERE id = 'ck1'", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(tt2, 170_000, "重跑回填结果应一致");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// V50 回填不误清零:老快照无消息级 token(重算=0)时保留原值(不 UPDATE)。
|
||||||
|
#[test]
|
||||||
|
fn v50_backfill_keeps_legacy_when_no_message_tokens() {
|
||||||
|
let conn = setup_legacy_db();
|
||||||
|
conn.execute_batch(
|
||||||
|
"CREATE TABLE IF NOT EXISTS 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);
|
||||||
|
INSERT INTO conversation_checkpoints (id, conv_id, snapshot, token_total, created_at)\
|
||||||
|
VALUES ('ck2','c2','[{\"role\":\"user\",\"content\":\"hi\"}]', 42, '100');",
|
||||||
|
)
|
||||||
|
.expect("建表塞老快照");
|
||||||
|
migrate_v50(&conn).expect("v50 应成功");
|
||||||
|
let tt: i64 = conn
|
||||||
|
.query_row("SELECT token_total FROM conversation_checkpoints WHERE id = 'ck2'", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(tt, 42, "无消息级 token 的老快照应保留原值不误清零");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// sum_snapshot_tokens 纯函数:字段缺失/非法 JSON 按 0 计。
|
||||||
|
#[test]
|
||||||
|
fn sum_snapshot_tokens_handles_missing_and_bad_json() {
|
||||||
|
assert_eq!(sum_snapshot_tokens("not json"), 0, "非法 JSON 应返回 0");
|
||||||
|
assert_eq!(sum_snapshot_tokens("[]"), 0, "空数组应为 0");
|
||||||
|
assert_eq!(
|
||||||
|
sum_snapshot_tokens(r#"[{"prompt_tokens":10},{"completion_tokens":5},{"foo":1}]"#),
|
||||||
|
15,
|
||||||
|
"逐条相加,缺字段按 0"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// V53 幂等:最小老库补建 working_context_json 列;重跑(含版本号回退模拟崩溃断点)
|
||||||
|
/// 不炸不报 duplicate column;列可写可读(NULL 语义=无 WorkingContext)。
|
||||||
|
#[test]
|
||||||
|
fn v53_working_context_json_idempotent() {
|
||||||
|
let conn = setup_legacy_db();
|
||||||
|
assert!(
|
||||||
|
!column_exists(&conn, "ai_conversations", "working_context_json"),
|
||||||
|
"迁移前应无 working_context_json 列"
|
||||||
|
);
|
||||||
|
|
||||||
|
migrate_v53(&conn).expect("v53 首次迁移应成功");
|
||||||
|
assert!(column_exists(&conn, "ai_conversations", "working_context_json"), "应有 working_context_json 列");
|
||||||
|
let v: i64 = conn
|
||||||
|
.query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(v, 53, "应写入版本号 53");
|
||||||
|
|
||||||
|
// 模拟崩溃断点:版本号回退但列已建 → 重跑走 column_exists 短路
|
||||||
|
conn.execute("DELETE FROM schema_version WHERE version = 53", []).unwrap();
|
||||||
|
migrate_v53(&conn).expect("列已存在时重跑应幂等跳过 ALTER 不报错");
|
||||||
|
let v2: i64 = conn
|
||||||
|
.query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(v2, 53, "重跑后版本号恢复 53");
|
||||||
|
|
||||||
|
// 列可写可读;老会话行(不带该列插入)读回 NULL
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO ai_conversations (id, messages, created_at, updated_at, working_context_json) \
|
||||||
|
VALUES ('c1', '[]', '0', '0', '{\"goals\":[],\"decisions\":[]}')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO ai_conversations (id, messages, created_at, updated_at) VALUES ('c2', '[]', '0', '0')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let got: Option<String> = conn
|
||||||
|
.query_row("SELECT working_context_json FROM ai_conversations WHERE id = 'c1'", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert!(got.is_some(), "working_context_json 应可写可读");
|
||||||
|
let legacy: Option<String> = conn
|
||||||
|
.query_row("SELECT working_context_json FROM ai_conversations WHERE id = 'c2'", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert!(legacy.is_none(), "老会话行应为 NULL(无 WorkingContext)");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,19 @@ pub(crate) fn column_exists(conn: &Connection, table: &str, col: &str) -> bool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 检测表是否存在(幂等迁移辅助)。
|
||||||
|
///
|
||||||
|
/// [`column_exists`] 对不存在的表与缺列都返回 false,无法区分——迁移里「加列」需先确认
|
||||||
|
/// 表在(否则 ALTER 报 no such table)。sqlite_master 精确查名(非 LIKE,避免前缀误命中)。
|
||||||
|
pub(crate) fn table_exists(conn: &Connection, table: &str) -> bool {
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT COUNT(*) > 0 FROM sqlite_master WHERE type = 'table' AND name = ?1",
|
||||||
|
[table],
|
||||||
|
|r| r.get::<_, bool>(0),
|
||||||
|
)
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -74,4 +87,16 @@ mod tests {
|
|||||||
assert!(!column_exists(&conn, "probe_t", "prompt"));
|
assert!(!column_exists(&conn, "probe_t", "prompt"));
|
||||||
assert!(!column_exists(&conn, "probe_t", "model_configs"));
|
assert!(!column_exists(&conn, "probe_t", "model_configs"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// table_exists 精确匹配表名,不误命中前缀/近似名(与 column_exists 区分表缺失与缺列)。
|
||||||
|
#[test]
|
||||||
|
fn table_exists_matches_exact_name() {
|
||||||
|
let conn = Connection::open_in_memory().expect("open in-memory db");
|
||||||
|
conn.execute_batch("CREATE TABLE probe_t (id TEXT);")
|
||||||
|
.expect("create probe table");
|
||||||
|
assert!(table_exists(&conn, "probe_t"));
|
||||||
|
assert!(!table_exists(&conn, "probe"), "不误命中前缀");
|
||||||
|
assert!(!table_exists(&conn, "probe_t2"), "不误命中扩展名");
|
||||||
|
assert!(!table_exists(&conn, "ghost"), "未建表返回 false");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,11 @@ pub struct ProjectRecord {
|
|||||||
pub path: Option<String>,
|
pub path: Option<String>,
|
||||||
/// 技术栈 JSON 数组字符串(如 ["rust","vue","tauri"],由探测填充,可空)
|
/// 技术栈 JSON 数组字符串(如 ["rust","vue","tauri"],由探测填充,可空)
|
||||||
pub stack: Option<String>,
|
pub stack: Option<String>,
|
||||||
|
/// 项目归档标记(归档功能 P2,V48)。true=归档态,退出主视图(list_active/
|
||||||
|
/// list_active_with_activity/list_by_query 恒带 archived = 0),仅在归档视图可见。
|
||||||
|
/// 与软删正交:回收站=删除缓冲,归档=低频可见保检索。#[serde(default)] 兼容旧前端 JSON。
|
||||||
|
#[serde(default)]
|
||||||
|
pub archived: bool,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
@@ -385,6 +390,21 @@ pub struct AiConversationRecord {
|
|||||||
/// 无人值守任务边界快照(V45):TaskBoundary JSON;None=未声明(现状逐次审批)。
|
/// 无人值守任务边界快照(V45):TaskBoundary JSON;None=未声明(现状逐次审批)。
|
||||||
/// 生命周期=会话存续(发起时一次声明,随会话同亡),save 随行写/switch 读还原。
|
/// 生命周期=会话存续(发起时一次声明,随会话同亡),save 随行写/switch 读还原。
|
||||||
pub boundary_json: Option<String>,
|
pub boundary_json: Option<String>,
|
||||||
|
/// loop 生命周期标记(V49,断点续跑底座):None=非任务会话/已收敛清理;
|
||||||
|
/// Some("running")=任务 loop 在跑(spawn 时置起);Some("parked")=进程重启时检测到
|
||||||
|
/// running 残留 → 启动扫描改写,侧栏标「任务中断可恢复」+ 一键恢复。
|
||||||
|
/// 纯元数据标记不进 update_full 通用 UPDATE,走专用 set_loop_state。
|
||||||
|
#[serde(default)]
|
||||||
|
pub loop_state: Option<String>,
|
||||||
|
/// 会话终局态(V50):active=进行中/默认(含正常完成),incomplete=停靠/断流等未完成终局,
|
||||||
|
/// error=循环错误终局。与 loop_state(运行期 running/parked,管「能否续跑」)正交——
|
||||||
|
/// 本列回答「会话如何结束」,供未完成率等统计。不进 update_full,走专用 set_status 单点写。
|
||||||
|
pub status: String,
|
||||||
|
/// WorkingContext 持久化(V53,跨会话召回 L4 重启不失忆):WorkingContext JSON 序列化,
|
||||||
|
/// 存储层按不透明 TEXT 存取(结构归 df-ai)。NULL=无上下文(老会话/全空),行为同旧;
|
||||||
|
/// save 随行写/会话载入与 parked 恢复读还原,随会话级联(对齐 boundary_json 模式)。
|
||||||
|
#[serde(default)]
|
||||||
|
pub working_context_json: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// AI 工具执行审计记录
|
/// AI 工具执行审计记录
|
||||||
@@ -408,6 +428,11 @@ pub struct AiToolExecutionRecord {
|
|||||||
pub requested_at: String,
|
pub requested_at: String,
|
||||||
pub executed_at: Option<String>,
|
pub executed_at: Option<String>,
|
||||||
pub decided_by: Option<String>, // human/auto
|
pub decided_by: Option<String>, // human/auto
|
||||||
|
/// 纯执行耗时(毫秒,V50):工具真正执行的墙钟时长(执行前→执行后),与审批等待解耦。
|
||||||
|
/// None=老数据 / 未测路径(缓存命中/未真执行)。审计面板耗时统计用本列,替代
|
||||||
|
/// requested_at→executed_at(后者在审批路径混入等待人工批准时长)。
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub duration_ms: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 消息记录(ai_messages 表,消息拆分存储)。
|
/// 消息记录(ai_messages 表,消息拆分存储)。
|
||||||
@@ -459,6 +484,37 @@ pub struct AiMessageRecord {
|
|||||||
/// 本轮 prompt 是否估算值(round_usage.prompt_tokens==0 → estimated_prompt 兜底打标)。
|
/// 本轮 prompt 是否估算值(round_usage.prompt_tokens==0 → estimated_prompt 兜底打标)。
|
||||||
/// reload 逐条回显「估算」标注;老消息 NULL → None(向前兼容)。
|
/// reload 逐条回显「估算」标注;老消息 NULL → None(向前兼容)。
|
||||||
pub is_estimated: Option<bool>,
|
pub is_estimated: Option<bool>,
|
||||||
|
/// 消息来源 JSON(#958 溯源,MessageSource 序列化;V52 加列)。
|
||||||
|
/// NULL=桌面本端(既有语义);非空=外部渠道(飞书 message_id 等,可跳原消息追溯)
|
||||||
|
pub source: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 待发箱记录(ai_outbox 表,V51)。
|
||||||
|
///
|
||||||
|
/// 队列与定时消息统一实体:队列项 due_at=NULL(会话空闲即发),定时项 due_at=epoch_ms(到点且空闲即发)。
|
||||||
|
/// 表是唯一真相源(前端内存队列退化为该会话 outbox 的只读视图)。设计见
|
||||||
|
/// docs/02-架构设计/专项设计/定时消息与待发箱持久化-设计-2026-10-10.md §3.1。
|
||||||
|
///
|
||||||
|
/// kind:queue(尽快)| scheduled(定时);预留 task(937 对话内定时任务)。
|
||||||
|
/// status:pending|sending|sent|failed|canceled。时间戳均 epoch 毫秒(存储层不转字符串本表特例:
|
||||||
|
/// due_at 需按数值比较排序,created_at/updated_at/sent_at 同为毫秒整数便于调度器直接比较)。
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AiOutboxRecord {
|
||||||
|
pub id: String,
|
||||||
|
pub conversation_id: String,
|
||||||
|
pub kind: String,
|
||||||
|
pub text: String,
|
||||||
|
pub skill: Option<String>,
|
||||||
|
pub model_override: Option<String>,
|
||||||
|
pub parts_json: Option<String>,
|
||||||
|
pub spans_json: Option<String>,
|
||||||
|
pub due_at: Option<i64>,
|
||||||
|
pub status: String,
|
||||||
|
pub attempts: i64,
|
||||||
|
pub last_error: Option<String>,
|
||||||
|
pub created_at: i64,
|
||||||
|
pub updated_at: i64,
|
||||||
|
pub sent_at: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ fn project(id: &str) -> ProjectRecord {
|
|||||||
idea_id: None,
|
idea_id: None,
|
||||||
path: None,
|
path: None,
|
||||||
stack: None,
|
stack: None,
|
||||||
|
archived: false,
|
||||||
created_at: now_ts(),
|
created_at: now_ts(),
|
||||||
updated_at: now_ts(),
|
updated_at: now_ts(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,12 +11,25 @@
|
|||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// 当前 Hello 握手协议版本。
|
||||||
|
///
|
||||||
|
/// 协商规则:旧客户端 Hello 缺 `proto` 字段 → 视为 v1(向后兼容);
|
||||||
|
/// 高于对端支持版本时由 peer 记录告警,为后续破坏性变更预留通知位。
|
||||||
|
pub const HELLO_PROTO_VERSION: u32 = 1;
|
||||||
|
|
||||||
|
/// `proto` 缺省值:旧客户端不带该字段时按 v1 处理(向后兼容)。
|
||||||
|
fn default_proto() -> u32 {
|
||||||
|
HELLO_PROTO_VERSION
|
||||||
|
}
|
||||||
|
|
||||||
/// 握手身份宣告帧(对齐 u-relay::relay::Hello)
|
/// 握手身份宣告帧(对齐 u-relay::relay::Hello)
|
||||||
///
|
///
|
||||||
/// 桌面端 WS 连 `/ws/agent` 后,首条消息发此 JSON,relay 校验 kind 与 token。
|
/// 桌面端 WS 连 `/ws/agent` 后,首条消息发此 JSON,relay 校验 kind 与 token。
|
||||||
/// 字段名/serde rename 与 u-relay 严格一致(`#[serde(rename_all="snake_case")]`),
|
/// 字段名/serde rename 与 u-relay 严格一致(`#[serde(rename_all="snake_case")]`),
|
||||||
/// 否则 relay 反序列化失败 → 握手被拒。
|
/// 否则 relay 反序列化失败 → 握手被拒。
|
||||||
///
|
///
|
||||||
|
/// `proto` 为协议版本,供 relay 端做版本协商(缺字段按 v1,新增破坏性变更时递增)。
|
||||||
|
///
|
||||||
/// 注意:本 crate 不依赖 u-relay(避免 lib→server 反向依赖),自行镜像协议结构。
|
/// 注意:本 crate 不依赖 u-relay(避免 lib→server 反向依赖),自行镜像协议结构。
|
||||||
/// 协议字段如有变更须同步 u-relay::relay::Hello 与本类型。
|
/// 协议字段如有变更须同步 u-relay::relay::Hello 与本类型。
|
||||||
///
|
///
|
||||||
@@ -30,6 +43,9 @@ pub struct Hello {
|
|||||||
pub channel_id: String,
|
pub channel_id: String,
|
||||||
/// 配对 token(env U_RELAY_TOKEN 同源)
|
/// 配对 token(env U_RELAY_TOKEN 同源)
|
||||||
pub token: String,
|
pub token: String,
|
||||||
|
/// 协议版本(缺省按 v1,兼容旧客户端)
|
||||||
|
#[serde(default = "default_proto")]
|
||||||
|
pub proto: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hello.kind 传输表示(serde 字符串,与 u-relay::ClientKindWire 一致)
|
/// Hello.kind 传输表示(serde 字符串,与 u-relay::ClientKindWire 一致)
|
||||||
@@ -155,3 +171,28 @@ pub enum TunnelMessage {
|
|||||||
/// 云后端转发的指令(上行,发往桌面端)
|
/// 云后端转发的指令(上行,发往桌面端)
|
||||||
Command(TunnelCommand),
|
Command(TunnelCommand),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hello_proto_defaults_to_v1_when_absent() {
|
||||||
|
// 旧客户端不发 proto → 反序列化按 v1,保证向后兼容
|
||||||
|
let json = r#"{"kind":"agent","channel_id":"c1","token":"t"}"#;
|
||||||
|
let h: Hello = serde_json::from_str(json).expect("缺 proto 应可解析");
|
||||||
|
assert_eq!(h.proto, HELLO_PROTO_VERSION);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hello_serializes_proto_field() {
|
||||||
|
let h = Hello {
|
||||||
|
kind: HelloKind::Agent,
|
||||||
|
channel_id: "c1".into(),
|
||||||
|
token: "t".into(),
|
||||||
|
proto: HELLO_PROTO_VERSION,
|
||||||
|
};
|
||||||
|
let v: serde_json::Value = serde_json::to_value(&h).unwrap();
|
||||||
|
assert_eq!(v["proto"], HELLO_PROTO_VERSION);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ use tokio_tungstenite::tungstenite::protocol::CloseFrame;
|
|||||||
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
||||||
|
|
||||||
use crate::error::{Result, TunnelError};
|
use crate::error::{Result, TunnelError};
|
||||||
use crate::events::{Hello, HelloKind, RelayControlError, TunnelEvent};
|
use crate::events::{Hello, HelloKind, RelayControlError, TunnelEvent, HELLO_PROTO_VERSION};
|
||||||
|
|
||||||
/// 收到 commander→agent 指令 payload 时的回调类型(Boxed Future,在收发循环 task 内 await)
|
/// 收到 commander→agent 指令 payload 时的回调类型(Boxed Future,在收发循环 task 内 await)
|
||||||
///
|
///
|
||||||
@@ -105,9 +105,13 @@ const HANDSHAKE_PROBE: Duration = Duration::from_secs(3);
|
|||||||
/// 包 `tokio::time::timeout` 在此窗口内未完成 → 返 TunnelError::Connect 显式错误。
|
/// 包 `tokio::time::timeout` 在此窗口内未完成 → 返 TunnelError::Connect 显式错误。
|
||||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
|
const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
|
||||||
|
|
||||||
/// 心跳间隔(应用层 Ping,补协议层 keepalive,防 NAT 连接表超时回收)
|
/// 心跳间隔(WS 协议层 Ping,补 TCP keepalive,防 NAT/反代空闲回收连接)。
|
||||||
/// u-relay 心跳走应用层 control 帧 ping→pong,relay 本地直回不转对端
|
///
|
||||||
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(25);
|
/// 上界约束:链路最短空闲超时在反代侧(生产 nginx `proxy_read_timeout`,配置样例见
|
||||||
|
/// u-relay wire 协议文档 §5.2,最小 300s),间隔须显著小于它并留余量——容忍数次心跳
|
||||||
|
/// 丢失或网络抖动仍不被判定空闲断链。取 30s:满足约束,且与 commander 参考实现
|
||||||
|
/// (u-ppt 30s ping)节拍一致,避免两端看门狗口径不一导致误重连。
|
||||||
|
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
/// 重连初始退避
|
/// 重连初始退避
|
||||||
const RECONNECT_BASE: Duration = Duration::from_secs(1);
|
const RECONNECT_BASE: Duration = Duration::from_secs(1);
|
||||||
@@ -201,7 +205,8 @@ impl TunnelClient for WsTunnelClient {
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
tracing::info!(%url, "WS 连接已建立,开始 Hello 握手");
|
// 版本协商(本端宣告):Hello 携带 proto,供 relay 侧比对决定兼容性
|
||||||
|
tracing::info!(%url, proto = HELLO_PROTO_VERSION, "WS 连接已建立,开始 Hello 握手");
|
||||||
|
|
||||||
let (mut sink, mut stream) = ws_stream.split();
|
let (mut sink, mut stream) = ws_stream.split();
|
||||||
|
|
||||||
@@ -210,6 +215,7 @@ impl TunnelClient for WsTunnelClient {
|
|||||||
kind: HelloKind::Agent,
|
kind: HelloKind::Agent,
|
||||||
channel_id: channel_id.to_string(),
|
channel_id: channel_id.to_string(),
|
||||||
token: token.to_string(),
|
token: token.to_string(),
|
||||||
|
proto: HELLO_PROTO_VERSION,
|
||||||
};
|
};
|
||||||
let hello_json = serde_json::to_string(&hello)?;
|
let hello_json = serde_json::to_string(&hello)?;
|
||||||
sink.send(WsMessage::Text(hello_json)).await.map_err(|e| {
|
sink.send(WsMessage::Text(hello_json)).await.map_err(|e| {
|
||||||
|
|||||||
@@ -315,6 +315,10 @@ impl ProjectStatus {
|
|||||||
///
|
///
|
||||||
/// Input Augmentation 层 ProjectResolver/TaskResolver 把记录的 String status 投影成
|
/// Input Augmentation 层 ProjectResolver/TaskResolver 把记录的 String status 投影成
|
||||||
/// 类型化枚举用。未知字符串返 None。
|
/// 类型化枚举用。未知字符串返 None。
|
||||||
|
///
|
||||||
|
/// ⚠️ TYPES-2:本函数返 None 是显式信号,但若调用方用 `unwrap_or_default()` 兜底,
|
||||||
|
/// 未知值会**静默归 Planning**(ProjectStatus::default),掩盖数据异常。建议调用方对
|
||||||
|
/// None 分支记 warn 审计(而非静默兜底),便于发现脏 status。
|
||||||
pub fn from_db_str(s: &str) -> Option<Self> {
|
pub fn from_db_str(s: &str) -> Option<Self> {
|
||||||
Some(match s {
|
Some(match s {
|
||||||
"planning" => ProjectStatus::Planning,
|
"planning" => ProjectStatus::Planning,
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ use df_types::types::{NodeId, NodeStatus};
|
|||||||
pub struct StateMachine {
|
pub struct StateMachine {
|
||||||
/// 节点状态映射(Arc 共享,clone 浅拷贝同一 HashMap)
|
/// 节点状态映射(Arc 共享,clone 浅拷贝同一 HashMap)
|
||||||
states: Arc<Mutex<HashMap<NodeId, NodeStatus>>>,
|
states: Arc<Mutex<HashMap<NodeId, NodeStatus>>>,
|
||||||
|
/// 取消信号唤醒器(FR-P4):set_cancelled 时 notify_waiters,
|
||||||
|
/// 阻塞节点(HumanNode)用 notified() 被动等待,替代 500ms 轮询。
|
||||||
|
/// 与 states 同 Arc 共享,clone 浅拷贝同一 Notify。
|
||||||
|
cancel_notify: Arc<tokio::sync::Notify>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StateMachine {
|
impl StateMachine {
|
||||||
@@ -37,9 +41,19 @@ impl StateMachine {
|
|||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
states: Arc::new(Mutex::new(HashMap::new())),
|
states: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
cancel_notify: Arc::new(tokio::sync::Notify::new()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 获取取消信号等待器(FR-P4)
|
||||||
|
///
|
||||||
|
/// 返回的 `Notified` future 在 `set_cancelled` 被调用时 resolve。
|
||||||
|
/// 调用方应配合 `is_cancelled` 做条件判断(Notify 无状态,可能错过
|
||||||
|
/// 早于 await 的 notify,故 select! 分支 resolve 后仍须复查 is_cancelled)。
|
||||||
|
pub fn cancel_notified(&self) -> tokio::sync::futures::Notified<'_> {
|
||||||
|
self.cancel_notify.notified()
|
||||||
|
}
|
||||||
|
|
||||||
/// 获取节点状态
|
/// 获取节点状态
|
||||||
///
|
///
|
||||||
/// 锁中毒时降级返回 `NodeStatus::Pending`(保守默认:视为未启动,
|
/// 锁中毒时降级返回 `NodeStatus::Pending`(保守默认:视为未启动,
|
||||||
@@ -139,6 +153,10 @@ impl StateMachine {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// FR-P4:唤醒所有等待取消信号的阻塞节点(HumanNode notified 分支)。
|
||||||
|
// notify_waiters 不持锁调用,避免与 states 锁耦合;Notify 无状态,
|
||||||
|
// 等待方 resolve 后仍复查 is_cancelled 兜底(防 notify 早于 await 丢失)。
|
||||||
|
self.cancel_notify.notify_waiters();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取所有状态快照(clone 返回,调用方持独立副本)
|
/// 获取所有状态快照(clone 返回,调用方持独立副本)
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# DevFlow 团队级基建 — 部署与运维
|
||||||
|
|
||||||
|
> 2026-10-05 落地。架构定位见《数据存储架构-定稿-2026-10-05》(团队级 = 汇聚节点 + 对象存储)。
|
||||||
|
|
||||||
|
## 资源清单
|
||||||
|
|
||||||
|
| 资源 | 位置 | 版本/规格 | 用途 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| PG 容器 `df-pg` | ecs-os(39.99.131.191) | PostgreSQL 18.6-alpine | devflow 团队库(服务端数据面) |
|
||||||
|
| OSS 私有桶 `devflow-data` | 阿里云 cn-hongkong | 私有 ACL | 团队文件存储(温层,内容寻址) |
|
||||||
|
| 域名 `df-oss.1216.top` | CNAME → devflow-data.oss-cn-hongkong.aliyuncs.com | HTTPS(泛域名证书已绑) | 桶访问入口 |
|
||||||
|
|
||||||
|
## PG(df-pg)
|
||||||
|
|
||||||
|
- 连接:`127.0.0.1:5432`(仅本机/内网,**不暴露公网**);服务端将来部署 ecs-os 时走 localhost
|
||||||
|
- 库:`devflow`(容器首启自动创建);用户 postgres
|
||||||
|
- 凭据:`/data/docker-apps/pg/.pg-credentials`(600 权限)
|
||||||
|
- compose:`/data/docker-apps/pg/docker-compose.yml`(服务名=容器名 df-pg,数据卷自包含 `/data/docker-apps/pg/data`)
|
||||||
|
- 备份:cron 每日 03:30 `/data/scripts/pg-backup.sh` → `/data/backup/pg/` 保留 7 份
|
||||||
|
|
||||||
|
## OSS(devflow-data)
|
||||||
|
|
||||||
|
- 访问:私有桶,读写须签名(oss2 AK:ops-kit/app.yml aliyun.accounts[4],即 zhub-site 归属主账号)
|
||||||
|
- HTTPS:泛域名 `*.1216.top` 证书(suke-dev /etc/letsencrypt/live/1216.top)已绑 CNAME;归属验证 TXT 已用毕删除
|
||||||
|
- 服务端用法:oss2 SDK 签发临时 URL 供客户端下载;上传走 SDK 直传
|
||||||
|
- 续期注意:证书到期随泛域名统一续期(Let's Encrypt DNS-01),续期后需重新 put_bucket_cname(参考 cert-manage)
|
||||||
|
|
||||||
|
## 升级记录
|
||||||
|
|
||||||
|
- 2026-10-05 建 PG 16 当日即升 18.6(库空无损);compose 风格对齐 ecs-os 惯例(注释头/服务名=容器名/卷自包含);旧顶层卷 /data/pg-data 已清
|
||||||
|
- 选型记录:文件桶定阿里云 OSS(七牛私有桶缺主账号 SK,qshell 双账号 uppt/lakeapp-suke 已确认;七牛保留公开 CDN 分工 img.1216.top)
|
||||||
|
|
||||||
|
## 关联
|
||||||
|
|
||||||
|
- 架构:《数据存储架构-定稿-2026-10-05》§六 团队级/平台级
|
||||||
|
- 服务索引:kms/服务索引-全机器.md(df-oss/df-pg 两行)
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
# DevFlow 签名与发布 SOP
|
||||||
|
|
||||||
|
> 2026-10-10 制定。适用对象:上游发布者(你)。目标:让「签名 → 打包 → 发布清单 → 客户端自动升级」这条链**可重复执行、可验证、密钥可控**。
|
||||||
|
> 相关文档:《版本更新与自动升级-设计-2026-10-09.md》(链路设计)、《团队级基建-部署与运维-2026-10-05.md》(分发设施)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、密钥体系一句话
|
||||||
|
|
||||||
|
DevFlow 用 **minisign / ed25519** 非对称签名:
|
||||||
|
|
||||||
|
| 角色 | 文件 | 位置 | 作用 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 私钥(绝密) | `devflow.key` | `%USERPROFILE%\.tauri\` | 签名安装包,**泄露=可伪造升级** |
|
||||||
|
| 公钥(公开) | `devflow.key.pub` | 同上 | 验签,**同时硬编码进客户端** |
|
||||||
|
| 公钥副本 | `pubkey` 字段 | `src-tauri/tauri.conf.json` `plugins.updater.pubkey` | 编译进 exe,客户端用它验签 |
|
||||||
|
|
||||||
|
**核心约束**:客户端 exe 里的公钥是**编译期固化**的。换密钥 = 换公钥 = 已分发的老客户端**永远无法自动升级到新密钥签的包**(只能手动重装)。所以密钥轮换是**断链级操作**,非必要不做。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、密钥文件格式(排障必知)
|
||||||
|
|
||||||
|
Tauri 私钥文件是 **base64 文本包 base64** 的两层结构:
|
||||||
|
|
||||||
|
```
|
||||||
|
外层 base64 → 解码得文本,两行:
|
||||||
|
L1 untrusted comment: ...
|
||||||
|
L2 <内层 base64> → 解码得 payload 字节
|
||||||
|
```
|
||||||
|
|
||||||
|
payload 布局:
|
||||||
|
|
||||||
|
| 长度 | 类型 | 含义 |
|
||||||
|
|---|---|---|
|
||||||
|
| 106 B | 明文(未加密) | `sig_alg(2)+keynum(8)+sk(64)+ck(32)` |
|
||||||
|
| 158 B | 加密(Scrypt KDF 段) | `sig_alg(2)+kdf_alg(2)+ck_alg(2)+salt(32)+opslimit(8)+memlimit(8)+keynum(8)+sk(64)+ck(32)` |
|
||||||
|
|
||||||
|
> ⚠️ **`cargo tauri signer generate` 产出的都是 158 B(带 KDF 段)**——即使你「没设密码」,结构上仍是加密布局,只是口令为空。所以**不能用文件大小判断是否设了密码**,必须用「空密码能否签名」实测(见 §六 自检)。
|
||||||
|
|
||||||
|
**公钥** payload 固定 42 B:`sig_alg(2)+keynum(8)+key(32)`。`keynum = payload[2:10]`,是**密钥指纹**,用于快速比对「配置公钥 ↔ 私钥 ↔ 签名」三者是否同一把钥匙。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、密钥生命周期操作
|
||||||
|
|
||||||
|
### 3.1 生成(首次或轮换)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 无密码(不推荐,当前生产就是这个状态)
|
||||||
|
cargo tauri signer generate -w "$env:USERPROFILE\.tauri\devflow.key"
|
||||||
|
|
||||||
|
# 带密码(推荐)
|
||||||
|
cargo tauri signer generate -p "<强口令>" -w "$env:USERPROFILE\.tauri\devflow.key"
|
||||||
|
```
|
||||||
|
|
||||||
|
生成后把 `.pub` 的内容填进 `src-tauri/tauri.conf.json` 的 `plugins.updater.pubkey`,然后**重新编译客户端**才生效。
|
||||||
|
|
||||||
|
### 3.2 备份(强制)
|
||||||
|
|
||||||
|
私钥丢失 = 无法再签发升级包 = 只能原地冻版。**生成后立刻**:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 复制 devflow.key / devflow.key.pub 到离线介质(U 盘 / 加密卷)
|
||||||
|
2. 记录密钥指纹(keynum),见 §六
|
||||||
|
3. 关键:口令单独记录(不要和私钥同处存储)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 轮换(断链级,慎用)
|
||||||
|
|
||||||
|
```
|
||||||
|
代价:已分发的所有客户端自动升级链断裂 → 必须手动重装
|
||||||
|
步骤:
|
||||||
|
1. 生成新密钥对 → 旧密钥改名归档(不要删,历史包验签要用)
|
||||||
|
2. 更新 tauri.conf.json pubkey → 重新编译 → 发布「桥接版」
|
||||||
|
(桥接版仍用旧私钥签,老客户端能升上来;桥接版内含新公钥,之后再用新私钥签)
|
||||||
|
3. 桥接版铺开 ≥1 个版本周期后,才切新私钥签发
|
||||||
|
```
|
||||||
|
|
||||||
|
> 单管理员自用场景可简化:接受「一次手动重装」,直接换。对外分发则**必须走桥接版**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、发布流程(一键脚本)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd E:\wk-lab\devflow
|
||||||
|
.\scripts\publish-release.ps1 # 完整发布(含 tauri build)
|
||||||
|
.\scripts\publish-release.ps1 -Notes "修复xx" # 手动指定更新说明
|
||||||
|
.\scripts\publish-release.ps1 -SkipBuild # 复用已有产物,只生成清单+上传
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本六步(每步失败即 `exit 1`,不静默):
|
||||||
|
|
||||||
|
| 步 | 动作 | 失败含义 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | **版本三源校验**(Cargo.toml / tauri.conf.json / package.json) | 三处版本号不一致,先对齐(见 §七 坑 2) |
|
||||||
|
| 2 | 签名私钥就位检查(env 优先,回退 `~/.tauri/devflow.key`) | 私钥缺失 |
|
||||||
|
| 3 | `cargo tauri build`(产出 `devflow_<ver>_x64-setup.exe` + `.exe.sig`) | 编译/打包失败 |
|
||||||
|
| 4 | 生成 `latest.json`(**UTF-8 无 BOM**) | — |
|
||||||
|
| 5 | 上传 `ssh-proxy` → `suke-dev:/var/www/app-site/download/devflow/` | SSH/上传失败 |
|
||||||
|
| 6 | 远端 `ls` + `cat latest.json` 回显验证 | 上传未落盘 |
|
||||||
|
|
||||||
|
**发布清单契约**(`latest.json`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": "0.1.2",
|
||||||
|
"notes": "...",
|
||||||
|
"pub_date": "2026-10-10T14:00:00Z",
|
||||||
|
"platforms": {
|
||||||
|
"windows-x86_64": {
|
||||||
|
"signature": "<.exe.sig 全文>",
|
||||||
|
"url": "https://app.1216.top/download/devflow/devflow_0.1.2_x64-setup.exe"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`app.1216.top` 已配 `no-cache`,改完即时生效,无需刷 CDN。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、手工签名(脚本之外,补签/演练用)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 无密码密钥
|
||||||
|
cargo tauri signer sign -f "$env:USERPROFILE\.tauri\devflow.key" -p "" --app-version 0.1.2 <file>
|
||||||
|
|
||||||
|
# 带密码密钥(口令走参数或环境变量,勿写进脚本)
|
||||||
|
cargo tauri signer sign -f "$env:USERPROFILE\.tauri\devflow.key" -p "<口令>" --app-version 0.1.2 <file>
|
||||||
|
|
||||||
|
# 环境变量方式(CI 友好)
|
||||||
|
$env:TAURI_SIGNING_PRIVATE_KEY = "$env:USERPROFILE\.tauri\devflow.key"
|
||||||
|
$env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD = "<口令>"
|
||||||
|
```
|
||||||
|
|
||||||
|
> **务必带 `--app-version`**:它把版本号写进签名的 trusted comment 并被签名覆盖。若客户端/服务端开启 `requireSignedVersion`,清单里改版本号就会被拒——这是防「篡改清单把新版号配旧包」的关键绑定。`cargo tauri build` 会自动带上,手工签要自己带。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、验证(发布后必做)
|
||||||
|
|
||||||
|
### 6.1 一键验签(推荐)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
node temp/verify_sig.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
输出:配置公钥 keynum / 每个 exe 的 keynum 匹配性 / **ed25519 实际验签结果** / `latest.json` 与 `.sig` 内容一致性。
|
||||||
|
验签数学:payload 首 2 字节 `ED` 表示 **blake2b512 预哈希**模式,即 `verify(blake2b512(file), sig)`;`Ed` 表示直接对原文。
|
||||||
|
|
||||||
|
### 6.2 密钥指纹自检
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python temp/sign_state_check.py # 三源 keynum 对照
|
||||||
|
python temp/key_layout_final.py # 私钥格式(106/158B)判定
|
||||||
|
```
|
||||||
|
|
||||||
|
判据:**配置公钥 keynum == 预期私钥对应 keynum**。不等 = 拿错钥匙,签出来的包客户端一律拒。
|
||||||
|
|
||||||
|
### 6.3 「私钥是否设了密码」实测(唯一可信方法)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$probe = "$env:TEMP\pw_probe.txt"; "x" | Set-Content $probe -NoNewline
|
||||||
|
cargo tauri signer sign -f "$env:USERPROFILE\.tauri\devflow.key" -p "" "$probe" ; $LASTEXITCODE
|
||||||
|
# exit=0 → 无密码保护;exit≠0 且报 incorrect password → 有密码
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、踩坑记录(实证)
|
||||||
|
|
||||||
|
**坑 1 — `latest.json` 的 UTF-8 BOM**
|
||||||
|
Windows PowerShell 5.1 的 `Out-File -Encoding utf8` / `Set-Content -Encoding utf8` **会写 BOM**(`EF BB BF`)。JSON 解析器遇到 BOM 报 `expected value at line 1 column 1`,表现为「签名没错、包没错,updater 就是拉不到清单」。
|
||||||
|
✅ 修正:`[System.IO.File]::WriteAllText($path, $json, (New-Object System.Text.UTF8Encoding($false)))`。已固化进 `publish-release.ps1`。
|
||||||
|
|
||||||
|
**坑 2 — 版本号三源分叉**
|
||||||
|
版本号存在于三处:`src-tauri/Cargo.toml`(Rust crate / `env!("CARGO_PKG_VERSION")`)、`src-tauri/tauri.conf.json`(打包器实际使用)、`package.json`(前端)。
|
||||||
|
任一处漏改 → 设置页显示与实际安装版本不符(灵异现象)。
|
||||||
|
✅ 修正:`publish-release.ps1` 第 1 步**硬校验三源一致**,不一致直接拒发。2026-10-10 已修 `package.json` 0.1.1 → 0.1.2。
|
||||||
|
|
||||||
|
**坑 3 — 发布脚本硬编码版本号**
|
||||||
|
`swap-install.ps1` 曾硬编码 `devflow_0.1.1_x64-setup.exe`。升版忘改 → 若旧包残留同目录,**静默装回旧版**(伪成功,最危险)。
|
||||||
|
✅ 修正:改 glob 探测 + `Sort-Object LastWriteTime -Descending | Select-Object -First 1`。
|
||||||
|
|
||||||
|
**坑 4 — 用文件大小判密码**
|
||||||
|
`cargo tauri signer generate` 产出的私钥**一律 158 B**(带 Scrypt KDF 段),无密码时只是口令为空。按大小判断会误判。
|
||||||
|
✅ 修正:用 §6.3 空密码实签判定。
|
||||||
|
|
||||||
|
**坑 5 — 密钥文件被误覆盖**
|
||||||
|
2026-10-10 曾发生野密钥覆盖生产密钥,靠备份 `*.bak-20261010-213046` 恢复。
|
||||||
|
✅ 教训:**动密钥前先归档**(`restore-key.ps1` 的归档-恢复-校验三步式可复用);密钥目录加 ACL 防误改(见 §八)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、安全加固(已实施 2026-10-10)
|
||||||
|
|
||||||
|
**威胁模型**:任何能读到 `devflow.key` 的本地进程,都能签发被客户端信任的更新包 → 推恶意升级。
|
||||||
|
|
||||||
|
已实施的缓解(见 `temp/harden_key_acl.ps1`):
|
||||||
|
|
||||||
|
```
|
||||||
|
%USERPROFILE%\.tauri\ 阻断继承,仅 (当前用户 / SYSTEM)
|
||||||
|
%USERPROFILE%\.tauri\devflow.key* 阻断继承,仅 (当前用户 读写删 / SYSTEM 全控)
|
||||||
|
```
|
||||||
|
|
||||||
|
**仍未消除的风险(重要)**:私钥当前**无口令保护**,文件本身即等价于签名能力;ACL 只挡了「非授权本地用户」,挡不住「以当前用户身份运行的程序」。
|
||||||
|
|
||||||
|
**根治方案(需拍板,有代价)**:
|
||||||
|
|
||||||
|
| 方案 | 强度 | 代价 |
|
||||||
|
|---|---|---|
|
||||||
|
| A. 私钥加口令 | 中 | 必须**重新生成密钥对**(`signer` 无 reencrypt 子命令)→ 公钥变 → **换公钥** → 见 §3.3 断链(老客户端需手动重装) |
|
||||||
|
| B. 私钥迁离线介质 | 高 | 每次发布需插盘;CI 不可用 |
|
||||||
|
| C. A+B 组合 | 最高 | 同上,推荐对外分发场景 |
|
||||||
|
| D. 仅 ACL(现状) | 低 | 零代价,只防误改/误读 |
|
||||||
|
|
||||||
|
> 现状为 D。若 DevFlow 仅自用,D 可接受;一旦对外分发,建议升到 C。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、应急:私钥泄露怎么办
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 立即停发:删除远端 latest.json(客户端拉不到清单即停止升级链)
|
||||||
|
2. 评估:泄露时间窗内是否有未授权包被签(检查远端目录文件时间戳)
|
||||||
|
3. 轮换:按 §3.3 生成新密钥对 → 发布桥接版(旧私钥签)→ 铺开后切新私钥
|
||||||
|
4. 通告:对外分发场景需告知用户手动重装
|
||||||
|
```
|
||||||
|
|
||||||
|
**注意:`删除 latest.json` 是唯一的「远端急停」手段**——客户端只在 check 时拉清单,清单不在 = 无法升级。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、日常检查清单
|
||||||
|
|
||||||
|
发布前:
|
||||||
|
- [ ] 版本三源一致(脚本第 1 步自动校验)
|
||||||
|
- [ ] 私钥就位且指纹与配置公钥一致(`node temp/verify_sig.mjs` 的 keynum 对照)
|
||||||
|
- [ ] 密钥已备份(首次/轮换后)
|
||||||
|
|
||||||
|
发布后:
|
||||||
|
- [ ] `node temp/verify_sig.mjs` 全绿(验签 + latest.json 对账)
|
||||||
|
- [ ] 远端 `curl https://app.1216.top/download/devflow/latest.json` 可取且 version 正确
|
||||||
|
- [ ] 老客户端实测一次「检查更新 → 下载 → 安装 → 重启后版本号变新」(B3 端到端)
|
||||||
+29
-2
@@ -4,7 +4,7 @@
|
|||||||
> 上级索引:[../INDEX.md](../INDEX.md)
|
> 上级索引:[../INDEX.md](../INDEX.md)
|
||||||
> 命名规范:见本目录 [文档记录规范-2026-06-14.md](./滚动规范/文档记录规范-2026-06-14.md) §九「文档命名规范」
|
> 命名规范:见本目录 [文档记录规范-2026-06-14.md](./滚动规范/文档记录规范-2026-06-14.md) §九「文档命名规范」
|
||||||
|
|
||||||
本目录是 DevFlow 的**架构设计 / 设计决策 / 设计方案 / 构想**文档集合(不含实现流水,流水走 `PROGRESS.md`;不含审查报告,审查走 `05-代码审查/`)。
|
本目录是 DevFlow 的**架构设计 / 设计决策 / 设计方案 / 构想**文档集合(不含实现流水,流水走 [`../07-项目管理/PROGRESS.md`](../07-项目管理/PROGRESS.md);不含审查报告,审查走 `05-代码审查/`)。
|
||||||
|
|
||||||
按**性质**分四类,每类一个子目录:
|
按**性质**分四类,每类一个子目录:
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
|
|
||||||
| 文档 | 状态 | 核心内容 |
|
| 文档 | 状态 | 核心内容 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| [业务系统设计-2026-06-12.md](./滚动规范/业务系统设计-2026-06-12.md) | 🏗 ARCHITECTURE 载体 | 项目无独立 ARCHITECTURE.md,本文档为实质载体:产品定位 / crate 结构 / 数据模型 / Phase 规划 |
|
| [业务系统设计-2026-06-12.md](./滚动规范/业务系统设计-2026-06-12.md) | 📐 设计 | 产品定位 / crate 结构 / 数据模型 / Phase 规划。项目架构总纲见根 [ARCHITECTURE.md](../../ARCHITECTURE.md) |
|
||||||
| [Phase1架构决策-2026-06-12.md](./滚动规范/Phase1架构决策-2026-06-12.md) | 📐 ADR | Phase 1 引擎骨架阶段架构级选型(ADR-001 引擎不绑定业务 等) |
|
| [Phase1架构决策-2026-06-12.md](./滚动规范/Phase1架构决策-2026-06-12.md) | 📐 ADR | Phase 1 引擎骨架阶段架构级选型(ADR-001 引擎不绑定业务 等) |
|
||||||
| [功能决策记录-2026-06-14.md](./滚动规范/功能决策记录-2026-06-14.md) | 📋 滚动 | 功能**需求规格 + 设计决策规格**(为什么这么定 / 要做什么)。最高频引用(67 入链) |
|
| [功能决策记录-2026-06-14.md](./滚动规范/功能决策记录-2026-06-14.md) | 📋 滚动 | 功能**需求规格 + 设计决策规格**(为什么这么定 / 要做什么)。最高频引用(67 入链) |
|
||||||
| [功能决策记录-归档-2026-06-14.md](./滚动规范/功能决策记录-归档-2026-06-14.md) | 🗄 归档只读 | 从主文档归档:纯流水 / 老 Sprint 决策 / UX 微调 / 已被取代细节 |
|
| [功能决策记录-归档-2026-06-14.md](./滚动规范/功能决策记录-归档-2026-06-14.md) | 🗄 归档只读 | 从主文档归档:纯流水 / 老 Sprint 决策 / UX 微调 / 已被取代细节 |
|
||||||
@@ -92,6 +92,33 @@
|
|||||||
| [测试落地实施方案-2026-07-01.md](./专项设计/测试落地实施方案-2026-07-01.md) | 📐 测试 | 各测试用例的落地方式(Rust 单元/集成/vitest/E2E 手动)、文件位置、Mock 策略、CI 自动化路径 |
|
| [测试落地实施方案-2026-07-01.md](./专项设计/测试落地实施方案-2026-07-01.md) | 📐 测试 | 各测试用例的落地方式(Rust 单元/集成/vitest/E2E 手动)、文件位置、Mock 策略、CI 自动化路径 |
|
||||||
| [devflow集成三方IM-飞书企微钉钉Web-规划设计-2026-10-04.md](./专项设计/devflow集成三方IM-飞书企微钉钉Web-规划设计-2026-10-04.md) | 📐 规划(待评审) | 飞书/企微/钉钉/自定义Web 远程接入 AI Chat:新增 df-im-gateway(WS Commander)复用 u-relay+df-tunnel,桌面端零改动。含必要性分析(§三A)、破坏性分析(§三B)、演进预留(§十 S1-S4:多人/引擎上云/新端)。P0网关→P1 Web→P2飞书→P3钉钉→P4企微 |
|
| [devflow集成三方IM-飞书企微钉钉Web-规划设计-2026-10-04.md](./专项设计/devflow集成三方IM-飞书企微钉钉Web-规划设计-2026-10-04.md) | 📐 规划(待评审) | 飞书/企微/钉钉/自定义Web 远程接入 AI Chat:新增 df-im-gateway(WS Commander)复用 u-relay+df-tunnel,桌面端零改动。含必要性分析(§三A)、破坏性分析(§三B)、演进预留(§十 S1-S4:多人/引擎上云/新端)。P0网关→P1 Web→P2飞书→P3钉钉→P4企微 |
|
||||||
| [三方IM-Rust接入与SDK调研-2026-10-04.md](./专项设计/三方IM-Rust接入与SDK调研-2026-10-04.md) | ✅ 调研完成 | 三平台 Rust SDK 选型:飞书 larksuite-oapi-sdk-rs / 钉钉 dingtalk-stream / 企微 wecom-aibot-rust-sdk。**重大发现:企微智能机器人走 WS 长连接免公网回调,三平台统一形态**。含统一 adapter 映射表与 5 项落地风险清单 |
|
| [三方IM-Rust接入与SDK调研-2026-10-04.md](./专项设计/三方IM-Rust接入与SDK调研-2026-10-04.md) | ✅ 调研完成 | 三平台 Rust SDK 选型:飞书 larksuite-oapi-sdk-rs / 钉钉 dingtalk-stream / 企微 wecom-aibot-rust-sdk。**重大发现:企微智能机器人走 WS 长连接免公网回调,三平台统一形态**。含统一 adapter 映射表与 5 项落地风险清单 |
|
||||||
|
| [上下文管理演进-任务技术设计-2026-07-20.md](./专项设计/上下文管理演进-任务技术设计-2026-07-20.md) | 📐 设计 | 上下文管理演进任务技术设计 |
|
||||||
|
| [上下文管理演进-执行计划-2026-07-20.md](./专项设计/上下文管理演进-执行计划-2026-07-20.md) | 📐 计划 | 上下文管理演进执行计划 |
|
||||||
|
| [上下文管理演进与发散思考-2026-07-20.md](./专项设计/上下文管理演进与发散思考-2026-07-20.md) | 📐 思考 | 上下文管理演进与发散思考 |
|
||||||
|
| [aichat-plan-driven-设计-2026-08-01.md](./专项设计/aichat-plan-driven-设计-2026-08-01.md) | 📐 设计 | AI Chat plan-driven 模式设计 |
|
||||||
|
| [devflow集成u-claw-可行性研究-2026-09-28.md](./专项设计/devflow集成u-claw-可行性研究-2026-09-28.md) | 📐 研究 | devflow 集成 u-claw 可行性研究 |
|
||||||
|
| [跨会话记忆四层架构-设计-2026-09-29.md](./专项设计/跨会话记忆四层架构-设计-2026-09-29.md) | 📐 设计 | 跨会话记忆四层架构 |
|
||||||
|
| [aichat上下文管理-派工与决策单-2026-09-29.md](./专项设计/aichat上下文管理-派工与决策单-2026-09-29.md) | 📋 决策单 | aichat 上下文管理派工与决策 |
|
||||||
|
| [DRY技术债走查报告-2026-10-01.md](./专项设计/DRY技术债走查报告-2026-10-01.md) | 📋 报告 | 全库 DRY 技术债走查 |
|
||||||
|
| [并行化与异步唤醒-设计-2026-10-04.md](./专项设计/并行化与异步唤醒-设计-2026-10-04.md) | 📐 设计 | 并行化与异步唤醒 |
|
||||||
|
| [无人值守长任务-设计-2026-10-04.md](./专项设计/无人值守长任务-设计-2026-10-04.md) | 📐 设计 | 无人值守长任务 |
|
||||||
|
| [上下文三层记忆-设计-2026-10-04.md](./专项设计/上下文三层记忆-设计-2026-10-04.md) | 📐 设计 | 上下文三层记忆 |
|
||||||
|
| [跨会话召回最小闭环-设计-2026-10-04.md](./专项设计/跨会话召回最小闭环-设计-2026-10-04.md) | 📐 设计 | 跨会话召回最小闭环 |
|
||||||
|
| [context-helpers拆分备勘-设计-2026-10-04.md](./专项设计/context-helpers拆分备勘-设计-2026-10-04.md) | 📐 设计 | context-helpers 拆分备勘 |
|
||||||
|
| [namespace淘汰治理-设计-2026-10-04.md](./专项设计/namespace淘汰治理-设计-2026-10-04.md) | 📐 设计 | namespace 淘汰治理 |
|
||||||
|
| [子代理完成卡已消化降级-设计-2026-10-04.md](./专项设计/子代理完成卡已消化降级-设计-2026-10-04.md) | 📐 设计 | 子代理完成卡消化降级 |
|
||||||
|
| [uclaw两侧勘察补全-2026-10-04.md](./专项设计/uclaw两侧勘察补全-2026-10-04.md) | 📋 勘察 | u-claw 两侧勘察补全 |
|
||||||
|
| [u-relay-wire-protocol-2026-10-04.md](./专项设计/u-relay-wire-protocol-2026-10-04.md) | 📐 协议 | u-relay 线协议规范 |
|
||||||
|
| [数据存储架构-定稿-2026-10-05.md](./专项设计/数据存储架构-定稿-2026-10-05.md) | 📐 定稿 | 数据存储架构定稿 |
|
||||||
|
| [知识库模型重审-精细化设计-2026-10-05.md](./专项设计/知识库模型重审-精细化设计-2026-10-05.md) | 📐 设计 | 知识库模型精细化重审 |
|
||||||
|
| [飞书直连接入-形态修正与问题记录-2026-10-05.md](./专项设计/飞书直连接入-形态修正与问题记录-2026-10-05.md) | 📋 记录 | 飞书直连接入形态修正与问题记录 |
|
||||||
|
| [版本更新与自动升级-设计-2026-10-09.md](./专项设计/版本更新与自动升级-设计-2026-10-09.md) | 📐 设计 | 版本更新与自动升级 |
|
||||||
|
| [u-relay-运维手册-2026-10-10.md](./专项设计/u-relay-运维手册-2026-10-10.md) | 📖 手册 | u-relay 运维手册 |
|
||||||
|
| [上下文分层组装器-落地方案-2026-10-10.md](./专项设计/上下文分层组装器-落地方案-2026-10-10.md) | 📐 方案 | 上下文分层组装器落地方案 |
|
||||||
|
| [定时消息与待发箱持久化-设计-2026-10-10.md](./专项设计/定时消息与待发箱持久化-设计-2026-10-10.md) | 📐 设计 | 定时消息与待发箱持久化 |
|
||||||
|
| [对话内定时延时任务-设计-2026-10-10.md](./专项设计/对话内定时延时任务-设计-2026-10-10.md) | 📐 设计 | 对话内定时延时任务 |
|
||||||
|
| [消息渲染管线重构-设计-2026-10-10.md](./专项设计/消息渲染管线重构-设计-2026-10-10.md) | 📐 设计 | 消息渲染管线重构 |
|
||||||
|
| [输入框列表预览与自动编号-设计-2026-10-10.md](./专项设计/输入框列表预览与自动编号-设计-2026-10-10.md) | 📐 设计 | 输入框列表预览与自动编号 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
# 上下文根治设计(2026-10-06)—— 治 223:1 结构性浪费
|
||||||
|
|
||||||
|
> 铁证:84cb104c 会话 2.2 天烧 4.3 亿 tokens,prompt:completion=223:1,日均 1.9 亿。
|
||||||
|
> 本质:每轮全量重发历史——轮数线性,token 爆炸,首 token 延迟同步恶化。不是 bug,是组装策略必然。
|
||||||
|
> 原则:分层组装取代全量重发;历史不删只降级(压缩可逆);对用户透明,对 agent 工具透明。
|
||||||
|
|
||||||
|
## 一、分层上下文(治"全量")
|
||||||
|
|
||||||
|
- **L1 常驻(固定小)**:system_prompt + pinned_goals + 任务状态卡。目标 <8K,永不随轮次增长。
|
||||||
|
- **L2 工作集(滑动窗口)**:最近 N 轮原文 + 当前任务活跃文件/结果。按"信息增益"滑动,非固定条数。
|
||||||
|
- **L3 检索层(按需召回)**:窗口外历史不进 prompt,转为可检索——关键词/语义命中才临时注入(复用 knowledge_inject 的检索管线,扩到对话历史)。
|
||||||
|
- 无破坏性保证:所有降级内容原文全在库(L3 可召回+压缩可逆 C-r1),agent 能力不降级,丢的是"每轮都带全部家当"的浪费。
|
||||||
|
|
||||||
|
## 二、工具结果瘦身(治最大头,先行)
|
||||||
|
|
||||||
|
9386 条消息中 tool 4379 条——单条最大 8K+,是上下文膨胀主力。
|
||||||
|
- 超阈值(如 2K)的工具结果:正文落盘(外置文件/独立表),上下文只留「头部 200 字摘要 + 句柄路径」。
|
||||||
|
- 与 D2a(过期外置)同构一套机制,两处受益。
|
||||||
|
- 句柄可被 agent 主动展开(read 句柄文件)——需要时找得回,不需要时不占位。
|
||||||
|
|
||||||
|
## 三、压缩升级(从"水位兜底"到"常规操作")
|
||||||
|
|
||||||
|
- 现状:120K 水位才触发,1M 窗口下形同虚设。
|
||||||
|
- 改:压缩不再是水位兜底,而是 L2→L3 的常规滑动——每轮组装时,窗口外的轮次自动结构化摘要(记忆卡形态,信息增益判据保留),原文留库可按段重载(C-r1)。
|
||||||
|
|
||||||
|
## 四、量化预期
|
||||||
|
- prompt 均值 71K → 目标 ≤20K(工作集+召回),单轮成本/延迟降 70%+
|
||||||
|
- 223:1 → 目标 ≤50:1(继续随 C-m 记忆卡质量优化)
|
||||||
|
- 长会话不再有"轮数税"——206 轮与 6 轮的单轮成本趋同。
|
||||||
|
|
||||||
|
## 五、实施序(每阶段独立可交付,无破坏)
|
||||||
|
- P1(S):工具结果瘦身(阈值落盘+句柄)——最大头先行,零行为变化
|
||||||
|
- P2(M):L1/L2/L3 组装器重构(build_for_request 改造,窗口+检索)
|
||||||
|
- P3(M):压缩常规化+记忆卡产出(C-m1)+按段重载(C-r1)
|
||||||
|
- P4(S):量化验收(223:1→≤50:1 看板)+回归(agent 能力不降级测试集)
|
||||||
|
|
||||||
|
## 六、与旧 C 批次关系
|
||||||
|
本设计整合取代 C1-C6/C-m1-m3 的发散条目:C-r0(验证后台化)独立不变;C-m2(判据挂 pinned_goals)/C-r1(可逆)并入 P2/P3。旧条目销账以本档为准。
|
||||||
|
|
||||||
|
## 七、修订(2026-10-06 用户定调):从「分层组装」升级为「上下文调度系统」
|
||||||
|
|
||||||
|
> 定调:同会话连续工作是**常态**,设计目标不是控制会话长度,而是支撑无限长会话的资源调度——OS 虚拟内存同构:窗口有限,会话地址空间无限。
|
||||||
|
> 修正:撤回"百轮会话收敛拆分"建议;一~六节的 L1/L2/L3 保留为**页管理基建**,本节新增调度层。
|
||||||
|
|
||||||
|
### 调度系统设计(新增)
|
||||||
|
- **页式单位**:上下文按页管理——轮次页/工具结果页/文件页。页=换入换出与压缩的最小单位。
|
||||||
|
- **热度调度**:页带热度(最近访问/被引用频次)。热页驻工作集(L2);冷页压缩归档(L3,结构化摘要+原文留库);**压缩不丢**=冷页永远可召回。
|
||||||
|
- **缺页召回**:agent 引用历史(关键词/句柄/goal 关联)→检索命中→临时换入本轮上下文,用完即出——召回不计入常驻。
|
||||||
|
- **任务聚类切换**(切换能力的核心):工作集按**任务/主题聚类**重组,非纯时间窗口——用户在多任务间切换时,上下文随任务维度切换(当前任务的热页+该任务关联页),并行任务互不污染。任务边界=页的聚类标签(pinned_goals/主题标记)。
|
||||||
|
- **调度目标函数**:token 花在刀刃=按"任务相关性×信息增益"分配窗口配额;调度决策零 LLM 调用(规则引擎,成本为零)。
|
||||||
|
|
||||||
|
### 修订后的一句话
|
||||||
|
**长会话=常态运行环境;上下文调度器=它的虚拟内存管理器;压缩可召回=它的 swap;任务聚类切换=它的进程调度。**
|
||||||
|
|
||||||
|
## 八、再修订(2026-10-06 用户定调):跨会话调度与召回也是常态
|
||||||
|
|
||||||
|
> 定调:调度地址空间=会话内 + **跨会话**。会话 A 的产出/结论,会话 B 工作中可召回引用——跨会话不是边界,是同一个页池的不同命名空间。
|
||||||
|
|
||||||
|
### 跨会话调度设计(新增)
|
||||||
|
- **页命名空间分级**:会话内页(默认私有) / **跨会话共享页**(结论页/知识页/任务产出页,显式或按策略晋升共享)。
|
||||||
|
- **跨会话召回**:检索半径覆盖全页池——会话 B 缺页召回时,共享页与会话 A 的可召回页同池命中,带来源标注(「来自会话 X 的结论」)。
|
||||||
|
- **与 knowledge 库关系**:知识条目=最高等级的共享页(已晋升、已蒸馏);本设计的跨会话召回把"未蒸馏的对话产出"也纳入召回半径——knowledge 是终点,页池是全程。
|
||||||
|
- **晋升机制**:会话内页→(蒸馏/用户标记/高频跨任务引用)→共享页→(结构化)→knowledge 条目。晋升是单向门,防未蒸馏噪音污染共享池。
|
||||||
|
|
||||||
|
### 多人场景(预留位,下阶段设计)
|
||||||
|
- 页带 owner 与 ACL 位;共享池分个人池/团队池;多人可召回引用=团队池+权限过滤。
|
||||||
|
- 本期只预留页结构字段(owner/acl/scope),不做多人实现——接口先行,机制后置。
|
||||||
|
|
||||||
|
## 九、完整审视(2026-10-06 二次评审)——四个真问题与修正
|
||||||
|
|
||||||
|
### 问题1:机制并存未收敛(最重)
|
||||||
|
压缩机制(现有)/D2a 工具结果外置/L3 检索召回/knowledge 条目/页池——**五个名字,一套事**(都是"内容降级+可找回")。三次定调追加未合并同类项。
|
||||||
|
**修正**:统一收敛——**唯一存储=页池,唯一调度器,唯一召回入口**;压缩/D2a/knowledge 都是页池上的策略与晋升形态,不再作为平行系统各自演化。旧机制改造为页池前身,不新建平行通路。
|
||||||
|
|
||||||
|
### 问题2:实施范围失控风险
|
||||||
|
页池+晋升+跨会话+多人——北极星画完了,但一个桌面 app 一次吃下=过度设计。
|
||||||
|
**修正:v1 范围锁死**=单会话页池+极简调度器(recency+引用计数热度,goal 标签聚类,关键词缺页召回)+knowledge 照旧(它已覆盖跨会话召回大部分需求)。跨会话命名空间/晋升/多人=**v2+,触发条件=单会话版实测后仍有跨会话痛点**。基建等真实触发。
|
||||||
|
|
||||||
|
### 问题3:缺评估设计与调度可观测
|
||||||
|
"agent 能力不降级"怎么证?P4 的回归测试集不存在。
|
||||||
|
**修正**:①建任务回归集(固定任务清单,改造前后跑对比:完成质量/轮数/成本)②调度器可观测(页命中/召回率/换出量进日志与面板)③误调度代价显性化(热页误换出→下轮回查次数)。
|
||||||
|
|
||||||
|
### 问题4:与模型平台原生能力对齐未检查
|
||||||
|
模型侧可能原生提供上下文编辑/记忆工具(如 Claude 的 context editing/memory tool)——自研调度若与平台能力重复,应优先用平台原生(维护成本低、演化跟随厂商)。
|
||||||
|
**修正**:实施前先做平台能力对齐检查(一次调研任务),能用原生的不自研;自研部分聚焦平台没有的(任务聚类切换/跨会话页池)。
|
||||||
|
|
||||||
|
### 审视后的 v1 锁定范围(修正结论)
|
||||||
|
P1 工具结果瘦身 + P2 单会话页池与极简调度 + P3 压缩常规化(页池前身改造) + P4 评估集与量化。
|
||||||
|
七/八节的调度系统与跨会话设计降级为**北极星方向**;实施以本节 v1 范围为准。
|
||||||
|
|
||||||
|
## 十、知识引用注入的统筹与分工(2026-10-06 补充:第九节收敛的正向展开)
|
||||||
|
|
||||||
|
> 上下文生态的注入类型盘点:工具结果句柄/对话历史页/压缩摘要与记忆卡/knowledge 条目/pinned_goals/技能注入/规范注入——统筹不当=五套并行的重演。
|
||||||
|
|
||||||
|
### 分工矩阵(按蒸馏程度×生命周期×注入方式)
|
||||||
|
|
||||||
|
| 类型 | 蒸馏程度 | 生命周期 | 注入方式 | 角色 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 工具结果句柄 | 原始 | 任务内 | agent 主动 read 展开 | 工作证据 |
|
||||||
|
| 对话历史页 | 轻摘要 | 会话内→跨会话(v2) | 检索召回临时换入 | 过程记忆 |
|
||||||
|
| 压缩摘要/记忆卡 | 中蒸馏 | 会话长期 | 工作集常驻(摘要形态) | 阶段结论 |
|
||||||
|
| knowledge 条目 | 高蒸馏 | 永久 | system_prompt 检索注入 | 沉淀规范 |
|
||||||
|
| pinned_goals/任务态 | 实时 | 任务期 | L1 常驻 | 当前意图 |
|
||||||
|
|
||||||
|
### 统筹四原则
|
||||||
|
1. **单一来源**:每种内容只有一个当前形态的家(原始在页池/蒸馏在 knowledge),晋升单向门,禁双存双写。
|
||||||
|
2. **注入方式随蒸馏程度**:越原始越被动(agent 主动拉/句柄展开),越蒸馏越主动(系统注入 prompt)——检索系统替 agent 省的每一分注意力,都来自上游的蒸馏质量。
|
||||||
|
3. **召回入口统一**:全部类型注册到同一检索面,agent 视角只有一个记忆系统(分类型返回,来源标注)——agent 不需要知道背后几套存储。
|
||||||
|
4. **三层配额防炸弹**:prompt 注入配额(既有 ≤20 条/1.5K)+每轮召回页配额(新增,如 ≤5 页)+句柄展开按需——总量有闸,信息炸弹在结构上不可能。
|
||||||
|
|
||||||
|
### 与既有机制对账
|
||||||
|
knowledge_inject(检索注入)=统筹第 4 行的既有实现,扩为统一检索面的注册方之一;compress(压缩)=第 3 行的产生器;D2a/P1(句柄)=第 1 行的产生器;审计 subscriber(H4)=统筹的观测面。**无新系统,只有分工明确化。**
|
||||||
|
|
||||||
|
## 十一、自主性原则(2026-10-06 用户定调:海纳川流,增强非依赖)
|
||||||
|
|
||||||
|
> 定调:CLAUDE.md/看板/外部规范/memory 等=**可选输入源**(海纳川流,有则增强);devflow 核心运作(知识/纪律/调度/领单)必须**内生**——外部全删,系统照常运作。
|
||||||
|
|
||||||
|
### 依赖矩阵重排
|
||||||
|
- **内生核心(必需,无外部也运作)**:知识库条目(自己的注入面)/引擎层机制(B5b 看板轮询领单=运行时化,非提示词)/仓库 pre-commit hook(跟仓库走)/状态机与调度器。
|
||||||
|
- **增强输入(可选,缺失不影响)**:CLAUDE.md 注入/共享技能(cm)/看板摘要/memory 召回/走查报告。
|
||||||
|
- **迁移**:CLAUDE.md 领单纪律降级为过渡形态,正解=①治理规则做成知识库条目(自己的记忆) ②领单引擎化(B5b)。pre-commit hook 从技能层迁仓库层(更内生)。
|
||||||
|
|
||||||
|
## 十二、顶层定调:广义知识库=知识载体总集(2026-10-06 用户定调)
|
||||||
|
|
||||||
|
> 定调:知识库=宏观概念,**包含 kms/memory/skill/页池/knowledge/外部输入的一切知识载体**。技能与记忆的"海纳+有效高质量引用"同属上下文知识库管理的一环。上下文调度系统的地址空间=全部载体。
|
||||||
|
|
||||||
|
### 载体谱系(蒸馏程度×形态×引用方式的光谱)
|
||||||
|
|
||||||
|
| 载体 | 形态 | 蒸馏/时效 | 引用方式 | 调度角色 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 会话页池 | 对话/工具原始页 | 原始/热 | 缺页召回换入 | 工作记忆 |
|
||||||
|
| 压缩摘要·记忆卡 | 结构化摘要 | 中蒸馏/温 | 工作集常驻 | 阶段结论 |
|
||||||
|
| knowledge 条目 | 高蒸馏条目 | 高蒸馏/持久 | system_prompt 注入 | 规范与沉淀 |
|
||||||
|
| pinned_goals | 意图态 | 实时 | L1 常驻 | 当前意图 |
|
||||||
|
| **memory 文件** | 跨会话个人记忆 | 中蒸馏/持久 | 项目召回+按需 Read | 跨会话自我认知 |
|
||||||
|
| **kms 文档** | 文档级知识 | 沉淀/冷 | 按需 Read | 领域参考书 |
|
||||||
|
| **skill** | **可执行流程知识** | 沉淀/持久 | **触发执行(不是读,是跑)** | 能力包 |
|
||||||
|
| 外部输入(CLAUDE.md/看板/报告) | 增强流 | 实时 | 注入/摄入 | 海纳川流 |
|
||||||
|
|
||||||
|
### 统筹原则扩展(在十节四原则之上)
|
||||||
|
5. **载体按形态分工**:陈述性知识走引用(页/条目/kms/memory),程序性知识走执行(skill)——"知道什么"引用,"会做什么"触发,引用与执行分治。
|
||||||
|
6. **海纳=摄入管线统一**:外部输入与内部产出进同一摄入管线(页池→蒸馏→晋升),skill 的沉淀也走此管线的"执行验证通过"分支——摄入不分内外,质量 gate 统一。
|
||||||
|
7. **引用质量=相关性×蒸馏度×时机**:高质量引用不是"多给",是"对的载体在对的时机以对的方式"——调度器的目标函数升级为此三元组。
|
||||||
|
|
||||||
|
### 对实施的影响
|
||||||
|
统一检索面(P2 细化)的注册方扩展至全载体(memory/kms/skill 的索引也注册);skill 特殊——注册的是"能力描述",命中时走执行而非注入。P2 工作量小幅上调,架构位置不变。
|
||||||
|
|
||||||
|
## 十三、devflow.md:项目认知投影文件(对标 CLAUDE.md,本土化超越)
|
||||||
|
|
||||||
|
> 定调:类似而非照搬,青出于蓝。CLAUDE.md=用户手写的静态注入;devflow.md=**agent 自主维护的活的项目认知投影**——从"每次启动读一遍死文件"升级为"分层分区的活上下文"。
|
||||||
|
|
||||||
|
### 与 CLAUDE.md 的能力对照
|
||||||
|
| 维度 | CLAUDE.md | devflow.md |
|
||||||
|
|---|---|---|
|
||||||
|
| 维护者 | 用户手写 | **agent 自动维护为主,用户可写覆盖** |
|
||||||
|
| 形态 | 纯文本一次读 | **结构化分区,分区独立刷新** |
|
||||||
|
| 时效 | 启动读一次静态 | 启动读取+**运行中事件驱动刷新**(警示/状态可中途生效) |
|
||||||
|
| 内容 | 手写规范 | 规范(迁移自 CLAUDE.md)+**状态投影**(HEAD/看板摘要/活跃任务,自动)+**知识投影**(按当前任务相关性从页池/knowledge 拉) |
|
||||||
|
| 兼容 | — | **海纳:项目存在 CLAUDE.md 则自动并入投影**(增强非依赖,呼应第十一节) |
|
||||||
|
|
||||||
|
### 四分区设计
|
||||||
|
1. **规范纪律区**(低频):领单纪律/提交规范/红线——从现 CLAUDE.md 迁移;用户手写优先,agent 建议追加需用户确认。
|
||||||
|
2. **项目状态区**(高频自动):HEAD/分支/未提交数/看板摘要/活跃任务——每次刷新重写,机器事实源(防凭记忆汇报的文件化)。
|
||||||
|
3. **知识投影区**(任务相关):当前任务关联的 knowledge 条目/记忆卡/页池热页摘要——调度器按相关性投喂,会话切换即换。
|
||||||
|
4. **警示区**(事件驱动):挂机必读/外部干预告知/退回说明——事件触发插入,读后标记已消费。
|
||||||
|
|
||||||
|
### 机制要点
|
||||||
|
- 读取:会话启动读 devflow.md(存在则并入;CLAUDE.md 同目录存在也并入,优先级 devflow.md > CLAUDE.md)——**两者都没有,按知识库内生纪律照常运作**(第十一节自主性)。
|
||||||
|
- 刷新:状态区/警示区事件驱动重写(写者唯一=状态投影器);运行中会话的刷新对下一轮组装生效。
|
||||||
|
- 边界:devflow.md 是**投影不是真相**——真相在引擎/DB/页池;文件损坏可随时重建(从真相源再生成)。
|
||||||
|
|
||||||
|
## 十四、devflow.md 多角色论证收敛(2026-10-06 /swarm:怀疑者+行业对标+实施务实+用户代言四方)
|
||||||
|
|
||||||
|
### 三路论证共识(全部指向同一结论)
|
||||||
|
**运行中文件刷新(原十三节设计)砍除**——①架构矛盾:system_prompt/volatile_tail 是 loop 不变量(启动组装一次,mod.rs:138/:441),中途改文件对运行中 loop 零生效;②token 代价:兑现刷新需破坏 provider prompt 缓存前缀,每轮重算 system 段=token 实涨(违背知识管理高效原则);③行业零先例(全行业规则文件均会话级加载),高风险无容错。
|
||||||
|
**替代(零新机制)**:中途更新走既有 insert 消息范式(insert_stall_warning/insert_goal_verify_nudge/inject_runtime_message——往 conv.messages 尾插,build_for_request 每轮自然重组,天然下一轮生效,G2 已在用)。
|
||||||
|
|
||||||
|
### 行业对标关键结论
|
||||||
|
验证有效:agent 生成规则(Cursor)/按相关性加载(Agent Requested+repo map)/跨会话持久。已证为坑:init fossilization/只增不删/多源漂移/静默截断。
|
||||||
|
**真空白护城河(坚持)**:状态机器投影(五家工具无一把工作流状态写入记忆文件)+警示事件驱动插入——但载体不是文件,是 insert 消息范式+DB 直注。
|
||||||
|
**同质化区(兼容)**:纪律区做得像 CLAUDE.md 越利于兼容——创新在投影/警示/领单,不在文件格式标新立异。
|
||||||
|
|
||||||
|
### 定稿:v1 MVP 切片(S-M)
|
||||||
|
1. **启动读取**:devflow.md(两分区:规范纪律+领单)+CLAUDE.md 并入→build_runtime_prompt 第七段纯追加(两条组装路径都接);文件读取微秒级,**注入的 token 才是成本——内容按相关性裁剪,禁全量**。
|
||||||
|
2. **警示区**:事件→insert 消息范式(零新机制);非文件分区。
|
||||||
|
3. **状态**:不文件化——DB 直注(build_context_lists 既有,比文件投影更新鲜)+H3 git 实测(已有)。
|
||||||
|
4. **知识投影**:knowledge_inject 既有+补相关性元数据(行业经验:规则死于描述,元数据决定拉取质量)。
|
||||||
|
### v2+(北极星,触发制)
|
||||||
|
agent 自主维护协议/知识投影动态化/跨会话投影——v1 实测后按真实痛点渐进。
|
||||||
|
### token 高效三律([补 1] 原则落定)
|
||||||
|
①不破坏 prompt 缓存前缀 ②投影内容按相关性裁剪禁全量注入 ③文件读取不花钱,注入 token 才花钱——一切设计以"注入的 token 换来的决策价值"为核算单位。
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
# FileTree 大目录卡顿 — 可实施方案
|
||||||
|
|
||||||
|
> 日期: 2026-10-10 | 状态: 设计稿(待拍板) | 勘察: 只读实测锚点(Wave Q)
|
||||||
|
> 定位: 「单个目录上千条合法条目」的卡顿风险可实施方案。本文只写方案,不改码。
|
||||||
|
> 触发: Wave Q 实测 — 后端 `get_module_file_tree` 不封顶、前端 FileTree 全量 `v-for`,当前靠三重缓解兜底。
|
||||||
|
> 结论预告: 推荐 **方案 B(后端 offset/limit 分页 + 前端「加载更多」)**,先做期 1 最小可用;虚拟滚动本期否决(见 §二·A 的实证疤痕)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、现状与证据(带行号)
|
||||||
|
|
||||||
|
### 1.1 后端:列目录不封顶
|
||||||
|
|
||||||
|
`src-tauri/src/commands/module.rs` 命令 `get_module_file_tree`(707-834):
|
||||||
|
|
||||||
|
- 774-777 `std::fs::read_dir(&target_dir)` 遍历目标目录**全部**条目;
|
||||||
|
- 781-791 过滤噪音目录(`is_noise_entry`,FileTree 档)+ 隐藏文件(`.` 开头);
|
||||||
|
- 810-818 每条 push 进 `items`;
|
||||||
|
- 820-824 排序(目录优先 + 字母序,稳定可预期);
|
||||||
|
- 830-833 一次性返回 `{ path, entries }`。
|
||||||
|
|
||||||
|
**无任何条数上限**。对照同语义的另一档位:AI 侧 `list_dir_recursive`(`src-tauri/src/commands/ai/tool_registry.rs:923-965`)有 `max_entries` 封顶(调用处 `tools/file.rs:347` 传 `1000`,达上限返 `truncated`)。即"列目录"这一语义,**AI 上下文档有封顶、文件浏览器档没有** —— 这是 Wave Q 要补的缺口。
|
||||||
|
|
||||||
|
### 1.2 前端:单层全量 v-for
|
||||||
|
|
||||||
|
`src/components/project/FileTree.vue`:
|
||||||
|
|
||||||
|
- 25-33 `<li v-for="entry in entries" :key="entry.path">` —— 当前层全部条目**一次性渲染**;
|
||||||
|
- 95-106 `FileTree` 自引用递归:展开的子目录再加一层同构 `<ul>`;
|
||||||
|
- 157-205 `entries` 为该层全量(命中 `loadedChildren` 缓存则整层直赋)。
|
||||||
|
|
||||||
|
滚动容器是**共享**的 `.explorer-tree`(`FileExplorer.vue:806-813`,`overflow-y: auto`),**不是每层各自滚动** —— 这决定了虚拟滚动若要落地,需先把递归树拍平成"单一带缩进的可见行列表"(见 §二·A)。
|
||||||
|
|
||||||
|
### 1.3 三重缓解(既定兜底,也是本次要复用的底座)
|
||||||
|
|
||||||
|
1. **按层懒加载**:FileTree 仅在某目录被展开时才拉该层(`loadEntries` 174-205;`toggleDir` 208-237),从不整树加载。
|
||||||
|
2. **噪音 / 隐藏过滤**:后端 `is_noise_entry`(FileTree 档,`tool_registry.rs:1045-1056`)+ `.` 隐藏文件(`module.rs:789`)剔除最大宗条目(`.git` 数千项、`node_modules`/`target`/`dist`/`__pycache__` 等)。
|
||||||
|
3. **KeepAlive 保活**:`src/views/ProjectDetail.vue:83-85` `<KeepAlive>` 包 `FileExplorer`,切 tab 不销毁、不重挂载、不重拉。
|
||||||
|
|
||||||
|
附随机制:`loadedChildren` 缓存(`FileExplorer.vue:280`)+ 在途去重 / 请求序号(`loadSeq`/`inflightEntries`,`FileTree.vue:170-171`)使重复展开秒开、并发请求去重。
|
||||||
|
|
||||||
|
**残留风险**:三重缓解把"上千条"压缩成"**单层**上千条",但对**一个真实含上千条且非噪音**的目录(如日志目录、生成代码目录、扁平资源目录),后端仍全量返回 + 前端仍全量 DOM。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、方案对比
|
||||||
|
|
||||||
|
| 维度 | A 前端虚拟滚动 | B 后端 offset/limit + 前端「加载更多」 | C 两者结合 |
|
||||||
|
|------|---------------|--------------------------------------|-----------|
|
||||||
|
| 机制 | 只渲染可视窗口(+ overscan) | 后端分页切片,前端累积 | B 分页 + A 窗口化 |
|
||||||
|
| 后端改动 | 无 | `get_module_file_tree` 加 offset/limit/total | 同 B |
|
||||||
|
| 前端改动 | FileTree 重构为扁平窗口化列表 | FileTree 累积 entries + 尾部按钮 | B + 窗口化 |
|
||||||
|
| 依赖 | 需新库(项目现有依赖无)或自研 | 无 | 需新库 |
|
||||||
|
| 与现有架构 | **冲突**(递归树需拍平;共享滚动容器需重排) | **天然契合**(每层本就独立请求) | 中 |
|
||||||
|
| 风险 | 高(高度测量/展开态/选中态同步;项目有自研虚拟滚动翻车疤痕) | 低(改动集中、可回退) | 高 |
|
||||||
|
| 适配量级 | 上万条 | **上千条(本场景)** | 上万条 |
|
||||||
|
|
||||||
|
### A 的风险:项目实证疤痕
|
||||||
|
|
||||||
|
自研虚拟滚动曾落地于消息列表(自研 IntersectionObserver + sentinel + ResizeObserver),因 **IO/RO 时序竞态致消息重叠**,2026-06-18 **彻底移除**(见 `docs/待决策.md:132-136`、`docs/02-架构设计/滚动规范/功能决策记录-2026-06-14.md:362-363`;`MessageList.vue` 至今标注"虚拟滚动已移除,恒渲染")。
|
||||||
|
|
||||||
|
并且 `package.json` 现有依赖**无任何虚拟滚动库**(`@vueuse/core` 也未安装)。再自研 = 重蹈覆辙;引库 = 新增依赖 + 仍需适配递归树拍平 —— 成本与"上千条"的收益不成比例。
|
||||||
|
|
||||||
|
### 推荐:B(最小可用)
|
||||||
|
|
||||||
|
理由:
|
||||||
|
|
||||||
|
1. **零架构冲突**:现有模型就是"每层一个独立请求 + `loadedChildren` 缓存",把"整层"切片成"整层分批"是该模型的自然延伸,不引入新范式。
|
||||||
|
2. **最小 diff、低风险、可回退**(见 §三)。
|
||||||
|
3. **量级匹配**:上千条 DOM 在现代浏览器渲染在数十 ms 量级;首屏限 200~300 条后交互即流畅,足以消除卡顿。无需为"上千条"上虚拟滚动的复杂度(A/C 属 YAGNI,仅当实测 >5000 条再评估)。
|
||||||
|
4. **给用户明确信号**:「加载更多 (已加载/总数)」比"悄悄截断"更符合用户预期。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、方案 B 改动面 / 风险 / 回退 / 验收
|
||||||
|
|
||||||
|
### 3.1 改动面(期 1 最小可用)
|
||||||
|
|
||||||
|
| 层 | 文件 | 改动 |
|
||||||
|
|----|------|------|
|
||||||
|
| 后端 | `src-tauri/src/commands/module.rs` | `get_module_file_tree` 加 `offset: Option<usize>`、`limit: Option<usize>` 参数;返回加 `total`。**先完整收集 + 过滤 + 排序,再切片**(切片点放在 820-824 排序之后),保证分页稳定。`limit` 缺省 = `None` → 返回全量(保持旧行为)。 |
|
||||||
|
| 前端 API | `src/api/module.ts` | `getModuleFileTree` 加 `offset`/`limit` 参数;`FileTreeResult` 加 `total?: number`。 |
|
||||||
|
| 前端组件 | `src/components/project/FileTree.vue` | `entries` 改为"累积注入":首屏拉 `limit` 条,`loadMore()` 按 `offset=已加载数` 追加,直到 `total`;层尾渲染「加载更多 (N/M)」;分页状态并入 `loadedChildren` 缓存(**建议其值由 `FileTreeEntry[]` 扩展为 `{ entries, total }` 或新增 `loadedTotals` map**,否则重展开丢失"还有更多"状态)。 |
|
||||||
|
| i18n | `src/i18n/zh-CN/fileExplorer.ts`、`src/i18n/en/fileExplorer.ts` | 新增 `loadMore`(「加载更多」/「Load more」)、可选 `allLoaded`。 |
|
||||||
|
|
||||||
|
不改 `FileExplorer.vue`(缓存语义、展开 / 选中逻辑不变)。改动集中、职责单一。
|
||||||
|
|
||||||
|
### 3.2 风险
|
||||||
|
|
||||||
|
- **offset 错位**:目录内容在分页期间变化 → 可能重复 / 漏项。自用场景 + `refresh` 清缓存重拉(`resetTreeState`,`FileExplorer.vue:330-336`)已覆盖,可接受。
|
||||||
|
- **多次请求 git status**:每个分片请求都重跑 `git_status_map_cached`,但有缓存(10s TTL),开销可控。
|
||||||
|
- **`has_children` 开销**:现每目录一次 `read_dir` 短路统计;分页后只对返回的前 N 条目录计算,反而**降**开销。
|
||||||
|
- **`loadMore` 与首屏请求竞争**:需让 `loadMore` 复用同一 inflight 去重键或使用独立键,避免与 `onMounted` 首屏请求互相覆盖(机制见 `FileTree.vue:185-188`)。
|
||||||
|
|
||||||
|
### 3.3 回退
|
||||||
|
|
||||||
|
- 后端 `limit`/`offset` 缺省 = `None` = 全量;前端不传 `limit` 即旧行为。
|
||||||
|
- 或前端恒定不渲染"加载更多"(首屏即全量)。
|
||||||
|
- 改动集中在 3 个源文件 + 2 个 i18n 文件,单次 revert 即可回滚。
|
||||||
|
|
||||||
|
### 3.4 验收口径
|
||||||
|
|
||||||
|
- **造数**:批量子项造 1500 条目录(如 1500 个空文件)。
|
||||||
|
- 首屏 DOM 行数 ≤ `limit`(如 200);层尾显「加载更多 (200/1500)」;点击逐次追加 200 条直到 1500;加载完按钮隐藏。
|
||||||
|
- **回归**:选中文件高亮、git 状态标、空目录标、展开子目录、切工程、刷新,均正常。
|
||||||
|
- **命令**:`cargo check -p devflow` EXIT 0;分页切片纯函数加单测(`module.rs:1574` 已有 `mod tests` 模块可承接);`bunx vue-tsc --noEmit` EXIT 0;`node scripts/check-i18n.cjs` 通过。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、分期(最小可用优先)
|
||||||
|
|
||||||
|
- **期 1(MVP,必做)**:后端点分页 + 前端「加载更多」按钮。
|
||||||
|
- **期 2(体验,可选)**:触底自动加载(IO sentinel)。**注意**:滚动容器是共享 `.explorer-tree`(非每层独立滚动),触底自动加载需定位"当前层尾部的 sentinel",实现比按钮复杂;且只做"触底追加"、**不做"卸载裁剪"**,以规避历史 IO 竞态。可延后。
|
||||||
|
- **期 3(仅当实测触发)**:若实测 >5000 条仍卡,评估虚拟滚动,且**用成熟库**(如 `@vueuse/core` `useVirtualList`)而非自研,并先把递归树拍平为单一带缩进列表。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、与既有能力的复用
|
||||||
|
|
||||||
|
- **懒加载缓存**:`loadedChildren`(`FileExplorer.vue:280`,`FileTree.vue:180/197/228`)→ 分页后仍存"累积 entries",展开秒开语义不变。
|
||||||
|
- **噪音过滤**:`is_noise_entry` FileTree 档(`tool_registry.rs:1045-1056`)→ 分页在**过滤之后**切片,过滤语义不变(不会因分页漏滤)。
|
||||||
|
- **KeepAlive**:`ProjectDetail.vue:83-85` → 分页游标随组件状态在 tab 切换间保留,回来不重拉。
|
||||||
|
- **在途去重 / 序号守卫**:`FileTree.vue:170-171/187` → `loadMore` 复用,避免并发覆盖。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、拍板点
|
||||||
|
|
||||||
|
1. **分页粒度**:首屏 `limit` 与每次追加步长各取多少?(建议各 200 —— 兼顾首屏速度与点击次数)
|
||||||
|
2. **交互形态**:MVP 用「加载更多」按钮(推荐:简单、可测、与共享滚动容器不冲突)vs 触底自动加载(期 2 再评估)?
|
||||||
|
3. **是否本期引入虚拟滚动**:建议否决(YAGNI + 自研疤痕),仅当实测 >5000 条再以成熟库评估 —— 是否认可?
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# ai-chat 上下文管理与信息组织·完善设计(2026-10-18)
|
||||||
|
|
||||||
|
> 用户令:ai-chat agent 上下文管理/信息组织开发完善;回复别太口水话(2026-10-18)。
|
||||||
|
> 证据输入(问题全量论证在彼,此处只引编号不重复):`temp/深查-aichat对话质量复测-2026-10-17.md`(E1-E13)、`temp/深查-aichat信息UX-2026-10-17.md`(F1-F6)。
|
||||||
|
> 现状判定:10-04 三大病(上下文失控/反复压缩/终局缺失)基本治愈;残量集中在**恢复链污染、终局态半闭环、存量脏数据、信息可达性、输出纪律**五个结构性面。
|
||||||
|
|
||||||
|
## 一、信息流模型与缺口定位
|
||||||
|
|
||||||
|
链路:`发起(直发/inject) → 目标提取(pinned_goals) → 上下文组装(system+摘要+近期) → 执行循环 → 压缩(view/cache) → 恢复(recovery) → 投影(轮桶/轨迹卡) → 呈现(桌面/飞书)`
|
||||||
|
|
||||||
|
| 缺口 | 根因(结构性,非散点) | 归并的散点 |
|
||||||
|
|---|---|---|
|
||||||
|
| **G-A 恢复链身份未建模** | 恢复消息无类型标记,兼具「系统信号」与「用户输入」双重身份,被目标提取源再次吸收 | E1 套娃、E3 二次恢复、看板 649 行恢复目标完整性守卫 |
|
||||||
|
| **G-B 终局态半闭环** | boundary_json/loop_state 字段已建、写入闭环未建(填充率 0) | P2-5、E3 竞态窗口 |
|
||||||
|
| **G-C 写入不保数据语义** | 写入方各自为政,全量重写/双写不保留原义 | E11 created_at 重写污染 1750 条、E5 存量目标不迁移、E12 双写 |
|
||||||
|
| **G-D 信息可达性瓶颈** | 呈现层硬上限+折叠体内不可见 | F4-1 会话 50 上限(实测 115 条已击穿)、F1-3+F3-2 注入降级藏审批、F6-1 |
|
||||||
|
| **G-E 输出纪律缺失** | 聚焦准则管「答什么」,不管「怎么答」——主提示词无回复风格条款 | 用户口水话反馈(本次令);摘要提示词已有该纪律可对齐 |
|
||||||
|
| **G-F 阴性结论无守卫** | 工具 0 命中被当事实断言,无复核引导 | E6 假阴性 3 轮错判链 |
|
||||||
|
|
||||||
|
## 二、修复批次(领单视角,M1 先行)
|
||||||
|
|
||||||
|
### M1 · P1 上下文正确性
|
||||||
|
1. **E1+E3 恢复链治本(G-A)**:恢复消息加 source 标记(与看板 961② inject 标记同基建,复用 V52 source 字段);build_recovery_message 过滤 `[系统恢复]` 前缀目标;目标写入侧拒收同前缀。防二次恢复:快照全部目标=completed 时短路,或恢复后 N 分钟窗口禁重复。
|
||||||
|
2. **E11 created_at 保义(G-C)**:message_repo.rs:285 全量重写保留原 created_at(records 读取带出原值回写)。
|
||||||
|
3. **E6 阴性存疑(G-F)**:search_files/grep 0 命中时结果强制附「阴性存疑,建议复核路径/glob」标记(seq 1549 hint 雏形转正)。
|
||||||
|
4. **E4 跑题换装验证**:三源活跃度排序已在树(第五十三段),换装后重放 6a5a3e04 seq1802 场景验证。
|
||||||
|
|
||||||
|
### M2 · P2 上下文域
|
||||||
|
5. **E5 存量目标一次性迁移(G-C)**:active 目标命中「执行/搜索/查看+工具参数」形态或 `[系统恢复]`/`[补充指令]` 前缀 → 降级为非目标或重取首条任务消息。
|
||||||
|
6. **E2 压缩降级透出**:压缩失败兜底时前端状态条「上下文已降级」,禁静默。
|
||||||
|
7. **E13 超时连败收敛**:同命令超时连败 N 次转「换方案/问用户」分支(与看板 B5d 退避策略同族,合并实施)。
|
||||||
|
8. **P2-5 终局闭环(G-B)**:停靠/终局时填充 boundary_json/loop_state,不只落 status。
|
||||||
|
|
||||||
|
### M3 · P2/P3 信息组织域
|
||||||
|
9. **F4-1 会话可达性(G-D)**:列表 offset 游标分页+侧栏「加载更多」;搜索走后端 LIKE(title+消息内容)。
|
||||||
|
10. **F5-1+F5-2 飞书审批闭环(同根一改双收)**:决议词表归一(trim/剥标点/同义词,复用 normalize_halfwidth)+ 订阅 AiToolCallCompleted 推终态卡;pending 为空时决议回「已失效」提示而非静默放行。
|
||||||
|
11. **F1-3+F3-2 折叠体可达(G-D,同根)**:executing 判定改「桶内存在 running/pending 工具」;scrollToFirstPending 前按 data-trace-key 预展开含 pending 的轨迹卡。
|
||||||
|
|
||||||
|
### M4 · 输出纪律+UX 小批
|
||||||
|
12. **G-E 提示词输出纪律**:system_prompt_parts 行为准则追加「回复风格」段——结论先行直接给动作/结果;省略复述用户问题、客套开场、总结性收尾;要点优先于段落;用户没问的不展开。中英双语同步+测试断言(对齐 prompt.rs 既有测试范式)。注意别过度约束:需要解释时照常解释,反的是空话不是内容。
|
||||||
|
13. **UX 确定性小批**:F1-1 i18n 补键 / F1-2+F3-1 主题 token 化 / F2-1 aria-label / F4-2 loaded 标志 / F6-2+F6-3 Esc 关闭 / 看板尾部队列带图项标记(10-18 残留)。
|
||||||
|
|
||||||
|
## 三、验收口径
|
||||||
|
|
||||||
|
- **复测脚本复跑**(复用 `temp/复测-aichat质量-1017/` 脚本,timestamp 列口径):恢复消息套娃=0、created_at 污染增量=0、boundary_json 填充率>0(新停靠会话 100%)。
|
||||||
|
- **可达性**:115 条活跃会话全量可从侧栏到达;审批卡在任何折叠形态下 scrollToFirstPending 均可见。
|
||||||
|
- **输出纪律**:3 个真实会话抽查,开场复述/空收尾/无信息客套=0;结论仍在首屏。
|
||||||
|
- 每批销账附验收命令输出(G4 惯例)。
|
||||||
@@ -170,6 +170,24 @@ Commander(控制端) ──wss──> nginx(:443 TLS 终结) ──ws──> u-r
|
|||||||
| **Commander** | 用 pong 更新 `lastPongTime`,看门狗超时则重连 |
|
| **Commander** | 用 pong 更新 `lastPongTime`,看门狗超时则重连 |
|
||||||
| **Agent**(跑桌面/移动网络) | 用 pong 重置读死线,死线超时走既有重连 |
|
| **Agent**(跑桌面/移动网络) | 用 pong 重置读死线,死线超时走既有重连 |
|
||||||
|
|
||||||
|
### 4.4 心跳间隔约束
|
||||||
|
|
||||||
|
心跳间隔不是自由值,**链路各层空闲超时中最小的一层决定其上界**:
|
||||||
|
|
||||||
|
| 层 | 空闲超时 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| nginx 反代 | `proxy_read_timeout`(样例 300s;生产实时配置可能更大) | 超时即静默断开 UPSTREAM WS,无任何错误帧 |
|
||||||
|
| NAT / 防火墙 | 数十秒 ~ 数分钟(设备相关) | 空闲连接被回收,后续帧永久不可达 |
|
||||||
|
|
||||||
|
**约定**:间隔须**显著小于最短超时**并留足余量(容忍连续数次心跳丢失 / 网络抖动仍不断链),**推荐 30–60s**。低于 30s 徒增无效流量,超过 60s 在 300s 反代下余量偏薄。
|
||||||
|
|
||||||
|
| 端 | 保活帧 | 间隔 |
|
||||||
|
|---|---|---|
|
||||||
|
| Commander 参考(u-ppt) | 应用层 `{control_kind:"ping"}` | 30s |
|
||||||
|
| Agent(df-tunnel) | WS 协议层 Ping 帧(对端协议栈自动回 Pong) | 30s |
|
||||||
|
|
||||||
|
> Agent 侧用协议层 Ping 即可保活——WS Ping/Pong 由对端协议栈(tungstenite/axum)自动应答,同样刷新反代空闲计时;应用层 `control_kind:"ping"` 主要用于需要显式 pong 观测的控制端看门狗。两端节拍取同值,避免一方看门狗口径与另一方保活周期错配导致误重连。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. 部署配置
|
## 5. 部署配置
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# u-relay 运维手册(token 轮换 · 一通道一 agent · 心跳约束)
|
||||||
|
|
||||||
|
> 适用:u-relay 跨端中继的日常运维纪律。协议细节见
|
||||||
|
> [u-relay Wire 协议规格](./u-relay-wire-protocol-2026-10-04.md),本手册只讲**运维动作与约束**。
|
||||||
|
> 来源:协议评估遗留三项(单共享 token 可冒充 / 心跳间隔未定值 / 同 channel 多 agent 全投递)。
|
||||||
|
> 本手册**不含**任何 token 值、主机地址等敏感信息——真值只在部署机 `/etc/u-relay.env` 与各客户端本地配置里。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 心跳间隔约束
|
||||||
|
|
||||||
|
心跳间隔须显著小于**链路最短空闲超时**(反代 `proxy_read_timeout`,配置样例 300s;生产实时值可能更大),
|
||||||
|
以容忍连续数次心跳丢失仍不断链。**约定 30–60s**,各方实现见协议规格 §4.4:
|
||||||
|
|
||||||
|
- Agent(df-tunnel):`HEARTBEAT_INTERVAL` 常量,30s(WS 协议层 Ping)
|
||||||
|
- Commander 参考(u-ppt):30s(应用层 `control_kind:"ping"`)
|
||||||
|
|
||||||
|
> 改反代 `proxy_read_timeout` 时须同步评估心跳间隔:若把超时压到 300s 以下,间隔也要相应收窄并保持「超时 / 间隔 ≥ 5」量级的余量。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 单共享 token 轮换 Runbook
|
||||||
|
|
||||||
|
### 2.1 现状与风险
|
||||||
|
|
||||||
|
u-relay 鉴权是**单一共享 token**(env `U_RELAY_TOKEN`,服务端常量时间比较),**不做 per-channel 隔离**:
|
||||||
|
任何持有该 token 的客户端可声明**任意 `channel_id`** 冒充任一通道。故 token 属敏感凭据,
|
||||||
|
泄露即等于整条中继失守;轮换是唯一的止损手段。
|
||||||
|
|
||||||
|
### 2.2 涉及方与各自持有位置
|
||||||
|
|
||||||
|
token 值散落在「服务端 + 多个客户端」,**没有集中下发通道**:
|
||||||
|
|
||||||
|
| 方 | 角色 | token 持有位置 |
|
||||||
|
|---|---|---|
|
||||||
|
| u-relay 服务端 | 鉴权源 | 部署机 `/etc/u-relay.env`(`U_RELAY_TOKEN`,权限 600)或 systemd unit 的 `Environment=` |
|
||||||
|
| devflow 桌面端 | Agent | `relay_token` 设置项(运行时读,缺省回退硬编码默认值) |
|
||||||
|
| devflow 小程序 | Commander | `apps/df-miniapp/src/config.ts` 缺省 + 设置页可改 |
|
||||||
|
| devflow ask_uclaw | Commander | 设置页 `uclaw_token`(与 relay token 同源) |
|
||||||
|
| u-claw | Agent/Runner | u-claw 侧配置 |
|
||||||
|
| u-ppt | Commander | `relay.ts` 配置 |
|
||||||
|
|
||||||
|
> 新增任何接入端都要登记到本表——漏一端的后果见下节。
|
||||||
|
|
||||||
|
### 2.3 锁步成本(务必先读)
|
||||||
|
|
||||||
|
因为**服务端只认一个 token、客户端各自缓存**,轮换天然是一个**锁步切换**:
|
||||||
|
|
||||||
|
- **无重叠窗口**:服务端不能同时接受新旧两个 token,故不存在「灰度换端」的过渡期。
|
||||||
|
- **顺序无解**:先改服务端 → 全部旧客户端立即 `auth_failed` 掉线;先改客户端 → 全部客户端在服务端切换前拿新 token 一样 `auth_failed`。两条路都有中断窗口。
|
||||||
|
- **漏端即静默失联**:任一客户端未同步换 token,会持续 `auth_failed` 被拒连,表现为**该端静默离线**(无业务错误、仅 relay 日志有 `auth_failed`),排查成本高。
|
||||||
|
- **不可逐端滚动**:无法像多 token 系统那样「先切一半、观察、再切一半」。
|
||||||
|
|
||||||
|
结论:**轮换是一次计划内的协调停机**,中断窗口 = 从「首个客户端改值」到「服务端重启加载新值」的间隔。执行要点是**把客户端改值的动作压到最短、紧接着重启服务端**。
|
||||||
|
|
||||||
|
### 2.4 执行步骤
|
||||||
|
|
||||||
|
前置:准备好新 token,确认上表**每一端**都有人能改、改完可验证。
|
||||||
|
|
||||||
|
1. **写服务端新值(先不重启)**:在部署机把 `/etc/u-relay.env`(或 unit `Environment=`)的 `U_RELAY_TOKEN` 改为新值,保持服务仍在跑旧值。此时新值尚未生效。
|
||||||
|
2. **集中改用各客户端**:将上表每一端改到新值。此步开始后各端会 `auth_failed`,**中断窗口从这里起算**。
|
||||||
|
3. **重启服务端加载新值**:`systemctl restart u-relay`。重启后已改端的客户端重连即通。
|
||||||
|
4. **验证**:见 2.5。
|
||||||
|
5. **未改端收尾**:对仍 `auth_failed` 的端补改(此时只有漏改者受影响,不再影响大局)。
|
||||||
|
|
||||||
|
> 若某些端的 token 是**编译期硬编码**(无运行时配置项),须提前把「新 token + 重新打包」的产物备好,否则第 2 步无法即时完成,窗口会被拉长。
|
||||||
|
|
||||||
|
### 2.5 验证
|
||||||
|
|
||||||
|
- 服务端:`systemctl status u-relay` 正常,`ss -tlnp | grep 9180` 在听。
|
||||||
|
- 连接:拿新 token 走一次握手冒烟(`/ws/agent` 或 `/ws/commander`,收 `hello_ack` 即通)。
|
||||||
|
- 日志:确认无 `auth_failed` 残留——有则说明还有端没换到新 token。
|
||||||
|
|
||||||
|
### 2.6 回滚
|
||||||
|
|
||||||
|
轮换失败(如某端无法改)时,**把服务端 env 回退到旧 token 并再重启一次**,回到轮换前状态;客户端已改的端随之 `auth_failed`,需一并回退。回滚与轮换同样是锁步中断。
|
||||||
|
|
||||||
|
### 2.7 根治方向
|
||||||
|
|
||||||
|
根治是**去掉单共享 token**:服务端改为 per-channel / per-client token(客户端拿自己的凭据只能绑自己的 channel),
|
||||||
|
届时任一端轮换互不影响,锁步成本消失。在此之前,本 runbook 的锁步纪律是必须遵守的现状约束。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 运维纪律:一通道一 agent
|
||||||
|
|
||||||
|
### 3.1 现象与根因
|
||||||
|
|
||||||
|
u-relay 路由按 `channel_id` 配对,且**同 channel_id 允许多个 agent 并存、命令全投递**(Vec 索引,无去重、无选举)。
|
||||||
|
即:同 `channel_id` 上跑两个 agent 实例(如两个 devflow 桌面端、或 devflow + u-claw runner 用了同一通道),
|
||||||
|
一条 commander 指令会被**两端各执行一次**——写文件、跑命令、调外部接口全部重复,且两端事件回传互相交错。
|
||||||
|
|
||||||
|
### 3.2 危害
|
||||||
|
|
||||||
|
- 指令重复执行:副作用翻倍(重复落库 / 重复发消息 / 重复改文件),且难以察觉。
|
||||||
|
- 事件串台:commander 收到两份进度/结果,流式 UI 错乱;两 agent 的上下文互相污染。
|
||||||
|
- 隐性化:两端都「看起来正常工作」,问题只在副作用层面暴露,事后才追溯得到。
|
||||||
|
|
||||||
|
### 3.3 纪律
|
||||||
|
|
||||||
|
- **一通道一 agent**:每个 `channel_id` 在任一时刻**只允许一个 active agent**。通道不是「实例池」。
|
||||||
|
- **启动前先查**:起第二个 agent 前,确认目标 `channel_id` 上没有在跑的 agent(换一个独立 `channel_id`,或用不同通道前缀)。
|
||||||
|
- **换装/重启要收尾**:旧 agent 若未干净退出(进程被杀但连接残留),WS 连接可能存活到对端超时,期间新旧两连接同通道并存 = 短暂双投递。换装脚本须确认旧连接已断、或等旧连接超时后再起新实例。
|
||||||
|
- **命名隔离**:不同用途用不同 `channel_id`(如按机器 / 按项目 / 按用户拆通道),从命名上杜绝复用。
|
||||||
|
|
||||||
|
### 3.4 排查手法
|
||||||
|
|
||||||
|
怀疑重复执行时:
|
||||||
|
|
||||||
|
- 看 relay 日志:同一 `channel_id` 是否同时有 >1 条 agent 连接。
|
||||||
|
- 看 commander 侧:同一 `request_id` 是否收到两份 `result` / 事件重复。
|
||||||
|
- 看副作用:文件 mtime、DB 记录、外部调用日志是否成对出现。
|
||||||
|
|
||||||
|
确认后,断开多余 agent 连接(停掉重复实例)即可恢复单投递。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附:关联
|
||||||
|
|
||||||
|
- 协议全貌:[u-relay Wire 协议规格](./u-relay-wire-protocol-2026-10-04.md)(§4 心跳、§5 部署、§3.3 路由)
|
||||||
|
- u-relay 仓 README / 部署技能:中继二进制与 systemd 发布流程
|
||||||
|
- 看板登记:`docs/todo.md`「新能力引入评估纪律」下三项 P3
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
# 上下文分层组装器 — 落地方案
|
||||||
|
|
||||||
|
> 日期: 2026-10-10 | 状态: 设计稿(待拍板) | 勘察: 只读实测锚点
|
||||||
|
> 定位: **《上下文根治设计-20261006.md》§一(L1/L2/L3)/§二(P1 瘦身)/§九(v1 锁定)/§十(检索面统筹)的落地细化**,不另立设计。
|
||||||
|
> 覆盖 Wave 上下文走查实测五病灶:① `history_tokens` 与真实体量脱钩;② 每轮全量深克隆+展开 O(n²);③ 预算恒 128K;④ 裁剪后前缀缓存整体失配;⑤ L1/L2/L3 未落地、窗口外历史无召回。
|
||||||
|
> 前置提醒: `crates/df-ai/**` 正由另一写者改动(P1 工具结果外置 WIP,见 §六),本方案明确与其重叠面与对接协议。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、现状勘察(锚点实测)
|
||||||
|
|
||||||
|
### 1.1 发送链路的真实数据流(五病灶的公共底座)
|
||||||
|
|
||||||
|
每一轮 LLM 请求的消息来源分四步,散落在两处:
|
||||||
|
|
||||||
|
| 步 | 锚点 | 动作 |
|
||||||
|
|----|------|------|
|
||||||
|
| ① 裁剪 | `crates/df-ai/src/context/mod.rs:185-273` `build_for_request(sys_tokens)` | 按 `history_tokens` 判预算,超则从**头部**淘汰轮次单元 |
|
||||||
|
| ② 展开 | `src-tauri/src/commands/ai/agentic/mod.rs:1927-1944` | 逐条把 `namespace://` 引用**展开回真实内容** |
|
||||||
|
| ③ 拼装 | `agentic/mod.rs:1945-1952` | 前置 system_prompt,尾部 `merge_volatile_tail`(`agentic/stream_relay.rs:789`) |
|
||||||
|
| ④ 瘦身 | `agentic/mod.rs:1968-1988` → `stall_detector.rs:284-385` `summarize_tool_results` | view-only 摘要压缩大 tool_result(`extract_key_info`) |
|
||||||
|
|
||||||
|
### 1.2 五病灶逐条锚点
|
||||||
|
|
||||||
|
**① `history_tokens` 与真实体量脱钩(根因,最重)**
|
||||||
|
|
||||||
|
- 工具结果在 push 前先被引用化:`audit/mod.rs:1184-1191`,`should_use_namespace`(`namespace_store.rs:47-53`)命中 → `store()`(`namespace_store.rs:124-131`)入内存,**消息里只留短串 `namespace://tool/{hash}`**。
|
||||||
|
- `ContextManager::push`(`context/mod.rs:101-143`)对这条短串跑 `estimate_message`(`context_helpers.rs:62-105`),`history_tokens += tokens` 在 `:108-110`。
|
||||||
|
- 但**发送时**才在 `agentic/mod.rs:1927-1944` 展开成真实体量(单条 max 150KB,见 P1 注释 `context_helpers.rs:427-432`)。
|
||||||
|
- 结果:`history_tokens` 记的是引用串(~30 字符),真实发送是全文。预算门 `context/mod.rs:198`(`history_tokens <= available`)与压缩门 `context_lifecycle.rs:133-135`(`available*6/10 < history_tokens`)**双双恒不触发** → 每轮全量重发 → 223:1。
|
||||||
|
|
||||||
|
**② 每轮 O(n) 全量克隆+展开+压缩 → 会话累计 O(n²)**
|
||||||
|
|
||||||
|
- 未超预算分支 `context/mod.rs:198-205` 走 `all_messages_clone()`(`:294-296`,逐条深克隆);
|
||||||
|
- 展开 `agentic/mod.rs:1927-1944` 对**全部**历史逐条 `read_only` + `String` 克隆;
|
||||||
|
- 压缩 `summarize_tool_results`(`stall_detector.rs:313-385`)再遍历全部消息做内容变换。
|
||||||
|
- 三者每轮各 O(n),会话累计 O(n²)。**根因与①同源**:门失效 → 窗口永不收敛 → n 随会话单调涨。
|
||||||
|
|
||||||
|
**③ 预算恒 128K,与模型真实窗口无关**
|
||||||
|
|
||||||
|
- 主会话 `PerConvState::new`(`commands/ai/mod.rs:1241`)= `ContextManager::new(ContextConfig::default())`,`max_tokens=128_000`(`context_helpers.rs:128-136`),`budget_limit` 由它算(`:138-143`)。
|
||||||
|
- 真实窗口在 `ModelConfig.context_window`(`crates/df-ai-core/src/model.rs:138-139`),但只在 router 用,而代码自陈该维度"恒过、失效"(`agentic/mod.rs:2012` 注释)。
|
||||||
|
- 子代理同病:`sub_agent.rs:119` 也是固定 128_000。
|
||||||
|
|
||||||
|
**④ 裁剪后前缀缓存整体失配**
|
||||||
|
|
||||||
|
- 超预算时 `context/mod.rs:239-246` 切 `self.messages[trim_end..]`——头部被切,整个请求前缀位移,provider 自动前缀缓存(DeepSeek/Anthropic)整段失效。
|
||||||
|
- 已有部分缓解:淘汰摘要改尾部追加(`context/mod.rs:259-261`)、`push_system_anchor` 避让悬空头(`:582-595`)、`tool_view_decisions` 冻结压缩决策防跨轮翻转(`stall_detector.rs:352-363`)。但**基础裁剪本身**仍位移前缀,且**淘汰摘要内容逐轮漂移**(被淘汰区单调增长,`:259` 每轮即时重生成)。
|
||||||
|
|
||||||
|
**⑤ L1/L2/L3 未落地 + 窗口外历史无召回**
|
||||||
|
|
||||||
|
- 组装是隐式的:system(L1 内容)由 `build_runtime_prompt`(`agentic/mod.rs:3042`)拼,历史由 `build_for_request` 拼,易变尾由 `merge_volatile_tail` 拼,无统一"组装器"对象、无可观测统计。
|
||||||
|
- 检索仅覆盖知识库:`hybrid_search`(`knowledge_inject.rs:421`)→ `build_knowledge_context`(`:508`)→ `build_knowledge_context_for_conv`(`:587`);**窗口外对话历史与压缩摘要无处可召回**。
|
||||||
|
|
||||||
|
### 1.3 与既有设计的衔接
|
||||||
|
|
||||||
|
- 本方案 = 根治设计 §一 的接口化落地;`上下文三层记忆-设计-2026-10-04.md` 的"记忆卡"(C-m1)是 L3 的**记忆卡来源**,本方案不重复其产出逻辑,只提供召回入口。
|
||||||
|
- §九 v1 锁定范围(P1 瘦身+P2 页池+P3 压缩常规化+P4 评估)不变;本方案细化 **P2**,并把 §九"唯一存储/唯一调度器/唯一召回入口"三收敛落到具体接口。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、目标与验收口径
|
||||||
|
|
||||||
|
**目标**
|
||||||
|
1. **账实相符**:预算/压缩门依据的 token 数 = 实际发送视图的体量(单一真值来源)。
|
||||||
|
2. **组装显式化**:L1 常驻 / L2 滑动窗口 / L3 检索召回 三段各有明确产出与稳定合并顺序;附带可观测统计。
|
||||||
|
3. **前缀稳定**:稳态下请求前缀只追加、不位移;裁剪以批量滞后发生。
|
||||||
|
4. **预算随模型**:上下文预算取自当前模型的 `context_window`,非恒定 128K。
|
||||||
|
5. **可召回**:窗口外历史/记忆卡经统一检索面按需注入,复用 `knowledge_inject` 管线。
|
||||||
|
|
||||||
|
**验收口径**
|
||||||
|
- 账实相符:`history_tokens` 与"发送视图 `estimate_message` 求和"偏差 ≤ 5%(构造含大 `namespace://` 引用的会话实测)。
|
||||||
|
- 门生效:构造超预算会话,`context_trimmed`(`context/mod.rs:263`)按真实体量触发;压缩门 `context_lifecycle.rs:133` 在正确水位触发。
|
||||||
|
- 前缀稳定:稳态连续 N 轮,请求消息数组的公共前缀长度 = 全量(无裁剪)或 = system 段(裁剪后仅在再裁剪时才变);provider 缓存命中率不再掉到个位数。
|
||||||
|
- 预算随模型:`context_window=256K` 的模型下 `budget_limit` = 256K×0.85−8K 量级,不再恒 128K。
|
||||||
|
- 可召回:窗口外某历史事实,仅凭关键词在下一轮被召回注入(带来源标注)。
|
||||||
|
- 不倒退:单轮/短会话(未超预算)行为与改造前逐字节一致;`df-ctx-*` 开关全关 = 旧路径。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、设计方案
|
||||||
|
|
||||||
|
### 3.0 核心判断
|
||||||
|
|
||||||
|
五病灶**同源于一个断裂**:"保留形态"与"发送形态"两套尺寸,门只看了前者。因此**不新增存储、不另立调度**——本方案只做三件事:
|
||||||
|
1. 把"门所依据的尺寸"与"发送形态"对齐(**记账基点 = 发送视图尺寸**);
|
||||||
|
2. 把隐式拼装收敛为一个显式**组装器**(三段产出 + 稳定顺序 + 统计);
|
||||||
|
3. 把检索面从"仅知识库"扩为**注册表**(知识库 + 历史页 + 记忆卡同入口),落到 `build_knowledge_context_for_conv` 单点。
|
||||||
|
|
||||||
|
### 3.1 单一真值来源的 token 估值(治 ①,兼解 ②)
|
||||||
|
|
||||||
|
**两法对比**
|
||||||
|
|
||||||
|
| 法 | 做法 | 评价 |
|
||||||
|
|----|------|------|
|
||||||
|
| **A 记账点算"发送视图尺寸"** | 在内容产生点(及 reload)按 `view_form(msg)` 估 token——大工具结果按 `extract_key_info` 后的摘要+句柄尺寸入账 | O(1)/条、纯函数、与发送形态**同函数**(单一真值)、不依赖 namespace LRU 状态 |
|
||||||
|
| B 估值器识别 ref 查真实大小 | `estimate_message` 遇 `namespace://` 前缀 → 查 store 取真实内容再估 | 破坏 `TokenEstimator` 纯函数性(需注入 store)、每轮 O(n) 查+扫(加重②)、引用被淘汰 / 重启后查不到 → 退回串长仍脱钩 |
|
||||||
|
|
||||||
|
**取 A**。理由:A 让"记账"与"发送"共用同一个纯函数,是真值来源唯一化的最短路径;B 把复杂度塞进每轮热路径且对淘汰态脆弱。A 单点落在估值函数,正是 `context_helpers.rs` 的职责域(与既有 `should_summarize_tool_result`/`extract_key_info` 同居一文件,DRY)。
|
||||||
|
|
||||||
|
**接口形态(新增,minimal)**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// crates/df-ai/src/context_helpers.rs — 与 extract_key_info 同域,共享同一压缩逻辑
|
||||||
|
/// 发送视图尺寸(记账唯一口径):与发送路径"展开→summarize_tool_results"后的形态对齐。
|
||||||
|
/// 大 tool_result → extract_key_info 结果的 token;其余 → 内容 token。
|
||||||
|
pub fn view_form_tokens(estimator: &TokenEstimator, content: &str, tool_name: Option<&str>) -> u32;
|
||||||
|
|
||||||
|
// crates/df-ai/src/context/mod.rs — push 的带尺寸覆盖入口(记账用)
|
||||||
|
impl ContextManager {
|
||||||
|
/// 同 push,但 token_count 用调用方给定的发送视图尺寸(工具名在产生点可得)。
|
||||||
|
pub fn push_with_tokens(&mut self, message: ChatMessage, tokens: u32);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**改动点**
|
||||||
|
- 产生点 `audit/mod.rs:1184-1191`:此处已握有真实 `content` 与 `draft.name`,算 `view_form_tokens(...)` 后走 `push_with_tokens`,取代 `push` 的 content 估值。删掉 push 的隐式 ref 估值这条唯一失真源。
|
||||||
|
- 其余重估入口(`replace_tool_result_content` `context/mod.rs:332`、`replace_last_active_user_content` `:418`)同改走 `view_form_tokens`,保证单一口径。
|
||||||
|
- **reload 一致性**:DB 存的是展开后的真实内容(`namespace_store.rs:3-4` 注释 / `agentic/mod.rs:1922`),故 `restore_from_messages`(`context/mod.rs:310`)须同样按 `view_form_tokens` 记账,否则"新会话(视图尺寸)≠ 重载(真实尺寸)"两态不一致。工具名从历史重建(`build_id_to_name`,`stall_detector.rs:300`)取得(见拍板点 2)。
|
||||||
|
- **② 的化解**:① 修好后门下移到位 → 裁剪按真实体量发生 → 发送窗口收敛为常数 W → 每轮克隆/展开/压缩从 O(n) 降到 O(W),会话累计从 O(n²) 降到 O(n·W)。②无需单独"优化",随①自愈。
|
||||||
|
|
||||||
|
### 3.2 分层组装的接口形态(治 ②④⑤)
|
||||||
|
|
||||||
|
**现状**:拼接散在 `context/mod.rs:185-273`(窗口裁剪)与 `agentic/mod.rs:1945-1952`(system+尾)。改为一个**显式组装器**,产出结构化的三段 + 易变尾 + 统计:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// src-tauri/src/commands/ai/agentic/assembler.rs (新文件;或收敛进现有 helpers.rs)
|
||||||
|
/// 组装器产出:有序三段 + 易变尾 + 可观测统计。
|
||||||
|
pub struct AssembledContext {
|
||||||
|
pub resident: Vec<ChatMessage>, // L1:system 段(见 3.2.1)
|
||||||
|
pub window: Vec<ChatMessage>, // L2:滑动窗口(三元组对齐的发送视图)
|
||||||
|
pub recall: Vec<ChatMessage>, // L3:检索召回(user 角色,易变,固定置尾)
|
||||||
|
pub volatile: Option<String>, // 既有易变尾(日期/清单/knowledge)
|
||||||
|
pub trimmed: bool,
|
||||||
|
pub stats: AssemblyStats, // 页命中/召回数/换出量/system 估算
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 合并顺序固定:resident → window → volatile → recall,全部只追加不位移前缀。
|
||||||
|
pub fn assemble(ctx: &ContextManager, sys_tokens: u32, recall: Vec<ChatMessage>, volatile: Option<String>)
|
||||||
|
-> AssembledContext;
|
||||||
|
```
|
||||||
|
|
||||||
|
**合并顺序与缓存友好的三条不变式**
|
||||||
|
1. **L1 resident 最前且为 loop 不变量**:`build_runtime_prompt`(`agentic/mod.rs:3042`)产出,pinned_goals/工作上下文/技能/DAG 均在此,整个 loop 文本不变 → 稳定缓存前缀最大块。
|
||||||
|
2. **L2 window 追加为主**:`build_for_request` 的裁剪保留区(`context/mod.rs:243-246`)即窗口。三元组原子性沿用 `build_eviction_units`(`:612-642`)的单元边界,窗口切片必落单元边界。稳态只 push 追加,前缀不变。
|
||||||
|
3. **L3 recall + volatile 严格置尾(role=user)**:召回复用 `merge_volatile_tail`(`stream_relay.rs:789`)的"末尾 user 承载"范式——易变内容不进缓存前缀。**召回绝不插进 window**(避免劈三元组、位移前缀)。
|
||||||
|
|
||||||
|
**与 `build_for_request` 的关系**:`build_for_request` 保留为 L2 的**纯函数内核**(签名与三元组逻辑不动),组装器在其上"包一层":调 `build_for_request(sys_tokens)` 取窗口 → 前置 resident → 追尾 volatile/recall → 填 `AssembledContext`。即"内核不改行为,组装器负责编排"。调用点 `agentic/mod.rs:1910-1953` 从内联拼装改为 `assemble(...)` 单调用。
|
||||||
|
|
||||||
|
**前缀稳定性(治④)**
|
||||||
|
- **滞后水位批量裁剪**:trim 触发用高水位(`high = available`),一次裁到低水位(`low = available × k`,建议 k≈0.6),使裁剪**稀疏发生**;两次裁剪之间窗口 append-only,前缀稳定。替代现行"每轮才够就裁一单元"(`context/mod.rs:211-219`)。
|
||||||
|
- **淘汰摘要冻结**:现行摘要每轮重生成致漂移(`:259`)。改为与 `tool_view_decisions` 同款**按内容哈希冻结**(首次生成后沿用),前缀稳定。
|
||||||
|
- **随①自愈**:① 使门在正确水位触发,裁剪频次本就下降。
|
||||||
|
|
||||||
|
### 3.3 检索召回复用 knowledge_inject 管线(治⑤,兼§十统筹)
|
||||||
|
|
||||||
|
**现状单入口**:两个调用点(`agentic/helpers.rs:150`、`commands/chat.rs:398`)都经 `build_knowledge_context_for_conv`(`knowledge_inject.rs:587`),内部 `hybrid_search`(`:421`)+ `merge_hybrid_results`(`:468`)排序。
|
||||||
|
|
||||||
|
**改为注册表(§十"召回入口统一"的落地)**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// src-tauri/src/commands/ai/knowledge_inject.rs — 扩为召回面
|
||||||
|
type RecallItem = (df_storage::models::KnowledgeRecord, RecallKind); // 复用既有 record 形态
|
||||||
|
enum RecallKind { Knowledge, HistoryPage, MemoryCard }
|
||||||
|
|
||||||
|
/// 各源实现同一检索签名;合并复用 merge_hybrid_results 的排序/去重/limit 语义。
|
||||||
|
async fn recall(query, limit, ...) -> Vec<RecallItem> // 内部:知识库(既有) + 历史页(新) + 记忆卡(新)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **入口不动**:`build_knowledge_context_for_conv`(`:587`)与 `inject_knowledge_into_prompt`(`:633`)保持签名,内部从"只查知识库"扩为"查注册表全部源",输出**按源标注**(`[知识]`/`[历史]`/`[记忆卡]`)。调用点零变更。
|
||||||
|
- **HistoryPageSource(v1 当前会话)**:query 复用末条 active user 文本(`knowledge_inject.rs:592-601` 已取);扫描当前 `conv.messages` 中 **!is_active(compressed/archived)** 与**窗口外**消息,关键词重叠排序取 top-K。**零新存储**——数据已在 `ContextManager.messages` 全量保留(`context/mod.rs:104-107`)。
|
||||||
|
- **MemoryCardSource**:压缩摘要(`ChatMessage::system(&summary.nl_summary)`,`context_lifecycle.rs:294` 尾部追加)与 `上下文三层记忆` C-m1 的记忆卡,作为可召回条目注册。v1 可与 HistoryPageSource 合并实现。
|
||||||
|
- **配额闸(§十原则4)**:召回页每轮 ≤K(建议 5),配额独立于知识注入既有上限,防信息炸弹。
|
||||||
|
|
||||||
|
### 3.4 预算随模型窗口(治③)
|
||||||
|
|
||||||
|
- 预算来源改为当前模型的 `ModelConfig.context_window`(`df-ai-core/src/model.rs:138-139`)——该值已在 `AiProviderRecord.model_configs`(`df-storage/src/models.rs:306+`,经 `deserialize_model_configs` 解析)可得,loop 顶部已持 `provider_config`。
|
||||||
|
- 落地:`ContextManager` 增 `set_max_tokens` 或在派发前用模型窗口重建 `ContextConfig`;`sys_tokens` 估算点(`agentic/mod.rs:1115`)不变。
|
||||||
|
- 缺省回退:当前模型无 `context_window`/为默认值时,沿用 128K(零回归)。
|
||||||
|
|
||||||
|
### 3.5 边界总览(三段职责)
|
||||||
|
|
||||||
|
```
|
||||||
|
L1 resident ← build_runtime_prompt (agentic/mod.rs:3042) 常驻,loop 不变量,缓存前缀
|
||||||
|
L2 window ← build_for_request (context/mod.rs:185) 滑动,三元组对齐,记账=view_form
|
||||||
|
L3 recall ← recall registry (knowledge_inject.rs:587 扩) 按需,置尾,配额闸
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、分期(每期独立可上线、可回退)
|
||||||
|
|
||||||
|
### P2.1 — 真值 token 估值(治①,兼解②)【S,最先】
|
||||||
|
- 新增 `view_form_tokens`(`context_helpers.rs`)+ `push_with_tokens`(`context/mod.rs`)。
|
||||||
|
- 改 `audit/mod.rs:1191` push 站点与重估入口(`context/mod.rs:332`/`:418`)、reload 路径(`:310`)。
|
||||||
|
- 开关 `df-ctx-accurate-tokens`(默认 **true**;false = 旧 content-based 记账,逐字节回退)。
|
||||||
|
- 验收:账实相符 + 门生效两条。**此期不动发送内容,零行为变化。**
|
||||||
|
|
||||||
|
### P2.2 — 组装器结构化(治④,为 P2.3 铺路)【M】
|
||||||
|
- 新增 `AssembledContext` + `assemble()`;`agentic/mod.rs:1910-1953` 内联拼装改单调用;加 `AssemblyStats` 日志/事件。
|
||||||
|
- trim 滞后水位 + 淘汰摘要冻结。
|
||||||
|
- 行为等价重构(除 trim 时机稀疏化,可单开关 `df-ctx-trim-hysteresis` 默认 true)。
|
||||||
|
- 验收:前缀稳定 + 不倒退两条。
|
||||||
|
|
||||||
|
### P2.3 — L3 检索召回(治⑤)【M】
|
||||||
|
- `knowledge_inject.rs` 扩为召回注册表 + HistoryPageSource(v1 当前会话)+ 源标注;配额闸 ≤K。
|
||||||
|
- 开关 `df-ctx-recall`(默认 **off**,择机灰度开)。
|
||||||
|
- 验收:可召回一条。
|
||||||
|
|
||||||
|
### P2.4 — 预算随模型窗口(治③)【S】
|
||||||
|
- 接 `ModelConfig.context_window`;缺省回退 128K。开关 `df-ctx-model-window`(默认 true)。
|
||||||
|
- 验收:预算随模型一条。
|
||||||
|
|
||||||
|
### P2.5 — 页池统一【北极星,触发制,不在本方案实施】
|
||||||
|
- 触发条件:单会话版实测后仍有跨会话/内存痛点(§九问题2);或将 namespace_store(内存)与 tool_offload(磁盘)合流为唯一内容寻址页池(§九问题1 收敛)。
|
||||||
|
- 本方案只**预留接口边界**(见 §六),不实施。
|
||||||
|
|
||||||
|
**顺序**:P2.1(最大杠杆、最小改动)→ P2.2(结构)→ P2.4(廉、高值,可与 P2.2 并行)→ P2.3。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、影响面(五维)
|
||||||
|
|
||||||
|
- **兼容**:P2.1 只改记账,不改发送内容;老会话 reload 后按 `view_form_tokens` 重算(与新建会话口径一致,依赖 reload 路径同改)。P2.2 行为等价重构。P2.3/P2.4 均带开关,关=旧路径。无 schema 变更。
|
||||||
|
- **破坏**:裁剪切点由"即时"变"滞后批量"(P2.2)——行为变更但语义等价(仍保三元组、仍有淘汰摘要);开关可退。无不可逆操作。
|
||||||
|
- **安全**:检索召回仅扩"只读召回面",注入内容受既有配额闸约束;不引入新命令执行面。召回的历史页来自本会话已保留内容,无跨权限面(v1 单会话)。
|
||||||
|
- **资源**:① 修正后 `history_tokens` 上升到真实量级,裁剪更积极 → **发送 token 下降**(目标 §一)。召回新增每轮 ≤K 页注入(可控)。无新持久化。
|
||||||
|
- **回退开关**:`df-ctx-accurate-tokens` / `df-ctx-trim-hysteresis` / `df-ctx-recall` / `df-ctx-model-window`(均 KV 热读,默认值见各期)。观测:`AssemblyStats`(召回命中/换出量/window token/trim 次数)+ 既有 `context_trimmed` 日志。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、与 P1 工具结果瘦身(另一写者)的边界与对接
|
||||||
|
|
||||||
|
**P1 现状(WIP,`git diff`)**:`crates/df-ai/src/lib.rs` 加 `pub mod tool_offload;`;`context_helpers.rs` 加 `READ_FILE_OFFLOAD_BYTES:432` / `TOOL_OFFLOAD_HANDLE_MIN_BYTES:436`,重构 `extract_key_info`(`:472`) 为"小文件豁免 + 超阈值压缩并落盘句柄"。消费点 `stall_detector.rs:342-351`、`sub_agent.rs`。
|
||||||
|
|
||||||
|
**职责二分(不双实现)**
|
||||||
|
| 归属 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| **P1(内容变换)** | 大工具结果**如何变小**:`extract_key_info` / `offload_with_handle`(`tool_offload.rs:46`)/ `namespace_store` 引用化 |
|
||||||
|
| **本方案(记账+组装)** | 变小后**占多少**、**发什么**、**如何召回**:`view_form_tokens` 记账基点、组装器、召回面 |
|
||||||
|
|
||||||
|
**对接协议(冻结接口)**
|
||||||
|
1. **单一压缩函数**:本方案的 `view_form_tokens` 内部**复用 P1 的 `extract_key_info`**(同域同 crate),不另写压缩逻辑。P1 若改阈值/句柄格式,本方案自动跟随——**一处定义**。
|
||||||
|
2. **单一存储形态(§九问题1)**:`namespace_store`(内存)与 `tool_offload`(磁盘)是**同一原文的两种介质**,构成双实现隐患。**本方案 v1 不碰二者**,只在 §3.3 召回面与 §二验收中把"结果页"抽象为内容哈希寻址(handle 与 ref key 均已是内容哈希:`namespace_store.rs:353` / `tool_offload.rs:37`)——为 P2.5 合流预留,不在 v1 制造第三套。
|
||||||
|
3. **文件重叠**:`context_helpers.rs` 双方都动(P1 改 `extract_key_info`/常量;本方案加 `view_form_tokens`)。约定:本方案新增函数**不改 P1 既有函数签名**;`view_form_tokens` 以只读方式调用 `extract_key_info`。**串行合并**:P1 先合(其 `extract_key_info` 形态是本方案记账的输入),本方案在 P1 之上加记账点。
|
||||||
|
4. **执行序**:P2.2/P2.3/P2.4 与 P1 无重叠文件(改 src-tauri,除消费 P1 常量),可并行;P2.1 与 P1 同文件,**待 P1 合入后落**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、拍板点清单
|
||||||
|
|
||||||
|
| # | 决策 | 选项 | 建议 |
|
||||||
|
|---|------|------|------|
|
||||||
|
| 1 | 记账基点(单一真值来源) | A 发送视图尺寸(view_form,与发送同函数);B 保留内容/引用串尺寸 | **A**。B 无法消除账实脱钩;B 是当前病灶本身 |
|
||||||
|
| 2 | reload 路径口径 | A 重建 id→name,reload 亦按 view_form(精确一致);B reload 按真实内容保守估(实现简,与新建会话口径不完全一致) | **A**。一致性是"单一真值"的前提;`build_id_to_name` 已存在可复用 |
|
||||||
|
| 3 | 组装器落地形态 | A 新增 `AssembledContext` 结构 + `assemble()` 单入口;B 保留 `build_for_request` 返回 tuple,拼接仍分散 | **A**。B 延续"拼接分散",统计/顺序无处安放 |
|
||||||
|
| 4 | L3 召回 v1 半径 | A 仅当前会话窗口外页(in-memory 关键词,零新存储);B 纳入跨会话/向量检索 | **A**。跨会话/向量是 §九 v2+ 北极星(触发制),v1 零新存储先证价值 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、与既有文档关系
|
||||||
|
|
||||||
|
- 本方案 = 《上下文根治设计-20261006.md》§一/§二/§九/§十的**接口细化与实施序**,不改其 v1 范围与北极星定位。
|
||||||
|
- `上下文三层记忆-设计-2026-10-04.md`:其 C-m1 记忆卡是 L3 的 `MemoryCardSource` 数据源,本方案只提召回入口(§3.3),不重复卡片产出。
|
||||||
|
- `namespace淘汰治理-设计-2026-10-04.md`:namespace_store 的 pin/EvictionLog/owner 治理不变;P2.5 页池统一时作为 RAM 层纳入。
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# 上下文管理对标 u-claw 与大批量并行差距分析(2026-10-19)
|
||||||
|
|
||||||
|
> 用户令(2026-10-18 飞书):①大批量 AI 任务调度/多会话多任务并行是未来常态核心场景;②战略目标=DevFlow 部署 Linux 替代 u-claw+Claude Code(CC 效率不够高、资源消耗太大);③第一门槛=上下文管理三命题:丢记忆、历史信息召回、高效利用 token;④方法=学习 u-claw 历史提交记录补 devflow 场景不足。
|
||||||
|
|
||||||
|
## 一、u-claw 上下文管理能力画像(源自主仓 6 条相关提交,核心是 86e9216)
|
||||||
|
|
||||||
|
| # | 能力 | u-claw 实现 | 要点 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | **token 水位判据** | 140K≈200K 窗口 70% 主判据(可配),体积判据降兜底 | 治「换代过早」:350KB 体积≈真实水位 20-35%,体积判据致 23 代/日→水位判据 <5/日 |
|
||||||
|
| 2 | **usage 真值采集** | 读流解析 message.usage(input+cache_read+cache_creation),失败静默 0 | 水位判据的数据底座 |
|
||||||
|
| 3 | **大内容外置纪律** | system prompt 处理规则:大结论写盘 out/,回复只给路径+3 行结论 | 治上下文滚雪球(attach/ 86 文件 25MB 实锤) |
|
||||||
|
| 4 | **换代通知推送** | rollover 等会话层事件经渠道真推送(非仅日志) | 用户对会话滚动有感知 |
|
||||||
|
| 5 | **/history 归档检索** | 无参列最近 10 代,关键词 grep summary | 跨代记忆召回入口 |
|
||||||
|
| 6 | **归档检索指引** | sopHint 注明 jsonl 字段与 grep→读 summary 操作路径 | 让 AI 自己会查历史 |
|
||||||
|
|
||||||
|
另:0a4f8dd 重上下文估算(防图片堆积)、a3f28dc 群聊上下文增量注入(按决策价值治理进度卡)。
|
||||||
|
|
||||||
|
## 二、devflow 现状对照(三命题 × 已治/缺口)
|
||||||
|
|
||||||
|
### 命题 1:丢记忆
|
||||||
|
- ✅ 已治:目标语义重构(用户任务原文入 pinned_goals)、恢复链快照([系统恢复]/[补充指令] 产物双侧过滤,10-18 批)、压缩摘要四段式(意图/决策/文件/约束+失败事实保留)、E2 降级透出(10-18)
|
||||||
|
- ❌ 缺口:**boundary/loop_state 填充率 0**(终局态建模半闭环)——「代」的显式语义缺失,会话边界靠消息流推断
|
||||||
|
|
||||||
|
### 命题 2:历史信息召回
|
||||||
|
- ✅ 已治:单会话内压缩可召回(archive jsonl+召回句柄行)、知识路标投影进系统提示词
|
||||||
|
- ❌ 缺口:**跨会话检索无入口**(u-claw /history 等价物缺失)——「上次那个任务怎么弄的」只能靠人翻侧栏;消息全文搜索未做(F4-1 只做了标题+列表分页)
|
||||||
|
|
||||||
|
### 命题 3:高效利用 token
|
||||||
|
- ✅ 已治:压缩 memoization+决策冻结(10-04 病②治愈)、尾部追加摘要保前缀缓存、E13 超时连败收敛
|
||||||
|
- ❌ 缺口:
|
||||||
|
- **usage 真值采集未落地**(6 月 P1 敲定至今暂缓;cache_hit≈0% 说明 cache 口径有列无真值)——水位判据缺数据底座
|
||||||
|
- **压缩触发判据待查**:压缩/请求比 4.8 偏频,疑体积/轮次判据过敏感(对标 u-claw 病灶同型)
|
||||||
|
- **大内容外置纪律缺失**:工具输出有 truncate,但「大结论写盘+回复给路径」没有
|
||||||
|
|
||||||
|
### 大批量并行新命题(u-claw 亦未解,devflow 需原创)
|
||||||
|
- 多会话同时压缩互不感知(各自预算独立,全局 token 消耗无视图)
|
||||||
|
- 并行任务的信息投影(侧栏/仪表盘聚合 N 个会话的进行态)
|
||||||
|
- 调度面:优先级/抢占/停靠恢复已有底座(TaskBoundary/parked),缺**全局调度器**视角
|
||||||
|
|
||||||
|
## 三、补差优先级(P0→P2)
|
||||||
|
|
||||||
|
| 级 | 项 | 移植来源 | 落点 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **P0** | usage 真值采集(stream_options.include_usage 三口径) | u-claw #2 | 6 月 P1 设计直接复活;先有数据再谈判据 |
|
||||||
|
| **P0** | 压缩触发改 token 水位主判据(≈窗口 70%,体积兜底) | u-claw #1 | maybe_auto_compress 判据重构,依赖上项 |
|
||||||
|
| **P1** | 跨会话归档检索:/history 等价(关键词搜消息全文+摘要) | u-claw #5/#6 | ai_messages 全文索引(FTS5 或 LIKE 起步);检索指令+AI 检索指引 |
|
||||||
|
| **P1** | boundary/loop_state 写入闭环(代语义显式化) | — | 停靠/终局时填充;换代通知推送(u-claw #4 同款) |
|
||||||
|
| **P2** | 大内容外置纪律(system prompt 处理规则+out/ 目录约定) | u-claw #3 | prompt.rs 处理规则段+workspace 目录约定 |
|
||||||
|
| **P2** | 并行资源治理:全局 token 预算视图+多会话压缩协防 | 原创 | 调度器域,与 Linux 化路线(看板 10-18 远程容器方向)合流设计 |
|
||||||
|
|
||||||
|
## 四、与 Linux 化战略的衔接
|
||||||
|
|
||||||
|
上下文管理是第一门槛(用户拍板),Linux 化是最终形态。依赖链:**usage 真值 → 水位判据 → 代语义+归档检索 → 并行调度治理 → server 化无头运行**。前三步在现有 Tauri 架构内即可推进(P0 两项纯 Rust 域),后两步与远程容器/df-tunnel 线合流。建议节奏:P0 两项作下一批(devflow agent 领单),P1 随后,P2 与 Linux 化立项合并设计。
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
# 压缩裁剪缓存友好化 — 设计
|
||||||
|
|
||||||
|
> 2026-10-11 · 只读勘察产物
|
||||||
|
> 主题:上下文压缩与裁剪以破坏 provider 前缀缓存为代价 → 在保住预算安全的前提下最大化缓存命中
|
||||||
|
> 行号快照:2026-10-11 工作区(context/mod.rs 与 context_helpers.rs 有并行代理在改,实施前须对齐最新行号)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 〇、文档定位与既有工作衔接
|
||||||
|
|
||||||
|
本文档是「上下文分层组装器-落地方案-2026-10-10.md」病灶④(裁剪后前缀缓存整体失配)的**机制细化**,并新增覆盖压缩链路的一次压尽问题。该方案已给出验收口径:「稳态下请求前缀只追加、不位移;裁剪以批量滞后发生」——本文档给出达成该口径的具体算法、参数推理与断点布点。两文档不冲突:分层组装器治①(真值 token)②(O(n²))③(预算随窗口)⑤(召回),本文档专治④+压缩,可与 P2.1/P2.2 并行实施(见§八协调注)。
|
||||||
|
|
||||||
|
另:看板 AC-EFF-C1-2「每轮全量重发 O(n²) 部分缓解(cache_control+稳定前缀后第2轮起命中,增量滑窗留待后续批)」与「警示消息打断前缀缓存」两登记项,均由本文档方案覆盖。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、两个缺陷的准确定性与证据
|
||||||
|
|
||||||
|
### 缺陷1:滑动裁剪致前缀缓存整体失配
|
||||||
|
|
||||||
|
`build_for_request`(`crates/df-ai/src/context/mod.rs:185`)超预算时走贪心最小裁剪:`for unit in &units { if history_tokens - removed <= available { break } removed += unit.token_sum }`(`mod.rs:211-219`)——**每次只裁到刚好塞下为止**。对话每增长一条消息就重新越过 `available` 一点,裁剪边界 `trim_end` 随之前移一个单元,发送视图的**第一条消息每轮都变**。前缀缓存(anthropic 显式 cache_control 与 openai_compat 自动前缀缓存同理)要求从第 0 字节起逐字节相同,首条即变 → 整段历史永远 miss。
|
||||||
|
|
||||||
|
已缓和但不治本的既有修正:
|
||||||
|
- 淘汰补偿摘要从头部 insert(0) 改尾部 push(user 角色,`mod.rs:250-261`),注释明言治「命中率掉到个位数」;
|
||||||
|
- `push_system_anchor` 尾部追加不位移前缀(`agentic/helpers.rs:28-44`、`context_lifecycle.rs:309-313`);
|
||||||
|
- `tool_view_decisions` 冻结压缩决策防跨轮翻转(`agentic/mod.rs:2004-2005`),`compress_cache` 按内容哈希记忆化摘要结果(`stall_detector.rs:368-379`)→ tool_result 视图摘要跨轮字节稳定,已不构成缓存威胁。
|
||||||
|
|
||||||
|
**残余根因**:裁剪边界本身逐轮爬行。注意一个连带效应——淘汰摘要每轮即时重生成,其内容在两次裁剪之间是静态的(被淘汰集不变 → `extract_top_keywords` 排序确定性,`context_helpers.rs:284-288`);「摘要逐轮漂移」正是裁剪逐轮爬行的投影。块状化后漂移自动收敛到裁剪事件点。
|
||||||
|
|
||||||
|
### 缺陷2:一次压尽
|
||||||
|
|
||||||
|
压缩触发:`available*6/10 < history_tokens`(`agentic/context_lifecycle.rs:129-136`),后台 LLM 摘要成功后 `compress_old_messages(protect_start)` 把 `[0, len-6)` 的**全部 active 消息一次性标 compressed**(`context_lifecycle.rs:308`、兜底路径 `:442`;`compress_old_messages` 实现 `mod.rs:670-699`)。sanitize step0 按 `is_active` 白名单过滤(`sanitize.rs:40-43`;`is_active`= status None/Active,`df-ai-core/provider.rs:91-99`)→ 被压消息从发送视图**整体蒸发**,视图前缀从第 0 条起全变,一次压缩 = 历史段全量缓存重建。信息损失同样一次集中(用户体感「丢历史」)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、现状机制盘点(设计依赖的既有事实)
|
||||||
|
|
||||||
|
| 机制 | 位置 | 事实 |
|
||||||
|
|---|---|---|
|
||||||
|
| 预算公式 | `context_helpers.rs:154-178` | `budget = (max_tokens − output_reserve) × safety_ratio`;默认 128K/8K/0.85 → **budget ≈ 101,836**;`available = budget − sys_tokens`(sys 5–10K → available ≈ 92–97K) |
|
||||||
|
| 裁剪触发 | `mod.rs:198,214` | `history_tokens > available`(100% 硬门) |
|
||||||
|
| 保护区 | `context_helpers.rs:683` | `PROTECT_COUNT = 6`(≈最近 2 轮);**context_lifecycle.rs:53 另有一份本地副本 6,双源待统一** |
|
||||||
|
| 淘汰单元 | `mod.rs:612-642` | 三元组原子(头+尾+紧随独立 assistant),压缩定位/分段标记/裁剪共用 |
|
||||||
|
| 压缩触发 | `context_lifecycle.rs:129-136` | `0.6×available` + `has_compressible_messages` + 非压缩中 |
|
||||||
|
| 压缩边界回退 | `mod.rs:676-683` | `compress_old_messages` 内建单元边界回退(只少压不多压)——**分批压缩可直接复用** |
|
||||||
|
| 摘要补偿 | `context_helpers.rs:332-364` | 淘汰摘要 ≥100 字符才产,硬顶 1000 字符(`:692`);关键词提炼确定性 |
|
||||||
|
| system 断点 | `anthropic_compat.rs:127-145` | 开关开 → 顶层 system 包 blocks + cache_control |
|
||||||
|
| 消息级断点 | `anthropic_compat.rs:243-247,339-376` | 打在**末条 user 之前相邻一条**(末条 user 是漂移边界:易变尾段/本轮新问题,打它永远不命中);注释明言预算 4 个 cache_control,现用 2 个 |
|
||||||
|
| 缓存观测 | `anthropic_compat.rs:723-731` | usage 已映射 `cache_read/cache_creation`——**命中率观测零成本可取** |
|
||||||
|
| 开关范式 | `prompt.rs:657-695` | settings KV `df-ai-anthropic-cache`,`parse_cache_flag`:空/true/1=开,false/0/杂值=关;进程级 AtomicBool 同步;`cache_split_provider_type` 按 provider gate;关=逐字节旧行为 |
|
||||||
|
| 易变尾段 | `prompt.rs:709-736`、`agentic/mod.rs:1983-1990`、`stream_relay.rs:791-795` | 日期/清单/augmentation/知识在**每次 send 时构建一次**(`commands/chat.rs:417,996,2209,2476`),loop 内多轮稳定,以 user 角色追加消息流末尾 |
|
||||||
|
| system 折叠 | `anthropic_compat.rs:128-133,153` | **关键发现:存储态所有 role=System 消息(不论位置)被抽离折叠进顶层 system(sys.join)**。压缩摘要/关键词兜底/话题标记/催办全走 `push_system_anchor(ChatMessage::system(...))`(`context_lifecycle.rs:313,449`、`agentic/mod.rs:1869,2510,2557`)→ 每次此类事件都改写顶层 system → **BP1 连带全失配**。淘汰摘要已因同因改 user 角色(`mod.rs:254-256` 注释),压缩摘要等锚点未跟进 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、设计总览
|
||||||
|
|
||||||
|
三机制分层,各管一段:
|
||||||
|
|
||||||
|
```
|
||||||
|
历史水位轴(×available)
|
||||||
|
0 0.55 0.75 1.0(=available)
|
||||||
|
| | | |
|
||||||
|
|<-- 压缩目标水位 C_low | |
|
||||||
|
| |<-- 批次≈0.2A |
|
||||||
|
| | ↑ 压缩触发 C_high(深清,后台,保语义)
|
||||||
|
| | ↑ 裁剪触发 1.0(应急,view-only)
|
||||||
|
| | |←→| 裁剪块≈0.2A → 裁到 T=0.8
|
||||||
|
```
|
||||||
|
|
||||||
|
- **裁剪=应急**(build_for_request,view-only):只在压缩失灵(后台失败连续多轮/未及生效)时兜底,块状跳变;
|
||||||
|
- **压缩=深清**(后台 LLM,改存储 status):常态水位管理,分批渐进;
|
||||||
|
- 稳态下裁剪永不触发(0.75+批内增长 < 1.0),历史段前缀在两次压缩事件之间逐字节稳定。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、方案一:块状裁剪
|
||||||
|
|
||||||
|
### 4.1 算法
|
||||||
|
|
||||||
|
替换 `mod.rs:211-219` 贪心循环,改「目标水位 + 轮边界吸附」:
|
||||||
|
|
||||||
|
1. 触发条件不变:`history_tokens > available`(硬安全门,`mod.rs:214` 同式)。
|
||||||
|
2. 目标水位 `T × available`(T 默认 0.8):累计淘汰单元 token,直到 `history_tokens − removed ≤ T×available` **且**当前边界吸附到轮边界。
|
||||||
|
3. **轮边界锚点**:单元边界 `unit.end` 处,若下一时刻消息(未淘汰首条)role=User,则该 end 是轮起点——整问整答同生同死,淘汰摘要按轮覆盖语义完整。
|
||||||
|
4. 若扫到保护区(`protect_start`)仍无同时满足两条件的边界(病态:单轮 token 均值 > 块大小),回退现行为:取满足硬预算 `available` 的最后单元边界(任何边界,保证不破安全底线)。
|
||||||
|
|
||||||
|
两次裁剪之间:`trim_end` 不动 → 被淘汰集不变 → 淘汰摘要内容不变(`extract_top_keywords` 确定性)→ 视图前缀逐字节稳定 → BP2 每轮命中、openai_compat 自动前缀缓存每轮命中。
|
||||||
|
|
||||||
|
### 4.2 块大小取值推理
|
||||||
|
|
||||||
|
块大小不设独立常量,由水位差导出:`块 = (1−T) × (budget − sys_tokens)`,随模型窗口自适应缩放(与 output_reserve/safety_ratio 的关系:二者先折算进 budget,块是其再切片,无双重调参)。
|
||||||
|
|
||||||
|
默认参数下 `1−T=0.2` → 块 ≈ 18–19K tokens。设单轮增长均值 Δ(本应用工具密集,2–10K 常态):
|
||||||
|
|
||||||
|
| T | 块 | 稳定窗口 ⌊块/Δ⌋ | 命中率≈窗口/(窗口+1) | 单次裁剪信息损失 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 0.9 | ≈9K | 1–4 轮 | 50–80% | 最小 |
|
||||||
|
| **0.8** | ≈19K | **2–9 轮** | **67–90%** | 中 |
|
||||||
|
| 0.7 | ≈29K | 3–14 轮 | 75–93% | 大 |
|
||||||
|
|
||||||
|
T=0.8 是拐点:再往上窗口塌缩(裁剪频度回升、每次裁剪的「全量重建税」频度变高),再往下单次丢历史变狠。对照今天:超预算后命中率 ≈0%(首条每轮变)。**估算误差 ±15%**(`context_helpers.rs:26` 自述)被 20% 水位差吸收,不会刚裁完就再触。
|
||||||
|
|
||||||
|
### 4.3 块边界锚点选型对比
|
||||||
|
|
||||||
|
| 候选 | 评价 |
|
||||||
|
|---|---|
|
||||||
|
| **轮边界(推荐)** | 语义完整(问+全工具链+答同生死);淘汰摘要按轮覆盖;边界是稳定地标,只在裁剪事件移动;三元组原子性由 `build_eviction_units` 已保证,轮吸附只是再加一层 |
|
||||||
|
| 消息数 | 块 token 分布不可控(30 条可 5K 也可 80K),裁剪频度与稳定窗口忽大忽小;仅配作病态回退 |
|
||||||
|
| 纯 token 水位 | 作为**停止条件**必要(§4.1 第 2 步),但单独作边界会劈轮;与轮吸附组合使用 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、方案二:分批压缩
|
||||||
|
|
||||||
|
### 5.1 双水位滞后 + 批次
|
||||||
|
|
||||||
|
- **触发 C_high = 0.75×available**(现 0.6):提高压缩间隔——压缩事件=全量缓存重建税,频度直接决定命中率;0.75 仍留 0.25A 给后台 LLM 压缩完成并下轮生效的提前量(后台化设计 `context_lifecycle.rs:30-31` 的既有前提)。
|
||||||
|
- **批次目标 C_low = 0.55×available**:每批压最旧一段,压到 `history − C_low×A` 的 token 量为止,即**每批 ≈ 0.2A ≈ 19K tokens**(与裁剪块同刻度,共用推理)。
|
||||||
|
- 批边界计算:从最旧起累计淘汰单元至批次 token 量,**轮边界吸附**(同§4.1 第 3 步);`batch_end ≤ len − PROTECT_COUNT`(保护区语义不变)。传入 `compress_old_messages(batch_end)`——其内部单元回退(`mod.rs:676-683`)与占位未闭合跳过(`compress.rs:65-66`)零改动复用。
|
||||||
|
- **滞后再武装**:压后 history=C_low < C_high,需再增长 0.2A 才触发下一批 → 天然防连发;`is_compressing` 重入标志、`head_id` 版本哨兵(`context_lifecycle.rs:70-77`)、L0-3 后台化全部不动。
|
||||||
|
- 关键词兜底路径(`context_lifecycle.rs:437-449`)同步改传 batch_end:失败降级的损失也从「一次压尽」收敛为「压一批」。
|
||||||
|
|
||||||
|
### 5.2 缓存语义:为什么分批仍然值得
|
||||||
|
|
||||||
|
压缩从视图头部移除消息,新前缀=[摘要锚点]+[剩余],**任何一次压缩都是历史段全量重建,分批不减小单次破坏面**。分批的收益在别处:
|
||||||
|
1. 单次信息损失有界(0.2A≈19K),摘要质量随输入变小而升,用户「丢历史」体感摊薄;
|
||||||
|
2. 摘要锚点改为**追加链**(见§六 BP3):第 k+1 次压缩把新摘要接在摘要链尾,链之前的 [system+链头] 前缀仍命中——一次压尽则每次都从零重建;
|
||||||
|
3. 两次压缩间隔拉长(0.75 触发+0.2A 滞后 vs 现 0.6 触发即压尽再爬坡),事件频度下降。
|
||||||
|
|
||||||
|
### 5.3 与块状裁剪的先后序(分工)
|
||||||
|
|
||||||
|
```
|
||||||
|
常态: 0.55A ←压缩批次— 0.75A 触发 … 增长 … 1.0A 裁剪(不该到)
|
||||||
|
失灵: 压缩连续失败(关键词兜底也失败/后台挂)→ 历史爬到 1.0A
|
||||||
|
→ 块状裁剪应急跳到 0.8A(丢真历史,无摘要)→ 给压缩再争取一批窗口
|
||||||
|
```
|
||||||
|
|
||||||
|
裁剪是唯一硬安全门,保持 100% 触发不动;压缩是语义保全的常态路径。二者水位关系(T=0.8 > C_high=0.75)保证:只要压缩机制活着,裁剪永不出场。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、方案三:缓存断点布点
|
||||||
|
|
||||||
|
anthropic 预算 4 个 cache_control(`anthropic_compat.rs:349`),现用 2,建议用到 3:
|
||||||
|
|
||||||
|
| 断点 | 位置 | 什么变化会打断它 |
|
||||||
|
|---|---|---|
|
||||||
|
| **BP1 顶层 system**(现有) | system blocks 尾块(`anthropic_compat.rs:137-141`) | ① custom_prompt 变更(稳定段含自定义段,`prompt.rs:727`);② **任何 System 角色锚点事件**(压缩摘要/关键词兜底/话题标记/催办——折叠进顶层 system,见§二末行);③ 提示词文本随代码版本变更 |
|
||||||
|
| **BP2 消息级**(现有) | 末条 user 前相邻一条(`:350-357`);有淘汰摘要时落摘要上(摘要两裁剪间静态,反而更稳) | ① 裁剪边界移动(块状化前每轮/块状化后仅裁剪事件);② 压缩事件;③ namespace LRU 淘汰的 notice 替换改写历史中段(`agentic/mod.rs:1965-1982`,低频);④ 新近度豁免滚动——末 2 条 tool_result 豁免过期转压缩时一次性改写(`stall_detector.rs:321-330`,每次新结果进来推移一次);⑤ sanitize 自愈(确定性,输入不变输出不变,不构成威胁) |
|
||||||
|
| **BP3 摘要链尾**(新增,可选) | 摘要锚点链之后第一条消息 | 仅压缩事件失效;为 [system+摘要链] 小前缀保命中,配合§5.2 追加链语义 |
|
||||||
|
|
||||||
|
易变尾段(日期/清单/augmentation/知识)在 BP2 之后,不打断任何断点(既有设计正确);其每次 send 重建的变化被 BP2 挡住,只自伤尾段自身(本来也不可缓存)。
|
||||||
|
|
||||||
|
### 6.1 必修项:system 锚点的顶层折叠(打断 BP1 的根)
|
||||||
|
|
||||||
|
**发现**:`convert_request` 把存储态全部 role=System 消息抽离折叠进顶层 system(`anthropic_compat.rs:128-133`),消息流中 `System => continue` 剔除(`:153`)。因此每次压缩摘要/话题标记/催办事件:顶层 system 内容变(BP1 失配)+ 消息数组少一条(BP2 位置迁移)→ 双断点同时重置。
|
||||||
|
|
||||||
|
**修法(view 层角色转换,存储不动)**:`build_for_request` 出口(sanitize 之后)把**下标>0 的 role=System 消息**映射为 role=User 发送。收益:
|
||||||
|
- BP1 内容回到纯 system_prompt 稳定段,锚点事件不再打断顶层缓存;
|
||||||
|
- 存储态仍是 System:`has_compressible_messages` 的 System 排除(`mod.rs:656`)、前端展示、压缩摘要定位语义全部不变——纯视图变更,与淘汰摘要先例(`mod.rs:254-256` role 必须 user)同构收口;
|
||||||
|
- openai_compat 侧同视图生效(system 留中原位,user 也合法,中性)。
|
||||||
|
|
||||||
|
细节:转换须在 sanitize step 之后(user 是最普通角色,不触发三元组/首条规则);首条 system_prompt(`agentic/mod.rs:1983`,组装层加入,不在 storage)不转换。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、风险与回退
|
||||||
|
|
||||||
|
### 7.1 行为变更逐条
|
||||||
|
|
||||||
|
| # | 变更 | 风险 | 缓解 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | 块状裁剪:单次多丢(裁到 T 水位而非刚好塞下) | 裁剪时刻上下文损失变大(有界:≤0.2A) | 换取两裁剪间全量命中;T 可调(拍板点1);病态回退保硬预算 |
|
||||||
|
| 2 | 分批压缩:C_high 0.6→0.75、批≈0.2A | 后台压缩生效前留给裁剪的余量 0.4A→0.25A;压缩事件频度上升(批小了) | is_compressing/版本哨兵/单元回退全复用;批失败下轮重试(滞后再武装);拍板点2 可保守维持 0.6 |
|
||||||
|
| 3 | system 锚点 view 层转 user | LLM 把摘要当作用户话语而非系统指令的语义差;多 provider 兼容面 | 纯 view-only 可瞬时回退;淘汰摘要已用同形态跑了数月无异常(`mod.rs:259-261`);拍板点3 |
|
||||||
|
| 4 | BP3 新增 | 网关对 cache_control 数量/位置的兼容 | 默认关,随开关;预算 4 内 |
|
||||||
|
|
||||||
|
### 7.2 开关设计(照 `df-ai-anthropic-cache` 范式)
|
||||||
|
|
||||||
|
- 新 settings KV **`df-ai-cache-friendly-context`**,`parse_cache_flag` 同语义(空/true/1=开;false/0/杂值=关),前端设置页一枚开关;
|
||||||
|
- 总开关 gate 三机制:块状裁剪 + 分批压缩 + system 锚点 view 转换;BP3 挂在同一开关(anthropic 缓存总开关 `df-ai-anthropic-cache` 仍独立管 cache_control 形态,两层正交);
|
||||||
|
- 开关关 = 三处全部旁路,行为与今日逐字节等价(与 `cache_split_provider_type` provider gate 叠加,非缓存受益 provider 不受 view 转换影响——不,view 转换与 provider 无关,应 gate 在新开关下全员生效或全员旁路,不由 provider 类型分叉);
|
||||||
|
- 裁剪/转换是 view-only(逐请求),回滚瞬时;压缩是存储 status 单向(已 compressed 不回滚,与今天回滚语义一致,可接受并在开关文档注明)。
|
||||||
|
|
||||||
|
### 7.3 回退路径
|
||||||
|
|
||||||
|
开关关 → 全量回旧行为。已落库的 compressed 状态保留(软删可追溯,历史数据默认保留);水位常量均在常量层(T/C_high/C_low),极端情况改常量即再调参,无需动逻辑。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、分期
|
||||||
|
|
||||||
|
### 快赢(下一小批可入,改动小、收益确定)
|
||||||
|
|
||||||
|
| 项 | 内容 | 落点 | 量级 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| W1 | **块状裁剪**(T 水位+轮吸附+病态回退) | `context/mod.rs:211-219` 替换 + 单测(水位/轮边界/病态三case) | S |
|
||||||
|
| W2 | **system 锚点 view 层转 user** | `build_for_request` 出口 + 单测(首条不转/转换后 sanitize 不破) | S |
|
||||||
|
| W3 | **命中率观测日志**:`cache_read/cache_creation` 已在 usage(`anthropic_compat.rs:728-730`),每轮 info 输出命中率 | stream 收尾处 | XS |
|
||||||
|
| W4 | BP3 摘要链尾断点(随 anthropic 开关) | `apply_message_cache_breakpoint` 加一档 | XS |
|
||||||
|
|
||||||
|
W3 最先:先有数据,后面所有调参有据。**协调注**:W1/W2 落在 context/mod.rs(并行代理在改),实施前 rebase;若分层组装器 P2.2(组装器结构化)先行合入,块状裁剪改为在组装器出口实施,算法不变。
|
||||||
|
|
||||||
|
### 结构性(需更多验证)
|
||||||
|
|
||||||
|
| 项 | 内容 | 依赖 |
|
||||||
|
|---|---|---|
|
||||||
|
| S1 | **分批压缩**(C_high/C_low 双水位+批边界+兜底同批)+ PROTECT_COUNT 双源统一(`context_helpers.rs:683` vs `context_lifecycle.rs:53`) | W3 数据验证压缩事件频度;狗粮长会话 |
|
||||||
|
| S2 | 水位再校准:T/C_high 按 W3 实测命中率-损失曲线微调 | S1 上线后一轮狗粮 |
|
||||||
|
| S3 | namespace LRU 淘汰 notice 的前缀断治理(notice 文案确定化已天然成立,残余为位置断,收益小后置) | 低优先 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、拍板点(4 项)
|
||||||
|
|
||||||
|
1. **裁剪目标水位 T**:推荐 **0.8**(块≈19K、稳定窗口 2–9 轮、命中 67–90%);备选 0.85(保守,少丢但裁剪频)——§4.2 表。
|
||||||
|
2. **压缩双水位**:推荐 **C_high=0.75 / C_low=0.55**(批≈0.2A,滞后 0.2A);备选维持现状 0.6 单水位(仅做分批不做提频调整)——§5.1。
|
||||||
|
3. **system 锚点 view 层转 user**:推荐**做**(治 BP1 折叠根因);备选不做(则压缩/话题标记事件继续双断点全失配,BP1 收益打折)——§6.1。
|
||||||
|
4. **总开关默认值**:推荐**默认开**(对齐 `df-ai-anthropic-cache` 默认开先例,看板 939⑧;view-only 为主可瞬时回退);备选默认关观察一轮——§7.2。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附:证据坐标索引
|
||||||
|
|
||||||
|
| 论断 | 坐标 |
|
||||||
|
|---|---|
|
||||||
|
| 贪心最小裁剪逐轮爬行 | `crates/df-ai/src/context/mod.rs:211-219`(触发 `:198,214`,切片 `:239-246`) |
|
||||||
|
| 淘汰摘要尾追+缓存注释 | `mod.rs:250-261`;确定性提炼 `context_helpers.rs:284-288,332-364` |
|
||||||
|
| 一次压尽 | `agentic/context_lifecycle.rs:129-136`(0.6 触发)、`:308,442`(compress_old_messages(protect_start))、`mod.rs:670-699` |
|
||||||
|
| is_active 白名单过滤 | `context/sanitize.rs:40-43`;`df-ai-core/provider.rs:91-99` |
|
||||||
|
| system 全量折叠(打断 BP1) | `anthropic_compat.rs:128-133,153`;锚点 system 入库 `context_lifecycle.rs:313,449`、`agentic/mod.rs:1869,2510,2557` |
|
||||||
|
| 消息级断点(末条 user 前) | `anthropic_compat.rs:243-247,339-376`;4 个预算 `:349`;usage 映射 `:723-731` |
|
||||||
|
| 开关范式 | `commands/ai/prompt.rs:657-695`;易变尾段拆分 `:709-736`、`stream_relay.rs:791-795`、每 send 构建一次 `commands/chat.rs:417,996,2209,2476` |
|
||||||
|
| tool_result 摘要已稳定(不威胁) | 冻结 `agentic/mod.rs:2004-2005`、`stall_detector.rs:352-363`;记忆化 `:368-379`;新近度豁免滚动 `:321-330` |
|
||||||
|
| 预算公式与保护区 | `context_helpers.rs:154-178,683,692`;双源 PROTECT_COUNT `context_lifecycle.rs:53` |
|
||||||
|
| 请求组装序 | `agentic/mod.rs:1143`(sys_tokens)、`:1957`(build_for_request)、`:1965-1982`(namespace 展开)、`:1983-1990`(system+history+尾段) |
|
||||||
|
| 上位目标与验收口径 | `docs/02-架构设计/专项设计/上下文分层组装器-落地方案-2026-10-10.md`(病灶④/「裁剪以批量滞后发生」) |
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
# 定时消息与待发箱持久化 — 设计
|
||||||
|
|
||||||
|
> 日期: 2026-10-10 | 状态: 设计稿(待拍板) | 勘察: 只读实测锚点
|
||||||
|
> 覆盖需求: todo **935**(定时消息多条 + 统一「定时列表」)与 todo **931**(发送队列网络超时/重启不丢)。
|
||||||
|
> 关联: todo **937**(对话内定时/延时任务)共用本设计调度器,仅预留扩展点,不在本设计实施。
|
||||||
|
> 目标(用户原话转译): 消息可设为定时发送、可设多条,有一个统一「定时列表」可查看/改期/取消;发队列遇网络超时、杀进程重启都不能丢。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、现状勘察(锚点实测)
|
||||||
|
|
||||||
|
### 1.1 发送队列(931 病灶)
|
||||||
|
|
||||||
|
| 件 | 锚点 | 现状 |
|
||||||
|
|----|------|------|
|
||||||
|
| 队列数据结构 | `src/stores/ai.ts:104`(类型)、`:136`(`queue: []` 初始化) | **纯内存模块级数组**,字段 `{ text, skill?, enqueuedAt, parts?, spans?, conversationId? }`,无任何持久化 |
|
||||||
|
| 入队 | `src/composables/ai/useAiSend.ts:392-404` | `sendMessage` 查 `ai_is_generating` 得 `backendGenerating` → 忙则 `state.queue.push(...)`;上限 `QUEUE_LIMIT=10`(`:53`),超时 `QUEUE_TIMEOUT_MS=30_000`(`:56`) |
|
||||||
|
| 续发派发 | `useAiSend.ts:308-353` `drainQueue`;监听器 `:607-612` `initDrainQueueListener` | **前端自驱**:`AiCompleted` → `useAiEvents` 发 `ai-drain-queue` → `drainQueue` 取出队首 `sendMessage` |
|
||||||
|
| 显式清队 | `useAiSend.ts:193-196`(regenerate)、`:269-272`(editMessage)、`:488-495` `clearQueue` | 重生成/编辑属用户显式动作,清本会话队列合理;`notifyQueueCleared`(`aiShared.ts:772`)toast 提示 |
|
||||||
|
| 队列 UI | `src/components/AiChat.vue:78-113`;`activeQueue` 计算 `:489` | 按 `activeConversationId` 过滤渲染当前会话队列,含 强制发送/清空/编辑/回填/立即发送/取消 |
|
||||||
|
|
||||||
|
**病灶(与 todo 931 勘察一致)**:①队列内存态,杀进程即丢;②后端 busy 判定 + 后续 `AiCompleted` 续发全依赖前端活着并收到事件——网络超时/事件竞态丢失则队列永久卡住或静默丢;③`state.queue` 无启动恢复入口。修法须从「内存 + 前端自驱」下沉到「持久化 + 后端自驱」。
|
||||||
|
|
||||||
|
### 1.2 后端发送受理链(定时复用的正典入口)
|
||||||
|
|
||||||
|
| 件 | 锚点 | 语义 |
|
||||||
|
|----|------|------|
|
||||||
|
| `ai_chat_send` IPC | `src-tauri/src/commands/ai/commands/chat.rs:636` | 拦截 `/status` → 解析前导技能 → 委派 `spawn_agentic_session` |
|
||||||
|
| `spawn_agentic_session` | `chat.rs:796`(`pub(crate)`) | **发送正典核心**:解析 provider → 目标 conv 入参优先/缺省 active/都无则懒建 → `can_accept_request` 门禁(仅 Idle\|Error 可接,拒 Generating/Stopping/Compressed)→ 写 stop_flag/language/iteration/model_override → spawn `run_agentic_loop`。`pin_active` 决定是否劫持用户活跃面板 |
|
||||||
|
| generating 判定 | `chat.rs:471` `ai_is_generating` | 读无锁 `state.conv_states.is_active(conv)` |
|
||||||
|
| 完成出口(单点) | `src-tauri/src/commands/ai/agentic/mod.rs:2882` `emit_ai_completed_once` → 调 `unattended::notify_batch_done(conv_id)`(`:2917`) | **全部正常收尾汇聚于此单点**,是「会话转空闲」的唯一可靠信号 |
|
||||||
|
|
||||||
|
结论:`spawn_agentic_session` 是后端可无前端直接调用的发送入口(无人值守引擎已这么用),定时到点可直接走它,无需前端在场。
|
||||||
|
|
||||||
|
### 1.3 后端常驻调度器先例(可直接抄形态)
|
||||||
|
|
||||||
|
`src-tauri/src/commands/ai/unattended.rs`:
|
||||||
|
- `start(app)`(`:505` 起)用 `tauri::async_runtime::spawn` + `tokio::time::interval` + `tokio::select!{ interval.tick(), notify.notified() }` 起常驻循环;周期/开关/范围全部**从 `Settings KV` 每轮热读**(`df-unattended-poll-secs` 等)。
|
||||||
|
- 派发走 `spawn_agentic_session(..., Some(new_id()), ..., false)`(`:847-857`):**独立后台会话 + `pin_active=false`**,不劫持用户活跃面板。
|
||||||
|
- `notify_batch_done`(`:483`)在完成出口清在途标志并 `notify_one` 唤醒轮询器。
|
||||||
|
- 单会话单在途双保险:内部 `in_flight` 标志 + `can_accept_request` 兜底清残留(异常退出漏钩子时不永久卡死)。
|
||||||
|
- 挂载点 `src-tauri/src/lib.rs:84` `commands::ai::unattended::start(app.handle().clone())`。
|
||||||
|
- 通知出口:托盘 `notify_session_event`(`commands/ai/notify.rs:47`,`NotifyKind::Completed/Failed/NeedAction`);飞书 `feishu_notify::push_card/push_monitor_alert`,目标 KV `df-monitor-chat-id`(`unattended.rs:52`)。
|
||||||
|
|
||||||
|
### 1.4 生命周期(「应用关闭后是否生效」的事实)
|
||||||
|
|
||||||
|
- `lib.rs:51` `.setup()`:开库+迁移(`AppState::init`)→ `app.manage` → 各 `spawn`(feishu、`unattended::start`、mcp、proxy gateway、tray)。
|
||||||
|
- `lib.rs:614-658` `on_window_event`:`CloseRequested` → **`api.prevent_close()` + `window.hide()` 托盘化,进程不退出**;真正退出只走托盘菜单「退出」(或系统关机)。
|
||||||
|
- 无 `RunEvent::Exit` 级关闭钩子(默认 `.run()`)。
|
||||||
|
|
||||||
|
**关键事实**:窗口关闭 → 托盘常驻 → **进程仍在,后端 tokio 调度器照常跑**;只有进程真退出(托盘退出/关机)调度器才停。故「应用关闭后是否生效」的精确答案是:**关窗(托盘化)期间生效;进程退出期间不生效,重启后按追赶策略补发**——这是必须写进验收口径的边界。
|
||||||
|
|
||||||
|
### 1.5 存储与迁移底座
|
||||||
|
|
||||||
|
- 迁移版本链 `crates/df-storage/src/migrations.rs`,当前 **V50**;`migrations_helpers::{column_exists, table_exists}` 提供幂等探测。
|
||||||
|
- Repo 两种范式:`impl_repo!` 宏(`crates/df-storage/src/crud/mod.rs`,生成 insert/get_by_id/list_all/update_full,适合固定 CRUD);`SettingsRepo`(`crud/settings.rs`)手写 KV(`app_settings` 表,key/value JSON),适合标量偏好。
|
||||||
|
- 会话记录 `AiConversationRecord`(`crates/df-storage/src/models.rs:372`)已有 `loop_state`(V49)/`status`(V50)等生命周期列先例。
|
||||||
|
- 前端会话 CRUD:`ai_conversation_create`(`src/api/ai.ts:318`)、`ai_conversation_list`(`:323`)——定时目标会话可在 composer 处先建。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、目标与验收口径
|
||||||
|
|
||||||
|
**功能目标**
|
||||||
|
1. Composer 旁「定时」入口:把当前输入(文本/技能/图片)设定为**某绝对时间**发送;可连续设多条。
|
||||||
|
2. 统一「定时列表」:跨会话查看全部定时项,支持 查看/改期/取消/编辑文本;到点自动发送。
|
||||||
|
3. 待发队列**持久化**:入队/出队/取消即落库;网络超时不清队列(仅标记该条可重试);杀进程重启后队列仍在且可续发。
|
||||||
|
4. 定时到点时会话忙 → 顺延到会话空闲再发(不丢、不打断当前生成)。
|
||||||
|
5. 失败可见:连续失败到上限 → 标失败 + 托盘通知(可选飞书),用户可在列表重试/取消。
|
||||||
|
|
||||||
|
**验收口径**
|
||||||
|
- 断网/超时:构造后端生成中入队 → 断网触发错误 → 队列项仍在(标记可重试),恢复网络后续发。
|
||||||
|
- 重启:入队 N 条 → 杀进程 → 重启 → 队列恢复 N 条且自动续发;`sending` 残留被复位为 `pending`。
|
||||||
|
- 定时:设 1 分钟后发送 → 关窗托盘化 → 到点仍在后台发出(session 落库可见);设 1 分钟后发送 → 立刻进程退出 → 重启 → 按追赶策略补发或标记过期。
|
||||||
|
- 多会话:定时项按目标会话独立排队,不误发到当前活跃会话;到点会话忙则顺延。
|
||||||
|
- 不倒退:无定时/无队列时,普通发送链路(L0/L1/L2)行为与改造前逐字节一致。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、设计方案
|
||||||
|
|
||||||
|
### 3.0 核心判断:统一为后端「待发箱(outbox)」
|
||||||
|
|
||||||
|
931 队列与 935 定时消息本质是**同一实体、不同触发条件**:队列 = 「会话空闲即发」,定时 = 「到点且空闲即发」。因此不建两套调度,统一落一张表 + 一个后端调度器:
|
||||||
|
|
||||||
|
- **触发条件统一为 `due_at`**:队列项 `due_at = NULL`(尽快);定时项 `due_at = epoch_ms`。调度器每次取「`due_at` 已到 且 目标会话空闲」的项派发。
|
||||||
|
- **单一真相源**:表是真相,前端 `state.queue` 退化为该会话 outbox 的**只读视图**,前后端各一套派发逻辑的「草率聚合」不再存在。
|
||||||
|
- **调度落点 = 后端 tokio 常驻**(抄 unattended 形态),复用 `spawn_agentic_session` 正典链 + `emit_ai_completed_once` 空闲钩子。
|
||||||
|
|
||||||
|
### 3.1 数据结构(新表 `ai_outbox`,迁移 V51)
|
||||||
|
|
||||||
|
选**独立表**而非 KV:`app_settings` 是标量偏好,读改写整块 JSON 无索引、并发改期/取消会互相覆盖;outbox 需按 `status+due_at` 索引扫描、按 id 逐条 CAS 更新,表是对的解。
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS ai_outbox (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
conversation_id TEXT NOT NULL, -- 目标会话(composer 处确定;无则前端先建)
|
||||||
|
kind TEXT NOT NULL DEFAULT 'queue', -- 'queue'(尽快) | 'scheduled'(定时);预留 'task'(937)
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
skill TEXT, -- /技能名
|
||||||
|
model_override TEXT, -- 用户指定模型(null=路由)
|
||||||
|
parts_json TEXT, -- ContentPart[] JSON(图片;空/NULL=纯文本)
|
||||||
|
spans_json TEXT, -- MentionSpan[] JSON
|
||||||
|
due_at INTEGER, -- epoch ms;NULL=尽快(queue)
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending', -- pending|sending|sent|failed|canceled
|
||||||
|
attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_error TEXT,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
sent_at INTEGER
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ai_outbox_due ON ai_outbox(status, due_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ai_outbox_conv ON ai_outbox(conversation_id, status);
|
||||||
|
```
|
||||||
|
|
||||||
|
迁移:V51 = 建表 + 两索引(`table_exists` 幂等守卫)。**可逆**:DOWN 等价 = `DROP TABLE ai_outbox`(纯新增表,不影响旧数据;回退即弃表 + 关调度器开关)。**务必按项逐条落状态标记,禁依赖全局内存推算**(V21 教训:`docs/todo.md:860` 全局 COUNT 幂等窗口中途崩溃致永久残缺)。
|
||||||
|
|
||||||
|
Rust 记录(加 `crates/df-storage/src/models.rs`):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct AiOutboxRecord {
|
||||||
|
pub id: String,
|
||||||
|
pub conversation_id: String,
|
||||||
|
pub kind: String,
|
||||||
|
pub text: String,
|
||||||
|
pub skill: Option<String>,
|
||||||
|
pub model_override: Option<String>,
|
||||||
|
pub parts_json: Option<String>,
|
||||||
|
pub spans_json: Option<String>,
|
||||||
|
pub due_at: Option<i64>,
|
||||||
|
pub status: String,
|
||||||
|
pub attempts: i64,
|
||||||
|
pub last_error: Option<String>,
|
||||||
|
pub created_at: i64,
|
||||||
|
pub updated_at: i64,
|
||||||
|
pub sent_at: Option<i64>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 存储层 Repo(手写,不走 `impl_repo!`)
|
||||||
|
|
||||||
|
`crates/df-storage/src/crud/outbox_repo.rs`(新)。因需状态 CAS 与条件更新,手写(同 `SettingsRepo` 范式):
|
||||||
|
|
||||||
|
- `insert(&record) -> id`
|
||||||
|
- `list_by_conv(conv_id, include_done: bool) -> Vec<AiOutboxRecord>`
|
||||||
|
- `list_due(now_ms, limit) -> Vec<AiOutboxRecord>`(status='pending' AND (due_at IS NULL OR due_at<=now) ORDER BY created_at)
|
||||||
|
- `claim(id) -> bool`:CAS `UPDATE ... SET status='sending', updated_at=? WHERE id=? AND status='pending'`(changes==1 才 true,防并发双取)
|
||||||
|
- `mark_sent(id)` / `mark_failed(id, err)` / `revert_pending(id, attempts, err)`
|
||||||
|
- `reschedule(id, due_at)` / `cancel(id)` / `update_text(id, text)` / `clear(conv_id?)`
|
||||||
|
- `reset_stale_sending()`:启动把残留 `sending` 复位为 `pending`(崩溃半途,镜像 unattended 的 `in_flight` 泄漏处置)
|
||||||
|
|
||||||
|
AppState 增 `pub outbox: OutboxRepo`(`state.rs`,`AppState::init` 内 `OutboxRepo::new(&db)`),与 `settings`/`conv_states` 并列。
|
||||||
|
|
||||||
|
### 3.3 后端调度器 + IPC(新文件 `src-tauri/src/commands/ai/outbox.rs`)
|
||||||
|
|
||||||
|
**挂载**:`lib.rs` setup 增 `commands::ai::outbox::start(app.handle().clone())`(与 `unattended::start` 并列);启动即先 `outbox.reset_stale_sending()`。
|
||||||
|
|
||||||
|
**循环**(抄 unattended 形态):
|
||||||
|
|
||||||
|
```
|
||||||
|
start(app): spawn loop {
|
||||||
|
select!{ interval.tick(), notify.notified() } // 周期兜底 + 事件即时唤醒
|
||||||
|
if !enabled(KV) continue
|
||||||
|
for item in list_due(now, 32) { // 按 created_at 排序
|
||||||
|
if dispatched_this_tick(conv) continue // 同会话同轮只派一条(串行保序)
|
||||||
|
if !conv_states.can_accept_request(conv) continue // 忙 → 本轮跳过,下次再取(不丢)
|
||||||
|
if !claim(item.id) continue // CAS 失败(他轮已取)
|
||||||
|
match spawn_agentic_session(app, state, item.text, None, item.skill,
|
||||||
|
item.model_override, Some(conv), parts, spans, false) {
|
||||||
|
Ok(_) => { mark_sent(item.id); emit("ai-outbox-sent", {id, conv}); mark_dispatched(conv) }
|
||||||
|
Err(e) => { // 仅 spawn 前置失败(provider未配/会话忙竞态)
|
||||||
|
if permanent(e) { mark_failed(item.id, e); notify(...) }
|
||||||
|
else { revert_pending(item.id); emit("ai-outbox-changed"); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键语义**
|
||||||
|
- `pin_active=false`:定时/队列的自动发送不劫持用户活跃面板(同无人值守);用户切到该会话仍能看到实时流(AiTextDelta 已按 conv 路由)。
|
||||||
|
- **「完成即唤醒」钩子**:`agentic/mod.rs:2917` 现有 `unattended::notify_batch_done(conv_id)` 旁并列加 `outbox::notify_conv_idle(conv_id)`——会话一空闲就唤醒调度器续发,替代前端 `ai-drain-queue` 自驱。
|
||||||
|
- **失败面界定**:`spawn_agentic_session` 返回 `Ok` 即「受理成功」,outbox 立即标 `sent`;LLM 轮次内的失败(流断/工具错)由既有 loop 自愈+错误气泡+看门狗处置,**不回流 outbox**(避免与既有韧性机制重叠)。outbox 的 `failed` 只覆盖**受理失败**(provider 未配、永久 4xx 特征串如 404/额度耗尽——复用 `classifySendError` 同源判据的 Rust 版)。
|
||||||
|
- 重试:`revert_pending` 保留下轮再试(忙场景天然重试);`attempts` 达 `df-outbox-max-attempts`(默认 3)记 `failed` + 通知。
|
||||||
|
- **过期追赶**:调度器只认「`due_at <= now`」,故进程退出期间到期的项在重启后立即命中(补发)。是否对「迟到过久」的项改标过期见拍板点 4。
|
||||||
|
|
||||||
|
**IPC 面**(注册进 `lib.rs` `invoke_handler`):
|
||||||
|
- `ai_outbox_enqueue(conversation_id, text, skill, model_override, parts, spans, due_at?) -> id`(`due_at` 空=队列项)
|
||||||
|
- `ai_outbox_list(conversation_id?, include_done?) -> Vec<AiOutboxItem>`
|
||||||
|
- `ai_outbox_reschedule(id, due_at)` / `ai_outbox_cancel(id)` / `ai_outbox_update_text(id, text)`
|
||||||
|
- `ai_outbox_send_now(id)`(插队=force;后端 CAS claim 后立即派发)
|
||||||
|
- `ai_outbox_clear(conversation_id?)`
|
||||||
|
|
||||||
|
**事件面**:`ai-outbox-changed`(任何变更后前端刷新视图)、`ai-outbox-sent { id, conversation_id }`、`ai-outbox-failed { id, conversation_id, error, permanent }`。
|
||||||
|
|
||||||
|
**通知出口**:失败经 `notify_session_event(app, NotifyKind::Failed/NeedAction, conv, ...)` 托盘补位;需要飞书时复用 `feishu_notify::push_card` + KV `df-monitor-chat-id`(与 unattended 同款,零新通道)。
|
||||||
|
|
||||||
|
**KV 配置**(`app_settings`,热读):`df-outbox-enabled`(默认 true,回退开关)、`df-outbox-poll-secs`(默认 10,下限钳 5)、`df-outbox-max-attempts`(默认 3)、`df-outbox-notify-feishu`(默认 false)。调度器另用 `Notify` 由 enqueue/完成钩子即时唤醒,周期 tick 仅兜底 `due_at` 到点。
|
||||||
|
|
||||||
|
### 3.4 前端改造(队列视图化 + 定时入口)
|
||||||
|
|
||||||
|
**`src/composables/ai/useAiSend.ts`**
|
||||||
|
- `sendMessage` L1 分支(`:392-404`):`state.queue.push(...)` → `await aiApi.outboxEnqueue(...)`,随后刷新本会话视图。
|
||||||
|
- 删前端派发:`drainQueue`(`:308`)、`initDrainQueueListener`(`:607`)、`ai-drain-queue` 监听改由 `ai-outbox-changed`/`ai-outbox-sent` 驱动的**视图刷新**;`useAiEvents` 的 `AiCompleted` 不再发 `ai-drain-queue`。
|
||||||
|
- 保留 `cancelQueued/clearQueue/editQueued/sendQueuedNow/tryForceSend`,后端代理为对应 IPC。
|
||||||
|
- regenerate/editMessage 的「清本会话队列」改为 `ai_outbox_clear(convId)`(语义不变:用户显式动作允许清)。
|
||||||
|
|
||||||
|
**新增 `src/composables/ai/useAiOutbox.ts`**:`state.queue` 保持为「本会话待发视图」数组(shape 兼容 `AiChat.vue`),由 `ai_outbox_list` 拉取 + 事件增量刷新;提供 `scheduleMessage(convId, text, ..., dueAt)`、`listAll()`、`reschedule/cancel` 等。
|
||||||
|
|
||||||
|
**`src/stores/ai.ts`**:`queue` 字段类型不变(视图契约零破裂),来源改为 composable 写入。
|
||||||
|
|
||||||
|
**Composer 入口 `src/components/ai/ChatInput.vue`**:发送按钮区(`:107-108`)旁加「定时」图标按钮 → 弹轻量时间选择(预设 5/30/60 分钟 + 自选时间)→ 调 `scheduleMessage`。空文本仅选时间时提示需输入内容。
|
||||||
|
|
||||||
|
**列表 UI `src/components/AiChat.vue`**:
|
||||||
|
- 现有内联队列条(`:78-113`)保留渲染本会话待发(含定时项可加 `🕐 时间` 角标 + 「改期」按钮)。
|
||||||
|
- 新增**「定时列表」抽屉**:跨会话展示全部 `scheduled`(+可选 queue)项,列 时间/会话/摘要/状态,操作 查看/改期/取消/编辑。可复用 TaskDrawer 抽屉范式(`temp/S4-TaskDrawer跳转勘察-2026-10-03.md` 已有跳转勘察)。
|
||||||
|
|
||||||
|
**启动恢复**:`useAiOutbox` 初始化时拉 `ai_outbox_list`;若存在恢复项,`ai-toast` 提示「已恢复 N 条待发」(参考 `notifyQueueCleared` 文案范式)。
|
||||||
|
|
||||||
|
### 3.5 UI 形态示意(文字稿)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌ ChatInput ─────────────────────────────┐
|
||||||
|
│ [图片预览行] │
|
||||||
|
│ ┌──────────────────────────┐ ⏰ ➤ │ ⏰=定时;➤=立即
|
||||||
|
│ └──────────────────────────┘ │
|
||||||
|
└────────────────────────────────────────┘
|
||||||
|
点 ⏰ → 小面板: [5分钟] [30分钟] [1小时] [自选时间 ▾] (确认后入 outbox kind=scheduled)
|
||||||
|
|
||||||
|
┌ 待发/定时列表(抽屉) ───────────────────┐
|
||||||
|
│ 定时 (2) │
|
||||||
|
│ 🕐 14:30 会话A 「跑一下构建…」 改期 ✕ │
|
||||||
|
│ 🕐 明天9:00 会话B 「/status」 改期 ✕ │
|
||||||
|
│ 待发 (1) │
|
||||||
|
│ ⏳ 会话A 「继续」 立即 ✕ │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、与 todo 931 的共用关系(同源方案)
|
||||||
|
|
||||||
|
- **同一持久层**:931 队列项与 935 定时项**同表 `ai_outbox`**,仅 `kind`/`due_at` 区分。931 不再单独造 KV/表。
|
||||||
|
- **同一调度器**:队列=「`due_at` 为空、空闲即发」,定时=「`due_at` 到时、空闲即发」,共用 `outbox.rs` 派发循环与 `spawn_agentic_session`。
|
||||||
|
- **共用修复点**:网络超时/重启不丢(§3.3 reconcile + §3.4 视图化)同时治 931;「错误不清队列」由「outbox 项仅 `sending→pending` 回退,不删」天然满足。
|
||||||
|
- 分期上允许 **P0 先落 `scheduled`(935)**,`queue`(931 迁移)随后并入同表——同表即刻共用,队列侧前端派发下线放在 P1,降低单批爆炸半径。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、分期与最小可用
|
||||||
|
|
||||||
|
### P0 — 后端待发箱 + 定时(最小可用,独立可验收)
|
||||||
|
- 新增:表 V51 + `models::AiOutboxRecord` + `crud/outbox_repo.rs`;`commands/ai/outbox.rs`(调度器+IPC+事件);`lib.rs` 挂载 `outbox::start` + `agentic/mod.rs:2917` 加 `outbox::notify_conv_idle` 钩子;`state.rs` 加 `outbox`。
|
||||||
|
- 前端:`useAiOutbox.ts` + ChatInput ⏰ 入口 + 定时列表抽屉 + `api/ai.ts` 包装 + i18n。
|
||||||
|
- 验收:§二「定时」两条 + 「多会话」+「不倒退」。此期**只服务 `kind='scheduled'`**;`queue` 仍走旧内存路径(未有回归)。
|
||||||
|
|
||||||
|
### P1 — 队列并入 outbox(治 931)
|
||||||
|
- 前端 `sendMessage` L1 改走 outbox;下线 `drainQueue`/`initDrainQueueListener`/`ai-drain-queue`;regenerate/edit/clear 接 IPC 清队。
|
||||||
|
- 验收:§二「断网/超时」「重启」两条 + 既有队列 UI 交互(编辑/回填/立即发送/取消/超时强发)零弱化。
|
||||||
|
|
||||||
|
### P2 — 进阶
|
||||||
|
- 飞书通知(`df-outbox-notify-feishu`);过期窗口策略(拍板点 4);`kind='task'` 扩展位预留供 937(定时任务/工作流经同一调度器,派发目标改 task/workflow 而非 `spawn_agentic_session`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、影响面(新能力引入评估纪律五维,`docs/todo.md:446`)
|
||||||
|
|
||||||
|
- **兼容性**:纯新增表 + 新增 IPC/事件,旧库由 V51 幂等建表向上迁移,老数据零触碰;前端 `state.queue` shape 不变(视图契约)。迁移可断点续跑(逐条状态标记,无全局 COUNT 推算)、可逆(`DROP TABLE`+关开关)。旧 `ai-drain-queue` 事件在 P1 下线时保留一个版本的空发兜底,防旧前端混跑。
|
||||||
|
- **破坏性**:不可逆操作仅「迁移 V51 建表」(可 `DROP` 回退);P1 下线上前端派发属行为变更,以开关 `df-outbox-enabled=false` 可回退旧路径(过渡期保留旧内存队列代码)。
|
||||||
|
- **安全面**:无新端点/命令执行面。outbox 项若触发工具调用,仍走既有 `audit`/`approval`/边界模型闸门,**定时不等于放行**(与无人值守 L1 同级,不自动升档)。落盘内容(文本/图片 base64)属本地库既有信任域(与 `ai_messages` 同级)。改期/取消为本地 IPC。
|
||||||
|
- **资源面**:DB 增长——`sent/canceled` 行建议保留(胜于删除,便于审计)但**须有上限**:图片 base64 项加体积上限(沿用前端 `PARTS_SIZE_BUDGET` 50MB 量级口径,超限入队即拒并提示);可加「已完成项保留 N 天」的清理点(默认可不做,登记为观测)。调度器 tick 默认 10s,配合 `Notify` 即时唤醒,常驻开销可忽略(低于 unattended 300s 轮询)。
|
||||||
|
- **回退开关**:`df-outbox-enabled`(默认 true,关=调度停=项只入不发,可观测);`df-outbox-poll-secs`/`df-outbox-max-attempts` 热读。灰度顺序:P0 scheduled 先在本机自用验证 → P1 队列迁移 → P2 通知/937。观测指标:outbox 积压条数、`sending` 残留数、失败率、`due→sent` 延迟。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、拍板点清单
|
||||||
|
|
||||||
|
| # | 决策 | 选项 | 建议 |
|
||||||
|
|---|------|------|------|
|
||||||
|
| 1 | 调度落点与「进程退出」语义 | A 后端 tokio 常驻(关窗托盘化期间生效;进程退出期间不生效,重启追赶补发);B 前端定时器(仅界面活着时生效);C 注册 OS 级计划任务(进程死也触发) | **A**。与无人值守同形态、复用 `spawn_agentic_session`;明确「进程退出=不触发,重启补发」为本设计边界,C 作远期非目标(跨平台成本高,偏移本设计) |
|
||||||
|
| 2 | 存储形态 | A 新表 `ai_outbox`;B 复用 `app_settings` KV | **A**。需按 `status+due_at` 索引扫描 + 逐条 CAS 改期/取消,KV 整块 JSON 读改写有并发覆盖风险且不可索引 |
|
||||||
|
| 3 | 队列与定时是否统一实体 | A 统一并入 outbox(`kind`/`due_at` 区分),前端队列视图化; B 队列保前端内存 + 独立后端定时器 | **A**。931 根因就是内存+前端自驱,B 会留双调度(草率聚合);A 一次治两需求 |
|
||||||
|
| 4 | 进程退出期间到期项的追赶策略 | A 全部到点即补发(迟到多久都补);B 迟到超阈值(如 >12h)标「已过期」+通知不补发;A+B 折衷 | **B(阈值建议 12h,值可调)**。补发全部易在长关机后制造批量突发发送;过期标记保证「不静默丢」(用户可见可手动重发),符合不静默丢失纪律 |
|
||||||
|
| 5 | 定时消息是否允许携带图片 | A 允许(限体积);B 定时项禁图片仅文本 | **A(B 作兜底)**。图片 base64 落库有膨胀,但用户「设好图片定时发」是合理诉求;用体积上限而非一刀切禁用 |
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
# 对话内定时/延时任务 — 设计
|
||||||
|
|
||||||
|
> 日期: 2026-10-10 | 状态: 设计稿(待拍板) | 需求锚点: `docs/todo.md:703` 条目 937 | 勘察: 只读实测(文件:行已核)
|
||||||
|
> 目标一句话: 在对话里直接说「1 小时后跑 X / 明早 9 点把任务 A 推进到审查」,系统登记一条**持久化的 `kind='task'` 待发箱项**,到点自动执行(注入发起会话续跑),任务卡显示「定时中/已触发」,并可在统一「定时列表」里查看/改期/取消。
|
||||||
|
> 边界: 仅设计,不改码。
|
||||||
|
> **与 935 的关系(要害)**: 937 **不新造调度底座**。表 / 调度器 / IPC / 事件 / KV / 「定时列表」抽屉**全部复用** [`定时消息与待发箱持久化-设计-2026-10-10.md`](./定时消息与待发箱持久化-设计-2026-10-10.md)(该设计已覆盖 todo 935 定时消息 + 931 队列,并在其 §3.1 明确预留 `kind='task'` 给 937、§五 P2 交棒)。**本设计只定义 `kind='task'` 这一分支的语义 + AI 工具面 + 触发执行 + 权限边界**。
|
||||||
|
> 关联: 并行化与异步唤醒-设计(§二 wake delay 型 / §6.3 挂机)/无人值守长任务-设计(TaskBoundary 边界)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、现状勘察(锚点实测)
|
||||||
|
|
||||||
|
### 1.1 AI 工具面(新工具的正确落点)
|
||||||
|
|
||||||
|
| 件 | 锚点 | 现状 |
|
||||||
|
|----|------|------|
|
||||||
|
| 注册宏 | `crates/df-ai/src/ai_tools_decl.rs` `declare_tool!` | 纯 `macro_rules!`,零新依赖;新工具**只加一个 `tools/<域>.rs` 文件** + 在对应 `register_*` 接一行 |
|
||||||
|
| 注册总入口 | `src-tauri/src/commands/ai/tool_registry.rs:541` `build_ai_tool_registry` | `register_data_tools`(:735) 内 `register_task_tools`(:737)/`register_workflow_tools`(:739);二者已 thin 委托 `tools::task::register`(:767)/`tools::workflow::register`(:813) |
|
||||||
|
| 任务工具 | `src-tauri/src/commands/ai/tools/task.rs` | 6 个:list_tasks(:40)/create_task(:72)/advance_task(:185,状态机推进唯一合法路径)/delete_task/get_task_count。**无任何「延后」维度** |
|
||||||
|
| 工作流工具 | `src-tauri/src/commands/ai/tools/workflow.rs:35` `run_workflow` | High 风险;handler 是防御兜底(返 Err),**真正执行经 `ai_approve` → `execute_run_workflow_for_tool`**(`src-tauri/src/commands/ai/mod.rs:1367`)→ `run_workflow_inner`(`src-tauri/src/commands/workflow.rs:127`);空 dag + target_status 时按 `template_for`(`crates/df-nodes/src/task_workflow_templates.rs:28`)自动选模板 |
|
||||||
|
| conv_id 透传先例 | `execute_run_workflow_for_tool`(`mod.rs:1367`) | AI 工具 handler 无 `State`,**发起会话 id 经调用方(task-local/参数)注入**——`schedule_task` 记录发起会话照此 |
|
||||||
|
| 基线守卫 | `tool_registry.rs` 测试 `test_build_ai_tool_registry_baseline_tool_count` | 钉死工具名集合;**新增工具须同步该断言(+1)** |
|
||||||
|
|
||||||
|
### 1.2 任务体系与推进链(被定时触发的目标能力,均已就位)
|
||||||
|
|
||||||
|
| 件 | 锚点 | 现状 |
|
||||||
|
|----|------|------|
|
||||||
|
| 状态机 | `crates/df-nodes/src/task_state_machine.rs` | 8 态合法转换纯函数;done 唯一终态 |
|
||||||
|
| 原子推进 | `crates/df-nodes/src/task_advance_node.rs:50` `advance_task_atomic` / `:335` `advance_task_with_parent` | 状态机校验 + CAS + review_rounds + 父聚合,已与 IPC/AI 工具三方同源 |
|
||||||
|
|
||||||
|
结论: 「到点后要做的事」(推进任务/跑工作流/执行指令)**能力齐备**,缺的只是**外部时间驱动源**与**工具面的延时表达**。
|
||||||
|
|
||||||
|
### 1.3 后端常驻调度器先例(unattended;935 已定 outbox 抄其形态)
|
||||||
|
|
||||||
|
| 件 | 锚点 | 现状 |
|
||||||
|
|----|------|------|
|
||||||
|
| 常驻轮询 | `src-tauri/src/commands/ai/unattended.rs:508` `start`;`lib.rs:84` 挂载 | `async_runtime::spawn` + `interval.tick()` / `notify.notified()` `select!` |
|
||||||
|
| 共享态/唤醒 | `unattended.rs:459` `EngineShared{notify: Notify, in_flight,…}`;`:483` `notify_batch_done` | 单在途 + 完成钩子即时唤醒 |
|
||||||
|
| 开工链路 | `unattended.rs:847` `spawn_agentic_session`(`chat.rs:796`,10 参,`pin_active=false`) | 后台会话派发正典入口 |
|
||||||
|
| 生命周期事实 | `lib.rs:614` `on_window_event` `prevent_close`+`hide` 托盘化 | **关窗=进程仍在=调度器照跑;进程退出=停,重启补发** |
|
||||||
|
|
||||||
|
**关键缺口(937 视角)**: 引擎作业源是看板文件,**不落库、不看时间点**;真正解决「持久化 + 到点」的是 935 设计的 `ai_outbox`(见下节),937 直接搭车。
|
||||||
|
|
||||||
|
### 1.4 存储 / 恢复 / 事件 / UI 样板(937 复用链)
|
||||||
|
|
||||||
|
| 件 | 锚点 | 现状 / 复用点 |
|
||||||
|
|----|------|--------------|
|
||||||
|
| 迁移框架 | `crates/df-storage/src/migrations.rs:9` `run`;调度数组 `:108-121`(止于 `(50, migrate_v50)` @ `:1483`) | 追加 `(N, migrate_vN)` 即挂;**935 占 V51(建 `ai_outbox`),937 用 V52 加列** |
|
||||||
|
| 加列样板 | `migrations.rs:1366` `migrate_v45` | `column_exists` 守卫 + `ALTER TABLE ADD COLUMN`,幂等可重跑;可 `DROP COLUMN` 回退 |
|
||||||
|
| 启动恢复样板 | `audit/restore.rs:26` `restore_pending_approvals` | 从表重建内存态;outbox 的 `reset_stale_sending` 同款 |
|
||||||
|
| 会话来源 | `models.rs:389` `AiConversationRecord.origin`(现仅 `"sub_agent"`) | 定时触发落后台会话时可用新值 `"schedule"` |
|
||||||
|
| 事件 | `mod.rs:134` `AiChatEvent`(`#[serde(tag="type")]`) | 935 定的 `ai-outbox-changed/sent/failed` 直接复用 |
|
||||||
|
| 卡片范式 | `src/components/ai/TaskParkedCard.vue` | 「定时任务卡」可照此(状态徽标 + 行为后更新) |
|
||||||
|
|
||||||
|
### 1.5 缺口清单(937 专属,底座缺口由 935 覆盖)
|
||||||
|
|
||||||
|
1. **工具面无延时维度**:LLM 无法表达「1 小时后」(`advance_task`/`run_workflow`/`create_task` 均即时)。
|
||||||
|
2. **无 `kind='task'` 分支**:935 的 `ai_outbox` 表/调度器只服务消息派发,`kind='task'` 的**载荷字段**(结构化指令/任务关联/边界快照)与**派发目标**(注入会话 or 直推任务)**尚未定义**。
|
||||||
|
3. **无触发执行的落点语义**:到点「谁来跑、跑哪个会话、结果给谁看」未定。
|
||||||
|
4. **无对话内定时卡片**:工具卡如何呈现「定时中/已触发」未定。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、目标与验收口径
|
||||||
|
|
||||||
|
| 目标 | 验收口径(可实测) |
|
||||||
|
|------|-------------------|
|
||||||
|
| G1 对话内创建 | LLM 调 `schedule_task(instruction, delay_secs)`,对话出现**定时任务卡**(指令摘要 + 触发时刻/倒计时 + 「定时中」徽标);`ai_outbox` 落一行 `kind='task'`, status=pending |
|
||||||
|
| G2 到点触发 | 到 `due_at` → 调度器把指令注入**发起会话**续跑(等价一条 user 消息注入),卡片变「已触发」,新 assistant 回复出现在原会话 |
|
||||||
|
| G3 持久存活 | **应用关闭跨过 due_at**:关窗托盘化期间照常触发;进程真退出则重启后按 935 追赶策略(§四)补触发或标过期;卡片状态经重启不丢 |
|
||||||
|
| G4 管理面 | 统一「定时列表」(935 抽屉,937 项并列显示,类型标「任务」)可查看/改期/取消;改期/取消即时写库刷新卡片 |
|
||||||
|
| G5 边界合规 | 创建 = Medium 风险(走既有审批);**触发执行零旁路**——写/High 风险仍在触发会话内走既有 `audit`/`path_auth`;黑名单永不放行 |
|
||||||
|
| G6 不打架 | 触发时发起会话正生成 → 轮界排队(不打断);会话已删/不可用 → 回落独立后台会话(origin=`schedule`) |
|
||||||
|
| G7 零回归 | 既有 advance_task/run_workflow/create_task 行为不变;无 task 作业时调度器空转零副作用;935 的 `queue`/`scheduled` 分支不受影响 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、设计方案
|
||||||
|
|
||||||
|
### 3.1 总体判断(先说结论)
|
||||||
|
|
||||||
|
1. **新工具 `schedule_task`,不复用 `advance_task` 加调度字段。** `advance_task` 的契约是「此刻」推进状态机(CAS + `applied.matched_target` 断言,`tools/task.rs:185`),loop 收敛判定依赖它;改成「可能延后」会污染该断言并让「任务状态」与「调度时点」耦合。调度是正交能力:指令里可以*提到*任务,但调度本身不是状态迁移。
|
||||||
|
2. **底座全复用 935 的 `ai_outbox` + `outbox.rs` 调度器,937 只加 `kind='task'` 分支。** 不新造表/不新造 loop,与需求「与 935 共用调度器」一致;亦避免「双调度器」的草率聚合。
|
||||||
|
3. **触发执行的通用形态 = 注入发起会话(把指令当一条用户消息续跑)**,由触发后的 agentic loop 统一消化(它本就会 `advance_task`/`run_workflow`);确定性「直推任务状态」作 P2 快路径(§3.6、拍板点 4)。
|
||||||
|
|
||||||
|
### 3.2 数据形态:复用 `ai_outbox` + 迁移 V52 增列
|
||||||
|
|
||||||
|
935 已定义 `ai_outbox`(`定时消息…设计` §3.1,V51),其列 `text / skill / model_override / parts_json / spans_json / due_at / status / attempts` 面向消息。937 复用其 `text`(= 指令原文,供统一列表渲染摘要)、`due_at`(= 触发时刻)、`status`、`attempts`,并**新增三列**承载 task 专属载荷:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 迁移 V52(937):幂等 ALTER(照 migrate_v45 样板),可 DROP COLUMN 回退
|
||||||
|
ALTER TABLE ai_outbox ADD COLUMN title TEXT; -- 卡片/列表标题(缺省取指令前 N 字)
|
||||||
|
ALTER TABLE ai_outbox ADD COLUMN payload_json TEXT; -- 结构化载荷 {instruction, task_id?, target_status?}
|
||||||
|
ALTER TABLE ai_outbox ADD COLUMN boundary_json TEXT; -- 触发执行继承的边界快照(可空=审批模式)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `kind='task'` 行:`text`=指令人读文本(列表摘要用);`payload_json`=`{instruction, task_id?, target_status?}`(instruction 冗余存一份便于派发时直接取;task_id/target_status 供 P2 直推快路径)。
|
||||||
|
- **不新增独立表**。`AiOutboxRecord`(`定时消息…设计` §3.1)对应加三个 `Option` 字段;`outbox_repo.rs` 的 insert/读方法带这三列(其余复用)。
|
||||||
|
- 迁移编号:935 占 V51,937 占 V52;若两需求同批落地,可合入一个迁移函数一次到位(建表 + 增列),但保持「V51 建表 / V52 增列」两段亦可(幂等,顺序无关)。
|
||||||
|
|
||||||
|
### 3.3 工具面:新增 `schedule_task`(`declare_tool!`,Medium)
|
||||||
|
|
||||||
|
新文件 `src-tauri/src/commands/ai/tools/schedule.rs`,仅一个工具;`tool_registry.rs:737` 旁接线(`register_schedule_tools`),基线测试 +1。
|
||||||
|
|
||||||
|
```rust
|
||||||
|
declare_tool!(
|
||||||
|
registry, db: Arc<Database>,
|
||||||
|
"schedule_task",
|
||||||
|
"登记一个定时/延时执行的任务作业:到点后在发起会话里自动执行 instruction(等价到点时我把这句话重新对你说一遍)。\
|
||||||
|
用 delay_secs 表达相对延时(如「1 小时后」=3600),或用 at 表达绝对时刻(RFC3339 或 epoch ms)。二者至少给一个,同时给以 at 为准。\
|
||||||
|
到点执行受既有审批/路径授权约束,不绕过任何安全闸。返回 job_id 与触发时刻。",
|
||||||
|
RiskLevel::Medium, // 登记未来自动执行,须人工知情(对齐 advance_task 的 Medium)
|
||||||
|
meta: ToolMeta {
|
||||||
|
entity_action: Some(("schedule", "create")),
|
||||||
|
approval_hint: Some(("创建定时任务:{}", &["instruction"])),
|
||||||
|
display_verb: Some("创建定时任务"),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
schema: object_schema(vec![
|
||||||
|
("instruction", "string", true), // 到点要执行的自然语言任务(必填)
|
||||||
|
("delay_secs", "integer", false), // 相对延时(秒),与 at 至少一个
|
||||||
|
("at", "string", false), // 绝对时刻 RFC3339;优先于 delay_secs
|
||||||
|
("title", "string", false), // 卡片标题(缺省取 instruction 前 N 字)
|
||||||
|
]),
|
||||||
|
args => {
|
||||||
|
// 1. 解析 instruction(必填)/delay_secs/at/title → 折算 due_at(epoch ms)
|
||||||
|
// 2. 发起 conv_id:handler 无 State → 经调用方注入(task-local,对齐 execute_run_workflow_for_tool mod.rs:1367)
|
||||||
|
// 3. outbox_repo::insert(kind="task", text=instruction, payload_json={instruction}, title, due_at, status=pending)
|
||||||
|
// 4. 唤醒调度器 notify → emit("ai-outbox-changed") 前端插卡
|
||||||
|
// 5. 返回 { job_id, due_at }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
- **相对 `delay_secs` 为主**(免时区/解析歧义);`at` 为便利补充(LLM 依 system prompt 里的当前时间折算)。
|
||||||
|
- **conv_id**:AI 工具 handler 是无 `State` 闭包;发起会话 id 沿用 workflow 工具先例经 task-local 注入(`execute_run_workflow_for_tool` 由 `ai_approve` 带 `conv_id` 调,`mod.rs:1367`)。`ai_outbox.conversation_id` 为 `NOT NULL`,而 AI 工具场景必在会话内,故恒可填;极少数拿不到 conv_id 的路径退回「前端/列表入口创建」(935 §3.4 composer 入口)。
|
||||||
|
|
||||||
|
### 3.4 调度器:复用 935 `outbox.rs`,加 `kind='task'` 派发分支
|
||||||
|
|
||||||
|
935 的调度循环(`定时消息…设计` §3.3)按 `kind` 分派。937 **只加一个分支**:
|
||||||
|
|
||||||
|
```
|
||||||
|
// outbox.rs list_due 取项后的派发(schema 由 935 定义,此处补 kind 分支)
|
||||||
|
match item.kind.as_str() {
|
||||||
|
"queue" | "scheduled" => { /* 935 既有:spawn_agentic_session(text,...) */ }
|
||||||
|
"task" => fire_task_job(app, state, &item), // ← 937 新增(§3.5)
|
||||||
|
other => { warn!(unknown kind); }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **CAS/并发/在途/唤醒钩子全部复用** 935:同会话忙(`can_accept_request=false`)→ 本轮跳过下次再取(不丢);`notify_conv_idle` 完成钩子即时续发;`claim` CAS 防双取;`reset_stale_sending` 启动复位。
|
||||||
|
- **`df-outbox-enabled` 复用**:关 = 所有 kind 冻结(937 的 task 也冻结),一致。
|
||||||
|
|
||||||
|
### 3.5 触发执行语义(`fire_task_job`,937 核心)
|
||||||
|
|
||||||
|
1. **注入文本**:`[定时任务触发] {instruction}`(固定前缀族,便于前端识别 + 完成卡归属)。
|
||||||
|
2. **落点选择**:
|
||||||
|
- 发起会话存在且 `can_accept_request` → **注入发起会话**:走 `spawn_agentic_session(text=<前缀+instruction>, …, Some(conv), …, pin_active=false)`(与 935 派发同源,`chat.rs:796`)。
|
||||||
|
- 发起会话正生成 → **轮界排队**:`revert_pending`(复用 935 的忙回退),下一 tick 重试(不打断流)。
|
||||||
|
- 发起会话已删/不存在 → **回落独立后台会话**:`Some(new_id())` + `origin="schedule"`,不静默丢。
|
||||||
|
3. **落库**:`mark_sent`(复用 935 语义:`spawn` 返回 Ok 即受理成功)+ `emit("ai-outbox-sent", {id, conv})`。
|
||||||
|
4. **失败**:spawn 前置失败(provider 未配/永久 4xx)→ `mark_failed`(`permament(error)` 复用 935 判据)+ 通知;非永久 → `revert_pending` 下轮重试。**LLM 轮次内的失败不回流**(与 935 同界定,交既有 loop 自愈)。
|
||||||
|
|
||||||
|
### 3.6 关闭/重启行为(复用 935 追赶策略,937 不另立规则)
|
||||||
|
|
||||||
|
- 迁移/恢复:复用 935 的 `reset_stale_sending`(启动把残留 `sending` → `pending`);调度器只认 `due_at <= now`,故**进程退出期间到期的 task 项重启后立即命中**。
|
||||||
|
- **迟到追赶策略 = 复用 935 拍板点 4(阈值 12h,建议值)**:迟到 ≤ 阈值 → 补触发;迟到 > 阈值 → 标过期(`status='expired'`? 935 用 `failed`/`canceled`,建议新增 `expired` 或用 `failed`+`last_error="missed"`)+ 通知,列表可见可手动重排。**937 不引入 24h 另一套阈值**,统一走 `df-outbox-*` 配置(如需 task 专属阈值,加 KV `df-outbox-task-missed-grace-secs`,默认同 12h)。
|
||||||
|
- 有界性:任何 task 作业按 `due_at + 宽限` 有界,不无限滞留。
|
||||||
|
|
||||||
|
### 3.7 权限与边界(与既有 audit/boundary 链的关系)
|
||||||
|
|
||||||
|
- **创建**:`schedule_task` Medium → 走既有 `audit/approval`(三档/边界会话按现行规则);**创建 ≠ 授权执行**。
|
||||||
|
- **触发执行**:走**同一** pipeline。`fire_task_job` 只把一句话送进会话,此后 agentic loop 里每个工具照常过 `audit`(写/High 审批)、`path_auth`(路径授权)、黑名单拒绝。**不新增任何旁路,不预授权**(红线)。
|
||||||
|
- **边界快照 `boundary_json`(可选)**:若发起会话已声明 `TaskBoundary`(`boundary.rs`,risk_ceiling≤Medium,落 `ai_conversations.boundary_json` V45),创建时可快照;触发时注入触发会话 → 边界内自动放行、边界外照旧拒绝。未声明 → 不写快照 → 触发走普通审批(首个写操作停靠等人工 + 通知)。
|
||||||
|
- **黑名单/破坏性命令**:永不放行(同 all 档/子代理先例)。
|
||||||
|
- **MCP 面(独立表面,首期不接)**:`crates/df-mcp/src/tools.rs:101` `all_tools()` 是**独立硬编码清单**(非 AI registry 派生);首期不把 `schedule_task` 加入 MCP(定时依赖本机 GUI 进程 + 调度 loop,远程场景语义不闭合),MCP 侧零改动。
|
||||||
|
|
||||||
|
### 3.8 UI 形态(复用 935 抽屉,937 只加卡片)
|
||||||
|
|
||||||
|
- **对话内定时任务卡(工具卡变体)**:`useToolCard.ts:513` 登记 `schedule_task` formatter;卡面 = 标题 + 指令摘要 + 「⏰ 触发于 MM-DD HH:mm(还有 X 分钟)」+ 状态徽标(`定时中`/`已触发`/`已取消`/`已过期`/`失败`)。**状态随 `ai-outbox-changed/sent/failed` 事件更新**(到点晚于工具返回,不能靠静态 tool_result)。形态照 `TaskParkedCard.vue`(per-conv 卡,行为后摘/更新)。
|
||||||
|
- **统一「定时列表」抽屉 = 复用 935**(`定时消息…设计` §3.4):937 的 task 项与 935 的 scheduled/queue 项**同列同抽屉**,仅多一列「类型=任务/消息」+ task 项显示关联任务(若有)。改期/取消复用 935 的 `ai_outbox_reschedule/cancel` IPC。
|
||||||
|
- **可选 task 专属入口**:ChatInput ⏰ 入口(935 已加,`ChatInput.vue:107`)+ 若要在列表里「手动新增定时任务」,复用同一 IPC(`ai_outbox_enqueue` 加 `kind='task'`+payload,或 937 加 `ai_schedule_task` 薄封装,见拍板点)。
|
||||||
|
- 设置页/TopBar 徽标:复用 935 的 `df-outbox-*` 区块与状态查询,不另开。
|
||||||
|
|
||||||
|
### 3.9 与既有链路的关系
|
||||||
|
|
||||||
|
- **worktree / 评审**:触发执行 = 一次普通 agentic run,worktree/review/run_workflow 全照旧;指令若含「推进任务/跑工作流」,由触发后的 loop 调 `advance_task`/`run_workflow`(工作流仍走 High 审批)。
|
||||||
|
- **无人值守引擎**:两者独立 loop、独立作业源;挂机在途(`in_flight`)与 outbox 派发各管各 conv,不冲突。
|
||||||
|
- **P2 快路径(可选)**:确定性作业「到点直推任务状态」(`payload_json.task_id`+`target_status`)可**不起 LLM**,直接 `advance_task_with_parent`(`task_advance_node.rs:335`)——省 token、免 LLM(状态机推进本身非破坏);须在创建时校验状态机合法性。见拍板点 4。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、分期与最小可用
|
||||||
|
|
||||||
|
| 期 | 范围 | 交付 | 依赖 |
|
||||||
|
|----|------|------|------|
|
||||||
|
| **P1(最小可用)** | 工具面 + task 分支闭环 | V52 增列 + `AiOutboxRecord` 三字段 + repo + `schedule_task` 工具 + `fire_task_job` 注入发起会话 + 启动追赶快照 + 工具卡徽标 | **935 P0 底座(`ai_outbox` 表 + `outbox.rs` 调度器 + 列表抽屉)** |
|
||||||
|
| **P2(管理/快路径)** | 列表类型区分 + 直推 | 列表 task 项类型标 + 关联任务显示;可选确定性直推快路径 + KV 阈值 | P1 稳定 |
|
||||||
|
| **P3** | 边界快照 / 与挂机联动 | `boundary_json` 快照注入触发执行;挂机自领定时任务(可选) | P2 + 边界模型落地 |
|
||||||
|
|
||||||
|
**最小可用定义**: P1 完成即达标——「对话里说 1 小时后跑 X → 卡片定时中 → 到点(含重启补触发)在原会话自动执行」。
|
||||||
|
**关键依赖**: 937 的 `kind='task'` 分支**不能脱离 935 的 `ai_outbox` 底座独立交付**;两需求应**同批或先后紧邻落地**,先 935 P0(表+调度器)后接 937 分支。这是本设计对编排的硬约束。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、影响面(新能力引入评估纪律 · 五维)
|
||||||
|
|
||||||
|
| 维度 | 判定 |
|
||||||
|
|------|------|
|
||||||
|
| **兼容性** | 复用 `ai_outbox`(V51)+ 937 加三列(V52,`column_exists` 幂等,可 `DROP` 回退);既有工具/任务/工作流零改动。**基线测试 `test_build_ai_tool_registry_baseline_tool_count` 须 +1(schedule_task)**。935 的 `queue`/`scheduled` 分支不受影响(仅多一个 `match` 分支)。**PASS(带迁移)** |
|
||||||
|
| **破坏性** | 无破坏性数据操作;不改 advance_task/run_workflow 语义;触发=发消息/起会话,可逆(取消/停止)。**PASS(低)** |
|
||||||
|
| **安全面** | 新攻击面 = 「未来自动执行 LLM/用户给定指令」。缓解:①创建 Medium 须知情审批 ②触发执行**零旁路**,写/High/路径/黑名单全走既有闸(G5/G6)③边界快照仅在会话已声明边界时生效(不放大权限)④可随时取消。**红线**:不得为「定时」开任何 approval/path 旁路。**PASS(受控)** |
|
||||||
|
| **资源面** | 复用 935 表与 10s tick loop;937 只多三列 + 一个 match 分支,资源增量≈0。task 项随 outbox 统一设上限(935 已定 sent/canceled 有界策略)。**PASS** |
|
||||||
|
| **回退开关** | 复用 `df-outbox-enabled`(置 false → task 项只入不发,冻结);V52 可 `DROP COLUMN` 回退。KV 热读免重启。**PASS(带开关)** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、拍板点(需人定,附建议)
|
||||||
|
|
||||||
|
1. **触发执行落点**:注入**发起会话**(结果可见、上下文连续,贴「对话内创建」语义)【建议默认】 vs 独立后台会话(不打扰但结果另起)。建议前者,发起会话忙则轮界排队、不可用则回落后台会话(§3.5 两全)。
|
||||||
|
2. **与 935 的落地顺序**:937 的 `kind='task'` 分支**依附 935 的 `ai_outbox` 底座**——先 935 P0 后接 937【建议】 vs 937 自建一套表/调度器(违反「共用调度器」且制造双调度,否决) vs 两设计合并为一次实施(978/935/937 同批)。建议前者,把「共用底座」写死为编排约束。
|
||||||
|
3. **触发执行授权**:默认**普通审批模式**(未声明边界的发起会话 → 触发后首个写操作停靠等人工并通知)【建议,最安全】 vs 创建即固化「完全信任」(攻击面大) vs 仅当发起会话已声明 `TaskBoundary` 时携带边界快照(边界内免打扰)。建议:默认普通审批 + 「有边界则快照」并存。
|
||||||
|
4. **快路径**:起步只做 `instruction`(通用,触发后 loop 消化一切)【建议】 vs 起步即带 `task_id`+`target_status` 确定性直推(省 LLM/免审批,但限死用途、需额外状态机校验)。建议 instruction 起步,直推作 P2(§3.9)。
|
||||||
|
5. **迟到追赶阈值**:复用 935 拍板点 4 的统一阈值(建议 12h)【建议】 vs 为 task 单设更短阈值(定时任务含副作用,可能希望更保守)。建议统一阈值起步,观测后如需再拆 KV。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、明确不做
|
||||||
|
|
||||||
|
- 不做 cron 表达式/周期重复任务(首期只做**一次性**定时;周期任务=挂机模式职责,另议)。
|
||||||
|
- 不做跨设备/云端定时(复用 935 边界:关窗托盘化期间生效,进程退出期间不触发,重启补发)。
|
||||||
|
- 不做定时执行结果的独立推送通道(复用 `notify_session_event` + 飞书,与 935 同出口)。
|
||||||
|
- 不为定时开任何 approval/path 旁路(红线)。
|
||||||
|
- 不新造调度底座(表/循环/IPC/事件/抽屉全复用 935)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附:实施落点清单(供实施会话直接取用;底座项见 935 设计,此处只列 937 增量)
|
||||||
|
|
||||||
|
| 文件 | 改动 |
|
||||||
|
|------|------|
|
||||||
|
| `crates/df-storage/src/migrations.rs` | 增 `migrate_v52`(3 个 `ALTER TABLE ai_outbox ADD COLUMN`,幂等);调度数组追加 `(52, migrate_v52)`;补幂等单测。**注意:V51 由 935 占,顺序不得换** |
|
||||||
|
| `crates/df-storage/src/models.rs` | `AiOutboxRecord`(**935 已加**)再补 `title/payload_json/boundary_json: Option<String>` |
|
||||||
|
| `crates/df-storage/src/crud/outbox_repo.rs`(**935 已建**) | insert/读方法带三新列;`list_by_status`/`list_due` 复用 |
|
||||||
|
| `src-tauri/src/commands/ai/tools/schedule.rs`(新) | `schedule_task` 工具(`declare_tool!`,Medium);`tool_registry.rs:737` 旁接线 + 基线测试 +1 |
|
||||||
|
| `src-tauri/src/commands/ai/outbox.rs`(**935 已建**) | 调度派发 `match kind` 加 `"task" => fire_task_job(...)`;`fire_task_job`(§3.5)+ 追赶快照 |
|
||||||
|
| `src-tauri/src/commands/ai/mod.rs` | 事件复用 `ai-outbox-changed/sent/failed`(935 已定);如卡片需状态细分可加可选字段,不新事件名 |
|
||||||
|
| `src-tauri/src/lib.rs` | `outbox::start` 由 935 挂载;937 无需另挂 |
|
||||||
|
| `src/composables/ai/useToolCard.ts` | `:513` 映射增 `schedule_task` formatter(定时任务卡) |
|
||||||
|
| `src/components/ai/` | 定时任务卡(仿 `TaskParkedCard.vue`);列表抽屉 task 项类型标(复用 935 抽屉) |
|
||||||
|
| `src/api/ai.ts` | 复用 935 `ai_outbox_*` 封装;若新增「手动建定时任务」入口再加薄封装 |
|
||||||
|
| `src/i18n/zh-CN/*` + `en/*` | 卡片状态词(`定时中`/`已触发`/`已过期`…)/类型「任务」 |
|
||||||
|
|
||||||
|
> 参照档: `定时消息与待发箱持久化-设计-2026-10-10.md`(935/931,**共用底座,必读**)· `无人值守长任务-设计-2026-10-04.md`(结构/详略)· `并行化与异步唤醒-设计-2026-10-04.md`(§二 wake delay 型/§6.3 挂机)· `输入框列表预览与自动编号-设计-2026-10-10.md`(同目录同日写法/五维自评)。
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# DevFlow 数据存储架构 — 定稿总结
|
||||||
|
|
||||||
|
> 2026-10-05 AI-Ops 归纳。来源:四维评估(空间治理/换库对比/量级基准/分布式协同)全部结论收敛,条款详见 todo「基础设施走查-1005」与「上下文精细化管理」两段。
|
||||||
|
|
||||||
|
## 一、三条定调(用户拍板)
|
||||||
|
|
||||||
|
1. **量级前提**:全生态实测 ≈2.1GB(本机 ~/.claude 2.0G + 六容器实例 <25MB)×10 ≈ **21GB**;流水不进主库,主库 GB 级以内。
|
||||||
|
2. **技术选型**:桌面端 **SQLite 不换**(换库是周级工程,PG 的优势场景用不上);**流水外置用文件**(Claude Code 自身先例:jsonl 存会话);**PG 属于未来云端服务端**(S3 多人时进,桌面不动)。
|
||||||
|
3. **向量非必选**:知识库检索按无向量设计(关键词+标签+kind 分簇),embedding 相关字段与管道视为遗留物。
|
||||||
|
|
||||||
|
## 二、分层架构
|
||||||
|
|
||||||
|
```
|
||||||
|
热层(主库 SQLite) 结构化条目:消息/任务/知识/审批 — GB 级内,D1-D3 治理
|
||||||
|
│ compress(相关性驱动,R 条款)
|
||||||
|
温层(外置文件区) 流水 jsonl / 工具大结果(>30 天外置)/ 长文档 — 内容哈希 id,
|
||||||
|
│ distill(蒸馏) 按会话/任务索引,跨会话跨端可找回(C5=量级前提)
|
||||||
|
冷层(知识库) 蒸馏条目:精巧即时、代谢进化(R7)、准入分级(R4)、无向量检索
|
||||||
|
│ inject(auto_inject,验证后准入)
|
||||||
|
热层(反哺新会话)
|
||||||
|
|
||||||
|
同步桥(横向):条目变更事件流(append-only)复用 u-relay/df-tunnel;
|
||||||
|
各端 SQLite 离线自治;冲突普通条目 LWW、关键实体状态机(task_state_machine CAS 先例);
|
||||||
|
云端多人(S3)时服务端 PG 作汇聚点。
|
||||||
|
```
|
||||||
|
|
||||||
|
## 三、统一条目模型(R8,架构轴)
|
||||||
|
|
||||||
|
- 一条信息 = 唯一 id + 内容 + 溯源 parent 链 + 元数据(相关性/时效/频率/**归属/origin/修订**)
|
||||||
|
- 生命周期状态机:hot → warm → cold → dead;compress/reload/distill/inject/merge/evict 都是状态转移
|
||||||
|
- 四类卡(10-04 设计)/知识条目/外置件 = 同一 schema 的类型扩展;新策略可插拔、新存储后端 = warm 层 trait
|
||||||
|
- **实施含义:C-m1 建模时四个分布式预留字段(归属/origin/修订/内容哈希)一次建齐,后续不再动地基**
|
||||||
|
|
||||||
|
## 四、治理与度量
|
||||||
|
|
||||||
|
- 空间:D1 VACUUM 止血(168MB→~70MB)/ D2a 结果 30 天外置 / D2b 月度定例 / D2c 文件:数据比 >1.5 告警
|
||||||
|
- 知识代谢:R7 入库先查存量(update 路径)/ R4 准入分级(candidate 不注入,verified 或 reuse 达标)/ R7 精巧即时
|
||||||
|
- 效果度量(R6):重载命中率 / 误压率 / 蒸馏采纳率;ai_messages 全程记录,评测基建现成
|
||||||
|
|
||||||
|
## 五、落地序
|
||||||
|
|
||||||
|
C-r0 验证后台化(独立先行)→ C-m1 建模(含四预留字段)→ C-m2 时机关联 → C-r1 按段重载 → C-m3 外置+蒸馏衔接(依赖知识库重审设计五步序)→ D 系列并行。
|
||||||
|
|
||||||
|
## 六、团队级 / 平台级 演进(用户 10-05)
|
||||||
|
|
||||||
|
原则:**演进不推翻** — 单机 → 团队级 → 平台级逐层加高,桌面端 SQLite 与统一条目模型永远不动;R8 预留的 scope/归属字段在此兑现(团队级 = scope 增加"团队"档,不是模型重构)。
|
||||||
|
|
||||||
|
数据三分类定共享边界:
|
||||||
|
- 私有(个人会话/上下文/记忆)→ 端内自治,永不上云
|
||||||
|
- 团队共享(团队知识/任务/项目/审批规则/模板)→ 团队空间事件同步
|
||||||
|
- 平台公共(跨团队知识/配额/审计)→ 平台层
|
||||||
|
|
||||||
|
**团队级**:一台汇聚节点(常驻机器)跑服务端 + PG 从第一天启用;成员桌面端照常 SQLite;共享空间走条目事件同步,私有不出端。与 u-claw 生态同构(u-relay 汇聚 + 各端自治),即该模式的产品化。
|
||||||
|
|
||||||
|
**平台级**:多租户 PG(小客户共享库+租户 id 行级 / 中型独立 schema / 大客户独立库,按规模分档)+ 温层升对象存储(内容寻址不变)+ 审计合规(events 表已有底子)+ API 网关/认证/配额服务层。
|
||||||
|
|
||||||
|
三阶段存储引擎各就各位,无任何阶段需要改条目模型或推翻同步协议。
|
||||||
|
|
||||||
|
> 关联文档:「上下文三层记忆-设计-2026-10-04」(实施主依据)/「知识库模型重审-精细化设计-2026-10-05」(冷层细则)/ todo R1-R9(风险条款全量)。
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
# DevFlow 布局样式大重构设计(2026-10-18)
|
||||||
|
|
||||||
|
> 依据:用户令「布局样式大重构,参考 D:\Downloads\u-agent」+「每页样式 DRY 提取统一定义」
|
||||||
|
> 输入:u-agent设计系统勘察-2026-10-18.md · 样式走查-1018/报告-布局全局审计.md · 报告-配色全局审计.md
|
||||||
|
> 原则:DRY 单一真值 · token 化 · 高密度(u-agent 13px 范式)· 双主题保留 · 紫 accent 品牌保留 · 视觉回归可控(dev 模式实时预览)
|
||||||
|
|
||||||
|
## 0. 现状诊断(三份勘察合并)
|
||||||
|
|
||||||
|
| 维度 | 现状 | u-agent 基准 | 差距 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 基准字号 | html 14px,aichat 已降 9-13,views 仍 11-14 | 13px 全局 | 功能页粗一档,密度断层 |
|
||||||
|
| 页头 | 三套并存(aichat 46px / Dashboard 18px 标题 / 功能页 24px h1 无固定高) | 46px 定高单行 | 全站统一为紧凑单行 |
|
||||||
|
| 侧栏 | 192px | 250-280px | 偏窄,功能页导航拥挤 |
|
||||||
|
| 按钮 | .btn 双基类分叉(global 34px vs settings 30px)+ 图标钮三档 | .cbtn 单基类 | 合一 30px |
|
||||||
|
| 徽章/tag | 圆角 3/4/6/8/10 五档并存;.df-tag 3px 字面量 | 小标签小圆角一档 | 收敛一档入 token |
|
||||||
|
| 圆角 token | 覆盖 75.5%,9 处 var 回退值写错(静默降档) | — | 修回退+字面量 97 处归 token |
|
||||||
|
| 间距 | 76.7% 收敛,7px×8/82px 手算对齐等离群 | 4px 刻度 | 离群归位+魔法数 token 化 |
|
||||||
|
| 字号 | 半像素 24 处(10.5/11.5/12.5)+17px 孤值 | 纯整数谱 | 归整 |
|
||||||
|
| 输入框 | 三档并存(21-22 / 26-27 / 30) | 30px 单档为主 | 行内搜索统一 26px 档 |
|
||||||
|
| 小节头 | 各页自造 | 9-10px uppercase mono 字距 统一范式 | 抽 .df-sec-h |
|
||||||
|
| 公共技法 | 无 | hairline 网格 kv 卡 / 发丝线行 / 染色两档 | 引入公共类 |
|
||||||
|
|
||||||
|
## 1. Token 层扩展(global.css :root,单一真值)
|
||||||
|
|
||||||
|
### 1.1 新增 token
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* 字号谱(纯整数,对齐 u-agent) */
|
||||||
|
--df-fs-micro: 9px; /* 小节头/tag/计数 */
|
||||||
|
--df-fs-meta: 10px; /* 徽章/元数据 */
|
||||||
|
--df-fs-aux: 11px; /* 辅助文字/列表次级 */
|
||||||
|
--df-fs-ctrl: 12px; /* 控件文字/小按钮 */
|
||||||
|
--df-fs-body: 13px; /* 正文基准 */
|
||||||
|
--df-fs-title: 16px; /* 页头标题 */
|
||||||
|
|
||||||
|
/* 控件高度档 */
|
||||||
|
--df-h-btn: 30px; /* 主按钮(.btn 合一目标) */
|
||||||
|
--df-h-input: 26px; /* 行内输入/搜索 */
|
||||||
|
--df-h-modal-input: 30px;
|
||||||
|
--df-h-icon-btn: 26px; /* 图标钮统一档(22/26/28 收敛) */
|
||||||
|
|
||||||
|
/* 微内距暗刻度(chip/badge 族) */
|
||||||
|
--df-pad-chip: 1px 6px;
|
||||||
|
--df-pad-badge: 3px 8px;
|
||||||
|
|
||||||
|
/* 图标槽 */
|
||||||
|
--df-icon-gutter: 42px; /* ToolCard 图标槽(38/42 魔法数归一) */
|
||||||
|
|
||||||
|
/* 染色两档(语义色背景/边框透明度统一,双主题各配) */
|
||||||
|
/* 由 --df-*-bg(12% 底)+ 新增 --df-*-bd(30% 边)承担,消除散落 rgba 字面量 */
|
||||||
|
--df-success-bd / --df-warning-bd / --df-danger-bd / --df-info-bd / --df-accent-bd
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 修正既有错误
|
||||||
|
- 9 处 var 回退值写错(lg 8→10 / md 6→8 / xs 3→2):**删回退值**(token 必注入,回退是静默降档源)
|
||||||
|
- .df-tag 圆角 3px 字面量 → var(--df-radius-xs)
|
||||||
|
- body 基准 14px → 13px(line-height 1.5 保持);aichat 已对齐,功能页随页头波次跟进
|
||||||
|
|
||||||
|
## 2. 公共组件层(global.css/components.css 单一来源)
|
||||||
|
|
||||||
|
| 公共类 | 来源 | 定义去向 |
|
||||||
|
|---|---|---|
|
||||||
|
| `.btn` 族 | global+settings 双基类合一 30px/8px 16px/13px | global.css 唯一定义,settings.css 删副本 |
|
||||||
|
| `.df-sec-h` | u-agent 小节头范式(10px uppercase letter-spacing .5px mono tx3) | global.css 新增 |
|
||||||
|
| `.df-kv` / `.df-kv-i/k/v` | hairline 网格档案卡(gap:1px+bd 背景) | components.css 新增 |
|
||||||
|
| `.df-row` | 发丝线行(border-bottom+last-child none) | components.css 新增 |
|
||||||
|
| `.df-note` | 通知条(info/warning/danger 染色) | components.css 新增 |
|
||||||
|
| `.df-pill` | 状态胶囊(全圆+染色底) | components.css 新增 |
|
||||||
|
| `.df-tab` / `.df-tab--active` | 下划线 tab(2px accent 底线+计数 chip) | components.css 新增 |
|
||||||
|
| 既有 `.df-dot/.df-badge/.df-tag/.df-mono/.df-hstat` | 第五十四段已注入 | 保留,随 1.2 修圆角 |
|
||||||
|
|
||||||
|
## 3. 页面适配波(DRY 提取:各页 scoped 重复样式→公共类)
|
||||||
|
|
||||||
|
- **页头统一**:功能页 8 页 + Dashboard 全部改 `.page-header` 紧凑单行(h1 16px/500 + 定高 46px 等价 padding + header-actions gap 8px),删各页自造页头样式
|
||||||
|
- **侧栏 192→220px**(AppSidebar 一处)
|
||||||
|
- **输入框**:GitChanges/Provider 行内 21-22px → var(--df-h-input)
|
||||||
|
- **列表/详情页**:status-tag/status-* 徽章族(components.css 已有 13 个状态色)染边补齐 + 圆角统一;Details 页 kv 区改 .df-kv;tab 行改 .df-tab
|
||||||
|
- **settings 族**:settings.css .btn 副本删除并轨;面板内重复 card/row 样式提取
|
||||||
|
- **ai 族**(已对齐 workbench):仅扫尾——圆角字面量 9px 气泡入注释锚定、7px 代码块归 token、tag 圆角收敛
|
||||||
|
- **离群修复**:7px 间距→6/8、ProviderPanel 82px 手算对齐改结构、26px 图标位 calc、FilePreview outline-name 补 nowrap(确定 bug)、定宽输入/浮层补 max-width
|
||||||
|
|
||||||
|
## 4. 实施波次(文件零交集,逐波门禁 vue-tsc + 目检)
|
||||||
|
|
||||||
|
| 波 | 内容 | 文件面 |
|
||||||
|
|---|---|---|
|
||||||
|
| R0 底座 | token 扩展+回退修正+公共类新增+.btn 合一+body 13px | styles/global.css + components.css + settings/settings.css |
|
||||||
|
| R1 功能页族 | Projects/Tasks/Ideas/Knowledge/AuditLog 页头+密度+tag 收敛 | views/*.vue(5 页可再拆并行) |
|
||||||
|
| R2 详情页族 | ProjectDetail/TaskDetail/IdeaDetail(kv/tab/徽章/页头) | 3 个 Detail.vue 可并行 |
|
||||||
|
| R3 设置族 | settings.css 并轨+面板 DRY 提取 | components/settings/* |
|
||||||
|
| R4 ai 扫尾+杂项 | 圆角/token 扫尾+离群修复+FilePreview bug | components/ai/* + 杂项 |
|
||||||
|
| R5 配色收口 | 按配色审计报告优先级表 10 项实施(报告 temp/样式走查-1018/报告-配色全局审计.md):①幽灵 token 清零(含 --df-bg-secondary 等 6 处声明失效真 bug)②.status-* 徽章系 token 三件套③白叠层 64 处入 token(--df-hover/--df-zebra 浅色覆写黑叠层)④回退值 111 处全删⑤global.css 边框 rgba 改 color-mix⑥--df-neutral-bg⑦views 族 rgba 清零⑧--df-shadow-sm/md⑨alpha 收敛⑩豁免注释 | 按报告分布;R5 先补 token(叠层/中性/阴影)再批量替换 |
|
||||||
|
|
||||||
|
每波:前台同步代理实施 → 主代理 diff 核查 + vue-tsc → dev 窗口目检(用户可实时看)。R0 先行(其他波依赖新 token/类),R1-R4 在 R0 后并行。
|
||||||
|
|
||||||
|
## 5. 拍板点(按推荐值先行,用户可回改)
|
||||||
|
|
||||||
|
1. **基准字号 13px**(功能页降一档,信息密度对齐 u-agent;若觉偏小回 14 仅改一处 token 层 body 声明)
|
||||||
|
2. **侧栏 220px**(192→220 折中,非 250)
|
||||||
|
3. **页头 16px 标题+紧凑单行**(废 24px 大标题)
|
||||||
|
4. **按钮 30px 主档**(对齐 settings 既有 30px 而非 global 34px)
|
||||||
|
5. tag/badge 圆角统一 xs(2px)/sm(4px) 档
|
||||||
|
|
||||||
|
## 6. 单文件拆分轨([补4],与样式波错峰)
|
||||||
|
|
||||||
|
行数实测(2026-10-18):MessageList 3297 / ProjectDetail 1750 / TopBar 1700 / Tasks 1540 / ChatInput 1503 / IdeaDetail 1228 / ConversationSidebar 1226 / TaskDetail 1160 / AiChat 1145 / FilePreview 1144 / ProviderPanel 964 / FileExplorer 932 / ToolResultBody 895。
|
||||||
|
|
||||||
|
**拆分纪律**:
|
||||||
|
- 纯拆分不改行为——样式/逻辑原样搬迁,验收=vue-tsc 0 + 构建过 + dev 窗口目检无视觉回归
|
||||||
|
- 拆分边界按「区块自治」(模板区块+配套样式+相关逻辑成组),优先抽展示型子组件;共享状态经 props/emits 或既有 composable,不新造 store
|
||||||
|
- **与样式波互斥调度**:同一文件的样式适配与拆分不并行(样式先行,拆分在后——拆完再改样式要重新定位锚点)
|
||||||
|
- 拆分批次(样式波收口后启动,每批文件零交集):
|
||||||
|
- C1 ai 族:MessageList(3297→消息项/工具卡区/折叠条带等子件) → ChatInput(1503) → TopBar(1700)
|
||||||
|
- C2 项目族:ProjectDetail(1750) → FilePreview(1144) → FileExplorer(932)
|
||||||
|
- C3 任务/想法族:Tasks(1540) → TaskDetail(1160) → IdeaDetail(1228)
|
||||||
|
- C4 侧栏+设置族:ConversationSidebar(1226) → AiChat(1145) → ProviderPanel(964) → ToolResultBody(895)
|
||||||
|
- 每文件拆分目标:主文件 ≤600 行,子件单一职责
|
||||||
|
|
||||||
|
## 7. 整洁性轨(用户令 2026-10-18:代码整洁性全量处理+样式代码统一抽取,[补1] 共享的样式才抽取)
|
||||||
|
|
||||||
|
探针实测(dead-style-scan.json / dup-style-scan.json,脚本在 temp/样式走查-1018/):
|
||||||
|
- 死样式候选 368 处 / 56 文件(MessageList 34/ToolCard 25/TopBar 20/ActiveProjectsPanel 19 领衔);**假阳性规则**:Vue transition 生成类(xxx-enter/leave/from/to/active 由 `<transition name="xxx">` 隐式消费,非死)、动态拼接类(`'status-' + s`)、子组件透传类——删前须逐处验证
|
||||||
|
- 跨文件共享重复规则 49 条;典型双轨:`.ai-toast-*`(AiChat/ApprovalPopup)与 `.toast-*`(Knowledge/Settings)同义共存;`.confirm-actions`×3;`@keyframes spin/shimmer` 10/3 文件各写一份
|
||||||
|
|
||||||
|
**底座已落**(主代理亲码 global.css):`.df-toast` 四色族+过渡 / `@keyframes df-spin`/`df-shimmer` / `.df-confirm-actions`+过渡 / `.df-diff-pre` 族。
|
||||||
|
|
||||||
|
**D 批分路**(R5 收口后启动,文件族零交集,每路两件事:本族死样式验证删除 + 本族共享类消费替换):
|
||||||
|
- D-A ai 族+根组件(MessageList/ToolCard/TopBar/ConversationSidebar/ChatInput/ToolResultBody/AiChat/ToolCardList):死样式 100+ 处 + ai-toast 族消费 + spin/shimmer keyframes 消费 + .ai-tool-diff-pre 族并轨 .df-diff-pre
|
||||||
|
- D-B views+project 族(Knowledge/ApprovalPopup/Settings/Projects/ProjectDetail/Tasks/AuditLog/FilePreview/FileExplorer/GitChanges/ProjectCard):死样式 100+ 处 + .toast-* 族消费 + confirm-actions 消费
|
||||||
|
- D-C settings+layout+dashboard+knowledge 族(AdvancedSection/AllowedDirsPanel/UpdateBadge/ActiveProjectsPanel/KnowledgeDetail 等):死样式残余 + 确认弹层消费
|
||||||
|
- D4 TS/模板整洁(随各路顺带):未用 import/变量(vue-tsc noUnusedLocals 级)、console.log 残留、死模板分支——纯删不改语义
|
||||||
|
|
||||||
|
**验收纪律**:每路 vue-tsc 0 + grep 证据(死样式删除数/共享类消费数);transition 类假阳性必须 grep `<transition name=` 确认后再删。
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
# 消息渲染管线重构 — 设计
|
||||||
|
|
||||||
|
> 日期: 2026-10-10 | 状态: 设计稿(待拍板) | 勘察: 只读实测锚点(全仓零 `v-memo`,实测 `grep -c v-memo = 0`)
|
||||||
|
> 对象: `src/components/ai/MessageList.vue`(3041→实测 **3054 行** God 组件)
|
||||||
|
> 目标(用户原话转译): 长会话流式掉帧根治 —— 拆分 God 组件、渲染记忆化、引入 `v-memo`、去 `JSON.stringify` 指纹、流式帧降载。
|
||||||
|
> 说明: 本专题为**只读走查 + 设计**,不含代码实施;所有行号均为实测锚点(文件以 `E:/wk-lab/devflow` 为根)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、现状勘察(锚点实测)
|
||||||
|
|
||||||
|
### 1.1 文件构成(3054 行 = script 1476 + template 460 + style 1117)
|
||||||
|
|
||||||
|
| 段 | 行区间 | 行数 | 备注 |
|
||||||
|
|----|--------|------|------|
|
||||||
|
| `<script setup>` | `1–1476` | 1476 | 逻辑 + 组合式调用 + 大量内联渲染辅助 |
|
||||||
|
| `<template>` | `1477–1936` | 460 | 单一 `v-for` 扁平渲染 + 6 个 `v-if/v-else-if` 分支 |
|
||||||
|
| `<style scoped>` | `1938–3054` | 1117 | 全组件样式集中于此(拆分后随子组件 scoped 自然分流) |
|
||||||
|
|
||||||
|
职责分类(**>6 类**,单一组件内并存):
|
||||||
|
|
||||||
|
1. 列表编排/分段 —— `messageSegments`(`:837`)、`renderItems`(`:1067`)、时间线 `needsTimeSep`(`:1049`);
|
||||||
|
2. 轨迹卡/步骤 —— trace 分支(`:1510`)/step 分支(`:1587`)+ 聚合助手 `:993–1246` + 轮次导航 `traceNav`(`:1386`);
|
||||||
|
3. 子代理完成卡 + peek 浮层 —— agentFin 分支(`:1601`)+ `openAgentTrace`(`:1280`)+ `agentPeek*`(`:1297–1384`);
|
||||||
|
4. 消息气泡 —— msg 分支(`:1644–1784`),user 走子组件 `MessageItem.vue`,assistant/system/json/长文折叠/错误操作条内联;
|
||||||
|
5. token 用量 —— 派生 `:172–224` + 模板块 `:1792+`;
|
||||||
|
6. 复制/选区 —— `copyMsgContent`(`:603`)、`copyMsgAsImage`(`:640`)、`saveSelection`/`restoreSelection`(`:501/:545`)、`offsetToDOMPosition`(`:481`)、代码块复制(`:728`);
|
||||||
|
7. 滚动/折叠联动 —— `useMessageScroll`(`:21/:230–238`)、`collapseAllToolLists`(`:395`)、`anchorCollapseScroll`(`:354`)、`buildActiveToolIds`(`:380`);
|
||||||
|
8. 展开状态集合 5 组 —— `useToggleSet`(`:869–880`)+ `expanded*`(`:883/:891/:944/:949/:953`)。
|
||||||
|
|
||||||
|
流式渲染(`useStreamRenderer.ts`)与 Markdown(`useMarkdown.ts`)此前已抽出;**本专题针对剩余编排层**。
|
||||||
|
|
||||||
|
### 1.2 热路径触发链(谁在每帧/每次事件重算)
|
||||||
|
|
||||||
|
| 触发源 | 频率 | 影响 | 锚点 |
|
||||||
|
|--------|------|------|------|
|
||||||
|
| `streamingBlocks` 每帧更新(rAF) | **每帧** | 组件 render 函数整体重跑 → `v-for` 对全部 item **逐条新建 vnode**(全仓零 `v-memo`) | `useStreamRenderer.ts:232`(rAF 内赋值)、模板 `:1727–1728` |
|
||||||
|
| 工具事件(status 翻转) | 每次工具事件 | `renderItems` 全量 O(n) 重算(读 `m.toolCalls`/`tc.status`/`tc.completedAt`) | `renderItems :1067`;status 就地改 `useAiToolEvents.ts:51/101/119`、`useAiApproval.ts:87/109`、`useAiConversations.ts:501`、`useAiStreamingEvents.ts:123` |
|
||||||
|
| 指纹 watch | 每次工具事件 / 消息数变 | `map(m=>toolCalls→{id,s})` + `JSON.stringify` 全量 O(n) | **`:429–442`**(source `:430–433`) |
|
||||||
|
| 展开/折叠 toggle | 用户点击 | `renderItems` 重算(trace/seg 集合在 dep 内)+ 全组件 render | `:1116`(expandedTraceIds)、`:1152`(expandedSegmentIds) |
|
||||||
|
|
||||||
|
**关键链路细节**:
|
||||||
|
- `renderItems`(`:1067–1189`)读 `store.state.messages`(数组)、`shouldRenderMsg`(`:159–167`,读 `msg.content?.trim()`)与 `isViewingGenerating`(`:126`)。**流式 delta 期间末条 AI 的 `msg.content` 不被改写**(正文回填在收尾单点 `aiShared.ts:544`),故 `renderItems` 本身**不逐帧重算**;逐帧的开销来自 render 函数里 `v-for` 对全部 item 重建 vnode(无 `v-memo` 跳过)。工具事件则确会触发 `renderItems` 全量重算。
|
||||||
|
- `flushBucket`(`:1075`)对每个桶 `collectTurnStats`(`useTurnTrace.ts:90`,遍历全部 toolCalls)+ trace 桶 `sumTurnTokens`(`useTurnTrace.ts:134`,遍历全部消息 token 字段)+ 模板逐条 `traceTaskRefs(item.traceMsgs)`(`:88`,遍历 toolCalls 提取任务引用)。
|
||||||
|
|
||||||
|
### 1.3 已有的内容级缓存(部分缓解,非本次病灶)
|
||||||
|
|
||||||
|
| 设施 | 锚点 | 粒度 |
|
||||||
|
|------|------|------|
|
||||||
|
| `renderMd` 整段缓存 LRU | `useMarkdown.ts:79`(`_MD_CACHE_LIMIT=200`)、`:195–207` | 文本→HTML |
|
||||||
|
| 代码块装饰缓存 LRU | `MessageList.vue:681–711`(`DECO_CACHE_LIMIT=800`) | 文本→装饰 HTML |
|
||||||
|
| 流式块级 memo + 增量切块 | `useStreamRenderer.ts:59`(`BLOCK_CACHE_LIMIT=300`)、`:67–79` | 单块→HTML |
|
||||||
|
| `MessageItem` 派生 computed | `MessageItem.vue:99–101` | 单条 user 派生值 |
|
||||||
|
|
||||||
|
**缺口(本次目标)**:内容级缓存已就绪,但**列表结构级(renderItems)与 vnode 级(v-memo)缺优化** —— 前者在工具事件时全量重算,后者在每帧重建全列表 vnode。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、目标与验收口径
|
||||||
|
|
||||||
|
**功能目标**
|
||||||
|
1. `MessageList.vue` 拆分至 **单文件 <400 行**(script+template;style 随子组件 scoped 分流)。
|
||||||
|
2. `renderItems` **记忆化**:按桶缓存,桶内容未变即复用同一 item 对象(稳定引用),不再 O(n) 重建。
|
||||||
|
3. 引入 **`v-memo`**:流式每帧仅重渲"真正变化"的 item,其余 item 跳过 vnode 新建/补丁。
|
||||||
|
4. 指纹 watch **去 `JSON.stringify`**,改 O(1) 版本计数。
|
||||||
|
5. 流式帧渲染**降载**:rAF 合并(已有)+ 离屏段落跳过。
|
||||||
|
|
||||||
|
**验收口径**
|
||||||
|
- 长会话(≥200 条)+ 长回复流式:每帧 patch 的 item 数从"全列表"降到"1(末条)"——Vue DevTools render 计数或 dev 打点可观测;人眼无掉帧、无卡顿。
|
||||||
|
- 工具事件(审批/完成/拒绝)后:轨迹卡统计(`collectTurnStats`)与聚合行正确刷新;`renderItems` 重算次数 ≤ 变更桶数。
|
||||||
|
- 展开/折叠(轨迹卡/思考 pill/JSON/长文/折叠段)即时生效,无"点了没反应"。
|
||||||
|
- 复制/选文字保持/错误重试/子代理 peek/轮次导航跳转等既有交互逐条回归无弱化。
|
||||||
|
- 不倒退:普通单轮问答、历史会话滚动、切换会话行为与改造前逐字节一致。
|
||||||
|
- `vue-tsc --noEmit` 与 `pnpm test:e2e`(Playwright,`tests/e2e/smoke.spec.ts`)全绿。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、设计方案
|
||||||
|
|
||||||
|
### 3.1 核心判断:三件事正交,可分笔落地
|
||||||
|
|
||||||
|
病灶分处三层,互不耦合,可分笔提交、独立回退:
|
||||||
|
|
||||||
|
- **vnode 层**(每帧) —— `v-memo` 缺 → 补 `v-memo`(不改结构)。
|
||||||
|
- **列表结构层**(每工具事件) —— `renderItems` 全量重算 → 桶 memo 缓存(不改结构)。
|
||||||
|
- **代码组织层**(可维护性) —— God 组件 → 拆 composable / 子组件(行为零变)。
|
||||||
|
|
||||||
|
设计原则:`v-memo` 与 memo 缓存**不改 `renderItems` 输出契约**(仍返回 `RenderItem[]`,`:1018–1028` 类型不变),模板 6 分支结构不变 → 回归面收敛在"渲染策略"而非"渲染语义"。
|
||||||
|
|
||||||
|
### 3.2 `renderItems` 记忆化(稳定 item 对象)
|
||||||
|
|
||||||
|
**输入指纹(桶签名 `sig`)**:仅取 `renderItems` 真正消费的字段(与 `:429` 指纹同源思路,但**按桶**):
|
||||||
|
|
||||||
|
```
|
||||||
|
sig(bucket) =
|
||||||
|
bucket.map(m => m.id + ':' + m.status + ':' + (m.toolCalls ?? [])
|
||||||
|
.map(t => t.id + ':' + t.status + ':' + (t.completedAt ?? 0) + ':' + (t.argsHash ?? '')).join(','))
|
||||||
|
.join('|')
|
||||||
|
+ '#' + finalIndex // 完成态桶的 finalMsg 下标
|
||||||
|
+ '#' + (expandedTraceIds.has(traceKey) ? 1 : 0)
|
||||||
|
```
|
||||||
|
|
||||||
|
**缓存**:组件级 `Map<string, RenderItem[]>`(LRU 有界,如 128,沿用 `_decoCache:681` 同款范式)。命中即回**同一 item 对象数组**(引用稳定);未命中才 `collectTurnStats`/`sumTurnTokens`/`traceTaskRefs` 并写缓存。顶层 `items` 数组仍每次重组(桶数 ≪ 消息数,成本可忽略)。
|
||||||
|
|
||||||
|
**收益**:①工具事件时 O(n)→O(变更桶);②item 引用稳定 → `v-memo` 的 dep 可用 `[item]` 表达(见 3.3)。
|
||||||
|
|
||||||
|
**风险**:签名漏字段 → 命中陈旧 item → 界面不刷新。故签名须覆盖所有影响输出的字段;此风险由 3.3 的保守 dep 兜底(primitive dep 即使签名漏,值变仍会重渲)。
|
||||||
|
|
||||||
|
> 备选(更保守):不引入桶 memo,P0 仅 v-memo。但工具事件时的 O(n) 重算仍在 —— 建议两步都做、分两笔提交。
|
||||||
|
|
||||||
|
### 3.3 `v-memo` 依赖数组口径
|
||||||
|
|
||||||
|
**核心原理**:`renderItems` 是 computed,跨帧被缓存(流式帧不重算)→ item 对象跨帧引用稳定;任其**已读取**的响应式源变化 → 重算 → 新 item 对象 → `[item]` dep 变化 → 该 item 重渲。因此 **dep 只需补"`renderItems` 未读、但模板渲染该 item 时读了"的兄弟响应式源**。
|
||||||
|
|
||||||
|
**已读(自动失效,无需列入)**:`store.state.messages`、`m.status`/`m.role`/`m.content`(经 `shouldRenderMsg`)、`expandedTraceIds`(`:1116`)、`expandedSegmentIds`(`:1152`)、`isViewingGenerating`(读 `streaming`)。
|
||||||
|
|
||||||
|
**未读(必须补入 dep,否则 v-memo 跳过致不刷新)**:
|
||||||
|
|
||||||
|
| 源 | 影响 | 锚点 |
|
||||||
|
|----|------|------|
|
||||||
|
| `expandedThinkIds` | 轨迹卡内思考 pill 展开 | `:953/:1559` |
|
||||||
|
| `expandedJsonIds` | JSON 工具结果展开 | `:944/:1711` |
|
||||||
|
| `expandedLongIds` | 长文折叠/展开 | `:891/:898/:1714` |
|
||||||
|
| `tokenPopoverMsgId` | token 详情面板显隐 | `:180` |
|
||||||
|
| `editingMsgId` | user 编辑按钮显隐 | `:1691` |
|
||||||
|
| `agentPeekLoadingId` | 子代理 peek 转圈 | `:1625` |
|
||||||
|
| `_mdRenderKey` | marked 就绪后首屏重渲 | `:457–458/:718` |
|
||||||
|
| 任务标题源 | trace 任务 chip 文案 | `taskTitleOf :83` |
|
||||||
|
|
||||||
|
**推荐写法**(DRY 单出口,补 dep 一处维护):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// template: <template v-for="item in renderItems" :key="item.key" v-memo="itemDeps(item)">
|
||||||
|
function itemDeps(it: RenderItem): unknown[] {
|
||||||
|
return [it, expandedThinkIds.value, expandedJsonIds.value, expandedLongIds.value,
|
||||||
|
tokenPopoverMsgId.value, editingMsgId.value, agentPeekLoadingId.value,
|
||||||
|
mdRenderVersion.value, taskTitleVersion.value]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `[it,...]` 依赖"renderItems 重算即新对象"这一既定事实(无需 primitive 枚举,简洁);兄弟集合用**整体引用**(`useToggleSet` 每次 toggle 替换 Set → 引用变 → 全部 item 重渲)。toggle 为用户点击(低频),全量重渲可接受,换取 dep 永不过期。
|
||||||
|
- `taskTitleVersion`/`mdRenderVersion` 用 O(1) 版本号(见 3.4)表达异步就绪源,避免把整个 `skills`/`tasks` 数组塞进 dep。
|
||||||
|
|
||||||
|
**风险与兜底**:若 Vite/Vue 编译器不支持在 `<template v-for>` 上挂 `v-memo`(与内部 `v-if` 链组合),则**回退方案**=为每个 item 包一层 keyed 子组件(即 P2 的 `<AiMessageBubble :item>` 提前),`v-memo` 落在子组件根 —— 属结构性,故列为风险项而非默认路径。
|
||||||
|
|
||||||
|
### 3.4 指纹 watch 改 O(1) 版本计数
|
||||||
|
|
||||||
|
**现状**(`:429–442`):source(`:430–433`)每次工具状态变动作 `messages.map(...)` + `JSON.stringify`,O(n) 分配 + 序列化。
|
||||||
|
|
||||||
|
**改法**:引入 `toolEpoch`(数字 ref,建议落 `aiShared.ts` 与 `convStreamStates` 同层),在**唯一入口** `bumpToolEpoch()` 自增;所有工具状态变更点调用:
|
||||||
|
|
||||||
|
- `useAiToolEvents.ts:39`(push 新 toolCall)、`:51`(completed)、`:101`(pending_approval)、`:119`(rejected);
|
||||||
|
- `useAiApproval.ts:87`(running/rejected)、`:109`(completed);
|
||||||
|
- `useAiConversations.ts:501`(待审批回填)、`useAiStreamingEvents.ts:123`(待审批)。
|
||||||
|
|
||||||
|
watch 改:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
watch([() => store.state.messages.length, () => toolEpoch.value], () => {
|
||||||
|
if (isFollowingBottom.value) collapseAllToolLists(buildActiveToolIds(store.state.messages))
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**收益**:每次工具事件从 O(n) map+stringify 降为 O(1) 计数;`lastMsgSnapshot` 去重串可删(watch 已按值变化触发)。**代价**:需在 ~7 处 mutation 点补 `bumpToolEpoch()` —— 建议收敛到一个 `markToolChanged()` 助手,避免散点漏调(漏调 = 折叠联动不触发,非渲染错误,风险低)。
|
||||||
|
|
||||||
|
### 3.5 流式帧渲染降载
|
||||||
|
|
||||||
|
1. **rAF 合并** —— 已有(`useStreamRenderer.scheduleStreamParse:225`),每帧仅一次 parse;保持不动。
|
||||||
|
2. **v-memo**(3.3)—— 每帧仅末条 item 重建 vnode,是**最主要的降载**(治"逐帧全列表 vnode 重建")。
|
||||||
|
3. **离屏段落跳过** —— 对 `.ai-msg-slot` 加 CSS `content-visibility: auto` + `contain-intrinsic-size`(浏览器级跳过离屏布局/绘制,零 JS)。**风险**:与 `scrollToBottom`(`anchorCollapseScroll:354` 读 `getBoundingClientRect`)可能因离屏高度估算产生滚动抖动 → 建议 P1 引入并实测,回退=去该 CSS 类。
|
||||||
|
4. **renderItems 不读流式正文** —— 现状已满足(正文回填在 `aiShared.ts:544` 收尾单点,delta 期间不改 `msg.content`);设计上**明确此约束不得破坏**(勿在 `renderItems` 内引入 `store.state.currentText` 读取),否则 computed 每 delta 重算,前功尽弃。
|
||||||
|
|
||||||
|
### 3.6 拆分方案(职责切分 + 数据流)
|
||||||
|
|
||||||
|
**数据流不变**:`store.state.messages`(真相)→ `messageSegments` → `renderItems`(`RenderItem[]`)→ 模板分支渲染。拆分仅重排"谁来持有逻辑"。
|
||||||
|
|
||||||
|
**P1 — 抽出 composable(模板不动,纯脚本迁移 + 依赖注入)**:参照已抽的 `useStreamRenderer`(回调注入)范式:
|
||||||
|
|
||||||
|
| 新 composable | 迁出内容 | 入参(注入) | 出参 |
|
||||||
|
|---------------|----------|-----------|------|
|
||||||
|
| `useRenderItems` | `messageSegments`/`renderItems`/时间线/traceNav + 桶 memo | messages、`isViewingGenerating`、各 expanded 集合 | `renderItems`、`traceNav`、`jumpToTrace` |
|
||||||
|
| `useTracePanel` | trace 展开/task refs/轮次导航 + agent peek 浮层 | store、`expandedTraceIds` | `toggleTrace`、`traceTaskRefs`、`openAgentTrace`/`closeAgentTrace`、`agentPeek*` |
|
||||||
|
| `useMessageActions` | 复制(文本/图片)/重试/装 obscura/去设置 | store、`emit`、`scrollToBottom` | `copyMsgContent`、`handleRetry`、… |
|
||||||
|
| `useTextSelection` | 保存/恢复选区 + 偏移定位 + 代码块复制委托 | `messagesContainer` | `saveSelection`/`restoreSelection`、`onMdCodeCopyClick` |
|
||||||
|
| `useTokenUsage` | token 派生 + popover 状态 | — | `token*Of`、`tokenPopoverMsgId`、`toggleTokenPopover` |
|
||||||
|
|
||||||
|
**P2 — 抽子组件(模板分流 + style scoped 随迁)**:
|
||||||
|
|
||||||
|
| 子组件 | 迁出模板 | 目标 |
|
||||||
|
|--------|----------|------|
|
||||||
|
| `TraceCard.vue` | trace(`:1510–1583`)+ step(`:1587–1596`)同构两态 + rail 面板 | 含内部 `v-memo`/computed,思考 pill 展开自治 |
|
||||||
|
| `AgentFinishedCard.vue` | agentFin(`:1601–1627`) | peek 点击 emit 上抛 |
|
||||||
|
| `AiMessageBubble.vue` | msg 的 assistant/system/json/长文折叠/错误操作(`:1697–1784`)+ 流式块(`:1727–1730`) | 单条气泡自治,`item` 作 prop |
|
||||||
|
| `TimeSeparator.vue` / `SegmentSeparator.vue` | timesep(`:1629`)/sep(`:1650–1671`) | 小件,可留 MessageList |
|
||||||
|
|
||||||
|
拆完后 `MessageList.vue` 余:容器 + 滚动 + 编排 `v-for` + 事件委托(目标 <400 行)。
|
||||||
|
|
||||||
|
**高内聚低耦合要点**:子组件只收 `item` 数据 + 经 `emit` 上抛动作(不直接持 `store` 写);`ToolCardList`/`MessageItem` 已是此模式,新组件对齐。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、分期(每期可独立上线、可回退)
|
||||||
|
|
||||||
|
### P0 — 零风险渲染层(v-memo + 桶 memo + 版本计数)**不改结构**
|
||||||
|
- 补 `v-memo`(`itemDeps`,3.3);`renderItems` 桶 memo(3.2);指纹 watch 改 `toolEpoch`(3.4)。
|
||||||
|
- **不改**模板分支、DOM 契约(`data-msg-id`/role class)、`RenderItem` 类型。
|
||||||
|
- 建议拆 **两笔**提交:`v-memo` 一笔、桶 memo+版本计数一笔 → 可分笔 revert。
|
||||||
|
- 验收:§二"每帧仅末条 patch"人眼/打点;工具事件统计刷新正确;toggle 即时生效;`vue-tsc` + e2e 全绿。
|
||||||
|
|
||||||
|
### P1 — 拆 composable + 离屏降载(模板不动)
|
||||||
|
- 抽 `useRenderItems`/`useTracePanel`/`useMessageActions`/`useTextSelection`/`useTokenUsage`(3.6);`.ai-msg-slot` 加 `content-visibility`(3.5-3)。
|
||||||
|
- 验收:`MessageList.vue` script 显著下降;所有交互回归;滚动锚点无抖动;e2e 全绿。
|
||||||
|
|
||||||
|
### P2 — 拆子组件(结构 + style 分流)
|
||||||
|
- 抽 `TraceCard`/`AgentFinishedCard`/`AiMessageBubble`(3.6);style scoped 随迁。
|
||||||
|
- 验收:单文件 **<400 行**;全交互 + e2e 全绿;`v-memo` 下沉各子组件(可改用 `[item]` 简化)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、影响面(五维)
|
||||||
|
|
||||||
|
- **兼容性**:纯前端渲染层重构,不改 IPC / 事件 / DB / 后端协议;`RenderItem[]` 输出契约与模板分支不变;DOM 语义属性保留 → 既有 e2e 选择器/`getBoundingClientRect` 锚点不破坏;`MessageItem`/`ToolCardList` 对外 props/emit 不变。
|
||||||
|
- **破坏性**:无不可逆操作(纯代码,git 可回退)。**行为风险**=`v-memo` dep 遗漏(局部不刷新,可见回归)、桶 memo 签名冲突(串内容)。缓解:dep 保守(3.3)+ 签名含 `id` 唯一性 + LRU 有界。
|
||||||
|
- **安全面**:无新端点/命令执行面;渲染 HTML 仍走既有 `DOMPurify`(`useMarkdown.renderMd`/`parseBlockNoCache`)链,`v-memo` 只跳过补丁、不改变 `v-html` 内容来源,无新 XSS 面。
|
||||||
|
- **资源面**:内存 —— 新增桶 memo Map + dep 数组,有界 LRU(128);CPU —— 每帧 vnode 从 O(全列表) 降 O(1~少数);工具事件 O(n) 重算 → O(变更桶);`content-visibility` 降离屏布局/绘制。工具事件 O(1) 版本计数替代 O(n) stringify。
|
||||||
|
- **回退开关**:`v-memo`/结构拆分属**编译期**,无运行时开关 → 回退粒度=**逐笔 git revert**(故 P0 拆两笔、P1/P2 各自独立)。`toolEpoch` 为本地计数,回退即恢复旧 watch。`content-visibility` 可用 CSS 类开关运行时回退。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、拍板点清单
|
||||||
|
|
||||||
|
| # | 决策 | 选项 | 建议 |
|
||||||
|
|---|------|------|------|
|
||||||
|
| 1 | `v-memo` 依赖口径 | A 逐 kind primitive 枚举;B `[item, ...兄弟引用]`(依赖 renderItems 缓存保证 item 稳定);C 仅对 msg 分支上 v-memo | **B**。简洁且覆盖充分;前提=3.2 桶 memo 与 3.3 的 sibling 清单正确;A 作理论最保守备选(枚举成本高、易漏) |
|
||||||
|
| 2 | P0 是否同时做桶 memo | A 仅 v-memo(结构零改,工具事件仍 O(n));B v-memo + 桶 memo(拆两笔) | **B**。两者正交、各自可测;桶 memo 治"工具事件级"重算,仅 v-memo 治不了 |
|
||||||
|
| 3 | 离屏 `content-visibility` 时机 | A P0 一起;B P1 引入;C 不做 | **B**。收益大但需实测滚动锚点抖动,单列为 P1 可回退项,风险隔离 |
|
||||||
|
| 4 | P2 子组件边界 | A 抽 TraceCard/AgentFinishedCard/AiMessageBubble;小件(时间线/折叠条)留父;B 仅抽最大件 AiMessageBubble | **A**。目标是 <400 行,需 style scoped 随迁才达标;B 达标不足 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附:实施纪律
|
||||||
|
|
||||||
|
- `renderItems` 是全仓最热路径,**回归面大** → 遵循"优先级 Bug→重构→新功能、单次 1-2 文件、原子操作"策略(`memory large-refactor-strategy`);每期独立提交 + 独立 e2e。
|
||||||
|
- 改代码一律用 Edit 工具(禁 sed/perl);验证阶段收官统一跑 `vue-tsc` + e2e,不逐文件全量 build(`CLAUDE.md` 编排纪律)。
|
||||||
|
- 存疑处**源码形态优先于文档描述**;`v-memo` dep 清单以本设计 3.3 表为准,实施时逐条 grep 模板引用核对,勿凭空增删。
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
# DevFlow 版本更新与自动升级 — 专项设计(2026-10-09)
|
||||||
|
|
||||||
|
> 对象:应用内「版本可见 → 检查更新 → 下载 → 安装重启」全链路。
|
||||||
|
> 标准:合理 · 兼容 · 优雅 · 可扩张。
|
||||||
|
> 现状勘察:`src-tauri/tauri.conf.json` / `src-tauri/Cargo.toml` / `src-tauri/capabilities/default.json` / `src-tauri/src/lib.rs` / `src/views/Settings.vue` / `src/components/settings/SettingsNav.vue` / `scripts/package-devflow.ps1` / `swap-install.ps1` / u-desk 先例。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、本质定性
|
||||||
|
|
||||||
|
### 1.1 两条"升级"通道必须分开命名
|
||||||
|
|
||||||
|
现状只有一个动作叫「换装」,但它其实是两个语义完全不同的事情,混称导致讨论失真:
|
||||||
|
|
||||||
|
| 通道 | 触发源 | 使用者 | 目标 | 现载体 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **本地换装** | 本机源码改动 | 开发者(你) | 把刚编译的产物装到自己机器 | `scripts/package-devflow.ps1 -Mode full` / `swap-install.ps1` |
|
||||||
|
| **应用内升级** | 远端新版本 | 任何使用者 | 把手上的旧版升到新版 | **不存在** |
|
||||||
|
|
||||||
|
**本设计只做第二条**。第一条保持不动(且是本设计的发布端依赖)。
|
||||||
|
|
||||||
|
### 1.2 为什么这件事值得做(而非"自用工具不需要")
|
||||||
|
|
||||||
|
现状的隐性成本:
|
||||||
|
|
||||||
|
1. **用户无法得知自己用的是哪版** —— 设置页无版本号展示,issue 反馈时只能靠猜("你那个缓存修复装上了吗?"——本次对话里就真实发生过:凌晨 02:50 装了包,13:02 又改了码,靠猜时间戳判断哪个生效)。
|
||||||
|
2. **升级不可达** —— 未来若分发给他人(u-desk 已有对外分发先例),对方拿到 v0.1.1 后永远停在 v0.1.1,只能等新包重装。
|
||||||
|
3. **升版本号是手工雷区** —— `swap-install.ps1` 硬编码 `devflow_0.1.1_x64-setup.exe`,改版本号忘改脚本 = 静默装回旧包(**静默伪成功**,最危险的一类故障)。
|
||||||
|
|
||||||
|
结论:**先做 L0(版本单一源 + 可见),再做 L1(检查→提示),最后 L2(下载安装)**。L0 交付即消除 1、3 两项,成本极低。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、现状勘察(全部实证)
|
||||||
|
|
||||||
|
### 2.1 三元缺口
|
||||||
|
|
||||||
|
| 层 | 现状 | 证据 |
|
||||||
|
|---|---|---|
|
||||||
|
| 插件层 | 无 `tauri-plugin-updater` | `src-tauri/Cargo.toml:18-25` 仅 dialog/opener/window-state/notification/single-instance |
|
||||||
|
| 配置层 | 无 `plugins.updater` 段 | `tauri.conf.json` 仅 `bundle.targets: ["nsis"]` |
|
||||||
|
| 逻辑层 | 无版本比对 IPC | `grep check_update\|latest_version\|auto_update` 于 `src-tauri/src` → 0 命中 |
|
||||||
|
| UI 层 | 无版本号/检查更新入口 | `grep 检查更新\|getVersion\|appVersion` 于 `src/` → 0 命中;SettingsNav 仅 7 分类 |
|
||||||
|
|
||||||
|
### 2.2 现存缺陷(本设计顺带治理)
|
||||||
|
|
||||||
|
**D1 — 版本双源不一致**
|
||||||
|
|
||||||
|
```
|
||||||
|
src-tauri/tauri.conf.json:3 "version": "0.1.1" ← 打包器实际用这个
|
||||||
|
src-tauri/Cargo.toml:3 version = "0.1.0" ← Rust crate 用这个
|
||||||
|
```
|
||||||
|
|
||||||
|
Tauri 2 中 `bundle` 版本取 `tauri.conf.json`,`CARGO_PKG_VERSION` 取 Cargo.toml。当前两者已经分叉(0.1.1 vs 0.1.0),未来必然出「设置页显示 0.1.0,实际装的是 0.1.1」这类灵异现象。
|
||||||
|
|
||||||
|
**D2 — 换装脚本硬编码版本号**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# swap-install.ps1:8
|
||||||
|
$installer = 'E:\wk-lab\devflow\target\release\bundle\nsis\devflow_0.1.1_x64-setup.exe'
|
||||||
|
```
|
||||||
|
|
||||||
|
版本号进入文件名,升版忘改 → 找不到文件 → `Start-Process` 抛错(尚好);若旧版包残留同目录,则**装回旧版**(伪成功)。必须改为 glob 探测(`package-devflow.ps1:44` 已用 `Sort-Object LastWriteTime -Descending | Select-Object -First 1` 的正确姿势,swap-install 未对齐)。
|
||||||
|
|
||||||
|
### 2.3 可复用的成熟资产
|
||||||
|
|
||||||
|
**u-desk(同作者的 Wails 应用)已完成全套更新链路**,虽技术栈不同(Go+Wails vs Rust+Tauri),但**方案骨架可直接借鉴**:
|
||||||
|
|
||||||
|
- 清单托管:`https://img.1216.top/u-desk/last-version.json`(七牛 CDN)
|
||||||
|
- 后端:`internal/api/update_api.go` + `internal/service/update_service.go`
|
||||||
|
- `CheckUpdate()` / `GetCurrentVersion()` / `GetUpdateConfig()` / `SetUpdateConfig()`
|
||||||
|
- `DownloadUpdate()` 异步 + `download-progress` 事件流
|
||||||
|
- `InstallUpdateWithHash()` 哈希校验后安装
|
||||||
|
- 自动检查定时器(`startAutoUpdateCheck` 可重入,配置变更 `resetAutoUpdateCheck` 免重启生效)
|
||||||
|
- 前端:`update-available` 事件驱动弹窗
|
||||||
|
|
||||||
|
**关键差异**:u-desk 是自研(无签名体系);Tauri 2 官方 updater **内建 ed25519 签名校验**,安全性更好,应优先采用官方插件而非照搬自研。
|
||||||
|
|
||||||
|
**另有可复用的健壮性经验**:`swap-install.ps1` 里已验证有效的三件套——
|
||||||
|
1. `Wait-HandleReleased()`:独占打开目标 exe 探测句柄释放(治 Windows 文件锁延迟释放致 NSIS 静默伪成功)
|
||||||
|
2. SHA256 三级判据(等磁盘快照 / 两次确定性输出 / 实变)
|
||||||
|
3. SYSTEM 上下文转投交互用户会话(GUI 才可见)
|
||||||
|
|
||||||
|
这三条应在应用内升级的「安装前等待」阶段复刻(Rust 侧实现等价逻辑)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、方案选型
|
||||||
|
|
||||||
|
### 方案 A:Tauri 官方 `tauri-plugin-updater`(推荐)
|
||||||
|
|
||||||
|
**能力**:`check()` → `Update` 对象(version/notes/date)→ `download_and_install(on_chunk)` → 自动重启。
|
||||||
|
|
||||||
|
**优点**
|
||||||
|
- 内建 **ed25519 签名校验**(防篡改,安全基线)
|
||||||
|
- 跨平台统一(NSIS / MSI / AppImage / dmg)
|
||||||
|
- 与 Tauri 窗口生命周期原生协同(安装前自动关窗)
|
||||||
|
- 官方维护,随 Tauri 2 演进
|
||||||
|
|
||||||
|
**代价**
|
||||||
|
- 需引入签名密钥(`tauri signer generate`,公钥入配置,私钥入打包环境)
|
||||||
|
- 打包需开 `bundle.createUpdaterArtifacts: true`(产出 `.nsis.zip` + `.nsis.zip.sig`)
|
||||||
|
- 端点必须 HTTPS + 清单格式固定(`latest.json`)
|
||||||
|
|
||||||
|
### 方案 B:自研(照搬 u-desk 模式)
|
||||||
|
|
||||||
|
**优点**:零新依赖,可完全复用 `swap-install` 的静默安装动作。
|
||||||
|
|
||||||
|
**缺点**:签名/校验/回滚/进度全自研;`latest.json` 契约要自己定;安全基线靠自己守。
|
||||||
|
|
||||||
|
### 选型结论:**A 为基座,B 的本地动作经验作为"安装前等待"的健壮性补丁喂给 A**
|
||||||
|
|
||||||
|
理由:
|
||||||
|
- 官方插件把「签名校验」这件最容易做错的事做对了,没有理由重造。
|
||||||
|
- devflow 是**联网分发**场景(未来对外),签名是刚需;u-desk 的裸下载在自用场景够,对外不够。
|
||||||
|
- 本地换装通道(脚本)**不废弃**,继续服务开发期高频迭代(它比走远端快得多)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、分层设计
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ L3 发布侧(脚本增强)────────────────────────────┐
|
||||||
|
│ tauri build → 产出 .nsis.zip + .sig │
|
||||||
|
│ → 生成 latest.json(version/notes/pub_date/urls/sig)│
|
||||||
|
│ → 上传 app.1216.top/download/devflow/ │
|
||||||
|
└────────────────────────────────────────────────────┘
|
||||||
|
↑ 拉取
|
||||||
|
┌─ L2 前端 ──────────────────────────────────────────┐
|
||||||
|
│ SettingsNav 新增「关于」分类(第 8 类) │
|
||||||
|
│ AboutPanel.vue:版本号 / 检查更新 / 更新弹窗 / 进度 │
|
||||||
|
└────────────────────────────────────────────────────┘
|
||||||
|
↑ IPC
|
||||||
|
┌─ L1 后端(Rust)───────────────────────────────────┐
|
||||||
|
│ tauri-plugin-updater 注册 + capabilities 授权 │
|
||||||
|
│ IPC: app_get_version / app_check_update / │
|
||||||
|
│ app_download_install_update │
|
||||||
|
│ 本地 KV: df-update-auto-check / -interval-minutes / │
|
||||||
|
│ -last-check / -notified-version │
|
||||||
|
└────────────────────────────────────────────────────┘
|
||||||
|
↑ 单一源
|
||||||
|
┌─ L0 版本治理 ──────────────────────────────────────┐
|
||||||
|
│ 单一版本源 + 设置页展示版本号(零风险,先落) │
|
||||||
|
└────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### L0:版本单一源治理
|
||||||
|
|
||||||
|
**决策**:以 `tauri.conf.json` 的 `version` 为**唯一权威源**(打包器实际使用它)。
|
||||||
|
|
||||||
|
**动作**
|
||||||
|
1. `src-tauri/Cargo.toml` 的 `version` 对齐同名值(人工同步,或加校验脚本);**§2.2-D1 双源问题在此关闭**。
|
||||||
|
2. 新增 IPC `app_get_version`:返回 `env!("CARGO_PKG_VERSION")` + 构建时间 + git short hash(option_env! 注入,未注入显示 `dev`)。
|
||||||
|
3. `swap-install.ps1:8` 改为 glob 探测(**§2.2-D2 在此关闭**):
|
||||||
|
```powershell
|
||||||
|
$installer = (Get-ChildItem 'E:\wk-lab\devflow\target\release\bundle\nsis\devflow_*_x64-setup.exe' |
|
||||||
|
Sort-Object LastWriteTime -Descending | Select-Object -First 1).FullName
|
||||||
|
```
|
||||||
|
4. 设置页「关于」展示版本号(L0 即可交付的可见价值)。
|
||||||
|
|
||||||
|
**扩张性**:未来 `df-im-gateway` 等独立服务走各自版本线(在其自身发布 skill 内治理),本设计不扩范围,但 `app_get_version` 的返回结构预留 `components: []` 数组位。
|
||||||
|
|
||||||
|
### L1:后端接入(插件注册 + 检查)
|
||||||
|
|
||||||
|
**Cargo.toml**
|
||||||
|
```toml
|
||||||
|
tauri-plugin-updater = "2"
|
||||||
|
```
|
||||||
|
|
||||||
|
**lib.rs**(`Builder` 链,插在 notification 之后)
|
||||||
|
```rust
|
||||||
|
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||||
|
```
|
||||||
|
|
||||||
|
**capabilities/default.json** 补
|
||||||
|
```json
|
||||||
|
"updater:default"
|
||||||
|
```
|
||||||
|
|
||||||
|
**tauri.conf.json**
|
||||||
|
```json
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": ["nsis"],
|
||||||
|
"createUpdaterArtifacts": true
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"updater": {
|
||||||
|
"endpoints": ["https://app.1216.top/download/devflow/latest.json"],
|
||||||
|
"pubkey": "<tauri signer generate 产出的公钥>",
|
||||||
|
"windows": { "installMode": "passive" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
> `installMode: passive` —— **不是静默**:弹进度条窗口,用户看得见进度,但无需点击「下一步」,装完自动往下走。
|
||||||
|
> 三档对比:`basic`(完整向导,需点下一步)/ `passive`(进度条,无需点击)/ `quiet`(完全无 UI,**失败不可见**,不推荐)。
|
||||||
|
> 注:`swap-install.ps1` 用的 NSIS `/S` 是真静默(等价 `quiet`),应用内升级刻意不采用——面向用户自己机器的升级需要失败可见性。
|
||||||
|
|
||||||
|
**IPC 契约**(新增 3 个,命名沿用 `app_*` 前缀)
|
||||||
|
|
||||||
|
| 命令 | 入参 | 返回 | 风险档 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `app_get_version` | — | `{version, build_time, git_hash}` | 只读 |
|
||||||
|
| `app_check_update` | — | `{has_update, version?, notes?, date?}` | 只读(外发网络) |
|
||||||
|
| `app_download_install_update` | — | 流式事件 + 成功即重启 | **高风险(需审批)** |
|
||||||
|
|
||||||
|
**配置 KV**(复用既有 appSettings 体系,零新表)
|
||||||
|
- `df-update-auto-check`:bool,默认 `true`
|
||||||
|
- `df-update-check-interval-minutes`:int,默认 `15`
|
||||||
|
- `df-update-last-check`:ISO 时间戳(节流用,防每次启动都拉)
|
||||||
|
- `df-update-notified-version`:string(**同版本只提示一次**,防骚扰)
|
||||||
|
|
||||||
|
**自动检查策略**:启动后延迟 **3s** → 拉清单 → 有更新则发事件 `app-update-available` 到主窗;此后**每 15 分钟**检查一次,但**同一版本号只提示一次**(用户点「稍后」后不再重复弹)。**只提示不自动装**(安装是高风险动作,必须人点头)。
|
||||||
|
|
||||||
|
### L2:前端(设置页「关于」分类)
|
||||||
|
|
||||||
|
**SettingsNav** 加第 8 类:
|
||||||
|
```ts
|
||||||
|
{ value: 'about', icon: 'ℹ️', labelKey: 'settings.navAbout' },
|
||||||
|
```
|
||||||
|
`searchIndex.ts` 补对应条目(`SettingsCategory` 联合类型同步扩 `'about'`)。
|
||||||
|
|
||||||
|
**新增 `src/components/settings/AboutPanel.vue`**(对齐既有 panel 视觉语言)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ 关于 DevFlow ─────────────────────────┐
|
||||||
|
│ 当前版本 v0.1.2 (a1b2c3d) [检查更新] │
|
||||||
|
│ 构建时间 2026-10-09 14:30 │
|
||||||
|
│ ─────────────────────────────────────── │
|
||||||
|
│ ☑ 启动时自动检查更新(启动后 3 秒) │
|
||||||
|
│ 检查间隔 [15 分钟 ▾] │
|
||||||
|
│ ─────────────────────────────────────── │
|
||||||
|
│ 上次检查 2026-10-09 14:35 │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**更新弹窗**(复用既有 ConfirmDialog 视觉,含:新版本号 / 更新说明 markdown / 下载进度条 / 「立即更新并重启」「稍后」)
|
||||||
|
|
||||||
|
**事件订阅**(对齐既有 `useAiEvents` 模式)
|
||||||
|
- `app-update-available` → 顶栏小红点 + 弹窗
|
||||||
|
- `app-update-progress` → 进度条(`downloaded` / `total` / 百分比)
|
||||||
|
|
||||||
|
**i18n**:zh-CN / en 双语同步补 `settings.navAbout` / `settings.about*` / `update.*` 系列键。
|
||||||
|
|
||||||
|
### L3:发布侧脚本增强
|
||||||
|
|
||||||
|
新增 `scripts/publish-release.ps1`(或扩展 `package-devflow.ps1` 加 `-Mode release` 通道):
|
||||||
|
|
||||||
|
1. 前置:校验 `tauri.conf.json.version` 与 `Cargo.toml.version` 一致(防 D1 复发)
|
||||||
|
2. tauri build(含 createUpdaterArtifacts → .nsis.zip + .sig)
|
||||||
|
3. 计算 sha256 + 读取 .sig 内容
|
||||||
|
4. 生成 latest.json:
|
||||||
|
{
|
||||||
|
"version": "0.1.2",
|
||||||
|
"notes": "<从 docs/CHANGELOG.md 提取本节>",
|
||||||
|
"pub_date": "2026-10-09T14:30:00Z",
|
||||||
|
"platforms": {
|
||||||
|
"windows-x86_64": {
|
||||||
|
"signature": "<.sig 内容>",
|
||||||
|
"url": "https://app.1216.top/download/devflow/devflow_0.1.2_x64-setup.nsis.zip"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
5. 上传 latest.json + .nsis.zip + .sig + 裸 exe 到 **app.1216.top** `download/devflow/`(走 publish-app-site 通道:suke-dev / `/var/www/app-site/`)
|
||||||
|
6. `app.1216.top` 的 JSON 已配 `Cache-Control: no-cache` → **无需刷 CDN**,发布即生效
|
||||||
|
```
|
||||||
|
|
||||||
|
**密钥管理**
|
||||||
|
- 一次性:`tauri signer generate -w ~/.tauri/devflow.key` → 公钥入 `tauri.conf.json` 的 `pubkey`
|
||||||
|
- 打包时设环境变量 `TAURI_SIGNING_PRIVATE_KEY`(路径)+ `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`
|
||||||
|
- **私钥绝不入仓**(`.gitignore` 补 `*.key`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、兼容性核验
|
||||||
|
|
||||||
|
| 既有资产 | 影响 | 结论 |
|
||||||
|
|---|---|---|
|
||||||
|
| `scripts/package-devflow.ps1` | 不动 verify/full 两通道,L3 新增独立通道 | ✔ 零回归 |
|
||||||
|
| `swap-install.ps1` | 仅改第 8 行硬编码 → glob(D2 修复) | ✔ 行为等价,更健壮 |
|
||||||
|
| `lib.rs` Builder 链 | 追加一个 `.plugin()`,不触碰既有 5 个插件与 68 IPC | ✔ 零侵入 |
|
||||||
|
| `capabilities/default.json` | 追加一行 `updater:default` | ✔ 纯增量 |
|
||||||
|
| SettingsNav 7 类 | 追加第 8 类,搜索/防抖/持久化逻辑自动覆盖(`SETTINGS_INDEX` 驱动) | ✔ 架构自洽 |
|
||||||
|
| appSettings KV 体系 | 新增 4 个 key,复用既有读写 | ✔ 零新表零迁移 |
|
||||||
|
| `tauri.conf.json` targets | 保持 `["nsis"]`,只加 `createUpdaterArtifacts` | ✔ 不扩大构建目标(对齐知识库「targets 收窄需还原」的教训:本项是新增字段非收窄,安全) |
|
||||||
|
| devflow.db / devflow-dev.db | L1/L2 只读 KV,不涉 schema | ✔ 零库变更 |
|
||||||
|
|
||||||
|
**dex 依赖**:无。**P0 依赖**:无(可立即开工)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、实施批次(全部可独立交付)
|
||||||
|
|
||||||
|
| 批次 | 规模 | 内容 | 验收 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **B1** | S | L0 版本治理:Cargo.toml 对齐 0.1.1 / `app_get_version` IPC / swap-install glob 修复 / 设置页版本号展示 | `cargo check -p devflow` 0 error;设置页可见 `v0.1.1`;换装脚本升版后仍能正确取包 |
|
||||||
|
| **B2** | M | L1 插件接入 + `app_check_update` IPC + 自动检查节流 + `app-update-available` 事件 | 手动改远端 `latest.json` 版本号 → 应用内提示可用 |
|
||||||
|
| **B3** | M | L2 下载安装:进度事件 + 更新弹窗 + `app_download_install_update` + 重启 | 端到端:旧版 → 提示 → 下载 → 装 → 重启后版本号变新 |
|
||||||
|
| **B4** | S | L3 发布脚本 + 密钥生成 + latest.json 生成上传 | 一条命令产出并上传可分发的更新清单(app.1216.top 无需刷缓存) |
|
||||||
|
|
||||||
|
**建议序**:B1 立即做(零风险、当天可交付、消除 D1/D2 两个真实缺陷);B2→B3 一起做(拆开无独立价值);B4 在首次真实发版前完成即可。
|
||||||
|
|
||||||
|
**总估算**:B1 0.5 天 / B2+B3 1.5-2 天 / B4 0.5 天 ≈ **2.5-3 天**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、风险与边界
|
||||||
|
|
||||||
|
| 风险 | 等级 | 对策 |
|
||||||
|
|---|---|---|
|
||||||
|
| 私钥泄露 | **高** | `.gitignore` 拦 `*.key`;仅打包机存私钥;公钥入仓无妨 |
|
||||||
|
| ~~CDN 缓存陈旧致拉了旧清单~~ | ~~中~~ **已消解** | C1 改走 `app.1216.top`(已配 `Cache-Control: no-cache`),无缓存陈旧问题;可选 `?t=<timestamp>` 兜底 |
|
||||||
|
| 更新包下载中断 | 中 | 插件内建重试;失败保留旧版不破坏现状 |
|
||||||
|
| 装完起不来(坏包) | 中 | `installMode: passive` 失败可见;保留上一版安装包以备手回滚 |
|
||||||
|
| 自动检查打扰 | 低 | 15 分钟周期 + **同版本只提示一次** + 只提示不自动装 + 可关 |
|
||||||
|
| 版本双源复发 | 低 | B1 加校验(发布脚本第 1 步)+ 可选 `build.rs` 断言 |
|
||||||
|
|
||||||
|
**明确不做(本设计范围外)**
|
||||||
|
- 增量更新(delta update)——体量收益不值复杂度
|
||||||
|
- 灰度/分渠道发布
|
||||||
|
- 非主应用组件(`df-im-gateway` 等)的版本线
|
||||||
|
- 回滚 UI(保留手动重装路径即可)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、决策点(✅ 2026-10-09 拍板定稿)
|
||||||
|
|
||||||
|
| # | 议题 | ✅ 定稿 | 依据 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **C1** | 清单托管位 | **`app.1216.top/download/devflow/`** | 产品门户语义正确;该站已配 `Cache-Control: no-cache`,发布即生效,无需刷 CDN;通道(suke-dev / `/var/www/app-site/`)现成 |
|
||||||
|
| **C2** | 安装模式 | **`passive`**(进度条窗口,无需点击) | 非静默;`quiet` 完全无感但失败不可见(静默伪成功风险) |
|
||||||
|
| **C3** | B1 是否先行 | **立即开工** | 零风险、当天交付,消 D1/D2 两个真实缺陷 |
|
||||||
|
| **C4** | 自动检查节奏 | **启动后 3s → 周期 15 分钟** + 同版本单次提示 + 关于页手动按钮 | 用户定:3s / 15min |
|
||||||
|
|
||||||
|
### C1 补充决策:双产物并存(站点规范 vs Tauri 强制格式)
|
||||||
|
|
||||||
|
app-site 站规为「不打 zip,直接裸 exe」,而 Tauri updater **强制** `.nsis.zip` + `.sig`。故 `download/devflow/` 并存四项:
|
||||||
|
|
||||||
|
| 文件 | 给谁 | 合规 |
|
||||||
|
|---|---|---|
|
||||||
|
| `devflow_<ver>_x64-setup.exe` | 手动下载的用户 | ✅ 站点「裸 exe」规范 |
|
||||||
|
| `devflow_<ver>_x64-setup.nsis.zip` | updater 拉取 | ✅ Tauri 格式强制 |
|
||||||
|
| `devflow_<ver>_x64-setup.nsis.zip.sig` | updater 验签 | ✅ |
|
||||||
|
| `latest.json` | updater 清单 | ✅ |
|
||||||
|
|
||||||
|
### C4 补充决策:体验设计「环境式更新」
|
||||||
|
|
||||||
|
**核心原则**:检查频率 ≠ 体验。体验由「提醒方式 / 去重 / 过程可见 / 时机可控」四件事决定。
|
||||||
|
|
||||||
|
| 触点 | ✅ 定稿 |
|
||||||
|
|---|---|
|
||||||
|
| 平时 | 完全安静,零存在感 |
|
||||||
|
| 发现新版 | **顶栏非模态徽章**(不弹窗打断) |
|
||||||
|
| 查看详情 | 点徽章展开:版本号 + 更新说明 + 包大小 |
|
||||||
|
| 决定 | 「立即升级」/「稍后」双选(**不自动下载安装**) |
|
||||||
|
| 下载 | 进度条 + 可取消 |
|
||||||
|
| 安装 | `passive` 进度窗口,无需点击 |
|
||||||
|
| 完成 | 重启后 toast「已升级到 vX.Y.Z」 |
|
||||||
|
| 反复 | **同版本只提示一次**(KV `df-update-notified-version`) |
|
||||||
|
|
||||||
|
**为什么必须是「环境式」而非「隐形型(全自动)」**:DevFlow 是**有状态工作工具**(可能正跑长任务/开着会话),任何自动重启都会打断工作。时机控制权必须交给用户。
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# 知识库模型重审 — 精细化设计(用例法+推演法)
|
||||||
|
|
||||||
|
> 2026-10-05 AI-Ops 分析移交。背景:C-m3(蒸馏衔接知识库)实施前,重审知识库模型能否承载 R4(防错误反哺)/R7(代谢进化)/R8(统一条目)职责。
|
||||||
|
> 现状实证(devflow.db,knowledges 1073 条 / knowledge_events 2221 条):
|
||||||
|
> - 字面厚实:confidence/reuse_count/verified/source_ref/embedding/kind 字段全有,update/archive/record_reuse/extract_now 命令齐备
|
||||||
|
> - 实际停滞:verified 仅 23 条(2%);confidence 87% high(置信通胀,自动提取默认 high 无评估);无淘汰态在用;status_changed 仅 31 次(流转冻结);reuse>0 仅 20%;向量 0/1073(**设计按无向量走,用户 10-05 拍板**:不依赖、不维护、不承诺,embedding 字段视为遗留)
|
||||||
|
> - 结论:骨架在,循环断。三条断链:蒸馏→查重→更新 断;注入→采纳→验证 断;时间→过时→淘汰 断。
|
||||||
|
|
||||||
|
## 一、用例走查(六用例,标注断点)
|
||||||
|
|
||||||
|
### UC1 蒸馏入库(会话收官 → 知识条目)
|
||||||
|
目标流程:触发蒸馏 → 查重(关键词+标签+kind 主题) → 命中相似则 update(合并/增强/置信重估) → 未命中则 create candidate。
|
||||||
|
现状断点:①查重只有关键词,同义异词查不出 → 同主题碎片堆积(kind=architecture_pattern 已 322 条)——缓解靠标签规范与 kind 主题分簇(设计基准无向量,不预留向量后路);②提取默认 confidence=high → 通胀;③查重命中后的 update 合并路径不存在(extract_now 只新增)。
|
||||||
|
|
||||||
|
### UC2 检索注入(新会话 → 知识反哺)
|
||||||
|
目标流程:用户消息 → 查询提取(关键词) → 候选 → **准入过滤(R4 门槛)** → 注入 → record_reuse。
|
||||||
|
现状断点:①召回靠关键词,召回质量以标签规范与 kind 分簇保障;②准入门槛缺失——verified 仅 2%,强制则无可注入,不强制则错误知识直通 → 需**分级准入**:candidate 不注入;published 且(verified 或 reuse≥N)才可注入。
|
||||||
|
已具备:record_reuse 有(216 条被引用),查询关键词提取(CJK bigram)在。
|
||||||
|
|
||||||
|
### UC3 验证升格(candidate → verified / 打回)
|
||||||
|
目标流程:注入后被实际采纳 → 采纳计数 → 达标升 verified;被用户纠正/结果证伪 → 降级打回。
|
||||||
|
现状断点:验证只有手工通道(23 条);「注入→采纳→升格」自动链不存在;用户纠正信号无回写条目的通道。
|
||||||
|
设计:events 增加 adopted(采纳)/contradicted(反驳)事件类型;adopted 计数达阈值自动升 verified;contradicted 触发降级+复核标记。
|
||||||
|
|
||||||
|
### UC4 代谢(过时/重复/腐化)
|
||||||
|
目标流程:周期或触发式 → 过时检测 → 重复聚类合并 → 久未用沉降(archive) → 被证伪淘汰。
|
||||||
|
现状断点:archive 命令在但零使用;无周期任务;无聚类合并;status 流转冻结。
|
||||||
|
设计:代谢三合一任务(手动命令先行,稳后挂定时):①候选→发布超时未验证的沉降;②同簇高相似聚类提示合并;③久未 referenced 的沉降。
|
||||||
|
|
||||||
|
### UC5 溯源找回(条目 → 原始件)
|
||||||
|
流程:source_ref → 原始消息(压缩后原文仍在库,可溯)/ 外置件(C-m3 落地后句柄)。
|
||||||
|
现状:source_ref 字段有值但可溯性未验证;C-m3 外置区落地后需统一句柄(R8)。
|
||||||
|
|
||||||
|
### UC6 跨会话/跨 agent 归属
|
||||||
|
推演:多 agent 场景知识需分 scope。现状仅 source_project 单字段。设计:加 scope(会话/项目/全局)层级字段,注入按 scope 过滤。
|
||||||
|
|
||||||
|
## 二、极端推演(五场景)
|
||||||
|
|
||||||
|
- E1 知识冲突:同主题两条矛盾(旧 pitfall 与新结论相反)。设计:kind+主题聚类为冲突簇,新条目入簇时触发旧条目复核标记,人工/LLM 裁决后 superseded_by 指针淘汰败方。
|
||||||
|
- E2 知识爆炸:万条量级下关键词检索劣化、注入筛选贵。前提:注入配额 top-k 上限;kind 分簇检索;标签规范。(万条量级仍走关键词+分簇,无向量前提)
|
||||||
|
- E3 知识过时:框架升级后旧 pitfall 失效仍被注入。设计:条目加 applicability(适用条件/版本范围),环境变更触发复核。
|
||||||
|
- E4 置信通胀:87% high 已失去区分度。设计:置信由证据推导——自动提取默认 medium;high 需(verified 或 reuse≥N 且无反驳);杜绝「无证据 high」。
|
||||||
|
- E5 恶性循环:低质会话蒸馏出低质知识再反哺。设计:蒸馏来源带质量权重(采纳率高的会话产出才可 high),与 R4 门槛联动。
|
||||||
|
|
||||||
|
## 三、模型与流程增量(在现有表上补,不推翻)
|
||||||
|
|
||||||
|
1. **检索主路线 = 关键词,设计基准无向量(用户 10-05 拍板)**:查重与召回以现有关键词(CJK bigram)+ 标签 + kind 主题分簇实现并持续优化(标签规范、分簇细分、查询扩展);不做向量预留,embedding 相关字段与 spawn_embedding 管道视为遗留物,不维护不扩展。
|
||||||
|
2. knowledges 补字段:scope、applicability(适用条件)、superseded_by(淘汰指针)、conflict_group(冲突簇)。
|
||||||
|
3. 置信规则化:自动提取默认 medium;high 需证据(verified 或 reuse≥N 且无反驳)——治通胀(E4)。
|
||||||
|
4. 事件扩型:events 增 adopted/contradicted;采纳计数驱动 UC3 升格,反驳驱动降级。
|
||||||
|
5. 准入分级(R4):candidate 不注入;可注入 = published 且(verified 或 reuse≥N 且非 contradict 状态)。
|
||||||
|
6. 蒸馏查重走 update 路径(R7 入口):相似度阈值命中 → 合并/增强/置信重估,只增不改的旧路径退役。
|
||||||
|
7. 代谢任务(UC4):三合一,手动命令先行。
|
||||||
|
8. 蒸馏来源质量权重(E5)。
|
||||||
|
|
||||||
|
## 四、实施序与验收
|
||||||
|
|
||||||
|
| 序 | 项 | 验收 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | 准入分级(R4) | candidate 零注入;注入列表 100% 满足门槛 |
|
||||||
|
| 2 | 蒸馏查重→update 路径(R7,关键词+标签+分簇) | 同主题条目数下降;新条目重复率 <5% |
|
||||||
|
| 3 | 置信重估(E4) | high 占比 <60% 且与 verified/reuse 正相关 |
|
||||||
|
| 4 | 代谢任务(UC4) | 沉降/淘汰有产出;status 流转事件 >收编前 10 倍 |
|
||||||
|
| 5 | 冲突簇+时效(E1/E3) | 矛盾对可被检出并裁决 |
|
||||||
|
|
||||||
|
> 关联:R4(准入)/R7(代谢)/R8(统一条目,本设计字段为 knowledge 侧投影)/C-m3(蒸馏衔接,依赖 1-3 项完成后可开 auto_inject)。
|
||||||
|
> 数据基线快照(2026-10-05):1073 条 / 向量 0 / verified 23 / high 935 / reuse>0 216 / status_changed 31——改进后对照。
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
# 跨会话召回四层架构 — 设计
|
||||||
|
|
||||||
|
> 日期: 2026-10-11 | 状态: 设计稿(待拍板) | 勘察: 只读实测锚点
|
||||||
|
> 决策背景: 用户拍板决策单 C1 走 B 重量路线(对齐 u-claw 分身养成层四层架构), 推翻原「先 A 后 B」建议(见 aichat上下文管理-派工与决策单-2026-09-29.md C1)。
|
||||||
|
> 前序文档: 跨会话记忆四层架构-设计-2026-09-29.md(首版四层稿, W1-W4 批次均未实施)与跨会话召回最小闭环-设计-2026-10-04.md(已拍板并落地, 构成本设计的存量底座)。本文档是 09-29 首版按 2026-10-11 现状的重勘与收编, 实施以本文档为准。
|
||||||
|
> 铁律(继承 u-claw, 09-29 首版已立): **跨会话召回内容严禁进 system prompt 与常驻组装段, 只走工具按需查**。工具描述可进 system(静态文本, 属稳定缓存前缀), 召回内容不进。u-claw 原案教训: 频繁变更的注入毁 provider 前缀缓存。
|
||||||
|
> 勘察基线: 2026-10-11 工作区。crates/df-ai 的 context/mod.rs 与 context_helpers.rs 有并行代理在改, 引用行号实施前须复核。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、四层口径与 u-claw 的对齐
|
||||||
|
|
||||||
|
u-claw 分身养成层原案按数据流阶段分层(捕获/精炼/召回/验证, 见 memory project-uclaw-avatar-layers); devflow 侧 09-29 首版起按**记忆的时间半径**分层(轮内/跨轮/跨会话/跨重启), 本设计沿用时间半径口径, 理由是它与 devflow 现有基建一一对应, 逐层可独立验收。两套口径的语义交点在第三层: u-claw 的「召回严禁进 system prompt」铁律原样继承, 并在 devflow 语境下收紧(u-claw 原案保留了低频 CLAUDE.md 摘要注入通道, rollover 级更新保缓存; devflow 侧跨会话召回不做任何形式的自动注入, 连低频摘要注入也不要, 只走工具)。
|
||||||
|
|
||||||
|
| u-claw 阶段口径 | devflow 时间半径口径 | 对应关系 |
|
||||||
|
|---|---|---|
|
||||||
|
| L1 捕获(hooks 零延迟) | L1 轮内 | 轮内上下文采集与组装 |
|
||||||
|
| L2 精炼(信号过滤+小模型) | L2 跨轮 | 压缩精炼+WorkingContext 回填 |
|
||||||
|
| L3 召回(严禁进 system prompt) | L3 跨会话 | 本设计主体 |
|
||||||
|
| L4 验证(观察项) | L4 跨重启 | 目标快照+恢复语义(验证语义落在恢复续接正确性) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、四层在 devflow 的映射(存储/写入/读取/生命周期/现状)
|
||||||
|
|
||||||
|
### 2.1 L1 轮内(现状已建, 本设计不动)
|
||||||
|
|
||||||
|
- 存储载体: per_conv 内存(`ContextManager.messages`, `crates/df-ai/src/context/mod.rs`)+ `ai_conversations.messages` 落库。
|
||||||
|
- 写入时机: 每轮 push, 大工具结果引用化入账(`src-tauri/src/commands/ai/audit/mod.rs:1184-1191`)。
|
||||||
|
- 读取通道: 每轮组装, 滑窗 `build_for_request`(`context/mod.rs:185`)+ system 段 `build_runtime_prompt`(`agentic/mod.rs:3042`)。
|
||||||
|
- 生命周期: 会话级, 随会话存亡。
|
||||||
|
- 现状标注: 分层组装器方案(上下文分层组装器-落地方案-2026-10-10.md)管辖此层与组装结构, 本设计零改动。
|
||||||
|
|
||||||
|
### 2.2 L2 跨轮(现状已建, 2026-10-11 新增压缩存档, 本设计不动)
|
||||||
|
|
||||||
|
- 存储载体: 内存视图(compressed 状态标记/淘汰摘要)+ 压缩摘要锚点(system anchor 尾部追加, `agentic/context_lifecycle.rs:335`)+ **压缩原文存档**(`<data_dir>/context-archive/<conv_id>-archive.jsonl` 追加写, `crates/df-ai/src/context/archive.rs:35-39`)。
|
||||||
|
- 写入时机: 压缩水位 `available*6/10 < history_tokens` 触发(`context_lifecycle.rs:133-135`), 后台 LLM 产 nl_summary+json_card; WorkingContext 写入链 `begin_turn`(`commands/chat.rs:950`、`:2444`)与 `sync_after_compress`(`context_lifecycle.rs:337`); 存档追加写(`archive.rs:44`), 开关 `df-ai-compress-recall` 默认开(`commands/ai/prompt.rs:709-718`)。
|
||||||
|
- 读取通道: 摘要常驻锚点(会话内每轮可见); 原文经摘要尾部召回句柄行 read_file 按需回读(`archive.rs:72-74`)。
|
||||||
|
- 生命周期: 会话级; 存档文件跨重启仍在, 会话删除级联清理是已记录缺口(`archive.rs:7` 注释自证「留待后续挂点」)。
|
||||||
|
- 现状标注: 压缩可召回 2026-10-11 刚落地(工作区未提交); 记忆卡四类卡(C-m1, 上下文三层记忆-设计-2026-10-04.md)设计已立未实施, 落地后是 L2 的结构化产物。
|
||||||
|
|
||||||
|
### 2.3 L3 跨会话(本设计主体)
|
||||||
|
|
||||||
|
**存量底座(A 路线已落地部分, 2026-10-11 实测)**:
|
||||||
|
|
||||||
|
- 写入: 自动提炼 `maybe_spawn_extraction`(`commands/ai/knowledge_inject.rs:660`)挂在正常收敛点(`agentic/mod.rs:1636`, save 之后), 水位制防重(`EXTRACT_WATERMARKS` :70, 新增 10 条用户消息起提 :52, 提炼窗口 40 条 :48), 产 candidate 带 source_project 弱推断归属(:1180、:1212)与 source_ref 会话级溯源(:1216-1219); `knowledge_insert` 工具(`commands/ai/tools/knowledge.rs:95`, 固定 candidate, 但 handler 无会话上下文拿不到 conv_id, source_ref 记 "ai-chat" 渠道兜底, :13-16 注释自证)。
|
||||||
|
- 读取: 注入侧 `auto_inject` top-3 带项目软排序(`knowledge_inject.rs:520-521`, 检索走 `search_keywords_scoped`, `crates/df-storage/src/crud/idea_repo.rs:834`, 同项目第一档/全局第二档/异项目第三档); 工具侧 `knowledge_search`(`tools/knowledge.rs:51`, 仅 published, 分词检索, 单条 content 截 500 字 :41, 空结果带可执行指引 :72-77)。
|
||||||
|
- 把关: `knowledge_promote` 显式转正工具(`tools/knowledge.rs:165`), 复用达标自动晋升(默认关, `knowledge_inject.rs:549-565`), 生命线留痕(KnowledgeTimeline)。
|
||||||
|
|
||||||
|
**缺口(本设计补齐)**: 会话终态提炼(删除/停靠时点无提炼挂点)、工具写入的归属补全(缺会话上下文注入机制)、工具读取的项目维度(knowledge_search 未接 scoped 检索)、用户侧脉络卡通道。详见第三节。
|
||||||
|
|
||||||
|
### 2.4 L4 跨重启(持久化面盘点)
|
||||||
|
|
||||||
|
**已有**:
|
||||||
|
|
||||||
|
- 消息与上下文重建: messages 落库 + `restore_from_messages`(`context/mod.rs:310` 附近)。
|
||||||
|
- 目标钉扎持久化(G1 已落地): pinned_goals 每轮 save 落库(`commands/ai/conversation.rs:466-468`), 会话载入恢复(`commands/conversation.rs:492-495`)。
|
||||||
|
- 任务边界: boundary_json(V45, `crates/df-storage/src/migrations.rs:1366`)。
|
||||||
|
- 断点续跑(V49): loop_state 列(`migrations.rs:1446`), 启动扫描 running 改 parked(`src/state.rs:455-460`), 恢复链 `ai_list_recoverable`/`ai_recover_parked`/`recover_parked_inner`(`commands/conversation.rs:292-337`), 前端 G5 可恢复分组(`src/components/ai/ConversationSidebar.vue:208`), B5 无人值守引擎自动恢复优先于领单(`agentic/unattended.rs:1017-1018`)。
|
||||||
|
- 收敛快照事件: AiCompleted 透传 pinned_goals_snapshot(`agentic/mod.rs:1663`)。
|
||||||
|
|
||||||
|
**缺口(本设计补齐, 详见第四节)**: WorkingContext 不持久化(df-storage 全仓零引用, 重启后 goals/decisions/unresolved 全丢, 压缩重锚定与目标钉扎跨重启断裂); namespace_store 重启清空(`agentic/mod.rs:1975` 注释自证「重启后 namespace 清空」, 懒重建未做); 目标快照槽语义缺失(看板 D4 挂账); 压缩存档文件无删除级联。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、L3 主体设计
|
||||||
|
|
||||||
|
### 3.1 写入侧: 三通道加一挂点
|
||||||
|
|
||||||
|
| 通道 | 时机 | 状态 |
|
||||||
|
|---|---|---|
|
||||||
|
| 自动提炼 | 每轮正常收敛后, 水位制(10 条新用户消息起提) | 已落地 |
|
||||||
|
| 工具沉淀 | 用户说「记住/记一下」时 agent 调 knowledge_insert | 已落地, 归属待补 |
|
||||||
|
| 显式转正 | 用户认可时 knowledge_promote | 已落地 |
|
||||||
|
| **会话终态提炼(新增)** | 会话删除前 + parked 停靠时 | 本设计新增 |
|
||||||
|
|
||||||
|
**终态提炼设计**:
|
||||||
|
|
||||||
|
- 触发点两处: ① `ai_conversation_delete`(`commands/conversation.rs:854`)删除动作前, 后台 fire-and-forget 执行, 提炼失败或未完成不阻塞删除(删除是用户显式意图, 延迟它属于体验倒退; 未及落库的条目随会话消失是可接受损失, 与「历史数据默认保留」不冲突, 因候选未及产生即无产物); ② `emit_task_parked` 停靠三件套处(`agentic/mod.rs:1464-1477`), 停靠意味着任务未完结, 此刻的目标/决策/未解项最有提炼价值。手动触发已有(`trigger_extraction_now`, `knowledge_inject.rs:819`), 不重复建。
|
||||||
|
- 强制清水位: 复用 `trigger_extraction_now` 的清哨兵语义(:827-828), 终态提炼不受「10 条新用户消息」水位限制, 短会话有实质产出也应提炼一次; 防重仍走 InFlight 水位, 与可能在飞的常规提炼互斥。
|
||||||
|
- 提炼范围: 常规提炼只看尾部 40 条窗口(:48); 终态提炼取全量 active 消息, 并把 WorkingContext 的 decisions/unresolved 一并喂给提炼提示词(此刻 working_context 在内存中是最新态), 让「本会话定了什么、卡在哪」成为提炼输入而非仅靠消息流重放。
|
||||||
|
- **提炼什么与不提炼什么的边界**: 提炼产出限「跨会话可复用的经验」: 决策与依据、事实发现(环境约束/结构/坑)、偏好与约定、踩坑。**未完成事项不进知识表**: 它是会话态不是知识, 归 L4 目标快照与停靠说明承接(与根治设计分工矩阵一致: knowledge 是高蒸馏沉淀, pinned_goals/任务态是实时意图, `docs/02-架构设计/上下文根治设计-20261006.md` 第十节)。此边界修正 09-29 首版「未完成事项提炼进知识库」的含糊表述。
|
||||||
|
- 工具写入归属补全: knowledge_insert 拿不到 conv_id 的根因是工具 handler 只有 args 入参(`tools/knowledge.rs:13-16`)。补法: 工具注册或执行侧传入当前活跃会话 id(与 R1 批的会话上下文注入机制同一件基建, 见 3.3), insert 前 `resolve_source_project`(`knowledge_inject.rs:948`)算归属, source_ref 记 `conv:{id}`, 与自动提炼产物同格式。
|
||||||
|
|
||||||
|
### 3.2 存储选型: 复用 knowledge 表, 零 schema 变更
|
||||||
|
|
||||||
|
| 选项 | 评价 |
|
||||||
|
|---|---|
|
||||||
|
| **A 复用 knowledges 表(推荐)** | 检索/复用计数/晋升/生命线/归属/溯源全套机制已在; kind 七种语义(踩坑/架构模式/诊断/评审规则等, `migrations.rs:1828-1830`)覆盖决策与事实类产出; 零迁移零新面 |
|
||||||
|
| B knowledges 补 scope 层级字段 | 知识库模型重审(2026-10-05)UC6 提案(会话/项目/全局); 触发制后置, 单项目桌面场景 source_project 已够, 宁少勿预留 |
|
||||||
|
| C 新建 session_memories 表 | 与 knowledges 的检索/晋升/代谢/留痕重复建设, 双轨违背 DRY; 09-29 首版未选, 本版明确否决 |
|
||||||
|
| D 复用压缩存档 JSONL 当跨会话记忆 | 存档是原文不是蒸馏产物, 无结构化元数据, 检索需全文件扫描; 根治设计第八节定位明确: 知识条目是晋升终点, 原文页池是全程。存档维持现状(会话内 read_file 召回, 已落地), 不升级为 L3 存储 |
|
||||||
|
|
||||||
|
**推荐 A**。与根治设计晋升单向门一致: 会话内页(压缩存档, 原始)到记忆卡(会话内, 中蒸馏, C-m1)到 knowledge 条目(跨会话, 高蒸馏)。L3 只收蒸馏产物, 原料留在 L2 侧。
|
||||||
|
|
||||||
|
### 3.3 读取侧: 只走工具按需查
|
||||||
|
|
||||||
|
**铁律的两层精确表述**(消解与组装器方案的表面冲突):
|
||||||
|
|
||||||
|
- 跨会话层(本设计): 召回内容一律走工具, 无任何自动注入通道。prompt 总 token 不随历史会话数增长(09-29 首版 W3 验收口径沿用)。
|
||||||
|
- 会话内层(组装器管辖): 分层组装器 P2.3 的 recall 段(会话内窗口外页/记忆卡)落发送视图尾部 user 角色、带配额闸, 不进 system 段, 与铁律字面一致; 其 v1 半径拍板已锁「仅当前会话」(组装器方案拍板点 4 选 A), 跨会话被明确延后到本设计, 且本设计选择工具通道而非注入通道, 比组装器的会话内置尾注入更保守。两文档无冲突: 组装器治会话内, 本设计治跨会话, 共用 `knowledge_inject` 检索面。
|
||||||
|
|
||||||
|
**工具语义(不新造 recall_memory, 强化学员 knowledge_search)**:
|
||||||
|
|
||||||
|
09-29 首版提案的 recall_memory 专用工具与已落地的 knowledge_search 语义重合(查「之前怎么解决的/有没有记过」, `tools/knowledge.rs:55` 描述已含使用时机引导), 再造一个违背 DRY。本设计对 knowledge_search 做两点强化:
|
||||||
|
|
||||||
|
1. 项目软排序接入: 检索改走 `search_keywords_scoped`, project_id 由调用时上下文解析(见下条)。同项目经验排前, 异项目与全局知识仍可召回不淹没(三档排序已在 df-storage 侧就绪, `idea_repo.rs:834-880`, 零新存储逻辑)。
|
||||||
|
2. 会话上下文注入机制: 工具 handler 现拿不到 conv_id(`tools/knowledge.rs:13-16` 自证), 需要在工具执行侧补一个「当前会话 id」的注入通道(注册时捕获或执行时从 conv_states 解析)。这是本设计唯一的新基建件, 同时服务 3.1 的写入归属与 3.3 的读取排序, 一件基建两处收益。
|
||||||
|
|
||||||
|
**引导注入方式**: 靠工具描述(静态, 随 system 进稳定缓存前缀, 现有描述已含使用时机)与空结果指引(:72-77 已有), **不新增每轮注入文字**。易变尾段是每轮变化区, 放常量引导既占 token 又无增量价值。
|
||||||
|
|
||||||
|
**注入侧与工具侧分工**: 注入侧(`auto_inject`, top-3, 项目软排序已接)是「高蒸馏 published 知识的被动注入」, 服务「agent 不知道自己不知道」的场景; 工具侧是「agent 主动按需查」, 服务明确的历史问题。两者互补且都已存在, 本设计不扩大注入剂量(top-3 配额不动)。
|
||||||
|
|
||||||
|
**脉络卡(用户侧辅助通道)**: 新会话或会话列表项上, 按 source_project 聚合展示同项目近期会话的决策/结论摘要(数据源: knowledge 表按 source_ref 反查会话 + ai_conversations 元数据, 纯前端读侧)。用户点「带入上下文」才转为显式 @提及注入。非 LLM 注入, 不触碰铁律; 把「要不要带历史」的决策权交给用户, 与 bot 决策接口哲学(用户只做决策)一致。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、L4 与目标快照: 重启恢复还缺什么
|
||||||
|
|
||||||
|
现有 parked 续跑链(V49 扫描、ai_recover_parked、B5 引擎自动恢复)解决的是「loop 死了能接着跑」; 缺的是「接着跑时脑子里还带着之前的记忆与最终意图」。三个缺口:
|
||||||
|
|
||||||
|
**缺口 1: WorkingContext 持久化(最重)**。重启后 goals/decisions/unresolved 全丢, 压缩重锚定(sync_after_compress 回填的决策)与目标钉扎跨重启断裂, 恢复的会话等于半失忆续跑。设计:
|
||||||
|
|
||||||
|
- 载体: `ai_conversations` 加 `working_context_json` 列(V50, ALTER TABLE 可逆, 与 boundary_json V45/loop_state V49 同款模式)。
|
||||||
|
- 写入时机: `save_conversation` 每轮落库点, 与 pinned_goals 同段序列化 `conv.working_context`(`commands/ai/conversation.rs:466` 附近)。
|
||||||
|
- 恢复时机: 会话载入(commands/conversation.rs:492 恢复 pinned_goals 的同段)与 `recover_parked_inner` 反序列化回 per_conv。
|
||||||
|
- 不选 settings KV 单键: 全会话映射挤一个键, 清理随会话删除无法级联, 单点膨胀。
|
||||||
|
|
||||||
|
**缺口 2: 目标快照槽(对齐 u-claw goal.go L1/L2/L3 语义, 看板 D4 并入本设计)**。现状恢复语义是注入恢复引导消息重走 spawn 正典链, 没有「按最终有效意图续, 不重放历史目标流」的快照裁决。设计:
|
||||||
|
|
||||||
|
- 快照内容: Active 目标(WorkingContext.goals)+ pinned_goals + unresolved + updated_at + source。**不另立存储**, 就是 working_context_json 列的语义化读取(单一事实源, 宁少勿预留), pinned_goals 已有独立列继续沿用。
|
||||||
|
- 恢复语义: 恢复/续接时按快照对齐当前有效意图, 中途被调整作废的目标不复活; 停靠卡与恢复引导消息由快照生成, 防止「按旧目标继续跑偏」(D4 原始动机)。
|
||||||
|
- 验收沿用 09-29 首版 W4 口径: 会话 A 调整目标后重启, 续接时只认最终目标。
|
||||||
|
|
||||||
|
**缺口 3: namespace 懒重建**。重启后 namespace 引用只读得到淘汰占位(`agentic/mod.rs:1975`), 而 DB messages 存的是展开后原文(namespace_store 侧既有约定)。设计: per_conv 重建时从 messages 原文回填 store(懒: 首次展开命中该引用时回填, 避免载入即全量扫描)。独立小批, 量级 S。
|
||||||
|
|
||||||
|
**顺带挂点**: 压缩存档的会话删除级联清理(archive.rs:7 记录的缺口)挂在 R3 批的删除提炼同一条链路上(提炼完成后清理存档文件, 顺序: 先提炼后删文件), 避免单独再开删除挂点。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、与既有机制的关系
|
||||||
|
|
||||||
|
**知识库读写闭环(看板「观察进化系统只写不读」验收, docs/todo.md:414-415)**: 本设计 L3 写入侧(自动提炼+工具沉淀+终态提炼三通道)与读取侧(注入面+工具面+脉络卡三面)就是该验收的实现载体。质量闸引用知识库模型重审(2026-10-05)的实施序: 准入分级(candidate 不注入, published 且 verified 或 reuse 达标才可注入)与置信重估(自动提取默认 medium, 治 87% high 通胀)是该设计已立项项, 本设计不重复立项, 仅保证终态提炼产物走同一套把关。
|
||||||
|
|
||||||
|
**与分层组装器方案(2026-10-10)**: 见 3.3 两层表述。补充一点衔接: 组装器的 MemoryCardSource(C-m1 记忆卡)属会话内 L2 产物; 记忆卡中被验证可复用的条目, 未来经终态提炼通道晋升进 knowledge 表(会话内中蒸馏到跨会话高蒸馏的单向门), 这是两套设计的汇合点, 实施上互不阻塞。
|
||||||
|
|
||||||
|
**与 u-claw 分身的关系: 各自实现, 语义对齐, 不共享组件**。理由: ①语言栈不同(Go 网关 vs Rust/Tauri 桌面应用), 抽公共 crate 的跨语言成本远大于收益; ②数据载体不同(u-claw 账本 JSONL+CLAUDE.md 通道 vs devflow SQLite knowledge 表+工具注册表), 硬对齐载体必有一方扭曲; ③真正需要一致的是四层语义、召回铁律、目标快照续接语义, 这些走设计档互引即可(本文档第一节即为对齐记录)。与「devflow集成u-claw-可行性研究-2026-09-28.md」的通道集成(ask_uclaw 工具形态)正交: 那是能力互调, 这是各自体内记忆架构, 不冲突。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、分期(每批独立验收)
|
||||||
|
|
||||||
|
| 批 | 内容 | 依赖 | 量级 | 验收 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| R1 | L3 读取强化: 会话上下文注入机制 + knowledge_search 接项目软排序 | 无(scoped 检索 df-storage 侧已就绪) | S-M | 新会话问「这个项目之前定了什么」调工具命中且同项目排前; prompt token 不随历史会话数增长 |
|
||||||
|
| R2 | L4 重启不失忆: working_context_json 列 + 保存/恢复接线 | 无 | M | 重启应用后压缩重锚定与目标钉扎仍生效 |
|
||||||
|
| R3 | L3 写入收口: 会话终态提炼(删除前+parked 停靠) + knowledge_insert 归属补全 + 存档级联清理 | R1(共用会话上下文注入机制) | M | 关会话/停靠后知识库新增归属正确条目; 删除会话后存档文件消失 |
|
||||||
|
| R4 | 目标快照槽+恢复续接语义(D4 并入) | R2(载体) | M | 调整目标后重启续接只认最终目标, 作废目标不复活 |
|
||||||
|
| R5 | namespace 懒重建 | 无 | S | 重启后旧引用可读原文, 淘汰占位消失 |
|
||||||
|
| R6 | 脉络卡(前端读侧) | R3(数据在) | S-M | 新会话可见同项目近期决策摘要, 「带入上下文」为显式动作 |
|
||||||
|
|
||||||
|
顺序: R1(读取先行见效, 依赖最少)到 R2 到 R3 到 R4, R5/R6 可随批插空。后置触发制(不在本设计实施): scope 层级字段、代谢任务、语义检索(知识库模型重审已立项, 触发制)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、拍板点(6 项)
|
||||||
|
|
||||||
|
1. **终态提炼触发点集合**: 建议三触发(删除前后台非阻断+parked 停靠+手动已有); 备选仅停靠+手动(删除走静默, 防删除动作被后台提炼占用资源, 代价是删除会话的经验流失)。见 3.1。
|
||||||
|
2. **knowledge_search 工具级项目软排序与会话上下文注入机制**: 建议做(跨会话工具查询才有项目维度, 且一件基建服务读写两处); 备选不做(工具保持全局检索, 项目维度仅注入侧, 量级降为 S)。见 3.3。
|
||||||
|
3. **WorkingContext 持久化载体**: 建议加列 working_context_json(V50 模式, 可迁移可回退, 随会话级联); 备选 settings KV 单键(免迁移, 但清理难、单点膨胀)。见四节缺口 1。
|
||||||
|
4. **目标快照与 D4 合并**: 建议并入本设计 R4 批(D4 原始诉求与快照槽同一件事, 拆开必双实现); 备选留 D4 单独走(则本设计 R4 改为只做存储载体)。见四节缺口 2。
|
||||||
|
5. **脉络卡是否本期做**: 建议做(用户可见收益最大, 纯读侧零注入风险); 备选后置(先跑工具通道看真实使用率)。见 3.3。
|
||||||
|
6. **未完成事项是否进知识表**: 建议不进(归 L4 快照与停靠说明, 知识表只收可复用经验, 防任务态污染知识面); 备选兼容收(kind 用 workflow_optimization+tags 标注 todo, 有「过期待办变死知识」风险, 依赖代谢任务兜底)。见 3.1 边界。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、风险
|
||||||
|
|
||||||
|
**存储膨胀**: 终态提炼加自动提炼的 candidate 增长(基线 1073 条, 知识库模型重审 2026-10-05 快照)。治理: 提炼查重(title 相似命中即跳过或 reuse+1, 最小闭环设计第四节已列)、代谢任务(重审 UC4, 触发制后置)、存档级联清理(R3 子项)、注入与检索配额上限不动(top-3/top-10)。万条量级 LIKE+索引可承受(重审 E2 前提)。
|
||||||
|
|
||||||
|
**提炼质量与幻觉**: LLM 提炼可能编造事实或置信通胀(87% high 已实证)。治理: candidate 不进检索面(误写代价低, 人工审核可弃)、自动提取置信默认 medium(high 需证据, 重审 E4)、reasoning 字段留判断依据(`knowledge_inject.rs:1197-1198` 已回填)、promote 显式转正为唯一人为把关口、蒸馏来源质量权重(重审 E5, 后置)。终态提炼喂入 WorkingContext 决策属结构化输入, 幻觉面小于全文重放, 但仍按同一候选通道把关不豁免。
|
||||||
|
|
||||||
|
**隐私边界**: 全链本地 SQLite+本地 JSONL, 与「本地优先, 数据不出门」pivot 一致。三个注意点: embedding 管道维持默认关(C2 已拍板, 本设计不触碰); 终态提炼调用 LLM 发送会话内容, 与普通对话同一 provider 同一外发面, 无增量风险; 压缩存档明文落 data_dir, 敏感度与 DB messages 同级, 确保不进任何同步/上传/日志路径(现状无此路径, 保持即可)。
|
||||||
|
|
||||||
|
**缓存与注入纪律回归**: 最大长期风险是未来实现把召回内容「顺手」拼进 system 或常驻段(毁前缀缓存, u-claw 原案教训)。结构防线: 工具通道在组装结构上就不进组装、脉络卡纯前端、组装器召回段落尾 user 角色; 验收红线: 「prompt token 不随历史会话数增长」进每批回归口径, R1 验收即含此项。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附录: 证据坐标索引
|
||||||
|
|
||||||
|
| 论断 | 坐标 |
|
||||||
|
|---|---|
|
||||||
|
| 自动提炼挂正常收敛点 | `src-tauri/src/commands/ai/agentic/mod.rs:1636`; 水位制 `knowledge_inject.rs:660-720`(:52 阈值/:48 窗口/:70 水位) |
|
||||||
|
| 提炼产 candidate 带 source_project/溯源 | `knowledge_inject.rs:1180`(:1212)/(:1216-1219); 归属弱推断 `:948` |
|
||||||
|
| 注入侧项目软排序已接 | `knowledge_inject.rs:520-521`; `crates/df-storage/src/crud/idea_repo.rs:834-880`(三档 CASE :862-867) |
|
||||||
|
| 三知识工具与归属缺口自证 | `src-tauri/src/commands/ai/tools/knowledge.rs:49`(register)/(:51 search)/(:95 insert)/(:165 promote); 拿不到 conv `:13-16` |
|
||||||
|
| 压缩链与 WorkingContext 写入 | `context_lifecycle.rs:133-135`(水位)/(:335 锚点)/(:337 sync); `chat.rs:950`(:2444) begin_turn; `context_helpers.rs:886-996`(结构与方法) |
|
||||||
|
| 压缩存档(今日落地, 未提交) | `crates/df-ai/src/context/archive.rs:17`(:35 路径/:44 追加/:72 句柄); 开关 `prompt.rs:709-718`; 清理缺口 `archive.rs:7` |
|
||||||
|
| knowledge 表 schema | `crates/df-storage/src/migrations.rs:1835-1858`(V7, source_project :1846, kind 注释 :1828-1830) |
|
||||||
|
| L4 已有持久化面 | `conversation.rs:466-468`(pinned 落库)/`commands/conversation.rs:492-495`(恢复); V45 `migrations.rs:1366`; V49 `:1446`; 启动扫描 `state.rs:455-460`; 恢复链 `commands/conversation.rs:292-337`; 前端 `ConversationSidebar.vue:208` |
|
||||||
|
| L4 缺口自证 | df-storage 全仓无 working_context 引用(grep 实测); namespace 重启清空 `agentic/mod.rs:1975`; AiCompleted 快照透传 `agentic/mod.rs:1663` |
|
||||||
|
| 删除与停靠挂点 | `commands/conversation.rs:854`(ai_conversation_delete); `agentic/mod.rs:1464-1477`(emit_task_parked 三件套) |
|
||||||
|
| 上位设计与衔接 | 上下文根治设计-20261006.md(第七/八/九/十节); 上下文分层组装器-落地方案-2026-10-10.md(3.3/P2.3/拍板点4); 上下文三层记忆-设计-2026-10-04.md(C-m1); 知识库模型重审-精细化设计-2026-10-05.md(UC6/E4/E5/实施序) |
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
# 输入框列表预览与自动编号 — 交互设计
|
||||||
|
|
||||||
|
> 日期: 2026-10-10 | 状态: 设计稿(待拍板) | 需求锚点: `docs/todo.md:701` 条目 933 | 勘察: 只读实测(文件:行已核)
|
||||||
|
> 目标一句话: ChatInput 输入 `1. ` / `- ` 后,输入框内呈现"列表已就绪"的反馈(高亮/预览)并支持回车续写补号,对齐飞书消息输入框的顺手感,而当前是纯文本无反馈。
|
||||||
|
> 边界: 仅设计,不改码;不涉及后端/协议/落库。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、现状勘察(锚点实测)
|
||||||
|
|
||||||
|
### 1.1 输入框本体
|
||||||
|
|
||||||
|
| 件 | 锚点 | 现状 |
|
||||||
|
|----|------|------|
|
||||||
|
| 编辑器 | `src/components/ai/ChatInput.vue:20-35` | 单个纯 `<textarea class="ai-input" v-model="inputText">`,`rows="1"`,无富文本、无 overlay、无 mirror。事件: `@keydown/@beforeinput/@input/@paste/@dragover/@drop` |
|
||||||
|
| 键处理 | `ChatInput.vue:814-910` | `handleKeydown` 一条优先级长链:IME 守卫(817)→ Ctrl+R(822)→ 编辑态 Esc(829)→ 技能 Esc(836)→ chip Esc(843)→ 技能导航 ↑↓/Enter/Tab(850-872)→ 技能无匹配 Enter 拦截(875)→ mentions 导航 ↑↓/Enter/Tab(882-905)→ **Enter 发送(906-909)** |
|
||||||
|
| 输入态互斥 | `ChatInput.vue:418-432` | `watch(inputText)`: 以 `/` 开头 → `skillOpen`;否则 `detectMentionTrigger()`。二者互斥(427-431) |
|
||||||
|
| `/` 判据 | `ChatInput.vue:408` | `skillQuery = inputText.startsWith('/') ? slice(1) : ''` —— **仅识别"整串以 `/` 开头"**,行首列表标记 `1. ` 天然不与之撞 |
|
||||||
|
| `@` 判据 | `ChatInput.vue:439-467` | 从光标向前找最近 `@`,要求其前一字符为空/空白;遇空白即停 |
|
||||||
|
| 程序化改文 | `ChatInput.vue:509-532` | 已有成熟范式: `beforeinput` 快照旧文 + `input` 时公共前后缀差分定位编辑区间 → `shiftMentionSpans` 平移/丢弃 `pendingMentionSpans`。注释明确"程序赋值不派发 input 事件"(508-509) |
|
||||||
|
| 高度自适应 | `ChatInput.vue:916-922` | `autoResize()`: `height='auto'` → `min(scrollHeight, INPUT_MAX_HEIGHT=200)` |
|
||||||
|
| 光标定位 | `ChatInput.vue:801-811` | `selectMention` 里 `setSelectionRange` + 同步重算高度,是既有的"程序化插字+回光标"样板 |
|
||||||
|
| 样式 | `ChatInput.vue:1201-1215` | `.ai-input { flex:1; font-size:13px; line-height:1.5; font-family:var(--df-font-sans); resize:vertical; height:20px; max-height:200px; overflow-y:auto }`。外层 `.ai-input-wrap`(1177) `position:relative` + `display:flex; flex-wrap:wrap` |
|
||||||
|
|
||||||
|
### 1.2 既有浮层/提及基础设施(可复用)
|
||||||
|
|
||||||
|
| 件 | 锚点 | 复用点 |
|
||||||
|
|----|------|--------|
|
||||||
|
| 技能浮层 | `SkillMention.vue:43-62` | `.ai-skill-popover` 绝对定位浮层 |
|
||||||
|
| `@` 浮层 | `MentionPopover.vue:39-64` | 同款 `.ai-skill-popover`,仅加组标题 |
|
||||||
|
| 浮层定位 | `src/styles/components.css:254` | `.ai-skill-popover { position:absolute; bottom:calc(100% + 4px); left:0; right:0; max-height:240px; overflow-y:auto; z-index:20 }` —— 浮层锚在输入框**上方**,输入框整体透明底(`.ai-input { background:transparent }`) |
|
||||||
|
| 已选技能/图片预览 | `ChatInput.vue:16-19` | 输入框上方已有 chip/预览条先例(ImageInput),说明"输入框上方加一条反馈带"是既有形态 |
|
||||||
|
|
||||||
|
### 1.3 发送与渲染链(证明"发送侧零改动")
|
||||||
|
|
||||||
|
| 件 | 锚点 | 现状 |
|
||||||
|
|----|------|------|
|
||||||
|
| 发送 | `ChatInput.vue:924-1046` | `handleSend` 取 `inputText.trim()` 原样透传 `store.sendMessage(text, ...)`,不做任何 markdown 预处理 |
|
||||||
|
| 渲染器 | `src/composables/useMarkdown.ts:115-132` | `marked.setOptions({ breaks:true, gfm:true })` —— **有序/无序列表本就渲染为 `<ol>/<ul>`** |
|
||||||
|
| 列表样式 | `src/styles/ai-md.css:8-9` | `.ai-md ul, .ai-md ol { margin:4px 0; padding-left:20px }` / `.ai-md li { margin:2px 0; line-height:1.5 }` |
|
||||||
|
| 文本预处理先例 | `useMarkdown.ts:239-299` `wrapNakedDiff` | marked.parse 前做文本预处理的既有范式;其守卫1(纯 `-` 列表不误包成 diff)说明**已考虑"`- ` 是普通 markdown 列表"的语义** |
|
||||||
|
|
||||||
|
**结论**: 消息落地后列表渲染链完整,本需求是**纯输入侧体验缺口**——输入阶段无任何"这是列表"的反馈、无回车续写、无自动补号。
|
||||||
|
|
||||||
|
### 1.4 缺口清单
|
||||||
|
|
||||||
|
1. 输入 `1. ` / `- ` / `* ` / `+ ` 后无任何视觉反馈(纯文本),与飞书"行首即成型"的手感差距明显。
|
||||||
|
2. 回车续写不补号:按 Shift+Enter 换行后,下一行不会自动带 `2. ` / `- `。
|
||||||
|
3. 空列表项无退出:清空某行内容后标记 `1. ` 残留,需手工删。
|
||||||
|
4. 更深度的是"自动重排号"(中间插一项后续号重排)完全缺失。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、目标与验收口径
|
||||||
|
|
||||||
|
| 目标 | 验收口径(可实测) |
|
||||||
|
|------|-------------------|
|
||||||
|
| G1 触发识别 | 行首匹配 `^(≤3 空格)(\d+\. )` / `^(≤3 空格)([-*+] )` 即判定为列表项;` ``` ` 围栏内不触发 |
|
||||||
|
| G2 回车续写补号 | 在非空列表项行末按 **Shift+Enter** → 新行自动前缀正确序号(`n` → `n+1`)或同型符号(`- `→`- `),光标落新标记后 |
|
||||||
|
| G3 空项退出 | 空列表项行(仅标记+空格)按 **Shift+Enter** 或 **末位退格** → 标记被移除,退出列表态,不产生新项 |
|
||||||
|
| G4 视觉反馈 | 列表行标记(`1.` / `- `)在输入框内获得区别于正文的样式(高亮/等宽/色块);无法在纯 textarea 内着色时,以"输入框上方列表预览条"替代(见 §3.3) |
|
||||||
|
| G5 不打架 | `/` 技能联想、`@` 提及、编辑态、IME 组词、代码围栏优先级正确;开启本能力后既有行为零回归 |
|
||||||
|
| G6 发送不变 | 发送文本仍为含字面标记的原文,消息气泡经 marked 渲染为列表——发送链/后端/落库零改动 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、设计方案
|
||||||
|
|
||||||
|
### 3.1 总体判断(先说结论)
|
||||||
|
|
||||||
|
**纯 `<textarea>` 无法在"框内"渲染真正的列表项**(圆点/编号缩进)——textarea 只能显示等宽字符流,无法对子串应用 bullet + hanging indent。因此"框内所见即所得"只有两条路:
|
||||||
|
|
||||||
|
| 档 | 实现 | 能力 | 成本/风险 |
|
||||||
|
|----|------|------|-----------|
|
||||||
|
| **档一 · 轻量** | 保留 textarea + 逻辑层(识别/补号/退出)+ 可选 mirror 高亮 | 补号、退出、标记高亮(字面仍为 `1.`,但高亮显示) | 低-中;mirror 需 textarea 文字透明化,有选区可见性坑 |
|
||||||
|
| **档二 · 完整** | contenteditable 富文本编辑器(真 `<ol>/<ul>`) | 真 WYSIWYG、可嵌套/拖动 | 高;替换 textarea,击穿 IME/`@`span/粘贴图/autoResize 全部既有机制 |
|
||||||
|
|
||||||
|
**建议先做档一**,档二单独立项(见 §6 拍板点 2)。理由:档一达成飞书体验的 ~90%(续写补号是主力价值),且完全不碰已验证多年的输入链;档二是输入层重写,风险与工作量不成比例。
|
||||||
|
|
||||||
|
### 3.2 逻辑层设计(档一核心,必做)
|
||||||
|
|
||||||
|
抽 `src/composables/ai/useListContinuation.ts`(纯函数,可单测),不改 textarea 渲染。
|
||||||
|
|
||||||
|
**数据结构**(仅内存,不落库):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface ListContext {
|
||||||
|
kind: 'ordered' | 'unordered'
|
||||||
|
indent: string // 行首缩进(≤3 空格,保留)
|
||||||
|
marker: string // 字面标记,如 "3. " / "- "
|
||||||
|
number?: number // ordered 的序号(含 . 或 ) 后解析)
|
||||||
|
bullet?: '-' | '*' | '+'// unordered 的符号字符
|
||||||
|
content: string // 标记后的正文(可空)
|
||||||
|
lineStart: number // 该行在 inputText 中的起始偏移
|
||||||
|
markerEnd: number // 标记结束偏移(标记后第一个正文字符)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判定给定文本 + 光标所处行的列表语境;围栏内/无匹配返回 null */
|
||||||
|
export function analyzeListContext(text: string, caret: number): ListContext | null
|
||||||
|
|
||||||
|
/** 续写:返回应插入的标记串(ordered → `${n+1}. `;unordered → 同型) */
|
||||||
|
export function nextMarker(ctx: ListContext): string
|
||||||
|
|
||||||
|
/** (可选增强)重排:给定文本,把连续 ordered 段重编为 1,2,3…;返回新文本 + 需平移偏移 */
|
||||||
|
export function renumberOrdered(text: string): { text: string; renumbered: boolean }
|
||||||
|
```
|
||||||
|
|
||||||
|
**触发判据(实现细节)**:
|
||||||
|
|
||||||
|
- 有序: `/^(\s{0,3})(\d{1,9})[.)]\s(.*)$/`(兼容 `.` 与 `)`;markdown 允许 1-9 数字 + `.`/`)`)
|
||||||
|
- 无序: `/^(\s{0,3})([-*+])\s(.*)$/`
|
||||||
|
- **围栏守卫**: 从文本第 0 行扫到当前行前,统计 `^\s{0,3}(```|~~~)` 出现次数,奇数 → 处于代码围栏内 → 返回 `null`(不在围栏内把 `- 1.` 之类误判为列表)。复用 `useMarkdown.wrapNakedDiff` 同款 `_FENCE_OPEN_RE` 思路。
|
||||||
|
- 光标必须落在当前行(用 `\n` 计数定位行号),避免"光标在别行却对当前行操作"。
|
||||||
|
|
||||||
|
**续写流程(Shift+Enter)**: 在 `handleKeydown` 里新增分支(位置见 §3.4):
|
||||||
|
|
||||||
|
```
|
||||||
|
Shift+Enter 且非 skillOpen/mentionOpen/编辑态:
|
||||||
|
ctx = analyzeListContext(inputText, caret)
|
||||||
|
if !ctx: return(交默认换行)
|
||||||
|
if ctx.content.trim() === '':
|
||||||
|
// 空项 → 退出列表:删除整行标记,留下空行
|
||||||
|
preventDefault; 删除 [lineStart, markerEnd) 区间;
|
||||||
|
shiftMentionSpans(旧, 新); 回光标; return
|
||||||
|
preventDefault
|
||||||
|
insert = '\n' + ctx.indent + nextMarker(ctx)
|
||||||
|
在 caret 处插入 insert; 光标置插入末
|
||||||
|
shiftMentionSpans(旧, 新) // 复用 509-532,列表插字同样会平移其后的 @span
|
||||||
|
autoResize(); updateMirror()
|
||||||
|
```
|
||||||
|
|
||||||
|
> 注意: 程序化赋值不触发 `input` 事件(`ChatInput.vue:508-509` 已注明),故 `shiftMentionSpans` 必须**显式手动调用**(不能依赖 `onInputText`),与 `selectMention` 的处理保持一致。
|
||||||
|
|
||||||
|
**退格退出(B3)**: 在 `handleKeydown` 增 `Backspace` 分支:光标在行末且当前行为空列表项(`^(\s*)([-*+]|\d+[.)])\s$`)→ preventDefault,删标记,退出列表。
|
||||||
|
|
||||||
|
**自动重排号(G-增强,可选)**: 默认**不做**。true "自动编号" 需在中间插入/删除项后重写后续行号(`1. 2. 3.` → 插一行后 `1. 2. 3. 4.`),会改动用户已输入文本 → 触碰光标/`@`span 偏移。若做,须走完整"旧文差分 → 重排 → 重新定位光标与 span",成本与回归面显著上升,建议单独开关(见 §6 拍板点 3)。
|
||||||
|
|
||||||
|
### 3.3 视觉反馈设计(三选一,见拍板点 4)
|
||||||
|
|
||||||
|
| 形态 | 实现 | 优 | 劣 |
|
||||||
|
|------|------|----|----|
|
||||||
|
| **V0 零视觉** | 只做补号/退出 | 零渲染风险,一文件 | 不满足"而非纯文本"的观感诉求 |
|
||||||
|
| **V1 mirror 高亮(推荐目标)** | 在 textarea 下垫一层等宽 mirror div,渲染原文(转义),把列表标记包成 `<span class="ai-list-marker">` 高亮;textarea 文字设 `color:transparent; caret-color:var(--df-text)`,可见字符来自 mirror;同步 `scrollTop`/高度 | 字面仍为 `1.` 但**框内可见高亮 + 悬挂缩进**,最接近飞书 | 需精确对齐 font/line-height(13px/1.5)、尾行高度、`resize:vertical`、滚动;**选区可见性坑**:透明文字下 `::selection` 只剩底色、字形不可见,需 `textarea::selection{ background:… }` 兜且体验打折 |
|
||||||
|
| **V2 预览条** | 输入框上方(ImageInput 同位置范式)一条折叠/淡显预览,复用 `useMarkdown.renderMd(inputText)`(已 DOMPurify 净化)渲染当前输入 | 复用既有渲染单例,零新解析器;能看到真实序号/圆点 | 非"框内",占垂直空间,与飞书形态有差 |
|
||||||
|
|
||||||
|
**建议**: 先 V0 落地 G1-G3/G5/G6(最小可用,零渲染风险),再把 **V1 作为独立增强步**单独评估(透明化对选区/无障碍的影响需实测);V2 作为可选辅助保留。不建议一步到位 V1,因其 textarea 透明化是全局输入体验改动,面比"列表"本身大。
|
||||||
|
|
||||||
|
若采用 V1,样式落 `src/styles/components.css`(与 `.ai-skill-popover` 同处,因 scoped 不渗透):
|
||||||
|
|
||||||
|
```css
|
||||||
|
.ai-input-field { position: relative; flex: 1; display: flex; } /* 新包裹层,仅包 textarea */
|
||||||
|
.ai-input-mirror {
|
||||||
|
position: absolute; inset: 0; overflow: hidden; pointer-events: none;
|
||||||
|
font: inherit; font-size: 13px; line-height: 1.5; font-family: var(--df-font-sans);
|
||||||
|
white-space: pre-wrap; word-break: break-word; color: var(--df-text);
|
||||||
|
padding: 0; /* 与 textarea 完全一致,靠 .ai-input-wrap 的 6px 8px 内边距 */
|
||||||
|
}
|
||||||
|
.ai-list-marker { color: var(--df-accent); font-weight: 600; }
|
||||||
|
```
|
||||||
|
|
||||||
|
> mirror 文本必须用 `document.createTextNode` 逐段拼接(**禁用 v-html 直插原始输入**,防 XSS);仅对已确定的标记区间包 span。若日后想直接渲染整段 markdown 预览,只能走 V2 的 `renderMd`(已 `_purify.sanitize`,189-208)。
|
||||||
|
|
||||||
|
### 3.4 与既有机制的优先级(冲突落地)
|
||||||
|
|
||||||
|
新增分支在 `handleKeydown`(814-910)的**精确插入点**:
|
||||||
|
|
||||||
|
```
|
||||||
|
IME 守卫(817) → Ctrl+R(822) → 编辑态 Esc(829) → 技能 Esc/chip(836-848)
|
||||||
|
→ 技能导航(850-872)/技能无匹配 Enter(875)
|
||||||
|
→ mention 导航(882-905)
|
||||||
|
→ 【新增】Backspace 空项退出 · 【新增】Shift+Enter 列表续写 ← 放在此处
|
||||||
|
→ Enter 发送(906-909)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`/` 技能**: `skillOpen` 仅当"整串以 `/` 开头"(408/423),列表标记在行首,首行不可能是 `/`;续行也不影响 `startsWith` 判定 → **不撞**。守卫:续写分支前置 `!skillOpen`。
|
||||||
|
- **`@` 提及**: 续写时若光标处 popover 未开,insert 后调 `shiftMentionSpans` 平移其后的 span;若 popover 开着(`mentionOpen`),用户意图是选实体,续写分支前置 `!mentionOpen`。
|
||||||
|
- **代码围栏 ` ``` `**: 由 `analyzeListContext` 的围栏守卫直接排除(§3.2),`- 1.` 在围栏内不触发,与 `wrapNakedDiff` 守卫3一致。
|
||||||
|
- **IME/isComposing(817)**: 续写分支位于其后,组词中的 Shift+Enter 不误触。
|
||||||
|
- **Enter 发送不变**: 本设计**不改 Enter 发送语义**——续写绑定 Shift+Enter(换行链),Enter 仍发送。为最小改动。若要与飞书"Enter 续行"完全一致需改语义,见拍板点 1。
|
||||||
|
|
||||||
|
### 3.5 UI 形态与文案
|
||||||
|
|
||||||
|
- 无新浮层;V1 高亮走 mirror,V2 预览条若采用则置 `.ai-input-wrap` 之后、`EnrichmentPanel`(`ChatInput.vue:113-121`)之前。
|
||||||
|
- 提示语: 可选在 `.ai-input-hint`(122,`aiChat.inputHint` zh:159)追加"列表续行 Shift+Enter",或单独加一行淡提示。新增 i18n key 落 `src/i18n/zh-CN/aiChat.ts` + `src/i18n/en/aiChat.ts`(现有 key 区见 130-165)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、分期与最小可用
|
||||||
|
|
||||||
|
| 期 | 范围 | 交付 | 依赖 |
|
||||||
|
|----|------|------|------|
|
||||||
|
| **P1(最小可用)** | 逻辑层 + V0 | `useListContinuation.ts`(analyze/nextMarker)+ ChatInput 接线(Shift+Enter 续写、Backspace 空项退出、围栏守卫)+ 开关 | 单测覆盖判定/续写/围栏 |
|
||||||
|
| **P2(视觉)** | V1 mirror 高亮(或 V2 预览条) | 框内标记高亮 + 悬挂缩进;样式入 components.css | P1 稳定 + 选区/无障碍实测通过 |
|
||||||
|
| **P3(增强,可选)** | 自动重排号(renumber) | 中间插/删项后重编序号;独立开关 | P1/P2 无回归 |
|
||||||
|
|
||||||
|
**最小可用定义**: P1 完成即视为需求达标可发布——触发识别 + 续写补号 + 空项退出三件事齐,发送侧零改动。视觉(V1/V2)与重排(P3)为增量。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、影响面(新能力引入评估纪律 · 五维)
|
||||||
|
|
||||||
|
| 维度 | 判定 |
|
||||||
|
|------|------|
|
||||||
|
| **兼容性** | 纯前端输入侧行为,**无协议/落库/迁移**。既有草稿(`convDrafts`)、编辑态回填、队列回填文本语义不变(仍是含字面标记的 markdown 原文)。旧会话/历史消息不受影响(渲染链本就支持列表)。**PASS** |
|
||||||
|
| **破坏性** | 增分支只拦 Shift+Enter/Backspace 的**特定空项/列表场景**,其余键行为不变;Enter 发送语义**不变**(拍板点 1 若选改才涉及行为变更,且可逆)。无不可逆操作。**PASS(低)** |
|
||||||
|
| **安全面** | 无新端点/命令执行/路径。**关键**: mirror/预览渲染用户输入时必须转义——mirror 用 textNode 逐段拼接;若用 v-html 走 `renderMd`(已 `DOMPurify.sanitize`,`useMarkdown.ts:201`)。禁裸 `v-html=原始输入`。新增攻击面为零,仅为既有 XSS 防护纪律的又一次适用。**PASS** |
|
||||||
|
| **资源面** | 每次键入一次纯字符串扫描(O(行数),典型 <100 行)一次可选 mirror DOM 更新。无需 rAF/防抖(P3 重排号若开,是 O(n) 文本改写,仍可控)。无磁盘/日志/内存增量。**PASS** |
|
||||||
|
| **回退开关** | 设 appSettings 键(沿用现网 `df-ai-streaming-md` / `df-ai-auto-execute-mode` 范式,`useAppSettingsStore().get<boolean>('df-ai-list-input', false)`),默认**关**再灰度;单点置 false 即完全回退到现状 textarea 行为,零残留。**PASS(带开关)** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、拍板点(需人定,附建议)
|
||||||
|
|
||||||
|
1. **Enter 语义**: 保留"Enter=发送,Shift+Enter=换行并续列表"(**建议**,零回归、不违 `aiChat.inputHint` 与 `handleKeydown:906` 既有契约) vs 改为"列表态下 Enter 续行、Cmd/Ctrl+Enter 才发送"(更贴飞书,但变更已固化的发送习惯,置信成本高)。
|
||||||
|
2. **渲染档位**: 先做**档一 轻量**、档二 WYSIWYG 单独立项(**建议**) vs 直接上档二 contenteditable 富文本。档二会击穿 IME/`@`span/粘贴图/autoResize 全链,工作量与回归面远超本需求。
|
||||||
|
3. **范围边界**: 标记仅 `\d+. ` + `- `(**建议**起步) vs 含 `)`、`*`、`+`、嵌套缩进、以及是否做**自动重排号**(renumber 改用户文本,单独开关)。
|
||||||
|
4. **视觉形态**: V0 零视觉先行 → V1 mirror 框内高亮(**建议路径**,但需实测 textarea 透明化的选区/无障碍影响) vs 直接 V1 vs 用 V2 输入框上方预览条替代。(此项与拍板点 2 联动:若否决档二,则 V1 是"框内感"的唯一可达形态。)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附:实施落点清单(供实施会话直接取用)
|
||||||
|
|
||||||
|
| 文件 | 改动 |
|
||||||
|
|------|------|
|
||||||
|
| `src/composables/ai/useListContinuation.ts` | 新增:`analyzeListContext` / `nextMarker` / 围栏守卫 /(可选)`renumberOrdered` + 单测 |
|
||||||
|
| `src/components/ai/ChatInput.vue` | `handleKeydown`(814-910)插入 Shift+Enter/Backspace 分支;`<script>` 引入 composable;V1 时包一层 `.ai-input-field` + `<div class="ai-input-mirror">` 并同步 scroll/height;读开关 `df-ai-list-input` |
|
||||||
|
| `src/styles/components.css` | V1:`.ai-input-field` / `.ai-input-mirror` / `.ai-list-marker` |
|
||||||
|
| `src/i18n/zh-CN/aiChat.ts` + `src/i18n/en/aiChat.ts` | 可选:续行提示文案 |
|
||||||
|
|
||||||
|
> 参照档: `无人值守长任务-设计-2026-10-04.md`(结构/详略) · `上下文三层记忆-设计-2026-10-04.md` · `跨会话召回最小闭环-设计-2026-10-04.md`(同一目录写法)。
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# 飞书直连接入 — 形态修正设计与问题记录(2026-10-05)
|
||||||
|
|
||||||
|
> 产出:AI-Ops(飞书 bot,只做分析/设计/问题记录);实施:devflow agent。
|
||||||
|
> 背景:用户 10-05 在飞书群多轮拍板,推翻 df-im-gateway→u-relay 网关链路,定稿「桌面本地 WS 直连飞书开放平台」。
|
||||||
|
|
||||||
|
## 一、形态拍板(用户令,已定稿)
|
||||||
|
|
||||||
|
```
|
||||||
|
飞书开放平台 ←WS长连接(出站,免公网回调)→ devflow 桌面端(内置飞书渠道) ⇄ ai-chat agent
|
||||||
|
```
|
||||||
|
|
||||||
|
- 参照形态:u-claw `feishu_channel.go`(官方 SDK `larksuite/oapi-sdk-go/v3` 的 `larkws.Client` 出站长连接 + REST API 回复,单进程闭环零中间件)
|
||||||
|
- 旧网关链路(飞书→df-im-gateway→u-relay→df-tunnel→桌面端)在单机场景属绕行,推翻;原网关规划设计文档的 §十(云端多人演进预留)保留远期参考
|
||||||
|
- 代价已确认:「桌面端关机 bot 不活」用户接受(先例:AI-Ops 即本机常驻)
|
||||||
|
|
||||||
|
## 二、实施要点(交 devflow agent)
|
||||||
|
|
||||||
|
1. **复用现有资产,勿新建 df-* 组件**(用户 10-05 明确):`crates/df-im-gateway/src/feishu.rs`(350 行,基于 larksuite-oapi-sdk-rs 0.3.12 channel 特性)的长连接内核可提炼,并入哪个现有 crate 由实施时定位(SDK 用法参考:公开 API `LarkClient::ws_client(dispatcher)` 直建,拿 `WsClientControl` 句柄做停机/诊断;AI-Ops 侧曾试建 df-feishu crate 已按要求撤销,其间验证过该路线可行:11 单测全过)
|
||||||
|
2. **消息链路**:飞书 Inbound(文本/卡片动作)→ chat_id↔conversation_id 映射 → ai-chat 会话链路(GUI 同源)→ agent → 流式回飞书;先文本回复,卡片流式更新/审批卡放第二批次
|
||||||
|
3. **Settings 契约**:app_settings 单键 `'feishu-config'` = JSON `{enabled, app_id, app_secret, whitelist[]}`(snake_case,存法对齐 `df-knowledge-config`)
|
||||||
|
- 注:AI-Ops 侧子代理已按此契约实现 Settings 面板于工作区(6 文件:`FeishuPanel.vue` 新增 + SettingsNav/searchIndex/Settings.vue/zh-en i18n 修改,vue-tsc 通过,未提交)。devflow agent 三选一:采纳 / 推翻重做 / 回退(`git checkout` 那 6 文件+删 FeishuPanel.vue)
|
||||||
|
4. **白名单**:空=放行所有(与现 df-im-gateway main.rs 行为一致);open_id 按应用隔离,严禁从其他 bot 搬,须本应用实测获取(首启留空 → 用户发一条消息 → 日志取 open_id 回填)
|
||||||
|
5. **凭证**:用户已在飞书开放平台建应用(10-05),经设置页录入;曾现「bot disabled」,接入验收前先在开放平台启用 bot
|
||||||
|
|
||||||
|
## 三、问题记录 ①:ai-chat 交互消息闪现即消(未结,待复现)
|
||||||
|
|
||||||
|
**现象**:用户 10-05 白天报告,ai-chat 对话页面消息闪现一下就消失,自述"又出现了"。
|
||||||
|
|
||||||
|
**已锁机制**:前端乐观气泡被回滚移除(`useAiSend.ts:132/190/199` — IPC 失败即移除占位气泡)。**实锤:10-05 00:12 换装重启后 ai_messages 零写入**(`Roaming/top.1216.devflow/devflow.db`),白天发的消息后端一个没接住,闪现=回滚删除。
|
||||||
|
|
||||||
|
**时间线**:10-05 00:03-00:12 凌晨会话(agent 自主)按攒批纪律以工作区状态 `bun tauri build` 打 NSIS 包,`DevFlowRedeploy` 计划任务强杀换装;00:35 攒批分组提交(8e1e56b 等);运行中二进制=`LOCALAPPDATA/devflow/devflow.exe`(00:11 产,48MB)。
|
||||||
|
|
||||||
|
**候选根因**(按嫌疑序,未定):
|
||||||
|
1. 换装后 agent 后端没接活,发消息 IPC 失败(spawn 前)——零写入吻合
|
||||||
|
2. "无活跃对话"回滚路径(useAiSend.ts:190)——重启后活跃会话指针丢失
|
||||||
|
3. 会话级:仅大会议(seq 4989 级)复现 → 嫌疑落"上下文管理信息组织"批次改动
|
||||||
|
|
||||||
|
**已排除**:provider 配置完好(ai_providers 5 行);9-28 走查#5 队列误清已由 c10f0e4 修复(本次是同症状另一条路径);工作区现有改动与本问题无关(df-im-gateway/df-mcp/df-nodes/df-storage 等,系 devflow agent 在途工作)。
|
||||||
|
|
||||||
|
**下一步(需现场)**:用户在 ai-chat 再发一条消息,同时记录三点:闪掉的是 user 气泡还是 assistant 占位;有无错误提示一闪而过;全部会话闪还是个别会话。前端 webview console(EBWebView CDP)是备选取证面。
|
||||||
|
|
||||||
|
## 四、问题记录 ②:seq=0 system 消息(次要,顺手修)
|
||||||
|
|
||||||
|
`ai_messages` 中发现 system 消息 `seq=0` 混于 seq=4951+ 序列(10-05 00:00:50,conv=84cb104c,内容为 assistant 风格"vision 技能已加载…")。seq 分配路径存在未走递增器的旁路,排序场景会被挤到队首。修复时一并核对 system 类消息的 seq 分配。
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# 无人值守可靠性专项设计(2026-10-06 swarm 产出)
|
||||||
|
|
||||||
|
> 对象:会话侧挂机护栏四条 + 任务侧 V49 断点续跑收尾。标准:合理·兼容·优雅·可扩张。
|
||||||
|
> 侦察/设计产出:temp/V49断点续跑设计-20261006.md(详设)+ 本档(总纲与任务单)。
|
||||||
|
|
||||||
|
## 一、本质定性
|
||||||
|
挂机≠无人值守:挂机是行为模式,无人值守是它最危险的子场景(无人工兜底)。
|
||||||
|
今天三案例:09:48 桌面纠偏(在场,安全)/ 11:23 汇报失实(离机,自报≠事实)/ 09:51 跨端互斥(正确但话术吞因)。
|
||||||
|
结论:在场靠「过程可见」,离机靠「机制护栏」——本专项只做机制,不做提示词说教。
|
||||||
|
|
||||||
|
## 二、会话侧护栏四条(落点全部现成,最小增量)
|
||||||
|
|
||||||
|
### H1 汇报附 git 实测
|
||||||
|
落点:chat.rs:919-941 单一发出点——组装 summary 前调 tools/git.rs exec_git_exit 取 branch/HEAD/未提交计数,追加「实测附录」段。
|
||||||
|
收益:飞书 watcher 与桌面任务卡零改动同享(单一事实源)。
|
||||||
|
|
||||||
|
### H2 token 预算闸
|
||||||
|
落点:主 loop 抄 sub_agent.rs:346 round_budget_stop 三闸(轮数/token/挂钟),挂 agentic/mod.rs:1115 for 头部(tokens.total() 现成)。
|
||||||
|
超限走既有 emit_task_parked("token_budget")——前端词表已预留,零新契约。
|
||||||
|
|
||||||
|
### H3 外部干预感知
|
||||||
|
落点:mod.rs:1119 轮首——loop_epoch 之外轻量 git 实测(HEAD 是否仍为自己上次记录),变更即 emit_task_parked("external_change")。
|
||||||
|
ConvStateStore.transition 唯一写口,补干预事件 emit。
|
||||||
|
|
||||||
|
### H4 产出审计
|
||||||
|
落点:AiToolCallStarted/Completed 已全量双写 event_bus——加审计 subscriber(event_bus.rs:157)或挂 audit 落库点,不动 loop。
|
||||||
|
## 三、任务侧 V49(详见 temp/V49断点续跑设计-20261006.md)
|
||||||
|
|
||||||
|
核心选型:①防覆盖=独立单列 UPDATE+摘除 SET 列(单写者根除,非乐观锁)②list_parked 补唯一 IPC ai_list_recoverable(完备真相查询,兼飞书投影源)③续跑新命令 ai_recover_parked(进程死后复活与进程内流断路径不同,禁混一个命令)。
|
||||||
|
状态机:LoopState{Idle,Running,Parked}(持久层,跨重启真相)/ ConvState{Idle,Generating,Stopping,Error}(进程内)分层;写点收敛 6 处全走 set_loop_state 单方法。
|
||||||
|
新识别 P4:重启后 parked 会话无 DB→内存重建+重 spawn 路径——闭环最后一块。
|
||||||
|
|
||||||
|
## 四、与异步架构(13/14/16/17)兼容性
|
||||||
|
- H1 单点改动:飞书/桌面零改动 ✔
|
||||||
|
- H2 走既有 emit_task_parked:前端零新契约 ✔
|
||||||
|
- 13 过程流式白得事件源:AiTaskProgress(每 5 轮,mod.rs:1119)已存在,飞书 refresher 补消费即可
|
||||||
|
- 14 委托引擎的 watcher 与 H1/H3 同通道,零竞态新增 ✔
|
||||||
|
- 16 纠偏注入与 H3 互补(人在场=16,离机=H3) ✔
|
||||||
|
|
||||||
|
## 五、实施序(4 批次,S/M/L/S 全可独立交付)
|
||||||
|
B1(S):H1 汇报 git 实测 + 看门狗口径修正(inflight 覆盖 spawn 窗口)
|
||||||
|
B2(M):H2 token 三闸 + H4 审计 subscriber
|
||||||
|
B3(M):V49 防覆盖定稿+回归单测 + ai_list_recoverable IPC + 桌面「可恢复」入口
|
||||||
|
B4(L):ai_recover_parked 重启恢复路径 + H3 外部干预感知
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> 创建: 2026-06-10 | 状态: 设计中 | 最后重写: 2026-06-15 (DOC-01 硬伤修复)
|
> 创建: 2026-06-10 | 状态: 设计中 | 最后重写: 2026-06-15 (DOC-01 硬伤修复)
|
||||||
>
|
>
|
||||||
> **本文档为 ARCHITECTURE.md 的实质载体**(项目无独立 ARCHITECTURE.md 文件)。所有数据模型、设计决策均以源码为基准,已剔除虚构内容。
|
> **项目架构总纲见根 [ARCHITECTURE.md](../../../ARCHITECTURE.md)**,本文档为其补充细节。所有数据模型、设计决策均以源码为基准,已剔除虚构内容。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# UX 走查 — Web Interface Guidelines 合规(2026-10-05)
|
||||||
|
|
||||||
|
> 范围:src 下 43 个 .vue 文件(以工作区未提交改动为主,机械反模式全量扫描含存量)。
|
||||||
|
> 方法:三路子代理分组走查(A 审批/工具卡域 6 文件、B aichat 域 9 文件、C 项目/知识/审计域 11 文件)+ 主会话 grep 全量机械扫描(transition:all / outline:none / div@click / overscroll / aria 语义)。子代理发现经行号复核;A 组子代理两轮超限,由主会话直读补核。
|
||||||
|
|
||||||
|
## 一、总体结论
|
||||||
|
|
||||||
|
**质量中上,无阻断项。** 本批新增代码 a11y 意识明显:svg 批量补 aria-hidden、复制/编辑按钮带 aria-label、MessageList 有 role=log+aria-live、hover 反馈普遍到位、i18n 完整。缺口高度集中在 **4 类系统性模式**,其中 2 类可用全局样式一刀切收敛大半。
|
||||||
|
|
||||||
|
## 二、系统性缺口(grep 全量实证)
|
||||||
|
|
||||||
|
### 1. 弹层缺 overscroll-behavior: contain —— 全仓 0 命中【覆盖面最广】
|
||||||
|
43 文件所有 modal/overlay/浮层(`modal-overlay`×6、`ai-shortcuts-mask`、`ai-agent-peek-mask`、`dep-graph__modal-overlay`、`confirm-mask`、审批浮层等约 12+ 处)无一声明 overscroll-behavior。弹层内容滚到边界后滚轮穿透到底层内容。
|
||||||
|
**修复**:全局样式一刀切(`[class*="overlay"], [class*="mask"] { overscroll-behavior: contain; }`)或逐弹层补。P2,一处 CSS。
|
||||||
|
|
||||||
|
### 2. transition: all —— 33 处 / 22 文件【密度最高】
|
||||||
|
行号清单:ApprovalPopup×4(594/632/728/809)、ApprovalOverlay×3(128/200/211)、ChatInput×2(1222/1236)、FilePreview×2、GitChanges×2、Knowledge.vue×2(501/546)、ProjectDetail×2、Tasks×2、App.vue:340(toast)、ConfirmDialog:103、Paginator:146、ToolCard:494、ToolResultBody:542、EmptyState×1、TopBar×1、IdeaDetail×1、AppSidebar:276、FileExplorer×1、ProjectCard×1、AuditLog:659、Ideas:588、Projects×1。
|
||||||
|
风险:布局属性被动画(掉帧)、主题切换时全属性过渡发糊。
|
||||||
|
**修复**:机械替换为具体属性(color/background-color/border-color/transform/box-shadow)。P3 批量低风险。
|
||||||
|
|
||||||
|
### 3. outline: none 无 :focus-visible 补偿 —— 19 处 / 14 文件【键盘可达性】
|
||||||
|
行号清单:IdeaDetail×3(1096/1112/1153)、ConversationSidebar×2(702/829)、Knowledge.vue×2、Tasks×2、AiChat:972、ChatInput:1197、TaskBoundaryCard:454、TopBar:718、KnowledgeDetail:453、GitChanges×1、SettingsNav×1、TaskBatchBar×1、WorkflowDagDisplay×1、AuditLog:471。
|
||||||
|
**修复**:全局兜底 `:focus-visible { outline: 2px solid var(--df-accent); outline-offset: 2px; }`,input 类可保留 border-color 补偿不强制 outline。P2。
|
||||||
|
|
||||||
|
### 4. div/span 充当可点击元素 —— @click 共 56 处,去 @click.self mask 合规项后约 20 处真缺口【键盘不可达】
|
||||||
|
典型(高频优先):ToolCard.vue:11 工具卡折叠头、MessageList.vue:1423 轨迹折叠头、ConflictResolver.vue:7 冲突行、ConversationSidebar.vue:205 归档组折叠头、TopBar.vue:176/192 历史/摘要行、AuditLog.vue:67 审计展开行 + :119 会话行、Dashboard.vue:38/42/46 统计卡、FileExplorer.vue:29/37/42 下拉项+面包屑、FilePreview.vue:124 大纲头、GitChanges.vue:120 加载更多、ProjectCard.vue:2 整卡。
|
||||||
|
(`@click.self` 的 mask 点击关闭是合理模式,不在修列。)
|
||||||
|
**修复**:高频 3 处改 button+样式重置;其余补 tabindex=0 + @keydown.enter/space + role。P2 分批。
|
||||||
|
|
||||||
|
### 5. icon-only 按钮缺 aria-label(子代理域审 + 抽查)
|
||||||
|
TopBar 多数按钮有 title(弱补偿);EnrichmentPanel 关闭钮仅 title;ProjectDetail:185/189 编辑/删除 icon 钮仅 title。title 与 aria-label 非等价(部分读屏不读 title)。
|
||||||
|
**修复**:aria-label 与 title 同 i18n 键复用,机械补齐。P3。
|
||||||
|
|
||||||
|
## 三、亮点(已做对的)
|
||||||
|
|
||||||
|
- MessageList.vue:1391 `role="log" + aria-live="polite"`:流式消息读屏可感知(UX 第五轮 P2-G1)
|
||||||
|
- 本批 git diff 新改 svg 批量补 `aria-hidden="true"`(审批拒绝按钮等实证)
|
||||||
|
- MessageItem 复制/编辑按钮带 aria-label,hover-only 操作区有 `:focus-within` 显形补偿
|
||||||
|
- 消息区键盘链(Esc/IME/滚动锚定)质量好;AuditLog 模板全 button + Paginator 组件分页规范
|
||||||
|
|
||||||
|
## 四、修复分期建议
|
||||||
|
|
||||||
|
| 期 | 内容 | 量级 |
|
||||||
|
|---|---|---|
|
||||||
|
| P2-1 | 全局两刀::focus-visible 兜底 + 弹层 overscroll-contain | 全局 CSS ~2 处,收敛 2 类大半缺口 |
|
||||||
|
| P2-2 | 高频 div→button:ToolCard 卡头 / MessageList 轨迹头 / AuditLog 行 | 3 处 |
|
||||||
|
| P3-1 | transition:all 33 处机械替换具体属性 | sed 级批量 |
|
||||||
|
| P3-2 | icon-only aria-label 补齐 + 剩余 div 键盘化 | ~15 处 |
|
||||||
|
|
||||||
|
**拍板点**:修复范围「存量 vs 仅本批改动」——机械扫描含存量(transition:all/outline:none 大半为存量代码),若仅清本批改动则这两类大半豁免,工作量降 60%+。
|
||||||
|
|
||||||
|
## 附:三路子代理域审摘要
|
||||||
|
|
||||||
|
- **B 组(aichat 域 9 文件)**:22 处。集中 icon-only 缺 aria-label(~12)与 outline 无补偿;4 处 transition:all;3 处 div 点击不可键盘达。逻辑层键盘处理(Esc/IME/双确认)质量好。MessageItem pass。已知线索复核:TopBar model select outline 无补偿属实(:718);EnrichmentPanel 关闭钮仅 title 属实。
|
||||||
|
- **C 组(项目/知识/审计域 11 文件)**:弹层 overscroll 系统性缺失(本组 4 文件)+ icon-only ~6 + div 可点击 ~5 + AppSidebar:276 transition:all、KnowledgeDetail:453 outline 裸删。无 v-for 超百项无虚拟化、无 aria-live 缺失。AuditLog/Knowledge.vue cat-chip 规范。
|
||||||
|
- **A 组(审批/工具卡域 6 文件,主会话补核)**:ToolCard.vue:11 卡头 div 可点击 + :494 transition:all(diff 中另一处 svg aria-hidden 为本批已修好的实证);ApprovalPopup transition:all×4;ApprovalOverlay×3(128/200/211);ToolResultBody:542;AiChat.vue:972 outline:none,:139 mask 为 @click.self 合规。
|
||||||
@@ -16,6 +16,15 @@
|
|||||||
|
|
||||||
> 注:用户贴的"你好"会话原始调用(`"project_id":"DevFlow"` + `pattern:"scan"`)在三个库中均**未检索到**,
|
> 注:用户贴的"你好"会话原始调用(`"project_id":"DevFlow"` + `pattern:"scan"`)在三个库中均**未检索到**,
|
||||||
> 疑未落库或已清理。但同类行为在库中有大量实证,结论不依赖该单例。
|
> 疑未落库或已清理。但同类行为在库中有大量实证,结论不依赖该单例。
|
||||||
|
>
|
||||||
|
> **✅ 2026-10-10 定位恢复(推翻上文"未检索到")**:该会话已实证定位——`devflow-dev.db` 会话
|
||||||
|
> `b4d6b4e0-6901-4937-af47-d3202678406b`(标题「打招呼」,模型 `sensenova-6.7-flash-lite`,创建
|
||||||
|
> 2026-08-04 04:00:46 UTC)。`ai_conversations.messages` blob 恰为该轮:`[user] "你好"` +
|
||||||
|
> `[assistant] 空文本 + tool_calls=[list_project_modules, search_files]`;`ai_tool_executions`
|
||||||
|
> 两行逐字对上截图(`{"project_id": "DevFlow"}`→`{"modules":[],"project_id":"DevFlow","total":0}`;
|
||||||
|
> `{"pattern":"scan","path":"E:/wk-lab/devflow/src-tauri","recursive":true}`→`{"has_more":false,...,"total":0}`),
|
||||||
|
> tool_call_id 为弱模型特征 `gen_stream_4`/`gen_stream_23`,message_id `msg_1785816059122_5`。
|
||||||
|
> 原检索为空的可能原因:该轮未落 `ai_messages`(该会话 ai_messages 最早为 2026-08-06),仅存于 blob+executions 表。
|
||||||
|
|
||||||
## 实证 1:重复探索是系统性现象(非偶发)
|
## 实证 1:重复探索是系统性现象(非偶发)
|
||||||
|
|
||||||
@@ -50,6 +59,12 @@
|
|||||||
## 实证 4:问候本身不触发工具,但弱模型任务中失控
|
## 实证 4:问候本身不触发工具,但弱模型任务中失控
|
||||||
|
|
||||||
- 修正:纯"你好"第一轮模型会正常回复能力介绍(09e7abfa/1982ff08 均如此),**0 例**"问候后紧邻调工具"
|
- 修正:纯"你好"第一轮模型会正常回复能力介绍(09e7abfa/1982ff08 均如此),**0 例**"问候后紧邻调工具"
|
||||||
|
|
||||||
|
> **⚠️ 2026-10-10 更正(实证推翻本条)**:会话 `b4d6b4e0`(标题「打招呼」)的**首轮**即是裸问候
|
||||||
|
> 「你好」→ assistant **空文本 + 直接发 2 个 tool_calls**,无任何前置上下文。故"问候本身不触发工具"
|
||||||
|
> 不成立——至少对 `sensenova-6.7-flash-lite` 存在**首轮问候即开工具**的实例。原"0 例"结论源于当时未
|
||||||
|
> 检索到该会话(其轮次未落 ai_messages),属样本缺失导致的误判。据此,「工具全量暴露 + 弱模型自控弱」
|
||||||
|
> 的诱因比原判更严重:**无需任务上下文,裸问候即可触发工具探索**。
|
||||||
- 用户贴的"你好犯傻"更可能是:会话有历史上下文(scan/工程模块任务残留),模型把"你好"当继续信号,或 system prompt 全量 55 工具 + "优先使用工具"引导诱使弱模型"准备上下文"
|
- 用户贴的"你好犯傻"更可能是:会话有历史上下文(scan/工程模块任务残留),模型把"你好"当继续信号,或 system prompt 全量 55 工具 + "优先使用工具"引导诱使弱模型"准备上下文"
|
||||||
- 一旦进入实质任务,弱模型即进入重复探索/绕行/参数错误循环
|
- 一旦进入实质任务,弱模型即进入重复探索/绕行/参数错误循环
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
# 上下文管理五维走查(2026-10-10)
|
||||||
|
|
||||||
|
> 走查方式:五路只读代理并行(组装/预算 · 压缩 · 自愈清洗 · 记忆召回 · 工具结果),全程源码取证;瓶颈清单摘要登记看板 `docs/todo.md` #939,本文为全文落档。
|
||||||
|
> ⚠️ 行号快照:`crates/df-ai/**` 与 `src-tauri` 部分文件当日有并行改动(工具结果外置 P1 WIP / Wave J 修复),行号为 2026-10-10 走查时点,批次开工以现场源码为准再核对。
|
||||||
|
> 关联设计:`docs/02-架构设计/上下文根治设计-20261006.md`(223:1 根治总纲)· `docs/02-架构设计/专项设计/上下文分层组装器-落地方案-2026-10-10.md`(五病灶锚点实测 + 接口化落地方案)。
|
||||||
|
> 状态标注:【已修-工作区】= 修复已落工作区待提交(Wave J/守夜期产出);【待解禁】= 修复点在 `crates/df-ai`,需并行写者完工后接手;★ = 可先在 src-tauri 侧落。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 零、背景与铁证
|
||||||
|
|
||||||
|
- **223:1 出处**:84cb104c 会话 2.2 天烧 4.3 亿 tokens,prompt:completion = **223:1**,日均 1.9 亿(`上下文根治设计-20261006.md` §量化预期)。
|
||||||
|
- 走查定位出该结构性浪费的共同根因:**记账(history_tokens)与真实发送体量脱钩 → 三重门槛(预算门/裁剪门/压缩门)恒不触发 → 窗口永不收敛 → 每轮全量重发**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、组装/预算维度
|
||||||
|
|
||||||
|
### HIGH① history_tokens 与真实请求体量脱钩(根因,最重)【待解禁】
|
||||||
|
|
||||||
|
机制链四步,环环实证:
|
||||||
|
|
||||||
|
1. **引用化发生在 push 前**:工具结果在审计层先经 `should_use_namespace` 判定(`audit/mod.rs:1184-1191`;`namespace_store.rs:47-53`),`ALWAYS_LARGE_TOOLS = read_file/list_directory/grep/diff_files`(`namespace_store.rs:38`)**命中即引用、与实际体积无关** → `store()`(`namespace_store.rs:124-131`)入内存池,消息里只留短串 `namespace://tool/{hash}`。
|
||||||
|
2. **记账只数短串**:`ContextManager::push`(`context/mod.rs:101-143`)对这条短串跑 `estimate_message`(`context_helpers.rs:62-105`),`history_tokens += tokens` 在 `:108-110` → **单条上万 token 的大结果只记 ~15 token(~30 字符)**。
|
||||||
|
3. **展开发生在发送时**:`agentic/mod.rs:1927-1944` 才把引用展开为真实体量(单条 max 150KB,见 P1 注释 `context_helpers.rs:427-432`)。
|
||||||
|
4. **三重门槛双双恒不触发**:
|
||||||
|
- 预算门 `context/mod.rs:198`:`history_tokens <= available` 恒真 → 直接返回全量;
|
||||||
|
- 裁剪门 `context/mod.rs:239-246`:永不达阈值;
|
||||||
|
- 压缩门 `context_lifecycle.rs:133-135`:`available*6/10 < history_tokens` 恒假。
|
||||||
|
|
||||||
|
→ 每轮实际重发展开后的全量历史 → 223:1。
|
||||||
|
|
||||||
|
- **修**(二选一或组合):push/外置时按**原文体量**入账(记账基点 = 发送视图尺寸单一真值,见组装器方案 §3.1,兼解 HIGH② 的自愈重算);或估算器识别 namespace ref 取池内真实大小。修复点在 `crates/df-ai`(估算器)+ `src-tauri`(入账点),df-ai 侧待解禁。
|
||||||
|
- **本质澄清**:namespace_store 是**内存态引用化**,只省进程内存,发 LLM 前展开全文 → **token 零收益**(与 P1 落盘外置的对比见维度五)。
|
||||||
|
|
||||||
|
### HIGH② 每轮 O(n) 全量克隆 + sanitize + 展开 + 摘要 → 会话累计 O(n²)【待解禁】
|
||||||
|
|
||||||
|
- 未超预算分支 `context/mod.rs:198-205` 走 `all_messages_clone()`(`:294-296`,逐条深克隆)+ sanitize + 出口断言;
|
||||||
|
- 展开 `agentic/mod.rs:1927-1944` 对**全部**历史逐条 `read_only` + String 克隆;
|
||||||
|
- 摘要 `summarize_tool_results`(`stall_detector.rs:313-385`)再遍历全部消息做内容变换。
|
||||||
|
- 三者每轮各 O(n)(调度点 `agentic/mod.rs:1904-1921/1945-1957/1981-1984`),会话累计 **O(n²) = 全量重发本体**。根因与①同源:门失效 → 窗口永不收敛 → n 随会话单调涨。**治本 = 组装器方案 P2 分层组装**(接口化,见关联设计)。
|
||||||
|
|
||||||
|
### ★HIGH③ 预算上限恒 128K,不随模型真实窗口【已修-工作区(主会话);子代理残留】
|
||||||
|
|
||||||
|
- 原状:主会话构造点 `commands/ai/mod.rs`(走查时点 :1239)`ContextManager::new(ContextConfig::default())`;`context_helpers.rs:128-136` `max_tokens=128_000`,`budget_limit` 由它算(`:138-143`);子代理同病 `sub_agent.rs:117-127`。真实窗口在 `ModelConfig.context_window`(`df-ai-core/src/model.rs:138-139`)却只被 router 使用,代码自陈该维度「恒过、失效」(`agentic/mod.rs:2012` 注释)。
|
||||||
|
- 影响:1M 窗口模型被当 128K 裁(**能力降级**);小窗口模型(如 deepseek-chat 64K)预算高于实际窗口(**超发风险**)。上界取 1M 而非 128K 的理由:`max_tokens` 只是**裁剪预算**——历史不足则不裁、不会撑大实际发送,卡 128K 等于不治根因。
|
||||||
|
- 修复(Wave J,工作区):`commands/ai/model_context.rs` 新增 `context_config_for_model` 三级优先(预设表/用户手填可信窗口/未知回退 128K)+ 仅按窗口下调或持平、绝不放大 + 安全顶挡脏预设值;主会话收口单一入口 `apply_model_window`(`commands/ai/mod.rs:1274-1284`,构造仍 default、发送入口按 resolved_model 重设)+ 7 单测。
|
||||||
|
- **残留缺口**:子代理未接——`sub_agent.rs:119`(`SUB_AGENT_CONTEXT_MAX_TOKENS=128_000`)、`:123-126`、`:761` 仍固定 128K,待改走 `context_config_for_model` 统一口径。
|
||||||
|
|
||||||
|
### MED⑤ 估算器 chars_ratio=0.35 系统性偏差【待解禁】
|
||||||
|
|
||||||
|
`context_helpers.rs:29-44/73-74`:中文低估 2-3 倍、base64 高估(注释自陈 OpenAI 按像素计而非字节,0.35 偏保守)。多模态回归修正已并入(parts 文本/base64 计入 + url 图 1600 token 地板折算在 ×ratio 之后),但系数本身未分语种/分载荷。方向:分系数校准,需独立评估裁剪边界。
|
||||||
|
|
||||||
|
### MED⑥ 保护区固定 6 条 + 淘汰补偿仅 300 字【待解禁】
|
||||||
|
|
||||||
|
`context/mod.rs:648/:655`:保护区条数硬编码;被淘汰内容的补偿摘要极短,早期主题对 LLM 快速失忆。与⑦(无召回)叠加后**窗口外内容实质永久丢失**。
|
||||||
|
|
||||||
|
### LOW⑬ 估值双轨从不互相校准
|
||||||
|
|
||||||
|
`estimated_prompt`(路由用,较准)与 `history_tokens`(预算用,失真)两轨并存,无任何校准/对账点——①的失真因此长期无告警。修①时可顺带加「双轨偏差超阈即 warn」的可观测。
|
||||||
|
|
||||||
|
### LOW⑭ sys_tokens 未计工具 schema
|
||||||
|
|
||||||
|
system 侧估算不含工具 schema 体积,预算口径偏乐观。
|
||||||
|
|
||||||
|
### 前缀缓存两处失效源(组装侧)
|
||||||
|
|
||||||
|
- **⑨ 滑动裁剪位移前缀【待解禁】**:超预算时 `context/mod.rs:239-246` 切 `messages[trim_end..]`——头部被切,整个请求前缀位移,provider 自动前缀缓存(DeepSeek/Anthropic)整段失效。已有部分缓解:淘汰摘要改尾部追加(`:259-261`)、`push_system_anchor` 避让悬空头(`:582-595`)、`tool_view_decisions` 冻结压缩决策防跨轮翻转(`stall_detector.rs:352-363`);但**基础裁剪本身仍位移前缀**,且淘汰摘要内容逐轮漂移(`:259` 每轮即时重生成,被淘汰区单调增长)。
|
||||||
|
- **⑧ system 可变段内嵌稳定段【已修-工作区】**:可变段(日期 + 项目/任务清单)混在稳定前缀内致前缀缓存失效;清单描述不截断(走查时点 `prompt.rs:565/:584`)加剧体量。修复(Wave J):`df-ai-anthropic-cache` 拆分开关**默认开**(`prompt.rs:653-687`,键名保留历史名、语义扩展覆盖 openai_compat 自动前缀缓存——易变段挪到消息流末尾,开关关时非官方网关零风险)+ 清单描述截断 `CONTEXT_DESC_MAX_CHARS=120`(`prompt.rs:562/:604/:629`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、压缩维度
|
||||||
|
|
||||||
|
### HIGH④ 压缩单向不可逆,被压原文对 LLM 永久不可见【待解禁】
|
||||||
|
|
||||||
|
- `compress_old_messages`(`context/mod.rs:664-699`)标记 Compressed(幂等:已压跳过、token 只扣首次)→ 发送视图 sanitize step0 按 `is_active` 过滤出局(`context/mod.rs:107-108` 统一剔除)。
|
||||||
|
- 后果:压缩后 LLM 对早期细节**不可回读**;窗口外内容(见⑥⑦叠加)实质永久丢失。设计 C-r1「压缩不丢/可召回」未落地。
|
||||||
|
- 方向:复用 P1 句柄模式——压缩写 pointer 可回读(压缩摘要 + 「原文句柄路径」,agent 需要时 read_file 找得回),与工具结果外置同构。
|
||||||
|
|
||||||
|
### MED⑩ 一次压尽,无分批【待解禁】
|
||||||
|
|
||||||
|
触发压缩时一次压完可压区,无 windows 式分批(分批可平摊摘要质量波动与 token 峰值)。
|
||||||
|
|
||||||
|
### HIGH⑪ 后台压缩无 epoch/内容版本守卫【已修-工作区】
|
||||||
|
|
||||||
|
- 原状(`context_lifecycle.rs:206-323` 走查时点):后台 task 无任何守卫——`clear()` 保留 per_conv 条目且复位 is_compressing → **压缩窗口内旧摘要可注入已清空的新上下文**(跨代污染)。loop 侧有 F1 epoch,task 侧没有。
|
||||||
|
- 修复(Wave J,工作区):**内容版本哨兵 = 首条消息全局唯一 id**(`context_lifecycle.rs:63-68`)——否掉 len 方案:压缩期间 loop 正常 append 会使 len 增长,以 len 相等为守卫永不生效;重建后首条必换新 id,是「上下文被整体替换」的准确且零成本哨兵。快照触发时首条 id,后台 task 提交前比对(`:74/:153`),不匹配即丢弃本次摘要。
|
||||||
|
|
||||||
|
### HIGH⑫ 后台压缩无 panic 兜底【已修-工作区】
|
||||||
|
|
||||||
|
- 原状:后台 task panic → is_compressing 永真 → **该会话压缩永久停摆**。
|
||||||
|
- 修复(Wave J,工作区):整个后台 body 包 `catch_unwind`(复用 `mod.rs::unwind_to_result` 薄包装,`context_lifecycle.rs:236-244`、复位句柄 `:79-82`),panic 以 Err 返回 → 复位 is_compressing + warn。依赖 panic=unwind(tokio 默认);panic=abort 构建下退化为进程终止(尽力兜底,已在注释声明)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、自愈清洗维度(sanitize)
|
||||||
|
|
||||||
|
本维整体结论**良好**,一处缺陷已修:
|
||||||
|
|
||||||
|
- **已核良好**:裁剪三元组原子性正确(`context/mod.rs:612-642`);909① 两处修复现状有效——`push_system_anchor` 锚点避让悬空头(`:582-595`)、压缩重入单锁复位,未见旧窗口复现。
|
||||||
|
- **出口防御在位**:畸形配对自愈后 `assert_placeholder_pairing` 出口断言(`context/mod.rs:198-205` 路径内),失败降级 `TOOL_MISSING_PREFIX` 自愈,防 400 orphan 复发。
|
||||||
|
- **已修【已修-工作区】多夹层归位有损**(对抗审查③):原 `reorder_interlopers_after_tool_run` 只把头与「首个连续 tool 段」间的夹层后移 → `[head(c1,c2), X, tool(c1), Y, tool(c2)]` 时 c2 仍被判未闭合剥除(非回归,但「无损」宣称不成立)。守夜期重写为**聚拢该头全部响应(保序)到夹层之前**,边界语义不变(遇下一个头即停/响应集合耗尽即停),更名 `gather_tool_responses`(`sanitize.rs:223`,调用点 `:50`,描述做的事、短一半);新增多夹层 + 幂等单测。「无损归位」至此对多夹层成立。复验:`cargo test -p df-ai --lib context` 157 passed / 0 failed。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、记忆召回维度
|
||||||
|
|
||||||
|
- **⑦ 窗口外历史零召回【待解禁(属 P2 分层组装)】**:组装是隐式的——system 由 `build_runtime_prompt`(`agentic/mod.rs:3042`)拼、历史由 `build_for_request` 拼、易变尾由 `merge_volatile_tail` 拼,无统一组装器对象、无可观测统计;检索**仅覆盖知识库**:`hybrid_search`(`knowledge_inject.rs:421`)→ `build_knowledge_context`(`:508`)→ `build_knowledge_context_for_conv`(`:587`),**窗口外对话历史与压缩摘要无处可召回**。与⑥(补偿仅 300 字)叠加 = 「裁掉即失忆」。
|
||||||
|
- **方向(组装器方案 §3.3 已定)**:检索召回复用 `knowledge_inject` 单入口——把窗口外历史/压缩摘要作为第三类召回源挂进既有管线,不另造第二套检索;L3 记忆卡(`上下文三层记忆-设计-2026-10-04.md` C-m1)是召回的内容来源,组装器只提供入口。
|
||||||
|
- **跨会话召回底座查证(Wave L)**:文件租约与跨会话召回 **HEAD 已实现**(`src-tauri/src/commands/ai/lease.rs`,cb260ab;召回链 cb260ab+9e81182)——未另造双轨,只补纯函数与单测(lease 11 / knowledge_inject 29 通过),W-h 已销账。新需求只接既有底座,勿重建。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、工具结果维度
|
||||||
|
|
||||||
|
- **namespace 引用化机制(HIGH① 的机制载体)**:判定 `should_use_namespace`(`namespace_store.rs:47-53`)两条路——`ALWAYS_LARGE_TOOLS` 四工具命中即引用(与体积无关)或超 `NAMESPACE_BYTE_THRESHOLD`/`NAMESPACE_LINE_THRESHOLD`;入池 `store()`(`:124-131`),pin 占比上限 0.5(`:44`),EvictionLog 容量 50 条(`:41`)。**定位澄清**:它治的是进程内存,不治 token——发送时展开回全文。
|
||||||
|
- **P1 工具结果外置(真正治 token)【并行写者 WIP,未提交】**:`crates/df-ai/src/tool_offload.rs`(新,~117 行)+ `context_helpers.rs`(`READ_FILE_OFFLOAD_BYTES=8KB` / `extract_key_info` 句柄追加)+ `lib.rs`(pub mod)+ `tests/**`——超阈值结果**正文落盘**、上下文只留「摘要 + 句柄路径」,agent 可 read_file 展开回读:被省略的中段不进 prompt 但找得回 → 真正减 token 且不丢信息。与 namespace_store 互补不冲突;降级契约(base 未注入返回 None)+ 幂等(内容稳定哈希只落盘一次)+ 只增不减保留策略齐备。该批静置归属待用户拍板(含 `src-tauri/src/lib.rs`、`stall_detector.rs` 挂点)。
|
||||||
|
- **HIGH① 的入账修复与外置的衔接**:外置落地后,push 侧按**落盘前原文体量**入账即可同源治掉①(记账基点 = 发送视图尺寸);两者是同一根因(记账失真)的内容侧与账本侧修复,须协同验收。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、修复状态总表(截至 2026-10-10 收盘)
|
||||||
|
|
||||||
|
| # | 瓶颈 | 级别 | 状态 | 落点 |
|
||||||
|
|---|------|------|------|------|
|
||||||
|
| ① | history_tokens 与真实体量脱钩 | HIGH | 待解禁(依赖 df-ai) | 入账点 src-tauri + 估算器 df-ai |
|
||||||
|
| ② | 每轮 O(n) 全量重发 → O(n²) | HIGH | 待解禁(P2 分层组装) | 组装器方案 |
|
||||||
|
| ③ | 预算恒 128K | ★HIGH | **主会话已修-工作区**;子代理残留 | `model_context.rs` + `mod.rs:1274`;子代理 `sub_agent.rs:761` 待接 |
|
||||||
|
| ④ | 压缩不可逆无重载 | HIGH | 待解禁(C-r1 句柄化) | df-ai |
|
||||||
|
| ⑤ | chars_ratio 偏差 | MED | 待解禁 | df-ai |
|
||||||
|
| ⑥ | 保护区 6 条 + 补偿 300 字 | MED | 待解禁 | df-ai |
|
||||||
|
| ⑦ | 窗口外零召回 | MED | 待解禁(复用 knowledge_inject 入口) | src-tauri |
|
||||||
|
| ⑧ | system 可变段毁前缀缓存 | ★ | **已修-工作区**(开关默认开 + 描述截断 120) | `prompt.rs` |
|
||||||
|
| ⑨ | 滑动裁剪位移前缀 | MED | 待解禁 | df-ai |
|
||||||
|
| ⑩ | 一次压尽无分批 | MED | 待解禁 | df-ai |
|
||||||
|
| ⑪ | 后台压缩无版本守卫 | HIGH | **已修-工作区**(首条消息 id 哨兵) | `context_lifecycle.rs:63-74` |
|
||||||
|
| ⑫ | 后台压缩无 panic 兜底 | HIGH | **已修-工作区**(catch_unwind 复位) | `context_lifecycle.rs:79-82,236-244` |
|
||||||
|
| ⑬ | 估值双轨无校准 | LOW | 随①顺带 | — |
|
||||||
|
| ⑭ | sys_tokens 无工具 schema | LOW | 待解禁 | df-ai |
|
||||||
|
| — | 多夹层归位有损(自愈维) | 缺陷 | **已修-工作区**(`gather_tool_responses`) | `sanitize.rs:223` |
|
||||||
|
|
||||||
|
> 收敛路线:★ 项与守卫类已落;①④⑨ 等 df-ai 侧修复待并行写者(工具结果外置 P1)完工解禁后接手,届时以「记账基点 = 发送视图尺寸单一真值」一条主线串起 ①→②自愈,量化目标 223:1 → ≤50:1(根治设计 §四)。
|
||||||
@@ -259,7 +259,7 @@
|
|||||||
|
|
||||||
> **已完成**:`SELECT *` 改为显式 14 列(id, kind, title, content, tags, status, confidence, reuse_count, verified, source_project, source_ref, reasoning, created_at, updated_at),`embedding` 单独另取。消除隐式依赖,字段级精简待单独立项。commit 4a95f6a(该 commit 内编号为 FR-D2)。
|
> **已完成**:`SELECT *` 改为显式 14 列(id, kind, title, content, tags, status, confidence, reuse_count, verified, source_project, source_ref, reasoning, created_at, updated_at),`embedding` 单独另取。消除隐式依赖,字段级精简待单独立项。commit 4a95f6a(该 commit 内编号为 FR-D2)。
|
||||||
|
|
||||||
### 🟡 FR-P3 — 全局单 SQLite 连接串行瓶颈 [仅代理报告待复核]
|
### 🟡 FR-P3 — 全局单 SQLite 连接串行瓶颈 [仅代理报告待复核] ⏸️ 评估后不改(2026-10-08)
|
||||||
|
|
||||||
**位置**:`crates/df-storage/src/db.rs:13-60` + `crud.rs` 宏内 `spawn_blocking`+`blocking_lock`
|
**位置**:`crates/df-storage/src/db.rs:13-60` + `crud.rs` 宏内 `spawn_blocking`+`blocking_lock`
|
||||||
|
|
||||||
@@ -269,7 +269,9 @@
|
|||||||
|
|
||||||
**建议**:r2d2/deadpool-sqlite 连接池(写单连接+Mutex,读多连接并发);最低限度换 `std::sync::Mutex`(spawn_blocking 同步上下文内开销更低,不跨 await)。
|
**建议**:r2d2/deadpool-sqlite 连接池(写单连接+Mutex,读多连接并发);最低限度换 `std::sync::Mutex`(spawn_blocking 同步上下文内开销更低,不跨 await)。
|
||||||
|
|
||||||
### 🟡 FR-P4 — `human_node` 500ms 忙轮询取消状态 [已核实]
|
> **⏸️ 2026-10-08 评估后判定不改**:① 架构约束——`conn()` 返 `Arc<Mutex<Connection>>`,全仓数十处调用点按此签名取锁,改连接池需重构全部调用点 + 迁移逻辑(`run_migrations` 亦持单锁),改动面大风险高;② 收益极低——SQLite 写操作本就串行(WAL 下写写互斥),连接池对写无收益,读并发在桌面单用户场景无实际压力;③ 现状已缓解——`busy_timeout(5000)` 已解 GUI+MCP server 多进程写冲突(真实痛点)且已落地;④ 收益/风险比不划算,动 DB 核心层高危。保留 `db.rs:60` TODO 注释备忘。
|
||||||
|
|
||||||
|
### 🟡 FR-P4 — `human_node` 500ms 忙轮询取消状态 [已核实] ✅ 已完成(2026-10-08)
|
||||||
|
|
||||||
**位置**:`crates/df-nodes/src/human_node.rs:54`
|
**位置**:`crates/df-nodes/src/human_node.rs:54`
|
||||||
|
|
||||||
@@ -279,7 +281,9 @@
|
|||||||
|
|
||||||
**建议**:取消走独立 `tokio::sync::Notify`/oneshot,HumanNode `notify.notified().await` 零轮询。注:todo.md:142 已记"停止生成 idle 用 Notify 替代轮询",此为同类不同位置(审批节点 vs stopChat)。
|
**建议**:取消走独立 `tokio::sync::Notify`/oneshot,HumanNode `notify.notified().await` 零轮询。注:todo.md:142 已记"停止生成 idle 用 Notify 替代轮询",此为同类不同位置(审批节点 vs stopChat)。
|
||||||
|
|
||||||
### 🟡 FR-P5 — `build_system_prompt` 每次发消息全表扫项目 [仅代理报告待复核]
|
> **✅ 2026-10-08 已完成**:`StateMachine` 加 `cancel_notify: Arc<tokio::sync::Notify>` 字段(与 states 同 Arc 共享,clone 浅拷贝同一 Notify);`set_cancelled` 置位后 `notify_waiters()` 唤醒;新增 `cancel_notified()` 暴露 `Notified` future。`human_node.rs` select! 循环内 `let cancel_wait = ctx.node_status.cancel_notified()` 替代 `cancel_tick.tick()`,唤醒后仍复查 `is_cancelled` 兜底(Notify 无状态,可能因他节点取消误唤醒)。`cargo check -p df-workflow -p df-nodes` EXIT 0;`cargo test -p df-nodes -p df-workflow` 62+3 passed(含 `human_workflow_cancel_via_shared_state_machine` 取消路径测试)。
|
||||||
|
|
||||||
|
### 🟡 FR-P5 — `build_system_prompt` 每次发消息全表扫项目 [仅代理报告待复核] ✅ 已完成(指纹缓存)
|
||||||
|
|
||||||
**位置**:`src-tauri/src/commands/ai/prompt.rs:70-86`
|
**位置**:`src-tauri/src/commands/ai/prompt.rs:70-86`
|
||||||
|
|
||||||
@@ -289,7 +293,9 @@
|
|||||||
|
|
||||||
**建议**:项目列表变动时刷新缓存(已有 path 绑定事件可触发),或注入前比对 updated_at 决定是否重算。
|
**建议**:项目列表变动时刷新缓存(已有 path 绑定事件可触发),或注入前比对 updated_at 决定是否重算。
|
||||||
|
|
||||||
### 🟡 FR-P6 — `AiChat.vue` watch messages 用 JSON.stringify 全量快照 [仅代理报告待复核]
|
> **✅ 已完成(指纹缓存)**:`prompt.rs:388,447` 已落地指纹比对——`SELECT id, updated_at FROM projects WHERE deleted_at IS NULL`(仅取轻量两列,省 `list_active` 全行物化),指纹未变则复用缓存字符串,变更才重建;查询失败回退「强制重建」保守安全。原「全表扫 + 每次重拼」病灶已消除。
|
||||||
|
|
||||||
|
### 🟡 FR-P6 — `AiChat.vue` watch messages 用 JSON.stringify 全量快照 [仅代理报告待复核] ✅ 已完成(已消除)
|
||||||
|
|
||||||
**位置**:`src/components/AiChat.vue:670-678`
|
**位置**:`src/components/AiChat.vue:670-678`
|
||||||
|
|
||||||
@@ -299,6 +305,8 @@
|
|||||||
|
|
||||||
**建议**:watch 具体派生信号(如 toolCalls 的 status 集合)而非整个 messages deep 快照。
|
**建议**:watch 具体派生信号(如 toolCalls 的 status 集合)而非整个 messages deep 快照。
|
||||||
|
|
||||||
|
> **✅ 已完成(已消除)**:`AiChat.vue` 全仓 grep `JSON.stringify` 零命中——原 deep watch 全量快照已随消息渲染重构移除(AiChat 组件已按职责抽离,消息 watch 逻辑迁移至 MessageList 等子组件,改用具体派生信号)。病灶不复存在。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. 可读性 / DRY(重构项)
|
## 6. 可读性 / DRY(重构项)
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# 版本升级链走查(2026-10-10)
|
||||||
|
|
||||||
|
> 走查方式:用户要求的只读走查 + 本地全链演练(零线上影响),源码与现场日志取证;本文为全文落档(看板/汇总仅摘要)。
|
||||||
|
> 铁证来源:应用 trace(`%TEMP%/devflow-trace.log`)· 演练服务访问日志(`temp/upgrade-drill/access.log`)· 脚本与配置现场(`scripts/publish-release.ps1` / `scripts/package-devflow.ps1` / `src-tauri/tauri.conf.json` / `src-tauri/src/commands/update.rs`)。
|
||||||
|
> 结论先行:**升级核心机制本身零缺陷**——四发现全在链路外围(端点发布/脚本产物格式/传输协议/本地打包拆分),这也是本地演练能全链闭环的前提。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、链路全景(走查基线:代码链本身完整 ✓)
|
||||||
|
|
||||||
|
- **后端**:`app_check_update` / `app_download_install_update`(`src-tauri/src/commands/update.rs`)——走 Tauri updater `check()` 拉 `tauri.conf.json` 的 `plugins.updater.endpoints` 配置的 latest.json,**内建 ed25519 签名校验**(公钥 `tauri.conf.json:47`);无更新返回 `has_update=false`,网络/解析失败返回 Err(前端据此提示,不谎报「已是最新」)。
|
||||||
|
- **前端**:`useUpdateCheck` → 徽章 / 设置页(徽章事件通道错配——Tauri 事件 vs window CustomEvent 同名不同通道致徽章永不触发——已于前批修复并登记看板 925)。
|
||||||
|
- **发布侧**:`publish-release.ps1` 六步(版本双源校验 → 构建 → 定位产物 → 生成 latest.json → 上传 app.1216.top);版本双源校验 `:28-38`(tauri.conf.json vs Cargo.toml 必须同值,防 D1 复发);私钥已就位 `~/.tauri/devflow.key`。
|
||||||
|
- **版本一致性闸**:`update.rs:89-104` 用 `CARGO_PKG_VERSION` 与 latest.json 的 `version` 比较,非更新返回 `has_update=false`(演练中验证其正确拦截,见 §三)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、四发现(按严重度)
|
||||||
|
|
||||||
|
### 发现 1 🔴 线上端点从未发布——应用内升级从未真正可用【已修复:v0.1.2 发布上线】
|
||||||
|
|
||||||
|
- 事实:`https://app.1216.top/download/devflow/latest.json` **404**;目录 403(存在但空)——从未有产物上传过。
|
||||||
|
- 铁证:应用 trace 每 15 分钟报 `update endpoint did not respond with a successful status code`,**07:33 / 07:48 / 08:03 / 08:18 / 08:23 连续五次**——自动检查一直在跑、一直失败。
|
||||||
|
- 影响:发布流程「能构建能上传产物」但 latest.json 这最后一环缺失,应用内升级通道自上线以来形同虚设。
|
||||||
|
|
||||||
|
### 发现 2 🟠 发布脚本产物名与 Tauri v2 不匹配——跑通也会 404【已修复:脚本 v2 适配】
|
||||||
|
|
||||||
|
- 事实:`publish-release.ps1` 期待 v1 格式 `...setup.nsis.zip` + `.nsis.zip.sig`;而 `createUpdaterArtifacts: true`(`tauri.conf.json:33`)在 **Tauri v2 实际产出 `...setup.exe.sig`**(更新包即安装器本身)。
|
||||||
|
- 两个后果:① 脚本停在「未找到 .sig 签名文件」直接 exit;② 即使产物名对上,它写进 latest.json 的 url 也指向不存在的 `.nsis.zip` → 客户端下载 404。
|
||||||
|
- 修法二选一(当时列出):脚本改读 `.exe.sig` + url 指 exe(v2 格式,**已采纳**);或配置改 `createUpdaterArtifacts: "v1Compatible"`。
|
||||||
|
|
||||||
|
### 发现 3 🟡 updater 强制 https——自建 http 更新源不可行【机制确认,线上无碍】
|
||||||
|
|
||||||
|
- 事实:本地 http 端点被 updater 拒绝,实测报错原文 `The configured updater endpoint must use a secure protocol like https`。
|
||||||
|
- 判定:线上是 https 无碍;但**演练/私有 http 更新源**必须二选一——真证书,或构建期 `--config` 注入 `dangerousInsecureTransportProtocol: true`(演练采纳后者,见 §三,仅演练专用构建携带、不改生产配置语义)。
|
||||||
|
|
||||||
|
### 发现 4 🟡 本地 `package-devflow -Mode full` 缺密钥必报「失败」并跳过安装段【已修复:换装与签名拆开】
|
||||||
|
|
||||||
|
- 事实:本机未设 `TAURI_SIGNING_PRIVATE_KEY` 时,产包 updater 签名段必报失败并**跳过安装段**——这正是此前每次都要手工跑 `swap-install.ps1` 的根因(2026-10-10 实录:`package-devflow -Mode full` 产包后因缺密钥报失败跳过安装)。
|
||||||
|
- 本质:本地换装(自用安装最新构建)与发版签名(updater 产物)是两类需求被一个开关捆死。修法:本地换装不需要签名产物,两者拆开。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、本地全链演练(零线上影响)
|
||||||
|
|
||||||
|
### 演练设施
|
||||||
|
|
||||||
|
| 件 | 位置 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| 端点覆盖 | `update.rs:63-84` `build_updater` | env `DEVFLOW_UPDATER_ENDPOINT` 覆盖端点,**不设即走 tauri.conf 配置**(生产零影响);覆盖时 warn 日志「更新端点被环境变量覆盖(本地演练模式)」 |
|
||||||
|
| 传输放开 | `temp/drill-tauri-override.json` | 仅 `dangerousInsecureTransportProtocol: true`,演练专用构建以 `--config` 注入(治发现 3 的演练侧) |
|
||||||
|
| 真 v2 产物 | `temp/upgrade-drill/` | `devflow_0.1.2_x64-setup.exe`(13.4MB)+ `.exe.sig`(436B,base64 minisign 格式,与 `tauri.conf.json:47` pubkey 配对)+ `latest.json`(version/notes/pub_date/platforms.windows-x86_64.{signature,url},url 指 `http://127.0.0.1:8765/...exe`) |
|
||||||
|
| 带日志本地源 | 同目录 `access.log` | 8765 端口静态服务,全量记录请求 |
|
||||||
|
|
||||||
|
### 演练链与现场证据(access.log 时间线)
|
||||||
|
|
||||||
|
1. **端点覆盖生效 → 检查 200**:`08:47:51 GET /latest.json 200`——env 覆盖后的首次检查即通(对照发现 1 的 trace 连续失败,同一检查链路换个可用端点立即恢复)。
|
||||||
|
2. **发现更新 → 下载 200**:`09:31:36-09:32:16` latest.json 200 → `GET /devflow_0.1.2_x64-setup.exe 200`(完整下载 13.4MB)。
|
||||||
|
3. **验签通过**:latest.json 的 signature(ed25519)经 `tauri.conf.json:47` 公钥校验通过——签名/公钥/私钥三件套自洽(此后下载才允许进入安装)。
|
||||||
|
4. **版本一致性闸正确拦截**:`update.rs:89-104` 以 `CARGO_PKG_VERSION` 与 latest.json `version` 比较——当本机版本 ≥ 通告版本时返回 `has_update=false` 拒绝安装(闸本身行为正确);演练初期版本差未成立即被此闸拦下,**0.1.2 产物重建(本机保持 0.1.1 演练版)后版本差成立**。
|
||||||
|
5. **闭环复现**:`12:43:03-12:43:50` 第二轮完整通过(latest.json 200 → exe 200 → 回查 latest.json)——检查→下载→验签→版本判定全链跑通;且 `09:43:43→11:46:37` 每 ~15 分钟一次的周期自动检查连续 9 次全部 200——自动检查定时器在演练端点下工作正常(对照发现 1:同一台机器同一定时器,此前对线上端点连续失败)。
|
||||||
|
|
||||||
|
> 演练结论:**升级核心链(检查→下载→ed25519 验签→版本一致性闸→安装)在本机全链可用**,四发现均为外围问题且逐一有解。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、已落地修复清单(截至当日收盘,均在盘待提交)
|
||||||
|
|
||||||
|
| # | 修复 | 落点 | 治哪个发现 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | env 端点覆盖 `DEVFLOW_UPDATER_ENDPOINT`(不设即走配置 + warn 日志) | `src-tauri/src/commands/update.rs:63-84` | 演练设施(兼私有源能力) |
|
||||||
|
| 2 | 脚本 v2 产物适配:`.exe.sig`/`.nsis.zip.sig` 双格式探测,与更新产物正确配对(v2 签名配 setup.exe,v1 配 zip);url 指向真实产物 | `scripts/publish-release.ps1:65-71` | 发现 2 |
|
||||||
|
| 3 | SHA256 计算兜底:本机 PowerShell 5.1 缺 `Get-FileHash`,异常走 `certutil -hashfile`(同 swap-install 兜底口径) | `scripts/publish-release.ps1:84-87` | 发布机环境鲁棒性 |
|
||||||
|
| 4 | **v0.1.2 正式发布上线**:`publish-release.ps1 -SkipBuild` 上传成功,线上 latest.json 实测 **200**(version 0.1.2)——应用内升级通道正式激活 | 线上 `app.1216.top/download/devflow/` | **发现 1(🔴 404 清零)** |
|
||||||
|
| 5 | 本地换装与发版签名拆开:未设 `TAURI_SIGNING_PRIVATE_KEY` 时以 `--config` 关掉 `createUpdaterArtifacts`,不再假失败、不再跳过安装段;发版(需签名)走 `publish-release.ps1` | `scripts/package-devflow.ps1:39-44` | 发现 4 |
|
||||||
|
| 6 | (前批)UpdateBadge 通道错配修复:补 window CustomEvent 监听 + 卸载清理 | `src/components/layout/UpdateBadge.vue` | 前端侧「有更新不亮徽章」 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、待办与纪律留痕
|
||||||
|
|
||||||
|
- **正式验收未走完**:本机装的仍是 **0.1.1 演练版**(带 env 覆盖构建)。验收动线:正常重启应用(**不设 env**)→ 检查更新 → 从**线上**发现 0.1.2 → 升级 → 关于页显示 v0.1.2 → 再查一次显「已是最新」。
|
||||||
|
- **纪律事件**:当日 e296c84 提交经用户令「撤」已回退,升级链 4 文件(update.rs / publish-release.ps1 / package-devflow.ps1 / tauri.conf.json)回工作区零丢失;纪律强化为**一切提交攒批、最后由用户显式发令**。
|
||||||
|
- **遗留提示**:发现 3 的 `dangerousInsecureTransportProtocol` 仅存在于演练 override 配置,生产 `tauri.conf.json` 未含、也不应含;若未来要私有 http 源,正路是证书而非放开传输。
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user