Compare commits
58
Commits
c70faa7bb8
...
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 |
@@ -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/
|
||||||
|
|||||||
@@ -16,5 +16,10 @@
|
|||||||
- 边界对齐意志卡 forbidden:发版/部署/重启线上、git push、生产数据操作、对外发消息,攒齐等拍板。
|
- 边界对齐意志卡 forbidden:发版/部署/重启线上、git push、生产数据操作、对外发消息,攒齐等拍板。
|
||||||
- 本机工具/脚本的行为变更属 report_after 级(干完通报、可回滚),不属确认级。
|
- 本机工具/脚本的行为变更属 report_after 级(干完通报、可回滚),不属确认级。
|
||||||
|
|
||||||
|
## 命名与代码风格(2026-10-10 用户重申)
|
||||||
|
|
||||||
|
- **变量/方法名:精简 + 准确**——一眼看出「是什么/做什么」且不超必要长度。忌把实现细节写进名字(`..._after_tool_run`)、忌无信息缩写(`tmp2`/`h`)、忌同概念多叫法。名字过长通常说明该拆函数或把说明下沉到注释(注释承载「为什么」,名字只承载「是什么」)。
|
||||||
|
- **DRY · 简洁 · 扩展性 · 高内聚低耦合**(同期重申):新增能力先找共用底座(如定时消息与定时任务共用一张 `ai_outbox` + 一个调度器),不造双轨;同一事实只留**单一真值来源**(如 token 估值口径)。
|
||||||
|
|
||||||
## 其他规范
|
## 其他规范
|
||||||
- 架构设计文档在 `docs/02-架构设计/`;当前活跃专项:无人值守可靠性(B1-B4)+ 异步任务会话(13-17),设计见 `temp/` 与 docs。
|
- 架构设计文档在 `docs/02-架构设计/`;当前活跃专项:无人值守可靠性(B1-B4)+ 异步任务会话(13-17),设计见 `temp/` 与 docs。
|
||||||
|
|||||||
Generated
+249
-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",
|
||||||
@@ -1039,7 +1059,10 @@ dependencies = [
|
|||||||
"df-workflow",
|
"df-workflow",
|
||||||
"futures",
|
"futures",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
"hex",
|
||||||
|
"hmac",
|
||||||
"htmd",
|
"htmd",
|
||||||
|
"httpdate",
|
||||||
"keyring",
|
"keyring",
|
||||||
"larksuite-oapi-sdk-rs",
|
"larksuite-oapi-sdk-rs",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
@@ -1047,6 +1070,8 @@ dependencies = [
|
|||||||
"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",
|
||||||
@@ -1054,6 +1079,7 @@ 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",
|
"tokio-tungstenite 0.23.1",
|
||||||
@@ -1076,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",
|
||||||
@@ -1278,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]]
|
||||||
@@ -1571,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"
|
||||||
@@ -2142,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"
|
||||||
@@ -2572,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"
|
||||||
@@ -2896,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"
|
||||||
@@ -3277,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"
|
||||||
@@ -3420,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"
|
||||||
@@ -4059,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",
|
||||||
@@ -4213,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"
|
||||||
@@ -4661,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"
|
||||||
@@ -4830,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"
|
||||||
@@ -4911,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",
|
||||||
@@ -4944,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"
|
||||||
@@ -4968,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",
|
||||||
@@ -5179,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"
|
||||||
@@ -5204,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",
|
||||||
@@ -5227,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",
|
||||||
@@ -6376,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"
|
||||||
@@ -7069,7 +7290,7 @@ dependencies = [
|
|||||||
"gtk",
|
"gtk",
|
||||||
"http",
|
"http",
|
||||||
"javascriptcore-rs",
|
"javascriptcore-rs",
|
||||||
"jni",
|
"jni 0.21.1",
|
||||||
"libc",
|
"libc",
|
||||||
"ndk",
|
"ndk",
|
||||||
"objc2",
|
"objc2",
|
||||||
@@ -7133,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"
|
||||||
@@ -7379,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
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -856,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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1163,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() {
|
||||||
@@ -1200,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();
|
||||||
@@ -1217,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");
|
||||||
@@ -1231,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");
|
||||||
}
|
}
|
||||||
@@ -1240,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(_), .. }));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,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) 接入后续。
|
||||||
@@ -50,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;
|
||||||
|
|||||||
@@ -60,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 {
|
||||||
@@ -138,6 +154,12 @@ impl NamespaceStore {
|
|||||||
|
|
||||||
fn store_inner(&mut self, tool_name: &str, content: &str, owner: Option<&str>, pinned: bool) -> String {
|
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,不重复占用空间;若本次要求 pin,升级既有条目
|
// 已存在 → 刷新 access_count,不重复占用空间;若本次要求 pin,升级既有条目
|
||||||
@@ -148,7 +170,7 @@ impl NamespaceStore {
|
|||||||
entry.pinned = true;
|
entry.pinned = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return path;
|
return path_with_len;
|
||||||
}
|
}
|
||||||
|
|
||||||
let new_bytes = content.len();
|
let new_bytes = content.len();
|
||||||
@@ -190,7 +212,7 @@ impl NamespaceStore {
|
|||||||
|
|
||||||
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)。
|
/// pin/取消 pin(事后)。pin 总量超 50% 容量时本调用拒绝并返回 false(防失控,拍板 C1)。
|
||||||
@@ -263,6 +285,40 @@ impl NamespaceStore {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 跨重启懒回填: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)
|
||||||
@@ -532,4 +588,61 @@ mod tests {
|
|||||||
assert!(ns.contains(&p_old) == false || ns.pinned_bytes() <= 50,
|
assert!(ns.contains(&p_old) == false || ns.pinned_bytes() <= 50,
|
||||||
"降级机制应使 pin 总量可被压缩");
|
"降级机制应使 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -999,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() {
|
||||||
@@ -1018,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);
|
||||||
|
}
|
||||||
@@ -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"。统一抽掉工具名前缀,只保留版本号本身。
|
||||||
|
|||||||
@@ -320,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>,
|
||||||
}
|
}
|
||||||
@@ -419,7 +419,7 @@ pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> {
|
|||||||
// 提交冻结机械闸(39 治理):冻结在场且命令为 git commit → spawn 前拒绝,改动留工作区
|
// 提交冻结机械闸(39 治理):冻结在场且命令为 git commit → spawn 前拒绝,改动留工作区
|
||||||
crate::commit_freeze::check(&request).map_err(anyhow::Error::msg)?;
|
crate::commit_freeze::check(&request).map_err(anyhow::Error::msg)?;
|
||||||
|
|
||||||
// 探测 pwsh(惰性 + OnceLock 全局缓存,只探一次),使后续 ShellType::default() 可读取缓存
|
// 探测 pwsh(惰性 + 模块级 PWSH_CACHE,成功才写缓存),使后续 ShellType::default() 可读取缓存
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
let _ = probe_pwsh().await;
|
let _ = probe_pwsh().await;
|
||||||
|
|
||||||
@@ -453,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 兜底)
|
||||||
|
|||||||
@@ -125,8 +125,6 @@ impl ScoringEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 统计 text 中命中任一关键词的数量(小写匹配,兼顾中英文)
|
|
||||||
/// 局限:纯子串匹配,不识别"不复用""无用户增长"等否定前缀,接 LLM 后由语义层修正
|
|
||||||
/// 单字否定前缀:关键词紧邻这些字时该命中不计(「不复用」不计入「复用」正向命中)。
|
/// 单字否定前缀:关键词紧邻这些字时该命中不计(「不复用」不计入「复用」正向命中)。
|
||||||
///
|
///
|
||||||
/// 注意「别」单字歧义大(别具/别的/特别 均非否定),故不进单字清单——
|
/// 注意「别」单字歧义大(别具/别的/特别 均非否定),故不进单字清单——
|
||||||
|
|||||||
@@ -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?;
|
||||||
|
|
||||||
|
|||||||
@@ -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:?}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -67,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」
|
||||||
@@ -112,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`。
|
||||||
|
// 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(),
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -646,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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,6 +112,10 @@ fn ai_conversation_from_row(row: &Row<'_>) -> std::result::Result<AiConversation
|
|||||||
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 列老库经 v49 迁移补建。unwrap_or(None) 兜底(NULL=非任务会话)。
|
||||||
loop_state: row.get("loop_state").unwrap_or(None),
|
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),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,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),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,29 +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, loop_state)
|
"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, ?17)",
|
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.loop_state
|
rec.boundary_json, rec.loop_state, rec.status, rec.working_context_json
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
update => |conn, rec| {
|
update => |conn, rec| {
|
||||||
// loop_state 有意不在 SET 列(V49 生命周期标记独立更新,对齐 boundary 早期模式):
|
// loop_state/status 有意不在 SET 列(V49/V50 生命周期标记独立更新,对齐 boundary 早期模式):
|
||||||
// update_full 的调用方(save_conversation 等)从 get_by_id 读旧记录再改元数据写回,
|
// update_full 的调用方(save_conversation 等)从 get_by_id 读旧记录再改元数据写回,
|
||||||
// 读改写窗口内 loop 生命周期可变更(spawn 置 running/收敛清 None)——若 SET 含
|
// 读改写窗口内 loop 生命周期/终局态可变更——若 SET 含二者,旧值会覆盖新值(lost-update)。
|
||||||
// loop_state,旧值会覆盖新值(lost-update)。生命周期只经 set_loop_state 单点写。
|
// 生命周期只经 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
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -249,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
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -298,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)?;
|
||||||
}
|
}
|
||||||
@@ -839,6 +848,29 @@ impl AiConversationRepo {
|
|||||||
.map_err(storage_err)?
|
.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' 残留改写为 'parked'。
|
||||||
///
|
///
|
||||||
/// 进程启动时调用一次:上次运行中 loop_state=running 的会话,进程重启后 loop 必死
|
/// 进程启动时调用一次:上次运行中 loop_state=running 的会话,进程重启后 loop 必死
|
||||||
@@ -1020,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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1068,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]
|
||||||
@@ -1094,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)");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1178,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!(
|
||||||
@@ -1715,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 终态不可重入队");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -87,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)]
|
||||||
@@ -243,29 +243,35 @@ 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, archived, 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), \
|
||||||
|
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 \
|
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",
|
||||||
)
|
)
|
||||||
@@ -840,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 最新值"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -249,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) 的父任务存在性检查:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
//! 数据库迁移 — 建表 SQL 与版本管理
|
//! 数据库迁移 — 建表 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;
|
||||||
|
|
||||||
@@ -59,7 +59,15 @@ pub fn run(conn: &Connection) -> Result<()> {
|
|||||||
// V48 = 项目归档(projects.archived 列,归档功能 P2,同款三方对齐)。
|
// V48 = 项目归档(projects.archived 列,归档功能 P2,同款三方对齐)。
|
||||||
// V49 = loop 生命周期标记(ai_conversations.loop_state TEXT,断点续跑底座:
|
// V49 = loop 生命周期标记(ai_conversations.loop_state TEXT,断点续跑底座:
|
||||||
// running/parked,重启扫描 running→parked 改写,侧栏「可恢复」标记)。
|
// running/parked,重启扫描 running→parked 改写,侧栏「可恢复」标记)。
|
||||||
let steps: [(i32, fn(&Connection) -> Result<()>); 49] = [
|
// 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),
|
||||||
@@ -113,6 +121,14 @@ pub fn run(conn: &Connection) -> Result<()> {
|
|||||||
(48, migrate_v48),
|
(48, migrate_v48),
|
||||||
// V49 = loop 生命周期标记(ai_conversations.loop_state,断点续跑底座)。
|
// V49 = loop 生命周期标记(ai_conversations.loop_state,断点续跑底座)。
|
||||||
(49, migrate_v49),
|
(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 {
|
||||||
@@ -1450,6 +1466,205 @@ fn migrate_v49(conn: &Connection) -> Result<()> {
|
|||||||
Ok(())
|
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)。
|
||||||
@@ -2478,9 +2693,9 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.expect("查 schema_version 应成功");
|
.expect("查 schema_version 应成功");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
max_version, 49,
|
max_version, LATEST_SCHEMA_VERSION as i64,
|
||||||
"全量迁移后 schema_version 应为 49(实际 {}),说明某条 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 也跑)
|
||||||
@@ -2556,7 +2771,7 @@ mod tests {
|
|||||||
|r| r.get(0),
|
|r| r.get(0),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(max_v, 49, "首轮应推进到 49");
|
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();
|
||||||
@@ -2568,7 +2783,7 @@ mod tests {
|
|||||||
|r| r.get(0),
|
|r| r.get(0),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(max_v2, 49, "重跑后应重新推进到 49");
|
assert_eq!(max_v2, LATEST_SCHEMA_VERSION as i64, "重跑后应重新推进到 {}", LATEST_SCHEMA_VERSION);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -2674,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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -396,6 +396,15 @@ pub struct AiConversationRecord {
|
|||||||
/// 纯元数据标记不进 update_full 通用 UPDATE,走专用 set_loop_state。
|
/// 纯元数据标记不进 update_full 通用 UPDATE,走专用 set_loop_state。
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub loop_state: Option<String>,
|
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 工具执行审计记录
|
||||||
@@ -419,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 表,消息拆分存储)。
|
||||||
@@ -470,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>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -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,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,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,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,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`(同一目录写法)。
|
||||||
@@ -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)**,本文档为其补充细节。所有数据模型、设计决策均以源码为基准,已剔除虚构内容。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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 源,正路是证书而非放开传输。
|
||||||
@@ -63,5 +63,5 @@ Phase 1 目标:**引擎骨架**,打通 `df-core → df-workflow → df-stora
|
|||||||
## 参考文档
|
## 参考文档
|
||||||
|
|
||||||
- [ARCHITECTURE.md](../../ARCHITECTURE.md) — 完整架构设计
|
- [ARCHITECTURE.md](../../ARCHITECTURE.md) — 完整架构设计
|
||||||
- [PROGRESS.md](../../PROGRESS.md) — 工作进展与交接
|
- [PROGRESS.md](./PROGRESS.md) — 工作进展与交接
|
||||||
- [Phase1 架构决策](../02-架构设计/滚动规范/Phase1架构决策-2026-06-12.md)
|
- [Phase1 架构决策](../02-架构设计/滚动规范/Phase1架构决策-2026-06-12.md)
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,30 @@
|
|||||||
|
# 飞书通道 WS 假活:探活只告警不自愈
|
||||||
|
|
||||||
|
> 登记: AI-Ops 2026-10-11 22:15 · 来源: 用户 @ bot 零回应事故排查 · 实施归 devflow agent
|
||||||
|
|
||||||
|
## 现象
|
||||||
|
|
||||||
|
用户 20:07 后在飞书 @ bot 发消息,零回应。feishu-channel.log 尾部停在 20:07 「打包换装」watcher 终版,其后 2h+ 零「收到消息」事件——消息根本未达应用,非模型/会话层问题。
|
||||||
|
|
||||||
|
## 证据
|
||||||
|
|
||||||
|
- 日志: `~/AppData/Roaming/top.1216.devflow/feishu-channel.log`
|
||||||
|
- 累计 90 条探活告警「660s 无任何事件且无在途会话(疑似假活,持续出现请重启应用重建连接)」,跨 10-05~10-11 多日分布,连接时好时坏
|
||||||
|
- 时间线: 14:00 前持续告警 → 20:04-20:07 恢复(收到 4 条消息正常受理+终版) → 20:07 起再断,至 22:12 仍零入站,进程未重启(devflow.exe PID 14172 同前)
|
||||||
|
- 应用本身健康: trace.log 持续输出, B5 引擎「🟢 健康,空闲」
|
||||||
|
- 对照: u-claw 曾有同款(热重建不关旧连接致僵尸 WS,closeGraceful 须显式 Close),见 u-claw 记忆 uclaw-zombie-ws-connection
|
||||||
|
|
||||||
|
## 根因方向
|
||||||
|
|
||||||
|
WS 长连接假活: 探活任务已检测到「无事件+无在途」但仅打日志,无自动重建连接动作,恢复依赖新消息触发或人工重启。为何 20:04 能自愈一次、20:07 后不能,需读 SDK 连接层确认(是否依赖入站流量触发重连)。
|
||||||
|
|
||||||
|
## 修复建议
|
||||||
|
|
||||||
|
1. 探活告警连续 N 次(如 2 次,约 20 分钟)→ 主动断开并重建 WS 连接(显式 Close 旧连接防僵尸),重建结果写日志
|
||||||
|
2. 重建失败退避重试,持续失败升级通知(桌面通知/卡片)
|
||||||
|
3. 复核 90 条告警的历史分布,确认是否存在更早未被用户察觉的静默窗口
|
||||||
|
|
||||||
|
## 排除项
|
||||||
|
|
||||||
|
- 97「收到消息」vs 47「受理返回」差值: 新旧版本日志口径混杂(受理返回为后加日志),受理守卫拒绝仅 2 条且走旧「AI 正在生成中」路径,无消息丢失
|
||||||
|
- watcher 终版无「回复」行: 新版投递走卡片原地更新(edit_card),非回复消息,属正常
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# DevFlow 批次可视化总览
|
# DevFlow 批次可视化总览
|
||||||
|
|
||||||
> 基于 [Batch.md](../Batch.md) 生成 | 更新: 2026-07-01
|
> 基于 [Batch.md](./07-项目管理/Batch.md) 生成 | 更新: 2026-07-01
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+47
-59
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> 创建: 2026-06-10 | 当前阶段: Phase 2 本地优先开发流程验证
|
> 创建: 2026-06-10 | 当前阶段: Phase 2 本地优先开发流程验证
|
||||||
> 更新: 2026-08-02 | 05-代码审查 补录 2026-08-02 走查批次:DevFlow AI 全库走查(00-05,12 crate/78 问题)、claude 多代理走查(汇总+nodes/ai-storage/ideas-前端/基建 5 文档)、本次修复摘要(5 根因 + 13 agent P0/P1/P2 + 架构专项 3,提交 fc249ad/f719546)
|
> 更新: 2026-08-02 | 05-代码审查 补录 2026-08-02 走查批次:DevFlow AI 全库走查(00-05,12 crate/78 问题)、claude 多代理走查(汇总+nodes/ai-storage/ideas-前端/基建 5 文档)、本次修复摘要(5 根因 + 13 agent P0/P1/P2 + 架构专项 3,提交 fc249ad/f719546)
|
||||||
> 历次: 2026-06-19 02-架构设计 核对优化(新增分类索引/补录缺失文档/命名规范);06-17 文档缺陷扫描通过(零死链)
|
> 历次: 2026-10-10 按 ls 对齐现存文件(crate 数 8→13;01/04/05/06 子树补录漏登文档);06-19 02-架构设计 核对优化(新增分类索引/补录缺失文档/命名规范);06-17 文档缺陷扫描通过(零死链)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -15,57 +15,23 @@ docs/
|
|||||||
├── todo.md # 工作看板(任务/bug 跟踪,~1200 行含历史)
|
├── todo.md # 工作看板(任务/bug 跟踪,~1200 行含历史)
|
||||||
├── 待审查.md # 代码审查队列(CR 登记+回填,67 条已审)
|
├── 待审查.md # 代码审查队列(CR 登记+回填,67 条已审)
|
||||||
├── 待决策.md # 需人工决策项(产品取舍/方向选型/排期)
|
├── 待决策.md # 需人工决策项(产品取舍/方向选型/排期)
|
||||||
├── 待决策论证-2026-06-16.md # (归档) 7 项决策论证(D-01~04 已定 / DEC-01~03 已闭环)
|
|
||||||
├── 01-技术文档/
|
├── 01-技术文档/
|
||||||
│ ├── SQLite-CRUD模式-2026-06-12.md
|
│ ├── SQLite-CRUD模式-2026-06-12.md
|
||||||
│ ├── Tauri-IPC模式-2026-06-12.md
|
│ ├── Tauri-IPC模式-2026-06-12.md
|
||||||
│ └── AntV-X6调研报告与引入计划-2026-06-30.md # 图可视化层选型(v3.1.7)+ 依赖图落地计划
|
│ ├── AntV-X6调研报告与引入计划-2026-06-30.md # 图可视化层选型(v3.1.7)+ 依赖图落地计划
|
||||||
|
│ ├── 多代理流水线实战经验-2026-10-03.md # 多代理流水线实战经验
|
||||||
|
│ ├── 团队级基建-部署与运维-2026-10-05.md # 团队级基建部署与运维
|
||||||
|
│ └── 签名与发布SOP-2026-10-10.md # 更新签名密钥体系/发布流程/验签/踩坑/应急吊销
|
||||||
├── 02-架构设计/
|
├── 02-架构设计/
|
||||||
│ ├── INDEX.md # 本目录分类索引(按性质四类 + 命名规范摘要)
|
│ ├── INDEX.md # 本目录分类索引(按性质四类 + 命名规范摘要)
|
||||||
│ ├── Phase1架构决策-2026-06-12.md
|
│ ├── 滚动规范/ # 长期维护的真相源 / 规范 / 决策记录
|
||||||
│ ├── 业务系统设计-2026-06-12.md
|
│ ├── 已编号方案/ # 带 F/B/CR 编号的方案文档
|
||||||
│ ├── 产品定位调整-2026-06-12.md
|
│ ├── 专项设计/ # 无编号的主题设计 / 审查派生方案
|
||||||
│ ├── 前后端类型对齐-2026-06-12.md
|
│ ├── 构想审查/ # 构想 / UX 改进 / 模块审查 / 论证
|
||||||
│ ├── 对抗论证裁决报告-2026-06-12.md
|
│ ├── 单对话并行多轮-设计-2026-06-20.md
|
||||||
│ ├── 功能决策记录-2026-06-14.md # 需求规格 + 设计决策规格
|
│ ├── 单对话并行多轮-Phase0落地路线图-2026-06-20.md
|
||||||
│ ├── 功能决策记录-归档-2026-06-14.md
|
│ ├── 上下文根治设计-20261006.md
|
||||||
│ ├── 功能创意池-2026-06-14.md # 5 架构创意(演化/时效/回溯/债务/契约)
|
│ └── 无人值守可靠性专项设计-20261006.md
|
||||||
│ ├── 经验记录-2026-06-14.md
|
|
||||||
│ ├── 文档记录规范-2026-06-14.md # 含 §八 文档命名规范
|
|
||||||
│ ├── 规格契约自检机制-2026-06-14.md
|
|
||||||
│ ├── Agent架构说明-2026-06-14.md
|
|
||||||
│ ├── B-03-人工审批响应机制-2026-06-14.md
|
|
||||||
│ ├── aichat审查报告-2026-06-14.md # AI Chat 模块代码审查
|
|
||||||
│ ├── aichat异步审批构想-2026-06-14.md
|
|
||||||
│ ├── aichat交互体验改进方案-2026-06-14.md
|
|
||||||
│ ├── aichat授权体验改进方案-2026-06-14.md
|
|
||||||
│ ├── aichat信息密度构想-2026-06-14.md
|
|
||||||
│ ├── 任务推进构想-2026-06-14.md
|
|
||||||
│ ├── 任务推进链实施路径-2026-06-16.md # 4 阶段实施路径(阶段1决策已定)
|
|
||||||
│ ├── aichat流式Markdown渲染调研-2026-06-15.md # 流式渲染优化方案(rAF节流/块级diff/换库)
|
|
||||||
│ ├── patch_file工具设计-2026-06-15.md # patch_file 完整设计(API/三层防御/边界情况)
|
|
||||||
│ ├── 工作流审批审查报告-2026-06-14.md
|
|
||||||
│ ├── F-07-df-ai-core-trait下沉设计-2026-06-14.md
|
|
||||||
│ ├── 密钥迁移健壮性-2026-06-15.md
|
|
||||||
│ ├── 工作流脚本执行边界-2026-06-15.md
|
|
||||||
│ ├── 条件表达式引擎-2026-06-15.md
|
|
||||||
│ ├── generating状态机加固-2026-06-15.md
|
|
||||||
│ ├── AiNode自审实施方案-2026-06-16.md # AiNode 自审闸门+verdict DAG 阻断
|
|
||||||
│ ├── B-260616-21排查方案-2026-06-16.md # 工具卡重复渲染排查(audit去重+前端幂等)
|
|
||||||
│ ├── F-01-模型能力系统与智能路由设计-2026-06-16.md # 模型能力 Phase 3 设计
|
|
||||||
│ ├── F-02-技能联想使用-实施机制设计-2026-06-16.md # / 联想技能 → AI 调用执行机制
|
|
||||||
│ ├── F-05-多模态实施方案设计-2026-06-16.md # 多模态 Phase2a/2b/2c 完整方案
|
|
||||||
│ ├── F-09-多会话并发架构设计-2026-06-19.md # AiSession 多会话并发 B 阶段(当前真相源)
|
|
||||||
│ ├── F-09B-多会话并发设计-2026-06-16.md # (过时·被 F-09 取代) 多会话 A/B 路线架构设计
|
|
||||||
│ ├── F-15-上下文管理增强设计-2026-06-16.md # 会话分段/压缩/裁剪设计
|
|
||||||
│ ├── secret下沉与provider注入方案-2026-06-16.md # secret 纯密钥下沉 df-storage 方案
|
|
||||||
│ ├── 推进链阶段2实施路径-2026-06-16.md # advance_task 走 df-nodes 实施路径
|
|
||||||
│ ├── 意图识别层论证-2026-06-19.md # 通用前置意图识别层 8 维度论证
|
|
||||||
│ ├── 上下文管理演进与发散思考-2026-07-20.md # 上下文管理现状评估、业界对标、7个发散方向与组合分析
|
|
||||||
│ ├── 上下文管理演进-任务技术设计-2026-07-20.md # T2命名空间/T4工作流DAG/T3结构化摘要技术设计与推演
|
|
||||||
│ ├── 上下文管理演进-执行计划-2026-07-20.md # Wave 划分、agent 并行策略、接口契约、验证 gate、回滚策略
|
|
||||||
│ ├── 多主题上下文管理愿景-2026-06-19.md # 多主题多摘要愿景(远期方向,关联 F-15/意图识别)
|
|
||||||
│ └── 查询效率优化方案-2026-06-19.md # 查询链路优化(SQL 下推/精确拉取/缓存/字段投影)
|
|
||||||
├── 03-模块文档/
|
├── 03-模块文档/
|
||||||
│ ├── df-storage-存储层-2026-06-12.md
|
│ ├── df-storage-存储层-2026-06-12.md
|
||||||
│ ├── df-workflow-工作流引擎-2026-06-12.md
|
│ ├── df-workflow-工作流引擎-2026-06-12.md
|
||||||
@@ -79,7 +45,9 @@ docs/
|
|||||||
│ ├── DEVFLOW-1.CRUD层实施-2026-06-12.md
|
│ ├── DEVFLOW-1.CRUD层实施-2026-06-12.md
|
||||||
│ ├── DEVFLOW-2.IPC桥接实施-2026-06-12.md
|
│ ├── DEVFLOW-2.IPC桥接实施-2026-06-12.md
|
||||||
│ ├── DEVFLOW-3.Store对接实施-2026-06-12.md
|
│ ├── DEVFLOW-3.Store对接实施-2026-06-12.md
|
||||||
│ └── DEVFLOW-4.端到端验证-2026-06-12.md
|
│ ├── DEVFLOW-4.端到端验证-2026-06-12.md
|
||||||
|
│ ├── 父子任务支持设计-2026-08-04.md # 父子任务支持设计
|
||||||
|
│ └── u-switch接入分析-2026-09-28.md # u-switch 接入分析
|
||||||
├── 05-代码审查/
|
├── 05-代码审查/
|
||||||
│ ├── 00-全库代码走查汇总-2026-08-02.md # 全库走查汇总(12 crate, 78 问题, 11 P0 / 30 P1 / 37 P2)
|
│ ├── 00-全库代码走查汇总-2026-08-02.md # 全库走查汇总(12 crate, 78 问题, 11 P0 / 30 P1 / 37 P2)
|
||||||
│ ├── 01-df-nodes-走查-2026-08-02.md # 17 节点走查(20 问题)
|
│ ├── 01-df-nodes-走查-2026-08-02.md # 17 节点走查(20 问题)
|
||||||
@@ -112,15 +80,35 @@ docs/
|
|||||||
│ ├── 任务模块问题分析-2026-06-16.md # 任务模块18项核对(真bug 7+增强5+假3+去重3)
|
│ ├── 任务模块问题分析-2026-06-16.md # 任务模块18项核对(真bug 7+增强5+假3+去重3)
|
||||||
│ ├── 任务执行与推进能力分析-2026-06-16.md # 推进能力实现度0%,4阶段路径+决策点
|
│ ├── 任务执行与推进能力分析-2026-06-16.md # 推进能力实现度0%,4阶段路径+决策点
|
||||||
│ ├── AI聊天组件极端数据场景分析-2026-06-17.md # aichat 交互层 20 项缺陷(HIGH×2/MED×8/LOW×8)
|
│ ├── AI聊天组件极端数据场景分析-2026-06-17.md # aichat 交互层 20 项缺陷(HIGH×2/MED×8/LOW×8)
|
||||||
│ ├── 工作流审批审查报告-2026-06-14.md # (归档)工作流审批链路审查
|
│ ├── aichat-技术债审查-2026-06-21.md # aichat 技术债审查(10 模块 0 P0 / 7 P1 / 25 P2 / 40 P3)
|
||||||
│ ├── 前后端类型对齐-2026-06-12.md # (归档)Rust↔TS 类型契约核对
|
│ ├── aichat-会话实测分析-2026-06-22.md # aichat 会话实测分析
|
||||||
│ ├── 对抗论证裁决报告-2026-06-12.md # (归档)df-ideas 对抗评估裁决
|
│ ├── 全量走查报告-2026-06-28.md # 全量走查报告
|
||||||
│ └── 架构与缺陷复核报告-2026-06-14.md # (归档)复核已修项+回归审计
|
│ ├── aichat-会话实测分析-2026-07-07.md # aichat 会话实测分析
|
||||||
|
│ ├── aichat-对话功能走查-2026-07-17.md # aichat 对话功能走查
|
||||||
|
│ ├── UIUX扩展审查-2026-08-02.md # UI/UX 扩展审查
|
||||||
|
│ ├── aichat历史会话实证诊断-2026-08-04.md # aichat 历史会话实证诊断
|
||||||
|
│ ├── AI工具失败画像复盘-2026-08-08.md # AI 工具失败画像复盘
|
||||||
|
│ ├── aichat效率走查-2026-08-09.md # aichat 效率走查
|
||||||
|
│ ├── df-mcp+工具crate走查-2026-08-09.md # df-mcp + 工具 crate 走查
|
||||||
|
│ ├── settings+审计+布局后端走查-2026-08-09.md # settings/审计/布局后端走查
|
||||||
|
│ ├── 任务+仪表盘+灵感知识走查-2026-08-09.md # 任务/仪表盘/灵感知识走查
|
||||||
|
│ ├── 项目详情页走查-2026-08-09.md # 项目详情页走查
|
||||||
|
│ ├── aichat交互走查-2026-09-28.md # aichat 交互走查
|
||||||
|
│ ├── aichat可用性与交互成本研究-2026-09-29.md # aichat 可用性与交互成本研究
|
||||||
|
│ ├── aichat知识库与记忆走查-2026-09-29.md # aichat 知识库与记忆走查
|
||||||
|
│ ├── 整体UIUX三维走查-2026-09-29.md # 整体 UI/UX 三维走查
|
||||||
|
│ ├── aichat三路走查报告-2026-10-02.md # aichat 三路走查报告
|
||||||
|
│ ├── UX走查-Web规范合规-2026-10-05.md # UX 走查 Web 规范合规
|
||||||
|
│ └── 全系统模块走查索引.md # 全系统模块走查索引
|
||||||
├── 06-前端开发/
|
├── 06-前端开发/
|
||||||
│ └── View改造指南-2026-06-12.md
|
│ ├── View改造指南-2026-06-12.md
|
||||||
|
│ └── 前端架构技术债-2026-06-19.md # 前端架构技术债盘点
|
||||||
├── 07-项目管理/
|
├── 07-项目管理/
|
||||||
|
│ ├── PROGRESS.md # 项目进展与交接记录
|
||||||
|
│ ├── Batch.md # 批次执行记录
|
||||||
│ ├── Phase1任务清单-2026-06-12.md
|
│ ├── Phase1任务清单-2026-06-12.md
|
||||||
│ └── Phase2计划-2026-06-12.md
|
│ ├── Phase2计划-2026-06-12.md
|
||||||
|
│ └── 用户主张思想总纲-2026-10.md # 跨会话用户主张蒸馏(注入用)
|
||||||
├── 08-用户指南/
|
├── 08-用户指南/
|
||||||
│ ├── 使用手册-2026-06-12.md # 用户手册(对齐 06-15 代码基线)
|
│ ├── 使用手册-2026-06-12.md # 用户手册(对齐 06-15 代码基线)
|
||||||
│ ├── AI文件操作工具手册.md # AI 工具 API 全量参考(10 工具,2026-06-16 更新)
|
│ ├── AI文件操作工具手册.md # AI 工具 API 全量参考(10 工具,2026-06-16 更新)
|
||||||
@@ -138,7 +126,7 @@ docs/
|
|||||||
| 🚀 快速开始 | [README.md](./README.md) | 安装、启动、快速上手 |
|
| 🚀 快速开始 | [README.md](./README.md) | 安装、启动、快速上手 |
|
||||||
| 📖 使用手册 | [使用手册-2026-06-12.md](./08-用户指南/使用手册-2026-06-12.md) | 用户手册 |
|
| 📖 使用手册 | [使用手册-2026-06-12.md](./08-用户指南/使用手册-2026-06-12.md) | 用户手册 |
|
||||||
| 🔧 AI 工具手册 | [AI文件操作工具手册.md](./08-用户指南/AI文件操作工具手册.md) | 10 个文件操作工具 API 参考 |
|
| 🔧 AI 工具手册 | [AI文件操作工具手册.md](./08-用户指南/AI文件操作工具手册.md) | 10 个文件操作工具 API 参考 |
|
||||||
| 📋 项目进度 | [PROGRESS.md](../PROGRESS.md) | 实时项目进展与状态 |
|
| 📋 项目进度 | [PROGRESS.md](./07-项目管理/PROGRESS.md) | 实时项目进展与状态 |
|
||||||
| 📝 工作看板 | [todo.md](./todo.md) | 任务/bug 路踪(P0~P3 + aichat 交互专区) |
|
| 📝 工作看板 | [todo.md](./todo.md) | 任务/bug 路踪(P0~P3 + aichat 交互专区) |
|
||||||
| 🔍 审查队列 | [待审查.md](./待审查.md) | 代码审查登记+回填(67 条已审) |
|
| 🔍 审查队列 | [待审查.md](./待审查.md) | 代码审查登记+回填(67 条已审) |
|
||||||
| ⚖️ 决策项 | [待决策.md](./待决策.md) | 需人工决策的产品/设计/排期 |
|
| ⚖️ 决策项 | [待决策.md](./待决策.md) | 需人工决策的产品/设计/排期 |
|
||||||
@@ -174,9 +162,9 @@ docs/
|
|||||||
| 文档 | 路径 | 说明 |
|
| 文档 | 路径 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| 架构设计 | `../ARCHITECTURE.md` | 完整架构文档 |
|
| 架构设计 | `../ARCHITECTURE.md` | 完整架构文档 |
|
||||||
| 项目进展 | `../PROGRESS.md` | 工作进展与交接 |
|
| 项目进展 | `./07-项目管理/PROGRESS.md` | 工作进展与交接 |
|
||||||
| 工作看板 | `./todo.md` | 任务/bug 跟踪(含决策记录) |
|
| 工作看板 | `./todo.md` | 任务/bug 跟踪(含决策记录) |
|
||||||
| Crate 结构 | `../ARCHITECTURE.md#四crate-结构` | 8 个 Crate 概览 |
|
| Crate 结构 | `../ARCHITECTURE.md#四crate-结构` | 13 个 Crate 概览 |
|
||||||
| 数据模型 | `../ARCHITECTURE.md#六数据模型` | SQLite 表结构定义 |
|
| 数据模型 | `../ARCHITECTURE.md#六数据模型` | SQLite 表结构定义 |
|
||||||
| Phase 规划 | `../ARCHITECTURE.md#八phase-规划` | 5 个 Phase 路线图 |
|
| Phase 规划 | `../ARCHITECTURE.md#八phase-规划` | 5 个 Phase 路线图 |
|
||||||
|
|
||||||
@@ -188,7 +176,7 @@ docs/
|
|||||||
|----|------|------|
|
|----|------|------|
|
||||||
| Desktop | Tauri v2 | Rust 后端 + WebView 前端 |
|
| Desktop | Tauri v2 | Rust 后端 + WebView 前端 |
|
||||||
| Frontend | Vue 3 + TypeScript + Pinia | Arco Design 组件库 |
|
| Frontend | Vue 3 + TypeScript + Pinia | Arco Design 组件库 |
|
||||||
| Engine | Rust Workspace (8 crate) | 多 crate 架构 |
|
| Engine | Rust Workspace (13 crate) | 多 crate 架构 |
|
||||||
| Storage | SQLite (rusqlite) | 本地优先,零运维 |
|
| Storage | SQLite (rusqlite) | 本地优先,零运维 |
|
||||||
| AI | Multi-Provider | Claude/GLM/DeepSeek/OpenAI 兼容 |
|
| AI | Multi-Provider | Claude/GLM/DeepSeek/OpenAI 兼容 |
|
||||||
| Build | npm + Vite | 前端构建(不要使用 bun) |
|
| Build | bun + Vite | 前端构建(`tauri.conf.json` 的 beforeDev/BuildCommand 即 `bun dev` / `bun run build`;单一 lockfile=bun.lock) |
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@
|
|||||||
- [DevFlow 使用手册](./08-用户指南/使用手册-2026-06-12.md) - 完整的使用指南,包含功能介绍、操作流程和最佳实践
|
- [DevFlow 使用手册](./08-用户指南/使用手册-2026-06-12.md) - 完整的使用指南,包含功能介绍、操作流程和最佳实践
|
||||||
|
|
||||||
### 📋 项目进度
|
### 📋 项目进度
|
||||||
- [PROGRESS.md](../PROGRESS.md) - 项目开发进展与交接记录
|
- [PROGRESS.md](./07-项目管理/PROGRESS.md) - 项目开发进展与交接记录
|
||||||
|
|
||||||
## 🎯 核心理念
|
## 🎯 核心理念
|
||||||
|
|
||||||
|
|||||||
+123
-300
@@ -14,6 +14,12 @@
|
|||||||
> **2026-06-18 归档**: 已完成 `[x]` 与历史分析段已迁至 [07-项目管理/todo归档/2026-06-18.md](./07-项目管理/todo归档/2026-06-18.md)。
|
> **2026-06-18 归档**: 已完成 `[x]` 与历史分析段已迁至 [07-项目管理/todo归档/2026-06-18.md](./07-项目管理/todo归档/2026-06-18.md)。
|
||||||
> **2026-06-27 归档**: 已完成 `[x]`/`✅` 项已迁至 [07-项目管理/todo归档/2026-06-27.md](./07-项目管理/todo归档/2026-06-27.md)。
|
> **2026-06-27 归档**: 已完成 `[x]`/`✅` 项已迁至 [07-项目管理/todo归档/2026-06-27.md](./07-项目管理/todo归档/2026-06-27.md)。
|
||||||
> **2026-10-05 归档**: 7 个实测核验全完成的历史走查段(父子任务支持/对话上下文透明化/文件拆分升级/ToolCard UI 合并/灵感模块诊断/aichat 深度走查/TaskDetail 按钮走查)迁至 [07-项目管理/todo归档/2026-10-05.md](./07-项目管理/todo归档/2026-10-05.md),销账批注与滞后勘误见该文件。
|
> **2026-10-05 归档**: 7 个实测核验全完成的历史走查段(父子任务支持/对话上下文透明化/文件拆分升级/ToolCard UI 合并/灵感模块诊断/aichat 深度走查/TaskDetail 按钮走查)迁至 [07-项目管理/todo归档/2026-10-05.md](./07-项目管理/todo归档/2026-10-05.md),销账批注与滞后勘误见该文件。
|
||||||
|
> **2026-10-10 归档**: 全量完成态条目/段落(编号 [x] 32 条 + 勾选 [x] + 标题带 ✅ 且段内零未完成项的历史段,含 907/908 与飞书交互面全量盘查批)迁至 [07-项目管理/todo归档/2026-10-10.md](./07-项目管理/todo归档/2026-10-10.md)。迁移为机械行搬运(零措辞改动),核对:未完成项 110 条零丢失、完成项 37 条全入归档、行数守恒。
|
||||||
|
> **2026-10-10 只读核验销账(源码取证,非文档自述)**: 挂机期派 4 路只读核查子代理逐项 grep/read 源码核验历史待办,结论如下(证据见核验记录/归档)。
|
||||||
|
> - **2026-06-21 技术债审查段**: MED-1(bind_directory 白名单刷新)/REFACTOR-260619-08(tool_registry 拆分组注册)/REFACTOR-260619-09(context.rs 已拆 context/ 子模块)/UX-260617-28(双监听器·已决策现状够用) **均已落地可销账**;SMELL-P1-6(executed/completed 双轨已修,newtype 父项待推进)/ ARC-260619-05(run_agentic_loop 仍约 1162 行未拆) **保持未完成**。
|
||||||
|
> - **其余**: WC-技能注入-trace定位(4b445e9 已根治)/ miniapp P3-B appid(已真实值)/ u-claw 集成代码面(ask_uclaw+UclawPanel 齐备,剩真机冒烟) **已落地**。
|
||||||
|
> - **2026-08-09 三域走查 P1**: 27 项核验 **25 done / 2 partial**(partial=PD-P1-3 事件协议无项目维度已降级标注、PRJ-1 normalize_path 小写化)。
|
||||||
|
> - 已核销的四个走查段整段迁归档:`2026-08-01 走查剩余` / `2026-06-22 miniapp 走查` / `2026-08-09 任务·仪表盘·灵感知识走查` / `2026-08-09 Settings·审计·布局·后端命令走查`;**含活口子的段保留主板**(项目详情页走查=PD-P1-3 / df-mcp 走查=PRJ-1 / 技术债审查=SMELL-P1-6·ARC-260619-05 / u-claw=剩真机冒烟)。
|
||||||
>
|
>
|
||||||
> **2026-08-05 编号碰撞登记**: `F-260619-03` 被两个特性复用(消息拆分存储=文档本体 + AI工具路径授权=复用)。子代理审查确认:改 18 处编号无实际价值有风险,**暂不改代码**,后续新增注释编号统一走日期+序号体系、避免复用已有编号。路径授权若后续单独立项再独立编号。
|
> **2026-08-05 编号碰撞登记**: `F-260619-03` 被两个特性复用(消息拆分存储=文档本体 + AI工具路径授权=复用)。子代理审查确认:改 18 处编号无实际价值有风险,**暂不改代码**,后续新增注释编号统一走日期+序号体系、避免复用已有编号。路径授权若后续单独立项再独立编号。
|
||||||
> **2026-08-15 编号碰撞登记**: `REFACTOR-260619-04` 被复用(父③③.2 审批状态机拆分=待办 vs ToolCard 拆分=已完成)。后续引用审批状态机拆分时用「审批状态机拆分」描述,不再沿用该编号。
|
> **2026-08-15 编号碰撞登记**: `REFACTOR-260619-04` 被复用(父③③.2 审批状态机拆分=待办 vs ToolCard 拆分=已完成)。后续引用审批状态机拆分时用「审批状态机拆分」描述,不再沿用该编号。
|
||||||
@@ -23,8 +29,7 @@
|
|||||||
> 背景:src 43 个 .vue 文件 UI 规范缺口全量合规走查(Vercel Web Interface Guidelines 适用子集)+ 审批/aichat/项目知识审计三域子代理分组审。质量中上无阻断,缺口集中 4 类系统性模式,其中 2 类可全局样式一刀切收敛。
|
> 背景:src 43 个 .vue 文件 UI 规范缺口全量合规走查(Vercel Web Interface Guidelines 适用子集)+ 审批/aichat/项目知识审计三域子代理分组审。质量中上无阻断,缺口集中 4 类系统性模式,其中 2 类可全局样式一刀切收敛。
|
||||||
> 详情:[05-代码审查/UX走查-Web规范合规-2026-10-05.md](./05-代码审查/UX走查-Web规范合规-2026-10-05.md)
|
> 详情:[05-代码审查/UX走查-Web规范合规-2026-10-05.md](./05-代码审查/UX走查-Web规范合规-2026-10-05.md)
|
||||||
|
|
||||||
- [ ] P2-2 高频可点 div 键盘化:ToolCard:11 卡头/MessageList:1423 轨迹头/AuditLog:67,119 审计行(@click div 全仓 56 处,去 mask 合规项约 20 真缺口,高频改 button 其余 tabindex+keydown)
|
- [~] P3-2 icon-only 按钮缺 aria-label ~15 处(title 非等价读屏补偿;EnrichmentPanel 关闭钮/ProjectDetail:185,189 编辑删除钮等)+剩余 div 键盘化 —— ✅ **2026-10-10 两批共 23 处**:①批次一 3 处(EnrichmentPanel 关闭 / ProjectDetail 模块编辑·删除);②批次二 20 处(TopBar 10 icon-only 按钮 aria-label / ConversationSidebar 4 aria-label + 2 分组标题键盘化 / MessageList 2 icon-only / KnowledgeDetail 3 章节折叠键盘化 / dashboard 两列表项键盘化 / FileExplorer 面包屑·模块下拉项键盘化 / ConflictResolver 冲突文件折叠键盘化)。全部复用已有 `:title`/i18n key,**零新增键**;遮罩类(@click.self)按约定跳过。验收:`bunx vue-tsc --noEmit` EXIT 0。**剩余**少数(会话项 space 键、dblclick 重命名入口等)+「存量 vs 仅本批」拍板点仍在。
|
||||||
- [ ] P3-2 icon-only 按钮缺 aria-label ~15 处(title 非等价读屏补偿;EnrichmentPanel 关闭钮/ProjectDetail:185,189 编辑删除钮等)+剩余 div 键盘化
|
|
||||||
- 拍板点:修复范围「存量 vs 仅本批」——transition:all/outline:none 大半为存量;本批新代码 a11y 意识已好(svg aria-hidden 批补/MessageList role=log+aria-live/hover-only focus-within 补偿),仅清本批则工作量降 60%+
|
- 拍板点:修复范围「存量 vs 仅本批」——transition:all/outline:none 大半为存量;本批新代码 a11y 意识已好(svg aria-hidden 批补/MessageList role=log+aria-live/hover-only focus-within 补偿),仅清本批则工作量降 60%+
|
||||||
|
|
||||||
### 💡 2026-09-29 aichat 可用性与交互成本研究(三动线走查+异常循环截图实证,39 项新发现)
|
### 💡 2026-09-29 aichat 可用性与交互成本研究(三动线走查+异常循环截图实证,39 项新发现)
|
||||||
@@ -64,85 +69,6 @@
|
|||||||
> 审批留在对话内(需要即时上下文),知识/灵感/通知放 Inbox。
|
> 审批留在对话内(需要即时上下文),知识/灵感/通知放 Inbox。
|
||||||
> 后续做详细设计方案。
|
> 后续做详细设计方案。
|
||||||
|
|
||||||
## 🎯 统一规划(2026-06-26 全景)
|
|
||||||
|
|
||||||
> 整合知识图谱设计 + 22 条未完成项 + G1 目标钉扎(✅已落地) + 多工程方案。
|
|
||||||
> **核心主线:AI Working** — AI 拥有项目知识图谱,自主分解/编排/执行/自检,人只决策。
|
|
||||||
> **执行策略(两阶段)**:① 先攒批无冲突小 bug/小调整 → 提交;② workflow 编排相关任务批量推进大重构(减少文件读取/LLM 交互,提高上下文效率)。
|
|
||||||
|
|
||||||
### 主线脉络
|
|
||||||
```
|
|
||||||
G1目标钉扎(✅已落地) → 知识图谱Phase1(任务网络,AI编排地基)
|
|
||||||
↓ ↓
|
|
||||||
实测验证
|
|
||||||
↓ ↑
|
|
||||||
F-09多会话B(立项) ────────────────┘ (Phase4前置:多会话各自注入)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 任务图谱(DAG · 有方向有状态)
|
|
||||||
|
|
||||||
> 节点=任务(着色=状态),边=依赖方向(前置 → 后继)。**推进时更新状态**(CLI/edit 同步此表 + Mermaid)。
|
|
||||||
> 图例:✅完成 / 🔨进行中 / 📋待办 / 🚫阻塞
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
graph TD
|
|
||||||
classDef done fill:#bfb,stroke:#393,stroke-width:2px
|
|
||||||
classDef doing fill:#ffd,stroke:#993,stroke-width:2px
|
|
||||||
classDef todo fill:#eef,stroke:#99c
|
|
||||||
classDef blocked fill:#fcc,stroke:#c33
|
|
||||||
|
|
||||||
G1["G1 目标钉扎<br/>✅代码 8ce18cb"]:::done
|
|
||||||
P1["父① 小bug攒批<br/>✅①.2/①.3(①.1过时·①.4归⑤)"]:::done
|
|
||||||
P2["父② 知识图谱Phase1<br/>✅Phase1(数据层+业务层)"]:::done
|
|
||||||
P3["父③ AI对话体验<br/>📋待办"]:::todo
|
|
||||||
P4["父④ F-09 per-conv<br/>✅前端per-conv(accessor)"]:::done
|
|
||||||
P5["父⑤ 灵感模块<br/>✅完成(⑤.1/①.4/⑤.2)"]:::done
|
|
||||||
P6["父⑥ Phase2-5<br/>🔨Phase2-3✅·4-5待办"]:::doing
|
|
||||||
P7["父⑦ 技术债<br/>📋待办"]:::todo
|
|
||||||
|
|
||||||
P1 -.先行.-> P2
|
|
||||||
G1 ==基建==> P6
|
|
||||||
P2 ==> P6
|
|
||||||
P4 ==> P6
|
|
||||||
P2 -.task_links.-> P5
|
|
||||||
```
|
|
||||||
|
|
||||||
| 任务 | 状态 | 子项 | 依赖(前置) |
|
|
||||||
|---|---|---|---|
|
|
||||||
| **G1** 目标钉扎 | ✅ 代码已落地 | G1/G2/G4 落地 | — |
|
|
||||||
| **父①** 小bug攒批 | ✅ 完成 | ①.2 白名单✅(settings.rs) / ①.3 priority✅(idea.rs) / ①.1 BUG层1❌过时(F-260619-03 方案①取代,层2待决策) / ①.4 雷达图→归父⑤ | — |
|
|
||||||
| **父②** 知识图谱Phase1 | ✅ Phase1完成 | ②.1 V29迁移✅ / ②.2 TaskRecord+TaskLinkRepo✅ / ②.3 IPC(create_task扩展+task_link CRUD+move_queue+get_tree)✅ / ②.4 父聚合✅(set_status_for_aggregation绕status收口) / ②.5 AI工具6✅(基线38) | G1(弱) |
|
|
||||||
| **父③** AI对话体验 | 🔨 ③.1✅ / ③.2待办 | ③.1 B-260619-04 ToolCard✅(跨轮收起已有+标题增强) / ③.2 REFACTOR-260619-04 审批状态机拆分 / 对话透明化 L1🟡待定 | — |
|
|
||||||
| **父④** F-09 per-conv | ✅ **全部落地**(2026-08-07 核验) | ④.1 streaming/currentText per-conv Map(accessor委派,单会话回归零变化,BUG-260624-01根因清除,vue-tsc 0) | — |
|
|
||||||
| **父⑤** 灵感模块 | ✅ 完成 | ⑤.1 软删除✅ / ①.4 雷达图✅ / ⑤.2 #05✅/#06拆const✅/#09/#10表单(逗号tags)✅ / #07 DEC-02保留purge(不改) / 附:priority_from_i32跨层映射修复(对齐前端0=critical) | #07→②.1 |
|
|
||||||
| **父⑥** Phase2-5 | 🔨 Phase4 注入进行中 | ⑥.1事件流✅(V30) / ⑥.2基础设施✅(V31) / ⑥.3注入✅(resolver 增强+extra 渲染) / ⑥.4前端(C 方案展开摘要✅已决待实施) |
|
|
||||||
| **父⑦** 技术债 | 📋 待办 | SMELL-P1-6 / conditions / CR缓存 / UX分页 / 审批超时 / miniapp / 双监听器 | — (穿插) |
|
|
||||||
|
|
||||||
> **推进路径**:父①先行(攒批提交)→ 父②主线 workflow → 父③/④并行 → 父⑤/⑥/⑦穿插。每父任务一个 workflow 批,子任务相关文件批量读改减少交互。**状态更新约定**:子项完成→父状态 🔨;全子完成→父 ✅;每批提交后同步此表。
|
|
||||||
|
|
||||||
### 协同点(跨主题复用)
|
|
||||||
1. **IDEA-FIX-07 关联单向 ↔ 知识图谱 `task_links`**:并入 Phase 1 用独立表(§2.2 论证 JSON 缺陷),别补 JSON 双向同步
|
|
||||||
2. **G1 system_prompt 注入基建 ↔ Phase 4 L0 注入**:复用 `pinned_goal` 拼接链路
|
|
||||||
3. **F-09 per-conv ↔ Phase 4 多会话注入**:F-09 前置
|
|
||||||
4. **REFACTOR-260619-04 审批状态机 ↔ `queue=decision` 待决策池**:审批对齐
|
|
||||||
|
|
||||||
|
|
||||||
## 交接状态(2026-06-14)
|
|
||||||
|
|
||||||
**代码健康度**:`cargo test --workspace` 全过、`npx vue-tsc --noEmit` 0 error(主代理独立验证,非 mission 自报)。
|
|
||||||
|
|
||||||
**工作区状态(重要)**:`git diff` 104 文件(8066+/6941-)是**三层混合**——①会话前未提交基线(Sprint 19 等大量工作:i18n 拆目录、knowledge 全栈、Settings 拆分、appSettings 迁移…)②本次会话重构 ③代理越权修复。**接手前务必 `git diff` 通览区分**,勿整体当作单一改动提交。
|
|
||||||
|
|
||||||
**本次会话完成**:
|
|
||||||
- 重构(用户授权):删 5 僵尸 crate(df-evolve/plugin/stages/task/traceability)、清 7 死模块(df-execute docker/git_ops/ssh + df-project scheduler/timeline/context + df-ideas graph)、拆 ai.rs→`commands/ai/` 11 文件、拆 ai.ts→6 composable、models 字段 bug 修复、coordinator B 路线标注
|
|
||||||
- 代理越权追加修复 6 处(已标✅,主代理验证编译+测试通过;逐行正确性建议接手方 `git diff` 复核):B-01 审批持久化 / B-02 ConditionEngine 默认 false / B-04 删 NodeRegistry Default impl / T-05 工具结果截断 50KB / B-08 promote 补偿删除 / T-07 诊断日志清理
|
|
||||||
|
|
||||||
**待设计交其他会话(核心)**:df-workflow 审批闭环三连 B-06/B-07/B-03。**✅ B-03 设计已完成**(接手会话,2026-06-14):见 [B-03-人工审批响应机制-2026-06-14.md](./02-架构设计/已编号方案/B-03-人工审批响应机制-2026-06-14.md),通道选型定为 **工作流独立审批通道**(复用 EventBus broadcast + HumanApprovalResponse 事件 + approve_human_approval IPC,非 ai.rs AiApprovalRequired——后者是 AI Chat 工具审批路径,与工作流节点审批是两条独立链路)。拆 B-03a(响应等待 + 超时,不依赖 B-07)/ B-03b(取消机制)。**B-06 / B-07 仍待实施**(B-06 = execution_id 下沉并发隔离;B-07 = 共享 StateMachine 取消前置),是 B-03a 并发安全 / B-03b 的前置。
|
|
||||||
|
|
||||||
**失控代理教训**:本次会话派的后台拆分代理在 stop hook 循环里失控,越权改代码/文档(先斩后奏)。接手方若再派 agent,注意约束其不碰决策记录(用户已要求手动触发)+ 限定单任务不自主续推。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 待办
|
## 待办
|
||||||
|
|
||||||
### 🔍 2026-06-21 AI Chat 技术债审查(workflow wf_92b61c35-4d2 · 10 模块串行 grep 核验)
|
### 🔍 2026-06-21 AI Chat 技术债审查(workflow wf_92b61c35-4d2 · 10 模块串行 grep 核验)
|
||||||
@@ -153,7 +79,7 @@ graph TD
|
|||||||
|
|
||||||
**销账核对(已登记 8 复核)**:SMELL-P1-6(line221·#5 覆盖 executed/completed 双轨)/ MED-1(tool_registry:546 bind_directory)/ REFACTOR-260619-08(tool_registry:881 拆)/ REFACTOR-260619-09(context.rs:1 God 1552行)/ ARC-260619-05(agentic:318 超长)/ UX-260617-28(双监听器 INFO)。**均仍在未完成,审查复核确认,保留**。
|
**销账核对(已登记 8 复核)**:SMELL-P1-6(line221·#5 覆盖 executed/completed 双轨)/ MED-1(tool_registry:546 bind_directory)/ REFACTOR-260619-08(tool_registry:881 拆)/ REFACTOR-260619-09(context.rs:1 God 1552行)/ ARC-260619-05(agentic:318 超长)/ UX-260617-28(双监听器 INFO)。**均仍在未完成,审查复核确认,保留**。
|
||||||
|
|
||||||
**残留诊断代码(待清)**:chat.rs `authz_debug`/`authz-debug.log`(L2 调试遗留,P2 报 chat.rs:548 硬编码绝对路径),L2 完成后可清。
|
**残留诊断代码(待清)**:chat.rs `authz_debug`/`authz-debug.log`(L2 调试遗留,P2 报 chat.rs:548 硬编码绝对路径),L2 完成后可清。✅ **2026-10-10 核验销账**:`grep -rn "authz_debug|authz-debug.log" src-tauri/src/` 零命中,残留已清。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -163,46 +89,6 @@ graph TD
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 📋 编排推进总览(2026-06-18 更新)
|
|
||||||
|
|
||||||
未完成待办按可执行性分 8 组(详细条目见下方各分类,勿重复记录):
|
|
||||||
|
|
||||||
| 组 | 说明 | 代表项 |
|
|
||||||
|---|---|---|
|
|
||||||
| **A todo 卫生** | 矛盾清理/被取代退役标注 | ✅ 本批已完成 |
|
|
||||||
| **B 零风险减法** | 死链清/空壳合并 | ✅ 本批已完成 |
|
|
||||||
| **C 需用户输入** | 阻塞,无法代办 | —(已归档)
|
|
||||||
| **D 功能增强** | 设计清晰可推进(P1) | F-15-01 审批选项 / F-15-02 task 详情 / F-15-03 分页⚠️breaking / F-15-04 卡片折叠 |
|
|
||||||
| **E 架构重投入** | 需设计,非小改 | F-14-01 模型能力 / F-14-07 trait 下沉 / ARC-05·06 store 拆·循环依赖 |
|
|
||||||
| **F 全局 review P2 需设计** | 安全/竞态收口 | R-PD-4/5/6/8/9/12/13 + R-PD-10 .map_err 85 处 |
|
|
||||||
| **H 长期功能池** | 不进主线 | F-14-02~10 / T-14-06 Settings 拆 / T-14-11 条件引擎 / B-14-05 / B-03b-R9 |
|
|
||||||
|
|
||||||
**推进原则**:能并行不串行(独立子任务 8-12 并发);每批重汇总+全优先级重排+销账核对 ✅;C 组阻塞项不代办等用户。**本会话首批**:A 卫生 + B 清死链。
|
|
||||||
|
|
||||||
**③④C类已迁**(2026-06-16):需人工决策项(③产品取舍 7 / ④设计方向 7 / 重投入排期 9 / C用户实测 5)见 [待决策.md](./待决策.md),从本文件抽离职责分离。**batch54 可自主批进展(2026-06-16·workflow wsfz7zl8i + 主代核查)**:复核-新⑪ 看门狗幽灵 ✅ 销账(清主窗口 state 视觉残留;**核验更正**:原 watchdog 幽灵已被 CR-260615-24 stopListener:371 clearStreamWatchdog 兜底);AE-03 diff预览 ⚠️ 降级阻塞(后端 write_file 未返 diff,待后端补,见 line 538);UX-04 断线保文 待 DEC-260616-03 授权(见待决策.md 🟡,本批不含)。
|
|
||||||
|
|
||||||
|
|
||||||
> **📦 已完成项归档**: [07-项目管理/todo归档/2026-06.md](./07-项目管理/todo归档/2026-06.md) — 2026-06-18 拆分, 已完成 `[x]` 与历史分析段迁此。另有 [2026-06-18.md](./07-项目管理/todo归档/2026-06-18.md) / [2026-06-27.md](./07-项目管理/todo归档/2026-06-27.md) — 本次归档。
|
|
||||||
|
|
||||||
### 🔴 2026-06-20 BUG-260620-05 F-260619-03 工程内路径误弹窗(reload_allowed_dirs 丢 workspace_root·b22e9ae 回归)
|
|
||||||
|
|
||||||
**现象**:AI Chat file_info 等文件工具访问工程内路径(如 docs/02-架构设计/单对话并行多轮-设计-2026-06-20.md)触发授权弹窗,用户称"本来就有访问权限,为什么还来申请"。
|
|
||||||
|
|
||||||
**根因**:`state.rs:586-619 reload_allowed_dirs` 合并 `kv_dirs(KV allowed_dirs)+ project_dirs(projects.bind_directory)` 为 `all_dirs`,**仅当 `all_dirs.is_empty()` 时(:618)才插入 workspace_root**。KV 配过 allowed_dirs 或有绑定项目 → all_dirs 非空 → **workspace_root 不入 persistent** → 工程内路径(在 workspace_root 下)`is_authorized`(:356 starts_with)失败 → `check_path_authorization` 返 `NeedsAuthorization` → 弹窗。
|
|
||||||
|
|
||||||
**注释承诺失配**::346-348 / :583 / :617 注释均称"KV 未配时保持 workspace_root 免授权(向后兼容)",但 :618 条件用 `all_dirs`(含 project_dirs),project_dirs 非空也丢 root。b22e9ae "reload 尊重用户 persistent" 引入的回归。
|
|
||||||
|
|
||||||
**用户政策指令**(2026-06-20):"默认直接访问,明确知道无权限才申请"。指向工程内(workspace_root 下)默认免授权,仅 workspace_root 外非白名单路径才弹窗。
|
|
||||||
|
|
||||||
**修法(层1·1 行确定性 bug)**:`state.rs:618` 去掉 `if all_dirs.is_empty()` 条件包裹,**无条件** `set.insert(workspace_root_path())`(工程内始终免授权,对齐用户政策 + 注释承诺)。不破坏现有能力:workspace_root 经 `set/get_allowed_dirs`(:664/680 filter root)本就不可见不可删,"用户删 root"语义未落地,保 root 无冲突。
|
|
||||||
|
|
||||||
**层2 待决策(政策范围)**:用户"默认直接访问"是否要求更宽语义——workspace_root 外的非黑名单路径也默认放行(翻转 `is_authorized` 为黑名单制,实质去白名单)?涉 P0 去固定根决策方向,见待决策.md。dev 自用场景层1 即满足。
|
|
||||||
|
|
||||||
**关联**:F-260619-03(todo :528 b22e9ae 回归源)/ 待决策 workspace_root 分发适配(方案b 用户项目绑定)/ memory [[devflow-project-path-binding]]
|
|
||||||
**状态**:✅ 已实施(层1 无条件插入 workspace_root + 层2 白名单制 persistent/session/once + 黑名单兼底,对齐 2026-06-28 用户决策)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 💡 2026-06-16 新需求(已分析·待实施)
|
### 💡 2026-06-16 新需求(已分析·待实施)
|
||||||
|
|
||||||
> 功能需求,已走查定位链路 + 方案记录,待用户确认后实施。
|
> 功能需求,已走查定位链路 + 方案记录,待用户确认后实施。
|
||||||
@@ -211,11 +97,6 @@ graph TD
|
|||||||
> **✅ 2026-08-07 核验销账(F-09 B 阶段全部落地)**:三方独立核验(2 agent + 主代 grep 抽查)确认 06-19 草案所有改造点(§4.1-4.7)已落地,**且代码超出草案**:① AiSession 去单例 → `per_conv: HashMap<String, PerConvState>`(mod.rs:593,顶层会话级字段全删)② ConvStateStore DashMap 无锁(治 guard.reset 锁竞争卡死连环)③ 三处退出校验改 conv 存在性(agentic/mod.rs 7 处,零旧 `active_conversation_id != conv_id` 残留)④ GeneratingGuard per-conv + loop_epoch owner token(F1 治同 conv 双 loop 竞态)⑤ last_heartbeat 心跳(F2 治卡死兜底误判)⑥ IPC 全加 conv_id + ConvStateStore 零锁读(switch 删 readonly / create 不杀旧 loop)⑦ 前端 convStates Map(enum 轨替代 bool Set)+ d2 多窗口(useAiWindow.ts `ai-detached-${convId}`)已落地。**剩余仅 B 路线 Phase3 单对话并行多轮**(`per_sub_flow` 占位未接,另线 [[aichat-b-route-parallel-multiround]])。c-1 决策已被用户「不设并发会话上限」推翻作废。
|
> **✅ 2026-08-07 核验销账(F-09 B 阶段全部落地)**:三方独立核验(2 agent + 主代 grep 抽查)确认 06-19 草案所有改造点(§4.1-4.7)已落地,**且代码超出草案**:① AiSession 去单例 → `per_conv: HashMap<String, PerConvState>`(mod.rs:593,顶层会话级字段全删)② ConvStateStore DashMap 无锁(治 guard.reset 锁竞争卡死连环)③ 三处退出校验改 conv 存在性(agentic/mod.rs 7 处,零旧 `active_conversation_id != conv_id` 残留)④ GeneratingGuard per-conv + loop_epoch owner token(F1 治同 conv 双 loop 竞态)⑤ last_heartbeat 心跳(F2 治卡死兜底误判)⑥ IPC 全加 conv_id + ConvStateStore 零锁读(switch 删 readonly / create 不杀旧 loop)⑦ 前端 convStates Map(enum 轨替代 bool Set)+ d2 多窗口(useAiWindow.ts `ai-detached-${convId}`)已落地。**剩余仅 B 路线 Phase3 单对话并行多轮**(`per_sub_flow` 占位未接,另线 [[aichat-b-route-parallel-multiround]])。c-1 决策已被用户「不设并发会话上限」推翻作废。
|
||||||
|
|
||||||
|
|
||||||
### 🔴 aichat API Key 401 排查(2026-06-16)
|
|
||||||
|
|
||||||
> 用户报对话失败「调用失败: API Key 无效或无权限」+ 重设 key 无效。**排查结论:代码链路全对(保存 keyring✓ / 读取 resolve_provider_secret✓ / 鉴权头 openai=Bearer·anthropic=x-api-key✓ / URL 智能拼接✓),401 来自服务端,非 devflow bug**。根因四选一(key 无效 / provider_type 不匹配 / base_url 错 / model 名错)。详见 [aichat-apikey-401排查-2026-06-15.md](./09-问题排查/aichat-apikey-401排查-2026-06-15.md)。
|
|
||||||
|
|
||||||
|
|
||||||
### 🟦 架构审查待修项(2026-06-16)
|
### 🟦 架构审查待修项(2026-06-16)
|
||||||
|
|
||||||
> 纯架构层评估(边界/依赖/抽象/扩展性/状态管理),详见 [架构审查-2026-06-15.md](./05-代码审查/架构审查-2026-06-15.md)。共 🔴6 🟡6 ⚪4 + 亮点 6。与 06-14 三份报告去重(不重复 bug/性能 FR-*、aichat AR-*)。
|
> 纯架构层评估(边界/依赖/抽象/扩展性/状态管理),详见 [架构审查-2026-06-15.md](./05-代码审查/架构审查-2026-06-15.md)。共 🔴6 🟡6 ⚪4 + 亮点 6。与 06-14 三份报告去重(不重复 bug/性能 FR-*、aichat AR-*)。
|
||||||
@@ -223,21 +104,8 @@ graph TD
|
|||||||
**中期(技术债)**
|
**中期(技术债)**
|
||||||
✅ ARC-260615-07 已决策:缓做(d归F-09B,其余ROI低不实施)
|
✅ ARC-260615-07 已决策:缓做(d归F-09B,其余ROI低不实施)
|
||||||
|
|
||||||
### P1 — 重要缺陷
|
|
||||||
|
|
||||||
|
|
||||||
### 🔴 架构坏味道全面扫描(2026-06-18·6路并行 Explore agent·57K行代码)
|
|
||||||
|
|
||||||
> 来源: God文件/长函数 + 循环依赖/耦合 + DRY违反 + 错误处理不一致 + 命名API/类型安全 + 测试覆盖/死代码。6 维度全覆盖。
|
|
||||||
|
|
||||||
**🟡 P1 应该改进 (9项)**:
|
|
||||||
|
|
||||||
✅ **SMELL-P1-6** — **[P1🟢 已决·待推进]** String 类型滥用替 newtype/enum —— execution_id/status/tool_type 等 5+ 处改强类型(b 路线:仅 Rust 内部 newtype)
|
|
||||||
|
|
||||||
**🆕 sweep 派生登记(2026-06-18·workflow w2xkw4ybh data 抽出 + wo7d6zdqm 多维度 sweep)**:
|
|
||||||
|
|
||||||
- ✅ **conditions 条件引擎** — ✅ 全部完成:引擎完备+feature flag 默认开+executor 集成+前端 UI(WorkflowDagDisplay.vue 可编辑)+i18n 补齐
|
|
||||||
|
|
||||||
### 🟡 AI Chat 交互体验改进(2026-06-14 方案 → 待办化)
|
### 🟡 AI Chat 交互体验改进(2026-06-14 方案 → 待办化)
|
||||||
|
|
||||||
> 来源:[aichat交互体验改进方案-2026-06-14.md](./02-架构设计/构想审查/aichat交互体验改进方案-2026-06-14.md)。7 大类 20 个改进项,按原文优先级分 4 批。**去重**:§1.3 复制 ⊂ F-260615-13/14(已写)、§3.4 新建中断 ⊂ B-260615-10(已做软复位,本文档加 confirm 增强)、§6.2 分离窗口同步 ⊂ CR-24/25(已写)。**状态: 待讨论→看板跟踪**。
|
> 来源:[aichat交互体验改进方案-2026-06-14.md](./02-架构设计/构想审查/aichat交互体验改进方案-2026-06-14.md)。7 大类 20 个改进项,按原文优先级分 4 批。**去重**:§1.3 复制 ⊂ F-260615-13/14(已写)、§3.4 新建中断 ⊂ B-260615-10(已做软复位,本文档加 confirm 增强)、§6.2 分离窗口同步 ⊂ CR-24/25(已写)。**状态: 待讨论→看板跟踪**。
|
||||||
@@ -278,52 +146,6 @@ graph TD
|
|||||||
|
|
||||||
**Phase3 联调待办**(D1-D6 决策点推荐:A纯透传 / 全19变体透传 / EventBus汇聚 / switch不处理 / 强类型保留 / 桥接层R1兜底):
|
**Phase3 联调待办**(D1-D6 决策点推荐:A纯透传 / 全19变体透传 / EventBus汇聚 / switch不处理 / 强类型保留 / 桥接层R1兜底):
|
||||||
|
|
||||||
### 🔍 2026-06-22 miniapp 功能走查(workflow wf_ddd49ac8 · 5 域并行核验 + 主代终态抽查)
|
|
||||||
|
|
||||||
> 用户 /review miniapp + workflow 汇总安排。5 agent 独立 grep/read 核验源码(不信注释/文档) + 主代终态核验(ws.ts 真路径 `src/api/ws.ts`、`relay.rs:255/355`、remote_bridge 命令数 10 非 11)。**18 findings → 去重 P1×5 / P2×3 / P3×1**,2 项 refuted。关联 F-260620-01 阶段4(真机联调前置)。**守 session-role-diagnose-only:本会话仅走查 + 登记,未实施代码。**
|
|
||||||
|
|
||||||
**P1(阻塞真机/可靠性·5 项)**:
|
|
||||||
|
|
||||||
|
|
||||||
**用户补充核验(2026-06-22 用户详读源码+设计文档,主代逐项核源码判真伪)**:
|
|
||||||
|
|
||||||
> ⚠️ 用户分析 P0 三项(协议三重不匹配 / EventBus 未接 / 桥接层占位)**经源码核验全部失实**,系基于 Phase3 阶段1-3 落地前的设计文档「计划态」描述,未核当前代码。证据:
|
|
||||||
> - **P0-1 协议**:`crates/df-tunnel/src/tunnel.rs:457/327/424` tunnel 纯透传 payload `Value` 上抛 on_command,**不**做 `from_value::<TunnelCommand>` 强类型反序列化(方案A 阶段1 已落地 commit 0fb5af5)→ miniapp `{cmd,args}` 反序列化在桥接层 `remote_bridge.rs:96 from_payload` 成功,非阻断。tunnel.rs:457 注释自证「不反序列化为 TunnelCommand 强类型」。
|
|
||||||
> - **P0-2 EventBus**:`src-tauri/src/lib.rs:128` `state.ai_event_bus.subscribe()`(tunnel subscriber 已接)+ `remote_bridge.rs:303/332/358/388` + `commands/ai/mod.rs:209/225` `publish_event` 跨端透传。**已双写接入**(todo line 760 commit 0fb5af5/612de4c emit 双写 chat19/audit12/guard3/stream_recv2/agentic18)。`event_bus.rs:53/115` 的 `#![allow(dead_code)]`+「骨架阶段」注释是**过时历史标注**(publish_event 现有调用方 remote_bridge/mod),非未接入。
|
|
||||||
> - **P0-3 桥接层**:`remote_bridge.rs` 完整实装(`handle_remote_command`:121 + `MiniCommand`:83 + `from_payload`:96 + match 路由 10 条 + `route_list/load_messages` publish_event)+ **已 wire** `lib.rs:150-154` `on_command = Arc::new(...handle_remote_command...)` + `:171` `.connect(&relay_url,&device_id,&token,on_command.clone())`。非占位/设计态。`remote_bridge.rs:40-42` 的 `#![allow(dead_code)]`+「零调用方」注释写于联调前,现 lib.rs 已调(过时标注,待清)。
|
|
||||||
|
|
||||||
**剩余开放项**:P3-B-260622 [P3·真机前置] — **manifest appid 占位**。`PLACEHOLDER` 无法真机调试,需微信公众平台注册的真 appid。— `apps/df-miniapp/src/manifest.json`
|
|
||||||
|
|
||||||
### ✅ 2026-06-24 aichat 可靠性修复(消息重叠×5 + 授权弹窗卡死 + 工具执行心跳误报 · 诊断workflow→论证workflow)
|
|
||||||
|
|
||||||
> 剩余低优 [ ](非阻断):
|
|
||||||
> - [x] BUG-260624-02 残留 ✅ 已销账(2026-08-15):ToolCard pending 态倒计时落地(`_approvalTimers` 升级携带 startedAt/timeoutMs + `getApprovalRemainingMs` 真值起点,expireSecs/expireLevel computed,≤60s `.is-urgent` 红;不限时回退正计时;i18n expireIn zh/en)+ detached 窗口核验无缺口(AiDetached→AiChat 独立 startListener,ai-chat-event app 级广播,主窗口 detach 时 stopListener 不竞态,结论注释 useAiWindow:107-115)
|
|
||||||
> - [x] F-09 根因:streaming/currentText 全局单例改 per-conv ✅ **2026-08-07 核验销账**(per_conv HashMap + 前端 convStates Map enum 轨 + AiConvStateChanged 事件 per-conv 路由,根治多会话串扰)(根治多会话串扰,见 docs/待决策.md)
|
|
||||||
> - [ ] CR-260622-01-P2-2 [P2低优·评估降级] — **HTML/markdown/JSON 等非代码文件无 session 级缓存**。**2026-06-24 评估**:已有 `TOOL_RESULT_COMPRESS_ENABLED`(mod.rs:98/1029-1078 view-only 摘要,>2KB tool_result 压缩,LLM 视图摘要非全文回灌 prompt)部分缓解;read_symbol 治代码文件(主场景,降 24.4x)。非代码文件完整 session 缓存(path→hash+content+patch失效)设计复杂 + LLM patch 后重读确认行为不确定(缓存命中提示可能不够 LLM 仍重读),归 B 路线 prompt 策略(约束 patch 后不重读)更合适,工具侧完整缓存低优暂缓。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 🔍 2026-08-01 走查剩余待办(P2/P3/专项·本会话登记)
|
|
||||||
|
|
||||||
> 本次走查剩余项登记,未实施代码(守 session-role-diagnose-only)。专项已落 memory,此处留指针。
|
|
||||||
|
|
||||||
**P2(中期改进)**:
|
|
||||||
|
|
||||||
|
|
||||||
**P3(风格/可读·低优)**:
|
|
||||||
|
|
||||||
|
|
||||||
**专项(已登记 memory,留指针)**:
|
|
||||||
|
|
||||||
- 📌 **WC-专项-AI-router-sanitize** — AI router + sanitize 链路梳理重构 — 详见 memory(已登记,本会话不实施)
|
|
||||||
- 📌 **WC-专项-run_agentic_loop-重构** — `run_agentic_loop` 重构(ARC-260619-05 agentic:318 超长,历史登记) — 详见 memory(已登记,本会话不实施)
|
|
||||||
|
|
||||||
**技能注入(待定位)**:
|
|
||||||
|
|
||||||
- [ ] **WC-技能注入-trace定位** — 技能/skill 注入链路待 trace 定位(注入时机、上下文来源、是否重复注入),定位后再决定是否待办化
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 🔍 2026-08-02 UI/UX 扩展审查(wf_wynylx23h · 10 路并行 · 基于任务 0115aea6 原 12 问题角度)
|
### 🔍 2026-08-02 UI/UX 扩展审查(wf_wynylx23h · 10 路并行 · 基于任务 0115aea6 原 12 问题角度)
|
||||||
|
|
||||||
> 详单:[UIUX扩展审查-2026-08-02.md](./05-代码审查/UIUX扩展审查-2026-08-02.md)。80 条发现(8 高/38 中/34 低;38 全新/29+ 延伸),高严重度主代理 grep 核验**全属实**。完整数据 `~/.claude/jobs/8ff8ccb7/tmp/audit_summary.json`。
|
> 详单:[UIUX扩展审查-2026-08-02.md](./05-代码审查/UIUX扩展审查-2026-08-02.md)。80 条发现(8 高/38 中/34 低;38 全新/29+ 延伸),高严重度主代理 grep 核验**全属实**。完整数据 `~/.claude/jobs/8ff8ccb7/tmp/audit_summary.json`。
|
||||||
@@ -421,30 +243,6 @@ graph TD
|
|||||||
> **优先级建议**:① C0-1+C0-2 缓存命中率 → ② L0-1 save + L0-2 system 缓存 + L0-3 压缩后台化 → ③ R1-1 全量重放 + S1-1 流式增量 → ④ T1-1 审计批量 + F1-1/2/3 列表刷新收敛。
|
> **优先级建议**:① C0-1+C0-2 缓存命中率 → ② L0-1 save + L0-2 system 缓存 + L0-3 压缩后台化 → ③ R1-1 全量重放 + S1-1 流式增量 → ④ T1-1 审计批量 + F1-1/2/3 列表刷新收敛。
|
||||||
> **memory 指针**:[[aichat-efficiency-walkthrough-2026-08-09]]
|
> **memory 指针**:[[aichat-efficiency-walkthrough-2026-08-09]]
|
||||||
|
|
||||||
### 🔍 2026-08-09 任务/仪表盘/灵感知识走查(3 路并行 · 阶段②第一批)
|
|
||||||
|
|
||||||
> 详单见 [任务+仪表盘+灵感知识走查-2026-08-09.md](./05-代码审查/任务+仪表盘+灵感知识走查-2026-08-09.md)。共 **59 项**(任务19/Dashboard+Projects17/Ideas+Knowledge23,P0=0/P1=7/P2=25/P3=27)。守 session-role-diagnose-only:仅走查登记,未实施代码。
|
|
||||||
|
|
||||||
**P1(确定性,优先修)**:
|
|
||||||
|
|
||||||
**P2(体验/数据)**: WK-8~13 ✅ 已修(0554cc1):快捷菜单状态机+补cancelled / overflow裁切根治(顶底角裁剪下放) / loadTasks seq守卫+keyword / 搜索态隐藏进度徽章 / 分页越界钳制 / 失败统一toast。剩余 WK-14~18 🟡 待后续
|
|
||||||
|
|
||||||
**P3(27 项)**: 见详单文档(TSK-10~19 / DBP-10~17 / IDEA-10~12 / KNOW-6~10 / C-1~3)
|
|
||||||
|
|
||||||
> **优先级**:WK-1 Dashboard 状态映射 → WK-2/3 Knowledge 确定性 → WK-4/5 Ideas → WK-6 状态徽章 → WK-7 错误条统一。
|
|
||||||
|
|
||||||
### 🔍 2026-08-09 Settings/审计/布局/后端命令走查(3 路并行 · 阶段②第二批)
|
|
||||||
|
|
||||||
> 详单见 [settings+审计+布局后端走查-2026-08-09.md](./05-代码审查/settings+审计+布局后端走查-2026-08-09.md)。共 **95 项**(Settings+AuditLog29 / layout+sidebar21 / 后端命令45,P0=0/P1=9/P2=32/P3=54)。守 session-role-diagnose-only:仅走查登记,未实施代码。
|
|
||||||
|
|
||||||
**P1(确定性/安全,优先修)**:
|
|
||||||
|
|
||||||
**P2(体验/数据·32 项)**: 见详单文档(SA-1~8 审计 i18n/脏表单/竞态、LS-2~10 侧栏偏好/小屏布局/审批浮窗/Paginator、BE-CMD-9~23 后端校验/聚合)
|
|
||||||
**P3(54 项)**: 见详单文档(SA-9~31 / LS-11~21 / BE-CMD-24~45)
|
|
||||||
|
|
||||||
> **优先级**:LW-1 ConfirmDialog 安全 → LW-2/3 update 裸奔 → LW-4 queue → LW-5 count/list → LW-6 软删 → LW-8 父聚合。
|
|
||||||
> **全系统索引**:前端 13 + 后端 9 命令全走查完成 ✅,剩 crate df-mcp/df-execute/df-project/df-ideas/df-tunnel/df-relay/df-types。
|
|
||||||
|
|
||||||
### 🔍 2026-08-09 df-mcp + 工具 crate 走查(阶段②最后一批)
|
### 🔍 2026-08-09 df-mcp + 工具 crate 走查(阶段②最后一批)
|
||||||
|
|
||||||
> 详单见 [df-mcp+工具crate走查-2026-08-09.md](./05-代码审查/df-mcp+工具crate走查-2026-08-09.md)。共 **35 项**(P0=0/P1=2/P2=8/P3=25)。守 session-role-diagnose-only:仅走查登记,未实施代码。
|
> 详单见 [df-mcp+工具crate走查-2026-08-09.md](./05-代码审查/df-mcp+工具crate走查-2026-08-09.md)。共 **35 项**(P0=0/P1=2/P2=8/P3=25)。守 session-role-diagnose-only:仅走查登记,未实施代码。
|
||||||
@@ -454,19 +252,6 @@ graph TD
|
|||||||
**P3(25 项)**: 见详单文档(MCP-7~16 / EX-1~4 / PRJ-2~4 / TUN-2~4 / RLY-2~5)
|
**P3(25 项)**: 见详单文档(MCP-7~16 / EX-1~4 / PRJ-2~4 / TUN-2~4 / RLY-2~5)
|
||||||
|
|
||||||
> **全系统走查(阶段②)完成 ✅**——前端 13 模块 + 后端 9 命令 + 全部 crate 已走查登记。进入**阶段③批量打包推进 /advance**。
|
> **全系统走查(阶段②)完成 ✅**——前端 13 模块 + 后端 9 命令 + 全部 crate 已走查登记。进入**阶段③批量打包推进 /advance**。
|
||||||
### 🔴 2026-08-09 AI 自审/工作流推进链路缺陷(✅ P0×3 已修,2026-10-05 核验销账)
|
|
||||||
|
|
||||||
> 用户「检查 AI self-review 遇到的问题」触发对工作流推进链路的深度审查。**原结论「三条推进链全部不可用」已过时——P0×3 全部已修**(task_id 注入 workflow.rs:340 + AiNode 自审落库含 agent 模式 + TaskDetail 审批 UI 随 2eea011 审批重设计批落地),核验详录迁 [07-项目管理/todo归档/2026-10-05.md](./07-项目管理/todo归档/2026-10-05.md)。以下原始诊断保留作档案,修复状态见归档勘误。
|
|
||||||
|
|
||||||
**涉及文件**:
|
|
||||||
- `src-tauri/src/commands/workflow.rs`(run_workflow_inner 核心)
|
|
||||||
- `crates/df-nodes/src/task_workflow_templates.rs`(三模板)
|
|
||||||
- `crates/df-nodes/src/ai_node.rs` / `ai_self_review_node.rs` / `ai_node_helpers.rs` / `human_node.rs`
|
|
||||||
- `crates/df-workflow/src/executor.rs` / `dag.rs` / `registry.rs` / `dag_def.rs`
|
|
||||||
- `src/views/TaskDetail.vue` / `src/stores/project/workflow.ts` / `src/views/ProjectDetail.vue`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔴 P0 缺陷(3 项,工作流推进 100% 失败)
|
## 🔴 P0 缺陷(3 项,工作流推进 100% 失败)
|
||||||
|
|
||||||
### P0-A task_id 断裂:AiSelfReviewNode 必定失败
|
### P0-A task_id 断裂:AiSelfReviewNode 必定失败
|
||||||
@@ -555,11 +340,6 @@ graph TD
|
|||||||
|
|
||||||
> 来源:中断根治批次全量走查(正常设计路径无漏 emit,风险集中四类)。P1-2/P0-1/P2-1 本批已修,以下为登记项。
|
> 来源:中断根治批次全量走查(正常设计路径无漏 emit,风险集中四类)。P1-2/P0-1/P2-1 本批已修,以下为登记项。
|
||||||
|
|
||||||
### P0 run_agentic_loop 包 catch_unwind ✅已修(2026-10-05 核验:loop 主体 unwind_to_result 包裹,panic 转 teardown_round 补终态,5 单测锁定 mod.rs:3135-3165)
|
|
||||||
- **位置**:`agentic/mod.rs` run_agentic_loop 主体(无 catch_unwind)
|
|
||||||
- **现状**:任一轮 prepare/stream/finalize/tools 段 panic → task 死亡 → GeneratingGuard::drop 只迁 Idle+emit AiConvStateChanged,**不 emit AiError/AiCompleted**,heartbeat 随作用域死亡——「工具完成后流断+无终止事件」的精确根因形状(前端探测式续跑已兜住后果,此为后端根治)。
|
|
||||||
- **修复**:整体包 `std::panic::catch_unwind(AssertUnwindSafe(...))`,unwind 时 emit AiError{error_type:Internal} 再收敛;guard Drop 兜底保留。核心 loop 改动,需谨慎设计+场景回放测试。
|
|
||||||
|
|
||||||
### P1 conv 删除类静默退出族 6 处补终态事件
|
### P1 conv 删除类静默退出族 6 处补终态事件
|
||||||
- **位置**:`agentic/mod.rs:417/:1316/:1379/:1437/:1593/:1423`、`stream_relay.rs:520-525`(MidStream 保文时 conv-deleted 跳过 teardown emit,最刺眼)
|
- **位置**:`agentic/mod.rs:417/:1316/:1379/:1437/:1593/:1423`、`stream_relay.rs:520-525`(MidStream 保文时 conv-deleted 跳过 teardown emit,最刺眼)
|
||||||
- **现状**:生成期间会话被删,裸 return 仅 guard Drop 迁 Idle,无终止事件。
|
- **现状**:生成期间会话被删,裸 return 仅 guard Drop 迁 Idle,无终止事件。
|
||||||
@@ -573,17 +353,18 @@ graph TD
|
|||||||
### 附:F2 兜底前移(可选)
|
### 附:F2 兜底前移(可选)
|
||||||
- `ai_chat_stop` 的心跳 60s stale 判死+emit AiCompleted 兜底只在用户点停止时运行;可在 loop spawn 时自带一份,panic 死 loop 无需用户点停止也能收敛。
|
- `ai_chat_stop` 的心跳 60s stale 判死+emit AiCompleted 兜底只在用户点停止时运行;可在 loop spawn 时自带一份,panic 死 loop 无需用户点停止也能收敛。
|
||||||
|
|
||||||
|
### 🔍 工具结果压缩·错误行洪泛缺陷(2026-10-15 发现,未修)
|
||||||
|
- **现象**:`extract_key_info_inner` 的错误行保留策略无上限——结果里每个含 `error`/`panic`/`.rs:`/「失败」的行都全量保留并加 `[行 N]` 前缀,grep/rustc 类输出(恰是压缩的主要对象)错误行占多数时输出≥原文,压缩失效且无落盘句柄(`compressed.len() < content.len()` 门不过)。
|
||||||
|
- **佐证**:`crates/df-ai/tests/context_offload.rs` 的 `non_read_file_large_result_also_gets_handle` 用 `src/foo.rs:N` 做夹具,先天必挂——该集成测试历来未被 `--lib` 门禁覆盖, until 2026-10-15 全量 workspace 门禁才暴露(夹具已改中性行修绿,生产行为未动)。
|
||||||
|
- **修法方向**:错误行保留加封顶(如最多 N 条+「另有 M 条错误行省略」),超限走正常省略段;配套恢复信号行夹具的鉴别力测试。
|
||||||
|
|
||||||
# aichat 三路走查增量待办(2026-10-02 深夜)
|
# aichat 三路走查增量待办(2026-10-02 深夜)
|
||||||
|
|
||||||
> 来源:上下文管理批收口期 rev-backend/rev-frontend/rev-crates 三路走查(历三次会话重启后续跑完成)。**全文与批次归并建议见 `docs/05-代码审查/aichat三路走查报告-2026-10-02.md`**。以下为可执行登记,开工以现场源码为准。
|
> 来源:上下文管理批收口期 rev-backend/rev-frontend/rev-crates 三路走查(历三次会话重启后续跑完成)。**全文与批次归并建议见 `docs/05-代码审查/aichat三路走查报告-2026-10-02.md`**。以下为可执行登记,开工以现场源码为准。
|
||||||
|
|
||||||
### 批次2(后端高危)并入项
|
|
||||||
|
|
||||||
### 批次3(前端体验)并入项
|
|
||||||
|
|
||||||
### 机械批(可独立小批)
|
|
||||||
|
|
||||||
### 登记观察(定级前先核)
|
|
||||||
|
|
||||||
# 子代理调度走查(2026-10-03 两路,详情 temp/走查-子代理{UX,调度后端}-2026-10-03.md)
|
# 子代理调度走查(2026-10-03 两路,详情 temp/走查-子代理{UX,调度后端}-2026-10-03.md)
|
||||||
|
|
||||||
@@ -595,9 +376,6 @@ graph TD
|
|||||||
|
|
||||||
> 与上节 25 项零重叠(报告尾附排除对账)。修复批:四路走查修复收口后,确定性项先行,需拍板项进拍板集。
|
> 与上节 25 项零重叠(报告尾附排除对账)。修复批:四路走查修复收口后,确定性项先行,需拍板项进拍板集。
|
||||||
|
|
||||||
- [x] P1[需拍板→已实施] 熔断无收尾轮 ✅ 2026-10-06:max_rounds 打满不再直接截断,补一次无工具(tools=None)收尾 LLM 调用——system 指令「输出最终任务总结报告」,成功则 push 报告+落库后走原收尾;失败(全候选耗尽/MidStream)静默落回原截断路径(报告是增益非闸门,零新增停靠)。预算:仅 1 轮 max_retries=0,无放大(agentic/mod.rs 达上限分支)
|
|
||||||
- [x] P1[需拍板→已实施] 报告被视图压缩吞掉 ✅ 2026-10-06:summarize_tool_results 加新近度豁免(RECENCY_EXEMPT_TOOL_RESULTS=2)——末尾 2 条 tool_result 恒不压缩(豁免在冻结决策之前,不进决策表),委托报告/末轮工具结果全文可见;单测 summarize_recency_exempt_keeps_last_two_tool_results 锁定行为(stall_detector.rs)
|
|
||||||
- [x] P2 namespace 10MB 全局 LRU 淘汰:报告蒸发为「重新调用」死路+多代理共池互踩 ✅设计完成(2026-10-04 [设计文档](./02-架构设计/专项设计/namespace淘汰治理-设计-2026-10-04.md)):pin 机制+EvictionLog 富提示(替代裸 EVICTED_PLACEHOLDER)+owner 软隔离三招,零 schema 变更;2 拍板点(pin 上限保护/报告外默认 pin 范围),约 0.5-1 天 ✅ 2026-10-06 实施完成 e0b29b4(pin+EvictionLog+owner 软隔离三招全落地,子代理报告出口 pin 收编;验收 cargo check 零 error)
|
|
||||||
- [ ] P2 被拒事实父上下文仅报告尾段单副本,LLM 压缩 prompt 无保留指令[需拍板]
|
- [ ] P2 被拒事实父上下文仅报告尾段单副本,LLM 压缩 prompt 无保留指令[需拍板]
|
||||||
- [ ] P2 完成卡(前端全文)与父上下文(压缩)双轨无「已消化」降级机制 ✅设计完成(2026-10-04 [设计文档](./02-架构设计/专项设计/子代理完成卡已消化降级-设计-2026-10-04.md)):勘察修正——重启回补已存在(aiShared.ts:677 ai_sub_agent_list 反查重建),设计聚焦 digested 标记(KV 持久)+卡降级交互+ai_sub_agent_trace 只读工具(LLM 追问自助通道,兼为 namespace 蒸发兜底);2 拍板点,约 1-1.5 天
|
- [ ] P2 完成卡(前端全文)与父上下文(压缩)双轨无「已消化」降级机制 ✅设计完成(2026-10-04 [设计文档](./02-架构设计/专项设计/子代理完成卡已消化降级-设计-2026-10-04.md)):勘察修正——重启回补已存在(aiShared.ts:677 ai_sub_agent_list 反查重建),设计聚焦 digested 标记(KV 持久)+卡降级交互+ai_sub_agent_trace 只读工具(LLM 追问自助通道,兼为 namespace 蒸发兜底);2 拍板点,约 1-1.5 天
|
||||||
- [ ] P2 子会话缺 maybe_auto_compress 层+预算恒 128k 默认[需拍板]
|
- [ ] P2 子会话缺 maybe_auto_compress 层+预算恒 128k 默认[需拍板]
|
||||||
@@ -637,10 +415,15 @@ graph TD
|
|||||||
- [ ] P2 审计表无执行耗时字段(requested→executed 混入审批等待,耗时统计不可信)【部分缓解:AuditLog.vue:71 ⏱ durationLabel 已显示且诚实标注「审批→执行」;纯执行耗时独立字段归 V49 schema 迁移批】
|
- [ ] P2 审计表无执行耗时字段(requested→executed 混入审批等待,耗时统计不可信)【部分缓解:AuditLog.vue:71 ⏱ durationLabel 已显示且诚实标注「审批→执行」;纯执行耗时独立字段归 V49 schema 迁移批】
|
||||||
- [ ] P2 messages blob 双存储冗余(89/89,单会话最大 1.7MB)【归 V49 schema 迁移批】
|
- [ ] P2 messages blob 双存储冗余(89/89,单会话最大 1.7MB)【归 V49 schema 迁移批】
|
||||||
- [ ] 观察自动执行模式架空审批(auto 系 81%/人工 15%,auto_takeover_all 含 run_command 886 次)——产品语义与 S2/信任分级联动
|
- [ ] 观察自动执行模式架空审批(auto 系 81%/人工 15%,auto_takeover_all 含 run_command 886 次)——产品语义与 S2/信任分级联动
|
||||||
|
- [ ] 913 ✅转待办 验收:auto 模式审批架空语义收敛——auto_takeover_all 触发高危工具(run_command 等)须按 S2/信任分级处置(拦截或旁路留痕);审计表可查 auto 会话审批触发/旁路记录,产品语义写入设计档。
|
||||||
- [ ] 观察进化系统只写不读:knowledges 1033 条/embedding 全 NULL/83.6% 零复用/knowledge_search 仅 5 次
|
- [ ] 观察进化系统只写不读:knowledges 1033 条/embedding 全 NULL/83.6% 零复用/knowledge_search 仅 5 次
|
||||||
|
- [ ] 915 ✅转待办 验收:知识库由「只写不读」转读写闭环——embedding_status 回填(或明确弃用向量路径),≥半数知识进检索候选,knowledge_search 调用与复用率实测上升(复用率 >50%、检索调用非个位数);口径=同库 DB 查询核验。
|
||||||
- [ ] 观察多模态未走通:贴图落"[图片已忽略]"占位,实际靠 run_command 外部 vision CLI 绕路
|
- [ ] 观察多模态未走通:贴图落"[图片已忽略]"占位,实际靠 run_command 外部 vision CLI 绕路
|
||||||
|
- [ ] 917 ✅转待办 验收:贴图走原生视觉路径不落占位——非视觉模型收图时明确拒绝或切视觉模型,视觉模型直接出识别结果,不再靠 run_command 调外部 vision CLI 绕路;实测贴图会话零"[图片已忽略]"占位。
|
||||||
- [ ] 观察编排面零使用:workflow/node_executions/ai_plans 等 9 张表全 0 行;四会话分工 origin 89/89 全 NULL
|
- [ ] 观察编排面零使用:workflow/node_executions/ai_plans 等 9 张表全 0 行;四会话分工 origin 89/89 全 NULL
|
||||||
|
- [ ] 919 ✅转待办 验收:编排面明确使用或弃用——e2e 跑通一个 workflow 使 node_executions 非空,或判定降级为预留并销账;会话 origin 回填(不再 89/89 NULL);口径=DB 查各表行数与 origin 非空计数。
|
||||||
- [ ] 观察数据目录 ~640MB(主库 140.5MB+dev 库 71.2MB+4 份手工备份 346.9MB 散落)
|
- [ ] 观察数据目录 ~640MB(主库 140.5MB+dev 库 71.2MB+4 份手工备份 346.9MB 散落)
|
||||||
|
- [ ] 921 ✅转待办 验收:数据目录有保留策略——手工备份纳入轮转(定份数与周期),dev 库与主库职责明确(合并或清理);口径=%APPDATA%/top.1216.devflow 目录大小回落且备份数受控。
|
||||||
|
|
||||||
# ai-chat UX 走查第五轮(2026-10-05,可访问性视角,详情 temp/UX走查-aiChat缺陷-2026-10-05.md)
|
# ai-chat UX 走查第五轮(2026-10-05,可访问性视角,详情 temp/UX走查-aiChat缺陷-2026-10-05.md)
|
||||||
|
|
||||||
@@ -675,6 +458,7 @@ graph TD
|
|||||||
- [ ] **资源面**:token/磁盘/内存/进程开销评估;日志与数据膨胀上限(借鉴日志无轮转教训)
|
- [ ] **资源面**:token/磁盘/内存/进程开销评估;日志与数据膨胀上限(借鉴日志无轮转教训)
|
||||||
- [ ] **回退开关**:新能力默认关或带单点回退(借鉴 GOAL_PIN 系开关范式);灰度顺序与观测指标
|
- [ ] **回退开关**:新能力默认关或带单点回退(借鉴 GOAL_PIN 系开关范式);灰度顺序与观测指标
|
||||||
- [ ] P2 指令离线即丢(§7 不缓存):commander 发指令时 agent 离线=静默丢失,移动端控制场景用户无感知——补业务层送达回执或短缓冲
|
- [ ] P2 指令离线即丢(§7 不缓存):commander 发指令时 agent 离线=静默丢失,移动端控制场景用户无感知——补业务层送达回执或短缓冲
|
||||||
|
- [ ] 923 ✅转待办 验收:离线指令不静默丢——补业务层送达回执(发送方可见成功/失败)或短缓冲(重连后补投);口径=断网/离线场景实测指令有回执或重连后送达,无静默丢失。
|
||||||
- [ ] P2 Hello 无协议版本字段:下次破坏性变更仍是无通知硬切换——加 proto 版本字段(现在加便宜,以后加贵)
|
- [ ] P2 Hello 无协议版本字段:下次破坏性变更仍是无通知硬切换——加 proto 版本字段(现在加便宜,以后加贵)
|
||||||
- [ ] P3 单共享 token 可冒充任意 channel_id(§8 自述):补 token 轮换 runbook(/etc/u-relay.env 改+重启+四方客户端同步换,锁步成本需写明)
|
- [ ] P3 单共享 token 可冒充任意 channel_id(§8 自述):补 token 轮换 runbook(/etc/u-relay.env 改+重启+四方客户端同步换,锁步成本需写明)
|
||||||
- [ ] P3 心跳间隔未定值:nginx proxy_read_timeout=300s,间隔须 <300s,文档补建议值(30-60s)
|
- [ ] P3 心跳间隔未定值:nginx proxy_read_timeout=300s,间隔须 <300s,文档补建议值(30-60s)
|
||||||
@@ -731,7 +515,7 @@ graph TD
|
|||||||
|
|
||||||
- [ ] W-g 状态即时同步与通知(设计 §九):W-g1 出口扇出+即时级(依赖飞书通道)/W-g2 摘要级+远控查询命令族/W-g3 每日报告(挂机联动)
|
- [ ] W-g 状态即时同步与通知(设计 §九):W-g1 出口扇出+即时级(依赖飞书通道)/W-g2 摘要级+远控查询命令族/W-g3 每日报告(挂机联动)
|
||||||
|
|
||||||
- [ ] W-h 会话间/代理间感知(FileLeaseRegistry:写前登记+冲突指路/query_leases 工具/兄弟感知/知识联动;与 W-a 组合成完整 lease)——设计 §十;根治今日全部互踩
|
- [x] W-h 会话间/代理间感知(FileLeaseRegistry:写前登记+冲突指路/query_leases 工具/兄弟感知/知识联动;与 W-a 组合成完整 lease) ✅ **2026-10-10 核验销账(源码取证,发现早已落地)**:实现在 `src-tauri/src/commands/ai/lease.rs`(345 行,commit cb260ab「断点续跑与日志租约底座」);接线三处齐备——`commands/ai/mod.rs:48 pub mod lease;` / `tool_registry.rs:561 register_lease_tool`(query_leases 只读 Low,工具基线 80 含 lease 且已有断言) / 写类工具前置校验+RAII 释放 `audit/mod.rs:274-297`。本轮增量=把「冲突检测/过期清理」抽为纯函数 `try_register`/`purge_expired` + 补 3 单测(过期回占/清理计数/异路径并存),`lease::` 11 passed;**未新建 file_lease.rs 造双轨**。⚠️ 并行坑留痕:任何「注入未来时间查全局 REGISTRY」的测试会因 query 的 retain 误清他测活跃租约致偶发失败,新增时间敏感测试须走纯函数+独立表。——设计 §十;根治今日全部互踩
|
||||||
|
|
||||||
- [ ] W-e2 全局并发池(小:子代理并发上限+预算联动)
|
- [ ] W-e2 全局并发池(小:子代理并发上限+预算联动)
|
||||||
- [ ] W-d1 挂机最小闭环(领单→执行→销账→循环+轻量续接;W-b wake 随其绑定实施)
|
- [ ] W-d1 挂机最小闭环(领单→执行→销账→循环+轻量续接;W-b wake 随其绑定实施)
|
||||||
@@ -811,7 +595,7 @@ graph TD
|
|||||||
|
|
||||||
### 待办(按优先级)
|
### 待办(按优先级)
|
||||||
1. [ ] **攒批提交 47 文件**(已授权):停笔窗口执行,按主题 4 笔分组——①V49 断点续跑批(state/main/lib/sub_agent/df-storage) ②飞书渠道批(feishu_channel 四修+流式+D1-D3) ③纪律内置+知识库批(N1 helpers 等) ④前端流式链批(useStreamRenderer 等)。提交前 cargo check + vue-tsc 终验。
|
1. [ ] **攒批提交 47 文件**(已授权):停笔窗口执行,按主题 4 笔分组——①V49 断点续跑批(state/main/lib/sub_agent/df-storage) ②飞书渠道批(feishu_channel 四修+流式+D1-D3) ③纪律内置+知识库批(N1 helpers 等) ④前端流式链批(useStreamRenderer 等)。提交前 cargo check + vue-tsc 终验。
|
||||||
2. [ ] **C-r0 验证命令后台化**(验收:>90s 同步等待占比 <15%,现状 45%)
|
2. [x] **C-r0 验证命令后台化**(原验收:>90s 同步等待占比 <15%,现状 45%)——**【10-11 已实施并推送 c7d0ed9,代码就绪;实测口径待开关开启后取数】**:实现见下方 941 条;开关 KV `df-ai-run-command-background` **默认关**(=同步路径逐字节不变,故生效前占比不变属预期),需开开关后跑一段时间再核 <15% 目标。
|
||||||
3. [ ] **D2a ai_tool_executions 结果过期外置**(DB 193.7MB 半天+17.6MB,紧迫)+ D2d 归档 90 天随批
|
3. [ ] **D2a ai_tool_executions 结果过期外置**(DB 193.7MB 半天+17.6MB,紧迫)+ D2d 归档 90 天随批
|
||||||
4. [ ] **锁面+同步 IO 技术债**(196 处 async 锁抽查、commands/ai 内 5 文件 std::fs 混入)
|
4. [ ] **锁面+同步 IO 技术债**(196 处 async 锁抽查、commands/ai 内 5 文件 std::fs 混入)
|
||||||
5. [ ] **注入验证**:知识库 691b351d/c142e6b2 已 published+high,新会话确认铁律段带出
|
5. [ ] **注入验证**:知识库 691b351d/c142e6b2 已 published+high,新会话确认铁律段带出
|
||||||
@@ -824,32 +608,11 @@ graph TD
|
|||||||
- 飞书四修+流式(占位卡片 edit_card 原地更新/表格降级/clip_for_card 超长裁剪/启动日志)——已上线
|
- 飞书四修+流式(占位卡片 edit_card 原地更新/表格降级/clip_for_card 超长裁剪/启动日志)——已上线
|
||||||
- 知识库 691b351d/c142e6b2 已 published+high(c142e6b2 含权威源声明);9c848ab 确认零丢失(rescue 分支锚定)
|
- 知识库 691b351d/c142e6b2 已 published+high(c142e6b2 含权威源声明);9c848ab 确认零丢失(rescue 分支锚定)
|
||||||
|
|
||||||
### 新缺陷(2026-10-05 22:2x 用户截图实证,与 N/D 清单同级)
|
|
||||||
8. [x] **工具输出 ANSI 转义裸露**(乱码):run_command 类工具输出含 ANSI 码(\x1b[7m 等)原样透传前端显示为 ␛[7m。修:执行器统一 strip_ansi(或注入 TERM=dumb/--color=never)+前端渲染层兜底过滤;验收:cargo test 输出在面板无 ␛ 残留。 ✅ 2026-10-06 补销: 已随 70d6e98 修复(ANSI 源头抑制+strip 兜底), 当时漏销
|
|
||||||
9. [x] **会话标题 content 泄漏**:会话标题显示原始正文片段(如 `<details> <summa…`,84cb104c 会话同症)——标题生成输入未清洗或兜底直接取 content 头部。修:title 生成失败/超时兜底改为清洗后的首行摘要,含 HTML/Markdown 标签时剥离;验收:长会话标题无标签裸露。 ✅ 2026-10-06 补销: 已随 70d6e98 修复(标题剥标签 sanitize(title.rs)), 当时漏销
|
|
||||||
> 附:截图实证单会话 206 轮/1500 万 tokens——C 批次(上下文管理)紧迫性最强活证,随 C-r0 优先消化。
|
|
||||||
|
|
||||||
### 🔴 提交后跟进(2026-10-05 23:0x 走查发现,cb260ab 已入库但未修)
|
|
||||||
10. [x] **loop_state 对新会话/编排会话静默失效(高)** ✅修复(2026-10-06,提交「修复: loop_state 懒创建继承与入口早退清理」):懒创建继承 running(conversation.rs)+入口早退清残留(agentic/mod.rs)+list_parked 消费方=TopBar「可恢复」分区(V49 读侧)。验收:cargo check EXIT 0+vue-tsc EXIT 0。
|
|
||||||
11. [x] 【中】conversation_repo.rs:817 注释矛盾 ✅修复(2026-10-06 同批):update_full 剥离 loop_state 列,封 lost-update 窗口,注释与实现一致。
|
|
||||||
12. [x] 【中】前端 i18n 双侧同缺 aiChat.reattachToMain 键 ✅修复(2026-10-06 同批):zh/en 补键。
|
|
||||||
> 以上为 72 文件走查结论(报告要点 23:0x 已交群),提交规范 18 笔全合规,质量项按本清单跟进。
|
|
||||||
|
|
||||||
## 流畅性根本解三件(2026-10-05 深夜用户立项:1+2+3,④双通道搁置)
|
## 流畅性根本解三件(2026-10-05 深夜用户立项:1+2+3,④双通道搁置)
|
||||||
|
|
||||||
13. [x] **①过程流式** ✅ 2026-10-06 实施:run_stream_refresher 消费面扩容(AiTextDelta + AiAgentRound + AiToolCallStarted/Completed 四事件,conv 路由过滤共用)——过程流不刷屏:卡片改「头部一行摘要 + 正文增量」结构,头行=轮次 + ⚙当前工具执行中/已完成 N 步(历史步骤折叠为计数,决策价值过滤);过程事件标脏强制重刷(纯工具轮无文本也有"活着"信号,治 206 轮长任务卡片静止猜卡死);签名去重防同 tick 事件风暴空刷;超限保尾截断口径不变。4 单测锁格式契约(首轮无头行/工具执行中/轮间空档/截断)。验收:cargo check EXIT 0 + feishu 7 passed。参照 u-claw 进度卡治理;桌面过程视图同步对齐见「桌面端能力同步清单」。
|
|
||||||
14. [ ] **②任务式委托**:重任务(agentic loop 任务)受理即回任务卡(「已受理·预计 N 分钟」),用户可离开;完成后卡片原地更新+结果在卡。底座已有(30min 超时任务后台不丢),重构 handle_message 等待形态为受理/完成两段式。验收:发「走查代码」3s 内收到受理卡,完成后卡片变结果,全程无需等待。
|
14. [ ] **②任务式委托**:重任务(agentic loop 任务)受理即回任务卡(「已受理·预计 N 分钟」),用户可离开;完成后卡片原地更新+结果在卡。底座已有(30min 超时任务后台不丢),重构 handle_message 等待形态为受理/完成两段式。验收:发「走查代码」3s 内收到受理卡,完成后卡片变结果,全程无需等待。
|
||||||
15. [ ] **③C 批次关联**:本组两件的提速收益依赖 C 批次(prompt 71K→按需)——C-m1/m2 优先级顺位提升(单轮推理是 80% 耗时大头,见流畅性分析 2026-10-05)。
|
15. [ ] **③C 批次关联**:本组两件的提速收益依赖 C 批次(prompt 71K→按需)——C-m1/m2 优先级顺位提升(单轮推理是 80% 耗时大头,见流畅性分析 2026-10-05)。
|
||||||
16. [x] **纠偏注入通道**(用户 2026-10-05 深夜点名,飞书对话补充消息目前只能排队不能注入运行中任务)✅ 2026-10-06 实施:引擎层 `inject_runtime_message`(chat.rs,活跃守卫 conv_states.is_active → 短锁 push per_conv.messages → save_conversation 增量落库,loop 下轮 build_for_request 自然带上,u-claw 补拉同构);飞书侧 handle_message 白名单后加注入旁路(chat_map 命中且 conv 活跃 → 注入 + 卡片确认「下一轮生效」,非活跃 Err 落回常规 spawn 路径零行为变更)。不改锁语义/不打断 loop;桌面任务卡补充入口待 14 异步委托批配。验收:cargo check EXIT 0 + chat 17 passed + feishu 3 passed(含注入前缀格式契约单测)。
|
|
||||||
> **14 升级(用户拍板 2026-10-06 凌晨):桌面也要异步能力,不做双端分叉**——异步委托是引擎层能力,桌面与飞书都是它的前端视图。实现要点:①引擎层 spawn 即受理返回,完成走通知桥广播(oneshot 改事件+等待者订阅) ②桌面任务卡 UI:受理/进行中 N 轮/完成+系统通知,发消息后可切会话/最小化 ③飞书为同一语义投影(受理卡/完成原地更新) ④锁语义保守起步=单会话单在途+排队,多并发看用量再说 ⑤yml 开关灰度。依赖链:16 纠偏通道(受理后补充入口)→13 过程流式(进行中视图)→本项。验收:桌面发任务→切走→完成收系统通知;飞书同构;双端行为一致。
|
|
||||||
|
|
||||||
> **设计约束(用户拍板 2026-10-06 凌晨):任务会话高度复杂,代码设计一定要优雅**——①状态机显式化:受理/运行/暂停/完成/失败用枚举统一,禁散落 bool 拼凑 ②单一事实源:任务状态只在引擎层,桌面/飞书 UI 都是投影(订阅不轮询) ③事件驱动贯通:13/14/16 共用同一事件总线,不做渠道私有旁路 ④优雅优先于速度:宁可工期换结构,不做特判堆补丁(优雅架构铁律)。
|
|
||||||
|
|
||||||
> **桌面端能力同步清单**(与渠道对齐,2026-10-06):16 纠偏注入做在 AiSession 会话层桌面天然受益;13 过程流式桌面过程视图同步对齐;14 任务卡+系统通知已在升级注记;另两件桌面专属:①V49 可恢复入口——走查实锤 list_parked 读侧零消费,桌面侧栏补「可恢复」列表消费它(断点续跑闭环最后一块)✅已做 e0b29b4(分组展示+点击恢复) ②缺陷9 会话标题泄漏桌面同症随批修。
|
|
||||||
17. [ ] **桌面会话内容同步飞书**(同会话,2026-10-06 凌晨拍板):绑定飞书 chat 的 conv(chat_map 映射既有,恢复机制在)——桌面端该 conv 的新消息(user/assistant)实时投影飞书卡片,用户离机后手机飞书可跟任务全程。实现:订阅 ai_event_bus 的 conv 内容事件→飞书投影,与 13 过程流式/14 任务卡共用单写卡者;按决策价值过滤防刷屏(完整历史在桌面,飞书给关键节点)。与 14 组合即完整闭环:桌面发任务→切走→手机看过程→收结果。验收:桌面 conv 发消息/收回复,绑定飞书 3s 内卡片更新关键节点。
|
17. [ ] **桌面会话内容同步飞书**(同会话,2026-10-06 凌晨拍板):绑定飞书 chat 的 conv(chat_map 映射既有,恢复机制在)——桌面端该 conv 的新消息(user/assistant)实时投影飞书卡片,用户离机后手机飞书可跟任务全程。实现:订阅 ai_event_bus 的 conv 内容事件→飞书投影,与 13 过程流式/14 任务卡共用单写卡者;按决策价值过滤防刷屏(完整历史在桌面,飞书给关键节点)。与 14 组合即完整闭环:桌面发任务→切走→手机看过程→收结果。验收:桌面 conv 发消息/收回复,绑定飞书 3s 内卡片更新关键节点。
|
||||||
18. [x] **渠道错误话术吞因**(09:51 实证)✅ 2026-10-06 修复:①spawn Err 分类话术——并发护栏拒绝(「正在生成」)→「⏳ 该会话已有任务进行中,本条将在任务完成后处理;可稍后重发,或先 /status 查看进度」;其余失败→附截断原始原因(不吞因)。②探活告警口径修正——inflight 计数点从 spawn 前提前到 handle_message 白名单通过处(RAII 覆盖占位卡 HTTP+per-chat 锁排队窗口,原该窗口任务在跑却报「无在途会话」);旧计数点删除防双重计数。验收:cargo check EXIT 0 + feishu 7 passed。
|
|
||||||
> 附:09:48 桌面纠偏「不是 haogame 是 redtimer」被运行中 loop 下一轮直接接住——16 纠偏注入的桌面形态天然存在,飞书侧对齐即可。
|
|
||||||
|
|
||||||
## 工作流治理升级(2026-10-06 用户拍板:根本性增强,源自提交质量检查三不达标)
|
## 工作流治理升级(2026-10-06 用户拍板:根本性增强,源自提交质量检查三不达标)
|
||||||
|
|
||||||
> 根因:任务传递靠软机制(看板摘要+群消息阅读),无结构化任务单与销账门禁——🔴被静默跳过/i18n 描述失实/描述与内容偏差,三层同源。
|
> 根因:任务传递靠软机制(看板摘要+群消息阅读),无结构化任务单与销账门禁——🔴被静默跳过/i18n 描述失实/描述与内容偏差,三层同源。
|
||||||
@@ -860,17 +623,6 @@ graph TD
|
|||||||
- [ ] **G3 描述反向核对**:提交前 diff 关键词与描述机械比对,不符打回(cm 技能增强)
|
- [ ] **G3 描述反向核对**:提交前 diff 关键词与描述机械比对,不符打回(cm 技能增强)
|
||||||
- [ ] **G4 销账带证据**:勾选 [x] 须附验收命令输出行,无证据视同未完成
|
- [ ] **G4 销账带证据**:勾选 [x] 须附验收命令输出行,无证据视同未完成
|
||||||
|
|
||||||
### 桌面能力同步(随本批)
|
|
||||||
- [x] G5 V49「可恢复」入口(桌面侧栏消费 list_parked,断点续跑闭环最后一块)✅ e0b29b4 可恢复分组+点击恢复已落地
|
|
||||||
- [ ] G6 缺陷9 标题泄漏桌面同症随批修
|
|
||||||
|
|
||||||
### ⚠️ 退回重处理(2026-10-06 用户令,G2/G3 治理标准首次执行)
|
|
||||||
- 三笔提交(cec4d11 委托引擎/84df9b5 租约/622507c UI)已 reset --mixed 退回(内容全在工作区,零丢失,未推远端无需处理远端)
|
|
||||||
- **重做要求**:①先修 🔴10 loop_state 失效(G2 门禁:🔴不清零不得提交新功能)②描述按 G3 如实:实际干了 14+16+事件广播三件须逐件列全 ③i18n reattachToMain 键随批补上 ④修完按主题重新分组提交,销账 [x] 附验收输出(G4)
|
|
||||||
19. [x] **飞书 outbound 统一出口**(11:23 实证)✅ 2026-10-06 收口:勘察确认对话/受理/完成/注入回执已统一走 deliver_result(降级/裁剪/卡片链内聚);病灶「挂机进度汇总独立发送路径」在当前 main 不存在(属退回的挂机实验代码)。本批防复发收口:①新增 push_to_chat pub(crate) 正典出口(主动推送形态——挂机进度汇总/巡检通知将来落地时禁再开独立路径,强制走降级/裁剪/卡片链) ②ACTIVE_CLIENT OnceLock 全局句柄(渠道启动就绪,推送复用长连接 client 不临时建连,未启动静默跳过)。验收:cargo check EXIT 0 + feishu 7 passed。
|
|
||||||
> 附:挂机汇报自报 🔴10 已修验证通过+43 文件产出——挂机模式推进有效,路径修复别误伤它。
|
|
||||||
|
|
||||||
## 无人值守可靠性专项(2026-10-06 swarm 立项,设计档:docs/02-架构设计/无人值守可靠性专项设计-20261006.md+temp/V49断点续跑设计-20261006.md)
|
|
||||||
|
|
||||||
## UI/UX 体验设计实施项(2026-10-06 设计档:temp/异步任务会话UIUX设计-20261006.md,依赖 13/14/16/17 机制先行)
|
## UI/UX 体验设计实施项(2026-10-06 设计档:temp/异步任务会话UIUX设计-20261006.md,依赖 13/14/16/17 机制先行)
|
||||||
- [ ] U1 任务卡生命周期组件(双端):桌面 TaskCard 八态投影枚举(全部由既有事件派生,零引擎新状态)+飞书 header 状态色结构卡(JSON 元素级规格已给,动作 v1 走文本命令)
|
- [ ] U1 任务卡生命周期组件(双端):桌面 TaskCard 八态投影枚举(全部由既有事件派生,零引擎新状态)+飞书 header 状态色结构卡(JSON 元素级规格已给,动作 v1 走文本命令)
|
||||||
@@ -879,24 +631,6 @@ graph TD
|
|||||||
- [ ] U4 纠偏交互:桌面直发默认注入·下一轮生效(排队可切),回执+前缀逃生;飞书回复即注入保持
|
- [ ] U4 纠偏交互:桌面直发默认注入·下一轮生效(排队可切),回执+前缀逃生;飞书回复即注入保持
|
||||||
- [ ] U5 异常态话术公式:状态+真实原因+已保留什么+≥2条出路;禁自动判死静默任务(15min 重入退避是合法等待),给判断材料不替用户决策
|
- [ ] U5 异常态话术公式:状态+真实原因+已保留什么+≥2条出路;禁自动判死静默任务(15min 重入退避是合法等待),给判断材料不替用户决策
|
||||||
- [ ] U6 跨端一致性:引擎唯一写者,双端不直同步,"实时"=秒级最终一致;断线恢复桌面走 DB 回放/飞书走 /status 回查
|
- [ ] U6 跨端一致性:引擎唯一写者,双端不直同步,"实时"=秒级最终一致;断线恢复桌面走 DB 回放/飞书走 /status 回查
|
||||||
20. [x] **委托制伪受理断层**(13:38 实证):受理卡在引擎确认可启动之前发出——12:57 任务停滞 Generating 时,「进度」spawn 被引擎拒但受理卡已发,之后零通知零进度(比 09:51 立即报错更隐蔽)。修:受理前过 can_accept_request,不可启动→直接回「前任务在跑,已排队/稍后」;可启动→才发受理卡+watcher 接力。附带:委托制下 inflight 生命周期改跟任务走(handle_message 提前返回致 18 修正再次失效,13:18 误报复现)。验收:同场景复测,占用期发消息得到明确排队提示而非静默受理。 ✅ 2026-10-06 c91b993(交互批八项合提,验收: cargo check 零 error+feishu 13/df-im-gateway 19/chat 17 passed+vue-tsc 0)
|
|
||||||
> **符号语义规范(2026-10-06 用户反馈:受理卡「✅ 已受理」歧义,随 19 统一出口一并落地)**:
|
|
||||||
> - 🚀 受理/开工(任务开始才用,禁 ✅)|⏳ 进行中/排队/等待|✅ 仅限完成且成功|❌ 失败(必须附原因)|⚠️ 警告/需注意(附数据)|📎 附录/附加信息
|
|
||||||
> - 原则:符号=状态的一眼判据,同一符号跨场景语义必须一致;受理卡改为「🚀 已受理,后台执行中」;完成推送才允许 ✅ 开头。
|
|
||||||
|
|
||||||
## 飞书交互面全量盘查(2026-10-06,11 条缺陷按恼人度降序,修复前话术避免引用 /status)
|
|
||||||
34. [x] 🔴 **过程推送散乱刷屏**(14:06 用户截图反馈「信息太散乱爆炸了」):push_to_chat(:797)每调用必新开独立卡片,通道无聚合语义——昨日调试把链路测试/修复上线/加固明细等 L3 过程逐条直推 4+ 条进群,与 U3 分级相悖。修双层:①机制=push_to_chat 增滚动汇总卡——同 chat 维护在途事务卡 message_id(内存即可,重启另起),进度用已有 update_card(:730)原地追加;首推需 send_markdown_card 返回新卡 id(现 :683 is_ok() 丢弃,比照 reply_markdown_card);文本以 ✅/❌ 开头=终态,更新后清句柄下条另起新卡;更新失败回退新发并重绑(比照 deliver_result 兜底)。②纪律=L3 过程默认不推群,落日志+进度文件;push 收敛「需决策/完成✅」两时机,调试确需的验证推也进汇总卡不新开。验收:同事务多步进度群时间线只占 1 张卡,过程类推送零直达。 ✅ 2026-10-06 c91b993(交互批八项合提,验收: cargo check 零 error+feishu 13/df-im-gateway 19/chat 17 passed+vue-tsc 0)
|
|
||||||
21. [x] 🔴 **/status 被话术推荐但不存在**(chat.rs:387/:486 两处):用户发了被当任务烧一轮 LLM。修:实现 /status(读 conv 状态返回进度摘要,配合委托制价值高)或先改话术删除引用。验收:发 /status 得进度卡而非 spawn。 ✅ 2026-10-06 c91b993(交互批八项合提,验收: cargo check 零 error+feishu 13/df-im-gateway 19/chat 17 passed+vue-tsc 0)
|
|
||||||
22. [x] 🔴 **35min 兜底回查会投出上一任务旧回答冒充本次结果**(feishu_channel.rs:483/:939):回查整 conv 最后 assistant 文本无任务边界。修:回查限定受理时间戳之后的 assistant,无则给真实状态话术。 ✅ 2026-10-06 c91b993(交互批八项合提,验收: cargo check 零 error+feishu 13/df-im-gateway 19/chat 17 passed+vue-tsc 0)
|
|
||||||
23. [x] 🔴 **watcher 首超时后永不弃守**(:486 continue 与注释"二次超时弃守"相反)+错误路径只发 AiError 无 AiAgentFinished→每 35min 重刷过期文案+任务泄漏。修:二次超时真弃守+AiError 也广播 Finished(status=error)。 ✅ 2026-10-06 c91b993(交互批八项合提,验收: cargo check 零 error+feishu 13/df-im-gateway 19/chat 17 passed+vue-tsc 0)
|
|
||||||
24. [x] **无取消/审批通路**:跑偏无法叫停(飞书零 stop 通道);审批挂起对飞书用户不可见(流式卡冻结无解释)。修:/cancel 对接 ai_stop_loop;审批事件上卡片(「需审批:xxx,回复 批准/拒绝」)。 /cancel 停止通路+watcher 收口+审批推送与批准/拒绝决议(路径类授权留原语差异), ✅ 2026-10-07 细项批(待提交)
|
|
||||||
25. [x] **非文本消息静默丢**(:243):发图/文件零反馈。修:回「暂仅支持文本,媒体能力规划中」(D7 前的兜底)。 非文本兜底已存在于基线(:255 回「暂仅支持文本」), ✅ 2026-10-07 核验补销
|
|
||||||
26. [x] **⏳话术承诺排队实际不入队**——话术与行为不符。修:改话术为实况(「可稍后重发」)或实现真排队(与 20 受理断层同修)。 ✅ 2026-10-06 c91b993(交互批八项合提,验收: cargo check 零 error+feishu 13/df-im-gateway 19/chat 17 passed+vue-tsc 0)
|
|
||||||
27. [x] **快任务兜底死分支**(:449 elapsed>=3s 入场即查恒假,「秒级任务直投终版」不可达)。修:受理卡发送后计时或事件驱动判定。 ✅ 2026-10-06 c91b993(交互批八项合提,验收: cargo check 零 error+feishu 13/df-im-gateway 19/chat 17 passed+vue-tsc 0)
|
|
||||||
28. [x] **注入末轮竞态**:loop 已在末轮则补充指令无人消费静默丢失。修:注入后检查 loop 剩余轮次,末轮则自动续一轮。 注入末轮顺延轮次预算(共享余量+轮顶动态判定+封顶 8), 5 单测锁定, ✅ 2026-10-07 细项批(待提交)
|
|
||||||
29. [x] **push_to_chat 取 KV map 首条**(:792):多 chat 绑定可能发错会话。修:按 conv 反查 chat。 push_to_chat 按 conv 反查+多绑定取最近活跃(签名加 conv_hint 零涟漪), ✅ 2026-10-07 细项批(待提交)
|
|
||||||
30. [x] 细节批::389 反引号不闭合;/new 大小写与附言容错;/new 时旧任务在跑无提示;标点半全角混排。 反引号闭合//new 容错与旧任务提示/半全角归一(parse_command 3 单测), ✅ 2026-10-07 细项批(待提交)
|
|
||||||
|
|
||||||
## 上下文根治专项(2026-10-06 设计定稿:docs/02-架构设计/上下文根治设计-20261006.md,取代旧 C 发散条目)
|
## 上下文根治专项(2026-10-06 设计定稿:docs/02-架构设计/上下文根治设计-20261006.md,取代旧 C 发散条目)
|
||||||
- [ ] P1(S):工具结果瘦身——超 2K 结果正文落盘+句柄,上下文留摘要+路径(与 D2a 同构,最大头先行)
|
- [ ] P1(S):工具结果瘦身——超 2K 结果正文落盘+句柄,上下文留摘要+路径(与 D2a 同构,最大头先行)
|
||||||
- [ ] P2(M):L1/L2/L3 分层组装器(build_for_request 改造:常驻+滑动窗口+检索召回)
|
- [ ] P2(M):L1/L2/L3 分层组装器(build_for_request 改造:常驻+滑动窗口+检索召回)
|
||||||
@@ -911,6 +645,11 @@ graph TD
|
|||||||
|
|
||||||
### B5 无人值守续作引擎(2026-10-06 用户实证"总停下确定性任务"的根治,挂机治理核心缺口)
|
### B5 无人值守续作引擎(2026-10-06 用户实证"总停下确定性任务"的根治,挂机治理核心缺口)
|
||||||
- [ ] **B5a 挂钟闸分任务定级**:确定性任务(看板任务/用户拍板项)挂钟闸放宽(如 120min)或按预估时长配置;闸到点改「检查点+续跑」(存进度不停工),仅非确定性/失控疑似才硬停靠。
|
- [ ] **B5a 挂钟闸分任务定级**:确定性任务(看板任务/用户拍板项)挂钟闸放宽(如 120min)或按预估时长配置;闸到点改「检查点+续跑」(存进度不停工),仅非确定性/失控疑似才硬停靠。
|
||||||
|
- [ ] **目标提取质量闸**(2026-10-12 目标丢失事故实锤:用户抱怨句「丢失 会话上下文了 找回目标」被注册成任务目标、「继续」单字成目标、半截命令「ssh-proxy upload --name i」以 active 驱动续跑):写入侧把关——短文本/纯沟通词/疑问句不入 goals,含参数的命令目标须完整形态;恢复侧放行前过同闸
|
||||||
|
- [ ] **恢复消息递归套娃**(2026-10-12 实锤:重启恢复消息整段被当用户输入再提取,目标里嵌套上一次恢复全文,层层放大):恢复消息注入走旁路,不进目标提取源
|
||||||
|
- [ ] **G2 停滞判定叠输出变化维度**(2026-10-12 OSS迁移会话实证:轮询 grep DONE 同参数连发5次被防卡死拦截,但输出 skip 8000→8500 在变=有进展硬证据):样本键叠加输出内容哈希,输出仍在变不算停滞(真死循环输出也不变,非放松防线);stall_detector.rs 签名族
|
||||||
|
- [ ] **恢复目标完整性守卫**(同上实证:残缺目标「ssh-proxy upload --name i」进快照被恢复引导放行,AI 强行续跑):R4 恢复消息对参数明显不完整的目标标注「须先向用户确认参数」;偏产品语义,与快照线同族
|
||||||
|
- [ ] **压缩失败路径召回句柄核查**(同上实证:压缩失败兜底摘要+archive jsonl 归档正常,但截断参数仍丢失):核查④压缩可召回句柄行在压缩失败路径是否注入/AI 是否被引导召回;与知识路标层同族问题
|
||||||
- [ ] **B5b 停靠自动续作**:停靠/parked/批收敛后自动回读看板——有 parked 可续任务→自动 resume;有未完批次→自动领单续作;真需决策才停(附决策项)。无人值守的"永动"机制,CLAUDE.md 领单纪律的运行时化。
|
- [ ] **B5b 停靠自动续作**:停靠/parked/批收敛后自动回读看板——有 parked 可续任务→自动 resume;有未完批次→自动领单续作;真需决策才停(附决策项)。无人值守的"永动"机制,CLAUDE.md 领单纪律的运行时化。
|
||||||
- [ ] **B5c 审批无人值守策略**:挂机中审批拦截按用户预设策略处置(低风险自动批+留痕,高风险停下+通知),策略 yml 可配。验收:挂机 4h 连续推进模拟(确定性批次队列),零人工干预零停顿;超大任务挂钟闸到点走检查点续跑。
|
- [ ] **B5c 审批无人值守策略**:挂机中审批拦截按用户预设策略处置(低风险自动批+留痕,高风险停下+通知),策略 yml 可配。验收:挂机 4h 连续推进模拟(确定性批次队列),零人工干预零停顿;超大任务挂钟闸到点走检查点续跑。
|
||||||
|
|
||||||
@@ -925,20 +664,17 @@ graph TD
|
|||||||
**补齐优先序:B5d 工具重试策略 → B5a 挂钟定级 → B5b 自动续作 → B5c 审批策略 → B2 预算闸 → 13 过程流式**
|
**补齐优先序:B5d 工具重试策略 → B5a 挂钟定级 → B5b 自动续作 → B5c 审批策略 → B2 预算闸 → 13 过程流式**
|
||||||
|
|
||||||
### 13a 飞书过程可见性加固(2026-10-06 用户实证"卡点到结束才回复",13 的最小件提前独立做,零新机制)
|
### 13a 飞书过程可见性加固(2026-10-06 用户实证"卡点到结束才回复",13 的最小件提前独立做,零新机制)
|
||||||
- [x] 13a-1 刷新可观测:refresher 的 update_card 失败从"静默跳过"改为 feishu_log 记录+连续失败计数(≥3 连败告警)——当前刷了还是没刷、刷败全不可知,排查无从下手。 ✅ 2026-10-07 实施(feishu_channel.rs 单文件):刷失败 feishu_log 落盘(连败次数/chat/msg/chars)+连败 ≥3 告警一次(REFRESH_FAIL_ALERT_THRESHOLD=3,== 阈值触发后继续累计不重复)+刷新恢复记「此前连败 N 次」+成功归零。验收:cargo check -p devflow EXIT 0 + cargo test --lib feishu 16 passed 0 failed。
|
|
||||||
- [ ] 13a-2 静默期心跳汇报:AiHeartbeat(20s 既有事件)接入 refresher——超 N 分钟无内容刷新→主动刷一行「仍在跑:第 X 轮 · 最近动作 yyy」(数据源 AiAgentRound/最后工具名),防静默期卡片冻结。
|
- [ ] 13a-2 静默期心跳汇报:AiHeartbeat(20s 既有事件)接入 refresher——超 N 分钟无内容刷新→主动刷一行「仍在跑:第 X 轮 · 最近动作 yyy」(数据源 AiAgentRound/最后工具名),防静默期卡片冻结。
|
||||||
- [ ] 13a-3 阶段汇报:AiTaskProgress(每 5 轮,mod.rs:1119 已 emit)接入 refresher——长任务的阶段性进展对飞书用户可见(当前只有桌面消费)。
|
- [ ] 13a-3 阶段汇报:AiTaskProgress(每 5 轮,mod.rs:1119 已 emit)接入 refresher——长任务的阶段性进展对飞书用户可见(当前只有桌面消费)。
|
||||||
- [ ] 13a-4 受理→首刷衔接:受理卡到首个 delta/心跳之间的静默窗口检查(>60s 无首刷则心跳兜底)。
|
- [ ] 13a-4 受理→首刷衔接:受理卡到首个 delta/心跳之间的静默窗口检查(>60s 无首刷则心跳兜底)。
|
||||||
验收:206 轮级长任务期间,占位卡至少每 3-5 分钟有一次真实更新(心跳或阶段),失败可见于 feishu-channel.log。
|
验收:206 轮级长任务期间,占位卡至少每 3-5 分钟有一次真实更新(心跳或阶段),失败可见于 feishu-channel.log。
|
||||||
31. [x] (自动续作) **AI-Ops→devflow 派工通道**(多 bot 编排正统通道,2026-10-06 缺口实证):AI-Ops 无法直接触发 devflow 开工(飞书 bot 消息不互触发/WebChat 无/remote_bridge 走 tunnel 不外用)。方案二选一:①u-claw 侧新增 ask_devflow(与既有 ask_uclaw 对等,经 remote_bridge 协议下发启动指令) ②devflow 侧看板轮询引擎(定时扫 todo.md 新🔴/高优条目自动开工,CLAUDE.md 领单纪律的运行时化=B5b 同源)。建议 ② 与 B5b 合并实现(同一套自动续作引擎)。验收:AI-Ops 写看板后 15min 内 devflow 自主开工,零人工中转。 ✅ 2026-10-07 方案②实施并与 B5b 合并落地(unattended.rs 636 行,B5b+31 同一引擎):看板解析器(编号条目四态+🔴 判据=标记后首字符防误判)/G2 门禁领单选择器(🔴 未清零只领红 fail-closed)/轮询调度器(tokio 常驻,KV 热读配置 df-unattended-{enabled,poll-secs,claim-scope,todo-path},默认关+300s)/收敛回读钩子 notify_batch_done 接 agentic emit_ai_completed_once 单点/parked 自动恢复优先/连续 3 次 spawn 失败熔断/领单原子写+spawn 失败回滚/引擎独立后台会话 pin_active=false。验收证据:①接线 rg 实测 lib.rs:77 unattended::start + agentic/mod.rs 收敛钩子调用 ②`cargo test --lib unattended` = 9 passed 0 failed(本轮实测修正 parse_statuses_and_red_marker 断言:🔴 判据仅认标记后首字符,正文提及不误判,与实现注释语义对齐) ③**活证**=本条目自身即引擎产出:AI-Ops 写板 → 引擎改写 `31. [→](自动续作)` → 派发本会话执行,零人工中转,15min 上限实测远低于下限。
|
|
||||||
|
|
||||||
### 31 泛化路线:「看板即队列」模板资产化(2026-10-06 swarm 设计定稿)
|
### 31 泛化路线:「看板即队列」模板资产化(2026-10-06 swarm 设计定稿)
|
||||||
- **抽象**:看板=任务队列(优先级/状态三态/验收口径)+ 领单纪律=消费者协议 + 治理门禁=质量闸。任何 Claude Code 实例 + 一个看板 = 一个自主工作单元。
|
- **抽象**:看板=任务队列(优先级/状态三态/验收口径)+ 领单纪律=消费者协议 + 治理门禁=质量闸。任何 Claude Code 实例 + 一个看板 = 一个自主工作单元。
|
||||||
- **组件三件套模板**(提炼自 devflow 今日实践,新场景开箱即用): 1.看板格式规范 2.领单纪律 CLAUDE.md 模板 3.治理门禁包(cm v1.3.0+pre-commit hook)
|
- **组件三件套模板**(提炼自 devflow 今日实践,新场景开箱即用): 1.看板格式规范 2.领单纪律 CLAUDE.md 模板 3.治理门禁包(cm v1.3.0+pre-commit hook)
|
||||||
- **推广路线**:devflow v1 验证 → 模板提炼进技能分发站 → u-claw 九实例各挂看板 → 多 bot 协作总线(N²→N+1) → 人-bot 协作(写板即派工)。
|
- **推广路线**:devflow v1 验证 → 模板提炼进技能分发站 → u-claw 九实例各挂看板 → 多 bot 协作总线(N²→N+1) → 人-bot 协作(写板即派工)。
|
||||||
- **前置约束**:仅异步任务适用;hook 随模板强制安装;多实例并发领单=[→]原子改写+git push 冲突天然仲裁。
|
- **前置约束**:仅异步任务适用;hook 随模板强制安装;多实例并发领单=[→]原子改写+git push 冲突天然仲裁。
|
||||||
- [ ] 32 泛化落地:devflow v1 跑通后,模板三件套提炼进技能分发站(skill 形态),九实例试点 2-3 个(先 AI-Ops 自领+devflow,跑一周数据再扩)。
|
- [ ] 32 泛化落地:devflow v1 跑通后,模板三件套提炼进技能分发站(skill 形态),九实例试点 2-3 个(先 AI-Ops 自领+devflow,跑一周数据再扩)。
|
||||||
33. [ ] **devflow.md MVP(十四节多角色论证定稿)**:①启动读取 devflow.md(规范纪律+领单两分区)+CLAUDE.md 并入 build_runtime_prompt 第七段,内容按相关性裁剪禁全量 ②警示区走 insert 消息范式 ③状态不文件化(DB 直注+H3 git 实测) ④知识投影补相关性元数据。砍除运行中文件刷新。v2+:agent 自主维护/知识投影动态化/跨会话投影。验收:启动双文件并入生效;警示事件下一轮可见;token 零缓存击穿。
|
33. [x] **devflow.md MVP(十四节多角色论证定稿)** ✅已实施(2026-10-12:devflow_md.rs 投影模块+第七段稳定尾追加双路径(主会话 build_runtime_prompt+子代理 spawn_agent 同源)/分区裁剪(纪律4000+领单2000+CLAUDE 4000/总6000)/conv_id 键会话级缓存零缓存击穿/缺失零回归;881 测试绿含 9 新):①启动读取 devflow.md(规范纪律+领单两分区)+CLAUDE.md 并入 build_runtime_prompt 第七段,内容按相关性裁剪禁全量 ②警示区走 insert 消息范式 ③状态不文件化(DB 直注+H3 git 实测) ④知识投影补相关性元数据。砍除运行中文件刷新。v2+:agent 自主维护/知识投影动态化/跨会话投影。验收:启动双文件并入生效;警示事件下一轮可见;token 零缓存击穿。
|
||||||
|
|
||||||
### 看板完整性防护(2026-10-06 todo.md 截断事故:文件被重写为 12 行+脱离 git 跟踪,内容经 git HEAD+残版合并零丢失恢复)
|
### 看板完整性防护(2026-10-06 todo.md 截断事故:文件被重写为 12 行+脱离 git 跟踪,内容经 git HEAD+残版合并零丢失恢复)
|
||||||
- [ ] **看板防护三件**:①条目数骤降告警——看板条目数单次变化 >50% 时视为异常(防静默截断),提交/重写前自检 ②看板修改一律原子写(tmp+rename)+保留前版快照(todo-快照-*.md 滚动一份) ③git add 覆盖检查——提交流程确认看板在跟踪列表(防选择性 add 遗漏致脱离跟踪)。根因:agent 提交/整理流程对 todo.md 组合失误(重写+跟踪移除),细节在其 transcript 不可见——机制防护优先于行为归因。
|
- [ ] **看板防护三件**:①条目数骤降告警——看板条目数单次变化 >50% 时视为异常(防静默截断),提交/重写前自检 ②看板修改一律原子写(tmp+rename)+保留前版快照(todo-快照-*.md 滚动一份) ③git add 覆盖检查——提交流程确认看板在跟踪列表(防选择性 add 遗漏致脱离跟踪)。根因:agent 提交/整理流程对 todo.md 组合失误(重写+跟踪移除),细节在其 transcript 不可见——机制防护优先于行为归因。
|
||||||
@@ -948,16 +684,103 @@ graph TD
|
|||||||
35. [~] ~~H3 teardown 疑似过重~~ **真相改写(验装日志实锤)**:17:47 进程消失=agent 自杀式换装脚本杀宿主(swap-install.log:17:48:14 old process stopped),非 H3——H3 嫌疑解除。真正缺陷转 36。
|
35. [~] ~~H3 teardown 疑似过重~~ **真相改写(验装日志实锤)**:17:47 进程消失=agent 自杀式换装脚本杀宿主(swap-install.log:17:48:14 old process stopped),非 H3——H3 嫌疑解除。真正缺陷转 36。
|
||||||
> 关联:13a 过程可见性四件**置顶催办**——受理后静默的体感投诉今天第三次(09:51/13:38/17:47+),13a-1 刷新可观测是排查此类问题的前提。
|
> 关联:13a 过程可见性四件**置顶催办**——受理后静默的体感投诉今天第三次(09:51/13:38/17:47+),13a-1 刷新可观测是排查此类问题的前提。
|
||||||
|
|
||||||
36. [x] **换装脚本失败:杀进程后 3s 即安装,Windows 文件锁延迟释放致 NSIS 静默伪成功**(17:48 实证 FAIL: exe not updated,installer exit=0)。✅ 2026-10-06 晚全链闭环:**脚本加固**(swap-install.ps1 v3,语法 0 错实测):①句柄释放探测(独占打开循环,30s) ②校验升级为构建产物 SHA256 强基准(mtime 判据作废,NSIS 保留源文件 mtime 必误报) ③校验失败自动重试一次 ④SYSTEM 触发时转投用户会话重入,四级 fallback,最坏落 swap-need-start.flag。**上线验证过**(18:44 版手动换装成功):运行 exe SHA256=构建产物=0C45683B,进程 12764 运行中,H1 行为实测通过(19:13 DevFlow 回复带「📎 git 实测」段)。
|
|
||||||
37. [ ] **监控轮询机制缺失(1004/1006 两次要求均未落地,全周响应性投诉的伴生缺口)**:10-04「持续不间断监控每 1 小时」+10-06 18:45「持续监控 10 分钟一轮」,两次均未建机制;18:45 断链实锤=传承摘要未继承该指令。根因:无头会话无定时能力(网关禁用 CronCreate)。方案二选一:①网关 cron(运维配 yml crons,周期拉 git/进程/日志快照+异常告警) ②devflow 侧自监控引擎(与 B5/31 看板轮询同源,顺带监控自身进程/会话活性+静默告警)。验收:监控自动轮询真实运转,devflow 静默/进程消失 1 个周期内被发现并通报。
|
37. [ ] **监控轮询机制缺失(1004/1006 两次要求均未落地,全周响应性投诉的伴生缺口)**:10-04「持续不间断监控每 1 小时」+10-06 18:45「持续监控 10 分钟一轮」,两次均未建机制;18:45 断链实锤=传承摘要未继承该指令。根因:无头会话无定时能力(网关禁用 CronCreate)。方案二选一:①网关 cron(运维配 yml crons,周期拉 git/进程/日志快照+异常告警) ②devflow 侧自监控引擎(与 B5/31 看板轮询同源,顺带监控自身进程/会话活性+静默告警)。验收:监控自动轮询真实运转,devflow 静默/进程消失 1 个周期内被发现并通报。
|
||||||
38. [x] **飞书出站 md 语法裸露净化+交互样式对齐 u-claw**(AI-Ops 2026-10-06 晚实证落板):任务报告 `##`/`**` 等 md 标记在飞书裸露(20:53 分析报告截图实锤)。根因:飞书 lark_md 不支持标题/表格等全谱语法,LLM 产出未经出站转换;主链 send_markdown_card(feishu_channel.rs:716,注释自认「text 类型会符号裸露」)只保粗体/链接,im-gateway StreamText 链纯文本直出。修:①出站净化层——md 转飞书友好格式(标题行转粗体/表格转列表/代码块适配),双层防线=出站兜底转换+agent 产出 prompt 约束,移植 u-claw 投递链降级/裁剪经验(u-claw 仓为范本) ②首回受理卡样式对齐 u-claw 任务状态卡(计数 N/M+逐条状态符+当前动作一行,10-02 用户拍板「效果不错」);受理卡符号 🚀 已由 AI-Ops 修(449 行)。验收:同类分析报告在飞书零 md 裸露标记,受理卡样式与 u-claw 一致。 ✅ 2026-10-06 c91b993(交互批八项合提,验收: cargo check 零 error+feishu 13/df-im-gateway 19/chat 17 passed+vue-tsc 0)
|
|
||||||
39. [x] 🔴 **提交冻结令被无人值守链路击穿**(10-07 用户查问「最近提交不符合要求」实证):看板顶部 🔒 冻结令(10-06 深夜用户令,期间只改不提)生效后,挂机链路 01:56-57 仍连提 4 笔(c5ba755 续作引擎 811 行/d0281cb 飞书取消审批 345 行/7ab25e5 换装判据/73fa7c2 看板销账),且 73fa7c2 看板内容自书「细项批(待提交)」与提交事实自相矛盾——冻结仅是文本纪律,引擎与挂机会话的提交步无机械闸。修:①引擎自动提交路径与挂机会话提交步在 commit 前必查冻结标志(看板顶部 🔒 行视为生效,KV 标志更佳),在场即拒绝提交、改动留工作区;禁用全局 pre-commit 拦截(误伤用户本人提交) ②引擎派发的会话启动 prompt 注入「冻结期禁 commit」硬约束 ③解冻=用户「攒批提交通知」后由会话摘除 🔒 行再提。验收:冻结令在场时引擎/挂机会话 commit 步零执行(实测),摘令后恢复;随 31 引擎同测。 ✅ 2026-10-07 实施(commit_freeze 三层闸,零提交全在工作区):①机械闸=crates/df-execute/src/commit_freeze.rs 新模块——静态冻结位 set_frozen(引擎 unattended::run_tick 每轮按看板 🔒 行+KV df-commit-frozen 覆盖同步)+execute/execute_streaming 入口 check(),git commit 变体(git -C/复合命令 ;&&|/引号包裹/多空格)spawn 前拒绝,改动留工作区;未挂全局 pre-commit,用户本人终端零影响。②任务书注入:claim_and_spawn 冻结在场时追加「提交冻结令硬约束」段(禁 git commit/push,执行层闸兜底)。③解冻=用户摘 🔒 行或 KV df-commit-frozen=false,下一轮轮询(≤300s)自动同步放行。验收实测:cargo test -p df-execute 26 passed 0 failed(18 lib 含 6 冻结闸新用例:commit 变体识别/只读命令放行/冻结拦截/解冻恢复/静态位切换;8 shell 集成)+ cargo test -p devflow --lib unattended 10 passed 0 failed(含冻结判据新用例:🔒 行/blockquote `> 🔒` 形态/KV 双向覆盖/默认不冻结)+ cargo check -p devflow --lib EXIT 0。冻结在场实测=单测断言 check() 对 `git commit` 返回 Err 且 `git diff` 放行,摘令后恢复=static_flag_toggle 用例复位断言;真实看板形态(`> 🔒` 引用行)经 commit_freeze_board_and_kv_judge 实测命中。
|
|
||||||
|
|
||||||
40. [ ] **桌面侧 u-relay 常驻连接管理器(设计命题,待综合评估后再规划设计)**(2026-10-07 用户议定「登记后处理」):ask_uclaw 现为每请求独立 WS 短连接(connect→hello→探活→request→progress/result→close,任务执行期内本就实时);用户提议改「全程一条常驻连接中转所有上下行(zhub 式单连接多路复用)」。**本项仅登记命题,具体方案待后续综合评估**——评估须回答:①当前唯一用例(ask_uclaw 15min 级委托)下常驻是否真有必要(握手 ~0.1s 可忽略、u-claw 侧单 channel 串行 worker 锁死并发=1)②push 型需求(u-claw 主动推事件/高频短指令/多 channel 并行/跨链路统一观测)何时兑现 ③是否与 10-05「推翻网关链路、定稿桌面直连」决策冲突(飞书直连接入-形态修正与问题记录-2026-10-05.md)。命中上述 push 型触发条件之一即启动评估;同构先例可参 df-im-gateway(一条常驻 tunnel 连接中转四端全部上下行)与 feishu_channel.rs。
|
40. [ ] **桌面侧 u-relay 常驻连接管理器(设计命题,待综合评估后再规划设计)**(2026-10-07 用户议定「登记后处理」):ask_uclaw 现为每请求独立 WS 短连接(connect→hello→探活→request→progress/result→close,任务执行期内本就实时);用户提议改「全程一条常驻连接中转所有上下行(zhub 式单连接多路复用)」。**本项仅登记命题,具体方案待后续综合评估**——评估须回答:①当前唯一用例(ask_uclaw 15min 级委托)下常驻是否真有必要(握手 ~0.1s 可忽略、u-claw 侧单 channel 串行 worker 锁死并发=1)②push 型需求(u-claw 主动推事件/高频短指令/多 channel 并行/跨链路统一观测)何时兑现 ③是否与 10-05「推翻网关链路、定稿桌面直连」决策冲突(飞书直连接入-形态修正与问题记录-2026-10-05.md)。命中上述 push 型触发条件之一即启动评估;同构先例可参 df-im-gateway(一条常驻 tunnel 连接中转四端全部上下行)与 feishu_channel.rs。
|
||||||
|
|
||||||
900. [x](自动续作) 🔴 测试条目
|
|
||||||
|
|
||||||
901. [x](自动续作) 🔴 提交工作区改动(转交实战,2026-10-07):将当前工作区改动按主题分组提交并推送 origin main。(21:4x AI-Ops 重置回队:前两轮引擎会话被 20:38-21:02 u-switch 故障窗口「网络中断不完整」腰斩,重入预算耗尽停 [→],网络已恢复)要求:①提交前 G4 前置走查(风格/明显缺陷/调试残留/注释规范,发现问题先处理再提交) ②分组建议:挂机模式 P1 总装一笔(UnattendedPanel/Settings/TopBar/TaskBoundaryCard/i18n/unattended 两 IPC/lib.rs/feishu_notify)+看板销账一笔(todo.md) ③swap-install.ps1 若有改动先审 diff 再定去留;删除 scripts/check_plaintext_key.py(一次性诊断脚本已完成使命) ④提交信息中文类型开头逐件列全,commit-msg hook 会拦截违规 ⑤完成后本条改 [x] 并附 git log --oneline -3 输出。验收(10-08 AI-Ops 补录,引擎 22:06 完成):git log --oneline -3 → 0aac14e 文档: 看板销账: 挂机模式 P1 总装与换装校验修复入库,901 销账 / 6bfcf01 修复: 换装校验同版本重装误判不通过 / 2441101 新增: 挂机模式 P1 总装: 设置面板+顶栏徽章+边界卡精简+监控告警推送;main=origin/main 已推,工作区清零。
|
|
||||||
|
|
||||||
902. [ ] 超时失败后的一键继续入口: 流式首字节超时(120s,服务收了不响应,不可自动重试)报「响应超时,请重试」后流程中断——轮次重入预算耗尽(3/3)即彻底停,用户只见裸文案。修:超时失败落停靠语义(复用 emit_task_parked),停靠卡带「继续」(ai_continue_loop parked 分流已修好)——错误后一键续跑而非中断。体验缺口实证 2026-10-07 晚 u-switch 故障窗口。
|
902. [ ] 超时失败后的一键继续入口: 流式首字节超时(120s,服务收了不响应,不可自动重试)报「响应超时,请重试」后流程中断——轮次重入预算耗尽(3/3)即彻底停,用户只见裸文案。修:超时失败落停靠语义(复用 emit_task_parked),停靠卡带「继续」(ai_continue_loop parked 分流已修好)——错误后一键续跑而非中断。体验缺口实证 2026-10-07 晚 u-switch 故障窗口。
|
||||||
903. [ ] u-switch 20:38-21:02 故障窗口根因深查: 11 连败+120s 首字节超时风暴(多会话多 provider 同时),21:0x 自愈。查 moyu-ops 机 u-switch 日志定因(上游抖动/服务重启/连接池耗尽),根治或加监控告警。**【21:5x AI-Ops 定因已实锤,journalctl 实测】上游连环降级雪崩非服务故障:api.deepseek.com 402 余额不足(冷却10m)+enterprise.moyu.info 401 认证失效(冷却10m)+sensenova kimi-k3 429/SSE空响应体(30s冷却循环)+bigmodel glm 429/SSE截断缺 message_stop——多通道同时打光致无首字节,冷却走完自愈。剩余工作=根治项:①switch-gw 的 deepseek 系 4 通道确认摘除(用户指认已摘但配置仍在役)②429 风暴时客户端退避协同(30s 冷却对重试风暴无效)③故障窗口告警。**
|
903. [ ] u-switch 20:38-21:02 故障窗口根因深查: 11 连败+120s 首字节超时风暴(多会话多 provider 同时),21:0x 自愈。查 moyu-ops 机 u-switch 日志定因(上游抖动/服务重启/连接池耗尽),根治或加监控告警。**【21:5x AI-Ops 定因已实锤,journalctl 实测】上游连环降级雪崩非服务故障:api.deepseek.com 402 余额不足(冷却10m)+enterprise.moyu.info 401 认证失效(冷却10m)+sensenova kimi-k3 429/SSE空响应体(30s冷却循环)+bigmodel glm 429/SSE截断缺 message_stop——多通道同时打光致无首字节,冷却走完自愈。剩余工作=根治项:①switch-gw 的 deepseek 系 4 通道确认摘除(用户指认已摘但配置仍在役)②429 风暴时客户端退避协同(30s 冷却对重试风暴无效)③故障窗口告警。**(10-08 已处置:moyu 令牌过期续期+主链切 ds-flash,见 900 区段外 905)
|
||||||
|
|
||||||
|
925. [ ] **前后端事件契约与 i18n key 全量核查(2026-10-10 只读 Wave D,源码取证)**:全量抽取后端 emit 与前端 listen/分派做对照;另把 src 下 1282 个静态 i18n key 与 zh-CN/en 键集合(各 1569)比对。
|
||||||
|
- ✅ **已修两处**:①`ai.aiTool.rejectedHint` 前缀写错(多一层 `ai.`)→ 2 处调用(`composables/ai/useAiApproval.ts:90` / `useAiToolEvents.ts:120`)改为正确键 `aiTool.rejectedHint`(此前 zh/en 双缺 → UI 渲染裸 key,908 类复发);②`components/layout/UpdateBadge.vue` 监听 Tauri 事件 `app-update-available`,而生产端 `composables/useUpdateCheck.ts` 走 **window CustomEvent** 同名不同通道 → 徽章永不触发;已补 window 监听 + 卸载清理(保留 Tauri 监听以兼容后端将来改走 emit)。
|
||||||
|
- [ ] **待处理·孤儿事件 2**:`AiPlanCreated`(`agentic/mod.rs:1006` emit)/`AiConflictResolved`(`commands/settings.rs:259` emit)——前端零消费;补消费或停发(现状为白 emit + 双写总线开销)。
|
||||||
|
- [ ] **待处理·死变体 3**:`AiMaxRoundsReached`(`commands/ai/mod.rs:239`)/`AiSubTaskStatusChanged`(`:421`)/`AiMergeCompleted`(`:429`)——前后端零构造零消费(前端仅在 types 联合与 NO_RESET_WATCHDOG 名单留残影);清理或补 emit+消费。
|
||||||
|
- [ ] **潜在(低)**:`dashboard.ideaStatus.archived` 键缺失(值域含 archived,当前 list 恒过滤 archived → 不可达);补键或注明。
|
||||||
|
- 备注:跨端 miniapp-only 5 变体(ConversationList/MessageHistory/SkillList/ModelList/EntityList)只走 publish_event→总线→tunnel→miniapp,桌面端不监听属设计,非孤儿。
|
||||||
|
|
||||||
|
927. [ ] **V50 迁移回填的不可逆风险(2026-10-10 只读风险自查,待处置)**:`crates/df-storage/src/migrations.rs:1511-1545` `backfill_checkpoint_token_total` 直接 UPDATE 覆盖 `conversation_checkpoints.token_total` 旧值,**无备份表、无 DOWN**、函数注释自认「无回退」;虽迁移文档论证旧值(消息条数)失真无保留价值,仍属不可逆数据覆盖且无快照兜底。
|
||||||
|
- [ ] **处置(成本极低)**:回填前把 `(id, old_total → new_total)` dump 到 temp 备份文件或日志,给「后悔一次」的机会;顺带 `migrations.rs:1516-1527` 一次性 collect 全部 checkpoints(含 snapshot 全文字符串)进内存 → 改分批/游标,防大表峰值内存膨胀。
|
||||||
|
- 已核安全项:`migrate_v50`(:1483)幂等(column/table_exists 守卫 + 仅 recomputed>0 且 !=old 才 UPDATE + schema_version 闸 + 重跑单测);df-mcp 新增仅写一个 KV,无新端点/帧/权限放大。
|
||||||
|
|
||||||
|
928. [ ] **aichat 前端性能热点与测试覆盖缺口(2026-10-10 只读 Wave F,源码取证)**
|
||||||
|
- **性能(按收益排序)** — 高:①`MessageList.vue` 3041 行 God 组件(职责>6 类);②`renderItems`(:1058) 每次依赖变化全量 O(n) 重建(工具事件级触发);③**全仓零 `v-memo`**(:1498/:1715) → 流式 rAF 每帧重建整个 renderItems vnode,长会话掉帧最直接来源。中:`JSON.stringify` 指纹 watch(:429) 每次工具状态变动全量 map+stringify;每帧逐条 `renderMdDecorated`/`traceTaskRefs` 调用(:1548/1556/1672/1519/1722);`TopBar` 三处 O(n) messages computed(:619/:676/:707) 可合并单次遍历;`useAiSend.ts` 595 行可再拆 `useAiQueue`;`useTurnTrace` switch 缺 default(:59,union 非穷尽)。低:`ConversationSidebar` 分组排序、`ChatInput` status `as string` 拓宽(:601/605/606)、`MessageItem` 模板同函数重复调用(:110-122)、md 缓存「满即全清」悬崖(:699 / useMarkdown:195) → 改 LRU。
|
||||||
|
- **已核良好(非热点)**:`useAiEvents/useAiToolEvents/useAiStreamingEvents/useAiLifecycleEvents` 分派器拆分清晰(均<400 行、switch 带 default);`useStreamRenderer` 块级 memo + 增量 lexer + rAF 节流已完善。**关键澄清**:流式 delta 不写 `messages.content`(仅收尾/新轮/停止/超时 flush),故热点是「工具事件级 + 流式帧级」而非每 token。
|
||||||
|
- [ ] **测试覆盖缺口(建议补最小用例)**:结果/过程分离渲染(renderItems 无任何前端断言,仓库仅有 playwright e2e 两个 spec)、`keyword_fallback_commit` 单锁窗口(context_lifecycle.rs 无测试)、`check-i18n` 门禁自身无反引号用例、`route_conv_event`/`route_scope` 事件改挂(event_bus 测试未覆盖)、`relocate_project_path` 路径校验(`..` 拦截)无用例 —— 对「泄漏风险 × 改动复杂度」高者优先补。
|
||||||
|
|
||||||
|
929. [ ] **模型配置「停用」无效(2026-10-10 用户报,已定位待修)**:用户在设置里停用模型后仍被使用。 ✅ **2026-10-10 主根因已修**:`prompt.rs get_active_provider` 原两条路径均不看 `enabled`(显式活跃 provider 直接返回 / 默认 provider 只按 `is_default` 挑)→ 已收敛为「启用集合」解析(`resolve_enabled_provider`,无启用 provider 时明确报错);✅ **兜底路径已修**:`compress.rs`/`title.rs` 新增 `resolve_fallback_model`(纯函数,title 复用同一实现——单一实现无双轨)+ 4 单测,覆盖「默认模型被停用→回落池内任一启用模型/池内无启用→报错」。⚠️ **同类次生缺口待收口(4 处,均已定位未改)**:`commands/ai/knowledge_inject.rs:231` 与 `:1116`(embedding/extract 兜底)、`commands/project.rs:800`(scan_model 兜底)、`agentic/router_setup.rs:15-16`(`resolve_model_with_override` 的 resolved 兜底;override 本身已校验)——四处兜底均不校验 `enabled`,建议随本项一并收口。**勘察结论**:路由层**是**过滤 enabled 的(`crates/df-ai/src/router.rs:98 .filter(|m| m.enabled)` + 单测 `enabled_false_excluded_from_routing`),故病灶在别处,三处嫌疑按置信排序:①**已选中的 model_override 被无条件采纳**——`agentic/mod.rs:740 let mut resolved_model = match model_override.as_deref()` 优先用前端选中的模型,若该模型随后被停用则**不校验 enabled**,继续照用;②**兜底 `default_model` 不校验 enabled**——`compress.rs:82-83`(及 `title.rs` 同款) 在池内无可选时 `unwrap_or_else(|| provider_config.default_model.clone())`,若 default_model 正是被停用的模型 → 照用;③**provider 级池停用(`p.enabled`,ProviderPanel `onPoolToggle`)不参与路由**——路由只过滤 model.enabled,池禁用可能仅影响前端展示。修法:选中/兜底/池三条路径统一过「enabled 校验」,不可用则回落到池路由(已过滤)或明确报错。验收:停用某模型后,选择器不列出、既有 override 落到其它启用模型、压缩/标题兜底不选它。
|
||||||
|
931. [ ] **消息发送队列会丢失(2026-10-10 用户报,已定位)**:会话遇网络超时等错误后队列消失;重启同样丢。**勘察结论**:`state.queue` 是**纯内存**(`src/stores/ai.ts:136`),无持久化;错误/收尾路径会清或丢弃(`useAiSend.ts:180/256` 重生成/编辑时 drop、`aiShared.ts:773` clearQueue)。修法:①队列按会话**持久化**(KV 或独立表),入队/出队/清空即写,启动与会话加载即恢复;②**错误/超时不得清队列**(只标记该条失败可重试),重生成/编辑类**显式用户动作**才允许清;③恢复后给出「N 条待发已恢复」提示与手动清理入口。验收:断网超时后队列仍在;杀进程重启后队列仍在且可续发。
|
||||||
|
933. [ ] **输入框支持有序列表渲染(`1. ` 类飞书,2026-10-10 用户报)**:ChatInput 内输入 `1. `(或 `- `)后应有**列表预览/自动编号**效果(对齐飞书消息输入框),而非纯文本。**需设计**:触发判据(行首 `\d+. ` / `- ` / `* `)、渲染形态(实时预览块 or 仅高亮)、回车续写自动补序号、退格退出列表、与既有 `/` 技能联想、`@` 提及、代码围栏的优先级冲突处理。设计落 docs 后再实施。
|
||||||
|
935. [ ] **定时消息(多条 + 定时列表,2026-10-10 用户报)**:支持把消息设定为定时发送,可设多条,并有统一「定时列表」管理(查看/改期/取消)。**需设计**:存储(表 or KV)、调度器(前端定时器 or 后端 tokio 任务,需在应用关闭后仍生效→倾向后端)、与队列/生成中状态的关系、失败重试与通知、UI 入口(CHATIN 旁「定时」按钮 + 列表抽屉)。设计落 docs 后再实施。
|
||||||
|
939. [ ] **上下文管理瓶颈清单(2026-10-10 五维只读走查,源码取证)** — 按收益排序,⚠️ `crates/df-ai/**` 被并行写者占用(工具结果外置 WIP)→ 带 ★ 的可先在 src-tauri 侧落,其余待解禁:
|
||||||
|
- **HIGH ① 🟡 namespace 半边已修(2026-10-11,走查复核通过)**:ref 尾段携带原文字节数(?len=),估算器识别后按真实体量入账(旧 ~15 token→真实万级)。**走查五线复核**:ref 入口唯一(audit 三处全走 store_inner)/展开点=发送链(agentic/mod.rs:1968,LLM 每轮收原文,估算必须按原文)/估算无旁路(ContextManager 4 调用点全走 estimate_message)/压缩扣减闭环(compress_old_messages :687 同口径扣)/子代理同代码自动生效。**tool_offload 句柄与审计侧无需接入**(offload 发送即摘要,估算按实际发送量已准;审计侧无估算需求)——此前「待接」标注系误标已删。已知取舍:字节直入字符口径中文 3x 高估(方向安全,ASCII 代码准)。原描述::工具结果经 namespace 引用化入内存(`audit/mod.rs:1183-1190` + `namespace_store.rs:40-55` ALWAYS_LARGE_TOOLS 命中即引用,与体积无关),估算器只数字符(`context_helpers.rs:62-105`)→ 单条上万 token 的大结果只记 ~15 token;**预算/裁剪门(`context/mod.rs:185-205`)/压缩门(`context_lifecycle.rs:103-108`)三重门槛形同虚设**,每轮实际重发展开后的全量历史(223:1 结构浪费根因)。修:push/外置时按原文体量入账,或估算器识别 ref 取真实大小。
|
||||||
|
- **📋 决策记录(2026-10-11 用户令留档): namespace→offload 双轨归一方案已分析并否决,后续再consider**——提案=大结果入队即 offload 摘要+句柄(不进池无 ref 无展开),三维看似全面优(精准零偏差/无协议演化/单轨砍展开点)。**破坏性分析否决理由**:①致命=新近度豁免失效(RECENCY_EXEMPT_TOOL_RESULTS=2,末尾 2 条恒不压缩保 LLM 见刚读文件全文;offload 入队即摘要,豁免无法作用——重建延迟摘要机制=回到现状架构,复杂度反超);②落盘膨胀无界(池有 LRU 上界,offload 只增不减需新增配额+清理);③压缩链输入降级(context_lifecycle:177 展开原文喂摘要任务→变摘要的摘要);④readonly 缓存比对语义漂移(cache.rs:85 两不同原文可能同摘要误命中)。**重consider触发条件**(全部满足才重启):近度豁免有替代机制(如句柄随取随读的 agent 习惯训练/近度窗内读池不读盘)+落盘配额与清理策略设计定稿+readonly 缓存比对改原文哈希。
|
||||||
|
- **HIGH ②** 每轮 O(n) 全量深克隆 + sanitize + namespace 展开 + 摘要 + 估算(`context/mod.rs:198-205` / `agentic/mod.rs:1904-1921`,`1945-1957`,`1981-1984`)→ 会话内 O(n²) 累积(全量重发本体);治本=设计档 P2 分层组装。
|
||||||
|
- **★HIGH ③ ✅ 已修(两批)**:主会话=model_context.rs 按 resolved_model 窗口构造(b37279f,未知窗口保守回退 128K 零回归);子代理=sub_agent_context_config 继承父会话预算(10-11,同模型同窗口单一事实来源,恒 128K 缺口收口)。
|
||||||
|
- **HIGH ④(压缩)** ✅ 已实施(2026-10-11):压缩原文 JSONL 存档(`data_dir/context-archive/<conv>-archive.jsonl` 追加写)+摘要尾部召回句柄行(经 push_system_anchor 锚点内,sanitize 安全)+开关 `df-ai-compress-recall` 默认开(src-tauri 读 KV 传参,df-ai 不直读 settings);存档失败降级无句柄不影响压缩;4 新测试,585+845 双绿。原设计 C-r1「压缩不丢/可召回」落地。
|
||||||
|
- **④ 设计要点(2026-10-11 登记)→ ✅ 已按要点实施,偏离两处**:②句柄改 df-ai 内独立轻量存档(archive.rs,settings 在 src-tauri 侧 SQLite 跨 crate 不可直读,tool_offload 同因不复用);⑤磁盘治理按「只增不减」,级联清理留挂点注释。其余要点逐条落地。
|
||||||
|
- MED ⑤ ✅ 已修(2026-10-11):CJK 分档 1.2 token/字+base64 改图地板(与 url 同口径,估算对象从字符流修正为实际消耗),`weighted_char_tokens` 单点;⑥ ✅ 已修(补偿上限 300→1000,fda991c);⑦ 分层组装 P2 → **✅ 已解锁待实施**(2026-10-11 C1 拍板走 B:跨会话召回四层架构,[设计文档](./02-架构设计/专项设计/跨会话召回四层架构-设计-2026-10-11.md)——L1/L2 基建已齐,L3 只补四缺口(终态提炼/工具归属与项目排序/脉络卡/读取强化),L4 三缺口(WorkingContext 持久化/namespace 懒重建/目标快照),6 批分期 R1-R6 拍板 6 项;窗口外历史召回走 L3 工具通道铁律不进 system prompt)→ **✅ R1-R6 全部落地**(2026-10-11:R1 读取强化/R2 WorkingContext 持久化 V53/R5 namespace 懒重建先行;R3 终态提炼删除+停靠双挂点/R4 目标快照恢复续接防旧目标复活/R6 脉络卡纯读侧+带入上下文经输入框显式注入);⑧ ✅ 已修核验(2026-10-11):缓存拆分默认开(cd77c1d)+清单描述 120 字截断(truncate_desc)+消费链四调用点实通(chat.rs×3/helpers.rs×1),开关关=逐字等价旧路径;⑨ 滑动裁剪致前缀缓存整体失配+⑩ 一次压尽 → **✅ 快赢 W1-W4 已实施**(2026-10-11 拍板按推荐值,[设计文档](./02-架构设计/专项设计/压缩裁剪缓存友好化-设计-2026-10-11.md)):W1 块状裁剪(0.8×水位+轮边界吸附+病态回退,贪心路径保留为开关关回退)/W2 锚点 view 转 user(治 BP1 折叠根因)/W3 命中率观测日志(每轮 info 供调参)/W4 BP3 断点(机制就位,W2 开时自然旁路);总开关 `df-ai-cache-friendly-context` 默认开。**剩 S1 分批压缩**(C_high 0.75/C_low 0.55,待 W3 观测数据验证压缩事件频度后实施)+S2 水位再校准+S3 namespace 淘汰断(后置);⑪ ✅ 已修(内容版本守卫,context_lifecycle 提交前比对快照);⑫ ✅ 已修(reset_compressing_after_panic)。
|
||||||
|
- LOW ⑬ 估值双轨(estimated_prompt 准确用于路由 vs history_tokens 失真用于预算)从不互相校准;⑭ sys_tokens 未计工具 schema。
|
||||||
|
- **已核良好**:裁剪三元组原子性正确(`context/mod.rs:612-642`);909 两处修复(锚点避让/单锁复位)现状有效,未见旧窗口复现。
|
||||||
|
|
||||||
|
941. [x] **C-r0 验证命令后台化(2026-10-11 已实施并推送 c7d0ed9)**(原 2026-10-10 Wave A 5a 勘察)——**【10-11 实施完成】**:audit/bg_run.rs 新模块(~530 行)——开关 KV `df-ai-run-command-background`(**默认关=同步路径逐字节不变**)+`df-ai-run-command-grace-secs`(默认 90,解析失败/0 按默认);**仅主会话生效**,子代理独立循环不转后台;grace 三路竞速(Done/Expired/Aborted)+占用闸 BG_HOLDS+HoldGuard RAII+detached 自持 30s 心跳+会话删除自中止+失败记忆+完成通知;挂接 audit/mod.rs(签名升 &Arc,同步路径逐字保留仅抽 abort_when_invalid 工厂)+guard.rs(+9 行 transition 单点拦 Idle,终局放行)。自查修复真缺陷:peek_loop_exited 先读后 release(先释放吞标记致永久占用)。验证:cargo test -p devflow --lib 845 passed(含本项新 10 测)。**注**:loop 主体 agentic/mod.rs 零改动,占用语义收敛在 guard/conv_state 单点表达(比原预估更小)。
|
||||||
|
- 事实:`run_command` 是 >90s 桶的 **95%**(11.21h/11.84h,n=4250),阻塞点=`agentic/mod.rs:2235` 整轮同步 await 工具 → `audit/mod.rs:246 execute_with_heartbeat`(tokio::select! await 至工具返回)→ `tools/file.rs:1304` → `df-execute/src/shell.rs:464`(等子进程 exit)。
|
||||||
|
- **为何不能只改工具层**:轮次生命周期必须同步改写,否则态不自洽——① 心跳 task 随 `execute_with_heartbeat` 返回即 abort(`audit/mod.rs:393-408`),前端 130s 看门狗(`useAiEvents.ts:262`)靠它续命;② 后台运行期间该会话是 Generating 还是 Idle(属轮次语义,落点在 `mod.rs`+前端);③ 完成回填若为注入消息,**不得楔进 head 与其 tool result 之间**(否则复现 907 类 400);④ 给 LLM 返回「已后台」非终态 tool_result = 核心 ReAct 语义变更。
|
||||||
|
- 建议方案(待拍板):`audit/mod.rs:246` 把 tools future 交 `tokio::spawn`(`tools` 形参升 `&Arc<AiToolRegistry>`,调用点已 clone/持 Arc,签名变更对调用方零改动)+ grace 窗口 select;detached task 自持心跳/失败记忆/完成通知;完成回填复用 `emit_ai_chat_event` + `chat.rs:1133 inject_runtime_message` / `helpers.rs:60 try_continue_agent_loop`;KV 开关 `df-ai-run-command-background`(默认关)+ grace 秒可配;**loop 侧配套改动**(心跳接管/后台期 conv 态定义/收敛判定)在 `mod.rs`。
|
||||||
|
- 拍板点:① 是否引入「非终态 tool_result」这一语义(默认关可回退);② grace 秒默认值;③ 后台期间会话态展示(Generating 还是「后台执行中」)。
|
||||||
|
|
||||||
|
937. [ ] **对话内创建定时/延时任务(2026-10-10 用户报)**:在对话中直接创建「定时执行/延时执行」的任务(如「1 小时后跑 X」)。**需设计**:与既有 Tasks/工作流(advance_task、run_workflow)的关系、调度落点(与 935 共用调度器)、无人值守引擎(unattended)是否复用、UI 呈现(任务卡带定时徽标)。设计落 docs 后再实施。
|
||||||
|
|
||||||
|
943. [ ] **ai-chat UI 检查:待发送队列横幅(2026-10-10 用户截图实证+代码核验)**——位置 `AiChat.vue:79-113`(样式 965-1050):
|
||||||
|
- **UI-1 全文不可辨**(中):`.ai-queue-text`(1005-1011)单行 ellipsis 且**无 title** → 长路径+指令完全无法看全;修:补 `:title="item.text"` 或两行 clamp。
|
||||||
|
- **UI-2 视觉层级倒挂**(低):横幅 accent 底色+多操作,比主输入区还醒目;待发是次要动作,建议降噪(中性底色/收起为一条计数)。
|
||||||
|
- **UI-3 操作语义重叠**(低):「回填(108)」与「编辑(110)」并存——回填=取出放回输入框,编辑=行内编辑,功能高度重叠;建议二合一。
|
||||||
|
- **UI-4 清空无确认**(中):`clearQueue`(84)直接清空全部待发,**无二次确认**;对比「强制发送」有 confirm(`tryForceSend`,533);误点即丢 10 条。修:加 confirmDialog。
|
||||||
|
- **UI-5 无上下文预期**(低):有等待时长(queueItemWait ✓)但无「预计还要等多久/当前生成还剩多少」;信息可后置,仅登记。
|
||||||
|
|
||||||
|
945. [ ] **ai-chat UI 检查:工具卡头部截断(2026-10-10 截图实证)**——`ToolCard.vue:27/:367`:头部摘要 `resultSummary` 有 `:title` 悬停,但 `summaryTitle` **只取 `args.path`**——`run_command` 类悬停看不到命令内容,头部命令文本又单行截断(446-450) → 截图实证「$ …命令成功 (19s)」完全不可辨。修:`summaryTitle` 按工具类型取关键参数(run_command→command;无 path 工具→首参)。
|
||||||
|
|
||||||
|
947. [ ] **ai-chat UI 检查:token 统计常驻每条消息(2026-10-10 截图实证)**——每条带 reasoning 的消息都挂思考 pill,右端 token 小字(`roundTokenText`,MessageList)逐条重复「55.4k in(估算) 974 c…」且截断显示不全 → 元信息噪音化。修(方向,需拍板):① pill 悬停才显 token;② 仅最新一显,历史轮收进轨迹卡统计;③ 截断点保底(最少显全「974 out」段)。
|
||||||
|
|
||||||
|
949. [ ] **强制发送后发送按钮失态(2026-10-10 用户实测报)**:队列「强制发送」后,后端在新 loop 正常生成(内容照常流出),但发送按钮回到普通「发送」态而非「停止」态。**根因方向(已定位待实施)**:force_send 复位旧 generating 时 emit 旧任务 AiCompleted(`chat.rs` 复位分支)——前端迟到收尾仲裁(useAiLifecycleEvents:60,IPC 查后端 generating)存在 ① 事件到达 vs 新 loop 占用 generating 的竞态窗口(查到 false 即误判真收尾);② AiTextDelta 无条件累加所以内容照流、streaming 却被翻 false,内容与按钮态脱钩正好吻合现象。修(三选一或组合,实施时细查 emit 顺序):① 后端把旧终态 emit 挪到新 loop 占用 generating 之后(消竞态);② 前端 AiConvStateChanged/相关收敛路径全纳入仲裁;③ 自愈兜底:AiHeartbeat 到达时若后端 generating=true 而前端 streaming=false → 置回 true。验收:强制发送后按钮全程「停止」态直至收尾。
|
||||||
|
951. [ ] **消息聚合形式优化(2026-10-10 用户提出)**:进一步聚合消息展示粒度——当前「结果/过程分离」是一桶一张轨迹卡(每轮一张);长任务(截图 20 轮)仍会有 N 张卡。方向(待拍板):① 任务级聚合=一次发送的全程收敛为**单张过程卡**(内部分轮导航/折叠),气泡只留最终结果;② 维持每轮一卡但加强轮间视觉折叠(连续轮默认收进「+N 轮」);③ 先上已实现的「结果/过程分离」新版实测再定(可能已够)。依赖:新版换装后实测再拍板粒度。
|
||||||
|
|
||||||
|
953. [ ] **主对话 max_tokens 截断无 UI 提示 + 续写整段重发(2026-10-10 用户新版实测截图,魔芋渠道接入会话)**:模型输出 921 out 戛然而止(finish_reason=length),气泡**无任何截断标记**——用户只能问「卡了吗」;追问后模型答「上一轮被截断,接着整理完整」却**从头发一遍**,同一方案在列表里重复两大段(token 翻倍+滚动负担)。代码核验:后端有专门截断标志(stream_recv.rs:105 truncated_by_max_tokens,openai_helpers.rs:341 finish_reason=length),但仅子代理完成卡消费(AgentFinishedStatus truncated);主对话气泡零消费。修(三层):①**UI 提示**:truncated_by_max_tokens 穿透到主对话气泡——尾部渲染「⚠ 输出因长度上限截断」弱提示条(附「继续输出」按钮=注入续写指令,复用 16 纠偏注入通道);②**行为**:续写注入让模型从断点续(上下文半截已在,提示「从中断处继续,勿重复已输出内容」),防整段重发;③**配置面**:921 out 即断疑似该渠道 max_tokens 上限小或默认 max_tokens 偏小,查 model_configs 该渠道 max_tokens 配置。验收:截断气泡有可见提示;继续输出不重复已发内容。
|
||||||
|
- **③ 配置面结论(2026-10-10 核验)**:`model_configs` 全部 `max_tokens=None`(ds-flash/glm/moyu 系 8 条)→ 请求不带输出上限,断点取决于上游默认;921 out 即断=该渠道模型输出上限小,且当前配置结构**无法干预**(model_configs 不支持 max_tokens 字段)→ 能力缺口:给 ModelConfig 增 max_tokens 可选字段+请求构造透传(涉 crates/df-ai,待解禁)。另:`agentic/mod.rs:2271` 截断后已有「以上内容可能不完整;可要求继续输出」system 提示(**只给模型,UI 不可见**)——模型能答「上轮被截断」正源于此,953① 的用户可见提示仍必要。旁证:DB 中 ctx=8192 哨兵大量存在,用户实测 56.7k in 未被卡 → J2 哨兵不采信守卫生效 ✓。
|
||||||
|
|
||||||
|
955. [ ] **DSML 工具调用标记泄漏为可见文本(2026-10-10 用户实测,魔芋渠道 ds-flash)**:模型输出 `<||DSML|| invoke name="run_command">`、`<||DSML|| parameter name="command" string="true">cd E:\wk-lab\devflow; npx vue-tsc...` 等 DeepSeek 文本协议工具调用标记,整段泄漏为可见文本(工具卡不渲染、命令裸露)。**根因(全仓 grep 取证)**:Rust/前端**均无 DSML 解析代码**——DevFlow 流解析只认原生 `tool_calls` delta;魔芋渠道的 DeepSeek v3.1+ 在该渠道不走原生 function calling 而是把工具调用以 DSML 特殊标记写进 content 文本(上游行为),无人解析即泄漏;且截断(921 out)发生在标记中间时,残缺 DSML 必然泄漏。**修(三层)**:①流解析层:openai_helpers/stream_recv 检测 content 中的 DSML invoke/parameter 标记 → 解析出 name/parameters 构造 ToolCallDraft(与原生 tool_calls 同路,工具卡正常渲染+真实执行);②渲染兜底:MessageItem/Markdown 渲染前剥离未闭合 DSML 残段(防截断泄漏);③**模型/渠道规避**:该行为是 DeepSeek v3.1+ 渠道侧特性,魔宇渠道换原生 function-calling 模型或升级渠道适配可根治。验收:DSML 工具调用正常出卡并执行;截断残段不泄漏。**注意**:全仓引入 DSML 解析是新能力,实施前按「新能力引入评估纪律」走设计档。
|
||||||
|
957. [ ] **审计决策者键补齐(2026-10-10 Wave G 发现)**:审计决策者列后端还有 5 个值渲染裸 key:`auto_retry_guard`(audit/approval.rs:185)、`auto_resolve_fail`(audit/mod.rs:750)、`boundary-dirs`(audit/mod.rs:909)、`boundary_auto`(audit/mod.rs:1449/1517)、`sub_agent_denied`(audit/mod.rs:116)——auditLog.ts 的 decided 段只有 8 键,缺这 5 个。修:i18n zh/en 补 5 键(值按语义直译);其中 boundary-dirs/sub_agent_denied 语义为拒绝,不要回退到既有 auto 键。
|
||||||
|
|
||||||
|
958. [x] **飞书通道图片入站能力(2026-10-11 用户发图实测,回「暂仅支持文本」提示卡)**:用户给 DevFlow 飞书 bot 发图,只收到降级提示——**勘察结论(AI-Ops 10-11,链路+可行性已核)**:非故障,系有意兜底(feishu_channel.rs:387,#25 静默丢修复);桌面端 ai-chat 图片链路**已完整**(ChatInput 粘贴/上传 → ContentPart::Image → chat.rs:920 has_image 自动转 vision 端点,anthropic 侧 url→base64 预拉已有),缺口**仅在飞书入站**。修(四件):①`feishu_channel.rs::handle_message` 增 image 分支(现 `message_type != "text"` 一刀切回提示卡);②拉图:飞书 im/v1/messages/{message_id}/resources/{file_key}?type=image 下载(file_key 从 image 消息 content JSON 取),SDK 无现成方法则手写 GET(租户 access_token 复用既有 client);③组装:字节→base64→ContentPart::Image+可选 caption 文本拼 user 消息,走既有 spawn 会话链路(vision 路由零改动);④开放平台侧:应用补 im:resource 只读权限。**注意**:①权限申领是对外变更(飞书后台),先拍板;②大图限流(base64 膨胀,建议 >10MB 拒收提示);③文件/PDF 等其他媒体不在本条范围。验收:发图给 bot,AI 能看到图并按图回答;非图媒体仍回现有提示卡。**【10-11 AI-Ops 实施完成(用户令「直接推进」),待换装+权限开通】**:①②③已全部落地 feishu_channel.rs 单文件——类型三分流(text/image/其余提示卡,image_key 取自 SDK 归一化 msg.resources 零手解析)/`download_feishu_image`(走 channel_messaging().download_message_resource 便捷方法,与 reply/send 同风格)/魔数判 media_type(sniff_image_mime 四格式,未知拒收防端点 400)+10MB 限流/引导词 IMAGE_INBOUND_PROMPT 喂模型(飞书 image 独立类型无法带文字)/白名单后下载(未授权不烧流量)+占位发票前失败早退/注入旁路排除图片(inject 只收文本,诚实落受理守卫)。验证:cargo check -p devflow 0 error + feishu_channel 测试 23 passed(含新 image_mime_sniff_four_formats)。**待两件**:①换装生效(重启应用,等令);②飞书开放平台给应用补「获取与上传图片或文件资源」权限(im:message.resource:readonly,后台操作,未开通时下载失败会回可读错误卡)。**【10-11 追加:引用化存储改造(用户指令「下载或转文件存储引用,信息存储只存一个引用,模型使用按需转换」)】**:base64 内嵌版升级为落盘引用版——图片字节落盘 `%APPDATA%/top.1216.devflow/media/<chat_id>/<uuid>.<ext>`(save_media_file,按会话分目录,不主动 GC),消息只存 `file://` 引用(ContentPart.url,base64 空),发送请求时 df-ai 新模块 image_ref.rs 单点展开(`inline_local_image_part`:读盘→base64;缺失降级 Text「[图片文件缺失]」不炸请求)——openai_compat(map 链插入)+anthropic_compat(content_part_to_block 入口)两臂接入,provider 转换层单点覆盖全部请求路径(主会话/子代理/标题/压缩+历史重放),零契约变更(url 字段语义扩展)。桌面粘贴图 base64 内嵌同族膨胀,后续批接同一机制(登记待办)。验证:df-ai 全量 **578 passed 0 failed**(新增 image_ref 3 测+openai/anthropic file:// 端到端各 1)+devflow check 0 error+feishu_channel 23 passed。**【10-11 第二轮:媒体域全面重构完成(用户令「推」+补1-补7),全链落地】**:①media_store.rs 新模块(媒体域单一归属,防臃肿)——hash 去重落盘(sha256 前 16hex,同内容同文件)+阿里云 OSS 双写(PUT Header 签名手写,失败降级 file:// 不阻塞)+文件名=`<hash16>[_<清洗原始名>]<ext>`(u-claw safeAttachName 模式:hash 保去重+原始名保可读,路径穿越清洗);②下载防线(对齐 u-claw):3 次指数退避+120s 超时+半写残件不留盘;③spawn offload 统一收口(send+resend 两处,vision 判定后):桌面粘贴图 base64→落盘引用,SQLite 不进大 base64;④消息溯源 V52(ai_messages.source 列,MessageSource{channel,message_id,chat_id,sender}),spawn 第 11 参穿透,飞书传真实 msg_id(可跳原消息追溯,多端混发可区分);⑤出站 [交付] 回传(u-claw replyLocalFiles 同模式):报告写 [交付] 路径行→自动上传飞书(图片→图片消息/其余→文件消息)+敏感名拦截(.env/id_rsa/token 等 8 特征);⑥引用统一走 url 字段(https=OSS 跨设备可解析/file://=本机 image_ref 展开),不自造 scheme。验证:cargo check --workspace 0 error+df-storage 214+12 绿/df-ai 578 绿/devflow 832 绿(新增 media_store 4+delivery 3+V52 迁移)。**待**:换装生效+飞书后台 im:resource 权限+media-oss-config KV 手配(设置面板后续批)。
|
||||||
|
|
||||||
|
959. [ ] **占用期消息必达·收件箱续投(2026-10-11 u-claw 对照,补6)**:用户在 bot 任务占用期发的消息,devflow 现状=QUEUE_BUSY_MSG 拒绝+用户须重发;u-claw 已进化为 pending 收件箱落盘+scanner 自动续投+卡片原地刷新「已进入待投队列(无需重发)」——重试责任不在用户。修:受理守卫拒绝路径改落盘收件箱(ai_outbox 表 V51 已有,复用)+原地 PATCH 提示;空闲自动续投+结果回推。验收:任务占用期发消息不丢,任务完成自动处理并回推结果。
|
||||||
|
960. [ ] **连续消息聚合防抖「已收 N 条无补充即处理」(2026-10-11 u-claw 对照,补6)**:u-claw 连发消息聚合「📥 已收 %d 条,%ds 内无补充即开始处理」防抖后再统一处理;devflow 每条立即受理(连续消息第二条起被占用守卫拒)。修:飞书入站短窗防抖(聚合期间到达的同 chat 消息合并为一次任务,文本拼接+附件清单合并),配合 959 收件箱。**设计要点**:防抖窗 1.5~2s(per-chat 首条到达起算,窗内消息合并:文本以空行拼接+image 附件合并为多图 parts);窗口期回 ACK 卡「已收 N 条,Ns 内无补充即处理」原地更新;窗结束按 959 口径受理(空闲即 spawn/占用即收件箱)。风险位:与 per-chat 锁/注入旁路/审批决议拦截的先后序,设计先行防双头。
|
||||||
|
961. [~] **桌面轮次交互定位重定义(2026-10-11 用户拍板,补7 三条)**:①轨迹卡片收敛:除主回复气泡外交互收敛到一张「有动态+可折叠」的轨迹卡(现有轮次折叠类型),**修缺陷:正生成时卡片展开不了**;②轮次语义纠正:现「压缩后即新轮次」定位错——正确定义=**一次信息发起=一个任务轮次,中途补充信息(inject)仍属同一轮,全部完成后新发起才是新轮**;③轮次编号稳定化:现编号不稳定(刚是 5 一会下面又出现 1)——应固定为「该对话的第 1 轮/第 2 轮」持久编号(与消息 seq/轮边界锚点绑定,不随视图压缩/折叠漂移)。
|
||||||
|
- ✅ **①已修(2026-10-11)**:执行中 step 卡支持点击展开(复用 expandedTraceIds,key 与完成态 trace 同构,执行中展开→完成转 trace 态不丢;展开区实时渲染该轮过程工具卡)。
|
||||||
|
- **②③设计要点(实施前拍板)**:轮边界判定锚点从「压缩摘要」改为「用户发起消息」——发起=桌面直发/飞书入站的 user 消息,补充=inject_runtime_message 写入的消息(需来源标记,建议复用 V52 source:{channel:"inject"} 与发起区分,归属判定 O(1));轮号=conv 内第 N 条「发起消息」序(数据派生,天然稳定,不随压缩/视图漂移),轨迹卡头/导航条/进度徽标统一取该口径(单一真值来源)。涉及:轮桶分桶逻辑(MessageList genBucketItems 上游)+轮号计算函数收敛+inject 消息 source 标记。
|
||||||
|
|
||||||
|
# 产品方向定位:执行 agent 运行到远程容器(2026-10-18 用户口述,设计先行未实施)
|
||||||
|
- **定位**:DevFlow 未来形态=执行 agent 跑在远程容器(本机只做控制面/交互面),对齐 u-claw 分身实例形态——桌面端发起任务→远程容器内 agent 全权执行→结果回传。收益:长任务不占本机/环境隔离/多实例并行/24h 无人值守天然成立。
|
||||||
|
- **衔接存量**:df-tunnel/u-relay 线(v3 wire 协议+ws 隧道,已在树)正是传输底座;TaskBoundary 无人值守体系(边界内自主)是权限面底座;两者拼合即远程执行骨架。
|
||||||
|
- **待设计问题**(立项时展开):远程执行体的工具面裁剪(本机路径语义如何映射)/媒体与文件存储归属(远程落盘 vs 回传)/审批与边界在远程侧的执行点/凭据注入方式/断线重连与会话恢复/多容器池调度。
|
||||||
|
- **本条为方向登记,实施等用户令。**
|
||||||
|
- [ ] 队列带图项零标记(2026-10-18 走查残留):队列行渲染在 AiChat.vue:81-115,带图队列项无图标记;与 F3-2(ToolCardList scrollToFirstPending 预展开)同批处理。
|
||||||
|
|
||||||
|
### 💡 2026-10-18 ai-chat 上下文管理与信息组织完善(设计已定,按批领单)
|
||||||
|
> 用户令「上下文管理/信息组织开发完善+回复别太口水话」。蓝图:[02-架构设计/专项设计/ai-chat上下文管理与信息组织完善设计-2026-10-18.md](../02-架构设计/专项设计/ai-chat上下文管理与信息组织完善设计-2026-10-18.md);证据:temp/深查-aichat对话质量复测-2026-10-17.md(E系)+temp/深查-aichat信息UX-2026-10-17.md(F系)。M1 为 P1 先行,条目锚点/修法/验收全在设计文档,此处只列索引。
|
||||||
|
- [x] M1-P1① 恢复链治本:恢复消息 source 标记+[系统恢复]目标双侧过滤+防二次恢复(E1/E3;与 961② 同基建,可并 649 行旧条销账)——✅ 验收:cargo test -p devflow --lib recovery **6 passed**(zero_nesting_with_polluted_goals/all_polluted_falls_back/drops_recovery_artifacts_idempotent 等);conversation.rs:415 双侧过滤+单点拦截在位
|
||||||
|
- [x] M1-P1② message_repo 全量重写保留原 created_at(E11;污染存量 1750 条不回刷,止增量即可)——✅ 验收:cargo test -p df-storage **14 passed** 含 replace_conversation_preserves_created_at ok
|
||||||
|
- [x] M1-P1③ search_files/grep 0 命中「阴性存疑」标记转正(E6)——✅ 22:11 AI-Ops 子代理补齐:file.rs negative_hit_hint 共享函数+grep/search_files 两处接入;验收:negative_hit_hint_states_doubt_and_scope ok
|
||||||
|
- [~] M1-P1④ 跑题修复换装验证(E4;修复在树,换装后重放 6a5a3e04 seq1802 验证)——**已在工作区实施待验收**(prompt.rs list_active_with_activity 三源活跃度排序+指纹缓存对齐;验证方式:跑 dev 后重放场景)
|
||||||
|
- [ ] M2 存量目标一次性迁移/压缩降级前端透出/超时连败收敛(并 B5d)/boundary_json+loop_state 写入闭环(E5/E2/E13/P2-5)
|
||||||
|
- [ ] M3 会话 offset 分页+后端搜索/飞书审批决议词归一+终态卡/executing 桶内活性判定+审批预展开(F4-1/F5-1+2/F1-3+F3-2)
|
||||||
|
- [x] M4-a 提示词回复风格纪律(反口水话,中英双语+测试断言)——✅ 22:11 AI-Ops 子代理补齐:prompt.rs 中英两版行为准则各加「回复风格/Response style」条款(结论先行/省客套复述收尾/要点优先/没问不展开/「省的是空话不是内容」);验收:system_prompt_zh/en_has_response_style_clause 2 passed(共 5 passed)
|
||||||
|
- [ ] M4-b UX 小批(i18n 补键/token 化/aria/loaded/Esc/队列带图标记)——未动,留板
|
||||||
|
- [x] **收尾令(用户 2026-10-18 21:41 飞书下达到 AI-Ops,转登记)**:M1 销账后——①按 G3 提交本批改动并 push origin main(推送令已拍板,限本批范围);②启动 dev 模式运行验证(重点验 M4 反口水话与 M1 恢复链);③完成信息经飞书推送用户。若 AI-Ops 监测到 M1 已销账而本条未执行,AI-Ops 代执行。——✅ 23:0x AI-Ops 执行完毕:四笔分组提交(05d0f89 上下文P1批/302c4bc 飞书闭环/25ec9cf 前端令牌批/fb83339 文档)已 push(3bb662f..fb83339);验收 cargo test 2281 passed 0 failed+vue-tsc EXIT 0;dev 已跑(vite:1420 HTTP 200);完成信息已飞书推送。
|
||||||
|
|
||||||
|
### 💡 2026-10-19 上下文管理对标 u-claw·大批量并行差距(AI-Ops 分析,待领单)
|
||||||
|
> 用户战略令(10-18 飞书):DevFlow 上 Linux 替代 u-claw+CC,第一门槛=上下文管理三命题(丢记忆/历史召回/token 高效),大批量多会话并行是常态场景。详情:[02-架构设计/专项设计/上下文管理对标u-claw与大批量并行差距分析-2026-10-19.md](../02-架构设计/专项设计/上下文管理对标u-claw与大批量并行差距分析-2026-10-19.md)
|
||||||
|
- [ ] P0-A usage 真值采集:stream_options.include_usage 三口径(input/cache_read/cache_creation),6 月 P1 设计复活(当时暂缓,结构层已回退干净)
|
||||||
|
- [ ] P0-B 压缩触发改 token 水位主判据(≈窗口 70% 可配,体积兜底;u-claw 86e9216 同款,治压缩/请求比 4.8 偏频),依赖 P0-A
|
||||||
|
- [ ] P1-A 跨会话归档检索(/history 等价):消息全文搜索(FTS5 或 LIKE 起步)+检索指令+AI 检索指引进 sop
|
||||||
|
- [ ] P1-B boundary/loop_state 写入闭环(代语义显式化+换代通知推送)
|
||||||
|
- [ ] P2-A 大内容外置纪律(大结论写盘 out/+回复给路径,system prompt 处理规则)
|
||||||
|
- [ ] P2-B 并行资源治理(全局 token 预算视图/多会话压缩协防)——与 Linux 化/远程容器方向合流设计
|
||||||
|
|||||||
Generated
-3438
File diff suppressed because it is too large
Load Diff
+4
-2
@@ -1,15 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "devflow",
|
"name": "devflow",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"dev:stop": "taskkill /f /im vite.exe",
|
"dev:stop": "taskkill /f /im vite.exe",
|
||||||
"dev:restart": "npm run dev:stop && npm run dev",
|
"dev:restart": "bun run dev:stop && bun run dev",
|
||||||
"build": "vue-tsc --noEmit && vite build",
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
|
"check:i18n": "node scripts/check-i18n.cjs",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
|
"test:scenario": "playwright test tests/e2e/stream-body-visibility.spec.ts",
|
||||||
"tauri": "tauri",
|
"tauri": "tauri",
|
||||||
"build:dev": "tauri build --debug --no-bundle"
|
"build:dev": "tauri build --debug --no-bundle"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
// 看板归档器:把 docs/todo.md 中「已完成」内容分离到 docs/07-项目管理/todo归档/<date>.md。
|
||||||
|
//
|
||||||
|
// 移动规则(纯行搬运,零措辞改动):
|
||||||
|
// ① 条目块为 [x] → 自动移出(其后续非条目行为同块延续,含缩进证据/引用块);
|
||||||
|
// ② **白名单整段移出**:命令行给出的标题(用引号原样传入) → 该段连同其子段(级别更深的后续段)
|
||||||
|
// 一并移出。历史/导航/已完成段走此路——**不靠启发式猜「已完成」**(本板存在大量无勾选的
|
||||||
|
// 行内待办,启发式会误移)。
|
||||||
|
// ③ 移出后残留的空标题(段内仅空行)自动删除,防悬空标题。
|
||||||
|
//
|
||||||
|
// 安全闸:未完成项([ ]/[~]/[→]/[!])零丢失断言失败即中止不写;默认 dry-run。
|
||||||
|
//
|
||||||
|
// 用法:
|
||||||
|
// node scripts/archive-todo.cjs # dry-run
|
||||||
|
// node scripts/archive-todo.cjs "## 🎯 统一规划(2026-06-26 全景)" # dry-run + 试移该段
|
||||||
|
// node scripts/archive-todo.cjs --apply "## 交接状态(2026-06-14)" # 实际写入
|
||||||
|
// node scripts/archive-todo.cjs --apply --date 2026-10-11 ...
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const argv = process.argv.slice(2);
|
||||||
|
const APPLY = argv.includes('--apply');
|
||||||
|
const di = argv.indexOf('--date');
|
||||||
|
const DATE = di >= 0 ? argv[di + 1] : new Date().toISOString().slice(0, 10);
|
||||||
|
const li = argv.indexOf('--list');
|
||||||
|
// 白名单来源:① 命令行位置参数(注意 Windows Git Bash 下中文会被编码破坏,慎用)
|
||||||
|
// ② --list <file>:UTF-8 文件,每行一个精确标题(推荐,避免编码问题)
|
||||||
|
const WHITELIST =
|
||||||
|
li >= 0
|
||||||
|
? fs.readFileSync(argv[li + 1], 'utf8').split('\n').map((s) => s.trim()).filter(Boolean)
|
||||||
|
: argv.filter((a, i) => !a.startsWith('--') && i !== di + 1 && i !== li + 1);
|
||||||
|
|
||||||
|
const SRC = 'docs/todo.md';
|
||||||
|
const ARC = `docs/07-项目管理/todo归档/${DATE}.md`;
|
||||||
|
const UNDONE = /^\s*- \[[ ~→!]\]|^\d+\. \[[ ~→!]\]/;
|
||||||
|
const DONE = /^\s*- \[x\]|^\d+\. \[x\]/i;
|
||||||
|
const BLOCK = /^(\s*- \[|\d+\. \[)/;
|
||||||
|
const HEAD = /^#{1,3} /;
|
||||||
|
const level = (h) => (h.match(/^#+/) || [''])[0].length;
|
||||||
|
|
||||||
|
// 行尾归一:工作区可能是 CRLF,按 \r?\n 切(否则标题比对带 \r 恒不等)。写回用 LF,git 提交时归一。
|
||||||
|
const lines = fs.readFileSync(SRC, 'utf8').split(/\r?\n/);
|
||||||
|
|
||||||
|
// 打标:``` 围栏内一律 text(防 mermaid/示例里的 ## 误导分段)
|
||||||
|
const kinds = [];
|
||||||
|
let inFence = false;
|
||||||
|
for (const ln of lines) {
|
||||||
|
if (ln.trim().startsWith('```')) { kinds.push('text'); inFence = !inFence; continue; }
|
||||||
|
kinds.push(inFence ? 'text' : HEAD.test(ln) ? 'head' : BLOCK.test(ln) ? 'block' : 'text');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分段(首个 head 前为 preamble,永留主板)
|
||||||
|
const segs = [];
|
||||||
|
let cur = { head: null, headLevel: 0, items: [] };
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
if (kinds[i] === 'head') { segs.push(cur); cur = { head: lines[i], headLevel: level(lines[i]), items: [] }; }
|
||||||
|
else cur.items.push(lines[i]);
|
||||||
|
}
|
||||||
|
segs.push(cur);
|
||||||
|
const preamble = segs[0].head === null ? segs.shift() : null;
|
||||||
|
|
||||||
|
// 块归并(块 = 条目起始行 + 其后非条目行)
|
||||||
|
function blocksOf(items) {
|
||||||
|
const out = [];
|
||||||
|
let curB = null;
|
||||||
|
for (const ln of items) {
|
||||||
|
if (BLOCK.test(ln)) {
|
||||||
|
if (curB) out.push(curB);
|
||||||
|
curB = { status: DONE.test(ln) ? 'done' : UNDONE.test(ln) ? 'undone' : 'other', lines: [ln] };
|
||||||
|
} else if (curB) curB.lines.push(ln);
|
||||||
|
else out.push({ status: 'text', lines: [ln] });
|
||||||
|
}
|
||||||
|
if (curB) out.push(curB);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const moved = [];
|
||||||
|
const kept = [];
|
||||||
|
if (preamble) kept.push(...preamble.items);
|
||||||
|
|
||||||
|
let treeLevel = null; // 白名单段的级别:更深的后续段随其移出
|
||||||
|
let movedSegs = 0, movedBlocks = 0;
|
||||||
|
for (const seg of segs) {
|
||||||
|
const whitelisted = seg.head !== null && WHITELIST.includes(seg.head);
|
||||||
|
const inTree = treeLevel !== null && seg.head !== null && seg.headLevel > treeLevel;
|
||||||
|
if (!seg.head) { /* 不可能:非 preamble 段必有 head */ }
|
||||||
|
if (whitelisted || inTree) {
|
||||||
|
if (whitelisted) treeLevel = seg.headLevel;
|
||||||
|
movedSegs++;
|
||||||
|
if (seg.head) moved.push(seg.head);
|
||||||
|
for (const b of blocksOf(seg.items)) moved.push(...b.lines);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
treeLevel = null;
|
||||||
|
if (seg.head) kept.push(seg.head);
|
||||||
|
for (const b of blocksOf(seg.items)) {
|
||||||
|
if (b.status === 'done') { movedBlocks++; moved.push(...b.lines); }
|
||||||
|
else kept.push(...b.lines);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删残留空标题:仅当「段内只剩空行」且「下一个标题级别 ≤ 当前」才删——
|
||||||
|
// 若下一个标题更深,说明它是**容器标题**(如 `## 待办` 下挂 `### 子节`),必须保留。
|
||||||
|
const cleaned = [];
|
||||||
|
for (let i = 0; i < kept.length; i++) {
|
||||||
|
if (HEAD.test(kept[i])) {
|
||||||
|
let j = i + 1;
|
||||||
|
while (j < kept.length && kept[j].trim() === '') j++;
|
||||||
|
const nextIsDeeper = j < kept.length && HEAD.test(kept[j]) && level(kept[j]) > level(kept[i]);
|
||||||
|
if (j >= kept.length || (HEAD.test(kept[j]) && !nextIsDeeper)) continue;
|
||||||
|
}
|
||||||
|
cleaned.push(kept[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 零丢失断言
|
||||||
|
const origUndone = lines.filter((l) => UNDONE.test(l));
|
||||||
|
const newUndone = new Set(cleaned.filter((l) => UNDONE.test(l)));
|
||||||
|
const lost = origUndone.filter((l) => !newUndone.has(l));
|
||||||
|
|
||||||
|
console.log(`[archive-todo] ${APPLY ? 'APPLY' : 'DRY-RUN'} date=${DATE} whitelist=${WHITELIST.length}`);
|
||||||
|
console.log(` 整段移出 ${movedSegs} 段,条目块移出 ${movedBlocks} 个,移出 ${moved.length} 行,主板 ${cleaned.length} 行(原 ${lines.length})`);
|
||||||
|
console.log(` 未完成项 原 ${origUndone.length} → 主板 ${newUndone.size},丢失 ${lost.length}`);
|
||||||
|
if (lost.length) {
|
||||||
|
console.error('✗ 零丢失断言失败,不写入:');
|
||||||
|
for (const l of lost.slice(0, 10)) console.error(' ' + l.slice(0, 100));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log(' 移出段标题:');
|
||||||
|
for (const l of moved.filter((x) => HEAD.test(x))) console.log(' - ' + l.slice(0, 90));
|
||||||
|
|
||||||
|
if (!APPLY) { console.log('(dry-run,加 --apply 写入)'); process.exit(0); }
|
||||||
|
if (moved.length === 0) { console.log('无内容可移,跳过写入'); process.exit(0); }
|
||||||
|
|
||||||
|
let arc = fs.existsSync(ARC) ? fs.readFileSync(ARC, 'utf8').trimEnd() : `# todo 归档(${DATE})`;
|
||||||
|
fs.writeFileSync(ARC, `${arc}\n\n---\n\n## ${DATE} 分拆(已完成内容再分离)\n\n${moved.join('\n')}\n`, 'utf8');
|
||||||
|
fs.writeFileSync(SRC, cleaned.join('\n'), 'utf8');
|
||||||
|
console.log(`[archive-todo] 已写入 ${SRC} 与 ${ARC}`);
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
// vue-i18n 消息预检:揪出只在 **prod runtime** 抛错的未转义特殊字符(字面 @ / | / 裸 {})。
|
||||||
|
//
|
||||||
|
// 背景:vue-i18n 9 把 message 里的字面 `@` 当 linked-message 语法、`|` 当 plural 分隔符,
|
||||||
|
// 未转义会编译报 "Invalid linked format"。该错误 dev 宽松回退、`vue-tsc` 捕获不到(message 是
|
||||||
|
// 字符串值非类型),只在打包装出的应用里炸——已两次踩坑(2026-06-15 白屏 / 2026-10-10 输入框消失),
|
||||||
|
// 且两次静态检查全绿。故前置到提交门禁 + 可手动跑:`node scripts/check-i18n.cjs`
|
||||||
|
//
|
||||||
|
// 修法:字面量用 vue-i18n 的插值转义,如 `{'@'}`(渲染仍显示 @)。
|
||||||
|
//
|
||||||
|
// 依赖 @intlify/message-compiler(vue-i18n 的编译器);解析不到时只告警不拦截(避免门禁误伤)。
|
||||||
|
//
|
||||||
|
// 扫描/编译逻辑抽为可导出函数(scanText / scanDirectory / compileMessage),供
|
||||||
|
// scripts/check-i18n.test.cjs 自测;CLI 入口行为保持不变。
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
let baseCompile = null;
|
||||||
|
try {
|
||||||
|
({ baseCompile } = require('@intlify/message-compiler'));
|
||||||
|
} catch {
|
||||||
|
baseCompile = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DIRS = ['src/i18n/zh-CN', 'src/i18n/en'];
|
||||||
|
// key: 'value' / "value" / `value`(带转义),三种字面量都取——
|
||||||
|
// 反引号模板字面量同样可能含未转义 @/|/裸{},原先只匹配引号 → 门禁假阴性(2026-10-10 对抗审查发现)。
|
||||||
|
const KV_RE = /(?:^|[\s,{])([A-Za-z0-9_$]+)\s*:\s*([`'"])((?:\\.|(?!\2).)*)\2/gm;
|
||||||
|
|
||||||
|
// 编译单个消息值,返回错误数组(@intlify 风格:{ message })
|
||||||
|
function compileMessage(val) {
|
||||||
|
const errs = [];
|
||||||
|
try {
|
||||||
|
baseCompile(val, { onError: (e) => errs.push(e), onWarn: () => {} });
|
||||||
|
} catch (e) {
|
||||||
|
errs.push(e);
|
||||||
|
}
|
||||||
|
return errs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 扫描一段源码文本,返回 { checked, violations }
|
||||||
|
// - checked: 含 vue-i18n 特殊字符(@ { |)并已送编译的值个数
|
||||||
|
// - violations: 编译报错的条目 [{ key, value, message }]
|
||||||
|
function scanText(txt) {
|
||||||
|
const violations = [];
|
||||||
|
let checked = 0;
|
||||||
|
KV_RE.lastIndex = 0;
|
||||||
|
let m;
|
||||||
|
while ((m = KV_RE.exec(txt))) {
|
||||||
|
const key = m[1];
|
||||||
|
// 模板字面量里的 ${...} 插值对 vue-i18n 无意义,编译前替换为占位符避免假报错
|
||||||
|
const val = m[2] === '`' ? m[3].replace(/\$\{[^}]*\}/g, 'X') : m[3];
|
||||||
|
// 只编译含 vue-i18n 特殊字符的值(@ { |),其余不可能因语法报错
|
||||||
|
if (!/[@{|]/.test(val)) continue;
|
||||||
|
checked++;
|
||||||
|
const errs = compileMessage(val);
|
||||||
|
if (errs.length) violations.push({ key, value: val, message: errs[0].message });
|
||||||
|
}
|
||||||
|
return { checked, violations };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 扫描目录下所有 .ts,返回 { checked, violations }(violations 带 file 字段)
|
||||||
|
function scanDirectory(dir) {
|
||||||
|
const violations = [];
|
||||||
|
let checked = 0;
|
||||||
|
let files;
|
||||||
|
try {
|
||||||
|
files = fs.readdirSync(dir);
|
||||||
|
} catch {
|
||||||
|
return { checked, violations };
|
||||||
|
}
|
||||||
|
for (const f of files) {
|
||||||
|
if (!f.endsWith('.ts')) continue;
|
||||||
|
const res = scanText(fs.readFileSync(path.join(dir, f), 'utf8'));
|
||||||
|
checked += res.checked;
|
||||||
|
for (const v of res.violations) violations.push({ file: f, ...v });
|
||||||
|
}
|
||||||
|
return { checked, violations };
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLI 主流程:返回进程退出码(0 通过 / 1 失败)
|
||||||
|
function run() {
|
||||||
|
if (!baseCompile) {
|
||||||
|
console.warn('[i18n-check] 未找到 @intlify/message-compiler,跳过消息编译预检(需 npm 依赖)');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let checked = 0;
|
||||||
|
let failed = 0;
|
||||||
|
for (const dir of DIRS) {
|
||||||
|
const res = scanDirectory(dir);
|
||||||
|
checked += res.checked;
|
||||||
|
for (const v of res.violations) {
|
||||||
|
failed++;
|
||||||
|
console.error(`✗ ${dir}/${v.file} :: ${v.key} => ${v.message}`);
|
||||||
|
console.error(` 值: ${JSON.stringify(v.value)}`);
|
||||||
|
console.error(` 提示: 字面 @ 须转义为 {'@'}(同理 | 与裸 {})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failed > 0) {
|
||||||
|
console.error(`[i18n-check] 失败 ${failed} 处(共编译 ${checked} 个含特殊字符的值)——prod 运行时会抛 Invalid linked format`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
console.log(`[i18n-check] 通过(编译 ${checked} 个含特殊字符的值,0 失败)`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
process.exit(run());
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { compileMessage, scanText, scanDirectory, run, KV_RE, DIRS };
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// check-i18n.cjs 的门禁自测:用 node 内置测试跑,不引第三方依赖。
|
||||||
|
// node --test scripts/check-i18n.test.cjs
|
||||||
|
//
|
||||||
|
// 覆盖(对应 2026-10-10 对抗审查发现的假阴性 + 常规门禁行为):
|
||||||
|
// ① 单引号值含裸 @ → 报错
|
||||||
|
// ② 双引号值含裸 @ → 报错
|
||||||
|
// ③ 反引号模板字面量含裸 @ → 报错(原实现只匹配引号,漏报)
|
||||||
|
// ④ 含 ${} 插值不误报(模板字面量里的插值需先占位再编译)
|
||||||
|
// ⑤ 转义写法 {'@'} → 通过
|
||||||
|
// ⑥ 含 | 与裸 {} 的用例
|
||||||
|
// 另附 scanDirectory 对临时目录样本的端到端断言。
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { scanText, scanDirectory } = require('./check-i18n.cjs');
|
||||||
|
|
||||||
|
let compilerAvailable = true;
|
||||||
|
try {
|
||||||
|
require('@intlify/message-compiler');
|
||||||
|
} catch {
|
||||||
|
compilerAvailable = false;
|
||||||
|
}
|
||||||
|
const opts = compilerAvailable ? {} : { skip: '@intlify/message-compiler 不可用,跳过编译相关用例' };
|
||||||
|
|
||||||
|
// 构造一份 TS 源样本:每行一个 `key: '<literal>',`
|
||||||
|
const src = (...lines) => `export default {\n${lines.map((l) => ` ${l},`).join('\n')}\n}\n`;
|
||||||
|
|
||||||
|
test('①单引号值含裸 @ → 报错', opts, () => {
|
||||||
|
const r = scanText(src("a: 'hello @ world'"));
|
||||||
|
assert.equal(r.checked, 1);
|
||||||
|
assert.equal(r.violations.length, 1);
|
||||||
|
assert.equal(r.violations[0].key, 'a');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('②双引号值含裸 @ → 报错', opts, () => {
|
||||||
|
const r = scanText(src('b: "hello @ world"'));
|
||||||
|
assert.equal(r.checked, 1);
|
||||||
|
assert.equal(r.violations.length, 1);
|
||||||
|
assert.equal(r.violations[0].key, 'b');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('③反引号模板字面量含裸 @ → 报错(假阴性回归)', opts, () => {
|
||||||
|
const r = scanText(src('c: `hello @ world`'));
|
||||||
|
assert.equal(r.checked, 1);
|
||||||
|
assert.equal(r.violations.length, 1);
|
||||||
|
assert.equal(r.violations[0].key, 'c');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('④-1 模板字面量含 ${} 插值(无特殊字符)不误报', opts, () => {
|
||||||
|
const r = scanText(src('d: `hello ${name}`'));
|
||||||
|
// 插值被占位后不含 @ { |,不计入 checked,也无违规
|
||||||
|
assert.equal(r.violations.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('④-2 插值位于花括号内不误报(占位替代的关键场景)', opts, () => {
|
||||||
|
// 原值 `total {${count}}` 直接编译会报 Invalid token in placeholder: '$',
|
||||||
|
// 必须先替换 ${...} 为占位符成为 `total {X}`(合法具名插值)才不误报。
|
||||||
|
const r = scanText(src('e: `total {${count}}`'));
|
||||||
|
assert.equal(r.violations.length, 0);
|
||||||
|
assert.equal(r.checked, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('⑤转义写法 {\'@\'} → 通过', opts, () => {
|
||||||
|
const r = scanText(src("f: \"{'@'}\""));
|
||||||
|
assert.equal(r.checked, 1);
|
||||||
|
assert.equal(r.violations.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('⑥-1 值含 | 会进入编译且正常通过', opts, () => {
|
||||||
|
const r = scanText(src("g: 'one | two'"));
|
||||||
|
assert.equal(r.checked, 1);
|
||||||
|
assert.equal(r.violations.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('⑥-2 值以 | 结尾(plural 缺省) → 报错', opts, () => {
|
||||||
|
const r = scanText(src("h: 'one |'"));
|
||||||
|
assert.equal(r.checked, 1);
|
||||||
|
assert.equal(r.violations.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('⑥-3 裸 { 未闭合 → 报错', opts, () => {
|
||||||
|
const r = scanText(src("i: 'x { y'"));
|
||||||
|
assert.equal(r.checked, 1);
|
||||||
|
assert.equal(r.violations.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scanDirectory 扫临时目录样本:定位到违规文件', opts, () => {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'i18n-check-'));
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(path.join(dir, 'ok.ts'), src("ok: 'plain text'"));
|
||||||
|
fs.writeFileSync(path.join(dir, 'bad.ts'), src('bad: `oops @ here`'));
|
||||||
|
const r = scanDirectory(dir);
|
||||||
|
assert.equal(r.violations.length, 1);
|
||||||
|
assert.equal(r.violations[0].file, 'bad.ts');
|
||||||
|
assert.equal(r.violations[0].key, 'bad');
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# 探测本机正在运行的 DevFlow 进程及其版本(只读)
|
||||||
|
$p = Get-Process devflow -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Path
|
||||||
|
if ($p) {
|
||||||
|
$v = (Get-Item $p).VersionInfo
|
||||||
|
Write-Host "PATH=$p"
|
||||||
|
Write-Host "FileVersion=$($v.FileVersion)"
|
||||||
|
Write-Host "ProductVersion=$($v.ProductVersion)"
|
||||||
|
} else {
|
||||||
|
Write-Host "NO_DEVFLOW_PROCESS"
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# 校验 updater 验签密钥配对(终版): 比对 keyid(KEYNUM 8字节) —— minisign 格式对齐
|
||||||
|
# conf.pubkey blob(42B): SIGALG"Ed"[0..2] + KEYNUM[2..10] + PUBKEY[10..42]
|
||||||
|
# 私钥文件 blob(116B): SIGALG"Rs"[0..2] + KDFALG[2..4] + CKSUM[4..12] + SALT[12..28] + OPS[28..36] + MEM[36..44] + KEYNUM[44..52] + SIG[52..116]
|
||||||
|
$confPath = "E:\wk-lab\devflow\src-tauri\tauri.conf.json"
|
||||||
|
$keyPath = Join-Path $env:USERPROFILE ".tauri\devflow.key"
|
||||||
|
|
||||||
|
# 1. conf 侧: 提取 pubkey → 解码 → 取 KEYNUM
|
||||||
|
$raw = Get-Content $confPath -Raw
|
||||||
|
if ($raw -match '"pubkey"\s*:\s*"([A-Za-z0-9+/=]{40,400})"') { $confPubB64 = $Matches[1] } else { Write-Host "CONF_PUBKEY_NOT_FOUND"; exit 1 }
|
||||||
|
# conf.pubkey 是 base64(minisign .pub 文件文本),需二次解码取末行
|
||||||
|
$confText = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($confPubB64))
|
||||||
|
$pubLines = @($confText -split "`n" | Where-Object { $_.Trim() -and $_ -notmatch 'comment' } | ForEach-Object { $_.Trim() })
|
||||||
|
if ($pubLines.Count -lt 1) { Write-Host "CONF_PUB_FORMAT_UNEXPECTED"; exit 1 }
|
||||||
|
$confBlob = [Convert]::FromBase64String($pubLines[0])
|
||||||
|
$confKeynum = ($confBlob[2..9] | ForEach-Object { $_.ToString("x2") }) -join ""
|
||||||
|
Write-Host "conf keyid : $confKeynum (blob $($confBlob.Length)B)"
|
||||||
|
|
||||||
|
# 2. key 侧: 私钥文件 = 注释行 + 1 条 b64 blob(KEYNUM 未加密,明文可读)
|
||||||
|
if (-not (Test-Path $keyPath)) { Write-Host "KEY_NOT_FOUND: $keyPath"; exit 1 }
|
||||||
|
$b64Lines = @(Get-Content $keyPath | Where-Object { $_ -and $_ -notmatch 'comment' } | ForEach-Object { $_.Trim() })
|
||||||
|
if ($b64Lines.Count -lt 1) { Write-Host "KEY_FORMAT_UNEXPECTED"; exit 1 }
|
||||||
|
$secBlob = [Convert]::FromBase64String($b64Lines[0])
|
||||||
|
$secKeynum = ($secBlob[44..51] | ForEach-Object { $_.ToString("x2") }) -join ""
|
||||||
|
Write-Host "key keyid : $secKeynum (blob $($secBlob.Length)B)"
|
||||||
|
|
||||||
|
# 3. 配对判定
|
||||||
|
if ($confKeynum -eq $secKeynum) {
|
||||||
|
Write-Host "MATCH - updater 验签密钥配对正确(keyid $confKeynum), B3 安装验签可通过"
|
||||||
|
exit 0
|
||||||
|
} else {
|
||||||
|
Write-Host "MISMATCH - tauri.conf.json 的 pubkey 与签名私钥不配对, B3 安装验签将失败!"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# 发布收尾:杀挂起脚本 -> 手动签名 0.1.3 -> latest.json -> 上传 -> 远端验证
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$repo = "E:\wk-lab\devflow"
|
||||||
|
$nsis = "$repo\target\release\bundle\nsis"
|
||||||
|
$exe = "$nsis\devflow_0.1.3_x64-setup.exe"
|
||||||
|
$key = "$env:USERPROFILE\.tauri\devflow.key"
|
||||||
|
$site = "suke-dev"
|
||||||
|
$remoteDir = "/var/www/app-site/download/devflow"
|
||||||
|
$baseUrl = "https://app.1216.top/download/devflow"
|
||||||
|
$notes = "版本更新与自动升级全链路上线:设置页新增「关于」,支持应用内检查/下载/安装更新"
|
||||||
|
|
||||||
|
# 1. 杀挂起的发布脚本与构建残留
|
||||||
|
Get-Process -Id 14328,2008,26208,28276,19412 -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
|
||||||
|
Write-Host "[1/5] 挂起进程已清理"
|
||||||
|
|
||||||
|
# 2. 手动签名(key 文件内容经 env 传入——-k/ev 传路径会被当 base64 解码,报 Invalid symbol 58)
|
||||||
|
Set-Location $repo
|
||||||
|
$env:TAURI_SIGNING_PRIVATE_KEY = (Get-Content $key -Raw).Trim()
|
||||||
|
$env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD = ""
|
||||||
|
cmd /c "cargo tauri signer sign -p `"`" --app-version 0.1.3 `"$exe`" < NUL 2>&1"
|
||||||
|
if ($LASTEXITCODE -ne 0) { Write-Host "[失败] 签名"; exit 1 }
|
||||||
|
$sigPath = "$exe.sig"
|
||||||
|
if (-not (Test-Path $sigPath)) { Write-Host "[失败] .sig 未生成"; exit 1 }
|
||||||
|
Write-Host "[2/5] 签名完成: $sigPath"
|
||||||
|
|
||||||
|
# 3. 生成 latest.json(UTF-8 无 BOM,防 PS5.1 BOM 坑)
|
||||||
|
$sig = (Get-Content $sigPath -Raw).Trim()
|
||||||
|
$pubDate = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
|
||||||
|
$latestJson = [ordered]@{
|
||||||
|
version = "0.1.3"
|
||||||
|
notes = $notes
|
||||||
|
pub_date = $pubDate
|
||||||
|
platforms = [ordered]@{
|
||||||
|
"windows-x86_64" = [ordered]@{
|
||||||
|
signature = $sig
|
||||||
|
url = "$baseUrl/devflow_0.1.3_x64-setup.exe"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} | ConvertTo-Json -Depth 5
|
||||||
|
$latestPath = "$nsis\latest.json"
|
||||||
|
[System.IO.File]::WriteAllText($latestPath, $latestJson, (New-Object System.Text.UTF8Encoding($false)))
|
||||||
|
Write-Host "[3/5] latest.json 已生成 (version=0.1.3)"
|
||||||
|
|
||||||
|
# 4. 上传三件套
|
||||||
|
$env:MSYS_NO_PATHCONV = "1"
|
||||||
|
ssh-proxy exec -n $site -c "mkdir -p $remoteDir"
|
||||||
|
foreach ($f in @($exe, $sigPath, $latestPath)) {
|
||||||
|
$name = Split-Path $f -Leaf
|
||||||
|
Write-Host "[4/5] 上传 $name ..."
|
||||||
|
ssh-proxy upload --name $site --src $f --dst "$remoteDir/$name"
|
||||||
|
if ($LASTEXITCODE -ne 0) { Write-Host "[失败] 上传 $name"; exit 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
# 5. 远端验证
|
||||||
|
Write-Host "[5/5] 远端清单验证:"
|
||||||
|
ssh-proxy exec -n $site -c "ls -la $remoteDir | grep 0.1.3; head -c 200 $remoteDir/latest.json"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "[发布完成] v0.1.3 清单地址: $baseUrl/latest.json"
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# i18n 消息特殊字符门禁(机制优先):暂存区含 src/i18n/ 改动时,跑 vue-i18n 消息编译预检。
|
||||||
|
# 背景:字面 @ / | / 裸 {} 未转义只在 prod runtime 抛 "Invalid linked format",dev 宽松、
|
||||||
|
# vue-tsc 捕获不到,已两次导致生产回归(2026-06-15 白屏 / 2026-10-10 输入框消失)。
|
||||||
|
# 修法:字面量转义为 {'@'} 等;详见 scripts/check-i18n.cjs
|
||||||
|
git diff --cached --name-only | grep -q '^src/i18n/' || exit 0
|
||||||
|
command -v node >/dev/null 2>&1 || exit 0
|
||||||
|
node scripts/check-i18n.cjs || {
|
||||||
|
echo "✗ [i18n] 消息含未转义特殊字符(prod 运行时会报 Invalid linked format)"
|
||||||
|
echo " 字面 @ 改写为 {'@'}(同理 | 与裸 {});本机可先跑 node scripts/check-i18n.cjs"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# 根目录卫生门禁(2026-10-10 根治):根目录只放项目元文件,**新增**根文件不在白名单即拒。
|
||||||
|
# 背景:日志/一次性脚本/临时产物反复堆到仓库根,已两次人工清理(2026-10-10)——治本靠门禁而非再清一遍。
|
||||||
|
# 约定:日志与临时产物入 temp/(已 gitignore);脚本入 scripts/;文档入 docs/。
|
||||||
|
added=$(git diff --cached --name-only --diff-filter=A)
|
||||||
|
[ -z "$added" ] && exit 0
|
||||||
|
bad=$(printf '%s\n' "$added" | awk -F/ 'NF==1' | grep -vE '^(ARCHITECTURE\.md|CLAUDE\.md|README\.md|Cargo\.toml|Cargo\.lock|bun\.lock|index\.html|package\.json|playwright\.config\.ts|tsconfig\.json|tsconfig\.node\.json|vite\.config\.ts|\.gitignore|\.gitattributes|\.dockerignore)$')
|
||||||
|
if [ -n "$bad" ]; then
|
||||||
|
echo "✗ [根目录卫生] 新增根文件不在白名单: $(printf '%s ' $bad)"
|
||||||
|
echo " 路径约定:日志/临时产物→temp/(已 gitignore);脚本→scripts/;文档→docs/;确属项目元文件才放根"
|
||||||
|
echo " 如确需新增根文件,请同步更新本规则白名单(scripts/hooks/rules/root-hygiene.sh)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# 后台启动 publish-release.ps1(tauri build 10 分钟级,run_command 600s 上限装不下)
|
||||||
|
# 用法: run_command 执行本文件 -> 立即返回 pid -> 之后轮询 release-out.log
|
||||||
|
$repo = 'E:\wk-lab\devflow'
|
||||||
|
$log = "$repo\target\release-out.log"
|
||||||
|
$errl = "$repo\target\release-err.log"
|
||||||
|
|
||||||
|
# 清掉上次 600s 超时可能残留的构建进程,防 cargo 构建锁互等
|
||||||
|
$killed = Get-Process cargo,cargo-tauri,rustc -ErrorAction SilentlyContinue
|
||||||
|
if ($killed) {
|
||||||
|
$killed | Stop-Process -Force
|
||||||
|
Write-Host ("清理残留构建进程: " + (($killed | Select-Object -ExpandProperty Id) -join ','))
|
||||||
|
} else {
|
||||||
|
Write-Host "无残留构建进程"
|
||||||
|
}
|
||||||
|
|
||||||
|
Remove-Item $log,$errl -ErrorAction SilentlyContinue
|
||||||
|
$p = Start-Process -FilePath 'powershell.exe' `
|
||||||
|
-ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File',"$repo\scripts\publish-release.ps1") `
|
||||||
|
-WorkingDirectory $repo `
|
||||||
|
-NoNewWindow `
|
||||||
|
-RedirectStandardOutput $log `
|
||||||
|
-RedirectStandardError $errl `
|
||||||
|
-PassThru
|
||||||
|
Set-Content -Path "$repo\target\release-pid.txt" -Value $p.Id -Encoding ascii
|
||||||
|
Write-Host "release started, pid=$($p.Id), log=$log"
|
||||||
@@ -36,7 +36,17 @@ if ($Mode -eq "verify") {
|
|||||||
|
|
||||||
Write-Host "== 通道2: 全量发版换装(tauri build -> 静默安装 -> 启动验证) =="
|
Write-Host "== 通道2: 全量发版换装(tauri build -> 静默安装 -> 启动验证) =="
|
||||||
Write-Host "[1/4] tauri build(可能 10 分钟级)..."
|
Write-Host "[1/4] tauri build(可能 10 分钟级)..."
|
||||||
cmd /c "cargo tauri build 2>&1"
|
# 本地换装不需要 updater 签名产物:未设私钥时用 --config 关掉 createUpdaterArtifacts,
|
||||||
|
# 否则 tauri build 必因「A public key has been found, but no private key」报错中断(实测多次),
|
||||||
|
# 安装段被跳过,只能手工补 swap-install。发版(需签名)走 publish-release.ps1。
|
||||||
|
$tauriArgs = "cargo tauri build"
|
||||||
|
if (-not $env:TAURI_SIGNING_PRIVATE_KEY) {
|
||||||
|
Write-Host " [本地换装模式] 未设 TAURI_SIGNING_PRIVATE_KEY -> 关闭 updater 产物(发版走 publish-release.ps1)"
|
||||||
|
$override = "$repo\temp\local-no-updater.json"
|
||||||
|
@{ bundle = @{ createUpdaterArtifacts = $false } } | ConvertTo-Json | Set-Content $override -Encoding utf8
|
||||||
|
$tauriArgs = "cargo tauri build --config `"$override`""
|
||||||
|
}
|
||||||
|
cmd /c "$tauriArgs 2>&1"
|
||||||
if ($LASTEXITCODE -ne 0) { Write-Host "[失败] tauri build"; exit 1 }
|
if ($LASTEXITCODE -ne 0) { Write-Host "[失败] tauri build"; exit 1 }
|
||||||
|
|
||||||
$bundle = Get-ChildItem "$repo\target\release\bundle\nsis\*.exe" | Sort-Object LastWriteTime -Descending | Select-Object -First 1
|
$bundle = Get-ChildItem "$repo\target\release\bundle\nsis\*.exe" | Sort-Object LastWriteTime -Descending | Select-Object -First 1
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
# 用途: DevFlow 应用内升级发布通道(B4/L3)
|
||||||
|
# 构建 updater 产物(.nsis.zip + .sig) -> 生成 latest.json -> 上传 app.1216.top
|
||||||
|
# 设计: docs/02-架构设计/专项设计/版本更新与自动升级-设计-2026-10-09.md §四 L3
|
||||||
|
#
|
||||||
|
# 用法:
|
||||||
|
# .\scripts\publish-release.ps1 # 用 git 最近提交作 notes
|
||||||
|
# .\scripts\publish-release.ps1 -Notes "修复xx" # 手动指定更新说明
|
||||||
|
# .\scripts\publish-release.ps1 -SkipBuild # 复用已有产物(仅生成清单+上传)
|
||||||
|
#
|
||||||
|
# 前置:
|
||||||
|
# 1. tauri.conf.json 与 Cargo.toml 版本号一致(脚本第 1 步校验,防 D1 复发)
|
||||||
|
# 2. 签名私钥就位: $env:TAURI_SIGNING_PRIVATE_KEY = "$env:USERPROFILE\.tauri\devflow.key"
|
||||||
|
# (无密码密钥无需 TAURI_SIGNING_PRIVATE_KEY_PASSWORD)
|
||||||
|
# 3. ssh-proxy 已配 suke-dev(39.99.243.191),web 根 /var/www/app-site/
|
||||||
|
param(
|
||||||
|
[string]$Notes = "",
|
||||||
|
[switch]$SkipBuild
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$repo = "E:\wk-lab\devflow"
|
||||||
|
$site = "suke-dev"
|
||||||
|
$remoteDir = "/var/www/app-site/download/devflow"
|
||||||
|
$baseUrl = "https://app.1216.top/download/devflow"
|
||||||
|
|
||||||
|
Set-Location $repo
|
||||||
|
|
||||||
|
# ── 1. 版本双源校验(防 D1 复发)──
|
||||||
|
Write-Host "[1/6] 校验版本双源一致..."
|
||||||
|
$confVer = (Get-Content "$repo\src-tauri\tauri.conf.json" -Raw | ConvertFrom-Json).version
|
||||||
|
$cargoVer = (Select-String -Path "$repo\src-tauri\Cargo.toml" -Pattern '^version\s*=\s*"([^"]+)"').Matches[0].Groups[1].Value
|
||||||
|
if ($confVer -ne $cargoVer) {
|
||||||
|
Write-Host "[失败] 版本不一致: tauri.conf.json=$confVer, Cargo.toml=$cargoVer"
|
||||||
|
Write-Host " 请先对齐两处版本号再发布(设计 D1:双源必须同值)"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
$version = $confVer
|
||||||
|
Write-Host " 版本 v$version 双源一致,通过"
|
||||||
|
|
||||||
|
# ── 2. 签名私钥就位检查 ──
|
||||||
|
if (-not $env:TAURI_SIGNING_PRIVATE_KEY) {
|
||||||
|
$keyPath = "$env:USERPROFILE\.tauri\devflow.key"
|
||||||
|
if (Test-Path $keyPath) {
|
||||||
|
$env:TAURI_SIGNING_PRIVATE_KEY = $keyPath
|
||||||
|
Write-Host "[2/6] 签名私钥: $keyPath"
|
||||||
|
} else {
|
||||||
|
Write-Host "[失败] 未找到签名私钥($keyPath),且未设 TAURI_SIGNING_PRIVATE_KEY"
|
||||||
|
Write-Host " 先执行: cargo tauri signer generate -w `$env:USERPROFILE\.tauri\devflow.key"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Host "[2/6] 签名私钥: 取自环境变量"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── 3. 构建 updater 产物 ──
|
||||||
|
$nsisDir = "$repo\target\release\bundle\nsis"
|
||||||
|
if (-not $SkipBuild) {
|
||||||
|
Write-Host "[3/6] tauri build(含 createUpdaterArtifacts,可能 10 分钟级)..."
|
||||||
|
cmd /c "cargo tauri build 2>&1"
|
||||||
|
if ($LASTEXITCODE -ne 0) { Write-Host "[失败] tauri build"; exit 1 }
|
||||||
|
} else {
|
||||||
|
Write-Host "[3/6] 跳过构建(-SkipBuild),复用已有产物"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 定位产物:Tauri v2 = setup.exe + .exe.sig(更新包即安装器本身);v1 遗留 = .nsis.zip + .nsis.zip.sig
|
||||||
|
$setupExe = Get-ChildItem "$nsisDir\devflow_${version}_x64-setup.exe" -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||||
|
$sigFile = Get-ChildItem "$nsisDir\devflow_${version}_x64-setup.exe.sig","$nsisDir\devflow_${version}_x64-setup.nsis.zip.sig" -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||||
|
$zipFile = Get-ChildItem "$nsisDir\devflow_${version}_x64-setup.nsis.zip" -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||||
|
if (-not $sigFile) { Write-Host "[失败] 未找到 .sig 签名文件(确认 createUpdaterArtifacts=true 且 TAURI_SIGNING_PRIVATE_KEY 就位)"; exit 1 }
|
||||||
|
# 与 .sig 配对的更新产物:v2 签名(.exe.sig)配 setup.exe;v1 签名(.nsis.zip.sig)配 .nsis.zip
|
||||||
|
$artifact = if ($sigFile.Name.EndsWith('.exe.sig')) { $setupExe } else { $zipFile }
|
||||||
|
if (-not $artifact) { Write-Host "[失败] 未找到与 $($sigFile.Name) 配对的更新产物"; exit 1 }
|
||||||
|
Write-Host " 产物: $($artifact.Name) ($([math]::Round($artifact.Length/1MB,1))MB)"
|
||||||
|
|
||||||
|
# ── 4. 生成 latest.json ──
|
||||||
|
Write-Host "[4/6] 生成 latest.json..."
|
||||||
|
$sig = (Get-Content $sigFile.FullName -Raw).Trim()
|
||||||
|
if (-not $Notes) {
|
||||||
|
# 用 git 最近一条提交作 notes(无 CHANGELOG 时的兜底)
|
||||||
|
$Notes = (git log -1 --pretty=format:"%s" 2>&1) -join "`n"
|
||||||
|
if (-not $Notes) { $Notes = "DevFlow v$version" }
|
||||||
|
}
|
||||||
|
$pubDate = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
|
||||||
|
# SHA256:本机 PowerShell 5.1 实测缺 Get-FileHash(同 swap-install 兜底口径),异常走 certutil
|
||||||
|
function Get-Sha256Lower($path) {
|
||||||
|
try { return (Get-FileHash -Path $path -Algorithm SHA256 -ErrorAction Stop).Hash.ToLower() }
|
||||||
|
catch { return (((certutil -hashfile $path SHA256) | Select-Object -Index 1) -replace '\s', '').ToLower() }
|
||||||
|
}
|
||||||
|
$sha256 = Get-Sha256Lower $artifact.FullName
|
||||||
|
$latestJson = [ordered]@{
|
||||||
|
version = $version
|
||||||
|
notes = $Notes
|
||||||
|
pub_date = $pubDate
|
||||||
|
platforms = [ordered]@{
|
||||||
|
"windows-x86_64" = [ordered]@{
|
||||||
|
signature = $sig
|
||||||
|
url = "$baseUrl/$($artifact.Name)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} | ConvertTo-Json -Depth 5
|
||||||
|
$latestPath = "$repo\target\release\bundle\nsis\latest.json"
|
||||||
|
# ⚠️ 必须写「UTF-8 无 BOM」:Windows PowerShell 5.1 的 `Out-File -Encoding utf8` 会写 BOM
|
||||||
|
# (EF BB BF),部分 JSON 解析路径会报 "expected value at line 1 column 1",
|
||||||
|
# 表现为 updater 拿到清单即失败(签名/包都没错,只多了头 3 字节)。2026-10-10 实证修正。
|
||||||
|
[System.IO.File]::WriteAllText($latestPath, $latestJson, (New-Object System.Text.UTF8Encoding($false)))
|
||||||
|
Write-Host " latest.json: version=$version, sha256=$($sha256.Substring(0,16))..."
|
||||||
|
|
||||||
|
# ── 5. 上传到 app.1216.top ──
|
||||||
|
Write-Host "[5/6] 上传到 $site`:$remoteDir ..."
|
||||||
|
$env:MSYS_NO_PATHCONV = "1"
|
||||||
|
# 确保远程目录存在
|
||||||
|
ssh-proxy exec -n $site -c "mkdir -p $remoteDir"
|
||||||
|
if ($LASTEXITCODE -ne 0) { Write-Host "[失败] 创建远程目录"; exit 1 }
|
||||||
|
|
||||||
|
$uploads = @($latestPath, $sigFile.FullName, $artifact.FullName)
|
||||||
|
if ($setupExe -and $setupExe.FullName -ne $artifact.FullName) { $uploads += $setupExe.FullName }
|
||||||
|
foreach ($f in $uploads) {
|
||||||
|
$name = Split-Path $f -Leaf
|
||||||
|
Write-Host " 上传 $name ..."
|
||||||
|
ssh-proxy upload --name $site --src $f --dst "$remoteDir/$name"
|
||||||
|
if ($LASTEXITCODE -ne 0) { Write-Host "[失败] 上传 $name"; exit 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── 6. 验证 ──
|
||||||
|
Write-Host "[6/6] 验证远端清单..."
|
||||||
|
$remoteCheck = ssh-proxy exec -n $site -c "ls -la $remoteDir && echo '---' && cat $remoteDir/latest.json | head -5"
|
||||||
|
Write-Host $remoteCheck
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "[发布完成] v$version"
|
||||||
|
Write-Host " 清单地址: $baseUrl/latest.json"
|
||||||
|
Write-Host " 应用内检查更新即可发现新版本(app.1216.top 已配 no-cache,无需刷 CDN)"
|
||||||
@@ -1,15 +1,36 @@
|
|||||||
# DevFlow 自宿主换装脚本 v3
|
# DevFlow 自宿主换装脚本 v3
|
||||||
# 流程: 等旧进程退出并探测句柄释放, 静默安装, 按构建产物哈希校验且失败重试一次, 交互用户可见重启, 结束自清理
|
# 流程: 等旧进程退出并探测句柄释放, 静默安装, 按构建产物哈希校验且失败重试一次, 交互用户可见重启, 结束自清理
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
$installer = 'E:\wk-lab\devflow\target\release\bundle\nsis\devflow_0.1.0_x64-setup.exe'
|
# 安装包路径:按构建产物 glob 探测(不硬编码版本号,版本升级时脚本零改动)。
|
||||||
|
# C3 决议:脚本消费构建产物,版本号唯一源头是 Cargo.toml → tauri.conf.json 注入文件名。
|
||||||
|
$nsisDir = 'E:\wk-lab\devflow\target\release\bundle\nsis'
|
||||||
|
$installer = $null
|
||||||
|
if (Test-Path $nsisDir) {
|
||||||
|
$installer = Get-ChildItem -Path $nsisDir -Filter 'devflow_*_x64-setup.exe' -ErrorAction SilentlyContinue |
|
||||||
|
Sort-Object LastWriteTime -Descending | Select-Object -First 1 -ExpandProperty FullName
|
||||||
|
}
|
||||||
|
if (-not $installer) {
|
||||||
|
# 兜底:目录缺失或无匹配产物时,保留旧硬编码路径以维持既有失败提示语义
|
||||||
|
$installer = 'E:\wk-lab\devflow\target\release\bundle\nsis\devflow_0.1.1_x64-setup.exe'
|
||||||
|
}
|
||||||
$target = 'C:\Users\23780\AppData\Local\devflow\devflow.exe'
|
$target = 'C:\Users\23780\AppData\Local\devflow\devflow.exe'
|
||||||
$builtExe = 'E:\wk-lab\devflow\target\release\devflow.exe'
|
$builtExe = 'E:\wk-lab\devflow\target\release\devflow.exe'
|
||||||
$log = 'E:\wk-lab\devflow\swap-install.log'
|
$log = 'E:\wk-lab\devflow\temp\swap-install.log'
|
||||||
$lockFile = 'E:\wk-lab\devflow\swap-install.lock'
|
$lockFile = 'E:\wk-lab\devflow\temp\swap-install.lock'
|
||||||
|
|
||||||
|
# SHA256 取值:Get-FileHash 在部分环境的本机 Windows PowerShell 5.1 上不可用(实测 2026-10-10 缺失),
|
||||||
|
# 兜底走系统自带的 certutil。返回大写十六进制串。
|
||||||
|
function Get-Sha256($path) {
|
||||||
|
try { return (Get-FileHash -Path $path -Algorithm SHA256 -ErrorAction Stop).Hash }
|
||||||
|
catch { return (((certutil -hashfile $path SHA256) | Select-Object -Index 1) -replace '\s', '').ToUpper() }
|
||||||
|
}
|
||||||
|
|
||||||
# 构建产物哈希是唯一强基准; 产物缺失时退化为与安装前快照比对的弱校验
|
# 构建产物哈希是唯一强基准; 产物缺失时退化为与安装前快照比对的弱校验
|
||||||
|
# 实测(2026-10-10):装机 exe 与本基准哈希不一致(与 target/release/devflow.exe 同尺寸但字节不同,
|
||||||
|
# 疑 NSIS 打包前打 bundle-type 补丁所致)→ 强基准恒不命中,实际退化为「两次安装字节一致 + 与
|
||||||
|
# 安装前不同」的校验。该退化仍能拦住「installer exit=0 但 exe 未更新」(before==after)。
|
||||||
$hashWant = $null
|
$hashWant = $null
|
||||||
if (Test-Path $builtExe) { $hashWant = (Get-FileHash $builtExe -Algorithm SHA256).Hash }
|
if (Test-Path $builtExe) { $hashWant = Get-Sha256 $builtExe }
|
||||||
|
|
||||||
# 0. SYSTEM 上下文转投用户会话
|
# 0. SYSTEM 上下文转投用户会话
|
||||||
$who = whoami
|
$who = whoami
|
||||||
@@ -69,7 +90,7 @@ function Invoke-SwapInstall {
|
|||||||
"[$(Get-Date -Format 'HH:mm:ss')] FAIL: target missing after install" | Add-Content $log
|
"[$(Get-Date -Format 'HH:mm:ss')] FAIL: target missing after install" | Add-Content $log
|
||||||
return $null
|
return $null
|
||||||
}
|
}
|
||||||
$afterHash = (Get-FileHash $target -Algorithm SHA256).Hash
|
$afterHash = Get-Sha256 $target
|
||||||
$afterSize = (Get-Item $target).Length
|
$afterSize = (Get-Item $target).Length
|
||||||
"[$(Get-Date -Format 'HH:mm:ss')] installed size=$afterSize hash=$($afterHash.Substring(0, 12))" | Add-Content $log
|
"[$(Get-Date -Format 'HH:mm:ss')] installed size=$afterSize hash=$($afterHash.Substring(0, 12))" | Add-Content $log
|
||||||
return $afterHash
|
return $afterHash
|
||||||
@@ -99,7 +120,7 @@ try {
|
|||||||
# 3. 安装前快照, 供弱校验与前后比对
|
# 3. 安装前快照, 供弱校验与前后比对
|
||||||
$before = @{ hash = 'absent'; size = 0 }
|
$before = @{ hash = 'absent'; size = 0 }
|
||||||
if (Test-Path $target) {
|
if (Test-Path $target) {
|
||||||
$before.hash = (Get-FileHash $target -Algorithm SHA256).Hash
|
$before.hash = Get-Sha256 $target
|
||||||
$before.size = (Get-Item $target).Length
|
$before.size = (Get-Item $target).Length
|
||||||
}
|
}
|
||||||
$beforeHashShort = $before.hash
|
$beforeHashShort = $before.hash
|
||||||
@@ -168,7 +189,7 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# 仍无进程, 落标记等人工拉起
|
# 仍无进程, 落标记等人工拉起
|
||||||
Set-Content -Path 'E:\wk-lab\devflow\swap-need-start.flag' -Value (Get-Date -Format o)
|
Set-Content -Path 'E:\wk-lab\devflow\temp\swap-need-start.flag' -Value (Get-Date -Format o)
|
||||||
"[$(Get-Date -Format 'HH:mm:ss')] FAIL: need manual start" | Add-Content $log
|
"[$(Get-Date -Format 'HH:mm:ss')] FAIL: need manual start" | Add-Content $log
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "devflow"
|
name = "devflow"
|
||||||
version = "0.1.0"
|
version = "0.1.3"
|
||||||
description = "DevFlow Desktop App"
|
description = "DevFlow Desktop App"
|
||||||
authors = [""]
|
authors = [""]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
@@ -23,6 +23,8 @@ tauri-plugin-window-state = "2"
|
|||||||
tauri-plugin-notification = "2"
|
tauri-plugin-notification = "2"
|
||||||
# Phase3 单实例:二次启动唤起已有进程主窗口,防多实例抢 18765 端口
|
# Phase3 单实例:二次启动唤起已有进程主窗口,防多实例抢 18765 端口
|
||||||
tauri-plugin-single-instance = "2"
|
tauri-plugin-single-instance = "2"
|
||||||
|
# B2 应用内升级:L1 接入 tauri-plugin-updater(内建 ed25519 签名校验)
|
||||||
|
tauri-plugin-updater = "2"
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
@@ -80,6 +82,12 @@ futures-util = "0.3"
|
|||||||
# 使用 rustls-tls(非 native-tls),避免 Windows SChannel 同步阻塞 tokio 工作线程
|
# 使用 rustls-tls(非 native-tls),避免 Windows SChannel 同步阻塞 tokio 工作线程
|
||||||
# 致 BUG-2026-07-17(aichat 流式调用永久 hang,外层 tokio timeout 亦无法推进计时器)。
|
# 致 BUG-2026-07-17(aichat 流式调用永久 hang,外层 tokio timeout 亦无法推进计时器)。
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "gzip", "brotli", "rustls-tls", "stream"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "gzip", "brotli", "rustls-tls", "stream"] }
|
||||||
|
# media_store(#958 引用化):hash 去重文件名(sha2+hex)+ 阿里云 OSS PUT 上传签名(hmac+sha1)
|
||||||
|
sha2 = "0.10"
|
||||||
|
hex = "0.4"
|
||||||
|
hmac = "0.12"
|
||||||
|
sha1 = "0.10"
|
||||||
|
httpdate = "1"
|
||||||
# fetch_url AI 工具:URL → markdown 文档嗅探(GET HTML → htmd 转 markdown → 去噪音 + 截断)。
|
# fetch_url AI 工具:URL → markdown 文档嗅探(GET HTML → htmd 转 markdown → 去噪音 + 截断)。
|
||||||
# 替代 http_request 拿原始 HTML(噪声大、爆 token)。turndown.js 移植,只读 GET,与 http_request 共享 SSRF 防护。
|
# 替代 http_request 拿原始 HTML(噪声大、爆 token)。turndown.js 移植,只读 GET,与 http_request 共享 SSRF 防护。
|
||||||
htmd = "0.5"
|
htmd = "0.5"
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"core:webview:allow-create-webview-window",
|
"core:webview:allow-create-webview-window",
|
||||||
"dialog:default",
|
"dialog:default",
|
||||||
"window-state:default",
|
"window-state:default",
|
||||||
"notification:default"
|
"notification:default",
|
||||||
|
"updater:default"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ pub async fn cleanup_expired_approvals(app: &AppHandle, state: &AppState, conv_i
|
|||||||
&tc_id,
|
&tc_id,
|
||||||
"rejected",
|
"rejected",
|
||||||
Some(format!("审批超时({}分钟)自动取消", timeout_minutes)),
|
Some(format!("审批超时({}分钟)自动取消", timeout_minutes)),
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
//!
|
//!
|
||||||
//! 流程(对齐 ai_chat_compress_context IPC 的 read-but-don't-mutate 模式):
|
//! 流程(对齐 ai_chat_compress_context IPC 的 read-but-don't-mutate 模式):
|
||||||
//! ① 读 active 克隆(不改 status / 不扣 token)→ 后台喂 LLM 出摘要;
|
//! ① 读 active 克隆(不改 status / 不扣 token)→ 后台喂 LLM 出摘要;
|
||||||
//! ② LLM 成功 → compress_old_messages(标 compressed + 扣 token)+ insert_at(摘要 system);
|
//! ② LLM 成功 → compress_old_messages(标 compressed + 扣 token)+ push(摘要 system 尾部追加);
|
||||||
//! ③ LLM 失败 → 消息状态完全不变(未改 status / 未扣 token),降级走原 build_for_request 裁剪。
|
//! ③ LLM 失败 → 消息状态完全不变(未改 status / 未扣 token),降级走原 build_for_request 裁剪。
|
||||||
//!
|
//!
|
||||||
//! 口径决策(任务让"你判断"):选**延迟 mutate(成功才改)**而非"失败回滚 status"。
|
//! 口径决策(任务让"你判断"):选**延迟 mutate(成功才改)**而非"失败回滚 status"。
|
||||||
@@ -30,16 +30,18 @@
|
|||||||
//! L0-3(2026-08-09):压缩 LLM 调用改后台 fire-and-forget(tokio::spawn),不再同步阻塞本轮
|
//! L0-3(2026-08-09):压缩 LLM 调用改后台 fire-and-forget(tokio::spawn),不再同步阻塞本轮
|
||||||
//! loop(非流式 LLM 最长 60s)。当前轮用未压缩上下文正常走(不丢回复),压缩结果下一轮生效。
|
//! loop(非流式 LLM 最长 60s)。当前轮用未压缩上下文正常走(不丢回复),压缩结果下一轮生效。
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use tauri::AppHandle;
|
use tauri::{AppHandle, Manager};
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
use df_ai::context::ContextManager;
|
||||||
use df_ai::context_helpers::{extract_keyword_summary, CompressedSummary};
|
use df_ai::context_helpers::{extract_keyword_summary, CompressedSummary};
|
||||||
use df_ai::provider::ChatMessage;
|
use df_ai::provider::ChatMessage;
|
||||||
use df_storage::models::AiProviderRecord;
|
use df_storage::models::AiProviderRecord;
|
||||||
|
|
||||||
use crate::state::LlmConcurrency;
|
use crate::state::{AppState, LlmConcurrency};
|
||||||
|
|
||||||
// compress_via_llm 位于 commands::ai::compress(mod.rs 的 super::compress)。
|
// compress_via_llm 位于 commands::ai::compress(mod.rs 的 super::compress)。
|
||||||
use super::super::compress::compress_via_llm;
|
use super::super::compress::compress_via_llm;
|
||||||
@@ -54,11 +56,37 @@ const PROTECT_COUNT: usize = 6;
|
|||||||
/// 改进3 B 常量开关:压缩失败时是否启用关键词摘要兜底(默认 true)。
|
/// 改进3 B 常量开关:压缩失败时是否启用关键词摘要兜底(默认 true)。
|
||||||
///
|
///
|
||||||
/// true(默认):LLM 压缩失败 → compress_old_messages 标 compressed(释放 token)
|
/// true(默认):LLM 压缩失败 → compress_old_messages 标 compressed(释放 token)
|
||||||
/// + insert_at(0, system 关键词摘要),保留用户反复提及的主题词作续接锚点。
|
/// + push(system 关键词摘要)尾部追加,保留用户反复提及的主题词作续接锚点。
|
||||||
/// false(回退):回退原裸裁剪行为(不插关键词摘要,仅 build_for_request 裁剪保最近 6 条)。
|
/// false(回退):回退原裸裁剪行为(不插关键词摘要,仅 build_for_request 裁剪保最近 6 条)。
|
||||||
/// 排障/对比用:置 false 即可观察无兜底时的裁剪效果。
|
/// 排障/对比用:置 false 即可观察无兜底时的裁剪效果。
|
||||||
const KEYWORD_FALLBACK_ENABLED: bool = true;
|
const KEYWORD_FALLBACK_ENABLED: bool = true;
|
||||||
|
|
||||||
|
/// 会话消息列表的内容版本哨兵(939⑪):首条消息的全局唯一 id(空列表 = None)。
|
||||||
|
///
|
||||||
|
/// 压缩**触发时**快照本值,后台 task **提交前**重新读取比对:相等才允许提交。
|
||||||
|
/// 首条消息 id 由 `new_message_id()` 全局唯一——loop 只 append(不改首条),而 `clear()`
|
||||||
|
/// 重建后首条必换新 id,故它是「上下文是否被整体替换」的准确且零成本哨兵。
|
||||||
|
/// 不用 len:loop 后台压缩期间正常 append 会使 len 增长,以 len 相等为守卫会误判版本
|
||||||
|
/// 不符致后台压缩永不生效。
|
||||||
|
fn head_id_of(messages: &mut ContextManager) -> Option<String> {
|
||||||
|
messages.messages_mut().first().and_then(|t| t.message.id.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 内容版本是否仍匹配(939⑪):快照与当前首条 id 相等才允许提交。
|
||||||
|
fn version_matches(snapshot: &Option<String>, current: &Option<String>) -> bool {
|
||||||
|
snapshot == current
|
||||||
|
}
|
||||||
|
|
||||||
|
/// panic 兜底复位(939⑫):清 is_compressing 防其永真致压缩永久停摆。
|
||||||
|
///
|
||||||
|
/// conv 已删(per_conv 无条目)跳过——标志随 conv 销毁无意义(对齐各正常出口语义)。
|
||||||
|
async fn reset_compressing_after_panic(session_arc: &Arc<Mutex<AiSession>>, conv_id: &str) {
|
||||||
|
let mut session = session_arc.lock().await;
|
||||||
|
if session.per_conv.contains_key(conv_id) {
|
||||||
|
session.conv(conv_id).messages.set_compressing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 自动压缩(智能裁剪)——在 build_for_request 之前预处理。
|
/// 自动压缩(智能裁剪)——在 build_for_request 之前预处理。
|
||||||
///
|
///
|
||||||
/// **L0-3(2026-08-09)后台化**:压缩 LLM 调用不再同步阻塞本轮 loop(非流式 LLM 最长 60s)。
|
/// **L0-3(2026-08-09)后台化**:压缩 LLM 调用不再同步阻塞本轮 loop(非流式 LLM 最长 60s)。
|
||||||
@@ -114,7 +142,7 @@ pub(super) async fn maybe_auto_compress(
|
|||||||
emit_ai_chat_event(app_handle, AiChatEvent::AiCompressing {
|
emit_ai_chat_event(app_handle, AiChatEvent::AiCompressing {
|
||||||
conversation_id: Some(conv_id.to_string()),
|
conversation_id: Some(conv_id.to_string()),
|
||||||
});
|
});
|
||||||
let (mut active_msgs, lang) = {
|
let (mut active_msgs, lang, head_id) = {
|
||||||
let mut session = session_arc.lock().await;
|
let mut session = session_arc.lock().await;
|
||||||
let conv = session.conv(&conv_id);
|
let conv = session.conv(&conv_id);
|
||||||
conv.messages.set_compressing(true);
|
conv.messages.set_compressing(true);
|
||||||
@@ -123,6 +151,9 @@ pub(super) async fn maybe_auto_compress(
|
|||||||
// 切片前按当时实际长度钳制,防 index out of bounds panic
|
// 切片前按当时实际长度钳制,防 index out of bounds panic
|
||||||
// (后续 compress_old_messages 内部自带 min(len) 钳制,口径一致)。
|
// (后续 compress_old_messages 内部自带 min(len) 钳制,口径一致)。
|
||||||
let msgs = conv.messages.messages_mut();
|
let msgs = conv.messages.messages_mut();
|
||||||
|
// 939⑪ 内容版本哨兵:快照触发时首条消息 id,后台 task 提交前比对
|
||||||
|
// (clear() 重建后闭包内提交会落到新上下文,靠此守卫丢弃)。
|
||||||
|
let head_id = msgs.first().and_then(|t| t.message.id.clone());
|
||||||
let cut = protect_start.min(msgs.len());
|
let cut = protect_start.min(msgs.len());
|
||||||
let active_msgs: Vec<ChatMessage> = msgs[..cut]
|
let active_msgs: Vec<ChatMessage> = msgs[..cut]
|
||||||
.iter()
|
.iter()
|
||||||
@@ -131,7 +162,7 @@ pub(super) async fn maybe_auto_compress(
|
|||||||
.collect();
|
.collect();
|
||||||
let lang = conv.agent_language.clone()
|
let lang = conv.agent_language.clone()
|
||||||
.unwrap_or_else(|| "zh-CN".to_string());
|
.unwrap_or_else(|| "zh-CN".to_string());
|
||||||
(active_msgs, lang)
|
(active_msgs, lang, head_id)
|
||||||
};
|
};
|
||||||
|
|
||||||
// T2-修复(方案A):展开 namespace 引用为真实内容,再喂压缩 LLM/关键词摘要。
|
// T2-修复(方案A):展开 namespace 引用为真实内容,再喂压缩 LLM/关键词摘要。
|
||||||
@@ -177,6 +208,17 @@ pub(super) async fn maybe_auto_compress(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 压缩可召回开关(KV `df-ai-compress-recall`,默认开):开则压缩标记时把原文
|
||||||
|
// 存档 + 摘要尾部拼召回句柄。spawn 前读值拷贝,后台 task 不持 AppState 引用
|
||||||
|
//(State 借用非 'static,不可移入 task)。
|
||||||
|
let (recall_on, data_dir) = {
|
||||||
|
let st = app_handle.state::<AppState>();
|
||||||
|
(
|
||||||
|
super::super::prompt::compress_recall_enabled(&st).await,
|
||||||
|
st.data_dir.clone(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
// ── L0-3 后台化:压缩 LLM 调用改 fire-and-forget(tokio::spawn 独立 task) ──
|
// ── L0-3 后台化:压缩 LLM 调用改 fire-and-forget(tokio::spawn 独立 task) ──
|
||||||
//
|
//
|
||||||
// 原实现在此 `compress_via_llm(...).await` 同步阻塞整轮(非流式 LLM 最长 60s),
|
// 原实现在此 `compress_via_llm(...).await` 同步阻塞整轮(非流式 LLM 最长 60s),
|
||||||
@@ -203,7 +245,15 @@ pub(super) async fn maybe_auto_compress(
|
|||||||
let conv_id = conv_id.to_string();
|
let conv_id = conv_id.to_string();
|
||||||
let provider_config = provider_config.clone();
|
let provider_config = provider_config.clone();
|
||||||
let llm_concurrency = llm_concurrency.clone();
|
let llm_concurrency = llm_concurrency.clone();
|
||||||
|
// 939⑫ panic 兜底句柄:内层 async move 会消费 session_arc/conv_id,panic 分支
|
||||||
|
// 需另持一份用于复位 is_compressing(防其永真致压缩永久停摆)。
|
||||||
|
let panic_session = session_arc.clone();
|
||||||
|
let panic_conv_id = conv_id.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
|
// 939⑫ 整个后台 body 包 catch_unwind(复用 mod.rs::unwind_to_result 薄包装):
|
||||||
|
// panic 以 Err 返回 → 复位 is_compressing + warn,防压缩永久停摆。依赖
|
||||||
|
// panic=unwind(tokio 默认);panic=abort 构建下退化为进程终止(尽力兜底)。
|
||||||
|
let outcome = super::unwind_to_result(async move {
|
||||||
// 后台重建 provider(secret::build_provider_for 三步打包:resolve→ensure→build)。
|
// 后台重建 provider(secret::build_provider_for 三步打包:resolve→ensure→build)。
|
||||||
// 失败 → 同 LLM 失败降级:复位 is_compressing + 关键词摘要兜底(标 compressed 释放
|
// 失败 → 同 LLM 失败降级:复位 is_compressing + 关键词摘要兜底(标 compressed 释放
|
||||||
// token + 插关键词),保持「失败→兜底→扣 token」语义——否则 history_tokens 未降,
|
// token + 插关键词),保持「失败→兜底→扣 token」语义——否则 history_tokens 未降,
|
||||||
@@ -218,22 +268,29 @@ pub(super) async fn maybe_auto_compress(
|
|||||||
"[ai] 后台压缩 provider 重建失败,降级走关键词摘要兜底(KEYWORD_FALLBACK_ENABLED={})",
|
"[ai] 后台压缩 provider 重建失败,降级走关键词摘要兜底(KEYWORD_FALLBACK_ENABLED={})",
|
||||||
KEYWORD_FALLBACK_ENABLED,
|
KEYWORD_FALLBACK_ENABLED,
|
||||||
);
|
);
|
||||||
// 单次锁内 check+mutate:复位 is_compressing(防 conv 两次锁间被删惰性重建)。
|
// 复位 is_compressing 收口进 keyword_fallback_commit 单锁(909①-补:
|
||||||
{
|
// 原先此处单独取锁复位,与提交之间留并发二次压缩窗口)。
|
||||||
let mut session = session_arc.lock().await;
|
let degraded = keyword_fallback_commit(
|
||||||
if session.per_conv.contains_key(&conv_id) {
|
|
||||||
session.conv(&conv_id).messages.set_compressing(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
keyword_fallback_commit(
|
|
||||||
&session_arc,
|
&session_arc,
|
||||||
&conv_id,
|
&conv_id,
|
||||||
protect_start,
|
protect_start,
|
||||||
&keyword_fallback,
|
&keyword_fallback,
|
||||||
pre_compress_tokens,
|
pre_compress_tokens,
|
||||||
"压缩 provider 重建失败",
|
"压缩 provider 重建失败",
|
||||||
|
&head_id,
|
||||||
|
recall_on.then_some(data_dir.as_path()),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
// E2:降级不再静默(实证 76730ce8 seq166 用户被迫原句重发)
|
||||||
|
if degraded {
|
||||||
|
emit_ai_chat_event(
|
||||||
|
&app_handle,
|
||||||
|
AiChatEvent::AiContextDegraded {
|
||||||
|
conversation_id: Some(conv_id.to_string()),
|
||||||
|
reason: "压缩 provider 重建失败,已降级为关键词摘要".into(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -252,7 +309,7 @@ pub(super) async fn maybe_auto_compress(
|
|||||||
|
|
||||||
match compress_outcome {
|
match compress_outcome {
|
||||||
Ok(Some(summary)) => {
|
Ok(Some(summary)) => {
|
||||||
// LLM 成功 → 标 compressed(扣 token)+ 摘要 system 插首位 + set_compressing(false)。
|
// LLM 成功 → 标 compressed(扣 token)+ 摘要 system 尾部追加 + set_compressing(false)。
|
||||||
// conv 已删(per_conv 无条目)时丢弃压缩结果——惰性重建会污染新 conv,
|
// conv 已删(per_conv 无条目)时丢弃压缩结果——惰性重建会污染新 conv,
|
||||||
// is_compressing 标志随 conv 销毁无意义,直接 return。
|
// is_compressing 标志随 conv 销毁无意义,直接 return。
|
||||||
{
|
{
|
||||||
@@ -261,9 +318,31 @@ pub(super) async fn maybe_auto_compress(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let conv = session.conv(&conv_id);
|
let conv = session.conv(&conv_id);
|
||||||
|
// 939⑪ 内容版本守卫:提交前比对触发时快照,不符(期间 clear() 重建致
|
||||||
|
// 上下文整体替换)→ 丢弃压缩结果。不复位 is_compressing(clear 已复位;
|
||||||
|
// 若新会话已启新压缩,此处复位会误伤新压缩信令)。
|
||||||
|
if !version_matches(&head_id, &head_id_of(&mut conv.messages)) {
|
||||||
|
tracing::warn!(
|
||||||
|
conv_id = %conv_id,
|
||||||
|
"[ai] 压缩完成但上下文版本已变(疑似 clear 重建),丢弃旧摘要"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if recall_on {
|
||||||
|
conv.messages.set_compress_recall(&data_dir, &conv_id);
|
||||||
|
}
|
||||||
let _compressed = conv.messages.compress_old_messages(protect_start);
|
let _compressed = conv.messages.compress_old_messages(protect_start);
|
||||||
// NL 摘要插首位 + JSON 卡片回填 WorkingContext,决策随压缩重锚定
|
// 召回句柄拼在摘要尾部(存档落盘成功才有):LLM 需要原文时 read_file
|
||||||
conv.messages.insert_at(0, ChatMessage::system(&summary.nl_summary));
|
// 回读;句柄行只进 system 锚点,前端事件仍用原 nl_summary 不外发。
|
||||||
|
let anchor_text = match conv.messages.take_archive_path() {
|
||||||
|
Some(p) => df_ai::context::archive::with_recall_line(&summary.nl_summary, &p),
|
||||||
|
None => summary.nl_summary.clone(),
|
||||||
|
};
|
||||||
|
// NL 摘要尾部追加(而非 insert_at(0) 头部插入):头部插入会位移整条
|
||||||
|
// 前缀字节,使 DeepSeek 等自动前缀缓存的 provider 全部缓存失效
|
||||||
|
// (命中率掉到个位数)。尾部追加紧邻本轮内容 LLM 仍可见,前缀稳定
|
||||||
|
// 命中率最大化。JSON 卡片回填 WorkingContext,决策随压缩重锚定。
|
||||||
|
conv.messages.push_system_anchor(ChatMessage::system(&anchor_text));
|
||||||
conv.messages.set_compressing(false);
|
conv.messages.set_compressing(false);
|
||||||
conv.working_context.sync_after_compress(&summary.json_card);
|
conv.working_context.sync_after_compress(&summary.json_card);
|
||||||
}
|
}
|
||||||
@@ -271,7 +350,7 @@ pub(super) async fn maybe_auto_compress(
|
|||||||
conv_id = %conv_id,
|
conv_id = %conv_id,
|
||||||
iteration,
|
iteration,
|
||||||
pre_tokens = pre_compress_tokens,
|
pre_tokens = pre_compress_tokens,
|
||||||
"[ai] 自动压缩成功(后台),摘要已插首位"
|
"[ai] 自动压缩成功(后台),摘要已尾部追加"
|
||||||
);
|
);
|
||||||
// 治 Task#1:loop 自动压缩用 AiAutoCompressed(非手动变体),
|
// 治 Task#1:loop 自动压缩用 AiAutoCompressed(非手动变体),
|
||||||
// 桌面端静默(仅复位 isCompressing,不弹 toast 不 switchConversation)。
|
// 桌面端静默(仅复位 isCompressing,不弹 toast 不 switchConversation)。
|
||||||
@@ -286,29 +365,28 @@ pub(super) async fn maybe_auto_compress(
|
|||||||
// 单次锁内 check+mutate(防 conv 在两次锁间被删致惰性重建污染新 conv)。
|
// 单次锁内 check+mutate(防 conv 在两次锁间被删致惰性重建污染新 conv)。
|
||||||
let mut session = session_arc.lock().await;
|
let mut session = session_arc.lock().await;
|
||||||
if session.per_conv.contains_key(&conv_id) {
|
if session.per_conv.contains_key(&conv_id) {
|
||||||
session.conv(&conv_id).messages.set_compressing(false);
|
let conv = session.conv(&conv_id);
|
||||||
|
// 939⑪ 版本守卫:上下文已被整体替换 → 不复位(否则误伤新会话信令)。
|
||||||
|
if version_matches(&head_id, &head_id_of(&mut conv.messages)) {
|
||||||
|
conv.messages.set_compressing(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// LLM 失败 → 改进3 B:仍标 compressed 释放 token + 关键词摘要塞回首条(非裸裁剪)。
|
// LLM 失败 → 改进3 B:仍标 compressed 释放 token + 关键词摘要尾部追加(非裸裁剪)。
|
||||||
//
|
//
|
||||||
// 旧行为:消息状态完全不变,降级走 build_for_request 裁剪(丢主题)。
|
// 旧行为:消息状态完全不变,降级走 build_for_request 裁剪(丢主题)。
|
||||||
// 新行为(KEYWORD_FALLBACK_ENABLED=true 默认):
|
// 新行为(KEYWORD_FALLBACK_ENABLED=true 默认):
|
||||||
// - compress_old_messages 标 [..protect_start] active 为 compressed(释放 token,
|
// - compress_old_messages 标 [..protect_start] active 为 compressed(释放 token,
|
||||||
// 与成功路径一致,后续 build_for_request 不再把它们进 LLM 上下文);
|
// 与成功路径一致,后续 build_for_request 不再把它们进 LLM 上下文);
|
||||||
// - keyword_fallback 非空 → insert_at(0, system 关键词摘要)作续接锚点;
|
// - keyword_fallback 非空 → push(system 关键词摘要)尾部追加作续接锚点;
|
||||||
// - keyword_fallback 空(无 user 消息/无可提取词)→ 不插,等价旧裁剪(保底)。
|
// - keyword_fallback 空(无 user 消息/无可提取词)→ 不插,等价旧裁剪(保底)。
|
||||||
// 持久化语义不变:compressed 仍软删可追溯(DB 全量保留),与成功路径一致。
|
// 持久化语义不变:compressed 仍软删可追溯(DB 全量保留),与成功路径一致。
|
||||||
// 压缩失败不发 AiError——前端 AiError case 无条件 setStreaming(false)
|
// 压缩失败不发 AiError——前端 AiError case 无条件 setStreaming(false)
|
||||||
// 误判生成结束(后端 loop 实际继续),致"压缩后停止"。降级为 warn + 兜底释放
|
// 误判生成结束(后端 loop 实际继续),致"压缩后停止"。降级为 warn + 兜底释放
|
||||||
// token + build_for_request 裁剪,loop 继续 stream_llm,用户自然看到后续回复。
|
// token + build_for_request 裁剪,loop 继续 stream_llm,用户自然看到后续回复。
|
||||||
// 单次锁内 check+mutate(防 conv 在两次锁间被删致惰性重建污染新 conv)。
|
// 复位 is_compressing 收口进 keyword_fallback_commit 单锁(909①-补,
|
||||||
{
|
// 同 provider 重建失败路径;消锁外复位→再取锁提交的并发窗口)。
|
||||||
let mut session = session_arc.lock().await;
|
|
||||||
if session.per_conv.contains_key(&conv_id) {
|
|
||||||
session.conv(&conv_id).messages.set_compressing(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
conv_id = %conv_id,
|
conv_id = %conv_id,
|
||||||
error = %e,
|
error = %e,
|
||||||
@@ -316,17 +394,40 @@ pub(super) async fn maybe_auto_compress(
|
|||||||
"[ai] 自动压缩失败(后台),降级走关键词摘要兜底(KEYWORD_FALLBACK_ENABLED={})",
|
"[ai] 自动压缩失败(后台),降级走关键词摘要兜底(KEYWORD_FALLBACK_ENABLED={})",
|
||||||
KEYWORD_FALLBACK_ENABLED,
|
KEYWORD_FALLBACK_ENABLED,
|
||||||
);
|
);
|
||||||
keyword_fallback_commit(
|
let degraded = keyword_fallback_commit(
|
||||||
&session_arc,
|
&session_arc,
|
||||||
&conv_id,
|
&conv_id,
|
||||||
protect_start,
|
protect_start,
|
||||||
&keyword_fallback,
|
&keyword_fallback,
|
||||||
pre_compress_tokens,
|
pre_compress_tokens,
|
||||||
"压缩失败",
|
"压缩失败",
|
||||||
|
&head_id,
|
||||||
|
recall_on.then_some(data_dir.as_path()),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
// E2:降级不再静默——AiError 语义是生成结束,此处 loop 仍在跑,
|
||||||
|
// 用专用事件让前端透出降级状态条/提示(不打断流)
|
||||||
|
if degraded {
|
||||||
|
emit_ai_chat_event(
|
||||||
|
&app_handle,
|
||||||
|
AiChatEvent::AiContextDegraded {
|
||||||
|
conversation_id: Some(conv_id.to_string()),
|
||||||
|
reason: "LLM 压缩失败,已降级为关键词摘要".into(),
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}) // 闭 catch_unwind 内 async move
|
||||||
|
.await;
|
||||||
|
if let Err(msg) = outcome {
|
||||||
|
reset_compressing_after_panic(&panic_session, &panic_conv_id).await;
|
||||||
|
tracing::warn!(
|
||||||
|
conv_id = %panic_conv_id,
|
||||||
|
error = %msg,
|
||||||
|
"[ai] 后台压缩 task panic,已复位 is_compressing(防压缩永久停摆)"
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -336,12 +437,25 @@ pub(super) async fn maybe_auto_compress(
|
|||||||
/// 关键词兜底提交单点(DRY #8 收敛,2026-10-01)。
|
/// 关键词兜底提交单点(DRY #8 收敛,2026-10-01)。
|
||||||
///
|
///
|
||||||
/// 抽自 [`maybe_auto_compress`] 后台 task 内的逐字双胞胎块:压缩 provider 重建失败路径
|
/// 抽自 [`maybe_auto_compress`] 后台 task 内的逐字双胞胎块:压缩 provider 重建失败路径
|
||||||
/// 与压缩 LLM 失败路径共用「KEYWORD_FALLBACK_ENABLED 门控 → 锁内 check+mutate
|
/// 与压缩 LLM 失败路径共用「复位 is_compressing → KEYWORD_FALLBACK_ENABLED 门控 → 锁内
|
||||||
/// (标 compressed 释放 token + 关键词摘要插首位,非空才插)→ 成功插入打 info」。
|
/// check+mutate(标 compressed 释放 token + 关键词摘要尾部追加,非空才插)→ 成功插入打 info」。
|
||||||
/// 两条失败路径只差日志正文,经 `log_head` 参数化(拼为
|
/// 两条失败路径只差日志正文,经 `log_head` 参数化(拼为
|
||||||
/// "[ai] {log_head}兜底(后台):关键词摘要已插首位(compressed 标记已扣 token)")。
|
/// "[ai] {log_head}兜底(后台):关键词摘要已尾部追加(compressed 标记已扣 token)")。
|
||||||
///
|
///
|
||||||
/// conv 已删(per_conv 无条目)→ 直接返回不兜底(对齐原 return 语义:防惰性重建污染新 conv)。
|
/// **复位重入标志收口单锁(909①-补,2026-10-10)**:`set_compressing(false)` 与兜底提交在同一
|
||||||
|
/// 次锁内原子完成——原调用方在锁外单独取锁复位,与这里的提交之间留出窗口(期间的压缩触发读到
|
||||||
|
/// 的仍是 false,可并发二次压缩,放大楔入概率)。注意复位在 `KEYWORD_FALLBACK_ENABLED` 门控
|
||||||
|
/// **之前**:开关关闭时同样必须复位,否则 is_compressing 永真 → 压缩永久停摆。
|
||||||
|
///
|
||||||
|
/// conv 已删(per_conv 无条目)→ 直接返回(标志随 conv 销毁无需复位;对齐原 return 语义:
|
||||||
|
/// 防惰性重建污染新 conv)。
|
||||||
|
///
|
||||||
|
/// **内容版本守卫(939⑪)**:`head_id` 为触发时快照的首条消息 id;提交前比对不符(期间
|
||||||
|
/// `clear()` 重建致上下文整体替换)→ 直接返回:既不标 compressed / 不插旧关键词摘要污染
|
||||||
|
/// 新上下文,也不复位 is_compressing(clear 已复位;复位会误伤新会话可能已启的新压缩信令)。
|
||||||
|
///
|
||||||
|
/// `recall`:压缩可召回开关(Some = 开,携带存档 data_dir)。开则压缩标记时原文落盘存档,
|
||||||
|
/// 关键词摘要尾部拼召回句柄行;None 零行为变更(不落盘、摘要无句柄)。
|
||||||
async fn keyword_fallback_commit(
|
async fn keyword_fallback_commit(
|
||||||
session_arc: &Arc<Mutex<AiSession>>,
|
session_arc: &Arc<Mutex<AiSession>>,
|
||||||
conv_id: &str,
|
conv_id: &str,
|
||||||
@@ -349,30 +463,178 @@ async fn keyword_fallback_commit(
|
|||||||
keyword_fallback: &str,
|
keyword_fallback: &str,
|
||||||
pre_tokens: u32,
|
pre_tokens: u32,
|
||||||
log_head: &str,
|
log_head: &str,
|
||||||
) {
|
head_id: &Option<String>,
|
||||||
if !KEYWORD_FALLBACK_ENABLED {
|
recall: Option<&Path>,
|
||||||
return;
|
) -> bool {
|
||||||
}
|
|
||||||
let inserted = {
|
let inserted = {
|
||||||
let mut session = session_arc.lock().await;
|
let mut session = session_arc.lock().await;
|
||||||
if !session.per_conv.contains_key(conv_id) {
|
if !session.per_conv.contains_key(conv_id) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
let conv = session.conv(conv_id);
|
let conv = session.conv(conv_id);
|
||||||
|
// 939⑪ 内容版本守卫:上下文已被整体替换 → 丢弃兜底提交(标志亦不复位)。
|
||||||
|
if !version_matches(head_id, &head_id_of(&mut conv.messages)) {
|
||||||
|
tracing::warn!(
|
||||||
|
conv_id = %conv_id,
|
||||||
|
"[ai] {}兜底提交前上下文版本已变(疑似 clear 重建),丢弃",
|
||||||
|
log_head
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 复位与提交同锁原子(909①-补):消「锁外复位→再取锁提交」之间的并发二次压缩窗口。
|
||||||
|
conv.messages.set_compressing(false);
|
||||||
|
if !KEYWORD_FALLBACK_ENABLED {
|
||||||
|
// 开关关闭 → 回退原裸裁剪行为:不压缩不插摘要,仅完成复位。
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
// 压缩可召回:开(Some)则压缩标记时原文落盘存档,关键词摘要尾部拼召回句柄行。
|
||||||
|
if let Some(dir) = recall {
|
||||||
|
conv.messages.set_compress_recall(dir, conv_id);
|
||||||
|
}
|
||||||
let _compressed = conv.messages.compress_old_messages(protect_start);
|
let _compressed = conv.messages.compress_old_messages(protect_start);
|
||||||
if keyword_fallback.is_empty() {
|
if keyword_fallback.is_empty() {
|
||||||
false
|
false
|
||||||
} else {
|
} else {
|
||||||
conv.messages.insert_at(0, ChatMessage::system(keyword_fallback));
|
// 召回句柄拼在摘要尾部(存档落盘成功才有),只进 system 锚点。
|
||||||
|
let anchor_text = match conv.messages.take_archive_path() {
|
||||||
|
Some(p) => df_ai::context::archive::with_recall_line(keyword_fallback, &p),
|
||||||
|
None => keyword_fallback.to_string(),
|
||||||
|
};
|
||||||
|
// 尾部追加(而非 insert_at(0) 头部插入):头部插入位移整条前缀字节,
|
||||||
|
// 使 DeepSeek 等自动前缀缓存 provider 全部缓存失效(命中率掉到个位数)。
|
||||||
|
// 尾部追加紧邻本轮内容 LLM 仍可见,前缀稳定命中率最大化。
|
||||||
|
conv.messages.push_system_anchor(ChatMessage::system(&anchor_text));
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
if inserted {
|
if inserted {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
conv_id = %conv_id,
|
conv_id = %conv_id,
|
||||||
pre_tokens,
|
pre_tokens,
|
||||||
"[ai] {}兜底(后台):关键词摘要已插首位(compressed 标记已扣 token)",
|
"[ai] {}兜底(后台):关键词摘要已尾部追加(compressed 标记已扣 token)",
|
||||||
log_head
|
log_head
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
inserted
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// 建 conv 并灌 n 条 active 消息,置 is_compressing(true)(模拟触发时已置位)。
|
||||||
|
/// 返回触发时的内容版本哨兵(首条消息 id),供 keyword_fallback_commit 的版本守卫比对。
|
||||||
|
async fn seed_compressing(arc: &Arc<Mutex<AiSession>>, conv_id: &str, n: usize) -> Option<String> {
|
||||||
|
let mut session = arc.lock().await;
|
||||||
|
let conv = session.conv(conv_id);
|
||||||
|
for i in 0..n {
|
||||||
|
conv.messages.push(if i % 2 == 0 {
|
||||||
|
ChatMessage::user(format!("用户消息{i}"))
|
||||||
|
} else {
|
||||||
|
ChatMessage::assistant(format!("助手回复{i}"))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
conv.messages.set_compressing(true);
|
||||||
|
head_id_of(&mut conv.messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 非空关键词:复位 is_compressing 与兜底提交(标 compressed + 尾部插锚点)一并生效。
|
||||||
|
#[tokio::test]
|
||||||
|
async fn keyword_fallback_commit_resets_and_inserts() {
|
||||||
|
let arc = Arc::new(Mutex::new(AiSession::new()));
|
||||||
|
let head = seed_compressing(&arc, "c1", 4).await;
|
||||||
|
|
||||||
|
keyword_fallback_commit(&arc, "c1", 4, "关键词锚点", 0, "压缩失败", &head, None).await;
|
||||||
|
|
||||||
|
let session = arc.lock().await;
|
||||||
|
let conv = session.conv_read("c1").expect("conv 应存在");
|
||||||
|
assert!(!conv.messages.is_compressing(), "提交后 is_compressing 应复位 false");
|
||||||
|
let msgs = conv.messages.all_messages_clone();
|
||||||
|
assert_eq!(msgs.len(), 5, "压缩后应尾部追加 1 条关键词 system 锚点");
|
||||||
|
for (i, m) in msgs[..4].iter().enumerate() {
|
||||||
|
assert!(!m.is_active(), "第 {i} 条原消息应被标 compressed(释放 token)");
|
||||||
|
}
|
||||||
|
assert_eq!(msgs[4].content, "关键词锚点", "末条应为关键词锚点正文");
|
||||||
|
assert!(
|
||||||
|
matches!(msgs[4].role, df_ai::provider::MessageRole::System),
|
||||||
|
"锚点应为 system 角色"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 空关键词:复位不能丢(否则 is_compressing 永真 → 压缩永久停摆),但不插锚点。
|
||||||
|
#[tokio::test]
|
||||||
|
async fn keyword_fallback_commit_empty_keeps_reset() {
|
||||||
|
let arc = Arc::new(Mutex::new(AiSession::new()));
|
||||||
|
let head = seed_compressing(&arc, "c2", 4).await;
|
||||||
|
|
||||||
|
keyword_fallback_commit(&arc, "c2", 4, "", 0, "压缩 provider 重建失败", &head, None).await;
|
||||||
|
|
||||||
|
let session = arc.lock().await;
|
||||||
|
let conv = session.conv_read("c2").expect("conv 应存在");
|
||||||
|
assert!(!conv.messages.is_compressing(), "关键词为空仍应复位 is_compressing");
|
||||||
|
assert_eq!(conv.messages.len(), 4, "关键词为空不应插锚点,len 不变");
|
||||||
|
for m in conv.messages.all_messages_clone() {
|
||||||
|
assert!(!m.is_active(), "空关键词路径仍应标 compressed 扣 token");
|
||||||
|
}
|
||||||
|
assert_eq!(conv.messages.history_tokens(), 0, "无锚点插入,token 应清零");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// conv 已删(per_conv 无条目):早返不惰性重建(防污染新 conv)。
|
||||||
|
#[tokio::test]
|
||||||
|
async fn keyword_fallback_commit_missing_conv_noop() {
|
||||||
|
let arc = Arc::new(Mutex::new(AiSession::new()));
|
||||||
|
|
||||||
|
keyword_fallback_commit(&arc, "gone", 4, "锚点", 0, "压缩失败", &None, None).await;
|
||||||
|
|
||||||
|
let session = arc.lock().await;
|
||||||
|
assert!(
|
||||||
|
session.per_conv.is_empty(),
|
||||||
|
"conv 已删不应惰性重建 per_conv 条目"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 939⑪⑫ 后台压缩守卫契约:纯函数级断言(版本不符→丢弃 / panic→标志复位)。
|
||||||
|
#[cfg(test)]
|
||||||
|
mod guard_tests {
|
||||||
|
use super::{head_id_of, reset_compressing_after_panic, version_matches, AiSession};
|
||||||
|
use df_ai::context::{ContextConfig, ContextManager};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
// 939⑪:内容版本不符(首条 id 变化 / 列表被清空 / 空→非空)即判定丢弃。
|
||||||
|
#[test]
|
||||||
|
fn version_guard_discards_on_head_change() {
|
||||||
|
let a = Some("msg_a".to_string());
|
||||||
|
let b = Some("msg_b".to_string());
|
||||||
|
assert!(version_matches(&a, &a), "同版本必须放行");
|
||||||
|
assert!(!version_matches(&a, &b), "首条 id 变(clear 重建)必须丢弃");
|
||||||
|
assert!(!version_matches(&a, &None), "列表被清空必须丢弃");
|
||||||
|
assert!(!version_matches(&None, &a), "空→非空(新会话)必须丢弃");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 939⑪:空列表的内容版本哨兵为 None。
|
||||||
|
#[test]
|
||||||
|
fn head_id_of_empty_list_is_none() {
|
||||||
|
let mut mgr = ContextManager::new(ContextConfig::default());
|
||||||
|
assert_eq!(head_id_of(&mut mgr), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 939⑫:后台 body panic → is_compressing 被复位,压缩不停摆。
|
||||||
|
#[tokio::test]
|
||||||
|
async fn panic_resets_compressing_flag() {
|
||||||
|
let session = Arc::new(Mutex::new(AiSession::new()));
|
||||||
|
let conv_id = "conv-panic";
|
||||||
|
session.lock().await.conv(conv_id).messages.set_compressing(true);
|
||||||
|
// 模拟后台 body panic,经 catch_unwind 薄包装转受控 Err
|
||||||
|
let r = super::super::unwind_to_result(async { panic!("boom in compress task") }).await;
|
||||||
|
assert!(r.is_err(), "panic 应被捕获为 Err");
|
||||||
|
// panic 分支:复位 is_compressing
|
||||||
|
reset_compressing_after_panic(&session, conv_id).await;
|
||||||
|
assert!(
|
||||||
|
!session.lock().await.conv_read(conv_id).unwrap().messages.is_compressing(),
|
||||||
|
"panic 后 is_compressing 必须复位,否则压缩永久停摆"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,15 @@ impl GeneratingGuard {
|
|||||||
|
|
||||||
/// 迁移 ConvState + emit AiConvStateChanged(经无锁 ConvStateStore,同步无 await)。
|
/// 迁移 ConvState + emit AiConvStateChanged(经无锁 ConvStateStore,同步无 await)。
|
||||||
fn transition(&self, target: ConvState) {
|
fn transition(&self, target: ConvState) {
|
||||||
|
// 后台工具占用闸:后台命令在飞时拦截 loop 退出的 Idle 迁移,
|
||||||
|
// 会话保持 Generating(受理守卫判占用、前端不显示空闲),并记录「loop 已退」
|
||||||
|
// 供 detached task 终局决策 try_continue 续跑;占用复位由最后一个后台任务
|
||||||
|
// 终局时放行(audit/bg_run.rs)。用户停止/致命错误路径不经过此闸(仅拦 Idle)。
|
||||||
|
if target == ConvState::Idle
|
||||||
|
&& crate::commands::ai::audit::bg_run::hold_blocks_idle(&self.conv_id)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
let conv_states = self
|
let conv_states = self
|
||||||
.app_handle
|
.app_handle
|
||||||
.state::<crate::state::AppState>()
|
.state::<crate::state::AppState>()
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user