Compare commits
12
Commits
91773da602
...
8e1e56b3e5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e1e56b3e5 | ||
|
|
26cc3c3df9 | ||
|
|
753de25696 | ||
|
|
6a50193b34 | ||
|
|
c83ea0c315 | ||
|
|
c10f0e46f0 | ||
|
|
83e86cd6b7 | ||
|
|
c6ebf15407 | ||
|
|
f4d31e884a | ||
|
|
c8c13d302a | ||
|
|
7dea2b86c4 | ||
|
|
e492f03191 |
@@ -94,3 +94,9 @@ devflow-dev.db-wal
|
|||||||
apps/df-miniapp/src/manifest.json
|
apps/df-miniapp/src/manifest.json
|
||||||
# 微信小程序 appid(真实 appid 本地保留,不提交仓库)
|
# 微信小程序 appid(真实 appid 本地保留,不提交仓库)
|
||||||
temp/
|
temp/
|
||||||
|
|
||||||
|
# 本地工具链缓存(protoc 等,不入库)
|
||||||
|
.tools/
|
||||||
|
|
||||||
|
# df-im-gateway 运行时数据(冒烟测试残留)
|
||||||
|
crates/df-im-gateway/data/
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# DevFlow 批次推进记录
|
# DevFlow 批次推进记录
|
||||||
|
|
||||||
> 记录每个批次的提交 hash、改动内容和交付价值。
|
> 记录每个批次的提交 hash、改动内容和交付价值。
|
||||||
> 最后更新: 2026-07-02 | 最新提交: `4624688`
|
最后更新: 2026-10-04 | 最新提交: `74ce9f7`(df-im-gateway 网关骨架 P0)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -443,7 +443,7 @@
|
|||||||
|
|
||||||
## Batch 38 — 合并产出落回主对话(Coordinator 接线)
|
## Batch 38 — 合并产出落回主对话(Coordinator 接线)
|
||||||
|
|
||||||
- **提交**: 待后续
|
- **提交**: `eee0f06`(新增: 合并产出落回主对话(Coordinator 接线))
|
||||||
- **内容**:
|
- **内容**:
|
||||||
- `run_agentic_loop`:plan_execution_enabled 时,decompose 后调 dispatch
|
- `run_agentic_loop`:plan_execution_enabled 时,decompose 后调 dispatch
|
||||||
→ merge → 合并产出以单条 assistant 消息推回主对话,emit AiCompleted 后 return
|
→ merge → 合并产出以单条 assistant 消息推回主对话,emit AiCompleted 后 return
|
||||||
@@ -490,9 +490,9 @@
|
|||||||
| 3 | ConflictResolver.vue(双栏 diff + 接受A/B/合并/手动按钮) | ✅ |
|
| 3 | ConflictResolver.vue(双栏 diff + 接受A/B/合并/手动按钮) | ✅ |
|
||||||
| 4 | i18n 冲突翻译键(中英文) | ✅ |
|
| 4 | i18n 冲突翻译键(中英文) | ✅ |
|
||||||
| 5 | agentic/mod.rs 拆分(提取自动压缩到 context_lifecycle.rs,2222→2071行) | ✅ |
|
| 5 | agentic/mod.rs 拆分(提取自动压缩到 context_lifecycle.rs,2222→2071行) | ✅ |
|
||||||
| 6 | Reviewer Agent 仲裁(persona.rs 扩展) | 待下一批 |
|
| 6 | Reviewer Agent 仲裁(persona.rs 扩展) | ✅(批次C前序散项) |
|
||||||
| 7 | 命令互斥锁(run_command 同目录 mutex) | 待下一批 |
|
| 7 | 命令互斥锁(run_command 同目录 mutex) | ✅(批次C前序散项) |
|
||||||
| 8 | context.rs 拆分(1956行) | 待下一批 |
|
| 8 | context.rs 拆分(1956行) | ✅(批次C前序散项) |
|
||||||
|
|
||||||
### 模板系统 + 节点补齐 ✅
|
### 模板系统 + 节点补齐 ✅
|
||||||
|
|
||||||
@@ -508,8 +508,8 @@
|
|||||||
| 4 | NotifyNode(桌面通知 + Webhook) | ✅ |
|
| 4 | NotifyNode(桌面通知 + Webhook) | ✅ |
|
||||||
| 5 | SubflowNode(嵌套子工作流 + 深度限制) | ✅ |
|
| 5 | SubflowNode(嵌套子工作流 + 深度限制) | ✅ |
|
||||||
| 6 | 内置模板 3 个预设(代码审查/Bug修复/功能开发) | ✅ |
|
| 6 | 内置模板 3 个预设(代码审查/Bug修复/功能开发) | ✅ |
|
||||||
| 7 | 前端模板选择入口 + 模板 CRUD IPC | 待下一批 |
|
| 7 | 前端模板选择入口 + 模板 CRUD IPC | ✅(批次C前序散项) |
|
||||||
| 8 | MessageList.vue 继续拆分 | 待下一批 |
|
| 8 | MessageList.vue 继续拆分 | ✅(批次C前序散项,1386→1148行) |
|
||||||
|
|
||||||
### Git/CI 集成 + DockerNode + God 文件收尾 ✅
|
### Git/CI 集成 + DockerNode + God 文件收尾 ✅
|
||||||
|
|
||||||
@@ -544,7 +544,7 @@
|
|||||||
| 6 | AiChat 进度条(completedTools 计数器) | ✅ |
|
| 6 | AiChat 进度条(completedTools 计数器) | ✅ |
|
||||||
| 7 | df-types augmentation 测试修复 | ✅ |
|
| 7 | df-types augmentation 测试修复 | ✅ |
|
||||||
| 8 | 前端 UI 审查修复(badge/confirm/加载态/CSS) | ✅ |
|
| 8 | 前端 UI 审查修复(badge/confirm/加载态/CSS) | ✅ |
|
||||||
| 9 | 合并产出落回主对话 | 待接线 |
|
| 9 | 合并产出落回主对话 | ✅ `eee0f06`(Batch 38) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Generated
+496
-12
@@ -8,6 +8,17 @@ version = "2.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aes"
|
||||||
|
version = "0.9.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32"
|
||||||
|
dependencies = [
|
||||||
|
"cipher",
|
||||||
|
"cpubits",
|
||||||
|
"cpufeatures 0.3.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ahash"
|
name = "ahash"
|
||||||
version = "0.8.12"
|
version = "0.8.12"
|
||||||
@@ -29,6 +40,40 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aioduct"
|
||||||
|
version = "0.2.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8bf59c8d5bf56c75528fd1384b528e8ff576aedf4f8483d1e78235a5e15c380d"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
|
"bytes",
|
||||||
|
"coarsetime",
|
||||||
|
"futures-channel",
|
||||||
|
"futures-core",
|
||||||
|
"h2",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"httparse",
|
||||||
|
"hyper",
|
||||||
|
"memchr",
|
||||||
|
"percent-encoding",
|
||||||
|
"pin-project-lite",
|
||||||
|
"quinn",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pemfile",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_urlencoded",
|
||||||
|
"socket2",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tokio",
|
||||||
|
"url",
|
||||||
|
"webpki-roots",
|
||||||
|
"x509-parser",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "alloc-no-stdlib"
|
name = "alloc-no-stdlib"
|
||||||
version = "2.0.4"
|
version = "2.0.4"
|
||||||
@@ -59,6 +104,45 @@ 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 = "asn1-rs"
|
||||||
|
version = "0.7.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8"
|
||||||
|
dependencies = [
|
||||||
|
"asn1-rs-derive",
|
||||||
|
"asn1-rs-impl",
|
||||||
|
"displaydoc",
|
||||||
|
"nom",
|
||||||
|
"num-traits",
|
||||||
|
"rusticata-macros",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"time",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "asn1-rs-derive"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.117",
|
||||||
|
"synstructure",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "asn1-rs-impl"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.117",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-broadcast"
|
name = "async-broadcast"
|
||||||
version = "0.7.2"
|
version = "0.7.2"
|
||||||
@@ -275,7 +359,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_path_to_error",
|
"serde_path_to_error",
|
||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
"sha1",
|
"sha1 0.10.6",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-tungstenite 0.24.0",
|
"tokio-tungstenite 0.24.0",
|
||||||
@@ -357,6 +441,24 @@ dependencies = [
|
|||||||
"generic-array",
|
"generic-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block-buffer"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
|
||||||
|
dependencies = [
|
||||||
|
"hybrid-array",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block-padding"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b"
|
||||||
|
dependencies = [
|
||||||
|
"hybrid-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block2"
|
name = "block2"
|
||||||
version = "0.6.2"
|
version = "0.6.2"
|
||||||
@@ -503,6 +605,15 @@ dependencies = [
|
|||||||
"toml 0.9.12+spec-1.1.0",
|
"toml 0.9.12+spec-1.1.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cbc"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896"
|
||||||
|
dependencies = [
|
||||||
|
"cipher",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.2.63"
|
version = "1.2.63"
|
||||||
@@ -566,6 +677,27 @@ dependencies = [
|
|||||||
"windows-link 0.2.1",
|
"windows-link 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cipher"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
|
||||||
|
dependencies = [
|
||||||
|
"crypto-common 0.2.2",
|
||||||
|
"inout",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "coarsetime"
|
||||||
|
version = "0.1.38"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "eae63702c5627c75addbfb1ea9d1b3842205a6ffd43e4cce884a2a9eb1828fd7"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"wasix",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "combine"
|
name = "combine"
|
||||||
version = "4.6.7"
|
version = "4.6.7"
|
||||||
@@ -603,6 +735,12 @@ dependencies = [
|
|||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "const-oid"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cookie"
|
name = "cookie"
|
||||||
version = "0.18.1"
|
version = "0.18.1"
|
||||||
@@ -663,6 +801,12 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpubits"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cpufeatures"
|
name = "cpufeatures"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -672,6 +816,15 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crc32fast"
|
name = "crc32fast"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -706,6 +859,15 @@ dependencies = [
|
|||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crypto-common"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
||||||
|
dependencies = [
|
||||||
|
"hybrid-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cssparser"
|
name = "cssparser"
|
||||||
version = "0.36.0"
|
version = "0.36.0"
|
||||||
@@ -810,6 +972,20 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "der-parser"
|
||||||
|
version = "10.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6"
|
||||||
|
dependencies = [
|
||||||
|
"asn1-rs",
|
||||||
|
"displaydoc",
|
||||||
|
"nom",
|
||||||
|
"num-bigint",
|
||||||
|
"num-traits",
|
||||||
|
"rusticata-macros",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deranged"
|
name = "deranged"
|
||||||
version = "0.5.8"
|
version = "0.5.8"
|
||||||
@@ -948,6 +1124,25 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "df-im-gateway"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"async-trait",
|
||||||
|
"df-tunnel",
|
||||||
|
"futures-util",
|
||||||
|
"larksuite-oapi-sdk-rs",
|
||||||
|
"rusqlite",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tempfile",
|
||||||
|
"tokio",
|
||||||
|
"tokio-tungstenite 0.23.1",
|
||||||
|
"tracing",
|
||||||
|
"tracing-subscriber",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "df-mcp"
|
name = "df-mcp"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -1077,8 +1272,19 @@ version = "0.10.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer",
|
"block-buffer 0.10.4",
|
||||||
"crypto-common",
|
"crypto-common 0.1.7",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "digest"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||||
|
dependencies = [
|
||||||
|
"block-buffer 0.12.1",
|
||||||
|
"const-oid",
|
||||||
|
"crypto-common 0.2.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1214,6 +1420,12 @@ version = "1.0.20"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "either"
|
||||||
|
version = "1.18.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "embed-resource"
|
name = "embed-resource"
|
||||||
version = "3.0.9"
|
version = "3.0.9"
|
||||||
@@ -1361,6 +1573,12 @@ version = "0.1.9"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fixedbitset"
|
||||||
|
version = "0.5.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "flate2"
|
name = "flate2"
|
||||||
version = "1.1.9"
|
version = "1.1.9"
|
||||||
@@ -1992,6 +2210,15 @@ version = "1.0.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hybrid-array"
|
||||||
|
version = "0.4.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
|
||||||
|
dependencies = [
|
||||||
|
"typenum",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper"
|
name = "hyper"
|
||||||
version = "1.10.1"
|
version = "1.10.1"
|
||||||
@@ -2252,6 +2479,16 @@ dependencies = [
|
|||||||
"cfb",
|
"cfb",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "inout"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
|
||||||
|
dependencies = [
|
||||||
|
"block-padding",
|
||||||
|
"hybrid-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ipnet"
|
name = "ipnet"
|
||||||
version = "2.12.0"
|
version = "2.12.0"
|
||||||
@@ -2277,6 +2514,15 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itertools"
|
||||||
|
version = "0.14.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
|
||||||
|
dependencies = [
|
||||||
|
"either",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.18"
|
version = "1.0.18"
|
||||||
@@ -2411,6 +2657,38 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "larksuite-oapi-sdk-rs"
|
||||||
|
version = "0.3.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "07c1fcaec70e6e1c98183aa4c57f08c7165403b5dece93713545d8a18104683b"
|
||||||
|
dependencies = [
|
||||||
|
"aes",
|
||||||
|
"aioduct",
|
||||||
|
"base64 0.22.1",
|
||||||
|
"bytes",
|
||||||
|
"cbc",
|
||||||
|
"flate2",
|
||||||
|
"futures-util",
|
||||||
|
"hex",
|
||||||
|
"http",
|
||||||
|
"percent-encoding",
|
||||||
|
"prost",
|
||||||
|
"prost-build",
|
||||||
|
"rustls",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"sha1 0.11.0",
|
||||||
|
"sha2 0.11.0",
|
||||||
|
"subtle",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tokio",
|
||||||
|
"tokio-tungstenite 0.26.2",
|
||||||
|
"tracing",
|
||||||
|
"url",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -2608,6 +2886,12 @@ version = "0.3.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "minimal-lexical"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "miniz_oxide"
|
name = "miniz_oxide"
|
||||||
version = "0.8.9"
|
version = "0.8.9"
|
||||||
@@ -2650,6 +2934,12 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "multimap"
|
||||||
|
version = "0.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "native-tls"
|
name = "native-tls"
|
||||||
version = "0.2.18"
|
version = "0.2.18"
|
||||||
@@ -2710,6 +3000,16 @@ dependencies = [
|
|||||||
"memoffset",
|
"memoffset",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nom"
|
||||||
|
version = "7.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
"minimal-lexical",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "notify-rust"
|
name = "notify-rust"
|
||||||
version = "4.18.1"
|
version = "4.18.1"
|
||||||
@@ -3030,6 +3330,15 @@ dependencies = [
|
|||||||
"objc2-foundation",
|
"objc2-foundation",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "oid-registry"
|
||||||
|
version = "0.8.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7"
|
||||||
|
dependencies = [
|
||||||
|
"asn1-rs",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.4"
|
version = "1.21.4"
|
||||||
@@ -3173,6 +3482,16 @@ version = "2.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "petgraph"
|
||||||
|
version = "0.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772"
|
||||||
|
dependencies = [
|
||||||
|
"fixedbitset",
|
||||||
|
"indexmap 2.14.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "phf"
|
name = "phf"
|
||||||
version = "0.13.1"
|
version = "0.13.1"
|
||||||
@@ -3404,6 +3723,58 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "prost"
|
||||||
|
version = "0.13.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"prost-derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "prost-build"
|
||||||
|
version = "0.13.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf"
|
||||||
|
dependencies = [
|
||||||
|
"heck 0.5.0",
|
||||||
|
"itertools",
|
||||||
|
"log",
|
||||||
|
"multimap",
|
||||||
|
"once_cell",
|
||||||
|
"petgraph",
|
||||||
|
"prettyplease",
|
||||||
|
"prost",
|
||||||
|
"prost-types",
|
||||||
|
"regex",
|
||||||
|
"syn 2.0.117",
|
||||||
|
"tempfile",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "prost-derive"
|
||||||
|
version = "0.13.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"itertools",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.117",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "prost-types"
|
||||||
|
version = "0.13.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16"
|
||||||
|
dependencies = [
|
||||||
|
"prost",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quick-xml"
|
name = "quick-xml"
|
||||||
version = "0.39.4"
|
version = "0.39.4"
|
||||||
@@ -3771,6 +4142,15 @@ dependencies = [
|
|||||||
"semver",
|
"semver",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rusticata-macros"
|
||||||
|
version = "4.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
|
||||||
|
dependencies = [
|
||||||
|
"nom",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustix"
|
name = "rustix"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
@@ -3798,6 +4178,27 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-native-certs"
|
||||||
|
version = "0.8.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
|
||||||
|
dependencies = [
|
||||||
|
"openssl-probe",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"schannel",
|
||||||
|
"security-framework 3.7.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-pemfile"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
|
||||||
|
dependencies = [
|
||||||
|
"rustls-pki-types",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls-pki-types"
|
name = "rustls-pki-types"
|
||||||
version = "1.14.1"
|
version = "1.14.1"
|
||||||
@@ -4188,8 +4589,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures",
|
"cpufeatures 0.2.17",
|
||||||
"digest",
|
"digest 0.10.7",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha1"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures 0.3.1",
|
||||||
|
"digest 0.11.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4199,8 +4611,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures",
|
"cpufeatures 0.2.17",
|
||||||
"digest",
|
"digest 0.10.7",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha2"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures 0.3.1",
|
||||||
|
"digest 0.11.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4613,7 +5036,7 @@ dependencies = [
|
|||||||
"semver",
|
"semver",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2 0.10.9",
|
||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
"tauri-utils",
|
"tauri-utils",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
@@ -5080,6 +5503,22 @@ dependencies = [
|
|||||||
"tungstenite 0.24.0",
|
"tungstenite 0.24.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-tungstenite"
|
||||||
|
version = "0.26.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084"
|
||||||
|
dependencies = [
|
||||||
|
"futures-util",
|
||||||
|
"log",
|
||||||
|
"rustls",
|
||||||
|
"rustls-native-certs",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
|
"tungstenite 0.26.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-util"
|
name = "tokio-util"
|
||||||
version = "0.7.18"
|
version = "0.7.18"
|
||||||
@@ -5461,7 +5900,7 @@ dependencies = [
|
|||||||
"log",
|
"log",
|
||||||
"native-tls",
|
"native-tls",
|
||||||
"rand 0.8.6",
|
"rand 0.8.6",
|
||||||
"sha1",
|
"sha1 0.10.6",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
"utf-8",
|
"utf-8",
|
||||||
]
|
]
|
||||||
@@ -5479,11 +5918,30 @@ dependencies = [
|
|||||||
"httparse",
|
"httparse",
|
||||||
"log",
|
"log",
|
||||||
"rand 0.8.6",
|
"rand 0.8.6",
|
||||||
"sha1",
|
"sha1 0.10.6",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
"utf-8",
|
"utf-8",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tungstenite"
|
||||||
|
version = "0.26.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"data-encoding",
|
||||||
|
"http",
|
||||||
|
"httparse",
|
||||||
|
"log",
|
||||||
|
"rand 0.9.4",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"sha1 0.10.6",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"utf-8",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typeid"
|
name = "typeid"
|
||||||
version = "1.0.3"
|
version = "1.0.3"
|
||||||
@@ -5714,6 +6172,15 @@ dependencies = [
|
|||||||
"wit-bindgen 0.51.0",
|
"wit-bindgen 0.51.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasix"
|
||||||
|
version = "0.13.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ae86f02046da16a333a9129d31451423e1657737ecdafed4193838a5f54c5cfe"
|
||||||
|
dependencies = [
|
||||||
|
"wasi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen"
|
name = "wasm-bindgen"
|
||||||
version = "0.2.123"
|
version = "0.2.123"
|
||||||
@@ -6610,7 +7077,7 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
"sha2",
|
"sha2 0.10.9",
|
||||||
"soup3",
|
"soup3",
|
||||||
"tao-macros",
|
"tao-macros",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
@@ -6645,6 +7112,23 @@ dependencies = [
|
|||||||
"pkg-config",
|
"pkg-config",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "x509-parser"
|
||||||
|
version = "0.18.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202"
|
||||||
|
dependencies = [
|
||||||
|
"asn1-rs",
|
||||||
|
"data-encoding",
|
||||||
|
"der-parser",
|
||||||
|
"lazy_static",
|
||||||
|
"nom",
|
||||||
|
"oid-registry",
|
||||||
|
"rusticata-macros",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"time",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "xdg-home"
|
name = "xdg-home"
|
||||||
version = "1.3.0"
|
version = "1.3.0"
|
||||||
@@ -6715,7 +7199,7 @@ dependencies = [
|
|||||||
"rand 0.8.6",
|
"rand 0.8.6",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_repr",
|
"serde_repr",
|
||||||
"sha1",
|
"sha1 0.10.6",
|
||||||
"static_assertions",
|
"static_assertions",
|
||||||
"tracing",
|
"tracing",
|
||||||
"uds_windows",
|
"uds_windows",
|
||||||
|
|||||||
@@ -26,3 +26,8 @@ keyring = { version = "3", features = ["windows-native", "apple-native", "linux-
|
|||||||
# ConvState 提到 ConvStateStore(DashMap),guard.reset/ai_is_generating 不依赖 session lock,
|
# ConvState 提到 ConvStateStore(DashMap),guard.reset/ai_is_generating 不依赖 session lock,
|
||||||
# 治卡死连环(AiCompleted 延迟/工具后中断/第二条进队列同源根因)。详见 workflow session-lock-redesign。
|
# 治卡死连环(AiCompleted 延迟/工具后中断/第二条进队列同源根因)。详见 workflow session-lock-redesign。
|
||||||
dashmap = "6"
|
dashmap = "6"
|
||||||
|
|
||||||
|
# release 增量编译:小改动重打包显著提速(2026-10-04 打包提速项);
|
||||||
|
# 代价为产物略大,正式发版如在意体积可临时注释此段
|
||||||
|
[profile.release]
|
||||||
|
incremental = true
|
||||||
|
|||||||
@@ -923,7 +923,9 @@ impl WorkingContext {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 按 chars 计数截断文本,越界补省略号(begin_turn / sync_after_compress 共用)
|
/// 按 chars 计数截断文本,越界补省略号(begin_turn / sync_after_compress 共用)。
|
||||||
|
/// 语义与正典(src-tauri audit/utils.rs truncate_chars_ellipsis,DRY #11)一致;
|
||||||
|
/// 跨 crate 不引入依赖,保持本地同语义实现(有意保留原名,勿误认遗漏)。
|
||||||
fn truncate_chars(s: &str, max: usize) -> String {
|
fn truncate_chars(s: &str, max: usize) -> String {
|
||||||
if s.chars().count() <= max {
|
if s.chars().count() <= max {
|
||||||
return s.to_string();
|
return s.to_string();
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
[package]
|
||||||
|
name = "df-im-gateway"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "IM 网关:飞书/企微/钉钉/Web 四端远程接入 DevFlow AI Chat(WS Commander 连 u-relay)"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
rusqlite = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
async-trait = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
df-tunnel = { path = "../df-tunnel" }
|
||||||
|
|
||||||
|
# WS(与 df-tunnel 同款本地版本声明,workspace 无这几项)
|
||||||
|
tokio-tungstenite = { version = "0.23", features = ["native-tls"] }
|
||||||
|
futures-util = "0.3"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
|
||||||
|
# 飞书 SDK(长连接 + 归一化消息;channel 自动连带 ws+messaging)
|
||||||
|
larksuite-oapi-sdk-rs = { version = "0.3.12", features = ["channel"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
//! 平台适配器(P0 预留项 ④:trait 分层,统一 InboundEvent/OutboundAction)
|
||||||
|
//!
|
||||||
|
//! 网关核心只认识这两个类型,四端差异(飞书卡片/企微 Markdown/钉钉卡片/Web JSON)收敛在各 adapter。
|
||||||
|
//! P0 只实现 Web,飞书/企微/钉钉留 trait 位(P1/P2/P4 各自落地,不动核心)。
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
/// 平台入站事件(IM 平台 → 网关,已归一化)
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum InboundEvent {
|
||||||
|
/// 用户发消息
|
||||||
|
Message {
|
||||||
|
platform: String,
|
||||||
|
chat_id: String,
|
||||||
|
user_id: String,
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
|
/// 会话切换(/new 或菜单)
|
||||||
|
NewSession { platform: String, chat_id: String },
|
||||||
|
/// 审批按钮回调
|
||||||
|
Approval {
|
||||||
|
platform: String,
|
||||||
|
chat_id: String,
|
||||||
|
approval_id: String,
|
||||||
|
approved: bool,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 平台出站动作(网关 → IM 平台,已归一化)
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum OutboundAction {
|
||||||
|
/// 发/更新 AI 流式文本(全量内容,适配器自选卡片或文本)
|
||||||
|
StreamText { chat_id: String, text: String, done: bool },
|
||||||
|
/// 发送审批卡片(同意/拒绝按钮)
|
||||||
|
ApprovalCard { chat_id: String, approval_id: String, title: String, detail: String },
|
||||||
|
/// 发系统提示
|
||||||
|
Notice { chat_id: String, text: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 平台适配器
|
||||||
|
#[async_trait]
|
||||||
|
pub trait PlatformAdapter: Send + Sync {
|
||||||
|
/// 平台名(feishu/wecom/dingtalk/web,与 session_map.platform 对应)。
|
||||||
|
/// 核心循环经 platform_index 注册表间接使用。
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn name(&self) -> &str;
|
||||||
|
/// 启动适配器(长连接/回调服务器/WS),入站事件从 events 通道流出。阻塞运行。
|
||||||
|
async fn run(&self, events: mpsc::Sender<InboundEvent>) -> anyhow::Result<()>;
|
||||||
|
|
||||||
|
/// 出站动作分发(网关核心调用)
|
||||||
|
async fn dispatch(&self, action: OutboundAction) -> anyhow::Result<()>;
|
||||||
|
|
||||||
|
/// 清理平台的流式消息状态(网关在新建会话映射时调用,兜底上轮异常中止的残留)。
|
||||||
|
/// 默认空实现:无流式覆写语义的平台(Web 直接推帧)无需关心。
|
||||||
|
async fn reset_stream(&self, _chat_id: &str) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Web Chat 适配器(网关直接托管浏览器 WS,无三方依赖)
|
||||||
|
pub mod web {
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use futures_util::{SinkExt, StreamExt};
|
||||||
|
use tokio::sync::{mpsc, Mutex};
|
||||||
|
use tokio_tungstenite::tungstenite::Message;
|
||||||
|
|
||||||
|
use super::{InboundEvent, OutboundAction, PlatformAdapter};
|
||||||
|
|
||||||
|
/// 浏览器连接注册表(chat_id → ws 发送通道)
|
||||||
|
type WsRegistry = Arc<Mutex<HashMap<String, mpsc::Sender<Message>>>>;
|
||||||
|
|
||||||
|
/// Web 适配器:浏览器 WS 直连网关 0.0.0.0:9191(accept 协议见 handle_web_ws)
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct WebAdapter {
|
||||||
|
registry: WsRegistry,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WebAdapter {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self { registry: Arc::new(Mutex::new(HashMap::new())) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for WebAdapter {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl PlatformAdapter for WebAdapter {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"web"
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run(&self, events: mpsc::Sender<InboundEvent>) -> anyhow::Result<()> {
|
||||||
|
let listener = tokio::net::TcpListener::bind("0.0.0.0:9191").await?;
|
||||||
|
tracing::info!("[web] WS 服务监听 0.0.0.0:9191");
|
||||||
|
loop {
|
||||||
|
let (stream, _peer) = listener.accept().await?;
|
||||||
|
let registry = self.registry.clone();
|
||||||
|
let events = events.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = handle_web_ws(stream, registry, events).await {
|
||||||
|
tracing::warn!("[web] 浏览器连接异常退出: {e}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn dispatch(&self, action: OutboundAction) -> anyhow::Result<()> {
|
||||||
|
match action {
|
||||||
|
OutboundAction::StreamText { chat_id, text, done } => {
|
||||||
|
let frame = serde_json::json!({
|
||||||
|
"type": if done { "stream_done" } else { "stream_delta" },
|
||||||
|
"text": text,
|
||||||
|
});
|
||||||
|
self.send_ws(&chat_id, &frame.to_string()).await
|
||||||
|
}
|
||||||
|
OutboundAction::ApprovalCard { chat_id, approval_id, title, detail } => {
|
||||||
|
let frame = serde_json::json!({
|
||||||
|
"type": "approval",
|
||||||
|
"approval_id": approval_id,
|
||||||
|
"title": title,
|
||||||
|
"detail": detail,
|
||||||
|
});
|
||||||
|
self.send_ws(&chat_id, &frame.to_string()).await
|
||||||
|
}
|
||||||
|
OutboundAction::Notice { chat_id, text } => {
|
||||||
|
let frame = serde_json::json!({ "type": "notice", "text": text });
|
||||||
|
self.send_ws(&chat_id, &frame.to_string()).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WebAdapter {
|
||||||
|
async fn send_ws(&self, chat_id: &str, text: &str) -> anyhow::Result<()> {
|
||||||
|
let registry = self.registry.lock().await;
|
||||||
|
if let Some(tx) = registry.get(chat_id) {
|
||||||
|
tx.send(Message::Text(text.to_string())).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单个浏览器 WS 会话。
|
||||||
|
///
|
||||||
|
/// 协议(浏览器 → 网关):
|
||||||
|
/// ```json
|
||||||
|
/// 首帧绑定: {"type":"hello","chat_id":"...","user_id":"..."}
|
||||||
|
/// 消息: {"type":"message","text":"..."}
|
||||||
|
/// 重置会话: {"type":"new_session"}
|
||||||
|
/// 审批: {"type":"approval","approval_id":"...","approved":true|false}
|
||||||
|
/// ```
|
||||||
|
/// 网关 → 浏览器:stream_delta / stream_done / approval / notice(见 dispatch)。
|
||||||
|
async fn handle_web_ws(
|
||||||
|
stream: tokio::net::TcpStream,
|
||||||
|
registry: WsRegistry,
|
||||||
|
events: mpsc::Sender<InboundEvent>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let ws = tokio_tungstenite::accept_async(stream).await?;
|
||||||
|
let (mut sink, mut upstream) = ws.split();
|
||||||
|
|
||||||
|
// 首帧 hello 绑定 chat_id
|
||||||
|
let first = upstream.next().await.ok_or_else(|| anyhow::anyhow!("浏览器连接即断"))??;
|
||||||
|
let hello: serde_json::Value = match &first {
|
||||||
|
Message::Text(t) => serde_json::from_str(t)?,
|
||||||
|
_ => anyhow::bail!("首帧非文本"),
|
||||||
|
};
|
||||||
|
let chat_id = hello
|
||||||
|
.get("chat_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("web-anon")
|
||||||
|
.to_string();
|
||||||
|
let user_id = hello.get("user_id").and_then(|v| v.as_str()).unwrap_or("anon").to_string();
|
||||||
|
|
||||||
|
// 注册发送通道:出站帧经 ws_tx → forward 任务写 socket
|
||||||
|
let (ws_tx, mut ws_rx) = mpsc::channel::<Message>(64);
|
||||||
|
registry.lock().await.insert(chat_id.clone(), ws_tx);
|
||||||
|
|
||||||
|
tracing::info!("[web] 浏览器接入 chat_id={chat_id} user={user_id}");
|
||||||
|
|
||||||
|
let forward = tokio::spawn(async move {
|
||||||
|
while let Some(frame) = ws_rx.recv().await {
|
||||||
|
if sink.send(frame).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let result: anyhow::Result<()> = async {
|
||||||
|
while let Some(msg) = upstream.next().await {
|
||||||
|
match msg? {
|
||||||
|
Message::Text(t) => {
|
||||||
|
let v: serde_json::Value = serde_json::from_str(&t).unwrap_or_default();
|
||||||
|
match v.get("type").and_then(|x| x.as_str()).unwrap_or("") {
|
||||||
|
"message" => {
|
||||||
|
let text = v.get("text").and_then(|x| x.as_str()).unwrap_or("").to_string();
|
||||||
|
events
|
||||||
|
.send(InboundEvent::Message {
|
||||||
|
platform: "web".into(),
|
||||||
|
chat_id: chat_id.clone(),
|
||||||
|
user_id: user_id.clone(),
|
||||||
|
text,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow::anyhow!("核心入站通道关闭"))?;
|
||||||
|
}
|
||||||
|
"new_session" => {
|
||||||
|
events
|
||||||
|
.send(InboundEvent::NewSession {
|
||||||
|
platform: "web".into(),
|
||||||
|
chat_id: chat_id.clone(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow::anyhow!("核心入站通道关闭"))?;
|
||||||
|
}
|
||||||
|
"approval" => {
|
||||||
|
let approval_id =
|
||||||
|
v.get("approval_id").and_then(|x| x.as_str()).unwrap_or("").to_string();
|
||||||
|
let approved = v.get("approved").and_then(|x| x.as_bool()).unwrap_or(false);
|
||||||
|
events
|
||||||
|
.send(InboundEvent::Approval {
|
||||||
|
platform: "web".into(),
|
||||||
|
chat_id: chat_id.clone(),
|
||||||
|
approval_id,
|
||||||
|
approved,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow::anyhow!("核心入站通道关闭"))?;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::Close(_) => break,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
|
||||||
|
registry.lock().await.remove(&chat_id);
|
||||||
|
forward.abort();
|
||||||
|
tracing::info!("[web] 浏览器断开 chat_id={chat_id}");
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
//! WS Commander 客户端(连 u-relay /ws/commander)
|
||||||
|
//!
|
||||||
|
//! 与桌面端 df-tunnel(Agent 角色)对称:网关作为 Commander 发 TunnelCommand、收 TunnelEvent。
|
||||||
|
//! 协议类型直接复用 df_tunnel::events(编译期一致)。
|
||||||
|
//!
|
||||||
|
//! 连接生命周期:hello {kind:"commander", channel_id, token} → (relay 校验) → 双向消息。
|
||||||
|
//! 断线重连:指数退避(1/2/4/8/16/30s 封顶,与桌面端 df-tunnel 同款策略)。
|
||||||
|
//!
|
||||||
|
//! 注:relay 对同 channel 多 Commander 的投递行为(P0 必验项 ①)待实测——
|
||||||
|
//! 过渡期约定同 channel 单活跃控制端。
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use futures_util::{SinkExt, StreamExt};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tokio_tungstenite::tungstenite::Message;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use df_tunnel::events::{Hello, HelloKind, RelayControlError};
|
||||||
|
use df_tunnel::{TunnelCommand, TunnelEvent, TunnelMessage};
|
||||||
|
|
||||||
|
/// 指数退避序列(秒,封顶 30)
|
||||||
|
fn backoff_secs(attempt: u32) -> u64 {
|
||||||
|
[1, 2, 4, 8, 16, 30]
|
||||||
|
.get(attempt.min(5) as usize)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(30)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 启动 Commander 连接循环(阻塞直到 shutdown)。重连在内部循环,指令/事件双向透传。
|
||||||
|
///
|
||||||
|
/// * `relay_url` — ws://host:9180/ws/commander
|
||||||
|
/// * `channel_id` — 配对的桌面端 channel
|
||||||
|
/// * `token` — U_RELAY_TOKEN 同源
|
||||||
|
/// * `ev_tx` — 事件出口(桌面端 → 网关核心)
|
||||||
|
/// * `cmd_rx` — 指令入口(网关核心 → 桌面端)
|
||||||
|
/// * `shutdown` — 关闭信号
|
||||||
|
pub async fn run_commander(
|
||||||
|
relay_url: String,
|
||||||
|
channel_id: String,
|
||||||
|
token: String,
|
||||||
|
ev_tx: mpsc::Sender<TunnelEvent>,
|
||||||
|
mut cmd_rx: mpsc::Receiver<TunnelCommand>,
|
||||||
|
shutdown: tokio::sync::watch::Receiver<bool>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let mut attempt: u32 = 0;
|
||||||
|
loop {
|
||||||
|
if *shutdown.borrow() {
|
||||||
|
info!("[commander] 收到关闭信号,退出");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
match connect_once(&relay_url, &channel_id, &token, &ev_tx, &mut cmd_rx, shutdown.clone()).await {
|
||||||
|
Ok(()) => {
|
||||||
|
info!("[commander] 连接正常关闭");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let wait = backoff_secs(attempt);
|
||||||
|
attempt += 1;
|
||||||
|
warn!("[commander] 连接断开({e}),{}s 后重连(第 {attempt} 次)", wait);
|
||||||
|
let mut shutdown_w = shutdown.clone();
|
||||||
|
tokio::select! {
|
||||||
|
_ = tokio::time::sleep(Duration::from_secs(wait)) => {}
|
||||||
|
_ = shutdown_w.changed() => return Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单次连接会话(hello 握手 + 消息循环),断开/出错返回 Err 触发外层重连
|
||||||
|
async fn connect_once(
|
||||||
|
relay_url: &str,
|
||||||
|
channel_id: &str,
|
||||||
|
token: &str,
|
||||||
|
ev_tx: &mpsc::Sender<TunnelEvent>,
|
||||||
|
cmd_rx: &mut mpsc::Receiver<TunnelCommand>,
|
||||||
|
mut shutdown: tokio::sync::watch::Receiver<bool>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let (ws, _resp) = tokio_tungstenite::connect_async(relay_url).await?;
|
||||||
|
let (mut sink, mut stream) = ws.split();
|
||||||
|
|
||||||
|
// hello 握手(kind=commander;字段与 u-relay::relay::Hello 严格一致)
|
||||||
|
let hello = Hello {
|
||||||
|
kind: HelloKind::Commander,
|
||||||
|
channel_id: channel_id.to_string(),
|
||||||
|
token: token.to_string(),
|
||||||
|
};
|
||||||
|
sink.send(Message::Text(serde_json::to_string(&hello)?)).await?;
|
||||||
|
|
||||||
|
// 等待 hello_ack(首条文本帧;control error 帧则报错)
|
||||||
|
let ack = tokio::time::timeout(Duration::from_secs(10), stream.next()).await
|
||||||
|
.map_err(|_| anyhow::anyhow!("hello_ack 10s 超时"))?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("连接在握手期关闭"))?;
|
||||||
|
match ack? {
|
||||||
|
Message::Text(t) => {
|
||||||
|
// relay 控制帧:{"kind":"control","error":"..."} → 握手被拒
|
||||||
|
if let Ok(ctrl_err) = serde_json::from_str::<RelayControlError>(&t) {
|
||||||
|
if ctrl_err.kind == "control" {
|
||||||
|
anyhow::bail!("握手被拒: {}", ctrl_err.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info!("[commander] hello_ack: channel={channel_id} 握手成功");
|
||||||
|
}
|
||||||
|
other => anyhow::bail!("hello_ack 非文本帧: {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 心跳:30s ping(relay 90s 读死线)
|
||||||
|
let mut ping = tokio::time::interval(Duration::from_secs(30));
|
||||||
|
ping.tick().await; // 首个立即返回,跳过
|
||||||
|
|
||||||
|
// 消息循环
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
_ = shutdown.changed() => {
|
||||||
|
let _ = sink.close().await;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
_ = ping.tick() => {
|
||||||
|
sink.send(Message::Ping(vec![])).await?;
|
||||||
|
}
|
||||||
|
cmd = cmd_rx.recv() => {
|
||||||
|
let Some(cmd) = cmd else {
|
||||||
|
// 核心侧通道关闭:正常收尾
|
||||||
|
let _ = sink.close().await;
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let frame = TunnelMessage::Command(cmd);
|
||||||
|
sink.send(Message::Text(serde_json::to_string(&frame)?)).await?;
|
||||||
|
}
|
||||||
|
msg = stream.next() => {
|
||||||
|
match msg {
|
||||||
|
Some(Ok(Message::Text(t))) => {
|
||||||
|
match serde_json::from_str::<TunnelMessage>(&t) {
|
||||||
|
Ok(TunnelMessage::Event(e)) => {
|
||||||
|
if ev_tx.send(e).await.is_err() {
|
||||||
|
return Err(anyhow::anyhow!("核心事件通道关闭"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(TunnelMessage::Command(_)) => {
|
||||||
|
warn!("[commander] commander 收到 Command 帧(协议错向,忽略)");
|
||||||
|
}
|
||||||
|
Err(_) => warn!("[commander] 无法解析帧(非 TunnelMessage,忽略): {} 字节", t.len()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(Ok(Message::Ping(p))) => { let _ = sink.send(Message::Pong(p)).await; }
|
||||||
|
Some(Ok(_)) => {}
|
||||||
|
Some(Err(e)) => return Err(e.into()),
|
||||||
|
None => return Err(anyhow::anyhow!("stream 结束")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backoff_sequence() {
|
||||||
|
assert_eq!(backoff_secs(0), 1);
|
||||||
|
assert_eq!(backoff_secs(1), 2);
|
||||||
|
assert_eq!(backoff_secs(2), 4);
|
||||||
|
assert_eq!(backoff_secs(3), 8);
|
||||||
|
assert_eq!(backoff_secs(4), 16);
|
||||||
|
assert_eq!(backoff_secs(5), 30);
|
||||||
|
assert_eq!(backoff_secs(99), 30, "封顶 30s");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tunnel_message_roundtrip() {
|
||||||
|
let cmd = TunnelMessage::Command(TunnelCommand::Send {
|
||||||
|
conv_id: "c1".into(),
|
||||||
|
content: "你好".into(),
|
||||||
|
});
|
||||||
|
let json = serde_json::to_string(&cmd).unwrap();
|
||||||
|
assert!(json.contains("\"command\""));
|
||||||
|
let back: TunnelMessage = serde_json::from_str(&json).unwrap();
|
||||||
|
match back {
|
||||||
|
TunnelMessage::Command(TunnelCommand::Send { conv_id, content }) => {
|
||||||
|
assert_eq!(conv_id, "c1");
|
||||||
|
assert_eq!(content, "你好");
|
||||||
|
}
|
||||||
|
_ => panic!("roundtrip 失败"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
//! 网关配置(env 加载,凭证不进 git)
|
||||||
|
//!
|
||||||
|
//! 配置来源:环境变量 > 默认值(gateway.toml 文件加载为后续增强,当前未实现)。
|
||||||
|
//! u-relay token 与桌面端 df-tunnel 同源(env U_RELAY_TOKEN)。
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
/// 网关全局配置
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct GatewayConfig {
|
||||||
|
/// u-relay WS 地址(生产 u-relay.1216.top,本地 http://127.0.0.1:9180)
|
||||||
|
pub relay_url: String,
|
||||||
|
/// relay 配对 token(env U_RELAY_TOKEN 同源)
|
||||||
|
pub relay_token: String,
|
||||||
|
/// channel 路由表:IM 侧默认走 default_channel;按前缀路由的多桌面端配置(预留项 ⑥)
|
||||||
|
#[serde(default)]
|
||||||
|
pub channels: Vec<ChannelRoute>,
|
||||||
|
/// 飞书应用凭证(未配置则该平台禁用)
|
||||||
|
#[serde(default)]
|
||||||
|
pub feishu: Option<FeishuConfig>,
|
||||||
|
/// TextDelta 节流窗口毫秒(默认 800,IM 频控友好)
|
||||||
|
#[serde(default = "default_throttle_ms")]
|
||||||
|
pub throttle_ms: u64,
|
||||||
|
/// 用户白名单(空=放行所有,生产部署务必显式配置)
|
||||||
|
///
|
||||||
|
/// 注:历史注释「空=拒绝所有」与 main.rs 实现(空=放行)矛盾,走查 2026-10-04 定为
|
||||||
|
/// 空=放行——否则首次启动零配置时飞书/Web 全部拒绝,连 banner 警告都没有,反而
|
||||||
|
/// 更难发现配置缺失。生产风险由 main.rs 启动 banner 常驻警告 + 白名单缺失提示兜底。
|
||||||
|
#[serde(default)]
|
||||||
|
pub whitelist: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// channel 路由项:IM 指令前缀 → 桌面端 channel
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct ChannelRoute {
|
||||||
|
/// 指令前缀(如 "/dev2 xxx" 路由到 chan-home);空串=默认 channel
|
||||||
|
pub prefix: String,
|
||||||
|
/// u-relay channel_id(桌面端 df-tunnel 配置的同一 channel)
|
||||||
|
pub channel_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 飞书企业自建应用凭证
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct FeishuConfig {
|
||||||
|
pub app_id: String,
|
||||||
|
pub app_secret: String,
|
||||||
|
/// 长连接模式事件订阅(免公网回调;当前恒 true,保留显式开关供回调模式扩展)
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub long_connection: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_throttle_ms() -> u64 {
|
||||||
|
800
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GatewayConfig {
|
||||||
|
/// 从 env + 可选 toml 文件构建。缺失关键项(relay_url/token)返回 Err。
|
||||||
|
pub fn load() -> anyhow::Result<Self> {
|
||||||
|
let relay_url = std::env::var("GATEWAY_RELAY_URL")
|
||||||
|
.unwrap_or_else(|_| "ws://127.0.0.1:9180/ws/commander".to_string());
|
||||||
|
let relay_token = std::env::var("GATEWAY_RELAY_TOKEN")
|
||||||
|
.or_else(|_| std::env::var("U_RELAY_TOKEN"))
|
||||||
|
.map_err(|_| anyhow::anyhow!("缺少 GATEWAY_RELAY_TOKEN / U_RELAY_TOKEN 环境变量"))?;
|
||||||
|
|
||||||
|
let feishu = match (
|
||||||
|
std::env::var("FEISHU_APP_ID"),
|
||||||
|
std::env::var("FEISHU_APP_SECRET"),
|
||||||
|
) {
|
||||||
|
(Ok(app_id), Ok(app_secret)) => Some(FeishuConfig {
|
||||||
|
app_id,
|
||||||
|
app_secret,
|
||||||
|
long_connection: true,
|
||||||
|
}),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let whitelist = std::env::var("GATEWAY_WHITELIST")
|
||||||
|
.map(|v| v.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let default_channel = std::env::var("GATEWAY_DEFAULT_CHANNEL").unwrap_or_default();
|
||||||
|
let channels = if default_channel.is_empty() {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
vec![ChannelRoute { prefix: String::new(), channel_id: default_channel }]
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
relay_url,
|
||||||
|
relay_token,
|
||||||
|
channels,
|
||||||
|
feishu,
|
||||||
|
throttle_ms: default_throttle_ms(),
|
||||||
|
whitelist,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 查指令应路由到的 channel(前缀匹配,空前缀=兜底默认)
|
||||||
|
pub fn route(&self, command_text: &str) -> Option<&str> {
|
||||||
|
// 精确前缀优先,空前缀兜底
|
||||||
|
self.channels
|
||||||
|
.iter()
|
||||||
|
.find(|c| !c.prefix.is_empty() && command_text.starts_with(&c.prefix))
|
||||||
|
.map(|c| c.channel_id.as_str())
|
||||||
|
.or_else(|| self.channels.iter().find(|c| c.prefix.is_empty()).map(|c| c.channel_id.as_str()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn route_prefix_priority() {
|
||||||
|
let cfg = GatewayConfig {
|
||||||
|
relay_url: String::new(),
|
||||||
|
relay_token: String::new(),
|
||||||
|
channels: vec![
|
||||||
|
ChannelRoute { prefix: "/dev2".into(), channel_id: "chan-home".into() },
|
||||||
|
ChannelRoute { prefix: String::new(), channel_id: "chan-work".into() },
|
||||||
|
],
|
||||||
|
feishu: None,
|
||||||
|
throttle_ms: 800,
|
||||||
|
whitelist: vec![],
|
||||||
|
};
|
||||||
|
assert_eq!(cfg.route("/dev2 修个 bug"), Some("chan-home"));
|
||||||
|
assert_eq!(cfg.route("普通指令"), Some("chan-work"));
|
||||||
|
assert_eq!(cfg.route("/dev2"), Some("chan-home"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn route_empty_when_no_channels() {
|
||||||
|
let cfg = GatewayConfig {
|
||||||
|
relay_url: String::new(),
|
||||||
|
relay_token: String::new(),
|
||||||
|
channels: vec![],
|
||||||
|
feishu: None,
|
||||||
|
throttle_ms: 800,
|
||||||
|
whitelist: vec![],
|
||||||
|
};
|
||||||
|
assert_eq!(cfg.route("任何"), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
//! 飞书适配器(P2):长连接事件 + 归一化消息 + 卡片审批
|
||||||
|
//!
|
||||||
|
//! 依赖 larksuite-oapi-sdk-rs features=["channel"](自动连带 ws+messaging)。
|
||||||
|
//! 长连接:飞书←→网关出站直连,不经 u-relay(u-relay 仅穿 NAT 段到桌面端)。
|
||||||
|
//!
|
||||||
|
//! 验证记录(2026-10-04,SDK 0.3.12 源码核对):
|
||||||
|
//! - 卡片回调:ChannelBuilder::on_card_action(NormalizedCardAction)
|
||||||
|
//! 内部走 dispatcher.on_card_action_trigger(channel/handler.rs:112),
|
||||||
|
//! 即新版 card.action.trigger 事件通道,长连接可收(遗留验证项 ✅)。
|
||||||
|
//! - 发卡片:SendInput.card(String) → INTERACTIVE 类型(messaging.rs:752)。
|
||||||
|
//! - 流式更新:channel_messaging().send() 拿 message_id 后 edit_text() 覆写全文。
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use larksuite_oapi_sdk_rs::card::v1::{
|
||||||
|
Action, ActionComponent, Button, ButtonType, Card, CardDocument, Div, Element, Header,
|
||||||
|
Markdown,
|
||||||
|
};
|
||||||
|
use larksuite_oapi_sdk_rs::channel::{Channel, ChannelPolicy};
|
||||||
|
use larksuite_oapi_sdk_rs::{EventDispatcher, LarkClient, LarkError, RequestOption};
|
||||||
|
use tokio::sync::mpsc::Sender;
|
||||||
|
|
||||||
|
use crate::adapters::{InboundEvent, OutboundAction, PlatformAdapter};
|
||||||
|
use crate::config::FeishuConfig;
|
||||||
|
|
||||||
|
/// 飞书适配器:channel 归一化消息 + 卡片审批,单飞书应用单实例
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct FeishuAdapter {
|
||||||
|
client: LarkClient,
|
||||||
|
/// 流式会话注册表:chat_id → 已发出的流式消息 message_id(用于 edit_text 覆写)
|
||||||
|
streams: Arc<tokio::sync::Mutex<HashMap<String, String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FeishuAdapter {
|
||||||
|
pub fn new(cfg: &FeishuConfig) -> anyhow::Result<Self> {
|
||||||
|
let client = LarkClient::builder(&cfg.app_id, &cfg.app_secret).build()?;
|
||||||
|
Ok(Self {
|
||||||
|
client,
|
||||||
|
streams: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 构造失败时的离线哨兵:client 用占位凭证(不启动 run,dispatch 记日志即弃)
|
||||||
|
pub fn offline() -> Self {
|
||||||
|
let client = LarkClient::builder("offline", "offline").build()
|
||||||
|
.expect("占位凭证构造 LarkClient 不发网络请求,必成功");
|
||||||
|
Self {
|
||||||
|
client,
|
||||||
|
streams: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl PlatformAdapter for FeishuAdapter {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"feishu"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 兜底清理流式残留(走查 #3):桌面端异常中止时无 Completed 事件,
|
||||||
|
/// streams entry 永驻导致下轮首帧错覆写旧消息。核心循环在新建会话映射时调用。
|
||||||
|
async fn reset_stream(&self, chat_id: &str) {
|
||||||
|
self.streams.lock().await.remove(chat_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run(&self, events: Sender<InboundEvent>) -> anyhow::Result<()> {
|
||||||
|
// 长连接模式:空 verification_token/encrypt_key(免公网回调)
|
||||||
|
let dispatcher = EventDispatcher::new("", "");
|
||||||
|
let client = self.client.clone();
|
||||||
|
|
||||||
|
// 入站事件通道复制两份,分别给消息/卡片回调闭包
|
||||||
|
let events_for_msg = events.clone();
|
||||||
|
let events_for_card = events;
|
||||||
|
|
||||||
|
let channel: Channel<'static> = {
|
||||||
|
// Channel<'a> 借用 client;用 Box::leak 提升为 'static(适配器生命周期即进程生命周期)
|
||||||
|
let leaked: &'static LarkClient = Box::leak(Box::new(client));
|
||||||
|
Channel::builder(leaked, dispatcher)
|
||||||
|
.policy(ChannelPolicy::default().require_mention(false))
|
||||||
|
.on_message(move |msg| {
|
||||||
|
let events = events_for_msg.clone();
|
||||||
|
async move {
|
||||||
|
handle_inbound_message(events, msg).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on_card_action(move |action| {
|
||||||
|
let events = events_for_card.clone();
|
||||||
|
async move {
|
||||||
|
handle_card_action(events, action).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.build()
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing::info!("[feishu] 长连接启动(免公网回调)");
|
||||||
|
channel.start().await.map_err(|e: LarkError| anyhow::anyhow!("飞书长连接退出: {e}"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn dispatch(&self, action: OutboundAction) -> anyhow::Result<()> {
|
||||||
|
let opt = &RequestOption::default();
|
||||||
|
match action {
|
||||||
|
OutboundAction::StreamText { chat_id, text, done } => {
|
||||||
|
// 两段式:锁内只取/写 message_id(纯内存操作),网络 IO 在锁外执行。
|
||||||
|
// 修复走查 #2:同 chat 仍串行(顺序语义),不同 chat 不再被同一把锁挡住。
|
||||||
|
let existing_id: Option<String> = {
|
||||||
|
let streams = self.streams.lock().await;
|
||||||
|
streams.get(&chat_id).cloned()
|
||||||
|
};
|
||||||
|
if let Some(message_id) = existing_id {
|
||||||
|
let res = self.client.channel_messaging().edit_text(&message_id, &text, opt).await;
|
||||||
|
if let Err(e) = res {
|
||||||
|
tracing::warn!("[feishu] 流式更新失败(消息可能已被撤回/超时): {e},回退新发");
|
||||||
|
self.streams.lock().await.remove(&chat_id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 首帧:发新文本消息,记录 message_id 供后续覆写
|
||||||
|
let input = larksuite_oapi_sdk_rs::channel::SendInput {
|
||||||
|
receive_id: Some(chat_id.clone()),
|
||||||
|
text: Some(text),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let send_result = self.client.channel_messaging().send(&input, opt).await;
|
||||||
|
match send_result {
|
||||||
|
Ok(sr) if !sr.message_id.is_empty() => {
|
||||||
|
self.streams.lock().await.insert(chat_id.clone(), sr.message_id);
|
||||||
|
}
|
||||||
|
Ok(_) => {
|
||||||
|
tracing::warn!("[feishu] 发送流式首帧未返回 message_id");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("[feishu] 发送流式首帧失败: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if done {
|
||||||
|
self.streams.lock().await.remove(&chat_id);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
OutboundAction::ApprovalCard { chat_id, approval_id, title, detail } => {
|
||||||
|
let card = build_approval_card(&approval_id, &title, &detail);
|
||||||
|
let document = CardDocument::new(card)
|
||||||
|
.map_err(|e| anyhow::anyhow!("审批卡片校验失败: {e}"))?;
|
||||||
|
// encoded_content 是 SDK pub(crate);直接序列化 card(校验已过)
|
||||||
|
let content = serde_json::to_string(document.card())
|
||||||
|
.map_err(|e| anyhow::anyhow!("卡片序列化失败: {e}"))?;
|
||||||
|
let input = larksuite_oapi_sdk_rs::channel::SendInput {
|
||||||
|
receive_id: Some(chat_id),
|
||||||
|
card: Some(content),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
self.client
|
||||||
|
.channel_messaging()
|
||||||
|
.send(&input, opt)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("审批卡片发送失败: {e}"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
OutboundAction::Notice { chat_id, text } => {
|
||||||
|
let input = larksuite_oapi_sdk_rs::channel::SendInput {
|
||||||
|
receive_id: Some(chat_id),
|
||||||
|
text: Some(text),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
self.client
|
||||||
|
.channel_messaging()
|
||||||
|
.send(&input, opt)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("通知发送失败: {e}"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 入站消息:群聊需 @机器人(mentioned_bot)或单聊直发
|
||||||
|
async fn handle_inbound_message(events: Sender<InboundEvent>, msg: larksuite_oapi_sdk_rs::channel::NormalizedMessage) {
|
||||||
|
// 只处理文本(text 优先,回退 content)
|
||||||
|
if msg.message_type != "text" {
|
||||||
|
tracing::debug!("[feishu] 忽略非文本消息 type={}", msg.message_type);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 群聊必须 @机器人;单聊(p2p)直发
|
||||||
|
if msg.chat_type == "group" && !msg.mentioned_bot {
|
||||||
|
tracing::debug!("[feishu] 群聊未 @机器人,忽略");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let text = msg.text.clone().unwrap_or_default().trim().to_string();
|
||||||
|
if text.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 指令:/new 重置会话
|
||||||
|
if text == "/new" {
|
||||||
|
let _ = events
|
||||||
|
.send(InboundEvent::NewSession {
|
||||||
|
platform: "feishu".into(),
|
||||||
|
chat_id: msg.chat_id.clone(),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let user_id = msg
|
||||||
|
.sender
|
||||||
|
.user_id
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|u| u.open_id.clone())
|
||||||
|
.unwrap_or_else(|| "unknown".into());
|
||||||
|
let _ = events
|
||||||
|
.send(InboundEvent::Message {
|
||||||
|
platform: "feishu".into(),
|
||||||
|
chat_id: msg.chat_id,
|
||||||
|
user_id,
|
||||||
|
text,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 卡片按钮回调:action_value 携带 approval_id + approved
|
||||||
|
async fn handle_card_action(events: Sender<InboundEvent>, action: larksuite_oapi_sdk_rs::channel::NormalizedCardAction) {
|
||||||
|
// 按钮约定的 value 形态:{"approval_id": "...", "approved": true|false}
|
||||||
|
let approval_id = action
|
||||||
|
.action_value
|
||||||
|
.get("approval_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string();
|
||||||
|
let approved = action
|
||||||
|
.action_value
|
||||||
|
.get("approved")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false);
|
||||||
|
if approval_id.is_empty() {
|
||||||
|
tracing::warn!("[feishu] 卡片回调缺 approval_id,忽略");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let chat_id = action.open_chat_id.clone().unwrap_or_default();
|
||||||
|
let _ = events
|
||||||
|
.send(InboundEvent::Approval {
|
||||||
|
platform: "feishu".into(),
|
||||||
|
chat_id,
|
||||||
|
approval_id,
|
||||||
|
approved,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 构造审批卡片(Card JSON 1.0):标题 + 详情 + 同意/拒绝按钮
|
||||||
|
fn build_approval_card(approval_id: &str, title: &str, detail: &str) -> Card {
|
||||||
|
let detail_md = format!("{}\n\n**审批 ID**: `{approval_id}`", escape_md(detail));
|
||||||
|
let to_json_value = |v: serde_json::Value| -> larksuite_oapi_sdk_rs::JsonValue {
|
||||||
|
serde_json::from_value(serde_json::to_value(v).expect("serde_json::Value 可逆"))
|
||||||
|
.expect("按钮 value 结构兼容")
|
||||||
|
};
|
||||||
|
let actions = Action::new()
|
||||||
|
.action(ActionComponent::Button(
|
||||||
|
Button::new(larksuite_oapi_sdk_rs::card::v1::Text::plain("✅ 同意"))
|
||||||
|
.button_type(ButtonType::Primary)
|
||||||
|
.value(to_json_value(serde_json::json!({"approval_id": approval_id, "approved": true}))),
|
||||||
|
))
|
||||||
|
.action(ActionComponent::Button(
|
||||||
|
Button::new(larksuite_oapi_sdk_rs::card::v1::Text::plain("❌ 拒绝"))
|
||||||
|
.button_type(ButtonType::Danger)
|
||||||
|
.value(to_json_value(serde_json::json!({"approval_id": approval_id, "approved": false}))),
|
||||||
|
));
|
||||||
|
Card::new()
|
||||||
|
.header(Header::new(title))
|
||||||
|
.element(Element::Div(Div::new(
|
||||||
|
larksuite_oapi_sdk_rs::card::v1::Text::lark_md(detail_md),
|
||||||
|
)))
|
||||||
|
.element(Element::Markdown(Markdown::new("---")))
|
||||||
|
.element(Element::Action(actions))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Markdown 转义(飞书卡片 lark_md 语法)
|
||||||
|
fn escape_md(s: &str) -> String {
|
||||||
|
s.replace('\\', "\\\\")
|
||||||
|
.replace('*', "\\*")
|
||||||
|
.replace('_', "\\_")
|
||||||
|
.replace('`', "\\`")
|
||||||
|
.replace('[', "\\[")
|
||||||
|
.replace(']', "\\]")
|
||||||
|
.replace('<', "\\<")
|
||||||
|
.replace('>', "\\>")
|
||||||
|
.replace('(', "\\(")
|
||||||
|
.replace(')', "\\)")
|
||||||
|
.replace('#', "\\#")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::config::FeishuConfig;
|
||||||
|
|
||||||
|
/// 构造冒烟:new/offline 均不发网络请求,验证适配器可实例化
|
||||||
|
#[test]
|
||||||
|
fn feishu_adapter_constructs() {
|
||||||
|
let cfg = FeishuConfig {
|
||||||
|
app_id: "cli_smoke".into(),
|
||||||
|
app_secret: "secret".into(),
|
||||||
|
long_connection: true,
|
||||||
|
};
|
||||||
|
let adapter = FeishuAdapter::new(&cfg).expect("适配器构造应成功");
|
||||||
|
assert_eq!(adapter.name(), "feishu");
|
||||||
|
|
||||||
|
let offline = FeishuAdapter::offline();
|
||||||
|
assert_eq!(offline.name(), "feishu");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 审批卡片构造 + 协议校验(CardDocument::new 是发送前的强校验门槛)
|
||||||
|
#[test]
|
||||||
|
fn approval_card_passes_protocol_validation() {
|
||||||
|
let card = build_approval_card("appr-123", "工具审批请求", "run_command: cargo test");
|
||||||
|
CardDocument::new(card).expect("审批卡片应通过 Card JSON 1.0 校验");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 卡片构造包含同意/拒绝按钮及 approval_id 载荷
|
||||||
|
#[test]
|
||||||
|
fn approval_card_has_buttons_and_payload() {
|
||||||
|
let card = build_approval_card("appr-456", "标题", "详情");
|
||||||
|
let json = serde_json::to_value(&card).unwrap();
|
||||||
|
let elements = json["elements"].as_array().expect("elements 数组");
|
||||||
|
// 最后一个元素是 Action 行,内含 2 个按钮
|
||||||
|
let action = elements.last().unwrap();
|
||||||
|
let buttons = action["actions"].as_array().expect("actions 数组");
|
||||||
|
assert_eq!(buttons.len(), 2);
|
||||||
|
let values: Vec<String> = buttons
|
||||||
|
.iter()
|
||||||
|
.map(|b| b["value"]["approval_id"].as_str().unwrap_or_default().to_string())
|
||||||
|
.collect();
|
||||||
|
assert!(values.iter().all(|v| v == "appr-456"));
|
||||||
|
let approved_flags: Vec<bool> = buttons
|
||||||
|
.iter()
|
||||||
|
.map(|b| b["value"]["approved"].as_bool().unwrap_or(false))
|
||||||
|
.collect();
|
||||||
|
assert!(approved_flags.contains(&true) && approved_flags.contains(&false));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Markdown 转义:卡片详情中的 markdown 语法字符被转义
|
||||||
|
#[test]
|
||||||
|
fn escape_md_works() {
|
||||||
|
assert_eq!(escape_md("a*b_c`d\\e"), "a\\*b\\_c\\`d\\\\e");
|
||||||
|
// 走查 #7:链接/标题/引用语法字符也须转义,防用户内容注入卡片结构
|
||||||
|
assert_eq!(escape_md("[x](http://e)#h <b>(y)"), "\\[x\\]\\(http://e\\)\\#h \\<b\\>\\(y\\)");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
//! df-im-gateway 主入口:装配 config + store + throttle + commander + adapters
|
||||||
|
//!
|
||||||
|
//! 骨架版(P0):Web 适配器真实可跑,事件流全链路打通。
|
||||||
|
//! 用法:
|
||||||
|
//! ```text
|
||||||
|
//! df-im-gateway.exe # 默认配置(env)
|
||||||
|
//! GATEWAY_RELAY_URL=... GATEWAY_RELAY_TOKEN=... \
|
||||||
|
//! GATEWAY_DEFAULT_CHANNEL=chan-work df-im-gateway.exe
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
mod adapters;
|
||||||
|
mod commander;
|
||||||
|
mod config;
|
||||||
|
mod feishu;
|
||||||
|
mod store;
|
||||||
|
mod throttle;
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
use adapters::{InboundEvent, OutboundAction, PlatformAdapter};
|
||||||
|
use config::GatewayConfig;
|
||||||
|
use store::SessionStore;
|
||||||
|
use throttle::DeltaThrottle;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(
|
||||||
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||||
|
)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let cfg = GatewayConfig::load()?;
|
||||||
|
if cfg.channels.is_empty() {
|
||||||
|
anyhow::bail!("未配置任何 channel 路由(GATEWAY_DEFAULT_CHANNEL),无法启动");
|
||||||
|
}
|
||||||
|
|
||||||
|
let data_dir = std::env::var("GATEWAY_DATA_DIR")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|_| PathBuf::from("data"));
|
||||||
|
std::fs::create_dir_all(&data_dir)?;
|
||||||
|
let store = SessionStore::open(&data_dir.join("sessions.db"))?;
|
||||||
|
|
||||||
|
info_banner(&cfg);
|
||||||
|
|
||||||
|
// ── 核心事件总线:适配器 → 核心 ─────────────────────────────
|
||||||
|
let (inbound_tx, mut inbound_rx) = mpsc::channel::<InboundEvent>(256);
|
||||||
|
|
||||||
|
// Web 适配器(四端首个,P0)
|
||||||
|
let web_adapter = adapters::web::WebAdapter::new();
|
||||||
|
{
|
||||||
|
let web_run = web_adapter.clone();
|
||||||
|
let ev_tx = inbound_tx.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = web_run.run(ev_tx).await {
|
||||||
|
tracing::error!("[web] 适配器退出: {e}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 飞书适配器(P2):FEISHU_APP_ID/SECRET 配置时启用长连接
|
||||||
|
let feishu_adapter = cfg.feishu.as_ref().map(|f| match feishu::FeishuAdapter::new(f) {
|
||||||
|
Ok(adapter) => {
|
||||||
|
let feishu_run = adapter.clone();
|
||||||
|
let ev_tx = inbound_tx.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = feishu_run.run(ev_tx).await {
|
||||||
|
tracing::error!("[feishu] 适配器退出: {e}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tracing::info!("[feishu] 适配器已启动(长连接)");
|
||||||
|
adapter
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("[feishu] 适配器构造失败(跳过): {e}");
|
||||||
|
feishu::FeishuAdapter::offline()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Commander 连接(u-relay)────────────────────────────────
|
||||||
|
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
|
||||||
|
let default_channel = cfg
|
||||||
|
.channels
|
||||||
|
.iter()
|
||||||
|
.find(|c| c.prefix.is_empty())
|
||||||
|
.map(|c| c.channel_id.clone())
|
||||||
|
.unwrap_or_else(|| cfg.channels[0].channel_id.clone());
|
||||||
|
|
||||||
|
// 事件流:commander 任务 → 核心循环
|
||||||
|
let (ev_tx, mut ev_rx) = mpsc::channel::<df_tunnel::events::TunnelEvent>(256);
|
||||||
|
// 指令流:核心循环 → commander 任务(透传到 WS)
|
||||||
|
let (cmd_tx, cmd_rx) = mpsc::channel::<df_tunnel::TunnelCommand>(256);
|
||||||
|
|
||||||
|
{
|
||||||
|
let relay_url = cfg.relay_url.clone();
|
||||||
|
let token = cfg.relay_token.clone();
|
||||||
|
let channel = default_channel.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = commander::run_commander(relay_url, channel, token, ev_tx, cmd_rx, shutdown_rx).await {
|
||||||
|
tracing::error!("[commander] 连接循环退出: {e}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 核心分发循环(单 select,持有全部状态,无跨线程共享)─────
|
||||||
|
let mut throttle = DeltaThrottle::new(cfg.throttle_ms);
|
||||||
|
// 审批幂等表:approval_id → 已处理标记(P0 必验项 ②:防 pending→approved 二次点击重复转发)
|
||||||
|
let mut handled_approvals: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||||
|
// 平台注册表(走查 #4):显式 platform → 下标映射,替代硬编码 "feishu"=>1 与
|
||||||
|
// adapters vec 构建顺序的隐式耦合。新增平台(企微/钉钉)只在此处注册一次。
|
||||||
|
let mut adapters: Vec<Box<dyn adapters::PlatformAdapter>> = vec![Box::new(web_adapter.clone())];
|
||||||
|
let mut platform_index: std::collections::HashMap<String, usize> =
|
||||||
|
std::collections::HashMap::from([("web".to_string(), 0usize)]);
|
||||||
|
if let Some(fs) = feishu_adapter {
|
||||||
|
platform_index.insert("feishu".to_string(), adapters.len());
|
||||||
|
adapters.push(Box::new(fs));
|
||||||
|
}
|
||||||
|
let platform_of = |platform_or_conv: &str| -> usize {
|
||||||
|
// conv_id 形如 "platform:chat_id",前缀即平台;未知平台回退 web(0)
|
||||||
|
let platform = platform_or_conv.split(':').next().unwrap_or("web");
|
||||||
|
*platform_index.get(platform).unwrap_or(&0)
|
||||||
|
};
|
||||||
|
// 停止信号
|
||||||
|
let shutdown_ctrl = tokio::signal::ctrl_c();
|
||||||
|
tokio::pin!(shutdown_ctrl);
|
||||||
|
let cfg_loop = cfg.clone();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
_ = &mut shutdown_ctrl => {
|
||||||
|
tracing::info!("收到 Ctrl+C,优雅退出");
|
||||||
|
let _ = shutdown_tx.send(true);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// ── TunnelEvent(桌面端 → 网关)──
|
||||||
|
ev = ev_rx.recv() => {
|
||||||
|
let Some(ev) = ev else { break };
|
||||||
|
match ev {
|
||||||
|
df_tunnel::TunnelEvent::TextDelta { conv_id, delta } => {
|
||||||
|
// 出站按 conv_id 前缀(platform:chat_id)路由回原平台
|
||||||
|
let flush = throttle.feed(&conv_id, &delta);
|
||||||
|
if let Some(text) = flush {
|
||||||
|
let idx = platform_of(&conv_id);
|
||||||
|
let _ = adapters[idx]
|
||||||
|
.dispatch(OutboundAction::StreamText { chat_id: conv_id, text, done: false })
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
df_tunnel::TunnelEvent::Completed { conv_id, .. } => {
|
||||||
|
let tail = throttle.finish(&conv_id);
|
||||||
|
let full = tail.unwrap_or_default();
|
||||||
|
let idx = platform_of(&conv_id);
|
||||||
|
let _ = adapters[idx]
|
||||||
|
.dispatch(OutboundAction::StreamText { chat_id: conv_id, text: full, done: true })
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
df_tunnel::TunnelEvent::Approval { conv_id, approval_id, description } => {
|
||||||
|
let idx = platform_of(&conv_id);
|
||||||
|
let _ = adapters[idx]
|
||||||
|
.dispatch(OutboundAction::ApprovalCard {
|
||||||
|
chat_id: conv_id,
|
||||||
|
approval_id,
|
||||||
|
title: "工具审批请求".into(),
|
||||||
|
detail: description,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
df_tunnel::TunnelEvent::ToolCall { .. } => {
|
||||||
|
// P0 骨架不转发 ToolCall 到 IM(卡片噪音),仅日志
|
||||||
|
tracing::debug!("[core] ToolCall 事件(骨架忽略)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ── InboundEvent(IM → 网关)──
|
||||||
|
inbound = inbound_rx.recv() => {
|
||||||
|
let Some(ev) = inbound else { break };
|
||||||
|
match ev {
|
||||||
|
InboundEvent::Message { platform, chat_id, user_id, text } => {
|
||||||
|
// 回复适配器:按来源平台回路由
|
||||||
|
let reply_idx = platform_of(&platform);
|
||||||
|
if !cfg_loop.whitelist.is_empty() && !cfg_loop.whitelist.contains(&user_id) {
|
||||||
|
let _ = adapters[reply_idx]
|
||||||
|
.dispatch(OutboundAction::Notice { chat_id, text: "⛔ 你不在白名单内".into() })
|
||||||
|
.await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(channel_id) = cfg_loop.route(&text) else {
|
||||||
|
let _ = adapters[reply_idx]
|
||||||
|
.dispatch(OutboundAction::Notice { chat_id, text: "⚠️ 无可用 channel".into() })
|
||||||
|
.await;
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
// 会话映射:无则新建(conv_id = platform:chat_id,Web 端 chat_id 同源)
|
||||||
|
let conv_id = match store.get_conv(&platform, &chat_id) {
|
||||||
|
Some(m) => m.conv_id,
|
||||||
|
None => {
|
||||||
|
let conv = format!("{platform}:{chat_id}");
|
||||||
|
let _ = store.set_conv(&platform, &chat_id, &conv, channel_id);
|
||||||
|
// 走查 #3:新建会话时清理流式残留(上轮异常中止无
|
||||||
|
// Completed 事件,streams entry 永驻会错覆写旧消息)
|
||||||
|
adapters[reply_idx].reset_stream(&chat_id).await;
|
||||||
|
conv
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let _ = cmd_tx
|
||||||
|
.send(df_tunnel::TunnelCommand::Send { conv_id, content: text })
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
InboundEvent::NewSession { platform, chat_id } => {
|
||||||
|
let _ = store.remove(&platform, &chat_id);
|
||||||
|
let reply_idx = platform_of(&platform);
|
||||||
|
let _ = adapters[reply_idx]
|
||||||
|
.dispatch(OutboundAction::Notice { chat_id, text: "🔄 已重置会话".into() })
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
InboundEvent::Approval { platform, chat_id, approval_id, approved } => {
|
||||||
|
// 幂等:同 approval_id 只处理首次(P0 必验项 ②)
|
||||||
|
if handled_approvals.contains(&approval_id) {
|
||||||
|
tracing::debug!("[core] 审批 {approval_id} 已处理,忽略重复点击");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
handled_approvals.insert(approval_id.clone());
|
||||||
|
let conv_id = store
|
||||||
|
.get_conv(&platform, &chat_id)
|
||||||
|
.map(|m| m.conv_id)
|
||||||
|
.unwrap_or_else(|| format!("{platform}:{chat_id}"));
|
||||||
|
let _ = cmd_tx
|
||||||
|
.send(df_tunnel::TunnelCommand::Approve { conv_id, approval_id, accepted: approved })
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn info_banner(cfg: &GatewayConfig) {
|
||||||
|
println!("╔══════════════════════════════════════════╗");
|
||||||
|
println!("║ df-im-gateway v0.1.0 (P0 骨架) ║");
|
||||||
|
println!("╚══════════════════════════════════════════╝");
|
||||||
|
println!(" relay : {}", cfg.relay_url);
|
||||||
|
println!(" channels : {} 条路由", cfg.channels.len());
|
||||||
|
println!(" throttle : {}ms", cfg.throttle_ms);
|
||||||
|
println!(" whitelist : {} 用户", cfg.whitelist.len());
|
||||||
|
if cfg.whitelist.is_empty() {
|
||||||
|
println!(" ⚠️ 白名单为空:任何人可用本网关(开发默认)。生产部署请设置 GATEWAY_WHITELIST!");
|
||||||
|
}
|
||||||
|
println!(" feishu : {}", if cfg.feishu.is_some() { "已配置" } else { "未配置(跳过)" });
|
||||||
|
println!(" web ws : 0.0.0.0:9191");
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
//! 会话映射存储(IM chat_id ↔ DevFlow conv_id,SQLite + WAL)
|
||||||
|
//!
|
||||||
|
//! 建表即带 owner_user_id/platform 列(P0 预留项 ⑤,当前恒默认值,
|
||||||
|
//! S2 多人场景免迁表)。
|
||||||
|
//!
|
||||||
|
//! 表结构:
|
||||||
|
//! ```sql
|
||||||
|
//! CREATE TABLE IF NOT EXISTS session_map (
|
||||||
|
//! platform TEXT NOT NULL, -- feishu/wecom/dingtalk/web
|
||||||
|
//! chat_id TEXT NOT NULL, -- IM 侧会话(单聊 user_id / 群聊 chat_id)
|
||||||
|
//! conv_id TEXT NOT NULL, -- DevFlow 会话 ID
|
||||||
|
//! owner_user_id TEXT NOT NULL DEFAULT '',-- 预留 S2 多人归属(当前恒 '')
|
||||||
|
//! channel_id TEXT NOT NULL DEFAULT '',-- 路由到的桌面端 channel(预留项 ⑥)
|
||||||
|
//! created_at INTEGER NOT NULL,
|
||||||
|
//! updated_at INTEGER NOT NULL,
|
||||||
|
//! PRIMARY KEY (platform, chat_id)
|
||||||
|
//! );
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use rusqlite::Connection;
|
||||||
|
|
||||||
|
/// 单条会话映射(字段预留 S2 多人/多桌面端,当前核心只读 conv_id)
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct SessionMap {
|
||||||
|
pub platform: String,
|
||||||
|
pub chat_id: String,
|
||||||
|
pub conv_id: String,
|
||||||
|
pub owner_user_id: String,
|
||||||
|
pub channel_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct SessionStore {
|
||||||
|
conn: Arc<std::sync::Mutex<Connection>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionStore {
|
||||||
|
/// 打开(或创建)库文件并确保表存在。WAL 模式(与桌面端一致)。
|
||||||
|
pub fn open(path: &Path) -> anyhow::Result<Self> {
|
||||||
|
let conn = Connection::open(path)?;
|
||||||
|
conn.pragma_update(None, "journal_mode", "WAL")?;
|
||||||
|
conn.execute_batch(
|
||||||
|
"CREATE TABLE IF NOT EXISTS session_map (
|
||||||
|
platform TEXT NOT NULL,
|
||||||
|
chat_id TEXT NOT NULL,
|
||||||
|
conv_id TEXT NOT NULL,
|
||||||
|
owner_user_id TEXT NOT NULL DEFAULT '',
|
||||||
|
channel_id TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (platform, chat_id)
|
||||||
|
);",
|
||||||
|
)?;
|
||||||
|
Ok(Self { conn: Arc::new(std::sync::Mutex::new(conn)) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 内存库(测试/演示)
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn open_in_memory() -> anyhow::Result<Self> {
|
||||||
|
let conn = Connection::open_in_memory()?;
|
||||||
|
conn.execute_batch(
|
||||||
|
"CREATE TABLE IF NOT EXISTS session_map (
|
||||||
|
platform TEXT NOT NULL, chat_id TEXT NOT NULL, conv_id TEXT NOT NULL,
|
||||||
|
owner_user_id TEXT NOT NULL DEFAULT '', channel_id TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (platform, chat_id)
|
||||||
|
);",
|
||||||
|
)?;
|
||||||
|
Ok(Self { conn: Arc::new(std::sync::Mutex::new(conn)) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 查 IM 会话映射的 conv_id(未映射返回 None)
|
||||||
|
pub fn get_conv(&self, platform: &str, chat_id: &str) -> Option<SessionMap> {
|
||||||
|
let conn = self.conn.lock().ok()?;
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT platform, chat_id, conv_id, owner_user_id, channel_id FROM session_map \
|
||||||
|
WHERE platform = ?1 AND chat_id = ?2",
|
||||||
|
[platform, chat_id],
|
||||||
|
|row| {
|
||||||
|
Ok(SessionMap {
|
||||||
|
platform: row.get(0)?,
|
||||||
|
chat_id: row.get(1)?,
|
||||||
|
conv_id: row.get(2)?,
|
||||||
|
owner_user_id: row.get(3)?,
|
||||||
|
channel_id: row.get(4)?,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 写/更新映射(upsert)
|
||||||
|
pub fn set_conv(&self, platform: &str, chat_id: &str, conv_id: &str, channel_id: &str) -> anyhow::Result<()> {
|
||||||
|
let conn = self.conn.lock().map_err(|e| anyhow::anyhow!("store 锁中毒: {e}"))?;
|
||||||
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_millis() as i64)
|
||||||
|
.unwrap_or(0);
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO session_map (platform, chat_id, conv_id, owner_user_id, channel_id, created_at, updated_at) \
|
||||||
|
VALUES (?1, ?2, ?3, '', ?4, ?5, ?5) \
|
||||||
|
ON CONFLICT(platform, chat_id) DO UPDATE SET conv_id = ?3, channel_id = ?4, updated_at = ?5",
|
||||||
|
rusqlite::params![platform, chat_id, conv_id, channel_id, now],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除映射(桌面端会话已删,悬空清理)
|
||||||
|
pub fn remove(&self, platform: &str, chat_id: &str) -> anyhow::Result<()> {
|
||||||
|
let conn = self.conn.lock().map_err(|e| anyhow::anyhow!("store 锁中毒: {e}"))?;
|
||||||
|
conn.execute("DELETE FROM session_map WHERE platform = ?1 AND chat_id = ?2", [platform, chat_id])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn upsert_and_get() {
|
||||||
|
let store = SessionStore::open_in_memory().unwrap();
|
||||||
|
store.set_conv("feishu", "oc_123", "conv_abc", "chan-work").unwrap();
|
||||||
|
let m = store.get_conv("feishu", "oc_123").unwrap();
|
||||||
|
assert_eq!(m.conv_id, "conv_abc");
|
||||||
|
assert_eq!(m.channel_id, "chan-work");
|
||||||
|
assert_eq!(m.owner_user_id, "");
|
||||||
|
|
||||||
|
// upsert 覆盖
|
||||||
|
store.set_conv("feishu", "oc_123", "conv_new", "chan-home").unwrap();
|
||||||
|
let m = store.get_conv("feishu", "oc_123").unwrap();
|
||||||
|
assert_eq!(m.conv_id, "conv_new");
|
||||||
|
|
||||||
|
// 删除后悬空
|
||||||
|
store.remove("feishu", "oc_123").unwrap();
|
||||||
|
assert!(store.get_conv("feishu", "oc_123").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn platform_isolated() {
|
||||||
|
let store = SessionStore::open_in_memory().unwrap();
|
||||||
|
store.set_conv("feishu", "c1", "conv1", "ch").unwrap();
|
||||||
|
store.set_conv("wecom", "c1", "conv2", "ch").unwrap();
|
||||||
|
assert_eq!(store.get_conv("feishu", "c1").unwrap().conv_id, "conv1");
|
||||||
|
assert_eq!(store.get_conv("wecom", "c1").unwrap().conv_id, "conv2");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
//! TextDelta 节流聚合器(IM 频控防护)
|
||||||
|
//!
|
||||||
|
//! IM 平台消息 API 频控(飞书 ~50 QPS 应用级、企微 200 次/分钟),逐 delta 刷卡片必被限流。
|
||||||
|
//! 策略:per-conv 聚合缓冲 + 时间窗(默认 800ms)批量 flush;Completed 时强制冲刷尾包。
|
||||||
|
//!
|
||||||
|
//! 设计要点:
|
||||||
|
//! - 每个 key 一个缓冲,互不干扰
|
||||||
|
//! - 纯函数式:feed() 返回 Option<全量文本>(窗口到期才返回),不持有回调/不 await
|
||||||
|
//! - flush 语义:全量文本(IM 卡片更新是「全量内容」语义,非增量)
|
||||||
|
//! - finish() 冲刷尾包并清缓冲;abort() 丢弃缓冲(错误/停止,不输出残文)
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
/// 单 key 缓冲
|
||||||
|
struct ConvBuffer {
|
||||||
|
/// 累积全量文本
|
||||||
|
full_text: String,
|
||||||
|
/// 上次 flush 时刻
|
||||||
|
last_flush: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 节流聚合器
|
||||||
|
pub struct DeltaThrottle {
|
||||||
|
window: Duration,
|
||||||
|
buffers: HashMap<String, ConvBuffer>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeltaThrottle {
|
||||||
|
pub fn new(window_ms: u64) -> Self {
|
||||||
|
Self { window: Duration::from_millis(window_ms), buffers: HashMap::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 喂入 delta。窗口到期则返回 Some(全量文本)(调用方负责外发),否则 None。
|
||||||
|
pub fn feed(&mut self, key: &str, delta: &str) -> Option<String> {
|
||||||
|
let now = Instant::now();
|
||||||
|
let buf = self.buffers.entry(key.to_string()).or_insert_with(|| ConvBuffer {
|
||||||
|
full_text: String::new(),
|
||||||
|
last_flush: now,
|
||||||
|
});
|
||||||
|
buf.full_text.push_str(delta);
|
||||||
|
if buf.last_flush.elapsed() >= self.window {
|
||||||
|
buf.last_flush = now;
|
||||||
|
Some(buf.full_text.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 会话完成:强制冲刷尾包 + 清缓冲。返回 Some(全量文本) 或(空会话)None。
|
||||||
|
pub fn finish(&mut self, key: &str) -> Option<String> {
|
||||||
|
self.buffers.remove(key).map(|buf| buf.full_text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 丢弃缓冲(错误/停止,不输出残文)
|
||||||
|
///
|
||||||
|
/// 注:当前 TunnelEvent 无 Error/Stopped 变体,核心循环暂无调用点;
|
||||||
|
/// df-tunnel Phase2 扩展后接入。
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn abort(&mut self, key: &str) {
|
||||||
|
self.buffers.remove(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn throttle_window_batch() {
|
||||||
|
let mut t = DeltaThrottle::new(60_000); // 60s 窗口:模拟窗口永不到期
|
||||||
|
assert_eq!(t.feed("k", "你"), None);
|
||||||
|
assert_eq!(t.feed("k", "好"), None);
|
||||||
|
assert_eq!(t.feed("k", "!"), None);
|
||||||
|
assert_eq!(t.finish("k"), Some("你好!".to_string()), "finish 冲刷全量");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn window_elapsed_flushes() {
|
||||||
|
let mut t = DeltaThrottle::new(0); // 0ms 窗口:每次 feed 都到期
|
||||||
|
assert_eq!(t.feed("k", "a"), Some("a".to_string()));
|
||||||
|
assert_eq!(t.feed("k", "b"), Some("ab".to_string()));
|
||||||
|
// 缓冲仍在 finish 前的最后 flush 版本;finish 拿走剩余
|
||||||
|
assert_eq!(t.finish("k"), Some("ab".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn abort_drops() {
|
||||||
|
let mut t = DeltaThrottle::new(60_000);
|
||||||
|
t.feed("k", "残文");
|
||||||
|
t.abort("k");
|
||||||
|
assert_eq!(t.finish("k"), None, "abort 后缓冲不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keys_isolated() {
|
||||||
|
let mut t = DeltaThrottle::new(60_000);
|
||||||
|
t.feed("a", "1");
|
||||||
|
t.feed("b", "2");
|
||||||
|
assert_eq!(t.finish("a"), Some("1".to_string()));
|
||||||
|
assert_eq!(t.finish("b"), Some("2".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -644,6 +644,7 @@ fn create_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
|||||||
queue,
|
queue,
|
||||||
parent_id,
|
parent_id,
|
||||||
content_json,
|
content_json,
|
||||||
|
archived: false,
|
||||||
created_at: now.clone(),
|
created_at: now.clone(),
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
@@ -705,6 +706,8 @@ fn update_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
|||||||
parent_id: existing.parent_id,
|
parent_id: existing.parent_id,
|
||||||
module_id: existing.module_id,
|
module_id: existing.module_id,
|
||||||
content_json: existing.content_json,
|
content_json: existing.content_json,
|
||||||
|
// 归档标记不经 MCP 修改(归档走桌面端 archive_task 专用 IPC,防旁路)
|
||||||
|
archived: existing.archived,
|
||||||
created_at: existing.created_at,
|
created_at: existing.created_at,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
@@ -819,6 +822,7 @@ fn create_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
|||||||
ai_analysis: None,
|
ai_analysis: None,
|
||||||
scores: None,
|
scores: None,
|
||||||
related_ids: None,
|
related_ids: None,
|
||||||
|
archived: false,
|
||||||
created_at: now.clone(),
|
created_at: now.clone(),
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
@@ -874,6 +878,7 @@ fn update_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
|||||||
ai_analysis: existing.ai_analysis,
|
ai_analysis: existing.ai_analysis,
|
||||||
scores: existing.scores,
|
scores: existing.scores,
|
||||||
related_ids: existing.related_ids.clone(),
|
related_ids: existing.related_ids.clone(),
|
||||||
|
archived: existing.archived,
|
||||||
created_at: existing.created_at,
|
created_at: existing.created_at,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
@@ -1239,6 +1244,7 @@ mod tests {
|
|||||||
ai_analysis: None,
|
ai_analysis: None,
|
||||||
scores: None,
|
scores: None,
|
||||||
related_ids: None,
|
related_ids: None,
|
||||||
|
archived: false,
|
||||||
created_at: now.clone(),
|
created_at: now.clone(),
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
@@ -1285,6 +1291,7 @@ mod tests {
|
|||||||
queue: "todo".to_string(),
|
queue: "todo".to_string(),
|
||||||
parent_id: None,
|
parent_id: None,
|
||||||
content_json: None,
|
content_json: None,
|
||||||
|
archived: false,
|
||||||
created_at: now.clone(),
|
created_at: now.clone(),
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -265,6 +265,7 @@ mod tests {
|
|||||||
queue: "todo".to_string(),
|
queue: "todo".to_string(),
|
||||||
parent_id: None,
|
parent_id: None,
|
||||||
content_json: None,
|
content_json: None,
|
||||||
|
archived: false,
|
||||||
created_at: "0".to_string(),
|
created_at: "0".to_string(),
|
||||||
updated_at: "0".to_string(),
|
updated_at: "0".to_string(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -340,6 +340,7 @@ mod tests {
|
|||||||
queue: "todo".to_string(),
|
queue: "todo".to_string(),
|
||||||
parent_id: None,
|
parent_id: None,
|
||||||
content_json: None,
|
content_json: None,
|
||||||
|
archived: false,
|
||||||
created_at: "0".to_string(),
|
created_at: "0".to_string(),
|
||||||
updated_at: "0".to_string(),
|
updated_at: "0".to_string(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,9 +8,14 @@
|
|||||||
//!
|
//!
|
||||||
//! 两条调用路径复用同一推进逻辑:
|
//! 两条调用路径复用同一推进逻辑:
|
||||||
//! - DAG 工作流:TaskAdvanceNode 实现 Node trait,从 NodeContext.config 读 task_id /
|
//! - DAG 工作流:TaskAdvanceNode 实现 Node trait,从 NodeContext.config 读 task_id /
|
||||||
//! target_status,持有 Arc<Database> 构造 TaskRepo。
|
//! target_status,持有 Arc<Database> 构造 TaskRepo(严格单跳,advance_task_atomic)。
|
||||||
//! - IPC 直驱:advance_task_atomic 公开 async fn,src-tauri commands::task::advance_task
|
//! - IPC 直驱:advance_task_atomic 公开 async fn,src-tauri commands::task::advance_task
|
||||||
//! thin 入口直接调用(无需构造 NodeContext,避免 EventBus/StateMachine 等重依赖)。
|
//! thin 入口直接调用(无需构造 NodeContext,避免 EventBus/StateMachine 等重依赖)。
|
||||||
|
//!
|
||||||
|
//! auto_path 层(默认开启):advance_task_with_parent(IPC/AI 工具/MCP 同源入口)走
|
||||||
|
//! advance_task_auto_path,跳态目标按 shortest_path 自动逐跳推进(每跳独立校验 +
|
||||||
|
//! review_rounds 回归计数照旧);直达单跳与严格路径行为一致,workflow.rs 回调等
|
||||||
|
//! 机器生成单跳场景仍直用 advance_task_atomic 保持严格语义。
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -113,6 +118,69 @@ pub async fn advance_task_atomic(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// auto_path — 跳态目标自动逐跳推进(治 LLM 直取跳态被状态机拒绝的空耗往返)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/// advance_task 自动路径推进(auto_path):目标不可直达时按最短路径逐跳推进。
|
||||||
|
///
|
||||||
|
/// 背景:实测 LLM 反复请求跳态推进(todo→done / in_progress→done),错误文本虽已附
|
||||||
|
/// shortest_path 提示,LLM 仍不走多跳,空耗往返。根修:跳态目标自动按
|
||||||
|
/// [`crate::task_state_machine::shortest_path`] 逐跳推进,每跳独立走
|
||||||
|
/// [`advance_task_atomic`](状态机校验 + 原子 CAS + review_rounds 回归计数规则照旧,
|
||||||
|
/// 如路径途经 in_review→in_progress 退回边照样 +1)。
|
||||||
|
///
|
||||||
|
/// 分派:
|
||||||
|
/// - 直达/委托(路径长 0 或 2):交 [`advance_task_atomic`] 单跳完成,同态/非法
|
||||||
|
/// target/终态不可达的错误文案零变更。
|
||||||
|
/// - 多跳(路径长 > 2):逐跳推进,任一跳失败即止(任务停在中间态,错误注明第几跳;
|
||||||
|
/// 不回滚已完成跳——回滚本身也要过状态机)。并发改动致某跳 CAS 失败时,调用方
|
||||||
|
/// 重读任务现状再决策即可。
|
||||||
|
///
|
||||||
|
/// 返回 (最终 TaskRecord, 实际路径):路径含首尾([from, ..., to]),直达时长 2、
|
||||||
|
/// 多跳时长 > 2,供调用方在 result 透出实际推进路径。
|
||||||
|
pub async fn advance_task_auto_path(
|
||||||
|
repo: &TaskRepo,
|
||||||
|
id: &str,
|
||||||
|
target_status: &str,
|
||||||
|
) -> df_types::error::Result<(TaskRecord, Vec<&'static str>)> {
|
||||||
|
let current = repo
|
||||||
|
.get_by_id(id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| df_types::error::Error::NotFound(format!("任务 {} 不存在", id)))?;
|
||||||
|
let from = current.status.as_str();
|
||||||
|
let path = crate::task_state_machine::shortest_path(from, target_status);
|
||||||
|
|
||||||
|
let record = if path.len() > 2 {
|
||||||
|
let mut record = current;
|
||||||
|
for (idx, &to) in path.iter().enumerate().skip(1) {
|
||||||
|
record = advance_task_atomic(repo, id, to).await.map_err(|e| match e {
|
||||||
|
// 任务被删(NotFound)原样透传;其余(状态机/CAS 冲突)包上跳次上下文
|
||||||
|
nf @ df_types::error::Error::NotFound(_) => nf,
|
||||||
|
other => df_types::error::Error::InvalidState {
|
||||||
|
current: format!(
|
||||||
|
"自动路径第 {}/{} 跳({} → {})失败: {other};任务停在中间态,请重读任务现状后再决策",
|
||||||
|
idx,
|
||||||
|
path.len() - 1,
|
||||||
|
path[idx - 1],
|
||||||
|
to
|
||||||
|
),
|
||||||
|
expected: to.to_string(),
|
||||||
|
},
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
record
|
||||||
|
} else {
|
||||||
|
// 直达(含同态/非法 target/终态不可达):atomic 原样校验与报错,语义零变更
|
||||||
|
advance_task_atomic(repo, id, target_status).await?
|
||||||
|
};
|
||||||
|
|
||||||
|
// 路径承载:shortest_path 产物全为 ALL_STATES 常量(&'static);空路径(同态/
|
||||||
|
// 非法/不可达)不会走到成功返回,此处兜底空列表。
|
||||||
|
let full_path = if path.len() >= 2 { path } else { Vec::new() };
|
||||||
|
Ok((record, full_path))
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 父任务聚合 — 父任务 status 重算 + 推进联动(容器模型,设计 §2.1)
|
// 父任务聚合 — 父任务 status 重算 + 推进联动(容器模型,设计 §2.1)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -200,7 +268,10 @@ pub async fn recompute_parent_status(
|
|||||||
/// 推进任务 + 若为子任务则触发父聚合(父聚合失败仅 warn 不阻断,宽容语义)。
|
/// 推进任务 + 若为子任务则触发父聚合(父聚合失败仅 warn 不阻断,宽容语义)。
|
||||||
///
|
///
|
||||||
/// 推进链唯一 status 写入路径的两段式(设计 D3 统一,IPC/AI 工具/MCP 同源):
|
/// 推进链唯一 status 写入路径的两段式(设计 D3 统一,IPC/AI 工具/MCP 同源):
|
||||||
/// 1. 推进自身:调 `advance_task_atomic`(状态机校验 + 原子 CAS + review_rounds 累加)。
|
/// 1. 推进自身:调 `advance_task_auto_path`(auto_path 默认开启:跳态目标按
|
||||||
|
/// shortest_path 自动逐跳推进,治实测 LLM todo→done / in_progress→done 直取被
|
||||||
|
/// 状态机拒绝后不走多跳的空耗往返;直达单跳行为与原 advance_task_atomic 一致)。
|
||||||
|
/// 实际路径结构化返回值在 auto_path,多跳时此处 tracing 留痕。
|
||||||
/// 2. 父聚合:推进成功后若 `updated.parent_id` 有值,触发 `recompute_parent_status` 重算
|
/// 2. 父聚合:推进成功后若 `updated.parent_id` 有值,触发 `recompute_parent_status` 重算
|
||||||
/// 父 status(父任务=容器模型,聚合规则见 recompute_parent_status)。父聚合失败仅
|
/// 父 status(父任务=容器模型,聚合规则见 recompute_parent_status)。父聚合失败仅
|
||||||
/// tracing::warn 不阻断——子任务推进成功是主结果,父 status 漂移可后续修正。
|
/// tracing::warn 不阻断——子任务推进成功是主结果,父 status 漂移可后续修正。
|
||||||
@@ -211,7 +282,16 @@ pub async fn advance_task_with_parent(
|
|||||||
id: &str,
|
id: &str,
|
||||||
target_status: &str,
|
target_status: &str,
|
||||||
) -> df_types::error::Result<TaskRecord> {
|
) -> df_types::error::Result<TaskRecord> {
|
||||||
let updated = advance_task_atomic(repo, id, target_status).await?;
|
let (updated, path) = advance_task_auto_path(repo, id, target_status).await?;
|
||||||
|
if path.len() > 2 {
|
||||||
|
tracing::info!(
|
||||||
|
task_id = %id,
|
||||||
|
target = %target_status,
|
||||||
|
path = %crate::task_state_machine::format_path(&path),
|
||||||
|
review_rounds = updated.review_rounds,
|
||||||
|
"[advance_task] 跳态目标已按最短路径自动逐跳推进"
|
||||||
|
);
|
||||||
|
}
|
||||||
if let Some(pid) = &updated.parent_id {
|
if let Some(pid) = &updated.parent_id {
|
||||||
if let Err(e) = recompute_parent_status(repo, pid).await {
|
if let Err(e) = recompute_parent_status(repo, pid).await {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
@@ -394,6 +474,7 @@ mod tests {
|
|||||||
queue: "todo".to_string(),
|
queue: "todo".to_string(),
|
||||||
parent_id: None,
|
parent_id: None,
|
||||||
content_json: None,
|
content_json: None,
|
||||||
|
archived: false,
|
||||||
created_at: "0".to_string(),
|
created_at: "0".to_string(),
|
||||||
updated_at: "0".to_string(),
|
updated_at: "0".to_string(),
|
||||||
}
|
}
|
||||||
@@ -575,6 +656,110 @@ mod tests {
|
|||||||
assert_eq!(r.review_rounds, 0, "解除 blocked 不累加");
|
assert_eq!(r.review_rounds, 0, "解除 blocked 不累加");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// auto_path — 跳态目标自动逐跳推进(治实测 todo→done / in_progress→done 直取失败)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn auto_path_todo_to_done_walks_full_gates() {
|
||||||
|
// 实测失败形态 1:todo 直取 done,auto_path 走满闸门链直达目标
|
||||||
|
let repo = setup().await;
|
||||||
|
repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap();
|
||||||
|
let (r, path) = advance_task_auto_path(&repo, "t1", "done").await.unwrap();
|
||||||
|
assert_eq!(r.status.as_str(), "done");
|
||||||
|
assert_eq!(r.review_rounds, 0, "纯前向路径不累加 review_rounds");
|
||||||
|
assert_eq!(path, vec!["todo", "in_progress", "in_review", "testing", "done"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn auto_path_in_progress_to_done() {
|
||||||
|
// 实测失败形态 2:in_progress 直取 done
|
||||||
|
let repo = setup().await;
|
||||||
|
repo.insert(rec("t1", TaskStatus::InProgress)).await.unwrap();
|
||||||
|
let (r, path) = advance_task_auto_path(&repo, "t1", "done").await.unwrap();
|
||||||
|
assert_eq!(r.status.as_str(), "done");
|
||||||
|
assert_eq!(path, vec!["in_progress", "in_review", "testing", "done"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn auto_path_regression_hop_bumps_rounds() {
|
||||||
|
// 目标不可直达且路径途经退回边:testing→in_review→in_progress 两跳均为退回,
|
||||||
|
// 每跳独立按 is_regression 累加 → review_rounds = 2(规则照旧,逐跳生效)
|
||||||
|
let repo = setup().await;
|
||||||
|
repo.insert(rec("t1", TaskStatus::Testing)).await.unwrap();
|
||||||
|
let (r, path) = advance_task_auto_path(&repo, "t1", "in_progress").await.unwrap();
|
||||||
|
assert_eq!(r.status.as_str(), "in_progress");
|
||||||
|
assert_eq!(path, vec!["testing", "in_review", "in_progress"]);
|
||||||
|
assert_eq!(r.review_rounds, 2, "两跳退回各 +1,应累加到 2");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn auto_path_multihop_forward_no_bump() {
|
||||||
|
// deferred → blocked:3 跳(deferred→todo→in_progress→blocked),全前向不累加
|
||||||
|
let repo = setup().await;
|
||||||
|
repo.insert(rec("t1", TaskStatus::Deferred)).await.unwrap();
|
||||||
|
let (r, path) = advance_task_auto_path(&repo, "t1", "blocked").await.unwrap();
|
||||||
|
assert_eq!(r.status.as_str(), "blocked");
|
||||||
|
assert_eq!(path, vec!["deferred", "todo", "in_progress", "blocked"]);
|
||||||
|
assert_eq!(r.review_rounds, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn auto_path_direct_transition_unchanged() {
|
||||||
|
// 直达单跳:行为与旧 advance_task_atomic 完全一致(路径长 2,rounds 不动)
|
||||||
|
let repo = setup().await;
|
||||||
|
repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap();
|
||||||
|
let (r, path) = advance_task_auto_path(&repo, "t1", "in_progress").await.unwrap();
|
||||||
|
assert_eq!(r.status.as_str(), "in_progress");
|
||||||
|
assert_eq!(r.review_rounds, 0);
|
||||||
|
assert_eq!(path, vec!["todo", "in_progress"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn auto_path_with_parent_multihop_lands_done() {
|
||||||
|
// LLM 入口(advance_task_with_parent)默认开启 auto_path:todo 直取 done 成功
|
||||||
|
let repo = setup().await;
|
||||||
|
repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap();
|
||||||
|
let r = advance_task_with_parent(&repo, "t1", "done").await.unwrap();
|
||||||
|
assert_eq!(r.status.as_str(), "done");
|
||||||
|
assert_eq!(r.review_rounds, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn auto_path_terminal_and_same_state_still_rejected() {
|
||||||
|
// auto_path 不越状态机:终态不可达 / 同态仍按原错误类型拒绝(委托 atomic 原样报错)
|
||||||
|
let repo = setup().await;
|
||||||
|
repo.insert(rec("t1", TaskStatus::Done)).await.unwrap();
|
||||||
|
let err = advance_task_with_parent(&repo, "t1", "todo").await.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(err, df_types::error::Error::InvalidState { .. }),
|
||||||
|
"终态不可达应是 InvalidState,实际: {err:?}"
|
||||||
|
);
|
||||||
|
repo.insert(rec("t2", TaskStatus::InProgress)).await.unwrap();
|
||||||
|
let err = advance_task_with_parent(&repo, "t2", "in_progress").await.unwrap_err();
|
||||||
|
match err {
|
||||||
|
df_types::error::Error::Validation(msg) => {
|
||||||
|
assert!(msg.contains("相同状态"), "同态消息应含「相同状态」,实际: {msg}");
|
||||||
|
}
|
||||||
|
other => panic!("同态应是 Validation,实际: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn auto_path_invalid_target_still_rejected() {
|
||||||
|
let repo = setup().await;
|
||||||
|
repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap();
|
||||||
|
let err = advance_task_with_parent(&repo, "t1", "merged").await.unwrap_err();
|
||||||
|
assert!(matches!(err, df_types::error::Error::Validation(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn auto_path_not_found_still_rejected() {
|
||||||
|
let repo = setup().await;
|
||||||
|
let err = advance_task_with_parent(&repo, "nope", "done").await.unwrap_err();
|
||||||
|
assert!(matches!(err, df_types::error::Error::NotFound(_)));
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// CR-06 观察项 ② 测试补强 — ②-3(completed)/②-4(failed) 回调推进覆盖
|
// CR-06 观察项 ② 测试补强 — ②-3(completed)/②-4(failed) 回调推进覆盖
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -44,11 +44,12 @@ const KNOWLEDGE_COLS_WITH_EMBEDDING: &str = concat!(
|
|||||||
/// 定义 + 配合 `IDEA_COL_COUNT` 断言 + 测试 `test_idea_cols_matches_record`,加列漏改即捕获。
|
/// 定义 + 配合 `IDEA_COL_COUNT` 断言 + 测试 `test_idea_cols_matches_record`,加列漏改即捕获。
|
||||||
///
|
///
|
||||||
/// V24 新增 related_ids 列(灵感间关联关系持久化打底),已纳入列清单 + 计数。
|
/// V24 新增 related_ids 列(灵感间关联关系持久化打底),已纳入列清单 + 计数。
|
||||||
|
/// V47 新增 archived 列(灵感归档,归档功能 P1),已纳入列清单 + 计数(14→15)。
|
||||||
#[cfg_attr(not(test), allow(dead_code))]
|
#[cfg_attr(not(test), allow(dead_code))]
|
||||||
const IDEA_COLS: &str = "id,title,description,status,priority,score,tags,source,promoted_to,ai_analysis,scores,related_ids,created_at,updated_at";
|
const IDEA_COLS: &str = "id,title,description,status,priority,score,tags,source,promoted_to,ai_analysis,scores,related_ids,archived,created_at,updated_at";
|
||||||
/// `IdeaRecord` 字段数(与上面列清单的逗号分隔项数一致,被测试断言)。
|
/// `IdeaRecord` 字段数(与上面列清单的逗号分隔项数一致,被测试断言)。
|
||||||
#[cfg_attr(not(test), allow(dead_code))]
|
#[cfg_attr(not(test), allow(dead_code))]
|
||||||
const IDEA_COL_COUNT: usize = 14;
|
const IDEA_COL_COUNT: usize = 15;
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 向量工具 — embedding BLOB 序列化 + 余弦相似度
|
// 向量工具 — embedding BLOB 序列化 + 余弦相似度
|
||||||
@@ -123,6 +124,8 @@ fn idea_from_row(row: &Row<'_>) -> std::result::Result<IdeaRecord, rusqlite::Err
|
|||||||
ai_analysis: row.get("ai_analysis")?,
|
ai_analysis: row.get("ai_analysis")?,
|
||||||
scores: row.get("scores")?,
|
scores: row.get("scores")?,
|
||||||
related_ids: row.get("related_ids")?,
|
related_ids: row.get("related_ids")?,
|
||||||
|
// 归档功能 P1 V47:15 列同步之一(SQLite 无真 BOOLEAN,0→false/非0→true)。
|
||||||
|
archived: row.get::<_, i32>("archived")? != 0,
|
||||||
created_at: row.get("created_at")?,
|
created_at: row.get("created_at")?,
|
||||||
updated_at: row.get("updated_at")?,
|
updated_at: row.get("updated_at")?,
|
||||||
})
|
})
|
||||||
@@ -218,22 +221,22 @@ impl_repo!(
|
|||||||
from_row => |row| idea_from_row(row),
|
from_row => |row| idea_from_row(row),
|
||||||
insert => |conn, rec| {
|
insert => |conn, rec| {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO ideas (id, title, description, status, priority, score, tags, source, promoted_to, ai_analysis, scores, related_ids, created_at, updated_at)
|
"INSERT INTO ideas (id, title, description, status, priority, score, tags, source, promoted_to, ai_analysis, scores, related_ids, archived, created_at, updated_at)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
|
||||||
params![
|
params![
|
||||||
rec.id, rec.title, rec.description, rec.status.as_str(), rec.priority,
|
rec.id, rec.title, rec.description, rec.status.as_str(), rec.priority,
|
||||||
rec.score, rec.tags, rec.source, rec.promoted_to, rec.ai_analysis,
|
rec.score, rec.tags, rec.source, rec.promoted_to, rec.ai_analysis,
|
||||||
rec.scores, rec.related_ids, rec.created_at, rec.updated_at
|
rec.scores, rec.related_ids, rec.archived as i32, rec.created_at, rec.updated_at
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
update => |conn, rec| {
|
update => |conn, rec| {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE ideas SET title = ?1, description = ?2, status = ?3, priority = ?4, score = ?5, tags = ?6, source = ?7, promoted_to = ?8, ai_analysis = ?9, scores = ?10, related_ids = ?11, updated_at = ?12 WHERE id = ?13",
|
"UPDATE ideas SET title = ?1, description = ?2, status = ?3, priority = ?4, score = ?5, tags = ?6, source = ?7, promoted_to = ?8, ai_analysis = ?9, scores = ?10, related_ids = ?11, archived = ?12, updated_at = ?13 WHERE id = ?14",
|
||||||
params![
|
params![
|
||||||
rec.title, rec.description, rec.status.as_str(), rec.priority,
|
rec.title, rec.description, rec.status.as_str(), rec.priority,
|
||||||
rec.score, rec.tags, rec.source, rec.promoted_to, rec.ai_analysis,
|
rec.score, rec.tags, rec.source, rec.promoted_to, rec.ai_analysis,
|
||||||
rec.scores, rec.related_ids, rec.updated_at, rec.id
|
rec.scores, rec.related_ids, rec.archived as i32, rec.updated_at, rec.id
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -306,7 +309,7 @@ impl IdeaRepo {
|
|||||||
// 累积 WHERE 子句 + 收集参数(按出现顺序绑定占位符)。
|
// 累积 WHERE 子句 + 收集参数(按出现顺序绑定占位符)。
|
||||||
// deleted_at IS NULL 恒带(常量条件无占位符),回收站任务不进结果
|
// deleted_at IS NULL 恒带(常量条件无占位符),回收站任务不进结果
|
||||||
// (对标 task_repo list_by_query 同款模式,防回收站泄漏,不可被 query 关闭)。
|
// (对标 task_repo list_by_query 同款模式,防回收站泄漏,不可被 query 关闭)。
|
||||||
let mut where_clauses: Vec<String> = vec!["deleted_at IS NULL".to_string()];
|
let mut where_clauses: Vec<String> = vec!["deleted_at IS NULL".to_string(), "archived = 0".to_string()];
|
||||||
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
||||||
|
|
||||||
if let Some(s) = &status {
|
if let Some(s) = &status {
|
||||||
@@ -428,7 +431,7 @@ impl IdeaRepo {
|
|||||||
validate_column_name(field, "ideas")?;
|
validate_column_name(field, "ideas")?;
|
||||||
let conn = self.conn.clone();
|
let conn = self.conn.clone();
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"UPDATE ideas SET {} = ?1, updated_at = ?2 WHERE id = ?3 AND deleted_at IS NULL",
|
"UPDATE ideas SET {} = ?1, updated_at = ?2 WHERE id = ?3 AND deleted_at IS NULL AND archived = 0",
|
||||||
field
|
field
|
||||||
);
|
);
|
||||||
let id = id.to_owned();
|
let id = id.to_owned();
|
||||||
@@ -445,6 +448,54 @@ impl IdeaRepo {
|
|||||||
.map_err(storage_err)?
|
.map_err(storage_err)?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 归档/取消归档(归档功能 P1,V47)。
|
||||||
|
///
|
||||||
|
/// 纯元数据标记,仅作用于未软删灵感(回收站优先级高于归档,先恢复再操作)。
|
||||||
|
/// archived=true 后灵感退出主视图(list_by_query 恒带 archived = 0),仅在归档视图可见。
|
||||||
|
/// 对标 TaskRepo::set_archived。返回是否命中。
|
||||||
|
pub async fn set_archived(&self, id: &str, archived: bool) -> Result<bool> {
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
let id = id.to_owned();
|
||||||
|
let now = now_millis_str();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
let affected = guard
|
||||||
|
.execute(
|
||||||
|
"UPDATE ideas SET archived = ?1, updated_at = ?2 WHERE id = ?3 AND deleted_at IS NULL",
|
||||||
|
params![if archived { 1 } else { 0 }, now, id],
|
||||||
|
)
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
Ok(affected > 0)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 列出归档灵感(archived = 1 且未软删),按更新时间降序(最近归档在前)。
|
||||||
|
///
|
||||||
|
/// 归档视图数据源,与 list_by_query 对偶(恒带 archived = 1)。
|
||||||
|
pub async fn list_archived(&self) -> Result<Vec<IdeaRecord>> {
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
let mut stmt = guard
|
||||||
|
.prepare(&format!(
|
||||||
|
"SELECT {IDEA_COLS} FROM ideas WHERE deleted_at IS NULL AND archived = 1 ORDER BY updated_at DESC"
|
||||||
|
))
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([], |row| idea_from_row(row))
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
let mut results = Vec::new();
|
||||||
|
for r in rows {
|
||||||
|
results.push(r.map_err(storage_err)?);
|
||||||
|
}
|
||||||
|
Ok(results)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
|
|
||||||
/// 原子「立项认领」:CAS 写回 status=promoted + promoted_to(`WHERE id AND promoted_to IS NULL`)。
|
/// 原子「立项认领」:CAS 写回 status=promoted + promoted_to(`WHERE id AND promoted_to IS NULL`)。
|
||||||
///
|
///
|
||||||
/// promote_idea 读-改-写竞态的原子关闭。promote_idea 先建项目再回写灵感,
|
/// promote_idea 读-改-写竞态的原子关闭。promote_idea 先建项目再回写灵感,
|
||||||
@@ -622,7 +673,8 @@ impl IdeaRepo {
|
|||||||
///
|
///
|
||||||
/// 不动 GUI 的无条件 [`update_full`](无 expected 语义,保持现状);本方法仅服务
|
/// 不动 GUI 的无条件 [`update_full`](无 expected 语义,保持现状);本方法仅服务
|
||||||
/// 需要乐观锁版本的调用方(df-mcp update_idea / score_idea)。
|
/// 需要乐观锁版本的调用方(df-mcp update_idea / score_idea)。
|
||||||
/// `Ok(false)` = id 不存在、已进回收站(deleted_at IS NULL 守卫,V28 灵感软删)或版本冲突。
|
/// `Ok(false)` = id 不存在、已进回收站(deleted_at IS NULL 守卫,V28 灵感软删)、
|
||||||
|
/// 已归档(archived = 0 守卫,V47 灵感归档,须先取消归档)或版本冲突。
|
||||||
pub async fn update_full_cas(
|
pub async fn update_full_cas(
|
||||||
&self,
|
&self,
|
||||||
record: &IdeaRecord,
|
record: &IdeaRecord,
|
||||||
@@ -635,8 +687,9 @@ impl IdeaRepo {
|
|||||||
let guard = conn.blocking_lock();
|
let guard = conn.blocking_lock();
|
||||||
let affected = guard
|
let affected = guard
|
||||||
.execute(
|
.execute(
|
||||||
// deleted_at IS NULL:回收站灵感不可被 CAS 覆盖(同 tasks/projects 同族守卫)
|
// deleted_at IS NULL:回收站灵感不可被 CAS 覆盖(同 tasks/projects 同族守卫);
|
||||||
"UPDATE ideas SET title = ?1, description = ?2, status = ?3, priority = ?4, score = ?5, tags = ?6, source = ?7, promoted_to = ?8, ai_analysis = ?9, scores = ?10, related_ids = ?11, updated_at = ?12 WHERE id = ?13 AND updated_at = ?14 AND deleted_at IS NULL",
|
// archived = 0:归档灵感冻结(同 tasks update_full_cas 同族守卫,须先取消归档再改)。
|
||||||
|
"UPDATE ideas SET title = ?1, description = ?2, status = ?3, priority = ?4, score = ?5, tags = ?6, source = ?7, promoted_to = ?8, ai_analysis = ?9, scores = ?10, related_ids = ?11, updated_at = ?12 WHERE id = ?13 AND updated_at = ?14 AND deleted_at IS NULL AND archived = 0",
|
||||||
params![
|
params![
|
||||||
rec.title, rec.description, rec.status.as_str(), rec.priority,
|
rec.title, rec.description, rec.status.as_str(), rec.priority,
|
||||||
rec.score, rec.tags, rec.source, rec.promoted_to, rec.ai_analysis,
|
rec.score, rec.tags, rec.source, rec.promoted_to, rec.ai_analysis,
|
||||||
@@ -1837,7 +1890,7 @@ mod tests {
|
|||||||
|
|
||||||
// ---------- IdeaRepo 软删回收站(对标 task_repo 软删测试,V28) ----------
|
// ---------- IdeaRepo 软删回收站(对标 task_repo 软删测试,V28) ----------
|
||||||
|
|
||||||
/// 构造一条 IdeaRecord fixture(14 字段全填,IdeaRecord 不含 deleted_at —— 纯 SQL 过滤)。
|
/// 构造一条 IdeaRecord fixture(15 字段全填,IdeaRecord 不含 deleted_at —— 纯 SQL 过滤)。
|
||||||
fn irec(id: &str, title: &str) -> IdeaRecord {
|
fn irec(id: &str, title: &str) -> IdeaRecord {
|
||||||
IdeaRecord {
|
IdeaRecord {
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
@@ -1852,6 +1905,7 @@ mod tests {
|
|||||||
ai_analysis: None,
|
ai_analysis: None,
|
||||||
scores: None,
|
scores: None,
|
||||||
related_ids: None,
|
related_ids: None,
|
||||||
|
archived: false,
|
||||||
created_at: "1700000000000".to_string(),
|
created_at: "1700000000000".to_string(),
|
||||||
updated_at: "1700000000000".to_string(),
|
updated_at: "1700000000000".to_string(),
|
||||||
}
|
}
|
||||||
@@ -2023,6 +2077,28 @@ mod tests {
|
|||||||
assert_eq!(after.updated_at, current.updated_at, "updated_at 不应抖动");
|
assert_eq!(after.updated_at, current.updated_at, "updated_at 不应抖动");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn idea_update_full_cas_skips_archived() {
|
||||||
|
// 归档灵感不可被 CAS 命中(archived = 0 守卫,V47 归档,对标 tasks 同族):
|
||||||
|
// 归档=冻结,须先取消归档(set_archived(false))再改,防 MCP 对归档灵感静默改写。
|
||||||
|
let repo = setup_idea_repo().await;
|
||||||
|
repo.insert(irec("i1", "原标题")).await.unwrap();
|
||||||
|
assert!(repo.set_archived("i1", true).await.unwrap());
|
||||||
|
// set_archived 刷新了 updated_at,取当前值作 expected(排除版本不匹配干扰)
|
||||||
|
let current = repo.get_by_id("i1").await.unwrap().unwrap();
|
||||||
|
let mut rec = current.clone();
|
||||||
|
rec.title = "归档里改标题".to_string();
|
||||||
|
rec.updated_at = "1900000000000".to_string();
|
||||||
|
let ok = repo
|
||||||
|
.update_full_cas(&rec, ¤t.updated_at)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(!ok, "归档灵感 CAS 应拒绝(archived 守卫)");
|
||||||
|
let after = repo.get_by_id("i1").await.unwrap().unwrap();
|
||||||
|
assert_eq!(after.title, "原标题", "归档灵感内容不应被改写");
|
||||||
|
assert_eq!(after.updated_at, current.updated_at, "updated_at 不应抖动");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn idea_update_full_cas_conflict_when_expected_stale() {
|
async fn idea_update_full_cas_conflict_when_expected_stale() {
|
||||||
let repo = setup_idea_repo().await;
|
let repo = setup_idea_repo().await;
|
||||||
|
|||||||
@@ -350,6 +350,7 @@ mod tests {
|
|||||||
parent_id: None,
|
parent_id: None,
|
||||||
content_json: None,
|
content_json: None,
|
||||||
module_id: None,
|
module_id: None,
|
||||||
|
archived: false,
|
||||||
created_at: "1700000000000".to_string(),
|
created_at: "1700000000000".to_string(),
|
||||||
updated_at: "1700000000000".to_string(),
|
updated_at: "1700000000000".to_string(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,20 @@ use crate::models::TaskRecord;
|
|||||||
use super::impl_repo;
|
use super::impl_repo;
|
||||||
use super::{now_millis_str, storage_err, validate_column_name};
|
use super::{now_millis_str, storage_err, validate_column_name};
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 列清单(防 COLS 漂移,对标 idea_repo.rs IDEA_COLS 先例)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/// `tasks` 表 20 列(顺序与 TaskRecord 结构体一致,不含 deleted_at)。
|
||||||
|
///
|
||||||
|
/// 19→20 列(归档功能 P1 V46 加 archived)。此前散落 8 处内联列名串
|
||||||
|
/// (list_active/get_by_id_active/advance_status_atomic 回读/list_active_by_project/
|
||||||
|
/// list_by_query/get_children/move_task_queue/list_deleted),加列须处处同步,
|
||||||
|
/// 漏一处只在运行时 rusqlite 报错。集中一处 + from_row 按名取列,加列漏改测试期捕获。
|
||||||
|
pub(crate) const TASK_COLS: &str = "id, project_id, title, description, status, priority, \
|
||||||
|
branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, \
|
||||||
|
idea_id, queue, parent_id, content_json, module_id, archived, created_at, updated_at";
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// from_row 辅助函数
|
// from_row 辅助函数
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -52,6 +66,8 @@ fn task_from_row(row: &Row<'_>) -> std::result::Result<TaskRecord, rusqlite::Err
|
|||||||
content_json: row.get("content_json")?,
|
content_json: row.get("content_json")?,
|
||||||
// 工程系统 V41:任务关联具体工程(module_id),18→19 列同步之一。
|
// 工程系统 V41:任务关联具体工程(module_id),18→19 列同步之一。
|
||||||
module_id: row.get("module_id")?,
|
module_id: row.get("module_id")?,
|
||||||
|
// 归档功能 P1 V46:20 列同步之一(SQLite 无真 BOOLEAN,0→false/非0→true)。
|
||||||
|
archived: row.get::<_, i32>("archived")? != 0,
|
||||||
created_at: row.get("created_at")?,
|
created_at: row.get("created_at")?,
|
||||||
updated_at: row.get("updated_at")?,
|
updated_at: row.get("updated_at")?,
|
||||||
})
|
})
|
||||||
@@ -138,26 +154,26 @@ impl_repo!(
|
|||||||
from_row => |row| task_from_row(row),
|
from_row => |row| task_from_row(row),
|
||||||
insert => |conn, rec| {
|
insert => |conn, rec| {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO tasks (id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, queue, parent_id, content_json, module_id, created_at, updated_at)
|
"INSERT INTO tasks (id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, queue, parent_id, content_json, module_id, archived, created_at, updated_at)
|
||||||
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)",
|
||||||
params![
|
params![
|
||||||
rec.id, rec.project_id, rec.title, rec.description, rec.status.as_str(), rec.priority,
|
rec.id, rec.project_id, rec.title, rec.description, rec.status.as_str(), rec.priority,
|
||||||
rec.branch_name, rec.assignee, rec.workflow_def_id, rec.base_branch,
|
rec.branch_name, rec.assignee, rec.workflow_def_id, rec.base_branch,
|
||||||
rec.review_rounds, rec.output_json, rec.idea_id,
|
rec.review_rounds, rec.output_json, rec.idea_id,
|
||||||
rec.queue, rec.parent_id, rec.content_json, rec.module_id,
|
rec.queue, rec.parent_id, rec.content_json, rec.module_id,
|
||||||
rec.created_at, rec.updated_at
|
rec.archived as i32, rec.created_at, rec.updated_at
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
update => |conn, rec| {
|
update => |conn, rec| {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE tasks SET project_id = ?1, title = ?2, description = ?3, status = ?4, priority = ?5, branch_name = ?6, assignee = ?7, workflow_def_id = ?8, base_branch = ?9, review_rounds = ?10, output_json = ?11, idea_id = ?12, queue = ?13, parent_id = ?14, content_json = ?15, module_id = ?16, updated_at = ?17 WHERE id = ?18",
|
"UPDATE tasks SET project_id = ?1, title = ?2, description = ?3, status = ?4, priority = ?5, branch_name = ?6, assignee = ?7, workflow_def_id = ?8, base_branch = ?9, review_rounds = ?10, output_json = ?11, idea_id = ?12, queue = ?13, parent_id = ?14, content_json = ?15, module_id = ?16, archived = ?17, updated_at = ?18 WHERE id = ?19",
|
||||||
params![
|
params![
|
||||||
rec.project_id, rec.title, rec.description, rec.status.as_str(), rec.priority,
|
rec.project_id, rec.title, rec.description, rec.status.as_str(), rec.priority,
|
||||||
rec.branch_name, rec.assignee, rec.workflow_def_id, rec.base_branch,
|
rec.branch_name, rec.assignee, rec.workflow_def_id, rec.base_branch,
|
||||||
rec.review_rounds, rec.output_json, rec.idea_id,
|
rec.review_rounds, rec.output_json, rec.idea_id,
|
||||||
rec.queue, rec.parent_id, rec.content_json, rec.module_id,
|
rec.queue, rec.parent_id, rec.content_json, rec.module_id,
|
||||||
rec.updated_at, rec.id
|
rec.archived as i32, rec.updated_at, rec.id
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -173,7 +189,7 @@ impl TaskRepo {
|
|||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let guard = conn.blocking_lock();
|
let guard = conn.blocking_lock();
|
||||||
let mut stmt = guard
|
let mut stmt = guard
|
||||||
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, queue, parent_id, content_json, module_id, created_at, updated_at FROM tasks WHERE deleted_at IS NULL ORDER BY created_at DESC")
|
.prepare(&format!("SELECT {TASK_COLS} FROM tasks WHERE deleted_at IS NULL AND archived = 0 ORDER BY created_at DESC"))
|
||||||
.map_err(storage_err)?;
|
.map_err(storage_err)?;
|
||||||
let rows = stmt
|
let rows = stmt
|
||||||
.query_map([], |row| task_from_row(row))
|
.query_map([], |row| task_from_row(row))
|
||||||
@@ -240,7 +256,7 @@ impl TaskRepo {
|
|||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let guard = conn.blocking_lock();
|
let guard = conn.blocking_lock();
|
||||||
let mut stmt = guard
|
let mut stmt = guard
|
||||||
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, queue, parent_id, content_json, module_id, created_at, updated_at FROM tasks WHERE id = ?1 AND deleted_at IS NULL")
|
.prepare(&format!("SELECT {TASK_COLS} FROM tasks WHERE id = ?1 AND deleted_at IS NULL AND archived = 0"))
|
||||||
.map_err(storage_err)?;
|
.map_err(storage_err)?;
|
||||||
let row = stmt
|
let row = stmt
|
||||||
.query_row(params![id], |row| task_from_row(row))
|
.query_row(params![id], |row| task_from_row(row))
|
||||||
@@ -285,10 +301,10 @@ impl TaskRepo {
|
|||||||
// 返回 None,杜绝回收站任务被推进(D-02 软删语义收口,一处关闭)。
|
// 返回 None,杜绝回收站任务被推进(D-02 软删语义收口,一处关闭)。
|
||||||
let sql = if bump_rounds {
|
let sql = if bump_rounds {
|
||||||
"UPDATE tasks SET status = ?1, review_rounds = review_rounds + 1, updated_at = ?2 \
|
"UPDATE tasks SET status = ?1, review_rounds = review_rounds + 1, updated_at = ?2 \
|
||||||
WHERE id = ?3 AND status = ?4 AND deleted_at IS NULL"
|
WHERE id = ?3 AND status = ?4 AND deleted_at IS NULL AND archived = 0"
|
||||||
} else {
|
} else {
|
||||||
"UPDATE tasks SET status = ?1, updated_at = ?2 \
|
"UPDATE tasks SET status = ?1, updated_at = ?2 \
|
||||||
WHERE id = ?3 AND status = ?4 AND deleted_at IS NULL"
|
WHERE id = ?3 AND status = ?4 AND deleted_at IS NULL AND archived = 0"
|
||||||
};
|
};
|
||||||
let affected = guard
|
let affected = guard
|
||||||
.execute(sql, params![new_status, now, id, expected])
|
.execute(sql, params![new_status, now, id, expected])
|
||||||
@@ -298,7 +314,7 @@ impl TaskRepo {
|
|||||||
}
|
}
|
||||||
// 回读更新后的记录(含新 status / 累加后的 review_rounds / 新 updated_at)。
|
// 回读更新后的记录(含新 status / 累加后的 review_rounds / 新 updated_at)。
|
||||||
let mut stmt = guard
|
let mut stmt = guard
|
||||||
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, queue, parent_id, content_json, module_id, created_at, updated_at FROM tasks WHERE id = ?1")
|
.prepare(&format!("SELECT {TASK_COLS} FROM tasks WHERE id = ?1"))
|
||||||
.map_err(storage_err)?;
|
.map_err(storage_err)?;
|
||||||
let row = stmt
|
let row = stmt
|
||||||
.query_row(params![id], |row| task_from_row(row))
|
.query_row(params![id], |row| task_from_row(row))
|
||||||
@@ -320,7 +336,7 @@ impl TaskRepo {
|
|||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let guard = conn.blocking_lock();
|
let guard = conn.blocking_lock();
|
||||||
let mut stmt = guard
|
let mut stmt = guard
|
||||||
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, queue, parent_id, content_json, module_id, created_at, updated_at FROM tasks WHERE deleted_at IS NULL AND project_id = ?1 ORDER BY created_at DESC")
|
.prepare(&format!("SELECT {TASK_COLS} FROM tasks WHERE deleted_at IS NULL AND archived = 0 AND project_id = ?1 ORDER BY created_at DESC"))
|
||||||
.map_err(storage_err)?;
|
.map_err(storage_err)?;
|
||||||
let rows = stmt
|
let rows = stmt
|
||||||
.query_map(params![pid], |row| task_from_row(row))
|
.query_map(params![pid], |row| task_from_row(row))
|
||||||
@@ -389,7 +405,7 @@ impl TaskRepo {
|
|||||||
// 真参数拿到 ?2 而 params_vec 只有 1 个元素 → rusqlite "needed 2, got 1"。
|
// 真参数拿到 ?2 而 params_vec 只有 1 个元素 → rusqlite "needed 2, got 1"。
|
||||||
// 用 params_vec.len()+1 保证 ?N 与参数位置严格对齐(N = 参数序号)。
|
// 用 params_vec.len()+1 保证 ?N 与参数位置严格对齐(N = 参数序号)。
|
||||||
// deleted_at IS NULL 恒带(常量条件无占位符),回收站任务不进结果(语义同 list_active)。
|
// deleted_at IS NULL 恒带(常量条件无占位符),回收站任务不进结果(语义同 list_active)。
|
||||||
let mut where_clauses: Vec<String> = vec!["deleted_at IS NULL".to_string()];
|
let mut where_clauses: Vec<String> = vec!["deleted_at IS NULL".to_string(), "archived = 0".to_string()];
|
||||||
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
||||||
|
|
||||||
if let Some(pid) = &project_id {
|
if let Some(pid) = &project_id {
|
||||||
@@ -454,9 +470,7 @@ impl TaskRepo {
|
|||||||
// 显式列出全部 19 列(含 V29 queue/parent_id/content_json 与 V41 module_id,
|
// 显式列出全部 19 列(含 V29 queue/parent_id/content_json 与 V41 module_id,
|
||||||
// 不 SELECT deleted_at:TaskRecord 不带该字段,取了 from_row 会因未知列报错)。
|
// 不 SELECT deleted_at:TaskRecord 不带该字段,取了 from_row 会因未知列报错)。
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT id, project_id, title, description, status, priority, branch_name, \
|
"SELECT {TASK_COLS} \
|
||||||
assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, \
|
|
||||||
queue, parent_id, content_json, module_id, created_at, updated_at \
|
|
||||||
FROM tasks WHERE {} ORDER BY {} DESC{}",
|
FROM tasks WHERE {} ORDER BY {} DESC{}",
|
||||||
where_clauses.join(" AND "),
|
where_clauses.join(" AND "),
|
||||||
order_col,
|
order_col,
|
||||||
@@ -511,7 +525,7 @@ impl TaskRepo {
|
|||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let guard = conn.blocking_lock();
|
let guard = conn.blocking_lock();
|
||||||
|
|
||||||
let mut where_clauses: Vec<String> = vec!["deleted_at IS NULL".to_string()];
|
let mut where_clauses: Vec<String> = vec!["deleted_at IS NULL".to_string(), "archived = 0".to_string()];
|
||||||
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
||||||
|
|
||||||
if let Some(ref pid) = project_id {
|
if let Some(ref pid) = project_id {
|
||||||
@@ -581,7 +595,7 @@ impl TaskRepo {
|
|||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let guard = conn.blocking_lock();
|
let guard = conn.blocking_lock();
|
||||||
let mut stmt = guard
|
let mut stmt = guard
|
||||||
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, queue, parent_id, content_json, module_id, created_at, updated_at FROM tasks WHERE deleted_at IS NULL AND parent_id = ?1 ORDER BY created_at ASC")
|
.prepare(&format!("SELECT {TASK_COLS} FROM tasks WHERE deleted_at IS NULL AND archived = 0 AND parent_id = ?1 ORDER BY created_at ASC"))
|
||||||
.map_err(storage_err)?;
|
.map_err(storage_err)?;
|
||||||
let rows = stmt
|
let rows = stmt
|
||||||
.query_map(params![pid], |row| task_from_row(row))
|
.query_map(params![pid], |row| task_from_row(row))
|
||||||
@@ -618,7 +632,7 @@ impl TaskRepo {
|
|||||||
let mut stmt = guard
|
let mut stmt = guard
|
||||||
.prepare(
|
.prepare(
|
||||||
"SELECT status, COUNT(*) AS cnt FROM tasks \
|
"SELECT status, COUNT(*) AS cnt FROM tasks \
|
||||||
WHERE deleted_at IS NULL AND parent_id = ?1 \
|
WHERE deleted_at IS NULL AND archived = 0 AND parent_id = ?1 \
|
||||||
GROUP BY status",
|
GROUP BY status",
|
||||||
)
|
)
|
||||||
.map_err(storage_err)?;
|
.map_err(storage_err)?;
|
||||||
@@ -660,7 +674,7 @@ impl TaskRepo {
|
|||||||
let affected = guard
|
let affected = guard
|
||||||
.execute(
|
.execute(
|
||||||
"UPDATE tasks SET status = ?1, updated_at = ?2 \
|
"UPDATE tasks SET status = ?1, updated_at = ?2 \
|
||||||
WHERE id = ?3 AND deleted_at IS NULL",
|
WHERE id = ?3 AND deleted_at IS NULL AND archived = 0",
|
||||||
params![new_status, now, id],
|
params![new_status, now, id],
|
||||||
)
|
)
|
||||||
.map_err(storage_err)?;
|
.map_err(storage_err)?;
|
||||||
@@ -716,11 +730,6 @@ impl TaskRepo {
|
|||||||
let id = id.to_owned();
|
let id = id.to_owned();
|
||||||
let new_queue = new_queue.to_owned();
|
let new_queue = new_queue.to_owned();
|
||||||
let now = now_millis_str();
|
let now = now_millis_str();
|
||||||
// 显式列出全部 19 列(同 from_row 消费列,不 SELECT deleted_at:
|
|
||||||
// TaskRecord 不带该字段,取了 from_row 会因未知列报错)。
|
|
||||||
const TASK_COLS: &str = "id, project_id, title, description, status, priority, branch_name, \
|
|
||||||
assignee, workflow_def_id, base_branch, review_rounds, output_json, \
|
|
||||||
idea_id, queue, parent_id, content_json, module_id, created_at, updated_at";
|
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
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)?;
|
||||||
@@ -729,7 +738,7 @@ impl TaskRepo {
|
|||||||
let current: Option<TaskRecord> = {
|
let current: Option<TaskRecord> = {
|
||||||
let mut stmt = tx
|
let mut stmt = tx
|
||||||
.prepare(&format!(
|
.prepare(&format!(
|
||||||
"SELECT {TASK_COLS} FROM tasks WHERE id = ?1 AND deleted_at IS NULL"
|
"SELECT {TASK_COLS} FROM tasks WHERE id = ?1 AND deleted_at IS NULL AND archived = 0"
|
||||||
))
|
))
|
||||||
.map_err(storage_err)?;
|
.map_err(storage_err)?;
|
||||||
stmt.query_row(params![id], |row| task_from_row(row))
|
stmt.query_row(params![id], |row| task_from_row(row))
|
||||||
@@ -793,7 +802,7 @@ impl TaskRepo {
|
|||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let guard = conn.blocking_lock();
|
let guard = conn.blocking_lock();
|
||||||
let mut stmt = guard
|
let mut stmt = guard
|
||||||
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, queue, parent_id, content_json, module_id, created_at, updated_at FROM tasks WHERE deleted_at IS NOT NULL ORDER BY updated_at DESC")
|
.prepare(&format!("SELECT {TASK_COLS} FROM tasks WHERE deleted_at IS NOT NULL ORDER BY updated_at DESC"))
|
||||||
.map_err(storage_err)?;
|
.map_err(storage_err)?;
|
||||||
let rows = stmt
|
let rows = stmt
|
||||||
.query_map([], |row| task_from_row(row))
|
.query_map([], |row| task_from_row(row))
|
||||||
@@ -833,7 +842,7 @@ impl TaskRepo {
|
|||||||
.execute(
|
.execute(
|
||||||
// deleted_at IS NULL:回收站任务不可被 CAS 覆盖——MCP 拿旧 expected 也能
|
// deleted_at IS NULL:回收站任务不可被 CAS 覆盖——MCP 拿旧 expected 也能
|
||||||
// 命中已软删行,造成回收站内容被改且 updated_at 抖动,调用方无从感知。
|
// 命中已软删行,造成回收站内容被改且 updated_at 抖动,调用方无从感知。
|
||||||
"UPDATE tasks SET project_id = ?1, title = ?2, description = ?3, status = ?4, priority = ?5, branch_name = ?6, assignee = ?7, workflow_def_id = ?8, base_branch = ?9, review_rounds = ?10, output_json = ?11, idea_id = ?12, queue = ?13, parent_id = ?14, content_json = ?15, module_id = ?16, updated_at = ?17 WHERE id = ?18 AND updated_at = ?19 AND deleted_at IS NULL",
|
"UPDATE tasks SET project_id = ?1, title = ?2, description = ?3, status = ?4, priority = ?5, branch_name = ?6, assignee = ?7, workflow_def_id = ?8, base_branch = ?9, review_rounds = ?10, output_json = ?11, idea_id = ?12, queue = ?13, parent_id = ?14, content_json = ?15, module_id = ?16, updated_at = ?17 WHERE id = ?18 AND updated_at = ?19 AND deleted_at IS NULL AND archived = 0",
|
||||||
params![
|
params![
|
||||||
rec.project_id, rec.title, rec.description, rec.status.as_str(), rec.priority,
|
rec.project_id, rec.title, rec.description, rec.status.as_str(), rec.priority,
|
||||||
rec.branch_name, rec.assignee, rec.workflow_def_id, rec.base_branch,
|
rec.branch_name, rec.assignee, rec.workflow_def_id, rec.base_branch,
|
||||||
@@ -859,7 +868,7 @@ impl TaskRepo {
|
|||||||
validate_column_name(field, "tasks")?;
|
validate_column_name(field, "tasks")?;
|
||||||
let conn = self.conn.clone();
|
let conn = self.conn.clone();
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"UPDATE tasks SET {} = ?1, updated_at = ?2 WHERE id = ?3 AND deleted_at IS NULL",
|
"UPDATE tasks SET {} = ?1, updated_at = ?2 WHERE id = ?3 AND deleted_at IS NULL AND archived = 0",
|
||||||
field
|
field
|
||||||
);
|
);
|
||||||
let id = id.to_owned();
|
let id = id.to_owned();
|
||||||
@@ -875,6 +884,56 @@ impl TaskRepo {
|
|||||||
.await
|
.await
|
||||||
.map_err(storage_err)?
|
.map_err(storage_err)?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 归档/取消归档(归档功能 P1,V46)。
|
||||||
|
///
|
||||||
|
/// 纯元数据标记(对标 ConversationRepo::set_archived),仅作用于未软删任务:
|
||||||
|
/// 软删(回收站)优先级高于归档——回收站任务不可直接归档/取消归档,先恢复再操作。
|
||||||
|
/// archived=true 后任务冻结(活跃查询恒过滤,不可推进/跨池/字段更新),仅在归档视图可见。
|
||||||
|
/// 返回是否命中。
|
||||||
|
pub async fn set_archived(&self, id: &str, archived: bool) -> Result<bool> {
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
let id = id.to_owned();
|
||||||
|
let now = now_millis_str();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
let affected = guard
|
||||||
|
.execute(
|
||||||
|
"UPDATE tasks SET archived = ?1, updated_at = ?2 WHERE id = ?3 AND deleted_at IS NULL",
|
||||||
|
params![if archived { 1 } else { 0 }, now, id],
|
||||||
|
)
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
Ok(affected > 0)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 列出归档任务(archived = 1 且未软删),按更新时间降序(最近归档在前)。
|
||||||
|
///
|
||||||
|
/// 归档视图数据源。与 list_active 对偶:活跃查询恒带 archived = 0,
|
||||||
|
/// 本方法恒带 archived = 1,两集合互斥且并集 = 全部未删任务。
|
||||||
|
pub async fn list_archived(&self) -> Result<Vec<TaskRecord>> {
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
let mut stmt = guard
|
||||||
|
.prepare(&format!(
|
||||||
|
"SELECT {TASK_COLS} FROM tasks WHERE deleted_at IS NULL AND archived = 1 ORDER BY updated_at DESC"
|
||||||
|
))
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([], |row| task_from_row(row))
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
let mut results = Vec::new();
|
||||||
|
for r in rows {
|
||||||
|
results.push(r.map_err(storage_err)?);
|
||||||
|
}
|
||||||
|
Ok(results)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -909,6 +968,7 @@ mod tests {
|
|||||||
parent_id: parent_id.map(|s| s.to_string()),
|
parent_id: parent_id.map(|s| s.to_string()),
|
||||||
content_json: None,
|
content_json: None,
|
||||||
module_id: None,
|
module_id: None,
|
||||||
|
archived: false,
|
||||||
created_at: "1700000000000".to_string(),
|
created_at: "1700000000000".to_string(),
|
||||||
updated_at: "1700000000000".to_string(),
|
updated_at: "1700000000000".to_string(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ mod tests {
|
|||||||
parent_id: parent_id.map(|s| s.to_string()),
|
parent_id: parent_id.map(|s| s.to_string()),
|
||||||
content_json: None,
|
content_json: None,
|
||||||
module_id: None,
|
module_id: None,
|
||||||
|
archived: false,
|
||||||
created_at: "1700000000000".to_string(),
|
created_at: "1700000000000".to_string(),
|
||||||
updated_at: "1700000000000".to_string(),
|
updated_at: "1700000000000".to_string(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,9 @@ pub fn run(conn: &Connection) -> Result<()> {
|
|||||||
// 桌面侧栏/miniapp 列表按 origin 过滤子会话)。
|
// 桌面侧栏/miniapp 列表按 origin 过滤子会话)。
|
||||||
// V45 = 无人值守任务边界(ai_conversations.boundary_json TEXT,TaskBoundary JSON 快照,
|
// V45 = 无人值守任务边界(ai_conversations.boundary_json TEXT,TaskBoundary JSON 快照,
|
||||||
// 发起时一次声明存续期生效,随会话同亡)。
|
// 发起时一次声明存续期生效,随会话同亡)。
|
||||||
let steps: [(i32, fn(&Connection) -> Result<()>); 45] = [
|
// V46/V47 = 任务/灵感归档(archived INTEGER NOT NULL DEFAULT 0,归档功能 P1,
|
||||||
|
// 对标 ai_conversations.archived V4;归档与软删正交:回收站=删除缓冲,归档=低频可见保检索)。
|
||||||
|
let steps: [(i32, fn(&Connection) -> Result<()>); 47] = [
|
||||||
(1, migrate_v1),
|
(1, migrate_v1),
|
||||||
(2, migrate_v2),
|
(2, migrate_v2),
|
||||||
(3, migrate_v3),
|
(3, migrate_v3),
|
||||||
@@ -99,6 +101,10 @@ pub fn run(conn: &Connection) -> Result<()> {
|
|||||||
(43, migrate_v43),
|
(43, migrate_v43),
|
||||||
(44, migrate_v44),
|
(44, migrate_v44),
|
||||||
(45, migrate_v45),
|
(45, migrate_v45),
|
||||||
|
// V46 = 任务归档(tasks.archived 列,归档功能 P1,对标 ai_conversations.archived V4);
|
||||||
|
// V47 = 灵感归档(ideas.archived 列,同款)。归档与软删正交:回收站=删除缓冲,归档=低频可见保检索。
|
||||||
|
(46, migrate_v46),
|
||||||
|
(47, migrate_v47),
|
||||||
];
|
];
|
||||||
|
|
||||||
for (version, migrate_fn) in steps {
|
for (version, migrate_fn) in steps {
|
||||||
@@ -1354,6 +1360,50 @@ fn migrate_v45(conn: &Connection) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// V46: 幂等补 tasks.archived 列(任务归档,归档功能 P1)
|
||||||
|
///
|
||||||
|
/// 归档语义对标 ai_conversations.archived(V4):纯元数据标记,与 status/queue/deleted_at 正交。
|
||||||
|
/// 「作废」走 status=cancelled,「完结」走 done/cancelled——归档是另一种动作:
|
||||||
|
/// 低频完结任务移出主视图但保留检索(回收站=删除缓冲不可见,归档=低频可见保检索)。
|
||||||
|
/// - `INTEGER NOT NULL DEFAULT 0`:老库行迁移后全为 0(未归档),行为零变化;
|
||||||
|
/// - 不进 update_field 白名单(settings.rs):归档走专用 set_archived(带埋点 + 布尔语义),
|
||||||
|
/// 对标 status 收口思路防旁路写;
|
||||||
|
/// - 活跃查询(list_active/list_by_query/count_by_query/list_active_by_project/get_children/
|
||||||
|
/// advance_status_atomic/move_task_queue)恒带 `archived = 0` 过滤,归档任务退出主视图与
|
||||||
|
/// 状态机(冻结:不可推进/不可跨池/不可字段更新,需先取消归档)。
|
||||||
|
/// - **幂等**:column_exists 守卫(同 V45 模式);**可逆**:`ALTER TABLE tasks DROP COLUMN archived`。
|
||||||
|
fn migrate_v46(conn: &Connection) -> Result<()> {
|
||||||
|
if !column_exists(conn, "tasks", "archived") {
|
||||||
|
conn.execute(
|
||||||
|
"ALTER TABLE tasks ADD COLUMN archived INTEGER NOT NULL DEFAULT 0",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
tracing::info!("v46: 补建 tasks.archived 列(任务归档)");
|
||||||
|
}
|
||||||
|
conn.execute("INSERT OR IGNORE INTO schema_version (version) VALUES (?)", [46])?;
|
||||||
|
tracing::info!("迁移 v46 完成: tasks 加 archived 列");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// V47: 幂等补 ideas.archived 列(灵感归档,归档功能 P1)
|
||||||
|
///
|
||||||
|
/// 对标 V46 tasks.archived:rejected/deferred/已晋升灵感一键归档移出主视图,保留检索。
|
||||||
|
/// 灵感无状态机收口(status 走 idea_repo 通用路径),但归档同样不进 update_field 白名单,
|
||||||
|
/// 走专用 set_archived(布尔语义 + 防旁路)。活跃查询恒带 `archived = 0`。
|
||||||
|
/// - **幂等**:column_exists 守卫;**可逆**:`ALTER TABLE ideas DROP COLUMN archived`。
|
||||||
|
fn migrate_v47(conn: &Connection) -> Result<()> {
|
||||||
|
if !column_exists(conn, "ideas", "archived") {
|
||||||
|
conn.execute(
|
||||||
|
"ALTER TABLE ideas ADD COLUMN archived INTEGER NOT NULL DEFAULT 0",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
tracing::info!("v47: 补建 ideas.archived 列(灵感归档)");
|
||||||
|
}
|
||||||
|
conn.execute("INSERT OR IGNORE INTO schema_version (version) VALUES (?)", [47])?;
|
||||||
|
tracing::info!("迁移 v47 完成: ideas 加 archived 列");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// 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)。
|
||||||
@@ -2357,6 +2407,16 @@ mod tests {
|
|||||||
column_exists(&conn, "tasks", "module_id"),
|
column_exists(&conn, "tasks", "module_id"),
|
||||||
"tasks.module_id 列缺失(V41 加)"
|
"tasks.module_id 列缺失(V41 加)"
|
||||||
);
|
);
|
||||||
|
// tasks.archived(V46 归档功能 P1)
|
||||||
|
assert!(
|
||||||
|
column_exists(&conn, "tasks", "archived"),
|
||||||
|
"tasks.archived 列缺失(V46 加)"
|
||||||
|
);
|
||||||
|
// ideas.archived(V47 归档功能 P1)
|
||||||
|
assert!(
|
||||||
|
column_exists(&conn, "ideas", "archived"),
|
||||||
|
"ideas.archived 列缺失(V47 加)"
|
||||||
|
);
|
||||||
|
|
||||||
// 3. schema_version 应推进到 45(全量迁移成功落版本号)
|
// 3. schema_version 应推进到 45(全量迁移成功落版本号)
|
||||||
let max_version: i64 = conn
|
let max_version: i64 = conn
|
||||||
@@ -2367,8 +2427,8 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.expect("查 schema_version 应成功");
|
.expect("查 schema_version 应成功");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
max_version, 45,
|
max_version, 47,
|
||||||
"全量迁移后 schema_version 应为 45(实际 {}),说明某条 migrate_vN 链路断在中间",
|
"全量迁移后 schema_version 应为 47(实际 {}),说明某条 migrate_vN 链路断在中间",
|
||||||
max_version
|
max_version
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -2445,7 +2505,7 @@ mod tests {
|
|||||||
|r| r.get(0),
|
|r| r.get(0),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(max_v, 45, "首轮应推进到 45");
|
assert_eq!(max_v, 47, "首轮应推进到 47");
|
||||||
|
|
||||||
// 清空版本表强制全链第二遍(每步 execute 第二次)
|
// 清空版本表强制全链第二遍(每步 execute 第二次)
|
||||||
conn.execute("DELETE FROM schema_version", []).unwrap();
|
conn.execute("DELETE FROM schema_version", []).unwrap();
|
||||||
@@ -2457,7 +2517,7 @@ mod tests {
|
|||||||
|r| r.get(0),
|
|r| r.get(0),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(max_v2, 45, "重跑后应重新推进到 45");
|
assert_eq!(max_v2, 47, "重跑后应重新推进到 47");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ pub struct IdeaRecord {
|
|||||||
pub ai_analysis: Option<String>, // AI 分析结果 JSON
|
pub ai_analysis: Option<String>, // AI 分析结果 JSON
|
||||||
pub scores: Option<String>, // 多维评分 JSON (feasibility/impact/urgency/overall)
|
pub scores: Option<String>, // 多维评分 JSON (feasibility/impact/urgency/overall)
|
||||||
pub related_ids: Option<String>, // 关联灵感 id JSON 数组(同 tags 模式,为关联关系 UI 打底)
|
pub related_ids: Option<String>, // 关联灵感 id JSON 数组(同 tags 模式,为关联关系 UI 打底)
|
||||||
|
/// 归档标记(归档功能 P1,V47 列)。纯元数据,archived=true 的灵感退出主视图,仅在归档视图可见。
|
||||||
|
/// DEFAULT 0 保证老库行非 NULL;#[serde(default)] 兼容旧前端 JSON(缺省 false)。
|
||||||
|
#[serde(default)]
|
||||||
|
pub archived: bool,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
@@ -123,9 +127,13 @@ pub struct TaskRecord {
|
|||||||
/// 一个项目可含多个工程(Monorepo/微服务),本字段让任务落到具体工程,是后续
|
/// 一个项目可含多个工程(Monorepo/微服务),本字段让任务落到具体工程,是后续
|
||||||
/// IPC / 前端 / AI 按工程筛选任务的数据基础。外键 REFERENCES project_modules(id)
|
/// IPC / 前端 / AI 按工程筛选任务的数据基础。外键 REFERENCES project_modules(id)
|
||||||
/// ON DELETE SET NULL(module 删除时任务自动解关联)。可空(老任务无关联 → None)。
|
/// ON DELETE SET NULL(module 删除时任务自动解关联)。可空(老任务无关联 → None)。
|
||||||
/// #[serde(default)] 兼容旧前端 JSON(无该字段时为 None)。
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub module_id: Option<String>,
|
pub module_id: Option<String>,
|
||||||
|
/// 归档标记(归档功能 P1,V46 列)。与 status/queue/deleted_at 正交的纯元数据:
|
||||||
|
/// archived=true 的任务退出主视图/状态机(冻结,不可推进/跨池/字段更新),仅在归档视图可见。
|
||||||
|
/// DEFAULT 0 保证老库行非 NULL;#[serde(default)] 兼容旧前端 JSON(缺省 false)。
|
||||||
|
#[serde(default)]
|
||||||
|
pub archived: bool,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ fn task(id: &str, project_id: &str) -> TaskRecord {
|
|||||||
parent_id: None,
|
parent_id: None,
|
||||||
content_json: None,
|
content_json: None,
|
||||||
module_id: None,
|
module_id: None,
|
||||||
|
archived: false,
|
||||||
created_at: now_ts(),
|
created_at: now_ts(),
|
||||||
updated_at: now_ts(),
|
updated_at: now_ts(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,6 +91,7 @@
|
|||||||
| [后续批次测试用例设计-2026-07-01.md](./专项设计/后续批次测试用例设计-2026-07-01.md) | 📐 测试 | 多Agent并行/worktree隔离/仲裁合并/模板系统/Git-CI/审批政策 的单元+集成+端到端测试用例 |
|
| [后续批次测试用例设计-2026-07-01.md](./专项设计/后续批次测试用例设计-2026-07-01.md) | 📐 测试 | 多Agent并行/worktree隔离/仲裁合并/模板系统/Git-CI/审批政策 的单元+集成+端到端测试用例 |
|
||||||
| [测试落地实施方案-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 项落地风险清单 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,8 @@
|
|||||||
|
$queries = @('feishu', 'lark', 'larksuite')
|
||||||
|
foreach ($q in $queries) {
|
||||||
|
Write-Output "===== query: $q ====="
|
||||||
|
$r = Invoke-RestMethod -Uri "https://crates.io/api/v1/crates?q=$q&per_page=20" -Headers @{ 'User-Agent' = 'devflow-research' }
|
||||||
|
$r.crates | ForEach-Object {
|
||||||
|
"{0,-22} v{1,-10} dl={2,-8} upd={3} repo={4}" -f $_.id, $_.max_version, $_.downloads, ($_.updated_at -split 'T')[0], $_.repository
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,168 @@
|
|||||||
|
# DingTalk Stream SDK for Rust
|
||||||
|
|
||||||
|
[](https://crates.io/crates/dingtalk-stream)
|
||||||
|
[](https://docs.rs/dingtalk-stream)
|
||||||
|
[](LICENSE)
|
||||||
|
[](https://www.rust-lang.org)
|
||||||
|
|
||||||
|
钉钉 Stream 模式 SDK 的 Rust 实现,完整对齐 Python 版 [dingtalk-stream-sdk-python](https://github.com/open-dingtalk/dingtalk-stream-sdk-python)。
|
||||||
|
|
||||||
|
> 对齐的 Python SDK 版本: commit [`6381a7b`](https://github.com/open-dingtalk/dingtalk-stream-sdk-python/commit/6381a7bacb0353e1e0cd425688a02ff785fda44c)
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
- 基于 WebSocket 的钉钉 Stream 协议连接(自动重连、60s Keepalive)
|
||||||
|
- 聊天机器人消息收发(文本、Markdown、卡片)
|
||||||
|
- 交互式卡片(Markdown / AI 流式 / 按钮 / 轮播 / RPA 插件)
|
||||||
|
- Graph API 支持
|
||||||
|
- 事件订阅
|
||||||
|
- OAuth2 Token 自动管理(带缓存与 5 分钟提前刷新)
|
||||||
|
- 统一 async/await(基于 tokio)
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
将依赖添加到 `Cargo.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
dingtalk-stream = "0.1"
|
||||||
|
```
|
||||||
|
|
||||||
|
最小示例:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use dingtalk_stream::*;
|
||||||
|
|
||||||
|
struct MyHandler;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl CallbackHandler for MyHandler {
|
||||||
|
async fn process(&self, message: &messages::frames::MessageBody) -> (u16, String) {
|
||||||
|
println!("Received: {}", message.data);
|
||||||
|
(AckMessage::STATUS_OK, "OK".to_owned())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let credential = Credential::new("your_client_id", "your_client_secret");
|
||||||
|
let handler = MyHandler;
|
||||||
|
|
||||||
|
let mut client = DingTalkStreamClient::builder(credential)
|
||||||
|
.register_callback_handler(ChatbotMessage::TOPIC, handler)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
client.start_forever().unwrap();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
更多示例见 [`examples/`](examples/) 目录。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── lib.rs # 公共 API 导出
|
||||||
|
├── client.rs # DingTalkStreamClient + Builder
|
||||||
|
├── credential.rs # Credential(Debug 遮蔽 secret)
|
||||||
|
├── error.rs # 统一错误类型
|
||||||
|
├── handlers/ # Handler traits (Callback/Event/System/Chatbot/Graph)
|
||||||
|
├── messages/ # 消息类型 (Frames/Chatbot/CardCallback/Graph)
|
||||||
|
├── card/ # 卡片系统 (Replier/Instances/Templates)
|
||||||
|
└── transport/ # 传输层 (HTTP/Token/WebSocket)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 文档
|
||||||
|
|
||||||
|
- [整体架构图](docs/architecture.md)
|
||||||
|
- [连接生命周期时序图](docs/connection-lifecycle.md)
|
||||||
|
- [卡片操作时序图](docs/card-operations.md)
|
||||||
|
|
||||||
|
## 端到端测试
|
||||||
|
|
||||||
|
1. 复制配置模板并填入凭证:
|
||||||
|
```bash
|
||||||
|
cp examples/secret.example.toml examples/secret.toml
|
||||||
|
# 编辑 examples/secret.toml,填入 client_id 和 client_secret
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 运行测试机器人:
|
||||||
|
```bash
|
||||||
|
cargo run --example e2e_test
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 带媒体下载测试:
|
||||||
|
```bash
|
||||||
|
cargo run --example e2e_test -- --download-dir /tmp/dingtalk_downloads
|
||||||
|
```
|
||||||
|
|
||||||
|
4. 通过钉钉向机器人发送消息测试:
|
||||||
|
- `ping` → 回复 pong
|
||||||
|
- `echo <text>` → 回声
|
||||||
|
- `card` → 发送 Markdown 卡片
|
||||||
|
- `info` → 显示消息详情
|
||||||
|
|
||||||
|
## 与 Python SDK 的设计差异
|
||||||
|
|
||||||
|
| Python | Rust | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| sync + async 双版本 | 统一 async | 消除代码重复 |
|
||||||
|
| 类继承 | Trait + 组合 | Rust 惯用模式 |
|
||||||
|
| ThreadPoolExecutor | tokio::spawn | 更高效的异步调度 |
|
||||||
|
| dict 动态类型 | 强类型 struct + serde | 编译期类型安全 |
|
||||||
|
|
||||||
|
## Rust SDK 独有功能
|
||||||
|
|
||||||
|
以下功能为 Rust SDK 独有,Python SDK 中不存在。同步 Python SDK 时请勿删除(代码中均有 `Rust SDK exclusive` 标注)。
|
||||||
|
|
||||||
|
### 扩展消息类型
|
||||||
|
|
||||||
|
Rust SDK 支持解析钉钉机器人可接收的全部 6 种消息类型,Python SDK 仅支持 text / richText / picture:
|
||||||
|
|
||||||
|
| 消息类型 | 结构体 | 场景限制 |
|
||||||
|
|---------|--------|---------|
|
||||||
|
| audio 语音 | `AudioContent` | 仅单聊 |
|
||||||
|
| file 文件 | `FileContent` | 仅单聊 |
|
||||||
|
| video 视频 | `VideoContent` | 仅单聊 |
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let msg = ChatbotMessage::from_value(&json)?;
|
||||||
|
if let Some(fc) = &msg.file_content {
|
||||||
|
println!("文件名: {:?}, 下载码: {:?}", fc.file_name, fc.download_code);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 统一下载码提取
|
||||||
|
|
||||||
|
`get_all_download_codes()` 一次性获取消息中所有媒体的下载码,覆盖 picture / richText 图片 / audio / file / video:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
for (media_type, download_code) in msg.get_all_download_codes() {
|
||||||
|
let url = replier.get_image_download_url(&download_code).await?;
|
||||||
|
let bytes = replier.download_bytes(&url).await?;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 文件下载
|
||||||
|
|
||||||
|
| 方法 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `download_bytes(url)` | 下载文件字节内容 |
|
||||||
|
| `download_bytes_with_limit(url, max_size)` | 带大小限制的流式下载(Content-Length 预检 + 累计检查) |
|
||||||
|
|
||||||
|
### 主动单聊消息
|
||||||
|
|
||||||
|
`send_oto_message()` 通过 OpenAPI 向指定用户发送单聊消息(对应 `POST /v1.0/robot/oToMessages/batchSend`):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
replier.send_oto_message(user_id, "sampleText", r#"{"content":"hello"}"#).await?;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 环境要求
|
||||||
|
|
||||||
|
- Rust ≥ 1.85.0(edition 2024)
|
||||||
|
- tokio 异步运行时
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
# Feishu SDK Rust
|
||||||
|
|
||||||
|
[](https://crates.io/crates/feishu-sdk)
|
||||||
|
[](https://docs.rs/feishu-sdk)
|
||||||
|
[](https://github.com/your-repo/feishu-sdk-rust)
|
||||||
|
|
||||||
|
> Notice: This is a community-maintained crate and is not an official Feishu/Lark SDK.
|
||||||
|
|
||||||
|
Rust SDK for Feishu/Lark Open Platform API. Provides a complete, type-safe interface to interact with Feishu APIs.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Complete API Coverage**: 1448+ operations from Feishu Open Platform
|
||||||
|
- **Token Management**: Automatic app_access_token and tenant_access_token handling
|
||||||
|
- **Event Processing**: Event subscription and card callback handling
|
||||||
|
- **WebSocket Support**: Long connection for real-time messaging
|
||||||
|
- **HTTP Server**: Built-in Salvo server for webhook handling
|
||||||
|
- **Type Safety**: Generated operation constants and typed APIs
|
||||||
|
- **Async/Await**: Full async support with tokio runtime
|
||||||
|
- **Pluggable Components**: Customizable cache, logger, serializer, and HTTP client
|
||||||
|
- **Code Generation**: Operation wrappers + typed model stubs from endpoint catalog
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Add to your `Cargo.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
feishu-sdk = "0.1"
|
||||||
|
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Optional Features
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
feishu-sdk = { version = "0.1", features = ["server", "websocket"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
- `server`: Enable HTTP server for event handling (Salvo)
|
||||||
|
- `websocket`: Enable WebSocket client for long connections
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Basic Usage
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use feishu_sdk::core::{Config, FEISHU_BASE_URL};
|
||||||
|
use feishu_sdk::Client;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let config = Config::builder("app_id", "app_secret")
|
||||||
|
.base_url(FEISHU_BASE_URL)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let client = Client::new(config)?;
|
||||||
|
|
||||||
|
// Get chat list
|
||||||
|
let resp = client
|
||||||
|
.operation("im.v1.chat.list")
|
||||||
|
.query_param("page_size", "20")
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
println!("Status: {}", resp.status);
|
||||||
|
println!("Body: {}", String::from_utf8_lossy(&resp.body));
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Send Message
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use feishu_sdk::core::Config;
|
||||||
|
use feishu_sdk::Client;
|
||||||
|
|
||||||
|
async fn send_message(client: &Client, chat_id: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let message = serde_json::json!({
|
||||||
|
"receive_id": chat_id,
|
||||||
|
"msg_type": "text",
|
||||||
|
"content": r#"{"text":"Hello!"}"#
|
||||||
|
});
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.operation("im.v1.messages.create")
|
||||||
|
.path_param("receive_id_type", "chat_id")
|
||||||
|
.body_json(&message)?
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
println!("Message sent: {:?}", resp.json_value()?);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Event Handling Server
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use feishu_sdk::core::{noop_logger, Config};
|
||||||
|
use feishu_sdk::event::{EventDispatcher, EventDispatcherConfig};
|
||||||
|
use feishu_sdk::server::FeishuServer;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
let event_config = EventDispatcherConfig::new()
|
||||||
|
.verification_token("your_token");
|
||||||
|
|
||||||
|
let dispatcher = EventDispatcher::new(event_config, noop_logger());
|
||||||
|
|
||||||
|
// Register event handlers
|
||||||
|
dispatcher.on_event("im.message.receive_v1", |event| {
|
||||||
|
Box::pin(async move {
|
||||||
|
println!("Received message: {:?}", event);
|
||||||
|
Ok(None)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
let server = FeishuServer::new(dispatcher).port(8080);
|
||||||
|
server.run().await;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### WebSocket Client
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use feishu_sdk::core::noop_logger;
|
||||||
|
use feishu_sdk::ws::{WebSocketClient, WsConfig};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let config = WsConfig::new("wss://ws.feishu.cn/...")
|
||||||
|
.heartbeat_interval(Duration::from_secs(30))
|
||||||
|
.reconnect_delay(Duration::from_secs(5));
|
||||||
|
|
||||||
|
let client = WebSocketClient::new(config, noop_logger()).await?;
|
||||||
|
|
||||||
|
// Check connection status
|
||||||
|
if client.is_connected().await {
|
||||||
|
println!("Connected to WebSocket");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Basic Configuration
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use feishu_sdk::core::{Config, LogLevel, FEISHU_BASE_URL, LARK_BASE_URL};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
let config = Config::builder("app_id", "app_secret")
|
||||||
|
.base_url(FEISHU_BASE_URL) // or LARK_BASE_URL for international
|
||||||
|
.log_level(LogLevel::Debug) // Set log level
|
||||||
|
.request_timeout(Duration::from_secs(30)) // Request timeout
|
||||||
|
.enable_token_cache(true) // Enable token caching
|
||||||
|
.build();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Marketplace App Configuration
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let config = Config::builder("app_id", "app_secret")
|
||||||
|
.marketplace_app() // Enable marketplace mode
|
||||||
|
.app_ticket("your_app_ticket") // Required for marketplace apps
|
||||||
|
.build();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Helpdesk Configuration
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let config = Config::builder("app_id", "app_secret")
|
||||||
|
.helpdesk("helpdesk_id", "helpdesk_token")
|
||||||
|
.build();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Client Options
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use feishu_sdk::client::{ClientBuilder, with_http_client, with_log_level, with_request_timeout, with_serializer};
|
||||||
|
use feishu_sdk::core::{Config, LogLevel};
|
||||||
|
|
||||||
|
let client = ClientBuilder::new(Config::builder("app_id", "app_secret").build())
|
||||||
|
.with(with_log_level(LogLevel::Debug))
|
||||||
|
.with(with_request_timeout(Duration::from_secs(60)))
|
||||||
|
.with(with_http_client(custom_http_client))
|
||||||
|
.with(with_serializer(custom_serializer))
|
||||||
|
.build()?;
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### Operation IDs
|
||||||
|
|
||||||
|
Operations use the format: `{service}.{version}.{resource}.{method}`
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- `im.v1.messages.create` - Send message
|
||||||
|
- `im.v1.chat.list` - List chats
|
||||||
|
- `contact.v3.user.get` - Get user info
|
||||||
|
- `auth.v3.tenant_access_token.internal.post` - Get tenant token
|
||||||
|
|
||||||
|
### Typed API Wrappers
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use feishu_sdk::api::all_services;
|
||||||
|
|
||||||
|
// Get chat info
|
||||||
|
let builder = all_services::im::v1::chat::get(&client, "oc_xxx")
|
||||||
|
.query_param("user_id_type", "open_id");
|
||||||
|
|
||||||
|
// List users
|
||||||
|
let builder = all_services::contact::v3::user::list(&client)
|
||||||
|
.query_param("department_id", "0")
|
||||||
|
.query_param("page_size", "50");
|
||||||
|
```
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
See the `examples/` directory:
|
||||||
|
|
||||||
|
- `get_token.rs` - Get tenant access token
|
||||||
|
- `send_message.rs` - Send a message
|
||||||
|
- `get_user.rs` - Get user information
|
||||||
|
- `im_v1_message.rs` - IM message operations
|
||||||
|
- `drive_v1_file.rs` - Drive file operations
|
||||||
|
- `calendar_v4.rs` - Calendar operations
|
||||||
|
- `docx_v1.rs` - Docx document operations
|
||||||
|
- `sheets_v3.rs` - Sheets operations
|
||||||
|
- `bitable_v1.rs` - Bitable operations
|
||||||
|
- `approval_v4.rs` - Approval operations
|
||||||
|
- `event_server.rs` - Event handling server
|
||||||
|
- `card_handler.rs` - Card callback server
|
||||||
|
- `websocket_client.rs` - WebSocket client
|
||||||
|
- `full_app.rs` - End-to-end app example (client + event + card)
|
||||||
|
|
||||||
|
Run examples:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Set environment variables
|
||||||
|
export FEISHU_APP_ID=your_app_id
|
||||||
|
export FEISHU_APP_SECRET=your_app_secret
|
||||||
|
|
||||||
|
# Run example
|
||||||
|
cargo run --example get_token
|
||||||
|
|
||||||
|
# Run server example (requires server feature)
|
||||||
|
cargo run --features server --example event_server
|
||||||
|
|
||||||
|
# Run websocket example (requires websocket feature)
|
||||||
|
cargo run --features websocket --example websocket_client
|
||||||
|
```
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- Usage guide: [`docs/USAGE.md`](docs/USAGE.md)
|
||||||
|
- FAQ: [`docs/FAQ.md`](docs/FAQ.md)
|
||||||
|
- Migration guide: [`docs/MIGRATION.md`](docs/MIGRATION.md)
|
||||||
|
- Best practices: [`docs/BEST_PRACTICES.md`](docs/BEST_PRACTICES.md)
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use feishu_sdk::core::Error;
|
||||||
|
|
||||||
|
match client.operation("im.v1.messages.create").send().await {
|
||||||
|
Ok(resp) => println!("Success: {:?}", resp.json_value()?),
|
||||||
|
Err(Error::Api(api_error)) => {
|
||||||
|
println!("API Error: code={}, msg={}", api_error.code, api_error.msg);
|
||||||
|
}
|
||||||
|
Err(Error::RequestTimeout(_)) => println!("Request timed out"),
|
||||||
|
Err(e) => println!("Error: {}", e),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$zip = "$env:TEMP\larksdk.zip"
|
||||||
|
$dir = "$env:TEMP\larksdk"
|
||||||
|
curl.exe -sL "https://goproxy.cn/github.com/larksuite/oapi-sdk-go/v3/@v/v3.12.0.zip" -o $zip
|
||||||
|
if (Test-Path $dir) { Remove-Item -Recurse -Force $dir }
|
||||||
|
Expand-Archive -Path $zip -DestinationPath $dir -Force
|
||||||
|
Write-Host "=== ws 相关文件 ==="
|
||||||
|
Get-ChildItem -Recurse $dir -Filter *.go | Where-Object { $_.FullName -match '\\ws\\|ws\\.go|longconn' } | ForEach-Object { $_.FullName.Replace($dir, '') }
|
||||||
|
Write-Host "`n=== 顶层目录 ==="
|
||||||
|
Get-ChildItem $dir | ForEach-Object { Get-ChildItem $_.FullName -Directory | Select-Object -ExpandProperty Name } | Select-Object -Unique
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# 从 larkrs-tree.json 提取关键源文件路径(ws/card/event/channel)
|
||||||
|
$j = Get-Content 'E:\wk-lab\devflow\docs\02-架构设计\专项设计\.research\larkrs-tree.json' -Raw | ConvertFrom-Json
|
||||||
|
$j.tree | Where-Object { $_.path -match '(ws|card|event|channel)' -and $_.path -match '\.rs$' } | ForEach-Object { $_.path }
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,117 @@
|
|||||||
|
=== maemual @ 2024-03-22T06:42:29Z ===
|
||||||
|
近期卡片新特性较多,人力比较紧张,还在推进中。
|
||||||
|
可以继续使用旧版的卡片回传交互使用。
|
||||||
|
|
||||||
|
=== Martin2877 @ 2024-06-21T02:13:35Z ===
|
||||||
|
|
||||||
|
卡片回调一直报这个错误 `json: cannot unmarshal string into Go struct field CardAction.Header of type []string]`
|
||||||
|
|
||||||
|
=== sbaby1997 @ 2024-07-31T13:45:20Z ===
|
||||||
|
这个问题非常影响使用呀!
|
||||||
|
|
||||||
|
=== maemual @ 2024-07-31T13:53:03Z ===
|
||||||
|
@sbaby1997 升级最新版 v3.3.0,然后卡片回传交互选“卡片回传交互(旧)”
|
||||||
|
|
||||||
|
=== Martin2877 @ 2024-08-02T01:54:18Z ===
|
||||||
|
All right. 鉴于官方还没修,我这里提出一个临时解决方法,大家可以参考:
|
||||||
|
|
||||||
|
在 `package larkcard` 文件中
|
||||||
|
|
||||||
|
1. 修改 `model.go` 中的 `CardAction`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type CardAction struct {
|
||||||
|
*larkevent.EventReq
|
||||||
|
Schema string `json:"schema"`
|
||||||
|
Header struct {
|
||||||
|
EventID string `json:"event_id"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
CreateTime string `json:"create_time"`
|
||||||
|
EventType string `json:"event_type"`
|
||||||
|
TenantKey string `json:"tenant_key"`
|
||||||
|
AppID string `json:"app_id"`
|
||||||
|
} `json:"header"`
|
||||||
|
|
||||||
|
Challenge string `json:"challenge"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
|
||||||
|
Event struct {
|
||||||
|
Operator struct {
|
||||||
|
TenantKey string `json:"tenant_key"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
OpenID string `json:"open_id"`
|
||||||
|
UnionID string `json:"union_id"`
|
||||||
|
} `json:"operator"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
Action struct {
|
||||||
|
Tag string `json:"tag"`
|
||||||
|
Timezone string `json:"timezone"`
|
||||||
|
FormValue map[string]string `json:"form_value"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"action"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Context struct {
|
||||||
|
OpenMessageID string `json:"open_message_id"`
|
||||||
|
OpenChatID string `json:"open_chat_id"`
|
||||||
|
} `json:"context"`
|
||||||
|
} `json:"event"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
2. 修改`card.go`中的 `VerifySign`方法,使用 eventEncryptKey
|
||||||
|
|
||||||
|
```go
|
||||||
|
if h.eventEncryptKey == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
...
|
||||||
|
|
||||||
|
targetSign := Signature(requestTimestamp, requestNonce,h.eventEncryptKey,string(req.Body))
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 修改`card.go`中的 `Signature` 方法,使用 sha256加密
|
||||||
|
```go
|
||||||
|
h := sha256.New()
|
||||||
|
```
|
||||||
|
|
||||||
|
=== crazykun @ 2024-09-14T02:43:54Z ===
|
||||||
|
新的改成event事件承载了
|
||||||
|
[v3代码示例](https://github.com/larksuite/oapi-sdk-go/blob/v3_main/sample/event/event.go#L171)
|
||||||
|
```
|
||||||
|
.OnP2CardActionTrigger(func(ctx context.Context, event *callback.CardActionTriggerEvent) (*callback.CardActionTriggerReponse, error) {
|
||||||
|
fmt.Println("receive card action")
|
||||||
|
fmt.Println(larkcore.Prettify(event))
|
||||||
|
return nil, nil
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
=== kj648 @ 2025-01-23T11:52:59Z ===
|
||||||
|
对于卡片消息回调的 Value 应该定义成interface{} . 至少目前的卡片消息回调, 回调事件的值 支持 字符串,如果用字符串传进来 就直接cannot unmarshal string into Go struct XXXXXXX 。为了不出发这个error 只能强制使用 对象结构作为value
|
||||||
|
event *callback.CardActionTriggerEvent
|
||||||
|
```go
|
||||||
|
type CallBackAction struct {
|
||||||
|
Value map[string]interface{} `json:"value"`
|
||||||
|
Tag string `json:"tag"`
|
||||||
|
Option string `json:"option"`
|
||||||
|
Timezone string `json:"timezone"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
FormValue map[string]interface{} `json:"form_value"`
|
||||||
|
InputValue string `json:"input_value"`
|
||||||
|
Options []string `json:"options"`
|
||||||
|
Checked bool `json:"checked"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
=== xiaozhi747 @ 2025-03-14T09:53:22Z ===
|
||||||
|
还没修复么?
|
||||||
|
|
||||||
|
=== maemual @ 2025-03-14T10:12:24Z ===
|
||||||
|
|
||||||
|
@xiaozhi747 早就支持了新版卡片回调,建议按照 README 文档使用。
|
||||||
|
|
||||||
|
=== eyotang @ 2025-11-24T04:13:19Z ===
|
||||||
|
> 卡片回调一直报这个错误 `json: cannot unmarshal string into Go struct field CardAction.Header of type []string]`
|
||||||
|
|
||||||
|
v3.4.3也有这个问题,请问怎么解决的?header是map[string]string,但是sdk里面定义的是map[string][]string
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"url":"https://api.github.com/repos/larksuite/oapi-sdk-go/issues/130","repository_url":"https://api.github.com/repos/larksuite/oapi-sdk-go","labels_url":"https://api.github.com/repos/larksuite/oapi-sdk-go/issues/130/labels{/name}","comments_url":"https://api.github.com/repos/larksuite/oapi-sdk-go/issues/130/comments","events_url":"https://api.github.com/repos/larksuite/oapi-sdk-go/issues/130/events","html_url":"https://github.com/larksuite/oapi-sdk-go/issues/130","id":2201794740,"node_id":"I_kwDOEiHOJs6DPLi0","number":130,"title":"使用新版卡片回调消息格式不匹配,反序列化错误","user":{"login":"lxitgto","id":11627541,"node_id":"MDQ6VXNlcjExNjI3NTQx","avatar_url":"https://avatars.githubusercontent.com/u/11627541?v=4","gravatar_id":"","url":"https://api.github.com/users/lxitgto","html_url":"https://github.com/lxitgto","followers_url":"https://api.github.com/users/lxitgto/followers","following_url":"https://api.github.com/users/lxitgto/following{/other_user}","gists_url":"https://api.github.com/users/lxitgto/gists{/gist_id}","starred_url":"https://api.github.com/users/lxitgto/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/lxitgto/subscriptions","organizations_url":"https://api.github.com/users/lxitgto/orgs","repos_url":"https://api.github.com/users/lxitgto/repos","events_url":"https://api.github.com/users/lxitgto/events{/privacy}","received_events_url":"https://api.github.com/users/lxitgto/received_events","type":"User","user_view_type":"public","site_admin":false},"labels":[],"state":"closed","locked":false,"assignees":[],"milestone":null,"comments":10,"created_at":"2024-03-22T06:30:32Z","updated_at":"2025-11-24T04:13:19Z","closed_at":"2025-03-14T10:12:28Z","assignee":null,"author_association":"NONE","issue_field_values":[],"type":null,"active_lock_reason":null,"sub_issues_summary":{"total":0,"completed":0,"percent_completed":0},"issue_dependencies_summary":{"blocked_by":0,"total_blocked_by":0,"blocking":0,"total_blocking":0},"body":"使用新版卡片回调消息格式不匹配,反序列化错误。\r\n\r\n`CardAction struct`格式为v1格式,请问什么时候支持新版消息格式?","closed_by":{"login":"maemual","id":2352843,"node_id":"MDQ6VXNlcjIzNTI4NDM=","avatar_url":"https://avatars.githubusercontent.com/u/2352843?v=4","gravatar_id":"","url":"https://api.github.com/users/maemual","html_url":"https://github.com/maemual","followers_url":"https://api.github.com/users/maemual/followers","following_url":"https://api.github.com/users/maemual/following{/other_user}","gists_url":"https://api.github.com/users/maemual/gists{/gist_id}","starred_url":"https://api.github.com/users/maemual/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/maemual/subscriptions","organizations_url":"https://api.github.com/users/maemual/orgs","repos_url":"https://api.github.com/users/maemual/repos","events_url":"https://api.github.com/users/maemual/events{/privacy}","received_events_url":"https://api.github.com/users/maemual/received_events","type":"User","user_view_type":"public","site_admin":false},"reactions":{"url":"https://api.github.com/repos/larksuite/oapi-sdk-go/issues/130/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"timeline_url":"https://api.github.com/repos/larksuite/oapi-sdk-go/issues/130/timeline","performed_via_github_app":null,"state_reason":"completed","pinned_comment":null}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"total_count":1,"incomplete_results":false,"items":[{"url":"https://api.github.com/repos/larksuite/oapi-sdk-go/issues/130","repository_url":"https://api.github.com/repos/larksuite/oapi-sdk-go","labels_url":"https://api.github.com/repos/larksuite/oapi-sdk-go/issues/130/labels{/name}","comments_url":"https://api.github.com/repos/larksuite/oapi-sdk-go/issues/130/comments","events_url":"https://api.github.com/repos/larksuite/oapi-sdk-go/issues/130/events","html_url":"https://github.com/larksuite/oapi-sdk-go/issues/130","id":2201794740,"node_id":"I_kwDOEiHOJs6DPLi0","number":130,"title":"使用新版卡片回调消息格式不匹配,反序列化错误","user":{"login":"lxitgto","id":11627541,"node_id":"MDQ6VXNlcjExNjI3NTQx","avatar_url":"https://avatars.githubusercontent.com/u/11627541?v=4","gravatar_id":"","url":"https://api.github.com/users/lxitgto","html_url":"https://github.com/lxitgto","followers_url":"https://api.github.com/users/lxitgto/followers","following_url":"https://api.github.com/users/lxitgto/following{/other_user}","gists_url":"https://api.github.com/users/lxitgto/gists{/gist_id}","starred_url":"https://api.github.com/users/lxitgto/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/lxitgto/subscriptions","organizations_url":"https://api.github.com/users/lxitgto/orgs","repos_url":"https://api.github.com/users/lxitgto/repos","events_url":"https://api.github.com/users/lxitgto/events{/privacy}","received_events_url":"https://api.github.com/users/lxitgto/received_events","type":"User","user_view_type":"public","site_admin":false},"labels":[],"state":"closed","locked":false,"assignees":[],"milestone":null,"comments":10,"created_at":"2024-03-22T06:30:32Z","updated_at":"2025-11-24T04:13:19Z","closed_at":"2025-03-14T10:12:28Z","assignee":null,"author_association":"NONE","issue_field_values":[],"type":null,"active_lock_reason":null,"sub_issues_summary":{"total":0,"completed":0,"percent_completed":0},"issue_dependencies_summary":{"blocked_by":0,"total_blocked_by":0,"blocking":0,"total_blocking":0},"body":"使用新版卡片回调消息格式不匹配,反序列化错误。\r\n\r\n`CardAction struct`格式为v1格式,请问什么时候支持新版消息格式?","reactions":{"url":"https://api.github.com/repos/larksuite/oapi-sdk-go/issues/130/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"timeline_url":"https://api.github.com/repos/larksuite/oapi-sdk-go/issues/130/timeline","performed_via_github_app":null,"state_reason":"completed","pinned_comment":null,"score":1.0}],"search_type":"lexical"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"total_count":1,"incomplete_results":false,"items":[{"url":"https://api.github.com/repos/larksuite/oapi-sdk-go/issues/244","repository_url":"https://api.github.com/repos/larksuite/oapi-sdk-go","labels_url":"https://api.github.com/repos/larksuite/oapi-sdk-go/issues/244/labels{/name}","comments_url":"https://api.github.com/repos/larksuite/oapi-sdk-go/issues/244/comments","events_url":"https://api.github.com/repos/larksuite/oapi-sdk-go/issues/244/events","html_url":"https://github.com/larksuite/oapi-sdk-go/pull/244","id":5270680313,"node_id":"PR_kwDOEiHOJs8AAAABBPl61Q","number":244,"title":"fix(ws): make WebSocket client lifecycle cancellable","user":{"login":"SadMemories","id":44433408,"node_id":"MDQ6VXNlcjQ0NDMzNDA4","avatar_url":"https://avatars.githubusercontent.com/u/44433408?v=4","gravatar_id":"","url":"https://api.github.com/users/SadMemories","html_url":"https://github.com/SadMemories","followers_url":"https://api.github.com/users/SadMemories/followers","following_url":"https://api.github.com/users/SadMemories/following{/other_user}","gists_url":"https://api.github.com/users/SadMemories/gists{/gist_id}","starred_url":"https://api.github.com/users/SadMemories/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/SadMemories/subscriptions","organizations_url":"https://api.github.com/users/SadMemories/orgs","repos_url":"https://api.github.com/users/SadMemories/repos","events_url":"https://api.github.com/users/SadMemories/events{/privacy}","received_events_url":"https://api.github.com/users/SadMemories/received_events","type":"User","user_view_type":"public","site_admin":false},"labels":[],"state":"closed","locked":false,"assignees":[],"milestone":null,"comments":0,"created_at":"2026-08-27T18:07:43Z","updated_at":"2026-08-28T09:41:33Z","closed_at":"2026-08-28T09:41:33Z","assignee":null,"author_association":"COLLABORATOR","issue_field_values":[],"type":null,"active_lock_reason":null,"draft":false,"pull_request":{"url":"https://api.github.com/repos/larksuite/oapi-sdk-go/pulls/244","html_url":"https://github.com/larksuite/oapi-sdk-go/pull/244","diff_url":"https://github.com/larksuite/oapi-sdk-go/pull/244.diff","patch_url":"https://github.com/larksuite/oapi-sdk-go/pull/244.patch","merged_at":"2026-08-28T09:41:33Z"},"body":"## Summary\n- make `Client.Start` stop deterministically for caller cancellation, explicit `Close`, and terminal connection errors\n- preserve public bootstrap error types and synchronous lifecycle callback delivery\n- detect half-open connections with read deadlines, and bound WebSocket writes so read or write failures enter the reconnect flow\n- remove the bootstrap dependency on the global HTTP client: add `WithHttpClient` and `WithWebSocketDialer`, with a bounded default bootstrap HTTP client\n- keep `Close` callback-safe and non-blocking; add `CloseAndWait(ctx)` for callers that must wait for connection teardown, `OnDisconnected`, and SDK workers\n- restore legacy lifecycle log wording and per-connection `conn_id` output\n\n## Fixed issues\n\nFixes #189\nFixes #201\nFixes #204\nFixes #224\n\n## Notes\n- `CloseAndWait(ctx)` must be called outside lifecycle callbacks; callbacks should call `Close()` to avoid waiting for the coordinator that is currently executing them.\n- Ping may start before `OnReady` / `OnReconnected` returns, consistent with the accepted behavior for this PR.\n\n## Validation\n- `go test -count=1 -race ./ws`\n- `go test ./...`\n- IM sample: Code 0\n- event and card mocks: HTTP 200\n- WebSocket sample: connected, ping success, receive pong","reactions":{"url":"https://api.github.com/repos/larksuite/oapi-sdk-go/issues/244/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"timeline_url":"https://api.github.com/repos/larksuite/oapi-sdk-go/issues/244/timeline","performed_via_github_app":null,"state_reason":null,"score":1.0}],"search_type":"lexical"}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
{"message":"Not Found","documentation_url":"https://docs.github.com/rest/git/trees#get-a-tree","status":"404"}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use larksuite_oapi_sdk_rs::{CardAction, CardActionHandler, EventReq};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let handler =
|
||||||
|
CardActionHandler::new("verification_token", "", |action: CardAction| async move {
|
||||||
|
println!("card clicked by open_id={}", action.open_id);
|
||||||
|
println!("action value: {:?}", action.action.value);
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"toast": {
|
||||||
|
"type": "info",
|
||||||
|
"content": "Handled by Rust"
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
.skip_sign_verify();
|
||||||
|
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"open_id": "ou_xxx",
|
||||||
|
"open_message_id": "om_xxx",
|
||||||
|
"open_chat_id": "oc_xxx",
|
||||||
|
"tenant_key": "tenant_xxx",
|
||||||
|
"token": "verification_token",
|
||||||
|
"action": {
|
||||||
|
"tag": "button",
|
||||||
|
"value": {
|
||||||
|
"action": "acknowledge",
|
||||||
|
"ticket_id": "INC-1001"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let req = EventReq {
|
||||||
|
headers: HashMap::new(),
|
||||||
|
body: serde_json::to_vec(&payload)?,
|
||||||
|
request_uri: "/webhook/card".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let resp = handler.handle(req).await;
|
||||||
|
println!("response status: {}", resp.status_code);
|
||||||
|
println!("response body: {}", String::from_utf8_lossy(&resp.body));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
# Card protocol alignment
|
||||||
|
|
||||||
|
Message cards are an embedded JSON protocol: IM and CardKit APIs transport a
|
||||||
|
card as opaque content, so REST route parity cannot establish card-schema
|
||||||
|
coverage. This guide defines the required reference and verification workflow
|
||||||
|
for every change to the SDK's card support.
|
||||||
|
|
||||||
|
## Source roles
|
||||||
|
|
||||||
|
Use each reference for the thing it can prove:
|
||||||
|
|
||||||
|
1. **Official Lark card documentation** is the wire-contract authority. It
|
||||||
|
defines Card JSON 1.0 and 2.0 roots, component tags, field names, value
|
||||||
|
domains, constraints, client-version requirements, and deprecations.
|
||||||
|
2. **The Lark CLI card references and fixtures** are an operational
|
||||||
|
cross-check. They supply representative valid payloads and callback/update
|
||||||
|
lifecycle details, including `card.action.trigger`.
|
||||||
|
3. **The official Go SDK** is an implementation cross-check and compatibility
|
||||||
|
reference. Its card model may deliberately represent a different layer or
|
||||||
|
expose a different surface from a particular documentation page; an apparent
|
||||||
|
gap is a discrepancy to investigate, not evidence that the Go SDK is
|
||||||
|
obsolete or incomplete.
|
||||||
|
4. **The existing Rust SDK** is a regression baseline only. An existing
|
||||||
|
serializer or a test that repeats its output is not evidence of protocol
|
||||||
|
correctness.
|
||||||
|
|
||||||
|
When sources disagree, record the discrepancy in the protocol inventory and
|
||||||
|
investigate its version, schema, and transport context. The official
|
||||||
|
documentation defines the wire contract, while current Go SDK and CLI behavior
|
||||||
|
are required implementation evidence; do not assume that either implementation
|
||||||
|
is stale, or silently preserve a conflicting spelling.
|
||||||
|
|
||||||
|
## Required workflow
|
||||||
|
|
||||||
|
Before implementing or accepting an embedded-protocol change:
|
||||||
|
|
||||||
|
1. Classify it. REST resources and typed events use the Go compatibility
|
||||||
|
workflow; message cards use this workflow. A change touching both must pass
|
||||||
|
both sets of checks.
|
||||||
|
2. Refresh the pinned Go SDK and Lark CLI references, and record the source
|
||||||
|
revision plus official-documentation URLs and access date in the protocol
|
||||||
|
inventory.
|
||||||
|
3. Update the version-specific inventory before implementation. Card JSON 1.0
|
||||||
|
and 2.0 have separate roots and must have separate component, field,
|
||||||
|
enumeration, validation, and client-version entries.
|
||||||
|
4. Add or update exact JSON fixtures for every changed wire shape. Fixtures
|
||||||
|
must cover the tag, required fields, optional fields, enum spelling, and at
|
||||||
|
least one invalid cross-field combination where the protocol defines one.
|
||||||
|
5. Cross-check each changed card component against the CLI and Go references.
|
||||||
|
Record an absence from either reference as an absence, not as permission to
|
||||||
|
omit a documented capability.
|
||||||
|
6. Run the aggregate reference-alignment check and the focused Rust tests. A
|
||||||
|
new card feature cannot merge with only a builder unit test that asserts the
|
||||||
|
implementation's own JSON.
|
||||||
|
|
||||||
|
## Callback and update coverage
|
||||||
|
|
||||||
|
For callback-bearing Card JSON 1.0 components, the inventory and fixtures must
|
||||||
|
also cover the corresponding `card.action.trigger` fields, callback value or
|
||||||
|
form-value shape, and the three-second acknowledgement/update response. The
|
||||||
|
SDK may expose outbound card construction and inbound event types independently,
|
||||||
|
but their protocol relationship must be tested together.
|
||||||
|
|
||||||
|
Card JSON 2.0 and CardKit are separate tracks. CardKit's typed Card JSON 2.0
|
||||||
|
document transport and ordered text-content streaming have their own manifest
|
||||||
|
and transport fixtures; they are not optional extensions of a Card JSON 1.0
|
||||||
|
fixture. CardKit settings, element mutation, and batch-action helpers remain
|
||||||
|
separate protocol surfaces with their own exact transport fixtures.
|
||||||
|
|
||||||
|
## Gate status
|
||||||
|
|
||||||
|
The Card JSON 1.0 inventory and fixture corpus extend the deterministic
|
||||||
|
`reference-alignment-check` command with card fixtures and validation. The
|
||||||
|
aggregate command receives pinned Go and Lark CLI checkout paths, verifies
|
||||||
|
their revisions, and checks recorded source artifacts before it runs the Rust
|
||||||
|
fixture tests. Card checks remain one part of this aggregate gate, alongside
|
||||||
|
REST, event, and future protocol alignment; they do not get a separate command
|
||||||
|
or CI job.
|
||||||
|
|
||||||
|
The Card JSON 2.0 inventory is separate from the 1.0 corpus. It records the
|
||||||
|
incompatible root, every documented handwritten component, the root and
|
||||||
|
cross-field constraints that the typed AST must enforce, and exact valid and
|
||||||
|
invalid wire fixtures. `partial` means that a component has a typed wire shape
|
||||||
|
but is not yet eligible to be marked complete: its optional fields, validation,
|
||||||
|
and exact fixtures must still be covered. The documented Card-Builder-only
|
||||||
|
recycling container is recorded as a known divergence because it has no
|
||||||
|
handwritten Card JSON tag.
|
||||||
|
|
||||||
|
Card Builder is not a Card JSON AST. The SDK does not replicate the visual
|
||||||
|
editor or represent builder-only components. It supports delivery of a published
|
||||||
|
template through `card::template::TemplateMessage<T>`. `T` is a caller-defined
|
||||||
|
`Serialize` variable object, validated before delivery; `TemplateMessage` uses
|
||||||
|
`JsonValue` by default as the dynamic JSON escape hatch. The template-delivery
|
||||||
|
manifest keeps that envelope, its IM transport, and the builder-only
|
||||||
|
recycling-container example in the same aggregate alignment check.
|
||||||
|
|
||||||
|
`implemented` is reserved for a component whose field-level inventory, valid
|
||||||
|
fixture, invalid constraint fixture where applicable, and direct serde
|
||||||
|
round-trip coverage are all present. A complete component group does not imply
|
||||||
|
that the incompatible Card JSON 2.0 root or unrelated interaction and update
|
||||||
|
surfaces are complete.
|
||||||
|
|
||||||
|
## CardKit document, mutation, and instance coverage
|
||||||
|
|
||||||
|
`card::cardkit::CardDocument` accepts only a locally validated Card JSON 2.0
|
||||||
|
document and emits CardKit's escaped `type: "card_json"` payload. The typed
|
||||||
|
`LarkClient::cardkit_cards()` helper covers entity creation and creates an
|
||||||
|
ordered update session for full replacement and full-content updates on one
|
||||||
|
text or Markdown element.
|
||||||
|
|
||||||
|
Creation returns a CardKit entity ID, not an IM message. Send it through an IM
|
||||||
|
`interactive` request with `card::cardkit::CardEntityMessage`, which emits the
|
||||||
|
separate `{"type":"card","data":{"card_id":"..."}}` content envelope.
|
||||||
|
Do not reuse the CardKit `card_json` REST envelope for this step.
|
||||||
|
|
||||||
|
Each CardKit update carries a caller-provided idempotency key and a positive
|
||||||
|
sequence. One `CardKitUpdateSession` owns the sequence across document and
|
||||||
|
content, settings, element, and batch updates, advancing it only after a
|
||||||
|
successful request; a failed update can therefore be retried with the same
|
||||||
|
sequence and key. Content updates intentionally send the complete current
|
||||||
|
value: Lark renders a prefix extension as a typewriter update and otherwise
|
||||||
|
replaces the element content.
|
||||||
|
|
||||||
|
The typed mutation surface retains Card JSON 2.0 settings and full elements,
|
||||||
|
validates partial-element and insertion constraints, and encodes the nested
|
||||||
|
objects required by CardKit batch actions exactly once. It also covers template
|
||||||
|
instance creation and variable updates (a name-keyed object with open JSON
|
||||||
|
values). The pinned Go CardKit source does not expose Card Instance resources,
|
||||||
|
so those endpoints are normatively tracked against the official documentation
|
||||||
|
rather than unrelated Go template models. The raw generated resource methods
|
||||||
|
remain available for any future CardKit endpoint not yet given a typed
|
||||||
|
composition layer.
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# Cards
|
||||||
|
|
||||||
|
The SDK keeps Card JSON 1.0, Card JSON 2.0, CardKit, and published Card Builder
|
||||||
|
templates as separate protocols. Choose the smallest surface that matches the
|
||||||
|
message lifecycle you need.
|
||||||
|
|
||||||
|
## Send a Card JSON document
|
||||||
|
|
||||||
|
Build a versioned Card JSON value, then create a `CardDocument` before passing
|
||||||
|
it to IM or channel message helpers. `CardDocument::new` validates the document;
|
||||||
|
outbound interactive-card helpers deliberately reject arbitrary serializable
|
||||||
|
values.
|
||||||
|
|
||||||
|
[`examples/card_send.rs`](../examples/card_send.rs) sends a validated Card JSON
|
||||||
|
1.0 document through the IM create-message API:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
APP_ID=... APP_SECRET=... CHAT_ID=oc_... cargo run --example card_send
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `card::v1` for Card JSON 1.0. Use `card::v2` for Card JSON 2.0; its root,
|
||||||
|
shared-card requirements, and element-identifier rules are incompatible with
|
||||||
|
1.0.
|
||||||
|
|
||||||
|
## Author Card JSON 2.0 layouts safely
|
||||||
|
|
||||||
|
`CardDocument::new` remains the final strict protocol gate. For dynamic
|
||||||
|
authoring, `CardDocument::new_with_diagnostic` preserves the original
|
||||||
|
`ValidationError` and also returns a stable code, a JSON Pointer, the violated
|
||||||
|
constraint, and relevant legal values. Do not parse its display string.
|
||||||
|
|
||||||
|
`ColumnSet` has constructors for the three legal width branches. They emit the
|
||||||
|
wire shape required by Card JSON 2.0 and leave document validation enabled:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use larksuite_oapi_sdk_rs::card::v2::{
|
||||||
|
AutoColumn, Body, Card, CardDocument, ColumnSet, ColumnWeight, Config,
|
||||||
|
Element, FixedColumn, FixedColumnWidth, Markdown, WeightedColumn,
|
||||||
|
};
|
||||||
|
|
||||||
|
let automatic = Element::ColumnSet(ColumnSet::automatic([
|
||||||
|
AutoColumn::new().element(Element::Markdown(Markdown::new("auto"))),
|
||||||
|
]));
|
||||||
|
|
||||||
|
let fixed = Element::ColumnSet(ColumnSet::fixed([
|
||||||
|
FixedColumn::new(FixedColumnWidth::pixels(160)?)
|
||||||
|
.element(Element::Markdown(Markdown::new("fixed"))),
|
||||||
|
]));
|
||||||
|
|
||||||
|
let weighted = Element::ColumnSet(ColumnSet::weighted([
|
||||||
|
WeightedColumn::new(ColumnWeight::new(3)?)
|
||||||
|
.element(Element::Markdown(Markdown::new("weighted"))),
|
||||||
|
]));
|
||||||
|
|
||||||
|
let card = Card::new()
|
||||||
|
.config(Config::new().update_multi())
|
||||||
|
.body(Body::new().element(automatic).element(fixed).element(weighted));
|
||||||
|
let _document = CardDocument::new(card)?;
|
||||||
|
# Ok::<(), larksuite_oapi_sdk_rs::card::v2::ValidationError>(())
|
||||||
|
```
|
||||||
|
|
||||||
|
The `Padding`, `Margin`, `Spacing::pixels`, `FixedColumnWidth`, and
|
||||||
|
`ColumnWeight` constructors encode the documented finite pixel/range grammar.
|
||||||
|
Existing string-oriented builders remain supported for compatibility and are
|
||||||
|
still validated by `CardDocument`.
|
||||||
|
|
||||||
|
## Stream a CardKit document
|
||||||
|
|
||||||
|
CardKit accepts a validated Card JSON 2.0 `card::cardkit::CardDocument`.
|
||||||
|
`LarkClient::cardkit_cards()` creates the entity; send that entity once with a
|
||||||
|
`card::cardkit::CardEntityMessage` through IM before beginning ordered updates.
|
||||||
|
Keep one update session for full-document, settings, element, and content
|
||||||
|
updates; each successful mutation advances its sequence. CardKit entities have
|
||||||
|
the documented 14-day lifetime and may be sent only once.
|
||||||
|
|
||||||
|
[`examples/cardkit_stream.rs`](../examples/cardkit_stream.rs) creates a
|
||||||
|
streaming CardKit card and replaces a Markdown element's complete content:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
APP_ID=... APP_SECRET=... CHAT_ID=oc_... cargo run --example cardkit_stream
|
||||||
|
```
|
||||||
|
|
||||||
|
## Send a published Card Builder template
|
||||||
|
|
||||||
|
Card Builder's editor and builder-only components are not a Card JSON AST.
|
||||||
|
Send a published template with `TemplateMessage<T>`, where `T: Serialize` is
|
||||||
|
your application's template-variable object. The SDK requires that it serialize
|
||||||
|
to a JSON object; `TemplateMessage<JsonValue>` remains available for dynamic
|
||||||
|
bindings.
|
||||||
|
|
||||||
|
[`examples/card_template_send.rs`](../examples/card_template_send.rs) sends a
|
||||||
|
typed published template:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
APP_ID=... APP_SECRET=... CHAT_ID=oc_... CARD_TEMPLATE_ID=... \
|
||||||
|
cargo run --example card_template_send
|
||||||
|
```
|
||||||
|
|
||||||
|
## Handle callbacks
|
||||||
|
|
||||||
|
Card callbacks are inbound events. Use
|
||||||
|
[`examples/card_action_handler.rs`](../examples/card_action_handler.rs) for a
|
||||||
|
typed `CardActionHandler` that returns a callback response. Application-level
|
||||||
|
authorization, routing, deduplication, and state management remain the
|
||||||
|
application's responsibility.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
use larksuite_oapi_sdk_rs::channel::{Channel, ChannelPolicy};
|
||||||
|
use larksuite_oapi_sdk_rs::{EventDispatcher, LarkClient, LarkError};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let app_id = std::env::var("APP_ID").expect("APP_ID env var required");
|
||||||
|
let app_secret = std::env::var("APP_SECRET").expect("APP_SECRET env var required");
|
||||||
|
let verification_token = std::env::var("VERIFICATION_TOKEN").unwrap_or_default();
|
||||||
|
let encrypt_key = std::env::var("ENCRYPT_KEY").unwrap_or_default();
|
||||||
|
|
||||||
|
let client = LarkClient::builder(app_id, app_secret).build()?;
|
||||||
|
let dispatcher = EventDispatcher::new(verification_token, encrypt_key);
|
||||||
|
|
||||||
|
Channel::builder(&client, dispatcher)
|
||||||
|
.policy(ChannelPolicy::default().require_mention(false))
|
||||||
|
.on_message(|message| async move {
|
||||||
|
println!("message_id: {}", message.message_id);
|
||||||
|
println!("raw content: {}", message.content);
|
||||||
|
println!("normalized text: {}", message.text.unwrap_or_default());
|
||||||
|
for resource in message.resources {
|
||||||
|
println!("resource: {} {}", resource.resource_type, resource.file_key);
|
||||||
|
}
|
||||||
|
Ok::<(), LarkError>(())
|
||||||
|
})
|
||||||
|
.build()
|
||||||
|
.start()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
# Events, WebSockets, and channels
|
||||||
|
|
||||||
|
Webhook handlers use `EventDispatcher` for typed event callbacks, signature
|
||||||
|
verification, and encrypted payload processing. The optional `ws` feature adds
|
||||||
|
long connections for event delivery; see
|
||||||
|
[`examples/ws_client.rs`](../examples/ws_client.rs).
|
||||||
|
|
||||||
|
Trusted user channels match the upstream Go SDK behavior. Configure the channel
|
||||||
|
before obtaining its control handle, start the client, then bind a user token
|
||||||
|
after the connection becomes ready. See
|
||||||
|
[`examples/ws_client.rs`](../examples/ws_client.rs) for the connection lifecycle
|
||||||
|
and [`examples/channel_send.rs`](../examples/channel_send.rs) for higher-level
|
||||||
|
messages.
|
||||||
|
|
||||||
|
```rust,no_run
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
# use larksuite_oapi_sdk_rs::{EventDispatcher, LarkClient};
|
||||||
|
# #[tokio::main]
|
||||||
|
# async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
# let client = LarkClient::builder("APP_ID", "APP_SECRET").build()?;
|
||||||
|
# let dispatcher = EventDispatcher::new("VERIFICATION_TOKEN", "ENCRYPT_KEY");
|
||||||
|
let ws_client = client
|
||||||
|
.ws_client(dispatcher)
|
||||||
|
.channel_tag("trusted_channel")
|
||||||
|
.websocket_query_params(HashMap::from([("env".into(), "staging".into())]));
|
||||||
|
let control = ws_client.control();
|
||||||
|
let task = tokio::spawn(ws_client.start());
|
||||||
|
|
||||||
|
while control.connection_id().is_none() {
|
||||||
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||||
|
}
|
||||||
|
control.attach_user("USER_ACCESS_TOKEN").await?;
|
||||||
|
// Later, before dropping the user session:
|
||||||
|
// control.detach_user("USER_ACCESS_TOKEN").await?;
|
||||||
|
control.close_and_wait().await;
|
||||||
|
task.await??;
|
||||||
|
# Ok(())
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|
||||||
|
`WsClientControl::close` requests shutdown without waiting, which is safe from
|
||||||
|
lifecycle callbacks. `close_and_wait` is for external teardown and waits until
|
||||||
|
the running client has stopped. The client honors a finite reconnect count from
|
||||||
|
the gateway bootstrap configuration, treats an inbound-silent connection as
|
||||||
|
failed after two ping intervals plus five seconds, and reconnects after either
|
||||||
|
that liveness timeout or a timed-out connection write. It bounds each
|
||||||
|
connection write to ten seconds by default and accepts `write_timeout` for a
|
||||||
|
different bound. Event callbacks run independently of the receive loop, so a
|
||||||
|
slow callback does not mask a silent gateway. Use
|
||||||
|
`websocket_connector` when a custom proxy, TLS, or test dial transport is
|
||||||
|
needed; it affects only the WebSocket gateway dial, not the bootstrap HTTP
|
||||||
|
client.
|
||||||
|
|
||||||
|
## Calendar share-token joins
|
||||||
|
|
||||||
|
Calendar invitations provide an opaque share token through their link, QR
|
||||||
|
code, or RSVP card. Join with that token; the endpoint deliberately has no
|
||||||
|
event-ID alternative.
|
||||||
|
|
||||||
|
```rust,no_run
|
||||||
|
use larksuite_oapi_sdk_rs::service::calendar::v4::JoinCalendarEventReqBody;
|
||||||
|
use larksuite_oapi_sdk_rs::{LarkClient, RequestOption};
|
||||||
|
|
||||||
|
# async fn example() -> Result<(), larksuite_oapi_sdk_rs::LarkError> {
|
||||||
|
let client = LarkClient::builder("APP_ID", "APP_SECRET").build()?;
|
||||||
|
client
|
||||||
|
.calendar()
|
||||||
|
.calendar
|
||||||
|
.join_event(
|
||||||
|
&JoinCalendarEventReqBody::new("CALENDAR_SHARE_TOKEN"),
|
||||||
|
&RequestOption::default(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
# Ok(())
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the optional `axum` feature for Axum HTTP adapters. The optional `messaging`
|
||||||
|
feature provides outbound send, reply, update, upload, and download operations
|
||||||
|
without WebSocket dependencies. The optional `channel` feature adds inbound
|
||||||
|
message normalization and runtime policy controls and enables both `messaging`
|
||||||
|
and `ws`.
|
||||||
|
|
||||||
|
## Replies
|
||||||
|
|
||||||
|
`ChannelMessaging::send` preserves convenience behavior: a reply whose target has
|
||||||
|
disappeared may retry as a top-level send. For workflows that must never escape
|
||||||
|
a thread, use `ChannelMessaging::reply` or `ChannelMessaging::reply_in_thread`.
|
||||||
|
Both methods only call the reply endpoint and return its error unchanged. Set
|
||||||
|
`uuid` to opt into Lark's one-hour idempotency window; automatic split messages
|
||||||
|
derive distinct UUIDs, so the supplied UUID must leave room for a `-N` suffix.
|
||||||
|
|
||||||
|
```rust,no_run
|
||||||
|
use larksuite_oapi_sdk_rs::channel::SendInput;
|
||||||
|
use larksuite_oapi_sdk_rs::{LarkClient, RequestOption};
|
||||||
|
|
||||||
|
# async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let client = LarkClient::builder("APP_ID", "APP_SECRET").build()?;
|
||||||
|
let messaging = client.channel_messaging();
|
||||||
|
messaging
|
||||||
|
.reply_in_thread(
|
||||||
|
"om_parent_message",
|
||||||
|
&SendInput {
|
||||||
|
markdown: Some("A reply that stays in the topic".into()),
|
||||||
|
uuid: Some("order-42-status".into()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
&RequestOption::default(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
# Ok(())
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Message updates
|
||||||
|
|
||||||
|
Use `ChannelMessaging::edit_text` to update a text message. Use
|
||||||
|
`ChannelMessaging::edit_card` only for validated interactive-card documents; it
|
||||||
|
uses the separate card patch operation. See
|
||||||
|
[`examples/card_send.rs`](../examples/card_send.rs) for Card JSON delivery and
|
||||||
|
[`examples/card_action_handler.rs`](../examples/card_action_handler.rs) for
|
||||||
|
interactive callback responses.
|
||||||
|
|
||||||
|
```rust,no_run
|
||||||
|
use larksuite_oapi_sdk_rs::card::v1::{Card, CardDocument, Div, Element, Header, Text};
|
||||||
|
use larksuite_oapi_sdk_rs::{LarkClient, RequestOption};
|
||||||
|
|
||||||
|
# async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let client = LarkClient::builder("APP_ID", "APP_SECRET").build()?;
|
||||||
|
let messaging = client.channel_messaging();
|
||||||
|
let card = CardDocument::new(
|
||||||
|
Card::new()
|
||||||
|
.header(Header::new("Done"))
|
||||||
|
.element(Element::Div(Div::new(Text::lark_md("Complete")))),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
messaging
|
||||||
|
.edit_text("om_text_message", "Updated status", &RequestOption::default())
|
||||||
|
.await?;
|
||||||
|
messaging
|
||||||
|
.edit_card(
|
||||||
|
"om_card_message",
|
||||||
|
&card,
|
||||||
|
&RequestOption::default(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
# Ok(())
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cards
|
||||||
|
|
||||||
|
Use `card::v1` or `card::v2` to build versioned Card JSON, and validate it with
|
||||||
|
their `CardDocument` types before sending. `CardActionHandler` handles callback
|
||||||
|
payloads. See the [Cards guide](cards.md) for CardKit and published Card Builder
|
||||||
|
templates.
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
# larksuite-oapi-sdk-rs
|
||||||
|
|
||||||
|
[](https://crates.io/crates/larksuite-oapi-sdk-rs)
|
||||||
|
[](https://docs.rs/larksuite-oapi-sdk-rs)
|
||||||
|
[](https://github.com/adamcavendish/larksuite-oapi-sdk-rs/actions/workflows/ci.yml)
|
||||||
|
[](./LICENSE)
|
||||||
|
|
||||||
|
Non-official, Tokio-native Rust SDK for Lark / Feishu (飞书) Open Platform
|
||||||
|
APIs, bots, events, WebSockets, and interactive cards. It follows the official
|
||||||
|
[Go SDK](https://github.com/larksuite/oapi-sdk-go) closely while providing
|
||||||
|
idiomatic Rust configuration, typed REST resources, event handling, and optional
|
||||||
|
WebSocket, Axum, and channel integration.
|
||||||
|
|
||||||
|
## Build with Lark / Feishu
|
||||||
|
|
||||||
|
- **Build a bot:** receive typed message events and reply through the IM API.
|
||||||
|
- **Receive events:** use WebSocket long connections without a public callback
|
||||||
|
URL, or verify and dispatch HTTP webhooks.
|
||||||
|
- **Automate Open Platform workflows:** use typed REST resources for messages,
|
||||||
|
calendars, documents, Base, approvals, and more.
|
||||||
|
|
||||||
|
New to the platform? Start with the
|
||||||
|
[build a Lark / Feishu bot tutorial](docs/tutorial-build-a-feishu-bot.md). It
|
||||||
|
creates a bot that replies to received messages over a WebSocket long
|
||||||
|
connection.
|
||||||
|
|
||||||
|
## Highlights
|
||||||
|
|
||||||
|
- Tenant, user, app, and marketplace token acquisition with configurable
|
||||||
|
caching and retry behavior.
|
||||||
|
- Typed REST resources with query inputs, upload/download helpers, and lazy
|
||||||
|
iterators for supported paginated endpoints.
|
||||||
|
- Raw requests and a Go-compatibility bridge for endpoints not yet promoted to
|
||||||
|
dedicated Rust resources.
|
||||||
|
- Webhook event dispatch with signature verification and encrypted payload
|
||||||
|
decryption.
|
||||||
|
- Optional WebSocket long connections, Axum handlers, and higher-level channel
|
||||||
|
message helpers.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
larksuite-oapi-sdk-rs = "0.3.12"
|
||||||
|
```
|
||||||
|
|
||||||
|
The minimum supported Rust version is 1.95.0.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
Build a client once, then call a typed resource. The SDK handles tenant-token
|
||||||
|
acquisition and request authentication for this endpoint.
|
||||||
|
|
||||||
|
```rust,no_run
|
||||||
|
use larksuite_oapi_sdk_rs::service::im::v1::CreateMessageReqBody;
|
||||||
|
use larksuite_oapi_sdk_rs::{LarkClient, RequestOption};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let client = LarkClient::builder("APP_ID", "APP_SECRET").build()?;
|
||||||
|
let body = CreateMessageReqBody {
|
||||||
|
receive_id: Some("CHAT_ID".to_string()),
|
||||||
|
msg_type: Some("text".to_string()),
|
||||||
|
content: Some(r#"{"text":"Hello from Rust"}"#.to_string()),
|
||||||
|
uuid: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.im()
|
||||||
|
.message
|
||||||
|
.create("chat_id", &body, &RequestOption::default())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
println!("success: {}", response.success());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `LarkClient::builder` to set a base URL, timeout, retry limit, token cache,
|
||||||
|
custom headers, marketplace mode, helpdesk credentials, or a JWT client
|
||||||
|
assertion provider. See [`examples/client_config.rs`](examples/client_config.rs)
|
||||||
|
for the complete setup.
|
||||||
|
|
||||||
|
## Common Flows
|
||||||
|
|
||||||
|
| Need | Start here |
|
||||||
|
| --- | --- |
|
||||||
|
| Build a first Lark / Feishu bot | [Tutorial: build a Lark / Feishu bot](docs/tutorial-build-a-feishu-bot.md) |
|
||||||
|
| Call a typed REST endpoint | [`examples/send_message.rs`](examples/send_message.rs) or [generated examples](examples/README.md) |
|
||||||
|
| Call an uncovered endpoint | [`examples/raw_api.rs`](examples/raw_api.rs) |
|
||||||
|
| Control VC meeting bots | [VC meeting bots](docs/vc-bots.md) |
|
||||||
|
| Export Spark app source | [Spark app export](docs/spark-app-export.md) |
|
||||||
|
| Preview or apply Spark database schema changes | [Spark database migration](docs/spark-db-migration.md) |
|
||||||
|
| Read or modify Mail conversations | [Mail user-mailbox threads](docs/mail-v1-threads.md) |
|
||||||
|
| Resolve Drive object metadata by token | [Drive token lookup](docs/drive-token-lookup.md) |
|
||||||
|
| Manage Unified KMS autonomous keys | [Unified KMS](docs/unified-kms.md) |
|
||||||
|
| Discover or manage BaseApps | [`examples/base_v3_app_read.rs`](examples/base_v3_app_read.rs) and [Base v3 application mode](docs/base-v3-apps.md) |
|
||||||
|
| Manage Base dashboards or dashboard blocks | [Base v3 dashboards](docs/base-v3-dashboards.md) |
|
||||||
|
| Read or update Base dashboard or form sharing | [Base v3 sharing](docs/base-v3-sharing.md) |
|
||||||
|
| Manage Base v3 forms and questions | [Base v3 forms](docs/base-v3-forms.md) |
|
||||||
|
| Manage Base workflows or visible fields | [Base workflows](docs/base-v3-workflows.md) and [Base visible fields](docs/base-visible-fields.md) |
|
||||||
|
| Configure a Base field extension or share records | [Base v3 field extensions and record share links](docs/base-v3-field-extensions.md) |
|
||||||
|
| Read or update modern Docx content | [`examples/docs_ai_fetch.rs`](examples/docs_ai_fetch.rs) and [Docs AI document content](docs/docs-ai.md) |
|
||||||
|
| Read, edit, or render Slides AI presentations | [`examples/slides_ai_read.rs`](examples/slides_ai_read.rs), [`examples/slides_ai_render.rs`](examples/slides_ai_render.rs), and [Slides AI presentation content](docs/slides-ai.md) |
|
||||||
|
| Exchange or refresh OAuth tokens | [`examples/authen_oauth.rs`](examples/authen_oauth.rs) and [OAuth SSO guide](docs/oauth-sso.md) |
|
||||||
|
| Join a shared calendar event | [Events, WebSockets, and channels](docs/events-and-channels.md#calendar-share-token-joins) |
|
||||||
|
| Receive webhook events | [`examples/event_handler.rs`](examples/event_handler.rs) |
|
||||||
|
| Send a validated interactive card | [`examples/card_send.rs`](examples/card_send.rs) and [Cards guide](docs/cards.md) |
|
||||||
|
| Stream a CardKit document | [`examples/cardkit_stream.rs`](examples/cardkit_stream.rs) and [Cards guide](docs/cards.md) |
|
||||||
|
| Send a published Card Builder template | [`examples/card_template_send.rs`](examples/card_template_send.rs) and [Cards guide](docs/cards.md) |
|
||||||
|
| Handle interactive card callbacks | [`examples/card_action_handler.rs`](examples/card_action_handler.rs) |
|
||||||
|
| Run a WebSocket event client | [`examples/ws_client.rs`](examples/ws_client.rs) with `ws` enabled |
|
||||||
|
| Send channel messages | [`examples/channel_send.rs`](examples/channel_send.rs) with `messaging` enabled |
|
||||||
|
| Normalize channel messages | [`examples/channel_normalize.rs`](examples/channel_normalize.rs) with `channel` enabled |
|
||||||
|
| Search bots or work with OKRs | [`examples/bot_search.rs`](examples/bot_search.rs) and [`examples/okr_v2.rs`](examples/okr_v2.rs) |
|
||||||
|
|
||||||
|
## Guides
|
||||||
|
|
||||||
|
- [Tutorial: build a Lark / Feishu bot](docs/tutorial-build-a-feishu-bot.md):
|
||||||
|
create a WebSocket-connected bot that receives and replies to messages.
|
||||||
|
- [Go service and event compatibility](docs/go-compatibility.md): raw
|
||||||
|
requests, the compatibility bridge, and deterministic Go-to-Rust checks.
|
||||||
|
- [Cards](docs/cards.md): versioned Card JSON, CardKit streaming, published
|
||||||
|
Card Builder templates, and callbacks.
|
||||||
|
- [Card protocol alignment](docs/card-protocol.md): source hierarchy and
|
||||||
|
verification workflow for Card JSON and card callbacks.
|
||||||
|
- [Events, WebSockets, and channels](docs/events-and-channels.md): webhook
|
||||||
|
dispatch, long connections, trusted user channels, replies, and updates.
|
||||||
|
- [OAuth SSO](docs/oauth-sso.md): Feishu and Lark browser login, PKCE, token
|
||||||
|
refresh, and application-owned session handling.
|
||||||
|
- [VC meeting bots](docs/vc-bots.md): event queries and outbound meeting join,
|
||||||
|
leave, message, countdown, and active-meeting operations.
|
||||||
|
- [Spark app export](docs/spark-app-export.md): streaming source archives,
|
||||||
|
response validation, and app-type-dependent source snapshots.
|
||||||
|
- [Spark database migration](docs/spark-db-migration.md): explicit preview,
|
||||||
|
irreversible apply, and caller-owned asynchronous status polling.
|
||||||
|
- [Mail user-mailbox threads](docs/mail-v1-threads.md): conversation reads
|
||||||
|
and batch label, folder, and Trash operations.
|
||||||
|
- [Drive token lookup](docs/drive-token-lookup.md): underlying object metadata
|
||||||
|
resolution without implicit download or export.
|
||||||
|
- [Unified KMS](docs/unified-kms.md): tenant-scoped autonomous-key lifecycle,
|
||||||
|
deletion plans, recovery, and import material.
|
||||||
|
- [Base v3 application mode](docs/base-v3-apps.md): user-token workspace,
|
||||||
|
BaseApp, page, and block operations.
|
||||||
|
- [Base v3 dashboards](docs/base-v3-dashboards.md): dashboard and dashboard
|
||||||
|
block lifecycle, chart data, and layout operations.
|
||||||
|
- [Base v3 sharing](docs/base-v3-sharing.md): dashboard and form sharing
|
||||||
|
reads and partial updates with user or tenant credentials.
|
||||||
|
- [Base v3 forms](docs/base-v3-forms.md): form lifecycle and form-question
|
||||||
|
operations, including explicit destructive-delete semantics.
|
||||||
|
- [Base v3 workflows](docs/base-v3-workflows.md): workflow lifecycle with
|
||||||
|
caller-owned definition bodies.
|
||||||
|
- [Base visible fields](docs/base-visible-fields.md): full-list view and form
|
||||||
|
field visibility and ordering.
|
||||||
|
- [Base v3 field extensions and record share links](docs/base-v3-field-extensions.md):
|
||||||
|
typed extension configuration, cell updates, and batch record links.
|
||||||
|
- [Docs AI document content](docs/docs-ai.md): modern Docx content fetches,
|
||||||
|
updates, asynchronous-task reads, and version-history operations.
|
||||||
|
- [Slides AI presentation content](docs/slides-ai.md): XML presentation and
|
||||||
|
slide operations with version-history support.
|
||||||
|
- [Documentation index](docs/README.md): detailed guides and runnable examples.
|
||||||
|
|
||||||
|
## Migration from 0.2
|
||||||
|
|
||||||
|
Version 0.3 makes API-breaking corrections so the public Rust surface matches
|
||||||
|
the upstream Lark contracts more closely.
|
||||||
|
|
||||||
|
- Replace `Client` with `LarkClient` and `ClientBuilder` with
|
||||||
|
`LarkClientBuilder`.
|
||||||
|
- Replace `client.wiki()` with `client.wiki_v1()` or `client.wiki_v2()`.
|
||||||
|
- Replace `do_req`, `do_req_typed`, and verb helpers with `raw_request*` and an
|
||||||
|
`ApiReq` when a dedicated typed resource is unavailable.
|
||||||
|
- Treat Hire v1 `IdNameObject.name` as `Option<I18n>` with `zh_cn` and `en_us`
|
||||||
|
fields.
|
||||||
|
- Handle JSON construction failures as `LarkError::Json`; legacy card element
|
||||||
|
helpers are fallible for the same reason.
|
||||||
|
- Use `JsonValue` for intentionally dynamic API data instead of public
|
||||||
|
`serde_json::Value` model fields.
|
||||||
|
|
||||||
|
Typed REST responses and incoming event payloads are `#[non_exhaustive]`.
|
||||||
|
Deserialize them and read their public fields as before, but do not use struct
|
||||||
|
literals to create server-owned values.
|
||||||
|
|
||||||
|
## Cargo Features
|
||||||
|
|
||||||
|
| Feature | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| `ws` | WebSocket long-connection event client with protobuf framing. |
|
||||||
|
| `messaging` | High-level outbound messaging and resource operations without WebSocket dependencies. |
|
||||||
|
| `channel` | Inbound normalization and runtime policy helpers; enables `messaging` and `ws`. |
|
||||||
|
| `axum` | Axum adapters for webhook and card-action handlers. |
|
||||||
|
|
||||||
|
Cargo unifies enabled features across the dependency graph. Enabling `channel`
|
||||||
|
therefore enables both `messaging` and `ws`; enabling `messaging` and `ws`
|
||||||
|
separately does not enable the `channel` runtime API.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
larksuite-oapi-sdk-rs = { version = "0.3.12", features = ["ws", "axum"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Coverage
|
||||||
|
|
||||||
|
The SDK provides dedicated resources across IM, Calendar, Drive, Docs, Sheets,
|
||||||
|
Bitable, Contacts, Approval, VC, Wiki, Hire, and the broader Lark/Feishu
|
||||||
|
OpenAPI surface. Generated smoke tests verify request paths, query inputs,
|
||||||
|
bodies, uploads, downloads, and response handling. See the
|
||||||
|
[changelog](CHANGELOG.md) for release detail and the
|
||||||
|
[examples index](examples/README.md) for runnable calls.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Rust 1.95.0 or newer
|
||||||
|
- Rust edition 2024
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Licensed under [MIT](LICENSE).
|
||||||
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,97 @@
|
|||||||
|
use larksuite_oapi_sdk_rs::event::EventDispatcher;
|
||||||
|
use larksuite_oapi_sdk_rs::events::im::P2MessageReceiveV1;
|
||||||
|
use larksuite_oapi_sdk_rs::service::im::v1::CreateMessageReqBody;
|
||||||
|
use larksuite_oapi_sdk_rs::{LarkClient, LarkError, RequestOption};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
tracing_subscriber::fmt::init();
|
||||||
|
|
||||||
|
let app_id = std::env::var("APP_ID").expect("APP_ID env var required");
|
||||||
|
let app_secret = std::env::var("APP_SECRET").expect("APP_SECRET env var required");
|
||||||
|
|
||||||
|
let client = LarkClient::builder(&app_id, &app_secret).build()?;
|
||||||
|
|
||||||
|
// Keep the SDK event handler short: the WebSocket client can acknowledge
|
||||||
|
// delivery as soon as the event is queued. A worker performs outbound I/O.
|
||||||
|
let (event_tx, mut event_rx) = tokio::sync::mpsc::channel::<P2MessageReceiveV1>(128);
|
||||||
|
let worker_client = client.clone();
|
||||||
|
let worker = tokio::spawn(async move {
|
||||||
|
while let Some(event) = event_rx.recv().await {
|
||||||
|
if event.message.message_type != "text" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let message_id = event.message.message_id;
|
||||||
|
let chat_id = event.message.chat_id;
|
||||||
|
println!(
|
||||||
|
"[worker] message: id={message_id} content={}",
|
||||||
|
event.message.content
|
||||||
|
);
|
||||||
|
|
||||||
|
let body = CreateMessageReqBody {
|
||||||
|
receive_id: Some(chat_id),
|
||||||
|
msg_type: Some("text".to_string()),
|
||||||
|
content: Some(r#"{"text":"Hello from the queued worker!"}"#.to_string()),
|
||||||
|
// Keep retries for the same inbound message idempotent.
|
||||||
|
uuid: Some(format!("reply-{message_id}")),
|
||||||
|
};
|
||||||
|
|
||||||
|
match worker_client
|
||||||
|
.im()
|
||||||
|
.message
|
||||||
|
.create("chat_id", &body, &RequestOption::default())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(response) if response.success() => {
|
||||||
|
println!("[worker] reply sent");
|
||||||
|
}
|
||||||
|
Ok(response) => {
|
||||||
|
eprintln!("[worker] reply rejected: {:?}", response.code_error);
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("[worker] reply failed: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let dispatcher = EventDispatcher::new("", "").on_p2_im_message_receive_v1(
|
||||||
|
move |event: P2MessageReceiveV1| {
|
||||||
|
let event_tx = event_tx.clone();
|
||||||
|
async move {
|
||||||
|
event_tx.try_send(event).map_err(|error| {
|
||||||
|
LarkError::Event(format!("event queue is full or closed: {error}"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut ws_client = client
|
||||||
|
.ws_client(dispatcher)
|
||||||
|
.on_ready(|| println!("websocket connected"))
|
||||||
|
.on_disconnected(|| eprintln!("websocket disconnected"))
|
||||||
|
.on_error(|error| eprintln!("websocket error: {error}"));
|
||||||
|
if let Ok(channel_tag) = std::env::var("WS_CHANNEL_TAG") {
|
||||||
|
ws_client = ws_client.channel_tag(channel_tag);
|
||||||
|
}
|
||||||
|
let user_access_token = std::env::var("USER_ACCESS_TOKEN").ok();
|
||||||
|
let control = user_access_token.as_ref().map(|_| ws_client.control());
|
||||||
|
|
||||||
|
println!("starting ws client (Ctrl+C to stop)...");
|
||||||
|
let ws_task = tokio::spawn(ws_client.start());
|
||||||
|
|
||||||
|
if let (Some(control), Some(user_access_token)) = (control, user_access_token) {
|
||||||
|
while control.connection_id().is_none() {
|
||||||
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
|
}
|
||||||
|
control.attach_user(&user_access_token).await?;
|
||||||
|
println!("trusted user channel attached");
|
||||||
|
}
|
||||||
|
|
||||||
|
ws_task.await??;
|
||||||
|
worker.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
$c = Get-Content 'E:\wk-lab\devflow\docs\02-架构设计\专项设计\.research\issue-130-comments.json' -Raw | ConvertFrom-Json
|
||||||
|
$out = foreach ($x in $c) {
|
||||||
|
"=== $($x.user.login) @ $($x.created_at) ==="
|
||||||
|
$x.body
|
||||||
|
""
|
||||||
|
}
|
||||||
|
$out | Set-Content 'E:\wk-lab\devflow\docs\02-架构设计\专项设计\.research\issue-130-comments.txt' -Encoding UTF8
|
||||||
|
Write-Output "written"
|
||||||
@@ -0,0 +1,546 @@
|
|||||||
|
# wecom-aibot-rust-sdk (Rust)
|
||||||
|
|
||||||
|
企业微信智能机器人 Rust SDK —— 基于 WebSocket 长连接通道,提供消息收发、流式回复、模板卡片、事件回调、文件下载解密等核心能力。
|
||||||
|
|
||||||
|
> 本项目是 Python 版本 [@wecom/aibot-python-sdk](https://pypi.org/project/wecom-aibot-python-sdk/) 的 Rust 改写版本。
|
||||||
|
|
||||||
|
## ✨ 特性
|
||||||
|
|
||||||
|
- 🔗 **WebSocket 长连接** — 基于 `wss://openws.work.weixin.qq.com` 内置默认地址,开箱即用
|
||||||
|
- 🔐 **自动认证** — 连接建立后自动发送认证帧(bot_id + secret)
|
||||||
|
- 💓 **心跳保活** — 自动维护心跳,连续未收到 ack 时自动判定连接异常
|
||||||
|
- 🔄 **断线重连** — 指数退避重连策略(1s → 2s → 4s → ... → 30s 上限),支持自定义最大重连次数
|
||||||
|
- 📨 **消息分发** — 自动解析消息类型并触发对应事件(text / image / mixed / voice / file)
|
||||||
|
- 🌊 **流式回复** — 内置流式回复方法,支持 Markdown 和图文混排
|
||||||
|
- 🃏 **模板卡片** — 支持回复模板卡片消息、流式 + 卡片组合回复、更新卡片
|
||||||
|
- 📤 **主动推送** — 支持向指定会话主动发送 Markdown 或模板卡片消息,无需依赖回调帧
|
||||||
|
- 📡 **事件回调** — 支持进入会话、模板卡片按钮点击、用户反馈等事件
|
||||||
|
- ⏩ **串行回复队列** — 同一 req_id 的回复消息串行发送,自动等待回执
|
||||||
|
- 🔑 **文件下载解密** — 内置 AES-256-CBC 文件解密,每个图片/文件消息自带独立的 aeskey
|
||||||
|
- 🪵 **可插拔日志** — 支持自定义 Logger,内置带时间戳的 DefaultLogger
|
||||||
|
- 🦀 **tokio 原生** — 基于 Rust tokio 异步运行时,支持 async/await
|
||||||
|
|
||||||
|
## 📦 安装
|
||||||
|
|
||||||
|
在 `Cargo.toml` 中添加依赖:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
wecom-aibot-rust-sdk = "1.0.2"
|
||||||
|
tokio = { version = "1.35", features = ["full"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
**依赖:**
|
||||||
|
- Rust >= 1.70
|
||||||
|
- tokio >= 1.35
|
||||||
|
- tokio-tungstenite >= 0.21
|
||||||
|
- tokio-stream >= 0.1
|
||||||
|
- reqwest >= 0.11
|
||||||
|
- serde >= 1.0
|
||||||
|
- serde_json >= 1.0
|
||||||
|
- aes >= 0.8
|
||||||
|
- cbc >= 0.1
|
||||||
|
- base64 >= 0.21
|
||||||
|
- cpufeatures >= 0.2
|
||||||
|
- thiserror >= 1.0
|
||||||
|
- futures >= 0.3
|
||||||
|
- parking_lot >= 0.12
|
||||||
|
- rand >= 0.9
|
||||||
|
- hex >= 0.4
|
||||||
|
- chrono >= 0.4
|
||||||
|
- regex >= 1.10
|
||||||
|
- urlencoding >= 2.1
|
||||||
|
|
||||||
|
## ⚙️ 配置
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 复制示例配置文件
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# 编辑 .env 文件,填入真实配置
|
||||||
|
WECHAT_BOT_ID=your-bot-id
|
||||||
|
WECHAT_BOT_SECRET=your-bot-secret
|
||||||
|
|
||||||
|
# (可选)配置下载文件存放目录,不设置则使用系统 temp 目录
|
||||||
|
WECHAT_BOT_DOWNLOAD_DIR=/path/to/download/dir
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 快速开始
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use dotenv::dotenv;
|
||||||
|
use std::env;
|
||||||
|
use tokio::time::{self, Duration};
|
||||||
|
use wecom_aibot_rust_sdk::{generate_req_id, WSClient, WSClientOptions};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
// 加载 .env 文件
|
||||||
|
dotenv().ok();
|
||||||
|
|
||||||
|
// 1. 创建客户端实例
|
||||||
|
let client = WSClient::new(WSClientOptions::new(
|
||||||
|
env::var("WECHAT_BOT_ID").unwrap(),
|
||||||
|
env::var("WECHAT_BOT_SECRET").unwrap(),
|
||||||
|
));
|
||||||
|
|
||||||
|
// 2. 监听认证成功
|
||||||
|
client.on_authenticated(|| {
|
||||||
|
println!("🔐 认证成功");
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. 监听文本消息并进行流式回复
|
||||||
|
let client_text = client.clone();
|
||||||
|
client.on_message_text(move |frame| {
|
||||||
|
let client_text = client_text.clone();
|
||||||
|
let frame = frame.clone();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let content = frame
|
||||||
|
.body
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
.and_then(|b| b.get("text"))
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
.and_then(|t| t.get("content"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
println!("收到文本:{}", content);
|
||||||
|
|
||||||
|
let stream_id = generate_req_id("stream");
|
||||||
|
|
||||||
|
// 发送流式中间内容
|
||||||
|
let _ = client_text
|
||||||
|
.reply_stream(&frame, &stream_id, "正在思考中...", false, None, None)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// 发送最终结果
|
||||||
|
time::sleep(Duration::from_secs(1)).await;
|
||||||
|
let _ = client_text
|
||||||
|
.reply_stream(
|
||||||
|
&frame,
|
||||||
|
&stream_id,
|
||||||
|
&format!("你好!你说的是:\"{}\"", content),
|
||||||
|
true,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. 监听进入会话事件(发送欢迎语)
|
||||||
|
let client_enter = client.clone();
|
||||||
|
client.on_event_enter_chat(move |frame| {
|
||||||
|
let client_enter = client_enter.clone();
|
||||||
|
let frame = frame.clone();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
use serde_json::json;
|
||||||
|
let _ = client_enter
|
||||||
|
.reply_welcome(
|
||||||
|
&frame,
|
||||||
|
json!({
|
||||||
|
"msgtype": "text",
|
||||||
|
"text": {"content": "您好!我是智能助手,有什么可以帮您的吗?"}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. 启动客户端
|
||||||
|
println!("正在启动机器人...");
|
||||||
|
if let Err(e) = client.connect().await {
|
||||||
|
eprintln!("启动失败:{}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("机器人启动成功,按 Ctrl+C 退出");
|
||||||
|
|
||||||
|
// 等待退出信号
|
||||||
|
tokio::signal::ctrl_c().await.ok();
|
||||||
|
|
||||||
|
println!("\n正在停止机器人...");
|
||||||
|
client.disconnect();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📚 示例说明
|
||||||
|
|
||||||
|
SDK 提供了多个示例代码,位于 `examples/` 目录下:
|
||||||
|
|
||||||
|
### 1. basic.rs - 基础使用示例
|
||||||
|
|
||||||
|
演示基本的连接、消息监听和回复功能。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run --example basic
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. echo_test.rs - Echo 测试机器人
|
||||||
|
|
||||||
|
收到消息后自动原样回复,适合测试基本功能。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run --example echo_test
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. test_files.rs - 文件/图片下载测试
|
||||||
|
|
||||||
|
演示如何下载和解密企业微信发送的图片和文件,支持:
|
||||||
|
- 下载加密图片并保存
|
||||||
|
- 下载加密文件并保存
|
||||||
|
- 自动处理未加密的文件(如截屏图片)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run --example test_files
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. sender_info.rs - 消息发送人信息示例
|
||||||
|
|
||||||
|
演示如何获取消息发送人的详细信息,包括:
|
||||||
|
- 发送人 userid
|
||||||
|
- 发送人 username(如果可用)
|
||||||
|
- 发送人 gender(如果可用)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run --example sender_info
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. upload_media.rs - 上传临时素材示例
|
||||||
|
|
||||||
|
演示如何通过 WebSocket 分片上传临时素材到企业微信,支持:
|
||||||
|
- 图片(image):≤10MB, JPG/PNG 格式
|
||||||
|
- 语音(voice):≤2MB, AMR 格式
|
||||||
|
- 视频(video):≤10MB, MP4 格式
|
||||||
|
- 文件(file):≤10MB
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 上传文件
|
||||||
|
cargo run --example upload_media <文件路径>
|
||||||
|
|
||||||
|
# 示例:上传图片
|
||||||
|
cargo run --example upload_media test.jpg
|
||||||
|
```
|
||||||
|
|
||||||
|
上传成功后返回 `media_id`,可用于发送消息时引用该素材。
|
||||||
|
|
||||||
|
## 📖 API 文档
|
||||||
|
|
||||||
|
### `WSClient`
|
||||||
|
|
||||||
|
核心客户端类,提供连接管理、消息收发等功能。
|
||||||
|
|
||||||
|
#### 构造函数
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let client = WSClient::new(WSClientOptions::new(bot_id, secret));
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 配置选项
|
||||||
|
|
||||||
|
`WSClientOptions` 完整配置:
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `bot_id` | `String` | ✅ | — | 机器人 ID(企业微信后台获取) |
|
||||||
|
| `secret` | `String` | ✅ | — | 机器人 Secret(企业微信后台获取) |
|
||||||
|
| `reconnect_interval` | `u64` | — | `1000` | 重连基础延迟(毫秒),实际延迟按指数退避递增 |
|
||||||
|
| `max_reconnect_attempts` | `i32` | — | `10` | 最大重连次数(`-1` 表示无限重连) |
|
||||||
|
| `heartbeat_interval` | `u64` | — | `30000` | 心跳间隔(毫秒) |
|
||||||
|
| `request_timeout` | `u64` | — | `10000` | HTTP 请求超时时间(毫秒) |
|
||||||
|
| `ws_url` | `Option<String>` | — | `None` | 自定义 WebSocket 连接地址 |
|
||||||
|
| `logger` | `Option<Box<dyn Logger>>` | — | `None` | 自定义日志实例 |
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let options = WSClientOptions {
|
||||||
|
bot_id: "your-bot-id".to_string(),
|
||||||
|
secret: "your-bot-secret".to_string(),
|
||||||
|
reconnect_interval: 1000,
|
||||||
|
max_reconnect_attempts: 10,
|
||||||
|
heartbeat_interval: 30000,
|
||||||
|
request_timeout: 10000,
|
||||||
|
ws_url: None,
|
||||||
|
logger: None,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 方法
|
||||||
|
|
||||||
|
| 方法 | 说明 | 返回值 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `async connect()` | 建立 WebSocket 连接,连接后自动认证 | `Result<(), SdkError>` |
|
||||||
|
| `disconnect()` | 主动断开连接 | `()` |
|
||||||
|
| `async reply(frame, body, cmd)` | 通过 WebSocket 通道发送回复消息(通用方法) | `Result<WsFrame, SdkError>` |
|
||||||
|
| `async reply_stream(frame, stream_id, content, finish, msg_item, feedback)` | 发送流式文本回复(便捷方法,支持 Markdown) | `Result<WsFrame, SdkError>` |
|
||||||
|
| `async reply_welcome(frame, body)` | 发送欢迎语回复(支持文本或模板卡片格式),需在收到事件 5s 内调用 | `Result<WsFrame, SdkError>` |
|
||||||
|
| `async reply_template_card(frame, template_card, feedback)` | 回复模板卡片消息 | `Result<WsFrame, SdkError>` |
|
||||||
|
| `async reply_stream_with_card(frame, stream_id, content, finish, ...)` | 发送流式消息 + 模板卡片组合回复 | `Result<WsFrame, SdkError>` |
|
||||||
|
| `async update_template_card(frame, template_card, userids)` | 更新模板卡片,需在收到事件 5s 内调用 | `Result<WsFrame, SdkError>` |
|
||||||
|
| `async send_message(chatid, body)` | 主动发送消息(支持 Markdown 或模板卡片),无需依赖回调帧 | `Result<WsFrame, SdkError>` |
|
||||||
|
| `async download_file(url, aes_key)` | 下载文件并使用 AES 密钥解密 | `Result<(Vec<u8>, Option<String>), SdkError>` |
|
||||||
|
| `async upload_media(media_type, file_data, filename)` | 上传临时素材(通过 WebSocket 分片上传) | `Result<serde_json::Value, SdkError>` |
|
||||||
|
|
||||||
|
#### 属性
|
||||||
|
|
||||||
|
| 方法 | 说明 | 返回类型 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `is_connected()` | 当前 WebSocket 连接状态 | `bool` |
|
||||||
|
| `api()` | 内部 API 客户端实例(高级用途) | `Arc<WeComApiClient>` |
|
||||||
|
|
||||||
|
### 事件监听
|
||||||
|
|
||||||
|
所有事件均通过 `on_*` 方法监听:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// 连接事件
|
||||||
|
client.on_connected(|| { println!("WebSocket 已连接"); });
|
||||||
|
client.on_authenticated(|| { println!("认证成功"); });
|
||||||
|
client.on_disconnected(|reason| { println!("连接已断开:{}", reason); });
|
||||||
|
client.on_reconnecting(|attempt| { println!("正在进行第 {} 次重连...", attempt); });
|
||||||
|
client.on_error(|error| { eprintln!("发生错误:{}", error); });
|
||||||
|
|
||||||
|
// 消息事件
|
||||||
|
client.on_message(|frame| { println!("收到消息:{:?}", frame); });
|
||||||
|
client.on_message_text(|frame| { println!("收到文本消息"); });
|
||||||
|
client.on_message_image(|frame| { println!("收到图片消息"); });
|
||||||
|
client.on_message_mixed(|frame| { println!("收到图文混排消息"); });
|
||||||
|
client.on_message_voice(|frame| { println!("收到语音消息"); });
|
||||||
|
client.on_message_file(|frame| { println!("收到文件消息"); });
|
||||||
|
|
||||||
|
// 事件回调
|
||||||
|
client.on_event(|frame| { println!("收到事件回调"); });
|
||||||
|
client.on_event_enter_chat(|frame| { println!("用户进入会话"); });
|
||||||
|
client.on_event_template_card(|frame| { println!("模板卡片事件"); });
|
||||||
|
client.on_event_feedback(|frame| { println!("用户反馈事件"); });
|
||||||
|
```
|
||||||
|
|
||||||
|
### `reply_stream` 详细说明
|
||||||
|
|
||||||
|
```rust
|
||||||
|
await client.reply_stream(
|
||||||
|
&frame, // 收到的原始 WebSocket 帧(透传 req_id)
|
||||||
|
&stream_id, // 流式消息 ID(使用 generate_req_id("stream") 生成)
|
||||||
|
"回复内容", // 回复内容(支持 Markdown)
|
||||||
|
false, // 是否结束流式消息
|
||||||
|
None, // 图文混排项(仅 finish=true 时有效)
|
||||||
|
None, // 反馈信息(仅首次回复时设置)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### `reply_welcome` 详细说明
|
||||||
|
|
||||||
|
发送欢迎语回复,需在收到 `event.enter_chat` 事件 5 秒内调用。
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// 文本欢迎语
|
||||||
|
use serde_json::json;
|
||||||
|
await client.reply_welcome(&frame, json!({
|
||||||
|
"msgtype": "text",
|
||||||
|
"text": {"content": "欢迎!"},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 模板卡片欢迎语
|
||||||
|
await client.reply_welcome(&frame, json!({
|
||||||
|
"msgtype": "template_card",
|
||||||
|
"template_card": {"card_type": "text_notice", "main_title": {"title": "欢迎"}},
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
### `send_message` 详细说明
|
||||||
|
|
||||||
|
主动向指定会话推送消息,无需依赖收到的回调帧。
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// 发送 Markdown 消息
|
||||||
|
await client.send_message("userid_or_chatid", json!({
|
||||||
|
"msgtype": "markdown",
|
||||||
|
"markdown": {"content": "这是一条**主动推送**的消息"},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 发送模板卡片消息
|
||||||
|
await client.send_message("userid_or_chatid", json!({
|
||||||
|
"msgtype": "template_card",
|
||||||
|
"template_card": {"card_type": "text_notice", "main_title": {"title": "通知"}},
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
### `upload_media` 上传临时素材
|
||||||
|
|
||||||
|
通过 WebSocket 分片上传临时素材到企业微信,支持图片、语音、视频和文件。
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
// 读取文件
|
||||||
|
let file_data = fs::read("test.pdf").unwrap();
|
||||||
|
|
||||||
|
// 上传文件(media_type: "image", "voice", "video", "file")
|
||||||
|
match client.upload_media("file", &file_data, "test.pdf").await {
|
||||||
|
Ok(result) => {
|
||||||
|
let media_id = result.get("media_id").and_then(|v| v.as_str()).unwrap();
|
||||||
|
println!("上传成功,media_id: {}", media_id);
|
||||||
|
}
|
||||||
|
Err(e) => eprintln!("上传失败:{}", e),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**文件大小限制:**
|
||||||
|
| 类型 | 大小限制 | 格式 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| image | ≤10MB | JPG, PNG |
|
||||||
|
| voice | ≤2MB | AMR (≤60s) |
|
||||||
|
| video | ≤10MB | MP4 |
|
||||||
|
| file | ≤10MB | - |
|
||||||
|
|
||||||
|
**注意:** 临时素材 `media_id` 有效期为 3 天。
|
||||||
|
|
||||||
|
### `download_file` 使用示例
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// aes_key 取自消息体中的 image.aeskey 或 file.aeskey
|
||||||
|
client.on_message_image(|frame| {
|
||||||
|
let client = client.clone();
|
||||||
|
let frame = frame.clone();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let aes_key = frame
|
||||||
|
.body
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
.and_then(|b| b.get("image"))
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
.and_then(|i| i.get("aeskey"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(String::from);
|
||||||
|
|
||||||
|
let url = frame
|
||||||
|
.body
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
.and_then(|b| b.get("image"))
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
.and_then(|i| i.get("url"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(String::from);
|
||||||
|
|
||||||
|
if let (Some(url), aes_key) = (url, aes_key) {
|
||||||
|
let (buffer, filename) = client.download_file(&url, aes_key.as_deref()).await.unwrap();
|
||||||
|
println!("文件名:{:?}, 大小:{} bytes", filename, buffer.len());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📋 消息类型
|
||||||
|
|
||||||
|
SDK 支持以下消息类型(`MessageType` 枚举):
|
||||||
|
|
||||||
|
| 类型 | 值 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `MessageType::Text` | `"text"` | 文本消息 |
|
||||||
|
| `MessageType::Image` | `"image"` | 图片消息 |
|
||||||
|
| `MessageType::Mixed` | `"mixed"` | 图文混排消息 |
|
||||||
|
| `MessageType::Voice` | `"voice"` | 语音消息 |
|
||||||
|
| `MessageType::File` | `"file"` | 文件消息 |
|
||||||
|
|
||||||
|
SDK 支持以下事件类型(`EventType` 枚举):
|
||||||
|
|
||||||
|
| 类型 | 值 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `EventType::EnterChat` | `"enter_chat"` | 进入会话事件 |
|
||||||
|
| `EventType::TemplateCardEvent` | `"template_card_event"` | 模板卡片事件 |
|
||||||
|
| `EventType::FeedbackEvent` | `"feedback_event"` | 用户反馈事件 |
|
||||||
|
|
||||||
|
## 🪵 自定义日志
|
||||||
|
|
||||||
|
实现 `Logger` Trait 接口即可自定义日志输出:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use wecom_aibot_rust_sdk::types::Logger;
|
||||||
|
|
||||||
|
struct MyLogger;
|
||||||
|
|
||||||
|
impl Logger for MyLogger {
|
||||||
|
fn debug(&self, message: &str) {
|
||||||
|
// 静默 debug 日志
|
||||||
|
}
|
||||||
|
|
||||||
|
fn info(&self, message: &str) {
|
||||||
|
println!("[INFO] {}", message);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn warn(&self, message: &str) {
|
||||||
|
println!("[WARN] {}", message);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn error(&self, message: &str) {
|
||||||
|
println!("[ERROR] {}", message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let options = WSClientOptions {
|
||||||
|
bot_id: "your-bot-id".to_string(),
|
||||||
|
secret: "your-bot-secret".to_string(),
|
||||||
|
logger: Some(Box::new(MyLogger)),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let client = WSClient::new(options);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📂 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
rust_wecom_bot_rust_sdk/
|
||||||
|
├── src/
|
||||||
|
│ ├── lib.rs # 库入口文件,统一导出
|
||||||
|
│ ├── client.rs # WSClient 核心客户端
|
||||||
|
│ ├── ws.rs # WebSocket 长连接管理器
|
||||||
|
│ ├── message_handler.rs # 消息解析与事件分发
|
||||||
|
│ ├── api.rs # HTTP API 客户端(文件下载)
|
||||||
|
│ ├── crypto_utils.rs # AES-256-CBC 文件解密
|
||||||
|
│ ├── logger.rs # 默认日志实现
|
||||||
|
│ ├── utils.rs # 工具方法(generate_req_id 等)
|
||||||
|
│ └── types.rs # 类型定义(枚举、结构体、常量)
|
||||||
|
├── examples/
|
||||||
|
│ ├── basic.rs # 基础使用示例
|
||||||
|
│ ├── send_test_message.rs # 发送测试消息示例
|
||||||
|
│ ├── echo_test.rs # Echo 测试机器人
|
||||||
|
│ ├── test_files.rs # 文件/图片下载测试
|
||||||
|
│ ├── sender_info.rs # 消息发送人信息示例
|
||||||
|
│ └── upload_media.rs # 上传临时素材示例
|
||||||
|
├── Cargo.toml # 项目配置
|
||||||
|
├── README.md # 本文件
|
||||||
|
└── .env.example # 环境变量示例
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🏗️ 架构说明
|
||||||
|
|
||||||
|
本 SDK 采用异步事件驱动架构,主要组件:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ WSClient │
|
||||||
|
│ (核心客户端,提供事件注册和消息发送接口) │
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||||
|
│ WsConnection │ │ MessageHandler │ │ WeComApiClient │
|
||||||
|
│ Manager │ │ (消息分发) │ │ (文件下载) │
|
||||||
|
│ (连接/心跳/重连) │ │ │ │ │
|
||||||
|
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌─────────────────┐ ┌─────────────────┐
|
||||||
|
│ WebSocket LWS │ │ Event Types │
|
||||||
|
│ (长连接) │ │ (文本/图片/事件)│
|
||||||
|
└─────────────────┘ └─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
MIT
|
||||||
@@ -2,24 +2,25 @@
|
|||||||
|
|
||||||
> 范围:全库(src-tauri + src),origin/main..HEAD 30 笔提交走查的横向延伸。
|
> 范围:全库(src-tauri + src),origin/main..HEAD 30 笔提交走查的横向延伸。
|
||||||
> 方法:只读审计,按(漂移后果 × 重复量)降序。纯相似但不漂移的不算债。
|
> 方法:只读审计,按(漂移后果 × 重复量)降序。纯相似但不漂移的不算债。
|
||||||
> 本轮已收口:#3。其余待排期,收敛方向已给出,勿新造抽象层。
|
> 本轮已收口:#1/#2/#3/#4/#5/#6/#7/#8/#9/#10/#11/#12 全部 12 项(B/C 批收口记录见任务 de6095bd 注释与 DRY-A 任务)。
|
||||||
|
> 状态表仅反映走查当时快照,实测以任务销账记录为准。
|
||||||
|
|
||||||
## 汇总
|
## 汇总
|
||||||
|
|
||||||
| # | 债务 | 重复量 | 漂移后果 | 状态 |
|
| # | 债务 | 重复量 | 漂移后果 | 状态 |
|
||||||
|---|------|--------|----------|------|
|
|---|------|--------|----------|------|
|
||||||
| 1 | emit+publish 双写样板 ~50 处 | ~120 行 | 已发生(BUG-260805-02 只 emit 未 publish 丢事件) | 待排期 |
|
| 1 | emit+publish 双写样板 ~50 处 | ~120 行 | 已发生(BUG-260805-02 只 emit 未 publish 丢事件) | ✅ 收口 |
|
||||||
| 2 | KV 引号归一 7 处 3 种策略并存 | ~25 行 | 已发生(auto-execute medium/all 档静默失效注释自证) | 待排期 |
|
| 2 | KV 引号归一 7 处 3 种策略并存 | ~25 行 | 已发生(auto-execute medium/all 档静默失效注释自证) | ✅ 收口 |
|
||||||
| 3 | 高风险工具名单 TS↔Rust 双写 | 名单×2 | 已漂移:前端缺 10 个 High 工具,审批后二次确认漏弹 | ✅ 本轮收口 |
|
| 3 | 高风险工具名单 TS↔Rust 双写 | 名单×2 | 已漂移:前端缺 10 个 High 工具,审批后二次确认漏弹 | ✅ 本轮收口 |
|
||||||
| 4 | 噪音目录名单 4 处 4 套 | ~25 行 | 已漂移(AI 搜索与文件树过滤集合不一致) | 待排期 |
|
| 4 | 噪音目录名单 4 处 4 套 | ~25 行 | 已漂移(AI 搜索与文件树过滤集合不一致) | ✅ 收口 |
|
||||||
| 5 | 工具元数据 match 表散布 5 处 | ~90 行 | 已漂移(幽灵 edit_file/list_dir 分支永不命中) | 待排期 |
|
| 5 | 工具元数据 match 表散布 5 处 | ~90 行 | 已漂移(幽灵 edit_file/list_dir 分支永不命中) | ✅ 收口 |
|
||||||
| 6 | 相对时间阶梯 3 套实现 | ~45 行 | 用户可见不一致(同为更新时间三种分档) | 待排期 |
|
| 6 | 相对时间阶梯 3 套实现 | ~45 行 | 用户可见不一致(同为更新时间三种分档) | ✅ 收口 |
|
||||||
| 7 | insert system 警示骨架 4 处同构 | ~100 行 | conv 生命周期语义变更需 4 处同改 | 待排期 |
|
| 7 | insert system 警示骨架 4 处同构 | ~100 行 | conv 生命周期语义变更需 4 处同改 | ✅ 收口 |
|
||||||
| 8 | 压缩关键词兜底块逐字双胞胎 | ~35 行 | 两条失败路径易只改其一产生行为分叉 | 待排期 |
|
| 8 | 压缩关键词兜底块逐字双胞胎 | ~35 行 | 两条失败路径易只改其一产生行为分叉 | ✅ 收口 |
|
||||||
| 9 | formatWaitTime+等待阈值双写三处 | ~36 行 | 审批浮窗与主窗「变红」时刻分叉风险 | 待排期 |
|
| 9 | formatWaitTime+等待阈值双写三处 | ~36 行 | 审批浮窗与主窗「变红」时刻分叉风险 | ✅ 收口 |
|
||||||
| 10 | 灵感 statusOptions 双写 | ~20 行 | 透传链断一处详情页回退英文原值 | 待排期 |
|
| 10 | 灵感 statusOptions 双写 | ~20 行 | 透传链断一处详情页回退英文原值 | ✅ 收口 |
|
||||||
| 11 | truncate 家族(同名不同义陷阱) | ~30 行 | fetch_url 与 audit 同名函数语义分叉误导复用 | 部分收口 |
|
| 11 | truncate 家族(同名不同义陷阱) | ~30 行 | fetch_url 与 audit 同名函数语义分叉误导复用 | ✅ 收口 |
|
||||||
| 12 | LOCK-SLOW 持锁计时样板 ~29 处 | ~145 行 | 低(阈值/行号写死变量名,维护噪音) | 待排期 |
|
| 12 | LOCK-SLOW 持锁计时样板 ~29 处 | ~145 行 | 低(阈值/行号写死变量名,维护噪音) | ✅ 收口 |
|
||||||
|
|
||||||
## 明细与收敛方向
|
## 明细与收敛方向
|
||||||
|
|
||||||
@@ -65,6 +66,7 @@
|
|||||||
- 正典 audit/utils.rs:32(补 …);fetch_url.rs:655 同名不同义(返回元组);generate_image.rs:460、knowledge_inject.rs:544 手写 take(500)
|
- 正典 audit/utils.rs:32(补 …);fetch_url.rs:655 同名不同义(返回元组);generate_image.rs:460、knowledge_inject.rs:544 手写 take(500)
|
||||||
- 已收口部分:tools/knowledge.rs 本地截断已改调正典(本轮)
|
- 已收口部分:tools/knowledge.rs 本地截断已改调正典(本轮)
|
||||||
- 残留收敛:正典上移共享位改名 truncate_chars_ellipsis;fetch_url 改名 truncate_markdown 消同名陷阱
|
- 残留收敛:正典上移共享位改名 truncate_chars_ellipsis;fetch_url 改名 truncate_markdown 消同名陷阱
|
||||||
|
- ✅ 全部完成(2026-10-04):audit/utils.rs 正典改名 truncate_chars_ellipsis + audit/mod.rs re-export / record.rs / generate_image.rs / knowledge_inject.rs / tools/knowledge.rs 全链同步;fetch_url truncate_markdown 已先行改名;df-ai context_helpers.rs 私有版跨 crate 不引依赖保留原名+正典标注注释。验收 cargo check --workspace EXIT 0 + devflow 679 测试全过。#2/#6/#9/#10 同日源码核验均为前批已落地(文档滞后)。
|
||||||
|
|
||||||
### 12. LOCK-SLOW 样板
|
### 12. LOCK-SLOW 样板
|
||||||
- 29 处 × 5 行,变量名编码行号(__hold610),阈值 30ms 逐处复制
|
- 29 处 × 5 行,变量名编码行号(__hold610),阈值 30ms 逐处复制
|
||||||
|
|||||||
@@ -152,3 +152,194 @@ IM 用户 @机器人 "把登录任务推进到 in_review"
|
|||||||
- **df-mcp**:对外 stdio 暴露数据层(本地进程级);本规划是远程人类入口(IM 级),互补不重叠
|
- **df-mcp**:对外 stdio 暴露数据层(本地进程级);本规划是远程人类入口(IM 级),互补不重叠
|
||||||
- **u-relay**:零改动(除非 D5 触发多 token 需求)
|
- **u-relay**:零改动(除非 D5 触发多 token 需求)
|
||||||
- **df-tunnel / AI 引擎**:零改动
|
- **df-tunnel / AI 引擎**:零改动
|
||||||
|
|
||||||
|
## 附录A、PostgreSQL 多租户 × DevFlow 结合分析(§十 S3 展开论证)
|
||||||
|
|
||||||
|
### A.1 概念:多租户三模式
|
||||||
|
|
||||||
|
| 模式 | 结构 | 优点 | 缺点 | 适合 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **行级隔离** | 同库同表,每行 `tenant_id` 列,查询强制 WHERE | 成本最低、迁移跑一份、跨租户统计易 | 隔离最弱(漏写 WHERE 即泄漏)、noisy neighbor | 租户多单租户数据小,SaaS 主流 |
|
||||||
|
| **独立 Schema** | 一实例每租户一 schema | 隔离较强,按 schema 备份 | 租户多时爆炸;DDL 迁移跑 N 遍 | 租户几百内 |
|
||||||
|
| **独立 Database** | 每租户一库 | 最强隔离 | 资源贵、连接池复杂、跨租户查询难 | 租户少而大、合规严 |
|
||||||
|
|
||||||
|
行级隔离通常配合 **PG RLS(Row Level Security)**:租户过滤下沉数据库层——session 设 `SET app.tenant_id`,策略自动过滤,应用层漏写 WHERE 也不泄漏。这是 PG 多租户的核心王牌。
|
||||||
|
|
||||||
|
### A.2 激活时机
|
||||||
|
|
||||||
|
DevFlow 当前是本地优先单机应用(SQLite/单用户/零运维),「租户」不存在。多租户仅在 **S3 AI 引擎上云**时被激活:SQLite 单写者模型扛不住云端多用户并发写,且「本地优先」范式失效——隔离必须显式设计。**多租户是需求,PostgreSQL 是载体**(PG 因并发写 + RLS + 生态成为默认答案)。
|
||||||
|
|
||||||
|
### A.3 租户模型与选型
|
||||||
|
|
||||||
|
- 租户模型:`tenant`=团队(付费主体),`user`=成员;现有全部表(projects/tasks/ideas/workflows/ai_messages/knowledge/audit…)挂 `tenant_id`
|
||||||
|
- 选型:**行级隔离 + RLS**(非 schema/database 隔离)。决定性理由是迁移演进节奏:V1-V16+ 已 20+ 表且快速迭代,per-tenant schema 下每次迁移要循环跑 N 遍并处理失败重试,直接拖死迭代速度;行级隔离迁移只跑一份
|
||||||
|
|
||||||
|
### A.4 落点三抓手
|
||||||
|
|
||||||
|
1. **df-storage trait 层**(对齐 F-07 下沉思路):存储访问收敛 Repository trait,多租户化 = trait 实现层注入租户上下文(依赖 PG session 变量),上层 commands 无感——现有分层红利
|
||||||
|
2. **迁移体系**:SQLite 手写迁移换 sqlx/refinery 版本化迁移,一套脚本跑一份
|
||||||
|
3. **RLS 策略**:每表 `USING (tenant_id = current_setting('app.tenant_id')::uuid)`,连接池(pgbouncer)按事务设租户变量
|
||||||
|
|
||||||
|
### A.5 配套改造清单(多租户 ≠ 换数据库驱动)
|
||||||
|
|
||||||
|
缓存键带租户(防 Redis 串数据)/ 对象存储按租户前缀隔离 / 日志审计按租户切分 / 备份恢复按租户粒度。**全链路改造**——这正是 §十 把 PG 多租户列入「显式不做」的原因:现在做 = 为不存在的需求付全链路成本。
|
||||||
|
|
||||||
|
### A.6 与本期规划的交集
|
||||||
|
|
||||||
|
唯一交集 = P0 已预留的会话映射表 `owner_user_id` 列(S2 时是「用户归属」,S3 升级语义为「租户内用户」),成本≈0。时序红线重申:S2 各人数据仍在各自桌面端 SQLite,云端只有路由表,**不需要 PG**;S3 才引入 PG+RLS,与引擎多租户化统一做。
|
||||||
|
|
||||||
|
## 附录B、引入云端后的多人架构模式(C4 视图)
|
||||||
|
|
||||||
|
> 按 C4 模型整理:C1 上下文(现状基线 → S2 多人 → S3 引擎上云)+ C2 容器(网关内部)+ 部署视图 + 数据归属演进表。
|
||||||
|
> 贯穿不变量:**TunnelCommand/TunnelEvent 是网关↔执行端唯一契约**,执行端可从「桌面端 df-tunnel」无缝换成「云端 df-agent」,网关与 IM 端零改动。
|
||||||
|
|
||||||
|
### B.1 C1 上下文 —— 现状基线(S0:单机 + 小程序遥控)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph local["💻 用户本地(数据主权 100% 在个人)"]
|
||||||
|
desktop["DevFlow 桌面端 (Tauri v2)<br/>AI Chat 引擎(Agentic Loop+审批) · SQLite · df-tunnel(Agent)"]
|
||||||
|
end
|
||||||
|
subgraph cloud["☁️ 云端 u-relay.1216.top(nginx TLS 终止)"]
|
||||||
|
relay["u-relay :9180<br/>channel_id 配对 · payload 透传零业务耦合"]
|
||||||
|
end
|
||||||
|
mini["📱 df-miniapp 微信小程序<br/>(Commander)"]
|
||||||
|
mini <-->|"WSS"| relay
|
||||||
|
relay <-.->|"WS Agent 出站穿NAT<br/>单 channel 配对"| desktop
|
||||||
|
```
|
||||||
|
|
||||||
|
### B.2 C1 上下文 —— S2 团队多人(云端路由化,数据仍归各人)
|
||||||
|
|
||||||
|
本期 IM 网关落地后的直接形态:**云端只有「路由与审计」,业务数据仍在各桌面端**。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph users["👥 多人入口"]
|
||||||
|
direction LR
|
||||||
|
im["飞书 / 钉钉 / 企微<br/>企业机器人"]
|
||||||
|
web["Web Chat<br/>浏览器/手机"]
|
||||||
|
mini["df-miniapp<br/>微信小程序"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph cloud["☁️ 云端(nginx TLS 终止)"]
|
||||||
|
direction TB
|
||||||
|
gw["df-im-gateway :9190<br/>平台适配器 · 会话映射 · 白名单<br/>节流聚合 · 审批分级 · 审计"]
|
||||||
|
relay["u-relay :9180<br/>多 channel 配对转发"]
|
||||||
|
gw <-->|"WS Commander(多channel)<br/>TunnelCommand / TunnelEvent"| relay
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph deskA["用户A 本地"]
|
||||||
|
dA["DevFlow 桌面端<br/>AI引擎 + SQLite(A) · df-tunnel"]
|
||||||
|
end
|
||||||
|
subgraph deskB["用户B 本地"]
|
||||||
|
dB["DevFlow 桌面端<br/>AI引擎 + SQLite(B) · df-tunnel"]
|
||||||
|
end
|
||||||
|
|
||||||
|
im --> gw
|
||||||
|
web <-->|"HTTP/WS"| gw
|
||||||
|
mini <-->|"WSS Commander"| relay
|
||||||
|
relay <-.->|"WS Agent · chan-userA"| dA
|
||||||
|
relay <-.->|"WS Agent · chan-userB"| dB
|
||||||
|
```
|
||||||
|
|
||||||
|
**路由链路**:IM 消息 → 网关查映射表 `chat_id → (owner_user_id, channel_id, conv_id)` → 发 `TunnelCommand::Send` 到对应 channel → 该用户桌面端执行。**用户隔离靠 channel 隔离**(一人一 channel,per-channel token 为 u-relay 演进观察项)。
|
||||||
|
|
||||||
|
### B.3 C2 容器 —— df-im-gateway 内部(适配器 trait 分层,S4 扩展点)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph adapters["平台适配器层(各平台差异收敛于此)"]
|
||||||
|
feishu["feishu_adapter<br/>长连接+卡片流式"]
|
||||||
|
dingtalk["dingtalk_adapter<br/>Stream Mode"]
|
||||||
|
wecom["wecom_adapter<br/>回调验签+AES"]
|
||||||
|
webui["web_adapter<br/>WS 直连+静态页"]
|
||||||
|
end
|
||||||
|
subgraph core["核心(平台无关,S4 加端零改动)"]
|
||||||
|
unify["统一模型<br/>InboundEvent / OutboundAction"]
|
||||||
|
sess["会话映射表 (SQLite)<br/>chat_id | conv_id | owner_user_id | platform | channel_id"]
|
||||||
|
throttle["节流聚合器<br/>TextDelta 时间窗"]
|
||||||
|
acl["白名单 + 审批分级"]
|
||||||
|
audit["审计日志<br/>IM消息 ↔ TunnelCommand"]
|
||||||
|
unify --> sess
|
||||||
|
sess --> throttle
|
||||||
|
acl --> sess
|
||||||
|
sess --> audit
|
||||||
|
end
|
||||||
|
relay["u-relay WS Commander"]
|
||||||
|
feishu --> unify
|
||||||
|
dingtalk --> unify
|
||||||
|
wecom --> unify
|
||||||
|
webui <--> unify
|
||||||
|
core <-->|"TunnelCommand / TunnelEvent"| relay
|
||||||
|
```
|
||||||
|
|
||||||
|
### B.4 C1 上下文 —— S3 引擎上云(云端多人完整形态,PG 多租户)
|
||||||
|
|
||||||
|
执行端从「各人桌面端」切换为「云端 df-agent 服务」,**同一协议**,网关/IM 端零改动;桌面端退化为瘦客户端/管理台,本地优先形态仍可用(不废弃)。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph users["👥 多人入口"]
|
||||||
|
direction LR
|
||||||
|
im["飞书/钉钉/企微"]
|
||||||
|
web["Web Chat"]
|
||||||
|
end
|
||||||
|
subgraph cloud["☁️ 云端"]
|
||||||
|
direction TB
|
||||||
|
gw["df-im-gateway<br/>(零改动)"]
|
||||||
|
relay["u-relay<br/>(零改动)"]
|
||||||
|
agent["df-agent 云端执行服务<br/>AI 引擎(Agentic Loop) · 工具沙箱<br/>多会话/多租户调度"]
|
||||||
|
gw <--> relay
|
||||||
|
relay <-.->|"同协议<br/>TunnelCommand / TunnelEvent"| agent
|
||||||
|
end
|
||||||
|
subgraph data["数据层(附录A 落点)"]
|
||||||
|
pg[("PostgreSQL<br/>行级 tenant_id + RLS<br/>projects/tasks/messages/audit")]
|
||||||
|
redis[("Redis<br/>租户前缀缓存键")]
|
||||||
|
oss[("对象存储<br/>租户前缀隔离")]
|
||||||
|
end
|
||||||
|
thin["💻 桌面端=瘦客户端/管理台<br/>本地 SQLite 留个人配置"]
|
||||||
|
im --> gw
|
||||||
|
web <--> gw
|
||||||
|
agent --> pg
|
||||||
|
agent --> redis
|
||||||
|
agent --> oss
|
||||||
|
thin <-.-> relay
|
||||||
|
```
|
||||||
|
|
||||||
|
### B.5 部署视图(S2 落地形态,单机双服务 + nginx)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph inet["互联网"]
|
||||||
|
u["用户 IM / 浏览器 / 小程序"]
|
||||||
|
end
|
||||||
|
subgraph server["云端主机(u-relay 同机,复用 nginx+systemd 模式)"]
|
||||||
|
nginx["nginx :443 TLS 终止<br/>/ws/* → 9180<br/>/im/* /chat/* → 9190"]
|
||||||
|
ur["u-relay :9180<br/>systemd · musl 单二进制 ~3.6MB"]
|
||||||
|
gig["df-im-gateway :9190<br/>systemd · musl 单二进制<br/>静态 Web Chat 页托管"]
|
||||||
|
nginx --> ur
|
||||||
|
nginx --> gig
|
||||||
|
end
|
||||||
|
subgraph homes["各用户本地"]
|
||||||
|
dt["DevFlow 桌面端<br/>出站 WSS → /ws/agent"]
|
||||||
|
end
|
||||||
|
u -->|"wss://u-relay.1216.top"| nginx
|
||||||
|
dt <-.->|"出站穿NAT"| nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
### B.6 数据归属与信任边界演进表
|
||||||
|
|
||||||
|
| 维度 | S0 现状 | S2 团队多人 | S3 引擎上云 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 身份/会话映射 | 桌面端 Settings | **网关 SQLite**(owner/channel 列已预留) | 网关 → 并入 PG |
|
||||||
|
| 项目/任务/工作流 | 桌面端 SQLite | **各桌面端 SQLite**(主权在个人) | PG(tenant_id+RLS) |
|
||||||
|
| AI 消息/溯源 | 桌面端 ai_messages | 各桌面端 | PG |
|
||||||
|
| IM↔指令审计 | — | 网关审计日志 | PG(租户维度) |
|
||||||
|
| 信任边界 | 个人设备 | 云端只见「指令与脱敏结果」 | 云端见全部业务数据(需租户隔离+合规) |
|
||||||
|
| 桌面端角色 | 全栈主体 | 执行主体 | 瘦客户端/管理台(可选保留本地优先) |
|
||||||
|
|
||||||
|
### B.7 演进的三条设计不变量
|
||||||
|
|
||||||
|
1. **协议不变性**:网关↔执行端唯一契约是 TunnelCommand/TunnelEvent(df-tunnel crate 类型),S3 换执行端零成本——这是全方案最重要的远期红利
|
||||||
|
2. **u-relay 纯转发零耦合**不破例:业务逻辑只进 df-im-gateway / df-agent,中继永远轻
|
||||||
|
3. **数据主权渐进上移**:S0/S2 数据在个人(本地优先不被废弃),S3 才集中上云并配套 RLS 隔离——每一步都由真实需求触发,不过度设计
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# 飞书/企微/钉钉 Rust 接入与 SDK 调研
|
||||||
|
|
||||||
|
> 创建: 2026-10-04 | 状态: ✅ 调研完成
|
||||||
|
> 服务于: [devflow集成三方IM-飞书企微钉钉Web-规划设计-2026-10-04.md](./devflow集成三方IM-飞书企微钉钉Web-规划设计-2026-10-04.md) P2/P3/P4
|
||||||
|
> 原始材料: [.research/](./.research/)(larkrs 全套 + 本次新增 dingtalk/wecom README 与 crates 数据)
|
||||||
|
|
||||||
|
## 一、总览:三家选型结论
|
||||||
|
|
||||||
|
| 维度 | 飞书 | 钉钉 | 企微 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **选型** | ⭐ larksuite-oapi-sdk-rs 0.3.12 | ⭐ dingtalk-stream 0.1.7 | ⭐ wecom-aibot-rust-sdk 1.0.2 |
|
||||||
|
| 作者 | adamcavendish(非官方,显式对齐 Go 官方 SDK) | spensercai(非官方,对齐官方 Python SDK commit 6381a7b) | camark(非官方,Python 版 wecom/aibot 的 Rust 改写) |
|
||||||
|
| 仓库 | github.com/adamcavendish/larksuite-oapi-sdk-rs | github.com/spensercai/dingtalk-stream-sdk-rs | github.com/camark/wecom-aibot-rust-sdk |
|
||||||
|
| 最近发版/提交 | 2026-10-03(活跃) | 2026-03-26 | 2026-04-07 |
|
||||||
|
| crates 下载(近期) | 4276(openlark-auth 关联) | 3459 | 106 |
|
||||||
|
| 连接方式 | **WS 长连接**(features=["ws"]) | **WS Stream Mode** | **WS 长连接**(wss://openws.work.weixin.qq.com)✅ |
|
||||||
|
| 免公网回调 | ✅ | ✅ | ✅(智能机器人形态,**非**传统自建应用回调模式) |
|
||||||
|
| 流式回复 | ✅ CardKit(CardEntity + sequence 递增) | ✅ 卡片 AI 流式 | ✅ reply_stream(stream_id + finish 标志,支持 Markdown) |
|
||||||
|
| 审批按钮回调 | ⚠️ card.action.trigger 新版事件(编译冒烟验证) | ✅ 卡片按钮回调 | ✅ template_card_event(5s 内 update_template_card) |
|
||||||
|
| Rust 版本要求 | 1.95+/edition 2024 | 1.85+/edition 2024 | 1.70+(tokio 1.35+) |
|
||||||
|
|
||||||
|
**核心结论**:三家全部有可用的 Rust 原生 SDK,且三家全部支持 WS 长连接——**df-im-gateway 三平台均不需要公网回调/可信 IP/AES 回调解密**。原规划中「企微接入复杂度最高(回调验签+AES+可信域名)」的判断基于传统自建应用形态,改用**智能机器人**形态后三平台接入复杂度拉平,P4 工作量从 3-4 天降到 2-3 天。
|
||||||
|
|
||||||
|
## 二、飞书:larksuite-oapi-sdk-rs(已有源码级勘察)
|
||||||
|
|
||||||
|
详见 [.research/larkrs-readme.md](./.research/larkrs-readme.md) / [larkrs-events-and-channels.md](./.research/larkrs-events-and-channels.md) / [larkrs-cards.md](./.research/larkrs-cards.md) 及任务 P2 描述中的勘察补充。要点:
|
||||||
|
|
||||||
|
- `features=["channel"]` 一站式:`channel_messaging()` 高层封装(send/reply/reply_in_thread/edit_text/edit_card),uuid 1h 幂等窗口
|
||||||
|
- ws feature:bootstrap 握手(POST /callback/ws/endpoint 换 wss URL)+ protobuf framing + 有限重连 + liveness(入站静默 2×ping+5s 判失败)+ write 超时 10s + 回调与收包循环解耦(慢回调不掩盖网关静默)
|
||||||
|
- 卡片三套:card::v1(JSON 1.0)/ card::v2(JSON 2.0)/ CardKit 流式(CardEntity 一次发送 + sequence 递增更新,14 天生命周期);CardDocument 带 JSON Pointer 级严格校验
|
||||||
|
- 事件:EventDispatcher typed handler(如 on_p2_im_message_receive_v1),mpsc 队列 + worker 出站 I/O 模式可直接套用([.research/larkrs-ws-client-example.rs](./.research/larkrs-ws-client-example.rs))
|
||||||
|
- ⚠️ 遗留验证项:CardActionHandler 官方示例是 HTTP webhook 形态;长连接下 card.action.trigger 应经事件通道分发,typed 注册口需编译冒烟确认(docs/card-protocol.md + examples/card_action_handler.rs 已存档)
|
||||||
|
- ⚠️ 坑(源码级实证,知识库已沉淀):卡片回调必须用**新版** card.action.trigger(EventDispatcher 注册);旧版 card 帧被 ws 客户端直接丢弃(Go SDK v3.12.0 中 WithCardHandler 被注释)
|
||||||
|
|
||||||
|
## 三、钉钉:dingtalk-stream(本次新调研)
|
||||||
|
|
||||||
|
完整对齐官方 Python SDK(open-dingtalk/dingtalk-stream-sdk-python @ 6381a7b),结构清晰(client/credential/handlers/messages/card/transport 分层)。
|
||||||
|
|
||||||
|
**能力覆盖**([.research/dingtalk-stream-readme.md](./.research/dingtalk-stream-readme.md)):
|
||||||
|
- Stream 协议连接:自动重连 + 60s Keepalive
|
||||||
|
- 消息收发:文本/Markdown/卡片;Rust 独有扩展——机器人可收全部 6 种消息类型(audio/file/video 仅单聊)
|
||||||
|
- 交互卡片:Markdown / **AI 流式** / 按钮 / 轮播 / RPA 插件——审批按钮与流式都覆盖
|
||||||
|
- 事件订阅 + Graph API
|
||||||
|
- OAuth2 Token 自动管理(缓存 + 5 分钟提前刷新)
|
||||||
|
- 主动单聊:send_oto_message(POST /v1.0/robot/oToMessages/batchSend)
|
||||||
|
- 媒体下载:get_all_download_codes 统一提取 + download_bytes_with_limit 流式限速下载
|
||||||
|
|
||||||
|
**接入姿势**:
|
||||||
|
```rust
|
||||||
|
let credential = Credential::new(client_id, client_secret);
|
||||||
|
let mut client = DingTalkStreamClient::builder(credential)
|
||||||
|
.register_callback_handler(ChatbotMessage::TOPIC, handler)
|
||||||
|
.build();
|
||||||
|
client.start_forever()?;
|
||||||
|
```
|
||||||
|
Handler trait 回 `(u16, String)` ACK——**注意 ACK 语义与飞书不同**(飞书是 Response 帧回执,钉钉是 handler 返回值)。
|
||||||
|
|
||||||
|
**评估**:能力全覆盖、架构对齐官方、文档含时序图、有 e2e_test 示例可直接冒烟。社区量小(3459 下载)但对齐官方 Python SDK 降低了漂移风险。备选 dingtalk-stream-sdk 0.2.5(wenhaozhao,更早但更新至 2026-04)可作 fallback。
|
||||||
|
|
||||||
|
## 四、企微:wecom-aibot-rust-sdk(本次新调研,重大发现)
|
||||||
|
|
||||||
|
**关键发现:企业微信「智能机器人」走 WS 长连接**(wss://openws.work.weixin.qq.com,bot_id+secret 认证帧),**完全不需要**传统自建应用的公网回调 URL / GET 验签 / POST AES 解密 / 可信 IP 白名单 / 可信域名。
|
||||||
|
|
||||||
|
**能力覆盖**([.research/wecom-aibot-readme.md](./.research/wecom-aibot-readme.md),546 行完整 API 文档):
|
||||||
|
- 连接管理:自动认证 + 心跳保活(30s)+ 指数退避重连(1s→30s 上限,可设无限)
|
||||||
|
- 消息分发:text / image / mixed / voice / file 五类 on_message_* 事件
|
||||||
|
- **流式回复**:reply_stream(frame, stream_id, content, finish, ...)——stream_id + finish 标志,天然贴合 TunnelEvent::TextDelta 聚合刷新模式
|
||||||
|
- 模板卡片:reply_template_card / **reply_stream_with_card**(流式+卡片组合)/ update_template_card(需事件 5s 内调用)
|
||||||
|
- 主动推送:send_message(chatid, body)——无需依赖回调帧,Markdown/卡片均可
|
||||||
|
- 事件回调:enter_chat(进会话,5s 内 reply_welcome)/ **template_card_event(卡片按钮点击)** / feedback
|
||||||
|
- 文件:download_file(AES-256-CBC 自动解密,媒体自带独立 aeskey)+ upload_media(WS 分片上传,media_id 3 天有效)
|
||||||
|
- 串行回复队列:同 req_id 回复串行发送自动等回执;可插拔 Logger;tokio 原生
|
||||||
|
|
||||||
|
**API 形态**:回调式(on_* 闭包注册)+ client 可 clone(Arc 内部)——与 df-im-gateway 的 adapter 模式兼容性极好,直接把 on_message_text 桥到 TunnelCommand::Send。
|
||||||
|
|
||||||
|
**限制与验证项**:
|
||||||
|
1. **形态确认**:智能机器人 vs 自建应用的能力边界需在管理后台确认(智能机器人是否支持群聊场景、可见范围配置)。README 场景以单聊(userid)+ chatid 为主,群聊支持度落地时验证
|
||||||
|
2. 模板卡片更新有 **5s 时窗限制**(enter_chat/template_card_event 后 5s 内)——审批卡片的「按钮点击后更新卡片状态」必须用时窗内 update_template_card 或直接发新卡片
|
||||||
|
3. 社区量小(346 下载)且为个人项目——锁版本 + 冒烟测试对冲;协议本身简单(WS+JSON 帧),最坏情况自实现成本可控(远低于原 AES 回调模式)
|
||||||
|
4. 备选:WecomTeam/wecom-cli 官方系(wecomx-* crate 族,2026-08 活跃)但为 CLI 形态服务化集成不便;传统自建应用路线(wecom-crypto + wecom-rs)仅在智能机器人不满足时启用
|
||||||
|
|
||||||
|
## 五、对 df-im-gateway 实施的影响
|
||||||
|
|
||||||
|
### 5.1 规划修正
|
||||||
|
1. **§四 四端对比表**:企微「事件接收」从「HTTP 回调(GET 验签+POST AES,需公网 URL)」修正为「WS 长连接(智能机器人形态,免公网回调)」;接入复杂度从「高」降为「中」
|
||||||
|
2. **P4 任务描述**:接入路线改为智能机器人(wecom-aibot-rust-sdk),传统回调模式降级为 fallback
|
||||||
|
3. **三平台统一形态**:三家全是「WS 长连接出站 + JSON/protobuf 帧 + 卡片流式 + 按钮回调」——df-im-gateway 的 adapter trait 可以高度统一(InboundEvent/OutboundAction 映射逻辑三平台趋同),印证 P0 的 ④ 预留项价值
|
||||||
|
|
||||||
|
### 5.2 统一技术底座
|
||||||
|
| 关注点 | 三家公共形态 | 网关侧统一处理 |
|
||||||
|
|---|---|---|
|
||||||
|
| 连接 | WS 出站长连接 + 心跳 + 自动重连 | adapter trait 的 connect/health_check 统一签名 |
|
||||||
|
| 收消息 | 事件/消息帧 → text content | InboundEvent::Message{chat_id, user, content} |
|
||||||
|
| 流式回复 | stream_id + finish 标志(企微)/ CardKit sequence(飞书)/ AI 卡片流式(钉钉) | OutboundAction::StreamChunk{conv_id, delta, finished} → 各 adapter 自映射 |
|
||||||
|
| 审批按钮 | 卡片按钮点击事件 | InboundEvent::CardAction{chat_id, action_id, value} → TunnelCommand::Approve |
|
||||||
|
| 媒体 | 下载码/aeskey(企微钉钉)、message resource(飞书) | 二期再做,一期纯文本 |
|
||||||
|
|
||||||
|
### 5.3 风险清单(落地时冒烟)
|
||||||
|
| # | 风险 | 对冲 |
|
||||||
|
|---|---|---|
|
||||||
|
| R1 | 飞书 card.action.trigger 长连接下 typed 注册口未编译验证 | P2 开工首个冒烟项(examples/card_action_handler.rs 已存档) |
|
||||||
|
| R2 | 企微智能机器人群聊支持度/可见范围未确认 | P4 开工前管理后台确认;不满足则回退传统自建应用回调路线 |
|
||||||
|
| R3 | 三家 SDK 均非官方(个人维护) | 锁精确版本 + e2e 冒烟进 CI + 协议简单最坏自实现 |
|
||||||
|
| R4 | 钉钉 SDK handler ACK 语义与飞书 Response 帧不同 | adapter 层各自封装,不追求网关核心层统一 |
|
||||||
|
| R5 | 企微卡片 5s 更新时窗 | 审批结果用新卡片回复而非更新原卡片,规避时窗 |
|
||||||
|
|
||||||
|
## 六、结论
|
||||||
|
|
||||||
|
三平台 Rust 接入**全部可行且路线统一**(WS 长连接 + SDK 现成 + 卡片流式 + 按钮审批),无阻塞性技术风险。规划文档的 P2/P3/P4 工作量估算维持 3-4 天/平台不变(复杂度下降但均摊了锁版本冒烟与群聊验证成本);企微从「最繁琐」降为与钉钉同级,**实施顺序可按 Web → 飞书 → 企微 → 钉钉 或按企业实际使用优先级自由排**。
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# 上下文三层记忆 — 设计
|
||||||
|
|
||||||
|
> 创建: 2026-10-04 | 定位: 本任务线原点能力(会话无限拉长/合适压缩时机/历史不失忆/高效高质量上下文管理)的正面成篇
|
||||||
|
> 衔接存量: 跨会话召回最小闭环-设计(已拍板 3/3)/无人值守设计(continuum)/今日已修族(memo/豁免/折叠/孤儿配对)
|
||||||
|
|
||||||
|
## 一、核心命题
|
||||||
|
|
||||||
|
**会话无限拉长 ≠ 上下文无限增大。** 正解=恒定的窗口 + 分层的记忆 + 可靠的召回——任何时刻 LLM 看到的窗口小、精、相关;需要的记忆都能按需取回。三件事各司其职:
|
||||||
|
|
||||||
|
```
|
||||||
|
工作记忆(窗口) 最近若干轮+当前任务上下文,恒定尺寸 ← 已有(滑窗+视图管理+今日修复)
|
||||||
|
情景记忆(压缩层) 本会话的结构化记忆(四类卡),不失忆的压缩 ← 本设计核心(缺口)
|
||||||
|
长期记忆(库) 跨会话知识/技能/发现,按需召回 ← 存量设计已拍板(召回闭环)
|
||||||
|
执行续点(continuum) 精确恢复用(轮次/预算/在飞),非语义 ← M2 既定路线
|
||||||
|
```
|
||||||
|
|
||||||
|
## 二、情景记忆:记忆卡片化压缩(根治失忆)
|
||||||
|
|
||||||
|
### 2.1 现状缺陷
|
||||||
|
|
||||||
|
压缩层只产「短摘要文本」——目标/决策/发现/约束全被摊平压成一坨,关键事实随压缩蒸发(225 轮仅存 2 条、主文档重写 17 次皆此类失忆的表征)。且压缩靠固定阈值触发,「第 N 轮全文第 N+1 轮摘要」的断崖导致行为不一致。
|
||||||
|
|
||||||
|
### 2.2 设计:压缩产物结构化四类卡
|
||||||
|
|
||||||
|
压缩时不再只产摘要,而产**记忆卡**(结构化,每类独立保真):
|
||||||
|
- **目标卡**: 当前任务目标链(pinned_goals 既有先例,扩展为完整目标链)
|
||||||
|
- **决策卡**: 已定方案/取舍/拍板(带依据一句话)
|
||||||
|
- **发现卡**: 事实性发现(软删地雷/文件结构/环境约束)
|
||||||
|
- **待办卡**: 未完成项+被拒操作(供续跑与停靠说明)
|
||||||
|
|
||||||
|
压缩后卡片常驻视图头部(小),原始轮次按需可被检索(不全丢)。**不失忆才能敢压**——这是高压缩率与行为一致性的前提。
|
||||||
|
|
||||||
|
### 2.3 压缩时机「合适」:信息增益信号化
|
||||||
|
|
||||||
|
- 触发条件从纯 token 阈值改为「token 阈值 × 信息增益」: 卡片集合自上次压缩无变化(无新目标/决策/发现)→ 可立即压缩;有变化→ 至少保留新卡所在轮完整
|
||||||
|
- 与 tool_view_decisions 冻结机制同源扩展(视图决策冻结已有,扩展到全文视图)
|
||||||
|
- 压缩事件、卡片变更全部落审计/监控(压缩不再黑箱)
|
||||||
|
|
||||||
|
## 三、长期记忆与召回(衔接存量,零新设计)
|
||||||
|
|
||||||
|
- 会话内提炼链已存在(knowledge_inject 提炼+水位防重);跨会话召回最小闭环设计已拍板(published 参与检索+项目维度过滤)——**本设计只提衔接口径**:记忆卡的「发现卡」同时走提炼链进知识库,会话内短期(卡)与会话间长期(库)双轨同源
|
||||||
|
- knowledges 1033 条只写不读的激活(embedding 回填/检索纳入)按已拍板项推进,本设计不重复立项
|
||||||
|
|
||||||
|
## 四、五项自评
|
||||||
|
|
||||||
|
兼容(压缩产物换代:旧会话走旧摘要,新会话起用卡片,双轨过渡)/破坏(压缩输出语义变化,需前端视图适配——卡片渲染)/安全(卡片含拒绝记录,与停靠说明同源无新面)/资源(卡片小,压缩后窗口反而更小=省 token)/回退(卡片化开关,关=退回纯摘要)。
|
||||||
|
|
||||||
|
## 五、分期
|
||||||
|
|
||||||
|
- C-m1: 记忆卡产出(压缩层重构为四类卡+渲染) — 独立可交付
|
||||||
|
- C-m2: 时机关联(信息增益信号+冻结扩展)
|
||||||
|
- C-m3: 与提炼链/知识库双轨衔接(依赖召回闭环实施)
|
||||||
|
|
||||||
|
## 六、拍板点
|
||||||
|
|
||||||
|
1. 卡片常驻视图头部(始终可见)还是折叠条(点击展开)?建议折叠条+悬浮预览,防长期占版面。
|
||||||
|
2. 卡片保留策略: 全压缩周期保留 last-N(建议 N=3 版本,防误压)。
|
||||||
|
3. 旧会话(存量摘要)是否补卡片化?建议不补,新会话起生效。
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
# 并行化与异步唤醒 — 能力设计
|
||||||
|
|
||||||
|
> 创建: 2026-10-04 | 定位: 能力建设批(非缺陷修复)。两条已实证的能力边界:①写并行缺位+同文件写竞态隐患 ②无异步唤醒原语(挂起即等人)
|
||||||
|
> 关联: 跨进程任务接力-设计(M1 接力棒/wake 对接)/无人值守长任务-设计(continuum/租约)/飞书直连通道(外部唤醒源)
|
||||||
|
|
||||||
|
## 一、能力 1:写并行化(risk × 路径租约二维分组)
|
||||||
|
|
||||||
|
### 1.1 现状与缺口
|
||||||
|
|
||||||
|
工具批执行按 risk 单维分组:Low 并行(join_all),Medium 逐个审批/串行,boundary 会话 Medium 旁路后并入并行向量。三个缺口:
|
||||||
|
|
||||||
|
1. **潜在竞态(顺带修复)**:boundary/auto 会话同轮两个 patch 同一文件→旁路后并行执行→写竞态(实证可能:LLM 同轮多次编辑同文件常见);
|
||||||
|
2. **非 boundary 会话写类纯串行**:多文件重构任务(今日主场景)单轮写吞吐受限;
|
||||||
|
3. **分组器无资源维度**:risk 之外,「是否同一路径」这一冲突维度未建模。
|
||||||
|
|
||||||
|
### 1.2 设计:二维分组器
|
||||||
|
|
||||||
|
- 分组三档:**纯读类**→并行(上限沿用 8);**写类按规范路径分片**→异路径并行、同路径按 LLM 发出顺序串行(路径经 canonicalize 归一,与 boundary 白名单同款归一口径);**High**→逐个审批不变。
|
||||||
|
- boundary/auto 旁路逻辑不变,旁路后的写类再按路径分片;非 boundary 会话维持逐个审批(用户逐个拍板语义不倒退)。
|
||||||
|
- 顺带消解 1.1-1 竞态:同路径强制串行即修复。
|
||||||
|
- 实现:单点在 audit 的 process_tool_calls 批分组器;写类路径冲突判定抽纯函数+单测(双 patch 同文件/异文件/读写混合三态)。
|
||||||
|
|
||||||
|
### 1.3 五项自评
|
||||||
|
|
||||||
|
兼容(零 schema)/破坏(同路径保序显式化=消竞态,是修复非破坏)/安全(写面与审批面均不扩)/资源(进程内并发写不同文件安全)/回退(分组器单点开关,默认随 auto/boundary 开)。
|
||||||
|
|
||||||
|
## 二、能力 2:wake 异步唤醒原语
|
||||||
|
|
||||||
|
### 2.1 模型
|
||||||
|
|
||||||
|
挂起任务登记唤醒条件,条件满足自动唤醒注入续跑——「挂起」与「等人」解耦。
|
||||||
|
|
||||||
|
- **WakeRegistry**(首期进程内存,随 app 存亡;宁少勿预留不落库):条目={conv_id, condition, payload, created_at, expires_at, once=true}
|
||||||
|
- **条件三型(首期)**:delay(延时到)/file-appear(文件出现,接力棒 result.json 场景)/process-exit(进程退出,自换装场景)
|
||||||
|
- **唤醒动作**:向目标会话注入一条 user 角色消息(复用 a5 换向注入路径+force_send 语义,过 generating guard/epoch 守卫),内容=唤醒上下文人话;注入即从当前会话状态续跑
|
||||||
|
- **防重**:once 语义+expires 过期清理(默认 24h);注册表上限 64
|
||||||
|
|
||||||
|
### 2.2 三源对接(唤醒源不新增机制)
|
||||||
|
|
||||||
|
| 唤醒源 | 对接方式 |
|
||||||
|
|---|---|
|
||||||
|
| 接力棒脚本(M1) | 脚本完成写 result.json → file-appear 唤醒 |
|
||||||
|
| 飞书直连通道 | 用户消息到达 → 直接注入(不经 registry,即时唤醒) |
|
||||||
|
| app 内调度器 | delay 型 tick 检查(复用既有定时模式) |
|
||||||
|
|
||||||
|
### 2.3 五项自评
|
||||||
|
|
||||||
|
兼容(零 schema,首期内存)/破坏(无既有语义变更)/安全(注入走既有 guard;外部源经飞书白名单;registry 本地条件无远程面)/资源(64 上限+24h 过期)/回退(随接力棒 M1 开关,未开关时 registry 不启用)。
|
||||||
|
|
||||||
|
## 三、分期
|
||||||
|
|
||||||
|
- W-a: 写并行二维分组器(独立交付,含同文件竞态修复)
|
||||||
|
- W-b: wake 原语核心(三条件+注入续跑)
|
||||||
|
- W-c: 与 M1/飞书通道对接(依赖各方落地)
|
||||||
|
|
||||||
|
## 四、拍板点
|
||||||
|
|
||||||
|
1. 写并行默认开(仅 auto/boundary 会话)还是全局?建议:仅 auto/boundary,普通会话维持逐个审批,观测一版再放宽。
|
||||||
|
2. wake 注入消息形态:user 角色(对齐 a5,落库可见)还是 system(静默)?建议 user,审计可见。
|
||||||
|
3. registry 首期内存态(重启即失)可接受?建议可接受——唤醒条件本就依附 app 存续,接力棒场景由进程外脚本兜底。
|
||||||
|
|
||||||
|
## 五、明确不做
|
||||||
|
|
||||||
|
- 不做跨设备唤醒分发;不做条件表达式引擎(三型够用,复杂条件=任务书职责);不做并行度动态调优(常量起步,实测再调)。
|
||||||
|
|
||||||
|
## 六、续接与主动性(下一阶能力,2026-10-04 深夜分析)
|
||||||
|
|
||||||
|
### 6.1 续接能力现状与根修
|
||||||
|
|
||||||
|
| 场景 | 现状 | 根修 |
|
||||||
|
|---|---|---|
|
||||||
|
| 同 loop 断流/失败 | ✅ 跨轮重入退避(P1 已修) | — |
|
||||||
|
| 停靠后续接 | ✅ 人工(点继续/重发);a5 落地后先自动换向×2 | — |
|
||||||
|
| 进程重启后 | ❌ 任务静默失踪(落库稀疏:225 轮存 2 条) | **continuum 任务连续体**(M2/W4 批,V46 轮级持久化) |
|
||||||
|
| 换装/进程死亡 | ❌ 无恢复入口 | **M1 接力棒**(result.json+file-appear 唤醒=6.2) |
|
||||||
|
| 长会话上下文 | ✅ 视图压缩续接(今日已修 memo/豁免) | — |
|
||||||
|
|
||||||
|
缺口本质=「执行续点」不持久+「进程级恢复」缺位。根修两件均设计已备(W4 批与 M1),不新增立项。
|
||||||
|
|
||||||
|
### 6.2 主动性能力分层与根修
|
||||||
|
|
||||||
|
主动性四层:反应性(✅ 响应指令)→自愈性(a5 ✅ 在飞)→**前瞻性(缺)**→**目标驱动持续性(缺)**。
|
||||||
|
|
||||||
|
根修=wake 原语的首个应用场景「**空闲自领**」(不新立大设计):
|
||||||
|
- 空闲检测(无活跃 loop+无 pending)→ delay 型 wake 唤醒 → 查 todo 板按优先级领任务 → 领单纪律(已注入的纪律段覆盖「修复必销账」)→ 完成回写
|
||||||
|
- **边界约束(要害)**:自领任务默认限 Low 风险档+白名单目录,Medium 以上仍需人工——主动性≠放权,是边界内自主(对齐无人值守 TaskBoundary 信任分级)
|
||||||
|
- 治理环联动:自领行为全量进监控 history.jsonl,自领质量纳入 S 阶升级判据
|
||||||
|
- 拍板点(新增 4):①自领默认开还是白名单会话(建议后者起步)②自领频率(建议空闲后 30 分钟延迟触发)③自领范围(todo 板确定性条目 only,设计先行类不领)④自领产出是否推送飞书(建议是,对齐治理环)
|
||||||
|
|
||||||
|
### 6.3 挂机模式(2026-10-04 深夜用户拍板:自领/自推进/自拆解/自设计)
|
||||||
|
|
||||||
|
W-d 空闲自领的完全体:agent 在边界内持续自主运转,人只做拍板与验收。
|
||||||
|
|
||||||
|
**核心循环**: 空闲检测(delay wake)→ 领单(todo 板按优先级)→ 分类(确定性条目直推/设计先行类先自设计)→ 自拆解(大任务切子批,文件零交集矩阵)→ 自推进(边界内执行,熔断走 a5 自愈)→ 自验证(门禁三件套)→ 自销账(板回写+核验语)→ 间隔 → 循环。
|
||||||
|
|
||||||
|
**约束框架(防失控四闸)**:
|
||||||
|
1. 边界闸: 挂机会话=boundary 会话(auto_exec+risk_ceiling+白名单目录),High 风险(部署/push/删除)一律挂起等拍板
|
||||||
|
2. 预算闸: 复用 TaskBudget 三值(挂机时长/单任务轮次/总 token),耗尽→停靠+飞书通知(不无限空转)
|
||||||
|
3. 自愈闸: 执行中熔断走 a5 换向×2;挂机内换向耗尽→跳过该任务领下一个(不死磕单点),同一任务两次挂机失败进「待人工」清单
|
||||||
|
4. 治理闸: 全程进 history.jsonl;每日挂机报告(领了几单/完成几单/返工率/消耗)→飞书
|
||||||
|
|
||||||
|
**与既建机制的组合**(零新基建,全是既有件循环化): wake(循环驱动)/a5(执行自愈)/continuum(续点)/boundary(边界)/TaskBudget(预算)/监控 history(治理)/飞书(通知与远程指令)。
|
||||||
|
|
||||||
|
**分期**:
|
||||||
|
- W-d1 挂机循环骨架(领单→执行→销账,确定性条目 only)
|
||||||
|
- W-d2 自拆解+自设计(设计先行类:先出设计稿落 docs→登记→作为后续实施批自领;编码类大任务切子批)
|
||||||
|
- W-d3 治理强化(预算闸/挂机报告/待人工清单)
|
||||||
|
|
||||||
|
**拍板点(挂机版,覆盖 6.2 的 4 项)**:
|
||||||
|
1. 挂机开关形态: 托盘一键+设置页(建议双入口)
|
||||||
|
2. 挂机时段: 全天 or 夜间档(初衷图既有「夜间额度高宜批量推进」设想,建议可配,默认全天)
|
||||||
|
3. 任务范围: 确定性条目 W-d1 起步;设计类 W-d2 起(自设计产出稿仍挂「待拍板」不直接实施,人工审后自动领实施)
|
||||||
|
4. 失败升级路径: 两次挂机失败→待人工清单+飞书(建议是)
|
||||||
|
|
||||||
|
### 6.3.1 续接能力并入挂机(2026-10-04 深夜 [补1] 用户令)
|
||||||
|
|
||||||
|
挂机跑长时段(尤其过夜),进程重启/崩溃/换装必须能续——**续接是挂机的命门,不是附加项**:
|
||||||
|
|
||||||
|
- **挂机状态持久化**: 挂机会话登记为 continuum 条目(挂机上下文=当前任务/子批进度/预算消耗/待人工清单);W-d1 首版用轻量挂机状态文件(挂机目录 JSON,对齐接力棒 result.json 模式),M2 continuum 落地后升级
|
||||||
|
- **重启自动续**: 启动扫描发现「挂机中」状态→自动恢复循环(挂机=显式完全信任档,重启自动续符合语义;区别于普通任务的「重启不自动续」,开关=挂机开关本身)
|
||||||
|
- **断点任务续跑**: 单任务中断从 continuum 续点恢复(不重放已落库轮次);W-d1 首版退化为「重启后该任务重领,已完成部分靠销账标记去重」——销账纪律(已注入)在此兜底
|
||||||
|
- **换装特殊流**: 挂机中自带换装(接力棒场景)→换装后 file-appear 唤醒自动续,全程无人
|
||||||
|
- 依赖序: W-d1 轻量续接(状态文件)→ M2 continuum(精确续点)→ W-d3 治理强化;续接不阻塞挂机骨架先行
|
||||||
|
|
||||||
|
## 七、多任务×子代理×真异步编排(2026-10-04 深夜用户定标)
|
||||||
|
|
||||||
|
### 7.1 三级并行模型
|
||||||
|
|
||||||
|
```
|
||||||
|
会话层: 多会话并行(F-09 per-conv 已有,并行度可配化)
|
||||||
|
└ 任务层: 每会话一活跃任务,多任务跨会话并行
|
||||||
|
└ 子代理层: 每任务 N 个子代理(spawn 并发已验证),W-a 后工具批内写类亦并行
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 真异步 spawn(核心新增)
|
||||||
|
|
||||||
|
现状:spawn_agent 同步语义(报告=工具返回值,父阻塞等全部子代理)。真异步变体:
|
||||||
|
|
||||||
|
- `spawn_agent(async=true)`: 派发后**立即返回**「已派发+agent_id」,父 loop 继续其它工具/下一轮
|
||||||
|
- 子代理完成 → 经 **wake 原语**(file-appear/process 条件,§二)唤醒父会话,注入「[子代理报告] agent-N: <摘要>」user 消息 → 父下一轮消化
|
||||||
|
- 父任务若已收敛: 报告落审计+完成卡,不强行唤醒(任务边界尊重)
|
||||||
|
- **轮界注入**: 父正在流式生成时报告到达→排队至本轮结束(对齐 a5 轮界注入模式),不打断流
|
||||||
|
|
||||||
|
### 7.3 并发治理(防爆炸)
|
||||||
|
|
||||||
|
- 全局子代理并发池上限(建议 4-6,实测调):防 API 限流+token 成本失控+CPU 争抢
|
||||||
|
- per-task TaskBudget 预算已备,async 子代理计入所属任务预算
|
||||||
|
- 超限行为: async spawn 排队(WakeRegistry 挂 pending 条件=有空槽),非拒绝
|
||||||
|
|
||||||
|
### 7.4 失败面
|
||||||
|
|
||||||
|
子代理失败/截断 → wake 注入失败报告(带 stop_kind,对齐停靠词表),父可重派;父会话已删 → 报告落审计即止。
|
||||||
|
|
||||||
|
### 7.5 依赖与分期
|
||||||
|
|
||||||
|
- 前置: W-a(写并行)/W-b(wake 原语)——本节是二者的组合应用层
|
||||||
|
- W-e1: async spawn 变体+报告回填注入(轮界排队)
|
||||||
|
- W-e2: 全局并发池+预算联动
|
||||||
|
- W-e3: 与 continuum 组合(父会话被压缩/切换后报告仍可达)
|
||||||
|
|
||||||
|
### 7.6 拍板点
|
||||||
|
|
||||||
|
1. async 子代理失败是否自动重派一次?(建议:是,带失败摘要,重派计入任务预算)
|
||||||
|
2. 报告回填消息是否也走 [自动换向] 类前缀族(建议:新增 [子代理报告] 前缀,前端完成卡旁路消费)
|
||||||
|
|
||||||
|
## 八、编排能力分析与规划(2026-10-04 深夜 [补1])
|
||||||
|
|
||||||
|
### 8.1 存量资产盘点(反转发现:编排面零使用≠零资产)
|
||||||
|
|
||||||
|
| 资产 | 状态 | 编排价值 |
|
||||||
|
|---|---|---|
|
||||||
|
| tasks 表(parent_id 层级+8 态状态机) | 在用 | 任务分解与推进的原语 |
|
||||||
|
| task_links 依赖图(DAG 关联+循环检测) | 建成零使用 | 依赖满足自动推进的底座 |
|
||||||
|
| df-workflow(DAG 引擎+workflow_executions 持久化) | 建成零使用(9 张编排表 0 行) | 编排轨迹与节点化执行面 |
|
||||||
|
| TaskAdvanceNode(DAG 节点已注册) | 建成未联动 | DAG×agent 融合的现成插销 |
|
||||||
|
| spawn_agent+registry/boundary/budget | 在用 | 子代理编配原语 |
|
||||||
|
| todo 协作板+挂机自领(W-d 设计) | 设计中 | 任务源与自驱入口 |
|
||||||
|
|
||||||
|
结论:**编排缺口不在资产,在「连接与驱动」**——依赖图没人推进、DAG 引擎不知道 agent、任务源没有调度器。
|
||||||
|
|
||||||
|
### 8.2 缺口清单
|
||||||
|
|
||||||
|
1. 依赖图静态:task_links 声明了 A→B,但 A 完成不会解锁 B(无依赖满足→自动推进)
|
||||||
|
2. 两套孤岛:df-workflow 与 agent loop 互不感知
|
||||||
|
3. 多任务调度器缺位:挂机领单只查板,无依赖/优先级/资源三维调度
|
||||||
|
4. 事件驱动编排缺位:「X 完成→触发 Y」无机制(wake 是雏形)
|
||||||
|
|
||||||
|
### 8.3 编排能力三层路线(渐进,复用率极高)
|
||||||
|
|
||||||
|
- **编排 1.0**(挂机模式内含,W-d):板驱动+任务状态机推进,无依赖图——已设计
|
||||||
|
- **编排 2.0 依赖图自动推进**:task_links 依赖满足(前置任务 completed)→wake 唤醒解锁下游任务(advance+可选自动派发);执行轨迹落 workflow_executions——把「死的依赖图」变成「活的流水线」;改动面=advance 成功回调+wake 注册,复用 df-nodes 状态机与 task_links
|
||||||
|
- **编排 3.0 DAG×agent 融合**:工作流节点=agent 动作(spawn/advance/notify 已有 TaskAdvanceNode 插销),DAG 即多任务编排面;初衷图 Code-Working 会话「dynamic workflow 编排推进」的落地形态;前置=2.0+async spawn(W-e)
|
||||||
|
|
||||||
|
### 8.4 拍板点
|
||||||
|
|
||||||
|
1. 编排 2.0 的依赖解锁是否自动派发执行(建议:解锁仅标记 ready,派发由挂机领单消费——调度权仍在挂机,编排只管条件)
|
||||||
|
2. 编排 3.0 的 DAG 定义形态(YAML 模板 vs 板驱动自动生成,建议后者起步)
|
||||||
|
|
||||||
|
## 九、状态即时同步与通知能力(2026-10-04 深夜 [补2])
|
||||||
|
|
||||||
|
可控支柱的推送面:agent 状态变化即时可达所有端。现状=事件总线单一事实源已立(AiChatEvent 族),缺的是**出口扇出与分级**。
|
||||||
|
|
||||||
|
### 9.1 出口扇出(一对多,零新事实源)
|
||||||
|
|
||||||
|
AiChatEvent 族(进度/停靠/完成/失败/审批)→ 出口适配器:桌面 UI(既有)/托盘 NeedAction(既有)/飞书通道(设计已立,关键路径)/relay-miniapp(隧道既有)/监控 history(已建)。
|
||||||
|
|
||||||
|
### 9.2 即时性分级(防刷屏)
|
||||||
|
|
||||||
|
| 级 | 事件 | 策略 |
|
||||||
|
|---|---|---|
|
||||||
|
| 即时 | 停靠/审批挂起/任务完成/失败熔断 | 即推(同类 5s 节流,notify 既有范式) |
|
||||||
|
| 摘要 | TaskProgress 每 N 轮 | 可选推(挂机模式开时推) |
|
||||||
|
| 批量 | 小时监控摘要/每日挂机报告 | cron 推(治理环既有设想) |
|
||||||
|
|
||||||
|
### 9.3 状态查询面(补「跑到哪了」)
|
||||||
|
|
||||||
|
远控查询命令族(飞书/miniapp 同构):查活跃任务/预算消耗/最近停靠原因——复用 remote_bridge 命令族扩展,只读零风险。
|
||||||
|
|
||||||
|
### 9.4 依赖与分期
|
||||||
|
|
||||||
|
W-g1 出口适配器框架+即时级(依赖飞书通道实施)/W-g2 摘要级+查询命令族/W-g3 每日报告(挂机 W-d3 联动)。
|
||||||
|
|
||||||
|
## 十、会话间/代理间感知能力(2026-10-04 深夜 [补3])
|
||||||
|
|
||||||
|
今天的全部互踩(双头编排/子代理同文件/主客两线)同根:**无人知道「谁正占着什么」**。感知能力=lease 原语的读写两面落地。
|
||||||
|
|
||||||
|
### 10.1 FileLeaseRegistry(文件面占用感知,要害)
|
||||||
|
|
||||||
|
- 写类工具(patch/write/edit)执行前登记 path→(conv, agent, ttl) 到进程级注册表,完成释放;同路径第二写者前置校验命中→拒绝+指路「该文件正被会话 B 编辑,等它完成或换文件」
|
||||||
|
- 与 W-a 二维分组器互补:分组器管**同轮内**保序,registry 管**跨轮跨会话**互斥——合起来=完整 lease
|
||||||
|
- 直接根治:双头编排互踩/子代理同文件竞态/主客两线叠改
|
||||||
|
|
||||||
|
### 10.2 拓扑与状态感知
|
||||||
|
|
||||||
|
- 父↔子: 父见子代理实时状态(ai_sub_agent_list 已备,S3 轨迹已建),子见兄弟在做什么(query_leases 天然覆盖)
|
||||||
|
- 会话↔会话: 一行状态查询「会话 B 在干什么」(per_conv 状态投影,只读)
|
||||||
|
- 发现共享: agent 的排障发现(如「X 是软删地雷」)写 knowledges→其他会话 knowledge_search 可见(盘活 1033 条只写不读的存量)
|
||||||
|
|
||||||
|
### 10.3 分期
|
||||||
|
|
||||||
|
W-h1 FileLeaseRegistry+写前置校验(小改,价值立现)/W-h2 query_leases 工具+兄弟感知/W-h3 跨会话状态+知识联动。前置: 无(纯增量);与 W-a 同批相邻落地最佳。
|
||||||
|
|
||||||
|
## 十一、伪命题甄别(2026-10-04 深夜 [补4],对抗自查,实施清单以此为准)
|
||||||
|
|
||||||
|
五测试:今天有无真实被卡/有无更轻替代/是否预建/与既有重复/收益成本比。
|
||||||
|
|
||||||
|
| 件 | 判定 | 理由与处置 |
|
||||||
|
|---|---|---|
|
||||||
|
| W-h 感知/租约 | **真命题,P0** | 今日三连实锤(双头/子代理同文件/主客两线);小改大收益,提到最前 |
|
||||||
|
| W-d1 挂机最小闭环 | **真命题** | 用户亲定场景;先跑一周看真实产出率再议深化 |
|
||||||
|
| W-e2 全局并发池 | **真命题(小)** | 3 子代理连发已近约束,防 token 爆炸真约束 |
|
||||||
|
| W-a 写并行吞吐 | **半伪,降级** | 墙钟大头是构建(38%)非写(秒级);**只修同文件竞态保序**(一行分组逻辑),吞吐优化等写耗时占比>20% 实测再做 |
|
||||||
|
| W-d2 自拆解/自设计 | **降级** | 板上条目登板时已拆好;设计稿产出不需要专门机制——作顺手能力,不立子系统 |
|
||||||
|
| W-b wake 原语 | **条件性,随场景建** | 现有消费者(挂机/接力棒)均未落地;单独建=预建。与 W-d1 绑定实施 |
|
||||||
|
| W-e1 async spawn | **条件性,缓** | 今日子报告全是下一步输入(依赖决定同步是对的);先建并发池,出现「父有事可做」场景再建 async |
|
||||||
|
| W-f 编排 2.0/3.0 | **冻结(高伪风险)** | task_links/df-workflow 至今零真实使用——「编排面零使用」的正确读法是**还没到需要编排的阶段**;启动信号=用户开始建依赖链或挂机领单遇前置任务 |
|
||||||
|
| W-g 状态推送 | 真命题但被阻塞 | 随飞书通道走,不单独排 |
|
||||||
|
| W-c 对接层 | 合并 | 非独立件,并入 W-d/M1 实施阶段标记 |
|
||||||
|
|
||||||
|
**实施序修正**: W-h 感知租约(最先)→ W-e2 并发池 → W-d1 挂机最小闭环(含 W-b wake 按需)→ 其余冻结等信号。
|
||||||
|
**自查教训**: 本文档 §1-§9 是「能力全景」(留给未来对照),§十一才是「现在做什么」——全景图与施工图必须分开,否则全景会冒充计划。
|
||||||
|
|
||||||
|
## 十一·修正(2026-10-04 深夜 [补2] 用户纠正)
|
||||||
|
|
||||||
|
**方法错误更正**:「零真实使用→冻结等信号」是鸡生蛋逻辑错误——task_links/df-workflow 零使用的主因是**能力未暴露**(无 agent 工具面入口/无调用门),不是需求不存在。**正确方法=投最小探针**:暴露最小可用入口让真实使用发生,用使用率(而非等待)决定深化。
|
||||||
|
|
||||||
|
修正后处置:
|
||||||
|
- W-f 2.0: 降级为**最小探针**——agent 工具面暴露「声明依赖」+依赖满足时提示一行(不做自动推进);探针使用率决定是否建自动推进
|
||||||
|
- W-e1 async spawn: 同理暴露 async 参数(小改),有机会用才有需求验证;并发池(W-e2)不受影响仍立做
|
||||||
|
- 其余维持: W-a 写并行降级有**实测数据支撑**(构建 38% vs 写秒级),不依赖使用信号,结论不变;W-d2 维持「顺手能力不立子系统」但自设计保留为挂机探索项
|
||||||
|
- 通用教训(入方法论): 能力甄别双判据——**有量化实测数据的按数据判;无数据的投最小探针判**;禁止把「未被使用」直接读成「不需要」(尤其当入口未提供时)
|
||||||
|
|
||||||
|
## 十二、自进化能力(2026-10-04 深夜 [补1]:自造工具+重复寻优)
|
||||||
|
|
||||||
|
初衷图「进化系统(自举)」的落地形态,两层:
|
||||||
|
|
||||||
|
### 12.1 重复→寻优(重复是重构信号,不只是停滞信号)
|
||||||
|
|
||||||
|
- 数据源已备: audit 表全量工具调用序列——**同一序列模式出现 N 次**(如「grep X→read Y→patch Z」×5)即抽象候选
|
||||||
|
- 两种触发: ①在线(G2 熔断前的中途检测:重复达阈值时先问「是否有更优解/可抽象为脚本」,从「数次数」升级为「问原因」)②离线(挂机空闲扫 audit 出模式报告——进化任务成为挂机的自领任务类型)
|
||||||
|
- 输出: 优化建议(换工具/写脚本/建技能),不自动改=零风险
|
||||||
|
|
||||||
|
### 12.2 工具/技能自造管线
|
||||||
|
|
||||||
|
- 现状: agent 会写脚本(run_command)但**无「晋升为注册技能」的管线**;devflow 技能库与 read_skill 已存在(read_skill cm/advance 今日实证)
|
||||||
|
- 设计: 脚本落 scripts/ → 注册进技能库(最小元数据: 名称/用途/入参/风险档) → 后续任务 read_skill 复用;风险档默认 Low(只读类)/写类需审批;资产可审计可回收
|
||||||
|
- 沉淀物分流: 重复的**排障发现**→knowledges;重复的**操作序列**→skills(两种进化物各归其位)
|
||||||
|
|
||||||
|
### 12.3 分期与拍板
|
||||||
|
|
||||||
|
- E-m1 模式扫描报告(只读,零风险,挂机自领一类)→ E-m2 在线启发(熔断对话化)→ E-m3 技能注册管线(风险档+审批)
|
||||||
|
- 拍板点: 技能注册是否需人工审(建议 E-m3 首次注册必审,之后同类自动)
|
||||||
|
|
||||||
|
## 十三、失败学习环(2026-10-04 深夜:所有失败作为学习教材,不反复在相同处失败)
|
||||||
|
|
||||||
|
「不重复失败」的量化判据分两层:同签名不重放(已管:loop 内窗口+纪律注入)+ **同类失败不跨会话重演**(缺,本节补)。
|
||||||
|
|
||||||
|
### 13.1 失败归类与学习
|
||||||
|
|
||||||
|
- audit 已是全量失败事实源(status+error+参数);缺「**归类**」:按错误模式聚类(状态机拒绝/路径未授权/精确匹配未中/网络/参数非法),类粒度才是学习单元
|
||||||
|
- 高频失败类 → 提炼**教训文本**(因果+规避法一句话),存 knowledges(失败分册,复用既有知识链与召回闭环设计,零新存储)
|
||||||
|
|
||||||
|
### 13.2 教训的三个消费点
|
||||||
|
|
||||||
|
1. **同类任务前置注入**: 派发/自领任务时,若目标域有高频失败类教训→提示词附「历史高频坑」(与 a5 换向注入同路)
|
||||||
|
2. **失败现场即时附学**: 新失败发生时,若该类已有教训→失败提示附「同类失败已 N 次,已学经验:...」(治盲试的直接抓手)
|
||||||
|
3. **挂机进化任务**: F-m1 失败归类统计=自领任务一类(只读零风险),产出高频失败 Top 与教训草案
|
||||||
|
|
||||||
|
### 13.3 分期与自评
|
||||||
|
|
||||||
|
F-m1 归类统计(只读)/F-m2 教训提炼+注入/F-m3 失败现场附学。自评: 兼容(复用 audit+knowledge 链零新存储)/安全(教训文本进提示词,无注入面——教训由 LLM 提炼经审核入板)/资源(统计查询按需)/回退(注入开关)。拍板点: 教训提炼由谁审(LLM 出稿+用户/主代理审,建议后者)。
|
||||||
|
|
||||||
|
### 12.4 工具进化环([补]:自写工具的自迭代)
|
||||||
|
|
||||||
|
自造(E-m3)之后是自进化——工具不是写完即终态:
|
||||||
|
|
||||||
|
- **使用遥测**: 自造工具每次调用记 次数/失败率/「调用后紧跟修正」率(补丁紧跟=不顺手信号),数据源=既有 audit,零新埋点
|
||||||
|
- **进化触发**: 失败率高 或 修正跟随率超阈值 → 该工具进进化候选
|
||||||
|
- **进化动作**: 读工具源码+遥测 → LLM 产改进版(**参数向后兼容**)→ 版本号递增 → 旧调用方不受影响 → 旧版退役
|
||||||
|
- **安全闸**: 进化=代码变更,走既有门禁(check+相关测试);行为变化审计留痕;风险档升级需审批(对齐技能注册同款纪律)
|
||||||
|
- 依赖序: E-m3 注册管线之后(先有自造才有自进化),真工具的真实使用积累(E-m3 落地后自然产生)——**本阶是最远期件,前面三阶不落地它不启动**
|
||||||
|
|
||||||
|
### 12.5 脚本生命周期纪律([补1]:不瞎写瞎用瞎删)
|
||||||
|
|
||||||
|
今日实证三类乱象:仓库根目录散落探查脚本(.fix_audit.py 类)/feishu_smoke.rs 写删横跳/docs 目录混入 .research 脚本堆。纪律四条:
|
||||||
|
|
||||||
|
1. **落位**: 一次性探查脚本→scripts/ 专用目录+命名 `scratch-<主题>.<ext>`,头部两行注释(用途/一次性或可复用);持久工具→走 E-m3 注册管线晋技能;**禁止落仓库根目录与文档目录**
|
||||||
|
2. **声明**: 头部即声明生命周期(用完即删/长期保留),删前核对「目的已达成且无引用」
|
||||||
|
3. **写前想清**: 结构性脚本(超过 30 行/含依赖引入)先定形态再写——对齐纪律段「一次成形」;写删回环超过一轮=停下换思路(与 G3 熔断同一触发语义,但纪律在前熔断在后)
|
||||||
|
4. **资产化优先**: 同类脚本写第二次→触发 E-m 重复寻优(抽象为注册技能),脚本资产不流失
|
||||||
|
|
||||||
|
落地: ①E-m3 管线含落位与声明规范 ②纪律段增补第 7 条(脚本落位,下一批注入修订顺带,不单独改) ③既有 memory「工作空间根目录整洁规范/AI临时文件写入规范」与此同源,agent 侧规则注入后即为同一纪律的对端执行
|
||||||
|
|
||||||
|
5. **临时文件同规**: 临时文件(中间产物/探针输出/快照)与脚本同一套生命周期——落专用目录(temp/ 或 .scratch/,禁根目录/文档目录/源码树)+命名带主题与日期+声明保留策略(用完即删/任务末清理)+**永不进 git**(gitignore 已挡,提交前三问第 1 问复核)。今天实证的应然态:探查脚本堆落 docs/.research/ 即违规,应落 temp/agent-monitor/ 同级专用目录。
|
||||||
|
|
||||||
|
6. **文档同规**: AI 产文档三分各归其位——①会话报告/分析结论:默认对话内直交,需跨会话保留落 temp/(命名主题-日期);②设计文档:docs/02-架构设计/专项设计/ +INDEX 登记;③项目文档:对应 docs/ 子目录+中文命名+INDEX 更新。禁:文档落根目录/未进 INDEX/设计稿与正式文档混放。今天正反例:四篇设计落专项设计目录(正)vs .research 勘察堆落 docs 目录(反,应落 temp/)。
|
||||||
|
|
||||||
|
### 13.4 有界失败记忆([补1]:不无限堆失败记录撑爆上下文)
|
||||||
|
|
||||||
|
失败记忆三界,界界有界:
|
||||||
|
|
||||||
|
- **窗口内(会话)**: A4 的失败豁免需精化——**同签名失败只保留首条全文,重复签名折叠为一行**「同上: <工具+错误类> (第 N 次)」(签名=工具名+错误类目,非全文哈希)。同时达成三事:上下文不灌爆/盲试被显式计数可视化(第 N 次本身就是给 LLM 的停止信号)/首条因果详情永不丢。豁免从「无条件全文」精化为「首条全文+后续折叠」,A4 语义不回退。
|
||||||
|
- **会话间(失败分册)**: knowledges 失败分册按**类**去重——一个失败类一条教训,新证据合并更新旧条目(版本化),非追加新条;分册 Top-K 有界(按频次×新近度保留 50 类,LRU 淘汰长尾)
|
||||||
|
- **监控(history.jsonl)**: 每小时一行体量极小,按月轮转归档即可(年增 ~2MB,卫生性轮转)
|
||||||
|
|
||||||
|
一致性: 三界的「类目」口径同源(错误归类函数复用,F-m1 的归类器一处实现)——窗口折叠/分册去重/统计归因用的是同一套签名,避免三处三套口径漂移。
|
||||||
@@ -1034,3 +1034,43 @@ graph TD
|
|||||||
|
|
||||||
- **reset --hard 前工作区必须干净**:当前存在未提交改动(含本节处方)。执行顺序=①先收尾在飞工作,连同本节处方一并提交为第 7 笔(消息如 `文档: 提交重整处方与在飞收尾`)②然后 reset --hard origin/main ③按原序 cherry-pick 前六笔并逐笔 amend 换消息 ④第 7 笔原样 cherry-pick 不改消息 ⑤树一致性校验(git diff 原HEAD 为空)
|
- **reset --hard 前工作区必须干净**:当前存在未提交改动(含本节处方)。执行顺序=①先收尾在飞工作,连同本节处方一并提交为第 7 笔(消息如 `文档: 提交重整处方与在飞收尾`)②然后 reset --hard origin/main ③按原序 cherry-pick 前六笔并逐笔 amend 换消息 ④第 7 笔原样 cherry-pick 不改消息 ⑤树一致性校验(git diff 原HEAD 为空)
|
||||||
- 重放期间禁止并行改码;校验不过立即 `git reset --hard $OLD` 回滚到重整前
|
- 重放期间禁止并行改码;校验不过立即 `git reset --hard $OLD` 回滚到重整前
|
||||||
|
|
||||||
|
# 能力建设批设计完成(2026-10-04 深夜,并行化+wake 原语)
|
||||||
|
|
||||||
|
> 设计: docs/02-架构设计/专项设计/并行化与异步唤醒-设计-2026-10-04.md(五项自评已过,拍板点 3 项)。实施按分工归 DevFlow agent 或我方批次,待令。
|
||||||
|
|
||||||
|
- [ ] W-a 写并行二维分组器(risk×路径租约:读类并行/写类异路径并行同路径保序/High 不变;顺带修 boundary 同文件双 patch 竞态)
|
||||||
|
- [ ] W-b wake 原语(内存 registry 三条件 delay/file-appear/process-exit,唤醒注入走 a5 路径+guard)
|
||||||
|
- [ ] W-c 与 M1 接力棒/飞书通道对接(依赖各方落地)
|
||||||
|
|
||||||
|
- [ ] W-d 空闲自领(wake 首应用):空闲检测→领 todo 确定性条目→边界内执行→完成回写+飞书推送;拍板点 4 项(默认关起步/30min 延迟/仅确定性/推送飞书)——设计要点见并行化与异步唤醒设计 §6.2
|
||||||
|
|
||||||
|
- [ ] W-d 挂机模式(用户拍板:自领/自推进/自拆解/自设计;设计 §6.3):W-d1 挂机循环骨架(确定性条目)/W-d2 自拆解+自设计(设计稿落 docs 挂待拍板)/W-d3 治理强化(预算闸+挂机报告+待人工清单);拍板点 4 项待定
|
||||||
|
|
||||||
|
- [ ] 指标阶梯升级为四支柱框架(2026-10-04 用户定):效率+自主+质量+可控四柱同时定级(木桶规则),每柱映射 L1-L4 指标族;已并入 baseline json framework 段;推进序不变
|
||||||
|
|
||||||
|
- [ ] W-e 多任务×子代理×真异步编排(设计 §七):W-e1 async spawn+报告轮界回填/W-e2 全局并发池+预算联动/W-e3 continuum 组合;前置=W-a+W-b;拍板点 2 项
|
||||||
|
|
||||||
|
- [ ] W-f 编排能力三层路线(设计 §八):1.0 板驱动(挂机内含)/2.0 task_links 依赖图自动推进(advance 回调+wake 解锁)/3.0 DAG×agent 融合(TaskAdvanceNode 插销已备);拍板点 2 项
|
||||||
|
|
||||||
|
- [ ] W-g 状态即时同步与通知(设计 §九):W-g1 出口扇出+即时级(依赖飞书通道)/W-g2 摘要级+远控查询命令族/W-g3 每日报告(挂机联动)
|
||||||
|
|
||||||
|
- [ ] W-h 会话间/代理间感知(FileLeaseRegistry:写前登记+冲突指路/query_leases 工具/兄弟感知/知识联动;与 W-a 组合成完整 lease)——设计 §十;根治今日全部互踩
|
||||||
|
|
||||||
|
- [ ] W-h 感知租约(升级 P0:今日三连互踩实锤,FileLeaseRegistry+写前置校验,小改大收益)——能力批实施第一位
|
||||||
|
- [ ] W-e2 全局并发池(小:子代理并发上限+预算联动)
|
||||||
|
- [ ] W-d1 挂机最小闭环(领单→执行→销账→循环+轻量续接;W-b wake 随其绑定实施)
|
||||||
|
- [ ] 冻结待信号: W-a 写并行吞吐(只先修竞态)/W-d2 自拆解自设计/W-f 编排 2.0-3.0/W-e1 async spawn——启动信号见设计 §十一
|
||||||
|
|
||||||
|
- [ ] C-m 上下文三层记忆(设计落档):C-m1 记忆卡四类(目标/决策/发现/待办)压缩重构/C-m2 压缩时机信息增益信号/C-m3 与召回闭环衔接;拍板点 3 项
|
||||||
|
- [ ] E-m 自进化能力(设计 §十二):E-m1 模式扫描报告(挂机自领类)/E-m2 在线启发(熔断对话化)/E-m3 技能注册管线;拍板点 1 项
|
||||||
|
- [ ] W-f 2.0 改为最小探针(依赖声明工具面暴露,使用率决定深化)——[补2] 方法修正
|
||||||
|
|
||||||
|
- [ ] F-m 失败学习环(设计 §十三):F-m1 失败归类统计(挂机自领类)/F-m2 教训提炼进 knowledges 失败分册+同类任务前置注入/F-m3 失败现场即时附学;拍板点 1 项(教训审核)
|
||||||
|
|
||||||
|
# 环境知识:GitHub 直连受限的替代方案(2026-10-04 深夜,失败学习环首条)
|
||||||
|
|
||||||
|
- download_file 拉 github.com releases 失败(今晚 protoc 实证)→ 替代:**URL 前缀镜像**(如 https://gh-proxy.com/ + 原URL)或 **gh 命令**(gh release download,已登录态)或本地包管理器(winget/choco 装 protoc 等构建依赖,一次预置后构建不再拉外网)
|
||||||
|
- download_file 工具增强候选:失败自动镜像回退(小改,登记待批)
|
||||||
|
|
||||||
|
- [ ] F-m 实施细节补充(§13.4 有界失败记忆):A4 失败豁免精化为「首条全文+同签名折叠一行(第N次)」——防窗口灌爆+盲试显式计数;失败分册按类去重 Top-K 有界;监控按月轮转;三界类目口径同源(同一归类函数)
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# 用途: devflow 打包换装双通道脚本(verify=快速编译验证 / full=全量发版换装)
|
||||||
|
# 生命周期: 长期保留(挂机模式打包组件的雏形,接力棒 M1 落地后由其接管安装段)
|
||||||
|
# 用法: .\scripts\package-devflow.ps1 -Mode verify # 只验 release 编译,分钟级
|
||||||
|
# .\scripts\package-devflow.ps1 -Mode full # tauri build -> 杀进程 -> /S 静默装 -> 启动验证
|
||||||
|
param(
|
||||||
|
[ValidateSet("verify", "full")]
|
||||||
|
[string]$Mode = "verify"
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Continue"
|
||||||
|
$repo = "E:\wk-lab\devflow"
|
||||||
|
$exe = "$env:LOCALAPPDATA\devflow\devflow.exe"
|
||||||
|
$minFreeGB = 10
|
||||||
|
|
||||||
|
Set-Location $repo
|
||||||
|
|
||||||
|
function Get-FreeGB($path) {
|
||||||
|
$drive = (Get-Item $path).PSDrive
|
||||||
|
return [math]::Round($drive.Free / 1GB, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
$free = Get-FreeGB $repo
|
||||||
|
if ($free -lt $minFreeGB) {
|
||||||
|
Write-Host "[预检失败] E 盘剩余 ${free}GB < ${minFreeGB}GB,先清 target\debug\incremental(历史教训 os error 112)"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Write-Host "[预检] E 盘剩余 ${free}GB,通过"
|
||||||
|
|
||||||
|
if ($Mode -eq "verify") {
|
||||||
|
Write-Host "== 通道1: 快速编译验证(cargo build --release,不打安装包) =="
|
||||||
|
cmd /c "cargo build --release 2>&1"
|
||||||
|
if ($LASTEXITCODE -ne 0) { Write-Host "[失败] 编译未过"; exit 1 }
|
||||||
|
Write-Host "[通过] release 编译成功(未打安装包)"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "== 通道2: 全量发版换装(tauri build -> 静默安装 -> 启动验证) =="
|
||||||
|
Write-Host "[1/4] tauri build(可能 10 分钟级)..."
|
||||||
|
cmd /c "cargo tauri build 2>&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
|
||||||
|
if (-not $bundle) { Write-Host "[失败] 未找到安装包产物"; exit 1 }
|
||||||
|
Write-Host "[2/4] 安装包: $($bundle.Name) ($([math]::Round($bundle.Length/1MB,1))MB, $($bundle.LastWriteTime))"
|
||||||
|
|
||||||
|
Write-Host "[3/4] 杀旧进程 -> 静默安装..."
|
||||||
|
Get-Process devflow -ErrorAction SilentlyContinue | Stop-Process -Force
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
Start-Process $bundle.FullName -ArgumentList "/S" -Wait
|
||||||
|
|
||||||
|
if (-not (Test-Path $exe)) { Write-Host "[失败] 安装后未找到 $exe"; exit 1 }
|
||||||
|
$exeTime = (Get-Item $exe).LastWriteTime
|
||||||
|
Write-Host "[4/4] 启动验证(装机 exe 时间: $exeTime)..."
|
||||||
|
Start-Process $exe
|
||||||
|
Start-Sleep -Seconds 6
|
||||||
|
$proc = Get-Process devflow -ErrorAction SilentlyContinue
|
||||||
|
if ($proc) {
|
||||||
|
Write-Host "[换装成功] PID $($proc.Id) 存活,装机时间 $exeTime"
|
||||||
|
} else {
|
||||||
|
Write-Host "[警告] 启动后 6 秒未见进程,请人工确认(可能仍在初始化)"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
@@ -466,10 +466,141 @@ pub(crate) fn is_tool_call_over_limit(count: usize) -> bool {
|
|||||||
count > super::MAX_TOOL_CALLS_PER_ROUND
|
count > super::MAX_TOOL_CALLS_PER_ROUND
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── UPDATE_GOALS:LLM 主动修正目标列表(目标维护闭环,2026-10-04) ──
|
||||||
|
//
|
||||||
|
// 动机(源:todo.md「AI Chat 体验优化 2026-07-03 批次」暂缓项):G1 目标钉扎的提取源是
|
||||||
|
// 工具调用推理(infer_goal_from_tool_calls),只能"看做过的"推目标;当用户中途补充信息
|
||||||
|
// 纠偏/意图递进时,之前推理的目标可能已偏,LLM 无法主动修正。本标记给 LLM 一个
|
||||||
|
// 显式通道:输出 [UPDATE_GOALS] 块 → 后端**替换** pinned_goals(区别于工具推理的追加)
|
||||||
|
// → 块从正文剥离(用户看到的是干净回复)。
|
||||||
|
//
|
||||||
|
// 格式(逐行解析,宽松容错):
|
||||||
|
// [UPDATE_GOALS]
|
||||||
|
// 1: 完成登录接口
|
||||||
|
// 2: 写单测 (completed)
|
||||||
|
// [END]
|
||||||
|
// 状态默认 active;行尾 "(completed)" 标记完成。"1:" 编号仅助 LLM 对齐,解析忽略。
|
||||||
|
|
||||||
|
/// 标记起止串(子串匹配;宽松容忍缩进由行级 trim 处理)。
|
||||||
|
pub(crate) const UPDATE_GOALS_OPEN: &str = "[UPDATE_GOALS]";
|
||||||
|
pub(crate) const UPDATE_GOALS_CLOSE: &str = "[END]";
|
||||||
|
|
||||||
|
/// 解析 [UPDATE_GOALS] 块:返回 (目标列表(text, active), 剥离标记后的正文)。
|
||||||
|
///
|
||||||
|
/// - 无开标记 → None(正文原样,零开销快路径)
|
||||||
|
/// - 有开无闭 → None(残块不解析,防半截输出误替换;正文原样保留——下一轮 LLM 重发)
|
||||||
|
/// - 目标 0 条 → None(空块视为噪声,不替空用户目标)
|
||||||
|
/// - 超 MAX_GOALS 截断(与工具推理提取同上限)
|
||||||
|
pub(crate) fn extract_update_goals_block(full_text: &str) -> Option<(Vec<(String, bool)>, String)> {
|
||||||
|
let open_idx = full_text.find(UPDATE_GOALS_OPEN)?;
|
||||||
|
let after_open = &full_text[open_idx + UPDATE_GOALS_OPEN.len()..];
|
||||||
|
let close_rel = after_open.find(UPDATE_GOALS_CLOSE)?;
|
||||||
|
let block_body = &after_open[..close_rel];
|
||||||
|
let cleaned = format!(
|
||||||
|
"{}{}",
|
||||||
|
&full_text[..open_idx],
|
||||||
|
&after_open[close_rel + UPDATE_GOALS_CLOSE.len()..],
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut goals: Vec<(String, bool)> = Vec::new();
|
||||||
|
for line in block_body.lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 去掉 "1:" 式编号前缀(编号+冒号,编号须全数字;普通文本含冒号不受影响)
|
||||||
|
let content = trimmed
|
||||||
|
.split_once(':')
|
||||||
|
.filter(|(head, _)| {
|
||||||
|
let h = head.trim();
|
||||||
|
!h.is_empty() && h.chars().all(|c| c.is_ascii_digit())
|
||||||
|
})
|
||||||
|
.map(|(_, tail)| tail.trim())
|
||||||
|
.unwrap_or(trimmed);
|
||||||
|
if content.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 行尾 "(completed)" → 已完成;其余默认 active(大小写不敏感,宽松容错)
|
||||||
|
let (text, active) = match content.strip_suffix(')').and_then(|c| c.rsplit_once('(')) {
|
||||||
|
Some((t, tag)) if tag.trim().eq_ignore_ascii_case("completed") => (t.trim(), false),
|
||||||
|
_ => (content, true),
|
||||||
|
};
|
||||||
|
if !text.is_empty() {
|
||||||
|
goals.push((text.to_string(), active));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if goals.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
goals.truncate(super::MAX_GOALS);
|
||||||
|
Some((goals, cleaned))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
// ── extract_update_goals_block 测试(UPDATE_GOALS 目标修正闭环) ──
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_goals_full_block_parsed_and_stripped() {
|
||||||
|
let text = "收到,我来更新目标列表。\n[UPDATE_GOALS]\n1: 完成登录接口\n2: 写单测 (completed)\n[END]\n继续推进。";
|
||||||
|
let (goals, cleaned) = extract_update_goals_block(text).unwrap();
|
||||||
|
assert_eq!(goals.len(), 2);
|
||||||
|
assert_eq!(goals[0], ("完成登录接口".to_string(), true));
|
||||||
|
assert_eq!(goals[1], ("写单测".to_string(), false));
|
||||||
|
// 标记与块内容剥离,前后正文保留
|
||||||
|
assert!(cleaned.contains("收到,我来更新目标列表。"));
|
||||||
|
assert!(cleaned.contains("继续推进。"));
|
||||||
|
assert!(!cleaned.contains("UPDATE_GOALS"));
|
||||||
|
assert!(!cleaned.contains("完成登录接口"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_goals_none_when_no_marker() {
|
||||||
|
assert!(extract_update_goals_block("普通回复,无标记").is_none());
|
||||||
|
assert!(extract_update_goals_block("").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_goals_unclosed_block_rejected() {
|
||||||
|
// 有开无闭(流式截断)→ 不解析不剥离,防半截输出误替换
|
||||||
|
let text = "回复 [UPDATE_GOALS]\n1: 目标A";
|
||||||
|
assert!(extract_update_goals_block(text).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_goals_empty_block_rejected() {
|
||||||
|
// 空块/纯空行 → None(不替空用户目标)
|
||||||
|
let text = "a [UPDATE_GOALS] [END] b";
|
||||||
|
assert!(extract_update_goals_block(text).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_goals_status_defaults_active_and_case_insensitive() {
|
||||||
|
let text = "[UPDATE_GOALS]\n做A\n做B (COMPLETED)\n做C (Completed)\n[END]";
|
||||||
|
let (goals, _) = extract_update_goals_block(text).unwrap();
|
||||||
|
assert_eq!(goals[0], ("做A".to_string(), true));
|
||||||
|
assert_eq!(goals[1], ("做B".to_string(), false));
|
||||||
|
assert_eq!(goals[2], ("做C".to_string(), false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_goals_number_prefix_optional_and_text_colon_safe() {
|
||||||
|
// 无编号行也认;普通文本含冒号不误拆(编号须全数字)
|
||||||
|
let text = "[UPDATE_GOALS]\n目标一: 直接写\n1: 编号目标\n[END]";
|
||||||
|
let (goals, _) = extract_update_goals_block(text).unwrap();
|
||||||
|
assert_eq!(goals[0], ("目标一: 直接写".to_string(), true));
|
||||||
|
assert_eq!(goals[1], ("编号目标".to_string(), true));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_goals_truncated_to_max() {
|
||||||
|
let text = "[UPDATE_GOALS]\n1: a\n2: b\n3: c\n4: d\n5: e\n6: f\n7: g\n[END]";
|
||||||
|
let (goals, _) = extract_update_goals_block(text).unwrap();
|
||||||
|
assert_eq!(goals.len(), super::super::MAX_GOALS);
|
||||||
|
}
|
||||||
|
|
||||||
// ── tool_call_signature 归一化测试 ──
|
// ── tool_call_signature 归一化测试 ──
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -391,6 +391,7 @@ pub(crate) use stall_detector::{
|
|||||||
};
|
};
|
||||||
pub(crate) use helpers::try_continue_agent_loop;
|
pub(crate) use helpers::try_continue_agent_loop;
|
||||||
pub(crate) use helpers::infer_goal_from_tool_calls;
|
pub(crate) use helpers::infer_goal_from_tool_calls;
|
||||||
|
pub(crate) use helpers::extract_update_goals_block;
|
||||||
pub(crate) use helpers::{
|
pub(crate) use helpers::{
|
||||||
tool_call_signature, is_repetitive_exploration, is_write_evidence_tool, is_pure_greeting, is_tool_call_over_limit,
|
tool_call_signature, is_repetitive_exploration, is_write_evidence_tool, is_pure_greeting, is_tool_call_over_limit,
|
||||||
DestructiveLoopSample, destructive_loop_sample, detect_destructive_loop,
|
DestructiveLoopSample, destructive_loop_sample, detect_destructive_loop,
|
||||||
@@ -1667,20 +1668,24 @@ async fn prepare_round_inputs(
|
|||||||
// 压缩决策经 per_conv.tool_view_decisions 冻结:首次判定后沿用,不再随阈值翻转
|
// 压缩决策经 per_conv.tool_view_decisions 冻结:首次判定后沿用,不再随阈值翻转
|
||||||
// (防同一条历史跨轮在全文/摘要间翻转致前缀断)。写回仅在 conv 存活时做(防惰性建)。
|
// (防同一条历史跨轮在全文/摘要间翻转致前缀断)。写回仅在 conv 存活时做(防惰性建)。
|
||||||
let messages: Vec<ChatMessage> = if TOOL_RESULT_COMPRESS_ENABLED {
|
let messages: Vec<ChatMessage> = if TOOL_RESULT_COMPRESS_ENABLED {
|
||||||
let (history_tokens_snapshot, mut view_decisions) = {
|
let (history_tokens_snapshot, mut view_decisions, mut compress_cache) = {
|
||||||
let session = session_arc.lock().await;
|
let session = session_arc.lock().await;
|
||||||
let conv = session.conv_read(conv_id);
|
let conv = session.conv_read(conv_id);
|
||||||
(
|
(
|
||||||
conv.map(|c| c.messages.history_tokens()).unwrap_or(0),
|
conv.map(|c| c.messages.history_tokens()).unwrap_or(0),
|
||||||
conv.map(|c| c.tool_view_decisions.clone()).unwrap_or_default(),
|
conv.map(|c| c.tool_view_decisions.clone()).unwrap_or_default(),
|
||||||
|
conv.map(|c| c.tool_compress_cache.clone()).unwrap_or_default(),
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
let messages =
|
let messages = summarize_tool_results(
|
||||||
summarize_tool_results(messages, history_tokens_snapshot, &mut view_decisions, conv_id, iteration);
|
messages, history_tokens_snapshot, &mut view_decisions, &mut compress_cache, conv_id, iteration,
|
||||||
if !view_decisions.is_empty() {
|
);
|
||||||
|
if !view_decisions.is_empty() || !compress_cache.is_empty() {
|
||||||
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).tool_view_decisions = view_decisions;
|
let conv = session.conv(conv_id);
|
||||||
|
conv.tool_view_decisions = view_decisions;
|
||||||
|
conv.tool_compress_cache = compress_cache;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
messages
|
messages
|
||||||
@@ -1777,16 +1782,39 @@ async fn finalize_round_output(
|
|||||||
);
|
);
|
||||||
return RoundEpilogue::Exit;
|
return RoundEpilogue::Exit;
|
||||||
}
|
}
|
||||||
|
// UPDATE_GOALS 剥离(先于 push):LLM 输出含 [UPDATE_GOALS] 块时,用户看到的
|
||||||
|
// assistant 消息用剥离后的干净文本(标记是实现细节,不入历史/不推前端)。
|
||||||
|
let (goals_override, text_to_push): (Option<Vec<(String, bool)>>, String) =
|
||||||
|
match extract_update_goals_block(full_text) {
|
||||||
|
Some((goals, cleaned)) => (Some(goals), cleaned),
|
||||||
|
None => (None, full_text.to_string()),
|
||||||
|
};
|
||||||
push_assistant_message(
|
push_assistant_message(
|
||||||
&mut session, conv_id, has_tool_calls, tool_calls_acc,
|
&mut session, conv_id, has_tool_calls, tool_calls_acc,
|
||||||
full_text, resolved_model, last_reasoning_content,
|
&text_to_push, resolved_model, last_reasoning_content,
|
||||||
round_usage.prompt_tokens, round_usage.completion_tokens,
|
round_usage.prompt_tokens, round_usage.completion_tokens,
|
||||||
round_usage.prompt_cache_hit_tokens, round_usage.prompt_cache_miss_tokens,
|
round_usage.prompt_cache_hit_tokens, round_usage.prompt_cache_miss_tokens,
|
||||||
round_usage.reasoning_tokens,
|
round_usage.reasoning_tokens,
|
||||||
round_is_estimated,
|
round_is_estimated,
|
||||||
);
|
);
|
||||||
if GOAL_PIN_ENABLED {
|
if GOAL_PIN_ENABLED {
|
||||||
update_pinned_goals(&mut session, conv_id, tool_calls_acc);
|
// UPDATE_GOALS 优先:LLM 显式修正标记存在 → **替换** pinned_goals(纠偏语义),
|
||||||
|
// 跳过工具推理追加(追加会与本轮显式修正打架)。
|
||||||
|
if let Some(goals) = goals_override {
|
||||||
|
let goal_conv = session.conv(conv_id);
|
||||||
|
goal_conv.pinned_goals = goals
|
||||||
|
.into_iter()
|
||||||
|
.map(|(text, active)| {
|
||||||
|
let mut e = super::GoalEntry::new(text);
|
||||||
|
if !active {
|
||||||
|
e.status = super::GoalStatus::Completed;
|
||||||
|
}
|
||||||
|
e
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
} else {
|
||||||
|
update_pinned_goals(&mut session, conv_id, tool_calls_acc);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2044,8 +2072,9 @@ async fn heartbeat_loop(
|
|||||||
_ = heartbeat_rx.changed() => break,
|
_ = heartbeat_rx.changed() => break,
|
||||||
_ = tokio::time::sleep(std::time::Duration::from_secs(20)) => {
|
_ = tokio::time::sleep(std::time::Duration::from_secs(20)) => {
|
||||||
last_heartbeat.store(df_types::now_millis() as u64, std::sync::atomic::Ordering::SeqCst);
|
last_heartbeat.store(df_types::now_millis() as u64, std::sync::atomic::Ordering::SeqCst);
|
||||||
let _ = app.emit(
|
// DRY #1:双写正典(miniapp 端靠心跳判活,只 emit 端看不到)
|
||||||
"ai-chat-event",
|
emit_ai_chat_event(
|
||||||
|
app,
|
||||||
AiChatEvent::AiHeartbeat { conversation_id: Some(conv_id.to_string()) },
|
AiChatEvent::AiHeartbeat { conversation_id: Some(conv_id.to_string()) },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2120,7 +2149,8 @@ fn emit_circuit_breaker_tripped(app_handle: &AppHandle, conv_id: &str, max_count
|
|||||||
conversation_id: Some(conv_id.to_string()),
|
conversation_id: Some(conv_id.to_string()),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let _ = app_handle.emit("ai-chat-event", ev);
|
// DRY #1(2026-10-04):裸 emit 换正典双写——原只 emit 未 publish,miniapp 端收不到熔断事件。
|
||||||
|
emit_ai_chat_event(app_handle, ev);
|
||||||
// P1 bg 化:熔断需人工介入,系统通知补位(条件发送,失败静默)
|
// P1 bg 化:熔断需人工介入,系统通知补位(条件发送,失败静默)
|
||||||
crate::commands::ai::notify::notify_session_event(
|
crate::commands::ai::notify::notify_session_event(
|
||||||
app_handle,
|
app_handle,
|
||||||
@@ -2253,8 +2283,9 @@ async fn check_stall_breaker(
|
|||||||
"[ai] G2 破坏性回环熔断:连续 {} 轮检测到同路径写删回环,疑似自毁循环停止", new_loop_count
|
"[ai] G2 破坏性回环熔断:连续 {} 轮检测到同路径写删回环,疑似自毁循环停止", new_loop_count
|
||||||
);
|
);
|
||||||
let path_desc = loop_hit_path.as_deref().unwrap_or("未知路径");
|
let path_desc = loop_hit_path.as_deref().unwrap_or("未知路径");
|
||||||
let _ = app_handle.emit(
|
// DRY #1:双写正典(熔断求助卡跨端可见)
|
||||||
"ai-chat-event",
|
emit_ai_chat_event(
|
||||||
|
app_handle,
|
||||||
AiChatEvent::AiHelpRequired {
|
AiChatEvent::AiHelpRequired {
|
||||||
reason: format!("连续 {} 轮检测到对同一文件({})反复写删,疑似「写完就删」自毁循环已停止", new_loop_count, path_desc),
|
reason: format!("连续 {} 轮检测到对同一文件({})反复写删,疑似「写完就删」自毁循环已停止", new_loop_count, path_desc),
|
||||||
context: "写文件后又删除同一文件的紧凑交替,可能在反复销毁自己的工作成果而无进展。".into(),
|
context: "写文件后又删除同一文件的紧凑交替,可能在反复销毁自己的工作成果而无进展。".into(),
|
||||||
@@ -2283,8 +2314,9 @@ async fn check_stall_breaker(
|
|||||||
sample_size = signatures.len(),
|
sample_size = signatures.len(),
|
||||||
"[ai] G2 探索熔断:连续 {} 次检测到重复工具调用,疑似卡住停止", new_count
|
"[ai] G2 探索熔断:连续 {} 次检测到重复工具调用,疑似卡住停止", new_count
|
||||||
);
|
);
|
||||||
let _ = app_handle.emit(
|
// DRY #1:双写正典(熔断求助卡跨端可见)
|
||||||
"ai-chat-event",
|
emit_ai_chat_event(
|
||||||
|
app_handle,
|
||||||
AiChatEvent::AiHelpRequired {
|
AiChatEvent::AiHelpRequired {
|
||||||
reason: format!("连续 {} 次检测到重复的工具调用(同样的 grep/读文件反复),疑似卡住已停止", new_count),
|
reason: format!("连续 {} 次检测到重复的工具调用(同样的 grep/读文件反复),疑似卡住已停止", new_count),
|
||||||
context: "工具调用签名高度重复,可能在反复做同一件事而无进展。".into(),
|
context: "工具调用签名高度重复,可能在反复做同一件事而无进展。".into(),
|
||||||
@@ -2548,6 +2580,8 @@ pub(crate) async fn emit_ai_completed_once(
|
|||||||
emit_ai_chat_event(app_handle, ev);
|
emit_ai_chat_event(app_handle, ev);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// 有意不走 emit_ai_chat_event(emit/publish 两端 incomplete 标志分叉,见上注释)——
|
||||||
|
// 此分支语义就是单路 emit 不 publish,裸写是正确形态,勿"收口"。
|
||||||
let _ = app_handle.emit("ai-chat-event", ev);
|
let _ = app_handle.emit("ai-chat-event", ev);
|
||||||
if do_publish {
|
if do_publish {
|
||||||
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(
|
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(
|
||||||
|
|||||||
@@ -279,6 +279,7 @@ pub(crate) fn summarize_tool_results(
|
|||||||
messages: Vec<ChatMessage>,
|
messages: Vec<ChatMessage>,
|
||||||
history_tokens_snapshot: u32,
|
history_tokens_snapshot: u32,
|
||||||
view_decisions: &mut std::collections::HashMap<u64, bool>,
|
view_decisions: &mut std::collections::HashMap<u64, bool>,
|
||||||
|
compress_cache: &mut std::collections::HashMap<u64, String>,
|
||||||
conv_id: &str,
|
conv_id: &str,
|
||||||
iteration: usize,
|
iteration: usize,
|
||||||
) -> Vec<ChatMessage> {
|
) -> Vec<ChatMessage> {
|
||||||
@@ -333,7 +334,18 @@ pub(crate) fn summarize_tool_results(
|
|||||||
return m;
|
return m;
|
||||||
}
|
}
|
||||||
original_bytes += content_len;
|
original_bytes += content_len;
|
||||||
let compressed = extract_key_info(&m.content, resolved_name);
|
// memoization(狗粮实证 369 组同内容反复重压):压缩是确定性纯函数,
|
||||||
|
// 同内容哈希命中直接复用缓存压缩结果,跳过重扫描。键与 view_decisions
|
||||||
|
// 同款 tool_view_decision_key(content),tool_name 差异(豁免 read_file)
|
||||||
|
// 已在上方分支处理,进到此处的都可压缩,故键不含 tool_name。
|
||||||
|
let compressed = match compress_cache.get(&key) {
|
||||||
|
Some(cached) => cached.clone(),
|
||||||
|
None => {
|
||||||
|
let c = extract_key_info(&m.content, resolved_name);
|
||||||
|
compress_cache.insert(key, c.clone());
|
||||||
|
c
|
||||||
|
}
|
||||||
|
};
|
||||||
compressed_bytes += compressed.len();
|
compressed_bytes += compressed.len();
|
||||||
summarized_count += 1;
|
summarized_count += 1;
|
||||||
m.content = compressed;
|
m.content = compressed;
|
||||||
|
|||||||
@@ -746,11 +746,17 @@ async fn run_sub_agent_loop_inner(
|
|||||||
msgs.extend(history_msgs);
|
msgs.extend(history_msgs);
|
||||||
let history_tokens = conv.messages.history_tokens();
|
let history_tokens = conv.messages.history_tokens();
|
||||||
// tool_result 大输出 view-only 摘要 + 占位配对断言(与主 loop 同款防线)。
|
// tool_result 大输出 view-only 摘要 + 占位配对断言(与主 loop 同款防线)。
|
||||||
// 压缩决策同主 loop 冻结在 per_conv(子会话条目),跨轮不随阈值翻转。
|
// 压缩决策同主 loop 冻结在 per_conv(子会话条目),跨轮不随阈值翻转;
|
||||||
|
// 压缩结果 memoization 同主 loop(同内容哈希跳过重压)。
|
||||||
let mut view_decisions = conv.tool_view_decisions.clone();
|
let mut view_decisions = conv.tool_view_decisions.clone();
|
||||||
let msgs = summarize_tool_results(msgs, history_tokens, &mut view_decisions, &child_conv_id, round as usize);
|
let mut compress_cache = conv.tool_compress_cache.clone();
|
||||||
if !view_decisions.is_empty() {
|
let msgs = summarize_tool_results(
|
||||||
session.conv(&child_conv_id).tool_view_decisions = view_decisions;
|
msgs, history_tokens, &mut view_decisions, &mut compress_cache, &child_conv_id, round as usize,
|
||||||
|
);
|
||||||
|
if !view_decisions.is_empty() || !compress_cache.is_empty() {
|
||||||
|
let conv = session.conv(&child_conv_id);
|
||||||
|
conv.tool_view_decisions = view_decisions;
|
||||||
|
conv.tool_compress_cache = compress_cache;
|
||||||
}
|
}
|
||||||
let msgs = ContextManager::assert_placeholder_pairing(msgs, PLACEHOLDER_INTEGRITY_ENABLED);
|
let msgs = ContextManager::assert_placeholder_pairing(msgs, PLACEHOLDER_INTEGRITY_ENABLED);
|
||||||
let est = TokenEstimator::default();
|
let est = TokenEstimator::default();
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ mod idea_source_test_helpers {
|
|||||||
ai_analysis: None,
|
ai_analysis: None,
|
||||||
scores: None,
|
scores: None,
|
||||||
related_ids: None,
|
related_ids: None,
|
||||||
|
archived: false,
|
||||||
created_at: now.clone(),
|
created_at: now.clone(),
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ use super::tools::entity_resolve;
|
|||||||
|
|
||||||
// utils(audit/utils.rs):RiskLevel ↔ 字符串转换 + 字符串安全截断纯 helper。
|
// utils(audit/utils.rs):RiskLevel ↔ 字符串转换 + 字符串安全截断纯 helper。
|
||||||
// 第五批从本文件抽离,行为零变更。pub(crate) use 保持 finalize / restore 子模块
|
// 第五批从本文件抽离,行为零变更。pub(crate) use 保持 finalize / restore 子模块
|
||||||
// super::risk_str / super::risk_from_str 引用路径不变;truncate_chars 供本文件 process_tool_calls
|
// super::risk_str / super::risk_from_str 引用路径不变;truncate_chars_ellipsis 供本文件 process_tool_calls
|
||||||
// DTO 装配 + record.rs 审计面板 IPC 共用。
|
// DTO 装配 + record.rs 审计面板 IPC 共用。
|
||||||
mod utils;
|
mod utils;
|
||||||
pub(crate) use utils::{risk_from_str, risk_str, truncate_chars};
|
pub(crate) use utils::{risk_from_str, risk_str, truncate_chars_ellipsis};
|
||||||
|
|
||||||
// diff(audit/diff.rs):write_file 审批预览 diff 生成。
|
// diff(audit/diff.rs):write_file 审批预览 diff 生成。
|
||||||
// 第五批从本文件抽离,行为零变更。
|
// 第五批从本文件抽离,行为零变更。
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
//! - `ToolExecutionDto`:传给前端的精简审计视图
|
//! - `ToolExecutionDto`:传给前端的精简审计视图
|
||||||
//! - `list_tool_executions`:审批历史面板查询 IPC
|
//! - `list_tool_executions`:审批历史面板查询 IPC
|
||||||
//!
|
//!
|
||||||
//! 依赖 audit/utils.rs 的 `truncate_chars` 做参数/结果截断,通过 `super::truncate_chars` 引用。
|
//! 依赖 audit/utils.rs 的 `truncate_chars_ellipsis` 做参数/结果截断,通过 `super::truncate_chars_ellipsis` 引用。
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
@@ -233,8 +233,8 @@ pub async fn list_tool_executions(
|
|||||||
// ToolCallId → String(DTO 边界,前端按字符串消费)
|
// ToolCallId → String(DTO 边界,前端按字符串消费)
|
||||||
tool_call_id: r.tool_call_id.into(),
|
tool_call_id: r.tool_call_id.into(),
|
||||||
tool_name: r.tool_name,
|
tool_name: r.tool_name,
|
||||||
arguments_brief: super::truncate_chars(&r.arguments, 120),
|
arguments_brief: super::truncate_chars_ellipsis(&r.arguments, 120),
|
||||||
result_brief: r.result.map(|s| super::truncate_chars(&s, 160)),
|
result_brief: r.result.map(|s| super::truncate_chars_ellipsis(&s, 160)),
|
||||||
status: r.status,
|
status: r.status,
|
||||||
risk_level: r.risk_level,
|
risk_level: r.risk_level,
|
||||||
requested_at: r.requested_at,
|
requested_at: r.requested_at,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
//! - `risk_str` / `risk_from_str`:finalize / restore 通过 `super::` 引用(pub(crate))。
|
//! - `risk_str` / `risk_from_str`:finalize / restore 通过 `super::` 引用(pub(crate))。
|
||||||
//! - `truncate_chars`:mod.rs 的 process_tool_calls DTO 装配用 + record.rs 审计面板 IPC 用(pub(crate))。
|
//! - `truncate_chars`:mod.rs 的 process_tool_calls DTO 装配用 + record.rs 审计面板 IPC 用(pub(crate))。
|
||||||
//!
|
//!
|
||||||
//! re-export 维持原路径透明:`super::risk_str` / `super::risk_from_str` / `super::truncate_chars`
|
//! re-export 维持原路径透明:`super::risk_str` / `super::risk_from_str` / `super::truncate_chars_ellipsis`
|
||||||
//! 在 audit/mod.rs 内通过 `pub(crate) use` 引入后裸名可用,外部子模块的 `super::risk_*` 引用不变。
|
//! 在 audit/mod.rs 内通过 `pub(crate) use` 引入后裸名可用,外部子模块的 `super::risk_*` 引用不变。
|
||||||
|
|
||||||
use df_ai::ai_tools::RiskLevel;
|
use df_ai::ai_tools::RiskLevel;
|
||||||
@@ -29,7 +29,7 @@ pub(crate) fn risk_from_str(s: &str) -> Option<RiskLevel> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 截断字符串到 max 字符(按 char_indices 边界切,避免切坏中文/emoji)
|
/// 截断字符串到 max 字符(按 char_indices 边界切,避免切坏中文/emoji)
|
||||||
pub(crate) fn truncate_chars(s: &str, max: usize) -> String {
|
pub(crate) fn truncate_chars_ellipsis(s: &str, max: usize) -> String {
|
||||||
if s.chars().count() <= max {
|
if s.chars().count() <= max {
|
||||||
return s.to_string();
|
return s.to_string();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ use df_storage::db::Database;
|
|||||||
use df_storage::models::AiProviderRecord;
|
use df_storage::models::AiProviderRecord;
|
||||||
use df_storage::secret::resolve_provider_secret;
|
use df_storage::secret::resolve_provider_secret;
|
||||||
|
|
||||||
use crate::commands::ai::audit::truncate_chars;
|
use crate::commands::ai::audit::truncate_chars_ellipsis;
|
||||||
use crate::commands::ai::http::{
|
use crate::commands::ai::http::{
|
||||||
build_client, execute_with_redirects, resolve_and_check_host, validate_url, MAX_REDIRECTS,
|
build_client, execute_with_redirects, resolve_and_check_host, validate_url, MAX_REDIRECTS,
|
||||||
};
|
};
|
||||||
@@ -184,7 +184,7 @@ pub(crate) async fn execute_generate_image(
|
|||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
// 错误响应读 body 摘要(截断 500 chars,截断时尾部补 …)帮助定位
|
// 错误响应读 body 摘要(截断 500 chars,截断时尾部补 …)帮助定位
|
||||||
let err_text = resp.text().await.unwrap_or_default();
|
let err_text = resp.text().await.unwrap_or_default();
|
||||||
let snippet: String = truncate_chars(&err_text, 500);
|
let snippet: String = truncate_chars_ellipsis(&err_text, 500);
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"图像生成端点返回 HTTP {}: {}",
|
"图像生成端点返回 HTTP {}: {}",
|
||||||
status.as_u16(),
|
status.as_u16(),
|
||||||
@@ -459,7 +459,7 @@ async fn write_atomic(output_path: &str, data: &[u8]) -> anyhow::Result<()> {
|
|||||||
/// 走 audit truncate_chars 正典(截断时尾部补 …);错误文案无硬长度约束,允许多 1 字符。
|
/// 走 audit truncate_chars 正典(截断时尾部补 …);错误文案无硬长度约束,允许多 1 字符。
|
||||||
fn body_snippet(v: &Value) -> String {
|
fn body_snippet(v: &Value) -> String {
|
||||||
let s = v.to_string();
|
let s = v.to_string();
|
||||||
truncate_chars(&s, 500)
|
truncate_chars_ellipsis(&s, 500)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ use df_types::types::new_id;
|
|||||||
|
|
||||||
use crate::state::{AppState, ExtractTrigger, LlmConcurrency, log_slow_lock};
|
use crate::state::{AppState, ExtractTrigger, LlmConcurrency, log_slow_lock};
|
||||||
|
|
||||||
use crate::commands::ai::audit::truncate_chars;
|
use crate::commands::ai::audit::truncate_chars_ellipsis;
|
||||||
use crate::commands::err_str;
|
use crate::commands::err_str;
|
||||||
|
|
||||||
use super::{AiSession};
|
use super::{AiSession};
|
||||||
@@ -562,7 +562,7 @@ pub(crate) async fn build_knowledge_context(
|
|||||||
let kind = r.kind.clone();
|
let kind = r.kind.clone();
|
||||||
let title = r.title.clone();
|
let title = r.title.clone();
|
||||||
// 截断 content 防膨胀(注入侧最多 500 字符;走 audit 正典,截断时尾部补 … 提示有省略)
|
// 截断 content 防膨胀(注入侧最多 500 字符;走 audit 正典,截断时尾部补 … 提示有省略)
|
||||||
let snippet: String = truncate_chars(&r.content, 500);
|
let snippet: String = truncate_chars_ellipsis(&r.content, 500);
|
||||||
out.push_str(&format!("- [{}] {}: {} (复用 {} 次)\n", kind, title, snippet, r.reuse_count));
|
out.push_str(&format!("- [{}] {}: {} (复用 {} 次)\n", kind, title, snippet, r.reuse_count));
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
|
|||||||
@@ -1073,6 +1073,13 @@ pub struct PerConvState {
|
|||||||
/// 仍为 view-only(不落库改历史,仅影响发送视图);键用内容哈希而非位置(裁剪/压缩后
|
/// 仍为 view-only(不落库改历史,仅影响发送视图);键用内容哈希而非位置(裁剪/压缩后
|
||||||
/// 位置漂移,内容不变)。随会话销毁不落库。
|
/// 位置漂移,内容不变)。随会话销毁不落库。
|
||||||
pub tool_view_decisions: std::collections::HashMap<u64, bool>,
|
pub tool_view_decisions: std::collections::HashMap<u64, bool>,
|
||||||
|
/// tool_result 压缩结果 memoization(content 稳定哈希 → 压缩后文本)。
|
||||||
|
///
|
||||||
|
/// 狗粮实证(2026-10-04):view-only 压缩 2280 次中 369 组同内容反复重压(最坏 54 次),
|
||||||
|
/// 纯 CPU 纯函数但大内容重复扫描浪费。压缩是确定性的(content,tool_name)→ 输出,
|
||||||
|
/// 同内容哈希命中直接复用缓存结果,跳过重算。与 tool_view_decisions 同为内存态
|
||||||
|
/// 不落库,随会话销毁;false 决策(未压缩)不进缓存(原样返回无结果可记)。
|
||||||
|
pub tool_compress_cache: std::collections::HashMap<u64, String>,
|
||||||
/// 无人值守任务级授权边界(设计 §二):发起时一次声明,存续期内边界内零审批。
|
/// 无人值守任务级授权边界(设计 §二):发起时一次声明,存续期内边界内零审批。
|
||||||
/// None=未声明(现状逐次审批零倒退)。内存真相源在 per_conv,落库随行
|
/// None=未声明(现状逐次审批零倒退)。内存真相源在 per_conv,落库随行
|
||||||
/// (ai_conversations.boundary_json,save 写/switch 读还原),随会话同亡。
|
/// (ai_conversations.boundary_json,save 写/switch 读还原),随会话同亡。
|
||||||
@@ -1229,6 +1236,7 @@ impl PerConvState {
|
|||||||
workflow_dag_summary: None,
|
workflow_dag_summary: None,
|
||||||
frozen_tool_names: None,
|
frozen_tool_names: None,
|
||||||
tool_view_decisions: std::collections::HashMap::new(),
|
tool_view_decisions: std::collections::HashMap::new(),
|
||||||
|
tool_compress_cache: std::collections::HashMap::new(),
|
||||||
boundary: None,
|
boundary: None,
|
||||||
readonly_cache_floor: 0,
|
readonly_cache_floor: 0,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ fn system_prompt_parts(lang: &str) -> (String, &'static str, &'static str) {
|
|||||||
- When a tool call fails, clearly tell the user it failed and why. Never disguise a fallback action as the original intent's success (e.g. don't write to description to fake a directory binding), and never falsely report success\n\
|
- When a tool call fails, clearly tell the user it failed and why. Never disguise a fallback action as the original intent's success (e.g. don't write to description to fake a directory binding), and never falsely report success\n\
|
||||||
- **Self-check before claiming completion**: Before announcing a task done, verify the result matches the original intent (grep key references / read back field values / cross-check). Fix discrepancies before declaring success; never claim all done without verification (repeatedly observed: missed reference renames, fields silently overwritten by later ops, forcing the user to manually verify)\n\
|
- **Self-check before claiming completion**: Before announcing a task done, verify the result matches the original intent (grep key references / read back field values / cross-check). Fix discrepancies before declaring success; never claim all done without verification (repeatedly observed: missed reference renames, fields silently overwritten by later ops, forcing the user to manually verify)\n\
|
||||||
- {}\n\
|
- {}\n\
|
||||||
|
- {}\n\
|
||||||
## Focus\n\
|
## Focus\n\
|
||||||
- Always center your response on the core goal of the user's current request; the previous round's topic is only background, not the current task.\n\
|
- Always center your response on the core goal of the user's current request; the previous round's topic is only background, not the current task.\n\
|
||||||
- When the user switches topics (a clear new intent), follow the latest request; do not drag the old topic into the new answer.\n\
|
- When the user switches topics (a clear new intent), follow the latest request; do not drag the old topic into the new answer.\n\
|
||||||
@@ -113,6 +114,7 @@ fn system_prompt_parts(lang: &str) -> (String, &'static str, &'static str) {
|
|||||||
- Do not proactively expand context (files/data) that is irrelevant to the current request.\n",
|
- Do not proactively expand context (files/data) that is irrelevant to the current request.\n",
|
||||||
read_symbol_guidance(lang),
|
read_symbol_guidance(lang),
|
||||||
duplicate_call_guidance(lang),
|
duplicate_call_guidance(lang),
|
||||||
|
file_reading_guidance(lang),
|
||||||
),
|
),
|
||||||
"\n## Current Projects\n",
|
"\n## Current Projects\n",
|
||||||
"\n## Current Tasks\n",
|
"\n## Current Tasks\n",
|
||||||
@@ -135,6 +137,7 @@ fn system_prompt_parts(lang: &str) -> (String, &'static str, &'static str) {
|
|||||||
- 优先使用工具完成操作,而不是只描述步骤\n\
|
- 优先使用工具完成操作,而不是只描述步骤\n\
|
||||||
- 工具调用失败时必须明确告知用户失败原因,严禁用替代操作冒充原意图成功(如绑定目录失败不得改写描述冒充已绑定),也绝不谎报成功\n\
|
- 工具调用失败时必须明确告知用户失败原因,严禁用替代操作冒充原意图成功(如绑定目录失败不得改写描述冒充已绑定),也绝不谎报成功\n\
|
||||||
- {}\n\
|
- {}\n\
|
||||||
|
- {}\n\
|
||||||
## 聚焦准则\n\
|
## 聚焦准则\n\
|
||||||
- 始终围绕用户当前请求的核心目标回答;上一轮的主题只是背景,不是当前任务。\n\
|
- 始终围绕用户当前请求的核心目标回答;上一轮的主题只是背景,不是当前任务。\n\
|
||||||
- 用户切换话题(明显的新意图)时,以最新请求为准,不要把旧话题带进新回答。\n\
|
- 用户切换话题(明显的新意图)时,以最新请求为准,不要把旧话题带进新回答。\n\
|
||||||
@@ -142,6 +145,7 @@ fn system_prompt_parts(lang: &str) -> (String, &'static str, &'static str) {
|
|||||||
- 需要的上下文(文件/数据)若与当前请求无关,不要主动展开。\n",
|
- 需要的上下文(文件/数据)若与当前请求无关,不要主动展开。\n",
|
||||||
read_symbol_guidance(lang),
|
read_symbol_guidance(lang),
|
||||||
duplicate_call_guidance(lang),
|
duplicate_call_guidance(lang),
|
||||||
|
file_reading_guidance(lang),
|
||||||
),
|
),
|
||||||
"\n## 当前项目\n",
|
"\n## 当前项目\n",
|
||||||
"\n## 当前任务\n",
|
"\n## 当前任务\n",
|
||||||
@@ -193,6 +197,16 @@ fn duplicate_call_guidance(lang: &str) -> &'static str {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 文件读取纪律指引(中/英,不含行首 "- ")。狗粮实证(2026-10-04):67+ 次 run_command
|
||||||
|
/// PowerShell/python 切片绕道读文件,而 read_file 本身支持 offset/limit 分页+search 参数。
|
||||||
|
/// 父会话能力清单与子代理工具纪律段同源拼接(同 read_symbol_guidance 模式)。
|
||||||
|
fn file_reading_guidance(lang: &str) -> &'static str {
|
||||||
|
match lang {
|
||||||
|
"en" => "**File reading discipline**: read_file supports offset/limit pagination and the search parameter (search matching lines inside a file) — use them to read large files in pages or locate content directly. Never use run_command (PowerShell/python scripts) to slice or read files — it is slower, bypasses path authorization and binary detection, and pollutes context; run_command is for running tests/builds/binaries only",
|
||||||
|
_ => "**文件读取纪律**:read_file 自带 offset/limit 分页与 search 参数(文件内容行级搜索)——大文件用它分页续读、定位内容直接传 search。严禁用 run_command(PowerShell/python 脚本)切片或读取文件——更慢、绕过路径授权与二进制检测、且污染上下文;run_command 仅用于跑测试/构建/二进制等命令执行场景",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 子代理工具纪律段(P1-1):与父会话 system prompt 同源拼接的工具使用纪律 ——
|
/// 子代理工具纪律段(P1-1):与父会话 system prompt 同源拼接的工具使用纪律 ——
|
||||||
/// read_symbol 优先 / 重复调用检测 / URL 三工具纪律与禁臆造 URL / render=true 失败
|
/// read_symbol 优先 / 重复调用检测 / URL 三工具纪律与禁臆造 URL / render=true 失败
|
||||||
/// 2 次必换方案 / 查配置用 get_app_config。子代理白名单缺省下发全量 Low 工具,
|
/// 2 次必换方案 / 查配置用 get_app_config。子代理白名单缺省下发全量 Low 工具,
|
||||||
@@ -202,10 +216,12 @@ fn duplicate_call_guidance(lang: &str) -> &'static str {
|
|||||||
pub(crate) fn sub_agent_tool_discipline_section(lang: &str) -> String {
|
pub(crate) fn sub_agent_tool_discipline_section(lang: &str) -> String {
|
||||||
format!(
|
format!(
|
||||||
"\n## 工具纪律\n\
|
"\n## 工具纪律\n\
|
||||||
|
- {}\n\
|
||||||
- {}\n\
|
- {}\n\
|
||||||
- {}\n{}{}",
|
- {}\n{}{}",
|
||||||
read_symbol_guidance(lang),
|
read_symbol_guidance(lang),
|
||||||
duplicate_call_guidance(lang),
|
duplicate_call_guidance(lang),
|
||||||
|
file_reading_guidance(lang),
|
||||||
doc_strategy_core_section(lang),
|
doc_strategy_core_section(lang),
|
||||||
app_config_query_guidance_section(lang),
|
app_config_query_guidance_section(lang),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -46,16 +46,33 @@ pub(crate) async fn ensure_conversation_title(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 取对话文本(仅 user/assistant,跳过 tool 噪音),取前 6 条供 LLM 总结
|
// 取对话文本(仅 user/assistant,跳过 tool 噪音),取前 6 条供 LLM 总结。
|
||||||
|
// 狗粮实证(2026-10-04):混合 take(6) 在 assistant 消息靠前场景(重提/续接)会把 user 首条挤出,
|
||||||
|
// LLM 拿 assistant 首句当主题生成标题(如「信息已足够。让我看一眼...」)。改两段式:
|
||||||
|
// user 首条必进(锚定主题),其余按序补足 6 条。
|
||||||
// F-260616-09 B 批4:per_conv.messages 唯一真相源(conv_id 来源:本函数入参)。
|
// F-260616-09 B 批4:per_conv.messages 唯一真相源(conv_id 来源:本函数入参)。
|
||||||
// conv() 惰性建:switch 触发的 spawn_ensure_title 前序 switch 已 conv();若极端未建,
|
// conv() 惰性建:switch 触发的 spawn_ensure_title 前序 switch 已 conv();若极端未建,
|
||||||
// conv() 建空 PerConvState → summary_msgs 空 → 下方 return(无素材不生成标题)。
|
// conv() 建空 PerConvState → summary_msgs 空 → 下方 return(无素材不生成标题)。
|
||||||
let (summary_msgs, all_msgs) = {
|
let (summary_msgs, all_msgs) = {
|
||||||
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);
|
||||||
let summary: Vec<ChatMessage> = conv.messages.iter()
|
let sample: Vec<_> = conv.messages.iter()
|
||||||
.filter(|m| matches!(m.role, MessageRole::User | MessageRole::Assistant))
|
.filter(|m| matches!(m.role, MessageRole::User | MessageRole::Assistant))
|
||||||
.take(6)
|
.collect();
|
||||||
|
// user 首条下标(无 user 消息则退回纯顺序 take(6),空素材由下方 return 兜住)
|
||||||
|
let first_user_idx = sample.iter().position(|m| matches!(m.role, MessageRole::User));
|
||||||
|
let mut picked: Vec<usize> = Vec::with_capacity(6);
|
||||||
|
if let Some(i) = first_user_idx {
|
||||||
|
picked.push(i);
|
||||||
|
}
|
||||||
|
for (i, _) in sample.iter().enumerate() {
|
||||||
|
if picked.len() >= 6 { break; }
|
||||||
|
if picked.contains(&i) { continue; }
|
||||||
|
picked.push(i);
|
||||||
|
}
|
||||||
|
picked.sort_unstable();
|
||||||
|
let summary: Vec<ChatMessage> = picked.into_iter()
|
||||||
|
.filter_map(|i| sample.get(i))
|
||||||
.map(|m| ChatMessage {
|
.map(|m| ChatMessage {
|
||||||
id: None, // 标题摘要派生消息:不落库不溯源,无需分配 ID(对齐老消息 None 语义)
|
id: None, // 标题摘要派生消息:不落库不溯源,无需分配 ID(对齐老消息 None 语义)
|
||||||
role: m.role.clone(),
|
role: m.role.clone(),
|
||||||
|
|||||||
@@ -187,10 +187,12 @@ pub(crate) fn truncate_output(s: &str, max: usize) -> (String, bool) {
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// patch_file 三模式共用底层
|
// patch_file 三模式共用底层
|
||||||
//
|
//
|
||||||
// 三模式互斥(old_text 精确匹配 / replace_lines 行号区间 / anchor 锚点),
|
// 三模式定位(old_text 精确匹配 / replace_lines 行号区间 / anchor 锚点),
|
||||||
// 后两者均归约为「按行号区间 [start, end](1-based 含首尾)替换为 new_text」,
|
// 后两者均归约为「按行号区间 [start, end](1-based 含首尾)替换为 new_text」,
|
||||||
// 由 apply_line_range 统一执行 splice。锚点经 resolve_anchor_to_lines 转行号区间。
|
// 由 apply_line_range 统一执行 splice。锚点经 resolve_anchor_to_lines 转行号区间
|
||||||
// 提取为纯函数便于直接单测(不依赖 async/fs/锁),handler 闭包内复用。
|
// (end 子串须唯一命中,多处命中报错)。多模式同时传入由 handler 层按优先级仲裁
|
||||||
|
// (见 tools/file.rs arbitrate_patch_mode)。提取为纯函数便于直接单测(不依赖 async/fs/锁),
|
||||||
|
// handler 闭包内复用。
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
/// 按行号区间替换:将 content 第 [start, end] 行(1-based, 含首尾)替换为 new_text。
|
/// 按行号区间替换:将 content 第 [start, end] 行(1-based, 含首尾)替换为 new_text。
|
||||||
@@ -242,11 +244,14 @@ pub(crate) fn apply_line_range(content: &str, start: usize, end: usize, new_text
|
|||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 锚点解析为行号区间:在 content 中找含 `start` 子串的首行 → 其后第一个含 `end` 子串的行。
|
/// 锚点解析为行号区间:在 content 中找含 `start` 子串的首行 → 其后含 `end` 子串的行。
|
||||||
///
|
///
|
||||||
/// - 子串匹配(大小写敏感,与 search_files 一致)。
|
/// - 子串匹配(大小写敏感,与 search_files 一致)。
|
||||||
/// - start/end 任一找不到返 Err;start 行号 > end 行号返 Err(即 end 必须在 start 之后或同行)。
|
/// - start/end 任一找不到返 Err;start 行号 > end 行号返 Err(即 end 必须在 start 之后或同行)。
|
||||||
/// - 同一行同时命中 start/end:start==end,区间退化为单行替换。
|
/// - 同一行同时命中 start/end:start==end,区间退化为单行替换。
|
||||||
|
/// - end 唯一性校验(治实测 ×2 多处命中替换错位):end 子串在 start 行(含)之后多处命中
|
||||||
|
/// 时返 Err,列出前两处命中行上下文,提示缩小 end 锚点或改用 old_text 模式。
|
||||||
|
/// 旧实现静默取第一个命中行,LLM 意图往往是更靠后的行,错位后返工。
|
||||||
/// - 返回 (start_line, end_line)(1-based 含首尾)。
|
/// - 返回 (start_line, end_line)(1-based 含首尾)。
|
||||||
pub(crate) fn resolve_anchor_to_lines(content: &str, start: &str, end: &str) -> anyhow::Result<(usize, usize)> {
|
pub(crate) fn resolve_anchor_to_lines(content: &str, start: &str, end: &str) -> anyhow::Result<(usize, usize)> {
|
||||||
if start.is_empty() {
|
if start.is_empty() {
|
||||||
@@ -265,22 +270,44 @@ pub(crate) fn resolve_anchor_to_lines(content: &str, start: &str, end: &str) ->
|
|||||||
let start_line = start_line
|
let start_line = start_line
|
||||||
.ok_or_else(|| anyhow::anyhow!("anchor.start 子串「{}」未在文件中找到", start))?;
|
.ok_or_else(|| anyhow::anyhow!("anchor.start 子串「{}」未在文件中找到", start))?;
|
||||||
|
|
||||||
// end 在 start_line 之后(含 start_line 同行)第一个命中的行
|
// end 在 start_line 之后(含 start_line 同行)收集全部命中行,唯一才放行
|
||||||
let mut end_line: Option<usize> = None;
|
let all_lines: Vec<&str> = content.lines().collect();
|
||||||
for (i, line) in content.lines().enumerate() {
|
let end_hits: Vec<usize> = all_lines
|
||||||
let lineno = i + 1;
|
.iter()
|
||||||
if lineno < start_line {
|
.enumerate()
|
||||||
continue;
|
.filter(|(i, line)| i + 1 >= start_line && line.contains(end))
|
||||||
|
.map(|(i, _)| i + 1)
|
||||||
|
.collect();
|
||||||
|
let end_line = match end_hits.len() {
|
||||||
|
0 => anyhow::bail!(
|
||||||
|
"anchor.end 子串「{}」未在 start 行(第 {} 行)之后找到",
|
||||||
|
end, start_line
|
||||||
|
),
|
||||||
|
1 => end_hits[0],
|
||||||
|
_ => {
|
||||||
|
// 列出前两处命中行原文(截断防超长行刷屏),供 LLM 对照改锚
|
||||||
|
let ctx = end_hits
|
||||||
|
.iter()
|
||||||
|
.take(2)
|
||||||
|
.map(|&ln| {
|
||||||
|
let raw = all_lines[ln - 1];
|
||||||
|
let shown: String = if raw.chars().count() > 80 {
|
||||||
|
raw.chars().take(80).collect::<String>() + "…"
|
||||||
|
} else {
|
||||||
|
raw.to_string()
|
||||||
|
};
|
||||||
|
format!("第 {} 行: {}", ln, shown)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
anyhow::bail!(
|
||||||
|
"anchor.end 子串「{}」在 start 行(第 {} 行)之后命中 {} 处,定位歧义。前两处:\n{}\n请缩小 end 锚点(加长子串使其唯一)或改用 old_text 模式",
|
||||||
|
end, start_line, end_hits.len(), ctx
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if line.contains(end) {
|
};
|
||||||
end_line = Some(lineno);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let end_line = end_line
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("anchor.end 子串「{}」未在 start 行(第 {} 行)之后找到", end, start_line))?;
|
|
||||||
|
|
||||||
// start_line <= end_line 已由搜索顺序保证,此处断言兜底
|
// start_line <= end_line 已由命中过滤条件保证,此处断言兜底
|
||||||
debug_assert!(start_line <= end_line, "anchor 解析顺序异常: start={} end={}", start_line, end_line);
|
debug_assert!(start_line <= end_line, "anchor 解析顺序异常: start={} end={}", start_line, end_line);
|
||||||
Ok((start_line, end_line))
|
Ok((start_line, end_line))
|
||||||
}
|
}
|
||||||
@@ -1995,12 +2022,12 @@ mod tests {
|
|||||||
assert_eq!((s, e), (1, 4));
|
assert_eq!((s, e), (1, 4));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// resolve_anchor_to_lines: 子串匹配会在 start 之后命中第一个含该子串的行
|
/// resolve_anchor_to_lines: 子串匹配含行内出现(如 println!("{}") 内的 })——
|
||||||
/// (含行内出现,如 println!("{}") 内的 })——锁定此行为语义,避免后续误改成「整行相等」
|
/// end 唯一命中时放行,锁定「子串包含」语义(非「整行相等」)
|
||||||
#[test]
|
#[test]
|
||||||
fn test_resolve_anchor_substring_matches_inside_line() {
|
fn test_resolve_anchor_substring_matches_inside_line() {
|
||||||
// end 锚 "}" 在 start 行之后第一个命中 = 第 3 行 println!("{}") 的 },而非第 4 行的 }
|
// end 锚 "}" 唯一命中第 3 行 println!("{}") 的 }(第 4 行换成无 } 的行)
|
||||||
let content = "fn foo() {\n let x = 1;\n println!(\"{}\");\n}\n";
|
let content = "fn foo() {\n let x = 1;\n println!(\"{}\");\nEND\n";
|
||||||
let (s, e) = resolve_anchor_to_lines(content, "fn foo", "}").unwrap();
|
let (s, e) = resolve_anchor_to_lines(content, "fn foo", "}").unwrap();
|
||||||
assert_eq!((s, e), (1, 3));
|
assert_eq!((s, e), (1, 3));
|
||||||
}
|
}
|
||||||
@@ -2014,13 +2041,20 @@ mod tests {
|
|||||||
assert_eq!((s, e), (1, 1));
|
assert_eq!((s, e), (1, 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// resolve_anchor_to_lines: end 在 start 之后第一个命中行(跳过中间同子串)
|
/// resolve_anchor_to_lines: end 子串多处命中 → Err 列前两处命中行 + 修复提示
|
||||||
|
/// (契约变更:旧实现静默取第一个命中行,实测 ×2 LLM 短 end 锚点多处命中替换错位,
|
||||||
|
/// 根修改为唯一性校验——多处命中报错,提示缩小 end 锚点或改用 old_text)
|
||||||
#[test]
|
#[test]
|
||||||
fn test_resolve_anchor_picks_first_end_after_start() {
|
fn test_resolve_anchor_end_multi_hit_rejected() {
|
||||||
// 第 2、4 行都含 "}",应取 start(第1行) 之后第一个 "}" → 第 2 行
|
// 第 2、4 行都含 "}",多处命中 → Err
|
||||||
let content = "START\n}\nx\n}";
|
let content = "START\n}\nx\n}";
|
||||||
let (s, e) = resolve_anchor_to_lines(content, "START", "}").unwrap();
|
let err = resolve_anchor_to_lines(content, "START", "}").unwrap_err();
|
||||||
assert_eq!((s, e), (1, 2));
|
let msg = format!("{}", err);
|
||||||
|
assert!(msg.contains("命中 2 处"), "应报多处命中数,实际: {msg}");
|
||||||
|
assert!(msg.contains("第 2 行"), "应列第一处命中行,实际: {msg}");
|
||||||
|
assert!(msg.contains("第 4 行"), "应列第二处命中行,实际: {msg}");
|
||||||
|
assert!(msg.contains("缩小 end 锚点"), "应含缩小锚点提示,实际: {msg}");
|
||||||
|
assert!(msg.contains("old_text"), "应含改用 old_text 建议,实际: {msg}");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// resolve_anchor_to_lines: 大小写敏感(与 search_files 一致)
|
/// resolve_anchor_to_lines: 大小写敏感(与 search_files 一致)
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ pub fn register(
|
|||||||
registry,
|
registry,
|
||||||
allowed_dirs: Arc<RwLock<AllowedDirs>>,
|
allowed_dirs: Arc<RwLock<AllowedDirs>>,
|
||||||
"read_file",
|
"read_file",
|
||||||
"读取文件内容,返回文本内容。支持 offset/limit 分页;传入 search 则在文件内容中搜索匹配行(大小写敏感,字符串包含匹配),返回 matches 数组(限50条)。仅需函数/类型符号的签名或定义体时优先用 read_symbol(骨架/全文,避免 prompt 爆)",
|
"读取文件内容,返回文本内容。支持 offset/limit 分页;传入 search 则在文件内容中搜索匹配行(大小写敏感,字符串包含匹配),返回 matches 数组(限50条)。仅需函数/类型符号的签名或定义体时优先用 read_symbol(骨架/全文,避免 prompt 爆)。遍历大文件用大 limit(300 左右)分段读,小页(15-40 行)遍历会成倍放大调用轮次",
|
||||||
RiskLevel::Low,
|
RiskLevel::Low,
|
||||||
meta: ToolMeta {
|
meta: ToolMeta {
|
||||||
path_args: &["path"],
|
path_args: &["path"],
|
||||||
@@ -181,7 +181,7 @@ pub fn register(
|
|||||||
let consumed = offset_used.unwrap_or(0) + returned_lines;
|
let consumed = offset_used.unwrap_or(0) + returned_lines;
|
||||||
let truncated_info = if has_more {
|
let truncated_info = if has_more {
|
||||||
Some(format!(
|
Some(format!(
|
||||||
"文件共 {line_count} 行,本次返回 {returned_lines} 行(offset={}, limit={limit}),剩余 {} 行未读。继续读取请用 offset={} 参数(可配合 limit={limit})",
|
"文件共 {line_count} 行,本次返回 {returned_lines} 行(offset={}, limit={limit}),剩余 {} 行未读。继续读取请用 offset={}(建议 limit 300,大段遍历远快于小页;精读已定位片段再用小 limit;仅需函数签名用 read_symbol)",
|
||||||
offset_used.map(|o| o.to_string()).unwrap_or_else(|| "0".to_string()),
|
offset_used.map(|o| o.to_string()).unwrap_or_else(|| "0".to_string()),
|
||||||
line_count - consumed,
|
line_count - consumed,
|
||||||
consumed,
|
consumed,
|
||||||
@@ -448,10 +448,10 @@ pub fn register(
|
|||||||
let mut props = serde_json::Map::new();
|
let mut props = serde_json::Map::new();
|
||||||
props.insert("path".into(), serde_json::json!({ "type": "string", "description": "目标文件路径(必填)" }));
|
props.insert("path".into(), serde_json::json!({ "type": "string", "description": "目标文件路径(必填)" }));
|
||||||
props.insert("new_text".into(), serde_json::json!({ "type": "string", "description": "替换后的新内容(三模式通用,必填)" }));
|
props.insert("new_text".into(), serde_json::json!({ "type": "string", "description": "替换后的新内容(三模式通用,必填)" }));
|
||||||
props.insert("old_text".into(), serde_json::json!({ "type": "string", "description": "模式1 精确匹配:要替换的原文(必须精确匹配含空格/缩进),三选一互斥" }));
|
props.insert("old_text".into(), serde_json::json!({ "type": "string", "description": "模式1 精确匹配:要替换的原文(必须精确匹配含空格/缩进),三选一。精确未中时自动按空白归一化重试(唯一命中即按整行区间替换)" }));
|
||||||
props.insert("replace_lines".into(), serde_json::json!({
|
props.insert("replace_lines".into(), serde_json::json!({
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "模式2 行号区间:{ start: 1-based 起始行(含), end: 1-based 结束行(含) },三选一互斥。配 expected_hash 防并发行号漂移",
|
"description": "模式2 行号区间:{ start: 1-based 起始行(含), end: 1-based 结束行(含) },三选一。配 expected_hash 防并发行号漂移",
|
||||||
"properties": {
|
"properties": {
|
||||||
"start": { "type": "integer", "description": "起始行号(1-based,含)" },
|
"start": { "type": "integer", "description": "起始行号(1-based,含)" },
|
||||||
"end": { "type": "integer", "description": "结束行号(1-based,含)" }
|
"end": { "type": "integer", "description": "结束行号(1-based,含)" }
|
||||||
@@ -460,10 +460,10 @@ pub fn register(
|
|||||||
}));
|
}));
|
||||||
props.insert("anchor".into(), serde_json::json!({
|
props.insert("anchor".into(), serde_json::json!({
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "模式3 锚点:{ start: 首行子串标记, end: 尾行子串标记 },内部定位首尾行号转区间替换,三选一互斥。不需完整原文",
|
"description": "模式3 锚点:{ start: 首行子串标记, end: 尾行子串标记 },内部定位首尾行号转区间替换,三选一。不需完整原文",
|
||||||
"properties": {
|
"properties": {
|
||||||
"start": { "type": "string", "description": "首行子串标记(大小写敏感)" },
|
"start": { "type": "string", "description": "首行子串标记(大小写敏感)" },
|
||||||
"end": { "type": "string", "description": "尾行子串标记(大小写敏感,在 start 行之后)" }
|
"end": { "type": "string", "description": "尾行子串标记(大小写敏感,在 start 行之后,须唯一命中;多处命中会报错并列出命中位置)" }
|
||||||
},
|
},
|
||||||
"required": ["start", "end"]
|
"required": ["start", "end"]
|
||||||
}));
|
}));
|
||||||
@@ -479,7 +479,7 @@ pub fn register(
|
|||||||
registry,
|
registry,
|
||||||
allowed_dirs: Arc<RwLock<AllowedDirs>>,
|
allowed_dirs: Arc<RwLock<AllowedDirs>>,
|
||||||
"patch_file",
|
"patch_file",
|
||||||
"局部更新文件内容(三模式互斥)。模式1 old_text:精确匹配原文替换(含空格/缩进,CAS 语义)。模式2 replace_lines:按行号区间 {start,end}(1-based 含首尾)替换,不需原文,配 expected_hash 防行号漂移。模式3 anchor:按首尾子串锚点 {start,end}(大小写敏感,子串匹配)定位行号区间替换,不需完整原文。三模式均需 path+new_text,expected_hash 可选通用。注意:若文件已被外部修改,请先重新 read_file 获取最新内容。所有 patch_file 操作自动放行不阻塞 AI 工作流,写入会落 audit 表可追溯。",
|
"局部更新文件内容(三模式定位)。模式1 old_text:精确匹配原文替换(含空格/缩进,CAS 语义);精确未中时自动按空白归一化(行首缩进归一+多空白折叠)重试,唯一命中即按整行区间替换并在 warning 注明「空白归一化命中」。模式2 replace_lines:按行号区间 {start,end}(1-based 含首尾)替换,不需原文,配 expected_hash 防行号漂移。模式3 anchor:按首尾子串锚点 {start,end}(大小写敏感,子串匹配)定位行号区间替换,不需完整原文;end 锚点须唯一命中,多处命中报错并列出前两处位置(届时缩小 end 子串或改用 old_text)。三个定位参数同时传入时按优先级 anchor > old_text > replace_lines 仲裁取一执行并在 warning 注明,全缺才报错。三模式均需 path+new_text,expected_hash 可选通用。注意:若文件已被外部修改,请先重新 read_file 获取最新内容。所有 patch_file 操作自动放行不阻塞 AI 工作流,写入会落 audit 表可追溯。",
|
||||||
RiskLevel::Medium,
|
RiskLevel::Medium,
|
||||||
meta: ToolMeta {
|
meta: ToolMeta {
|
||||||
path_args: &["path"],
|
path_args: &["path"],
|
||||||
@@ -500,8 +500,11 @@ pub fn register(
|
|||||||
let new_text = args["new_text"].as_str()
|
let new_text = args["new_text"].as_str()
|
||||||
.ok_or_else(|| anyhow::anyhow!("缺少 new_text 参数"))?;
|
.ok_or_else(|| anyhow::anyhow!("缺少 new_text 参数"))?;
|
||||||
|
|
||||||
// 三模式互斥校验(old_text / replace_lines / anchor)
|
// 三模式定位(old_text / replace_lines / anchor)
|
||||||
// 三选一:统计传入的模式参数数,>1 报错,0 报错(缺定位方式)
|
// 旧实现:三选一互斥,>1 直接拒绝。实测 LLM 反复同时传 old_text+anchor(×3)
|
||||||
|
// 被拒后重试仍传双参,空耗往返。改:多模式按优先级仲裁取一执行
|
||||||
|
// (anchor > old_text > replace_lines,见 arbitrate_patch_mode),
|
||||||
|
// result.warning 注明仲裁结果;全缺时才报参数缺失。
|
||||||
let has_old_text = args.get("old_text").map(|v| !v.is_null()).unwrap_or(false);
|
let has_old_text = args.get("old_text").map(|v| !v.is_null()).unwrap_or(false);
|
||||||
let has_replace_lines = args.get("replace_lines").map(|v| !v.is_null()).unwrap_or(false);
|
let has_replace_lines = args.get("replace_lines").map(|v| !v.is_null()).unwrap_or(false);
|
||||||
let has_anchor = args.get("anchor").map(|v| !v.is_null()).unwrap_or(false);
|
let has_anchor = args.get("anchor").map(|v| !v.is_null()).unwrap_or(false);
|
||||||
@@ -509,12 +512,8 @@ pub fn register(
|
|||||||
if mode_count == 0 {
|
if mode_count == 0 {
|
||||||
anyhow::bail!("缺少定位方式:必须提供 old_text / replace_lines / anchor 之一");
|
anyhow::bail!("缺少定位方式:必须提供 old_text / replace_lines / anchor 之一");
|
||||||
}
|
}
|
||||||
if mode_count > 1 {
|
let (use_anchor, use_old_text, use_replace_lines, mode_arbitration) =
|
||||||
anyhow::bail!(
|
arbitrate_patch_mode(has_anchor, has_old_text, has_replace_lines);
|
||||||
"模式互斥冲突:old_text / replace_lines / anchor 仅可传一个(检测到 {} 个)",
|
|
||||||
mode_count
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 文件存在性 / 大小限制(三模式通用,先于内容读取)
|
// 文件存在性 / 大小限制(三模式通用,先于内容读取)
|
||||||
let target = std::path::Path::new(path);
|
let target = std::path::Path::new(path);
|
||||||
@@ -572,7 +571,7 @@ pub fn register(
|
|||||||
let match_count: usize;
|
let match_count: usize;
|
||||||
let warning: Option<String>;
|
let warning: Option<String>;
|
||||||
|
|
||||||
if has_old_text {
|
if use_old_text {
|
||||||
let old_text = args["old_text"].as_str()
|
let old_text = args["old_text"].as_str()
|
||||||
.ok_or_else(|| anyhow::anyhow!("old_text 必须为字符串"))?;
|
.ok_or_else(|| anyhow::anyhow!("old_text 必须为字符串"))?;
|
||||||
if old_text.is_empty() {
|
if old_text.is_empty() {
|
||||||
@@ -583,11 +582,26 @@ pub fn register(
|
|||||||
"path": path, "changed": false, "warning": "new_text 与 old_text 相同,无实际更改"
|
"path": path, "changed": false, "warning": "new_text 与 old_text 相同,无实际更改"
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
// L2: old_text 精确匹配(CAS 语义)
|
// L2: old_text 精确匹配(CAS 语义)→ 空白归一化回退 → 相近片段诊断。
|
||||||
// 失败时附相近片段提示把文件里与 old_text 相似的几行列给 LLM,
|
// 实测 ×15 失败是 LLM 缩进/空白对不齐:精确未中时按「行首缩进归一+多空白
|
||||||
// 供其对照真实缩进/空格修正锚点,避免靠猜反复重试。完全无关(无相似行)则
|
// 折叠」归一化重匹配,唯一命中自动采用(治根),多命中/零命中维持原报错,
|
||||||
// 退回原提示,不误导。
|
// 附相近片段供 LLM 对照真实缩进修正(AC-5)。
|
||||||
if !content.contains(old_text) {
|
if content.contains(old_text) {
|
||||||
|
let mc = content.matches(old_text).count();
|
||||||
|
match_count = mc;
|
||||||
|
warning = if mc > 1 {
|
||||||
|
Some(format!("匹配到 {} 处,仅替换第 1 处", mc))
|
||||||
|
} else { None };
|
||||||
|
new_content = content.replacen(old_text, new_text, 1);
|
||||||
|
} else if let Some((sl, el)) = find_whitespace_normalized_match(&content, old_text) {
|
||||||
|
// 归一化唯一命中:按整行区间替换(old_text 归一化行与目标行全等,
|
||||||
|
// 首尾行无需保留的行内其他内容)。warning 注明命中方式,LLM 可知情复核。
|
||||||
|
new_content = apply_line_range(&content, sl, el, new_text)?;
|
||||||
|
match_count = 1;
|
||||||
|
warning = Some(format!(
|
||||||
|
"空白归一化命中(精确匹配未中,第 {sl}-{el} 行已按整行区间替换);建议重读文件核对实际缩进"
|
||||||
|
));
|
||||||
|
} else {
|
||||||
let similar = similar_line_fragments(&content, old_text, 3);
|
let similar = similar_line_fragments(&content, old_text, 3);
|
||||||
if similar.is_empty() {
|
if similar.is_empty() {
|
||||||
anyhow::bail!("未找到目标文本,文件可能已被修改");
|
anyhow::bail!("未找到目标文本,文件可能已被修改");
|
||||||
@@ -597,13 +611,7 @@ pub fn register(
|
|||||||
similar.join("\n")
|
similar.join("\n")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let mc = content.matches(old_text).count();
|
} else if use_replace_lines {
|
||||||
match_count = mc;
|
|
||||||
warning = if mc > 1 {
|
|
||||||
Some(format!("匹配到 {} 处,仅替换第 1 处", mc))
|
|
||||||
} else { None };
|
|
||||||
new_content = content.replacen(old_text, new_text, 1);
|
|
||||||
} else if has_replace_lines {
|
|
||||||
let rl = args.get("replace_lines")
|
let rl = args.get("replace_lines")
|
||||||
.ok_or_else(|| anyhow::anyhow!("缺少 replace_lines 参数"))?;
|
.ok_or_else(|| anyhow::anyhow!("缺少 replace_lines 参数"))?;
|
||||||
let start = rl["start"].as_u64()
|
let start = rl["start"].as_u64()
|
||||||
@@ -614,7 +622,7 @@ pub fn register(
|
|||||||
match_count = 1;
|
match_count = 1;
|
||||||
warning = None;
|
warning = None;
|
||||||
} else {
|
} else {
|
||||||
// has_anchor
|
// use_anchor
|
||||||
let an = args.get("anchor")
|
let an = args.get("anchor")
|
||||||
.ok_or_else(|| anyhow::anyhow!("缺少 anchor 参数"))?;
|
.ok_or_else(|| anyhow::anyhow!("缺少 anchor 参数"))?;
|
||||||
let a_start = an["start"].as_str()
|
let a_start = an["start"].as_str()
|
||||||
@@ -652,15 +660,31 @@ pub fn register(
|
|||||||
// 生成 unified diff 供前端审批卡/审计留痕展示
|
// 生成 unified diff 供前端审批卡/审计留痕展示
|
||||||
// content(旧)与 new_content(新)作用域内可用,无需重读文件
|
// content(旧)与 new_content(新)作用域内可用,无需重读文件
|
||||||
let diff = generate_diff(&content, &new_content);
|
let diff = generate_diff(&content, &new_content);
|
||||||
|
let mode_name = if use_anchor {
|
||||||
|
"anchor"
|
||||||
|
} else if use_old_text {
|
||||||
|
"old_text"
|
||||||
|
} else {
|
||||||
|
"replace_lines"
|
||||||
|
};
|
||||||
let mut result = serde_json::json!({
|
let mut result = serde_json::json!({
|
||||||
"path": path,
|
"path": path,
|
||||||
"changed": true,
|
"changed": true,
|
||||||
|
"mode": mode_name,
|
||||||
"size_diff": size_diff,
|
"size_diff": size_diff,
|
||||||
"matches_found": match_count,
|
"matches_found": match_count,
|
||||||
"diff": diff,
|
"diff": diff,
|
||||||
});
|
});
|
||||||
|
// 汇总提示:多模式仲裁说明 + 分支 warning(空白归一化命中/多匹配仅换第 1 处)
|
||||||
|
let mut notes: Vec<String> = Vec::new();
|
||||||
|
if let Some(a) = mode_arbitration {
|
||||||
|
notes.push(a);
|
||||||
|
}
|
||||||
if let Some(w) = warning {
|
if let Some(w) = warning {
|
||||||
result["warning"] = serde_json::Value::String(w);
|
notes.push(w);
|
||||||
|
}
|
||||||
|
if !notes.is_empty() {
|
||||||
|
result["warning"] = serde_json::Value::String(notes.join(";"));
|
||||||
}
|
}
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
@@ -1032,7 +1056,13 @@ pub fn register(
|
|||||||
// 旧实现直接 grep_recursive(root) → 内部 read_dir → 文件路径报 os error 267「目录名称无效」。
|
// 旧实现直接 grep_recursive(root) → 内部 read_dir → 文件路径报 os error 267「目录名称无效」。
|
||||||
// 改:文件走 grep_one_file(单文件匹配),目录走 grep_recursive(递归)。
|
// 改:文件走 grep_one_file(单文件匹配),目录走 grep_recursive(递归)。
|
||||||
let root_meta = tokio::fs::metadata(root).await
|
let root_meta = tokio::fs::metadata(root).await
|
||||||
.map_err(|e| anyhow::anyhow!("无法访问路径 {}: {}", root, e))?;
|
.map_err(|e| {
|
||||||
|
if e.kind() == std::io::ErrorKind::NotFound {
|
||||||
|
file_not_found_error(root)
|
||||||
|
} else {
|
||||||
|
anyhow::anyhow!("无法访问路径 {}: {}", root, e)
|
||||||
|
}
|
||||||
|
})?;
|
||||||
if root_meta.is_file() {
|
if root_meta.is_file() {
|
||||||
let file_name = std::path::Path::new(root)
|
let file_name = std::path::Path::new(root)
|
||||||
.file_name()
|
.file_name()
|
||||||
@@ -1379,7 +1409,7 @@ async fn read_text_file(path: &str) -> anyhow::Result<(String, u64)> {
|
|||||||
let mut file = File::open(path).await
|
let mut file = File::open(path).await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
if e.kind() == std::io::ErrorKind::NotFound {
|
if e.kind() == std::io::ErrorKind::NotFound {
|
||||||
anyhow::anyhow!("文件不存在: {}", path)
|
file_not_found_error(path)
|
||||||
} else {
|
} else {
|
||||||
anyhow::anyhow!("无法访问文件 {}: {}", path, e)
|
anyhow::anyhow!("无法访问文件 {}: {}", path, e)
|
||||||
}
|
}
|
||||||
@@ -1473,6 +1503,84 @@ fn similar_line_fragments(content: &str, needle: &str, max: usize) -> Vec<String
|
|||||||
scored.into_iter().map(|(_, s)| s).collect()
|
scored.into_iter().map(|(_, s)| s).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// patch_file 多模式仲裁 + 空白归一化回退 — 纯函数(便于单测,handler 内复用)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/// patch_file 多模式仲裁:同时传入多个定位参数时按优先级取一执行。
|
||||||
|
///
|
||||||
|
/// 优先级 anchor > old_text > replace_lines(anchor 语义最精确,行号区间次之)。
|
||||||
|
/// 返回 (use_anchor, use_old_text, use_replace_lines, 仲裁说明):单模式时原样放行、
|
||||||
|
/// 说明为 None;多模式时只开选中开关,说明含「检测到多模式,已按 X 执行」。
|
||||||
|
/// 全 false(缺定位方式)由调用方先行报错,本函数不处理。
|
||||||
|
fn arbitrate_patch_mode(
|
||||||
|
has_anchor: bool,
|
||||||
|
has_old_text: bool,
|
||||||
|
has_replace_lines: bool,
|
||||||
|
) -> (bool, bool, bool, Option<String>) {
|
||||||
|
let count = [has_anchor, has_old_text, has_replace_lines]
|
||||||
|
.iter()
|
||||||
|
.filter(|&&b| b)
|
||||||
|
.count();
|
||||||
|
if count <= 1 {
|
||||||
|
return (has_anchor, has_old_text, has_replace_lines, None);
|
||||||
|
}
|
||||||
|
let chosen = if has_anchor {
|
||||||
|
"anchor"
|
||||||
|
} else if has_old_text {
|
||||||
|
"old_text"
|
||||||
|
} else {
|
||||||
|
"replace_lines"
|
||||||
|
};
|
||||||
|
(
|
||||||
|
chosen == "anchor",
|
||||||
|
chosen == "old_text",
|
||||||
|
chosen == "replace_lines",
|
||||||
|
Some(format!(
|
||||||
|
"检测到多模式({count} 个),已按 {chosen} 执行(优先级 anchor > old_text > replace_lines)"
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单行空白归一化:所有空白序列(行首缩进/制表符/连续空格)折叠为单个空格。
|
||||||
|
/// " let x = 1;" 归一化为 "let x = 1;"。
|
||||||
|
fn normalize_ws_line(line: &str) -> String {
|
||||||
|
line.split_whitespace().collect::<Vec<&str>>().join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 空白归一化行序列匹配:old_text 各行归一化后在 content 归一化行序列中找连续命中。
|
||||||
|
///
|
||||||
|
/// 用途:patch_file old_text 精确匹配未中时的回退(实测 ×15 缩进/空白对不齐)。
|
||||||
|
/// 仅当归一化后**唯一**命中才返回 Some((start_line, end_line))(1-based 含首尾);
|
||||||
|
/// 多命中(歧义)/零命中返回 None,调用方维持原报错(含相近片段诊断)。
|
||||||
|
/// 匹配为整行粒度:old_text 各归一化行须与目标行全等,故命中区间按整行替换不丢内容。
|
||||||
|
fn find_whitespace_normalized_match(content: &str, old_text: &str) -> Option<(usize, usize)> {
|
||||||
|
let target: Vec<String> = old_text.lines().map(normalize_ws_line).collect();
|
||||||
|
// old_text 归一化后全空行 → 无意义锚点,不回退(交由精确路径报错)
|
||||||
|
if target.iter().all(|l| l.is_empty()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let hay: Vec<String> = content.lines().map(normalize_ws_line).collect();
|
||||||
|
let m = target.len();
|
||||||
|
if hay.len() < m {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut hits: Vec<usize> = Vec::new();
|
||||||
|
for i in 0..=hay.len() - m {
|
||||||
|
if hay[i..i + m] == target[..] {
|
||||||
|
hits.push(i);
|
||||||
|
if hits.len() > 1 {
|
||||||
|
break; // 多命中即歧义,提前终止
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hits.len() == 1 {
|
||||||
|
Some((hits[0] + 1, hits[0] + m))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// read_file/read_symbol NotFound 提示辅助 — 相近文件名候选
|
// read_file/read_symbol NotFound 提示辅助 — 相近文件名候选
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -1742,6 +1850,96 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// patch_file 多模式仲裁 + 空白归一化回退 — 三态锁死(命中/多命中/零命中)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn arbitrate_single_mode_passthrough_no_note() {
|
||||||
|
// 单模式/零模式:开关原样放行,无仲裁说明
|
||||||
|
assert_eq!(arbitrate_patch_mode(false, true, false), (false, true, false, None));
|
||||||
|
assert_eq!(arbitrate_patch_mode(false, false, true), (false, false, true, None));
|
||||||
|
assert_eq!(arbitrate_patch_mode(true, false, false), (true, false, false, None));
|
||||||
|
assert_eq!(arbitrate_patch_mode(false, false, false), (false, false, false, None));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn arbitrate_multi_mode_prefers_anchor_then_old_text() {
|
||||||
|
// 优先级 anchor > old_text > replace_lines,仲裁说明含选中模式名
|
||||||
|
let (a, o, r, note) = arbitrate_patch_mode(true, true, true);
|
||||||
|
assert!(a && !o && !r, "三模式全传应选 anchor");
|
||||||
|
assert!(note.unwrap().contains("anchor"));
|
||||||
|
let (a, o, r, note) = arbitrate_patch_mode(false, true, true);
|
||||||
|
assert!(!a && o && !r, "old_text+replace_lines 应选 old_text");
|
||||||
|
assert!(note.unwrap().contains("old_text"));
|
||||||
|
let (a, o, r, _) = arbitrate_patch_mode(true, false, true);
|
||||||
|
assert!(a && !o && !r, "anchor+replace_lines 应选 anchor");
|
||||||
|
// 仲裁说明措辞契约(LLM 依赖该文案知情):「检测到多模式」「已按 X 执行」
|
||||||
|
let (_, _, _, note) = arbitrate_patch_mode(true, true, false);
|
||||||
|
let text = note.unwrap();
|
||||||
|
assert!(text.contains("检测到多模式"), "实际: {text}");
|
||||||
|
assert!(text.contains("已按 anchor 执行"), "实际: {text}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalize_ws_line_collapses_all_whitespace() {
|
||||||
|
assert_eq!(normalize_ws_line(" let x\t= 1; "), "let x = 1;");
|
||||||
|
assert_eq!(normalize_ws_line(""), "");
|
||||||
|
assert_eq!(normalize_ws_line("\t\t"), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalized_match_unique_indent_drift_hits() {
|
||||||
|
// 实测主失败形态:缩进漂移(2/6 空格 vs LLM 传 4 空格)+ 行内多空白 → 唯一命中
|
||||||
|
let content = "fn main() {\n let x = 1;\n let y = 2;\n}\n";
|
||||||
|
let old = " let y = 2;";
|
||||||
|
assert_eq!(find_whitespace_normalized_match(content, old), Some((3, 3)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalized_match_multiline_block_hits() {
|
||||||
|
// 多行块(含 tab 缩进):归一化后行序列连续命中第 2-3 行
|
||||||
|
let content = "a\n one two;\n\tthree;\nb\n";
|
||||||
|
let old = "one two;\n three;";
|
||||||
|
assert_eq!(find_whitespace_normalized_match(content, old), Some((2, 3)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalized_match_exact_compatible_scenario() {
|
||||||
|
// 精确匹配本可命中的场景,归一化同样定位到同区间(handler 精确优先,此为一致性)
|
||||||
|
let content = "let x = 1;\n";
|
||||||
|
assert_eq!(find_whitespace_normalized_match(content, "let x = 1;"), Some((1, 1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalized_match_multi_hit_returns_none() {
|
||||||
|
// 多命中 = 歧义 → None(handler 维持报错路径,不瞎选)
|
||||||
|
let content = "x = 1;\ny = 2;\nx = 1;\n";
|
||||||
|
assert_eq!(find_whitespace_normalized_match(content, "x = 1;"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalized_match_no_hit_returns_none() {
|
||||||
|
// 零命中 → None(handler 维持相近片段诊断报错)
|
||||||
|
let content = "fn a() {}\nfn b() {}\n";
|
||||||
|
assert_eq!(find_whitespace_normalized_match(content, "fn totally_other() {}"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalized_match_blank_old_text_returns_none() {
|
||||||
|
// 全空行 old_text → None(交由精确路径报错)
|
||||||
|
assert_eq!(find_whitespace_normalized_match("a\n\nb\n", "\n\n"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalized_match_range_feeds_line_replace_e2e() {
|
||||||
|
// 归一化命中行区间 → apply_line_range 整行替换(handler 同款调用链)
|
||||||
|
let content = "keep\n let x = 1;\n let y = 2;\nkeep\n";
|
||||||
|
let (sl, el) = find_whitespace_normalized_match(content, " let x = 1;").unwrap();
|
||||||
|
let out = apply_line_range(content, sl, el, "REPLACED").unwrap();
|
||||||
|
assert_eq!(out, "keep\nREPLACED\n let y = 2;\nkeep\n");
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// similar_file_names / file_not_found_error — AC-5 NotFound 相近文件名候选
|
// similar_file_names / file_not_found_error — AC-5 NotFound 相近文件名候选
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
|||||||
source: args["source"].as_str().map(|s| s.to_string()),
|
source: args["source"].as_str().map(|s| s.to_string()),
|
||||||
promoted_to: None, ai_analysis: None, scores: None,
|
promoted_to: None, ai_analysis: None, scores: None,
|
||||||
related_ids: None,
|
related_ids: None,
|
||||||
|
archived: false,
|
||||||
created_at: now_millis(), updated_at: now_millis(),
|
created_at: now_millis(), updated_at: now_millis(),
|
||||||
};
|
};
|
||||||
let id = record.id.clone();
|
let id = record.id.clone();
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ use df_storage::db::Database;
|
|||||||
use df_storage::models::KnowledgeRecord;
|
use df_storage::models::KnowledgeRecord;
|
||||||
use df_types::types::new_id;
|
use df_types::types::new_id;
|
||||||
|
|
||||||
use crate::commands::ai::audit::truncate_chars;
|
use crate::commands::ai::audit::truncate_chars_ellipsis;
|
||||||
use crate::commands::ai::knowledge_inject::{extract_query_keywords, spawn_embedding_for_record};
|
use crate::commands::ai::knowledge_inject::{extract_query_keywords, spawn_embedding_for_record};
|
||||||
use crate::commands::knowledge_timeline::KnowledgeTimeline;
|
use crate::commands::knowledge_timeline::KnowledgeTimeline;
|
||||||
use crate::commands::now_millis;
|
use crate::commands::now_millis;
|
||||||
@@ -81,7 +81,7 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
|||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"title": k.title,
|
"title": k.title,
|
||||||
"kind": k.kind,
|
"kind": k.kind,
|
||||||
"content": truncate_chars(&k.content, SEARCH_CONTENT_SNIPPET_LEN),
|
"content": truncate_chars_ellipsis(&k.content, SEARCH_CONTENT_SNIPPET_LEN),
|
||||||
"status": k.status,
|
"status": k.status,
|
||||||
"reuse_count": k.reuse_count,
|
"reuse_count": k.reuse_count,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
|||||||
queue,
|
queue,
|
||||||
parent_id,
|
parent_id,
|
||||||
content_json,
|
content_json,
|
||||||
|
archived: false,
|
||||||
created_at: now_millis(), updated_at: now_millis(),
|
created_at: now_millis(), updated_at: now_millis(),
|
||||||
};
|
};
|
||||||
let id = record.id.clone();
|
let id = record.id.clone();
|
||||||
@@ -133,7 +134,7 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
|||||||
registry,
|
registry,
|
||||||
db: Arc<Database>,
|
db: Arc<Database>,
|
||||||
"update_task",
|
"update_task",
|
||||||
"更新任务的指定字段(title/description/priority/assignee 等),需要提供任务 ID、字段名和新值。注意:status 改动须走 advance_task 工具(状态机推进,8 态 todo/in_progress/in_review/testing/blocked/done/cancelled/deferred,按 target_status 推进不可跳跃),本工具不接受 status 字段(防绕过状态机)",
|
"更新任务的指定字段(title/description/priority/assignee 等),需要提供任务 ID、字段名和新值。注意:status 改动须走 advance_task 工具(状态机推进,8 态 todo/in_progress/in_review/testing/blocked/done/cancelled/deferred,按 target_status 推进,目标不可直达时 auto_path 沿合法路径自动多跳),本工具不接受 status 字段(防绕过状态机)",
|
||||||
RiskLevel::Medium,
|
RiskLevel::Medium,
|
||||||
meta: ToolMeta {
|
meta: ToolMeta {
|
||||||
entity_action: Some(("task", "update")),
|
entity_action: Some(("task", "update")),
|
||||||
@@ -150,7 +151,7 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
|||||||
// 对齐 Agent B df-storage tasks 白名单移 status——双重防御(schema 拒绝 + 白名单拒绝)。
|
// 对齐 Agent B df-storage tasks 白名单移 status——双重防御(schema 拒绝 + 白名单拒绝)。
|
||||||
if field == "status" {
|
if field == "status" {
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"status 改动须走 advance_task 工具(状态机推进,8 态不可跳跃),本工具不接受 status 字段"
|
"status 改动须走 advance_task 工具(状态机推进,8 态,目标不可直达时沿合法路径自动多跳),本工具不接受 status 字段"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// 复用 df-storage CRUD 白名单(按表隔离),与 update_field 校验同源
|
// 复用 df-storage CRUD 白名单(按表隔离),与 update_field 校验同源
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ pub async fn create_idea(
|
|||||||
ai_analysis: None,
|
ai_analysis: None,
|
||||||
scores: None,
|
scores: None,
|
||||||
related_ids: None,
|
related_ids: None,
|
||||||
|
archived: false,
|
||||||
created_at: now.clone(),
|
created_at: now.clone(),
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
@@ -142,13 +143,20 @@ pub async fn update_idea(
|
|||||||
serde_json::from_str::<serde_json::Value>(&value)
|
serde_json::from_str::<serde_json::Value>(&value)
|
||||||
.map_err(|e| format!("{field} 不是合法 JSON: {e}"))?;
|
.map_err(|e| format!("{field} 不是合法 JSON: {e}"))?;
|
||||||
}
|
}
|
||||||
// update_field_active 过滤软删(deleted_at IS NULL),回收站灵感不可改字段。
|
// update_field_active 过滤软删与归档(deleted_at IS NULL AND archived = 0),
|
||||||
|
// 回收站/归档灵感不可改字段。
|
||||||
let updated = state
|
let updated = state
|
||||||
.ideas
|
.ideas
|
||||||
.update_field_active(&id, &field, &value)
|
.update_field_active(&id, &field, &value)
|
||||||
.await
|
.await
|
||||||
.map_err(err_str)?;
|
.map_err(err_str)?;
|
||||||
if !updated {
|
if !updated {
|
||||||
|
// 区分「已归档」与「不存在/已软删」,避免归档灵感被误报为已删除。
|
||||||
|
if let Ok(Some(rec)) = state.ideas.get_by_id(&id).await {
|
||||||
|
if rec.archived {
|
||||||
|
return Err(format!("灵感 ID {id} 已归档,请先取消归档再修改"));
|
||||||
|
}
|
||||||
|
}
|
||||||
return Err(format!("灵感 ID {id} 不存在或已删除"));
|
return Err(format!("灵感 ID {id} 不存在或已删除"));
|
||||||
}
|
}
|
||||||
Ok(true)
|
Ok(true)
|
||||||
@@ -166,6 +174,26 @@ pub async fn restore_idea(state: State<'_, AppState>, id: String) -> Result<bool
|
|||||||
state.ideas.restore(&id).await.map_err(err_str)
|
state.ideas.restore(&id).await.map_err(err_str)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 归档/取消归档灵感(归档功能 P1,V47)。对标 archive_task。
|
||||||
|
///
|
||||||
|
/// 纯元数据标记:归档后灵感退出主视图(list_by_query 恒带 archived = 0),
|
||||||
|
/// 仅在归档视图(list_archived_ideas)可见。软删优先:回收站灵感不可直接归档。
|
||||||
|
/// 设计要点(灵感归档动线):rejected/deferred 状态提供一键归档,清空主视图噪音。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn archive_idea(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
id: String,
|
||||||
|
archived: bool,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
state.ideas.set_archived(&id, archived).await.map_err(err_str)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 列出归档灵感(archived = 1 且未删),按更新时间降序。归档视图数据源。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_archived_ideas(state: State<'_, AppState>) -> Result<Vec<IdeaRecord>, String> {
|
||||||
|
state.ideas.list_archived().await.map_err(err_str)
|
||||||
|
}
|
||||||
|
|
||||||
/// 双向同步关联关系:原子地设置主体灵感的关联目标列表,并自动添加/移除反向关联。
|
/// 双向同步关联关系:原子地设置主体灵感的关联目标列表,并自动添加/移除反向关联。
|
||||||
/// 主体灵感 + 所有受影响的关联目标在同一 SQLite 事务中更新,保证原子性。
|
/// 主体灵感 + 所有受影响的关联目标在同一 SQLite 事务中更新,保证原子性。
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|||||||
@@ -329,6 +329,7 @@ pub async fn create_task(
|
|||||||
queue,
|
queue,
|
||||||
parent_id,
|
parent_id,
|
||||||
content_json,
|
content_json,
|
||||||
|
archived: false,
|
||||||
created_at: now.clone(),
|
created_at: now.clone(),
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
@@ -471,6 +472,11 @@ pub async fn update_task(
|
|||||||
.await
|
.await
|
||||||
.map_err(err_str)?;
|
.map_err(err_str)?;
|
||||||
if !updated {
|
if !updated {
|
||||||
|
// update_field_active 恒带 archived = 0:0 行时区分「已归档」与「不存在/已软删」,
|
||||||
|
// 避免归档任务被误报为已删除(用户无从知道要先取消归档)。
|
||||||
|
if current.as_ref().is_some_and(|c| c.archived) {
|
||||||
|
return Err(format!("任务 ID {id} 已归档,请先取消归档再修改"));
|
||||||
|
}
|
||||||
return Err(format!("任务 ID {id} 不存在或已删除"));
|
return Err(format!("任务 ID {id} 不存在或已删除"));
|
||||||
}
|
}
|
||||||
// LW-9改 parent_id 后重算父聚合——旧父(任务已离开,计数减少)与新父(任务挂上)。
|
// LW-9改 parent_id 后重算父聚合——旧父(任务已离开,计数减少)与新父(任务挂上)。
|
||||||
@@ -588,6 +594,52 @@ pub async fn restore_task(state: State<'_, AppState>, id: String) -> Result<bool
|
|||||||
Ok(ok)
|
Ok(ok)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 归档/取消归档任务(归档功能 P1,V46)。对标 ai_conversation_archive。
|
||||||
|
///
|
||||||
|
/// 纯元数据标记,与 status/queue 正交:归档后任务退出主视图与状态机(冻结:
|
||||||
|
/// 不可推进/跨池/字段更新,repo 层活跃查询与写入路径恒带 archived = 0 收口),
|
||||||
|
/// 仅在归档视图(list_archived_tasks)可见。软删优先:回收站任务不可直接归档。
|
||||||
|
///
|
||||||
|
/// 埋点 task_archived/task_unarchived(项目事件流,best-effort 不阻断)。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn archive_task(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
id: String,
|
||||||
|
archived: bool,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let rec = state.tasks.get_by_id(&id).await.map_err(err_str)?;
|
||||||
|
let ok = state.tasks.set_archived(&id, archived).await.map_err(err_str)?;
|
||||||
|
if ok {
|
||||||
|
if let Some(r) = rec {
|
||||||
|
emit_event(
|
||||||
|
&state,
|
||||||
|
&r.project_id,
|
||||||
|
if archived { "task_archived" } else { "task_unarchived" },
|
||||||
|
Some("task"),
|
||||||
|
Some(&r.id),
|
||||||
|
None,
|
||||||
|
Some(if archived { "archived" } else { "active" }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 列出归档任务(archived = 1 且未删),按更新时间降序。归档视图数据源。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_archived_tasks(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
project_id: Option<String>,
|
||||||
|
) -> Result<Vec<TaskRecord>, String> {
|
||||||
|
let tasks = state.tasks.list_archived().await.map_err(err_str)?;
|
||||||
|
// 归档量级小(低频操作),内存过滤 project_id 足够;SQL 下推待归档量增长再说
|
||||||
|
Ok(match project_id {
|
||||||
|
Some(pid) => tasks.into_iter().filter(|t| t.project_id == pid).collect(),
|
||||||
|
None => tasks,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// 推进任务状态(任务推进链 F-260616-02,推进链唯一 status 写入路径)。
|
/// 推进任务状态(任务推进链 F-260616-02,推进链唯一 status 写入路径)。
|
||||||
///
|
///
|
||||||
/// thin 入口业务逻辑(状态机校验 + 原子 CAS + review_rounds 累加 + 父聚合)
|
/// thin 入口业务逻辑(状态机校验 + 原子 CAS + review_rounds 累加 + 父聚合)
|
||||||
|
|||||||
@@ -373,6 +373,9 @@ pub fn run() {
|
|||||||
commands::task::update_task,
|
commands::task::update_task,
|
||||||
commands::task::delete_task,
|
commands::task::delete_task,
|
||||||
commands::task::restore_task,
|
commands::task::restore_task,
|
||||||
|
// 归档功能 P1(V46/V47):任务归档 + 归档视图
|
||||||
|
commands::task::archive_task,
|
||||||
|
commands::task::list_archived_tasks,
|
||||||
commands::task::get_task_by_id,
|
commands::task::get_task_by_id,
|
||||||
commands::task::advance_task,
|
commands::task::advance_task,
|
||||||
// 知识图谱 Phase 1(对标设计 §五):任务横向关联 CRUD + 跨池移动 + 父子树
|
// 知识图谱 Phase 1(对标设计 §五):任务横向关联 CRUD + 跨池移动 + 父子树
|
||||||
@@ -415,6 +418,9 @@ pub fn run() {
|
|||||||
commands::idea::relate_ideas,
|
commands::idea::relate_ideas,
|
||||||
commands::idea::delete_idea,
|
commands::idea::delete_idea,
|
||||||
commands::idea::restore_idea,
|
commands::idea::restore_idea,
|
||||||
|
// 归档功能 P1(V47):灵感归档 + 归档视图
|
||||||
|
commands::idea::archive_idea,
|
||||||
|
commands::idea::list_archived_ideas,
|
||||||
commands::idea::list_deleted_ideas,
|
commands::idea::list_deleted_ideas,
|
||||||
commands::idea::evaluate_idea,
|
commands::idea::evaluate_idea,
|
||||||
commands::idea::list_idea_evaluations,
|
commands::idea::list_idea_evaluations,
|
||||||
|
|||||||
@@ -49,4 +49,14 @@ export const ideaApi = {
|
|||||||
listEvaluations(ideaId: string): Promise<IdeaEvaluationRecord[]> {
|
listEvaluations(ideaId: string): Promise<IdeaEvaluationRecord[]> {
|
||||||
return invoke('list_idea_evaluations', { ideaId })
|
return invoke('list_idea_evaluations', { ideaId })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** 归档/取消归档灵感(归档功能 P1,V47)。返回是否命中(灵感不存在/已软删为 false)。 */
|
||||||
|
archive(id: string, archived: boolean): Promise<boolean> {
|
||||||
|
return invoke('archive_idea', { id, archived })
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 列出归档灵感(archived = 1 且未删,updated_at DESC)。 */
|
||||||
|
listArchived(): Promise<IdeaRecord[]> {
|
||||||
|
return invoke('list_archived_ideas')
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,4 +59,14 @@ export const taskApi = {
|
|||||||
advance(id: string, targetStatus: string): Promise<TaskRecord> {
|
advance(id: string, targetStatus: string): Promise<TaskRecord> {
|
||||||
return invoke('advance_task', { id, targetStatus })
|
return invoke('advance_task', { id, targetStatus })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** 归档/取消归档任务(归档功能 P1,V46)。返回是否命中(任务不存在/已软删为 false)。 */
|
||||||
|
archive(id: string, archived: boolean): Promise<boolean> {
|
||||||
|
return invoke('archive_task', { id, archived })
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 列出归档任务(archived = 1 且未删,updated_at DESC)。projectId 可选过滤。 */
|
||||||
|
listArchived(projectId?: string): Promise<TaskRecord[]> {
|
||||||
|
return invoke('list_archived_tasks', { projectId: projectId ?? null })
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ export interface IdeaRecord {
|
|||||||
scores: string | null
|
scores: string | null
|
||||||
/** 关联灵感 id JSON 数组字符串(对齐后端 related_ids Option<String>),null/空串=无关联 */
|
/** 关联灵感 id JSON 数组字符串(对齐后端 related_ids Option<String>),null/空串=无关联 */
|
||||||
related_ids: string | null
|
related_ids: string | null
|
||||||
|
/** 归档标记(归档功能 P1,V47 列)。归档灵感退出主视图,仅在归档箱可见。 */
|
||||||
|
archived: boolean
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
@@ -189,6 +191,12 @@ export interface TaskRecord {
|
|||||||
* 老任务无结构化规格时为 undefined。
|
* 老任务无结构化规格时为 undefined。
|
||||||
*/
|
*/
|
||||||
content_json?: string
|
content_json?: string
|
||||||
|
/**
|
||||||
|
* 归档标记(归档功能 P1,V46 列)。与 status/queue 正交的纯元数据:
|
||||||
|
* archived=true 的任务退出主视图(后端活跃查询恒过滤),仅在归档视图可见。
|
||||||
|
* 老任务后端 DEFAULT 0 保证该字段始终存在。
|
||||||
|
*/
|
||||||
|
archived: boolean
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<div v-if="tc.name === 'read_file' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-file-content">
|
<div v-if="tc.name === 'read_file' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-file-content">
|
||||||
<div class="ai-tool-file-bar">
|
<div class="ai-tool-file-bar">
|
||||||
<span class="ai-tool-file-icon" v-html="fileIcon"></span>
|
<span class="ai-tool-file-icon" v-html="fileIcon"></span>
|
||||||
<span class="ai-tool-file-path" :title="parsed?.path">{{ shortPath(parsed?.path || '') }}</span>
|
<button type="button" class="ai-tool-file-path ai-tool-file-path--copy" :title="parsed?.path + ' — ' + $t('aiTool.clickToCopy')" @click.stop="copyResultPath($event, parsed?.path || '')">{{ shortPath(parsed?.path || '') }}</button>
|
||||||
<!-- BUG-260625-02: search 模式返回 matches(命中行)非 content,
|
<!-- BUG-260625-02: search 模式返回 matches(命中行)非 content,
|
||||||
旧模板取 lines/returned_lines 显「0 行」+ 空 content 区,用户误以为读取失败。
|
旧模板取 lines/returned_lines 显「0 行」+ 空 content 区,用户误以为读取失败。
|
||||||
search 模式显搜索词 + 命中数(复用 grepHitsN 文案);默认模式显行数/大小 + 展开。 -->
|
search 模式显搜索词 + 命中数(复用 grepHitsN 文案);默认模式显行数/大小 + 展开。 -->
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
<div v-else-if="tc.name === 'list_directory' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-dir-list">
|
<div v-else-if="tc.name === 'list_directory' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-dir-list">
|
||||||
<div class="ai-tool-file-bar">
|
<div class="ai-tool-file-bar">
|
||||||
<span class="ai-tool-file-icon ai-tool-file-icon--dir" v-html="dirIcon"></span>
|
<span class="ai-tool-file-icon ai-tool-file-icon--dir" v-html="dirIcon"></span>
|
||||||
<span class="ai-tool-file-path" :title="parsed?.path">{{ shortPath(parsed?.path || '') }}</span>
|
<button type="button" class="ai-tool-file-path ai-tool-file-path--copy" :title="parsed?.path + ' — ' + $t('aiTool.clickToCopy')" @click.stop="copyResultPath($event, parsed?.path || '')">{{ shortPath(parsed?.path || '') }}</button>
|
||||||
<span class="ai-tool-file-meta">{{ parsed?.entries?.length || 0 }} {{ $t('aiTool.items') }}</span>
|
<span class="ai-tool-file-meta">{{ parsed?.entries?.length || 0 }} {{ $t('aiTool.items') }}</span>
|
||||||
<span v-if="parsed?.truncated" class="ai-tool-file-meta ai-tool-file-meta--warn">{{ $t('aiTool.dirTruncated', { n: DIR_TRUNCATE_LIMIT }) }}</span>
|
<span v-if="parsed?.truncated" class="ai-tool-file-meta ai-tool-file-meta--warn">{{ $t('aiTool.dirTruncated', { n: DIR_TRUNCATE_LIMIT }) }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
<div v-else-if="tc.name === 'search_files' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-dir-list">
|
<div v-else-if="tc.name === 'search_files' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-dir-list">
|
||||||
<div class="ai-tool-file-bar">
|
<div class="ai-tool-file-bar">
|
||||||
<span class="ai-tool-file-icon ai-tool-file-icon--dir" v-html="dirIcon"></span>
|
<span class="ai-tool-file-icon ai-tool-file-icon--dir" v-html="dirIcon"></span>
|
||||||
<span class="ai-tool-file-path" :title="parsed?.path">{{ shortPath(parsed?.path || '') }}</span>
|
<button type="button" class="ai-tool-file-path ai-tool-file-path--copy" :title="parsed?.path + ' — ' + $t('aiTool.clickToCopy')" @click.stop="copyResultPath($event, parsed?.path || '')">{{ shortPath(parsed?.path || '') }}</button>
|
||||||
<span v-if="parsed?.pattern" class="ai-tool-file-meta">「{{ parsed?.pattern }}」</span>
|
<span v-if="parsed?.pattern" class="ai-tool-file-meta">「{{ parsed?.pattern }}」</span>
|
||||||
<span class="ai-tool-file-meta">{{ $t('aiTool.foundN', { n: typeof parsed?.total === 'number' ? parsed.total : (parsed?.results?.length || 0) }) }}</span>
|
<span class="ai-tool-file-meta">{{ $t('aiTool.foundN', { n: typeof parsed?.total === 'number' ? parsed.total : (parsed?.results?.length || 0) }) }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
<div v-else-if="tc.name === 'grep' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-grep-result">
|
<div v-else-if="tc.name === 'grep' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-grep-result">
|
||||||
<div class="ai-tool-file-bar">
|
<div class="ai-tool-file-bar">
|
||||||
<span class="ai-tool-file-icon" v-html="searchIcon"></span>
|
<span class="ai-tool-file-icon" v-html="searchIcon"></span>
|
||||||
<span class="ai-tool-file-path" :title="parsed?.path">{{ shortPath(parsed?.path || '') }}</span>
|
<button type="button" class="ai-tool-file-path ai-tool-file-path--copy" :title="parsed?.path + ' — ' + $t('aiTool.clickToCopy')" @click.stop="copyResultPath($event, parsed?.path || '')">{{ shortPath(parsed?.path || '') }}</button>
|
||||||
<span v-if="parsed?.pattern" class="ai-tool-file-meta">「{{ parsed?.pattern }}」</span>
|
<span v-if="parsed?.pattern" class="ai-tool-file-meta">「{{ parsed?.pattern }}」</span>
|
||||||
<span class="ai-tool-file-meta">{{ grepHitLabel }}</span>
|
<span class="ai-tool-file-meta">{{ grepHitLabel }}</span>
|
||||||
<span v-if="parsed?.truncated" class="ai-tool-file-meta ai-tool-file-meta--warn">{{ $t('aiTool.grepTruncated') }}</span>
|
<span v-if="parsed?.truncated" class="ai-tool-file-meta ai-tool-file-meta--warn">{{ $t('aiTool.grepTruncated') }}</span>
|
||||||
@@ -125,7 +125,7 @@
|
|||||||
<div v-else-if="tc.name === 'patch_file' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-patch-result">
|
<div v-else-if="tc.name === 'patch_file' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-patch-result">
|
||||||
<div class="ai-tool-file-bar">
|
<div class="ai-tool-file-bar">
|
||||||
<span class="ai-tool-file-icon" v-html="fileIcon"></span>
|
<span class="ai-tool-file-icon" v-html="fileIcon"></span>
|
||||||
<span class="ai-tool-file-path" :title="parsed?.path">{{ shortPath(parsed?.path || '') }}</span>
|
<button type="button" class="ai-tool-file-path ai-tool-file-path--copy" :title="parsed?.path + ' — ' + $t('aiTool.clickToCopy')" @click.stop="copyResultPath($event, parsed?.path || '')">{{ shortPath(parsed?.path || '') }}</button>
|
||||||
<span class="ai-tool-file-meta">{{ formatSizeDiff(parsed?.size_diff) }} {{ $t('aiTool.bytesUnit') }}</span>
|
<span class="ai-tool-file-meta">{{ formatSizeDiff(parsed?.size_diff) }} {{ $t('aiTool.bytesUnit') }}</span>
|
||||||
<span v-if="parsed?.matches_found && parsed.matches_found > 1" class="ai-tool-file-meta">{{ $t('aiTool.patchedMatches', { n: parsed.matches_found }) }}</span>
|
<span v-if="parsed?.matches_found && parsed.matches_found > 1" class="ai-tool-file-meta">{{ $t('aiTool.patchedMatches', { n: parsed.matches_found }) }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -257,6 +257,39 @@ const emit = defineEmits<{
|
|||||||
'install-obscura': []
|
'install-obscura': []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
// ── R6 结果路径可复制 ──
|
||||||
|
// AI 读过/写过的文件,用户想拿去本地操作(编辑器打开/再 grep)只能手动重敲路径。
|
||||||
|
// file-bar 的 path 改为可点击:复制完整路径(parsed.path 原文,非 shortPath 截断展示),
|
||||||
|
// 按钮文字短暂变「✓」作反馈(1.2s 还原,同 R4 代码块复制反馈节奏)。
|
||||||
|
let _pathCopiedTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
async function copyResultPath(e: MouseEvent, path: string): Promise<void> {
|
||||||
|
if (!path) return
|
||||||
|
const btn = e.currentTarget as HTMLButtonElement | null
|
||||||
|
let ok = false
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
|
await navigator.clipboard.writeText(path)
|
||||||
|
ok = true
|
||||||
|
}
|
||||||
|
} catch { /* 落 execCommand 兜底 */ }
|
||||||
|
if (!ok) {
|
||||||
|
const ta = document.createElement('textarea')
|
||||||
|
ta.value = path
|
||||||
|
ta.style.position = 'fixed'
|
||||||
|
ta.style.opacity = '0'
|
||||||
|
document.body.appendChild(ta)
|
||||||
|
ta.select()
|
||||||
|
try { ok = document.execCommand('copy') } catch { ok = false }
|
||||||
|
document.body.removeChild(ta)
|
||||||
|
}
|
||||||
|
if (btn) {
|
||||||
|
const prev = btn.textContent
|
||||||
|
btn.textContent = ok ? '✓' : '✕'
|
||||||
|
if (_pathCopiedTimer) clearTimeout(_pathCopiedTimer)
|
||||||
|
_pathCopiedTimer = setTimeout(() => { btn.textContent = prev }, 1200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// run_command 输出折叠态:失败命令默认展开(便于看 stderr),成功默认折叠
|
// run_command 输出折叠态:失败命令默认展开(便于看 stderr),成功默认折叠
|
||||||
const cmdOutputExpanded = ref(false)
|
const cmdOutputExpanded = ref(false)
|
||||||
// http_request 响应 body 折叠态:失败(非 2xx)默认展开便于看错误,成功默认折叠
|
// http_request 响应 body 折叠态:失败(非 2xx)默认展开便于看错误,成功默认折叠
|
||||||
@@ -403,6 +436,14 @@ watch(() => props.tc.status, (s) => {
|
|||||||
.ai-tool-file-meta--warn {
|
.ai-tool-file-meta--warn {
|
||||||
color: var(--df-warning);
|
color: var(--df-warning);
|
||||||
}
|
}
|
||||||
|
/* R6:路径可点击复制(按钮语义,视觉保持原 span 形态;hover 变 accent 提示可点) */
|
||||||
|
.ai-tool-file-path--copy {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s var(--df-ease);
|
||||||
|
}
|
||||||
|
.ai-tool-file-path--copy:hover {
|
||||||
|
color: var(--df-accent);
|
||||||
|
}
|
||||||
|
|
||||||
/* grep 结果文件栏复用 ai-tool-file-bar,命中行用等宽渲染 */
|
/* grep 结果文件栏复用 ai-tool-file-bar,命中行用等宽渲染 */
|
||||||
.ai-tool-grep-result {
|
.ai-tool-grep-result {
|
||||||
|
|||||||
@@ -34,11 +34,14 @@ defineProps<{
|
|||||||
msg: AiMessage
|
msg: AiMessage
|
||||||
/** 是否显示编辑按钮(父合成:末条 user + 非生成中 + 非编辑中) */
|
/** 是否显示编辑按钮(父合成:末条 user + 非生成中 + 非编辑中) */
|
||||||
canEdit?: boolean
|
canEdit?: boolean
|
||||||
|
/** I6:是否显示重新生成按钮(父合成同 canEdit 时机;作用于末条 user 触发的回复) */
|
||||||
|
canRegenerate?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'copy', msg: AiMessage): void
|
(e: 'copy', msg: AiMessage): void
|
||||||
(e: 'start-edit', msg: AiMessage): void
|
(e: 'start-edit', msg: AiMessage): void
|
||||||
|
(e: 'regenerate', msg: AiMessage): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// ── Input Augmentation: 按 mention 区间切段 ──
|
// ── Input Augmentation: 按 mention 区间切段 ──
|
||||||
@@ -122,6 +125,10 @@ function imgAlt(img: ImageContentPart): string {
|
|||||||
<img v-for="(img, idx) in msgImageParts(msg)" :key="msg.id + '-img-' + idx" :src="imageSrc(img)" :alt="imgAlt(img)" class="ai-msg-image" loading="lazy" />
|
<img v-for="(img, idx) in msgImageParts(msg)" :key="msg.id + '-img-' + idx" :src="imageSrc(img)" :alt="imgAlt(img)" class="ai-msg-image" loading="lazy" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
<!-- I6:重新生成按钮(hover,编辑按钮左侧);复用后端 regenerate 链路(弹末尾 AI 回复取触发 user 消息重跑) -->
|
||||||
|
<button v-if="canRegenerate" class="ai-msg-regen-btn" :title="t('aiChat.regenerate')" @click.stop="emit('regenerate', msg)">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>
|
||||||
|
</button>
|
||||||
<!-- UX-09:末条 user 消息编辑按钮(hover 显示),点击经 start-edit 链回填输入框编辑重发 -->
|
<!-- UX-09:末条 user 消息编辑按钮(hover 显示),点击经 start-edit 链回填输入框编辑重发 -->
|
||||||
<button v-if="canEdit" class="ai-msg-edit-btn" :title="t('aiChat.editMessage')" @click.stop="emit('start-edit', msg)">
|
<button v-if="canEdit" class="ai-msg-edit-btn" :title="t('aiChat.editMessage')" @click.stop="emit('start-edit', msg)">
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||||
@@ -195,8 +202,9 @@ function imgAlt(img: ImageContentPart): string {
|
|||||||
max-height: 200px;
|
max-height: 200px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
/* 复制/编辑按钮(hover 显示,同栏同视觉;编辑按钮仅 canEdit 时渲染) */
|
/* 复制/编辑/重新生成按钮(hover 显示,同栏同视觉;编辑/重新生成仅各自 can* 时渲染) */
|
||||||
.ai-copy-btn,
|
.ai-copy-btn,
|
||||||
|
.ai-msg-regen-btn,
|
||||||
.ai-msg-edit-btn {
|
.ai-msg-edit-btn {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.15s;
|
transition: opacity 0.15s;
|
||||||
@@ -207,7 +215,11 @@ function imgAlt(img: ImageContentPart): string {
|
|||||||
color: var(--df-text-dim);
|
color: var(--df-text-dim);
|
||||||
}
|
}
|
||||||
.ai-msg-user:hover .ai-copy-btn,
|
.ai-msg-user:hover .ai-copy-btn,
|
||||||
|
.ai-msg-user:hover .ai-msg-regen-btn,
|
||||||
.ai-msg-user:hover .ai-msg-edit-btn {
|
.ai-msg-user:hover .ai-msg-edit-btn {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
.ai-msg-regen-btn:hover {
|
||||||
|
color: var(--df-accent);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -620,10 +620,16 @@ function decorateCodeHtml(raw: string): string {
|
|||||||
const hit = _decoCache.get(raw)
|
const hit = _decoCache.get(raw)
|
||||||
if (hit !== undefined) return hit
|
if (hit !== undefined) return hit
|
||||||
// marked 的 code renderer 统一输出 <pre><code class="hljs ...">…</code></pre>
|
// marked 的 code renderer 统一输出 <pre><code class="hljs ...">…</code></pre>
|
||||||
//(含缩进码块),代码内容已 HTML 转义,正则非贪婪到首个闭合标签不会越界
|
//(含缩进码块),代码内容已 HTML 转义,正则非贪婪到首个闭合标签不会越界。
|
||||||
|
// R4 语言角标:code class 形如 hljs language-ts,捕获 language-xxx 输出到 .md-code-block
|
||||||
|
// 的 data-lang,CSS ::after 显示;无语言(裸块/auto 猜测无 language- 类)不显角标。
|
||||||
const out = raw.replace(
|
const out = raw.replace(
|
||||||
/<pre><code([^>]*)>([\s\S]*?)<\/code><\/pre>/g,
|
/<pre><code([^>]*)>([\s\S]*?)<\/code><\/pre>/g,
|
||||||
'<div class="md-code-block"><button type="button" class="md-code-copy">复制</button><pre><code$1>$2</code></pre></div>',
|
(_m: string, attrs: string, body: string) => {
|
||||||
|
const lang = /language-([a-z0-9+#.-]+)/i.exec(attrs)?.[1] ?? ''
|
||||||
|
const langAttr = lang ? ` data-lang="${lang}"` : ''
|
||||||
|
return `<div class="md-code-block"${langAttr}><button type="button" class="md-code-copy">复制</button><pre><code${attrs}>${body}</code></pre></div>`
|
||||||
|
},
|
||||||
)
|
)
|
||||||
if (_decoCache.size > DECO_CACHE_LIMIT) _decoCache.clear()
|
if (_decoCache.size > DECO_CACHE_LIMIT) _decoCache.clear()
|
||||||
_decoCache.set(raw, out)
|
_decoCache.set(raw, out)
|
||||||
@@ -718,6 +724,15 @@ async function handleRetry(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* I6 重新生成按钮:与错误气泡「重试」同链路(store.regenerate,后端弹末尾 AI 回复取触发
|
||||||
|
* user 消息重跑),入口场景不同——用户对已有回复不满意主动重问,挂在末条 user 气泡 hover。
|
||||||
|
* 与隐藏快捷键 Ctrl+R(AiChat.vue)等价,补齐 UI 入口(i18n aiChat.regenerate 文案已备)。
|
||||||
|
*/
|
||||||
|
async function handleRegenerate(): Promise<void> {
|
||||||
|
await handleRetry()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* fetch_url obscura 未装时"AI 帮装"按钮:自动触发 AI 对话让 AI run_command 装流式。
|
* fetch_url obscura 未装时"AI 帮装"按钮:自动触发 AI 对话让 AI run_command 装流式。
|
||||||
* 提示语足够具体(给出 cargo install + GitHub releases 两种装法 + 装完告知用户重试),
|
* 提示语足够具体(给出 cargo install + GitHub releases 两种装法 + 装完告知用户重试),
|
||||||
@@ -1530,8 +1545,10 @@ defineExpose({
|
|||||||
v-else-if="item.msg.role === 'user'"
|
v-else-if="item.msg.role === 'user'"
|
||||||
:msg="item.msg"
|
:msg="item.msg"
|
||||||
:can-edit="isLastUser(item.msg) && !store.state.streaming && item.msg.id !== editingMsgId"
|
:can-edit="isLastUser(item.msg) && !store.state.streaming && item.msg.id !== editingMsgId"
|
||||||
|
:can-regenerate="isLastUser(item.msg) && !store.state.streaming"
|
||||||
@copy="copyMsgContent"
|
@copy="copyMsgContent"
|
||||||
@start-edit="(m) => emit('start-edit', m)"
|
@start-edit="(m) => emit('start-edit', m)"
|
||||||
|
@regenerate="handleRegenerate"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- AI 消息 -->
|
<!-- AI 消息 -->
|
||||||
@@ -2100,6 +2117,31 @@ defineExpose({
|
|||||||
color: var(--df-danger);
|
color: var(--df-danger);
|
||||||
border-color: var(--df-danger);
|
border-color: var(--df-danger);
|
||||||
}
|
}
|
||||||
|
/* R4 语言角标:左上角,hover 随复制按钮一起显现(hover pre 块时) */
|
||||||
|
.ai-md :deep(.md-code-block)::before {
|
||||||
|
content: attr(data-lang);
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
left: 8px;
|
||||||
|
z-index: 1;
|
||||||
|
font-size: 9px;
|
||||||
|
line-height: 1.4;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
color: var(--df-text-dim);
|
||||||
|
background: color-mix(in srgb, var(--df-bg) 82%, transparent);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: var(--df-radius-sm);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s var(--df-ease);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.ai-md :deep(.md-code-block:hover)::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
/* 无 data-lang 的裸块不渲染空角标 */
|
||||||
|
.ai-md :deep(.md-code-block:not([data-lang]))::before {
|
||||||
|
content: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── UX-02/消息操作栏(hover 显示 / 错误气泡常显) ── */
|
/* ── UX-02/消息操作栏(hover 显示 / 错误气泡常显) ── */
|
||||||
.ai-msg-actions {
|
.ai-msg-actions {
|
||||||
|
|||||||
@@ -286,10 +286,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 操作(P0-② 立项按钮已上移至标题行,此处仅留删除) -->
|
<!-- 操作(P0-② 立项按钮已上移至标题行) -->
|
||||||
<div class="detail-section" style="margin-top:8px">
|
<div class="detail-section" style="margin-top:8px">
|
||||||
<div class="action-buttons">
|
<div class="action-buttons">
|
||||||
<button class="btn btn-ghost" :disabled="deleting" @click="$emit('delete')">
|
<!-- 归档功能 P1:主列表=归档动线;归档箱=取消归档动线(删除隐藏,防误删归档记忆) -->
|
||||||
|
<button class="btn btn-ghost" :disabled="deleting" @click="$emit('archive', !inArchiveBox)">
|
||||||
|
{{ inArchiveBox ? $t('ideas.quickUnarchive') : $t('ideas.quickArchive') }}
|
||||||
|
</button>
|
||||||
|
<button v-if="!inArchiveBox" class="btn btn-ghost" :disabled="deleting" @click="$emit('delete')">
|
||||||
{{ deleting ? $t('ideas.deleting') : $t('ideas.deleteIdea') }}
|
{{ deleting ? $t('ideas.deleting') : $t('ideas.deleteIdea') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -315,12 +319,15 @@ const props = defineProps<{
|
|||||||
evalError: string
|
evalError: string
|
||||||
promoting: boolean
|
promoting: boolean
|
||||||
deleting: boolean
|
deleting: boolean
|
||||||
|
/** 归档功能 P1:父视图是否处于归档箱模式(决定操作按钮语义:归档↔取消归档) */
|
||||||
|
inArchiveBox?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'evaluate'): void
|
(e: 'evaluate'): void
|
||||||
(e: 'promote'): void
|
(e: 'promote'): void
|
||||||
(e: 'delete'): void
|
(e: 'delete'): void
|
||||||
|
(e: 'archive', archived: boolean): void
|
||||||
(e: 'status-change', value: IdeaStatus): void
|
(e: 'status-change', value: IdeaStatus): void
|
||||||
// 返 Promise<boolean> 让子组件 await 知成功/失败。
|
// 返 Promise<boolean> 让子组件 await 知成功/失败。
|
||||||
// true=落库成功(关编辑态);false=失败(保持编辑态,toast 由父组件处理)。
|
// true=落库成功(关编辑态);false=失败(保持编辑态,toast 由父组件处理)。
|
||||||
|
|||||||
@@ -225,10 +225,12 @@ export function handleEvent(event: AiChatEvent) {
|
|||||||
if (event.type === 'AiCompleted') {
|
if (event.type === 'AiCompleted') {
|
||||||
emit('ai-drain-queue', { conversationId: event.conversation_id })
|
emit('ai-drain-queue', { conversationId: event.conversation_id })
|
||||||
} else if (event.type === 'AiError') {
|
} else if (event.type === 'AiError') {
|
||||||
|
// 9-28#5 收口:无 convId 的 AiError 属异常防御路径(后端 emit 必带),清全部队列
|
||||||
|
// 会误伤其他会话排队消息,改保留 + console.warn 留痕
|
||||||
if (event.conversation_id) {
|
if (event.conversation_id) {
|
||||||
state.queue = state.queue.filter(q => q.conversationId !== event.conversation_id)
|
state.queue = state.queue.filter(q => q.conversationId !== event.conversation_id)
|
||||||
} else {
|
} else {
|
||||||
state.queue = []
|
console.warn('[ai] AiError 缺 conversation_id,跳过清队列(防误伤其他会话排队)')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (event.type === 'AiTextDelta' && convId && switchingConvs.has(convId)) {
|
} else if (event.type === 'AiTextDelta' && convId && switchingConvs.has(convId)) {
|
||||||
|
|||||||
@@ -208,8 +208,9 @@ export function handleLifecycleEvent(event: AiChatEvent): boolean {
|
|||||||
state.queue = state.queue.filter(q => q.conversationId !== event.conversation_id)
|
state.queue = state.queue.filter(q => q.conversationId !== event.conversation_id)
|
||||||
notifyQueueCleared(dropped)
|
notifyQueueCleared(dropped)
|
||||||
} else {
|
} else {
|
||||||
notifyQueueCleared(state.queue.length)
|
// 9-28#5 收口:无 convId 的 AiError 属异常防御路径(后端 emit 必带),清全部队列
|
||||||
state.queue = []
|
// 会误伤其他会话排队消息,改保留 + console.warn 留痕(不再弹「已清 N 条」误导用户)
|
||||||
|
console.warn('[ai] AiError 缺 conversation_id,跳过清队列(防误伤其他会话排队)')
|
||||||
}
|
}
|
||||||
state.messages.push({
|
state.messages.push({
|
||||||
id: `err-${nextMsgId()}` as MessageId,
|
id: `err-${nextMsgId()}` as MessageId,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
*/
|
*/
|
||||||
import { ref, reactive, computed, watch, onBeforeUnmount, type Ref } from 'vue'
|
import { ref, reactive, computed, watch, onBeforeUnmount, type Ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import i18n from '@/i18n'
|
||||||
import { useConfirm } from '@/composables/useConfirm'
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
import { aiApi } from '@/api'
|
import { aiApi } from '@/api'
|
||||||
import { getApprovalRemainingMs } from './aiShared'
|
import { getApprovalRemainingMs } from './aiShared'
|
||||||
@@ -47,8 +48,9 @@ export function syncHighRiskTools(tools: { name: string; risk: string }[]): void
|
|||||||
if (high.length > 0) registryHighRisk = new Set(high)
|
if (high.length > 0) registryHighRisk = new Set(high)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 审批二次确认门控:优先注册表投影,未拉到元数据时退兜底名单 */
|
/** 审批二次确认门控:优先注册表投影,未拉到元数据时退兜底名单。
|
||||||
function isHighRiskTool(name: string): boolean {
|
* V4 导出:系统浮窗 ApprovalPopup 的批准路径与 ToolCard 同门槛复用。 */
|
||||||
|
export function isHighRiskTool(name: string): boolean {
|
||||||
return (registryHighRisk ?? HIGH_RISK_TOOLS_FALLBACK).has(name)
|
return (registryHighRisk ?? HIGH_RISK_TOOLS_FALLBACK).has(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,6 +82,21 @@ export function dispatchApprovalIPC(
|
|||||||
return aiApi.approve(tc.id, approved)
|
return aiApi.approve(tc.id, approved)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** V4:High 风险工具二次确认文案(模块级,供 ApprovalPopup 在无组件上下文复用;
|
||||||
|
* i18n 全局实例取 t,与 useI18n 同源 locale) */
|
||||||
|
export function highRiskConfirmText(name: string): string {
|
||||||
|
// i18n.global 整体访问触发 vue-i18n 泛型递归 TS2589,先断言窄化再取 t
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const g = i18n.global as any
|
||||||
|
const gt = g.t as (key: string) => string
|
||||||
|
if (name === 'run_command') return gt('aiTool.confirmHighExec')
|
||||||
|
if (name === 'http_request') return gt('aiTool.confirmHighHttp')
|
||||||
|
if (name === 'delete_task' || name === 'delete_project' || name === 'purge_project' || name === 'delete_file' || name === 'restore_project') {
|
||||||
|
return gt('aiTool.confirmHighDelete')
|
||||||
|
}
|
||||||
|
return gt('aiTool.confirmHighGeneric')
|
||||||
|
}
|
||||||
|
|
||||||
/** useToolApproval emit 签名 — 补全类型替代 any */
|
/** useToolApproval emit 签名 — 补全类型替代 any */
|
||||||
interface ToolApprovalEmit {
|
interface ToolApprovalEmit {
|
||||||
(event: 'approve', payload: { id: string; approved: boolean; decision?: 'once' | 'session' | 'always' | 'deny' }): void
|
(event: 'approve', payload: { id: string; approved: boolean; decision?: 'once' | 'session' | 'always' | 'deny' }): void
|
||||||
@@ -145,14 +162,9 @@ export function useToolApproval(tc: Ref<AiToolCallInfo>, emit: ToolApprovalEmit)
|
|||||||
_approveToastTimer = setTimeout(() => { approveToast.visible = false }, 3000)
|
_approveToastTimer = setTimeout(() => { approveToast.visible = false }, 3000)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** High 风险工具的二次确认文案(按操作类型) */
|
/** High 风险工具的二次确认文案(按操作类型;V4 提升为模块级导出,浮窗复用) */
|
||||||
function highRiskConfirmMsg(name: string): string {
|
function highRiskConfirmMsg(name: string): string {
|
||||||
if (name === 'run_command') return t('aiTool.confirmHighExec')
|
return highRiskConfirmText(name)
|
||||||
if (name === 'http_request') return t('aiTool.confirmHighHttp')
|
|
||||||
if (name === 'delete_task' || name === 'delete_project' || name === 'purge_project' || name === 'delete_file' || name === 'restore_project') {
|
|
||||||
return t('aiTool.confirmHighDelete')
|
|
||||||
}
|
|
||||||
return t('aiTool.confirmHighGeneric')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -112,12 +112,12 @@ export default {
|
|||||||
clearSkill: 'Clear skill',
|
clearSkill: 'Clear skill',
|
||||||
skillBadgeTip: 'Injected full skill playbook: {name}',
|
skillBadgeTip: 'Injected full skill playbook: {name}',
|
||||||
placeholderWithSkill: 'Skill selected. Type arguments and press Enter',
|
placeholderWithSkill: 'Skill selected. Type arguments and press Enter',
|
||||||
placeholderDefault: "Type a message... (/ for skills, {'@'} to mention)",
|
placeholderDefault: "Type a message... (/ for skills, @ to mention)",
|
||||||
skillNoMatch: 'No matching skill',
|
skillNoMatch: 'No matching skill',
|
||||||
skillNotLoaded: 'No local skills loaded',
|
skillNotLoaded: 'No local skills loaded',
|
||||||
|
|
||||||
// ── @ entity mention (@ opens popover, select inserts [Type:Name] token) ──
|
// ── @ entity mention (@ opens popover, select inserts [Type:Name] token) ──
|
||||||
mentionHint: "{'@'} to mention project/task",
|
mentionHint: "@ to mention project/task",
|
||||||
mentionGroupProject: 'Projects',
|
mentionGroupProject: 'Projects',
|
||||||
mentionGroupTask: 'Tasks',
|
mentionGroupTask: 'Tasks',
|
||||||
mentionGroupIdea: 'Ideas',
|
mentionGroupIdea: 'Ideas',
|
||||||
@@ -207,7 +207,7 @@ export default {
|
|||||||
|
|
||||||
// ── Input Augmentation layer: mention/skill injection feedback ──
|
// ── Input Augmentation layer: mention/skill injection feedback ──
|
||||||
// search_files blind search denied by whitelist fallback (no auth dialog; prompt user to use @mention or absolute path)
|
// search_files blind search denied by whitelist fallback (no auth dialog; prompt user to use @mention or absolute path)
|
||||||
searchFilesBlocked: "search_files only works inside authorized dirs. Use {'@'}[project] to reference or provide an absolute path.",
|
searchFilesBlocked: "search_files only works inside authorized dirs. Use @[project] to reference or provide an absolute path.",
|
||||||
// /skill hot reload (ai_reload_skills IPC) success toast
|
// /skill hot reload (ai_reload_skills IPC) success toast
|
||||||
skillReloaded: 'Skills reloaded',
|
skillReloaded: 'Skills reloaded',
|
||||||
// @mention entity resolve failed (id points to a project/task/idea/skill that was deleted or unavailable)
|
// @mention entity resolve failed (id points to a project/task/idea/skill that was deleted or unavailable)
|
||||||
@@ -288,7 +288,7 @@ export default {
|
|||||||
// ── TopBar goal/project context labels ──
|
// ── TopBar goal/project context labels ──
|
||||||
goalsCount: 'Goals ({n})',
|
goalsCount: 'Goals ({n})',
|
||||||
none: 'None',
|
none: 'None',
|
||||||
projectLabel: "{'@'}Projects",
|
projectLabel: "@Projects",
|
||||||
|
|
||||||
// ── 3c UX batch addition (I5 classified send-failure messages): friendly text by
|
// ── 3c UX batch addition (I5 classified send-failure messages): friendly text by
|
||||||
// error kind; {msg} keeps the raw error string for troubleshooting ──
|
// error kind; {msg} keeps the raw error string for troubleshooting ──
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export default {
|
|||||||
// tool execution failure marker (AR-6 keeps status=completed, red to distinguish from success)
|
// tool execution failure marker (AR-6 keeps status=completed, red to distinguish from success)
|
||||||
executionFailed: 'Failed',
|
executionFailed: 'Failed',
|
||||||
executionFailedHint: 'Tool execution failed, see details below',
|
executionFailedHint: 'Tool execution failed, see details below',
|
||||||
|
// R6: click-to-copy full result path (hover title suffix)
|
||||||
|
clickToCopy: 'Click to copy full path',
|
||||||
// Singular expand/collapse (ToolCard single-card content, command output toggle)
|
// Singular expand/collapse (ToolCard single-card content, command output toggle)
|
||||||
expand: 'Expand',
|
expand: 'Expand',
|
||||||
collapse: 'Collapse',
|
collapse: 'Collapse',
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export default {
|
|||||||
createFailed: 'Failed to create idea',
|
createFailed: 'Failed to create idea',
|
||||||
deleteFailed: 'Failed to delete idea',
|
deleteFailed: 'Failed to delete idea',
|
||||||
relateFailed: 'Failed to update relations',
|
relateFailed: 'Failed to update relations',
|
||||||
|
archiveFailed: 'Archive operation failed',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Action feedback toasts (WK-16/18: success/failure light toast, aligns Knowledge.createdToast)
|
// Action feedback toasts (WK-16/18: success/failure light toast, aligns Knowledge.createdToast)
|
||||||
@@ -147,6 +148,14 @@ export default {
|
|||||||
// ⑥ Status change confirm (terminal promoted/rejected/archived or leaving approved)
|
// ⑥ Status change confirm (terminal promoted/rejected/archived or leaving approved)
|
||||||
confirmStatus: 'Change idea "{title}" status from {from} to {to}?',
|
confirmStatus: 'Change idea "{title}" status from {from} to {to}?',
|
||||||
promoteFailed: 'Promotion failed',
|
promoteFailed: 'Promotion failed',
|
||||||
|
// Archive P1: idea archive box
|
||||||
|
filterArchived: '📦 Archive',
|
||||||
|
archiveBoxEmpty: 'No archived ideas',
|
||||||
|
quickArchive: '📦 Archive',
|
||||||
|
quickUnarchive: '📤 Unarchive',
|
||||||
|
confirmArchive: 'Archive idea "{title}"? It will leave the main view; find it in the Archive box.',
|
||||||
|
archivedToast: 'Archived',
|
||||||
|
unarchivedToast: 'Unarchived',
|
||||||
evalFailed: 'Evaluation failed',
|
evalFailed: 'Evaluation failed',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,14 @@ export default {
|
|||||||
quickStatus: 'Status',
|
quickStatus: 'Status',
|
||||||
quickPriority: 'Priority',
|
quickPriority: 'Priority',
|
||||||
quickDelete: '🗑 Delete',
|
quickDelete: '🗑 Delete',
|
||||||
|
// Archive P1: archive moves out of main view, keep searchable; delete moves to trash
|
||||||
|
quickArchive: '📦 Archive',
|
||||||
|
quickUnarchive: '📤 Unarchive',
|
||||||
|
confirmArchive: 'Archive task "{title}"? It will leave the main view; find it in the Archive box.',
|
||||||
|
archivedToast: 'Archived',
|
||||||
|
unarchivedToast: 'Unarchived',
|
||||||
|
archiveBox: '📦 Archive',
|
||||||
|
archiveBoxEmpty: 'No archived tasks',
|
||||||
|
|
||||||
// Batch action bar (shared by Tasks / ProjectDetail / TaskDetail subtasks)
|
// Batch action bar (shared by Tasks / ProjectDetail / TaskDetail subtasks)
|
||||||
batch: {
|
batch: {
|
||||||
@@ -129,6 +137,7 @@ export default {
|
|||||||
createFailed: 'Failed to create task',
|
createFailed: 'Failed to create task',
|
||||||
deleteFailed: 'Failed to delete task',
|
deleteFailed: 'Failed to delete task',
|
||||||
updateFailed: 'Failed to update task',
|
updateFailed: 'Failed to update task',
|
||||||
|
archiveFailed: 'Archive operation failed',
|
||||||
selectProject: 'Please select a project',
|
selectProject: 'Please select a project',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -112,12 +112,12 @@ export default {
|
|||||||
clearSkill: '取消技能',
|
clearSkill: '取消技能',
|
||||||
skillBadgeTip: '已注入技能「{name}」的完整规程',
|
skillBadgeTip: '已注入技能「{name}」的完整规程',
|
||||||
placeholderWithSkill: '已选技能,输入参数后回车',
|
placeholderWithSkill: '已选技能,输入参数后回车',
|
||||||
placeholderDefault: "输入消息...(/ 触发技能,{'@'} 引用实体)",
|
placeholderDefault: "输入消息...(/ 触发技能,@ 引用实体)",
|
||||||
skillNoMatch: '无匹配技能',
|
skillNoMatch: '无匹配技能',
|
||||||
skillNotLoaded: '未读取到本机技能',
|
skillNotLoaded: '未读取到本机技能',
|
||||||
|
|
||||||
// ── @ 实体引用(@ 触发 popover,选中插入 [类型:名] 标记) ──
|
// ── @ 实体引用(@ 触发 popover,选中插入 [类型:名] 标记) ──
|
||||||
mentionHint: "{'@'} 引用项目/任务",
|
mentionHint: "@ 引用项目/任务",
|
||||||
mentionGroupProject: '项目',
|
mentionGroupProject: '项目',
|
||||||
mentionGroupTask: '任务',
|
mentionGroupTask: '任务',
|
||||||
mentionGroupIdea: '灵感',
|
mentionGroupIdea: '灵感',
|
||||||
@@ -208,7 +208,7 @@ export default {
|
|||||||
|
|
||||||
// ── Input Augmentation 层: mention/skill 注入反馈 ──
|
// ── Input Augmentation 层: mention/skill 注入反馈 ──
|
||||||
// search_files 盲搜被白名单兜底拒绝(不应弹授权窗,直接友好提示用户改用 @引用或绝对路径)
|
// search_files 盲搜被白名单兜底拒绝(不应弹授权窗,直接友好提示用户改用 @引用或绝对路径)
|
||||||
searchFilesBlocked: "search_files 仅在已授权目录可用,请用 {'@'}[项目] 引用或提供绝对路径",
|
searchFilesBlocked: "search_files 仅在已授权目录可用,请用 @[项目] 引用或提供绝对路径",
|
||||||
// /技能 热重载(ai_reload_skills IPC)成功后提示
|
// /技能 热重载(ai_reload_skills IPC)成功后提示
|
||||||
skillReloaded: '技能已重新加载',
|
skillReloaded: '技能已重新加载',
|
||||||
// @引用 实体 resolve 失败(id 指向的项目/任务/灵感/技能已被删除或不可用)
|
// @引用 实体 resolve 失败(id 指向的项目/任务/灵感/技能已被删除或不可用)
|
||||||
@@ -289,7 +289,7 @@ export default {
|
|||||||
// ── TopBar 目标/项目上下文标签 ──
|
// ── TopBar 目标/项目上下文标签 ──
|
||||||
goalsCount: '目标({n})',
|
goalsCount: '目标({n})',
|
||||||
none: '无',
|
none: '无',
|
||||||
projectLabel: "{'@'}项目",
|
projectLabel: "@项目",
|
||||||
|
|
||||||
// ── 3c UX 批追加(I5 发送失败错误分类文案):按错误内容映射友好提示,
|
// ── 3c UX 批追加(I5 发送失败错误分类文案):按错误内容映射友好提示,
|
||||||
// {msg} 保留原始错误串供排查,不丢线索 ──
|
// {msg} 保留原始错误串供排查,不丢线索 ──
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export default {
|
|||||||
// 工具执行失败视觉标识(AR-6 下 status=completed,需红色区分于成功)
|
// 工具执行失败视觉标识(AR-6 下 status=completed,需红色区分于成功)
|
||||||
executionFailed: '执行失败',
|
executionFailed: '执行失败',
|
||||||
executionFailedHint: '工具执行失败,详情见下方',
|
executionFailedHint: '工具执行失败,详情见下方',
|
||||||
|
// R6:工具结果路径可点击复制(hover title 后半段)
|
||||||
|
clickToCopy: '点击复制完整路径',
|
||||||
collapse: '收起',
|
collapse: '收起',
|
||||||
expand: '展开',
|
expand: '展开',
|
||||||
written: '已写入',
|
written: '已写入',
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export default {
|
|||||||
createFailed: '创建灵感失败',
|
createFailed: '创建灵感失败',
|
||||||
deleteFailed: '删除灵感失败',
|
deleteFailed: '删除灵感失败',
|
||||||
relateFailed: '更新关联关系失败',
|
relateFailed: '更新关联关系失败',
|
||||||
|
archiveFailed: '归档操作失败',
|
||||||
},
|
},
|
||||||
|
|
||||||
// 操作反馈 toast(WK-16/18:成功/失败轻提示,对齐 Knowledge.createdToast)
|
// 操作反馈 toast(WK-16/18:成功/失败轻提示,对齐 Knowledge.createdToast)
|
||||||
@@ -147,6 +148,14 @@ export default {
|
|||||||
// ⑥ 状态切换确认(终态 promoted/rejected/archived 或离开 approved 失去立项入口)
|
// ⑥ 状态切换确认(终态 promoted/rejected/archived 或离开 approved 失去立项入口)
|
||||||
confirmStatus: '将灵感「{title}」状态从 {from} 改为 {to}?',
|
confirmStatus: '将灵感「{title}」状态从 {from} 改为 {to}?',
|
||||||
promoteFailed: '立项失败',
|
promoteFailed: '立项失败',
|
||||||
|
// 归档功能 P1(灵感归档箱)
|
||||||
|
filterArchived: '📦 归档箱',
|
||||||
|
archiveBoxEmpty: '暂无归档灵感',
|
||||||
|
quickArchive: '📦 归档到归档箱',
|
||||||
|
quickUnarchive: '📤 取消归档',
|
||||||
|
confirmArchive: '归档灵感「{title}」?归档后移出主视图,可在归档箱查看。',
|
||||||
|
archivedToast: '已归档',
|
||||||
|
unarchivedToast: '已取消归档',
|
||||||
evalFailed: '评估失败',
|
evalFailed: '评估失败',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,14 @@ export default {
|
|||||||
quickStatus: '状态',
|
quickStatus: '状态',
|
||||||
quickPriority: '优先级',
|
quickPriority: '优先级',
|
||||||
quickDelete: '🗑 删除',
|
quickDelete: '🗑 删除',
|
||||||
|
// 归档功能 P1(归档=移出主视图保留检索,区别于删除=进回收站)
|
||||||
|
quickArchive: '📦 归档',
|
||||||
|
quickUnarchive: '📤 取消归档',
|
||||||
|
confirmArchive: '归档任务「{title}」?归档后移出主视图,可在归档箱查看。',
|
||||||
|
archivedToast: '已归档',
|
||||||
|
unarchivedToast: '已取消归档',
|
||||||
|
archiveBox: '📦 归档箱',
|
||||||
|
archiveBoxEmpty: '暂无归档任务',
|
||||||
|
|
||||||
// 批量操作栏(三处任务列表共用:Tasks / ProjectDetail / TaskDetail 子任务)
|
// 批量操作栏(三处任务列表共用:Tasks / ProjectDetail / TaskDetail 子任务)
|
||||||
batch: {
|
batch: {
|
||||||
@@ -129,6 +137,7 @@ export default {
|
|||||||
createFailed: '创建任务失败',
|
createFailed: '创建任务失败',
|
||||||
deleteFailed: '删除任务失败',
|
deleteFailed: '删除任务失败',
|
||||||
updateFailed: '更新任务失败',
|
updateFailed: '更新任务失败',
|
||||||
|
archiveFailed: '归档操作失败',
|
||||||
selectProject: '请选择项目',
|
selectProject: '请选择项目',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -179,6 +179,7 @@ function createStore() {
|
|||||||
createTask: tasksStore.createTask,
|
createTask: tasksStore.createTask,
|
||||||
updateTask: tasksStore.updateTask,
|
updateTask: tasksStore.updateTask,
|
||||||
deleteTask: tasksStore.deleteTask,
|
deleteTask: tasksStore.deleteTask,
|
||||||
|
archiveTask: tasksStore.archiveTask,
|
||||||
// P1-b A0:统计独立通道(Dashboard 卡片 + ActiveProjectsPanel 各项目任务数)
|
// P1-b A0:统计独立通道(Dashboard 卡片 + ActiveProjectsPanel 各项目任务数)
|
||||||
loadStats,
|
loadStats,
|
||||||
// idea actions
|
// idea actions
|
||||||
@@ -186,6 +187,7 @@ function createStore() {
|
|||||||
createIdea: ideasStore.createIdea,
|
createIdea: ideasStore.createIdea,
|
||||||
updateIdea: ideasStore.updateIdea,
|
updateIdea: ideasStore.updateIdea,
|
||||||
deleteIdea: ideasStore.deleteIdea,
|
deleteIdea: ideasStore.deleteIdea,
|
||||||
|
archiveIdea: ideasStore.archiveIdea,
|
||||||
evaluateIdea: ideasStore.evaluateIdea,
|
evaluateIdea: ideasStore.evaluateIdea,
|
||||||
promoteIdea: ideasStore.promoteIdea,
|
promoteIdea: ideasStore.promoteIdea,
|
||||||
relateIdeas: ideasStore.relateIdeas,
|
relateIdeas: ideasStore.relateIdeas,
|
||||||
|
|||||||
@@ -70,6 +70,19 @@ export function createIdeasStore() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 归档/取消归档灵感(归档功能 P1)。归档=移出主视图(list_by_query 恒带 archived=0),
|
||||||
|
* 本地同步从列表移除(取消归档时调 loadIdeas 重拉)。返回后端命中布尔。
|
||||||
|
*/
|
||||||
|
async function archiveIdea(id: string, archived: boolean): Promise<boolean> {
|
||||||
|
const ok = await runWithCatch(state, t('ideas.err.archiveFailed'), async () => {
|
||||||
|
const hit = await ideaApi.archive(id, archived)
|
||||||
|
if (hit && archived) state.ideas = state.ideas.filter(x => x.id !== id)
|
||||||
|
return hit
|
||||||
|
})
|
||||||
|
return ok === true
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
loadIdeas,
|
loadIdeas,
|
||||||
createIdea,
|
createIdea,
|
||||||
@@ -78,5 +91,6 @@ export function createIdeasStore() {
|
|||||||
evaluateIdea,
|
evaluateIdea,
|
||||||
promoteIdea,
|
promoteIdea,
|
||||||
relateIdeas,
|
relateIdeas,
|
||||||
|
archiveIdea,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,10 +69,24 @@ export function createTasksStore() {
|
|||||||
return ok === true
|
return ok === true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 归档/取消归档任务(归档功能 P1)。归档=移出主视图(后端活跃查询恒过滤),
|
||||||
|
* 本地同步从列表移除(取消归档时调 loadTasks 重拉)。返回后端命中布尔。
|
||||||
|
*/
|
||||||
|
async function archiveTask(id: string, archived: boolean): Promise<boolean> {
|
||||||
|
const ok = await runWithCatch(state, t('tasks.err.archiveFailed'), async () => {
|
||||||
|
const hit = await taskApi.archive(id, archived)
|
||||||
|
if (hit && archived) state.tasks = state.tasks.filter(x => x.id !== id)
|
||||||
|
return hit
|
||||||
|
})
|
||||||
|
return ok === true
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
loadTasks,
|
loadTasks,
|
||||||
createTask,
|
createTask,
|
||||||
updateTask,
|
updateTask,
|
||||||
deleteTask,
|
deleteTask,
|
||||||
|
archiveTask,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||||
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'
|
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'
|
||||||
import { toolDisplayName } from '@/composables/ai/useToolCardHeader'
|
import { toolDisplayName } from '@/composables/ai/useToolCardHeader'
|
||||||
import { dispatchApprovalIPC } from '@/composables/ai/useToolApproval'
|
import { dispatchApprovalIPC, isHighRiskTool, highRiskConfirmText } from '@/composables/ai/useToolApproval'
|
||||||
import { ApprovalPopupEvents } from '@/composables/ai/useApprovalPopup'
|
import { ApprovalPopupEvents } from '@/composables/ai/useApprovalPopup'
|
||||||
import { formatWaitTime, waitLevelOf } from '@/utils/time'
|
import { formatWaitTime, waitLevelOf } from '@/utils/time'
|
||||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||||
@@ -170,20 +170,24 @@ const { toast, showToast } = useToast()
|
|||||||
/** 拖动态(样式反馈) */
|
/** 拖动态(样式反馈) */
|
||||||
const isDragging = ref(false)
|
const isDragging = ref(false)
|
||||||
|
|
||||||
// ── M5:path 类 deny 二次确认(独立窗口本地 confirm;拒绝不可逆——工具返 Err、loop 续跑不再重试)──
|
// ── M5/V4:浮窗本地二次确认(独立窗口本地 confirm;与主窗口 JS 上下文隔离,不复用 useConfirm)──
|
||||||
// 浮窗是独立 WebviewWindow,与主窗口 JS 上下文隔离,不复用 useConfirm 跨窗口 reactive。
|
// M5:path 类 deny(拒绝不可逆——工具返 Err、loop 续跑不再重试)。
|
||||||
// pendingDenyTc 持有「待确认拒绝」的 tc;answerConfirm 据 result 决定续跑 runApproval 或放弃。
|
// V4:High 风险工具 risk 批准前置确认(对齐 ToolCard 安全语义:注意力分散场景数字键「1」
|
||||||
const confirmState = reactive<{ visible: boolean; msg: string; tc: AiToolCallInfo | null }>({
|
// 单键放行 run_command/delete_file 太危险)。action 记录「待确认动作」供 answerConfirm 分派。
|
||||||
|
const confirmState = reactive<{ visible: boolean; msg: string; tc: AiToolCallInfo | null; action: 'deny' | 'approve' }>({
|
||||||
visible: false,
|
visible: false,
|
||||||
msg: '',
|
msg: '',
|
||||||
tc: null,
|
tc: null,
|
||||||
|
action: 'deny',
|
||||||
})
|
})
|
||||||
function answerConfirm(ok: boolean) {
|
function answerConfirm(ok: boolean) {
|
||||||
const tc = confirmState.tc
|
const tc = confirmState.tc
|
||||||
|
const action = confirmState.action
|
||||||
confirmState.visible = false
|
confirmState.visible = false
|
||||||
confirmState.tc = null
|
confirmState.tc = null
|
||||||
if (ok && tc) {
|
if (ok && tc) {
|
||||||
void runApproval(tc, false, 'deny')
|
if (action === 'approve') void runApproval(tc, true)
|
||||||
|
else void runApproval(tc, false, 'deny')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,8 +410,16 @@ async function onApproveSession(tc: AiToolCallInfo) {
|
|||||||
async function onApproveAlways(tc: AiToolCallInfo) {
|
async function onApproveAlways(tc: AiToolCallInfo) {
|
||||||
await runApproval(tc, false, 'always')
|
await runApproval(tc, false, 'always')
|
||||||
}
|
}
|
||||||
/** risk 类:批准(approved=true) */
|
/** risk 类:批准(approved=true)。V4:High 风险工具先二次确认(按钮与数字键 1 同走本入口,
|
||||||
|
* 确认框打开时数字键守卫生效,不绕确认;拒绝仍按 M5 原逻辑)。 */
|
||||||
async function onApprove(tc: AiToolCallInfo) {
|
async function onApprove(tc: AiToolCallInfo) {
|
||||||
|
if (isHighRiskTool(tc.name)) {
|
||||||
|
confirmState.msg = highRiskConfirmText(tc.name)
|
||||||
|
confirmState.tc = tc
|
||||||
|
confirmState.action = 'approve'
|
||||||
|
confirmState.visible = true
|
||||||
|
return
|
||||||
|
}
|
||||||
await runApproval(tc, true)
|
await runApproval(tc, true)
|
||||||
}
|
}
|
||||||
/** path 类 deny / risk 类 reject(统一入口,kind 由 dispatchApprovalIPC 据 tc.kind 分派)。
|
/** path 类 deny / risk 类 reject(统一入口,kind 由 dispatchApprovalIPC 据 tc.kind 分派)。
|
||||||
|
|||||||
+48
-4
@@ -54,7 +54,7 @@
|
|||||||
<div class="idea-list">
|
<div class="idea-list">
|
||||||
<!-- 三态:加载/空(错误改走顶部 error-banner,WK-7 不整表替换列表) -->
|
<!-- 三态:加载/空(错误改走顶部 error-banner,WK-7 不整表替换列表) -->
|
||||||
<div v-if="store.loading" class="empty-state">{{ $t('common.loading') }}</div>
|
<div v-if="store.loading" class="empty-state">{{ $t('common.loading') }}</div>
|
||||||
<div v-else-if="filteredIdeas.length === 0" class="empty-state">{{ $t('ideas.listEmpty') }}</div>
|
<div v-else-if="filteredIdeas.length === 0" class="empty-state">{{ activeFilter === 'archived' ? $t('ideas.archiveBoxEmpty') : $t('ideas.listEmpty') }}</div>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div
|
<div
|
||||||
v-for="idea in pagedIdeas"
|
v-for="idea in pagedIdeas"
|
||||||
@@ -93,9 +93,11 @@
|
|||||||
:eval-error="evalError"
|
:eval-error="evalError"
|
||||||
:promoting="promoting"
|
:promoting="promoting"
|
||||||
:deleting="deleting"
|
:deleting="deleting"
|
||||||
|
:in-archive-box="activeFilter === 'archived'"
|
||||||
@evaluate="evaluateCurrentIdea"
|
@evaluate="evaluateCurrentIdea"
|
||||||
@promote="promoteToProject"
|
@promote="promoteToProject"
|
||||||
@delete="deleteCurrentIdea"
|
@delete="deleteCurrentIdea"
|
||||||
|
@archive="archiveCurrentIdea"
|
||||||
@status-change="onStatusChange"
|
@status-change="onStatusChange"
|
||||||
@update-desc="onUpdateDesc"
|
@update-desc="onUpdateDesc"
|
||||||
@update-related="onUpdateRelated"
|
@update-related="onUpdateRelated"
|
||||||
@@ -160,7 +162,8 @@ import { useRouter, useRoute } from 'vue-router'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { Message } from '@arco-design/web-vue'
|
import { Message } from '@arco-design/web-vue'
|
||||||
import { useProjectStore } from '../stores/project'
|
import { useProjectStore } from '../stores/project'
|
||||||
import type { IdeaStatus, IdeaQuery } from '../api/types'
|
import type { IdeaRecord, IdeaStatus, IdeaQuery } from '../api/types'
|
||||||
|
import { ideaApi } from '../api'
|
||||||
import { formatDate } from '../utils/time'
|
import { formatDate } from '../utils/time'
|
||||||
import { stripMd } from '../utils/markdown'
|
import { stripMd } from '../utils/markdown'
|
||||||
import { scoreTier } from '../utils/ideaEval'
|
import { scoreTier } from '../utils/ideaEval'
|
||||||
@@ -180,7 +183,7 @@ const store = useProjectStore()
|
|||||||
// 确认弹层状态机抽至 composables/useConfirm(原 4 视图重复:Projects/ProjectDetail/Ideas/Settings)
|
// 确认弹层状态机抽至 composables/useConfirm(原 4 视图重复:Projects/ProjectDetail/Ideas/Settings)
|
||||||
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
|
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
|
||||||
|
|
||||||
type FilterKey = 'all' | 'hot' | 'pending' | 'promoted'
|
type FilterKey = 'all' | 'hot' | 'pending' | 'promoted' | 'archived'
|
||||||
|
|
||||||
// ⑤ 列表筛选/排序/分页状态持久化 localStorage(对齐 Tasks.vue:193-201 用 usePersistedRef),
|
// ⑤ 列表筛选/排序/分页状态持久化 localStorage(对齐 Tasks.vue:193-201 用 usePersistedRef),
|
||||||
// key 前缀 'ideas.',刷新页面/重开恢复用户的筛选视图。selectedId 不持久化(随路由恢复)。
|
// key 前缀 'ideas.',刷新页面/重开恢复用户的筛选视图。selectedId 不持久化(随路由恢复)。
|
||||||
@@ -212,6 +215,8 @@ const filters: { key: FilterKey; labelKey: string; icon: string }[] = [
|
|||||||
{ key: 'hot', labelKey: 'ideas.filter.hot', icon: '🔥' },
|
{ key: 'hot', labelKey: 'ideas.filter.hot', icon: '🔥' },
|
||||||
{ key: 'pending', labelKey: 'ideas.filter.pending', icon: '⏳' },
|
{ key: 'pending', labelKey: 'ideas.filter.pending', icon: '⏳' },
|
||||||
{ key: 'promoted', labelKey: 'ideas.filter.promoted', icon: '🚀' },
|
{ key: 'promoted', labelKey: 'ideas.filter.promoted', icon: '🚀' },
|
||||||
|
// 归档功能 P1:归档箱(独立数据源 listArchived,不走主查询)
|
||||||
|
{ key: 'archived', labelKey: 'ideas.filterArchived', icon: '📦' },
|
||||||
]
|
]
|
||||||
|
|
||||||
// ── 状态映射(单一数据源在 @/constants/project,列表/详情/下拉共用) ──
|
// ── 状态映射(单一数据源在 @/constants/project,列表/详情/下拉共用) ──
|
||||||
@@ -237,9 +242,21 @@ function buildIdeaQuery(): IdeaQuery {
|
|||||||
return q
|
return q
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 归档功能 P1:归档箱独立数据源(切到归档箱 filter 时拉取;其余 filter 保持 null 不请求)
|
||||||
|
const archivedIdeas = ref<IdeaRecord[]>([])
|
||||||
|
|
||||||
// 按当前视图查询加载(登记 setActiveIdeaQuery 供 df-data-changed 联动刷新复用同一筛选,
|
// 按当前视图查询加载(登记 setActiveIdeaQuery 供 df-data-changed 联动刷新复用同一筛选,
|
||||||
// 避免 promote/relate/监听触发无参 loadIdeas 全量冲掉当前筛选视图)
|
// 避免 promote/relate/监听触发无参 loadIdeas 全量冲掉当前筛选视图)
|
||||||
function loadCurrentView() {
|
// 归档箱模式:拉归档数据源,主查询不动。
|
||||||
|
async function loadCurrentView() {
|
||||||
|
if (activeFilter.value === 'archived') {
|
||||||
|
try {
|
||||||
|
archivedIdeas.value = await ideaApi.listArchived()
|
||||||
|
} catch (e: any) {
|
||||||
|
Message.error(e?.toString() ?? t('ideas.err.loadFailed'))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
store.setActiveIdeaQuery(buildIdeaQuery())
|
store.setActiveIdeaQuery(buildIdeaQuery())
|
||||||
return store.loadIdeas(buildIdeaQuery())
|
return store.loadIdeas(buildIdeaQuery())
|
||||||
}
|
}
|
||||||
@@ -272,6 +289,9 @@ watch(sortMode, () => { page.value = 1; void reloadIdeas(true) })
|
|||||||
watch(searchQuery, () => { page.value = 1; void reloadIdeas(false) })
|
watch(searchQuery, () => { page.value = 1; void reloadIdeas(false) })
|
||||||
|
|
||||||
const filteredIdeas = computed(() => {
|
const filteredIdeas = computed(() => {
|
||||||
|
// 归档箱模式:整列表切归档数据源
|
||||||
|
if (activeFilter.value === 'archived') return archivedIdeas.value
|
||||||
|
|
||||||
let ideas = store.ideas
|
let ideas = store.ideas
|
||||||
|
|
||||||
// hot(按 score≥80)/pending(多状态 draft+pending_review)保留前端 filter:
|
// hot(按 score≥80)/pending(多状态 draft+pending_review)保留前端 filter:
|
||||||
@@ -380,6 +400,30 @@ async function deleteCurrentIdea() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 归档功能 P1:归档/取消归档当前灵感(归档箱内取消归档后自动切回 all 重载)。
|
||||||
|
async function archiveCurrentIdea(archived: boolean) {
|
||||||
|
if (!currentIdea.value || deleting.value) return
|
||||||
|
if (archived && !await confirmDialog(t('ideas.confirmArchive', { title: currentIdea.value.title }))) return
|
||||||
|
deleting.value = true
|
||||||
|
try {
|
||||||
|
const ok = await store.archiveIdea(currentIdea.value.id, archived)
|
||||||
|
if (!ok) { Message.error(t('ideas.err.archiveFailed')); return }
|
||||||
|
Message.success(archived ? t('ideas.archivedToast') : t('ideas.unarchivedToast'))
|
||||||
|
if (activeFilter.value === 'archived') {
|
||||||
|
// 归档箱内:本地移除 + 选中态清理
|
||||||
|
archivedIdeas.value = archivedIdeas.value.filter(i => i.id !== currentIdea.value?.id)
|
||||||
|
if (selectedId.value === currentIdea.value?.id) selectedId.value = null
|
||||||
|
} else {
|
||||||
|
// 主列表归档成功:store 已本地移除,清选中(归档项不再可见)
|
||||||
|
selectedId.value = null
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
Message.error(e?.toString() ?? t('ideas.err.archiveFailed'))
|
||||||
|
} finally {
|
||||||
|
deleting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function promoteToProject() {
|
async function promoteToProject() {
|
||||||
if (!currentIdea.value || promoting.value) return
|
if (!currentIdea.value || promoting.value) return
|
||||||
promoting.value = true
|
promoting.value = true
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user