Compare commits

...
12 Commits
Author SHA1 Message Date
lxy 8e1e56b3e5 优化: 打包提速与换装脚本
- Cargo.toml release profile 开 incremental(小改动重打包显著提速,正式发版在意体积可临时注释)
- 新增 scripts/package-devflow.ps1 双通道打包脚本:verify 快速编译验证/full 全量发版换装,含磁盘预检与启动验证
2026-10-05 00:35:05 +08:00
lxy 26cc3c3df9 文档: 能力批设计收口与走查状态同步
- DRY技术债走查报告:状态表同步 12/12 全收口,头部注记状态表为走查快照
- 并行化与异步唤醒设计:补 §13.4 有界失败记忆(窗口折叠/失败分册按类去重/监控轮转,三界类目口径同源)
- todo:登记 GitHub 直连受限替代方案(失败学习环首条)+F-m 实施细节补充
2026-10-05 00:35:04 +08:00
lxy 753de25696 重构: DRY截断正典改名收尾
- audit/utils.rs truncate_chars→truncate_chars_ellipsis 正典改名(补…语义),re-export 链同步
- record.rs/generate_image.rs/knowledge_inject.rs/tools/knowledge.rs 调用点全链更新
- df-ai context_helpers.rs 私有版跨 crate 不引依赖保留原名,加正典标注注释
- DRY #11 收口,同名不同义陷阱消除
2026-10-05 00:35:04 +08:00
lxy 6a50193b34 优化: 狗粮短板收口与patch_file增强
- patch_file old_text 精确未中时空白归一化回退(行首缩进归一+多空白折叠,唯一命中按整行区间替换,warning 注明)
- patch_file anchor end 唯一性校验:多处命中报错并列前两处位置,治实测×2 锚点错位;三模式同传按优先级仲裁(anchor>old_text>replace_lines),warning 注明
- tool_result 压缩 memoization:PerConvState 加 tool_compress_cache(内容哈希→压缩结果),主 loop+子代理双调用点接线,治 369 组同内容反复重压
- 标题采样改两段式:user 首条必进锚定主题+其余按序补足 6 条,治 assistant 首句被当主题
- prompt 注入文件读取纪律段(父会话+子代理同源):read_file 分页/search 直读,禁 run_command 切片读文件;read_file 工具描述与 truncated 提示补大 limit 建议
- 顺带 DRY#1:agentic/mod.rs 三处裸 emit 换正典双写(心跳/破坏性回环熔断/stall 熔断),miniapp 跨端盲区补齐
2026-10-05 00:35:04 +08:00
lxy c83ea0c315 新增: advance_task auto_path跳态自动推进
- 跳态目标按 shortest_path 自动逐跳推进(advance_task_auto_path),每跳独立校验+review_rounds 回归计数照旧
- 直达单跳与严格路径行为一致;workflow.rs 回调等机器生成单跳场景仍直用 advance_task_atomic 保持严格语义
- update_task 工具描述与 status 拦截错误文案同步 auto_path 语义
- 治 LLM 反复请求跳态推进被状态机拒绝的空耗往返
2026-10-05 00:35:03 +08:00
lxy c10f0e46f0 新增: aichat前端体验四件与队列残留收口
- R4 代码块语言角标:MessageList decorateCodeHtml 捕获 language-xxx 输出 data-lang+CSS ::before 左上角标,hover 显,无语言裸块不渲染
- R6 工具结果路径可复制:ToolResultBody 五处 file-bar 的 path span→button(read_file/list_directory/search_files/grep/patch_file),点击复制完整路径+✓/✕ 1.2s 反馈+execCommand 降级链
- I6 重新生成 UI 入口:MessageItem user 气泡 hover 补重新生成按钮(canRegenerate=isLastUser&&非流式),复用 handleRetry→store.regenerate 链路
- V4 浮窗审批 High 风险二次确认:useToolApproval 导出 isHighRiskTool/highRiskConfirmText,ApprovalPopup.onApprove 前置 ConfirmDialog,数字键守卫不绕确认
- 9-28#5 两处全局清队列残留:useAiEvents/useAiLifecycleEvents 无 convId 的 AiError 分支改保留队列+console.warn(防误伤其他会话排队消息)
- i18n zh/en 补 clickToCopy 键;裸 @ 改 @ 转义(vue-i18n linked-message 前缀吞噬)
2026-10-05 00:35:03 +08:00
lxy 83e86cd6b7 新增: 灵感归档箱与任务归档动线
- ideasStore/taskStore: archiveIdea/archiveTask IPC 封装(本地移除/本地更新,对齐 delete 语义)
- project.ts 组合 store 显式透传 archiveIdea/archiveTask
- Ideas.vue: 筛选栏归档箱 Tab(独立数据源 ideaApi.listArchived,主查询不受扰);archiveCurrentIdea 处理器(归档确认+归档箱内取消归档自动清选中);IdeaDetail 传 in-archive-box prop
- IdeaDetail.vue: inArchiveBox prop + archive emit;操作区归档/取消归档按钮,归档箱模式隐藏删除(防误删归档记忆)
- i18n zh-CN/en ideas 补 filterArchived/archiveBoxEmpty/quickArchive/quickUnarchive/confirmArchive/toast 键(前批遗留)
验收: vue-tsc --noEmit EXIT 0
2026-10-05 00:35:02 +08:00
lxy c6ebf15407 文档: 能力建设批设计归档 2026-10-05 00:35:01 +08:00
lxy f4d31e884a 新增: 任务灵感归档后端 2026-10-05 00:35:01 +08:00
lxy c8c13d302a 新增: LLM目标列表主动修正 2026-10-05 00:35:01 +08:00
lxy 7dea2b86c4 优化: Batch.md 状态同步 2026-10-05 00:35:00 +08:00
lxy e492f03191 新增: df-im-gateway 网关骨架 2026-10-05 00:35:00 +08:00
101 changed files with 6203 additions and 225 deletions
+6
View File
@@ -94,3 +94,9 @@ devflow-dev.db-wal
apps/df-miniapp/src/manifest.json
# 微信小程序 appid(真实 appid 本地保留,不提交仓库)
temp/
# 本地工具链缓存(protoc 等,不入库)
.tools/
# df-im-gateway 运行时数据(冒烟测试残留)
crates/df-im-gateway/data/
+8 -8
View File
@@ -1,7 +1,7 @@
# DevFlow 批次推进记录
> 记录每个批次的提交 hash、改动内容和交付价值。
> 最后更新: 2026-07-02 | 最新提交: `4624688`
最后更新: 2026-10-04 | 最新提交: `74ce9f7`(df-im-gateway 网关骨架 P0)
---
@@ -443,7 +443,7 @@
## Batch 38 — 合并产出落回主对话(Coordinator 接线)
- **提交**: 待后续
- **提交**: `eee0f06`(新增: 合并产出落回主对话(Coordinator 接线))
- **内容**:
- `run_agentic_loop`:plan_execution_enabled 时,decompose 后调 dispatch
→ merge → 合并产出以单条 assistant 消息推回主对话,emit AiCompleted 后 return
@@ -490,9 +490,9 @@
| 3 | ConflictResolver.vue(双栏 diff + 接受A/B/合并/手动按钮) | ✅ |
| 4 | i18n 冲突翻译键(中英文) | ✅ |
| 5 | agentic/mod.rs 拆分(提取自动压缩到 context_lifecycle.rs,2222→2071行) | ✅ |
| 6 | Reviewer Agent 仲裁(persona.rs 扩展) | 待下一批 |
| 7 | 命令互斥锁(run_command 同目录 mutex) | 待下一批 |
| 8 | context.rs 拆分(1956行) | 待下一批 |
| 6 | Reviewer Agent 仲裁(persona.rs 扩展) | ✅(批次C前序散项) |
| 7 | 命令互斥锁(run_command 同目录 mutex) | ✅(批次C前序散项) |
| 8 | context.rs 拆分(1956行) | ✅(批次C前序散项) |
### 模板系统 + 节点补齐 ✅
@@ -508,8 +508,8 @@
| 4 | NotifyNode(桌面通知 + Webhook) | ✅ |
| 5 | SubflowNode(嵌套子工作流 + 深度限制) | ✅ |
| 6 | 内置模板 3 个预设(代码审查/Bug修复/功能开发) | ✅ |
| 7 | 前端模板选择入口 + 模板 CRUD IPC | 待下一批 |
| 8 | MessageList.vue 继续拆分 | 待下一批 |
| 7 | 前端模板选择入口 + 模板 CRUD IPC | ✅(批次C前序散项) |
| 8 | MessageList.vue 继续拆分 | ✅(批次C前序散项,1386→1148行) |
### Git/CI 集成 + DockerNode + God 文件收尾 ✅
@@ -544,7 +544,7 @@
| 6 | AiChat 进度条(completedTools 计数器) | ✅ |
| 7 | df-types augmentation 测试修复 | ✅ |
| 8 | 前端 UI 审查修复(badge/confirm/加载态/CSS) | ✅ |
| 9 | 合并产出落回主对话 | 待接线 |
| 9 | 合并产出落回主对话 | ✅ `eee0f06`(Batch 38) |
---
Generated
+496 -12
View File
@@ -8,6 +8,17 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "ahash"
version = "0.8.12"
@@ -29,6 +40,40 @@ dependencies = [
"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]]
name = "alloc-no-stdlib"
version = "2.0.4"
@@ -59,6 +104,45 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "async-broadcast"
version = "0.7.2"
@@ -275,7 +359,7 @@ dependencies = [
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sha1",
"sha1 0.10.6",
"sync_wrapper",
"tokio",
"tokio-tungstenite 0.24.0",
@@ -357,6 +441,24 @@ dependencies = [
"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]]
name = "block2"
version = "0.6.2"
@@ -503,6 +605,15 @@ dependencies = [
"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]]
name = "cc"
version = "1.2.63"
@@ -566,6 +677,27 @@ dependencies = [
"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]]
name = "combine"
version = "4.6.7"
@@ -603,6 +735,12 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "const-oid"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "cookie"
version = "0.18.1"
@@ -663,6 +801,12 @@ dependencies = [
"libc",
]
[[package]]
name = "cpubits"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -672,6 +816,15 @@ dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.0"
@@ -706,6 +859,15 @@ dependencies = [
"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]]
name = "cssparser"
version = "0.36.0"
@@ -810,6 +972,20 @@ dependencies = [
"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]]
name = "deranged"
version = "0.5.8"
@@ -948,6 +1124,25 @@ dependencies = [
"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]]
name = "df-mcp"
version = "0.1.0"
@@ -1077,8 +1272,19 @@ version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"block-buffer 0.10.4",
"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]]
@@ -1214,6 +1420,12 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "either"
version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
[[package]]
name = "embed-resource"
version = "3.0.9"
@@ -1361,6 +1573,12 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "fixedbitset"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]]
name = "flate2"
version = "1.1.9"
@@ -1992,6 +2210,15 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "hyper"
version = "1.10.1"
@@ -2252,6 +2479,16 @@ dependencies = [
"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]]
name = "ipnet"
version = "2.12.0"
@@ -2277,6 +2514,15 @@ dependencies = [
"once_cell",
]
[[package]]
name = "itertools"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.18"
@@ -2411,6 +2657,38 @@ dependencies = [
"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]]
name = "lazy_static"
version = "1.5.0"
@@ -2608,6 +2886,12 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -2650,6 +2934,12 @@ dependencies = [
"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]]
name = "native-tls"
version = "0.2.18"
@@ -2710,6 +3000,16 @@ dependencies = [
"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]]
name = "notify-rust"
version = "4.18.1"
@@ -3030,6 +3330,15 @@ dependencies = [
"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]]
name = "once_cell"
version = "1.21.4"
@@ -3173,6 +3482,16 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "phf"
version = "0.13.1"
@@ -3404,6 +3723,58 @@ dependencies = [
"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]]
name = "quick-xml"
version = "0.39.4"
@@ -3771,6 +4142,15 @@ dependencies = [
"semver",
]
[[package]]
name = "rusticata-macros"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
dependencies = [
"nom",
]
[[package]]
name = "rustix"
version = "1.1.4"
@@ -3798,6 +4178,27 @@ dependencies = [
"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]]
name = "rustls-pki-types"
version = "1.14.1"
@@ -4188,8 +4589,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
"cpufeatures 0.2.17",
"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]]
@@ -4199,8 +4611,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
"cpufeatures 0.2.17",
"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]]
@@ -4613,7 +5036,7 @@ dependencies = [
"semver",
"serde",
"serde_json",
"sha2",
"sha2 0.10.9",
"syn 2.0.117",
"tauri-utils",
"thiserror 2.0.18",
@@ -5080,6 +5503,22 @@ dependencies = [
"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]]
name = "tokio-util"
version = "0.7.18"
@@ -5461,7 +5900,7 @@ dependencies = [
"log",
"native-tls",
"rand 0.8.6",
"sha1",
"sha1 0.10.6",
"thiserror 1.0.69",
"utf-8",
]
@@ -5479,11 +5918,30 @@ dependencies = [
"httparse",
"log",
"rand 0.8.6",
"sha1",
"sha1 0.10.6",
"thiserror 1.0.69",
"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]]
name = "typeid"
version = "1.0.3"
@@ -5714,6 +6172,15 @@ dependencies = [
"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]]
name = "wasm-bindgen"
version = "0.2.123"
@@ -6610,7 +7077,7 @@ dependencies = [
"once_cell",
"percent-encoding",
"raw-window-handle",
"sha2",
"sha2 0.10.9",
"soup3",
"tao-macros",
"thiserror 2.0.18",
@@ -6645,6 +7112,23 @@ dependencies = [
"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]]
name = "xdg-home"
version = "1.3.0"
@@ -6715,7 +7199,7 @@ dependencies = [
"rand 0.8.6",
"serde",
"serde_repr",
"sha1",
"sha1 0.10.6",
"static_assertions",
"tracing",
"uds_windows",
+5
View File
@@ -26,3 +26,8 @@ keyring = { version = "3", features = ["windows-native", "apple-native", "linux-
# ConvState 提到 ConvStateStore(DashMap),guard.reset/ai_is_generating 不依赖 session lock,
# 治卡死连环(AiCompleted 延迟/工具后中断/第二条进队列同源根因)。详见 workflow session-lock-redesign。
dashmap = "6"
# release 增量编译:小改动重打包显著提速(2026-10-04 打包提速项);
# 代价为产物略大,正式发版如在意体积可临时注释此段
[profile.release]
incremental = true
+3 -1
View File
@@ -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 {
if s.chars().count() <= max {
return s.to_string();
+26
View File
@@ -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"
+251
View File
@@ -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
}
}
+188
View File
@@ -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 失败"),
}
}
}
+143
View File
@@ -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);
}
}
+350
View File
@@ -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\\)");
}
}
+255
View File
@@ -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");
}
+151
View File
@@ -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");
}
}
+103
View File
@@ -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()));
}
}
+7
View File
@@ -644,6 +644,7 @@ fn create_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
queue,
parent_id,
content_json,
archived: false,
created_at: now.clone(),
updated_at: now,
};
@@ -705,6 +706,8 @@ fn update_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
parent_id: existing.parent_id,
module_id: existing.module_id,
content_json: existing.content_json,
// 归档标记不经 MCP 修改(归档走桌面端 archive_task 专用 IPC,防旁路)
archived: existing.archived,
created_at: existing.created_at,
updated_at: now,
};
@@ -819,6 +822,7 @@ fn create_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
ai_analysis: None,
scores: None,
related_ids: None,
archived: false,
created_at: now.clone(),
updated_at: now,
};
@@ -874,6 +878,7 @@ fn update_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
ai_analysis: existing.ai_analysis,
scores: existing.scores,
related_ids: existing.related_ids.clone(),
archived: existing.archived,
created_at: existing.created_at,
updated_at: now,
};
@@ -1239,6 +1244,7 @@ mod tests {
ai_analysis: None,
scores: None,
related_ids: None,
archived: false,
created_at: now.clone(),
updated_at: now,
};
@@ -1285,6 +1291,7 @@ mod tests {
queue: "todo".to_string(),
parent_id: None,
content_json: None,
archived: false,
created_at: now.clone(),
updated_at: now,
};
+1
View File
@@ -265,6 +265,7 @@ mod tests {
queue: "todo".to_string(),
parent_id: None,
content_json: None,
archived: false,
created_at: "0".to_string(),
updated_at: "0".to_string(),
})
@@ -340,6 +340,7 @@ mod tests {
queue: "todo".to_string(),
parent_id: None,
content_json: None,
archived: false,
created_at: "0".to_string(),
updated_at: "0".to_string(),
})
+188 -3
View File
@@ -8,9 +8,14 @@
//!
//! 两条调用路径复用同一推进逻辑:
//! - 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
//! 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;
@@ -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)
// ============================================================
@@ -200,7 +268,10 @@ pub async fn recompute_parent_status(
/// 推进任务 + 若为子任务则触发父聚合(父聚合失败仅 warn 不阻断,宽容语义)。
///
/// 推进链唯一 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` 重算
/// 父 status(父任务=容器模型,聚合规则见 recompute_parent_status)。父聚合失败仅
/// tracing::warn 不阻断——子任务推进成功是主结果,父 status 漂移可后续修正。
@@ -211,7 +282,16 @@ pub async fn advance_task_with_parent(
id: &str,
target_status: &str,
) -> 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 Err(e) = recompute_parent_status(repo, pid).await {
tracing::warn!(
@@ -394,6 +474,7 @@ mod tests {
queue: "todo".to_string(),
parent_id: None,
content_json: None,
archived: false,
created_at: "0".to_string(),
updated_at: "0".to_string(),
}
@@ -575,6 +656,110 @@ mod tests {
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) 回调推进覆盖
//
+89 -13
View File
@@ -44,11 +44,12 @@ const KNOWLEDGE_COLS_WITH_EMBEDDING: &str = concat!(
/// 定义 + 配合 `IDEA_COL_COUNT` 断言 + 测试 `test_idea_cols_matches_record`,加列漏改即捕获。
///
/// V24 新增 related_ids 列(灵感间关联关系持久化打底),已纳入列清单 + 计数。
/// V47 新增 archived 列(灵感归档,归档功能 P1),已纳入列清单 + 计数(14→15)。
#[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` 字段数(与上面列清单的逗号分隔项数一致,被测试断言)。
#[cfg_attr(not(test), allow(dead_code))]
const IDEA_COL_COUNT: usize = 14;
const IDEA_COL_COUNT: usize = 15;
// ============================================================
// 向量工具 — embedding BLOB 序列化 + 余弦相似度
@@ -123,6 +124,8 @@ fn idea_from_row(row: &Row<'_>) -> std::result::Result<IdeaRecord, rusqlite::Err
ai_analysis: row.get("ai_analysis")?,
scores: row.get("scores")?,
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")?,
updated_at: row.get("updated_at")?,
})
@@ -218,22 +221,22 @@ impl_repo!(
from_row => |row| idea_from_row(row),
insert => |conn, rec| {
conn.execute(
"INSERT INTO ideas (id, title, description, status, priority, score, tags, source, promoted_to, ai_analysis, scores, related_ids, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
"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, ?15)",
params![
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.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| {
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![
rec.title, rec.description, rec.status.as_str(), rec.priority,
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 子句 + 收集参数(按出现顺序绑定占位符)。
// deleted_at IS NULL 恒带(常量条件无占位符),回收站任务不进结果
// (对标 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();
if let Some(s) = &status {
@@ -428,7 +431,7 @@ impl IdeaRepo {
validate_column_name(field, "ideas")?;
let conn = self.conn.clone();
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
);
let id = id.to_owned();
@@ -445,6 +448,54 @@ impl IdeaRepo {
.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`)。
///
/// promote_idea 读-改-写竞态的原子关闭。promote_idea 先建项目再回写灵感,
@@ -622,7 +673,8 @@ impl IdeaRepo {
///
/// 不动 GUI 的无条件 [`update_full`](无 expected 语义,保持现状);本方法仅服务
/// 需要乐观锁版本的调用方(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(
&self,
record: &IdeaRecord,
@@ -635,8 +687,9 @@ impl IdeaRepo {
let guard = conn.blocking_lock();
let affected = guard
.execute(
// 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",
// deleted_at IS NULL:回收站灵感不可被 CAS 覆盖(同 tasks/projects 同族守卫);
// 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![
rec.title, rec.description, rec.status.as_str(), rec.priority,
rec.score, rec.tags, rec.source, rec.promoted_to, rec.ai_analysis,
@@ -1837,7 +1890,7 @@ mod tests {
// ---------- 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 {
IdeaRecord {
id: id.to_string(),
@@ -1852,6 +1905,7 @@ mod tests {
ai_analysis: None,
scores: None,
related_ids: None,
archived: false,
created_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 不应抖动");
}
#[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, &current.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]
async fn idea_update_full_cas_conflict_when_expected_stale() {
let repo = setup_idea_repo().await;
@@ -350,6 +350,7 @@ mod tests {
parent_id: None,
content_json: None,
module_id: None,
archived: false,
created_at: "1700000000000".to_string(),
updated_at: "1700000000000".to_string(),
}
+88 -28
View File
@@ -15,6 +15,20 @@ use crate::models::TaskRecord;
use super::impl_repo;
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 辅助函数
// ============================================================
@@ -52,6 +66,8 @@ fn task_from_row(row: &Row<'_>) -> std::result::Result<TaskRecord, rusqlite::Err
content_json: row.get("content_json")?,
// 工程系统 V41:任务关联具体工程(module_id),18→19 列同步之一。
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")?,
updated_at: row.get("updated_at")?,
})
@@ -138,26 +154,26 @@ impl_repo!(
from_row => |row| task_from_row(row),
insert => |conn, rec| {
conn.execute(
"INSERT INTO tasks (id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, queue, parent_id, content_json, module_id, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)",
"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, ?20)",
params![
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.review_rounds, rec.output_json, rec.idea_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| {
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![
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.review_rounds, rec.output_json, rec.idea_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 || {
let guard = conn.blocking_lock();
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)?;
let rows = stmt
.query_map([], |row| task_from_row(row))
@@ -240,7 +256,7 @@ impl TaskRepo {
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
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)?;
let row = stmt
.query_row(params![id], |row| task_from_row(row))
@@ -285,10 +301,10 @@ impl TaskRepo {
// 返回 None,杜绝回收站任务被推进(D-02 软删语义收口,一处关闭)。
let sql = if bump_rounds {
"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 {
"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
.execute(sql, params![new_status, now, id, expected])
@@ -298,7 +314,7 @@ impl TaskRepo {
}
// 回读更新后的记录(含新 status / 累加后的 review_rounds / 新 updated_at)。
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)?;
let row = stmt
.query_row(params![id], |row| task_from_row(row))
@@ -320,7 +336,7 @@ impl TaskRepo {
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
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)?;
let rows = stmt
.query_map(params![pid], |row| task_from_row(row))
@@ -389,7 +405,7 @@ impl TaskRepo {
// 真参数拿到 ?2 而 params_vec 只有 1 个元素 → rusqlite "needed 2, got 1"。
// 用 params_vec.len()+1 保证 ?N 与参数位置严格对齐(N = 参数序号)。
// 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();
if let Some(pid) = &project_id {
@@ -454,9 +470,7 @@ impl TaskRepo {
// 显式列出全部 19 列(含 V29 queue/parent_id/content_json 与 V41 module_id,
// 不 SELECT deleted_at:TaskRecord 不带该字段,取了 from_row 会因未知列报错)。
let sql = format!(
"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 \
"SELECT {TASK_COLS} \
FROM tasks WHERE {} ORDER BY {} DESC{}",
where_clauses.join(" AND "),
order_col,
@@ -511,7 +525,7 @@ impl TaskRepo {
tokio::task::spawn_blocking(move || {
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();
if let Some(ref pid) = project_id {
@@ -581,7 +595,7 @@ impl TaskRepo {
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
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)?;
let rows = stmt
.query_map(params![pid], |row| task_from_row(row))
@@ -618,7 +632,7 @@ impl TaskRepo {
let mut stmt = guard
.prepare(
"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",
)
.map_err(storage_err)?;
@@ -660,7 +674,7 @@ impl TaskRepo {
let affected = guard
.execute(
"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],
)
.map_err(storage_err)?;
@@ -716,11 +730,6 @@ impl TaskRepo {
let id = id.to_owned();
let new_queue = new_queue.to_owned();
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 || {
let mut guard = conn.blocking_lock();
let tx = guard.transaction().map_err(storage_err)?;
@@ -729,7 +738,7 @@ impl TaskRepo {
let current: Option<TaskRecord> = {
let mut stmt = tx
.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)?;
stmt.query_row(params![id], |row| task_from_row(row))
@@ -793,7 +802,7 @@ impl TaskRepo {
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
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)?;
let rows = stmt
.query_map([], |row| task_from_row(row))
@@ -833,7 +842,7 @@ impl TaskRepo {
.execute(
// deleted_at IS NULL:回收站任务不可被 CAS 覆盖——MCP 拿旧 expected 也能
// 命中已软删行,造成回收站内容被改且 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![
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,
@@ -859,7 +868,7 @@ impl TaskRepo {
validate_column_name(field, "tasks")?;
let conn = self.conn.clone();
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
);
let id = id.to_owned();
@@ -875,6 +884,56 @@ impl TaskRepo {
.await
.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)]
@@ -909,6 +968,7 @@ mod tests {
parent_id: parent_id.map(|s| s.to_string()),
content_json: None,
module_id: None,
archived: false,
created_at: "1700000000000".to_string(),
updated_at: "1700000000000".to_string(),
}
@@ -130,6 +130,7 @@ mod tests {
parent_id: parent_id.map(|s| s.to_string()),
content_json: None,
module_id: None,
archived: false,
created_at: "1700000000000".to_string(),
updated_at: "1700000000000".to_string(),
}
+65 -5
View File
@@ -53,7 +53,9 @@ pub fn run(conn: &Connection) -> Result<()> {
// 桌面侧栏/miniapp 列表按 origin 过滤子会话)。
// 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),
(2, migrate_v2),
(3, migrate_v3),
@@ -99,6 +101,10 @@ pub fn run(conn: &Connection) -> Result<()> {
(43, migrate_v43),
(44, migrate_v44),
(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 {
@@ -1354,6 +1360,50 @@ fn migrate_v45(conn: &Connection) -> Result<()> {
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 表
///
/// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。
@@ -2357,6 +2407,16 @@ mod tests {
column_exists(&conn, "tasks", "module_id"),
"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(全量迁移成功落版本号)
let max_version: i64 = conn
@@ -2367,8 +2427,8 @@ mod tests {
)
.expect("查 schema_version 应成功");
assert_eq!(
max_version, 45,
"全量迁移后 schema_version 应为 45(实际 {}),说明某条 migrate_vN 链路断在中间",
max_version, 47,
"全量迁移后 schema_version 应为 47(实际 {}),说明某条 migrate_vN 链路断在中间",
max_version
);
@@ -2445,7 +2505,7 @@ mod tests {
|r| r.get(0),
)
.unwrap();
assert_eq!(max_v, 45, "首轮应推进到 45");
assert_eq!(max_v, 47, "首轮应推进到 47");
// 清空版本表强制全链第二遍(每步 execute 第二次)
conn.execute("DELETE FROM schema_version", []).unwrap();
@@ -2457,7 +2517,7 @@ mod tests {
|r| r.get(0),
)
.unwrap();
assert_eq!(max_v2, 45, "重跑后应重新推进到 45");
assert_eq!(max_v2, 47, "重跑后应重新推进到 47");
}
// ============================================================
+9 -1
View File
@@ -23,6 +23,10 @@ pub struct IdeaRecord {
pub ai_analysis: Option<String>, // AI 分析结果 JSON
pub scores: Option<String>, // 多维评分 JSON (feasibility/impact/urgency/overall)
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 updated_at: String,
}
@@ -123,9 +127,13 @@ pub struct TaskRecord {
/// 一个项目可含多个工程(Monorepo/微服务),本字段让任务落到具体工程,是后续
/// IPC / 前端 / AI 按工程筛选任务的数据基础。外键 REFERENCES project_modules(id)
/// ON DELETE SET NULL(module 删除时任务自动解关联)。可空(老任务无关联 → None)。
/// #[serde(default)] 兼容旧前端 JSON(无该字段时为 None)。
#[serde(default)]
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 updated_at: String,
}
@@ -56,6 +56,7 @@ fn task(id: &str, project_id: &str) -> TaskRecord {
parent_id: None,
content_json: None,
module_id: None,
archived: false,
created_at: now_ts(),
updated_at: now_ts(),
}
+1
View File
@@ -91,6 +91,7 @@
| [后续批次测试用例设计-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 自动化路径 |
| [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
[![Crates.io](https://img.shields.io/crates/v/dingtalk-stream.svg)](https://crates.io/crates/dingtalk-stream)
[![docs.rs](https://docs.rs/dingtalk-stream/badge.svg)](https://docs.rs/dingtalk-stream)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![Rust](https://img.shields.io/badge/rust-%3E%3D1.85.0-orange.svg)](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
[![Crates.io](https://img.shields.io/crates/v/feishu-sdk.svg)](https://crates.io/crates/feishu-sdk)
[![Documentation](https://docs.rs/feishu-sdk/badge.svg)](https://docs.rs/feishu-sdk)
[![License](https://img.shields.io/crates/l/feishu-sdk.svg)](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
[![Crates.io](https://img.shields.io/crates/v/larksuite-oapi-sdk-rs)](https://crates.io/crates/larksuite-oapi-sdk-rs)
[![Docs](https://docs.rs/larksuite-oapi-sdk-rs/badge.svg)](https://docs.rs/larksuite-oapi-sdk-rs)
[![CI](https://github.com/adamcavendish/larksuite-oapi-sdk-rs/actions/workflows/ci.yml/badge.svg)](https://github.com/adamcavendish/larksuite-oapi-sdk-rs/actions/workflows/ci.yml)
[![MIT licensed](https://img.shields.io/badge/license-MIT-blue.svg)](./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 笔提交走查的横向延伸。
> 方法:只读审计,按(漂移后果 × 重复量)降序。纯相似但不漂移的不算债。
> 本轮已收口:#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 丢事件) | 待排期 |
| 2 | KV 引号归一 7 处 3 种策略并存 | ~25 行 | 已发生(auto-execute medium/all 档静默失效注释自证) | 待排期 |
| 1 | emit+publish 双写样板 ~50 处 | ~120 行 | 已发生(BUG-260805-02 只 emit 未 publish 丢事件) | ✅ 收口 |
| 2 | KV 引号归一 7 处 3 种策略并存 | ~25 行 | 已发生(auto-execute medium/all 档静默失效注释自证) | ✅ 收口 |
| 3 | 高风险工具名单 TS↔Rust 双写 | 名单×2 | 已漂移:前端缺 10 个 High 工具,审批后二次确认漏弹 | ✅ 本轮收口 |
| 4 | 噪音目录名单 4 处 4 套 | ~25 行 | 已漂移(AI 搜索与文件树过滤集合不一致) | 待排期 |
| 5 | 工具元数据 match 表散布 5 处 | ~90 行 | 已漂移(幽灵 edit_file/list_dir 分支永不命中) | 待排期 |
| 6 | 相对时间阶梯 3 套实现 | ~45 行 | 用户可见不一致(同为更新时间三种分档) | 待排期 |
| 7 | insert system 警示骨架 4 处同构 | ~100 行 | conv 生命周期语义变更需 4 处同改 | 待排期 |
| 8 | 压缩关键词兜底块逐字双胞胎 | ~35 行 | 两条失败路径易只改其一产生行为分叉 | 待排期 |
| 9 | formatWaitTime+等待阈值双写三处 | ~36 行 | 审批浮窗与主窗「变红」时刻分叉风险 | 待排期 |
| 10 | 灵感 statusOptions 双写 | ~20 行 | 透传链断一处详情页回退英文原值 | 待排期 |
| 11 | truncate 家族(同名不同义陷阱) | ~30 行 | fetch_url 与 audit 同名函数语义分叉误导复用 | 部分收口 |
| 12 | LOCK-SLOW 持锁计时样板 ~29 处 | ~145 行 | 低(阈值/行号写死变量名,维护噪音) | 待排期 |
| 4 | 噪音目录名单 4 处 4 套 | ~25 行 | 已漂移(AI 搜索与文件树过滤集合不一致) | ✅ 收口 |
| 5 | 工具元数据 match 表散布 5 处 | ~90 行 | 已漂移(幽灵 edit_file/list_dir 分支永不命中) | ✅ 收口 |
| 6 | 相对时间阶梯 3 套实现 | ~45 行 | 用户可见不一致(同为更新时间三种分档) | ✅ 收口 |
| 7 | insert system 警示骨架 4 处同构 | ~100 行 | conv 生命周期语义变更需 4 处同改 | ✅ 收口 |
| 8 | 压缩关键词兜底块逐字双胞胎 | ~35 行 | 两条失败路径易只改其一产生行为分叉 | ✅ 收口 |
| 9 | formatWaitTime+等待阈值双写三处 | ~36 行 | 审批浮窗与主窗「变红」时刻分叉风险 | ✅ 收口 |
| 10 | 灵感 statusOptions 双写 | ~20 行 | 透传链断一处详情页回退英文原值 | ✅ 收口 |
| 11 | truncate 家族(同名不同义陷阱) | ~30 行 | fetch_url 与 audit 同名函数语义分叉误导复用 | ✅ 收口 |
| 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)
- 已收口部分:tools/knowledge.rs 本地截断已改调正典(本轮)
- 残留收敛:正典上移共享位改名 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 样板
- 29 处 × 5 行,变量名编码行号(__hold610),阈值 30ms 逐处复制
@@ -152,3 +152,194 @@ IM 用户 @机器人 "把登录任务推进到 in_review"
- **df-mcp**:对外 stdio 暴露数据层(本地进程级);本规划是远程人类入口(IM 级),互补不重叠
- **u-relay**:零改动(除非 D5 触发多 token 需求)
- **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 的归类器一处实现)——窗口折叠/分册去重/统计归因用的是同一套签名,避免三处三套口径漂移。
+40
View File
@@ -1034,3 +1034,43 @@ graph TD
- **reset --hard 前工作区必须干净**:当前存在未提交改动(含本节处方)。执行顺序=①先收尾在飞工作,连同本节处方一并提交为第 7 笔(消息如 `文档: 提交重整处方与在飞收尾`)②然后 reset --hard origin/main ③按原序 cherry-pick 前六笔并逐笔 amend 换消息 ④第 7 笔原样 cherry-pick 不改消息 ⑤树一致性校验(git diff 原HEAD 为空)
- 重放期间禁止并行改码;校验不过立即 `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 有界;监控按月轮转;三界类目口径同源(同一归类函数)
+62
View File
@@ -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
}
// ── 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)]
mod tests {
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 归一化测试 ──
#[test]
+48 -14
View File
@@ -391,6 +391,7 @@ pub(crate) use stall_detector::{
};
pub(crate) use helpers::try_continue_agent_loop;
pub(crate) use helpers::infer_goal_from_tool_calls;
pub(crate) use helpers::extract_update_goals_block;
pub(crate) use helpers::{
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,
@@ -1667,20 +1668,24 @@ async fn prepare_round_inputs(
// 压缩决策经 per_conv.tool_view_decisions 冻结:首次判定后沿用,不再随阈值翻转
// (防同一条历史跨轮在全文/摘要间翻转致前缀断)。写回仅在 conv 存活时做(防惰性建)。
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 conv = session.conv_read(conv_id);
(
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_compress_cache.clone()).unwrap_or_default(),
)
};
let messages =
summarize_tool_results(messages, history_tokens_snapshot, &mut view_decisions, conv_id, iteration);
if !view_decisions.is_empty() {
let messages = summarize_tool_results(
messages, history_tokens_snapshot, &mut view_decisions, &mut compress_cache, conv_id, iteration,
);
if !view_decisions.is_empty() || !compress_cache.is_empty() {
let mut session = session_arc.lock().await;
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
@@ -1777,16 +1782,39 @@ async fn finalize_round_output(
);
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(
&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_cache_hit_tokens, round_usage.prompt_cache_miss_tokens,
round_usage.reasoning_tokens,
round_is_estimated,
);
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,
_ = tokio::time::sleep(std::time::Duration::from_secs(20)) => {
last_heartbeat.store(df_types::now_millis() as u64, std::sync::atomic::Ordering::SeqCst);
let _ = app.emit(
"ai-chat-event",
// DRY #1:双写正典(miniapp 端靠心跳判活,只 emit 端看不到)
emit_ai_chat_event(
app,
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()),
}
};
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 化:熔断需人工介入,系统通知补位(条件发送,失败静默)
crate::commands::ai::notify::notify_session_event(
app_handle,
@@ -2253,8 +2283,9 @@ async fn check_stall_breaker(
"[ai] G2 破坏性回环熔断:连续 {} 轮检测到同路径写删回环,疑似自毁循环停止", new_loop_count
);
let path_desc = loop_hit_path.as_deref().unwrap_or("未知路径");
let _ = app_handle.emit(
"ai-chat-event",
// DRY #1:双写正典(熔断求助卡跨端可见)
emit_ai_chat_event(
app_handle,
AiChatEvent::AiHelpRequired {
reason: format!("连续 {} 轮检测到对同一文件({})反复写删,疑似「写完就删」自毁循环已停止", new_loop_count, path_desc),
context: "写文件后又删除同一文件的紧凑交替,可能在反复销毁自己的工作成果而无进展。".into(),
@@ -2283,8 +2314,9 @@ async fn check_stall_breaker(
sample_size = signatures.len(),
"[ai] G2 探索熔断:连续 {} 次检测到重复工具调用,疑似卡住停止", new_count
);
let _ = app_handle.emit(
"ai-chat-event",
// DRY #1:双写正典(熔断求助卡跨端可见)
emit_ai_chat_event(
app_handle,
AiChatEvent::AiHelpRequired {
reason: format!("连续 {} 次检测到重复的工具调用(同样的 grep/读文件反复),疑似卡住已停止", new_count),
context: "工具调用签名高度重复,可能在反复做同一件事而无进展。".into(),
@@ -2548,6 +2580,8 @@ pub(crate) async fn emit_ai_completed_once(
emit_ai_chat_event(app_handle, ev);
return;
}
// 有意不走 emit_ai_chat_event(emit/publish 两端 incomplete 标志分叉,见上注释)——
// 此分支语义就是单路 emit 不 publish,裸写是正确形态,勿"收口"。
let _ = app_handle.emit("ai-chat-event", ev);
if do_publish {
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(
@@ -279,6 +279,7 @@ pub(crate) fn summarize_tool_results(
messages: Vec<ChatMessage>,
history_tokens_snapshot: u32,
view_decisions: &mut std::collections::HashMap<u64, bool>,
compress_cache: &mut std::collections::HashMap<u64, String>,
conv_id: &str,
iteration: usize,
) -> Vec<ChatMessage> {
@@ -333,7 +334,18 @@ pub(crate) fn summarize_tool_results(
return m;
}
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();
summarized_count += 1;
m.content = compressed;
+10 -4
View File
@@ -746,11 +746,17 @@ async fn run_sub_agent_loop_inner(
msgs.extend(history_msgs);
let history_tokens = conv.messages.history_tokens();
// tool_result 大输出 view-only 摘要 + 占位配对断言(与主 loop 同款防线)。
// 压缩决策同主 loop 冻结在 per_conv(子会话条目),跨轮不随阈值翻转。
// 压缩决策同主 loop 冻结在 per_conv(子会话条目),跨轮不随阈值翻转;
// 压缩结果 memoization 同主 loop(同内容哈希跳过重压)。
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);
if !view_decisions.is_empty() {
session.conv(&child_conv_id).tool_view_decisions = view_decisions;
let mut compress_cache = conv.tool_compress_cache.clone();
let msgs = summarize_tool_results(
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 est = TokenEstimator::default();
@@ -165,6 +165,7 @@ mod idea_source_test_helpers {
ai_analysis: None,
scores: None,
related_ids: None,
archived: false,
created_at: now.clone(),
updated_at: now,
})
+2 -2
View File
@@ -18,10 +18,10 @@ use super::tools::entity_resolve;
// utils(audit/utils.rs):RiskLevel ↔ 字符串转换 + 字符串安全截断纯 helper。
// 第五批从本文件抽离,行为零变更。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 共用。
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 生成。
// 第五批从本文件抽离,行为零变更。
+3 -3
View File
@@ -7,7 +7,7 @@
//! - `ToolExecutionDto`:传给前端的精简审计视图
//! - `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 std::collections::{HashMap, HashSet};
@@ -233,8 +233,8 @@ pub async fn list_tool_executions(
// ToolCallId → String(DTO 边界,前端按字符串消费)
tool_call_id: r.tool_call_id.into(),
tool_name: r.tool_name,
arguments_brief: super::truncate_chars(&r.arguments, 120),
result_brief: r.result.map(|s| super::truncate_chars(&s, 160)),
arguments_brief: super::truncate_chars_ellipsis(&r.arguments, 120),
result_brief: r.result.map(|s| super::truncate_chars_ellipsis(&s, 160)),
status: r.status,
risk_level: r.risk_level,
requested_at: r.requested_at,
+2 -2
View File
@@ -4,7 +4,7 @@
//! - `risk_str` / `risk_from_str`:finalize / restore 通过 `super::` 引用(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_*` 引用不变。
use df_ai::ai_tools::RiskLevel;
@@ -29,7 +29,7 @@ pub(crate) fn risk_from_str(s: &str) -> Option<RiskLevel> {
}
/// 截断字符串到 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 {
return s.to_string();
}
+3 -3
View File
@@ -49,7 +49,7 @@ use df_storage::db::Database;
use df_storage::models::AiProviderRecord;
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::{
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() {
// 错误响应读 body 摘要(截断 500 chars,截断时尾部补 …)帮助定位
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!(
"图像生成端点返回 HTTP {}: {}",
status.as_u16(),
@@ -459,7 +459,7 @@ async fn write_atomic(output_path: &str, data: &[u8]) -> anyhow::Result<()> {
/// 走 audit truncate_chars 正典(截断时尾部补 …);错误文案无硬长度约束,允许多 1 字符。
fn body_snippet(v: &Value) -> 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::commands::ai::audit::truncate_chars;
use crate::commands::ai::audit::truncate_chars_ellipsis;
use crate::commands::err_str;
use super::{AiSession};
@@ -562,7 +562,7 @@ pub(crate) async fn build_knowledge_context(
let kind = r.kind.clone();
let title = r.title.clone();
// 截断 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
+8
View File
@@ -1073,6 +1073,13 @@ pub struct PerConvState {
/// 仍为 view-only(不落库改历史,仅影响发送视图);键用内容哈希而非位置(裁剪/压缩后
/// 位置漂移,内容不变)。随会话销毁不落库。
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,落库随行
/// (ai_conversations.boundary_json,save 写/switch 读还原),随会话同亡。
@@ -1229,6 +1236,7 @@ impl PerConvState {
workflow_dag_summary: None,
frozen_tool_names: None,
tool_view_decisions: std::collections::HashMap::new(),
tool_compress_cache: std::collections::HashMap::new(),
boundary: None,
readonly_cache_floor: 0,
}
+16
View File
@@ -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\
- **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\
## 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\
- 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",
read_symbol_guidance(lang),
duplicate_call_guidance(lang),
file_reading_guidance(lang),
),
"\n## Current Projects\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\
@@ -142,6 +145,7 @@ fn system_prompt_parts(lang: &str) -> (String, &'static str, &'static str) {
- 需要的上下文(文件/数据)若与当前请求无关,不要主动展开。\n",
read_symbol_guidance(lang),
duplicate_call_guidance(lang),
file_reading_guidance(lang),
),
"\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 同源拼接的工具使用纪律 ——
/// read_symbol 优先 / 重复调用检测 / URL 三工具纪律与禁臆造 URL / render=true 失败
/// 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 {
format!(
"\n## 工具纪律\n\
- {}\n\
- {}\n\
- {}\n{}{}",
read_symbol_guidance(lang),
duplicate_call_guidance(lang),
file_reading_guidance(lang),
doc_strategy_core_section(lang),
app_config_query_guidance_section(lang),
)
+20 -3
View File
@@ -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 来源:本函数入参)。
// conv() 惰性建:switch 触发的 spawn_ensure_title 前序 switch 已 conv();若极端未建,
// conv() 建空 PerConvState → summary_msgs 空 → 下方 return(无素材不生成标题)。
let (summary_msgs, all_msgs) = {
let mut session = session_arc.lock().await;
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))
.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 {
id: None, // 标题摘要派生消息:不落库不溯源,无需分配 ID(对齐老消息 None 语义)
role: m.role.clone(),
+61 -27
View File
@@ -187,10 +187,12 @@ pub(crate) fn truncate_output(s: &str, max: usize) -> (String, bool) {
// ============================================================
// patch_file 三模式共用底层
//
// 三模式互斥(old_text 精确匹配 / replace_lines 行号区间 / anchor 锚点),
// 三模式定位(old_text 精确匹配 / replace_lines 行号区间 / anchor 锚点),
// 后两者均归约为「按行号区间 [start, end](1-based 含首尾)替换为 new_text」,
// 由 apply_line_range 统一执行 splice。锚点经 resolve_anchor_to_lines 转行号区间。
// 提取为纯函数便于直接单测(不依赖 async/fs/锁),handler 闭包内复用。
// 由 apply_line_range 统一执行 splice。锚点经 resolve_anchor_to_lines 转行号区间
// (end 子串须唯一命中,多处命中报错)。多模式同时传入由 handler 层按优先级仲裁
// (见 tools/file.rs arbitrate_patch_mode)。提取为纯函数便于直接单测(不依赖 async/fs/锁),
// handler 闭包内复用。
// ============================================================
/// 按行号区间替换:将 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)
}
/// 锚点解析为行号区间:在 content 中找含 `start` 子串的首行 → 其后第一个含 `end` 子串的行。
/// 锚点解析为行号区间:在 content 中找含 `start` 子串的首行 → 其后含 `end` 子串的行。
///
/// - 子串匹配(大小写敏感,与 search_files 一致)。
/// - start/end 任一找不到返 Err;start 行号 > end 行号返 Err(即 end 必须在 start 之后或同行)。
/// - 同一行同时命中 start/end:start==end,区间退化为单行替换。
/// - end 唯一性校验(治实测 ×2 多处命中替换错位):end 子串在 start 行(含)之后多处命中
/// 时返 Err,列出前两处命中行上下文,提示缩小 end 锚点或改用 old_text 模式。
/// 旧实现静默取第一个命中行,LLM 意图往往是更靠后的行,错位后返工。
/// - 返回 (start_line, end_line)(1-based 含首尾)。
pub(crate) fn resolve_anchor_to_lines(content: &str, start: &str, end: &str) -> anyhow::Result<(usize, usize)> {
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
.ok_or_else(|| anyhow::anyhow!("anchor.start 子串「{}」未在文件中找到", start))?;
// end 在 start_line 之后(含 start_line 同行)第一个命中的行
let mut end_line: Option<usize> = None;
for (i, line) in content.lines().enumerate() {
let lineno = i + 1;
if lineno < start_line {
continue;
// end 在 start_line 之后(含 start_line 同行)收集全部命中行,唯一才放行
let all_lines: Vec<&str> = content.lines().collect();
let end_hits: Vec<usize> = all_lines
.iter()
.enumerate()
.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);
Ok((start_line, end_line))
}
@@ -1995,12 +2022,12 @@ mod tests {
assert_eq!((s, e), (1, 4));
}
/// resolve_anchor_to_lines: 子串匹配会在 start 之后命中第一个含该子串的行
/// (含行内出现,如 println!("{}") 内的 })——锁定此行为语义,避免后续误改成「整行相等」
/// resolve_anchor_to_lines: 子串匹配含行内出现(如 println!("{}") 内的 })——
/// end 唯一命中时放行,锁定「子串包含」语义(非「整行相等」)
#[test]
fn test_resolve_anchor_substring_matches_inside_line() {
// end 锚 "}" 在 start 行之后第一个命中 = 第 3 行 println!("{}") 的 },而非第 4 行的 }
let content = "fn foo() {\n let x = 1;\n println!(\"{}\");\n}\n";
// end 锚 "}" 唯一命中第 3 行 println!("{}") 的 }(第 4 行换成无 } 的行)
let content = "fn foo() {\n let x = 1;\n println!(\"{}\");\nEND\n";
let (s, e) = resolve_anchor_to_lines(content, "fn foo", "}").unwrap();
assert_eq!((s, e), (1, 3));
}
@@ -2014,13 +2041,20 @@ mod tests {
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]
fn test_resolve_anchor_picks_first_end_after_start() {
// 第 2、4 行都含 "}",应取 start(第1行) 之后第一个 "}" → 第 2 行
fn test_resolve_anchor_end_multi_hit_rejected() {
// 第 2、4 行都含 "}",多处命中 → Err
let content = "START\n}\nx\n}";
let (s, e) = resolve_anchor_to_lines(content, "START", "}").unwrap();
assert_eq!((s, e), (1, 2));
let err = resolve_anchor_to_lines(content, "START", "}").unwrap_err();
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 一致)
+230 -32
View File
@@ -71,7 +71,7 @@ pub fn register(
registry,
allowed_dirs: Arc<RwLock<AllowedDirs>>,
"read_file",
"读取文件内容,返回文本内容。支持 offset/limit 分页;传入 search 则在文件内容中搜索匹配行(大小写敏感,字符串包含匹配),返回 matches 数组(限50条)。仅需函数/类型符号的签名或定义体时优先用 read_symbol(骨架/全文,避免 prompt 爆)",
"读取文件内容,返回文本内容。支持 offset/limit 分页;传入 search 则在文件内容中搜索匹配行(大小写敏感,字符串包含匹配),返回 matches 数组(限50条)。仅需函数/类型符号的签名或定义体时优先用 read_symbol(骨架/全文,避免 prompt 爆)。遍历大文件用大 limit(300 左右)分段读,小页(15-40 行)遍历会成倍放大调用轮次",
RiskLevel::Low,
meta: ToolMeta {
path_args: &["path"],
@@ -181,7 +181,7 @@ pub fn register(
let consumed = offset_used.unwrap_or(0) + returned_lines;
let truncated_info = if has_more {
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()),
line_count - consumed,
consumed,
@@ -448,10 +448,10 @@ pub fn register(
let mut props = serde_json::Map::new();
props.insert("path".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!({
"type": "object",
"description": "模式2 行号区间:{ start: 1-based 起始行(含), end: 1-based 结束行(含) },三选一互斥。配 expected_hash 防并发行号漂移",
"description": "模式2 行号区间:{ start: 1-based 起始行(含), end: 1-based 结束行(含) },三选一。配 expected_hash 防并发行号漂移",
"properties": {
"start": { "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!({
"type": "object",
"description": "模式3 锚点:{ start: 首行子串标记, end: 尾行子串标记 },内部定位首尾行号转区间替换,三选一互斥。不需完整原文",
"description": "模式3 锚点:{ start: 首行子串标记, end: 尾行子串标记 },内部定位首尾行号转区间替换,三选一。不需完整原文",
"properties": {
"start": { "type": "string", "description": "首行子串标记(大小写敏感)" },
"end": { "type": "string", "description": "尾行子串标记(大小写敏感,在 start 行之后)" }
"end": { "type": "string", "description": "尾行子串标记(大小写敏感,在 start 行之后,须唯一命中;多处命中会报错并列出命中位置)" }
},
"required": ["start", "end"]
}));
@@ -479,7 +479,7 @@ pub fn register(
registry,
allowed_dirs: Arc<RwLock<AllowedDirs>>,
"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,
meta: ToolMeta {
path_args: &["path"],
@@ -500,8 +500,11 @@ pub fn register(
let new_text = args["new_text"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 new_text 参数"))?;
// 三模式互斥校验(old_text / replace_lines / anchor)
// 三选一:统计传入的模式参数数,>1 报错,0 报错(缺定位方式)
// 三模式定位(old_text / replace_lines / anchor)
// 旧实现:三选一互斥,>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_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);
@@ -509,12 +512,8 @@ pub fn register(
if mode_count == 0 {
anyhow::bail!("缺少定位方式:必须提供 old_text / replace_lines / anchor 之一");
}
if mode_count > 1 {
anyhow::bail!(
"模式互斥冲突:old_text / replace_lines / anchor 仅可传一个(检测到 {} 个)",
mode_count
);
}
let (use_anchor, use_old_text, use_replace_lines, mode_arbitration) =
arbitrate_patch_mode(has_anchor, has_old_text, has_replace_lines);
// 文件存在性 / 大小限制(三模式通用,先于内容读取)
let target = std::path::Path::new(path);
@@ -572,7 +571,7 @@ pub fn register(
let match_count: usize;
let warning: Option<String>;
if has_old_text {
if use_old_text {
let old_text = args["old_text"].as_str()
.ok_or_else(|| anyhow::anyhow!("old_text 必须为字符串"))?;
if old_text.is_empty() {
@@ -583,11 +582,26 @@ pub fn register(
"path": path, "changed": false, "warning": "new_text 与 old_text 相同,无实际更改"
}));
}
// L2: old_text 精确匹配(CAS 语义)
// 失败时附相近片段提示把文件里与 old_text 相似的几行列给 LLM,
// 供其对照真实缩进/空格修正锚点,避免靠猜反复重试。完全无关(无相似行)则
// 退回原提示,不误导。
if !content.contains(old_text) {
// L2: old_text 精确匹配(CAS 语义)→ 空白归一化回退 → 相近片段诊断。
// 实测 ×15 失败是 LLM 缩进/空白对不齐:精确未中时按「行首缩进归一+多空白
// 折叠」归一化重匹配,唯一命中自动采用(治根),多命中/零命中维持原报错,
// 附相近片段供 LLM 对照真实缩进修正(AC-5)。
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);
if similar.is_empty() {
anyhow::bail!("未找到目标文本,文件可能已被修改");
@@ -597,13 +611,7 @@ pub fn register(
similar.join("\n")
);
}
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 has_replace_lines {
} else if use_replace_lines {
let rl = args.get("replace_lines")
.ok_or_else(|| anyhow::anyhow!("缺少 replace_lines 参数"))?;
let start = rl["start"].as_u64()
@@ -614,7 +622,7 @@ pub fn register(
match_count = 1;
warning = None;
} else {
// has_anchor
// use_anchor
let an = args.get("anchor")
.ok_or_else(|| anyhow::anyhow!("缺少 anchor 参数"))?;
let a_start = an["start"].as_str()
@@ -652,15 +660,31 @@ pub fn register(
// 生成 unified 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!({
"path": path,
"changed": true,
"mode": mode_name,
"size_diff": size_diff,
"matches_found": match_count,
"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 {
result["warning"] = serde_json::Value::String(w);
notes.push(w);
}
if !notes.is_empty() {
result["warning"] = serde_json::Value::String(notes.join(";"));
}
Ok(result)
}
@@ -1032,7 +1056,13 @@ pub fn register(
// 旧实现直接 grep_recursive(root) → 内部 read_dir → 文件路径报 os error 267「目录名称无效」。
// 改:文件走 grep_one_file(单文件匹配),目录走 grep_recursive(递归)。
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() {
let file_name = std::path::Path::new(root)
.file_name()
@@ -1379,7 +1409,7 @@ async fn read_text_file(path: &str) -> anyhow::Result<(String, u64)> {
let mut file = File::open(path).await
.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
anyhow::anyhow!("文件不存在: {}", path)
file_not_found_error(path)
} else {
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()
}
// ============================================================
// 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 提示辅助 — 相近文件名候选
// ============================================================
@@ -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 相近文件名候选
// ============================================================
+1
View File
@@ -86,6 +86,7 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
source: args["source"].as_str().map(|s| s.to_string()),
promoted_to: None, ai_analysis: None, scores: None,
related_ids: None,
archived: false,
created_at: now_millis(), updated_at: now_millis(),
};
let id = record.id.clone();
+2 -2
View File
@@ -30,7 +30,7 @@ use df_storage::db::Database;
use df_storage::models::KnowledgeRecord;
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::knowledge_timeline::KnowledgeTimeline;
use crate::commands::now_millis;
@@ -81,7 +81,7 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
serde_json::json!({
"title": k.title,
"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,
"reuse_count": k.reuse_count,
})
+3 -2
View File
@@ -121,6 +121,7 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
queue,
parent_id,
content_json,
archived: false,
created_at: now_millis(), updated_at: now_millis(),
};
let id = record.id.clone();
@@ -133,7 +134,7 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
registry,
db: Arc<Database>,
"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,
meta: ToolMeta {
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 拒绝 + 白名单拒绝)。
if field == "status" {
anyhow::bail!(
"status 改动须走 advance_task 工具(状态机推进,8 态不可跳跃),本工具不接受 status 字段"
"status 改动须走 advance_task 工具(状态机推进,8 态,目标不可直达时沿合法路径自动多跳),本工具不接受 status 字段"
);
}
// 复用 df-storage CRUD 白名单(按表隔离),与 update_field 校验同源
+29 -1
View File
@@ -96,6 +96,7 @@ pub async fn create_idea(
ai_analysis: None,
scores: None,
related_ids: None,
archived: false,
created_at: now.clone(),
updated_at: now,
};
@@ -142,13 +143,20 @@ pub async fn update_idea(
serde_json::from_str::<serde_json::Value>(&value)
.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
.ideas
.update_field_active(&id, &field, &value)
.await
.map_err(err_str)?;
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} 不存在或已删除"));
}
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)
}
/// 归档/取消归档灵感(归档功能 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 事务中更新,保证原子性。
#[tauri::command]
+52
View File
@@ -329,6 +329,7 @@ pub async fn create_task(
queue,
parent_id,
content_json,
archived: false,
created_at: now.clone(),
updated_at: now,
};
@@ -471,6 +472,11 @@ pub async fn update_task(
.await
.map_err(err_str)?;
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} 不存在或已删除"));
}
// LW-9改 parent_id 后重算父聚合——旧父(任务已离开,计数减少)与新父(任务挂上)。
@@ -588,6 +594,52 @@ pub async fn restore_task(state: State<'_, AppState>, id: String) -> Result<bool
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 写入路径)。
///
/// thin 入口业务逻辑(状态机校验 + 原子 CAS + review_rounds 累加 + 父聚合)
+6
View File
@@ -373,6 +373,9 @@ pub fn run() {
commands::task::update_task,
commands::task::delete_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::advance_task,
// 知识图谱 Phase 1(对标设计 §五):任务横向关联 CRUD + 跨池移动 + 父子树
@@ -415,6 +418,9 @@ pub fn run() {
commands::idea::relate_ideas,
commands::idea::delete_idea,
commands::idea::restore_idea,
// 归档功能 P1(V47):灵感归档 + 归档视图
commands::idea::archive_idea,
commands::idea::list_archived_ideas,
commands::idea::list_deleted_ideas,
commands::idea::evaluate_idea,
commands::idea::list_idea_evaluations,
+10
View File
@@ -49,4 +49,14 @@ export const ideaApi = {
listEvaluations(ideaId: string): Promise<IdeaEvaluationRecord[]> {
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')
},
}
+10
View File
@@ -59,4 +59,14 @@ export const taskApi = {
advance(id: string, targetStatus: string): Promise<TaskRecord> {
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 })
},
}
+8
View File
@@ -37,6 +37,8 @@ export interface IdeaRecord {
scores: string | null
/** 关联灵感 id JSON 数组字符串(对齐后端 related_ids Option<String>),null/空串=无关联 */
related_ids: string | null
/** 归档标记(归档功能 P1,V47 列)。归档灵感退出主视图,仅在归档箱可见。 */
archived: boolean
created_at: string
updated_at: string
}
@@ -189,6 +191,12 @@ export interface TaskRecord {
* 老任务无结构化规格时为 undefined。
*/
content_json?: string
/**
* 归档标记(归档功能 P1,V46 列)。与 status/queue 正交的纯元数据:
* archived=true 的任务退出主视图(后端活跃查询恒过滤),仅在归档视图可见。
* 老任务后端 DEFAULT 0 保证该字段始终存在。
*/
archived: boolean
created_at: string
updated_at: string
}
+46 -5
View File
@@ -6,7 +6,7 @@
<div v-if="tc.name === 'read_file' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-file-content">
<div class="ai-tool-file-bar">
<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,
旧模板取 lines/returned_lines 显「0 行」+ 空 content 区,用户误以为读取失败。
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 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-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 v-if="parsed?.truncated" class="ai-tool-file-meta ai-tool-file-meta--warn">{{ $t('aiTool.dirTruncated', { n: DIR_TRUNCATE_LIMIT }) }}</span>
</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 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-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 class="ai-tool-file-meta">{{ $t('aiTool.foundN', { n: typeof parsed?.total === 'number' ? parsed.total : (parsed?.results?.length || 0) }) }}</span>
</div>
@@ -77,7 +77,7 @@
<div v-else-if="tc.name === 'grep' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-grep-result">
<div class="ai-tool-file-bar">
<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 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>
@@ -125,7 +125,7 @@
<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">
<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 v-if="parsed?.matches_found && parsed.matches_found > 1" class="ai-tool-file-meta">{{ $t('aiTool.patchedMatches', { n: parsed.matches_found }) }}</span>
</div>
@@ -257,6 +257,39 @@ const emit = defineEmits<{
'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),成功默认折叠
const cmdOutputExpanded = ref(false)
// http_request 响应 body 折叠态:失败(非 2xx)默认展开便于看错误,成功默认折叠
@@ -403,6 +436,14 @@ watch(() => props.tc.status, (s) => {
.ai-tool-file-meta--warn {
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,命中行用等宽渲染 */
.ai-tool-grep-result {
+13 -1
View File
@@ -34,11 +34,14 @@ defineProps<{
msg: AiMessage
/** 是否显示编辑按钮(父合成:末条 user + 非生成中 + 非编辑中) */
canEdit?: boolean
/** I6:是否显示重新生成按钮(父合成同 canEdit 时机;作用于末条 user 触发的回复) */
canRegenerate?: boolean
}>()
const emit = defineEmits<{
(e: 'copy', msg: AiMessage): void
(e: 'start-edit', msg: AiMessage): void
(e: 'regenerate', msg: AiMessage): void
}>()
// ── 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" />
</div>
</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 链回填输入框编辑重发 -->
<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>
@@ -195,8 +202,9 @@ function imgAlt(img: ImageContentPart): string {
max-height: 200px;
border-radius: 6px;
}
/* 复制/编辑按钮(hover 显示,同栏同视觉;编辑按钮仅 canEdit 时渲染) */
/* 复制/编辑/重新生成按钮(hover 显示,同栏同视觉;编辑/重新生成仅各自 can* 时渲染) */
.ai-copy-btn,
.ai-msg-regen-btn,
.ai-msg-edit-btn {
opacity: 0;
transition: opacity 0.15s;
@@ -207,7 +215,11 @@ function imgAlt(img: ImageContentPart): string {
color: var(--df-text-dim);
}
.ai-msg-user:hover .ai-copy-btn,
.ai-msg-user:hover .ai-msg-regen-btn,
.ai-msg-user:hover .ai-msg-edit-btn {
opacity: 1;
}
.ai-msg-regen-btn:hover {
color: var(--df-accent);
}
</style>
+44 -2
View File
@@ -620,10 +620,16 @@ function decorateCodeHtml(raw: string): string {
const hit = _decoCache.get(raw)
if (hit !== undefined) return hit
// 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(
/<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()
_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 装流式。
* 提示语足够具体(给出 cargo install + GitHub releases 两种装法 + 装完告知用户重试),
@@ -1530,8 +1545,10 @@ defineExpose({
v-else-if="item.msg.role === 'user'"
:msg="item.msg"
:can-edit="isLastUser(item.msg) && !store.state.streaming && item.msg.id !== editingMsgId"
:can-regenerate="isLastUser(item.msg) && !store.state.streaming"
@copy="copyMsgContent"
@start-edit="(m) => emit('start-edit', m)"
@regenerate="handleRegenerate"
/>
<!-- AI 消息 -->
@@ -2100,6 +2117,31 @@ defineExpose({
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 显示 / 错误气泡常显) ── */
.ai-msg-actions {
+9 -2
View File
@@ -286,10 +286,14 @@
</div>
</div>
<!-- 操作(P0-② 立项按钮已上移至标题行,此处仅留删除) -->
<!-- 操作(P0-② 立项按钮已上移至标题行) -->
<div class="detail-section" style="margin-top:8px">
<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') }}
</button>
</div>
@@ -315,12 +319,15 @@ const props = defineProps<{
evalError: string
promoting: boolean
deleting: boolean
/** 归档功能 P1:父视图是否处于归档箱模式(决定操作按钮语义:归档↔取消归档) */
inArchiveBox?: boolean
}>()
const emit = defineEmits<{
(e: 'evaluate'): void
(e: 'promote'): void
(e: 'delete'): void
(e: 'archive', archived: boolean): void
(e: 'status-change', value: IdeaStatus): void
// 返 Promise<boolean> 让子组件 await 知成功/失败。
// true=落库成功(关编辑态);false=失败(保持编辑态,toast 由父组件处理)。
+3 -1
View File
@@ -225,10 +225,12 @@ export function handleEvent(event: AiChatEvent) {
if (event.type === 'AiCompleted') {
emit('ai-drain-queue', { conversationId: event.conversation_id })
} else if (event.type === 'AiError') {
// 9-28#5 收口:无 convId 的 AiError 属异常防御路径(后端 emit 必带),清全部队列
// 会误伤其他会话排队消息,改保留 + console.warn 留痕
if (event.conversation_id) {
state.queue = state.queue.filter(q => q.conversationId !== event.conversation_id)
} else {
state.queue = []
console.warn('[ai] AiError 缺 conversation_id,跳过清队列(防误伤其他会话排队)')
}
}
} else if (event.type === 'AiTextDelta' && convId && switchingConvs.has(convId)) {
+3 -2
View File
@@ -208,8 +208,9 @@ export function handleLifecycleEvent(event: AiChatEvent): boolean {
state.queue = state.queue.filter(q => q.conversationId !== event.conversation_id)
notifyQueueCleared(dropped)
} else {
notifyQueueCleared(state.queue.length)
state.queue = []
// 9-28#5 收口:无 convId 的 AiError 属异常防御路径(后端 emit 必带),清全部队列
// 会误伤其他会话排队消息,改保留 + console.warn 留痕(不再弹「已清 N 条」误导用户)
console.warn('[ai] AiError 缺 conversation_id,跳过清队列(防误伤其他会话排队)')
}
state.messages.push({
id: `err-${nextMsgId()}` as MessageId,
+21 -9
View File
@@ -9,6 +9,7 @@
*/
import { ref, reactive, computed, watch, onBeforeUnmount, type Ref } from 'vue'
import { useI18n } from 'vue-i18n'
import i18n from '@/i18n'
import { useConfirm } from '@/composables/useConfirm'
import { aiApi } from '@/api'
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)
}
/** 审批二次确认门控:优先注册表投影,未拉到元数据时退兜底名单 */
function isHighRiskTool(name: string): boolean {
/** 审批二次确认门控:优先注册表投影,未拉到元数据时退兜底名单。
* V4 导出:系统浮窗 ApprovalPopup 的批准路径与 ToolCard 同门槛复用。 */
export function isHighRiskTool(name: string): boolean {
return (registryHighRisk ?? HIGH_RISK_TOOLS_FALLBACK).has(name)
}
@@ -80,6 +82,21 @@ export function dispatchApprovalIPC(
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 */
interface ToolApprovalEmit {
(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)
}
/** High 风险工具的二次确认文案(按操作类型) */
/** High 风险工具的二次确认文案(按操作类型;V4 提升为模块级导出,浮窗复用) */
function highRiskConfirmMsg(name: string): string {
if (name === 'run_command') return t('aiTool.confirmHighExec')
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')
return highRiskConfirmText(name)
}
/**
+4 -4
View File
@@ -112,12 +112,12 @@ export default {
clearSkill: 'Clear skill',
skillBadgeTip: 'Injected full skill playbook: {name}',
placeholderWithSkill: 'Skill selected. Type arguments and press Enter',
placeholderDefault: "Type a message... (/ for skills, {'@'} to mention)",
placeholderDefault: "Type a message... (/ for skills, &#64; to mention)",
skillNoMatch: 'No matching skill',
skillNotLoaded: 'No local skills loaded',
// ── @ entity mention (@ opens popover, select inserts [Type:Name] token) ──
mentionHint: "{'@'} to mention project/task",
mentionHint: "&#64; to mention project/task",
mentionGroupProject: 'Projects',
mentionGroupTask: 'Tasks',
mentionGroupIdea: 'Ideas',
@@ -207,7 +207,7 @@ export default {
// ── 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)
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 &#64;[project] to reference or provide an absolute path.",
// /skill hot reload (ai_reload_skills IPC) success toast
skillReloaded: 'Skills reloaded',
// @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 ──
goalsCount: 'Goals ({n})',
none: 'None',
projectLabel: "{'@'}Projects",
projectLabel: "&#64;Projects",
// ── 3c UX batch addition (I5 classified send-failure messages): friendly text by
// error kind; {msg} keeps the raw error string for troubleshooting ──
+2
View File
@@ -15,6 +15,8 @@ export default {
// tool execution failure marker (AR-6 keeps status=completed, red to distinguish from success)
executionFailed: 'Failed',
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)
expand: 'Expand',
collapse: 'Collapse',
+9
View File
@@ -28,6 +28,7 @@ export default {
createFailed: 'Failed to create idea',
deleteFailed: 'Failed to delete idea',
relateFailed: 'Failed to update relations',
archiveFailed: 'Archive operation failed',
},
// 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)
confirmStatus: 'Change idea "{title}" status from {from} to {to}?',
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',
},
}
+9
View File
@@ -53,6 +53,14 @@ export default {
quickStatus: 'Status',
quickPriority: 'Priority',
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: {
@@ -129,6 +137,7 @@ export default {
createFailed: 'Failed to create task',
deleteFailed: 'Failed to delete task',
updateFailed: 'Failed to update task',
archiveFailed: 'Archive operation failed',
selectProject: 'Please select a project',
},
},
+4 -4
View File
@@ -112,12 +112,12 @@ export default {
clearSkill: '取消技能',
skillBadgeTip: '已注入技能「{name}」的完整规程',
placeholderWithSkill: '已选技能,输入参数后回车',
placeholderDefault: "输入消息...(/ 触发技能,{'@'} 引用实体)",
placeholderDefault: "输入消息...(/ 触发技能,&#64; 引用实体)",
skillNoMatch: '无匹配技能',
skillNotLoaded: '未读取到本机技能',
// ── @ 实体引用(@ 触发 popover,选中插入 [类型:名] 标记) ──
mentionHint: "{'@'} 引用项目/任务",
mentionHint: "&#64; 引用项目/任务",
mentionGroupProject: '项目',
mentionGroupTask: '任务',
mentionGroupIdea: '灵感',
@@ -208,7 +208,7 @@ export default {
// ── Input Augmentation 层: mention/skill 注入反馈 ──
// search_files 盲搜被白名单兜底拒绝(不应弹授权窗,直接友好提示用户改用 @引用或绝对路径)
searchFilesBlocked: "search_files 仅在已授权目录可用,请用 {'@'}[项目] 引用或提供绝对路径",
searchFilesBlocked: "search_files 仅在已授权目录可用,请用 &#64;[项目] 引用或提供绝对路径",
// /技能 热重载(ai_reload_skills IPC)成功后提示
skillReloaded: '技能已重新加载',
// @引用 实体 resolve 失败(id 指向的项目/任务/灵感/技能已被删除或不可用)
@@ -289,7 +289,7 @@ export default {
// ── TopBar 目标/项目上下文标签 ──
goalsCount: '目标({n})',
none: '无',
projectLabel: "{'@'}项目",
projectLabel: "&#64;项目",
// ── 3c UX 批追加(I5 发送失败错误分类文案):按错误内容映射友好提示,
// {msg} 保留原始错误串供排查,不丢线索 ──
+2
View File
@@ -15,6 +15,8 @@ export default {
// 工具执行失败视觉标识(AR-6 下 status=completed,需红色区分于成功)
executionFailed: '执行失败',
executionFailedHint: '工具执行失败,详情见下方',
// R6:工具结果路径可点击复制(hover title 后半段)
clickToCopy: '点击复制完整路径',
collapse: '收起',
expand: '展开',
written: '已写入',
+9
View File
@@ -28,6 +28,7 @@ export default {
createFailed: '创建灵感失败',
deleteFailed: '删除灵感失败',
relateFailed: '更新关联关系失败',
archiveFailed: '归档操作失败',
},
// 操作反馈 toast(WK-16/18:成功/失败轻提示,对齐 Knowledge.createdToast)
@@ -147,6 +148,14 @@ export default {
// ⑥ 状态切换确认(终态 promoted/rejected/archived 或离开 approved 失去立项入口)
confirmStatus: '将灵感「{title}」状态从 {from} 改为 {to}?',
promoteFailed: '立项失败',
// 归档功能 P1(灵感归档箱)
filterArchived: '📦 归档箱',
archiveBoxEmpty: '暂无归档灵感',
quickArchive: '📦 归档到归档箱',
quickUnarchive: '📤 取消归档',
confirmArchive: '归档灵感「{title}」?归档后移出主视图,可在归档箱查看。',
archivedToast: '已归档',
unarchivedToast: '已取消归档',
evalFailed: '评估失败',
},
}
+9
View File
@@ -53,6 +53,14 @@ export default {
quickStatus: '状态',
quickPriority: '优先级',
quickDelete: '🗑 删除',
// 归档功能 P1(归档=移出主视图保留检索,区别于删除=进回收站)
quickArchive: '📦 归档',
quickUnarchive: '📤 取消归档',
confirmArchive: '归档任务「{title}」?归档后移出主视图,可在归档箱查看。',
archivedToast: '已归档',
unarchivedToast: '已取消归档',
archiveBox: '📦 归档箱',
archiveBoxEmpty: '暂无归档任务',
// 批量操作栏(三处任务列表共用:Tasks / ProjectDetail / TaskDetail 子任务)
batch: {
@@ -129,6 +137,7 @@ export default {
createFailed: '创建任务失败',
deleteFailed: '删除任务失败',
updateFailed: '更新任务失败',
archiveFailed: '归档操作失败',
selectProject: '请选择项目',
},
},
+2
View File
@@ -179,6 +179,7 @@ function createStore() {
createTask: tasksStore.createTask,
updateTask: tasksStore.updateTask,
deleteTask: tasksStore.deleteTask,
archiveTask: tasksStore.archiveTask,
// P1-b A0:统计独立通道(Dashboard 卡片 + ActiveProjectsPanel 各项目任务数)
loadStats,
// idea actions
@@ -186,6 +187,7 @@ function createStore() {
createIdea: ideasStore.createIdea,
updateIdea: ideasStore.updateIdea,
deleteIdea: ideasStore.deleteIdea,
archiveIdea: ideasStore.archiveIdea,
evaluateIdea: ideasStore.evaluateIdea,
promoteIdea: ideasStore.promoteIdea,
relateIdeas: ideasStore.relateIdeas,
+14
View File
@@ -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 {
loadIdeas,
createIdea,
@@ -78,5 +91,6 @@ export function createIdeasStore() {
evaluateIdea,
promoteIdea,
relateIdeas,
archiveIdea,
}
}
+14
View File
@@ -69,10 +69,24 @@ export function createTasksStore() {
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 {
loadTasks,
createTask,
updateTask,
deleteTask,
archiveTask,
}
}
+19 -7
View File
@@ -151,7 +151,7 @@ import { useI18n } from 'vue-i18n'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'
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 { formatWaitTime, waitLevelOf } from '@/utils/time'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
@@ -170,20 +170,24 @@ const { toast, showToast } = useToast()
/** 拖动态(样式反馈) */
const isDragging = ref(false)
// ── M5:path 类 deny 二次确认(独立窗口本地 confirm;拒绝不可逆——工具返 Err、loop 续跑不再重试)──
// 浮窗是独立 WebviewWindow,与主窗口 JS 上下文隔离,不复用 useConfirm 跨窗口 reactive。
// pendingDenyTc 持有「待确认拒绝」的 tc;answerConfirm 据 result 决定续跑 runApproval 或放弃。
const confirmState = reactive<{ visible: boolean; msg: string; tc: AiToolCallInfo | null }>({
// ── M5/V4:浮窗本地二次确认(独立窗口本地 confirm;与主窗口 JS 上下文隔离,不复用 useConfirm)──
// M5:path 类 deny(拒绝不可逆——工具返 Err、loop 续跑不再重试)。
// V4:High 风险工具 risk 批准前置确认(对齐 ToolCard 安全语义:注意力分散场景数字键「1」
// 单键放行 run_command/delete_file 太危险)。action 记录「待确认动作」供 answerConfirm 分派。
const confirmState = reactive<{ visible: boolean; msg: string; tc: AiToolCallInfo | null; action: 'deny' | 'approve' }>({
visible: false,
msg: '',
tc: null,
action: 'deny',
})
function answerConfirm(ok: boolean) {
const tc = confirmState.tc
const action = confirmState.action
confirmState.visible = false
confirmState.tc = null
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) {
await runApproval(tc, false, 'always')
}
/** risk 类:批准(approved=true) */
/** risk 类:批准(approved=true)。V4:High 风险工具先二次确认(按钮与数字键 1 同走本入口,
* 确认框打开时数字键守卫生效,不绕确认;拒绝仍按 M5 原逻辑)。 */
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)
}
/** path 类 deny / risk 类 reject(统一入口,kind 由 dispatchApprovalIPC 据 tc.kind 分派)。
+48 -4
View File
@@ -54,7 +54,7 @@
<div class="idea-list">
<!-- 三态:加载/空(错误改走顶部 error-banner,WK-7 不整表替换列表) -->
<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>
<div
v-for="idea in pagedIdeas"
@@ -93,9 +93,11 @@
:eval-error="evalError"
:promoting="promoting"
:deleting="deleting"
:in-archive-box="activeFilter === 'archived'"
@evaluate="evaluateCurrentIdea"
@promote="promoteToProject"
@delete="deleteCurrentIdea"
@archive="archiveCurrentIdea"
@status-change="onStatusChange"
@update-desc="onUpdateDesc"
@update-related="onUpdateRelated"
@@ -160,7 +162,8 @@ import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Message } from '@arco-design/web-vue'
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 { stripMd } from '../utils/markdown'
import { scoreTier } from '../utils/ideaEval'
@@ -180,7 +183,7 @@ const store = useProjectStore()
// 确认弹层状态机抽至 composables/useConfirm(原 4 视图重复:Projects/ProjectDetail/Ideas/Settings)
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
type FilterKey = 'all' | 'hot' | 'pending' | 'promoted'
type FilterKey = 'all' | 'hot' | 'pending' | 'promoted' | 'archived'
// ⑤ 列表筛选/排序/分页状态持久化 localStorage(对齐 Tasks.vue:193-201 用 usePersistedRef),
// key 前缀 'ideas.',刷新页面/重开恢复用户的筛选视图。selectedId 不持久化(随路由恢复)。
@@ -212,6 +215,8 @@ const filters: { key: FilterKey; labelKey: string; icon: string }[] = [
{ key: 'hot', labelKey: 'ideas.filter.hot', icon: '🔥' },
{ key: 'pending', labelKey: 'ideas.filter.pending', icon: '⏳' },
{ key: 'promoted', labelKey: 'ideas.filter.promoted', icon: '🚀' },
// 归档功能 P1:归档箱(独立数据源 listArchived,不走主查询)
{ key: 'archived', labelKey: 'ideas.filterArchived', icon: '📦' },
]
// ── 状态映射(单一数据源在 @/constants/project,列表/详情/下拉共用) ──
@@ -237,9 +242,21 @@ function buildIdeaQuery(): IdeaQuery {
return q
}
// 归档功能 P1:归档箱独立数据源(切到归档箱 filter 时拉取;其余 filter 保持 null 不请求)
const archivedIdeas = ref<IdeaRecord[]>([])
// 按当前视图查询加载(登记 setActiveIdeaQuery 供 df-data-changed 联动刷新复用同一筛选,
// 避免 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())
return store.loadIdeas(buildIdeaQuery())
}
@@ -272,6 +289,9 @@ watch(sortMode, () => { page.value = 1; void reloadIdeas(true) })
watch(searchQuery, () => { page.value = 1; void reloadIdeas(false) })
const filteredIdeas = computed(() => {
// 归档箱模式:整列表切归档数据源
if (activeFilter.value === 'archived') return archivedIdeas.value
let ideas = store.ideas
// 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() {
if (!currentIdea.value || promoting.value) return
promoting.value = true

Some files were not shown because too many files have changed in this diff Show More