Compare commits
231
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
21b906ffb0 | ||
|
|
b26bc55a15 | ||
|
|
1cd38514f3 | ||
|
|
625ae33c1e | ||
|
|
68ba85389b | ||
|
|
b01e2ddb1a | ||
|
|
0c5fa7b2ae | ||
|
|
9a3b3cff8b | ||
|
|
ae68bb0241 | ||
|
|
b4d55f412e | ||
|
|
d81635672e | ||
|
|
23b6e3fe1e | ||
|
|
d680390300 | ||
|
|
6deab2e836 | ||
|
|
811e463cea | ||
|
|
789e3af74e | ||
|
|
da1d23dd23 | ||
|
|
f01fd7db20 | ||
|
|
1107b2a717 | ||
|
|
74c9f2d8df | ||
|
|
a19d12b228 | ||
|
|
6ac214366e | ||
|
|
d27c1d4e33 | ||
|
|
b989f0bb0c | ||
|
|
2dae4693be | ||
|
|
a684328b76 | ||
|
|
987cefdf28 | ||
|
|
a4efbffe60 | ||
|
|
cc566354fa | ||
|
|
71438f666c | ||
|
|
1b4d0a552f | ||
|
|
5b8c7a3ef3 | ||
|
|
33ea7a830f | ||
|
|
ce9af42945 | ||
|
|
8e3311cea2 | ||
|
|
90f430216a | ||
|
|
f310173b4c | ||
|
|
6fe68ecfac | ||
|
|
74e171bc04 | ||
|
|
c68387a69a | ||
|
|
8325d1bb8c | ||
|
|
6d92536276 | ||
|
|
353293e807 | ||
|
|
bff57bd1ae | ||
|
|
7f63c5bec4 | ||
|
|
93c5a4c46f | ||
|
|
23dc9a3e5e | ||
|
|
77dbf9b1f7 | ||
|
|
2c56bc9704 | ||
|
|
bc1dee3f56 | ||
|
|
e5e5207c94 | ||
|
|
c46053e276 | ||
|
|
8051906afa | ||
|
|
378faf212e | ||
|
|
25aa50e532 | ||
|
|
c512609c63 | ||
|
|
aa0bf8b5a0 | ||
|
|
ef8fc66e54 | ||
|
|
f847769698 | ||
|
|
9126d653e5 |
No files matched your search
+11
-5
@@ -183,8 +183,13 @@ LLM_LOCAL_ENABLED=false
|
|||||||
|
|
||||||
# ---- 记忆嵌入(可选,真语义) ----
|
# ---- 记忆嵌入(可选,真语义) ----
|
||||||
# 不配置时使用内置 n-gram / FTS 向量(dim=256)。配置后必须真实走语义路径。
|
# 不配置时使用内置 n-gram / FTS 向量(dim=256)。配置后必须真实走语义路径。
|
||||||
# EMBEDDING_API_URL 是【完整 /embeddings 端点】(不是 base)——例:Ollama / llama.cpp = http://localhost:11434/v1/embeddings
|
# EMBEDDING_API_URL 是【完整 /embeddings 端点】(不是 base)——例:Ollama / llama.cpp = http://127.0.0.1:11434/v1/embeddings
|
||||||
# EMBEDDING_API_URL=http://localhost:11434/v1/embeddings
|
# ⚠️ 回环地址请写具体的 IPv4 字面量 127.0.0.1,**不要写 localhost**:本机 embedding 服务
|
||||||
|
# (llama.cpp / ollama)默认只绑 IPv4 回环,而 localhost 会先解析到 IPv6 ::1 —— 那里没有
|
||||||
|
# 监听,轻则多一次无效拨号(实测 localhost 约 0.2s,127.0.0.1 约 0.001s),重则在「::1 被
|
||||||
|
# 静默丢弃」的环境里等到系统连接超时,白白吃掉整条入库审查的预算。
|
||||||
|
# (客户端已对 localhost / 0.0.0.0 / 空主机做兜底规范化,但直接写对更省事、更好排查。)
|
||||||
|
# EMBEDDING_API_URL=http://127.0.0.1:11434/v1/embeddings
|
||||||
# EMBEDDING_API_KEY=
|
# EMBEDDING_API_KEY=
|
||||||
# EMBEDDING_MODEL=Qwen3-Embedding-0.6B # 推荐(Q8_0 ~639MB、dim=1024、ModelScope 国内可下)
|
# EMBEDDING_MODEL=Qwen3-Embedding-0.6B # 推荐(Q8_0 ~639MB、dim=1024、ModelScope 国内可下)
|
||||||
|
|
||||||
@@ -234,9 +239,10 @@ WEB_SEARCH_PROVIDER=searxng
|
|||||||
# 只在该家「不可用」(无 key / 鉴权失败 / 限流 / 额度 / 网络)时前进一格;结果里会
|
# 只在该家「不可用」(无 key / 鉴权失败 / 限流 / 额度 / 网络)时前进一格;结果里会
|
||||||
# 如实标注**实际应答**的 provider,绝不拿兜底冒充主力。空结果**不**触发前进。
|
# 如实标注**实际应答**的 provider,绝不拿兜底冒充主力。空结果**不**触发前进。
|
||||||
# WEB_SEARCH_FALLBACK=zhipu,bocha,searxng
|
# WEB_SEARCH_FALLBACK=zhipu,bocha,searxng
|
||||||
# WEB_SEARCH_MAX_RESULTS = 单次检索返回条数上限。默认 5;provider 自身的条数上限
|
# WEB_SEARCH_MAX_RESULTS = 单次检索返回条数上限(**播种层**的键:面板文件一旦落盘,
|
||||||
# (如博查 50、IQS 固定 10)优先于本键的下限。
|
# 就以 $DATA_DIR/config/plugins/web-search.json 的 maxResults 为准,改面板即生效、不用重启)。
|
||||||
WEB_SEARCH_MAX_RESULTS=5
|
# 默认 10;provider 自身的条数上限(如博查 50、IQS 固定 10)优先于本键的下限。
|
||||||
|
WEB_SEARCH_MAX_RESULTS=10
|
||||||
# WEB_SEARCH_ENDPOINT = **searxng provider** 的端点(SearXNG 兼容,GET + format=json)。
|
# WEB_SEARCH_ENDPOINT = **searxng provider** 的端点(SearXNG 兼容,GET + format=json)。
|
||||||
# 默认值 http://127.0.0.1:38471 是**本机兜底**:scripts/searxng/settings.yml 就是它的
|
# 默认值 http://127.0.0.1:38471 是**本机兜底**:scripts/searxng/settings.yml 就是它的
|
||||||
# 配套配置(docker 起法见该文件头注释),无需任何第三方 API key、开箱即用 —— 默认指向
|
# 配套配置(docker 起法见该文件头注释),无需任何第三方 API key、开箱即用 —— 默认指向
|
||||||
|
|||||||
@@ -36,8 +36,10 @@
|
|||||||
- 目的:防主会话上下文超限触发压缩;压缩后靠本文件 + `DevDocs/工作小结/00-当前态.md` 恢复锚点,继续当监工。
|
- 目的:防主会话上下文超限触发压缩;压缩后靠本文件 + `DevDocs/工作小结/00-当前态.md` 恢复锚点,继续当监工。
|
||||||
|
|
||||||
## 9. 全球红线(跨里程碑,必须遵守;违反即架构偏离)
|
## 9. 全球红线(跨里程碑,必须遵守;违反即架构偏离)
|
||||||
- **单二进制、单端口 `:8080`、零跨进程 IPC**:模块间同步走**进程内接口 + 依赖注入**(直接方法调用),异步走**进程内 `bus`**;**不另开监听端口、不 HTTP 互调**。
|
- **单二进制、单端口 `:8080`、零跨进程 IPC**(🔴 **本条约束的是 Cyrene 框架自身,不约束扩展插件**):框架模块间同步走**进程内接口 + 依赖注入**(直接方法调用),异步走**进程内 `bus`**;**框架自身不另开监听端口、不 HTTP 互调**。
|
||||||
- ♢ **已登记例外(主人 2026-10-04 裁决)**:`qq-obv11` 渠道的 **OneBot 反连(server)模式**需要独立 `net.Listen` 端口 —— **仅限该渠道的 server 模式**,且端口必须由配置**显式给出**(`listen_addr`),**不得默认开启**;其余渠道/模式一律不得新开监听端口。规格见 `design/08 §4`、`design/13 §0`。
|
- 🆕 **2026-10-07 主人口径补充(原「豁免」升格为一般规则)**:**扩展插件不受本条约束** —— `plugins/` 下的渠道适配器与工具、MCP server、以及插件自己拉起的本地伴随进程/服务,**可以开自己的监听端口、可以有自己的进程形态**。这**不是「例外」,是插件的正当能力**;原先只为 `qq-obv11` 登记的那条例外,**随之由「例外」变为本条一般规则的一个实例**(该渠道规格仍见 `design/08 §4`)。
|
||||||
|
- 🔴 **插件开端口的四条护栏(主人 2026-10-07 确认采纳)**:① 端口/地址**必须由配置显式给出**,**不得默认开启、不得写死**;② **默认绑 `127.0.0.1`**,要对外暴露必须由 `.env` 显式决定(与框架暴露面同一套口径);③ **必须登记日志来源与审计**(谁开的、绑在哪);④ **端口不得成为绕过身份/权限判定的入口** —— actor 元数据 + 白名单 + 策略引擎仍是唯一判据,端口不是后门。
|
||||||
|
- 「**零跨进程 IPC**」的原意**仍然有效**:**框架内部模块**不得为解耦而拆成多进程/多服务、不得 HTTP 硬编码 URL 互调(详见 `DevDocs/00-overview.md`)。**框架与插件之间的边界通信不在本条约束内**;但框架**不得**因此把自身模块拆出去变成插件。
|
||||||
- **身份识别靠 channel 层强类型 `actor` 元数据 + 策略引擎**,**绝不靠提示词/LLM 判断**(主人/群聊/陌生人判定与权限永远由 actor 元数据 + 白名单 + policy engine 决定)。
|
- **身份识别靠 channel 层强类型 `actor` 元数据 + 策略引擎**,**绝不靠提示词/LLM 判断**(主人/群聊/陌生人判定与权限永远由 actor 元数据 + 白名单 + policy engine 决定)。
|
||||||
- **人格 = 身份**(**不限制能力**);**行为模式 = 呈现**(≠边界,可热切换、不构成能力边界);**能力全局全能**;**前端(Web)是第一公民**。
|
- **人格 = 身份**(**不限制能力**);**行为模式 = 呈现**(≠边界,可热切换、不构成能力边界);**能力全局全能**;**前端(Web)是第一公民**。
|
||||||
- **IM 是镜像**:只 chat 文本,Web 不回流 IM;输出一律 chat 文本。
|
- **IM 是镜像**:只 chat 文本,Web 不回流 IM;输出一律 chat 文本。
|
||||||
@@ -79,6 +81,6 @@
|
|||||||
- 🔴 **等待子代理:不许用长 `WaitFor` 阻塞回合(主人 2026-10-04 反馈)**:主会话在 `WaitFor` 里等子代理时,**主人无法给任何补充输入** —— 等于把主人锁在门外。规矩:**派完就结束回合**(子代理的完成通知会自动唤醒主会话);确需同回合内等,**单次 `WaitFor` ≤ 60s**,且只用于「马上就要用它的结果」;等待期间若有并行工作,**先做工作再结束回合**,不要空等。
|
- 🔴 **等待子代理:不许用长 `WaitFor` 阻塞回合(主人 2026-10-04 反馈)**:主会话在 `WaitFor` 里等子代理时,**主人无法给任何补充输入** —— 等于把主人锁在门外。规矩:**派完就结束回合**(子代理的完成通知会自动唤醒主会话);确需同回合内等,**单次 `WaitFor` ≤ 60s**,且只用于「马上就要用它的结果」;等待期间若有并行工作,**先做工作再结束回合**,不要空等。
|
||||||
- 🔴 **遇到开发文档冲突 / 意义不明 / 规格矛盾时**:**阻塞会话**,用 `AskUserQuestion` 询问主人,**不得自行猜测**;文档清、不冲突则自主推进。
|
- 🔴 **遇到开发文档冲突 / 意义不明 / 规格矛盾时**:**阻塞会话**,用 `AskUserQuestion` 询问主人,**不得自行猜测**;文档清、不冲突则自主推进。
|
||||||
- 🔴 阶段验证:后端 `go build ./...` + `go vet ./...` + `go test ./...` 全绿 + windows/darwin 交叉编译;前端(M9+)`tsc --noEmit` + `vite build` 通过。
|
- 🔴 阶段验证:后端 `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」**——不得只在别处口头称完成。**禁止把「未验证」表述成「已修好」**。
|
- 🔴 **裁定必须带完成状态(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`(工作树内容不丢)。
|
- 🔴 **提交一律逐文件 `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`(工作树内容不丢)。
|
||||||
+510
-51
@@ -1,12 +1,12 @@
|
|||||||
# Cyrene v2 客户端 API 契约
|
# Cyrene v2 客户端 API 契约
|
||||||
|
|
||||||
> **契约版本:`v0.82`**(上一版 `v0.81`,其条目见 §6;`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 桥接等**跨平台客户端开发者**。
|
> **读者**:Web 前端 / 移动端原生客户端(含 Android)/ IM 桥接等**跨平台客户端开发者**。
|
||||||
> **唯一真源 = 当前代码**(`internal/services/gateway/*` 等)。本文档由代码逐条核对得出;与代码不符时**以代码为准**,并回改本文档。
|
> **唯一真源 = 当前代码**(`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,后端全量字段级文档)。归档的原因是**结构性臃肿 + 历史条目并列 + 长期漂移**;本文档**不复制**那段历史,需要考古时去归档。
|
> **旧的超详细版已归档、只读**:`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 **立即改本文档**;契约变更必须版本化,不破坏既有客户端。
|
> **维护铁律**:每实现/变更一个 REST 端点、WS 事件、admin API **立即改本文档**;契约变更必须版本化,不破坏既有客户端。
|
||||||
> **准确性 > 详尽度**:宁少写也别写错;**写不了确证的写「未确证」**,见 [§7](#7-在飞切片与未实现未确证)。
|
> **准确性 > 详尽度**:宁少写也别写错;**写不了确证的写「未确证」**,见 [§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.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.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` 语义都未改)。
|
> **v0.70 是精确化版(已实现,纯增量)**:`GET /api/v1/admin/backthink` 的 `urgency` **默认就是该轮实际生效的精确档位**(随记录落库),另加可选 `urgencyLowerBound:true` 专门标记**旧行的下限推导值**;实时 `backthink` 帧自本版起也带记录里的 `urgency`(§2.2 B8 / §4.10(b),并更正 §4.10(b) 原「实时帧不带这两键」的表述)。**无破坏性变更**(新键可选,老客户端不读即零影响;`urgency` 的既有取值域与 `quietHoursBypassed` 语义都未改)。
|
||||||
@@ -20,17 +20,26 @@
|
|||||||
|
|
||||||
> **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.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` **未动**。
|
||||||
|
|
||||||
|
> **v0.85 是「整理不再只产建议」版(已实现并推库 `6d92536`;**新增 1 条 writeAuth 端点 + 一处行为变更(整理默认自动落地)+ `link` 候选停产;无破坏性变更**)**:① **新增端点 `POST /api/v1/admin/memories/candidates/reject?action=&limit=`(B25)** —— 按**动作**批量把 `pending` 候选标成 `rejected`(**幂等、只改候选状态、绝不碰记忆行**)。`action` **必填**且是**闭集**(`merge|link|archive|reclassify|keep`,未知值 → `400 bad_request`);`limit` 缺省 **1000**、硬顶 **10000**、非整数 → `400`。`200 {data:{action,limit,total,rejected,truncated}}`(`total` 是命中筛选的**真**条数,`rejected` 只覆盖 `limit` 之内 ⇒ 不等时 `truncated:true`)。审计 `memory.tidy.reject`(只带计数)。语义见 **§4.15(e)**。② 🔴 **行为变更(默认生效):整理在轮末会「自动落地」两类高置信动作**,不再只产建议(主人 2026-10-07 口径「该删就删」)——**可自动的只有**:`merge`(**字面重复** ∨「事实集合等价 ∧ 相似度 ≥ 0.95」)、`archive`(理由 = `one_off_event` **且不像习惯**)。**不自动**:`reclassify`、低置信 merge、跨分区/跨角色、任何改正文、**受限来源(群聊/陌生人)**。六条护栏:软动作(正文不改、行不删)/ **每轮上限 50**(硬顶 500,超限如实 `truncated`)/ 审计 `memory.tidy.apply`(`auto=true`)/ **可 revert**(回滚路径与人工应用同一条)/ 幂等 / **一键关回只产建议**(`$DATA_DIR/config/memory-tidy.json` 的 `{"autoApply": false}`,**零新 env 键**)。⚠️ **面板侧的候选列表仍可能显示历史 `link` 行**,直到用 B25 清掉。③ **`link` 候选停产**:`link` 在 `design/23 §14` 里被列为**未细化**(既无落点也无数据模型),且 `applyable()` 只认 merge/archive/reclassify ⇒ **应用 link 恒 `skipped`,产出它只是往待办里灌噪音**。现在规则层不再产出(判 `keep`);`TidyLink` 常量保留**只为读历史行**。存量用 B25 清(主会话已执行)。④ **计数**:`writeAuth` 段 24 → **25**、管理路由总数 78 → **79**(`14 + 25 + 40`;`routes.go` 核:经 `writeAuth` 挂载的 admin 路由实有 **27**,按历史口径减 §2.4(a) 的 `channels/events` 与 `.../counters` ⇒ **25**)。⑤ ⚠️ **诚实边界**:**未部署**(要一次 `build` + `restart`);**自动档的真机行为未验**(不能重启后端);`reclassify` 仍全部人工;`TidyRuleVersion` **刻意未升版**(本次不改变任何判定结论,升版会让既有候选按幂等键复制出重复行)。
|
||||||
|
|
||||||
|
> **v0.84 是「显示名 + 段落标签硬约束」版(已实现并推库:`c46053e` 显示名 / `8051906` 段落修复 / `bc1dee3` provider 配置面去 env 化;**一处形状变更(插件动作响应)、一处新增硬约束;无破坏性变更**)**:① **`plugin.providers` 候选响应形状:裸字符串数组 → 对象数组 `{"options":[{"value":<id>,"label":<中文显示名>},…]}`**(§4.14(c))。修的是主人报障「中文搜索接口在管理后台下拉菜单里全给的英文名,你甚至不愿意带个括号把中文名写上去」—— 根因不是漏写文案,而是**整条链路没有「显示名」这个位置**(字段声明只有 `optionsFrom`,动作回的就是纯 id 数组)。🔴 **两条硬口径**:**(i) `label` 仅供显示,`value` 才是落盘值**(把 `label` 提交进 `provider` 会写坏配置文件);**(ii) 前端解码器兼容三种形状**(裸字符串 / `{value,label}` / `{id}`)⇒ 仍返回裸字符串的其他插件与老声明**零影响**。表格单元格按 `value→label` 索引显示、查不到回落原值;多选(降级链)改为「候选开关(显示 label、写 value)+ 落盘值输入框」。② **产出侧新硬约束(§4.16)**:助手文本(落库 / 对外 / IM 镜像)**不得出现未配对的段落标签**(落单 `<p …>` 或 `</p>`)—— `一个 <p> = 一段`,**段落不嵌套**。修的是安卓端登记的 `<p><p>正文</p>` 畸形(他们按非贪婪契约必然把 `<p>` 显示给用户);**实测两半都是产出侧造成**:规整器**原样放过**该形状,且会在「末段漏 `</p>`」时**亲手套出**它(并顺带修掉一个幂等漏洞:落单 `</p>` 会越跑越多)。🔴 **历史行不回填**,消费方**仍应保留**剥落单标签的容错。③ **模型面登记(非客户端契约面,供跨端对照)**:`web-fetch` 的**浏览器渲染降级档已接线**(`design/28 §1 M5 / §8 B5`),其返回体用 **`fetchedBy`**(`direct` / `browser`)**如实标注正文实际来自哪条路**,两条档的失败各自留痕(`browserError` / `directError`),**绝不静默替换**;被 SSRF 拒绝的目标**不换路**。④ **计数**:本版**无新端点** ⇒ §2.0 权限矩阵维持 **14 + 24 + 40 = 78**。⑤ ⚠️ **诚实边界**:**`web-search` 的真机工具路径在登记时仍有一处未部署的修复**(智谱对「热搜/资讯」类查询返回的条目 `link` 整批为空,旧判据把「无链接」当「不是结果」整批丢光 ⇒ 工具报 `no results` 而面板探针报 `ok` —— 因为探针**不过 `Finalize`**,对这一类缺陷**结构性失明**)。修复已推库(`2c56bc9`:无链接条目照原样保留、只有 link/title/content/snippet 全空的空壳行才不算结果),**要等下次 `build` + `restart` 才真机生效**。**教训登记**:**别拿面板探针通不通来推断工具路径通不通**。
|
||||||
|
|
||||||
|
> **v0.83 是记忆整理「应用面」版(已实现并推库:切片 TIDY-1 `454ddb0` + 读侧 status 闸 `9126d65` + 网关端到端回归 `f847769`;**纯增量、无破坏性变更**)**:① **新增 5 条 `writeAuth` 端点**,补上「整理只产建议、永远不落地」这个两天没闭环的缺口 —— **B20** `GET /api/v1/admin/memories/candidates`(候选列表)、**B21** `POST …/candidates/{id}/apply`(单条应用)、**B22** `POST …/candidates/apply?runId=&limit=`(批量应用;`runId` 省略 = **全部待定**)、**B23** `POST …/candidates/{id}/revert`(单条回滚)、**B24** `POST …/memories/tidy/run`(手动**真跑**一轮:写候选 + 留痕,**不自动应用**)。语义与形状见 **§4.15**。🔴 **三条硬口径(客户端按此编程)**:**(i) 幂等不是错误** —— 已决定过的候选回 `200` + `status:"skipped"`,界面**不得**画成失败;**(ii) 超限必须如实** —— 批量回执的 `total` 是**真正命中筛选的待定候选数**,而 `applied+skipped+failed` 只覆盖 `limit` 之内,两者不等时 `truncated:true`(不许把「本轮 100 / 共 342」画成「全部完成」);**(iii) 永不物理删除** —— merge / archive 都是**软动作**(只改 `status`,正文一个字不改、行一行不删),因此 `revert` 无损可逆。② **读侧 status 闸(行为修复,形状零变化)**:记忆的**四条列表读路径**(`ListByPersona` / `List` / `ListScope` / `ListProject`)现在只回 **`status='active'`** 的行 ⇒ 被软合并(`'merged'`)与被归档(`'archived'`)的记忆**不再被召回、不再进上下文、也不再出现在面板 `GET /admin/memories`**(此前它们照样出现 ⇒ 等于「清了也白清」)。🔴 **刻意不过闸的两处**:按 id 的 `Get`(撤销软合并必须先读得到被吸收的行)与 **`GET /admin/memories/reviews`**(审查留痕**本身没有 status 概念**,且它是软删行的查看与撤销入口 —— 加闸等于审计自我抹除)。③ **调度不再饿死(内部行为,对客户端零形状影响)**:整理从「ticker 从进程启动算起、重启十几次就永远走不完 24h」改成**到点即跑 + catch-up**,以 `memory_tidy_runs` 的留痕行为**持久事实**判「上次到底跑过没有」,并把手动真跑端点(B24)做成 panic button。④ **计数**:`writeAuth` 段 19 → **24**、管理路由总数 73 → **78**(`14 + 24 + 40`;按 `routes.go` 逐条核对:经 `writeAuth` 挂载的 **admin** 路由实有 **26** 条,按历史口径减去 §2.4(a) 的 `channels/events` 与 `.../counters` ⇒ **24**)。⚠️ **诚实边界(别当已验证)**:**生产库全程零动作** —— 1191 行记忆与 77 条待定候选**都没有被应用或清理**(等主人拍板,见 §4.15(d));**面板上的真机端到端未跑**(前端操作面在本版登记时尚未落地,并行切片在做);旧候选的 `run_id` 是空串 ⇒ 「**按 run 批量**」找不到它们,要清它们必须走**全部待定**那一档;候选表的 `run_id` 列目前**只在存储包前滚**(`memory.NewStore`),**未**同步进迁移 `0019`。
|
||||||
|
>
|
||||||
> **v0.82 是三面增量版(① 已实现(切片 Y1 `215eedf`);② 已实现(切片 Y3 `4213b26`);③ 形状已定、代码在树(切片 Y4 `e0a6bca` + 组合根挂点);**纯增量、无破坏性变更**)**:① **`tool_execution.end` 的 `data` 新增可选 `resultSummary`(string)** —— 工具卡从此能在**实时**显示「这次调用拿到了什么」,不必等事后翻回看面。它修的是「实时只看到工具名与状态、结果要事后才知道」。🔴 **缺席语义(安卓端按此编程)**:**键在场 ⇒ 渲染结果区;键缺席 ⇒ 只显示工具名与状态** —— **内容与回看面 `toolCalls[].resultSummary` 同源同口径**(同一份**已脱敏**文本 `trace.RedactText`:凭据形状清洗 + 截断)、**同一个上限 `≤500` rune**(超长按 rune 截断、末位 `…`);**空值 ⇒ 整键缺席,绝不用空串顶位**(空串会让客户端渲染出一个空的结果区)。**只有「真的没有结果」才缺席**:调用成功但结果为空(nil / 空串)、或错误文本本身为空;`tool_execution.start` / `.update` **不带**该键。⚠️ **失败也带**(本版口径):调用失败 / 取消 / 超时的 `.end` 帧带的是**该次错误文本的脱敏渲染** —— 与回看面(那里失败调用回的是图上的 `error` 摘要)**给同一份说法**,客户端不必为「实时」与「回看」写两套判据。详见 **§3.4.2**。② **自排的成本可见面**(切片 Y3,design/12 §N C.5/C.6)—— `GET /api/v1/admin/backthink` 新增**可选** `?trigger=` **服务端筛选**(取值域 = **闭集**:三个内建标签 + 本服务订阅的事件主题,真源 `backthink.TriggerFilterValues()`;值**未知或为空** ⇒ **`400 invalid_trigger`**,**绝不静默忽略** —— 静默忽略会让面板在显示全部的同时声称「正在筛选」;合法值**下推 SQL**,因此 `limit` 数的是**匹配**条数);新增 1 条 `writeAuth` 只读端点 **`GET /api/v1/admin/backthink/self-wake`**(§2.2 B19 / §4.10(e),**三态** `available`/`running`/`reason` + `gate` 快照 + `configFile`)。⚠️ **`trigger` 的「显示口径」仍是开放的**(客户端按字面值显示、不做闭集校验)—— **筛选白名单**与**显示取值域**是两件事,别混(见 §4.10(d))。③ **`browser` 插件管理面板(B4)**(切片 Y4,design/28 §8)—— `GET /api/v1/admin/panels` 的面板目录新增 `browser` / `settings`(「浏览器与截图」,4 个区块:配置表单 + 状态 / 指纹 / 截图三张只读表),该插件声明**六个动作**(3 读 3 写,见 §4.14(d));**不开任何新路由**(走既有 A14 通用动作分发),因此 §2.0 计数**只**因 ② 的 self-wake 端点 +1。**模型面**(非客户端契约面)另有一条:`browser` 工具的 `screenshot` 返回值新增 **`turnAttachment`**(闭集 `queued` / `no-session` / `outbox-full` / `disabled`)。**④ 本版把 §4.10(d) 一句已成假话的口径改正**:`request_think` **已接线**(`cmd/cyrene/wire_services.go` 的 `wireSelfWakeTool`;启动日志 `self wake tool registered tool=request_think`)⇒ `trigger="self_wake"` 与 4 条 `backthink.self_wake.*` 审计动词**在生产里会真的出现**。**⑤ 计数**:`writeAuth` 段 18 → **19**、管理路由总数 72 → **73**(`14 + 19 + 40`)。⚠️ **登记时的工作树状态(如实)**:③ 的**组合根两个挂点**(`cmd/cyrene/wire_frontend.go` 的 `wireBrowserPanel(hub, a, a.log)`、`cmd/cyrene/wire_runtime.go` 的 `TransformContext: a.browserShotTransform(visionResolver),`)与 ① 的**失败也带摘要**(`internal/runtime/loop/tool_frame_summary.go`)在本版登记时**仍在工作树、未提交** ⇒ 提交前,跑着的后端里**没有**这些行为;提交后请按 `git show` 逐字复核。
|
> **v0.82 是三面增量版(① 已实现(切片 Y1 `215eedf`);② 已实现(切片 Y3 `4213b26`);③ 形状已定、代码在树(切片 Y4 `e0a6bca` + 组合根挂点);**纯增量、无破坏性变更**)**:① **`tool_execution.end` 的 `data` 新增可选 `resultSummary`(string)** —— 工具卡从此能在**实时**显示「这次调用拿到了什么」,不必等事后翻回看面。它修的是「实时只看到工具名与状态、结果要事后才知道」。🔴 **缺席语义(安卓端按此编程)**:**键在场 ⇒ 渲染结果区;键缺席 ⇒ 只显示工具名与状态** —— **内容与回看面 `toolCalls[].resultSummary` 同源同口径**(同一份**已脱敏**文本 `trace.RedactText`:凭据形状清洗 + 截断)、**同一个上限 `≤500` rune**(超长按 rune 截断、末位 `…`);**空值 ⇒ 整键缺席,绝不用空串顶位**(空串会让客户端渲染出一个空的结果区)。**只有「真的没有结果」才缺席**:调用成功但结果为空(nil / 空串)、或错误文本本身为空;`tool_execution.start` / `.update` **不带**该键。⚠️ **失败也带**(本版口径):调用失败 / 取消 / 超时的 `.end` 帧带的是**该次错误文本的脱敏渲染** —— 与回看面(那里失败调用回的是图上的 `error` 摘要)**给同一份说法**,客户端不必为「实时」与「回看」写两套判据。详见 **§3.4.2**。② **自排的成本可见面**(切片 Y3,design/12 §N C.5/C.6)—— `GET /api/v1/admin/backthink` 新增**可选** `?trigger=` **服务端筛选**(取值域 = **闭集**:三个内建标签 + 本服务订阅的事件主题,真源 `backthink.TriggerFilterValues()`;值**未知或为空** ⇒ **`400 invalid_trigger`**,**绝不静默忽略** —— 静默忽略会让面板在显示全部的同时声称「正在筛选」;合法值**下推 SQL**,因此 `limit` 数的是**匹配**条数);新增 1 条 `writeAuth` 只读端点 **`GET /api/v1/admin/backthink/self-wake`**(§2.2 B19 / §4.10(e),**三态** `available`/`running`/`reason` + `gate` 快照 + `configFile`)。⚠️ **`trigger` 的「显示口径」仍是开放的**(客户端按字面值显示、不做闭集校验)—— **筛选白名单**与**显示取值域**是两件事,别混(见 §4.10(d))。③ **`browser` 插件管理面板(B4)**(切片 Y4,design/28 §8)—— `GET /api/v1/admin/panels` 的面板目录新增 `browser` / `settings`(「浏览器与截图」,4 个区块:配置表单 + 状态 / 指纹 / 截图三张只读表),该插件声明**六个动作**(3 读 3 写,见 §4.14(d));**不开任何新路由**(走既有 A14 通用动作分发),因此 §2.0 计数**只**因 ② 的 self-wake 端点 +1。**模型面**(非客户端契约面)另有一条:`browser` 工具的 `screenshot` 返回值新增 **`turnAttachment`**(闭集 `queued` / `no-session` / `outbox-full` / `disabled`)。**④ 本版把 §4.10(d) 一句已成假话的口径改正**:`request_think` **已接线**(`cmd/cyrene/wire_services.go` 的 `wireSelfWakeTool`;启动日志 `self wake tool registered tool=request_think`)⇒ `trigger="self_wake"` 与 4 条 `backthink.self_wake.*` 审计动词**在生产里会真的出现**。**⑤ 计数**:`writeAuth` 段 18 → **19**、管理路由总数 72 → **73**(`14 + 19 + 40`)。⚠️ **登记时的工作树状态(如实)**:③ 的**组合根两个挂点**(`cmd/cyrene/wire_frontend.go` 的 `wireBrowserPanel(hub, a, a.log)`、`cmd/cyrene/wire_runtime.go` 的 `TransformContext: a.browserShotTransform(visionResolver),`)与 ① 的**失败也带摘要**(`internal/runtime/loop/tool_frame_summary.go`)在本版登记时**仍在工作树、未提交** ⇒ 提交前,跑着的后端里**没有**这些行为;提交后请按 `git show` 逐字复核。
|
||||||
|
|
||||||
> **v0.81 是「她自排轮的用户可见面 + 时间轴保留期信号 + `round` 跨重启单调修复」版(切片 C1-1 `94da6bc` / C1-2 `ed50053`(自排)+ 切片 X1(时间轴两处真缺陷);**无新端点、无破坏性变更**)**:① **`GET /api/v1/admin/backthink` 的行 `trigger` 新增取值 `"self_wake"`**(= 这一轮是**她自己排起**的思考,`design/12 §N C`)—— `trigger` 的取值域**不封闭**(`heartbeat` 周期心跳 / `startup` 启动轮 / 事件轮 = 事件主题 / `self_wake` 自排),客户端**按字面值显示、不要当闭集校验**(见 §4.10(d))。⚠️ **可达性**:她的入口工具 `request_think` **尚未接线** ⇒ 该取值与 ② 的动词在生产里**目前不会出现**(形状先登记)。—— ✅ **本条已过期,已由 v0.82 ④ 改正**:`request_think` 已接线(`cmd/cyrene/wire_services.go` 的 `wireSelfWakeTool`,切片 X2 `2d211c2`),生产里**会真的出现**。② **新增 4 条审计动词** `backthink.self_wake.{queued,coalesced,capped,denied}`,经既有 `GET /api/v1/admin/observability/audit`(B9)可查,`who` 恒为 `backthink`(加性,见 §4.10(d))。③ **时间轴新增 `retentionBoundaryReached`(bool)并与 `truncated` 分意**:`truncated:true` **只**表示「本次不是全部」(分页未到底 **或** 落盘源未接线),**不能**据此断言「更早的轮次已被保留期清扫」——那件事由 **`retentionBoundaryReached:true`** 单独回答(判据三条缺一不可 + **三个盲区如实登记**,见 §4.11.1(a)(b)-3)。④ **`round` 跨重启不再重复**(发布器先从落盘播种计数器):修的是「重启后轮次号从 1 重来 ⇒ 时间轴把重启前后两轮并成一块」的真机缺陷,契约里「`round` 每会话单调」这句**从此真的成立**(见 §4.11.1(e))。⑤ **计数**:本版**无新端点** ⇒ §2.0 权限矩阵维持 **14 + 18 + 40 = 72**(§2.2 段内 B1–B18 共 **18** 条)。⚠️ **同批对客户端零形状影响(如实说明)**:技能渐进披露接线(L2 `6475eb8`)、示例技能与端到端验收(V3 `4a04ad4`)、浏览器 B1 接线 + B2 薄层工具面(V5 `f1fbc24`)**都不改变任何 REST / WS / admin 形状**。⚠️ 括注原文写「浏览器是**模型面工具**,不进客户端契约面」—— **对 B1/B2 成立,但已部分过期**:v0.82 的 **B4**(浏览器管理面板,切片 Y4)**进**客户端契约面(`GET /admin/panels` 多一条目录项 + 六个动作走既有 A14),见 **§4.14(d)**。⚠️ **③④ 的形状取自切片 X1 的工作树实现,登记时尚未提交** ⇒ 提交后请按 `git show` 复核键名与判据。
|
> **v0.81 是「她自排轮的用户可见面 + 时间轴保留期信号 + `round` 跨重启单调修复」版(切片 C1-1 `94da6bc` / C1-2 `ed50053`(自排)+ 切片 X1(时间轴两处真缺陷);**无新端点、无破坏性变更**)**:① **`GET /api/v1/admin/backthink` 的行 `trigger` 新增取值 `"self_wake"`**(= 这一轮是**她自己排起**的思考,`design/12 §N C`)—— `trigger` 的取值域**不封闭**(`heartbeat` 周期心跳 / `startup` 启动轮 / 事件轮 = 事件主题 / `self_wake` 自排),客户端**按字面值显示、不要当闭集校验**(见 §4.10(d))。⚠️ **可达性**:她的入口工具 `request_think` **尚未接线** ⇒ 该取值与 ② 的动词在生产里**目前不会出现**(形状先登记)。—— ✅ **本条已过期,已由 v0.82 ④ 改正**:`request_think` 已接线(`cmd/cyrene/wire_services.go` 的 `wireSelfWakeTool`,切片 X2 `2d211c2`),生产里**会真的出现**。② **新增 4 条审计动词** `backthink.self_wake.{queued,coalesced,capped,denied}`,经既有 `GET /api/v1/admin/observability/audit`(B9)可查,`who` 恒为 `backthink`(加性,见 §4.10(d))。③ **时间轴新增 `retentionBoundaryReached`(bool)并与 `truncated` 分意**:`truncated:true` **只**表示「本次不是全部」(分页未到底 **或** 落盘源未接线),**不能**据此断言「更早的轮次已被保留期清扫」——那件事由 **`retentionBoundaryReached:true`** 单独回答(判据三条缺一不可 + **三个盲区如实登记**,见 §4.11.1(a)(b)-3)。④ **`round` 跨重启不再重复**(发布器先从落盘播种计数器):修的是「重启后轮次号从 1 重来 ⇒ 时间轴把重启前后两轮并成一块」的真机缺陷,契约里「`round` 每会话单调」这句**从此真的成立**(见 §4.11.1(e))。⑤ **计数**:本版**无新端点** ⇒ §2.0 权限矩阵维持 **14 + 18 + 40 = 72**(§2.2 段内 B1–B18 共 **18** 条)。⚠️ **同批对客户端零形状影响(如实说明)**:技能渐进披露接线(L2 `6475eb8`)、示例技能与端到端验收(V3 `4a04ad4`)、浏览器 B1 接线 + B2 薄层工具面(V5 `f1fbc24`)**都不改变任何 REST / WS / admin 形状**。⚠️ 括注原文写「浏览器是**模型面工具**,不进客户端契约面」—— **对 B1/B2 成立,但已部分过期**:v0.82 的 **B4**(浏览器管理面板,切片 Y4)**进**客户端契约面(`GET /admin/panels` 多一条目录项 + 六个动作走既有 A14),见 **§4.14(d)**。⚠️ **③④ 的形状取自切片 X1 的工作树实现,登记时尚未提交** ⇒ 提交后请按 `git show` 复核键名与判据。
|
||||||
|
|
||||||
> **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.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**。
|
> **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**。
|
||||||
|
|
||||||
@@ -41,7 +50,7 @@
|
|||||||
### 0.1 基址与版本
|
### 0.1 基址与版本
|
||||||
|
|
||||||
- **Base URL**:`http://<host>:<PORT>`。`PORT` 默认 **8080**(`env:"PORT" default:"8080"`);生产一般经反代/HTTPS,运行实例可能自定义端口。
|
- **Base URL**:`http://<host>:<PORT>`。`PORT` 默认 **8080**(`env:"PORT" default:"8080"`);生产一般经反代/HTTPS,运行实例可能自定义端口。
|
||||||
- **唯一对外端口**:单二进制、单端口(运行实例实测 `:10942`)。REST、WS、管理 API、静态资源**全在同一端口**;不另有监听端口(唯一登记例外:`qq-obv11` 渠道 OneBot 反连 server 模式,见 design/08 §4,与客户端契约无关)。
|
- **框架唯一对外端口**:单二进制、单端口(运行实例实测 `:10942`)。REST、WS、管理 API、静态资源**全在同一端口**。🆕 **2026-10-07 口径补充(v0.88)**:这条「单端口」约束**只约束 Cyrene 框架自身**;**扩展插件**(渠道适配器 / 工具 / MCP server / 插件自拉的本地伴随服务)**可以开自己的监听端口、可以有自己的进程形态** —— 那些端口**不在本契约范围内**(客户端一律只连这一个网关端口)。原「`qq-obv11` 渠道 OneBot 反连 server 模式」那条例外随之**由「例外」升格为一般规则的实例**(见 `design/08 §4`)。
|
||||||
- **版本前缀**:
|
- **版本前缀**:
|
||||||
- 用户侧 REST:`/api/v1/{resource}`
|
- 用户侧 REST:`/api/v1/{resource}`
|
||||||
- 管理侧 REST:`/api/v1/admin/*`
|
- 管理侧 REST:`/api/v1/admin/*`
|
||||||
@@ -114,7 +123,7 @@
|
|||||||
| U3 | GET | `/api/v1/personas` | 任意已认证 | 200 | 角色目录 `{data:[{id,name,character,isDefault,mode,memoryNamespace,avatar?,chatBackground?,mood}]}`。`mood` 为空 = 该角色暂无被跟踪情绪(不伪造)。**不含** admin 专属 `thinking` 旋钮。 |
|
| 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-枚举权威表)。 |
|
| 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 + 审计**。 |
|
| 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`。 |
|
| 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`。**带审计**。 |
|
| 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 者可取,勿外泄。 |
|
| U9 | GET | `/api/v1/files/{name}` | **无需**(能力 URL) | 200 / 404 | 上传文件读取。`name` 仅接受「32 位小写 hex + 白名单扩展名」;路径穿越全形态拒绝。**GET 不鉴权**是该 URL 为 128-bit 随机能力地址(`<img>`/provider 无法带 token);任何持有 URL 者可取,勿外泄。 |
|
||||||
@@ -146,10 +155,15 @@
|
|||||||
| 档位 | 中间件 | 条数 | 说明 |
|
| 档位 | 中间件 | 条数 | 说明 |
|
||||||
|---|:--:|:--:|---|
|
|---|:--:|:--:|---|
|
||||||
| **只读监控档** | `viewerAuth`(owner/admin/viewer) | **14** | 非敏感投影:无凭据、无他人账号数据、无消息正文。**v0.78 新增两条**:面板接入点快照(A13)与插件动作分发(A14,**写门落在 handler 内**,见 A14 行注) |
|
| **只读监控档** | `viewerAuth`(owner/admin/viewer) | **14** | 非敏感投影:无凭据、无他人账号数据、无消息正文。**v0.78 新增两条**:面板接入点快照(A13)与插件动作分发(A14,**写门落在 handler 内**,见 A14 行注) |
|
||||||
| **owner/admin 读档** | `writeAuth`(owner/admin) | **19** | viewer 不得看到的一切:记忆、LLM 栈、账号/key、后台思考、审计链、渠道消息日志、完整会话导出、配置事实面(v0.68)、最近一轮链路图快照(v0.71)、记忆整理试算(v0.75)、入库前审查留痕与回滚(**v0.79**,B16/B17)、链路图时间轴(**v0.80**,B18)、**自排闸只读快照(v0.82,B19)** |
|
| **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** | 带审计的策略写、每个获取凭据的渠道端点、其余全部变更 |
|
| **owner-only 档** | `ownerAuth`(owner) | **40** | 带审计的策略写、每个获取凭据的渠道端点、其余全部变更 |
|
||||||
|
| **合计** | — | **85** | `14 + 31 + 40`(v0.95) |
|
||||||
|
|
||||||
**管理路由总数 = 14 + 19 + 40 = 73 条。**
|
**管理路由总数 = 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。
|
> **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。
|
||||||
|
|
||||||
@@ -162,7 +176,9 @@
|
|||||||
|
|
||||||
> **v0.82 计数变更(实数,已按 `routes.go` 核对)**:`writeAuth` 段新增一条(**B19 `GET /admin/backthink/self-wake`**,切片 Y3)⇒ 段内 **18 → 19**、总数 **72 → 73**。三张清单的行数同步为 **§2.1 14 行**(A1–A14)/ **§2.2 19 行**(B1–B19)/ **§2.3 40 行**(C1–C40)。**核对方式(可复现)**:`internal/services/gateway/routes.go` 里经 `writeAuth` 挂载的 **admin** 路由**实有 21 条**,按历史口径减去 §2.4(a) 的 `channels/events` 与 `.../counters` ⇒ **19**,与 §2.2 的行数一致;`viewerAuth` 挂载 **14**、`ownerAuth` 挂载 **40**(逐条列举见 `grep -c 'mux.Handle("[A-Z]* /api/v1/admin.*<middleware>'`)。⚠️ **本版另一个「看似该 +1、其实不动计数」的面**:`browser` 插件的六个动作(§4.14(d))**不开任何新路由** —— 它走**既有** A14 `POST /admin/plugins/{id}/actions/{action}`,因此 §2.1 的 14 与总数都**不变**(A14 那一行本就写明「写门落在 handler 内」,动作名由**声明**白名单决定,声明多一个插件不新增路由)。
|
> **v0.82 计数变更(实数,已按 `routes.go` 核对)**:`writeAuth` 段新增一条(**B19 `GET /admin/backthink/self-wake`**,切片 Y3)⇒ 段内 **18 → 19**、总数 **72 → 73**。三张清单的行数同步为 **§2.1 14 行**(A1–A14)/ **§2.2 19 行**(B1–B19)/ **§2.3 40 行**(C1–C40)。**核对方式(可复现)**:`internal/services/gateway/routes.go` 里经 `writeAuth` 挂载的 **admin** 路由**实有 21 条**,按历史口径减去 §2.4(a) 的 `channels/events` 与 `.../counters` ⇒ **19**,与 §2.2 的行数一致;`viewerAuth` 挂载 **14**、`ownerAuth` 挂载 **40**(逐条列举见 `grep -c 'mux.Handle("[A-Z]* /api/v1/admin.*<middleware>'`)。⚠️ **本版另一个「看似该 +1、其实不动计数」的面**:`browser` 插件的六个动作(§4.14(d))**不开任何新路由** —— 它走**既有** A14 `POST /admin/plugins/{id}/actions/{action}`,因此 §2.1 的 14 与总数都**不变**(A14 那一行本就写明「写门落在 handler 内」,动作名由**声明**白名单决定,声明多一个插件不新增路由)。
|
||||||
|
|
||||||
> **计数口径(v0.71 明确)**:总数是 §2.1 / §2.2 / §2.3 三张清单里**列出的行数**(与 v0.68 起的口径一致)。⚠️ `internal/services/gateway/routes.go` 里实际经 `writeAuth` 挂载的 **admin** 路由比 §2.2 多:v0.67 登记的 `GET /admin/channels/events` 与 `.../counters`(见 §2.4(a))**未计入本表**——这是历史口径,本版未改,也**未**把它算进上表的 **writeAuth 条数**(v0.82 为 **19**)。逐条以代码为准。
|
> **v0.83 计数变更(实数,已按 `routes.go` 逐条核对)**:`writeAuth` 段新增**五**条(**B20–B24**:候选列表 / 单条应用 / 批量应用 / 单条回滚 / 手动真跑,切片 TIDY-1)⇒ 段内 **19 → 24**、总数 **73 → 78**。三张清单行数同步为 **§2.1 14 行** / **§2.2 24 行**(B1–B24)/ **§2.3 40 行**。**核对方式(可复现)**:`grep -c 'mux.Handle("[A-Z]* /api/v1/admin.*writeAuth' internal/services/gateway/routes.go` = **26**,按历史口径减去 §2.4(a) 的 `channels/events` 与 `.../counters` ⇒ **24**(`viewerAuth` **14** / `ownerAuth` **40**,与上表一致)。
|
||||||
|
|
||||||
|
> **计数口径(v0.71 明确)**:总数是 §2.1 / §2.2 / §2.3 三张清单里**列出的行数**(与 v0.68 起的口径一致)。⚠️ `internal/services/gateway/routes.go` 里实际经 `writeAuth` 挂载的 **admin** 路由比 §2.2 多:v0.67 登记的 `GET /admin/channels/events` 与 `.../counters`(见 §2.4(a))**未计入本表**——这是历史口径,本版未改,也**未**把它算进上表的 **writeAuth 条数**(v0.83 为 **24**)。逐条以代码为准。
|
||||||
|
|
||||||
> ⚠️ **与归档版不一致(已更正)**:`Docs/archive/api-client.v0.65.md` §2.0 写「12 + 11 + 38 = 61」(其 v0.62 条目又写「12 + 10 + 38 = 60」,自相矛盾)。**以代码为准 = 12 + 11 + 40 = 63**。
|
> ⚠️ **与归档版不一致(已更正)**:`Docs/archive/api-client.v0.65.md` §2.0 写「12 + 11 + 38 = 61」(其 v0.62 条目又写「12 + 10 + 38 = 60」,自相矛盾)。**以代码为准 = 12 + 11 + 40 = 63**。
|
||||||
|
|
||||||
@@ -187,7 +203,7 @@
|
|||||||
|
|
||||||
> **A13 / A14 的角色门依据**(`routes.go` 紧邻注释):快照端点对 viewer 开放是因为它回的**本来就是被裁掉写面的只读投影**;动作端点的读来源动作对 viewer 可见,**写门落在 handler 内**(路由中间件只能有一道门)。`panels_changed` 帧(§3.4.5)**不进** `frameReachesAll` 白名单 ⇒ **viewer 收不到该帧**,需自行刷新/轮询重拉 A13。
|
> **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`)
|
||||||
|
|
||||||
| # | 方法 | 路径 | 要点 |
|
| # | 方法 | 路径 | 要点 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
@@ -211,6 +227,21 @@
|
|||||||
| B18 | GET | `/api/v1/admin/flow/timeline` | **链路图时间轴 / 多轮串联(v0.80 新增,切片 W1;v0.81 增量)**,只读、不审计。`?sessionId=`(省略 = 主会话 `web`)+ `?limit=`(缺省 **50**、> **200** 钳到 200、非整数 `400 bad_request`、`≤0` 同缺省)+ `?before=`(**游标**:取开始时刻**严格早于**它的轮次;省略 = 最新那一页;取值 = 响应里的 `nextBefore`,也接受 RFC3339 或 Unix 毫秒,非法 → `400 bad_request`)。响应 `{data:{sessionId,source,rounds[],nextBefore,hasMore,truncated,retentionBoundaryReached,retentionHours,retentionEnabled}}`:`rounds` **按时间正序**(最早在前),每项 `{turnId,round,startedAt,endedAt,lanes[]}`(`lanes[]` 每项 `{turnId,graph}`,`graph` 与 `graph_update` 同形);`round` 为 `null` = **未知**(老行),`endedAt` 为空串 = **尚未收尾**(在飞轮);`source` ∈ `persisted`(本页至少一轮来自落盘)/`live`(本页全部来自内存)/`none`。🆕 **v0.81(切片 X1)**:① 新增 **`retentionBoundaryReached`**(bool)= 「该会话确有更早的轮次已被保留期清扫」——**与 `truncated` 分意**,只有它 `true` 时面板才可说「更早的轮次已过保留期」(判据/盲区见 **§4.11.1(a)**);② **`round` 跨重启不再重复**(发布器先从落盘播种计数器,见 **§4.11.1(e)**)⇒ 按 `round` 分组不再把重启前后两轮并成一块。未接线(内存 + 落盘都没接)→ `500 flow_unavailable`。语义与空态见 **§4.11.1** |
|
| B18 | GET | `/api/v1/admin/flow/timeline` | **链路图时间轴 / 多轮串联(v0.80 新增,切片 W1;v0.81 增量)**,只读、不审计。`?sessionId=`(省略 = 主会话 `web`)+ `?limit=`(缺省 **50**、> **200** 钳到 200、非整数 `400 bad_request`、`≤0` 同缺省)+ `?before=`(**游标**:取开始时刻**严格早于**它的轮次;省略 = 最新那一页;取值 = 响应里的 `nextBefore`,也接受 RFC3339 或 Unix 毫秒,非法 → `400 bad_request`)。响应 `{data:{sessionId,source,rounds[],nextBefore,hasMore,truncated,retentionBoundaryReached,retentionHours,retentionEnabled}}`:`rounds` **按时间正序**(最早在前),每项 `{turnId,round,startedAt,endedAt,lanes[]}`(`lanes[]` 每项 `{turnId,graph}`,`graph` 与 `graph_update` 同形);`round` 为 `null` = **未知**(老行),`endedAt` 为空串 = **尚未收尾**(在飞轮);`source` ∈ `persisted`(本页至少一轮来自落盘)/`live`(本页全部来自内存)/`none`。🆕 **v0.81(切片 X1)**:① 新增 **`retentionBoundaryReached`**(bool)= 「该会话确有更早的轮次已被保留期清扫」——**与 `truncated` 分意**,只有它 `true` 时面板才可说「更早的轮次已过保留期」(判据/盲区见 **§4.11.1(a)**);② **`round` 跨重启不再重复**(发布器先从落盘播种计数器,见 **§4.11.1(e)**)⇒ 按 `round` 分组不再把重启前后两轮并成一块。未接线(内存 + 落盘都没接)→ `500 flow_unavailable`。语义与空态见 **§4.11.1** |
|
||||||
| B19 | GET | `/api/v1/admin/backthink/self-wake` | **自排闸只读快照(v0.82 新增,切片 Y3;design/12 §N C.5 的读侧)**,只读、不审计、不写库。**无参数**。响应 `{data:{available,running,reason?,configFile?,gate?}}` —— **三态必须可区分**(面板**不得**把「看不到」画成「0 次」):① `available:false` ⇒ 本进程给不出快照(后台思考未接线 / 实现不提供读口 / 闸不在),此时 **`gate` 缺席**、`reason` 说明原因(**HTTP 仍是 `200`**,不假装有一个 0/0 的闸);② `available:true, running:false` ⇒ 已接线但不在运行,`gate` **仍带**配置面数字(上限/窗口/代码默认值),`reason` 说明「此刻她排不出新的一轮」;③ `available:true, running:true` ⇒ 正常态,`reason` 缺席。`gate` 快照 = `{pending,used,limit,windowSec,defaultLimit,defaultWindowSec,limitIsDefault,windowIsDefault}`(`used` 与工具收据 `SelfWakeReceipt.Used` **同一口径**;`limit`/`windowSec` 是**进程生效**值,已 normalize)。🔴 **`limitIsDefault` / `windowIsDefault` 的语义**:报告生效值**恰好等于**代码默认值 —— **不是**「配置文件里没写这个键」的断言(网关**不读** `backthink.json`,与 config-facts 的 `isDefault` 同一条诚实口径:无从区分「配了 4」与「没配」)。`configFile` = **相对落点**(`config/backthink.json`,相对 `$DATA_DIR`)。⚠️ **它不含敏感信息、也不是写口**:改上限/窗口要改配置文件 + 重启(面板写路径**未做**)。语义与三态口径见 **§4.10(e)**。✅ **真机已核(路由存在性,无需凭据)**:`:10942` 上 `POST /api/v1/admin/backthink/self-wake` → **`405` + `Allow: GET, HEAD`**、`GET` 同路径无 token → **`401`**(负对照:同法打一个不存在的 admin 路径 → `404`)⇒ 这条路由**确在**运行中的产物里(⚠️ **不代表**已用真凭据拿到过 `data`;后者**未验**) |
|
| B19 | GET | `/api/v1/admin/backthink/self-wake` | **自排闸只读快照(v0.82 新增,切片 Y3;design/12 §N C.5 的读侧)**,只读、不审计、不写库。**无参数**。响应 `{data:{available,running,reason?,configFile?,gate?}}` —— **三态必须可区分**(面板**不得**把「看不到」画成「0 次」):① `available:false` ⇒ 本进程给不出快照(后台思考未接线 / 实现不提供读口 / 闸不在),此时 **`gate` 缺席**、`reason` 说明原因(**HTTP 仍是 `200`**,不假装有一个 0/0 的闸);② `available:true, running:false` ⇒ 已接线但不在运行,`gate` **仍带**配置面数字(上限/窗口/代码默认值),`reason` 说明「此刻她排不出新的一轮」;③ `available:true, running:true` ⇒ 正常态,`reason` 缺席。`gate` 快照 = `{pending,used,limit,windowSec,defaultLimit,defaultWindowSec,limitIsDefault,windowIsDefault}`(`used` 与工具收据 `SelfWakeReceipt.Used` **同一口径**;`limit`/`windowSec` 是**进程生效**值,已 normalize)。🔴 **`limitIsDefault` / `windowIsDefault` 的语义**:报告生效值**恰好等于**代码默认值 —— **不是**「配置文件里没写这个键」的断言(网关**不读** `backthink.json`,与 config-facts 的 `isDefault` 同一条诚实口径:无从区分「配了 4」与「没配」)。`configFile` = **相对落点**(`config/backthink.json`,相对 `$DATA_DIR`)。⚠️ **它不含敏感信息、也不是写口**:改上限/窗口要改配置文件 + 重启(面板写路径**未做**)。语义与三态口径见 **§4.10(e)**。✅ **真机已核(路由存在性,无需凭据)**:`:10942` 上 `POST /api/v1/admin/backthink/self-wake` → **`405` + `Allow: GET, HEAD`**、`GET` 同路径无 token → **`401`**(负对照:同法打一个不存在的 admin 路径 → `404`)⇒ 这条路由**确在**运行中的产物里(⚠️ **不代表**已用真凭据拿到过 `data`;后者**未验**) |
|
||||||
|
|
||||||
|
| B20 | GET | `/api/v1/admin/memories/candidates` | **整理候选列表(v0.83 新增,切片 TIDY-1)**,只读、不审计。`?status=`(**省略 ⇒ 默认只回 `pending`**;落库取值是**闭集** `pending` / `applied` —— `revert` 会把候选退回 `pending`;⚠️ **`rejected` 尚未实现**,没有 reject 端点)+ `?limit=`(缺省 **100**、`>500` 钳到 500、非整数 → `400 bad_request`)。响应 `{data:{candidates:[…],count:N}}`,`candidates` **恒为非 null 数组**(空数组 = 「真没有候选」,与「没接线」的错误响应必须能区分)。每条:`id` / `action` ∈ `merge\|link\|archive\|reclassify` / `status` / `sourceIds[]`(恒为数组)/ `targetId?` / `personaId` / `scope` / `projectId?` / `kind?` / `proposedContent?` / `mergedContent?` / `reason` / `ruleVersion` / `runId?` / `scoreMin` / `scoreMax` / `createdAt`(**UTC RFC3339**)/ `evidence`。失败:未接线 → `500 tidy_unavailable`(**绝不回空数组**假装没有候选)、读取失败 → `500 tidy_candidates_failed`。语义见 **§4.15(a)** |
|
||||||
|
| B21 | POST | `/api/v1/admin/memories/candidates/{id}/apply` | **应用一条候选(v0.83 新增,切片 TIDY-1;design/23 §7/§8)** —— 唯二真正改记忆的粒度之一。**无请求体**。`200 {data:{candidateId,action,status,reason?,targetId?,affectedIds[]}}`:`status` ∈ `applied`(真改了)/ `skipped`(**幂等**:这条已被决定过 ⇒ 不是错误,界面不得报红)。🔴 **别把这个 `status` 与 B20 候选行上的 `status` 混读**:这里是**本次操作的结果**(`memory.TidyApplyStatus`,另有 `failed` 一档),B20 那个是**候选的落库状态**(闭集 `pending`/`applied`)。`affectedIds` = 这次真的被改动的 `memory_items` id(**恒为数组**)。失败:候选不存在 → `404 not_found`;未接线 → `500 tidy_unavailable`;写失败 → `500 tidy_apply_failed`。**落审计** `memory.tidy.apply`(`result` 以 `ok` 开头 + 动作/结果/受影响行数;**绝不带正文**)。🔴 **软动作**:merge = 被吸收行 `status='merged'` + `merged_into=<存活 id>`,存活行 `merged_from` 追加 + `merge_count`+1 + `confidence` 取较大者 + `updated_at` 刷新;archive = `status='archived'`;**正文一个字不改、行一行不删** |
|
||||||
|
| B22 | POST | `/api/v1/admin/memories/candidates/apply?runId=&limit=` | **批量应用候选(v0.83 新增,切片 TIDY-1)**。`runId` 给出 ⇒ 只应用**该轮产出的**候选;**省略 ⇒ 全部待定**(面板的「清全部」档)。`limit` 省略/空 ⇒ 领域侧默认 **100**;`>500` **钳到 500**;非整数或负数 → `400 bad_request`。`200 {data:{runId?,limit,total,truncated,applied,skipped,failed,merged,archived,reclassified,results[]}}`。🔴 **两条读数铁律**:① `total` = **真正命中筛选的待定候选数**,而 `applied+skipped+failed` 只覆盖 `limit` 之内 ⇒ 两者不等时 **`truncated:true`**(客户端**必须**如实显示「本轮处理 N / 共 M」);② `results[]` 逐条给 `status`(**失败隔离**:一条失败不拖累其余)。失败:`runId` 查不到(**留痕行**不存在)→ `404 tidy_run_not_found`(与「这一轮没有待定候选」**不是一回事**);未接线 → `500 tidy_unavailable`;写失败 → `500 tidy_apply_failed`。**落审计** `memory.tidy.apply`(带 run_id / limit / total / truncated / applied / skipped / failed)。⚠️ **旧候选(TIDY-1 之前产出的 77 条)的 `run_id` 是空串** ⇒ 「按 run 批量」找不到它们,要清它们只能走**全部待定**这一档 |
|
||||||
|
| B23 | POST | `/api/v1/admin/memories/candidates/{id}/revert` | **回滚一条已应用的候选(v0.83 新增,切片 TIDY-1;design/23 §8「回滚」)**。**无请求体**。响应形状与 B21 **逐字同形**(`{candidateId,action,status,reason?,targetId?,affectedIds[]}`)。**无损可逆**:被吸收行复原为 `active` + `merged_into=''`、从存活行 `merged_from` 摘除、`merge_count` 减 1(下限 1)、候选退回 `pending`;**行永不物理删除**。失败:候选不存在 → `404 not_found`;未接线 → `500 tidy_unavailable`;写失败 → `500 tidy_revert_failed`。**落审计** `memory.tidy.revert`(与 B17 的 `memory.review.revert` **不同**:B17 按**记忆 id** 撤销 D3 的软合并,本条按**候选 id**,故不复用同一动词) |
|
||||||
|
| 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 响应形状不变。
|
> **读取 `GET /admin/observability/{logs,metrics,trace}` 兼任可观测推送的订阅握手**(该凭据的连接全部断开即退订)。REST 响应形状不变。
|
||||||
|
|
||||||
### 2.3 owner-only(40 条,`ownerAuth`)
|
### 2.3 owner-only(40 条,`ownerAuth`)
|
||||||
@@ -235,8 +266,8 @@
|
|||||||
| C16 | POST | `/api/v1/admin/llm` | 添加/更新 LLM provider(写 `models.json` + 热重载) |
|
| C16 | POST | `/api/v1/admin/llm` | 添加/更新 LLM provider(写 `models.json` + 热重载) |
|
||||||
| C17 | POST | `/api/v1/admin/llm/test` | 连通性测试 |
|
| C17 | POST | `/api/v1/admin/llm/test` | 连通性测试 |
|
||||||
| C18 | POST | `/api/v1/admin/llm/reset-from-env` | 重置为 env 默认(切片 R1,v0.62;body 必须 `{"confirm":true}`,否则 `400` 且一个字节都不动;审计 `llm.resetFromEnv`) |
|
| C18 | POST | `/api/v1/admin/llm/reset-from-env` | 重置为 env 默认(切片 R1,v0.62;body 必须 `{"confirm":true}`,否则 `400` 且一个字节都不动;审计 `llm.resetFromEnv`) |
|
||||||
| C19 | POST | `/api/v1/admin/channels/open` | 打开渠道(v0.66 增 `accountId`,见下注) |
|
| C19 | POST | `/api/v1/admin/channels/open` | 打开渠道(v0.66 增 `accountId`,见下注;**v0.90 起 `connected` 为真实状态**,并按需回可选 `pending`/`ready`/`reason`) |
|
||||||
| C20 | POST | `/api/v1/admin/channels/close` | 关闭渠道(v0.66 增 `accountId`,见下注) |
|
| C20 | POST | `/api/v1/admin/channels/close` | 关闭渠道(v0.66 增 `accountId`,见下注;**v0.90 起 `connected` 取真实 `IsConnected()`**) |
|
||||||
| C21 | POST | `/api/v1/admin/channels/test` | 渠道连通测试 |
|
| C21 | POST | `/api/v1/admin/channels/test` | 渠道连通测试 |
|
||||||
| C22 | GET | `/api/v1/admin/channels/{platform}/login-qr` | 取登录二维码(获取凭据,故 owner-only) |
|
| C22 | GET | `/api/v1/admin/channels/{platform}/login-qr` | 取登录二维码(获取凭据,故 owner-only) |
|
||||||
| C23 | GET | `/api/v1/admin/channels/{platform}/login-status` | 查扫码登录状态 |
|
| C23 | GET | `/api/v1/admin/channels/{platform}/login-status` | 查扫码登录状态 |
|
||||||
@@ -271,9 +302,9 @@
|
|||||||
> - `id` 与 `empty=1` 同时给出 ⇒ `400 bad_request`;**两者都缺 ⇒ `400 bad_request`**(绝不把「id 缺失」默认解释成删无 id 条目 —— 那是一次没有字面证据的批量删除)。`?empty=` / `?empty=0` / `?empty=false` 一律是「否」。
|
> - `id` 与 `empty=1` 同时给出 ⇒ `400 bad_request`;**两者都缺 ⇒ `400 bad_request`**(绝不把「id 缺失」默认解释成删无 id 条目 —— 那是一次没有字面证据的批量删除)。`?empty=` / `?empty=0` / `?empty=false` 一律是「否」。
|
||||||
> - 响应:`{data:{persisted, path, platform, accountId, empty, removed}}`(`empty`/`removed` 是 v0.66 新增);`removed` = 实际删除的条目数。删空后平台键整个消失(与按 id 删除的收尾口径一致)。
|
> - 响应:`{data:{persisted, path, platform, accountId, empty, removed}}`(`empty`/`removed` 是 v0.66 新增);`removed` = 实际删除的条目数。删空后平台键整个消失(与按 id 删除的收尾口径一致)。
|
||||||
|
|
||||||
### 2.4 v0.67 登记的管理面(渠道事件查询 + 账号在线状态)—— **(a) 已实现 / (b) 已实现**;v0.78 追加 **(c) iLink 凭据三态**
|
### 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) 渠道事件(非消息事件)只读查询 —— **已实现**
|
#### (a) 渠道事件(非消息事件)只读查询 —— **已实现**
|
||||||
|
|
||||||
@@ -314,6 +345,15 @@
|
|||||||
- 🔴 **同域但未实现的面**:`GET /api/v1/admin/channels` 的 `accounts[]` 元素**尚未**带**每账号** `authState`/`authDetail`(缺 `AdminChannelAccount` 两字段与合并器的接线)—— 见 §7.2。
|
- 🔴 **同域但未实现的面**:`GET /api/v1/admin/channels` 的 `accounts[]` 元素**尚未**带**每账号** `authState`/`authDetail`(缺 `AdminChannelAccount` 两字段与合并器的接线)—— 见 §7.2。
|
||||||
- ⚠️ **未确证(真机)**:真实 `errcode=-14` 至今**从未在日志里观测到** ⇒ `rejected` 分支只有单测 / httptest 证据,**没有真机证据**(见 §7.4-26)。
|
- ⚠️ **未确证(真机)**:真实 `errcode=-14` 至今**从未在日志里观测到** ⇒ `rejected` 分支只有单测 / httptest 证据,**没有真机证据**(见 §7.4-26)。
|
||||||
|
|
||||||
|
#### (d) 渠道连接态三态:`pending` / `ready` / `reason`(**v0.90 新增,已实现**)
|
||||||
|
|
||||||
|
- **出现位置**:`POST /api/v1/admin/channels/open` 与 `.../close` 的响应体、`GET /api/v1/admin/channels` 的**平台行**与 `accounts[]` **账号行**(三处同名同义)。
|
||||||
|
- **三个键都是可选的,取不到就整键缺席**(同 `needsRescan` / `lastError` 口径):`connected:true` 时,或适配器没有这条状态面时,`pending` / `ready` / `reason` 一律不出现。
|
||||||
|
- `reason` 只可能在 `pending:true` 时出现,是**已脱敏**的原因短句(`access_token` 等凭据绝不出现在其中)。
|
||||||
|
- **判据是结构化字段**(适配器自报的闭集状态),不是正文、不是提示词 —— 客户端据此把卡片画成「已连接 / 连接中(原因)/ 就绪待接入 / 未连接」四档。
|
||||||
|
- **口径归属**:**适配器自报**(`ConnectOutcome`,网关经类型断言探测);核心 `sdk.Channel` 契约**刻意不扩展** ⇒ 没实现该状态面的平台响应**逐字节不变**。
|
||||||
|
- ⚠️ **为什么 `ready` 不带 `reason`**:它本来就不是坏消息(监听已就绪、就等对面来连),给它编一个原因等于把「等接入」说成「出问题了」。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. WebSocket `/ws/chat`
|
## 3. WebSocket `/ws/chat`
|
||||||
@@ -335,7 +375,7 @@
|
|||||||
{"type":"history","sessionId":"web","data":{"messages":[ <historyMessage> ... ]}}
|
{"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 监控面)。
|
- **viewer 连接完成握手时完全不做回放**(其只读窗口是 REST 监控面)。
|
||||||
- 回放是尽力而为:存储未接或读出错静默跳过。
|
- 回放是尽力而为:存储未接或读出错静默跳过。
|
||||||
|
|
||||||
@@ -449,7 +489,7 @@
|
|||||||
> **思维链的唯一出口是实时帧**:§3.4.2 的 `thinking.start` / `thinking.delta` / `thinking.end`(owner/admin 档)。客户端**不要**为「从历史里读回思维链」写任何代码。
|
> **思维链的唯一出口是实时帧**:§3.4.2 的 `thinking.start` / `thinking.delta` / `thinking.end`(owner/admin 档)。客户端**不要**为「从历史里读回思维链」写任何代码。
|
||||||
> ⚠️ 本行**更正**本文档 v0.65 及更早的写法(旧版称「历史思维链只有 `GET /sessions/{id}/messages`(owner/admin)返回」)—— **那是文档写错了**,与实现不符。见 §7.3。
|
> ⚠️ 本行**更正**本文档 v0.65 及更早的写法(旧版称「历史思维链只有 `GET /sessions/{id}/messages`(owner/admin)返回」)—— **那是文档写错了**,与实现不符。见 §7.3。
|
||||||
> 另注:管理面板人格清单里的 `thinking` 是**人格旋钮**(`GET /admin/personas` 的 `thinking` 块),与消息 DTO 的 `thinking` 不是同一个字段。
|
> 另注:管理面板人格清单里的 `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)
|
#### 3.4.5 子代理 / 任务 / 系统帧(owner+admin)
|
||||||
|
|
||||||
@@ -507,15 +547,15 @@
|
|||||||
| 400 | `bad_request`、`image_too_large`、`too_many_documents`、`invalid_payload`(**v0.79**)、`unknown_provider`(**v0.79**)、`not_configured`(**v0.79**)、`primary_not_configured`(**v0.80**)、`invalid_trigger`(**v0.82**) | 请求体/字段非法、图片超限(`image` **字符串长度**,**仅 base64 / `data:` 形态会触发**,见 §4.5)、附件超条数;**插件动作的载荷形状错 / provider 名未登记 / 该家没配齐**(这三个码自 v0.79 起由动作处理器给出,见 §4.14(b)(c));**面板保存的 `primary` 指名一家「已登记但未配齐」的家**(**v0.80**,写盘**之前**就拒 —— 那份配置会让后端下次起不来,见 §4.14(c));**`GET /admin/backthink?trigger=` 的值未知或为空**(**v0.82**,见 §4.10(e-1);`browser` 面板的**未知字段**也走 `invalid_payload`,见 §4.14(d)) |
|
| 400 | `bad_request`、`image_too_large`、`too_many_documents`、`invalid_payload`(**v0.79**)、`unknown_provider`(**v0.79**)、`not_configured`(**v0.79**)、`primary_not_configured`(**v0.80**)、`invalid_trigger`(**v0.82**) | 请求体/字段非法、图片超限(`image` **字符串长度**,**仅 base64 / `data:` 形态会触发**,见 §4.5)、附件超条数;**插件动作的载荷形状错 / provider 名未登记 / 该家没配齐**(这三个码自 v0.79 起由动作处理器给出,见 §4.14(b)(c));**面板保存的 `primary` 指名一家「已登记但未配齐」的家**(**v0.80**,写盘**之前**就拒 —— 那份配置会让后端下次起不来,见 §4.14(c));**`GET /admin/backthink?trigger=` 的值未知或为空**(**v0.82**,见 §4.10(e-1);`browser` 面板的**未知字段**也走 `invalid_payload`,见 §4.14(d)) |
|
||||||
| 401 | `unauthorized`、`invalid_grant` | 无/坏凭据;refresh token 非法 |
|
| 401 | `unauthorized`、`invalid_grant` | 无/坏凭据;refresh token 非法 |
|
||||||
| 403 | `forbidden`、`unknown_role`(**v0.78**,面板快照的角色无法识别,理论不可达、fail-closed) | 角色不足;主会话不可删;**插件写动作的调用者不是 owner/admin**(v0.78,见 §4.14(b)) |
|
| 403 | `forbidden`、`unknown_role`(**v0.78**,面板快照的角色无法识别,理论不可达、fail-closed) | 角色不足;主会话不可删;**插件写动作的调用者不是 owner/admin**(v0.78,见 §4.14(b)) |
|
||||||
| 404 | `not_found`、`session_not_found`、`document_not_found`、`account_not_found`、`media_not_found`(**v0.67 登记、v0.79 已实现**)、`tidy_run_not_found`(**v0.77 登记、已实现**)、`action_not_declared`(**v0.78 登记、已实现**) | 资源缺失;消息列表的会话不存在;文档缺失(含名字非法/穿越);渠道账号 id 不存在(C19/C20);**语音副本缺失**(含名字非法/穿越,三者同一响应,见 §4.5);**异步试算的 `runId` 未知 / 超 TTL / 进程重启后查不到**(三种原因刻意不区分,见 §4.12.1);**插件动作名未在该插件声明里出现过**(**不是**「没权限」,见 §4.14(b));**回滚的记忆条目不在被合并状态**(`not_found`,v0.79,见 §2.2 B17) |
|
| 404 | `not_found`、`session_not_found`、`document_not_found`、`account_not_found`、`media_not_found`(**v0.67 登记、v0.79 已实现**)、`tidy_run_not_found`(**v0.77 登记、已实现**)、`action_not_declared`(**v0.78 登记、已实现**) | 资源缺失;消息列表的会话不存在;文档缺失(含名字非法/穿越);渠道账号 id 不存在(C19/C20);**语音副本缺失**(含名字非法/穿越,三者同一响应,见 §4.5);**异步试算的 `runId` 未知 / 超 TTL / 进程重启后查不到**(三种原因刻意不区分,见 §4.12.1);**插件动作名未在该插件声明里出现过**(**不是**「没权限」,见 §4.14(b));**回滚的记忆条目不在被合并状态**(`not_found`,v0.79,见 §2.2 B17);**候选 id 不存在**与**「按 run 批量应用」的留痕行不存在**(**v0.83**,B21/B23 与 B22 —— 后者刻意与「这一轮没有待定候选」区分开) |
|
||||||
| 409 | `chat_busy`、`account_not_registered`、`plugin_disabled`(**v0.78**)、`config_unstartable`(**v0.80**)、`browser_unavailable` / `browser_session_not_open` / `config_rejected`(**v0.82**) | 待处理队列满;渠道账号已配置但没有已注册适配器(C19/C20,v0.66);**插件已被显式停用,其声明还在但入口与动作都下线**(v0.78,见 §4.14(b));**这次面板保存的结果会让 web-search 起不来**(主 provider 未配齐 / 未登记 ⇒ 下次启动 fail-fast)⇒ 已**按字节回滚**、运行中的工具保持原样(**v0.80**,见 §4.14(c));**`browser` 面板(v0.82,见 §4.14(d))**:本机没有可用浏览器来源 / 当前没有打开的页面(面板**不替模型开页**)/ 这次保存没能生效**已按字节回滚** |
|
| 409 | `chat_busy`、`account_not_registered`、`plugin_disabled`(**v0.78**)、`config_unstartable`(**v0.80**)、`browser_unavailable` / `browser_session_not_open` / `config_rejected`(**v0.82**) | 待处理队列满;渠道账号已配置但没有已注册适配器(C19/C20,v0.66);**插件已被显式停用,其声明还在但入口与动作都下线**(v0.78,见 §4.14(b));**记忆整理被配置关闭**(`tidy_disabled`,v0.75 试算既有口径、**v0.83 的手动真跑 B24 复用同码同义** —— `MEMORY_TIDY_ENABLED=false`);**这次面板保存的结果会让 web-search 起不来**(主 provider 未配齐 / 未登记 ⇒ 下次启动 fail-fast)⇒ 已**按字节回滚**、运行中的工具保持原样(**v0.80**,见 §4.14(c));**`browser` 面板(v0.82,见 §4.14(d))**:本机没有可用浏览器来源 / 当前没有打开的页面(面板**不替模型开页**)/ 这次保存没能生效**已按字节回滚** |
|
||||||
| 413 | `payload_too_large`、`document_too_large` | 上传/文档超限 |
|
| 413 | `payload_too_large`、`document_too_large` | 上传/文档超限 |
|
||||||
| 415 | `unsupported_media_type` | 图片/文档类型拒绝 |
|
| 415 | `unsupported_media_type` | 图片/文档类型拒绝 |
|
||||||
| 429 | `rate_limited` | 限流(框架级) |
|
| 429 | `rate_limited` | 限流(框架级) |
|
||||||
| 500 | `internal`、`chat_unavailable`、`chat_failed`、`voice_unavailable`、`stt_failed`、`tts_failed`、`tts_empty`、`voice_clone_failed`、`usage_unavailable`、`storage_unavailable`、`channel_events_unavailable`(**v0.67 已实现**,见 §2.4(a))、`refresh_unavailable`、`sessions_failed`、`messages_failed`、`session_failed`、`message_failed`、`document_upload_failed`、`login_failed`、`memory_review_unavailable`(**v0.79 新增**,切片 L45:入库前审查留痕存储未接线 —— 列表与回滚**共用**)、`reviews_failed`(**v0.79**:读留痕失败)、`revert_failed`(**v0.79**:回滚写失败)、`config_unreadable`(**v0.79**:面板可变源存在但读不出来,**不覆盖**原文件)、`config_write_failed`(**v0.79**:可变源写不进去,本次改动**没有**生效;⚠️ **回滚本身失败**时也用这个码并明说「盘上可能已经变了」—— **v0.82 的 `browser` 面板复用这两个码**,见 §4.14(d)) | 内部错误或依赖未接线(**绝不伪装成功**) |
|
| 500 | `internal`、`chat_unavailable`、`chat_failed`、`voice_unavailable`、`stt_failed`、`tts_failed`、`tts_empty`、`voice_clone_failed`、`usage_unavailable`、`storage_unavailable`、`channel_events_unavailable`(**v0.67 已实现**,见 §2.4(a))、`refresh_unavailable`、`sessions_failed`、`messages_failed`、`session_failed`、`message_failed`、`document_upload_failed`、`login_failed`、`memory_review_unavailable`(**v0.79 新增**,切片 L45:入库前审查留痕存储未接线 —— 列表与回滚**共用**)、`reviews_failed`(**v0.79**:读留痕失败)、`revert_failed`(**v0.79**:回滚写失败)、`config_unreadable`(**v0.79**:面板可变源存在但读不出来,**不覆盖**原文件)、`config_write_failed`(**v0.79**:可变源写不进去,本次改动**没有**生效;⚠️ **回滚本身失败**时也用这个码并明说「盘上可能已经变了」—— **v0.82 的 `browser` 面板复用这两个码**,见 §4.14(d))、**记忆整理应用面(v0.83,切片 TIDY-1)**:`tidy_unavailable`(整理写面未接线;**候选列表 / 应用 / 回滚共用**,与只读试算组同名同义)、`tidy_candidates_failed`(读候选失败)、`tidy_apply_failed`(单条或批量应用失败)、`tidy_revert_failed`(回滚写失败)、`tidy_run_unavailable`(**只接线了只读试算、真跑入口没接** —— 「没跑」与「跑了没产出」必须能分辨)、`tidy_run_failed`(真跑失败) | 内部错误或依赖未接线(**绝不伪装成功**) |
|
||||||
| 501 | `not_implemented`、`panels_unavailable`(**v0.78**)、`action_not_implemented`(**v0.78**) | HistoryStore 等未注入;**插件面板中枢未接线**(见 §4.14(a));**动作已声明,但宿主没有登记它的处理器** —— 🔴 **v0.79 起不再是兜底档**:内置 `web-search` 的动作已全部登记(见 §4.14(b)(c)),这条只在「声明写了却没实现」时出现 |
|
| 501 | `not_implemented`、`panels_unavailable`(**v0.78**)、`action_not_implemented`(**v0.78**) | HistoryStore 等未注入;**插件面板中枢未接线**(见 §4.14(a));**动作已声明,但宿主没有登记它的处理器** —— 🔴 **v0.79 起不再是兜底档**:内置 `web-search` 的动作已全部登记(见 §4.14(b)(c)),这条只在「声明写了却没实现」时出现 |
|
||||||
| 502 | `action_failed`(**v0.78**)、探针码 `unauthorized` / `rate_limited` / `quota_exceeded` / `network` / `upstream` / `bad_response` / `no_results` / `unknown`(**v0.79**)、`browser_action_failed`(**v0.82**) | **插件动作执行失败**(结构化错误码 + 文案,详细原因只进后端日志);中间一组是 `plugin.testProvider` 探针的**上游结论**(其中 **`no_results` = 连通正常但没查到结果**);末一个 = `browser` 面板的截图 / 指纹自检**执行失败**(**v0.82**,见 §4.14(b)(d)) |
|
| 502 | `action_failed`(**v0.78**)、探针码 `unauthorized` / `rate_limited` / `quota_exceeded` / `network` / `upstream` / `bad_response` / `no_results` / `unknown`(**v0.79**)、`browser_action_failed`(**v0.82**) | **插件动作执行失败**(结构化错误码 + 文案,详细原因只进后端日志);中间一组是 `plugin.testProvider` 探针的**上游结论**(其中 **`no_results` = 连通正常但没查到结果**);末一个 = `browser` 面板的截图 / 指纹自检**执行失败**(**v0.82**,见 §4.14(b)(d)) |
|
||||||
| 503 | `timeout`、`chat_dispatch_failed`、`catalog_unavailable` | 派发/上游不可用 |
|
| 503 | `timeout`、`chat_dispatch_failed`、`catalog_unavailable`、`tidy_timeout`(**v0.83** 手动真跑超时:**本轮未完整跑完,已跑部分仍写入了留痕** —— 不假装跑全了) | 派发/上游不可用 |
|
||||||
|
|
||||||
> **未确证**:`conflict`(409) 的通用码存在(框架级 `ErrConflict`),但除了 `chat_busy` 之外,本文档未逐一确证还有哪些端点会带它。
|
> **未确证**:`conflict`(409) 的通用码存在(框架级 `ErrConflict`),但除了 `chat_busy` 之外,本文档未逐一确证还有哪些端点会带它。
|
||||||
> **⚠️ `416` 不是错误信封(v0.67 登记,**v0.79 已实现**)**:`GET /api/v1/media/{name}` 的 `Range` 语法非法或范围不可满足时,**由 Go 标准库直写** `416`(`text/plain` + `Content-Range: bytes */<size>`),**不套** `{"error":{…}}` ⇒ 客户端**不要**拿 `error.code` 去解析它(见 §4.5)。这是字节流端点的既有事实(`/audio/speech` 的 `200`、`/documents/{name}` 的 `200` 同样不是 JSON 信封),**不是**本版新引入的怪癖。
|
> **⚠️ `416` 不是错误信封(v0.67 登记,**v0.79 已实现**)**:`GET /api/v1/media/{name}` 的 `Range` 语法非法或范围不可满足时,**由 Go 标准库直写** `416`(`text/plain` + `Content-Range: bytes */<size>`),**不套** `{"error":{…}}` ⇒ 客户端**不要**拿 `error.code` 去解析它(见 §4.5)。这是字节流端点的既有事实(`/audio/speech` 的 `200`、`/documents/{name}` 的 `200` 同样不是 JSON 信封),**不是**本版新引入的怪癖。
|
||||||
@@ -570,9 +610,9 @@
|
|||||||
{ "name": "web_search", "status": "ok", "durationMs": 1200,
|
{ "name": "web_search", "status": "ok", "durationMs": 1200,
|
||||||
"argsSummary": "{\"query\":\"明天天气\"}", "resultSummary": "晴,18~26℃" }
|
"argsSummary": "{\"query\":\"明天天气\"}", "resultSummary": "晴,18~26℃" }
|
||||||
],
|
],
|
||||||
"voice": { // v0.67 登记,v0.79 已实现(切片 L45);非语音消息键缺席;🎤 判据 = 本键存在
|
"voice": { // v0.67 登记,v0.79 已实现(切片 L45);v0.98 起也承载她的出站语音;非语音消息键缺席;🎤 判据 = 本键存在 && status != "failed"(v0.98)
|
||||||
"source": "asr", // "asr"(服务端识别,含云端)| "platform"(渠道平台自带转写)
|
"source": "asr", // "asr"(服务端识别,含云端)| "platform"(渠道平台自带转写)| "tts"(她的出站合成语音,v0.98)
|
||||||
"status": "transcribed", // "transcribed" | "empty" | "failed"(闭集三态)
|
"status": "transcribed", // "transcribed" | "empty" | "failed"(闭集三态;failed 时 fallbackReason 可选在场,v0.98)
|
||||||
"name": "a1b2c3….ogg", // 音频副本存储名;无副本 ⇒ 键省略
|
"name": "a1b2c3….ogg", // 音频副本存储名;无副本 ⇒ 键省略
|
||||||
"mime": "audio/ogg",
|
"mime": "audio/ogg",
|
||||||
"size": 12345,
|
"size": 12345,
|
||||||
@@ -609,9 +649,9 @@
|
|||||||
|
|
||||||
**`voice` 语义(v0.67 登记;✅ **v0.79 已实现**,切片 L45 —— 覆盖率见本节末)**:
|
**`voice` 语义(v0.67 登记;✅ **v0.79 已实现**,切片 L45 —— 覆盖率见本节末)**:
|
||||||
|
|
||||||
- 形状:`{source,status,name?,mime?,size?,durationMs?,url?}`,`omitempty`(**非语音消息键缺席**,与引入语音前逐字相同)。
|
- 形状:`{source,status,name?,mime?,size?,durationMs?,url?,fallbackReason?}`,`omitempty`(**非语音消息键缺席**,与引入语音前逐字相同;`fallbackReason` 仅 `status=="failed"` 时在场,v0.98)。
|
||||||
- **🎤 判据 = `voice` 键存在**(这条消息来自一条语音消息)。客户端**绝不**从 `content` 正文里认媒体类型:QQ 语音正文里那个 `[语音]` 占位标记自 v0.67 起**不再下发**(见 §6 的 v0.67 行为变更),正文里也可能出现用户自己打出的 `[语音]` 字样。**只认 `voice` 键**。
|
- **🎤 判据 = `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`)。
|
- `source` ∈ `"asr"`(服务端语音识别所得,含云端)/ `"platform"`(渠道平台自带转写,微信 iLink 的 `voice_item.text`)/ **`"tts"`(v0.98:她的出站合成语音——她调了模型侧 `speak` 工具且合成成功;播放与入站语音走同一 `GET /api/v1/media/{name}`)**。
|
||||||
- `status` ∈ `"transcribed"`(正文即转写文本)/ `"empty"`(转写成功但没识别出文字)/ `"failed"`(没拿到转写文本 —— 正文是那行中文诚实提示)。**客户端按 `status` 分支,绝不按正文文案分支**(§0.3 的同一纪律)。设计还**刻意不下发** `failed` 的机器可读细分(未配置 / 鉴权 / 欠费 / 限流 / 超时 / 格式不支持 / 无字节 ⇒ 分类只进服务端日志,正文给的是人读的原因方向)。
|
- `status` ∈ `"transcribed"`(正文即转写文本)/ `"empty"`(转写成功但没识别出文字)/ `"failed"`(没拿到转写文本 —— 正文是那行中文诚实提示)。**客户端按 `status` 分支,绝不按正文文案分支**(§0.3 的同一纪律)。设计还**刻意不下发** `failed` 的机器可读细分(未配置 / 鉴权 / 欠费 / 限流 / 超时 / 格式不支持 / 无字节 ⇒ 分类只进服务端日志,正文给的是人读的原因方向)。
|
||||||
- `name` / `mime` / `size` / `url` **四者同生共死**:有音频副本时全在,**无副本时全缺席**(**不会**出现 `url` 指着一个不存在的 `name` 这种形状)。
|
- `name` / `mime` / `size` / `url` **四者同生共死**:有音频副本时全在,**无副本时全缺席**(**不会**出现 `url` 指着一个不存在的 `name` 这种形状)。
|
||||||
- `durationMs` **只有渠道如实给出时才有**;读不出来就**省略键,绝不估算**(当前未确证各渠道是否都给得出,见 design/24 §10.12-8)。
|
- `durationMs` **只有渠道如实给出时才有**;读不出来就**省略键,绝不估算**(当前未确证各渠道是否都给得出,见 design/24 §10.12-8)。
|
||||||
@@ -619,7 +659,7 @@
|
|||||||
- 落库形状 = DTO 形状**减去** `url`(列 `chat_messages.voice TEXT NOT NULL DEFAULT ''`,JSON **单对象**;**用空串不用 NULL**,与 `turn_id` 同一口径)。音频原件**复用文档那一套存储 / 元数据表 / 引用计数 / GC**(同一 `<DataDir>/uploads/documents` 目录),但**不进文档的抽取 / 注入链路**:不被抽取、不进 `documents` DTO 数组、不占注入预算、不出现在附件清单行里。
|
- 落库形状 = DTO 形状**减去** `url`(列 `chat_messages.voice TEXT NOT NULL DEFAULT ''`,JSON **单对象**;**用空串不用 NULL**,与 `turn_id` 同一口径)。音频原件**复用文档那一套存储 / 元数据表 / 引用计数 / GC**(同一 `<DataDir>/uploads/documents` 目录),但**不进文档的抽取 / 注入链路**:不被抽取、不进 `documents` DTO 数组、不占注入预算、不出现在附件清单行里。
|
||||||
- **一条消息只落一份音频副本**(= 被转写的那一条语音);同一条消息里的第 2..N 条语音既不转写也不落盘,DTO 里也没有它们(**刻意的边界**,不是遗漏)。
|
- **一条消息只落一份音频副本**(= 被转写的那一条语音);同一条消息里的第 2..N 条语音既不转写也不落盘,DTO 里也没有它们(**刻意的边界**,不是遗漏)。
|
||||||
- ⚠️ **微信(`weixin-ilink`)语音没有音频副本、不可回放**:按既有裁决「平台自带转写就不白下载那份音频」⇒ `source:"platform"` 且**无 `name`/`mime`/`size`/`url`**,只保留 🎤 标记。
|
- ⚠️ **微信(`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` 不同)。
|
- ⚠️ **列被外部改坏时可见地缺席**:非法 JSON / 闭集破坏时**不静默当「不是语音」** —— 记一条带来源与消息 id(**不含正文**)的 WARN,并让该字段缺席。契约的 `voice` 没有「读不出」这一态,因此缺席是唯一诚实的表达(与 `documents` 回一条 `unreadable` 不同)。
|
||||||
|
|
||||||
**`documents` 数组语义(核心)**:
|
**`documents` 数组语义(核心)**:
|
||||||
@@ -673,10 +713,11 @@
|
|||||||
- `/voice-clone`:`{url,prefix?}` → `{data:{voiceId}}`。⚠️ 真建音色需公网参考音频 URL(当前无,待主人提供)+ 北京地域 API Key。
|
- `/voice-clone`:`{url,prefix?}` → `{data:{voiceId}}`。⚠️ 真建音色需公网参考音频 URL(当前无,待主人提供)+ 北京地域 API Key。
|
||||||
- WS 语音回合(`voice_input`):**与文本回合走同一条路**(进编排器 → 落库 → 流式 → 跨端同步 → 忙碌/排队语义一致)。转写文本落库为用户消息、助手回复落库;`deviceId`/`clientMsgId` 与 `chat` 帧同形。
|
- WS 语音回合(`voice_input`):**与文本回合走同一条路**(进编排器 → 落库 → 流式 → 跨端同步 → 忙碌/排队语义一致)。转写文本落库为用户消息、助手回复落库;`deviceId`/`clientMsgId` 与 `chat` 帧同形。
|
||||||
- `voice_output` 在**该回合结算时**才定向回发起连接(可能晚于若干状态帧,甚至由续跑回合或被 steer 注入的那一回合产生)。空正文 / TTS 失败/降级都走 `provider` 降级信号(`silent` 等),**不把 `stopReason` 当语音内容**;连接断开不补投,客户端带回同一 `clientMsgId` 可安全重试。
|
- `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.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 语音在「音频格式不在可转写白名单内」时正文诚实提示由「…(需要先转码,当前未做)」改为「…(服务端只把白名单内的音频送语音识别),这条语音没有转写。」—— **形状不变**(正文仍是转写文本或一行诚实提示;客户端本就**不该**按正文文案分支)。
|
> **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 语音在「音频格式不在可转写白名单内」时正文诚实提示由「…(需要先转码,当前未做)」改为「…(服务端只把白名单内的音频送语音识别),这条语音没有转写。」—— **形状不变**(正文仍是转写文本或一行诚实提示;客户端本就**不该**按正文文案分支)。
|
||||||
|
|
||||||
@@ -1035,7 +1076,7 @@
|
|||||||
| 请求体 | **可为空**(端点不读 body)。没有参数:阈值/上限一律取**进程生效的既有配置**(`MEMORY_TIDY_RECALL_THRESHOLD` / `_SCAN_LIMIT` / `_CANDIDATE_LIMIT` / **`_COMPARE_LIMIT`(v0.77 新增)**),端点**自己不带任何旋钮** |
|
| 请求体 | **可为空**(端点不读 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[]}}` |
|
| 响应体 | `{data:{dryRun,recallMode,durationMs,ruleVersion,partitions,scanned,compared,pairs,vetoed,vetoReasons,truncated,candidates[]}}` |
|
||||||
| `dryRun` | 恒为 `true`。它是**结构化**判据:这份响应**没有**改动任何记忆(客户端不必只靠本地硬编码文案说明这件事) |
|
| `dryRun` | 恒为 `true`。它是**结构化**判据:这份响应**没有**改动任何记忆(客户端不必只靠本地硬编码文案说明这件事) |
|
||||||
| `recallMode` | `"embedding"`(语义向量召回)或 `"lexical"`(零依赖词项余弦,仅当进程没有接 embedder) |
|
| `recallMode` | **如实上报本轮实际选用的召回路径**(不再是「配置态」):`"embedding"`(语义向量召回)|`"lexical"`(零依赖词项余弦)|`"mixed"`(**部分分区降级**、其余走向量)|`""`(**没有候选可比** ⇒ 本轮没做召回,不是降级、也不该显示成空)。⚠️ v0.91 起语义收紧:旧实现把「全量降级」静默报成 `embedding`,面板因此看不出降级 |
|
||||||
| `durationMs` | 本次试算的墙钟耗时(毫秒) |
|
| `durationMs` | 本次试算的墙钟耗时(毫秒) |
|
||||||
| `ruleVersion` | 规则集版本(`internal/runtime/memory` 的 `TidyRuleVersion`,进候选幂等键;**改判定规则必须同时升它**) |
|
| `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`) |
|
| 观测口径 | `partitions`(含在册条目的分区数)/ `scanned`(扫描条数)/ `compared`(**真正参与两两比对**的条数,受 **`MEMORY_TIDY_COMPARE_LIMIT`** 与**时间预算**两道闸约束 —— 因此 `compared < scanned` 时要看 `truncated` 是不是指出了原因)/ `pairs`(相似度 ≥ 召回门的对数)/ `vetoed` 与 `vetoReasons`(否决数与原因分布,如 `scope`/`persona`/`project`/`source`/`temporal`/`numeric`/`conflict`/`low_confidence`) |
|
||||||
@@ -1230,12 +1271,12 @@
|
|||||||
| 动作名 | 语义 | 面 | 请求体 | 成功响应 `result` |
|
| 动作名 | 语义 | 面 | 请求体 | 成功响应 `result` |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| `plugin.listProviders` | 列出各条 provider 配置 | read(table `source`) | `{}` | `{"rows":[{"provider":"searxng","state":"ok\|not_configured\|disabled\|unknown","enabled":true}]}`(**凭据/地址不回**) |
|
| `plugin.listProviders` | 列出各条 provider 配置 | read(table `source`) | `{}` | `{"rows":[{"provider":"searxng","state":"ok\|not_configured\|disabled\|unknown","enabled":true}]}`(**凭据/地址不回**) |
|
||||||
| `plugin.providers` | 下拉候选:本期**全部已登记**的 provider 名 | read(字段 `optionsFrom:"plugin:providers"`) | `{}` | `{"options":["aliyun_iqs","baidu_qianfan","bocha","searxng","volcengine","zhipu"]}`(稳定排序,**含尚未配置的家**) |
|
| `plugin.providers` | 下拉候选:本期**全部已登记**的 provider(**带显示名**) | read(字段 `optionsFrom:"plugin:providers"`) | `{}` | `{"options":[{"value":"zhipu","label":"智谱 BigModel(zhipu)"},…]}`(稳定排序,**含尚未配置的家**)。🆕 **v0.84 形状变更(对象数组)**:此前是**裸字符串数组**(`{"options":["zhipu",…]}`)。🔴 **两条硬口径**:① **`label` 仅供显示,`value` 才是落盘值** —— 把 `label` 写回 `provider` 字段会把配置文件写坏(提交给 `plugin.saveProvider` 的必须是 `value`);② **前端解码器本就兼容三种形状**(裸字符串 / `{value,label}` / `{id}`)⇒ 仍返回裸字符串的其他插件与老声明**不受影响**(零破坏性)。前端「候选开关」写回的是 `value`;表格单元格按 `value→label` 索引显示、查不到回落显示原值 |
|
||||||
| `plugin.saveProvider` | 新增/修改一条(按 `provider` **upsert**);**同一次提交可带两个文件级字段** `primary` / `fallback`(主 provider 与降级链,**缺位 = 不改动**) | write(form `submit`) | `{"values":{"provider":"bocha","apiKey":"…","baseUrl":"https://…","enabled":true,"primary":"bocha","fallback":["searxng"]}}` | `{"saved":true,"provider":"bocha","created":true,"enabled":true,"primary":"bocha","fallback":["searxng"],"hotReload":{"applied":true,"registered":true}}`(`primary` / `fallback` 是**落盘后**的取值,没给也如实回显;`hotReload` 见下) |
|
| `plugin.saveProvider` | 新增/修改一条(按 `provider` **upsert**);**同一次提交可带两个文件级字段** `primary` / `fallback`(主 provider 与降级链,**缺位 = 不改动**) | write(form `submit`) | `{"values":{"provider":"bocha","apiKey":"…","baseUrl":"https://…","enabled":true,"primary":"bocha","fallback":["searxng"]}}` | `{"saved":true,"provider":"bocha","created":true,"enabled":true,"primary":"bocha","fallback":["searxng"],"hotReload":{"applied":true,"registered":true}}`(`primary` / `fallback` 是**落盘后**的取值,没给也如实回显;`hotReload` 见下) |
|
||||||
| `plugin.testProvider` | 测试连通(**真发一次最小检索**) | write(table `rowActions`) | `{"row":{"provider":"bocha"}}`(亦可给 `{"values":{…}}` 或顶层 `provider`) | `{"ok":true,"provider":"bocha","count":1,"latencyMs":123}`;**没通 = 非 2xx**(见错误表),**不在 200 里回 `ok:false`** |
|
| `plugin.testProvider` | 测试连通(**真发一次最小检索**) | write(table `rowActions`) | `{"row":{"provider":"bocha"}}`(亦可给 `{"values":{…}}` 或顶层 `provider`) | `{"ok":true,"provider":"bocha","count":1,"latencyMs":123}`;**没通 = 非 2xx**(见错误表),**不在 200 里回 `ok:false`** |
|
||||||
| `plugin.reauthProvider` | 重配凭据:把该条带回表单 | write(table `rowActions`) | `{"row":{"provider":"bocha"}}` | `{"values":{"provider":"bocha","apiKey":"****cdef","baseUrl":"https://api.bocha.cn","enabled":true,"primary":"bocha","fallback":["searxng"]}}`(**`apiKey` 只回掩码**;前端对 secret 字段一律不回填输入框,**空 = 不改动**)。⚠️ **v0.80 起 `values` 另带两个文件级字段** `primary`(字符串)/ `fallback`(字符串数组)—— **不带它们**的话,面板「清空降级链」在客户端三态写回里会落成「缺位 = 不改动」(**清不掉**) |
|
| `plugin.reauthProvider` | 重配凭据:把该条带回表单 | write(table `rowActions`) | `{"row":{"provider":"bocha"}}` | `{"values":{"provider":"bocha","apiKey":"****cdef","baseUrl":"https://api.bocha.cn","enabled":true,"primary":"bocha","fallback":["searxng"]}}`(**`apiKey` 只回掩码**;前端对 secret 字段一律不回填输入框,**空 = 不改动**)。⚠️ **v0.80 起 `values` 另带两个文件级字段** `primary`(字符串)/ `fallback`(字符串数组)—— **不带它们**的话,面板「清空降级链」在客户端三态写回里会落成「缺位 = 不改动」(**清不掉**) |
|
||||||
|
|
||||||
`plugin.saveProvider` 的**三态写回哨兵**(写侧语义,前端须照此送值):`values.apiKey` **缺位** ⇒ 保留盘上原值;**空串** ⇒ 显式清空;非空 ⇒ 覆盖。`values.baseUrl` / `values.enabled` **缺位** ⇒ 保留;给出(含 `""` / `false`)⇒ 采用(`enabled` 给非布尔值 ⇒ `400 invalid_payload`,**不静默保留**)。`values.provider` **必填**,且必须是本期已登记的接入位(`searxng` / `zhipu` / `aliyun_iqs` / `bocha` / `volcengine` / `baidu_qianfan`),否则 `400 unknown_provider`。
|
`plugin.saveProvider` 的**三态写回哨兵**(写侧语义,前端须照此送值):`values.apiKey` **缺位** ⇒ 保留盘上原值;**空串** ⇒ 显式清空;非空 ⇒ 覆盖。`values.baseUrl` / `values.enabled` **缺位** ⇒ 保留;给出(含 `""` / `false`)⇒ 采用(`enabled` 给非布尔值 ⇒ `400 invalid_payload`,**不静默保留**)。`values.provider` **必填**,且必须是本期已登记的接入位(`searxng` / `zhipu` / `aliyun_iqs` / `bocha` / `volcengine` / `baidu_qianfan` / **`tencent_wsa`** / **`metaso`**),否则 `400 unknown_provider`。🆕 **v0.87:名单由六家扩到八家**(新增腾讯云·元宝搜索与秘塔搜索),**端点形状与错误码一字未改**。
|
||||||
|
|
||||||
**(v0.80 新增)两个文件级字段的三态写回**(与条目字段同一套哨兵语义):`values.primary` **缺位** ⇒ 保留盘上值;给字符串(含空串 / `off` / `none`)⇒ 采用 —— **空串与 `off` / `none` 都是「关掉这把工具」的哨兵**(`off` / `none` 大小写与空白不敏感),`WEB_SEARCH_PROVIDER` 与面板**共用同一套关闭语义**(不另造第二套)。`values.primary` 指名一家**已登记但未配齐**的家 ⇒ **`400 primary_not_configured`**(**写盘之前**就拒,因为那份配置会让后端下次起不来)。`values.fallback` **缺位** ⇒ 保留;给数组(或逗号 / 顿号 / 分号 / 换行分隔的字符串)⇒ 采用(去空白、去重、丢空项);元素**未登记** ⇒ `400 unknown_provider`;给非数组 / 非字符串 ⇒ `400 invalid_payload`(**不静默当空数组** —— 那等于一次保存悄悄清空降级链)。降级链**允许**放本期已登记但**尚未配齐**的家(运行期会跳过它)。
|
**(v0.80 新增)两个文件级字段的三态写回**(与条目字段同一套哨兵语义):`values.primary` **缺位** ⇒ 保留盘上值;给字符串(含空串 / `off` / `none`)⇒ 采用 —— **空串与 `off` / `none` 都是「关掉这把工具」的哨兵**(`off` / `none` 大小写与空白不敏感),`WEB_SEARCH_PROVIDER` 与面板**共用同一套关闭语义**(不另造第二套)。`values.primary` 指名一家**已登记但未配齐**的家 ⇒ **`400 primary_not_configured`**(**写盘之前**就拒,因为那份配置会让后端下次起不来)。`values.fallback` **缺位** ⇒ 保留;给数组(或逗号 / 顿号 / 分号 / 换行分隔的字符串)⇒ 采用(去空白、去重、丢空项);元素**未登记** ⇒ `400 unknown_provider`;给非数组 / 非字符串 ⇒ `400 invalid_payload`(**不静默当空数组** —— 那等于一次保存悄悄清空降级链)。降级链**允许**放本期已登记但**尚未配齐**的家(运行期会跳过它)。
|
||||||
|
|
||||||
@@ -1297,6 +1338,178 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### 4.15 记忆整理「应用面」(B20–B24;**v0.83 新增,切片 TIDY-1**)
|
||||||
|
|
||||||
|
> 依据 `design/23`(§7 人工在环 / §8 软动作与回滚 / §10 status 列 / §11 管理面)。**背景**:v0.75 起只有「试算」(B14/B15 —— 只回建议、**零副作用**)⇒ 主人的实测口径是「候选 77 条**全 pending**、两天**一条没应用**」。本版把**执行面**补上:候选能列、能应用、能回滚,整理能手动真跑。
|
||||||
|
|
||||||
|
#### (a) 端点族一览
|
||||||
|
|
||||||
|
| 端点 | 语义 | 关键参数 | 审计 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| B20 `GET …/memories/candidates` | 列候选(含 `id`/`runId`/证据) | `status`(省略 = 全部)、`limit`(缺省 100 / 钳 500 / 非法 400) | 无(只读) |
|
||||||
|
| B21 `POST …/candidates/{id}/apply` | 应用**一条** | 无体 | `memory.tidy.apply` |
|
||||||
|
| B22 `POST …/candidates/apply` | **批量**应用 | `runId`(省略 = **全部待定**)、`limit`(缺省 100 / 钳 500 / 非法 400) | `memory.tidy.apply` |
|
||||||
|
| B23 `POST …/candidates/{id}/revert` | 回滚**一条**已应用 | 无体 | `memory.tidy.revert` |
|
||||||
|
| B24 `POST …/memories/tidy/run` | 手动**真跑**一轮(写候选 + 留痕,**不自动应用**) | 无体 | 领域侧记 `memory.tidy.run`(一次点击一条) |
|
||||||
|
|
||||||
|
形状逐字段见 §2.2 的 B20–B24 行。**门一律 `writeAuth`**(owner\|admin)—— 候选正文含隐私,与同族 `GET /admin/memories`、`POST …/tidy/dry-run` 同档。⚠️ `design/23 §11` 表格把**回滚**标为 owner-only,而实现用了 `writeAuth`:**口径差异待裁决**(若以设计为准,`routes.go` 里把那两行换成 `ownerAuth` 即可,一行一处;与 §2.2 B17 的同类差异是同一件事)。
|
||||||
|
|
||||||
|
#### (b) 三条硬约束(客户端与面板都按这个编程)
|
||||||
|
|
||||||
|
1. **幂等不是错误**:执行闸是**候选状态的原子条件更新**(`WHERE id=? AND status='pending'` + `RowsAffected`),记忆行侧还有第二道闸(`AND status='active'`)。因此并发重复点击**只有一个赢家**,其余拿到 `200` + `status:"skipped"` —— 面板**不得**把它渲染成失败,也**不得**据此提示「重试」。
|
||||||
|
2. **超限必须如实**:批量回执的 `total` 与 `applied+skipped+failed` **口径不同**(前者 = 命中筛选的**真**待定数,后者只覆盖 `limit` 之内),差集由 **`truncated:true`** 表达。🔴 这是「不许把本轮 100 / 共 342 画成全部完成」的契约级要求。
|
||||||
|
3. **软动作、永不物理删除**:merge = 被吸收行 `status='merged'` + `merged_into`;archive = `status='archived'`;**`content` 一个字不改、行一行不删** ⇒ `revert`(B23)必然无损可逆。同时,**软删行必须真的从读侧消失**(v0.83 行为修复):记忆的四条列表读路径(`ListByPersona` / `List` / `ListScope` / `ListProject`)只回 `status='active'`,因此被软合并/归档的记忆**不再被召回、不再进上下文、不再出现在 `GET /admin/memories`**。🔴 **只有两处刻意不过闸**:按 id 的 `Get`(撤销软合并必须先读得到被吸收的行)与 **`GET /admin/memories/reviews`**(审查留痕本身没有 status 概念,且它是软删行的查看入口 —— 加闸等于审计自我抹除)。
|
||||||
|
|
||||||
|
#### (c) 调度:到点即跑 + catch-up(对客户端零形状影响)
|
||||||
|
|
||||||
|
原实现是「`interval=24h` 的 ticker 从**进程启动**算起」⇒ 后端两天重启十几次,**24h 永远走不完**(`memory_tidy_runs` 只有 1 行,这就是「两天没跑」的一半根因)。现在以 **`memory_tidy_runs` 的留痕行**作为「上次到底跑过没有」的**持久事实**,启动时与心跳到点都判一次(三条判据),**重启不饿死**。B24 是它的手动入口(panic button),与心跳**同一套规则、同一份配置**。
|
||||||
|
|
||||||
|
#### (d) 诚实边界(**别当已验证**)
|
||||||
|
|
||||||
|
- 🔴 **生产库全程零动作**:1191 行记忆与 77 条待定候选**都没有被应用或清理**。本版交付的是**能力**,不是「已经清过了」—— 真跑与批量应用要主人拍板后执行(可分批、可 revert)。
|
||||||
|
- **面板上的真机端到端未跑**:本版登记时**前端操作面还没落地**(前端切片并行在做),因此本版证据是**存储层单测 + 网关端到端测试 + 变异自证**,不含「面板点一下真生效」。
|
||||||
|
- **旧候选的 `run_id` 是空串**(77 条在生产库里都是这样)⇒ 「**按 run 批量**」(B22 带 `runId`)**找不到它们**;要清它们必须走**全部待定**那一档。
|
||||||
|
- 候选表的 `run_id` 列目前**只在存储包前滚**(`memory.NewStore` 的 `ensureTidySchema`),**未**同步进迁移 `0019`;组合根每次启动都会跑到前滚,因此生产库拿到该列没问题,但「迁移是不是唯一真源」这条口径**有欠**。
|
||||||
|
- **`memory.recall` 的语义召回在本机不可复现**(远端 embedder 对同一文本返回不同向量,TIDY-2 诊断发现)—— 它**不影响**本族的规则层判定与状态机,但**影响「整理候选是否可复现」**,登记在此以免被当成实现缺陷。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
#### (e) v0.85:「不再只产建议」+ `link` 停产 + 按动作批量拒绝(B25)
|
||||||
|
|
||||||
|
**① 轮末自动落地(默认生效)**:整理跑完一轮后会**自动**把两类**规则层已判定为高置信**的候选落地,不再等人点:
|
||||||
|
- `merge`:**字面重复**(两侧正文逐字相同)**或**「事实集合等价 ∧ 相似度 ≥ **0.95**」;
|
||||||
|
- `archive`:理由 = `one_off_event` **且不像习惯**(`design/23 §5.2-1` 红线:习惯类会因正文里带钟点被误判成一次性事件,**归档它是错的**)。
|
||||||
|
|
||||||
|
🔴 **不自动**(别指望它替你干这些):`reclassify`、**低置信** merge、跨分区 / 跨角色、**任何改正文**、**受限来源(群聊 / 陌生人)**。
|
||||||
|
**六条护栏**:软动作(只改 `status` / `merged_*`,**正文不改、行不删**)· **每轮上限 50**(硬顶 500,超限**如实** `truncated` + 真总数)· 审计 `memory.tidy.apply`(`auto=true`,**不带正文**)· **可 revert**(回滚路径与人工应用**同一条**)· 幂等(候选状态条件更新)· **一键关回只产建议**:写 `$DATA_DIR/config/memory-tidy.json` → `{"autoApply": false}`(调上限同文件 `autoApplyPerRound`;**启动期读一次,改了要重启**;**零新 env 键**)。
|
||||||
|
|
||||||
|
**② `link` 候选已停产**:`link` 在 `design/23 §14` 里是**未细化项**(既无存储落点、也无数据模型),而 `applyable()` 只认 `merge/archive/reclassify` ⇒ **应用一条 `link` 只会拿到 `skipped`**。⇒ 规则层不再产出它(判 `keep`)。`TidyLink` 常量保留**只为读历史行**与「不可应用」判定。
|
||||||
|
|
||||||
|
**③ 存量清理走 B25**(幂等、只改候选状态、不碰记忆):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BASE=http://127.0.0.1:10942
|
||||||
|
# ① 先看现状(默认只看 pending;limit 缺省 100、钳 500)
|
||||||
|
curl -s -H "X-API-Key: $KEY" "$BASE/api/v1/admin/memories/candidates?status=pending&limit=500"
|
||||||
|
|
||||||
|
# ② 一次性拒绝全部 link 候选(幂等;limit 缺省 1000、硬顶 10000)
|
||||||
|
curl -s -X POST -H "X-API-Key: $KEY" \
|
||||||
|
"$BASE/api/v1/admin/memories/candidates/reject?action=link&limit=1000"
|
||||||
|
# 期望 {"data":{"action":"link","limit":1000,"total":501,"rejected":501,"truncated":false}}
|
||||||
|
|
||||||
|
# ③ 复核:同一调用再来一次 ⇒ total:0 / rejected:0(**不是错误**)
|
||||||
|
```
|
||||||
|
|
||||||
|
🔴 **只该整类拒 `link`(必要时 `keep`)** —— `merge/archive/reclassify` **有执行语义**,交给自动档或人工,别整类拒掉。
|
||||||
|
⚠️ 本条的 `total/rejected/truncated` 口径与 B22(批量应用)**同构**:`total` 是真命中数,处理数只覆盖 `limit` 之内。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### 4.16 助手文本的段落标签约束(**v0.84 新增,产出侧硬约束**)
|
||||||
|
|
||||||
|
**约束(一句话)**:助手文本在**落库、对外读接口、IM 镜像**里**不得出现未配对的段落标签** —— 不许有落单的 `<p …>` 或 `</p>`。语义上:**一个 `<p>` = 一段,段落不嵌套**。
|
||||||
|
|
||||||
|
**为什么写进契约**(这曾是真实纠纷):安卓端从 `GET /api/v1/sessions/web/messages` 读到过一条 `content` 形如
|
||||||
|
|
||||||
|
```
|
||||||
|
<p><action>满意地点点头</action></p>
|
||||||
|
<p>真乖♪ 这可是今天最让人家放心的一句。</p>
|
||||||
|
<p><p>那现在——想让人家陪你看会儿晚霞,还是安静坐着发会儿呆?</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
他们的解析契约 `P_BLOCK_RE = <p(\s[^>]*)?>([\s\S]*?)</p>` 是**非贪婪**的 ⇒ 第三段必然匹配成「外层 `<p>` 开、内容 = `<p>正文`、`</p>` 收」⇒ **用户直接看到字面的 `<p>`**。这不是他们解析器的口味问题:**任何按该契约定实现的消费方(Web 面板 / IM 桥 / 第三方)都会踩**。
|
||||||
|
|
||||||
|
**实测根因(两半都在产出侧,已修 `8051906`)**:
|
||||||
|
|
||||||
|
1. 规整器**原样放过**该形状(分块正则非贪婪 ⇒ `<p><p>X</p>` 被当成「一个已完整包裹的 `<p>` 块」逐字保留;更糟的是旧的覆盖检查用**同一非贪婪正则**,护栏本身是盲的);
|
||||||
|
2. 规整器还会**亲手制造**它:段首 `<p>` 未闭合(模型末段漏 `</p>`)时**无条件再套一层** —— 实证:模型吐 `<p>那现在…?`(无闭标签)经旧代码规整后与上报串**逐字节相同**;
|
||||||
|
3. 顺带修掉一个**幂等漏洞**:落单 `</p>` 旧行为 ⇒ `<p>正文。</p></p>`,再跑一次又变 ⇒ 越跑越多。
|
||||||
|
|
||||||
|
**修后的处置口径(产出侧,逐条有测试)**:`<p><p>X</p>` ⇒ `<p>X</p>`;`<p>X`(漏闭)⇒ 补 `</p>`(**补闭不补开**);`<p>正文<p>` ⇒ `<p>正文</p>`(合并不吞字);`<p>A</p><p>` ⇒ `<p>A</p>`(丢空开标签、不造空气泡);`<p></p>` ⇒ 整对丢;落单 `</p>` ⇒ 丢标签、**正文不动**。
|
||||||
|
|
||||||
|
**🔴 客户端要做 / 不用做的**:
|
||||||
|
|
||||||
|
- **不用做**:不必为这个畸形写特例解析器(产出侧已保证不再产出)。
|
||||||
|
- **仍要做**:**保留**你现有的「剥落单标签」容错 —— ① **历史行不回填**(库里已经有畸形的行);② 产出链**不止一条**(渠道镜像 / 主动推送 / 后台思考各有包装点),不能假设全都干净。
|
||||||
|
- **护栏(不许退化)**:`1 < 2`、`a<b`、`<p2>`、`<p`(无 `>`)、代码围栏内的 `<p>`、`<action>` 块内的 `<p>`、自闭合 `<p/>` —— 这些**都不是**段落标签,一律原样保留(产出侧与消费侧**同一条口径**)。
|
||||||
|
|
||||||
|
**一个保守处置(待设计裁决,生产库 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 与模型配置(与契约相关)
|
## 5. env 与模型配置(与契约相关)
|
||||||
|
|
||||||
> **`.env` vs 面板文件**:面板「LLM 模型」页保存写 `<DATA_DIR>/models.json`(默认 `./data/models.json`);「渠道管理」页写 `<DATA_DIR>/channels.json`。**只要文件存在且有效,运行时优先用它**,`.env` 只作兜底/播种层。`.env.example` 与实现同步(键名逐字一致、值占位符)。
|
> **`.env` vs 面板文件**:面板「LLM 模型」页保存写 `<DATA_DIR>/models.json`(默认 `./data/models.json`);「渠道管理」页写 `<DATA_DIR>/channels.json`。**只要文件存在且有效,运行时优先用它**,`.env` 只作兜底/播种层。`.env.example` 与实现同步(键名逐字一致、值占位符)。
|
||||||
@@ -1325,7 +1538,7 @@
|
|||||||
| `DOCUMENT_MAX_INJECT_CHARS` | 空 → 预算 1/8 | 单份附件注入上限 |
|
| `DOCUMENT_MAX_INJECT_CHARS` | 空 → 预算 1/8 | 单份附件注入上限 |
|
||||||
| `GUARDRAIL_MAX_INPUT_CHARS` / `GUARDRAIL_KEYWORDS` | 空 | 请求侧字符上限 / 关键词护栏 |
|
| `GUARDRAIL_MAX_INPUT_CHARS` / `GUARDRAIL_KEYWORDS` | 空 | 请求侧字符上限 / 关键词护栏 |
|
||||||
| `VOICE_STT_TIMEOUT_SEC` | `20` | **只约束 IM 渠道入站语音转写**;Web 客户端 `voice_input` 不受它约束 |
|
| `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** |
|
| `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` 时被其覆盖) |
|
| `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) |
|
| `DOCUMENT_GC_INTERVAL` / `DOCUMENT_RETENTION` | `1h` / `168h` | 文档 GC(`0`/负 = 关闭 GC) |
|
||||||
@@ -1340,7 +1553,7 @@
|
|||||||
| `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_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_QUEUE_SIZE` | `256` | **v0.77 登记**:有界审查队列长度。⚠️ **队列满 ⇒ 该条不做审查**(记忆本身早已落库,**绝不因队列满而丢记忆**),只记一条带来源的 WARN |
|
||||||
| `MEMORY_REVIEW_SCAN_LIMIT` | `200` | **v0.77 登记**:每条审查在同一分区内最多看多少条既有记忆(top-K 上限) |
|
| `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) |
|
| (`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_*` 四个键**不在**事实面里(那是「入库前审查」的另一组配置)。
|
> **上面 13 个键(记忆整理 8 + 后台思考心跳 3 + 安静时段 2)自 v0.68 起可经 `GET /api/v1/admin/config-facts` 读到**「进程生效值 + 声明默认值」(3 个心跳键自 v0.75 起并入、`MEMORY_TIDY_COMPARE_LIMIT` 自 v0.77 起并入);在此之前面板没有任何 env 读取面。⚠️ `MEMORY_REVIEW_*` 四个键**不在**事实面里(那是「入库前审查」的另一组配置)。
|
||||||
@@ -1370,7 +1583,253 @@
|
|||||||
> 只保留最近若干版(**v0.60 起**);更早的全部历史见归档 `Docs/archive/api-client.v0.65.md` §5(v0.28 → v0.59)。
|
> 只保留最近若干版(**v0.60 起**);更早的全部历史见归档 `Docs/archive/api-client.v0.65.md` §5(v0.28 → v0.59)。
|
||||||
> 每版一句话 + 三档标注。**三档定义见 [§0.4](#04-三档标注惯例)。**
|
> 每版一句话 + 三档标注。**三档定义见 [§0.4](#04-三档标注惯例)。**
|
||||||
|
|
||||||
### v0.82(当前契约版本)
|
### 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`。)
|
||||||
|
|
||||||
|
- 🔴 **纠正的谎**:`POST /api/v1/admin/channels/open` 此前在 `ch.Connect` 返回 nil 后**硬编码** `connected:true`。而 `Connect` 返回 nil 只说明「这次调用没有致命失败」——**ws+client 首连失败刻意非致命**(OneBot 实现可能比 Cyrene 后启动,重试交给读循环),**ws+server 则只需监听就绪**,这两种情况下 `IsConnected()` 都是 `false`。前端又只按「请求成功」写死「已连接」⇒ 管理面把「后端受理了」说成了「渠道连上了」。
|
||||||
|
- **三态闭集(口径落在适配器自身,网关只做映射)**:
|
||||||
|
|
||||||
|
| 状态 | 含义 | 线上投影 |
|
||||||
|
|---|---|---|
|
||||||
|
| `connected` | 真连上(client 拨号成功 / HTTP 传输已装配就绪) | `connected: true`,三个可选键**全缺席** |
|
||||||
|
| `pending` | 已在后台重试、当前未连上(仅 ws+client 有重试循环) | `connected: false` + `pending: true` + `reason` |
|
||||||
|
| `ready` | 已绑定监听、就绪等对端接入(ws+server);`IsConnected()` 同样为 `false`,但**不是失败** | `connected: false` + `ready: true`(**不带** `reason`) |
|
||||||
|
| 无意见 | 未启动,或已启动但无可解释的就绪面 | 退回只看 `IsConnected()`,三个键全缺席 |
|
||||||
|
|
||||||
|
- **新增可选键(三处形状同名同义)**:① `POST /api/v1/admin/channels/open` 与 `.../close` 的响应体;② `GET /api/v1/admin/channels` 的**平台行**;③ 同端点的**账号行**(`accounts[]`)。`reason` 已**脱敏**(形状级 → 已知明文替换 → 剥 `access_token=` 查询参数),**凭据绝不出现**。
|
||||||
|
- 🔴 **行为变更(须客户端跟进)**:`open` 的 `connected` 现在是**真实连接状态**,**不再恒为 `true`**;`close` 报**真实** `IsConnected()`,不再假定「调用成功 ⇒ 一定断开了」。老客户端若只看 HTTP 2xx 就提示「已连接」,在 `pending` / `ready` 下会继续骗人 —— 请改读 `connected` 加上述两个键,把卡片画成「已连接 / 连接中(原因)/ 就绪待接入 / 未连接」四档。
|
||||||
|
- **退回行为(无破坏性)**:没实现该状态面的平台(kook / telegram / webhook / weixin-iLink)响应**逐字节不变**(既有四键 + 零变换)。
|
||||||
|
- **计数不变**:无新端点 ⇒ §2.0 维持 `14 + 25 + 40 = 79`。详见 **§2.4(d)**。
|
||||||
|
|
||||||
|
### v0.89
|
||||||
|
|
||||||
|
**「助手消息的行内标签白名单」版 —— 只影响渲染口径,**服务端 API 形状一字未改****(依据:主人 2026-10-07「她在聊天消息中插入了 html/md 标签,能否增加网页前端对此类标签的基本渲染支持?」;Web 端实现推库 `2dae469`。)
|
||||||
|
|
||||||
|
- 🔴 **背景(实测)**:她输出的**不是 Markdown**(最近 12 条 assistant 消息里 `**` 记号 0 个、列表行 0 个),而是**有限的一小组 HTML 标签**:`<p>` 96 次、`<action>` 34 次、**`<b>` 6 次**。`<p>` / `<action>` 各端早已处理,**`<b>` 此前没有任何一端处理 ⇒ 用户直接看到裸标签**。
|
||||||
|
- **新增渲染口径(跨端一致)**:助手消息正文里,以下**行内标签**应被渲染成对应样式,而**不是**按字面显示:
|
||||||
|
`b` / `strong`(粗)· `i` / `em`(斜)· `code`(等宽)· `u`(下划线)。
|
||||||
|
⚠️ **白名单最终定为上面六项** —— 主人 2026-10-07 明确「**下划线确实可以保留**」⇒ `u` 加回;`s`(删除线)**仍不在其内**(她 100 条输出里 0 命中);`br` **不是行内标签**,见下一节(它是分隔符)。
|
||||||
|
- **🔴 `<br>` 是「气泡/消息分隔符」,不是行内标签(同日追加,主人口径)**:主人原话「**对消息内的 `<br>` 始终处理为多条消息隔断**」。
|
||||||
|
- **语义与 `<p>` 同级**:`<br>` 两边的文本**必须成为两条独立的消息**(IM 侧)/ **两个独立的气泡**(Web 侧),**而不是**同一条消息里的一个换行。
|
||||||
|
- `<br>` / `<br/>` / `</br>` 三种写法**等价**(HTML5 口径)。
|
||||||
|
- **空气泡照旧丢弃**(与既有 `<p>` 空气泡口径一致);`<br>` 两侧都是空白 ⇒ 不产生多余空气泡。
|
||||||
|
- ⚠️ **影响面(各端须知)**:一条含 `<br>` 的回复在 IM 上会变成**多条消息**(与 `<p>` 一样受既有的气泡间隔节流约束)。
|
||||||
|
- ⚠️ **实现状态**:本条发出时**实现跟进中**(Web 侧 `2dae469` 当时把 `<br>` 当行内换行,正在改;IM 出站侧当时连行内标签都还没剥,正在改)。**以本契约为准,不以当日实现为准。**
|
||||||
|
- **🆕 非 chat 模式(单卡正文)的口径(同日追加)**:工作 / 代码 / 文档 / 首页 / 视觉 / 语音 / 智能体 / 设置 等**单卡正文**界面**不是气泡模型** ⇒ 那些模式里:**`<br>` ⇒ 一个真换行(不切分)**、**行内白名单照常出样式**、白名单外仍逐字原样。⚠️ **这些模式里也不许出现字面标签**。各模式**既有的较强排版约定优先**(例:工作模式的按行切清单、代码模式的 `<pre>` 等宽块 —— 通用规则不得覆盖它们)。
|
||||||
|
- **🔴 `<br>` 在哪**不**分隔(同日追加,跨端口径)**:`<br>` **只在「普通文本层」分隔**。以下两种位置**不分隔**:① **角色注记内部**(`<action>` / `<narration>` 里)—— 否则注记正文会被切成聊天气泡、把本该隐藏的旁白发给对方;② **白名单行内元素内部**(尤其 `<code>`)—— 它是**成对**元素,从中间切开必然制造不平衡、触发「整段降级」,反而把裸标签露给用户。
|
||||||
|
- **🔴 三条硬口径(各端必须照此实现,否则跨端显示不一致)**:
|
||||||
|
1. **白名单外一律按纯文本原样显示** —— `<script>` 显示成字面量 `<script>`,**字符一个不丢**;
|
||||||
|
2. **属性一律不解析** —— `<b class=x onclick=y>` 只取标签名,**属性不得变成任何 DOM/视图属性**;
|
||||||
|
3. **结构不平衡**(未闭合 / 落单闭标签 / 交错嵌套)⇒ **该段不产生任何行内渲染,且字符一个不丢**。
|
||||||
|
⚠️ **同日晚修订**:具体粒度(「整段降级为原文」还是「只让坏的那一对保持字面」)**降级为实现细节**,契约不再强制统一 —— 这类输入极罕见(安卓扫 100 条、Web 扫 12 条,均 0 例),为它让任一端返工不划算。
|
||||||
|
- **🆕 行内标签在 IM 出口的行为**:IM 渠道只收纯聊天文本 ⇒ **成对且平衡的白名单行内标签在投递前剥除标签、保留内部文本**(`<b>18:35</b>` ⇒ `18:35`);**白名单外与结构不平衡的标签一律原样保留**(它们是文本,逐字不丢)。这与既有的 `<action>`/`<narration>`「IM 出口整体剥除」**语义不同**(那两个是角色扮演标记,要丢;行内标签是排版标记,剥样式留文本)。**`<br>` 不在此列** —— 它按上一节走「切分成多条消息」。
|
||||||
|
- **大小写口径(各端自行决定,契约不强制)**:Web 端**不敏感**(`<B>` = `<b>`);安卓端**只认小写**(与它 `parse` 只认小写 `<p>` 同口径)。两者都能通过本契约的硬口径,**这一条留待各端自定**(已与安卓端协商)。
|
||||||
|
- 🔴 **安全红线**:助手文本是**不可信内容**(模型生成 + 用户可诱导)⇒ **任何端都不许把整段内容当 HTML 注入**(Web 端禁 `dangerouslySetInnerHTML`,原生端禁等价手段)。正确姿势是**解析成结构化节点、交给各自的安全渲染原语**(Web:React 子节点自动转义)。Web 端已由「**全程无 `dangerouslySetInnerHTML`** + 节点类型里**没有属性位**」两条结构性保证锁住,并以「白名单外的 `script` 必须逐字原样」为测试断言。
|
||||||
|
- **范围克制**:**不做块级**(段落仍由 §4.16 的 `<p>` 机制表达)· **不做 `<a>`**(链接有独立安全面,且她目前不用)· **不做 Markdown**(她不用)。
|
||||||
|
- **不改既有**:`<action>` / `<narration>` 的既有行为逐字不变(Web 灰斜体、IM 出口剥除);**注记内部不参与行内渲染**(注记里写 `<b>` 仍是字面量)。
|
||||||
|
- **无回归证据**:Web 端改动前后拿 11 个消息样例过**真实气泡切分链路**逐字节对照 —— 7 个逐字节相同,4 个差异恰是含白名单标签的样例。
|
||||||
|
- **兼容性**:**服务端零变更**(无新端点/帧/字段,§2.0 计数不变)。对本契约的读者而言,本版是**新增的渲染约定**:老客户端不跟进 ⇒ 行为与 v0.88 相同(**只会继续显示裸标签**,不会崩)。
|
||||||
|
|
||||||
|
### v0.88
|
||||||
|
|
||||||
|
**「单端口红线的适用范围澄清」版 —— 不改任何端点 / 帧 / 字段,只澄清一个边界**(依据:主人 2026-10-07 口径补充「关于不允许开新端口的红线,我加一个补充。那是对 cyrene 框架自身的约束,而不是扩展插件的。」)
|
||||||
|
|
||||||
|
- 🔴 **澄清内容**:「单二进制、单端口、零跨进程 IPC」这条红线**只约束 Cyrene 框架自身** —— 框架不另开监听端口、不 HTTP 互调(§0.1 已更新措辞为「**框架**唯一对外端口」)。
|
||||||
|
- **扩展插件不受该约束**:`plugins/` 下的渠道适配器与工具、MCP server、以及插件自己拉起的本地伴随进程/服务,**可以开自己的监听端口、可以有自己的进程形态**。**那些端口不在本契约范围内** —— **客户端一律只连这一个网关端口**,不得假定能从插件端口取到任何契约面。
|
||||||
|
- 原「`qq-obv11` 渠道 OneBot 反连 server 模式」那条例外**由「例外」升格为一般规则的实例**(规格仍见 `design/08 §4`)。
|
||||||
|
- **插件端口的四条护栏(主人 2026-10-07 确认采纳)**:① 端口/地址必须由配置**显式给出**,不得默认开启、不得写死;② **默认绑 `127.0.0.1`**,要对外暴露必须由 `.env` 显式决定;③ 必须**登记日志来源与审计**;④ 端口**不得成为绕过身份/权限判定的入口**。
|
||||||
|
- **兼容性**:**零破坏性变更** —— 无新端点、无新帧、无字段形状变化、**§2.0 路由计数不变**(`14 + 25 + 40 = 79`)。唯一对客户端的要求是:**别把「插件监听端口」当成契约面**(此前也从未有过这种端口与契约的关联)。
|
||||||
|
|
||||||
|
### v0.87
|
||||||
|
|
||||||
|
**「搜索 provider 扩到八家」版 —— 名单扩充,端点形状与错误码一字未改**(切片 `agent-389`,本次提交;依据:主人「我看之前不是走有头CDP扒了好几家搜索提供商的接口方式在本地参考文档吗?为什么不都做一下适配?」+「优先只做国内能用到的几家」。)
|
||||||
|
|
||||||
|
- **新增两家已登记接入位**:`tencent_wsa`(腾讯云·元宝搜索,`POST {base}/SearchPro` + `Authorization: Bearer`,**零签名**)与 `metaso`(秘塔搜索,`POST {base}/api/v1/search`)。
|
||||||
|
- **契约面影响(只有两处,且都是名单扩充)**:① `plugin.saveProvider` 的 `values.provider` 合法名单 **六家 → 八家**(见 §4.14 的三态写回那段);② `plugin.providers` 候选新增两条 `{value,label}`(中文名见 §6 本版上方那条的逐字表)。**§2.0 计数不变**(无新端点,维持 `14 + 25 + 40 = 79`)。
|
||||||
|
- **前端零改动**:候选是数据驱动的(`optionsFrom`),中文名只是 `label`。
|
||||||
|
- ⚠️ **诚实边界(如实,别当已验)**:① **腾讯成功响应未真机实测**(无 key)—— 依据是官方文档 + 本机无 key 探测(缺 `Authorization` 也回 **HTTP 200** + `Response.Error`);② **秘塔响应 schema 全部来自第三方来源且互相打架** ⇒ 代码走**宽松解析**(多字段名回退),**未真机验证**,拿到真 key 后必须按真实报文收窄;③ 两家的请求体都**只发官方确证为必选的字段**(腾讯只发 `Query`),条数靠本地截断。
|
||||||
|
- **零新配置键**:两家只走 `$DATA_DIR/config/plugins/web-search.json`;`.env` / `.env.example` **未动**(`.env` 冻结口径不变)。
|
||||||
|
|
||||||
|
### v0.86
|
||||||
|
|
||||||
|
**「搜索结果条数上限可配」版 —— 面板多一个 number 字段 + 两个动作各多一个键**(切片 `bff57bd` + `c68387a`;依据:主人 2026-10-07 口径「返回五条结果会不会太少了?」+ 上一片登记的「`maxResults` 在面板上根本改不了」那条真缺陷。)
|
||||||
|
|
||||||
|
- **上限真源与语义**:运行期真源是**面板文件** `$DATA_DIR/config/plugins/web-search.json` 的 `maxResults`(**压过** `.env` 的 `WEB_SEARCH_MAX_RESULTS`),是**真上限** —— 模型的 `count` 只能更小(`count:50` ⇒ 只给上限那么多;`count:3` ⇒ 给 3;缺省/非法 ⇒ 上限)。代码默认值 **5 → 10**,三处口径已一致。
|
||||||
|
- **契约增量(三项,纯加性)**:① 面板声明新增字段 `maxResults`(`type:"number"`, `min:1`,前端无需改动);② `plugin.saveProvider` 的 `values` 新可写键(**三态**:缺位不改动 / 正整数采用 / 其余 **`400 invalid_payload`** 且**盘上不动**);③ 响应回显 —— `saveProvider.data.result.maxResults`(**0 = 未声明**)与 `reauthProvider.values.maxResults`(盘上有值才出现)。
|
||||||
|
- **计数**:**无新端点** ⇒ §2.0 维持 **`14 + 25 + 40 = 79`**。
|
||||||
|
- **验证证据**:整树 `go build ./...` 通过;三包 vet/测试绿;端到端「面板存 20 ⇒ 模型要 50 也只给 20、要 3 给 3」(真 action hub → 落盘 → 热重载 → `WithSearchMaxResults` → 真 dispatcher,**未重启后端**);主会话用 `-overlay` 摘掉落盘那一行 ⇒ 两条用例变红。
|
||||||
|
- ⚠️ **已知取舍(如实)**:**清空输入框 = `400`**(没有「去掉上限」的手势;要回 `.env` 兜底得改文件);**小数/负数一律拒**;文件未声明该键时 `reauth` **不回填**(该框为空,看不出当前生效值)—— 与「面板显示的是该条、不是生效值」的既有口径一致。
|
||||||
|
|
||||||
|
### v0.85(上一版)
|
||||||
|
|
||||||
|
**「整理不再只产建议」版 —— 新增 1 条端点(B25)+ 一处行为变更(轮末自动落地)+ `link` 候选停产**(切片 TIDY-AUTO `6d92536`;依据:主人 2026-10-07 口径「该删就删,怎么这个记忆整理机制这么保守呢」;`design/23 §7/§13/§14`。)
|
||||||
|
|
||||||
|
- **① 新端点 `POST /api/v1/admin/memories/candidates/reject?action=&limit=`(B25)**:按**动作**批量把 `pending` 候选标成 `rejected`。`action` **必填 + 闭集**;`limit` 缺省 1000 / 硬顶 10000;返回 `{action,limit,total,rejected,truncated}`;审计 `memory.tidy.reject`。**幂等**(再来一次 `total:0`),**只改候选状态、绝不碰记忆行**。详见 **§2.2 B25 / §4.15(e)**。
|
||||||
|
- **② 行为变更:轮末自动落地默认开**(可自动仅两类:字面重复 ∨ 事实等价 ∧ ≥0.95 的 `merge`;`one_off_event` 且不像习惯的 `archive`);六条护栏(软动作 / 每轮上限 50 / 审计 `auto=true` / 可 revert / 幂等 / 一键关回只产建议,开关落 `$DATA_DIR/config/memory-tidy.json`,**零新 env 键**)。**不自动**:`reclassify`、低置信 merge、跨分区/跨角色、改正文、受限来源。详见 **§4.15(e)**。
|
||||||
|
- **③ `link` 候选停产**:`design/23 §14` 把它列为未细化(无落点无模型),`applyable()` 也不认它 ⇒ 产出它只是噪音。存量清理由 B25 走(主会话已执行)。`TidyLink` 常量保留**只为读历史行**。
|
||||||
|
- **④ 计数**:`writeAuth` **24 → 25**、总数 **78 → 79**(`14 + 25 + 40`;`routes.go` 核:`writeAuth` 挂载的 admin 路由实有 27,按历史口径减 2 ⇒ 25)。
|
||||||
|
- **⑤ 诚实边界**:**未部署**(要一次 `build` + `restart`);**自动档的真机行为未验**;`reclassify` 仍全部人工;`TidyRuleVersion` **刻意未升版**(不改变任何判定结论;升版会让既有候选按幂等键复制重复行);面板「整理候选」页签仍未落地(前端切片)。
|
||||||
|
|
||||||
|
### v0.84(上一版)
|
||||||
|
|
||||||
|
**「显示名 + 段落标签硬约束」版 —— 一处形状变更(插件动作响应)+ 一处新增产出侧硬约束 + 一条模型面登记**(依据:主人报障「下拉里全给英文名,你甚至不愿意带个括号把中文名写上去」;安卓端登记 `<p><p>正文</p>` 畸形;`design/28 §1 M5 / §8 B5` 的浏览器降级档。切片:显示名 `c46053e`、段落修复 `8051906`、provider 配置面去 env 化 `bc1dee3`、搜索 linkless 修复 `2c56bc9`。)
|
||||||
|
|
||||||
|
- **① 形状变更:`plugin.providers` 候选从裸字符串数组 → 对象数组 `{value,label}`**。见 **§4.14(c)** 与 §2.1 A14。根因**不是**漏写文案,而是**整条链路没有「显示名」的位置**(字段声明只有 `optionsFrom`,动作回的就是 `KnownNames()` 的纯 id 数组)。中文名逐字:`SearXNG 本地兜底(searxng)` / `智谱 BigModel(zhipu)` / `阿里云 IQS 信息查询(aliyun_iqs)` / `博查 Bocha(bocha)` / `火山引擎·豆包搜索(volcengine)` / `百度千帆 AI 搜索(baidu_qianfan)` / **`腾讯云·元宝搜索(tencent_wsa)`** / **`秘塔搜索 Metaso(metaso)`**(🆕 **v0.87 起八家**)。🔴 **`label` 仅供显示、`value` 才是落盘值**;前端解码器兼容三种形状 ⇒ 其他插件零影响;表格按 `value→label` 索引显示、查不到回落原值。
|
||||||
|
- **② 新增产出侧硬约束:助手文本不得出现未配对的段落标签**(落单 `<p …>` / `</p>`)。见 **§4.16**。修的是安卓登记的 `<p><p>正文</p>`;**两半根因都在产出侧**(规整器原样放过 + 在「末段漏 `</p>`」时亲手制造),另修一个幂等漏洞。**历史行不回填**、消费方**仍应保留**容错。
|
||||||
|
- **③ 模型面登记(非客户端契约面)**:`web-fetch` 的浏览器渲染降级档已接线,返回体用 `fetchedBy`(`direct` / `browser`)**如实标注**正文实际来自哪条路;两条档的失败各自留痕,**绝不静默替换**,被 SSRF 拒绝的目标**不换路**。
|
||||||
|
- **④ provider 配置面「去 env 化」(对客户端零形状影响,登记供跨端对照)**:新增 provider 不再需要新的 `.env` 键 —— 面板文件 `$DATA_DIR/config/plugins/web-search.json` **是权威**,`.env` 只在文件缺失/为空时**播种**(文件一旦存在,「文件里没有的家」**不从 env 复活** —— 这是既有回归用例钉住的口径,本次未改);各家「没配 baseUrl 就回落代码默认值」的默认值表移进接入位表(`search.Descriptor.DefaultBaseURL`),**逐字未变**。
|
||||||
|
- **行为变更**:**有一处(且是"变真")**:① 让「文件里没有的家不从 env 复活」这条既有口径在**新家**上也成立(此前新家根本进不来);② 段落标签约束让产出**不再畸形**(对不产畸形的部署零影响)。
|
||||||
|
- **破坏性**:**无**(`plugin.providers` 只改**候选**形状且前端三形状兼容;无键被删、无端点被删、无既有语义被改)。
|
||||||
|
- **计数**:**无新端点** ⇒ §2.0 权限矩阵维持 **14 + 24 + 40 = 78**(§2.2 B1–B24)。
|
||||||
|
- **验证证据(本版实测)**:`go build ./...` + 各包 `vet` 绿;相关包测试全绿;三处变异由主会话**亲自**复现打红(把 `option.value` 换成 `option.label` ⇒ 渲染测试红,输出正是会写坏配置的 `"bocha, 智谱 BigModel(zhipu)"`;摘掉配对修复接线 ⇒ 段落用例红;把「文件里的 key」忽略 ⇒ 4 条 provider 投影用例红)。
|
||||||
|
- ⚠️ **诚实边界**:① **`web-search` 真机工具路径在登记时仍有一处未部署的修复**(详见 v0.84 横幅与 §7.2)—— 已推库、**未 build/restart**;② 显示名的**真机浏览器点击**未跑(项目无 jsdom,证据止于渲染层)—— **且 `internal/webui/dist` 是 `go:embed` 进二进制的,前端改动必须重新 `build` 才生效**(子代理曾误称"纯前端改动不需要重启",已纠正);③ **`viewer` 角色看不到 form 区块** ⇒ 表格对 viewer 仍显示裸 id(未做);④ 腾讯云元宝 / 秘塔两家的适配**尚未实现**(文档已逐页落盘)。
|
||||||
|
|
||||||
|
### v0.83(上一版)
|
||||||
|
|
||||||
|
**记忆整理「应用面」—— 五条 `writeAuth` 新端点 + 读侧 status 闸(行为修复)**(切片 TIDY-1 `454ddb0` + `9126d65` + `f847769`;依据:`design/23` §7/§8/§10/§11;主人实测口径「候选 77 条全 pending、两天一条没应用」+「重复无效记忆都一千多条了」。)
|
||||||
|
|
||||||
|
- **① 五条新端点(B20–B24)**:`GET …/memories/candidates`(候选列表,只读不审计)、`POST …/candidates/{id}/apply`(单条应用)、`POST …/candidates/apply?runId=&limit=`(批量;`runId` 省略 = 全部待定)、`POST …/candidates/{id}/revert`(单条回滚)、`POST …/memories/tidy/run`(手动真跑,写候选 + 留痕、**不自动应用**)。语义与逐字段形状见 **§2.2 B20–B24** 与 **§4.15**。审计:应用/回滚走 `memory.tidy.apply` / `memory.tidy.revert`(**只记计数与动作、不带正文**);真跑的 `memory.tidy.run` 由**领域侧**记(一次点击一条)。
|
||||||
|
- **② 读侧 status 闸(行为修复,形状零变化)**:记忆的四条**列表**读路径(`ListByPersona` / `List` / `ListScope` / `ListProject`)现在只回 `status='active'` ⇒ 被软合并(`'merged'`)/被归档(`'archived'`)的记忆**不再被召回、不再进上下文、不再出现在 `GET /admin/memories`**。修的是「软动作写了 status,读侧却不看 ⇒ 清了也白清」(生产库已有 10 条 `merged` 行是现成反例)。🔴 **两处刻意不过闸**:`Get`(撤销软合并要先读得到被吸收的行)与 `GET /admin/memories/reviews`(留痕表本身没有 status 概念,且它是查看入口 —— 加闸等于审计自我抹除)。
|
||||||
|
- **③ 调度不再饿死(对客户端零形状影响)**:整理由「ticker 从进程启动算起、重启十几次就永远走不完 24h」改为**到点即跑 + catch-up**,以 `memory_tidy_runs` 留痕行为**持久事实**判「上次跑过没有」;配 B24 作手动 panic button。
|
||||||
|
- **行为变更**:**有(一处)**:② 让软删行从检索/上下文/面板里**真的消失** —— 这正是本版的目的;对**不软删任何东西**的部署,行为与 v0.82 逐字相同。
|
||||||
|
- **破坏性**:**无**(五条都是新端点;② 是读侧收紧,不删键、不改键义、不改既有端点形状)。
|
||||||
|
- **计数**:`writeAuth` 段 **19 → 24**、管理路由总数 **73 → 78**(`14 + 24 + 40`;`routes.go` 核:经 `writeAuth` 挂载的 admin 路由实有 26,按历史口径减 §2.4(a) 的 `channels/events` 与 `.../counters` ⇒ 24)。§2.2 行数 **B1–B24**。
|
||||||
|
- **验证证据(本版实测)**:`go build ./...` + `go vet` 绿;`internal/runtime/memory` 包全量测试绿 + 新增读路径逐条断言(4 条列表 + 检索端到端「内容逐字相同的两条,软合并后只召回一条」+ `Get` 反向护栏);`internal/services/gateway` 新增面板端到端用例(真 `sql.DB` + 真 store + 真 HTTP handler);**两组变异**(overlay 把闸门改成恒真)都被打红。
|
||||||
|
- ⚠️ **诚实边界**:① **生产库全程零动作**(1191 行记忆 / 77 条候选都还没应用或清理 —— 交付的是**能力**);② **面板真机端到端未跑**(前端操作面在本版登记时未落地,并行切片在做);③ 旧候选 `run_id` 为空串 ⇒ 只能用「全部待定」档清;④ 候选表 `run_id` 列只在存储包前滚、**未**入迁移 `0019`;⑤ 远端 embedder 对同一文本返回不同向量 ⇒ **语义召回不可复现**(不影响规则层与状态机,但影响「候选是否可复现」)。
|
||||||
|
|
||||||
|
### v0.82(上一版)
|
||||||
|
|
||||||
**三面增量 —— ① `tool_execution.end` 带可选 `resultSummary`(工具执行结果实时可见,切片 Y1 `215eedf`)|② 自排的成本可见面:`?trigger=` 服务端筛选 + 新端点 `GET /admin/backthink/self-wake`(切片 Y3 `4213b26`)|③ `browser` 插件的管理面板与六个动作(切片 Y4 `e0a6bca`)**(依据:安卓端按 ① 把工具卡 UI 定死;`design/12 §N C.5/C.6` 的自排成本口径与筛选;`design/28 §8 B4` 的面板入口。)
|
**三面增量 —— ① `tool_execution.end` 带可选 `resultSummary`(工具执行结果实时可见,切片 Y1 `215eedf`)|② 自排的成本可见面:`?trigger=` 服务端筛选 + 新端点 `GET /admin/backthink/self-wake`(切片 Y3 `4213b26`)|③ `browser` 插件的管理面板与六个动作(切片 Y4 `e0a6bca`)**(依据:安卓端按 ① 把工具卡 UI 定死;`design/12 §N C.5/C.6` 的自排成本口径与筛选;`design/28 §8 B4` 的面板入口。)
|
||||||
|
|
||||||
@@ -1410,7 +1869,7 @@
|
|||||||
- **③ 入站语音 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**。
|
- **③ 入站语音 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` 的键集合与语义未变,只是**此前缺席的键现在会出现**)。
|
- **行为变更**:① 无(新端点;老端点逐字未改);② **有**(面板保存由「需重启生效」变为**下一轮即生效**;新增两个可配字段与两个错误码;`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(上一版)
|
### v0.79(上一版)
|
||||||
|
|
||||||
@@ -1418,11 +1877,11 @@
|
|||||||
|
|
||||||
- **① 插件面板动作真可用(切片 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**。
|
- **① 插件面板动作真可用(切片 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**。
|
- **② 「入库前审查」(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**。
|
- **④ 🔴 链路图权威回合标识覆盖到全部编排回合(切片 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 的读取方须改用响应值(**其余形状与缺席语义一字未改**)。
|
- **行为变更**:① **有**(面板动作由「全 501」变为**真执行**)+ 新增一批错误码;② 无(新端点;老客户端零影响);③ 无(`voice` 是既有 DTO 的可选键、`GET /media/{name}` 是新端点);④ **有(唯一一处形状变化)**:`graph.id` 对单路也追加后缀 ⇒ 自拼 id 的读取方须改用响应值(**其余形状与缺席语义一字未改**)。
|
||||||
- **破坏性**:**无**(新端点 / 新可选键 / 新错误码;④的形状变化只影响「自己拼 `graph.id`」的读取方,而本文档自 v0.73 起就写明该键**不透明、应从响应取**)。
|
- **破坏性**:**无**(新端点 / 新可选键 / 新错误码;④的形状变化只影响「自己拼 `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(上一版)
|
### v0.78(上一版)
|
||||||
|
|
||||||
@@ -1433,10 +1892,10 @@
|
|||||||
- **③ 搜索 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**。
|
- **③ 搜索 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**。
|
- **④ 账号上下线落事件 + 渠道事件实时帧(切片 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)**。
|
- **⑤ 微信 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 语音「不可转写格式」的正文诚实提示**改口径**(**形状不变**)。
|
- **行为变更**:① 无(新可选键 + 新迁移列/索引);② 无(新端点 / 新帧 / 新可选键;老客户端零影响)—— ⚠️ 但**新增了 `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(上一版)
|
### v0.77(上一版)
|
||||||
|
|
||||||
@@ -1444,7 +1903,7 @@
|
|||||||
|
|
||||||
- **① `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 页 / 后台思考页仍只显示最新一路。
|
- **① `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**。
|
- **② 记忆整理护栏抬高 + 诚实截断 + 试算异步落地(切片 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`**、不改变任何既有行为)。
|
- **行为变更**:无(① 两个新键可选、`lanes[]` 纯增量、单路行为逐字不变;② 只改默认值 / 新增一个截断标签,同步试算的响应形状未变;③ 只多了一组**以代码默认值生效**的旋钮,**不进 `.env`**、不改变任何既有行为)。
|
||||||
- **破坏性**:无。
|
- **破坏性**:无。
|
||||||
- **⚠️ 诚实边界**:① 真并行**未做真机端到端**(线上产物是旧构建;真并行是在单测层用「真 `Loop.Run` × 2 + 网关照同的 ctx 通道」复现的)—— 见 §7.4-24;② 上面 ②「抬高后真机 `compared == scanned`」**尚未被真机证实**(只有单测与量级推算支持);③ MT-B 的入库前审查**未在生产库上跑过**。
|
- **⚠️ 诚实边界**:① 真并行**未做真机端到端**(线上产物是旧构建;真并行是在单测层用「真 `Loop.Run` × 2 + 网关照同的 ctx 通道」复现的)—— 见 §7.4-24;② 上面 ②「抬高后真机 `compared == scanned`」**尚未被真机证实**(只有单测与量级推算支持);③ MT-B 的入库前审查**未在生产库上跑过**。
|
||||||
@@ -1566,11 +2025,11 @@
|
|||||||
|
|
||||||
**三批已在设计文档定稿的新增面一次性登记**:语音消息(播放端点 + 消息 DTO `voice` + 音频副本口径)、非消息事件查询面、账号在线状态(依据 `design/24 §10.11`、`design/26 §4.5` / `§2.6`)。登记时三批**都还没有实现**;其中**账号在线状态**(`52dac45`)与**非消息事件查询面**(本切片 E6)**已落地**(见下)。
|
**三批已在设计文档定稿的新增面一次性登记**:语音消息(播放端点 + 消息 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)。
|
① 新端点 `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` 并列;**刻意不复用**它,否则客户端分不清缺的是文档还是语音)。
|
③ 新错误码 **`media_not_found`(404)** → 加进 §4.1 的 404 行(与 `document_not_found` 并列;**刻意不复用**它,否则客户端分不清缺的是文档还是语音)。
|
||||||
④ 新错误形态 **`416`**(**非信封**,标准库直写)→ §0.3 / §4.1 / §4.5。
|
④ 新错误形态 **`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);未给窗口 ⇒ 不注入窗口)。
|
⑤ 新 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);未给窗口 ⇒ 不注入窗口)。
|
||||||
@@ -1665,7 +2124,7 @@
|
|||||||
|
|
||||||
### 7.1 在飞切片
|
### 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。
|
||||||
|
|
||||||
| 项 | 状态 | 冻结位置 |
|
| 项 | 状态 | 冻结位置 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -1690,7 +2149,7 @@
|
|||||||
|
|
||||||
| 项 | 现状 |
|
| 项 | 现状 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **✅ 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/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 在飞切片表 |
|
| **✅ 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 注 |
|
| **✅ 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 注 |
|
||||||
@@ -1701,8 +2160,8 @@
|
|||||||
| **env `CHAT_MAX_PARALLEL_TURNS`** | **未接线**:`Deps.MaxParallelTurns` 是构造期旋钮,但组合根未从任何 env 读它 ⇒ **生产恒为默认 2** |
|
| **env `CHAT_MAX_PARALLEL_TURNS`** | **未接线**:`Deps.MaxParallelTurns` 是构造期旋钮,但组合根未从任何 env 读它 ⇒ **生产恒为默认 2** |
|
||||||
| **`tool_execution.update` 的生产者** | wire 映射已实现,但工具分发器暂无中途进度上报源(全仓无 `StreamKindToolExecutionUpdate` 的生产者)⇒ 实际**暂无生产者**(`start`/`end` 真实发出) |
|
| **`tool_execution.update` 的生产者** | wire 映射已实现,但工具分发器暂无中途进度上报源(全仓无 `StreamKindToolExecutionUpdate` 的生产者)⇒ 实际**暂无生产者**(`start`/`end` 真实发出) |
|
||||||
| **`chat_messages.turn_id` 上的 IM 行** | IM 渠道桥**不走**网关编排器 ⇒ 其行 `turn_id` 留空属**预期**(契约里该键缺席),不是缺陷 |
|
| **`chat_messages.turn_id` 上的 IM 行** | IM 渠道桥**不走**网关编排器 ⇒ 其行 `turn_id` 留空属**预期**(契约里该键缺席),不是缺陷 |
|
||||||
| **记忆整理候选的 apply / reject / revert + 候选列表 + 运行历史**(`design/23 §11`) | **未实现**(v0.75 **只**加了「试算」B14):试算只「算给我看」、**不改任何记忆**。`GET /admin/memories/candidates`、`POST .../candidates/{id}/{apply,reject,revert}`、`GET /admin/memories/tidy/runs`、`POST /admin/memories/tidy/run` **均尚未实现**。客户端**不要**假定它们存在(`design/23` 的第一版口径本就是「只产建议,apply 才写记忆」,apply 未做的前置原因见该节开放点)。⚠️ **别与 v0.79 的 `POST /admin/memories/{id}/revert` 混淆**:后者是**入库前审查(D3)软合并**的回滚(§2.2 B17),与「整理候选」是**两件事** |
|
| **记忆整理候选的 apply / reject / revert + 候选列表 + 运行历史**(`design/23 §11`) | ⚠️ **已部分实现(v0.83,切片 TIDY-1)** —— v0.75 只加了「试算」(B14,**不改任何记忆**);v0.83 补上了**执行面**:`GET /admin/memories/candidates`(B20,只按 `status` 筛)、`POST .../candidates/{id}/apply`(B21)、`POST .../candidates/apply`(**批量**,B22)、`POST .../candidates/{id}/revert`(B23)、`POST /admin/memories/tidy/run`(**手动真跑**,B24)**都已实现并推库**。🔴 **仍缺三样(别假定它们存在)**:① **`reject`**(把候选标成「不采纳」而不应用)**没有端点**;② **运行历史读侧** `GET /admin/memories/tidy/runs` **没有**(真跑的留痕行**已经落库**在 `memory_tidy_runs`,只是没有读口);③ 候选列表**不能按 `runId` 筛**(只能按 `status`)—— 按 run 只能走**应用**那一档(B22 的 `runId`)。⚠️ **别与 v0.79 的 `POST /admin/memories/{id}/revert` 混淆**:后者是**入库前审查(D3)软合并**的回滚(按**记忆 id**,§2.2 B17),与「整理候选」(按**候选 id**)是**两件事** |
|
||||||
| **`GET /api/v1/admin/memories` 的整理相关字段**(`kind` / `tags` / `confidence` / `createdAt` / `updatedAt` / `sourceConversation` / `status` / `mergedInto` / `mergeCount`) | **未实现**:该端点当前仍只回 `{id,scope,text}`(`design/23 §11` 登记的读侧缺口)。v0.75 **未**改它的形状 ⇒ 面板暂时无法在这一页按 kind/status 过滤(试算端点 B14 的候选自带这些分区键与 kind) |
|
| **`GET /api/v1/admin/memories` 的整理相关字段**(`kind` / `tags` / `confidence` / `createdAt` / `updatedAt` / `sourceConversation` / `status` / `mergedInto` / `mergeCount`) | **未实现**:该端点当前仍只回 `{id,scope,text}`(`design/23 §11` 登记的读侧缺口)。v0.75 **未**改它的形状 ⇒ 面板暂时无法在这一页按 kind/status 过滤(试算端点 B14 的候选自带这些分区键与 kind)。⚠️ **v0.83 起服务端已按 status 过滤**(四条列表读路径只回 `active`)⇒ 被软合并 / 被归档的记忆**不再出现在这一页**;但**字段面仍未补齐** ⇒ 面板在这一页**看不到** `status`/`mergedInto`/`mergeCount`,也就**无法显示「哪些已被合并」**(那要看 **B20 候选列表**或 **B16 审查留痕**) |
|
||||||
| **✅ 记忆「入库前审查」的面板面**(`GET /api/v1/admin/memories/reviews`、`POST /api/v1/admin/memories/{id}/revert`) | **已实现(v0.79,切片 L45;v0.77 登记时无 HTTP 入口)**:两条都走 `writeAuth`(owner\|admin),形状见 §2.2 B16/B17。⚠️ **残留**:① 留痕列表**只有 `limit`、没有分页游标**(无 `before`/`offset`);② `memory_ingest_reviews` **无 TTL / GC**;③ 回滚**只回滚「被吸收行」这一侧**(不恢复存活行被抬高的 `confidence`);④ 门与 `design/23 §11` 表格的 owner-only **有差异**(待裁决)。同样是**新审计动词** `memory.review.revert`(`design/23 §16.4` 表里还没有它,需设计侧补记) |
|
| **✅ 记忆「入库前审查」的面板面**(`GET /api/v1/admin/memories/reviews`、`POST /api/v1/admin/memories/{id}/revert`) | **已实现(v0.79,切片 L45;v0.77 登记时无 HTTP 入口)**:两条都走 `writeAuth`(owner\|admin),形状见 §2.2 B16/B17。⚠️ **残留**:① 留痕列表**只有 `limit`、没有分页游标**(无 `before`/`offset`);② `memory_ingest_reviews` **无 TTL / GC**;③ 回滚**只回滚「被吸收行」这一侧**(不恢复存活行被抬高的 `confidence`);④ 门与 `design/23 §11` 表格的 owner-only **有差异**(待裁决)。同样是**新审计动词** `memory.review.revert`(`design/23 §16.4` 表里还没有它,需设计侧补记) |
|
||||||
| **✅ v0.78 插件动作的「执行器」**(`POST /admin/plugins/{id}/actions/{action}` 的动作本体) | **已接线(v0.79,切片 L1/PLG-2 执行面接线)**:插件管理器新增**动作处理器登记面**(`Manager.RegisterActions`;单一全局 `SetExecutor` 已删除),组合根在 `wirePanelHub` 里登记内置 `web-search` 的四个动作 + 候选来源 `plugin.providers` ⇒ **这些动作真执行**(真读写可变源、探针真发一次 HTTP)。**`501 action_not_implemented` 只在「已声明但宿主未登记处理器」时出现**(不再是兜底档)。✅ **v0.80(切片 W3)**:面板保存后**热重载**(重建工具 + 原子替换注册表条目,**下一轮生效、不需重启**;会让后端下次起不来的配置 ⇒ **按字节回滚** + `409 config_unstartable`);面板**已可配主 provider / 降级链**(`primary` / `fallback[]`)。⚠️ **残留**:**探针只用 `httptest` 假上游**(未对真实上游连通);**启用态不跨重启持久化**;**真机面板端到端未跑**。见 §4.14(b)(c) / §7.4-26 / §7.4-27 |
|
| **✅ v0.78 插件动作的「执行器」**(`POST /admin/plugins/{id}/actions/{action}` 的动作本体) | **已接线(v0.79,切片 L1/PLG-2 执行面接线)**:插件管理器新增**动作处理器登记面**(`Manager.RegisterActions`;单一全局 `SetExecutor` 已删除),组合根在 `wirePanelHub` 里登记内置 `web-search` 的四个动作 + 候选来源 `plugin.providers` ⇒ **这些动作真执行**(真读写可变源、探针真发一次 HTTP)。**`501 action_not_implemented` 只在「已声明但宿主未登记处理器」时出现**(不再是兜底档)。✅ **v0.80(切片 W3)**:面板保存后**热重载**(重建工具 + 原子替换注册表条目,**下一轮生效、不需重启**;会让后端下次起不来的配置 ⇒ **按字节回滚** + `409 config_unstartable`);面板**已可配主 provider / 降级链**(`primary` / `fallback[]`)。⚠️ **残留**:**探针只用 `httptest` 假上游**(未对真实上游连通);**启用态不跨重启持久化**;**真机面板端到端未跑**。见 §4.14(b)(c) / §7.4-26 / §7.4-27 |
|
||||||
| **🔴 v0.78 热装插件的面板声明来源**(`plugins/<id>/manifest.json` 读取) | **未实现**(切片 PLG-2 / PLG-1):目录能键热装插件、生命周期已接,但**没人喂声明** ⇒ 热装插件在 `GET /admin/panels` 的 `pluginIds` / `groups` 里**恒为「未声明管理页面」**。有声明的是**内置**插件(组合根装配期 `Declare`):`web-search`(v0.78 起)、**`browser` / `settings`(v0.82 起,切片 Y4)** |
|
| **🔴 v0.78 热装插件的面板声明来源**(`plugins/<id>/manifest.json` 读取) | **未实现**(切片 PLG-2 / PLG-1):目录能键热装插件、生命周期已接,但**没人喂声明** ⇒ 热装插件在 `GET /admin/panels` 的 `pluginIds` / `groups` 里**恒为「未声明管理页面」**。有声明的是**内置**插件(组合根装配期 `Declare`):`web-search`(v0.78 起)、**`browser` / `settings`(v0.82 起,切片 Y4)** |
|
||||||
@@ -1723,7 +2182,7 @@
|
|||||||
- **`task_done` / `automation_result` / `subagent_result` 不是会话消息**:它们**不进消息 store、不是气泡**(后端也没有把它们写成 `chat_messages` 行;`subagent_result` 更是**错过即永久丢**)。客户端应把它们落到自己的可观测面(Web 端落在本端 WS 事件回显 + 任务日志)。
|
- **`task_done` / `automation_result` / `subagent_result` 不是会话消息**:它们**不进消息 store、不是气泡**(后端也没有把它们写成 `chat_messages` 行;`subagent_result` 更是**错过即永久丢**)。客户端应把它们落到自己的可观测面(Web 端落在本端 WS 事件回显 + 任务日志)。
|
||||||
- `GET /api/v1/files/{name}` **无鉴权**(能力 URL);文档读侧相反、**必须 Bearer**。
|
- `GET /api/v1/files/{name}` **无鉴权**(能力 URL);文档读侧相反、**必须 Bearer**。
|
||||||
- `GET /admin/llm/usage` **不按 `session_id`** 过滤/分组(尽管该列已落库)。
|
- `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。
|
- `/audio/voice-clone` **真建音色**需公网参考音频 URL(当前无,待提供)+ 北京地域 API Key。
|
||||||
- 可观测三帧 `observability_*`:`?level=` 分级过滤**由客户端本地做**(帧广播给所有 owner/admin 连接,订阅只决定循环跑不跑)。审计 tab 仍 5s REST 轮询(**未做** `observability_audit` 帧)。
|
- 可观测三帧 `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。
|
- **`thinking` 恒缺席(v0.66 口径更正)**:消息 DTO 的 `thinking` 在**所有**读取面都不出现(`history.Message.Thinking` 全仓无写入者)。思维链只有实时 `thinking.*` 帧(owner/admin)。**旧文档称「历史思维链可由 `GET /sessions/{id}/messages` 返回」是错的**,见 §3.4.4。
|
||||||
@@ -1734,7 +2193,7 @@
|
|||||||
- **账号级限流字段是「值语义」,不是三态(v0.69 明确)**:`groupMsgPerMin`/`msgPerMin`/`maxMessages`/`directMsgPerMin` 在请求体里**省略即 `0`(未设置)**—— 一次「只改 token」的 PUT 若不带这四个键,会把已设限额(含 `directMsgPerMin:-1`)重置为 `0`。要保住就必须回送(面板已如此做)。这是 v0.68 起既有的顶层字段口径,v0.69 **未改**。
|
- **账号级限流字段是「值语义」,不是三态(v0.69 明确)**:`groupMsgPerMin`/`msgPerMin`/`maxMessages`/`directMsgPerMin` 在请求体里**省略即 `0`(未设置)**—— 一次「只改 token」的 PUT 若不带这四个键,会把已设限额(含 `directMsgPerMin:-1`)重置为 `0`。要保住就必须回送(面板已如此做)。这是 v0.68 起既有的顶层字段口径,v0.69 **未改**。
|
||||||
- **后台思考行的实时帧只带记录里的字段(v0.70 边界)**:`urgency` 在实时帧上一定是**精确档位**(记录落库值),`urgencyLowerBound` / `quietHoursBypassed` **不在记录里**、因此实时帧不带 —— 它们由网关读历史时按安静时段窗口推导(见 §4.10(b))。⇒ 实时行的「穿透安静时段」标记要等重开页面/历史拉取才出现,**这不等于**「这一轮没有穿透」。
|
- **后台思考行的实时帧只带记录里的字段(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)。
|
- **`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、不记正文**。
|
- **`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 键新增/删除。
|
- **`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 尚未回读)。
|
- **`channel_event` 是 best-effort 增量提示、且当前只覆盖一路(v0.78 边界)**:帧可能在 hub 发送缓冲满时**丢掉该连接的那一帧** ⇒ 客户端**必须**保留 REST 轮询兜底(以轮询结果为准重排)。且目前**只有账号状态迁移**会产帧(其余渠道事件的实时装饰器**未接线**,见 §7.2);帧里那行的 `id` **可能整缺**(刚落库、id 尚未回读)。
|
||||||
@@ -1788,7 +2247,7 @@
|
|||||||
|
|
||||||
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`)。
|
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 的兜底上限)。**为什么验不了**:本波禁换产物 / 禁重启后端(红线:后端常驻)。
|
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 的兜底上限)。**为什么验不了**:本波禁换产物 / 禁重启后端(红线:后端常驻)。
|
||||||
|
|
||||||
@@ -1796,4 +2255,4 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*本文件由「以代码为真源」维护(当前 `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 条目。*
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
### ✅ 已实现
|
### ✅ 已实现
|
||||||
|
|
||||||
- **单二进制、进程内协作** — 所有服务同进程打包,仅网关一个对外端口(默认 `:8080`);模块间走进程内接口 + 事件总线,**零跨进程 IPC**。唯一登记例外:`qq-obv11` 渠道的 OneBot 反连(server)模式可开监听端口,且**必须由配置显式给出 `listen_addr`**,不默认开
|
- **单二进制、进程内协作** — **框架**的所有服务同进程打包、**框架**仅网关一个对外端口(默认 `:8080`);框架模块间走进程内接口 + 事件总线,**零跨进程 IPC**。🔴 **2026-10-07 口径补充:该红线只约束框架自身,不约束扩展插件** —— `plugins/` 下的渠道适配器与工具、MCP server、以及插件自己拉起的本地伴随服务**可以开自己的监听端口、可以有自己的进程形态**(原「`qq-obv11` 反连 server 模式」那条例外随之升格为一般规则的实例)。插件端口仍须:**由配置显式给出**、**默认绑 `127.0.0.1`**、**登记日志与审计**、**不得绕过身份判定**
|
||||||
- **人格化陪伴** — 多人格(`configs/personas/*.yaml`)/ 情绪状态机 / 好感度;**人格 = 身份**(不限制能力)、per-persona 记忆隔离、能力全局共享
|
- **人格化陪伴** — 多人格(`configs/personas/*.yaml`)/ 情绪状态机 / 好感度;**人格 = 身份**(不限制能力)、per-persona 记忆隔离、能力全局共享
|
||||||
- **通用 Agent 运行时** — 多供应商 LLM(per-role 路由 + 降级:主 → 备 → 本地,本地级默认关)、工具调用(**8 轮上限**)、流式输出、子代理编排(生命周期 / 结果合并 / 并行上限 / 超时 / 防失控)、会话持久化 + 主·次级会话、上下文压缩(预算按模型窗口推导)
|
- **通用 Agent 运行时** — 多供应商 LLM(per-role 路由 + 降级:主 → 备 → 本地,本地级默认关)、工具调用(**8 轮上限**)、流式输出、子代理编排(生命周期 / 结果合并 / 并行上限 / 超时 / 防失控)、会话持久化 + 主·次级会话、上下文压缩(预算按模型窗口推导)
|
||||||
- **记忆系统** — **三类作用域隔离**(用户习惯全局共享 / 角色日常他角色只读 / 项目人人读写 + 项目索引分层)、相关性 + 最近度检索、衰减、溯源、RAG;**LLM 异步提取**(每轮落定后后台提取,手工提取兜底);**定期记忆整理**(低峰后台任务,只产候选建议、绝不自动改写);**入库前审查**(D3:新记忆落库前先判定 merge / modify / create / skip,软合并可 `revert` 回滚)
|
- **记忆系统** — **三类作用域隔离**(用户习惯全局共享 / 角色日常他角色只读 / 项目人人读写 + 项目索引分层)、相关性 + 最近度检索、衰减、溯源、RAG;**LLM 异步提取**(每轮落定后后台提取,手工提取兜底);**定期记忆整理**(低峰后台任务,只产候选建议、绝不自动改写);**入库前审查**(D3:新记忆落库前先判定 merge / modify / create / skip,软合并可 `revert` 回滚)
|
||||||
|
|||||||
+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)
|
log.Warn("channel account build skipped", "platform", platform, "account", acct.ID, "error", berr)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
logFlowControl(log, built)
|
||||||
out = append(out, built)
|
out = append(out, built)
|
||||||
}
|
}
|
||||||
return out
|
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>_* 适配器,加上
|
// channelAccountsFromEnv 合成 env 兜底:单账号 CHANNEL_<NAME>_* 适配器,加上
|
||||||
// CHANNELS_JSON 里的多账号条目。它是 channels.json 缺失或非法时传给
|
// CHANNELS_JSON 里的多账号条目。它是 channels.json 缺失或非法时传给
|
||||||
// runtimeconfig.LoadChannels 的 .env 兜底。.env 只承载必要的连接兜底
|
// runtimeconfig.LoadChannels 的 .env 兜底。.env 只承载必要的连接兜底
|
||||||
@@ -174,8 +225,18 @@ type namedAdapter struct {
|
|||||||
denyGroups []string
|
denyGroups []string
|
||||||
allowUsers []string
|
allowUsers []string
|
||||||
denyUsers []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
|
groupMsgPerMin int
|
||||||
directMsgPerMin int
|
directMsgPerMin int
|
||||||
msgPerMin int
|
msgPerMin int
|
||||||
|
|||||||
@@ -125,6 +125,13 @@ func run() error {
|
|||||||
return fmt.Errorf("start gateway: %w", err)
|
return fmt.Errorf("start gateway: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// emotionTTS 自启(切片 M47):**在网关 Listen 之后**才挂 —— 网关的
|
||||||
|
// ListenAndServe 已交给自己的 goroutine,这里只投递一次「探活 → 必要时拉起」,
|
||||||
|
// 整段(毫秒级探活 + 起一个 .bat)都在后台跑,因此**不占**后端启动时长:走到
|
||||||
|
// 这里时进程已经可以服务。开关默认关(面板文件 voice-tts-launch.json),
|
||||||
|
// 失败只 WARN,绝不影响后端启动与退出。
|
||||||
|
a.startVoiceTTSLaunch(ctx)
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
a.log.Info("cyrene signal received, shutting down")
|
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
|
||||||
|
}
|
||||||
@@ -33,7 +33,11 @@ func (e webSearchActionEnv) listProviders(_ context.Context, _ pluginmgr.ActionR
|
|||||||
return map[string]any{"rows": providerRows(f)}, nil
|
return map[string]any{"rows": providerRows(f)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// listProviderOptions 给出下拉候选:本期全部已登记的 provider 名(含尚未配置的家)。
|
// listProviderOptions 给出下拉候选:本期全部已登记的 provider(含尚未配置的家)。
|
||||||
|
//
|
||||||
|
// 每项是 `{value,label}`(search.ProviderOption):**`value` 才是落盘的值** —— 它逐字等于
|
||||||
|
// search.KnownNames(),会被写进 web-search.json 并被注册表按名字取用;`label` 只是显示名
|
||||||
|
// (中文 + 括注 id),前端拿它渲染下拉与表格,任何写盘路径都不该碰它。
|
||||||
//
|
//
|
||||||
// 给全部而不是「已配置的家」的理由见 search.ProviderOptions:表单的用途就是**新增**
|
// 给全部而不是「已配置的家」的理由见 search.ProviderOptions:表单的用途就是**新增**
|
||||||
// 一家,只列已配置的会让第一次配置无从选起。
|
// 一家,只列已配置的会让第一次配置无从选起。
|
||||||
@@ -48,9 +52,11 @@ func (e webSearchActionEnv) listProviderOptions(_ context.Context, _ pluginmgr.A
|
|||||||
// - apiKey **空串** ⇒ 显式清空(前端只有勾了「清除」才会送出空串);
|
// - apiKey **空串** ⇒ 显式清空(前端只有勾了「清除」才会送出空串);
|
||||||
// - baseUrl / enabled 缺位或空串 ⇒ 保留;给出(含 false / 空地址)⇒ 采用。
|
// - baseUrl / enabled 缺位或空串 ⇒ 保留;给出(含 false / 空地址)⇒ 采用。
|
||||||
//
|
//
|
||||||
// 同一次提交里还可能带两个**文件级**字段(`primary` / `fallback`,切片 W3):它们不是这一条
|
// 同一次提交里还可能带三个**文件级**字段(`primary` / `fallback` / `maxResults`):它们不是
|
||||||
// provider 的属性,而是「谁先用 / 失败后按什么顺序降级」,因此合并到文件级而不是条目上
|
// 这一条 provider 的属性,而是「谁先用 / 失败后按什么顺序降级 / 单次最多几条」,因此合并到
|
||||||
// (解析与校验在 websearch_selection.go)。缺位 = 不改动,与其余字段同一套哨兵语义。
|
// 文件级而不是条目上(解析与校验在 websearch_selection.go)。缺位 = 不改动;但给出了**不合法**
|
||||||
|
// 的值(如 `maxResults` ≤0 / 非整数)⇒ 整笔 400,**绝不静默丢弃** —— 静默丢弃会让用户以为
|
||||||
|
// 上限已经改了,而跑的还是旧的那个(那正是这一片要消灭的那条「我改了它却没变」)。
|
||||||
//
|
//
|
||||||
// 落盘之后由组合根的热重载事务接管(`websearch_reload.go`:重建工具 + 换注册表条目,
|
// 落盘之后由组合根的热重载事务接管(`websearch_reload.go`:重建工具 + 换注册表条目,
|
||||||
// 失败则连同本次落盘一起回滚)—— 本函数只负责「把改动存进去」这一件事。
|
// 失败则连同本次落盘一起回滚)—— 本函数只负责「把改动存进去」这一件事。
|
||||||
@@ -79,11 +85,20 @@ func (e webSearchActionEnv) saveProvider(_ context.Context, req pluginmgr.Action
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
entry.Provider = name
|
entry.Provider = name
|
||||||
next, err := e.applySelectionPatch(cur, values)
|
next, setPrimary, err := e.mergeSelectionPatch(cur, values)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := runtimeconfig.SaveWebSearch(e.path, runtimeconfig.UpsertProviderEntry(next, entry)); err != nil {
|
final := runtimeconfig.UpsertProviderEntry(next, entry)
|
||||||
|
// 🔴 校验必须在**并条之后**(顺序理由见 websearch_selection.go 的 mergeSelectionPatch):
|
||||||
|
// 主 provider 配没配齐,取决于**这次刚落进文件的凭据** —— 拿旧文件校验会把
|
||||||
|
// 「第一次填 key + 同一次提交里选它」整笔回滚,连 key 一起丢。
|
||||||
|
if setPrimary {
|
||||||
|
if err := e.checkPrimaryUsable(final, final.Provider); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := runtimeconfig.SaveWebSearch(e.path, final); err != nil {
|
||||||
e.log.Error("web-search panel config write failed", "path", e.path, "provider", name, "error", err)
|
e.log.Error("web-search panel config write failed", "path", e.path, "provider", name, "error", err)
|
||||||
return nil, pluginmgr.NewActionError(actionCodeConfigWriteFailed,
|
return nil, pluginmgr.NewActionError(actionCodeConfigWriteFailed,
|
||||||
"面板配置写不进去(磁盘或权限问题),本次改动**没有**生效", http.StatusInternalServerError, err)
|
"面板配置写不进去(磁盘或权限问题),本次改动**没有**生效", http.StatusInternalServerError, err)
|
||||||
@@ -95,10 +110,12 @@ func (e webSearchActionEnv) saveProvider(_ context.Context, req pluginmgr.Action
|
|||||||
"provider": name,
|
"provider": name,
|
||||||
"created": !existed,
|
"created": !existed,
|
||||||
"enabled": entry.IsEnabled(),
|
"enabled": entry.IsEnabled(),
|
||||||
// 文件级选择的**落盘后**取值(没给 primary/fallback 时也如实回显当前值):
|
// 文件级取值的**落盘后**样子(没给这几个键时也如实回显当前值):面板按它回显
|
||||||
// 面板按它回显「谁先用」,也让「这次保存有没有动到选择」有可机读的答案。
|
// 「谁先用」,也让「这次保存有没有动到选择 / 上限」有可机读的答案。
|
||||||
"primary": strings.ToLower(strings.TrimSpace(next.Provider)),
|
"primary": strings.ToLower(strings.TrimSpace(final.Provider)),
|
||||||
"fallback": selectionNames(next.Fallback),
|
"fallback": selectionNames(final.Fallback),
|
||||||
|
// maxResults 的 0 = 文件里未声明(由 `.env` / 代码默认兜底),原样回显,不编一个假值。
|
||||||
|
"maxResults": final.MaxResults,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,6 +171,10 @@ func (e webSearchActionEnv) testProvider(ctx context.Context, req pluginmgr.Acti
|
|||||||
// 只把它算作「盘上已有值」⇒ 留空 = 不改动、要清空必须显式勾选;
|
// 只把它算作「盘上已有值」⇒ 留空 = 不改动、要清空必须显式勾选;
|
||||||
// - 其余字段回**盘上那条的原样**,不做默认值回填:回填代码默认地址会让一次
|
// - 其余字段回**盘上那条的原样**,不做默认值回填:回填代码默认地址会让一次
|
||||||
// 「只改 key」的保存把默认地址固化进文件(面板显示的是「该条」,不是「生效值」)。
|
// 「只改 key」的保存把默认地址固化进文件(面板显示的是「该条」,不是「生效值」)。
|
||||||
|
//
|
||||||
|
// 文件级字段(`maxResults` 在内)也按同一条口径回填:**盘上有值才回**。上限不回填的话表单
|
||||||
|
// 里那个框是空的,用户既看不见当前上限、也无从改起;而盘上没写(0 = 未声明)时**不回** 0 ——
|
||||||
|
// 0 会被前端当成一个值送回来,写侧又拒 ≤0,那会让「只改 key」的保存整笔进不去。
|
||||||
func (e webSearchActionEnv) reauthProvider(_ context.Context, req pluginmgr.ActionRequest) (any, error) {
|
func (e webSearchActionEnv) reauthProvider(_ context.Context, req pluginmgr.ActionRequest) (any, error) {
|
||||||
name, err := providerNameOf(req.Payload)
|
name, err := providerNameOf(req.Payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -172,8 +193,9 @@ func (e webSearchActionEnv) reauthProvider(_ context.Context, req pluginmgr.Acti
|
|||||||
"baseUrl": entry.BaseURL,
|
"baseUrl": entry.BaseURL,
|
||||||
"enabled": !exists || entry.IsEnabled(),
|
"enabled": !exists || entry.IsEnabled(),
|
||||||
}
|
}
|
||||||
// 两个文件级字段一并回填:不然用户「清空降级链」在前端的三态写回里会落成缺位
|
// 三个文件级字段一并回填(主 provider / 降级链 / 条数上限):不然用户「清空降级链」在前端
|
||||||
// (= 不改动),「我清掉了它」就成了一句假话。
|
// 的三态写回里会落成缺位(= 不改动),「我清掉了它」就成了一句假话;上限同理 ——
|
||||||
|
// 不回填就看不见盘上的值,改上限也就无从下手。
|
||||||
addSelectionValues(values, f)
|
addSelectionValues(values, f)
|
||||||
if masked, ok := maskedProviderEntry(f, name); ok && strings.TrimSpace(masked.APIKey) != "" {
|
if masked, ok := maskedProviderEntry(f, name); ok && strings.TrimSpace(masked.APIKey) != "" {
|
||||||
values["apiKey"] = masked.APIKey
|
values["apiKey"] = masked.APIKey
|
||||||
@@ -239,29 +261,12 @@ func maskedProviderEntry(f runtimeconfig.WebSearchFile, name string) (runtimecon
|
|||||||
}
|
}
|
||||||
|
|
||||||
// effectiveBaseURL 给出一条配置**实际会用的**基地址:盘上有就用盘上的,没有就回落到
|
// effectiveBaseURL 给出一条配置**实际会用的**基地址:盘上有就用盘上的,没有就回落到
|
||||||
// 代码默认值(各云家的厂商默认地址)。
|
// **该接入位的代码默认基地址**(`plugins/tools/io/search` 的接入位表,唯一出处)。
|
||||||
//
|
//
|
||||||
// searxng 不回落(空地址 = 关掉这家,回落会让「清空地址」变成假话)—— 与
|
// 进位链与装配期**同一个函数**(resolveBaseURL):searxng 的默认基地址是空 —— 它的地址
|
||||||
// `effectiveSearchConfig` 的同一条权威边界。
|
// 为空本身就是「关掉这家」,回落会让「清空地址」变成假话。
|
||||||
func (e webSearchActionEnv) effectiveBaseURL(name string, entry runtimeconfig.ProviderEntry) string {
|
func (e webSearchActionEnv) effectiveBaseURL(name string, entry runtimeconfig.ProviderEntry) string {
|
||||||
if base := strings.TrimSpace(entry.BaseURL); base != "" {
|
return resolveBaseURL(name, entry.BaseURL)
|
||||||
return base
|
|
||||||
}
|
|
||||||
if strings.EqualFold(name, search.NameSearXNG) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return e.defaultBaseURL(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
// defaultBaseURL 取某家云 provider 的代码默认基地址(来自 `.env` 解析后的配置,未写该键
|
|
||||||
// 时就是标签默认值)。名字表复用组合根既有那张(`searchProviderVector`),不新造一份。
|
|
||||||
func (e webSearchActionEnv) defaultBaseURL(name string) string {
|
|
||||||
for _, p := range searchProviderVector(e.envCfg) {
|
|
||||||
if strings.EqualFold(p.Name, name) {
|
|
||||||
return strings.TrimSpace(p.BaseURL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// probeTimeout 给出一次探针的超时上限:面板文件的 timeout 优先(面板里刚存的就是它),
|
// probeTimeout 给出一次探针的超时上限:面板文件的 timeout 优先(面板里刚存的就是它),
|
||||||
|
|||||||
@@ -0,0 +1,286 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// 本文件钉住「面板文件的 `maxResults` 就是运行期的条数上限」这条路**通了**
|
||||||
|
// (主人 2026-10-07 裁定;口径见 design/27 §3 与 `cmd/cyrene/wire_io_tools.go` 的
|
||||||
|
// `effectiveSearchConfig` —— 面板文件是可变源,`.env` 只播种)。
|
||||||
|
//
|
||||||
|
// 上半部分直接写盘(测投影:文件里的 `maxResults` 就是真上限);下半部分走**面板动作**
|
||||||
|
// (`plugin.saveProvider` 带上 `maxResults` ⇒ 热重载 ⇒ 下一次搜索真的最多那么多条),
|
||||||
|
// 并钉住三态:缺位 = 不改动、正整数 = 采用、其余 = 400 invalid_payload(绝不静默丢弃)。
|
||||||
|
//
|
||||||
|
// 夹具一律写进**本用例自己的** `$DATA_DIR`(`wsLoadConfig` 的 `t.TempDir()`):
|
||||||
|
// 生产文件 `data/config/plugins/web-search.json` 不在测试的触碰范围内。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
|
||||||
|
)
|
||||||
|
|
||||||
|
// wsManyResultsBody 造一份 searxng 形状、n 条结果的假上游应答。
|
||||||
|
// link 逐条不同:`search.Finalize` 按 link 去重,重复的 link 会让「截断到 N」看不出效果。
|
||||||
|
func wsManyResultsBody(n int) string {
|
||||||
|
items := make([]string, 0, n)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
items = append(items, fmt.Sprintf(
|
||||||
|
`{"title":"T%d","url":"https://r%d.example/%d","content":"c%d"}`, i, i, i, i))
|
||||||
|
}
|
||||||
|
return `{"results":[` + strings.Join(items, ",") + `]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// wsPanelWithMaxResults 把一份夹具面板配置写进 cfg 的数据目录(maxResults 给 0 = 未声明)。
|
||||||
|
func wsPanelWithMaxResults(t *testing.T, cfg *config.Config, maxResults int, upstream string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := runtimeconfig.SaveWebSearch(wsPanelPath(cfg), runtimeconfig.WebSearchFile{
|
||||||
|
Version: runtimeconfig.WebSearchVersion,
|
||||||
|
Provider: search.NameSearXNG,
|
||||||
|
Fallback: []string{},
|
||||||
|
MaxResults: maxResults,
|
||||||
|
Providers: []runtimeconfig.ProviderEntry{
|
||||||
|
{Provider: search.NameSearXNG, BaseURL: upstream, Enabled: runtimeconfig.EnableFlag(true)},
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("写面板配置: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wsSearchCount 走注册面里那把 web-search(组合根装配出来的那一把)打一次,返回条数。
|
||||||
|
func wsSearchCount(t *testing.T, a *app, args map[string]any) int {
|
||||||
|
t.Helper()
|
||||||
|
got, err := a.dispatcher.Invoke(context.Background(), webSearchToolName, args)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Invoke: %v", err)
|
||||||
|
}
|
||||||
|
m, ok := got.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("工具返回值类型 = %T", got)
|
||||||
|
}
|
||||||
|
n, ok := m["count"].(int)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("count 不是 int: %#v", m["count"])
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebSearchPanelMaxResultsIsTheRuntimeLimit 是这条路的主判据:`.env` 写 5、
|
||||||
|
// 面板文件写 20 ⇒ 生效值必须是 **20**(面板优先),且它仍是**真上限**
|
||||||
|
// (模型要 50 也只给 20、要 3 就给 3)。
|
||||||
|
func TestWebSearchPanelMaxResultsIsTheRuntimeLimit(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
up := newWSFakeUpstream(t, http.StatusOK, wsManyResultsBody(30))
|
||||||
|
cfg := wsLoadConfig(t,
|
||||||
|
"WEB_SEARCH_PROVIDER="+search.NameSearXNG,
|
||||||
|
"WEB_SEARCH_MAX_RESULTS=5",
|
||||||
|
)
|
||||||
|
wsPanelWithMaxResults(t, cfg, 20, up.url)
|
||||||
|
a := wsSurfaceApp(t, cfg, nil)
|
||||||
|
|
||||||
|
if got := wsSearchCount(t, a, map[string]any{"query": "cyrene", "count": 50}); got != 20 {
|
||||||
|
t.Fatalf("count = %d, want 20(模型要 50,上限是面板文件里的 20)", got)
|
||||||
|
}
|
||||||
|
if got := wsSearchCount(t, a, map[string]any{"query": "cyrene", "count": 3}); got != 3 {
|
||||||
|
t.Fatalf("count = %d, want 3(模型要得更少时尊重模型)", got)
|
||||||
|
}
|
||||||
|
if got := wsSearchCount(t, a, map[string]any{"query": "cyrene"}); got != 20 {
|
||||||
|
t.Fatalf("count = %d, want 20(模型没给 count ⇒ 用面板文件的上限)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebSearchPanelMaxResultsUnsetFallsBackToEnv 钉住投影的另一半:
|
||||||
|
// 面板文件里 `maxResults` **缺位**(0 = 未声明)⇒ 沿用 `.env` 那条既有键的值。
|
||||||
|
func TestWebSearchPanelMaxResultsUnsetFallsBackToEnv(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
up := newWSFakeUpstream(t, http.StatusOK, wsManyResultsBody(30))
|
||||||
|
cfg := wsLoadConfig(t,
|
||||||
|
"WEB_SEARCH_PROVIDER="+search.NameSearXNG,
|
||||||
|
"WEB_SEARCH_MAX_RESULTS=7",
|
||||||
|
)
|
||||||
|
wsPanelWithMaxResults(t, cfg, 0, up.url)
|
||||||
|
a := wsSurfaceApp(t, cfg, nil)
|
||||||
|
|
||||||
|
if got := wsSearchCount(t, a, map[string]any{"query": "cyrene", "count": 50}); got != 7 {
|
||||||
|
t.Fatalf("count = %d, want 7(面板 maxResults 未声明 ⇒ 回落 .env 的 7)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebSearchDefaultsToTenWithoutPanelOrEnvGivesTen 钉住代码默认值这一档:
|
||||||
|
// `.env` 与面板文件都没给条数 ⇒ io 包的共享上限(= 10),且它同样是真上限。
|
||||||
|
func TestWebSearchDefaultsToTenWithoutPanelOrEnvGivesTen(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
up := newWSFakeUpstream(t, http.StatusOK, wsManyResultsBody(30))
|
||||||
|
cfg := wsLoadConfig(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
|
||||||
|
wsPanelWithMaxResults(t, cfg, 0, up.url) // 0 = 未声明;.env 也没给
|
||||||
|
a := wsSurfaceApp(t, cfg, nil)
|
||||||
|
|
||||||
|
if got := wsSearchCount(t, a, map[string]any{"query": "cyrene", "count": 50}); got != 10 {
|
||||||
|
t.Fatalf("count = %d, want 10(代码默认值)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 面板动作这一侧:存得进去、真的生效、非法值如实报错 -------------------------
|
||||||
|
|
||||||
|
// wsMaxResultsEnv 是一份「`.env` 给 envMaxResults、假上游答 30 条」的面板动作夹具
|
||||||
|
// (热重载面 + 分派器都在:`newReloadTestEnv`)。`.env` 那个值是留给「面板压过 `.env`」
|
||||||
|
// 这条判据当对照用的。
|
||||||
|
func wsMaxResultsEnv(t *testing.T, envMaxResults int) (*reloadTestEnv, *wsFakeUpstream) {
|
||||||
|
t.Helper()
|
||||||
|
up := newWSFakeUpstream(t, http.StatusOK, wsManyResultsBody(30))
|
||||||
|
env := newReloadTestEnv(t,
|
||||||
|
"WEB_SEARCH_PROVIDER="+search.NameSearXNG,
|
||||||
|
"WEB_SEARCH_ENDPOINT="+up.url,
|
||||||
|
fmt.Sprintf("WEB_SEARCH_MAX_RESULTS=%d", envMaxResults),
|
||||||
|
)
|
||||||
|
return env, up
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebSearchPanelSaveAppliesMaxResults 是本片的主判据(端到端):面板提交里带上
|
||||||
|
// `maxResults: 20` ⇒ 落盘 20 ⇒ **热重载**后下一次搜索真的最多 20 条 —— `.env` 的 5 不作数,
|
||||||
|
// 也没有重启后端。
|
||||||
|
func TestWebSearchPanelSaveAppliesMaxResults(t *testing.T) {
|
||||||
|
env, up := wsMaxResultsEnv(t, 5)
|
||||||
|
outcome := env.mustSave(t, map[string]any{
|
||||||
|
"provider": search.NameSearXNG, "baseUrl": up.url, "enabled": true,
|
||||||
|
"maxResults": 20,
|
||||||
|
})
|
||||||
|
if !outcome.Applied || !outcome.Registered {
|
||||||
|
t.Fatalf("hotReload = %+v, want applied 且 registered", outcome)
|
||||||
|
}
|
||||||
|
if got := env.loadFile(t).MaxResults; got != 20 {
|
||||||
|
t.Fatalf("盘上 maxResults = %d, want 20(面板给的值必须真的落盘)", got)
|
||||||
|
}
|
||||||
|
if got := wsSearchCount(t, env.app, map[string]any{"query": "cyrene", "count": 50}); got != 20 {
|
||||||
|
t.Fatalf("count = %d, want 20(模型要 50,上限是面板刚存下的 20 —— 且没重启)", got)
|
||||||
|
}
|
||||||
|
if got := wsSearchCount(t, env.app, map[string]any{"query": "cyrene", "count": 3}); got != 3 {
|
||||||
|
t.Fatalf("count = %d, want 3(模型要得更少时尊重模型)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebSearchPanelSaveWithoutMaxResultsKeepsStoredValue 钉住三态的第一态:提交里**缺位**
|
||||||
|
// ⇒ 盘上那个上限一个字节都不动(「我没改它」必须真的是没改),运行中的生效值也照旧。
|
||||||
|
//
|
||||||
|
// 前置的 20 是**直接写盘**的(不经过写侧):这条判据只关心「缺位的那次保存有没有动它」,
|
||||||
|
// 不该依赖另一条用例的能力。
|
||||||
|
func TestWebSearchPanelSaveWithoutMaxResultsKeepsStoredValue(t *testing.T) {
|
||||||
|
env, up := wsMaxResultsEnv(t, 5)
|
||||||
|
if err := runtimeconfig.SaveWebSearch(env.path, runtimeconfig.WebSearchFile{
|
||||||
|
Version: runtimeconfig.WebSearchVersion,
|
||||||
|
Provider: search.NameSearXNG,
|
||||||
|
MaxResults: 20,
|
||||||
|
Providers: []runtimeconfig.ProviderEntry{
|
||||||
|
{Provider: search.NameSearXNG, BaseURL: up.url, Enabled: runtimeconfig.EnableFlag(true)},
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("写面板配置: %v", err)
|
||||||
|
}
|
||||||
|
env.mustSave(t, map[string]any{
|
||||||
|
"provider": search.NameSearXNG, "baseUrl": up.url, "enabled": true,
|
||||||
|
})
|
||||||
|
if got := env.loadFile(t).MaxResults; got != 20 {
|
||||||
|
t.Fatalf("缺位的一次保存把上限改成了 %d, want 20(缺位 = 不改动)", got)
|
||||||
|
}
|
||||||
|
if got := wsSearchCount(t, env.app, map[string]any{"query": "cyrene", "count": 50}); got != 20 {
|
||||||
|
t.Fatalf("count = %d, want 20(上限没被动过,热重载后的生效值也该照旧)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebSearchPanelSaveRejectsInvalidMaxResults 钉住三态的第三态:给了值却用不了 ⇒
|
||||||
|
// **400 invalid_payload**,盘上一个字节都不动 —— 绝不静默忽略。静默忽略正是这一片要消灭的
|
||||||
|
// 那条「我改了它却没变」(旧行为就是:手塞进去的值被写侧无声丢掉,文件不变、也不报错)。
|
||||||
|
func TestWebSearchPanelSaveRejectsInvalidMaxResults(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
value any
|
||||||
|
}{
|
||||||
|
{"零", 0},
|
||||||
|
{"负数", -3},
|
||||||
|
{"小数", 2.5},
|
||||||
|
{"空串(前端清空输入框送的就是它)", ""},
|
||||||
|
{"非数字字符串", "twenty"},
|
||||||
|
{"布尔", true},
|
||||||
|
{"数组", []any{20}},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
env, up := wsMaxResultsEnv(t, 5)
|
||||||
|
before := env.rawFile(t)
|
||||||
|
_, err := env.save(map[string]any{
|
||||||
|
"provider": search.NameSearXNG, "baseUrl": up.url, "enabled": true,
|
||||||
|
"maxResults": tc.value,
|
||||||
|
})
|
||||||
|
ae, ok := pluginmgr.AsActionError(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("err = %v, want 结构化 ActionError(非法上限绝不能被静默忽略)", err)
|
||||||
|
}
|
||||||
|
if ae.Code != pluginmgr.ActionCodeInvalidPayload || ae.Status != http.StatusBadRequest {
|
||||||
|
t.Fatalf("失败 = %d/%s, want 400/%s", ae.Status, ae.Code, pluginmgr.ActionCodeInvalidPayload)
|
||||||
|
}
|
||||||
|
if got := env.rawFile(t); !bytes.Equal(got, before) {
|
||||||
|
t.Fatalf("被拒的保存动了盘上的文件:\nbefore=%s\nafter =%s", before, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebSearchReauthEchoesMaxResults:回填必须带上盘上的上限 —— 不回填的话表单里那个框是空的,
|
||||||
|
// 用户既看不见当前上限,也无从改起。
|
||||||
|
func TestWebSearchReauthEchoesMaxResults(t *testing.T) {
|
||||||
|
env, up := wsMaxResultsEnv(t, 5)
|
||||||
|
env.mustSave(t, map[string]any{
|
||||||
|
"provider": search.NameSearXNG, "baseUrl": up.url, "enabled": true, "maxResults": 20,
|
||||||
|
})
|
||||||
|
values := wsReauthValues(t, env, search.NameSearXNG)
|
||||||
|
if got := values[search.FieldMaxResults]; got != 20 {
|
||||||
|
t.Fatalf("回填的 %s = %#v, want 20", search.FieldMaxResults, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebSearchReauthOmitsUnsetMaxResults:盘上**没给**上限(`maxResults` 缺位 ⇒ Go 侧 0 =
|
||||||
|
// 未声明,由 `.env` 兜底)时不得回填一个 0 —— 0 会被前端当成一个值送回来,而写侧拒 ≤0,
|
||||||
|
// 那会让「只改 key」的一次保存整笔进不去。
|
||||||
|
func TestWebSearchReauthOmitsUnsetMaxResults(t *testing.T) {
|
||||||
|
env, up := wsMaxResultsEnv(t, 5)
|
||||||
|
if err := runtimeconfig.SaveWebSearch(env.path, runtimeconfig.WebSearchFile{
|
||||||
|
Version: runtimeconfig.WebSearchVersion,
|
||||||
|
Provider: search.NameSearXNG,
|
||||||
|
// 刻意不写 maxResults(= 未声明)。
|
||||||
|
Providers: []runtimeconfig.ProviderEntry{
|
||||||
|
{Provider: search.NameSearXNG, BaseURL: up.url, Enabled: runtimeconfig.EnableFlag(true)},
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("写面板配置: %v", err)
|
||||||
|
}
|
||||||
|
if got := env.loadFile(t).MaxResults; got != 0 {
|
||||||
|
t.Fatalf("前置不成立:盘上 maxResults = %d, want 0(未声明)", got)
|
||||||
|
}
|
||||||
|
values := wsReauthValues(t, env, search.NameSearXNG)
|
||||||
|
if raw, ok := values[search.FieldMaxResults]; ok {
|
||||||
|
t.Fatalf("未声明时回填了 %#v(前端会把它当值送回,写侧又拒 ≤0)", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wsReauthValues 走 reauth 动作,取出回填的 values。
|
||||||
|
func wsReauthValues(t *testing.T, env *reloadTestEnv, provider string) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
out, err := env.hub.Dispatch(context.Background(), pluginmgr.ActionRequest{
|
||||||
|
PluginID: search.PanelPluginID, Action: search.ActionReauthProvider,
|
||||||
|
Payload: map[string]any{"row": map[string]any{"provider": provider}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reauth: %v", err)
|
||||||
|
}
|
||||||
|
values, _ := out.(map[string]any)["values"].(map[string]any)
|
||||||
|
if values == nil {
|
||||||
|
t.Fatalf("reauth 结果缺 values: %#v", out)
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// 本文件钉住本片新增的两家(`tencent_wsa` / `metaso`)在**组合根里真的接通了**:
|
||||||
|
// 它们**没有 `.env` 字段**(`.env` 已冻结),因此凭据与地址只能来自面板文件
|
||||||
|
// `$DATA_DIR/config/plugins/web-search.json`,而组合根**零改动**地按接入位表把它们投影出来
|
||||||
|
// (`resolveSearchProviders` 逐家走 `search.Descriptors()`)。
|
||||||
|
//
|
||||||
|
// 为什么值得端到端一条:`cmd/cyrene/websearch_providers.go` 里**一行都没改**——这正是
|
||||||
|
// 「按接入位表投影」的设计意图;没有这条用例,「零改动」就只是一句没有证据的话。
|
||||||
|
//
|
||||||
|
// 夹具(假上游、`wsSurfaceApp`、`wsExecuteSearch`)在 websearch_testenv_test.go /
|
||||||
|
// websearch_projection_test.go;`.env` 字段表的完整性由 wire_web_search_*_test.go 钉住。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestWebSearchPanelOnlyProvidersWiredEndToEnd 是判据:面板文件里配好一家**没有 env 字段**
|
||||||
|
// 的家 ⇒ 工具进注册面 ⇒ 真的把请求打到面板里那个地址、带上面板里那把 key,且结果里如实
|
||||||
|
// 标注 provider。
|
||||||
|
func TestWebSearchPanelOnlyProvidersWiredEndToEnd(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
fixture string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: search.NameTencentWSA,
|
||||||
|
path: "/SearchPro",
|
||||||
|
// Pages 的元素是 JSON **字符串**(官方 Array of String 形状)。
|
||||||
|
fixture: `{"Response":{"Pages":["{\"title\":\"元宝结果\",\"url\":\"https://t.example/1\",` +
|
||||||
|
`\"passage\":\"元宝摘要\"}"],"Query":"昔涟","RequestId":"r"}}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: search.NameMetaso,
|
||||||
|
path: "/api/v1/search",
|
||||||
|
fixture: `{"webpages":[{"title":"秘塔结果","link":"https://m.example/1","snippet":"秘塔摘要"}]}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
const key = "test-panel-only-key"
|
||||||
|
up := newWSFakeUpstream(t, http.StatusOK, tc.fixture)
|
||||||
|
// `.env` 只给「谁当主家」:这两家在 `.env` 里没有字段,凭据与地址只能来自面板文件。
|
||||||
|
cfg := wsLoadConfig(t, "WEB_SEARCH_PROVIDER="+tc.name)
|
||||||
|
// 前提:它确实不在 `.env` 字段表里(否则这条用例会退化成既有 6 家的回归)。
|
||||||
|
for _, p := range searchProviderVector(cfg.WebSearch) {
|
||||||
|
if strings.EqualFold(p.Name, tc.name) {
|
||||||
|
t.Fatalf("前提被破坏:%s 不该出现在 .env 的字段表里", tc.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 前提:接入位表里确有它(否则组合根根本投影不出来)。
|
||||||
|
if !search.Known(tc.name) {
|
||||||
|
t.Fatalf("前提被破坏:%s 应已在接入位表里登记", tc.name)
|
||||||
|
}
|
||||||
|
wsSavePanel(t, cfg, runtimeconfig.WebSearchFile{
|
||||||
|
Provider: tc.name,
|
||||||
|
Providers: []runtimeconfig.ProviderEntry{
|
||||||
|
{Provider: tc.name, APIKey: key, BaseURL: up.url, Enabled: runtimeconfig.EnableFlag(true)},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
a := wsSurfaceApp(t, cfg, nil)
|
||||||
|
if !wsHasTool(a, webSearchToolName) {
|
||||||
|
t.Fatalf("面板文件里配好的 %s 应让工具进注册面,实际工具面 = %v", tc.name, toolNames(a))
|
||||||
|
}
|
||||||
|
if got := wsExecuteSearch(t, a, "昔涟"); got["provider"] != tc.name {
|
||||||
|
t.Fatalf("provider = %v, want %q(如实标注实际应答的家)", got["provider"], tc.name)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case r := <-up.hits:
|
||||||
|
if r.URL.Path != tc.path {
|
||||||
|
t.Errorf("请求路径 = %q, want %q(各家固定路径)", r.URL.Path, tc.path)
|
||||||
|
}
|
||||||
|
if got := r.Header.Get("Authorization"); got != "Bearer "+key {
|
||||||
|
t.Errorf("Authorization = %q,want 面板文件里那把 key(凭据必须以文件为准)", got)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
t.Fatalf("%s 一个请求都没收到 —— 面板里的配置没接到 provider 上", tc.name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,436 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// 本文件钉住 web-search「各家 provider 配置投影」的新形状(切片 WS-P2 的「去 env 化」)。
|
||||||
|
//
|
||||||
|
// 为什么要有这一片:旧形状是**一家一个 `.env` 字段**(`config.WebSearchConfig` 的
|
||||||
|
// `ZhipuAPIKey` / `BochaAPIKey` / …)+ 组合根里一个逐家写死的 switch。那个形状的后果是
|
||||||
|
// 「加一家 provider 就必须先加一个 env 键」—— 直接破主人红线(`.env` 已冻结)。
|
||||||
|
// 新形状把名字表收回到插件自己的接入位表(`plugins/tools/io/search`),组合根只按名字
|
||||||
|
// 从**面板文件**里取值;`.env` 退居**播种层**(只在文件缺失/为空时落盘一次)。
|
||||||
|
//
|
||||||
|
// 四条判据(任务书 ①②③④)与一条边界(文件权威 vs `.env` 字段表)都在这里:
|
||||||
|
// ① 文件里有 zhipu 的 apiKey ⇒ 该家进注册表;
|
||||||
|
// ② 文件里没有 zhipu、但 `.env` 有(播种层)⇒ 与改动前逐字一致;
|
||||||
|
// ③ **没有 env 字段的家**在文件里有 key ⇒ 也能生效(本片的核心判据,用假接入位驱动);
|
||||||
|
// ④ 文件里的 baseUrl 缺位 ⇒ 回落该接入位的代码默认基地址。
|
||||||
|
//
|
||||||
|
// 夹具(假上游、日志字段读取、`wsSurfaceApp`)在 wire_web_search_test.go 与
|
||||||
|
// websearch_testenv_test.go;`.env` 与面板文件的既有语义在各 wire_web_search_*_test.go。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
|
||||||
|
)
|
||||||
|
|
||||||
|
// wsFakeNewProviderDef 造一个**测试专用的假接入位**:模拟「下一片要接的新家」。
|
||||||
|
//
|
||||||
|
// 它在 search 包里**没有**登记(`search.Known` 为 false),也没有对应的 `.env` 字段 ——
|
||||||
|
// 于是它正好是判据 ③ 的载体:「没有 env 字段的家,只认文件也能被投影出生效配置」。
|
||||||
|
//
|
||||||
|
// ⚠️ 名字取 `fake_next_provider` 而不是某个真接入位(早先这里写的是 `tencent_wsa`):
|
||||||
|
// `tencent_wsa` 已经**真的登记**(本片新增),沿用旧名会让这条用例的前提断言直接失败 ——
|
||||||
|
// 假接入位必须是一个**永远不会被登记**的名字,用例才钉得住「没 env 字段的家」这件事。
|
||||||
|
func wsFakeNewProviderDef() search.Descriptor {
|
||||||
|
return search.Descriptor{
|
||||||
|
Name: "fake_next_provider",
|
||||||
|
NeedKey: true,
|
||||||
|
DefaultBaseURL: "https://fake-next.example",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wsSavePanel 写一份面板文件(临时 DataDir 里的真实落点,与面板保存走同一个写口)。
|
||||||
|
func wsSavePanel(t *testing.T, cfg *config.Config, f runtimeconfig.WebSearchFile) {
|
||||||
|
t.Helper()
|
||||||
|
if f.Version == 0 {
|
||||||
|
f.Version = runtimeconfig.WebSearchVersion
|
||||||
|
}
|
||||||
|
if err := runtimeconfig.SaveWebSearch(wsPanelPath(cfg), f); err != nil {
|
||||||
|
t.Fatalf("写面板配置: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wsEffectiveProviders 走组合根的**同一条解析路**(effectiveWebSearch),返回生效配置与来源标签。
|
||||||
|
func wsEffectiveProviders(t *testing.T, cfg *config.Config) (effectiveWebSearchConfig, string) {
|
||||||
|
t.Helper()
|
||||||
|
a := &app{cfg: cfg, log: testLog()}
|
||||||
|
return a.effectiveWebSearch(a.log)
|
||||||
|
}
|
||||||
|
|
||||||
|
// wsFindWire 在投影里按名字取一家(名字大小写与空白不敏感,与注册表同口径)。
|
||||||
|
func wsFindWire(providers []searchProviderWire, name string) (searchProviderWire, bool) {
|
||||||
|
for _, p := range providers {
|
||||||
|
if strings.EqualFold(p.Name, name) {
|
||||||
|
return p, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return searchProviderWire{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// wsZhipuSearchBody 造一份智谱官方形状的应答(`search_result[]`,见 search 包的 zhipuSpec)。
|
||||||
|
func wsZhipuSearchBody(title string) string {
|
||||||
|
return `{"search_result":[{"title":"` + title + `","link":"https://z.example/1","content":"` +
|
||||||
|
title + ` 正文","publish_date":"2025-05-23"}]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// wsExecuteSearch 从注册面取出 web-search 真打一次(返回线格式结果)。
|
||||||
|
func wsExecuteSearch(t *testing.T, a *app, query string) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
tl, err := a.caps.Get(webSearchToolName)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("web-search 不在注册面里: %v(工具面 = %v)", err, toolNames(a))
|
||||||
|
}
|
||||||
|
exec, ok := tl.Value.(interface {
|
||||||
|
Execute(context.Context, any) (any, error)
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("注册表里的值类型 = %T, 不是可执行工具", tl.Value)
|
||||||
|
}
|
||||||
|
got, err := exec.Execute(context.Background(), map[string]any{"query": query})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Execute(%q): %v", query, err)
|
||||||
|
}
|
||||||
|
m, ok := got.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("返回值类型 = %T", got)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ④ 默认基地址只有一个出处:接入位表 vs `.env` 标签默认值,逐字对齐 ----------
|
||||||
|
|
||||||
|
// TestWebSearchAccessSlotDefaultsAlignWithEnvDefaults:接入位表里的默认基地址必须与
|
||||||
|
// `.env` 解析出的**标签默认值**逐字一致。
|
||||||
|
//
|
||||||
|
// 只钉一侧不够:`.env` 的那几个键仍然存在(冻结的基础键,仍然参与播种),接入位表是本片
|
||||||
|
// 新增的出处。两侧漂移时,同一家 provider 会因「值从哪来」指向两个不同的上游。
|
||||||
|
func TestWebSearchAccessSlotDefaultsAlignWithEnvDefaults(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
// 一个 WEB_SEARCH_*_BASE_URL 都不写 ⇒ 下面每项取的都是标签默认值。
|
||||||
|
cfg := wsLoadConfig(t)
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
envDefault string
|
||||||
|
}{
|
||||||
|
{search.NameZhipu, cfg.WebSearch.ZhipuBaseURL},
|
||||||
|
{search.NameAliyunIQS, cfg.WebSearch.AliyunIQSBaseURL},
|
||||||
|
{search.NameBocha, cfg.WebSearch.BochaBaseURL},
|
||||||
|
{search.NameVolcengine, cfg.WebSearch.VolcengineBaseURL},
|
||||||
|
{search.NameBaiduQianfan, cfg.WebSearch.BaiduQianfanBaseURL},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
// 进程环境里的同名覆盖会让「标签默认值」这一侧变成用户值 —— 那不是本表要钉的事实
|
||||||
|
// (缺省值),如实跳过而不是报假红。
|
||||||
|
envKey := "WEB_SEARCH_" + strings.ToUpper(tc.name) + "_BASE_URL"
|
||||||
|
if os.Getenv(envKey) != "" {
|
||||||
|
t.Skipf("%s 在进程环境里有覆盖值,跳过默认值对齐(本表只钉缺省值)", envKey)
|
||||||
|
}
|
||||||
|
if tc.envDefault == "" {
|
||||||
|
t.Fatalf("%s 的标签默认值解析出来是空的(.env 冻结键不许被清空)", tc.name)
|
||||||
|
}
|
||||||
|
if got := search.DefaultBaseURL(tc.name); got != tc.envDefault {
|
||||||
|
t.Errorf("接入位表 %s 的默认基地址 = %q,.env 的标签默认值 = %q(必须逐字一致)",
|
||||||
|
tc.name, got, tc.envDefault)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// searxng 的「默认地址」是 WEB_SEARCH_ENDPOINT 的标签默认值,且**不进**接入位表的默认表
|
||||||
|
// ——它的地址为空是「关掉这家」的开关语义。
|
||||||
|
if got := search.DefaultBaseURL(search.NameSearXNG); got != "" || cfg.WebSearch.Endpoint == "" {
|
||||||
|
t.Errorf("searxng: 接入位默认地址 = %q(want 空),.env 端点默认值 = %q(want 非空)",
|
||||||
|
got, cfg.WebSearch.Endpoint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ① 文件里有 key ⇒ 进注册表 ----------------------------------------------
|
||||||
|
|
||||||
|
// TestWebSearchFileProviderEntersRegistry 是判据 ①:**文件里有 zhipu 的 apiKey** ⇒ 该家进
|
||||||
|
// 注册表(`.env` 一个 zhipu 凭据键都没写),且请求真的带着文件里那把 key 打到文件里那个地址。
|
||||||
|
func TestWebSearchFileProviderEntersRegistry(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
const key = "test-file-zhipu-key"
|
||||||
|
up := newWSFakeUpstream(t, http.StatusOK, wsZhipuSearchBody("文件里的智谱"))
|
||||||
|
cfg := wsLoadConfig(t, "WEB_SEARCH_PROVIDER="+search.NameZhipu)
|
||||||
|
wsSavePanel(t, cfg, runtimeconfig.WebSearchFile{
|
||||||
|
Provider: search.NameZhipu,
|
||||||
|
Providers: []runtimeconfig.ProviderEntry{
|
||||||
|
{Provider: search.NameZhipu, APIKey: key, BaseURL: up.url, Enabled: runtimeconfig.EnableFlag(true)},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
eff, source := wsEffectiveProviders(t, cfg)
|
||||||
|
if source != webSearchSourcePanel {
|
||||||
|
t.Fatalf("configSource = %q, want %q(文件存在时它是唯一来源)", source, webSearchSourcePanel)
|
||||||
|
}
|
||||||
|
if got := availableProviderNames(searchProviderSpecs(eff.Scalar, eff.Providers)); len(got) != 1 || got[0] != search.NameZhipu {
|
||||||
|
t.Fatalf("可用清单 = %v, want [%s](凭据来自面板文件)", got, search.NameZhipu)
|
||||||
|
}
|
||||||
|
|
||||||
|
a := wsSurfaceApp(t, cfg, nil)
|
||||||
|
if !wsHasTool(a, webSearchToolName) {
|
||||||
|
t.Fatalf("文件里配好的主家应让工具进注册面: %v", toolNames(a))
|
||||||
|
}
|
||||||
|
if got := wsExecuteSearch(t, a, "昔涟"); got["provider"] != search.NameZhipu {
|
||||||
|
t.Fatalf("provider = %v, want %q", got["provider"], search.NameZhipu)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case r := <-up.hits:
|
||||||
|
if r.Header.Get("Authorization") != "Bearer "+key {
|
||||||
|
t.Errorf("Authorization = %q,want 文件里那把 key(凭据必须以文件为准)", r.Header.Get("Authorization"))
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
t.Fatal("文件里配好的家一个请求都没收到")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ② `.env` 是播种层:文件缺失时与改动前逐字一致 ----------------------------
|
||||||
|
|
||||||
|
// TestWebSearchProviderFromEnvSeedLayer 是判据 ②:**文件里没有 zhipu、但 `.env` 有**
|
||||||
|
// ⇒ 与改动前逐字一致:文件缺失/为空时由 `.env` 播种落盘,本次就按 `.env` 运行。
|
||||||
|
//
|
||||||
|
// 「与改动前一致」的落点有三处:播种内容、来源标签(env)、生效凭据。
|
||||||
|
func TestWebSearchProviderFromEnvSeedLayer(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cfg := wsLoadConfig(t,
|
||||||
|
"WEB_SEARCH_PROVIDER="+search.NameZhipu,
|
||||||
|
"WEB_SEARCH_ZHIPU_API_KEY=sk-env-001",
|
||||||
|
)
|
||||||
|
// 第一次解析发生在文件缺失时 ⇒ 它就是「播种那一次」。
|
||||||
|
eff, source := wsEffectiveProviders(t, cfg)
|
||||||
|
if source != webSearchSourceEnv {
|
||||||
|
t.Fatalf("configSource = %q, want %q(文件缺失时按 .env 运行)", source, webSearchSourceEnv)
|
||||||
|
}
|
||||||
|
z, ok := wsFindWire(eff.Providers, search.NameZhipu)
|
||||||
|
if !ok || z.APIKey != "sk-env-001" || z.BaseURL != search.DefaultBaseURL(search.NameZhipu) {
|
||||||
|
t.Fatalf("播种层的 zhipu 投影 = %+v, want 凭据来自 .env、基地址回落默认值", z)
|
||||||
|
}
|
||||||
|
if got := availableProviderNames(searchProviderSpecs(eff.Scalar, eff.Providers)); len(got) != 2 {
|
||||||
|
t.Fatalf("可用清单 = %v, want searxng + zhipu 两家", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 播种真的落了盘:条目里带 `.env` 的凭据(`.env` 是播种层的输入)。
|
||||||
|
f, found, err := runtimeconfig.LoadWebSearch(wsPanelPath(cfg))
|
||||||
|
if err != nil || !found {
|
||||||
|
t.Fatalf("文件缺失时必须按 .env 播种落盘: found=%v err=%v", found, err)
|
||||||
|
}
|
||||||
|
entry, ok := findProviderEntry(f, search.NameZhipu)
|
||||||
|
if !ok || entry.APIKey != "sk-env-001" {
|
||||||
|
t.Fatalf("播种的 zhipu 条目 = %+v, want 带 .env 的凭据", entry)
|
||||||
|
}
|
||||||
|
if f.Provider != search.NameZhipu {
|
||||||
|
t.Fatalf("播种的主 provider = %q, want %q", f.Provider, search.NameZhipu)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 播种之后工具照常在注册面里(`.env` 走的是与面板键同一条装配路)。
|
||||||
|
if !wsHasTool(wsSurfaceApp(t, cfg, nil), webSearchToolName) {
|
||||||
|
t.Fatal("按 .env 播种后 web-search 应在注册面里")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ③ 没有 env 字段的家:只认文件(本片的**核心判据**) ----------------------
|
||||||
|
|
||||||
|
// TestWebSearchProviderWithoutEnvFieldIsProjected 是判据 ③ 的投影层:**没有 env 字段的家**
|
||||||
|
// (测试专用的假接入位 `fake_next_provider`)在文件里有 key ⇒ 照样被投影出生效配置。
|
||||||
|
//
|
||||||
|
// 这条判据就是本片存在的理由:旧形状的逐家 switch 只认 `.env` 字段表里的 6 家,第 7 家
|
||||||
|
// 想进来只能先加 env 键(破红线)。新形状按**接入位表**逐家投影,因此第 7 家只需在
|
||||||
|
// `plugins/tools/io/search` 登记(加一个接入位文件 + 表里一行),组合根零改动
|
||||||
|
// —— 本片新接的 `tencent_wsa` / `metaso` 走的正是这条路(它们没有 env 键)。
|
||||||
|
//
|
||||||
|
// 最后一跳(进注册表)由同文件的 TestWebSearchFileOnlyProviderEntersRegistry 用真实接入位
|
||||||
|
// 走完:注册表按名字查的正是同一张接入位表。
|
||||||
|
func TestWebSearchProviderWithoutEnvFieldIsProjected(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
fake := wsFakeNewProviderDef()
|
||||||
|
cfg := wsLoadConfig(t)
|
||||||
|
|
||||||
|
// 前提一:它确实不在 `.env` 的字段表里(否则这条用例会退化成「既有 6 家」的回归)。
|
||||||
|
for _, p := range searchProviderVector(cfg.WebSearch) {
|
||||||
|
if strings.EqualFold(p.Name, fake.Name) {
|
||||||
|
t.Fatalf("前提被破坏:%s 不该出现在 .env 的字段表里", fake.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 前提二:它确实还没登记(假接入位的全部意义就在于此)。
|
||||||
|
if search.Known(fake.Name) {
|
||||||
|
t.Fatalf("前提被破坏:假接入位 %s 不该已经登记", fake.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
f := runtimeconfig.WebSearchFile{
|
||||||
|
Version: runtimeconfig.WebSearchVersion,
|
||||||
|
Providers: []runtimeconfig.ProviderEntry{
|
||||||
|
// 连 baseUrl 都不给:顺带钉住判据 ④ 在「没有 env 字段」的家上同样成立。
|
||||||
|
{Provider: fake.Name, APIKey: "sk-wsa-001"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
got := resolveSearchProviders(append(search.Descriptors(), fake), f)
|
||||||
|
w, ok := wsFindWire(got, fake.Name)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("没有 env 字段的家在文件里有 key 时也必须被投影出来,实际 = %+v", got)
|
||||||
|
}
|
||||||
|
if w.APIKey != "sk-wsa-001" {
|
||||||
|
t.Fatalf("投影的凭据 = %q, want 文件里的 sk-wsa-001", w.APIKey)
|
||||||
|
}
|
||||||
|
if w.BaseURL != fake.DefaultBaseURL {
|
||||||
|
t.Fatalf("投影的基地址 = %q, want 接入位默认值 %q", w.BaseURL, fake.DefaultBaseURL)
|
||||||
|
}
|
||||||
|
// 没配的家照样占位(它带着默认基地址进 SSRF 白名单)。
|
||||||
|
if _, ok := wsFindWire(got, search.NameBaiduQianfan); !ok {
|
||||||
|
t.Fatalf("未配置的家也要出现在投影里(否则 SSRF 白名单会少一家): %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebSearchFileOnlyProviderEntersRegistry 是判据 ③ 的端到端:文件里有 key、
|
||||||
|
// `.env` 里没有任何该家的值 ⇒ 该家真的进注册表,且请求带着**文件里那把** key 打出去。
|
||||||
|
//
|
||||||
|
// 落在真实接入位(bocha)上,是因为注册表按名字查的正是那张接入位表:这条路径一旦通了,
|
||||||
|
// 「没有 env 字段的家」登记之后走的就是同一条路。
|
||||||
|
func TestWebSearchFileOnlyProviderEntersRegistry(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
const key = "test-file-only-key"
|
||||||
|
got := make(chan *http.Request, 1)
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
got <- r
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"webPages":{"value":[{"name":"博查结果","url":"https://b.example/1","summary":"正文"}]}}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
// `.env` 只给「谁当主家」,凭据与地址一个都不给。
|
||||||
|
cfg := wsLoadConfig(t, "WEB_SEARCH_PROVIDER="+search.NameBocha)
|
||||||
|
wsSavePanel(t, cfg, runtimeconfig.WebSearchFile{
|
||||||
|
Provider: search.NameBocha,
|
||||||
|
Providers: []runtimeconfig.ProviderEntry{
|
||||||
|
{Provider: search.NameBocha, APIKey: key, BaseURL: srv.URL, Enabled: runtimeconfig.EnableFlag(true)},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
a := wsSurfaceApp(t, cfg, nil)
|
||||||
|
if !wsHasTool(a, webSearchToolName) {
|
||||||
|
t.Fatalf("文件里配好的家应让工具进注册面: %v", toolNames(a))
|
||||||
|
}
|
||||||
|
if m := wsExecuteSearch(t, a, "昔涟"); m["provider"] != search.NameBocha {
|
||||||
|
t.Fatalf("provider = %v, want %q", m["provider"], search.NameBocha)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case r := <-got:
|
||||||
|
if r.Header.Get("Authorization") != "Bearer "+key {
|
||||||
|
t.Errorf("Authorization = %q,want 文件里那把 key", r.Header.Get("Authorization"))
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
t.Fatal("文件里配好的家一个请求都没收到")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ④ 基地址的进位链:文件值 → 接入位默认值 ---------------------------------
|
||||||
|
|
||||||
|
// TestWebSearchBaseURLFallbackChain 是判据 ④ 的表驱动:
|
||||||
|
// 文件里的 baseUrl 缺位 ⇒ 回落**该接入位的代码默认基地址**(逐字对上 registry 里的值)。
|
||||||
|
func TestWebSearchBaseURLFallbackChain(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
entries []runtimeconfig.ProviderEntry
|
||||||
|
wantKey string
|
||||||
|
wantURL string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"文件没给地址 ⇒ 回落接入位默认值",
|
||||||
|
[]runtimeconfig.ProviderEntry{{Provider: search.NameZhipu, APIKey: "sk-1"}},
|
||||||
|
"sk-1", search.DefaultBaseURL(search.NameZhipu),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"文件给了地址 ⇒ 用文件的(进位链第一跳)",
|
||||||
|
[]runtimeconfig.ProviderEntry{{Provider: search.NameZhipu, APIKey: "sk-1", BaseURL: "https://proxy.example"}},
|
||||||
|
"sk-1", "https://proxy.example",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"空串地址等同于没给 ⇒ 回落接入位默认值",
|
||||||
|
[]runtimeconfig.ProviderEntry{{Provider: search.NameZhipu, APIKey: "sk-1", BaseURL: " "}},
|
||||||
|
"sk-1", search.DefaultBaseURL(search.NameZhipu),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"被停用的家仍带默认地址(白名单口径),但没有凭据",
|
||||||
|
[]runtimeconfig.ProviderEntry{
|
||||||
|
{Provider: search.NameZhipu, APIKey: "sk-1", Enabled: runtimeconfig.EnableFlag(false)},
|
||||||
|
},
|
||||||
|
"", search.DefaultBaseURL(search.NameZhipu),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
f := runtimeconfig.WebSearchFile{Version: runtimeconfig.WebSearchVersion, Providers: tc.entries}
|
||||||
|
got := resolveSearchProviders(search.Descriptors(), f)
|
||||||
|
w, ok := wsFindWire(got, search.NameZhipu)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("zhipu 必须出现在投影里: %+v", got)
|
||||||
|
}
|
||||||
|
if w.BaseURL != tc.wantURL {
|
||||||
|
t.Errorf("基地址 = %q, want %q", w.BaseURL, tc.wantURL)
|
||||||
|
}
|
||||||
|
if w.APIKey != tc.wantKey {
|
||||||
|
t.Errorf("凭据 = %q, want %q", w.APIKey, tc.wantKey)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// 逐字对齐:这个「接入位默认值」必须就是 registry 里那张表的值(不是从 `.env` 抄来的)。
|
||||||
|
if got := search.DefaultBaseURL(search.NameZhipu); got != "https://open.bigmodel.cn" {
|
||||||
|
t.Fatalf("registry 的默认基地址 = %q, want 逐字 https://open.bigmodel.cn", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 边界:文件存在时它**就是**权威(`.env` 不复活任何条目) ------------------
|
||||||
|
|
||||||
|
// TestWebSearchFileAuthorityOverEnvFieldTable 钉住投影层的权威边界:文件里**没有** zhipu
|
||||||
|
// 条目 ⇒ 即使 `.env` 里有 zhipu 的 key,这家也不生效(凭据不复活)。`.env` 只在**文件
|
||||||
|
// 缺失/为空**时经 seedWebSearchFile 播种(判据 ②)—— 文件一旦存在,权威就在文件那边。
|
||||||
|
//
|
||||||
|
// 这是工具层既有回归用例(`wire_web_search_panel_test.go` 的
|
||||||
|
// `TestWireIOTools_PanelConfigClearedProviderDoesNotReviveFromEnv`:主家因此没配齐 ⇒ 启动
|
||||||
|
// 报错)在**投影层**的同一条口径,也是与本体形状契约(runtimeconfig:providers 为空
|
||||||
|
// 数组 ⇒ 一家都没配)一致的行为。
|
||||||
|
func TestWebSearchFileAuthorityOverEnvFieldTable(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cfg := wsLoadConfig(t,
|
||||||
|
"WEB_SEARCH_PROVIDER="+search.NameZhipu,
|
||||||
|
"WEB_SEARCH_ZHIPU_API_KEY=sk-env-stale",
|
||||||
|
)
|
||||||
|
f := runtimeconfig.WebSearchFile{
|
||||||
|
Version: runtimeconfig.WebSearchVersion,
|
||||||
|
Provider: search.NameZhipu,
|
||||||
|
Providers: []runtimeconfig.ProviderEntry{
|
||||||
|
{Provider: search.NameSearXNG, BaseURL: "http://127.0.0.1:38471"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
// 文件真的落到盘上(否则「文件里没有这家」这条用例会退化成「文件缺失 ⇒ 播种」)。
|
||||||
|
wsSavePanel(t, cfg, f)
|
||||||
|
|
||||||
|
got := resolveSearchProviders(search.Descriptors(), f)
|
||||||
|
w, ok := wsFindWire(got, search.NameZhipu)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("未配置的家也要在投影里占位(它带着默认基地址进 SSRF 白名单): %+v", got)
|
||||||
|
}
|
||||||
|
if w.APIKey != "" {
|
||||||
|
t.Fatalf("文件里没有的家不得从 .env 复活凭据,得到 %q(.env 只是播种层)", w.APIKey)
|
||||||
|
}
|
||||||
|
if w.BaseURL != search.DefaultBaseURL(search.NameZhipu) {
|
||||||
|
t.Fatalf("基地址 = %q, want 接入位默认值 %q", w.BaseURL, search.DefaultBaseURL(search.NameZhipu))
|
||||||
|
}
|
||||||
|
// 与 `.env` 无关的旁证:那份 `.env` 里确实有 key(否则这条用例是空转)。
|
||||||
|
if cfg.WebSearch.ZhipuAPIKey != "sk-env-stale" {
|
||||||
|
t.Fatalf(".env 里的 zhipu key = %q(用例前提不成立)", cfg.WebSearch.ZhipuAPIKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 工具层:主家没配齐 ⇒ 启动报错(既不复活,也不静默换一家)。
|
||||||
|
if err := wsNewTools(t, cfg); !errors.Is(err, search.ErrNotConfigured) {
|
||||||
|
t.Fatalf("err = %v, want errors.Is(..., ErrNotConfigured):文件里没有的家不得从 .env 复活", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// 本文件是 web-search「**各家 provider 的配置投影**」—— 组合根里唯一把配置翻译成各家接线
|
||||||
|
// 的地方。它存在的理由是一条硬约束:**`.env` 已冻结**(基础键不加/不改/不删)。
|
||||||
|
//
|
||||||
|
// 旧形状是「一家一个 env 字段」:`config.WebSearchConfig` 有 `ZhipuAPIKey` / `BochaAPIKey` / …,
|
||||||
|
// 组合根再逐家读这些字段(外加一个逐家写死的 switch 把面板文件的值搬回去)。那个形状的
|
||||||
|
// 后果是**加一家 provider 就要先加一个 env 键** —— 直接破 `.env` 冻结红线。
|
||||||
|
//
|
||||||
|
// 新形状把**名字表收回到插件自己手里**(`plugins/tools/io/search` 的接入位表):
|
||||||
|
//
|
||||||
|
// - 各家叫什么、要不要 key、没给基地址时回落哪个默认地址 ⇒ 全在 `search.Descriptors()`;
|
||||||
|
// - 组合根只做一件事:**按名字**在面板文件里找同名条目,有就取文件的值,没有就是没配;
|
||||||
|
// - `.env` 退居**播种层**:只有「文件缺失/为空」时才由 `seedWebSearchFile` 落盘一次
|
||||||
|
// (既有 6 家的键与值逐字不变,主人已在用的配置不受影响)。
|
||||||
|
//
|
||||||
|
// ⇒ 加一家 provider = `plugins/tools/io/search` 加一个接入位文件 + 表里一行,**本文件一行
|
||||||
|
// 都不用改**,`.env` 一个键都不用加。`tencent_wsa` / `metaso` 就是这么进来的(两家都
|
||||||
|
// **没有** env 字段:`.env` 已冻结,它们的 key / 基地址只从面板文件取)。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||||
|
iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
|
||||||
|
)
|
||||||
|
|
||||||
|
// effectiveWebSearchConfig 是一次装配的**生效配置**,分两层:
|
||||||
|
//
|
||||||
|
// - Scalar:文件级标量(主 provider / 降级链 / 条数上限 / 超时)与 searxng 的端点。
|
||||||
|
// 它沿用 config.WebSearchConfig,因为那几个裁决函数都挂在它上面
|
||||||
|
// (ResolveProvider / ResolveEndpoint),而它们是 `.env` 与面板**共用**的一套语义。
|
||||||
|
// - Providers:各家(**不含 searxng**)的生效投影,按**接入位表**逐家给出。
|
||||||
|
//
|
||||||
|
// 为什么各家的 key/基地址不再写回 Scalar 的逐家字段:那张字段表是 `.env` 的形状,碰它就要
|
||||||
|
// 动 env 键(见文件头)。逐家字段在这个结构里**不再承载生效值**(清零),免得被读成
|
||||||
|
// 「生效配置里的凭据」—— 那正是旧形状最容易被误用的地方。
|
||||||
|
type effectiveWebSearchConfig struct {
|
||||||
|
// Scalar 是文件级标量与 searxng 端点(逐家字段一律清零,见上方说明)。
|
||||||
|
Scalar config.WebSearchConfig
|
||||||
|
// Providers 是各家的生效投影(不含 searxng),顺序 = 接入位表的展示顺序。
|
||||||
|
Providers []searchProviderWire
|
||||||
|
}
|
||||||
|
|
||||||
|
// searchProviderWire 是「一家云 provider 的配置投影」:名字 + 凭据 + 基地址。
|
||||||
|
// searxng 不在这张表里(它的「基地址」就是端点,由 ResolveEndpoint 裁决整把工具的存亡)。
|
||||||
|
type searchProviderWire struct {
|
||||||
|
Name string
|
||||||
|
APIKey string
|
||||||
|
BaseURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// searchProviderVector 是 **`.env` 播种层**给出的各家投影(既有 6 家的 env 字段表,
|
||||||
|
// 云家部分)。它只服务两处:`.env` 缺失时的播种内容(seedWebSearchFile),以及
|
||||||
|
// 「没有可用文件」时的生效投影(envEffectiveWebSearch)。
|
||||||
|
//
|
||||||
|
// ⚠️ 它**不是**「本期内置几家」的名单:登记表里还有两家**没有 env 字段**的家
|
||||||
|
// (tencent_wsa / metaso,`.env` 已冻结 ⇒ 不给它们加键),它们只从面板文件生效
|
||||||
|
// (`resolveSearchProviders` 按 `search.Descriptors()` 逐家投影,不经过本函数)。
|
||||||
|
// 它也**不是**逐条的兜底层:文件存在时,文件里没有的家就是没有(见 resolveSearchProviders)。
|
||||||
|
// 这条口径由 `wire_web_search_panel_test.go` 的
|
||||||
|
// `TestWireIOTools_PanelConfigClearedProviderDoesNotReviveFromEnv` 钉住。
|
||||||
|
func searchProviderVector(ws config.WebSearchConfig) []searchProviderWire {
|
||||||
|
return []searchProviderWire{
|
||||||
|
{Name: search.NameZhipu, APIKey: ws.ZhipuAPIKey, BaseURL: ws.ZhipuBaseURL},
|
||||||
|
{Name: search.NameAliyunIQS, APIKey: ws.AliyunIQSAPIKey, BaseURL: ws.AliyunIQSBaseURL},
|
||||||
|
{Name: search.NameBocha, APIKey: ws.BochaAPIKey, BaseURL: ws.BochaBaseURL},
|
||||||
|
{Name: search.NameVolcengine, APIKey: ws.VolcengineAPIKey, BaseURL: ws.VolcengineBaseURL},
|
||||||
|
{Name: search.NameBaiduQianfan, APIKey: ws.BaiduQianfanAPIKey, BaseURL: ws.BaiduQianfanBaseURL},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// envSearchProviders 是 `.env` 播种层的**全量**投影:searxng(基地址 = WEB_SEARCH_ENDPOINT)
|
||||||
|
// 打头,其余各家跟随 —— 顺序与既有播种逐字一致。
|
||||||
|
func envSearchProviders(ws config.WebSearchConfig) []searchProviderWire {
|
||||||
|
out := make([]searchProviderWire, 0, len(searchProviderVector(ws))+1)
|
||||||
|
out = append(out, searchProviderWire{Name: search.NameSearXNG, BaseURL: seedEndpoint(ws.Endpoint)})
|
||||||
|
return append(out, searchProviderVector(ws)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// effectiveSearchProviders 是各家(**不含 searxng**)在本次装配里的生效投影。
|
||||||
|
//
|
||||||
|
// 生产路径只有薄薄这一层:接入位表从插件自己那里取(`search.Descriptors()`)。加一家
|
||||||
|
// provider 时这里**零改动** —— 这正是本片「去 env 化」的核心。
|
||||||
|
func effectiveSearchProviders(f runtimeconfig.WebSearchFile) []searchProviderWire {
|
||||||
|
return resolveSearchProviders(search.Descriptors(), f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveSearchProviders 逐家算出生效投影。**判定顺序(文件权威)**:
|
||||||
|
//
|
||||||
|
// 1. 面板文件里有同名条目且**启用** ⇒ 取条目里的 apiKey / baseUrl(凭据以文件为准:
|
||||||
|
// 条目里 key 为空 = 这家没配,绝不拿 `.env` 的旧 key 顶上,否则「我在面板里删了它」
|
||||||
|
// 会变成一句假话);
|
||||||
|
// 2. 面板文件里有同名条目但**被显式停用** ⇒ 这家不生效(停用是显式选择,不回落别处);
|
||||||
|
// 3. 面板文件里**没有**该条目 ⇒ 这家不生效(`.env` 只在**文件缺失/为空**时播种,文件一旦
|
||||||
|
// 存在就是权威;见 searchProviderVector 的注释与那条既有回归用例)。
|
||||||
|
//
|
||||||
|
// ⚠️ **一处经核对的偏差(记在这里,免得后人踩)**:任务书里有一句「既有 6 家在文件里没有该
|
||||||
|
// 条目时仍按现状回落 `.env` 值」。本仓现状**没有**这层回落,且有两条既有约束钉着它:
|
||||||
|
// ① `runtimeconfig.WebSearchFile.Providers` 的形状契约写明「允许为空数组(= 一家都没配 ⇒
|
||||||
|
// 组合根不注册 web-search)」;② 回归用例 `TestWireIOTools_PanelConfigClearedProviderDoesNot
|
||||||
|
// ReviveFromEnv` 明确要求「文件里没有的家不得从 `.env` 复活」。本片按「**不改变既有行为**」
|
||||||
|
// 实现(既有 websearch 系列用例逐条未改语义、全绿),需要那层回落时改的就是下面这个循环。
|
||||||
|
//
|
||||||
|
// 被跳过与未配置的家**仍带着基地址**(回落到接入位的默认值):接线表的 BaseURL 会被组合根
|
||||||
|
// 逐个加进 SSRF 白名单(io 包的 withSearchEndpointHost),这里悄悄少一家等于改了那份白名单。
|
||||||
|
//
|
||||||
|
// defs 由调用方给(生产 = `search.Descriptors()`):测试可注入一个**假接入位**,从而驱动
|
||||||
|
// 「没有 env 字段的家也能只认文件生效」这条判据 —— 那是本片存在的理由,必须可证。
|
||||||
|
func resolveSearchProviders(defs []search.Descriptor, f runtimeconfig.WebSearchFile) []searchProviderWire {
|
||||||
|
out := make([]searchProviderWire, 0, len(defs))
|
||||||
|
for _, d := range defs {
|
||||||
|
// searxng 的「基地址」是端点(`WEB_SEARCH_ENDPOINT` / 文件里那条的 baseUrl),
|
||||||
|
// 由 effectiveSearchConfig 单独裁决:空地址 = 关掉这家,语义与云家不同。
|
||||||
|
if strings.EqualFold(d.Name, search.NameSearXNG) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entry, found := findProviderEntry(f, d.Name)
|
||||||
|
if !found || !entry.IsEnabled() {
|
||||||
|
out = append(out, searchProviderWire{Name: d.Name, BaseURL: baseURLChain("", d.DefaultBaseURL)})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, searchProviderWire{
|
||||||
|
Name: d.Name,
|
||||||
|
APIKey: strings.TrimSpace(entry.APIKey),
|
||||||
|
BaseURL: baseURLChain(entry.BaseURL, d.DefaultBaseURL),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// baseURLChain 是「一家的基地址」的**唯一进位链**:条目里的值 → 该接入位的代码默认基地址
|
||||||
|
// → 空。
|
||||||
|
//
|
||||||
|
// 空只可能出现在没有默认地址的家上(searxng):它的地址为空本身就是「关掉这家」的开关语义,
|
||||||
|
// 回落一个本机地址会让「清空地址」变成假话。
|
||||||
|
func baseURLChain(fromEntry, accessSlotDefault string) string {
|
||||||
|
if s := strings.TrimSpace(fromEntry); s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return accessSlotDefault
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveBaseURL 按**名字**走同一条进位链(动作面只拿得到名字与条目):默认基地址由接入位表
|
||||||
|
// 给出(`search.DefaultBaseURL`,未登记的名字 ⇒ 空)。
|
||||||
|
//
|
||||||
|
// 装配期**不用**这个入口而是直接用 baseURLChain:那里手里已经有接入位描述,而「没有 env
|
||||||
|
// 字段的家」在登记之前按名字查不到默认地址 —— 那正是本片要打通的那一类家。
|
||||||
|
func resolveBaseURL(name, fromEntry string) string {
|
||||||
|
return baseURLChain(fromEntry, search.DefaultBaseURL(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
// searchProviderSpecs 把生效配置翻译成各家接线。
|
||||||
|
//
|
||||||
|
// searxng 单独一行:它的基地址是端点、可用性由 ResolveEndpoint 裁决(不是「有没有 key」);
|
||||||
|
// 其余各家按接入位表逐家给出,Configured 的口径仍由 `search.Configured` 统一判定
|
||||||
|
// (searxng 看端点、云家看 key;false ⇒ 该家不进注册表、不出现在可用清单里)。
|
||||||
|
//
|
||||||
|
// **密钥只在这里流转,绝不写日志。**
|
||||||
|
func searchProviderSpecs(ws config.WebSearchConfig, providers []searchProviderWire) []iotools.SearchProviderSpec {
|
||||||
|
endpoint := ws.ResolveEndpoint()
|
||||||
|
specs := make([]iotools.SearchProviderSpec, 0, len(providers)+1)
|
||||||
|
specs = append(specs, iotools.SearchProviderSpec{
|
||||||
|
Name: search.NameSearXNG,
|
||||||
|
BaseURL: endpoint.URL,
|
||||||
|
Configured: endpoint.Enabled,
|
||||||
|
})
|
||||||
|
for _, p := range providers {
|
||||||
|
specs = append(specs, iotools.SearchProviderSpec{
|
||||||
|
Name: p.Name,
|
||||||
|
APIKey: p.APIKey,
|
||||||
|
BaseURL: p.BaseURL,
|
||||||
|
Configured: search.Configured(p.Name, p.APIKey, p.BaseURL),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return specs
|
||||||
|
}
|
||||||
|
|
||||||
|
// availableProviderNames 返回已配置的家(即「可用清单」),供启动日志如实展示。
|
||||||
|
// **不含任何密钥** —— 只回名字。
|
||||||
|
func availableProviderNames(specs []iotools.SearchProviderSpec) []string {
|
||||||
|
out := make([]string, 0, len(specs))
|
||||||
|
for _, s := range specs {
|
||||||
|
if s.Configured {
|
||||||
|
out = append(out, s.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedEndpoint 把 `WEB_SEARCH_ENDPOINT` 的取值播进 searxng 条目:显式关闭的哨兵落成空串
|
||||||
|
// (两者语义相同:都表示不出这家,但面板里不该显示一个不是地址的 "off")。
|
||||||
|
func seedEndpoint(endpoint string) string {
|
||||||
|
if config.IsWebSearchOffSentinel(endpoint) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(endpoint)
|
||||||
|
}
|
||||||
@@ -1,17 +1,18 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
// 本文件是「搜索提供商」页表单里**两个文件级字段**的解析与落盘(切片 W3;
|
// 本文件是「搜索提供商」页表单里**文件级字段**的解析与落盘(切片 W3;
|
||||||
// `DECISIONS-2026-10-06` 裁决 #2:声明面必须能配「谁先用」——`primary` + `fallback[]`)。
|
// `DECISIONS-2026-10-06` 裁决 #2:声明面必须能配「谁先用」——`primary` + `fallback[]`;
|
||||||
|
// 后续切片又加了 `maxResults`:结果条数上限也得能在面板上改)。
|
||||||
//
|
//
|
||||||
// 它们与其余字段(provider / apiKey / baseUrl / enabled)的区别:
|
// 它们与其余字段(provider / apiKey / baseUrl / enabled)的区别:
|
||||||
//
|
//
|
||||||
// - 其余字段描述**一条 provider 条目**(providers[] 里的一个元素);
|
// - 其余字段描述**一条 provider 条目**(providers[] 里的一个元素);
|
||||||
// - `primary` / `fallback` 描述**整份配置的选择**(文件级的 `provider` / `fallback`,
|
// - 这三个描述**整份配置**:`primary` / `fallback` 是 design/27 §3 的「主后端 + 降级链」,
|
||||||
// 即 design/27 §3 的「主后端 + 降级链」)。
|
// `maxResults` 是单次检索的条数上限 —— 三者都是可变源顶层的同名键。
|
||||||
//
|
//
|
||||||
// 为什么挂在**同一个** form 区块里,而不是另开一个:前端的「带回表单」(reauth)只在面板
|
// 为什么挂在**同一个** form 区块里,而不是另开一个:前端的「带回表单」(reauth)只在面板
|
||||||
// **恰好一个** form 区块时转交(`frontend/web/src/admin/plugin/PluginPanel.tsx` 的
|
// **恰好一个** form 区块时转交(`frontend/web/src/admin/plugin/PluginPanel.tsx` 的
|
||||||
// `singleForm`),加第二个 form 会让既有的「重配凭据」回填整条失灵。因此这两项作为文件级
|
// `singleForm`),加第二个 form 会让既有的「重配凭据」回填整条失灵。因此这些项作为文件级
|
||||||
// 字段进既有表单,回填值由 `reauthProvider` 一并给出(见 addSelectionValues 的调用点)。
|
// 字段进既有表单,回填值由 `reauthProvider` 一并给出(见 addSelectionValues 的调用点)。
|
||||||
//
|
//
|
||||||
// 字段 key 刻意不叫 `provider`:表单里的 `provider` 已经是「这一条 provider 的名字」,
|
// 字段 key 刻意不叫 `provider`:表单里的 `provider` 已经是「这一条 provider 的名字」,
|
||||||
@@ -19,7 +20,9 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
|
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
|
||||||
@@ -28,15 +31,17 @@ import (
|
|||||||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
|
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 表单里两个文件级字段的 key 由**声明侧**给出(search.FieldPrimary / search.FieldFallback):
|
// 表单里三个文件级字段的 key 由**声明侧**给出(search.FieldPrimary / FieldFallback /
|
||||||
// 它们必须与 panel.go 里写下的字段声明逐字一致(本文件读的正是这些 key),而
|
// FieldMaxResults):它们必须与 panel.go 里写下的字段声明逐字一致(本文件读的正是这些 key),
|
||||||
// 「声明里写一个 key、处理器里读另一个」这类漂移只有共用一个常量才拦得住 ——
|
// 而「声明里写一个 key、处理器里读另一个」这类漂移只有共用一个常量才拦得住 ——
|
||||||
// 在 cmd/cyrene 里再写一遍字面量就等于第二份真源。
|
// 在 cmd/cyrene 里再写一遍字面量就等于第二份真源。
|
||||||
const (
|
const (
|
||||||
// selectionPrimaryKey 是主 provider 的字段 key(落到文件级 `provider`)。
|
// selectionPrimaryKey 是主 provider 的字段 key(落到文件级 `provider`)。
|
||||||
selectionPrimaryKey = search.FieldPrimary
|
selectionPrimaryKey = search.FieldPrimary
|
||||||
// selectionFallbackKey 是降级链的字段 key(落到文件级 `fallback`)。
|
// selectionFallbackKey 是降级链的字段 key(落到文件级 `fallback`)。
|
||||||
selectionFallbackKey = search.FieldFallback
|
selectionFallbackKey = search.FieldFallback
|
||||||
|
// selectionMaxResultsKey 是结果条数上限的字段 key(落到文件级 `maxResults`)。
|
||||||
|
selectionMaxResultsKey = search.FieldMaxResults
|
||||||
)
|
)
|
||||||
|
|
||||||
// actionCodePrimaryNotConfigured 报告「选中的主 provider 在本文件里还没配齐」——
|
// actionCodePrimaryNotConfigured 报告「选中的主 provider 在本文件里还没配齐」——
|
||||||
@@ -44,24 +49,38 @@ const (
|
|||||||
// fail-fast),所以在写盘之前就拒掉,而不是先写进盘里等着下次起不来。
|
// fail-fast),所以在写盘之前就拒掉,而不是先写进盘里等着下次起不来。
|
||||||
const actionCodePrimaryNotConfigured = "primary_not_configured"
|
const actionCodePrimaryNotConfigured = "primary_not_configured"
|
||||||
|
|
||||||
// applySelectionPatch 把表单里的主 provider / 降级链合并进文件(三态:**缺位 = 不改动**)。
|
// mergeSelectionPatch 把表单里的三个**文件级**字段(主 provider / 降级链 / 结果条数上限)
|
||||||
|
// 合并进文件(三态:**缺位 = 不改动**),并回报「这次有没有给出 `primary`」
|
||||||
|
// —— **校验不在这里**。
|
||||||
//
|
//
|
||||||
// 与其余字段同一套哨兵语义(websearch_action_data.go 的 saveProvider 注释):
|
// 与其余字段同一套哨兵语义(websearch_action_data.go 的 saveProvider 注释):
|
||||||
// `primary` 缺位 ⇒ 保留盘上的;给了值(含显式空串 / off / none)⇒ 采用 —— 空串与
|
// `primary` 缺位 ⇒ 保留盘上的;给了值(含显式空串 / off / none)⇒ 采用 —— 空串与
|
||||||
// `off`/`none` 是本项目**唯一一套**关闭语义(config.IsWebSearchOffSentinel),面板不另造一套。
|
// `off`/`none` 是本项目**唯一一套**关闭语义(config.IsWebSearchOffSentinel),面板不另造一套。
|
||||||
//
|
//
|
||||||
// 校验放在写盘之前(主 provider 必须已配齐),因为「先存下来、下次启动才发现起不来」
|
// `maxResults` 的第三态**比另外两个严**:它没有「空串 = 关闭」这种解释,因此 ≤0 与非整数
|
||||||
// 是本项目明确要避免的失败形态(AGENTS §10 的「诚实性」与 design/27 §3 的 fail-fast)。
|
// 一律 400(口径与理由见 selectionMaxResults)—— 那正是这一片要消灭的「我改了它却没变」。
|
||||||
func (e webSearchActionEnv) applySelectionPatch(
|
//
|
||||||
|
// 🔴 **顺序硬约束(缺陷修复 2026-10-07,真机报障)**:主 provider 的可用性校验必须由调用方
|
||||||
|
// 在「**本次这条 provider 条目已经并进文件之后**」做(`checkPrimaryUsable(final, …)`)。
|
||||||
|
// 本函数此前自己顺手校验,用的是**盘上的旧文件** ⇒ 面板上第一次给某家填 key、又在**同一次
|
||||||
|
// 提交**里把它选为主 provider 时,校验看不到那个还没落盘的 key,于是整笔(**连同刚填的 key**)
|
||||||
|
// 被 400 `primary_not_configured` 回滚 —— 界面上表现为「怎么填都存不进去」,而盘上文件
|
||||||
|
// mtime 一动不动(2026-10-07 09:50 日志连续两次 `plugin.saveProvider … code=primary_not_configured`)。
|
||||||
|
// 调用点见 websearch_action_data.go 的 saveProvider:先 `UpsertProviderEntry`,再校验,最后写盘。
|
||||||
|
func (e webSearchActionEnv) mergeSelectionPatch(
|
||||||
f runtimeconfig.WebSearchFile, values map[string]any,
|
f runtimeconfig.WebSearchFile, values map[string]any,
|
||||||
) (runtimeconfig.WebSearchFile, error) {
|
) (runtimeconfig.WebSearchFile, bool, error) {
|
||||||
primary, setPrimary, err := selectionPrimary(values)
|
primary, setPrimary, err := selectionPrimary(values)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return f, err
|
return f, false, err
|
||||||
}
|
}
|
||||||
fallback, setFallback, err := selectionFallback(values)
|
fallback, setFallback, err := selectionFallback(values)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return f, err
|
return f, false, err
|
||||||
|
}
|
||||||
|
maxResults, setMaxResults, err := selectionMaxResults(values)
|
||||||
|
if err != nil {
|
||||||
|
return f, false, err
|
||||||
}
|
}
|
||||||
if setPrimary {
|
if setPrimary {
|
||||||
f.Provider = primary
|
f.Provider = primary
|
||||||
@@ -69,19 +88,17 @@ func (e webSearchActionEnv) applySelectionPatch(
|
|||||||
if setFallback {
|
if setFallback {
|
||||||
f.Fallback = fallback
|
f.Fallback = fallback
|
||||||
}
|
}
|
||||||
if setPrimary {
|
if setMaxResults {
|
||||||
if err := e.checkPrimaryUsable(f, f.Provider); err != nil {
|
f.MaxResults = maxResults
|
||||||
return f, err
|
|
||||||
}
|
}
|
||||||
}
|
return f, setPrimary, nil
|
||||||
return f, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkPrimaryUsable 判定一个主 provider 名在**落盘后的文件**里是否可用。
|
// checkPrimaryUsable 判定一个主 provider 名在**落盘后的文件**里是否可用。
|
||||||
//
|
//
|
||||||
// 判定口径与工具接线同源(searchProviderSpecs + effectiveBaseURL):云家看 API Key、
|
// 判定口径与工具接线同源(resolveSearchProviders + resolveBaseURL):云家看 API Key、
|
||||||
// searxng 看接口地址,基地址缺位时回落到代码默认值;条目被显式停用视同没配
|
// searxng 看接口地址,基地址缺位时回落**该接入位的代码默认基地址**;条目被显式停用
|
||||||
// (effectiveSearchConfig 会跳过它)。
|
// 视同没配(装配期会跳过它)。判据本身**不放松**:真的没配齐的主家仍必须被拒。
|
||||||
func (e webSearchActionEnv) checkPrimaryUsable(f runtimeconfig.WebSearchFile, name string) error {
|
func (e webSearchActionEnv) checkPrimaryUsable(f runtimeconfig.WebSearchFile, name string) error {
|
||||||
// 显式关闭(空串 / off / none)永远合法:它表示「不要这把工具」,工具不进注册面,
|
// 显式关闭(空串 / off / none)永远合法:它表示「不要这把工具」,工具不进注册面,
|
||||||
// 后端照常启动(与 WEB_SEARCH_PROVIDER 的既有语义逐字一致)。
|
// 后端照常启动(与 WEB_SEARCH_PROVIDER 的既有语义逐字一致)。
|
||||||
@@ -92,18 +109,26 @@ func (e webSearchActionEnv) checkPrimaryUsable(f runtimeconfig.WebSearchFile, na
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
entry, found := findProviderEntry(f, name)
|
entry, found := findProviderEntry(f, name)
|
||||||
base := ""
|
if !found || !entry.IsEnabled() {
|
||||||
if found {
|
return primaryNotConfiguredError(name)
|
||||||
base = e.effectiveBaseURL(name, entry)
|
|
||||||
}
|
}
|
||||||
if !found || !entry.IsEnabled() || !search.Configured(name, entry.APIKey, base) {
|
// 基地址的进位链与装配期**同一个函数**:条目里的值 → 该接入位的默认基地址 → 空。
|
||||||
|
// 云家的可用性只看 key(地址取默认值也照样能打通),searxng 的默认值是空
|
||||||
|
// —— 因此「searxng 主家必须有地址」这条判据与既往逐字一致。
|
||||||
|
if !search.Configured(name, entry.APIKey, resolveBaseURL(name, entry.BaseURL)) {
|
||||||
|
return primaryNotConfiguredError(name)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// primaryNotConfiguredError 造「主家没配齐」这一条的 400:文案与码只有一份
|
||||||
|
// (两个判据分支共用,免得两处措辞漂移)。
|
||||||
|
func primaryNotConfiguredError(name string) error {
|
||||||
return pluginmgr.NewActionError(actionCodePrimaryNotConfigured,
|
return pluginmgr.NewActionError(actionCodePrimaryNotConfigured,
|
||||||
fmt.Sprintf("主提供商 %q 还没配齐(缺 API Key / 接口地址,或这条被停用了):"+
|
fmt.Sprintf("主提供商 %q 还没配齐(缺 API Key / 接口地址,或这条被停用了):"+
|
||||||
"先把它配好再选它 —— 否则这份配置会让后端下一次启动直接失败", name),
|
"先把它配好再选它 —— 否则这份配置会让后端下一次启动直接失败", name),
|
||||||
http.StatusBadRequest, nil)
|
http.StatusBadRequest, nil)
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// selectionNames 返回降级链的线形状(**非 nil 空切片**:nil 会在 JSON 里变成 null,
|
// selectionNames 返回降级链的线形状(**非 nil 空切片**:nil 会在 JSON 里变成 null,
|
||||||
// 而「一条降级都没有」与「这个字段没值」在前端不该长得不一样)。
|
// 而「一条降级都没有」与「这个字段没值」在前端不该长得不一样)。
|
||||||
@@ -114,10 +139,11 @@ func selectionNames(names []string) []string {
|
|||||||
return names
|
return names
|
||||||
}
|
}
|
||||||
|
|
||||||
// addSelectionValues 把盘上的文件级选择回填进 `reauth` 的 values。
|
// addSelectionValues 把盘上的**文件级取值**回填进 `reauth` 的 values。
|
||||||
//
|
//
|
||||||
// 回填的必要性在前端的三态写回里:只有 `initial` 有值,用户「清空降级链」才会被送成
|
// 回填的必要性在前端的三态写回里:只有 `initial` 有值,用户「清空降级链」才会被送成
|
||||||
// 显式空(`planSubmit`),否则会落成「缺位 = 不改动」——「我清掉了它」就成了一句假话。
|
// 显式空(`planSubmit`),否则会落成「缺位 = 不改动」——「我清掉了它」就成了一句假话。
|
||||||
|
// `maxResults` 同理:不回填的话表单里那个框是空的,用户看不见当前上限、也就无从改起。
|
||||||
func addSelectionValues(values map[string]any, f runtimeconfig.WebSearchFile) {
|
func addSelectionValues(values map[string]any, f runtimeconfig.WebSearchFile) {
|
||||||
values[selectionPrimaryKey] = f.Provider
|
values[selectionPrimaryKey] = f.Provider
|
||||||
fb := f.Fallback
|
fb := f.Fallback
|
||||||
@@ -125,6 +151,60 @@ func addSelectionValues(values map[string]any, f runtimeconfig.WebSearchFile) {
|
|||||||
fb = []string{}
|
fb = []string{}
|
||||||
}
|
}
|
||||||
values[selectionFallbackKey] = fb
|
values[selectionFallbackKey] = fb
|
||||||
|
// 上限只在盘上**真的给过**时才回填(`maxResults` 的 0 = 未声明):回填一个 0 会被前端
|
||||||
|
// 当成一个值送回来,而写侧拒 ≤0 —— 用户「什么都没改」的一次保存就整笔进不去了。
|
||||||
|
if f.MaxResults > 0 {
|
||||||
|
values[selectionMaxResultsKey] = f.MaxResults
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// selectionMaxResults 解析结果条数上限字段(落点:文件级 `maxResults`)。**三态**:
|
||||||
|
//
|
||||||
|
// - 缺位 / JSON null ⇒ 不改动;
|
||||||
|
// - 正整数 ⇒ 采用(落盘后由热重载事务接上工具,下一次搜索就用它);
|
||||||
|
// - 其余(0、负数、小数、空串、非数字字符串、布尔、数组…)⇒ **400 invalid_payload**。
|
||||||
|
//
|
||||||
|
// 为什么第三态必须报错,而不是像 `enabled` 那样「空串 = 未改动」:这一片的来由正是
|
||||||
|
// 「我改了它却没变」—— 手工塞进提交体的上限被写侧静默丢弃(文件不变、也不报错)。既然
|
||||||
|
// 面板上给了这个输入框,任何**给了值却没被采用**的提交都必须说得出原因;否则用户以为上限
|
||||||
|
// 是 20,跑的还是 10。0 / 负数在这里也没有第二种解释:文件形状里 `maxResults` 的 0 =
|
||||||
|
// 「未声明」(`omitempty` 不落盘),把 0 当「去掉上限」会让「去掉」与「从没配过」长得一模一样。
|
||||||
|
func selectionMaxResults(values map[string]any) (int, bool, error) {
|
||||||
|
raw, ok := values[selectionMaxResultsKey]
|
||||||
|
if !ok || raw == nil {
|
||||||
|
return 0, false, nil
|
||||||
|
}
|
||||||
|
n, ok := wholeNumber(raw)
|
||||||
|
if !ok || n < 1 {
|
||||||
|
// %q + Sprint:字符串会带引号(空串才看得见),数字/布尔照原样。
|
||||||
|
return 0, false, invalidPayloadActionError(fmt.Sprintf(
|
||||||
|
"%s 必须是正整数(≥1),收到 %q:这个框不能靠留空或 0 去掉上限,请填一个具体条数",
|
||||||
|
selectionMaxResultsKey, fmt.Sprint(raw)))
|
||||||
|
}
|
||||||
|
return n, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// wholeNumber 把一个补丁值还原成整数:JSON 数字(float64,必须**没有小数部分**)、Go 侧构造的
|
||||||
|
// int、以及整数字符串(与 `wire_browser_panel_data.go` 的 patchInt 同一取舍:某些控件会把值以
|
||||||
|
// 字符串送出)。其余类型一律拒 —— 不做静默转换,那正是本片要消灭的那类假成功。
|
||||||
|
func wholeNumber(raw any) (int, bool) {
|
||||||
|
switch v := raw.(type) {
|
||||||
|
case float64:
|
||||||
|
if v != math.Trunc(v) {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return int(v), true
|
||||||
|
case int:
|
||||||
|
return v, true
|
||||||
|
case string:
|
||||||
|
n, err := strconv.Atoi(strings.TrimSpace(v))
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return n, true
|
||||||
|
default:
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// selectionPrimary 解析主 provider 字段:缺位 ⇒ (…, false, nil);非字符串 ⇒ 400。
|
// selectionPrimary 解析主 provider 字段:缺位 ⇒ (…, false, nil);非字符串 ⇒ 400。
|
||||||
|
|||||||
@@ -280,3 +280,49 @@ func TestWebSearchProbeNotConfiguredIs400(t *testing.T) {
|
|||||||
t.Fatalf("err = %#v, want 400 %s", err, search.ProbeNotConfigured)
|
t.Fatalf("err = %#v, want 400 %s", err, search.ProbeNotConfigured)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestWebSearchSaveProviderAndPrimaryInOneSubmit 是 2026-10-07 真机报障的回归:
|
||||||
|
// 面板上第一次给某家填 key、又在**同一次提交**里把它选为主 provider,必须**一次成功**。
|
||||||
|
//
|
||||||
|
// 缺陷原形:主 provider 的可用性校验拿的是**盘上的旧文件**(那条 provider 还没 key)
|
||||||
|
// ⇒ 整笔(**连同刚填的 key**)被 400 primary_not_configured 回滚,界面上表现为
|
||||||
|
// 「怎么填都存不进去」,而盘上文件 mtime 一动不动。修法:校验移到「本次条目已并进
|
||||||
|
// 文件之后」(websearch_action_data.go 的 saveProvider 先 UpsertProviderEntry 再 check)。
|
||||||
|
func TestWebSearchSaveProviderAndPrimaryInOneSubmit(t *testing.T) {
|
||||||
|
env := newReloadTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
|
||||||
|
|
||||||
|
// 前置:盘上那条 zhipu 没有 key —— 面板第一次配置它时正是这个状态。
|
||||||
|
if e, ok := findProviderEntry(env.loadFile(t), search.NameZhipu); !ok || strings.TrimSpace(e.APIKey) != "" {
|
||||||
|
t.Fatalf("前置不成立:盘上 zhipu 应存在且尚无 key(found=%v, key=%q)", ok, e.APIKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := env.save(map[string]any{
|
||||||
|
"provider": search.NameZhipu, "apiKey": "test-key-001", "enabled": true,
|
||||||
|
"primary": search.NameZhipu,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("同一次提交里填 key + 选它当主家被拒(键与选择必须能一起落盘): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
after := env.loadFile(t)
|
||||||
|
if got := strings.ToLower(strings.TrimSpace(after.Provider)); got != search.NameZhipu {
|
||||||
|
t.Fatalf("盘上 primary = %q, want %q", got, search.NameZhipu)
|
||||||
|
}
|
||||||
|
entry, ok := findProviderEntry(after, search.NameZhipu)
|
||||||
|
if !ok || entry.APIKey != "test-key-001" {
|
||||||
|
t.Fatalf("盘上 zhipu 条目 = %+v, want 带本次填的 key", entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 反向护栏:**真的没配齐**的主家仍必须被拒,且盘上一个字节都不动。
|
||||||
|
// (本次修复只改「校验看哪一份文件」,不改判据。)
|
||||||
|
beforeReject := env.rawFile(t)
|
||||||
|
_, err := env.save(map[string]any{
|
||||||
|
"provider": search.NameBocha, "enabled": true, "primary": search.NameBocha,
|
||||||
|
})
|
||||||
|
ae, ok := pluginmgr.AsActionError(err)
|
||||||
|
if !ok || ae.Code != actionCodePrimaryNotConfigured {
|
||||||
|
t.Fatalf("err = %#v, want 400 %s(bocha 这次没填 key)", err, actionCodePrimaryNotConfigured)
|
||||||
|
}
|
||||||
|
if got := env.rawFile(t); !bytes.Equal(got, beforeReject) {
|
||||||
|
t.Fatalf("被拒的保存动了盘上的文件:\nbefore=%s\nafter =%s", beforeReject, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// 本文件是 design/28 §1 M5 / §8 B5「浏览器渲染取正文」降级档的**组合根挂载点** ——
|
||||||
|
// 切片 Z5 把降级链做好了、但生产接不通(能力面没有取正文入口 ⇒ 没东西可注入 ⇒
|
||||||
|
// 这一档实际关着),本文件就是那次收口留下的缺口的解封。
|
||||||
|
//
|
||||||
|
// 六条口径:
|
||||||
|
//
|
||||||
|
// 1. **挂载条件 = 能力面里确实有可用浏览器**(注册面里那把 browser 工具的驱动
|
||||||
|
// `Available() == true`)。这条判据来自 design/28 §7 判据 1 的诚实口径
|
||||||
|
// 「都没有 ⇒ 工具不注册(不假装可用)」:拿不到来源就不该把一条注定走不通的档挂上。
|
||||||
|
// 2. **不新增开关**。设计**没有**为这条降级档定义配置项(§8 的配置面清单是「端点、
|
||||||
|
// 有头/无头、视口、超时、并发、截图上限」,§8 末的配置面约束又写死「`.env` 已冻结、
|
||||||
|
// 可调项只落配置文件 + 面板」)⇒ 自造一个 `bodyFallback: bool` 字段属于 AGENTS §1 的
|
||||||
|
// 「自行发挥」。挂载条件因此由能力面自身给出,而不是第二处开关。
|
||||||
|
// 3. **io 包的「默认关」口径没有被改写**:`iotools.WithBrowserFallback` 不传 / 传 nil
|
||||||
|
// 仍然是关(`plugins/tools/io` 的库默认)。本文件只把「确实有来源」的那一种情形
|
||||||
|
// 打开 —— 从「永远关」变成「没来源就关」,这正是 design/28 §2 把 M5 列进推荐组合、
|
||||||
|
// §8 把 B5 列为实施项的意思。
|
||||||
|
// 4. **每次调用现取管理器**:理由见 browserBodyRenderer 的文档(面板保存会换掉那一把
|
||||||
|
// 工具与它背后的管理器)。
|
||||||
|
// 5. **只读装配**:本文件不发请求、不起进程、不注册工具 —— `ioToolOptions()` 是只读
|
||||||
|
// 装配,探测的副作用归 `wireBrowser()`(`wire_browser_test.go` 钉住不许在这里探测)。
|
||||||
|
// 6. **失败如实**:挂不上只记一条**带来源**的 WARN(原因码 + 这一档对模型面的实际含义),
|
||||||
|
// 不阻断启动 —— 浏览器是可选增强(design/28 §8 逐片落地状态)。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||||
|
browsertools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/browser"
|
||||||
|
iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 挂载点的文案常量(进日志与结果,不美化)。
|
||||||
|
const (
|
||||||
|
// browserFallbackReasonNoSource 是「这一档没挂上」的原因码:能力面里没有可用浏览器。
|
||||||
|
// 分类原因码而不是「失败」两个字,照 web-search「拿不到后端就不注册」的口径。
|
||||||
|
browserFallbackReasonNoSource = "no-usable-browser"
|
||||||
|
|
||||||
|
// browserFallbackTrigger 写清这一档在什么条件下被走(进启动日志,免得「它到底管什么」
|
||||||
|
// 只能靠读代码)。
|
||||||
|
browserFallbackTrigger = "direct fetch failed or the page was judged dynamic"
|
||||||
|
|
||||||
|
// browserFallbackOffEffect 写清「没挂」对模型面的**实际**含义:不是说结果会假,
|
||||||
|
// 而是说这一档没有第二次机会(直连的真实遭遇仍然照实报出来)。
|
||||||
|
browserFallbackOffEffect = "web-fetch keeps the direct path only; the real outcome is still reported (dynamicSuspect / directError), there is just no browser second look"
|
||||||
|
)
|
||||||
|
|
||||||
|
// bodyFallbackOption 是 design/28 §8 B5 的**挂载点**,落地本文件顶部的六条口径。
|
||||||
|
//
|
||||||
|
// 两种去处都只有一个出口,口径不会在分支里漂移:
|
||||||
|
// - 没有可用浏览器 ⇒ 不挂(`WithBrowserFallback(nil)`)+ 一条带来源的 WARN,说明原因;
|
||||||
|
// - 有可用浏览器 ⇒ 挂上适配器,并记一条 INFO 说明这一档什么时候会被走。
|
||||||
|
func (a *app) bodyFallbackOption() iotools.Option {
|
||||||
|
log := logger.WithSource(a.log, ioLogSource)
|
||||||
|
mgr := browserManagerOf(a.caps)
|
||||||
|
if mgr == nil || !mgr.Available() {
|
||||||
|
log.Warn("web-fetch browser fallback not wired",
|
||||||
|
"reason", browserFallbackReasonNoSource,
|
||||||
|
"effect", browserFallbackOffEffect)
|
||||||
|
return iotools.WithBrowserFallback(nil)
|
||||||
|
}
|
||||||
|
log.Info("web-fetch browser fallback wired", "trigger", browserFallbackTrigger)
|
||||||
|
return iotools.WithBrowserFallback(&browserBodyRenderer{caps: a.caps, log: log})
|
||||||
|
}
|
||||||
|
|
||||||
|
// browserFallbackReasonBudget 是「这一档**没有发起**」的原因码:调用方这一轮的预算已经
|
||||||
|
// 耗尽。它与 `no-usable-browser` 是两件事(一个是「没时间了」,一个是「没有浏览器」),
|
||||||
|
// 混成一句会把人带偏(真机就出现过:窗口上是浏览器的抱怨,日志里像浏览器坏了)。
|
||||||
|
const browserFallbackReasonBudget = "budget-exhausted"
|
||||||
|
|
||||||
|
// browserFallbackBudgetNote 写清这一刻的**实际**含义:浏览器没坏、这一档没被尝试。
|
||||||
|
// 它进错误文本(顺 web-fetch 的 browserError 面出来),因此必须一眼可读、不含猜测。
|
||||||
|
const browserFallbackBudgetNote = "本轮工具预算已耗尽(调用方上下文已结束)⇒ 未尝试浏览器渲染,这不是浏览器故障"
|
||||||
|
|
||||||
|
// browserBodyRenderer 把浏览器能力面(`*browsercap.Manager.RenderText`)适配成 io 包的
|
||||||
|
// `iotools.BodyRenderer` —— 这是「进程内接口 + 依赖注入」的适配器,不引入第二个浏览器栈、
|
||||||
|
// 不新开任何监听端口(AGENTS §9)。
|
||||||
|
//
|
||||||
|
// **为什么每次调用现取管理器**:面板保存 browser 配置会重建那把工具并换掉它背后的管理器
|
||||||
|
// (`wire_browser_panel.go` 的热重载 + `ReplaceTool`),装配时抓住的旧指针会在一次保存之后
|
||||||
|
// 变成一具尸体 —— 那条降级档会静默退化成「每次都如实失败」,而这是最难查的一类失效。
|
||||||
|
// 从**注册面**取当前那一把,拿到的就是正在跑的那一个。
|
||||||
|
//
|
||||||
|
// 红线(与 `iotools.BodyRenderer` 的注释同源):只做对外连接;SSRF 判定由浏览器侧用
|
||||||
|
// **同一份** `io.ValidateRequestURL` 自己再过一遍(本适配器不替它免检,原始 URL 原样交过去);
|
||||||
|
// 正文在渲染完成后才取(由 `Manager.RenderText` 保证)。
|
||||||
|
//
|
||||||
|
// 空正文**不在这里**判:`RenderText` 的「渲染成功但正文为空」是读数不是错误,而 io 的
|
||||||
|
// 降级档有自己的口径(`ErrEmptyRenderedBody`)—— 本适配器原样交出去,不替上层下结论。
|
||||||
|
type browserBodyRenderer struct {
|
||||||
|
caps *registry.Registry
|
||||||
|
// log 是挂载点的日志(已带来源)。可为 nil(单测直接构造):那就不记,不做替代语义。
|
||||||
|
log *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderBody 实现 iotools.BodyRenderer。
|
||||||
|
//
|
||||||
|
// **预算口径**(design/28 §1 M5「降级档」+ §7 判据 4「如实标注」+ §6 坑 5「失败要如实报」,
|
||||||
|
// 设计没有为这一档规定独立预算 ⇒ 取最小的诚实口径):ctx 已经结束(这一轮的工具预算耗尽 /
|
||||||
|
// 会话被取消)时**不发起**,如实报「预算耗尽 ⇒ 未尝试」,而**不是**报一个看起来像
|
||||||
|
// 「浏览器坏了」的错误。三条理由:
|
||||||
|
//
|
||||||
|
// - 这一档要冷启动真浏览器(秒级),启动所需的预算此刻已经不存在;
|
||||||
|
// - 工具派发层在 ctx 结束时就要把结果丢掉(`internal/runtime/tool` 的 callTool 在
|
||||||
|
// ctx.Done 与结果之间二选一,且 ctx 结束时返回 ctx.Err())⇒ 硬起一个浏览器只会留下
|
||||||
|
// 一个被立刻杀掉的窗口,**结果没人读**(真机踩过:窗口上跳出浏览器的抱怨);
|
||||||
|
// - 没试过就不能报成「试了失败」:`fetchedBy` / `browserError` 的语义(见
|
||||||
|
// `plugins/tools/io/bodyfetch.go`)一字不动,本适配器只交「没发起」这个事实。
|
||||||
|
//
|
||||||
|
// ctx 还活着时**不动**它的预算(不另造一个第二预算):浏览器会话可能是热的(同一次
|
||||||
|
// 会话里刚渲染过),渲染只要一两秒;真有取消,浏览器侧会把它如实分类成「预算耗尽」
|
||||||
|
// (见 `internal/capability/browser` 的分类标记),不会再冒充「浏览器坏了」。
|
||||||
|
func (r *browserBodyRenderer) RenderBody(ctx context.Context, rawURL string) (iotools.RenderedBody, error) {
|
||||||
|
mgr := browserManagerOf(r.caps)
|
||||||
|
if mgr == nil || !mgr.Available() {
|
||||||
|
// 诚实降级:此时是「这一档没有来源」,**不是**「取到了空正文」。
|
||||||
|
return iotools.RenderedBody{}, errors.New("web-fetch browser fallback: no usable browser available")
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
if r.log != nil {
|
||||||
|
r.log.Info("web-fetch browser fallback not attempted",
|
||||||
|
"reason", browserFallbackReasonBudget, "error", err)
|
||||||
|
}
|
||||||
|
return iotools.RenderedBody{}, fmt.Errorf("web-fetch browser fallback: %s: %s: %w",
|
||||||
|
browserFallbackReasonBudget, browserFallbackBudgetNote, err)
|
||||||
|
}
|
||||||
|
rendered, err := mgr.RenderText(ctx, rawURL)
|
||||||
|
if err != nil {
|
||||||
|
return iotools.RenderedBody{}, fmt.Errorf("browser render: %w", err)
|
||||||
|
}
|
||||||
|
return iotools.RenderedBody{Text: rendered.Text, Source: rendered.Source}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// browserManagerOf 从能力面取回**当前**的浏览器管理器;没有可用来源(没注册 / 注册的
|
||||||
|
// 不是这把工具 / 驱动不是本机的管理器,例如单测夹具)时返回 nil。
|
||||||
|
//
|
||||||
|
// 它**只读**注册面:不发请求、不起进程、不注册工具 —— 本文件被 `ioToolOptions()` 调用,
|
||||||
|
// 那是一条只读装配路径(口径 5)。
|
||||||
|
func browserManagerOf(caps *registry.Registry) *browser.Manager {
|
||||||
|
if caps == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
entry, err := caps.Get(browsertools.ToolName)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tool, ok := entry.Value.(*browsertools.Tool)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return tool.Manager()
|
||||||
|
}
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// 本文件钉住切片 Z5 解封的那条接线(design/28 §1 M5 / §8 B5):把浏览器能力面的
|
||||||
|
// 「渲染取正文」适配成 web-fetch 的降级档,并在组合根决定**挂不挂**。
|
||||||
|
//
|
||||||
|
// 三件事必须有测试拦(它们删掉之后运行期都没有症状):
|
||||||
|
//
|
||||||
|
// 1. 能力面里**没有**可用浏览器 ⇒ 这一档**不挂**,且如实留一条带来源的原因;
|
||||||
|
// 2. 能力面里**有**可用浏览器 ⇒ 这一档真的被挂上、被调用(判据是浏览器那一档的
|
||||||
|
// 真实遭遇出现在返回体里,而不是「某个函数被调过」);
|
||||||
|
// 3. 适配器在「取正文时已经没有可用浏览器」时如实报错,**不返回空串冒充成功**。
|
||||||
|
//
|
||||||
|
// 另外两条是接线形状的钉子(照 `TestWireRuntime_CallsWireBrowser` 的先例):挂载点不能
|
||||||
|
// 从生产组装里消失,装配顺序不能把浏览器排在 io 工具集之后。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
browsercap "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||||
|
browsertools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/browser"
|
||||||
|
iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io"
|
||||||
|
)
|
||||||
|
|
||||||
|
// webFetchToolName 是 web-fetch 在注册面里的名字(io 包里的字面量,这里按同一份契约引用)。
|
||||||
|
const webFetchToolName = "web-fetch"
|
||||||
|
|
||||||
|
// serveSkeletonPage 起一个「直连能成功、但只有前端框架挂载点」的页面:它是这条降级档的
|
||||||
|
// 入口条件之一(直连成功却 `dynamicSuspect`)。
|
||||||
|
func serveSkeletonPage(t *testing.T) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_, _ = w.Write([]byte(`<html><body><div id="app"></div><script src="/x.js"></script></body></html>`))
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
// endpointHost 是白名单条目的写法:host:port(判定源会剥端口比对)。
|
||||||
|
func endpointHost(srv *httptest.Server) string { return strings.TrimPrefix(srv.URL, "http://") }
|
||||||
|
|
||||||
|
// fallbackFetchTool 造一把**走挂载点**的 web-fetch:选项里带 `bodyFallbackOption()`,
|
||||||
|
// 测试服务放进 SSRF 白名单(否则直连那一档先被守卫拒掉,降级档根本不会被触发)。
|
||||||
|
//
|
||||||
|
// 选项走**生产路径**:`a.ioToolOptions()`(挂载点就在里面),而不是只把 `bodyFallbackOption()`
|
||||||
|
// 单独拿出来 —— 这样两个用例证明的就是「启动期真正交出去的那份选项」。
|
||||||
|
func fallbackFetchTool(t *testing.T, a *app, srv *httptest.Server) sdk.Tool {
|
||||||
|
t.Helper()
|
||||||
|
opts := append(a.ioToolOptions(),
|
||||||
|
iotools.WithAllowedHosts(endpointHost(srv)), iotools.WithHTTPClient(srv.Client()))
|
||||||
|
set, err := iotools.New(a.cfg, opts...)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("iotools.New: %v", err)
|
||||||
|
}
|
||||||
|
for _, tool := range set {
|
||||||
|
if tool.Name() == webFetchToolName {
|
||||||
|
return tool
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("工具集里没有 %s", webFetchToolName)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// execFetch 跑一次 web-fetch 并把它返回的 map 取出来。
|
||||||
|
func execFetch(t *testing.T, tool sdk.Tool, rawURL string) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
got, err := tool.Execute(context.Background(), map[string]any{"url": rawURL})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("web-fetch Execute: %v", err)
|
||||||
|
}
|
||||||
|
m, ok := got.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("web-fetch 返回的不是对象: %#v", got)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// newWiredBrowserManager 造一个**报告可用**的浏览器管理器:探测结论说「本机有 Chrome」,
|
||||||
|
// 但可执行文件故意不存在 —— `Available()` 为 true(于是降级档会被挂上),而任何一次真实
|
||||||
|
// 会话都会以 ErrLaunch 如实失败。
|
||||||
|
func newWiredBrowserManager(t *testing.T, cfg *config.Config, allowedHost string) *browsercap.Manager {
|
||||||
|
t.Helper()
|
||||||
|
mgr := browsercap.NewManager(browsercap.Options{
|
||||||
|
Availability: browsercfg.Availability{
|
||||||
|
Usable: true, Source: browsercfg.SourceLocalChrome,
|
||||||
|
Executable: "definitely-not-a-browser.exe", Detail: "假来源(本用例不真起浏览器)",
|
||||||
|
},
|
||||||
|
Config: browsercfg.Defaults(cfg.DataDir),
|
||||||
|
DataDir: cfg.DataDir,
|
||||||
|
AllowedHosts: []string{allowedHost},
|
||||||
|
})
|
||||||
|
t.Cleanup(func() { _ = mgr.Close() })
|
||||||
|
return mgr
|
||||||
|
}
|
||||||
|
|
||||||
|
// ① 没有可用浏览器 ⇒ 这一档不挂:直连的结论照实交出来,并如实说明「没有浏览器第二眼」;
|
||||||
|
// 日志里留下分类原因码与来源标签。
|
||||||
|
func TestBodyFallback_NoBrowserMeansTierNotWired(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cfg := wsLoadConfig(t)
|
||||||
|
var out bytes.Buffer
|
||||||
|
a := browserTestApp(t, cfg, &out) // 注册面里没有 browser 工具 = 这台机器没有可用来源
|
||||||
|
srv := serveSkeletonPage(t)
|
||||||
|
|
||||||
|
m := execFetch(t, fallbackFetchTool(t, a, srv), srv.URL)
|
||||||
|
|
||||||
|
if m["fetchedBy"] != iotools.FetchedByDirect {
|
||||||
|
t.Errorf("fetchedBy = %v, want %q(浏览器没取到,来源仍是直连)", m["fetchedBy"], iotools.FetchedByDirect)
|
||||||
|
}
|
||||||
|
if m["dynamicSuspect"] != true {
|
||||||
|
t.Errorf("dynamicSuspect = %v,骨架页必须被判成可疑", m["dynamicSuspect"])
|
||||||
|
}
|
||||||
|
be, _ := m["browserError"].(string)
|
||||||
|
if !strings.Contains(be, "browser fallback disabled") {
|
||||||
|
t.Errorf("这一档没挂时必须如实说明(browserError = %q)", be)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out.String(), "reason="+browserFallbackReasonNoSource) {
|
||||||
|
t.Errorf("日志里必须留下分类原因码 reason=%s(实得:%s)", browserFallbackReasonNoSource, out.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(out.String(), "source=cmd/cyrene/wire_io_tools") {
|
||||||
|
t.Errorf("这条日志必须带来源标签(AGENTS §10):%s", out.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ② 有可用浏览器 ⇒ 这一档真的挂上并被调用:浏览器那一档的**真实遭遇**(这里是不存在的
|
||||||
|
// 可执行文件 ⇒ ErrLaunch)出现在返回体里,而不是被吞掉。
|
||||||
|
func TestBodyFallback_AvailableBrowserIsWiredAndHonest(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cfg := wsLoadConfig(t)
|
||||||
|
var out bytes.Buffer
|
||||||
|
a := browserTestApp(t, cfg, &out)
|
||||||
|
srv := serveSkeletonPage(t)
|
||||||
|
if err := a.caps.RegisterTool(browsertools.New(newWiredBrowserManager(t, cfg, endpointHost(srv)))); err != nil {
|
||||||
|
t.Fatalf("注册 browser 工具: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m := execFetch(t, fallbackFetchTool(t, a, srv), srv.URL)
|
||||||
|
|
||||||
|
be, _ := m["browserError"].(string)
|
||||||
|
if !strings.Contains(be, "启动本机浏览器失败") {
|
||||||
|
t.Errorf("浏览器这一档被挂上后就该被真调用,并把失败如实写进结果;browserError = %q", be)
|
||||||
|
}
|
||||||
|
if m["fetchedBy"] != iotools.FetchedByDirect {
|
||||||
|
t.Errorf("fetchedBy = %v, want %q(浏览器没取到正文)", m["fetchedBy"], iotools.FetchedByDirect)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out.String(), "web-fetch browser fallback wired") {
|
||||||
|
t.Errorf("挂上时应有带来源的 INFO:%s", out.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ③ 适配器在「取正文时已经没有可用浏览器」时如实报错,不返回空串冒充成功。
|
||||||
|
func TestBrowserBodyRenderer_NoUsableBrowserIsNotSilentSuccess(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cfg := wsLoadConfig(t)
|
||||||
|
withUnavailable := registry.New()
|
||||||
|
if err := withUnavailable.RegisterTool(browsertools.New(
|
||||||
|
newUnavailableBrowserManager(t, cfg))); err != nil {
|
||||||
|
t.Fatalf("注册 browser 工具: %v", err)
|
||||||
|
}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
caps *registry.Registry
|
||||||
|
}{
|
||||||
|
{"注册面为 nil", nil},
|
||||||
|
{"注册面里没有 browser", registry.New()},
|
||||||
|
{"工具在但驱动不可用", withUnavailable},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
r := &browserBodyRenderer{caps: tc.caps}
|
||||||
|
got, err := r.RenderBody(context.Background(), "https://example.com/")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("没有可用浏览器时必须报错(实得 %+v)", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "no usable browser") {
|
||||||
|
t.Errorf("err = %v,应说清是「这一档没有来源」", err)
|
||||||
|
}
|
||||||
|
if got.Text != "" || got.Source != "" {
|
||||||
|
t.Errorf("失败时不该交出半个结论: %+v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newUnavailableBrowserManager 造一个「探测不到来源」的管理器(Available() == false)。
|
||||||
|
func newUnavailableBrowserManager(t *testing.T, cfg *config.Config) *browsercap.Manager {
|
||||||
|
t.Helper()
|
||||||
|
mgr := browsercap.NewManager(browsercap.Options{
|
||||||
|
Availability: browsercfg.Availability{Usable: false, Detail: "本机没有浏览器"},
|
||||||
|
Config: browsercfg.Defaults(cfg.DataDir),
|
||||||
|
DataDir: cfg.DataDir,
|
||||||
|
})
|
||||||
|
t.Cleanup(func() { _ = mgr.Close() })
|
||||||
|
return mgr
|
||||||
|
}
|
||||||
|
|
||||||
|
// ④ 适配器跟的是**注册面里的当前那一把**,不是装配时的那一个:面板保存 browser 配置会
|
||||||
|
// 用 `ReplaceTool` 换掉工具与它背后的管理器,抓着旧指针会让这条降级档在一次保存之后变成
|
||||||
|
// 一具尸体(而且只在「取正文时」才暴露)。
|
||||||
|
func TestBrowserBodyRenderer_FollowsRegistryReplacement(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cfg := wsLoadConfig(t)
|
||||||
|
var out bytes.Buffer
|
||||||
|
a := browserTestApp(t, cfg, &out)
|
||||||
|
if err := a.caps.RegisterTool(browsertools.New(
|
||||||
|
newWiredBrowserManager(t, cfg, "example.com"))); err != nil {
|
||||||
|
t.Fatalf("注册 browser 工具: %v", err)
|
||||||
|
}
|
||||||
|
r := &browserBodyRenderer{caps: a.caps}
|
||||||
|
|
||||||
|
if _, err := r.RenderBody(context.Background(), "https://example.com/"); err == nil ||
|
||||||
|
strings.Contains(err.Error(), "no usable browser") {
|
||||||
|
t.Fatalf("替换前应当用**可用的**那一把(真去渲染,因假 exe 而失败);实得 %v", err)
|
||||||
|
}
|
||||||
|
// 面板保存 → 换掉注册面里的那一把(新的管理器不可用)。
|
||||||
|
if err := a.caps.ReplaceTool(browsertools.New(newUnavailableBrowserManager(t, cfg))); err != nil {
|
||||||
|
t.Fatalf("替换 browser 工具: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := r.RenderBody(context.Background(), "https://example.com/"); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), "no usable browser") {
|
||||||
|
t.Fatalf("替换后适配器必须改口成「没有可用来源」,实得 %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ④ 挂载点不能从生产组装里消失:`ioToolOptions()` 是启动期唯一把降级档交给 io 包的地方,
|
||||||
|
// 它被删掉之后编译照过、启动照起,只有模型那边少一次「浏览器第二眼」。
|
||||||
|
func TestIOToolOptions_IncludesBodyFallback(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
src := readSource(t, "wire_io_tools.go")
|
||||||
|
if !strings.Contains(src, "\t\ta.bodyFallbackOption(),") {
|
||||||
|
t.Fatal("wire_io_tools.go 里找不到 `a.bodyFallbackOption()` —— 浏览器降级档不会被挂上" +
|
||||||
|
"(J5 在 wire_body_fallback.go,挂载点必须留在生产组装里)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⑤ 装配顺序:浏览器必须在 io 工具集**之前**装配。降级档的挂载条件读的就是注册面,
|
||||||
|
// 反过来写会让它永远读到「没有浏览器」⇒ 静默关死(没有任何运行期症状)。
|
||||||
|
func TestWireRuntime_BrowserWiredBeforeIoTools(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
src := readSource(t, "wire_runtime.go")
|
||||||
|
browse := strings.Index(src, "\ta.wireBrowser()")
|
||||||
|
tools := strings.Index(src, "ioTools, ioErr := iotools.New(a.cfg, a.ioToolOptions()...")
|
||||||
|
if browse < 0 || tools < 0 {
|
||||||
|
t.Fatalf("wire_runtime.go 里找不到装配点(browser=%d, ioTools=%d)", browse, tools)
|
||||||
|
}
|
||||||
|
if browse > tools {
|
||||||
|
t.Fatal("wire_runtime.go 把浏览器装配排在了 io 工具集之后 —— web-fetch 的浏览器降级档" +
|
||||||
|
"会静默关死(挂载条件读的是注册面)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⑥ 预算口径:这一轮的预算已经耗尽(ctx 已结束)时,这一档**不发起**,如实报
|
||||||
|
// 「预算耗尽 ⇒ 未尝试浏览器渲染」。判据是两句话都要在,而那句误导性的
|
||||||
|
// 「启动本机浏览器失败」绝不能出现 —— 假 exe 一定启动失败,它出现就证明我们真去起了
|
||||||
|
// 浏览器(那正是真机踩的坑:窗口上跳出浏览器的抱怨,日志里像浏览器坏了)。
|
||||||
|
func TestBrowserBodyRenderer_ExhaustedBudgetIsNotABrowserFailure(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cfg := wsLoadConfig(t)
|
||||||
|
var out bytes.Buffer
|
||||||
|
a := browserTestApp(t, cfg, &out)
|
||||||
|
if err := a.caps.RegisterTool(browsertools.New(
|
||||||
|
newWiredBrowserManager(t, cfg, "example.com"))); err != nil {
|
||||||
|
t.Fatalf("注册 browser 工具: %v", err)
|
||||||
|
}
|
||||||
|
r := &browserBodyRenderer{caps: a.caps, log: logger.WithSource(a.log, ioLogSource)}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
ctx func() (context.Context, context.CancelFunc)
|
||||||
|
}{
|
||||||
|
{"已取消", func() (context.Context, context.CancelFunc) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
return ctx, cancel
|
||||||
|
}},
|
||||||
|
{"已到期", func() (context.Context, context.CancelFunc) {
|
||||||
|
return context.WithTimeout(context.Background(), 0)
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
ctx, cancel := tc.ctx()
|
||||||
|
defer cancel()
|
||||||
|
got, err := r.RenderBody(ctx, "https://example.com/")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("预算耗尽时必须报错(不许拿空正文冒充成功): %+v", got)
|
||||||
|
}
|
||||||
|
t.Logf("实测 err=%v", err)
|
||||||
|
msg := err.Error()
|
||||||
|
if !strings.Contains(msg, browserFallbackReasonBudget) {
|
||||||
|
t.Errorf("err = %v,应带原因码 %q", err, browserFallbackReasonBudget)
|
||||||
|
}
|
||||||
|
if !strings.Contains(msg, "未尝试浏览器渲染") {
|
||||||
|
t.Errorf("err = %v,应说清「没有试过」", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(msg, "启动本机浏览器失败") {
|
||||||
|
t.Errorf("预算耗尽不许被报成浏览器故障: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(msg, "no usable browser") {
|
||||||
|
t.Errorf("「预算耗尽」与「没有可用来源」是两件事,不许混成一句: %v", err)
|
||||||
|
}
|
||||||
|
if got.Text != "" || got.Source != "" {
|
||||||
|
t.Errorf("失败时不该交出半个结论: %+v", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out.String(), "reason="+browserFallbackReasonBudget) {
|
||||||
|
t.Errorf("日志里必须留下原因码(实得:%s)", out.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(out.String(), "source="+ioLogSource) {
|
||||||
|
t.Errorf("这条日志必须带来源标签(AGENTS §10):%s", out.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⑦ 端到端(走生产接线的选项):预算耗尽的那一轮,web-fetch 的错误里**两条档的真实遭遇
|
||||||
|
// 都要在** —— 直连为什么没成 + 浏览器这一档为什么**没试**(预算耗尽)。io 层的
|
||||||
|
// `fetchedBy` / `browserError` 语义不许变(那是它的红线),所以这里断言的是它们的**输出**
|
||||||
|
// 仍然照实:两条失败各自出现,谁也别替谁背锅。
|
||||||
|
func TestBodyFallback_ExhaustedBudgetIsHonestEndToEnd(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cfg := wsLoadConfig(t)
|
||||||
|
var out bytes.Buffer
|
||||||
|
a := browserTestApp(t, cfg, &out)
|
||||||
|
srv := serveSkeletonPage(t)
|
||||||
|
if err := a.caps.RegisterTool(browsertools.New(
|
||||||
|
newWiredBrowserManager(t, cfg, endpointHost(srv)))); err != nil {
|
||||||
|
t.Fatalf("注册 browser 工具: %v", err)
|
||||||
|
}
|
||||||
|
tool := fallbackFetchTool(t, a, srv)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
got, err := tool.Execute(ctx, map[string]any{"url": srv.URL})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("两条档都没成时必须报错(实得 %#v)", got)
|
||||||
|
}
|
||||||
|
t.Logf("实测 web-fetch 错误:%v", err)
|
||||||
|
msg := err.Error()
|
||||||
|
if !strings.Contains(msg, "browser fallback failed too") {
|
||||||
|
t.Errorf("io 层「两条失败都照实报」的口径不许变: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(msg, browserFallbackReasonBudget) {
|
||||||
|
t.Errorf("浏览器那一档要如实说「预算耗尽 ⇒ 未尝试」: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(msg, "启动本机浏览器失败") {
|
||||||
|
t.Errorf("预算耗尽不许被说成浏览器故障(真去起了浏览器): %v", err)
|
||||||
|
}
|
||||||
|
if got != nil {
|
||||||
|
t.Errorf("失败时不该交出结果: %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,11 +15,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -124,11 +122,15 @@ func TestAppClose_RealChromeStopsOnShutdown(t *testing.T) {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
// ① 起一个真浏览器(指纹自检走 CDP,无需外网),并从 profile 读回它的调试端点。
|
// ① 起一个真浏览器(指纹自检走 CDP,无需外网),并取回它的调试端点。
|
||||||
if _, err := tool.Execute(ctx, map[string]any{"action": "info"}); err != nil {
|
if _, err := tool.Execute(ctx, map[string]any{"action": "info"}); err != nil {
|
||||||
t.Fatalf("action=info(起真浏览器): %v", err)
|
t.Fatalf("action=info(起真浏览器): %v", err)
|
||||||
}
|
}
|
||||||
endpoint := devToolsEndpoint(t, browsercfg.DefaultProfileDir(cfg.DataDir))
|
endpoint, ok := browsercap.DebugEndpoint(browsercfg.DefaultProfileDir(cfg.DataDir))
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("会话已建立,但 profile %s 下没有端口记录(显式端口由我们自己记,见 portrecord.go)",
|
||||||
|
browsercfg.DefaultProfileDir(cfg.DataDir))
|
||||||
|
}
|
||||||
if !endpointAlive(endpoint) {
|
if !endpointAlive(endpoint) {
|
||||||
t.Fatalf("会话已建立但调试端点 %s 不应答:本用例的前提不成立", endpoint)
|
t.Fatalf("会话已建立但调试端点 %s 不应答:本用例的前提不成立", endpoint)
|
||||||
}
|
}
|
||||||
@@ -151,22 +153,6 @@ func TestAppClose_RealChromeStopsOnShutdown(t *testing.T) {
|
|||||||
endpoint, tailOfLog(out.String()))
|
endpoint, tailOfLog(out.String()))
|
||||||
}
|
}
|
||||||
|
|
||||||
// devToolsEndpoint 从 profile 目录的 DevToolsActivePort 读出这次会话的调试端点
|
|
||||||
// (格式与 launch.go 的 waitDevToolsPort 同源:首行是端口号)。
|
|
||||||
func devToolsEndpoint(t *testing.T, profileDir string) string {
|
|
||||||
t.Helper()
|
|
||||||
raw, err := os.ReadFile(filepath.Join(profileDir, "DevToolsActivePort"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("读 DevToolsActivePort: %v(profile=%s)", err, profileDir)
|
|
||||||
}
|
|
||||||
line, _, _ := strings.Cut(strings.TrimSpace(string(raw)), "\n")
|
|
||||||
port, err := strconv.Atoi(strings.TrimSpace(line))
|
|
||||||
if err != nil || port <= 0 {
|
|
||||||
t.Fatalf("DevToolsActivePort 内容不合法: %q", raw)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("http://127.0.0.1:%d", port)
|
|
||||||
}
|
|
||||||
|
|
||||||
// endpointAlive 报告调试端点是否在应答(一次 /json/version)。
|
// endpointAlive 报告调试端点是否在应答(一次 /json/version)。
|
||||||
func endpointAlive(endpoint string) bool {
|
func endpointAlive(endpoint string) bool {
|
||||||
client := &http.Client{Timeout: 2 * time.Second}
|
client := &http.Client{Timeout: 2 * time.Second}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -17,6 +18,7 @@ import (
|
|||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/channelbridge"
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/channelbridge"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ownerRegistrar 实现 gateway.OwnerRegistrar:它把一个渠道账号 id 持久化进主人
|
// ownerRegistrar 实现 gateway.OwnerRegistrar:它把一个渠道账号 id 持久化进主人
|
||||||
@@ -57,6 +59,30 @@ func (r *ownerRegistrar) RegisterChannelOwner(_ context.Context, platform, accou
|
|||||||
// 加载失败则为 nil,gateway 会退回「真实落盘 + 提示需重启」,绝不假装热生效。
|
// 加载失败则为 nil,gateway 会退回「真实落盘 + 提示需重启」,绝不假装热生效。
|
||||||
func (r *ownerRegistrar) LiveOwnerProfile() *user.OwnerProfile { return r.owner }
|
func (r *ownerRegistrar) LiveOwnerProfile() *user.OwnerProfile { return r.owner }
|
||||||
|
|
||||||
|
// channelCaps 把能力注册表适配成 channelbridge.ChannelRegistry(热重载同步注册表的
|
||||||
|
// 窄端口)。替换时写入的形状与 wireChannels 启动注册**逐字相同**
|
||||||
|
// (Name/Kind/Description/Value):Description 取适配器名、Value 取适配器本身 ——
|
||||||
|
// adminListChannels 把 Value 断言成 sdk.Channel、面板按注册名精确匹配,
|
||||||
|
// 形状一旦不一致,渠道就会从面板上消失。
|
||||||
|
type channelCaps struct{ caps *registry.Registry }
|
||||||
|
|
||||||
|
func (c channelCaps) ReplaceChannel(ch sdk.Channel) error {
|
||||||
|
if ch == nil {
|
||||||
|
return fmt.Errorf("wire channel registry: %w", registry.ErrInvalidArgument)
|
||||||
|
}
|
||||||
|
if err := c.caps.ReplaceChannel(ch); err != nil {
|
||||||
|
return fmt.Errorf("wire channel registry %q: %w", ch.Name(), err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c channelCaps) RemoveChannel(name string) error {
|
||||||
|
if err := c.caps.Remove(name); err != nil {
|
||||||
|
return fmt.Errorf("wire channel registry %q: %w", name, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// wireChannels 依环境配置构造四个 IM 渠道适配器,把它们注册进能力注册表(管理
|
// wireChannels 依环境配置构造四个 IM 渠道适配器,把它们注册进能力注册表(管理
|
||||||
// API 据此枚举),并接进渠道 bridge——让已配置的适配器连上,把入站消息按
|
// API 据此枚举),并接进渠道 bridge——让已配置的适配器连上,把入站消息按
|
||||||
// session→actor→loop→reply 路由。缺凭据的适配器保持未连接(只记日志,绝不作
|
// session→actor→loop→reply 路由。缺凭据的适配器保持未连接(只记日志,绝不作
|
||||||
@@ -83,6 +109,18 @@ func (a *app) wireChannels() error {
|
|||||||
// 入站附件的目录口径与文档摄取层(wire_runtime.go 的 docResolver)同源,
|
// 入站附件的目录口径与文档摄取层(wire_runtime.go 的 docResolver)同源,
|
||||||
// 两处必须指向同一棵目录树,否则附件会落在一个地方、抽取去另一个地方找。
|
// 两处必须指向同一棵目录树,否则附件会落在一个地方、抽取去另一个地方找。
|
||||||
docDataDir := resolveDataDir(a.cfg)
|
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{
|
bridge, berr := channelbridge.New(channelbridge.Options{
|
||||||
Log: logger.WithSource(a.log, "services/channelbridge"),
|
Log: logger.WithSource(a.log, "services/channelbridge"),
|
||||||
Loop: a.loop,
|
Loop: a.loop,
|
||||||
@@ -120,8 +158,38 @@ func (a *app) wireChannels() error {
|
|||||||
PersonaPrompt: a.defaultInstructions(),
|
PersonaPrompt: a.defaultInstructions(),
|
||||||
Engage: channelbridge.DefaultEngageConfig(),
|
Engage: channelbridge.DefaultEngageConfig(),
|
||||||
// EngageFor 留空(P2a);按群覆盖随 P2c 到来。
|
// 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 让面板保存时热替换某平台的适配器而无需重启(design/04 §3.1)。
|
||||||
Factory: factory,
|
Factory: factory,
|
||||||
|
// Channels 让热重载把新适配器写回能力注册表:管理面板(渠道卡片、账号在线
|
||||||
|
// 状态、open/close 的目标解析)读的就是注册表,不同步它就只能看到旧实例,
|
||||||
|
// 于是卡片永远「未连接」、点「连接」驱动的是旧 URL 的那把。
|
||||||
|
Channels: channelCaps{caps: a.caps},
|
||||||
// ChannelPersona 从工厂构建所用的同一份配置里解析账号的渠道级角色
|
// ChannelPersona 从工厂构建所用的同一份配置里解析账号的渠道级角色
|
||||||
// (design/22 §2 方案 b);bridge 按已连接的适配器缓存它,并在每次重载时刷新。
|
// (design/22 §2 方案 b);bridge 按已连接的适配器缓存它,并在每次重载时刷新。
|
||||||
ChannelPersona: factory.Persona,
|
ChannelPersona: factory.Persona,
|
||||||
@@ -155,6 +223,7 @@ func (a *app) wireChannels() error {
|
|||||||
fallback: factory.fallback,
|
fallback: factory.fallback,
|
||||||
bridge: bridge,
|
bridge: bridge,
|
||||||
owner: a.owner,
|
owner: a.owner,
|
||||||
|
coalesce: coalesceStore,
|
||||||
log: a.log,
|
log: a.log,
|
||||||
}
|
}
|
||||||
// 启动即灌一次每渠道管理员白名单(裁决 06.4.2):不必等面板打开或热重载,
|
// 启动即灌一次每渠道管理员白名单(裁决 06.4.2):不必等面板打开或热重载,
|
||||||
@@ -175,11 +244,55 @@ type runtimeChannels struct {
|
|||||||
// owner 是渠道 bridge 判 actor 用的**同一个**活档案指针:读配置时把每渠道管理员
|
// owner 是渠道 bridge 判 actor 用的**同一个**活档案指针:读配置时把每渠道管理员
|
||||||
// 白名单灌进它的 adminIDs 层(裁决 06.4.2),面板保存后无需重启即生效。
|
// 白名单灌进它的 adminIDs 层(裁决 06.4.2),面板保存后无需重启即生效。
|
||||||
owner *user.OwnerProfile
|
owner *user.OwnerProfile
|
||||||
|
// coalesce 是静默窗口合并(M23)的**同一个** store 实例:bridge 每次发车前从它
|
||||||
|
// 读生效值,因此面板保存后下一次发车即生效(热生效),无需重启。
|
||||||
|
coalesce *runtimeconfig.CoalesceStore
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *runtimeChannels) Path() string { return r.path }
|
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 {
|
func (r *runtimeChannels) Load() map[string][]config.ChannelAccount {
|
||||||
accounts := r.loadAccounts()
|
accounts := r.loadAccounts()
|
||||||
r.applyAdminIDs(accounts)
|
r.applyAdminIDs(accounts)
|
||||||
|
|||||||
@@ -126,7 +126,11 @@ func (a *app) wireFrontend() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("gateway: %w", err)
|
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)
|
a.gw = gw // 必须在下面接线 hook 之前赋值——那些 hook 捕获了 a.gw / gw(否则会 nil 接收者 panic)
|
||||||
// 可观测性 trace 出口(design/19 §2):网关已存在,其环形缓冲必须由循环产出的
|
// 可观测性 trace 出口(design/19 §2):网关已存在,其环形缓冲必须由循环产出的
|
||||||
// span 喂养,否则管理端 trace/metrics 接口会一直是空的。
|
// span 喂养,否则管理端 trace/metrics 接口会一直是空的。
|
||||||
@@ -165,6 +169,12 @@ func (a *app) wireFrontend() error {
|
|||||||
// backthink.New 时网关还没组装。
|
// backthink.New 时网关还没组装。
|
||||||
a.backthink.SetAuditSink(gw)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,6 +213,13 @@ func wirePanelHub(a *app) {
|
|||||||
})
|
})
|
||||||
a.plugins.AttachPanels(hub)
|
a.plugins.AttachPanels(hub)
|
||||||
declareBuiltinPanels(hub, log)
|
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)的面板声明 + 六个动作:它自己就把「先声明、后登记
|
// browser 插件(design/28 §8 B4)的面板声明 + 六个动作:它自己就把「先声明、后登记
|
||||||
// 动作」这一步走完(wire_browser_panel.go 一处),因此与 search 的纯声明并列。
|
// 动作」这一步走完(wire_browser_panel.go 一处),因此与 search 的纯声明并列。
|
||||||
//
|
//
|
||||||
|
|||||||
+59
-156
@@ -2,13 +2,14 @@ package main
|
|||||||
|
|
||||||
// 本文件装配 io 工具集(plugins/tools/io)的选项:知识检索器、日志、
|
// 本文件装配 io 工具集(plugins/tools/io)的选项:知识检索器、日志、
|
||||||
// **web-search 的后端与超时**(切片 WS1-A 接线 + WS1-B provider 层 + PLG-4 可变源),
|
// **web-search 的后端与超时**(切片 WS1-A 接线 + WS1-B provider 层 + PLG-4 可变源),
|
||||||
// 以及 design/28 §8 B5 的**浏览器降级档开关**(`bodyFallbackOption`,当前 = 关)。
|
// 以及 design/28 §8 B5 的**浏览器降级档挂载**(`bodyFallbackOption()`,实现与口径在
|
||||||
|
// `wire_body_fallback.go` —— 挂载条件 = 能力面里确实有可用浏览器,详见那个文件)。
|
||||||
//
|
//
|
||||||
// 为什么单独成文件而不是塞在 wire_runtime.go 里:那条装配线本就接近文件行数上限,
|
// 为什么单独成文件而不是塞在 wire_runtime.go 里:那条装配线本就接近文件行数上限,
|
||||||
// 而这里新增的裁决(端点/provider 关闭 ⇒ 工具不进注册面;选定 provider 未登记/未配置
|
// 而这里新增的裁决(端点/provider 关闭 ⇒ 工具不进注册面;选定 provider 未登记/未配置
|
||||||
// ⇒ 启动报错;面板配置优先、缺失由 `.env` 播种)需要一个能单测的落点。
|
// ⇒ 启动报错;面板配置优先、缺失由 `.env` 播种)需要一个能单测的落点。
|
||||||
//
|
//
|
||||||
// 五条口径:
|
// 六条口径:
|
||||||
//
|
//
|
||||||
// 1. **后端由配置选定**:主 provider 选谁(`WEB_SEARCH_PROVIDER` / 面板文件),降级链
|
// 1. **后端由配置选定**:主 provider 选谁(`WEB_SEARCH_PROVIDER` / 面板文件),降级链
|
||||||
// 怎么走,各家的 key 与基地址从哪来(design/27 §3)。
|
// 怎么走,各家的 key 与基地址从哪来(design/27 §3)。
|
||||||
@@ -19,10 +20,14 @@ package main
|
|||||||
// 3. **拿不到可用后端就不注册工具**(主人 2026-10-05 报障的核心诉求):端点显式关闭
|
// 3. **拿不到可用后端就不注册工具**(主人 2026-10-05 报障的核心诉求):端点显式关闭
|
||||||
// (空串 / off / none)或不是 http(s) 绝对地址时,web-search **不进注册面** ——
|
// (空串 / off / none)或不是 http(s) 绝对地址时,web-search **不进注册面** ——
|
||||||
// 模型根本看不见它,而不是看得见、一调就报错。
|
// 模型根本看不见它,而不是看得见、一调就报错。
|
||||||
// 4. **未配置的家不进可用清单**:`search.Configured` 判定(云家看 key、searxng 看端点),
|
// 4. **各家的名字表来自插件,不来自 `.env`**:文件存在时它是**唯一权威**(文件里没有的家
|
||||||
|
// 就是没有),逐家的名字 / 是否需要 key / 默认基地址全取 `plugins/tools/io/search` 的
|
||||||
|
// 接入位表 ⇒ **加一家 provider 不必加 env 键**(`.env` 冻结红线)。实现在
|
||||||
|
// `websearch_providers.go`。
|
||||||
|
// 5. **未配置的家不进可用清单**:`search.Configured` 判定(云家看 key、searxng 看端点),
|
||||||
// false ⇒ 该家不出现在注册表里(fail-closed)。选定 provider 未配置时由 io 工具集在
|
// false ⇒ 该家不出现在注册表里(fail-closed)。选定 provider 未配置时由 io 工具集在
|
||||||
// **启动期报错**,绝不静默回落到兜底。
|
// **启动期报错**,绝不静默回落到兜底。
|
||||||
// 5. **超时不另造默认值**:`WEB_SEARCH_TIMEOUT` / 面板的 `timeout` 未声明时沿用 io 包既有
|
// 6. **超时不另造默认值**:`WEB_SEARCH_TIMEOUT` / 面板的 `timeout` 未声明时沿用 io 包既有
|
||||||
// 超时;负值是笔误,按未配置处理并记一条带键名的 WARN(不静默)。
|
// 超时;负值是笔误,按未配置处理并记一条带键名的 WARN(不静默)。
|
||||||
//
|
//
|
||||||
// 同一套解析也服务**热重载**(切片 W3,裁决 2026-10-06 #1):面板保存后由
|
// 同一套解析也服务**热重载**(切片 W3,裁决 2026-10-06 #1):面板保存后由
|
||||||
@@ -91,34 +96,14 @@ func (a *app) ioToolOptions() []iotools.Option {
|
|||||||
opts := []iotools.Option{
|
opts := []iotools.Option{
|
||||||
iotools.WithKnowledgeRetriever(a.retriever),
|
iotools.WithKnowledgeRetriever(a.retriever),
|
||||||
iotools.WithLogger(a.log),
|
iotools.WithLogger(a.log),
|
||||||
// B5 的降级档开关(当前 = **关**,理由见 bodyFallbackOption 的文档)。
|
// B5 的挂载点:有可用浏览器就挂上浏览器渲染取正文,没有就不挂(口径与实现
|
||||||
|
// 都在 wire_body_fallback.go)。挂载条件必须在**浏览器装配之后**求值 ——
|
||||||
|
// 这就是 wireRuntime 里 `a.wireBrowser()` 排在 io 工具集之前的原因。
|
||||||
a.bodyFallbackOption(),
|
a.bodyFallbackOption(),
|
||||||
}
|
}
|
||||||
return append(opts, a.webSearchOptions(webSearchCauseStartup)...)
|
return append(opts, a.webSearchOptions(webSearchCauseStartup)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// bodyFallbackOption 是 design/28 §8 B5 的**开关点**:web-fetch 取正文时,直连失败 /
|
|
||||||
// 判定为动态页要不要改走**浏览器渲染**(`iotools.WithBrowserFallback`,默认关)。
|
|
||||||
//
|
|
||||||
// **当前结论 = 关**(本函数返回的就是「不注入渲染器」)。理由在能力面上,不在这个开关上:
|
|
||||||
// `internal/capability/browser` 目前只导出 `Open`(导航,回报 url/title/readyState)、
|
|
||||||
// `Screenshot`、`Fingerprint` —— **没有**任何「取页面正文」的入口,`plugins/tools/browser`
|
|
||||||
// 的工具动作也只有 `open`/`screenshot`/`info`。没有渲染器可注入,这一档打开也只可能
|
|
||||||
// 如实失败 —— 那不是「降级档」,是给每次取正文加一条注定失败的噪声。
|
|
||||||
//
|
|
||||||
// 打开它要等能力面先补一个正文抽取入口(形如
|
|
||||||
// `Manager.RenderText(ctx, url) (RenderedText, error)`,内部走既有的隔离世界求值 +
|
|
||||||
// 同一份 `io.ValidateRequestURL` 判定源),届时把这里换成注入该适配器即可(一行)。
|
|
||||||
// 在此之前**不假装可用** —— 与 web-search「拿不到后端就不注册」同一口径
|
|
||||||
// (design/28 §7 判据 1、§7 判据 4 的诚实标注)。
|
|
||||||
//
|
|
||||||
// 为什么不在这里现场探测浏览器:`ioToolOptions()` 是**只读装配**,探测的副作用
|
|
||||||
// (起进程 / 连 CDP 端点)不属于「取一份 io 选项」这件事 —— 口径见 `wire_browser.go`
|
|
||||||
// 顶部第 3 条,且 `wire_browser_test.go` 钉住它不许在这里探测。
|
|
||||||
func (a *app) bodyFallbackOption() iotools.Option {
|
|
||||||
return iotools.WithBrowserFallback(nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildWebSearchTool 按**当前生效配置**构造这一把 web-search 工具(面板保存后的热重载用它
|
// buildWebSearchTool 按**当前生效配置**构造这一把 web-search 工具(面板保存后的热重载用它
|
||||||
// 重建工具,见 websearch_reload.go)。
|
// 重建工具,见 websearch_reload.go)。
|
||||||
//
|
//
|
||||||
@@ -152,7 +137,8 @@ func (a *app) buildWebSearchTool(cause string) (sdk.Tool, error) {
|
|||||||
// 端点/后端「关闭 / 非法 / 可用」三种去处都在这里定死(ResolveProvider/ResolveEndpoint)。
|
// 端点/后端「关闭 / 非法 / 可用」三种去处都在这里定死(ResolveProvider/ResolveEndpoint)。
|
||||||
func (a *app) webSearchOptions(cause string) []iotools.Option {
|
func (a *app) webSearchOptions(cause string) []iotools.Option {
|
||||||
log := logger.WithSource(a.log, ioLogSource)
|
log := logger.WithSource(a.log, ioLogSource)
|
||||||
ws, source := a.effectiveWebSearch(log)
|
eff, source := a.effectiveWebSearch(log)
|
||||||
|
ws := eff.Scalar
|
||||||
|
|
||||||
// ① 后端显式关闭(主 provider = 空 / off / none)⇒ 整件工具不注册。
|
// ① 后端显式关闭(主 provider = 空 / off / none)⇒ 整件工具不注册。
|
||||||
if decision := ws.ResolveProvider(); decision.Disabled {
|
if decision := ws.ResolveProvider(); decision.Disabled {
|
||||||
@@ -164,7 +150,7 @@ func (a *app) webSearchOptions(cause string) []iotools.Option {
|
|||||||
return []iotools.Option{iotools.WithoutSearch()}
|
return []iotools.Option{iotools.WithoutSearch()}
|
||||||
}
|
}
|
||||||
|
|
||||||
specs := searchProviderSpecs(ws)
|
specs := searchProviderSpecs(ws, eff.Providers)
|
||||||
|
|
||||||
// ② searxng 的可用性 = 端点裁决(WS1-A 的既有语义):端点显式关闭 / 不是 http(s)
|
// ② searxng 的可用性 = 端点裁决(WS1-A 的既有语义):端点显式关闭 / 不是 http(s)
|
||||||
// 绝对地址时**整件工具不注册** —— 那两种取值是「关掉搜索」的显式选择,不是配置
|
// 绝对地址时**整件工具不注册** —— 那两种取值是「关掉搜索」的显式选择,不是配置
|
||||||
@@ -210,7 +196,7 @@ func (a *app) webSearchOptions(cause string) []iotools.Option {
|
|||||||
// - 文件存在但非法 ⇒ 记 WARN、本次回落 `.env`,**不覆盖**原文件。
|
// - 文件存在但非法 ⇒ 记 WARN、本次回落 `.env`,**不覆盖**原文件。
|
||||||
//
|
//
|
||||||
// 返回值里的第二个值是来源标签,只用于日志(`.env` 还是面板),不参与任何裁决。
|
// 返回值里的第二个值是来源标签,只用于日志(`.env` 还是面板),不参与任何裁决。
|
||||||
func (a *app) effectiveWebSearch(log *slog.Logger) (config.WebSearchConfig, string) {
|
func (a *app) effectiveWebSearch(log *slog.Logger) (effectiveWebSearchConfig, string) {
|
||||||
env := a.cfg.WebSearch
|
env := a.cfg.WebSearch
|
||||||
path := runtimeconfig.WebSearchConfigPath(resolveDataDir(a.cfg))
|
path := runtimeconfig.WebSearchConfigPath(resolveDataDir(a.cfg))
|
||||||
f, found, err := runtimeconfig.LoadWebSearch(path)
|
f, found, err := runtimeconfig.LoadWebSearch(path)
|
||||||
@@ -218,21 +204,30 @@ func (a *app) effectiveWebSearch(log *slog.Logger) (config.WebSearchConfig, stri
|
|||||||
case err != nil:
|
case err != nil:
|
||||||
log.Warn("web-search 面板配置非法,本次按 .env 运行(不覆盖原文件)",
|
log.Warn("web-search 面板配置非法,本次按 .env 运行(不覆盖原文件)",
|
||||||
"path", path, "error", err)
|
"path", path, "error", err)
|
||||||
return env, webSearchSourceEnv
|
return envEffectiveWebSearch(env), webSearchSourceEnv
|
||||||
case !found:
|
case !found:
|
||||||
return a.seedWebSearchConfig(log, path, env), webSearchSourceEnv
|
return envEffectiveWebSearch(a.seedWebSearchConfig(log, path, env)), webSearchSourceEnv
|
||||||
}
|
}
|
||||||
warnUnknownSearchProviders(log, path, f)
|
warnUnknownSearchProviders(log, path, f)
|
||||||
eff, convErr := effectiveSearchConfig(env, f)
|
eff, convErr := effectiveSearchConfig(env, f)
|
||||||
if convErr != nil {
|
if convErr != nil {
|
||||||
log.Warn("web-search 面板配置无法生效,本次按 .env 运行",
|
log.Warn("web-search 面板配置无法生效,本次按 .env 运行",
|
||||||
"path", path, "error", convErr)
|
"path", path, "error", convErr)
|
||||||
return env, webSearchSourceEnv
|
return envEffectiveWebSearch(env), webSearchSourceEnv
|
||||||
}
|
}
|
||||||
log.Info("web-search 面板配置生效", "path", path, "providers", len(f.Providers))
|
log.Info("web-search 面板配置生效", "path", path, "providers", len(f.Providers))
|
||||||
return eff, webSearchSourcePanel
|
return eff, webSearchSourcePanel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// envEffectiveWebSearch 是「**没有可用文件**」时的生效配置:逐字沿用 `.env` 播种层的语义
|
||||||
|
// (= `seedWebSearchFile` 会落盘的那份形状)。
|
||||||
|
//
|
||||||
|
// 它是 `.env` 唯一还能决定各家接线的时刻:文件一旦存在,权威就在文件那边(design/09 §5.7 E
|
||||||
|
// 的「先读可变源、缺失回落 `.env` 播种」)。
|
||||||
|
func envEffectiveWebSearch(env config.WebSearchConfig) effectiveWebSearchConfig {
|
||||||
|
return effectiveWebSearchConfig{Scalar: env, Providers: searchProviderVector(env)}
|
||||||
|
}
|
||||||
|
|
||||||
// seedWebSearchConfig 把 `.env` 播种成面板文件(`.env` 只作引导面)。
|
// seedWebSearchConfig 把 `.env` 播种成面板文件(`.env` 只作引导面)。
|
||||||
//
|
//
|
||||||
// 播种失败**不阻断启动**:本次按 `.env` 运行,只记一条 WARN(与 models.json 的处置一致)。
|
// 播种失败**不阻断启动**:本次按 `.env` 运行,只记一条 WARN(与 models.json 的处置一致)。
|
||||||
@@ -251,15 +246,14 @@ func (a *app) seedWebSearchConfig(log *slog.Logger, path string, env config.WebS
|
|||||||
//
|
//
|
||||||
// **本期内置的家全列**(含还没配 key 的):面板的用途就是「一次把各家配齐」,
|
// **本期内置的家全列**(含还没配 key 的):面板的用途就是「一次把各家配齐」,
|
||||||
// 让用户先看到完整清单、再逐条填,比只列已配置的家少一轮「名字得自己知道」。
|
// 让用户先看到完整清单、再逐条填,比只列已配置的家少一轮「名字得自己知道」。
|
||||||
|
//
|
||||||
|
// 「全列」的名单来自 `.env` 的字段表(`envSearchProviders`)—— 那是**播种层**的输入,
|
||||||
|
// 因此这里的条数与被播种的键集逐字保持现状(`.env` 冻结:不加、不改、不删)。
|
||||||
func seedWebSearchFile(ws config.WebSearchConfig) runtimeconfig.WebSearchFile {
|
func seedWebSearchFile(ws config.WebSearchConfig) runtimeconfig.WebSearchFile {
|
||||||
enabled := runtimeconfig.EnableFlag(true)
|
enabled := runtimeconfig.EnableFlag(true)
|
||||||
entries := make([]runtimeconfig.ProviderEntry, 0, len(searchProviderVector(ws))+1)
|
providers := envSearchProviders(ws)
|
||||||
// searxng 的基地址 = `WEB_SEARCH_ENDPOINT`。关闭哨兵(空 / off / none)播成空串:
|
entries := make([]runtimeconfig.ProviderEntry, 0, len(providers))
|
||||||
// 两者语义相同(都表示不出这家),但面板里不该显示一个不是地址的 "off"。
|
for _, p := range providers {
|
||||||
entries = append(entries, runtimeconfig.ProviderEntry{
|
|
||||||
Provider: search.NameSearXNG, BaseURL: seedEndpoint(ws.Endpoint), Enabled: enabled,
|
|
||||||
})
|
|
||||||
for _, p := range searchProviderVector(ws) {
|
|
||||||
entries = append(entries, runtimeconfig.ProviderEntry{
|
entries = append(entries, runtimeconfig.ProviderEntry{
|
||||||
Provider: p.Name, APIKey: p.APIKey, BaseURL: p.BaseURL, Enabled: enabled,
|
Provider: p.Name, APIKey: p.APIKey, BaseURL: p.BaseURL, Enabled: enabled,
|
||||||
})
|
})
|
||||||
@@ -274,19 +268,19 @@ func seedWebSearchFile(ws config.WebSearchConfig) runtimeconfig.WebSearchFile {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// effectiveSearchConfig 把面板文件翻译成工具要的配置。
|
// effectiveSearchConfig 把面板文件翻译成工具要的生效配置。
|
||||||
//
|
//
|
||||||
// **权威边界**(写清楚,免得「谁覆盖谁」靠猜):
|
// **权威边界**(写清楚,免得「谁覆盖谁」靠猜):
|
||||||
//
|
//
|
||||||
// - **结构以文件为准**:主 provider / 降级链 / 各家条目都来自文件 —— 文件里没有的家就是
|
// - **标量以文件为准**:主 provider / 降级链来自文件(文件里写空 / off / none = 显式关闭,
|
||||||
// 没有(绝不从 `.env` 复活),这正是「面板可改」的意思;
|
// 与 `.env` 同一套哨兵语义);maxResults / timeout 在「没写」时沿用 `.env`(那两个标量
|
||||||
// - **凭据以文件为准**:某家的 apiKey 为空 = 这家没配(绝不拿 `.env` 的 key 顶上,
|
// 在「没写」与「写成空」之间无法区分,而它们的默认值本来就是引导面的一部分)。
|
||||||
// 否则「我在面板里删了它」会变成一句假话);
|
// - **各家以文件为准**:文件里没有的家就是没有(绝不从 `.env` 复活,否则「我在面板里
|
||||||
// - **只有「没写」才回落 `.env`(= 代码默认值)**:maxResults / timeout / 云家的 baseUrl。
|
// 删了它」会变成一句假话),凭据为空 = 这家没配。逐家的名字表**不再由 `.env` 的字段表
|
||||||
// 前两个标量在「没写」与「写成空」之间无法区分,而它们的默认值本来就是引导面的一部分;
|
// 决定**,而是来自接入位表 —— 见 effectiveSearchProviders / resolveSearchProviders。
|
||||||
// 云家 baseUrl 回落到厂商默认地址(它是代码默认值,不是凭据),searxng 的地址**不回落**
|
// - **searxng 的端点是开关**:文件里那条的 baseUrl 为空 = 关掉这家,**不回落** `.env`
|
||||||
// —— 在 searxng 身上「地址为空」就是「关掉这家」的开关语义,回落会让"清空地址"变成假话。
|
// (回落会让「清空地址」变成假话)。
|
||||||
func effectiveSearchConfig(env config.WebSearchConfig, f runtimeconfig.WebSearchFile) (config.WebSearchConfig, error) {
|
func effectiveSearchConfig(env config.WebSearchConfig, f runtimeconfig.WebSearchFile) (effectiveWebSearchConfig, error) {
|
||||||
out := env
|
out := env
|
||||||
out.Provider = f.Provider
|
out.Provider = f.Provider
|
||||||
out.Fallback = append([]string(nil), f.Fallback...)
|
out.Fallback = append([]string(nil), f.Fallback...)
|
||||||
@@ -295,45 +289,24 @@ func effectiveSearchConfig(env config.WebSearchConfig, f runtimeconfig.WebSearch
|
|||||||
}
|
}
|
||||||
d, err := f.TimeoutDuration()
|
d, err := f.TimeoutDuration()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return env, err
|
return effectiveWebSearchConfig{}, err
|
||||||
}
|
}
|
||||||
if d > 0 {
|
if d > 0 {
|
||||||
out.Timeout = d
|
out.Timeout = d
|
||||||
}
|
}
|
||||||
// 凭据一律清零后按条目回填:文件没给的家就是没配。
|
// 逐家字段一律清零:它们承载的是 `.env` 的**播种**形状,而生效值现在由 Providers 承载
|
||||||
out.ZhipuAPIKey = ""
|
// (见 effectiveWebSearchConfig 的注释)。清零也顺带保证了任何误用都不会把 `.env` 的
|
||||||
out.AliyunIQSAPIKey = ""
|
// 旧凭据当成「生效凭据」。
|
||||||
out.BochaAPIKey = ""
|
out.ZhipuAPIKey, out.ZhipuBaseURL = "", ""
|
||||||
out.VolcengineAPIKey = ""
|
out.AliyunIQSAPIKey, out.AliyunIQSBaseURL = "", ""
|
||||||
out.BaiduQianfanAPIKey = ""
|
out.BochaAPIKey, out.BochaBaseURL = "", ""
|
||||||
|
out.VolcengineAPIKey, out.VolcengineBaseURL = "", ""
|
||||||
|
out.BaiduQianfanAPIKey, out.BaiduQianfanBaseURL = "", ""
|
||||||
out.Endpoint = ""
|
out.Endpoint = ""
|
||||||
for _, e := range f.Providers {
|
if e, ok := findProviderEntry(f, search.NameSearXNG); ok && e.IsEnabled() {
|
||||||
if !e.IsEnabled() {
|
out.Endpoint = strings.TrimSpace(e.BaseURL)
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
name := strings.ToLower(strings.TrimSpace(e.Provider))
|
return effectiveWebSearchConfig{Scalar: out, Providers: effectiveSearchProviders(f)}, nil
|
||||||
base := strings.TrimSpace(e.BaseURL)
|
|
||||||
switch name {
|
|
||||||
case search.NameSearXNG:
|
|
||||||
out.Endpoint = base
|
|
||||||
case search.NameZhipu:
|
|
||||||
out.ZhipuAPIKey = e.APIKey
|
|
||||||
out.ZhipuBaseURL = orDefault(base, out.ZhipuBaseURL)
|
|
||||||
case search.NameAliyunIQS:
|
|
||||||
out.AliyunIQSAPIKey = e.APIKey
|
|
||||||
out.AliyunIQSBaseURL = orDefault(base, out.AliyunIQSBaseURL)
|
|
||||||
case search.NameBocha:
|
|
||||||
out.BochaAPIKey = e.APIKey
|
|
||||||
out.BochaBaseURL = orDefault(base, out.BochaBaseURL)
|
|
||||||
case search.NameVolcengine:
|
|
||||||
out.VolcengineAPIKey = e.APIKey
|
|
||||||
out.VolcengineBaseURL = orDefault(base, out.VolcengineBaseURL)
|
|
||||||
case search.NameBaiduQianfan:
|
|
||||||
out.BaiduQianfanAPIKey = e.APIKey
|
|
||||||
out.BaiduQianfanBaseURL = orDefault(base, out.BaiduQianfanBaseURL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// warnUnknownSearchProviders 对文件里本期没登记的家记一条 WARN。
|
// warnUnknownSearchProviders 对文件里本期没登记的家记一条 WARN。
|
||||||
@@ -356,70 +329,9 @@ func selectsSearXNG(ws config.WebSearchConfig) bool {
|
|||||||
return strings.EqualFold(strings.TrimSpace(ws.Provider), search.NameSearXNG)
|
return strings.EqualFold(strings.TrimSpace(ws.Provider), search.NameSearXNG)
|
||||||
}
|
}
|
||||||
|
|
||||||
// searchProviderWire 是「一家云 provider 的配置投影」:名字 + 凭据 + 基地址。
|
// 各家的配置投影(searchProviderWire / searchProviderVector / searchProviderSpecs /
|
||||||
// searxng 不在这张表里(它的「基地址」就是 `WEB_SEARCH_ENDPOINT`,由 ResolveEndpoint 裁决)。
|
// availableProviderNames)在 `websearch_providers.go` —— 那一片是本片「去 env 化」的本体
|
||||||
type searchProviderWire struct {
|
// (按**接入位表**逐家投影,而不是按 `.env` 的字段表)。
|
||||||
Name string
|
|
||||||
APIKey string
|
|
||||||
BaseURL string
|
|
||||||
}
|
|
||||||
|
|
||||||
// searchProviderVector 是本期内置各家的配置投影(**一家一行**)。
|
|
||||||
//
|
|
||||||
// 它同时服务两处:装配接线表(searchProviderSpecs)与 `.env` 播种(seedWebSearchFile)——
|
|
||||||
// 一家一处,新增一家 = 这里加一行 + search 包加一个文件。
|
|
||||||
func searchProviderVector(ws config.WebSearchConfig) []searchProviderWire {
|
|
||||||
return []searchProviderWire{
|
|
||||||
{Name: search.NameZhipu, APIKey: ws.ZhipuAPIKey, BaseURL: ws.ZhipuBaseURL},
|
|
||||||
{Name: search.NameAliyunIQS, APIKey: ws.AliyunIQSAPIKey, BaseURL: ws.AliyunIQSBaseURL},
|
|
||||||
{Name: search.NameBocha, APIKey: ws.BochaAPIKey, BaseURL: ws.BochaBaseURL},
|
|
||||||
{Name: search.NameVolcengine, APIKey: ws.VolcengineAPIKey, BaseURL: ws.VolcengineBaseURL},
|
|
||||||
{Name: search.NameBaiduQianfan, APIKey: ws.BaiduQianfanAPIKey, BaseURL: ws.BaiduQianfanBaseURL},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// searchProviderSpecs 把生效配置翻译成各家接线。
|
|
||||||
//
|
|
||||||
// Configured 的口径由 search.Configured 统一判定:searxng 看端点、云家看 API key;
|
|
||||||
// false ⇒ 该家不进注册表、不出现在可用清单里。**密钥只在这里流转,绝不写日志。**
|
|
||||||
func searchProviderSpecs(ws config.WebSearchConfig) []iotools.SearchProviderSpec {
|
|
||||||
endpoint := ws.ResolveEndpoint()
|
|
||||||
specs := []iotools.SearchProviderSpec{{
|
|
||||||
Name: search.NameSearXNG,
|
|
||||||
BaseURL: endpoint.URL,
|
|
||||||
Configured: endpoint.Enabled,
|
|
||||||
}}
|
|
||||||
// 各家独立一组键(design/27 §3):新增一家 = searchProviderVector 加一行 + 一个文件。
|
|
||||||
for _, p := range searchProviderVector(ws) {
|
|
||||||
specs = append(specs, iotools.SearchProviderSpec{
|
|
||||||
Name: p.Name,
|
|
||||||
APIKey: p.APIKey,
|
|
||||||
BaseURL: p.BaseURL,
|
|
||||||
Configured: search.Configured(p.Name, p.APIKey, p.BaseURL),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return specs
|
|
||||||
}
|
|
||||||
|
|
||||||
// availableProviderNames 返回已配置的家(即「可用清单」),供启动日志如实展示。
|
|
||||||
// **不含任何密钥** —— 只回名字。
|
|
||||||
func availableProviderNames(specs []iotools.SearchProviderSpec) []string {
|
|
||||||
out := make([]string, 0, len(specs))
|
|
||||||
for _, s := range specs {
|
|
||||||
if s.Configured {
|
|
||||||
out = append(out, s.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// seedEndpoint 把 `WEB_SEARCH_ENDPOINT` 的取值播进 searxng 条目:显式关闭的哨兵落成空串。
|
|
||||||
func seedEndpoint(endpoint string) string {
|
|
||||||
if config.IsWebSearchOffSentinel(endpoint) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(endpoint)
|
|
||||||
}
|
|
||||||
|
|
||||||
// seedTimeout 把超时播成 duration 字符串:未声明 / 笔误(≤0)落成空 = 未声明。
|
// seedTimeout 把超时播成 duration 字符串:未声明 / 笔误(≤0)落成空 = 未声明。
|
||||||
func seedTimeout(d time.Duration) string {
|
func seedTimeout(d time.Duration) string {
|
||||||
@@ -428,12 +340,3 @@ func seedTimeout(d time.Duration) string {
|
|||||||
}
|
}
|
||||||
return d.String()
|
return d.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// orDefault 在 value 去空白后非空时用它,否则用 fallback(云家 baseUrl 的「没写就回落
|
|
||||||
// 代码默认值」口径,见 effectiveSearchConfig 的权威边界)。
|
|
||||||
func orDefault(value, fallback string) string {
|
|
||||||
if s := strings.TrimSpace(value); s != "" {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -1,21 +1,32 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// errT1ModelNotReady 在 T1 需要模型而模型栈尚未就绪时返回。
|
||||||
|
//
|
||||||
|
// 它是 **fail-safe** 信号,不是故障:审查据此回落 `create` + WARN,绝不阻塞、
|
||||||
|
// 绝不丢记忆(design/23 §15 D3-5)。之所以可能存在这一刻:本服务在
|
||||||
|
// `wireMemory` 阶段装配,模型栈在更晚的 `wireRuntime` 阶段才建好。
|
||||||
|
var errT1ModelNotReady = errors.New("memory review: t1 judge: model adapter not ready")
|
||||||
|
|
||||||
// 本文件把 D3「入库前审查」(DevDocs/design/23-记忆整理与去重 §15.1 D3)接进
|
// 本文件把 D3「入库前审查」(DevDocs/design/23-记忆整理与去重 §15.1 D3)接进
|
||||||
// 组合根。三件事只在这里定:
|
// 组合根。三件事只在这里定:
|
||||||
//
|
//
|
||||||
// - **形态**:记忆管理会话(复用既有的 sess-memorytidy 装配,见
|
// - **形态**:记忆管理会话(复用既有的 sess-memorytidy 装配,见
|
||||||
// memory.TidySessionID)的有界后台队列,不是会话内派 subagent。它与整理
|
// memory.TidySessionID)的有界后台队列,不是会话内派 subagent。它与整理
|
||||||
// 心跳并列启动、在 close 里先于关库停止;
|
// 心跳并列启动、在 close 里先于关库停止;
|
||||||
// - **依赖面**:只给它记忆存储与 embedder —— 服务侧拿不到任何会话/历史读数,
|
// - **依赖面**:只给它记忆存储、embedder,以及 **T1 的一次性模型调用口**
|
||||||
|
// (memoryT1Judge,§15.1 D3-2 的灰区判定)。服务侧拿不到任何会话/历史读数,
|
||||||
// 因此「独立会话、零上下文、不碰主会话上下文」(§15 D3)是结构性成立的,
|
// 因此「独立会话、零上下文、不碰主会话上下文」(§15 D3)是结构性成立的,
|
||||||
// 而不是靠自觉;
|
// 而不是靠自觉;
|
||||||
// - **审计**:接到网关的 AuditEvent(口径 memory.review.ingest)。出口在调用
|
// - **审计**:接到网关的 AuditEvent(口径 memory.review.ingest)。出口在调用
|
||||||
@@ -23,7 +34,17 @@ import (
|
|||||||
// 一手法。未就绪时只记日志,绝不假装写过审计。
|
// 一手法。未就绪时只记日志,绝不假装写过审计。
|
||||||
//
|
//
|
||||||
// 关闭(MEMORY_REVIEW_ENABLED=false)时**不建、不接**:store 的写入路径因此
|
// 关闭(MEMORY_REVIEW_ENABLED=false)时**不建、不接**:store 的写入路径因此
|
||||||
// 逐字回到本次改动之前(纯增量,无破坏性变更)。
|
// 逐字回到本次改动之前(纯增量、无破坏性变更)。
|
||||||
|
//
|
||||||
|
// 🔴 两个开关都不在 env 里(`.env` 已冻结,design/13 §2.1),它们读
|
||||||
|
// `$DATA_DIR/config/memory-tidy.json`,与整理自动档同一个文件、**零新 env 键**:
|
||||||
|
// - `{"ingestGate": false}` ⇒ 一键回到「只插不审」(同步入库闸);
|
||||||
|
// - `{"t1Judge": false}` ⇒ 一键回到纯规则(T1 灰区判定,**零模型调用**);
|
||||||
|
// - `{"t1PerWindow": 120, "t1WindowSec": 3600}` ⇒ T1 的**全局限速**额度
|
||||||
|
// (design/23 §15.4);缺位 = 代码默认 120 次/60 分钟;
|
||||||
|
// - `{"tempTtlSec": 604800}` ⇒ 第五态 `defer` 的**临时记忆存活秒数**
|
||||||
|
// (design/03 §9.6);缺位/坏值 = 代码默认 7 天。**它没有 `.env` 档**
|
||||||
|
// (`.env` 已冻结,本键在那里从来没有出处)⇒ 实际是「面板 > 代码默认」。
|
||||||
func (a *app) wireMemoryReview() error {
|
func (a *app) wireMemoryReview() error {
|
||||||
cfg, err := config.LoadMemoryReviewConfig(envPath)
|
cfg, err := config.LoadMemoryReviewConfig(envPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -34,6 +55,19 @@ func (a *app) wireMemoryReview() error {
|
|||||||
"memory ingest review disabled by configuration", "env", "MEMORY_REVIEW_ENABLED")
|
"memory ingest review disabled by configuration", "env", "MEMORY_REVIEW_ENABLED")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
gateOff := a.memoryIngestGateOff()
|
||||||
|
// T1 灰区模型判定(design/23 §15.1 D3-2):代码默认**开**,`{"t1Judge": false}`
|
||||||
|
// 一键回到纯规则(零模型调用)。与入库闸同一个文件、同一套「没配 = 保持默认」
|
||||||
|
// 口径,**零新 env 键**。
|
||||||
|
t1Off := a.memoryT1JudgeOff()
|
||||||
|
// T1 的全局限速额度(design/23 §15.4):同样读那个文件,缺位/坏文件一律
|
||||||
|
// 交零值给 normalizeReviewConfig 回落**代码默认值**(120 次/60 分钟)——
|
||||||
|
// 坏配置绝不能让一处限速静默变成「无上限」。
|
||||||
|
t1PerWindow, t1WindowSec := a.memoryT1RateLimit()
|
||||||
|
// 临时记忆 TTL(design/03 §9.6):面板 `tempTtlSec` 优先,缺位/坏值回落代码
|
||||||
|
// 默认 **7 天**(经 normalizeReviewConfig 落地)。**中间那一档(`.env`)在本键
|
||||||
|
// 上结构性为空** —— `.env` 已冻结(design/13 §2.1),这个键在那里从来没有出处。
|
||||||
|
tempTTL := a.memoryTempTTL()
|
||||||
reviewer, err := memory.NewPreInsertReviewer(memory.ReviewConfig{
|
reviewer, err := memory.NewPreInsertReviewer(memory.ReviewConfig{
|
||||||
Enabled: cfg.Enabled,
|
Enabled: cfg.Enabled,
|
||||||
QueueSize: cfg.QueueSize,
|
QueueSize: cfg.QueueSize,
|
||||||
@@ -42,11 +76,28 @@ func (a *app) wireMemoryReview() error {
|
|||||||
// 召回门复用记忆整理的既有阈值(design/23 §13 开放点 2 = 0.80):
|
// 召回门复用记忆整理的既有阈值(design/23 §13 开放点 2 = 0.80):
|
||||||
// 入库前审查与夜间整理必须用同一把尺子,否则同一对条目会有两个结论。
|
// 入库前审查与夜间整理必须用同一把尺子,否则同一对条目会有两个结论。
|
||||||
Threshold: a.cfg.MemoryTidy.RecallThreshold,
|
Threshold: a.cfg.MemoryTidy.RecallThreshold,
|
||||||
|
// 同步入库闸:`$DATA_DIR/config/memory-tidy.json` 的 `ingestGate: false`
|
||||||
|
// 一键回到「只插不审」(= 本次改动之前的异步行为)。预算与扫描上限走
|
||||||
|
// 代码默认值(闸的总墙钟预算必须有界,但它是**实现参数**不是运维旋钮)。
|
||||||
|
DisableIngestGate: gateOff,
|
||||||
|
// T1 的时长走代码默认值(8s,且被单条审查的 Timeout 收紧取小):
|
||||||
|
// 它是**实现参数**不是运维旋钮 —— 要让 T1 完全不花钱,就关掉它。
|
||||||
|
DisableT1: t1Off,
|
||||||
|
// T1 的**全局限速**额度(design/23 §15.4):数值来自
|
||||||
|
// `$DATA_DIR/config/memory-tidy.json` 的 `t1PerWindow` / `t1WindowSec`,
|
||||||
|
// 缺位或坏配置时是零值 ⇒ 由 normalizeReviewConfig 回落代码默认
|
||||||
|
// (120 次/60 分钟)。**没有「无上限」这个状态**:要零花费请关 t1Judge。
|
||||||
|
T1PerWindow: t1PerWindow,
|
||||||
|
T1Window: time.Duration(t1WindowSec) * time.Second,
|
||||||
|
// 第五态 `defer` 的落点(design/23 §18.1):`expires_at` = 判定时刻 + 它。
|
||||||
|
// 零值 = 没配 ⇒ 由 normalizeReviewConfig 回落代码默认 7 天(tempTtlSec)。
|
||||||
|
TempTTL: tempTTL,
|
||||||
}, memory.ReviewDeps{
|
}, memory.ReviewDeps{
|
||||||
Store: a.memStore,
|
Store: a.memStore,
|
||||||
Embedder: a.embedder,
|
Embedder: a.embedder,
|
||||||
Audit: a.auditMemoryReview,
|
Audit: a.auditMemoryReview,
|
||||||
Log: a.log,
|
Log: a.log,
|
||||||
|
Judge: a.memoryT1Judge(),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("memory ingest review: %w", err)
|
return fmt.Errorf("memory ingest review: %w", err)
|
||||||
@@ -56,6 +107,112 @@ func (a *app) wireMemoryReview() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// memoryIngestGateOff 报告同步入库闸是否被配置显式关掉。读不到/解不开
|
||||||
|
// `memory-tidy.json` 时返回 false(= 保持代码默认的「开」)并记 WARN ——
|
||||||
|
// 坏配置绝不能让一处防护静默失效,也绝不能让主人以为它关了却其实开着。
|
||||||
|
func (a *app) memoryIngestGateOff() bool {
|
||||||
|
tuning, path, err := a.readMemoryTidyTuning()
|
||||||
|
if err != nil {
|
||||||
|
logger.WithSource(a.log, "memory/review").Warn(
|
||||||
|
"memory ingest gate tuning unreadable, falling back to code default (enabled)",
|
||||||
|
"path", path, "error", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return tuning.IngestGateOff()
|
||||||
|
}
|
||||||
|
|
||||||
|
// memoryT1JudgeOff 报告 **T1 灰区模型判定**是否被配置显式关掉。口径与
|
||||||
|
// memoryIngestGateOff 逐字相同:读不到/解不开 `memory-tidy.json` 时返回 false
|
||||||
|
// (= 保持代码默认的「开」)并记 WARN —— 坏配置绝不能让一处防护静默失效,
|
||||||
|
// 也绝不能让主人以为它关了却其实开着。
|
||||||
|
func (a *app) memoryT1JudgeOff() bool {
|
||||||
|
tuning, path, err := a.readMemoryTidyTuning()
|
||||||
|
if err != nil {
|
||||||
|
logger.WithSource(a.log, "memory/review").Warn(
|
||||||
|
"memory t1 judge tuning unreadable, falling back to code default (enabled)",
|
||||||
|
"path", path, "error", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return tuning.T1JudgeOff()
|
||||||
|
}
|
||||||
|
|
||||||
|
// memoryT1RateLimit 读 **T1 全局限速**的两项旋钮:窗口额度(次)与窗口长度(秒)。
|
||||||
|
//
|
||||||
|
// 口径与 memoryIngestGateOff / memoryT1JudgeOff 逐字相同:读不到/解不开
|
||||||
|
// `memory-tidy.json` 时返回零值(= 不覆盖)并记 WARN —— 零值经
|
||||||
|
// normalizeReviewConfig 回落**代码默认值**(120 次/60 分钟),因此坏配置既不会
|
||||||
|
// 让限速静默变成「无上限」,也不会让 T1 静默失效。
|
||||||
|
//
|
||||||
|
// 只有**两项都写了且为正**才报一条 Info(自解释的配置回执);只写了一项时静默
|
||||||
|
// 回落缺的那一项,不报「已配置」以免报出一个与生效值不符的印象。
|
||||||
|
func (a *app) memoryT1RateLimit() (perWindow int, windowSec int) {
|
||||||
|
tuning, path, err := a.readMemoryTidyTuning()
|
||||||
|
if err != nil {
|
||||||
|
logger.WithSource(a.log, "memory/review").Warn(
|
||||||
|
"memory t1 rate limit tuning unreadable, falling back to code defaults",
|
||||||
|
"path", path, "error", err)
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
if tuning.T1PerWindow > 0 && tuning.T1WindowSec > 0 {
|
||||||
|
logger.WithSource(a.log, "memory/review").Info(
|
||||||
|
"memory t1 rate limit loaded",
|
||||||
|
"path", path, "t1_per_window", tuning.T1PerWindow, "t1_window_sec", tuning.T1WindowSec)
|
||||||
|
}
|
||||||
|
return tuning.T1PerWindow, tuning.T1WindowSec
|
||||||
|
}
|
||||||
|
|
||||||
|
// memoryTempTTL 返回**临时记忆 TTL**(design/03 §9.6 的 `tempTtlSec`):面板
|
||||||
|
// `$DATA_DIR/config/memory-tidy.json` 的值优先,缺位 / 坏值一律回落**代码默认
|
||||||
|
// 7 天**(返回零值即「不覆盖」,由 NewPreInsertReviewer 的 normalizeReviewConfig
|
||||||
|
// 落地 —— 与 memoryT1RateLimit 返回零值的口径逐字相同)。
|
||||||
|
//
|
||||||
|
// 解析与坏值 WARN 都交给 `memory.ApplyReviewTuning`(与 tidy_tuning.go 对
|
||||||
|
// TidyConfig 是同一手法):runtimeconfig 只管**文件形状**,而「缺位 / 坏值怎么
|
||||||
|
// 回落」属于 ReviewConfig 的归一化语义 —— 那里是**唯一**实现点,组合根不再抄一遍
|
||||||
|
// (抄一遍就意味着两条路径各自决定「什么是坏值」)。
|
||||||
|
//
|
||||||
|
// 读不到 / 解不开那个文件时返回零值 + WARN:坏配置绝不能让 TTL 静默变成
|
||||||
|
// 「不过期」,也不能让每条临时记忆**落库即过期**(一个 ≤0 的 TTL 正是那个形态)。
|
||||||
|
//
|
||||||
|
// 🔴 中间那一档(`.env`)在本键上**结构性为空**:`.env` 已冻结
|
||||||
|
// (design/13 §2.1「不加、不改、不删」),而这个键在那里**从来没有出处**。
|
||||||
|
// 因此本键的实际生效链是「**面板 > 代码默认**」——不是漏配,设计原文
|
||||||
|
// (design/03 §9.6)写的就是这一档。
|
||||||
|
func (a *app) memoryTempTTL() time.Duration {
|
||||||
|
tuning, path, err := a.readMemoryTidyTuning()
|
||||||
|
log := logger.WithSource(a.log, "memory/review")
|
||||||
|
if err != nil {
|
||||||
|
log.Warn("memory temp ttl tuning unreadable, falling back to code default (7 days)",
|
||||||
|
"path", path, "error", err)
|
||||||
|
}
|
||||||
|
return memory.ApplyReviewTuning(memory.ReviewConfig{}, tuning, log).TempTTL
|
||||||
|
}
|
||||||
|
|
||||||
|
// memoryT1Judge 把「调用时才解析的模型适配器」包成 T1 的一次性调用口。
|
||||||
|
//
|
||||||
|
// 为什么是闭包而不是直接塞 `a.modelAdapter`:组合根的顺序是 **wireMemory(建
|
||||||
|
// 记忆栈,含本服务)→ wireRuntime(建模型栈)**,装配这一刻 `a.modelAdapter`
|
||||||
|
// 还是 nil。闭包把解析推迟到调用时,于是既保住了「记忆模块只拿到一个调用口、
|
||||||
|
// 拿不到模型栈」这条依赖面(§15 D3:独立会话、零上下文),又不必改动既有装配
|
||||||
|
// 顺序 —— 与 a.gw 的晚组装是同一手法(见本文件顶部)。
|
||||||
|
//
|
||||||
|
// 用的是**既有**的模型栈本身(含按角色路由与主→备→本地降级),不新造 LLM
|
||||||
|
// 客户端;`PersonaID` 留空即「无角色绑定」,由生效组的默认链服务它。模型栈仍
|
||||||
|
// 未就绪时返回 errT1ModelNotReady:那是 fail-safe(回落 create + WARN)。
|
||||||
|
//
|
||||||
|
// 读 `a.modelAdapter` 无锁是安全的:它在 wireRuntime 里被**写一次**,而任何
|
||||||
|
// 读到它的路径(审查工作协程)都在 main 里 `memoryReview.Start` 之后才开始,
|
||||||
|
// 写入严格先于读取。
|
||||||
|
func (a *app) memoryT1Judge() memory.T1Judge {
|
||||||
|
return func(ctx context.Context, req model.Request, opts model.Options) (model.Response, error) {
|
||||||
|
adapter := a.modelAdapter
|
||||||
|
if adapter == nil {
|
||||||
|
return model.Response{}, errT1ModelNotReady
|
||||||
|
}
|
||||||
|
return adapter.Chat(ctx, req, opts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// auditMemoryReview 把一条审查结论写进网关审计链。它**只带决定、依据与 id**,
|
// auditMemoryReview 把一条审查结论写进网关审计链。它**只带决定、依据与 id**,
|
||||||
// 绝不带任何记忆正文(design §15 D3-8「记忆正文不进日志」)。
|
// 绝不带任何记忆正文(design §15 D3-8「记忆正文不进日志」)。
|
||||||
func (a *app) auditMemoryReview(who, what, result string) {
|
func (a *app) auditMemoryReview(who, what, result string) {
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
"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/memory"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 本文件锁定 D3「入库前审查」(design/23 §15.1 D3)在**组合根**上的三件事:
|
// 本文件锁定 D3「入库前审查」(design/23 §15.1 D3)在**组合根**上的三件事:
|
||||||
@@ -54,6 +60,27 @@ func TestMemoryReviewIsWired(t *testing.T) {
|
|||||||
"Audit: a.auditMemoryReview",
|
"Audit: a.auditMemoryReview",
|
||||||
// 接到 store 的唯一写入路径上。
|
// 接到 store 的唯一写入路径上。
|
||||||
"a.memStore.AttachIngestReviewer(reviewer)",
|
"a.memStore.AttachIngestReviewer(reviewer)",
|
||||||
|
// 同步入库闸(2026-10-07)的开关来自 `$DATA_DIR/config/memory-tidy.json`
|
||||||
|
// 的 `{"ingestGate": false}`(**零新 env 键**,`.env` 已冻结)。
|
||||||
|
"a.memoryIngestGateOff()",
|
||||||
|
"DisableIngestGate: gateOff",
|
||||||
|
// T1 灰区模型判定(design/23 §15.1 D3-2):开关同样来自那个文件
|
||||||
|
// (`{"t1Judge": false}`),模型出口**在调用时才解析**(本服务早于
|
||||||
|
// 模型栈装配,与 a.gw 的晚组装同一手法)。
|
||||||
|
"a.memoryT1JudgeOff()",
|
||||||
|
"DisableT1: t1Off",
|
||||||
|
"Judge: a.memoryT1Judge()",
|
||||||
|
// T1 的**全局限速**额度(design/23 §15.4)同样来自那个文件,
|
||||||
|
// 缺位/坏文件一律回落代码默认(120 次/60 分钟)。
|
||||||
|
"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": {
|
"main.go": {
|
||||||
"a.memoryReview.Start(ctx)",
|
"a.memoryReview.Start(ctx)",
|
||||||
@@ -75,15 +102,193 @@ func TestMemoryReviewIsWired(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// store 侧的唯一写入路径必须投递审查(否则新记忆永远不进队列)。
|
// store 侧的唯一写入路径必须投递审查(否则新记忆永远不进队列),
|
||||||
|
// 且必须**先过同步入库闸**(2026-10-07:闸就摆在写路径上,重复当场落 merged)。
|
||||||
storeSrc, err := os.ReadFile("../../internal/runtime/memory/store.go")
|
storeSrc, err := os.ReadFile("../../internal/runtime/memory/store.go")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read memory/store.go: %v", err)
|
t.Fatalf("read memory/store.go: %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(string(storeSrc), "s.enqueueReview(item)") {
|
for _, want := range []string{"s.enqueueReview(item)", "s.planIngest(ctx, item)", "s.finishIngest(ctx, item, plan)"} {
|
||||||
t.Fatal("memory/store.go 的写入路径上没有投递审查(写路径唯一)")
|
if !strings.Contains(string(storeSrc), want) {
|
||||||
|
t.Fatalf("memory/store.go 的写入路径上找不到 %q(唯一写路径上的闸/投递断了)", want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMemoryIngestGate_ReadsDataDirConfig 钉住**同步入库闸**的开关确实来自
|
||||||
|
// `$DATA_DIR/config/memory-tidy.json`(它没有 env 键:`.env` 已冻结)——
|
||||||
|
// 若组合根没读那个文件,「一键回到只插不审」就是一句空话。
|
||||||
|
//
|
||||||
|
// 口径与自动档同一条:没配 = 开;显式 false = 关;坏文件 = 回落代码默认(开)
|
||||||
|
// 并记 WARN,**绝不静默把一处防护关掉**。
|
||||||
|
func TestMemoryIngestGate_ReadsDataDirConfig(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
empty := &app{cfg: &config.Config{DataDir: t.TempDir()}, log: tidyTestLog()}
|
||||||
|
if empty.memoryIngestGateOff() {
|
||||||
|
t.Fatal("没配过时入库闸应当保持代码默认(开)")
|
||||||
|
}
|
||||||
|
|
||||||
|
dataDir := t.TempDir()
|
||||||
|
dir := filepath.Join(dataDir, "config")
|
||||||
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||||
|
t.Fatalf("mkdir: %v", err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, "memory-tidy.json")
|
||||||
|
if err := os.WriteFile(path, []byte(`{"ingestGate": false}`), 0o600); err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
off := &app{cfg: &config.Config{DataDir: dataDir}, log: tidyTestLog()}
|
||||||
|
if !off.memoryIngestGateOff() {
|
||||||
|
t.Fatal("ingestGate: false 没被读到(闸没有一键关掉的路径)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(path, []byte(`{"ingestGate": "nope"}`), 0o600); err != nil {
|
||||||
|
t.Fatalf("write broken: %v", err)
|
||||||
|
}
|
||||||
|
broken := &app{cfg: &config.Config{DataDir: dataDir}, log: tidyTestLog()}
|
||||||
|
if broken.memoryIngestGateOff() {
|
||||||
|
t.Fatal("坏配置把入库闸静默关掉了:必须回落代码默认(开)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMemoryT1Judge_ReadsDataDirConfig 钉住 **T1 灰区模型判定**的开关确实来自
|
||||||
|
// `$DATA_DIR/config/memory-tidy.json` 的 `t1Judge`(它没有 env 键:`.env` 已冻结)
|
||||||
|
// —— T1 是一处**会花掉模型额度**的动作,「一行配置关掉」必须真的成立。
|
||||||
|
//
|
||||||
|
// 口径与入库闸同一条:没配 = 开(主人 2026-10-07 要的就是「真的走记忆管理会话」);
|
||||||
|
// 显式 false = 关(回落纯规则、零模型调用);坏文件 = 回落代码默认(开)并记
|
||||||
|
// WARN,**绝不静默把一处防护关掉**。
|
||||||
|
func TestMemoryT1Judge_ReadsDataDirConfig(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
empty := &app{cfg: &config.Config{DataDir: t.TempDir()}, log: tidyTestLog()}
|
||||||
|
if empty.memoryT1JudgeOff() {
|
||||||
|
t.Fatal("没配过时 T1 应当保持代码默认(开)")
|
||||||
|
}
|
||||||
|
|
||||||
|
dataDir := t.TempDir()
|
||||||
|
dir := filepath.Join(dataDir, "config")
|
||||||
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||||
|
t.Fatalf("mkdir: %v", err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, "memory-tidy.json")
|
||||||
|
// 只写一个上限键:它不许把 T1 静默关掉(指针字段的存在理由)。
|
||||||
|
if err := os.WriteFile(path, []byte(`{"autoApplyPerRound": 5}`), 0o600); err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
partial := &app{cfg: &config.Config{DataDir: dataDir}, log: tidyTestLog()}
|
||||||
|
if partial.memoryT1JudgeOff() {
|
||||||
|
t.Fatal("只写了别的键时 T1 被静默关掉了(必须保持代码默认的「开」)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(path, []byte(`{"t1Judge": false}`), 0o600); err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
off := &app{cfg: &config.Config{DataDir: dataDir}, log: tidyTestLog()}
|
||||||
|
if !off.memoryT1JudgeOff() {
|
||||||
|
t.Fatal("t1Judge: false 没被读到(T1 没有一键关掉的路径)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(path, []byte(`{"t1Judge": "nope"}`), 0o600); err != nil {
|
||||||
|
t.Fatalf("write broken: %v", err)
|
||||||
|
}
|
||||||
|
broken := &app{cfg: &config.Config{DataDir: dataDir}, log: tidyTestLog()}
|
||||||
|
if broken.memoryT1JudgeOff() {
|
||||||
|
t.Fatal("坏配置把 T1 静默关掉了:必须回落代码默认(开)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMemoryT1RateLimit_ReadsDataDirConfig 钉住 **T1 全局限速**的额度确实来自
|
||||||
|
// `$DATA_DIR/config/memory-tidy.json` 的 `t1PerWindow` / `t1WindowSec`
|
||||||
|
// (design/23 §15.4):T1 默认开,每次有灰区候选的审查就发一次调用 ⇒ 额度必须能
|
||||||
|
// 按现网节奏调,而 `.env` 已冻结、不许为它开 env 键。
|
||||||
|
//
|
||||||
|
// 口径与入库闸/T1 开关同一条:没配 = 返回零值(交给 normalizeReviewConfig 回落
|
||||||
|
// 代码默认 120 次/60 分钟);坏文件 = 同样回落默认额 **并记 WARN** ——
|
||||||
|
// 坏配置绝不能让限速静默变成「无上限」。
|
||||||
|
func TestMemoryT1RateLimit_ReadsDataDirConfig(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
empty := &app{cfg: &config.Config{DataDir: t.TempDir()}, log: tidyTestLog()}
|
||||||
|
if perWindow, windowSec := empty.memoryT1RateLimit(); perWindow != 0 || windowSec != 0 {
|
||||||
|
t.Fatalf("没配过时应当返回零值(= 回落代码默认):%d/%d", perWindow, windowSec)
|
||||||
|
}
|
||||||
|
|
||||||
|
dataDir := t.TempDir()
|
||||||
|
dir := filepath.Join(dataDir, "config")
|
||||||
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||||
|
t.Fatalf("mkdir: %v", err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, "memory-tidy.json")
|
||||||
|
if err := os.WriteFile(path, []byte(`{"t1PerWindow": 240, "t1WindowSec": 1800}`), 0o600); err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
set := &app{cfg: &config.Config{DataDir: dataDir}, log: tidyTestLog()}
|
||||||
|
if perWindow, windowSec := set.memoryT1RateLimit(); perWindow != 240 || windowSec != 1800 {
|
||||||
|
t.Fatalf("额度没被读到:%d/%d, want 240/1800", perWindow, windowSec)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 坏配置:回落默认额(零值)+ 一条 WARN,绝不静默不限流。
|
||||||
|
if err := os.WriteFile(path, []byte(`{"t1PerWindow": "many"}`), 0o600); err != nil {
|
||||||
|
t.Fatalf("write broken: %v", err)
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
broken := &app{cfg: &config.Config{DataDir: dataDir},
|
||||||
|
log: slog.New(slog.NewTextHandler(&buf, nil))}
|
||||||
|
if perWindow, windowSec := broken.memoryT1RateLimit(); perWindow != 0 || windowSec != 0 {
|
||||||
|
t.Fatalf("坏配置下应当回落零值:%d/%d", perWindow, windowSec)
|
||||||
|
}
|
||||||
|
if logged := buf.String(); !strings.Contains(logged, "t1 rate limit tuning unreadable") ||
|
||||||
|
!strings.Contains(logged, "level=WARN") {
|
||||||
|
t.Fatalf("坏配置没有留下 WARN:%q", logged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMemoryT1Judge_UsesTheWiredModelStack 钉住 T1 的模型出口**复用既有模型栈**
|
||||||
|
// (不新造 LLM 客户端),且返回一个闭包而不是当场取值 —— 本服务在 wireMemory
|
||||||
|
// 阶段装配,而 a.modelAdapter 要等更晚的 wireRuntime 才建好。
|
||||||
|
func TestMemoryT1Judge_UsesTheWiredModelStack(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
a := &app{log: tidyTestLog()}
|
||||||
|
judge := a.memoryT1Judge()
|
||||||
|
if judge == nil {
|
||||||
|
t.Fatal("memoryT1Judge 返回了 nil")
|
||||||
|
}
|
||||||
|
// 模型栈还没建好:必须 fail-safe(返回错误),绝不 panic、绝不假装成功。
|
||||||
|
if _, err := judge(context.Background(), model.Request{}, model.Options{}); !errors.Is(err, errT1ModelNotReady) {
|
||||||
|
t.Fatalf("模型栈未就绪时 err = %v, want %v", err, errT1ModelNotReady)
|
||||||
|
}
|
||||||
|
// 接上模型栈后,同一个闭包就能发请求(晚解析的证据)。
|
||||||
|
stub := &reviewAdapterStub{reply: "{}"}
|
||||||
|
a.modelAdapter = stub
|
||||||
|
if _, err := judge(context.Background(), model.Request{Purpose: "memory_t1_judge"}, model.Options{}); err != nil {
|
||||||
|
t.Fatalf("接上模型栈后调用失败:%v", err)
|
||||||
|
}
|
||||||
|
if stub.calls != 1 {
|
||||||
|
t.Fatalf("模型栈被调用 %d 次, want 1", stub.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reviewAdapterStub 是最小的 model.Adapter 桩(只为验「闭包解析到既有的那个
|
||||||
|
// 模型栈」这件事,不参与判定正确性)。
|
||||||
|
type reviewAdapterStub struct {
|
||||||
|
calls int
|
||||||
|
reply string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *reviewAdapterStub) Name() string { return "stub" }
|
||||||
|
|
||||||
|
func (s *reviewAdapterStub) Chat(context.Context, model.Request, model.Options) (model.Response, error) {
|
||||||
|
s.calls++
|
||||||
|
return model.Response{Content: s.reply}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *reviewAdapterStub) ChatStream(
|
||||||
|
ctx context.Context, req model.Request, onEvent func(model.StreamEvent),
|
||||||
|
) (model.Response, error) {
|
||||||
|
return s.Chat(ctx, req, model.Options{})
|
||||||
|
}
|
||||||
|
|
||||||
// TestMemoryReviewSessionIsNotConversationLane 与整理同一条口径:审查会话不落在
|
// TestMemoryReviewSessionIsNotConversationLane 与整理同一条口径:审查会话不落在
|
||||||
// 主人的对话车道("web" / "web:<uuid>")上,因此不改她的心情、不进主会话泳道。
|
// 主人的对话车道("web" / "web:<uuid>")上,因此不改她的心情、不进主会话泳道。
|
||||||
|
|||||||
@@ -3,28 +3,49 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 本文件把记忆整理(DevDocs/design/23-记忆整理与去重 §4)接进组合根。
|
// 本文件把记忆整理(DevDocs/design/23-记忆整理与去重 §4)接进组合根。
|
||||||
//
|
//
|
||||||
// 三件事只在这里定:
|
// 六件事只在这里定:
|
||||||
// - **形态**:低峰定期后台任务(memory.TidyService 的心跳),不是会话内派
|
// - **形态**:定期后台任务(安静时段降频)的心跳(memory.TidyService),不是
|
||||||
// subagent。它在 main.go 与 backthink 并列启动、在 close 里先于关库停止;
|
// 会话内派 subagent。它在 main.go 与 backthink 并列启动、在 close 里先于关库停止;
|
||||||
// - **依赖面**:只给它记忆存储与 embedder —— 服务侧拿不到任何会话/历史读数,
|
// - **依赖面**:只给它记忆存储与 embedder —— 服务侧拿不到任何会话/历史读数,
|
||||||
// 因此 design §4.1「绝不碰主会话上下文」是结构性成立的,而不是靠自觉;
|
// 因此 design §4.1「绝不碰主会话上下文」是结构性成立的,而不是靠自觉;
|
||||||
// - **审计**:接到网关的 AuditEvent(design §8 的 memory.tidy.*),出口在调用
|
// - **审计**:接到网关的 AuditEvent(design §8 的 memory.tidy.*),出口在调用
|
||||||
// 时才解析 a.gw —— 网关比本服务晚组装(wireFrontend),与 onToolAudit
|
// 时才解析 a.gw —— 网关比本服务晚组装(wireFrontend),与 onToolAudit
|
||||||
// 同一手法。未就绪时只记日志,绝不假装写过审计。
|
// 同一手法。未就绪时只记日志,绝不假装写过审计;
|
||||||
|
// - **运行期调优**(2026-10-07 新增):从 `$DATA_DIR/config/memory-tidy.json`
|
||||||
|
// 读「自动落地开关 + 每轮上限」,以及(同日追加)**整理频率**四项
|
||||||
|
// (intervalSec / quietIntervalSec / quietStartHour / quietEndHour)。它是
|
||||||
|
// `.env` 冻结(design/13 §2.1)之后新旋钮的唯一去处,**零新 env 键**;面板优先、
|
||||||
|
// `.env` 兜底,键缺位或值不可用一律回落下一档并记 WARN(坏配置绝不能让自动档
|
||||||
|
// 变成「无限制」,也不能让整理频率变成忙轮询);
|
||||||
|
// - **启动播种**(2026-10-07 追加,主人「后端**自动创建**独立的面板配置文件**要做**」):
|
||||||
|
// 那个文件不存在时由后端自己写一份「当前生效值」进去(seedMemoryTidyPanel),
|
||||||
|
// 使「面板优先」从第一次启动起就成立,而不是靠 `.env` 一直兜着;
|
||||||
|
// - **到期清扫的挂点**(2026-10-13 追加,切片 M3,design/23 §19):临时记忆到期
|
||||||
|
// 后要**真删掉**(读侧时效闸只负责「看不见」)。清扫是 memory 包内的后台件
|
||||||
|
// (expire.go / expire_loop.go),起在 **TidyService.Start** 里、与心跳共用
|
||||||
|
// 同一个 ctx 与 wg,因此它跟整理同生共死(`Stop` 一并停掉它),不新开关闭链、
|
||||||
|
// 不新开端口、不加 env 键 —— 组合根这边**零接线**。
|
||||||
//
|
//
|
||||||
// 刻意**不做**的两件事(design §4.1 F、§3 非目标):
|
// 刻意**不做**的一件事(design §4.1 F):
|
||||||
// - 不把 sess-memorytidy 加进 flowPub 的图源白名单(默认关:整理是系统任务,
|
// - 不把 sess-memorytidy 加进 flowPub 的图源白名单(默认关:整理是系统任务,
|
||||||
// 不是主人的会话);
|
// 不是主人的会话)。
|
||||||
// - 不给它任何写记忆的路径(第一版只产候选,"立即整理"入口是
|
|
||||||
// TidyService.RunOnce)。
|
|
||||||
func (a *app) wireMemoryTidy() error {
|
func (a *app) wireMemoryTidy() error {
|
||||||
svc, err := memory.NewTidyService(memory.TidyConfigFromConfig(a.cfg.MemoryTidy), memory.TidyDeps{
|
cfg := memory.TidyConfigFromConfig(a.cfg.MemoryTidy)
|
||||||
|
a.applyMemoryTidyTuning(&cfg)
|
||||||
|
// 启动播种(主人 2026-10-07:「后端**自动创建**独立的面板配置文件**要做**」):
|
||||||
|
// 文件不存在/空白 ⇒ 把**此刻的生效值**写成面板的一等来源,此后 `.env` 只兜底。
|
||||||
|
// 放在 applyMemoryTidyTuning **之后**是刻意的:那一刻的 cfg 就是
|
||||||
|
// 「面板 > `.env` > 代码默认」的结果(面板缺失时即 `.env` + 默认),播它 ⇒ 行为
|
||||||
|
// 零变化。文件已存在(含损坏)⇒ 一个字节都不动(幂等,见 runtimeconfig.SeedMemoryTidy)。
|
||||||
|
a.seedMemoryTidyPanel(cfg)
|
||||||
|
svc, err := memory.NewTidyService(cfg, memory.TidyDeps{
|
||||||
Store: a.memStore,
|
Store: a.memStore,
|
||||||
Embedder: a.embedder,
|
Embedder: a.embedder,
|
||||||
Audit: a.auditMemoryTidy,
|
Audit: a.auditMemoryTidy,
|
||||||
@@ -33,10 +54,90 @@ func (a *app) wireMemoryTidy() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("memory tidy: %w", err)
|
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
|
a.memoryTidy = svc
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// seedMemoryTidyPanel 在 `$DATA_DIR/config/memory-tidy.json` 缺失/空白时播种一份
|
||||||
|
// 「当前生效值」快照(`memory.TidyTuningSnapshot`),使面板配置从第一次启动起就自足,
|
||||||
|
// 而不是每次都靠 `.env` 兜。失败只记日志、**绝不阻断启动**:运行期仍按 `.env` +
|
||||||
|
// 代码默认跑,行为与播种前逐字相同(与 materializeModels 同一口径)。
|
||||||
|
//
|
||||||
|
// 闸与 T1 的四键(ingestGate / t1Judge / t1PerWindow / t1WindowSec)**外加**临时
|
||||||
|
// 记忆 TTL 一键(tempTtlSec,切片 M2 加进来的第五个 review 键)在 `.env` 里
|
||||||
|
// **都没有出处**(`.env` 已冻结,它们只由这个面板文件与代码默认决定,见
|
||||||
|
// wireMemoryReview 的四个 helper:memoryIngestGateOff / memoryT1JudgeOff /
|
||||||
|
// memoryT1RateLimit / memoryTempTTL),而播种只发生在**面板文件不存在**时 —— 那正是
|
||||||
|
// 「零值 + normalizeReviewConfig」这一条路径,因此这里给零值 ReviewConfig 就是
|
||||||
|
// **当时**的生效值,不是猜的。审查开关本身(MEMORY_REVIEW_ENABLED)不在这十一键里,
|
||||||
|
// 因此不必为它去读一次 env。
|
||||||
|
func (a *app) seedMemoryTidyPanel(tidy memory.TidyConfig) {
|
||||||
|
path := runtimeconfig.DefaultMemoryTidyPath(resolveDataDir(a.cfg))
|
||||||
|
log := logger.WithSource(a.log, "memory/tidy")
|
||||||
|
// 播种函数自己会把「跳过 / 无法解析」记成带路径的日志(含 WARN),这里只报
|
||||||
|
// 「该写却没写成」那一种 —— 不重复记同一条 WARN。
|
||||||
|
if _, err := runtimeconfig.SeedMemoryTidy(path, memory.TidyTuningSnapshot(tidy, memory.ReviewConfig{}), log); err != nil {
|
||||||
|
log.Warn("memory-tidy.json 播种失败(面板配置仍缺位,.env 继续兜底)", "path", path, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyMemoryTidyTuning 把 `$DATA_DIR/config/memory-tidy.json` 叠到 env 侧的
|
||||||
|
// TidyConfig 上(见 runtimeconfig.MemoryTidyTuning 的零值口径):
|
||||||
|
// - 文件里**没写** autoApply ⇒ 保持代码默认(开);
|
||||||
|
// - 写 `false` ⇒ DisableAutoApply = true(一键回到「只产建议」,不必改代码);
|
||||||
|
// - autoApplyPerRound ≤ 0 ⇒ 不覆盖,交给 normalizeTidyConfig 填默认上限;
|
||||||
|
// - **调度四键**(intervalSec / quietIntervalSec / quietStartHour /
|
||||||
|
// quietEndHour)⇒ 交 memory.ApplyTidyScheduleTuning 逐键解析(面板 >
|
||||||
|
// `.env` > 代码默认),坏值回落 + WARN。
|
||||||
|
//
|
||||||
|
// 读不到/解不开时只记 WARN 并保持 env 侧的值:那正是「回落下一档」——坏文件时
|
||||||
|
// tuning 是零值(四个调度键都是 nil),因此 ApplyTidyScheduleTuning 一个键都不覆盖,
|
||||||
|
// env 值原样留着(`.env` 也缺位时由 normalizeTidyConfig 落到代码默认)。
|
||||||
|
func (a *app) applyMemoryTidyTuning(cfg *memory.TidyConfig) {
|
||||||
|
tuning, path, err := a.readMemoryTidyTuning()
|
||||||
|
log := logger.WithSource(a.log, "memory/tidy")
|
||||||
|
if err != nil {
|
||||||
|
log.Warn(
|
||||||
|
"memory tidy tuning unreadable, falling back to the env layer (.env values kept)",
|
||||||
|
"path", path, "error", err)
|
||||||
|
} else {
|
||||||
|
cfg.DisableAutoApply = tuning.AutoApplyOff()
|
||||||
|
if tuning.AutoApplyPerRound > 0 {
|
||||||
|
cfg.AutoApplyPerRound = tuning.AutoApplyPerRound
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 调度四键在两种情形下都要过一遍:成功时按面板值覆盖,失败时 tuning 是零值
|
||||||
|
// ⇒ 原样返回(= 保持 env 值),因此不必为它再分一条路径。
|
||||||
|
*cfg = memory.ApplyTidyScheduleTuning(*cfg, tuning, log)
|
||||||
|
// 这里打的是**归一化之前**的值(quiet_interval_sec 可能还是 0 = 「缺省,等于
|
||||||
|
// intervalSec」):真正生效的两档周期由 TidyService.Start 的启动日志报。
|
||||||
|
// panel_decoded = 「文件缺失/空白/解析成功」三者之一(LoadMemoryTidyTuning 把
|
||||||
|
// 前两者都当作「还没配过」而不是错误),为 false 即坏文件 ⇒ 四个键都回落 `.env`。
|
||||||
|
log.Info(
|
||||||
|
"memory tidy tuning applied (panel > .env > code defaults)",
|
||||||
|
"path", path, "panel_decoded", err == nil, "auto_apply", !cfg.DisableAutoApply,
|
||||||
|
"auto_apply_per_round", cfg.AutoApplyPerRound,
|
||||||
|
"interval_sec", cfg.IntervalSec, "quiet_interval_sec", cfg.QuietIntervalSec,
|
||||||
|
"quiet_hours", fmt.Sprintf("%02d:00-%02d:00", cfg.QuietStartHour, cfg.QuietEndHour))
|
||||||
|
}
|
||||||
|
|
||||||
|
// readMemoryTidyTuning 读一次 `$DATA_DIR/config/memory-tidy.json`(**只读**)。
|
||||||
|
// 它把「落点 + 读取 + 路径回报」收在一处,供整理自动档与**同步入库闸**两个
|
||||||
|
// 消费者共用 —— 后者(wireMemoryReview)不属于整理服务,但它的开关就在同一个
|
||||||
|
// 文件里(零新 env 键,design/13 §2.1)。错误由调用方记 WARN 并回落代码默认值。
|
||||||
|
func (a *app) readMemoryTidyTuning() (runtimeconfig.MemoryTidyTuning, string, error) {
|
||||||
|
path := runtimeconfig.DefaultMemoryTidyPath(resolveDataDir(a.cfg))
|
||||||
|
tuning, err := runtimeconfig.LoadMemoryTidyTuning(path)
|
||||||
|
if err != nil {
|
||||||
|
return runtimeconfig.MemoryTidyTuning{}, path, err
|
||||||
|
}
|
||||||
|
return tuning, path, nil
|
||||||
|
}
|
||||||
|
|
||||||
// auditMemoryTidy 把一轮整理的计数写进网关审计链。它**只带计数**,绝不带
|
// auditMemoryTidy 把一轮整理的计数写进网关审计链。它**只带计数**,绝不带
|
||||||
// 任何记忆正文(design §8 的 who/what/when/result 口径)。
|
// 任何记忆正文(design §8 的 who/what/when/result 口径)。
|
||||||
func (a *app) auditMemoryTidy(who, what, result string) {
|
func (a *app) auditMemoryTidy(who, what, result string) {
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"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/db"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 本文件锁定**启动播种在组合根上的接线**(wire_memory_tidy.go 的 seedMemoryTidyPanel;
|
||||||
|
// 主人 2026-10-07:「`.env` 只做兜底用 —— 后端**自动创建**独立的面板配置文件**要做**」):
|
||||||
|
//
|
||||||
|
// ① 文件不存在 ⇒ 后端自己写一份「当时生效值」(`.env` 给了的用 `.env`,没给的用代码
|
||||||
|
// 默认),面板配置自此自足;
|
||||||
|
// ② 播种之后再走一遍解析,生效配置与没播种时**逐字相同**(播种那一刻行为零变化);
|
||||||
|
// ③ 幂等:再跑一次不改字节(文件已在 ⇒ 走「绝不覆盖」那条路)。
|
||||||
|
//
|
||||||
|
// 用例全部落在临时 DataDir(`t.TempDir()`):生产那份 data/config/memory-tidy.json 是
|
||||||
|
// 主人手工建的,用例不许依赖它存在与否,也不许碰它。
|
||||||
|
|
||||||
|
// panelPath 返回某个 DataDir 下面板配置的落点(与组合根同一套路径推导)。
|
||||||
|
func panelPath(dataDir string) string {
|
||||||
|
return runtimeconfig.DefaultMemoryTidyPath(dataDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// readPanel 读回面板文件原文(幂等断言要比字节,不能只看解出来的键)。
|
||||||
|
func readPanel(t *testing.T, path string) string {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读回 %s: %v", path, err)
|
||||||
|
}
|
||||||
|
return string(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWireMemoryTidySeedsPanelOnBoot 是最接近主人场景的一条:直接调**组合根的真实
|
||||||
|
// 装配函数** `wireMemoryTidy`(它被 wireMemory → newApp 调用,即进程启动那条路),
|
||||||
|
// 断言这一次启动就**自己把面板配置文件建出来**了;再跑一次(= 第二次启动)字节不变。
|
||||||
|
//
|
||||||
|
// 为什么不用「跑一遍真二进制」来验收:那要再起一个 :8080 的同进程服务,与「后端常驻」
|
||||||
|
// 那条红线冲突(不许为验收停/重启正在跑的后端)。因此这里走的是**同一个函数**、同一套
|
||||||
|
// 路径推导,只把 DataDir 换成临时目录。
|
||||||
|
func TestWireMemoryTidySeedsPanelOnBoot(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
conn, err := db.Open(ctx, ":memory:", tidyTestLog())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = conn.Close() }()
|
||||||
|
if err := db.MigrateUp(ctx, conn, tidyTestLog()); err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
store, err := memory.NewStore(ctx, conn, tidyTestLog())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("memory store: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
env := config.MemoryTidyConfig{Enabled: true, IntervalSec: 3600, QuietStartHour: 23, QuietEndHour: 7}
|
||||||
|
dataDir := t.TempDir()
|
||||||
|
a := &app{
|
||||||
|
cfg: &config.Config{DataDir: dataDir, MemoryTidy: env},
|
||||||
|
memStore: store,
|
||||||
|
log: tidyTestLog(),
|
||||||
|
}
|
||||||
|
if err := a.wireMemoryTidy(); err != nil {
|
||||||
|
t.Fatalf("wireMemoryTidy: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
path := panelPath(dataDir)
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
t.Fatalf("启动装配没有自动创建 %s:%v(这正是主人要求的那条「后端自动创建要做」)", path, err)
|
||||||
|
}
|
||||||
|
got, err := runtimeconfig.LoadMemoryTidyTuning(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("回读播种结果: %v", err)
|
||||||
|
}
|
||||||
|
if got.IntervalSec == nil || *got.IntervalSec != 3600 ||
|
||||||
|
got.QuietIntervalSec == nil || *got.QuietIntervalSec != 3600 {
|
||||||
|
t.Fatalf("两档周期 = %v/%v, want 3600/3600(取 `.env`)", got.IntervalSec, got.QuietIntervalSec)
|
||||||
|
}
|
||||||
|
if got.QuietStartHour == nil || *got.QuietStartHour != 23 ||
|
||||||
|
got.QuietEndHour == nil || *got.QuietEndHour != 7 {
|
||||||
|
t.Fatalf("安静时段 = %v-%v, want 23-7", got.QuietStartHour, got.QuietEndHour)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第二次启动(同一个装配函数再跑一次):文件已在 ⇒ 一个字节都不动。
|
||||||
|
before := readPanel(t, path)
|
||||||
|
second := &app{
|
||||||
|
cfg: &config.Config{DataDir: dataDir, MemoryTidy: env},
|
||||||
|
memStore: store,
|
||||||
|
log: tidyTestLog(),
|
||||||
|
}
|
||||||
|
if err := second.wireMemoryTidy(); err != nil {
|
||||||
|
t.Fatalf("第二次 wireMemoryTidy: %v", err)
|
||||||
|
}
|
||||||
|
if after := readPanel(t, path); after != before {
|
||||||
|
t.Fatalf("第二次启动改动了面板文件:\n before=%s\n after =%s", before, after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSeedMemoryTidyPanel_WritesEffectiveValuesOnBoot 钉住「自动创建」这件事本身:
|
||||||
|
// 组合根启动时必须自己把面板文件建出来,内容 = 当时生效值(`.env` 档 + 代码默认)。
|
||||||
|
func TestSeedMemoryTidyPanel_WritesEffectiveValuesOnBoot(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// `.env` 层:与代码默认**故意不同**,这样「播的是生效值」与「播了一份硬编码默认值」
|
||||||
|
// 一眼可分。
|
||||||
|
env := config.MemoryTidyConfig{Enabled: true, IntervalSec: 10800, QuietStartHour: 22, QuietEndHour: 6}
|
||||||
|
dataDir := t.TempDir()
|
||||||
|
a := &app{cfg: &config.Config{DataDir: dataDir, MemoryTidy: env}, log: tidyTestLog()}
|
||||||
|
path := panelPath(dataDir)
|
||||||
|
|
||||||
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("前置:%s 不该存在", path)
|
||||||
|
}
|
||||||
|
a.seedMemoryTidyPanel(memory.TidyConfigFromConfig(env))
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
t.Fatalf("启动播种没有把面板文件建出来:%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := runtimeconfig.LoadMemoryTidyTuning(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("回读播种结果: %v", err)
|
||||||
|
}
|
||||||
|
// `.env` 档:三键如实落进快照(quietIntervalSec 没有 env 键 ⇒ 缺省 = intervalSec)。
|
||||||
|
if got.IntervalSec == nil || *got.IntervalSec != 10800 {
|
||||||
|
t.Fatalf("intervalSec = %v, want 10800(取 `.env`)", got.IntervalSec)
|
||||||
|
}
|
||||||
|
if got.QuietIntervalSec == nil || *got.QuietIntervalSec != 10800 {
|
||||||
|
t.Fatalf("quietIntervalSec = %v, want 10800(缺省 = intervalSec)", got.QuietIntervalSec)
|
||||||
|
}
|
||||||
|
if got.QuietStartHour == nil || *got.QuietStartHour != 22 ||
|
||||||
|
got.QuietEndHour == nil || *got.QuietEndHour != 6 {
|
||||||
|
t.Fatalf("安静时段 = %v-%v, want 22-6(取 `.env`)", got.QuietStartHour, got.QuietEndHour)
|
||||||
|
}
|
||||||
|
// 代码默认档:老六键必须齐(否则「面板能管住自动档/闸/T1」仍是空话)。
|
||||||
|
if got.AutoApply == nil || !*got.AutoApply || got.AutoApplyPerRound <= 0 {
|
||||||
|
t.Fatalf("自动档两键 = %v/%d, want 显式 true 且正数上限", got.AutoApply, got.AutoApplyPerRound)
|
||||||
|
}
|
||||||
|
if got.IngestGate == nil || !*got.IngestGate || got.T1Judge == nil || !*got.T1Judge {
|
||||||
|
t.Fatalf("闸/T1 开关 = %v/%v, want 显式 true", got.IngestGate, got.T1Judge)
|
||||||
|
}
|
||||||
|
if got.T1PerWindow <= 0 || got.T1WindowSec <= 0 {
|
||||||
|
t.Fatalf("T1 限速 = %d/%d, want 正数", got.T1PerWindow, got.T1WindowSec)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 幂等 + 「绝不覆盖」:再跑一次,字节一个不动。
|
||||||
|
before := readPanel(t, path)
|
||||||
|
a.seedMemoryTidyPanel(memory.TidyConfigFromConfig(env))
|
||||||
|
if after := readPanel(t, path); after != before {
|
||||||
|
t.Fatalf("第二次播种改动了面板文件:\n before=%s\n after =%s", before, after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSeedMemoryTidyPanel_KeepsRuntimeBehaviorIdentical 钉住「播种那一刻行为零变化」:
|
||||||
|
// 播种前后各按组合根的次序解析一次,得到的生效配置必须逐字相同 —— 否则播种就成了
|
||||||
|
// 一次没人察觉的行为变更,而不是「把现状写进文件」。
|
||||||
|
func TestSeedMemoryTidyPanel_KeepsRuntimeBehaviorIdentical(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := config.MemoryTidyConfig{Enabled: true, IntervalSec: 7200, QuietStartHour: 1, QuietEndHour: 5}
|
||||||
|
dataDir := t.TempDir()
|
||||||
|
a := &app{cfg: &config.Config{DataDir: dataDir, MemoryTidy: env}, log: tidyTestLog()}
|
||||||
|
|
||||||
|
// 比对的是**归一化之后**的生效配置:TidyConfig 是归一化前的中间形态,面板写不写
|
||||||
|
// quietIntervalSec / autoApplyPerRound 会让它在 0 与「等于生效值」之间变化,语义却
|
||||||
|
// 相同 —— 因此必须走 normalizeTidyConfig 这一层(memory.NewTidier 的既有入口)才能
|
||||||
|
// 说「行为逐字未变」。
|
||||||
|
ctx := context.Background()
|
||||||
|
conn, err := db.Open(ctx, ":memory:", tidyTestLog())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = conn.Close() }()
|
||||||
|
if err := db.MigrateUp(ctx, conn, tidyTestLog()); err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
store, err := memory.NewStore(ctx, conn, tidyTestLog())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("memory store: %v", err)
|
||||||
|
}
|
||||||
|
effective := func(t *testing.T, cfg memory.TidyConfig) memory.TidyConfig {
|
||||||
|
t.Helper()
|
||||||
|
tidier, err := memory.NewTidier(cfg, memory.TidyDeps{Store: store, Log: tidyTestLog()})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewTidier: %v", err)
|
||||||
|
}
|
||||||
|
return tidier.Config() // 已套默认值
|
||||||
|
}
|
||||||
|
|
||||||
|
// ① 播种前:面板文件不存在 ⇒ 只有 `.env` + 代码默认这一档。
|
||||||
|
before := memory.TidyConfigFromConfig(env)
|
||||||
|
a.applyMemoryTidyTuning(&before)
|
||||||
|
before = effective(t, before)
|
||||||
|
// ② 启动播种。
|
||||||
|
a.seedMemoryTidyPanel(memory.TidyConfigFromConfig(env))
|
||||||
|
// ③ 播种后:面板文件已在,同样的 env 再解析一次。
|
||||||
|
after := memory.TidyConfigFromConfig(env)
|
||||||
|
a.applyMemoryTidyTuning(&after)
|
||||||
|
after = effective(t, after)
|
||||||
|
|
||||||
|
if before != after {
|
||||||
|
t.Fatalf("播种改变了生效配置:\n before=%+v\n after =%+v", before, after)
|
||||||
|
}
|
||||||
|
// 两档周期(intervalAt 的真判据)也要如实等价:安静时段窗口内用 quietInterval。
|
||||||
|
if after.QuietInterval != after.Interval || after.Interval != time.Duration(env.IntervalSec)*time.Second {
|
||||||
|
t.Fatalf("两档周期 = %s/%s, want 都等于 `.env` 的 %s",
|
||||||
|
after.Interval, after.QuietInterval, time.Duration(env.IntervalSec)*time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSeedMemoryTidyPanel_PanelTakesOverAfterSeeding 是主人口径的终点:
|
||||||
|
// 「后端自动创建独立的面板配置文件之后就走面板配置文件」—— 播种之后,改那个文件
|
||||||
|
// 必须真的改变生效值(否则「自动创建」只是造了个没人读的文件)。
|
||||||
|
func TestSeedMemoryTidyPanel_PanelTakesOverAfterSeeding(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := config.MemoryTidyConfig{Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7}
|
||||||
|
dataDir := t.TempDir()
|
||||||
|
a := &app{cfg: &config.Config{DataDir: dataDir, MemoryTidy: env}, log: tidyTestLog()}
|
||||||
|
a.seedMemoryTidyPanel(memory.TidyConfigFromConfig(env))
|
||||||
|
|
||||||
|
path := panelPath(dataDir)
|
||||||
|
if err := os.WriteFile(path, []byte(`{"intervalSec": 3600}`), 0o600); err != nil {
|
||||||
|
t.Fatalf("改写面板文件: %v", err)
|
||||||
|
}
|
||||||
|
cfg := memory.TidyConfigFromConfig(env)
|
||||||
|
a.applyMemoryTidyTuning(&cfg)
|
||||||
|
if cfg.IntervalSec != 3600 {
|
||||||
|
t.Fatalf("intervalSec = %d, want 3600(面板优先,`.env` 只兜底)", cfg.IntervalSec)
|
||||||
|
}
|
||||||
|
// 面板没写的键仍由 `.env` 兜底(面板只管它写了的那几个键)。
|
||||||
|
if cfg.QuietStartHour != 23 || cfg.QuietEndHour != 7 {
|
||||||
|
t.Fatalf("安静时段 = %02d:00-%02d:00, want `.env` 的 23:00-07:00", cfg.QuietStartHour, cfg.QuietEndHour)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSeedMemoryTidyPanel_LeavesExistingFileAlone 钉住组合根这一侧的「绝不覆盖」:
|
||||||
|
// 盘上已有一份主人手写的配置(哪怕缺键、哪怕带未知字段)⇒ 启动播种一个字节都不动。
|
||||||
|
func TestSeedMemoryTidyPanel_LeavesExistingFileAlone(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := config.MemoryTidyConfig{Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7}
|
||||||
|
dataDir := t.TempDir()
|
||||||
|
dir := filepath.Join(dataDir, "config")
|
||||||
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||||
|
t.Fatalf("mkdir: %v", err)
|
||||||
|
}
|
||||||
|
body := "{\n \"autoApply\": false,\n \"手工写的未知键\": 1\n}\n"
|
||||||
|
path := panelPath(dataDir)
|
||||||
|
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||||
|
t.Fatalf("写手写的配置: %v", err)
|
||||||
|
}
|
||||||
|
a := &app{cfg: &config.Config{DataDir: dataDir, MemoryTidy: env}, log: tidyTestLog()}
|
||||||
|
a.seedMemoryTidyPanel(memory.TidyConfigFromConfig(env))
|
||||||
|
if got := readPanel(t, path); got != body {
|
||||||
|
t.Fatalf("已存在的面板文件被改了:\n want=%s\n got =%s", body, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,7 +52,16 @@ func TestMemoryTidyIsWired(t *testing.T) {
|
|||||||
"a.embedder = embedder",
|
"a.embedder = embedder",
|
||||||
},
|
},
|
||||||
"wire_memory_tidy.go": {
|
"wire_memory_tidy.go": {
|
||||||
"memory.NewTidyService(memory.TidyConfigFromConfig(a.cfg.MemoryTidy)",
|
"memory.NewTidyService(cfg, memory.TidyDeps{",
|
||||||
|
// 调优(自动落地开关 + 每轮上限)从 $DATA_DIR/config/memory-tidy.json
|
||||||
|
// 来,**零新 env 键**(`.env` 已冻结,design/13 §2.1)。少了这一句,
|
||||||
|
// 那个文件就成了「写了没人读」的摆设,而自动档仍然开着 —— 静默。
|
||||||
|
"runtimeconfig.LoadMemoryTidyTuning",
|
||||||
|
// 启动播种(2026-10-07 追加,主人「后端**自动创建**独立的面板配置文件
|
||||||
|
// **要做**」):少了这两句,那个文件在生产里**根本不存在** ⇒「面板优先」
|
||||||
|
// 永远是空话,生效的仍是 `.env` 兜底那一档 —— 而不会有任何用例变红。
|
||||||
|
"a.seedMemoryTidyPanel(cfg)",
|
||||||
|
"runtimeconfig.SeedMemoryTidy(",
|
||||||
// 依赖面里**没有**任何会话/历史读数 —— 这是「绝不碰主会话上下文」
|
// 依赖面里**没有**任何会话/历史读数 —— 这是「绝不碰主会话上下文」
|
||||||
// 的结构性保证,而不是靠自觉。
|
// 的结构性保证,而不是靠自觉。
|
||||||
"Audit: a.auditMemoryTidy",
|
"Audit: a.auditMemoryTidy",
|
||||||
@@ -60,6 +69,18 @@ func TestMemoryTidyIsWired(t *testing.T) {
|
|||||||
"main.go": {
|
"main.go": {
|
||||||
"a.memoryTidy.Start(ctx)",
|
"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": {
|
"wire_services.go": {
|
||||||
// 关库之前先停整理(它每轮要用连接池)。
|
// 关库之前先停整理(它每轮要用连接池)。
|
||||||
"a.memoryTidy.Stop(ctx)",
|
"a.memoryTidy.Stop(ctx)",
|
||||||
@@ -206,3 +227,113 @@ func readConversationFingerprint(t *testing.T, ctx context.Context, conn *sql.DB
|
|||||||
}
|
}
|
||||||
return fp
|
return fp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestApplyMemoryTidyTuning_ReadsDataDirConfig 钉住「自动档开关确实来自
|
||||||
|
// $DATA_DIR/config/memory-tidy.json」这条接线:它没有 env 键(`.env` 已冻结),
|
||||||
|
// 因此若组合根没读那个文件,「一键回到只产建议」就是一句空话 —— 文件写了没人看。
|
||||||
|
func TestApplyMemoryTidyTuning_ReadsDataDirConfig(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// ① 文件不存在 ⇒ 保持代码默认(自动档开着)。
|
||||||
|
empty := &app{cfg: &config.Config{DataDir: t.TempDir()}, log: tidyTestLog()}
|
||||||
|
cfg := memory.TidyConfigFromConfig(config.MemoryTidyConfig{Enabled: true})
|
||||||
|
empty.applyMemoryTidyTuning(&cfg)
|
||||||
|
if cfg.DisableAutoApply || cfg.AutoApplyPerRound != 0 {
|
||||||
|
t.Fatalf("没配过时 cfg = %+v, want 保持零值(由 normalize 填代码默认)", cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ② 写 {"autoApply": false, "autoApplyPerRound": 5} ⇒ 关掉 + 抬高上限。
|
||||||
|
dataDir := t.TempDir()
|
||||||
|
dir := filepath.Join(dataDir, "config")
|
||||||
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||||
|
t.Fatalf("mkdir: %v", err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, "memory-tidy.json")
|
||||||
|
if err := os.WriteFile(path, []byte(`{"autoApply": false, "autoApplyPerRound": 5}`), 0o600); err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
off := &app{cfg: &config.Config{DataDir: dataDir}, log: tidyTestLog()}
|
||||||
|
cfg = memory.TidyConfigFromConfig(config.MemoryTidyConfig{Enabled: true})
|
||||||
|
off.applyMemoryTidyTuning(&cfg)
|
||||||
|
if !cfg.DisableAutoApply || cfg.AutoApplyPerRound != 5 {
|
||||||
|
t.Fatalf("cfg = %+v, want DisableAutoApply=true 且 AutoApplyPerRound=5", cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ③ 坏文件 ⇒ 记 WARN 并回落代码默认(绝不把自动档读成「无限制」或「关掉」)。
|
||||||
|
if err := os.WriteFile(path, []byte(`{"autoApply": "nope"}`), 0o600); err != nil {
|
||||||
|
t.Fatalf("write broken: %v", err)
|
||||||
|
}
|
||||||
|
broken := &app{cfg: &config.Config{DataDir: dataDir}, log: tidyTestLog()}
|
||||||
|
cfg = memory.TidyConfigFromConfig(config.MemoryTidyConfig{Enabled: true})
|
||||||
|
broken.applyMemoryTidyTuning(&cfg)
|
||||||
|
if cfg.DisableAutoApply || cfg.AutoApplyPerRound != 0 {
|
||||||
|
t.Fatalf("坏配置时 cfg = %+v, want 回落零值(默认值由 normalize 填)", cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestApplyMemoryTidyTuning_ScheduleKnobs 钉住**整理频率**四键在组合根上的接线:
|
||||||
|
// 面板($DATA_DIR/config/memory-tidy.json)优先、`.env` 兜底 —— 主人 2026-10-07
|
||||||
|
// 口径「.env 只做兜底用 …… 这些键优先从面板配置上取」。
|
||||||
|
//
|
||||||
|
// 这里只断言「叠完之后 cfg 里是什么」,不重复 memory 包的优先级链用例:组合根这一层
|
||||||
|
// 要证明的是**那个文件真的被读了、真的落到了 cfg 上**(少这一句,面板改了频率也无效,
|
||||||
|
// 而不会有任何用例变红)。
|
||||||
|
func TestApplyMemoryTidyTuning_ScheduleKnobs(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// env 层:与面板值故意对立。
|
||||||
|
env := config.MemoryTidyConfig{Enabled: true, IntervalSec: 7200, QuietStartHour: 23, QuietEndHour: 7}
|
||||||
|
|
||||||
|
newApp := func(dataDir string) *app {
|
||||||
|
return &app{cfg: &config.Config{DataDir: dataDir}, log: tidyTestLog()}
|
||||||
|
}
|
||||||
|
apply := func(dataDir string) memory.TidyConfig {
|
||||||
|
cfg := memory.TidyConfigFromConfig(env)
|
||||||
|
newApp(dataDir).applyMemoryTidyTuning(&cfg)
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// ① 面板四键都写了 ⇒ 四个都用面板值(覆盖 `.env`)。
|
||||||
|
dataDir := t.TempDir()
|
||||||
|
dir := filepath.Join(dataDir, "config")
|
||||||
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||||
|
t.Fatalf("mkdir: %v", err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, "memory-tidy.json")
|
||||||
|
body := `{"intervalSec": 3600, "quietIntervalSec": 10800, "quietStartHour": 1, "quietEndHour": 5}`
|
||||||
|
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
cfg := apply(dataDir)
|
||||||
|
if cfg.IntervalSec != 3600 {
|
||||||
|
t.Fatalf("intervalSec = %d, want 3600(面板优先)", cfg.IntervalSec)
|
||||||
|
}
|
||||||
|
if cfg.QuietIntervalSec != 10800 {
|
||||||
|
t.Fatalf("quietIntervalSec = %d, want 10800(面板优先)", cfg.QuietIntervalSec)
|
||||||
|
}
|
||||||
|
if cfg.QuietStartHour != 1 || cfg.QuietEndHour != 5 {
|
||||||
|
t.Fatalf("安静时段 = %02d:00-%02d:00, want 01:00-05:00(面板优先)",
|
||||||
|
cfg.QuietStartHour, cfg.QuietEndHour)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ② 文件不存在 ⇒ 四键保持 `.env` 值(面板只是「能覆盖」,不是「必须有」)。
|
||||||
|
empty := t.TempDir()
|
||||||
|
cfg = apply(empty)
|
||||||
|
if cfg.IntervalSec != 7200 || cfg.QuietStartHour != 23 || cfg.QuietEndHour != 7 {
|
||||||
|
t.Fatalf("没配过时应保持 env 值,得到 %+v", cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ③ 坏文件(类型写错)⇒ 整份回落 `.env`,且**不启动失败**:组合根照旧拿到
|
||||||
|
// 可用的 cfg(一个坏键不得让整理停摆)。
|
||||||
|
if err := os.WriteFile(path, []byte(`{"intervalSec": "3600"}`), 0o600); err != nil {
|
||||||
|
t.Fatalf("write broken: %v", err)
|
||||||
|
}
|
||||||
|
cfg = apply(dataDir)
|
||||||
|
if cfg.IntervalSec != 7200 || cfg.QuietStartHour != 23 || cfg.QuietEndHour != 7 {
|
||||||
|
t.Fatalf("坏文件时应回落 env 值,得到 %+v", cfg)
|
||||||
|
}
|
||||||
|
if cfg.QuietIntervalSec != 0 {
|
||||||
|
t.Fatalf("坏文件时 quietIntervalSec 应当是零值(由 normalize 取 = intervalSec),得到 %d",
|
||||||
|
cfg.QuietIntervalSec)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
|
"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"
|
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/internal/capability/registry"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||||
@@ -99,23 +100,45 @@ func TestWirePanelHubDeclaresBuiltinSearchPanel(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("List: %v", err)
|
t.Fatalf("List: %v", err)
|
||||||
}
|
}
|
||||||
if len(entries) != 1 || entries[0].PluginID != search.PanelPluginID ||
|
// 按 pluginID 定位,而不是数「恰好一条」:内置提供者会越来越多(本片新增
|
||||||
entries[0].Panel.ID != search.PanelIDProviders {
|
// mcp/servers 管理页),数总数的断言每加一页就假红一次,而它真正想守的是
|
||||||
t.Fatalf("内置面板 = %+v,期望 web-search/providers 一条", entries)
|
// 「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() {
|
for _, name := range search.ActionNames() {
|
||||||
if _, ok := hub.Action(search.PanelPluginID, name); !ok {
|
if _, ok := hub.Action(search.PanelPluginID, name); !ok {
|
||||||
t.Fatalf("内置插件声明的动作 %q 未进白名单", name)
|
t.Fatalf("内置插件声明的动作 %q 未进白名单", name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// viewer 拿到的必须是裁掉 form 的只读投影。
|
// viewer 拿到的必须是裁掉 form 的只读投影(只剩那个表格区块)。
|
||||||
viewer, err := hub.List(auth.RoleViewer)
|
viewer, err := hub.List(auth.RoleViewer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("List(viewer): %v", err)
|
t.Fatalf("List(viewer): %v", err)
|
||||||
}
|
}
|
||||||
if len(viewer) != 1 || len(viewer[0].Panel.Sections) != 1 {
|
viewerEntry, ok := findPanelEntry(viewer, search.PanelPluginID, search.PanelIDProviders)
|
||||||
t.Fatalf("viewer 的只读投影 = %+v,期望只剩表格区块", viewer)
|
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)。
|
// TestWirePanelHubNilPluginsIsNoop 断言未构造插件管理器时接线是空操作(不 panic)。
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ package main
|
|||||||
// - `websearch_action_data.go`:动作本体的业务数据面(可变源的读写与翻译、载荷解析);
|
// - `websearch_action_data.go`:动作本体的业务数据面(可变源的读写与翻译、载荷解析);
|
||||||
// - `websearch_probe.go`:探针的传输层绑定(HTTP 客户端 + 同主机守卫 + 状态映射);
|
// - `websearch_probe.go`:探针的传输层绑定(HTTP 客户端 + 同主机守卫 + 状态映射);
|
||||||
// - `websearch_reload.go`:保存后的**热重载**事务(重建工具 + 换注册表条目 + 失败回滚);
|
// - `websearch_reload.go`:保存后的**热重载**事务(重建工具 + 换注册表条目 + 失败回滚);
|
||||||
// - `websearch_selection.go`:表单里两个文件级字段(主 provider / 降级链)的读写。
|
// - `websearch_selection.go`:表单里文件级字段(主 provider / 降级链 / 条数上限)的读写。
|
||||||
//
|
//
|
||||||
// 三条边界:
|
// 三条边界:
|
||||||
//
|
//
|
||||||
@@ -152,8 +152,8 @@ type webSearchActionEnv struct {
|
|||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
// path 是可变源落点 `$DATA_DIR/config/plugins/web-search.json`。
|
// path 是可变源落点 `$DATA_DIR/config/plugins/web-search.json`。
|
||||||
path string
|
path string
|
||||||
// envCfg 是 `.env` 解析结果(**只用来取代码默认值**:各云家的默认基地址与超时)。
|
// envCfg 是 `.env` 解析结果(**只用来取标量默认值**:探针超时;各家的默认基地址改由
|
||||||
// 把面板没写的字段落回代码默认值,与工具运行时的 `orDefault` 口径一致。
|
// 接入位表给出,见 resolveBaseURL)。
|
||||||
envCfg config.WebSearchConfig
|
envCfg config.WebSearchConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -393,6 +393,9 @@ func TestWebSearchActionsRejectUnknownProviderAndBadPayload(t *testing.T) {
|
|||||||
|
|
||||||
// TestWebSearchOptionsActionFeedsDeclaredOptionsFrom 锁死「声明的下拉一定拉得到候选」:
|
// TestWebSearchOptionsActionFeedsDeclaredOptionsFrom 锁死「声明的下拉一定拉得到候选」:
|
||||||
// 声明里每个 optionsFrom 映射出的动作名都**已登记且已声明**(否则前端只能降级成文本框)。
|
// 声明里每个 optionsFrom 映射出的动作名都**已登记且已声明**(否则前端只能降级成文本框)。
|
||||||
|
//
|
||||||
|
// 候选的形状是 `{value,label}`(切片 LBL-1):这里连**显示名非空**一起钉住 —— 只有值的
|
||||||
|
// 候选会让面板下拉与表格里全是英文 id(主人 2026-10-06 报障的那个形状)。
|
||||||
func TestWebSearchOptionsActionFeedsDeclaredOptionsFrom(t *testing.T) {
|
func TestWebSearchOptionsActionFeedsDeclaredOptionsFrom(t *testing.T) {
|
||||||
env := newActionTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
|
env := newActionTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
|
||||||
|
|
||||||
@@ -413,15 +416,30 @@ func TestWebSearchOptionsActionFeedsDeclaredOptionsFrom(t *testing.T) {
|
|||||||
t.Errorf("候选来源 %q 调不动: %v", src, err)
|
t.Errorf("候选来源 %q 调不动: %v", src, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
options, _ := out.(map[string]any)["options"].([]string)
|
options, _ := out.(map[string]any)["options"].([]search.ProviderOption)
|
||||||
if len(options) == 0 {
|
if len(options) == 0 {
|
||||||
t.Errorf("候选来源 %q 回了空候选(表单下拉会空着)", src)
|
t.Errorf("候选来源 %q 回了空候选(表单下拉会空着)", src)
|
||||||
}
|
}
|
||||||
if strings.Join(options, ",") != strings.Join(search.ProviderOptions(), ",") {
|
got, want := optionValues(options), optionValues(search.ProviderOptions())
|
||||||
t.Errorf("候选 = %v, want %v", options, search.ProviderOptions())
|
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||||
|
t.Errorf("候选值 = %v, want %v(value 逐字等于 KnownNames())", got, want)
|
||||||
|
}
|
||||||
|
for _, o := range options {
|
||||||
|
if strings.TrimSpace(o.Label) == "" {
|
||||||
|
t.Errorf("候选来源 %q 的 %q 没有显示名(下拉里会是空白项)", src, o.Value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// optionValues 取出结构化候选的**值**(落盘真源),丢掉显示名。
|
||||||
|
func optionValues(opts []search.ProviderOption) []string {
|
||||||
|
out := make([]string, 0, len(opts))
|
||||||
|
for _, o := range opts {
|
||||||
|
out = append(out, o.Value)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// TestWebSearchActionsUnwiredAnswers501 锁定降级口径:**没有配置**(测试夹具/装配未完成)
|
// TestWebSearchActionsUnwiredAnswers501 锁定降级口径:**没有配置**(测试夹具/装配未完成)
|
||||||
// 时不登记任何处理器 ⇒ 已声明的动作诚实回 501(ErrActionUnavailable),
|
// 时不登记任何处理器 ⇒ 已声明的动作诚实回 501(ErrActionUnavailable),
|
||||||
|
|||||||
@@ -164,8 +164,19 @@ func (a *app) wireRuntime() error {
|
|||||||
if err := basic.Register(a.caps); err != nil {
|
if err := basic.Register(a.caps); err != nil {
|
||||||
return fmt.Errorf("register basic tools: %w", err)
|
return fmt.Errorf("register basic tools: %w", err)
|
||||||
}
|
}
|
||||||
// io 工具集的选项(含 web-search 的端点与超时裁决)在 wire_io_tools.go 一处组装:
|
// 浏览器工具(design/28 B1 接线,切片 V5;挂点由 X3 归位到这里):
|
||||||
// 端点不可用时它交出 WithoutSearch(),使 web-search **不进注册面**(WS1-A)。
|
// 与 vision / transcode 同族 —— 可选增强,不可用就**不注册**(模型看不见这个
|
||||||
|
// 工具,而不是看得见、一调就报错),失败不阻塞启动;装配细节与六条口径都在
|
||||||
|
// wire_browser.go 一处。它的关闭挂点在 a.close(wire_services.go),成对。
|
||||||
|
//
|
||||||
|
// ⚠️ 它必须排在 io 工具集**之前**(切片 Z5 的解封):io 的 web-fetch 降级档
|
||||||
|
// (design/28 §1 M5 / §8 B5)按「能力面里到底有没有可用浏览器」决定挂不挂,
|
||||||
|
// 而那条判据就是在这里被写进注册面的(`bodyFallbackOption()`,见
|
||||||
|
// wire_body_fallback.go)。装配顺序即这条判定的输入,颠倒过来降级档会静默关死。
|
||||||
|
a.wireBrowser()
|
||||||
|
// io 工具集的选项(含 web-search 的端点与超时裁决、web-fetch 的浏览器降级档)
|
||||||
|
// 在 wire_io_tools.go 一处组装:端点不可用时它交出 WithoutSearch(),使 web-search
|
||||||
|
// **不进注册面**(WS1-A)。
|
||||||
ioTools, ioErr := iotools.New(a.cfg, a.ioToolOptions()...)
|
ioTools, ioErr := iotools.New(a.cfg, a.ioToolOptions()...)
|
||||||
if ioErr != nil {
|
if ioErr != nil {
|
||||||
return fmt.Errorf("build io tools: %w", ioErr)
|
return fmt.Errorf("build io tools: %w", ioErr)
|
||||||
@@ -177,11 +188,6 @@ func (a *app) wireRuntime() error {
|
|||||||
}
|
}
|
||||||
a.wireVision()
|
a.wireVision()
|
||||||
a.wireTranscode()
|
a.wireTranscode()
|
||||||
a.wireBrowser()
|
|
||||||
// 浏览器工具(design/28 B1 接线,切片 V5;挂点由 X3 归位到这里):
|
|
||||||
// 与 vision / transcode 同族 —— 可选增强,不可用就**不注册**(模型看不见这个
|
|
||||||
// 工具,而不是看得见、一调就报错),失败不阻塞启动;装配细节与六条口径都在
|
|
||||||
// wire_browser.go 一处。它的关闭挂点在 a.close(wire_services.go),成对。
|
|
||||||
|
|
||||||
// 上下文溢出压缩:预算按模型窗口推导(CONTEXT_MAX_TOKENS 未设置时;
|
// 上下文溢出压缩:预算按模型窗口推导(CONTEXT_MAX_TOKENS 未设置时;
|
||||||
// 显式 >0 = 覆盖、0/负 = 关闭),推导与「窗口未声明」的 WARN 都在
|
// 显式 >0 = 覆盖、0/负 = 关闭),推导与「窗口未声明」的 WARN 都在
|
||||||
@@ -318,7 +324,7 @@ func (a *app) wireRuntime() error {
|
|||||||
OnTurnEnd: a.onTurnEnd,
|
OnTurnEnd: a.onTurnEnd,
|
||||||
Memory: store,
|
Memory: store,
|
||||||
Retriever: a.retriever,
|
Retriever: a.retriever,
|
||||||
Extractor: a.buildMemoryExtractor(),
|
Extractor: loopMemoryExtractor(a.buildMemoryExtractor()),
|
||||||
Checkpoint: checkpoint,
|
Checkpoint: checkpoint,
|
||||||
Tracer: tracer,
|
Tracer: tracer,
|
||||||
Flow: flowPub,
|
Flow: flowPub,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -73,8 +73,18 @@ func TestWireIOTools_PanelConfigSeedsFromEnvThenIsAuthoritative(t *testing.T) {
|
|||||||
if f.Provider != search.NameBocha {
|
if f.Provider != search.NameBocha {
|
||||||
t.Fatalf("播种的主 provider = %q, want %q", f.Provider, search.NameBocha)
|
t.Fatalf("播种的主 provider = %q, want %q", f.Provider, search.NameBocha)
|
||||||
}
|
}
|
||||||
if len(f.Providers) != len(search.KnownNames()) {
|
// 播种内容 = `.env` 字段表那张表(searxng + 5 家云家)。**没有 env 字段的家**
|
||||||
t.Fatalf("播种必须列全本期内置的家:%d 条, want %d", len(f.Providers), len(search.KnownNames()))
|
// (tencent_wsa / metaso)不进播种:`.env` 已冻结(不加键),它们只从面板文件配。
|
||||||
|
seededNames := envSearchProviders(first.WebSearch)
|
||||||
|
if len(f.Providers) != len(seededNames) {
|
||||||
|
t.Fatalf("播种必须列全 `.env` 字段表里的家:%d 条, want %d", len(f.Providers), len(seededNames))
|
||||||
|
}
|
||||||
|
for _, absent := range []string{search.NameTencentWSA, search.NameMetaso} {
|
||||||
|
for _, e := range f.Providers {
|
||||||
|
if strings.EqualFold(e.Provider, absent) {
|
||||||
|
t.Fatalf("没有 env 字段的家 %s 不得被播种(它只从面板文件配)", absent)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
seeded := false
|
seeded := false
|
||||||
for _, e := range f.Providers {
|
for _, e := range f.Providers {
|
||||||
|
|||||||
@@ -112,7 +112,10 @@ func TestWireIOTools_AvailableListExcludesUnconfigured(t *testing.T) {
|
|||||||
if !strings.Contains(out, search.NameBocha) {
|
if !strings.Contains(out, search.NameBocha) {
|
||||||
t.Fatalf("可用清单里应有 bocha: %q", out)
|
t.Fatalf("可用清单里应有 bocha: %q", out)
|
||||||
}
|
}
|
||||||
for _, absent := range []string{search.NameZhipu, search.NameAliyunIQS, search.NameVolcengine, search.NameBaiduQianfan} {
|
for _, absent := range []string{
|
||||||
|
search.NameZhipu, search.NameAliyunIQS, search.NameVolcengine, search.NameBaiduQianfan,
|
||||||
|
search.NameTencentWSA, search.NameMetaso,
|
||||||
|
} {
|
||||||
if strings.Contains(out, absent) {
|
if strings.Contains(out, absent) {
|
||||||
t.Fatalf("缺 key 的 %s 不得出现在可用清单里: %q", absent, out)
|
t.Fatalf("缺 key 的 %s 不得出现在可用清单里: %q", absent, out)
|
||||||
}
|
}
|
||||||
@@ -170,16 +173,17 @@ func TestWireIOTools_SelectedCloudProviderIsUsed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestWireIOTools_ProviderVectorSummary 是一句「本期内置哪几家」的防回归:
|
// TestWireIOTools_ProviderVectorSummary 是一句「本期内置哪几家」的防回归:
|
||||||
// 六家的名字都必须被 search 包登记(新增一家别忘了加文件 + 注册表一行)。
|
// 八家的名字都必须被 search 包登记(新增一家别忘了加文件 + 注册表一行)。
|
||||||
func TestWireIOTools_ProviderVectorSummary(t *testing.T) {
|
func TestWireIOTools_ProviderVectorSummary(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
want := map[string]bool{
|
want := map[string]bool{
|
||||||
search.NameSearXNG: true, search.NameZhipu: true, search.NameAliyunIQS: true,
|
search.NameSearXNG: true, search.NameZhipu: true, search.NameAliyunIQS: true,
|
||||||
search.NameBocha: true, search.NameVolcengine: true, search.NameBaiduQianfan: true,
|
search.NameBocha: true, search.NameVolcengine: true, search.NameBaiduQianfan: true,
|
||||||
|
search.NameTencentWSA: true, search.NameMetaso: true,
|
||||||
}
|
}
|
||||||
got := search.KnownNames()
|
got := search.KnownNames()
|
||||||
if len(got) != len(want) {
|
if len(got) != len(want) {
|
||||||
t.Fatalf("KnownNames() = %v, want %d 家(本期内置:searxng/zhipu/aliyun_iqs/bocha/volcengine/baidu_qianfan)",
|
t.Fatalf("KnownNames() = %v, want %d 家(本期内置:searxng/zhipu/aliyun_iqs/bocha/volcengine/baidu_qianfan/tencent_wsa/metaso)",
|
||||||
got, len(want))
|
got, len(want))
|
||||||
}
|
}
|
||||||
for _, n := range got {
|
for _, n := range got {
|
||||||
|
|||||||
@@ -2,14 +2,27 @@
|
|||||||
// the Go package can //go:embed it into the single Cyrene binary. It runs as the
|
// 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,
|
// last step of `npm run build`. The source dist is the canonical build output,
|
||||||
// so it also serves as the external deploy-override directory.
|
// 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";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
const here = dirname(fileURLToPath(import.meta.url));
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
const src = resolve(here, "dist");
|
const src = resolve(here, "dist");
|
||||||
const dest = resolve(here, "..", "..", "internal", "webui", "dist");
|
const dest = resolve(here, "..", "..", "internal", "webui", "dist");
|
||||||
|
const placeholder = ".gitkeep";
|
||||||
|
|
||||||
await rm(dest, { recursive: true, force: true });
|
await rm(dest, { recursive: true, force: true });
|
||||||
|
try {
|
||||||
await cp(src, dest, { recursive: true });
|
await cp(src, dest, { recursive: true });
|
||||||
|
} finally {
|
||||||
|
await mkdir(dest, { recursive: true });
|
||||||
|
await writeFile(join(dest, placeholder), "");
|
||||||
|
}
|
||||||
console.log(`webui: copied ${src} -> ${dest}`);
|
console.log(`webui: copied ${src} -> ${dest}`);
|
||||||
@@ -8,6 +8,8 @@ import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components"
|
|||||||
import { Button, Surface, Tag } from "../../components/primitives";
|
import { Button, Surface, Tag } from "../../components/primitives";
|
||||||
import { MemoryTidyFactsCard } from "./ConfigFactsCards";
|
import { MemoryTidyFactsCard } from "./ConfigFactsCards";
|
||||||
import { MemoryTidyDryRunCard } from "./MemoryTidyDryRunCard";
|
import { MemoryTidyDryRunCard } from "./MemoryTidyDryRunCard";
|
||||||
|
import { MemoryTidyApplyCard } from "./MemoryTidyApplyCard";
|
||||||
|
import { MemoryTidyTuningCard } from "./MemoryTidyTuningCard";
|
||||||
|
|
||||||
const scopeTone: Record<string, "ok" | "warn" | "accent" | "neutral"> = {
|
const scopeTone: Record<string, "ok" | "warn" | "accent" | "neutral"> = {
|
||||||
user: "accent",
|
user: "accent",
|
||||||
@@ -188,6 +190,10 @@ export function MemoriesModule(): React.ReactNode {
|
|||||||
|
|
||||||
<MemoryTidyDryRunCard />
|
<MemoryTidyDryRunCard />
|
||||||
|
|
||||||
|
<MemoryTidyApplyCard />
|
||||||
|
|
||||||
|
<MemoryTidyTuningCard />
|
||||||
|
|
||||||
<MemoryTidyFactsCard />
|
<MemoryTidyFactsCard />
|
||||||
|
|
||||||
<Notice>
|
<Notice>
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
// 记忆整理**候选应用卡**的真实渲染测试(切片 TIDY-1)。
|
||||||
|
//
|
||||||
|
// 为什么需要它:这张卡是「整理建议的执行面」,而修好之后最容易走样的三条口径
|
||||||
|
// 都是**呈现**:
|
||||||
|
// ① 批量回执被上限截断时,必须如实说出「命中多少 / 本轮处理多少 / 还剩多少未
|
||||||
|
// 处理」—— 绝不能画成「全部完成」(否则主人会以为 342 条都处理完了);
|
||||||
|
// ② `skipped` 是**幂等命中/动作不可应用**,不是错误 —— 界面**不许报红**;
|
||||||
|
// ③ 行的两个按钮按**后端 status** 决定可用性(pending 行的回滚必须禁用),
|
||||||
|
// 未知状态原样显示、不吞、不猜。
|
||||||
|
//
|
||||||
|
// 跑法(与 ConfigFactsCards.render.test.ts / MemoryTidyDryRunCard.render.test.ts 同):
|
||||||
|
//
|
||||||
|
// cd frontend/web \
|
||||||
|
// && ./node_modules/.bin/esbuild src/admin/modules/MemoryTidyApplyCard.render.test.ts \
|
||||||
|
// --bundle --platform=node --format=cjs \
|
||||||
|
// --outfile=/tmp/cyrene-memory-tidy-apply.render.test.cjs \
|
||||||
|
// && node /tmp/cyrene-memory-tidy-apply.render.test.cjs
|
||||||
|
//
|
||||||
|
// 🔴 渲染类测试必须 `--format=cjs`:`--format=esm` 会整批崩在 bundle 深处。
|
||||||
|
// `tsc --noEmit` 会对本文件做类型检查,因此它只从项目内导入、不 import node:*。
|
||||||
|
|
||||||
|
import { createElement } from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { TIDY_STATUS_APPLIED, TIDY_STATUS_PENDING } from "../../lib/api";
|
||||||
|
import type {
|
||||||
|
AdminTidyApplyBatch,
|
||||||
|
AdminTidyCandidateRecord,
|
||||||
|
AdminTidyRunResult,
|
||||||
|
} from "../../lib/types";
|
||||||
|
import {
|
||||||
|
MemoryTidyApplyBody,
|
||||||
|
TIDY_APPLY_ALL_DONE_TAG,
|
||||||
|
TIDY_APPLY_SKIPPED_HINT,
|
||||||
|
TIDY_APPLY_TRUNCATED_HINT,
|
||||||
|
TIDY_CANDIDATE_LIST_ERROR_HINT,
|
||||||
|
} from "./MemoryTidyApplyCard";
|
||||||
|
import type { TidyApplyActions, TidyApplyViewState } from "./memoryTidyApplyState";
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
function countOf(haystack: string, needle: string): number {
|
||||||
|
return haystack.split(needle).length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按钮的禁用属性在静态标记里的形态(`disabled:opacity-50` 这个**类名**里也有
|
||||||
|
* "disabled" 字样,因此只能按属性形态 `disabled=""` 计数)。 */
|
||||||
|
const DISABLED_ATTR = 'disabled=""';
|
||||||
|
|
||||||
|
/** 动作替身:展示体只调这些出口,测试不关心它们做什么。 */
|
||||||
|
const NOOP_ACTIONS: TidyApplyActions = {
|
||||||
|
onStatusChange: () => {},
|
||||||
|
onApply: () => {},
|
||||||
|
onRevert: () => {},
|
||||||
|
onRequest: () => {},
|
||||||
|
onConfirm: () => {},
|
||||||
|
onCancelConfirm: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const PENDING: AdminTidyCandidateRecord = {
|
||||||
|
id: "cand-pending-1",
|
||||||
|
action: "merge",
|
||||||
|
status: "pending",
|
||||||
|
sourceIds: ["mem-short"],
|
||||||
|
targetId: "mem-long",
|
||||||
|
personaId: "cyrene",
|
||||||
|
scope: "persona",
|
||||||
|
kind: "relationship",
|
||||||
|
proposedContent: "主人常在深夜与助手聊天,有熬夜习惯",
|
||||||
|
mergedContent: "主人常在深夜与助手聊天,有熬夜习惯,作息偏晚",
|
||||||
|
reason: "containment_a_subset_b",
|
||||||
|
ruleVersion: "t0.1",
|
||||||
|
runId: "run-1",
|
||||||
|
scoreMin: 0.95,
|
||||||
|
scoreMax: 0.95,
|
||||||
|
createdAt: "2026-10-05T10:18:10Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
const APPLIED: AdminTidyCandidateRecord = { ...PENDING, id: "cand-applied-1", status: "applied" };
|
||||||
|
|
||||||
|
/** 后端候选状态闭集只有 pending / applied;这个 fixture 用的是**闭集外**的值,
|
||||||
|
* 用来钉住「未知值原样显示、不猜」。 */
|
||||||
|
const UNKNOWN: AdminTidyCandidateRecord = { ...PENDING, id: "cand-weird-1", status: "reverted" };
|
||||||
|
|
||||||
|
function viewState(patch: Partial<TidyApplyViewState> = {}): TidyApplyViewState {
|
||||||
|
return {
|
||||||
|
status: TIDY_STATUS_PENDING,
|
||||||
|
list: { kind: "ready", data: { candidates: [PENDING], count: 1 } },
|
||||||
|
busy: null,
|
||||||
|
confirming: null,
|
||||||
|
batch: null,
|
||||||
|
run: null,
|
||||||
|
last: null,
|
||||||
|
fail: null,
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function withCandidates(candidate: AdminTidyCandidateRecord, status = TIDY_STATUS_PENDING): TidyApplyViewState {
|
||||||
|
return viewState({ status, list: { kind: "ready", data: { candidates: [candidate], count: 1 } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(state: TidyApplyViewState): string {
|
||||||
|
return renderToStaticMarkup(createElement(MemoryTidyApplyBody, { state, actions: NOOP_ACTIONS }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ① 候选表:动作 / 状态 / 摘要 / 理由 / 时间 + 每行两个动作 ────────────────
|
||||||
|
const pendingHtml = render(withCandidates(PENDING));
|
||||||
|
ok(has(pendingHtml, "并入"), "merge 要显示中文标签「并入」");
|
||||||
|
ok(has(pendingHtml, "待定"), "状态要按后端值显示「待定」");
|
||||||
|
ok(has(pendingHtml, "作息偏晚"), "摘要要显示存活条目原文(diff 预览)");
|
||||||
|
ok(has(pendingHtml, "containment_a_subset_b"), "必须显示机器可读理由");
|
||||||
|
ok(has(pendingHtml, "2026-10-05 10:18"), "必须显示候选产生时间(RFC3339 裁到分)");
|
||||||
|
ok(has(pendingHtml, "应用") && has(pendingHtml, "回滚"), "每行必须有「应用」与「回滚」两个按钮");
|
||||||
|
|
||||||
|
// ── ② 回滚只在 status=applied 时可用(按后端 status,不按「我记得点过」)───────
|
||||||
|
const pendingDisabled = countOf(pendingHtml, DISABLED_ATTR);
|
||||||
|
ok(pendingDisabled === 1, `待定行只有回滚该禁用(实际 disabled 属性出现 ${pendingDisabled} 次)`);
|
||||||
|
const appliedHtml = render(withCandidates(APPLIED, TIDY_STATUS_APPLIED));
|
||||||
|
ok(has(appliedHtml, "已应用"), "已应用行要显示「已应用」");
|
||||||
|
const appliedDisabled = countOf(appliedHtml, DISABLED_ATTR);
|
||||||
|
ok(appliedDisabled === 0, `已应用行的回滚必须可点(实际 disabled 属性出现 ${appliedDisabled} 次)`);
|
||||||
|
const unknownHtml = render(withCandidates(UNKNOWN));
|
||||||
|
ok(has(unknownHtml, "reverted"), "闭集外的状态值必须原样显示(绝不吞掉)");
|
||||||
|
ok(countOf(unknownHtml, DISABLED_ATTR) === 1, "未知状态按「非已应用」处理:回滚禁用");
|
||||||
|
|
||||||
|
// ── ③ 批量回执:truncated 时必须如实显示「还剩多少未处理」 ──────────────────
|
||||||
|
const BATCH_TRUNCATED: AdminTidyApplyBatch = {
|
||||||
|
limit: 100,
|
||||||
|
total: 342,
|
||||||
|
truncated: true,
|
||||||
|
applied: 90,
|
||||||
|
skipped: 10,
|
||||||
|
failed: 0,
|
||||||
|
merged: 80,
|
||||||
|
archived: 10,
|
||||||
|
reclassified: 0,
|
||||||
|
results: [],
|
||||||
|
};
|
||||||
|
const truncatedHtml = render(viewState({ batch: BATCH_TRUNCATED }));
|
||||||
|
ok(has(truncatedHtml, TIDY_APPLY_TRUNCATED_HINT), "截断时必须显式点明「命中数大于本轮处理数」");
|
||||||
|
ok(has(truncatedHtml, "命中待定 342 条"), "必须显示命中的**真**总数(不是 len(results))");
|
||||||
|
ok(has(truncatedHtml, "本轮处理 100 条"), "必须显示本轮真正处理了多少条");
|
||||||
|
ok(has(truncatedHtml, "还有 242 条待定未处理"), "必须算出并显示未处理量(342 - 100)");
|
||||||
|
ok(!has(truncatedHtml, TIDY_APPLY_ALL_DONE_TAG), "截断时绝不得渲染「已全部处理」标记");
|
||||||
|
ok(!has(truncatedHtml, "待实现"), "截断提示不得带「待实现」前缀(那会被读成这功能还没做)");
|
||||||
|
|
||||||
|
const BATCH_DONE: AdminTidyApplyBatch = { ...BATCH_TRUNCATED, limit: 100, total: 5, applied: 3, skipped: 2, failed: 0, truncated: false };
|
||||||
|
const doneHtml = render(viewState({ batch: BATCH_DONE }));
|
||||||
|
ok(has(doneHtml, TIDY_APPLY_ALL_DONE_TAG), "未截断时才可以说「待定候选已全部处理」");
|
||||||
|
ok(!has(doneHtml, "未处理"), "未截断时不得出现「未处理」字样(否则那句话是假的)");
|
||||||
|
|
||||||
|
// ── ④ skipped 不显示为错误 ────────────────────────────────────────────────
|
||||||
|
const skippedHtml = render(
|
||||||
|
viewState({
|
||||||
|
batch: BATCH_DONE,
|
||||||
|
last: {
|
||||||
|
candidateId: "cand-link-9",
|
||||||
|
action: "link",
|
||||||
|
status: "skipped",
|
||||||
|
reason: "action_not_applyable",
|
||||||
|
affectedIds: [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
ok(has(skippedHtml, TIDY_APPLY_SKIPPED_HINT), "skipped 必须显式说明「不是错误」");
|
||||||
|
ok(has(skippedHtml, "已跳过"), "单条回执要按后端 status 显示「已跳过」");
|
||||||
|
ok(has(skippedHtml, "reason=action_not_applyable"), "必须带上机器可读的跳过原因");
|
||||||
|
ok(!has(skippedHtml, "text-[var(--mu-err)]"), "skipped 绝不得用错误色渲染(界面不许报红)");
|
||||||
|
ok(!has(skippedHtml, "应用失败"), "skipped 不得被呈现成失败");
|
||||||
|
|
||||||
|
// 反向哨兵:确有 failed 时错误色**必须**出现 —— 否则上面那条否定断言是空的。
|
||||||
|
const failedHtml = render(
|
||||||
|
viewState({ batch: { ...BATCH_DONE, applied: 2, skipped: 0, failed: 1, total: 3 } })
|
||||||
|
);
|
||||||
|
ok(has(failedHtml, "text-[var(--mu-err)]"), "失败计数非 0 时必须出现错误色(否定断言的对照)");
|
||||||
|
ok(countOf(failedHtml, TIDY_APPLY_ALL_DONE_TAG) === 1, "有失败但未截断:完成标记仍按 truncated=false 显示");
|
||||||
|
|
||||||
|
// ── ⑤ 列表读失败 ≠ 没有候选 ──────────────────────────────────────────────
|
||||||
|
const listErrHtml = render(
|
||||||
|
viewState({ list: { kind: "error", message: "tidy_unavailable:记忆整理候选未接线" } })
|
||||||
|
);
|
||||||
|
ok(has(listErrHtml, "tidy_unavailable"), "必须显示后端给的原因");
|
||||||
|
ok(has(listErrHtml, TIDY_CANDIDATE_LIST_ERROR_HINT), "要说清「没读到」不是「没有候选」");
|
||||||
|
ok(!has(listErrHtml, "没有待定候选"), "读失败时绝不得渲染空态文案(把失败当结论)");
|
||||||
|
ok(has(render(viewState({ list: { kind: "loading" } })), "加载候选…"), "取数中要显示加载态");
|
||||||
|
|
||||||
|
// ── ⑥ 两个写动作必须先二次确认 ────────────────────────────────────────────
|
||||||
|
const idleHtml = render(viewState());
|
||||||
|
ok(has(idleHtml, "批量应用全部待定") && has(idleHtml, "手动真跑一轮"), "顶部必须给出两个写动作");
|
||||||
|
ok(!has(idleHtml, "确认批量应用"), "没点按钮时不得出现确认块");
|
||||||
|
const confirmBatchHtml = render(viewState({ confirming: "batch" }));
|
||||||
|
ok(has(confirmBatchHtml, "确认批量应用"), "批量应用要先展开二次确认");
|
||||||
|
ok(has(confirmBatchHtml, "行永不物理删除"), "确认文案要说清软动作与可回滚");
|
||||||
|
const confirmRunHtml = render(viewState({ confirming: "run" }));
|
||||||
|
ok(has(confirmRunHtml, "确认真跑一轮"), "真跑也要先二次确认");
|
||||||
|
ok(has(confirmRunHtml, "不自动应用"), "真跑确认文案要说清「不自动应用」");
|
||||||
|
|
||||||
|
// ── ⑦ 手动真跑回执:建议侧计数 + 只产候选 ──────────────────────────────────
|
||||||
|
const RUN: AdminTidyRunResult = {
|
||||||
|
runId: "run-20261005-1018",
|
||||||
|
trigger: "manual",
|
||||||
|
ruleVersion: "t0.1",
|
||||||
|
durationMs: 1234,
|
||||||
|
partitions: 3,
|
||||||
|
scanned: 736,
|
||||||
|
compared: 400,
|
||||||
|
pairs: 12,
|
||||||
|
vetoed: 4,
|
||||||
|
errors: 0,
|
||||||
|
truncated: [],
|
||||||
|
vetoReasons: { scope: 2 },
|
||||||
|
candidatesCreated: 42,
|
||||||
|
candidatesDuplicate: 7,
|
||||||
|
candidatesByAction: { merge: 30, link: 12 },
|
||||||
|
};
|
||||||
|
const runHtml = render(viewState({ run: RUN }));
|
||||||
|
ok(has(runHtml, "只产候选,未自动应用"), "真跑回执必须点明不自动应用");
|
||||||
|
ok(has(runHtml, "run-20261005-1018"), "必须显示 runId(批量应用按轮次要用它)");
|
||||||
|
ok(has(runHtml, "新增候选 42 条"), "必须显示新建候选数");
|
||||||
|
ok(has(runHtml, "幂等去重 7 条"), "必须显示被幂等键挡下的数量");
|
||||||
|
ok(has(runHtml, "并入=30") && has(runHtml, "只建链接=12"), "按动作分布要用人话标签");
|
||||||
|
const runErrHtml = render(viewState({ run: { ...RUN, errors: 2 } }));
|
||||||
|
ok(has(runErrHtml, "本轮有 2 处失败"), "真跑里有失败必须如实显示");
|
||||||
|
|
||||||
|
// ── ⑧ 写动作失败要显示后端文案(不自己编)──────────────────────────────────
|
||||||
|
const failHtml = render(viewState({ fail: "tidy_disabled:记忆整理已被配置关闭" }));
|
||||||
|
ok(has(failHtml, "tidy_disabled"), "写动作失败要显示后端 message");
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`MemoryTidyApplyCard.render: ${checks} checks passed`);
|
||||||
@@ -0,0 +1,448 @@
|
|||||||
|
import {
|
||||||
|
TIDY_APPLY_STATUS_APPLIED,
|
||||||
|
TIDY_APPLY_STATUS_FAILED,
|
||||||
|
TIDY_APPLY_STATUS_SKIPPED,
|
||||||
|
TIDY_STATUS_APPLIED,
|
||||||
|
TIDY_STATUS_PENDING,
|
||||||
|
} from "../../lib/api";
|
||||||
|
import type {
|
||||||
|
AdminTidyApplyBatch,
|
||||||
|
AdminTidyApplyResult,
|
||||||
|
AdminTidyCandidateRecord,
|
||||||
|
AdminTidyRunResult,
|
||||||
|
} from "../../lib/types";
|
||||||
|
import { Button, Surface, Tag } from "../../components/primitives";
|
||||||
|
import { AdminTable, Notice, type AdminColumn } from "../components";
|
||||||
|
import {
|
||||||
|
useTidyApplyState,
|
||||||
|
type TidyApplyActions,
|
||||||
|
type TidyApplyViewState,
|
||||||
|
type TidyConfirmKind,
|
||||||
|
} from "./memoryTidyApplyState";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记忆整理「候选」卡的**展示**半边(切片 TIDY-1;取数与写动作在
|
||||||
|
* `memoryTidyApplyState.ts`,拆开只为守 AGENTS §10 的文件行数上限)。
|
||||||
|
*
|
||||||
|
* 动因是实测缺口:后端已经有候选列表 / 单条应用 / 批量应用 / 单条回滚 / 手动真跑,
|
||||||
|
* 面板却只有一张只读试算卡 —— 于是候选全 pending、一条都应用不了。本卡把「点得到」
|
||||||
|
* 这件事补上,四条不可走样的口径:
|
||||||
|
*
|
||||||
|
* 1. **权威键只读**:一切状态与计数只从响应里取。界面**不**推断「应该成功了」——
|
||||||
|
* 单条回执按 `status` 如实显示 applied / skipped / failed;批量回执按
|
||||||
|
* `applied/skipped/failed/total/truncated` 如实显示。
|
||||||
|
* 2. **skipped 不是错误**:幂等命中(已决定过)与 link/keep(本就没有执行语义)
|
||||||
|
* 都是 200,面板用中性标签显示,**绝不报红**。
|
||||||
|
* 3. **超限绝不静默**:`truncated=true` 时必须说出「命中多少 / 本轮处理多少 /
|
||||||
|
* 还剩多少未处理」,不许画出「全部完成」的样子。
|
||||||
|
* 4. **回滚只在已应用时可用**:按候选 `status` 决定按钮可用性(不是按「我记得刚
|
||||||
|
* 点过应用」)。未知值一律原样显示,不猜、不吞。
|
||||||
|
*
|
||||||
|
* 展示与取数分层:`MemoryTidyApplyBody` 只吃状态(真实渲染测试直接喂 fixture),
|
||||||
|
* `MemoryTidyApplyCard` 接取数钩子。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 诚实文案:这张卡会真的改记忆(与试算卡的「一行都不写」相对)。 */
|
||||||
|
export const TIDY_APPLY_DISCLAIMER = "会真的改动记忆(软合并 / 归档 / 改分类,可回滚)";
|
||||||
|
|
||||||
|
/** 列表读失败时的说明:区分「没读到」与「没有候选」。 */
|
||||||
|
export const TIDY_CANDIDATE_LIST_ERROR_HINT =
|
||||||
|
"这是「没读到」,不是「没有候选」—— 整理写面未接线时后端回 500 tidy_unavailable,绝不回空列表";
|
||||||
|
|
||||||
|
/** 批量回执被上限截断时的定性(测试钉住它必须出现)。 */
|
||||||
|
export const TIDY_APPLY_TRUNCATED_HINT = "命中数大于本轮处理数";
|
||||||
|
|
||||||
|
/** 批量回执「处理完了」的标记 —— 只有 `truncated=false` 时才可以出现。 */
|
||||||
|
export const TIDY_APPLY_ALL_DONE_TAG = "待定候选已全部处理";
|
||||||
|
|
||||||
|
/** skipped 的定性(幂等 / 不可应用都不是失败)。 */
|
||||||
|
export const TIDY_APPLY_SKIPPED_HINT =
|
||||||
|
"跳过不是错误:已决定过的候选重复应用会幂等跳过,link/keep 本就没有执行语义";
|
||||||
|
|
||||||
|
/** 批量应用的二次确认文案(写动作走既有确认惯例)。 */
|
||||||
|
const CONFIRM_BATCH_HINT =
|
||||||
|
"会把全部待定候选真正落地:merge = 软合并(被吸收行标记 merged,正文一个字都不改)、archive = 软删(行保留)、reclassify = 改 kind。行永不物理删除,因此每一步都能回滚。确定继续?";
|
||||||
|
|
||||||
|
/** 手动真跑的二次确认文案。 */
|
||||||
|
const CONFIRM_RUN_HINT =
|
||||||
|
"立刻按与低峰轮同一套规则与配置跑一轮:写候选 + 写运行留痕,不自动应用任何候选。跑完在下面的待定列表里逐条决定。确定继续?";
|
||||||
|
|
||||||
|
const actionLabel: Record<string, string> = {
|
||||||
|
merge: "并入",
|
||||||
|
link: "只建链接",
|
||||||
|
archive: "归档",
|
||||||
|
reclassify: "改分类",
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionTone: Record<string, "ok" | "warn" | "accent" | "neutral"> = {
|
||||||
|
merge: "warn",
|
||||||
|
link: "accent",
|
||||||
|
archive: "neutral",
|
||||||
|
reclassify: "neutral",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 候选状态的中文标签(真源只有 pending / applied;未知值原样显示)。 */
|
||||||
|
const statusLabel: Record<string, string> = {
|
||||||
|
pending: "待定",
|
||||||
|
applied: "已应用",
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusTone: Record<string, "ok" | "warn" | "accent" | "neutral"> = {
|
||||||
|
pending: "accent",
|
||||||
|
applied: "ok",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 未知值原样显示(后端加值不能丢信息)。 */
|
||||||
|
function label(table: Record<string, string>, value: string): string {
|
||||||
|
return table[value] ?? value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** shortTime 把 RFC3339 裁成「日期 + 时分」(秒与时区对判读候选新旧没有信息量)。 */
|
||||||
|
function shortTime(iso: string): string {
|
||||||
|
return iso.length >= 16 ? `${iso.slice(0, 10)} ${iso.slice(11, 16)}` : iso;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** WarnNote 是**黄牌提示**:截断 / 未处理完这类「真实但不致命」的话用它。
|
||||||
|
*
|
||||||
|
* ⚠️ 刻意**不用**共用的 `Notice tone="warn"` —— 那个组件会把 tone 渲染成固定前缀
|
||||||
|
* 「待实现」,贴在「本轮还有 N 条未处理」上会被读成「这功能还没做」(措辞失实)。
|
||||||
|
* 真失败仍走 `Notice tone="err"`(它的「错误」前缀是对的)。 */
|
||||||
|
function WarnNote({ children }: { children: React.ReactNode }): React.ReactNode {
|
||||||
|
return (
|
||||||
|
<div className="rounded-[var(--mu-radius)] border border-[var(--mu-warn)]/40 bg-[var(--mu-warn)]/10 px-3 py-2 text-xs text-[var(--mu-text-mid)]">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 候选动作标签。 */
|
||||||
|
function ActionTag({ action }: { action: string }): React.ReactNode {
|
||||||
|
return <Tag tone={actionTone[action] ?? "neutral"}>{label(actionLabel, action)}</Tag>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 候选状态标签(只按后端给的值渲染,不回推)。 */
|
||||||
|
function StatusTag({ status }: { status: string }): React.ReactNode {
|
||||||
|
return <Tag tone={statusTone[status] ?? "neutral"}>{label(statusLabel, status)}</Tag>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 摘要单元格:优先存活条目原文,其次被吸收条目原文,截断显示(title 给全文)。 */
|
||||||
|
function SummaryCell({ candidate }: { candidate: AdminTidyCandidateRecord }): React.ReactNode {
|
||||||
|
const text = candidate.mergedContent || candidate.proposedContent || "";
|
||||||
|
if (!text) {
|
||||||
|
return <span className="text-[11px] text-[var(--mu-text-lo)]">(该动作无正文预览)</span>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="block max-w-md truncate text-xs text-[var(--mu-text-mid)]" title={text}>
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一行的两个动作按钮:**回滚只在 status=applied 时可用**。 */
|
||||||
|
function CandidateActions({
|
||||||
|
candidate,
|
||||||
|
busy,
|
||||||
|
onApply,
|
||||||
|
onRevert,
|
||||||
|
}: {
|
||||||
|
candidate: AdminTidyCandidateRecord;
|
||||||
|
busy: string | null;
|
||||||
|
onApply: (id: string) => void;
|
||||||
|
onRevert: (id: string) => void;
|
||||||
|
}): React.ReactNode {
|
||||||
|
const applied = candidate.status === TIDY_STATUS_APPLIED;
|
||||||
|
const working = busy !== null;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Button size="sm" variant="primary" disabled={working} onClick={() => onApply(candidate.id)}>
|
||||||
|
{busy === `apply:${candidate.id}` ? "应用中…" : "应用"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={working || !applied}
|
||||||
|
title={applied ? "把这条候选的改动复原(候选退回待定)" : "只有已应用的候选才能回滚"}
|
||||||
|
onClick={() => onRevert(candidate.id)}
|
||||||
|
>
|
||||||
|
{busy === `revert:${candidate.id}` ? "回滚中…" : "回滚"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单条应用/回滚的回执(按后端 `status` 如实分色;未知状态原样显示)。 */
|
||||||
|
function SingleReceipt({ result }: { result: AdminTidyApplyResult }): React.ReactNode {
|
||||||
|
const applied = result.status === TIDY_APPLY_STATUS_APPLIED;
|
||||||
|
const skipped = result.status === TIDY_APPLY_STATUS_SKIPPED;
|
||||||
|
const failed = result.status === TIDY_APPLY_STATUS_FAILED;
|
||||||
|
const tone = applied ? "ok" : failed ? "err" : "neutral";
|
||||||
|
const text = applied
|
||||||
|
? `已应用(改动 ${result.affectedIds.length} 行)`
|
||||||
|
: skipped
|
||||||
|
? "已跳过(幂等命中或不可应用,不是错误)"
|
||||||
|
: failed
|
||||||
|
? "应用失败"
|
||||||
|
: `未知状态 ${result.status}`;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||||
|
<Tag tone={tone}>{text}</Tag>
|
||||||
|
<span className="font-mono text-[11px] text-[var(--mu-text-lo)]">{result.candidateId}</span>
|
||||||
|
{result.reason ? (
|
||||||
|
<span className="font-mono text-[11px] text-[var(--mu-text-lo)]">
|
||||||
|
reason={result.reason}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<span className="text-[11px] text-[var(--mu-text-lo)]">
|
||||||
|
受影响行:{result.affectedIds.length ? result.affectedIds.join("、") : "无"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量应用回执:计数全部取自响应,`truncated` 时如实说「还剩多少未处理」。 */
|
||||||
|
function BatchReceipt({ batch }: { batch: AdminTidyApplyBatch }): React.ReactNode {
|
||||||
|
const processed = batch.applied + batch.skipped + batch.failed;
|
||||||
|
const remaining = batch.total - processed;
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||||
|
<Tag tone="accent">批量应用回执</Tag>
|
||||||
|
<span className="text-[var(--mu-text-mid)]">
|
||||||
|
命中待定 {batch.total} 条 · 本轮处理 {processed} 条 · 单轮上限 {batch.limit}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3 text-xs text-[var(--mu-text-mid)]">
|
||||||
|
<span>
|
||||||
|
应用 <b>{batch.applied}</b>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
跳过 <b>{batch.skipped}</b>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
失败 <b>{batch.failed}</b>
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-[var(--mu-text-lo)]">
|
||||||
|
其中合并 {batch.merged} / 归档 {batch.archived} / 改分类 {batch.reclassified}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{batch.truncated ? (
|
||||||
|
<WarnNote>
|
||||||
|
{TIDY_APPLY_TRUNCATED_HINT}:命中 {batch.total} 条,本轮只处理 {processed} 条(单轮上限{" "}
|
||||||
|
{batch.limit}),还有 {remaining} 条待定未处理 —— 再点一次继续。
|
||||||
|
</WarnNote>
|
||||||
|
) : (
|
||||||
|
<Tag tone="ok">{TIDY_APPLY_ALL_DONE_TAG}</Tag>
|
||||||
|
)}
|
||||||
|
{batch.skipped > 0 ? (
|
||||||
|
<span className="block text-[11px] text-[var(--mu-text-lo)]">
|
||||||
|
{TIDY_APPLY_SKIPPED_HINT}(本轮 {batch.skipped} 条)。
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{batch.failed > 0 ? (
|
||||||
|
<Notice tone="err">
|
||||||
|
失败 {batch.failed} 条(失败隔离:其余候选照常处理,逐条原因见后端审计 memory.tidy.apply)。
|
||||||
|
</Notice>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 手动真跑回执:它是**建议侧**计数 —— 只产候选,未自动应用。 */
|
||||||
|
function RunReceipt({ run }: { run: AdminTidyRunResult }): React.ReactNode {
|
||||||
|
const byAction = Object.entries(run.candidatesByAction ?? {}).sort((a, b) =>
|
||||||
|
a[0] < b[0] ? -1 : 1
|
||||||
|
);
|
||||||
|
const byActionText = byAction.length
|
||||||
|
? byAction.map(([action, count]) => `${label(actionLabel, action)}=${count}`).join(" / ")
|
||||||
|
: "无";
|
||||||
|
return (
|
||||||
|
<div className="space-y-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||||
|
<Tag tone="ok">真跑完成(只产候选,未自动应用)</Tag>
|
||||||
|
<span className="font-mono text-[11px] text-[var(--mu-text-lo)]">runId={run.runId}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-[var(--mu-text-mid)]">
|
||||||
|
新增候选 {run.candidatesCreated} 条({byActionText})· 幂等去重 {run.candidatesDuplicate} 条 ·
|
||||||
|
扫描 {run.scanned} / 比对 {run.compared} / 召回 {run.pairs} 对 / 否决 {run.vetoed} · 用时{" "}
|
||||||
|
{run.durationMs} ms · 规则 {run.ruleVersion} · 分区 {run.partitions}
|
||||||
|
</div>
|
||||||
|
{run.errors > 0 ? (
|
||||||
|
<Notice tone="err">
|
||||||
|
本轮有 {run.errors} 处失败(分区或候选写入):已写下的候选仍然有效。
|
||||||
|
</Notice>
|
||||||
|
) : null}
|
||||||
|
{run.truncated && run.truncated.length > 0 ? (
|
||||||
|
<WarnNote>
|
||||||
|
本轮被护栏截断的分区:{run.truncated.join("、")} —— 这一轮看到的比库里少。
|
||||||
|
</WarnNote>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 二次确认块(批量应用 / 真跑共用;写动作先确认再发请求)。 */
|
||||||
|
function ConfirmBlock({
|
||||||
|
kind,
|
||||||
|
busy,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
kind: TidyConfirmKind;
|
||||||
|
busy: boolean;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}): React.ReactNode {
|
||||||
|
const isBatch = kind === "batch";
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] p-3">
|
||||||
|
<p className="text-xs text-[var(--mu-text-mid)]">
|
||||||
|
{isBatch ? CONFIRM_BATCH_HINT : CONFIRM_RUN_HINT}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button size="sm" variant={isBatch ? "danger" : "primary"} disabled={busy} onClick={onConfirm}>
|
||||||
|
{busy ? "处理中…" : isBatch ? "确认批量应用" : "确认真跑一轮"}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" disabled={busy} onClick={onCancel}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 顶部条:状态筛选 + 两个写动作(含二次确认)。 */
|
||||||
|
function Toolbar({
|
||||||
|
state,
|
||||||
|
actions,
|
||||||
|
}: {
|
||||||
|
state: TidyApplyViewState;
|
||||||
|
actions: TidyApplyActions;
|
||||||
|
}): React.ReactNode {
|
||||||
|
const working = state.busy !== null;
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={state.status}
|
||||||
|
onChange={(e) => actions.onStatusChange(e.target.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)]"
|
||||||
|
>
|
||||||
|
<option value={TIDY_STATUS_PENDING}>待定(可应用)</option>
|
||||||
|
<option value={TIDY_STATUS_APPLIED}>已应用(可回滚)</option>
|
||||||
|
</select>
|
||||||
|
<span className="text-[11px] text-[var(--mu-text-lo)]">
|
||||||
|
筛选就是后端查询条件本身:只有 pending / applied 两个真值(后端把空筛选按 pending 处理,因此这里不提供「全部」)
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto flex items-center gap-2">
|
||||||
|
<Button size="sm" variant="soft" disabled={working} onClick={() => actions.onRequest("batch")}>
|
||||||
|
批量应用全部待定
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" disabled={working} onClick={() => actions.onRequest("run")}>
|
||||||
|
手动真跑一轮
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{state.confirming ? (
|
||||||
|
<ConfirmBlock
|
||||||
|
kind={state.confirming}
|
||||||
|
busy={working}
|
||||||
|
onConfirm={actions.onConfirm}
|
||||||
|
onCancel={actions.onCancelConfirm}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 候选表(取数三态:加载 / 失败 / 就绪;空态按当前筛选给不同文案)。 */
|
||||||
|
function CandidatesSection({
|
||||||
|
state,
|
||||||
|
actions,
|
||||||
|
}: {
|
||||||
|
state: TidyApplyViewState;
|
||||||
|
actions: TidyApplyActions;
|
||||||
|
}): React.ReactNode {
|
||||||
|
const { list } = state;
|
||||||
|
if (list.kind === "loading") {
|
||||||
|
return <div className="px-3 py-6 text-center text-xs text-[var(--mu-text-lo)]">加载候选…</div>;
|
||||||
|
}
|
||||||
|
if (list.kind === "error") {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Notice tone="err">读取候选失败:{list.message}</Notice>
|
||||||
|
<span className="text-[11px] text-[var(--mu-text-lo)]">{TIDY_CANDIDATE_LIST_ERROR_HINT}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const columns: AdminColumn<AdminTidyCandidateRecord>[] = [
|
||||||
|
{ key: "action", title: "动作", render: (c) => <ActionTag action={c.action} /> },
|
||||||
|
{ key: "status", title: "状态", render: (c) => <StatusTag status={c.status} /> },
|
||||||
|
{ key: "summary", title: "摘要", render: (c) => <SummaryCell candidate={c} /> },
|
||||||
|
{
|
||||||
|
key: "reason",
|
||||||
|
title: "理由",
|
||||||
|
render: (c) => <span className="font-mono text-[11px] text-[var(--mu-text-lo)]">{c.reason}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "createdAt",
|
||||||
|
title: "时间",
|
||||||
|
render: (c) => (
|
||||||
|
<span className="font-mono text-[11px] text-[var(--mu-text-lo)]">{shortTime(c.createdAt)}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: "ops", title: "操作", render: (c) => <CandidateActions candidate={c} busy={state.busy} onApply={actions.onApply} onRevert={actions.onRevert} /> },
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<AdminTable
|
||||||
|
columns={columns}
|
||||||
|
rows={list.data.candidates}
|
||||||
|
loading={false}
|
||||||
|
error={null}
|
||||||
|
emptyTitle={
|
||||||
|
state.status === TIDY_STATUS_APPLIED
|
||||||
|
? "没有已应用的候选:先在「待定」列表里点「应用」"
|
||||||
|
: "没有待定候选:点「手动真跑一轮」产出候选(低峰时段那一轮还没到也能跑)"
|
||||||
|
}
|
||||||
|
rowKey={(c) => c.id}
|
||||||
|
/>
|
||||||
|
<span className="block text-[11px] text-[var(--mu-text-lo)]">
|
||||||
|
本页返回 {list.data.count} 条(后端列表默认上限 100、钳到 500;「到底有多少待定」的权威数字在批量回执的命中数里,本页不把它冒充成全部)。
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MemoryTidyApplyBody 是候选卡的纯展示体(测试直接喂状态与动作替身)。 */
|
||||||
|
export function MemoryTidyApplyBody({
|
||||||
|
state,
|
||||||
|
actions,
|
||||||
|
}: {
|
||||||
|
state: TidyApplyViewState;
|
||||||
|
actions: TidyApplyActions;
|
||||||
|
}): React.ReactNode {
|
||||||
|
return (
|
||||||
|
<Surface className="space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<h3 className="text-sm font-semibold">记忆整理候选(应用 / 回滚)</h3>
|
||||||
|
<Tag tone="accent">{TIDY_APPLY_DISCLAIMER}</Tag>
|
||||||
|
<Tag tone="warn">写操作 · 已审计</Tag>
|
||||||
|
<span className="text-xs text-[var(--mu-text-lo)]">
|
||||||
|
GET/POST /api/v1/admin/memories/candidates…
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Toolbar state={state} actions={actions} />
|
||||||
|
{state.fail ? <Notice tone="err">操作失败:{state.fail}</Notice> : null}
|
||||||
|
{state.batch ? <BatchReceipt batch={state.batch} /> : null}
|
||||||
|
{state.run ? <RunReceipt run={state.run} /> : null}
|
||||||
|
{state.last ? <SingleReceipt result={state.last} /> : null}
|
||||||
|
<CandidatesSection state={state} actions={actions} />
|
||||||
|
</Surface>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MemoryTidyApplyCard 是记忆管理页挂的候选卡(取数 + 写动作走 useTidyApplyState)。 */
|
||||||
|
export function MemoryTidyApplyCard(): React.ReactNode {
|
||||||
|
const { state, actions } = useTidyApplyState();
|
||||||
|
return <MemoryTidyApplyBody state={state} actions={actions} />;
|
||||||
|
}
|
||||||
@@ -25,6 +25,8 @@ import {
|
|||||||
MemoryTidyDryRunBody,
|
MemoryTidyDryRunBody,
|
||||||
TIDY_DRY_RUN_DISCLAIMER,
|
TIDY_DRY_RUN_DISCLAIMER,
|
||||||
TIDY_DRY_RUN_ERROR_HINT,
|
TIDY_DRY_RUN_ERROR_HINT,
|
||||||
|
TIDY_RECALL_UNAVAILABLE,
|
||||||
|
TIDY_RECALL_UNAVAILABLE_HINT,
|
||||||
type TidyDryRunState,
|
type TidyDryRunState,
|
||||||
} from "./MemoryTidyDryRunCard";
|
} from "./MemoryTidyDryRunCard";
|
||||||
|
|
||||||
@@ -96,7 +98,7 @@ ok(has(doneHtml, "0.950"), "必须显示分数(区间)");
|
|||||||
ok(has(doneHtml, "mem-short") && has(doneHtml, "保留 mem-long"), "必须显示「哪条 → 保留哪条」");
|
ok(has(doneHtml, "mem-short") && has(doneHtml, "保留 mem-long"), "必须显示「哪条 → 保留哪条」");
|
||||||
ok(has(doneHtml, "作息偏晚"), "必须显示合并后的存活条目原文(diff 预览)");
|
ok(has(doneHtml, "作息偏晚"), "必须显示合并后的存活条目原文(diff 预览)");
|
||||||
ok(has(doneHtml, "扫描 736") && has(doneHtml, "用时 42 ms"), "必须显示扫描口径与耗时");
|
ok(has(doneHtml, "扫描 736") && has(doneHtml, "用时 42 ms"), "必须显示扫描口径与耗时");
|
||||||
ok(has(doneHtml, "召回来源:embedding"), "必须显示召回来源(lexical 是降级信号)");
|
ok(has(doneHtml, "召回来源:语义向量(embedding)"), "必须显示召回来源(lexical 是降级信号)");
|
||||||
ok(has(doneHtml, "scope=1"), "必须显示否决分布(它解释「为什么没有更多建议」)");
|
ok(has(doneHtml, "scope=1"), "必须显示否决分布(它解释「为什么没有更多建议」)");
|
||||||
|
|
||||||
// ── 截断:必须说明「这一轮看到的比库里少 + 该调哪个键」 ─────────────────────
|
// ── 截断:必须说明「这一轮看到的比库里少 + 该调哪个键」 ─────────────────────
|
||||||
@@ -124,6 +126,42 @@ ok(!has(errorHtml, "扫描 "), "错误态绝不得渲染观测口径(扫描/
|
|||||||
ok(!has(errorHtml, "这一轮没有产生任何候选"), "错误态不得复用空态文案(两者含义不同)");
|
ok(!has(errorHtml, "这一轮没有产生任何候选"), "错误态不得复用空态文案(两者含义不同)");
|
||||||
ok(has(errorHtml, "把失败画成「0 条」"), "错误态反而要解释「为什么不显示 0 条」");
|
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" });
|
const idleHtml = render({ kind: "idle" });
|
||||||
ok(has(idleHtml, "试算整理建议"), "未跑时必须给出按钮文案");
|
ok(has(idleHtml, "试算整理建议"), "未跑时必须给出按钮文案");
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { adminMemoryTidyDryRun } from "../../lib/api";
|
import { ApiError, adminMemoryTidyDryRun } from "../../lib/api";
|
||||||
import type { AdminTidyDryRunCandidate, AdminTidyDryRunResult } from "../../lib/types";
|
import type { AdminTidyDryRunCandidate, AdminTidyDryRunResult } from "../../lib/types";
|
||||||
import { Button, Surface, Tag } from "../../components/primitives";
|
import { Button, Surface, Tag } from "../../components/primitives";
|
||||||
import { Notice } from "../components";
|
import { Notice } from "../components";
|
||||||
@@ -38,7 +38,18 @@ export type TidyDryRunState =
|
|||||||
| { kind: "idle" }
|
| { kind: "idle" }
|
||||||
| { kind: "running" }
|
| { kind: "running" }
|
||||||
| { kind: "done"; result: AdminTidyDryRunResult }
|
| { 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> = {
|
const actionLabel: Record<string, string> = {
|
||||||
merge: "并入",
|
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 渲染一次试算的完整结果(有候选 / 空 / 观测口径)。 */
|
/** ResultBody 渲染一次试算的完整结果(有候选 / 空 / 观测口径)。 */
|
||||||
function ResultBody({ result }: { result: AdminTidyDryRunResult }): React.ReactNode {
|
function ResultBody({ result }: { result: AdminTidyDryRunResult }): React.ReactNode {
|
||||||
const vetoReasons = result.vetoReasons ?? {};
|
const vetoReasons = result.vetoReasons ?? {};
|
||||||
@@ -119,7 +174,7 @@ function ResultBody({ result }: { result: AdminTidyDryRunResult }): React.ReactN
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<Tag tone="ok">算完了,一行都没写</Tag>
|
<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)]">
|
<span className="text-[11px] text-[var(--mu-text-lo)]">
|
||||||
扫描 {result.scanned} / 比对 {result.compared} / 召回 {result.pairs} 对 / 否决 {result.vetoed} 对 · 用时{" "}
|
扫描 {result.scanned} / 比对 {result.compared} / 召回 {result.pairs} 对 / 否决 {result.vetoed} 对 · 用时{" "}
|
||||||
{result.durationMs} ms · 规则 {result.ruleVersion} · 分区 {result.partitions}
|
{result.durationMs} ms · 规则 {result.ruleVersion} · 分区 {result.partitions}
|
||||||
@@ -174,6 +229,9 @@ function StateBody({ state }: { state: TidyDryRunState }): React.ReactNode {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Notice tone="err">试算失败:{state.message}</Notice>
|
<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)]">
|
<span className="text-[11px] text-[var(--mu-text-lo)]">
|
||||||
{TIDY_DRY_RUN_ERROR_HINT}。这里刻意不显示候选数 —— 把失败画成「0 条」就是把「没算出来」冒充成结论。
|
{TIDY_DRY_RUN_ERROR_HINT}。这里刻意不显示候选数 —— 把失败画成「0 条」就是把「没算出来」冒充成结论。
|
||||||
</span>
|
</span>
|
||||||
@@ -223,7 +281,11 @@ export function MemoryTidyDryRunCard(): React.ReactNode {
|
|||||||
})
|
})
|
||||||
.catch((e: unknown) => {
|
.catch((e: unknown) => {
|
||||||
log("memory.tidy.dryRun", "error");
|
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 { Button } from "../../components/primitives";
|
||||||
import {
|
import {
|
||||||
accountCandidates,
|
accountCandidates,
|
||||||
flattenAccounts,
|
draftAccounts,
|
||||||
groupAccounts,
|
groupAccounts,
|
||||||
platformIsConfigured,
|
platformIsConfigured,
|
||||||
platformOptions,
|
platformOptions,
|
||||||
@@ -13,11 +13,16 @@ import {
|
|||||||
/**
|
/**
|
||||||
* 渠道账号白名单编辑器(主人 2026-10-05 要求):**一个平台一条**,该平台下用
|
* 渠道账号白名单编辑器(主人 2026-10-05 要求):**一个平台一条**,该平台下用
|
||||||
* 「+ 添加账号 ID」加多行;平台是权威枚举**下拉**;账号 ID 旁给「从该平台已配置
|
* 「+ 添加账号 ID」加多行;平台是权威枚举**下拉**;账号 ID 旁给「从该平台已配置
|
||||||
* 账号里选」的候选。改动经 `onChange` 回到草稿时**立即展平**为
|
* 账号里选」的候选。改动经 `onChange` 回到草稿时**写回扁平数组**
|
||||||
* `accounts:[{platform,accountId}…]` —— 后端契约与 `owner.yaml` 格式完全不变。
|
* `accounts:[{platform,accountId}…]` —— 后端契约与 `owner.yaml` 格式完全不变。
|
||||||
*
|
*
|
||||||
* 为什么不在这里直接保存:档案的读取/草稿/保存状态由 `Users.tsx` 的
|
* 为什么不在这里直接保存:档案的读取/草稿/保存状态由 `Users.tsx` 的
|
||||||
* `OwnerProfileSection` 统一持有(一次真实 PUT),本组件只管呈现与展平。
|
* `OwnerProfileSection` 统一持有(一次真实 PUT),本组件只管呈现与写回草稿。
|
||||||
|
*
|
||||||
|
* 为什么拆成两个函数:`OwnerAccountsEditor` 只留「分组/选项」这层 memo(同一表单里改
|
||||||
|
* 别的字段时不必重算),界面本体 `OwnerAccountsView` 是**无 hook** 的 —— 本项目没有
|
||||||
|
* jsdom,测试正是**直接调用**它、点它自己的按钮来钉住「手势 → 草稿 → 重渲染」这条往返
|
||||||
|
* (见 `ownerAccounts.test.ts` ⑤)。往 View 里加 hook 会让那条测试报 "Invalid hook call"。
|
||||||
*/
|
*/
|
||||||
export function OwnerAccountsEditor({
|
export function OwnerAccountsEditor({
|
||||||
accounts,
|
accounts,
|
||||||
@@ -39,10 +44,39 @@ export function OwnerAccountsEditor({
|
|||||||
() => platformOptions(platforms, groups.map((g) => g.platform)),
|
() => platformOptions(platforms, groups.map((g) => g.platform)),
|
||||||
[platforms, groups]
|
[platforms, groups]
|
||||||
);
|
);
|
||||||
|
return (
|
||||||
|
<OwnerAccountsView
|
||||||
|
groups={groups}
|
||||||
|
options={options}
|
||||||
|
channels={channels}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 每次改动都「分组 → 展平 → 回草稿」:草稿里始终是后端要的扁平形态,
|
export interface OwnerAccountsViewProps {
|
||||||
// 界面上的分组只是它的投影,两者不会各说各话。
|
/** 界面上的分组(草稿的投影);手势产生的下一份**草稿**也由它算出来。 */
|
||||||
const emit = (next: OwnerAccountGroup[]): void => onChange(flattenAccounts(next));
|
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 =>
|
const patchGroup = (i: number, patch: Partial<OwnerAccountGroup>): void =>
|
||||||
emit(groups.map((g, idx) => (idx === i ? { ...g, ...patch } : g)));
|
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) => (
|
{group.accountIds.map((accountId, j) => (
|
||||||
<AccountIdRow
|
<AccountIdRow
|
||||||
key={`acct-${index}-${j}`}
|
key={`acct-${index}-${j}`}
|
||||||
|
row={j}
|
||||||
listId={`acct-${index}-${j}-candidates`}
|
listId={`acct-${index}-${j}-candidates`}
|
||||||
accountId={accountId}
|
accountId={accountId}
|
||||||
candidates={candidates}
|
candidates={candidates}
|
||||||
@@ -227,6 +262,8 @@ function PlatformPicker({
|
|||||||
|
|
||||||
interface AccountIdRowProps {
|
interface AccountIdRowProps {
|
||||||
listId: string;
|
listId: string;
|
||||||
|
/** 在本分组里的行号(`0` 起):给行一个稳定的定位标记,浏览器里/测试里都指得到具体那一行。 */
|
||||||
|
row: number;
|
||||||
accountId: string;
|
accountId: string;
|
||||||
candidates: string[];
|
candidates: string[];
|
||||||
platform: string;
|
platform: string;
|
||||||
@@ -239,6 +276,7 @@ interface AccountIdRowProps {
|
|||||||
/** 一行账号 ID:手填 + 该平台已配置账号的候选(点一下就填入)。 */
|
/** 一行账号 ID:手填 + 该平台已配置账号的候选(点一下就填入)。 */
|
||||||
function AccountIdRow({
|
function AccountIdRow({
|
||||||
listId,
|
listId,
|
||||||
|
row,
|
||||||
accountId,
|
accountId,
|
||||||
candidates,
|
candidates,
|
||||||
platform,
|
platform,
|
||||||
@@ -248,7 +286,7 @@ function AccountIdRow({
|
|||||||
onRemove,
|
onRemove,
|
||||||
}: AccountIdRowProps): React.ReactNode {
|
}: AccountIdRowProps): React.ReactNode {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1" data-account-row={row}>
|
||||||
<div className="flex flex-wrap items-end gap-2">
|
<div className="flex flex-wrap items-end gap-2">
|
||||||
<label className="block min-w-56 flex-1 space-y-1">
|
<label className="block min-w-56 flex-1 space-y-1">
|
||||||
<span className="text-xs text-[var(--mu-text-lo)]">账号 ID</span>
|
<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 { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
|
||||||
import { Button, Surface, Switch, Tag } from "../../components/primitives";
|
import { Button, Surface, Switch, Tag } from "../../components/primitives";
|
||||||
import { InstalledPluginsPanel } from "./plugins/InstalledPlugins";
|
import { InstalledPluginsPanel } from "./plugins/InstalledPlugins";
|
||||||
|
import {
|
||||||
|
mcpServerEntries,
|
||||||
|
originGroupTitle,
|
||||||
|
otherCapabilityEntries,
|
||||||
|
toolOriginBuckets,
|
||||||
|
} from "./pluginOrigin";
|
||||||
|
|
||||||
type CapKind = "tool" | "skill" | "channel";
|
type CapKind = "tool" | "skill" | "channel";
|
||||||
|
|
||||||
@@ -16,6 +22,9 @@ const KIND_LABELS: Record<CapKind, string> = {
|
|||||||
channel: "渠道 Channel",
|
channel: "渠道 Channel",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 指向「MCP 服务器」页的提示文案:server 级动作(启停/重连/卸载)都在那边。 */
|
||||||
|
const MCP_PAGE_HINT = "在「MCP 服务器」页卸载该 server";
|
||||||
|
|
||||||
// Channel adapters are not gated by the tool policy (group/stranger markers are a
|
// 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
|
// tool concept; a channel is configured via its own 渠道管理 page), so the
|
||||||
// channel block shows identity + a pointer rather than the policy switches.
|
// channel block shows identity + a pointer rather than the policy switches.
|
||||||
@@ -27,18 +36,17 @@ 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.
|
||||||
function registeredColumns(
|
*
|
||||||
|
* 它对**工具名**生效(`tool_policies` 以 name 为键),因此来自 MCP server 的工具有同
|
||||||
|
* 一套开关;`mcp:<server>` 那条 KindMCP 条目拿不到开关(策略判定从不读它),所以在
|
||||||
|
* MCP 登记段里不给开关 —— 假开关比没有开关更糟。 */
|
||||||
|
function policyColumn(
|
||||||
isOwner: boolean,
|
isOwner: boolean,
|
||||||
busy: string | null,
|
busy: string | null,
|
||||||
setPolicy: (name: string, allowInGroup: boolean, allowInStranger: boolean) => void,
|
setPolicy: (name: string, allowInGroup: boolean, allowInStranger: boolean) => void,
|
||||||
uninstall: (name: string) => void,
|
): AdminColumn<AdminPluginRecord> {
|
||||||
): AdminColumn<AdminPluginRecord>[] {
|
return {
|
||||||
return [
|
|
||||||
{ 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",
|
key: "policy",
|
||||||
title: "环境授权(owner)",
|
title: "环境授权(owner)",
|
||||||
render: (p) => (
|
render: (p) => (
|
||||||
@@ -62,7 +70,21 @@ function registeredColumns(
|
|||||||
{busy === p.name ? <span className="text-xs text-[var(--mu-text-lo)]">保存中…</span> : null}
|
{busy === p.name ? <span className="text-xs text-[var(--mu-text-lo)]">保存中…</span> : null}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Columns for a host-builtin tool / a skill row (uninstall = remove this capability). */
|
||||||
|
function registeredColumns(
|
||||||
|
isOwner: boolean,
|
||||||
|
busy: string | null,
|
||||||
|
setPolicy: (name: string, allowInGroup: boolean, allowInStranger: boolean) => void,
|
||||||
|
uninstall: (name: string) => void,
|
||||||
|
): AdminColumn<AdminPluginRecord>[] {
|
||||||
|
return [
|
||||||
|
{ 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> },
|
||||||
|
policyColumn(isOwner, busy, setPolicy),
|
||||||
{
|
{
|
||||||
key: "actions",
|
key: "actions",
|
||||||
title: "操作",
|
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 {
|
export function PluginsModule(): React.ReactNode {
|
||||||
const { data, loading, error, reload } = useAsyncData(adminListPlugins, []);
|
const { data, loading, error, reload } = useAsyncData(adminListPlugins, []);
|
||||||
useAutoReload(reload, 10000);
|
useAutoReload(reload, 10000);
|
||||||
@@ -125,14 +202,22 @@ export function PluginsModule(): React.ReactNode {
|
|||||||
return groups;
|
return groups;
|
||||||
}, [data]);
|
}, [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 channelCols = channelColumns();
|
||||||
|
const serverCols = mcpServerColumns();
|
||||||
|
const otherCols = otherColumns();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="插件与能力"
|
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}
|
{!isOwner ? <Notice tone="warn">当前角色为 {getStoredRole()},工具策略/插件安装仅 owner 可写(读取/查看可见)。</Notice> : null}
|
||||||
@@ -144,28 +229,66 @@ export function PluginsModule(): React.ReactNode {
|
|||||||
<Surface className="space-y-3">
|
<Surface className="space-y-3">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<h3 className="text-sm font-semibold">已注册能力</h3>
|
<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>
|
</div>
|
||||||
|
|
||||||
{(["tool", "skill"] as CapKind[]).map((kind) => {
|
<div className="space-y-1.5">
|
||||||
const rows = byKind[kind];
|
|
||||||
return (
|
|
||||||
<div key={kind} className="space-y-1.5">
|
|
||||||
<div className="flex items-center gap-2 pt-1">
|
<div className="flex items-center gap-2 pt-1">
|
||||||
<Tag tone="accent">{KIND_LABELS[kind]}</Tag>
|
<Tag tone="accent">{KIND_LABELS.tool}</Tag>
|
||||||
<span className="text-xs text-[var(--mu-text-lo)]">{rows.length} 个</span>
|
<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>
|
</div>
|
||||||
<AdminTable
|
<AdminTable
|
||||||
columns={toolSkillColumns}
|
columns={mcpToolCols}
|
||||||
rows={loading ? null : rows}
|
rows={loading ? null : group.rows}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
error={error}
|
error={error}
|
||||||
emptyTitle={`无${KIND_LABELS[kind]}`}
|
emptyTitle="无工具"
|
||||||
rowKey={(p) => `${kind}:${p.name}`}
|
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>
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<div className="flex items-center gap-2 pt-1">
|
<div className="flex items-center gap-2 pt-1">
|
||||||
@@ -181,9 +304,48 @@ export function PluginsModule(): React.ReactNode {
|
|||||||
rowKey={(p) => `channel:${p.name}`}
|
rowKey={(p) => `channel:${p.name}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</Surface>
|
||||||
|
|
||||||
<Notice>工具 schema / 技能为后端 M10+ 提供;此页按类型分组展示已注册能力。群聊/陌生人环境可见性标记(默认关闭)仅对工具/技能生效。</Notice>
|
<Notice>
|
||||||
|
工具按「注册时」的来源分节(来自哪台 MCP server 由后端盖章,不解析工具名);这是「呈现」分组,权限仍由
|
||||||
|
tool_policies 的逐工具环境授权决定。MCP server 的启停/重连/卸载/安装都在「MCP 服务器」页 ——
|
||||||
|
单把 MCP 工具不能单独卸载(否则 server 侧的工具账本会不同步)。
|
||||||
|
</Notice>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -20,6 +20,7 @@ import type {
|
|||||||
import { useAuditStore } from "../audit";
|
import { useAuditStore } from "../audit";
|
||||||
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
|
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
|
||||||
import { OwnerAccountsEditor } from "./OwnerAccountsEditor";
|
import { OwnerAccountsEditor } from "./OwnerAccountsEditor";
|
||||||
|
import { accountsForSave } from "./ownerAccounts";
|
||||||
import { Button, Surface, Tag } from "../../components/primitives";
|
import { Button, Surface, Tag } from "../../components/primitives";
|
||||||
|
|
||||||
const roleTone: Record<string, "accent" | "ok" | "neutral" | "warn"> = {
|
const roleTone: Record<string, "accent" | "ok" | "neutral" | "warn"> = {
|
||||||
@@ -166,7 +167,9 @@ function OwnerProfileSection({ isOwner }: { isOwner: boolean }): React.ReactNode
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
setFail(null);
|
setFail(null);
|
||||||
setNotice(null);
|
setNotice(null);
|
||||||
adminSaveOwnerProfile(profile)
|
// 草稿里允许有还没填完的空行(编辑期不能丢行),写盘前才收成写盘形态:只留平台与账号 ID
|
||||||
|
// 都非空的条目,并去掉两侧空白。既不把空行写进 owner.yaml,也不会因为半成品条目被后端 400。
|
||||||
|
adminSaveOwnerProfile({ ...profile, accounts: accountsForSave(profile.accounts) })
|
||||||
.then((saved) => {
|
.then((saved) => {
|
||||||
log("owner.profile.edit", "ok");
|
log("owner.profile.edit", "ok");
|
||||||
setProfile(saved);
|
setProfile(saved);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { AdminChannelAccount } from "../../../lib/types";
|
|||||||
import { Button, Tag } from "../../../components/primitives";
|
import { Button, Tag } from "../../../components/primitives";
|
||||||
import { authKeyOf } from "../channelFieldRules";
|
import { authKeyOf } from "../channelFieldRules";
|
||||||
import { adapterNameOf } from "./channelUtils";
|
import { adapterNameOf } from "./channelUtils";
|
||||||
import { accountActionGate, accountOnlineStatus, shortAccountID, type ChannelStatus } from "./channelStatus";
|
import { accountActionGate, accountOnlineStatus, channelConnectStateOf, connectStateView, shortAccountID, type ChannelStatus, type ChannelStatusTone } from "./channelStatus";
|
||||||
|
|
||||||
/** 一条渠道账号在活动适配器列表里的行。
|
/** 一条渠道账号在活动适配器列表里的行。
|
||||||
*
|
*
|
||||||
@@ -46,7 +46,7 @@ export function AccountRow({
|
|||||||
const raw = account.config?.[authKey];
|
const raw = account.config?.[authKey];
|
||||||
const authSet = typeof raw === "string" && raw.trim() !== "";
|
const authSet = typeof raw === "string" && raw.trim() !== "";
|
||||||
const gate = accountActionGate(status, account.connected);
|
const gate = accountActionGate(status, account.connected);
|
||||||
const state = connectionTag(account.connected);
|
const state = connectionTag(account);
|
||||||
const online = accountOnlineStatus(account.accountOnline);
|
const online = accountOnlineStatus(account.accountOnline);
|
||||||
const blocker = status.kind === "unknown" ? "读取不到连接状态(后端失联),已禁用" : undefined;
|
const blocker = status.kind === "unknown" ? "读取不到连接状态(后端失联),已禁用" : undefined;
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ export function AccountRow({
|
|||||||
id {short}
|
id {short}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<Tag tone={account.connected === true ? "ok" : account.connected === false ? "err" : "neutral"}>{state}</Tag>
|
<Tag tone={state.tone} title={state.hint}>{state.label}</Tag>
|
||||||
{/* 账号在**平台侧**的在线状态 —— 与上一枚「连接」标签说的是两件事:OneBot 的
|
{/* 账号在**平台侧**的在线状态 —— 与上一枚「连接」标签说的是两件事:OneBot 的
|
||||||
进程/WS 可以一直连着,而背后的 QQ 账号已掉线(消息收不到也发不出)。
|
进程/WS 可以一直连着,而背后的 QQ 账号已掉线(消息收不到也发不出)。
|
||||||
两枚标签刻意不合并:合并后「我们连着但账号掉了」这一个事故现场就无迹可寻。
|
两枚标签刻意不合并:合并后「我们连着但账号掉了」这一个事故现场就无迹可寻。
|
||||||
@@ -127,11 +127,23 @@ export function AccountRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 账号级连接状态的呈现:已连接 / 未连接 / 状态未知(后端没报告该账号的活适配器)。 */
|
/** 账号级连接状态的呈现:已连接 / 连接中 / 等待接入 / 未连接 / 状态未知
|
||||||
function connectionTag(connected: boolean | undefined): string {
|
* (后端没报告该账号的活适配器)。
|
||||||
if (connected === true) return "已连接";
|
*
|
||||||
if (connected === false) return "未连接";
|
* 「连接中」与「等待接入」来自后端自报的连接态三态(见 ChannelConnectState)。
|
||||||
return "状态未知";
|
* 没有它们时,一个正在后台重试的 ws+client 账号会被画成红色的「未连接」,主人就会
|
||||||
|
* 以为它坏了 —— 那正是 2026-10-07 事故的另一半(一半是弹「已连接」,一半是卡片
|
||||||
|
* 永远显示「未连接」)。两态都用非错误腔:它们不是结论,是进行中。 */
|
||||||
|
function connectionTag(account: AdminChannelAccount): {
|
||||||
|
label: string;
|
||||||
|
tone: ChannelStatusTone;
|
||||||
|
hint: string | undefined;
|
||||||
|
} {
|
||||||
|
if (account.connected === true) return { label: "已连接", tone: "ok", hint: undefined };
|
||||||
|
const view = connectStateView(channelConnectStateOf(account), account.reason);
|
||||||
|
if (view !== null) return { label: view.label, tone: view.tone, hint: view.hint };
|
||||||
|
if (account.connected === false) return { label: "未连接", tone: "err", hint: undefined };
|
||||||
|
return { label: "状态未知", tone: "neutral", hint: undefined };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 「本次会作用在哪一条账号」的小字。这是修掉「连了谁」这一困惑的关键文案:
|
/** 「本次会作用在哪一条账号」的小字。这是修掉「连了谁」这一困惑的关键文案:
|
||||||
|
|||||||
@@ -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 { adapterNameOf, configuredAccountCount, configuredPlatforms, platformOf } from "./channelUtils";
|
||||||
import { channelListState, channelStatus } from "./channelStatus";
|
import { channelListState, channelStatus } from "./channelStatus";
|
||||||
import { ChannelLoadError, ChannelStatusTag } from "./channelStatusView";
|
import { ChannelLoadError, ChannelStatusTag } from "./channelStatusView";
|
||||||
|
import { ChannelCoalesceCard } from "./CoalesceCard";
|
||||||
import type { AdapterNav } from "./useAdapterNav";
|
import type { AdapterNav } from "./useAdapterNav";
|
||||||
|
|
||||||
/** Page ① — aggregate all adapters: connection state, message volume, health.
|
/** 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" })}
|
onOpenList={() => navigate({ kind: "page", page: "list" })}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{/* 群聊静默窗口合并(M23):窗口/上限这类调优只该在面板改,不该 SSH 改文件。 */}
|
||||||
|
<ChannelCoalesceCard />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,4 +154,24 @@ function buttonHTML(html: string, label: string): string {
|
|||||||
ok(has(down, "账号在平台上"), "离线标签的悬停说明点明这是账号在平台上的状态");
|
ok(has(down, "账号在平台上"), "离线标签的悬停说明点明这是账号在平台上的状态");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── ⑤ 重试中 / 就绪等接入:不许画成红色的「未连接」 ────────────────────────────
|
||||||
|
// 2026-10-07 事故的另一半:点了「连接」之后卡片一直显示「未连接」。ws+client 的首连
|
||||||
|
// 失败刻意非致命(OneBot 实现可能比 Cyrene 后启动),所以那不是故障,是「正在连」;
|
||||||
|
// ws+server 则是「就绪等对端接入」。两者都必须与静态的「未连接」分开画。
|
||||||
|
{
|
||||||
|
const REASON = "qq-obv11: dial onebot ws ws://localhost:10301/: connection refused";
|
||||||
|
const retrying = renderRow({ id: "9616b27a", config: {}, connected: false, pending: true, reason: REASON });
|
||||||
|
ok(has(retrying, ">连接中</span>"), "重试中的账号画「连接中」");
|
||||||
|
ok(!has(retrying, ">未连接</span>"), "重试中不许画成「未连接」(它是进行中,不是结论)");
|
||||||
|
ok(has(retrying, REASON), "重试中的原因随标签悬停一并给出(说不出为什么就等于没说)");
|
||||||
|
|
||||||
|
const ready = renderRow({ id: "9616b27a", config: {}, connected: false, ready: true });
|
||||||
|
ok(has(ready, ">等待接入</span>"), "监听就绪的账号画「等待接入」");
|
||||||
|
ok(!has(ready, ">未连接</span>"), "就绪等接入不许画成「未连接」");
|
||||||
|
|
||||||
|
// 回归:后端没这条状态面的账号逐字不变。
|
||||||
|
const plain = renderRow({ id: "9616b27a", config: {}, connected: false });
|
||||||
|
ok(has(plain, ">未连接</span>"), "没有状态面的账号仍是「未连接」");
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`channel account row render test: ${checks} checks passed`);
|
console.log(`channel account row render test: ${checks} checks passed`);
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
// 「点了连接之后,面板到底说了什么」的真实断言(2026-10-07 UI 撒谎事故的回归测试)。
|
||||||
|
//
|
||||||
|
// 为什么必须存在:旧代码只按「请求成功」判断 —— `adminChannelOpen` 一 resolve 就弹
|
||||||
|
// 「已连接」,而 ws+client 的首连失败**刻意非致命**(OneBot 实现可能比 Cyrene 后启动),
|
||||||
|
// 于是「后端受理了」被说成了「渠道连上了」。这里不看组件、不靠记忆:既直接驱动
|
||||||
|
// `createChannelActions`(stub 掉 globalThis.fetch,读它真正写进 notice 的那句话),
|
||||||
|
// 也直接驱动卡片用的纯函数 `channelStatus`。
|
||||||
|
//
|
||||||
|
// 跑法(与 channelActions.test.ts 同款 esbuild + node,react-dom 的 CJS 需要 require):
|
||||||
|
//
|
||||||
|
// cd frontend/web \
|
||||||
|
// && ./node_modules/.bin/esbuild src/admin/modules/channels/channelConnectNotice.test.ts \
|
||||||
|
// --bundle --platform=node --format=cjs \
|
||||||
|
// --outfile=/tmp/cyrene-channel-connect-notice.test.cjs \
|
||||||
|
// && node /tmp/cyrene-channel-connect-notice.test.cjs
|
||||||
|
//
|
||||||
|
// `tsc --noEmit`(npm run build 的一部分)会对本文件做类型检查,因此它只从项目内
|
||||||
|
// 导入、不 import node:*。
|
||||||
|
|
||||||
|
import type { AdminChannelRecord } from "../../../lib/types";
|
||||||
|
import { channelConnectStateOf, channelStatus } from "./channelStatus";
|
||||||
|
import { connectDisconnectNotice, createChannelActions } from "./useChannelActions";
|
||||||
|
|
||||||
|
let checks = 0;
|
||||||
|
|
||||||
|
function ok(cond: boolean, what: string): void {
|
||||||
|
checks += 1;
|
||||||
|
if (!cond) throw new Error(`FAILED: ${what}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function notOk(cond: boolean, what: string): void {
|
||||||
|
ok(!cond, what);
|
||||||
|
}
|
||||||
|
|
||||||
|
function eq(got: unknown, want: unknown, what: string): void {
|
||||||
|
checks += 1;
|
||||||
|
if (got !== want) throw new Error(`FAILED: ${what}(got ${JSON.stringify(got)}, want ${JSON.stringify(want)})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const REASON = "qq-obv11: dial onebot ws ws://localhost:10301/: connection refused";
|
||||||
|
|
||||||
|
// ── ① 纯函数:提示文案只看**响应**,不看请求是否成功 ─────────────────────────────
|
||||||
|
{
|
||||||
|
// 本次事故的正题:请求 200 + connected:false + pending:true ⇒ 绝不是「已连接」。
|
||||||
|
const pending = connectDisconnectNotice(true, "qq-obv11", {
|
||||||
|
name: "qq-obv11",
|
||||||
|
connected: false,
|
||||||
|
pending: true,
|
||||||
|
reason: REASON,
|
||||||
|
});
|
||||||
|
notOk(pending.includes("已连接"), `重试中不许说「已连接」,got ${JSON.stringify(pending)}`);
|
||||||
|
ok(pending.includes("正在连接"), `重试中必须说「正在连接」,got ${JSON.stringify(pending)}`);
|
||||||
|
ok(pending.includes(REASON), `重试中必须带上原因,got ${JSON.stringify(pending)}`);
|
||||||
|
|
||||||
|
// 连上了才是「已连接」。
|
||||||
|
eq(
|
||||||
|
connectDisconnectNotice(true, "qq-obv11:9616b27a", { name: "qq-obv11", connected: true }),
|
||||||
|
"已连接 qq-obv11:9616b27a",
|
||||||
|
"真连上才说「已连接」(并回显实际驱动的适配器)",
|
||||||
|
);
|
||||||
|
|
||||||
|
// server 已就绪、等待对端接入:既不是「已连接」,也不是一句静态的「未连接」。
|
||||||
|
const ready = connectDisconnectNotice(true, "qq-obv11", { name: "qq-obv11", connected: false, ready: true });
|
||||||
|
notOk(ready.includes("已连接"), `就绪等接入不许说「已连接」,got ${JSON.stringify(ready)}`);
|
||||||
|
ok(ready.includes("已就绪"), `就绪等接入必须说清楚,got ${JSON.stringify(ready)}`);
|
||||||
|
|
||||||
|
// 后端没有这条状态面(两个键都缺):如实说「尚未连上」,绝不乐观。
|
||||||
|
const bare = connectDisconnectNotice(true, "kook", { name: "kook", connected: false });
|
||||||
|
notOk(bare.includes("已连接"), `连不上不许说「已连接」,got ${JSON.stringify(bare)}`);
|
||||||
|
ok(bare.includes("尚未连上"), `必须如实说尚未连上,got ${JSON.stringify(bare)}`);
|
||||||
|
|
||||||
|
// 断开同样不乐观:后端报连接仍在,就不能说「已断开」。
|
||||||
|
eq(
|
||||||
|
connectDisconnectNotice(false, "kook", { name: "kook", connected: false }),
|
||||||
|
"已断开 kook",
|
||||||
|
"真断开才说「已断开」",
|
||||||
|
);
|
||||||
|
const stuck = connectDisconnectNotice(false, "kook", { name: "kook", connected: true });
|
||||||
|
notOk(stuck.includes("已断开"), `仍未断开不许说「已断开」,got ${JSON.stringify(stuck)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ② 卡片状态:三态在**真实渲染逻辑**里与「未连接」分开 ────────────────────────
|
||||||
|
{
|
||||||
|
const pendingRecord: AdminChannelRecord = {
|
||||||
|
name: "qq-obv11",
|
||||||
|
connected: false,
|
||||||
|
pending: true,
|
||||||
|
reason: REASON,
|
||||||
|
};
|
||||||
|
const pending = channelStatus([pendingRecord], null, pendingRecord);
|
||||||
|
eq(pending.kind, "connecting", "重试中的卡片是 connecting,不是 disconnected");
|
||||||
|
eq(pending.label, "连接中", "重试中的卡片写「连接中」");
|
||||||
|
eq(pending.tone, "warn", "重试中用警示色而不是错误色(它不是故障)");
|
||||||
|
ok(pending.hint !== null && pending.hint.includes(REASON), "重试中的卡片带上原因");
|
||||||
|
notOk(pending.label.includes("未连接"), "重试中不许画成「未连接」");
|
||||||
|
|
||||||
|
const readyRecord: AdminChannelRecord = { name: "qq-obv11", connected: false, ready: true };
|
||||||
|
const ready = channelStatus([readyRecord], null, readyRecord);
|
||||||
|
eq(ready.kind, "ready", "就绪等接入的卡片是 ready");
|
||||||
|
eq(ready.label, "等待接入", "就绪等接入的卡片写「等待接入」");
|
||||||
|
eq(ready.tone, "neutral", "就绪等接入不用错误色");
|
||||||
|
|
||||||
|
// 回归:后端没这条状态面的平台逐字不变。
|
||||||
|
const plain: AdminChannelRecord = { name: "kook", connected: false };
|
||||||
|
const disconnected = channelStatus([plain], null, plain);
|
||||||
|
eq(disconnected.kind, "disconnected", "没有状态面的平台仍是 disconnected");
|
||||||
|
eq(disconnected.label, "未连接", "没有状态面的平台仍写「未连接」");
|
||||||
|
eq(disconnected.tone, "err", "没有状态面的平台仍用错误色");
|
||||||
|
|
||||||
|
// 真连上优先:pending 与 connected 同时给出时不许被降级成「连接中」。
|
||||||
|
const both: AdminChannelRecord = { name: "qq-obv11", connected: true, pending: true };
|
||||||
|
eq(channelStatus([both], null, both).kind, "connected", "connected=true 优先于 pending");
|
||||||
|
|
||||||
|
eq(channelConnectStateOf({}), null, "两个键都缺省 = 没有这条状态面");
|
||||||
|
eq(channelConnectStateOf({ pending: true }), "pending", "pending 优先");
|
||||||
|
eq(channelConnectStateOf({ ready: true }), "ready", "ready 单独给出也认");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ③ 真实请求:createChannelActions 写进 notice 的那句话 ───────────────────────
|
||||||
|
// 与前两段不同,这里驱动的是面板按钮真正调用的那一层,并用 stub 的 fetch 回一个
|
||||||
|
// 「请求成功但没连上」的响应 —— 旧代码在这条路径上会说「已连接」。
|
||||||
|
|
||||||
|
interface Captured {
|
||||||
|
url: string;
|
||||||
|
method: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const captured: Captured[] = [];
|
||||||
|
// 每次请求各取一个响应:三个动作在同一个 tick 里发出,若共用一个可变变量,
|
||||||
|
// 先发的两次会读到第三次的响应(那样断言的就是错的东西)。
|
||||||
|
const payloadQueue: unknown[] = [];
|
||||||
|
|
||||||
|
function installStubs(): void {
|
||||||
|
const store = new Map<string, string>();
|
||||||
|
Object.defineProperty(globalThis, "localStorage", {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
getItem: (k: string) => store.get(k) ?? null,
|
||||||
|
setItem: (k: string, v: string) => void store.set(k, v),
|
||||||
|
removeItem: (k: string) => void store.delete(k),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, "fetch", {
|
||||||
|
configurable: true,
|
||||||
|
value: (url: string, init?: RequestInit) => {
|
||||||
|
captured.push({ url: String(url), method: String(init?.method ?? "GET") });
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: () => Promise.resolve({ data: payloadQueue.shift() ?? {} }),
|
||||||
|
} as unknown as Response);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
installStubs();
|
||||||
|
|
||||||
|
const notices: string[] = [];
|
||||||
|
const errors: string[] = [];
|
||||||
|
let reloads = 0;
|
||||||
|
const actions = createChannelActions({
|
||||||
|
log: () => {},
|
||||||
|
setBusy: () => {},
|
||||||
|
setError: (e) => {
|
||||||
|
if (e != null) errors.push(e);
|
||||||
|
},
|
||||||
|
setNotice: (n) => notices.push(n ?? ""),
|
||||||
|
reload: () => {
|
||||||
|
reloads += 1;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 场景 A:后端回「请求成功但仍在重试」。
|
||||||
|
payloadQueue.push({
|
||||||
|
name: "qq-obv11",
|
||||||
|
resolved: "qq-obv11:9616b27a",
|
||||||
|
accountId: "9616b27a",
|
||||||
|
connected: false,
|
||||||
|
pending: true,
|
||||||
|
reason: REASON,
|
||||||
|
});
|
||||||
|
actions.toggle("qq-obv11", true, "9616b27a");
|
||||||
|
|
||||||
|
// 场景 B:后端回「真连上」。
|
||||||
|
payloadQueue.push({ name: "qq-obv11", resolved: "qq-obv11:9616b27a", connected: true });
|
||||||
|
actions.toggle("qq-obv11", true, "9616b27a");
|
||||||
|
|
||||||
|
// 场景 C:断开成功。
|
||||||
|
payloadQueue.push({ name: "qq-obv11", resolved: "qq-obv11:9616b27a", connected: false });
|
||||||
|
actions.toggle("qq-obv11", false, "9616b27a");
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const pendingNotice = notices.find((n) => n.includes("正在连接"));
|
||||||
|
ok(pendingNotice !== undefined, `重试中必须提示「正在连接」,got ${JSON.stringify(notices)}`);
|
||||||
|
ok(
|
||||||
|
!notices[0]?.includes("已连接"),
|
||||||
|
`第一个动作(仍在重试)绝不能说「已连接」,got ${JSON.stringify(notices[0])}`,
|
||||||
|
);
|
||||||
|
ok(notices[0]?.includes(REASON) === true, `重试提示必须带上原因,got ${JSON.stringify(notices[0])}`);
|
||||||
|
ok(
|
||||||
|
notices.some((n) => n.includes("已连接 qq-obv11:9616b27a")),
|
||||||
|
`真连上时必须回显实际驱动的适配器,got ${JSON.stringify(notices)}`,
|
||||||
|
);
|
||||||
|
ok(
|
||||||
|
notices.some((n) => n.includes("已断开 qq-obv11:9616b27a")),
|
||||||
|
`断开成功要说「已断开」,got ${JSON.stringify(notices)}`,
|
||||||
|
);
|
||||||
|
eq(errors.length, 0, "这些路径都不该产生错误提示");
|
||||||
|
eq(reloads, 3, "每次动作后都刷新列表(卡片状态要靠它才不再撒谎)");
|
||||||
|
eq(captured[0].url, "/api/v1/admin/channels/open", "连接仍打到既有路径");
|
||||||
|
eq(captured[2].url, "/api/v1/admin/channels/close", "断开仍打到既有路径");
|
||||||
|
console.log(`channel connect notice test: ${checks} checks passed`);
|
||||||
|
}, 20);
|
||||||
@@ -7,6 +7,8 @@ import type { AdminChannelRecord } from "../../../lib/types";
|
|||||||
* as plain node — see channelStatus.test.ts. */
|
* as plain node — see channelStatus.test.ts. */
|
||||||
export type ChannelStatusKind =
|
export type ChannelStatusKind =
|
||||||
| "connected"
|
| "connected"
|
||||||
|
| "connecting"
|
||||||
|
| "ready"
|
||||||
| "disconnected"
|
| "disconnected"
|
||||||
| "unconfigured"
|
| "unconfigured"
|
||||||
| "unknown"
|
| "unknown"
|
||||||
@@ -29,6 +31,55 @@ export interface ChannelStatus {
|
|||||||
/** How much of the channel list we actually know right now. */
|
/** How much of the channel list we actually know right now. */
|
||||||
export type ChannelListState = "ready" | "pending" | "unknown";
|
export type ChannelListState = "ready" | "pending" | "unknown";
|
||||||
|
|
||||||
|
/** 适配器自报的**连接态三态**(后端可选键 `pending` / `ready`,契约 v0.68;见
|
||||||
|
* internal/services/gateway/admin_channel_connect_outcome.go)。
|
||||||
|
*
|
||||||
|
* 为什么需要它:`connected:false` 至少有两种互不相同的现状 —— 「已在后台重试」
|
||||||
|
* (ws+client 的首连失败刻意非致命:OneBot 实现可能比 Cyrene 后启动)与「已就绪、
|
||||||
|
* 等待对端接入」(server 模式监听已绑定)。用一句静态的「未连接」覆盖两者,主人就会
|
||||||
|
* 把「正在连接」读成故障,或者(更糟)反方向把「没连上」读成「已连接」——
|
||||||
|
* 2026-10-07 的 UI 撒谎事故正是这么来的。
|
||||||
|
*
|
||||||
|
* `null` = 适配器没有这条状态面(后端两个键都缺省)⇒ 调用方退回只看 `connected`
|
||||||
|
* 布尔,**绝不**凭空编一个状态。 */
|
||||||
|
export type ChannelConnectState = "pending" | "ready" | null;
|
||||||
|
|
||||||
|
/** 把后端可选的 `pending` / `ready` 两键映成上面的三态。两者都缺省 ⇒ null。 */
|
||||||
|
export function channelConnectStateOf(record: {
|
||||||
|
pending?: boolean;
|
||||||
|
ready?: boolean;
|
||||||
|
}): ChannelConnectState {
|
||||||
|
if (record.pending === true) return "pending";
|
||||||
|
if (record.ready === true) return "ready";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 未连上时那句如实的话:`pending` 说「正在重试」并把原因带上(原因缺失时也不假装
|
||||||
|
* 知道为什么),`ready` 说「等待接入」。两者都不是失败腔。`null` 表示后端没有这条
|
||||||
|
* 状态面 ⇒ 调用方退回自己那套说法。 */
|
||||||
|
export function connectStateView(
|
||||||
|
state: ChannelConnectState,
|
||||||
|
reason?: string,
|
||||||
|
): { kind: ChannelStatusKind; label: string; tone: ChannelStatusTone; hint: string } | null {
|
||||||
|
if (state === "pending") {
|
||||||
|
return {
|
||||||
|
kind: "connecting",
|
||||||
|
label: "连接中",
|
||||||
|
tone: "warn",
|
||||||
|
hint: reason ? `正在后台重试连接:${reason}` : "已在后台重试连接(尚未连上)",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (state === "ready") {
|
||||||
|
return {
|
||||||
|
kind: "ready",
|
||||||
|
label: "等待接入",
|
||||||
|
tone: "neutral",
|
||||||
|
hint: "监听已就绪,等待对端接入(尚未连上)",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/** List-level tri-state: a list we never read plus a recorded error is *unknown*,
|
/** List-level tri-state: a list we never read plus a recorded error is *unknown*,
|
||||||
* never "empty". `data == null` with no error is the first load still running. */
|
* never "empty". `data == null` with no error is the first load still running. */
|
||||||
export function channelListState<T>(
|
export function channelListState<T>(
|
||||||
@@ -91,6 +142,24 @@ export function channelStatus(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const connected = record.connected === true;
|
const connected = record.connected === true;
|
||||||
|
if (!connected) {
|
||||||
|
// 「没连上」有三种互不相同的现状,只有后端自报时才敢细说(见 ChannelConnectState):
|
||||||
|
// pending 是「正在重试」、ready 是「就绪等接入」。两者都不是「未连接」这样一个
|
||||||
|
// 静态结论,因此不给错误腔,也不把「断开」摆出来(会话本来就没建立)。
|
||||||
|
const state = channelConnectStateOf(record);
|
||||||
|
const view = connectStateView(state, record.reason);
|
||||||
|
if (view !== null) {
|
||||||
|
return {
|
||||||
|
kind: view.kind,
|
||||||
|
label: view.label,
|
||||||
|
tone: view.tone,
|
||||||
|
hint: error != null ? `${view.hint};本次刷新失败,数据可能过期:${error}` : view.hint,
|
||||||
|
stale: error != null,
|
||||||
|
canConnect: true,
|
||||||
|
canDisconnect: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
kind: connected ? "connected" : "disconnected",
|
kind: connected ? "connected" : "disconnected",
|
||||||
label: connected ? "已连接" : "未连接",
|
label: connected ? "已连接" : "未连接",
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import type { ChannelStatus, ChannelStatusKind } from "./channelStatus";
|
|||||||
|
|
||||||
const dot: Record<ChannelStatusKind, string> = {
|
const dot: Record<ChannelStatusKind, string> = {
|
||||||
connected: "bg-[var(--mu-ok)]",
|
connected: "bg-[var(--mu-ok)]",
|
||||||
|
connecting: "bg-[var(--mu-warn)] mu-blink",
|
||||||
|
ready: "bg-[var(--mu-text-lo)]",
|
||||||
disconnected: "bg-[var(--mu-err)]",
|
disconnected: "bg-[var(--mu-err)]",
|
||||||
unconfigured: "bg-[var(--mu-text-lo)]",
|
unconfigured: "bg-[var(--mu-text-lo)]",
|
||||||
unknown: "bg-[var(--mu-text-lo)]",
|
unknown: "bg-[var(--mu-text-lo)]",
|
||||||
@@ -10,9 +12,10 @@ const dot: Record<ChannelStatusKind, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** The one honest place an adapter's connection state is rendered, so list page,
|
/** The one honest place an adapter's connection state is rendered, so list page,
|
||||||
* detail page and overview can never drift apart: 已连接 / 未连接 / 状态未知.
|
* detail page and overview can never drift apart: 已连接 / 连接中 / 等待接入 /
|
||||||
* `showHint` off renders the compact form for dense surfaces (table row, card) —
|
* 未连接 / 状态未知. `showHint` off renders the compact form for dense surfaces
|
||||||
* the surrounding banner already carries the failure reason there. */
|
* (table row, card) — there the hint (e.g. the retry reason behind「连接中」) rides
|
||||||
|
* along as a hover title instead of taking a line of its own. */
|
||||||
export function ChannelStatusTag({
|
export function ChannelStatusTag({
|
||||||
status,
|
status,
|
||||||
showHint = true,
|
showHint = true,
|
||||||
@@ -24,7 +27,7 @@ export function ChannelStatusTag({
|
|||||||
}): React.ReactNode {
|
}): React.ReactNode {
|
||||||
return (
|
return (
|
||||||
<span className={`inline-flex flex-wrap items-center gap-2 ${className}`}>
|
<span className={`inline-flex flex-wrap items-center gap-2 ${className}`}>
|
||||||
<Tag tone={status.tone}>
|
<Tag tone={status.tone} title={status.hint ?? undefined}>
|
||||||
<span className={`inline-block h-1.5 w-1.5 rounded-full ${dot[status.kind]}`} />
|
<span className={`inline-block h-1.5 w-1.5 rounded-full ${dot[status.kind]}`} />
|
||||||
{status.label}
|
{status.label}
|
||||||
</Tag>
|
</Tag>
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
adminChannelOpen,
|
adminChannelOpen,
|
||||||
adminDeleteChannelAccount,
|
adminDeleteChannelAccount,
|
||||||
adminDeleteEmptyChannelAccounts,
|
adminDeleteEmptyChannelAccounts,
|
||||||
|
type ChannelActionResult,
|
||||||
} from "../../../lib/api";
|
} from "../../../lib/api";
|
||||||
import { useAuditStore } from "../../audit";
|
import { useAuditStore } from "../../audit";
|
||||||
|
|
||||||
@@ -19,6 +20,35 @@ export interface ChannelActionDeps {
|
|||||||
reload: () => void;
|
reload: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 连接/断开返回后要说给主人的那句话。
|
||||||
|
*
|
||||||
|
* **只看响应,不看请求是否成功**:请求 200 只说明后端受理了这次调用,不说明渠道真的
|
||||||
|
* 连上了 —— ws+client 的首连失败刻意非致命(OneBot 实现可能比 Cyrene 后启动),此时
|
||||||
|
* 后端回 `connected:false` + `pending:true` + `reason`。把这句写死成「已连接」,
|
||||||
|
* 正是 2026-10-07 那次 UI 撒谎的根因。断开同理:报后端回的真实连接状态,而不是假定
|
||||||
|
* 「调用成功就一定断了」。
|
||||||
|
*
|
||||||
|
* 纯函数、与 React 解耦,因此「响应 connected:false + pending:true ⇒ 提示里没有
|
||||||
|
* 『已连接』」可以在 node 侧被直接断言(见 channelActions.test.ts)。 */
|
||||||
|
export function connectDisconnectNotice(
|
||||||
|
connect: boolean,
|
||||||
|
who: string,
|
||||||
|
res?: ChannelActionResult,
|
||||||
|
): string {
|
||||||
|
if (!connect) {
|
||||||
|
return res?.connected === true ? `${who} 仍未断开(后端报告连接仍存在)` : `已断开 ${who}`;
|
||||||
|
}
|
||||||
|
if (res?.connected === true) return `已连接 ${who}`;
|
||||||
|
if (res?.pending === true) {
|
||||||
|
return res.reason
|
||||||
|
? `${who} 正在连接(${res.reason})`
|
||||||
|
: `${who} 正在连接(尚未连上,已在后台重试)`;
|
||||||
|
}
|
||||||
|
if (res?.ready === true) return `${who} 已就绪,等待对端接入`;
|
||||||
|
// 既没连上、也没有可解释的状态面:如实说「尚未连上」——请求被受理不等于连上了。
|
||||||
|
return `${who} 尚未连上(连接请求已受理,可稍后在列表中查看状态)`;
|
||||||
|
}
|
||||||
|
|
||||||
/** 与 React 解耦的渠道动作集合(连接/断开/删除账号/删除无 id 条目)。 */
|
/** 与 React 解耦的渠道动作集合(连接/断开/删除账号/删除无 id 条目)。 */
|
||||||
export interface ChannelActions {
|
export interface ChannelActions {
|
||||||
/** 连接/断开。`accountId` 给出时**只**作用于该账号(多账号平台逐条操作);
|
/** 连接/断开。`accountId` 给出时**只**作用于该账号(多账号平台逐条操作);
|
||||||
@@ -42,7 +72,7 @@ export function createChannelActions(deps: ChannelActionDeps): ChannelActions {
|
|||||||
// 回显**实际被驱动**的适配器:多账号平台上请求里的平台名说明不了「连的是哪一条」,
|
// 回显**实际被驱动**的适配器:多账号平台上请求里的平台名说明不了「连的是哪一条」,
|
||||||
// 后端把解析结果放在 resolved 里,这里如实说出来。
|
// 后端把解析结果放在 resolved 里,这里如实说出来。
|
||||||
const who = res?.resolved ?? name;
|
const who = res?.resolved ?? name;
|
||||||
setNotice(`${connect ? "已连接" : "已断开"} ${who}`);
|
setNotice(connectDisconnectNotice(connect, who, res));
|
||||||
reload();
|
reload();
|
||||||
})
|
})
|
||||||
.catch((e: unknown) => {
|
.catch((e: unknown) => {
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
TIDY_STATUS_PENDING,
|
||||||
|
adminTidyCandidateApply,
|
||||||
|
adminTidyCandidateRevert,
|
||||||
|
adminTidyCandidates,
|
||||||
|
adminTidyCandidatesApplyBatch,
|
||||||
|
adminTidyRun,
|
||||||
|
} from "../../lib/api";
|
||||||
|
import type {
|
||||||
|
AdminTidyApplyBatch,
|
||||||
|
AdminTidyApplyResult,
|
||||||
|
AdminTidyCandidatesResponse,
|
||||||
|
AdminTidyRunResult,
|
||||||
|
} from "../../lib/types";
|
||||||
|
import { useAsyncData } from "../../lib/useAsyncData";
|
||||||
|
import { useAuditStore } from "../audit";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记忆整理「候选」卡的**取数与写动作**半边(切片 TIDY-1),与展示半边
|
||||||
|
* (`MemoryTidyApplyCard.tsx`)分开,只为守 AGENTS §10 的文件/函数长度上限。
|
||||||
|
*
|
||||||
|
* 三条口径落在这里,而不是散在组件里:
|
||||||
|
* 1. **权威键只读**:本模块只把响应原样交给状态(`batch` / `run` / `last` / 列表),
|
||||||
|
* 从不合成计数、从不把「我点了按钮」当成「它成功了」—— 界面显示的一切都来自后端。
|
||||||
|
* 2. **筛选即查询条件**:`status` 一变就重取,且**旧数据不冒充新筛选的结果**。
|
||||||
|
* 3. **写动作四件套**(单条应用 / 单条回滚 / 批量应用 / 真跑)共用一份 busy + 失败 +
|
||||||
|
* 客户端审计收尾:失败文案取后端 `error.message`,不自己编。
|
||||||
|
*
|
||||||
|
* 后端另有权威审计(`memory.tidy.apply` / `memory.tidy.revert` / `memory.tidy.run`),
|
||||||
|
* 这里记的是面板侧可见的那一份(与既有 `memory.add` / `llm.resetFromEnv` 同惯例)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 二次确认展开的是哪个写动作。 */
|
||||||
|
export type TidyConfirmKind = "batch" | "run";
|
||||||
|
|
||||||
|
/** 候选列表的取数态。 */
|
||||||
|
export type TidyCandidateListState =
|
||||||
|
| { kind: "loading" }
|
||||||
|
| { kind: "ready"; data: AdminTidyCandidatesResponse }
|
||||||
|
| { kind: "error"; message: string };
|
||||||
|
|
||||||
|
/** 应用卡的完整视图态(纯数据;真实渲染测试直接喂)。 */
|
||||||
|
export interface TidyApplyViewState {
|
||||||
|
status: string;
|
||||||
|
list: TidyCandidateListState;
|
||||||
|
/** 正在进行的动作(`apply:<id>` / `revert:<id>` / `batch` / `run`),null = 空闲。 */
|
||||||
|
busy: string | null;
|
||||||
|
confirming: TidyConfirmKind | null;
|
||||||
|
/** 最近一次批量应用回执(计数的**唯一**来源)。 */
|
||||||
|
batch: AdminTidyApplyBatch | null;
|
||||||
|
/** 最近一次手动真跑回执。 */
|
||||||
|
run: AdminTidyRunResult | null;
|
||||||
|
/** 最近一次单条应用/回滚回执。 */
|
||||||
|
last: AdminTidyApplyResult | null;
|
||||||
|
/** 写动作的失败文案(后端 message)。 */
|
||||||
|
fail: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 视图态的六个交互出口(展示半边只调它们,不碰 API)。 */
|
||||||
|
export interface TidyApplyActions {
|
||||||
|
onStatusChange: (status: string) => void;
|
||||||
|
onApply: (id: string) => void;
|
||||||
|
onRevert: (id: string) => void;
|
||||||
|
/** 展开某个写动作的二次确认(此时还不发请求)。 */
|
||||||
|
onRequest: (kind: TidyConfirmKind) => void;
|
||||||
|
/** 确认落刀(批量应用 / 真跑由 `state.confirming` 决定是哪一个)。 */
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancelConfirm: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 写动作那一半的出口(`onStatusChange` 属于筛选的持有者,不在这里)。 */
|
||||||
|
type TidyWriteActions = Omit<TidyApplyActions, "onStatusChange">;
|
||||||
|
|
||||||
|
/** 一次写动作的收尾器:busy 标记 + 失败文案 + 客户端审计 + 成功收起确认。 */
|
||||||
|
type TidyAct = (label: string, audit: string, task: () => Promise<void>) => void;
|
||||||
|
|
||||||
|
/** useTidyCandidateList 取候选列表,并把「屏幕上这批数据属于哪次请求」记在
|
||||||
|
* `dataStatus` 里:切筛选时旧数据**不许**冒充新筛选的结果(在途期间只显示加载态)。
|
||||||
|
*
|
||||||
|
* `dataStatus` 在**成功与失败**两条路上都记 —— 否则新筛选请求失败时它会停在旧值,
|
||||||
|
* 界面会永远卡在「加载中」,把失败也一起藏掉。 */
|
||||||
|
function useTidyCandidateList(status: string): {
|
||||||
|
list: TidyCandidateListState;
|
||||||
|
reload: () => void;
|
||||||
|
} {
|
||||||
|
const [dataStatus, setDataStatus] = useState<string>(status);
|
||||||
|
const fetchList = (): Promise<AdminTidyCandidatesResponse> => {
|
||||||
|
const requested = status;
|
||||||
|
return adminTidyCandidates({ status: requested }).then(
|
||||||
|
(res) => {
|
||||||
|
setDataStatus(requested);
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
(e: unknown) => {
|
||||||
|
setDataStatus(requested);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const { data, loading, error, reload } = useAsyncData(fetchList, [status]);
|
||||||
|
const inFlight = dataStatus !== status || (loading && data === null);
|
||||||
|
const list: TidyCandidateListState = inFlight
|
||||||
|
? { kind: "loading" }
|
||||||
|
: error
|
||||||
|
? { kind: "error", message: error }
|
||||||
|
: data
|
||||||
|
? { kind: "ready", data }
|
||||||
|
: { kind: "loading" };
|
||||||
|
return { list, reload };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** runConfirmed 落刀二次确认里的那个写动作(批量应用 / 真跑),回执存进状态。 */
|
||||||
|
function runConfirmed(
|
||||||
|
kind: TidyConfirmKind,
|
||||||
|
act: TidyAct,
|
||||||
|
store: {
|
||||||
|
setBatch: (batch: AdminTidyApplyBatch) => void;
|
||||||
|
setRun: (run: AdminTidyRunResult) => void;
|
||||||
|
reload: () => void;
|
||||||
|
}
|
||||||
|
): void {
|
||||||
|
if (kind === "run") {
|
||||||
|
act("run", "memory.tidy.run", () =>
|
||||||
|
adminTidyRun().then((res) => {
|
||||||
|
store.setRun(res);
|
||||||
|
store.reload();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
act("batch", "memory.tidy.applyBatch", () =>
|
||||||
|
adminTidyCandidatesApplyBatch().then((res) => {
|
||||||
|
store.setBatch(res);
|
||||||
|
store.reload();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** useTidyWrites 持有四个写动作的状态(busy / 二次确认 / 三种回执 / 失败)并把它们
|
||||||
|
* 装配成交互出口。`act` 是四者共用的收尾,四个动作一字排开只差调哪个端点。 */
|
||||||
|
function useTidyWrites(reload: () => void): {
|
||||||
|
busy: string | null;
|
||||||
|
confirming: TidyConfirmKind | null;
|
||||||
|
batch: AdminTidyApplyBatch | null;
|
||||||
|
run: AdminTidyRunResult | null;
|
||||||
|
last: AdminTidyApplyResult | null;
|
||||||
|
fail: string | null;
|
||||||
|
actions: TidyWriteActions;
|
||||||
|
reset: () => void;
|
||||||
|
} {
|
||||||
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
|
const [confirming, setConfirming] = useState<TidyConfirmKind | null>(null);
|
||||||
|
const [batch, setBatch] = useState<AdminTidyApplyBatch | null>(null);
|
||||||
|
const [run, setRun] = useState<AdminTidyRunResult | null>(null);
|
||||||
|
const [last, setLast] = useState<AdminTidyApplyResult | null>(null);
|
||||||
|
const [fail, setFail] = useState<string | null>(null);
|
||||||
|
const log = useAuditStore((s) => s.log);
|
||||||
|
|
||||||
|
const act: TidyAct = (action, audit, task) => {
|
||||||
|
setBusy(action);
|
||||||
|
setFail(null);
|
||||||
|
task()
|
||||||
|
.then(() => {
|
||||||
|
log(audit, "ok");
|
||||||
|
setConfirming(null);
|
||||||
|
})
|
||||||
|
.catch((e: unknown) => {
|
||||||
|
log(audit, "error");
|
||||||
|
setFail(e instanceof Error ? e.message : "操作失败");
|
||||||
|
})
|
||||||
|
.finally(() => setBusy(null));
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmKind = confirming;
|
||||||
|
const actions: TidyWriteActions = {
|
||||||
|
onApply: (id) =>
|
||||||
|
act(`apply:${id}`, "memory.tidy.apply", () =>
|
||||||
|
adminTidyCandidateApply(id).then((res) => {
|
||||||
|
setLast(res);
|
||||||
|
reload();
|
||||||
|
})
|
||||||
|
),
|
||||||
|
onRevert: (id) =>
|
||||||
|
act(`revert:${id}`, "memory.tidy.revert", () =>
|
||||||
|
adminTidyCandidateRevert(id).then((res) => {
|
||||||
|
setLast(res);
|
||||||
|
reload();
|
||||||
|
})
|
||||||
|
),
|
||||||
|
onRequest: (kind) => setConfirming(kind),
|
||||||
|
onCancelConfirm: () => setConfirming(null),
|
||||||
|
onConfirm: () => {
|
||||||
|
if (confirmKind) runConfirmed(confirmKind, act, { setBatch, setRun, reload });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
busy,
|
||||||
|
confirming,
|
||||||
|
batch,
|
||||||
|
run,
|
||||||
|
last,
|
||||||
|
fail,
|
||||||
|
actions,
|
||||||
|
reset: () => {
|
||||||
|
setLast(null);
|
||||||
|
setFail(null);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** useTidyApplyState 把两半拼成候选卡的状态与出口。 */
|
||||||
|
export function useTidyApplyState(): {
|
||||||
|
state: TidyApplyViewState;
|
||||||
|
actions: TidyApplyActions;
|
||||||
|
} {
|
||||||
|
const [status, setStatus] = useState<string>(TIDY_STATUS_PENDING);
|
||||||
|
const { list, reload } = useTidyCandidateList(status);
|
||||||
|
const writes = useTidyWrites(reload);
|
||||||
|
return {
|
||||||
|
state: {
|
||||||
|
status,
|
||||||
|
list,
|
||||||
|
busy: writes.busy,
|
||||||
|
confirming: writes.confirming,
|
||||||
|
batch: writes.batch,
|
||||||
|
run: writes.run,
|
||||||
|
last: writes.last,
|
||||||
|
fail: writes.fail,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
...writes.actions,
|
||||||
|
onStatusChange: (next) => {
|
||||||
|
setStatus(next);
|
||||||
|
writes.reset();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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」;平台是权威
|
||||||
// 枚举**下拉**;账号 ID 给出「从该平台已配置账号里选」的候选;**写盘时仍是扁平数组**。
|
// 枚举**下拉**;账号 ID 给出「从该平台已配置账号里选」的候选;**写盘时仍是扁平数组**。
|
||||||
//
|
//
|
||||||
// 锁三件事:
|
// 锁这些事:
|
||||||
// ① 分组/展平是纯函数且**展平后就是后端契约的扁平数组**(`owner.yaml` 格式不变);
|
// ① 分组/草稿/写盘形态(纯函数):**草稿无损**(`draftAccounts` 逐行保留,空行/未选平台的组
|
||||||
|
// 都在 —— 编辑期的中间态不许蒸发),**写盘有损**(`accountsForSave` 只留平台与账号 ID 都
|
||||||
|
// 非空的条目:半成品条目会让后端 `Validate()` 直接 400,完全空的条目被跳过);
|
||||||
// ② 下拉选项 = 权威枚举 + 存量里出现过的平台(未列出的旧值不被下拉吃掉);
|
// ② 下拉选项 = 权威枚举 + 存量里出现过的平台(未列出的旧值不被下拉吃掉);
|
||||||
// ③ 渲染:一个平台一条(两个平台 ⇒ 两块)、有「+ 添加账号 ID」、有候选可点。
|
// ③ 候选账号 = 该平台已配置的渠道账号 id;
|
||||||
|
// ④ 渲染:一个平台一条(两个平台 ⇒ 两块)、有「+ 添加账号 ID」、有候选可点;
|
||||||
|
// ⑤ **手势往返**(主人 2026-10-07 报的缺陷:卡片里「+ 添加账号 ID」似乎没用):点一下必须
|
||||||
|
// 真的多出一行可输入的账号行;「+ 添加平台」走同一条往返,同样钉住。
|
||||||
//
|
//
|
||||||
// 跑法(react-dom/server 的 CJS 构建需要 require,所以必须打成 cjs):
|
// 跑法(react-dom/server 的 CJS 构建需要 require,所以必须打成 cjs):
|
||||||
//
|
//
|
||||||
@@ -21,12 +26,14 @@
|
|||||||
// `tsc --noEmit`(npm run build 的一部分)会对本文件做类型检查,因此它只从项目内导入、
|
// `tsc --noEmit`(npm run build 的一部分)会对本文件做类型检查,因此它只从项目内导入、
|
||||||
// 不 import node:*。
|
// 不 import node:*。
|
||||||
|
|
||||||
import { createElement } from "react";
|
import { createElement, type ReactNode } from "react";
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import type { AdminChannelRecord, EnumItem, OwnerAccount } from "../../lib/types";
|
import type { AdminChannelRecord, EnumItem, OwnerAccount } from "../../lib/types";
|
||||||
import { OwnerAccountsEditor } from "./OwnerAccountsEditor";
|
import { OwnerAccountsEditor, OwnerAccountsView } from "./OwnerAccountsEditor";
|
||||||
import {
|
import {
|
||||||
accountCandidates,
|
accountCandidates,
|
||||||
|
accountsForSave,
|
||||||
|
draftAccounts,
|
||||||
flattenAccounts,
|
flattenAccounts,
|
||||||
groupAccounts,
|
groupAccounts,
|
||||||
platformIsConfigured,
|
platformIsConfigured,
|
||||||
@@ -56,7 +63,7 @@ const PUBLISHED: EnumItem[] = [
|
|||||||
{ id: "webhook", label: "Webhook" },
|
{ id: "webhook", label: "Webhook" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// ── ① 分组 / 展平(纯函数) ─────────────────────────────────────────────────────
|
// ── ① 分组 / 草稿 / 写盘形态(纯函数) ──────────────────────────────────────────
|
||||||
{
|
{
|
||||||
const flat: OwnerAccount[] = [
|
const flat: OwnerAccount[] = [
|
||||||
{ platform: "qq-obv11", accountId: "9616b27a" },
|
{ platform: "qq-obv11", accountId: "9616b27a" },
|
||||||
@@ -68,15 +75,71 @@ const PUBLISHED: EnumItem[] = [
|
|||||||
same(groups[0], { platform: "qq-obv11", accountIds: ["9616b27a", "aaaa1111"] }, "同平台的账号收进一块");
|
same(groups[0], { platform: "qq-obv11", accountIds: ["9616b27a", "aaaa1111"] }, "同平台的账号收进一块");
|
||||||
same(groups[1], { platform: "web", accountIds: ["web-user"] }, "顺序 = 首次出现顺序");
|
same(groups[1], { platform: "web", accountIds: ["web-user"] }, "顺序 = 首次出现顺序");
|
||||||
|
|
||||||
// 展平回扁平数组。注意:分组天然把**同一平台的账号排到相邻位置** ⇒ 保存后文件的
|
// 草稿(编辑期形态,`draftAccounts`)**无损**:逐行原样回来 —— 空行、还没选平台的组都在。
|
||||||
// 行序会变成「按平台聚集」。白名单是一个**集合**(命中即 owner,与顺序无关),所以
|
// 这一条是主人 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[] =>
|
const sorted = (list: OwnerAccount[]): OwnerAccount[] =>
|
||||||
[...list].sort((a, b) => `${a.platform}/${a.accountId}`.localeCompare(`${b.platform}/${b.accountId}`));
|
[...list].sort((a, b) => `${a.platform}/${a.accountId}`.localeCompare(`${b.platform}/${b.accountId}`));
|
||||||
same(sorted(flattenAccounts(groups)), sorted(flat), "分组 → 展平 回到同一组白名单条目");
|
same(sorted(flattenAccounts(groups)), sorted(flat), "分组 → 展平 回到同一组白名单条目");
|
||||||
const shape = Object.keys(flattenAccounts(groups)[0]).sort();
|
const shape = Object.keys(flattenAccounts(groups)[0]).sort();
|
||||||
same(shape, ["accountId", "platform"], "写盘条目只有 {platform, accountId}(owner.yaml 格式不变)");
|
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 元数据,是噪声)。
|
// 空值:平台或账号 id 任一为空都不写(写了也匹配不上 actor 元数据,是噪声)。
|
||||||
same(
|
same(
|
||||||
flattenAccounts([
|
flattenAccounts([
|
||||||
@@ -171,4 +234,247 @@ const PUBLISHED: EnumItem[] = [
|
|||||||
ok(other.includes('disabled=""'), "只读角色下控件被禁用(不假装能改)");
|
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`);
|
console.log(`owner accounts editor test: ${checks} checks passed`);
|
||||||
@@ -12,7 +12,13 @@ import type { AdminChannelRecord, EnumItem, OwnerAccount } from "../../lib/types
|
|||||||
* (`owner.IsOwner` 匹配不上 actor 元数据 ⇒ 主人被当陌生人,界面上没有任何提示)。
|
* (`owner.IsOwner` 匹配不上 actor 元数据 ⇒ 主人被当陌生人,界面上没有任何提示)。
|
||||||
* 平台改为权威枚举下拉 + 账号 id 可从该平台已配置账号里选,消灭这类静默失效。
|
* 平台改为权威枚举下拉 + 账号 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 groups: OwnerAccountGroup[] = [];
|
||||||
const byPlatform = new Map<string, OwnerAccountGroup>();
|
const byPlatform = new Map<string, OwnerAccountGroup>();
|
||||||
for (const a of accounts) {
|
for (const a of accounts) {
|
||||||
@@ -38,10 +44,27 @@ export function groupAccounts(accounts: OwnerAccount[]): OwnerAccountGroup[] {
|
|||||||
return groups;
|
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[] = [];
|
const out: OwnerAccount[] = [];
|
||||||
for (const g of groups) {
|
for (const g of groups) {
|
||||||
const platform = g.platform.trim();
|
const platform = g.platform.trim();
|
||||||
@@ -54,6 +77,16 @@ export function flattenAccounts(groups: OwnerAccountGroup[]): OwnerAccount[] {
|
|||||||
return out;
|
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`)在前,随后补上
|
/** 下拉选项:后端权威枚举(`GET /api/v1/meta/enums` 的 `platforms`)在前,随后补上
|
||||||
* 存量里出现过的平台 —— 未列出的旧值**原样保留**(display-raw:绝不下拉吃掉它,
|
* 存量里出现过的平台 —— 未列出的旧值**原样保留**(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));
|
||||||
|
}
|
||||||
@@ -6,8 +6,9 @@
|
|||||||
* ② 提交前先在本地跑必填校验(`schema/values.planSubmit`),拦下「填了却起不来」;
|
* ② 提交前先在本地跑必填校验(`schema/values.planSubmit`),拦下「填了却起不来」;
|
||||||
* ③ viewer 一律看不到表单(后端已在快照里裁掉,这里是第二道闸)。
|
* ③ viewer 一律看不到表单(后端已在快照里裁掉,这里是第二道闸)。
|
||||||
*
|
*
|
||||||
* 状态与规则在 `formState.ts`(三个 hook),字段呈现与说明文案在 `FormFields.tsx`;
|
* 状态与规则在 `formState.ts`(值状态 + 提交两个 hook;候选由面板级拉好传进来),
|
||||||
* 本文件只剩「拼装 + 提交按钮 + 反馈行」,因此每个函数都在 80 行以内。
|
* 字段呈现与说明文案在 `FormFields.tsx`;本文件只剩「拼装 + 提交按钮 + 反馈行」,
|
||||||
|
* 因此每个函数都在 80 行以内。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useMemo, type ReactNode } from "react";
|
import { useMemo, type ReactNode } from "react";
|
||||||
@@ -17,10 +18,10 @@ import { useAuditStore } from "../audit";
|
|||||||
import { Notice } from "../components";
|
import { Notice } from "../components";
|
||||||
import type { SchemaField } from "../schema/fieldTypes";
|
import type { SchemaField } from "../schema/fieldTypes";
|
||||||
import { FormFieldsGrid } from "./FormFields";
|
import { FormFieldsGrid } from "./FormFields";
|
||||||
import { useFieldOptions, useFormSubmit, useFormValues, type FormFlash } from "./formState";
|
import { useFormSubmit, useFormValues, type FormFlash } from "./formState";
|
||||||
import { canWrite } from "./nav";
|
import { canWrite } from "./nav";
|
||||||
import { SectionHeader } from "./SectionHeader";
|
import { SectionHeader } from "./SectionHeader";
|
||||||
import type { PanelFieldDto, PanelSectionDto } from "./types";
|
import type { PanelFieldDto, PanelOptionDto, PanelSectionDto } from "./types";
|
||||||
|
|
||||||
/** 声明字段 → 通用 schema(`options` 由 `optionsFrom` 拉到后由栅格填进来)。 */
|
/** 声明字段 → 通用 schema(`options` 由 `optionsFrom` 拉到后由栅格填进来)。 */
|
||||||
export function panelFieldToSchema(f: PanelFieldDto): SchemaField {
|
export function panelFieldToSchema(f: PanelFieldDto): SchemaField {
|
||||||
@@ -58,9 +59,22 @@ export interface PanelFormSectionProps {
|
|||||||
/** 行内动作「带回表单」的值(design/09 §5.2.1 示例的 reauth);`null` = 没有。
|
/** 行内动作「带回表单」的值(design/09 §5.2.1 示例的 reauth);`null` = 没有。
|
||||||
* 只认显式 `values` / `formValues`(见 `types.formValuesOfResult`),不做形状猜测。 */
|
* 只认显式 `values` / `formValues`(见 `types.formValuesOfResult`),不做形状猜测。 */
|
||||||
prefill?: Record<string, unknown> | null;
|
prefill?: Record<string, unknown> | null;
|
||||||
|
/** 下拉候选(面板级只拉一次,见 `formState.useFieldOptions`):`optionsFrom` → 候选项。
|
||||||
|
* 本组件**不再自己拉** —— 同一份候选两处各拉一次会重复请求,且表格那份拿它做显示名。 */
|
||||||
|
options: Record<string, readonly PanelOptionDto[]>;
|
||||||
|
/** `optionsFrom` → 失败原因(取不到候选不是致命错误,但要如实说出来)。 */
|
||||||
|
optionErrors: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PanelFormSection({ pluginId, section, role, onReload, prefill = null }: PanelFormSectionProps): ReactNode {
|
export function PanelFormSection({
|
||||||
|
pluginId,
|
||||||
|
section,
|
||||||
|
role,
|
||||||
|
onReload,
|
||||||
|
prefill = null,
|
||||||
|
options,
|
||||||
|
optionErrors,
|
||||||
|
}: PanelFormSectionProps): ReactNode {
|
||||||
const log = useAuditStore((s) => s.log);
|
const log = useAuditStore((s) => s.log);
|
||||||
// 依赖键用 JSON 串:快照每 30s 重取一次,对象身份每次都变,直接依赖对象会把用户正在
|
// 依赖键用 JSON 串:快照每 30s 重取一次,对象身份每次都变,直接依赖对象会把用户正在
|
||||||
// 输入的内容重置掉(「打着字被清空」是比不刷新更糟的体验)。
|
// 输入的内容重置掉(「打着字被清空」是比不刷新更糟的体验)。
|
||||||
@@ -72,7 +86,6 @@ export function PanelFormSection({ pluginId, section, role, onReload, prefill =
|
|||||||
);
|
);
|
||||||
const fields = useMemo(() => (section.fields ?? []).map((f) => panelFieldToSchema(f)), [fieldsKey]);
|
const fields = useMemo(() => (section.fields ?? []).map((f) => panelFieldToSchema(f)), [fieldsKey]);
|
||||||
const state = useFormValues(fields, fieldsKey, external);
|
const state = useFormValues(fields, fieldsKey, external);
|
||||||
const { options, optionErrors } = useFieldOptions(pluginId, section.fields ?? []);
|
|
||||||
const action = section.submit?.action ?? "";
|
const action = section.submit?.action ?? "";
|
||||||
const { submit, busy, flash } = useFormSubmit({ pluginId, action, fields, state, onReload, log });
|
const { submit, busy, flash } = useFormSubmit({ pluginId, action, fields, state, onReload, log });
|
||||||
|
|
||||||
|
|||||||
@@ -4,20 +4,24 @@
|
|||||||
* - `PluginPanelHost`(容器):从快照里找当前 URL 指向的面板,负责「加载中 / 取数失败 /
|
* - `PluginPanelHost`(容器):从快照里找当前 URL 指向的面板,负责「加载中 / 取数失败 /
|
||||||
* 没这个面板」三种状态。找不到就是找不到 —— 未启用、`when` 不满足、或该插件根本没
|
* 没这个面板」三种状态。找不到就是找不到 —— 未启用、`when` 不满足、或该插件根本没
|
||||||
* 声明这个页面,直接输 URL 也进不来(快照是按角色与 `when` 过滤后的结果);
|
* 声明这个页面,直接输 URL 也进不来(快照是按角色与 `when` 过滤后的结果);
|
||||||
* - `PluginPanelView`(纯呈现):只吃 `panel` + `role`,因此可以在单测里直接渲染,
|
* - `PluginPanelView`(呈现 + 取候选):只吃 `panel` + `role`,因此可以在单测里直接渲染,
|
||||||
* 不依赖路由、快照与浏览器环境(`--format=cjs` 的渲染测试就是打它)。
|
* 不依赖路由、快照与浏览器环境(`--format=cjs` 的渲染测试就是打它)。它另持一份
|
||||||
|
* **下拉候选**状态(面板级只拉一次);服务端渲染不跑 effect ⇒ 测试里候选是空的,
|
||||||
|
* 标签相关的断言请直接打 `PanelFormSection`(可显式传 `options`)或纯函数。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Button, EmptyState, Spinner, Tag } from "../../components/primitives";
|
import { Button, EmptyState, Spinner, Tag } from "../../components/primitives";
|
||||||
import type { Role } from "../../lib/types";
|
import type { Role } from "../../lib/types";
|
||||||
import { Notice, PageHeader } from "../components";
|
import { Notice, PageHeader } from "../components";
|
||||||
import { PanelFormSection } from "./FormSection";
|
import { PanelFormSection } from "./FormSection";
|
||||||
import { PanelLogSection, PanelStatusSection } from "./ReadOnlySections";
|
import { PanelLogSection, PanelStatusSection } from "./ReadOnlySections";
|
||||||
import { PanelTableSection } from "./TableSection";
|
import { PanelTableSection } from "./TableSection";
|
||||||
|
import { useFieldOptions } from "./formState";
|
||||||
import { findPanelEntry, viewerVisibleSections } from "./nav";
|
import { findPanelEntry, viewerVisibleSections } from "./nav";
|
||||||
import type { PanelEntryDto, PanelSectionDto } from "./types";
|
import { optionLabelIndex } from "./tableState";
|
||||||
|
import type { PanelEntryDto, PanelFieldDto, PanelOptionDto, PanelSectionDto } from "./types";
|
||||||
import type { PluginTarget } from "../router";
|
import type { PluginTarget } from "../router";
|
||||||
|
|
||||||
/** 区块分发(闭集):form / table / status / log 四种,未知类型如实说明而不是半渲染。 */
|
/** 区块分发(闭集):form / table / status / log 四种,未知类型如实说明而不是半渲染。 */
|
||||||
@@ -28,6 +32,9 @@ function SectionBlock({
|
|||||||
onReload,
|
onReload,
|
||||||
formPrefill,
|
formPrefill,
|
||||||
onRowValues,
|
onRowValues,
|
||||||
|
options,
|
||||||
|
optionErrors,
|
||||||
|
optionLabels,
|
||||||
}: {
|
}: {
|
||||||
pluginId: string;
|
pluginId: string;
|
||||||
section: PanelSectionDto;
|
section: PanelSectionDto;
|
||||||
@@ -35,6 +42,9 @@ function SectionBlock({
|
|||||||
onReload: () => void;
|
onReload: () => void;
|
||||||
formPrefill: Record<string, unknown> | null;
|
formPrefill: Record<string, unknown> | null;
|
||||||
onRowValues?: (values: Record<string, unknown>) => void;
|
onRowValues?: (values: Record<string, unknown>) => void;
|
||||||
|
options: Record<string, readonly PanelOptionDto[]>;
|
||||||
|
optionErrors: Record<string, string>;
|
||||||
|
optionLabels: ReadonlyMap<string, string>;
|
||||||
}): ReactNode {
|
}): ReactNode {
|
||||||
switch (section.type) {
|
switch (section.type) {
|
||||||
case "form":
|
case "form":
|
||||||
@@ -45,6 +55,8 @@ function SectionBlock({
|
|||||||
role={role}
|
role={role}
|
||||||
onReload={onReload}
|
onReload={onReload}
|
||||||
prefill={formPrefill}
|
prefill={formPrefill}
|
||||||
|
options={options}
|
||||||
|
optionErrors={optionErrors}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "table":
|
case "table":
|
||||||
@@ -55,6 +67,7 @@ function SectionBlock({
|
|||||||
role={role}
|
role={role}
|
||||||
onReload={onReload}
|
onReload={onReload}
|
||||||
onValues={onRowValues}
|
onValues={onRowValues}
|
||||||
|
optionLabels={optionLabels}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "status":
|
case "status":
|
||||||
@@ -75,16 +88,31 @@ export interface PluginPanelViewProps {
|
|||||||
onReload: () => void;
|
onReload: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 面板里声明过的**全部**字段(不只是当前角色可见的区块)。
|
||||||
|
*
|
||||||
|
* 候选来源(`optionsFrom`)是**声明事实**:用整个面板的字段去拉,能拉到的候选最多。
|
||||||
|
* **已知边界**:viewer 的快照在服务端就已把 form 区块整体摘掉(`paneldecl.filterPanel`),
|
||||||
|
* 因此 viewer 拿不到候选 ⇒ 表格照样显示裸 id(`cellDisplayText` 的回落),不空白、也不猜。 */
|
||||||
|
function declaredFieldsOf(panel: PanelEntryDto): PanelFieldDto[] {
|
||||||
|
return (panel.sections ?? []).flatMap((s) => s.fields ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
/** 插件页的纯呈现:标题 + 描述 + 按角色复检后的区块列表。
|
/** 插件页的纯呈现:标题 + 描述 + 按角色复检后的区块列表。
|
||||||
*
|
*
|
||||||
* 行内动作「带回表单」(design/09 §5.2.1 示例的 reauth)在**这里**接线:表格返回一份
|
* 行内动作「带回表单」(design/09 §5.2.1 示例的 reauth)在**这里**接线:表格返回一份
|
||||||
* 显式值对象时把它交给表单。只在「恰好一个 form 区块」时转交 —— 两个表单就没有归属,
|
* 显式值对象时把它交给表单。只在「恰好一个 form 区块」时转交 —— 两个表单就没有归属,
|
||||||
* 宁可不填也不猜(猜错就是把 A 条的数据填进 B 条的表单)。 */
|
* 宁可不填也不猜(猜错就是把 A 条的数据填进 B 条的表单)。
|
||||||
|
*
|
||||||
|
* 下拉候选也在**这里**拉一次(`useFieldOptions`)并分发给 form 与 table:表单要它渲染
|
||||||
|
* 下拉,表格要它把行里的裸值翻成显示名。候选是 `{value,label}`,显示用 label、写回只用
|
||||||
|
* value(见 `controls.tsx` 的 SelectControl / MultiSelectControl)。 */
|
||||||
export function PluginPanelView({ panel, role, onReload }: PluginPanelViewProps): ReactNode {
|
export function PluginPanelView({ panel, role, onReload }: PluginPanelViewProps): ReactNode {
|
||||||
const [prefill, setPrefill] = useState<Record<string, unknown> | null>(null);
|
const [prefill, setPrefill] = useState<Record<string, unknown> | null>(null);
|
||||||
const sections = viewerVisibleSections(panel, role);
|
const sections = viewerVisibleSections(panel, role);
|
||||||
const formSections = sections.filter((s) => s.type === "form");
|
const formSections = sections.filter((s) => s.type === "form");
|
||||||
const singleForm = formSections.length === 1 ? formSections[0].id : null;
|
const singleForm = formSections.length === 1 ? formSections[0].id : null;
|
||||||
|
const { options, optionErrors } = useFieldOptions(panel.pluginId, declaredFieldsOf(panel));
|
||||||
|
const optionLabels = useMemo(() => optionLabelIndex(options), [options]);
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -101,6 +129,9 @@ export function PluginPanelView({ panel, role, onReload }: PluginPanelViewProps)
|
|||||||
onReload={onReload}
|
onReload={onReload}
|
||||||
formPrefill={section.type === "form" && section.id === singleForm ? prefill : null}
|
formPrefill={section.type === "form" && section.id === singleForm ? prefill : null}
|
||||||
onRowValues={singleForm === null ? undefined : (values) => setPrefill(values)}
|
onRowValues={singleForm === null ? undefined : (values) => setPrefill(values)}
|
||||||
|
options={options}
|
||||||
|
optionErrors={optionErrors}
|
||||||
|
optionLabels={optionLabels}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{sections.length === 0 ? (
|
{sections.length === 0 ? (
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
// 搜索提供商「值 / 显示名分离」的真实渲染测试(切片 LBL-1)。
|
||||||
|
//
|
||||||
|
// 为什么需要它:主人报障「中文搜索接口在管理后台下拉菜单里全给的英文名」——修好之后最容易
|
||||||
|
// 走样的两条恰恰是**呈现**与**写回**的分界,而这两条都是「一改就写坏配置文件」的形状:
|
||||||
|
// ① 候选 `{"value":"zhipu","label":"智谱 BigModel(zhipu)"}` ⇒ 下拉/多选里显示中文,
|
||||||
|
// 而 **option 的 value 必须还是英文 id**(value 会被写进 web-search.json,注册表按
|
||||||
|
// 名字取 provider;写成 label 就是「配置里存了中文名,后端一家都不认识」);
|
||||||
|
// ② 老形状(候选是**裸字符串**)必须仍然可用 —— 别的插件/老声明还在用它,且**绝不能
|
||||||
|
// 因为取不到标签就渲染成空白项**;
|
||||||
|
// ③ 表格行里的裸 id 要显示成中文标签,**未知 id 原样显示**(不吞、不丢、不空白)。
|
||||||
|
//
|
||||||
|
// 跑法(与 PluginPanel.render.test.ts 同):
|
||||||
|
//
|
||||||
|
// cd frontend/web \
|
||||||
|
// && ./node_modules/.bin/esbuild src/admin/plugin/ProviderOptionLabels.render.test.ts \
|
||||||
|
// --bundle --platform=node --format=cjs \
|
||||||
|
// --outfile=/tmp/cyrene-provider-option-labels.render.test.cjs \
|
||||||
|
// && node /tmp/cyrene-provider-option-labels.render.test.cjs
|
||||||
|
//
|
||||||
|
// 🔴 渲染类测试必须 `--format=cjs`:`--format=esm` 会整批崩在 bundle 深处。
|
||||||
|
// `tsc --noEmit` 会对本文件做类型检查,因此它只从项目内导入、不 import node:*。
|
||||||
|
//
|
||||||
|
// 范围说明:`PanelTableSection` 的行靠 effect 取(服务端渲染不跑 effect ⇒ 表里没有行),
|
||||||
|
// 所以表格那一档打的是它渲染单元格时用的纯函数(`cellDisplayText` / `optionLabelIndex`),
|
||||||
|
// 而不是 HTML 快照。
|
||||||
|
|
||||||
|
import { createElement } from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { splitListValue, toggleListOption } from "../schema/values";
|
||||||
|
import { PanelFormSection } from "./FormSection";
|
||||||
|
import { cellDisplayText, optionLabelIndex } from "./tableState";
|
||||||
|
import { decodeOptions, type PanelOptionDto, type PanelSectionDto } from "./types";
|
||||||
|
|
||||||
|
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}\n got = ${JSON.stringify(got)}\n want = ${JSON.stringify(want)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function has(haystack: string, needle: string): boolean {
|
||||||
|
return haystack.includes(needle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把 `<option value="…">文本</option>` 的**文本**取出来 —— 「显示名 ↔ 落盘值」配对的唯一硬证据。
|
||||||
|
* 属性顺序(React 可能补 `selected`)由 `[^>]*` 容错。 */
|
||||||
|
function optionText(html: string, value: string): string | null {
|
||||||
|
const m = new RegExp(`<option[^>]*\\bvalue="${value}"[^>]*>([^<]*)</option>`).exec(html);
|
||||||
|
return m === null ? null : m[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** web-search 的 `providers` 页表单(与 `plugins/tools/io/search/panel.go` 同形):
|
||||||
|
* provider 单选下拉、fallback 多选(降级链),两者都从 `plugin:providers` 拉候选。 */
|
||||||
|
const FORM: PanelSectionDto = {
|
||||||
|
type: "form",
|
||||||
|
id: "add",
|
||||||
|
title: "添加 / 修改提供商",
|
||||||
|
fields: [
|
||||||
|
{ key: "provider", label: "提供商", type: "select", optionsFrom: "plugin:providers", required: true },
|
||||||
|
{ key: "fallback", label: "降级链", type: "multiSelect", optionsFrom: "plugin:providers", placeholder: "按顺序依次尝试" },
|
||||||
|
],
|
||||||
|
submit: { action: "plugin.saveProvider" },
|
||||||
|
values: { provider: "zhipu", fallback: "bocha" },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 后端 `plugin.providers` 动作**新**形状解出来的候选(`{value,label}`)。 */
|
||||||
|
const NEW_SHAPE: Record<string, readonly PanelOptionDto[]> = {
|
||||||
|
"plugin:providers": decodeOptions({
|
||||||
|
options: [
|
||||||
|
{ value: "zhipu", label: "智谱 BigModel(zhipu)" },
|
||||||
|
{ value: "bocha", label: "博查 Bocha(bocha)" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 后端**老**形状(裸字符串数组)解出来的候选 —— 向后兼容的那一半。 */
|
||||||
|
const OLD_SHAPE: Record<string, readonly PanelOptionDto[]> = {
|
||||||
|
"plugin:providers": decodeOptions({ options: ["searxng", "zhipu"] }),
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderForm(options: Record<string, readonly PanelOptionDto[]>): string {
|
||||||
|
return renderToStaticMarkup(
|
||||||
|
createElement(PanelFormSection, {
|
||||||
|
pluginId: "web-search",
|
||||||
|
section: FORM,
|
||||||
|
role: "owner",
|
||||||
|
onReload: () => {},
|
||||||
|
options,
|
||||||
|
optionErrors: {},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ① 新形状:显示中文标签,提交/落盘的是英文 id ───────────────────────────────
|
||||||
|
{
|
||||||
|
const html = renderForm(NEW_SHAPE);
|
||||||
|
|
||||||
|
eq(optionText(html, "zhipu"), "智谱 BigModel(zhipu)", "候选的 value 是 id,显示名是中文标签");
|
||||||
|
eq(optionText(html, "bocha"), "博查 Bocha(bocha)", "第二家同样:值 → 显示名配对正确");
|
||||||
|
ok(has(html, "<option value=\"zhipu\""), "option 的 value 就是落盘的 id(不是 label)");
|
||||||
|
ok(
|
||||||
|
optionText(html, "智谱 BigModel(zhipu)") === null,
|
||||||
|
"中文标签**没有**变成任何 option 的 value(那就是「把 label 写进配置文件」的错法)",
|
||||||
|
);
|
||||||
|
ok(!has(html, 'value="智谱'), "整个表单里没有任何控件的值是中文标签(多选开关同理)");
|
||||||
|
|
||||||
|
// 多选(降级链):候选项显示中文,写回的是 id。
|
||||||
|
ok(has(html, ">智谱 BigModel(zhipu)</button>"), "多选候选项显示中文标签");
|
||||||
|
ok(has(html, 'title="zhipu"'), "多选候选项把落盘的 id 挂在 title 上(看得见、可核对)");
|
||||||
|
ok(!has(html, 'title="智谱'), "title 里也不是中文名(挂的是真正的值)");
|
||||||
|
ok(has(html, 'value="bocha"'), "多选下面那行是真正落盘的值(当前降级链 = bocha)");
|
||||||
|
ok(has(html, "英文 id"), "并如实说明「下面这行是真正落盘的值」");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ② 老形状(裸字符串候选):仍然可用,且不渲染空白项 ────────────────────────
|
||||||
|
{
|
||||||
|
eq(
|
||||||
|
JSON.stringify(OLD_SHAPE["plugin:providers"]),
|
||||||
|
JSON.stringify([
|
||||||
|
{ value: "searxng", label: "searxng" },
|
||||||
|
{ value: "zhipu", label: "zhipu" },
|
||||||
|
]),
|
||||||
|
"裸字符串候选归一成 {value,label},label 回落成值本身",
|
||||||
|
);
|
||||||
|
const html = renderForm(OLD_SHAPE);
|
||||||
|
eq(optionText(html, "searxng"), "searxng", "老形状下拉照旧可用:没有中文标签就显示值");
|
||||||
|
ok(!has(html, "<option value=\"\">"), "没有空白项(下拉里绝不出现一条空白候选)");
|
||||||
|
ok(has(html, 'title="searxng"'), "多选项也照旧(显示值、写回值)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ③ 标签缺失 / 空串:一律回落显示值,绝不显示空白 ───────────────────────────
|
||||||
|
{
|
||||||
|
const decoded = decodeOptions({ options: [{ value: "bocha" }, { value: "tavily", label: "" }] });
|
||||||
|
eq(JSON.stringify(decoded), JSON.stringify([{ value: "bocha", label: "bocha" }, { value: "tavily", label: "tavily" }]), "缺 label / 空 label 都回落成 value");
|
||||||
|
const html = renderForm({ "plugin:providers": decoded });
|
||||||
|
eq(optionText(html, "bocha"), "bocha", "缺标签的下拉项显示值本身");
|
||||||
|
ok(!has(html, "<option></option>"), "不出现「有值但没字」的空 option");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ④ 表格列:把行里的裸值翻成中文标签,未知 id 原样显示 ──────────────────────
|
||||||
|
{
|
||||||
|
const labels = optionLabelIndex(NEW_SHAPE);
|
||||||
|
eq(cellDisplayText("zhipu", labels), "智谱 BigModel(zhipu)", "表格里的 provider 显示中文标签(标签里已括注 id)");
|
||||||
|
eq(cellDisplayText("tavily", labels), "tavily", "未知 id 原样显示(不吞、不丢)");
|
||||||
|
eq(cellDisplayText("", labels), "—", "空值仍是占位符,不变成空白单元格");
|
||||||
|
eq(cellDisplayText(undefined, labels), "—", "缺字段同上");
|
||||||
|
eq(
|
||||||
|
JSON.stringify([...optionLabelIndex({ a: [{ value: "x", label: " " }], b: [{ value: "y", label: "Y" }] })]),
|
||||||
|
JSON.stringify([["y", "Y"]]),
|
||||||
|
"空白标签不进索引(免得表里出现空白单元格),跨来源合并成一张表",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ⑤ 多选写回:写的是 id(不是中文标签) ─────────────────────────────────────
|
||||||
|
{
|
||||||
|
const ZHIPU = { value: "zhipu", label: "智谱 BigModel(zhipu)" };
|
||||||
|
const BOCHA = { value: "bocha", label: "博查 Bocha(bocha)" };
|
||||||
|
eq(toggleListOption("bocha", ZHIPU), "bocha, zhipu", "点选把候选项的 id 追加到降级链末尾(不是它的中文标签)");
|
||||||
|
eq(toggleListOption("zhipu, bocha", ZHIPU), "bocha", "再点一次移出该 id,其余保持次序");
|
||||||
|
eq(toggleListOption("", BOCHA), "bocha", "空值起手可加入");
|
||||||
|
eq(JSON.stringify(splitListValue("zhipu,bocha\nsearxng")), JSON.stringify(["zhipu", "bocha", "searxng"]), "逗号 / 中文逗号 / 换行都当分隔符(与提交解析同一处口径)");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`provider option labels render test: ${checks} checks passed`);
|
||||||
@@ -2,8 +2,12 @@
|
|||||||
*
|
*
|
||||||
* 复用既有 `AdminTable`(含窗口化与空/错/加载三态),所以这里只做三件事:把列声明翻成
|
* 复用既有 `AdminTable`(含窗口化与空/错/加载三态),所以这里只做三件事:把列声明翻成
|
||||||
* `AdminColumn`、把行内动作接上宿主动作端点、把反馈画出来(含 viewer 的只读说明)。
|
* `AdminColumn`、把行内动作接上宿主动作端点、把反馈画出来(含 viewer 的只读说明)。
|
||||||
* 状态机在 `tableState.ts`;纯助手 `cellText` / `rowIdentity` 从那里复出口(单测按
|
* 状态机在 `tableState.ts`;纯助手 `cellText` / `rowIdentity` / `cellDisplayText` 从那里
|
||||||
* 本模块路径 import,换家不必改测试)。
|
* 复出口(单测按本模块路径 import,换家不必改测试)。
|
||||||
|
*
|
||||||
|
* 单元格的**中文显示名**:行里的 `provider` 这类裸值经 `cellDisplayText` 查一次
|
||||||
|
* `optionLabels`(面板级候选,来自声明的 `optionsFrom`)⇒ 有标签显示标签、没有就原样显示值。
|
||||||
|
* 查找**只影响显示**:行内动作照旧把原始 row(值为 id)送回后端,配置文件里永远不会出现标签。
|
||||||
*
|
*
|
||||||
* 对 viewer:后端快照已摘掉行内动作,这里**再摘一次**(纵深防御,见 `nav.canWrite`)。
|
* 对 viewer:后端快照已摘掉行内动作,这里**再摘一次**(纵深防御,见 `nav.canWrite`)。
|
||||||
*/
|
*/
|
||||||
@@ -15,12 +19,23 @@ import { useAuditStore } from "../audit";
|
|||||||
import { AdminTable, Notice, type AdminColumn } from "../components";
|
import { AdminTable, Notice, type AdminColumn } from "../components";
|
||||||
import { canWrite } from "./nav";
|
import { canWrite } from "./nav";
|
||||||
import { SectionHeader } from "./SectionHeader";
|
import { SectionHeader } from "./SectionHeader";
|
||||||
import { cellText, rowIdentity, useRowActionRunner, useTableRows, type PanelRow, type TableFlash } from "./tableState";
|
import {
|
||||||
|
cellDisplayText,
|
||||||
|
cellText,
|
||||||
|
rowIdentity,
|
||||||
|
useRowActionRunner,
|
||||||
|
useTableRows,
|
||||||
|
type PanelRow,
|
||||||
|
type TableFlash,
|
||||||
|
} from "./tableState";
|
||||||
import type { PanelSectionDto } from "./types";
|
import type { PanelSectionDto } from "./types";
|
||||||
|
|
||||||
export { cellText, rowIdentity };
|
export { cellText, rowIdentity, cellDisplayText };
|
||||||
export type { PanelRow };
|
export type { PanelRow };
|
||||||
|
|
||||||
|
/** 没有候选时的空索引(常量:避免每次渲染新建 Map 造成列定义无谓变化)。 */
|
||||||
|
const EMPTY_LABELS: ReadonlyMap<string, string> = new Map();
|
||||||
|
|
||||||
/** 反馈行:错误码与文案分两处(§5.4.6),成功/失败用不同色。 */
|
/** 反馈行:错误码与文案分两处(§5.4.6),成功/失败用不同色。 */
|
||||||
function TableFlashRow({ flash }: { flash: TableFlash | null }): ReactNode {
|
function TableFlashRow({ flash }: { flash: TableFlash | null }): ReactNode {
|
||||||
if (!flash) return null;
|
if (!flash) return null;
|
||||||
@@ -41,19 +56,24 @@ export interface PanelTableSectionProps {
|
|||||||
onReload: () => void;
|
onReload: () => void;
|
||||||
/** 行内动作返回显式值对象时回调(reauth「把该条带回表单」,见 `formValuesOfResult`)。 */
|
/** 行内动作返回显式值对象时回调(reauth「把该条带回表单」,见 `formValuesOfResult`)。 */
|
||||||
onValues?: (values: Record<string, unknown>) => void;
|
onValues?: (values: Record<string, unknown>) => void;
|
||||||
|
/** `value → label` 索引(面板级候选,见 `optionLabelIndex`):把行里的裸值翻成显示名。
|
||||||
|
* 候选来自**声明面的字段**(`optionsFrom`),viewer 的快照里没有写表单 ⇒ 取不到就显示
|
||||||
|
* 值本身,不空白、也不猜。 */
|
||||||
|
optionLabels?: ReadonlyMap<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PanelTableSection({ pluginId, section, role, onReload, onValues }: PanelTableSectionProps): ReactNode {
|
export function PanelTableSection({ pluginId, section, role, onReload, onValues, optionLabels }: PanelTableSectionProps): ReactNode {
|
||||||
const log = useAuditStore((s) => s.log);
|
const log = useAuditStore((s) => s.log);
|
||||||
const { rows, loading, error, load } = useTableRows(pluginId, section.source?.action ?? "");
|
const { rows, loading, error, load } = useTableRows(pluginId, section.source?.action ?? "");
|
||||||
const { busy, flash, run } = useRowActionRunner({ pluginId, onReload, reloadRows: load, log, onValues });
|
const { busy, flash, run } = useRowActionRunner({ pluginId, onReload, reloadRows: load, log, onValues });
|
||||||
|
|
||||||
const rowActions = section.rowActions ?? [];
|
const rowActions = section.rowActions ?? [];
|
||||||
const canAct = canWrite(role) && rowActions.length > 0;
|
const canAct = canWrite(role) && rowActions.length > 0;
|
||||||
|
const labels = optionLabels ?? EMPTY_LABELS;
|
||||||
const columns: AdminColumn<PanelRow>[] = (section.columns ?? []).map((c) => ({
|
const columns: AdminColumn<PanelRow>[] = (section.columns ?? []).map((c) => ({
|
||||||
key: c.key,
|
key: c.key,
|
||||||
title: c.label ?? c.key,
|
title: c.label ?? c.key,
|
||||||
render: (row) => <span className="block max-w-xs truncate">{cellText(row.data[c.key])}</span>,
|
render: (row) => <span className="block max-w-xs truncate">{cellDisplayText(row.data[c.key], labels)}</span>,
|
||||||
}));
|
}));
|
||||||
if (canAct) {
|
if (canAct) {
|
||||||
columns.push({
|
columns.push({
|
||||||
|
|||||||
@@ -97,7 +97,15 @@ export interface FieldOptionsState {
|
|||||||
optionErrors: Record<string, string>;
|
optionErrors: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 下拉候选:按 `optionsFrom` 逐条取一次(取不到就降级成文本框,由调用方在说明里写明)。 */
|
/** 下拉候选:按 `optionsFrom` 逐条取一次(取不到就降级成文本框,由调用方在说明里写明)。
|
||||||
|
*
|
||||||
|
* 两条口径:
|
||||||
|
* - **形状兼容**由 `types.decodeOptions` 兜住:候选项可以是**裸字符串**(老声明 / 别的
|
||||||
|
* 插件还在用的老形状)或 `{value,label}` 对象,两种都归一成 `{value,label}`。控件显示
|
||||||
|
* `label`、**只有 `value` 会进表单值** —— label 一旦落盘就是把配置文件写坏。
|
||||||
|
* - **面板级只调一次**(`PluginPanelView`):表单拿它渲染下拉,表格拿它把行里的裸值翻成
|
||||||
|
* 中文显示名(`tableState.optionLabelIndex` 查表)。分两处各拉一次就是同一份候选发两次
|
||||||
|
* 请求,且表格那份会因「本区块没有字段」而拉不到来源。 */
|
||||||
export function useFieldOptions(pluginId: string, declared: readonly PanelFieldDto[]): FieldOptionsState {
|
export function useFieldOptions(pluginId: string, declared: readonly PanelFieldDto[]): FieldOptionsState {
|
||||||
const [options, setOptions] = useState<Record<string, readonly PanelOptionDto[]>>({});
|
const [options, setOptions] = useState<Record<string, readonly PanelOptionDto[]>>({});
|
||||||
const [optionErrors, setOptionErrors] = useState<Record<string, string>>({});
|
const [optionErrors, setOptionErrors] = useState<Record<string, string>>({});
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { actionFailure } from "./actions";
|
import { actionFailure } from "./actions";
|
||||||
import { runPluginAction } from "./panelsApi";
|
import { runPluginAction } from "./panelsApi";
|
||||||
import { decodeRows, formValuesOfResult } from "./types";
|
import { decodeRows, formValuesOfResult, type PanelOptionDto } from "./types";
|
||||||
|
|
||||||
export interface PanelRow {
|
export interface PanelRow {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -33,6 +33,29 @@ export function cellText(v: unknown): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 候选项的 `value → label` 索引(各来源合并):表格把行里的**裸值**翻成显示名用。
|
||||||
|
*
|
||||||
|
* **只用于显示**:落盘/写回的永远是 value(label 进配置文件就是写坏配置)。同值多来源
|
||||||
|
* 冲突时先到者胜(一个面板里通常只有一个来源);空值或空标签一律跳过 —— 免得在表里
|
||||||
|
* 留下一条空白单元格。 */
|
||||||
|
export function optionLabelIndex(options: Record<string, readonly PanelOptionDto[]>): Map<string, string> {
|
||||||
|
const index = new Map<string, string>();
|
||||||
|
for (const list of Object.values(options)) {
|
||||||
|
for (const o of list) {
|
||||||
|
if (o.value === "" || o.label.trim() === "") continue;
|
||||||
|
if (!index.has(o.value)) index.set(o.value, o.label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单元格的显示文本:值命中候选 ⇒ 显示 label(标签里括注了 id,人工核对对得上);
|
||||||
|
* **查不到 ⇒ 原样显示值本身** —— 未知 id 不吞、不丢,也绝不显示成一个空白项。 */
|
||||||
|
export function cellDisplayText(v: unknown, labels: ReadonlyMap<string, string>): string {
|
||||||
|
const text = cellText(v);
|
||||||
|
return labels.get(text) ?? text;
|
||||||
|
}
|
||||||
|
|
||||||
/** 行身份:插件给了真实键就用它,否则用**零填充下标** —— `AdminTable` 会按 rowKey 排序,
|
/** 行身份:插件给了真实键就用它,否则用**零填充下标** —— `AdminTable` 会按 rowKey 排序,
|
||||||
* 裸下标的自然序("10" < "2")会把后端给的顺序打乱。 */
|
* 裸下标的自然序("10" < "2")会把后端给的顺序打乱。 */
|
||||||
export function rowIdentity(row: Record<string, unknown>, index: number): string {
|
export function rowIdentity(row: Record<string, unknown>, index: number): string {
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
* (AGENTS §10);这里每个控件都是「一个类型、十来行」,分发器留在 `fields.tsx`。
|
* (AGENTS §10);这里每个控件都是「一个类型、十来行」,分发器留在 `fields.tsx`。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Switch } from "../../components/primitives";
|
import { Button, Switch } from "../../components/primitives";
|
||||||
import type { SchemaField, SchemaOption } from "./fieldTypes";
|
import type { SchemaField, SchemaOption } from "./fieldTypes";
|
||||||
import { fieldLabel } from "./values";
|
import { fieldLabel, splitListValue, toggleListOption } from "./values";
|
||||||
|
|
||||||
export const inputCls =
|
export const inputCls =
|
||||||
"w-full 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)]";
|
"w-full 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)]";
|
||||||
@@ -95,7 +95,10 @@ export function SwitchControl({ field, value, onChange, disabled }: ControlProps
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 下拉:有候选才给下拉。自由输入一个未知值会被后端静默回落默认(值域受后端归一化约束),
|
/** 下拉:有候选才给下拉。自由输入一个未知值会被后端静默回落默认(值域受后端归一化约束),
|
||||||
* 没有候选时降级成文本框(调用方会在字段说明里写明原因),而不是给一个空下拉。 */
|
* 没有候选时降级成文本框(调用方会在字段说明里写明原因),而不是给一个空下拉。
|
||||||
|
*
|
||||||
|
* 候选项是 `{value,label}`:**显示 `label`(中文名)、提交 `value`(落盘的 id)** ——
|
||||||
|
* `value` 写错一个字母就是「配了不生效」,`label` 进配置文件则是直接把配置写坏。 */
|
||||||
export function SelectControl({ field, value, onChange, disabled, invalid, options }: ControlProps): React.ReactNode {
|
export function SelectControl({ field, value, onChange, disabled, invalid, options }: ControlProps): React.ReactNode {
|
||||||
const opts = options ?? field.options ?? [];
|
const opts = options ?? field.options ?? [];
|
||||||
if (opts.length === 0) return <TextControl field={field} value={value} onChange={onChange} disabled={disabled} invalid={invalid} />;
|
if (opts.length === 0) return <TextControl field={field} value={value} onChange={onChange} disabled={disabled} invalid={invalid} />;
|
||||||
@@ -119,20 +122,55 @@ export function SelectControl({ field, value, onChange, disabled, invalid, optio
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 多选:逗号分隔的文本框,占位符列出候选(候选多时下拉反而难选,且候选可能为空)。 */
|
/** 多选:**有候选就画成「候选开关」**(点一下加入 / 移出,显示 `label`、写进去的是 `value`);
|
||||||
|
* 没有候选就退回手填文本框(与旧行为逐字一致),逗号分隔。
|
||||||
|
*
|
||||||
|
* 为什么不是只给一个文本框:候选原来只回英文 id,主人看到的就是「全是英文名」;而把中文
|
||||||
|
* 显示名填进文本框又会写坏配置(落盘的值是 provider 名,后端按名字取)。开关让显示与落盘
|
||||||
|
* 各归各位。
|
||||||
|
*
|
||||||
|
* 文本框保留:它显示并允许直接编辑**真正落盘的值**(逗号分隔),也是「候选里没有的家 /
|
||||||
|
* 顺序调整」的唯一出口。 */
|
||||||
export function MultiSelectControl({ field, value, onChange, disabled, invalid, options }: ControlProps): React.ReactNode {
|
export function MultiSelectControl({ field, value, onChange, disabled, invalid, options }: ControlProps): React.ReactNode {
|
||||||
const opts = options ?? field.options ?? [];
|
const opts = options ?? field.options ?? [];
|
||||||
const placeholder = opts.length > 0 ? opts.map((o) => o.value).join(", ") : field.placeholder;
|
if (opts.length === 0) {
|
||||||
|
return <TextControl field={field} value={value} onChange={onChange} disabled={disabled} invalid={invalid} />;
|
||||||
|
}
|
||||||
|
const selected = splitListValue(value);
|
||||||
return (
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{opts.map((o) => {
|
||||||
|
const on = selected.includes(o.value);
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={o.value}
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={on ? "soft" : "ghost"}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-pressed={on}
|
||||||
|
title={o.value}
|
||||||
|
onClick={() => onChange(toggleListOption(value, o))}
|
||||||
|
>
|
||||||
|
{o.label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
placeholder={placeholder}
|
placeholder={field.placeholder}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
aria-label={fieldLabel(field)}
|
aria-label={fieldLabel(field)}
|
||||||
className={invalid ? inputClsError : inputCls}
|
className={invalid ? inputClsError : inputCls}
|
||||||
/>
|
/>
|
||||||
|
<span className="block text-[11px] text-[var(--mu-text-mid)]">
|
||||||
|
上面点选的是显示名,下面这行是真正落盘的值(英文 id、逗号分隔,顺序即先后顺序),也可以直接改。
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
* (design/09 §5.4.2 的哨兵语义)。
|
* (design/09 §5.4.2 的哨兵语义)。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { SchemaField, SchemaFieldType } from "./fieldTypes";
|
import type { SchemaField, SchemaFieldType, SchemaOption } from "./fieldTypes";
|
||||||
|
|
||||||
/** 表单值一律以字符串承载(与渠道配置表单同一口径):控件层只认 string,
|
/** 表单值一律以字符串承载(与渠道配置表单同一口径):控件层只认 string,
|
||||||
* 提交时再按声明的 type 还原成后端要的类型。 */
|
* 提交时再按声明的 type 还原成后端要的类型。 */
|
||||||
@@ -29,6 +29,24 @@ export function fieldLabel(f: SchemaField): string {
|
|||||||
return label !== "" ? label : f.key;
|
return label !== "" ? label : f.key;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 多选字段值的分隔口径(逗号 / 中文逗号 / 换行)。**只此一处**:装载、提交解析与
|
||||||
|
* 「点选候选开关」三处共用,各写一份就会出现「面板看着有值、后端收到空数组」。 */
|
||||||
|
export function splitListValue(text: string): string[] {
|
||||||
|
return text.split(/[,,\n]/).map((s) => s.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 候选开关的取值:点一下把候选项的 **`value`(英文 id)** 加入 / 移出多选值。
|
||||||
|
*
|
||||||
|
* **写进去的必须是 `value`,不是 `label`(中文显示名)** —— 这个字段的值会落进
|
||||||
|
* `web-search.json` 的降级链,后端按名字取 provider,写中文名等于写坏配置。参数收整个
|
||||||
|
* 候选项(只读它的 `value`)就是为了让这条分离在函数签名上看得见。
|
||||||
|
* 顺序 = 加入顺序(降级链按序生效),因此从值末尾追加、移出时保持其余相对次序。 */
|
||||||
|
export function toggleListOption(text: string, option: SchemaOption): string {
|
||||||
|
const items = splitListValue(text);
|
||||||
|
const next = items.includes(option.value) ? items.filter((x) => x !== option.value) : [...items, option.value];
|
||||||
|
return next.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
/** 敏感字段:读侧永不回显明文,写侧留空 = 不改动(design/09 §5.4.2)。 */
|
/** 敏感字段:读侧永不回显明文,写侧留空 = 不改动(design/09 §5.4.2)。 */
|
||||||
export function isSensitive(f: SchemaField): boolean {
|
export function isSensitive(f: SchemaField): boolean {
|
||||||
return f.type === "secret";
|
return f.type === "secret";
|
||||||
@@ -129,7 +147,7 @@ export function parseFieldValue(f: SchemaField, raw: string): { ok: true; value:
|
|||||||
case "switch":
|
case "switch":
|
||||||
return { ok: true, value: text === "true" };
|
return { ok: true, value: text === "true" };
|
||||||
case "multiSelect":
|
case "multiSelect":
|
||||||
return { ok: true, value: text.split(/[,,\n]/).map((s) => s.trim()).filter(Boolean) };
|
return { ok: true, value: splitListValue(text) };
|
||||||
case "kv": {
|
case "kv": {
|
||||||
let parsed: unknown;
|
let parsed: unknown;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,492 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import type { ChatMessage, Mode, PersonaRecord, ToolUsed } from "../lib/types";
|
|
||||||
import { useAppStore } from "../store/useAppStore";
|
|
||||||
import { useCurrentPersona } from "../store/personaSelectors";
|
|
||||||
import { personaAvatar, resolveMessagePersona } from "../lib/persona";
|
|
||||||
import { labelFor } from "../lib/enums";
|
|
||||||
import { Tag } from "./primitives";
|
|
||||||
import { Icon, type IconName } from "./Icon";
|
|
||||||
import { WakePath } from "./status";
|
|
||||||
import { modeBody } from "./messages";
|
|
||||||
import { DocumentList } from "./messages/Documents";
|
|
||||||
import { DeliveryChip, TurnTag, userBubbleClass } from "./messages/TurnMeta";
|
|
||||||
import { UserMessageActions } from "./messages/MessageActions";
|
|
||||||
import { renderContent } from "../lib/renderContent";
|
|
||||||
import { splitBubble, type ContentPart } from "../lib/parseContent";
|
|
||||||
|
|
||||||
/** Collapsible inline chain-of-thought block. A Miuix disclosure row: icon chip
|
|
||||||
* + label, spinner when still thinking, and a single-line muted preview of the
|
|
||||||
* reasoning while collapsed so the block reads at a glance. */
|
|
||||||
export function ThinkingBlock({
|
|
||||||
text,
|
|
||||||
streaming,
|
|
||||||
}: {
|
|
||||||
text?: string;
|
|
||||||
streaming?: boolean;
|
|
||||||
}): React.ReactNode | null {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
if (!text && !streaming) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mb-2 overflow-hidden rounded-[var(--mu-radius-sm)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)]/60">
|
|
||||||
<button
|
|
||||||
onClick={() => setOpen((v) => !v)}
|
|
||||||
className="flex w-full items-center gap-2 px-3 py-2 text-xs transition-colors duration-200 hover:bg-[var(--mu-surface-4)]/40"
|
|
||||||
>
|
|
||||||
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-[var(--mu-radius-xs)] bg-[var(--mu-accent)]/15 text-[var(--mu-accent)]">
|
|
||||||
<Icon name="memory" size={13} />
|
|
||||||
</span>
|
|
||||||
<span className="font-medium text-[var(--mu-text-mid)]">思维链</span>
|
|
||||||
{streaming && !text ? (
|
|
||||||
<span className="inline-flex items-center gap-1 text-[var(--mu-text-lo)]">
|
|
||||||
<Icon name="refresh" size={11} className="animate-spin" />
|
|
||||||
思考中…
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-[var(--mu-text-lo)]">{open ? "收起" : "展开"}</span>
|
|
||||||
)}
|
|
||||||
<span
|
|
||||||
className={`ml-auto transition-transform duration-200 ease-[var(--mu-spring)] ${open ? "rotate-90" : ""}`}
|
|
||||||
>
|
|
||||||
<Icon name="arrow" size={12} className="text-[var(--mu-text-lo)]" />
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
{open && text ? (
|
|
||||||
<pre className="max-h-40 overflow-auto whitespace-pre-wrap px-3 pb-2.5 text-xs leading-relaxed text-[var(--mu-text-mid)]">
|
|
||||||
{text}
|
|
||||||
</pre>
|
|
||||||
) : !open && text ? (
|
|
||||||
<p className="truncate px-3 pb-2 text-xs text-[var(--mu-text-lo)]">{text}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Tool echo card (工具回显). A status chip + tool name + muted one-line summary
|
|
||||||
* on the left, a color-coded result tag on the right, and collapsible detail. */
|
|
||||||
export function ToolCard({ tool }: { tool: ToolUsed }): React.ReactNode {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const status = tool.ok === undefined ? "neutral" : tool.ok ? "ok" : "err";
|
|
||||||
return (
|
|
||||||
<div className="mb-2 overflow-hidden rounded-[var(--mu-radius-sm)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)]/60">
|
|
||||||
<button
|
|
||||||
onClick={() => setOpen((v) => !v)}
|
|
||||||
className="flex w-full items-center gap-2.5 px-3 py-2 text-xs transition-colors duration-200 hover:bg-[var(--mu-surface-4)]/40"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-[var(--mu-radius-xs)] ${statusBadge(status)}`}
|
|
||||||
>
|
|
||||||
<Icon name={statusIcon(status)} size={12} className={status === "neutral" ? "animate-spin" : undefined} />
|
|
||||||
</span>
|
|
||||||
<span className="min-w-0 flex-1 text-left">
|
|
||||||
<span className="block truncate font-medium text-[var(--mu-text-mid)]">{tool.name}</span>
|
|
||||||
{tool.summary ? (
|
|
||||||
<span className="block truncate text-[var(--mu-text-lo)]">{tool.summary}</span>
|
|
||||||
) : null}
|
|
||||||
</span>
|
|
||||||
<Tag tone={status} className="shrink-0">{statusLabel(status)}</Tag>
|
|
||||||
<span className={`shrink-0 transition-transform duration-200 ease-[var(--mu-spring)] ${open ? "rotate-90" : ""}`}>
|
|
||||||
<Icon name="arrow" size={12} className="text-[var(--mu-text-lo)]" />
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
{(open || tool.detail) && tool.detail ? (
|
|
||||||
<pre className="max-h-40 overflow-auto whitespace-pre-wrap px-3 pb-2.5 text-xs text-[var(--mu-text-mid)]">
|
|
||||||
{tool.detail}
|
|
||||||
</pre>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type ToolStatus = "ok" | "err" | "neutral";
|
|
||||||
|
|
||||||
function statusBadge(status: ToolStatus): string {
|
|
||||||
switch (status) {
|
|
||||||
case "ok": return "bg-[var(--mu-ok)]/15 text-[var(--mu-ok)]";
|
|
||||||
case "err": return "bg-[var(--mu-err)]/15 text-[var(--mu-err)]";
|
|
||||||
default: return "bg-[var(--mu-surface-4)] text-[var(--mu-text-mid)]";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function statusIcon(status: ToolStatus): IconName {
|
|
||||||
switch (status) {
|
|
||||||
case "ok": return "check";
|
|
||||||
case "err": return "close";
|
|
||||||
default: return "refresh";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function statusLabel(status: ToolStatus): string {
|
|
||||||
switch (status) {
|
|
||||||
case "ok": return "成功";
|
|
||||||
case "err": return "失败";
|
|
||||||
default: return "运行中";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Small muted source label for a persisted message (design/16 §4.5): shows the
|
|
||||||
* concrete client the message came from, e.g. "来自 web:chrome:a1b2c3". Rendered
|
|
||||||
* only when a deviceId is present, so legacy/unlabelled messages stay clean. The
|
|
||||||
* Web client defaults to this label; other clients decide themselves. */
|
|
||||||
function SourceLabel({ deviceId, role }: { deviceId?: string; role?: string }): React.ReactNode {
|
|
||||||
if (!deviceId) return null;
|
|
||||||
// A user message is received FROM a client; an assistant reply goes TO the
|
|
||||||
// client that prompted it. Semantic direction differs — "来自" vs "发送至".
|
|
||||||
const dir = role === "assistant" ? "发送至" : "来自";
|
|
||||||
return (
|
|
||||||
<div className="text-[10px] leading-tight text-[var(--mu-text-lo)]">
|
|
||||||
{dir} {deviceId}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Small sender label for a third-party message (design/16 §4.6): shows the
|
|
||||||
* sender's display name, e.g. "第三方 · 张三". Blank for the owner's private chat
|
|
||||||
* (no senderName), so owner bubbles stay unlabelled. */
|
|
||||||
function SenderLabel({ name }: { name?: string }): React.ReactNode {
|
|
||||||
if (!name) return null;
|
|
||||||
return (
|
|
||||||
<div className="text-[10px] leading-tight text-[var(--mu-text-lo)]">
|
|
||||||
第三方 · {name}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The bubble-side binding of `lib/persona.ts#resolveMessagePersona` (design/22
|
|
||||||
* §7-5): which role spoke this message, and whether it differs from the role the
|
|
||||||
* session speaks as now. */
|
|
||||||
function useSpokenPersona(personaId?: string): {
|
|
||||||
persona: PersonaRecord | null;
|
|
||||||
switched: boolean;
|
|
||||||
} {
|
|
||||||
const personas = useAppStore((s) => s.personas);
|
|
||||||
const active = useCurrentPersona();
|
|
||||||
return resolveMessagePersona(personaId, personas, active);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Muted "角色 · X" line for a bubble whose authoring role differs from the role
|
|
||||||
* the session speaks as now. */
|
|
||||||
function PersonaLabel({ name }: { name?: string }): React.ReactNode {
|
|
||||||
if (!name) return null;
|
|
||||||
return (
|
|
||||||
<div className="text-[10px] leading-tight text-[var(--mu-text-lo)]">角色 · {name}</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 用户气泡下面那行小字:回合小标 + 投递态角标 + 来源。两个用户气泡(单块 / 分段)
|
|
||||||
* 共用一处,避免「四份渲染各写一遍、日后漂移」。 */
|
|
||||||
function UserMeta({ message }: { message: ChatMessage }): React.ReactNode {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-wrap items-center justify-end gap-1.5">
|
|
||||||
<TurnTag turnId={message.turnId} align="right" />
|
|
||||||
<DeliveryChip
|
|
||||||
delivery={message.delivery}
|
|
||||||
steerStep={message.steerStep}
|
|
||||||
rejectReason={message.rejectReason}
|
|
||||||
/>
|
|
||||||
<SourceLabel deviceId={message.deviceId} role={message.role} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A single message bubble. Per design/07 §1 the bubble shape is driven by the
|
|
||||||
* message's own `mode`: the same role renders differently under different modes,
|
|
||||||
* and a single session may mix shapes. Falls back to `chat` when mode is absent
|
|
||||||
* (legacy persisted messages). */
|
|
||||||
export function MessageBubble({
|
|
||||||
message,
|
|
||||||
active = false,
|
|
||||||
}: {
|
|
||||||
message: ChatMessage;
|
|
||||||
active?: boolean;
|
|
||||||
}): React.ReactNode {
|
|
||||||
const mode = message.mode ?? "chat";
|
|
||||||
if (message.role === "user") {
|
|
||||||
return <UserBubble message={message} mode={mode} />;
|
|
||||||
}
|
|
||||||
return <AssistantBubble message={message} mode={mode} active={active} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Split a chat reply's annotation-stripped body into `<p>`-delimited paragraph
|
|
||||||
* bodies. Each complete `<p>…</p>` block becomes one bubble segment; a body that
|
|
||||||
* carries no `<p>` wrapper falls back to splitting on `\n\n` runs, and a body
|
|
||||||
* with neither yields an empty list so the caller handles the empty reply.
|
|
||||||
* Non-greedy + multi-line so a tag may span newlines; a still-streaming,
|
|
||||||
* unclosed `<p>` simply does not match and stays out until it closes. */
|
|
||||||
function splitParagraphs(content: string): string[] {
|
|
||||||
// Preferred: the agent wraps each paragraph in <p>…</p> (design/07 §2). When it
|
|
||||||
// emits plain \n\n runs instead (the model often does), split on those too so
|
|
||||||
// each logical paragraph becomes its own bubble. `content` here is the
|
|
||||||
// annotation-stripped body from `splitBubble`, so no <action>/<narration> block
|
|
||||||
// can be cut across; never split on a single \n (that is an in-line break, kept
|
|
||||||
// inside the paragraph).
|
|
||||||
const pRe = /<p[^>]*>([\s\S]*?)<\/p>/g;
|
|
||||||
const pSegments = [...content.matchAll(pRe)].map((m) => m[1].trim());
|
|
||||||
if (pSegments.length > 0) return pSegments;
|
|
||||||
|
|
||||||
// Keep non-<p> remainder as its own paragraph bodies.
|
|
||||||
const segments: string[] = [];
|
|
||||||
for (const part of content.split(/\n\s*\n/)) {
|
|
||||||
if (!part.trim()) continue;
|
|
||||||
segments.push(part.trim());
|
|
||||||
}
|
|
||||||
return segments;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Small muted attachment note for a single `<action>` / `<narration>` block.
|
|
||||||
* Rendered as a grey italic micro-line OUTSIDE the bubble (anchored to the
|
|
||||||
* message's head/tail by `splitBubble`) — never inside the bubble body.
|
|
||||||
* `align` mirrors the bubble column: `left` indents under the assistant avatar
|
|
||||||
* column, `right` lines the note up with a right-aligned user bubble. */
|
|
||||||
function BubbleNotes({
|
|
||||||
items,
|
|
||||||
align = "left",
|
|
||||||
}: {
|
|
||||||
items: ContentPart[];
|
|
||||||
align?: "left" | "right";
|
|
||||||
}): React.ReactNode {
|
|
||||||
if (items.length === 0) return null;
|
|
||||||
const container =
|
|
||||||
align === "right"
|
|
||||||
? "flex flex-col items-end gap-0.5"
|
|
||||||
: "flex flex-col items-start gap-0.5 pl-10";
|
|
||||||
return (
|
|
||||||
<div className={container}>
|
|
||||||
{items.map((item, i) => (
|
|
||||||
<div key={i} className="text-[10px] italic leading-tight text-[var(--mu-text-lo)]">
|
|
||||||
*{item.type === "action" ? "动作" : "旁白"}:{item.text}*
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Chat-mode body: a Miuix surface shell around a whitespace-preserving span.
|
|
||||||
* `text` is already the annotation-stripped body paragraph from `splitBubble`,
|
|
||||||
* so it renders as plain text only — action/narration notes live outside. */
|
|
||||||
function ChatBody({ text, streaming }: { text: string; streaming?: boolean }): React.ReactNode {
|
|
||||||
return (
|
|
||||||
<div className="liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5 text-sm text-[var(--mu-text-hi)] shadow-[var(--mu-shadow-1)]">
|
|
||||||
{text ? (
|
|
||||||
<span className="whitespace-pre-wrap">{renderContent(text)}</span>
|
|
||||||
) : streaming ? (
|
|
||||||
<span className="inline-flex items-center gap-1.5 text-[var(--mu-text-lo)]">
|
|
||||||
<Icon name="refresh" size={12} className="animate-spin" />
|
|
||||||
生成中…
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One assistant paragraph bubble. `full` renders the complete bubble (avatar /
|
|
||||||
* mood / 思维链 / 工具卡 / wake path); compact segments render just the aligned
|
|
||||||
* body, so the chrome is not repeated paragraph after paragraph. */
|
|
||||||
function AssistantParaBubble({
|
|
||||||
message,
|
|
||||||
text,
|
|
||||||
full,
|
|
||||||
active = false,
|
|
||||||
}: {
|
|
||||||
message: ChatMessage;
|
|
||||||
text: string;
|
|
||||||
full: boolean;
|
|
||||||
active?: boolean;
|
|
||||||
}): React.ReactNode {
|
|
||||||
const { persona, switched } = useSpokenPersona(message.personaId);
|
|
||||||
return (
|
|
||||||
<div className="mu-rise-in flex gap-3">
|
|
||||||
{full ? (
|
|
||||||
<img src={personaAvatar(persona)} alt="" className="mt-1 h-7 w-7 shrink-0 rounded-full object-cover ring-1 ring-[var(--mu-glass-border)]" />
|
|
||||||
) : (
|
|
||||||
<div className="w-7 shrink-0" aria-hidden />
|
|
||||||
)}
|
|
||||||
<div className="min-w-0 max-w-[85%]">
|
|
||||||
{full ? <ModeTag mode={message.mode ?? "chat"} /> : null}
|
|
||||||
{full && message.mood ? (
|
|
||||||
<div className="mb-1 text-xs text-[var(--mu-text-lo)]">心情 · {labelFor("mood", message.mood)}</div>
|
|
||||||
) : null}
|
|
||||||
{full ? <SourceLabel deviceId={message.deviceId} role={message.role} /> : null}
|
|
||||||
{full && switched ? <PersonaLabel name={persona?.name} /> : null}
|
|
||||||
{full ? <TurnTag turnId={message.turnId} /> : null}
|
|
||||||
{full ? <WakePath active={active} /> : null}
|
|
||||||
{full ? <ThinkingBlock text={message.thinking} streaming={message.streaming} /> : null}
|
|
||||||
{full ? message.tools?.map((t, i) => <ToolCard key={i} tool={t} />) : null}
|
|
||||||
<ChatBody text={text} streaming={message.streaming} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One user paragraph bubble. Only the first segment carries the optional image. */
|
|
||||||
function UserParaBubble({
|
|
||||||
message,
|
|
||||||
text,
|
|
||||||
full,
|
|
||||||
}: {
|
|
||||||
message: ChatMessage;
|
|
||||||
text: string;
|
|
||||||
full: boolean;
|
|
||||||
}): React.ReactNode {
|
|
||||||
return (
|
|
||||||
<div className="mu-rise-in flex flex-col items-end gap-0.5">
|
|
||||||
<div
|
|
||||||
className={`${userBubbleClass(message.delivery)} max-w-[80%] rounded-[var(--mu-radius-lg)] px-4 py-2.5 text-sm shadow-[var(--mu-shadow-1)]`}
|
|
||||||
>
|
|
||||||
{full && message.image ? (
|
|
||||||
<img
|
|
||||||
src={message.image}
|
|
||||||
alt="附件"
|
|
||||||
className="mb-1.5 block max-h-56 rounded-[var(--mu-radius-sm)] object-cover"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{full ? <DocumentList documents={message.documents} /> : null}
|
|
||||||
<span className="whitespace-pre-wrap">{renderContent(text)}</span>
|
|
||||||
</div>
|
|
||||||
{full ? <UserMeta message={message} /> : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Chat-only entry: a `chat`-mode reply's `<action>`/`<narration>` blocks are
|
|
||||||
* peeled off by `splitBubble` into head/tail attachment notes, and the remaining
|
|
||||||
* plain-text body is split into stacked bubbles (first = full bubble, remainder
|
|
||||||
* = compact). Any other mode — code / home / work / docs / vision / voice /
|
|
||||||
* agents / settings — is passed straight through to MessageBubble so its body
|
|
||||||
* renders untouched: no `<p>` splitting, and its content (which may itself
|
|
||||||
* contain `<p>` / `<action>` / `<narration>` as literal text or code) is never
|
|
||||||
* re-parsed. */
|
|
||||||
export function SplitChatBubble({
|
|
||||||
message,
|
|
||||||
active = false,
|
|
||||||
}: {
|
|
||||||
message: ChatMessage;
|
|
||||||
active?: boolean;
|
|
||||||
}): React.ReactNode {
|
|
||||||
const mode = message.mode ?? "chat";
|
|
||||||
if (mode !== "chat") return <MessageBubble message={message} active={active} />;
|
|
||||||
|
|
||||||
const { body, head, tail } = splitBubble(message.content ?? "");
|
|
||||||
const segments = splitParagraphs(body);
|
|
||||||
|
|
||||||
if (segments.length === 0 && head.length === 0 && tail.length === 0) {
|
|
||||||
// No visible content: hand off so the empty / still-streaming reply keeps its
|
|
||||||
// usual chrome (avatar, mood, "生成中…").
|
|
||||||
return <MessageBubble message={message} active={active} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group the paragraphs of one reply into a tighter column so intra-reply
|
|
||||||
// segments read as one bubble while separate replies keep the 16px list gap.
|
|
||||||
// The annotation notes (head/tail) attach to the whole group — above the first
|
|
||||||
// bubble and below the last — never inside a bubble's body.
|
|
||||||
const align = message.role === "user" ? "right" : "left";
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`flex flex-col gap-2.5 ${message.role === "user" ? "group relative items-end" : ""}`}
|
|
||||||
>
|
|
||||||
<BubbleNotes items={head} align={align} />
|
|
||||||
{segments.map((seg, i) =>
|
|
||||||
message.role === "user" ? (
|
|
||||||
<UserParaBubble key={i} message={message} text={seg} full={i === 0} />
|
|
||||||
) : (
|
|
||||||
<AssistantParaBubble
|
|
||||||
key={i}
|
|
||||||
message={message}
|
|
||||||
text={seg}
|
|
||||||
full={i === 0}
|
|
||||||
active={active}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
<BubbleNotes items={tail} align={align} />
|
|
||||||
{message.role === "user" ? <UserMessageActions message={message} /> : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** User-side bubble. The outer accent pill is shared; the inner content may be
|
|
||||||
* mode-shaped (e.g. code request renders in a mono pre). */
|
|
||||||
function UserBubble({ message, mode }: { message: ChatMessage; mode: Mode }): React.ReactNode {
|
|
||||||
const body: React.ReactNode =
|
|
||||||
mode === "code" ? (
|
|
||||||
<pre className="whitespace-pre-wrap font-mono text-xs leading-relaxed">{renderContent(message.content)}</pre>
|
|
||||||
) : message.content ? (
|
|
||||||
<span className="whitespace-pre-wrap">{renderContent(message.content)}</span>
|
|
||||||
) : null;
|
|
||||||
return (
|
|
||||||
<div className="mu-rise-in group relative flex flex-col items-end gap-0.5">
|
|
||||||
<div
|
|
||||||
className={`${userBubbleClass(message.delivery)} max-w-[80%] rounded-[var(--mu-radius-lg)] px-4 py-2.5 text-sm shadow-[var(--mu-shadow-1)]`}
|
|
||||||
>
|
|
||||||
{message.image ? (
|
|
||||||
<img
|
|
||||||
src={message.image}
|
|
||||||
alt="附件"
|
|
||||||
className="mb-1.5 block max-h-56 rounded-[var(--mu-radius-sm)] object-cover"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<DocumentList documents={message.documents} />
|
|
||||||
{body}
|
|
||||||
</div>
|
|
||||||
<UserMeta message={message} />
|
|
||||||
<SenderLabel name={message.senderName} />
|
|
||||||
<UserMessageActions message={message} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Assistant-side bubble. Outer layout shared; the body renders per mode via the
|
|
||||||
* mode-specific body (chat / code / docs / home / work / vision …). */
|
|
||||||
function AssistantBubble({
|
|
||||||
message,
|
|
||||||
mode,
|
|
||||||
active = false,
|
|
||||||
}: {
|
|
||||||
message: ChatMessage;
|
|
||||||
mode: Mode;
|
|
||||||
active?: boolean;
|
|
||||||
}): React.ReactNode {
|
|
||||||
const { persona, switched } = useSpokenPersona(message.personaId);
|
|
||||||
return (
|
|
||||||
<div className="mu-rise-in flex gap-3">
|
|
||||||
<img src={personaAvatar(persona)} alt="" className="mt-1 h-7 w-7 shrink-0 rounded-full object-cover ring-1 ring-[var(--mu-glass-border)]" />
|
|
||||||
<div className="min-w-0 max-w-[85%]">
|
|
||||||
<ModeTag mode={mode} />
|
|
||||||
{message.mood ? (
|
|
||||||
<div className="mb-1 text-xs text-[var(--mu-text-lo)]">心情 · {labelFor("mood", message.mood)}</div>
|
|
||||||
) : null}
|
|
||||||
<SourceLabel deviceId={message.deviceId} role={message.role} />
|
|
||||||
{switched ? <PersonaLabel name={persona?.name} /> : null}
|
|
||||||
<TurnTag turnId={message.turnId} />
|
|
||||||
<WakePath active={active} />
|
|
||||||
<ThinkingBlock text={message.thinking} streaming={message.streaming} />
|
|
||||||
{message.tools?.map((t, i) => <ToolCard key={i} tool={t} />)}
|
|
||||||
{modeBody(mode, message)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Small chip showing the message's behavior mode. */
|
|
||||||
function ModeTag({ mode }: { mode: Mode }): React.ReactNode {
|
|
||||||
if (mode === "chat") return null;
|
|
||||||
return (
|
|
||||||
<div className="mb-1 inline-flex items-center gap-1 rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-4)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--mu-text-mid)]">
|
|
||||||
<Icon name={modeIcon(mode)} size={11} />
|
|
||||||
{modeLabel(mode)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function modeLabel(mode: Mode): string {
|
|
||||||
const map: Record<Mode, string> = {
|
|
||||||
chat: "对话", code: "代码", home: "家居", work: "工作",
|
|
||||||
docs: "知识库", voice: "语音", vision: "视觉", agents: "子代理", settings: "设置",
|
|
||||||
};
|
|
||||||
return map[mode];
|
|
||||||
}
|
|
||||||
function modeIcon(mode: Mode): IconName {
|
|
||||||
const map: Record<Mode, IconName> = {
|
|
||||||
chat: "chat", code: "terminal", home: "home", work: "folder",
|
|
||||||
docs: "docs", voice: "mic", vision: "vision", agents: "agent", settings: "settings",
|
|
||||||
};
|
|
||||||
return map[mode];
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import type { ChatMessage } from "../../lib/types";
|
||||||
|
import { Icon } from "../Icon";
|
||||||
|
import { renderContent } from "../../lib/renderContent";
|
||||||
|
import type { ContentPart } from "../../lib/parseContent";
|
||||||
|
|
||||||
|
/** Small muted attachment note for a single `<action>` / `<narration>` block.
|
||||||
|
* Rendered as a grey italic micro-line OUTSIDE the bubble (anchored to the
|
||||||
|
* message's head/tail by `splitBubble`) — never inside the bubble body.
|
||||||
|
* `align` mirrors the bubble column: `left` indents under the assistant avatar
|
||||||
|
* column, `right` lines the note up with a right-aligned user bubble. */
|
||||||
|
export function BubbleNotes({
|
||||||
|
items,
|
||||||
|
align = "left",
|
||||||
|
}: {
|
||||||
|
items: ContentPart[];
|
||||||
|
align?: "left" | "right";
|
||||||
|
}): React.ReactNode {
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
const container =
|
||||||
|
align === "right"
|
||||||
|
? "flex flex-col items-end gap-0.5"
|
||||||
|
: "flex flex-col items-start gap-0.5 pl-10";
|
||||||
|
return (
|
||||||
|
<div className={container}>
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<div key={i} className="text-[10px] italic leading-tight text-[var(--mu-text-lo)]">
|
||||||
|
*{item.type === "action" ? "动作" : "旁白"}:{item.text}*
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 零气泡时是否还有「回复外壳」要出(见 `SplitChatBubble`):思维链 / 工具卡 / 心情是
|
||||||
|
* 用户可见内容,流式中的「生成中…」是必要的等待反馈 —— 有其一就补一行外壳;纯注记回复
|
||||||
|
* (这几样都没有)保持「只剩注记本身」的观感,与改动前一致。 */
|
||||||
|
export function hasBubbleChrome(message: ChatMessage): boolean {
|
||||||
|
return Boolean(message.streaming || message.thinking || message.mood || message.tools?.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Chat-mode body: a Miuix surface shell around a whitespace-preserving span.
|
||||||
|
* `text` is already the annotation-stripped body paragraph from `splitBubble`,
|
||||||
|
* so it renders as plain text only — action/narration notes live outside.
|
||||||
|
* Empty text with no stream ⇒ **不画外壳**(零气泡回复里的那行「注记外壳」不因为是空的
|
||||||
|
* 就变成一个空气泡)。 */
|
||||||
|
export function ChatBody({ text, streaming }: { text: string; streaming?: boolean }): React.ReactNode {
|
||||||
|
if (!text && !streaming) return null;
|
||||||
|
return (
|
||||||
|
<div className="liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5 text-sm text-[var(--mu-text-hi)] shadow-[var(--mu-shadow-1)]">
|
||||||
|
{text ? (
|
||||||
|
<span className="whitespace-pre-wrap">{renderContent(text)}</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-[var(--mu-text-lo)]">
|
||||||
|
<Icon name="refresh" size={12} className="animate-spin" />
|
||||||
|
生成中…
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import type { ChatMessage, Mode } from "../../lib/types";
|
||||||
|
import { personaAvatar } from "../../lib/persona";
|
||||||
|
import { labelFor } from "../../lib/enums";
|
||||||
|
import { Icon, type IconName } from "../Icon";
|
||||||
|
import { WakePath } from "../status";
|
||||||
|
import { modeBody } from "../messages";
|
||||||
|
import { DocumentList } from "../messages/Documents";
|
||||||
|
import { TurnTag, userBubbleClass } from "../messages/TurnMeta";
|
||||||
|
import { UserMessageActions } from "../messages/MessageActions";
|
||||||
|
import { CARD_RENDER, renderContent } from "../../lib/renderContent";
|
||||||
|
import { splitBubble } from "../../lib/parseContent";
|
||||||
|
import { splitParagraphs } from "./split";
|
||||||
|
import { BubbleNotes, ChatBody, hasBubbleChrome } from "./chrome";
|
||||||
|
import { ThinkingBlock, ToolCard } from "./toolviz";
|
||||||
|
import { PersonaLabel, SenderLabel, SourceLabel, useSpokenPersona, UserMeta } from "./labels";
|
||||||
|
|
||||||
|
export { ThinkingBlock, ToolCard };
|
||||||
|
|
||||||
|
/** A single message bubble. Per design/07 §1 the bubble shape is driven by the
|
||||||
|
* message's own `mode`: the same role renders differently under different modes,
|
||||||
|
* and a single session may mix shapes. Falls back to `chat` when mode is absent
|
||||||
|
* (legacy persisted messages). */
|
||||||
|
export function MessageBubble({
|
||||||
|
message,
|
||||||
|
active = false,
|
||||||
|
}: {
|
||||||
|
message: ChatMessage;
|
||||||
|
active?: boolean;
|
||||||
|
}): React.ReactNode {
|
||||||
|
const mode = message.mode ?? "chat";
|
||||||
|
if (message.role === "user") {
|
||||||
|
return <UserBubble message={message} mode={mode} />;
|
||||||
|
}
|
||||||
|
return <AssistantBubble message={message} mode={mode} active={active} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One assistant paragraph bubble. `full` renders the complete bubble (avatar /
|
||||||
|
* mood / 思维链 / 工具卡 / wake path); compact segments render just the aligned
|
||||||
|
* body, so the chrome is not repeated paragraph after paragraph. */
|
||||||
|
function AssistantParaBubble({
|
||||||
|
message,
|
||||||
|
text,
|
||||||
|
full,
|
||||||
|
active = false,
|
||||||
|
}: {
|
||||||
|
message: ChatMessage;
|
||||||
|
text: string;
|
||||||
|
full: boolean;
|
||||||
|
active?: boolean;
|
||||||
|
}): React.ReactNode {
|
||||||
|
const { persona, switched } = useSpokenPersona(message.personaId);
|
||||||
|
return (
|
||||||
|
<div className="mu-rise-in flex gap-3">
|
||||||
|
{full ? (
|
||||||
|
<img src={personaAvatar(persona)} alt="" className="mt-1 h-7 w-7 shrink-0 rounded-full object-cover ring-1 ring-[var(--mu-glass-border)]" />
|
||||||
|
) : (
|
||||||
|
<div className="w-7 shrink-0" aria-hidden />
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 max-w-[85%]">
|
||||||
|
{full ? <ModeTag mode={message.mode ?? "chat"} /> : null}
|
||||||
|
{full && message.mood ? (
|
||||||
|
<div className="mb-1 text-xs text-[var(--mu-text-lo)]">心情 · {labelFor("mood", message.mood)}</div>
|
||||||
|
) : null}
|
||||||
|
{full ? <SourceLabel deviceId={message.deviceId} role={message.role} /> : null}
|
||||||
|
{full && switched ? <PersonaLabel name={persona?.name} /> : null}
|
||||||
|
{full ? <TurnTag turnId={message.turnId} /> : null}
|
||||||
|
{full ? <WakePath active={active} /> : null}
|
||||||
|
{full ? <ThinkingBlock text={message.thinking} streaming={message.streaming} /> : null}
|
||||||
|
{full ? message.tools?.map((t, i) => <ToolCard key={i} tool={t} />) : null}
|
||||||
|
<ChatBody text={text} streaming={message.streaming} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One user paragraph bubble. Only the first segment carries the optional image. */
|
||||||
|
function UserParaBubble({
|
||||||
|
message,
|
||||||
|
text,
|
||||||
|
full,
|
||||||
|
}: {
|
||||||
|
message: ChatMessage;
|
||||||
|
text: string;
|
||||||
|
full: boolean;
|
||||||
|
}): React.ReactNode {
|
||||||
|
return (
|
||||||
|
<div className="mu-rise-in flex flex-col items-end gap-0.5">
|
||||||
|
<div
|
||||||
|
className={`${userBubbleClass(message.delivery)} max-w-[80%] rounded-[var(--mu-radius-lg)] px-4 py-2.5 text-sm shadow-[var(--mu-shadow-1)]`}
|
||||||
|
>
|
||||||
|
{full && message.image ? (
|
||||||
|
<img
|
||||||
|
src={message.image}
|
||||||
|
alt="附件"
|
||||||
|
className="mb-1.5 block max-h-56 rounded-[var(--mu-radius-sm)] object-cover"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{full ? <DocumentList documents={message.documents} /> : null}
|
||||||
|
<span className="whitespace-pre-wrap">{renderContent(text)}</span>
|
||||||
|
</div>
|
||||||
|
{full ? <UserMeta message={message} /> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Chat-only entry: a `chat`-mode reply's `<action>`/`<narration>` blocks are
|
||||||
|
* peeled off by `splitBubble` into head/tail attachment notes, and the remaining
|
||||||
|
* plain-text body is split into stacked bubbles by `splitParagraphs` — two peers
|
||||||
|
* delimit a bubble: `<p>…</p>` (§4.16) and `<br>` (主人 2026-10-07: a `<br>` is a
|
||||||
|
* **message separator**, so it must never render as an in-bubble line break).
|
||||||
|
* First segment = full bubble, the rest compact. Any other mode — code / home /
|
||||||
|
* work / docs / vision / voice / agents / settings — is passed straight through
|
||||||
|
* to MessageBubble → `modeBody`: those are **单卡正文**(没有「多条气泡」的概念),
|
||||||
|
* 所以那边不走气泡切分,`<br>` 由 `renderContent(…, {lineBreaks:true})` 渲染成**换行**
|
||||||
|
* (主人 2026-10-07 晚「非聊天模式按照应当有的专业卡片和格式来显示」)。 */
|
||||||
|
export function SplitChatBubble({
|
||||||
|
message,
|
||||||
|
active = false,
|
||||||
|
}: {
|
||||||
|
message: ChatMessage;
|
||||||
|
active?: boolean;
|
||||||
|
}): React.ReactNode {
|
||||||
|
const mode = message.mode ?? "chat";
|
||||||
|
if (mode !== "chat") return <MessageBubble message={message} active={active} />;
|
||||||
|
|
||||||
|
const { body, head, tail } = splitBubble(message.content ?? "");
|
||||||
|
const segments = splitParagraphs(body);
|
||||||
|
|
||||||
|
if (segments.length === 0 && head.length === 0 && tail.length === 0) {
|
||||||
|
// No visible content: hand off so the empty / still-streaming reply keeps its
|
||||||
|
// usual chrome (avatar, mood, "生成中…"). 一处例外:正文**非空却一个气泡都切不出**
|
||||||
|
// (只剩 `<br>` 分隔符,或只剩空 `<p>` 块)时**不能**把原文交下去 —— 那会把字面量
|
||||||
|
// `<br>` / `<p>` 画进气泡,等于凭空造一个空气泡。这种情况按「空正文」交下去(外壳照旧),
|
||||||
|
// 与空气泡丢弃的口径一致。
|
||||||
|
const bodyExhausted = body.length > 0;
|
||||||
|
return (
|
||||||
|
<MessageBubble
|
||||||
|
message={bodyExhausted ? { ...message, content: "" } : message}
|
||||||
|
active={active}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group the paragraphs of one reply into a tighter column so intra-reply
|
||||||
|
// segments read as one bubble while separate replies keep the 16px list gap.
|
||||||
|
// The annotation notes (head/tail) attach to the whole group — above the first
|
||||||
|
// bubble and below the last — never inside a bubble's body.
|
||||||
|
const align = message.role === "user" ? "right" : "left";
|
||||||
|
// 零气泡(纯动作 / 纯独白回复):注记之外还有用户可见内容(思维链 / 工具卡 / 心情)
|
||||||
|
// 或还在流式等待中时,补一行**无正文**的外壳 —— 头像、思维链、工具卡是内容,不该因为
|
||||||
|
// 丢了空气泡就一并消失;`ChatBody` 对空正文返回 null,所以这里不会画出空气泡。
|
||||||
|
const chromeOnly =
|
||||||
|
segments.length === 0 && message.role === "assistant" && hasBubbleChrome(message);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex flex-col gap-2.5 ${message.role === "user" ? "group relative items-end" : ""}`}
|
||||||
|
>
|
||||||
|
<BubbleNotes items={head} align={align} />
|
||||||
|
{chromeOnly ? (
|
||||||
|
<AssistantParaBubble message={message} text="" full active={active} />
|
||||||
|
) : null}
|
||||||
|
{segments.map((seg, i) =>
|
||||||
|
message.role === "user" ? (
|
||||||
|
<UserParaBubble key={i} message={message} text={seg} full={i === 0} />
|
||||||
|
) : (
|
||||||
|
<AssistantParaBubble
|
||||||
|
key={i}
|
||||||
|
message={message}
|
||||||
|
text={seg}
|
||||||
|
full={i === 0}
|
||||||
|
active={active}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
<BubbleNotes items={tail} align={align} />
|
||||||
|
{message.role === "user" ? <UserMessageActions message={message} /> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** User-side bubble. The outer accent pill is shared; the inner content may be
|
||||||
|
* mode-shaped (e.g. code request renders in a mono pre). */
|
||||||
|
function UserBubble({ message, mode }: { message: ChatMessage; mode: Mode }): React.ReactNode {
|
||||||
|
// 非 chat 模式的用户气泡同样是**单卡正文**(`<br>` ⇒ 换行);chat 模式的正文在
|
||||||
|
// `SplitChatBubble` 里就已按 `<br>` 切成多条,能落到这里的只剩「空正文」的情形,
|
||||||
|
// 因此保持气泡口径 —— `<br>` 绝不在这里变回换行。
|
||||||
|
const options = mode === "chat" ? undefined : CARD_RENDER;
|
||||||
|
const body: React.ReactNode =
|
||||||
|
mode === "code" ? (
|
||||||
|
<pre className="whitespace-pre-wrap font-mono text-xs leading-relaxed">{renderContent(message.content, options)}</pre>
|
||||||
|
) : message.content ? (
|
||||||
|
<span className="whitespace-pre-wrap">{renderContent(message.content, options)}</span>
|
||||||
|
) : null;
|
||||||
|
return (
|
||||||
|
<div className="mu-rise-in group relative flex flex-col items-end gap-0.5">
|
||||||
|
<div
|
||||||
|
className={`${userBubbleClass(message.delivery)} max-w-[80%] rounded-[var(--mu-radius-lg)] px-4 py-2.5 text-sm shadow-[var(--mu-shadow-1)]`}
|
||||||
|
>
|
||||||
|
{message.image ? (
|
||||||
|
<img
|
||||||
|
src={message.image}
|
||||||
|
alt="附件"
|
||||||
|
className="mb-1.5 block max-h-56 rounded-[var(--mu-radius-sm)] object-cover"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<DocumentList documents={message.documents} />
|
||||||
|
{body}
|
||||||
|
</div>
|
||||||
|
<UserMeta message={message} />
|
||||||
|
<SenderLabel name={message.senderName} />
|
||||||
|
<UserMessageActions message={message} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Assistant-side bubble. Outer layout shared; the body renders per mode via the
|
||||||
|
* mode-specific body (chat / code / docs / home / work / vision …). */
|
||||||
|
function AssistantBubble({
|
||||||
|
message,
|
||||||
|
mode,
|
||||||
|
active = false,
|
||||||
|
}: {
|
||||||
|
message: ChatMessage;
|
||||||
|
mode: Mode;
|
||||||
|
active?: boolean;
|
||||||
|
}): React.ReactNode {
|
||||||
|
const { persona, switched } = useSpokenPersona(message.personaId);
|
||||||
|
return (
|
||||||
|
<div className="mu-rise-in flex gap-3">
|
||||||
|
<img src={personaAvatar(persona)} alt="" className="mt-1 h-7 w-7 shrink-0 rounded-full object-cover ring-1 ring-[var(--mu-glass-border)]" />
|
||||||
|
<div className="min-w-0 max-w-[85%]">
|
||||||
|
<ModeTag mode={mode} />
|
||||||
|
{message.mood ? (
|
||||||
|
<div className="mb-1 text-xs text-[var(--mu-text-lo)]">心情 · {labelFor("mood", message.mood)}</div>
|
||||||
|
) : null}
|
||||||
|
<SourceLabel deviceId={message.deviceId} role={message.role} />
|
||||||
|
{switched ? <PersonaLabel name={persona?.name} /> : null}
|
||||||
|
<TurnTag turnId={message.turnId} />
|
||||||
|
<WakePath active={active} />
|
||||||
|
<ThinkingBlock text={message.thinking} streaming={message.streaming} />
|
||||||
|
{message.tools?.map((t, i) => <ToolCard key={i} tool={t} />)}
|
||||||
|
{modeBody(mode, message)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Small chip showing the message's behavior mode. */
|
||||||
|
function ModeTag({ mode }: { mode: Mode }): React.ReactNode {
|
||||||
|
if (mode === "chat") return null;
|
||||||
|
return (
|
||||||
|
<div className="mb-1 inline-flex items-center gap-1 rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-4)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--mu-text-mid)]">
|
||||||
|
<Icon name={modeIcon(mode)} size={11} />
|
||||||
|
{modeLabel(mode)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function modeLabel(mode: Mode): string {
|
||||||
|
const map: Record<Mode, string> = {
|
||||||
|
chat: "对话", code: "代码", home: "家居", work: "工作",
|
||||||
|
docs: "知识库", voice: "语音", vision: "视觉", agents: "子代理", settings: "设置",
|
||||||
|
};
|
||||||
|
return map[mode];
|
||||||
|
}
|
||||||
|
function modeIcon(mode: Mode): IconName {
|
||||||
|
const map: Record<Mode, IconName> = {
|
||||||
|
chat: "chat", code: "terminal", home: "home", work: "folder",
|
||||||
|
docs: "docs", voice: "mic", vision: "vision", agents: "agent", settings: "settings",
|
||||||
|
};
|
||||||
|
return map[mode];
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import type { ChatMessage, PersonaRecord } from "../../lib/types";
|
||||||
|
import { useAppStore } from "../../store/useAppStore";
|
||||||
|
import { useCurrentPersona } from "../../store/personaSelectors";
|
||||||
|
import { resolveMessagePersona } from "../../lib/persona";
|
||||||
|
import { DeliveryChip, TurnTag } from "../messages/TurnMeta";
|
||||||
|
|
||||||
|
/** Small muted source label for a persisted message (design/16 §4.5): shows the
|
||||||
|
* concrete client the message came from, e.g. "来自 web:chrome:a1b2c3". Rendered
|
||||||
|
* only when a deviceId is present, so legacy/unlabelled messages stay clean. The
|
||||||
|
* Web client defaults to this label; other clients decide themselves. */
|
||||||
|
export function SourceLabel({ deviceId, role }: { deviceId?: string; role?: string }): React.ReactNode {
|
||||||
|
if (!deviceId) return null;
|
||||||
|
// A user message is received FROM a client; an assistant reply goes TO the
|
||||||
|
// client that prompted it. Semantic direction differs — "来自" vs "发送至".
|
||||||
|
const dir = role === "assistant" ? "发送至" : "来自";
|
||||||
|
return (
|
||||||
|
<div className="text-[10px] leading-tight text-[var(--mu-text-lo)]">
|
||||||
|
{dir} {deviceId}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Small sender label for a third-party message (design/16 §4.6): shows the
|
||||||
|
* sender's display name, e.g. "第三方 · 张三". Blank for the owner's private chat
|
||||||
|
* (no senderName), so owner bubbles stay unlabelled. */
|
||||||
|
export function SenderLabel({ name }: { name?: string }): React.ReactNode {
|
||||||
|
if (!name) return null;
|
||||||
|
return (
|
||||||
|
<div className="text-[10px] leading-tight text-[var(--mu-text-lo)]">
|
||||||
|
第三方 · {name}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The bubble-side binding of `lib/persona.ts#resolveMessagePersona` (design/22
|
||||||
|
* §7-5): which role spoke this message, and whether it differs from the role the
|
||||||
|
* session speaks as now. */
|
||||||
|
export function useSpokenPersona(personaId?: string): {
|
||||||
|
persona: PersonaRecord | null;
|
||||||
|
switched: boolean;
|
||||||
|
} {
|
||||||
|
const personas = useAppStore((s) => s.personas);
|
||||||
|
const active = useCurrentPersona();
|
||||||
|
return resolveMessagePersona(personaId, personas, active);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Muted "角色 · X" line for a bubble whose authoring role differs from the role
|
||||||
|
* the session speaks as now. */
|
||||||
|
export function PersonaLabel({ name }: { name?: string }): React.ReactNode {
|
||||||
|
if (!name) return null;
|
||||||
|
return (
|
||||||
|
<div className="text-[10px] leading-tight text-[var(--mu-text-lo)]">角色 · {name}</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用户气泡下面那行小字:回合小标 + 投递态角标 + 来源。两个用户气泡(单块 / 分段)
|
||||||
|
* 共用一处,避免「四份渲染各写一遍、日后漂移」。 */
|
||||||
|
export function UserMeta({ message }: { message: ChatMessage }): React.ReactNode {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center justify-end gap-1.5">
|
||||||
|
<TurnTag turnId={message.turnId} align="right" />
|
||||||
|
<DeliveryChip
|
||||||
|
delivery={message.delivery}
|
||||||
|
steerStep={message.steerStep}
|
||||||
|
rejectReason={message.rejectReason}
|
||||||
|
/>
|
||||||
|
<SourceLabel deviceId={message.deviceId} role={message.role} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/** `<br>` 的**气泡分隔**判据(主人 2026-10-07 口径:「对消息内的 `<br>` 始终处理为多条
|
||||||
|
* 消息隔断」)。`<br>` / `<br/>` / `</br>` 三种写法等价(HTML5 口径),属性不解析
|
||||||
|
* (`<br class=x>` 同样是分隔符)。`(?![a-zA-Z0-9])` 是「别把它名当成 br」的护栏
|
||||||
|
* —— `<brow>` / `<br2>` 不是 br 标签,与 `inlineMarkup.ts` 里「标签候选 = `<` + 字母」
|
||||||
|
* 同一条口径;`[^<>]*` 与那个正则一样要求标签体内不出现尖括号(`<br` 无 `>` 不算标签)。 */
|
||||||
|
const BR_SEPARATOR_RE = /<\/?br(?![a-zA-Z0-9])[^<>]*>/i;
|
||||||
|
|
||||||
|
/** 把一个已切好的段落再按 `<br>` 切开:`<br>` 与 `<p>` **同级** —— 两边的文本各自
|
||||||
|
* 成为独立气泡,所以「气泡内的换行」这个概念在这里被消灭。
|
||||||
|
*
|
||||||
|
* 🔴 **空气泡一律丢弃**(主人 2026-10-07 晚「多余的空气泡确实可以丢掉」):`<p>甲<br><br>乙</p>`
|
||||||
|
* ⇒ `甲` / `乙`;`<br>` 两侧都是空白 ⇒ 不产生多余气泡;**空的 `<p>` 块也丢** ——
|
||||||
|
* `<p></p>` / `<p> </p>`,以及注记被剥走后只剩空壳的 `<p><action>…</action></p>`,
|
||||||
|
* 从此与 `<br>` 碎片同一套口径(上一轮只丢了 `<br>` 碎片、空 `<p>` 仍留一个气泡)。
|
||||||
|
* **不含 `<br>` 的段落逐字返回原样**(输入早已 trim 过)⇒ 既有 `<p>` 口径一个字节不动。 */
|
||||||
|
export function splitBubbleBreaks(segment: string): string[] {
|
||||||
|
if (!BR_SEPARATOR_RE.test(segment)) return segment ? [segment] : [];
|
||||||
|
return segment
|
||||||
|
.split(BR_SEPARATOR_RE)
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter((part) => part.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split a chat reply's annotation-stripped body into paragraph bodies, one bubble
|
||||||
|
* each. Two peers delimit bubbles: `<p>…</p>` (contract §4.16, one `<p>` = one
|
||||||
|
* bubble) and `<br>` (主人 2026-10-07: `<br>` is a message separator, never an
|
||||||
|
* in-bubble line break). A body with no `<p>` wrapper falls back to splitting on
|
||||||
|
* `\n\n` runs; a body with none of the three yields an empty list so the caller
|
||||||
|
* handles the empty reply. Non-greedy + multi-line so a tag may span newlines; a
|
||||||
|
* still-streaming, unclosed `<p>` simply does not match and stays out until it
|
||||||
|
* closes. */
|
||||||
|
export function splitParagraphs(content: string): string[] {
|
||||||
|
// Preferred: the agent wraps each paragraph in <p>…</p> (design/07 §2). When it
|
||||||
|
// emits plain \n\n runs instead (the model often does), split on those too so
|
||||||
|
// each logical paragraph becomes its own bubble. `content` here is the
|
||||||
|
// annotation-stripped body from `splitBubble`, so no <action>/<narration> block
|
||||||
|
// can be cut across; never split on a single \n (that is an in-line break, kept
|
||||||
|
// inside the paragraph). Every produced segment then goes through
|
||||||
|
// `splitBubbleBreaks`, so a <br> inside a paragraph cuts it into several bubbles.
|
||||||
|
const pRe = /<p[^>]*>([\s\S]*?)<\/p>/g;
|
||||||
|
const pSegments = [...content.matchAll(pRe)].map((m) => m[1].trim());
|
||||||
|
if (pSegments.length > 0) return pSegments.flatMap(splitBubbleBreaks);
|
||||||
|
|
||||||
|
// Keep non-<p> remainder as its own paragraph bodies.
|
||||||
|
const segments: string[] = [];
|
||||||
|
for (const part of content.split(/\n\s*\n/)) {
|
||||||
|
if (!part.trim()) continue;
|
||||||
|
segments.push(part.trim());
|
||||||
|
}
|
||||||
|
return segments.flatMap(splitBubbleBreaks);
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import type { ToolUsed } from "../../lib/types";
|
||||||
|
import { Tag } from "../primitives";
|
||||||
|
import { Icon, type IconName } from "../Icon";
|
||||||
|
|
||||||
|
/** Collapsible inline chain-of-thought block. A Miuix disclosure row: icon chip
|
||||||
|
* + label, spinner when still thinking, and a single-line muted preview of the
|
||||||
|
* reasoning while collapsed so the block reads at a glance. */
|
||||||
|
export function ThinkingBlock({
|
||||||
|
text,
|
||||||
|
streaming,
|
||||||
|
}: {
|
||||||
|
text?: string;
|
||||||
|
streaming?: boolean;
|
||||||
|
}): React.ReactNode | null {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
if (!text && !streaming) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-2 overflow-hidden rounded-[var(--mu-radius-sm)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)]/60">
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-2 text-xs transition-colors duration-200 hover:bg-[var(--mu-surface-4)]/40"
|
||||||
|
>
|
||||||
|
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-[var(--mu-radius-xs)] bg-[var(--mu-accent)]/15 text-[var(--mu-accent)]">
|
||||||
|
<Icon name="memory" size={13} />
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-[var(--mu-text-mid)]">思维链</span>
|
||||||
|
{streaming && !text ? (
|
||||||
|
<span className="inline-flex items-center gap-1 text-[var(--mu-text-lo)]">
|
||||||
|
<Icon name="refresh" size={11} className="animate-spin" />
|
||||||
|
思考中…
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[var(--mu-text-lo)]">{open ? "收起" : "展开"}</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={`ml-auto transition-transform duration-200 ease-[var(--mu-spring)] ${open ? "rotate-90" : ""}`}
|
||||||
|
>
|
||||||
|
<Icon name="arrow" size={12} className="text-[var(--mu-text-lo)]" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{open && text ? (
|
||||||
|
<pre className="max-h-40 overflow-auto whitespace-pre-wrap px-3 pb-2.5 text-xs leading-relaxed text-[var(--mu-text-mid)]">
|
||||||
|
{text}
|
||||||
|
</pre>
|
||||||
|
) : !open && text ? (
|
||||||
|
<p className="truncate px-3 pb-2 text-xs text-[var(--mu-text-lo)]">{text}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tool echo card (工具回显). A status chip + tool name + muted one-line summary
|
||||||
|
* on the left, a color-coded result tag on the right, and collapsible detail. */
|
||||||
|
export function ToolCard({ tool }: { tool: ToolUsed }): React.ReactNode {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const status = tool.ok === undefined ? "neutral" : tool.ok ? "ok" : "err";
|
||||||
|
return (
|
||||||
|
<div className="mb-2 overflow-hidden rounded-[var(--mu-radius-sm)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)]/60">
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="flex w-full items-center gap-2.5 px-3 py-2 text-xs transition-colors duration-200 hover:bg-[var(--mu-surface-4)]/40"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-[var(--mu-radius-xs)] ${statusBadge(status)}`}
|
||||||
|
>
|
||||||
|
<Icon name={statusIcon(status)} size={12} className={status === "neutral" ? "animate-spin" : undefined} />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 text-left">
|
||||||
|
<span className="block truncate font-medium text-[var(--mu-text-mid)]">{tool.name}</span>
|
||||||
|
{tool.summary ? (
|
||||||
|
<span className="block truncate text-[var(--mu-text-lo)]">{tool.summary}</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<Tag tone={status} className="shrink-0">{statusLabel(status)}</Tag>
|
||||||
|
<span className={`shrink-0 transition-transform duration-200 ease-[var(--mu-spring)] ${open ? "rotate-90" : ""}`}>
|
||||||
|
<Icon name="arrow" size={12} className="text-[var(--mu-text-lo)]" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{(open || tool.detail) && tool.detail ? (
|
||||||
|
<pre className="max-h-40 overflow-auto whitespace-pre-wrap px-3 pb-2.5 text-xs text-[var(--mu-text-mid)]">
|
||||||
|
{tool.detail}
|
||||||
|
</pre>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToolStatus = "ok" | "err" | "neutral";
|
||||||
|
|
||||||
|
function statusBadge(status: ToolStatus): string {
|
||||||
|
switch (status) {
|
||||||
|
case "ok": return "bg-[var(--mu-ok)]/15 text-[var(--mu-ok)]";
|
||||||
|
case "err": return "bg-[var(--mu-err)]/15 text-[var(--mu-err)]";
|
||||||
|
default: return "bg-[var(--mu-surface-4)] text-[var(--mu-text-mid)]";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function statusIcon(status: ToolStatus): IconName {
|
||||||
|
switch (status) {
|
||||||
|
case "ok": return "check";
|
||||||
|
case "err": return "close";
|
||||||
|
default: return "refresh";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function statusLabel(status: ToolStatus): string {
|
||||||
|
switch (status) {
|
||||||
|
case "ok": return "成功";
|
||||||
|
case "err": return "失败";
|
||||||
|
default: return "运行中";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import type { ChatMessage, Mode } from "../../lib/types";
|
|||||||
import { Tag } from "../primitives";
|
import { Tag } from "../primitives";
|
||||||
import { Icon } from "../Icon";
|
import { Icon } from "../Icon";
|
||||||
import { useAppStore } from "../../store/useAppStore";
|
import { useAppStore } from "../../store/useAppStore";
|
||||||
import { renderContent } from "../../lib/renderContent";
|
import { CARD_RENDER, renderContent as renderContentIn } from "../../lib/renderContent";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mode-specific assistant message bodies (design/07 §2). The shared outer
|
* Mode-specific assistant message bodies (design/07 §2). The shared outer
|
||||||
@@ -13,6 +13,14 @@ import { renderContent } from "../../lib/renderContent";
|
|||||||
* role under different modes looks different while a single session mixes them.
|
* role under different modes looks different while a single session mixes them.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/** 本文件(**非 chat 单卡正文**面)的统一正文入口:`<br>` ⇒ **换行**,不切气泡 ——
|
||||||
|
* 工作 / 代码 / 文档 / 首页 / 视觉 / 语音 / 智能体 / 设置这些卡片没有「多条气泡」的概念
|
||||||
|
* (主人 2026-10-07 晚「非聊天模式按照应当有的专业卡片和格式来显示」)。绑定在这里写一次,
|
||||||
|
* 免得各卡片各写一份口径、日后漂移;chat 气泡不走这里(`components/chat.tsx` 另有一层)。 */
|
||||||
|
function renderContent(text: string): ReactNode[] {
|
||||||
|
return renderContentIn(text, CARD_RENDER);
|
||||||
|
}
|
||||||
|
|
||||||
function shell(children: ReactNode): ReactNode {
|
function shell(children: ReactNode): ReactNode {
|
||||||
return (
|
return (
|
||||||
<div className="liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5 text-sm text-[var(--mu-text-hi)]">
|
<div className="liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5 text-sm text-[var(--mu-text-hi)]">
|
||||||
@@ -218,7 +226,11 @@ export function SettingsMessageBody({ message }: { message: ChatMessage }): Reac
|
|||||||
return shell(fallbackText(message));
|
return shell(fallbackText(message));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Map a mode to its message body component for the dispatcher. */
|
/** Map a mode to its message body component for the dispatcher.
|
||||||
|
* 除 `chat` 外的八个模式都是**单卡正文**(本文件所有卡片都绑定 `CARD_RENDER`)。`default`
|
||||||
|
* 落到的就是 `chat` —— 那条路只在 `SplitChatBubble` 把「切不出气泡的空正文」交下来时走
|
||||||
|
* (见 `components/chat.tsx`),此时 content 必为空/纯空白,因此这里的 `<br>` 口径无从生效;
|
||||||
|
* chat 模式的 `<br>` 仍由气泡切分层处理,绝不会在这里变成换行。 */
|
||||||
export function modeBody(mode: Mode, message: ChatMessage): ReactNode {
|
export function modeBody(mode: Mode, message: ChatMessage): ReactNode {
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
case "code": return <CodeMessageBody message={message} />;
|
case "code": return <CodeMessageBody message={message} />;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
* admin.ts /admin/* CRUD (personas, users, llm, rag, iot, automations…)
|
* admin.ts /admin/* CRUD (personas, users, llm, rag, iot, automations…)
|
||||||
* adminChannels.ts /admin/channels/* (adapters, config, platform views, login)
|
* adminChannels.ts /admin/channels/* (adapters, config, platform views, login)
|
||||||
* adminSystem.ts /admin/observability|notifications|backthink|system/*
|
* adminSystem.ts /admin/observability|notifications|backthink|system/*
|
||||||
|
* adminMemoryTidyApply.ts /admin/memories/candidates|tidy/run (整理应用/回滚;TIDY-1)
|
||||||
*
|
*
|
||||||
* Everything below is a verbatim re-export — behaviour is unchanged.
|
* Everything below is a verbatim re-export — behaviour is unchanged.
|
||||||
*/
|
*/
|
||||||
@@ -141,3 +142,25 @@ export type {
|
|||||||
AdminSelfWakeGate,
|
AdminSelfWakeGate,
|
||||||
AdminSelfWakeStatus,
|
AdminSelfWakeStatus,
|
||||||
} from "./api/adminSystem";
|
} from "./api/adminSystem";
|
||||||
|
|
||||||
|
export {
|
||||||
|
TIDY_APPLY_STATUS_APPLIED,
|
||||||
|
TIDY_APPLY_STATUS_FAILED,
|
||||||
|
TIDY_APPLY_STATUS_SKIPPED,
|
||||||
|
TIDY_STATUS_APPLIED,
|
||||||
|
TIDY_STATUS_PENDING,
|
||||||
|
adminTidyCandidateApply,
|
||||||
|
adminTidyCandidateRevert,
|
||||||
|
adminTidyCandidates,
|
||||||
|
adminTidyCandidatesApplyBatch,
|
||||||
|
adminTidyRun,
|
||||||
|
} from "./api/adminMemoryTidyApply";
|
||||||
|
export {
|
||||||
|
adminMemoryTidyTuning,
|
||||||
|
adminMemoryTidyTuningSave,
|
||||||
|
} from "./api/adminMemories";
|
||||||
|
export type {
|
||||||
|
AdminMemoryTidyTuningPatch,
|
||||||
|
AdminMemoryTidyTuningSpec,
|
||||||
|
AdminMemoryTidyTuningView,
|
||||||
|
} from "./api/adminMemories";
|
||||||
@@ -23,12 +23,26 @@ export interface ChannelConfigSaveResult {
|
|||||||
/** Result of POST /admin/channels/open|close. `resolved` is the adapter that was
|
/** Result of POST /admin/channels/open|close. `resolved` is the adapter that was
|
||||||
* actually driven (`qq-obv11:9616b27a`), which is what the panel must show —
|
* actually driven (`qq-obv11:9616b27a`), which is what the panel must show —
|
||||||
* the request's `name` alone never tells a multi-account platform which account
|
* the request's `name` alone never tells a multi-account platform which account
|
||||||
* was touched. */
|
* was touched.
|
||||||
|
*
|
||||||
|
* `connected` 说的是**现在通不通**,不是「这次请求成没成功」:ws+client 的首连失败
|
||||||
|
* 刻意非致命(OneBot 实现可能比 Cyrene 后启动),因此 `Connect` 返回 nil 并不等于连上了。
|
||||||
|
* `pending`/`ready`(后端可选,契约 v0.68)进一步说明「没连上是哪种没连上」:
|
||||||
|
*
|
||||||
|
* - `pending: true` → 已在后台重试、当前未连上 ⇒ 提示须是「正在连接(reason)」,
|
||||||
|
* **绝不是**「已连接」,也不是一句静态的「未连接」;
|
||||||
|
* - `ready: true` → 已就绪、等待对端接入(server 模式)⇒ 同样不是失败;
|
||||||
|
* - `reason` → 未连上的原因(已脱敏)。
|
||||||
|
*
|
||||||
|
* 三者缺省 = 该适配器没有这条状态面 ⇒ 只看 `connected`。 */
|
||||||
export interface ChannelActionResult {
|
export interface ChannelActionResult {
|
||||||
name: string;
|
name: string;
|
||||||
resolved?: string;
|
resolved?: string;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
|
pending?: boolean;
|
||||||
|
ready?: boolean;
|
||||||
|
reason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** open/close 的请求体。**accountId 缺省时逐字维持旧契约 `{name}`**(老后端/老客户端
|
/** open/close 的请求体。**accountId 缺省时逐字维持旧契约 `{name}`**(老后端/老客户端
|
||||||
|
|||||||
@@ -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),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
// 记忆整理**应用路径** api 客户端的测试(切片 TIDY-1)。
|
||||||
|
//
|
||||||
|
// 为什么需要它:这张卡的价值全在「点得到且说真话」,而最容易走样的是**传输层**的三件事 ——
|
||||||
|
// ① 五个端点的 URL / 方法形状(批量是 `…/candidates/apply`,不是 `…/candidates/{id}/apply`);
|
||||||
|
// ② `{data}` 信封解包(回到调用方的必须是 data 本身,不能是信封);
|
||||||
|
// ③ **`status:"skipped"` 是 200、不是错误** —— 传输层不许把它抛成异常(界面才不会报红),
|
||||||
|
// 而真正的失败(tidy_unavailable / not_found / tidy_run_not_found)必须带着 `code` 抛出,
|
||||||
|
// **绝不**回一份空结果假装「没有候选」。
|
||||||
|
//
|
||||||
|
// 跑法(与 flowTimeline.test.ts / configFacts.test.ts 同手法;项目无前端测试运行器):
|
||||||
|
//
|
||||||
|
// cd frontend/web \
|
||||||
|
// && ./node_modules/.bin/esbuild src/lib/api/adminMemoryTidyApply.test.ts \
|
||||||
|
// --bundle --platform=node --format=esm \
|
||||||
|
// --banner:js='globalThis.window={localStorage:{getItem:()=>null,setItem:()=>{},removeItem:()=>{}}};' \
|
||||||
|
// --outfile=/tmp/cyrene-memory-tidy-apply-api.test.mjs \
|
||||||
|
// && node /tmp/cyrene-memory-tidy-apply-api.test.mjs
|
||||||
|
//
|
||||||
|
// `tsc --noEmit` 会对本文件做类型检查,因此它不 import `node:*`(用 globalThis 取 fetch)。
|
||||||
|
|
||||||
|
import { ApiError } from "./core";
|
||||||
|
import {
|
||||||
|
adminTidyCandidateApply,
|
||||||
|
adminTidyCandidateRevert,
|
||||||
|
adminTidyCandidates,
|
||||||
|
adminTidyCandidatesApplyBatch,
|
||||||
|
adminTidyRun,
|
||||||
|
} from "./adminMemoryTidyApply";
|
||||||
|
|
||||||
|
let checks = 0;
|
||||||
|
|
||||||
|
function ok(cond: boolean, what: string): void {
|
||||||
|
checks += 1;
|
||||||
|
if (!cond) throw new Error(`FAILED: ${what}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function same<T>(got: T, want: T, what: string): void {
|
||||||
|
ok(got === want, `${what}(实际 ${JSON.stringify(got)},期望 ${JSON.stringify(want)})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Call = { url: string; method: string };
|
||||||
|
const calls: Call[] = [];
|
||||||
|
|
||||||
|
/** api 客户端的 `request` 会读 `localStorage` 取 token / API key;node 下没有它。 */
|
||||||
|
globalThis.localStorage = {
|
||||||
|
getItem: () => null,
|
||||||
|
setItem: () => {},
|
||||||
|
removeItem: () => {},
|
||||||
|
} as unknown as Storage;
|
||||||
|
|
||||||
|
/** 2xx 桩:回 `{data: body}` 信封(真后端 writeJSON 就是这个形状)。 */
|
||||||
|
function stubOK(body: unknown): void {
|
||||||
|
globalThis.fetch = ((url: string, init?: { method?: string }) => {
|
||||||
|
calls.push({ url: String(url), method: init?.method ?? "GET" });
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: () => Promise.resolve({ data: body }),
|
||||||
|
} as unknown as Response);
|
||||||
|
}) as typeof globalThis.fetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 非 2xx 桩:回 `{error:{code,message}}` 信封(真后端 writeError 的形状)。 */
|
||||||
|
function stubErr(status: number, code: string, message: string): void {
|
||||||
|
globalThis.fetch = ((url: string, init?: { method?: string }) => {
|
||||||
|
calls.push({ url: String(url), method: init?.method ?? "GET" });
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: false,
|
||||||
|
status,
|
||||||
|
json: () => Promise.resolve({ error: { code, message } }),
|
||||||
|
} as unknown as Response);
|
||||||
|
}) as typeof globalThis.fetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 跑一次调用并把它抛出来的错误码取回来(没抛则回 "no-error")。 */
|
||||||
|
async function codeOf(task: () => Promise<unknown>): Promise<string> {
|
||||||
|
try {
|
||||||
|
await task();
|
||||||
|
return "no-error";
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return err instanceof ApiError ? (err.code ?? "no-code") : "not-apierror";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ① 候选列表:URL 形状 + `{data}` 解包 ────────────────────────────────────
|
||||||
|
const CANDIDATES_BODY = { candidates: [], count: 0 };
|
||||||
|
stubOK(CANDIDATES_BODY);
|
||||||
|
const list = await adminTidyCandidates({ status: "pending" });
|
||||||
|
same(calls[0].url, "/api/v1/admin/memories/candidates?status=pending", "候选列表 URL");
|
||||||
|
same(calls[0].method, "GET", "候选列表是只读 GET");
|
||||||
|
ok(list.candidates.length === 0 && list.count === 0, "解包 `{data}` 信封(回的是 data 本身)");
|
||||||
|
|
||||||
|
stubOK(CANDIDATES_BODY);
|
||||||
|
await adminTidyCandidates();
|
||||||
|
same(calls[calls.length - 1].url, "/api/v1/admin/memories/candidates", "不给 status 时不带查询串");
|
||||||
|
|
||||||
|
// ── ② 单条应用 / 回滚:路径 + POST,且 **skipped 是 200 不是错误** ─────────
|
||||||
|
const SKIPPED = {
|
||||||
|
candidateId: "cand-1",
|
||||||
|
action: "link",
|
||||||
|
status: "skipped",
|
||||||
|
reason: "action_not_applyable",
|
||||||
|
affectedIds: [],
|
||||||
|
};
|
||||||
|
stubOK(SKIPPED);
|
||||||
|
const skipped = await adminTidyCandidateApply("cand-1");
|
||||||
|
same(calls[calls.length - 1].url, "/api/v1/admin/memories/candidates/cand-1/apply", "单条应用 URL");
|
||||||
|
same(calls[calls.length - 1].method, "POST", "应用是写动作 POST");
|
||||||
|
same(skipped.status, "skipped", "200 + skipped 正常解包(客户端不许把它抛成异常)");
|
||||||
|
same(skipped.reason, "action_not_applyable", "skipped 的机器可读原因要透传给调用方");
|
||||||
|
|
||||||
|
stubOK({ ...SKIPPED, status: "applied", affectedIds: ["mem-short"] });
|
||||||
|
const applied = await adminTidyCandidateApply("cand-1");
|
||||||
|
same(applied.status, "applied", "applied 回执按后端值透传");
|
||||||
|
same(applied.affectedIds.length, 1, "受影响行 id 要透传(回滚的抓手)");
|
||||||
|
|
||||||
|
stubOK({ candidateId: "cand-1", action: "merge", status: "applied", affectedIds: ["mem-short"] });
|
||||||
|
await adminTidyCandidateRevert("cand-1");
|
||||||
|
same(calls[calls.length - 1].url, "/api/v1/admin/memories/candidates/cand-1/revert", "回滚 URL");
|
||||||
|
same(calls[calls.length - 1].method, "POST", "回滚是写动作 POST");
|
||||||
|
|
||||||
|
// 候选 id 必须 URL 编码(不拼裸串)。
|
||||||
|
stubOK(SKIPPED);
|
||||||
|
await adminTidyCandidateApply("cand a/b");
|
||||||
|
same(
|
||||||
|
calls[calls.length - 1].url,
|
||||||
|
"/api/v1/admin/memories/candidates/cand%20a%2Fb/apply",
|
||||||
|
"候选 id 要 URL 编码"
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── ③ 批量应用:`…/candidates/apply` + `truncated`/`total` 原样透传 ──────────
|
||||||
|
const BATCH = {
|
||||||
|
limit: 100,
|
||||||
|
total: 342,
|
||||||
|
truncated: true,
|
||||||
|
applied: 90,
|
||||||
|
skipped: 10,
|
||||||
|
failed: 0,
|
||||||
|
merged: 80,
|
||||||
|
archived: 10,
|
||||||
|
reclassified: 0,
|
||||||
|
results: [SKIPPED],
|
||||||
|
};
|
||||||
|
stubOK(BATCH);
|
||||||
|
const batch = await adminTidyCandidatesApplyBatch();
|
||||||
|
same(calls[calls.length - 1].url, "/api/v1/admin/memories/candidates/apply", "批量应用 URL(不带 id)");
|
||||||
|
same(calls[calls.length - 1].method, "POST", "批量应用是写动作 POST");
|
||||||
|
same(batch.total, 342, "total 是真待定总数,原样透传");
|
||||||
|
same(batch.truncated, true, "truncated 原样透传(界面据此说「还剩多少未处理」)");
|
||||||
|
|
||||||
|
stubOK({ ...BATCH, runId: "run-1", total: 5, truncated: false });
|
||||||
|
await adminTidyCandidatesApplyBatch({ runId: "run-1", limit: 50 });
|
||||||
|
same(
|
||||||
|
calls[calls.length - 1].url,
|
||||||
|
"/api/v1/admin/memories/candidates/apply?runId=run-1&limit=50",
|
||||||
|
"按轮次批量应用要带 runId 与 limit"
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── ④ 手动真跑 ────────────────────────────────────────────────────────────
|
||||||
|
stubOK({
|
||||||
|
runId: "run-9",
|
||||||
|
trigger: "manual",
|
||||||
|
ruleVersion: "t0.1",
|
||||||
|
durationMs: 12,
|
||||||
|
partitions: 1,
|
||||||
|
scanned: 3,
|
||||||
|
compared: 1,
|
||||||
|
pairs: 1,
|
||||||
|
vetoed: 0,
|
||||||
|
errors: 0,
|
||||||
|
vetoReasons: {},
|
||||||
|
candidatesCreated: 2,
|
||||||
|
candidatesDuplicate: 0,
|
||||||
|
candidatesByAction: { merge: 2 },
|
||||||
|
});
|
||||||
|
const run = await adminTidyRun();
|
||||||
|
same(calls[calls.length - 1].url, "/api/v1/admin/memories/tidy/run", "手动真跑 URL");
|
||||||
|
same(calls[calls.length - 1].method, "POST", "手动真跑是写动作 POST");
|
||||||
|
same(run.candidatesCreated, 2, "真跑回执按后端值透传");
|
||||||
|
|
||||||
|
// ── ⑤ 诚实失败:错误码必须原样上抛(绝不回空结果) ──────────────────────────
|
||||||
|
stubErr(500, "tidy_unavailable", "记忆整理候选未接线");
|
||||||
|
same(
|
||||||
|
await codeOf(() => adminTidyCandidates({ status: "pending" })),
|
||||||
|
"tidy_unavailable",
|
||||||
|
"写面未接线 ⇒ 抛 tidy_unavailable(不是空候选列表)"
|
||||||
|
);
|
||||||
|
stubErr(404, "not_found", "整理候选不存在");
|
||||||
|
same(await codeOf(() => adminTidyCandidateApply("nope")), "not_found", "候选不存在 ⇒ not_found");
|
||||||
|
stubErr(404, "tidy_run_not_found", "整理运行不存在");
|
||||||
|
same(
|
||||||
|
await codeOf(() => adminTidyCandidatesApplyBatch({ runId: "nope" })),
|
||||||
|
"tidy_run_not_found",
|
||||||
|
"runId 查不到 ⇒ tidy_run_not_found(与「这一轮没有待定候选」是两件事)"
|
||||||
|
);
|
||||||
|
stubErr(409, "tidy_disabled", "记忆整理已被配置关闭");
|
||||||
|
same(await codeOf(() => adminTidyRun()), "tidy_disabled", "配置关闭 ⇒ 409 tidy_disabled");
|
||||||
|
stubErr(500, "tidy_run_unavailable", "记忆整理真跑入口未接线");
|
||||||
|
same(await codeOf(() => adminTidyRun()), "tidy_run_unavailable", "只接了试算 ⇒ 真跑回 unavailable");
|
||||||
|
stubErr(503, "tidy_timeout", "记忆整理真跑超时");
|
||||||
|
same(await codeOf(() => adminTidyRun()), "tidy_timeout", "超时 ⇒ 503 tidy_timeout");
|
||||||
|
stubErr(400, "bad_request", "invalid limit");
|
||||||
|
same(
|
||||||
|
await codeOf(() => adminTidyCandidates({ status: "pending", limit: -1 })),
|
||||||
|
"bad_request",
|
||||||
|
"非法 limit 由后端回 400(客户端只转发合法值)"
|
||||||
|
);
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`adminMemoryTidyApply: ${checks} checks passed`);
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import type {
|
||||||
|
AdminTidyApplyBatch,
|
||||||
|
AdminTidyApplyResult,
|
||||||
|
AdminTidyCandidatesResponse,
|
||||||
|
AdminTidyRunResult,
|
||||||
|
} from "../types";
|
||||||
|
import { request } from "./core";
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------------------
|
||||||
|
* 记忆整理**应用路径**(切片 TIDY-1)。真源:
|
||||||
|
* internal/services/gateway/admin_memory_tidy_apply.go
|
||||||
|
* internal/services/gateway/admin_memory_tidy_run.go
|
||||||
|
*
|
||||||
|
* GET /admin/memories/candidates?status=&limit= 候选列表(含 id/runId)
|
||||||
|
* POST /admin/memories/candidates/{id}/apply 应用一条(真的改记忆)
|
||||||
|
* POST /admin/memories/candidates/apply?runId=&limit= 批量应用(省略 runId = 全部待定)
|
||||||
|
* POST /admin/memories/candidates/{id}/revert 回滚一条(行永不物理删除)
|
||||||
|
* POST /admin/memories/tidy/run 手动**真跑**一轮(写候选,不自动应用)
|
||||||
|
*
|
||||||
|
* 五个都是 `writeAuth`(owner|admin):候选正文含隐私,读侧也不给 viewer。
|
||||||
|
* ⚠️ 失败走 `error.code`(tidy_unavailable / tidy_disabled / tidy_run_unavailable /
|
||||||
|
* tidy_timeout / tidy_run_failed / not_found / tidy_run_not_found),**绝不**回一份
|
||||||
|
* 空结果假装「没有候选」。`status:"skipped"` 是 200 + 幂等命中,不是错误。
|
||||||
|
* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/** 候选列表的路径(五条里有三条挂在它下面)。 */
|
||||||
|
const CANDIDATES_PATH = "/admin/memories/candidates";
|
||||||
|
|
||||||
|
/** 候选列表只认这两个状态(真源:tidy_store.go / tidy_apply_store.go 的字面量)。
|
||||||
|
* 因此面板只提供这两个筛选项 —— 加一个「全部」会撒谎,因为空 `status` 在**后端**
|
||||||
|
* 是「按 pending 筛」,不是「不过滤」(见 listTidyCandidates 的默认化分支)。 */
|
||||||
|
export const TIDY_STATUS_PENDING = "pending";
|
||||||
|
export const TIDY_STATUS_APPLIED = "applied";
|
||||||
|
|
||||||
|
/** 应用/回滚**回执**的状态闭集(真源 internal/runtime/memory/tidy_apply.go 的
|
||||||
|
* `TidyApplyStatus`:applied = 真的改了/撤销了;skipped = 没改,且**不是错误**;
|
||||||
|
* failed = 尝试过并失败)。 */
|
||||||
|
export const TIDY_APPLY_STATUS_APPLIED = "applied";
|
||||||
|
export const TIDY_APPLY_STATUS_SKIPPED = "skipped";
|
||||||
|
export const TIDY_APPLY_STATUS_FAILED = "failed";
|
||||||
|
|
||||||
|
/** GET /api/v1/admin/memories/candidates?status=&limit=
|
||||||
|
*
|
||||||
|
* `limit` 缺省 100、>500 钳到 500、非整数回 400 bad_request —— **钳位与默认值都在
|
||||||
|
* 后端**,本函数只转发调用方显式给出的值(否则 URL 里不出现 `limit`)。
|
||||||
|
*
|
||||||
|
* ⚠️ 响应里的 `count` 是**返回条数**,后端没有给「待定总数」;列表被 `limit` 截断时
|
||||||
|
* 没有 `truncated` 标记。因此界面不许把这一页读成「全部待定」——「到底有多少待定」
|
||||||
|
* 的权威数字在**批量应用回执**的 `total` 里。 */
|
||||||
|
export function adminTidyCandidates(
|
||||||
|
params: { status?: string; limit?: number } = {}
|
||||||
|
): Promise<AdminTidyCandidatesResponse> {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params.status) qs.set("status", params.status);
|
||||||
|
if (params.limit && params.limit > 0) qs.set("limit", String(params.limit));
|
||||||
|
const q = qs.toString();
|
||||||
|
return request<AdminTidyCandidatesResponse>(`${CANDIDATES_PATH}${q ? `?${q}` : ""}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST …/candidates/{id}/apply —— 应用**一条**候选。
|
||||||
|
*
|
||||||
|
* 幂等:已决定过的候选回 **200 + `status:"skipped"`**(不是错误,界面不许报红);
|
||||||
|
* 候选不存在回 404 not_found。 */
|
||||||
|
export function adminTidyCandidateApply(id: string): Promise<AdminTidyApplyResult> {
|
||||||
|
return request<AdminTidyApplyResult>(`${CANDIDATES_PATH}/${encodeURIComponent(id)}/apply`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST …/candidates/{id}/revert —— 回滚一条**已应用**候选(记忆行复原 + 候选退回
|
||||||
|
* pending;行永不物理删除,因此无损可逆)。候选不在已应用状态时回
|
||||||
|
* `status:"skipped"` + `reason:"not_applied"`。 */
|
||||||
|
export function adminTidyCandidateRevert(id: string): Promise<AdminTidyApplyResult> {
|
||||||
|
return request<AdminTidyApplyResult>(`${CANDIDATES_PATH}/${encodeURIComponent(id)}/revert`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST …/candidates/apply?runId=&limit= —— **批量**应用。
|
||||||
|
*
|
||||||
|
* `runId` 省略 = 应用**全部待定**(面板那一档);给出时只应用该轮产出的候选
|
||||||
|
* (查不到该轮回 404 tidy_run_not_found)。`limit` 省略 ⇒ 由后端领域侧取默认值
|
||||||
|
* (100,钳到 500)。
|
||||||
|
*
|
||||||
|
* ⚠️ 回执里的 `total` 与 `applied+skipped+failed` **可以不等**:后者只覆盖 `limit`
|
||||||
|
* 之内,前者是命中的真总数 —— 不等时 `truncated=true`,界面必须如实显示「本轮未
|
||||||
|
* 处理完 + 还剩多少」,不许画成「全部完成」。 */
|
||||||
|
export function adminTidyCandidatesApplyBatch(
|
||||||
|
opts: { runId?: string; limit?: number } = {}
|
||||||
|
): Promise<AdminTidyApplyBatch> {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (opts.runId) qs.set("runId", opts.runId);
|
||||||
|
if (opts.limit && opts.limit > 0) qs.set("limit", String(opts.limit));
|
||||||
|
const q = qs.toString();
|
||||||
|
return request<AdminTidyApplyBatch>(`${CANDIDATES_PATH}/apply${q ? `?${q}` : ""}`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/v1/admin/memories/tidy/run —— 手动**真跑**一轮(写候选 + 写运行留痕,
|
||||||
|
* **不自动应用**)。它是「不必等到低峰时段」的那个按钮。
|
||||||
|
*
|
||||||
|
* 失败是**诚实错误**,不是空回执:配置关闭 ⇒ 409 tidy_disabled;超时 ⇒ 503
|
||||||
|
* tidy_timeout;未接线(只接了试算)⇒ 500 tidy_run_unavailable;其余 ⇒ 500
|
||||||
|
* tidy_run_failed。审计由领域侧记 `memory.tidy.run`,本函数不重复。 */
|
||||||
|
export function adminTidyRun(): Promise<AdminTidyRunResult> {
|
||||||
|
return request<AdminTidyRunResult>("/admin/memories/tidy/run", { method: "POST" });
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
// 助手文本的**行内白名单标签**解析(主人 2026-10-07 需求「基本渲染支持」)。
|
||||||
|
//
|
||||||
|
// 背景:昔涟产出的是**少量 HTML 标签**而不是 Markdown —— 段落用 `<p>`(契约 §4.16,
|
||||||
|
// 由 `splitParagraphs` 处理),动作/旁白用 `<action>`/`<narration>`(由 `splitBubble`
|
||||||
|
// 处理,Web 渲染为气泡外的灰斜体注记),**行内**则偶发 `<b>` 之类强调标签。
|
||||||
|
// 后两类此前没有处理 ⇒ `<b>18:35</b>` 会把字面量 `<b>` 直接显示给用户。
|
||||||
|
//
|
||||||
|
// 🔴 **`<br>` 有两种身份,由调用方的 `lineBreaks` 决定**(主人 2026-10-07 口径
|
||||||
|
// 「对消息内的 `<br>` 始终处理为多条消息隔断」+ 2026-10-07 晚「非聊天模式按照应当有的
|
||||||
|
// 专业卡片和格式来显示」):
|
||||||
|
// · **chat 模式(默认,`lineBreaks` 关)**:`<br>` 是**气泡/消息分隔符**,与 `<p>` 同级,
|
||||||
|
// 由 `components/chat.tsx` 的 `splitParagraphs`(气泡切分层)切掉;到这里它已不存在。
|
||||||
|
// 万一有 `<br>` 走到本模块(注记文本等不切气泡的路径),按**白名单外**处理 ⇒ 逐字原样,
|
||||||
|
// 绝不在这里伪造一个「气泡内换行」。
|
||||||
|
// · **非 chat 的单卡正文(`lineBreaks` 开)**:工作 / 代码 / 文档 / 首页 / 视觉 / 语音 /
|
||||||
|
// 智能体 / 设置这些界面**没有「多条气泡」的概念** ⇒ `<br>` 只**换行**、不切分,解析成
|
||||||
|
// `{kind:"break"}`(渲染为真 `<br/>` 元素,不依赖父容器的 `white-space`)。
|
||||||
|
//
|
||||||
|
// 🔴 安全口径(本模块存在的理由):产物是**不可信文本**(模型生成 + 用户可诱导),
|
||||||
|
// 因此**绝不**把它当 HTML 注入(无 `innerHTML` / `dangerouslySetInnerHTML`)。
|
||||||
|
// 这里的做法是**解析成结构化节点**(`InlineNode[]`),交给 React 元素渲染 —— React
|
||||||
|
// 对文本节点默认转义,天然安全。规则:
|
||||||
|
// ① 只认**行内**白名单元素(六项:`b` / `strong` / `i` / `em` / `code` / `u`),块级一律
|
||||||
|
// 不支持(段落仍由 §4.16 的 `<p>` 机制表达);
|
||||||
|
// ② **属性一律丢弃**(`<b class=x onclick=y>` 只取标签名 `b`,属性绝不进 DOM);
|
||||||
|
// ③ 白名单外标签**逐字原样**当文本(`<script>` 显示成 `<script>`,不丢字符);
|
||||||
|
// ④ 未闭合 / 交错 / 大小写混写 / 带属性 —— 结构不平衡时**整段降级为纯文本**,
|
||||||
|
// 保证「不崩、字符一个不丢」(降级输出 = 原始字符串,逐字相同);
|
||||||
|
// ⑤ 大小写不敏感(`<B>` 等于 `<b>`)。
|
||||||
|
|
||||||
|
/** 允许渲染的行内**元素**标签(成对出现、有子节点)。主人 2026-10-07 晚先把它收窄为
|
||||||
|
* 五项(`u` / `s` 在她百条输出里 0 命中),当日复核后又把 **`u` 加回**(「下划线确实
|
||||||
|
* 可以保留」)⇒ 现为 **六项**。`s` **仍在白名单外**(照旧逐字原样显示);`br` 也**不是**
|
||||||
|
* 行内元素 —— 它是 void 标签,见文件头(chat 里是气泡分隔符、单卡正文里是换行)。 */
|
||||||
|
export type InlineElementTag = "b" | "strong" | "i" | "em" | "code" | "u";
|
||||||
|
|
||||||
|
/** 解析结果:一段文本被拆成的有序节点序列。
|
||||||
|
* `break` 只在 `lineBreaks` 打开时产生(非 chat 单卡正文里的一个 `<br>` ⇒ 一次换行)。 */
|
||||||
|
export type InlineNode =
|
||||||
|
| { kind: "text"; text: string }
|
||||||
|
| { kind: "element"; tag: InlineElementTag; children: InlineNode[] }
|
||||||
|
| { kind: "break" };
|
||||||
|
|
||||||
|
/** 词法单元:文本 / 开标签 / 闭标签 / 换行(`<br>`)。 */
|
||||||
|
type Token =
|
||||||
|
| { kind: "text"; text: string }
|
||||||
|
| { kind: "open"; tag: InlineElementTag }
|
||||||
|
| { kind: "close"; tag: InlineElementTag }
|
||||||
|
| { kind: "break" };
|
||||||
|
|
||||||
|
const INLINE_ELEMENT_TAGS = new Set<string>(["b", "strong", "i", "em", "code", "u"]);
|
||||||
|
|
||||||
|
/** 标签候选:`<` 紧跟字母、标签体里不再出现尖括号。因此 `1 < 2`、`a<b`(无 `>`)、
|
||||||
|
* `<p`(无 `>`)都**不是**标签候选,会原样留在文本里(契约 §4.16 的护栏同一条口径)。 */
|
||||||
|
const TAG_RE = /<\/?([a-zA-Z][a-zA-Z0-9]*)[^<>]*>/g;
|
||||||
|
|
||||||
|
/** 把一段文本解析成行内节点序列。结构不平衡时整段降级为单个文本节点(逐字保留原文)。
|
||||||
|
* `lineBreaks` 打开时(非 chat 单卡正文)`<br>` 变成 `{kind:"break"}`;关闭时(chat 气泡)
|
||||||
|
* 它按白名单外文本处理 —— 因为那里 `<br>` 早被 `splitParagraphs` 当气泡分隔符切掉了。 */
|
||||||
|
export function parseInlineMarkup(text: string, lineBreaks = false): InlineNode[] {
|
||||||
|
const built = buildTree(tokenize(text, lineBreaks));
|
||||||
|
return built ?? [{ kind: "text", text }];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 切出标签候选,其余按文本切段。非白名单标签连同其尖括号一起作为**文本**单元。 */
|
||||||
|
function tokenize(text: string, lineBreaks: boolean): Token[] {
|
||||||
|
const tokens: Token[] = [];
|
||||||
|
let last = 0;
|
||||||
|
TAG_RE.lastIndex = 0;
|
||||||
|
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = TAG_RE.exec(text)) !== null) {
|
||||||
|
if (m.index > last) tokens.push({ kind: "text", text: text.slice(last, m.index) });
|
||||||
|
tokens.push(classify(m, lineBreaks));
|
||||||
|
last = m.index + m[0].length;
|
||||||
|
}
|
||||||
|
if (last < text.length) tokens.push({ kind: "text", text: text.slice(last) });
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判定一个标签候选的词法身份。白名单外 ⇒ 原样文本(含尖括号,字符不丢)。
|
||||||
|
* `<br>` 在 `lineBreaks` 打开时是换行;关闭时走**白名单外**那一支(chat 模式下它本就被
|
||||||
|
* 气泡切分层切走,`components/chat.tsx#BR_SEPARATOR_RE` 用的是同一条标签候选口径:
|
||||||
|
* `<br>` / `<br/>` / `</br>` 等价、属性不解析、`<brow>` / `<br2>` 都不是 br)。 */
|
||||||
|
function classify(m: RegExpExecArray, lineBreaks: boolean): Token {
|
||||||
|
const raw = m[0];
|
||||||
|
const name = m[1].toLowerCase();
|
||||||
|
if (lineBreaks && name === "br") return { kind: "break" };
|
||||||
|
if (!isInlineElementTag(name)) return { kind: "text", text: raw };
|
||||||
|
return raw.charAt(1) === "/" ? { kind: "close", tag: name } : { kind: "open", tag: name };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInlineElementTag(name: string): name is InlineElementTag {
|
||||||
|
return INLINE_ELEMENT_TAGS.has(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 栈式建树。返回 `null` 表示结构不平衡(落单闭标签 / 交错 / 未闭合)⇒ 调用方整段降级。
|
||||||
|
* 只做「标签名 + 位置」这一层配对,不解析任何属性 —— 属性在 `classify` 里已被丢弃。
|
||||||
|
* `break` 不参与配对(void 标签),只按位置落进当前节点的子序列。 */
|
||||||
|
function buildTree(tokens: Token[]): InlineNode[] | null {
|
||||||
|
const root: InlineNode[] = [];
|
||||||
|
const stack: { tag: InlineElementTag; children: InlineNode[] }[] = [];
|
||||||
|
const current = (): InlineNode[] => (stack.length > 0 ? stack[stack.length - 1].children : root);
|
||||||
|
|
||||||
|
for (const token of tokens) {
|
||||||
|
if (token.kind === "text") {
|
||||||
|
appendText(current(), token.text);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (token.kind === "break") {
|
||||||
|
current().push({ kind: "break" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (token.kind === "open") {
|
||||||
|
const children: InlineNode[] = [];
|
||||||
|
current().push({ kind: "element", tag: token.tag, children });
|
||||||
|
stack.push({ tag: token.tag, children });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const top = stack.pop();
|
||||||
|
if (!top || top.tag !== token.tag) return null;
|
||||||
|
}
|
||||||
|
return stack.length === 0 ? root : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 追加文本并合并相邻文本节点(相邻文本节点在静态渲染里会被插入注释分隔符,
|
||||||
|
* 合并后输出更干净,也让「降级 = 逐字原样」更容易断言)。 */
|
||||||
|
function appendText(nodes: InlineNode[], text: string): void {
|
||||||
|
const last = nodes[nodes.length - 1];
|
||||||
|
if (last && last.kind === "text") last.text += text;
|
||||||
|
else nodes.push({ kind: "text", text });
|
||||||
|
}
|
||||||
@@ -0,0 +1,451 @@
|
|||||||
|
// 行内白名单标签的渲染测试(主人 2026-10-07:「聊天消息里插入了 html/md 标签,能否
|
||||||
|
// 增加网页前端的基本渲染支持」)。
|
||||||
|
//
|
||||||
|
// 为什么需要它:昔涟产出的是**少量 HTML 标签**而不是 Markdown —— 段落 `<p>`(契约
|
||||||
|
// §4.16)、动作 `<action>`(气泡外灰斜体注记)此前都已处理,**行内**标签(实测 12 条
|
||||||
|
// assistant 消息里有 6 个 `<b>`)没有处理 ⇒ 用户直接看到字面的 `<b>`。
|
||||||
|
// 这个测试锁住「行内标签渲染」这一层,以及**它不能带来的风险**:产物是不可信文本
|
||||||
|
// (模型生成、用户可诱导),任何把它当 HTML 注入的写法都必须在这里变红。
|
||||||
|
//
|
||||||
|
// 分九组:
|
||||||
|
// ① 白名单标签渲染成真元素(不是字面量);白名单当天先收窄到五项(`u`/`s` 0 命中),
|
||||||
|
// 当日复核后又把 **`u` 加回**(主人「下划线确实可以保留」)⇒ 现为**六项**:`b`/`strong`/
|
||||||
|
// `i`/`em`/`code`/`u`。**`s` 仍在白名单外** ⇒ 从此按白名单外口径逐字原样;
|
||||||
|
// ② 白名单外(`<script>` / `<img onerror>` / `<div>` / `<a>`)⇒ **纯文本字面量**,
|
||||||
|
// 树里不出现这些元素、不出现任何来自产物的属性;
|
||||||
|
// ③ 未闭合 / 落单闭标签 ⇒ 整段降级为纯文本,不崩、字符一个不丢;
|
||||||
|
// ④ 交错标签(`<b>a<i>b</b>c</i>`)⇒ 同上,不崩、字符一个不丢;
|
||||||
|
// ⑤ 带属性 ⇒ 只出元素本身,属性既不进 markup 也不进 props;
|
||||||
|
// ⑥ 既有 `<p>` / `<action>` 行为(走真实的 SplitChatBubble 链路)—— 含 **2026-10-07 晚
|
||||||
|
// 的口径变更:空 `<p>` 块不再产出空气泡**(旧的「三个 `<p>` ⇒ 三个气泡」断言随之改为
|
||||||
|
// 两个气泡);
|
||||||
|
// ⑦ `<br>` = **气泡/消息分隔符**(主人 2026-10-07 口径「对消息内的 `<br>` 始终处理为
|
||||||
|
// 多条消息隔断」):与 `<p>` 同级切气泡、空气泡丢弃、**绝不**是气泡内换行;
|
||||||
|
// ⑧ 🆕 **纯动作 / 纯独白回复 ⇒ 零个气泡**(不崩、不画空气泡),且工具卡 / 思维链 / 心情
|
||||||
|
// 这些**用户可见内容**不能跟着被丢;
|
||||||
|
// ⑨ 🆕 **非 chat 单卡正文**(工作 / 代码 / 文档 / 首页 / 视觉 / 语音 / 智能体 / 设置):
|
||||||
|
// `<br>` ⇒ **换行**(那些界面没有「多条气泡」的概念,所以不切分)、行内白名单出样式、
|
||||||
|
// 白名单外仍逐字字面;chat 模式的 `<br>` 分隔口径不受影响(⑨ 里有一条回归断言)。
|
||||||
|
//
|
||||||
|
// 跑法(react-dom/server 的 CJS 构建需要 require,所以必须打成 cjs):
|
||||||
|
//
|
||||||
|
// cd frontend/web \
|
||||||
|
// && ./node_modules/.bin/esbuild src/lib/renderContent.render.test.ts \
|
||||||
|
// --bundle --platform=node --format=cjs \
|
||||||
|
// --banner:js='globalThis.window={localStorage:{getItem:()=>null,setItem:()=>{},removeItem:()=>{}}};' \
|
||||||
|
// --outfile=/tmp/cyrene-inline-markup.test.cjs \
|
||||||
|
// && node /tmp/cyrene-inline-markup.test.cjs
|
||||||
|
//
|
||||||
|
// `tsc --noEmit`(npm run build 的一部分)会对本文件做类型检查,因此它只从项目内导入、
|
||||||
|
// 不 import node:*。
|
||||||
|
|
||||||
|
import { createElement, Fragment, type ReactNode } from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { renderContent } from "./renderContent";
|
||||||
|
import { SplitChatBubble } from "../components/chat";
|
||||||
|
import { modeBody } from "../components/messages";
|
||||||
|
import type { ChatMessage } from "./types";
|
||||||
|
|
||||||
|
let checks = 0;
|
||||||
|
|
||||||
|
function ok(cond: boolean, what: string): void {
|
||||||
|
checks += 1;
|
||||||
|
if (!cond) throw new Error(`FAILED: ${what}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function has(html: string, needle: string): boolean {
|
||||||
|
return html.includes(needle);
|
||||||
|
}
|
||||||
|
|
||||||
|
function count(html: string, needle: string): number {
|
||||||
|
return html.split(needle).length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 渲染一段(`<p>` 切分后的)段落文本,取静态 HTML。 */
|
||||||
|
function markup(text: string): string {
|
||||||
|
return renderToStaticMarkup(createElement(Fragment, null, renderContent(text)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 还原成「用户实际看见的字」:先剥掉**真元素**的标记(含 React 的相邻文本分隔注释),
|
||||||
|
* 再还原实体。顺序不能反 —— 先解码再剥标签会把降级后的字面量 `<b>` 也当成标签误删。 */
|
||||||
|
function decode(html: string): string {
|
||||||
|
return html
|
||||||
|
.replace(/<[^>]*>/g, "")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/&/g, "&");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 渲染后**用户实际看见的字**。 */
|
||||||
|
function seen(text: string): string {
|
||||||
|
return decode(markup(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ElInfo {
|
||||||
|
type: string;
|
||||||
|
propKeys: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 走一遍 `renderContent` 产出的 React 元素树,列出「元素类型 + 非 children 的属性名」。
|
||||||
|
* 这是「有没有不该出现的节点 / 属性」最直接的一层断言 —— 看的是树,不是字符串。 */
|
||||||
|
function tree(text: string): ElInfo[] {
|
||||||
|
const out: ElInfo[] = [];
|
||||||
|
const walk = (node: ReactNode): void => {
|
||||||
|
if (node === null || node === undefined || typeof node !== "object") return;
|
||||||
|
if (Array.isArray(node)) {
|
||||||
|
(node as ReactNode[]).forEach(walk);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const el = node as unknown as { type: unknown; props?: Record<string, unknown> };
|
||||||
|
const kind = el.type;
|
||||||
|
const name =
|
||||||
|
typeof kind === "string"
|
||||||
|
? kind
|
||||||
|
: ((kind as { displayName?: string; name?: string } | null)?.displayName ??
|
||||||
|
(kind as { name?: string } | null)?.name ??
|
||||||
|
"Fragment");
|
||||||
|
const props = el.props ?? {};
|
||||||
|
out.push({ type: name, propKeys: Object.keys(props).filter((k) => k !== "children") });
|
||||||
|
walk(props.children as ReactNode);
|
||||||
|
};
|
||||||
|
walk(renderContent(text));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function elementTypes(text: string): string[] {
|
||||||
|
return tree(text).map((e) => e.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 除最外层 span 与承载纯文本的 Fragment 之外的节点类型 —— 应当为空,即「没有造出
|
||||||
|
* 任何额外节点」(Fragment 是纯文本的载体,不产生 DOM)。 */
|
||||||
|
function foreignTypes(text: string): string[] {
|
||||||
|
return elementTypes(text).filter((t) => t !== "span" && t !== "Fragment");
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(message: ChatMessage): string {
|
||||||
|
return renderToStaticMarkup(createElement(SplitChatBubble, { message }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const REAL_SAMPLE =
|
||||||
|
"<p><action>赶紧凑过来看你那边,手指在空气里划了下</action></p>\n" +
|
||||||
|
"<p>花溪呀——人家按贵阳花溪区算的哦,不对的话你得纠正人家♪</p>\n" +
|
||||||
|
"<p>今天那儿是晴,13~25℃,日落 <b>18:35</b>,比上海晚了整整一小时呢。</p>";
|
||||||
|
|
||||||
|
// ── ① 白名单标签渲染成真元素 ────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
const html = markup("日落 <b>18:35</b> 了");
|
||||||
|
ok(has(html, "<b>18:35</b>"), "① `<b>` 渲染成粗体元素");
|
||||||
|
ok(!has(html, "<b>"), "① 不再把 `<b>` 当字面量显示");
|
||||||
|
ok(seen("日落 <b>18:35</b> 了") === "日落 18:35 了", "① 标签本身不作为文字出现(只剩正文)");
|
||||||
|
|
||||||
|
// 白名单 = **六项**(主人 2026-10-07 晚先收窄到五项,当日复核后把 `u` 加回:
|
||||||
|
// 「下划线确实可以保留」)⇒ `<u>` 从此渲染成下划线元素,不再是字面量。
|
||||||
|
const tags: [string, string][] = [
|
||||||
|
["<b>粗</b>", "<b>粗</b>"],
|
||||||
|
["<strong>粗</strong>", "<strong>粗</strong>"],
|
||||||
|
["<i>斜</i>", "<i>斜</i>"],
|
||||||
|
["<em>强调</em>", "<em>强调</em>"],
|
||||||
|
["<u>下划</u>", "<u>下划</u>"],
|
||||||
|
];
|
||||||
|
for (const [src, want] of tags) ok(has(markup(src), want), `① ${want} 正常渲染`);
|
||||||
|
ok(has(markup("<code>go build</code>"), "<code"), "① `<code>` 渲染成等宽元素");
|
||||||
|
ok(seen("<code>go build</code>") === "go build", "① code 内文本逐字保留");
|
||||||
|
ok(seen("<u>下划</u>") === "下划", "① `u` 的标签本身不作为文字出现(只剩正文)");
|
||||||
|
ok(foreignTypes("<u>下划</u>").join(",") === "u", "① `u` 是一个真元素(不是字面量)");
|
||||||
|
|
||||||
|
// 大小写混写:按 HTML 口径不敏感(`<B>` 就是 `<b>`)。
|
||||||
|
ok(has(markup("<B>大写</B>"), "<b>大写</b>"), "① `<B>` 大小写不敏感");
|
||||||
|
|
||||||
|
// 嵌套白名单标签(正常闭合)照常渲染。
|
||||||
|
const nested = markup("<b>外 <i>内</i></b>");
|
||||||
|
ok(has(nested, "<b>外 <i>内</i></b>"), "① 正常嵌套逐层渲染");
|
||||||
|
|
||||||
|
// 🔴 只有 `s` 出列(`u` 已加回白名单):它按**白名单外**口径逐字原样,不产出元素 ——
|
||||||
|
// 别顺手把 `s` 也加回来。
|
||||||
|
ok(seen("<s>划掉</s>") === "<s>划掉</s>", "① 出列的 `<s>` 逐字原样显示(字符不丢)");
|
||||||
|
ok(!has(markup("<s>划掉</s>"), "<s>"), "① 不再为 `s` 产出元素");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ② 白名单外一律纯文本字面量,且不产生任何节点/属性 ──────────────────────────
|
||||||
|
{
|
||||||
|
const cases: [string, string, string][] = [
|
||||||
|
["<script>alert(1)</script>", "<script", "script"],
|
||||||
|
["<img src=x onerror=alert(1)>", "<img", "img"],
|
||||||
|
["<div>块级不做</div>", "<div", "div"],
|
||||||
|
["<ul><li>列表不做</li></ul>", "<ul", "ul"],
|
||||||
|
["<a href=\"javascript:alert(1)\">链接</a>", "<a ", "a"],
|
||||||
|
["<iframe src=\"https://x/\"></iframe>", "<iframe", "iframe"],
|
||||||
|
["<style>body{display:none}</style>", "<style", "style"],
|
||||||
|
];
|
||||||
|
for (const [src, forbidden, name] of cases) {
|
||||||
|
ok(!has(markup(src), forbidden), `② 不产出 <${name}> 元素`);
|
||||||
|
ok(seen(src) === src, `② 白名单外的 \`${name}\` 逐字原样显示(不丢字符)`);
|
||||||
|
}
|
||||||
|
ok(
|
||||||
|
seen("<script>alert(1)</script>") === "<script>alert(1)</script>",
|
||||||
|
"② `<script>` 显示为字面量文本,不是可执行节点"
|
||||||
|
);
|
||||||
|
ok(
|
||||||
|
foreignTypes("<script>alert(1)</script>").length === 0,
|
||||||
|
"② 元素树里除最外层 span 外没有任何节点"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 「没有可执行的 DOM 属性」:树里除 children 外**不存在**任何属性位。
|
||||||
|
for (const e of tree("<img src=x onerror=alert(1)>")) {
|
||||||
|
ok(e.propKeys.length === 0, `② 元素 ${e.type} 上没有任何属性(含 onerror)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ③ 未闭合 / 落单闭标签 ⇒ 整段降级为纯文本 ────────────────────────────────────
|
||||||
|
{
|
||||||
|
const broken = [
|
||||||
|
"<b>x",
|
||||||
|
"日落 <b>18:35 了",
|
||||||
|
"</b>孤立闭标签",
|
||||||
|
"<b>x <i>y</i>", // 开 b 未闭合(i 已闭合)⇒ 整段降级
|
||||||
|
"<b><i>x</b>", // 交错(i 未完)⇒ 整段降级
|
||||||
|
];
|
||||||
|
for (const src of broken) {
|
||||||
|
const html = markup(src);
|
||||||
|
ok(seen(src) === src, `③ 结构不平衡时整段降级为纯文本:${src}`);
|
||||||
|
ok(!has(html, "<b>") && !has(html, "<i>"), `③ 降级后不产出元素:${src}`);
|
||||||
|
ok(foreignTypes(src).length === 0, `③ 降级后没有多余节点:${src}`);
|
||||||
|
}
|
||||||
|
ok(seen("1 < 2") === "1 < 2", "③ §4.16 护栏:`1 < 2` 不是标签");
|
||||||
|
ok(seen("a<b") === "a<b", "③ §4.16 护栏:`a<b`(无 `>`)不是标签");
|
||||||
|
ok(seen("<p2>x</p2>") === "<p2>x</p2>", "③ §4.16 护栏:`<p2>` 不是标签");
|
||||||
|
ok(seen("文本 <p 无尖括号") === "文本 <p 无尖括号", "③ §4.16 护栏:`<p`(无 `>`)不是标签");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ④ 交错标签:不崩、字符一个不丢 ──────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
const interleaved = "<b>a<i>b</b>c</i>";
|
||||||
|
ok(seen(interleaved) === interleaved, "④ `<b>a<i>b</b>c</i>` 逐字保留,不崩");
|
||||||
|
ok(foreignTypes(interleaved).length === 0, "④ 交错时不产出元素(不猜结构)");
|
||||||
|
|
||||||
|
// 同一段里既有合法标签又有畸形标签:**整段降级**(不猜结构),因此连合法的 `<b>` 对
|
||||||
|
// 也一并以字面量显示 —— 代价是「这一段没有行内渲染」,换来的是「一个字符都不丢」。
|
||||||
|
const mixed = "日落 <b>18:35</b>,还有 <i>未闭合";
|
||||||
|
ok(seen(mixed) === mixed, "④ 畸形参与时整段降级为纯文本,字符不丢");
|
||||||
|
ok(foreignTypes(mixed).length === 0, "④ 整段降级时不产出任何元素");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ⑤ 带属性 ⇒ 只出元素,属性不生效 ────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
const html = markup("<b class=x onclick=y>z</b>");
|
||||||
|
ok(has(html, "<b>z</b>"), "⑤ 只出粗体元素本身");
|
||||||
|
ok(!has(html, "onclick") && !has(html, "class="), "⑤ 属性不进入 markup");
|
||||||
|
|
||||||
|
const bEl = tree("<b class=x onclick=y>z</b>").find((e) => e.type === "b");
|
||||||
|
ok(Boolean(bEl), "⑤ 树里存在 b 元素");
|
||||||
|
ok(bEl !== undefined && bEl.propKeys.length === 0, "⑤ b 元素上没有任何属性(class/onclick 被丢弃)");
|
||||||
|
|
||||||
|
ok(seen("<b class=x onclick=y>z</b>") === "z", "⑤ 属性被丢弃,正文逐字保留");
|
||||||
|
|
||||||
|
const codeHtml = markup("<code data-x=1 style=color:red>c</code>");
|
||||||
|
ok(has(codeHtml, "<code") && !has(codeHtml, "data-x") && !has(codeHtml, "color:red"), "⑤ code 上只保留自己的样式类");
|
||||||
|
|
||||||
|
ok(seen("<b title=\"点我\">提示</b>") === "提示", "⑤ 引号含空格的属性同样不生效");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ⑥ 既有 `<p>` / `<action>` 行为无回归(真实气泡链路) ────────────────────────
|
||||||
|
{
|
||||||
|
const html = render({ id: "a1", role: "assistant" as const, content: REAL_SAMPLE, createdAt: 1 } as ChatMessage);
|
||||||
|
|
||||||
|
ok(has(html, "*动作:赶紧凑过来看你那边,手指在空气里划了下*"), "⑥ action 仍是气泡外的灰斜体注记");
|
||||||
|
// 🔴 **断言变更**(主人 2026-10-07 晚「多余的空气泡确实可以丢掉」):首段是
|
||||||
|
// `<p><action>…</action></p>`,注记被剥走后只剩空壳 `<p></p>` ⇒ 与 `<br>` 碎片同一套
|
||||||
|
// 口径,**不再产出空气泡**。原断言为 `count(…) === 3`,理由是「首段只有动作注记 ⇒
|
||||||
|
// 空气泡,与改动前逐字一致」;现为 **2**(就是正文那两段)。
|
||||||
|
ok(count(html, "liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5") === 2, "⑥ 三个 <p> 里首段被注记剥空 ⇒ 只剩两个气泡(空气泡丢弃)");
|
||||||
|
// 光数「气泡正文」还不够 —— 空段若仍在,会留下一行「只有头像、没有正文」的空行
|
||||||
|
// (`ChatBody` 对空正文返回 null ⇒ 空气泡的**壳**没了,但那一行仍在)。所以按**行数**再锁一次。
|
||||||
|
ok(count(html, "mu-rise-in flex gap-3") === 2, "⑥ 首段空壳连「气泡行」一起消失(不是只把壳画没了)");
|
||||||
|
ok(has(html, "花溪呀——人家按贵阳花溪区算的哦,不对的话你得纠正人家♪"), "⑥ 第二段正文照常");
|
||||||
|
ok(has(html, "今天那儿是晴,13~25℃,日落 <b>18:35</b>,比上海晚了整整一小时呢。"), "⑥ 第三段正文照常,且行内 <b> 渲染为粗体");
|
||||||
|
ok(!has(html, "<p>") && !has(html, "<p>"), "⑥ `<p>` 一个都不进 DOM(段落机制不变)");
|
||||||
|
ok(has(html, "italic leading-tight"), "⑥ 注记样式类不变(text-[10px] italic leading-tight)");
|
||||||
|
|
||||||
|
// 纯 `<p>` 三段 ⇒ 三个气泡(首段 full、其余 compact)。
|
||||||
|
const three = render({ id: "a2", role: "assistant", content: "<p>第一段</p><p>第二段</p><p>第三段</p>", createdAt: 2 } as ChatMessage);
|
||||||
|
ok(count(three, "liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5") === 3, "⑥ 纯 <p> 三段 ⇒ 三个气泡");
|
||||||
|
ok(has(three, "第一段") && has(three, "第二段") && has(three, "第三段"), "⑥ 三段正文都在");
|
||||||
|
|
||||||
|
// 🆕 **空 `<p>` 块也丢**(主人 2026-10-07 晚「多余的空气泡确实可以丢掉」):与 `<br>`
|
||||||
|
// 碎片同一套口径 —— 上一轮只丢了 `<br>` 碎片,空的 `<p>` 仍留一个空气泡,本轮改掉。
|
||||||
|
const emptyP = render({ id: "a5", role: "assistant", content: "<p></p><p>只有这段</p>", createdAt: 5 } as ChatMessage);
|
||||||
|
ok(count(emptyP, "liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5") === 1, "⑥ 空 `<p></p>` 块丢弃 ⇒ 只剩正文那一个气泡");
|
||||||
|
ok(count(emptyP, "mu-rise-in flex gap-3") === 1, "⑥ 空 `<p></p>` 块不留「只有头像」的空行");
|
||||||
|
const blankP = render({ id: "a6", role: "assistant", content: "<p>甲</p><p> </p><p>乙</p>", createdAt: 6 } as ChatMessage);
|
||||||
|
ok(count(blankP, "liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5") === 2, "⑥ `<p> </p>`(全空白)同样丢弃 ⇒ 两个气泡");
|
||||||
|
ok(count(blankP, "mu-rise-in flex gap-3") === 2, "⑥ `<p> </p>` 不留「只有头像」的空行");
|
||||||
|
const onlyEmptyP = render({ id: "a7", role: "assistant", content: "<p></p>", createdAt: 7 } as ChatMessage);
|
||||||
|
ok(count(onlyEmptyP, "liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5") === 1 && !has(onlyEmptyP, "<p>"), "⑥ 正文只剩空 `<p>` ⇒ 不画字面量 `<p>`,只留空回复外壳(与只剩 `<br>` 同一处置)");
|
||||||
|
|
||||||
|
// 🔴 `<action>` 注记**不参与**行内渲染(注记文案是原样文本)—— 既有行为一个字没改。
|
||||||
|
const noteWithTag = render({ id: "a3", role: "assistant", content: "<action>她 <b>笑</b> 了</action><p>正文</p>", createdAt: 3 } as ChatMessage);
|
||||||
|
ok(has(noteWithTag, "*动作:她 <b>笑</b> 了*"), "⑥ 注记内的标签保持字面量(不改注记行为)");
|
||||||
|
ok(has(noteWithTag, "正文"), "⑥ 注记后的正文照常渲染");
|
||||||
|
|
||||||
|
// 空正文 + streaming:仍是「生成中…」而不是空渲染。
|
||||||
|
const streaming = render({ id: "a4", role: "assistant", content: "", streaming: true, createdAt: 4 } as ChatMessage);
|
||||||
|
ok(has(streaming, "生成中…"), "⑥ 空正文 streaming 回落到「生成中…」");
|
||||||
|
|
||||||
|
// 用户侧同理(用户气泡也走 renderContent)。
|
||||||
|
const user = render({ id: "u1", role: "user", content: "帮我看看 <b>这个</b> 和 <script>x</script>", createdAt: 5 } as ChatMessage);
|
||||||
|
ok(has(user, "<b>这个</b>"), "⑥ 用户气泡同样渲染行内标签");
|
||||||
|
ok(!has(user, "<script") && has(user, "<script>x</script>"), "⑥ 用户气泡里的白名单外标签仍是字面量");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ⑦ `<br>` = 气泡/消息分隔(与 `<p>` 同级,**不是**气泡内换行) ────────────────
|
||||||
|
// 主人 2026-10-07 口径:「对消息内的 `<br>` 始终处理为多条消息隔断」。这一组全部走
|
||||||
|
// **真实链路**(`SplitChatBubble` → `splitBubble` → `splitParagraphs` → `renderContent`),
|
||||||
|
// 所以它同时是「上一轮把 `<br>` 当行内换行」那个错误实现的**反向断言**。
|
||||||
|
{
|
||||||
|
const BUBBLE = "liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5";
|
||||||
|
const asst = (content: string) => render({ id: "br1", role: "assistant", content, createdAt: 1 } as ChatMessage);
|
||||||
|
|
||||||
|
// 主人给的四条口径。
|
||||||
|
const inP = asst("<p>甲<br>乙</p>");
|
||||||
|
ok(count(inP, BUBBLE) === 2, "⑦ `<p>甲<br>乙</p>` ⇒ 两个气泡");
|
||||||
|
ok(has(inP, "<span>甲</span>") && has(inP, "<span>乙</span>"), "⑦ 两个气泡的正文分别是 甲 / 乙");
|
||||||
|
ok(!has(inP, "<br/>") && !has(inP, "<br"), "⑦ `<br>` 既不进 DOM、也不作为字面量出现在正文里");
|
||||||
|
|
||||||
|
const noP = asst("甲<br>乙");
|
||||||
|
ok(count(noP, BUBBLE) === 2, "⑦ 无 `<p>` 时 `甲<br>乙` ⇒ 两个气泡");
|
||||||
|
ok(has(noP, "<span>甲</span>") && has(noP, "<span>乙</span>"), "⑦ 无 `<p>` 时同样按 `<br>` 切开");
|
||||||
|
|
||||||
|
ok(count(asst("<p>甲<br><br>乙</p>"), BUBBLE) === 2, "⑦ `<p>甲<br><br>乙</p>` ⇒ 两个气泡(中间空气泡丢弃)");
|
||||||
|
ok(count(asst("<p>甲 <br> 乙</p>"), BUBBLE) === 2, "⑦ `<br>` 两侧空白 ⇒ 不产生多余空气泡");
|
||||||
|
ok(count(asst("<p>甲<br></p>"), BUBBLE) === 1, "⑦ 尾随 `<br>` 不留空气泡");
|
||||||
|
ok(count(asst("<p>甲<br> </p>"), BUBBLE) === 1, "⑦ `<br>` 后全空白不留空气泡");
|
||||||
|
|
||||||
|
// 正文只剩 `<br>`:气泡分隔把它切光 ⇒ 按空正文交下去(空气泡丢弃,别把字面量画出来)。
|
||||||
|
const onlyBr = asst("<br>");
|
||||||
|
ok(count(onlyBr, BUBBLE) === 1 && !has(onlyBr, "<br"), "⑦ 正文只剩 `<br>` ⇒ 不产生字面量、只留空回复外壳");
|
||||||
|
|
||||||
|
// 三种写法等价(HTML5):`<br>` / `<br/>` / `</br>`;属性不解析(`<br class=x>` 照样切)。
|
||||||
|
for (const src of ["<p>甲<br>乙</p>", "<p>甲<br/>乙</p>", "<p>甲</br>乙</p>", "<p>甲<br class=\"x\">乙</p>"]) {
|
||||||
|
ok(count(asst(src), BUBBLE) === 2, `⑦ 等价写法都切成两个气泡:${src}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 护栏:`<brow>` / `<br2>` 不是 br 标签(与 `1 < 2`、`a<b`、`<p2>` 同一条口径)。
|
||||||
|
const brow = asst("<p>甲<brow>乙</brow>丙</p>");
|
||||||
|
ok(count(brow, BUBBLE) === 1, "⑦ `<brow>` 不是 br ⇒ 不切气泡");
|
||||||
|
ok(has(brow, "<brow>"), "⑦ `<brow>` 按白名单外口径逐字原样");
|
||||||
|
ok(count(asst("<p>甲<br2>乙</br2>丙</p>"), BUBBLE) === 1, "⑦ `<br2>` / `</br2>` 同样不是 br");
|
||||||
|
|
||||||
|
// 流式半截 `<br`(还没等到 `>`)⇒ 不是分隔符,本段仍是一个气泡。
|
||||||
|
const partial = asst("<p>甲<br</p>");
|
||||||
|
ok(count(partial, BUBBLE) === 1, "⑦ 流式半截 `<br`(无 `>`)不切气泡");
|
||||||
|
ok(has(partial, "<br"), "⑦ 半截标签按字面量显示(字符不丢)");
|
||||||
|
|
||||||
|
// 与行内标签混用:切开的两段各自照常做行内渲染。
|
||||||
|
const mixed = asst("<p>日落 <b>18:35</b><br>晚安</p>");
|
||||||
|
ok(count(mixed, BUBBLE) === 2, "⑦ 含行内标签的段落同样按 `<br>` 切开");
|
||||||
|
ok(has(mixed, "<b>18:35</b>") && has(mixed, "<span>晚安</span>"), "⑦ 两段各自渲染(第一段保留粗体)");
|
||||||
|
|
||||||
|
// 用户气泡走同一条链。
|
||||||
|
const userBr = render({ id: "u2", role: "user", content: "<p>甲<br>乙</p>", createdAt: 2 } as ChatMessage);
|
||||||
|
ok(count(userBr, "mu-rise-in flex flex-col items-end gap-0.5") === 2, "⑦ 用户气泡同样按 `<br>` 切成两个");
|
||||||
|
|
||||||
|
// `<action>` 注记不受 `<br>` 影响(注记文案原样、仍在气泡外),正文照常切。
|
||||||
|
const noteBr = asst("<action>她 笑 了</action><p>甲<br>乙</p>");
|
||||||
|
ok(count(noteBr, BUBBLE) === 2 && has(noteBr, "*动作:她 笑 了*"), "⑦ 注记照旧在气泡外,正文照常切成两段");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ⑧ 纯动作 / 纯独白回复 ⇒ **零气泡**(空气泡丢弃的边界 + 下游核实) ──────────────
|
||||||
|
{
|
||||||
|
const BUBBLE = "liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5";
|
||||||
|
const asst = (content: string, extra: Partial<ChatMessage> = {}) =>
|
||||||
|
render({ id: "z1", role: "assistant", content, createdAt: 1, ...extra } as ChatMessage);
|
||||||
|
|
||||||
|
// `<p>` 包的纯注记回复:注记被剥走后只剩空壳 `<p></p>` ⇒ **零气泡**
|
||||||
|
// (上一轮这里还是 1 个空气泡;本轮口径变更的边界就在这里)。
|
||||||
|
const pureP = asst("<p><action>她点点头</action></p>");
|
||||||
|
ok(count(pureP, BUBBLE) === 0, "⑧ `<p><action>…</action></p>` ⇒ 零个气泡");
|
||||||
|
ok(has(pureP, "*动作:她点点头*"), "⑧ 注记照常显示(没连注记一起丢)");
|
||||||
|
ok(!has(pureP, "<p>") && !has(pureP, "<action>"), "⑧ 零气泡时也不留任何字面标签");
|
||||||
|
|
||||||
|
// 不带 `<p>` 的纯注记回复(更早的形态):零气泡是**既有行为**,此处锁住不回归。
|
||||||
|
ok(count(asst("<action>她点点头</action>"), BUBBLE) === 0, "⑧ 无 `<p>` 的纯注记回复同样零气泡(既有行为不变)");
|
||||||
|
ok(count(asst("*她点点头*"), BUBBLE) === 0, "⑧ 全角星号形态的纯注记回复同样零气泡");
|
||||||
|
|
||||||
|
// 🔴 下游核实:零气泡不等于「什么都不画」—— 思维链 / 工具卡 / 心情是**用户可见内容**,
|
||||||
|
// 不能跟着空气泡一起消失,所以补一行**无正文**的外壳(正文本就是空的 ⇒ 没有空气泡)。
|
||||||
|
const withChrome = asst("<p><action>她查了下天气</action></p>", {
|
||||||
|
thinking: "先查花溪天气",
|
||||||
|
mood: "calm",
|
||||||
|
tools: [{ name: "web-search", summary: "花溪天气", ok: true, at: 1 }],
|
||||||
|
});
|
||||||
|
ok(count(withChrome, BUBBLE) === 0, "⑧ 带工具卡的纯注记回复依然零气泡(空气泡没有回来)");
|
||||||
|
ok(has(withChrome, "web-search") && has(withChrome, "花溪天气"), "⑧ 零气泡时工具卡仍渲染");
|
||||||
|
ok(has(withChrome, "思维链") && has(withChrome, "先查花溪天气"), "⑧ 零气泡时思维链仍渲染");
|
||||||
|
ok(has(withChrome, "心情"), "⑧ 零气泡时心情小字仍渲染");
|
||||||
|
|
||||||
|
// 流式中的零气泡回复:仍要给「生成中…」的等待反馈(这一条外壳是有内容的,不是空气泡)。
|
||||||
|
const streamingNotes = asst("<p><action>她正要说</action></p>", { streaming: true });
|
||||||
|
ok(count(streamingNotes, BUBBLE) === 1 && has(streamingNotes, "生成中…"), "⑧ 零气泡 + 流式中给「生成中…」反馈(外壳非空)");
|
||||||
|
|
||||||
|
// 零气泡 + 只有尾注记(注记在正文之后)同样不崩。
|
||||||
|
const tailOnly = asst("<p>先说这句</p><p><action>然后她笑了</action></p>");
|
||||||
|
ok(count(tailOnly, BUBBLE) === 1 && has(tailOnly, "*动作:然后她笑了*"), "⑧ 尾注记 + 空壳段 ⇒ 一个正文气泡 + 一条注记");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ⑨ 非 chat 单卡正文:`<br>` ⇒ 换行、行内出样式、白名单外仍字面 ──────────────────
|
||||||
|
// 主人 2026-10-07 晚:「非聊天模式按照应当有的专业卡片和格式来显示」。这些界面是**单卡
|
||||||
|
// 正文**(没有「多条气泡」的概念)⇒ `<br>` 只换行、不切分;chat 模式的 `<br>` 分隔口径
|
||||||
|
// 不受影响(本组末尾有回归断言)。全部走真实的 `modeBody` 入口(8 个非 chat 模式一个不漏)。
|
||||||
|
{
|
||||||
|
const BUBBLE = "liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5";
|
||||||
|
const CARD_MODES = ["work", "code", "docs", "home", "vision", "voice", "agents", "settings"] as const;
|
||||||
|
const src = "甲<br>日落 <b>18:35</b>、<u>下划</u>、<s>划掉</s>、<script>x</script>";
|
||||||
|
const card = (mode: (typeof CARD_MODES)[number], content: string) =>
|
||||||
|
renderToStaticMarkup(
|
||||||
|
createElement(Fragment, null, modeBody(mode, { id: `card-${mode}`, role: "assistant", content, createdAt: 1 } as ChatMessage))
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const mode of CARD_MODES) {
|
||||||
|
const html = card(mode, src);
|
||||||
|
ok(count(html, "<br/>") === 1, `⑨ ${mode}:\`<br>\` ⇒ 一个真换行元素`);
|
||||||
|
ok(!has(html, "<br"), `⑨ ${mode}:不再把 \`<br>\` 当字面量显示`);
|
||||||
|
ok(has(html, "<b>18:35</b>"), `⑨ ${mode}:行内 \`<b>\` 出粗体`);
|
||||||
|
ok(has(html, "<u>下划</u>"), `⑨ ${mode}:行内 \`<u>\` 出下划线`);
|
||||||
|
ok(!has(html, "<s>"), `⑨ ${mode}:\`<s>\` 仍在白名单外(不产出元素)`);
|
||||||
|
ok(has(html, "<s>划掉</s>"), `⑨ ${mode}:白名单外的 \`<s>\` 仍逐字字面`);
|
||||||
|
ok(has(html, "<script>x</script>"), `⑨ ${mode}:白名单外的 \`<script>\` 逐字字面(不执行、不丢字符)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// `<br>` 的三种写法在单卡正文里等价(与 chat 侧 BR_SEPARATOR_RE 同一条标签候选口径)。
|
||||||
|
for (const s of ["甲<br>乙", "甲<br/>乙", "甲</br>乙", "甲<br class=\"x\">乙"]) {
|
||||||
|
ok(count(card("settings", s), "<br/>") === 1, `⑨ 单卡正文里等价写法都换行:${s}`);
|
||||||
|
}
|
||||||
|
const one = card("docs", "甲<br>乙");
|
||||||
|
ok(has(one, "甲") && has(one, "乙"), "⑨ 换行两侧文本都在同一个正文节点里(不切分、不丢字)");
|
||||||
|
ok(count(one, "<br") === 0, "⑨ 换行后不留任何字面量");
|
||||||
|
|
||||||
|
// 护栏:`<brow>` / `<br2>` 不是 br(与 chat 侧、与 `1 < 2` / `a<b` / `<p2>` 同一条口径)。
|
||||||
|
ok(count(card("settings", "甲<brow>乙</brow>丙"), "<br/>") === 0, "⑨ `<brow>` 不是 br ⇒ 不换行");
|
||||||
|
ok(has(card("settings", "甲<brow>乙</brow>丙"), "<brow>"), "⑨ `<brow>` 按白名单外口径逐字原样");
|
||||||
|
ok(count(card("settings", "甲<br2>乙</br2>丙"), "<br/>") === 0, "⑨ `<br2>` / `</br2>` 同样不是 br");
|
||||||
|
|
||||||
|
// 结构不平衡 ⇒ 仍按既有硬口径:整段降级为原文(连那个 `<br>` 也逐字保留,不换行)。
|
||||||
|
const broken = card("settings", "日落 <b>18:35 了<br>晚安");
|
||||||
|
ok(has(broken, "<b>18:35 了<br>晚安"), "⑨ 结构不平衡时整段降级为原文(字符一个不丢)");
|
||||||
|
ok(count(broken, "<br/>") === 0, "⑨ 降级段不产出任何元素(含换行)");
|
||||||
|
|
||||||
|
// 🔴 回归:chat 模式的 `<br>` 仍是**气泡分隔符**,绝不因本组而变成换行。
|
||||||
|
const chatBr = render({ id: "c1", role: "assistant", content: "<p>甲<br>乙</p>", createdAt: 1 } as ChatMessage);
|
||||||
|
ok(count(chatBr, BUBBLE) === 2, "⑨ 回归:chat 模式 `<p>甲<br>乙</p>` 仍是两个气泡");
|
||||||
|
ok(count(chatBr, "<br/>") === 0, "⑨ 回归:chat 模式不产出换行元素(`<br>` 是分隔符,不是换行)");
|
||||||
|
|
||||||
|
// 非 chat 模式走**真实入口**(SplitChatBubble → MessageBubble → modeBody)同样是换行。
|
||||||
|
const docsCard = render({ id: "c2", role: "assistant", mode: "docs", content: "甲<br>乙", createdAt: 2 } as ChatMessage);
|
||||||
|
ok(count(docsCard, "<br/>") === 1, "⑨ 非 chat 模式经 SplitChatBubble 也是换行");
|
||||||
|
ok(has(docsCard, "<span>甲<br/>乙</span>"), "⑨ …且两侧文本在同一个正文节点里(没有切成多气泡)");
|
||||||
|
ok(!has(docsCard, "<br"), "⑨ 非 chat 模式的字面 `<br>` 彻底消失");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`inline markup render test: ${checks} checks passed`);
|
||||||
@@ -1,11 +1,69 @@
|
|||||||
import type { ReactNode } from "react";
|
import { Fragment, type ReactNode } from "react";
|
||||||
import { parseContent } from "../lib/parseContent";
|
import { parseContent } from "../lib/parseContent";
|
||||||
|
import { parseInlineMarkup, type InlineNode } from "../lib/inlineMarkup";
|
||||||
|
|
||||||
/** Render a message body into its plain-text segments only. `<action>` /
|
/** 正文渲染口径(两种界面形态各一套,别混用):
|
||||||
* `<narration>` blocks are NOT rendered inline here — they are peeled out by
|
* · 缺省 / `lineBreaks:false` = **chat 气泡正文** —— `<br>` 是气泡分隔符,在上一层
|
||||||
* `splitBubble` and shown as attachment notes outside the bubble. */
|
* (`components/chat.tsx` 的 `splitParagraphs`)就切成独立气泡了;这里绝不把 `<br>`
|
||||||
export function renderContent(content: string): ReactNode[] {
|
* 变成换行(等于把「多条消息」偷偷还原成「一条消息里的换行」,与主人口径冲突)。
|
||||||
|
* · `lineBreaks:true` = **非 chat 的单卡正文**(工作 / 代码 / 文档 / 首页 / 视觉 / 语音 /
|
||||||
|
* 智能体 / 设置)—— 那些界面没有「多条气泡」的概念,`<br>` 只换行、不切分。 */
|
||||||
|
export interface RenderOptions {
|
||||||
|
lineBreaks?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 非 chat **单卡正文**的统一渲染口径(工作 / 代码 / 文档 / 首页 / 视觉 / 语音 / 智能体 /
|
||||||
|
* 设置,以及这些模式里的用户气泡):`<br>` ⇒ 换行。抽成一处常量,避免各卡片各写一份
|
||||||
|
* 而在日后漂移(chat 气泡**不**用它 —— 那里 `<br>` 是气泡分隔符)。 */
|
||||||
|
export const CARD_RENDER: RenderOptions = { lineBreaks: true };
|
||||||
|
|
||||||
|
/** 渲染助手/用户消息正文。`<action>` / `<narration>` 块在这里**不**进入气泡 —— 它们由
|
||||||
|
* `splitBubble` 剥出、以气泡外的注记呈现(单卡正文里则整块略去);剩下的文本段落交给
|
||||||
|
* 行内白名单渲染。
|
||||||
|
*
|
||||||
|
* 🔴 这里**只**做行内(`b`/`strong`/`i`/`em`/`code`/`u`)的事:段落 `<p>` 与消息断隔
|
||||||
|
* `<br>` 都在上一层(`components/chat.tsx` 的 `splitParagraphs`)就已切成独立气泡。
|
||||||
|
*
|
||||||
|
* 🔴 安全:文本一律交给 React 当**文本子节点**渲染(默认转义),全程没有
|
||||||
|
* `innerHTML` / `dangerouslySetInnerHTML`,也没有字符串拼 HTML —— 白名单外的标签
|
||||||
|
* (`<script>` 等)只会变成可见的字面量文本,不产生 DOM 节点、不执行。
|
||||||
|
* `lineBreaks` 也只多产出一个 `<br/>` 元素(结构节点),不外泄任何来自产物的属性。 */
|
||||||
|
export function renderContent(content: string, options: RenderOptions = {}): ReactNode[] {
|
||||||
|
const lineBreaks = options.lineBreaks ?? false;
|
||||||
return parseContent(content)
|
return parseContent(content)
|
||||||
.filter((part) => part.type === "text")
|
.filter((part) => part.type === "text")
|
||||||
.map((part, i) => <span key={i}>{part.text}</span>);
|
.map((part, i) => <span key={i}>{renderInline(part.text, lineBreaks)}</span>);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一段(`<p>` / `<br>` 切分后的单气泡)文本 ⇒ 行内节点序列 ⇒ React 元素。 */
|
||||||
|
function renderInline(text: string, lineBreaks: boolean): ReactNode[] {
|
||||||
|
return parseInlineMarkup(text, lineBreaks).map((node, i) => renderInlineNode(node, `n${i}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单个行内节点 ⇒ React 元素。文本走 Fragment(React 转义),标签走原生元素名,
|
||||||
|
* 因此不存在「把标签名当 HTML 执行」的路径;节点树里也没有任何属性位。
|
||||||
|
* `break` 只在单卡正文里出现(那个 `<br>` 是**换行**,不是气泡分隔符)。 */
|
||||||
|
function renderInlineNode(node: InlineNode, key: string): ReactNode {
|
||||||
|
if (node.kind === "text") return <Fragment key={key}>{node.text}</Fragment>;
|
||||||
|
if (node.kind === "break") return <br key={key} />;
|
||||||
|
|
||||||
|
const children = node.children.map((child, i) => renderInlineNode(child, `${key}-${i}`));
|
||||||
|
switch (node.tag) {
|
||||||
|
case "b":
|
||||||
|
return <b key={key}>{children}</b>;
|
||||||
|
case "strong":
|
||||||
|
return <strong key={key}>{children}</strong>;
|
||||||
|
case "i":
|
||||||
|
return <i key={key}>{children}</i>;
|
||||||
|
case "em":
|
||||||
|
return <em key={key}>{children}</em>;
|
||||||
|
case "u":
|
||||||
|
return <u key={key}>{children}</u>;
|
||||||
|
case "code":
|
||||||
|
return (
|
||||||
|
<code key={key} className="rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-2)] px-1 font-mono">
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -14,4 +14,5 @@ export * from "./types/kook";
|
|||||||
export * from "./types/notification";
|
export * from "./types/notification";
|
||||||
export * from "./types/configFacts";
|
export * from "./types/configFacts";
|
||||||
export * from "./types/memoryTidy";
|
export * from "./types/memoryTidy";
|
||||||
|
export * from "./types/memoryTidyApply";
|
||||||
export * from "./types/channelEvents";
|
export * from "./types/channelEvents";
|
||||||
@@ -8,6 +8,17 @@ export interface MemoryRecord {
|
|||||||
text: string;
|
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. */
|
/** A capability/plugin as returned by GET /api/v1/admin/plugins. */
|
||||||
export interface AdminPluginRecord {
|
export interface AdminPluginRecord {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -15,6 +26,8 @@ export interface AdminPluginRecord {
|
|||||||
description: string;
|
description: string;
|
||||||
allowInGroup: boolean;
|
allowInGroup: boolean;
|
||||||
allowInStranger: boolean;
|
allowInStranger: boolean;
|
||||||
|
/** 上游来源;缺席 = 宿主内置 / 来源未知(老后端同样缺席)。 */
|
||||||
|
origin?: CapabilityOrigin;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One runtime-installed plugin (GET /api/v1/admin/plugins/installed, v0.28).
|
/** One runtime-installed plugin (GET /api/v1/admin/plugins/installed, v0.28).
|
||||||
@@ -40,6 +53,19 @@ export interface InstalledPlugin {
|
|||||||
export interface AdminChannelRecord {
|
export interface AdminChannelRecord {
|
||||||
name: string;
|
name: string;
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
|
/** 适配器自报的**连接态三态**(后端可选键,契约 v0.68;见 internal/services/
|
||||||
|
* gateway/admin_channel_connect_outcome.go)。`connected:false` 至少有两种互不相同
|
||||||
|
* 的现状,缺了它就只能被画成一句静态的「未连接」:
|
||||||
|
*
|
||||||
|
* - `pending` → 已在后台重试、当前未连上 ⇒ 面板读作「连接中」(`reason` 说明原因);
|
||||||
|
* - `ready` → 已就绪、等待对端接入(server 模式监听已绑定)⇒ 不是失败。
|
||||||
|
*
|
||||||
|
* 两者都**缺省** = 该适配器没有这条状态面(绝大多数平台)⇒ 面板退回只看 `connected`,
|
||||||
|
* 绝不凭空编一个状态。 */
|
||||||
|
pending?: boolean;
|
||||||
|
ready?: boolean;
|
||||||
|
/** 未连上时的原因(已脱敏,绝不含凭据)。只有 `pending` 时才会出现。 */
|
||||||
|
reason?: string;
|
||||||
/** Configured accounts for this channel's platform (token masked). */
|
/** Configured accounts for this channel's platform (token masked). */
|
||||||
accounts?: AdminChannelAccount[];
|
accounts?: AdminChannelAccount[];
|
||||||
}
|
}
|
||||||
@@ -80,6 +106,12 @@ export interface AdminChannelAccount {
|
|||||||
* 缺省(undefined)= **状态未知**(该渠道没有独立账号状态面,或尚未探到结果),
|
* 缺省(undefined)= **状态未知**(该渠道没有独立账号状态面,或尚未探到结果),
|
||||||
* 面板画「账号状态未知」,**绝不**画成「账号离线」。 */
|
* 面板画「账号状态未知」,**绝不**画成「账号离线」。 */
|
||||||
accountOnline?: boolean;
|
accountOnline?: boolean;
|
||||||
|
/** 该账号适配器自报的连接态三态(可选,见 `AdminChannelRecord.pending`)。三者缺省 =
|
||||||
|
* 该适配器没有这条状态面 ⇒ 退回只看 `connected`。 */
|
||||||
|
pending?: boolean;
|
||||||
|
ready?: boolean;
|
||||||
|
/** 未连上时的原因(已脱敏)。只有 `pending` 时才会出现。 */
|
||||||
|
reason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The weixin-iLink scan-login QR payload (GET /admin/channels/{platform}/login-qr).
|
/** The weixin-iLink scan-login QR payload (GET /admin/channels/{platform}/login-qr).
|
||||||
@@ -338,6 +370,13 @@ export interface OwnerProfile {
|
|||||||
defaultPersona: string;
|
defaultPersona: string;
|
||||||
identityFile: string;
|
identityFile: string;
|
||||||
notes: 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[];
|
accounts: OwnerAccount[];
|
||||||
}
|
}
|
||||||
export interface OwnerAccount {
|
export interface OwnerAccount {
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
// 记忆整理**应用/回滚**的线上类型(切片 TIDY-1)。
|
||||||
|
// 唯一真源:internal/services/gateway/admin_memory_tidy_apply_dto.go
|
||||||
|
// + internal/runtime/memory/tidy_apply.go(状态闭集与批量回执口径)。
|
||||||
|
//
|
||||||
|
// ⚠️ 三个「不许自己推断」的点:
|
||||||
|
// ① 候选状态闭集来自**后端字段**(`pending` / `applied`,见 tidy_store.go 与
|
||||||
|
// tidy_apply_store.go 的常量),面板不回推、不猜测第三个值;未知值原样显示;
|
||||||
|
// ② 批量回执的 `total` 是**命中筛选的真总数**,而 `applied+skipped+failed` 只覆盖
|
||||||
|
// `limit` 之内的那些 —— 两者不等时 `truncated=true`,**不是**「全部完成」;
|
||||||
|
// ③ `status:"skipped"` 是**幂等命中/动作不可应用**,不是错误(后端回 200)。
|
||||||
|
|
||||||
|
import type { AdminTidyEvidence } from "./memoryTidy";
|
||||||
|
|
||||||
|
/** 一条**落库**候选(与试算候选的区别:这里有 id / status / runId / createdAt)。 */
|
||||||
|
export interface AdminTidyCandidateRecord {
|
||||||
|
id: string;
|
||||||
|
/** merge(并入)/ link(只建链接)/ archive(归档)/ reclassify(改分类)。 */
|
||||||
|
action: string;
|
||||||
|
/** 候选状态:`pending` / `applied`(真源见上)。 */
|
||||||
|
status: string;
|
||||||
|
sourceIds: string[];
|
||||||
|
/** merge 时的存活条目(超集);其余动作可能为空。 */
|
||||||
|
targetId?: string;
|
||||||
|
personaId: string;
|
||||||
|
scope: string;
|
||||||
|
projectId?: string;
|
||||||
|
kind?: string;
|
||||||
|
/** 被吸收条目的原文(diff 预览用)。 */
|
||||||
|
proposedContent?: string;
|
||||||
|
/** 存活条目的原文。 */
|
||||||
|
mergedContent?: string;
|
||||||
|
/** 机器可读理由(如 containment_a_subset_b)。 */
|
||||||
|
reason: string;
|
||||||
|
ruleVersion: string;
|
||||||
|
runId?: string;
|
||||||
|
scoreMin: number;
|
||||||
|
scoreMax: number;
|
||||||
|
/** UTC RFC3339(后端投影时统一格式化)。 */
|
||||||
|
createdAt: string;
|
||||||
|
evidence?: AdminTidyEvidence;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/v1/admin/memories/candidates 的 data(`candidates` 恒为数组)。 */
|
||||||
|
export interface AdminTidyCandidatesResponse {
|
||||||
|
candidates: AdminTidyCandidateRecord[];
|
||||||
|
/** = `candidates.length`(后端返回条数,**不是**库里的待定总数)。 */
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一条候选的应用/回滚回执。 */
|
||||||
|
export interface AdminTidyApplyResult {
|
||||||
|
candidateId: string;
|
||||||
|
action: string;
|
||||||
|
/** `applied`(真的改了/撤销了)/ `skipped`(没改,且**不是错误**)/ `failed`。 */
|
||||||
|
status: string;
|
||||||
|
/** skipped 的机器可读原因(already_decided / not_applied / action_not_applyable …)。 */
|
||||||
|
reason?: string;
|
||||||
|
targetId?: string;
|
||||||
|
/** 这次真的被改动的 memory_items id(回滚的抓手)。 */
|
||||||
|
affectedIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/v1/admin/memories/candidates/apply 的 data(批量应用回执)。 */
|
||||||
|
export interface AdminTidyApplyBatch {
|
||||||
|
runId?: string;
|
||||||
|
/** 本轮的单批上限(后端领域侧默认 100 / 钳到 500)。 */
|
||||||
|
limit: number;
|
||||||
|
/** 命中筛选的真待定候选数(**不是** len(results))。 */
|
||||||
|
total: number;
|
||||||
|
/** `total > 本轮处理的条数` 时为 true —— 界面必须如实显示「未处理完」。 */
|
||||||
|
truncated: boolean;
|
||||||
|
applied: number;
|
||||||
|
skipped: number;
|
||||||
|
/** 尝试过并失败的条数(失败隔离:一条失败不阻塞其余)。 */
|
||||||
|
failed: number;
|
||||||
|
merged: number;
|
||||||
|
archived: number;
|
||||||
|
reclassified: number;
|
||||||
|
results: AdminTidyApplyResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/v1/admin/memories/tidy/run 的 data(手动**真跑**一轮回执)。
|
||||||
|
*
|
||||||
|
* ⚠️ 它是**建议侧**计数:真跑只写候选 + 留痕、**不自动应用**。要落地改动,
|
||||||
|
* 接着调候选的 apply / 批量 apply。 */
|
||||||
|
export interface AdminTidyRunResult {
|
||||||
|
runId: string;
|
||||||
|
/** 触发源(手动真跑恒为 `manual`)。 */
|
||||||
|
trigger: string;
|
||||||
|
ruleVersion: string;
|
||||||
|
durationMs: number;
|
||||||
|
partitions: number;
|
||||||
|
scanned: number;
|
||||||
|
compared: number;
|
||||||
|
pairs: number;
|
||||||
|
vetoed: number;
|
||||||
|
/** 本轮失败计数(分区失败 + 候选写入失败):非 0 必须如实显示。 */
|
||||||
|
errors: number;
|
||||||
|
/** 被护栏截断的分区标签。 */
|
||||||
|
truncated?: string[];
|
||||||
|
vetoReasons?: Record<string, number>;
|
||||||
|
/** 本次**新建**的候选数;`candidatesDuplicate` 是被幂等键挡下的。 */
|
||||||
|
candidatesCreated: number;
|
||||||
|
candidatesDuplicate: number;
|
||||||
|
/** 新建候选按动作的分布(merge / link / archive / reclassify)。 */
|
||||||
|
candidatesByAction?: Record<string, number>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
package browser
|
||||||
|
|
||||||
|
// 本文件是启动路径的**前置判定**:起进程之前先看「这个 profile 上是不是已经有一个浏览器」。
|
||||||
|
// 现场(主人实测复现):同一个 profile 上再起一次 Chrome 时,新进程 **exit 0**、不写新的端口
|
||||||
|
// 记录,stderr 只有一句「正在现有的浏览器会话中打开。」—— Chromium 把命令**委托给已在跑的
|
||||||
|
// 那个实例**后自己退出。于是只按「等新进程诞生」实现的话,这一档永远等不到可用端点:上一次
|
||||||
|
// 会话只要没退干净,整个浏览器档就不可用。
|
||||||
|
//
|
||||||
|
// 三条判定与三条不同的出路(顺序即 Dial 里的调用顺序):
|
||||||
|
//
|
||||||
|
// ① 端点活着(端口在听 + `/json/version` 有合法响应)⇒ **复用它**,不再起第二个进程;
|
||||||
|
// ② 端口记录在、但端口没人听 ⇒ 那是**陈记录**,清掉后照常启动;
|
||||||
|
// ③ 端口在听却握不上手 ⇒ **如实报错**(带处置建议),不起进程 —— 绝不静默忽略。
|
||||||
|
//
|
||||||
|
// 判定用的端口来自 debugPortCandidates(portrecord.go):我们自己记的 `cyrene-debug-port`
|
||||||
|
// 优先,再兼容一眼老版二进制留下的 `DevToolsActivePort`(换二进制时,上一版起的浏览器可能
|
||||||
|
// 还在跑 —— 不认它就会把「profile 上已经有我们的浏览器」误报成「profile 被别人占了」)。
|
||||||
|
//
|
||||||
|
// 红线:这一路只做**出站**连接,不杀任何进程、不发任何信号去抢 profile、不开任何监听端口
|
||||||
|
// (AGENTS §9);主人的浏览器可能正开着,我们只能连它或绕开它。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
)
|
||||||
|
|
||||||
|
// probeExistingEndpoint 探「这个 profile 上记的调试端点还能不能用」——启动路径的第一步。
|
||||||
|
// 三条结论各自对应一条**不同于启动**的出路:
|
||||||
|
//
|
||||||
|
// ("", nil) 没有可复用的端点:一个记录都没有,或记录的端口**已经没人听**(陈记录)
|
||||||
|
// ⇒ 调用方清掉陈记录后正常启动(见 clearStalePortRecords);
|
||||||
|
// (url, nil) 端口在听、且 `/json/version` 有**合法响应** ⇒ 复用它,不再起第二个进程;
|
||||||
|
// ("", err) 端口在听、但握手失败 ⇒ **如实报错**(launchMarkerEndpointBroken),不起进程。
|
||||||
|
//
|
||||||
|
// 为什么先独立做一次 TCP 探活(而不是把 HTTP 探活的失败一分为二):两条出路必须分开 ——
|
||||||
|
// 「没人听」是**陈记录**(可以安全清掉后照常启动),「在听却不答 CDP」是**占用/异常**(只能
|
||||||
|
// 如实报)。混成一句「探测失败 ⇒ 直接启动」,就会在真被占用时把一个必然「委托出去就退出」的
|
||||||
|
// 空壳进程当成本次启动,把原因退化成一句查不明白的「启动失败」。
|
||||||
|
//
|
||||||
|
// 探测超时给得很短(endpointProbeTimeout):这是「要不要复用」的判断,不该拖住启动路径。
|
||||||
|
func (l *localChrome) probeExistingEndpoint(ctx context.Context) (string, error) {
|
||||||
|
var brokenPort int
|
||||||
|
var brokenCause error
|
||||||
|
for _, port := range debugPortCandidates(l.profileDir) {
|
||||||
|
state, cause := probeDebugEndpoint(ctx, port)
|
||||||
|
switch state {
|
||||||
|
case endpointReady:
|
||||||
|
return endpointURL(port), nil
|
||||||
|
case endpointForeign:
|
||||||
|
if brokenCause == nil {
|
||||||
|
brokenPort, brokenCause = port, cause
|
||||||
|
}
|
||||||
|
case endpointAbsent:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
// 探测期间调用方的预算先没了:这是「没时间了」,**不**说端点坏了。
|
||||||
|
return "", budgetLaunchErr(ctx)
|
||||||
|
}
|
||||||
|
if brokenCause != nil {
|
||||||
|
return "", fmt.Errorf("%w: %s:%s 在听,但 /json/version 握手失败:%v。%s",
|
||||||
|
ErrLaunch, launchMarkerEndpointBroken, endpointURL(brokenPort), brokenCause, endpointBrokenHint(l.profileDir))
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// debugEndpointState 是一次探活的三态结论。
|
||||||
|
type debugEndpointState int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// endpointAbsent:这个端口上没人听(连接被拒)—— 记它下来的那条记录是**陈记录**。
|
||||||
|
endpointAbsent debugEndpointState = iota
|
||||||
|
// endpointReady:在听,且 `/json/version` 答得上一个带 Browser 字段的合法响应 ⇒ 就是 CDP。
|
||||||
|
endpointReady
|
||||||
|
// endpointForeign:在听,但答的不是可用的 CDP(拒答 / 非 JSON / 缺 Browser 字段)——
|
||||||
|
// 只能**如实报**,不能当成本次会话的端点。
|
||||||
|
endpointForeign
|
||||||
|
)
|
||||||
|
|
||||||
|
// probeDebugEndpoint 探一个端口的三态,并带回「不合法」那一态的原因(用于如实上报)。
|
||||||
|
// 它只做出站连接(AGENTS §9),不启动进程、不发信号。
|
||||||
|
//
|
||||||
|
// 三个判定为什么不能压成一个 bool:「没人听」与「在听但不是 CDP」的处置**完全不同**(前者
|
||||||
|
// 清记录后照常启动,后者只能如实报),压扁就必然丢掉其中一条。
|
||||||
|
func probeDebugEndpoint(ctx context.Context, port int) (debugEndpointState, error) {
|
||||||
|
if !portListening(ctx, port) {
|
||||||
|
return endpointAbsent, nil
|
||||||
|
}
|
||||||
|
probeCtx, cancel := context.WithTimeout(ctx, endpointProbeTimeout)
|
||||||
|
defer cancel()
|
||||||
|
var version struct {
|
||||||
|
Browser string `json:"Browser"`
|
||||||
|
}
|
||||||
|
err := httpGetJSON(probeCtx, endpointURL(port)+"/json/version", &version)
|
||||||
|
if err == nil && version.Browser != "" {
|
||||||
|
return endpointReady, nil
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
err = errors.New("响应是合法 JSON,但没有 Browser 字段(不像 CDP 的 /json/version)")
|
||||||
|
}
|
||||||
|
return endpointForeign, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// portListening 报告回环上有没有人在听这个端口(这一次探测的连接立刻关掉)。它只做
|
||||||
|
// **出站**连接,不开任何监听端口(AGENTS §9)。
|
||||||
|
func portListening(ctx context.Context, port int) bool {
|
||||||
|
d := net.Dialer{Timeout: endpointProbeTimeout}
|
||||||
|
conn, err := d.DialContext(ctx, "tcp", loopbackAddr(port))
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_ = conn.Close()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// withChromeOutput 把浏览器自己说的话附进**返回给调用方**的错误里。日志里本来就有它,但用户
|
||||||
|
// 侧看到的工具结果(`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)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%w;浏览器输出:%s", err, preview)
|
||||||
|
}
|
||||||
|
|
||||||
|
// endpointBrokenHint 是「端口在听、却握不上手」的**可操作**处置建议。它只给**不动别人进程**的
|
||||||
|
// 出路(红线:占着这个 profile 的浏览器可能是主人在用的那个,我们不杀、也不发任何信号去抢)。
|
||||||
|
func endpointBrokenHint(profileDir string) string {
|
||||||
|
return fmt.Sprintf("处置:先确认是谁占着这个端口(本代理不会、也不该杀任何进程);"+
|
||||||
|
"确认它不是本 profile 的浏览器调试端点后,删掉 %s 再重试,或把 profileDir 指到另一个目录。",
|
||||||
|
portRecordPath(profileDir))
|
||||||
|
}
|
||||||
|
|
||||||
|
// profileBusyHint 是「新进程立刻退出」的处置建议:Chromium 把命令委托给了同一个 profile 上已在
|
||||||
|
// 跑的实例,然后自己退出 —— 给出路,但不碰任何不属于我们的进程。
|
||||||
|
func profileBusyHint(profileDir string) string {
|
||||||
|
return fmt.Sprintf("多半是该 profile(%s)已被另一个浏览器实例占用。"+
|
||||||
|
"处置:若那个实例是上一次会话留下的**我们的**浏览器、且调试端点还在,它在启动前就已被复用;"+
|
||||||
|
"走到这里说明它没有可用的调试端点(例如是被人手动用这个 profile 起的)。"+
|
||||||
|
"请先关掉那个浏览器窗口再重试,或把 profileDir 指到另一个目录 —— 本代理绝不杀别人的浏览器。",
|
||||||
|
profileDir)
|
||||||
|
}
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
package browser
|
||||||
|
|
||||||
|
// 本文件盯住「起进程之前的三条判定」(实现见 adopt.go):
|
||||||
|
//
|
||||||
|
// ① 端点活着 ⇒ **复用**,不起第二个进程;
|
||||||
|
// ② 端口记录在、端口没人听 ⇒ 清掉陈记录后照常启动;
|
||||||
|
// ③ 端口在听却握不上手 ⇒ 如实报错(带处置建议),不起进程;
|
||||||
|
// ④ 被占用且拿不到端点 ⇒ 情况②之外的「真被占用」:如实报 + 带上 Chrome 自己的原话。
|
||||||
|
//
|
||||||
|
// 判据①必须有**能证伪**的观察手段:怎么证明「没起第二个进程」?答案是**两重观察** ——
|
||||||
|
// ① **诱饵**:这些用例给 localChrome 配的 exe 是「一被拉起就大声喊、然后退出」的假浏览器
|
||||||
|
// (fakebrowser_test.go 的重跑自己范式),真走了启动路径,喊声会进日志、Dial 也会失败;
|
||||||
|
// ② **启动计数**:假浏览器每次被拉起都会往 profile 目录追一行(`fakeLaunches`),可以直接
|
||||||
|
// 数出「起了几次」。用例因此是绿的 ⇒ 启动路径没被走到。(变异自证:把复用那一支去掉 ⇒
|
||||||
|
// 判据①立刻变红。)
|
||||||
|
//
|
||||||
|
// 现场依据(主人实测复现):同一个 profile 上再起一次 Chrome 时,新进程 exit 0、不写新的端口
|
||||||
|
// 记录,stderr 逐字只有一句「正在现有的浏览器会话中打开。」—— Chromium 把命令委托给了已在跑
|
||||||
|
// 的实例。不做复用,这一档就一直报启动失败,直到那个进程退出。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeBrowserLure 是诱饵:假浏览器一旦被拉起就喊这句,用例据此判定「起了第二个进程」。
|
||||||
|
const fakeBrowserLure = "假浏览器被拉起了 —— 这一档不该有第二个进程"
|
||||||
|
|
||||||
|
// chromeSaysSessionOpen 是真 Chrome 把命令委托给现存实例时写出的那句 stderr 原话。
|
||||||
|
const chromeSaysSessionOpen = "正在现有的浏览器会话中打开。"
|
||||||
|
|
||||||
|
// deadPort 返回一个「刚刚还活着、现在没人听」的回环端口号:用来造**陈**的端口记录。
|
||||||
|
func deadPort(t *testing.T) int {
|
||||||
|
t.Helper()
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("取空闲端口: %v", err)
|
||||||
|
}
|
||||||
|
port := ln.Addr().(*net.TCPAddr).Port
|
||||||
|
if err := ln.Close(); err != nil {
|
||||||
|
t.Fatalf("释放端口: %v", err)
|
||||||
|
}
|
||||||
|
return port
|
||||||
|
}
|
||||||
|
|
||||||
|
// portOfEndpoint 取 `http://127.0.0.1:PORT` 里的端口号(写端口记录用)。
|
||||||
|
func portOfEndpoint(t *testing.T, endpoint string) int {
|
||||||
|
t.Helper()
|
||||||
|
port, err := strconv.Atoi(strings.TrimPrefix(endpoint, "http://127.0.0.1:"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("从 %q 取端口: %v", endpoint, err)
|
||||||
|
}
|
||||||
|
return port
|
||||||
|
}
|
||||||
|
|
||||||
|
// portFileExists 报告**我们自己的**端口记录还在不在(不清陈记录 / 保留现场两类断言都用它)。
|
||||||
|
func portFileExists(dir string) bool {
|
||||||
|
_, err := os.Stat(portRecordPath(dir))
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writePortRecordFile 写一条我们自己的端口记录(实现见 portrecord.go;格式:一行端口号)。
|
||||||
|
func writePortRecordFile(t *testing.T, dir string, port int) {
|
||||||
|
t.Helper()
|
||||||
|
if err := writePortRecord(dir, port); err != nil {
|
||||||
|
t.Fatalf("写端口记录: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeLegacyDevToolsPortFile 写一条**老格式**的端口记录(Chromium 在 `port==0` 时写的
|
||||||
|
// `DevToolsActivePort`:首行端口、次行 browser_guid)—— 用它钉住「换二进制后上一版起的浏览器
|
||||||
|
// 仍能被复用」这条兼容路径。
|
||||||
|
func writeLegacyDevToolsPortFile(t *testing.T, dir string, port int) {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(dir, legacyDevToolsPortFile)
|
||||||
|
if err := os.WriteFile(path, []byte(strconv.Itoa(port)+"\nGUID\n"), 0o600); err != nil {
|
||||||
|
t.Fatalf("写老格式端口记录: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDial_ReusesLiveEndpointAndDoesNotSpawnSecondProcess 是判据①:profile 里记的调试端点
|
||||||
|
// 在听、`/json/version` 也答得上来 ⇒ **连它**,不出第二个进程。
|
||||||
|
//
|
||||||
|
// 复用的必要性不只是省一次启动:同 profile 上起第二个实例时 Chromium 会把命令委托出去后自己
|
||||||
|
// 退出,新进程永远不写端口记录 ⇒ 不复用就等于这一档不可用(详见 adopt.go 的文件头)。
|
||||||
|
func TestDial_ReusesLiveEndpointAndDoesNotSpawnSecondProcess(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cdp := newFakeCDP(t, func(string, map[string]any) (any, *cdpError) { return map[string]any{}, nil })
|
||||||
|
log, buf := newCapturedLog(slog.LevelDebug)
|
||||||
|
l := newFakeChrome(t, fakeBrowserScript{Stderr: []string{fakeBrowserLure}, ExitCode: 9}, 5*time.Second, log)
|
||||||
|
writePortRecordFile(t, l.profileDir, portOfEndpoint(t, cdp.base))
|
||||||
|
|
||||||
|
endpoint, stop, err := l.Dial(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("活着且握手成功的端点应当被复用,实得错误: %v", err)
|
||||||
|
}
|
||||||
|
defer stop()
|
||||||
|
if endpoint != cdp.base {
|
||||||
|
t.Errorf("endpoint = %q, want %q(必须是复用来的那个端点)", endpoint, cdp.base)
|
||||||
|
}
|
||||||
|
logs := buf.String()
|
||||||
|
t.Logf("实测日志:\n%s", logs)
|
||||||
|
if containsInLog(logs, fakeBrowserLure) {
|
||||||
|
t.Errorf("起了第二个进程(诱饵被拉起)—— 复用这一支没生效:\n%s", logs)
|
||||||
|
}
|
||||||
|
if !strings.Contains(logs, "browser debug endpoint reused") {
|
||||||
|
t.Errorf("复用必须留一条 INFO(说明复用了已存在的调试端点):\n%s", logs)
|
||||||
|
}
|
||||||
|
if !strings.Contains(logs, "source="+logSource) {
|
||||||
|
t.Errorf("这条 INFO 必须带来源(AGENTS §10 禁无来源裸日志):\n%s", logs)
|
||||||
|
}
|
||||||
|
if n := fakeLaunches(l.profileDir); n != 0 {
|
||||||
|
t.Errorf("复用这条路上不该起任何进程(假浏览器被拉起 %d 次)", n)
|
||||||
|
}
|
||||||
|
stop() // 复用来的实例不归我们杀:停止函数必须是空操作(能调用即通过)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDial_ReusesLegacyDevToolsActivePort 钉住一条**兼容**路径:老版二进制(那时我们传的是
|
||||||
|
// `--remote-debugging-port=0`)在 profile 里留下的 `DevToolsActivePort` 若还活着,也必须被认领。
|
||||||
|
//
|
||||||
|
// 为什么值得单独钉:换二进制时上一版起的浏览器可能还在跑(本项目的后端常驻、浏览器按需回收)。
|
||||||
|
// 若不认那个文件,新二进制会把「profile 上已经有我们的浏览器」误报成「profile 被别人占了」,
|
||||||
|
// 于是功能一直不可用,直到那个浏览器自己空闲回收。
|
||||||
|
func TestDial_ReusesLegacyDevToolsActivePort(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cdp := newFakeCDP(t, func(string, map[string]any) (any, *cdpError) { return map[string]any{}, nil })
|
||||||
|
log, buf := newCapturedLog(slog.LevelDebug)
|
||||||
|
l := newFakeChrome(t, fakeBrowserScript{Stderr: []string{fakeBrowserLure}, ExitCode: 9}, 5*time.Second, log)
|
||||||
|
writeLegacyDevToolsPortFile(t, l.profileDir, portOfEndpoint(t, cdp.base))
|
||||||
|
|
||||||
|
endpoint, stop, err := l.Dial(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("老格式记录指向的活端点也该被复用,实得: %v", err)
|
||||||
|
}
|
||||||
|
defer stop()
|
||||||
|
if endpoint != cdp.base {
|
||||||
|
t.Errorf("endpoint = %q, want %q", endpoint, cdp.base)
|
||||||
|
}
|
||||||
|
if n := fakeLaunches(l.profileDir); n != 0 {
|
||||||
|
t.Errorf("复用这条路上不该起任何进程(假浏览器被拉起 %d 次):\n%s", n, buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDial_ClearsStaleLegacyRecord 补上「陈记录清理」的另一半:**老格式**记录指向的端口已经
|
||||||
|
// 没人听时,它也必须被清掉(否则下一次启动还会去探一个死端点),且不挡住正常启动。
|
||||||
|
func TestDial_ClearsStaleLegacyRecord(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
log, _ := newCapturedLog(slog.LevelDebug)
|
||||||
|
l := newFakeChrome(t, fakeBrowserScript{Serve: serveCDP}, 5*time.Second, log)
|
||||||
|
writeLegacyDevToolsPortFile(t, l.profileDir, deadPort(t))
|
||||||
|
|
||||||
|
endpoint, stop, err := l.Dial(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("陈的老格式记录不该挡住正常启动: %v", err)
|
||||||
|
}
|
||||||
|
defer stop()
|
||||||
|
port, ok := readPortRecord(l.profileDir)
|
||||||
|
if !ok || endpoint != endpointURL(port) {
|
||||||
|
t.Fatalf("成功之后应当记下本次用的端口:endpoint=%q port=%d ok=%v", endpoint, port, ok)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(l.profileDir, legacyDevToolsPortFile)); err == nil {
|
||||||
|
t.Error("陈的老格式记录应当已被清掉(留着它下次还会去探那个死端口)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDial_ClearsStalePortFileThenLaunches 是判据②:端口记录在、但端口已经没人听 ⇒ 那是
|
||||||
|
// **陈记录**,清掉后照常启动。两个子用例各钉一半:
|
||||||
|
//
|
||||||
|
// (1) 清掉之后,本次就绪判定认**新进程实际服务的**那个端口;
|
||||||
|
// (2) 清掉是**真清**:新进程从不就绪时,Dial 必须报「等端口就绪超时」—— 而不是把陈端口当成
|
||||||
|
// 本次结果稀里糊涂地成功(那正是「不清陈记录」会造成的伪装)。
|
||||||
|
func TestDial_ClearsStalePortFileThenLaunches(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
t.Run("以新进程实际服务的端口为准", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
log, _ := newCapturedLog(slog.LevelDebug)
|
||||||
|
l := newFakeChrome(t, fakeBrowserScript{Serve: serveCDP}, 5*time.Second, log)
|
||||||
|
stale := deadPort(t)
|
||||||
|
writePortRecordFile(t, l.profileDir, stale)
|
||||||
|
|
||||||
|
endpoint, stop, err := l.Dial(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("陈记录不该挡住正常启动: %v", err)
|
||||||
|
}
|
||||||
|
defer stop()
|
||||||
|
port, ok := readPortRecord(l.profileDir)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("成功之后必须记下本次用的端口")
|
||||||
|
}
|
||||||
|
if endpoint != endpointURL(port) {
|
||||||
|
t.Errorf("endpoint = %q, want %q", endpoint, endpointURL(port))
|
||||||
|
}
|
||||||
|
if port == stale {
|
||||||
|
t.Errorf("陈端口(%d)被当成本次结果了", stale)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("陈端口不会被当成本次结果", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
log, buf := newCapturedLog(slog.LevelDebug)
|
||||||
|
l := newFakeChrome(t, fakeBrowserScript{
|
||||||
|
Stderr: []string{"起来了,但从不答 CDP"}, SleepMS: 3000,
|
||||||
|
}, 300*time.Millisecond, log)
|
||||||
|
stale := deadPort(t)
|
||||||
|
writePortRecordFile(t, l.profileDir, stale)
|
||||||
|
|
||||||
|
endpoint, _, err := l.Dial(context.Background())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("陈记录被清掉、新进程又从不就绪 ⇒ 必须报错;实得 endpoint=%q", endpoint)
|
||||||
|
}
|
||||||
|
t.Logf("实测 error=%v\n实测日志=%s", err, buf.String())
|
||||||
|
if !errors.Is(err, ErrLaunch) {
|
||||||
|
t.Fatalf("仍须包住 ErrLaunch: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), launchMarkerPortTimeout) {
|
||||||
|
t.Errorf("应当读成「等调试端口就绪超时」: %v", err)
|
||||||
|
}
|
||||||
|
if endpoint == endpointURL(stale) {
|
||||||
|
t.Errorf("把陈端口当成本次结果了(endpoint=%q)", endpoint)
|
||||||
|
}
|
||||||
|
if portFileExists(l.profileDir) {
|
||||||
|
t.Error("陈的端口记录应当已被清掉(否则下次还会读到它)")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDial_ReportsLiveButUnusableEndpoint 是判据③:端口在听、但 `/json/version` 握不上手 ⇒
|
||||||
|
// **如实报错**,而且**不许静默忽略后另起一个进程**(起出来的那个只会委托出去再退出,把原因
|
||||||
|
// 糊成一句「启动失败」)。三种「在听但不是 CDP」的形态各钉一格。
|
||||||
|
func TestDial_ReportsLiveButUnusableEndpoint(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
handler http.HandlerFunc
|
||||||
|
}{
|
||||||
|
{"响应不是 JSON", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = w.Write([]byte("<html>我不是 CDP</html>"))
|
||||||
|
}},
|
||||||
|
{"JSON 但没有 Browser 字段", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(`{"hello":"world"}`))
|
||||||
|
}},
|
||||||
|
{"HTTP 503", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
srv := httptest.NewServer(tc.handler)
|
||||||
|
defer srv.Close()
|
||||||
|
log, buf := newCapturedLog(slog.LevelDebug)
|
||||||
|
l := newFakeChrome(t, fakeBrowserScript{Stderr: []string{fakeBrowserLure}, ExitCode: 9}, 3*time.Second, log)
|
||||||
|
writePortRecordFile(t, l.profileDir, portOfEndpoint(t, srv.URL))
|
||||||
|
|
||||||
|
_, stop, err := l.Dial(context.Background())
|
||||||
|
if stop != nil {
|
||||||
|
t.Error("失败路径不该交出停止函数")
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("端口在听却握不上手时必须如实报错,不许静默忽略")
|
||||||
|
}
|
||||||
|
t.Logf("实测 error=%v\n实测日志=%s", err, buf.String())
|
||||||
|
if !errors.Is(err, ErrLaunch) {
|
||||||
|
t.Fatalf("仍须包住 ErrLaunch: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), launchMarkerEndpointBroken) {
|
||||||
|
t.Errorf("错误里应点明这一类(端口在听但不是 CDP 端点): %v", err)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"处置", portRecordFile, l.profileDir} {
|
||||||
|
if !containsInLog(err.Error(), want) {
|
||||||
|
t.Errorf("可操作的处置建议里缺少 %q: %v", want, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if containsInLog(buf.String(), fakeBrowserLure) {
|
||||||
|
t.Errorf("不许静默忽略后另起一个进程(诱饵被拉起):\n%s", buf.String())
|
||||||
|
}
|
||||||
|
if !portFileExists(l.profileDir) {
|
||||||
|
t.Error("握不上手时不该动端口记录:它是现场证据,也是复用判定的唯一依据")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDial_ReportsOccupiedProfileWithChromeOutput 是判据④,也是本片要修的那个「必然等不到
|
||||||
|
// 端口」的场景在**我们没法复用**时的出口:新进程 exit 0(真机就是这样)、不写端口记录,
|
||||||
|
// Chrome 自己只在 stderr 说一句「正在现有的浏览器会话中打开。」。
|
||||||
|
//
|
||||||
|
// 要求:立刻报(不把预算干等完)、说清是哪一类(launchMarkerProcessExited)、给可操作建议、
|
||||||
|
// 并且把 **Chrome 自己的原话** 带进**返回给调用方**的错误里(用户侧的工具结果来自这条错误,
|
||||||
|
// 只留在日志里等于用户看不到)。
|
||||||
|
func TestDial_ReportsOccupiedProfileWithChromeOutput(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
log, buf := newCapturedLog(slog.LevelDebug)
|
||||||
|
l := newFakeChrome(t, fakeBrowserScript{Stderr: []string{chromeSaysSessionOpen}, ExitCode: 0}, 5*time.Second, log)
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
_, stop, err := l.Dial(context.Background())
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
if stop != nil {
|
||||||
|
t.Error("失败路径不该交出停止函数")
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("进程 exit 0 却不写端口记录 ⇒ 必须报错(真机就是这个形态)")
|
||||||
|
}
|
||||||
|
logs := buf.String()
|
||||||
|
t.Logf("实测 error=%v\n实测耗时=%v\n实测日志=%s", err, elapsed, logs)
|
||||||
|
if elapsed > 2*time.Second {
|
||||||
|
t.Errorf("应当立刻报,不把配置的 timeout 白等完:等了 %v", elapsed)
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrLaunch) {
|
||||||
|
t.Fatalf("仍须包住 ErrLaunch: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), launchMarkerProcessExited) {
|
||||||
|
t.Errorf("错误里应点明「进程立刻退出」这一类: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), chromeSaysSessionOpen) {
|
||||||
|
t.Errorf("错误里必须带上 Chrome 自己的原话(用户侧只看得到这条错误): %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "浏览器输出:") {
|
||||||
|
t.Errorf("原话应当以「浏览器输出:」引出(对应 withChromeOutput): %v", err)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"处置", "关掉那个浏览器窗口", "绝不杀别人的浏览器", l.profileDir} {
|
||||||
|
if !containsInLog(err.Error(), want) {
|
||||||
|
t.Errorf("可操作的处置建议里缺少 %q: %v", want, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
"--user-data-dir=" + l.profileDir, // 现场可复现:本次 argv
|
||||||
|
"chrome_output_bytes=", // 浏览器说了多少
|
||||||
|
chromeSaysSessionOpen, // 日志里也要有原话
|
||||||
|
} {
|
||||||
|
if !containsInLog(logs, want) {
|
||||||
|
t.Errorf("失败日志里缺少 %q:\n%s", want, logs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,10 +5,13 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||||
)
|
)
|
||||||
|
|
||||||
// cdpCallTimeout 是单条 CDP 命令的兜底超时(handshake / 读超时)。正常调用还会被
|
// cdpCallTimeout 是单条 CDP 命令的兜底超时(handshake / 读超时)。正常调用还会被
|
||||||
@@ -30,8 +33,28 @@ type cdpError struct {
|
|||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error 实现 error 接口。
|
// Error 渲染协议错误(实现 error 接口)。
|
||||||
func (e *cdpError) Error() string { return fmt.Sprintf("CDP %d: %s", e.Code, e.Message) }
|
//
|
||||||
|
// 🔴 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 关联的待答表。
|
// cdpClient 是最小 CDP 客户端:一条 WebSocket、一个读协程、按 id 关联的待答表。
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package browser
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -105,11 +106,23 @@ func TestCDPClient_CallRoundTrip(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestCDPClient_ProtocolErrorIsWrapped 钉住协议错误被 %w 包成 ErrCDP(上层据此
|
// TestCDPClient_ProtocolErrorKeepsNoFreeText 钉住两件事:协议错误被 %w 包成 ErrCDP
|
||||||
// 判定「连接级问题 ⇒ 丢会话重起」)。
|
// (上层据此判定「连接级问题 ⇒ 丢会话重起」),而 CDP 的 error.message **不出镜**。
|
||||||
func TestCDPClient_ProtocolErrorIsWrapped(t *testing.T) {
|
//
|
||||||
|
// 为什么 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) {
|
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)
|
wsURL, _ := pageTargetWS(context.Background(), f.base)
|
||||||
c, err := newCDPClient(context.Background(), wsURL)
|
c, err := newCDPClient(context.Background(), wsURL)
|
||||||
@@ -118,14 +131,47 @@ func TestCDPClient_ProtocolErrorIsWrapped(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer func() { _ = c.Close() }()
|
defer func() { _ = c.Close() }()
|
||||||
err = c.Call(context.Background(), "Runtime.evaluate", nil, nil)
|
err = c.Call(context.Background(), "Runtime.evaluate", nil, nil)
|
||||||
if err == nil || !strings.Contains(err.Error(), "boom") {
|
if err == nil {
|
||||||
t.Fatalf("Call 的错误 = %v,应含 CDP 的 message", err)
|
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 调用失败") {
|
if !strings.Contains(err.Error(), "CDP 调用失败") {
|
||||||
t.Errorf("错误没带上 ErrCDP 文案: %v", err)
|
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 钉住「取消能立刻返回」,不是等对端回话。
|
// TestCDPClient_ContextCancelStopsWaiting 钉住「取消能立刻返回」,不是等对端回话。
|
||||||
func TestCDPClient_ContextCancelStopsWaiting(t *testing.T) {
|
func TestCDPClient_ContextCancelStopsWaiting(t *testing.T) {
|
||||||
f := newFakeCDP(t, func(string, map[string]any) (any, *cdpError) {
|
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:])
|
||||||
|
}
|
||||||
@@ -162,7 +162,7 @@ func runCompareOnce(t *testing.T, rawURL string, g compareGroup, repeat int, run
|
|||||||
|
|
||||||
rec := compareRecord{
|
rec := compareRecord{
|
||||||
TS: time.Now().Format(time.RFC3339), Group: g.Name, URL: rawURL,
|
TS: time.Now().Format(time.RFC3339), Group: g.Name, URL: rawURL,
|
||||||
Mode: modeName(g.Headless), LaunchFlags: drv.launchArgs(),
|
Mode: modeName(g.Headless), LaunchFlags: drv.launchArgs(portOfEndpoint(t, s.endpoint)),
|
||||||
CDPRuntimeEnable: false, // 本驱动从源头**不发** Runtime.enable(reference/06 的 P0)
|
CDPRuntimeEnable: false, // 本驱动从源头**不发** Runtime.enable(reference/06 的 P0)
|
||||||
Driver: "cyrene-go-cdp", ChromeVersion: chromeVersionOf(t, s.endpoint),
|
Driver: "cyrene-go-cdp", ChromeVersion: chromeVersionOf(t, s.endpoint),
|
||||||
UserDataDir: profile, Headless: g.Headless,
|
UserDataDir: profile, Headless: g.Headless,
|
||||||
|
|||||||
@@ -20,9 +20,12 @@
|
|||||||
//
|
//
|
||||||
// 红线(design/28 §3/§4、AGENTS §9/§10):
|
// 红线(design/28 §3/§4、AGENTS §9/§10):
|
||||||
//
|
//
|
||||||
// - **不新开对外监听端口**:调试端口由 Chromium 自己绑在 `127.0.0.1` 的**随机**
|
// - **不新开对外监听端口**:调试端口是**我们选号、Chromium 自己绑在 `127.0.0.1`** 的
|
||||||
// 端口(`--remote-debugging-port=0`,端口从 profile 目录的 `DevToolsActivePort`
|
// 显式端口(`--remote-debugging-port=<port>`,端口号由 `listen(:0)` 借内核挑一个空闲号
|
||||||
// 读回),我们是**出站连接方**;容器路径同理(我们连它)。
|
// 后立刻放掉,并记在 `<profile>/cyrene-debug-port` 里供下次复用,见 portrecord.go)——
|
||||||
|
// 本包**不持有任何监听端口**,始终是**出站连接方**;容器路径同理(我们连它)。
|
||||||
|
// 为什么不传 `--remote-debugging-port=0`:Chromium 会把「端口=0」当作 ChromeDriver 式
|
||||||
|
// 自动化启动、主动点亮 `navigator.webdriver`(reference/chromium-cdp/00 §2)。
|
||||||
// - **profile 含凭据**(cookie / 登录态):目录 0700、不得位于 git 工作树内
|
// - **profile 含凭据**(cookie / 登录态):目录 0700、不得位于 git 工作树内
|
||||||
// (`browsercfg.EnsureProfileDir`),内容绝不进上下文与长期记忆。
|
// (`browsercfg.EnsureProfileDir`),内容绝不进上下文与长期记忆。
|
||||||
// - **截图可能含凭据/二维码**:文件 0600、默认不进长期记忆(design/28 §4)。
|
// - **截图可能含凭据/二维码**:文件 0600、默认不进长期记忆(design/28 §4)。
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
Loaded 100 of 572 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user