Compare commits
186
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c625f8342c | ||
|
|
81a47720be | ||
|
|
44fbd29836 | ||
|
|
a8bc2b4470 | ||
|
|
058fa141bf | ||
|
|
caa19d690b | ||
|
|
c34b9d195d | ||
|
|
8f7017fc0b | ||
|
|
589e26c887 | ||
|
|
9d28477e3a | ||
|
|
7c4424f857 | ||
|
|
8ba4cde61d | ||
|
|
28afa11aeb | ||
|
|
9f6243a729 | ||
|
|
bdf1494319 | ||
|
|
a0b4c941e4 | ||
|
|
9c13d8a469 | ||
|
|
c9da488749 | ||
|
|
39ae883a4e | ||
|
|
1347a77d33 | ||
|
|
27a661436c | ||
|
|
eca1f33d8c | ||
|
|
f84a358e30 | ||
|
|
07df06b86d | ||
|
|
b040f588b2 | ||
|
|
e8bc8727da | ||
|
|
b626b3f5c0 | ||
|
|
50cc60d019 | ||
|
|
a1c9da6e8a | ||
|
|
e39cc5d096 | ||
|
|
5a69d6e0ff | ||
|
|
8058660537 | ||
|
|
e0793debc3 | ||
|
|
28a6cd7e53 | ||
|
|
5099c21520 | ||
|
|
a5b3d40a74 | ||
|
|
d47185feb8 | ||
|
|
656c35f1e8 | ||
|
|
921938d084 | ||
|
|
76263d42ed | ||
|
|
969857682f | ||
|
|
604f9ebaa9 | ||
|
|
bfbf5057bc | ||
|
|
7fbb8d6232 | ||
|
|
d6493d79f4 | ||
|
|
1b2576ecaf | ||
|
|
2702777950 | ||
|
|
82e9213804 | ||
|
|
996201c4ed | ||
|
|
c7c0294a16 | ||
|
|
3a0cc64160 | ||
|
|
3b9ae3f918 | ||
|
|
fe3872ee28 | ||
|
|
4224fc2b12 | ||
|
|
4e9cd17fbd | ||
|
|
fb55769cd7 | ||
|
|
67d7e4bd1b | ||
|
|
98e1ae6aa9 | ||
|
|
92b0f89492 | ||
|
|
91101dedfb | ||
|
|
0ec9cbe32b | ||
|
|
b955a27479 | ||
|
|
faf18afb91 | ||
|
|
5c6f4e50a0 | ||
|
|
6906d274fc | ||
|
|
5717735da9 | ||
|
|
3001fcc716 | ||
|
|
4c187ac070 | ||
|
|
af61b8ccfe | ||
|
|
6be76f1665 | ||
|
|
f37fe05aee | ||
|
|
abbd79a3b1 | ||
|
|
4b4173dd42 | ||
|
|
3157abcf3c | ||
|
|
fa5d3d23bf | ||
|
|
9e88605cc8 | ||
|
|
0f9251b983 | ||
|
|
66dda084cd | ||
|
|
a697fa0a1e | ||
|
|
48b3dae5ed | ||
|
|
b90811fae4 | ||
|
|
46a6da13e0 | ||
|
|
066319f9c6 | ||
|
|
1c2944fcf4 | ||
|
|
542bac677d | ||
|
|
765ef167bd | ||
|
|
e684061e56 | ||
|
|
6d0df6a825 | ||
|
|
003d12a712 | ||
|
|
cc6904b1b4 | ||
|
|
8820b0b316 | ||
|
|
efc25c6a7f | ||
|
|
240b59708d | ||
|
|
3a2711a7e0 | ||
|
|
4c79728553 | ||
|
|
a895754d55 | ||
|
|
01aff71bc1 | ||
|
|
7700a7eda0 | ||
|
|
54e32304f2 | ||
|
|
dfac06bbeb | ||
|
|
6a3f3a329a | ||
|
|
210c8b999f | ||
|
|
f56d74c363 | ||
|
|
1ae9158250 | ||
|
|
7d9b2132bb | ||
|
|
20cf3e0a93 | ||
|
|
da6a365222 | ||
|
|
3acc81cec8 | ||
|
|
bd49080bd1 | ||
|
|
0fe88a44c0 | ||
|
|
4c9cabce1b | ||
|
|
ca8577f05b | ||
|
|
679c8abf1d | ||
|
|
c608dd4afb | ||
|
|
86aef7b454 | ||
|
|
fd8a62edf5 | ||
|
|
e3e8878ead | ||
|
|
7ea2850f18 | ||
|
|
187eb327d6 | ||
|
|
cfb8700b61 | ||
|
|
2142de92f2 | ||
|
|
a25e0c8870 | ||
|
|
960d7b46fd | ||
|
|
e91b868a5c | ||
|
|
ec6c98d26a | ||
|
|
76d972f0f2 | ||
|
|
a1d162febf | ||
|
|
1d3965aa80 | ||
|
|
2421a8dac9 | ||
|
|
4518385f44 | ||
|
|
6b0af81136 | ||
|
|
9654ce8861 | ||
|
|
fa573663c1 | ||
|
|
4ed5925296 | ||
|
|
28967d3435 | ||
|
|
13a0519115 | ||
|
|
fe53e029ea | ||
|
|
6432088586 | ||
|
|
5969e700e1 | ||
|
|
0c2322bb92 | ||
|
|
9f1f895059 | ||
|
|
18ad2b89ab | ||
|
|
42a07cb454 | ||
|
|
202cd8535e | ||
|
|
9ff0421888 | ||
|
|
f6c47b0ae1 | ||
|
|
4df8d6f53a | ||
|
|
97c0c1b94a | ||
|
|
bdf3f3edaf | ||
|
|
a87fbeb494 | ||
|
|
f3fb097630 | ||
|
|
e9e7b7dcb2 | ||
|
|
27ee0a8149 | ||
|
|
eddf921229 | ||
|
|
17c41265e8 | ||
|
|
8a274e120d | ||
|
|
69210a8586 | ||
|
|
44c25eb03b | ||
|
|
a017ab6cf8 | ||
|
|
80096c8bd1 | ||
|
|
20051f0168 | ||
|
|
8690d129a7 | ||
|
|
48a5bb89f2 | ||
|
|
c7ea67dcb6 | ||
|
|
a3ae98ef2e | ||
|
|
95f517d61d | ||
|
|
4365224c9b | ||
|
|
6c30ca540a | ||
|
|
1d49d266f0 | ||
|
|
537a8b7988 | ||
|
|
b1d51d8de2 | ||
|
|
041a6ff504 | ||
|
|
b504df01e0 | ||
|
|
5daba92d0f | ||
|
|
979d1cfbb2 | ||
|
|
3f266c26b2 | ||
|
|
e32d85afc3 | ||
|
|
91e4dd2fe1 | ||
|
|
59672720bd | ||
|
|
d4ed043d5c | ||
|
|
8ab241f627 | ||
|
|
a6f3817061 | ||
|
|
b26bc55a15 | ||
|
|
1cd38514f3 | ||
|
|
625ae33c1e | ||
|
|
b01e2ddb1a |
No files matched your search
@@ -81,6 +81,6 @@
|
||||
- 🔴 **等待子代理:不许用长 `WaitFor` 阻塞回合(主人 2026-10-04 反馈)**:主会话在 `WaitFor` 里等子代理时,**主人无法给任何补充输入** —— 等于把主人锁在门外。规矩:**派完就结束回合**(子代理的完成通知会自动唤醒主会话);确需同回合内等,**单次 `WaitFor` ≤ 60s**,且只用于「马上就要用它的结果」;等待期间若有并行工作,**先做工作再结束回合**,不要空等。
|
||||
- 🔴 **遇到开发文档冲突 / 意义不明 / 规格矛盾时**:**阻塞会话**,用 `AskUserQuestion` 询问主人,**不得自行猜测**;文档清、不冲突则自主推进。
|
||||
- 🔴 阶段验证:后端 `go build ./...` + `go vet ./...` + `go test ./...` 全绿 + windows/darwin 交叉编译;前端(M9+)`tsc --noEmit` + `vite build` 通过。
|
||||
- 🔴 **换二进制前先确认前端是否变更(2026-10-05 登记)**:`scripts\ctrl.bat build` **只跑 `go build`,不含前端**。若本次改动触及 `frontend/web/**`,必须先在 `frontend/web` 跑 `npm run build`(会把 `dist` 拷到 `internal/webui/dist`),**再** `ctrl.bat build` + **一次** `restart`;否则后端产物里带的是旧前端。反过来:**纯前端改动不需要重启后端**(运行时优先读磁盘 `frontend/web/dist`),刷新浏览器即可。
|
||||
- 🔴 **换二进制前先确认前端是否变更(2026-10-05 登记)**:🔴 **`internal/webui/dist/.gitkeep` 是 `git add -f` 追踪的占位文件,不要删、不要 `git rm --cached`** —— 它让**干净 checkout / 新 worktree** 能直接 `go build ./...`(`//go:embed all:dist` 在空目录上会编译失败);真实产物仍被 `.gitignore` 的 `dist/` 忽略,`frontend/web/copy-dist.mjs` 已在构建末尾保证把它写回来。删掉它会同时打坏编译与前端拷贝链。`scripts\ctrl.bat build` **只跑 `go build`,不含前端**。若本次改动触及 `frontend/web/**`,必须先在 `frontend/web` 跑 `npm run build`(会把 `dist` 拷到 `internal/webui/dist`),**再** `ctrl.bat build` + **一次** `restart`;否则后端产物里带的是旧前端。反过来:**纯前端改动不需要重启后端**(运行时优先读磁盘 `frontend/web/dist`),刷新浏览器即可。
|
||||
- 🔴 **裁定必须带完成状态(2026-10-05 登记)**:凡是给主人 / 给安卓端的裁定、承诺、回执,**每一条都必须标注「已完成 / 在办 / 未开始」**;标「已完成」须附 commit 号或验收证据,且**完成后回原条目处补记「已完成 + commit」**——不得只在别处口头称完成。**禁止把「未验证」表述成「已修好」**。
|
||||
- 🔴 **提交一律逐文件 `git add <显式路径>`(2026-10-05 实测事故)**:**禁止**在「同一目录下有多路在途切片」时用 `git add -A -- <目录>` —— 实测它会**把别的子代理的在途文件一起吞进提交**(本次误把 237 的 `sessionenv.go` 提交并推了)。修复套路:`git reset --soft HEAD~1` → `git restore --staged <误入文件>` → 重新 commit → `git push --force-with-lease origin dev`(工作树内容不丢)。
|
||||
+257
-39
@@ -1,12 +1,12 @@
|
||||
# Cyrene v2 客户端 API 契约
|
||||
|
||||
> **契约版本:`v0.90`**(上一版 `v0.89`,其条目见 §6;`v0.88` / `v0.87` / `v0.86` / `v0.85` / `v0.84` / `v0.83` / `v0.82` / `v0.81` / `v0.80` / `v0.79` / `v0.78` / `v0.77` / `v0.76` / `v0.75` / `v0.74` / `v0.73` / `v0.72` / `v0.71` 及更早见本文件 §6,`v0.67` 及更早的基线已归档、只读:`Docs/archive/api-client.v0.65.md`)。
|
||||
> **契约版本:`v0.96`**(上一版 `v0.95`,其条目见 §6;`v0.89` / `v0.88` / `v0.87` / `v0.86` / `v0.85` / `v0.84` / `v0.83` / `v0.82` / `v0.81` / `v0.80` / `v0.79` / `v0.78` / `v0.77` / `v0.76` / `v0.75` / `v0.74` / `v0.73` / `v0.72` / `v0.71` 及更早见本文件 §6,`v0.67` 及更早的基线已归档、只读:`Docs/archive/api-client.v0.65.md`)。
|
||||
> **读者**:Web 前端 / 移动端原生客户端(含 Android)/ IM 桥接等**跨平台客户端开发者**。
|
||||
> **唯一真源 = 当前代码**(`internal/services/gateway/*` 等)。本文档由代码逐条核对得出;与代码不符时**以代码为准**,并回改本文档。
|
||||
> **旧的超详细版已归档、只读**:`Docs/archive/api-client.v0.65.md`(412 KB,含 v0.28→v0.65 全部历史条目)与 `Docs/archive/api-reference.v0.65.md`(170 KB,后端全量字段级文档)。归档的原因是**结构性臃肿 + 历史条目并列 + 长期漂移**;本文档**不复制**那段历史,需要考古时去归档。
|
||||
> **维护铁律**:每实现/变更一个 REST 端点、WS 事件、admin API **立即改本文档**;契约变更必须版本化,不破坏既有客户端。
|
||||
> **准确性 > 详尽度**:宁少写也别写错;**写不了确证的写「未确证」**,见 [§7](#7-在飞切片与未实现未确证)。
|
||||
> ⚠️ **v0.67 是「契约先行」登记版**:本版新增的三批 —— **语音消息**(播放端点 + 消息 DTO `voice`)、**非消息事件查询面**、**账号在线状态** —— 登记时**都还没有实现**,形状**取自设计文档**(`design/24 §10.11`、`design/26 §4.5` / `§2.6`)**而非代码核对**。**已落地**:账号在线状态(`52dac45`)、非消息事件查询面(切片 E6)—— 见 §2.4;**语音消息的「用户可见面」已由 v0.79 落地**(切片 L45:DTO `voice` + `GET /api/v1/media/{name}` + 副本落盘;残留缺口——WS 实时帧/握手回放不带 `voice`、AMR 不落副本(**后者已由 v0.80 修复**)——见 §4.5 / §7.2 / §7.4-29)。已在 §7.2 逐条如实标注。**本文档其余内容仍以代码为真源**。
|
||||
> ⚠️ **v0.67 是「契约先行」登记版**:本版新增的三批 —— **语音消息**(播放端点 + 消息 DTO `voice`)、**非消息事件查询面**、**账号在线状态** —— 登记时**都还没有实现**,形状**取自设计文档**(`design/24 §10.11`、`design/26 §4.5` / `§2.6`)**而非代码核对**。**已落地**:账号在线状态(`52dac45`)、非消息事件查询面(切片 E6)—— 见 §2.4;**语音消息的「用户可见面」已由 v0.79 落地**(切片 L45:DTO `voice` + `GET /api/v1/media/{name}` + 副本落盘;残留缺口——WS 实时帧/握手回放不带 `voice`、AMR 不落副本——**两者均已修复**:前者已由 v0.97(切片 M44)、后者已由 v0.80(切片 W4)——见 §4.5 / §7.2 / §7.4-29)。已在 §7.2 逐条如实标注。**本文档其余内容仍以代码为真源**。
|
||||
> **v0.68 是纯增量版(已实现,非契约先行)**:新增 1 条管理只读端点 `GET /api/v1/admin/config-facts`(配置事实面,见 §2.2 B12),并给 `GET /api/v1/admin/backthink` 的条目加 2 个可选字段(`urgency` / `quietHoursBypassed`,见 §2.2 B8)。**无破坏性变更**,老客户端零影响。
|
||||
> **v0.69 是缺口修复版(已实现)**:`PUT /api/v1/admin/channels/{platform}/config` 现在**接收并原样落盘**账号顶层 `directMsgPerMin`,`GET /api/v1/admin/channels` 的账号元素也**如实回显**它(§2.2 A5 / §2.3 C28 注)。修的是「面板保存一次渠道配置,就把主人手配的**关闭流控(-1)**静默重置回 `0` = 保护性默认 6 条/分」。**无破坏性变更**(老客户端不带该键,行为与 v0.68 相同)。
|
||||
> **v0.70 是精确化版(已实现,纯增量)**:`GET /api/v1/admin/backthink` 的 `urgency` **默认就是该轮实际生效的精确档位**(随记录落库),另加可选 `urgencyLowerBound:true` 专门标记**旧行的下限推导值**;实时 `backthink` 帧自本版起也带记录里的 `urgency`(§2.2 B8 / §4.10(b),并更正 §4.10(b) 原「实时帧不带这两键」的表述)。**无破坏性变更**(新键可选,老客户端不读即零影响;`urgency` 的既有取值域与 `quietHoursBypassed` 语义都未改)。
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
> **v0.76 是三面增量版(⚠️ **逐条状态不同**:**① 已在 v0.78 实现、覆盖率又由 v0.79 补齐**;**②③ 已实现**)**:① **消息读取面新增 `toolCalls[]`(工具回看面)—— ✅ 已实现(v0.78,切片 CODE-TC),覆盖率已于 v0.79 补齐至全部编排回合**:`GET /api/v1/sessions/{id}/messages` 的消息对象加可选 `toolCalls`(`omitempty`),每条含工具名 / 状态(`ok`/`error`/`unknown`)/ 耗时毫秒 / **已脱敏**的入参与结果摘要;数据源是**已落盘的轮次链路图**(`flow_graphs`,迁移 `0022`;**v0.78 加迁移 `0023` 的按路查询键**,保留期 168h),**不从正文反解**。🔴 **缺席语义**:图取不到(超保留期 / 该轮未落盘 / 隔离渠道会话从不入库 / 该路标识查不到图)⇒ 字段**整体缺席**,**绝不回空数组**冒充「这一轮没调过工具」。⚠️ **历史说明(v0.76 登记时未实现的原因,实测确认非推断)**:`chat_messages.turn_id` 与 `flow_graphs.turn_id` **不同源** ⇒ 两者无法按消息字面 join,按时间窗猜映射项目口径不做;补齐需先让链路图带上**按路的回合标识**、并补一个**按路查图**的读侧入口。**v0.78 已把这两件都补上** ⇒ `toolCalls[]` 已实现;🔴 **v0.78 时只有真并行路能回看**(普通单路回合的图当时不带权威 `turnId`)—— **已由 v0.79(切片 CODE-TC-2)补齐**(见 **§4.4、§7.2、§7.4-23 与 §6 的 v0.78 ① / v0.79 ④**)。② **未读面(✅ 已实现)** —— `message_persisted` 的 `data` 加**可选** `unreadCount`(`omitempty`:只在 **>0 且确实算得出来**时出现;**缺席 = 老行为**),`GET /api/v1/sessions` 列表项同键(该键**自 D4 起就已在**,本版未改它)。口径 = **绝对未读 + 主人单一已读游标(`chat_sessions.read_at`)⇒ 多端共享**(Web 读了移动端也清),这是与安卓侧已对齐的**取舍**,不是缺陷。详见 **§3.4.4、§4.13**。③ **记忆整理试算加性异步** —— `POST /api/v1/admin/memories/tidy/dry-run?async=1` ⇒ `202` + `runId`;新增 `GET /api/v1/admin/memories/tidy/dry-run/{runId}` 轮询;**不带 `async` 仍是原同步行为**。**无破坏性变更**(新键 / 新可选参数 / 新端点;老客户端零影响)。详见 §1(会话列表与消息读取)、§2.2 B15 / §4.12.1、§3.4.4。
|
||||
|
||||
> **v0.77 是链路图按路分桶 + 记忆整理护栏/异步落地版(已实现;纯增量)**:① **`graph_update` 按路分桶**(切片 FV2)—— 帧 `data` 新增可选 `turnId`(该图的**路**的权威回合标识)与 `round`(会话轮次号);真并行(`parallel:true`)时同一会话**每一路各发自己的帧**(`data.id` / `data.turnId` 各不相同),两路不再互相顶掉;`GET /api/v1/admin/flow/graph` 响应新增 `lanes[]`(**最近一轮每一路**的图,恒为非 null 数组),`graph` 语义不变(最新一路 / 指定轮 / `null` 诚实空态)⇒ **老的读取方零改动**。**无破坏性变更**(两个新键可选;单路时 `turnId` 缺席 ⇒ 客户端行为与 v0.75 逐字相同)。详见 **§2.2 B13 / §3.4.5 / §4.11 / §7.4-19**。② **记忆整理护栏抬高 + 诚实截断 + 试算异步落地**(切片 MT-A)—— 每分区候选上限 `MEMORY_TIDY_CANDIDATE_LIMIT` 默认值 `20 → 200`、两两比对上限改为 env 键新键 `MEMORY_TIDY_COMPARE_LIMIT`(默认 `2000`,原先**写死** `400`);配置事实面 **12 → 13 条**;`truncated` 新增第 4 种标签 **`(budget)`**(墙钟时间预算截断);**v0.76 ③ 的加性异步已落地**(`?async=1` + B15 轮询可用;新错误码 **`tidy_run_not_found`**)。详见 **§2.2 B14/B15 / §4.10(a) / §4.12 / §4.12.1 / §5**。③ **「入库前审查」的 4 个 env 键登记**(切片 MT-B,纯增量)—— `MEMORY_REVIEW_ENABLED`(`true`)/ `MEMORY_REVIEW_QUEUE_SIZE`(`256`)/ `MEMORY_REVIEW_SCAN_LIMIT`(`200`)/ `MEMORY_REVIEW_TIMEOUT_SEC`(`20`);⚠️ 该切片的**面板读侧 / 回滚端点目前没有 HTTP 入口**(见 §7.2)。详见 **§5**。
|
||||
> **v0.77 是链路图按路分桶 + 记忆整理护栏/异步落地版(已实现;纯增量)**:① **`graph_update` 按路分桶**(切片 FV2)—— 帧 `data` 新增可选 `turnId`(该图的**路**的权威回合标识)与 `round`(会话轮次号);真并行(`parallel:true`)时同一会话**每一路各发自己的帧**(`data.id` / `data.turnId` 各不相同),两路不再互相顶掉;`GET /api/v1/admin/flow/graph` 响应新增 `lanes[]`(**最近一轮每一路**的图,恒为非 null 数组),`graph` 语义不变(最新一路 / 指定轮 / `null` 诚实空态)⇒ **老的读取方零改动**。**无破坏性变更**(两个新键可选;单路时 `turnId` 缺席 ⇒ 客户端行为与 v0.75 逐字相同)。详见 **§2.2 B13 / §3.4.5 / §4.11 / §7.4-19**。② **记忆整理护栏抬高 + 诚实截断 + 试算异步落地**(切片 MT-A)—— 每分区候选上限 `MEMORY_TIDY_CANDIDATE_LIMIT` 默认值 `20 → 200`、两两比对上限改为 env 键新键 `MEMORY_TIDY_COMPARE_LIMIT`(默认 `2000`,原先**写死** `400`);配置事实面 **12 → 13 条**;`truncated` 新增第 4 种标签 **`(budget)`**(墙钟时间预算截断);**v0.76 ③ 的加性异步已落地**(`?async=1` + B15 轮询可用;新错误码 **`tidy_run_not_found`**)。详见 **§2.2 B14/B15 / §4.10(a) / §4.12 / §4.12.1 / §5**。③ **「入库前审查」的 4 个 env 键登记**(切片 MT-B,纯增量)—— `MEMORY_REVIEW_ENABLED`(`true`)/ `MEMORY_REVIEW_QUEUE_SIZE`(`256`)/ `MEMORY_REVIEW_SCAN_LIMIT`(`200`)/ `MEMORY_REVIEW_TIMEOUT_SEC`(`20`,⚠️ **该默认值已由 v0.93 改为 `90`** —— 当时登记的值是准确的,见 §6 v0.93);⚠️ 该切片的**面板读侧 / 回滚端点目前没有 HTTP 入口**(见 §7.2)。详见 **§5**。
|
||||
|
||||
> **v0.86 是「搜索结果条数上限可配」版(已实现并推库 `bff57bd` + `c68387a`;**纯增量:面板多一个 number 字段 + 两个动作各多一个键;无破坏性变更**)**:`web-search` 的「结果条数上限」从**改不了**变成**面板上真能改**(此前三处断点:字段表里没这个字段 / `saveProvider` 只并四个键 / `mergeSelectionPatch` 只处理 `primary`+`fallback` ⇒ **手工塞进 `values` 也会被静默丢弃**,「我改了它却没变」成立)。① **面板声明**新增字段:`pluginId=web-search` / `panel=providers` / form `add` 多一个 **`maxResults`**(`type:"number"`, `min:1`, 标签「结果条数上限(默认 10)」)—— 前端本就渲染 `number` ⇒ **客户端零改动**。② **`plugin.saveProvider` 的 `values` 新增可写键 `maxResults`**:**三态** —— 缺位/null ⇒ 不改动;**正整数**(含整数字符串)⇒ 采用;`0` / 负数 / 小数 / 空串 / 非数 / 布尔 / 数组 ⇒ **`400 invalid_payload`**,且**盘上一字节不动**(**绝不静默忽略**)。③ 响应新增回显:`plugin.saveProvider` 的 `data.result.maxResults`(**0 = 文件里未声明**)、`plugin.reauthProvider` 的 `values.maxResults`(盘上有值才出现 ⇒ 用于「重配凭据」回填,否则一保存就把上限清掉)。④ **语义**:面板文件的 `maxResults` **压过** `.env` 的 `WEB_SEARCH_MAX_RESULTS`,是**运行期真上限**(模型的 `count` 只能更小),**保存即生效**(走既有热重载,响应里的 `hotReload`);代码默认值 **5 → 10**(三处口径已一致:env 默认 / io 包 `defaultMaxResults` / 面板文件)。⑤ **计数不变**(无新端点 ⇒ §2.0 维持 `14 + 25 + 40 = 79`)。⚠️ **已知取舍(如实)**:**清空输入框 = `400`**(后端按「非数 ⇒ 报错」拒)⇒ 面板上**没有「去掉上限」的手势**(要回到 `.env` 兜底得改文件);**小数/负数一律拒**;文件里没写该键时 `reauth` **不回填**(避免回填 0 让保存整笔进不去),因此那个框是空的、看不出当前生效值。
|
||||
> **v0.87 是「搜索 provider 扩到八家」版(已实现,纯增量;⚠️ 两家的成功响应都未真机实测)**:新增两家已登记接入位 —— **`tencent_wsa`**(腾讯云·元宝搜索,`POST {base}/SearchPro` + `Authorization: Bearer`,**零签名**;🔴 **HTTP 状态码恒 200** ⇒ 判失败**只能**读响应体 `Response.Error.Code`;`Pages` 是**JSON 字符串数组** ⇒ 需二次解析)与 **`metaso`**(秘塔搜索,`POST {base}/api/v1/search`;⚠️ 响应 schema 全部来自第三方来源且互相打架 ⇒ 实现走**宽松解析、未实测**)。对客户端的**唯一影响是两处名单**:`plugin.saveProvider` 的 `values.provider` 合法闭集(**六家 → 八家**)与 `plugin.providers` 的候选(多两条 `{value,label}`,中文名见 §6 本版条目)—— **端点形状 / 错误码 / 三态写回语义 / §2.0 计数(`14 + 25 + 40 = 79`)全部一字未改**,且前端候选本就是 `optionsFrom` 数据驱动 ⇒ **客户端零改动**。**零新配置键**:两家只走 `$DATA_DIR/config/plugins/web-search.json`,`.env` / `.env.example` **未动**。
|
||||
@@ -37,9 +37,9 @@
|
||||
|
||||
> **v0.80 是三批增量版(均**已实现**,**纯增量、无破坏性变更**)**:① **链路图时间轴出口**(切片 W1,`design/19 §7`)—— 新增 1 条管理只读端点 **`GET /api/v1/admin/flow/timeline`**(§2.2 B18 / §4.11.1),把「链路图」从「**最近一轮** + 已落盘轮次的**键列表**」扩到**按时间正序的多轮时间轴**(每轮含该轮**每一路**),支持 `limit`(缺省 **50** / 硬上限 **200**)与 `before` 游标向**更早**翻页;响应另给 `retentionHours` / `retentionEnabled` / `truncated`,供面板**如实**标注「更早的轮次已过保留期」。修的是主人 2026-10-06 的原话「web 后台的全链路流程图我要的是**所有消息按时间顺序串起来**的流程图,而不是只有最新一个来回」。老端点 `GET /api/v1/admin/flow/graph`(B13)**逐字未改**(老客户端零改动)。② **搜索 provider 热重载 + 主 provider / 降级链**(切片 W3;裁决 2026-10-06 #1 / #2)—— `plugin.saveProvider` 成功落盘后,组合根**按落盘后的配置重建 web-search 工具并原子替换能力注册表里的条目**(`registry.ReplaceTool`,保留运行期策略)⇒ **下一轮对话即用新配置、不需要重启**;若这次改动会让 web-search **没有可用后端**(下次启动会 fail-fast)⇒ **文件按字节回滚** + **`409 config_unstartable`**。同一次提交可带两个**文件级**字段 `primary` / `fallback[]`(缺位 = 不改动);响应新增机器可读事实 **`hotReload:{applied,registered}`**;新增错误码 **`primary_not_configured`(400)** / **`config_unstartable`(409)**。🔴 **本版把三处已成假话的旧口径改正**(§4.14(b)(c) / §7.2)—— 此前写的「面板保存后**不**热替换运行中的工具、需重启」与「面板**无**主 provider / 降级链字段」**都不再成立**。⚠️ **未确证(如实)**:真机面板上「新增/改一家 provider → 下一次搜索真的走它」的端到端、以及**真机探针打真实上游**,都没跑。③ **入站语音 AMR 也可回放**(切片 W4;裁决 #11)—— 组合根补齐 `channelbridge.Options.VoiceTranscoder` 注入(`cmd/cyrene/wire_channels.go`)⇒ **不可播格式的入站语音在落盘之前先转成 `.wav` 再落副本**(`mime: audio/wav`;本期唯一必需的转码路 = QQ OneBot v11 的 `amr → wav`,见 `internal/runtime/voice/playable.go`),客户端从此能拿到可回放的字节;此前走 `category=transcode_not_wired` 的诚实 WARN、**不落副本**。**形状零变化**(DTO `voice` 的键集合与语义一字未改;可播格式仍**原样落、一次转码都不发生**)。🔴 **同批改正 §4.5 / §7.1 / §7.2 / §7.4 里已成假话的「组合根未注入 `VoiceTranscoder` ⇒ AMR 不落副本」**(`transcode_not_wired` 从此只剩「引擎未装配 / 构造失败」一档;旧版本横幅保留当时状态,另加「已由 v0.80 修复」注)。⚠️ **未确证(如实)**:③ 的**生产端到端未跑**(红线:不重启后端)⇒ 本期证据是 `channelbridge` 的行为单测(真 AMR 夹具 → 真引擎 → RIFF/WAVE)与 `cmd/cyrene` 的**源码级接线用例**。详见 **§6**。
|
||||
|
||||
> **v0.79 是「面板动作真可用 + 记忆审查/语音用户可见面 + 链路图回合标识全覆盖」版(四批,均**已实现**;**唯一一处形状变化**)**:① **插件面板动作真可用**(切片 L1/PLG-2 的执行面接线)—— `POST /api/v1/admin/plugins/{id}/actions/{action}` **不再一律 501**:web-search 的四个动作 + 下拉候选来源(`plugin.providers`)都已登记**生产处理器**,**真读写** `$DATA_DIR/config/plugins/web-search.json`、探针**真发一次 HTTP**;失败回**结构化非 2xx**(错误码/文案/状态由插件侧给出,宿主只透传),新增 `invalid_payload` / `unknown_provider` / `not_configured` / `config_unreadable` / `config_write_failed` 与一批探针码(502);**只有「已声明但宿主未登记处理器」才回 `501 action_not_implemented`**(不再是兜底档)。审计新增附加字段 `code`(**不进 DB 记录体、不含请求体**)。详见 **§2.1 A14 / §4.1 / §4.14 / §7.2**。② **「入库前审查」(D3)的管理面落地**(切片 L45)—— 新增 2 条 `writeAuth` 端点 **`GET /api/v1/admin/memories/reviews?limit=`**(留痕列表,**不含记忆正文**;`limit` 缺省 100 / `≤0` 同缺省 / >500 钳位 / 非整数 `400`)与 **`POST /api/v1/admin/memories/{id}/revert`**(撤销一次软合并,**无损可逆**,落审计 `memory.review.revert`);未接线 ⇒ `500 memory_review_unavailable`(**绝不回空数组**假装没有留痕)。详见 **§2.2 B16/B17 / §4.1**。③ **语音消息的用户可见面落地**(切片 L45)—— 消息 DTO 的 **`voice`** 现在真的填充(REST 读取面 + `message_edited`),新增 **`GET /api/v1/media/{name}`**(200/206/304/416/`HEAD`;`audio/*` 内联 + `nosniff` + `Accept-Ranges`;**无 `ETag`**;名字非法 / 穿越 / 缺失统一 `404 media_not_found`),副本真落 `$DATA_DIR`(`documents.SaveAudio`,内容寻址、0600)。⚠️ **残留缺口(如实)**:`message_persisted` / `channel_persisted` 帧与握手 `history` 回放**仍不带 `voice`**;组合根**未注入 `VoiceTranscoder`** ⇒ **AMR 不落副本**(可播格式照落)—— ✅ **AMR 这一项已由 v0.80(切片 W4)修复**。详见 **§1.2 U19 / §3.4.4 / §4.1 / §4.4 / §4.5 / §7.2**。④ 🔴 **链路图的权威回合标识覆盖到全部编排回合**(切片 CODE-TC-2)—— 编排器驱动的**每一路**(含**普通单路回合**与队首续跑路)的图现在都带权威 `turnId`,取值 = 该轮消息行的 `chat_messages.turn_id` ⇒ `GET /sessions/{id}/messages` 的 **`toolCalls[]` 覆盖率从「只有真并行路」扩大到「全部编排回合」**(普通单路聊天与队首续跑回复现在也能回看工具)。🔴 **唯一形状变化**:`graph.id` 的 `#{回合标识}` 后缀现在**单路也追加**(`web-7` → `web-7#turn-…`)⇒ 客户端**一律使用响应里给出的 `graph.id` / `turns[].turnId`**,**不要自己拼** `{会话}-{序号}`。**缺席语义一字未改**:拿不到权威标识的写入路径(未接编排器的调用方、**后台思考** `sess-backthink`、旧后端)`turnId` 仍缺席、`toolCalls` 仍整体缺席;**v0.79 之前的单路历史行不回填**。详见 **§4.4 / §4.11 / §7.2 / §7.4-23**。**无破坏性变更**(新端点 / 新可选键 / 新错误码;老客户端若不自己拼 `graph.id` 则零改动)。
|
||||
> **v0.79 是「面板动作真可用 + 记忆审查/语音用户可见面 + 链路图回合标识全覆盖」版(四批,均**已实现**;**唯一一处形状变化**)**:① **插件面板动作真可用**(切片 L1/PLG-2 的执行面接线)—— `POST /api/v1/admin/plugins/{id}/actions/{action}` **不再一律 501**:web-search 的四个动作 + 下拉候选来源(`plugin.providers`)都已登记**生产处理器**,**真读写** `$DATA_DIR/config/plugins/web-search.json`、探针**真发一次 HTTP**;失败回**结构化非 2xx**(错误码/文案/状态由插件侧给出,宿主只透传),新增 `invalid_payload` / `unknown_provider` / `not_configured` / `config_unreadable` / `config_write_failed` 与一批探针码(502);**只有「已声明但宿主未登记处理器」才回 `501 action_not_implemented`**(不再是兜底档)。审计新增附加字段 `code`(**不进 DB 记录体、不含请求体**)。详见 **§2.1 A14 / §4.1 / §4.14 / §7.2**。② **「入库前审查」(D3)的管理面落地**(切片 L45)—— 新增 2 条 `writeAuth` 端点 **`GET /api/v1/admin/memories/reviews?limit=`**(留痕列表,**不含记忆正文**;`limit` 缺省 100 / `≤0` 同缺省 / >500 钳位 / 非整数 `400`)与 **`POST /api/v1/admin/memories/{id}/revert`**(撤销一次软合并,**无损可逆**,落审计 `memory.review.revert`);未接线 ⇒ `500 memory_review_unavailable`(**绝不回空数组**假装没有留痕)。详见 **§2.2 B16/B17 / §4.1**。③ **语音消息的用户可见面落地**(切片 L45)—— 消息 DTO 的 **`voice`** 现在真的填充(REST 读取面 + `message_edited`),新增 **`GET /api/v1/media/{name}`**(200/206/304/416/`HEAD`;`audio/*` 内联 + `nosniff` + `Accept-Ranges`;**无 `ETag`**;名字非法 / 穿越 / 缺失统一 `404 media_not_found`),副本真落 `$DATA_DIR`(`documents.SaveAudio`,内容寻址、0600)。⚠️ **残留缺口(如实)**:`message_persisted` / `channel_persisted` 帧与握手 `history` 回放**仍不带 `voice`** —— ✅ **该缺口已由 v0.97(切片 M44)修复**(首帧即带,见 §6 v0.97);组合根**未注入 `VoiceTranscoder`** ⇒ **AMR 不落副本**(可播格式照落)—— ✅ **AMR 这一项已由 v0.80(切片 W4)修复**。详见 **§1.2 U19 / §3.4.4 / §4.1 / §4.4 / §4.5 / §7.2**。④ 🔴 **链路图的权威回合标识覆盖到全部编排回合**(切片 CODE-TC-2)—— 编排器驱动的**每一路**(含**普通单路回合**与队首续跑路)的图现在都带权威 `turnId`,取值 = 该轮消息行的 `chat_messages.turn_id` ⇒ `GET /sessions/{id}/messages` 的 **`toolCalls[]` 覆盖率从「只有真并行路」扩大到「全部编排回合」**(普通单路聊天与队首续跑回复现在也能回看工具)。🔴 **唯一形状变化**:`graph.id` 的 `#{回合标识}` 后缀现在**单路也追加**(`web-7` → `web-7#turn-…`)⇒ 客户端**一律使用响应里给出的 `graph.id` / `turns[].turnId`**,**不要自己拼** `{会话}-{序号}`。**缺席语义一字未改**:拿不到权威标识的写入路径(未接编排器的调用方、**后台思考** `sess-backthink`、旧后端)`turnId` 仍缺席、`toolCalls` 仍整体缺席;**v0.79 之前的单路历史行不回填**。详见 **§4.4 / §4.11 / §7.2 / §7.4-23**。**无破坏性变更**(新端点 / 新可选键 / 新错误码;老客户端若不自己拼 `graph.id` 则零改动)。
|
||||
|
||||
> **v0.78 是六面增量版(⚠️ **①~⑤ 已实现;⑥ 只到落库面**(用户可见面**已由 v0.79 补齐**);以纯增量为主,无破坏性变更)**:① **消息读取面 `toolCalls[]` 落地**(切片 CODE-TC)—— v0.76 ① 从「未实现」改为**已实现**(数据源 `flow_graphs` + 迁移 `0023` 的**按路查询键** `lane_turn_id`);🔴 **v0.78 时覆盖率受限(如实)**:只有**真并行路**的图带权威 `turnId`,普通单路回合**查不到图 ⇒ 字段缺席**(契约允许,但不是想要的覆盖率)—— **已由 v0.79 补齐**(见 §6 v0.79 ④)。详见 **§4.4 / §7.2 / §7.4-23**。② **插件管理面板接入点(管理面新增两条路由 + 一条 WS 帧)**(切片 PLG-2)—— 新增 `GET /api/v1/admin/panels`(**按调用者角色过滤**后的接入点快照,viewer 可读)与 `POST /api/v1/admin/plugins/{id}/actions/{action}`(**只接受该插件声明过的动作名**,未声明 ⇒ `404 action_not_declared`;写动作 `writeAuth` + 审计);WS 下行帧 **`panels_changed`**(插件生命周期变化后推,**只发 owner/admin**)。⚠️ **动作执行器在 v0.78 无生产实现 ⇒ 当时所有已声明动作都回 `501 action_not_implemented`** —— **已由 v0.79 接线**(见 §6 v0.79 ① 与 §4.14)。详见 **§2.1 A13/A14 / §3.4.5 / §4.14**。③ **搜索 provider 可变源**(切片 PLG-4)—— 插件 `web-search` 的配置从 `.env` 迁到 `$DATA_DIR/config/plugins/web-search.json`(0600,文件名 = 插件 id),组合根「**先可变源、缺失回落 `.env` 播种**」;面板数据面 = `plugin.listProviders` / `plugin.saveProvider` / `plugin.testProvider` / `plugin.reauthProvider` 四个动作。🔴 **不新增任何 env 键**(`.env` 已冻结为引导面)。详见 **§4.14(c)**。④ **账号上下线落事件 + 渠道事件实时帧**(切片 E7)—— 账号 online/offline 迁移落进 `internal/runtime/events`(复用迁移 `0021`,`tier=t2` / `category=lifecycle`),并经**既有进程内 bus** 推成 WS 下行帧 **`channel_event`**(**只发 owner/admin**,viewer 走 REST 只读档)。⚠️ **当前只覆盖账号状态迁移这一路**;覆盖其余全部渠道事件的装饰器**未接线**(见 §7.2)。详见 **§3.4.5**。⑤ **微信 iLink 账号状态三态**(切片 CODE-F3)—— `GET /api/v1/admin/channels/{platform}/login-status` 新增可选 `authState` / `authDetail`(**有效 / 被拒 / 未知**);🔴 **`errcode=-14` 是「被拒」不是「过期」**,**未知同样不许写成「过期」**。⚠️ `GET /admin/channels` 的 `accounts[]` **每账号**三态**未实现**(见 §7.2)。详见 **§2.4(c)**。⑥ **语音消息落库面**(切片 CODE-VOICE)—— 迁移 `0020` 给 `chat_messages` 加 `voice` 列(`TEXT NOT NULL DEFAULT ''`);**用户可见面(DTO `voice` + `GET /api/v1/media/{name}`)在 v0.78 时未实现,已由 v0.79 落地**(切片 L45,见 §6 v0.79 ③);残留缺口(WS 实时帧 / 握手回放不带 `voice`;AMR 那一项**已由 v0.80 修复**)见 **§4.5 / §7.2**。**无破坏性变更**(新端点 / 新可选键 / 新帧;老客户端零影响)。
|
||||
> **v0.78 是六面增量版(⚠️ **①~⑤ 已实现;⑥ 只到落库面**(用户可见面**已由 v0.79 补齐**);以纯增量为主,无破坏性变更)**:① **消息读取面 `toolCalls[]` 落地**(切片 CODE-TC)—— v0.76 ① 从「未实现」改为**已实现**(数据源 `flow_graphs` + 迁移 `0023` 的**按路查询键** `lane_turn_id`);🔴 **v0.78 时覆盖率受限(如实)**:只有**真并行路**的图带权威 `turnId`,普通单路回合**查不到图 ⇒ 字段缺席**(契约允许,但不是想要的覆盖率)—— **已由 v0.79 补齐**(见 §6 v0.79 ④)。详见 **§4.4 / §7.2 / §7.4-23**。② **插件管理面板接入点(管理面新增两条路由 + 一条 WS 帧)**(切片 PLG-2)—— 新增 `GET /api/v1/admin/panels`(**按调用者角色过滤**后的接入点快照,viewer 可读)与 `POST /api/v1/admin/plugins/{id}/actions/{action}`(**只接受该插件声明过的动作名**,未声明 ⇒ `404 action_not_declared`;写动作 `writeAuth` + 审计);WS 下行帧 **`panels_changed`**(插件生命周期变化后推,**只发 owner/admin**)。⚠️ **动作执行器在 v0.78 无生产实现 ⇒ 当时所有已声明动作都回 `501 action_not_implemented`** —— **已由 v0.79 接线**(见 §6 v0.79 ① 与 §4.14)。详见 **§2.1 A13/A14 / §3.4.5 / §4.14**。③ **搜索 provider 可变源**(切片 PLG-4)—— 插件 `web-search` 的配置从 `.env` 迁到 `$DATA_DIR/config/plugins/web-search.json`(0600,文件名 = 插件 id),组合根「**先可变源、缺失回落 `.env` 播种**」;面板数据面 = `plugin.listProviders` / `plugin.saveProvider` / `plugin.testProvider` / `plugin.reauthProvider` 四个动作。🔴 **不新增任何 env 键**(`.env` 已冻结为引导面)。详见 **§4.14(c)**。④ **账号上下线落事件 + 渠道事件实时帧**(切片 E7)—— 账号 online/offline 迁移落进 `internal/runtime/events`(复用迁移 `0021`,`tier=t2` / `category=lifecycle`),并经**既有进程内 bus** 推成 WS 下行帧 **`channel_event`**(**只发 owner/admin**,viewer 走 REST 只读档)。⚠️ **当前只覆盖账号状态迁移这一路**;覆盖其余全部渠道事件的装饰器**未接线**(见 §7.2)。详见 **§3.4.5**。⑤ **微信 iLink 账号状态三态**(切片 CODE-F3)—— `GET /api/v1/admin/channels/{platform}/login-status` 新增可选 `authState` / `authDetail`(**有效 / 被拒 / 未知**);🔴 **`errcode=-14` 是「被拒」不是「过期」**,**未知同样不许写成「过期」**。⚠️ `GET /admin/channels` 的 `accounts[]` **每账号**三态**未实现**(见 §7.2)。详见 **§2.4(c)**。⑥ **语音消息落库面**(切片 CODE-VOICE)—— 迁移 `0020` 给 `chat_messages` 加 `voice` 列(`TEXT NOT NULL DEFAULT ''`);**用户可见面(DTO `voice` + `GET /api/v1/media/{name}`)在 v0.78 时未实现,已由 v0.79 落地**(切片 L45,见 §6 v0.79 ③);残留缺口(WS 实时帧 / 握手回放不带 `voice`——**已由 v0.97(切片 M44)修复**;AMR 那一项**已由 v0.80 修复**)见 **§4.5 / §7.2**。**无破坏性变更**(新端点 / 新可选键 / 新帧;老客户端零影响)。
|
||||
|
||||
> **v0.75 是记忆整理试算版(已实现,纯增量)**:新增 1 条管理端点 **`POST /api/v1/admin/memories/tidy/dry-run`**(**只读语义的 POST**,`writeAuth` = owner/admin)—— 跑**同一套**记忆整理规则(召回门 + 包含判定 + 规则否决,与低峰定时轮共用代码路径),把候选**直接回在响应里**,**一行都不写**(不写 `memory_tidy_candidates` / `memory_tidy_runs`,绝不改 `memory_items`)。修的是「整理器只在 23:00–07:00 跑(design/23 §4),在它第一次开跑前面板只能空着」——主人要「现在就想看到在 736 条上它会建议合并哪些」。**零副作用是硬约束**(后端有逐行快照单测钉死);失败**一律如实作答**(`tidy_unavailable` / `tidy_disabled` / `tidy_recall_unavailable` / `tidy_timeout`),**绝不回空候选数组假装「没有建议」**。**无破坏性变更**(新端点;既有端点/帧一字未改)。详见 **§2.2 B14 / §4.12**。
|
||||
|
||||
@@ -123,7 +123,7 @@
|
||||
| U3 | GET | `/api/v1/personas` | 任意已认证 | 200 | 角色目录 `{data:[{id,name,character,isDefault,mode,memoryNamespace,avatar?,chatBackground?,mood}]}`。`mood` 为空 = 该角色暂无被跟踪情绪(不伪造)。**不含** admin 专属 `thinking` 旋钮。 |
|
||||
| U4 | GET | `/api/v1/meta/enums` | 任意已认证 | 200 | 共享枚举权威表 `{data:{platforms,moods,modes,unknown}}`;每项 `{id,label}`。`unknown` 恒 `"display-raw"`。**未知取值原样显示,不要丢弃/报错**(服务端新增取值是非破坏性变更)。详见 [§4.7](#47-枚举权威表)。 |
|
||||
| U5 | POST | `/api/v1/audio/transcribe` | **owner** | 200 / 400 / 500 | STT。body `{audio,format?,deviceId?}` → `{data:{text, provider?}}`。`provider` 只在结果为空/降级时出现(如 `"silent"`)。空 audio → `400`;未接语音 → `500 voice_unavailable`;识别失败 → `500 stt_failed`。**owner-only + 审计**。 |
|
||||
| U6 | POST | `/api/v1/audio/speech` | **owner** | 200 / 400 / 500 | TTS。body `{text,voice?,format?,instruction?}` → **`200` 原始音频字节**(`Content-Type: audio/<format>`,**非 JSON 信封**)。空 text → `400`;未接语音 → `500 voice_unavailable`;失败 → `500 tts_failed`;静默空音频 → `500 tts_empty` 且响应头 `X-Voice-Provider: silent`。**owner-only + 审计**。 |
|
||||
| U6 | POST | `/api/v1/audio/speech` | **owner** | 200 / 400 / 500 | TTS。body `{text, voice?, format?, instruction?, sessionId?}` → **`200` 原始音频字节**(**非 JSON 信封**)。🔴 **鉴权 = 既有双凭据门(v0.97 明示)**:`Authorization: Bearer <owner JWT>` **或** `X-API-Key: <已签发 key>`,两分支同 `authorize(RoleOwner)`——Bearer 走完整 JWT 校验 + owner-only(**不因「带了 Bearer」就放行**);X-API-Key 一律 owner 级,未知/吊销 key 一律 `401` 且**不回落** Bearer(fail-closed);key 表未注入时只认 Bearer。无凭据/无效凭据 `401`,错误形状同 §4.1 信封。🔴 **v0.96 起发音人按 `sessionId` 的生效人格解析**(会话绑定 → 全局默认人格 → 空 ⇒ 配置的 `default`),**省略 `sessionId` 不等于没有角色**;请求体 `voice` 接受但不生效;产物恒 WAV,实际格式按字节嗅探(见 §6 v0.96)。空 text → `400`;未接语音 → `500 voice_unavailable`;失败 → `500 tts_failed`;人格无映射 → `500 tts_persona_unmapped`;连生效人格都没有且没配 default → `500 tts_no_character`;静默空音频 → `500 tts_empty` 且响应头 `X-Voice-Provider: silent`。**owner-only + 审计**。 |
|
||||
| U7 | POST | `/api/v1/audio/voice-clone` | **owner** | 200 / 400 / 500 | 声音复刻建音色。body `{url,prefix?}` → `{data:{voiceId}}`。参考 URL **绝不进日志/审计**。空 url → `400`;失败 → `500 voice_clone_failed`。 |
|
||||
| U8 | POST | `/api/v1/upload` | owner/admin | 201 / 400 / 413 / 415 / 500 | **图片上传**,`multipart/form-data` 字段 `file`(光栅图,**单文件字节 ≤ 5 MiB**,即 `maxUploadBytes = 5 << 20`)→ `{data:{url,size,mime,name}}`。`url` 是**绝对同源 URL**,可直接作 `chat.image`(该路径的 5 MiB 在**上传时**定死,与 `chat.image` 的字符长度判据无关,见 [§4.5](#45-图片--文档--语音链路))。嗅探裁决 png/jpeg/gif/webp/bmp(拒 SVG/HTML);原始文件名丢弃,用 128-bit 随机名。缺文件/空文件 `400`、超限 `413 payload_too_large`、非白名单 `415 unsupported_media_type`。**带审计**。 |
|
||||
| U9 | GET | `/api/v1/files/{name}` | **无需**(能力 URL) | 200 / 404 | 上传文件读取。`name` 仅接受「32 位小写 hex + 白名单扩展名」;路径穿越全形态拒绝。**GET 不鉴权**是该 URL 为 128-bit 随机能力地址(`<img>`/provider 无法带 token);任何持有 URL 者可取,勿外泄。 |
|
||||
@@ -155,11 +155,15 @@
|
||||
| 档位 | 中间件 | 条数 | 说明 |
|
||||
|---|:--:|:--:|---|
|
||||
| **只读监控档** | `viewerAuth`(owner/admin/viewer) | **14** | 非敏感投影:无凭据、无他人账号数据、无消息正文。**v0.78 新增两条**:面板接入点快照(A13)与插件动作分发(A14,**写门落在 handler 内**,见 A14 行注) |
|
||||
| **owner/admin 读档** | `writeAuth`(owner/admin) | **25** | viewer 不得看到的一切:记忆、LLM 栈、账号/key、后台思考、审计链、渠道消息日志、完整会话导出、配置事实面(v0.68)、最近一轮链路图快照(v0.71)、记忆整理试算(v0.75)、入库前审查留痕与回滚(**v0.79**,B16/B17)、链路图时间轴(**v0.80**,B18)、自排闸只读快照(**v0.82**,B19)、记忆整理应用面五条(**v0.83**,B20–B24)、**按动作批量拒绝候选(v0.85,B25)** |
|
||||
| **owner/admin 读档** | `writeAuth`(owner/admin) | **31** | viewer 不得看到的一切:记忆、LLM 栈、账号/key、后台思考、审计链、渠道消息日志、完整会话导出、配置事实面(v0.68)、最近一轮链路图快照(v0.71)、记忆整理试算(v0.75)、入库前审查留痕与回滚(**v0.79**,B16/B17)、链路图时间轴(**v0.80**,B18)、自排闸只读快照(**v0.82**,B19)、记忆整理应用面五条(**v0.83**,B20–B24)、**按动作批量拒绝候选(v0.85,B25)**、**记忆整理调优键读写(v0.92,B26/B27)**、**渠道非消息事件与静默窗口四条(v0.95 补登,B28–B31)** |
|
||||
| **owner-only 档** | `ownerAuth`(owner) | **40** | 带审计的策略写、每个获取凭据的渠道端点、其余全部变更 |
|
||||
| **合计** | — | **79** | `14 + 25 + 40`(v0.85) |
|
||||
| **合计** | — | **85** | `14 + 31 + 40`(v0.95) |
|
||||
|
||||
**管理路由总数 = 14 + 25 + 40 = 79 条。**(v0.85 起;`14 + 24 + 40 = 78` 是 v0.84 的读数)
|
||||
**管理路由总数 = 14 + 31 + 40 = 85 条(`routes.go` 逐条枚举;补齐 B28–B31 后与代码逐条相等)。**
|
||||
> 🔴 **本节此前的「18 + 43 + 43 = 104」是一个口径错误,已改正**(2026-10-26 由切片 CONTRACTMATRIX 逐条复算):那三个数**不是本表三段的分组计数**,而是**整张表按中间件用量全文统计**的结果 —— `writeAuth` 全文 **43** = 本表 **31** + 用户面 12(`chat` / `upload` / `documents` / `media/{name}` / `history` 等);`ownerAuth` 全文 **43** = 本表 **40** + `audio/*` **3**;而「`viewerAuth` 18」**根本不是 viewerAuth**(它全文就是 **14**,且全部是本表路由),18 是「用户面被中间件包住」的条数(12 + 3 + 3)。把 18 与两个 43 相加 ⇒ 用户面那 18 条被**重复计入一次**。正确口径:被四种中间件包住的路由 = **85(管理)+ 18(用户面)= 103**,另有 6 条未包(4 条公开 + `GET /api/v1/files/{name}` + `/ws/chat`),字面量注册合计 109,再加 `mountWebhooks` 一条动态注册 ⇒ `grep -c` 得 110。
|
||||
> ⚠️ 同理,此前「差 23 条遗漏行」也是巧合:**23 恰好是用户面路由总数**(§1.1 的 P1–P4 共 4 条 + §1.2 的 U1–U19 共 19 条)—— 用户面本来就一条不缺。真实缺口是 **4 条**(全在 `writeAuth`),已补为 B28–B31。
|
||||
> ✅ **v0.94(MCP 管理页 + 按上游分节的 `origin`)与 v0.95(coalesce 两端点 + 窗口语义)的形状级条目已登记**,见 §6 v0.94 / v0.95。
|
||||
> ✅ **本表现与 `routes.go` 逐条相等**(A14 / B31 / C40 = 85),此前的「已知文档债」警告已失效、已删除。反向也核过:81 行旧条目里**没有一条**是幽灵行,且**零条分档错误**。⚠️ 唯一一处**文档已自行标注**的差异是 B17(代码 `writeAuth`,`design/23 §11` 对同族标 owner-only)—— 那是对**设计文档**的差异,不在本节范围。
|
||||
|
||||
> **v0.78 计数变更**:`viewerAuth` 段新增两条(A13 `GET /admin/panels`、A14 `POST /admin/plugins/{id}/actions/{action}`)⇒ 12 → **14**、总数 66 → **68**。A14 走 `viewerAuth` 中间件,但**写动作在 handler 内按 owner/admin 收紧**(路由中间件只能有一道门)—— 因此它**是**本表的一行,读写面却不同档,详见 §2.1 A14 与 §4.14。
|
||||
|
||||
@@ -199,7 +203,7 @@
|
||||
|
||||
> **A13 / A14 的角色门依据**(`routes.go` 紧邻注释):快照端点对 viewer 开放是因为它回的**本来就是被裁掉写面的只读投影**;动作端点的读来源动作对 viewer 可见,**写门落在 handler 内**(路由中间件只能有一道门)。`panels_changed` 帧(§3.4.5)**不进** `frameReachesAll` 白名单 ⇒ **viewer 收不到该帧**,需自行刷新/轮询重拉 A13。
|
||||
|
||||
### 2.2 owner/admin 读(19 条,`writeAuth`)
|
||||
### 2.2 owner/admin 读(31 条,`writeAuth`)
|
||||
|
||||
| # | 方法 | 路径 | 要点 |
|
||||
|---|---|---|---|
|
||||
@@ -230,6 +234,13 @@
|
||||
| B24 | POST | `/api/v1/admin/memories/tidy/run` | **手动真跑一轮整理(v0.83 新增,切片 TIDY-1)** —— 不等 23:00 的 panic button:按**同一套规则与配置**立刻跑一轮,**写候选 + 写运行留痕**,**不自动应用**(人工在环,能落地的只有 B21/B22)。**无请求体**。`200 {data:{runId,trigger:"manual",ruleVersion,durationMs,partitions,scanned,compared,pairs,vetoed,errors,truncated[],vetoReasons{},candidatesCreated,candidatesDuplicate,candidatesByAction{}}}`。🔴 **如实计数**:`candidatesCreated` = 本次**新建**候选数、`candidatesDuplicate` = 被**幂等键**挡下的重复数(同一批数据重复跑 ⇒ 全进 duplicate,**不是失败**);`errors` 非 0 面板必须显示;`truncated[]` 是**被护栏截断**的分区标签(扫描/比对/候选上限,或墙钟预算 `[budget]`)。失败:配置关闭 → `409 tidy_disabled`;超时 → `503 tidy_timeout`(**已跑部分仍写入了留痕**);只接线了试算(没接真跑)→ `500 tidy_run_unavailable`;未接线 → `500 tidy_unavailable`;失败 → `500 tidy_run_failed`。**审计不在这里写** —— 由领域侧记一条 `memory.tidy.run`(`trigger=manual` + run_id + 计数),**一次点击一条**。语义见 **§4.15(b)** |
|
||||
|
||||
| B25 | POST | `/api/v1/admin/memories/candidates/reject?action=&limit=` | **按动作批量拒绝整理候选(v0.85 新增,切片 TIDY-AUTO)** —— 把某一类 `pending` 候选一次性标成 `rejected`(**幂等**:同一调用再来一次 ⇒ `total:0`;**只改候选 status,绝不碰记忆行**)。`action` **必填**、**闭集** `merge\|link\|archive\|reclassify\|keep`(未知/空 → `400 bad_request`);`limit` 缺省 **1000**、硬顶 **10000**、非整数 → `400 bad_request`。`200 {data:{action,limit,total,rejected,truncated}}`。🔴 **两个计数口径不同**:`total` = 命中筛选的**真**条数,`rejected` 只覆盖 `limit` 之内 ⇒ 不等时 **`truncated:true`**(客户端必须如实显示)。**落审计** `memory.tidy.reject`(只带计数)。用法见 **§4.15(e)**(`link` 停产后的存量清理就靠它)。⚠️ **不是「删除」**:候选行仍在,只是不再待办 |
|
||||
| B26 | GET | `/api/v1/admin/memories/tidy/tuning` | **记忆整理调优键读取(v0.92 新增,切片 M4)**,只读、**不审计**。**无参数**。`200 {data:{path,present,hot,hotError?,specs[],values{},envValues{}}}`。🔴 **三块分工(客户端按此编程)**:`specs[]` = **取值规则唯一真源**(`{key,kind,unit?,min?,max?,fallback}`;`fallback` 只给**来源** `env:<键名>` | `panel:<键名>` | `code-default`,**不给数值**);`values` = **盘上**的值,**键缺位 ⇒ 该键在对象里不出现**(= 跟随兜底),**不是 0**;`envValues` = 键缺位时**下一档(`.env`)**的进程生效值(连 `.env` 档也没有 ⇒ 该键不出现;网关绝不 fopen `.env`)。管 7 键:`intervalSec`/`quietIntervalSec`/`quietStartHour`/`quietEndHour`/`ingestGate`/`autoApply`/`tempTtlSec`;同文件不受管的键(`t1Judge` 等)不回显。🔴 **`hot` 恒为 false** —— 读不改变任何东西,这是**如实占位**,别读成能力声明。失败:数据目录未接线 → `500 tuning_unavailable`。语义见 **§4.17** |
|
||||
| B27 | PUT | `/api/v1/admin/memories/tidy/tuning` | **记忆整理调优键写入(v0.92 新增,切片 M4)**。门同 B26(`writeAuth`)。请求体 = **扁平 JSON 对象**,只放要改的键;值 int(秒/钟点)或 bool;**`null` = 清除该键**(回到下一档兜底)。🔴 **三态(硬要求,逐字)**:键**缺位** ⇒ 不改动;**合法值** ⇒ 采用;**非法值** ⇒ `400 invalid_payload` 且**盘上一个字节不动**;**未知键(含拼错)也归「非法」,绝不静默忽略**。`200` 与 B26 **同形状**,但值是**写完重新读盘**的真值(带 `hot`/`hotError`)。错误码:`400 invalid_payload`(值非法/未知键/体不是对象/体是 `null`)|`409 tuning_corrupt`(盘上那份运行期读不走 ⇒ **拒绝覆盖**)|`500 tuning_unavailable`|`500 tuning_write_failed`(落盘失败,盘上未改动)。**两条语义**:**值一个都没变时不写盘**(连 mtime 都不动);同文件**不受管的键原样保留**。🔴 **`hot` 诚实口径**:`hot:true` = 值已叠到运行期(整理档会重排心跳,否则新周期要等旧周期那一觉睡完);`hot:false` = **已落盘但运行期没接上**,`hotError` 给原因,面板文案必须是「**重启后端后生效**」—— **绝不把「落盘了」说成「生效了」**。**落审计** `memory.tidy.tuning`(`result` ∈ `ok\|error`)。语义见 **§4.17** |
|
||||
|
||||
| B28 | GET | `/api/v1/admin/channels/events` | **渠道非消息事件列表(倒序)**,只读、**不审计**。此前只登记在 §2.4(a) D1,本条是它的矩阵行。query:`platform`(闭集 `qq`/`kook`/`telegram`/`weixin-ilink`/`webhook`,未知 ⇒ `400`,**绝不降级成「不过滤」**)/ `tier`(`t1\|t2\|t3`,其它 ⇒ `400`)/ `category`(🔴 **不做白名单**:类别轴的权威在适配器层且会持续增加,未知类别是一次**精确匹配**(返回空),不是 `400`)/ `channelId` / `since` / `until`(RFC3339 或 Unix 毫秒;跨度 > **90 天** ⇒ `400`;**两个都不给就不注入任何时间条件** —— 端点绝不替客户端造下界)/ `limit`(`≤0` 或缺省 ⇒ 存储层默认 **100**;> **500** 钳到 500 **不报错**;非整数 ⇒ `400`)/ `offset`(非法整数 ⇒ `400`,负值归 0)。响应 `{data:{events:[…],total}}`,`events` **恒为 `[]` 不是 `null`**。元素键见 §2.4(a)。**不含正文、不含媒体 URL、不含凭据**。未接线或读库失败 ⇒ `500 channel_events_unavailable`(**绝不回全 0 假汇总**)。⚠️ `weixin-ilink` 事实上无此类事件 ⇒ 对它查询恒为空,**不得用别平台事件顶替** |
|
||||
| B29 | GET | `/api/v1/admin/channels/events/counters` | **渠道事件逐类计数(近似值)**,只读、**不审计**。此前只登记在 §2.4(a) D2。query 仅 `platform`(空 = 全部平台;未知 ⇒ `400`,与 B28 同一把校验)。响应 `{data:{approx:true,counters:[{platform,accountId,tier,category,platformType,count,firstSeenAt,lastSeenAt}]}}` —— `approx` 与 `counters` **同级**。🔴 **`approx` 恒为 `true` 且不是装饰**:计数是**内存聚合 + 每 30s flush**,读到的表**不含待 flush 部分** ⇒ 只少不多、是个**下界**;面板必须如实标注,别画成精确账。未接线或读库失败 ⇒ `500 channel_events_unavailable` |
|
||||
| B30 | GET | `/api/v1/admin/channels/coalesce` | **渠道静默窗口合并的读(v0.95)**,只读、**不审计**。此前只登记在 §6 v0.95,本条是它的矩阵行。**无参数**。`200 {data:{path,present,hot,default{…六键…},patch{},conversations{},rules{…},ownerPrivate:"never"}}`。🔴 **三块分工(客户端按此编程)**:`default` = **生效值**(代码默认 + 盘上 `default` 段,**已 clamp**);`patch` = **盘上原值**,**键缺位 ⇒ 该键不出现**(= 跟随默认,**不是 0**);`conversations` 的键是 `platform\|channel`(可读形态,NUL 已换成 `\|`),值是**生效值**。`hot` 恒为 `true`(桥每次发车前重读同一个 store ⇒ **这是事实,不是承诺**)。🔴 `ownerPrivate` 恒为 `"never"` = **只读事实**:主人私聊的车道类型由 actor 元数据判定,**没有任何请求键能翻转它**,面板只显示一行说明、不给开关。取值区间:`windowMs` **300–15000**、`maxWaitMs` 下界**跟随窗口**且 ≤ **30000**、`maxBatchMessages` **1–50**;越界是 **clamp + WARN,不是 `400`**。`path` 是 `$DATA_DIR/config/coalesce.json` 的落点(**零新 `.env` 键**)。未接线 ⇒ `500 coalesce_unavailable` |
|
||||
| B31 | PUT | `/api/v1/admin/channels/coalesce` | **渠道静默窗口合并的写(v0.95)**,门同 B30(`writeAuth` = owner/admin)。请求体 `{default?:{enabled?,windowMs?,maxWaitMs?,maxBatchMessages?,applyToStrangers?,strangerWindowMs?}, conversations?:{"<platform>\|<channel>": {…} 或 null}}`。🔴 **三态(硬要求)**:键**缺位** ⇒ 不动;**合法值** ⇒ 采用;**非法值** ⇒ `400 invalid_payload` 且**盘上一个字节都不动**(校验全部发生在落盘**之前**,是结构性的、不靠回滚)。**严格解码**:未知键(含拼错)与「一个以上的 JSON 对象」都归 `400 invalid_payload`,**绝不静默忽略**;**负数**同样 `400 invalid_payload`(越界的合法数字**不是** 400 —— 由运行期 clamp + WARN)。`default` 缺位 ⇒ 全局不动;`conversations` 里值为 `null` ⇒ **删掉该会话覆盖**(回到全局 default),没提到的会话一概不动。**值一个都没变时不写盘**(连 mtime 都不动)但仍回 `200`。`200` 响应与 B30 **同形状**,是**写完重新读盘**的真值。错误码:`400 invalid_payload`(未知键/负数/体形态错)|`400 bad_request`(读体失败)|`500 coalesce_unavailable`|`500 coalesce_write_failed`(落盘失败,**盘上未改动**)。**落审计** `channels.coalesce`(`result` ∈ `ok\|error`;⚠️ **空补丁也记一条 `ok`**) |
|
||||
|
||||
> **读取 `GET /admin/observability/{logs,metrics,trace}` 兼任可观测推送的订阅握手**(该凭据的连接全部断开即退订)。REST 响应形状不变。
|
||||
|
||||
@@ -293,7 +304,7 @@
|
||||
|
||||
### 2.4 v0.67 登记的管理面(渠道事件查询 + 账号在线状态)—— **(a) 已实现 / (b) 已实现**;v0.78 追加 **(c) iLink 凭据三态**;v0.90 追加 **(d) 渠道连接态三态**
|
||||
|
||||
> ⚠️ **本节两项都已落地**(账号在线状态 `52dac45`;渠道事件查询面本切片 E6):形状取自设计文档(`design/26 §4.5`、`§2.6`),落地时已按代码核对。**§2.0 的计数(12 + 11 + 40 = 63)原本不含本节端点**;两个渠道事件端点已登记进 `routes.go`(`owner|admin` 档),下版改文档时应把它们并入 §2.1/§2.2 并更新计数。**本行留在此处仅为与 v0.67 冻结版对照**。
|
||||
> ⚠️ **本节两项都已落地**(账号在线状态 `52dac45`;渠道事件查询面本切片 E6):形状取自设计文档(`design/26 §4.5`、`§2.6`),落地时已按代码核对。**§2.0 的计数原本不含本节端点**;两个渠道事件端点已登记进 `routes.go`(`owner|admin` 档)。✅ **已并入矩阵(v0.95 补登)**:B28 = `GET …/channels/events`、B29 = `GET …/channels/events/counters`。🔴 **形状级真源从此以 B28/B29 为准**,下面 D1/D2 两行**保留仅作 v0.67 冻结版对照**,改端点时只改 B28/B29,别在四处一起改。
|
||||
|
||||
#### (a) 渠道事件(非消息事件)只读查询 —— **已实现**
|
||||
|
||||
@@ -364,7 +375,7 @@
|
||||
{"type":"history","sessionId":"web","data":{"messages":[ <historyMessage> ... ]}}
|
||||
```
|
||||
|
||||
- 内容 = 主会话最近 **50** 条(`wsReplayLimit`),`thinking` 恒缺席(见 §3.4.4 口径更正);每条**可能**带可选 `turnId`(v0.66,空则键省略)。🔴 **`voice` 不在握手回放里**(v0.67 登记时曾计划覆盖):v0.79 的 `voice` 只填在 4 个 REST 读取面 + `message_edited`,握手 `history` 回放**不带该键**(见 §6 v0.79 ③ / §4.4 / §7.2)。
|
||||
- 内容 = 主会话最近 **50** 条(`wsReplayLimit`),`thinking` 恒缺席(见 §3.4.4 口径更正);每条**可能**带可选 `turnId`(v0.66,空则键省略)。✅ **`voice` 在握手回放里(v0.97 起,切片 M44)**:与 REST 读面同一收口点,形状与 §4.4 一致,非语音消息键缺席(v0.67~v0.96 期间曾长期缺席,那时的「不要在握手回放上等 `voice`」口径已作废)。
|
||||
- **viewer 连接完成握手时完全不做回放**(其只读窗口是 REST 监控面)。
|
||||
- 回放是尽力而为:存储未接或读出错静默跳过。
|
||||
|
||||
@@ -478,7 +489,7 @@
|
||||
> **思维链的唯一出口是实时帧**:§3.4.2 的 `thinking.start` / `thinking.delta` / `thinking.end`(owner/admin 档)。客户端**不要**为「从历史里读回思维链」写任何代码。
|
||||
> ⚠️ 本行**更正**本文档 v0.65 及更早的写法(旧版称「历史思维链只有 `GET /sessions/{id}/messages`(owner/admin)返回」)—— **那是文档写错了**,与实现不符。见 §7.3。
|
||||
> 另注:管理面板人格清单里的 `thinking` 是**人格旋钮**(`GET /admin/personas` 的 `thinking` 块),与消息 DTO 的 `thinking` 不是同一个字段。
|
||||
> **`voice` 覆盖同一批读取面(v0.67 登记;🔴 **v0.79 只落地了其中一部分**)**:消息 DTO 的可选 `voice` 对象**设计上**一次覆盖 `message_persisted` / `channel_persisted` / `message_edited` 与握手 `history` 回放(形状与语义见 §4.4)。🔴 **v0.79 实测覆盖**:只有 `message_edited`(与 §4.4 列出的 4 个 REST 读取面)会带它;**`message_persisted` / `channel_persisted` 与握手 `history` 回放仍不带 `voice`**(补齐需给 `history.Message` 加字段,**未做**)⇒ 客户端**不要在实时帧 / 握手回放上等 `voice`**。**WS 协议本身零变化**:无新帧、无新上行字段;`voice` 只是既有 DTO 的新可选键。
|
||||
> **`voice` 覆盖同一批读取面(v0.67 登记;✅ **v0.79 落地 REST 面,v0.97 补齐 WS 面**)**:消息 DTO 的可选 `voice` 对象覆盖 `message_persisted` / `channel_persisted` / `message_edited` 与握手 `history` 回放(形状与语义见 §4.4)。🔴 **v0.97 起全部在读面上填充**:`message_edited` 与 4 个 REST 读取面自 v0.79 起;`message_persisted` / `channel_persisted` 帧与握手回放自 v0.97(切片 M44,落库顺序改为 INSERT→写列→notify,**首帧即带**)⇒ 客户端可以统一按「`voice` 键存在 = 语音消息」处理所有读面。**WS 协议本身零变化**:无新帧、无新上行字段;`voice` 只是既有 DTO 的可选键。
|
||||
|
||||
#### 3.4.5 子代理 / 任务 / 系统帧(owner+admin)
|
||||
|
||||
@@ -599,9 +610,9 @@
|
||||
{ "name": "web_search", "status": "ok", "durationMs": 1200,
|
||||
"argsSummary": "{\"query\":\"明天天气\"}", "resultSummary": "晴,18~26℃" }
|
||||
],
|
||||
"voice": { // v0.67 登记,v0.79 已实现(切片 L45);非语音消息键缺席;🎤 判据 = 本键存在
|
||||
"source": "asr", // "asr"(服务端识别,含云端)| "platform"(渠道平台自带转写)
|
||||
"status": "transcribed", // "transcribed" | "empty" | "failed"(闭集三态)
|
||||
"voice": { // v0.67 登记,v0.79 已实现(切片 L45);v0.98 起也承载她的出站语音;非语音消息键缺席;🎤 判据 = 本键存在 && status != "failed"(v0.98)
|
||||
"source": "asr", // "asr"(服务端识别,含云端)| "platform"(渠道平台自带转写)| "tts"(她的出站合成语音,v0.98)
|
||||
"status": "transcribed", // "transcribed" | "empty" | "failed"(闭集三态;failed 时 fallbackReason 可选在场,v0.98)
|
||||
"name": "a1b2c3….ogg", // 音频副本存储名;无副本 ⇒ 键省略
|
||||
"mime": "audio/ogg",
|
||||
"size": 12345,
|
||||
@@ -638,9 +649,9 @@
|
||||
|
||||
**`voice` 语义(v0.67 登记;✅ **v0.79 已实现**,切片 L45 —— 覆盖率见本节末)**:
|
||||
|
||||
- 形状:`{source,status,name?,mime?,size?,durationMs?,url?}`,`omitempty`(**非语音消息键缺席**,与引入语音前逐字相同)。
|
||||
- **🎤 判据 = `voice` 键存在**(这条消息来自一条语音消息)。客户端**绝不**从 `content` 正文里认媒体类型:QQ 语音正文里那个 `[语音]` 占位标记自 v0.67 起**不再下发**(见 §6 的 v0.67 行为变更),正文里也可能出现用户自己打出的 `[语音]` 字样。**只认 `voice` 键**。
|
||||
- `source` ∈ `"asr"`(服务端语音识别所得,含云端)/ `"platform"`(渠道平台自带转写,微信 iLink 的 `voice_item.text`)。
|
||||
- 形状:`{source,status,name?,mime?,size?,durationMs?,url?,fallbackReason?}`,`omitempty`(**非语音消息键缺席**,与引入语音前逐字相同;`fallbackReason` 仅 `status=="failed"` 时在场,v0.98)。
|
||||
- **🎤 判据 = `voice` 键存在 && `status != "failed"`(v0.98 起)**(这条消息是一条可播放的语音)。🔴 **v0.98 变更**:出站降级行(她请求语音但 TTS 超时/失败转文字)与入站转写失败行都是 `status:"failed"` 且**不可播**——按旧判据(仅键存在)会被误渲染成语音气泡;`status=="failed"` 的行按**文字消息 + 降级小标**显示(`fallbackReason` 如 `"tts_timeout"` 可做特殊提示)。客户端**绝不**从 `content` 正文里认媒体类型:QQ 语音正文里那个 `[语音]` 占位标记自 v0.67 起**不再下发**(见 §6 的 v0.67 行为变更),正文里也可能出现用户自己打出的 `[语音]` 字样。
|
||||
- `source` ∈ `"asr"`(服务端语音识别所得,含云端)/ `"platform"`(渠道平台自带转写,微信 iLink 的 `voice_item.text`)/ **`"tts"`(v0.98:她的出站合成语音——她调了模型侧 `speak` 工具且合成成功;播放与入站语音走同一 `GET /api/v1/media/{name}`)**。
|
||||
- `status` ∈ `"transcribed"`(正文即转写文本)/ `"empty"`(转写成功但没识别出文字)/ `"failed"`(没拿到转写文本 —— 正文是那行中文诚实提示)。**客户端按 `status` 分支,绝不按正文文案分支**(§0.3 的同一纪律)。设计还**刻意不下发** `failed` 的机器可读细分(未配置 / 鉴权 / 欠费 / 限流 / 超时 / 格式不支持 / 无字节 ⇒ 分类只进服务端日志,正文给的是人读的原因方向)。
|
||||
- `name` / `mime` / `size` / `url` **四者同生共死**:有音频副本时全在,**无副本时全缺席**(**不会**出现 `url` 指着一个不存在的 `name` 这种形状)。
|
||||
- `durationMs` **只有渠道如实给出时才有**;读不出来就**省略键,绝不估算**(当前未确证各渠道是否都给得出,见 design/24 §10.12-8)。
|
||||
@@ -648,7 +659,7 @@
|
||||
- 落库形状 = DTO 形状**减去** `url`(列 `chat_messages.voice TEXT NOT NULL DEFAULT ''`,JSON **单对象**;**用空串不用 NULL**,与 `turn_id` 同一口径)。音频原件**复用文档那一套存储 / 元数据表 / 引用计数 / GC**(同一 `<DataDir>/uploads/documents` 目录),但**不进文档的抽取 / 注入链路**:不被抽取、不进 `documents` DTO 数组、不占注入预算、不出现在附件清单行里。
|
||||
- **一条消息只落一份音频副本**(= 被转写的那一条语音);同一条消息里的第 2..N 条语音既不转写也不落盘,DTO 里也没有它们(**刻意的边界**,不是遗漏)。
|
||||
- ⚠️ **微信(`weixin-ilink`)语音没有音频副本、不可回放**:按既有裁决「平台自带转写就不白下载那份音频」⇒ `source:"platform"` 且**无 `name`/`mime`/`size`/`url`**,只保留 🎤 标记。
|
||||
- ✅ **覆盖率(v0.79,如实)**:`voice` 已在**这 4 个读取面**填充 —— `GET /sessions/{id}/messages`、`GET /admin/channels/messages`、`GET /admin/channels/{platform}/messages`、`PATCH /messages/{id}` 的**响应与 `message_edited` 广播帧**。🔴 **尚未填充**:`message_persisted` / `channel_persisted`(`broadcast.go`)与握手 `history` 回放(`ws_history.go`)—— 需要给 `history.Message` 加 `voice` 字段(**未做**);且入站语音的 `voice` 列是**消息落库之后**回填的,`message_persisted` 那一帧在**落库那一刻**发出,即使加了字段也拿不到值。⇒ **客户端不要在实时帧上等 `voice`**(见 §7.2、§7.4-29)。
|
||||
- ✅ **覆盖率(v0.97,如实)**:`voice` 已在**全部读面**填充 —— `GET /sessions/{id}/messages`、`GET /admin/channels/messages`、`GET /admin/channels/{platform}/messages`、`PATCH /messages/{id}` 的**响应与 `message_edited` 广播帧**(v0.79 起),以及 **`message_persisted` / `channel_persisted` 帧与握手 `history` 回放(v0.97,切片 M44)**。首帧即带的保证来自落库顺序 **INSERT → 写 `voice` 列 → notify**(两条实现前提见 §6 v0.97);帧内值是**本帧发出那一刻**的即时列读(每帧 +1 次读列、亚毫秒同步)。
|
||||
- ⚠️ **列被外部改坏时可见地缺席**:非法 JSON / 闭集破坏时**不静默当「不是语音」** —— 记一条带来源与消息 id(**不含正文**)的 WARN,并让该字段缺席。契约的 `voice` 没有「读不出」这一态,因此缺席是唯一诚实的表达(与 `documents` 回一条 `unreadable` 不同)。
|
||||
|
||||
**`documents` 数组语义(核心)**:
|
||||
@@ -702,10 +713,11 @@
|
||||
- `/voice-clone`:`{url,prefix?}` → `{data:{voiceId}}`。⚠️ 真建音色需公网参考音频 URL(当前无,待主人提供)+ 北京地域 API Key。
|
||||
- WS 语音回合(`voice_input`):**与文本回合走同一条路**(进编排器 → 落库 → 流式 → 跨端同步 → 忙碌/排队语义一致)。转写文本落库为用户消息、助手回复落库;`deviceId`/`clientMsgId` 与 `chat` 帧同形。
|
||||
- `voice_output` 在**该回合结算时**才定向回发起连接(可能晚于若干状态帧,甚至由续跑回合或被 steer 注入的那一回合产生)。空正文 / TTS 失败/降级都走 `provider` 降级信号(`silent` 等),**不把 `stopReason` 当语音内容**;连接断开不补投,客户端带回同一 `clientMsgId` 可安全重试。
|
||||
- **emotionTTS 自启(v0.97,切片 M47,默认关)**:后端启动时探活本机 emotionTTS,未起则异步拉起(失败只 WARN 不阻塞启动)。开关 = `$DATA_DIR/config/voice-tts-launch.json` 的 `{"enabled": true}`(面板配置文件、零新 `.env` 键;`probeUrl` 空 = `http://127.0.0.1:9880`,且**单向兜底**取 `voice-tts.json` 的 `baseUrl`)。⚠️ 快速 restart 两次会拉起两个 TTS 进程(跨进程去重不生效,记档不修)。见 §6 v0.97。
|
||||
|
||||
**入站语音的音频副本与播放端点(v0.67 登记;✅ **v0.79 已实现**,切片 L45;残留缺口见下)**:
|
||||
|
||||
> ✅ **v0.79 落地进展(切片 L45,如实登记)**:**已实现** —— ① `documents.Store.SaveAudio`(音频白名单 / 内容寻址 / 同字节去重 / 目录 0700、**文件 0600** / 元数据写同一张 `documents` 表、引用计数并入同一套 `documents.ref_count`);② `history.Store.UpdateMessageVoice` + `VoiceColumns`(`chat_messages.voice` 列的**读写两条路**)⇒ channelbridge 的入站副本链**端到端可落地**;③ **网关 DTO `voice`(含唯一派生字段 `url`)** 与 **`GET /api/v1/media/{name}`**(§1.2 U19 / 下表)。🔴 **残留缺口(如实,不美化)**:① ✅ **已修(v0.80,切片 W4 / 裁决 #11)**:组合根**已注入** `VoiceTranscoder`(`cmd/cyrene/wire_channels.go` 的 `channelbridge.Options`)⇒ **不可播格式(`amr`)也落副本**(落盘前先转 `.wav`,`mime: audio/wav`);`transcode_not_wired` 只剩「引擎未装配 / 构造失败」一档。⚠️ **生产端到端仍未跑**;**可播格式(ogg/wav/mp3/m4a/aac/flac/webm/oga/opus)仍原样落、不转码**;② **WS 实时帧 / 握手回放不带 `voice`**(见 §3.4.4 与 §4.4 覆盖率);③ **消息 / 会话被删除时音频副本的引用计数不被释放**(`refs.go` 的增减点只认 `document` 列)⇒ 副本 `ref_count` **偏高**(后果是**晚回收 / 不回收**,绝不早删);④ **生产端到端未跑**(禁止重启后端)⇒ 「真机 IM 语音 → 盘上 0600 副本 → 客户端取到字节」只有替身 + 临时库/临时目录级证据。
|
||||
> ✅ **v0.79 落地进展(切片 L45,如实登记)**:**已实现** —— ① `documents.Store.SaveAudio`(音频白名单 / 内容寻址 / 同字节去重 / 目录 0700、**文件 0600** / 元数据写同一张 `documents` 表、引用计数并入同一套 `documents.ref_count`);② `history.Store.UpdateMessageVoice` + `VoiceColumns`(`chat_messages.voice` 列的**读写两条路**)⇒ channelbridge 的入站副本链**端到端可落地**;③ **网关 DTO `voice`(含唯一派生字段 `url`)** 与 **`GET /api/v1/media/{name}`**(§1.2 U19 / 下表)。🔴 **残留缺口(如实,不美化)**:① ✅ **已修(v0.80,切片 W4 / 裁决 #11)**:组合根**已注入** `VoiceTranscoder`(`cmd/cyrene/wire_channels.go` 的 `channelbridge.Options`)⇒ **不可播格式(`amr`)也落副本**(落盘前先转 `.wav`,`mime: audio/wav`);`transcode_not_wired` 只剩「引擎未装配 / 构造失败」一档。⚠️ **生产端到端仍未跑**;**可播格式(ogg/wav/mp3/m4a/aac/flac/webm/oga/opus)仍原样落、不转码**;② **WS 实时帧 / 握手回放不带 `voice`**(✅ **已由 v0.97(切片 M44)修复**,见 §3.4.4 与 §4.4 覆盖率);③ **消息 / 会话被删除时音频副本的引用计数不被释放**(`refs.go` 的增减点只认 `document` 列)⇒ 副本 `ref_count` **偏高**(后果是**晚回收 / 不回收**,绝不早删);④ **生产端到端未跑**(禁止重启后端)⇒ 「真机 IM 语音 → 盘上 0600 副本 → 客户端取到字节」只有替身 + 临时库/临时目录级证据。
|
||||
|
||||
> **v0.78 已落地的基础件(切片 CODE-VOICE;v0.79 已在其上补齐用户可见面)**:① 迁移 `0020_chat_message_voice` 给 `chat_messages` 加 `voice TEXT NOT NULL DEFAULT ''` 列(JSON 单对象,形状 = 下面的 DTO `voice` **减去 `url`**);② `internal/runtime/voice/**` 新包(`Source`/`Status` 闭集、`Ref` 编解码、格式级可播表 + `amr→wav` 转码归一);③ `channelbridge` 入站语音「归一(最前端)→ 闸门**之后**落副本 → 落库后写 `voice` 列」全链打通(落盘内容寻址、复用文档存储与 GC,但**不进文档抽取/注入链路**)。唯一已生效且**客户端可见**的既有变化:IM 语音在「音频格式不在可转写白名单内」时正文诚实提示由「…(需要先转码,当前未做)」改为「…(服务端只把白名单内的音频送语音识别),这条语音没有转写。」—— **形状不变**(正文仍是转写文本或一行诚实提示;客户端本就**不该**按正文文案分支)。
|
||||
|
||||
@@ -1064,7 +1076,7 @@
|
||||
| 请求体 | **可为空**(端点不读 body)。没有参数:阈值/上限一律取**进程生效的既有配置**(`MEMORY_TIDY_RECALL_THRESHOLD` / `_SCAN_LIMIT` / `_CANDIDATE_LIMIT` / **`_COMPARE_LIMIT`(v0.77 新增)**),端点**自己不带任何旋钮** |
|
||||
| 响应体 | `{data:{dryRun,recallMode,durationMs,ruleVersion,partitions,scanned,compared,pairs,vetoed,vetoReasons,truncated,candidates[]}}` |
|
||||
| `dryRun` | 恒为 `true`。它是**结构化**判据:这份响应**没有**改动任何记忆(客户端不必只靠本地硬编码文案说明这件事) |
|
||||
| `recallMode` | `"embedding"`(语义向量召回)或 `"lexical"`(零依赖词项余弦,仅当进程没有接 embedder) |
|
||||
| `recallMode` | **如实上报本轮实际选用的召回路径**(不再是「配置态」):`"embedding"`(语义向量召回)|`"lexical"`(零依赖词项余弦)|`"mixed"`(**部分分区降级**、其余走向量)|`""`(**没有候选可比** ⇒ 本轮没做召回,不是降级、也不该显示成空)。⚠️ v0.91 起语义收紧:旧实现把「全量降级」静默报成 `embedding`,面板因此看不出降级 |
|
||||
| `durationMs` | 本次试算的墙钟耗时(毫秒) |
|
||||
| `ruleVersion` | 规则集版本(`internal/runtime/memory` 的 `TidyRuleVersion`,进候选幂等键;**改判定规则必须同时升它**) |
|
||||
| 观测口径 | `partitions`(含在册条目的分区数)/ `scanned`(扫描条数)/ `compared`(**真正参与两两比对**的条数,受 **`MEMORY_TIDY_COMPARE_LIMIT`** 与**时间预算**两道闸约束 —— 因此 `compared < scanned` 时要看 `truncated` 是不是指出了原因)/ `pairs`(相似度 ≥ 召回门的对数)/ `vetoed` 与 `vetoReasons`(否决数与原因分布,如 `scope`/`persona`/`project`/`source`/`temporal`/`numeric`/`conflict`/`low_confidence`) |
|
||||
@@ -1423,6 +1435,81 @@ curl -s -X POST -H "X-API-Key: $KEY" \
|
||||
|
||||
**一个保守处置(待设计裁决,生产库 0 例)**:`<p>A\n\n<p>B</p>`(未闭合段落之间夹正文)产出侧选择**合并** ⇒ `<p>A\n\nB</p>`,**不**做「插入式隐式收尾」(那要猜作者意图)。客户端不必为此适配。
|
||||
|
||||
### 4.17 记忆整理调优键的读写面(B26 / B27;**v0.92 新增,切片 M4**)
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 方法 / 路径 | `GET` 与 `PUT` `/api/v1/admin/memories/tidy/tuning` |
|
||||
| 鉴权档 | `writeAuth`(**owner / admin**;`viewer` → `403`,未认证 → `401`)。与同族 `GET /admin/memories`、`POST …/tidy/dry-run` 同档 |
|
||||
| 审计 | GET **不审计**;PUT 落 **`memory.tidy.tuning`**(`result` ∈ `ok\|error`) |
|
||||
| 落盘 | `$DATA_DIR/config/memory-tidy.json`(原子写:temp + rename;**值一个都没变时不写盘**) |
|
||||
|
||||
它救的是「这些键只能改文件 + 重启后端」——面板从此是配置的一等来源(`.env` 只做兜底)。
|
||||
|
||||
#### 管的 7 个键
|
||||
|
||||
| key | kind | unit | 取值 | 兜底来源 |
|
||||
|---|---|---|---|---|
|
||||
| `intervalSec` | int | sec | ≥ 1 | `env:MEMORY_TIDY_INTERVAL_SEC` |
|
||||
| `quietIntervalSec` | int | sec | ≥ 1 | `panel:intervalSec` |
|
||||
| `quietStartHour` | int | hour | 0–23 | `env:MEMORY_TIDY_QUIET_START_HOUR` |
|
||||
| `quietEndHour` | int | hour | 0–23 | `env:MEMORY_TIDY_QUIET_END_HOUR` |
|
||||
| `ingestGate` | bool | — | — | `code-default` |
|
||||
| `autoApply` | bool | — | — | `code-default` |
|
||||
| `tempTtlSec` | int | sec | ≥ 1 | `code-default` |
|
||||
|
||||
🔴 **`specs` 是取值规则唯一真源**:面板据此渲染与就地校验 ⇒ 不会出现两套规则。`fallback` **只给来源**(`env:<键名>` | `panel:<键名>` | `code-default`)、**不给数值** —— 数值真源仍在领域侧,网关不另抄一份默认值。
|
||||
|
||||
#### 响应(GET 与 PUT 的 200 同形状)
|
||||
|
||||
```json
|
||||
{
|
||||
"path": "config/memory-tidy.json",
|
||||
"present": true,
|
||||
"hot": false,
|
||||
"hotError": "…(仅 hot=false 时出现)",
|
||||
"specs": ["…见上表,每项 {key,kind,unit?,min?,max?,fallback}…"],
|
||||
"values": {"intervalSec": 3600, "quietStartHour": 23, "quietEndHour": 7, "ingestGate": false},
|
||||
"envValues": {"intervalSec": 86400, "quietStartHour": 23, "quietEndHour": 7}
|
||||
}
|
||||
```
|
||||
|
||||
🔴 **`values` 的缺位语义**:**键缺位 ⇒ 该键在对象里不出现**(= 跟随下一档兜底),**不是 0** ——「没配」与「配成 0」必须分得开。只回这 7 键;同文件里**不受管**的键(`t1Judge` / `t1PerWindow` / `autoApplyPerRound` 等)**不回显**,但 PUT 时**原样保留**。
|
||||
|
||||
🔴 **`envValues`**:键缺位时的**下一档(`.env`)进程生效值**(与 `/admin/config-facts` 同源 `s.cfg`,网关**绝不 fopen `.env`**);某键连 `.env` 档也没有值 ⇒ 该键不出现(例:`intervalSec ≤ 0`、安静时段两端都为 0 = 未填)。
|
||||
|
||||
#### 写侧三态(硬要求,逐字)
|
||||
|
||||
| 请求体里 | 结果 |
|
||||
|---|---|
|
||||
| 键**缺位** | 不改动 |
|
||||
| **合法值** | 采用 |
|
||||
| **非法值** | `400 invalid_payload`,且**盘上一个字节不动** |
|
||||
| **未知键**(含拼错) | 归「非法」⇒ `400`,**绝不静默忽略** |
|
||||
| 值 `null` | **清除该键**(回到下一档兜底) |
|
||||
|
||||
- 请求体 = **扁平 JSON 对象**(值类型 int 秒/钟点 或 bool)。
|
||||
- **PUT 的 200 是「写完重新读盘」的真值**,不是回显请求体。
|
||||
|
||||
#### 错误码
|
||||
|
||||
| code | 何时 |
|
||||
|---|---|
|
||||
| `400 invalid_payload` | 值非法 / 未知键 / 体不是对象 / 体是 `null` |
|
||||
| `409 tuning_corrupt` | 盘上那份**运行期读不走** ⇒ **拒绝覆盖**(文案说明此刻正回落 `.env`/代码默认) |
|
||||
| `500 tuning_unavailable` | 数据目录未接线 |
|
||||
| `500 tuning_write_failed` | 落盘失败(盘上未改动) |
|
||||
|
||||
#### 🔴 `hot` 的诚实口径(客户端按此渲染)
|
||||
|
||||
- `hot:true` = 值**已叠到运行期**:整理档会 `Stop→写→Start` **重排心跳**(否则新周期要等旧周期那一觉睡完 —— 主人把 24h 改成 3h 就「等于没改」);审查档重算 `ingestGate` / `tempTtlSec`。
|
||||
- `hot:false` = **已落盘但运行期没接上**,`hotError` 给原因,面板文案必须是「已保存到面板配置文件,**重启后端后生效**」。
|
||||
- **绝不把「落盘了」说成「生效了」。**
|
||||
|
||||
#### `recallMode` 的渲染(前端,承接 v0.91)
|
||||
|
||||
试算卡片(B14)的 `recallMode` 现在四态如实渲染:`embedding` ⇒「召回来源:语义向量」|`lexical` ⇒「召回来源:词项余弦(未接向量,已降级)」|`mixed` ⇒「召回来源:部分分区降级(其余走向量)」|`""` ⇒「召回来源:无候选可比(本轮未做召回)」——**不再渲染成空标签**。未登记取值 ⇒「…(未登记取值)」(不吞信息)。「尝试过但向量源不可用」走**错误路径**(`error.code === "tidy_recall_unavailable"`),面板额外渲染一句解释,**不套在别的错误码上**。
|
||||
|
||||
## 5. env 与模型配置(与契约相关)
|
||||
|
||||
> **`.env` vs 面板文件**:面板「LLM 模型」页保存写 `<DATA_DIR>/models.json`(默认 `./data/models.json`);「渠道管理」页写 `<DATA_DIR>/channels.json`。**只要文件存在且有效,运行时优先用它**,`.env` 只作兜底/播种层。`.env.example` 与实现同步(键名逐字一致、值占位符)。
|
||||
@@ -1451,7 +1538,7 @@ curl -s -X POST -H "X-API-Key: $KEY" \
|
||||
| `DOCUMENT_MAX_INJECT_CHARS` | 空 → 预算 1/8 | 单份附件注入上限 |
|
||||
| `GUARDRAIL_MAX_INPUT_CHARS` / `GUARDRAIL_KEYWORDS` | 空 | 请求侧字符上限 / 关键词护栏 |
|
||||
| `VOICE_STT_TIMEOUT_SEC` | `20` | **只约束 IM 渠道入站语音转写**;Web 客户端 `voice_input` 不受它约束 |
|
||||
| `VOICE_TTS_VOICE_ID` / `VOICE_TTS_FORMAT` / `VOICE_TTS_MODEL` | 空 | `POST /audio/speech` 当前遵循的服务端配置(请求体 `voice`/`format` 为前向兼容接受但暂不生效) |
|
||||
| `VOICE_TTS_VOICE_ID` / `VOICE_TTS_FORMAT` / `VOICE_TTS_MODEL` | 空 | ⚠️ **v0.96 起不再是发音人的真源**:发音人由**生效人格 → `$DATA_DIR/config/voice-tts.json` 的 `personas` 映射**决定(面板文件,零新 `.env` 键);请求体 `voice`/`format` 仍为前向兼容接受但不生效,产物恒 WAV |
|
||||
| `VOICE_TTS_INSTRUCTION` | 空 | 系统音色 `longxiaochun_v3` 固定格式、**不使用自由 instruction** |
|
||||
| `LLM_PROVIDER` / `LLM_BASE_URL` / `LLM_API_KEY` / `LLM_MODEL` / `LLM_FALLBACK_MODEL` / `LLM_REASONING_KIND` 等 | 见模板 | LLM provider 组的 `.env` 合成组兜底(有有效 `models.json` 时被其覆盖) |
|
||||
| `DOCUMENT_GC_INTERVAL` / `DOCUMENT_RETENTION` | `1h` / `168h` | 文档 GC(`0`/负 = 关闭 GC) |
|
||||
@@ -1466,7 +1553,7 @@ curl -s -X POST -H "X-API-Key: $KEY" \
|
||||
| `MEMORY_REVIEW_ENABLED` | `true` | **v0.77 登记**:记忆「入库前审查」(`design/23 §15.1 D3`,切片 MT-B)总开关。新记忆落库后由**独立的记忆管理会话**(复用 `sess-memorytidy` 装配)在有界后台队列里判定 `skip`/`merge`/`modify`/`create`,结论以**软合并**回写(`status` + `merged_into`/`merged_from`/`merge_count`,**可 revert、绝不改既有行 `content`**)。`false` ⇒ 组合根不建、不接,写入路径**逐字回到**改动之前。默认开启是因为判定是**纯规则、零模型调用**(T1 灰区模型判定**未实现**) |
|
||||
| `MEMORY_REVIEW_QUEUE_SIZE` | `256` | **v0.77 登记**:有界审查队列长度。⚠️ **队列满 ⇒ 该条不做审查**(记忆本身早已落库,**绝不因队列满而丢记忆**),只记一条带来源的 WARN |
|
||||
| `MEMORY_REVIEW_SCAN_LIMIT` | `200` | **v0.77 登记**:每条审查在同一分区内最多看多少条既有记忆(top-K 上限) |
|
||||
| `MEMORY_REVIEW_TIMEOUT_SEC` | `20` | **v0.77 登记**:单条审查的墙钟上限(秒)。超时 ⇒ **不做任何改写**(记忆照旧保留)+ WARN |
|
||||
| `MEMORY_REVIEW_TIMEOUT_SEC` | **`90`**(v0.93 起;v0.77–v0.92 为 `20`) | 单条审查的墙钟上限(秒)。超时 ⇒ **不做任何改写**(记忆照旧保留)+ WARN。🔴 **默认值的唯一真源在 memory 包**(`internal/runtime/memory` 的 `defaultReviewTimeout`)—— 该 env 键**不设自带默认值**,未配置读出 `0` = 「没配」,由 `normalizeReviewConfig` 单点落地;**显式写正数才压过它**(不做「保底抬到 90」的加码)。⚠️ `20s` 已被实测证伪:批量 8 时一条完整召回链墙钟 **21.5–28.2s** ⇒ 20s 预算会让审查**整条失败且不写留痕**(见 §6 v0.93) |
|
||||
| (`MEMORY_REVIEW_*` 的共用口径) | — | 上面四个键 **`≤0` 一律视为「用默认」**(由 memory 包 normalize 收敛,避免一个笔误让进程起不来);召回门**复用 `MEMORY_TIDY_RECALL_THRESHOLD`**,不另立键。⚠️ **本版只登记已实现的部分**:审查的**读侧 / 回滚目前没有 HTTP 端点**(见 §7.2) |
|
||||
|
||||
> **上面 13 个键(记忆整理 8 + 后台思考心跳 3 + 安静时段 2)自 v0.68 起可经 `GET /api/v1/admin/config-facts` 读到**「进程生效值 + 声明默认值」(3 个心跳键自 v0.75 起并入、`MEMORY_TIDY_COMPARE_LIMIT` 自 v0.77 起并入);在此之前面板没有任何 env 读取面。⚠️ `MEMORY_REVIEW_*` 四个键**不在**事实面里(那是「入库前审查」的另一组配置)。
|
||||
@@ -1496,7 +1583,138 @@ curl -s -X POST -H "X-API-Key: $KEY" \
|
||||
> 只保留最近若干版(**v0.60 起**);更早的全部历史见归档 `Docs/archive/api-client.v0.65.md` §5(v0.28 → v0.59)。
|
||||
> 每版一句话 + 三档标注。**三档定义见 [§0.4](#04-三档标注惯例)。**
|
||||
|
||||
### v0.90(当前契约版本)
|
||||
### v0.98(当前契约版本)
|
||||
|
||||
**「她的出站语音」版(M57 / M58 / M59;一处判据变更如实标注,其余纯增量)**
|
||||
|
||||
- **模型侧新工具 `speak`**(不是客户端端点,客户端零新调用):她自主决定某条回复用**语音**发。参数 `{text, emotion?}`;执行 = 按字数算超时预算(`clamp(3000 + 25×rune, 3000, 20000)` ms,`utf8.RuneCountInString` 一字一算)→ emotionTTS(发音人按生效人格,v0.96 链不动)→ 成功落音频副本(内容寻址 0600);**超时/失败返回结构化错误**(`tts_timeout` / `tts_failed` / `tts_no_audio` / `tts_store_failed` / `text_too_long` / `invalid_args`,均带预算与实际耗时)——她能在后续输出里读到成败并自然改用文字。
|
||||
- 🔴 **speak 豁免能力收缩**(quiet/awake 等状态也广告)但**不豁免安全门**:群聊/陌生人在工具策略面按「未知工具」deny(fail-closed,design/11 §3)、owner 恒放行;「群聊是否放行 speak」留待后续裁决。
|
||||
- ⚠️ `OutboundVoice.DurationMs` 恒 `0`(本版不解析音频时长);客户端**不得**以 `durationMs==0` 判定失败。
|
||||
- **出站 `voice` 落列与降级标记(M58)**:她请求语音且合成成功 ⇒ assistant 消息的 `voice` 列写 `{source:"tts", status:"transcribed", name, mime, size, durationMs}`(**与入站 DTO 形状一致**,`source` 新值 `"tts"` = 她说的合成语音;`url` 由网关派生,规则同入站);**她请求了但降级**(超时/失败)⇒ 写 `{source:"tts", status:"failed", fallbackReason}`(如 `"tts_timeout"`)——**`name`/`mime`/`size`/`url` 四键整体缺席**,正文照发文字。降级事实**随消息落库**(不只实时帧)——历史回看可见。
|
||||
- 🔴 **判据变更(唯一 breaking,如实标注)**:🎤 判据从「`voice` 键存在」改为 **「`voice` 键存在 && `status != "failed"` = 可播语音气泡;`status == "failed"` = 文字消息 + 降级小标**(`fallbackReason` 供特殊显示,如「语音超时,已转文字」)。**入站同样适用**:入站转写失败的行本就是 `status:"failed"` 且不可播——判据统一后语义更干净。旧判据客户端会把降级行误渲染成语音气泡。
|
||||
- **渠道侧(M59)**:QQ OneBot v11 以 `record` 段发**真语音条**(`base64://` 内联首选,Napcat 自行转 silk;真机 silk 效果**待验证**);微信 iLink 无语音发送能力 ⇒ 该渠道降级纯文本(但全端语音副本仍可见——IM 文本与 Web/安卓语音气泡并存)。**出站 Media 由语音事实单独构造**,绝不透传入站 Media(2026-10-04 事故纪律)。
|
||||
- **全部读面自动生效**:`source` 闭集在 `voice` 包单点(`asr`/`platform`/`tts`);REST 读面 / 实时帧 / 握手回放 / `message_edited` 经同一投影点 `attachVoiceTo`,首帧即带(M44 顺序纪律沿用)。
|
||||
|
||||
### v0.97
|
||||
|
||||
**「语音帧补全 + 上游错误出口收敛」版(M44 / M47 / M49 / M50;以纯增量为主,一处行为变更如实标注)**
|
||||
|
||||
- **`voice` 进 WS 实时帧与握手回放(M44,纯增量)**:`message_persisted` / `channel_persisted` 帧内的 `message` 对象、以及握手 `history` 回放的每条消息,现在携带可选 `voice`(形状与语义与 §4.4 的 DTO **完全一致**,同一收口点 `attachVoiceTo → historyVoiceOf`;非语音消息整键缺席)。🔴 **首帧就带**:入站语音的落库顺序已改为 **INSERT → 写 `voice` 列 → notify** ⇒ 这条语音消息的**第一条** `message_persisted` / `channel_persisted` 帧就带 `voice`,客户端不用等回读。
|
||||
- 🔴 **两条实现前提(新写入路径必须保持,否则首帧拿不到值)**:① `voice` 列的写入必须**先于** notify;② 帧内 `voice` 是**本帧发出那一刻**的即时列读(每帧 +1 次读列,被写列同步阻塞,亚毫秒级),不是消息结构体的缓存字段。
|
||||
- ✅ **本版改正正文中已成假话的「WS 实时帧 / 握手回放不带 `voice` ⇒ 客户端不要在实时帧上等 `voice`」**(§3.2 / §4.4 / §7.2 / §7.4-29 及 v0.79 / v0.80 版本横幅已加注)。安卓端为此做的容忍逻辑(回读兜底)可以撤了。
|
||||
- ⚠️ **不在本批范围**:`voice_output`(语音回合的定向回帧)形状未动;删除消息 / 会话时音频副本引用计数不释放的旧缺口仍在(见 §7.2)。
|
||||
- **emotionTTS 自启(M47,默认关,运维面)**:后端启动时探活本机 emotionTTS,未起则异步拉起(`一键启动.bat`),**失败只 WARN 不阻塞启动**;探活在线则不重复拉起。
|
||||
- 开关 = `$DATA_DIR/config/voice-tts-launch.json` 的 `{"enabled": true}`(**面板配置文件,零新 `.env` 键**);键:`enabled`(bool,**缺省/文件缺失 = 不启用**)、`probeUrl`(探活地址,空 = `http://127.0.0.1:9880`)。
|
||||
- `probeUrl` 为空时**单向兜底**取 `voice-tts.json` 的 `baseUrl`(只降级不回写,方向唯一)。非法探活地址 = 不启用(宁可不自启,不拉错目标)。
|
||||
- ⚠️ **已知限制(记档不修)**:快速 restart 两次会拉起两个 TTS 进程(跨进程去重不生效——防得住本进程并发,防不住上一进程拉起的孤儿)。
|
||||
- **上游错误出口「构造即安全」(M49 / M50 / M48 / M52;🔴 一处行为变更)**:`trace.UpstreamError` 构造函数本身就是清洗边界——`error.code` / `error.type` **固定过令牌守卫**(≤64 字节、仅 `[A-Za-z0-9_.-]`,构造后注入走 `SetCode`/`SetType` 同一道守卫);**上游响应体的任何字节都不进错误**(只留长度 + sha256 前缀,仍能做相关性比对)。
|
||||
- 🔴 **行为变更(如实)**:上游错误的 `error.message` **默认缺席**(此前部分出口会带上游自由文本)。要它必须调用点显式 opt-in(`WithUpstreamMessage`),且仍过凭据打码 + 128 rune 截断。理由:`{"error":{"message":"Invalid value: <用户原文>"}}` 这类回显真实存在,默认带 = 把她的原话进日志。分类排障用 `status/code/type/bodyLen/sha256` 足够。
|
||||
- `trace.URLCause` 统一剥 `*url.Error` 的「方法 + 地址段」因果链(**层数上限 8**,环不会栈溢出);全仓 19 处 `%v` 直打已清零。URL 查询串里的凭据从此不出现在错误出口。
|
||||
- **契约形状零变化**:REST / WS 错误信封(`{error:{code,message}}`)的形状、错误码集合都没动;这是**内容脱敏口径**的收敛,客户端按既有信封编程即可。
|
||||
- **同批已核实、无契约面变化的内部项**:M45 `temporal` 列只观测不判定(`IngestRuleVersion=d3.5`,`resolveRetention` 不读它);M51 MCP keepalive 日志面收口(redactingLogger);M46/M48 `URLCause` 五处收敛与防环。
|
||||
- **U6 `/audio/speech` 鉴权双凭据明示(本版补写,既有能力非新增)**:该端点自诞生起挂的就是 `authorize(RoleOwner)` 双分支门——**owner Bearer JWT 与 `X-API-Key` 都能用**(与全部 owner 端点同一扇门,见 §1.2 U6 更新)。裁决 2026-10-26(主人选 B):Android 不内置 API Key 入口、用现有 owner JWT 调 TTS。⚠️ Bearer 路径的真机 curl 属「同门已验证」的结构性结论(`auth` 层 `WithAPIKey` 双分支有核心断言;`/audio/speech` 与聊天端点共用同一门),**独立真机 Bearer 样例待安卓首次接入时顺手确证**。
|
||||
|
||||
### v0.96
|
||||
|
||||
**「语音发音人绑人格 + 管理面脱敏投影」版(M35 / M42;纯增量,无破坏性变更)**
|
||||
|
||||
- **`POST /api/v1/audio/speech` 新增可选 `sessionId`**(纯增量,`omitempty`)。发音人不再由请求体决定,而是按该会话的**生效人格**解析:**会话绑定 → 全局默认人格 → 空**。
|
||||
- 🔴 **省略 `sessionId` 不等于「没有角色」**:此时按**全局默认人格**选发音人(常规安装必有全局默认人格 ⇒ 仍走 `personas` 映射)。只有**连全局默认人格都没有**时才落到 `voice-tts.json` 的 `default`(没配则不合成)。
|
||||
- ⚠️ **不要**写成「`sessionId` 为空 ⇒ 发音人置空」——那会让本口永远用 `default` 那个声音,正是「换了人格还用老角色」的病害。
|
||||
- 请求体的 `voice` 仍**接受但不生效**(前向兼容);音色只能由服务端 `personas` 映射决定。
|
||||
- 产物**恒为 WAV**(服务端无转码),实际格式一律按**字节嗅探**判定,客户端**不要**照抄 `Content-Type`。请求体 `format` 仅为前向兼容接受。
|
||||
- **新增两个错误码**(状态码仍是 `500` —— 这是**服务端配置缺失**,不是客户端的错):
|
||||
| `error.code` | 含义 |
|
||||
| --- | --- |
|
||||
| `tts_persona_unmapped` | 这个生效人格在 `personas` 里没有映射(去面板补) |
|
||||
| `tts_no_character` | 连生效人格都没有,且没配 `default` |
|
||||
| `tts_failed` | 其余合成失败(原口径) |
|
||||
- 🔴 **管理面两条读端点是「脱敏投影」,不是原始数据**(M42):
|
||||
- `GET /api/v1/admin/observability/logs`(A10):每行 `msg` 先过凭据打码,**上限 4096 rune**(够一行日志全文,但不会把 MiB 级行塞进 JSON)。`time`/`level`/`source` **原样**,条目数与最新优先序不变。
|
||||
- `GET /api/v1/admin/observability/trace`(A11):每个 span 先过 `SanitizeRecord` ⇒ `props` 变**白名单投影**,表外键折成 `omitted: {键名 → {bytes, runes, sha256 前缀}}`(**仍能回答「这次输出了多大、两次是不是同一份」**);事件里按定义是正文的键同样折成 Digest。**字段名与帧形状不变**,客户端**不得依赖**未声明的 props。
|
||||
- `id/name/kind/status/startedAt/endedAt/durationNs`、`level/source/time`、条数与排序——**排障字段一律保留**。
|
||||
|
||||
### v0.95
|
||||
|
||||
|
||||
**「渠道静默窗口可写」版 —— 群里「一鼓作气打三句」只回一次,且参数在面板上调(切片 M23)**
|
||||
|
||||
- **新增 `GET /api/v1/admin/channels/coalesce`(读)** 与 **`PUT` 同路径(写)**。门 = `writeAuth`(owner|admin;`viewer` → 403、未认证 → 401),与 `admin_memory_tuning` 同档:它写的是**面板配置文件**,不是身份/凭据面。GET 只读不审计;PUT 落审计 **`channels.coalesce`**(`result` ∈ `ok|error`)。
|
||||
- **写面是「三态补丁」**(键缺位 ⇒ 不动;合法值 ⇒ 采用;非法值 ⇒ **`400 invalid_payload` 且盘上一个字节都不动** —— 校验全部发生在落盘之前):
|
||||
| 键 | 含义 |
|
||||
| --- | --- |
|
||||
| `enabled` | 是否启用静默窗口合并(bool) |
|
||||
| `windowMs` | 首条到达后再等多久收批(ms) |
|
||||
| `maxWaitMs` | **硬上限**:从首条算起最多等多久,到点就发车 |
|
||||
| `maxBatchMessages` | 一批最多攒几条,够数立即发车 |
|
||||
| `applyToStrangers` | 是否对陌生人/成员车道也应用(bool) |
|
||||
| `strangerWindowMs` | 陌生人/成员车道的独立窗口(ms) |
|
||||
- **每会话覆盖**:请求/响应带 `conversations` 映射(会话 id → 同一组键的补丁),因此窗口是**按会话**算的,不是适配器顶层一个值。
|
||||
- **热生效是事实不是承诺**:桥每次发车从**同一个** store 实例读生效值 ⇒ 保存后下一次发车即用新值,响应里 `hot:true`。
|
||||
- 🔴 **主人私聊不受此设置影响,且没有任何键能翻转它**:车道类型由 actor 元数据决定(`!msg.IsGroup() && actor.IsOwner()` ⇒ direct 车道、**没有窗口对象、拿不到定时器工厂**)。响应里下发 `ownerPrivate:"never"` 是**只读事实**,面板据此只显示一行说明、不给开关。
|
||||
- 🔴 **一批 = 一个 `turnID` = 一次 `loop.Run` = 一条助手回复**,但批内 N 条仍是**各自一行 `user`**(同 `turnID`、顺序 = 到达顺序),助手回复与它们同 `turnID`。⇒ 客户端按 `turnID` 聚合时,一个回合会看到 N 条用户消息 + 1 条助手消息。
|
||||
- **零新 `.env` 键**(design/13 §2.1):配置落 `$DATA_DIR/config/coalesce.json`。
|
||||
|
||||
### v0.94
|
||||
|
||||
**「MCP 管理面 + 工具来源」版 —— 新增一页声明式管理面板,并给能力条目加可选 `origin`(切片 M20 / M30)**
|
||||
|
||||
- **前端路由 `/admin/plugin/mcp/servers`**。它**不是新路由/新端点**:这是在既有面板目录里声明的一页(`id = mcp`,`panel = servers`),字段与动作都由 `pkg/plugins/sdk` 的 `Panel` 描述,前端用**既有通用渲染器**画。
|
||||
- **所有动作走既有的通用分发端点 `POST /api/v1/admin/plugins/mcp/actions/<action>`**(插件只给动作名、不给 URL)。**7 个动作**:
|
||||
| 动作名 | 档 | 作用 |
|
||||
| --- | --- | --- |
|
||||
| `plugin.mcpServers` | 读 | 全部 server 的状态快照(表格数据源) |
|
||||
| `plugin.mcpTransports` | 读 | 传输形态候选(`stdio` / `sse` / `http`) |
|
||||
| `plugin.installMcpServer` | 写 | 安装(或刷新)一台 server |
|
||||
| `plugin.reconnectMcpServer` | 写 | 断开重连并重新发现工具 |
|
||||
| `plugin.enableMcpServer` | 写 | 启用一台被停用的 server |
|
||||
| `plugin.disableMcpServer` | 写 | 停用一台 server |
|
||||
| `plugin.uninstallMcpServer` | 写 | 卸载一台 server |
|
||||
- **鉴权在两处**:路由挂 `viewerAuth`(读动作 viewer 可用),**写动作由分发器再查 `callerHasWrite`(owner|admin)**,每次写动作落审计。失败码是**稳定闭集**(前端按码本地化,不解析文案):`invalid_config` / `install_failed` / `reconnect_failed` / `enable_failed` / `disable_failed` / `uninstall_failed` / `server_not_found` / `server_disabled`。
|
||||
- 🔴 **安全提示(客户端/运维必读)**:`transport=stdio` 的 `command` + `args` 等于**在本机执行任意命令**,安装即生效。⚠️ **本版存在一处档位不对称(已 file finding,未在本版修)**:面板的写动作是 **owner|admin**,而 REST 的 `POST /api/v1/admin/plugins/install` 与 `/uninstall` 是 **owner-only**(`ownerAuth`)⇒ 一个非 owner 的 `admin` 可以通过面板动作装上一台 stdio server,却走不通 REST 安装口。单 admin 部署无实际差别,多账号部署请把 admin 视同 owner 收敛。
|
||||
- **工具条目新增可选 `origin` 对象**:`{ "kind": "mcp", "server": "<server 名>", "transport": "stdio|sse|http" }`。**宿主内置能力整键缺席**(不是空对象)—— 「没有 `origin`」正是「宿主自己提供」的表达。
|
||||
- 🔴 **归属只来自注册那一刻的元数据,绝不解析工具名字前缀**:MCP server 的工具名由它自己给,宿主不加前缀;实测一台 `halo` server 的工具分属 4 个不同前缀族 ⇒ 按前缀猜归属必错。
|
||||
- **这是呈现隔离,不是权限隔离**:`tool_policies` 没有 server 维度,`mcp:<server>` 策略行是惰性的 ⇒ **本版不提供「按 server 开关工具」,也不给假开关**。
|
||||
|
||||
### v0.93
|
||||
|
||||
|
||||
**「审查预算的唯一真源」版 —— 一个 env 默认值两处副本导致的静默失效**(实现切片 M16;起因 = **塔在部署后读真实启动行时发现** v0.92 那批修复里的 F1 **根本没生效**)
|
||||
|
||||
- 🔴 **`MEMORY_REVIEW_TIMEOUT_SEC` 的默认值 `20` → `90`**,并把默认值**收到唯一真源**:它**不再自带 env tag 默认值**,未配置读出 `0` = 「没配」,由 memory 包 `normalizeReviewConfig` 单点落地 `defaultReviewTimeout`。
|
||||
- **未配置 ⇒ `90s`**;**显式写正数 ⇒ 原样生效**(**不做**「保底抬到 90」的加码);**写 `0` / 负数 ⇒ `90s`**。
|
||||
- 🔴 **它修的是一个「看起来改了、其实没改」的事故**:v0.92 那批切片把 memory 包的 `defaultReviewTimeout` 从 `20s` 改成 `90s`,但 config 包的 env tag `default:"20"` **永远产出非零的 20** ⇒ `normalizeReviewConfig` 的「`≤ 0` 才填默认」分支**永远不走** ⇒ **90s 是死代码**,线上启动行仍打印 `timeout=20s`。
|
||||
- **为什么必须修**:批量上限抬到 8 之后,一条完整召回链实测 **21.5–28.2s** ⇒ 20s 预算下审查**整条失败、且不写留痕** ⇒ 召回降级、灰区恒空、T1 发不出调用 ⇒ 「重复记忆收不动」这条链**只修了一半**。
|
||||
- **为什么旧断言拦不住**:既有跨包断言比的是 **memory 包的常量**(「我打算用多少」),而病害发生在**另一个包**(config 的 tag),单包自洽、跨包漂移看不见。本版新增一条**走真实组装路径**的断言(`LoadMemoryReviewConfig` → `ReviewConfig` → `normalizeReviewConfig` → **生效值**,再断言它 > 实测最坏链长 + T1 子超时),并在注释里写明「**比常量 vs 比生效值**」的区别。
|
||||
- **兼容性**:**纯行为修复,无 API 形状变化**;`.env` / `.env.example` 文件内容**一字未动**(该键从未进过模板,符合 `.env` 冻结口径);受影响的只有「没显式配这个键」的部署 —— 它们的单条审查预算由 20s 变 90s。
|
||||
- **诚实边界**:若某部署**显式**写了 `MEMORY_REVIEW_TIMEOUT_SEC=20`,本版**不会**替你抬到 90(那是显式配置,尊重它)。另有两处**散文副本**仍写着旧值(`internal/runtime/memory/preinsert_t1.go` 的一处注释),已 file finding、不在本版改动面内。
|
||||
|
||||
### v0.92
|
||||
|
||||
**「记忆整理调优键可写」版 —— 管理面新增 2 条 `writeAuth` 端点,把面板从「只能改文件 + 重启」救出来**(依据:主人 2026-10-13 口径「`.env` 只做兜底…这些键优先从面板配置上取」「频率必须调…白天间隔短一点、静默期长一点」;实现切片 M4)
|
||||
|
||||
- **新增 `GET /api/v1/admin/memories/tidy/tuning`(B26)** 与 **`PUT` 同路径(B27)**。管 7 个键:`intervalSec` / `quietIntervalSec` / `quietStartHour` / `quietEndHour` / `ingestGate` / `autoApply` / `tempTtlSec`。门 = `writeAuth`(owner|admin;`viewer` → 403,未认证 → 401),与同族 `GET /admin/memories`、`POST …/tidy/dry-run` 同档;GET 只读**不审计**,PUT 落审计 **`memory.tidy.tuning`**(`result` ∈ `ok|error`)。
|
||||
- 🔴 **响应三块的分工(客户端按此编程,别自己算默认值)**:
|
||||
- `specs[]` 是**取值规则唯一真源**(`kind` / `unit` / `min` / `max` / `fallback`),面板据此渲染与就地校验 ⇒ 不会出现两套规则;`fallback` **只给来源**(`env:<键名>` | `panel:<键名>` | `code-default`)、**不给数值** —— 数值真源仍在领域侧,网关不另抄一份默认值。
|
||||
- `values` 是**盘上**的值:**键缺位 ⇒ 该键在对象里不出现**(= 跟随下一档兜底),**不是 0** ——「没配」与「配成 0」必须分得开。只回这 7 键,同文件里不受管的键(`t1Judge` / `t1PerWindow` / `autoApplyPerRound` 等)**不回显**。
|
||||
- `envValues` 是键缺位时**下一档(`.env`)**的进程生效值(与 `/admin/config-facts` 同源,网关**绝不 fopen `.env`**);连 `.env` 档也没有值 ⇒ 该键不出现。
|
||||
- 🔴 **写侧三态(硬要求,逐字)**:键**缺位** ⇒ 不改动;**合法值** ⇒ 采用;**非法值** ⇒ `400 invalid_payload` 且**盘上一个字节不动**。**未知键(含拼错)也归「非法」,绝不静默忽略**。值 `null` = **清除该键**(回到下一档兜底)。错误码:`400 invalid_payload` | `409 tuning_corrupt`(盘上那份运行期读不走 ⇒ **拒绝覆盖**)| `500 tuning_unavailable`(数据目录未接线)| `500 tuning_write_failed`(落盘失败,盘上未改动)。
|
||||
- **两条语义**:**值一个都没变时不写盘**(连 mtime 都不动);同文件**不受管的键原样保留**。PUT 的 200 与 GET **同形状**,但值是**写完重新读盘**的真值。
|
||||
- 🔴 **`hot` 语义(绝不把「落盘了」说成「生效了」)**:`hot:true` = 值已叠到运行期(整理档会 `Stop→写→Start` **重排心跳**,否则新周期要等旧周期那一觉睡完;审查档重算 `ingestGate`/`tempTtlSec`);`hot:false` = 已落盘但运行期没接上,`hotError` 给原因,面板文案必须是「**重启后端后生效**」。GET 的 `hot` **恒为 false**(读不改变任何东西)——那是如实占位,**别读成能力声明**。
|
||||
- **兼容性**:**纯增量**(新端点;既有端点/帧一字未改),老客户端零影响。
|
||||
- **计数**:`writeAuth` 段 25 → **27**;管理路由总数 79 → **81**(`14 + 27 + 40`)。字段级说明见 **§4.17**。
|
||||
|
||||
### v0.91
|
||||
|
||||
**「召回来源如实上报」版 —— `recallMode` 取值域扩两个 + 语义收紧**(实现 `5967272`,切片 M9;起因 = M6 诊断发现旧实现把「全量降级」静默报成 `embedding`,见 `DevDocs/audit/去重失效诊断-2026-10-13.md`)
|
||||
|
||||
- `recallMode` 现在**如实上报本轮实际选用的召回路径**,取值域:`"embedding"` | `"lexical"` | **`"mixed"`(部分分区降级、其余走向量 —— v0.91 新增)** | **`""`(没有候选可比 ⇒ 本轮未做召回 —— v0.91 新增;它不是降级,也不该被显示成空标签)**。
|
||||
- 🔴 **行为变更(客户端须跟进)**:旧实现报的是**配置态**(进程接没接 embedder),因此「向量路径实际失败、回退到词项」会被显示成 `embedding` —— 面板**看不出降级**。现改为实际态。**试算端点(B14)与整理轮日志/审计同源**;整理轮另新增 `recall_degraded_partitions` 计数,用于回答「哪几个分区降级了」。
|
||||
- **兼容性**:按 `embedding|lexical` 二分读的老客户端需自行兜底新值(`mixed` → 「部分降级」;`""` → 「无候选可比」)。前端渲染由后续切片修正(`MemoryTidyDryRunCard`)。
|
||||
- **附带的稳定性修复(对客户端透明,无契约影响)**:embedding 批上限改为按「单次请求预算 = 客户端超时的一半」**推演**得出,并在 embedder 内部强制切片(任何调用方给多大的批都安全);**客户端 5s 超时值本身一字未改**。它修的是「客户端先放弃、服务端继续算 ⇒ 队列堆积级联」这条真机制(M9 实测:空闲态单条 ~0.03s(ASCII)/ ~0.16s(40 汉字),饱和态可放大 70~90 倍)。
|
||||
- **计数不变**:无新端点 ⇒ §2.0 维持 `14 + 25 + 40 = 79`。字段级说明见 **§4.12** 的 `recallMode` 行。
|
||||
|
||||
### v0.90
|
||||
|
||||
**「渠道连接态三态」版 —— 管理面新增三个可选键,并把 `connected` 的语义从「动作被受理」纠正为「真的连上了」**(依据:2026-10-13 主人报「改了 QQ 适配器配置后点连接,卡片仍显示未连接,却提示已连接」;实现推库 `b4d55f4`。)
|
||||
|
||||
@@ -1651,7 +1869,7 @@ curl -s -X POST -H "X-API-Key: $KEY" \
|
||||
- **③ 入站语音 AMR 也可回放(切片 W4;裁决 #11)**:组合根补齐 `channelbridge.Options.VoiceTranscoder`(`cmd/cyrene/wire_channels.go`)—— 注入的是 `wireTranscode` 装配的**同一个**引擎实例(与工具面 `transcode` 工具同源,不是第二条降级链)。⇒ **不可播格式的入站语音在落盘之前先转成 `.wav` 再落副本**(`mime: audio/wav`;本期唯一必需的转码路 = QQ OneBot v11 的 `amr → wav`),客户端的 `voice` 从此带副本事实(`name` / `mime` / `size` / `durationMs` / `url`);**此前这些键缺席**(走 `category=transcode_not_wired` 的诚实 WARN、不落副本)。**形状零变化**(DTO `voice` 的键集合与语义一字未改);**可播格式**(ogg/wav/mp3/m4a/aac/flac/webm/oga/opus)仍**原样落、一次转码都不发生**。稳定 `category` 列表**不变**(10 类),但 `transcode_not_wired` 在生产装配下**不再出现**(它现在只剩「引擎未装配 / 构造失败」这一档)。🔴 **本版改正 6 处已成假话的「组合根未注入 `VoiceTranscoder` ⇒ AMR 不落副本」**:§4.5(缺口① + 现状行)/ §7.1(对照注)/ §7.2(语音面行 ②)/ §7.4-16(前向提示)/ §7.4-29(②)/ 旧版本横幅与 §6 旧版条目(v0.67 / v0.78 / v0.79)。**§4.5 / §7.2 / §7.4-29**。
|
||||
- **行为变更**:① 无(新端点;老端点逐字未改);② **有**(面板保存由「需重启生效」变为**下一轮即生效**;新增两个可配字段与两个错误码;`plugin.saveProvider` / `plugin.reauthProvider` 的响应形状**加键**);③ 无(DTO `voice` 的键集合与语义未变,只是**此前缺席的键现在会出现**)。
|
||||
- **破坏性**:**无**(新端点 / 新可选键 / 新错误码;新增的响应键都是**加性**的,老客户端不读即行为不变)。
|
||||
- ⚠️ **诚实边界**:① 时间轴的**前端渲染 / 真机面板联调**不在后端出口的证据内;`graph_json` 破损行的 `500` 路径**无测试**;游标精度到**毫秒**(同一毫秒起跑的两个不同轮次在极端情况下可能被一并跳过 —— 理论边界,已写进 §4.11.1(b)-4)。② 热重载的**真机面板端到端未跑**、**真机探针未打真实上游**;**启用态不跨重启持久化**。③ 语音**生产端到端未跑**(红线:不重启后端)⇒ 证据是 `channelbridge` 行为单测(真 AMR 夹具 → 真引擎 → RIFF/WAVE)与 `cmd/cyrene` 的**源码级接线用例**;WS 实时帧 / 握手回放**仍不带 `voice`**;删除消息 / 会话时音频副本引用计数**仍不释放**。逐条见 **§7.2 / §7.3 / §7.4**。
|
||||
- ⚠️ **诚实边界**:① 时间轴的**前端渲染 / 真机面板联调**不在后端出口的证据内;`graph_json` 破损行的 `500` 路径**无测试**;游标精度到**毫秒**(同一毫秒起跑的两个不同轮次在极端情况下可能被一并跳过 —— 理论边界,已写进 §4.11.1(b)-4)。② 热重载的**真机面板端到端未跑**、**真机探针未打真实上游**;**启用态不跨重启持久化**。③ 语音**生产端到端未跑**(红线:不重启后端)⇒ 证据是 `channelbridge` 行为单测(真 AMR 夹具 → 真引擎 → RIFF/WAVE)与 `cmd/cyrene` 的**源码级接线用例**;WS 实时帧 / 握手回放**仍不带 `voice`** —— ✅ **已由 v0.97(切片 M44)修复**(首帧即带,见 §6 v0.97);删除消息 / 会话时音频副本引用计数**仍不释放**。逐条见 **§7.2 / §7.3 / §7.4**。
|
||||
|
||||
### v0.79(上一版)
|
||||
|
||||
@@ -1659,11 +1877,11 @@ curl -s -X POST -H "X-API-Key: $KEY" \
|
||||
|
||||
- **① 插件面板动作真可用(切片 L1/PLG-2)**:插件管理器新增**动作处理器登记面**(`Manager.RegisterActions` → `Panels.RegisterActions`,取代单一全局执行器;**声明仍是唯一闸门** —— 登记≠可执行);组合根在 `wirePanelHub` 里登记内置 `web-search` 的**四个动作 + 下拉候选来源**(`plugin.providers`)⇒ `POST /api/v1/admin/plugins/{id}/actions/{action}` **不再一律 501** —— 真读写 `$DATA_DIR/config/plugins/web-search.json`(0600 + temp+rename,**重启仍生效**;**v0.80 起保存后即热重载**,见 §6 v0.80 ②)、探针**真发一次最小 HTTP 检索**。失败回**结构化非 2xx**(码/文案/状态由动作处理器给出,宿主只透传、状态越界回落 502):新增 `invalid_payload`(400) / `unknown_provider`(400) / `not_configured`(400) / `config_unreadable`(500) / `config_write_failed`(500),以及探针码 `unauthorized` / `rate_limited` / `quota_exceeded` / `network` / `upstream` / `bad_response` / `no_results` / `unknown`(502);**`501 action_not_implemented` 只在「已声明但宿主未登记处理器」时出现**(**不再是兜底档**);兜底执行失败仍是 `502 action_failed`。审计仍 `what="plugin.action"`,**服务端日志新增附加字段 `code`**(**不进 DB 记录体、不含请求体**)。**§2.1 A14 / §4.1 / §4.14**。
|
||||
- **② 「入库前审查」(D3)的管理面(切片 L45)**:新增 2 条 `writeAuth`(owner\|admin)端点 —— `GET /api/v1/admin/memories/reviews?limit=`(留痕列表:`limit` 缺省 100、`≤0` 同缺省、>500 钳位、非整数 `400`;元素含 `decision ∈ skip\|merge\|modify\|create`、`mergedIds` 恒为非 null 数组、`createdAt` UTC RFC3339;🔴 **不含记忆正文**)与 `POST /api/v1/admin/memories/{id}/revert`(撤销一次**软合并**:被吸收行复原 `active`、从存活行 `merged_from` 摘除、`merge_count` 减 1;**行永不物理删除**)。未接线 ⇒ `500 memory_review_unavailable`(**绝不回空数组**);回滚落审计新动词 **`memory.review.revert`**(只记 item id)。权限矩阵:`writeAuth` 14 → **16**、总数 68 → **70**。**§2.0 / §2.2 B16/B17 / §4.1**。
|
||||
- **③ 语音消息的用户可见面(切片 L45)**:消息 DTO 的 **`voice`**(含服务端唯一派生的 `url`)在 **4 个 REST 读取面 + `message_edited`** 真实填充;新增 **`GET /api/v1/media/{name}`**(`writeAuth`;`audio/*` 内联 + `nosniff` + `Accept-Ranges` + `Cache-Control: private, max-age=86400, immutable`;**无 `ETag`**;`Range` ⇒ 206、条件请求 ⇒ 304、支持 `HEAD`、不可满足 `Range` ⇒ 416(**非 JSON 信封**);名字非法 / 穿越 / 缺失统一 `404 media_not_found`);副本落盘走新增 `documents.Store.SaveAudio`(内容寻址、0600、元数据进同一张 `documents` 表、并入同一套引用计数),`chat_messages.voice` 列读写两条路补齐(`history.Store.UpdateMessageVoice` / `VoiceColumns`)。⚠️ **残留缺口(如实)**:`message_persisted` / `channel_persisted` 帧与握手 `history` 回放**仍不带 `voice`**(需给 `history.Message` 加字段,未做;且 `message_persisted` 在落库那一刻发出,天生拿不到落库后才回填的值);组合根**未注入 `VoiceTranscoder`** ⇒ **AMR 不落副本**(可播格式照落)—— ✅ **该缺口已由 v0.80(切片 W4)修复**(组合根已注入,见 §6 v0.80 ③ / §4.5 / §7.2)。**§0.3 / §1.2 U19 / §3.4.4 / §4.1 / §4.4 / §4.5**。
|
||||
- **③ 语音消息的用户可见面(切片 L45)**:消息 DTO 的 **`voice`**(含服务端唯一派生的 `url`)在 **4 个 REST 读取面 + `message_edited`** 真实填充;新增 **`GET /api/v1/media/{name}`**(`writeAuth`;`audio/*` 内联 + `nosniff` + `Accept-Ranges` + `Cache-Control: private, max-age=86400, immutable`;**无 `ETag`**;`Range` ⇒ 206、条件请求 ⇒ 304、支持 `HEAD`、不可满足 `Range` ⇒ 416(**非 JSON 信封**);名字非法 / 穿越 / 缺失统一 `404 media_not_found`);副本落盘走新增 `documents.Store.SaveAudio`(内容寻址、0600、元数据进同一张 `documents` 表、并入同一套引用计数),`chat_messages.voice` 列读写两条路补齐(`history.Store.UpdateMessageVoice` / `VoiceColumns`)。⚠️ **残留缺口(如实)**:`message_persisted` / `channel_persisted` 帧与握手 `history` 回放**仍不带 `voice`** —— ✅ **该缺口已由 v0.97(切片 M44)修复**(首帧即带,见 §6 v0.97);组合根**未注入 `VoiceTranscoder`** ⇒ **AMR 不落副本**(可播格式照落)—— ✅ **该缺口已由 v0.80(切片 W4)修复**(组合根已注入,见 §6 v0.80 ③ / §4.5 / §7.2)。**§0.3 / §1.2 U19 / §3.4.4 / §4.1 / §4.4 / §4.5**。
|
||||
- **④ 🔴 链路图权威回合标识覆盖到全部编排回合(切片 CODE-TC-2)**:新增 `loop.FlowPublisher.StartRoundWithTurnID(ctx, owner, text)`(权威回合标识取自 ctx 的 `TurnIDFrom`),`run.go` 的**唯一生产建图点**改走它 ⇒ 编排器驱动的**每一路**(**普通单路回合**、队首续跑路、真并行路)的图都带与 `chat_messages.turn_id` **同值的 `turnId`** ⇒ 消息面 **`toolCalls[]` 覆盖率从「只有真并行路」扩大到「全部编排回合」**(普通单路聊天与队首续跑回复现在也能回看工具)。🔴 **唯一形状变化**:`graph.id` 的 `#{权威回合标识}` 后缀现在**单路也追加**(`web-7` → `web-7#turn-…`)—— 该键仍**不透明**、仍可能含 `#`;客户端**一律使用响应里的 `graph.id` / `turns[].turnId`**,**不要自己拼** `{会话}-{序号}`(自拼的老客户端**查不到新行**)。**缺席语义一字未改**:拿不到权威标识的写入路径(未接编排器的调用方、**后台思考** `sess-backthink`、旧后端)`turnId` 仍缺席、`toolCalls` 仍**整体缺席**(**绝不回空数组**);**v0.79 之前的单路历史行不回填**(`lane_turn_id` 为空且无可回填来源)。**§4.4 / §4.11 / §7.2 / §7.4-23**。
|
||||
- **行为变更**:① **有**(面板动作由「全 501」变为**真执行**)+ 新增一批错误码;② 无(新端点;老客户端零影响);③ 无(`voice` 是既有 DTO 的可选键、`GET /media/{name}` 是新端点);④ **有(唯一一处形状变化)**:`graph.id` 对单路也追加后缀 ⇒ 自拼 id 的读取方须改用响应值(**其余形状与缺席语义一字未改**)。
|
||||
- **破坏性**:**无**(新端点 / 新可选键 / 新错误码;④的形状变化只影响「自己拼 `graph.id`」的读取方,而本文档自 v0.73 起就写明该键**不透明、应从响应取**)。
|
||||
- ⚠️ **诚实边界**:① 面板动作**热重载未做**(保存后运行中的 web-search 工具不换接线,需重启)—— ✅ **已由 v0.80(切片 W3)修复**(见 §6 v0.80 ②)、**探针只用 `httptest` 假上游**(未对真实上游连通,**仍未做**);② 回滚端点与 `design/23 §11` 的 owner-only 口径**有差异**(待裁决)、留痕列表**无分页游标**、`memory_ingest_reviews` 无 TTL/GC;③ 语音**生产端到端未跑**、WS 实时帧/握手回放不带 `voice`、AMR 不落副本(✅ **已由 v0.80 修复**)、删除消息/会话时音频副本引用计数不释放;④ 单路 `toolCalls[]` 的**HTTP 出口端到端未由本切片确证**(写侧已验,网关读侧由主会话补验)、`turns[]` 仍按**路**列条目、真机未验。逐条见 **§7.2 / §7.4**。
|
||||
- ⚠️ **诚实边界**:① 面板动作**热重载未做**(保存后运行中的 web-search 工具不换接线,需重启)—— ✅ **已由 v0.80(切片 W3)修复**(见 §6 v0.80 ②)、**探针只用 `httptest` 假上游**(未对真实上游连通,**仍未做**);② 回滚端点与 `design/23 §11` 的 owner-only 口径**有差异**(待裁决)、留痕列表**无分页游标**、`memory_ingest_reviews` 无 TTL/GC;③ 语音**生产端到端未跑**、WS 实时帧/握手回放不带 `voice`(✅ **已由 v0.97(切片 M44)修复**)、AMR 不落副本(✅ **已由 v0.80 修复**)、删除消息/会话时音频副本引用计数不释放;④ 单路 `toolCalls[]` 的**HTTP 出口端到端未由本切片确证**(写侧已验,网关读侧由主会话补验)、`turns[]` 仍按**路**列条目、真机未验。逐条见 **§7.2 / §7.4**。
|
||||
|
||||
### v0.78(上一版)
|
||||
|
||||
@@ -1674,10 +1892,10 @@ curl -s -X POST -H "X-API-Key: $KEY" \
|
||||
- **③ 搜索 provider 可变源(切片 PLG-4)**:插件 `web-search` 新增可变源 `$DATA_DIR/config/plugins/web-search.json`(**0600**、temp+rename、文件名 = 插件 id),组合根「**先可变源、缺失回落 `.env` 播种**」;面板数据面 = 四个动作 `plugin.listProviders` / `plugin.saveProvider` / `plugin.testProvider` / `plugin.reauthProvider`(表格行 `{provider,state,enabled}`,`state ∈ {unknown,disabled,not_configured,ok}`;`testProvider` 的 `errorCode` 闭集十项,⚠️ **`no_results` 时「空结果 ≠ 不通」**)。🔴 **不新增任何 env 键**(`.env` 已冻结);**`.env` 的 `WEB_SEARCH_*` 自此只在文件缺失时生效**(运维视角的行为变更,如实登记)。**§4.14(c) / §5**。
|
||||
- **④ 账号上下线落事件 + 渠道事件实时帧(切片 E7)**:`internal/runtime/events` 增「账号状态迁移」这一路 —— 三态归一(**未知也照常落行**,不整类丢弃)→ 落一行 `tier=t2` / `category=lifecycle`(**复用迁移 `0021`**)→ 经**既有进程内 bus** 的主题 `channel.event` 推成 WS 下行帧 **`channel_event`**(`data.event` 与 REST `events[]` **同一个对象**;⚠️ **实时帧的 `id` 键可能整缺**;**只发 owner/admin**,**viewer 走 REST 只读档**)。采样常驻(约 60s 一拍,**不看面板也在跑**)。⚠️ **当前只覆盖账号状态迁移这一路**(其余渠道事件的实时装饰器**未接线**,见 §7.2);采样通道目前唯一来源是 `qq-obv11` 的探活。**§3.4.5**。
|
||||
- **⑤ 微信 iLink 凭据三态(切片 CODE-F3)**:`GET /api/v1/admin/channels/{platform}/login-status` 新增**可选** `authState` / `authDetail`(`"active"` 有效 / `"rejected"` **被拒且需重扫** / `"unknown"` 未知;**闭集**)。🔴 **文案口径写死**:**`errcode=-14` 是「被拒」不是「过期」**、**未知原因不许写成「过期」/「未连接」**(响应体全文由测试钉住)。**键缺席 = 该账号没有这条状态面**,≠ `unknown`。**无新端点、无新路由**(沿用 v0.39 起的端点)。⚠️ `GET /admin/channels` 的 `accounts[]` **每账号**三态**未实现**(见 §7.2)。**§2.4(c)**。
|
||||
- **⑥ 语音消息落库面(切片 CODE-VOICE)**:迁移 `0020_chat_message_voice` 给 `chat_messages` 加 `voice TEXT NOT NULL DEFAULT ''`(JSON 单对象,形状 = DTO `voice` 减去 `url`;**刻意不建索引**);`internal/runtime/voice/**` 新包(`Source`/`Status` 闭集、`Ref` 编解码、格式级可播表 + `amr→wav` 转码归一);`channelbridge` 入站语音「归一 → **闸门之后**落副本 → 落库后写 `voice` 列」全链打通(落盘内容寻址、复用文档存储与 GC、**不进文档抽取/注入链路**)。⚠️ **用户可见面(DTO `voice` + `GET /api/v1/media/{name}`)在 v0.78 时未实现,已由 v0.79 落地**(切片 L45,见 §6 v0.79 ③);**残留缺口**(WS 实时帧/握手回放不带 `voice`;AMR 那一项**已由 v0.80 修复**)见 §4.5 / §7.2。唯一已生效的**客户端可见**变化:IM 语音在「格式不在可转写白名单内」时正文诚实提示改口径(**形状不变**,客户端本就不该按正文文案分支)。**§4.5 / §7.2**。
|
||||
- **⑥ 语音消息落库面(切片 CODE-VOICE)**:迁移 `0020_chat_message_voice` 给 `chat_messages` 加 `voice TEXT NOT NULL DEFAULT ''`(JSON 单对象,形状 = DTO `voice` 减去 `url`;**刻意不建索引**);`internal/runtime/voice/**` 新包(`Source`/`Status` 闭集、`Ref` 编解码、格式级可播表 + `amr→wav` 转码归一);`channelbridge` 入站语音「归一 → **闸门之后**落副本 → 落库后写 `voice` 列」全链打通(落盘内容寻址、复用文档存储与 GC、**不进文档抽取/注入链路**)。⚠️ **用户可见面(DTO `voice` + `GET /api/v1/media/{name}`)在 v0.78 时未实现,已由 v0.79 落地**(切片 L45,见 §6 v0.79 ③);**残留缺口**(WS 实时帧/握手回放不带 `voice`——**已由 v0.97(切片 M44)修复**;AMR 那一项**已由 v0.80 修复**)见 §4.5 / §7.2。唯一已生效的**客户端可见**变化:IM 语音在「格式不在可转写白名单内」时正文诚实提示改口径(**形状不变**,客户端本就不该按正文文案分支)。**§4.5 / §7.2**。
|
||||
- **行为变更**:① 无(新可选键 + 新迁移列/索引);② 无(新端点 / 新帧 / 新可选键;老客户端零影响)—— ⚠️ 但**新增了 `502` 状态码**(仅插件动作执行器失败路径)与一批插件动作错误码;③ **有**(运维视角:`.env` 的 `WEB_SEARCH_*` 仅在可变源文件缺失时生效);④ 无(新帧 + 新事件行;REST 形状未变);⑤ 无(两个可选键;`status`/`botId`/`botToken`/`err` 与 v0.39 起逐字一致);⑥ **有(轻微)**:IM 语音「不可转写格式」的正文诚实提示**改口径**(**形状不变**)。
|
||||
- **破坏性**:**无**。
|
||||
- ⚠️ **诚实边界**:① `toolCalls[]` **v0.78 时覆盖率只到真并行路**(普通单路回合查不到图 ⇒ 缺席)—— **已由 v0.79 补齐**(见 §6 v0.79 ④),且**未做真机端到端**(真并行路的 `lane_turn_id` 与 `chat_messages.turn_id` 逐字相等仍是**代码级**推论);② 插件动作**生产无执行器**(全回 501)—— **已由 v0.79 接线**(见 §6 v0.79 ①)、热装插件的声明来源缺、`panels_changed` 对 viewer 不可见、启用态**不跨重启持久化**、审计持久化记录**未含 pluginId/action**(都在 §7.2);③ `web-search` 声明已就绪但**「面板加一家 provider」未做真机端到端**;④ `channel_event` **只覆盖账号状态迁移这一路**(组合根装饰器未接线),且**未做真机端到端**;⑤ iLink `rejected` 分支**只有单测/httptest 证据**(真实 `errcode=-14` 至今 0 次观测);⑥ 语音**生产端到端未跑** —— **用户可见面已由 v0.79 落地**(见 §6 v0.79 ③),但 WS 帧/握手回放仍不带 `voice`;AMR 不落副本**已由 v0.80 修复**(见 §6 v0.80 ③)。
|
||||
- ⚠️ **诚实边界**:① `toolCalls[]` **v0.78 时覆盖率只到真并行路**(普通单路回合查不到图 ⇒ 缺席)—— **已由 v0.79 补齐**(见 §6 v0.79 ④),且**未做真机端到端**(真并行路的 `lane_turn_id` 与 `chat_messages.turn_id` 逐字相等仍是**代码级**推论);② 插件动作**生产无执行器**(全回 501)—— **已由 v0.79 接线**(见 §6 v0.79 ①)、热装插件的声明来源缺、`panels_changed` 对 viewer 不可见、启用态**不跨重启持久化**、审计持久化记录**未含 pluginId/action**(都在 §7.2);③ `web-search` 声明已就绪但**「面板加一家 provider」未做真机端到端**;④ `channel_event` **只覆盖账号状态迁移这一路**(组合根装饰器未接线),且**未做真机端到端**;⑤ iLink `rejected` 分支**只有单测/httptest 证据**(真实 `errcode=-14` 至今 0 次观测);⑥ 语音**生产端到端未跑** —— **用户可见面已由 v0.79 落地**(见 §6 v0.79 ③),但 WS 帧/握手回放仍不带 `voice`(✅ **已由 v0.97(切片 M44)修复**);AMR 不落副本**已由 v0.80 修复**(见 §6 v0.80 ③)。
|
||||
|
||||
### v0.77(上一版)
|
||||
|
||||
@@ -1685,7 +1903,7 @@ curl -s -X POST -H "X-API-Key: $KEY" \
|
||||
|
||||
- **① `graph_update` 按路分桶(切片 FV2)**:帧 `data` 新增**可选** `turnId`(这张图所属**路**的权威回合标识,与 `turn_begin` / `steer_ack` / 流帧帧壳同值)与 `round`(会话轮次号);图按 `{会话}#{路}` **归属桶**建(单路的桶逐字等于会话 id ⇒ **单路行为逐字不变**),因此真并行时**每一路各发自己的帧、各落一行**(`flow_graphs` 的唯一键 `(session_id, turn_id)` 不再把另一路整行顶掉)。`GET /api/v1/admin/flow/graph` 响应新增 **`lanes[]`**(最近一轮每一路的图 `[{turnId,graph}]`,按路开始时间升序,恒为非 null 数组;`graph` 仍是「最新一路」⇒ **老的读取方零改动**),前端在 ≥2 路时按**并列泳道**呈现 —— **§2.2 B13 / §3.4.5 / §4.11(a)(d) / §7.2 / §7.4-19**。⚠️ 留白:`turns[]` 仍按**路**列条目(一轮两路显示两条),按轮分组需新迁移 `0023`(**未做**);Agents 页 / 后台思考页仍只显示最新一路。
|
||||
- **② 记忆整理护栏抬高 + 诚实截断 + 试算异步落地(切片 MT-A)**:每分区候选上限 `MEMORY_TIDY_CANDIDATE_LIMIT` 默认 **`20 → 200`**;两两比对上限从**写死的 `400`** 改为 env 键 **`MEMORY_TIDY_COMPARE_LIMIT`(默认 `2000`)**;配置事实面 **12 → 13 条**;`truncated` 新增第 4 种标签 **`(budget)`**(被**墙钟时间预算**截断:整分区跳过,或两两比对中途收手 —— 成因是**本次调用的时间上限**而不是配置配额);**v0.76 ③ 的加性异步已落地**(`?async=1` + B15 轮询:并发**复用既有 `runId`**、与同步**同一个 3 分钟**墙钟、`done` 的 `result` 与同步 `data` **逐字节同形**、`running` 时 `result`/`error` **双缺席**、重启后旧 `runId` 如实 `404`)—— **§4.10(a) / §4.12 / §4.12.1 / §5**。
|
||||
- **③ 「入库前审查」(D3)的 4 个旋钮(切片 MT-B)**:`MEMORY_REVIEW_ENABLED`(`true`)/ `MEMORY_REVIEW_QUEUE_SIZE`(`256`)/ `MEMORY_REVIEW_SCAN_LIMIT`(`200`)/ `MEMORY_REVIEW_TIMEOUT_SEC`(`20`)—— 队列满 ⇒ **该条不审查但记忆不丢**(只 WARN)、超时 ⇒ **零改写** + WARN、`≤0` = 用默认、召回门**复用** `MEMORY_TIDY_RECALL_THRESHOLD`。🔴 **这 4 个键不进 `.env` 模板**(主人 2026-10-06:`.env` 冻结为「只保证最小化功能可跑」的引导面、**不再新增键**)⇒ **有效行为 = 上面这组代码默认值**,调优入口将由**配置文件 / 面板**提供(**未接线**)。⚠️ 该切片的**面板读侧端点(`GET /admin/memories/reviews`)与回滚端点(`POST /admin/memories/{id}/revert`)目前没有 HTTP 入口**,**本版不登记为可用**(见 §7.2)。
|
||||
- **③ 「入库前审查」(D3)的 4 个旋钮(切片 MT-B)**:`MEMORY_REVIEW_ENABLED`(`true`)/ `MEMORY_REVIEW_QUEUE_SIZE`(`256`)/ `MEMORY_REVIEW_SCAN_LIMIT`(`200`)/ `MEMORY_REVIEW_TIMEOUT_SEC`(`20`,⚠️ **默认值已由 v0.93 改为 `90`** —— 键名与文件内容未动,只是代码侧默认值,见 §6 v0.93)—— 队列满 ⇒ **该条不审查但记忆不丢**(只 WARN)、超时 ⇒ **零改写** + WARN、`≤0` = 用默认、召回门**复用** `MEMORY_TIDY_RECALL_THRESHOLD`。🔴 **这 4 个键不进 `.env` 模板**(主人 2026-10-06:`.env` 冻结为「只保证最小化功能可跑」的引导面、**不再新增键**)⇒ **有效行为 = 上面这组代码默认值**,调优入口将由**配置文件 / 面板**提供(**未接线**)。⚠️ 该切片的**面板读侧端点(`GET /admin/memories/reviews`)与回滚端点(`POST /admin/memories/{id}/revert`)目前没有 HTTP 入口**,**本版不登记为可用**(见 §7.2)。
|
||||
- **行为变更**:无(① 两个新键可选、`lanes[]` 纯增量、单路行为逐字不变;② 只改默认值 / 新增一个截断标签,同步试算的响应形状未变;③ 只多了一组**以代码默认值生效**的旋钮,**不进 `.env`**、不改变任何既有行为)。
|
||||
- **破坏性**:无。
|
||||
- **⚠️ 诚实边界**:① 真并行**未做真机端到端**(线上产物是旧构建;真并行是在单测层用「真 `Loop.Run` × 2 + 网关照同的 ctx 通道」复现的)—— 见 §7.4-24;② 上面 ②「抬高后真机 `compared == scanned`」**尚未被真机证实**(只有单测与量级推算支持);③ MT-B 的入库前审查**未在生产库上跑过**。
|
||||
@@ -1807,11 +2025,11 @@ curl -s -X POST -H "X-API-Key: $KEY" \
|
||||
|
||||
**三批已在设计文档定稿的新增面一次性登记**:语音消息(播放端点 + 消息 DTO `voice` + 音频副本口径)、非消息事件查询面、账号在线状态(依据 `design/24 §10.11`、`design/26 §4.5` / `§2.6`)。登记时三批**都还没有实现**;其中**账号在线状态**(`52dac45`)与**非消息事件查询面**(本切片 E6)**已落地**(见下)。
|
||||
|
||||
> ⚠️ **本版是「契约先行」登记**:登记的三批里,**账号在线状态**(见 §2.4(b))与**非消息事件查询面**(见 §2.4(a))**已落地**;**语音消息**当时未实现,那一批的形状**取自设计文档而非代码核对** —— **v0.79 已把用户可见面落地**(切片 L45,见 §6 v0.79 ③ / §4.5 / §7.2),**残留缺口**(WS 实时帧/握手回放不带 `voice`;AMR 的那一项**已由 v0.80 修复**)仍按「未确证/未实现」读。
|
||||
> ⚠️ **本版是「契约先行」登记**:登记的三批里,**账号在线状态**(见 §2.4(b))与**非消息事件查询面**(见 §2.4(a))**已落地**;**语音消息**当时未实现,那一批的形状**取自设计文档而非代码核对** —— **v0.79 已把用户可见面落地**(切片 L45,见 §6 v0.79 ③ / §4.5 / §7.2),**残留缺口**(WS 实时帧/握手回放不带 `voice`——**已由 v0.97(切片 M44)修复**;AMR 的那一项**已由 v0.80 修复**)仍按「未确证/未实现」读。
|
||||
|
||||
- **纯增量**:
|
||||
① 新端点 `GET /api/v1/media/{name}`(用户侧,**owner/admin 必须 Bearer**):`200` 字节流 / `206 Partial Content` / `304 Not Modified`;`Content-Type: audio/*`;`Content-Disposition: inline; filename="<name>"`;`nosniff`;`Accept-Ranges: bytes`;`Cache-Control: private, max-age=86400, immutable`;支持 `Range` 与 `HEAD`;`404 media_not_found`;`401`/`403` 由中间件给出。⚠️ **`416` 是标准库直写的非 JSON 响应**(见 §0.3、§4.1、§4.5)。
|
||||
② 消息 DTO 增**可选** `voice` 对象(`omitempty`;**非语音消息键缺席**):`{source,status,name?,mime?,size?,durationMs?,url?}`;`source` ∈ `asr`/`platform`,`status` ∈ `transcribed`/`empty`/`failed`,`name`/`mime`/`size`/`url` **四者同生共死**(有副本时才在);`url` 是服务端相对路径 `/api/v1/media/<name>`。**🎤 判据 = `voice` 键存在**。⚠️ **v0.79 校正(实测覆盖)**:真正会带 `voice` 的读取面**只有** `GET /sessions/{id}/messages`、`GET /admin/channels[/{platform}]/messages`、`PATCH /messages/{id}` 响应与 `message_edited`;**`message_persisted` / `channel_persisted` 与握手 `history` 回放不带**(见 §3.2、§3.4.4、§4.4 覆盖率的「尚未填充」)。
|
||||
② 消息 DTO 增**可选** `voice` 对象(`omitempty`;**非语音消息键缺席**):`{source,status,name?,mime?,size?,durationMs?,url?}`;`source` ∈ `asr`/`platform`(🔴 **v0.98 增 `tts`** = 她的出站合成语音,含 `fallbackReason` 降级标记与判据变更,见 §4.4 / §6 v0.98),`status` ∈ `transcribed`/`empty`/`failed`,`name`/`mime`/`size`/`url` **四者同生共死**(有副本时才在;**降级行四键整体缺席**);`url` 是服务端相对路径 `/api/v1/media/<name>`。**🎤 判据 = `voice` 键存在 && `status != "failed"`(v0.98 起)**。⚠️ **v0.79 校正(实测覆盖)**:真正会带 `voice` 的读取面**只有** `GET /sessions/{id}/messages`、`GET /admin/channels[/{platform}]/messages`、`PATCH /messages/{id}` 响应与 `message_edited`;**`message_persisted` / `channel_persisted` 与握手 `history` 回放不带**(✅ **已由 v0.97(切片 M44)补齐**,见 §3.2、§3.4.4、§4.4 覆盖率)。
|
||||
③ 新错误码 **`media_not_found`(404)** → 加进 §4.1 的 404 行(与 `document_not_found` 并列;**刻意不复用**它,否则客户端分不清缺的是文档还是语音)。
|
||||
④ 新错误形态 **`416`**(**非信封**,标准库直写)→ §0.3 / §4.1 / §4.5。
|
||||
⑤ 新 admin 端点 **`GET /api/v1/admin/channels/events`**(非消息事件列表,倒序;`?platform=&tier=&category=&channelId=&since=&until=&limit=&offset=`;响应 `{data:{events:[…],total}}`;`since`/`until` 按 UTC ISO-8601 定宽字符串**字典序**比较、**跨度上限 90 天**;不含正文、不含媒体 URL、不含任何凭据 / `flag`)—— §2.4(a)。**已落地**(`owner|admin` 档;元素键名 camelCase,逐字见 §2.4(a);未给窗口 ⇒ 不注入窗口)。
|
||||
@@ -1906,7 +2124,7 @@ curl -s -X POST -H "X-API-Key: $KEY" \
|
||||
|
||||
### 7.1 在飞切片
|
||||
|
||||
**v0.66 及更早登记的在飞项已全部冻结、客户端可直接依赖**(下表)。⚠️ **v0.67 新登记的三批中,账号在线状态(`accounts[].accountOnline`)与非消息事件查询面都已落地**(见 §7.2);**语音消息的用户可见面已由 v0.79 落地**(仍残留 WS 帧/握手回放不带 `voice`;AMR 不落副本**已由 v0.80 修复** —— 见 §7.2 / §7.4-29),**不在本表**,逐条见 §7.2。
|
||||
**v0.66 及更早登记的在飞项已全部冻结、客户端可直接依赖**(下表)。⚠️ **v0.67 新登记的三批中,账号在线状态(`accounts[].accountOnline`)与非消息事件查询面都已落地**(见 §7.2);**语音消息的用户可见面已由 v0.79 落地**(WS 帧/握手回放不带 `voice` 的缺口**已由 v0.97(切片 M44)修复**;AMR 不落副本**已由 v0.80 修复** —— 见 §7.2 / §7.4-29),**不在本表**,逐条见 §7.2。
|
||||
|
||||
| 项 | 状态 | 冻结位置 |
|
||||
|---|---|---|
|
||||
@@ -1931,7 +2149,7 @@ curl -s -X POST -H "X-API-Key: $KEY" \
|
||||
|
||||
| 项 | 现状 |
|
||||
|---|---|
|
||||
| **✅ v0.67 登记的「语音消息面」**(`GET /api/v1/media/{name}` + 消息 DTO `voice` + 入站语音音频副本落盘) | **用户可见面已实现(v0.79,切片 L45)**:端点 / 字段 / 副本口径见 §1.2 U19、§4.4、§4.5。落地:`documents.Store.SaveAudio`(音频白名单 / 内容寻址 / 0600)、`history.Store.UpdateMessageVoice` + `VoiceColumns`(`chat_messages.voice` 列读写两条路)、网关 DTO `voice`(含唯一派生 `url`)与 `GET /api/v1/media/{name}`。🔴 **残留缺口(如实)**:① **`message_persisted` / `channel_persisted` 帧与握手 `history` 回放仍不带 `voice`**(需给 `history.Message` 加字段,**未做**;且 `message_persisted` 在落库那一刻发出,天生拿不到落库后才回填的值)⇒ **客户端不要在实时帧/握手回放上等 `voice`**;② ✅ **已修(v0.80,切片 W4 / 裁决 #11)**:组合根**已注入 `VoiceTranscoder`** ⇒ **AMR 也落副本**(落盘前转成 `.wav`,`mime: audio/wav`);`transcode_not_wired` 只剩「引擎未装配 / 构造失败」一档(**生产装配下不再出现**);③ **消息/会话被删除时音频副本引用计数不被释放**(`refs.go` 只认 `document` 列 ⇒ `ref_count` 偏高,后果是**晚回收**,绝不早删);④ **生产端到端未跑**(未重启后端)。⚠️ 同批的**口径变更**(`/admin/system/storage` 的 `documents` 段含语音副本)仍**尚未生效**;**已生效**的是 IM 语音「不可转写格式」正文提示改口径(**形状不变**,见 §4.5)。权威设计 `design/24 §10` |
|
||||
| **✅ v0.67 登记的「语音消息面」**(`GET /api/v1/media/{name}` + 消息 DTO `voice` + 入站语音音频副本落盘) | **用户可见面已实现(v0.79,切片 L45)**:端点 / 字段 / 副本口径见 §1.2 U19、§4.4、§4.5。落地:`documents.Store.SaveAudio`(音频白名单 / 内容寻址 / 0600)、`history.Store.UpdateMessageVoice` + `VoiceColumns`(`chat_messages.voice` 列读写两条路)、网关 DTO `voice`(含唯一派生 `url`)与 `GET /api/v1/media/{name}`。🔴 **残留缺口(如实)**:① ✅ **已修(v0.97,切片 M44)**:**`message_persisted` / `channel_persisted` 帧与握手 `history` 回放已带 `voice`**(落库顺序改 INSERT→写列→notify,**首帧即带**,见 §6 v0.97);② ✅ **已修(v0.80,切片 W4 / 裁决 #11)**:组合根**已注入 `VoiceTranscoder`** ⇒ **AMR 也落副本**(落盘前转成 `.wav`,`mime: audio/wav`);`transcode_not_wired` 只剩「引擎未装配 / 构造失败」一档(**生产装配下不再出现**);③ **消息/会话被删除时音频副本引用计数不被释放**(`refs.go` 只认 `document` 列 ⇒ `ref_count` 偏高,后果是**晚回收**,绝不早删);④ ⚠️ **`weixin-ilink` 语音无副本是刻意的**(平台自带转写就不白下载音频),不受 M44 影响。⚠️ 同批的**口径变更**(`/admin/system/storage` 的 `documents` 段含语音副本)仍**尚未生效**;**已生效**的是 IM 语音「不可转写格式」正文提示改口径(**形状不变**,见 §4.5)。权威设计 `design/24 §10` |
|
||||
| **✅ v0.67 登记的「非消息事件查询面」**(`GET /admin/channels/events`、`GET /admin/channels/events/counters`) | **已实现**(v0.67 登记项本切片落地):形状与错误码见 §2.4(a)(已按代码核对:元素键名 camelCase、`approx` 与 `counters` 同级、`400 bad_request`、`owner\|admin` 档);权威设计 `design/26 §4.5`。未接线存储 ⇒ `500 channel_events_unavailable`。⚠️ 本行留在此表仅为与上一版对照,下版改文档时应移入 §7.1 在飞切片表并回填 §2.1/§2.2 的端点计数 |
|
||||
| **✅ v0.67 登记的「账号在线状态」**(`GET /admin/channels` 的 `accounts[].accountOnline`) | **已实现**(v0.67 登记项本次落地;落地前基线 `f91a83f`,本切片 commit 由主会话补记):形状见 §2.4(b)。管理面按**可选接口断言**读 `AccountStatus()`(`plugins/channels/qq_obv11/probe.go`,`a79fe96`);适配器**没有**该状态面、或实现了但 `known=false` ⇒ **键缺席**(未知 ≠ 离线,绝不用 `false` 冒充)。当前只有 `qq-obv11` 会带该键。⚠️ 本行留在此表仅为与上一版对照,下版改文档时应移入 §7.1 在飞切片表 |
|
||||
| **✅ P1-T:`directMsgPerMin` 的 gateway 写入路径**(`GET /admin/channels` 的 `accounts[].directMsgPerMin` + `PUT .../config` 的顶层 `directMsgPerMin`) | **已实现**(v0.69):缺口 = gateway 面没让该字段通过 ⇒ **面板保存一次渠道配置就把主人手配的「关闭流控(-1)」静默重置回 `0` = 保护性默认 6 条/分**。现 5 处透传(`AdminChannelAccount` DTO / `channelConfigRequest` / `config.ChannelAccount` 拷贝 / `adminChannelAccountOf` / 微信扫码重建账号)。形状见 §2.2 A5 与 §2.3 C28 注 |
|
||||
@@ -1964,7 +2182,7 @@ curl -s -X POST -H "X-API-Key: $KEY" \
|
||||
- **`task_done` / `automation_result` / `subagent_result` 不是会话消息**:它们**不进消息 store、不是气泡**(后端也没有把它们写成 `chat_messages` 行;`subagent_result` 更是**错过即永久丢**)。客户端应把它们落到自己的可观测面(Web 端落在本端 WS 事件回显 + 任务日志)。
|
||||
- `GET /api/v1/files/{name}` **无鉴权**(能力 URL);文档读侧相反、**必须 Bearer**。
|
||||
- `GET /admin/llm/usage` **不按 `session_id`** 过滤/分组(尽管该列已落库)。
|
||||
- `POST /audio/speech` 的请求体 `voice`/`format` **当前不生效**(合成遵循服务端 `VOICE_TTS_VOICE_ID`/`VOICE_TTS_FORMAT`)。
|
||||
- `POST /audio/speech` 的请求体 `voice`/`format` **不生效**;**v0.96 起发音人不再是服务端 env 决定**,而是按 `sessionId` 的**生效人格** → `$DATA_DIR/config/voice-tts.json` 的 `personas` 映射(见 §6 v0.96)。产物恒 WAV。
|
||||
- `/audio/voice-clone` **真建音色**需公网参考音频 URL(当前无,待提供)+ 北京地域 API Key。
|
||||
- 可观测三帧 `observability_*`:`?level=` 分级过滤**由客户端本地做**(帧广播给所有 owner/admin 连接,订阅只决定循环跑不跑)。审计 tab 仍 5s REST 轮询(**未做** `observability_audit` 帧)。
|
||||
- **`thinking` 恒缺席(v0.66 口径更正)**:消息 DTO 的 `thinking` 在**所有**读取面都不出现(`history.Message.Thinking` 全仓无写入者)。思维链只有实时 `thinking.*` 帧(owner/admin)。**旧文档称「历史思维链可由 `GET /sessions/{id}/messages` 返回」是错的**,见 §3.4.4。
|
||||
@@ -1975,7 +2193,7 @@ curl -s -X POST -H "X-API-Key: $KEY" \
|
||||
- **账号级限流字段是「值语义」,不是三态(v0.69 明确)**:`groupMsgPerMin`/`msgPerMin`/`maxMessages`/`directMsgPerMin` 在请求体里**省略即 `0`(未设置)**—— 一次「只改 token」的 PUT 若不带这四个键,会把已设限额(含 `directMsgPerMin:-1`)重置为 `0`。要保住就必须回送(面板已如此做)。这是 v0.68 起既有的顶层字段口径,v0.69 **未改**。
|
||||
- **后台思考行的实时帧只带记录里的字段(v0.70 边界)**:`urgency` 在实时帧上一定是**精确档位**(记录落库值),`urgencyLowerBound` / `quietHoursBypassed` **不在记录里**、因此实时帧不带 —— 它们由网关读历史时按安静时段窗口推导(见 §4.10(b))。⇒ 实时行的「穿透安静时段」标记要等重开页面/历史拉取才出现,**这不等于**「这一轮没有穿透」。
|
||||
- **`toolCalls` 只在 `GET /sessions/{id}/messages` 填充(v0.78 边界;覆盖率 v0.79 已补齐)**:共享 DTO 上的新键,但**只有该 handler** 会填它;其余出口(`PATCH /messages/{id}` 响应、`GET /admin/channels[/{platform}]/messages`、`message_persisted` / `channel_persisted` / `message_edited`、握手 `history` 回放)**逐字不变**(有单测钉住)。⚠️ **v0.78 时覆盖率只到真并行路** —— **已由 v0.79(切片 CODE-TC-2)补齐至全部编排回合**;仍缺席的是后台思考会话与 v0.79 之前的单路历史行(见 §4.4 / §7.2)。
|
||||
- **`voice` 只在 4 个 REST 读取面 + `message_edited` 填充(v0.79 边界)**:**`message_persisted` / `channel_persisted` 帧与握手 `history` 回放仍不带 `voice`**(需给 `history.Message` 加字段,未做);且入站语音的 `voice` 列是**落库之后**回填的 ⇒ 即使加了字段,`message_persisted`(在落库那一刻发出)也拿不到值。**客户端不要在实时帧 / 握手回放上等 `voice`**(见 §3.4.4 / §4.4 / §4.5)。
|
||||
- **`voice` 已在全部读面填充(v0.97 边界)**:**`message_persisted` / `channel_persisted` 帧与握手 `history` 回放自 v0.97(切片 M44)带 `voice`,且首帧即带**(落库顺序 INSERT→写列→notify;即时列读,每帧 +1 次读列、亚毫秒)。`voice_output` 定向回帧不在该批,形状未动(见 §3.4.4 / §4.4 / §4.5)。
|
||||
- **`memory.review.revert` 是新审计动词(v0.79)**:`design/23 §16.4` 的 `memory.*` 表里还没有它(需设计侧补记);回滚**只记 item id、不记正文**。
|
||||
- **`web-search` 的「`.env` 只播种」(v0.78 行为变更)**:可变源文件 `$DATA_DIR/config/plugins/web-search.json` 一旦落盘,`.env` 的 `WEB_SEARCH_*` **只在文件缺失时**起作用 ⇒ **改 `.env` 不再影响运行中的部署**(这是 design/09 §5.7 E 要的「`.env` 只播种、面板是配置面」,但运维视角上是行为变更)。🔴 **不是** env 键新增/删除。
|
||||
- **`channel_event` 是 best-effort 增量提示、且当前只覆盖一路(v0.78 边界)**:帧可能在 hub 发送缓冲满时**丢掉该连接的那一帧** ⇒ 客户端**必须**保留 REST 轮询兜底(以轮询结果为准重排)。且目前**只有账号状态迁移**会产帧(其余渠道事件的实时装饰器**未接线**,见 §7.2);帧里那行的 `id` **可能整缺**(刚落库、id 尚未回读)。
|
||||
@@ -2029,7 +2247,7 @@ curl -s -X POST -H "X-API-Key: $KEY" \
|
||||
|
||||
28. **(v0.78 登记)渠道事件实时面未做真机端到端**:`channel_event` 的「采样 → 落行 → 推帧 → 只投 owner/admin」由**真 SQLite + 真 bus + 真 WS 连接**的单测/端到端用例覆盖,但**运行中的 `cyrene.exe` 未重启**(红线:主会话换产物)⇒ **没有真机日志证据**。且当前 QQ 已停连 ⇒ 若真机启动,`qq-obv11` 账号为 `known=false`,首拍会落一行 `账号状态未知(尚未探到)`。另:**心跳通道(`meta_event.heartbeat`/`lifecycle`)驱动的迁移仍未接**(需适配器把状态投影进状态面,属 `plugins/**`),当前**唯一**迁移来源是探活(`qq_obv11/probe.go`)。
|
||||
|
||||
29. **(v0.78 登记 · v0.79 更新)语音的端到端未确证**:`internal/runtime/voice/**` 与 `channelbridge` 入站副本链有**真 `transcode.Engine`**(真 AMR 夹具 ⇒ RIFF/WAVE + 时长可确证)的走通用例;**v0.79** 补齐了网关侧(`media_test.go` 用真 `httptest` 真发 `GET`/`Range`/`HEAD`/条件请求,断言响应头与字节逐字相符 ⇒ **端点可用是「真跑过」的**),并新增 `documents.SaveAudio`(含 0600 断言)+ `history` 的 `voice` 列读写。⚠️ **仍未确证**:① **生产端到端未跑**(禁止重启后端)⇒ 「真机 IM 语音 → 盘上有 0600 副本 → 客户端取到字节」只有**替身 + 临时库/临时目录**级证据;② ✅ **已修(v0.80,切片 W4 / 裁决 #11)**:组合根**已注入 `VoiceTranscoder`** ⇒ AMR 转 `.wav` 后落副本 ⇒ 「Web 上能点开听 **AMR** 语音」的**接线**已成立(① 的**生产端到端仍未跑**,未重启后端);③ **`message_persisted` / `channel_persisted` / 握手 `history` 回放不带 `voice`**(见 §4.4 / §7.3);④ `amr` / `flac` / `opus` 在部分浏览器内核上**可播放性未逐一实测**(判据是设计登记的 MDN 规范级结论);⑤ **删除消息/会话时音频副本引用计数不释放**(`ref_count` 偏高 ⇒ 晚回收,绝不早删)。`weixin-ilink` 语音**无副本、不可回放**是**刻意**的(平台自带转写就不白下载音频)。
|
||||
29. **(v0.78 登记 · v0.79 更新)语音的端到端未确证**:`internal/runtime/voice/**` 与 `channelbridge` 入站副本链有**真 `transcode.Engine`**(真 AMR 夹具 ⇒ RIFF/WAVE + 时长可确证)的走通用例;**v0.79** 补齐了网关侧(`media_test.go` 用真 `httptest` 真发 `GET`/`Range`/`HEAD`/条件请求,断言响应头与字节逐字相符 ⇒ **端点可用是「真跑过」的**),并新增 `documents.SaveAudio`(含 0600 断言)+ `history` 的 `voice` 列读写。⚠️ **仍未确证**:① **生产端到端未跑**(禁止重启后端)⇒ 「真机 IM 语音 → 盘上有 0600 副本 → 客户端取到字节」只有**替身 + 临时库/临时目录**级证据;② ✅ **已修(v0.80,切片 W4 / 裁决 #11)**:组合根**已注入 `VoiceTranscoder`** ⇒ AMR 转 `.wav` 后落副本 ⇒ 「Web 上能点开听 **AMR** 语音」的**接线**已成立(① 的**生产端到端仍未跑**,未重启后端);③ ✅ **已修(v0.97,切片 M44)**:`message_persisted` / `channel_persisted` / 握手 `history` 回放**已带 `voice` 且首帧即带**(见 §4.4 / §6 v0.97 / §7.3);④ `amr` / `flac` / `opus` 在部分浏览器内核上**可播放性未逐一实测**(判据是设计登记的 MDN 规范级结论);⑤ **删除消息/会话时音频副本引用计数不释放**(`ref_count` 偏高 ⇒ 晚回收,绝不早删)。`weixin-ilink` 语音**无副本、不可回放**是**刻意**的(平台自带转写就不白下载音频)。
|
||||
|
||||
30. **(v0.82 登记)`tool_execution.end` 的 `resultSummary` 未做真机端到端**:证据止于**测试进程内**的「真 loop + 真工具 + 真 tracer/flow 图 → 真 bus → 真 relay → 真 hub → **真 WS 连接**」帧(`internal/services/gateway/ws_tool_result_summary_test.go` 用真 `httptest` + `websocket.Dialer` 读回帧),以及 loop 侧「帧上摘要是**从真图快照反读**的同一份文本」的同源钉(`TestRun_ToolEndFrameSummaryMatchesGraphResultSummary`)。⚠️ **仍未确证**:① 对 `:10942` 上的发布产物发一条**真会调工具**的消息、看客户端收到的 `.end` 帧是否带该键(换产物前不可能验);② **失败路径**上「实时帧的错误摘要」与「回看面的图上 `error` 摘要」是否**逐字一致**(同源链是代码级结论:两侧都过 `trace.RedactText`,但没有真机样例);③ 真机上「一次调用拿到超长结果」时帧上那份摘要的长度(代码级是 `RedactText` 的 ≤80 rune;测试造的是 >500 的假值,用来钉网关那道 ≤500 的兜底上限)。**为什么验不了**:本波禁换产物 / 禁重启后端(红线:后端常驻)。
|
||||
|
||||
@@ -2037,4 +2255,4 @@ curl -s -X POST -H "X-API-Key: $KEY" \
|
||||
|
||||
---
|
||||
|
||||
*本文件由「以代码为真源」维护(当前 `v0.82`;`v0.81` / `v0.80` / `v0.79` / `v0.78` / `v0.77` / `v0.76` / `v0.75` / `v0.74` / `v0.73` / `v0.72` / `v0.71` / `v0.70` / `v0.69` / `v0.68` 见本文件 §6,`v0.67` 及更早见本文件 §6 与 `Docs/archive/`)。范围:`v0.82`(三面 —— Y1 `resultSummary` / Y3 自排成本可见面(`?trigger=` + `/admin/backthink/self-wake`)/ Y4 `browser` 面板与六个动作)同步了 `internal/runtime/model/stream.go`、`internal/runtime/loop/**`(`tools.go` / `tool_frame_summary.go` / `tool_frame_summary_test.go`)、`internal/services/gateway/**`(`ws_bridge.go` / `ws_tool_result_summary_test.go` / `admin_backthink_filter.go` / `admin_backthink_filter_test.go` / `admin_backthink_selfwake.go` / `admin_query.go` / `routes.go`)、`internal/services/backthink/**`(`backthink.go` / `store.go` / `trigger.go` / `trigger_domain.go` / `selfwake_status.go` 及其测试)、`internal/config/runtimeconfig/backthink.go`、`plugins/tools/browser/**`(`panel.go` / `paneldata.go` / `attach.go` / `tool.go` 及测试)、`internal/capability/browser/**`(`state.go` / `manager.go`)、`internal/runtime/multimodal/vision/**`(`shot.go` / `resolver.go`)、`cmd/cyrene/**`(`wire_browser_panel.go` / `wire_browser_panel_data.go` / `wire_browser_context.go` 及测试)`frontend/web/src/admin/modules/Backthink.tsx`、`frontend/web/src/admin/plugin/nav.ts`、`frontend/web/src/lib/api/adminSystem.ts` 与本文件(§2.0 / §2.1 A13 / §2.2 B8·B19 / §3.4.2 / §4.10(d)(e) / §4.14(a)(d) / §6 / §7.1 / §7.2 / §7.4);`v0.81`(自排轮可见面 + 时间轴 `retentionBoundaryReached` + `round` 跨重启单调)同步了 `internal/services/backthink/**`(`trigger.go` / `selfwake*.go` / `store.go`)、`internal/runtime/flowgraph/**`(`timeline.go` / `timeline_test.go` / `flowgraph.go`)、`internal/services/gateway/**`(`admin_flow_timeline.go` 及测试 / `routes.go`)与本文件(§0.1 / §2.2 B18 / §4.10(d) / §4.11.1 / §6 / §7.1);`v0.80`(三批 —— W1 链路图时间轴出口 / W3 搜索 provider 热重载 + 主 provider·降级链 / W4 入站语音 AMR 转码落副本)同步了 `internal/runtime/flowgraph/**`(`timeline.go` / `timeline_test.go`)、`internal/services/gateway/**`(`admin_flow_timeline.go` / `admin_flow_timeline_test.go` / `history_toolcalls_gap_test.go` / `routes.go`)、`internal/capability/registry/**`(`registry.go` / `replacetool_test.go`)、`plugins/tools/io/search/**`(`panel.go` / `panel_test.go`)、`cmd/cyrene/**`(`websearch_reload.go` / `websearch_reload_test.go` / `websearch_selection.go` / `websearch_selection_test.go` / `websearch_testenv_test.go` / `websearch_action_data.go` / `wire_io_tools.go` / `wire_plugin_actions.go` / `wire_channels.go` / `wire_channels_test.go`)与本文件(§2.0 / §2.2 B18 / §4.1 / §4.5 / §4.11.1 / §4.14 / §6 / §7.1 / §7.2 / §7.3 / §7.4);`v0.79`(四批 —— L1/PLG-2 插件动作执行面接线 / L45 入库前审查管理面 / L45 语音用户可见面 / CODE-TC-2 链路图回合标识覆盖全部编排回合)同步了 `internal/capability/plugin/**`(`actions.go` / `panelactions.go` / `panels.go`)、`internal/services/gateway/**`(`admin_panels.go` / `admin_memory_review.go` / `history.go` / `history_write.go` / `history_voice.go` / `media.go` / `routes.go`)、`internal/runtime/voice/media.go`、`internal/runtime/documents/store_audio.go`、`internal/runtime/history/voice_column.go`、`internal/runtime/loop/**`(`flow.go` / `run.go`)、`internal/runtime/trace/graph.go`、`internal/runtime/flowgraph/flowgraph.go`、`plugins/tools/io/search/**`(`paneldata.go` / `probe.go`)、`cmd/cyrene/**`(`wire_plugin_actions.go` / `websearch_action_data.go` / `websearch_probe.go` / `wire_frontend.go`)与本文件(§0.3 / §1.2 U19 / §2.0 / §2.1 A13-A14 / §2.2 B16-B17 / §3.4.4 / §4.1 / §4.4 / §4.5 / §4.11 / §4.14 / §6 / §7.2 / §7.3 / §7.4);`v0.78`(六面 —— CODE-TC `toolCalls[]` / PLG-2 插件面板接入点 / PLG-4 搜索 provider 可变源 / E7 账号状态事件与 `channel_event` / CODE-F3 iLink 凭据三态 / CODE-VOICE 语音落库面)同步了 `internal/db/migrations/0023_flow_graphs_lane_turn_id.*`、`internal/db/migrations/0020_chat_message_voice.*`、`internal/runtime/flowgraph/**`、`internal/services/gateway/**`(`history.go` / `history_toolcalls.go` / `contracts.go` / `admin_panels.go` / `admin_channels_weixin.go` / `admin_channel_status.go` / `ws_channel_events.go` / `ws_bridge.go` / `ws_hub.go` / `routes.go`)、`internal/capability/plugin/**`(`panels.go` / `manager.go`)、`internal/capability/paneldecl/**`、`internal/config/runtimeconfig/websearch.go`、`internal/config/websearch.go`、`plugins/tools/io/search/**`(`panel.go` / `paneldata.go` / `probe.go`)、`plugins/channels/weixin_ilink/**`(`session_status.go` / `poll.go` / `send.go`)、`internal/runtime/events/**`、`interLine truncated
|
||||
*本文件由「以代码为真源」维护(当前 `v0.98`;`v0.97` ~ `v0.68` 见本文件 §6,`v0.67` 及更早见本文件 §6 与 `Docs/archive/`)。范围:`v0.98`(她的出站语音 —— M57 `speak` 工具 + 字数预算 / M58 出站 voice 落列 + 降级标记 + 判据变更 / M59 QQ record 段)同步了 `internal/runtime/loop/**`(`speak_tool.go` / `voice.go` / `request.go` / `tools*.go` 及测试)、`internal/runtime/voice/voice.go`(`SourceTTS` / `Ref.FallbackReason`)、`internal/services/channelbridge/**`(`outbound_voice.go` 及 e2e / `bubbles.go` / `inbound*.go` / `persist*.go` / `proactive*.go`)、`internal/services/gateway/history_voice.go`、`plugins/channels/qq_obv11/outbound_segments.go` 及测试与本文件(§4.4 / §6 / 旧横幅加注);`v0.97` ~ `v0.82` 各版的文件级范围见各自 §6 条目。*
|
||||
+63
-2
@@ -90,11 +90,62 @@ func buildPlatformAdapters(platform string, accts []config.ChannelAccount, log *
|
||||
log.Warn("channel account build skipped", "platform", platform, "account", acct.ID, "error", berr)
|
||||
continue
|
||||
}
|
||||
logFlowControl(log, built)
|
||||
out = append(out, built)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// logFlowControl 逐账号打一条启动日志,如实报出这个账号此刻被限成什么样(M24 裁决 A):
|
||||
// 闸是否开、每群几条/分、待处理上限、审查触发水位(reviewAt)。
|
||||
//
|
||||
// 为什么需要它:namedAdapter 的 Config() 无条件暴露四个流控键(含零值),桥的 cfgInt
|
||||
// 因此永远认为「键存在」,flowOrDefault 再把 0 映射成保护性默认 —— 于是「一个流控键都
|
||||
// 没写的账号,闸门仍然是开着的」。这条事实必须一眼可见,否则「群里为什么有消息被丢」
|
||||
// 只能靠读代码解释。判据:只看这一行日志就能说出这个群的限流/审查额度。
|
||||
//
|
||||
// 判定依据是**构建出来的那个适配器本身**,不是重新读一遍配置:非 namedAdapter 的实例
|
||||
// 不暴露流控键,桥按 flowgate.go「键缺失 ⇒ 该限制关闭」处理;这时必须如实报 off,
|
||||
// 不能拿保护性默认值假装成「开着」。
|
||||
func logFlowControl(log *slog.Logger, built adapter.PlatformAdapter) {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
log = logger.WithSource(log, "cmd/cyrene/channels")
|
||||
n, ok := built.(*namedAdapter)
|
||||
if !ok {
|
||||
log.Info("channel account ingress flow control",
|
||||
"account", built.Name(), "gate", "off",
|
||||
"why", "adapter exposes no flow keys, every limit stays disabled")
|
||||
return
|
||||
}
|
||||
group := flowLimitOrDefault(n.groupMsgPerMin, adapter.FlowLimitDefault)
|
||||
direct := flowLimitOrDefault(n.directMsgPerMin, adapter.FlowDirectDefault)
|
||||
pending := flowLimitOrDefault(n.maxMessages, adapter.FlowCapDefault)
|
||||
log.Info("channel account ingress flow control",
|
||||
"account", n.Name(), "gate", "on",
|
||||
"group_msg_per_min", group, "group_msg_per_min_raw", n.groupMsgPerMin,
|
||||
"direct_msg_per_min", direct, "direct_msg_per_min_raw", n.directMsgPerMin,
|
||||
"msg_per_min", max(n.msgPerMin, 0),
|
||||
"max_messages", pending, "max_messages_raw", n.maxMessages,
|
||||
"review_at", adapter.ReviewAtFor(pending),
|
||||
"note", "raw 0 (or absent in channels.json) takes the protective default; negative disables")
|
||||
}
|
||||
|
||||
// flowLimitOrDefault 把账号原始值映射为实际生效值:负值关闭该限制、零值取保护性默认、
|
||||
// 正值固定。它与 internal/services/channelbridge/flowgate.go 的 flowOrDefault 逐字同
|
||||
// 口径,这里只用于上面那条启动日志 —— 日志必须报生效值,不能报原始值。
|
||||
func flowLimitOrDefault(v, def int) int {
|
||||
switch {
|
||||
case v < 0:
|
||||
return 0
|
||||
case v == 0:
|
||||
return def
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
// channelAccountsFromEnv 合成 env 兜底:单账号 CHANNEL_<NAME>_* 适配器,加上
|
||||
// CHANNELS_JSON 里的多账号条目。它是 channels.json 缺失或非法时传给
|
||||
// runtimeconfig.LoadChannels 的 .env 兜底。.env 只承载必要的连接兜底
|
||||
@@ -174,8 +225,18 @@ type namedAdapter struct {
|
||||
denyGroups []string
|
||||
allowUsers []string
|
||||
denyUsers []string
|
||||
// 流控限制(design/12 §6 策略三/四)。它们始终经 Config() 暴露,使桥能构建按账号
|
||||
// 的入口闸门;桥把零值默认映射为保护性取值,把负值映射为「关闭」。
|
||||
// 流控限制(design/12 §6 策略三/四)。**四个键始终经 Config() 暴露,包括零值**:
|
||||
// 桥的 cfgInt 因此永远认为「键存在」,flowOrDefault 于是把 0 映射成保护性默认
|
||||
// (每群 6 条/分、待处理上限 50、reviewAt = 40)—— 一个在 channels.json 里一个
|
||||
// 流控键都没写的账号,闸门**仍然是开着的**(M24 只读勘测实测:现存 qq 账号就是
|
||||
// 这种状态)。负值经桥映射为「关闭」。
|
||||
//
|
||||
// 边界的准确说法(M24):flowgate.go 那句「键缺失 ⇒ 该限制关闭」的向后兼容分支,
|
||||
// 对 CHANNELS_JSON 账号**不可达** —— config.ChannelAccount 用的是普通 int
|
||||
// (omitempty),「未配置」与「显式配 0」解出来都是 0,这里无从区分。要区分就得
|
||||
// 改 config 的类型(*int / 存在性表)或在本题二次解析原始 JSON;而那样做会改掉
|
||||
// 「闸门默认开还是关」这个产品口径(群聊限流/审查默认从开变关),属主人裁决,
|
||||
// 不在本处自行翻转。见 M24 回报里的选项 A/B/C。
|
||||
groupMsgPerMin int
|
||||
directMsgPerMin int
|
||||
msgPerMin int
|
||||
|
||||
@@ -125,6 +125,13 @@ func run() error {
|
||||
return fmt.Errorf("start gateway: %w", err)
|
||||
}
|
||||
|
||||
// emotionTTS 自启(切片 M47):**在网关 Listen 之后**才挂 —— 网关的
|
||||
// ListenAndServe 已交给自己的 goroutine,这里只投递一次「探活 → 必要时拉起」,
|
||||
// 整段(毫秒级探活 + 起一个 .bat)都在后台跑,因此**不占**后端启动时长:走到
|
||||
// 这里时进程已经可以服务。开关默认关(面板文件 voice-tts-launch.json),
|
||||
// 失败只 WARN,绝不影响后端启动与退出。
|
||||
a.startVoiceTTSLaunch(ctx)
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
a.log.Info("cyrene signal received, shutting down")
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package main
|
||||
|
||||
// 本文件把**后端启动时顺带照顾 emotionTTS**(切片 M47)接进组合根,并守住三条
|
||||
// 红线(塔 2026-10 已定,主人原话见 internal/runtime/voice/ttslaunch 包注释):
|
||||
//
|
||||
// 1. **默认关**:面板文件 `$DATA_DIR/config/voice-tts-launch.json` 没写
|
||||
// `{"enabled": true}` 时,这里**连探活都不发**、也不构造任何后台任务 ——
|
||||
// 主人早前说过「开机自启先不急 后面做」。
|
||||
// 2. **不占启动时长**:`Trigger` 把探活 + 起脚本整段丢进后台 goroutine 后立即
|
||||
// 返回,调用点在网关 Listen 之后(见 main.run);探活超时/脚本拉起耗时都
|
||||
// 落不到后端的启动路径上。
|
||||
// 3. **失败只 WARN**:脚本不存在、起进程失败、探活超时都只是带 source 的日志,
|
||||
// 本函数**没有 error 返回**,绝不阻塞或改坏后端启动。
|
||||
// 4. **探活地址单向回退**(塔 2026-10-26 裁决,见 resolveProbeURL):launch 文件
|
||||
// 显式写了 `probeUrl` 就以它为准;没写则用 `voice-tts.json` 的 `baseUrl`;两者
|
||||
// 都空才是默认 9880。反向(拿这里的地址去改合成面)一律不做。
|
||||
//
|
||||
// 零新 `.env` 键(`.env` 已冻结):开关与脚本路径都走面板文件家族
|
||||
// (`runtimeconfig`,与 `voice-tts.json` 同一套读取口径)。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/ttslaunch"
|
||||
)
|
||||
|
||||
// voiceTTSLaunchSource 是本切片日志的来源标签(AGENTS §10:日志必须标来源)。
|
||||
const voiceTTSLaunchSource = "cmd/cyrene/voice-tts-launch"
|
||||
|
||||
// startVoiceTTSLaunch 读面板配置,按需投递**一次**「探活 → 必要时拉起」。
|
||||
//
|
||||
// 返回 nil 表示这一级没启用(没配过 / 显式 false / 文件坏掉),调用方据此什么都
|
||||
// 不做;返回非 nil 仅为了让调用方在关闭期或验收里能 `Wait()`(生产路径忽略它)。
|
||||
func (a *app) startVoiceTTSLaunch(ctx context.Context) *ttslaunch.Launcher {
|
||||
log := logger.WithSource(a.log, voiceTTSLaunchSource)
|
||||
path := runtimeconfig.DefaultVoiceTTSLaunchPath(resolveDataDir(a.cfg))
|
||||
tuning, err := runtimeconfig.LoadVoiceTTSLaunchTuning(path)
|
||||
if err != nil {
|
||||
// 坏文件 ⇒ **不启用**:一份读不懂的配置绝不能变成"随手拉起一个进程"。
|
||||
log.Warn("voice tts launch config unreadable, emotionTTS auto-launch stays off",
|
||||
"path", runtimeconfig.VoiceTTSLaunchConfigRelPath, "err", err)
|
||||
return nil
|
||||
}
|
||||
if !tuning.EnabledOn() {
|
||||
log.Debug("emotionTTS auto-launch not enabled in panel config",
|
||||
"path", runtimeconfig.VoiceTTSLaunchConfigRelPath, "hint", `set {"enabled": true}`)
|
||||
return nil
|
||||
}
|
||||
resolved := a.resolveProbeURL(tuning.ProbeURL)
|
||||
if err := validateResolvedProbeURL(resolved); err != nil {
|
||||
// 🔴 写错地址(漏 scheme / 主机拼错)会让"没起"永远成立 ⇒ **每次启动都拉一次
|
||||
// 进程**。宁可不启用,也不做这种自动重试机器(复审 P2-2b)。
|
||||
log.Warn("emotionTTS auto-launch stays off: invalid probe url",
|
||||
"probe_url", resolved.url,
|
||||
"probe_url_source", resolved.probeSource,
|
||||
"err", err)
|
||||
return nil
|
||||
}
|
||||
launcher := ttslaunch.New(withProbeURL(voiceTTSLaunchConfig(tuning), resolved), ttslaunch.WithLogger(log))
|
||||
cfg := launcher.Config()
|
||||
log.Info("emotionTTS auto-launch enabled",
|
||||
"probe_url", cfg.ProbeURL,
|
||||
// 探活地址可能来自三处,日志必须**同时**给出地址与来源:主人改端口后
|
||||
// 一眼能看出实际问的是哪个地址、以及它是显式写的还是兜底来的。
|
||||
"probe_url_source", resolved.probeSource,
|
||||
"script", cfg.ScriptPath,
|
||||
"probe_timeout_ms", cfg.ProbeTimeout.Milliseconds(),
|
||||
"cooldown_sec", int(cfg.Cooldown/time.Second),
|
||||
"config", runtimeconfig.VoiceTTSLaunchConfigRelPath)
|
||||
// 立即返回:探活与起脚本都在后台跑(见 ttslaunch.Launcher.Trigger)。
|
||||
launcher.Trigger(ctx)
|
||||
return launcher
|
||||
}
|
||||
|
||||
// probeResolution 是"探活地址 + 它的来源"(来源只用于日志,绝不参与判定)。
|
||||
type probeResolution struct {
|
||||
url string
|
||||
probeSource string
|
||||
}
|
||||
|
||||
// 探活地址的三个来源标签(写进启动 Info 行,供主人一眼核对)。
|
||||
const (
|
||||
probeSourceLaunchConfig = "voice-tts-launch.json"
|
||||
probeSourceVoiceTTS = "voice-tts.json"
|
||||
probeSourceDefault = "default"
|
||||
)
|
||||
|
||||
// resolveProbeURL 决定这次到底探哪个地址 —— **单向**回退,且只在组合根发生:
|
||||
//
|
||||
// 1. `voice-tts-launch.json` 显式写了 `probeUrl` ⇒ 用它(**永不**被别的键压过);
|
||||
// 2. 没写 ⇒ 兜底用 `voice-tts.json` 的 `baseUrl`(同一个服务,主人换端口只该改
|
||||
// 一处;这正是"两处一起改"的 footgun 的收口);
|
||||
// 3. 两者都没写(或 voice-tts.json 坏了)⇒ 空串,由 `ttslaunch.Config.Normalized`
|
||||
// 补默认 `http://127.0.0.1:9880`。
|
||||
//
|
||||
// 🔴 只做这一向:反向(拿 launch 的地址去改 `voice-tts.json` 的 `baseUrl`,或用它
|
||||
// 顶替合成地址)一律不做 —— 合成面与"起没起"是两个问题,前者不能被后者的配置影响。
|
||||
func (a *app) resolveProbeURL(explicit string) probeResolution {
|
||||
if url := strings.TrimSpace(explicit); url != "" {
|
||||
return probeResolution{url: url, probeSource: probeSourceLaunchConfig}
|
||||
}
|
||||
log := logger.WithSource(a.log, voiceTTSLaunchSource)
|
||||
tuning, err := runtimeconfig.LoadVoiceTTSTuning(runtimeconfig.DefaultVoiceTTSPath(resolveDataDir(a.cfg)))
|
||||
if err != nil {
|
||||
// 坏文件 ⇒ 这里只回落默认地址(**不**因此关掉自启:那是 launch 文件自己的
|
||||
// 事),并且必须说清楚"我没读到 baseUrl"。
|
||||
log.Warn("voice tts config unreadable, probe url falls back to default",
|
||||
"path", runtimeconfig.VoiceTTSConfigRelPath, "err", err)
|
||||
return probeResolution{probeSource: probeSourceDefault}
|
||||
}
|
||||
if base := strings.TrimSpace(tuning.BaseURL); base != "" {
|
||||
log.Debug("probe url inherited from voice tts config",
|
||||
"path", runtimeconfig.VoiceTTSConfigRelPath, "base_url", base)
|
||||
return probeResolution{url: base, probeSource: probeSourceVoiceTTS}
|
||||
}
|
||||
return probeResolution{probeSource: probeSourceDefault}
|
||||
}
|
||||
|
||||
// withProbeURL 把解析出的探活地址填进 launcher 配置(空串留给 Normalized 补默认)。
|
||||
func withProbeURL(cfg ttslaunch.Config, r probeResolution) ttslaunch.Config {
|
||||
cfg.ProbeURL = r.url
|
||||
return cfg
|
||||
}
|
||||
|
||||
// validateResolvedProbeURL 校验**最终要探的那个地址**(空串 = 两个文件都没给,由
|
||||
// Normalized 补默认 9880,默认值本身合法,这里不重复校验)。
|
||||
//
|
||||
// 它和 `ttslaunch.New` 里的那道校验是同一把闸,但这里多做一件事:WARN 带上
|
||||
// `probe_url_source` —— 主人一眼能看出该改哪个文件(launch 文件的 probeUrl,
|
||||
// 还是 voice-tts.json 的 baseUrl)。
|
||||
func validateResolvedProbeURL(r probeResolution) error {
|
||||
if r.url == "" {
|
||||
return nil
|
||||
}
|
||||
return ttslaunch.ValidateProbeURL(r.url)
|
||||
}
|
||||
|
||||
// voiceTTSLaunchConfig 把面板形状映射成 launcher 的配置。
|
||||
//
|
||||
// 它**不**在这里裁决缺省值(脚本路径/探活地址/超时/冷却的默认值全在
|
||||
// `ttslaunch.Config.Normalized` 一处):零值原样传下去,由那一处补齐。
|
||||
func voiceTTSLaunchConfig(t runtimeconfig.VoiceTTSLaunchTuning) ttslaunch.Config {
|
||||
return ttslaunch.Config{
|
||||
Enabled: t.EnabledOn(),
|
||||
ScriptPath: t.ScriptPath,
|
||||
ProbeURL: t.ProbeURL,
|
||||
ProbeTimeout: time.Duration(t.ProbeTimeoutMs) * time.Millisecond,
|
||||
Cooldown: time.Duration(t.CooldownSec) * time.Second,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package main
|
||||
|
||||
// 本文件锁定复审 P2-2 在**组合根**上的两道闸:
|
||||
//
|
||||
// 1. **关闭期取消 ⇒ 0 次拉起**(P2-2a):后端关闭时 ctx 被取消,探活随之返回
|
||||
// `context.Canceled`,这必须是"跳过",绝不能在关闭过程中拉起 TTS;
|
||||
// 2. **探活地址非法 ⇒ 不启用、一次都不拉**(P2-2b):`probeUrl` 与 `voice-tts.json`
|
||||
// 的 `baseUrl` 兜底**两个来源**都要被挡(漏一个就等于绕过闸门,变成"每次启动
|
||||
// 都拉一次进程")。
|
||||
//
|
||||
// 用例除了看返回值,还会真等一会儿确认没有进程被拉起(拉起是异步的,脚本一跑
|
||||
// 就会落 marker 文件);探活用黑洞监听(只接连接不回话),保证取消一定落在探活
|
||||
// 进行中。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/ttslaunch"
|
||||
)
|
||||
|
||||
// TestStartVoiceTTSLaunch_ShutdownCancelDoesNotLaunch 钉住"关闭期取消 ⇒ 0 次拉起"。
|
||||
//
|
||||
// 变异点:去掉 ttslaunch 里 `errors.Is(probeErr, context.Canceled)` 那个跳过分支 ⇒
|
||||
// 取消被当成"没起" ⇒ 脚本被拉起(marker 出现),本用例红。
|
||||
func TestStartVoiceTTSLaunch_ShutdownCancelDoesNotLaunch(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
blackhole, addr := blackholeListener(t)
|
||||
defer blackhole.Close()
|
||||
|
||||
a, buf := launchTestApp(t, dir)
|
||||
script, marker := markerBat(t, dir)
|
||||
writeLaunchPanel(t, dir, fmt.Sprintf(
|
||||
`{"enabled":true,"probeUrl":%q,"probeTimeoutMs":5000,"scriptPath":%q}`,
|
||||
"http://"+addr+"/", script))
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
launcher := a.startVoiceTTSLaunch(ctx)
|
||||
if launcher == nil {
|
||||
t.Fatal("启用后应拿到 launcher")
|
||||
}
|
||||
// 模拟后端关闭:探活还挂在黑洞上时取消。
|
||||
cancel()
|
||||
launcher.Wait()
|
||||
|
||||
assertNotLaunched(t, marker)
|
||||
if out := buf.String(); strings.Contains(out, "emotionTTS launch failed") {
|
||||
t.Fatalf("关闭期取消被记成了拉起失败: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartVoiceTTSLaunch_InvalidProbeURLStaysOff 钉住第一道闸:launch 文件里写了
|
||||
// 非法 `probeUrl` ⇒ 返回 nil(不构造 launcher)、WARN 标出源、**一次都不拉**。
|
||||
//
|
||||
// 变异点:去掉 `validateResolvedProbeURL` ⇒ 返回非 nil,且探活每轮必败 ⇒ 脚本被
|
||||
// 拉起(marker 出现),两条断言都会红。
|
||||
func TestStartVoiceTTSLaunch_InvalidProbeURLStaysOff(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
a, buf := launchTestApp(t, dir)
|
||||
script, marker := markerBat(t, dir)
|
||||
writeLaunchPanel(t, dir, fmt.Sprintf(`{"enabled":true,"probeUrl":"127.0.0.1:9880","scriptPath":%q}`, script))
|
||||
|
||||
launcher := a.startVoiceTTSLaunch(context.Background())
|
||||
if launcher != nil {
|
||||
t.Fatal("非法 probeUrl 仍启用了自启(会每次启动都拉一次进程)")
|
||||
}
|
||||
assertNotLaunched(t, marker)
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "invalid probe url") {
|
||||
t.Fatalf("非法 probeUrl 没记 WARN: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "probe_url_source=voice-tts-launch.json") {
|
||||
t.Fatalf("WARN 没标出该改哪个文件: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartVoiceTTSLaunch_InvalidBaseURLFallbackStaysOff 钉住第二道闸:launch 文件
|
||||
// 没写 probeUrl、而 `voice-tts.json` 的 baseUrl 非法 ⇒ 同样不启用、一次都不拉。
|
||||
func TestStartVoiceTTSLaunch_InvalidBaseURLFallbackStaysOff(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
a, buf := launchTestApp(t, dir)
|
||||
script, marker := markerBat(t, dir)
|
||||
writeVoiceTTSPanel(t, a, `{"enabled":true,"baseUrl":"127.0.0.1:9880"}`)
|
||||
writeLaunchPanel(t, dir, fmt.Sprintf(`{"enabled":true,"scriptPath":%q}`, script))
|
||||
|
||||
launcher := a.startVoiceTTSLaunch(context.Background())
|
||||
if launcher != nil {
|
||||
t.Fatal("baseUrl 兜底来的非法地址也没被挡住(每次启动都会拉一次进程)")
|
||||
}
|
||||
assertNotLaunched(t, marker)
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "invalid probe url") {
|
||||
t.Fatalf("非法 baseUrl 没记 WARN: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "probe_url_source=voice-tts.json") {
|
||||
t.Fatalf("WARN 没标出该改哪个文件: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateResolvedProbeURL_EmptyMeansDefault 钉住"空串不在这里判死":两个文件
|
||||
// 都没给地址时交给 Normalized 补 9880(默认值本身合法)。
|
||||
func TestValidateResolvedProbeURL_EmptyMeansDefault(t *testing.T) {
|
||||
if err := validateResolvedProbeURL(probeResolution{url: "", probeSource: probeSourceDefault}); err != nil {
|
||||
t.Fatalf("空地址应交给默认值: %v", err)
|
||||
}
|
||||
if err := validateResolvedProbeURL(probeResolution{url: ttslaunch.DefaultProbeURL}); err != nil {
|
||||
t.Fatalf("默认地址必须合法: %v", err)
|
||||
}
|
||||
if err := validateResolvedProbeURL(probeResolution{url: "localhost:9880"}); err == nil {
|
||||
t.Fatal("漏 scheme 的地址被放过了")
|
||||
}
|
||||
}
|
||||
|
||||
// markerBat 造一个"跑起来就落 marker"的 .bat,用于观察"到底有没有进程被拉起"。
|
||||
// 非 Windows 返回空串(那台机器上这个脚本本来也拉不起来),调用方据此跳过观察。
|
||||
func markerBat(t *testing.T, dir string) (script, marker string) {
|
||||
t.Helper()
|
||||
if runtime.GOOS != "windows" {
|
||||
return "", ""
|
||||
}
|
||||
script = filepath.Join(dir, "一键启动.bat")
|
||||
marker = filepath.Join(dir, "launched.txt")
|
||||
body := fmt.Sprintf("@echo off\r\necho launched> \"%s\"\r\n", marker)
|
||||
if err := os.WriteFile(script, []byte(body), 0o600); err != nil {
|
||||
t.Fatalf("write script: %v", err)
|
||||
}
|
||||
return script, marker
|
||||
}
|
||||
|
||||
// assertNotLaunched 等一小会儿确认脚本**没有**被拉起(拉起是异步的,所以不能只看
|
||||
// 返回值;空 marker = 非 Windows,跳过观察)。
|
||||
func assertNotLaunched(t *testing.T, marker string) {
|
||||
t.Helper()
|
||||
if marker == "" {
|
||||
return
|
||||
}
|
||||
deadline := time.Now().Add(600 * time.Millisecond)
|
||||
for time.Now().Before(deadline) {
|
||||
if _, err := os.Stat(marker); err == nil {
|
||||
t.Fatalf("脚本被拉起了: %s", marker)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
package main
|
||||
|
||||
// 本文件锁定切片 M47 在**组合根**上的口径(都是"改坏就静默开错/静默拖慢启动"的那种):
|
||||
//
|
||||
// 1. 面板文件没配过 / 写了 `enabled:false` ⇒ **不启用**(返回 nil),连探活都不发;
|
||||
// 2. 启用后 `startVoiceTTSLaunch` **立即返回**,即使探活会挂住整整一个超时 ——
|
||||
// 证明 TTS 的延迟不计入后端启动时长(它是唯一"启动耗时"可测的量);
|
||||
// 3. 拉不起来(脚本不存在)⇒ 只有 WARN,**没有任何 error 回流**到启动路径。
|
||||
// 4. 探活地址**单向**回退(塔 2026-10-26 裁决):launch 文件显式写了 `probeUrl`
|
||||
// 就以它为准;没写则用 `voice-tts.json` 的 `baseUrl`;两者都空才是默认 9880。
|
||||
// 启动 Info 行必须同时给出地址与来源。
|
||||
//
|
||||
// 它用 `DefaultVoiceTTSLaunchPath` 的真实落点
|
||||
// ($DATA_DIR/config/voice-tts-launch.json)跑,因此也钉住"零新 .env 键"。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/ttslaunch"
|
||||
)
|
||||
|
||||
// captureBuf 是并发安全的日志缓冲(WARN 由后台 goroutine 写)。
|
||||
type captureBuf struct {
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (b *captureBuf) Write(p []byte) (int, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.Write(p)
|
||||
}
|
||||
|
||||
func (b *captureBuf) String() string {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.String()
|
||||
}
|
||||
|
||||
// launchTestApp 造一个只够本题用的 app(数据目录 + 收日志的 logger)。
|
||||
func launchTestApp(t *testing.T, dataDir string) (*app, *captureBuf) {
|
||||
t.Helper()
|
||||
buf := &captureBuf{}
|
||||
log := slog.New(slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
return &app{cfg: &config.Config{DataDir: dataDir}, log: log}, buf
|
||||
}
|
||||
|
||||
// writeLaunchPanel 把面板文件写到真实落点($DATA_DIR/config/voice-tts-launch.json)。
|
||||
func writeLaunchPanel(t *testing.T, dataDir, body string) {
|
||||
t.Helper()
|
||||
path := runtimeconfig.DefaultVoiceTTSLaunchPath(dataDir)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatalf("write panel: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartVoiceTTSLaunch_DefaultOff 钉住"默认关":没配过 ⇒ 不构造、不探活、不拉起。
|
||||
func TestStartVoiceTTSLaunch_DefaultOff(t *testing.T) {
|
||||
a, buf := launchTestApp(t, t.TempDir())
|
||||
start := time.Now()
|
||||
if l := a.startVoiceTTSLaunch(context.Background()); l != nil {
|
||||
t.Fatal("没配过却启用了自启")
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 100*time.Millisecond {
|
||||
t.Fatalf("默认关也花了 %v", elapsed)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "not enabled") {
|
||||
t.Fatalf("没留一条「没启用」的痕迹: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartVoiceTTSLaunch_ExplicitFalse 钉住"显式 false 同样一次都不拉"。
|
||||
func TestStartVoiceTTSLaunch_ExplicitFalse(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeLaunchPanel(t, dir, `{"enabled":false}`)
|
||||
a, _ := launchTestApp(t, dir)
|
||||
if l := a.startVoiceTTSLaunch(context.Background()); l != nil {
|
||||
t.Fatal("enabled:false 却启用了自启")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartVoiceTTSLaunch_ReturnsBeforeProbe 是本切片的"启动耗时"证明:
|
||||
// 探活会挂住一个 800ms 的超时(对端只接连接不回话),而挂载动作必须在毫秒级返回。
|
||||
//
|
||||
// 同时它顺带钉住第 3 条:脚本不存在 ⇒ 后台只记 WARN,`startVoiceTTSLaunch` 自己
|
||||
// 没有任何 error 可返回(签名里就没有),后端启动路径不受影响。
|
||||
func TestStartVoiceTTSLaunch_ReturnsBeforeProbe(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
blackhole, addr := blackholeListener(t)
|
||||
defer blackhole.Close()
|
||||
|
||||
writeLaunchPanel(t, dir, fmt.Sprintf(
|
||||
`{"enabled":true,"probeUrl":%q,"probeTimeoutMs":800,"scriptPath":%q}`,
|
||||
"http://"+addr+"/", filepath.Join(dir, "不存在的一键启动.bat")))
|
||||
a, buf := launchTestApp(t, dir)
|
||||
|
||||
start := time.Now()
|
||||
launcher := a.startVoiceTTSLaunch(context.Background())
|
||||
elapsed := time.Since(start)
|
||||
if launcher == nil {
|
||||
t.Fatal("启用后应拿到 launcher")
|
||||
}
|
||||
if elapsed > 150*time.Millisecond {
|
||||
t.Fatalf("挂载花了 %v —— 探活被同步等在了启动路径上", elapsed)
|
||||
}
|
||||
// 后台那次探活会等到 800ms 超时,然后去拉一个不存在的脚本。
|
||||
launcher.Wait()
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "level=WARN") || !strings.Contains(out, "backend startup unaffected") {
|
||||
t.Fatalf("拉起失败没有只记 WARN: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVoiceTTSLaunchConfig_MapsTuning 钉住"面板字段 → launcher 配置"的映射:
|
||||
// 缺省值不由组合根裁决(空字段原样传下去,由 ttslaunch.Config.Normalized 补)。
|
||||
func TestVoiceTTSLaunchConfig_MapsTuning(t *testing.T) {
|
||||
enabled := true
|
||||
cfg := voiceTTSLaunchConfig(runtimeconfig.VoiceTTSLaunchTuning{
|
||||
Enabled: &enabled,
|
||||
ScriptPath: `D:\Project\Service\emotionTTS_v5.1.0\一键启动.bat`,
|
||||
ProbeURL: "http://127.0.0.1:9880",
|
||||
ProbeTimeoutMs: 250,
|
||||
CooldownSec: 90,
|
||||
})
|
||||
if !cfg.Enabled {
|
||||
t.Fatal("enabled 没映射过来")
|
||||
}
|
||||
if cfg.ProbeTimeout != 250*time.Millisecond || cfg.Cooldown != 90*time.Second {
|
||||
t.Fatalf("超时/冷却没映射: %+v", cfg)
|
||||
}
|
||||
if cfg.ScriptPath != ttslaunch.DefaultScriptPath || cfg.ProbeURL != ttslaunch.DefaultProbeURL {
|
||||
t.Fatalf("路径/地址没映射: %+v", cfg)
|
||||
}
|
||||
|
||||
// 空配置 ⇒ 不启用,且一个缺省值都不在这里填(Normalized 才是唯一裁决处)。
|
||||
if empty := voiceTTSLaunchConfig(runtimeconfig.VoiceTTSLaunchTuning{}); empty.Enabled {
|
||||
t.Fatal("空配置不该启用")
|
||||
} else if empty.ScriptPath != "" || empty.ProbeURL != "" || empty.ProbeTimeout != 0 {
|
||||
t.Fatalf("组合根不该替 launcher 填缺省值: %+v", empty)
|
||||
}
|
||||
}
|
||||
|
||||
// blackholeListener 起一个"只接连接、绝不回话"的回环监听:探活会一直等到自己的
|
||||
// 超时,正好用来证明挂载动作没有同步等它。
|
||||
func blackholeListener(t *testing.T) (net.Listener, string) {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
var conns []net.Conn
|
||||
var mu sync.Mutex
|
||||
go func() {
|
||||
for {
|
||||
c, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
conns = append(conns, c)
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
t.Cleanup(func() {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
for _, c := range conns {
|
||||
_ = c.Close()
|
||||
}
|
||||
})
|
||||
return ln, ln.Addr().String()
|
||||
}
|
||||
|
||||
// TestStartVoiceTTSLaunch_ProbeURLInheritedFromVoiceTTS 钉住**单向回退**的第一向:
|
||||
// launch 文件没写 `probeUrl` 时,用 `voice-tts.json` 的 `baseUrl`(主人换端口只该
|
||||
// 改一处)。
|
||||
//
|
||||
// 它不只比字符串 —— 真让探活打到那个端口(hits > 0):把回退摘掉,探活就会跑去
|
||||
// 默认 9880,本用例立刻红。
|
||||
func TestStartVoiceTTSLaunch_ProbeURLInheritedFromVoiceTTS(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
voiceTTS, hits := countingServer(t)
|
||||
a, buf := launchTestApp(t, dir)
|
||||
|
||||
// 合成面把端口改成了自定义端口;自启面**没写** probeUrl。
|
||||
writeVoiceTTSPanel(t, a, fmt.Sprintf(`{"enabled":true,"baseUrl":%q,"personas":{"cyrene":"char_1"}}`, voiceTTS.URL))
|
||||
writeLaunchPanel(t, dir, fmt.Sprintf(`{"enabled":true,"scriptPath":%q}`,
|
||||
filepath.Join(dir, "不存在的一键启动.bat")))
|
||||
|
||||
launcher := a.startVoiceTTSLaunch(context.Background())
|
||||
if launcher == nil {
|
||||
t.Fatal("启用后应拿到 launcher")
|
||||
}
|
||||
if got := launcher.Config().ProbeURL; got != voiceTTS.URL {
|
||||
t.Fatalf("探活地址 = %q, want voice-tts.json 的 baseUrl %q", got, voiceTTS.URL)
|
||||
}
|
||||
launcher.Wait()
|
||||
if n := atomic.LoadInt32(hits); n == 0 {
|
||||
t.Fatal("探活没打到 voice-tts.json 的 baseUrl 端口(回退失效)")
|
||||
}
|
||||
if out := buf.String(); !strings.Contains(out, "probe_url_source=voice-tts.json") {
|
||||
t.Fatalf("启动 Info 行没标出地址是兜底来的: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartVoiceTTSLaunch_ProbeURLFallsBackToDefault 钉住第二向:两个文件都没给
|
||||
// 地址 ⇒ 默认 `http://127.0.0.1:9880`,且日志标 `default`。
|
||||
func TestStartVoiceTTSLaunch_ProbeURLFallsBackToDefault(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
a, buf := launchTestApp(t, dir)
|
||||
// 只有自启文件,没有 voice-tts.json。
|
||||
writeLaunchPanel(t, dir, fmt.Sprintf(`{"enabled":true,"scriptPath":%q}`,
|
||||
filepath.Join(dir, "不存在的一键启动.bat")))
|
||||
|
||||
launcher := a.startVoiceTTSLaunch(context.Background())
|
||||
if launcher == nil {
|
||||
t.Fatal("启用后应拿到 launcher")
|
||||
}
|
||||
if got := launcher.Config().ProbeURL; got != ttslaunch.DefaultProbeURL {
|
||||
t.Fatalf("探活地址 = %q, want 默认 %q", got, ttslaunch.DefaultProbeURL)
|
||||
}
|
||||
launcher.Wait()
|
||||
if out := buf.String(); !strings.Contains(out, "probe_url_source=default") {
|
||||
t.Fatalf("启动 Info 行没标出地址用的是默认值: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartVoiceTTSLaunch_ExplicitProbeURLWins 钉住"显式压过兜底":launch 文件写了
|
||||
// `probeUrl` ⇒ 用它,**永不**去问 voice-tts.json 的 baseUrl(后者一次都不该被探)。
|
||||
func TestStartVoiceTTSLaunch_ExplicitProbeURLWins(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
explicit, explicitHits := countingServer(t)
|
||||
inherited, inheritedHits := countingServer(t)
|
||||
a, buf := launchTestApp(t, dir)
|
||||
|
||||
writeVoiceTTSPanel(t, a, fmt.Sprintf(`{"enabled":true,"baseUrl":%q}`, inherited.URL))
|
||||
writeLaunchPanel(t, dir, fmt.Sprintf(`{"enabled":true,"probeUrl":%q,"scriptPath":%q}`,
|
||||
explicit.URL, filepath.Join(dir, "不存在的一键启动.bat")))
|
||||
|
||||
launcher := a.startVoiceTTSLaunch(context.Background())
|
||||
if launcher == nil {
|
||||
t.Fatal("启用后应拿到 launcher")
|
||||
}
|
||||
if got := launcher.Config().ProbeURL; got != explicit.URL {
|
||||
t.Fatalf("探活地址 = %q, want 显式 %q", got, explicit.URL)
|
||||
}
|
||||
launcher.Wait()
|
||||
if atomic.LoadInt32(explicitHits) == 0 {
|
||||
t.Fatal("显式 probeUrl 没被用来探活")
|
||||
}
|
||||
if n := atomic.LoadInt32(inheritedHits); n != 0 {
|
||||
t.Fatalf("显式 probeUrl 仍去问了 voice-tts.json 的地址 %d 次", n)
|
||||
}
|
||||
if out := buf.String(); !strings.Contains(out, "probe_url_source=voice-tts-launch.json") {
|
||||
t.Fatalf("启动 Info 行没标出地址是显式配置的: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// countingServer 起一个"数请求次数"的 httptest 服务:用来证明探活**真的打到了**
|
||||
// 某个地址,而不只是配置里写着那个字符串。
|
||||
func countingServer(t *testing.T) (*httptest.Server, *int32) {
|
||||
t.Helper()
|
||||
var hits int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
atomic.AddInt32(&hits, 1)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv, &hits
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
@@ -108,6 +109,18 @@ func (a *app) wireChannels() error {
|
||||
// 入站附件的目录口径与文档摄取层(wire_runtime.go 的 docResolver)同源,
|
||||
// 两处必须指向同一棵目录树,否则附件会落在一个地方、抽取去另一个地方找。
|
||||
docDataDir := resolveDataDir(a.cfg)
|
||||
// owner 车道结构化旋钮(design/18 §11「乙 = C」)的落盘面:主人在群里说「少说点」
|
||||
// 这类诉求被写成 $DATA_DIR/config/owner_lane.json(可读可改,面板后续照此写入)。
|
||||
// 它只承载「说不说 / 多频繁」,不承载任何身份/权限信息。
|
||||
ownerLane := runtimeconfig.NewOwnerLaneStore(
|
||||
runtimeconfig.DefaultOwnerLanePath(docDataDir),
|
||||
logger.WithSource(a.log, "services/channelbridge/ownerlane"))
|
||||
// 静默窗口合并(M23)的落盘面:$DATA_DIR/config/coalesce.json(零新 env 键,
|
||||
// design/13 §2.1)。它只承载**调优**(窗口/绝对上限/单批上限/给不给陌生人),
|
||||
// **不承载身份**,也**翻不开**主人私聊的 direct 车道(那是 actor 元数据决定的类型)。
|
||||
coalesceStore := runtimeconfig.NewCoalesceStore(
|
||||
runtimeconfig.DefaultCoalescePath(docDataDir),
|
||||
logger.WithSource(a.log, "services/channelbridge/coalesce"))
|
||||
bridge, berr := channelbridge.New(channelbridge.Options{
|
||||
Log: logger.WithSource(a.log, "services/channelbridge"),
|
||||
Loop: a.loop,
|
||||
@@ -145,6 +158,32 @@ func (a *app) wireChannels() error {
|
||||
PersonaPrompt: a.defaultInstructions(),
|
||||
Engage: channelbridge.DefaultEngageConfig(),
|
||||
// EngageFor 留空(P2a);按群覆盖随 P2c 到来。
|
||||
// OwnerLaneFor / OwnerLaneSet 是 owner 车道结构化旋钮(design/18 §11「乙 = C」)
|
||||
// 的读取/落盘接缝:读接口按 (platform, groupID) 返回该群生效策略(缺省 = 不对她说
|
||||
// 就不开口),写接口把「少说点」这类结构化诉求落进 owner_lane.json,也是面板后续
|
||||
// 写入的那一份。它只影响说不说/多频繁,绝不触碰权限与 actor 元数据。
|
||||
OwnerLaneFor: func(platform, groupID string) *channelbridge.OwnerLanePolicy {
|
||||
p := ownerLane.PolicyFor(platform, groupID)
|
||||
return &channelbridge.OwnerLanePolicy{
|
||||
ReplyToUnaddressed: p.ReplyToUnaddressed,
|
||||
MinReplyGapSec: p.MinReplyGapSec,
|
||||
}
|
||||
},
|
||||
OwnerLaneSet: func(platform, groupID string, p channelbridge.OwnerLanePolicy) error {
|
||||
return ownerLane.Set(platform, groupID, runtimeconfig.OwnerLanePolicy{
|
||||
ReplyToUnaddressed: p.ReplyToUnaddressed,
|
||||
MinReplyGapSec: p.MinReplyGapSec,
|
||||
})
|
||||
},
|
||||
// Coalesce 是**静默窗口合并**(M23)的注入面:按车道键取生效窗口参数。
|
||||
// 车道类型不在这里决定 —— 主人私聊恒为 direct 车道(无窗口),由 actor 元数据
|
||||
// 判定,配置无权翻转;这里只给 batch 车道(群聊/陌生人私聊)的窗口取值。
|
||||
Coalesce: channelbridge.CoalesceOptions{
|
||||
For: func(laneKey string) *runtimeconfig.CoalescePolicy {
|
||||
p := coalesceStore.PolicyFor(laneKey)
|
||||
return &p
|
||||
},
|
||||
},
|
||||
// Factory 让面板保存时热替换某平台的适配器而无需重启(design/04 §3.1)。
|
||||
Factory: factory,
|
||||
// Channels 让热重载把新适配器写回能力注册表:管理面板(渠道卡片、账号在线
|
||||
@@ -184,6 +223,7 @@ func (a *app) wireChannels() error {
|
||||
fallback: factory.fallback,
|
||||
bridge: bridge,
|
||||
owner: a.owner,
|
||||
coalesce: coalesceStore,
|
||||
log: a.log,
|
||||
}
|
||||
// 启动即灌一次每渠道管理员白名单(裁决 06.4.2):不必等面板打开或热重载,
|
||||
@@ -204,11 +244,55 @@ type runtimeChannels struct {
|
||||
// owner 是渠道 bridge 判 actor 用的**同一个**活档案指针:读配置时把每渠道管理员
|
||||
// 白名单灌进它的 adminIDs 层(裁决 06.4.2),面板保存后无需重启即生效。
|
||||
owner *user.OwnerProfile
|
||||
log *slog.Logger
|
||||
// coalesce 是静默窗口合并(M23)的**同一个** store 实例:bridge 每次发车前从它
|
||||
// 读生效值,因此面板保存后下一次发车即生效(热生效),无需重启。
|
||||
coalesce *runtimeconfig.CoalesceStore
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
func (r *runtimeChannels) Path() string { return r.path }
|
||||
|
||||
// CoalescePath 返回静默窗口配置文件(coalesce.json)的绝对落点,供面板如实回显。
|
||||
func (r *runtimeChannels) CoalescePath() string {
|
||||
if r.coalesce == nil {
|
||||
return ""
|
||||
}
|
||||
return r.coalesce.Path()
|
||||
}
|
||||
|
||||
// CoalescePresent 报告静默窗口配置文件是否已存在(面板据此显示「还没配置过」)。
|
||||
func (r *runtimeChannels) CoalescePresent() bool {
|
||||
if r.coalesce == nil {
|
||||
return false
|
||||
}
|
||||
return r.coalesce.Present()
|
||||
}
|
||||
|
||||
// CoalesceSnapshot 返回盘上那份静默窗口配置(未 clamp 的原值),供面板回显。
|
||||
func (r *runtimeChannels) CoalesceSnapshot() runtimeconfig.CoalesceFile {
|
||||
if r.coalesce == nil {
|
||||
return runtimeconfig.CoalesceFile{}
|
||||
}
|
||||
return r.coalesce.Snapshot()
|
||||
}
|
||||
|
||||
// ApplyCoalesce 应用一份三态补丁:内存与落盘在同一个临界区,桥下一次发车即读到新值
|
||||
// (store 与 bridge 是**同一个**实例,因此不需要任何重启或重载)。
|
||||
func (r *runtimeChannels) ApplyCoalesce(patch runtimeconfig.CoalescePatch) (bool, error) {
|
||||
if r.coalesce == nil {
|
||||
return false, errors.New("channel coalesce config store not wired")
|
||||
}
|
||||
wrote, err := r.coalesce.Apply(patch)
|
||||
if err != nil {
|
||||
return wrote, fmt.Errorf("apply coalesce patch: %w", err)
|
||||
}
|
||||
if wrote {
|
||||
r.log.Info("channel coalesce config saved (hot: the bridge reads it on the next departure)",
|
||||
"path", r.CoalescePath())
|
||||
}
|
||||
return wrote, nil
|
||||
}
|
||||
|
||||
func (r *runtimeChannels) Load() map[string][]config.ChannelAccount {
|
||||
accounts := r.loadAccounts()
|
||||
r.applyAdminIDs(accounts)
|
||||
|
||||
@@ -126,7 +126,11 @@ func (a *app) wireFrontend() error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("gateway: %w", err)
|
||||
}
|
||||
gw.SetVoice(a.voice)
|
||||
// 语音面只在这**一处**注入:emotionTTS 已按面板配置启用时是「emotionTTS +
|
||||
// 既有链」的门面(失败回落既有链),否则就是既有链本身(行为逐字不变)。
|
||||
// 两条既有出站口(POST /api/v1/audio/speech 与 WS voice_output)都读这一份,
|
||||
// 因此不新建端点、不加帧族(切片 M35 红线)。
|
||||
gw.SetVoice(a.voiceForGateway())
|
||||
a.gw = gw // 必须在下面接线 hook 之前赋值——那些 hook 捕获了 a.gw / gw(否则会 nil 接收者 panic)
|
||||
// 可观测性 trace 出口(design/19 §2):网关已存在,其环形缓冲必须由循环产出的
|
||||
// span 喂养,否则管理端 trace/metrics 接口会一直是空的。
|
||||
@@ -165,6 +169,12 @@ func (a *app) wireFrontend() error {
|
||||
// backthink.New 时网关还没组装。
|
||||
a.backthink.SetAuditSink(gw)
|
||||
}
|
||||
// 子代理结果回填(M32;design/05 §2/§5/§9):网关 **已存在**(回填的实时出口就是
|
||||
// 它的 `message_persisted` 扇出)之后才能接线。它订阅 `subagent.result`,把后台
|
||||
// 批次的结论写成父会话的一条 **system 注记**(不冒充 user/assistant、不带 turn_id、
|
||||
// 绝不进记忆抽取),因此「她拍了个子代理出去,然后没有下文」这件事在历史与下一轮
|
||||
// 上下文里都有落点。装配细节与降级口径见 wire_subagent_backfill.go。
|
||||
wireSubagentBackfill(a, gw)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -203,6 +213,13 @@ func wirePanelHub(a *app) {
|
||||
})
|
||||
a.plugins.AttachPanels(hub)
|
||||
declareBuiltinPanels(hub, log)
|
||||
// 「MCP 服务器」页(切片 M20):声明 + 动作处理器成对装配,且必须在中枢接线之后
|
||||
// (AttachMCPPanel 经插件管理器取中枢,未接线时会响亮报错而不是静默不挂)。
|
||||
// 它归属插件管理器自己的领域数据(Statuses/Install/SetEnabled/Uninstall),因此
|
||||
// 与 web-search 的纯声明不同:这里一处调用同时完成声明与动作登记。
|
||||
if err := a.plugins.AttachMCPPanel(); err != nil {
|
||||
log.Warn("mcp server panel unavailable; its actions will answer 501", "error", err)
|
||||
}
|
||||
// browser 插件(design/28 §8 B4)的面板声明 + 六个动作:它自己就把「先声明、后登记
|
||||
// 动作」这一步走完(wire_browser_panel.go 一处),因此与 search 的纯声明并列。
|
||||
//
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package main
|
||||
|
||||
// 本文件只有一件事:给循环的记忆提取器套上「剔除子代理结果注记」这一层,并把这条
|
||||
// 口径写在一个可测的入口上(M32)。
|
||||
//
|
||||
// 为什么要单独一个函数,而不是在 `wire_runtime.go` 的 Options 字面量里就地调一次:
|
||||
//
|
||||
// - 它是**红线级**口径(子代理结论绝不进记忆库),值得有一个名字与一个测试,
|
||||
// 而不是夹在一长串 Options 里的一行调用 —— 那一行被删掉时没有任何东西会红;
|
||||
// - 组合根里所有「必须套上」的装饰都走这种小函数(同 `a.buildMemoryExtractor()`
|
||||
// 之于提取器本身),语义在函数名下,接线在唯一一处。
|
||||
//
|
||||
// 判据与实现见 `internal/services/gateway/subagent_backfill_extract.go`:元数据
|
||||
// (role=system + 注记标记),不是提示词。
|
||||
|
||||
import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway"
|
||||
)
|
||||
|
||||
// loopMemoryExtractor 返回循环实际使用的提取器(`wire_runtime.go` 的
|
||||
// `loop.Options.Extractor` 就是它):内层是组合根按配置构建的那个(LLM 或确定性),
|
||||
// 外层保证子代理结果注记**永远不进**抽取输入。
|
||||
//
|
||||
// base 为 nil 时返回 nil(未配置提取器的部署逐字不变:循环那边同样是 nil 即跳过)。
|
||||
func loopMemoryExtractor(base memory.Extractor) memory.Extractor {
|
||||
return gateway.WithoutSubagentNotes(base)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/transcript"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway"
|
||||
)
|
||||
|
||||
// 本文件锁住组合根那一条红线:循环用的提取器**必须**套上「剔除子代理注记」这一层
|
||||
// (loopMemoryExtractor)。删掉那层装饰,子代理的结论就会被当成对话写进记忆库 ——
|
||||
// 那条污染线刚查过(84% 记忆来自内部会话),不能再添一条。
|
||||
//
|
||||
// 断言的是**组合根这个入口**的语义,因此即使 gateway 那边的装饰器自己写对,把这里
|
||||
// 换成 `base` 直通也会红。
|
||||
|
||||
// noteCaptureStore 是回填落库面的替身:只留下注记正文。
|
||||
type noteCaptureStore struct {
|
||||
mu sync.Mutex
|
||||
rows []history.Message
|
||||
}
|
||||
|
||||
func (s *noteCaptureStore) AppendFull(_ context.Context, msg history.Message) (history.Message, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if msg.ID == "" {
|
||||
msg.ID = "msg-note"
|
||||
}
|
||||
s.rows = append(s.rows, msg)
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// seenCaptureExtractor 记录内层提取器实际收到的对话。
|
||||
type seenCaptureExtractor struct {
|
||||
mu sync.Mutex
|
||||
seen []model.Message
|
||||
}
|
||||
|
||||
func (e *seenCaptureExtractor) Extract(_ context.Context, conversation []model.Message) ([]memory.MemoryItem, error) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.seen = append([]model.Message(nil), conversation...)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// TestLoopMemoryExtractorStripsBackfilledNotes 走真链路取一条注记(回填 → 落库 →
|
||||
// 转录播种),再把它连同一条主人输入交给组合根返回的提取器:内层必须只看得到主人
|
||||
// 那一句。
|
||||
func TestLoopMemoryExtractorStripsBackfilledNotes(t *testing.T) {
|
||||
b := bus.New()
|
||||
t.Cleanup(func() { _ = b.Close() })
|
||||
store := ¬eCaptureStore{}
|
||||
bf, err := gateway.NewSubagentBackfill(gateway.SubagentBackfillDeps{Bus: b, History: store})
|
||||
if err != nil {
|
||||
t.Fatalf("NewSubagentBackfill: %v", err)
|
||||
}
|
||||
if err := bf.Handle(context.Background(), bus.Event{
|
||||
Subject: orchestration.SubagentResultSubject,
|
||||
Payload: &orchestration.SubagentResultEvent{
|
||||
SessionID: history.MainSessionID,
|
||||
SpawnResult: &orchestration.SpawnResult{
|
||||
Status: "done",
|
||||
Summary: "子代理汇总: 共 1 个, 1 成功",
|
||||
Agents: []orchestration.SpawnAgentResult{{
|
||||
ID: "sub-a1", Goal: "调研 X", Status: "done", Summary: "结论:X 可行",
|
||||
}},
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("Handle: %v", err)
|
||||
}
|
||||
if len(store.rows) != 1 {
|
||||
t.Fatalf("backfill wrote %d rows, want one system note", len(store.rows))
|
||||
}
|
||||
note := transcript.Message(store.rows[0])
|
||||
if note.Role != model.RoleSystem {
|
||||
t.Fatalf("seeded note role = %q, want system", note.Role)
|
||||
}
|
||||
|
||||
inner := &seenCaptureExtractor{}
|
||||
extractor := loopMemoryExtractor(inner)
|
||||
if extractor == nil {
|
||||
t.Fatal("loopMemoryExtractor(non-nil) = nil")
|
||||
}
|
||||
if _, err := extractor.Extract(context.Background(), []model.Message{
|
||||
note,
|
||||
model.TextMessage(model.RoleUser, "接着说"),
|
||||
}); err != nil {
|
||||
t.Fatalf("Extract: %v", err)
|
||||
}
|
||||
inner.mu.Lock()
|
||||
seen := inner.seen
|
||||
inner.mu.Unlock()
|
||||
if len(seen) != 1 {
|
||||
t.Fatalf("inner extractor saw %d messages, want only the user turn: the composition root dropped the note filter", len(seen))
|
||||
}
|
||||
if !strings.Contains(messageTextOf(seen[0]), "接着说") {
|
||||
t.Fatalf("inner extractor lost the real dialogue: %+v", seen)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoopMemoryExtractorKeepsNil 锁住未配置提取器的部署逐字不变。
|
||||
func TestLoopMemoryExtractorKeepsNil(t *testing.T) {
|
||||
if got := loopMemoryExtractor(nil); got != nil {
|
||||
t.Fatalf("loopMemoryExtractor(nil) = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
// messageTextOf 返回一条模型消息的正文(测试窄视图)。
|
||||
func messageTextOf(m model.Message) string {
|
||||
var b strings.Builder
|
||||
for _, c := range m.Content {
|
||||
if c.Type == model.ContentTypeText {
|
||||
b.WriteString(c.Text)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -75,6 +75,12 @@ func TestMemoryReviewIsWired(t *testing.T) {
|
||||
"a.memoryT1RateLimit()",
|
||||
"T1PerWindow: t1PerWindow",
|
||||
"T1Window: time.Duration(t1WindowSec) * time.Second",
|
||||
// 临时记忆 TTL(design/03 §9.6):第五态 `defer` 写进 `expires_at` 的
|
||||
// 存活秒数,同样来自那个文件(`tempTtlSec`),缺位/坏值回落代码默认
|
||||
// 7 天。**它没有 `.env` 档**(`.env` 已冻结,本键在那里从来没有出处),
|
||||
// 因此这一行被摘掉 = 面板配了也不生效,正是本缝断言要拦的回归。
|
||||
"a.memoryTempTTL()",
|
||||
"TempTTL: tempTTL",
|
||||
},
|
||||
"main.go": {
|
||||
"a.memoryReview.Start(ctx)",
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
|
||||
// 本文件把记忆整理(DevDocs/design/23-记忆整理与去重 §4)接进组合根。
|
||||
//
|
||||
// 四件事只在这里定:
|
||||
// 六件事只在这里定:
|
||||
// - **形态**:定期后台任务(安静时段降频)的心跳(memory.TidyService),不是
|
||||
// 会话内派 subagent。它在 main.go 与 backthink 并列启动、在 close 里先于关库停止;
|
||||
// - **依赖面**:只给它记忆存储与 embedder —— 服务侧拿不到任何会话/历史读数,
|
||||
@@ -26,7 +26,12 @@ import (
|
||||
// 变成「无限制」,也不能让整理频率变成忙轮询);
|
||||
// - **启动播种**(2026-10-07 追加,主人「后端**自动创建**独立的面板配置文件**要做**」):
|
||||
// 那个文件不存在时由后端自己写一份「当前生效值」进去(seedMemoryTidyPanel),
|
||||
// 使「面板优先」从第一次启动起就成立,而不是靠 `.env` 一直兜着。
|
||||
// 使「面板优先」从第一次启动起就成立,而不是靠 `.env` 一直兜着;
|
||||
// - **到期清扫的挂点**(2026-10-13 追加,切片 M3,design/23 §19):临时记忆到期
|
||||
// 后要**真删掉**(读侧时效闸只负责「看不见」)。清扫是 memory 包内的后台件
|
||||
// (expire.go / expire_loop.go),起在 **TidyService.Start** 里、与心跳共用
|
||||
// 同一个 ctx 与 wg,因此它跟整理同生共死(`Stop` 一并停掉它),不新开关闭链、
|
||||
// 不新开端口、不加 env 键 —— 组合根这边**零接线**。
|
||||
//
|
||||
// 刻意**不做**的一件事(design §4.1 F):
|
||||
// - 不把 sess-memorytidy 加进 flowPub 的图源白名单(默认关:整理是系统任务,
|
||||
@@ -49,6 +54,10 @@ func (a *app) wireMemoryTidy() error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("memory tidy: %w", err)
|
||||
}
|
||||
// 到期清扫(design/23 §19 的第 ② / ③ 步)**不在组合根挂**:它起在
|
||||
// TidyService.Start 里,与整理心跳共用同一个 ctx 与 wg(见 expire_loop.go)。
|
||||
// 因此这里零改动即完成接线 —— 不新起一条关闭链、不新开监听端口、不加任何
|
||||
// env 键,`wire_services.go` 的 a.memoryTidy.Stop 会连同它一起停掉。
|
||||
a.memoryTidy = svc
|
||||
return nil
|
||||
}
|
||||
@@ -58,11 +67,13 @@ func (a *app) wireMemoryTidy() error {
|
||||
// 而不是每次都靠 `.env` 兜。失败只记日志、**绝不阻断启动**:运行期仍按 `.env` +
|
||||
// 代码默认跑,行为与播种前逐字相同(与 materializeModels 同一口径)。
|
||||
//
|
||||
// 闸与 T1 的四键(ingestGate / t1Judge / t1PerWindow / t1WindowSec)在 `.env` 里
|
||||
// **没有出处**(`.env` 已冻结,它们只由这个面板文件与代码默认决定,见
|
||||
// wireMemoryReview 的三个 helper),而播种只发生在**面板文件不存在**时 —— 那正是
|
||||
// 闸与 T1 的四键(ingestGate / t1Judge / t1PerWindow / t1WindowSec)**外加**临时
|
||||
// 记忆 TTL 一键(tempTtlSec,切片 M2 加进来的第五个 review 键)在 `.env` 里
|
||||
// **都没有出处**(`.env` 已冻结,它们只由这个面板文件与代码默认决定,见
|
||||
// wireMemoryReview 的四个 helper:memoryIngestGateOff / memoryT1JudgeOff /
|
||||
// memoryT1RateLimit / memoryTempTTL),而播种只发生在**面板文件不存在**时 —— 那正是
|
||||
// 「零值 + normalizeReviewConfig」这一条路径,因此这里给零值 ReviewConfig 就是
|
||||
// **当时**的生效值,不是猜的。审查开关本身(MEMORY_REVIEW_ENABLED)不在这十键里,
|
||||
// **当时**的生效值,不是猜的。审查开关本身(MEMORY_REVIEW_ENABLED)不在这十一键里,
|
||||
// 因此不必为它去读一次 env。
|
||||
func (a *app) seedMemoryTidyPanel(tidy memory.TidyConfig) {
|
||||
path := runtimeconfig.DefaultMemoryTidyPath(resolveDataDir(a.cfg))
|
||||
|
||||
@@ -69,6 +69,18 @@ func TestMemoryTidyIsWired(t *testing.T) {
|
||||
"main.go": {
|
||||
"a.memoryTidy.Start(ctx)",
|
||||
},
|
||||
// 到期清扫(切片 M3,design/23 §19 的第 ② / ③ 步):临时记忆到期后要
|
||||
// **真删掉**,而它起在 TidyService.Start 里(与心跳同一个 cctx / wg)。
|
||||
// 少了这一句,过期行只会被读侧时效闸「挡在门外」而**永远不删** ——
|
||||
// 不报错、不红任何别的用例,只是库里的临时层无界留存。因此这里把它
|
||||
// 钉成 seam:`TestTidyServiceStart_清扫随服务起停` 是它的运行时对照。
|
||||
"../../internal/runtime/memory/tidy_service.go": {
|
||||
"go s.expirySweepLoop(cctx)",
|
||||
},
|
||||
"../../internal/runtime/memory/expire_loop.go": {
|
||||
"func (s *TidyService) expirySweepLoop(ctx context.Context)",
|
||||
"DefaultExpireSweepInterval",
|
||||
},
|
||||
"wire_services.go": {
|
||||
// 关库之前先停整理(它每轮要用连接池)。
|
||||
"a.memoryTidy.Stop(ctx)",
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/paneldecl"
|
||||
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
@@ -99,23 +100,45 @@ func TestWirePanelHubDeclaresBuiltinSearchPanel(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(entries) != 1 || entries[0].PluginID != search.PanelPluginID ||
|
||||
entries[0].Panel.ID != search.PanelIDProviders {
|
||||
t.Fatalf("内置面板 = %+v,期望 web-search/providers 一条", entries)
|
||||
// 按 pluginID 定位,而不是数「恰好一条」:内置提供者会越来越多(本片新增
|
||||
// mcp/servers 管理页),数总数的断言每加一页就假红一次,而它真正想守的是
|
||||
// 「web-search/providers 在目录里,且形状仍是声明的那一版」。
|
||||
entry, ok := findPanelEntry(entries, search.PanelPluginID, search.PanelIDProviders)
|
||||
if !ok {
|
||||
t.Fatalf("内置面板里没有 web-search/providers:%+v", entries)
|
||||
}
|
||||
// 形状也钉住:一个 form(写面)+ 一个 table(读面)。
|
||||
if len(entry.Panel.Sections) != 2 || entry.Panel.Sections[0].Type != sdk.SectionForm ||
|
||||
entry.Panel.Sections[1].Type != sdk.SectionTable {
|
||||
t.Fatalf("web-search/providers 的区块形状 = %+v,期望 form+table", entry.Panel.Sections)
|
||||
}
|
||||
for _, name := range search.ActionNames() {
|
||||
if _, ok := hub.Action(search.PanelPluginID, name); !ok {
|
||||
t.Fatalf("内置插件声明的动作 %q 未进白名单", name)
|
||||
}
|
||||
}
|
||||
// viewer 拿到的必须是裁掉 form 的只读投影。
|
||||
// viewer 拿到的必须是裁掉 form 的只读投影(只剩那个表格区块)。
|
||||
viewer, err := hub.List(auth.RoleViewer)
|
||||
if err != nil {
|
||||
t.Fatalf("List(viewer): %v", err)
|
||||
}
|
||||
if len(viewer) != 1 || len(viewer[0].Panel.Sections) != 1 {
|
||||
t.Fatalf("viewer 的只读投影 = %+v,期望只剩表格区块", viewer)
|
||||
viewerEntry, ok := findPanelEntry(viewer, search.PanelPluginID, search.PanelIDProviders)
|
||||
if !ok {
|
||||
t.Fatalf("viewer 快照里没有 web-search/providers:%+v", viewer)
|
||||
}
|
||||
if len(viewerEntry.Panel.Sections) != 1 || viewerEntry.Panel.Sections[0].Type != sdk.SectionTable {
|
||||
t.Fatalf("viewer 的只读投影 = %+v,期望只剩表格区块", viewerEntry.Panel.Sections)
|
||||
}
|
||||
}
|
||||
|
||||
// findPanelEntry 在目录快照里按 (pluginId, panelId) 定位那一条。
|
||||
func findPanelEntry(entries []paneldecl.Entry, pluginID, panelID string) (paneldecl.Entry, bool) {
|
||||
for _, e := range entries {
|
||||
if e.PluginID == pluginID && e.Panel.ID == panelID {
|
||||
return e, true
|
||||
}
|
||||
}
|
||||
return paneldecl.Entry{}, false
|
||||
}
|
||||
|
||||
// TestWirePanelHubNilPluginsIsNoop 断言未构造插件管理器时接线是空操作(不 panic)。
|
||||
|
||||
@@ -324,11 +324,15 @@ func (a *app) wireRuntime() error {
|
||||
OnTurnEnd: a.onTurnEnd,
|
||||
Memory: store,
|
||||
Retriever: a.retriever,
|
||||
Extractor: a.buildMemoryExtractor(),
|
||||
Extractor: loopMemoryExtractor(a.buildMemoryExtractor()),
|
||||
Checkpoint: checkpoint,
|
||||
Tracer: tracer,
|
||||
Flow: flowPub,
|
||||
Guards: guards,
|
||||
// speak 的两个依赖(wire_speak.go):emotionTTS 未启用时 synth 为 nil,
|
||||
// speak 不广告(voiceEnabled() 要求两者齐备)。
|
||||
VoiceSynth: a.speakSynthForLoop(),
|
||||
VoiceStore: a.speakStoreForLoop(docDataDir),
|
||||
PersonaID: personaID,
|
||||
PersonaName: a.personaName,
|
||||
PersonaKnobs: a.personaKnobs,
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package main
|
||||
|
||||
// speak 的组合根接线(M57/M58 三片合并后的集成缝隙修复,2026-10-27):
|
||||
// `loop.VoiceSynth` / `VoiceStore` 两个依赖此前没有任何 wire 层注入 ⇒
|
||||
// `voiceEnabled()` 恒 false ⇒ speak 从不被广告(症状:她说"不能发语音"、
|
||||
// 查工具清单也看不到 speak)。本文件补上这一跳。
|
||||
//
|
||||
// 语义:speak 依赖 emotionTTS(发音人按人格解析在那条链上)——面板
|
||||
// voice-tts.json 未启用时注入 nil,speak 不广告(比"广告了但永远
|
||||
// not_configured 降级"诚实)。副本落盘与 channelbridge / 文档上传同源:
|
||||
// 同一棵 `<DataDir>/uploads/documents` 目录、同一张 documents 表
|
||||
// (引用计数与 GC 的单一事实来源不被旁路)。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
voicetts "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/tts"
|
||||
)
|
||||
|
||||
const speakWireSource = "cmd/cyrene/speak"
|
||||
|
||||
// speakSynthForLoop 返回注入 loop 的合成能力:emotionTTS 按面板启用时是
|
||||
// 一个独立装配的 voicetts.Service(与网关的实例同配置不同对象——装配期
|
||||
// 一次性构造,无共享状态竞争);未启用返回 nil(speak 不广告)。
|
||||
func (a *app) speakSynthForLoop() loop.VoiceSynth {
|
||||
cfg, enabled := a.voiceTTSConfig()
|
||||
if !enabled {
|
||||
logger.WithSource(a.log, speakWireSource).Debug("speak stays unadvertised: emotionTTS not enabled",
|
||||
"config", "voice-tts.json", "hint", "set {\"enabled\": true}")
|
||||
return nil
|
||||
}
|
||||
client := voicetts.New(cfg, voicetts.WithLogger(logger.WithSource(a.log, speakWireSource)))
|
||||
return voicetts.NewService(client, a.voice, voicetts.WithServiceLogger(logger.WithSource(a.log, speakWireSource)))
|
||||
}
|
||||
|
||||
// speakStoreForLoop 返回注入 loop 的音频副本落盘能力:与 channelbridge 的
|
||||
// DocumentStore 同目录同表(wire_channels.go 的口径),经 VoiceStoreFunc
|
||||
// 做 documents.Ref → loop.VoiceRef 的窄转换(loop 不 import documents,
|
||||
// 测试期 import cycle 的既定裁决)。
|
||||
func (a *app) speakStoreForLoop(docDataDir string) loop.VoiceStore {
|
||||
store := documents.NewStore(documents.StoreConfig{
|
||||
DataDir: docDataDir,
|
||||
UploadsDir: filepath.Join(docDataDir, "uploads", "documents"),
|
||||
Meta: documents.MetadataWriterFor(a.dbPath, a.log),
|
||||
Log: a.log,
|
||||
})
|
||||
return loop.VoiceStoreFunc(func(ctx context.Context, origName string, data []byte) (loop.VoiceRef, error) {
|
||||
ref, err := store.SaveAudio(ctx, origName, data)
|
||||
if err != nil {
|
||||
return loop.VoiceRef{}, err
|
||||
}
|
||||
return loop.VoiceRef{Name: ref.Name, MIME: ref.MIME, Size: ref.Size}, nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package main
|
||||
|
||||
// 本文件把**子代理结果回填**接进组合根(M32;design/05 §2/§5/§9)。
|
||||
//
|
||||
// 为什么必须在组合根装配,而不是在网关 `New` 里顺手订阅:
|
||||
//
|
||||
// - 回填要写的是**父会话的聊天历史**,而网关 New 拿到的 History 与组合根
|
||||
// `as.history` 是同一个 `*history.Store`,但它此刻还没被 hub 的广播出口包上 ——
|
||||
// 落库之后必须用**同一个** `message_persisted` 扇出(`Server.BroadcastPersisted`)
|
||||
// 才能让在线端与刷新/重连的端看到同一行;
|
||||
// - 与 hub 的分工:hub 只把主题归一成 `subagent_result` 帧(投递),回填订阅者只把
|
||||
// 结果写成父会话的一行系统注记(落库)。两者订阅**同一个主题**、互不相识,
|
||||
// 因此以后改帧不会牵连落库,改落库也不会牵连帧。
|
||||
//
|
||||
// 订阅用的 ctx 是 `context.Background()`:组合根这一步没有任何长生命周期 ctx 可用
|
||||
// (`newApp` 的 ctx 只用于构造期的库操作),而进程关停时 `app.close` 会
|
||||
// `a.bus.Close()` —— 它取消所有订阅的处理器 ctx,因此这里不需要第二个停止开关。
|
||||
// 这与 hub(`Server.Start` 里自建 ctx 并在 `Close` 取消)是同一取舍。
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway"
|
||||
)
|
||||
|
||||
// backfillWireLogSource 是这条接线自己的日志来源:订阅成功/失败要能按来源定位,
|
||||
// 而不是混在 `main` 或 `gateway` 的裸日志里(AGENTS §10 的日志来源铁律)。
|
||||
const backfillWireLogSource = "cmd/cyrene/wire_subagent_backfill"
|
||||
|
||||
// wireSubagentBackfill 把「后台子代理结果 → 父会话系统注记」的订阅者接到组合根的
|
||||
// 进程内总线上(与调度器、网关 hub **同一个** bus 实例)。
|
||||
//
|
||||
// 三条降级口径(都**必须可见**,绝不静默):
|
||||
//
|
||||
// - 没有 bus 或没有历史存储:这条回填此刻做不了(未接持久化的构建);
|
||||
// - 订阅失败:总线已关闭或主题非法 —— 那意味着后台结果永远不会回填;
|
||||
// - 广播出口缺失(网关为 nil):仍然落库(历史是唯一事实来源),只是不推帧。
|
||||
func wireSubagentBackfill(a *app, gw *gateway.Server) {
|
||||
log := logger.WithSource(a.log, backfillWireLogSource)
|
||||
if a.bus == nil || a.history == nil {
|
||||
log.Warn("subagent backfill not wired: needs both a bus and a history store",
|
||||
"bus", a.bus != nil, "history", a.history != nil)
|
||||
return
|
||||
}
|
||||
backfill, err := gateway.NewSubagentBackfill(gateway.SubagentBackfillDeps{
|
||||
Bus: a.bus,
|
||||
History: a.history,
|
||||
Broadcast: backfillSink(gw),
|
||||
Log: a.log,
|
||||
})
|
||||
if err != nil {
|
||||
log.Warn("subagent backfill not wired", "err", err)
|
||||
return
|
||||
}
|
||||
if err := backfill.Start(context.Background()); err != nil {
|
||||
log.Warn("subagent backfill subscribe failed; background results will not reach the parent session",
|
||||
"err", err)
|
||||
return
|
||||
}
|
||||
log.Info("subagent backfill wired",
|
||||
"subject", "subagent.result", "session_role", history.RoleSystem)
|
||||
}
|
||||
|
||||
// backfillSink 返回回填的下行出口。网关为 nil 时返回**真 nil 接口**(带类型的 nil
|
||||
// 指针会让订阅者里的 `!= nil` 判断失效,落库之后去调一个空接收者)。
|
||||
func backfillSink(gw *gateway.Server) gateway.SubagentNoteSink {
|
||||
if gw == nil {
|
||||
return nil
|
||||
}
|
||||
return gw
|
||||
}
|
||||
|
||||
// 编译期锁住这条链路的两端:网关仍是回填的下行出口,历史存储仍是它的落库面。
|
||||
// 任何一边改签名,这里先红,而不是等到运行期回填静默缺席。
|
||||
var (
|
||||
_ gateway.SubagentNoteSink = (*gateway.Server)(nil)
|
||||
_ gateway.SubagentNoteStore = (*history.Store)(nil)
|
||||
)
|
||||
@@ -0,0 +1,82 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration"
|
||||
)
|
||||
|
||||
// 本文件锁住 M32 的接线本身:**组合根真的把回填订阅者挂上了那条总线**。
|
||||
//
|
||||
// 为什么单独测这一步:这次缺陷的形状就是「所有零件都在、没有一根线」。回填订阅者
|
||||
// 写对了、总线也发得对,只要 `wireFrontend` 里少掉那一行调用,主人在界面上看到的
|
||||
// 仍然是「没有下文」—— 而且没有任何测试会红。因此这里断言的是**装配后的行为**:
|
||||
// 组合根接线 → 真 bus 上发布编排层载荷 → 父会话真的多出一条系统注记行。
|
||||
|
||||
// waitForBackfillRow 轮询历史,直到父会话出现一条消息或到期。
|
||||
func waitForBackfillRow(t *testing.T, st *history.Store, sessionID string) []history.Message {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
rows, err := st.Recent(context.Background(), sessionID, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("read recent: %v", err)
|
||||
}
|
||||
if len(rows) > 0 {
|
||||
return rows
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("the parent session never received the backfilled note: the composition root did not wire the subscriber")
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestWireSubagentBackfillPersistsThroughBus 是 ④ 的正面证据:装配 → 发布 → 落行。
|
||||
func TestWireSubagentBackfillPersistsThroughBus(t *testing.T) {
|
||||
b := bus.New()
|
||||
t.Cleanup(func() { _ = b.Close() })
|
||||
a := &app{bus: b, history: newTestHistory(t), log: testLog()}
|
||||
|
||||
wireSubagentBackfill(a, nil)
|
||||
|
||||
payload := &orchestration.SubagentResultEvent{
|
||||
SessionID: history.MainSessionID,
|
||||
SpawnResult: &orchestration.SpawnResult{
|
||||
Status: "done",
|
||||
Summary: "子代理汇总: 共 1 个, 1 成功",
|
||||
Agents: []orchestration.SpawnAgentResult{{
|
||||
ID: "sub-a1", Goal: "调研 X", Role: "cyrene", Status: "done", Summary: "结论:X 可行",
|
||||
}},
|
||||
},
|
||||
}
|
||||
if err := b.Publish(context.Background(), orchestration.SubagentResultSubject, payload); err != nil {
|
||||
t.Fatalf("publish: %v", err)
|
||||
}
|
||||
|
||||
rows := waitForBackfillRow(t, a.history, history.MainSessionID)
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("parent session has %d messages, want exactly the one system note: %+v", len(rows), rows)
|
||||
}
|
||||
row := rows[0]
|
||||
if row.Role != history.RoleSystem {
|
||||
t.Fatalf("backfilled row role = %q, want %q (a sub-agent conclusion is not her own words)", row.Role, history.RoleSystem)
|
||||
}
|
||||
if row.TurnID != "" || row.PersonaID != "" {
|
||||
t.Fatalf("backfilled row = %+v, want no turn_id and no persona_id", row)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWireSubagentBackfillWithoutHistoryWarns 锁住降级口径:没有历史存储时不装配、
|
||||
// 不 panic,而是留一条可见的告警(绝不假装回填成立)。
|
||||
func TestWireSubagentBackfillWithoutHistoryWarns(t *testing.T) {
|
||||
a := &app{bus: bus.New(), log: testLog()}
|
||||
wireSubagentBackfill(a, nil) // 没有 history:只允许告警
|
||||
if err := a.bus.Publish(context.Background(), orchestration.SubagentResultSubject,
|
||||
&orchestration.SubagentResultEvent{SessionID: history.MainSessionID}); err != nil {
|
||||
t.Fatalf("publish on an unwired root: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package main
|
||||
|
||||
// 本文件把**半云端 emotionTTS** 接成她的一级发音人(切片 M35),并保持三条红线:
|
||||
//
|
||||
// 1. **配置走面板文件** `$DATA_DIR/config/voice-tts.json`(零新 `.env` 键,design/13
|
||||
// §2.1)。文件缺失/坏掉 ⇒ 这一级**不启用**,既有语音链逐字不变。
|
||||
// 2. **文本优先**:合成失败只回落既有链 + 一条带来源的 WARN,绝不把"这条回复"
|
||||
// 判成失败(聊天正文早已发出/落库)。
|
||||
// 3. **不新建端点/帧族**:只换网关 `SetVoice` 的注入点,因此既有两条出站口
|
||||
// (`POST /api/v1/audio/speech` 与 WS `voice_output`)同时用上它。
|
||||
//
|
||||
// 为什么包一层而不是插进链里:既有链由 `multimodal/voice.New` 按 env 组装,各级
|
||||
// 列表不外露;`.env` 已冻结,不能为新能力加键。包装既不动那条链,也让"失败回落"
|
||||
// 发生在**合成入口**这一处,而不是散在两条出站口里。
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
|
||||
voicetts "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/tts"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway"
|
||||
)
|
||||
|
||||
// voiceTTSSource 是本切片日志的来源标签(AGENTS §10:日志必须标来源)。
|
||||
const voiceTTSSource = "cmd/cyrene/voice-tts"
|
||||
|
||||
// voiceForGateway 返回注入给网关的语音服务:emotionTTS 已按面板配置启用时是
|
||||
// 「emotionTTS + 既有链」的门面,否则**就是既有链本身**(行为逐字不变)。
|
||||
//
|
||||
// 它只在 `wireFrontend` 接线时调用一次;每次调用都重新读一次面板文件(启动期
|
||||
// 读一次的口径与 `backthink.json` 一致)。
|
||||
func (a *app) voiceForGateway() gateway.VoiceService {
|
||||
if a.voice == nil {
|
||||
return nil
|
||||
}
|
||||
cfg, enabled := a.voiceTTSConfig()
|
||||
if !enabled {
|
||||
return a.voice
|
||||
}
|
||||
client := voicetts.New(cfg, voicetts.WithLogger(logger.WithSource(a.log, voiceTTSSource)))
|
||||
service := voicetts.NewService(client, a.voice, voicetts.WithServiceLogger(logger.WithSource(a.log, voiceTTSSource)))
|
||||
logger.WithSource(a.log, voiceTTSSource).Info("emotionTTS voice enabled",
|
||||
"base_url", client.RedactedBaseURL(),
|
||||
"personas", len(cfg.Personas),
|
||||
"default_set", cfg.Default != "",
|
||||
"timeout_sec", int(cfg.Timeout/time.Second),
|
||||
"config", runtimeconfig.VoiceTTSConfigRelPath)
|
||||
return service
|
||||
}
|
||||
|
||||
// voiceTTSConfig 读面板配置并映射成 provider 配置。第二个返回值报告"这一级是否
|
||||
// 启用"——坏文件、缺文件、显式 false 都得出 false(并在坏文件时留一条 WARN)。
|
||||
//
|
||||
// 它**不**在这里做"缺省填什么"的裁决:默认值在 `voicetts.Config.Normalized` 一处。
|
||||
// 🔴 它也**不**给 personas/default 补任何角色:发音人只能由面板显式给出(全局定死
|
||||
// 一个 character 正是主人点名的病害)。
|
||||
func (a *app) voiceTTSConfig() (voicetts.Config, bool) {
|
||||
log := logger.WithSource(a.log, voiceTTSSource)
|
||||
path := runtimeconfig.DefaultVoiceTTSPath(resolveDataDir(a.cfg))
|
||||
tuning, err := runtimeconfig.LoadVoiceTTSTuning(path)
|
||||
if err != nil {
|
||||
// 坏文件 ⇒ **这一级不启用**:宁可没有它,也不要用一份读不懂的配置去驱动
|
||||
// 一台外部服务。既有链照旧服务(文本优先)。
|
||||
log.Warn("voice tts config unreadable, emotionTTS stays disabled",
|
||||
"path", runtimeconfig.VoiceTTSConfigRelPath, "err", err)
|
||||
return voicetts.Config{}, false
|
||||
}
|
||||
if !tuning.EnabledOn() {
|
||||
log.Debug("emotionTTS not enabled in panel config",
|
||||
"path", runtimeconfig.VoiceTTSConfigRelPath, "hint", "set {\"enabled\": true}")
|
||||
return voicetts.Config{}, false
|
||||
}
|
||||
if legacy := tuning.LegacyGlobalCharacter(); legacy != "" {
|
||||
// 旧版的全局 character:**忽略**,但绝不静默 —— 运维需要知道换成 personas。
|
||||
log.Warn("voice tts config: global `character` is deprecated and ignored",
|
||||
"character", legacy, "config", runtimeconfig.VoiceTTSConfigRelPath,
|
||||
"hint", "use {\"personas\": {\"<personaId>\": \"<characterId>\"}} so the voice follows the persona")
|
||||
}
|
||||
return voicetts.Config{
|
||||
Enabled: true,
|
||||
BaseURL: tuning.BaseURL,
|
||||
Personas: tuning.Personas,
|
||||
Default: tuning.Default,
|
||||
Timeout: time.Duration(tuning.TimeoutSec) * time.Second,
|
||||
}, true
|
||||
}
|
||||
|
||||
// voiceFallbackChain 声明 a.voice 满足 emotionTTS 门面要的底链面(编译期断言)。
|
||||
// 它把"既有链少了一个方法"这种改动的代价,从**运行期 panic** 提前到**编译失败**。
|
||||
var _ voicetts.Chain = (*voice.Chain)(nil)
|
||||
@@ -0,0 +1,127 @@
|
||||
package main
|
||||
|
||||
// 本文件锁定切片 M35 在**组合根**上的三条口径(都是"改坏就静默不工作/静默开错"的那种):
|
||||
//
|
||||
// 1. 面板文件缺失 ⇒ **这一级不启用**,返回的仍是既有链本身(行为逐字不变);
|
||||
// 2. 文件里 `enabled:true` ⇒ 返回 emotionTTS 门面,`personas`/`default`/`timeoutSec`
|
||||
// 按文件来(发音人只能来自 persona 映射;已废弃的全局 `character` 不生效);
|
||||
// 3. 文件坏掉 ⇒ **不启用**(宁可没有这一级,也不拿读不懂的配置去驱动外部服务)。
|
||||
//
|
||||
// 它用 `DefaultVoiceTTSPath` 的真实落点($DATA_DIR/config/voice-tts.json)跑,
|
||||
// 因此也钉住"零新 .env 键":没有任何一处读环境变量。
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
|
||||
voicetts "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/tts"
|
||||
)
|
||||
|
||||
// voiceTTSApp 造一个只够本题用的 app:数据目录 + 一条既有链。
|
||||
func voiceTTSApp(t *testing.T) *app {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
return &app{
|
||||
cfg: &config.Config{DataDir: dir},
|
||||
log: tidyTestLog(),
|
||||
voice: mvoice.New(config.VoiceConfig{}),
|
||||
}
|
||||
}
|
||||
|
||||
// TestVoiceForGateway_DisabledByDefault 钉住"没配过 ⇒ 原样用既有链"。
|
||||
func TestVoiceForGateway_DisabledByDefault(t *testing.T) {
|
||||
a := voiceTTSApp(t)
|
||||
got := a.voiceForGateway()
|
||||
if got != any(a.voice) {
|
||||
t.Fatalf("没配过时不该包装既有链: %T", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVoiceForGateway_WrapsWhenEnabled 钉住"文件启用 ⇒ 门面生效 + 配置逐项读对"。
|
||||
//
|
||||
// 🔴 它同时钉住主人 2026-10-26 那条硬要求在**组合根**上的形态:配置里读出来的是
|
||||
// `personas` 映射与 `default`,**没有**任何"全局一个 character"。
|
||||
func TestVoiceForGateway_WrapsWhenEnabled(t *testing.T) {
|
||||
a := voiceTTSApp(t)
|
||||
writeVoiceTTSPanel(t, a, `{"enabled":true,"baseUrl":"http://127.0.0.1:9880",
|
||||
"personas":{"cyrene":"char_84fb88a3","furina":"char_5314489e"},
|
||||
"default":"char_84fb88a3","format":"wav","timeoutSec":45}`)
|
||||
|
||||
cfg, enabled := a.voiceTTSConfig()
|
||||
if !enabled {
|
||||
t.Fatal("面板写了 enabled:true,但组合根报未启用")
|
||||
}
|
||||
if len(cfg.Personas) != 2 || cfg.Personas["furina"] != "char_5314489e" {
|
||||
t.Fatalf("personas 没有逐项读出: %+v", cfg.Personas)
|
||||
}
|
||||
if cfg.Default != "char_84fb88a3" {
|
||||
t.Fatalf("default = %q, want char_84fb88a3", cfg.Default)
|
||||
}
|
||||
|
||||
got := a.voiceForGateway()
|
||||
if _, ok := got.(*voicetts.Service); !ok {
|
||||
t.Fatalf("启用后应返回 *tts.Service, got %T", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVoiceForGateway_LegacyCharacterIgnored 钉住已废弃的全局 character **不生效**:
|
||||
// 只写旧键时,映射与 default 都是空的 ⇒ 任何请求都选不到发音人(不会悄悄回退到
|
||||
// 那个全局角色,因为那正是主人担心的病害)。
|
||||
func TestVoiceForGateway_LegacyCharacterIgnored(t *testing.T) {
|
||||
a := voiceTTSApp(t)
|
||||
writeVoiceTTSPanel(t, a, `{"enabled":true,"character":"char_84fb88a3"}`)
|
||||
|
||||
cfg, enabled := a.voiceTTSConfig()
|
||||
if !enabled {
|
||||
t.Fatal("enabled:true 应让这一级启用")
|
||||
}
|
||||
if len(cfg.Personas) != 0 || cfg.Default != "" {
|
||||
t.Fatalf("废弃的全局 character 被当成了发音人: personas=%v default=%q", cfg.Personas, cfg.Default)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVoiceForGateway_BadFileStaysDisabled 钉住"坏文件 ⇒ 不启用"(绝不半开)。
|
||||
func TestVoiceForGateway_BadFileStaysDisabled(t *testing.T) {
|
||||
a := voiceTTSApp(t)
|
||||
writeVoiceTTSPanel(t, a, `{"enabled":`)
|
||||
|
||||
got := a.voiceForGateway()
|
||||
if _, ok := got.(*voicetts.Service); ok {
|
||||
t.Fatal("坏文件却启用了 emotionTTS(会拿读不懂的配置去调外部服务)")
|
||||
}
|
||||
if got != any(a.voice) {
|
||||
t.Fatalf("坏文件时应原样用既有链, got %T", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVoiceTTSConfig_NoEnvKeys 钉住"零新 .env 键":把相关环境变量设成垃圾值,
|
||||
// 配置解析的结果只由面板文件决定(证明它根本没读环境)。
|
||||
func TestVoiceTTSConfig_NoEnvKeys(t *testing.T) {
|
||||
a := voiceTTSApp(t)
|
||||
t.Setenv("VOICE_TTS_PROVIDER", "emotiontts")
|
||||
t.Setenv("EMOTIONTTS_BASE_URL", "http://evil.invalid")
|
||||
writeVoiceTTSPanel(t, a, `{"enabled":true}`)
|
||||
|
||||
cfg, enabled := a.voiceTTSConfig()
|
||||
if !enabled {
|
||||
t.Fatal("面板启用却没启用")
|
||||
}
|
||||
if cfg.BaseURL != "" {
|
||||
t.Errorf("baseURL = %q,want 空(默认值由 Normalized 补,不读环境)", cfg.BaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
// writeVoiceTTSPanel 把内容写进**真实落点**($DATA_DIR/config/voice-tts.json)。
|
||||
func writeVoiceTTSPanel(t *testing.T, a *app, body string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(a.cfg.DataDir, "config", "voice-tts.json")
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatalf("write panel: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,27 @@
|
||||
// the Go package can //go:embed it into the single Cyrene binary. It runs as the
|
||||
// last step of `npm run build`. The source dist is the canonical build output,
|
||||
// so it also serves as the external deploy-override directory.
|
||||
import { cp, rm } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
//
|
||||
// The destination is wiped first so assets from an older build cannot linger in
|
||||
// the binary. internal/webui/dist/.gitkeep is committed because //go:embed
|
||||
// all:dist fails to compile when the directory holds no files at all — that is
|
||||
// what makes `go build ./...` work in a clean checkout. The wipe may clear build
|
||||
// output but never the placeholder, so it is restored on every run, including a
|
||||
// failed copy: a bare placeholder still compiles, a missing one breaks the build.
|
||||
import { cp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const src = resolve(here, "dist");
|
||||
const dest = resolve(here, "..", "..", "internal", "webui", "dist");
|
||||
const placeholder = ".gitkeep";
|
||||
|
||||
await rm(dest, { recursive: true, force: true });
|
||||
await cp(src, dest, { recursive: true });
|
||||
try {
|
||||
await cp(src, dest, { recursive: true });
|
||||
} finally {
|
||||
await mkdir(dest, { recursive: true });
|
||||
await writeFile(join(dest, placeholder), "");
|
||||
}
|
||||
console.log(`webui: copied ${src} -> ${dest}`);
|
||||
@@ -9,6 +9,7 @@ import { Button, Surface, Tag } from "../../components/primitives";
|
||||
import { MemoryTidyFactsCard } from "./ConfigFactsCards";
|
||||
import { MemoryTidyDryRunCard } from "./MemoryTidyDryRunCard";
|
||||
import { MemoryTidyApplyCard } from "./MemoryTidyApplyCard";
|
||||
import { MemoryTidyTuningCard } from "./MemoryTidyTuningCard";
|
||||
|
||||
const scopeTone: Record<string, "ok" | "warn" | "accent" | "neutral"> = {
|
||||
user: "accent",
|
||||
@@ -191,6 +192,8 @@ export function MemoriesModule(): React.ReactNode {
|
||||
|
||||
<MemoryTidyApplyCard />
|
||||
|
||||
<MemoryTidyTuningCard />
|
||||
|
||||
<MemoryTidyFactsCard />
|
||||
|
||||
<Notice>
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
MemoryTidyDryRunBody,
|
||||
TIDY_DRY_RUN_DISCLAIMER,
|
||||
TIDY_DRY_RUN_ERROR_HINT,
|
||||
TIDY_RECALL_UNAVAILABLE,
|
||||
TIDY_RECALL_UNAVAILABLE_HINT,
|
||||
type TidyDryRunState,
|
||||
} from "./MemoryTidyDryRunCard";
|
||||
|
||||
@@ -96,7 +98,7 @@ ok(has(doneHtml, "0.950"), "必须显示分数(区间)");
|
||||
ok(has(doneHtml, "mem-short") && has(doneHtml, "保留 mem-long"), "必须显示「哪条 → 保留哪条」");
|
||||
ok(has(doneHtml, "作息偏晚"), "必须显示合并后的存活条目原文(diff 预览)");
|
||||
ok(has(doneHtml, "扫描 736") && has(doneHtml, "用时 42 ms"), "必须显示扫描口径与耗时");
|
||||
ok(has(doneHtml, "召回来源:embedding"), "必须显示召回来源(lexical 是降级信号)");
|
||||
ok(has(doneHtml, "召回来源:语义向量(embedding)"), "必须显示召回来源(lexical 是降级信号)");
|
||||
ok(has(doneHtml, "scope=1"), "必须显示否决分布(它解释「为什么没有更多建议」)");
|
||||
|
||||
// ── 截断:必须说明「这一轮看到的比库里少 + 该调哪个键」 ─────────────────────
|
||||
@@ -124,6 +126,42 @@ ok(!has(errorHtml, "扫描 "), "错误态绝不得渲染观测口径(扫描/
|
||||
ok(!has(errorHtml, "这一轮没有产生任何候选"), "错误态不得复用空态文案(两者含义不同)");
|
||||
ok(has(errorHtml, "把失败画成「0 条」"), "错误态反而要解释「为什么不显示 0 条」");
|
||||
|
||||
|
||||
// ── ④ 召回来源(契约 v0.91):四种取值**各有各的话**,空串绝不是空标签 ────────
|
||||
// 起因是 M6 的诊断(DevDocs/audit/去重失效诊断-2026-10-13.md):旧实现把「全量
|
||||
// 降级」静默报成 embedding,面板据此判读会把「向量不可用」读成「向量正常」。
|
||||
// 因此这个字段的**全部价值**就是让人看得出这一轮到底走了哪条路。
|
||||
const recallCases: Array<[string, string]> = [
|
||||
["embedding", "语义向量"],
|
||||
["lexical", "已降级"],
|
||||
["mixed", "部分分区降级"],
|
||||
["", "无候选可比"],
|
||||
["unregistered_mode", "未登记取值"],
|
||||
];
|
||||
for (const [mode, want] of recallCases) {
|
||||
const html = render({ kind: "done", result: { ...RESULT, recallMode: mode } });
|
||||
ok(has(html, want), `recallMode=${JSON.stringify(mode)} 必须渲染成可读中文(含「${want}」)`);
|
||||
}
|
||||
// 空串绝不许渲染成「召回来源:」后面空白(那是本次要修的那个缺陷)。
|
||||
const noCandidateHtml = render({ kind: "done", result: { ...RESULT, recallMode: "", candidates: [], pairs: 0 } });
|
||||
ok(has(noCandidateHtml, "无候选可比"), "空串 = 本轮没做召回,必须说出来");
|
||||
ok(!has(noCandidateHtml, "召回来源:<"), "空串绝不能渲染成空标签(后面什么都没有)");
|
||||
ok(!has(noCandidateHtml, "召回来源:</"), "空串绝不能渲染成空标签(连标签体都空)");
|
||||
ok(has(noCandidateHtml, "未做召回"), "空串要解释「这一轮压根没算过相似度」,不能含糊成「—」");
|
||||
|
||||
// ── ⑤ 「尝试过召回但向量源不可用」:与上面两种都不同,走**错误**路径 ─────────
|
||||
// 试算的严格口径下这种失败不回响应体(前端只能从 error.code 认出来),因此这里
|
||||
// 必须单独给一句说明 —— 否则主人对着一句「试算失败」分不清是没候选还是向量挂了。
|
||||
const recallDownHtml = render({
|
||||
kind: "error",
|
||||
message: "语义召回(embedding)不可用,试算未产出候选(试算不做静默降级)",
|
||||
code: TIDY_RECALL_UNAVAILABLE,
|
||||
});
|
||||
ok(has(recallDownHtml, TIDY_RECALL_UNAVAILABLE_HINT), "向量源不可用时要给专门的说明");
|
||||
ok(has(recallDownHtml, "不等于"), "必须与「没有候选可比」显式区分");
|
||||
ok(has(recallDownHtml, "没有降级到词项"), "必须说清它也没有降级到词项余弦");
|
||||
ok(!has(errorHtml, TIDY_RECALL_UNAVAILABLE_HINT), "别的错误不该套用这条说明(不许乱归因)");
|
||||
|
||||
// ── 未跑 / 在跑 ────────────────────────────────────────────────────────────
|
||||
const idleHtml = render({ kind: "idle" });
|
||||
ok(has(idleHtml, "试算整理建议"), "未跑时必须给出按钮文案");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { adminMemoryTidyDryRun } from "../../lib/api";
|
||||
import { ApiError, adminMemoryTidyDryRun } from "../../lib/api";
|
||||
import type { AdminTidyDryRunCandidate, AdminTidyDryRunResult } from "../../lib/types";
|
||||
import { Button, Surface, Tag } from "../../components/primitives";
|
||||
import { Notice } from "../components";
|
||||
@@ -38,7 +38,18 @@ export type TidyDryRunState =
|
||||
| { kind: "idle" }
|
||||
| { kind: "running" }
|
||||
| { kind: "done"; result: AdminTidyDryRunResult }
|
||||
| { kind: "error"; message: string };
|
||||
| { kind: "error"; message: string; code?: string };
|
||||
|
||||
/** TIDY_RECALL_UNAVAILABLE 是「这一轮**尝试过**向量召回但向量源不可用」的错误码。
|
||||
*
|
||||
* 它与「没有候选可比」(成功响应 + `recallMode:""`)和「降级到词项」(`lexical`)
|
||||
* 是三件不同的事,必须各有各的话。试算的严格口径下这种失败**不回响应体**,因此它
|
||||
* 只走错误路径 —— 面板在这里把它说出来,而不是让主人对着一句「试算失败」猜。 */
|
||||
export const TIDY_RECALL_UNAVAILABLE = "tidy_recall_unavailable";
|
||||
|
||||
/** TIDY_RECALL_UNAVAILABLE_HINT 是上面那个错误码的补充说明。 */
|
||||
export const TIDY_RECALL_UNAVAILABLE_HINT =
|
||||
"这一轮尝试过向量召回、但向量源不可用(试算不做静默降级):它既不等于「没有候选可比」,也没有降级到词项余弦。请检查 embedding 服务后重试。";
|
||||
|
||||
const actionLabel: Record<string, string> = {
|
||||
merge: "并入",
|
||||
@@ -112,6 +123,50 @@ function CandidateRow({ candidate }: { candidate: AdminTidyDryRunCandidate }): R
|
||||
);
|
||||
}
|
||||
|
||||
/** RecallSourceTag 渲染「这一轮到底走的是向量还是词项」(契约 v0.91 的 `recallMode`)。
|
||||
*
|
||||
* 它是 M6 诊断(`DevDocs/audit/去重失效诊断-2026-10-13.md`)的落点:旧实现按「有没
|
||||
* 有配 embedder」写这个字段,于是**全量降级被静默报成 `embedding`**,面板据此判读会
|
||||
* 把「向量不可用」读成「向量正常工作」。取值域与读法(三种态**必须分得清**):
|
||||
*
|
||||
* - `embedding` —— 语义向量召回真的用上了(好态);
|
||||
* - `lexical` —— 全部走零依赖词项余弦(进程没接 embedder ⇒ 降级,命中会更少);
|
||||
* - `mixed` —— **部分分区降级**、其余走向量(v0.91 新增);
|
||||
* - `""` —— **没有候选可比**:这一轮压根没算过相似度(不是降级,也不该显示成
|
||||
* 一个空标签 —— 那会让人以为字段坏了)。
|
||||
*
|
||||
* ⚠️ 「尝试过召回但向量源不可用」**不在这里**:试算的严格口径下那种情况直接报错
|
||||
* (`error.code = tidy_recall_unavailable`),响应体根本不会出现;线上也没有
|
||||
* `recall_unavailable_partitions` 这个键(它只存在于后端日志行)。因此本组件不许
|
||||
* 用「—」把空串含糊掉 —— 每个取值都有一句可读的中文。 */
|
||||
export function recallSourceLabel(mode: string): string {
|
||||
switch (mode) {
|
||||
case "embedding":
|
||||
return "召回来源:语义向量(embedding)";
|
||||
case "lexical":
|
||||
return "召回来源:词项余弦(未接向量,已降级)";
|
||||
case "mixed":
|
||||
return "召回来源:部分分区降级(其余走向量)";
|
||||
case "":
|
||||
return "召回来源:无候选可比(本轮未做召回)";
|
||||
default:
|
||||
return `召回来源:${mode}(未登记取值)`;
|
||||
}
|
||||
}
|
||||
|
||||
/** recallSourceTone 给上面那句话配色调:好态 accent、降级 warn、无候选 neutral。 */
|
||||
export function recallSourceTone(mode: string): "accent" | "warn" | "neutral" {
|
||||
switch (mode) {
|
||||
case "embedding":
|
||||
return "accent";
|
||||
case "lexical":
|
||||
case "mixed":
|
||||
return "warn";
|
||||
default:
|
||||
return "neutral";
|
||||
}
|
||||
}
|
||||
|
||||
/** ResultBody 渲染一次试算的完整结果(有候选 / 空 / 观测口径)。 */
|
||||
function ResultBody({ result }: { result: AdminTidyDryRunResult }): React.ReactNode {
|
||||
const vetoReasons = result.vetoReasons ?? {};
|
||||
@@ -119,7 +174,7 @@ function ResultBody({ result }: { result: AdminTidyDryRunResult }): React.ReactN
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Tag tone="ok">算完了,一行都没写</Tag>
|
||||
<Tag tone={result.recallMode === "embedding" ? "accent" : "warn"}>召回来源:{result.recallMode}</Tag>
|
||||
<Tag tone={recallSourceTone(result.recallMode)}>{recallSourceLabel(result.recallMode)}</Tag>
|
||||
<span className="text-[11px] text-[var(--mu-text-lo)]">
|
||||
扫描 {result.scanned} / 比对 {result.compared} / 召回 {result.pairs} 对 / 否决 {result.vetoed} 对 · 用时{" "}
|
||||
{result.durationMs} ms · 规则 {result.ruleVersion} · 分区 {result.partitions}
|
||||
@@ -174,6 +229,9 @@ function StateBody({ state }: { state: TidyDryRunState }): React.ReactNode {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Notice tone="err">试算失败:{state.message}</Notice>
|
||||
{state.code === TIDY_RECALL_UNAVAILABLE ? (
|
||||
<span className="text-[11px] text-[var(--mu-text-mid)]">{TIDY_RECALL_UNAVAILABLE_HINT}</span>
|
||||
) : null}
|
||||
<span className="text-[11px] text-[var(--mu-text-lo)]">
|
||||
{TIDY_DRY_RUN_ERROR_HINT}。这里刻意不显示候选数 —— 把失败画成「0 条」就是把「没算出来」冒充成结论。
|
||||
</span>
|
||||
@@ -223,7 +281,11 @@ export function MemoryTidyDryRunCard(): React.ReactNode {
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
log("memory.tidy.dryRun", "error");
|
||||
setState({ kind: "error", message: e instanceof Error ? e.message : "试算失败" });
|
||||
setState({
|
||||
kind: "error",
|
||||
message: e instanceof Error ? e.message : "试算失败",
|
||||
code: e instanceof ApiError ? e.code : undefined,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
// 记忆整理「调优」卡的**真实渲染**测试(切片 MEMPANEL / M4)。
|
||||
//
|
||||
// 它钉住三件**文案与呈现**的事(逻辑那一半见 memoryTidyTuning.test.ts):
|
||||
// ① 7 个键都有中文标签 + 各自的兜底说明(「留空 ⇒ 跟随谁」);
|
||||
// ② 热生效与否**如实**:`hot:false` 必须写「重启后端后生效」,绝不写成「已生效」;
|
||||
// ③ 坏值**就地报错**并拦住保存(后端那道 400 仍在,但不让主人白跑)。
|
||||
//
|
||||
// 跑法(与 MemoryTidyDryRunCard.render.test.ts 同):
|
||||
//
|
||||
// cd frontend/web \
|
||||
// && ./node_modules/.bin/esbuild src/admin/modules/MemoryTidyTuningCard.render.test.ts \
|
||||
// --bundle --platform=node --format=cjs \
|
||||
// --outfile=/tmp/cyrene-memory-tidy-tuning.render.test.cjs \
|
||||
// && node /tmp/cyrene-memory-tidy-tuning.render.test.cjs
|
||||
//
|
||||
// `tsc --noEmit` 会对本文件做类型检查,因此它只从项目内导入、不 import node:*。
|
||||
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import type { AdminMemoryTidyTuningView } from "../../lib/api/adminMemories";
|
||||
import {
|
||||
MemoryTidyTuningBody,
|
||||
TIDY_TUNING_COLD_LABEL,
|
||||
TIDY_TUNING_DISCLAIMER,
|
||||
TIDY_TUNING_HOT_LABEL,
|
||||
} from "./MemoryTidyTuningCard";
|
||||
import { draftFromView, TUNING_LABELS } from "./memoryTidyTuning";
|
||||
|
||||
let checks = 0;
|
||||
|
||||
function ok(cond: boolean, what: string): void {
|
||||
checks += 1;
|
||||
if (!cond) throw new Error(`FAILED: ${what}`);
|
||||
}
|
||||
|
||||
function has(haystack: string, needle: string): boolean {
|
||||
return haystack.includes(needle);
|
||||
}
|
||||
|
||||
/** fixtureView 造一份 GET 响应形状的数据(含全部 7 个键的规格)。 */
|
||||
function fixtureView(overrides: Partial<AdminMemoryTidyTuningView> = {}): AdminMemoryTidyTuningView {
|
||||
return {
|
||||
path: "config/memory-tidy.json",
|
||||
present: true,
|
||||
hot: false,
|
||||
hotError: "运行期没有接线可热生效的实现(deps.MemoryTidy / deps.Memories);新值已落盘,重启后端后生效",
|
||||
specs: [
|
||||
{ key: "intervalSec", kind: "int", unit: "sec", min: 1, fallback: "env:MEMORY_TIDY_INTERVAL_SEC" },
|
||||
{ key: "quietIntervalSec", kind: "int", unit: "sec", min: 1, fallback: "panel:intervalSec" },
|
||||
{ key: "quietStartHour", kind: "int", unit: "hour", min: 0, max: 23, fallback: "env:MEMORY_TIDY_QUIET_START_HOUR" },
|
||||
{ key: "quietEndHour", kind: "int", unit: "hour", min: 0, max: 23, fallback: "env:MEMORY_TIDY_QUIET_END_HOUR" },
|
||||
{ key: "ingestGate", kind: "bool", fallback: "code-default" },
|
||||
{ key: "autoApply", kind: "bool", fallback: "code-default" },
|
||||
{ key: "tempTtlSec", kind: "int", unit: "sec", min: 1, fallback: "code-default" },
|
||||
],
|
||||
values: { intervalSec: 10800, quietStartHour: 23, quietEndHour: 7, ingestGate: true, autoApply: true },
|
||||
envValues: { intervalSec: 86400, quietStartHour: 23, quietEndHour: 7 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderBody(view: AdminMemoryTidyTuningView, draft: Record<string, string>, errors: Record<string, string> = {}, extra: Record<string, unknown> = {}): string {
|
||||
return renderToStaticMarkup(
|
||||
createElement(MemoryTidyTuningBody, {
|
||||
view,
|
||||
draft,
|
||||
errors,
|
||||
busy: false,
|
||||
receipt: null,
|
||||
savedView: null,
|
||||
onDraftChange: () => {},
|
||||
onSave: () => {},
|
||||
...extra,
|
||||
} as never)
|
||||
);
|
||||
}
|
||||
|
||||
const view = fixtureView();
|
||||
const html = renderBody(view, draftFromView(view), {}, { receipt: "已保存:平时整理间隔。", savedView: { ...view, hot: true } });
|
||||
|
||||
// ── ① 7 个键:中文标签 + 键名 + 兜底说明 ───────────────────────────────────
|
||||
for (const key of Object.keys(TUNING_LABELS)) {
|
||||
ok(has(html, TUNING_LABELS[key]), `必须渲染 ${key} 的中文标签`);
|
||||
ok(has(html, key), `必须显示键名 ${key}(后端契约的键,面板不改名)`);
|
||||
}
|
||||
ok(has(html, TIDY_TUNING_DISCLAIMER), "恒显示「面板配置文件优先,.env 只做兜底」");
|
||||
ok(has(html, "config/memory-tidy.json"), "必须显示面板配置文件的落点");
|
||||
ok(has(html, "跟随 .env 的 MEMORY_TIDY_INTERVAL_SEC"), "缺位的键要写清「留空 ⇒ 跟随谁」(env 档)");
|
||||
ok(has(html, "当前 86400"), "env 档的当前生效值要如实带出来");
|
||||
ok(has(html, "跟随面板里的 intervalSec"), "quietIntervalSec 的下一档是面板内的 intervalSec");
|
||||
ok(has(html, "跟随代码默认"), "ingestGate/autoApply/tempTtlSec 的下一档是代码默认");
|
||||
ok(has(html, "≈ 3 小时"), "秒数要给可读提示(10800 ⇒ 3 小时)");
|
||||
|
||||
// ── ② 热生效与否:两种口径必须分得清 ───────────────────────────────────────
|
||||
ok(has(html, TIDY_TUNING_HOT_LABEL), "hot:true 时必须说「已热生效(不用重启后端)」");
|
||||
ok(has(html, "保存后回显"), "保存后必须回显**从磁盘重新读回**的真值");
|
||||
ok(has(html, "intervalSec = 10800"), "回显要逐键给出真值");
|
||||
|
||||
const coldHtml = renderBody(view, draftFromView(view), {}, {
|
||||
receipt: "已保存:平时整理间隔。",
|
||||
savedView: view, // hot: false
|
||||
});
|
||||
ok(has(coldHtml, TIDY_TUNING_COLD_LABEL), "hot:false 时必须说「已保存到面板配置文件,重启后端后生效」");
|
||||
ok(!has(coldHtml, TIDY_TUNING_HOT_LABEL), "hot:false 时绝不许出现「已热生效」");
|
||||
ok(has(coldHtml, "运行期没有接线可热生效的实现"), "hot:false 要带出后端给的 hotError 原因");
|
||||
|
||||
// ── ③ 坏值就地报错并拦住保存 ───────────────────────────────────────────────
|
||||
const badHtml = renderBody(view, { ...draftFromView(view), intervalSec: "0" }, {
|
||||
intervalSec: "平时整理间隔 必须 ≥ 1 秒",
|
||||
});
|
||||
ok(has(badHtml, "必须 ≥ 1 秒"), "坏值要就地显示错误文案");
|
||||
ok(has(badHtml, "有坏值,未提交"), "有坏值时必须明说「未提交」(盘上不会被动)");
|
||||
ok(has(badHtml, "disabled"), "有坏值时保存按钮必须禁用");
|
||||
ok(has(badHtml, "清空一个键 = 从面板文件里删掉它"), "要解释「清空 ≠ 写 0」这条三态口径");
|
||||
ok(has(badHtml, "T1 判定"), "要说明同文件其它旋钮原样保留(这张卡只写 7 个键)");
|
||||
|
||||
// 缺位下的盘上值:没配 ⇒ 面板显示「未写 ⇒ 跟随兜底」,不编一个 0。
|
||||
const missing = fixtureView({ values: {} });
|
||||
const missingHtml = renderBody(missing, draftFromView(missing));
|
||||
ok(has(missingHtml, "留空 ⇒ 跟随 .env 的 MEMORY_TIDY_INTERVAL_SEC"), "没配的键要显示「留空 ⇒ 跟随下一档」");
|
||||
ok(!has(missingHtml, "intervalSec = 0"), "没配的键绝不能显示成 0(那是「写了 0」的坏值形态)");
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`MemoryTidyTuningCard.render: ${checks} checks passed`);
|
||||
@@ -0,0 +1,273 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
adminMemoryTidyTuning,
|
||||
adminMemoryTidyTuningSave,
|
||||
type AdminMemoryTidyTuningView,
|
||||
} from "../../lib/api/adminMemories";
|
||||
import { Button, Surface, Tag } from "../../components/primitives";
|
||||
import { Notice } from "../components";
|
||||
import { useAuditStore } from "../audit";
|
||||
import type { TuningDraft } from "./memoryTidyTuning";
|
||||
import {
|
||||
TUNING_LABELS,
|
||||
buildTuningPatch,
|
||||
draftFromView,
|
||||
fallbackLabel,
|
||||
patchSummary,
|
||||
secondsHint,
|
||||
unitLabel,
|
||||
} from "./memoryTidyTuning";
|
||||
|
||||
/**
|
||||
* 记忆整理「调优」卡(切片 MEMPANEL / M4)。真源:
|
||||
* internal/services/gateway/admin_memory_tuning.go。
|
||||
*
|
||||
* 它回答的是主人的两句原话:「`.env` 只做兜底用 …… 后端自动创建独立的面板配置文件
|
||||
* 之后就走面板配置文件,这些键优先从面板配置上取」与「频率必须调,可以调到 1 小时
|
||||
* 或者三小时,或者动态分配,比如白天间隔短一点、晚上静默期稍微长一点」。
|
||||
*
|
||||
* 三条口径落在文案里,因为它们是这张卡的产品承诺:
|
||||
* 1. **面板优先、`.env` 兜底**:留空 = 不写这个键 ⇒ 回落 `.env` → 代码默认。
|
||||
* 每个键都写清「跟随谁」,不做含糊的「未设置」;
|
||||
* 2. **坏值就地报错**:校验规则来自后端的 specs(类型/单位/上下界),非法草稿
|
||||
* **不发请求**(后端那道 400 仍在,只是不让主人白跑);
|
||||
* 3. **热生效与否如实说**:后端回 `hot:false` 时写「重启后端后生效」,绝不写成
|
||||
* 「已生效」。
|
||||
*
|
||||
* 呈现与取数分层:`MemoryTidyTuningBody` 只吃状态(渲染测试直接喂 fixture),卡组件
|
||||
* 负责取数 + 表单草稿 + 保存。
|
||||
*/
|
||||
|
||||
/** 这张卡的核心承诺(渲染测试钉住它必须出现)。 */
|
||||
export const TIDY_TUNING_DISCLAIMER = "面板配置文件优先,.env 只做兜底";
|
||||
|
||||
/** 热生效与「要重启」两种文案(测试分别钉住)。 */
|
||||
export const TIDY_TUNING_HOT_LABEL = "已热生效(不用重启后端)";
|
||||
export const TIDY_TUNING_COLD_LABEL = "已保存到面板配置文件,重启后端后生效";
|
||||
|
||||
/** BodyProps 是展示半边的输入(全部状态由外面给,便于直接渲染断言)。 */
|
||||
export interface TuningBodyProps {
|
||||
view: AdminMemoryTidyTuningView;
|
||||
draft: TuningDraft;
|
||||
errors: Record<string, string>;
|
||||
busy: boolean;
|
||||
/** 保存成功的回执文案(含热生效与否);null = 还没保存过。 */
|
||||
receipt: string | null;
|
||||
/** 保存后回显的真值(后端**重新读盘**的结果)。 */
|
||||
savedView: AdminMemoryTidyTuningView | null;
|
||||
onDraftChange: (key: string, value: string) => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
/** hotLabel 返回「这次到底生没生效」的诚实文案。 */
|
||||
export function hotLabel(view: AdminMemoryTidyTuningView): string {
|
||||
return view.hot ? TIDY_TUNING_HOT_LABEL : TIDY_TUNING_COLD_LABEL;
|
||||
}
|
||||
|
||||
/** fallbackHint 返回「留空时跟随谁 + 兜底那一档当前是多少」(有就报数)。 */
|
||||
function fallbackHint(view: AdminMemoryTidyTuningView, key: string, fallback: string): string {
|
||||
const envValue = view.envValues?.[key];
|
||||
const base = fallbackLabel({ key, kind: "int", fallback });
|
||||
return envValue === undefined ? base : `${base}(当前 ${envValue})`;
|
||||
}
|
||||
|
||||
/** FieldRow 渲染一个键:中文标签 + 控件 + 兜底说明 + 就地错误。 */
|
||||
function FieldRow({
|
||||
spec,
|
||||
value,
|
||||
error,
|
||||
fallback,
|
||||
valueHint,
|
||||
onChange,
|
||||
}: {
|
||||
spec: AdminMemoryTidyTuningView["specs"][number];
|
||||
value: string;
|
||||
error: string;
|
||||
fallback: string;
|
||||
valueHint: string;
|
||||
onChange: (next: string) => void;
|
||||
}): React.ReactNode {
|
||||
const label = TUNING_LABELS[spec.key] ?? spec.key;
|
||||
const inputCls =
|
||||
"w-40 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]";
|
||||
return (
|
||||
<div className="space-y-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)]/60 px-3 py-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="min-w-56 text-xs text-[var(--mu-text-hi)]">{label}</span>
|
||||
{spec.kind === "bool" ? (
|
||||
<select
|
||||
value={value}
|
||||
className="h-9 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
>
|
||||
<option value="">跟随兜底</option>
|
||||
<option value="true">开</option>
|
||||
<option value="false">关</option>
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
value={value}
|
||||
inputMode="numeric"
|
||||
placeholder="留空 = 跟随兜底"
|
||||
className={inputCls}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
{valueHint ? <span className="text-[11px] text-[var(--mu-text-lo)]">{valueHint}</span> : null}
|
||||
<span className="font-mono text-[11px] text-[var(--mu-text-lo)]">{spec.key}</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-[var(--mu-text-lo)]">
|
||||
{value.trim() === "" ? `留空 ⇒ ${fallback}` : `写进面板配置文件(覆盖 ${fallback})`}
|
||||
{spec.kind === "int" && unitLabel(spec) ? ` · 单位:${unitLabel(spec)}` : ""}
|
||||
</div>
|
||||
{error ? <div className="text-[11px] text-[var(--mu-err)]">⚠ {error}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** MemoryTidyTuningBody 是展示半边:吃状态、不取数(渲染测试直接喂它)。 */
|
||||
export function MemoryTidyTuningBody({
|
||||
view,
|
||||
draft,
|
||||
errors,
|
||||
busy,
|
||||
receipt,
|
||||
savedView,
|
||||
onDraftChange,
|
||||
onSave,
|
||||
}: TuningBodyProps): React.ReactNode {
|
||||
const hasErrors = Object.keys(errors).length > 0;
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Tag tone="accent">{TIDY_TUNING_DISCLAIMER}</Tag>
|
||||
<span className="font-mono text-[11px] text-[var(--mu-text-lo)]">{view.path}</span>
|
||||
<span className="text-[11px] text-[var(--mu-text-lo)]">
|
||||
{view.present ? "面板配置已存在" : "面板配置还没创建(后端下次启动会自动创建)"}
|
||||
</span>
|
||||
</div>
|
||||
{view.specs.map((spec) => (
|
||||
<FieldRow
|
||||
key={spec.key}
|
||||
spec={spec}
|
||||
value={draft[spec.key] ?? ""}
|
||||
error={errors[spec.key] ?? ""}
|
||||
fallback={fallbackHint(view, spec.key, spec.fallback)}
|
||||
valueHint={spec.unit === "sec" && /^\d+$/.test((draft[spec.key] ?? "").trim())
|
||||
? secondsHint(Number((draft[spec.key] ?? "").trim()))
|
||||
: ""}
|
||||
onChange={(next) => onDraftChange(spec.key, next)}
|
||||
/>
|
||||
))}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="primary" disabled={busy || hasErrors} onClick={onSave}>
|
||||
{busy ? "保存中…" : "保存调优"}
|
||||
</Button>
|
||||
{hasErrors ? (
|
||||
<span className="text-[11px] text-[var(--mu-text-lo)]">有坏值,未提交(后端一个字都不会改)</span>
|
||||
) : null}
|
||||
</div>
|
||||
{receipt ? <Notice>{receipt}</Notice> : null}
|
||||
{savedView ? (
|
||||
<div className="space-y-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-[11px]">
|
||||
<span className="text-[var(--mu-text-mid)]">保存后回显(从磁盘重新读回的真值):</span>
|
||||
{savedView.specs.map((spec) => {
|
||||
const v = savedView.values[spec.key];
|
||||
return (
|
||||
<div key={spec.key} className="font-mono text-[var(--mu-text-lo)]">
|
||||
{spec.key} = {v === undefined ? "(未写 ⇒ 跟随兜底)" : String(v)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="text-[var(--mu-text-mid)]">{hotLabel(savedView)}</div>
|
||||
{savedView.hot ? null : (
|
||||
<div className="text-[var(--mu-text-lo)]">{savedView.hotError ?? "运行期未接线可热生效的实现"}</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
<p className="text-[11px] text-[var(--mu-text-lo)]">
|
||||
只写这张卡的 7 个键;文件里其它旋钮(T1 判定 / T1 限速 / 每轮自动落地上限)原样保留。
|
||||
清空一个键 = 从面板文件里删掉它(回到下一档兜底),不是写 0。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** MemoryTidyTuningCard 负责取数、表单草稿与保存。 */
|
||||
export function MemoryTidyTuningCard(): React.ReactNode {
|
||||
const [view, setView] = useState<AdminMemoryTidyTuningView | null>(null);
|
||||
const [draft, setDraft] = useState<TuningDraft>({});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [fail, setFail] = useState<string | null>(null);
|
||||
const [receipt, setReceipt] = useState<string | null>(null);
|
||||
const [savedView, setSavedView] = useState<AdminMemoryTidyTuningView | null>(null);
|
||||
const log = useAuditStore((s) => s.log);
|
||||
|
||||
const reload = useCallback(() => {
|
||||
setFail(null);
|
||||
return adminMemoryTidyTuning()
|
||||
.then((next) => {
|
||||
setView(next);
|
||||
setDraft(draftFromView(next));
|
||||
})
|
||||
.catch((e: unknown) => setFail(e instanceof Error ? e.message : "读取面板配置失败"));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, [reload]);
|
||||
|
||||
const onDraftChange = useCallback((key: string, value: string) => {
|
||||
setDraft((prev) => ({ ...prev, [key]: value }));
|
||||
setReceipt(null);
|
||||
}, []);
|
||||
|
||||
const onSave = useCallback(() => {
|
||||
if (!view) return;
|
||||
const check = buildTuningPatch(view, draft);
|
||||
setErrors(check.errors);
|
||||
if (Object.keys(check.errors).length > 0) return;
|
||||
setBusy(true);
|
||||
setFail(null);
|
||||
adminMemoryTidyTuningSave(check.patch)
|
||||
.then((next) => {
|
||||
// 响应里的值是后端**重新读盘**的结果:据此重铺草稿(回显真值)。
|
||||
setView(next);
|
||||
setDraft(draftFromView(next));
|
||||
setSavedView(next);
|
||||
setErrors({});
|
||||
setReceipt(`已保存:${patchSummary(check.patch)}。${hotLabel(next)}`);
|
||||
log("memory.tidy.tuning", "ok");
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
setFail(e instanceof Error ? e.message : "保存面板配置失败");
|
||||
log("memory.tidy.tuning", "error");
|
||||
})
|
||||
.finally(() => setBusy(false));
|
||||
}, [view, draft, log]);
|
||||
|
||||
return (
|
||||
<Surface className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-sm font-semibold">记忆整理调优(面板配置)</h3>
|
||||
<Tag tone="neutral">GET / PUT /api/v1/admin/memories/tidy/tuning</Tag>
|
||||
</div>
|
||||
{fail ? <Notice tone="err">{fail}</Notice> : null}
|
||||
{view === null ? (
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">{fail ? "读取失败" : "读取中…"}</span>
|
||||
) : (
|
||||
<MemoryTidyTuningBody
|
||||
view={view}
|
||||
draft={draft}
|
||||
errors={errors}
|
||||
busy={busy}
|
||||
receipt={receipt}
|
||||
savedView={savedView}
|
||||
onDraftChange={onDraftChange}
|
||||
onSave={onSave}
|
||||
/>
|
||||
)}
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import type { AdminChannelRecord, EnumItem, OwnerAccount } from "../../lib/types
|
||||
import { Button } from "../../components/primitives";
|
||||
import {
|
||||
accountCandidates,
|
||||
flattenAccounts,
|
||||
draftAccounts,
|
||||
groupAccounts,
|
||||
platformIsConfigured,
|
||||
platformOptions,
|
||||
@@ -13,11 +13,16 @@ import {
|
||||
/**
|
||||
* 渠道账号白名单编辑器(主人 2026-10-05 要求):**一个平台一条**,该平台下用
|
||||
* 「+ 添加账号 ID」加多行;平台是权威枚举**下拉**;账号 ID 旁给「从该平台已配置
|
||||
* 账号里选」的候选。改动经 `onChange` 回到草稿时**立即展平**为
|
||||
* 账号里选」的候选。改动经 `onChange` 回到草稿时**写回扁平数组**
|
||||
* `accounts:[{platform,accountId}…]` —— 后端契约与 `owner.yaml` 格式完全不变。
|
||||
*
|
||||
* 为什么不在这里直接保存:档案的读取/草稿/保存状态由 `Users.tsx` 的
|
||||
* `OwnerProfileSection` 统一持有(一次真实 PUT),本组件只管呈现与展平。
|
||||
* `OwnerProfileSection` 统一持有(一次真实 PUT),本组件只管呈现与写回草稿。
|
||||
*
|
||||
* 为什么拆成两个函数:`OwnerAccountsEditor` 只留「分组/选项」这层 memo(同一表单里改
|
||||
* 别的字段时不必重算),界面本体 `OwnerAccountsView` 是**无 hook** 的 —— 本项目没有
|
||||
* jsdom,测试正是**直接调用**它、点它自己的按钮来钉住「手势 → 草稿 → 重渲染」这条往返
|
||||
* (见 `ownerAccounts.test.ts` ⑤)。往 View 里加 hook 会让那条测试报 "Invalid hook call"。
|
||||
*/
|
||||
export function OwnerAccountsEditor({
|
||||
accounts,
|
||||
@@ -39,10 +44,39 @@ export function OwnerAccountsEditor({
|
||||
() => platformOptions(platforms, groups.map((g) => g.platform)),
|
||||
[platforms, groups]
|
||||
);
|
||||
return (
|
||||
<OwnerAccountsView
|
||||
groups={groups}
|
||||
options={options}
|
||||
channels={channels}
|
||||
disabled={disabled}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 每次改动都「分组 → 展平 → 回草稿」:草稿里始终是后端要的扁平形态,
|
||||
// 界面上的分组只是它的投影,两者不会各说各话。
|
||||
const emit = (next: OwnerAccountGroup[]): void => onChange(flattenAccounts(next));
|
||||
export interface OwnerAccountsViewProps {
|
||||
/** 界面上的分组(草稿的投影);手势产生的下一份**草稿**也由它算出来。 */
|
||||
groups: OwnerAccountGroup[];
|
||||
options: EnumItem[];
|
||||
channels: AdminChannelRecord[] | null;
|
||||
disabled: boolean;
|
||||
/** 新草稿(扁平数组)。 */
|
||||
onChange: (next: OwnerAccount[]) => void;
|
||||
}
|
||||
|
||||
/** 界面本体(**无 hook**,见文件头):所有手势都走这里 → `onChange(新草稿)`。 */
|
||||
export function OwnerAccountsView({
|
||||
groups,
|
||||
options,
|
||||
channels,
|
||||
disabled,
|
||||
onChange,
|
||||
}: OwnerAccountsViewProps): React.ReactNode {
|
||||
// 每次改动都「分组 → 改 → 回草稿」:草稿里始终是扁平形态,界面上的分组只是它的投影,
|
||||
// 两者不会各说各话。写回必须**无损**(`draftAccounts`):一旦写成有损的写盘形态,刚点出来的
|
||||
// 「+ 添加账号 ID」空行当场蒸发 —— 点了等于没点。空行只在保存时丢(见 `accountsForSave`)。
|
||||
const emit = (next: OwnerAccountGroup[]): void => onChange(draftAccounts(next));
|
||||
|
||||
const patchGroup = (i: number, patch: Partial<OwnerAccountGroup>): void =>
|
||||
emit(groups.map((g, idx) => (idx === i ? { ...g, ...patch } : g)));
|
||||
@@ -156,6 +190,7 @@ function PlatformGroupRow(props: PlatformGroupRowProps): React.ReactNode {
|
||||
{group.accountIds.map((accountId, j) => (
|
||||
<AccountIdRow
|
||||
key={`acct-${index}-${j}`}
|
||||
row={j}
|
||||
listId={`acct-${index}-${j}-candidates`}
|
||||
accountId={accountId}
|
||||
candidates={candidates}
|
||||
@@ -227,6 +262,8 @@ function PlatformPicker({
|
||||
|
||||
interface AccountIdRowProps {
|
||||
listId: string;
|
||||
/** 在本分组里的行号(`0` 起):给行一个稳定的定位标记,浏览器里/测试里都指得到具体那一行。 */
|
||||
row: number;
|
||||
accountId: string;
|
||||
candidates: string[];
|
||||
platform: string;
|
||||
@@ -239,6 +276,7 @@ interface AccountIdRowProps {
|
||||
/** 一行账号 ID:手填 + 该平台已配置账号的候选(点一下就填入)。 */
|
||||
function AccountIdRow({
|
||||
listId,
|
||||
row,
|
||||
accountId,
|
||||
candidates,
|
||||
platform,
|
||||
@@ -248,7 +286,7 @@ function AccountIdRow({
|
||||
onRemove,
|
||||
}: AccountIdRowProps): React.ReactNode {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-1" data-account-row={row}>
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<label className="block min-w-56 flex-1 space-y-1">
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">账号 ID</span>
|
||||
|
||||
@@ -7,6 +7,12 @@ import { useAuditStore } from "../audit";
|
||||
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
|
||||
import { Button, Surface, Switch, Tag } from "../../components/primitives";
|
||||
import { InstalledPluginsPanel } from "./plugins/InstalledPlugins";
|
||||
import {
|
||||
mcpServerEntries,
|
||||
originGroupTitle,
|
||||
otherCapabilityEntries,
|
||||
toolOriginBuckets,
|
||||
} from "./pluginOrigin";
|
||||
|
||||
type CapKind = "tool" | "skill" | "channel";
|
||||
|
||||
@@ -16,6 +22,9 @@ const KIND_LABELS: Record<CapKind, string> = {
|
||||
channel: "渠道 Channel",
|
||||
};
|
||||
|
||||
/** 指向「MCP 服务器」页的提示文案:server 级动作(启停/重连/卸载)都在那边。 */
|
||||
const MCP_PAGE_HINT = "在「MCP 服务器」页卸载该 server";
|
||||
|
||||
// Channel adapters are not gated by the tool policy (group/stranger markers are a
|
||||
// tool concept; a channel is configured via its own 渠道管理 page), so the
|
||||
// channel block shows identity + a pointer rather than the policy switches.
|
||||
@@ -27,7 +36,44 @@ function channelColumns(): AdminColumn<AdminPluginRecord>[] {
|
||||
];
|
||||
}
|
||||
|
||||
/** Columns for a tool / skill row (has environment markers, owner can edit). */
|
||||
/** The environment-marker switches, shared by tool and skill rows.
|
||||
*
|
||||
* 它对**工具名**生效(`tool_policies` 以 name 为键),因此来自 MCP server 的工具有同
|
||||
* 一套开关;`mcp:<server>` 那条 KindMCP 条目拿不到开关(策略判定从不读它),所以在
|
||||
* MCP 登记段里不给开关 —— 假开关比没有开关更糟。 */
|
||||
function policyColumn(
|
||||
isOwner: boolean,
|
||||
busy: string | null,
|
||||
setPolicy: (name: string, allowInGroup: boolean, allowInStranger: boolean) => void,
|
||||
): AdminColumn<AdminPluginRecord> {
|
||||
return {
|
||||
key: "policy",
|
||||
title: "环境授权(owner)",
|
||||
render: (p) => (
|
||||
<span className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-1.5">
|
||||
<Switch
|
||||
checked={p.allowInGroup}
|
||||
disabled={!isOwner || busy === p.name}
|
||||
onChange={(v) => setPolicy(p.name, v, p.allowInStranger)}
|
||||
/>
|
||||
<span className="text-xs text-[var(--mu-text-mid)]">群聊</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5">
|
||||
<Switch
|
||||
checked={p.allowInStranger}
|
||||
disabled={!isOwner || busy === p.name}
|
||||
onChange={(v) => setPolicy(p.name, p.allowInGroup, v)}
|
||||
/>
|
||||
<span className="text-xs text-[var(--mu-text-mid)]">陌生人</span>
|
||||
</label>
|
||||
{busy === p.name ? <span className="text-xs text-[var(--mu-text-lo)]">保存中…</span> : null}
|
||||
</span>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Columns for a host-builtin tool / a skill row (uninstall = remove this capability). */
|
||||
function registeredColumns(
|
||||
isOwner: boolean,
|
||||
busy: string | null,
|
||||
@@ -38,31 +84,7 @@ function registeredColumns(
|
||||
{ key: "name", title: "名称", render: (p) => <span className="font-medium">{p.name}</span> },
|
||||
{ key: "kind", title: "类型", render: (p) => <Tag tone="accent">{(KIND_LABELS as Record<string, string>)[p.kind] ?? p.kind}</Tag> },
|
||||
{ key: "desc", title: "描述", render: (p) => <span className="block max-w-sm truncate text-[var(--mu-text-mid)]">{p.description || "—"}</span> },
|
||||
{
|
||||
key: "policy",
|
||||
title: "环境授权(owner)",
|
||||
render: (p) => (
|
||||
<span className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-1.5">
|
||||
<Switch
|
||||
checked={p.allowInGroup}
|
||||
disabled={!isOwner || busy === p.name}
|
||||
onChange={(v) => setPolicy(p.name, v, p.allowInStranger)}
|
||||
/>
|
||||
<span className="text-xs text-[var(--mu-text-mid)]">群聊</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5">
|
||||
<Switch
|
||||
checked={p.allowInStranger}
|
||||
disabled={!isOwner || busy === p.name}
|
||||
onChange={(v) => setPolicy(p.name, p.allowInGroup, v)}
|
||||
/>
|
||||
<span className="text-xs text-[var(--mu-text-mid)]">陌生人</span>
|
||||
</label>
|
||||
{busy === p.name ? <span className="text-xs text-[var(--mu-text-lo)]">保存中…</span> : null}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
policyColumn(isOwner, busy, setPolicy),
|
||||
{
|
||||
key: "actions",
|
||||
title: "操作",
|
||||
@@ -75,6 +97,61 @@ function registeredColumns(
|
||||
];
|
||||
}
|
||||
|
||||
/** Columns for a tool that belongs to an MCP server.
|
||||
*
|
||||
* 🔴 行内**没有**「卸载」:单把 MCP 工具不是独立安装单元 —— 卸载它只能绕过插件管理器
|
||||
* 裸删注册表条目,而该 server 的 names 账本不同步(重连/refresh 后工具复活,用户以为
|
||||
* 卸掉了)。服务端同样拒绝这条路径(`plugin_uninstall_scope`),这里给出该去哪里做。 */
|
||||
function mcpToolColumns(
|
||||
isOwner: boolean,
|
||||
busy: string | null,
|
||||
setPolicy: (name: string, allowInGroup: boolean, allowInStranger: boolean) => void,
|
||||
): AdminColumn<AdminPluginRecord>[] {
|
||||
return [
|
||||
{ key: "name", title: "名称", render: (p) => <span className="font-medium">{p.name}</span> },
|
||||
{
|
||||
key: "origin",
|
||||
title: "来源",
|
||||
render: (p) => <Tag tone="neutral" title="注册时由后端盖章的上游归属,不从工具名推断">{p.origin?.server || "未知 server"}</Tag>,
|
||||
},
|
||||
{ key: "desc", title: "描述", render: (p) => <span className="block max-w-sm truncate text-[var(--mu-text-mid)]">{p.description || "—"}</span> },
|
||||
policyColumn(isOwner, busy, setPolicy),
|
||||
{
|
||||
key: "actions",
|
||||
title: "操作",
|
||||
render: () => <span className="text-xs text-[var(--mu-text-lo)]">{MCP_PAGE_HINT}</span>,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** Columns for a `kind:"mcp"` entry: one registration row per server, server-scoped. */
|
||||
function mcpServerColumns(): AdminColumn<AdminPluginRecord>[] {
|
||||
return [
|
||||
{ key: "name", title: "登记键", render: (p) => <span className="font-mono text-xs font-medium">{p.name}</span> },
|
||||
{ key: "desc", title: "描述", render: (p) => <span className="block max-w-md truncate text-[var(--mu-text-mid)]">{p.description || "—"}</span> },
|
||||
{
|
||||
key: "admin",
|
||||
title: "管理",
|
||||
render: () => (
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">
|
||||
启停 / 重连 / 卸载 / 安装见「MCP 服务器」页;本行是注册表登记条目,不承载单独的工具策略
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** Columns for capability kinds the page does not otherwise model (e.g. iot).
|
||||
*
|
||||
* design/09 §5.2.1 红线③:已装但本页不认识的条目要**如实显示**,不得静默过滤。 */
|
||||
function otherColumns(): AdminColumn<AdminPluginRecord>[] {
|
||||
return [
|
||||
{ key: "name", title: "名称", render: (p) => <span className="font-medium">{p.name}</span> },
|
||||
{ key: "kind", title: "类型", render: (p) => <Tag tone="neutral">{p.kind}</Tag> },
|
||||
{ key: "desc", title: "描述", render: (p) => <span className="block max-w-md truncate text-[var(--mu-text-mid)]">{p.description || "—"}</span> },
|
||||
];
|
||||
}
|
||||
|
||||
export function PluginsModule(): React.ReactNode {
|
||||
const { data, loading, error, reload } = useAsyncData(adminListPlugins, []);
|
||||
useAutoReload(reload, 10000);
|
||||
@@ -125,14 +202,22 @@ export function PluginsModule(): React.ReactNode {
|
||||
return groups;
|
||||
}, [data]);
|
||||
|
||||
const toolSkillColumns = registeredColumns(isOwner, busy, setPolicy, uninstall);
|
||||
// 工具段的两级分节:先按上游 server,再剩「宿主内置」。
|
||||
const toolBuckets = useMemo(() => toolOriginBuckets(byKind.tool), [byKind.tool]);
|
||||
const mcpServers = useMemo(() => mcpServerEntries(data ?? []), [data]);
|
||||
const others = useMemo(() => otherCapabilityEntries(data ?? []), [data]);
|
||||
|
||||
const toolCols = registeredColumns(isOwner, busy, setPolicy, uninstall);
|
||||
const mcpToolCols = mcpToolColumns(isOwner, busy, setPolicy);
|
||||
const channelCols = channelColumns();
|
||||
const serverCols = mcpServerColumns();
|
||||
const otherCols = otherColumns();
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<PageHeader
|
||||
title="插件与能力"
|
||||
subtitle="已注册能力(按工具 / 技能 / 渠道分组)+ 运行时 MCP 安装/卸载 · POST /api/v1/admin/plugins/policy (owner-only)"
|
||||
subtitle="已注册能力(工具按上游 MCP server 分节 / 技能 / 渠道)+ 运行时 MCP 安装 · POST /api/v1/admin/plugins/policy (owner-only)"
|
||||
/>
|
||||
|
||||
{!isOwner ? <Notice tone="warn">当前角色为 {getStoredRole()},工具策略/插件安装仅 owner 可写(读取/查看可见)。</Notice> : null}
|
||||
@@ -144,28 +229,66 @@ export function PluginsModule(): React.ReactNode {
|
||||
<Surface className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-sm font-semibold">已注册能力</h3>
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">GET /api/v1/admin/plugins · 按工具 / 技能 / 渠道分组</span>
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">GET /api/v1/admin/plugins · 工具按上游 server 分节</span>
|
||||
</div>
|
||||
|
||||
{(["tool", "skill"] as CapKind[]).map((kind) => {
|
||||
const rows = byKind[kind];
|
||||
return (
|
||||
<div key={kind} className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Tag tone="accent">{KIND_LABELS[kind]}</Tag>
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">{rows.length} 个</span>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Tag tone="accent">{KIND_LABELS.tool}</Tag>
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">
|
||||
{byKind.tool.length} 个 · {toolBuckets.mcp.length} 台 MCP server + 宿主内置
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{toolBuckets.mcp.map((group) => (
|
||||
<div key={group.key} className="space-y-1.5">
|
||||
<div className="flex flex-wrap items-center gap-2 pt-1">
|
||||
<Tag tone="neutral" title="来源由后端在注册时盖章,不从工具名解析">
|
||||
{originGroupTitle(group)}
|
||||
</Tag>
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">server 级动作见「MCP 服务器」页</span>
|
||||
</div>
|
||||
<AdminTable
|
||||
columns={toolSkillColumns}
|
||||
rows={loading ? null : rows}
|
||||
columns={mcpToolCols}
|
||||
rows={loading ? null : group.rows}
|
||||
loading={loading}
|
||||
error={error}
|
||||
emptyTitle={`无${KIND_LABELS[kind]}`}
|
||||
rowKey={(p) => `${kind}:${p.name}`}
|
||||
emptyTitle="无工具"
|
||||
rowKey={(p) => `mcp:${group.key}:${p.name}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex flex-wrap items-center gap-2 pt-1">
|
||||
<Tag tone="neutral">宿主内置工具</Tag>
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">{toolBuckets.builtin.length} 把</span>
|
||||
</div>
|
||||
<AdminTable
|
||||
columns={toolCols}
|
||||
rows={loading ? null : toolBuckets.builtin}
|
||||
loading={loading}
|
||||
error={error}
|
||||
emptyTitle="无宿主内置工具"
|
||||
rowKey={(p) => `tool:${p.name}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Tag tone="accent">{KIND_LABELS.skill}</Tag>
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">{byKind.skill.length} 个</span>
|
||||
</div>
|
||||
<AdminTable
|
||||
columns={toolCols}
|
||||
rows={loading ? null : byKind.skill}
|
||||
loading={loading}
|
||||
error={error}
|
||||
emptyTitle={`无${KIND_LABELS.skill}`}
|
||||
rowKey={(p) => `skill:${p.name}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
@@ -181,9 +304,48 @@ export function PluginsModule(): React.ReactNode {
|
||||
rowKey={(p) => `channel:${p.name}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 🔴 `kind:"mcp"` 条目**必须如实显示**(design/09 §5.2.1 红线③):它是每台
|
||||
server 的注册登记,不是可调用能力,因此这里给事实 + 指向,不给策略开关
|
||||
(`tool_policies` 只按工具名判定,对这条永远是惰性的 = 假开关)。 */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Tag tone="neutral">MCP server 登记</Tag>
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">{mcpServers.length} 台</span>
|
||||
</div>
|
||||
<AdminTable
|
||||
columns={serverCols}
|
||||
rows={loading ? null : mcpServers}
|
||||
loading={loading}
|
||||
error={error}
|
||||
emptyTitle="无 MCP server 登记(可在「MCP 服务器」页添加)"
|
||||
rowKey={(p) => `mcpentry:${p.name}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{others.length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Tag tone="neutral">其它能力</Tag>
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">{others.length} 个 · 本页暂无专用视图,如实列出</span>
|
||||
</div>
|
||||
<AdminTable
|
||||
columns={otherCols}
|
||||
rows={others}
|
||||
loading={loading}
|
||||
error={error}
|
||||
emptyTitle="无其它能力"
|
||||
rowKey={(p) => `other:${p.name}`}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</Surface>
|
||||
|
||||
<Notice>工具 schema / 技能为后端 M10+ 提供;此页按类型分组展示已注册能力。群聊/陌生人环境可见性标记(默认关闭)仅对工具/技能生效。</Notice>
|
||||
<Notice>
|
||||
工具按「注册时」的来源分节(来自哪台 MCP server 由后端盖章,不解析工具名);这是「呈现」分组,权限仍由
|
||||
tool_policies 的逐工具环境授权决定。MCP server 的启停/重连/卸载/安装都在「MCP 服务器」页 ——
|
||||
单把 MCP 工具不能单独卸载(否则 server 侧的工具账本会不同步)。
|
||||
</Notice>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
import { useAuditStore } from "../audit";
|
||||
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
|
||||
import { OwnerAccountsEditor } from "./OwnerAccountsEditor";
|
||||
import { accountsForSave } from "./ownerAccounts";
|
||||
import { Button, Surface, Tag } from "../../components/primitives";
|
||||
|
||||
const roleTone: Record<string, "accent" | "ok" | "neutral" | "warn"> = {
|
||||
@@ -166,7 +167,9 @@ function OwnerProfileSection({ isOwner }: { isOwner: boolean }): React.ReactNode
|
||||
setBusy(true);
|
||||
setFail(null);
|
||||
setNotice(null);
|
||||
adminSaveOwnerProfile(profile)
|
||||
// 草稿里允许有还没填完的空行(编辑期不能丢行),写盘前才收成写盘形态:只留平台与账号 ID
|
||||
// 都非空的条目,并去掉两侧空白。既不把空行写进 owner.yaml,也不会因为半成品条目被后端 400。
|
||||
adminSaveOwnerProfile({ ...profile, accounts: accountsForSave(profile.accounts) })
|
||||
.then((saved) => {
|
||||
log("owner.profile.edit", "ok");
|
||||
setProfile(saved);
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
/**
|
||||
* 渠道「静默窗口合并」卡(M23)。真源:internal/services/gateway/admin_coalesce.go。
|
||||
*
|
||||
* 它回答的是主人的原话「要让群聊聊天很自然,很流畅的做法」:群里「一鼓作气打三句」
|
||||
* 不该让她回三次 —— 她在群里等一小段静默,把这几句**合并成一轮**回答(批内每条仍是
|
||||
* 独立的一条消息,绝不拼成一条)。
|
||||
*
|
||||
* 三条口径落在文案里,因为它们是这张卡的产品承诺:
|
||||
* 1. **主人私聊没有这个窗口**:她的私聊一条一条立刻回(`ownerPrivate:"never"` 是后端
|
||||
* 下发的只读事实,面板**不给**开关 —— 那条车道由身份元数据决定,配置翻不开);
|
||||
* 2. **越界不拒绝、会夹紧**:规则随读下发(`rules`),面板就地校验,后端仍会 clamp
|
||||
* 并留 WARN —— 不让主人白跑,也不假装「你配的数就是生效的数」;
|
||||
* 3. **保存即热生效**:桥每次发车前重读同一份配置,所以 `hot:true` 是事实。
|
||||
*
|
||||
* 呈现与取数分层:`CoalesceCardBody` 只吃状态(渲染测试直接喂 fixture),
|
||||
* `ChannelCoalesceCard` 负责取数 + 表单草稿 + 保存。
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { request } from "../../../lib/api/core";
|
||||
import { Button, Surface, Tag } from "../../../components/primitives";
|
||||
import { Notice } from "../../components";
|
||||
import { useAuditStore } from "../../audit";
|
||||
|
||||
/** 一组窗口参数(后端 AdminCoalescePolicy 的同形投影)。 */
|
||||
export interface CoalescePolicyView {
|
||||
enabled: boolean;
|
||||
windowMs: number;
|
||||
maxWaitMs: number;
|
||||
maxBatchMessages: number;
|
||||
applyToStrangers: boolean;
|
||||
strangerWindowMs: number;
|
||||
}
|
||||
|
||||
/** 取值规则(后端 AdminCoalesceRules)。 */
|
||||
export interface CoalesceRules {
|
||||
windowMs: [number, number];
|
||||
maxWaitMs: [number, number];
|
||||
maxBatchMessages: [number, number];
|
||||
clamp: string;
|
||||
flushReasons: string[];
|
||||
}
|
||||
|
||||
/** GET/PUT 的 data 形状(后端 AdminCoalesceView)。 */
|
||||
export interface CoalesceView {
|
||||
path: string;
|
||||
present: boolean;
|
||||
hot: boolean;
|
||||
default: CoalescePolicyView;
|
||||
patch: Record<string, unknown>;
|
||||
conversations: Record<string, CoalescePolicyView>;
|
||||
rules: CoalesceRules;
|
||||
ownerPrivate: string;
|
||||
}
|
||||
|
||||
/** 表单草稿:一律字符串(输入框的原样值),保存时才转数字。 */
|
||||
export interface CoalesceDraft {
|
||||
enabled: string;
|
||||
windowMs: string;
|
||||
maxWaitMs: string;
|
||||
maxBatchMessages: string;
|
||||
applyToStrangers: string;
|
||||
strangerWindowMs: string;
|
||||
}
|
||||
|
||||
/** 只读事实的文案(渲染测试钉住它必须出现)。 */
|
||||
export const OWNER_PRIVATE_NOTE =
|
||||
"主人私聊不受此设置影响:她的私聊没有静默窗口,一条一条立刻回(由身份元数据决定,面板没有开关)";
|
||||
|
||||
/** 热生效文案。 */
|
||||
export const COALESCE_HOT_LABEL = "已热生效(下一次发车就用新值,不用重启后端)";
|
||||
|
||||
/** 字段顺序与中文标签(表单项由它驱动,避免手写六段重复代码)。 */
|
||||
export const COALESCE_FIELDS: ReadonlyArray<{
|
||||
key: keyof CoalesceDraft;
|
||||
label: string;
|
||||
kind: "bool" | "int";
|
||||
hint: string;
|
||||
}> = [
|
||||
{ key: "enabled", label: "启用群聊静默窗口", kind: "bool", hint: "关掉 = 恢复到「到达即回、一条一轮」" },
|
||||
{ key: "windowMs", label: "静默窗口(毫秒)", kind: "int", hint: "新消息会重置计时(等她把话说完)" },
|
||||
{ key: "maxWaitMs", label: "绝对上限(毫秒)", kind: "int", hint: "从这一批第一条算起,到点必发车" },
|
||||
{ key: "maxBatchMessages", label: "单批上限(条)", kind: "int", hint: "超出不是丢弃,是多出来的排成下一批" },
|
||||
{ key: "applyToStrangers", label: "对陌生人私聊生效", kind: "bool", hint: "关掉 = 陌生人私聊逐条回" },
|
||||
{ key: "strangerWindowMs", label: "陌生人私聊窗口(毫秒)", kind: "int", hint: "仅在「对陌生人私聊生效」时使用" },
|
||||
];
|
||||
|
||||
/** draftFromView 把生效值铺成草稿。 */
|
||||
export function draftFromView(view: CoalesceView): CoalesceDraft {
|
||||
return {
|
||||
enabled: String(view.default.enabled),
|
||||
windowMs: String(view.default.windowMs),
|
||||
maxWaitMs: String(view.default.maxWaitMs),
|
||||
maxBatchMessages: String(view.default.maxBatchMessages),
|
||||
applyToStrangers: String(view.default.applyToStrangers),
|
||||
strangerWindowMs: String(view.default.strangerWindowMs),
|
||||
};
|
||||
}
|
||||
|
||||
/** parseCoalescePatch 把草稿按**三态**转成补丁:只有与当前生效值不同的键才写。 */
|
||||
export function parseCoalescePatch(
|
||||
view: CoalesceView,
|
||||
draft: CoalesceDraft
|
||||
): { patch: { default: Record<string, number | boolean> }; errors: Record<string, string>; changed: string[] } {
|
||||
const errors: Record<string, string> = {};
|
||||
const out: Record<string, number | boolean> = {};
|
||||
const changed: string[] = [];
|
||||
const intIn = (key: string, value: string, lo: number, hi: number): number | null => {
|
||||
const trimmed = value.trim();
|
||||
if (!/^\d+$/.test(trimmed)) {
|
||||
errors[key] = "要填整数(毫秒/条)";
|
||||
return null;
|
||||
}
|
||||
const n = Number(trimmed);
|
||||
if (n < lo || n > hi) {
|
||||
errors[key] = `取值范围 ${lo}–${hi}`;
|
||||
return null;
|
||||
}
|
||||
return n;
|
||||
};
|
||||
const window = intIn("windowMs", draft.windowMs, view.rules.windowMs[0], view.rules.windowMs[1]);
|
||||
const maxWait = window === null
|
||||
? null
|
||||
: intIn("maxWaitMs", draft.maxWaitMs, Math.max(view.rules.maxWaitMs[0], window), view.rules.maxWaitMs[1]);
|
||||
const batch = intIn("maxBatchMessages", draft.maxBatchMessages, view.rules.maxBatchMessages[0], view.rules.maxBatchMessages[1]);
|
||||
const strangerWindow = intIn(
|
||||
"strangerWindowMs",
|
||||
draft.strangerWindowMs,
|
||||
view.rules.windowMs[0],
|
||||
view.rules.windowMs[1]
|
||||
);
|
||||
const enabled = draft.enabled === "true";
|
||||
const applyToStrangers = draft.applyToStrangers === "true";
|
||||
const put = (key: string, next: number | boolean, current: number | boolean): void => {
|
||||
if (next !== current) {
|
||||
out[key] = next;
|
||||
changed.push(key);
|
||||
}
|
||||
};
|
||||
if (window !== null) put("windowMs", window, view.default.windowMs);
|
||||
if (maxWait !== null) put("maxWaitMs", maxWait, view.default.maxWaitMs);
|
||||
if (batch !== null) put("maxBatchMessages", batch, view.default.maxBatchMessages);
|
||||
if (strangerWindow !== null) put("strangerWindowMs", strangerWindow, view.default.strangerWindowMs);
|
||||
put("enabled", enabled, view.default.enabled);
|
||||
put("applyToStrangers", applyToStrangers, view.default.applyToStrangers);
|
||||
return { patch: { default: out }, errors, changed };
|
||||
}
|
||||
|
||||
/** fetchCoalesce 读一次配置(面板配置面,writeAuth = owner|admin)。 */
|
||||
export function fetchCoalesce(): Promise<CoalesceView> {
|
||||
return request<CoalesceView>("/admin/channels/coalesce");
|
||||
}
|
||||
|
||||
/** saveCoalesce 提交三态补丁(返回**重新读盘**后的真值)。 */
|
||||
export function saveCoalesce(patch: { default: Record<string, number | boolean> }): Promise<CoalesceView> {
|
||||
return request<CoalesceView>("/admin/channels/coalesce", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
}
|
||||
|
||||
/** BodyProps 是展示半边的输入(全部状态由外面给,便于直接渲染断言)。 */
|
||||
export interface CoalesceBodyProps {
|
||||
view: CoalesceView;
|
||||
draft: CoalesceDraft;
|
||||
errors: Record<string, string>;
|
||||
busy: boolean;
|
||||
receipt: string | null;
|
||||
savedView: CoalesceView | null;
|
||||
onDraftChange: (key: keyof CoalesceDraft, value: string) => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
/** CoalesceCardBody 是展示半边:吃状态、不取数(渲染测试直接喂它)。 */
|
||||
export function CoalesceCardBody({
|
||||
view,
|
||||
draft,
|
||||
errors,
|
||||
busy,
|
||||
receipt,
|
||||
savedView,
|
||||
onDraftChange,
|
||||
onSave,
|
||||
}: CoalesceBodyProps): React.ReactNode {
|
||||
const hasErrors = Object.keys(errors).length > 0;
|
||||
const inputCls =
|
||||
"w-36 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]";
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Tag tone="accent">{COALESCE_HOT_LABEL}</Tag>
|
||||
<span className="font-mono text-[11px] text-[var(--mu-text-lo)]">{view.path}</span>
|
||||
<span className="text-[11px] text-[var(--mu-text-lo)]">
|
||||
{view.present ? "面板配置已存在" : "面板配置还没写过(运行期走代码默认:2.0s / 6s / 24 条)"}
|
||||
</span>
|
||||
</div>
|
||||
<Notice>{OWNER_PRIVATE_NOTE}</Notice>
|
||||
{COALESCE_FIELDS.map((field) => (
|
||||
<div key={field.key} className="space-y-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)]/60 px-3 py-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="min-w-56 text-xs text-[var(--mu-text-hi)]">{field.label}</span>
|
||||
{field.kind === "bool" ? (
|
||||
<select
|
||||
value={draft[field.key]}
|
||||
className="h-9 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
|
||||
onChange={(e) => onDraftChange(field.key, e.target.value)}
|
||||
>
|
||||
<option value="true">开</option>
|
||||
<option value="false">关</option>
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
value={draft[field.key]}
|
||||
inputMode="numeric"
|
||||
className={inputCls}
|
||||
onChange={(e) => onDraftChange(field.key, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
<span className="font-mono text-[11px] text-[var(--mu-text-lo)]">{field.key}</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-[var(--mu-text-lo)]">{field.hint}</div>
|
||||
{errors[field.key] ? <div className="text-[11px] text-[var(--mu-err)]">⚠ {errors[field.key]}</div> : null}
|
||||
</div>
|
||||
))}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="primary" disabled={busy || hasErrors} onClick={onSave}>
|
||||
{busy ? "保存中…" : "保存窗口设置"}
|
||||
</Button>
|
||||
{hasErrors ? (
|
||||
<span className="text-[11px] text-[var(--mu-text-lo)]">有坏值,未提交(后端一个字都不会改)</span>
|
||||
) : null}
|
||||
</div>
|
||||
{receipt ? <Notice>{receipt}</Notice> : null}
|
||||
{savedView ? (
|
||||
<div className="space-y-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-[11px]">
|
||||
<span className="text-[var(--mu-text-mid)]">保存后回显(后端重新读盘的真值):</span>
|
||||
<div className="font-mono text-[var(--mu-text-lo)]">
|
||||
窗口 {savedView.default.windowMs}ms · 上限 {savedView.default.maxWaitMs}ms · 单批{" "}
|
||||
{savedView.default.maxBatchMessages} 条 · {savedView.default.enabled ? "已启用" : "已关闭"}
|
||||
</div>
|
||||
<div className="text-[var(--mu-text-mid)]">{savedView.hot ? COALESCE_HOT_LABEL : "已落盘,但未能热生效"}</div>
|
||||
</div>
|
||||
) : null}
|
||||
{Object.keys(view.conversations).length > 0 ? (
|
||||
<div className="space-y-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-[11px]">
|
||||
<span className="text-[var(--mu-text-mid)]">每会话覆盖(在配置文件里维护,键 = 平台|渠道):</span>
|
||||
{Object.entries(view.conversations).map(([key, policy]) => (
|
||||
<div key={key} className="font-mono text-[var(--mu-text-lo)]">
|
||||
{key} → {policy.enabled ? "启用" : "关闭"} · 窗口 {policy.windowMs}ms · 单批 {policy.maxBatchMessages} 条
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<p className="text-[11px] text-[var(--mu-text-lo)]">
|
||||
规则:窗口 {view.rules.windowMs[0]}–{view.rules.windowMs[1]}ms、绝对上限 ≥ 窗口且 ≤ {view.rules.maxWaitMs[1]}ms、单批{" "}
|
||||
{view.rules.maxBatchMessages[0]}–{view.rules.maxBatchMessages[1]} 条;越界值由后端夹紧({view.rules.clamp})并留日志。
|
||||
发车原因:{view.rules.flushReasons.join(" / ")}。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** ChannelCoalesceCard 负责取数、表单草稿与保存。 */
|
||||
export function ChannelCoalesceCard(): React.ReactNode {
|
||||
const [view, setView] = useState<CoalesceView | null>(null);
|
||||
const [draft, setDraft] = useState<CoalesceDraft | null>(null);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [fail, setFail] = useState<string | null>(null);
|
||||
const [receipt, setReceipt] = useState<string | null>(null);
|
||||
const [savedView, setSavedView] = useState<CoalesceView | null>(null);
|
||||
const log = useAuditStore((s) => s.log);
|
||||
|
||||
const reload = useCallback(() => {
|
||||
setFail(null);
|
||||
return fetchCoalesce()
|
||||
.then((next) => {
|
||||
setView(next);
|
||||
setDraft(draftFromView(next));
|
||||
})
|
||||
.catch((e: unknown) => setFail(e instanceof Error ? e.message : "读取窗口配置失败"));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, [reload]);
|
||||
|
||||
const onDraftChange = useCallback((key: keyof CoalesceDraft, value: string) => {
|
||||
setDraft((prev) => (prev ? { ...prev, [key]: value } : prev));
|
||||
setReceipt(null);
|
||||
}, []);
|
||||
|
||||
const onSave = useCallback(() => {
|
||||
if (!view || !draft) return;
|
||||
const check = parseCoalescePatch(view, draft);
|
||||
setErrors(check.errors);
|
||||
if (Object.keys(check.errors).length > 0) return;
|
||||
if (check.changed.length === 0) {
|
||||
setReceipt("没有改动(一个键都没写)");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setFail(null);
|
||||
saveCoalesce(check.patch)
|
||||
.then((next) => {
|
||||
setView(next);
|
||||
setDraft(draftFromView(next));
|
||||
setSavedView(next);
|
||||
setErrors({});
|
||||
setReceipt(`已保存:${check.changed.join(", ")}。${COALESCE_HOT_LABEL}`);
|
||||
log("channels.coalesce", "ok");
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
setFail(e instanceof Error ? e.message : "保存窗口配置失败");
|
||||
log("channels.coalesce", "error");
|
||||
})
|
||||
.finally(() => setBusy(false));
|
||||
}, [view, draft, log]);
|
||||
|
||||
return (
|
||||
<Surface className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-sm font-semibold">群聊静默窗口合并</h3>
|
||||
<Tag tone="neutral">GET / PUT /api/v1/admin/channels/coalesce</Tag>
|
||||
</div>
|
||||
{fail ? <Notice tone="err">{fail}</Notice> : null}
|
||||
{view === null || draft === null ? (
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">{fail ? "读取失败" : "读取中…"}</span>
|
||||
) : (
|
||||
<CoalesceCardBody
|
||||
view={view}
|
||||
draft={draft}
|
||||
errors={errors}
|
||||
busy={busy}
|
||||
receipt={receipt}
|
||||
savedView={savedView}
|
||||
onDraftChange={onDraftChange}
|
||||
onSave={onSave}
|
||||
/>
|
||||
)}
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { PLATFORM_LABEL, PLATFORM_TYPES } from "../channelFields";
|
||||
import { adapterNameOf, configuredAccountCount, configuredPlatforms, platformOf } from "./channelUtils";
|
||||
import { channelListState, channelStatus } from "./channelStatus";
|
||||
import { ChannelLoadError, ChannelStatusTag } from "./channelStatusView";
|
||||
import { ChannelCoalesceCard } from "./CoalesceCard";
|
||||
import type { AdapterNav } from "./useAdapterNav";
|
||||
|
||||
/** Page ① — aggregate all adapters: connection state, message volume, health.
|
||||
@@ -79,6 +80,9 @@ export function OverviewPage({ navigate }: { navigate: (n: AdapterNav) => void }
|
||||
onOpenList={() => navigate({ kind: "page", page: "list" })}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* 群聊静默窗口合并(M23):窗口/上限这类调优只该在面板改,不该 SSH 改文件。 */}
|
||||
<ChannelCoalesceCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
// 群聊静默窗口卡(M23)的**真实渲染**测试。
|
||||
//
|
||||
// 它钉住三件**呈现**的事(逻辑那一半在 CoalesceCard.tsx 的导出函数里):
|
||||
// ① 六个字段都有中文标签 + 一句人话说明(窗口/上限/单批/陌生人/开关);
|
||||
// ② 主人私聊**只给一行只读说明**(`ownerPrivate:"never"` 的呈现),**不给开关**;
|
||||
// ③ 越界/非整数就地报错并拦住保存(后端那道 400 仍在,只是不让主人白跑)。
|
||||
//
|
||||
// 跑法(与 MemoryTidyTuningCard.render.test.ts 同):
|
||||
//
|
||||
// cd frontend/web \
|
||||
// && ./node_modules/.bin/esbuild src/admin/modules/channels/coalesceCard.render.test.ts \
|
||||
// --bundle --platform=node --format=cjs \
|
||||
// --outfile=/tmp/cyrene-coalesce-card.render.test.cjs \
|
||||
// && node /tmp/cyrene-coalesce-card.render.test.cjs
|
||||
//
|
||||
// `tsc --noEmit` 会对本文件做类型检查,因此它只从项目内导入、不 import node:*。
|
||||
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import {
|
||||
COALESCE_FIELDS,
|
||||
COALESCE_HOT_LABEL,
|
||||
OWNER_PRIVATE_NOTE,
|
||||
CoalesceCardBody,
|
||||
draftFromView,
|
||||
parseCoalescePatch,
|
||||
type CoalesceView,
|
||||
} from "./CoalesceCard";
|
||||
|
||||
let checks = 0;
|
||||
|
||||
function ok(cond: boolean, what: string): void {
|
||||
checks += 1;
|
||||
if (!cond) throw new Error(`FAILED: ${what}`);
|
||||
}
|
||||
|
||||
function has(haystack: string, needle: string): boolean {
|
||||
return haystack.includes(needle);
|
||||
}
|
||||
|
||||
/** fixtureView 造一份 GET 响应形状的数据(代码默认 + 一条会话覆盖)。 */
|
||||
function fixtureView(overrides: Partial<CoalesceView> = {}): CoalesceView {
|
||||
return {
|
||||
path: "/data/config/coalesce.json",
|
||||
present: true,
|
||||
hot: true,
|
||||
default: {
|
||||
enabled: true,
|
||||
windowMs: 2000,
|
||||
maxWaitMs: 6000,
|
||||
maxBatchMessages: 24,
|
||||
applyToStrangers: true,
|
||||
strangerWindowMs: 2000,
|
||||
},
|
||||
patch: {},
|
||||
conversations: {
|
||||
"telegram|group:-20": {
|
||||
enabled: false,
|
||||
windowMs: 2000,
|
||||
maxWaitMs: 6000,
|
||||
maxBatchMessages: 24,
|
||||
applyToStrangers: true,
|
||||
strangerWindowMs: 2000,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
windowMs: [300, 15000],
|
||||
maxWaitMs: [300, 30000],
|
||||
maxBatchMessages: [1, 50],
|
||||
clamp: "clamp",
|
||||
flushReasons: ["quiet", "max_wait", "all_stale", "off", "stop"],
|
||||
},
|
||||
ownerPrivate: "never",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function render(view: CoalesceView, draftOverride?: Partial<ReturnType<typeof draftFromView>>): string {
|
||||
const draft = { ...draftFromView(view), ...(draftOverride ?? {}) };
|
||||
const check = parseCoalescePatch(view, draft);
|
||||
return renderToStaticMarkup(
|
||||
createElement(CoalesceCardBody, {
|
||||
view,
|
||||
draft,
|
||||
errors: check.errors,
|
||||
busy: false,
|
||||
receipt: null,
|
||||
savedView: null,
|
||||
onDraftChange: () => undefined,
|
||||
onSave: () => undefined,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const view = fixtureView();
|
||||
const html = render(view);
|
||||
|
||||
// ① 六个字段的中文标签 + 说明都在,且「主人私聊」只有说明、没有开关。
|
||||
for (const field of COALESCE_FIELDS) {
|
||||
ok(has(html, field.label), `field label missing: ${field.label}`);
|
||||
ok(has(html, field.hint), `field hint missing for ${field.key}`);
|
||||
ok(has(html, field.key), `field key not shown for ${field.key}`);
|
||||
}
|
||||
ok(has(html, OWNER_PRIVATE_NOTE), "owner-private read-only note missing");
|
||||
ok(has(html, "面板没有开关"), "owner-private note must say the panel has no switch for it");
|
||||
ok(has(html, COALESCE_HOT_LABEL), "hot label missing");
|
||||
ok(has(html, "/data/config/coalesce.json"), "config path not shown");
|
||||
ok(has(html, "telegram|group:-20"), "per-conversation override not listed");
|
||||
ok(has(html, "300–15000ms"), "window rules not shown");
|
||||
ok(has(html, "quiet"), "flush reasons not shown");
|
||||
|
||||
// ② 三态补丁:只有改过的键才写;没改 ⇒ 空补丁。
|
||||
const unchanged = parseCoalescePatch(view, draftFromView(view));
|
||||
ok(Object.keys(unchanged.patch.default).length === 0, "unchanged draft must produce an empty patch");
|
||||
ok(unchanged.changed.length === 0, "unchanged draft must report no changed keys");
|
||||
|
||||
const changed = parseCoalescePatch(view, { ...draftFromView(view), windowMs: "3000" });
|
||||
ok(changed.patch.default.windowMs === 3000, "changed window must land in the patch");
|
||||
ok(changed.changed.length === 1, `only the changed key may be written, got ${changed.changed.join(",")}`);
|
||||
|
||||
// ③ 坏值就地报错,且补丁里不含那个键(不会把一半好一半坏的东西发出去)。
|
||||
const bad = parseCoalescePatch(view, { ...draftFromView(view), windowMs: "10", maxBatchMessages: "abc" });
|
||||
ok(bad.errors.windowMs !== undefined, "out-of-range window must be reported");
|
||||
ok(bad.errors.maxBatchMessages !== undefined, "non-numeric batch must be reported");
|
||||
ok(bad.patch.default.windowMs === undefined, "invalid window must not be sent");
|
||||
ok(bad.patch.default.maxBatchMessages === undefined, "invalid batch must not be sent");
|
||||
|
||||
// 绝对上限必须 ≥ 窗口(下层规则由面板现算,不能只信后端)。
|
||||
const waitTooSmall = parseCoalescePatch(view, { ...draftFromView(view), windowMs: "5000", maxWaitMs: "3000" });
|
||||
ok(waitTooSmall.errors.maxWaitMs !== undefined, "maxWait below the window must be rejected");
|
||||
|
||||
const badHtml = render(view, { windowMs: "10" });
|
||||
ok(has(badHtml, "取值范围"), "invalid draft must render an inline error");
|
||||
ok(has(badHtml, "有坏值,未提交"), "invalid draft must block the save button");
|
||||
|
||||
process.stdout.write(`coalesce card render: ${checks} checks passed\n`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,167 @@
|
||||
// 记忆整理「调优」卡**纯逻辑**的测试(切片 MEMPANEL / M4)。
|
||||
//
|
||||
// 为什么需要它:这张卡最容易写错的地方不是样式,而是**三态**(键缺位 / 合法值 /
|
||||
// 非法值)与「清空 ≠ 不改」这两条 —— 错一处就会写出「主人以为改了、盘上没动」或者
|
||||
// 「一个坏值把整份配置写成 0」这类事故。这些判据全在 memoryTidyTuning.ts 的纯函数里,
|
||||
// 因此不需要渲染就能钉住。
|
||||
//
|
||||
// 跑法(与同目录 *.render.test.ts 同一手法,本文件不依赖 React):
|
||||
//
|
||||
// cd frontend/web \
|
||||
// && ./node_modules/.bin/esbuild src/admin/modules/memoryTidyTuning.test.ts \
|
||||
// --bundle --platform=node --format=cjs \
|
||||
// --outfile=/tmp/cyrene-memory-tidy-tuning.test.cjs \
|
||||
// && node /tmp/cyrene-memory-tidy-tuning.test.cjs
|
||||
//
|
||||
// `tsc --noEmit` 会对本文件做类型检查,因此它只从项目内导入、不 import node:*。
|
||||
|
||||
import type {
|
||||
AdminMemoryTidyTuningSpec,
|
||||
AdminMemoryTidyTuningView,
|
||||
} from "../../lib/api/adminMemories";
|
||||
import {
|
||||
buildTuningPatch,
|
||||
draftFromView,
|
||||
fallbackLabel,
|
||||
fieldError,
|
||||
patchSummary,
|
||||
secondsHint,
|
||||
} from "./memoryTidyTuning";
|
||||
|
||||
let checks = 0;
|
||||
|
||||
function ok(cond: boolean, what: string): void {
|
||||
checks += 1;
|
||||
if (!cond) throw new Error(`FAILED: ${what}`);
|
||||
}
|
||||
|
||||
function eq<T>(got: T, want: T, what: string): void {
|
||||
checks += 1;
|
||||
if (got !== want) throw new Error(`FAILED: ${what}:got=${JSON.stringify(got)} want=${JSON.stringify(want)}`);
|
||||
}
|
||||
|
||||
function deepEq(got: unknown, want: unknown, what: string): void {
|
||||
const g = JSON.stringify(got);
|
||||
const w = JSON.stringify(want);
|
||||
checks += 1;
|
||||
if (g !== w) throw new Error(`FAILED: ${what}:got=${g} want=${w}`);
|
||||
}
|
||||
|
||||
/** spec 是后端 specs 的替身(取值规则来自后端,前端只消费)。 */
|
||||
function spec(key: string, kind: string, extra: Partial<AdminMemoryTidyTuningSpec> = {}): AdminMemoryTidyTuningSpec {
|
||||
return { key, kind, fallback: "code-default", ...extra } as AdminMemoryTidyTuningSpec;
|
||||
}
|
||||
|
||||
/** view 造一份 GET 响应形状的 fixture。 */
|
||||
function view(values: Record<string, number | boolean>, envValues?: Record<string, number>): AdminMemoryTidyTuningView {
|
||||
return {
|
||||
path: "config/memory-tidy.json",
|
||||
present: true,
|
||||
hot: false,
|
||||
hotError: "运行期没有接线可热生效的实现",
|
||||
specs: [
|
||||
spec("intervalSec", "int", { unit: "sec", min: 1, fallback: "env:MEMORY_TIDY_INTERVAL_SEC" }),
|
||||
spec("quietIntervalSec", "int", { unit: "sec", min: 1, fallback: "panel:intervalSec" }),
|
||||
spec("quietStartHour", "int", { unit: "hour", min: 0, max: 23, fallback: "env:MEMORY_TIDY_QUIET_START_HOUR" }),
|
||||
spec("quietEndHour", "int", { unit: "hour", min: 0, max: 23, fallback: "env:MEMORY_TIDY_QUIET_END_HOUR" }),
|
||||
spec("ingestGate", "bool", { fallback: "code-default" }),
|
||||
spec("autoApply", "bool", { fallback: "code-default" }),
|
||||
spec("tempTtlSec", "int", { unit: "sec", min: 1, fallback: "code-default" }),
|
||||
],
|
||||
values,
|
||||
envValues,
|
||||
};
|
||||
}
|
||||
|
||||
// ① 草稿:键缺位 ⇒ 空串(= 跟随兜底),而不是 0 —— 「没配」与「配成 0」必须分得开。
|
||||
{
|
||||
const draft = draftFromView(view({ intervalSec: 10800, ingestGate: false }));
|
||||
eq(draft.intervalSec, "10800", "草稿回填盘上值");
|
||||
eq(draft.ingestGate, "false", "布尔键回填为字符串");
|
||||
eq(draft.quietIntervalSec, "", "盘上没这个键 ⇒ 空串(跟随兜底)");
|
||||
eq(draft.tempTtlSec, "", "盘上没这个键 ⇒ 空串(跟随兜底)");
|
||||
}
|
||||
|
||||
// ② 三个态:没动过的键**不进补丁**(= 不改动);改过的进补丁;合法值按类型提交。
|
||||
{
|
||||
const base = view({ intervalSec: 86400, quietStartHour: 23, quietEndHour: 7, ingestGate: false });
|
||||
const draft = draftFromView(base);
|
||||
const untouched = buildTuningPatch(base, draft);
|
||||
deepEq(untouched.patch, {}, "没动过任何键 ⇒ 空补丁(盘上逐字节不动)");
|
||||
deepEq(untouched.errors, {}, "没动过任何键 ⇒ 无错误");
|
||||
|
||||
const changed = buildTuningPatch(base, { ...draft, intervalSec: "10800", ingestGate: "true" });
|
||||
deepEq(changed.patch, { intervalSec: 10800, ingestGate: true }, "合法值 ⇒ 采用(且只含改动过的键)");
|
||||
deepEq(changed.errors, {}, "合法值 ⇒ 无错误");
|
||||
}
|
||||
|
||||
// ③ 清空哨兵:原本有值、现在清空 ⇒ `null`(删掉该键,回到下一档兜底);
|
||||
// 原本就没值、现在还是空 ⇒ 什么都不发(不改动)。
|
||||
{
|
||||
const base = view({ intervalSec: 3600, tempTtlSec: 604800 });
|
||||
const draft = draftFromView(base);
|
||||
const cleared = buildTuningPatch(base, { ...draft, intervalSec: "" });
|
||||
deepEq(cleared.patch, { intervalSec: null }, "清空一个有值的键 ⇒ null(回到兜底)");
|
||||
const alreadyEmpty = view({ ingestGate: true });
|
||||
const mix = view({ ingestGate: true });
|
||||
const r2 = buildTuningPatch(mix, { ...draftFromView(alreadyEmpty), tempTtlSec: "" });
|
||||
deepEq(r2.patch, {}, "原本就没值的键保持空 ⇒ 不改动(不是清除)");
|
||||
}
|
||||
|
||||
// ④ 非法值:**就地报错且不进补丁**(界面据此拦下保存),后端的 400 仍是权威那道门。
|
||||
{
|
||||
const base = view({ intervalSec: 3600, quietStartHour: 23, quietEndHour: 7 });
|
||||
const draft = draftFromView(base);
|
||||
const cases: Array<[string, string, string]> = [
|
||||
["intervalSec", "abc", "非整数"],
|
||||
["intervalSec", "0", "低于下界(0 会让整理空转)"],
|
||||
["intervalSec", "-60", "负数"],
|
||||
["quietStartHour", "24", "钟点越上界"],
|
||||
["quietEndHour", "-1", "钟点越下界"],
|
||||
["tempTtlSec", "1.5", "小数不是整数"],
|
||||
];
|
||||
for (const [key, raw, why] of cases) {
|
||||
const res = buildTuningPatch(base, { ...draft, [key]: raw });
|
||||
ok(res.errors[key] !== undefined, `非法值必须就地报错(${why})`);
|
||||
ok(res.patch[key] === undefined, `非法值绝不能进补丁(${why})`);
|
||||
}
|
||||
// 混着来:一个坏值不影响对合法键的校验,但界面整体不许保存(errors 非空)。
|
||||
const mixed = buildTuningPatch(base, { ...draft, intervalSec: "0", quietStartHour: "22" });
|
||||
ok(Object.keys(mixed.errors).length === 1, "只有一个坏值时只报一处");
|
||||
eq(mixed.patch.quietStartHour, 22, "合法键仍照常进补丁(后端一次事务性写盘)");
|
||||
// 布尔键只认三个取值。
|
||||
eq(fieldError(spec("ingestGate", "bool", { fallback: "code-default" }), "yes").length > 0, true, "布尔键的坏值要报错");
|
||||
eq(fieldError(spec("ingestGate", "bool", { fallback: "code-default" }), ""), "", "空 = 跟随兜底,合法");
|
||||
}
|
||||
|
||||
// ⑤ 边界值合法(下界/上界本身可用,不是「越界」)。
|
||||
{
|
||||
eq(fieldError(spec("intervalSec", "int", { unit: "sec", min: 1, fallback: "x" }), "1"), "", "下界本身合法");
|
||||
eq(fieldError(spec("quietStartHour", "int", { unit: "hour", min: 0, max: 23, fallback: "x" }), "0"), "", "钟点 0 合法");
|
||||
eq(fieldError(spec("quietEndHour", "int", { unit: "hour", min: 0, max: 23, fallback: "x" }), "23"), "", "钟点 23 合法");
|
||||
}
|
||||
|
||||
// ⑥ 文案:兜底来源与单位要写成人话(面板的「留空 ⇒ 跟随谁」全靠它)。
|
||||
{
|
||||
eq(fallbackLabel(spec("intervalSec", "int", { fallback: "env:MEMORY_TIDY_INTERVAL_SEC" })),
|
||||
"跟随 .env 的 MEMORY_TIDY_INTERVAL_SEC", "env 兜底文案");
|
||||
eq(fallbackLabel(spec("quietIntervalSec", "int", { fallback: "panel:intervalSec" })),
|
||||
"跟随面板里的 intervalSec", "面板内兜底文案");
|
||||
eq(fallbackLabel(spec("tempTtlSec", "int", { fallback: "code-default" })), "跟随代码默认", "代码默认文案");
|
||||
eq(secondsHint(10800), "≈ 3 小时", "秒 → 小时提示");
|
||||
eq(secondsHint(1800), "≈ 30 分钟", "秒 → 分钟提示");
|
||||
eq(secondsHint(0), "", "非正数不提示");
|
||||
eq(patchSummary({ intervalSec: 3600, ingestGate: false }), "同步入库闸(写入时当场审查)、平时整理间隔", "回执文案");
|
||||
eq(patchSummary({}), "没有需要改的键", "空补丁回执");
|
||||
}
|
||||
|
||||
// ⑦ 字符串数字不做隐式转换(" 12 " 这种带空白的要能通过,但 "12 3" 不行)。
|
||||
{
|
||||
const base = view({ intervalSec: 3600 });
|
||||
const padded = buildTuningPatch(base, { ...draftFromView(base), intervalSec: " 7200 " });
|
||||
deepEq(padded.patch, { intervalSec: 7200 }, "两侧空白应当被容忍");
|
||||
const bad = buildTuningPatch(base, { ...draftFromView(base), intervalSec: "12 3" });
|
||||
ok(bad.errors.intervalSec !== undefined, "内部空格不是合法整数");
|
||||
}
|
||||
|
||||
console.log(`memoryTidyTuning.test.ts: ${checks} checks passed`);
|
||||
@@ -0,0 +1,152 @@
|
||||
import type {
|
||||
AdminMemoryTidyTuningPatch,
|
||||
AdminMemoryTidyTuningSpec,
|
||||
AdminMemoryTidyTuningView,
|
||||
} from "../../lib/api/adminMemories";
|
||||
|
||||
/* ----------------------------------------------------------------------------
|
||||
* 记忆整理「面板调优」卡的**纯逻辑**(切片 MEMPANEL / M4):表单草稿 ↔ 三态补丁。
|
||||
*
|
||||
* 与呈现分开(与 memoryTidyApplyState.ts 同一手法):这一半不依赖 React,可以直接
|
||||
* 用 node 跑断言(见 memoryTidyTuning.test.ts)。
|
||||
*
|
||||
* 三条不可走样的口径:
|
||||
* 1. **取值规则来自后端的 specs**(类型/单位/上下界/兜底来源):面板不硬编码第二套
|
||||
* 枚举或上下界 —— 否则就会出现「面板允许、后端 400」的漂移。本文件只负责把
|
||||
* spec 翻成**草稿的校验规则**与中文文案。
|
||||
* 2. **键缺位 ≠ 清空**:草稿里没动过的键**不进补丁**(= 后端不动它);把一个原本
|
||||
* 有值的键清空 ⇒ 发 `null`(= 后端删掉该键,回到下一档兜底)。两者在盘上的结果
|
||||
* 完全不同,因此这里必须分开。
|
||||
* 3. **坏值就地报错**:非法草稿**不产生补丁**(界面据此拦下保存),而不是发出去等
|
||||
* 后端 400 —— 后端那道门仍在(它是权威),这只是不让主人白跑一趟。
|
||||
* -------------------------------------------------------------------------- */
|
||||
|
||||
/** `true` / `false` / 三个中文标签:调优键的中文名(面板文案)。 */
|
||||
export const TUNING_LABELS: Record<string, string> = {
|
||||
intervalSec: "平时整理间隔",
|
||||
quietIntervalSec: "静默时段整理间隔",
|
||||
quietStartHour: "静默时段开始(本地钟点)",
|
||||
quietEndHour: "静默时段结束(本地钟点)",
|
||||
ingestGate: "同步入库闸(写入时当场审查)",
|
||||
autoApply: "轮末自动落地(高置信候选直接改记忆)",
|
||||
tempTtlSec: "临时记忆存活时长",
|
||||
};
|
||||
|
||||
/** 兜底来源的中文说明(后端 specs 的 `fallback` 只有三族)。 */
|
||||
export function fallbackLabel(spec: AdminMemoryTidyTuningSpec): string {
|
||||
if (spec.fallback.startsWith("env:")) {
|
||||
return `跟随 .env 的 ${spec.fallback.slice("env:".length)}`;
|
||||
}
|
||||
if (spec.fallback.startsWith("panel:")) {
|
||||
return `跟随面板里的 ${spec.fallback.slice("panel:".length)}`;
|
||||
}
|
||||
return "跟随代码默认";
|
||||
}
|
||||
|
||||
/** 单位的中文说明。 */
|
||||
export function unitLabel(spec: AdminMemoryTidyTuningSpec): string {
|
||||
if (spec.unit === "sec") return "秒";
|
||||
if (spec.unit === "hour") return "本地钟点(0-23)";
|
||||
return "";
|
||||
}
|
||||
|
||||
/** 秒数的可读提示(面板不改变单位,只帮主人把 10800 读成 3 小时)。 */
|
||||
export function secondsHint(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) return "";
|
||||
if (seconds % 3600 === 0) return `≈ ${seconds / 3600} 小时`;
|
||||
if (seconds % 60 === 0) return `≈ ${seconds / 60} 分钟`;
|
||||
return `≈ ${seconds} 秒`;
|
||||
}
|
||||
|
||||
/** 表单草稿:每个键一个字符串(bool 键用 "true"/"false",空串 = 跟随兜底/清空)。 */
|
||||
export type TuningDraft = Record<string, string>;
|
||||
|
||||
/** 草稿的校验结果。 */
|
||||
export interface TuningDraftCheck {
|
||||
/** 键 → 就地错误文案(空对象 = 可以保存)。 */
|
||||
errors: Record<string, string>;
|
||||
/** 真正要提交的三态补丁(只含改动过的键)。 */
|
||||
patch: AdminMemoryTidyTuningPatch;
|
||||
}
|
||||
|
||||
/** draftFromView 把盘上的值铺成表单草稿(键缺位 ⇒ 空串 = 跟随兜底)。 */
|
||||
export function draftFromView(view: AdminMemoryTidyTuningView): TuningDraft {
|
||||
const draft: TuningDraft = {};
|
||||
for (const spec of view.specs) {
|
||||
const value = view.values[spec.key];
|
||||
if (value === undefined || value === null) {
|
||||
draft[spec.key] = "";
|
||||
continue;
|
||||
}
|
||||
draft[spec.key] = typeof value === "boolean" ? (value ? "true" : "false") : String(value);
|
||||
}
|
||||
return draft;
|
||||
}
|
||||
|
||||
/** fieldError 校验**单个**键的草稿值;返回空串表示合法(空 = 沿用兜底,也合法)。 */
|
||||
export function fieldError(spec: AdminMemoryTidyTuningSpec, raw: string): string {
|
||||
const text = raw.trim();
|
||||
if (text === "") return "";
|
||||
if (spec.kind === "bool") {
|
||||
return text === "true" || text === "false" ? "" : `${TUNING_LABELS[spec.key] ?? spec.key} 只能选「开 / 关 / 跟随兜底」`;
|
||||
}
|
||||
if (!/^-?\d+$/.test(text)) {
|
||||
return `${TUNING_LABELS[spec.key] ?? spec.key} 必须是整数(秒),收到「${text}」`;
|
||||
}
|
||||
const n = Number(text);
|
||||
if (spec.min !== undefined && n < spec.min) {
|
||||
return `${TUNING_LABELS[spec.key] ?? spec.key} 必须 ≥ ${spec.min}${unitLabel(spec)}`;
|
||||
}
|
||||
if (spec.max !== undefined && n > spec.max) {
|
||||
return `${TUNING_LABELS[spec.key] ?? spec.key} 必须 ≤ ${spec.max}${unitLabel(spec)}`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/** buildTuningPatch 是「草稿 → 三态补丁」的唯一实现点(口径 2、3)。 */
|
||||
export function buildTuningPatch(
|
||||
view: AdminMemoryTidyTuningView,
|
||||
draft: TuningDraft
|
||||
): TuningDraftCheck {
|
||||
const errors: Record<string, string> = {};
|
||||
const patch: AdminMemoryTidyTuningPatch = {};
|
||||
for (const spec of view.specs) {
|
||||
const raw = draft[spec.key] ?? "";
|
||||
const err = fieldError(spec, raw);
|
||||
if (err) {
|
||||
errors[spec.key] = err;
|
||||
continue;
|
||||
}
|
||||
const current = view.values[spec.key];
|
||||
const text = raw.trim();
|
||||
if (text === "") {
|
||||
// 原本就没有值 ⇒ 一个字节都不必动(后端同样把「键缺位」当「不改动」)。
|
||||
if (current === undefined || current === null) continue;
|
||||
// 原本有值、现在清空 ⇒ **清除哨兵**(删掉该键,回到下一档兜底)。
|
||||
patch[spec.key] = null;
|
||||
continue;
|
||||
}
|
||||
if (spec.kind === "bool") {
|
||||
const next = text === "true";
|
||||
if (typeof current === "boolean" && current === next) continue;
|
||||
patch[spec.key] = next;
|
||||
continue;
|
||||
}
|
||||
const next = Number(text);
|
||||
if (typeof current === "number" && current === next) continue;
|
||||
patch[spec.key] = next;
|
||||
}
|
||||
return { errors, patch };
|
||||
}
|
||||
|
||||
/** patchKeys 返回补丁里出现的键(供回执文案与日志/审计展示,顺序稳定)。 */
|
||||
export function patchKeys(patch: AdminMemoryTidyTuningPatch): string[] {
|
||||
return Object.keys(patch).sort();
|
||||
}
|
||||
|
||||
/** patchSummary 把补丁说成人话(保存后回执用,例如「平时整理间隔、同步入库闸」)。 */
|
||||
export function patchSummary(patch: AdminMemoryTidyTuningPatch): string {
|
||||
const keys = patchKeys(patch);
|
||||
if (keys.length === 0) return "没有需要改的键";
|
||||
return keys.map((k) => TUNING_LABELS[k] ?? k).join("、");
|
||||
}
|
||||
@@ -5,10 +5,15 @@
|
||||
// 界面无任何提示)。改法:界面**一个平台一条** + 该平台下「+ 添加账号 ID」;平台是权威
|
||||
// 枚举**下拉**;账号 ID 给出「从该平台已配置账号里选」的候选;**写盘时仍是扁平数组**。
|
||||
//
|
||||
// 锁三件事:
|
||||
// ① 分组/展平是纯函数且**展平后就是后端契约的扁平数组**(`owner.yaml` 格式不变);
|
||||
// 锁这些事:
|
||||
// ① 分组/草稿/写盘形态(纯函数):**草稿无损**(`draftAccounts` 逐行保留,空行/未选平台的组
|
||||
// 都在 —— 编辑期的中间态不许蒸发),**写盘有损**(`accountsForSave` 只留平台与账号 ID 都
|
||||
// 非空的条目:半成品条目会让后端 `Validate()` 直接 400,完全空的条目被跳过);
|
||||
// ② 下拉选项 = 权威枚举 + 存量里出现过的平台(未列出的旧值不被下拉吃掉);
|
||||
// ③ 渲染:一个平台一条(两个平台 ⇒ 两块)、有「+ 添加账号 ID」、有候选可点。
|
||||
// ③ 候选账号 = 该平台已配置的渠道账号 id;
|
||||
// ④ 渲染:一个平台一条(两个平台 ⇒ 两块)、有「+ 添加账号 ID」、有候选可点;
|
||||
// ⑤ **手势往返**(主人 2026-10-07 报的缺陷:卡片里「+ 添加账号 ID」似乎没用):点一下必须
|
||||
// 真的多出一行可输入的账号行;「+ 添加平台」走同一条往返,同样钉住。
|
||||
//
|
||||
// 跑法(react-dom/server 的 CJS 构建需要 require,所以必须打成 cjs):
|
||||
//
|
||||
@@ -21,12 +26,14 @@
|
||||
// `tsc --noEmit`(npm run build 的一部分)会对本文件做类型检查,因此它只从项目内导入、
|
||||
// 不 import node:*。
|
||||
|
||||
import { createElement } from "react";
|
||||
import { createElement, type ReactNode } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import type { AdminChannelRecord, EnumItem, OwnerAccount } from "../../lib/types";
|
||||
import { OwnerAccountsEditor } from "./OwnerAccountsEditor";
|
||||
import { OwnerAccountsEditor, OwnerAccountsView } from "./OwnerAccountsEditor";
|
||||
import {
|
||||
accountCandidates,
|
||||
accountsForSave,
|
||||
draftAccounts,
|
||||
flattenAccounts,
|
||||
groupAccounts,
|
||||
platformIsConfigured,
|
||||
@@ -56,7 +63,7 @@ const PUBLISHED: EnumItem[] = [
|
||||
{ id: "webhook", label: "Webhook" },
|
||||
];
|
||||
|
||||
// ── ① 分组 / 展平(纯函数) ─────────────────────────────────────────────────────
|
||||
// ── ① 分组 / 草稿 / 写盘形态(纯函数) ──────────────────────────────────────────
|
||||
{
|
||||
const flat: OwnerAccount[] = [
|
||||
{ platform: "qq-obv11", accountId: "9616b27a" },
|
||||
@@ -68,15 +75,71 @@ const PUBLISHED: EnumItem[] = [
|
||||
same(groups[0], { platform: "qq-obv11", accountIds: ["9616b27a", "aaaa1111"] }, "同平台的账号收进一块");
|
||||
same(groups[1], { platform: "web", accountIds: ["web-user"] }, "顺序 = 首次出现顺序");
|
||||
|
||||
// 展平回扁平数组。注意:分组天然把**同一平台的账号排到相邻位置** ⇒ 保存后文件的
|
||||
// 行序会变成「按平台聚集」。白名单是一个**集合**(命中即 owner,与顺序无关),所以
|
||||
// 这里按集合断言;顺序变化在面板上一眼可见,不是隐藏改动。
|
||||
// 草稿(编辑期形态,`draftAccounts`)**无损**:逐行原样回来 —— 空行、还没选平台的组都在。
|
||||
// 这一条是主人 2026-10-07 那个缺陷的正面表述:中间态不许在往返里蒸发。
|
||||
same(
|
||||
groupAccounts(draftAccounts(groups)),
|
||||
groups,
|
||||
"组 → 草稿 → 组:原样回来(无损往返)"
|
||||
);
|
||||
same(
|
||||
draftAccounts([{ platform: "kook", accountIds: ["9616b27a", "", " "] }]),
|
||||
[
|
||||
{ platform: "kook", accountId: "9616b27a" },
|
||||
{ platform: "kook", accountId: "" },
|
||||
{ platform: "kook", accountId: " " },
|
||||
],
|
||||
"空行留在草稿里(不 trim、不丢行:编辑期不改写主人正在输入的字)"
|
||||
);
|
||||
same(
|
||||
groupAccounts(draftAccounts([{ platform: "", accountIds: [""] }])),
|
||||
[{ platform: "", accountIds: [""] }],
|
||||
"「还没选平台」的空组也在草稿里(「+ 添加平台」靠它)"
|
||||
);
|
||||
|
||||
// 写盘形态(`flattenAccounts` / `accountsForSave`):分组天然把**同一平台的账号排到相邻位置**
|
||||
// ⇒ 保存后文件的条目序会变成「按平台聚集」。白名单是一个**集合**(命中即 owner,与顺序无关),
|
||||
// 所以这里按集合断言;顺序变化在面板上一眼可见,不是隐藏改动。
|
||||
const sorted = (list: OwnerAccount[]): OwnerAccount[] =>
|
||||
[...list].sort((a, b) => `${a.platform}/${a.accountId}`.localeCompare(`${b.platform}/${b.accountId}`));
|
||||
same(sorted(flattenAccounts(groups)), sorted(flat), "分组 → 展平 回到同一组白名单条目");
|
||||
const shape = Object.keys(flattenAccounts(groups)[0]).sort();
|
||||
same(shape, ["accountId", "platform"], "写盘条目只有 {platform, accountId}(owner.yaml 格式不变)");
|
||||
|
||||
// 保存路径(草稿 → 写盘形态):只写填完的条目。
|
||||
// 草稿里允许有空行,写盘时收掉;半成品条目(缺平台或缺 id)**必须**在这里收掉,否则
|
||||
// 后端 `Validate()`(internal/user/load.go)对这类条目直接 400,主人会看到「保存失败」。
|
||||
same(
|
||||
accountsForSave([
|
||||
{ platform: "qq-obv11", accountId: "9616b27a" },
|
||||
{ platform: "qq-obv11", accountId: "" },
|
||||
{ platform: "kook", accountId: "" },
|
||||
]),
|
||||
[{ platform: "qq-obv11", accountId: "9616b27a" }],
|
||||
"保存:只写平台与账号 ID 都非空的条目(空行不进 owner.yaml)"
|
||||
);
|
||||
same(
|
||||
accountsForSave([
|
||||
{ platform: "", accountId: "9616b27a" },
|
||||
{ platform: "kook", accountId: "" },
|
||||
{ platform: "", accountId: "" },
|
||||
]),
|
||||
[],
|
||||
"保存:半成品条目一个都不写(写了会被后端 400)"
|
||||
);
|
||||
same(accountsForSave([]), [], "保存:空白名单写出空数组(不是 null)");
|
||||
same(sorted(accountsForSave(flat)), sorted(flat), "保存:已生效的条目原样往返(不会被写没)");
|
||||
same(
|
||||
sorted(accountsForSave(flattenAccounts(groups))),
|
||||
sorted(flat),
|
||||
"保存:写盘形态再收一次仍是同一组(幂等)"
|
||||
);
|
||||
same(
|
||||
Object.keys(accountsForSave([{ platform: "kook", accountId: "42" }])[0]).sort(),
|
||||
["accountId", "platform"],
|
||||
"保存:落盘条目仍只有 {platform, accountId}(owner.yaml 格式一个字不改)"
|
||||
);
|
||||
|
||||
// 空值:平台或账号 id 任一为空都不写(写了也匹配不上 actor 元数据,是噪声)。
|
||||
same(
|
||||
flattenAccounts([
|
||||
@@ -171,4 +234,247 @@ const PUBLISHED: EnumItem[] = [
|
||||
ok(other.includes('disabled=""'), "只读角色下控件被禁用(不假装能改)");
|
||||
}
|
||||
|
||||
// ── ⑤ 手势 → 草稿 → 渲染:任何界面手势产生的中间态都不能在往返中蒸发 ─────────────
|
||||
//
|
||||
// 这一节钉的是主人报的那个缺陷:「配置指定适配器主人账号的卡片内的『添加账号ID』按钮似乎没用」。
|
||||
// 它只发生在「手势 → 草稿 → 再投影回界面」这条往返上:草稿若是**有损**的扁平形态(展平时把空行
|
||||
// 丢掉),刚点出来的空行当场蒸发、界面照旧 —— 点了等于没点。注意纯函数往返测试
|
||||
// (`展平(分组(flat)) == flat`)**测不到**它:那个等式把「空行被丢掉」当成恒等,等于把有损性
|
||||
// 当成了正确性。
|
||||
//
|
||||
// 本项目没有 jsdom(见 flowTimelineWiring.render.test.ts 的说明)⇒ 这里直接调用**无 hook** 的
|
||||
// `OwnerAccountsView` 拿到真的元素树,**点真的那个按钮**(调它自己的 `onClick`),把 `onChange`
|
||||
// 收到的**真草稿**再喂回去重渲染 —— 走的正是 `Users.tsx` 的接线(草稿是唯一状态,界面是它的投影)。
|
||||
|
||||
/** 元素树里的一个宿主节点(`button`/`input`/`select`/`div`…)。 */
|
||||
interface HostEl {
|
||||
type: string;
|
||||
props: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** 节点里的可见文字(按钮标签、提示语靠它找)。 */
|
||||
function textOf(node: ReactNode): string {
|
||||
if (node === null || node === undefined || typeof node === "boolean") return "";
|
||||
if (typeof node === "string" || typeof node === "number") return String(node);
|
||||
if (Array.isArray(node)) return node.map(textOf).join("");
|
||||
return textOf((node as { props?: { children?: ReactNode } }).props?.children);
|
||||
}
|
||||
|
||||
/** 走一遍元素树,收集宿主节点。函数组件会被**直接调用**(`Button` / `PlatformGroupRow` /
|
||||
* `AccountIdRow` 都是无 hook 的纯呈现)—— 若往视图里塞 hook,这里会以 `Invalid hook call`
|
||||
* 报错,那是提醒你把手势逻辑留在无 hook 的那一层。 */
|
||||
function hostEls(node: ReactNode): HostEl[] {
|
||||
const out: HostEl[] = [];
|
||||
const walk = (n: ReactNode): void => {
|
||||
if (n === null || n === undefined || typeof n === "boolean") return;
|
||||
if (typeof n === "string" || typeof n === "number") return;
|
||||
if (Array.isArray(n)) {
|
||||
n.forEach(walk);
|
||||
return;
|
||||
}
|
||||
const el = n as { type?: unknown; props?: Record<string, unknown> };
|
||||
const props = el.props ?? {};
|
||||
if (typeof el.type === "function") {
|
||||
walk((el.type as (p: Record<string, unknown>) => ReactNode)(props));
|
||||
return;
|
||||
}
|
||||
if (typeof el.type === "string") out.push({ type: el.type, props });
|
||||
walk(props.children as ReactNode);
|
||||
};
|
||||
walk(node);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 必须存在的东西(不存在就报错,而不是把 undefined 一路带下去)。 */
|
||||
function must<T>(found: T | undefined, what: string): T {
|
||||
checks += 1;
|
||||
if (found === undefined) throw new Error(`FAILED: ${what}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
/** 界面的替身:草稿存在这里,每次操作都从草稿重新投影(= `Users.tsx` 那条接线)。 */
|
||||
class Harness {
|
||||
draft: OwnerAccount[];
|
||||
|
||||
constructor(accounts: OwnerAccount[]) {
|
||||
this.draft = accounts;
|
||||
}
|
||||
|
||||
/** 当前这一步的元素树(宿主节点)。 */
|
||||
tree(): HostEl[] {
|
||||
const groups = groupAccounts(this.draft);
|
||||
return hostEls(
|
||||
createElement(OwnerAccountsView, {
|
||||
groups,
|
||||
options: platformOptions(PUBLISHED, groups.map((g) => g.platform)),
|
||||
channels: null,
|
||||
disabled: false,
|
||||
onChange: (next: OwnerAccount[]) => {
|
||||
this.draft = next;
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** 界面上能看见的字(「提示语说了什么」这类断言靠它)。 */
|
||||
text(): string {
|
||||
return this.tree()
|
||||
.map((e) => textOf(e.props.children as ReactNode))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
/** 点标签为 `label` 的第 `n` 个按钮(找不到就报错,不静默放过)。 */
|
||||
click(label: string, n = 0): void {
|
||||
const hits = this.tree().filter(
|
||||
(e) => e.type === "button" && textOf(e.props.children as ReactNode) === label
|
||||
);
|
||||
const hit = must(hits[n], `界面上第 ${n + 1} 个「${label}」按钮(共找到 ${hits.length} 个)`);
|
||||
(hit.props.onClick as () => void)();
|
||||
}
|
||||
|
||||
/** 账号 ID 行数(每行带定位标记)。 */
|
||||
rows(): number {
|
||||
return this.tree().filter((e) => e.type === "div" && e.props["data-account-row"] !== undefined)
|
||||
.length;
|
||||
}
|
||||
|
||||
selects(): number {
|
||||
return this.tree().filter((e) => e.type === "select").length;
|
||||
}
|
||||
|
||||
inputs(): HostEl[] {
|
||||
return this.tree().filter((e) => e.type === "input" && e.props.value !== undefined);
|
||||
}
|
||||
|
||||
/** 在第 `n` 行输入框里打字(走它自己的 onChange)。 */
|
||||
type(n: number, value: string): void {
|
||||
const el = must(this.inputs()[n], `第 ${n + 1} 个账号 ID 输入框`);
|
||||
(el.props.onChange as (e: unknown) => void)({ target: { value } });
|
||||
}
|
||||
|
||||
/** 在第 `n` 块里选平台(走它自己的 onChange)。 */
|
||||
pickPlatform(n: number, platform: string): void {
|
||||
const el = must(this.tree().filter((e) => e.type === "select")[n], `第 ${n + 1} 块平台下拉`);
|
||||
(el.props.onChange as (e: unknown) => void)({ target: { value: platform } });
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
// 起点 = 主人现在的真实样子:qq-obv11 下已有一个账号,他想再加一个。
|
||||
const ui = new Harness([{ platform: "qq-obv11", accountId: "9616b27a" }]);
|
||||
same(ui.rows(), 1, "起点:该平台一行账号 ID");
|
||||
same(ui.inputs().length, 1, "起点:一个账号 ID 输入框");
|
||||
|
||||
ui.click("+ 添加账号 ID");
|
||||
same(ui.rows(), 2, "点「+ 添加账号 ID」⇒ 界面上真的多出一行(空行没在往返里蒸发)");
|
||||
same(
|
||||
ui.draft,
|
||||
[
|
||||
{ platform: "qq-obv11", accountId: "9616b27a" },
|
||||
{ platform: "qq-obv11", accountId: "" },
|
||||
],
|
||||
"新行真的进了草稿(空的、挂在同一平台下)—— 不是「点了没点」"
|
||||
);
|
||||
same(
|
||||
must(ui.inputs()[1], "新行输入框").props.value,
|
||||
"",
|
||||
"新行是一个可输入的空输入框(接着就能打字)"
|
||||
);
|
||||
|
||||
// 在新行里打字 ⇒ 值进草稿、界面仍是两行:这条往返得是**可用**的,不是只能加不能填。
|
||||
ui.type(1, "aaaa1111");
|
||||
same(
|
||||
ui.draft,
|
||||
[
|
||||
{ platform: "qq-obv11", accountId: "9616b27a" },
|
||||
{ platform: "qq-obv11", accountId: "aaaa1111" },
|
||||
],
|
||||
"在新行里打的字落进草稿"
|
||||
);
|
||||
same(ui.rows(), 2, "打字后仍是两行(已填的值没在往返里丢)");
|
||||
ok(!ui.text().includes("有 1 条空白账号 ID"), "两行都填了就不再报空白(提示不自相矛盾)");
|
||||
|
||||
// 空行的既有提示语义保留:加了行还没填时,界面明说「保存时会被忽略」,不是静默丢弃。
|
||||
const hinted = new Harness([{ platform: "qq-obv11", accountId: "9616b27a" }]);
|
||||
hinted.click("+ 添加账号 ID");
|
||||
ok(
|
||||
hinted.text().includes("有 1 条空白账号 ID:保存时会被忽略"),
|
||||
"新加的空行有显式提示(保存时会被忽略)"
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
// 「+ 添加平台」走的是同一条往返(任务 3):点一下必须真的多出一块、块里带一行可填。
|
||||
const blank = new Harness([]);
|
||||
same(blank.rows(), 0, "起点:一个平台都没有");
|
||||
blank.click("+ 添加平台");
|
||||
same(
|
||||
blank.draft,
|
||||
[{ platform: "", accountId: "" }],
|
||||
"点「+ 添加平台」⇒ 草稿里真的多出一个未选平台的空组(不是「点了没点」)"
|
||||
);
|
||||
same(blank.selects(), 1, "界面上真的多出一块平台(下拉)");
|
||||
same(blank.rows(), 1, "这块里带一行可填的账号 ID");
|
||||
|
||||
blank.pickPlatform(0, "kook");
|
||||
same(blank.draft, [{ platform: "kook", accountId: "" }], "在块里选平台会落到草稿(块没在往返里蒸发)");
|
||||
blank.click("+ 添加账号 ID");
|
||||
same(
|
||||
blank.draft,
|
||||
[
|
||||
{ platform: "kook", accountId: "" },
|
||||
{ platform: "kook", accountId: "" },
|
||||
],
|
||||
"选完平台再加一行:两行都挂在 kook 下"
|
||||
);
|
||||
same(blank.rows(), 2, "界面上两行");
|
||||
|
||||
// 已知边界(**扁平草稿的固有性质**,不是本次缺陷):第二块「未选平台」的组会和第一块并成一块
|
||||
// —— `groupAccounts` 按平台聚合,两个空平台同键。行不会丢,只是同块(有下拉可接着选平台)。
|
||||
const two = new Harness([]);
|
||||
two.click("+ 添加平台");
|
||||
two.click("+ 添加平台");
|
||||
same(two.rows(), 2, "连点两次「+ 添加平台」:两行都在(空平台两块并成一块)");
|
||||
same(two.selects(), 1, "空平台两块并成一块下拉");
|
||||
}
|
||||
|
||||
{
|
||||
// 「删除某平台唯一一行」= 撤掉该平台(平台选择随之消失)。这是**修前就有**的语义,本次不动,
|
||||
// 但在这里显式钉住:日后若被当成新缺陷、或被顺手改掉,这条会先红。
|
||||
const one = new Harness([{ platform: "kook", accountId: "42" }]);
|
||||
one.click("删除");
|
||||
same(one.draft, [], "删掉唯一一行 ⇒ 该平台块整体消失(既有语义)");
|
||||
same(one.rows(), 0, "界面上也不再有这一块");
|
||||
same(one.selects(), 0, "平台下拉也一并撤掉");
|
||||
}
|
||||
|
||||
// ⑤ 收尾:把「手势刚产生的草稿」接到「写盘形态」上。草稿留着空行(上面钉住了),那保存时
|
||||
// 就必须有人把它收干净 —— 后端 `Validate()`(internal/user/load.go)对「平台空 / 账号 id 空」
|
||||
// 这种半成品条目**直接 400**(主人看到「保存失败」),只有完全空的条目才是跳过。
|
||||
{
|
||||
const half = new Harness([]);
|
||||
half.click("+ 添加平台");
|
||||
half.type(0, "9616b27a"); // 只填了 id,平台还没来得及选
|
||||
same(half.draft, [{ platform: "", accountId: "9616b27a" }], "草稿里这一行留着(编辑期不丢行)");
|
||||
same(accountsForSave(half.draft), [], "写盘时收掉半成品条目(不发给后端 ⇒ 不会 400)");
|
||||
|
||||
const filled = new Harness([{ platform: "qq-obv11", accountId: "9616b27a" }]);
|
||||
filled.click("+ 添加账号 ID");
|
||||
same(
|
||||
accountsForSave(filled.draft),
|
||||
[{ platform: "qq-obv11", accountId: "9616b27a" }],
|
||||
"写盘时收掉空行:owner.yaml 只拿到填完的条目"
|
||||
);
|
||||
filled.type(1, " aaaa1111 "); // 手滑多打了空格
|
||||
same(filled.draft[1].accountId, " aaaa1111 ", "编辑期不改写正在输入的字(逐键 trim 会吃掉空格)");
|
||||
same(
|
||||
accountsForSave(filled.draft),
|
||||
[
|
||||
{ platform: "qq-obv11", accountId: "9616b27a" },
|
||||
{ platform: "qq-obv11", accountId: "aaaa1111" },
|
||||
],
|
||||
"写盘时去掉两侧空白(带空格的键永远匹配不上 actor 元数据)"
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`owner accounts editor test: ${checks} checks passed`);
|
||||
@@ -12,7 +12,13 @@ import type { AdminChannelRecord, EnumItem, OwnerAccount } from "../../lib/types
|
||||
* (`owner.IsOwner` 匹配不上 actor 元数据 ⇒ 主人被当陌生人,界面上没有任何提示)。
|
||||
* 平台改为权威枚举下拉 + 账号 id 可从该平台已配置账号里选,消灭这类静默失效。
|
||||
*
|
||||
* 本模块是纯函数(无 DOM、无请求),因此分组/展平/下拉选项/候选都能在 node 下逐条测。
|
||||
* 本模块是纯函数(无 DOM、无请求),因此分组/草稿/写盘形态/下拉选项/候选都能在 node 下逐条测。
|
||||
*
|
||||
* **两种扁平形态,别混用**(这是主人 2026-10-07 报的「添加账号 ID 按钮似乎没用」的根因所在):
|
||||
* ① **草稿**(`draftAccounts`,编辑期):逐行原样保留,空行/未选平台的组都在 ——
|
||||
* 界面手势产生的中间态**绝不能在往返中蒸发**;
|
||||
* ② **写盘形态**(`accountsForSave`,保存期):只留平台与账号 id 都非空的条目。
|
||||
* 把①错当②用(编辑期就走写盘形态),空行当场消失 ⇒ 点了等于没点,界面上还看不出原因。
|
||||
*/
|
||||
|
||||
/** 一个平台分组:界面上的一条。 */
|
||||
@@ -22,7 +28,7 @@ export interface OwnerAccountGroup {
|
||||
}
|
||||
|
||||
/** 把扁平数组按平台分组(保持首次出现顺序,同一平台的多条账号收进一组)。 */
|
||||
export function groupAccounts(accounts: OwnerAccount[]): OwnerAccountGroup[] {
|
||||
export function groupAccounts(accounts: readonly OwnerAccount[]): OwnerAccountGroup[] {
|
||||
const groups: OwnerAccountGroup[] = [];
|
||||
const byPlatform = new Map<string, OwnerAccountGroup>();
|
||||
for (const a of accounts) {
|
||||
@@ -38,10 +44,27 @@ export function groupAccounts(accounts: OwnerAccount[]): OwnerAccountGroup[] {
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** 展平成后端契约要的扁平数组。**平台与账号 id 都非空才写**:任一为空都匹配不上任何
|
||||
* actor 元数据(写了也是噪声,还会让主人误以为已配置)。空的账号行/空平台组被忽略,
|
||||
* 界面上有对应提示,不是静默丢弃。 */
|
||||
export function flattenAccounts(groups: OwnerAccountGroup[]): OwnerAccount[] {
|
||||
/** 组 → **草稿**(编辑期形态):逐行原样保留 —— 空行、以及「还没选平台」的组都在。
|
||||
*
|
||||
* 为什么必须保行:草稿是**界面手势的载体** —— 每次改动都走「草稿 → 分组 → 改 → 回草稿」,
|
||||
* 写回若有损(把空行丢掉),刚点出来的「+ 添加账号 ID」空行当场蒸发 ⇒ 点了等于没点
|
||||
* (主人 2026-10-07 报的正是这个)。空行只在**保存时**才丢(见 `accountsForSave`),
|
||||
* 界面上另有「有 N 条空白账号 ID:保存时会被忽略」的显式提示,不是静默丢弃。
|
||||
*
|
||||
* 另外:编辑期**不改写**主人正在输入的字(不 trim)。逐键 trim 会把还在输入的空白吃掉;
|
||||
* 规范化(去空白 + 丢空行)留到写盘那一步做。 */
|
||||
export function draftAccounts(groups: readonly OwnerAccountGroup[]): OwnerAccount[] {
|
||||
const out: OwnerAccount[] = [];
|
||||
for (const g of groups) {
|
||||
for (const accountId of g.accountIds) out.push({ platform: g.platform, accountId });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 展平成后端契约要的扁平数组(**写盘形态**)。**平台与账号 id 都非空才写**:任一为空都
|
||||
* 匹配不上任何 actor 元数据(写了也是噪声,还会让主人误以为已配置)。空的账号行/空平台组
|
||||
* 被忽略,界面上有对应提示,不是静默丢弃。 */
|
||||
export function flattenAccounts(groups: readonly OwnerAccountGroup[]): OwnerAccount[] {
|
||||
const out: OwnerAccount[] = [];
|
||||
for (const g of groups) {
|
||||
const platform = g.platform.trim();
|
||||
@@ -54,6 +77,16 @@ export function flattenAccounts(groups: OwnerAccountGroup[]): OwnerAccount[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 草稿 → 写盘形态(`PUT /api/v1/admin/owner-profile` 的 `accounts`,即
|
||||
* `configs/users/owner.yaml` 的条目)。**保存路径只走它**,别自己拼这两步。
|
||||
*
|
||||
* 编辑期草稿里允许有空行,但后端 `Validate()` 对「平台空、账号 id 非空」这种半成品条目
|
||||
* 直接 400(`internal/user/load.go`),完全空的条目则被跳过 —— 所以这一步是必须的,不是洁癖。
|
||||
* 去掉两侧空白同理:带空格的键永远匹配不上 actor 元数据。 */
|
||||
export function accountsForSave(accounts: readonly OwnerAccount[]): OwnerAccount[] {
|
||||
return flattenAccounts(groupAccounts(accounts));
|
||||
}
|
||||
|
||||
/** 下拉选项:后端权威枚举(`GET /api/v1/meta/enums` 的 `platforms`)在前,随后补上
|
||||
* 存量里出现过的平台 —— 未列出的旧值**原样保留**(display-raw:绝不下拉吃掉它,
|
||||
* 否则一次保存就把主人已经生效的配置删了)。 */
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// 「插件与能力」页按上游 server 分组的**纯逻辑**测试(DevDocs/audit/MCP工具按上游分组-2026-10-24.md)。
|
||||
//
|
||||
// 锁四件事:
|
||||
// ① **归属只看 origin,不看名字**:MCP 工具名里没有任何前缀(`mcp:` 是 KindMCP
|
||||
// 条目的注册键),所以判据必须是后端盖的章 —— 本测试给工具起一个带 `mcp:` 字样的
|
||||
// 名字但**无 origin**,它必须落进「宿主内置」;
|
||||
// ② **transport 不编**:后端没报告 transport 时标题省略括号,而不是假设 stdio;
|
||||
// ③ **空 server 不冒充内置**:报告了 mcp 来源却没说哪台 server 的工具,仍归 MCP 段
|
||||
// (标题写「未知 MCP server」),不能被并进「宿主内置」—— 那是不同的结论;
|
||||
// ④ **不得静默丢弃**:`kind:"mcp"` 登记条目与未知 kind(如 iot)都有专门的取值口。
|
||||
//
|
||||
// 项目没有前端测试框架,所以这是一个零依赖的 plain-node 检查(同目录既有手法):
|
||||
//
|
||||
// cd frontend/web \
|
||||
// && ./node_modules/.bin/esbuild src/admin/modules/pluginOrigin.test.ts \
|
||||
// --bundle --platform=node --format=cjs \
|
||||
// --outfile=/tmp/cyrene-plugin-origin.test.cjs \
|
||||
// && node /tmp/cyrene-plugin-origin.test.cjs
|
||||
//
|
||||
// `tsc --noEmit`(npm run build 的一部分)会对本文件做类型检查,因此它只从项目内导入、
|
||||
// 不 import node:*。
|
||||
|
||||
import type { AdminPluginRecord } from "../../lib/types";
|
||||
import {
|
||||
mcpServerEntries,
|
||||
originGroupTitle,
|
||||
otherCapabilityEntries,
|
||||
toolOriginBuckets,
|
||||
} from "./pluginOrigin";
|
||||
|
||||
let checks = 0;
|
||||
|
||||
function ok(cond: boolean, what: string): void {
|
||||
checks += 1;
|
||||
if (!cond) throw new Error(`FAILED: ${what}`);
|
||||
}
|
||||
|
||||
function same(got: unknown, want: unknown, what: string): void {
|
||||
const g = JSON.stringify(got);
|
||||
const w = JSON.stringify(want);
|
||||
ok(g === w, `${what} — got ${g}, want ${w}`);
|
||||
}
|
||||
|
||||
/** 造一条能力记录(origin 只在给定时出现,模拟后端 omitempty)。 */
|
||||
function rec(
|
||||
name: string,
|
||||
kind: string,
|
||||
origin?: { kind: string; server?: string; transport?: string },
|
||||
): AdminPluginRecord {
|
||||
const base: AdminPluginRecord = { name, kind, description: "", allowInGroup: false, allowInStranger: false };
|
||||
return origin ? { ...base, origin } : base;
|
||||
}
|
||||
|
||||
// ① 两个 server 各两把工具 + 两把内置工具(其中一把名字里带 mcp: 字样的假前缀)。
|
||||
const rows: AdminPluginRecord[] = [
|
||||
rec("read_file", "tool", { kind: "mcp", server: "fs", transport: "stdio" }),
|
||||
rec("write_file", "tool", { kind: "mcp", server: "fs", transport: "stdio" }),
|
||||
rec("search", "tool", { kind: "mcp", server: "github", transport: "http" }),
|
||||
rec("mcp:read_file", "tool"), // 名字像 MCP,但没有来源章 ⇒ 内置
|
||||
rec("web_search", "tool"),
|
||||
];
|
||||
|
||||
const buckets = toolOriginBuckets(rows);
|
||||
|
||||
// ① 归属只看 origin:两个 MCP 分节(按 server 名排序),其余归内置。
|
||||
same(
|
||||
buckets.mcp.map((g) => [g.server, g.transport, g.rows.map((r) => r.name)]),
|
||||
[
|
||||
["fs", "stdio", ["read_file", "write_file"]],
|
||||
["github", "http", ["search"]],
|
||||
],
|
||||
"MCP 工具按 origin.server 分节(按 server 名排序)",
|
||||
);
|
||||
same(
|
||||
buckets.builtin.map((r) => r.name),
|
||||
["mcp:read_file", "web_search"],
|
||||
"无 origin 的归宿主内置 —— 名字里带 mcp: 不算来源",
|
||||
);
|
||||
|
||||
// ② 标题:transport 有则带括号,无则省略(不编 stdio);计数是「把工具」。
|
||||
same(originGroupTitle(buckets.mcp[0]), "来自 fs(stdio)· 2 把工具", "带 transport 的组标题");
|
||||
same(
|
||||
originGroupTitle({ key: "mcp:x", server: "x", transport: "", rows: [rec("a", "tool")] }),
|
||||
"来自 x · 1 把工具",
|
||||
"未报告 transport 时省略括号,不编默认值",
|
||||
);
|
||||
|
||||
// ③ 报告了 mcp 却没说哪台 server:仍属 MCP 段,不得冒充内置。
|
||||
const anonymous = toolOriginBuckets([rec("orphan_tool", "tool", { kind: "mcp" }), rec("web_search", "tool")]);
|
||||
same(anonymous.mcp.length, 1, "缺 server 名的 MCP 工具仍是 MCP 段的一员");
|
||||
same(anonymous.mcp[0].server, "", "缺 server 名时 server 留空,不编一个名字");
|
||||
same(originGroupTitle(anonymous.mcp[0]), "来自 未知 MCP server · 1 把工具", "缺 server 名的标题如实写未知");
|
||||
same(anonymous.builtin.map((r) => r.name), ["web_search"], "内置桶只装真的没有 mcp 来源的工具");
|
||||
|
||||
// ④ 不得静默丢弃:KindMCP 登记条目与未知 kind 各有取值口。
|
||||
const mixed: AdminPluginRecord[] = [
|
||||
rec("read_file", "tool", { kind: "mcp", server: "fs", transport: "stdio" }),
|
||||
rec("mcp:fs", "mcp"),
|
||||
rec("kb_search", "skill"),
|
||||
rec("lamp", "iot"),
|
||||
rec("kook", "channel"),
|
||||
];
|
||||
same(mcpServerEntries(mixed).map((r) => r.name), ["mcp:fs"], "kind:mcp 登记条目要能被页面拿到");
|
||||
same(otherCapabilityEntries(mixed).map((r) => r.name), ["lamp"], "未知 kind(iot)不得被静默过滤");
|
||||
same(toolOriginBuckets(mixed).builtin.length, 0, "只有工具行进工具桶(技能/渠道/登记条目都不进)");
|
||||
|
||||
// 空输入不炸,且三个出口都是空数组而不是 undefined。
|
||||
const empty = toolOriginBuckets([]);
|
||||
same([empty.mcp.length, empty.builtin.length], [0, 0], "空清单得到空结果");
|
||||
|
||||
console.log(`pluginOrigin: ${checks} checks passed`);
|
||||
@@ -0,0 +1,93 @@
|
||||
// 「插件与能力」页的**来源分组**逻辑(纯函数、零 React 依赖)。
|
||||
//
|
||||
// 主人 2026-10-24 的要求:「能否将每个 MCP 服务器所承载的插件和工具按上游(来源)服务器
|
||||
// 分类并与其他插件相对隔离?」结论(DevDocs/audit/MCP工具按上游分组-2026-10-24.md):
|
||||
// 归属**只能来自注册时的元数据**(`origin`),不能解析工具名 —— MCP 工具名由 server
|
||||
// 自己给,宿主不加任何前缀(`mcp:` 是 KindMCP 条目的注册键,不是工具的命名空间)。
|
||||
//
|
||||
// 因此本模块只做三件事:
|
||||
// ① 把 `kind:"tool"` 的行按 `origin.server` 切成分节,无 MCP 来源的归「宿主内置」;
|
||||
// ② 生成分节标题(`来自 fs(stdio)· 12 把工具`)——transport 未报告时省略括号,
|
||||
// 绝不编一个默认值;
|
||||
// ③ 把不再是「工具/技能/渠道」行的事实摆出来(`mcp` 登记条目、其它未知 kind),
|
||||
// 供页面**如实显示**而不是静默丢弃(design/09 §5.2.1 红线③)。
|
||||
//
|
||||
// 这里判的是「呈现归属」,不涉及权限:工具能不能被调用仍只由 tool_policies 的逐工具
|
||||
// 布尔 + actor 元数据决定,分组既不收窄也不放宽任何权限。
|
||||
|
||||
import type { AdminPluginRecord } from "../../lib/types";
|
||||
|
||||
/** 一个上游 MCP server 承载的工具分节。 */
|
||||
export interface ToolOriginGroup {
|
||||
/** React key:同一 server 的所有工具共用一个。 */
|
||||
key: string;
|
||||
/** server 名(plugins.json / MCP_SERVERS_JSON 的 name);空串 = 报告了 MCP 却没说哪台。 */
|
||||
server: string;
|
||||
/** stdio / sse / http;空串 = 后端未报告。 */
|
||||
transport: string;
|
||||
rows: AdminPluginRecord[];
|
||||
}
|
||||
|
||||
/** 工具段的两级分节结果。 */
|
||||
export interface ToolOriginBuckets {
|
||||
/** 按上游 server 分节(按 server 名排序);只含来自 MCP 的工具。 */
|
||||
mcp: ToolOriginGroup[];
|
||||
/** 没有 MCP 来源的工具:宿主内置 / 来源未知。 */
|
||||
builtin: AdminPluginRecord[];
|
||||
}
|
||||
|
||||
/** 页面自己成段渲染的 kind;其余 kind 必须落到「其它能力」段,不得静默丢弃。 */
|
||||
export const RENDERED_KINDS = ["tool", "skill", "channel", "mcp"] as const;
|
||||
|
||||
/** record 是否来自一台 MCP server(判据是后端盖的来源章,不是名字)。 */
|
||||
export function isMCPOrigin(rec: AdminPluginRecord): boolean {
|
||||
return rec.origin?.kind === "mcp";
|
||||
}
|
||||
|
||||
/** 把工具行按上游 server 分节;无 MCP 来源的归「宿主内置」桶。
|
||||
*
|
||||
* 只收 `kind === "tool"` 的行:技能/渠道/MCP 登记条目由页面自己成段渲染(见
|
||||
* `RENDERED_KINDS` 与另两个取值口),它们**不是**工具、也不该被算进任何一个工具分节。
|
||||
* 传整份清单进来是安全的 —— 忽略 ≠ 丢弃,其余 kind 在页面上各有位置。 */
|
||||
export function toolOriginBuckets(rows: AdminPluginRecord[]): ToolOriginBuckets {
|
||||
const groups = new Map<string, ToolOriginGroup>();
|
||||
const builtin: AdminPluginRecord[] = [];
|
||||
for (const rec of rows) {
|
||||
if (rec.kind !== "tool") continue;
|
||||
if (!isMCPOrigin(rec)) {
|
||||
builtin.push(rec);
|
||||
continue;
|
||||
}
|
||||
const server = (rec.origin?.server ?? "").trim();
|
||||
const transport = (rec.origin?.transport ?? "").trim();
|
||||
const key = `mcp:${server}`;
|
||||
const group = groups.get(key);
|
||||
if (group) {
|
||||
group.rows.push(rec);
|
||||
// 同一 server 的传输形态应当一致;万一不一致,保留先到的那个而不是拼接,
|
||||
// 免得标题读成「stdio/sse」(拼接会让人以为一台 server 同时两种传输)。
|
||||
continue;
|
||||
}
|
||||
groups.set(key, { key, server, transport, rows: [rec] });
|
||||
}
|
||||
const mcp = [...groups.values()].sort((a, b) => (a.server < b.server ? -1 : a.server > b.server ? 1 : 0));
|
||||
builtin.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
||||
return { mcp, builtin };
|
||||
}
|
||||
|
||||
/** 分节标题:`来自 fs(stdio)· 12 把工具`;transport / server 缺失时如实省略。 */
|
||||
export function originGroupTitle(group: ToolOriginGroup): string {
|
||||
const count = `${group.rows.length} 把工具`;
|
||||
const server = group.server || "未知 MCP server";
|
||||
return group.transport ? `来自 ${server}(${group.transport})· ${count}` : `来自 ${server} · ${count}`;
|
||||
}
|
||||
|
||||
/** `kind:"mcp"` 的登记条目(每台 server 一条,名字是 `mcp:<server>`)。 */
|
||||
export function mcpServerEntries(rows: AdminPluginRecord[]): AdminPluginRecord[] {
|
||||
return rows.filter((rec) => rec.kind === "mcp");
|
||||
}
|
||||
|
||||
/** 既不是工具/技能/渠道、也不是 MCP 登记条目的能力(如 iot):必须如实显示。 */
|
||||
export function otherCapabilityEntries(rows: AdminPluginRecord[]): AdminPluginRecord[] {
|
||||
return rows.filter((rec) => !(RENDERED_KINDS as readonly string[]).includes(rec.kind));
|
||||
}
|
||||
@@ -155,3 +155,12 @@ export {
|
||||
adminTidyCandidatesApplyBatch,
|
||||
adminTidyRun,
|
||||
} from "./api/adminMemoryTidyApply";
|
||||
export {
|
||||
adminMemoryTidyTuning,
|
||||
adminMemoryTidyTuningSave,
|
||||
} from "./api/adminMemories";
|
||||
export type {
|
||||
AdminMemoryTidyTuningPatch,
|
||||
AdminMemoryTidyTuningSpec,
|
||||
AdminMemoryTidyTuningView,
|
||||
} from "./api/adminMemories";
|
||||
@@ -0,0 +1,75 @@
|
||||
import { request } from "./core";
|
||||
|
||||
/* ----------------------------------------------------------------------------
|
||||
* 记忆整理**面板调优**(切片 MEMPANEL / M4)。真源:
|
||||
* internal/services/gateway/admin_memory_tuning.go(形状与错误码)
|
||||
* internal/services/gateway/admin_memory_tuning_file.go(取值规则 specs)
|
||||
*
|
||||
* GET /admin/memories/tidy/tuning 读:盘上 7 键值 + 取值规则
|
||||
* PUT /admin/memories/tidy/tuning 写:三态补丁(键缺位 = 不改动 / 合法值 = 采用 /
|
||||
* 非法值 = 400 且盘上不动;`null` = 清掉该键)
|
||||
*
|
||||
* 两条都是 writeAuth(owner|admin)——与同族的 `/admin/memories` 同档:它是私有记忆面
|
||||
* 的运行期旋钮,不是给 viewer 的监控投影。
|
||||
*
|
||||
* ⚠️ 失败走 `error.code`,**绝不**回一份「已保存」的假成功:
|
||||
* - `invalid_payload` 400:值非法 / 未知键(**盘上一个字节都没动**)
|
||||
* - `tuning_corrupt` 409:盘上那份配置文件读不走 ⇒ 拒绝覆盖(要人工修/删)
|
||||
* - `tuning_unavailable` 500:数据目录未接线
|
||||
* - `tuning_write_failed` 500:落盘失败(盘上内容未改动)
|
||||
*
|
||||
* 响应里的 `hot` 是**如实**的:true = 保存后当场生效;false = 已落盘,但运行期没接到
|
||||
* 可热生效的实现(`hotError` 说明原因),此时界面必须写「重启后端后生效」。
|
||||
* -------------------------------------------------------------------------- */
|
||||
|
||||
/** 调的落点(面板配置文件的**相对**路径,形如 `config/memory-tidy.json`)。 */
|
||||
const TUNING_PATH = "/admin/memories/tidy/tuning";
|
||||
|
||||
/** 一个可写键的取值规则(后端下发,面板不另抄一份枚举/上下界)。 */
|
||||
export interface AdminMemoryTidyTuningSpec {
|
||||
/** 键名,如 `intervalSec` / `ingestGate`。 */
|
||||
key: string;
|
||||
/** `int` | `bool`(面板据此选控件类型)。 */
|
||||
kind: string;
|
||||
/** `sec` | `hour`(仅 int 键;面板据此写单位)。 */
|
||||
unit?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
/** 缺位时生效值从哪来:`env:MEMORY_TIDY_*` / `panel:<key>` / `code-default`。 */
|
||||
fallback: string;
|
||||
}
|
||||
|
||||
/** GET / PUT 的 data(PUT 的响应是**重新读盘**的结果,即真值回显)。 */
|
||||
export interface AdminMemoryTidyTuningView {
|
||||
/** 面板配置文件相对 `$DATA_DIR` 的路径。 */
|
||||
path: string;
|
||||
/** 文件是否已存在且有内容(false = 还没播种,后端下次启动会自动创建)。 */
|
||||
present: boolean;
|
||||
/** 本次结果是否**当场生效**。false = 已落盘,要等后端重启(见 hotError)。 */
|
||||
hot: boolean;
|
||||
/** `hot:false` 时的原因。 */
|
||||
hotError?: string;
|
||||
specs: AdminMemoryTidyTuningSpec[];
|
||||
/** **盘上**的值:键缺位 ⇒ 该键不出现(= 跟随兜底),不是 0。 */
|
||||
values: Record<string, number | boolean>;
|
||||
/** 缺位时**下一档(`.env`)**的进程生效值(与 /admin/config-facts 同源)。 */
|
||||
envValues?: Record<string, number>;
|
||||
}
|
||||
|
||||
/** 写补丁:只放**真的要改**的键;`null` = 清掉该键(回到下一档兜底)。 */
|
||||
export type AdminMemoryTidyTuningPatch = Record<string, number | boolean | null>;
|
||||
|
||||
/** GET /api/v1/admin/memories/tidy/tuning —— 读盘上的调优值 + 取值规则。 */
|
||||
export function adminMemoryTidyTuning(): Promise<AdminMemoryTidyTuningView> {
|
||||
return request<AdminMemoryTidyTuningView>(TUNING_PATH);
|
||||
}
|
||||
|
||||
/** PUT /api/v1/admin/memories/tidy/tuning —— 三态补丁写盘(并尽量热生效)。 */
|
||||
export function adminMemoryTidyTuningSave(
|
||||
patch: AdminMemoryTidyTuningPatch
|
||||
): Promise<AdminMemoryTidyTuningView> {
|
||||
return request<AdminMemoryTidyTuningView>(TUNING_PATH, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
}
|
||||
@@ -8,6 +8,17 @@ export interface MemoryRecord {
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** 一条能力的上游来源(GET /api/v1/admin/plugins 的可选 `origin` 键,契约 v0.83)。
|
||||
*
|
||||
* 它回答「这条能力来自哪台上游 server」,来自**注册时的元数据**——绝不是从
|
||||
* `name` 里解析前缀得来的(MCP 工具名里没有任何前缀)。宿主内置能力的该键
|
||||
* **整个缺席**(不是 `{}`),前端据此把它们归「宿主内置」。 */
|
||||
export interface CapabilityOrigin {
|
||||
kind: string;
|
||||
server?: string;
|
||||
transport?: string;
|
||||
}
|
||||
|
||||
/** A capability/plugin as returned by GET /api/v1/admin/plugins. */
|
||||
export interface AdminPluginRecord {
|
||||
name: string;
|
||||
@@ -15,6 +26,8 @@ export interface AdminPluginRecord {
|
||||
description: string;
|
||||
allowInGroup: boolean;
|
||||
allowInStranger: boolean;
|
||||
/** 上游来源;缺席 = 宿主内置 / 来源未知(老后端同样缺席)。 */
|
||||
origin?: CapabilityOrigin;
|
||||
}
|
||||
|
||||
/** One runtime-installed plugin (GET /api/v1/admin/plugins/installed, v0.28).
|
||||
@@ -357,6 +370,13 @@ export interface OwnerProfile {
|
||||
defaultPersona: string;
|
||||
identityFile: string;
|
||||
notes: string;
|
||||
/**
|
||||
* Channel account whitelist. While the panel is being edited this array is a **draft**: a row may
|
||||
* legitimately be blank (`accountId: ""`, or no platform picked yet) because every UI gesture
|
||||
* round-trips through it — dropping blank rows here makes a freshly added row disappear, i.e. the
|
||||
* button looks dead. Only the save path narrows it (`accountsForSave`), so owner.yaml and the PUT
|
||||
* contract keep carrying entries with both `platform` and `accountId` set.
|
||||
*/
|
||||
accounts: OwnerAccount[];
|
||||
}
|
||||
export interface OwnerAccount {
|
||||
|
||||
@@ -119,6 +119,18 @@ func portListening(ctx context.Context, port int) bool {
|
||||
// 侧看到的工具结果(`browserError`)来自这条错误 —— 不上报原话,用户就只看到我们这一侧的猜测
|
||||
// (真机踩过:窗口上跳出浏览器的抱怨,日志里像浏览器坏了,工具结果里什么都没有)。
|
||||
// 没有输出时也如实点明,那本身是线索(参数被拒之类通常会带话)。
|
||||
//
|
||||
// 拼接后整条都是**调用方可见**的,因此两半各自的出镜口径必须交代清楚:
|
||||
//
|
||||
// - `preview` 是浏览器可控的自由文本 ⇒ 已在 preview() 里过了 chromeOutputPreview
|
||||
// (凭据形状打码 + 600 rune 上限)。这里**不再**加一道带上限的守卫:同一份上限套在
|
||||
// 整条上,会把下面这条错误自己的**可操作**建议截掉(`profileBusyHint` 里带 profileDir
|
||||
// 路径,adopt_test 钉着它必须可见);
|
||||
// - `err` 那一半是我们自己拼的(ErrLaunch 分类标记、endpointURL 回环地址、两条 hint 里
|
||||
// 的文件系统路径),**不含对端文本** —— 对端说的话一律经 preview 走,没有第二条入口。
|
||||
//
|
||||
// 换言之:整条被覆盖,靠的是「对端文本只有一个入口(preview)」这个结构约束,而不是在
|
||||
// 拼接处再洗一遍。
|
||||
func withChromeOutput(err error, preview string) error {
|
||||
if preview == "" {
|
||||
return fmt.Errorf("%w;浏览器没有输出任何内容", err)
|
||||
|
||||
@@ -5,10 +5,13 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// cdpCallTimeout 是单条 CDP 命令的兜底超时(handshake / 读超时)。正常调用还会被
|
||||
@@ -30,8 +33,28 @@ type cdpError struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// Error 实现 error 接口。
|
||||
func (e *cdpError) Error() string { return fmt.Sprintf("CDP %d: %s", e.Code, e.Message) }
|
||||
// Error 渲染协议错误(实现 error 接口)。
|
||||
//
|
||||
// 🔴 Message 是**上游自由文本**:Chrome 与各类兼容实现可以往里面放任何东西 —— 包括
|
||||
// 它刚被要求操作的表达式、地址或页面内容。原样进错误等于把那段内容抄进日志与模型
|
||||
// 可见视图,因此默认**不出镜**:
|
||||
// - 只有像协议枚举的短串(trace.ErrorToken:≤64 字节、仅 [A-Za-z0-9_.-])才保留
|
||||
// —— 真正的枚举(context_not_found 之类)能过,散文与回显一律过不了;
|
||||
// - 其余只留**长度**(字节数),够判断「对端给了句话」而不带那句话。
|
||||
//
|
||||
// 方法名由调用点补齐(cdpClient.call / Call 的 %w 文案),诊断面并不只剩一个码。
|
||||
func (e *cdpError) Error() string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "CDP %d", e.Code)
|
||||
switch token := trace.ErrorToken(e.Message); {
|
||||
case token != "":
|
||||
b.WriteString(": ")
|
||||
b.WriteString(token)
|
||||
case e.Message != "":
|
||||
fmt.Fprintf(&b, " (message %d bytes withheld)", len(e.Message))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// cdpClient 是最小 CDP 客户端:一条 WebSocket、一个读协程、按 id 关联的待答表。
|
||||
//
|
||||
|
||||
@@ -3,6 +3,7 @@ package browser
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -105,11 +106,23 @@ func TestCDPClient_CallRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCDPClient_ProtocolErrorIsWrapped 钉住协议错误被 %w 包成 ErrCDP(上层据此
|
||||
// 判定「连接级问题 ⇒ 丢会话重起」)。
|
||||
func TestCDPClient_ProtocolErrorIsWrapped(t *testing.T) {
|
||||
// TestCDPClient_ProtocolErrorKeepsNoFreeText 钉住两件事:协议错误被 %w 包成 ErrCDP
|
||||
// (上层据此判定「连接级问题 ⇒ 丢会话重起」),而 CDP 的 error.message **不出镜**。
|
||||
//
|
||||
// 为什么 message 不能出镜:它是**上游自由文本** —— Chrome 与各类兼容实现可以往里面
|
||||
// 放它刚被要求操作的东西(表达式、地址、页面内容)。只有像协议枚举的短串
|
||||
// (trace.ErrorToken)才保留,其余只留字节数。
|
||||
//
|
||||
// 夹具刻意用**带空格的散文 + 会被回显的页面文本**:真正的 CDP message 就长这样
|
||||
// ("Cannot find context with specified id"),而像令牌的单字("boom")本来就会过
|
||||
// 守卫 —— 用例不该拿它当「自由文本」。
|
||||
//
|
||||
// 变异判据:把 cdpError.Error() 换回 `fmt.Sprintf("CDP %d: %s", e.Code, e.Message)`
|
||||
// ⇒ 哨兵文本出现在错误串里,本用例红。
|
||||
func TestCDPClient_ProtocolErrorKeepsNoFreeText(t *testing.T) {
|
||||
const message = "Cannot resolve context for <script>PAGE-ECHO-SECRET</script>"
|
||||
f := newFakeCDP(t, func(string, map[string]any) (any, *cdpError) {
|
||||
return nil, &cdpError{Code: -32000, Message: "boom"}
|
||||
return nil, &cdpError{Code: -32000, Message: message}
|
||||
})
|
||||
wsURL, _ := pageTargetWS(context.Background(), f.base)
|
||||
c, err := newCDPClient(context.Background(), wsURL)
|
||||
@@ -118,14 +131,47 @@ func TestCDPClient_ProtocolErrorIsWrapped(t *testing.T) {
|
||||
}
|
||||
defer func() { _ = c.Close() }()
|
||||
err = c.Call(context.Background(), "Runtime.evaluate", nil, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "boom") {
|
||||
t.Fatalf("Call 的错误 = %v,应含 CDP 的 message", err)
|
||||
if err == nil {
|
||||
t.Fatal("协议错误应当返回 error")
|
||||
}
|
||||
if strings.Contains(err.Error(), "PAGE-ECHO-SECRET") {
|
||||
t.Fatalf("CDP 的自由文本进了错误串:%v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "CDP -32000") {
|
||||
t.Errorf("错误没带上协议码: %v", err)
|
||||
}
|
||||
if want := fmt.Sprintf("message %d bytes withheld", len(message)); !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("错误没带上被扣下文本的长度(期望 %q): %v", want, err)
|
||||
}
|
||||
// 方法名是代码给的名字,必须仍在 —— 扣掉 message 之后诊断面靠它定位。
|
||||
if !strings.Contains(err.Error(), "Runtime.evaluate") {
|
||||
t.Errorf("错误丢了方法名: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "CDP 调用失败") {
|
||||
t.Errorf("错误没带上 ErrCDP 文案: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCDPClient_ProtocolErrorKeepsProtocolToken 是「协议枚举仍可读」的正向证据:
|
||||
// 取值像令牌(≤64 字节、仅 [A-Za-z0-9_.-])的 message 原样保留。
|
||||
//
|
||||
// 变异判据:把 cdpError.Error() 改成「message 一律不带」⇒ 本用例红。
|
||||
func TestCDPClient_ProtocolErrorKeepsProtocolToken(t *testing.T) {
|
||||
f := newFakeCDP(t, func(string, map[string]any) (any, *cdpError) {
|
||||
return nil, &cdpError{Code: -32602, Message: "context_not_found"}
|
||||
})
|
||||
wsURL, _ := pageTargetWS(context.Background(), f.base)
|
||||
c, err := newCDPClient(context.Background(), wsURL)
|
||||
if err != nil {
|
||||
t.Fatalf("newCDPClient: %v", err)
|
||||
}
|
||||
defer func() { _ = c.Close() }()
|
||||
err = c.Call(context.Background(), "Runtime.evaluate", nil, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "context_not_found") {
|
||||
t.Fatalf("协议枚举形状的 message 应保留:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCDPClient_ContextCancelStopsWaiting 钉住「取消能立刻返回」,不是等对端回话。
|
||||
func TestCDPClient_ContextCancelStopsWaiting(t *testing.T) {
|
||||
f := newFakeCDP(t, func(string, map[string]any) (any, *cdpError) {
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package browser
|
||||
|
||||
// 本文件钉住「浏览器自己说的话」这条自由文本出口的两条约束 —— 既**有界**(600 rune +
|
||||
// 12 行)又**打码**(凭据形状不外泄),而且两者都**不许**伤到排障信息:普通原话必须仍然
|
||||
// 看得见(这是 launch_test / adopt_test 既有的「失败要如实报」口径)。
|
||||
//
|
||||
// 「删实现能打红」的落点:把 preview() 里的 chromeOutputPreview 调用退回原样直吐,本文件
|
||||
// 的凭据断言(以及日志读面那条)会全红。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// TestChromeOutputPreviewRedactsCredentials 是这条出口的**核心断言**:浏览器把带凭据的
|
||||
// 地址/请求头打进 stderr 时,出镜的预览里只有 [redacted],没有凭据本体;同时排障用的
|
||||
// 普通原话一个字都不许少。
|
||||
func TestChromeOutputPreviewRedactsCredentials(t *testing.T) {
|
||||
t.Parallel()
|
||||
const (
|
||||
complaint = "ERROR:process_singleton.cc(343)] 参数被拒: --remote-allow-origins=*"
|
||||
credURL = "ERROR:navigation] 载入失败 https://example.com/cb?access_token=sk-live-ABCDEFGH12345"
|
||||
bearer = "WARNING:net] 请求头 Authorization: Bearer abcdefghij1234567890"
|
||||
)
|
||||
var out chromeOutput
|
||||
for _, line := range []string{complaint, credURL, bearer} {
|
||||
if _, err := out.Write([]byte(line + "\n")); err != nil {
|
||||
t.Fatalf("chromeOutput.Write 永不返回错误: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
got := out.preview()
|
||||
t.Logf("实测预览=%q", got)
|
||||
|
||||
for _, secret := range []string{"sk-live-ABCDEFGH12345", "abcdefghij1234567890", "access_token="} {
|
||||
if strings.Contains(got, secret) {
|
||||
t.Errorf("凭据形状 %q 原样出镜了: %q", secret, got)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(got, "[redacted]") {
|
||||
t.Errorf("凭据应被打成 [redacted](看得见的打码,不是静默删除): %q", got)
|
||||
}
|
||||
// 反面:打码不能顺手把「浏览器为什么拒绝」一起吃掉 —— 那正是这条出口存在的理由。
|
||||
for _, want := range []string{complaint, "载入失败", "请求头 Authorization"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("排障用的普通原话 %q 不该被洗掉: %q", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestChromeOutputPreviewIsBounded 钉住上限的两半:正文被截到 chromeOutputPreviewRunes
|
||||
// 并留 `…`;而「其实说了多少字节」这条**我们自己的**说明必须活着(上限只压正文,不吃
|
||||
// 量级)。没有它,读者会把半截输出误当成完整输出。
|
||||
func TestChromeOutputPreviewIsBounded(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("单行超长只留上限并标截断", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var out chromeOutput
|
||||
if _, err := out.Write([]byte(strings.Repeat("A", 4000) + "\n")); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
got := out.preview()
|
||||
if !strings.HasSuffix(got, "…") {
|
||||
t.Errorf("超上限必须留可见的截断标记: 长度=%d 尾部=%q", utf8.RuneCountInString(got), tailRunes(got, 8))
|
||||
}
|
||||
if n := utf8.RuneCountInString(got); n != chromeOutputPreviewRunes+1 {
|
||||
t.Errorf("正文应正好截到 %d rune + 一个 `…`,实际 %d rune", chromeOutputPreviewRunes, n)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("字节截断时仍报总量", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var out chromeOutput
|
||||
// 5000 字节 > chromeOutputMaxBytes(4096)⇒ 缓存本身就被截,preview 必须点明总量。
|
||||
if _, err := out.Write([]byte(strings.Repeat("B", 5000))); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
got := out.preview()
|
||||
if !strings.Contains(got, "已截断,共 5000 字节") {
|
||||
t.Errorf("字节被截时必须报出真实总量(不能拿留下的那部分当全部): %q", tailRunes(got, 40))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestDial_ChromeOutputOnFailureIsRedacted 走真实失败路径(假浏览器 exit 0 ⇒
|
||||
// profileBusyHint + withChromeOutput),断言**拼接后的整条**(hint 那半 + 浏览器原话那半)
|
||||
// 在用户侧可见的错误串与失败日志里都没有凭据形状,且两半的信息都还在。
|
||||
func TestDial_ChromeOutputOnFailureIsRedacted(t *testing.T) {
|
||||
t.Parallel()
|
||||
const (
|
||||
complaint = "不支持的标记: --remote-allow-origins=*"
|
||||
credLine = "载入失败 https://example.com/cb?access_token=sk-live-ABCDEFGH12345"
|
||||
bearerLine = "请求头 Authorization: Bearer abcdefghij1234567890"
|
||||
)
|
||||
log, buf := newCapturedLog(slog.LevelDebug)
|
||||
l := newFakeChrome(t, fakeBrowserScript{
|
||||
Stderr: []string{complaint, credLine, bearerLine}, ExitCode: 0,
|
||||
}, 5*time.Second, log)
|
||||
|
||||
_, stop, err := l.Dial(context.Background())
|
||||
if stop != nil {
|
||||
t.Error("失败路径不该交出停止函数")
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("进程 exit 0 却不写端口记录 ⇒ 必须报错")
|
||||
}
|
||||
if !errors.Is(err, ErrLaunch) {
|
||||
t.Fatalf("仍须包住 ErrLaunch: %v", err)
|
||||
}
|
||||
msg := err.Error()
|
||||
logs := buf.String()
|
||||
t.Logf("实测 error=%v\n实测日志=%s", err, logs)
|
||||
|
||||
for _, secret := range []string{"sk-live-ABCDEFGH12345", "abcdefghij1234567890", "access_token="} {
|
||||
if strings.Contains(msg, secret) {
|
||||
t.Errorf("返回给调用方的错误里出现了凭据形状 %q(用户侧工具结果直接来自这条串): %v", secret, err)
|
||||
}
|
||||
if strings.Contains(logs, secret) {
|
||||
t.Errorf("失败日志里出现了凭据形状 %q(密钥不落日志):\n%s", secret, logs)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(msg, "[redacted]") {
|
||||
t.Errorf("打码应当看得见([redacted]): %v", err)
|
||||
}
|
||||
|
||||
// 两半都得活着:hint 那半(可操作建议 + profileDir 路径)不能被上限截掉,
|
||||
// 浏览器原话那半是这条出口存在的理由。
|
||||
for _, want := range []string{
|
||||
"绝不杀别人的浏览器", l.profileDir,
|
||||
complaint, "载入失败",
|
||||
} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("整条错误里缺少 %q(过度打码/截断): %v", want, err)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(logs, complaint) {
|
||||
t.Errorf("失败日志里丢了浏览器原话 %q(排障信息):\n%s", complaint, logs)
|
||||
}
|
||||
}
|
||||
|
||||
// tailRunes 返回 s 的末 n 个 rune,供失败断言打印尾部而不是把 4000 字符灌进报告。
|
||||
func tailRunes(s string, n int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= n {
|
||||
return s
|
||||
}
|
||||
return string(r[len(r)-n:])
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package browser
|
||||
|
||||
// 本文件是**对端自由文本出口**的收敛点:有三处会把页面/浏览器自己说的话原样交给我们,
|
||||
// 它们出镜(进 error ⇒ 日志 + 模型可见视图)之前必须在这里过一道。
|
||||
//
|
||||
// 三处**不是同一类**,因此守卫也不同(口径见 internal/runtime/trace/errortoken.go
|
||||
// 的「按字段类别判定」):
|
||||
//
|
||||
// - `Page.navigate` 的 `errorText` 是**协议枚举**:值域是 Chrome 的 net error 码
|
||||
// (`net::ERR_NAME_NOT_RESOLVED` 之类),短、标识符形状 ⇒ 用 trace.ErrorToken 守:
|
||||
// 像枚举就保留,散文一律丢弃;
|
||||
// - `Runtime.evaluate` 的 `exceptionDetails.text` 是**页面自由文本**:页面的 JS 可以
|
||||
// throw 任何东西 —— 包括它读到的 DOM 正文、地址栏里带 token 的 URL。⇒ 默认不带
|
||||
// 全文,按「带上限地带」处理:压平空白 + trace.RedactTextLimited 截断;
|
||||
// - 浏览器自己的 stdout/stderr 预览(launchoutput.go 的 `preview()`)也是自由文本:
|
||||
// 参数被拒时 Chromium 会把**本次 argv** 与地址原话打出来(`chromeOutputPreview`)。
|
||||
//
|
||||
// 🔴 两类守卫**不能互换**:拿 ErrorToken 去「清洗」自由文本是错的口径(它只会把整段
|
||||
// 话删掉,不是清洗);拿 RedactTextLimited 去守枚举字段也不对(它不判形状,挡不住
|
||||
// 散文)。清洗实现仍然只有一份,本文件只做「选哪一道 + 补形状前提」。
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// maxExceptionTextRunes 是 `exceptionDetails.text` 允许出镜的 rune 上限:**200**。
|
||||
//
|
||||
// 为什么不复用既有的两个档位:
|
||||
// - **80**(`trace.RedactText` 的摘要档)会把真实的异常消息拦腰截断 ——
|
||||
// `TypeError: Cannot read properties of undefined (reading 'addEventListener')`
|
||||
// 已经 73 rune,再带上半句调用点就过 80,而调用点是唯一的定位线索;
|
||||
// - **4096**(网关日志页 `maxLogMsgRunes` 那一档)是给「运维要读全文」的日志行用的;
|
||||
// 这个字段是**页面自己说的话**,4096 等于允许页面把 4KB 正文塞进一条错误串 ——
|
||||
// 而这条错误串同时进日志与模型可见视图。
|
||||
//
|
||||
// 200 容得下「异常消息 + 一段调用点」,又把页面可控文本压在「一句话」量级。超限时
|
||||
// trace.RedactTextLimited 会补 `…`,因此截断本身是看得见的,不会被误读成全文。
|
||||
const maxExceptionTextRunes = 200
|
||||
|
||||
// netErrorNamespace 是 Chrome net error 码的命名空间前缀(`net::ERR_...`)。
|
||||
//
|
||||
// 为什么要先剥掉它再交给 ErrorToken:ErrorToken 的字符集是 `[A-Za-z0-9_.-]`,**不含
|
||||
// `:`**,整串直接送进去会把合法的网络错误码一起判掉 —— 而「合法网络错误码形态保留」
|
||||
// 是这条出口的硬要求(上层与工具都在断言它,见 render_test ③)。这个前缀是**协议
|
||||
// 常量**,不是从对端抄来的文本,因此剥它是安全的。
|
||||
const netErrorNamespace = "net::"
|
||||
|
||||
// netErrorCodePrefix 是 net error 枚举的名字前缀(`ERR_`):所有真实取值都以它开头。
|
||||
// 于是这一条额外把「恰好是单个令牌的自由文本」(`hunter2` 之类)也挡掉 —— 形状判定
|
||||
// 仍由 trace.ErrorToken 做,本文件只补这一个前提,不复制第二份守卫实现。
|
||||
const netErrorCodePrefix = "ERR_"
|
||||
|
||||
// netErrorCode 只放行「像 net error 枚举」的 errorText:`[net::]ERR_<标识符>`。
|
||||
// 其余(散文、被回显的 URL、带凭据的文本)返回空串,由调用方按「已隐去」处理。
|
||||
func netErrorCode(raw string) string {
|
||||
text := strings.TrimSpace(raw)
|
||||
namespace := ""
|
||||
if rest, ok := strings.CutPrefix(text, netErrorNamespace); ok {
|
||||
namespace, text = netErrorNamespace, rest
|
||||
}
|
||||
if !strings.HasPrefix(text, netErrorCodePrefix) {
|
||||
return ""
|
||||
}
|
||||
token := trace.ErrorToken(text)
|
||||
if token == "" {
|
||||
return ""
|
||||
}
|
||||
return namespace + token
|
||||
}
|
||||
|
||||
// navigateCause 把 `Page.navigate` 的 errorText 变成可出镜的原因子句。
|
||||
// 调用前提:raw 非空(navigate 只在 errorText 非空时判定为失败)。
|
||||
//
|
||||
// 不是枚举时一个字符的自由文本都不带,只留**字节数** —— 够判断「对端说了句话」,
|
||||
// 不带那句话(与 cdpError.Error 同一口径)。
|
||||
func navigateCause(raw string) string {
|
||||
if code := netErrorCode(raw); code != "" {
|
||||
return code
|
||||
}
|
||||
return fmt.Sprintf("网络错误文本已隐去(%d 字节)", len(raw))
|
||||
}
|
||||
|
||||
// pageExceptionText 是 `exceptionDetails.text` 出镜前的唯一处理:压平空白(一个换行
|
||||
// 就能在按行聚合的日志里伪造出「另一条记录」,与 sdklog / judgeReason 同一做法)+
|
||||
// 凭据形状打码 + 截断(清洗实现只有一份:trace.RedactTextLimited)。
|
||||
func pageExceptionText(raw string) string {
|
||||
return trace.RedactTextLimited(strings.Join(strings.Fields(raw), " "), maxExceptionTextRunes)
|
||||
}
|
||||
|
||||
// evaluateCause 把 `Runtime.evaluate` 的 exceptionDetails.text 变成可出镜的原因子句。
|
||||
func evaluateCause(raw string) string {
|
||||
if text := pageExceptionText(raw); text != "" {
|
||||
return text
|
||||
}
|
||||
// 压平之后什么都不剩(对端只给了空白):如实说「没给文本」,不假装异常无因。
|
||||
return "页面异常(对端未给文本)"
|
||||
}
|
||||
|
||||
// chromeOutputPreviewRunes 是浏览器 stdout/stderr 预览出镜时的 rune 上限:**600**
|
||||
// (= 12 行 × 50 rune)。
|
||||
//
|
||||
// 为什么是这个量级:预览的**行**预算已经是 12 行(chromeOutputMaxLines),50 rune 大致
|
||||
// 是一个短分句 —— Chromium 的抱怨句式是「原因在前」(`ERROR:...] 参数被拒:...`),
|
||||
// 所以 600 保得住原因那一句,又把「一整块转储」挡在模型可见的错误串之外。
|
||||
//
|
||||
// 为什么不沿用另外两档:80(`trace.RedactText` 摘要档)连**一行**浏览器日志都放不下;
|
||||
// 4096(`chromeOutputMaxBytes` 缓存档、网关日志页 maxLogMsgRunes)是「运维读全文」的
|
||||
// 量级,对浏览器可控的字段等于允许把 4KB 正文塞进工具结果。
|
||||
//
|
||||
// 为什么 600 不会把「到底说了多少」藏起来:本上限只压在**预览正文**上,调用方
|
||||
// (launchoutput.go)随后仍会追加「已截断,共 N 字节」——读者看到的永远是「这里有 600
|
||||
// rune 的原话 + 它其实说了 N 字节」,截断与量级都看得见。
|
||||
const chromeOutputPreviewRunes = 600
|
||||
|
||||
// chromeOutputPreview 是浏览器 stdout/stderr 预览出镜前的唯一处理:凭据形状打码 +
|
||||
// 截断(清洗实现只有一份:trace.RedactTextLimited)。它服务**两个**读面 —— 失败日志的
|
||||
// `chrome_output` 字段与返回给调用方的错误串(launch.go 的 launchOnce),因此收敛在
|
||||
// preview() 里,两个读面不会判得不一致。
|
||||
func chromeOutputPreview(text string) string {
|
||||
return trace.RedactTextLimited(text, chromeOutputPreviewRunes)
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// navigateErrorCDP 造一个「导航必失败、errorText 是给定文本」的假 CDP:只应答 Page.enable
|
||||
// 与 Page.navigate,导航之后一律拒绝(失败之后不该再有求值)。
|
||||
func navigateErrorCDP(t *testing.T, errorText string) *fakeCDP {
|
||||
t.Helper()
|
||||
return newFakeCDP(t, func(method string, _ map[string]any) (any, *cdpError) {
|
||||
switch method {
|
||||
case "Page.enable":
|
||||
return map[string]any{}, nil
|
||||
case "Page.navigate":
|
||||
return map[string]any{"errorText": errorText}, nil
|
||||
}
|
||||
return nil, &cdpError{Code: -32601, Message: "导航失败后不该再发 " + method}
|
||||
})
|
||||
}
|
||||
|
||||
// evaluateExceptionCDP 造一个「导航与就绪都正常、取正文那一次求值抛异常」的假 CDP:
|
||||
// 异常文本由调用方给(模拟页面 JS 往 exceptionDetails.text 里塞任何东西)。
|
||||
func evaluateExceptionCDP(t *testing.T, exceptionText string) *fakeCDP {
|
||||
t.Helper()
|
||||
return newFakeCDP(t, func(method string, params map[string]any) (any, *cdpError) {
|
||||
switch method {
|
||||
case "Page.enable", "Page.navigate":
|
||||
return map[string]any{}, nil
|
||||
case "Page.getFrameTree":
|
||||
return map[string]any{"frameTree": map[string]any{"frame": map[string]any{"id": "frame-1"}}}, nil
|
||||
case "Page.createIsolatedWorld":
|
||||
return map[string]any{"executionContextId": 1}, nil
|
||||
case "Runtime.evaluate":
|
||||
expr, _ := params["expression"].(string)
|
||||
if strings.Contains(expr, "readyState") {
|
||||
return map[string]any{"result": map[string]any{"value": map[string]any{
|
||||
"url": "https://example.com/", "title": "t", "readyState": "complete",
|
||||
}}}, nil
|
||||
}
|
||||
return map[string]any{"exceptionDetails": map[string]any{"text": exceptionText}}, nil
|
||||
}
|
||||
return nil, &cdpError{Code: -32601, Message: "unexpected " + method}
|
||||
})
|
||||
}
|
||||
|
||||
// 出口 A(`Page.navigate` 的 errorText):散文形状的值一个字符都不许出镜 —— 代理与网关
|
||||
// 会把它回显成任意文本,包括它刚收到的那条带 token 的 URL。判据是「原文本里每个有辨识度
|
||||
// 的片段都不在错误串里」,且出镜的是**字节数**(够判断对端说了句话,不带那句话)。
|
||||
//
|
||||
// 删掉 navigateCause 的调用(退回 `fmt.Errorf("%w: %s", ErrNavigate, text)`)时本用例必红。
|
||||
func TestManager_RenderTextNavigateErrorTextNeverEchoesFreeText(t *testing.T) {
|
||||
t.Parallel()
|
||||
const raw = "Failed loading https://example.com/?access_token=SUPERSECRET123 (via proxy)"
|
||||
m := renderTestManager(t, cdpEndpointAvail(navigateErrorCDP(t, raw)))
|
||||
|
||||
_, err := m.RenderText(context.Background(), "https://example.com/")
|
||||
if !errors.Is(err, ErrNavigate) {
|
||||
t.Fatalf("err = %v, want ErrNavigate", err)
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, leak := range []string{"SUPERSECRET123", "access_token", "https://example.com/?", "Failed loading", "proxy"} {
|
||||
if strings.Contains(msg, leak) {
|
||||
t.Errorf("错误串泄露了 errorText 的自由文本片段 %q: %q", leak, msg)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(msg, "已隐去") {
|
||||
t.Errorf("应点明文本已隐去(否则读起来像对端没说话): %q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, strconv.Itoa(len(raw))) {
|
||||
t.Errorf("应带出被隐去文本的字节数 %d(唯一的排查线索): %q", len(raw), msg)
|
||||
}
|
||||
}
|
||||
|
||||
// 出口 A 的正向:合法的 net error 枚举**必须原样保留** —— 它是调用方与工具分类失败的
|
||||
// 唯一依据(render_test ③ 也在断言它)。这条同时钉住「net:: 前缀要先剥掉再交给
|
||||
// ErrorToken」这个实现前提:整串直接送进 ErrorToken 会因 `:` 被判空,正向就丢了。
|
||||
func TestManager_RenderTextNavigateErrorTextKeepsNetErrorCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, code := range []string{
|
||||
"net::ERR_NAME_NOT_RESOLVED",
|
||||
"net::ERR_CONNECTION_REFUSED",
|
||||
"net::ERR_TIMED_OUT",
|
||||
"ERR_ABORTED",
|
||||
} {
|
||||
m := renderTestManager(t, cdpEndpointAvail(navigateErrorCDP(t, code)))
|
||||
_, err := m.RenderText(context.Background(), "https://example.com/")
|
||||
if !errors.Is(err, ErrNavigate) {
|
||||
t.Fatalf("%s: err = %v, want ErrNavigate", code, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), code) {
|
||||
t.Errorf("%s 是合法的网络错误码,必须原样保留,实得 %q", code, err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 出口 B(`Runtime.evaluate` 的 exceptionDetails.text):页面 JS 可以 throw 任何东西,
|
||||
// 包括它读到的凭据。判据是「凭据形状被打码、消息本体仍可排障」—— 不是整段丢弃(自由
|
||||
// 文本的处置口径是「带上限地带」,见 errtext.go)。
|
||||
//
|
||||
// 删掉 evaluateCause 的调用(退回原样直吐)时本用例必红。
|
||||
func TestManager_RenderTextExceptionTextRedactsCredentials(t *testing.T) {
|
||||
t.Parallel()
|
||||
const raw = "TypeError: fetch failed https://api.example.com/v1?api_key=sk-live-ABCDEFGH12345"
|
||||
m := renderTestManager(t, cdpEndpointAvail(evaluateExceptionCDP(t, raw)))
|
||||
|
||||
_, err := m.RenderText(context.Background(), "https://example.com/")
|
||||
if !errors.Is(err, ErrEvaluate) {
|
||||
t.Fatalf("err = %v, want ErrEvaluate", err)
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, leak := range []string{"ABCDEFGH12345", "sk-live"} {
|
||||
if strings.Contains(msg, leak) {
|
||||
t.Errorf("错误串泄露了凭据 %q: %q", leak, msg)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(msg, "[redacted]") {
|
||||
t.Errorf("凭据形状应被打成 [redacted](看得见的打码,不是静默删除): %q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "TypeError") {
|
||||
t.Errorf("异常消息本体是唯一的定位线索,不该被整段丢掉: %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// 出口 B 的上限:页面可以把 4KB 正文塞进异常文本,而这条错误串同时进日志与模型可见视图。
|
||||
// 判据:超限时截断看得见(`…`),且超出上限的尾部**确实没出镜**;换行被压平(一个换行
|
||||
// 就能在按行聚合的日志里伪造出另一条记录)。
|
||||
func TestManager_RenderTextExceptionTextIsBoundedAndFlattened(t *testing.T) {
|
||||
t.Parallel()
|
||||
const tail = "TAILMARKER"
|
||||
raw := strings.Repeat("A", maxExceptionTextRunes) + "\n" + tail
|
||||
m := renderTestManager(t, cdpEndpointAvail(evaluateExceptionCDP(t, raw)))
|
||||
|
||||
_, err := m.RenderText(context.Background(), "https://example.com/")
|
||||
if !errors.Is(err, ErrEvaluate) {
|
||||
t.Fatalf("err = %v, want ErrEvaluate", err)
|
||||
}
|
||||
msg := err.Error()
|
||||
if strings.Contains(msg, tail) {
|
||||
t.Errorf("超出上限的尾部不该出镜: %q", msg)
|
||||
}
|
||||
if !strings.HasSuffix(msg, "…") {
|
||||
t.Errorf("截断必须看得见(尾部补省略号),不许让半截文本读起来像全文: %q", msg)
|
||||
}
|
||||
if strings.ContainsAny(msg, "\r\n") {
|
||||
t.Errorf("异常文本里的换行必须压平(否则可在按行聚合的日志里伪造记录): %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// pageExceptionText 的档位就是 maxExceptionTextRunes:超限出 maxExceptionTextRunes 个
|
||||
// rune 加一个省略号,一个不多。
|
||||
func TestPageExceptionTextLimitsRunes(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := pageExceptionText(strings.Repeat("长", maxExceptionTextRunes+50))
|
||||
if n, want := utf8.RuneCountInString(got), maxExceptionTextRunes+1; n != want {
|
||||
t.Errorf("rune 数 = %d, want %d(%d + 省略号)", n, want, maxExceptionTextRunes)
|
||||
}
|
||||
if !strings.HasSuffix(got, "…") {
|
||||
t.Errorf("截断应补省略号: %q", got)
|
||||
}
|
||||
// 未超限时原样返回,不补省略号(截断标记只表示「确实被截了」)。
|
||||
short := pageExceptionText("TypeError: body is gone")
|
||||
if short != "TypeError: body is gone" {
|
||||
t.Errorf("未超限不该改动文本: %q", short)
|
||||
}
|
||||
// 只给空白 ⇒ 空串,由 evaluateCause 如实说「对端未给文本」。
|
||||
if got := evaluateCause(" \n\t "); got != "页面异常(对端未给文本)" {
|
||||
t.Errorf("空白异常文本的处理 = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// netErrorCode 只放行「像 net error 枚举」的值:形状判定仍由 trace.ErrorToken 做,本
|
||||
// 函数只补「net:: 命名空间可剥 + 必须以 ERR_ 开头」这两个前提 —— 于是「恰好是单个令牌
|
||||
// 的自由文本」(`hunter2`)不会被误当成错误码保留。
|
||||
func TestNetErrorCodeOnlyAcceptsEnumerations(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"net::ERR_NAME_NOT_RESOLVED", "net::ERR_NAME_NOT_RESOLVED"},
|
||||
{" net::ERR_ABORTED ", "net::ERR_ABORTED"},
|
||||
{"ERR_FAILED", "ERR_FAILED"},
|
||||
{"hunter2", ""},
|
||||
{"Failed loading https://example.com/", ""},
|
||||
{"net::ERR_NOT_A_CODE because spaces", ""},
|
||||
{"net::hunter2", ""},
|
||||
{"", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := netErrorCode(c.in); got != c.want {
|
||||
t.Errorf("netErrorCode(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,10 +20,12 @@ const (
|
||||
// 的落地前提是「看得见失败」—— 启动失败时浏览器把原因写在自己的 stdout/stderr 上
|
||||
// (参数被拒、profile 锁、崩溃),不接住它,日志里就只剩我们这一侧的猜测。
|
||||
//
|
||||
// 两条口径:
|
||||
// 三条口径:
|
||||
//
|
||||
// - **有界**:只留前 N 字节 / 上报前 N 行。正常启动时 Chrome 也会往 stderr 写常规
|
||||
// 信息,全打进日志就是刷屏;缓存也无上限地吃内存。
|
||||
// - **打码**:这是浏览器可控的自由文本,出镜前必过 chromeOutputPreview(凭据形状
|
||||
// 打码 + rune 上限,见 errtext.go)。它进的不只是日志,还有用户侧的工具结果。
|
||||
// - **不主动打**:本类型只缓存,**不输出**。成功时只报字节数(Debug),失败时由
|
||||
// `localChrome.Dial` 一次性把前 N 行与本次 argv 一起报出来(见 launch.go)。
|
||||
type chromeOutput struct {
|
||||
@@ -65,6 +67,13 @@ func (o *chromeOutput) totalBytes() int {
|
||||
// preview 返回失败上报用的「前 N 行」:每行内的空白压成单空格、空行去掉;被截断时
|
||||
// 在末尾点明**截断与总字节数**(绝不把半截输出说得像完整输出)。没有输出时返回空串,
|
||||
// 调用方据此判断「浏览器一个字都没说」。
|
||||
//
|
||||
// 🔴 正文出镜前必过 chromeOutputPreview(凭据形状打码 + 600 rune 上限,见 errtext.go):
|
||||
// 这个字符串**是浏览器可控的自由文本**,而它会同时进失败日志与**返回给调用方的错误串**
|
||||
// (launchOnce ⇒ 用户侧工具结果)。真机上 Chromium 会把本次 argv、被拒的参数、地址原话
|
||||
// 打出来 —— 一旦里面出现 `?access_token=` 或 `Bearer ...`,原样透出就是凭据落进日志与
|
||||
// 模型上下文。上限只压正文:下面的「已截断,共 N 字节」后缀是我们自己的定长说明,不进
|
||||
// 这份预算,因此截断与量级都照实可见。
|
||||
func (o *chromeOutput) preview() string {
|
||||
o.mu.Lock()
|
||||
raw := truncateRune(o.head)
|
||||
@@ -84,7 +93,9 @@ func (o *chromeOutput) preview() string {
|
||||
break
|
||||
}
|
||||
}
|
||||
text := strings.Join(kept, " | ")
|
||||
// 先拼再清洗(而不是逐行清洗):跨行的凭据形状(`access_token=` 与值被换行拆开)
|
||||
// 只有拼起来之后才看得见。
|
||||
text := chromeOutputPreview(strings.Join(kept, " | "))
|
||||
if truncated && text != "" {
|
||||
text += " | ...(已截断,共 " + strconv.Itoa(total) + " 字节)"
|
||||
}
|
||||
|
||||
@@ -100,7 +100,8 @@ func (s *session) navigate(ctx context.Context, url string) (pageState, error) {
|
||||
return pageState{}, err
|
||||
}
|
||||
if text := strings.TrimSpace(res.ErrorText); text != "" {
|
||||
return pageState{}, fmt.Errorf("%w: %s", ErrNavigate, text)
|
||||
// errorText 是**协议枚举**面:像 net error 码就保留,散文一律丢弃(见 errtext.go)。
|
||||
return pageState{}, fmt.Errorf("%w: %s", ErrNavigate, navigateCause(text))
|
||||
}
|
||||
if err := s.waitReady(ctx); err != nil {
|
||||
return pageState{}, err
|
||||
@@ -163,7 +164,9 @@ func (s *session) evaluate(ctx context.Context, expr string, out any) error {
|
||||
return err
|
||||
}
|
||||
if res.ExceptionDetails != nil {
|
||||
return fmt.Errorf("%w: %s", ErrEvaluate, res.ExceptionDetails.Text)
|
||||
// text 是**页面自由文本**面:页面 JS 可以 throw 任何东西(含 DOM 正文、带 token
|
||||
// 的 URL)⇒ 压平空白 + 凭据打码 + 截断后再出镜(见 errtext.go)。
|
||||
return fmt.Errorf("%w: %s", ErrEvaluate, evaluateCause(res.ExceptionDetails.Text))
|
||||
}
|
||||
if out == nil || len(res.Result.Value) == 0 {
|
||||
return nil
|
||||
|
||||
@@ -33,7 +33,6 @@ func (c *Client) Connect(ctx context.Context) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("mcp connect %q: %w", c.cfg.Name, wrapConnectErr(err))
|
||||
}
|
||||
|
||||
info := serverInfoFrom(session, c.hostFeatures())
|
||||
c.mu.Lock()
|
||||
c.session = session
|
||||
@@ -62,7 +61,7 @@ func (c *Client) Close() error {
|
||||
|
||||
if session != nil {
|
||||
if err := session.Close(); err != nil {
|
||||
return fmt.Errorf("mcp close %q: %w", c.cfg.Name, err)
|
||||
return fmt.Errorf("mcp close %q: %w", c.cfg.Name, upstreamError(err))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -82,7 +81,7 @@ func (c *Client) ListTools(ctx context.Context) ([]ToolInfo, error) {
|
||||
out := make([]ToolInfo, 0, 8)
|
||||
for tool, err := range session.Tools(ctx, nil) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mcp list tools %q: %w", c.cfg.Name, err)
|
||||
return nil, fmt.Errorf("mcp list tools %q: %w", c.cfg.Name, upstreamError(err))
|
||||
}
|
||||
out = append(out, toolInfoFrom(tool))
|
||||
}
|
||||
@@ -112,7 +111,7 @@ func (c *Client) callTool(ctx context.Context, name string, args map[string]any,
|
||||
}
|
||||
res, err := session.CallTool(ctx, &mcpsdk.CallToolParams{Name: name, Arguments: prepared})
|
||||
if err != nil {
|
||||
return ToolResult{}, fmt.Errorf("mcp call tool %q: %w", name, err)
|
||||
return ToolResult{}, fmt.Errorf("mcp call tool %q on %q: %w", name, c.cfg.Name, upstreamError(err))
|
||||
}
|
||||
return ToolResult{
|
||||
Content: toContentBlocks(res.Content),
|
||||
@@ -165,7 +164,9 @@ func (c *Client) handleToolListChanged(context.Context, *mcpsdk.ToolListChangedR
|
||||
// newSDKClient 按配置构建官方 SDK 客户端:只宣告宿主真正具备的客户端能力。
|
||||
func (c *Client) newSDKClient() *mcpsdk.Client {
|
||||
client := mcpsdk.NewClient(&mcpsdk.Implementation{Name: clientName, Version: clientVersion}, &mcpsdk.ClientOptions{
|
||||
Logger: c.log,
|
||||
// 🔴 交给 SDK 的 Logger 必须过 redactingLogger(sdklog.go):SDK 自己会把对端
|
||||
// 错误原样打进日志(keepalive 那条已实测),那条路绕开了本包 upstreamError 的出口。
|
||||
Logger: redactingLogger(c.log),
|
||||
Capabilities: c.clientCapabilities(),
|
||||
CreateMessageHandler: c.wrapSampling(),
|
||||
ElicitationHandler: c.wrapElicitation(),
|
||||
@@ -188,12 +189,17 @@ func (c *Client) sessionOptions() *mcpsdk.ClientSessionOptions {
|
||||
}
|
||||
|
||||
// wrapConnectErr 把 SDK 报的「协议版本不支持」映射到本包哨兵错误,便于调用方判定。
|
||||
//
|
||||
// 它同时是握手失败的**脱敏点**:原始 SDK 错误(第三方 server 的自由文本)只经
|
||||
// upstreamError 留下 code 与长度,绝不被 %w 带进文案 —— 而哨兵映射仍然成立,因为
|
||||
// upstreamFailure 的 cause 保留了整条链,errors.Is 不受影响。
|
||||
func wrapConnectErr(err error) error {
|
||||
safe := upstreamError(err)
|
||||
var wire *sdkjsonrpc.Error
|
||||
if errors.As(err, &wire) && wire.Code == mcpsdk.CodeUnsupportedProtocolVersion {
|
||||
return fmt.Errorf("%w: %w", ErrUnsupportedProtocolVersion, err)
|
||||
return fmt.Errorf("%w: %w", ErrUnsupportedProtocolVersion, safe)
|
||||
}
|
||||
return err
|
||||
return safe
|
||||
}
|
||||
|
||||
// normalizeArgs 把 agent 传来的参数值规整成 MCP arguments 对象(必须是 JSON 对象)。
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
sdkjsonrpc "github.com/modelcontextprotocol/go-sdk/jsonrpc"
|
||||
)
|
||||
|
||||
// 本文件是「第三方 MCP server 的错误怎么写进本包错误」的**唯一**收敛点。
|
||||
//
|
||||
// 为什么需要它:MCP server 是第三方进程/远端服务,它的错误文本是**上游自由文本** ——
|
||||
// 它可以把刚收到的工具参数、读到的文件内容、甚至是它自己的系统提示词塞进
|
||||
// `error.message`。原样经 SDK error 进本包错误,那条文本就会随 `errors.Is` 分类链
|
||||
// 一路进日志、进管理面板、进模型可见视图。与 trace.UpstreamError 同一口径:
|
||||
// **默认不带自由文本**,只留可分类的骨架。
|
||||
//
|
||||
// 留下的骨架(够定位,不够回显):
|
||||
// - 哪台 server / 哪把工具 —— 由调用点的 %w 文案给(名字来自配置与 tools/list,
|
||||
// 不是错误文本);
|
||||
// - JSON-RPC code —— 协议枚举(-32601 method not found、-32602 invalid params …),
|
||||
// 是本文件唯一允许出镜的「对端给的值」,且以数字渲染;
|
||||
// - message 的**字节数** —— 能区分「对端给了句话」与「对端什么都没说」,不带那句话。
|
||||
|
||||
// upstreamFailure 是一次第三方 server 失败在本包错误链里的形态。它的 Error() 只
|
||||
// 渲染 code 与长度;原始原因挂在 cause 上,只供 errors.Is / errors.As 分类。
|
||||
//
|
||||
// 为什么不直接把 SDK 的 err 用 %w 包上:%w 只加前缀,被包的那条文本仍然在
|
||||
// err.Error() 里 —— 包一层不是修复,只是换个位置泄漏(同 trace.URLCause 的口径)。
|
||||
type upstreamFailure struct {
|
||||
// code 是 JSON-RPC 错误码;hasCode 为 false 表示这条错误不是线上响应里的
|
||||
// 结构化错误(传输层失败、SDK 内部错误、ctx 取消等),此时不带任何码。
|
||||
code int64
|
||||
hasCode bool
|
||||
// length 是被扣下的自由文本的字节数。
|
||||
length int
|
||||
// cause 只用于分类,绝不参与 Error()。
|
||||
cause error
|
||||
}
|
||||
|
||||
// Error 渲染一行只含 code / 长度的诊断;自由文本一个字节都不出。
|
||||
func (f *upstreamFailure) Error() string {
|
||||
if f.hasCode {
|
||||
return fmt.Sprintf("mcp server error: code=%d message_bytes=%d", f.code, f.length)
|
||||
}
|
||||
return fmt.Sprintf("mcp server error: %T message_bytes=%d", f.cause, f.length)
|
||||
}
|
||||
|
||||
// Unwrap 让 errors.Is/As 仍然看得见原始原因(context.Canceled、
|
||||
// context.DeadlineExceeded、*jsonrpc.Error 的码…),而 Error() 绝不渲染它。
|
||||
// 少了它,取消会被上层当成普通工具失败继续跑。
|
||||
func (f *upstreamFailure) Unwrap() error { return f.cause }
|
||||
|
||||
// upstreamError 把一次第三方 server 失败收敛成**不带自由文本**的错误。
|
||||
//
|
||||
// 收 code 用的 errors.As 会遍历整条链:SDK 常把线上错误再包一层(fmt 包装、
|
||||
// JSON-RPC 信封),所以不能只断言最外层。
|
||||
func upstreamError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
failure := &upstreamFailure{cause: err, length: len(err.Error())}
|
||||
var wire *sdkjsonrpc.Error
|
||||
if errors.As(err, &wire) {
|
||||
failure.code = wire.Code
|
||||
failure.hasCode = true
|
||||
// WireError.Error() 就是 Message 本身,所以这里量的正是被扣下的那段文本。
|
||||
failure.length = len(wire.Message)
|
||||
}
|
||||
return failure
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package mcp
|
||||
|
||||
// 本文件钉住「第三方 MCP server 的错误文本不进错误与日志」这条口径(errors.go)。
|
||||
//
|
||||
// 生产风险:MCP server 是第三方进程/远端服务,它可以把刚收到的工具参数、读到的文件
|
||||
// 内容、甚至自己的系统提示词塞进 error.message。原样经 SDK error 进本包错误,那条
|
||||
// 文本就会随分类链一路进日志、进管理面板、进模型可见视图。
|
||||
//
|
||||
// 变异判据(每条都对应一个会红的实现):
|
||||
// - 把 upstreamError 换成裸 err(或 `%w` 包原始 err)⇒ 哨兵文本出现在错误串里;
|
||||
// - 把 upstreamFailure 的 Error() 改成渲染 cause ⇒ 同上;
|
||||
// - 摘掉 Unwrap ⇒ 「分类仍成立」那条断言红(取消会被当成普通工具失败)。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
sdkjsonrpc "github.com/modelcontextprotocol/go-sdk/jsonrpc"
|
||||
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
// errSentinel 是「绝不能出现在错误串里」的第三方自由文本标记。
|
||||
const errSentinel = "MCP-UPSTREAM-SENTINEL-原文回显"
|
||||
|
||||
// sentinelServer 起一台会在 tools/call 里返回带哨兵文本的 JSON-RPC 错误的 server。
|
||||
func sentinelServer() *mcpsdk.Server {
|
||||
srv := mcpsdk.NewServer(&mcpsdk.Implementation{Name: "sentinel-mcp", Version: "1.0.0"}, nil)
|
||||
srv.AddTool(&mcpsdk.Tool{
|
||||
Name: "boom",
|
||||
InputSchema: map[string]any{"type": "object"},
|
||||
}, func(context.Context, *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) {
|
||||
return nil, &sdkjsonrpc.Error{Code: sdkjsonrpc.CodeInternalError, Message: errSentinel}
|
||||
})
|
||||
return srv
|
||||
}
|
||||
|
||||
// TestCallTool_KeepsServerErrorTextOut 是 CallTool 的正面证据:错误里只剩
|
||||
// server / 工具 / 协议码 / 长度,哨兵文本一个字节都没有。
|
||||
func TestCallTool_KeepsServerErrorTextOut(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := connectFakeClient(t, sentinelServer())
|
||||
_, err := c.CallTool(context.Background(), "boom", map[string]any{})
|
||||
if err == nil {
|
||||
t.Fatal("server 返回协议错误时 CallTool 应当报错")
|
||||
}
|
||||
if strings.Contains(err.Error(), errSentinel) {
|
||||
t.Fatalf("第三方 server 的自由文本进了错误串:%v", err)
|
||||
}
|
||||
for _, want := range []string{"boom", "fake", "code=-32603"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("错误里应保留可分类的骨架 %q:%v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpstreamError_KeepsClassificationButNotText 直接钉住 errors.go 的契约:
|
||||
// Error() 只有骨架,errors.Is/As 仍然看得见原始原因。
|
||||
func TestUpstreamError_KeepsClassificationButNotText(t *testing.T) {
|
||||
t.Parallel()
|
||||
cause := fmt.Errorf("sdk wrapper: %w", &sdkjsonrpc.Error{Code: -32601, Message: errSentinel})
|
||||
err := upstreamError(cause)
|
||||
|
||||
if strings.Contains(err.Error(), errSentinel) {
|
||||
t.Fatalf("自由文本进了错误串:%v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "code=-32601") {
|
||||
t.Errorf("错误里丢了 JSON-RPC 码:%v", err)
|
||||
}
|
||||
// 分类面:WireError 的 Is 比的是码,链不能断。
|
||||
if !errors.Is(err, &sdkjsonrpc.Error{Code: -32601}) {
|
||||
t.Errorf("errors.Is 丢失了 JSON-RPC 码分类:%v", err)
|
||||
}
|
||||
var wire *sdkjsonrpc.Error
|
||||
if !errors.As(err, &wire) || wire.Code != -32601 {
|
||||
t.Errorf("errors.As 取不到原始 JSON-RPC 错误:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpstreamError_KeepsContextCancellation 钉住「取消仍可分类」:上层靠
|
||||
// errors.Is(err, context.Canceled) 区分「用户取消」与「工具失败」,
|
||||
// 收敛文本时绝不能把这条链切断。
|
||||
func TestUpstreamError_KeepsContextCancellation(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := upstreamError(fmt.Errorf("mcp: %w", context.Canceled))
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("errors.Is(err, context.Canceled) = false,取消被吞掉了:%v", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "context canceled") {
|
||||
t.Errorf("取消的原文也被带进了错误串:%v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// 本文件钉住 MCP 工具的**来源章**(DevDocs/audit/MCP工具按上游分组-2026-10-24.md):
|
||||
// 每把注册进注册表的工具都要带「哪台 server + 什么传输」,供面板按 server 分节。
|
||||
|
||||
// TestRegistryRegisterStampsToolOrigin 覆盖**两条接入路径**的共同汇点:
|
||||
// - 运行时热装:plugin.Manager.start → mcp.NewRegistry → **Register**;
|
||||
// - `.env` 的 MCP_SERVERS_JSON 启动期装:cmd/cyrene.registerMCPServer →
|
||||
// mcp.NewRegistry → **同一个 Register**(不经插件管理器)。
|
||||
//
|
||||
// 两条路都在这里进注册表,因此这一处的断言就同时钉住了它们;也从反面证明
|
||||
// 「从插件管理器现场反推归属」会漏掉 env 路径。
|
||||
func TestRegistryRegisterStampsToolOrigin(t *testing.T) {
|
||||
reg := registry.New()
|
||||
cfg := MCPConfig{Name: "calc", TransportType: "stdio"}
|
||||
r := NewRegistry(reg, cfg, WithTransport(startFakeServer(t, newFakeServer())))
|
||||
if err := r.Register(context.Background()); err != nil {
|
||||
t.Fatalf("Register: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = r.Unregister(context.Background()) })
|
||||
|
||||
want := sdk.Origin{Kind: sdk.OriginKindMCP, Server: "calc", Transport: "stdio"}
|
||||
tools := reg.List(registry.KindTool)
|
||||
if len(tools) != 2 {
|
||||
t.Fatalf("注册进注册表的工具数 = %d, want 2(echo/add)", len(tools))
|
||||
}
|
||||
for _, cap := range tools {
|
||||
if cap.Origin != want {
|
||||
t.Fatalf("工具 %q 的来源章 = %+v, want %+v", cap.Name, cap.Origin, want)
|
||||
}
|
||||
}
|
||||
// 来源是 server 名,不是注册键 —— 与 `mcp:calc` 那条 KindMCP 条目无关。
|
||||
if err := r.Unregister(context.Background()); err != nil {
|
||||
t.Fatalf("Unregister: %v", err)
|
||||
}
|
||||
if left := reg.List(registry.KindTool); len(left) != 0 {
|
||||
t.Fatalf("停用 server 后还剩 %d 把工具:条目必须连同来源章一起消失,不留孤儿", len(left))
|
||||
}
|
||||
}
|
||||
|
||||
// TestTransportNameOf 钉住来源章里 transport 的取值:它必须报**实际会用的**传输,
|
||||
// 而不是原样下发可能为空的 TransportType(配置常见留空,由推断决定)。
|
||||
// 判据与 newTransport 的推断同源,因此面板显示的传输永远是真在用的那个。
|
||||
func TestTransportNameOf(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg MCPConfig
|
||||
want string
|
||||
}{
|
||||
{"显式 stdio", MCPConfig{TransportType: "stdio"}, "stdio"},
|
||||
{"显式 sse", MCPConfig{TransportType: "sse"}, "sse"},
|
||||
{"显式 http", MCPConfig{TransportType: "http"}, "http"},
|
||||
{"streamable 归一为 http", MCPConfig{TransportType: "streamable"}, "http"},
|
||||
{"留空 + 命令 ⇒ stdio", MCPConfig{Command: "npx"}, "stdio"},
|
||||
{"留空 + 地址 ⇒ sse", MCPConfig{URL: "https://example.test/sse"}, "sse"},
|
||||
{"命令优先于地址(与 newTransport 同序)", MCPConfig{Command: "npx", URL: "https://example.test"}, "stdio"},
|
||||
{"都没有 ⇒ 空串(不编一个)", MCPConfig{}, ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := transportNameOf(tc.cfg); got != tc.want {
|
||||
t.Fatalf("transportNameOf(%+v) = %q, want %q", tc.cfg, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package mcp
|
||||
|
||||
// 本文件是「交给官方 SDK 的 Logger」的唯一收敛点。
|
||||
//
|
||||
// 为什么需要它(探针证据见 sdklog_probe_test.go):官方 SDK v1.8.0 的客户端侧会自己
|
||||
// 打日志,其中 keepalive 那条把**对端错误原样**交给 logger:
|
||||
//
|
||||
// logger.Error("keepalive ping failed; closing session", "error", err, …)
|
||||
//
|
||||
// 而 err 是 SDK 自己的包装(`calling "ping": <对端 error.message>`),**不是**本包
|
||||
// upstreamError 的产物 —— 于是第三方 server 的自由文本可以借 SDK 的手进我们的日志,
|
||||
// 绕开 client.go 里那条「默认不带自由文本」的出口。探针实测:未加本文件时日志里出现
|
||||
// 了对端回显的整句话。
|
||||
//
|
||||
// 收敛口径(与 client.go 同一刻画,不做第二套):
|
||||
// - **线上错误**(链上有 *jsonrpc.Error):自由文本一律不带,只留
|
||||
// `upstreamError` 渲染的 code + message_bytes;
|
||||
// - **本地错误**(context deadline / canceled、传输错误):文本是代码自己产生的,
|
||||
// 保留并以 maxSDKLogRunes 截断 —— 运维靠它判断「为什么 session 被关掉」;
|
||||
// - 消息本身与所有字符串属性同样过 trace.RedactTextLimited(抹凭据形状 + 截断)。
|
||||
//
|
||||
// 为什么不是「一律 RedactText」:RedactText 只抹**凭据形状**,不删「她的话」——
|
||||
// 对端把用户正文回显进 error.message 时,凭据清洗一个字符都不会动它。要挡住这一类,
|
||||
// 判据必须是「这条文本来自线上」而不是「它像不像凭据」。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
sdkjsonrpc "github.com/modelcontextprotocol/go-sdk/jsonrpc"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// maxSDKLogRunes 是 SDK 日志行(消息与字符串属性)允许出镜的 rune 上限。
|
||||
//
|
||||
// 取 300 的理由:这些行是「方法名 + 原因」的合成串(`calling "ping": …`),
|
||||
// 300 rune 容得下任何真实诊断,同时挡住 MiB 级对象把日志行撑爆;与网关日志页
|
||||
// 的 maxLogMsgRunes(4096,整页一行要留全文)刻意不同 —— 这里的行是我们自己
|
||||
// 产出的诊断,不需要全文,只需要「哪一步失败、为什么」。
|
||||
const maxSDKLogRunes = 300
|
||||
|
||||
// redactingLogger 返回交给 SDK 的日志器:同一底层 handler,包一层日志面收敛。
|
||||
// 它不改变级别与输出格式(Enabled 原样委托),因此宿主注入的 Logger 行为不变。
|
||||
func redactingLogger(l *slog.Logger) *slog.Logger {
|
||||
return slog.New(&redactingHandler{inner: l.Handler()})
|
||||
}
|
||||
|
||||
// redactingHandler 是 redactingLogger 的 handler 形态。
|
||||
type redactingHandler struct {
|
||||
inner slog.Handler
|
||||
}
|
||||
|
||||
// Enabled 原样委托:收敛发生在写出时,不影响任何级别判定。
|
||||
func (h *redactingHandler) Enabled(ctx context.Context, level slog.Level) bool {
|
||||
return h.inner.Enabled(ctx, level)
|
||||
}
|
||||
|
||||
// Handle 先收敛一条记录(消息 + 全部属性),再交给底层 handler。
|
||||
func (h *redactingHandler) Handle(ctx context.Context, r slog.Record) error {
|
||||
out := slog.NewRecord(r.Time, r.Level, scrubSDKText(r.Message), r.PC)
|
||||
r.Attrs(func(a slog.Attr) bool {
|
||||
out.AddAttrs(scrubSDKAttr(a))
|
||||
return true
|
||||
})
|
||||
return h.inner.Handle(ctx, out)
|
||||
}
|
||||
|
||||
// WithAttrs 让预置属性(logger.With(...))同样过收敛 —— 否则「先 With 再打」的
|
||||
// 记录会绕过本文件。
|
||||
func (h *redactingHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
scrubbed := make([]slog.Attr, len(attrs))
|
||||
for i, a := range attrs {
|
||||
scrubbed[i] = scrubSDKAttr(a)
|
||||
}
|
||||
return &redactingHandler{inner: h.inner.WithAttrs(scrubbed)}
|
||||
}
|
||||
|
||||
// WithGroup 透传分组,组内属性仍由 Handle 逐条收敛。
|
||||
func (h *redactingHandler) WithGroup(name string) slog.Handler {
|
||||
return &redactingHandler{inner: h.inner.WithGroup(name)}
|
||||
}
|
||||
|
||||
// scrubSDKText 是一条自由文本进入日志前唯一处理:压平空白(一个换行就能在按行聚合
|
||||
// 的日志里伪造出「另一条记录」)+ 凭据形状清洗 + 截断。
|
||||
func scrubSDKText(s string) string {
|
||||
return trace.RedactTextLimited(strings.Join(strings.Fields(s), " "), maxSDKLogRunes)
|
||||
}
|
||||
|
||||
// scrubSDKAttr 收敛一条属性:字符串与 error 是自由文本面,分组递归,其余(数字、
|
||||
// 时间、布尔)原样保留 —— 它们是体积与状态,没有内容。
|
||||
func scrubSDKAttr(a slog.Attr) slog.Attr {
|
||||
switch a.Value.Kind() {
|
||||
case slog.KindString:
|
||||
a.Value = slog.StringValue(scrubSDKText(a.Value.String()))
|
||||
case slog.KindAny:
|
||||
if err, ok := a.Value.Any().(error); ok && err != nil {
|
||||
a.Value = slog.StringValue(scrubSDKError(err))
|
||||
}
|
||||
case slog.KindGroup:
|
||||
group := a.Value.Group()
|
||||
scrubbed := make([]slog.Attr, len(group))
|
||||
for i, item := range group {
|
||||
scrubbed[i] = scrubSDKAttr(item)
|
||||
}
|
||||
a.Value = slog.GroupValue(scrubbed...)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// scrubSDKError 区分两类错误来源(口径见文件头):
|
||||
// - 链上有 *jsonrpc.Error ⇒ 文本来自对端,走 upstreamError 的骨架(code +
|
||||
// message_bytes),一个字节的自由文本都不留;
|
||||
// - 否则是本地产生的错误(context deadline / canceled、传输层、SDK 自身),
|
||||
// 保留原因(运维要靠它判断失败性质),只做凭据清洗与截断。
|
||||
func scrubSDKError(err error) string {
|
||||
var wire *sdkjsonrpc.Error
|
||||
if errors.As(err, &wire) {
|
||||
return upstreamError(err).Error()
|
||||
}
|
||||
return scrubSDKText(err.Error())
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package mcp
|
||||
|
||||
// 本文件是「SDK 自己的 Logger 日志面会不会漏第三方 server 的自由文本」的**探针**,
|
||||
// 由塔 2026-10-26 复审 p2 要求:先证明有没有泄漏,再决定要不要上基础设施。
|
||||
//
|
||||
// 探针覆盖 go-sdk v1.8.0 客户端侧两处会打日志的点:
|
||||
// 1. 正常的方法调用失败(tools/call 返回 JSON-RPC error)—— SDK 是否记录它;
|
||||
// 2. keepalive ping 被对端用 JSON-RPC error 拒绝 —— SDK 明确会
|
||||
// `logger.Warn("keepalive ping failed;…", "error", err)`,err 就是**对端的自由文本**。
|
||||
//
|
||||
// 结论(v1.8.0 实测):第 1 条不打日志;第 2 条会,且原样带出 message —— 因此
|
||||
// newSDKClient 交给 SDK 的 Logger 必须经过 redactingLogger(见 sdklog.go)。
|
||||
//
|
||||
// 变异判据:把 newSDKClient 的 redactingLogger 摘掉 ⇒ 本文件的哨兵断言红。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
sdkjsonrpc "github.com/modelcontextprotocol/go-sdk/jsonrpc"
|
||||
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
)
|
||||
|
||||
// sdkLogSentinel 是第三方 server 塞进 JSON-RPC error.message 的哨兵(模拟「她刚才
|
||||
// 说的那句话」被对端回显)。
|
||||
const sdkLogSentinel = "SENTINEL-MCPLOG 她刚才说的那句话"
|
||||
|
||||
// pingErrorTransport 包一层传输:把客户端的 `ping` 拦下来,直接用一条**带哨兵文本的
|
||||
// JSON-RPC error** 回答它(不转发给真 server),从而稳定复现「对端 free text 进
|
||||
// keepalive 日志」这条路径。
|
||||
//
|
||||
// 为什么要在传输层拦 ping:SDK server 的 ping 应答是内置的(总回空 result),无法让
|
||||
// 它回错误;而 ping 失败正是客户端侧唯一一处「把对端 error 原样交给 logger」的地方。
|
||||
type pingErrorTransport struct {
|
||||
inner mcpsdk.Transport
|
||||
}
|
||||
|
||||
func (t *pingErrorTransport) Connect(ctx context.Context) (mcpsdk.Connection, error) {
|
||||
conn, err := t.inner.Connect(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newPingErrorConn(conn), nil
|
||||
}
|
||||
|
||||
// pingErrorConn 把内层连接的消息泵到一个自己的 channel,好让拦下的 ping 能**插队**
|
||||
// 回一条错误响应(直接在 Read 里塞响应不行:读循环已经阻塞在内层 Read 上)。
|
||||
type pingErrorConn struct {
|
||||
inner mcpsdk.Connection
|
||||
msgs chan sdkjsonrpc.Message
|
||||
done chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func newPingErrorConn(inner mcpsdk.Connection) *pingErrorConn {
|
||||
c := &pingErrorConn{
|
||||
inner: inner,
|
||||
msgs: make(chan sdkjsonrpc.Message, 16),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
go c.pump()
|
||||
return c
|
||||
}
|
||||
|
||||
// pump 把内层消息搬进 msgs;连接结束就关掉 msgs(读侧据此如实报错)。
|
||||
func (c *pingErrorConn) pump() {
|
||||
defer close(c.msgs)
|
||||
for {
|
||||
msg, err := c.inner.Read(context.Background())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case c.msgs <- msg:
|
||||
case <-c.done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *pingErrorConn) Write(ctx context.Context, msg sdkjsonrpc.Message) error {
|
||||
req, ok := msg.(*sdkjsonrpc.Request)
|
||||
if !ok || req.Method != "ping" {
|
||||
return c.inner.Write(ctx, msg)
|
||||
}
|
||||
select {
|
||||
case c.msgs <- &sdkjsonrpc.Response{
|
||||
ID: req.ID,
|
||||
Error: &sdkjsonrpc.Error{Code: sdkjsonrpc.CodeInternalError, Message: sdkLogSentinel},
|
||||
}:
|
||||
return nil
|
||||
case <-c.done:
|
||||
return context.Canceled
|
||||
}
|
||||
}
|
||||
|
||||
func (c *pingErrorConn) Read(ctx context.Context) (sdkjsonrpc.Message, error) {
|
||||
select {
|
||||
case msg, ok := <-c.msgs:
|
||||
if !ok {
|
||||
return nil, context.Canceled
|
||||
}
|
||||
return msg, nil
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *pingErrorConn) Close() error {
|
||||
c.once.Do(func() { close(c.done) })
|
||||
return c.inner.Close()
|
||||
}
|
||||
|
||||
// SessionID 满足 mcpsdk.Connection(SDK 把它当作传输的身份标签,探针原样透传)。
|
||||
func (c *pingErrorConn) SessionID() string { return c.inner.SessionID() }
|
||||
|
||||
// TestSDKLogger_ToolCallErrorIsNotLogged 证明探针不是空转:tools/call 的 JSON-RPC
|
||||
// 错误**确实**会被我们的(redacted)日志器看到 —— 只是 SDK 并不记录它。
|
||||
func TestSDKLogger_ToolCallErrorIsNotLogged(t *testing.T) {
|
||||
t.Parallel()
|
||||
var buf bytes.Buffer
|
||||
log := logger.New(logger.Options{Format: "text", Level: "debug", Out: &buf})
|
||||
c := New(MCPConfig{Name: "fake"},
|
||||
WithTransport(startFakeServer(t, sentinelServer())),
|
||||
WithLogger(log))
|
||||
if err := c.Connect(context.Background()); err != nil {
|
||||
t.Fatalf("Connect: %v", err)
|
||||
}
|
||||
defer func() { _ = c.Close() }()
|
||||
if _, err := c.CallTool(context.Background(), "boom", map[string]any{}); err == nil {
|
||||
t.Fatal("server 返回协议错误时 CallTool 应当报错")
|
||||
}
|
||||
if got := buf.String(); strings.Contains(got, errSentinel) {
|
||||
t.Fatalf("tools/call 路径的 SDK 日志里出现哨兵(原先认为它不打日志,结论要改):\n%s", got)
|
||||
}
|
||||
t.Logf("tools/call 路径 SDK 日志(预期不含哨兵):\n%s", buf.String())
|
||||
}
|
||||
|
||||
// TestSDKLogger_KeepaliveErrorTextStaysOut 是探针的主断言:keepalive 被对端用
|
||||
// JSON-RPC error 拒绝时,SDK 会把 err 原样打进 WARN —— 哨兵绝不能出现在日志里。
|
||||
func TestSDKLogger_KeepaliveErrorTextStaysOut(t *testing.T) {
|
||||
t.Parallel()
|
||||
var buf bytes.Buffer
|
||||
log := logger.New(logger.Options{Format: "text", Level: "debug", Out: &buf})
|
||||
tr := &pingErrorTransport{inner: startFakeServer(t, newFakeServer())}
|
||||
c := New(MCPConfig{Name: "fake"},
|
||||
WithTransport(tr),
|
||||
// 固定到 keepalive 生效的旧协议版本(同 keepalive_test.go 的口径)。
|
||||
WithProtocolVersion("2025-11-25"),
|
||||
WithKeepAlive(20*time.Millisecond, 1),
|
||||
WithLogger(log))
|
||||
if err := c.Connect(context.Background()); err != nil {
|
||||
t.Fatalf("Connect: %v", err)
|
||||
}
|
||||
defer func() { _ = c.Close() }()
|
||||
|
||||
waitFor(t, 5*time.Second, "keepalive 报出 ping 失败", func() bool {
|
||||
return strings.Contains(buf.String(), "keepalive ping failed")
|
||||
})
|
||||
logged := buf.String()
|
||||
// 探针本身要可证伪:日志里确实有那条 keepalive 记录,否则「没哨兵」是空转。
|
||||
if !strings.Contains(logged, "keepalive ping failed") {
|
||||
t.Fatalf("探针没触发 keepalive 日志,结论不可用:\n%s", logged)
|
||||
}
|
||||
if strings.Contains(logged, sdkLogSentinel) {
|
||||
t.Fatalf("SDK 的 keepalive 日志把对端自由文本原样打出来了:\n%s", logged)
|
||||
}
|
||||
t.Logf("keepalive 路径 SDK 日志(预期含记录、不含哨兵):\n%s", logged)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package mcp
|
||||
|
||||
// 本文件钉住 sdklog.go 的两条分支(线上错误 / 本地错误),sdklog_probe_test.go 则
|
||||
// 用真 server 证明这条日志面确实走过收敛。
|
||||
//
|
||||
// 变异判据:把 scrubSDKError 的分支判据摘掉(一律保留原文)⇒ 第一条子用例红;
|
||||
// 反过来「一律只留骨架」⇒ 第二、三条子用例红(运维会丢掉「为什么 session 被关掉」)。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
sdkjsonrpc "github.com/modelcontextprotocol/go-sdk/jsonrpc"
|
||||
)
|
||||
|
||||
// TestScrubSDKError_SplitsWireFromLocal 是分类判据的正面证据。
|
||||
func TestScrubSDKError_SplitsWireFromLocal(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// 1. 线上错误(链上有 *jsonrpc.Error):自由文本一个字节都不留,只留码与长度。
|
||||
wire := fmt.Errorf("calling %q: %w", "ping",
|
||||
&sdkjsonrpc.Error{Code: sdkjsonrpc.CodeInternalError, Message: "SENTINEL-MCPLOG 她刚才说的话"})
|
||||
got := scrubSDKError(wire)
|
||||
if strings.Contains(got, "SENTINEL-MCPLOG") {
|
||||
t.Fatalf("线上错误的自由文本进了日志:%q", got)
|
||||
}
|
||||
if !strings.Contains(got, "code=-32603") {
|
||||
t.Fatalf("线上错误的协议码被丢掉了:%q", got)
|
||||
}
|
||||
|
||||
// 2. 本地错误:原因必须保留(运维靠它判断失败性质)。
|
||||
if got := scrubSDKError(context.DeadlineExceeded); !strings.Contains(got, "deadline exceeded") {
|
||||
t.Fatalf("本地错误的原因被吞掉了:%q", got)
|
||||
}
|
||||
|
||||
// 3. 本地错误里的凭据形状仍要打码,换行仍要压平。
|
||||
leaky := errors.New("dial failed: Authorization: Bearer sk-live-0123456789abcdef\nsecond line")
|
||||
got = scrubSDKError(leaky)
|
||||
if strings.Contains(got, "sk-live") {
|
||||
t.Fatalf("本地错误里的凭据没被打码:%q", got)
|
||||
}
|
||||
if strings.Contains(got, "\n") || !strings.Contains(got, "second line") {
|
||||
t.Fatalf("本地错误的换行没被压平(或压平把内容吞了):%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestScrubSDKAttr_KeepsScalars 钉住「只收敛自由文本面」:数字/布尔/时间是体积与
|
||||
// 状态,不该被字符串化或截断。
|
||||
func TestScrubSDKAttr_KeepsScalars(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := scrubSDKAttr(slog.Int("consecutiveFailures", 3)); got.Value.Kind().String() != "Int64" {
|
||||
t.Fatalf("数字属性被改成了 %s(应保持数字)", got.Value.Kind())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package mcp
|
||||
|
||||
// 本文件是 MCP 客户端 registry 的**状态读面**(切片 M20:MCP 服务器的管理页)。
|
||||
//
|
||||
// 为什么放在 mcp 包而不是插件管理器:工具名与描述的唯一真源是**已注册进能力注册表
|
||||
// 的那些条目**(`Register`/`refresh` 写进去、`Unregister` 摘掉的正是它们)。管理器只
|
||||
// 知道「这个 server 活没活」,它没有工具描述;让它自己去猜工具清单就会出现「面板上的
|
||||
// 工具列表」与「模型真正能调的工具」两份事实。这里只读,不改任何注册/刷新逻辑。
|
||||
|
||||
// ToolSummary 是一把工具在状态快照里的形状:名字 + 截断后的描述。
|
||||
//
|
||||
// 描述必然要截断:MCP 服务器的描述可以是一整段 Markdown(实测有几百字的),而状态页是
|
||||
// 给人扫一眼的排障面,把原文全塞进表格只会把有用的列挤没。
|
||||
type ToolSummary struct {
|
||||
// Name 是工具名(模型调用时用的标识,不截断:它是排障时要对的那个字符串)。
|
||||
Name string `json:"name"`
|
||||
// Description 是截断后的描述(原始描述见 mcp 客户端的 tools/list)。
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
// ToolSummaries 返回本 registry 当前注册的工具摘要(按注册顺序)。
|
||||
//
|
||||
// 「当前注册」= `ToolNames` 里还活着的名字;被 `refresh` 摘掉的工具不会留下影子。
|
||||
// descriptionLimit ≤ 0 表示不截断。描述取自能力注册表的条目:**注册失败的工具不在
|
||||
// 其中**,因此清单与模型可见的工具集合同源。
|
||||
func (r *Registry) ToolSummaries(descriptionLimit int) []ToolSummary {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
names := r.ToolNames()
|
||||
out := make([]ToolSummary, 0, len(names))
|
||||
for _, name := range names {
|
||||
desc := ""
|
||||
if r.reg != nil {
|
||||
if cap, err := r.reg.Get(name); err == nil {
|
||||
desc = cap.Description
|
||||
}
|
||||
}
|
||||
out = append(out, ToolSummary{Name: name, Description: TruncateRunes(desc, descriptionLimit)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TruncateRunes 把 s 截到最多 limit 个字符(按 rune 计,不切坏多字节字符);被截断时
|
||||
// 末尾补一个省略号,让「这里还有」在界面上可见(静默截断会被读成「原文就这么长」)。
|
||||
// limit ≤ 0 表示不截断。
|
||||
func TruncateRunes(s string, limit int) string {
|
||||
if limit <= 0 {
|
||||
return s
|
||||
}
|
||||
runes := []rune(s)
|
||||
if len(runes) <= limit {
|
||||
return s
|
||||
}
|
||||
return string(runes[:limit]) + "…"
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"strings"
|
||||
|
||||
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// ToolInfo 是 MCP 服务器暴露的一把工具。
|
||||
@@ -60,6 +62,17 @@ func (t *mcpTool) Name() string { return t.info.Name }
|
||||
// Description 返回 MCP 工具描述。
|
||||
func (t *mcpTool) Description() string { return t.info.Description }
|
||||
|
||||
// Origin 回报这把工具的上游来源(DevDocs/audit/MCP工具按上游分组-2026-10-24.md):
|
||||
// server 名与传输形态都在手上的 `client.cfg` 里,因此归属是**注册时的事实**,
|
||||
// 不靠工具名推断。宿主在 registry.RegisterTool 处读到它并盖在能力条目上。
|
||||
func (t *mcpTool) Origin() sdk.Origin {
|
||||
return sdk.Origin{
|
||||
Kind: sdk.OriginKindMCP,
|
||||
Server: t.client.cfg.Name,
|
||||
Transport: transportNameOf(t.client.cfg),
|
||||
}
|
||||
}
|
||||
|
||||
// Parameters 返回 MCP input schema;服务器未给 schema 时返回无约束空对象 schema。
|
||||
func (t *mcpTool) Parameters() (map[string]any, error) {
|
||||
if t.info.InputSchema == nil {
|
||||
|
||||
@@ -33,6 +33,30 @@ func newTransport(cfg MCPConfig) (mcpsdk.Transport, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// transportNameOf 返回 cfg **实际会用到**的传输名(stdio / sse / http),与
|
||||
// newTransport 的推断同源:显式 TransportType 优先(http / streamable 归一为 http),
|
||||
// 为空则「有 Command 走 stdio,有 URL 走 sse」。两者都没有时返回空串 —— 配置不成立,
|
||||
// 上游来源里该键缺席,而不是编一个 stdio。
|
||||
//
|
||||
// 它服务于能力条目的来源章(mcpTool.Origin):面板要能写「来自 fs(stdio)」,
|
||||
// 而 cfg.TransportType 常常留空(由推断决定),因此不能直接下发原始配置值。
|
||||
func transportNameOf(cfg MCPConfig) string {
|
||||
switch cfg.TransportType {
|
||||
case "http", "streamable":
|
||||
return "http"
|
||||
case "sse", "stdio":
|
||||
return cfg.TransportType
|
||||
}
|
||||
switch {
|
||||
case cfg.Command != "":
|
||||
return "stdio"
|
||||
case cfg.URL != "":
|
||||
return "sse"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// httpURLTransport 构建 URL 型传输:streamable=true 走 Streamable HTTP(2025-11-25
|
||||
// 规范首选),否则走旧式 SSE。
|
||||
func httpURLTransport(cfg MCPConfig, streamable bool) (mcpsdk.Transport, error) {
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
package plugin
|
||||
|
||||
// 本文件是「按 server 独立启停」的落点(切片 M20):启用态**持久化**在 plugins.json 的
|
||||
// `enabled` 字段上,因此停用在重启后仍然有效;热切换的语义是「停用 = 摘掉工具,启用 =
|
||||
// 连上并重新发现」,两条路都经插件管理器(design/09 §5.2.1「插件管理器 = 唯一中枢」)。
|
||||
//
|
||||
// 为什么这些函数从 manager.go 搬出来:manager.go 已接近「文件 ≤500 行」的上限,而启停
|
||||
// 生命周期本身就有四件事——落盘、内存态、面板入口、运行期观察——挤在一起会既超行数又
|
||||
// 说不清顺序。顺序在这里写死:**先落盘、再动活 server、最后改面板入口**;落盘失败即
|
||||
// 原样返回错误,绝不出现「面板说停用了、重启又连上」。
|
||||
//
|
||||
// 刻意不做的事:
|
||||
// - 停用**不动** plugins.json 的条目(停用 ≠ 卸载,卸载才删配置);
|
||||
// - 启用的失败**不改**已落盘的 enabled(失败仍禁用,如实报错,不假装开着)。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
)
|
||||
|
||||
// ErrPluginNotFound 报告指定的插件既没活着也不在 plugins.json 里。
|
||||
var ErrPluginNotFound = errors.New("plugin: not found")
|
||||
|
||||
// ErrPluginStatePersist 报告「启用态没写成」:内存/连接态可能已经变了,但 plugins.json
|
||||
// 还是旧值 —— 因此**重启后会退回旧状态**。
|
||||
//
|
||||
// 它与「连不上」是两件相反的事,调用方必须能分开(文案说反就是骗人):连不上 ⇒ server
|
||||
// 现在不可用;落盘失败 ⇒ server 可能正连着、工具可用,只是这个事实活不过一次重启。
|
||||
var ErrPluginStatePersist = errors.New("plugin: enabled state not persisted")
|
||||
|
||||
// notFound 构造一条带名字的「找不到」。
|
||||
func notFound(name string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("%w: empty name", ErrPluginNotFound)
|
||||
}
|
||||
return fmt.Errorf("%w: %s", ErrPluginNotFound, name)
|
||||
}
|
||||
|
||||
// SetEnabled 启用或停用一台已安装的 server。
|
||||
//
|
||||
// 语义(design/09 §5.2.1 生命周期一栏):
|
||||
// - 停用:落盘 `enabled:false` → 关掉活的 server(其工具随之下线,模型看不见)→ 隐藏
|
||||
// 它的管理面板入口(`when:{enabled:true}` 的入口不再出现)。配置保留,重启后仍是停用。
|
||||
// - 启用:按 plugins.json 的配置重新连接 + 发现 + 注册 → 入口回来 → 落盘 `enabled:true`。
|
||||
// **连接失败时返回值即错误,且落盘的 enabled 保持 false**(不假装开着)。
|
||||
//
|
||||
// 两条路都会推一次 `panels_changed`(停用推 disable、启用推 enable)。
|
||||
// 找不到该 server 时返回 ErrPluginNotFound。
|
||||
func (m *Manager) SetEnabled(ctx context.Context, name string, on bool) error {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return notFound("")
|
||||
}
|
||||
if !on {
|
||||
return m.disable(ctx, name)
|
||||
}
|
||||
return m.enable(ctx, name)
|
||||
}
|
||||
|
||||
// Reconnect 断开并重新连接一台已启用的 server(重新发现工具,重新注册)。
|
||||
//
|
||||
// 它与「先停用再启用」的差别只在面板帧的原因标签与「已停用时拒绝」:重连不改变启用态,
|
||||
// 所以它既不写 enabled,也不该替用户启用一个被停用的 server(那会让「为什么它忽然活了」
|
||||
// 变成一个查不出的谜)。
|
||||
func (m *Manager) Reconnect(ctx context.Context, name string) (Installed, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
cfg, ok := m.persistedConfig(name)
|
||||
if !ok {
|
||||
if srv := m.liveServer(name); srv != nil {
|
||||
cfg, ok = srv.cfg, true
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
return Installed{}, notFound(name)
|
||||
}
|
||||
if !cfg.IsEnabled() {
|
||||
return Installed{}, fmt.Errorf("%w: %s", ErrPluginDisabled, name)
|
||||
}
|
||||
return m.install(ctx, cfg, false, PanelChangeInstalled)
|
||||
}
|
||||
|
||||
// disable 停用一台 server:落盘 → 下线 → 隐藏入口。
|
||||
//
|
||||
// 落盘在**前面**:先写文件的好处是「写不进去时 server 还在跑」,而不是先摘掉工具再
|
||||
// 报一个落盘错误(那样内存与文件分叉,用户看到的与重启后得到的不是一回事)。
|
||||
func (m *Manager) disable(ctx context.Context, name string) error {
|
||||
srv := m.liveServer(name)
|
||||
cfg, persisted := m.persistedConfig(name)
|
||||
if srv == nil && !persisted {
|
||||
return notFound(name)
|
||||
}
|
||||
if !persisted {
|
||||
// 文件里没有这条,但 server 活着(手工塞进来的):用活 server 的配置补一条,
|
||||
// 否则「停用」重启后会变成「不存在」。
|
||||
cfg = srv.cfg
|
||||
}
|
||||
if err := m.persistEnabled(cfg, false); err != nil {
|
||||
return fmt.Errorf("plugin %q: %w", name, err)
|
||||
}
|
||||
if srv != nil {
|
||||
m.take(srv.cfg.Name)
|
||||
m.stop(ctx, srv)
|
||||
}
|
||||
m.noteDisabled(cfg)
|
||||
m.setPanelsEnabled(cfg.Name, false)
|
||||
m.log.Info("plugin disabled", "name", cfg.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// enable 重新启用一台已安装的 server:按 plugins.json 的配置重新连接。
|
||||
// 已经活着的 server 是幂等空操作(只把文件里的 enabled 纠正为 true,不推多余帧)。
|
||||
func (m *Manager) enable(ctx context.Context, name string) error {
|
||||
cfg, ok := m.persistedConfig(name)
|
||||
if !ok {
|
||||
if srv := m.liveServer(name); srv != nil {
|
||||
return m.persistEnabled(srv.cfg, true)
|
||||
}
|
||||
return notFound(name)
|
||||
}
|
||||
if srv := m.liveServer(name); srv != nil {
|
||||
if cfg.IsEnabled() {
|
||||
return nil
|
||||
}
|
||||
return m.persistEnabled(cfg, true)
|
||||
}
|
||||
// 只有连上才落 enabled=true:连接/发现失败时文件保持停用,返回值就是那条失败。
|
||||
res, err := m.install(ctx, cfg.WithEnabled(true), false, PanelChangeEnabled)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return m.persistEnabled(res.Config, true)
|
||||
}
|
||||
|
||||
// persistEnabled 把启用态写回 plugins.json(upsert by name,其余字段原样保留)。
|
||||
//
|
||||
// 它**不**碰内存里的活 server:调用方按自己的顺序决定何时连线/断线。
|
||||
// 失败用 ErrPluginStatePersist 包起来:调用方要能把它与「连不上」区分开 —— 这两个失败
|
||||
// 的事实相反(一个没连上、一个可能已经连上而磁盘没记住)。
|
||||
func (m *Manager) persistEnabled(cfg runtimeconfig.PluginConfig, on bool) error {
|
||||
if err := m.persistUpsert(cfg.WithEnabled(on)); err != nil {
|
||||
return fmt.Errorf("%w: 插件 %q 的启用态未落盘: %w", ErrPluginStatePersist, cfg.Name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// liveServer 返回活着的 server(按名字或 id 匹配,无则 nil)。
|
||||
func (m *Manager) liveServer(name string) *server {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if srv := m.servers[name]; srv != nil {
|
||||
return srv
|
||||
}
|
||||
for _, srv := range m.servers {
|
||||
if srv.cfg.ID != "" && srv.cfg.ID == name {
|
||||
return srv
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// takeAny 按名字或 id 摘掉活着的 server(take 只认精确的名字键)。
|
||||
func (m *Manager) takeAny(name string) *server {
|
||||
if srv := m.take(name); srv != nil {
|
||||
return srv
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for key, srv := range m.servers {
|
||||
if srv.cfg.ID != "" && srv.cfg.ID == name {
|
||||
delete(m.servers, key)
|
||||
return srv
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// persistedConfig 按名(或 id)在 plugins.json 里找一条配置。文件读不出来时按
|
||||
// 「没有这条」处理并记日志:停用/启用不该因为一个坏文件而变成 500。
|
||||
func (m *Manager) persistedConfig(name string) (runtimeconfig.PluginConfig, bool) {
|
||||
cfgs, err := runtimeconfig.LoadPlugins(m.path)
|
||||
if err != nil {
|
||||
m.log.Warn("plugins.json load failed", "path", m.path, "error", err)
|
||||
return runtimeconfig.PluginConfig{}, false
|
||||
}
|
||||
for _, cfg := range cfgs {
|
||||
if cfg.Name == name || (cfg.ID != "" && cfg.ID == name) {
|
||||
return cfg, true
|
||||
}
|
||||
}
|
||||
return runtimeconfig.PluginConfig{}, false
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package plugin
|
||||
|
||||
// 本文件锁定「按 server 独立启停」的三条硬要求(切片 M20):
|
||||
// ① 热切换:停用后工具**真的**从注册表消失(模型看不见它们),启用后回来;
|
||||
// ② 持久化:`enabled:false` 落进 plugins.json,重启(Restore)后仍是停用 —— 而且
|
||||
// **一次都不去连**(不是「连了一下再判定停用」);
|
||||
// ③ 失败如实:启用失败时不假装开着,落盘的 enabled 保持 false,快照给 lastError。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
)
|
||||
|
||||
// newEnabledManager 构造一台连到假 MCP server 的已安装插件,返回管理器与它的注册表。
|
||||
func newEnabledManager(t *testing.T, path string, endpoint string, tools ...string) (*Manager, *registry.Registry) {
|
||||
t.Helper()
|
||||
reg := registry.New()
|
||||
mgr := NewManager(reg, path, nil)
|
||||
t.Cleanup(func() { mgr.Close(context.Background()) })
|
||||
if _, err := mgr.Install(context.Background(), runtimeconfig.PluginConfig{
|
||||
Name: "calc", Transport: runtimeconfig.TransportHTTP, Endpoint: endpoint,
|
||||
}); err != nil {
|
||||
t.Fatalf("Install: %v", err)
|
||||
}
|
||||
return mgr, reg
|
||||
}
|
||||
|
||||
// TestDisableUnregistersToolsAndPersists 锁定 ①+② 的一半:停用即摘工具、写 enabled:false。
|
||||
func TestDisableUnregistersToolsAndPersists(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := filepath.Join(t.TempDir(), "plugins.json")
|
||||
srv := newFakeMCP(t, "", "add", "mul")
|
||||
mgr, reg := newEnabledManager(t, path, srv.URL)
|
||||
|
||||
if names := toolNames(reg); !names["add"] || !names["mul"] {
|
||||
t.Fatalf("安装后工具 = %v,期望 add+mul", names)
|
||||
}
|
||||
if err := mgr.SetEnabled(context.Background(), "calc", false); err != nil {
|
||||
t.Fatalf("SetEnabled(false): %v", err)
|
||||
}
|
||||
// 工具必须真的不在注册表里:模型的面就是这样来的。
|
||||
if names := toolNames(reg); len(names) != 0 {
|
||||
t.Fatalf("停用后工具仍可被模型看到 = %v", names)
|
||||
}
|
||||
if _, err := reg.Get("mcp:calc"); err == nil {
|
||||
t.Fatal("停用后 mcp:calc 能力仍在")
|
||||
}
|
||||
cfgs, err := runtimeconfig.LoadPlugins(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadPlugins: %v", err)
|
||||
}
|
||||
if len(cfgs) != 1 || cfgs[0].IsEnabled() {
|
||||
t.Fatalf("落盘配置 = %+v,期望仅一条且已停用", cfgs)
|
||||
}
|
||||
if cfgs[0].Enabled == nil {
|
||||
t.Fatal("停用必须写显式的 enabled=false(缺席=启用,无法表达停用)")
|
||||
}
|
||||
st := statusOfOne(t, mgr, "calc")
|
||||
if st.State != ServerStateDisabled || st.Connected == nil || *st.Connected {
|
||||
t.Fatalf("停用后状态 = %+v,期望 disabled/connected=false", st)
|
||||
}
|
||||
if st.ToolCount != nil {
|
||||
t.Fatalf("停用的 server 不该报工具数(不知道它有什么),得到 %d", *st.ToolCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnableBringsToolsBack 锁定 ①的另一半:启用重新连接并重新注册。
|
||||
func TestEnableBringsToolsBack(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := filepath.Join(t.TempDir(), "plugins.json")
|
||||
srv := newFakeMCP(t, "", "add", "mul")
|
||||
mgr, reg := newEnabledManager(t, path, srv.URL)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := mgr.SetEnabled(ctx, "calc", false); err != nil {
|
||||
t.Fatalf("SetEnabled(false): %v", err)
|
||||
}
|
||||
if err := mgr.SetEnabled(ctx, "calc", true); err != nil {
|
||||
t.Fatalf("SetEnabled(true): %v", err)
|
||||
}
|
||||
if names := toolNames(reg); !names["add"] || !names["mul"] {
|
||||
t.Fatalf("启用后工具 = %v,期望 add+mul 回来", names)
|
||||
}
|
||||
cfgs, err := runtimeconfig.LoadPlugins(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadPlugins: %v", err)
|
||||
}
|
||||
if len(cfgs) != 1 || !cfgs[0].IsEnabled() {
|
||||
t.Fatalf("启用后落盘 = %+v,期望 enabled=true", cfgs)
|
||||
}
|
||||
st := statusOfOne(t, mgr, "calc")
|
||||
if st.State != ServerStateConnected || st.ToolCount == nil || *st.ToolCount != 2 {
|
||||
t.Fatalf("启用后状态 = %+v,期望 connected 且 2 把工具", st)
|
||||
}
|
||||
if len(st.Tools) != 2 {
|
||||
t.Fatalf("工具明细 = %+v,期望 2 条", st.Tools)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRestoreHonoursDisabledFlag 锁定 ②:重启后停用仍然是停用,而且**一次都不连**。
|
||||
func TestRestoreHonoursDisabledFlag(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := filepath.Join(t.TempDir(), "plugins.json")
|
||||
srv := newFakeMCP(t, "", "add")
|
||||
mgr, _ := newEnabledManager(t, path, srv.URL)
|
||||
if err := mgr.SetEnabled(context.Background(), "calc", false); err != nil {
|
||||
t.Fatalf("SetEnabled(false): %v", err)
|
||||
}
|
||||
mgr.Close(context.Background())
|
||||
before := srv.Config.Handler.(*fakeMCP).hitCount()
|
||||
|
||||
reg2 := registry.New()
|
||||
mgr2 := NewManager(reg2, path, nil)
|
||||
defer mgr2.Close(context.Background())
|
||||
mgr2.Restore(context.Background())
|
||||
|
||||
if names := toolNames(reg2); len(names) != 0 {
|
||||
t.Fatalf("重启后停用的 server 仍注册了工具 = %v", names)
|
||||
}
|
||||
if after := srv.Config.Handler.(*fakeMCP).hitCount(); after != before {
|
||||
t.Fatalf("重启期对停用的 server 发起了连接:hits %d -> %d", before, after)
|
||||
}
|
||||
st := statusOfOne(t, mgr2, "calc")
|
||||
if st.State != ServerStateDisabled || st.Enabled {
|
||||
t.Fatalf("重启后状态 = %+v,期望仍是 disabled", st)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInstallDisabledDoesNotConnect 锁定 Install 也尊重 enabled:装上但停用 = 落盘且不连。
|
||||
func TestInstallDisabledDoesNotConnect(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := filepath.Join(t.TempDir(), "plugins.json")
|
||||
srv := newFakeMCP(t, "", "add")
|
||||
reg := registry.New()
|
||||
mgr := NewManager(reg, path, nil)
|
||||
defer mgr.Close(context.Background())
|
||||
|
||||
got, err := mgr.Install(context.Background(), runtimeconfig.PluginConfig{
|
||||
Name: "calc", Endpoint: srv.URL, Enabled: enabledFlag(false),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Install(disabled): %v", err)
|
||||
}
|
||||
if got.Live {
|
||||
t.Fatalf("停用安装不该有活 server:%+v", got)
|
||||
}
|
||||
if hits := srv.Config.Handler.(*fakeMCP).hitCount(); hits != 0 {
|
||||
t.Fatalf("停用安装发起了 %d 次连接", hits)
|
||||
}
|
||||
if names := toolNames(reg); len(names) != 0 {
|
||||
t.Fatalf("停用安装注册了工具 = %v", names)
|
||||
}
|
||||
cfgs, err := runtimeconfig.LoadPlugins(path)
|
||||
if err != nil || len(cfgs) != 1 || cfgs[0].IsEnabled() {
|
||||
t.Fatalf("落盘 = %+v(err=%v),期望一条停用配置", cfgs, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnableFailureKeepsDisabled 锁定 ③:启用失败 ⇒ 返回错误 + 落盘仍是停用 + 快照给 lastError。
|
||||
func TestEnableFailureKeepsDisabled(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := filepath.Join(t.TempDir(), "plugins.json")
|
||||
srv := newFakeMCP(t, "", "add")
|
||||
mgr, _ := newEnabledManager(t, path, srv.URL)
|
||||
ctx := context.Background()
|
||||
if err := mgr.SetEnabled(ctx, "calc", false); err != nil {
|
||||
t.Fatalf("SetEnabled(false): %v", err)
|
||||
}
|
||||
// 让 server 连不上:关掉进程内的假 server。
|
||||
srv.Close()
|
||||
|
||||
if err := mgr.SetEnabled(ctx, "calc", true); err == nil {
|
||||
t.Fatal("启用一台连不上的 server 应当报错")
|
||||
}
|
||||
cfgs, err := runtimeconfig.LoadPlugins(path)
|
||||
if err != nil || len(cfgs) != 1 {
|
||||
t.Fatalf("落盘 = %+v(err=%v)", cfgs, err)
|
||||
}
|
||||
if cfgs[0].IsEnabled() {
|
||||
t.Fatalf("启用失败却把 enabled 写成了 true:%+v", cfgs[0])
|
||||
}
|
||||
// 状态仍是 disabled(启用没成功,它就还是停用态),而 lastError 说清了这次失败 ——
|
||||
// 「有意停用」与「想启用但失败」由这两列分别表达,不互相冒充。
|
||||
st := statusOfOne(t, mgr, "calc")
|
||||
if st.State != ServerStateDisabled || st.Enabled {
|
||||
t.Fatalf("启用失败后状态 = %q/enabled=%v,期望 disabled/false", st.State, st.Enabled)
|
||||
}
|
||||
if st.Connected == nil || *st.Connected {
|
||||
t.Fatalf("启用失败后 connected = %v,期望 false(确认没连)", st.Connected)
|
||||
}
|
||||
if st.LastError == "" {
|
||||
t.Fatal("启用失败后 lastError 为空:面板说不出为什么挂了")
|
||||
}
|
||||
if st.LastErrorAt == "" {
|
||||
t.Fatal("启用失败后 lastErrorAt 为空")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReconnectRejectsDisabled 锁定:重连不替用户启用(那会让「它为什么忽然活了」变成谜)。
|
||||
func TestReconnectRejectsDisabled(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := filepath.Join(t.TempDir(), "plugins.json")
|
||||
srv := newFakeMCP(t, "", "add")
|
||||
mgr, _ := newEnabledManager(t, path, srv.URL)
|
||||
ctx := context.Background()
|
||||
if err := mgr.SetEnabled(ctx, "calc", false); err != nil {
|
||||
t.Fatalf("SetEnabled(false): %v", err)
|
||||
}
|
||||
if _, err := mgr.Reconnect(ctx, "calc"); !errors.Is(err, ErrPluginDisabled) {
|
||||
t.Fatalf("对停用的 server 重连 = %v,期望 ErrPluginDisabled", err)
|
||||
}
|
||||
if _, err := mgr.Reconnect(ctx, "ghost"); !errors.Is(err, ErrPluginNotFound) {
|
||||
t.Fatalf("重连不存在的 server = %v,期望 ErrPluginNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
// enabledFlag 是 `&false` 的简写(PluginConfig.Enabled 是三态指针)。
|
||||
func enabledFlag(v bool) *bool { return &v }
|
||||
@@ -33,6 +33,19 @@ const (
|
||||
mcpCapabilityPrefix = "mcp:"
|
||||
)
|
||||
|
||||
// MCPServerName 从 `mcp:<server>` 登记条目的注册键里取出 server 名。
|
||||
// 第二个返回值为 false 表示这不是一条 KindMCP 登记条目的键(或者 server 名为空)。
|
||||
//
|
||||
// 它是**注册键格式**的唯一真相(`mcpCapabilityPrefix` 只在这里出现),因此网关在需要
|
||||
// 「这条登记条目是哪台 server」时应当调它,而不是自己在别处再拼一遍前缀。
|
||||
func MCPServerName(capName string) (string, bool) {
|
||||
server, ok := strings.CutPrefix(capName, mcpCapabilityPrefix)
|
||||
if !ok || server == "" {
|
||||
return "", false
|
||||
}
|
||||
return server, true
|
||||
}
|
||||
|
||||
// Installed is one plugin as reported to the admin API: its persisted config,
|
||||
// whether a live server currently backs it, and how many tools it registered.
|
||||
type Installed struct {
|
||||
@@ -61,6 +74,10 @@ type Manager struct {
|
||||
|
||||
mu sync.Mutex
|
||||
servers map[string]*server
|
||||
// observed 是每台 server 最近一次被观察到的运行期事实(成功连上的时刻、最近一次失败
|
||||
// 的文本与时刻)。它与 servers(活没活)是两个不同的事实:一台连不上的 server 不在
|
||||
// servers 里,但它的失败记录必须留着 —— 否则「为什么挂了」在快照里就是一个空。
|
||||
observed map[string]observed
|
||||
// panelKeys 记住每个已安装插件**当时用过的**面板目录键(插件名 → 目录键)。
|
||||
// 它是 memo 而不是第二份真源:目录键由 PanelPluginID 从 cfg 推出,这里只把
|
||||
// 结果留下,好让 Uninstall 在活 server 已不在时仍能精确清掉该插件的面板。
|
||||
@@ -80,6 +97,7 @@ func NewManager(reg *registry.Registry, path string, log *slog.Logger) *Manager
|
||||
path: path,
|
||||
log: log,
|
||||
servers: make(map[string]*server),
|
||||
observed: make(map[string]observed),
|
||||
panelKeys: make(map[string]string),
|
||||
}
|
||||
}
|
||||
@@ -116,6 +134,11 @@ func (m *Manager) Install(ctx context.Context, cfg runtimeconfig.PluginConfig) (
|
||||
// install is Install with the persist step optional (Restore reuses it without
|
||||
// rewriting the file it just read). changeReason labels the panel change pushed
|
||||
// when the plugin becomes live (install vs enable).
|
||||
//
|
||||
// It respects cfg.Enabled: a config that says enabled=false is **persisted but not
|
||||
// connected** (no live server, no tools), which is how "installed but switched off"
|
||||
// survives a restart. Restore goes through this same path, so startup honours the
|
||||
// flag too.
|
||||
func (m *Manager) install(ctx context.Context, cfg runtimeconfig.PluginConfig, persist bool, changeReason string) (Installed, error) {
|
||||
cfg = cfg.Normalize()
|
||||
if err := cfg.Validate(); err != nil {
|
||||
@@ -129,8 +152,13 @@ func (m *Manager) install(ctx context.Context, cfg runtimeconfig.PluginConfig, p
|
||||
if prev != nil {
|
||||
m.stop(ctx, prev)
|
||||
}
|
||||
if !cfg.IsEnabled() {
|
||||
return m.installDisabled(cfg, persist, prev)
|
||||
}
|
||||
srv, err := m.start(ctx, cfg)
|
||||
if err != nil {
|
||||
// 失败也要留痕:状态快照要能说出「为什么挂了」,而不是把失败悄悄丢进日志。
|
||||
m.noteFailure(cfg, err)
|
||||
m.restore(prev)
|
||||
return Installed{}, err
|
||||
}
|
||||
@@ -142,11 +170,30 @@ func (m *Manager) install(ctx context.Context, cfg runtimeconfig.PluginConfig, p
|
||||
}
|
||||
}
|
||||
m.put(srv)
|
||||
m.noteSeen(cfg)
|
||||
m.log.Info("plugin installed", "name", cfg.Name, "kind", cfg.Kind, "transport", cfg.Transport, "tools", srv.toolCount)
|
||||
m.markInstalled(cfg, changeReason)
|
||||
return Installed{Config: cfg, Live: true, ToolCount: srv.toolCount}, nil
|
||||
}
|
||||
|
||||
// installDisabled 走「已安装但停用」这条路:只落配置,不连接、不发现、不注册任何工具,
|
||||
// 并把面板入口隐藏(`when:{enabled:true}` 的入口不出现)。
|
||||
//
|
||||
// 调用前 prev(同名活 server)已经被停掉:配置现在说的是「停用」,让旧 server 继续挂着
|
||||
// 就成了两份互相矛盾的事实。落盘失败时把 prev 恢复回来(回滚到「启用且活着」那个状态)。
|
||||
func (m *Manager) installDisabled(cfg runtimeconfig.PluginConfig, persist bool, prev *server) (Installed, error) {
|
||||
if persist {
|
||||
if err := m.persistUpsert(cfg); err != nil {
|
||||
m.restore(prev)
|
||||
return Installed{}, err
|
||||
}
|
||||
}
|
||||
m.noteDisabled(cfg)
|
||||
m.markDisabled(cfg)
|
||||
m.log.Info("plugin installed disabled", "name", cfg.Name, "kind", cfg.Kind, "transport", cfg.Transport)
|
||||
return Installed{Config: cfg}, nil
|
||||
}
|
||||
|
||||
// Uninstall removes the plugin matching name: it unregisters the live server's
|
||||
// tools and closes it, then deletes the persisted config. It reports whether
|
||||
// anything matched, so the caller can fall back to a plain capability removal.
|
||||
@@ -166,6 +213,7 @@ func (m *Manager) Uninstall(ctx context.Context, name string) (bool, error) {
|
||||
panelName = srv.cfg.Name
|
||||
}
|
||||
m.forgetPanels(panelName)
|
||||
m.forgetObserved(panelName)
|
||||
m.log.Info("plugin uninstalled", "name", name)
|
||||
}
|
||||
return removed, nil
|
||||
@@ -231,114 +279,6 @@ func (m *Manager) Close(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// SetEnabled 启用或停用一个已安装插件。
|
||||
//
|
||||
// 语义(§5.2.1 生命周期一栏):停用会关闭活的 server(其工具随之下线)并隐藏它的
|
||||
// 管理面板入口(`when: {enabled:true}` 的入口不再出现),**配置保留在 plugins.json**
|
||||
// —— 因此重启后它仍然「已安装」,只是要不要连着由这次调用决定;启用则重新连接并
|
||||
// 让入口回来。两条路都会推一次 `panels_changed`。
|
||||
//
|
||||
// ⚠️ 已知缺口(本片如实登记):启用状态本身**目前不持久化**(PluginConfig 没有该
|
||||
// 字段,给 plugins.json 加字段属 runtimeconfig 面,不在本片文件范围内),因此
|
||||
// **重启后停用态丢失、按已安装恢复为启用**。需要跨重启保持就寝语义时,先给
|
||||
// plugins.json 加 `enabled` 字段(并让 Restore 读它)。
|
||||
//
|
||||
// 找不到该插件(既不在活 server 里也不在 plugins.json 里)时返回 ErrPluginNotFound。
|
||||
func (m *Manager) SetEnabled(ctx context.Context, name string, on bool) error {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return fmt.Errorf("%w: empty name", ErrPluginNotFound)
|
||||
}
|
||||
if !on {
|
||||
return m.disable(ctx, name)
|
||||
}
|
||||
return m.enable(ctx, name)
|
||||
}
|
||||
|
||||
// ErrPluginNotFound 报告 SetEnabled 指定的插件既没活着也不在 plugins.json 里。
|
||||
var ErrPluginNotFound = errors.New("plugin: not found")
|
||||
|
||||
// disable 停用一个插件:摘掉活的 server,保留配置,并隐藏面板入口。
|
||||
//
|
||||
// 它刻意**不**动 plugins.json:「停用」不是「卸载」,卸载才是删配置
|
||||
// (design/09 §5.2.1 把 install/uninstall 与 enable/disable 并列,说明两者不同)。
|
||||
func (m *Manager) disable(ctx context.Context, name string) error {
|
||||
srv := m.takeAny(name)
|
||||
_, persisted := m.persistedConfig(name)
|
||||
if srv == nil && !persisted {
|
||||
return fmt.Errorf("%w: %s", ErrPluginNotFound, name)
|
||||
}
|
||||
if srv != nil {
|
||||
m.stop(ctx, srv)
|
||||
}
|
||||
m.setPanelsEnabled(name, false)
|
||||
m.log.Info("plugin disabled", "name", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// enable 重新启用一个已安装插件:按 plugins.json 里的配置重新连接。
|
||||
// 已经活着的插件是幂等空操作(面板集合没变 ⇒ 不推帧)。
|
||||
func (m *Manager) enable(ctx context.Context, name string) error {
|
||||
if m.liveServer(name) != nil {
|
||||
return nil
|
||||
}
|
||||
cfg, ok := m.persistedConfig(name)
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: %s", ErrPluginNotFound, name)
|
||||
}
|
||||
if _, err := m.install(ctx, cfg, false, PanelChangeEnabled); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// liveServer 返回活着的 server(按名字或 id 匹配,无则 nil)。
|
||||
func (m *Manager) liveServer(name string) *server {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if srv := m.servers[name]; srv != nil {
|
||||
return srv
|
||||
}
|
||||
for _, srv := range m.servers {
|
||||
if srv.cfg.ID != "" && srv.cfg.ID == name {
|
||||
return srv
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// takeAny 按名字或 id 摘掉活着的 server(take 只认精确的名字键)。
|
||||
func (m *Manager) takeAny(name string) *server {
|
||||
if srv := m.take(name); srv != nil {
|
||||
return srv
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for key, srv := range m.servers {
|
||||
if srv.cfg.ID != "" && srv.cfg.ID == name {
|
||||
delete(m.servers, key)
|
||||
return srv
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// persistedConfig 按名(或 id)在 plugins.json 里找一条配置。文件读不出来时按
|
||||
// 「没有这条」处理并记日志:停用/启用不该因为一个坏文件而变成 500。
|
||||
func (m *Manager) persistedConfig(name string) (runtimeconfig.PluginConfig, bool) {
|
||||
cfgs, err := runtimeconfig.LoadPlugins(m.path)
|
||||
if err != nil {
|
||||
m.log.Warn("plugins.json load failed", "path", m.path, "error", err)
|
||||
return runtimeconfig.PluginConfig{}, false
|
||||
}
|
||||
for _, cfg := range cfgs {
|
||||
if cfg.Name == name || (cfg.ID != "" && cfg.ID == name) {
|
||||
return cfg, true
|
||||
}
|
||||
}
|
||||
return runtimeconfig.PluginConfig{}, false
|
||||
}
|
||||
|
||||
// start connects cfg, registers its tools and surfaces its KindMCP capability.
|
||||
// On a registration failure it cleans up and returns the error.
|
||||
func (m *Manager) start(ctx context.Context, cfg runtimeconfig.PluginConfig) (*server, error) {
|
||||
@@ -389,6 +329,8 @@ func (m *Manager) restore(prev *server) {
|
||||
return
|
||||
}
|
||||
m.put(srv)
|
||||
// 回滚成功也是一次成功连接:快照的 lastSeenAt 不该停留在这之前的时刻。
|
||||
m.noteSeen(prev.cfg)
|
||||
}
|
||||
|
||||
// take removes and returns the live server named name, or nil.
|
||||
|
||||
@@ -62,6 +62,15 @@ type fakeMCP struct {
|
||||
|
||||
mu sync.Mutex
|
||||
auth string
|
||||
hits int
|
||||
}
|
||||
|
||||
// hitCount 返回该 server 收到的 POST 次数(「有没有真的连过」的证据:停用的 server
|
||||
// 在重启后不该被连一次)。
|
||||
func (f *fakeMCP) hitCount() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.hits
|
||||
}
|
||||
|
||||
func (f *fakeMCP) setTools(tools ...string) {
|
||||
@@ -88,6 +97,7 @@ func (f *fakeMCP) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
f.mu.Lock()
|
||||
f.auth = r.Header.Get("Authorization")
|
||||
f.hits++
|
||||
tools := append([]string(nil), f.tools...)
|
||||
f.mu.Unlock()
|
||||
if f.wantAuth != "" && r.Header.Get("Authorization") != "Bearer "+f.wantAuth {
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
package plugin
|
||||
|
||||
// 本文件是「MCP 服务器」管理页的**声明面**(design/09 §5.2 的 adminPanels,形状即
|
||||
// `pkg/plugins/sdk` 的 Panel):清单表(状态列 + 行内动作)+ 安装表单。
|
||||
//
|
||||
// 为什么这页由插件管理器自己声明(而不是某个外部插件):它是**管理器自己的页面** ——
|
||||
// 数据面就是 `*Manager` 的 `Statuses()`,动作面就是 `Install/Reconnect/SetEnabled/
|
||||
// Uninstall`。把声明放到别处就要再导出一套「代管理器说话」的中转,两处必然分叉。
|
||||
//
|
||||
// 声明只说三件事:要什么字段、怎么渲染、动作叫什么。它不注入 JS/CSS/HTML、不给 URL
|
||||
// (只有动作名)、不开任何路由 —— 分发走既有的通用动作端点
|
||||
// `POST /api/v1/admin/plugins/{id}/actions/{action}`(写动作要求 owner/admin + 审计)。
|
||||
//
|
||||
// 与前端通用渲染器的核对结论(本片任务书要求先验,`frontend/web/src/admin/plugin/`):
|
||||
// - 表格列只有 `key`/`label`(没有列类型/徽标)⇒ 状态列显示**闭集码**(与 web-search
|
||||
// 页的 `state` 列同一口径),工具栏退化成一列截断文本;
|
||||
// - 行内动作没有二次确认,且一律按**写**动作鉴权 ⇒ 卸载只能一键即发;
|
||||
// - `args`(字符串列表)用 `multiSelect`、`env`(键值对)用 `kv`:这两项**能直接渲染**
|
||||
// (无候选的 multiSelect 降级成逗号/换行分隔文本框,提交值仍是 `string[]`;
|
||||
// kv 是 JSON 对象输入),因此本页不需要新增字段类型、也不需要改前端。
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// MCPPanelPluginID 是「MCP 服务器」页在面板目录里的命名空间。
|
||||
//
|
||||
// 它是**宿主内置提供者**的 id,不是某个热装 MCP server 的名字。两者可能撞名(主人可以
|
||||
// 把 server 也叫 mcp),因此热装插件的生命周期一律不许改动这个名字的目录条目
|
||||
// (见 isReservedPanelKey)。
|
||||
const MCPPanelPluginID = "mcp"
|
||||
|
||||
// MCPPanelID 是面板 id。前端路由为 `/admin/plugin/mcp/servers`。
|
||||
const MCPPanelID = "servers"
|
||||
|
||||
// 本页声明的动作名。它们只是名字(不是 URL),分发端点是既有的
|
||||
// `POST /api/v1/admin/plugins/mcp/actions/<name>`。
|
||||
const (
|
||||
// ActionListMCPServers 列出全部 server 的状态快照(表格数据源,读面)。
|
||||
ActionListMCPServers = "plugin.mcpServers"
|
||||
// ActionInstallMCPServer 安装(或刷新)一台 server(表单提交,写面)。
|
||||
ActionInstallMCPServer = "plugin.installMcpServer"
|
||||
// ActionReconnectMCPServer 断开重连并重新发现工具(行内动作,写面)。
|
||||
ActionReconnectMCPServer = "plugin.reconnectMcpServer"
|
||||
// ActionEnableMCPServer 启用一台被停用的 server(行内动作,写面)。
|
||||
ActionEnableMCPServer = "plugin.enableMcpServer"
|
||||
// ActionDisableMCPServer 停用一台 server(行内动作,写面)。
|
||||
ActionDisableMCPServer = "plugin.disableMcpServer"
|
||||
// ActionUninstallMCPServer 卸载一台 server(行内动作,写面)。
|
||||
ActionUninstallMCPServer = "plugin.uninstallMcpServer"
|
||||
// ActionMCPTransports 是传输形态的候选来源(读面):`optionsFrom` 为
|
||||
// OptionsSourceTransports 时前端按此动作名取候选。
|
||||
ActionMCPTransports = "plugin.mcpTransports"
|
||||
)
|
||||
|
||||
// OptionsSourceTransports 是表单 `transport` 字段的候选来源(声明面写法 `plugin:` 前缀)。
|
||||
const OptionsSourceTransports = "plugin:mcpTransports"
|
||||
|
||||
// 「MCP 服务器」表单与表格的字段 key。它们是**面板契约**的一部分(前端提交的键名),
|
||||
// 因此集中在这里,处理器引用常量而不另写字面量。
|
||||
const (
|
||||
// FieldServerName 是 server 名(唯一标签,也是能力命名空间 `mcp:<name>`)。
|
||||
FieldServerName = "name"
|
||||
// FieldTransport 是传输形态(stdio / http / sse)。
|
||||
FieldTransport = "transport"
|
||||
// FieldCommand 是 stdio 的启动命令。
|
||||
FieldCommand = "command"
|
||||
// FieldArgs 是 stdio 的命令参数(字符串列表)。
|
||||
FieldArgs = "args"
|
||||
// FieldURL 是 http/sse 的服务地址(与运行时字段 endpoint 同义,面板用 url 更直白)。
|
||||
FieldURL = "url"
|
||||
// FieldAPIKey 是下发给 server 的 Bearer 凭据(secret:读侧永不回显)。
|
||||
FieldAPIKey = "apiKey"
|
||||
// FieldEnv 是 stdio 子进程的额外环境变量(键值对)。
|
||||
FieldEnv = "env"
|
||||
)
|
||||
|
||||
// MCPAdminPanels 返回「MCP 服务器」页的声明。
|
||||
func MCPAdminPanels() []sdk.Panel {
|
||||
return []sdk.Panel{{
|
||||
ID: MCPPanelID,
|
||||
Title: "MCP 服务器",
|
||||
Description: "接入外部 MCP server 并管理它们的运行状态:每台可独立启停、重连、" +
|
||||
"卸载,清单里直接看到连没连上、暴露了哪些工具、最近一次错在哪。" +
|
||||
"配置落在 $DATA_DIR/plugins.json,装/卸/启停**立即生效**(下一轮对话即可用," +
|
||||
"无需重启)。",
|
||||
Group: sdk.GroupPlugins,
|
||||
Order: 10,
|
||||
Icon: "server",
|
||||
// 内置提供者恒为启用;写条件是为了让「启用事实」由面板中枢给出(单一事实来源),
|
||||
// 而不是由声明自己假设。
|
||||
When: &sdk.When{Enabled: flagBool(true)},
|
||||
Sections: []sdk.Section{
|
||||
{
|
||||
Type: sdk.SectionTable,
|
||||
ID: "list",
|
||||
Title: "已接入的服务器",
|
||||
Source: &sdk.Source{Action: ActionListMCPServers},
|
||||
// 列 key 与 ServerRow 的字段同源;`target` / `toolsText` 是给表格用的
|
||||
// 显示派生量(地址或命令二选一、工具名拼一行),机器字段仍是
|
||||
// `endpoint`/`command`/`tools[]`。
|
||||
Columns: []sdk.Column{
|
||||
{Key: "name", Label: "名称"},
|
||||
{Key: "transport", Label: "传输"},
|
||||
{Key: "target", Label: "地址 / 命令"},
|
||||
{Key: "state", Label: "状态"},
|
||||
{Key: "toolsText", Label: "工具"},
|
||||
{Key: "lastError", Label: "最近一次错误"},
|
||||
{Key: "lastSeenAt", Label: "最近连接"},
|
||||
},
|
||||
RowActions: []sdk.RowAction{
|
||||
{ID: "reconnect", Label: "重连", Action: ActionReconnectMCPServer},
|
||||
{ID: "enable", Label: "启用", Action: ActionEnableMCPServer},
|
||||
{ID: "disable", Label: "停用", Action: ActionDisableMCPServer},
|
||||
{ID: "uninstall", Label: "卸载", Action: ActionUninstallMCPServer},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: sdk.SectionForm,
|
||||
ID: "install",
|
||||
Title: "安装 / 刷新服务器",
|
||||
Fields: []sdk.Field{
|
||||
{Key: FieldServerName, Label: "名称", Type: sdk.FieldText, Required: true,
|
||||
Placeholder: "如 fs / github;同名即刷新,能力命名空间为 mcp:<名称>"},
|
||||
{Key: FieldTransport, Label: "传输", Type: sdk.FieldSelect,
|
||||
OptionsFrom: OptionsSourceTransports,
|
||||
Placeholder: "留空 = 按 command / url 推断(有命令走 stdio,有地址走 http)"},
|
||||
{Key: FieldCommand, Label: "启动命令(stdio)", Type: sdk.FieldText,
|
||||
Placeholder: "npx / uvx / 可执行文件路径"},
|
||||
{Key: FieldArgs, Label: "命令参数(stdio)", Type: sdk.FieldMultiSelect,
|
||||
Placeholder: "逗号或换行分隔,如 -y,@modelcontextprotocol/server-filesystem,/tmp"},
|
||||
{Key: FieldURL, Label: "服务地址(http / sse)", Type: sdk.FieldText,
|
||||
Placeholder: "https://..."},
|
||||
{Key: FieldAPIKey, Label: "API Key", Type: sdk.FieldSecret,
|
||||
Placeholder: "留空 = 不带鉴权(非空则作为 Bearer 下发)"},
|
||||
{Key: FieldEnv, Label: "环境变量(stdio)", Type: sdk.FieldKV,
|
||||
Placeholder: `{"TOKEN":"..."}`},
|
||||
},
|
||||
Submit: &sdk.Submit{Action: ActionInstallMCPServer},
|
||||
},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
// DeclareMCPPanel 把「MCP 服务器」页登记到面板中枢(幂等:重复调用等于重新声明)。
|
||||
//
|
||||
// 它是组合根在**声明阶段**要调的那一步;动作处理器另由 `(*Manager).RegisterMCPPanelActions`
|
||||
// 登记(声明与登记是两个事实,见 actions.go 的口径)。
|
||||
func DeclareMCPPanel(hub *Panels) ([]string, error) {
|
||||
if hub == nil {
|
||||
return nil, fmt.Errorf("plugin: nil panel hub")
|
||||
}
|
||||
return hub.Declare(MCPPanelPluginID, MCPAdminPanels())
|
||||
}
|
||||
|
||||
// AttachMCPPanel 把「MCP 服务器」页完整接到本管理器的已接线中枢上:先声明、再登记动作
|
||||
// 处理器。组合根在 `wirePanelHub`(AttachPanels 之后)调它一次即可。
|
||||
//
|
||||
// 两步都在这里做完,是因为它们必须成对出现:只声明不登记 ⇒ 每个动作都诚实回 501;
|
||||
// 只登记不声明 ⇒ 动作不可达(Dispatch 先查声明)。中枢未接线时报错而不是静默成功。
|
||||
func (m *Manager) AttachMCPPanel() error {
|
||||
hub := m.Panels()
|
||||
if hub == nil {
|
||||
err := fmt.Errorf("%w: %s(面板中枢未接线,MCP 管理页无处挂)",
|
||||
ErrInvalidActionRegistration, MCPPanelPluginID)
|
||||
m.log.Error("mcp panel not declared; its actions will answer 501",
|
||||
"pluginId", MCPPanelPluginID, "error", err)
|
||||
return err
|
||||
}
|
||||
if _, err := DeclareMCPPanel(hub); err != nil {
|
||||
m.log.Error("mcp panel declaration rejected; its actions will answer 501",
|
||||
"pluginId", MCPPanelPluginID, "error", err)
|
||||
return fmt.Errorf("plugin: declare mcp panel: %w", err)
|
||||
}
|
||||
if err := m.RegisterMCPPanelActions(); err != nil {
|
||||
m.log.Error("mcp panel actions not registered; they will answer 501",
|
||||
"pluginId", MCPPanelPluginID, "error", err)
|
||||
return err
|
||||
}
|
||||
m.log.Info("mcp panel attached", "pluginId", MCPPanelPluginID, "panelId", MCPPanelID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// isReservedPanelKey 报告该目录键属于**宿主内置面板提供者**(不是热装插件的命名空间)。
|
||||
//
|
||||
// 护栏的方向是单向的:热装插件的安装/停用/卸载绝不许改动内置提供者的目录条目。理由很
|
||||
// 实在 —— 内置提供者的 id 是给人看的短名字(mcp / web-search / browser),而 server 的
|
||||
// 名字由主人随手起;一台恰好同名的 server 在卸载时若照常 MarkUninstalled,那整页内置页
|
||||
// 就在**重启前**消失了(`forgetPanels → MarkUninstalled` 连声明与动作索引一起清)。
|
||||
//
|
||||
// 名单写死成 switch 而不是包级 map:新增内置页时没补这一行,review 时一眼可见(与
|
||||
// paneldecl.shapeOf 同一取舍)。当前三个内置提供者:本包的 mcp 页(MCP 服务器)、
|
||||
// `plugins/tools/io/search` 的 web-search 页、`plugins/tools/browser` 的 browser 页。
|
||||
func isReservedPanelKey(key string) bool {
|
||||
switch key {
|
||||
case MCPPanelPluginID, "web-search", "browser":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
package plugin
|
||||
|
||||
// 本文件是「MCP 服务器」页的**执行面**(切片 M20):动作处理器 + 载荷解析 + 失败分类。
|
||||
//
|
||||
// 三条分工(与 design/09 §5.2.1 的动作分发一栏同向):
|
||||
// - 鉴权与审计在宿主端点(网关 `adminPluginAction`:写动作要求 owner/admin,每次落审计);
|
||||
// - **能不能调**由声明决定(Dispatch 先查声明),处理器只负责「这个名字要做什么」;
|
||||
// - 失败要说清病因:ActionError 带稳定错误码 + 一句人话 + 目标状态码,绝不把上游报文
|
||||
// 原样吐给前端(§5.4.6),也绝不把凭据写进返回值或日志。
|
||||
//
|
||||
// 载荷形状由前端通用渲染器决定(`frontend/web/src/admin/plugin/tableState.ts` /
|
||||
// `formState.ts`):表格数据源发空体;行内动作发 `{"row":{…行数据…}}`;表单提交发
|
||||
// `{"values":{…}}`(multiSelect → `[]string`、kv → JSON 对象、switch → bool、其余 → string)。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
)
|
||||
|
||||
// 本页动作的失败码(机器可读、稳定;前端按码本地化,不解析文案)。
|
||||
const (
|
||||
// mcpCodeInvalidConfig 报告提交的配置本身不合法(缺 name / 缺 command / 缺 endpoint)。
|
||||
mcpCodeInvalidConfig = "invalid_config"
|
||||
// mcpCodeInstallFailed 报告连接或发现失败(配置**不会**落盘)。
|
||||
mcpCodeInstallFailed = "install_failed"
|
||||
// mcpCodeReconnectFailed 报告重连失败(原连接已被摘下)。
|
||||
mcpCodeReconnectFailed = "reconnect_failed"
|
||||
// mcpCodeEnableFailed 报告启用失败(server 保持停用,不假装开着)。
|
||||
mcpCodeEnableFailed = "enable_failed"
|
||||
// mcpCodeDisableFailed 报告停用失败(落盘失败;连接可能仍在)。
|
||||
mcpCodeDisableFailed = "disable_failed"
|
||||
// mcpCodeUninstallFailed 报告卸载失败(落盘失败;工具是否已下线取决于错误发生的时机)。
|
||||
mcpCodeUninstallFailed = "uninstall_failed"
|
||||
// mcpCodeServerNotFound 报告点名的 server 既不在 plugins.json 里也不活着。
|
||||
mcpCodeServerNotFound = "server_not_found"
|
||||
// mcpCodeServerDisabled 报告对一台已停用的 server 发起了「重连」。
|
||||
mcpCodeServerDisabled = "server_disabled"
|
||||
)
|
||||
|
||||
// RegisterMCPPanelActions 给「MCP 服务器」页登记动作处理器。
|
||||
//
|
||||
// 它必须在**声明之后**调用(`AttachMCPPanel` 就按这个顺序做):登记期会核对「登记的
|
||||
// 处理器有没有对应声明」,顺序反过来只会得到一串「声明还没到」的 WARN。
|
||||
func (m *Manager) RegisterMCPPanelActions() error {
|
||||
return m.RegisterActions(MCPPanelPluginID, map[string]ActionHandler{
|
||||
ActionListMCPServers: m.actListServers,
|
||||
ActionInstallMCPServer: m.actInstallServer,
|
||||
ActionReconnectMCPServer: m.actReconnectServer,
|
||||
ActionEnableMCPServer: m.actEnableServer,
|
||||
ActionDisableMCPServer: m.actDisableServer,
|
||||
ActionUninstallMCPServer: m.actUninstallServer,
|
||||
ActionMCPTransports: m.actTransports,
|
||||
})
|
||||
}
|
||||
|
||||
// actListServers 返回全部 server 的状态快照(表格数据源)。
|
||||
//
|
||||
// 它是**读面**(viewer 也能调):快照里没有任何凭据(见 status.go 的口径②)。
|
||||
func (m *Manager) actListServers(_ context.Context, _ ActionRequest) (any, error) {
|
||||
statuses := m.Statuses()
|
||||
rows := make([]ServerRow, 0, len(statuses))
|
||||
for _, st := range statuses {
|
||||
rows = append(rows, serverRowOf(st))
|
||||
}
|
||||
return map[string]any{"rows": rows}, nil
|
||||
}
|
||||
|
||||
// actTransports 返回传输形态候选(`transport` 字段的 `optionsFrom`)。
|
||||
//
|
||||
// 候选的 value 与运行时接受的取值逐字一致(`runtimeconfig.Transport*`),label 是给人看
|
||||
// 的一句说明:写 `http` 而以为是「老式 SSE」是最常见的一次配错。
|
||||
func (m *Manager) actTransports(_ context.Context, _ ActionRequest) (any, error) {
|
||||
return map[string]any{"options": []map[string]string{
|
||||
{"value": runtimeconfig.TransportStdio, "label": "stdio(启动本地子进程)"},
|
||||
{"value": runtimeconfig.TransportHTTP, "label": "http(Streamable HTTP)"},
|
||||
{"value": runtimeconfig.TransportSSE, "label": "sse(老式 SSE 长连接)"},
|
||||
}}, nil
|
||||
}
|
||||
|
||||
// actInstallServer 安装(或刷新)一台 server:连接 + 发现 + 注册后才落盘。
|
||||
func (m *Manager) actInstallServer(ctx context.Context, req ActionRequest) (any, error) {
|
||||
values, err := actionValues(req.Payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg, err := installConfigOf(values)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, NewActionError(mcpCodeInvalidConfig, fmt.Sprintf("配置不完整:%v", err), 400, err)
|
||||
}
|
||||
res, err := m.Install(ctx, cfg)
|
||||
if err != nil {
|
||||
m.log.Warn("mcp install failed", "name", cfg.Name, "transport", cfg.Transport, "error", err)
|
||||
return nil, NewActionError(mcpCodeInstallFailed,
|
||||
fmt.Sprintf("连接/发现 MCP server %q 失败,配置未落盘", cfg.Name), 502, err)
|
||||
}
|
||||
return map[string]any{
|
||||
"installed": true,
|
||||
"name": res.Config.Name,
|
||||
"transport": res.Config.Transport,
|
||||
"toolCount": res.ToolCount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// installConfigOf 把表单值翻译成插件配置。
|
||||
//
|
||||
// `args` / `env` 的形状错**必须在这里变成 400**:前端的 `kv` 控件是自由 JSON textarea,
|
||||
// 主人写 `{"PORT":8080}` 是最自然的写法,若后端把非字符串值静默丢掉,结果就是「装完提示
|
||||
// 成功、server 也连上了、env 却没生效」—— 正是任务书点名要防的「看着配好了、其实没生效」。
|
||||
// 同理 `args` 里的非字符串元素也不许悄悄消失。
|
||||
func installConfigOf(values map[string]any) (runtimeconfig.PluginConfig, error) {
|
||||
args, err := actionStringList(values[FieldArgs])
|
||||
if err != nil {
|
||||
return runtimeconfig.PluginConfig{}, invalidConfigError(err)
|
||||
}
|
||||
env, err := actionStringMap(values[FieldEnv])
|
||||
if err != nil {
|
||||
return runtimeconfig.PluginConfig{}, invalidConfigError(err)
|
||||
}
|
||||
return runtimeconfig.PluginConfig{
|
||||
Name: actionText(values, FieldServerName),
|
||||
Kind: runtimeconfig.PluginKindMCP,
|
||||
Transport: actionText(values, FieldTransport),
|
||||
Endpoint: firstNonEmpty(actionText(values, FieldURL), actionText(values, "endpoint")),
|
||||
Command: actionText(values, FieldCommand),
|
||||
Args: args,
|
||||
APIKey: actionText(values, FieldAPIKey),
|
||||
Env: env,
|
||||
}.Normalize(), nil
|
||||
}
|
||||
|
||||
// invalidConfigError 把一条「配置没写对」包成结构化动作失败(400)。
|
||||
//
|
||||
// 文案里只出现**键名与类型**,绝不回显值:env 的值可能就是凭据。
|
||||
func invalidConfigError(err error) error {
|
||||
return NewActionError(mcpCodeInvalidConfig, fmt.Sprintf("配置没写对:%v", err), 400, err)
|
||||
}
|
||||
|
||||
// actReconnectServer 断开并重新连接一台已启用的 server(重新发现工具)。
|
||||
func (m *Manager) actReconnectServer(ctx context.Context, req ActionRequest) (any, error) {
|
||||
name, err := actionRowName(req.Payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res, err := m.Reconnect(ctx, name)
|
||||
switch {
|
||||
case errors.Is(err, ErrPluginNotFound):
|
||||
return nil, NewActionError(mcpCodeServerNotFound, fmt.Sprintf("没有这台 server:%s", name), 404, err)
|
||||
case errors.Is(err, ErrPluginDisabled):
|
||||
return nil, NewActionError(mcpCodeServerDisabled,
|
||||
fmt.Sprintf("server %q 已停用:先「启用」再重连", name), 409, err)
|
||||
case err != nil:
|
||||
m.log.Warn("mcp reconnect failed", "name", name, "error", err)
|
||||
return nil, NewActionError(mcpCodeReconnectFailed,
|
||||
fmt.Sprintf("重连 %q 失败,详细原因见后端日志", name), 502, err)
|
||||
}
|
||||
return map[string]any{"name": res.Config.Name, "reconnected": true, "toolCount": res.ToolCount}, nil
|
||||
}
|
||||
|
||||
// actEnableServer 启用一台 server(连接成功后才把 enabled=true 落盘)。
|
||||
func (m *Manager) actEnableServer(ctx context.Context, req ActionRequest) (any, error) {
|
||||
name, err := actionRowName(req.Payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := m.SetEnabled(ctx, name, true); err != nil {
|
||||
if errors.Is(err, ErrPluginNotFound) {
|
||||
return nil, NewActionError(mcpCodeServerNotFound, fmt.Sprintf("没有这台 server:%s", name), 404, err)
|
||||
}
|
||||
m.log.Warn("mcp enable failed", "name", name, "error", err)
|
||||
// 两种失败的事实不同,文案也必须不同:连不上 ⇒ 它仍是停用的;**已经连上但启用态
|
||||
// 没落盘** ⇒ 现在能用、重启后会回到停用(说成「仍处于停用状态」就是与事实相反)。
|
||||
if errors.Is(err, ErrPluginStatePersist) {
|
||||
return nil, NewActionError(mcpCodeEnableFailed,
|
||||
fmt.Sprintf("%q 已连上,但启用态未落盘:重启后会回到停用,详细原因见后端日志", name), 502, err)
|
||||
}
|
||||
return nil, NewActionError(mcpCodeEnableFailed,
|
||||
fmt.Sprintf("启用 %q 失败:它仍处于停用状态,详细原因见后端日志", name), 502, err)
|
||||
}
|
||||
return map[string]any{"name": name, "enabled": true, "connected": true}, nil
|
||||
}
|
||||
|
||||
// actDisableServer 停用一台 server(工具随之下线,配置保留)。
|
||||
func (m *Manager) actDisableServer(ctx context.Context, req ActionRequest) (any, error) {
|
||||
name, err := actionRowName(req.Payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := m.SetEnabled(ctx, name, false); err != nil {
|
||||
if errors.Is(err, ErrPluginNotFound) {
|
||||
return nil, NewActionError(mcpCodeServerNotFound, fmt.Sprintf("没有这台 server:%s", name), 404, err)
|
||||
}
|
||||
m.log.Warn("mcp disable failed", "name", name, "error", err)
|
||||
// 停用的落盘在**前面**(见 enabled.go):写不进去时 server 还在跑、工具还在。
|
||||
return nil, NewActionError(mcpCodeDisableFailed,
|
||||
fmt.Sprintf("停用 %q 失败:启用态未能落盘,server 仍在运行(可再试一次),详细原因见后端日志", name), 500, err)
|
||||
}
|
||||
return map[string]any{"name": name, "enabled": false, "connected": false}, nil
|
||||
}
|
||||
|
||||
// actUninstallServer 卸载一台 server(摘工具 + 删配置)。
|
||||
func (m *Manager) actUninstallServer(ctx context.Context, req ActionRequest) (any, error) {
|
||||
name, err := actionRowName(req.Payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
removed, err := m.Uninstall(ctx, name)
|
||||
if err != nil {
|
||||
m.log.Warn("mcp uninstall failed", "name", name, "error", err)
|
||||
return nil, NewActionError(mcpCodeUninstallFailed,
|
||||
fmt.Sprintf("卸载 %q 失败,详细原因见后端日志", name), 500, err)
|
||||
}
|
||||
if !removed {
|
||||
return nil, NewActionError(mcpCodeServerNotFound, fmt.Sprintf("没有这台 server:%s", name), 404, nil)
|
||||
}
|
||||
return map[string]any{"name": name, "uninstalled": true}, nil
|
||||
}
|
||||
|
||||
// ServerRow 是「MCP 服务器」表格的一行:状态快照 + 两列显示派生量。
|
||||
//
|
||||
// 快照字段(Status)逐字嵌入,因此一行既是表格数据、也是机器可读的状态契约;两个派生列
|
||||
// 只为表格排版而存在(一列只能显示一个标量)。
|
||||
type ServerRow struct {
|
||||
Status
|
||||
// Target 是「地址 / 命令」列的显示值(http/sse ⇒ 地址;stdio ⇒ 命令)。
|
||||
Target string `json:"target,omitempty"`
|
||||
// ToolsText 是「工具」列的显示值(工具名以「、」拼接并截断)。
|
||||
ToolsText string `json:"toolsText,omitempty"`
|
||||
}
|
||||
|
||||
// serverRowOf 把一条快照翻成表格行。
|
||||
func serverRowOf(st Status) ServerRow {
|
||||
target := st.Endpoint
|
||||
if target == "" {
|
||||
target = st.Command
|
||||
}
|
||||
return ServerRow{Status: st, Target: target, ToolsText: toolsText(st.Tools)}
|
||||
}
|
||||
|
||||
// --- 载荷解析 -----------------------------------------------------------------
|
||||
|
||||
// actionValues 取出表单提交的 `values` 对象(缺省 / 形状不对都如实报错)。
|
||||
func actionValues(payload any) (map[string]any, error) {
|
||||
obj, ok := payload.(map[string]any)
|
||||
if !ok {
|
||||
return nil, NewActionError(ActionCodeInvalidPayload, "请求体缺少 values 对象", 400, nil)
|
||||
}
|
||||
raw, ok := obj["values"]
|
||||
if !ok {
|
||||
return nil, NewActionError(ActionCodeInvalidPayload, "请求体缺少 values 对象", 400, nil)
|
||||
}
|
||||
values, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return nil, NewActionError(ActionCodeInvalidPayload, "values 必须是对象", 400, nil)
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
// actionRowName 取出行内动作点名的 server 名(`{"row":{"name":…}}`,也接受 `{"name":…}`)。
|
||||
func actionRowName(payload any) (string, error) {
|
||||
obj, ok := payload.(map[string]any)
|
||||
if !ok {
|
||||
return "", NewActionError(ActionCodeInvalidPayload, "行内动作缺少 row 对象", 400, nil)
|
||||
}
|
||||
row, ok := obj["row"].(map[string]any)
|
||||
if !ok {
|
||||
// 容错:某些客户端直接发 `{name, …}`。名字仍然只有这一个真源,不猜别的键。
|
||||
row = obj
|
||||
}
|
||||
name := actionText(row, FieldServerName)
|
||||
if name == "" {
|
||||
return "", NewActionError(ActionCodeInvalidPayload, "row 里缺少 name", 400, nil)
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// actionText 取一个字符串值(非字符串 / 缺省都给空串 —— 空串在配置里就是「没填」)。
|
||||
func actionText(values map[string]any, key string) string {
|
||||
raw, ok := values[key]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
s, ok := raw.(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
// actionStringList 取一个字符串列表(`multiSelect` 提交的是 `[]string`,JSON 解码后是
|
||||
// `[]any`)。
|
||||
//
|
||||
// 形状错一律报错:非数组、元素非字符串都不许**静默丢弃**(少一个参数、或多出一个空参数,
|
||||
// 都会让 server 起来后行为不对,而面板还报「装好了」)。空项按前端 `splitListValue` 的同一
|
||||
// 口径去掉(那只来自多余的分隔符,不是用户意图)。
|
||||
func actionStringList(raw any) ([]string, error) {
|
||||
switch items := raw.(type) {
|
||||
case nil:
|
||||
return nil, nil
|
||||
case []string:
|
||||
return compactStrings(items), nil
|
||||
case []any:
|
||||
out := make([]string, 0, len(items))
|
||||
for i, item := range items {
|
||||
s, ok := item.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("args 的第 %d 项不是字符串(收到 %s);每个参数都必须是字符串", i+1, jsonKindOf(item))
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return compactStrings(out), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("args 必须是字符串数组(收到 %s)", jsonKindOf(raw))
|
||||
}
|
||||
}
|
||||
|
||||
// actionStringMap 取一个键值对(`kv` 提交的是 JSON 对象)。
|
||||
//
|
||||
// 值**只接受字符串**,其它类型一律报错而不是丢掉那个键:`{"PORT":8080}` 是最自然的写法,
|
||||
// 静默丢掉它等于「装完提示成功、env 却没生效」(前端 `KvControl` 只校验「是个对象」,
|
||||
// 所以这条路 UI 天然可达)。空键同理 —— 丢一个键与拒一个键必须选后者。
|
||||
func actionStringMap(raw any) (map[string]string, error) {
|
||||
var pairs map[string]any
|
||||
switch obj := raw.(type) {
|
||||
case nil:
|
||||
return nil, nil
|
||||
case map[string]any:
|
||||
pairs = obj
|
||||
case map[string]string:
|
||||
pairs = make(map[string]any, len(obj))
|
||||
for k, v := range obj {
|
||||
pairs[k] = v
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("env 必须是键值对对象(收到 %s)", jsonKindOf(raw))
|
||||
}
|
||||
out := make(map[string]string, len(pairs))
|
||||
for k, v := range pairs {
|
||||
key := strings.TrimSpace(k)
|
||||
if key == "" {
|
||||
return nil, fmt.Errorf("env 里有空键;键就是环境变量名,不能为空")
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("env.%s 的值不是字符串(收到 %s);要写数字/布尔请加引号,如 \"8080\"", key, jsonKindOf(v))
|
||||
}
|
||||
out[key] = s
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// jsonKindOf 给出一个 JSON 值的中文类型名(只为错误文案:说清「收到的是什么」)。
|
||||
//
|
||||
// 它刻意不打印值本身:env 的值可能就是凭据。
|
||||
func jsonKindOf(v any) string {
|
||||
switch v.(type) {
|
||||
case nil:
|
||||
return "null"
|
||||
case string:
|
||||
return "字符串"
|
||||
case bool:
|
||||
return "布尔"
|
||||
case float64, float32, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
|
||||
return "数字"
|
||||
case []any:
|
||||
return "数组"
|
||||
case map[string]any:
|
||||
return "对象"
|
||||
default:
|
||||
return fmt.Sprintf("%T", v)
|
||||
}
|
||||
}
|
||||
|
||||
// compactStrings 去掉两端空白与空项;结果为空时返回 nil。
|
||||
func compactStrings(items []string) []string {
|
||||
out := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
if s := strings.TrimSpace(item); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// firstNonEmpty 返回首个非空值(安装表单里 url 与 endpoint 是同义键)。
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, v := range values {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
package plugin
|
||||
|
||||
// 本文件锁定「MCP 服务器」页(切片 M20)的三件事:
|
||||
// ① 声明本身合法(形状按 paneldecl 校验通过 —— 前端按声明泛化渲染,形状错就是半渲染);
|
||||
// ② 声明与登记**成对**(每个声明的动作都有处理器:只有声明 ⇒ 每个动作都 501);
|
||||
// ③ 动作面端到端可用:装 → 看 → 重连 → 停 → 启用 → 卸,每一步都推 panels_changed,
|
||||
// 失败按结构化错误码如实分类(install_failed / server_not_found / server_disabled)。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/paneldecl"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// TestMCPPanelDeclarationIsValid 锁定 ①:整份声明过 paneldecl 的形状校验。
|
||||
func TestMCPPanelDeclarationIsValid(t *testing.T) {
|
||||
t.Parallel()
|
||||
if err := paneldecl.Validate(MCPPanelPluginID, MCPAdminPanels()); err != nil {
|
||||
t.Fatalf("「MCP 服务器」页声明非法:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMCPPanelActionsWired 锁定 ②:声明的动作与登记的处理器必须一一对应。
|
||||
//
|
||||
// 这条断言的价值是把「声明里写了、没人实现」从「用户点出一个 501」提前到测试里:少一个
|
||||
// 处理器,本测试直接红。
|
||||
func TestMCPPanelActionsWired(t *testing.T) {
|
||||
t.Parallel()
|
||||
mgr, hub, _ := newPanelManager(t)
|
||||
defer mgr.Close(context.Background())
|
||||
if err := mgr.AttachMCPPanel(); err != nil {
|
||||
t.Fatalf("AttachMCPPanel: %v", err)
|
||||
}
|
||||
declared := hub.DeclaredActions(MCPPanelPluginID)
|
||||
handled := hub.ActionHandlerNames(MCPPanelPluginID)
|
||||
if len(declared) == 0 || len(handled) == 0 {
|
||||
t.Fatalf("声明 %v / 处理器 %v;两者都不该为空", declared, handled)
|
||||
}
|
||||
set := make(map[string]bool, len(handled))
|
||||
for _, name := range handled {
|
||||
set[name] = true
|
||||
}
|
||||
for _, name := range declared {
|
||||
if !set[name] {
|
||||
t.Fatalf("动作 %q 已声明但没有处理器(点了会 501)", name)
|
||||
}
|
||||
}
|
||||
// 候选来源(`optionsFrom: plugin:mcpTransports`)必须能被解析成已登记的动作名,
|
||||
// 否则前端的传输下拉会静默降级成文本框。
|
||||
if name := OptionsActionName(OptionsSourceTransports); !set[name] {
|
||||
t.Fatalf("候选来源 %q 映射到 %q,但没有处理器", OptionsSourceTransports, name)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMCPPanelInstallAndList 锁定 ③ 的前半:安装(表单载荷)→ 看(表格行)。
|
||||
func TestMCPPanelInstallAndList(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := newFakeMCP(t, "", "add", "mul")
|
||||
mgr, hub, _ := newPanelManager(t)
|
||||
defer mgr.Close(context.Background())
|
||||
if err := mgr.AttachMCPPanel(); err != nil {
|
||||
t.Fatalf("AttachMCPPanel: %v", err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
installViaAction(t, hub, ctx, "calc", srv.URL)
|
||||
assertInstalledConfig(t, mgr, "calc")
|
||||
row := mustRows(t, hub, ctx)[0]
|
||||
if row.Name != "calc" || row.State != ServerStateConnected || row.ToolCount == nil || *row.ToolCount != 2 {
|
||||
t.Fatalf("行 = %+v,期望 calc/connected/2 工具", row)
|
||||
}
|
||||
if row.ToolsText != "add、mul" {
|
||||
t.Fatalf("toolsText = %q,期望 add、mul", row.ToolsText)
|
||||
}
|
||||
if row.Target != srv.URL {
|
||||
t.Fatalf("target = %q,期望地址 %q", row.Target, srv.URL)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMCPPanelToggleUninstallAndFrames 锁定 ③ 的后半:重连 / 停用 / 启用 / 卸载,
|
||||
// 以及每一步都推 `panels_changed`(前端据此热更新,不必刷新页面)。
|
||||
func TestMCPPanelToggleUninstallAndFrames(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := newFakeMCP(t, "", "add", "mul")
|
||||
mgr, hub, rec := newPanelManager(t)
|
||||
defer mgr.Close(context.Background())
|
||||
if err := mgr.AttachMCPPanel(); err != nil {
|
||||
t.Fatalf("AttachMCPPanel: %v", err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
installViaAction(t, hub, ctx, "calc", srv.URL)
|
||||
|
||||
if _, err := dispatchRow(t, hub, ActionReconnectMCPServer, "calc"); err != nil {
|
||||
t.Fatalf("reconnect 动作: %v", err)
|
||||
}
|
||||
if _, err := dispatchRow(t, hub, ActionDisableMCPServer, "calc"); err != nil {
|
||||
t.Fatalf("disable 动作: %v", err)
|
||||
}
|
||||
if names := toolNames(mgr.reg); len(names) != 0 {
|
||||
t.Fatalf("停用后工具仍在注册表 = %v", names)
|
||||
}
|
||||
if row := mustRows(t, hub, ctx)[0]; row.State != ServerStateDisabled || row.Enabled {
|
||||
t.Fatalf("停用后行 = %+v", row)
|
||||
}
|
||||
_, err := dispatchRow(t, hub, ActionReconnectMCPServer, "calc")
|
||||
if ae, ok := AsActionError(err); !ok || ae.Code != mcpCodeServerDisabled || ae.Status != http.StatusConflict {
|
||||
t.Fatalf("对停用的 server 重连 = %v,期望 %s/409", err, mcpCodeServerDisabled)
|
||||
}
|
||||
|
||||
if _, err := dispatchRow(t, hub, ActionEnableMCPServer, "calc"); err != nil {
|
||||
t.Fatalf("enable 动作: %v", err)
|
||||
}
|
||||
if names := toolNames(mgr.reg); !names["add"] || !names["mul"] {
|
||||
t.Fatalf("启用后工具 = %v,期望 add+mul", names)
|
||||
}
|
||||
if _, err := dispatchRow(t, hub, ActionUninstallMCPServer, "calc"); err != nil {
|
||||
t.Fatalf("uninstall 动作: %v", err)
|
||||
}
|
||||
if rows := mustRows(t, hub, ctx); len(rows) != 0 {
|
||||
t.Fatalf("卸载后仍有行:%+v", rows)
|
||||
}
|
||||
|
||||
reasons := changeReasons(rec.all())
|
||||
for _, want := range []string{
|
||||
PanelChangeInstalled, PanelChangeDisabled, PanelChangeEnabled, PanelChangeUninstalled,
|
||||
} {
|
||||
if !containsString(reasons, want) {
|
||||
t.Fatalf("变更原因 = %v,缺 %q", reasons, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMCPPanelInstallFailureIsClassified 锁定失败分类:装不上 ≠ 找不到 ≠ 已停用。
|
||||
func TestMCPPanelInstallFailureIsClassified(t *testing.T) {
|
||||
t.Parallel()
|
||||
dead := httptest.NewServer(http.NotFoundHandler())
|
||||
deadURL := dead.URL
|
||||
dead.Close()
|
||||
mgr, hub, _ := newPanelManager(t)
|
||||
defer mgr.Close(context.Background())
|
||||
if err := mgr.AttachMCPPanel(); err != nil {
|
||||
t.Fatalf("AttachMCPPanel: %v", err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := hub.Dispatch(ctx, ActionRequest{
|
||||
PluginID: MCPPanelPluginID, Action: ActionInstallMCPServer,
|
||||
Payload: map[string]any{"values": map[string]any{"name": "down", "url": deadURL}},
|
||||
})
|
||||
if ae, ok := AsActionError(err); !ok || ae.Code != mcpCodeInstallFailed || ae.Status != http.StatusBadGateway {
|
||||
t.Fatalf("连接失败 = %v,期望 %s/502", err, mcpCodeInstallFailed)
|
||||
}
|
||||
// 配置不落盘,但失败要留在快照里(面板要说得出为什么挂了)。
|
||||
if cfgs, err := runtimeconfig.LoadPlugins(mgr.path); err != nil || len(cfgs) != 0 {
|
||||
t.Fatalf("失败后落盘 = %+v(err=%v),期望空", cfgs, err)
|
||||
}
|
||||
if row := mustRows(t, hub, ctx)[0]; row.State != ServerStateError || row.LastError == "" {
|
||||
t.Fatalf("失败行 = %+v,期望 error 且带 lastError", row)
|
||||
}
|
||||
|
||||
// 缺字段 ⇒ invalid_config / 400(不是 502:请求本身写错了)。
|
||||
_, err = hub.Dispatch(ctx, ActionRequest{
|
||||
PluginID: MCPPanelPluginID, Action: ActionInstallMCPServer,
|
||||
Payload: map[string]any{"values": map[string]any{"transport": "stdio"}},
|
||||
})
|
||||
if ae, ok := AsActionError(err); !ok || ae.Code != mcpCodeInvalidConfig || ae.Status != http.StatusBadRequest {
|
||||
t.Fatalf("缺 name/command = %v,期望 %s/400", err, mcpCodeInvalidConfig)
|
||||
}
|
||||
|
||||
// 卸载一个不存在的名字 ⇒ server_not_found / 404。
|
||||
_, err = dispatchRow(t, hub, ActionUninstallMCPServer, "ghost")
|
||||
if ae, ok := AsActionError(err); !ok || ae.Code != mcpCodeServerNotFound || ae.Status != http.StatusNotFound {
|
||||
t.Fatalf("卸载不存在的 server = %v,期望 %s/404", err, mcpCodeServerNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMCPPanelReservedNamespaceIsGuarded 锁定护栏:一台恰好叫 mcp 的 server 的卸载/停用
|
||||
// 不许把内置「MCP 服务器」页整页摘掉。
|
||||
func TestMCPPanelReservedNamespaceIsGuarded(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := newFakeMCP(t, "", "add")
|
||||
mgr, hub, _ := newPanelManager(t)
|
||||
defer mgr.Close(context.Background())
|
||||
if err := mgr.AttachMCPPanel(); err != nil {
|
||||
t.Fatalf("AttachMCPPanel: %v", err)
|
||||
}
|
||||
installFake(t, mgr, MCPPanelPluginID, srv.URL)
|
||||
if _, err := mgr.Uninstall(context.Background(), MCPPanelPluginID); err != nil {
|
||||
t.Fatalf("Uninstall: %v", err)
|
||||
}
|
||||
entries, err := hub.List(auth.RoleOwner)
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(entries) != 1 || entries[0].PluginID != MCPPanelPluginID {
|
||||
t.Fatalf("内置 MCP 页被热装插件的生命周期改动了:%+v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
// mustRows 取表格行(`{rows}` 载荷)并断言载荷形状。
|
||||
func mustRows(t *testing.T, hub *Panels, ctx context.Context) []ServerRow {
|
||||
t.Helper()
|
||||
res, err := hub.Dispatch(ctx, ActionRequest{PluginID: MCPPanelPluginID, Action: ActionListMCPServers})
|
||||
if err != nil {
|
||||
t.Fatalf("list 动作: %v", err)
|
||||
}
|
||||
body, ok := res.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("list 结果 = %T,期望对象", res)
|
||||
}
|
||||
rows, ok := body["rows"].([]ServerRow)
|
||||
if !ok {
|
||||
t.Fatalf("list 结果的 rows 类型 = %T", body["rows"])
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// dispatchRow 以行内动作的形状(`{row:{name}}`)分发一次动作。
|
||||
func dispatchRow(t *testing.T, hub *Panels, action, name string) (any, error) {
|
||||
t.Helper()
|
||||
return hub.Dispatch(context.Background(), ActionRequest{
|
||||
PluginID: MCPPanelPluginID, Action: action,
|
||||
Payload: map[string]any{"row": map[string]any{FieldServerName: name}},
|
||||
})
|
||||
}
|
||||
|
||||
// installViaAction 以表单载荷(`{values}`)经动作面安装一台 server。
|
||||
func installViaAction(t *testing.T, hub *Panels, ctx context.Context, name, url string) {
|
||||
t.Helper()
|
||||
if _, err := hub.Dispatch(ctx, ActionRequest{
|
||||
PluginID: MCPPanelPluginID, Action: ActionInstallMCPServer,
|
||||
Payload: map[string]any{"values": map[string]any{
|
||||
"name": name, "transport": "http", "url": url,
|
||||
"args": []any{"--flag", "value"},
|
||||
"env": map[string]any{"TOKEN": "t0ken"},
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatalf("install 动作: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// assertInstalledConfig 断言表单字段确实映射进了 plugins.json(args/env 这类列表/键值
|
||||
// 最容易在「前端提交了、后端读错键」这一步静默丢失)。
|
||||
func assertInstalledConfig(t *testing.T, mgr *Manager, name string) {
|
||||
t.Helper()
|
||||
cfgs, err := runtimeconfig.LoadPlugins(mgr.path)
|
||||
if err != nil || len(cfgs) != 1 {
|
||||
t.Fatalf("落盘 = %+v(err=%v),期望一条", cfgs, err)
|
||||
}
|
||||
if cfgs[0].Name != name {
|
||||
t.Fatalf("落盘名字 = %q", cfgs[0].Name)
|
||||
}
|
||||
if len(cfgs[0].Args) != 2 || cfgs[0].Args[1] != "value" {
|
||||
t.Fatalf("args 没映射进配置:%+v", cfgs[0].Args)
|
||||
}
|
||||
if cfgs[0].Env["TOKEN"] != "t0ken" {
|
||||
t.Fatalf("env 没映射进配置:%+v", cfgs[0].Env)
|
||||
}
|
||||
}
|
||||
|
||||
// containsString 报告 items 里是否有 want。
|
||||
func containsString(items []string, want string) bool {
|
||||
for _, item := range items {
|
||||
if item == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TestMCPPanelInstallRejectsNonStringEnv 锁定 P2-1:`env` / `args` 里的非字符串值必须
|
||||
// 变成 400,**不许静默丢键**(`{"PORT":8080}` 是前端 kv 控件最自然的写法:装完提示成功、
|
||||
// server 连上了、env 却没生效 —— 比直接失败更坏)。
|
||||
func TestMCPPanelInstallRejectsNonStringEnv(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := newFakeMCP(t, "", "add")
|
||||
mgr, hub, _ := newPanelManager(t)
|
||||
defer mgr.Close(context.Background())
|
||||
if err := mgr.AttachMCPPanel(); err != nil {
|
||||
t.Fatalf("AttachMCPPanel: %v", err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
mutate func(values map[string]any)
|
||||
wantKey string
|
||||
}{
|
||||
{"env 值是数字", func(v map[string]any) { v["env"] = map[string]any{"PORT": 9182.0} }, "env.PORT"},
|
||||
{"env 值是布尔", func(v map[string]any) { v["env"] = map[string]any{"DEBUG": true} }, "env.DEBUG"},
|
||||
{"args 元素是数字", func(v map[string]any) { v["args"] = []any{"-y", 42.0} }, "args"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
values := map[string]any{
|
||||
"name": "calc", "transport": "http", "url": srv.URL,
|
||||
"env": map[string]any{"TOKEN": "abc"},
|
||||
"args": []any{"--flag"},
|
||||
}
|
||||
tc.mutate(values)
|
||||
_, err := hub.Dispatch(ctx, ActionRequest{
|
||||
PluginID: MCPPanelPluginID, Action: ActionInstallMCPServer,
|
||||
Payload: map[string]any{"values": values},
|
||||
})
|
||||
ae, ok := AsActionError(err)
|
||||
if !ok || ae.Code != mcpCodeInvalidConfig || ae.Status != http.StatusBadRequest {
|
||||
t.Fatalf("%s:err = %v,期望 %s/400", tc.name, err, mcpCodeInvalidConfig)
|
||||
}
|
||||
if !strings.Contains(ae.Message, tc.wantKey) {
|
||||
t.Fatalf("%s:文案没点名出错的键(%q 不含 %q)", tc.name, ae.Message, tc.wantKey)
|
||||
}
|
||||
// 文案里不许回显**收到的值**(env 的值可能就是凭据):9182 是本用例特意挑的
|
||||
// 独特数字,文案里的 "8080" 只是「要加引号」的示例,不是回显。
|
||||
if strings.Contains(ae.Message, "9182") {
|
||||
t.Fatalf("%s:文案回显了值:%q", tc.name, ae.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// 三次都失败 ⇒ 盘上零变更、注册表零工具、列表零行。
|
||||
if cfgs, err := runtimeconfig.LoadPlugins(mgr.path); err != nil || len(cfgs) != 0 {
|
||||
t.Fatalf("盘上 = %+v(err=%v),期望空", cfgs, err)
|
||||
}
|
||||
if names := toolNames(mgr.reg); len(names) != 0 {
|
||||
t.Fatalf("注册表里有工具 = %v", names)
|
||||
}
|
||||
if rows := mustRows(t, hub, ctx); len(rows) != 0 {
|
||||
t.Fatalf("快照里有行 = %+v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReservedPanelKeysProtectBuiltinProviders 锁定顺手件 1:三台内置提供者的命名空间
|
||||
// 都不许被一台同名 server 的安装/停用/启用/卸载改写(否则内置页在重启前就消失)。
|
||||
func TestReservedPanelKeysProtectBuiltinProviders(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, id := range []string{MCPPanelPluginID, "web-search", "browser"} {
|
||||
id := id
|
||||
t.Run(id, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := newFakeMCP(t, "", "add")
|
||||
mgr, hub, _ := newPanelManager(t)
|
||||
defer mgr.Close(context.Background())
|
||||
ctx := context.Background()
|
||||
// 模拟该内置提供者在装配期声明的那一页。
|
||||
if _, err := hub.Declare(id, []sdk.Panel{samplePanel()}); err != nil {
|
||||
t.Fatalf("Declare(%s): %v", id, err)
|
||||
}
|
||||
installFake(t, mgr, id, srv.URL)
|
||||
assertBuiltinPageAlive(t, hub, id, "安装")
|
||||
if err := mgr.SetEnabled(ctx, id, false); err != nil {
|
||||
t.Fatalf("SetEnabled(false): %v", err)
|
||||
}
|
||||
assertBuiltinPageAlive(t, hub, id, "停用")
|
||||
if err := mgr.SetEnabled(ctx, id, true); err != nil {
|
||||
t.Fatalf("SetEnabled(true): %v", err)
|
||||
}
|
||||
if _, err := mgr.Uninstall(ctx, id); err != nil {
|
||||
t.Fatalf("Uninstall: %v", err)
|
||||
}
|
||||
assertBuiltinPageAlive(t, hub, id, "卸载")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// assertBuiltinPageAlive 断言该内置提供者的页仍在目录里。
|
||||
func assertBuiltinPageAlive(t *testing.T, hub *Panels, id, step string) {
|
||||
t.Helper()
|
||||
entries, err := hub.List(auth.RoleOwner)
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.PluginID == id {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("%s一台同名 server 之后,内置页 %q 不见了:%+v", step, id, entries)
|
||||
}
|
||||
|
||||
// TestMCPPanelEnablePersistFailureReportsAccurately 锁定顺手件 2:server **已连上**但
|
||||
// 启用态没落盘时,文案必须说的是这件事(「仍处于停用状态」是相反的陈述)。
|
||||
//
|
||||
// 只在本平台跑:靠「把 plugins.json 置成只读」让 rename 失败(Unix 下 rename 由目录权限
|
||||
// 决定,同法不成立)。
|
||||
func TestMCPPanelEnablePersistFailureReportsAccurately(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("只读位只在 windows 的 rename 上生效")
|
||||
}
|
||||
t.Parallel()
|
||||
srv := newFakeMCP(t, "", "add")
|
||||
mgr, hub, _ := newPanelManager(t)
|
||||
defer mgr.Close(context.Background())
|
||||
if err := mgr.AttachMCPPanel(); err != nil {
|
||||
t.Fatalf("AttachMCPPanel: %v", err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
installViaAction(t, hub, ctx, "calc", srv.URL)
|
||||
if _, err := dispatchRow(t, hub, ActionDisableMCPServer, "calc"); err != nil {
|
||||
t.Fatalf("disable: %v", err)
|
||||
}
|
||||
if err := os.Chmod(mgr.path, 0o444); err != nil {
|
||||
t.Fatalf("chmod: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chmod(mgr.path, 0o600) })
|
||||
|
||||
_, err := dispatchRow(t, hub, ActionEnableMCPServer, "calc")
|
||||
ae, ok := AsActionError(err)
|
||||
if !ok || ae.Code != mcpCodeEnableFailed || ae.Status != http.StatusBadGateway {
|
||||
t.Fatalf("启用(落盘失败)= %v,期望 %s/502", err, mcpCodeEnableFailed)
|
||||
}
|
||||
// 断言的是**文案与事实一致**(下面两条),不是某个内部哨兵:这句话是给主人看的。
|
||||
|
||||
if !strings.Contains(ae.Message, "未落盘") || strings.Contains(ae.Message, "仍处于停用状态") {
|
||||
t.Fatalf("文案与事实不符:%q", ae.Message)
|
||||
}
|
||||
// 事实是「已经连上、工具可用」,快照也这么说(state 派生口径不变)。
|
||||
if names := toolNames(mgr.reg); !names["add"] {
|
||||
t.Fatalf("启用后工具 = %v,期望已连上并注册", names)
|
||||
}
|
||||
if row := mustRows(t, hub, ctx)[0]; row.State != ServerStateConnected || row.Enabled {
|
||||
t.Fatalf("快照 = %+v,期望 state=connected 且 enabled=false(盘上仍是停用)", row)
|
||||
}
|
||||
}
|
||||
@@ -287,15 +287,11 @@ func PanelIDValid(id string) bool {
|
||||
// markInstalled 记录安装时用过的面板目录键(memo,供卸载/停用复用),并把它报给
|
||||
// 面板中枢。未接线时只记 memo —— 计时与归因不依赖中枢在不在。
|
||||
func (m *Manager) markInstalled(cfg runtimeconfig.PluginConfig, changeReason string) {
|
||||
key := PanelPluginID(cfg)
|
||||
if key == "" {
|
||||
m.log.Warn("plugin has no panel catalog key", "name", cfg.Name)
|
||||
key := m.rememberPanelKey(cfg)
|
||||
if key == "" || isReservedPanelKey(key) {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.panelKeys[cfg.Name] = key
|
||||
hub := m.panels
|
||||
m.mu.Unlock()
|
||||
hub := m.panelsHub()
|
||||
if hub == nil {
|
||||
return
|
||||
}
|
||||
@@ -308,6 +304,51 @@ func (m *Manager) markInstalled(cfg runtimeconfig.PluginConfig, changeReason str
|
||||
hub.MarkInstalled(key)
|
||||
}
|
||||
|
||||
// markDisabled 记录停用时用过的面板目录键,并让中枢隐藏该插件 `when:{enabled:true}` 的
|
||||
// 入口(声明保留,重新启用即原样回来)。
|
||||
//
|
||||
// 「停用」是**有意**没连,因此入口必须消失(§5.6.1「未启用的插件 ⇒ 入口不出现」);
|
||||
// 而「连不上」不该摘入口 —— 那时用户正需要点「重连」。
|
||||
func (m *Manager) markDisabled(cfg runtimeconfig.PluginConfig) {
|
||||
key := m.rememberPanelKey(cfg)
|
||||
if key == "" || isReservedPanelKey(key) {
|
||||
return
|
||||
}
|
||||
if hub := m.panelsHub(); hub != nil {
|
||||
hub.SetEnabled(key, false)
|
||||
}
|
||||
}
|
||||
|
||||
// rememberPanelKey 记下该插件的面板目录键(memo)并回它;键非法时记日志并回空串。
|
||||
//
|
||||
// memo 是**缓存而不是第二份真源**:键由 PanelPluginID 从 cfg 推出,这里只把结果留下,
|
||||
// 好让「活 server 已不在了」的卸载/停用仍能精确找到该清哪一条。
|
||||
func (m *Manager) rememberPanelKey(cfg runtimeconfig.PluginConfig) string {
|
||||
key := PanelPluginID(cfg)
|
||||
if key == "" {
|
||||
m.log.Warn("plugin has no panel catalog key", "name", cfg.Name)
|
||||
return ""
|
||||
}
|
||||
if isReservedPanelKey(key) {
|
||||
// 内置提供者的命名空间不归热装插件管:一台恰好叫 mcp 的 server 不许改动
|
||||
// 「MCP 服务器」页本身(见 isReservedPanelKey)。
|
||||
m.log.Info("plugin name collides with a built-in panel namespace; panel entry untouched",
|
||||
"name", cfg.Name, "panelId", key)
|
||||
return ""
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.panelKeys[cfg.Name] = key
|
||||
m.mu.Unlock()
|
||||
return key
|
||||
}
|
||||
|
||||
// panelsHub 返回已接线中枢(未接线时 nil)。
|
||||
func (m *Manager) panelsHub() *Panels {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.panels
|
||||
}
|
||||
|
||||
// forgetPanels 在卸载后清掉该插件的面板。键优先取安装时记下的 memo;没有记录
|
||||
// (例如配置文件是手工放进 plugins.json 的)时回落到「名字恰好合法就用名字」。
|
||||
func (m *Manager) forgetPanels(name string) {
|
||||
@@ -325,6 +366,9 @@ func (m *Manager) forgetPanels(name string) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if isReservedPanelKey(key) {
|
||||
return
|
||||
}
|
||||
hub.MarkUninstalled(key)
|
||||
}
|
||||
|
||||
@@ -343,5 +387,8 @@ func (m *Manager) setPanelsEnabled(name string, on bool) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if isReservedPanelKey(key) {
|
||||
return
|
||||
}
|
||||
hub.SetEnabled(key, on)
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
package plugin
|
||||
|
||||
// 本文件是 MCP 服务器的**状态读面**(切片 M20):每台 server 的连接态、工具清单、最近一次
|
||||
// 错误与时间。它是「看不见连没连上 / 暴露了哪些工具 / 为什么挂了」这个洞的补丁。
|
||||
//
|
||||
// 三条口径:
|
||||
//
|
||||
// 1. **三态,缺席 = 未知**(项目既有纪律:零值不得冒充「没有/未连接」,见
|
||||
// 「零值 = 不知道」类裁决)。因此 `connected` 是 `*bool`:present true = 连着;
|
||||
// present false = 确认没连(停用 / 上次连接失败);**absent = 宿主不知道**。同理
|
||||
// `toolCount` / `tools` / `lastSeenAt` 只在「知道」时才出现 —— 一台停用的 server
|
||||
// 不是「暴露 0 把工具」,而是「没连,所以不知道它有什么」。
|
||||
// 2. **凭据绝不出现在快照里**:`apiKey` 不进任何字段(Env 与 Args 也一律不下发 ——
|
||||
// stdio 的 args 常带 `--token xxx`),自由文本(`lastError`、`endpoint`、`command`)
|
||||
// 在**记录/构造那一刻**就用 `runtimeconfig.RedactPluginSecrets` 抹过(含凭据的任意
|
||||
// 片段)。抹在这里而不是在网关:读面不止一条路(面板动作、日志、未来的端点),
|
||||
// 把安全押在「每个调用方都记得抹」上迟早漏一次。
|
||||
// 3. **状态是派生的、不是另存一份**:`state` 由「活没活 / 启没启用 / 有没有失败记录」
|
||||
// 推出来(见 statusOf),只有运行期观察(lastError / lastSeenAt)是记下来的事实。
|
||||
// 两处各存一份状态必然分叉 —— 那正是「面板说连上了、模型调不到工具」的形状。
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/mcp"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
)
|
||||
|
||||
// serverState 的取值闭集(`state` 列 / `Status.State`)。它们是**机器读**的码,不是文案:
|
||||
// 前端按码分支,中文名由前端映射(与 web-search 页 `state` 列同一口径)。
|
||||
const (
|
||||
// ServerStateConnected 表示 server 已连接、工具已注册。
|
||||
ServerStateConnected = "connected"
|
||||
// ServerStateError 表示已启用但最近一次连接/发现失败(`lastError` 有值)。
|
||||
ServerStateError = "error"
|
||||
// ServerStateDisabled 表示被显式停用(`enabled=false`):没连,是**有意**没连。
|
||||
ServerStateDisabled = "disabled"
|
||||
// ServerStateUnknown 表示宿主说不清:已启用、没连着、也没有失败记录
|
||||
// (例如 plugins.json 被手工改过、启动期还没轮到它)。
|
||||
ServerStateUnknown = "unknown"
|
||||
)
|
||||
|
||||
// 快照里的截断上限(都是「表格能扫一眼」的排版倒推出来的,不是随手取的数)。
|
||||
const (
|
||||
// toolDescriptionLimit 是单把工具描述的字符上限。
|
||||
toolDescriptionLimit = 200
|
||||
// lastErrorLimit 是最近一次错误的字符上限。错误链可能很长,排障要的是病因那一句。
|
||||
lastErrorLimit = 400
|
||||
// toolsTextLimit 是表格「工具」列(工具名拼成的文本)的字符上限。
|
||||
toolsTextLimit = 240
|
||||
)
|
||||
|
||||
// Status 是一台 MCP server 的状态快照(可序列化,供面板行与未来端点复用)。
|
||||
type Status struct {
|
||||
// Name 是 server 名(plugins.json 的唯一标签,也是能力的命名空间)。
|
||||
Name string `json:"name"`
|
||||
// ID 是 server 的稳定标识(缺省省略)。
|
||||
ID string `json:"id,omitempty"`
|
||||
// Kind 是插件种类(本期恒为 mcp)。
|
||||
Kind string `json:"kind,omitempty"`
|
||||
// Transport 是连接形态(stdio / http / sse)。
|
||||
Transport string `json:"transport,omitempty"`
|
||||
// Endpoint 是 http/sse 的地址(已抹凭据)。
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
// Command 是 stdio 的可执行文件(参数**不下发**:args 常带凭据)。
|
||||
Command string `json:"command,omitempty"`
|
||||
// Enabled 是启用态(来自 plugins.json;缺省视为 true)。
|
||||
Enabled bool `json:"enabled"`
|
||||
// State 是状态闭集码(见 ServerState*)。
|
||||
State string `json:"state"`
|
||||
// Connected 是三态连接态:true/false 是事实,**缺席是未知**(见文件头口径①)。
|
||||
Connected *bool `json:"connected,omitempty"`
|
||||
// ToolCount 是已注册的工具数。只在**连着**时给出:不连的 server 不是「0 把工具」,
|
||||
// 而是「不知道它有哪些工具」。
|
||||
ToolCount *int `json:"toolCount,omitempty"`
|
||||
// Tools 是工具摘要(名字 + 截断描述)。同样只在连着时给出;连着但零工具时
|
||||
// `toolCount` 是 0 而本字段缺席(空数组同样意味着「没有工具」,两种表达不必并存)。
|
||||
Tools []mcp.ToolSummary `json:"tools,omitempty"`
|
||||
// LastError 是最近一次连接/发现失败的文本(已抹凭据、已截断)。
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
// LastErrorAt 是那次失败的时刻(RFC3339)。
|
||||
LastErrorAt string `json:"lastErrorAt,omitempty"`
|
||||
// LastSeenAt 是最近一次**成功**连上的时刻(RFC3339)。缺席 = 还没成功连过。
|
||||
LastSeenAt string `json:"lastSeenAt,omitempty"`
|
||||
}
|
||||
|
||||
// observed 是一台 server 最近一次被观察到的运行期事实(内存态,不落盘)。
|
||||
//
|
||||
// 它只记「事件」:成功连上过(lastSeenAt)、失败过(lastError/lastErrorAt)。启用态与
|
||||
// 连接态都不在这里 —— 前者的真源是 plugins.json,后者的真源是 servers 表。
|
||||
type observed struct {
|
||||
cfg runtimeconfig.PluginConfig
|
||||
lastError string
|
||||
lastErrorAt time.Time
|
||||
lastSeenAt time.Time
|
||||
}
|
||||
|
||||
// Statuses 返回全部 MCP server 的状态快照:plugins.json 的顺序在前,其后是「只活在内存里」
|
||||
// 的(装了但还没落盘、或文件丢了但 server 活着)与「只留下失败记录」的,各自按名字排序
|
||||
// (map 迭代顺序不稳定,快照顺序必须稳定 —— 否则前端每次刷新都在跳行)。
|
||||
func (m *Manager) Statuses() []Status {
|
||||
cfgs, err := runtimeconfig.LoadPlugins(m.path)
|
||||
if err != nil {
|
||||
m.log.Warn("plugins.json load skipped", "path", m.path, "error", err)
|
||||
cfgs = nil
|
||||
}
|
||||
live, obs := m.snapshotState()
|
||||
|
||||
out := make([]Status, 0, len(cfgs)+len(obs))
|
||||
seen := make(map[string]bool, len(cfgs)+len(obs))
|
||||
for _, cfg := range cfgs {
|
||||
out = append(out, statusOf(cfg, live[cfg.Name], obs[cfg.Name]))
|
||||
seen[cfg.Name] = true
|
||||
}
|
||||
for _, name := range sortedKeys(obs) {
|
||||
if seen[name] {
|
||||
continue
|
||||
}
|
||||
out = append(out, statusOf(obs[name].cfg, live[name], obs[name]))
|
||||
seen[name] = true
|
||||
}
|
||||
for _, name := range sortedKeys(live) {
|
||||
if seen[name] {
|
||||
continue
|
||||
}
|
||||
out = append(out, statusOf(live[name].cfg, live[name], obs[name]))
|
||||
seen[name] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// snapshotState 复制一份活 server 表与观察表(锁内复制、锁外计算:statusOf 会问能力注册表,
|
||||
// 那是外部代码,持本管理器的锁去碰它等于把两把锁的顺序交给外部)。
|
||||
func (m *Manager) snapshotState() (map[string]*server, map[string]observed) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
live := make(map[string]*server, len(m.servers))
|
||||
for name, srv := range m.servers {
|
||||
live[name] = srv
|
||||
}
|
||||
obs := make(map[string]observed, len(m.observed))
|
||||
for name, rec := range m.observed {
|
||||
obs[name] = rec
|
||||
}
|
||||
return live, obs
|
||||
}
|
||||
|
||||
// statusOf 把「配置 + 活 server + 观察记录」推成一条快照。
|
||||
func statusOf(cfg runtimeconfig.PluginConfig, srv *server, rec observed) Status {
|
||||
st := Status{
|
||||
Name: cfg.Name,
|
||||
ID: cfg.ID,
|
||||
Kind: cfg.Kind,
|
||||
Transport: cfg.Transport,
|
||||
Endpoint: runtimeconfig.RedactPluginSecrets(cfg, cfg.Endpoint),
|
||||
Command: runtimeconfig.RedactPluginSecrets(cfg, cfg.Command),
|
||||
Enabled: cfg.IsEnabled(),
|
||||
LastError: rec.lastError,
|
||||
LastErrorAt: formatTime(rec.lastErrorAt),
|
||||
}
|
||||
switch {
|
||||
case srv != nil:
|
||||
st.State = ServerStateConnected
|
||||
st.Connected = flagBool(true)
|
||||
count := srv.toolCount
|
||||
st.ToolCount = &count
|
||||
st.Tools = srv.reg.ToolSummaries(toolDescriptionLimit)
|
||||
st.LastSeenAt = formatTime(rec.lastSeenAt)
|
||||
case !cfg.IsEnabled():
|
||||
// 停用是**有意**没连:连接态是确认的 false(不是未知),工具则是未知。
|
||||
//
|
||||
// 「想启用但失败了」也落在这里(落盘的 enabled 仍是 false,见 enabled.go):此时
|
||||
// `state` 说「它现在是停用的」,`lastError` 说「刚才那次启用为什么没成」—— 两个
|
||||
// 事实各归各的列,谁也不冒充谁。若让 error 抢在 disabled 前面,一台**故意**停用
|
||||
// 过又失败过一次的 server 会永远显示「错误」,用户就没法把它当成「关着的」。
|
||||
st.State = ServerStateDisabled
|
||||
st.Connected = flagBool(false)
|
||||
st.LastSeenAt = formatTime(rec.lastSeenAt)
|
||||
case rec.lastError != "":
|
||||
st.State = ServerStateError
|
||||
st.Connected = flagBool(false)
|
||||
st.LastSeenAt = formatTime(rec.lastSeenAt)
|
||||
default:
|
||||
// 已启用、没连着、也没有失败记录 ⇒ 宿主不知道它到底行不行(不猜)。
|
||||
st.State = ServerStateUnknown
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// noteSeen 记录一次成功连接(tools 已注册)。
|
||||
func (m *Manager) noteSeen(cfg runtimeconfig.PluginConfig) {
|
||||
m.observe(cfg, func(rec *observed) { rec.lastSeenAt = time.Now().UTC() })
|
||||
}
|
||||
|
||||
// noteFailure 记录一次连接/发现失败。错误文本在**这一刻**就抹掉凭据并截断:内存里也不留
|
||||
// 明文,之后的任何出口(面板、日志、未来端点)都不必再各自记得抹。
|
||||
func (m *Manager) noteFailure(cfg runtimeconfig.PluginConfig, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
text := mcp.TruncateRunes(runtimeconfig.RedactPluginSecrets(cfg, err.Error()), lastErrorLimit)
|
||||
m.observe(cfg, func(rec *observed) {
|
||||
rec.lastError = text
|
||||
rec.lastErrorAt = time.Now().UTC()
|
||||
})
|
||||
}
|
||||
|
||||
// noteDisabled 记录「这台 server 现在是停用的」(保留历史观察:排障时要看它停用前发生过什么)。
|
||||
func (m *Manager) noteDisabled(cfg runtimeconfig.PluginConfig) {
|
||||
m.observe(cfg, func(*observed) {})
|
||||
}
|
||||
|
||||
// observe 在锁内更新一台 server 的观察记录(不存在则新建)。
|
||||
func (m *Manager) observe(cfg runtimeconfig.PluginConfig, apply func(*observed)) {
|
||||
name := cfg.Name
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.observed == nil {
|
||||
m.observed = make(map[string]observed, 1)
|
||||
}
|
||||
rec := m.observed[name]
|
||||
rec.cfg = cfg
|
||||
apply(&rec)
|
||||
m.observed[name] = rec
|
||||
}
|
||||
|
||||
// forgetObserved 丢掉一台 server 的观察记录(卸载即不再有这台 server 的任何事实)。
|
||||
func (m *Manager) forgetObserved(name string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.observed, name)
|
||||
}
|
||||
|
||||
// formatTime 把时刻格式化成 RFC3339;零值返回空串(缺席 = 还没发生,不写 1970)。
|
||||
func formatTime(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// flagBool 是 `&true` / `&false` 的简写(Connected 是三态指针)。
|
||||
func flagBool(v bool) *bool { return &v }
|
||||
|
||||
// sortedKeys 返回 map 键的有序副本(快照顺序必须稳定)。
|
||||
func sortedKeys[V any](m map[string]V) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// toolsText 把工具摘要拼成表格里「工具」列的一行文本(名字在前、描述省略)。
|
||||
//
|
||||
// 表格单元格只认标量(前端 `cellText` 会把数组用「、」拼起来,但长度不可控),因此这里
|
||||
// 自己拼并截断:排障要看的是**有哪些工具**,不是每把工具的完整描述(描述在 `tools[]` 里)。
|
||||
func toolsText(tools []mcp.ToolSummary) string {
|
||||
if len(tools) == 0 {
|
||||
return ""
|
||||
}
|
||||
names := make([]string, 0, len(tools))
|
||||
for _, t := range tools {
|
||||
names = append(names, t.Name)
|
||||
}
|
||||
return mcp.TruncateRunes(strings.Join(names, "、"), toolsTextLimit)
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package plugin
|
||||
|
||||
// 本文件锁定 MCP 服务器状态快照的三条硬要求(切片 M20):
|
||||
// ① 三态:缺席 = 未知,不用零值冒充「未连接」;
|
||||
// ② 凭据绝不出现(apiKey 及其任意片段),带变异自证;
|
||||
// ③ 连接失败如实给 lastError + connected=false,且失败的那台也出现在快照里
|
||||
// (否则「为什么挂了」在面板上根本无处可看)。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
)
|
||||
|
||||
// statusOfOne 返回快照里某台 server 的那一条(没有就 fail)。
|
||||
func statusOfOne(t *testing.T, mgr *Manager, name string) Status {
|
||||
t.Helper()
|
||||
for _, st := range mgr.Statuses() {
|
||||
if st.Name == name {
|
||||
return st
|
||||
}
|
||||
}
|
||||
t.Fatalf("快照里没有 %q:%+v", name, mgr.Statuses())
|
||||
return Status{}
|
||||
}
|
||||
|
||||
// TestStatusesTriState 锁定 ①:三种「知道」程度各自长什么样。
|
||||
func TestStatusesTriState(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := filepath.Join(t.TempDir(), "plugins.json")
|
||||
srv := newFakeMCP(t, "", "add")
|
||||
mgr, _ := newEnabledManager(t, path, srv.URL)
|
||||
|
||||
// 连着:connected=true、工具数/明细/最近连接都在。
|
||||
live := statusOfOne(t, mgr, "calc")
|
||||
if live.State != ServerStateConnected {
|
||||
t.Fatalf("state = %q,期望 connected", live.State)
|
||||
}
|
||||
if live.Connected == nil || !*live.Connected || live.ToolCount == nil || *live.ToolCount != 1 {
|
||||
t.Fatalf("连接态快照不对 = %+v", live)
|
||||
}
|
||||
if live.LastSeenAt == "" || live.LastError != "" {
|
||||
t.Fatalf("活着时应当有 lastSeenAt 且没有 lastError:%+v", live)
|
||||
}
|
||||
if live.Endpoint != srv.URL {
|
||||
t.Fatalf("endpoint = %q,期望 %q", live.Endpoint, srv.URL)
|
||||
}
|
||||
|
||||
// 停用:connected 是**确认的 false**(有意没连),工具则是未知。
|
||||
if err := mgr.SetEnabled(context.Background(), "calc", false); err != nil {
|
||||
t.Fatalf("SetEnabled(false): %v", err)
|
||||
}
|
||||
off := statusOfOne(t, mgr, "calc")
|
||||
if off.State != ServerStateDisabled || off.Connected == nil || *off.Connected {
|
||||
t.Fatalf("停用快照 = %+v", off)
|
||||
}
|
||||
if off.ToolCount != nil || off.Tools != nil {
|
||||
t.Fatalf("停用时工具应当是**未知**(缺席),得到 %+v / %+v", off.ToolCount, off.Tools)
|
||||
}
|
||||
|
||||
// 未知:文件里有这一条,但本次进程从没连过也没失败过(手工改文件就是这样)。
|
||||
handEdited := runtimeconfig.PluginConfig{Name: "manual", Kind: runtimeconfig.PluginKindMCP,
|
||||
Transport: runtimeconfig.TransportHTTP, Endpoint: "https://example.invalid/mcp"}
|
||||
if err := runtimeconfig.SavePlugins(path, []runtimeconfig.PluginConfig{handEdited}); err != nil {
|
||||
t.Fatalf("SavePlugins: %v", err)
|
||||
}
|
||||
unknown := statusOfOne(t, mgr, "manual")
|
||||
if unknown.State != ServerStateUnknown {
|
||||
t.Fatalf("state = %q,期望 unknown", unknown.State)
|
||||
}
|
||||
if unknown.Connected != nil {
|
||||
t.Fatalf("未知的 connected 必须**缺席**(不得用零值冒充未连接),得到 %v", *unknown.Connected)
|
||||
}
|
||||
if unknown.ToolCount != nil || unknown.LastSeenAt != "" || unknown.LastError != "" {
|
||||
t.Fatalf("未知时不该有工具/时间/错误:%+v", unknown)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStatusesFailureIsVisible 锁定 ③:连不上的**新装**attempt 也要出现在快照里。
|
||||
func TestStatusesFailureIsVisible(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := filepath.Join(t.TempDir(), "plugins.json")
|
||||
dead := httptest.NewServer(http.NotFoundHandler())
|
||||
deadURL := dead.URL
|
||||
dead.Close()
|
||||
|
||||
mgr := NewManager(registry.New(), path, nil)
|
||||
defer mgr.Close(context.Background())
|
||||
if _, err := mgr.Install(context.Background(), runtimeconfig.PluginConfig{
|
||||
Name: "down", Transport: runtimeconfig.TransportHTTP, Endpoint: deadURL,
|
||||
}); err == nil {
|
||||
t.Fatal("对关掉的 server 安装应当失败")
|
||||
}
|
||||
st := statusOfOne(t, mgr, "down")
|
||||
if st.State != ServerStateError || st.Connected == nil || *st.Connected {
|
||||
t.Fatalf("失败快照 = %+v,期望 error 且 connected=false", st)
|
||||
}
|
||||
if st.LastError == "" {
|
||||
t.Fatal("失败快照没有 lastError:面板说不出病因")
|
||||
}
|
||||
if st.Enabled != true {
|
||||
t.Fatalf("失败的那台是「想启用但没连上」,enabled 应为 true:%+v", st)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStatusesNeverLeakAPIKey 锁定 ②:快照 JSON 里不得出现 apiKey 或它的任意片段。
|
||||
//
|
||||
// 覆盖两条最容易漏的路:① 正常快照里的配置字段;② 失败文本(MCP 客户端/上游可能把
|
||||
// Bearer 令牌写进错误里)。片段检查是「变异自证」:把密钥切成若干 8 字符片段再各查一次。
|
||||
func TestStatusesNeverLeakAPIKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
const key = "sk-live-SECRET-9f3c2b1d"
|
||||
path := filepath.Join(t.TempDir(), "plugins.json")
|
||||
srv := newFakeMCP(t, key, "add")
|
||||
mgr := NewManager(registry.New(), path, nil)
|
||||
defer mgr.Close(context.Background())
|
||||
if _, err := mgr.Install(context.Background(), runtimeconfig.PluginConfig{
|
||||
Name: "calc", Transport: runtimeconfig.TransportHTTP, Endpoint: srv.URL, APIKey: key,
|
||||
}); err != nil {
|
||||
t.Fatalf("Install: %v", err)
|
||||
}
|
||||
cfgs, err := runtimeconfig.LoadPlugins(path)
|
||||
if err != nil || len(cfgs) != 1 {
|
||||
t.Fatalf("LoadPlugins = %+v, %v", cfgs, err)
|
||||
}
|
||||
cfgWithKey := cfgs[0]
|
||||
// 失败文本里塞进整把钥匙和它的中段(这正是「上游把凭据回显进 401」的形状)。
|
||||
mgr.noteFailure(cfgWithKey, errors.New("401 Unauthorized: bearer "+key+" rejected; echo=SECRET-9f3"))
|
||||
mgr.noteFailure(cfgWithKey, errors.New("dial "+srv.URL+"?api_key="+key+" failed"))
|
||||
|
||||
raw, err := json.Marshal(mgr.Statuses())
|
||||
if err != nil {
|
||||
t.Fatalf("marshal statuses: %v", err)
|
||||
}
|
||||
body := string(raw)
|
||||
assertNoSecret(t, body, key)
|
||||
if !strings.Contains(body, "failed") {
|
||||
t.Fatalf("抹凭据不该把病因整条抹掉:%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedactPluginSecrets 直接钉住抹除函数:整把、前缀、中段、后缀都不许留下。
|
||||
func TestRedactPluginSecrets(t *testing.T) {
|
||||
t.Parallel()
|
||||
const key = "tok_ABCDEF123456"
|
||||
cfg := runtimeconfig.PluginConfig{APIKey: key,
|
||||
Env: map[string]string{"GITHUB_TOKEN": "ghp_ZZZZZZ9999", "PLAIN": "keep-me"}}
|
||||
cases := []string{
|
||||
"authorization: Bearer " + key,
|
||||
"prefix " + key[:8] + " suffix",
|
||||
"middle ABCDEF123456 and more",
|
||||
"env dump GITHUB_TOKEN=ghp_ZZZZZZ9999",
|
||||
}
|
||||
for _, in := range cases {
|
||||
out := runtimeconfig.RedactPluginSecrets(cfg, in)
|
||||
assertNoSecret(t, out, key)
|
||||
assertNoSecret(t, out, "ghp_ZZZZZZ9999")
|
||||
}
|
||||
// 非敏感值不该被连带抹掉(否则排障信息会被无谓地毁掉)。
|
||||
if out := runtimeconfig.RedactPluginSecrets(cfg, "plain keep-me"); !strings.Contains(out, "keep-me") {
|
||||
t.Fatalf("非敏感值被误抹:%q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// assertNoSecret 断言 text 不含 secret 本身,也不含它的任意 8 字符片段。
|
||||
func assertNoSecret(t *testing.T, text, secret string) {
|
||||
t.Helper()
|
||||
if strings.Contains(text, secret) {
|
||||
t.Fatalf("文本里出现了完整凭据 %q:%s", secret, text)
|
||||
}
|
||||
const frag = 8
|
||||
for i := 0; i+frag <= len(secret); i++ {
|
||||
if part := secret[i : i+frag]; strings.Contains(text, part) {
|
||||
t.Fatalf("文本里出现了凭据片段 %q:%s", part, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// 本文件钉住「来源章」(DevDocs/audit/MCP工具按上游分组-2026-10-24.md §2 方案①):
|
||||
// 归属只能来自注册时的元数据,因此盖章必须发生在**注册汇点**上,且热替换不能把它抹掉。
|
||||
//
|
||||
// 三条判据各自**能变红**:
|
||||
// - 去掉 RegisterTool 里的盖章 → 第一条红;
|
||||
// - 去掉 ReplaceTool 里的盖章(它逐字段重建条目)→ 第二条红;
|
||||
// - 给所有工具都编一个章(比如按名字猜)→ 第三条红。
|
||||
|
||||
// originTool 是自报来源的最小工具(宿主内置工具不实现 sdk.OriginAware)。
|
||||
type originTool struct {
|
||||
name string
|
||||
origin sdk.Origin
|
||||
}
|
||||
|
||||
func (t originTool) Name() string { return t.name }
|
||||
func (t originTool) Description() string { return "来自 MCP" }
|
||||
func (t originTool) Parameters() (map[string]any, error) {
|
||||
return map[string]any{"type": "object"}, nil
|
||||
}
|
||||
func (t originTool) Execute(_ context.Context, _ any) (any, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (t originTool) Origin() sdk.Origin { return t.origin }
|
||||
|
||||
var _ sdk.OriginAware = originTool{}
|
||||
|
||||
// TestRegisterToolStampsOrigin:注册汇点把工具自报的来源盖在能力条目上。
|
||||
func TestRegisterToolStampsOrigin(t *testing.T) {
|
||||
r := New()
|
||||
want := sdk.Origin{Kind: sdk.OriginKindMCP, Server: "fs", Transport: "stdio"}
|
||||
if err := r.RegisterTool(originTool{name: "read_file_mcp", origin: want}); err != nil {
|
||||
t.Fatalf("RegisterTool: %v", err)
|
||||
}
|
||||
cap, err := r.Get("read_file_mcp")
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if cap.Origin != want {
|
||||
t.Fatalf("条目上的来源章 = %+v, want %+v(RegisterTool 是唯一汇点,必须在这里盖章)", cap.Origin, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReplaceToolKeepsOrigin:热替换必须重新盖章 —— 它逐字段重建 Capability,
|
||||
// 漏一位就等于「面板保存一次,这把工具从它的 server 分节里掉进内置」。
|
||||
func TestReplaceToolKeepsOrigin(t *testing.T) {
|
||||
r := New()
|
||||
origin := sdk.Origin{Kind: sdk.OriginKindMCP, Server: "fs", Transport: "http"}
|
||||
if err := r.RegisterTool(originTool{name: "read_file_mcp", origin: origin}); err != nil {
|
||||
t.Fatalf("RegisterTool: %v", err)
|
||||
}
|
||||
// 热替换成同名的新实现,来源相同(换实现不换 server)。
|
||||
if err := r.ReplaceTool(originTool{name: "read_file_mcp", origin: origin}); err != nil {
|
||||
t.Fatalf("ReplaceTool: %v", err)
|
||||
}
|
||||
cap, err := r.Get("read_file_mcp")
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if cap.Origin != origin {
|
||||
t.Fatalf("热替换后来源章 = %+v, want %+v(ReplaceTool 重建条目,必须重新盖章)", cap.Origin, origin)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegisterToolLeavesBuiltinOriginZero:宿主内置工具(没实现 OriginAware)
|
||||
// 的条目**没有**来源章。它是「键缺席」的根据 —— 读面据此省略 origin,而不是编一个。
|
||||
func TestRegisterToolLeavesBuiltinOriginZero(t *testing.T) {
|
||||
r := New()
|
||||
if err := r.RegisterTool(&stubTool{name: "web_search", desc: "内置"}); err != nil {
|
||||
t.Fatalf("RegisterTool: %v", err)
|
||||
}
|
||||
cap, err := r.Get("web_search")
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if !cap.Origin.IsZero() {
|
||||
t.Fatalf("内置工具却带来源章 %+v:没有 OriginAware 就不得编一个来源", cap.Origin)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetPolicyKeepsOrigin:策略写路径不受来源章影响,也不许把它覆盖掉。
|
||||
// 这是「tool_policies / 权限闸零影响」的断言 —— 策略仍按**名字**存,
|
||||
// 来源章只在条目上,两者互不干涉。
|
||||
func TestSetPolicyKeepsOrigin(t *testing.T) {
|
||||
r := New()
|
||||
origin := sdk.Origin{Kind: sdk.OriginKindMCP, Server: "fs", Transport: "stdio"}
|
||||
if err := r.RegisterTool(originTool{name: "read_file_mcp", origin: origin}); err != nil {
|
||||
t.Fatalf("RegisterTool: %v", err)
|
||||
}
|
||||
if err := r.SetAllowInGroup("read_file_mcp", true); err != nil {
|
||||
t.Fatalf("SetAllowInGroup: %v", err)
|
||||
}
|
||||
cap, err := r.Get("read_file_mcp")
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if !cap.Policy.AllowInGroup || cap.Policy.AllowInStranger {
|
||||
t.Fatalf("策略 = %+v, want 只有 AllowInGroup=true(按名字写的策略行)", cap.Policy)
|
||||
}
|
||||
if cap.Origin != origin {
|
||||
t.Fatalf("改策略把来源章冲掉了:%+v, want %+v", cap.Origin, origin)
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,12 @@ type Capability struct {
|
||||
// Policy is the editable reachability markers for this capability. It
|
||||
// defaults to zero, which the policy engine reads as owner-only.
|
||||
Policy ToolPolicy
|
||||
// Origin 是能力的**上游来源章**(DevDocs/audit/MCP工具按上游分组-2026-10-24.md
|
||||
// 方案①):由工具自己通过 sdk.OriginAware 声明,注册汇点
|
||||
// RegisterTool/ReplaceTool 盖在这里。零值 = 宿主内置 / 来源未知 ——
|
||||
// 这枚章**只用于呈现归属**,不参与权限判定、不进模型可见的工具面
|
||||
// (restrict 只按名字查策略,ToolSchemas 只取 Name/Description/Parameters)。
|
||||
Origin sdk.Origin
|
||||
// Value is the underlying capability object. For KindTool/KindSkill/
|
||||
// KindChannel it is the SDK interface from pkg/plugins/sdk; for KindMCP and
|
||||
// KindIoT it is owned by the relevant subsystem.
|
||||
@@ -225,6 +231,13 @@ func (r *Registry) setPolicyField(name, field string, apply func(*ToolPolicy)) e
|
||||
}
|
||||
|
||||
// RegisterTool registers t as a KindTool capability.
|
||||
//
|
||||
// 它是**来源章的唯一汇点**(DevDocs/audit/MCP工具按上游分组-2026-10-24.md §2 方案①):
|
||||
// MCP 工具的**两条接入路径**都从这里进注册表 —— 运行时热装
|
||||
// (plugin.Manager.start → mcp.NewRegistry → mcp.Registry.Register)与 `.env` 的
|
||||
// MCP_SERVERS_JSON 启动期装(cmd/cyrene/mcp.go,不经 Manager)—— 因此在这一处盖章
|
||||
// 一次就覆盖两条路(以及 tools/list_changed 的增量 refresh)。
|
||||
// 不从 Manager 现场反推归属:那会漏掉 env 路径,把现役接入方式误报成「无来源」。
|
||||
func (r *Registry) RegisterTool(t sdk.Tool) error {
|
||||
if t == nil {
|
||||
return fmt.Errorf("registry: register tool: %w", ErrInvalidArgument)
|
||||
@@ -233,10 +246,21 @@ func (r *Registry) RegisterTool(t sdk.Tool) error {
|
||||
Name: t.Name(),
|
||||
Kind: KindTool,
|
||||
Description: t.Description(),
|
||||
Origin: originOf(t),
|
||||
Value: t,
|
||||
})
|
||||
}
|
||||
|
||||
// originOf 返回 t 自报的来源章;没实现 sdk.OriginAware(宿主内置工具)或自报零值时
|
||||
// 返回零值 Origin —— 零值在线上表现为**键缺席**,不是一个空对象。
|
||||
func originOf(t sdk.Tool) sdk.Origin {
|
||||
origin, ok := sdk.OriginOf(t)
|
||||
if !ok {
|
||||
return sdk.Origin{}
|
||||
}
|
||||
return origin
|
||||
}
|
||||
|
||||
// ReplaceTool 用**同名**的 t 就地换掉已登记的工具条目(热替换,design/09 §5.5:
|
||||
// 面板保存后下一轮即用新接线,不必重启)。
|
||||
//
|
||||
@@ -270,6 +294,10 @@ func (r *Registry) ReplaceTool(t sdk.Tool) error {
|
||||
Description: t.Description(),
|
||||
// Policy 沿用旧条目的:它是可编辑的运行期配置,不属于工具的构造产出。
|
||||
Policy: cur.Policy,
|
||||
// Origin **必须重新盖章**:这里逐字段重建条目,漏掉一位就等于「热替换把来源
|
||||
// 抹掉」,面板上那把工具会从它的 server 分节里掉进「宿主内置」。来源是工具的
|
||||
// 构造产出(跟 RegisterTool 同一口径取),不沿用旧条目 —— 换了实现就该换章。
|
||||
Origin: originOf(t),
|
||||
Value: t,
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -3,6 +3,8 @@ package restrict
|
||||
import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// SensitiveOp 是 design/11 §4「敏感清单」里的操作类别。它只用于审计与前端
|
||||
@@ -45,7 +47,8 @@ var SensitiveOps = []SensitiveOp{
|
||||
}
|
||||
|
||||
// sensitiveSummaryRunes 截断动作摘要:代审小字与审计只需一眼看出「想干什么」,
|
||||
// 同时限制进入前端/日志的文本块大小(也降低把长参数里的秘密整段搬进日志的机会)。
|
||||
// 同时限制进入前端/日志的文本块大小。它只管**长度** —— 秘密由 summarize 里的
|
||||
// trace.RedactText 负责打码,不靠截断「顺手甩掉」。
|
||||
const sensitiveSummaryRunes = 80
|
||||
|
||||
// SensitiveCall 判定一次工具调用是否属敏感操作(design/11 §4)。
|
||||
@@ -135,8 +138,14 @@ func httpCall(args map[string]any) (SensitiveOp, string, bool) {
|
||||
|
||||
// summarize 拼接「动作 + 目标」摘要并按 sensitiveSummaryRunes 截断(不切开
|
||||
// 多字节字符);目标为空时只返回动作。
|
||||
//
|
||||
// 目标先过 trace.RedactText 打码:这个摘要会进审计与前端小字两个读面,而
|
||||
// terminal 的 command / http 的 url 里常常就带着凭据(`curl -H 'api_key=…'`、
|
||||
// `?token=…`),截断并不能让它们变得安全。RedactText 自身已按同一档(80 rune)
|
||||
// 截断,后面的 clipRunes 保留是为了让长度上限继续由本包的
|
||||
// sensitiveSummaryRunes 掌握 —— 两处同档,叠加不改变结果。
|
||||
func summarize(action, target string) string {
|
||||
target = strings.TrimSpace(target)
|
||||
target = trace.RedactText(strings.TrimSpace(target))
|
||||
if target == "" {
|
||||
return action
|
||||
}
|
||||
|
||||
@@ -77,3 +77,41 @@ func TestSensitiveCall_SummaryIsClipped(t *testing.T) {
|
||||
t.Fatalf("被截断的摘要应以省略号结尾: %q", detail)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSensitiveCall_SummaryIsRedacted 锁定「参数摘要进审计与前端小字之前必须
|
||||
// 打码」:terminal 的 command、http 的 url 里常常就带着凭据,截断不是脱敏。
|
||||
func TestSensitiveCall_SummaryIsRedacted(t *testing.T) {
|
||||
t.Parallel()
|
||||
const secret = "sk-live-9f8e7d6c5b4a3210"
|
||||
cases := []struct {
|
||||
name string
|
||||
args map[string]any
|
||||
start string // 摘要应保留的开头(打码不能把摘要打没)
|
||||
}{
|
||||
{
|
||||
name: "terminal",
|
||||
args: map[string]any{"command": "curl -H 'api_key=" + secret + "' https://x"},
|
||||
start: "terminal ",
|
||||
},
|
||||
{
|
||||
name: "http",
|
||||
args: map[string]any{"method": "POST", "url": "https://x/hook?token=" + secret},
|
||||
start: "http POST ",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
_, detail, sensitive := SensitiveCall(tc.name, tc.args)
|
||||
if !sensitive {
|
||||
t.Fatalf("%s 应判定为敏感", tc.name)
|
||||
}
|
||||
if strings.Contains(detail, secret) {
|
||||
t.Fatalf("%s 摘要泄露了凭据原文: %q", tc.name, detail)
|
||||
}
|
||||
if !strings.Contains(detail, "[redacted]") {
|
||||
t.Fatalf("%s 摘要应带打码标记: %q", tc.name, detail)
|
||||
}
|
||||
if !strings.HasPrefix(detail, tc.start) {
|
||||
t.Fatalf("%s 摘要应以 %q 开头(动作不能被一起打掉): %q", tc.name, tc.start, detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,21 +13,32 @@ import (
|
||||
// It is a pure noise/importance filter (design/12 §6 策略四). Implementations must
|
||||
// never decide identity: who the sender is was already resolved structurally by the
|
||||
// channel layer. A nil Reviewer makes the gate fall back to a plain FIFO cap.
|
||||
//
|
||||
// The gate calls it **off the admission path** (see FlowGate.Admit), so an
|
||||
// implementation may take as long as the model takes; it is bounded by
|
||||
// reviewTimeout and by the per-window budget, never by the caller's patience.
|
||||
type Reviewer func(ctx context.Context, msgs []UnifiedMessage) (keep []int, err error)
|
||||
|
||||
// GateVerdict reports how the gate classified one inbound message.
|
||||
type GateVerdict struct {
|
||||
// Proceed reports whether the message should be delivered downstream.
|
||||
Proceed bool
|
||||
// Reason is a short token describing a drop: "rate_limited", "review_pruned"
|
||||
// or "capped". It is empty when Proceed is true.
|
||||
// Reason is a short token describing a drop. After M24 it is always
|
||||
// "rate_limited" (over the sliding window — dropped at the door, the message
|
||||
// never enters the pending set), because nothing else on the admission path can
|
||||
// refuse a message: the pending cap evicts the **oldest** entries and the review
|
||||
// model runs in the background, so neither can reject the message that just
|
||||
// arrived. The audit hook still reports their drops with "capped" /
|
||||
// "review_pruned"; those are retained-message bookkeeping, not admissions.
|
||||
// Reason is empty when Proceed is true.
|
||||
Reason string
|
||||
// DroppedMsg is the message that failed admission (nil when Proceed is true).
|
||||
DroppedMsg *UnifiedMessage
|
||||
// Pruned lists every message the gate dropped during this admission. On the
|
||||
// review path it is the noise the reviewer rejected; on the cap path it is the
|
||||
// oldest messages evicted to honor the total cap. Each is recorded to the
|
||||
// OnPruned audit hook before being dropped, so nothing is lost silently.
|
||||
// Pruned lists every message the gate dropped during this admission: on the cap
|
||||
// path it is the oldest messages evicted to honor the total cap. Each is recorded
|
||||
// to the OnPruned audit hook before being dropped, so nothing is lost silently.
|
||||
// Backlog drops made by a background review reach the audit hook with
|
||||
// "review_pruned" when that review completes, not through this field.
|
||||
Pruned []UnifiedMessage
|
||||
}
|
||||
|
||||
@@ -56,15 +67,23 @@ type FlowGateOptions struct {
|
||||
MsgPerMin int
|
||||
// MaxMessages is the **per-conversation** pending-message cap (design/12 §6
|
||||
// 策略四 / 裁决 06.4.3). When a conversation's pending set nears it the Reviewer
|
||||
// is invoked to prune noise; with no Reviewer the oldest messages are evicted
|
||||
// FIFO. Zero disables the cap.
|
||||
// is invoked — in the background — to prune noise; with no Reviewer the oldest
|
||||
// messages are evicted FIFO. Zero disables the cap.
|
||||
MaxMessages int
|
||||
// Window is the sliding window over which the per-minute limits are counted.
|
||||
// Zero uses defaultRateWindow.
|
||||
Window time.Duration
|
||||
// Review is the optional review model. When nil the gate enforces the cap with
|
||||
// FIFO eviction instead of model judgment.
|
||||
// FIFO eviction instead of model judgment. It is always called from a background
|
||||
// goroutine, at most once per conversation per Window, with at most one call in
|
||||
// flight per gate.
|
||||
Review Reviewer
|
||||
// ReviewPerMin is the hard per-account ceiling on Reviewer calls started per
|
||||
// Window: the budget that bounds review cost no matter how many conversations
|
||||
// cross their own threshold in the same window. Zero (or negative) uses
|
||||
// defaultReviewPerWindow. It bounds calls started, not calls finished, so the
|
||||
// bound holds independently of model latency.
|
||||
ReviewPerMin int
|
||||
// OnPruned is called once per pruned/dropped message with a short reason token
|
||||
// ("review_pruned" or "capped") so the caller can record an audit trail.
|
||||
// It may be nil (then the caller only sees Pruned on the verdict).
|
||||
@@ -80,6 +99,13 @@ const (
|
||||
// runs. Design/12 §6 策略四 says "maxMessages 的 80%": this is the cost guard
|
||||
// that keeps review from firing on every message.
|
||||
defaultReviewPct = 80
|
||||
// defaultReviewPerWindow is the hard per-adapter ceiling on reviewer calls per
|
||||
// window (6/minute, the same order of magnitude as FlowLimitDefault). Before it
|
||||
// existed the only bound was "one per conversation per window", which an account
|
||||
// with many conversations could multiply without limit. At p50 5.3 s per call
|
||||
// this still allows ~half a minute of review work per minute per account, so it
|
||||
// bounds the tail without starving normal traffic.
|
||||
defaultReviewPerWindow = 6
|
||||
// FlowLimitDefault is the protective default of 6 group messages per minute
|
||||
// (design/12 §6 策略三). It is applied when an account pins no group rate limit.
|
||||
FlowLimitDefault = 6
|
||||
@@ -128,6 +154,10 @@ type flowSession struct {
|
||||
groupHits []time.Time
|
||||
directHits []time.Time
|
||||
buffer []queuedMsg
|
||||
// lastReview 是**最近一次审查被派发**的时刻(不是完成时刻,也无论成败):
|
||||
// 写在这里而不是「成功之后」,失败路径才同样被窗口节流(修复前它只在
|
||||
// err == nil 分支置位,而 68% 的审查因对端回带 BOM 解析失败 ⇒ 缓冲满之后
|
||||
// 每条入站帧都付一次 ~6 s 的模型调用)。
|
||||
lastReview time.Time
|
||||
// inFlight 是该会话**正在跑**的回合数(AcquireTurn/ReleaseTurn 维护)。受限车道
|
||||
// 用它把「同一发送者同时最多 1 个在跑的回合」落到实处。
|
||||
@@ -148,6 +178,14 @@ type flowSession struct {
|
||||
//
|
||||
// 两个策略的计数都按会话独立(裁决 06.4.3),只有 MsgPerMin 总量护栏按账号汇总。
|
||||
//
|
||||
// **审查不在准入路径上**:Admit 只读待处理集合里已经存在的结论(限流计数、
|
||||
// 上一批已完成审查留下的队列),触发条件满足时只把一次审查**交给后台**,因此
|
||||
// 适配器的读 goroutine 永远不会等模型 —— 修复前它会同步等一次 `noise_review`
|
||||
// (实测 p50 5.3 s / p95 6.7 s / max 9.2 s),把消息、心跳与探活回执全排在同一条
|
||||
// 队上(见 DevDocs/audit/群聊排队与回旧话-2026-10-24.md)。后台审查的三重上界:
|
||||
// 同一适配器至多一次在飞(reviewInFlight)、同一会话每 Window 至多一次
|
||||
// (lastReview)、整个适配器每 Window 至多 defaultReviewPerWindow 次(reviewHits)。
|
||||
//
|
||||
// Owner-private messages are not routed through the gate (the caller bypasses it),
|
||||
// so the owner is never throttled or pruned. Identity is never examined here.
|
||||
// The gate also carries the per-conversation running-turn slot (AcquireTurn /
|
||||
@@ -160,24 +198,48 @@ type FlowGate struct {
|
||||
reviewAt int
|
||||
window time.Duration
|
||||
review Reviewer
|
||||
reviewBudget int
|
||||
onPruned func(UnifiedMessage, string)
|
||||
now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
totalHits []time.Time
|
||||
sessions map[string]*flowSession
|
||||
admitted uint64
|
||||
seq uint64
|
||||
// reviewHits 是本适配器在窗口内**派发**审查的时刻,用于账号级预算
|
||||
// (defaultReviewPerWindow)。
|
||||
reviewHits []time.Time
|
||||
// reviewInFlight 表示本适配器已有一次审查在后台跑:同一适配器至多一次在飞,
|
||||
// 使后台审查有界(不会因为多个群同时越阈而并发起 N 个模型调用)。
|
||||
reviewInFlight bool
|
||||
sessions map[string]*flowSession
|
||||
admitted uint64
|
||||
seq uint64
|
||||
}
|
||||
|
||||
// queuedMsg wraps a buffer entry with a stable sequence so pruning decisions can
|
||||
// be audited and the fate of the just-admitted message be tracked across index
|
||||
// shifts (value equality on UnifiedMessage is unsafe because it holds slices).
|
||||
// The sequence also lets a review verdict land on the entries the reviewer actually
|
||||
// saw, even when the buffer has moved on while the call was in flight.
|
||||
type queuedMsg struct {
|
||||
seq uint64
|
||||
msg UnifiedMessage
|
||||
}
|
||||
|
||||
// ReviewAtFor 返回给定待处理上限对应的审查触发水位(design/12 §6 策略四:
|
||||
// maxMessages 的 80%,至少 1)。它是 NewFlowGate 内部同一处的口径,导出只为让调用方
|
||||
// **如实报出生效值**(例如 cmd/cyrene 的逐账号入口流控启动日志),而不是在别处另写
|
||||
// 一遍 80% 的算法。非正上限表示上限关闭,返回 0(永不触发审查)。
|
||||
func ReviewAtFor(maxMessages int) int {
|
||||
if maxMessages <= 0 {
|
||||
return 0
|
||||
}
|
||||
at := maxMessages * defaultReviewPct / 100
|
||||
if at < 1 {
|
||||
return 1
|
||||
}
|
||||
return at
|
||||
}
|
||||
|
||||
// NewFlowGate builds a gate from opts. Options that are zero disable their limit,
|
||||
// except Window which defaults to one minute when left zero.
|
||||
func NewFlowGate(opts FlowGateOptions) *FlowGate {
|
||||
@@ -188,6 +250,7 @@ func NewFlowGate(opts FlowGateOptions) *FlowGate {
|
||||
maxMessages: opts.MaxMessages,
|
||||
window: opts.Window,
|
||||
review: opts.Review,
|
||||
reviewBudget: opts.ReviewPerMin,
|
||||
onPruned: opts.OnPruned,
|
||||
now: opts.Now,
|
||||
sessions: make(map[string]*flowSession),
|
||||
@@ -195,22 +258,24 @@ func NewFlowGate(opts FlowGateOptions) *FlowGate {
|
||||
if g.window <= 0 {
|
||||
g.window = defaultRateWindow
|
||||
}
|
||||
if g.maxMessages > 0 {
|
||||
g.reviewAt = g.maxMessages * defaultReviewPct / 100
|
||||
if g.reviewAt < 1 {
|
||||
g.reviewAt = 1
|
||||
}
|
||||
if g.reviewBudget <= 0 {
|
||||
g.reviewBudget = defaultReviewPerWindow
|
||||
}
|
||||
g.reviewAt = ReviewAtFor(g.maxMessages)
|
||||
if g.now == nil {
|
||||
g.now = time.Now
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// Admit applies the rate limits then the per-conversation cap/review gate to one
|
||||
// inbound message. It returns a verdict telling the caller whether to proceed;
|
||||
// messages that the gate drops are returned (and audited) so nothing is lost
|
||||
// silently.
|
||||
// Admit applies the rate limits then the per-conversation cap gate to one inbound
|
||||
// message. It returns a verdict telling the caller whether to proceed; messages
|
||||
// that the gate drops are returned (and audited) so nothing is lost silently.
|
||||
//
|
||||
// **它从不同步调用审查模型**:限流与上限是 O(1) 的判定,审查只是一次「后台修剪」
|
||||
// 的派发。修复前这里同步等 `noise_review`(p50 5.3 s),而五路入站里有三路
|
||||
// (qq-ws、kook-ws、weixin 长轮询)是「单读 goroutine + 同步派发」,于是消息、
|
||||
// 心跳、探活回执共享同一条 6 s 队列 —— 主人口中「消息一直排队」的机制。
|
||||
//
|
||||
// 计数按会话独立(裁决 06.4.3):群聊滑窗与待处理上限都落在 ConversationKey 对应的
|
||||
// flowSession 上(两个群并发互不挤占),只有 MsgPerMin 这一账号级总量护栏仍按适配器汇总。
|
||||
@@ -218,48 +283,55 @@ func (g *FlowGate) Admit(ctx context.Context, msg UnifiedMessage) GateVerdict {
|
||||
now := g.now()
|
||||
key := ConversationKey(msg)
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.maybeGCLocked(now)
|
||||
verdict, job := g.admitLocked(msg, key, now)
|
||||
g.mu.Unlock()
|
||||
|
||||
if job != nil {
|
||||
// 派发在解锁之后:后台审查自己会加锁把结论落回待处理集合,绝不能让它
|
||||
// 与这次准入抢同一把锁(也绝不能让准入等它)。
|
||||
go g.runReview(ctx, job)
|
||||
}
|
||||
return verdict
|
||||
}
|
||||
|
||||
// admitLocked runs the whole admission decision under g.mu and returns the verdict
|
||||
// plus, when due, the background review job the caller must launch after unlocking.
|
||||
// It never calls the Reviewer itself.
|
||||
func (g *FlowGate) admitLocked(msg UnifiedMessage, key string, now time.Time) (GateVerdict, *reviewJob) {
|
||||
// 策略三(会话级): sliding-window rate limits. 群聊上限按每个群独立计数;MsgPerMin
|
||||
// 是账号级的非主人消息总量护栏。Over-limit messages are dropped at the door
|
||||
// (downgraded) instead of blocking.
|
||||
// (downgraded) instead of blocking. 这一段的语义与修复前**一字不改**:
|
||||
// 被限流的消息根本不进待处理集合,也永远不会因为审查被「捞回来」。
|
||||
if g.groupPerMin > 0 && msg.ChannelType == ChannelGroup {
|
||||
st := g.sessionLocked(key)
|
||||
st.groupHits = g.trimHits(st.groupHits, now)
|
||||
if len(st.groupHits) >= g.groupPerMin {
|
||||
return g.drop("rate_limited", msg)
|
||||
return g.drop("rate_limited", msg), nil
|
||||
}
|
||||
st.groupHits = append(st.groupHits, now)
|
||||
}
|
||||
if g.msgPerMin > 0 {
|
||||
g.totalHits = g.trimHits(g.totalHits, now)
|
||||
if len(g.totalHits) >= g.msgPerMin {
|
||||
return g.drop("rate_limited", msg)
|
||||
return g.drop("rate_limited", msg), nil
|
||||
}
|
||||
g.totalHits = append(g.totalHits, now)
|
||||
}
|
||||
|
||||
if g.maxMessages <= 0 {
|
||||
return GateVerdict{Proceed: true}
|
||||
return GateVerdict{Proceed: true}, nil
|
||||
}
|
||||
|
||||
// 策略四(会话级): add to this conversation's pending set, then prune when it
|
||||
// nears the cap.
|
||||
// 策略四(会话级): add to this conversation's pending set, then enforce the cap
|
||||
// synchronously. 审查只被**派发**(下面的 scheduleReviewLocked),结论稍后由
|
||||
// 后台落回 —— 准入读的是「当前已有结论」。
|
||||
st := g.sessionLocked(key)
|
||||
g.seq++
|
||||
seq := g.seq
|
||||
st.buffer = append(st.buffer, queuedMsg{seq: seq, msg: msg})
|
||||
|
||||
var dropped []queuedMsg
|
||||
if g.review != nil && len(st.buffer) >= g.reviewAt && !g.reviewRecently(st, now) {
|
||||
keep, err := g.review(ctx, queueMessages(st.buffer))
|
||||
if err == nil {
|
||||
st.buffer, dropped = g.applyReview(st.buffer, keep)
|
||||
st.lastReview = now
|
||||
g.auditDropped(dropped, "review_pruned")
|
||||
}
|
||||
}
|
||||
if len(st.buffer) > g.maxMessages {
|
||||
n := len(st.buffer) - g.maxMessages
|
||||
var evicted []queuedMsg
|
||||
@@ -267,18 +339,12 @@ func (g *FlowGate) Admit(ctx context.Context, msg UnifiedMessage) GateVerdict {
|
||||
dropped = append(dropped, evicted...)
|
||||
g.auditDropped(evicted, "capped")
|
||||
}
|
||||
job := g.scheduleReviewLocked(st, key, now)
|
||||
|
||||
if !bufferHasSeq(st.buffer, seq) {
|
||||
reason := "capped"
|
||||
if containsSeq(dropped, seq) {
|
||||
reason = "review_pruned"
|
||||
}
|
||||
v := GateVerdict{Proceed: false, Reason: reason, Pruned: queueMessages(dropped)}
|
||||
c := msg
|
||||
v.DroppedMsg = &c
|
||||
return v
|
||||
}
|
||||
return GateVerdict{Proceed: true, Pruned: queueMessages(dropped)}
|
||||
// 本条一定还在缓冲里:上限按新旧顺序丢最旧,而它是最新的(maxMessages ≥ 1)。
|
||||
// 于是修复后准入路径上唯一能让消息 Proceed=false 的判据就是上面的限流 —— 审查
|
||||
// (已移到后台)与上限(丢最旧)都只影响**已经放行过的**历史条目,只留审计痕迹。
|
||||
return GateVerdict{Proceed: true, Pruned: queueMessages(dropped)}, job
|
||||
}
|
||||
|
||||
// AllowDirect enforces the per-sender sliding-window rate limit for a **direct
|
||||
@@ -385,15 +451,6 @@ func (g *FlowGate) maybeGCLocked(now time.Time) {
|
||||
}
|
||||
}
|
||||
|
||||
// reviewRecently throttles review so it does not run on every message: it fires at
|
||||
// most once per window per conversation, which is the cost guard for the review model.
|
||||
func (g *FlowGate) reviewRecently(st *flowSession, now time.Time) bool {
|
||||
if st == nil || st.lastReview.IsZero() {
|
||||
return false
|
||||
}
|
||||
return now.Sub(st.lastReview) < g.window
|
||||
}
|
||||
|
||||
// trimHits drops event timestamps older than the sliding window.
|
||||
func (g *FlowGate) trimHits(hits []time.Time, now time.Time) []time.Time {
|
||||
cutoff := now.Add(-g.window)
|
||||
@@ -407,27 +464,6 @@ func (g *FlowGate) trimHits(hits []time.Time, now time.Time) []time.Time {
|
||||
return hits[:keep]
|
||||
}
|
||||
|
||||
// applyReview keeps only the entries at the returned indices (validated, de-duped,
|
||||
// ascending) and returns the kept slice plus the dropped entries in original order.
|
||||
func (g *FlowGate) applyReview(buffer []queuedMsg, keep []int) (kept, dropped []queuedMsg) {
|
||||
seen := make(map[int]bool, len(keep))
|
||||
for _, i := range keep {
|
||||
if i >= 0 && i < len(buffer) {
|
||||
seen[i] = true
|
||||
}
|
||||
}
|
||||
out := make([]queuedMsg, 0, len(buffer))
|
||||
drop := make([]queuedMsg, 0)
|
||||
for i, entry := range buffer {
|
||||
if seen[i] {
|
||||
out = append(out, entry)
|
||||
} else {
|
||||
drop = append(drop, entry)
|
||||
}
|
||||
}
|
||||
return out, drop
|
||||
}
|
||||
|
||||
// dropFront removes n entries from the front (oldest) and returns the rest plus the
|
||||
// dropped ones.
|
||||
func (g *FlowGate) dropFront(buffer []queuedMsg, n int) (kept, dropped []queuedMsg) {
|
||||
@@ -437,27 +473,6 @@ func (g *FlowGate) dropFront(buffer []queuedMsg, n int) (kept, dropped []queuedM
|
||||
return buffer[n:], append([]queuedMsg(nil), buffer[:n]...)
|
||||
}
|
||||
|
||||
// bufferHasSeq reports whether an entry with the given sequence is still buffered
|
||||
// in the conversation's pending set.
|
||||
func bufferHasSeq(buffer []queuedMsg, seq uint64) bool {
|
||||
for _, e := range buffer {
|
||||
if e.seq == seq {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// containsSeq reports whether seq appears among the dropped entries.
|
||||
func containsSeq(dropped []queuedMsg, seq uint64) bool {
|
||||
for _, e := range dropped {
|
||||
if e.seq == seq {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// auditDropped records every dropped entry to the OnPruned audit hook.
|
||||
func (g *FlowGate) auditDropped(dropped []queuedMsg, reason string) {
|
||||
for _, e := range dropped {
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
package adapter
|
||||
|
||||
// 本文件覆盖 M24:把策略四的审查**移出准入路径**,并让节流在失败路径同样生效。
|
||||
// 每条用例都对应一条可变异点(见 M24 回报里的「预期会红的用例名」)。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// auditRecorder 收集 OnPruned 的审计回调。审查结论来自后台 goroutine,因此这里
|
||||
// 必须加锁(-race 会盯)。
|
||||
type auditRecorder struct {
|
||||
mu sync.Mutex
|
||||
events []auditEvent
|
||||
}
|
||||
|
||||
type auditEvent struct {
|
||||
content string
|
||||
reason string
|
||||
}
|
||||
|
||||
func (r *auditRecorder) hook() func(UnifiedMessage, string) {
|
||||
return func(m UnifiedMessage, reason string) {
|
||||
r.mu.Lock()
|
||||
r.events = append(r.events, auditEvent{content: m.Content, reason: reason})
|
||||
r.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (r *auditRecorder) snapshot() []auditEvent {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]auditEvent(nil), r.events...)
|
||||
}
|
||||
|
||||
func (r *auditRecorder) reasons() []string {
|
||||
out := make([]string, 0)
|
||||
for _, e := range r.snapshot() {
|
||||
out = append(out, e.reason)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// waitReviewIdle 等到闸门没有在飞的审查调用,使后台结论已经落回待处理集合。
|
||||
// reviewInFlight 在**派发时**(Admit 持锁期间)就置位,因此 Admit 返回后立刻
|
||||
// 调用它不会漏掉刚派发的调用。
|
||||
func waitReviewIdle(t *testing.T, g *FlowGate) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
g.mu.Lock()
|
||||
busy := g.reviewInFlight
|
||||
g.mu.Unlock()
|
||||
if !busy {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("reviewer call did not finish within 5s")
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// pendingLen 返回某会话待处理集合的条目数。
|
||||
func pendingLen(g *FlowGate, key string) int {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if st := g.sessions[key]; st != nil {
|
||||
return len(st.buffer)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// keepAll 是「审查者什么都不想丢」的替身:返回全部索引,使待处理集合恒在阈值上,
|
||||
// 从而让节流/预算成为唯一的抑制因素(否则队列一被修剪就跌破阈值,测不出节流)。
|
||||
func keepAll(_ context.Context, msgs []UnifiedMessage) ([]int, error) {
|
||||
keep := make([]int, len(msgs))
|
||||
for i := range msgs {
|
||||
keep[i] = i
|
||||
}
|
||||
return keep, nil
|
||||
}
|
||||
|
||||
// TestFlowGate_AdmitNeverWaitsForSlowReview 是 M24 的核心证据:一个 3–6 s 量级的
|
||||
// 慢审查替身压住闸门时,入站帧仍然按节奏被放行 —— 读循环不再被模型调用阻塞。
|
||||
//
|
||||
// 变异判据:把审查改回同步调用(在 Admit 里直接 g.review(...))⇒ 帧间隔出现一个
|
||||
// ≈4 s 的空窗 ⇒ 本用例红。
|
||||
func TestFlowGate_AdmitNeverWaitsForSlowReview(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
release := make(chan struct{})
|
||||
started := make(chan struct{}, 1)
|
||||
reviewer := func(_ context.Context, _ []UnifiedMessage) ([]int, error) {
|
||||
select {
|
||||
case started <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
// 4 s 的慢审查替身:实测 noise_review 的 p50 5.3 s / max 9.2 s 同量级。
|
||||
select {
|
||||
case <-release:
|
||||
case <-time.After(4 * time.Second):
|
||||
}
|
||||
return nil, fmt.Errorf("slow stand-in returned")
|
||||
}
|
||||
g := NewFlowGate(FlowGateOptions{MaxMessages: 10, Window: time.Minute, Review: reviewer})
|
||||
defer close(release)
|
||||
|
||||
const frames = 100
|
||||
const cadence = 20 * time.Millisecond
|
||||
var maxGap time.Duration
|
||||
var slowestAdmit time.Duration
|
||||
prev := time.Now()
|
||||
for i := 0; i < frames; i++ {
|
||||
admitStart := time.Now()
|
||||
// 消息与心跳/探活回执在适配器侧走的是同一条 Admit 路,这里逐帧模拟。
|
||||
if v := g.Admit(context.Background(), groupMsg(i)); !v.Proceed {
|
||||
t.Fatalf("frame %d dropped by the gate: %+v", i, v)
|
||||
}
|
||||
if d := time.Since(admitStart); d > slowestAdmit {
|
||||
slowestAdmit = d
|
||||
}
|
||||
if gap := time.Since(prev); gap > maxGap {
|
||||
maxGap = gap
|
||||
}
|
||||
prev = time.Now()
|
||||
time.Sleep(cadence)
|
||||
}
|
||||
|
||||
// 先断言「帧没有被拖住」——同步实现会在这里给出可读的红。
|
||||
if maxGap > 500*time.Millisecond {
|
||||
t.Fatalf("inbound frames stalled behind the reviewer: max gap %v (cadence %v)", maxGap, cadence)
|
||||
}
|
||||
if slowestAdmit > 100*time.Millisecond {
|
||||
t.Fatalf("Admit waited on the reviewer: slowest admission %v", slowestAdmit)
|
||||
}
|
||||
|
||||
// 审查确实被派发并且**仍在飞**:证明替身是慢的,上面测到的不是「审查根本没跑」。
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("reviewer was never called: the gate did not schedule the review")
|
||||
}
|
||||
g.mu.Lock()
|
||||
busy := g.reviewInFlight
|
||||
g.mu.Unlock()
|
||||
if !busy {
|
||||
t.Fatal("slow reviewer already returned; the stand-in was not slow enough to prove anything")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlowGate_ReviewThrottleHoldsOnFailure 固定「节流在失败路径也必须生效」:
|
||||
// 审查者连续失败时,同一窗口内同一会话只允许一次调用 —— 这正是修复前失守的地方
|
||||
// (lastReview 只在 err == nil 分支置位,而实测 67–85% 的审查因 BOM 解析失败)。
|
||||
//
|
||||
// 变异判据:把 scheduleReviewLocked 里的 `st.lastReview = now` 挪回「成功之后」
|
||||
// (或删掉)⇒ 11 条消息各派发一次 ⇒ 本用例红。
|
||||
func TestFlowGate_ReviewThrottleHoldsOnFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now, _ := fixedClock(time.Unix(1000, 0))
|
||||
var mu sync.Mutex
|
||||
calls := 0
|
||||
audit := &auditRecorder{}
|
||||
g := NewFlowGate(FlowGateOptions{
|
||||
MaxMessages: 4, // reviewAt = 3
|
||||
Window: time.Minute,
|
||||
Now: now,
|
||||
Review: func(_ context.Context, _ []UnifiedMessage) ([]int, error) {
|
||||
mu.Lock()
|
||||
calls++
|
||||
mu.Unlock()
|
||||
return nil, fmt.Errorf("invalid character 'ï' looking for beginning of value")
|
||||
},
|
||||
OnPruned: audit.hook(),
|
||||
})
|
||||
|
||||
for i := 0; i < 11; i++ {
|
||||
if v := g.Admit(context.Background(), groupMsg(i)); !v.Proceed {
|
||||
t.Fatalf("message %d dropped although the reviewer only failed: %+v", i, v)
|
||||
}
|
||||
waitReviewIdle(t, g)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
got := calls
|
||||
mu.Unlock()
|
||||
if got != 1 {
|
||||
t.Fatalf("reviewer calls within one window = %d, want 1 (throttle must cover the failure path)", got)
|
||||
}
|
||||
// 失败方向:既不额外丢弃,也不给队列任何条目「保留」特权 —— 只有上限该有的
|
||||
// "capped" 审计,绝不出现 "review_pruned"。
|
||||
for _, r := range audit.reasons() {
|
||||
if r == "review_pruned" {
|
||||
t.Fatalf("a failed review must not prune anything, got audits %v", audit.reasons())
|
||||
}
|
||||
}
|
||||
if n := pendingLen(g, ConversationKey(groupMsg(0))); n != 4 {
|
||||
t.Fatalf("pending set = %d, want 4 (cap only, no review pruning)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlowGate_ReviewBudgetBoundsCallsPerWindow 把「每适配器每分钟 LLM 审查次数」的
|
||||
// 硬上界写成可测断言:40 个群在同一窗口内全部越阈,实际调用不超过
|
||||
// defaultReviewPerWindow(6)。
|
||||
//
|
||||
// 变异判据:删掉 reviewBudgetLocked 的调用(或把 reviewBudget 置 0/极大)⇒ 40 次
|
||||
// ⇒ 本用例红。
|
||||
func TestFlowGate_ReviewBudgetBoundsCallsPerWindow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now, _ := fixedClock(time.Unix(1000, 0))
|
||||
var mu sync.Mutex
|
||||
calls := 0
|
||||
g := NewFlowGate(FlowGateOptions{
|
||||
MaxMessages: 4, // reviewAt = 3
|
||||
Window: time.Minute,
|
||||
Now: now,
|
||||
Review: func(ctx context.Context, msgs []UnifiedMessage) ([]int, error) {
|
||||
mu.Lock()
|
||||
calls++
|
||||
mu.Unlock()
|
||||
return keepAll(ctx, msgs)
|
||||
},
|
||||
})
|
||||
|
||||
const groups = 40
|
||||
for gi := 0; gi < groups; gi++ {
|
||||
group := fmt.Sprintf("-20%d", gi)
|
||||
for i := 0; i < 4; i++ {
|
||||
g.Admit(context.Background(), groupMsgIn(group, i))
|
||||
}
|
||||
waitReviewIdle(t, g)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
got := calls
|
||||
mu.Unlock()
|
||||
if got > defaultReviewPerWindow {
|
||||
t.Fatalf("reviewer calls in one window = %d, want <= %d (per-adapter budget)", got, defaultReviewPerWindow)
|
||||
}
|
||||
if got != defaultReviewPerWindow {
|
||||
t.Fatalf("reviewer calls = %d, want exactly %d (every conversation crossed, budget is the only cap)", got, defaultReviewPerWindow)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlowGate_ReviewSingleInFlightPerAdapter 固定「同一适配器至多一次在飞」:
|
||||
// 两个群同时越阈,第二个必须让位,不并发起第二次模型调用。
|
||||
//
|
||||
// 变异判据:删掉 scheduleReviewLocked 里的 reviewInFlight 判断 ⇒ 第二个群也起一次
|
||||
// 调用 ⇒ 本用例红。
|
||||
func TestFlowGate_ReviewSingleInFlightPerAdapter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
release := make(chan struct{})
|
||||
started := make(chan string, 4)
|
||||
reviewer := func(_ context.Context, msgs []UnifiedMessage) ([]int, error) {
|
||||
started <- msgs[0].Content
|
||||
<-release
|
||||
return keepAll(context.Background(), msgs)
|
||||
}
|
||||
g := NewFlowGate(FlowGateOptions{MaxMessages: 4, Window: time.Minute, Review: reviewer})
|
||||
defer close(release)
|
||||
|
||||
for i := 0; i < 4; i++ {
|
||||
g.Admit(context.Background(), groupMsgIn("-100", i))
|
||||
}
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("first review never started")
|
||||
}
|
||||
for i := 0; i < 4; i++ {
|
||||
g.Admit(context.Background(), groupMsgIn("-200", i))
|
||||
}
|
||||
select {
|
||||
case extra := <-started:
|
||||
t.Fatalf("second review started while one was in flight (%q)", extra)
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlowGate_ReviewIgnoresMessagesArrivedDuringCall 固定后台审查的落点:结论只作用于
|
||||
// 审查者**当时看到的那批**(用序号对齐)。审查在飞期间新到的消息没被审查过,绝不能被
|
||||
// 这次结论连带修剪 —— 否则审计会记出「review_pruned」却都是已经放行的消息。
|
||||
//
|
||||
// 变异判据:把 applyReviewSeqs 写成「不在 keep 集合里就丢」(不做快照过滤)⇒ 在飞期间
|
||||
// 到达的两条也被丢 ⇒ 本用例红。
|
||||
func TestFlowGate_ReviewIgnoresMessagesArrivedDuringCall(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
release := make(chan struct{})
|
||||
started := make(chan struct{}, 1)
|
||||
audit := &auditRecorder{}
|
||||
g := NewFlowGate(FlowGateOptions{
|
||||
MaxMessages: 10, // reviewAt = 8
|
||||
Window: time.Minute,
|
||||
Review: func(_ context.Context, _ []UnifiedMessage) ([]int, error) {
|
||||
started <- struct{}{}
|
||||
<-release
|
||||
return []int{0}, nil // 只保留快照里的第 0 条,其余视为噪声
|
||||
},
|
||||
OnPruned: audit.hook(),
|
||||
})
|
||||
|
||||
for i := 0; i < 8; i++ {
|
||||
g.Admit(context.Background(), groupMsg(i))
|
||||
}
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("review never started")
|
||||
}
|
||||
// 审查在飞期间又到两条(seq 9、10):它们不在这次审查的快照里。
|
||||
g.Admit(context.Background(), groupMsg(8))
|
||||
g.Admit(context.Background(), groupMsg(9))
|
||||
close(release)
|
||||
waitReviewIdle(t, g)
|
||||
|
||||
// 快照第 0 条(msg 0)保留 + 在飞期间到达的两条 = 3。
|
||||
if n := pendingLen(g, ConversationKey(groupMsg(0))); n != 3 {
|
||||
t.Fatalf("pending set = %d, want 3 (msg0 + the two messages the reviewer never saw)", n)
|
||||
}
|
||||
for _, e := range audit.snapshot() {
|
||||
if e.reason != "review_pruned" {
|
||||
t.Fatalf("audit reason = %q, want review_pruned", e.reason)
|
||||
}
|
||||
if e.content == "msg 8" || e.content == "msg 9" {
|
||||
t.Fatalf("a message that arrived during the call was pruned: %q", e.content)
|
||||
}
|
||||
}
|
||||
if got := len(audit.snapshot()); got != 7 {
|
||||
t.Fatalf("review_pruned count = %d, want 7 (snapshot entries 1..7)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlowGate_CapStillEvictsOnAdmission 固定上限那一半仍是**同步**的:队列超出
|
||||
// maxMessages 时最旧的条目在准入当次就被挤掉并审计 "capped",不依赖审查。
|
||||
//
|
||||
// 变异判据:把上限修剪也挪进后台 ⇒ v.Pruned 为空 ⇒ 本用例红。
|
||||
func TestFlowGate_CapStillEvictsOnAdmission(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
audit := &auditRecorder{}
|
||||
g := NewFlowGate(FlowGateOptions{
|
||||
MaxMessages: 3,
|
||||
Review: keepAll,
|
||||
OnPruned: audit.hook(),
|
||||
})
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
if v := g.Admit(context.Background(), groupMsg(i)); !v.Proceed {
|
||||
t.Fatalf("message %d should fit under the cap: %+v", i, v)
|
||||
}
|
||||
}
|
||||
v := g.Admit(context.Background(), groupMsg(3))
|
||||
if !v.Proceed {
|
||||
t.Fatalf("new message should be admitted after FIFO eviction, got %+v", v)
|
||||
}
|
||||
if len(v.Pruned) != 1 || v.Pruned[0].Content != "msg 0" {
|
||||
t.Fatalf("pruned = %+v, want [msg 0]", v.Pruned)
|
||||
}
|
||||
found := false
|
||||
for _, r := range audit.reasons() {
|
||||
if r == "capped" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("cap eviction not audited as capped: %v", audit.reasons())
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlowGate_RateLimitDropsAtTheDoor 固定 rate_limited 的「门口丢」语义一字不改:
|
||||
// 被滑窗挡下的消息根本不进待处理集合,审查也绝不会把它「捞回来」。
|
||||
func TestFlowGate_RateLimitDropsAtTheDoor(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now, _ := fixedClock(time.Unix(1000, 0))
|
||||
g := NewFlowGate(FlowGateOptions{
|
||||
GroupMsgPerMin: 2,
|
||||
MaxMessages: 3,
|
||||
Window: time.Minute,
|
||||
Now: now,
|
||||
Review: keepAll,
|
||||
})
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
if v := g.Admit(context.Background(), groupMsg(i)); !v.Proceed {
|
||||
t.Fatalf("message %d should pass the group window: %+v", i, v)
|
||||
}
|
||||
}
|
||||
v := g.Admit(context.Background(), groupMsg(2))
|
||||
if v.Proceed {
|
||||
t.Fatal("third message in the window must be rate limited")
|
||||
}
|
||||
if v.Reason != "rate_limited" {
|
||||
t.Fatalf("reason = %q, want rate_limited", v.Reason)
|
||||
}
|
||||
if v.DroppedMsg == nil || v.DroppedMsg.Content != "msg 2" {
|
||||
t.Fatalf("dropped msg = %+v, want msg 2", v.DroppedMsg)
|
||||
}
|
||||
if n := pendingLen(g, ConversationKey(groupMsg(0))); n != 2 {
|
||||
t.Fatalf("pending set = %d, want 2: a rate-limited message never enters it", n)
|
||||
}
|
||||
waitReviewIdle(t, g)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package adapter
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// utf8BOM 是 UTF-8 的字节序标记。对端(审查模型网关)会把它原样回带,而
|
||||
// encoding/json 见到它报 `invalid character 'ï' looking for beginning of value` ——
|
||||
// 实测 9 天 1,909 次审查里 1,288 次(67%)因此解析失败,每一次都白烧一次模型调用
|
||||
// 并把「每窗口一次」的节流打穿(见 DevDocs/audit/群聊排队与回旧话-2026-10-24.md)。
|
||||
const utf8BOM = "\ufeff"
|
||||
|
||||
// reviewExcerptLen 是解析失败时附带在错误里的模型输出长度(**rune**)。它只含模型
|
||||
// 返回的编号数组(数字/方括号/逗号/围栏),足以定位「又换了什么壳」,不至于把
|
||||
// 消息内容带进日志。
|
||||
//
|
||||
// 🔴 单位是 rune 不是字节:审查模型回的中文/全角标点每个字 3 字节,按 40 **字节**
|
||||
// 截会把其中一个字切成半个 —— 错误串变成非法 UTF-8,终端显示 U+FFFD、JSON 客户端
|
||||
// 各自乱码。带上限出镜是刻意的(失败原因对排障有用),但上限必须以字符计。
|
||||
const reviewExcerptLen = 40
|
||||
|
||||
// ParseReviewIndices decodes the keep-index array a review model returned, rising
|
||||
// above the shapes real gateways produce instead of requiring a bare JSON array:
|
||||
//
|
||||
// - a UTF-8 BOM prefix (`\ufeff`) — the actual cause of the 67% failure rate;
|
||||
// - leading/trailing whitespace and newlines;
|
||||
// - a Markdown code fence (` ```json … ``` ` or ` ``` … ``` `);
|
||||
// - surrounding prose ("好的,保留以下编号:[0, 2]") — the outermost `[ … ]`
|
||||
// span is used.
|
||||
//
|
||||
// The decoded indices are validated the same way the previous strict parser did:
|
||||
// out-of-range and duplicate values are dropped and the rest sorted ascending, so a
|
||||
// hallucinated number can never evict a real message. An empty or absent array is
|
||||
// NOT an error here — the caller decides; a missing/non-array answer is.
|
||||
func ParseReviewIndices(s string, bound int) ([]int, error) {
|
||||
body, ok := reviewArraySpan(s)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no JSON array in reviewer output %q", reviewExcerpt(s))
|
||||
}
|
||||
var idx []int
|
||||
if err := json.Unmarshal([]byte(body), &idx); err != nil {
|
||||
return nil, fmt.Errorf("decode reviewer indices %q: %w", reviewExcerpt(body), err)
|
||||
}
|
||||
seen := make(map[int]bool, len(idx))
|
||||
out := make([]int, 0, len(idx))
|
||||
for _, i := range idx {
|
||||
if i >= 0 && i < bound && !seen[i] {
|
||||
seen[i] = true
|
||||
out = append(out, i)
|
||||
}
|
||||
}
|
||||
sort.Ints(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// reviewArraySpan strips the decorations a model puts around its answer (BOM,
|
||||
// whitespace, code fence, prose) and returns the outermost JSON array span.
|
||||
func reviewArraySpan(s string) (string, bool) {
|
||||
s = strings.TrimSpace(strings.TrimPrefix(s, utf8BOM))
|
||||
s = strings.TrimSpace(unwrapCodeFence(s))
|
||||
start := strings.IndexByte(s, '[')
|
||||
end := strings.LastIndexByte(s, ']')
|
||||
if start < 0 || end < start {
|
||||
return "", false
|
||||
}
|
||||
return s[start : end+1], true
|
||||
}
|
||||
|
||||
// unwrapCodeFence drops a leading ``` fence line and the closing ``` so the fenced
|
||||
// body is left for the array scan. Anything without a leading fence is returned
|
||||
// unchanged; a fence with no body is returned as-is and fails the array scan.
|
||||
func unwrapCodeFence(s string) string {
|
||||
if !strings.HasPrefix(s, "```") {
|
||||
return s
|
||||
}
|
||||
nl := strings.IndexByte(s, '\n')
|
||||
if nl < 0 {
|
||||
return s
|
||||
}
|
||||
s = s[nl+1:]
|
||||
if close := strings.LastIndex(s, "```"); close >= 0 {
|
||||
s = s[:close]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// reviewExcerpt 把模型输出裁成一段可以进错误串的摘要。两步都不是可选的:
|
||||
//
|
||||
// - trace.RedactTextLimited 先清洗凭据形状(审查网关偶有把 Authorization 回显进
|
||||
// 输出的),再按 **rune** 截到 reviewExcerptLen —— 按字节截会把多字节字符切成
|
||||
// 半个,错误串因此变成非法 UTF-8;
|
||||
// - 压平空白:一条诊断只占一行,也挡掉换行注入。
|
||||
func reviewExcerpt(s string) string {
|
||||
return trace.RedactTextLimited(strings.Join(strings.Fields(s), " "), reviewExcerptLen)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package adapter
|
||||
|
||||
// 本文件用**对端原样输出**(含 UTF-8 BOM 字节)做夹具,固定审查输出的解析韧性。
|
||||
// 生产症状(`~/cy.log`,9 天):`flow gate review output unparsable` 1,301 次,其中
|
||||
// 1,288 次的错误串逐字是 `invalid character 'ï' looking for beginning of value` ——
|
||||
// 对端在 JSON 数组前带了 BOM,而闸门用的 json.Unmarshal 不接受它。
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// bomReviewFixture 是审查模型网关的实际回包形状:UTF-8 BOM(EF BB BF)紧接一个
|
||||
// JSON 数组。写死字节而不是用 \ufeff,是为了让「夹具被谁悄悄去掉 BOM」本身就会红。
|
||||
const bomReviewFixture = "\xef\xbb\xbf[0, 1, 2, 3, 4, 5]"
|
||||
|
||||
// TestReviewBOMFixtureStillReproducesTheFailure 先钉住夹具的真实性:它必须以 BOM
|
||||
// 开头,且严格的 json.Unmarshal 必须在它上面失败,错误串必须就是生产日志里那条。
|
||||
//
|
||||
// 变异判据:把夹具里的 BOM 去掉 ⇒ 本用例红(同时也说明 BOM 相关用例已失去意义)。
|
||||
func TestReviewBOMFixtureStillReproducesTheFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if !strings.HasPrefix(bomReviewFixture, "\xef\xbb\xbf") {
|
||||
t.Fatal("BOM fixture lost its BOM bytes: it no longer reproduces the production failure")
|
||||
}
|
||||
var idx []int
|
||||
err := json.Unmarshal([]byte(bomReviewFixture), &idx)
|
||||
if err == nil {
|
||||
t.Fatal("strict json.Unmarshal accepted the BOM fixture: the regression it models is gone")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid character 'ï'") {
|
||||
t.Fatalf("strict parse error = %v, want the production symptom \"invalid character 'ï'\"", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseReviewIndices_ToleratesBOM 是修复的正面证据:同一份带 BOM 的原样回包
|
||||
// 现在能解析出编号。
|
||||
//
|
||||
// 变异判据:去掉 ParseReviewIndices 里的 reviewArraySpan(回到裸 json.Unmarshal)
|
||||
// ⇒ 本用例红。
|
||||
func TestParseReviewIndices_ToleratesBOM(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
keep, err := ParseReviewIndices(bomReviewFixture, 6)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseReviewIndices(BOM fixture) error = %v, want nil", err)
|
||||
}
|
||||
want := []int{0, 1, 2, 3, 4, 5}
|
||||
if len(keep) != len(want) {
|
||||
t.Fatalf("keep = %v, want %v", keep, want)
|
||||
}
|
||||
for i := range want {
|
||||
if keep[i] != want[i] {
|
||||
t.Fatalf("keep = %v, want %v", keep, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseReviewIndices_ToleratesDecorations 覆盖模型常见的几种外壳:首尾空白与换行、
|
||||
// Markdown 代码围栏(带或不带语言标签)、以及围在数组外面的解说文字。
|
||||
func TestParseReviewIndices_ToleratesDecorations(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := map[string]string{
|
||||
"newlines": "\n\n[0, 2]\n\n",
|
||||
"fence": "```\n[0, 2]\n```",
|
||||
"fence with lang": "```json\n[0, 2]\n```",
|
||||
"prose prefix": "好的,保留以下编号:[0, 2]",
|
||||
"prose suffix": "[0, 2] 以上是保留的编号。",
|
||||
"bom plus fence": "\xef\xbb\xbf```json\n[0, 2]\n```",
|
||||
"spaced values": "[ 0 , 2 ]",
|
||||
}
|
||||
for name, raw := range cases {
|
||||
keep, err := ParseReviewIndices(raw, 3)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: error = %v, want nil", name, err)
|
||||
}
|
||||
if len(keep) != 2 || keep[0] != 0 || keep[1] != 2 {
|
||||
t.Fatalf("%s: keep = %v, want [0 2]", name, keep)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseReviewIndices_FiltersHallucinations 固定过滤口径与修复前一致:越界、负数、
|
||||
// 重复的编号一律丢弃(模型幻觉出的数字绝不能淘汰真实消息),结果升序。
|
||||
func TestParseReviewIndices_FiltersHallucinations(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
keep, err := ParseReviewIndices("\xef\xbb\xbf[2, 2, 99, -1, 0]", 3)
|
||||
if err != nil {
|
||||
t.Fatalf("error = %v, want nil", err)
|
||||
}
|
||||
if len(keep) != 2 || keep[0] != 0 || keep[1] != 2 {
|
||||
t.Fatalf("keep = %v, want [0 2]", keep)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseReviewIndices_EmptyArrayIsNotAnError:模型明确回空数组 = 「一条都不保留」,
|
||||
// 这是有效结论(全部当噪声),不是解析失败 —— 失败方向必须留给真正的畸形输出。
|
||||
func TestParseReviewIndices_EmptyArrayIsNotAnError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
keep, err := ParseReviewIndices("\xef\xbb\xbf[]", 5)
|
||||
if err != nil {
|
||||
t.Fatalf("error = %v, want nil", err)
|
||||
}
|
||||
if len(keep) != 0 {
|
||||
t.Fatalf("keep = %v, want empty", keep)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseReviewIndices_RejectsGarbage:真正畸形的输出仍然报错(调用方据此走
|
||||
// 「不额外丢弃、也不提权」的安全方向),错误里带一小段原文便于定位下一次变种。
|
||||
func TestParseReviewIndices_RejectsGarbage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for name, raw := range map[string]string{
|
||||
"no array": "抱歉,我无法判断。",
|
||||
"empty": "",
|
||||
"array of str": `["a", "b"]`,
|
||||
} {
|
||||
if _, err := ParseReviewIndices(raw, 5); err == nil {
|
||||
t.Fatalf("%s: error = nil, want a decode error", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestReviewExcerpt_TruncatesByRune 钉住「上限按字符计,不按字节计」:审查模型回的中文
|
||||
// 每个字 3 字节,按 40 **字节**截会把其中一个字切成半个,错误串因此成为非法 UTF-8
|
||||
// (终端显示 U+FFFD、JSON 客户端各自乱码)。
|
||||
//
|
||||
// 变异判据:把 reviewExcerpt 换回 `s[:reviewExcerptLen] + "…"` 的字节截断 ⇒ 本用例红
|
||||
// (utf8.ValidString 为 false)。
|
||||
func TestReviewExcerpt_TruncatesByRune(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := reviewExcerpt(strings.Repeat("中", reviewExcerptLen*2))
|
||||
if !utf8.ValidString(got) {
|
||||
t.Fatalf("截断后不是合法 UTF-8(按字节截断了半个汉字):%q", got)
|
||||
}
|
||||
body := strings.TrimSuffix(got, "…")
|
||||
if n := utf8.RuneCountInString(body); n != reviewExcerptLen {
|
||||
t.Fatalf("保留 %d 个字符,期望 %d:%q", n, reviewExcerptLen, got)
|
||||
}
|
||||
if !strings.HasSuffix(got, "…") {
|
||||
t.Errorf("超限时应当带省略号:%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReviewExcerpt_RedactsCredentialShapes 钉住「带上限出镜 ≠ 原样出镜」:摘要仍要
|
||||
// 过 trace.RedactText,网关回显的凭据形状(sk-… / token=… 等)必须被打码。
|
||||
//
|
||||
// 变异判据:让 reviewExcerpt 只做截断、不过 trace.RedactTextLimited ⇒ 本用例红。
|
||||
func TestReviewExcerpt_RedactsCredentialShapes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const secret = "sk-abcdefghijklmnop"
|
||||
got := reviewExcerpt("[0, 1] Authorization: Bearer " + secret)
|
||||
if strings.Contains(got, secret) {
|
||||
t.Fatalf("凭据形状没被打码:%q", got)
|
||||
}
|
||||
if !strings.Contains(got, "[redacted]") {
|
||||
t.Errorf("打码标记缺失:%q", got)
|
||||
}
|
||||
if !strings.Contains(got, "[0, 1]") {
|
||||
t.Errorf("摘要丢了诊断价值(编号数组):%q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package adapter
|
||||
|
||||
// 本文件是策略四的「审查」半边:把它从准入路径上摘下来的派发器(reviewJob /
|
||||
// scheduleReviewLocked / runReview)与三重上界(同适配器在飞、每会话窗口、每适配器
|
||||
// 窗口预算)。解析韧性在 flowgate_parse.go,准入判定仍在 flowgate.go。
|
||||
//
|
||||
// 为什么必须是后台:`noise_review` 实测 p50 5,294 ms / p95 6,651 ms / max 9,204 ms
|
||||
// (`~/cy.log`,9 天 1,900 次),而五路入站里 qq-ws、kook-ws、weixin 长轮询都是
|
||||
// 「单读 goroutine + 同步派发」—— 同步跑在读循环上等于把消息、心跳、探活回执全排在
|
||||
// 一次 6 秒的模型调用后面(DevDocs/audit/群聊排队与回旧话-2026-10-24.md)。
|
||||
//
|
||||
// # 与修复前的语义差异(哪一条变了、哪一条没变)
|
||||
//
|
||||
// - `rate_limited`(策略三,门口丢):**一字不改**。超窗消息在准入当次就被拒绝、
|
||||
// 根本不进待处理集合,也不经过审查。
|
||||
// - 上限修剪(策略四的硬部分):**一字不改**。待处理集合超过 maxMessages 时,最旧的
|
||||
// 条目仍在**准入当次同步**被挤掉并审计为 "capped";触发水位仍是上限的 80%。
|
||||
// - 审查修剪(策略四的模型部分):**判据不变,时机变了**。触发条件(≥80% 上限、
|
||||
// 同一会话每窗口至多一次、返回保留编号、越界/重复编号丢弃)逐条沿用;变的是结论
|
||||
// 落地时点 —— 它不再决定「触发它的那条消息」是否放行,而是修剪**它当时看到的**
|
||||
// 那批历史条目(审计为 "review_pruned")。
|
||||
// 直接后果:`review_pruned` 不再从门口丢消息(实测原本 41–74 条/日),那些消息
|
||||
// 现在会进入流水线 —— 完整性↑、噪声↑,是否开口仍由 engage_judge / 冷避 / owner
|
||||
// 闸判(这三处判据一行未动)。
|
||||
//
|
||||
// 为什么这个差异可以接受:design/12 §6 策略四给审查的职责是「保护 loop 不被不重要的
|
||||
// 消息塞满」的历史积压去噪,不是逐条消息的准入判决;把一次 6 秒的模型调用钉在准入
|
||||
// 路径上,代价(读循环阻塞 → 消息/心跳/探活全排队)远大于收益(少进 41–74 条噪声/日)。
|
||||
// 真正决定「进不进 loop」的两道判据 —— 限流与上限 —— 都留在准入路径上,且未改口径。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// reviewTimeout bounds one background reviewer call. The in-flight slot is held
|
||||
// for the whole call, so an unbounded call would also freeze every other
|
||||
// conversation's review for that account. 上一版没有上界,最长一次实测 9.2 s;
|
||||
// 30 s 留足余量而不至于让一次挂死的调用长期占位。
|
||||
reviewTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
// reviewJob is one scheduled reviewer call: the snapshot of the pending set the
|
||||
// reviewer is asked about, plus the sequence numbers that identify those entries
|
||||
// when the answer comes back (the buffer keeps moving while the call is in flight).
|
||||
type reviewJob struct {
|
||||
key string
|
||||
msgs []UnifiedMessage
|
||||
seqs []uint64
|
||||
}
|
||||
|
||||
// scheduleReviewLocked reports whether this admission should start a background
|
||||
// review and, when it should, marks that review as started and returns its job.
|
||||
//
|
||||
// 三重上界都在这里(调用方须持有 g.mu):
|
||||
// 1. 同一适配器至多一次在飞(reviewInFlight)—— 后台审查有界,不会因为多个群
|
||||
// 同时越阈而并发起 N 个模型调用;
|
||||
// 2. 同一会话每 window 至多一次(lastReview)—— 修复前它只在成功分支置位,
|
||||
// 于是 68% 解析失败时「每窗口一次」退化成「每条消息一次」;
|
||||
// 3. 整个适配器每 window 至多 reviewBudget 次(reviewHits)—— 与模型耗时无关的
|
||||
// 硬上界,会话再多也乘不上去。
|
||||
//
|
||||
// lastReview 在这里(**派发时**)写入,而不是等调用返回:成功与失败两条路径因此
|
||||
// 同样被节流,这是「每条入站帧都付一次 6 秒」的正面修复。
|
||||
func (g *FlowGate) scheduleReviewLocked(st *flowSession, key string, now time.Time) *reviewJob {
|
||||
if g.review == nil || g.maxMessages <= 0 || len(st.buffer) < g.reviewAt {
|
||||
return nil
|
||||
}
|
||||
if g.reviewInFlight || g.reviewRecently(st, now) || !g.reviewBudgetLocked(now) {
|
||||
return nil
|
||||
}
|
||||
st.lastReview = now
|
||||
g.reviewInFlight = true
|
||||
g.reviewHits = append(g.reviewHits, now)
|
||||
return &reviewJob{key: key, msgs: queueMessages(st.buffer), seqs: bufferSeqs(st.buffer)}
|
||||
}
|
||||
|
||||
// runReview executes one scheduled review off the admission path and folds its
|
||||
// verdict back into the conversation's pending set. It is the only place the
|
||||
// Reviewer is called.
|
||||
//
|
||||
// 失败方向(安全侧,与修复前 `err != nil` 分支同口径):审查失败**既不额外丢弃**
|
||||
// (失败绝不被解释成「低价值 ⇒ 丢掉」),**也不给任何条目保留特权**(失败绝不被
|
||||
// 解释成「高价值 ⇒ 提权」);待处理集合原样保留,只由既有的 FIFO 上限兜底。
|
||||
// 唯一的代价是这一批噪声没被修剪,而窗口节流保证下一次尝试要等到下个窗口。
|
||||
func (g *FlowGate) runReview(ctx context.Context, job *reviewJob) {
|
||||
rctx, cancel := reviewContext(ctx)
|
||||
defer cancel()
|
||||
keep, err := g.review(rctx, job.msgs)
|
||||
|
||||
g.mu.Lock()
|
||||
g.reviewInFlight = false
|
||||
var dropped []queuedMsg
|
||||
if err == nil {
|
||||
// 会话可能在审查期间被 GC 掉(空闲清理),那时结论无处可落,直接丢弃。
|
||||
if st := g.sessions[job.key]; st != nil {
|
||||
st.buffer, dropped = applyReviewSeqs(st.buffer, job.seqs, keep)
|
||||
}
|
||||
}
|
||||
g.mu.Unlock()
|
||||
|
||||
// 审计在锁外:钩子由 channelbridge 接(落库/面板),不该占着闸门的锁跑 I/O。
|
||||
g.auditDropped(dropped, "review_pruned")
|
||||
}
|
||||
|
||||
// reviewContext detaches the reviewer call from the admission caller's cancellation
|
||||
// — the inbound handler returns the moment the message is admitted, and on several
|
||||
// adapters its ctx dies with the frame — while still bounding the call so a hung
|
||||
// model cannot pin the single in-flight slot forever.
|
||||
func reviewContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
return context.WithTimeout(context.WithoutCancel(ctx), reviewTimeout)
|
||||
}
|
||||
|
||||
// reviewRecently throttles review so it does not run on every message: it fires at
|
||||
// most once per window per conversation, which is the cost guard for the review model.
|
||||
func (g *FlowGate) reviewRecently(st *flowSession, now time.Time) bool {
|
||||
if st == nil || st.lastReview.IsZero() {
|
||||
return false
|
||||
}
|
||||
return now.Sub(st.lastReview) < g.window
|
||||
}
|
||||
|
||||
// reviewBudgetLocked enforces the per-adapter ceiling on reviewer calls started in
|
||||
// the current window. 调用方须持有 g.mu。
|
||||
func (g *FlowGate) reviewBudgetLocked(now time.Time) bool {
|
||||
g.reviewHits = g.trimHits(g.reviewHits, now)
|
||||
return len(g.reviewHits) < g.reviewBudget
|
||||
}
|
||||
|
||||
// applyReviewSeqs keeps only the buffered entries the reviewer chose. Entries are
|
||||
// matched by sequence number, so:
|
||||
// - entries evicted by the cap while the call was in flight simply do not appear
|
||||
// in the buffer any more (they were audited as "capped" at eviction time);
|
||||
// - entries that arrived **after** the snapshot are never judged by this review
|
||||
// and always survive (the reviewer never saw them).
|
||||
func applyReviewSeqs(buffer []queuedMsg, seqs []uint64, keep []int) (kept, dropped []queuedMsg) {
|
||||
reviewed := make(map[uint64]bool, len(seqs))
|
||||
for _, s := range seqs {
|
||||
reviewed[s] = true
|
||||
}
|
||||
keepSet := make(map[uint64]bool, len(keep))
|
||||
for _, i := range keep {
|
||||
if i >= 0 && i < len(seqs) {
|
||||
keepSet[seqs[i]] = true
|
||||
}
|
||||
}
|
||||
kept = make([]queuedMsg, 0, len(buffer))
|
||||
dropped = make([]queuedMsg, 0)
|
||||
for _, e := range buffer {
|
||||
if !reviewed[e.seq] || keepSet[e.seq] {
|
||||
kept = append(kept, e)
|
||||
continue
|
||||
}
|
||||
dropped = append(dropped, e)
|
||||
}
|
||||
return kept, dropped
|
||||
}
|
||||
|
||||
// bufferSeqs extracts the sequence numbers of buffered entries in order, so a
|
||||
// review verdict can be applied to exactly the entries it was asked about.
|
||||
func bufferSeqs(buffer []queuedMsg) []uint64 {
|
||||
out := make([]uint64, len(buffer))
|
||||
for i, e := range buffer {
|
||||
out[i] = e.seq
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package adapter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -118,10 +119,16 @@ func TestFlowGate_MsgPerMinCapsAllNonOwner(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlowGate_ReviewPrunesWhenNearCap(t *testing.T) {
|
||||
// TestFlowGate_ReviewPrunesBacklogAfterAdmission 固定策略四的新口径:审查**不在准入
|
||||
// 路径上**。越阈那一条消息照常放行(它的到达只负责派发审查),审查完成后只修剪
|
||||
// **它当时看到的那批**待处理条目,并以 "review_pruned" 入审计。
|
||||
//
|
||||
// 变异判据:把审查改回在 Admit 里同步执行并按结论丢「刚到的那条」⇒ 本用例的
|
||||
// `v.Proceed` 断言红。
|
||||
func TestFlowGate_ReviewPrunesBacklogAfterAdmission(t *testing.T) {
|
||||
t.Parallel()
|
||||
now, _ := fixedClock(time.Unix(1000, 0))
|
||||
var audited []string
|
||||
audit := &auditRecorder{}
|
||||
g := NewFlowGate(FlowGateOptions{
|
||||
MaxMessages: 10,
|
||||
Window: time.Minute,
|
||||
@@ -130,9 +137,7 @@ func TestFlowGate_ReviewPrunesWhenNearCap(t *testing.T) {
|
||||
// Keep only the first two messages; drop the rest as noise.
|
||||
return []int{0, 1}, nil
|
||||
},
|
||||
OnPruned: func(_ UnifiedMessage, reason string) {
|
||||
audited = append(audited, reason)
|
||||
},
|
||||
OnPruned: audit.hook(),
|
||||
})
|
||||
|
||||
// Seven messages stay below the 80% threshold (reviewAt = 8), no review fires.
|
||||
@@ -141,69 +146,87 @@ func TestFlowGate_ReviewPrunesWhenNearCap(t *testing.T) {
|
||||
t.Fatalf("message %d should be admitted before threshold: %+v", i, v)
|
||||
}
|
||||
}
|
||||
if len(audited) != 0 {
|
||||
t.Fatalf("review fired below threshold: %v", audited)
|
||||
waitReviewIdle(t, g)
|
||||
if n := len(audit.snapshot()); n != 0 {
|
||||
t.Fatalf("review fired below threshold: %d audits", n)
|
||||
}
|
||||
|
||||
// The 8th message crosses the 80% threshold → review runs and drops msg2..7,
|
||||
// including the just-admitted message 7.
|
||||
// The 8th message crosses the 80% threshold → the review is dispatched in the
|
||||
// background; the message itself is still admitted (admission never waits).
|
||||
v := g.Admit(context.Background(), groupMsg(7))
|
||||
if v.Proceed {
|
||||
t.Fatal("just-admitted message 7 must be pruned by the reviewer")
|
||||
if !v.Proceed {
|
||||
t.Fatalf("the message that crosses the threshold must still be admitted, got %+v", v)
|
||||
}
|
||||
if v.Reason != "review_pruned" {
|
||||
t.Fatalf("reason = %q, want review_pruned", v.Reason)
|
||||
if v.Reason != "" {
|
||||
t.Fatalf("reason = %q, want empty", v.Reason)
|
||||
}
|
||||
if len(v.Pruned) != 6 {
|
||||
t.Fatalf("pruned = %d, want 6 (msg2..msg7)", len(v.Pruned))
|
||||
waitReviewIdle(t, g)
|
||||
|
||||
// The review keeps 0 and 1, so msg2..7 are pruned from the pending set and audited.
|
||||
if n := len(audit.snapshot()); n != 6 {
|
||||
t.Fatalf("audit count = %d, want 6 (msg2..msg7)", n)
|
||||
}
|
||||
if len(audited) != 6 {
|
||||
t.Fatalf("audit count = %d, want 6", len(audited))
|
||||
}
|
||||
for _, r := range audited {
|
||||
if r != "review_pruned" {
|
||||
t.Fatalf("audit reason = %q, want review_pruned", r)
|
||||
for _, e := range audit.snapshot() {
|
||||
if e.reason != "review_pruned" {
|
||||
t.Fatalf("audit reason = %q, want review_pruned", e.reason)
|
||||
}
|
||||
}
|
||||
if n := pendingLen(g, ConversationKey(groupMsg(0))); n != 2 {
|
||||
t.Fatalf("pending set = %d, want 2 (only the kept entries)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlowGate_ReviewThrottledWithinWindow 固定「每会话每窗口至多一次审查」。
|
||||
// 审查者保留全部条目,使待处理集合恒在阈值上 —— 于是节流(而不是队列长度)是唯一的
|
||||
// 抑制因素;这正是修复前失守的那条判据(lastReview 只在成功分支置位)。
|
||||
func TestFlowGate_ReviewThrottledWithinWindow(t *testing.T) {
|
||||
t.Parallel()
|
||||
now, clock := fixedClock(time.Unix(1000, 0))
|
||||
var mu sync.Mutex
|
||||
reviews := 0
|
||||
g := NewFlowGate(FlowGateOptions{
|
||||
MaxMessages: 3,
|
||||
Window: time.Minute,
|
||||
Now: now,
|
||||
Review: func(_ context.Context, msgs []UnifiedMessage) ([]int, error) {
|
||||
Review: func(ctx context.Context, msgs []UnifiedMessage) ([]int, error) {
|
||||
mu.Lock()
|
||||
reviews++
|
||||
return []int{0}, nil
|
||||
mu.Unlock()
|
||||
return keepAll(ctx, msgs)
|
||||
},
|
||||
})
|
||||
count := func() int {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return reviews
|
||||
}
|
||||
|
||||
// First threshold crossing triggers a review.
|
||||
for i := 0; i < 3; i++ {
|
||||
g.Admit(context.Background(), groupMsg(i))
|
||||
}
|
||||
if reviews != 1 {
|
||||
t.Fatalf("reviews after crossing threshold = %d, want 1", reviews)
|
||||
waitReviewIdle(t, g)
|
||||
if got := count(); got != 1 {
|
||||
t.Fatalf("reviews after crossing threshold = %d, want 1", got)
|
||||
}
|
||||
|
||||
// Within the same window the gate must not re-review even when it refills.
|
||||
for i := 3; i < 6; i++ {
|
||||
for i := 3; i < 9; i++ {
|
||||
g.Admit(context.Background(), groupMsg(i))
|
||||
}
|
||||
if reviews != 1 {
|
||||
t.Fatalf("reviews within window = %d, want 1 (throttled)", reviews)
|
||||
waitReviewIdle(t, g)
|
||||
if got := count(); got != 1 {
|
||||
t.Fatalf("reviews within window = %d, want 1 (throttled)", got)
|
||||
}
|
||||
|
||||
// A fresh window allows a review again.
|
||||
*clock = clock.Add(2 * time.Minute)
|
||||
for i := 6; i < 9; i++ {
|
||||
for i := 9; i < 12; i++ {
|
||||
g.Admit(context.Background(), groupMsg(i))
|
||||
}
|
||||
if reviews != 2 {
|
||||
t.Fatalf("reviews after window roll = %d, want 2", reviews)
|
||||
waitReviewIdle(t, g)
|
||||
if got := count(); got != 2 {
|
||||
t.Fatalf("reviews after window roll = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,16 @@
|
||||
// future platforms; existing adapters (Telegram, OneBot) that already carry
|
||||
// their own transport are left untouched.
|
||||
//
|
||||
// It depends only on the standard library and internal/logger. The auth token is
|
||||
// never logged or surfaced in errors. The Authorization scheme is configurable
|
||||
// (default "Bearer" for OAuth2-style tokens; KOOK robots use "Bot").
|
||||
// It depends only on the standard library, internal/logger and internal/runtime/trace.
|
||||
// The auth token is never logged or surfaced in errors. The Authorization scheme is
|
||||
// configurable (default "Bearer" for OAuth2-style tokens; KOOK robots use "Bot").
|
||||
//
|
||||
// The envelope's message is an upstream free-text channel: upstreams echo the rejected
|
||||
// request body into it, and that body holds what she said. It is therefore **not**
|
||||
// carried in errors by default (see WithEnvelopeMessage); code and HTTP status are. The
|
||||
// same rule — code/status stay, free text goes — applies to every channel adapter's own
|
||||
// envelope handling (Telegram description, OneBot msg, iLink CDN x-error-message); see
|
||||
// upstream_text.go.
|
||||
package httpclient
|
||||
|
||||
import (
|
||||
@@ -23,6 +30,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// ErrAPI is the sentinel returned when the remote API reports a non-zero code or
|
||||
@@ -39,6 +47,9 @@ type Client struct {
|
||||
token string
|
||||
authScheme string
|
||||
log *slog.Logger
|
||||
// keepEnvelopeMessage 是**默认关闭**的开关:信封 message 只有显式 opt-in
|
||||
// (WithEnvelopeMessage)才会进错误文本。零值 = 安全,见该函数的注释。
|
||||
keepEnvelopeMessage bool
|
||||
}
|
||||
|
||||
// Option customizes a Client at construction time.
|
||||
@@ -80,6 +91,23 @@ func WithLogger(l *slog.Logger) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithEnvelopeMessage 显式要求把信封的 message 保留在错误文本里。**默认不带**。
|
||||
//
|
||||
// 🔴 默认不带是刻意的,理由与 trace.UpstreamError 的 message 同源:上游会把被拒的输入
|
||||
// 回显在 message 里(KOOK 实测 `invalid content: <她原话>`),于是那句话会随错误进
|
||||
// 调用方的日志。排障真正用得到的分类信息是 `code` 与 `status`,两者**恒在**;message
|
||||
// 只是人话提示。宁可少一条提示,也不让她的原话进日志。
|
||||
//
|
||||
// 只有在你**确认该上游不回显用户内容**时才打开。打开后 message 仍会压平空白、清洗
|
||||
// 凭据形状并截到 maxUpstreamTextRunes(40 rune)—— 上限不像 trace 的 128,是这层没有
|
||||
// 「一屏摘要」的诉求,短到装不下一句正文。
|
||||
//
|
||||
// 函数式选项的形状让最常用的 `New(...)` 天然就是最安全的那个:想放宽必须在这里显式
|
||||
// 写出来,代码评审时一眼可见(与 trace.WithUpstreamMessage 同一个范式)。
|
||||
func WithEnvelopeMessage() Option {
|
||||
return func(c *Client) { c.keepEnvelopeMessage = true }
|
||||
}
|
||||
|
||||
// New builds a Client. With no base URL the caller must pass absolute URLs (via
|
||||
// the Do/Get helpers' path being an absolute URL).
|
||||
func New(opts ...Option) *Client {
|
||||
@@ -129,7 +157,13 @@ func (c *Client) resolve(path string) string {
|
||||
}
|
||||
|
||||
// do performs the request, injecting auth and decoding the envelope.
|
||||
//
|
||||
// 地址在这一层只以来源形态出镜:target 由调用方给(GetJSON 的 query 可能带凭据、
|
||||
// 绝对 path 可能带 user:pass@),完整串既不进错误也不进日志。响应体原文同理 ——
|
||||
// 上游可能把被拒的输入回显在体里,因此 4xx/5xx 只收敛成状态码 + 信封的 code
|
||||
// (message 默认不带,见 WithEnvelopeMessage),其余字节一概不留。
|
||||
func (c *Client) do(ctx context.Context, method, target string, body any) (json.RawMessage, error) {
|
||||
host := trace.HostOf(target)
|
||||
var buf bytes.Buffer
|
||||
if body != nil {
|
||||
if err := json.NewEncoder(&buf).Encode(body); err != nil {
|
||||
@@ -138,7 +172,7 @@ func (c *Client) do(ctx context.Context, method, target string, body any) (json.
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, target, &buf)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("httpclient: build %s %s request: %w", method, target, err)
|
||||
return nil, fmt.Errorf("httpclient: build %s %s request: %w", method, host, trace.URLCause(err))
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
@@ -152,16 +186,16 @@ func (c *Client) do(ctx context.Context, method, target string, body any) (json.
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("httpclient: %s %s: %w", method, target, err)
|
||||
return nil, fmt.Errorf("httpclient: %s %s: %w", method, host, trace.URLCause(err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("httpclient: read %s %s response: %w", method, target, err)
|
||||
return nil, fmt.Errorf("httpclient: read %s %s response: %w", method, host, trace.URLCause(err))
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("httpclient: %s %s: status %d: %s: %w", method, target, resp.StatusCode, truncate(raw), ErrAPI)
|
||||
return nil, c.apiFailure(method, host, resp, raw)
|
||||
}
|
||||
|
||||
var envelope APIEnvelope
|
||||
@@ -169,15 +203,49 @@ func (c *Client) do(ctx context.Context, method, target string, body any) (json.
|
||||
return raw, nil
|
||||
}
|
||||
if envelope.Code != 0 {
|
||||
return nil, fmt.Errorf("httpclient: %s %s: code %d: %s: %w", method, target, envelope.Code, envelope.Message, ErrAPI)
|
||||
if detail := c.envelopeDetail(envelope.Message); detail != "" {
|
||||
return nil, fmt.Errorf("httpclient: %s %s: code %d: %s: %w",
|
||||
method, host, envelope.Code, detail, ErrAPI)
|
||||
}
|
||||
return nil, fmt.Errorf("httpclient: %s %s: code %d: %w", method, host, envelope.Code, ErrAPI)
|
||||
}
|
||||
return envelope.Data, nil
|
||||
}
|
||||
|
||||
// truncate caps a byte slice to 512 bytes for inclusion in an error message.
|
||||
func truncate(b []byte) string {
|
||||
if len(b) > 512 {
|
||||
return strings.TrimSpace(string(b[:512])) + "…"
|
||||
// apiFailure 把一次非 2xx 响应收敛成一条不携带响应体原文的错误:来源主机 + 状态码,
|
||||
// 外加(当体确实是 `{code,message}` 信封时)code —— message 同样只在显式 opt-in 时
|
||||
// 才出现。其余字节只经 trace.UpstreamError 留下体积与 sha256 前缀,供「是不是同一份
|
||||
// 报文」这类相关性判断。
|
||||
func (c *Client) apiFailure(method, host string, resp *http.Response, raw []byte) error {
|
||||
upstream := trace.UpstreamError(host, resp.StatusCode, resp.Header.Get("Content-Type"), raw)
|
||||
var envelope APIEnvelope
|
||||
if err := json.Unmarshal(raw, &envelope); err == nil {
|
||||
detail := c.envelopeDetail(envelope.Message)
|
||||
switch {
|
||||
case envelope.Code != 0 && detail != "":
|
||||
return fmt.Errorf("httpclient: %s %s: code %d: %s: %w: %w",
|
||||
method, host, envelope.Code, detail, upstream, ErrAPI)
|
||||
case envelope.Code != 0:
|
||||
return fmt.Errorf("httpclient: %s %s: code %d: %w: %w", method, host, envelope.Code, upstream, ErrAPI)
|
||||
case detail != "":
|
||||
return fmt.Errorf("httpclient: %s %s: %s: %w: %w", method, host, detail, upstream, ErrAPI)
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
return fmt.Errorf("httpclient: %s: %w: %w", method, upstream, ErrAPI)
|
||||
}
|
||||
|
||||
// envelopeDetail 返回**允许进错误文本**的信封 message:默认空串(不带)。
|
||||
//
|
||||
// 为什么默认不带(这条取舍是实测得出的):上游会把被拒的请求正文回显在 message 里,
|
||||
// 而那条正文就是她的话。M49 复审的探针在隔离副本里拿到的实际文本是
|
||||
// `... code 1: invalid content: 她刚才说的那句话: ...: status=400` —— 她的原话既进了
|
||||
// 错误也进了调用方的日志。排障需要的是 code 与 status(两者恒在),不是那句话。
|
||||
//
|
||||
// 只有 WithEnvelopeMessage() 显式打开后,这里才返回 upstreamText(压平空白 + 凭据打码
|
||||
// + 截到 40 rune)。
|
||||
func (c *Client) envelopeDetail(message string) string {
|
||||
if !c.keepEnvelopeMessage {
|
||||
return ""
|
||||
}
|
||||
return upstreamText(message)
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package httpclient
|
||||
|
||||
// 本文件钉住本客户端的出镜边界(M41 #10/#11 那一族,M50 补信封 message):
|
||||
//
|
||||
// - **非 2xx 的响应体原文不进错误**:旧实现把体截到 512B 拼进 error,而调用方
|
||||
// (KOOK 适配器)会把这条 error 直接落日志 —— 上游在体里回显什么,日志里就有
|
||||
// 什么。现在只留状态码、来源主机,以及信封的 code(message 默认不带)。
|
||||
// - **信封 message 默认不带**:上游会用它在被拒时回显请求正文(也就是她的话)。
|
||||
// 要带必须显式 WithEnvelopeMessage(),那时仍过 upstreamText(凭据打码 + 40 rune)。
|
||||
// - **请求地址的凭据不进错误**:target 可能带 `?access_token=…` 或 `user:pass@`,
|
||||
// 而 net/http 的 *url.Error 会把整条地址拼进错误串。
|
||||
//
|
||||
// 每条用例都是「把它改回去就变红」的形状:断言的是**错误文本**,也就是调用方实际
|
||||
// 打进日志的那段字符串。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// failingTransport 让 http.Client.Do 以传输层错误收场。选定它而不是拨一个真端口,
|
||||
// 是因为只有传输层失败才会让 net/http 包出 **带完整地址** 的 *url.Error ——
|
||||
// 那正是要钉住的泄漏形态。
|
||||
type failingTransport struct{}
|
||||
|
||||
func (failingTransport) RoundTrip(*http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("dial tcp: connection refused")
|
||||
}
|
||||
|
||||
// TestDoJSON_StatusErrorDoesNotEchoBodySentinel 4xx 体里放哨兵 ⇒ 错误里搜不到。
|
||||
//
|
||||
// 用非信封体(代理返回的 HTML 错误页就是这样):信封类的体按设计会保留 code(message
|
||||
// 默认不带),非信封体则一个字节都不留。
|
||||
func TestDoJSON_StatusErrorDoesNotEchoBodySentinel(t *testing.T) {
|
||||
t.Parallel()
|
||||
const sentinel = "SENTINEL-4XX-BODY"
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
_, _ = w.Write([]byte("<html><body>bad gateway " + sentinel + "</body></html>"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()), WithLogger(discardLogger()))
|
||||
_, err := c.DoJSON(context.Background(), http.MethodPost, "/message/create", map[string]any{"a": 1})
|
||||
if err == nil {
|
||||
t.Fatal("502 却报成功")
|
||||
}
|
||||
if !errors.Is(err, ErrAPI) {
|
||||
t.Fatalf("err = %v, want ErrAPI", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), sentinel) {
|
||||
t.Fatalf("响应体原文进了错误(会随调用方落日志):%v", err)
|
||||
}
|
||||
// 可诊断性不能一起丢:来源主机与状态码必须还在,且带得出「体有多大」。
|
||||
host, _, ok := strings.Cut(strings.TrimPrefix(srv.URL, "http://"), ":")
|
||||
if !ok || !strings.Contains(err.Error(), host) {
|
||||
t.Fatalf("错误里没有来源主机 %q:%v", host, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "status=502") {
|
||||
t.Fatalf("错误里没有状态码:%v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "body_bytes=") {
|
||||
t.Fatalf("错误里没有响应体体积(相关性判断要用):%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoJSON_StatusErrorKeepsEnvelopeCode 非 2xx 的信封体仍要给出 code:这是本客户端
|
||||
// 唯一认识的诊断面,替换掉体原文之后不能连它一起丢。而 message 是上游自由文本面,
|
||||
// **默认不带**(M50 口径)—— 上游会用它在被拒时回显她的话。
|
||||
func TestDoJSON_StatusErrorKeepsEnvelopeCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
const sentinel = "invalid content: 她刚才说的那句话"
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"code":40101,"message":"` + sentinel + `","data":null}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()), WithLogger(discardLogger()))
|
||||
_, err := c.DoJSON(context.Background(), http.MethodGet, "/gateway/index", nil)
|
||||
if err == nil {
|
||||
t.Fatal("400 却报成功")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "code 40101") || !strings.Contains(err.Error(), "status=400") {
|
||||
t.Fatalf("信封的 code / 上游 status 丢了:%v", err)
|
||||
}
|
||||
// 哨兵出现在 message 里 ⇒ 错误与日志都不得带它(变异:把 envelopeDetail 改回
|
||||
// 无条件 upstreamText(message) ⇒ 本断言立刻红)。
|
||||
var logBuf bytes.Buffer
|
||||
slog.New(slog.NewJSONHandler(&logBuf, nil)).Error("send failed", "err", err)
|
||||
for _, surface := range []string{err.Error(), logBuf.String()} {
|
||||
if strings.Contains(surface, "她刚才说的那句话") {
|
||||
t.Fatalf("信封 message(上游回显的她的原话)进了错误或日志:%s", surface)
|
||||
}
|
||||
}
|
||||
// 哨兵链不变:分类靠 errors.Is,不靠文本。
|
||||
if !errors.Is(err, ErrAPI) || !errors.Is(err, trace.ErrUpstream) {
|
||||
t.Fatalf("err = %v, want both ErrAPI and trace.ErrUpstream", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoJSON_NonZeroCodeDropsEnvelopeMessage 是 2xx + `code != 0` 这**另一个分支**:
|
||||
// 它此前把 message 直接拼进错误(M49 探针复现的原文里 `code 1: invalid content: …`
|
||||
// 走的就是跨过 apiFailure 的这一支)。code 必须留下,message 必须不带。
|
||||
func TestDoJSON_NonZeroCodeDropsEnvelopeMessage(t *testing.T) {
|
||||
t.Parallel()
|
||||
const sentinel = "invalid content: 她刚才说的那句话"
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// 200 + 非零 code:KOOK 一类平台的「业务失败也 200」形态。
|
||||
_, _ = w.Write([]byte(`{"code":1,"message":"` + sentinel + `","data":null}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()), WithLogger(discardLogger()))
|
||||
_, err := c.DoJSON(context.Background(), http.MethodPost, "/message/create", map[string]any{"content": "她刚才说的那句话"})
|
||||
if err == nil {
|
||||
t.Fatal("非零 code 却报成功")
|
||||
}
|
||||
if strings.Contains(err.Error(), "她刚才说的那句话") {
|
||||
t.Fatalf("200 + code!=0 分支仍把 message 拼进错误:%v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "code 1") {
|
||||
t.Fatalf("2xx 分支的 code 丢了(那一条没别的分类信息):%v", err)
|
||||
}
|
||||
if !errors.Is(err, ErrAPI) {
|
||||
t.Fatalf("err = %v, want ErrAPI", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoJSON_EnvelopeMessageOptInRedactsMessageSecrets opt-in(WithEnvelopeMessage)是
|
||||
// 唯一能带 message 的通道:带上的那一刻仍必须清洗凭据形状。
|
||||
func TestDoJSON_EnvelopeMessageOptInRedactsMessageSecrets(t *testing.T) {
|
||||
t.Parallel()
|
||||
const secret = "big-secret-value-123456"
|
||||
body := `{"code":40101,"message":"access_token=` + secret + `","data":null}`
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()),
|
||||
WithLogger(discardLogger()), WithToken(secret), WithEnvelopeMessage())
|
||||
_, err := c.DoJSON(context.Background(), http.MethodGet, "/gateway/index", nil)
|
||||
if err == nil {
|
||||
t.Fatal("非零 code 却报成功")
|
||||
}
|
||||
if strings.Contains(err.Error(), secret) {
|
||||
t.Fatalf("opt-in 的信封 message 没有清洗凭据形状:%v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "[redacted]") {
|
||||
t.Fatalf("opt-in 生效后错误里没有 message(无法证明这条通道还活着):%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoJSON_EnvelopeMessageOptInIsBounded 钉住 opt-in 下 message 的长度口径:40 rune
|
||||
// (maxUpstreamTextRunes)+ 省略号。这条让「到底哪一层在截、截到多少」变成可断言的事实 ——
|
||||
// 上界刻意比 trace.RedactText 的 80 更短(错误里那句话是提示,不是摘要)。
|
||||
func TestDoJSON_EnvelopeMessageOptInIsBounded(t *testing.T) {
|
||||
t.Parallel()
|
||||
long := strings.Repeat("a", 300)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"code":40101,"message":"` + long + `","data":null}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()),
|
||||
WithLogger(discardLogger()), WithEnvelopeMessage())
|
||||
_, err := c.DoJSON(context.Background(), http.MethodGet, "/gateway/index", nil)
|
||||
if err == nil {
|
||||
t.Fatal("401 却报成功")
|
||||
}
|
||||
if !strings.Contains(err.Error(), strings.Repeat("a", maxUpstreamTextRunes)+"…") {
|
||||
t.Fatalf("信封 message 没有按 %d rune 截断:%v", maxUpstreamTextRunes, err)
|
||||
}
|
||||
if strings.Contains(err.Error(), strings.Repeat("a", maxUpstreamTextRunes+1)) {
|
||||
t.Fatalf("信封 message 超过了 %d rune 上限:%v", maxUpstreamTextRunes, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetJSON_TransportErrorOmitsTargetCredentials 传输层失败时,错误里不得出现目标
|
||||
// 地址(含 query 里的凭据)—— net/http 会把整条 URL 放在 *url.Error 里。
|
||||
func TestGetJSON_TransportErrorOmitsTargetCredentials(t *testing.T) {
|
||||
t.Parallel()
|
||||
const secret = "QUERY-SECRET-VALUE"
|
||||
c := New(
|
||||
WithBaseURL("http://api.example.com"),
|
||||
WithHTTPClient(&http.Client{Transport: failingTransport{}}),
|
||||
WithLogger(discardLogger()),
|
||||
)
|
||||
_, err := c.GetJSON(context.Background(), "/gateway/index", url.Values{"access_token": {secret}})
|
||||
if err == nil {
|
||||
t.Fatal("传输层失败却报成功")
|
||||
}
|
||||
if strings.Contains(err.Error(), secret) {
|
||||
t.Fatalf("目标地址的凭据进了错误:%v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "api.example.com") || !strings.Contains(err.Error(), "connection refused") {
|
||||
t.Fatalf("剥掉地址时把来源或原因一起丢了:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetJSON_UserinfoNeverEntersError 绝对地址带 user:pass@ 时同样不得出镜。
|
||||
//
|
||||
// 断言用户名而不只是密码:net/http 自己在 *url.Error 里就把密码打成 xxxxx,因此
|
||||
// 「密码不出现」在剥壳与不剥壳两种实现下都成立(这样的用例是空跑);用户名才是
|
||||
// 剥壳与不剥壳的分水岭。
|
||||
func TestGetJSON_UserinfoNeverEntersError(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := New(
|
||||
WithHTTPClient(&http.Client{Transport: failingTransport{}}),
|
||||
WithLogger(discardLogger()),
|
||||
)
|
||||
_, err := c.GetJSON(context.Background(), "https://leakuser:leakpass@api.example.com/x", nil)
|
||||
if err == nil {
|
||||
t.Fatal("传输层失败却报成功")
|
||||
}
|
||||
msg := err.Error()
|
||||
if strings.Contains(msg, "leakuser") || strings.Contains(msg, "leakpass") {
|
||||
t.Fatalf("user:pass@ 进了错误:%v", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "api.example.com") {
|
||||
t.Fatalf("剥掉地址时把来源一起丢了:%v", msg)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package httpclient
|
||||
|
||||
// 本文件是**渠道侧上游自由文本的唯一清洗入口**(M50)。它管的是同一件事在四个出口上
|
||||
// 的形态:KOOK 一类 `{code,message}` 信封的 `message`、Telegram Bot API 的
|
||||
// `description`、OneBot v11 信封的 `msg` / `wording`、iLink CDN 的 `x-error-message`
|
||||
// 头。四者都是上游给的**人话**,都可能把被拒的请求内容回显出来 —— 而请求内容里就有
|
||||
// 她的原话。
|
||||
//
|
||||
// 🔴 契约(改本文件前先读):
|
||||
// - 上层口径是 **默认不带**:这些字段默认不进 error、不进日志(见 client.go 的
|
||||
// WithEnvelopeMessage 与各渠道调用点的注释)。本文件的函数**只在调用方已经显式
|
||||
// 选择保留**时才被调用 —— 它是「保留时的上限」,不是「可以带」的许可。
|
||||
// - 清洗只有一份:`trace.RedactTextLimited`(凭据形状打码 + rune 截断)。本包**不**
|
||||
// 另写一套正则或截断,另写一套必然漂移,而漂移的方向总是「其中一条出口开始原样
|
||||
// 吐凭据」。
|
||||
// - 压平空白也是必需的一步:一条诊断只许占一行,否则上游可以用换行伪造日志行
|
||||
// (log injection)。
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// maxUpstreamTextRunes 是上游自由文本允许保留的 rune 上限:**40**。
|
||||
//
|
||||
// 为什么比 `trace.RedactText` 的 80 更短:那一档服务的是**摘要素材**(出图、工具结果
|
||||
// 摘要),读者只看这一条;本文件的产物进的是**错误链与日志**,同一条信息还会被调用方
|
||||
// 再落一次,长度预算没有理由按摘要给。而 40 rune 已足够装下渠道侧真正有用的那类说明
|
||||
// (`invalid token`、`参数错误`、`缺少 code`、`FLOOD_WAIT_30`),又短到装不下一句她
|
||||
// 说的话 —— 上游回显 `invalid content: <正文>` 时,前半句就吃掉大半预算。
|
||||
const maxUpstreamTextRunes = 40
|
||||
|
||||
// upstreamText 把一段上游自由文本收敛成可以出镜的短诊断:压平空白,再交给
|
||||
// `trace.RedactTextLimited` 打码凭据形状并截到 maxUpstreamTextRunes(超出的带省略号)。
|
||||
//
|
||||
// 空串进 ⇒ 空串出;调用方据此判断「这一段有没有东西可显示」。
|
||||
//
|
||||
// 🔴 它**不**决定该不该带(默认不带)。把它的结果无条件拼进 error 仍然是把她的原话
|
||||
// 送上日志 —— 调用点必须自己持有那个显式的开关(本包只有 WithEnvelopeMessage 一处)。
|
||||
// 渠道插件当前**不带**任何自由文本,因此没有第二个调用点;将来若真要开,也要回到
|
||||
// 这一条(`trace` 的唯一清洗 + 40 rune),不要在插件里另写一套截断。
|
||||
func upstreamText(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
return trace.RedactTextLimited(strings.Join(strings.Fields(s), " "), maxUpstreamTextRunes)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package httpclient
|
||||
|
||||
// 本文件是 M49 的**出口回归探针**:令牌守卫上移进 trace.UpstreamError 之后,
|
||||
// 本客户端一行没改也应当自动安全 —— 上游把散文塞进 error.code / error.type 时,
|
||||
// 错误文本与日志里都搜不到它,而 status / body_bytes 这些诊断面照旧。
|
||||
//
|
||||
// 断言形状是「摘掉 trace.UpstreamError 里的守卫就变红」:这里**没有任何本地守卫**,
|
||||
// 也就没有任何本地补丁能掩盖缺口。M46 的教训正是「守卫加在调用方 ⇒ 新调用点默认
|
||||
// 不安全」,所以这一条故意不去调用点补。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestDoJSON_StatusErrorDropsFreeTextUpstreamCode 4xx 报文的 error.code / error.type
|
||||
// 里带散文(带空格与标点,不像协议令牌)⇒ 两个字段整体消失,而状态码与体积摘要仍在。
|
||||
func TestDoJSON_StatusErrorDropsFreeTextUpstreamCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
const prose = "Invalid value: '她刚才说的那句话'"
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
_, _ = w.Write([]byte(`{"error":{"code":"` + prose + `","type":"` + prose + `"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()), WithLogger(discardLogger()))
|
||||
_, err := c.DoJSON(context.Background(), http.MethodPost, "/message/create", map[string]any{"a": 1})
|
||||
if err == nil {
|
||||
t.Fatal("502 却报成功")
|
||||
}
|
||||
if !errors.Is(err, ErrAPI) {
|
||||
t.Fatalf("err = %v, want ErrAPI", err)
|
||||
}
|
||||
|
||||
var logBuf bytes.Buffer
|
||||
slog.New(slog.NewJSONHandler(&logBuf, nil)).Error("upstream call failed", "err", err)
|
||||
for _, surface := range []string{err.Error(), logBuf.String()} {
|
||||
if strings.Contains(surface, "她刚才说的那句话") {
|
||||
t.Fatalf("上游 error.code/type 里的散文进了错误或日志:%s", surface)
|
||||
}
|
||||
}
|
||||
// 可诊断性不能一起丢:状态码与体积摘要仍是分类与相关性比对的依据。
|
||||
for _, want := range []string{"status=502", "body_bytes="} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("错误里缺少 %q:%v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -116,17 +116,30 @@ type UnifiedMessage struct {
|
||||
SenderID string
|
||||
SenderName string
|
||||
Sender Actor
|
||||
// SelfID 是**她自己在该平台的账号 id**(OneBot 的 self_id、KOOK 的 bot_id…),
|
||||
// 只从平台元数据取,绝不从正文推断。空串 = 平台没给 / 适配器尚未接线:此时
|
||||
// 「是否指向她」的判定必须 fail-closed(见 AddressedToMe),判据缺失不等于命中。
|
||||
SelfID string
|
||||
Content string
|
||||
ContentType ContentType
|
||||
Media []Media
|
||||
Mentions []Mention
|
||||
ReplyToID string
|
||||
ReplyToText string
|
||||
GroupID string
|
||||
GroupName string
|
||||
ThreadID string
|
||||
MessageID string
|
||||
Timestamp time.Time
|
||||
// MentionAll 报告本条是否 **@全体**(OneBot 的 at 段 qq="all";design/26 §2.4 要求
|
||||
// 以结构化标志留痕的 mention_all)。它是 AddressedToMe 的子集信号:true ⇒ 该字段也 true。
|
||||
// 单独留一列是为了让「@全体」与「直接点名她」在数据上可分辨(审计 / 降噪各取所需)。
|
||||
MentionAll bool
|
||||
// AddressedToMe 报告本条消息在结构上**是否指向她本人**(design/18 §2 触发① 的判据,
|
||||
// 主人 2026-10-13 裁决「甲 = 要」)。语义与推导见 AddressedToSelf:
|
||||
// 群聊 = 点名她或 @全体;私聊 = 恒 true;平台未给 SelfID = false(fail-closed)。
|
||||
// 判据**全部来自平台元数据**,绝不匹配正文里的 "@<数字>"(项目红线)。
|
||||
AddressedToMe bool
|
||||
ReplyToID string
|
||||
ReplyToText string
|
||||
GroupID string
|
||||
GroupName string
|
||||
ThreadID string
|
||||
MessageID string
|
||||
Timestamp time.Time
|
||||
// RawData is the opaque native payload the message was converted from.
|
||||
RawData []byte
|
||||
}
|
||||
@@ -159,3 +172,36 @@ func (m UnifiedMessage) Validate() error {
|
||||
|
||||
// IsGroup reports whether the message belongs to a group conversation.
|
||||
func (m UnifiedMessage) IsGroup() bool { return m.ChannelType == ChannelGroup }
|
||||
|
||||
// MentionedSelf 报告 mentions 里是否**点名**了 selfID:结构化 id 的精确比对,
|
||||
// 绝不做正文匹配。selfID 为空(平台没给她的账号 id,或适配器未接线)或 mentions
|
||||
// 为空时一律 false —— 判据缺失就 fail-closed,绝不猜。它是各适配器接线的唯一出处:
|
||||
// 适配器只需把平台给出的 SelfID 与 Mentions 交给它。
|
||||
func MentionedSelf(selfID string, mentions []Mention) bool {
|
||||
if selfID == "" {
|
||||
return false
|
||||
}
|
||||
for _, m := range mentions {
|
||||
if m.UserID == selfID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// AddressedToSelf 依据本条消息的**结构化平台元数据**推导 AddressedToMe
|
||||
// (design/18 §2 触发①;主人 2026-10-13 裁决「甲 = 要」):
|
||||
//
|
||||
// - 私聊:恒 true —— 对端就是发送者,本条天然是对她说的;
|
||||
// - 群聊:点名了她(Mentions 含 SelfID)**或** @全体(MentionAll);
|
||||
// - SelfID 为空:一律 false —— 判据缺失 ⇒ fail-closed,绝不退回正文文本匹配。
|
||||
//
|
||||
// 适配器在填好 SelfID / ChannelType / Mentions / MentionAll 之后调用它回写
|
||||
// AddressedToMe;推导的**唯一出处**在这里,避免每个适配器各写一份判据而互相漂移。
|
||||
// 它只用于**真消息**(走 toUnified 的那条路径),系统事件包装不适用。
|
||||
func (m UnifiedMessage) AddressedToSelf() bool {
|
||||
if m.ChannelType == ChannelDirect {
|
||||
return true
|
||||
}
|
||||
return m.MentionAll || MentionedSelf(m.SelfID, m.Mentions)
|
||||
}
|
||||
@@ -149,3 +149,105 @@ func TestContentTypeValid(t *testing.T) {
|
||||
t.Error("bogus ContentType.Valid() = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMentionedSelf(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mentions := []Mention{{UserID: "all"}, {UserID: "u-2"}}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
selfID string
|
||||
mentions []Mention
|
||||
want bool
|
||||
}{
|
||||
{name: "platform has not provided self id", selfID: "", mentions: mentions, want: false},
|
||||
{name: "no mentions", selfID: "u-2", mentions: nil, want: false},
|
||||
{name: "points at someone else", selfID: "u-9", mentions: mentions, want: false},
|
||||
{name: "points at her", selfID: "u-2", mentions: mentions, want: true},
|
||||
{name: "at-all marker is not a user id", selfID: "all", mentions: nil, want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := MentionedSelf(tt.selfID, tt.mentions); got != tt.want {
|
||||
t.Errorf("MentionedSelf(%q, %+v) = %v, want %v", tt.selfID, tt.mentions, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddressedToSelf(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
msg UnifiedMessage
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "private chat is always addressed to her",
|
||||
msg: UnifiedMessage{ChannelType: ChannelDirect, SelfID: "u-1"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "private chat without platform self id stays addressed",
|
||||
msg: UnifiedMessage{ChannelType: ChannelDirect},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "group mentions her",
|
||||
msg: UnifiedMessage{
|
||||
ChannelType: ChannelGroup, GroupID: "g-1", SelfID: "u-1",
|
||||
Mentions: []Mention{{UserID: "u-1"}},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "group mentions someone else",
|
||||
msg: UnifiedMessage{
|
||||
ChannelType: ChannelGroup, GroupID: "g-1", SelfID: "u-1",
|
||||
Mentions: []Mention{{UserID: "u-2"}},
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "group mentions all",
|
||||
msg: UnifiedMessage{
|
||||
ChannelType: ChannelGroup, GroupID: "g-1", SelfID: "u-1", MentionAll: true,
|
||||
Mentions: []Mention{{UserID: "all"}},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "group without mention",
|
||||
msg: UnifiedMessage{ChannelType: ChannelGroup, GroupID: "g-1", SelfID: "u-1"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "group without platform self id is fail-closed",
|
||||
msg: UnifiedMessage{
|
||||
ChannelType: ChannelGroup, GroupID: "g-1",
|
||||
Mentions: []Mention{{UserID: "u-1"}},
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "zero value stays fail-closed for adapters that do not wire it",
|
||||
msg: UnifiedMessage{},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := tt.msg.AddressedToSelf(); got != tt.want {
|
||||
t.Errorf("AddressedToSelf() = %v, want %v (msg=%+v)", got, tt.want, tt.msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package adapter
|
||||
|
||||
// 本文件承载**出站消息 id 的出口**(切片 OUTBOUNDID / M12 的 A 层)。
|
||||
//
|
||||
// 背景(M11 查明的缺口):`PlatformAdapter.SendMessage(ctx, resp) error` 只回成败,
|
||||
// **没有任何位置**放下「平台给这条消息分配的 id」。于是她自己刚发出的那条消息在系统里
|
||||
// 没有身份 —— 平台侧明明把 id 回给了我们,却在解析的那一刻就被丢掉。任何「接她的话」
|
||||
// (对方回复的是**她自己**那条消息 ⇒ `UnifiedMessage.ReplyToID` 落在她自己的出站 id 上)
|
||||
// 的判定都无从谈起。
|
||||
//
|
||||
// ## 形态选择:**可选接口 + 调用方探测**(不采用返回 id 的直接改签名,也不用回调)
|
||||
//
|
||||
// 范式与 `SystemNoticeNormalizer` / `DeliveryFailure` 逐字同族 —— 都在本包,都是
|
||||
// 「适配器按需实现、调用方探测」,理由也一样:契约层是唯一不必让任何一方反向依赖
|
||||
// 具体插件的落点。
|
||||
//
|
||||
// - **不把它加进 `PlatformAdapter`**:加方法等于契约破坏 —— 四个未接线适配器
|
||||
// (kook / telegram / weixin-ilink / webhook)与全部桩实现会**立刻编译失败**,
|
||||
// 而它们拿不到 id 的原因是**平台不给**,不是「没接线」。向后兼容是硬指标。
|
||||
// - **也不采用回调 / sink 注册**:那会把 id 变成异步、无关联的推送 —— 调用方拿到一条
|
||||
// id 时已经无法确定它属于哪一次发送、哪一个会话;重连与重试下还会把上一轮的旧 id
|
||||
// 混进环形表。出站 id 是**某一次投递的属性**,就该随那次投递一起返回。
|
||||
//
|
||||
// ## 三条铁律(任何实现都必须遵守)
|
||||
//
|
||||
// 1. **取不到就空**:`SendReceipt.MessageID` 为空一律表示「不知道」,绝不用占位值 /
|
||||
// 序号 / 时间戳兜底。一个编出来的 id 会让「接她的话」凭空命中(把别人的话认成
|
||||
// 回给她的)。宁可判不出来,不许判错。
|
||||
// 2. **三态可分辨**:`SendIDStatus` 把「回执带回了 id」「回执到了但没带 id」
|
||||
// 「压根没等到回执」分开 —— 排障时才分得清「平台没上报」与「我们没等到」。
|
||||
// 3. **不改变既有投递语义**:这是**增量** seam。`SendMessage` 的成败判据、限流、
|
||||
// 目标解析一律照旧;没实现本接口的适配器行为一个字节都不变。
|
||||
|
||||
import "context"
|
||||
|
||||
// SendOutcome 是一次投递的完整结果:**逐条**的投递凭据,顺序与
|
||||
// `UnifiedResponse.Messages` 一一对应。
|
||||
//
|
||||
// 用结构体而不是裸切片:日后要给投递结果补别的属性(逐条耗时可观测、失败分类)时,
|
||||
// 签名不必再动一次。
|
||||
type SendOutcome struct {
|
||||
// Receipts 是每条已投递消息的回执。调用方必须按**下标**与 Messages 对齐 ——
|
||||
// 长度不一致说明适配器没遵守契约(实现应保证等长)。
|
||||
Receipts []SendReceipt
|
||||
}
|
||||
|
||||
// SendReceipt 是一条出站消息的投递凭据。`MessageID` 是出站 id 的**唯一**出口。
|
||||
type SendReceipt struct {
|
||||
// MessageID 是平台回执给出的消息 id(OneBot 的 `data.message_id`)。
|
||||
//
|
||||
// **空串一律表示「不知道」**:没等到回执、回执里没有这个字段、或平台根本不给。
|
||||
// 绝不编造 —— 下游判「对方是不是在接她的话」时,空 id 必须落成「判不出来」,
|
||||
// 而不是拿一个假值去撞环形表。
|
||||
MessageID string
|
||||
// IDStatus 说明上面那个空 / 非空是怎么来的(见 SendIDStatus 的三态)。
|
||||
IDStatus SendIDStatus
|
||||
}
|
||||
|
||||
// SendIDStatus 是出站 id 的**到达状态**(三态,绝不合并)。
|
||||
//
|
||||
// 零值即 `SendIDUnreported`(不知道):把「不知道」设为零值是刻意的 fail-closed
|
||||
// 设计 —— 一个只写了 `SendReceipt{}` 的实现天然表示「我取不到 id」,绝不会因为
|
||||
// 忘了填某个字段而被读成「取到了」。
|
||||
type SendIDStatus string
|
||||
|
||||
const (
|
||||
// SendIDUnreported 表示**没等到回执**:等待超时 / 连接在投递中途断了 / 适配器
|
||||
// 压根没有取 id 的能力(此时它不该实现 OutboundReceiptAdapter,但调用方探测失败
|
||||
// 时也走这一态)。帧可能已经写出去了 ——「发没发出去」由 error 回答,这一态只回
|
||||
// 答「id 拿没拿到」。
|
||||
SendIDUnreported SendIDStatus = ""
|
||||
// SendIDReported 表示**回执把 id 带回来了**(此时 MessageID 必非空)。
|
||||
SendIDReported SendIDStatus = "reported"
|
||||
// SendIDAbsent 表示**回执到了,但它没带 id**(字段缺失 / 为 null / 形态不可用)。
|
||||
// 与 SendIDUnreported 分开:前者是「平台明确给了个空」,后者是「我们没等到」。
|
||||
SendIDAbsent SendIDStatus = "absent"
|
||||
)
|
||||
|
||||
// NewSendReceipt 由平台回执里的 id 构造凭据:非空 ⇒ Reported,空 ⇒ Absent。
|
||||
//
|
||||
// 它是**唯一的构造出处**,因此 `MessageID` 与 `IDStatus` 不可能自相矛盾
|
||||
// (不会出现「状态说取到了、id 却是空的」)。只用于「回执已经到了」的场景 ——
|
||||
// 没等到回执时用零值 `SendReceipt{}`(SendIDUnreported)。
|
||||
func NewSendReceipt(platformID string) SendReceipt {
|
||||
if platformID == "" {
|
||||
return SendReceipt{IDStatus: SendIDAbsent}
|
||||
}
|
||||
return SendReceipt{MessageID: platformID, IDStatus: SendIDReported}
|
||||
}
|
||||
|
||||
// Reported 报告这条凭据里是否真的有一个可用的平台 id。
|
||||
func (r SendReceipt) Reported() bool {
|
||||
return r.IDStatus == SendIDReported && r.MessageID != ""
|
||||
}
|
||||
|
||||
// OutboundReceiptAdapter 是**可选**的出站凭据 seam:能在投递时给出平台消息 id 的适配器
|
||||
// 实现它。调用方用 `SendWithReceipt` 探测,绝不直接类型断言。
|
||||
type OutboundReceiptAdapter interface {
|
||||
// SendMessageWithReceipt 的投递语义与 `SendMessage` **完全一致**(同一套目标解析、
|
||||
// 限流、错误与重试口径),额外逐条回一条投递凭据。
|
||||
//
|
||||
// 返回的 error 只描述**投递**:拿到了回执但回执报错、或回执里没有 id,都**不是**
|
||||
// 这里的 error(那是 `SendIDStatus` 的答案)。error 非 nil 时 `SendOutcome` 可能
|
||||
// 只带了前几条的凭据 —— 调用方按 `len(Receipts)` 取**已投递**的那几条。
|
||||
SendMessageWithReceipt(ctx context.Context, resp UnifiedResponse) (SendOutcome, error)
|
||||
}
|
||||
|
||||
// SendWithReceipt 投递一次回复并**尽力**取回平台消息 id —— 调用方(B 层的环形表)
|
||||
// 的唯一入口。
|
||||
//
|
||||
// - 适配器实现了 `OutboundReceiptAdapter`:走它的凭据路径(HTTP / WS 各按自己的
|
||||
// 平台协议取)。
|
||||
// - 没实现(kook / telegram / weixin-ilink / webhook 等):退回既有的 `SendMessage`,
|
||||
// 并**如实**为每条消息报一条 `SendIDUnreported` 的空凭据 —— 不是失败,是「这个
|
||||
// 渠道给不出 id」。调用方据此就知道该渠道的环形表永远是空的,而不是以为取到了。
|
||||
//
|
||||
// 两种情况的 Receipts 长度都等于 len(resp.Messages),调用方不必分叉处理。
|
||||
func SendWithReceipt(ctx context.Context, a PlatformAdapter, resp UnifiedResponse) (SendOutcome, error) {
|
||||
if a == nil {
|
||||
return SendOutcome{}, ErrNotConnected
|
||||
}
|
||||
if ra, ok := a.(OutboundReceiptAdapter); ok {
|
||||
return ra.SendMessageWithReceipt(ctx, resp)
|
||||
}
|
||||
if err := a.SendMessage(ctx, resp); err != nil {
|
||||
return SendOutcome{}, err
|
||||
}
|
||||
return SendOutcome{Receipts: makeUnknownReceipts(len(resp.Messages))}, nil
|
||||
}
|
||||
|
||||
// makeUnknownReceipts 造 n 条「不知道」的空凭据(零值即不知道,见 SendIDStatus)。
|
||||
func makeUnknownReceipts(n int) []SendReceipt {
|
||||
if n <= 0 {
|
||||
return nil
|
||||
}
|
||||
return make([]SendReceipt, n)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package adapter
|
||||
|
||||
// 本文件证明出站 id 出口(outbound.go)的三条硬性质:**向后兼容**(老适配器一行不改、
|
||||
// 探测失败即如实报「不知道」)、**三态可分辨**、**取不到就是空 / 绝不编造**。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// receiptAdapter 是同时实现了 PlatformAdapter 与 OutboundReceiptAdapter 的桩:
|
||||
// 它记录两条路径各被调用了几次,用来证明「实现了 seam 的适配器**只**走凭据路径」
|
||||
// (绝不出现一条消息发两遍)。
|
||||
type receiptAdapter struct {
|
||||
mockAdapter
|
||||
receiptCalls int
|
||||
receipts []SendReceipt
|
||||
}
|
||||
|
||||
func (m *receiptAdapter) SendMessageWithReceipt(_ context.Context, resp UnifiedResponse) (SendOutcome, error) {
|
||||
m.receiptCalls++
|
||||
m.sent = resp.Messages
|
||||
return SendOutcome{Receipts: m.receipts}, nil
|
||||
}
|
||||
|
||||
var (
|
||||
_ PlatformAdapter = (*receiptAdapter)(nil)
|
||||
_ OutboundReceiptAdapter = (*receiptAdapter)(nil)
|
||||
)
|
||||
|
||||
// failAdapter 的投递恒失败,用来验证错误原样穿过 SendWithReceipt。
|
||||
type failAdapter struct {
|
||||
mockAdapter
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *failAdapter) SendMessage(context.Context, UnifiedResponse) error { return f.err }
|
||||
|
||||
func TestNewSendReceiptNeverReportsEmptyID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
absent := NewSendReceipt("")
|
||||
if absent.MessageID != "" || absent.IDStatus != SendIDAbsent || absent.Reported() {
|
||||
t.Errorf("NewSendReceipt(\"\") = %+v, want absent with empty id", absent)
|
||||
}
|
||||
|
||||
reported := NewSendReceipt("1700000012345")
|
||||
if reported.MessageID != "1700000012345" || !reported.Reported() {
|
||||
t.Errorf("NewSendReceipt(id) = %+v, want reported id", reported)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendReceiptZeroValueIsUnknown 钉住 fail-closed 的零值口径:一个只写了
|
||||
// `SendReceipt{}` 的实现表示「不知道」,绝不能被读成「平台给了 id」。
|
||||
func TestSendReceiptZeroValueIsUnknown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var zero SendReceipt
|
||||
if zero.IDStatus != SendIDUnreported {
|
||||
t.Errorf("zero IDStatus = %q, want %q", zero.IDStatus, SendIDUnreported)
|
||||
}
|
||||
if zero.MessageID != "" || zero.Reported() {
|
||||
t.Errorf("zero receipt = %+v, want empty and not reported", zero)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlainAdapterIsNotAReceiptAdapter 是**向后兼容**的运行时证据:一个只实现
|
||||
// PlatformAdapter 的适配器(等价于 kook / telegram / weixin-ilink / webhook)**不是**
|
||||
// OutboundReceiptAdapter —— 契约没被破坏,它们一行都不用改。
|
||||
func TestPlainAdapterIsNotAReceiptAdapter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var a PlatformAdapter = &mockAdapter{name: "telegram"}
|
||||
if _, ok := a.(OutboundReceiptAdapter); ok {
|
||||
t.Fatal("plain adapter must not satisfy OutboundReceiptAdapter")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendWithReceiptFallsBackToSendMessage 覆盖未接线渠道:投递照旧走 SendMessage,
|
||||
// 凭据如实报「未取到」——**逐条**与 Messages 等长,且 id 全空(绝不编造)。
|
||||
func TestSendWithReceiptFallsBackToSendMessage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ad := &mockAdapter{name: "telegram"}
|
||||
resp := UnifiedResponse{Messages: []UnifiedMessage{
|
||||
{ChannelType: ChannelDirect, ContentType: ContentText, Content: "一"},
|
||||
{ChannelType: ChannelDirect, ContentType: ContentText, Content: "二"},
|
||||
}}
|
||||
|
||||
out, err := SendWithReceipt(context.Background(), ad, resp)
|
||||
if err != nil {
|
||||
t.Fatalf("SendWithReceipt() error = %v", err)
|
||||
}
|
||||
if len(ad.sent) != 2 {
|
||||
t.Fatalf("fallback did not deliver through SendMessage: sent = %+v", ad.sent)
|
||||
}
|
||||
if len(out.Receipts) != 2 {
|
||||
t.Fatalf("Receipts len = %d, want 2 (one per message)", len(out.Receipts))
|
||||
}
|
||||
for i, r := range out.Receipts {
|
||||
if r.MessageID != "" || r.IDStatus != SendIDUnreported || r.Reported() {
|
||||
t.Errorf("receipt[%d] = %+v, want empty id with unreported status", i, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendWithReceiptUsesSeamWhenImplemented 证明实现了 seam 的适配器只走凭据路径
|
||||
// (不会两条路径各发一遍)。
|
||||
func TestSendWithReceiptUsesSeamWhenImplemented(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ad := &receiptAdapter{receipts: []SendReceipt{NewSendReceipt("9001")}}
|
||||
ad.name = "qq-obv11"
|
||||
|
||||
out, err := SendWithReceipt(context.Background(), ad, UnifiedResponse{Messages: []UnifiedMessage{
|
||||
{ChannelType: ChannelGroup, GroupID: "123", ContentType: ContentText, Content: "pong"},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("SendWithReceipt() error = %v", err)
|
||||
}
|
||||
if ad.receiptCalls != 1 {
|
||||
t.Errorf("receipt path calls = %d, want 1", ad.receiptCalls)
|
||||
}
|
||||
if len(out.Receipts) != 1 || out.Receipts[0].MessageID != "9001" {
|
||||
t.Errorf("Receipts = %+v, want one reported 9001", out.Receipts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendWithReceiptPropagatesError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
want := errors.New("boom")
|
||||
ad := &failAdapter{err: want}
|
||||
|
||||
_, err := SendWithReceipt(context.Background(), ad, UnifiedResponse{Messages: []UnifiedMessage{
|
||||
{ChannelType: ChannelDirect, ContentType: ContentText, Content: "x"},
|
||||
}})
|
||||
if !errors.Is(err, want) {
|
||||
t.Fatalf("SendWithReceipt() error = %v, want %v", err, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendWithReceiptNilAdapter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if _, err := SendWithReceipt(context.Background(), nil, UnifiedResponse{}); !errors.Is(err, ErrNotConnected) {
|
||||
t.Fatalf("SendWithReceipt(nil) error = %v, want ErrNotConnected", err)
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -179,7 +180,7 @@ func Fetch(ctx context.Context, req Request) ([]byte, error) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mediafetch: 取回 %s 失败: %w", origin, sanitizeErr(err))
|
||||
return nil, fmt.Errorf("mediafetch: 取回 %s 失败: %w", origin, trace.URLCause(err))
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
@@ -189,7 +190,7 @@ func Fetch(ctx context.Context, req Request) ([]byte, error) {
|
||||
// 边读边限:多读一个字节用来判定「超限」,因此绝不会把一份超大文件读满内存。
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mediafetch: 读取 %s 失败: %w", origin, sanitizeErr(err))
|
||||
return nil, fmt.Errorf("mediafetch: 读取 %s 失败: %w", origin, trace.URLCause(err))
|
||||
}
|
||||
if int64(len(data)) > maxBytes {
|
||||
return nil, fmt.Errorf("%w(上限 %d 字节,来源 %s)", ErrTooLarge, maxBytes, origin)
|
||||
@@ -239,18 +240,6 @@ func applyHeader(dst, src http.Header) {
|
||||
}
|
||||
}
|
||||
|
||||
// sanitizeErr 剥掉 *url.Error 的外壳:它会把**完整 URL**(含查询串里的签名凭据)
|
||||
// 拼进错误串。剥壳同时保留内层哨兵链(`context.DeadlineExceeded` 等仍可用
|
||||
// errors.Is 判定)。内层已是最终错误时**原样透传**(这里只做剥壳,不新增包装 ——
|
||||
// 再包一层只会把同一个原因说两遍)。
|
||||
func sanitizeErr(err error) error {
|
||||
var uerr *url.Error
|
||||
if errors.As(err, &uerr) && uerr.Err != nil {
|
||||
return uerr.Err
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// originOfURL 把一个已解析的 URL 归一成来源字符串。
|
||||
func originOfURL(u *url.URL) (string, error) {
|
||||
if u == nil {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package session
|
||||
|
||||
// 本文件把「消息 → 会话」的键派生**导出**给别的层复用(M22 复审 P1-1)。
|
||||
//
|
||||
// 为什么必须导出:会话的键一直是会话层自己算的(`channelID` + `platform \x00 channelID`),
|
||||
// 而渠道桥接器的**每会话回合车道**需要「什么算同一个会话」这个判据与它**同源**。
|
||||
// 各算各的会漂移,而且漂移的后果不是排序不好看,是**同一个 session 两条车道、两个回合
|
||||
// 并发**——两条 run 共享 sessionID,checkpoint 行 id `{sessionID}:{step}` 互相覆盖、
|
||||
// `loadCheckpoint` 可能续到另一条在途 run 的快照而重放工具调用,flow 桶
|
||||
// `p.graphs[sessionID]` 被先收尾者删掉(loop 没有会话级互斥,IM 侧也没有网关那层
|
||||
// `session_turn_fence` 兜底)。
|
||||
//
|
||||
// 两处真实反例(都来自复审实测):
|
||||
// - kook:同一 guild 的两个 text channel —— `ChannelID` 不同(c1/c2)但 `GroupID` 相同,
|
||||
// 会话层按 `group:<GroupID>` 判成**同一个** session;任何把 `ChannelID` 当会话判据的
|
||||
// 派生都会把它们拆成两条车道。
|
||||
// - 多账号:同一平台两个账号对同一对端 —— 账号名不同(`telegram:conf0`/`:conf1`)、
|
||||
// `platform` 常量相同、`ChannelID` 相同 ⇒ 也是**同一个** session;任何把「账号」当
|
||||
// 会话判据的派生都会把它们拆开。
|
||||
//
|
||||
// 因此本文件只做一件事:把既有的派生**原样**导出,语义一个字不改(`channelID` 仍是唯一
|
||||
// 的实现,见 manager.go)。
|
||||
|
||||
import "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
|
||||
// sessionKeySep 分隔 (platform, channel_id) 两个维度。用 NUL 是因为平台名与渠道 id 都
|
||||
// 不可能包含它,拼接因此不会产生歧义(`a` + "b" 与 `ab` + "" 不会撞键)。
|
||||
const sessionKeySep = "\x00"
|
||||
|
||||
// ChannelKey 返回一条入站消息所代表的**渠道**键(会话派生的第二个维度):
|
||||
// 群消息按 `group:<GroupID>`(加前缀使群不会与同 id 的私聊渠道撞键),私聊按
|
||||
// `ChannelID`,没有显式渠道 id 时退回 `SenderID`。
|
||||
//
|
||||
// 它就是 `SessionManager.GetOrCreate` 内部用的那一个函数(manager.go 的 `channelID`),
|
||||
// 导出只为让别的层(渠道桥接器的回合车道)能与会话键同源,**不得**在别处另写一份。
|
||||
// 返回空串表示这条消息没有可用的渠道(`GetOrCreate` 会以 `ErrInvalidChannel` 拒掉它)。
|
||||
func ChannelKey(msg adapter.UnifiedMessage) string { return channelID(msg) }
|
||||
|
||||
// Key 返回一条入站消息的**会话键** —— 与 `SessionManager` 的缓存键和
|
||||
// `channel_sessions` 表的唯一键**逐字相同**(`platform \x00 channelID`)。
|
||||
//
|
||||
// 它是本包对外唯一的「什么算同一个会话」判据:任何需要按会话串行/分桶的调用方都用它,
|
||||
// 不要自己拼 `platform|channel` 之类的近似键(那正是 M22 复审里「车道键与会话键不同源」
|
||||
// 的成因)。返回的键在渠道不可用时仍然非空(`platform\x00`),调用方若需要区分,
|
||||
// 用 ChannelKey 判空。
|
||||
func Key(msg adapter.UnifiedMessage) string {
|
||||
return sessionKey(msg.Platform, channelID(msg))
|
||||
}
|
||||
|
||||
// sessionKey 拼接 (platform, channel_id) 两维度。本包内所有「会话键」的**唯一**出处
|
||||
// (GetOrCreate / List / 导出的 Key 都经这里),因此不可能各自漂移。
|
||||
func sessionKey(platform, cid string) string {
|
||||
return platform + sessionKeySep + cid
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package session
|
||||
|
||||
// 本文件钉住 keys.go 导出的两个派生:`ChannelKey` 与会话层内部 `channelID` 是**同一个**
|
||||
// 实现,`Key` 与会话缓存键**逐字相同**。它们是「什么算同一个会话」的唯一定义 —— 车道
|
||||
// (internal/services/channelbridge/lane.go)按会话串行就靠它,两处一旦分叉,同一个
|
||||
// session 就会出现两条车道、两个回合并发(M22 复审 P1-1)。
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
)
|
||||
|
||||
func TestChannelKeyDerivations(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
msg adapter.UnifiedMessage
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "群:按 group:<GroupID>,与频道无关",
|
||||
msg: adapter.UnifiedMessage{Platform: "kook", ChannelType: adapter.ChannelGroup, ChannelID: "c1", GroupID: "g1"},
|
||||
want: "group:g1",
|
||||
},
|
||||
{
|
||||
name: "群:缺 GroupID 时退回 ChannelID",
|
||||
msg: adapter.UnifiedMessage{Platform: "kook", ChannelType: adapter.ChannelGroup, ChannelID: "c1"},
|
||||
want: "c1",
|
||||
},
|
||||
{
|
||||
name: "私聊:按 ChannelID",
|
||||
msg: adapter.UnifiedMessage{Platform: "telegram", ChannelType: adapter.ChannelDirect, ChannelID: "200", SenderID: "200"},
|
||||
want: "200",
|
||||
},
|
||||
{
|
||||
name: "私聊:缺 ChannelID 时退回 SenderID",
|
||||
msg: adapter.UnifiedMessage{Platform: "telegram", ChannelType: adapter.ChannelDirect, SenderID: "200"},
|
||||
want: "200",
|
||||
},
|
||||
{
|
||||
name: "私聊:两者都缺 ⇒ 空(调用方据此以 ErrInvalidChannel 拒掉)",
|
||||
msg: adapter.UnifiedMessage{Platform: "telegram", ChannelType: adapter.ChannelDirect},
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
c := c
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := ChannelKey(c.msg); got != c.want {
|
||||
t.Fatalf("ChannelKey = %q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestKeyIsTheSessionManagerCacheKey 钉住「导出的 Key 就是会话缓存键」:
|
||||
// 形状 `platform \x00 channelID`(NUL 分隔,杜绝拼接歧义),且两维度都参与 ——
|
||||
// 同平台不同渠道、不同平台同渠道都必须是**不同**的键。
|
||||
func TestKeyIsTheSessionManagerCacheKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
group1 := adapter.UnifiedMessage{Platform: "kook", ChannelType: adapter.ChannelGroup, ChannelID: "c1", GroupID: "g1"}
|
||||
group1OtherChannel := adapter.UnifiedMessage{Platform: "kook", ChannelType: adapter.ChannelGroup, ChannelID: "c2", GroupID: "g1"}
|
||||
group2 := adapter.UnifiedMessage{Platform: "kook", ChannelType: adapter.ChannelGroup, ChannelID: "c1", GroupID: "g2"}
|
||||
otherPlatform := adapter.UnifiedMessage{Platform: "qq-obv11", ChannelType: adapter.ChannelGroup, ChannelID: "c1", GroupID: "g1"}
|
||||
|
||||
if got, want := Key(group1), "kook\x00group:g1"; got != want {
|
||||
t.Fatalf("Key = %q, want %q", got, want)
|
||||
}
|
||||
// 同一 guild 的两个 text channel 在会话层是**同一个**会话。
|
||||
if Key(group1) != Key(group1OtherChannel) {
|
||||
t.Fatalf("same guild channels must share one session key: %q vs %q", Key(group1), Key(group1OtherChannel))
|
||||
}
|
||||
// 不同群、不同平台必须是不同的会话。
|
||||
if Key(group1) == Key(group2) {
|
||||
t.Fatalf("different groups must not share a session key: %q", Key(group1))
|
||||
}
|
||||
if Key(group1) == Key(otherPlatform) {
|
||||
t.Fatalf("different platforms must not share a session key: %q", Key(group1))
|
||||
}
|
||||
// 渠道缺失时键仍非空,但 ChannelKey 能为空(调用方据此区分不可用渠道)。
|
||||
noChannel := adapter.UnifiedMessage{Platform: "telegram", ChannelType: adapter.ChannelDirect}
|
||||
if Key(noChannel) == "" || ChannelKey(noChannel) != "" {
|
||||
t.Fatalf("missing-channel case: Key=%q ChannelKey=%q", Key(noChannel), ChannelKey(noChannel))
|
||||
}
|
||||
}
|
||||
@@ -110,7 +110,7 @@ func (m *SessionManager) GetOrCreate(ctx context.Context, msg adapter.UnifiedMes
|
||||
if cid == "" {
|
||||
return Session{}, fmt.Errorf("session: get-or-create %s: %w", msg.Platform, ErrInvalidChannel)
|
||||
}
|
||||
key := msg.Platform + "\x00" + cid
|
||||
key := sessionKey(msg.Platform, cid)
|
||||
|
||||
// 快路径:已绑定且车道与本次解析一致——只读锁即可返回。
|
||||
if s, ok := m.lookupCached(key); ok && s.ActorRole == actor.Role {
|
||||
@@ -155,7 +155,7 @@ func (m *SessionManager) List(ctx context.Context) ([]Session, error) {
|
||||
if err := rows.Scan(&platform, &cid, &sessionID, &roleStr); err != nil {
|
||||
return nil, fmt.Errorf("session: scan list: %w", err)
|
||||
}
|
||||
key := platform + "\x00" + cid
|
||||
key := sessionKey(platform, cid)
|
||||
if cached, ok := m.lookupCached(key); ok {
|
||||
sessions = append(sessions, cached)
|
||||
continue
|
||||
|
||||
@@ -12,7 +12,8 @@ import (
|
||||
// 为什么用独立加载器而不是挂进 Config 根结构:Config 由 Load 统一解码,而
|
||||
// 本切片刻意**不碰** Config / 记忆整理那组键(并行切片正在改它们)。这里复用
|
||||
// 与 LoadLLMDefaults 同一手法(parseDotEnv + 同一份 `env:"…"` 解码器),
|
||||
// 因此优先级与 Load 逐字一致:进程环境变量 > `.env` > 标签默认值。
|
||||
// 因此优先级与 Load 逐字一致:进程环境变量 > `.env` > 标签默认值(TimeoutSec
|
||||
// 没有标签默认值,见该字段的注释 —— 未配置时它读出 0,由 memory 包定其生效值)。
|
||||
// envPath 为空表示不看 `.env`(与 Load 的约定一致)。
|
||||
type MemoryReviewConfig struct {
|
||||
// Enabled 是入库前审查总开关,默认 true。依据:主人 2026-10-05 的裁定
|
||||
@@ -28,12 +29,36 @@ type MemoryReviewConfig struct {
|
||||
ScanLimit int `env:"MEMORY_REVIEW_SCAN_LIMIT" default:"200"`
|
||||
// TimeoutSec 是单条审查的墙钟上限(秒)。超时 ⇒ **不做任何改写**,
|
||||
// 记忆照旧保留 + WARN(design/23 §15 D3-5 的 fail-safe)。
|
||||
TimeoutSec int `env:"MEMORY_REVIEW_TIMEOUT_SEC" default:"20"`
|
||||
//
|
||||
// 🔴 **本字段刻意没有 `default:` 标签**(2026-10-13,切片 M16)。单条审查预算的
|
||||
// **唯一真源**是 memory 包的 `defaultReviewTimeout`(90s,推导见
|
||||
// internal/runtime/memory/preinsert_config.go 的文件头注释)。这里原写
|
||||
// `default:"20"`,后果不是「默认值是 20」而是**默认值根本不由 memory 侧决定**:
|
||||
// env 层永远产出一个非零值 ⇒ memory 侧那道「≤0 才回落默认」的归一化成了**死代码**
|
||||
// (90s 从未生效,生产启动行 `memory ingest review started … timeout=20s` 即铁证,
|
||||
// 而实测召回链就要 21~23s ⇒ 每条候选满额的审查都超时、连留痕都不写)。
|
||||
//
|
||||
// 去掉字面默认后的**生效值**(路径:本字段 → cmd/cyrene/wire_memory_review.go 的
|
||||
// `ReviewConfig.Timeout` → `normalizeReviewConfig`):
|
||||
//
|
||||
// `.env` / 进程环境没写这个键 ⇒ 这里 0 ⇒ memory 侧回落 90s
|
||||
// 写了正数 ⇒ 原样生效(归一化只动 ≤0)
|
||||
// 写了 0 / 负数 ⇒ 回落 90s(与项目其它旋钮同一口径:≤0 = 用默认)
|
||||
//
|
||||
// ⚠️ 别在这里重新加回字面默认值 —— 那会让 memory 侧的 90s 再次变成死代码,
|
||||
// 而 memory 包的 TestReviewTimeoutEffectiveValueIsTheOnlyDefault 会立刻变红
|
||||
// (它走完整组装路径取生效值,不比对常量)。
|
||||
TimeoutSec int `env:"MEMORY_REVIEW_TIMEOUT_SEC"`
|
||||
}
|
||||
|
||||
// LoadMemoryReviewConfig 读取入库前审查的旋钮。非法(负数)值不在这里报错,
|
||||
// 而是与项目其它旋钮同一口径 —— **≤0 视为「用默认」**,由 memory 包 normalize
|
||||
// 收敛,避免一个笔误让进程起不来。
|
||||
//
|
||||
// 🔴 因此本函数**对 TimeoutSec 不做任何回落**(切片 M16):返回 0 就是「没配」,
|
||||
// 调用方必须原样交给 memory 包的 ReviewConfig(组合根今天就是这么做的),
|
||||
// 「0 ⇒ 多少秒」由 `normalizeReviewConfig` 在那**唯一一处**决定。调用方若在这里
|
||||
// 又补一个自己的默认值,全链就会重新长出第二个真源 —— 那正是本切片修掉的形态。
|
||||
func LoadMemoryReviewConfig(envPath string) (MemoryReviewConfig, error) {
|
||||
fromEnv := map[string]string{}
|
||||
if envPath != "" {
|
||||
|
||||
@@ -1,13 +1,47 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// reviewTimeoutEnvUnset 让 MEMORY_REVIEW_TIMEOUT_SEC 在本次用例中**真的未设置**。
|
||||
//
|
||||
// 为什么不用 t.Setenv(key, ""):空串在 decode.go 的 lookupRaw 里**照样命中 env 分支**
|
||||
// (`os.LookupEnv` 对空值返回 ok=true)⇒ 配置层读出 0,于是**看不见 tag 上的字面
|
||||
// 默认值** —— 而那正是本切片的回归面(tag 上再加回 `default:"20"` 必须变红)。
|
||||
// 未设置才会走到 tag 默认那一档。
|
||||
func reviewTimeoutEnvUnset(t *testing.T) {
|
||||
t.Helper()
|
||||
old, had := os.LookupEnv("MEMORY_REVIEW_TIMEOUT_SEC")
|
||||
t.Cleanup(func() {
|
||||
if had {
|
||||
_ = os.Setenv("MEMORY_REVIEW_TIMEOUT_SEC", old)
|
||||
return
|
||||
}
|
||||
_ = os.Unsetenv("MEMORY_REVIEW_TIMEOUT_SEC")
|
||||
})
|
||||
if err := os.Unsetenv("MEMORY_REVIEW_TIMEOUT_SEC"); err != nil {
|
||||
t.Fatalf("unset MEMORY_REVIEW_TIMEOUT_SEC: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadMemoryReviewConfigDefaults 锁定 D3「入库前审查」(design/23 §15.1 D3)
|
||||
// 的 env 契约默认值。它们必须与 memory 包的兜底默认值一致 —— 两处不一致会让
|
||||
// 「没配这个键」与「配了但解析失败」走向不同的行为。
|
||||
//
|
||||
// 🔴 TimeoutSec 是**例外的那一个,而且是刻意的**(切片 M16,2026-10-13):
|
||||
// 它**没有**字面默认值,未配置时就是 0,含义是「没配」而不是「0 秒」。
|
||||
// 单条审查预算的唯一真源在 memory 包的 `defaultReviewTimeout`(90s),
|
||||
// 全链的生效值 = `ReviewConfig.Timeout` 经 `normalizeReviewConfig` 归一化之后的值。
|
||||
//
|
||||
// 为什么不在这里断言 90:那会让本仓重新长出**两个**真源(env tag 一处、
|
||||
// memory 常量一处),而 `.env` 冻结口径(AGENTS §10)本来就把这类「上限/调优」
|
||||
// 参数指向**代码默认值**。断言生效值的那条测试在 memory 包
|
||||
// (TestReviewTimeoutEffectiveValueIsTheOnlyDefault,走完整组装路径)——
|
||||
// 本包不能引 memory 包(memory → config 已成立,反向会成环)。
|
||||
func TestLoadMemoryReviewConfigDefaults(t *testing.T) {
|
||||
reviewTimeoutEnvUnset(t)
|
||||
cfg, err := LoadMemoryReviewConfig("")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMemoryReviewConfig() error: %v", err)
|
||||
@@ -21,13 +55,19 @@ func TestLoadMemoryReviewConfigDefaults(t *testing.T) {
|
||||
if cfg.ScanLimit != 200 {
|
||||
t.Errorf("MEMORY_REVIEW_SCAN_LIMIT = %d, want 200(§15 D3-4 每条审查有界)", cfg.ScanLimit)
|
||||
}
|
||||
if cfg.TimeoutSec != 20 {
|
||||
t.Errorf("MEMORY_REVIEW_TIMEOUT_SEC = %d, want 20(§15 D3-5 超时即不改写)", cfg.TimeoutSec)
|
||||
if cfg.TimeoutSec != 0 {
|
||||
t.Errorf("MEMORY_REVIEW_TIMEOUT_SEC = %d, want 0(未配置)—— "+
|
||||
"这里出现非零值说明 tag 上又被加回了字面默认值,memory 侧的 "+
|
||||
"defaultReviewTimeout 会再次变成死代码(M16 修的正是这个形态)", cfg.TimeoutSec)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadMemoryReviewConfigEnvOverrides 锁定键名与类型:写进 .env 的旋钮真的
|
||||
// 生效(键名写错会静默沿用默认值,这类错误只能靠这条用例抓住)。
|
||||
//
|
||||
// TimeoutSec 另钉一条口径(M16):**正数原样透传、≤0 原样透传**。配置层不做
|
||||
// 「坏值回落」——「0 或负数 ⇒ 用默认 90s」是 memory 包 normalizeReviewConfig
|
||||
// 的语义,全链只有那一处决定它(组合根不许在这里补默认值)。
|
||||
func TestLoadMemoryReviewConfigEnvOverrides(t *testing.T) {
|
||||
t.Setenv("MEMORY_REVIEW_ENABLED", "false")
|
||||
t.Setenv("MEMORY_REVIEW_QUEUE_SIZE", "8")
|
||||
@@ -41,4 +81,22 @@ func TestLoadMemoryReviewConfigEnvOverrides(t *testing.T) {
|
||||
if cfg.Enabled || cfg.QueueSize != 8 || cfg.ScanLimit != 32 || cfg.TimeoutSec != 3 {
|
||||
t.Fatalf("MEMORY_REVIEW_* 未生效:%+v", cfg)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
raw string
|
||||
want int
|
||||
}{
|
||||
{"0", 0}, // 显式 0 = 用默认(下游归一化),不许在这里被改成 90
|
||||
{"-5", -5}, // 负数同样是「用默认」,也不在这里报错(AGENTS:不让笔误把进程弄起不来)
|
||||
} {
|
||||
t.Setenv("MEMORY_REVIEW_TIMEOUT_SEC", tc.raw)
|
||||
got, err := LoadMemoryReviewConfig("")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMemoryReviewConfig(%q) error: %v", tc.raw, err)
|
||||
}
|
||||
if got.TimeoutSec != tc.want {
|
||||
t.Fatalf("MEMORY_REVIEW_TIMEOUT_SEC=%q 读出 %d, want %d(配置层不回落,回落只在 memory 包)",
|
||||
tc.raw, got.TimeoutSec, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
// channels_coalesce.go 承载**渠道静默窗口合并**(M23)的落盘面(M23 蓝图 §7)。
|
||||
//
|
||||
// 它回答的是主人 2026-10-23/24 的原话「要让群聊聊天很自然,很流畅的做法」:群里
|
||||
// 「一鼓作气打三句」不该让她回三次。窗口/上限这些旋钮属于**调优**,因此按
|
||||
// design/13 §2.1「`.env` 冻结 ⇒ 新配置走 `$DATA_DIR/config/**` + 代码默认值」落在
|
||||
// $DATA_DIR/config/coalesce.json,**零新 env 键**。
|
||||
//
|
||||
// 本文件是纯存储层:它只负责读写、默认值归一与 clamp,**不做任何行为判定** ——
|
||||
// 「哪些消息进窗口」「主人私聊为什么没有窗口」的判据在
|
||||
// internal/services/channelbridge/coalesce*.go(车道类型由 actor 元数据决定,
|
||||
// **配置无权翻转**:这里既没有「对主人私聊生效」的键,也没有能改车道类型的开关)。
|
||||
package runtimeconfig
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
// coalesceConfigSubdir 与 owner_lane.json / backthink.json 共用 $DATA_DIR 下的
|
||||
// config 子目录口径。
|
||||
coalesceConfigSubdir = "config"
|
||||
// coalesceFileName 是静默窗口合并旋钮的落盘文件名。
|
||||
coalesceFileName = "coalesce.json"
|
||||
// coalesceLogSource 是本模块日志的来源标记(AGENTS §10:禁止无来源裸日志)。
|
||||
coalesceLogSource = "config/runtimeconfig/coalesce"
|
||||
|
||||
// clamp 上下限(M23 蓝图 §4 + 塔 2026-10-24 裁定):窗口 300–15000ms、
|
||||
// 绝对上限 ≥ 窗口且 ≤ 30000ms、单批 1–50 条。越界值**clamp + WARN**,
|
||||
// 绝不静默采信一个不可能的值,也绝不因此拦住入站。
|
||||
CoalesceWindowMinMs = 300
|
||||
CoalesceWindowMaxMs = 15000
|
||||
CoalesceMaxWaitMinMs = CoalesceWindowMinMs
|
||||
CoalesceMaxWaitMaxMs = 30000
|
||||
CoalesceBatchMin = 1
|
||||
CoalesceBatchMax = 50
|
||||
coalesceDefaultWindow = 2000
|
||||
coalesceDefaultWait = 6000
|
||||
coalesceDefaultBatch = 24
|
||||
)
|
||||
|
||||
// DefaultCoalescePath 返回静默窗口合并配置文件在 dataDir 下的路径。
|
||||
func DefaultCoalescePath(dataDir string) string {
|
||||
return filepath.Join(dataDir, coalesceConfigSubdir, coalesceFileName)
|
||||
}
|
||||
|
||||
// CoalesceConversationKey 把**车道键**(`platform \x00 channelID`,与会话键同源)
|
||||
// 渲染成落盘/面板用的可读键:NUL 换成 `|`。
|
||||
//
|
||||
// 为什么需要这一层:车道键里的分隔符是 NUL(`session.Key` 的唯一实现点决定),
|
||||
// 直接写进 JSON 会变成 `\u0000` —— 面板的输入框里看不见、复制不出来,主人根本
|
||||
// 没法给某个群配覆盖。反向不歧义:平台名与渠道 id 都不含 `|`,所以这个渲染是
|
||||
// 单射的;桥只按「同一个函数」把车道键映射成配置键,绝不在别处另拼一份。
|
||||
func CoalesceConversationKey(laneKey string) string {
|
||||
return strings.ReplaceAll(laneKey, "\x00", "|")
|
||||
}
|
||||
|
||||
// CoalescePolicy 是一组**生效**的静默窗口参数(已归一、已 clamp)。
|
||||
//
|
||||
// 零值**不是**「关闭」的等价物(Enabled=false 才是):读盘缺失时走
|
||||
// DefaultCoalescePolicy,绝不因为「没写这个键」就把特性当成关闭。
|
||||
type CoalescePolicy struct {
|
||||
// Enabled 是群聊窗口的总开关;false ⇒ 行为逐字回到 M22(到达即投递、一条一轮)。
|
||||
Enabled bool `json:"enabled"`
|
||||
// WindowMs 是**静默窗口**:滑动语义,新消息重置计时(「等她把话说完」)。
|
||||
WindowMs int `json:"windowMs"`
|
||||
// MaxWaitMs 是**绝对上限**,锚定在批内第一条的到达时刻:一个不停说话的群
|
||||
// 因此不会让计时永不归零(宁可晚回,不许不回)。
|
||||
MaxWaitMs int `json:"maxWaitMs"`
|
||||
// MaxBatchMessages 是单批消息条数上限;超出的**不是丢弃**,是由车道回插队首
|
||||
// 成为下一批(见 channelbridge/coalesce.go 的 split)。
|
||||
MaxBatchMessages int `json:"maxBatchMessages"`
|
||||
// ApplyToStrangers 决定陌生人私聊是否也走窗口;false ⇒ 陌生人私聊逐条跑。
|
||||
ApplyToStrangers bool `json:"applyToStrangers"`
|
||||
// StrangerWindowMs 是陌生人私聊的独立窗口(v1 与 WindowMs 同值,纯预留:
|
||||
// 蓝图 §3 明确「现状没有证据支撑两个值」,先留字段避免日后改结构)。
|
||||
StrangerWindowMs int `json:"strangerWindowMs"`
|
||||
}
|
||||
|
||||
// DefaultCoalescePolicy 返回代码默认值(也是面板「留空 ⇒ 跟随」的那一档)。
|
||||
func DefaultCoalescePolicy() CoalescePolicy {
|
||||
return CoalescePolicy{
|
||||
Enabled: true,
|
||||
WindowMs: coalesceDefaultWindow,
|
||||
MaxWaitMs: coalesceDefaultWait,
|
||||
MaxBatchMessages: coalesceDefaultBatch,
|
||||
ApplyToStrangers: true,
|
||||
StrangerWindowMs: coalesceDefaultWindow,
|
||||
}
|
||||
}
|
||||
|
||||
// CoalescePolicyPatch 是**三态补丁**:键缺位(nil)= 不动;有值 = 采用。
|
||||
// `null` 在文件层没有清除语义(每个键都有代码默认值兜底),因此无需 Clear 集合。
|
||||
type CoalescePolicyPatch struct {
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
WindowMs *int `json:"windowMs,omitempty"`
|
||||
MaxWaitMs *int `json:"maxWaitMs,omitempty"`
|
||||
MaxBatchMessages *int `json:"maxBatchMessages,omitempty"`
|
||||
ApplyToStrangers *bool `json:"applyToStrangers,omitempty"`
|
||||
StrangerWindowMs *int `json:"strangerWindowMs,omitempty"`
|
||||
}
|
||||
|
||||
// apply 把补丁里**出现过的**键叠到 p 上(缺位键保持 p 的原值)。
|
||||
func (patch CoalescePolicyPatch) apply(p CoalescePolicy) CoalescePolicy {
|
||||
if patch.Enabled != nil {
|
||||
p.Enabled = *patch.Enabled
|
||||
}
|
||||
if patch.WindowMs != nil {
|
||||
p.WindowMs = *patch.WindowMs
|
||||
}
|
||||
if patch.MaxWaitMs != nil {
|
||||
p.MaxWaitMs = *patch.MaxWaitMs
|
||||
}
|
||||
if patch.MaxBatchMessages != nil {
|
||||
p.MaxBatchMessages = *patch.MaxBatchMessages
|
||||
}
|
||||
if patch.ApplyToStrangers != nil {
|
||||
p.ApplyToStrangers = *patch.ApplyToStrangers
|
||||
}
|
||||
if patch.StrangerWindowMs != nil {
|
||||
p.StrangerWindowMs = *patch.StrangerWindowMs
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// merge 返回自身与后到补丁的合并(后者出现过的键优先)。写盘时用它保留
|
||||
// 「这次没提到的键」的旧值 —— 三态里的「不动」在文件层就落成这个合并。
|
||||
func (patch CoalescePolicyPatch) merge(next CoalescePolicyPatch) CoalescePolicyPatch {
|
||||
out := patch
|
||||
if next.Enabled != nil {
|
||||
out.Enabled = next.Enabled
|
||||
}
|
||||
if next.WindowMs != nil {
|
||||
out.WindowMs = next.WindowMs
|
||||
}
|
||||
if next.MaxWaitMs != nil {
|
||||
out.MaxWaitMs = next.MaxWaitMs
|
||||
}
|
||||
if next.MaxBatchMessages != nil {
|
||||
out.MaxBatchMessages = next.MaxBatchMessages
|
||||
}
|
||||
if next.ApplyToStrangers != nil {
|
||||
out.ApplyToStrangers = next.ApplyToStrangers
|
||||
}
|
||||
if next.StrangerWindowMs != nil {
|
||||
out.StrangerWindowMs = next.StrangerWindowMs
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Empty 报告补丁是否一个键都没有(写盘时据此跳过空改写)。
|
||||
func (patch CoalescePolicyPatch) Empty() bool {
|
||||
return patch.Enabled == nil && patch.WindowMs == nil && patch.MaxWaitMs == nil &&
|
||||
patch.MaxBatchMessages == nil && patch.ApplyToStrangers == nil &&
|
||||
patch.StrangerWindowMs == nil
|
||||
}
|
||||
|
||||
// CoalesceFile 是 coalesce.json 的形状:一个全局 default 加一份每会话覆盖表
|
||||
// (键 = CoalesceConversationKey(车道键))。
|
||||
type CoalesceFile struct {
|
||||
Default CoalescePolicyPatch `json:"default"`
|
||||
Conversations map[string]CoalescePolicyPatch `json:"conversations,omitempty"`
|
||||
}
|
||||
|
||||
// EffectiveDefault 返回「代码默认 + 盘上 default 段」归一后的**生效值**(已 clamp)。
|
||||
// 面板据此显示「当前值」,而 Values() 显示「你写进去的键」—— 两者刻意分开:
|
||||
// 一个是运行期事实,一个是盘上原文。
|
||||
func (f CoalesceFile) EffectiveDefault() CoalescePolicy {
|
||||
p, _ := ClampCoalescePolicy(f.Default.apply(DefaultCoalescePolicy()))
|
||||
return p
|
||||
}
|
||||
|
||||
// EffectiveConversation 返回某条会话覆盖的生效值:全局生效值 → 该会话覆盖 → clamp。
|
||||
func (f CoalesceFile) EffectiveConversation(key string) (CoalescePolicy, bool) {
|
||||
conv, ok := f.Conversations[key]
|
||||
if !ok {
|
||||
return CoalescePolicy{}, false
|
||||
}
|
||||
p, _ := ClampCoalescePolicy(conv.apply(f.EffectiveDefault()))
|
||||
return p, true
|
||||
}
|
||||
|
||||
// Values 返回该补丁里**出现过的**键(键缺位 ⇒ 不出现),供面板「留空 ⇒ 跟随默认」
|
||||
// 如实回显。值是该键在盘上的原值(未经 clamp)。
|
||||
func (patch CoalescePolicyPatch) Values() map[string]any {
|
||||
out := map[string]any{}
|
||||
if patch.Enabled != nil {
|
||||
out["enabled"] = *patch.Enabled
|
||||
}
|
||||
if patch.WindowMs != nil {
|
||||
out["windowMs"] = *patch.WindowMs
|
||||
}
|
||||
if patch.MaxWaitMs != nil {
|
||||
out["maxWaitMs"] = *patch.MaxWaitMs
|
||||
}
|
||||
if patch.MaxBatchMessages != nil {
|
||||
out["maxBatchMessages"] = *patch.MaxBatchMessages
|
||||
}
|
||||
if patch.ApplyToStrangers != nil {
|
||||
out["applyToStrangers"] = *patch.ApplyToStrangers
|
||||
}
|
||||
if patch.StrangerWindowMs != nil {
|
||||
out["strangerWindowMs"] = *patch.StrangerWindowMs
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ClampCoalescePolicy 把一组参数夹进可运行区间,并返回**被改动过**的说明
|
||||
// (调用方按 WARN 落地:越界不是错误,但必须留痕,否则「我配了 100ms」会变成
|
||||
// 一个没人知道的 300ms)。
|
||||
func ClampCoalescePolicy(p CoalescePolicy) (CoalescePolicy, []string) {
|
||||
var notes []string
|
||||
if p.WindowMs != 0 && p.WindowMs < CoalesceWindowMinMs {
|
||||
notes = append(notes, fmt.Sprintf("windowMs=%d 太小,取 %d", p.WindowMs, CoalesceWindowMinMs))
|
||||
p.WindowMs = CoalesceWindowMinMs
|
||||
}
|
||||
if p.WindowMs > CoalesceWindowMaxMs {
|
||||
notes = append(notes, fmt.Sprintf("windowMs=%d 太大,取 %d", p.WindowMs, CoalesceWindowMaxMs))
|
||||
p.WindowMs = CoalesceWindowMaxMs
|
||||
}
|
||||
if p.WindowMs == 0 {
|
||||
// 蓝图 §4:`0` 与 enabled:false 同义(「窗口 0 毫秒」不可能是有意的调优)。
|
||||
notes = append(notes, "windowMs=0 视为关闭静默窗口")
|
||||
p.Enabled = false
|
||||
p.WindowMs = coalesceDefaultWindow
|
||||
}
|
||||
if p.StrangerWindowMs <= 0 {
|
||||
p.StrangerWindowMs = p.WindowMs
|
||||
}
|
||||
if p.StrangerWindowMs < CoalesceWindowMinMs || p.StrangerWindowMs > CoalesceWindowMaxMs {
|
||||
notes = append(notes, fmt.Sprintf("strangerWindowMs=%d 越界,取 windowMs=%d", p.StrangerWindowMs, p.WindowMs))
|
||||
p.StrangerWindowMs = p.WindowMs
|
||||
}
|
||||
if p.MaxWaitMs < p.WindowMs {
|
||||
notes = append(notes, fmt.Sprintf("maxWaitMs=%d 小于窗口,取 %d", p.MaxWaitMs, p.WindowMs))
|
||||
p.MaxWaitMs = p.WindowMs
|
||||
}
|
||||
if p.MaxWaitMs > CoalesceMaxWaitMaxMs {
|
||||
notes = append(notes, fmt.Sprintf("maxWaitMs=%d 太大,取 %d", p.MaxWaitMs, CoalesceMaxWaitMaxMs))
|
||||
p.MaxWaitMs = CoalesceMaxWaitMaxMs
|
||||
}
|
||||
if p.MaxBatchMessages < CoalesceBatchMin {
|
||||
notes = append(notes, fmt.Sprintf("maxBatchMessages=%d 太小,取 %d", p.MaxBatchMessages, CoalesceBatchMin))
|
||||
p.MaxBatchMessages = CoalesceBatchMin
|
||||
}
|
||||
if p.MaxBatchMessages > CoalesceBatchMax {
|
||||
notes = append(notes, fmt.Sprintf("maxBatchMessages=%d 太大,取 %d", p.MaxBatchMessages, CoalesceBatchMax))
|
||||
p.MaxBatchMessages = CoalesceBatchMax
|
||||
}
|
||||
return p, notes
|
||||
}
|
||||
|
||||
// ValidateCoalescePatch 拒绝那些**不是调优、而是敲错**的补丁(负数;类型错误已由
|
||||
// JSON 解码挡掉)。返回错误 ⇒ 调用方回 400 且**盘上一个字节都不动**。
|
||||
func ValidateCoalescePatch(p CoalescePolicyPatch) error {
|
||||
fields := []struct {
|
||||
name string
|
||||
v *int
|
||||
}{
|
||||
{"windowMs", p.WindowMs},
|
||||
{"maxWaitMs", p.MaxWaitMs},
|
||||
{"maxBatchMessages", p.MaxBatchMessages},
|
||||
{"strangerWindowMs", p.StrangerWindowMs},
|
||||
}
|
||||
for _, f := range fields {
|
||||
if f.v != nil && *f.v < 0 {
|
||||
return fmt.Errorf("%s 不能为负数(%d)", f.name, *f.v)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CoalescePatch 是 PUT 请求体的形状(三态):`default` 缺位 ⇒ 全局不动、
|
||||
// 出现过的键采用;`conversations` 里出现过的键逐键采用,值为 `null` ⇒
|
||||
// **删掉**该会话覆盖(回到全局 default),没提到的会话一概不动。
|
||||
type CoalescePatch struct {
|
||||
Default *CoalescePolicyPatch `json:"default,omitempty"`
|
||||
Conversations map[string]*CoalescePolicyPatch `json:"conversations,omitempty"`
|
||||
}
|
||||
|
||||
// Empty 报告补丁是否什么都没提(面板「没改任何键就点保存」不该在盘上留一次改写)。
|
||||
func (p CoalescePatch) Empty() bool {
|
||||
if p.Default != nil && !p.Default.Empty() {
|
||||
return false
|
||||
}
|
||||
return len(p.Conversations) == 0
|
||||
}
|
||||
|
||||
// CoalesceStore 是静默窗口旋钮的读写面:内存缓存 + 原子落盘。它可安全并发使用。
|
||||
// 读缺失文件、坏文件都退回默认值(绝不因为配置坏了而阻断入站),但留带来源的 WARN。
|
||||
type CoalesceStore struct {
|
||||
path string
|
||||
log *slog.Logger
|
||||
mu sync.RWMutex
|
||||
cfg CoalesceFile
|
||||
}
|
||||
|
||||
// NewCoalesceStore 构造 store 并尽力加载已有文件。path 为空或文件缺失时走默认值,
|
||||
// 不返回错误:配置坏了只影响窗口取值,绝不该拦住渠道启动。
|
||||
func NewCoalesceStore(path string, log *slog.Logger) *CoalesceStore {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
s := &CoalesceStore{
|
||||
path: path,
|
||||
log: logger.WithSource(log, coalesceLogSource),
|
||||
cfg: CoalesceFile{Default: CoalescePolicyPatch{}, Conversations: map[string]CoalescePolicyPatch{}},
|
||||
}
|
||||
s.load()
|
||||
return s
|
||||
}
|
||||
|
||||
// Path 返回这份配置的落点(面板如实回显「改的是哪个文件」用)。
|
||||
func (s *CoalesceStore) Path() string { return s.path }
|
||||
|
||||
// Present 报告配置文件是否已存在(false = 还没写过,运行期正走代码默认)。
|
||||
func (s *CoalesceStore) Present() bool {
|
||||
if s.path == "" {
|
||||
return false
|
||||
}
|
||||
_, err := os.Stat(s.path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// load 尽力从磁盘读入配置。文件不存在是正常情况(首次运行),静默走默认值;
|
||||
// 其它读/解析错误留 WARN 后仍走默认值。
|
||||
func (s *CoalesceStore) load() {
|
||||
if s.path == "" {
|
||||
return
|
||||
}
|
||||
raw, err := os.ReadFile(s.path)
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
s.log.Warn("coalesce config unreadable, using defaults", "path", s.path, "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
var f CoalesceFile
|
||||
if err := json.Unmarshal(raw, &f); err != nil {
|
||||
s.log.Warn("coalesce config invalid, using defaults", "path", s.path, "error", err)
|
||||
return
|
||||
}
|
||||
if f.Conversations == nil {
|
||||
f.Conversations = map[string]CoalescePolicyPatch{}
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.cfg = f
|
||||
s.mu.Unlock()
|
||||
s.logClamp(f)
|
||||
}
|
||||
|
||||
// logClamp 把 disk 上那份的越界值一次性记进日志(每次 load/Set 一次,不是每次发车)。
|
||||
func (s *CoalesceStore) logClamp(f CoalesceFile) {
|
||||
if _, notes := ClampCoalescePolicy(f.Default.apply(DefaultCoalescePolicy())); len(notes) > 0 {
|
||||
s.log.Warn("coalesce default clamped", "path", s.path, "notes", strings.Join(notes, "; "))
|
||||
}
|
||||
for key, patch := range f.Conversations {
|
||||
if _, notes := ClampCoalescePolicy(patch.apply(DefaultCoalescePolicy())); len(notes) > 0 {
|
||||
s.log.Warn("coalesce conversation override clamped",
|
||||
"path", s.path, "conversation", key, "notes", strings.Join(notes, "; "))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PolicyFor 返回某条车道键**生效**的窗口参数:代码默认 → 全局 default → 该会话覆盖,
|
||||
// 逐键叠完后 clamp。它永不返回「未设置」:调用方永远拿到一个可运行的值。
|
||||
//
|
||||
// 它读的是内存(不碰磁盘),因此面板保存后**下一次发车**就生效(热生效),
|
||||
// 也与入站路径一样不会被一次落盘抖动卡住。
|
||||
func (s *CoalesceStore) PolicyFor(laneKey string) CoalescePolicy {
|
||||
s.mu.RLock()
|
||||
def := s.cfg.Default
|
||||
conv, ok := s.cfg.Conversations[CoalesceConversationKey(laneKey)]
|
||||
s.mu.RUnlock()
|
||||
p, _ := ClampCoalescePolicy(def.apply(DefaultCoalescePolicy()))
|
||||
if ok {
|
||||
p, _ = ClampCoalescePolicy(conv.apply(p))
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// Snapshot 返回配置的一份拷贝(**盘上原值**,未经 clamp),供面板/测试读取。
|
||||
// 与 PolicyFor 的区别是刻意的:面板要如实回显「你写进去的是什么」,
|
||||
// 而不是「运行期把它夹成了什么」。
|
||||
func (s *CoalesceStore) Snapshot() CoalesceFile {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := CoalesceFile{Default: s.cfg.Default, Conversations: make(map[string]CoalescePolicyPatch, len(s.cfg.Conversations))}
|
||||
for k, v := range s.cfg.Conversations {
|
||||
out.Conversations[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Apply 应用一份三态补丁:出现过的键采用,缺位的键保持原值;conversations 里
|
||||
// 值为 nil 的键 = **删掉**该会话覆盖(回到全局 default)。内存与落盘在同一把锁内,
|
||||
// 因此并发写入不会互相覆盖,也不会留下半份文件。返回 (是否真的改了盘上的字节)。
|
||||
//
|
||||
// 校验(ValidateCoalescePatch)由调用方在**进本函数之前**做完:走到这里意味着
|
||||
// 「这份补丁合法」,落盘因此不会出现「写了一半才发现非法」。
|
||||
func (s *CoalesceStore) Apply(patch CoalescePatch) (bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
before, _ := json.Marshal(s.cfg)
|
||||
if patch.Default != nil {
|
||||
s.cfg.Default = s.cfg.Default.merge(*patch.Default)
|
||||
}
|
||||
if s.cfg.Conversations == nil {
|
||||
s.cfg.Conversations = map[string]CoalescePolicyPatch{}
|
||||
}
|
||||
for key, conv := range patch.Conversations {
|
||||
if conv == nil {
|
||||
delete(s.cfg.Conversations, key)
|
||||
continue
|
||||
}
|
||||
s.cfg.Conversations[key] = s.cfg.Conversations[key].merge(*conv)
|
||||
}
|
||||
after, _ := json.Marshal(s.cfg)
|
||||
if string(before) == string(after) {
|
||||
return false, nil
|
||||
}
|
||||
if err := s.saveLocked(); err != nil {
|
||||
return false, fmt.Errorf("runtimeconfig: save coalesce config %s: %w", s.path, err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// saveLocked 原子写出当前配置(临时文件 + rename)。调用方**必须持有 s.mu**。
|
||||
// 0600 与 owner_lane.json 同口径:这份文件只被后端自己读写。
|
||||
func (s *CoalesceStore) saveLocked() error {
|
||||
if s.path == "" {
|
||||
return nil
|
||||
}
|
||||
dir := filepath.Dir(s.path)
|
||||
if dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("mkdir: %w", err)
|
||||
}
|
||||
}
|
||||
raw, err := json.MarshalIndent(s.cfg, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal: %w", err)
|
||||
}
|
||||
tmp, err := os.CreateTemp(dir, filepath.Base(s.path)+".*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temp in %s: %w", dir, err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
if err := tmp.Chmod(0o600); err != nil {
|
||||
_ = tmp.Close()
|
||||
_ = os.Remove(tmpName)
|
||||
return fmt.Errorf("chmod %s: %w", tmpName, err)
|
||||
}
|
||||
if _, err := tmp.Write(raw); err != nil {
|
||||
_ = tmp.Close()
|
||||
_ = os.Remove(tmpName)
|
||||
return fmt.Errorf("write %s: %w", tmpName, err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
_ = os.Remove(tmpName)
|
||||
return fmt.Errorf("close %s: %w", tmpName, err)
|
||||
}
|
||||
if err := os.Rename(tmpName, s.path); err != nil {
|
||||
_ = os.Remove(tmpName)
|
||||
return fmt.Errorf("rename: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
// channels_ownerlane.go 承载 owner 车道结构化旋钮的**落盘面**(design/18 §11
|
||||
// 「乙 = C」,主人 2026-10-13 裁决):主人在群里说「少说点」这类诉求不再靠她自己记,
|
||||
// 而是被写成一份可读可改的每群配置(默认值 + 每群覆盖),面板后续照这份形状写入。
|
||||
//
|
||||
// 它落在 $DATA_DIR/config/owner_lane.json(与 backthink.json 同一目录口径、同一
|
||||
// 「.env 冻结 ⇒ 新配置走 $DATA_DIR/config/**」纪律,design/13 §2.1),**不进 .env**。
|
||||
// 本文件是纯存储层:它只负责读写与默认值归一,**不做任何行为判定** ——
|
||||
// 「说不说 / 多频繁」的判据在 channelbridge/ownerlane.go,身份权限仍只由 actor 元数据决定。
|
||||
package runtimeconfig
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
// ownerLaneConfigSubdir 与 backthink.json 共用 $DATA_DIR 下的 config 子目录口径。
|
||||
ownerLaneConfigSubdir = "config"
|
||||
// ownerLaneFileName 是 owner 车道旋钮的落盘文件名。
|
||||
ownerLaneFileName = "owner_lane.json"
|
||||
// ownerLaneLogSource 是本模块日志的来源标记,使「旋钮被谁改成什么」跨模块可定位
|
||||
// (项目规范 §五:禁止无来源裸日志)。
|
||||
ownerLaneLogSource = "config/runtimeconfig/ownerlane"
|
||||
)
|
||||
|
||||
// DefaultOwnerLanePath 返回 owner 车道旋钮文件在 dataDir 下的路径。
|
||||
func DefaultOwnerLanePath(dataDir string) string {
|
||||
return filepath.Join(dataDir, ownerLaneConfigSubdir, ownerLaneFileName)
|
||||
}
|
||||
|
||||
// OwnerLanePolicy 是 owner 车道的一档参与策略(design/18 §11 的可调旋钮):
|
||||
// 它**只**影响「说不说 / 多频繁」,绝不动权限、工具面或 actor 元数据(AGENTS §9)。
|
||||
//
|
||||
// 零值 = 最保守的一档(只回指向她的消息),也就是裁决「甲」的默认口径。
|
||||
type OwnerLanePolicy struct {
|
||||
// ReplyToUnaddressed 是「说不说」开关:true 时主人**非指向她**的群消息也参与回复;
|
||||
// false(默认)时只回指向她的(@她 / @全体 / 未接线渠道的旧判据)。
|
||||
ReplyToUnaddressed bool `json:"replyToUnaddressed"`
|
||||
// MinReplyGapSec 是「多频繁」:owner 车道两条**非指向她**的回复之间的最小间隔(秒)。
|
||||
// 0 = 不额外限制。指向她的消息一律必回,**不受本字段约束**(硬保留)。
|
||||
MinReplyGapSec int `json:"minReplyGapSec"`
|
||||
}
|
||||
|
||||
// DefaultOwnerLanePolicy 返回裁决「甲」的默认口径:不对她说就不开口。
|
||||
func DefaultOwnerLanePolicy() OwnerLanePolicy {
|
||||
return OwnerLanePolicy{}
|
||||
}
|
||||
|
||||
// OwnerLaneFile 是 owner_lane.json 的形状:一个全局 default(可被面板改)
|
||||
// 加一份每群覆盖表。键见 OwnerLaneGroupKey。
|
||||
type OwnerLaneFile struct {
|
||||
Default OwnerLanePolicy `json:"default"`
|
||||
Groups map[string]OwnerLanePolicy `json:"groups,omitempty"`
|
||||
}
|
||||
|
||||
// OwnerLaneGroupKey 是每群覆盖表的键:平台 + 群 id。群 id 为空表示**全局默认**那一档
|
||||
// (面板把「所有群」的旋钮写进 default 时用它)。
|
||||
func OwnerLaneGroupKey(platform, groupID string) string {
|
||||
return platform + "|" + groupID
|
||||
}
|
||||
|
||||
// OwnerLaneStore 是 owner 车道旋钮的读写面:内存缓存 + 落盘。它可安全并发使用。
|
||||
// 读缺失文件、坏文件都退回默认值(绝不因为配置坏了而阻断入站),但会留一条带来源的 WARN。
|
||||
type OwnerLaneStore struct {
|
||||
path string
|
||||
log *slog.Logger
|
||||
mu sync.RWMutex
|
||||
cfg OwnerLaneFile
|
||||
}
|
||||
|
||||
// NewOwnerLaneStore 构造 store 并尽力加载已有文件。path 为空或文件缺失时走默认值,
|
||||
// 不返回错误:配置坏了只影响旋钮取值,绝不该拦住渠道启动。
|
||||
func NewOwnerLaneStore(path string, log *slog.Logger) *OwnerLaneStore {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
s := &OwnerLaneStore{
|
||||
path: path,
|
||||
log: logger.WithSource(log, ownerLaneLogSource),
|
||||
cfg: OwnerLaneFile{Default: DefaultOwnerLanePolicy(), Groups: map[string]OwnerLanePolicy{}},
|
||||
}
|
||||
s.load()
|
||||
return s
|
||||
}
|
||||
|
||||
// load 尽力从磁盘读入配置。文件不存在是正常情况(首次运行),静默走默认值;
|
||||
// 其它读/解析错误留一条带来源的 WARN 后仍走默认值。
|
||||
func (s *OwnerLaneStore) load() {
|
||||
if s.path == "" {
|
||||
return
|
||||
}
|
||||
raw, err := os.ReadFile(s.path)
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
s.log.Warn("owner lane config unreadable, using defaults", "path", s.path, "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
var f OwnerLaneFile
|
||||
if err := json.Unmarshal(raw, &f); err != nil {
|
||||
s.log.Warn("owner lane config invalid, using defaults", "path", s.path, "error", err)
|
||||
return
|
||||
}
|
||||
if f.Groups == nil {
|
||||
f.Groups = map[string]OwnerLanePolicy{}
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.cfg = f
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// PolicyFor 返回某个 (platform, groupID) 生效的策略:有每群覆盖就用它,否则用全局
|
||||
// default(default 缺省即「甲」)。它不返回「未设置」,因此调用方永远拿到一个可用的档位。
|
||||
func (s *OwnerLaneStore) PolicyFor(platform, groupID string) OwnerLanePolicy {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if p, ok := s.cfg.Groups[OwnerLaneGroupKey(platform, groupID)]; ok {
|
||||
return p
|
||||
}
|
||||
return s.cfg.Default
|
||||
}
|
||||
|
||||
// Snapshot 返回配置的一份拷贝,供面板/测试读取(改动它不影响 store)。
|
||||
func (s *OwnerLaneStore) Snapshot() OwnerLaneFile {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := OwnerLaneFile{Default: s.cfg.Default, Groups: make(map[string]OwnerLanePolicy, len(s.cfg.Groups))}
|
||||
for k, v := range s.cfg.Groups {
|
||||
out.Groups[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Set 写入某个 (platform, groupID) 的策略并落盘。groupID 为空表示写全局 default 档。
|
||||
//
|
||||
// 内存改动与**落盘都在同一把锁内**(review p1):并发 Set 因此完全串行 —— 既不会两个
|
||||
// 写者去写同一个临时文件把 JSON 写坏,也不会因为原子 rename 的完成先后与各快照的新旧
|
||||
// 不一致而丢掉「后发生的那次更新」。落盘量是一个小 JSON,持锁期间的 I/O 代价可忽略;
|
||||
// 读侧(PolicyFor)读的是内存、不碰文件,因此不会被这次落盘抖动。
|
||||
//
|
||||
// 内存先改、失败返回错误(调用方留可见 WARN),但内存里的新档位仍然生效 ——
|
||||
// 「改不动磁盘」不该让已经做出的行为决定回退。
|
||||
func (s *OwnerLaneStore) Set(platform, groupID string, p OwnerLanePolicy) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if groupID == "" {
|
||||
s.cfg.Default = p
|
||||
} else {
|
||||
if s.cfg.Groups == nil {
|
||||
s.cfg.Groups = map[string]OwnerLanePolicy{}
|
||||
}
|
||||
s.cfg.Groups[OwnerLaneGroupKey(platform, groupID)] = p
|
||||
}
|
||||
if err := s.saveLocked(); err != nil {
|
||||
return fmt.Errorf("runtimeconfig: save owner lane config %s: %w", s.path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// saveLocked 原子写出当前配置。调用方**必须持有 s.mu**。
|
||||
//
|
||||
// 它每次都用**新建的临时文件**(`os.CreateTemp`,仓库同款先例见 internal/user/save.go)
|
||||
// 再原子 rename:即便有别的进程/goroutine 也在写同一个目录,也不会撞同一个临时文件名,
|
||||
// 而半份文件永远不会以 s.path 的名字出现。
|
||||
func (s *OwnerLaneStore) saveLocked() error {
|
||||
if s.path == "" {
|
||||
return nil
|
||||
}
|
||||
dir := filepath.Dir(s.path)
|
||||
if dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("mkdir: %w", err)
|
||||
}
|
||||
}
|
||||
raw, err := json.MarshalIndent(s.cfg, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal: %w", err)
|
||||
}
|
||||
tmp, err := os.CreateTemp(dir, filepath.Base(s.path)+".*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temp in %s: %w", dir, err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
// CreateTemp 以 0600 打开;显式再 chmod 一次,使落盘文件不因 umask 变化而更宽。
|
||||
// 任一步失败都删掉临时文件,绝不留垃圾。
|
||||
if err := tmp.Chmod(0o600); err != nil {
|
||||
_ = tmp.Close()
|
||||
_ = os.Remove(tmpName)
|
||||
return fmt.Errorf("chmod %s: %w", tmpName, err)
|
||||
}
|
||||
if _, err := tmp.Write(raw); err != nil {
|
||||
_ = tmp.Close()
|
||||
_ = os.Remove(tmpName)
|
||||
return fmt.Errorf("write %s: %w", tmpName, err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
_ = os.Remove(tmpName)
|
||||
return fmt.Errorf("close %s: %w", tmpName, err)
|
||||
}
|
||||
if err := os.Rename(tmpName, s.path); err != nil {
|
||||
_ = os.Remove(tmpName)
|
||||
return fmt.Errorf("rename: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package runtimeconfig
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestOwnerLaneStoreDefaultsWhenFileMissing 钉住默认档(裁决「甲」):
|
||||
// 文件不存在时读到的就是最保守的一档 —— 不对她说就不开口,且没有额外频率闸。
|
||||
func TestOwnerLaneStoreDefaultsWhenFileMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "config", "owner_lane.json")
|
||||
s := NewOwnerLaneStore(path, nil)
|
||||
|
||||
got := s.PolicyFor("qq-obv11", "672432767")
|
||||
if got.ReplyToUnaddressed || got.MinReplyGapSec != 0 {
|
||||
t.Fatalf("默认档 = %+v, want 零值(不对她说就不开口)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOwnerLaneStoreSetPersistsAndReloads 钉住「可读可改」:Set 落盘后,新建的
|
||||
// store 能读回同一个每群覆盖;未覆盖的群仍走全局 default。
|
||||
func TestOwnerLaneStoreSetPersistsAndReloads(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "config", "owner_lane.json")
|
||||
s := NewOwnerLaneStore(path, nil)
|
||||
want := OwnerLanePolicy{ReplyToUnaddressed: true, MinReplyGapSec: 120}
|
||||
if err := s.Set("qq-obv11", "672432767", want); err != nil {
|
||||
t.Fatalf("Set: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("owner_lane.json 未落盘:%v", err)
|
||||
}
|
||||
|
||||
reloaded := NewOwnerLaneStore(path, nil)
|
||||
if got := reloaded.PolicyFor("qq-obv11", "672432767"); got != want {
|
||||
t.Fatalf("重载后该群策略 = %+v, want %+v", got, want)
|
||||
}
|
||||
// 未被覆盖的群仍走全局 default(零值)。
|
||||
if got := reloaded.PolicyFor("qq-obv11", "another-group"); got != DefaultOwnerLanePolicy() {
|
||||
t.Fatalf("未覆盖群策略 = %+v, want 默认档", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOwnerLaneStoreGlobalDefault 钉住面板可改的「全局 default」那一档:
|
||||
// groupID 为空即写默认,所有未单独覆盖的群都跟着变。
|
||||
func TestOwnerLaneStoreGlobalDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := NewOwnerLaneStore(filepath.Join(t.TempDir(), "config", "owner_lane.json"), nil)
|
||||
global := OwnerLanePolicy{ReplyToUnaddressed: true, MinReplyGapSec: 60}
|
||||
if err := s.Set("", "", global); err != nil {
|
||||
t.Fatalf("Set default: %v", err)
|
||||
}
|
||||
if got := s.PolicyFor("telegram", "-20"); got != global {
|
||||
t.Fatalf("全局默认生效值 = %+v, want %+v", got, global)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOwnerLaneStoreCorruptFileFallsBackToDefaults 钉住「配置坏了也不阻断」:
|
||||
// 畸形文件只退回默认档并留 WARN,绝不 panic、绝不返回错误阻断渠道启动。
|
||||
func TestOwnerLaneStoreCorruptFileFallsBackToDefaults(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config", "owner_lane.json")
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("{ this is not json "), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
s := NewOwnerLaneStore(path, nil)
|
||||
if got := s.PolicyFor("qq-obv11", "g"); got != DefaultOwnerLanePolicy() {
|
||||
t.Fatalf("坏配置下策略 = %+v, want 默认档", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOwnerLaneStoreConcurrentSetKeepsAllUpdates 钉住并发 Set 的安全性(review p1):
|
||||
// 落盘必须与内存改动**同一把锁**、且用**新建的临时文件**,否则两个写者会撞同一个
|
||||
// tmp 名把 JSON 写坏(⇒ load 退回默认「甲」、主人刚说的「少说点」白说且无提示),
|
||||
// 或者 rename 的完成顺序与新快照的先后不一致,丢掉后发生的更新。
|
||||
//
|
||||
// 修前必红(原实现:固定 `path+".tmp"` + mu.Unlock() 之后落盘),断言:
|
||||
// ① 每一轮结束后文件都是**合法 JSON**;
|
||||
// ② 本轮写过的**每一个**键都能在最终文件里读到(不丢更新)。
|
||||
func TestOwnerLaneStoreConcurrentSetKeepsAllUpdates(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "config", "owner_lane.json")
|
||||
s := NewOwnerLaneStore(path, nil)
|
||||
|
||||
const (
|
||||
rounds = 24
|
||||
writers = 24
|
||||
)
|
||||
for round := 0; round < rounds; round++ {
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < writers; i++ {
|
||||
key := fmt.Sprintf("g-%d-%d", round, i)
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
// 写者的返回值故意不看:修前它会因 tmp 被别的写者 rename 走而报错,
|
||||
// 但那正是本用例要靠**文件内容**抓住的失败。
|
||||
_ = s.Set("qq-obv11", key, OwnerLanePolicy{ReplyToUnaddressed: true, MinReplyGapSec: i})
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("round %d: 读回 owner_lane.json 失败:%v", round, err)
|
||||
}
|
||||
var f OwnerLaneFile
|
||||
if err := json.Unmarshal(raw, &f); err != nil {
|
||||
t.Fatalf("round %d: 并发落盘把配置写坏了(文件不是合法 JSON):%v\n内容=%q", round, err, raw)
|
||||
}
|
||||
for i := 0; i < writers; i++ {
|
||||
key := OwnerLaneGroupKey("qq-obv11", fmt.Sprintf("g-%d-%d", round, i))
|
||||
if _, ok := f.Groups[key]; !ok {
|
||||
t.Fatalf("round %d: 丢更新 —— 键 %s 不在最终文件里(并发的某次 Set 没落盘)", round, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
// memorytidy_save.go 是 `$DATA_DIR/config/memory-tidy.json` 的**写路径**(面板调优)。
|
||||
//
|
||||
// 为什么需要它(主人 2026-10-07 的两条口径,逐字):「`.env` 只做兜底用 在除了第1次
|
||||
// 部署之后 后端自动创建 独立的面板配置文件之后就走面板配置文件。这些键优先从面板
|
||||
// 配置上取」+「频率必须调 可以调到 1 小时或者三小时 或者动态分配,比如白天是间隔短
|
||||
// 一点 晚上到了静默期稍微长一点」。在这条写路径出现之前,那个文件**只有读路径**
|
||||
// (LoadMemoryTidyTuning + 启动播种 SeedMemoryTidy):主人要改频率只能改文件 + 重启。
|
||||
//
|
||||
// 它与读取侧(memorytidy.go)是**同一份文件形状的唯一两个门**:decodeMemoryTidy
|
||||
// 仍是判「这份文件能不能被读走」的唯一那一行代码,写路径不另立一套判定。
|
||||
//
|
||||
// 四条口径(与既有的 SaveChannels / SaveWebSearch 同族同手法):
|
||||
//
|
||||
// 1. **补丁语义**:调用方给的是「要设的键(原样 JSON 值)+ 要删的键」,不是整份
|
||||
// 文档。盘上其它键(`t1Judge` / `autoApplyPerRound` …以及将来新增的键)**原样
|
||||
// 保留** —— 一次面板保存绝不把主人手写进文件的其它旋钮抹掉。
|
||||
// 2. **值不变就不落盘**:合并后的文档与盘上那份**语义相同**(同一个 MarshalIndent
|
||||
// 口径比较,而不是比原始字节)时**一个字节都不写**、连 mtime 都不动 —— 主人的
|
||||
// 手写排版不该因为一次空操作被重排。
|
||||
// 3. **读不走的文件拒绝覆盖**:文件存在但不是 JSON 对象 / 结构解不开 ⇒ 返回
|
||||
// `ErrInvalidMemoryTidy`,调用方应**原样报错并拒绝写**(盘上此刻运行期正整体
|
||||
// 回落 `.env`/代码默认,盖掉它只会掩盖问题)。
|
||||
// 4. **原子写**:temp + rename(文件 0600 / 目录 0755,与 SeedMemoryTidy、
|
||||
// SaveWebSearch 一致):并发读者要么看到旧文件、要么看到新文件,不会读到半个
|
||||
// JSON;且**先写好再换名**,任何一步失败都不会留下半份配置。
|
||||
//
|
||||
// 格式上的代价(如实登记):补丁经 `map[string]json.RawMessage` 落盘 ⇒ 键序变成
|
||||
// 字典序、缩进统一为 2 空格。每个键的值一个不差,但人工排过版的文件在**真正发生
|
||||
// 改动时**会被重排一次(口径 2 保证「没改动就不重排」)。
|
||||
package runtimeconfig
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// memoryTidySaveFileMode 是落盘文件的权限(只给跑服务的账号读)。
|
||||
memoryTidySaveFileMode = 0o600
|
||||
// memoryTidySaveDirMode 是 `$DATA_DIR/config/` 的建目录权限。
|
||||
memoryTidySaveDirMode = 0o755
|
||||
)
|
||||
|
||||
// PatchMemoryTidyTuning 把一组**已经过调用方校验**的键值并进
|
||||
// `memory-tidy.json` 并原子写回,返回落盘后**重新读回**的调优结构。
|
||||
//
|
||||
// set 的键是「要写的键 → 原样 JSON 值」(调用方负责值的类型/取值域校验:那是 API
|
||||
// 契约的一部分,属于网关侧;本函数只管**文件形状与落盘**)。clear 是要从文件里删掉
|
||||
// 的键(= 回到下一档兜底)。两者都为空时**不落盘**。
|
||||
//
|
||||
// 返回 (tuning, wrote, err):
|
||||
// - tuning 是**重新读盘**的结果(真值回显,不是「我以为写成了什么」);
|
||||
// - wrote 报告这次是否真的写了字节(内容语义未变时为 false);
|
||||
// - err 非 nil 时**盘上一个字节都没动**;错误链里带 ErrInvalidMemoryTidy 表示
|
||||
// 「盘上那份读不走」⇒ 调用方应拒绝覆盖并如实报错。
|
||||
func PatchMemoryTidyTuning(path string, set map[string]json.RawMessage, clear []string) (MemoryTidyTuning, bool, error) {
|
||||
doc, err := readMemoryTidyDocForPatch(path)
|
||||
if err != nil {
|
||||
return MemoryTidyTuning{}, false, err
|
||||
}
|
||||
before := canonicalMemoryTidyJSON(doc)
|
||||
merged := make(map[string]json.RawMessage, len(doc)+len(set))
|
||||
for key, value := range doc {
|
||||
merged[key] = value
|
||||
}
|
||||
for key, value := range set {
|
||||
merged[key] = value
|
||||
}
|
||||
for _, key := range clear {
|
||||
delete(merged, key)
|
||||
}
|
||||
raw, err := json.MarshalIndent(merged, "", " ")
|
||||
if err != nil {
|
||||
return MemoryTidyTuning{}, false, fmt.Errorf("runtimeconfig: marshal memory tidy: %w", err)
|
||||
}
|
||||
wrote := false
|
||||
if !bytes.Equal(raw, before) {
|
||||
if err := writeMemoryTidyTuningFile(path, raw); err != nil {
|
||||
return MemoryTidyTuning{}, false, err
|
||||
}
|
||||
wrote = true
|
||||
}
|
||||
tuning, err := LoadMemoryTidyTuning(path)
|
||||
if err != nil {
|
||||
// 刚落盘就读不回来:这是本函数自己的错误(写出去的内容必须能被自己的读取
|
||||
// 路径读走),如实报错,绝不回一份「成功」。
|
||||
return MemoryTidyTuning{}, wrote, fmt.Errorf("runtimeconfig: reread %s after patch: %w", path, err)
|
||||
}
|
||||
return tuning, wrote, nil
|
||||
}
|
||||
|
||||
// readMemoryTidyDocForPatch 读盘上的文档(键 → 原样 JSON 值)并确认**运行期读得走**。
|
||||
//
|
||||
// 缺失/全空白 ⇒ 空文档(= 还没配过,与 LoadMemoryTidyTuning 同义,不是错误);
|
||||
// 存在但不是 JSON 对象、或整体结构解不开(类型写错)⇒ ErrInvalidMemoryTidy
|
||||
// (口径 3:拒绝覆盖)。
|
||||
func readMemoryTidyDocForPatch(path string) (map[string]json.RawMessage, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return map[string]json.RawMessage{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("runtimeconfig: read %s: %w", path, err)
|
||||
}
|
||||
if strings.TrimSpace(string(raw)) == "" {
|
||||
return map[string]json.RawMessage{}, nil
|
||||
}
|
||||
// 与读取路径**同一行代码**判「这份文件能不能被读走」:一处说坏了、另一处说没事
|
||||
// 就是自相矛盾(写入方会据此把一份坏配置盖掉)。
|
||||
if _, err := decodeMemoryTidy(raw, path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
doc := map[string]json.RawMessage{}
|
||||
if err := json.Unmarshal(raw, &doc); err != nil {
|
||||
return nil, fmt.Errorf("%w: decode %s as object: %w", ErrInvalidMemoryTidy, path, err)
|
||||
}
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
// canonicalMemoryTidyJSON 返回文档的规范化字节(与落盘同一个 MarshalIndent 口径),
|
||||
// 供「值有没有变」的语义比较用。序列化失败时返回 nil —— 那只可能让比较不相等
|
||||
// (= 走正常落盘路径),不会误判成「没变」。
|
||||
func canonicalMemoryTidyJSON(doc map[string]json.RawMessage) []byte {
|
||||
raw, err := json.MarshalIndent(doc, "", " ")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// writeMemoryTidyTuningFile 原子写出文件(口径 4):建目录 → 写临时文件 → rename。
|
||||
func writeMemoryTidyTuningFile(path string, raw []byte) error {
|
||||
if dir := filepath.Dir(path); dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, memoryTidySaveDirMode); err != nil {
|
||||
return fmt.Errorf("runtimeconfig: mkdir %s: %w", dir, err)
|
||||
}
|
||||
}
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, raw, memoryTidySaveFileMode); err != nil {
|
||||
return fmt.Errorf("runtimeconfig: write %s: %w", tmp, err)
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
return fmt.Errorf("runtimeconfig: rename %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package runtimeconfig
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件锁定 `memory-tidy.json` **写路径**(PatchMemoryTidyTuning)的四条口径:
|
||||
// ① 补丁语义:只改提交的键,盘上其它键(t1Judge 等)**原样保留**;
|
||||
// ② 值没变 ⇒ 一个字节都不写(连 mtime 都不动);
|
||||
// ③ 盘上那份**读不走**时拒绝覆盖(ErrInvalidMemoryTidy),且**盘上不动**;
|
||||
// ④ 原子写:temp + rename,成功之后不留 `.tmp`,文件权限 0600(Windows 不校验)。
|
||||
|
||||
// patchTuning 是测试用的补丁构造:键 → 原样 JSON 值。
|
||||
func patchTuning(t *testing.T, pairs map[string]any) map[string]json.RawMessage {
|
||||
t.Helper()
|
||||
out := make(map[string]json.RawMessage, len(pairs))
|
||||
for k, v := range pairs {
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal %s: %v", k, err)
|
||||
}
|
||||
out[k] = raw
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestPatchMemoryTidyTuning_MergesAndKeepsOtherKeys 补丁只动提交的键,其余键原样保留;
|
||||
// 返回值是**重新读盘**的结果(真值),不是「我以为写成了什么」。
|
||||
func TestPatchMemoryTidyTuning_MergesAndKeepsOtherKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := writeMemoryTidyFile(t, `{"t1Judge": false, "t1PerWindow": 120, "intervalSec": 3600}`)
|
||||
tuning, wrote, err := PatchMemoryTidyTuning(path, patchTuning(t, map[string]any{"intervalSec": 7200}), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("patch: %v", err)
|
||||
}
|
||||
if !wrote {
|
||||
t.Fatal("值变了却没写盘")
|
||||
}
|
||||
if tuning.IntervalSec == nil || *tuning.IntervalSec != 7200 {
|
||||
t.Fatalf("返回值不是读回的真值:%+v", tuning.IntervalSec)
|
||||
}
|
||||
if tuning.T1Judge == nil || *tuning.T1Judge {
|
||||
t.Fatalf("没提交的 t1Judge 被动了:%+v", tuning.T1Judge)
|
||||
}
|
||||
doc := map[string]json.RawMessage{}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(raw, &doc); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if string(doc["intervalSec"]) != "7200" || string(doc["t1PerWindow"]) != "120" {
|
||||
t.Fatalf("盘上内容 = %s", raw)
|
||||
}
|
||||
if string(doc["t1Judge"]) != "false" {
|
||||
t.Fatalf("不受管的 t1Judge 被抹掉了:%s", raw)
|
||||
}
|
||||
// 清除哨兵:clear 里的键从盘上消失(= 回到下一档兜底)。
|
||||
if _, _, err := PatchMemoryTidyTuning(path, nil, []string{"intervalSec"}); err != nil {
|
||||
t.Fatalf("clear: %v", err)
|
||||
}
|
||||
doc = map[string]json.RawMessage{}
|
||||
raw, _ = os.ReadFile(path)
|
||||
if err := json.Unmarshal(raw, &doc); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if _, ok := doc["intervalSec"]; ok {
|
||||
t.Fatalf("clear 之后盘上仍有 intervalSec:%s", raw)
|
||||
}
|
||||
if _, ok := doc["t1Judge"]; !ok {
|
||||
t.Fatalf("clear 抹掉了别的键:%s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPatchMemoryTidyTuning_NoChangeWritesNothing 值没变 ⇒ 不写盘、不重排格式。
|
||||
func TestPatchMemoryTidyTuning_NoChangeWritesNothing(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := writeMemoryTidyFile(t, `{"intervalSec": 3600}`)
|
||||
before, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
tuning, wrote, err := PatchMemoryTidyTuning(path, patchTuning(t, map[string]any{"intervalSec": 3600}), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("patch: %v", err)
|
||||
}
|
||||
if wrote {
|
||||
t.Fatal("值没变却写了盘")
|
||||
}
|
||||
after, _ := os.ReadFile(path)
|
||||
if string(after) != string(before) {
|
||||
t.Fatalf("值没变却改了盘上内容:\nbefore=%s\nafter=%s", before, after)
|
||||
}
|
||||
if tuning.IntervalSec == nil || *tuning.IntervalSec != 3600 {
|
||||
t.Fatalf("没写盘也要回读真值:%+v", tuning.IntervalSec)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPatchMemoryTidyTuning_RefusesUnreadableFile 盘上那份运行期读不走时拒绝覆盖:
|
||||
// 返回 ErrInvalidMemoryTidy(调用方据此回 409),且**盘上一个字节都不动**。
|
||||
func TestPatchMemoryTidyTuning_RefusesUnreadableFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, body := range []string{`{"intervalSec": "3600"}`, `not json at all`, `[1, 2]`} {
|
||||
body := body
|
||||
t.Run(body, func(t *testing.T) {
|
||||
path := writeMemoryTidyFile(t, body)
|
||||
_, wrote, err := PatchMemoryTidyTuning(path, patchTuning(t, map[string]any{"intervalSec": 3600}), nil)
|
||||
if !errors.Is(err, ErrInvalidMemoryTidy) {
|
||||
t.Fatalf("err = %v, want ErrInvalidMemoryTidy", err)
|
||||
}
|
||||
if wrote {
|
||||
t.Fatal("读不走的文件被写入了")
|
||||
}
|
||||
raw, _ := os.ReadFile(path)
|
||||
if string(raw) != body {
|
||||
t.Fatalf("坏文件被改动了:%s", raw)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPatchMemoryTidyTuning_CreatesMissingFileAtomically 文件缺失 ⇒ 建目录并原子写出
|
||||
// (temp + rename):成功之后不留 `.tmp`,权限 0600(Windows 不校验)。
|
||||
func TestPatchMemoryTidyTuning_CreatesMissingFileAtomically(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := filepath.Join(t.TempDir(), "config", memoryTidyFileName)
|
||||
tuning, wrote, err := PatchMemoryTidyTuning(path, patchTuning(t, map[string]any{"tempTtlSec": 604800}), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("patch: %v", err)
|
||||
}
|
||||
if !wrote {
|
||||
t.Fatal("缺失文件时应当写出")
|
||||
}
|
||||
if tuning.TempTtlSec == nil || *tuning.TempTtlSec != 604800 {
|
||||
t.Fatalf("回读真值 = %+v", tuning.TempTtlSec)
|
||||
}
|
||||
if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) {
|
||||
t.Fatalf("原子写留下了临时文件:err=%v", err)
|
||||
}
|
||||
if info, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
} else if info.Mode().Perm() != memoryTidySaveFileMode && runtime.GOOS != "windows" {
|
||||
t.Fatalf("文件权限 = %v, want %v", info.Mode().Perm(), memoryTidySaveFileMode)
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@
|
||||
// 残缺);调用方记一条 WARN 即可 —— 播种失败时运行期仍按 `.env` + 代码默认跑,
|
||||
// 行为与播种前逐字相同。
|
||||
//
|
||||
// 日志**只打路径、原因与键名,不打值**:这十键今天都不是凭据,但「配置日志不打值」
|
||||
// 日志**只打路径、原因与键名,不打值**:这十一键今天都不是凭据,但「配置日志不打值」
|
||||
// 是本仓既有口径(materialize.go 的「绝不打 apiKey」),这里从一开始就照做,免得
|
||||
// 日后加一个敏感键时要回头改日志。
|
||||
package runtimeconfig
|
||||
@@ -157,7 +157,7 @@ func writeMemoryTidySeed(log *slog.Logger, path string, seed MemoryTidyTuning, r
|
||||
return MemoryTidySeed{Written: true}, nil
|
||||
}
|
||||
|
||||
// memoryTidySeedComplete 报告种子是否「十键齐全且值可用」。
|
||||
// memoryTidySeedComplete 报告种子是否「十一键齐全且值可用」。
|
||||
//
|
||||
// 为什么要「齐全」而不是「非 nil 就放行」:MemoryTidyTuning 的指针语义是「nil = 没配」,
|
||||
// 一份缺键的种子写下去虽然与「不写」在读取端等价,但它会让文件里出现 null —— 这份文件
|
||||
|
||||
Loaded 100 of 441 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user