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)。配置后必须真实走语义路径。
|
||||
# EMBEDDING_API_URL 是【完整 /embeddings 端点】(不是 base)——例:Ollama / llama.cpp = http://localhost:11434/v1/embeddings
|
||||
# EMBEDDING_API_URL=http://localhost:11434/v1/embeddings
|
||||
# EMBEDDING_API_URL 是【完整 /embeddings 端点】(不是 base)——例:Ollama / llama.cpp = http://127.0.0.1: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_MODEL=Qwen3-Embedding-0.6B # 推荐(Q8_0 ~639MB、dim=1024、ModelScope 国内可下)
|
||||
|
||||
@@ -234,9 +239,10 @@ WEB_SEARCH_PROVIDER=searxng
|
||||
# 只在该家「不可用」(无 key / 鉴权失败 / 限流 / 额度 / 网络)时前进一格;结果里会
|
||||
# 如实标注**实际应答**的 provider,绝不拿兜底冒充主力。空结果**不**触发前进。
|
||||
# WEB_SEARCH_FALLBACK=zhipu,bocha,searxng
|
||||
# WEB_SEARCH_MAX_RESULTS = 单次检索返回条数上限。默认 5;provider 自身的条数上限
|
||||
# (如博查 50、IQS 固定 10)优先于本键的下限。
|
||||
WEB_SEARCH_MAX_RESULTS=5
|
||||
# WEB_SEARCH_MAX_RESULTS = 单次检索返回条数上限(**播种层**的键:面板文件一旦落盘,
|
||||
# 就以 $DATA_DIR/config/plugins/web-search.json 的 maxResults 为准,改面板即生效、不用重启)。
|
||||
# 默认 10;provider 自身的条数上限(如博查 50、IQS 固定 10)优先于本键的下限。
|
||||
WEB_SEARCH_MAX_RESULTS=10
|
||||
# WEB_SEARCH_ENDPOINT = **searxng provider** 的端点(SearXNG 兼容,GET + format=json)。
|
||||
# 默认值 http://127.0.0.1:38471 是**本机兜底**:scripts/searxng/settings.yml 就是它的
|
||||
# 配套配置(docker 起法见该文件头注释),无需任何第三方 API key、开箱即用 —— 默认指向
|
||||
|
||||
@@ -36,8 +36,10 @@
|
||||
- 目的:防主会话上下文超限触发压缩;压缩后靠本文件 + `DevDocs/工作小结/00-当前态.md` 恢复锚点,继续当监工。
|
||||
|
||||
## 9. 全球红线(跨里程碑,必须遵守;违反即架构偏离)
|
||||
- **单二进制、单端口 `:8080`、零跨进程 IPC**:模块间同步走**进程内接口 + 依赖注入**(直接方法调用),异步走**进程内 `bus`**;**不另开监听端口、不 HTTP 互调**。
|
||||
- ♢ **已登记例外(主人 2026-10-04 裁决)**:`qq-obv11` 渠道的 **OneBot 反连(server)模式**需要独立 `net.Listen` 端口 —— **仅限该渠道的 server 模式**,且端口必须由配置**显式给出**(`listen_addr`),**不得默认开启**;其余渠道/模式一律不得新开监听端口。规格见 `design/08 §4`、`design/13 §0`。
|
||||
- **单二进制、单端口 `:8080`、零跨进程 IPC**(🔴 **本条约束的是 Cyrene 框架自身,不约束扩展插件**):框架模块间同步走**进程内接口 + 依赖注入**(直接方法调用),异步走**进程内 `bus`**;**框架自身不另开监听端口、不 HTTP 互调**。
|
||||
- 🆕 **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 决定)。
|
||||
- **人格 = 身份**(**不限制能力**);**行为模式 = 呈现**(≠边界,可热切换、不构成能力边界);**能力全局全能**;**前端(Web)是第一公民**。
|
||||
- **IM 是镜像**:只 chat 文本,Web 不回流 IM;输出一律 chat 文本。
|
||||
@@ -79,6 +81,6 @@
|
||||
- 🔴 **等待子代理:不许用长 `WaitFor` 阻塞回合(主人 2026-10-04 反馈)**:主会话在 `WaitFor` 里等子代理时,**主人无法给任何补充输入** —— 等于把主人锁在门外。规矩:**派完就结束回合**(子代理的完成通知会自动唤醒主会话);确需同回合内等,**单次 `WaitFor` ≤ 60s**,且只用于「马上就要用它的结果」;等待期间若有并行工作,**先做工作再结束回合**,不要空等。
|
||||
- 🔴 **遇到开发文档冲突 / 意义不明 / 规格矛盾时**:**阻塞会话**,用 `AskUserQuestion` 询问主人,**不得自行猜测**;文档清、不冲突则自主推进。
|
||||
- 🔴 阶段验证:后端 `go build ./...` + `go vet ./...` + `go test ./...` 全绿 + windows/darwin 交叉编译;前端(M9+)`tsc --noEmit` + `vite build` 通过。
|
||||
- 🔴 **换二进制前先确认前端是否变更(2026-10-05 登记)**:`scripts\ctrl.bat build` **只跑 `go build`,不含前端**。若本次改动触及 `frontend/web/**`,必须先在 `frontend/web` 跑 `npm run build`(会把 `dist` 拷到 `internal/webui/dist`),**再** `ctrl.bat build` + **一次** `restart`;否则后端产物里带的是旧前端。反过来:**纯前端改动不需要重启后端**(运行时优先读磁盘 `frontend/web/dist`),刷新浏览器即可。
|
||||
- 🔴 **换二进制前先确认前端是否变更(2026-10-05 登记)**:🔴 **`internal/webui/dist/.gitkeep` 是 `git add -f` 追踪的占位文件,不要删、不要 `git rm --cached`** —— 它让**干净 checkout / 新 worktree** 能直接 `go build ./...`(`//go:embed all:dist` 在空目录上会编译失败);真实产物仍被 `.gitignore` 的 `dist/` 忽略,`frontend/web/copy-dist.mjs` 已在构建末尾保证把它写回来。删掉它会同时打坏编译与前端拷贝链。`scripts\ctrl.bat build` **只跑 `go build`,不含前端**。若本次改动触及 `frontend/web/**`,必须先在 `frontend/web` 跑 `npm run build`(会把 `dist` 拷到 `internal/webui/dist`),**再** `ctrl.bat build` + **一次** `restart`;否则后端产物里带的是旧前端。反过来:**纯前端改动不需要重启后端**(运行时优先读磁盘 `frontend/web/dist`),刷新浏览器即可。
|
||||
- 🔴 **裁定必须带完成状态(2026-10-05 登记)**:凡是给主人 / 给安卓端的裁定、承诺、回执,**每一条都必须标注「已完成 / 在办 / 未开始」**;标「已完成」须附 commit 号或验收证据,且**完成后回原条目处补记「已完成 + commit」**——不得只在别处口头称完成。**禁止把「未验证」表述成「已修好」**。
|
||||
- 🔴 **提交一律逐文件 `git add <显式路径>`(2026-10-05 实测事故)**:**禁止**在「同一目录下有多路在途切片」时用 `git add -A -- <目录>` —— 实测它会**把别的子代理的在途文件一起吞进提交**(本次误把 237 的 `sessionenv.go` 提交并推了)。修复套路:`git reset --soft HEAD~1` → `git restore --staged <误入文件>` → 重新 commit → `git push --force-with-lease origin dev`(工作树内容不丢)。
|
||||
+2258
-1799
File diff suppressed because it is too large.
Load diff
@@ -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 记忆隔离、能力全局共享
|
||||
- **通用 Agent 运行时** — 多供应商 LLM(per-role 路由 + 降级:主 → 备 → 本地,本地级默认关)、工具调用(**8 轮上限**)、流式输出、子代理编排(生命周期 / 结果合并 / 并行上限 / 超时 / 防失控)、会话持久化 + 主·次级会话、上下文压缩(预算按模型窗口推导)
|
||||
- **记忆系统** — **三类作用域隔离**(用户习惯全局共享 / 角色日常他角色只读 / 项目人人读写 + 项目索引分层)、相关性 + 最近度检索、衰减、溯源、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)
|
||||
continue
|
||||
}
|
||||
logFlowControl(log, built)
|
||||
out = append(out, built)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// logFlowControl 逐账号打一条启动日志,如实报出这个账号此刻被限成什么样(M24 裁决 A):
|
||||
// 闸是否开、每群几条/分、待处理上限、审查触发水位(reviewAt)。
|
||||
//
|
||||
// 为什么需要它:namedAdapter 的 Config() 无条件暴露四个流控键(含零值),桥的 cfgInt
|
||||
// 因此永远认为「键存在」,flowOrDefault 再把 0 映射成保护性默认 —— 于是「一个流控键都
|
||||
// 没写的账号,闸门仍然是开着的」。这条事实必须一眼可见,否则「群里为什么有消息被丢」
|
||||
// 只能靠读代码解释。判据:只看这一行日志就能说出这个群的限流/审查额度。
|
||||
//
|
||||
// 判定依据是**构建出来的那个适配器本身**,不是重新读一遍配置:非 namedAdapter 的实例
|
||||
// 不暴露流控键,桥按 flowgate.go「键缺失 ⇒ 该限制关闭」处理;这时必须如实报 off,
|
||||
// 不能拿保护性默认值假装成「开着」。
|
||||
func logFlowControl(log *slog.Logger, built adapter.PlatformAdapter) {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
log = logger.WithSource(log, "cmd/cyrene/channels")
|
||||
n, ok := built.(*namedAdapter)
|
||||
if !ok {
|
||||
log.Info("channel account ingress flow control",
|
||||
"account", built.Name(), "gate", "off",
|
||||
"why", "adapter exposes no flow keys, every limit stays disabled")
|
||||
return
|
||||
}
|
||||
group := flowLimitOrDefault(n.groupMsgPerMin, adapter.FlowLimitDefault)
|
||||
direct := flowLimitOrDefault(n.directMsgPerMin, adapter.FlowDirectDefault)
|
||||
pending := flowLimitOrDefault(n.maxMessages, adapter.FlowCapDefault)
|
||||
log.Info("channel account ingress flow control",
|
||||
"account", n.Name(), "gate", "on",
|
||||
"group_msg_per_min", group, "group_msg_per_min_raw", n.groupMsgPerMin,
|
||||
"direct_msg_per_min", direct, "direct_msg_per_min_raw", n.directMsgPerMin,
|
||||
"msg_per_min", max(n.msgPerMin, 0),
|
||||
"max_messages", pending, "max_messages_raw", n.maxMessages,
|
||||
"review_at", adapter.ReviewAtFor(pending),
|
||||
"note", "raw 0 (or absent in channels.json) takes the protective default; negative disables")
|
||||
}
|
||||
|
||||
// flowLimitOrDefault 把账号原始值映射为实际生效值:负值关闭该限制、零值取保护性默认、
|
||||
// 正值固定。它与 internal/services/channelbridge/flowgate.go 的 flowOrDefault 逐字同
|
||||
// 口径,这里只用于上面那条启动日志 —— 日志必须报生效值,不能报原始值。
|
||||
func flowLimitOrDefault(v, def int) int {
|
||||
switch {
|
||||
case v < 0:
|
||||
return 0
|
||||
case v == 0:
|
||||
return def
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
// channelAccountsFromEnv 合成 env 兜底:单账号 CHANNEL_<NAME>_* 适配器,加上
|
||||
// CHANNELS_JSON 里的多账号条目。它是 channels.json 缺失或非法时传给
|
||||
// runtimeconfig.LoadChannels 的 .env 兜底。.env 只承载必要的连接兜底
|
||||
@@ -174,8 +225,18 @@ type namedAdapter struct {
|
||||
denyGroups []string
|
||||
allowUsers []string
|
||||
denyUsers []string
|
||||
// 流控限制(design/12 §6 策略三/四)。它们始终经 Config() 暴露,使桥能构建按账号
|
||||
// 的入口闸门;桥把零值默认映射为保护性取值,把负值映射为「关闭」。
|
||||
// 流控限制(design/12 §6 策略三/四)。**四个键始终经 Config() 暴露,包括零值**:
|
||||
// 桥的 cfgInt 因此永远认为「键存在」,flowOrDefault 于是把 0 映射成保护性默认
|
||||
// (每群 6 条/分、待处理上限 50、reviewAt = 40)—— 一个在 channels.json 里一个
|
||||
// 流控键都没写的账号,闸门**仍然是开着的**(M24 只读勘测实测:现存 qq 账号就是
|
||||
// 这种状态)。负值经桥映射为「关闭」。
|
||||
//
|
||||
// 边界的准确说法(M24):flowgate.go 那句「键缺失 ⇒ 该限制关闭」的向后兼容分支,
|
||||
// 对 CHANNELS_JSON 账号**不可达** —— config.ChannelAccount 用的是普通 int
|
||||
// (omitempty),「未配置」与「显式配 0」解出来都是 0,这里无从区分。要区分就得
|
||||
// 改 config 的类型(*int / 存在性表)或在本题二次解析原始 JSON;而那样做会改掉
|
||||
// 「闸门默认开还是关」这个产品口径(群聊限流/审查默认从开变关),属主人裁决,
|
||||
// 不在本处自行翻转。见 M24 回报里的选项 A/B/C。
|
||||
groupMsgPerMin int
|
||||
directMsgPerMin int
|
||||
msgPerMin int
|
||||
|
||||
@@ -125,6 +125,13 @@ func run() error {
|
||||
return fmt.Errorf("start gateway: %w", err)
|
||||
}
|
||||
|
||||
// emotionTTS 自启(切片 M47):**在网关 Listen 之后**才挂 —— 网关的
|
||||
// ListenAndServe 已交给自己的 goroutine,这里只投递一次「探活 → 必要时拉起」,
|
||||
// 整段(毫秒级探活 + 起一个 .bat)都在后台跑,因此**不占**后端启动时长:走到
|
||||
// 这里时进程已经可以服务。开关默认关(面板文件 voice-tts-launch.json),
|
||||
// 失败只 WARN,绝不影响后端启动与退出。
|
||||
a.startVoiceTTSLaunch(ctx)
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
a.log.Info("cyrene signal received, shutting down")
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package main
|
||||
|
||||
// 本文件把**后端启动时顺带照顾 emotionTTS**(切片 M47)接进组合根,并守住三条
|
||||
// 红线(塔 2026-10 已定,主人原话见 internal/runtime/voice/ttslaunch 包注释):
|
||||
//
|
||||
// 1. **默认关**:面板文件 `$DATA_DIR/config/voice-tts-launch.json` 没写
|
||||
// `{"enabled": true}` 时,这里**连探活都不发**、也不构造任何后台任务 ——
|
||||
// 主人早前说过「开机自启先不急 后面做」。
|
||||
// 2. **不占启动时长**:`Trigger` 把探活 + 起脚本整段丢进后台 goroutine 后立即
|
||||
// 返回,调用点在网关 Listen 之后(见 main.run);探活超时/脚本拉起耗时都
|
||||
// 落不到后端的启动路径上。
|
||||
// 3. **失败只 WARN**:脚本不存在、起进程失败、探活超时都只是带 source 的日志,
|
||||
// 本函数**没有 error 返回**,绝不阻塞或改坏后端启动。
|
||||
// 4. **探活地址单向回退**(塔 2026-10-26 裁决,见 resolveProbeURL):launch 文件
|
||||
// 显式写了 `probeUrl` 就以它为准;没写则用 `voice-tts.json` 的 `baseUrl`;两者
|
||||
// 都空才是默认 9880。反向(拿这里的地址去改合成面)一律不做。
|
||||
//
|
||||
// 零新 `.env` 键(`.env` 已冻结):开关与脚本路径都走面板文件家族
|
||||
// (`runtimeconfig`,与 `voice-tts.json` 同一套读取口径)。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/ttslaunch"
|
||||
)
|
||||
|
||||
// voiceTTSLaunchSource 是本切片日志的来源标签(AGENTS §10:日志必须标来源)。
|
||||
const voiceTTSLaunchSource = "cmd/cyrene/voice-tts-launch"
|
||||
|
||||
// startVoiceTTSLaunch 读面板配置,按需投递**一次**「探活 → 必要时拉起」。
|
||||
//
|
||||
// 返回 nil 表示这一级没启用(没配过 / 显式 false / 文件坏掉),调用方据此什么都
|
||||
// 不做;返回非 nil 仅为了让调用方在关闭期或验收里能 `Wait()`(生产路径忽略它)。
|
||||
func (a *app) startVoiceTTSLaunch(ctx context.Context) *ttslaunch.Launcher {
|
||||
log := logger.WithSource(a.log, voiceTTSLaunchSource)
|
||||
path := runtimeconfig.DefaultVoiceTTSLaunchPath(resolveDataDir(a.cfg))
|
||||
tuning, err := runtimeconfig.LoadVoiceTTSLaunchTuning(path)
|
||||
if err != nil {
|
||||
// 坏文件 ⇒ **不启用**:一份读不懂的配置绝不能变成"随手拉起一个进程"。
|
||||
log.Warn("voice tts launch config unreadable, emotionTTS auto-launch stays off",
|
||||
"path", runtimeconfig.VoiceTTSLaunchConfigRelPath, "err", err)
|
||||
return nil
|
||||
}
|
||||
if !tuning.EnabledOn() {
|
||||
log.Debug("emotionTTS auto-launch not enabled in panel config",
|
||||
"path", runtimeconfig.VoiceTTSLaunchConfigRelPath, "hint", `set {"enabled": true}`)
|
||||
return nil
|
||||
}
|
||||
resolved := a.resolveProbeURL(tuning.ProbeURL)
|
||||
if err := validateResolvedProbeURL(resolved); err != nil {
|
||||
// 🔴 写错地址(漏 scheme / 主机拼错)会让"没起"永远成立 ⇒ **每次启动都拉一次
|
||||
// 进程**。宁可不启用,也不做这种自动重试机器(复审 P2-2b)。
|
||||
log.Warn("emotionTTS auto-launch stays off: invalid probe url",
|
||||
"probe_url", resolved.url,
|
||||
"probe_url_source", resolved.probeSource,
|
||||
"err", err)
|
||||
return nil
|
||||
}
|
||||
launcher := ttslaunch.New(withProbeURL(voiceTTSLaunchConfig(tuning), resolved), ttslaunch.WithLogger(log))
|
||||
cfg := launcher.Config()
|
||||
log.Info("emotionTTS auto-launch enabled",
|
||||
"probe_url", cfg.ProbeURL,
|
||||
// 探活地址可能来自三处,日志必须**同时**给出地址与来源:主人改端口后
|
||||
// 一眼能看出实际问的是哪个地址、以及它是显式写的还是兜底来的。
|
||||
"probe_url_source", resolved.probeSource,
|
||||
"script", cfg.ScriptPath,
|
||||
"probe_timeout_ms", cfg.ProbeTimeout.Milliseconds(),
|
||||
"cooldown_sec", int(cfg.Cooldown/time.Second),
|
||||
"config", runtimeconfig.VoiceTTSLaunchConfigRelPath)
|
||||
// 立即返回:探活与起脚本都在后台跑(见 ttslaunch.Launcher.Trigger)。
|
||||
launcher.Trigger(ctx)
|
||||
return launcher
|
||||
}
|
||||
|
||||
// probeResolution 是"探活地址 + 它的来源"(来源只用于日志,绝不参与判定)。
|
||||
type probeResolution struct {
|
||||
url string
|
||||
probeSource string
|
||||
}
|
||||
|
||||
// 探活地址的三个来源标签(写进启动 Info 行,供主人一眼核对)。
|
||||
const (
|
||||
probeSourceLaunchConfig = "voice-tts-launch.json"
|
||||
probeSourceVoiceTTS = "voice-tts.json"
|
||||
probeSourceDefault = "default"
|
||||
)
|
||||
|
||||
// resolveProbeURL 决定这次到底探哪个地址 —— **单向**回退,且只在组合根发生:
|
||||
//
|
||||
// 1. `voice-tts-launch.json` 显式写了 `probeUrl` ⇒ 用它(**永不**被别的键压过);
|
||||
// 2. 没写 ⇒ 兜底用 `voice-tts.json` 的 `baseUrl`(同一个服务,主人换端口只该改
|
||||
// 一处;这正是"两处一起改"的 footgun 的收口);
|
||||
// 3. 两者都没写(或 voice-tts.json 坏了)⇒ 空串,由 `ttslaunch.Config.Normalized`
|
||||
// 补默认 `http://127.0.0.1:9880`。
|
||||
//
|
||||
// 🔴 只做这一向:反向(拿 launch 的地址去改 `voice-tts.json` 的 `baseUrl`,或用它
|
||||
// 顶替合成地址)一律不做 —— 合成面与"起没起"是两个问题,前者不能被后者的配置影响。
|
||||
func (a *app) resolveProbeURL(explicit string) probeResolution {
|
||||
if url := strings.TrimSpace(explicit); url != "" {
|
||||
return probeResolution{url: url, probeSource: probeSourceLaunchConfig}
|
||||
}
|
||||
log := logger.WithSource(a.log, voiceTTSLaunchSource)
|
||||
tuning, err := runtimeconfig.LoadVoiceTTSTuning(runtimeconfig.DefaultVoiceTTSPath(resolveDataDir(a.cfg)))
|
||||
if err != nil {
|
||||
// 坏文件 ⇒ 这里只回落默认地址(**不**因此关掉自启:那是 launch 文件自己的
|
||||
// 事),并且必须说清楚"我没读到 baseUrl"。
|
||||
log.Warn("voice tts config unreadable, probe url falls back to default",
|
||||
"path", runtimeconfig.VoiceTTSConfigRelPath, "err", err)
|
||||
return probeResolution{probeSource: probeSourceDefault}
|
||||
}
|
||||
if base := strings.TrimSpace(tuning.BaseURL); base != "" {
|
||||
log.Debug("probe url inherited from voice tts config",
|
||||
"path", runtimeconfig.VoiceTTSConfigRelPath, "base_url", base)
|
||||
return probeResolution{url: base, probeSource: probeSourceVoiceTTS}
|
||||
}
|
||||
return probeResolution{probeSource: probeSourceDefault}
|
||||
}
|
||||
|
||||
// withProbeURL 把解析出的探活地址填进 launcher 配置(空串留给 Normalized 补默认)。
|
||||
func withProbeURL(cfg ttslaunch.Config, r probeResolution) ttslaunch.Config {
|
||||
cfg.ProbeURL = r.url
|
||||
return cfg
|
||||
}
|
||||
|
||||
// validateResolvedProbeURL 校验**最终要探的那个地址**(空串 = 两个文件都没给,由
|
||||
// Normalized 补默认 9880,默认值本身合法,这里不重复校验)。
|
||||
//
|
||||
// 它和 `ttslaunch.New` 里的那道校验是同一把闸,但这里多做一件事:WARN 带上
|
||||
// `probe_url_source` —— 主人一眼能看出该改哪个文件(launch 文件的 probeUrl,
|
||||
// 还是 voice-tts.json 的 baseUrl)。
|
||||
func validateResolvedProbeURL(r probeResolution) error {
|
||||
if r.url == "" {
|
||||
return nil
|
||||
}
|
||||
return ttslaunch.ValidateProbeURL(r.url)
|
||||
}
|
||||
|
||||
// voiceTTSLaunchConfig 把面板形状映射成 launcher 的配置。
|
||||
//
|
||||
// 它**不**在这里裁决缺省值(脚本路径/探活地址/超时/冷却的默认值全在
|
||||
// `ttslaunch.Config.Normalized` 一处):零值原样传下去,由那一处补齐。
|
||||
func voiceTTSLaunchConfig(t runtimeconfig.VoiceTTSLaunchTuning) ttslaunch.Config {
|
||||
return ttslaunch.Config{
|
||||
Enabled: t.EnabledOn(),
|
||||
ScriptPath: t.ScriptPath,
|
||||
ProbeURL: t.ProbeURL,
|
||||
ProbeTimeout: time.Duration(t.ProbeTimeoutMs) * time.Millisecond,
|
||||
Cooldown: time.Duration(t.CooldownSec) * time.Second,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package main
|
||||
|
||||
// 本文件锁定复审 P2-2 在**组合根**上的两道闸:
|
||||
//
|
||||
// 1. **关闭期取消 ⇒ 0 次拉起**(P2-2a):后端关闭时 ctx 被取消,探活随之返回
|
||||
// `context.Canceled`,这必须是"跳过",绝不能在关闭过程中拉起 TTS;
|
||||
// 2. **探活地址非法 ⇒ 不启用、一次都不拉**(P2-2b):`probeUrl` 与 `voice-tts.json`
|
||||
// 的 `baseUrl` 兜底**两个来源**都要被挡(漏一个就等于绕过闸门,变成"每次启动
|
||||
// 都拉一次进程")。
|
||||
//
|
||||
// 用例除了看返回值,还会真等一会儿确认没有进程被拉起(拉起是异步的,脚本一跑
|
||||
// 就会落 marker 文件);探活用黑洞监听(只接连接不回话),保证取消一定落在探活
|
||||
// 进行中。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/ttslaunch"
|
||||
)
|
||||
|
||||
// TestStartVoiceTTSLaunch_ShutdownCancelDoesNotLaunch 钉住"关闭期取消 ⇒ 0 次拉起"。
|
||||
//
|
||||
// 变异点:去掉 ttslaunch 里 `errors.Is(probeErr, context.Canceled)` 那个跳过分支 ⇒
|
||||
// 取消被当成"没起" ⇒ 脚本被拉起(marker 出现),本用例红。
|
||||
func TestStartVoiceTTSLaunch_ShutdownCancelDoesNotLaunch(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
blackhole, addr := blackholeListener(t)
|
||||
defer blackhole.Close()
|
||||
|
||||
a, buf := launchTestApp(t, dir)
|
||||
script, marker := markerBat(t, dir)
|
||||
writeLaunchPanel(t, dir, fmt.Sprintf(
|
||||
`{"enabled":true,"probeUrl":%q,"probeTimeoutMs":5000,"scriptPath":%q}`,
|
||||
"http://"+addr+"/", script))
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
launcher := a.startVoiceTTSLaunch(ctx)
|
||||
if launcher == nil {
|
||||
t.Fatal("启用后应拿到 launcher")
|
||||
}
|
||||
// 模拟后端关闭:探活还挂在黑洞上时取消。
|
||||
cancel()
|
||||
launcher.Wait()
|
||||
|
||||
assertNotLaunched(t, marker)
|
||||
if out := buf.String(); strings.Contains(out, "emotionTTS launch failed") {
|
||||
t.Fatalf("关闭期取消被记成了拉起失败: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartVoiceTTSLaunch_InvalidProbeURLStaysOff 钉住第一道闸:launch 文件里写了
|
||||
// 非法 `probeUrl` ⇒ 返回 nil(不构造 launcher)、WARN 标出源、**一次都不拉**。
|
||||
//
|
||||
// 变异点:去掉 `validateResolvedProbeURL` ⇒ 返回非 nil,且探活每轮必败 ⇒ 脚本被
|
||||
// 拉起(marker 出现),两条断言都会红。
|
||||
func TestStartVoiceTTSLaunch_InvalidProbeURLStaysOff(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
a, buf := launchTestApp(t, dir)
|
||||
script, marker := markerBat(t, dir)
|
||||
writeLaunchPanel(t, dir, fmt.Sprintf(`{"enabled":true,"probeUrl":"127.0.0.1:9880","scriptPath":%q}`, script))
|
||||
|
||||
launcher := a.startVoiceTTSLaunch(context.Background())
|
||||
if launcher != nil {
|
||||
t.Fatal("非法 probeUrl 仍启用了自启(会每次启动都拉一次进程)")
|
||||
}
|
||||
assertNotLaunched(t, marker)
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "invalid probe url") {
|
||||
t.Fatalf("非法 probeUrl 没记 WARN: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "probe_url_source=voice-tts-launch.json") {
|
||||
t.Fatalf("WARN 没标出该改哪个文件: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartVoiceTTSLaunch_InvalidBaseURLFallbackStaysOff 钉住第二道闸:launch 文件
|
||||
// 没写 probeUrl、而 `voice-tts.json` 的 baseUrl 非法 ⇒ 同样不启用、一次都不拉。
|
||||
func TestStartVoiceTTSLaunch_InvalidBaseURLFallbackStaysOff(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
a, buf := launchTestApp(t, dir)
|
||||
script, marker := markerBat(t, dir)
|
||||
writeVoiceTTSPanel(t, a, `{"enabled":true,"baseUrl":"127.0.0.1:9880"}`)
|
||||
writeLaunchPanel(t, dir, fmt.Sprintf(`{"enabled":true,"scriptPath":%q}`, script))
|
||||
|
||||
launcher := a.startVoiceTTSLaunch(context.Background())
|
||||
if launcher != nil {
|
||||
t.Fatal("baseUrl 兜底来的非法地址也没被挡住(每次启动都会拉一次进程)")
|
||||
}
|
||||
assertNotLaunched(t, marker)
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "invalid probe url") {
|
||||
t.Fatalf("非法 baseUrl 没记 WARN: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "probe_url_source=voice-tts.json") {
|
||||
t.Fatalf("WARN 没标出该改哪个文件: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateResolvedProbeURL_EmptyMeansDefault 钉住"空串不在这里判死":两个文件
|
||||
// 都没给地址时交给 Normalized 补 9880(默认值本身合法)。
|
||||
func TestValidateResolvedProbeURL_EmptyMeansDefault(t *testing.T) {
|
||||
if err := validateResolvedProbeURL(probeResolution{url: "", probeSource: probeSourceDefault}); err != nil {
|
||||
t.Fatalf("空地址应交给默认值: %v", err)
|
||||
}
|
||||
if err := validateResolvedProbeURL(probeResolution{url: ttslaunch.DefaultProbeURL}); err != nil {
|
||||
t.Fatalf("默认地址必须合法: %v", err)
|
||||
}
|
||||
if err := validateResolvedProbeURL(probeResolution{url: "localhost:9880"}); err == nil {
|
||||
t.Fatal("漏 scheme 的地址被放过了")
|
||||
}
|
||||
}
|
||||
|
||||
// markerBat 造一个"跑起来就落 marker"的 .bat,用于观察"到底有没有进程被拉起"。
|
||||
// 非 Windows 返回空串(那台机器上这个脚本本来也拉不起来),调用方据此跳过观察。
|
||||
func markerBat(t *testing.T, dir string) (script, marker string) {
|
||||
t.Helper()
|
||||
if runtime.GOOS != "windows" {
|
||||
return "", ""
|
||||
}
|
||||
script = filepath.Join(dir, "一键启动.bat")
|
||||
marker = filepath.Join(dir, "launched.txt")
|
||||
body := fmt.Sprintf("@echo off\r\necho launched> \"%s\"\r\n", marker)
|
||||
if err := os.WriteFile(script, []byte(body), 0o600); err != nil {
|
||||
t.Fatalf("write script: %v", err)
|
||||
}
|
||||
return script, marker
|
||||
}
|
||||
|
||||
// assertNotLaunched 等一小会儿确认脚本**没有**被拉起(拉起是异步的,所以不能只看
|
||||
// 返回值;空 marker = 非 Windows,跳过观察)。
|
||||
func assertNotLaunched(t *testing.T, marker string) {
|
||||
t.Helper()
|
||||
if marker == "" {
|
||||
return
|
||||
}
|
||||
deadline := time.Now().Add(600 * time.Millisecond)
|
||||
for time.Now().Before(deadline) {
|
||||
if _, err := os.Stat(marker); err == nil {
|
||||
t.Fatalf("脚本被拉起了: %s", marker)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
package main
|
||||
|
||||
// 本文件锁定切片 M47 在**组合根**上的口径(都是"改坏就静默开错/静默拖慢启动"的那种):
|
||||
//
|
||||
// 1. 面板文件没配过 / 写了 `enabled:false` ⇒ **不启用**(返回 nil),连探活都不发;
|
||||
// 2. 启用后 `startVoiceTTSLaunch` **立即返回**,即使探活会挂住整整一个超时 ——
|
||||
// 证明 TTS 的延迟不计入后端启动时长(它是唯一"启动耗时"可测的量);
|
||||
// 3. 拉不起来(脚本不存在)⇒ 只有 WARN,**没有任何 error 回流**到启动路径。
|
||||
// 4. 探活地址**单向**回退(塔 2026-10-26 裁决):launch 文件显式写了 `probeUrl`
|
||||
// 就以它为准;没写则用 `voice-tts.json` 的 `baseUrl`;两者都空才是默认 9880。
|
||||
// 启动 Info 行必须同时给出地址与来源。
|
||||
//
|
||||
// 它用 `DefaultVoiceTTSLaunchPath` 的真实落点
|
||||
// ($DATA_DIR/config/voice-tts-launch.json)跑,因此也钉住"零新 .env 键"。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/ttslaunch"
|
||||
)
|
||||
|
||||
// captureBuf 是并发安全的日志缓冲(WARN 由后台 goroutine 写)。
|
||||
type captureBuf struct {
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (b *captureBuf) Write(p []byte) (int, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.Write(p)
|
||||
}
|
||||
|
||||
func (b *captureBuf) String() string {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.String()
|
||||
}
|
||||
|
||||
// launchTestApp 造一个只够本题用的 app(数据目录 + 收日志的 logger)。
|
||||
func launchTestApp(t *testing.T, dataDir string) (*app, *captureBuf) {
|
||||
t.Helper()
|
||||
buf := &captureBuf{}
|
||||
log := slog.New(slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
return &app{cfg: &config.Config{DataDir: dataDir}, log: log}, buf
|
||||
}
|
||||
|
||||
// writeLaunchPanel 把面板文件写到真实落点($DATA_DIR/config/voice-tts-launch.json)。
|
||||
func writeLaunchPanel(t *testing.T, dataDir, body string) {
|
||||
t.Helper()
|
||||
path := runtimeconfig.DefaultVoiceTTSLaunchPath(dataDir)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatalf("write panel: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartVoiceTTSLaunch_DefaultOff 钉住"默认关":没配过 ⇒ 不构造、不探活、不拉起。
|
||||
func TestStartVoiceTTSLaunch_DefaultOff(t *testing.T) {
|
||||
a, buf := launchTestApp(t, t.TempDir())
|
||||
start := time.Now()
|
||||
if l := a.startVoiceTTSLaunch(context.Background()); l != nil {
|
||||
t.Fatal("没配过却启用了自启")
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 100*time.Millisecond {
|
||||
t.Fatalf("默认关也花了 %v", elapsed)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "not enabled") {
|
||||
t.Fatalf("没留一条「没启用」的痕迹: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartVoiceTTSLaunch_ExplicitFalse 钉住"显式 false 同样一次都不拉"。
|
||||
func TestStartVoiceTTSLaunch_ExplicitFalse(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeLaunchPanel(t, dir, `{"enabled":false}`)
|
||||
a, _ := launchTestApp(t, dir)
|
||||
if l := a.startVoiceTTSLaunch(context.Background()); l != nil {
|
||||
t.Fatal("enabled:false 却启用了自启")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartVoiceTTSLaunch_ReturnsBeforeProbe 是本切片的"启动耗时"证明:
|
||||
// 探活会挂住一个 800ms 的超时(对端只接连接不回话),而挂载动作必须在毫秒级返回。
|
||||
//
|
||||
// 同时它顺带钉住第 3 条:脚本不存在 ⇒ 后台只记 WARN,`startVoiceTTSLaunch` 自己
|
||||
// 没有任何 error 可返回(签名里就没有),后端启动路径不受影响。
|
||||
func TestStartVoiceTTSLaunch_ReturnsBeforeProbe(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
blackhole, addr := blackholeListener(t)
|
||||
defer blackhole.Close()
|
||||
|
||||
writeLaunchPanel(t, dir, fmt.Sprintf(
|
||||
`{"enabled":true,"probeUrl":%q,"probeTimeoutMs":800,"scriptPath":%q}`,
|
||||
"http://"+addr+"/", filepath.Join(dir, "不存在的一键启动.bat")))
|
||||
a, buf := launchTestApp(t, dir)
|
||||
|
||||
start := time.Now()
|
||||
launcher := a.startVoiceTTSLaunch(context.Background())
|
||||
elapsed := time.Since(start)
|
||||
if launcher == nil {
|
||||
t.Fatal("启用后应拿到 launcher")
|
||||
}
|
||||
if elapsed > 150*time.Millisecond {
|
||||
t.Fatalf("挂载花了 %v —— 探活被同步等在了启动路径上", elapsed)
|
||||
}
|
||||
// 后台那次探活会等到 800ms 超时,然后去拉一个不存在的脚本。
|
||||
launcher.Wait()
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "level=WARN") || !strings.Contains(out, "backend startup unaffected") {
|
||||
t.Fatalf("拉起失败没有只记 WARN: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVoiceTTSLaunchConfig_MapsTuning 钉住"面板字段 → launcher 配置"的映射:
|
||||
// 缺省值不由组合根裁决(空字段原样传下去,由 ttslaunch.Config.Normalized 补)。
|
||||
func TestVoiceTTSLaunchConfig_MapsTuning(t *testing.T) {
|
||||
enabled := true
|
||||
cfg := voiceTTSLaunchConfig(runtimeconfig.VoiceTTSLaunchTuning{
|
||||
Enabled: &enabled,
|
||||
ScriptPath: `D:\Project\Service\emotionTTS_v5.1.0\一键启动.bat`,
|
||||
ProbeURL: "http://127.0.0.1:9880",
|
||||
ProbeTimeoutMs: 250,
|
||||
CooldownSec: 90,
|
||||
})
|
||||
if !cfg.Enabled {
|
||||
t.Fatal("enabled 没映射过来")
|
||||
}
|
||||
if cfg.ProbeTimeout != 250*time.Millisecond || cfg.Cooldown != 90*time.Second {
|
||||
t.Fatalf("超时/冷却没映射: %+v", cfg)
|
||||
}
|
||||
if cfg.ScriptPath != ttslaunch.DefaultScriptPath || cfg.ProbeURL != ttslaunch.DefaultProbeURL {
|
||||
t.Fatalf("路径/地址没映射: %+v", cfg)
|
||||
}
|
||||
|
||||
// 空配置 ⇒ 不启用,且一个缺省值都不在这里填(Normalized 才是唯一裁决处)。
|
||||
if empty := voiceTTSLaunchConfig(runtimeconfig.VoiceTTSLaunchTuning{}); empty.Enabled {
|
||||
t.Fatal("空配置不该启用")
|
||||
} else if empty.ScriptPath != "" || empty.ProbeURL != "" || empty.ProbeTimeout != 0 {
|
||||
t.Fatalf("组合根不该替 launcher 填缺省值: %+v", empty)
|
||||
}
|
||||
}
|
||||
|
||||
// blackholeListener 起一个"只接连接、绝不回话"的回环监听:探活会一直等到自己的
|
||||
// 超时,正好用来证明挂载动作没有同步等它。
|
||||
func blackholeListener(t *testing.T) (net.Listener, string) {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
var conns []net.Conn
|
||||
var mu sync.Mutex
|
||||
go func() {
|
||||
for {
|
||||
c, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
conns = append(conns, c)
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
t.Cleanup(func() {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
for _, c := range conns {
|
||||
_ = c.Close()
|
||||
}
|
||||
})
|
||||
return ln, ln.Addr().String()
|
||||
}
|
||||
|
||||
// TestStartVoiceTTSLaunch_ProbeURLInheritedFromVoiceTTS 钉住**单向回退**的第一向:
|
||||
// launch 文件没写 `probeUrl` 时,用 `voice-tts.json` 的 `baseUrl`(主人换端口只该
|
||||
// 改一处)。
|
||||
//
|
||||
// 它不只比字符串 —— 真让探活打到那个端口(hits > 0):把回退摘掉,探活就会跑去
|
||||
// 默认 9880,本用例立刻红。
|
||||
func TestStartVoiceTTSLaunch_ProbeURLInheritedFromVoiceTTS(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
voiceTTS, hits := countingServer(t)
|
||||
a, buf := launchTestApp(t, dir)
|
||||
|
||||
// 合成面把端口改成了自定义端口;自启面**没写** probeUrl。
|
||||
writeVoiceTTSPanel(t, a, fmt.Sprintf(`{"enabled":true,"baseUrl":%q,"personas":{"cyrene":"char_1"}}`, voiceTTS.URL))
|
||||
writeLaunchPanel(t, dir, fmt.Sprintf(`{"enabled":true,"scriptPath":%q}`,
|
||||
filepath.Join(dir, "不存在的一键启动.bat")))
|
||||
|
||||
launcher := a.startVoiceTTSLaunch(context.Background())
|
||||
if launcher == nil {
|
||||
t.Fatal("启用后应拿到 launcher")
|
||||
}
|
||||
if got := launcher.Config().ProbeURL; got != voiceTTS.URL {
|
||||
t.Fatalf("探活地址 = %q, want voice-tts.json 的 baseUrl %q", got, voiceTTS.URL)
|
||||
}
|
||||
launcher.Wait()
|
||||
if n := atomic.LoadInt32(hits); n == 0 {
|
||||
t.Fatal("探活没打到 voice-tts.json 的 baseUrl 端口(回退失效)")
|
||||
}
|
||||
if out := buf.String(); !strings.Contains(out, "probe_url_source=voice-tts.json") {
|
||||
t.Fatalf("启动 Info 行没标出地址是兜底来的: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartVoiceTTSLaunch_ProbeURLFallsBackToDefault 钉住第二向:两个文件都没给
|
||||
// 地址 ⇒ 默认 `http://127.0.0.1:9880`,且日志标 `default`。
|
||||
func TestStartVoiceTTSLaunch_ProbeURLFallsBackToDefault(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
a, buf := launchTestApp(t, dir)
|
||||
// 只有自启文件,没有 voice-tts.json。
|
||||
writeLaunchPanel(t, dir, fmt.Sprintf(`{"enabled":true,"scriptPath":%q}`,
|
||||
filepath.Join(dir, "不存在的一键启动.bat")))
|
||||
|
||||
launcher := a.startVoiceTTSLaunch(context.Background())
|
||||
if launcher == nil {
|
||||
t.Fatal("启用后应拿到 launcher")
|
||||
}
|
||||
if got := launcher.Config().ProbeURL; got != ttslaunch.DefaultProbeURL {
|
||||
t.Fatalf("探活地址 = %q, want 默认 %q", got, ttslaunch.DefaultProbeURL)
|
||||
}
|
||||
launcher.Wait()
|
||||
if out := buf.String(); !strings.Contains(out, "probe_url_source=default") {
|
||||
t.Fatalf("启动 Info 行没标出地址用的是默认值: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartVoiceTTSLaunch_ExplicitProbeURLWins 钉住"显式压过兜底":launch 文件写了
|
||||
// `probeUrl` ⇒ 用它,**永不**去问 voice-tts.json 的 baseUrl(后者一次都不该被探)。
|
||||
func TestStartVoiceTTSLaunch_ExplicitProbeURLWins(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
explicit, explicitHits := countingServer(t)
|
||||
inherited, inheritedHits := countingServer(t)
|
||||
a, buf := launchTestApp(t, dir)
|
||||
|
||||
writeVoiceTTSPanel(t, a, fmt.Sprintf(`{"enabled":true,"baseUrl":%q}`, inherited.URL))
|
||||
writeLaunchPanel(t, dir, fmt.Sprintf(`{"enabled":true,"probeUrl":%q,"scriptPath":%q}`,
|
||||
explicit.URL, filepath.Join(dir, "不存在的一键启动.bat")))
|
||||
|
||||
launcher := a.startVoiceTTSLaunch(context.Background())
|
||||
if launcher == nil {
|
||||
t.Fatal("启用后应拿到 launcher")
|
||||
}
|
||||
if got := launcher.Config().ProbeURL; got != explicit.URL {
|
||||
t.Fatalf("探活地址 = %q, want 显式 %q", got, explicit.URL)
|
||||
}
|
||||
launcher.Wait()
|
||||
if atomic.LoadInt32(explicitHits) == 0 {
|
||||
t.Fatal("显式 probeUrl 没被用来探活")
|
||||
}
|
||||
if n := atomic.LoadInt32(inheritedHits); n != 0 {
|
||||
t.Fatalf("显式 probeUrl 仍去问了 voice-tts.json 的地址 %d 次", n)
|
||||
}
|
||||
if out := buf.String(); !strings.Contains(out, "probe_url_source=voice-tts-launch.json") {
|
||||
t.Fatalf("启动 Info 行没标出地址是显式配置的: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// countingServer 起一个"数请求次数"的 httptest 服务:用来证明探活**真的打到了**
|
||||
// 某个地址,而不只是配置里写着那个字符串。
|
||||
func countingServer(t *testing.T) (*httptest.Server, *int32) {
|
||||
t.Helper()
|
||||
var hits int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
atomic.AddInt32(&hits, 1)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv, &hits
|
||||
}
|
||||
@@ -33,7 +33,11 @@ func (e webSearchActionEnv) listProviders(_ context.Context, _ pluginmgr.ActionR
|
||||
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:表单的用途就是**新增**
|
||||
// 一家,只列已配置的会让第一次配置无从选起。
|
||||
@@ -48,9 +52,11 @@ func (e webSearchActionEnv) listProviderOptions(_ context.Context, _ pluginmgr.A
|
||||
// - apiKey **空串** ⇒ 显式清空(前端只有勾了「清除」才会送出空串);
|
||||
// - baseUrl / enabled 缺位或空串 ⇒ 保留;给出(含 false / 空地址)⇒ 采用。
|
||||
//
|
||||
// 同一次提交里还可能带两个**文件级**字段(`primary` / `fallback`,切片 W3):它们不是这一条
|
||||
// provider 的属性,而是「谁先用 / 失败后按什么顺序降级」,因此合并到文件级而不是条目上
|
||||
// (解析与校验在 websearch_selection.go)。缺位 = 不改动,与其余字段同一套哨兵语义。
|
||||
// 同一次提交里还可能带三个**文件级**字段(`primary` / `fallback` / `maxResults`):它们不是
|
||||
// 这一条 provider 的属性,而是「谁先用 / 失败后按什么顺序降级 / 单次最多几条」,因此合并到
|
||||
// 文件级而不是条目上(解析与校验在 websearch_selection.go)。缺位 = 不改动;但给出了**不合法**
|
||||
// 的值(如 `maxResults` ≤0 / 非整数)⇒ 整笔 400,**绝不静默丢弃** —— 静默丢弃会让用户以为
|
||||
// 上限已经改了,而跑的还是旧的那个(那正是这一片要消灭的那条「我改了它却没变」)。
|
||||
//
|
||||
// 落盘之后由组合根的热重载事务接管(`websearch_reload.go`:重建工具 + 换注册表条目,
|
||||
// 失败则连同本次落盘一起回滚)—— 本函数只负责「把改动存进去」这一件事。
|
||||
@@ -79,11 +85,20 @@ func (e webSearchActionEnv) saveProvider(_ context.Context, req pluginmgr.Action
|
||||
return nil, err
|
||||
}
|
||||
entry.Provider = name
|
||||
next, err := e.applySelectionPatch(cur, values)
|
||||
next, setPrimary, err := e.mergeSelectionPatch(cur, values)
|
||||
if err != nil {
|
||||
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)
|
||||
return nil, pluginmgr.NewActionError(actionCodeConfigWriteFailed,
|
||||
"面板配置写不进去(磁盘或权限问题),本次改动**没有**生效", http.StatusInternalServerError, err)
|
||||
@@ -95,10 +110,12 @@ func (e webSearchActionEnv) saveProvider(_ context.Context, req pluginmgr.Action
|
||||
"provider": name,
|
||||
"created": !existed,
|
||||
"enabled": entry.IsEnabled(),
|
||||
// 文件级选择的**落盘后**取值(没给 primary/fallback 时也如实回显当前值):
|
||||
// 面板按它回显「谁先用」,也让「这次保存有没有动到选择」有可机读的答案。
|
||||
"primary": strings.ToLower(strings.TrimSpace(next.Provider)),
|
||||
"fallback": selectionNames(next.Fallback),
|
||||
// 文件级取值的**落盘后**样子(没给这几个键时也如实回显当前值):面板按它回显
|
||||
// 「谁先用」,也让「这次保存有没有动到选择 / 上限」有可机读的答案。
|
||||
"primary": strings.ToLower(strings.TrimSpace(final.Provider)),
|
||||
"fallback": selectionNames(final.Fallback),
|
||||
// maxResults 的 0 = 文件里未声明(由 `.env` / 代码默认兜底),原样回显,不编一个假值。
|
||||
"maxResults": final.MaxResults,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -154,6 +171,10 @@ func (e webSearchActionEnv) testProvider(ctx context.Context, req pluginmgr.Acti
|
||||
// 只把它算作「盘上已有值」⇒ 留空 = 不改动、要清空必须显式勾选;
|
||||
// - 其余字段回**盘上那条的原样**,不做默认值回填:回填代码默认地址会让一次
|
||||
// 「只改 key」的保存把默认地址固化进文件(面板显示的是「该条」,不是「生效值」)。
|
||||
//
|
||||
// 文件级字段(`maxResults` 在内)也按同一条口径回填:**盘上有值才回**。上限不回填的话表单
|
||||
// 里那个框是空的,用户既看不见当前上限、也无从改起;而盘上没写(0 = 未声明)时**不回** 0 ——
|
||||
// 0 会被前端当成一个值送回来,写侧又拒 ≤0,那会让「只改 key」的保存整笔进不去。
|
||||
func (e webSearchActionEnv) reauthProvider(_ context.Context, req pluginmgr.ActionRequest) (any, error) {
|
||||
name, err := providerNameOf(req.Payload)
|
||||
if err != nil {
|
||||
@@ -172,8 +193,9 @@ func (e webSearchActionEnv) reauthProvider(_ context.Context, req pluginmgr.Acti
|
||||
"baseUrl": entry.BaseURL,
|
||||
"enabled": !exists || entry.IsEnabled(),
|
||||
}
|
||||
// 两个文件级字段一并回填:不然用户「清空降级链」在前端的三态写回里会落成缺位
|
||||
// (= 不改动),「我清掉了它」就成了一句假话。
|
||||
// 三个文件级字段一并回填(主 provider / 降级链 / 条数上限):不然用户「清空降级链」在前端
|
||||
// 的三态写回里会落成缺位(= 不改动),「我清掉了它」就成了一句假话;上限同理 ——
|
||||
// 不回填就看不见盘上的值,改上限也就无从下手。
|
||||
addSelectionValues(values, f)
|
||||
if masked, ok := maskedProviderEntry(f, name); ok && strings.TrimSpace(masked.APIKey) != "" {
|
||||
values["apiKey"] = masked.APIKey
|
||||
@@ -239,29 +261,12 @@ func maskedProviderEntry(f runtimeconfig.WebSearchFile, name string) (runtimecon
|
||||
}
|
||||
|
||||
// effectiveBaseURL 给出一条配置**实际会用的**基地址:盘上有就用盘上的,没有就回落到
|
||||
// 代码默认值(各云家的厂商默认地址)。
|
||||
// **该接入位的代码默认基地址**(`plugins/tools/io/search` 的接入位表,唯一出处)。
|
||||
//
|
||||
// searxng 不回落(空地址 = 关掉这家,回落会让「清空地址」变成假话)—— 与
|
||||
// `effectiveSearchConfig` 的同一条权威边界。
|
||||
// 进位链与装配期**同一个函数**(resolveBaseURL):searxng 的默认基地址是空 —— 它的地址
|
||||
// 为空本身就是「关掉这家」,回落会让「清空地址」变成假话。
|
||||
func (e webSearchActionEnv) effectiveBaseURL(name string, entry runtimeconfig.ProviderEntry) string {
|
||||
if base := strings.TrimSpace(entry.BaseURL); base != "" {
|
||||
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 ""
|
||||
return resolveBaseURL(name, entry.BaseURL)
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
// 本文件是「搜索提供商」页表单里**两个文件级字段**的解析与落盘(切片 W3;
|
||||
// `DECISIONS-2026-10-06` 裁决 #2:声明面必须能配「谁先用」——`primary` + `fallback[]`)。
|
||||
// 本文件是「搜索提供商」页表单里**文件级字段**的解析与落盘(切片 W3;
|
||||
// `DECISIONS-2026-10-06` 裁决 #2:声明面必须能配「谁先用」——`primary` + `fallback[]`;
|
||||
// 后续切片又加了 `maxResults`:结果条数上限也得能在面板上改)。
|
||||
//
|
||||
// 它们与其余字段(provider / apiKey / baseUrl / enabled)的区别:
|
||||
//
|
||||
// - 其余字段描述**一条 provider 条目**(providers[] 里的一个元素);
|
||||
// - `primary` / `fallback` 描述**整份配置的选择**(文件级的 `provider` / `fallback`,
|
||||
// 即 design/27 §3 的「主后端 + 降级链」)。
|
||||
// - 这三个描述**整份配置**:`primary` / `fallback` 是 design/27 §3 的「主后端 + 降级链」,
|
||||
// `maxResults` 是单次检索的条数上限 —— 三者都是可变源顶层的同名键。
|
||||
//
|
||||
// 为什么挂在**同一个** form 区块里,而不是另开一个:前端的「带回表单」(reauth)只在面板
|
||||
// **恰好一个** form 区块时转交(`frontend/web/src/admin/plugin/PluginPanel.tsx` 的
|
||||
// `singleForm`),加第二个 form 会让既有的「重配凭据」回填整条失灵。因此这两项作为文件级
|
||||
// `singleForm`),加第二个 form 会让既有的「重配凭据」回填整条失灵。因此这些项作为文件级
|
||||
// 字段进既有表单,回填值由 `reauthProvider` 一并给出(见 addSelectionValues 的调用点)。
|
||||
//
|
||||
// 字段 key 刻意不叫 `provider`:表单里的 `provider` 已经是「这一条 provider 的名字」,
|
||||
@@ -19,7 +20,9 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// 表单里两个文件级字段的 key 由**声明侧**给出(search.FieldPrimary / search.FieldFallback):
|
||||
// 它们必须与 panel.go 里写下的字段声明逐字一致(本文件读的正是这些 key),而
|
||||
// 「声明里写一个 key、处理器里读另一个」这类漂移只有共用一个常量才拦得住 ——
|
||||
// 表单里三个文件级字段的 key 由**声明侧**给出(search.FieldPrimary / FieldFallback /
|
||||
// FieldMaxResults):它们必须与 panel.go 里写下的字段声明逐字一致(本文件读的正是这些 key),
|
||||
// 而「声明里写一个 key、处理器里读另一个」这类漂移只有共用一个常量才拦得住 ——
|
||||
// 在 cmd/cyrene 里再写一遍字面量就等于第二份真源。
|
||||
const (
|
||||
// selectionPrimaryKey 是主 provider 的字段 key(落到文件级 `provider`)。
|
||||
selectionPrimaryKey = search.FieldPrimary
|
||||
// selectionFallbackKey 是降级链的字段 key(落到文件级 `fallback`)。
|
||||
selectionFallbackKey = search.FieldFallback
|
||||
// selectionMaxResultsKey 是结果条数上限的字段 key(落到文件级 `maxResults`)。
|
||||
selectionMaxResultsKey = search.FieldMaxResults
|
||||
)
|
||||
|
||||
// actionCodePrimaryNotConfigured 报告「选中的主 provider 在本文件里还没配齐」——
|
||||
@@ -44,24 +49,38 @@ const (
|
||||
// fail-fast),所以在写盘之前就拒掉,而不是先写进盘里等着下次起不来。
|
||||
const actionCodePrimaryNotConfigured = "primary_not_configured"
|
||||
|
||||
// applySelectionPatch 把表单里的主 provider / 降级链合并进文件(三态:**缺位 = 不改动**)。
|
||||
// mergeSelectionPatch 把表单里的三个**文件级**字段(主 provider / 降级链 / 结果条数上限)
|
||||
// 合并进文件(三态:**缺位 = 不改动**),并回报「这次有没有给出 `primary`」
|
||||
// —— **校验不在这里**。
|
||||
//
|
||||
// 与其余字段同一套哨兵语义(websearch_action_data.go 的 saveProvider 注释):
|
||||
// `primary` 缺位 ⇒ 保留盘上的;给了值(含显式空串 / off / none)⇒ 采用 —— 空串与
|
||||
// `off`/`none` 是本项目**唯一一套**关闭语义(config.IsWebSearchOffSentinel),面板不另造一套。
|
||||
//
|
||||
// 校验放在写盘之前(主 provider 必须已配齐),因为「先存下来、下次启动才发现起不来」
|
||||
// 是本项目明确要避免的失败形态(AGENTS §10 的「诚实性」与 design/27 §3 的 fail-fast)。
|
||||
func (e webSearchActionEnv) applySelectionPatch(
|
||||
// `maxResults` 的第三态**比另外两个严**:它没有「空串 = 关闭」这种解释,因此 ≤0 与非整数
|
||||
// 一律 400(口径与理由见 selectionMaxResults)—— 那正是这一片要消灭的「我改了它却没变」。
|
||||
//
|
||||
// 🔴 **顺序硬约束(缺陷修复 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,
|
||||
) (runtimeconfig.WebSearchFile, error) {
|
||||
) (runtimeconfig.WebSearchFile, bool, error) {
|
||||
primary, setPrimary, err := selectionPrimary(values)
|
||||
if err != nil {
|
||||
return f, err
|
||||
return f, false, err
|
||||
}
|
||||
fallback, setFallback, err := selectionFallback(values)
|
||||
if err != nil {
|
||||
return f, err
|
||||
return f, false, err
|
||||
}
|
||||
maxResults, setMaxResults, err := selectionMaxResults(values)
|
||||
if err != nil {
|
||||
return f, false, err
|
||||
}
|
||||
if setPrimary {
|
||||
f.Provider = primary
|
||||
@@ -69,19 +88,17 @@ func (e webSearchActionEnv) applySelectionPatch(
|
||||
if setFallback {
|
||||
f.Fallback = fallback
|
||||
}
|
||||
if setPrimary {
|
||||
if err := e.checkPrimaryUsable(f, f.Provider); err != nil {
|
||||
return f, err
|
||||
}
|
||||
if setMaxResults {
|
||||
f.MaxResults = maxResults
|
||||
}
|
||||
return f, nil
|
||||
return f, setPrimary, nil
|
||||
}
|
||||
|
||||
// checkPrimaryUsable 判定一个主 provider 名在**落盘后的文件**里是否可用。
|
||||
//
|
||||
// 判定口径与工具接线同源(searchProviderSpecs + effectiveBaseURL):云家看 API Key、
|
||||
// searxng 看接口地址,基地址缺位时回落到代码默认值;条目被显式停用视同没配
|
||||
// (effectiveSearchConfig 会跳过它)。
|
||||
// 判定口径与工具接线同源(resolveSearchProviders + resolveBaseURL):云家看 API Key、
|
||||
// searxng 看接口地址,基地址缺位时回落**该接入位的代码默认基地址**;条目被显式停用
|
||||
// 视同没配(装配期会跳过它)。判据本身**不放松**:真的没配齐的主家仍必须被拒。
|
||||
func (e webSearchActionEnv) checkPrimaryUsable(f runtimeconfig.WebSearchFile, name string) error {
|
||||
// 显式关闭(空串 / off / none)永远合法:它表示「不要这把工具」,工具不进注册面,
|
||||
// 后端照常启动(与 WEB_SEARCH_PROVIDER 的既有语义逐字一致)。
|
||||
@@ -92,19 +109,27 @@ func (e webSearchActionEnv) checkPrimaryUsable(f runtimeconfig.WebSearchFile, na
|
||||
return err
|
||||
}
|
||||
entry, found := findProviderEntry(f, name)
|
||||
base := ""
|
||||
if found {
|
||||
base = e.effectiveBaseURL(name, entry)
|
||||
if !found || !entry.IsEnabled() {
|
||||
return primaryNotConfiguredError(name)
|
||||
}
|
||||
if !found || !entry.IsEnabled() || !search.Configured(name, entry.APIKey, base) {
|
||||
return pluginmgr.NewActionError(actionCodePrimaryNotConfigured,
|
||||
fmt.Sprintf("主提供商 %q 还没配齐(缺 API Key / 接口地址,或这条被停用了):"+
|
||||
"先把它配好再选它 —— 否则这份配置会让后端下一次启动直接失败", name),
|
||||
http.StatusBadRequest, nil)
|
||||
// 基地址的进位链与装配期**同一个函数**:条目里的值 → 该接入位的默认基地址 → 空。
|
||||
// 云家的可用性只看 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,
|
||||
fmt.Sprintf("主提供商 %q 还没配齐(缺 API Key / 接口地址,或这条被停用了):"+
|
||||
"先把它配好再选它 —— 否则这份配置会让后端下一次启动直接失败", name),
|
||||
http.StatusBadRequest, nil)
|
||||
}
|
||||
|
||||
// selectionNames 返回降级链的线形状(**非 nil 空切片**:nil 会在 JSON 里变成 null,
|
||||
// 而「一条降级都没有」与「这个字段没值」在前端不该长得不一样)。
|
||||
func selectionNames(names []string) []string {
|
||||
@@ -114,10 +139,11 @@ func selectionNames(names []string) []string {
|
||||
return names
|
||||
}
|
||||
|
||||
// addSelectionValues 把盘上的文件级选择回填进 `reauth` 的 values。
|
||||
// addSelectionValues 把盘上的**文件级取值**回填进 `reauth` 的 values。
|
||||
//
|
||||
// 回填的必要性在前端的三态写回里:只有 `initial` 有值,用户「清空降级链」才会被送成
|
||||
// 显式空(`planSubmit`),否则会落成「缺位 = 不改动」——「我清掉了它」就成了一句假话。
|
||||
// `maxResults` 同理:不回填的话表单里那个框是空的,用户看不见当前上限、也就无从改起。
|
||||
func addSelectionValues(values map[string]any, f runtimeconfig.WebSearchFile) {
|
||||
values[selectionPrimaryKey] = f.Provider
|
||||
fb := f.Fallback
|
||||
@@ -125,6 +151,60 @@ func addSelectionValues(values map[string]any, f runtimeconfig.WebSearchFile) {
|
||||
fb = []string{}
|
||||
}
|
||||
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。
|
||||
|
||||
@@ -280,3 +280,49 @@ func TestWebSearchProbeNotConfiguredIs400(t *testing.T) {
|
||||
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"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -124,11 +122,15 @@ func TestAppClose_RealChromeStopsOnShutdown(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
// ① 起一个真浏览器(指纹自检走 CDP,无需外网),并从 profile 读回它的调试端点。
|
||||
// ① 起一个真浏览器(指纹自检走 CDP,无需外网),并取回它的调试端点。
|
||||
if _, err := tool.Execute(ctx, map[string]any{"action": "info"}); err != nil {
|
||||
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) {
|
||||
t.Fatalf("会话已建立但调试端点 %s 不应答:本用例的前提不成立", endpoint)
|
||||
}
|
||||
@@ -151,22 +153,6 @@ func TestAppClose_RealChromeStopsOnShutdown(t *testing.T) {
|
||||
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)。
|
||||
func endpointAlive(endpoint string) bool {
|
||||
client := &http.Client{Timeout: 2 * time.Second}
|
||||
|
||||
+114
-1
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
@@ -17,6 +18,7 @@ import (
|
||||
"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/user"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// ownerRegistrar 实现 gateway.OwnerRegistrar:它把一个渠道账号 id 持久化进主人
|
||||
@@ -57,6 +59,30 @@ func (r *ownerRegistrar) RegisterChannelOwner(_ context.Context, platform, accou
|
||||
// 加载失败则为 nil,gateway 会退回「真实落盘 + 提示需重启」,绝不假装热生效。
|
||||
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 渠道适配器,把它们注册进能力注册表(管理
|
||||
// API 据此枚举),并接进渠道 bridge——让已配置的适配器连上,把入站消息按
|
||||
// session→actor→loop→reply 路由。缺凭据的适配器保持未连接(只记日志,绝不作
|
||||
@@ -83,6 +109,18 @@ func (a *app) wireChannels() error {
|
||||
// 入站附件的目录口径与文档摄取层(wire_runtime.go 的 docResolver)同源,
|
||||
// 两处必须指向同一棵目录树,否则附件会落在一个地方、抽取去另一个地方找。
|
||||
docDataDir := resolveDataDir(a.cfg)
|
||||
// owner 车道结构化旋钮(design/18 §11「乙 = C」)的落盘面:主人在群里说「少说点」
|
||||
// 这类诉求被写成 $DATA_DIR/config/owner_lane.json(可读可改,面板后续照此写入)。
|
||||
// 它只承载「说不说 / 多频繁」,不承载任何身份/权限信息。
|
||||
ownerLane := runtimeconfig.NewOwnerLaneStore(
|
||||
runtimeconfig.DefaultOwnerLanePath(docDataDir),
|
||||
logger.WithSource(a.log, "services/channelbridge/ownerlane"))
|
||||
// 静默窗口合并(M23)的落盘面:$DATA_DIR/config/coalesce.json(零新 env 键,
|
||||
// design/13 §2.1)。它只承载**调优**(窗口/绝对上限/单批上限/给不给陌生人),
|
||||
// **不承载身份**,也**翻不开**主人私聊的 direct 车道(那是 actor 元数据决定的类型)。
|
||||
coalesceStore := runtimeconfig.NewCoalesceStore(
|
||||
runtimeconfig.DefaultCoalescePath(docDataDir),
|
||||
logger.WithSource(a.log, "services/channelbridge/coalesce"))
|
||||
bridge, berr := channelbridge.New(channelbridge.Options{
|
||||
Log: logger.WithSource(a.log, "services/channelbridge"),
|
||||
Loop: a.loop,
|
||||
@@ -120,8 +158,38 @@ func (a *app) wireChannels() error {
|
||||
PersonaPrompt: a.defaultInstructions(),
|
||||
Engage: channelbridge.DefaultEngageConfig(),
|
||||
// EngageFor 留空(P2a);按群覆盖随 P2c 到来。
|
||||
// OwnerLaneFor / OwnerLaneSet 是 owner 车道结构化旋钮(design/18 §11「乙 = C」)
|
||||
// 的读取/落盘接缝:读接口按 (platform, groupID) 返回该群生效策略(缺省 = 不对她说
|
||||
// 就不开口),写接口把「少说点」这类结构化诉求落进 owner_lane.json,也是面板后续
|
||||
// 写入的那一份。它只影响说不说/多频繁,绝不触碰权限与 actor 元数据。
|
||||
OwnerLaneFor: func(platform, groupID string) *channelbridge.OwnerLanePolicy {
|
||||
p := ownerLane.PolicyFor(platform, groupID)
|
||||
return &channelbridge.OwnerLanePolicy{
|
||||
ReplyToUnaddressed: p.ReplyToUnaddressed,
|
||||
MinReplyGapSec: p.MinReplyGapSec,
|
||||
}
|
||||
},
|
||||
OwnerLaneSet: func(platform, groupID string, p channelbridge.OwnerLanePolicy) error {
|
||||
return ownerLane.Set(platform, groupID, runtimeconfig.OwnerLanePolicy{
|
||||
ReplyToUnaddressed: p.ReplyToUnaddressed,
|
||||
MinReplyGapSec: p.MinReplyGapSec,
|
||||
})
|
||||
},
|
||||
// Coalesce 是**静默窗口合并**(M23)的注入面:按车道键取生效窗口参数。
|
||||
// 车道类型不在这里决定 —— 主人私聊恒为 direct 车道(无窗口),由 actor 元数据
|
||||
// 判定,配置无权翻转;这里只给 batch 车道(群聊/陌生人私聊)的窗口取值。
|
||||
Coalesce: channelbridge.CoalesceOptions{
|
||||
For: func(laneKey string) *runtimeconfig.CoalescePolicy {
|
||||
p := coalesceStore.PolicyFor(laneKey)
|
||||
return &p
|
||||
},
|
||||
},
|
||||
// Factory 让面板保存时热替换某平台的适配器而无需重启(design/04 §3.1)。
|
||||
Factory: factory,
|
||||
// Channels 让热重载把新适配器写回能力注册表:管理面板(渠道卡片、账号在线
|
||||
// 状态、open/close 的目标解析)读的就是注册表,不同步它就只能看到旧实例,
|
||||
// 于是卡片永远「未连接」、点「连接」驱动的是旧 URL 的那把。
|
||||
Channels: channelCaps{caps: a.caps},
|
||||
// ChannelPersona 从工厂构建所用的同一份配置里解析账号的渠道级角色
|
||||
// (design/22 §2 方案 b);bridge 按已连接的适配器缓存它,并在每次重载时刷新。
|
||||
ChannelPersona: factory.Persona,
|
||||
@@ -155,6 +223,7 @@ func (a *app) wireChannels() error {
|
||||
fallback: factory.fallback,
|
||||
bridge: bridge,
|
||||
owner: a.owner,
|
||||
coalesce: coalesceStore,
|
||||
log: a.log,
|
||||
}
|
||||
// 启动即灌一次每渠道管理员白名单(裁决 06.4.2):不必等面板打开或热重载,
|
||||
@@ -175,11 +244,55 @@ type runtimeChannels struct {
|
||||
// owner 是渠道 bridge 判 actor 用的**同一个**活档案指针:读配置时把每渠道管理员
|
||||
// 白名单灌进它的 adminIDs 层(裁决 06.4.2),面板保存后无需重启即生效。
|
||||
owner *user.OwnerProfile
|
||||
log *slog.Logger
|
||||
// coalesce 是静默窗口合并(M23)的**同一个** store 实例:bridge 每次发车前从它
|
||||
// 读生效值,因此面板保存后下一次发车即生效(热生效),无需重启。
|
||||
coalesce *runtimeconfig.CoalesceStore
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
func (r *runtimeChannels) Path() string { return r.path }
|
||||
|
||||
// CoalescePath 返回静默窗口配置文件(coalesce.json)的绝对落点,供面板如实回显。
|
||||
func (r *runtimeChannels) CoalescePath() string {
|
||||
if r.coalesce == nil {
|
||||
return ""
|
||||
}
|
||||
return r.coalesce.Path()
|
||||
}
|
||||
|
||||
// CoalescePresent 报告静默窗口配置文件是否已存在(面板据此显示「还没配置过」)。
|
||||
func (r *runtimeChannels) CoalescePresent() bool {
|
||||
if r.coalesce == nil {
|
||||
return false
|
||||
}
|
||||
return r.coalesce.Present()
|
||||
}
|
||||
|
||||
// CoalesceSnapshot 返回盘上那份静默窗口配置(未 clamp 的原值),供面板回显。
|
||||
func (r *runtimeChannels) CoalesceSnapshot() runtimeconfig.CoalesceFile {
|
||||
if r.coalesce == nil {
|
||||
return runtimeconfig.CoalesceFile{}
|
||||
}
|
||||
return r.coalesce.Snapshot()
|
||||
}
|
||||
|
||||
// ApplyCoalesce 应用一份三态补丁:内存与落盘在同一个临界区,桥下一次发车即读到新值
|
||||
// (store 与 bridge 是**同一个**实例,因此不需要任何重启或重载)。
|
||||
func (r *runtimeChannels) ApplyCoalesce(patch runtimeconfig.CoalescePatch) (bool, error) {
|
||||
if r.coalesce == nil {
|
||||
return false, errors.New("channel coalesce config store not wired")
|
||||
}
|
||||
wrote, err := r.coalesce.Apply(patch)
|
||||
if err != nil {
|
||||
return wrote, fmt.Errorf("apply coalesce patch: %w", err)
|
||||
}
|
||||
if wrote {
|
||||
r.log.Info("channel coalesce config saved (hot: the bridge reads it on the next departure)",
|
||||
"path", r.CoalescePath())
|
||||
}
|
||||
return wrote, nil
|
||||
}
|
||||
|
||||
func (r *runtimeChannels) Load() map[string][]config.ChannelAccount {
|
||||
accounts := r.loadAccounts()
|
||||
r.applyAdminIDs(accounts)
|
||||
|
||||
@@ -126,7 +126,11 @@ func (a *app) wireFrontend() error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("gateway: %w", err)
|
||||
}
|
||||
gw.SetVoice(a.voice)
|
||||
// 语音面只在这**一处**注入:emotionTTS 已按面板配置启用时是「emotionTTS +
|
||||
// 既有链」的门面(失败回落既有链),否则就是既有链本身(行为逐字不变)。
|
||||
// 两条既有出站口(POST /api/v1/audio/speech 与 WS voice_output)都读这一份,
|
||||
// 因此不新建端点、不加帧族(切片 M35 红线)。
|
||||
gw.SetVoice(a.voiceForGateway())
|
||||
a.gw = gw // 必须在下面接线 hook 之前赋值——那些 hook 捕获了 a.gw / gw(否则会 nil 接收者 panic)
|
||||
// 可观测性 trace 出口(design/19 §2):网关已存在,其环形缓冲必须由循环产出的
|
||||
// span 喂养,否则管理端 trace/metrics 接口会一直是空的。
|
||||
@@ -165,6 +169,12 @@ func (a *app) wireFrontend() error {
|
||||
// backthink.New 时网关还没组装。
|
||||
a.backthink.SetAuditSink(gw)
|
||||
}
|
||||
// 子代理结果回填(M32;design/05 §2/§5/§9):网关 **已存在**(回填的实时出口就是
|
||||
// 它的 `message_persisted` 扇出)之后才能接线。它订阅 `subagent.result`,把后台
|
||||
// 批次的结论写成父会话的一条 **system 注记**(不冒充 user/assistant、不带 turn_id、
|
||||
// 绝不进记忆抽取),因此「她拍了个子代理出去,然后没有下文」这件事在历史与下一轮
|
||||
// 上下文里都有落点。装配细节与降级口径见 wire_subagent_backfill.go。
|
||||
wireSubagentBackfill(a, gw)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -203,6 +213,13 @@ func wirePanelHub(a *app) {
|
||||
})
|
||||
a.plugins.AttachPanels(hub)
|
||||
declareBuiltinPanels(hub, log)
|
||||
// 「MCP 服务器」页(切片 M20):声明 + 动作处理器成对装配,且必须在中枢接线之后
|
||||
// (AttachMCPPanel 经插件管理器取中枢,未接线时会响亮报错而不是静默不挂)。
|
||||
// 它归属插件管理器自己的领域数据(Statuses/Install/SetEnabled/Uninstall),因此
|
||||
// 与 web-search 的纯声明不同:这里一处调用同时完成声明与动作登记。
|
||||
if err := a.plugins.AttachMCPPanel(); err != nil {
|
||||
log.Warn("mcp server panel unavailable; its actions will answer 501", "error", err)
|
||||
}
|
||||
// browser 插件(design/28 §8 B4)的面板声明 + 六个动作:它自己就把「先声明、后登记
|
||||
// 动作」这一步走完(wire_browser_panel.go 一处),因此与 search 的纯声明并列。
|
||||
//
|
||||
|
||||
+59
-156
@@ -2,13 +2,14 @@ package main
|
||||
|
||||
// 本文件装配 io 工具集(plugins/tools/io)的选项:知识检索器、日志、
|
||||
// **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 里:那条装配线本就接近文件行数上限,
|
||||
// 而这里新增的裁决(端点/provider 关闭 ⇒ 工具不进注册面;选定 provider 未登记/未配置
|
||||
// ⇒ 启动报错;面板配置优先、缺失由 `.env` 播种)需要一个能单测的落点。
|
||||
//
|
||||
// 五条口径:
|
||||
// 六条口径:
|
||||
//
|
||||
// 1. **后端由配置选定**:主 provider 选谁(`WEB_SEARCH_PROVIDER` / 面板文件),降级链
|
||||
// 怎么走,各家的 key 与基地址从哪来(design/27 §3)。
|
||||
@@ -19,10 +20,14 @@ package main
|
||||
// 3. **拿不到可用后端就不注册工具**(主人 2026-10-05 报障的核心诉求):端点显式关闭
|
||||
// (空串 / 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 工具集在
|
||||
// **启动期报错**,绝不静默回落到兜底。
|
||||
// 5. **超时不另造默认值**:`WEB_SEARCH_TIMEOUT` / 面板的 `timeout` 未声明时沿用 io 包既有
|
||||
// 6. **超时不另造默认值**:`WEB_SEARCH_TIMEOUT` / 面板的 `timeout` 未声明时沿用 io 包既有
|
||||
// 超时;负值是笔误,按未配置处理并记一条带键名的 WARN(不静默)。
|
||||
//
|
||||
// 同一套解析也服务**热重载**(切片 W3,裁决 2026-10-06 #1):面板保存后由
|
||||
@@ -91,34 +96,14 @@ func (a *app) ioToolOptions() []iotools.Option {
|
||||
opts := []iotools.Option{
|
||||
iotools.WithKnowledgeRetriever(a.retriever),
|
||||
iotools.WithLogger(a.log),
|
||||
// B5 的降级档开关(当前 = **关**,理由见 bodyFallbackOption 的文档)。
|
||||
// B5 的挂载点:有可用浏览器就挂上浏览器渲染取正文,没有就不挂(口径与实现
|
||||
// 都在 wire_body_fallback.go)。挂载条件必须在**浏览器装配之后**求值 ——
|
||||
// 这就是 wireRuntime 里 `a.wireBrowser()` 排在 io 工具集之前的原因。
|
||||
a.bodyFallbackOption(),
|
||||
}
|
||||
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 工具(面板保存后的热重载用它
|
||||
// 重建工具,见 websearch_reload.go)。
|
||||
//
|
||||
@@ -152,7 +137,8 @@ func (a *app) buildWebSearchTool(cause string) (sdk.Tool, error) {
|
||||
// 端点/后端「关闭 / 非法 / 可用」三种去处都在这里定死(ResolveProvider/ResolveEndpoint)。
|
||||
func (a *app) webSearchOptions(cause string) []iotools.Option {
|
||||
log := logger.WithSource(a.log, ioLogSource)
|
||||
ws, source := a.effectiveWebSearch(log)
|
||||
eff, source := a.effectiveWebSearch(log)
|
||||
ws := eff.Scalar
|
||||
|
||||
// ① 后端显式关闭(主 provider = 空 / off / none)⇒ 整件工具不注册。
|
||||
if decision := ws.ResolveProvider(); decision.Disabled {
|
||||
@@ -164,7 +150,7 @@ func (a *app) webSearchOptions(cause string) []iotools.Option {
|
||||
return []iotools.Option{iotools.WithoutSearch()}
|
||||
}
|
||||
|
||||
specs := searchProviderSpecs(ws)
|
||||
specs := searchProviderSpecs(ws, eff.Providers)
|
||||
|
||||
// ② searxng 的可用性 = 端点裁决(WS1-A 的既有语义):端点显式关闭 / 不是 http(s)
|
||||
// 绝对地址时**整件工具不注册** —— 那两种取值是「关掉搜索」的显式选择,不是配置
|
||||
@@ -210,7 +196,7 @@ func (a *app) webSearchOptions(cause string) []iotools.Option {
|
||||
// - 文件存在但非法 ⇒ 记 WARN、本次回落 `.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
|
||||
path := runtimeconfig.WebSearchConfigPath(resolveDataDir(a.cfg))
|
||||
f, found, err := runtimeconfig.LoadWebSearch(path)
|
||||
@@ -218,21 +204,30 @@ func (a *app) effectiveWebSearch(log *slog.Logger) (config.WebSearchConfig, stri
|
||||
case err != nil:
|
||||
log.Warn("web-search 面板配置非法,本次按 .env 运行(不覆盖原文件)",
|
||||
"path", path, "error", err)
|
||||
return env, webSearchSourceEnv
|
||||
return envEffectiveWebSearch(env), webSearchSourceEnv
|
||||
case !found:
|
||||
return a.seedWebSearchConfig(log, path, env), webSearchSourceEnv
|
||||
return envEffectiveWebSearch(a.seedWebSearchConfig(log, path, env)), webSearchSourceEnv
|
||||
}
|
||||
warnUnknownSearchProviders(log, path, f)
|
||||
eff, convErr := effectiveSearchConfig(env, f)
|
||||
if convErr != nil {
|
||||
log.Warn("web-search 面板配置无法生效,本次按 .env 运行",
|
||||
"path", path, "error", convErr)
|
||||
return env, webSearchSourceEnv
|
||||
return envEffectiveWebSearch(env), webSearchSourceEnv
|
||||
}
|
||||
log.Info("web-search 面板配置生效", "path", path, "providers", len(f.Providers))
|
||||
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` 只作引导面)。
|
||||
//
|
||||
// 播种失败**不阻断启动**:本次按 `.env` 运行,只记一条 WARN(与 models.json 的处置一致)。
|
||||
@@ -251,15 +246,14 @@ func (a *app) seedWebSearchConfig(log *slog.Logger, path string, env config.WebS
|
||||
//
|
||||
// **本期内置的家全列**(含还没配 key 的):面板的用途就是「一次把各家配齐」,
|
||||
// 让用户先看到完整清单、再逐条填,比只列已配置的家少一轮「名字得自己知道」。
|
||||
//
|
||||
// 「全列」的名单来自 `.env` 的字段表(`envSearchProviders`)—— 那是**播种层**的输入,
|
||||
// 因此这里的条数与被播种的键集逐字保持现状(`.env` 冻结:不加、不改、不删)。
|
||||
func seedWebSearchFile(ws config.WebSearchConfig) runtimeconfig.WebSearchFile {
|
||||
enabled := runtimeconfig.EnableFlag(true)
|
||||
entries := make([]runtimeconfig.ProviderEntry, 0, len(searchProviderVector(ws))+1)
|
||||
// searxng 的基地址 = `WEB_SEARCH_ENDPOINT`。关闭哨兵(空 / off / none)播成空串:
|
||||
// 两者语义相同(都表示不出这家),但面板里不该显示一个不是地址的 "off"。
|
||||
entries = append(entries, runtimeconfig.ProviderEntry{
|
||||
Provider: search.NameSearXNG, BaseURL: seedEndpoint(ws.Endpoint), Enabled: enabled,
|
||||
})
|
||||
for _, p := range searchProviderVector(ws) {
|
||||
providers := envSearchProviders(ws)
|
||||
entries := make([]runtimeconfig.ProviderEntry, 0, len(providers))
|
||||
for _, p := range providers {
|
||||
entries = append(entries, runtimeconfig.ProviderEntry{
|
||||
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 / 降级链 / 各家条目都来自文件 —— 文件里没有的家就是
|
||||
// 没有(绝不从 `.env` 复活),这正是「面板可改」的意思;
|
||||
// - **凭据以文件为准**:某家的 apiKey 为空 = 这家没配(绝不拿 `.env` 的 key 顶上,
|
||||
// 否则「我在面板里删了它」会变成一句假话);
|
||||
// - **只有「没写」才回落 `.env`(= 代码默认值)**:maxResults / timeout / 云家的 baseUrl。
|
||||
// 前两个标量在「没写」与「写成空」之间无法区分,而它们的默认值本来就是引导面的一部分;
|
||||
// 云家 baseUrl 回落到厂商默认地址(它是代码默认值,不是凭据),searxng 的地址**不回落**
|
||||
// —— 在 searxng 身上「地址为空」就是「关掉这家」的开关语义,回落会让"清空地址"变成假话。
|
||||
func effectiveSearchConfig(env config.WebSearchConfig, f runtimeconfig.WebSearchFile) (config.WebSearchConfig, error) {
|
||||
// - **标量以文件为准**:主 provider / 降级链来自文件(文件里写空 / off / none = 显式关闭,
|
||||
// 与 `.env` 同一套哨兵语义);maxResults / timeout 在「没写」时沿用 `.env`(那两个标量
|
||||
// 在「没写」与「写成空」之间无法区分,而它们的默认值本来就是引导面的一部分)。
|
||||
// - **各家以文件为准**:文件里没有的家就是没有(绝不从 `.env` 复活,否则「我在面板里
|
||||
// 删了它」会变成一句假话),凭据为空 = 这家没配。逐家的名字表**不再由 `.env` 的字段表
|
||||
// 决定**,而是来自接入位表 —— 见 effectiveSearchProviders / resolveSearchProviders。
|
||||
// - **searxng 的端点是开关**:文件里那条的 baseUrl 为空 = 关掉这家,**不回落** `.env`
|
||||
// (回落会让「清空地址」变成假话)。
|
||||
func effectiveSearchConfig(env config.WebSearchConfig, f runtimeconfig.WebSearchFile) (effectiveWebSearchConfig, error) {
|
||||
out := env
|
||||
out.Provider = f.Provider
|
||||
out.Fallback = append([]string(nil), f.Fallback...)
|
||||
@@ -295,45 +289,24 @@ func effectiveSearchConfig(env config.WebSearchConfig, f runtimeconfig.WebSearch
|
||||
}
|
||||
d, err := f.TimeoutDuration()
|
||||
if err != nil {
|
||||
return env, err
|
||||
return effectiveWebSearchConfig{}, err
|
||||
}
|
||||
if d > 0 {
|
||||
out.Timeout = d
|
||||
}
|
||||
// 凭据一律清零后按条目回填:文件没给的家就是没配。
|
||||
out.ZhipuAPIKey = ""
|
||||
out.AliyunIQSAPIKey = ""
|
||||
out.BochaAPIKey = ""
|
||||
out.VolcengineAPIKey = ""
|
||||
out.BaiduQianfanAPIKey = ""
|
||||
// 逐家字段一律清零:它们承载的是 `.env` 的**播种**形状,而生效值现在由 Providers 承载
|
||||
// (见 effectiveWebSearchConfig 的注释)。清零也顺带保证了任何误用都不会把 `.env` 的
|
||||
// 旧凭据当成「生效凭据」。
|
||||
out.ZhipuAPIKey, out.ZhipuBaseURL = "", ""
|
||||
out.AliyunIQSAPIKey, out.AliyunIQSBaseURL = "", ""
|
||||
out.BochaAPIKey, out.BochaBaseURL = "", ""
|
||||
out.VolcengineAPIKey, out.VolcengineBaseURL = "", ""
|
||||
out.BaiduQianfanAPIKey, out.BaiduQianfanBaseURL = "", ""
|
||||
out.Endpoint = ""
|
||||
for _, e := range f.Providers {
|
||||
if !e.IsEnabled() {
|
||||
continue
|
||||
}
|
||||
name := strings.ToLower(strings.TrimSpace(e.Provider))
|
||||
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)
|
||||
}
|
||||
if e, ok := findProviderEntry(f, search.NameSearXNG); ok && e.IsEnabled() {
|
||||
out.Endpoint = strings.TrimSpace(e.BaseURL)
|
||||
}
|
||||
return out, nil
|
||||
return effectiveWebSearchConfig{Scalar: out, Providers: effectiveSearchProviders(f)}, nil
|
||||
}
|
||||
|
||||
// warnUnknownSearchProviders 对文件里本期没登记的家记一条 WARN。
|
||||
@@ -356,70 +329,9 @@ func selectsSearXNG(ws config.WebSearchConfig) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(ws.Provider), search.NameSearXNG)
|
||||
}
|
||||
|
||||
// searchProviderWire 是「一家云 provider 的配置投影」:名字 + 凭据 + 基地址。
|
||||
// searxng 不在这张表里(它的「基地址」就是 `WEB_SEARCH_ENDPOINT`,由 ResolveEndpoint 裁决)。
|
||||
type searchProviderWire struct {
|
||||
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)
|
||||
}
|
||||
// 各家的配置投影(searchProviderWire / searchProviderVector / searchProviderSpecs /
|
||||
// availableProviderNames)在 `websearch_providers.go` —— 那一片是本片「去 env 化」的本体
|
||||
// (按**接入位表**逐家投影,而不是按 `.env` 的字段表)。
|
||||
|
||||
// seedTimeout 把超时播成 duration 字符串:未声明 / 笔误(≤0)落成空 = 未声明。
|
||||
func seedTimeout(d time.Duration) string {
|
||||
@@ -428,12 +340,3 @@ func seedTimeout(d time.Duration) 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
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"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/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)接进
|
||||
// 组合根。三件事只在这里定:
|
||||
//
|
||||
// - **形态**:记忆管理会话(复用既有的 sess-memorytidy 装配,见
|
||||
// memory.TidySessionID)的有界后台队列,不是会话内派 subagent。它与整理
|
||||
// 心跳并列启动、在 close 里先于关库停止;
|
||||
// - **依赖面**:只给它记忆存储与 embedder —— 服务侧拿不到任何会话/历史读数,
|
||||
// - **依赖面**:只给它记忆存储、embedder,以及 **T1 的一次性模型调用口**
|
||||
// (memoryT1Judge,§15.1 D3-2 的灰区判定)。服务侧拿不到任何会话/历史读数,
|
||||
// 因此「独立会话、零上下文、不碰主会话上下文」(§15 D3)是结构性成立的,
|
||||
// 而不是靠自觉;
|
||||
// - **审计**:接到网关的 AuditEvent(口径 memory.review.ingest)。出口在调用
|
||||
@@ -23,7 +34,17 @@ import (
|
||||
// 一手法。未就绪时只记日志,绝不假装写过审计。
|
||||
//
|
||||
// 关闭(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 {
|
||||
cfg, err := config.LoadMemoryReviewConfig(envPath)
|
||||
if err != nil {
|
||||
@@ -34,6 +55,19 @@ func (a *app) wireMemoryReview() error {
|
||||
"memory ingest review disabled by configuration", "env", "MEMORY_REVIEW_ENABLED")
|
||||
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{
|
||||
Enabled: cfg.Enabled,
|
||||
QueueSize: cfg.QueueSize,
|
||||
@@ -42,11 +76,28 @@ func (a *app) wireMemoryReview() error {
|
||||
// 召回门复用记忆整理的既有阈值(design/23 §13 开放点 2 = 0.80):
|
||||
// 入库前审查与夜间整理必须用同一把尺子,否则同一对条目会有两个结论。
|
||||
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{
|
||||
Store: a.memStore,
|
||||
Embedder: a.embedder,
|
||||
Audit: a.auditMemoryReview,
|
||||
Log: a.log,
|
||||
Judge: a.memoryT1Judge(),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("memory ingest review: %w", err)
|
||||
@@ -56,6 +107,112 @@ func (a *app) wireMemoryReview() error {
|
||||
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**,
|
||||
// 绝不带任何记忆正文(design §15 D3-8「记忆正文不进日志」)。
|
||||
func (a *app) auditMemoryReview(who, what, result string) {
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"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/history"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// 本文件锁定 D3「入库前审查」(design/23 §15.1 D3)在**组合根**上的三件事:
|
||||
@@ -54,6 +60,27 @@ func TestMemoryReviewIsWired(t *testing.T) {
|
||||
"Audit: a.auditMemoryReview",
|
||||
// 接到 store 的唯一写入路径上。
|
||||
"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": {
|
||||
"a.memoryReview.Start(ctx)",
|
||||
@@ -75,16 +102,194 @@ func TestMemoryReviewIsWired(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// store 侧的唯一写入路径必须投递审查(否则新记忆永远不进队列)。
|
||||
// store 侧的唯一写入路径必须投递审查(否则新记忆永远不进队列),
|
||||
// 且必须**先过同步入库闸**(2026-10-07:闸就摆在写路径上,重复当场落 merged)。
|
||||
storeSrc, err := os.ReadFile("../../internal/runtime/memory/store.go")
|
||||
if err != nil {
|
||||
t.Fatalf("read memory/store.go: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(storeSrc), "s.enqueueReview(item)") {
|
||||
t.Fatal("memory/store.go 的写入路径上没有投递审查(写路径唯一)")
|
||||
for _, want := range []string{"s.enqueueReview(item)", "s.planIngest(ctx, item)", "s.finishIngest(ctx, item, plan)"} {
|
||||
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 与整理同一条口径:审查会话不落在
|
||||
// 主人的对话车道("web" / "web:<uuid>")上,因此不改她的心情、不进主会话泳道。
|
||||
func TestMemoryReviewSessionIsNotConversationLane(t *testing.T) {
|
||||
|
||||
@@ -3,28 +3,49 @@ package main
|
||||
import (
|
||||
"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/runtime/memory"
|
||||
)
|
||||
|
||||
// 本文件把记忆整理(DevDocs/design/23-记忆整理与去重 §4)接进组合根。
|
||||
//
|
||||
// 三件事只在这里定:
|
||||
// - **形态**:低峰定期后台任务(memory.TidyService 的心跳),不是会话内派
|
||||
// subagent。它在 main.go 与 backthink 并列启动、在 close 里先于关库停止;
|
||||
// 六件事只在这里定:
|
||||
// - **形态**:定期后台任务(安静时段降频)的心跳(memory.TidyService),不是
|
||||
// 会话内派 subagent。它在 main.go 与 backthink 并列启动、在 close 里先于关库停止;
|
||||
// - **依赖面**:只给它记忆存储与 embedder —— 服务侧拿不到任何会话/历史读数,
|
||||
// 因此 design §4.1「绝不碰主会话上下文」是结构性成立的,而不是靠自觉;
|
||||
// - **审计**:接到网关的 AuditEvent(design §8 的 memory.tidy.*),出口在调用
|
||||
// 时才解析 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 的图源白名单(默认关:整理是系统任务,
|
||||
// 不是主人的会话);
|
||||
// - 不给它任何写记忆的路径(第一版只产候选,"立即整理"入口是
|
||||
// TidyService.RunOnce)。
|
||||
// 不是主人的会话)。
|
||||
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,
|
||||
Embedder: a.embedder,
|
||||
Audit: a.auditMemoryTidy,
|
||||
@@ -33,10 +54,90 @@ func (a *app) wireMemoryTidy() error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("memory tidy: %w", err)
|
||||
}
|
||||
// 到期清扫(design/23 §19 的第 ② / ③ 步)**不在组合根挂**:它起在
|
||||
// TidyService.Start 里,与整理心跳共用同一个 ctx 与 wg(见 expire_loop.go)。
|
||||
// 因此这里零改动即完成接线 —— 不新起一条关闭链、不新开监听端口、不加任何
|
||||
// env 键,`wire_services.go` 的 a.memoryTidy.Stop 会连同它一起停掉。
|
||||
a.memoryTidy = svc
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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 把一轮整理的计数写进网关审计链。它**只带计数**,绝不带
|
||||
// 任何记忆正文(design §8 的 who/what/when/result 口径)。
|
||||
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",
|
||||
},
|
||||
"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",
|
||||
@@ -60,6 +69,18 @@ func TestMemoryTidyIsWired(t *testing.T) {
|
||||
"main.go": {
|
||||
"a.memoryTidy.Start(ctx)",
|
||||
},
|
||||
// 到期清扫(切片 M3,design/23 §19 的第 ② / ③ 步):临时记忆到期后要
|
||||
// **真删掉**,而它起在 TidyService.Start 里(与心跳同一个 cctx / wg)。
|
||||
// 少了这一句,过期行只会被读侧时效闸「挡在门外」而**永远不删** ——
|
||||
// 不报错、不红任何别的用例,只是库里的临时层无界留存。因此这里把它
|
||||
// 钉成 seam:`TestTidyServiceStart_清扫随服务起停` 是它的运行时对照。
|
||||
"../../internal/runtime/memory/tidy_service.go": {
|
||||
"go s.expirySweepLoop(cctx)",
|
||||
},
|
||||
"../../internal/runtime/memory/expire_loop.go": {
|
||||
"func (s *TidyService) expirySweepLoop(ctx context.Context)",
|
||||
"DefaultExpireSweepInterval",
|
||||
},
|
||||
"wire_services.go": {
|
||||
// 关库之前先停整理(它每轮要用连接池)。
|
||||
"a.memoryTidy.Stop(ctx)",
|
||||
@@ -206,3 +227,113 @@ func readConversationFingerprint(t *testing.T, ctx context.Context, conn *sql.DB
|
||||
}
|
||||
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/bus"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/paneldecl"
|
||||
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
@@ -99,23 +100,45 @@ func TestWirePanelHubDeclaresBuiltinSearchPanel(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(entries) != 1 || entries[0].PluginID != search.PanelPluginID ||
|
||||
entries[0].Panel.ID != search.PanelIDProviders {
|
||||
t.Fatalf("内置面板 = %+v,期望 web-search/providers 一条", entries)
|
||||
// 按 pluginID 定位,而不是数「恰好一条」:内置提供者会越来越多(本片新增
|
||||
// mcp/servers 管理页),数总数的断言每加一页就假红一次,而它真正想守的是
|
||||
// 「web-search/providers 在目录里,且形状仍是声明的那一版」。
|
||||
entry, ok := findPanelEntry(entries, search.PanelPluginID, search.PanelIDProviders)
|
||||
if !ok {
|
||||
t.Fatalf("内置面板里没有 web-search/providers:%+v", entries)
|
||||
}
|
||||
// 形状也钉住:一个 form(写面)+ 一个 table(读面)。
|
||||
if len(entry.Panel.Sections) != 2 || entry.Panel.Sections[0].Type != sdk.SectionForm ||
|
||||
entry.Panel.Sections[1].Type != sdk.SectionTable {
|
||||
t.Fatalf("web-search/providers 的区块形状 = %+v,期望 form+table", entry.Panel.Sections)
|
||||
}
|
||||
for _, name := range search.ActionNames() {
|
||||
if _, ok := hub.Action(search.PanelPluginID, name); !ok {
|
||||
t.Fatalf("内置插件声明的动作 %q 未进白名单", name)
|
||||
}
|
||||
}
|
||||
// viewer 拿到的必须是裁掉 form 的只读投影。
|
||||
// viewer 拿到的必须是裁掉 form 的只读投影(只剩那个表格区块)。
|
||||
viewer, err := hub.List(auth.RoleViewer)
|
||||
if err != nil {
|
||||
t.Fatalf("List(viewer): %v", err)
|
||||
}
|
||||
if len(viewer) != 1 || len(viewer[0].Panel.Sections) != 1 {
|
||||
t.Fatalf("viewer 的只读投影 = %+v,期望只剩表格区块", viewer)
|
||||
viewerEntry, ok := findPanelEntry(viewer, search.PanelPluginID, search.PanelIDProviders)
|
||||
if !ok {
|
||||
t.Fatalf("viewer 快照里没有 web-search/providers:%+v", viewer)
|
||||
}
|
||||
if len(viewerEntry.Panel.Sections) != 1 || viewerEntry.Panel.Sections[0].Type != sdk.SectionTable {
|
||||
t.Fatalf("viewer 的只读投影 = %+v,期望只剩表格区块", viewerEntry.Panel.Sections)
|
||||
}
|
||||
}
|
||||
|
||||
// findPanelEntry 在目录快照里按 (pluginId, panelId) 定位那一条。
|
||||
func findPanelEntry(entries []paneldecl.Entry, pluginID, panelID string) (paneldecl.Entry, bool) {
|
||||
for _, e := range entries {
|
||||
if e.PluginID == pluginID && e.Panel.ID == panelID {
|
||||
return e, true
|
||||
}
|
||||
}
|
||||
return paneldecl.Entry{}, false
|
||||
}
|
||||
|
||||
// TestWirePanelHubNilPluginsIsNoop 断言未构造插件管理器时接线是空操作(不 panic)。
|
||||
|
||||
@@ -11,7 +11,7 @@ package main
|
||||
// - `websearch_action_data.go`:动作本体的业务数据面(可变源的读写与翻译、载荷解析);
|
||||
// - `websearch_probe.go`:探针的传输层绑定(HTTP 客户端 + 同主机守卫 + 状态映射);
|
||||
// - `websearch_reload.go`:保存后的**热重载**事务(重建工具 + 换注册表条目 + 失败回滚);
|
||||
// - `websearch_selection.go`:表单里两个文件级字段(主 provider / 降级链)的读写。
|
||||
// - `websearch_selection.go`:表单里文件级字段(主 provider / 降级链 / 条数上限)的读写。
|
||||
//
|
||||
// 三条边界:
|
||||
//
|
||||
@@ -152,8 +152,8 @@ type webSearchActionEnv struct {
|
||||
log *slog.Logger
|
||||
// path 是可变源落点 `$DATA_DIR/config/plugins/web-search.json`。
|
||||
path string
|
||||
// envCfg 是 `.env` 解析结果(**只用来取代码默认值**:各云家的默认基地址与超时)。
|
||||
// 把面板没写的字段落回代码默认值,与工具运行时的 `orDefault` 口径一致。
|
||||
// envCfg 是 `.env` 解析结果(**只用来取标量默认值**:探针超时;各家的默认基地址改由
|
||||
// 接入位表给出,见 resolveBaseURL)。
|
||||
envCfg config.WebSearchConfig
|
||||
}
|
||||
|
||||
|
||||
@@ -393,6 +393,9 @@ func TestWebSearchActionsRejectUnknownProviderAndBadPayload(t *testing.T) {
|
||||
|
||||
// TestWebSearchOptionsActionFeedsDeclaredOptionsFrom 锁死「声明的下拉一定拉得到候选」:
|
||||
// 声明里每个 optionsFrom 映射出的动作名都**已登记且已声明**(否则前端只能降级成文本框)。
|
||||
//
|
||||
// 候选的形状是 `{value,label}`(切片 LBL-1):这里连**显示名非空**一起钉住 —— 只有值的
|
||||
// 候选会让面板下拉与表格里全是英文 id(主人 2026-10-06 报障的那个形状)。
|
||||
func TestWebSearchOptionsActionFeedsDeclaredOptionsFrom(t *testing.T) {
|
||||
env := newActionTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
|
||||
|
||||
@@ -413,16 +416,31 @@ func TestWebSearchOptionsActionFeedsDeclaredOptionsFrom(t *testing.T) {
|
||||
t.Errorf("候选来源 %q 调不动: %v", src, err)
|
||||
continue
|
||||
}
|
||||
options, _ := out.(map[string]any)["options"].([]string)
|
||||
options, _ := out.(map[string]any)["options"].([]search.ProviderOption)
|
||||
if len(options) == 0 {
|
||||
t.Errorf("候选来源 %q 回了空候选(表单下拉会空着)", src)
|
||||
}
|
||||
if strings.Join(options, ",") != strings.Join(search.ProviderOptions(), ",") {
|
||||
t.Errorf("候选 = %v, want %v", options, search.ProviderOptions())
|
||||
got, want := optionValues(options), optionValues(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 锁定降级口径:**没有配置**(测试夹具/装配未完成)
|
||||
// 时不登记任何处理器 ⇒ 已声明的动作诚实回 501(ErrActionUnavailable),
|
||||
// 绝不假装成功地回一份空列表。
|
||||
|
||||
@@ -164,8 +164,19 @@ func (a *app) wireRuntime() error {
|
||||
if err := basic.Register(a.caps); err != nil {
|
||||
return fmt.Errorf("register basic tools: %w", err)
|
||||
}
|
||||
// io 工具集的选项(含 web-search 的端点与超时裁决)在 wire_io_tools.go 一处组装:
|
||||
// 端点不可用时它交出 WithoutSearch(),使 web-search **不进注册面**(WS1-A)。
|
||||
// 浏览器工具(design/28 B1 接线,切片 V5;挂点由 X3 归位到这里):
|
||||
// 与 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()...)
|
||||
if ioErr != nil {
|
||||
return fmt.Errorf("build io tools: %w", ioErr)
|
||||
@@ -177,11 +188,6 @@ func (a *app) wireRuntime() error {
|
||||
}
|
||||
a.wireVision()
|
||||
a.wireTranscode()
|
||||
a.wireBrowser()
|
||||
// 浏览器工具(design/28 B1 接线,切片 V5;挂点由 X3 归位到这里):
|
||||
// 与 vision / transcode 同族 —— 可选增强,不可用就**不注册**(模型看不见这个
|
||||
// 工具,而不是看得见、一调就报错),失败不阻塞启动;装配细节与六条口径都在
|
||||
// wire_browser.go 一处。它的关闭挂点在 a.close(wire_services.go),成对。
|
||||
|
||||
// 上下文溢出压缩:预算按模型窗口推导(CONTEXT_MAX_TOKENS 未设置时;
|
||||
// 显式 >0 = 覆盖、0/负 = 关闭),推导与「窗口未声明」的 WARN 都在
|
||||
@@ -318,7 +324,7 @@ func (a *app) wireRuntime() error {
|
||||
OnTurnEnd: a.onTurnEnd,
|
||||
Memory: store,
|
||||
Retriever: a.retriever,
|
||||
Extractor: a.buildMemoryExtractor(),
|
||||
Extractor: loopMemoryExtractor(a.buildMemoryExtractor()),
|
||||
Checkpoint: checkpoint,
|
||||
Tracer: tracer,
|
||||
Flow: flowPub,
|
||||
|
||||
@@ -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 {
|
||||
t.Fatalf("播种的主 provider = %q, want %q", f.Provider, search.NameBocha)
|
||||
}
|
||||
if len(f.Providers) != len(search.KnownNames()) {
|
||||
t.Fatalf("播种必须列全本期内置的家:%d 条, want %d", len(f.Providers), len(search.KnownNames()))
|
||||
// 播种内容 = `.env` 字段表那张表(searxng + 5 家云家)。**没有 env 字段的家**
|
||||
// (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
|
||||
for _, e := range f.Providers {
|
||||
|
||||
@@ -112,7 +112,10 @@ func TestWireIOTools_AvailableListExcludesUnconfigured(t *testing.T) {
|
||||
if !strings.Contains(out, search.NameBocha) {
|
||||
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) {
|
||||
t.Fatalf("缺 key 的 %s 不得出现在可用清单里: %q", absent, out)
|
||||
}
|
||||
@@ -170,16 +173,17 @@ func TestWireIOTools_SelectedCloudProviderIsUsed(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestWireIOTools_ProviderVectorSummary 是一句「本期内置哪几家」的防回归:
|
||||
// 六家的名字都必须被 search 包登记(新增一家别忘了加文件 + 注册表一行)。
|
||||
// 八家的名字都必须被 search 包登记(新增一家别忘了加文件 + 注册表一行)。
|
||||
func TestWireIOTools_ProviderVectorSummary(t *testing.T) {
|
||||
t.Parallel()
|
||||
want := map[string]bool{
|
||||
search.NameSearXNG: true, search.NameZhipu: true, search.NameAliyunIQS: true,
|
||||
search.NameBocha: true, search.NameVolcengine: true, search.NameBaiduQianfan: true,
|
||||
search.NameTencentWSA: true, search.NameMetaso: true,
|
||||
}
|
||||
got := search.KnownNames()
|
||||
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))
|
||||
}
|
||||
for _, n := range got {
|
||||
|
||||
@@ -2,14 +2,27 @@
|
||||
// the Go package can //go:embed it into the single Cyrene binary. It runs as the
|
||||
// last step of `npm run build`. The source dist is the canonical build output,
|
||||
// so it also serves as the external deploy-override directory.
|
||||
import { cp, rm } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
//
|
||||
// The destination is wiped first so assets from an older build cannot linger in
|
||||
// the binary. internal/webui/dist/.gitkeep is committed because //go:embed
|
||||
// all:dist fails to compile when the directory holds no files at all — that is
|
||||
// what makes `go build ./...` work in a clean checkout. The wipe may clear build
|
||||
// output but never the placeholder, so it is restored on every run, including a
|
||||
// failed copy: a bare placeholder still compiles, a missing one breaks the build.
|
||||
import { cp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const src = resolve(here, "dist");
|
||||
const dest = resolve(here, "..", "..", "internal", "webui", "dist");
|
||||
const placeholder = ".gitkeep";
|
||||
|
||||
await rm(dest, { recursive: true, force: true });
|
||||
await cp(src, dest, { recursive: true });
|
||||
try {
|
||||
await cp(src, dest, { recursive: true });
|
||||
} finally {
|
||||
await mkdir(dest, { recursive: true });
|
||||
await writeFile(join(dest, placeholder), "");
|
||||
}
|
||||
console.log(`webui: copied ${src} -> ${dest}`);
|
||||
@@ -8,6 +8,8 @@ import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components"
|
||||
import { Button, Surface, Tag } from "../../components/primitives";
|
||||
import { MemoryTidyFactsCard } from "./ConfigFactsCards";
|
||||
import { MemoryTidyDryRunCard } from "./MemoryTidyDryRunCard";
|
||||
import { MemoryTidyApplyCard } from "./MemoryTidyApplyCard";
|
||||
import { MemoryTidyTuningCard } from "./MemoryTidyTuningCard";
|
||||
|
||||
const scopeTone: Record<string, "ok" | "warn" | "accent" | "neutral"> = {
|
||||
user: "accent",
|
||||
@@ -188,6 +190,10 @@ export function MemoriesModule(): React.ReactNode {
|
||||
|
||||
<MemoryTidyDryRunCard />
|
||||
|
||||
<MemoryTidyApplyCard />
|
||||
|
||||
<MemoryTidyTuningCard />
|
||||
|
||||
<MemoryTidyFactsCard />
|
||||
|
||||
<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,
|
||||
TIDY_DRY_RUN_DISCLAIMER,
|
||||
TIDY_DRY_RUN_ERROR_HINT,
|
||||
TIDY_RECALL_UNAVAILABLE,
|
||||
TIDY_RECALL_UNAVAILABLE_HINT,
|
||||
type TidyDryRunState,
|
||||
} from "./MemoryTidyDryRunCard";
|
||||
|
||||
@@ -96,7 +98,7 @@ ok(has(doneHtml, "0.950"), "必须显示分数(区间)");
|
||||
ok(has(doneHtml, "mem-short") && has(doneHtml, "保留 mem-long"), "必须显示「哪条 → 保留哪条」");
|
||||
ok(has(doneHtml, "作息偏晚"), "必须显示合并后的存活条目原文(diff 预览)");
|
||||
ok(has(doneHtml, "扫描 736") && has(doneHtml, "用时 42 ms"), "必须显示扫描口径与耗时");
|
||||
ok(has(doneHtml, "召回来源:embedding"), "必须显示召回来源(lexical 是降级信号)");
|
||||
ok(has(doneHtml, "召回来源:语义向量(embedding)"), "必须显示召回来源(lexical 是降级信号)");
|
||||
ok(has(doneHtml, "scope=1"), "必须显示否决分布(它解释「为什么没有更多建议」)");
|
||||
|
||||
// ── 截断:必须说明「这一轮看到的比库里少 + 该调哪个键」 ─────────────────────
|
||||
@@ -124,6 +126,42 @@ ok(!has(errorHtml, "扫描 "), "错误态绝不得渲染观测口径(扫描/
|
||||
ok(!has(errorHtml, "这一轮没有产生任何候选"), "错误态不得复用空态文案(两者含义不同)");
|
||||
ok(has(errorHtml, "把失败画成「0 条」"), "错误态反而要解释「为什么不显示 0 条」");
|
||||
|
||||
|
||||
// ── ④ 召回来源(契约 v0.91):四种取值**各有各的话**,空串绝不是空标签 ────────
|
||||
// 起因是 M6 的诊断(DevDocs/audit/去重失效诊断-2026-10-13.md):旧实现把「全量
|
||||
// 降级」静默报成 embedding,面板据此判读会把「向量不可用」读成「向量正常」。
|
||||
// 因此这个字段的**全部价值**就是让人看得出这一轮到底走了哪条路。
|
||||
const recallCases: Array<[string, string]> = [
|
||||
["embedding", "语义向量"],
|
||||
["lexical", "已降级"],
|
||||
["mixed", "部分分区降级"],
|
||||
["", "无候选可比"],
|
||||
["unregistered_mode", "未登记取值"],
|
||||
];
|
||||
for (const [mode, want] of recallCases) {
|
||||
const html = render({ kind: "done", result: { ...RESULT, recallMode: mode } });
|
||||
ok(has(html, want), `recallMode=${JSON.stringify(mode)} 必须渲染成可读中文(含「${want}」)`);
|
||||
}
|
||||
// 空串绝不许渲染成「召回来源:」后面空白(那是本次要修的那个缺陷)。
|
||||
const noCandidateHtml = render({ kind: "done", result: { ...RESULT, recallMode: "", candidates: [], pairs: 0 } });
|
||||
ok(has(noCandidateHtml, "无候选可比"), "空串 = 本轮没做召回,必须说出来");
|
||||
ok(!has(noCandidateHtml, "召回来源:<"), "空串绝不能渲染成空标签(后面什么都没有)");
|
||||
ok(!has(noCandidateHtml, "召回来源:</"), "空串绝不能渲染成空标签(连标签体都空)");
|
||||
ok(has(noCandidateHtml, "未做召回"), "空串要解释「这一轮压根没算过相似度」,不能含糊成「—」");
|
||||
|
||||
// ── ⑤ 「尝试过召回但向量源不可用」:与上面两种都不同,走**错误**路径 ─────────
|
||||
// 试算的严格口径下这种失败不回响应体(前端只能从 error.code 认出来),因此这里
|
||||
// 必须单独给一句说明 —— 否则主人对着一句「试算失败」分不清是没候选还是向量挂了。
|
||||
const recallDownHtml = render({
|
||||
kind: "error",
|
||||
message: "语义召回(embedding)不可用,试算未产出候选(试算不做静默降级)",
|
||||
code: TIDY_RECALL_UNAVAILABLE,
|
||||
});
|
||||
ok(has(recallDownHtml, TIDY_RECALL_UNAVAILABLE_HINT), "向量源不可用时要给专门的说明");
|
||||
ok(has(recallDownHtml, "不等于"), "必须与「没有候选可比」显式区分");
|
||||
ok(has(recallDownHtml, "没有降级到词项"), "必须说清它也没有降级到词项余弦");
|
||||
ok(!has(errorHtml, TIDY_RECALL_UNAVAILABLE_HINT), "别的错误不该套用这条说明(不许乱归因)");
|
||||
|
||||
// ── 未跑 / 在跑 ────────────────────────────────────────────────────────────
|
||||
const idleHtml = render({ kind: "idle" });
|
||||
ok(has(idleHtml, "试算整理建议"), "未跑时必须给出按钮文案");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { adminMemoryTidyDryRun } from "../../lib/api";
|
||||
import { ApiError, adminMemoryTidyDryRun } from "../../lib/api";
|
||||
import type { AdminTidyDryRunCandidate, AdminTidyDryRunResult } from "../../lib/types";
|
||||
import { Button, Surface, Tag } from "../../components/primitives";
|
||||
import { Notice } from "../components";
|
||||
@@ -38,7 +38,18 @@ export type TidyDryRunState =
|
||||
| { kind: "idle" }
|
||||
| { kind: "running" }
|
||||
| { kind: "done"; result: AdminTidyDryRunResult }
|
||||
| { kind: "error"; message: string };
|
||||
| { kind: "error"; message: string; code?: string };
|
||||
|
||||
/** TIDY_RECALL_UNAVAILABLE 是「这一轮**尝试过**向量召回但向量源不可用」的错误码。
|
||||
*
|
||||
* 它与「没有候选可比」(成功响应 + `recallMode:""`)和「降级到词项」(`lexical`)
|
||||
* 是三件不同的事,必须各有各的话。试算的严格口径下这种失败**不回响应体**,因此它
|
||||
* 只走错误路径 —— 面板在这里把它说出来,而不是让主人对着一句「试算失败」猜。 */
|
||||
export const TIDY_RECALL_UNAVAILABLE = "tidy_recall_unavailable";
|
||||
|
||||
/** TIDY_RECALL_UNAVAILABLE_HINT 是上面那个错误码的补充说明。 */
|
||||
export const TIDY_RECALL_UNAVAILABLE_HINT =
|
||||
"这一轮尝试过向量召回、但向量源不可用(试算不做静默降级):它既不等于「没有候选可比」,也没有降级到词项余弦。请检查 embedding 服务后重试。";
|
||||
|
||||
const actionLabel: Record<string, string> = {
|
||||
merge: "并入",
|
||||
@@ -112,6 +123,50 @@ function CandidateRow({ candidate }: { candidate: AdminTidyDryRunCandidate }): R
|
||||
);
|
||||
}
|
||||
|
||||
/** RecallSourceTag 渲染「这一轮到底走的是向量还是词项」(契约 v0.91 的 `recallMode`)。
|
||||
*
|
||||
* 它是 M6 诊断(`DevDocs/audit/去重失效诊断-2026-10-13.md`)的落点:旧实现按「有没
|
||||
* 有配 embedder」写这个字段,于是**全量降级被静默报成 `embedding`**,面板据此判读会
|
||||
* 把「向量不可用」读成「向量正常工作」。取值域与读法(三种态**必须分得清**):
|
||||
*
|
||||
* - `embedding` —— 语义向量召回真的用上了(好态);
|
||||
* - `lexical` —— 全部走零依赖词项余弦(进程没接 embedder ⇒ 降级,命中会更少);
|
||||
* - `mixed` —— **部分分区降级**、其余走向量(v0.91 新增);
|
||||
* - `""` —— **没有候选可比**:这一轮压根没算过相似度(不是降级,也不该显示成
|
||||
* 一个空标签 —— 那会让人以为字段坏了)。
|
||||
*
|
||||
* ⚠️ 「尝试过召回但向量源不可用」**不在这里**:试算的严格口径下那种情况直接报错
|
||||
* (`error.code = tidy_recall_unavailable`),响应体根本不会出现;线上也没有
|
||||
* `recall_unavailable_partitions` 这个键(它只存在于后端日志行)。因此本组件不许
|
||||
* 用「—」把空串含糊掉 —— 每个取值都有一句可读的中文。 */
|
||||
export function recallSourceLabel(mode: string): string {
|
||||
switch (mode) {
|
||||
case "embedding":
|
||||
return "召回来源:语义向量(embedding)";
|
||||
case "lexical":
|
||||
return "召回来源:词项余弦(未接向量,已降级)";
|
||||
case "mixed":
|
||||
return "召回来源:部分分区降级(其余走向量)";
|
||||
case "":
|
||||
return "召回来源:无候选可比(本轮未做召回)";
|
||||
default:
|
||||
return `召回来源:${mode}(未登记取值)`;
|
||||
}
|
||||
}
|
||||
|
||||
/** recallSourceTone 给上面那句话配色调:好态 accent、降级 warn、无候选 neutral。 */
|
||||
export function recallSourceTone(mode: string): "accent" | "warn" | "neutral" {
|
||||
switch (mode) {
|
||||
case "embedding":
|
||||
return "accent";
|
||||
case "lexical":
|
||||
case "mixed":
|
||||
return "warn";
|
||||
default:
|
||||
return "neutral";
|
||||
}
|
||||
}
|
||||
|
||||
/** ResultBody 渲染一次试算的完整结果(有候选 / 空 / 观测口径)。 */
|
||||
function ResultBody({ result }: { result: AdminTidyDryRunResult }): React.ReactNode {
|
||||
const vetoReasons = result.vetoReasons ?? {};
|
||||
@@ -119,7 +174,7 @@ function ResultBody({ result }: { result: AdminTidyDryRunResult }): React.ReactN
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Tag tone="ok">算完了,一行都没写</Tag>
|
||||
<Tag tone={result.recallMode === "embedding" ? "accent" : "warn"}>召回来源:{result.recallMode}</Tag>
|
||||
<Tag tone={recallSourceTone(result.recallMode)}>{recallSourceLabel(result.recallMode)}</Tag>
|
||||
<span className="text-[11px] text-[var(--mu-text-lo)]">
|
||||
扫描 {result.scanned} / 比对 {result.compared} / 召回 {result.pairs} 对 / 否决 {result.vetoed} 对 · 用时{" "}
|
||||
{result.durationMs} ms · 规则 {result.ruleVersion} · 分区 {result.partitions}
|
||||
@@ -174,6 +229,9 @@ function StateBody({ state }: { state: TidyDryRunState }): React.ReactNode {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Notice tone="err">试算失败:{state.message}</Notice>
|
||||
{state.code === TIDY_RECALL_UNAVAILABLE ? (
|
||||
<span className="text-[11px] text-[var(--mu-text-mid)]">{TIDY_RECALL_UNAVAILABLE_HINT}</span>
|
||||
) : null}
|
||||
<span className="text-[11px] text-[var(--mu-text-lo)]">
|
||||
{TIDY_DRY_RUN_ERROR_HINT}。这里刻意不显示候选数 —— 把失败画成「0 条」就是把「没算出来」冒充成结论。
|
||||
</span>
|
||||
@@ -223,7 +281,11 @@ export function MemoryTidyDryRunCard(): React.ReactNode {
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
log("memory.tidy.dryRun", "error");
|
||||
setState({ kind: "error", message: e instanceof Error ? e.message : "试算失败" });
|
||||
setState({
|
||||
kind: "error",
|
||||
message: e instanceof Error ? e.message : "试算失败",
|
||||
code: e instanceof ApiError ? e.code : undefined,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
// 记忆整理「调优」卡的**真实渲染**测试(切片 MEMPANEL / M4)。
|
||||
//
|
||||
// 它钉住三件**文案与呈现**的事(逻辑那一半见 memoryTidyTuning.test.ts):
|
||||
// ① 7 个键都有中文标签 + 各自的兜底说明(「留空 ⇒ 跟随谁」);
|
||||
// ② 热生效与否**如实**:`hot:false` 必须写「重启后端后生效」,绝不写成「已生效」;
|
||||
// ③ 坏值**就地报错**并拦住保存(后端那道 400 仍在,但不让主人白跑)。
|
||||
//
|
||||
// 跑法(与 MemoryTidyDryRunCard.render.test.ts 同):
|
||||
//
|
||||
// cd frontend/web \
|
||||
// && ./node_modules/.bin/esbuild src/admin/modules/MemoryTidyTuningCard.render.test.ts \
|
||||
// --bundle --platform=node --format=cjs \
|
||||
// --outfile=/tmp/cyrene-memory-tidy-tuning.render.test.cjs \
|
||||
// && node /tmp/cyrene-memory-tidy-tuning.render.test.cjs
|
||||
//
|
||||
// `tsc --noEmit` 会对本文件做类型检查,因此它只从项目内导入、不 import node:*。
|
||||
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import type { AdminMemoryTidyTuningView } from "../../lib/api/adminMemories";
|
||||
import {
|
||||
MemoryTidyTuningBody,
|
||||
TIDY_TUNING_COLD_LABEL,
|
||||
TIDY_TUNING_DISCLAIMER,
|
||||
TIDY_TUNING_HOT_LABEL,
|
||||
} from "./MemoryTidyTuningCard";
|
||||
import { draftFromView, TUNING_LABELS } from "./memoryTidyTuning";
|
||||
|
||||
let checks = 0;
|
||||
|
||||
function ok(cond: boolean, what: string): void {
|
||||
checks += 1;
|
||||
if (!cond) throw new Error(`FAILED: ${what}`);
|
||||
}
|
||||
|
||||
function has(haystack: string, needle: string): boolean {
|
||||
return haystack.includes(needle);
|
||||
}
|
||||
|
||||
/** fixtureView 造一份 GET 响应形状的数据(含全部 7 个键的规格)。 */
|
||||
function fixtureView(overrides: Partial<AdminMemoryTidyTuningView> = {}): AdminMemoryTidyTuningView {
|
||||
return {
|
||||
path: "config/memory-tidy.json",
|
||||
present: true,
|
||||
hot: false,
|
||||
hotError: "运行期没有接线可热生效的实现(deps.MemoryTidy / deps.Memories);新值已落盘,重启后端后生效",
|
||||
specs: [
|
||||
{ key: "intervalSec", kind: "int", unit: "sec", min: 1, fallback: "env:MEMORY_TIDY_INTERVAL_SEC" },
|
||||
{ key: "quietIntervalSec", kind: "int", unit: "sec", min: 1, fallback: "panel:intervalSec" },
|
||||
{ key: "quietStartHour", kind: "int", unit: "hour", min: 0, max: 23, fallback: "env:MEMORY_TIDY_QUIET_START_HOUR" },
|
||||
{ key: "quietEndHour", kind: "int", unit: "hour", min: 0, max: 23, fallback: "env:MEMORY_TIDY_QUIET_END_HOUR" },
|
||||
{ key: "ingestGate", kind: "bool", fallback: "code-default" },
|
||||
{ key: "autoApply", kind: "bool", fallback: "code-default" },
|
||||
{ key: "tempTtlSec", kind: "int", unit: "sec", min: 1, fallback: "code-default" },
|
||||
],
|
||||
values: { intervalSec: 10800, quietStartHour: 23, quietEndHour: 7, ingestGate: true, autoApply: true },
|
||||
envValues: { intervalSec: 86400, quietStartHour: 23, quietEndHour: 7 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderBody(view: AdminMemoryTidyTuningView, draft: Record<string, string>, errors: Record<string, string> = {}, extra: Record<string, unknown> = {}): string {
|
||||
return renderToStaticMarkup(
|
||||
createElement(MemoryTidyTuningBody, {
|
||||
view,
|
||||
draft,
|
||||
errors,
|
||||
busy: false,
|
||||
receipt: null,
|
||||
savedView: null,
|
||||
onDraftChange: () => {},
|
||||
onSave: () => {},
|
||||
...extra,
|
||||
} as never)
|
||||
);
|
||||
}
|
||||
|
||||
const view = fixtureView();
|
||||
const html = renderBody(view, draftFromView(view), {}, { receipt: "已保存:平时整理间隔。", savedView: { ...view, hot: true } });
|
||||
|
||||
// ── ① 7 个键:中文标签 + 键名 + 兜底说明 ───────────────────────────────────
|
||||
for (const key of Object.keys(TUNING_LABELS)) {
|
||||
ok(has(html, TUNING_LABELS[key]), `必须渲染 ${key} 的中文标签`);
|
||||
ok(has(html, key), `必须显示键名 ${key}(后端契约的键,面板不改名)`);
|
||||
}
|
||||
ok(has(html, TIDY_TUNING_DISCLAIMER), "恒显示「面板配置文件优先,.env 只做兜底」");
|
||||
ok(has(html, "config/memory-tidy.json"), "必须显示面板配置文件的落点");
|
||||
ok(has(html, "跟随 .env 的 MEMORY_TIDY_INTERVAL_SEC"), "缺位的键要写清「留空 ⇒ 跟随谁」(env 档)");
|
||||
ok(has(html, "当前 86400"), "env 档的当前生效值要如实带出来");
|
||||
ok(has(html, "跟随面板里的 intervalSec"), "quietIntervalSec 的下一档是面板内的 intervalSec");
|
||||
ok(has(html, "跟随代码默认"), "ingestGate/autoApply/tempTtlSec 的下一档是代码默认");
|
||||
ok(has(html, "≈ 3 小时"), "秒数要给可读提示(10800 ⇒ 3 小时)");
|
||||
|
||||
// ── ② 热生效与否:两种口径必须分得清 ───────────────────────────────────────
|
||||
ok(has(html, TIDY_TUNING_HOT_LABEL), "hot:true 时必须说「已热生效(不用重启后端)」");
|
||||
ok(has(html, "保存后回显"), "保存后必须回显**从磁盘重新读回**的真值");
|
||||
ok(has(html, "intervalSec = 10800"), "回显要逐键给出真值");
|
||||
|
||||
const coldHtml = renderBody(view, draftFromView(view), {}, {
|
||||
receipt: "已保存:平时整理间隔。",
|
||||
savedView: view, // hot: false
|
||||
});
|
||||
ok(has(coldHtml, TIDY_TUNING_COLD_LABEL), "hot:false 时必须说「已保存到面板配置文件,重启后端后生效」");
|
||||
ok(!has(coldHtml, TIDY_TUNING_HOT_LABEL), "hot:false 时绝不许出现「已热生效」");
|
||||
ok(has(coldHtml, "运行期没有接线可热生效的实现"), "hot:false 要带出后端给的 hotError 原因");
|
||||
|
||||
// ── ③ 坏值就地报错并拦住保存 ───────────────────────────────────────────────
|
||||
const badHtml = renderBody(view, { ...draftFromView(view), intervalSec: "0" }, {
|
||||
intervalSec: "平时整理间隔 必须 ≥ 1 秒",
|
||||
});
|
||||
ok(has(badHtml, "必须 ≥ 1 秒"), "坏值要就地显示错误文案");
|
||||
ok(has(badHtml, "有坏值,未提交"), "有坏值时必须明说「未提交」(盘上不会被动)");
|
||||
ok(has(badHtml, "disabled"), "有坏值时保存按钮必须禁用");
|
||||
ok(has(badHtml, "清空一个键 = 从面板文件里删掉它"), "要解释「清空 ≠ 写 0」这条三态口径");
|
||||
ok(has(badHtml, "T1 判定"), "要说明同文件其它旋钮原样保留(这张卡只写 7 个键)");
|
||||
|
||||
// 缺位下的盘上值:没配 ⇒ 面板显示「未写 ⇒ 跟随兜底」,不编一个 0。
|
||||
const missing = fixtureView({ values: {} });
|
||||
const missingHtml = renderBody(missing, draftFromView(missing));
|
||||
ok(has(missingHtml, "留空 ⇒ 跟随 .env 的 MEMORY_TIDY_INTERVAL_SEC"), "没配的键要显示「留空 ⇒ 跟随下一档」");
|
||||
ok(!has(missingHtml, "intervalSec = 0"), "没配的键绝不能显示成 0(那是「写了 0」的坏值形态)");
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`MemoryTidyTuningCard.render: ${checks} checks passed`);
|
||||
@@ -0,0 +1,273 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
adminMemoryTidyTuning,
|
||||
adminMemoryTidyTuningSave,
|
||||
type AdminMemoryTidyTuningView,
|
||||
} from "../../lib/api/adminMemories";
|
||||
import { Button, Surface, Tag } from "../../components/primitives";
|
||||
import { Notice } from "../components";
|
||||
import { useAuditStore } from "../audit";
|
||||
import type { TuningDraft } from "./memoryTidyTuning";
|
||||
import {
|
||||
TUNING_LABELS,
|
||||
buildTuningPatch,
|
||||
draftFromView,
|
||||
fallbackLabel,
|
||||
patchSummary,
|
||||
secondsHint,
|
||||
unitLabel,
|
||||
} from "./memoryTidyTuning";
|
||||
|
||||
/**
|
||||
* 记忆整理「调优」卡(切片 MEMPANEL / M4)。真源:
|
||||
* internal/services/gateway/admin_memory_tuning.go。
|
||||
*
|
||||
* 它回答的是主人的两句原话:「`.env` 只做兜底用 …… 后端自动创建独立的面板配置文件
|
||||
* 之后就走面板配置文件,这些键优先从面板配置上取」与「频率必须调,可以调到 1 小时
|
||||
* 或者三小时,或者动态分配,比如白天间隔短一点、晚上静默期稍微长一点」。
|
||||
*
|
||||
* 三条口径落在文案里,因为它们是这张卡的产品承诺:
|
||||
* 1. **面板优先、`.env` 兜底**:留空 = 不写这个键 ⇒ 回落 `.env` → 代码默认。
|
||||
* 每个键都写清「跟随谁」,不做含糊的「未设置」;
|
||||
* 2. **坏值就地报错**:校验规则来自后端的 specs(类型/单位/上下界),非法草稿
|
||||
* **不发请求**(后端那道 400 仍在,只是不让主人白跑);
|
||||
* 3. **热生效与否如实说**:后端回 `hot:false` 时写「重启后端后生效」,绝不写成
|
||||
* 「已生效」。
|
||||
*
|
||||
* 呈现与取数分层:`MemoryTidyTuningBody` 只吃状态(渲染测试直接喂 fixture),卡组件
|
||||
* 负责取数 + 表单草稿 + 保存。
|
||||
*/
|
||||
|
||||
/** 这张卡的核心承诺(渲染测试钉住它必须出现)。 */
|
||||
export const TIDY_TUNING_DISCLAIMER = "面板配置文件优先,.env 只做兜底";
|
||||
|
||||
/** 热生效与「要重启」两种文案(测试分别钉住)。 */
|
||||
export const TIDY_TUNING_HOT_LABEL = "已热生效(不用重启后端)";
|
||||
export const TIDY_TUNING_COLD_LABEL = "已保存到面板配置文件,重启后端后生效";
|
||||
|
||||
/** BodyProps 是展示半边的输入(全部状态由外面给,便于直接渲染断言)。 */
|
||||
export interface TuningBodyProps {
|
||||
view: AdminMemoryTidyTuningView;
|
||||
draft: TuningDraft;
|
||||
errors: Record<string, string>;
|
||||
busy: boolean;
|
||||
/** 保存成功的回执文案(含热生效与否);null = 还没保存过。 */
|
||||
receipt: string | null;
|
||||
/** 保存后回显的真值(后端**重新读盘**的结果)。 */
|
||||
savedView: AdminMemoryTidyTuningView | null;
|
||||
onDraftChange: (key: string, value: string) => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
/** hotLabel 返回「这次到底生没生效」的诚实文案。 */
|
||||
export function hotLabel(view: AdminMemoryTidyTuningView): string {
|
||||
return view.hot ? TIDY_TUNING_HOT_LABEL : TIDY_TUNING_COLD_LABEL;
|
||||
}
|
||||
|
||||
/** fallbackHint 返回「留空时跟随谁 + 兜底那一档当前是多少」(有就报数)。 */
|
||||
function fallbackHint(view: AdminMemoryTidyTuningView, key: string, fallback: string): string {
|
||||
const envValue = view.envValues?.[key];
|
||||
const base = fallbackLabel({ key, kind: "int", fallback });
|
||||
return envValue === undefined ? base : `${base}(当前 ${envValue})`;
|
||||
}
|
||||
|
||||
/** FieldRow 渲染一个键:中文标签 + 控件 + 兜底说明 + 就地错误。 */
|
||||
function FieldRow({
|
||||
spec,
|
||||
value,
|
||||
error,
|
||||
fallback,
|
||||
valueHint,
|
||||
onChange,
|
||||
}: {
|
||||
spec: AdminMemoryTidyTuningView["specs"][number];
|
||||
value: string;
|
||||
error: string;
|
||||
fallback: string;
|
||||
valueHint: string;
|
||||
onChange: (next: string) => void;
|
||||
}): React.ReactNode {
|
||||
const label = TUNING_LABELS[spec.key] ?? spec.key;
|
||||
const inputCls =
|
||||
"w-40 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]";
|
||||
return (
|
||||
<div className="space-y-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)]/60 px-3 py-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="min-w-56 text-xs text-[var(--mu-text-hi)]">{label}</span>
|
||||
{spec.kind === "bool" ? (
|
||||
<select
|
||||
value={value}
|
||||
className="h-9 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
>
|
||||
<option value="">跟随兜底</option>
|
||||
<option value="true">开</option>
|
||||
<option value="false">关</option>
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
value={value}
|
||||
inputMode="numeric"
|
||||
placeholder="留空 = 跟随兜底"
|
||||
className={inputCls}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
{valueHint ? <span className="text-[11px] text-[var(--mu-text-lo)]">{valueHint}</span> : null}
|
||||
<span className="font-mono text-[11px] text-[var(--mu-text-lo)]">{spec.key}</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-[var(--mu-text-lo)]">
|
||||
{value.trim() === "" ? `留空 ⇒ ${fallback}` : `写进面板配置文件(覆盖 ${fallback})`}
|
||||
{spec.kind === "int" && unitLabel(spec) ? ` · 单位:${unitLabel(spec)}` : ""}
|
||||
</div>
|
||||
{error ? <div className="text-[11px] text-[var(--mu-err)]">⚠ {error}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** MemoryTidyTuningBody 是展示半边:吃状态、不取数(渲染测试直接喂它)。 */
|
||||
export function MemoryTidyTuningBody({
|
||||
view,
|
||||
draft,
|
||||
errors,
|
||||
busy,
|
||||
receipt,
|
||||
savedView,
|
||||
onDraftChange,
|
||||
onSave,
|
||||
}: TuningBodyProps): React.ReactNode {
|
||||
const hasErrors = Object.keys(errors).length > 0;
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Tag tone="accent">{TIDY_TUNING_DISCLAIMER}</Tag>
|
||||
<span className="font-mono text-[11px] text-[var(--mu-text-lo)]">{view.path}</span>
|
||||
<span className="text-[11px] text-[var(--mu-text-lo)]">
|
||||
{view.present ? "面板配置已存在" : "面板配置还没创建(后端下次启动会自动创建)"}
|
||||
</span>
|
||||
</div>
|
||||
{view.specs.map((spec) => (
|
||||
<FieldRow
|
||||
key={spec.key}
|
||||
spec={spec}
|
||||
value={draft[spec.key] ?? ""}
|
||||
error={errors[spec.key] ?? ""}
|
||||
fallback={fallbackHint(view, spec.key, spec.fallback)}
|
||||
valueHint={spec.unit === "sec" && /^\d+$/.test((draft[spec.key] ?? "").trim())
|
||||
? secondsHint(Number((draft[spec.key] ?? "").trim()))
|
||||
: ""}
|
||||
onChange={(next) => onDraftChange(spec.key, next)}
|
||||
/>
|
||||
))}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="primary" disabled={busy || hasErrors} onClick={onSave}>
|
||||
{busy ? "保存中…" : "保存调优"}
|
||||
</Button>
|
||||
{hasErrors ? (
|
||||
<span className="text-[11px] text-[var(--mu-text-lo)]">有坏值,未提交(后端一个字都不会改)</span>
|
||||
) : null}
|
||||
</div>
|
||||
{receipt ? <Notice>{receipt}</Notice> : null}
|
||||
{savedView ? (
|
||||
<div className="space-y-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-[11px]">
|
||||
<span className="text-[var(--mu-text-mid)]">保存后回显(从磁盘重新读回的真值):</span>
|
||||
{savedView.specs.map((spec) => {
|
||||
const v = savedView.values[spec.key];
|
||||
return (
|
||||
<div key={spec.key} className="font-mono text-[var(--mu-text-lo)]">
|
||||
{spec.key} = {v === undefined ? "(未写 ⇒ 跟随兜底)" : String(v)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="text-[var(--mu-text-mid)]">{hotLabel(savedView)}</div>
|
||||
{savedView.hot ? null : (
|
||||
<div className="text-[var(--mu-text-lo)]">{savedView.hotError ?? "运行期未接线可热生效的实现"}</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
<p className="text-[11px] text-[var(--mu-text-lo)]">
|
||||
只写这张卡的 7 个键;文件里其它旋钮(T1 判定 / T1 限速 / 每轮自动落地上限)原样保留。
|
||||
清空一个键 = 从面板文件里删掉它(回到下一档兜底),不是写 0。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** MemoryTidyTuningCard 负责取数、表单草稿与保存。 */
|
||||
export function MemoryTidyTuningCard(): React.ReactNode {
|
||||
const [view, setView] = useState<AdminMemoryTidyTuningView | null>(null);
|
||||
const [draft, setDraft] = useState<TuningDraft>({});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [fail, setFail] = useState<string | null>(null);
|
||||
const [receipt, setReceipt] = useState<string | null>(null);
|
||||
const [savedView, setSavedView] = useState<AdminMemoryTidyTuningView | null>(null);
|
||||
const log = useAuditStore((s) => s.log);
|
||||
|
||||
const reload = useCallback(() => {
|
||||
setFail(null);
|
||||
return adminMemoryTidyTuning()
|
||||
.then((next) => {
|
||||
setView(next);
|
||||
setDraft(draftFromView(next));
|
||||
})
|
||||
.catch((e: unknown) => setFail(e instanceof Error ? e.message : "读取面板配置失败"));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, [reload]);
|
||||
|
||||
const onDraftChange = useCallback((key: string, value: string) => {
|
||||
setDraft((prev) => ({ ...prev, [key]: value }));
|
||||
setReceipt(null);
|
||||
}, []);
|
||||
|
||||
const onSave = useCallback(() => {
|
||||
if (!view) return;
|
||||
const check = buildTuningPatch(view, draft);
|
||||
setErrors(check.errors);
|
||||
if (Object.keys(check.errors).length > 0) return;
|
||||
setBusy(true);
|
||||
setFail(null);
|
||||
adminMemoryTidyTuningSave(check.patch)
|
||||
.then((next) => {
|
||||
// 响应里的值是后端**重新读盘**的结果:据此重铺草稿(回显真值)。
|
||||
setView(next);
|
||||
setDraft(draftFromView(next));
|
||||
setSavedView(next);
|
||||
setErrors({});
|
||||
setReceipt(`已保存:${patchSummary(check.patch)}。${hotLabel(next)}`);
|
||||
log("memory.tidy.tuning", "ok");
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
setFail(e instanceof Error ? e.message : "保存面板配置失败");
|
||||
log("memory.tidy.tuning", "error");
|
||||
})
|
||||
.finally(() => setBusy(false));
|
||||
}, [view, draft, log]);
|
||||
|
||||
return (
|
||||
<Surface className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-sm font-semibold">记忆整理调优(面板配置)</h3>
|
||||
<Tag tone="neutral">GET / PUT /api/v1/admin/memories/tidy/tuning</Tag>
|
||||
</div>
|
||||
{fail ? <Notice tone="err">{fail}</Notice> : null}
|
||||
{view === null ? (
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">{fail ? "读取失败" : "读取中…"}</span>
|
||||
) : (
|
||||
<MemoryTidyTuningBody
|
||||
view={view}
|
||||
draft={draft}
|
||||
errors={errors}
|
||||
busy={busy}
|
||||
receipt={receipt}
|
||||
savedView={savedView}
|
||||
onDraftChange={onDraftChange}
|
||||
onSave={onSave}
|
||||
/>
|
||||
)}
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import type { AdminChannelRecord, EnumItem, OwnerAccount } from "../../lib/types
|
||||
import { Button } from "../../components/primitives";
|
||||
import {
|
||||
accountCandidates,
|
||||
flattenAccounts,
|
||||
draftAccounts,
|
||||
groupAccounts,
|
||||
platformIsConfigured,
|
||||
platformOptions,
|
||||
@@ -13,11 +13,16 @@ import {
|
||||
/**
|
||||
* 渠道账号白名单编辑器(主人 2026-10-05 要求):**一个平台一条**,该平台下用
|
||||
* 「+ 添加账号 ID」加多行;平台是权威枚举**下拉**;账号 ID 旁给「从该平台已配置
|
||||
* 账号里选」的候选。改动经 `onChange` 回到草稿时**立即展平**为
|
||||
* 账号里选」的候选。改动经 `onChange` 回到草稿时**写回扁平数组**
|
||||
* `accounts:[{platform,accountId}…]` —— 后端契约与 `owner.yaml` 格式完全不变。
|
||||
*
|
||||
* 为什么不在这里直接保存:档案的读取/草稿/保存状态由 `Users.tsx` 的
|
||||
* `OwnerProfileSection` 统一持有(一次真实 PUT),本组件只管呈现与展平。
|
||||
* `OwnerProfileSection` 统一持有(一次真实 PUT),本组件只管呈现与写回草稿。
|
||||
*
|
||||
* 为什么拆成两个函数:`OwnerAccountsEditor` 只留「分组/选项」这层 memo(同一表单里改
|
||||
* 别的字段时不必重算),界面本体 `OwnerAccountsView` 是**无 hook** 的 —— 本项目没有
|
||||
* jsdom,测试正是**直接调用**它、点它自己的按钮来钉住「手势 → 草稿 → 重渲染」这条往返
|
||||
* (见 `ownerAccounts.test.ts` ⑤)。往 View 里加 hook 会让那条测试报 "Invalid hook call"。
|
||||
*/
|
||||
export function OwnerAccountsEditor({
|
||||
accounts,
|
||||
@@ -39,10 +44,39 @@ export function OwnerAccountsEditor({
|
||||
() => platformOptions(platforms, groups.map((g) => g.platform)),
|
||||
[platforms, groups]
|
||||
);
|
||||
return (
|
||||
<OwnerAccountsView
|
||||
groups={groups}
|
||||
options={options}
|
||||
channels={channels}
|
||||
disabled={disabled}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 每次改动都「分组 → 展平 → 回草稿」:草稿里始终是后端要的扁平形态,
|
||||
// 界面上的分组只是它的投影,两者不会各说各话。
|
||||
const emit = (next: OwnerAccountGroup[]): void => onChange(flattenAccounts(next));
|
||||
export interface OwnerAccountsViewProps {
|
||||
/** 界面上的分组(草稿的投影);手势产生的下一份**草稿**也由它算出来。 */
|
||||
groups: OwnerAccountGroup[];
|
||||
options: EnumItem[];
|
||||
channels: AdminChannelRecord[] | null;
|
||||
disabled: boolean;
|
||||
/** 新草稿(扁平数组)。 */
|
||||
onChange: (next: OwnerAccount[]) => void;
|
||||
}
|
||||
|
||||
/** 界面本体(**无 hook**,见文件头):所有手势都走这里 → `onChange(新草稿)`。 */
|
||||
export function OwnerAccountsView({
|
||||
groups,
|
||||
options,
|
||||
channels,
|
||||
disabled,
|
||||
onChange,
|
||||
}: OwnerAccountsViewProps): React.ReactNode {
|
||||
// 每次改动都「分组 → 改 → 回草稿」:草稿里始终是扁平形态,界面上的分组只是它的投影,
|
||||
// 两者不会各说各话。写回必须**无损**(`draftAccounts`):一旦写成有损的写盘形态,刚点出来的
|
||||
// 「+ 添加账号 ID」空行当场蒸发 —— 点了等于没点。空行只在保存时丢(见 `accountsForSave`)。
|
||||
const emit = (next: OwnerAccountGroup[]): void => onChange(draftAccounts(next));
|
||||
|
||||
const patchGroup = (i: number, patch: Partial<OwnerAccountGroup>): void =>
|
||||
emit(groups.map((g, idx) => (idx === i ? { ...g, ...patch } : g)));
|
||||
@@ -156,6 +190,7 @@ function PlatformGroupRow(props: PlatformGroupRowProps): React.ReactNode {
|
||||
{group.accountIds.map((accountId, j) => (
|
||||
<AccountIdRow
|
||||
key={`acct-${index}-${j}`}
|
||||
row={j}
|
||||
listId={`acct-${index}-${j}-candidates`}
|
||||
accountId={accountId}
|
||||
candidates={candidates}
|
||||
@@ -227,6 +262,8 @@ function PlatformPicker({
|
||||
|
||||
interface AccountIdRowProps {
|
||||
listId: string;
|
||||
/** 在本分组里的行号(`0` 起):给行一个稳定的定位标记,浏览器里/测试里都指得到具体那一行。 */
|
||||
row: number;
|
||||
accountId: string;
|
||||
candidates: string[];
|
||||
platform: string;
|
||||
@@ -239,6 +276,7 @@ interface AccountIdRowProps {
|
||||
/** 一行账号 ID:手填 + 该平台已配置账号的候选(点一下就填入)。 */
|
||||
function AccountIdRow({
|
||||
listId,
|
||||
row,
|
||||
accountId,
|
||||
candidates,
|
||||
platform,
|
||||
@@ -248,7 +286,7 @@ function AccountIdRow({
|
||||
onRemove,
|
||||
}: AccountIdRowProps): React.ReactNode {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-1" data-account-row={row}>
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<label className="block min-w-56 flex-1 space-y-1">
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">账号 ID</span>
|
||||
|
||||
@@ -7,6 +7,12 @@ import { useAuditStore } from "../audit";
|
||||
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
|
||||
import { Button, Surface, Switch, Tag } from "../../components/primitives";
|
||||
import { InstalledPluginsPanel } from "./plugins/InstalledPlugins";
|
||||
import {
|
||||
mcpServerEntries,
|
||||
originGroupTitle,
|
||||
otherCapabilityEntries,
|
||||
toolOriginBuckets,
|
||||
} from "./pluginOrigin";
|
||||
|
||||
type CapKind = "tool" | "skill" | "channel";
|
||||
|
||||
@@ -16,6 +22,9 @@ const KIND_LABELS: Record<CapKind, string> = {
|
||||
channel: "渠道 Channel",
|
||||
};
|
||||
|
||||
/** 指向「MCP 服务器」页的提示文案:server 级动作(启停/重连/卸载)都在那边。 */
|
||||
const MCP_PAGE_HINT = "在「MCP 服务器」页卸载该 server";
|
||||
|
||||
// Channel adapters are not gated by the tool policy (group/stranger markers are a
|
||||
// tool concept; a channel is configured via its own 渠道管理 page), so the
|
||||
// channel block shows identity + a pointer rather than the policy switches.
|
||||
@@ -27,7 +36,44 @@ function channelColumns(): AdminColumn<AdminPluginRecord>[] {
|
||||
];
|
||||
}
|
||||
|
||||
/** Columns for a tool / skill row (has environment markers, owner can edit). */
|
||||
/** The environment-marker switches, shared by tool and skill rows.
|
||||
*
|
||||
* 它对**工具名**生效(`tool_policies` 以 name 为键),因此来自 MCP server 的工具有同
|
||||
* 一套开关;`mcp:<server>` 那条 KindMCP 条目拿不到开关(策略判定从不读它),所以在
|
||||
* MCP 登记段里不给开关 —— 假开关比没有开关更糟。 */
|
||||
function policyColumn(
|
||||
isOwner: boolean,
|
||||
busy: string | null,
|
||||
setPolicy: (name: string, allowInGroup: boolean, allowInStranger: boolean) => void,
|
||||
): AdminColumn<AdminPluginRecord> {
|
||||
return {
|
||||
key: "policy",
|
||||
title: "环境授权(owner)",
|
||||
render: (p) => (
|
||||
<span className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-1.5">
|
||||
<Switch
|
||||
checked={p.allowInGroup}
|
||||
disabled={!isOwner || busy === p.name}
|
||||
onChange={(v) => setPolicy(p.name, v, p.allowInStranger)}
|
||||
/>
|
||||
<span className="text-xs text-[var(--mu-text-mid)]">群聊</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5">
|
||||
<Switch
|
||||
checked={p.allowInStranger}
|
||||
disabled={!isOwner || busy === p.name}
|
||||
onChange={(v) => setPolicy(p.name, p.allowInGroup, v)}
|
||||
/>
|
||||
<span className="text-xs text-[var(--mu-text-mid)]">陌生人</span>
|
||||
</label>
|
||||
{busy === p.name ? <span className="text-xs text-[var(--mu-text-lo)]">保存中…</span> : null}
|
||||
</span>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Columns for a host-builtin tool / a skill row (uninstall = remove this capability). */
|
||||
function registeredColumns(
|
||||
isOwner: boolean,
|
||||
busy: string | null,
|
||||
@@ -38,31 +84,7 @@ function registeredColumns(
|
||||
{ key: "name", title: "名称", render: (p) => <span className="font-medium">{p.name}</span> },
|
||||
{ key: "kind", title: "类型", render: (p) => <Tag tone="accent">{(KIND_LABELS as Record<string, string>)[p.kind] ?? p.kind}</Tag> },
|
||||
{ key: "desc", title: "描述", render: (p) => <span className="block max-w-sm truncate text-[var(--mu-text-mid)]">{p.description || "—"}</span> },
|
||||
{
|
||||
key: "policy",
|
||||
title: "环境授权(owner)",
|
||||
render: (p) => (
|
||||
<span className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-1.5">
|
||||
<Switch
|
||||
checked={p.allowInGroup}
|
||||
disabled={!isOwner || busy === p.name}
|
||||
onChange={(v) => setPolicy(p.name, v, p.allowInStranger)}
|
||||
/>
|
||||
<span className="text-xs text-[var(--mu-text-mid)]">群聊</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5">
|
||||
<Switch
|
||||
checked={p.allowInStranger}
|
||||
disabled={!isOwner || busy === p.name}
|
||||
onChange={(v) => setPolicy(p.name, p.allowInGroup, v)}
|
||||
/>
|
||||
<span className="text-xs text-[var(--mu-text-mid)]">陌生人</span>
|
||||
</label>
|
||||
{busy === p.name ? <span className="text-xs text-[var(--mu-text-lo)]">保存中…</span> : null}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
policyColumn(isOwner, busy, setPolicy),
|
||||
{
|
||||
key: "actions",
|
||||
title: "操作",
|
||||
@@ -75,6 +97,61 @@ function registeredColumns(
|
||||
];
|
||||
}
|
||||
|
||||
/** Columns for a tool that belongs to an MCP server.
|
||||
*
|
||||
* 🔴 行内**没有**「卸载」:单把 MCP 工具不是独立安装单元 —— 卸载它只能绕过插件管理器
|
||||
* 裸删注册表条目,而该 server 的 names 账本不同步(重连/refresh 后工具复活,用户以为
|
||||
* 卸掉了)。服务端同样拒绝这条路径(`plugin_uninstall_scope`),这里给出该去哪里做。 */
|
||||
function mcpToolColumns(
|
||||
isOwner: boolean,
|
||||
busy: string | null,
|
||||
setPolicy: (name: string, allowInGroup: boolean, allowInStranger: boolean) => void,
|
||||
): AdminColumn<AdminPluginRecord>[] {
|
||||
return [
|
||||
{ key: "name", title: "名称", render: (p) => <span className="font-medium">{p.name}</span> },
|
||||
{
|
||||
key: "origin",
|
||||
title: "来源",
|
||||
render: (p) => <Tag tone="neutral" title="注册时由后端盖章的上游归属,不从工具名推断">{p.origin?.server || "未知 server"}</Tag>,
|
||||
},
|
||||
{ key: "desc", title: "描述", render: (p) => <span className="block max-w-sm truncate text-[var(--mu-text-mid)]">{p.description || "—"}</span> },
|
||||
policyColumn(isOwner, busy, setPolicy),
|
||||
{
|
||||
key: "actions",
|
||||
title: "操作",
|
||||
render: () => <span className="text-xs text-[var(--mu-text-lo)]">{MCP_PAGE_HINT}</span>,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** Columns for a `kind:"mcp"` entry: one registration row per server, server-scoped. */
|
||||
function mcpServerColumns(): AdminColumn<AdminPluginRecord>[] {
|
||||
return [
|
||||
{ key: "name", title: "登记键", render: (p) => <span className="font-mono text-xs font-medium">{p.name}</span> },
|
||||
{ key: "desc", title: "描述", render: (p) => <span className="block max-w-md truncate text-[var(--mu-text-mid)]">{p.description || "—"}</span> },
|
||||
{
|
||||
key: "admin",
|
||||
title: "管理",
|
||||
render: () => (
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">
|
||||
启停 / 重连 / 卸载 / 安装见「MCP 服务器」页;本行是注册表登记条目,不承载单独的工具策略
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** Columns for capability kinds the page does not otherwise model (e.g. iot).
|
||||
*
|
||||
* design/09 §5.2.1 红线③:已装但本页不认识的条目要**如实显示**,不得静默过滤。 */
|
||||
function otherColumns(): AdminColumn<AdminPluginRecord>[] {
|
||||
return [
|
||||
{ key: "name", title: "名称", render: (p) => <span className="font-medium">{p.name}</span> },
|
||||
{ key: "kind", title: "类型", render: (p) => <Tag tone="neutral">{p.kind}</Tag> },
|
||||
{ key: "desc", title: "描述", render: (p) => <span className="block max-w-md truncate text-[var(--mu-text-mid)]">{p.description || "—"}</span> },
|
||||
];
|
||||
}
|
||||
|
||||
export function PluginsModule(): React.ReactNode {
|
||||
const { data, loading, error, reload } = useAsyncData(adminListPlugins, []);
|
||||
useAutoReload(reload, 10000);
|
||||
@@ -125,14 +202,22 @@ export function PluginsModule(): React.ReactNode {
|
||||
return groups;
|
||||
}, [data]);
|
||||
|
||||
const toolSkillColumns = registeredColumns(isOwner, busy, setPolicy, uninstall);
|
||||
// 工具段的两级分节:先按上游 server,再剩「宿主内置」。
|
||||
const toolBuckets = useMemo(() => toolOriginBuckets(byKind.tool), [byKind.tool]);
|
||||
const mcpServers = useMemo(() => mcpServerEntries(data ?? []), [data]);
|
||||
const others = useMemo(() => otherCapabilityEntries(data ?? []), [data]);
|
||||
|
||||
const toolCols = registeredColumns(isOwner, busy, setPolicy, uninstall);
|
||||
const mcpToolCols = mcpToolColumns(isOwner, busy, setPolicy);
|
||||
const channelCols = channelColumns();
|
||||
const serverCols = mcpServerColumns();
|
||||
const otherCols = otherColumns();
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<PageHeader
|
||||
title="插件与能力"
|
||||
subtitle="已注册能力(按工具 / 技能 / 渠道分组)+ 运行时 MCP 安装/卸载 · POST /api/v1/admin/plugins/policy (owner-only)"
|
||||
subtitle="已注册能力(工具按上游 MCP server 分节 / 技能 / 渠道)+ 运行时 MCP 安装 · POST /api/v1/admin/plugins/policy (owner-only)"
|
||||
/>
|
||||
|
||||
{!isOwner ? <Notice tone="warn">当前角色为 {getStoredRole()},工具策略/插件安装仅 owner 可写(读取/查看可见)。</Notice> : null}
|
||||
@@ -144,28 +229,66 @@ export function PluginsModule(): React.ReactNode {
|
||||
<Surface className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-sm font-semibold">已注册能力</h3>
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">GET /api/v1/admin/plugins · 按工具 / 技能 / 渠道分组</span>
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">GET /api/v1/admin/plugins · 工具按上游 server 分节</span>
|
||||
</div>
|
||||
|
||||
{(["tool", "skill"] as CapKind[]).map((kind) => {
|
||||
const rows = byKind[kind];
|
||||
return (
|
||||
<div key={kind} className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Tag tone="accent">{KIND_LABELS[kind]}</Tag>
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">{rows.length} 个</span>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Tag tone="accent">{KIND_LABELS.tool}</Tag>
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">
|
||||
{byKind.tool.length} 个 · {toolBuckets.mcp.length} 台 MCP server + 宿主内置
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{toolBuckets.mcp.map((group) => (
|
||||
<div key={group.key} className="space-y-1.5">
|
||||
<div className="flex flex-wrap items-center gap-2 pt-1">
|
||||
<Tag tone="neutral" title="来源由后端在注册时盖章,不从工具名解析">
|
||||
{originGroupTitle(group)}
|
||||
</Tag>
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">server 级动作见「MCP 服务器」页</span>
|
||||
</div>
|
||||
<AdminTable
|
||||
columns={toolSkillColumns}
|
||||
rows={loading ? null : rows}
|
||||
columns={mcpToolCols}
|
||||
rows={loading ? null : group.rows}
|
||||
loading={loading}
|
||||
error={error}
|
||||
emptyTitle={`无${KIND_LABELS[kind]}`}
|
||||
rowKey={(p) => `${kind}:${p.name}`}
|
||||
emptyTitle="无工具"
|
||||
rowKey={(p) => `mcp:${group.key}:${p.name}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex flex-wrap items-center gap-2 pt-1">
|
||||
<Tag tone="neutral">宿主内置工具</Tag>
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">{toolBuckets.builtin.length} 把</span>
|
||||
</div>
|
||||
<AdminTable
|
||||
columns={toolCols}
|
||||
rows={loading ? null : toolBuckets.builtin}
|
||||
loading={loading}
|
||||
error={error}
|
||||
emptyTitle="无宿主内置工具"
|
||||
rowKey={(p) => `tool:${p.name}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Tag tone="accent">{KIND_LABELS.skill}</Tag>
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">{byKind.skill.length} 个</span>
|
||||
</div>
|
||||
<AdminTable
|
||||
columns={toolCols}
|
||||
rows={loading ? null : byKind.skill}
|
||||
loading={loading}
|
||||
error={error}
|
||||
emptyTitle={`无${KIND_LABELS.skill}`}
|
||||
rowKey={(p) => `skill:${p.name}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
@@ -181,9 +304,48 @@ export function PluginsModule(): React.ReactNode {
|
||||
rowKey={(p) => `channel:${p.name}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 🔴 `kind:"mcp"` 条目**必须如实显示**(design/09 §5.2.1 红线③):它是每台
|
||||
server 的注册登记,不是可调用能力,因此这里给事实 + 指向,不给策略开关
|
||||
(`tool_policies` 只按工具名判定,对这条永远是惰性的 = 假开关)。 */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Tag tone="neutral">MCP server 登记</Tag>
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">{mcpServers.length} 台</span>
|
||||
</div>
|
||||
<AdminTable
|
||||
columns={serverCols}
|
||||
rows={loading ? null : mcpServers}
|
||||
loading={loading}
|
||||
error={error}
|
||||
emptyTitle="无 MCP server 登记(可在「MCP 服务器」页添加)"
|
||||
rowKey={(p) => `mcpentry:${p.name}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{others.length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Tag tone="neutral">其它能力</Tag>
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">{others.length} 个 · 本页暂无专用视图,如实列出</span>
|
||||
</div>
|
||||
<AdminTable
|
||||
columns={otherCols}
|
||||
rows={others}
|
||||
loading={loading}
|
||||
error={error}
|
||||
emptyTitle="无其它能力"
|
||||
rowKey={(p) => `other:${p.name}`}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</Surface>
|
||||
|
||||
<Notice>工具 schema / 技能为后端 M10+ 提供;此页按类型分组展示已注册能力。群聊/陌生人环境可见性标记(默认关闭)仅对工具/技能生效。</Notice>
|
||||
<Notice>
|
||||
工具按「注册时」的来源分节(来自哪台 MCP server 由后端盖章,不解析工具名);这是「呈现」分组,权限仍由
|
||||
tool_policies 的逐工具环境授权决定。MCP server 的启停/重连/卸载/安装都在「MCP 服务器」页 ——
|
||||
单把 MCP 工具不能单独卸载(否则 server 侧的工具账本会不同步)。
|
||||
</Notice>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
import { useAuditStore } from "../audit";
|
||||
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
|
||||
import { OwnerAccountsEditor } from "./OwnerAccountsEditor";
|
||||
import { accountsForSave } from "./ownerAccounts";
|
||||
import { Button, Surface, Tag } from "../../components/primitives";
|
||||
|
||||
const roleTone: Record<string, "accent" | "ok" | "neutral" | "warn"> = {
|
||||
@@ -166,7 +167,9 @@ function OwnerProfileSection({ isOwner }: { isOwner: boolean }): React.ReactNode
|
||||
setBusy(true);
|
||||
setFail(null);
|
||||
setNotice(null);
|
||||
adminSaveOwnerProfile(profile)
|
||||
// 草稿里允许有还没填完的空行(编辑期不能丢行),写盘前才收成写盘形态:只留平台与账号 ID
|
||||
// 都非空的条目,并去掉两侧空白。既不把空行写进 owner.yaml,也不会因为半成品条目被后端 400。
|
||||
adminSaveOwnerProfile({ ...profile, accounts: accountsForSave(profile.accounts) })
|
||||
.then((saved) => {
|
||||
log("owner.profile.edit", "ok");
|
||||
setProfile(saved);
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { AdminChannelAccount } from "../../../lib/types";
|
||||
import { Button, Tag } from "../../../components/primitives";
|
||||
import { authKeyOf } from "../channelFieldRules";
|
||||
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 authSet = typeof raw === "string" && raw.trim() !== "";
|
||||
const gate = accountActionGate(status, account.connected);
|
||||
const state = connectionTag(account.connected);
|
||||
const state = connectionTag(account);
|
||||
const online = accountOnlineStatus(account.accountOnline);
|
||||
const blocker = status.kind === "unknown" ? "读取不到连接状态(后端失联),已禁用" : undefined;
|
||||
|
||||
@@ -67,7 +67,7 @@ export function AccountRow({
|
||||
id {short}
|
||||
</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 的
|
||||
进程/WS 可以一直连着,而背后的 QQ 账号已掉线(消息收不到也发不出)。
|
||||
两枚标签刻意不合并:合并后「我们连着但账号掉了」这一个事故现场就无迹可寻。
|
||||
@@ -127,11 +127,23 @@ export function AccountRow({
|
||||
);
|
||||
}
|
||||
|
||||
/** 账号级连接状态的呈现:已连接 / 未连接 / 状态未知(后端没报告该账号的活适配器)。 */
|
||||
function connectionTag(connected: boolean | undefined): string {
|
||||
if (connected === true) return "已连接";
|
||||
if (connected === false) return "未连接";
|
||||
return "状态未知";
|
||||
/** 账号级连接状态的呈现:已连接 / 连接中 / 等待接入 / 未连接 / 状态未知
|
||||
* (后端没报告该账号的活适配器)。
|
||||
*
|
||||
* 「连接中」与「等待接入」来自后端自报的连接态三态(见 ChannelConnectState)。
|
||||
* 没有它们时,一个正在后台重试的 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 { channelListState, channelStatus } from "./channelStatus";
|
||||
import { ChannelLoadError, ChannelStatusTag } from "./channelStatusView";
|
||||
import { ChannelCoalesceCard } from "./CoalesceCard";
|
||||
import type { AdapterNav } from "./useAdapterNav";
|
||||
|
||||
/** Page ① — aggregate all adapters: connection state, message volume, health.
|
||||
@@ -79,6 +80,9 @@ export function OverviewPage({ navigate }: { navigate: (n: AdapterNav) => void }
|
||||
onOpenList={() => navigate({ kind: "page", page: "list" })}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* 群聊静默窗口合并(M23):窗口/上限这类调优只该在面板改,不该 SSH 改文件。 */}
|
||||
<ChannelCoalesceCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -154,4 +154,24 @@ function buttonHTML(html: string, label: string): string {
|
||||
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`);
|
||||
@@ -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. */
|
||||
export type ChannelStatusKind =
|
||||
| "connected"
|
||||
| "connecting"
|
||||
| "ready"
|
||||
| "disconnected"
|
||||
| "unconfigured"
|
||||
| "unknown"
|
||||
@@ -29,6 +31,55 @@ export interface ChannelStatus {
|
||||
/** How much of the channel list we actually know right now. */
|
||||
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*,
|
||||
* never "empty". `data == null` with no error is the first load still running. */
|
||||
export function channelListState<T>(
|
||||
@@ -91,6 +142,24 @@ export function channelStatus(
|
||||
}
|
||||
|
||||
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 {
|
||||
kind: connected ? "connected" : "disconnected",
|
||||
label: connected ? "已连接" : "未连接",
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { ChannelStatus, ChannelStatusKind } from "./channelStatus";
|
||||
|
||||
const dot: Record<ChannelStatusKind, string> = {
|
||||
connected: "bg-[var(--mu-ok)]",
|
||||
connecting: "bg-[var(--mu-warn)] mu-blink",
|
||||
ready: "bg-[var(--mu-text-lo)]",
|
||||
disconnected: "bg-[var(--mu-err)]",
|
||||
unconfigured: "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,
|
||||
* detail page and overview can never drift apart: 已连接 / 未连接 / 状态未知.
|
||||
* `showHint` off renders the compact form for dense surfaces (table row, card) —
|
||||
* the surrounding banner already carries the failure reason there. */
|
||||
* detail page and overview can never drift apart: 已连接 / 连接中 / 等待接入 /
|
||||
* 未连接 / 状态未知. `showHint` off renders the compact form for dense surfaces
|
||||
* (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({
|
||||
status,
|
||||
showHint = true,
|
||||
@@ -24,7 +27,7 @@ export function ChannelStatusTag({
|
||||
}): React.ReactNode {
|
||||
return (
|
||||
<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]}`} />
|
||||
{status.label}
|
||||
</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,
|
||||
adminDeleteChannelAccount,
|
||||
adminDeleteEmptyChannelAccounts,
|
||||
type ChannelActionResult,
|
||||
} from "../../../lib/api";
|
||||
import { useAuditStore } from "../../audit";
|
||||
|
||||
@@ -19,6 +20,35 @@ export interface ChannelActionDeps {
|
||||
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 条目)。 */
|
||||
export interface ChannelActions {
|
||||
/** 连接/断开。`accountId` 给出时**只**作用于该账号(多账号平台逐条操作);
|
||||
@@ -42,7 +72,7 @@ export function createChannelActions(deps: ChannelActionDeps): ChannelActions {
|
||||
// 回显**实际被驱动**的适配器:多账号平台上请求里的平台名说明不了「连的是哪一条」,
|
||||
// 后端把解析结果放在 resolved 里,这里如实说出来。
|
||||
const who = res?.resolved ?? name;
|
||||
setNotice(`${connect ? "已连接" : "已断开"} ${who}`);
|
||||
setNotice(connectDisconnectNotice(connect, who, res));
|
||||
reload();
|
||||
})
|
||||
.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 给出「从该平台已配置账号里选」的候选;**写盘时仍是扁平数组**。
|
||||
//
|
||||
// 锁三件事:
|
||||
// ① 分组/展平是纯函数且**展平后就是后端契约的扁平数组**(`owner.yaml` 格式不变);
|
||||
// 锁这些事:
|
||||
// ① 分组/草稿/写盘形态(纯函数):**草稿无损**(`draftAccounts` 逐行保留,空行/未选平台的组
|
||||
// 都在 —— 编辑期的中间态不许蒸发),**写盘有损**(`accountsForSave` 只留平台与账号 ID 都
|
||||
// 非空的条目:半成品条目会让后端 `Validate()` 直接 400,完全空的条目被跳过);
|
||||
// ② 下拉选项 = 权威枚举 + 存量里出现过的平台(未列出的旧值不被下拉吃掉);
|
||||
// ③ 渲染:一个平台一条(两个平台 ⇒ 两块)、有「+ 添加账号 ID」、有候选可点。
|
||||
// ③ 候选账号 = 该平台已配置的渠道账号 id;
|
||||
// ④ 渲染:一个平台一条(两个平台 ⇒ 两块)、有「+ 添加账号 ID」、有候选可点;
|
||||
// ⑤ **手势往返**(主人 2026-10-07 报的缺陷:卡片里「+ 添加账号 ID」似乎没用):点一下必须
|
||||
// 真的多出一行可输入的账号行;「+ 添加平台」走同一条往返,同样钉住。
|
||||
//
|
||||
// 跑法(react-dom/server 的 CJS 构建需要 require,所以必须打成 cjs):
|
||||
//
|
||||
@@ -21,12 +26,14 @@
|
||||
// `tsc --noEmit`(npm run build 的一部分)会对本文件做类型检查,因此它只从项目内导入、
|
||||
// 不 import node:*。
|
||||
|
||||
import { createElement } from "react";
|
||||
import { createElement, type ReactNode } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import type { AdminChannelRecord, EnumItem, OwnerAccount } from "../../lib/types";
|
||||
import { OwnerAccountsEditor } from "./OwnerAccountsEditor";
|
||||
import { OwnerAccountsEditor, OwnerAccountsView } from "./OwnerAccountsEditor";
|
||||
import {
|
||||
accountCandidates,
|
||||
accountsForSave,
|
||||
draftAccounts,
|
||||
flattenAccounts,
|
||||
groupAccounts,
|
||||
platformIsConfigured,
|
||||
@@ -56,7 +63,7 @@ const PUBLISHED: EnumItem[] = [
|
||||
{ id: "webhook", label: "Webhook" },
|
||||
];
|
||||
|
||||
// ── ① 分组 / 展平(纯函数) ─────────────────────────────────────────────────────
|
||||
// ── ① 分组 / 草稿 / 写盘形态(纯函数) ──────────────────────────────────────────
|
||||
{
|
||||
const flat: OwnerAccount[] = [
|
||||
{ platform: "qq-obv11", accountId: "9616b27a" },
|
||||
@@ -68,15 +75,71 @@ const PUBLISHED: EnumItem[] = [
|
||||
same(groups[0], { platform: "qq-obv11", accountIds: ["9616b27a", "aaaa1111"] }, "同平台的账号收进一块");
|
||||
same(groups[1], { platform: "web", accountIds: ["web-user"] }, "顺序 = 首次出现顺序");
|
||||
|
||||
// 展平回扁平数组。注意:分组天然把**同一平台的账号排到相邻位置** ⇒ 保存后文件的
|
||||
// 行序会变成「按平台聚集」。白名单是一个**集合**(命中即 owner,与顺序无关),所以
|
||||
// 这里按集合断言;顺序变化在面板上一眼可见,不是隐藏改动。
|
||||
// 草稿(编辑期形态,`draftAccounts`)**无损**:逐行原样回来 —— 空行、还没选平台的组都在。
|
||||
// 这一条是主人 2026-10-07 那个缺陷的正面表述:中间态不许在往返里蒸发。
|
||||
same(
|
||||
groupAccounts(draftAccounts(groups)),
|
||||
groups,
|
||||
"组 → 草稿 → 组:原样回来(无损往返)"
|
||||
);
|
||||
same(
|
||||
draftAccounts([{ platform: "kook", accountIds: ["9616b27a", "", " "] }]),
|
||||
[
|
||||
{ platform: "kook", accountId: "9616b27a" },
|
||||
{ platform: "kook", accountId: "" },
|
||||
{ platform: "kook", accountId: " " },
|
||||
],
|
||||
"空行留在草稿里(不 trim、不丢行:编辑期不改写主人正在输入的字)"
|
||||
);
|
||||
same(
|
||||
groupAccounts(draftAccounts([{ platform: "", accountIds: [""] }])),
|
||||
[{ platform: "", accountIds: [""] }],
|
||||
"「还没选平台」的空组也在草稿里(「+ 添加平台」靠它)"
|
||||
);
|
||||
|
||||
// 写盘形态(`flattenAccounts` / `accountsForSave`):分组天然把**同一平台的账号排到相邻位置**
|
||||
// ⇒ 保存后文件的条目序会变成「按平台聚集」。白名单是一个**集合**(命中即 owner,与顺序无关),
|
||||
// 所以这里按集合断言;顺序变化在面板上一眼可见,不是隐藏改动。
|
||||
const sorted = (list: OwnerAccount[]): OwnerAccount[] =>
|
||||
[...list].sort((a, b) => `${a.platform}/${a.accountId}`.localeCompare(`${b.platform}/${b.accountId}`));
|
||||
same(sorted(flattenAccounts(groups)), sorted(flat), "分组 → 展平 回到同一组白名单条目");
|
||||
const shape = Object.keys(flattenAccounts(groups)[0]).sort();
|
||||
same(shape, ["accountId", "platform"], "写盘条目只有 {platform, accountId}(owner.yaml 格式不变)");
|
||||
|
||||
// 保存路径(草稿 → 写盘形态):只写填完的条目。
|
||||
// 草稿里允许有空行,写盘时收掉;半成品条目(缺平台或缺 id)**必须**在这里收掉,否则
|
||||
// 后端 `Validate()`(internal/user/load.go)对这类条目直接 400,主人会看到「保存失败」。
|
||||
same(
|
||||
accountsForSave([
|
||||
{ platform: "qq-obv11", accountId: "9616b27a" },
|
||||
{ platform: "qq-obv11", accountId: "" },
|
||||
{ platform: "kook", accountId: "" },
|
||||
]),
|
||||
[{ platform: "qq-obv11", accountId: "9616b27a" }],
|
||||
"保存:只写平台与账号 ID 都非空的条目(空行不进 owner.yaml)"
|
||||
);
|
||||
same(
|
||||
accountsForSave([
|
||||
{ platform: "", accountId: "9616b27a" },
|
||||
{ platform: "kook", accountId: "" },
|
||||
{ platform: "", accountId: "" },
|
||||
]),
|
||||
[],
|
||||
"保存:半成品条目一个都不写(写了会被后端 400)"
|
||||
);
|
||||
same(accountsForSave([]), [], "保存:空白名单写出空数组(不是 null)");
|
||||
same(sorted(accountsForSave(flat)), sorted(flat), "保存:已生效的条目原样往返(不会被写没)");
|
||||
same(
|
||||
sorted(accountsForSave(flattenAccounts(groups))),
|
||||
sorted(flat),
|
||||
"保存:写盘形态再收一次仍是同一组(幂等)"
|
||||
);
|
||||
same(
|
||||
Object.keys(accountsForSave([{ platform: "kook", accountId: "42" }])[0]).sort(),
|
||||
["accountId", "platform"],
|
||||
"保存:落盘条目仍只有 {platform, accountId}(owner.yaml 格式一个字不改)"
|
||||
);
|
||||
|
||||
// 空值:平台或账号 id 任一为空都不写(写了也匹配不上 actor 元数据,是噪声)。
|
||||
same(
|
||||
flattenAccounts([
|
||||
@@ -171,4 +234,247 @@ const PUBLISHED: EnumItem[] = [
|
||||
ok(other.includes('disabled=""'), "只读角色下控件被禁用(不假装能改)");
|
||||
}
|
||||
|
||||
// ── ⑤ 手势 → 草稿 → 渲染:任何界面手势产生的中间态都不能在往返中蒸发 ─────────────
|
||||
//
|
||||
// 这一节钉的是主人报的那个缺陷:「配置指定适配器主人账号的卡片内的『添加账号ID』按钮似乎没用」。
|
||||
// 它只发生在「手势 → 草稿 → 再投影回界面」这条往返上:草稿若是**有损**的扁平形态(展平时把空行
|
||||
// 丢掉),刚点出来的空行当场蒸发、界面照旧 —— 点了等于没点。注意纯函数往返测试
|
||||
// (`展平(分组(flat)) == flat`)**测不到**它:那个等式把「空行被丢掉」当成恒等,等于把有损性
|
||||
// 当成了正确性。
|
||||
//
|
||||
// 本项目没有 jsdom(见 flowTimelineWiring.render.test.ts 的说明)⇒ 这里直接调用**无 hook** 的
|
||||
// `OwnerAccountsView` 拿到真的元素树,**点真的那个按钮**(调它自己的 `onClick`),把 `onChange`
|
||||
// 收到的**真草稿**再喂回去重渲染 —— 走的正是 `Users.tsx` 的接线(草稿是唯一状态,界面是它的投影)。
|
||||
|
||||
/** 元素树里的一个宿主节点(`button`/`input`/`select`/`div`…)。 */
|
||||
interface HostEl {
|
||||
type: string;
|
||||
props: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** 节点里的可见文字(按钮标签、提示语靠它找)。 */
|
||||
function textOf(node: ReactNode): string {
|
||||
if (node === null || node === undefined || typeof node === "boolean") return "";
|
||||
if (typeof node === "string" || typeof node === "number") return String(node);
|
||||
if (Array.isArray(node)) return node.map(textOf).join("");
|
||||
return textOf((node as { props?: { children?: ReactNode } }).props?.children);
|
||||
}
|
||||
|
||||
/** 走一遍元素树,收集宿主节点。函数组件会被**直接调用**(`Button` / `PlatformGroupRow` /
|
||||
* `AccountIdRow` 都是无 hook 的纯呈现)—— 若往视图里塞 hook,这里会以 `Invalid hook call`
|
||||
* 报错,那是提醒你把手势逻辑留在无 hook 的那一层。 */
|
||||
function hostEls(node: ReactNode): HostEl[] {
|
||||
const out: HostEl[] = [];
|
||||
const walk = (n: ReactNode): void => {
|
||||
if (n === null || n === undefined || typeof n === "boolean") return;
|
||||
if (typeof n === "string" || typeof n === "number") return;
|
||||
if (Array.isArray(n)) {
|
||||
n.forEach(walk);
|
||||
return;
|
||||
}
|
||||
const el = n as { type?: unknown; props?: Record<string, unknown> };
|
||||
const props = el.props ?? {};
|
||||
if (typeof el.type === "function") {
|
||||
walk((el.type as (p: Record<string, unknown>) => ReactNode)(props));
|
||||
return;
|
||||
}
|
||||
if (typeof el.type === "string") out.push({ type: el.type, props });
|
||||
walk(props.children as ReactNode);
|
||||
};
|
||||
walk(node);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 必须存在的东西(不存在就报错,而不是把 undefined 一路带下去)。 */
|
||||
function must<T>(found: T | undefined, what: string): T {
|
||||
checks += 1;
|
||||
if (found === undefined) throw new Error(`FAILED: ${what}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
/** 界面的替身:草稿存在这里,每次操作都从草稿重新投影(= `Users.tsx` 那条接线)。 */
|
||||
class Harness {
|
||||
draft: OwnerAccount[];
|
||||
|
||||
constructor(accounts: OwnerAccount[]) {
|
||||
this.draft = accounts;
|
||||
}
|
||||
|
||||
/** 当前这一步的元素树(宿主节点)。 */
|
||||
tree(): HostEl[] {
|
||||
const groups = groupAccounts(this.draft);
|
||||
return hostEls(
|
||||
createElement(OwnerAccountsView, {
|
||||
groups,
|
||||
options: platformOptions(PUBLISHED, groups.map((g) => g.platform)),
|
||||
channels: null,
|
||||
disabled: false,
|
||||
onChange: (next: OwnerAccount[]) => {
|
||||
this.draft = next;
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** 界面上能看见的字(「提示语说了什么」这类断言靠它)。 */
|
||||
text(): string {
|
||||
return this.tree()
|
||||
.map((e) => textOf(e.props.children as ReactNode))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
/** 点标签为 `label` 的第 `n` 个按钮(找不到就报错,不静默放过)。 */
|
||||
click(label: string, n = 0): void {
|
||||
const hits = this.tree().filter(
|
||||
(e) => e.type === "button" && textOf(e.props.children as ReactNode) === label
|
||||
);
|
||||
const hit = must(hits[n], `界面上第 ${n + 1} 个「${label}」按钮(共找到 ${hits.length} 个)`);
|
||||
(hit.props.onClick as () => void)();
|
||||
}
|
||||
|
||||
/** 账号 ID 行数(每行带定位标记)。 */
|
||||
rows(): number {
|
||||
return this.tree().filter((e) => e.type === "div" && e.props["data-account-row"] !== undefined)
|
||||
.length;
|
||||
}
|
||||
|
||||
selects(): number {
|
||||
return this.tree().filter((e) => e.type === "select").length;
|
||||
}
|
||||
|
||||
inputs(): HostEl[] {
|
||||
return this.tree().filter((e) => e.type === "input" && e.props.value !== undefined);
|
||||
}
|
||||
|
||||
/** 在第 `n` 行输入框里打字(走它自己的 onChange)。 */
|
||||
type(n: number, value: string): void {
|
||||
const el = must(this.inputs()[n], `第 ${n + 1} 个账号 ID 输入框`);
|
||||
(el.props.onChange as (e: unknown) => void)({ target: { value } });
|
||||
}
|
||||
|
||||
/** 在第 `n` 块里选平台(走它自己的 onChange)。 */
|
||||
pickPlatform(n: number, platform: string): void {
|
||||
const el = must(this.tree().filter((e) => e.type === "select")[n], `第 ${n + 1} 块平台下拉`);
|
||||
(el.props.onChange as (e: unknown) => void)({ target: { value: platform } });
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
// 起点 = 主人现在的真实样子:qq-obv11 下已有一个账号,他想再加一个。
|
||||
const ui = new Harness([{ platform: "qq-obv11", accountId: "9616b27a" }]);
|
||||
same(ui.rows(), 1, "起点:该平台一行账号 ID");
|
||||
same(ui.inputs().length, 1, "起点:一个账号 ID 输入框");
|
||||
|
||||
ui.click("+ 添加账号 ID");
|
||||
same(ui.rows(), 2, "点「+ 添加账号 ID」⇒ 界面上真的多出一行(空行没在往返里蒸发)");
|
||||
same(
|
||||
ui.draft,
|
||||
[
|
||||
{ platform: "qq-obv11", accountId: "9616b27a" },
|
||||
{ platform: "qq-obv11", accountId: "" },
|
||||
],
|
||||
"新行真的进了草稿(空的、挂在同一平台下)—— 不是「点了没点」"
|
||||
);
|
||||
same(
|
||||
must(ui.inputs()[1], "新行输入框").props.value,
|
||||
"",
|
||||
"新行是一个可输入的空输入框(接着就能打字)"
|
||||
);
|
||||
|
||||
// 在新行里打字 ⇒ 值进草稿、界面仍是两行:这条往返得是**可用**的,不是只能加不能填。
|
||||
ui.type(1, "aaaa1111");
|
||||
same(
|
||||
ui.draft,
|
||||
[
|
||||
{ platform: "qq-obv11", accountId: "9616b27a" },
|
||||
{ platform: "qq-obv11", accountId: "aaaa1111" },
|
||||
],
|
||||
"在新行里打的字落进草稿"
|
||||
);
|
||||
same(ui.rows(), 2, "打字后仍是两行(已填的值没在往返里丢)");
|
||||
ok(!ui.text().includes("有 1 条空白账号 ID"), "两行都填了就不再报空白(提示不自相矛盾)");
|
||||
|
||||
// 空行的既有提示语义保留:加了行还没填时,界面明说「保存时会被忽略」,不是静默丢弃。
|
||||
const hinted = new Harness([{ platform: "qq-obv11", accountId: "9616b27a" }]);
|
||||
hinted.click("+ 添加账号 ID");
|
||||
ok(
|
||||
hinted.text().includes("有 1 条空白账号 ID:保存时会被忽略"),
|
||||
"新加的空行有显式提示(保存时会被忽略)"
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
// 「+ 添加平台」走的是同一条往返(任务 3):点一下必须真的多出一块、块里带一行可填。
|
||||
const blank = new Harness([]);
|
||||
same(blank.rows(), 0, "起点:一个平台都没有");
|
||||
blank.click("+ 添加平台");
|
||||
same(
|
||||
blank.draft,
|
||||
[{ platform: "", accountId: "" }],
|
||||
"点「+ 添加平台」⇒ 草稿里真的多出一个未选平台的空组(不是「点了没点」)"
|
||||
);
|
||||
same(blank.selects(), 1, "界面上真的多出一块平台(下拉)");
|
||||
same(blank.rows(), 1, "这块里带一行可填的账号 ID");
|
||||
|
||||
blank.pickPlatform(0, "kook");
|
||||
same(blank.draft, [{ platform: "kook", accountId: "" }], "在块里选平台会落到草稿(块没在往返里蒸发)");
|
||||
blank.click("+ 添加账号 ID");
|
||||
same(
|
||||
blank.draft,
|
||||
[
|
||||
{ platform: "kook", accountId: "" },
|
||||
{ platform: "kook", accountId: "" },
|
||||
],
|
||||
"选完平台再加一行:两行都挂在 kook 下"
|
||||
);
|
||||
same(blank.rows(), 2, "界面上两行");
|
||||
|
||||
// 已知边界(**扁平草稿的固有性质**,不是本次缺陷):第二块「未选平台」的组会和第一块并成一块
|
||||
// —— `groupAccounts` 按平台聚合,两个空平台同键。行不会丢,只是同块(有下拉可接着选平台)。
|
||||
const two = new Harness([]);
|
||||
two.click("+ 添加平台");
|
||||
two.click("+ 添加平台");
|
||||
same(two.rows(), 2, "连点两次「+ 添加平台」:两行都在(空平台两块并成一块)");
|
||||
same(two.selects(), 1, "空平台两块并成一块下拉");
|
||||
}
|
||||
|
||||
{
|
||||
// 「删除某平台唯一一行」= 撤掉该平台(平台选择随之消失)。这是**修前就有**的语义,本次不动,
|
||||
// 但在这里显式钉住:日后若被当成新缺陷、或被顺手改掉,这条会先红。
|
||||
const one = new Harness([{ platform: "kook", accountId: "42" }]);
|
||||
one.click("删除");
|
||||
same(one.draft, [], "删掉唯一一行 ⇒ 该平台块整体消失(既有语义)");
|
||||
same(one.rows(), 0, "界面上也不再有这一块");
|
||||
same(one.selects(), 0, "平台下拉也一并撤掉");
|
||||
}
|
||||
|
||||
// ⑤ 收尾:把「手势刚产生的草稿」接到「写盘形态」上。草稿留着空行(上面钉住了),那保存时
|
||||
// 就必须有人把它收干净 —— 后端 `Validate()`(internal/user/load.go)对「平台空 / 账号 id 空」
|
||||
// 这种半成品条目**直接 400**(主人看到「保存失败」),只有完全空的条目才是跳过。
|
||||
{
|
||||
const half = new Harness([]);
|
||||
half.click("+ 添加平台");
|
||||
half.type(0, "9616b27a"); // 只填了 id,平台还没来得及选
|
||||
same(half.draft, [{ platform: "", accountId: "9616b27a" }], "草稿里这一行留着(编辑期不丢行)");
|
||||
same(accountsForSave(half.draft), [], "写盘时收掉半成品条目(不发给后端 ⇒ 不会 400)");
|
||||
|
||||
const filled = new Harness([{ platform: "qq-obv11", accountId: "9616b27a" }]);
|
||||
filled.click("+ 添加账号 ID");
|
||||
same(
|
||||
accountsForSave(filled.draft),
|
||||
[{ platform: "qq-obv11", accountId: "9616b27a" }],
|
||||
"写盘时收掉空行:owner.yaml 只拿到填完的条目"
|
||||
);
|
||||
filled.type(1, " aaaa1111 "); // 手滑多打了空格
|
||||
same(filled.draft[1].accountId, " aaaa1111 ", "编辑期不改写正在输入的字(逐键 trim 会吃掉空格)");
|
||||
same(
|
||||
accountsForSave(filled.draft),
|
||||
[
|
||||
{ platform: "qq-obv11", accountId: "9616b27a" },
|
||||
{ platform: "qq-obv11", accountId: "aaaa1111" },
|
||||
],
|
||||
"写盘时去掉两侧空白(带空格的键永远匹配不上 actor 元数据)"
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`owner accounts editor test: ${checks} checks passed`);
|
||||
@@ -12,7 +12,13 @@ import type { AdminChannelRecord, EnumItem, OwnerAccount } from "../../lib/types
|
||||
* (`owner.IsOwner` 匹配不上 actor 元数据 ⇒ 主人被当陌生人,界面上没有任何提示)。
|
||||
* 平台改为权威枚举下拉 + 账号 id 可从该平台已配置账号里选,消灭这类静默失效。
|
||||
*
|
||||
* 本模块是纯函数(无 DOM、无请求),因此分组/展平/下拉选项/候选都能在 node 下逐条测。
|
||||
* 本模块是纯函数(无 DOM、无请求),因此分组/草稿/写盘形态/下拉选项/候选都能在 node 下逐条测。
|
||||
*
|
||||
* **两种扁平形态,别混用**(这是主人 2026-10-07 报的「添加账号 ID 按钮似乎没用」的根因所在):
|
||||
* ① **草稿**(`draftAccounts`,编辑期):逐行原样保留,空行/未选平台的组都在 ——
|
||||
* 界面手势产生的中间态**绝不能在往返中蒸发**;
|
||||
* ② **写盘形态**(`accountsForSave`,保存期):只留平台与账号 id 都非空的条目。
|
||||
* 把①错当②用(编辑期就走写盘形态),空行当场消失 ⇒ 点了等于没点,界面上还看不出原因。
|
||||
*/
|
||||
|
||||
/** 一个平台分组:界面上的一条。 */
|
||||
@@ -22,7 +28,7 @@ export interface OwnerAccountGroup {
|
||||
}
|
||||
|
||||
/** 把扁平数组按平台分组(保持首次出现顺序,同一平台的多条账号收进一组)。 */
|
||||
export function groupAccounts(accounts: OwnerAccount[]): OwnerAccountGroup[] {
|
||||
export function groupAccounts(accounts: readonly OwnerAccount[]): OwnerAccountGroup[] {
|
||||
const groups: OwnerAccountGroup[] = [];
|
||||
const byPlatform = new Map<string, OwnerAccountGroup>();
|
||||
for (const a of accounts) {
|
||||
@@ -38,10 +44,27 @@ export function groupAccounts(accounts: OwnerAccount[]): OwnerAccountGroup[] {
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** 展平成后端契约要的扁平数组。**平台与账号 id 都非空才写**:任一为空都匹配不上任何
|
||||
* actor 元数据(写了也是噪声,还会让主人误以为已配置)。空的账号行/空平台组被忽略,
|
||||
* 界面上有对应提示,不是静默丢弃。 */
|
||||
export function flattenAccounts(groups: OwnerAccountGroup[]): OwnerAccount[] {
|
||||
/** 组 → **草稿**(编辑期形态):逐行原样保留 —— 空行、以及「还没选平台」的组都在。
|
||||
*
|
||||
* 为什么必须保行:草稿是**界面手势的载体** —— 每次改动都走「草稿 → 分组 → 改 → 回草稿」,
|
||||
* 写回若有损(把空行丢掉),刚点出来的「+ 添加账号 ID」空行当场蒸发 ⇒ 点了等于没点
|
||||
* (主人 2026-10-07 报的正是这个)。空行只在**保存时**才丢(见 `accountsForSave`),
|
||||
* 界面上另有「有 N 条空白账号 ID:保存时会被忽略」的显式提示,不是静默丢弃。
|
||||
*
|
||||
* 另外:编辑期**不改写**主人正在输入的字(不 trim)。逐键 trim 会把还在输入的空白吃掉;
|
||||
* 规范化(去空白 + 丢空行)留到写盘那一步做。 */
|
||||
export function draftAccounts(groups: readonly OwnerAccountGroup[]): OwnerAccount[] {
|
||||
const out: OwnerAccount[] = [];
|
||||
for (const g of groups) {
|
||||
for (const accountId of g.accountIds) out.push({ platform: g.platform, accountId });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 展平成后端契约要的扁平数组(**写盘形态**)。**平台与账号 id 都非空才写**:任一为空都
|
||||
* 匹配不上任何 actor 元数据(写了也是噪声,还会让主人误以为已配置)。空的账号行/空平台组
|
||||
* 被忽略,界面上有对应提示,不是静默丢弃。 */
|
||||
export function flattenAccounts(groups: readonly OwnerAccountGroup[]): OwnerAccount[] {
|
||||
const out: OwnerAccount[] = [];
|
||||
for (const g of groups) {
|
||||
const platform = g.platform.trim();
|
||||
@@ -54,6 +77,16 @@ export function flattenAccounts(groups: OwnerAccountGroup[]): OwnerAccount[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 草稿 → 写盘形态(`PUT /api/v1/admin/owner-profile` 的 `accounts`,即
|
||||
* `configs/users/owner.yaml` 的条目)。**保存路径只走它**,别自己拼这两步。
|
||||
*
|
||||
* 编辑期草稿里允许有空行,但后端 `Validate()` 对「平台空、账号 id 非空」这种半成品条目
|
||||
* 直接 400(`internal/user/load.go`),完全空的条目则被跳过 —— 所以这一步是必须的,不是洁癖。
|
||||
* 去掉两侧空白同理:带空格的键永远匹配不上 actor 元数据。 */
|
||||
export function accountsForSave(accounts: readonly OwnerAccount[]): OwnerAccount[] {
|
||||
return flattenAccounts(groupAccounts(accounts));
|
||||
}
|
||||
|
||||
/** 下拉选项:后端权威枚举(`GET /api/v1/meta/enums` 的 `platforms`)在前,随后补上
|
||||
* 存量里出现过的平台 —— 未列出的旧值**原样保留**(display-raw:绝不下拉吃掉它,
|
||||
* 否则一次保存就把主人已经生效的配置删了)。 */
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// 「插件与能力」页按上游 server 分组的**纯逻辑**测试(DevDocs/audit/MCP工具按上游分组-2026-10-24.md)。
|
||||
//
|
||||
// 锁四件事:
|
||||
// ① **归属只看 origin,不看名字**:MCP 工具名里没有任何前缀(`mcp:` 是 KindMCP
|
||||
// 条目的注册键),所以判据必须是后端盖的章 —— 本测试给工具起一个带 `mcp:` 字样的
|
||||
// 名字但**无 origin**,它必须落进「宿主内置」;
|
||||
// ② **transport 不编**:后端没报告 transport 时标题省略括号,而不是假设 stdio;
|
||||
// ③ **空 server 不冒充内置**:报告了 mcp 来源却没说哪台 server 的工具,仍归 MCP 段
|
||||
// (标题写「未知 MCP server」),不能被并进「宿主内置」—— 那是不同的结论;
|
||||
// ④ **不得静默丢弃**:`kind:"mcp"` 登记条目与未知 kind(如 iot)都有专门的取值口。
|
||||
//
|
||||
// 项目没有前端测试框架,所以这是一个零依赖的 plain-node 检查(同目录既有手法):
|
||||
//
|
||||
// cd frontend/web \
|
||||
// && ./node_modules/.bin/esbuild src/admin/modules/pluginOrigin.test.ts \
|
||||
// --bundle --platform=node --format=cjs \
|
||||
// --outfile=/tmp/cyrene-plugin-origin.test.cjs \
|
||||
// && node /tmp/cyrene-plugin-origin.test.cjs
|
||||
//
|
||||
// `tsc --noEmit`(npm run build 的一部分)会对本文件做类型检查,因此它只从项目内导入、
|
||||
// 不 import node:*。
|
||||
|
||||
import type { AdminPluginRecord } from "../../lib/types";
|
||||
import {
|
||||
mcpServerEntries,
|
||||
originGroupTitle,
|
||||
otherCapabilityEntries,
|
||||
toolOriginBuckets,
|
||||
} from "./pluginOrigin";
|
||||
|
||||
let checks = 0;
|
||||
|
||||
function ok(cond: boolean, what: string): void {
|
||||
checks += 1;
|
||||
if (!cond) throw new Error(`FAILED: ${what}`);
|
||||
}
|
||||
|
||||
function same(got: unknown, want: unknown, what: string): void {
|
||||
const g = JSON.stringify(got);
|
||||
const w = JSON.stringify(want);
|
||||
ok(g === w, `${what} — got ${g}, want ${w}`);
|
||||
}
|
||||
|
||||
/** 造一条能力记录(origin 只在给定时出现,模拟后端 omitempty)。 */
|
||||
function rec(
|
||||
name: string,
|
||||
kind: string,
|
||||
origin?: { kind: string; server?: string; transport?: string },
|
||||
): AdminPluginRecord {
|
||||
const base: AdminPluginRecord = { name, kind, description: "", allowInGroup: false, allowInStranger: false };
|
||||
return origin ? { ...base, origin } : base;
|
||||
}
|
||||
|
||||
// ① 两个 server 各两把工具 + 两把内置工具(其中一把名字里带 mcp: 字样的假前缀)。
|
||||
const rows: AdminPluginRecord[] = [
|
||||
rec("read_file", "tool", { kind: "mcp", server: "fs", transport: "stdio" }),
|
||||
rec("write_file", "tool", { kind: "mcp", server: "fs", transport: "stdio" }),
|
||||
rec("search", "tool", { kind: "mcp", server: "github", transport: "http" }),
|
||||
rec("mcp:read_file", "tool"), // 名字像 MCP,但没有来源章 ⇒ 内置
|
||||
rec("web_search", "tool"),
|
||||
];
|
||||
|
||||
const buckets = toolOriginBuckets(rows);
|
||||
|
||||
// ① 归属只看 origin:两个 MCP 分节(按 server 名排序),其余归内置。
|
||||
same(
|
||||
buckets.mcp.map((g) => [g.server, g.transport, g.rows.map((r) => r.name)]),
|
||||
[
|
||||
["fs", "stdio", ["read_file", "write_file"]],
|
||||
["github", "http", ["search"]],
|
||||
],
|
||||
"MCP 工具按 origin.server 分节(按 server 名排序)",
|
||||
);
|
||||
same(
|
||||
buckets.builtin.map((r) => r.name),
|
||||
["mcp:read_file", "web_search"],
|
||||
"无 origin 的归宿主内置 —— 名字里带 mcp: 不算来源",
|
||||
);
|
||||
|
||||
// ② 标题:transport 有则带括号,无则省略(不编 stdio);计数是「把工具」。
|
||||
same(originGroupTitle(buckets.mcp[0]), "来自 fs(stdio)· 2 把工具", "带 transport 的组标题");
|
||||
same(
|
||||
originGroupTitle({ key: "mcp:x", server: "x", transport: "", rows: [rec("a", "tool")] }),
|
||||
"来自 x · 1 把工具",
|
||||
"未报告 transport 时省略括号,不编默认值",
|
||||
);
|
||||
|
||||
// ③ 报告了 mcp 却没说哪台 server:仍属 MCP 段,不得冒充内置。
|
||||
const anonymous = toolOriginBuckets([rec("orphan_tool", "tool", { kind: "mcp" }), rec("web_search", "tool")]);
|
||||
same(anonymous.mcp.length, 1, "缺 server 名的 MCP 工具仍是 MCP 段的一员");
|
||||
same(anonymous.mcp[0].server, "", "缺 server 名时 server 留空,不编一个名字");
|
||||
same(originGroupTitle(anonymous.mcp[0]), "来自 未知 MCP server · 1 把工具", "缺 server 名的标题如实写未知");
|
||||
same(anonymous.builtin.map((r) => r.name), ["web_search"], "内置桶只装真的没有 mcp 来源的工具");
|
||||
|
||||
// ④ 不得静默丢弃:KindMCP 登记条目与未知 kind 各有取值口。
|
||||
const mixed: AdminPluginRecord[] = [
|
||||
rec("read_file", "tool", { kind: "mcp", server: "fs", transport: "stdio" }),
|
||||
rec("mcp:fs", "mcp"),
|
||||
rec("kb_search", "skill"),
|
||||
rec("lamp", "iot"),
|
||||
rec("kook", "channel"),
|
||||
];
|
||||
same(mcpServerEntries(mixed).map((r) => r.name), ["mcp:fs"], "kind:mcp 登记条目要能被页面拿到");
|
||||
same(otherCapabilityEntries(mixed).map((r) => r.name), ["lamp"], "未知 kind(iot)不得被静默过滤");
|
||||
same(toolOriginBuckets(mixed).builtin.length, 0, "只有工具行进工具桶(技能/渠道/登记条目都不进)");
|
||||
|
||||
// 空输入不炸,且三个出口都是空数组而不是 undefined。
|
||||
const empty = toolOriginBuckets([]);
|
||||
same([empty.mcp.length, empty.builtin.length], [0, 0], "空清单得到空结果");
|
||||
|
||||
console.log(`pluginOrigin: ${checks} checks passed`);
|
||||
@@ -0,0 +1,93 @@
|
||||
// 「插件与能力」页的**来源分组**逻辑(纯函数、零 React 依赖)。
|
||||
//
|
||||
// 主人 2026-10-24 的要求:「能否将每个 MCP 服务器所承载的插件和工具按上游(来源)服务器
|
||||
// 分类并与其他插件相对隔离?」结论(DevDocs/audit/MCP工具按上游分组-2026-10-24.md):
|
||||
// 归属**只能来自注册时的元数据**(`origin`),不能解析工具名 —— MCP 工具名由 server
|
||||
// 自己给,宿主不加任何前缀(`mcp:` 是 KindMCP 条目的注册键,不是工具的命名空间)。
|
||||
//
|
||||
// 因此本模块只做三件事:
|
||||
// ① 把 `kind:"tool"` 的行按 `origin.server` 切成分节,无 MCP 来源的归「宿主内置」;
|
||||
// ② 生成分节标题(`来自 fs(stdio)· 12 把工具`)——transport 未报告时省略括号,
|
||||
// 绝不编一个默认值;
|
||||
// ③ 把不再是「工具/技能/渠道」行的事实摆出来(`mcp` 登记条目、其它未知 kind),
|
||||
// 供页面**如实显示**而不是静默丢弃(design/09 §5.2.1 红线③)。
|
||||
//
|
||||
// 这里判的是「呈现归属」,不涉及权限:工具能不能被调用仍只由 tool_policies 的逐工具
|
||||
// 布尔 + actor 元数据决定,分组既不收窄也不放宽任何权限。
|
||||
|
||||
import type { AdminPluginRecord } from "../../lib/types";
|
||||
|
||||
/** 一个上游 MCP server 承载的工具分节。 */
|
||||
export interface ToolOriginGroup {
|
||||
/** React key:同一 server 的所有工具共用一个。 */
|
||||
key: string;
|
||||
/** server 名(plugins.json / MCP_SERVERS_JSON 的 name);空串 = 报告了 MCP 却没说哪台。 */
|
||||
server: string;
|
||||
/** stdio / sse / http;空串 = 后端未报告。 */
|
||||
transport: string;
|
||||
rows: AdminPluginRecord[];
|
||||
}
|
||||
|
||||
/** 工具段的两级分节结果。 */
|
||||
export interface ToolOriginBuckets {
|
||||
/** 按上游 server 分节(按 server 名排序);只含来自 MCP 的工具。 */
|
||||
mcp: ToolOriginGroup[];
|
||||
/** 没有 MCP 来源的工具:宿主内置 / 来源未知。 */
|
||||
builtin: AdminPluginRecord[];
|
||||
}
|
||||
|
||||
/** 页面自己成段渲染的 kind;其余 kind 必须落到「其它能力」段,不得静默丢弃。 */
|
||||
export const RENDERED_KINDS = ["tool", "skill", "channel", "mcp"] as const;
|
||||
|
||||
/** record 是否来自一台 MCP server(判据是后端盖的来源章,不是名字)。 */
|
||||
export function isMCPOrigin(rec: AdminPluginRecord): boolean {
|
||||
return rec.origin?.kind === "mcp";
|
||||
}
|
||||
|
||||
/** 把工具行按上游 server 分节;无 MCP 来源的归「宿主内置」桶。
|
||||
*
|
||||
* 只收 `kind === "tool"` 的行:技能/渠道/MCP 登记条目由页面自己成段渲染(见
|
||||
* `RENDERED_KINDS` 与另两个取值口),它们**不是**工具、也不该被算进任何一个工具分节。
|
||||
* 传整份清单进来是安全的 —— 忽略 ≠ 丢弃,其余 kind 在页面上各有位置。 */
|
||||
export function toolOriginBuckets(rows: AdminPluginRecord[]): ToolOriginBuckets {
|
||||
const groups = new Map<string, ToolOriginGroup>();
|
||||
const builtin: AdminPluginRecord[] = [];
|
||||
for (const rec of rows) {
|
||||
if (rec.kind !== "tool") continue;
|
||||
if (!isMCPOrigin(rec)) {
|
||||
builtin.push(rec);
|
||||
continue;
|
||||
}
|
||||
const server = (rec.origin?.server ?? "").trim();
|
||||
const transport = (rec.origin?.transport ?? "").trim();
|
||||
const key = `mcp:${server}`;
|
||||
const group = groups.get(key);
|
||||
if (group) {
|
||||
group.rows.push(rec);
|
||||
// 同一 server 的传输形态应当一致;万一不一致,保留先到的那个而不是拼接,
|
||||
// 免得标题读成「stdio/sse」(拼接会让人以为一台 server 同时两种传输)。
|
||||
continue;
|
||||
}
|
||||
groups.set(key, { key, server, transport, rows: [rec] });
|
||||
}
|
||||
const mcp = [...groups.values()].sort((a, b) => (a.server < b.server ? -1 : a.server > b.server ? 1 : 0));
|
||||
builtin.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
||||
return { mcp, builtin };
|
||||
}
|
||||
|
||||
/** 分节标题:`来自 fs(stdio)· 12 把工具`;transport / server 缺失时如实省略。 */
|
||||
export function originGroupTitle(group: ToolOriginGroup): string {
|
||||
const count = `${group.rows.length} 把工具`;
|
||||
const server = group.server || "未知 MCP server";
|
||||
return group.transport ? `来自 ${server}(${group.transport})· ${count}` : `来自 ${server} · ${count}`;
|
||||
}
|
||||
|
||||
/** `kind:"mcp"` 的登记条目(每台 server 一条,名字是 `mcp:<server>`)。 */
|
||||
export function mcpServerEntries(rows: AdminPluginRecord[]): AdminPluginRecord[] {
|
||||
return rows.filter((rec) => rec.kind === "mcp");
|
||||
}
|
||||
|
||||
/** 既不是工具/技能/渠道、也不是 MCP 登记条目的能力(如 iot):必须如实显示。 */
|
||||
export function otherCapabilityEntries(rows: AdminPluginRecord[]): AdminPluginRecord[] {
|
||||
return rows.filter((rec) => !(RENDERED_KINDS as readonly string[]).includes(rec.kind));
|
||||
}
|
||||
@@ -6,8 +6,9 @@
|
||||
* ② 提交前先在本地跑必填校验(`schema/values.planSubmit`),拦下「填了却起不来」;
|
||||
* ③ viewer 一律看不到表单(后端已在快照里裁掉,这里是第二道闸)。
|
||||
*
|
||||
* 状态与规则在 `formState.ts`(三个 hook),字段呈现与说明文案在 `FormFields.tsx`;
|
||||
* 本文件只剩「拼装 + 提交按钮 + 反馈行」,因此每个函数都在 80 行以内。
|
||||
* 状态与规则在 `formState.ts`(值状态 + 提交两个 hook;候选由面板级拉好传进来),
|
||||
* 字段呈现与说明文案在 `FormFields.tsx`;本文件只剩「拼装 + 提交按钮 + 反馈行」,
|
||||
* 因此每个函数都在 80 行以内。
|
||||
*/
|
||||
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
@@ -17,10 +18,10 @@ import { useAuditStore } from "../audit";
|
||||
import { Notice } from "../components";
|
||||
import type { SchemaField } from "../schema/fieldTypes";
|
||||
import { FormFieldsGrid } from "./FormFields";
|
||||
import { useFieldOptions, useFormSubmit, useFormValues, type FormFlash } from "./formState";
|
||||
import { useFormSubmit, useFormValues, type FormFlash } from "./formState";
|
||||
import { canWrite } from "./nav";
|
||||
import { SectionHeader } from "./SectionHeader";
|
||||
import type { PanelFieldDto, PanelSectionDto } from "./types";
|
||||
import type { PanelFieldDto, PanelOptionDto, PanelSectionDto } from "./types";
|
||||
|
||||
/** 声明字段 → 通用 schema(`options` 由 `optionsFrom` 拉到后由栅格填进来)。 */
|
||||
export function panelFieldToSchema(f: PanelFieldDto): SchemaField {
|
||||
@@ -58,9 +59,22 @@ export interface PanelFormSectionProps {
|
||||
/** 行内动作「带回表单」的值(design/09 §5.2.1 示例的 reauth);`null` = 没有。
|
||||
* 只认显式 `values` / `formValues`(见 `types.formValuesOfResult`),不做形状猜测。 */
|
||||
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);
|
||||
// 依赖键用 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 state = useFormValues(fields, fieldsKey, external);
|
||||
const { options, optionErrors } = useFieldOptions(pluginId, section.fields ?? []);
|
||||
const action = section.submit?.action ?? "";
|
||||
const { submit, busy, flash } = useFormSubmit({ pluginId, action, fields, state, onReload, log });
|
||||
|
||||
|
||||
@@ -4,20 +4,24 @@
|
||||
* - `PluginPanelHost`(容器):从快照里找当前 URL 指向的面板,负责「加载中 / 取数失败 /
|
||||
* 没这个面板」三种状态。找不到就是找不到 —— 未启用、`when` 不满足、或该插件根本没
|
||||
* 声明这个页面,直接输 URL 也进不来(快照是按角色与 `when` 过滤后的结果);
|
||||
* - `PluginPanelView`(纯呈现):只吃 `panel` + `role`,因此可以在单测里直接渲染,
|
||||
* 不依赖路由、快照与浏览器环境(`--format=cjs` 的渲染测试就是打它)。
|
||||
* - `PluginPanelView`(呈现 + 取候选):只吃 `panel` + `role`,因此可以在单测里直接渲染,
|
||||
* 不依赖路由、快照与浏览器环境(`--format=cjs` 的渲染测试就是打它)。它另持一份
|
||||
* **下拉候选**状态(面板级只拉一次);服务端渲染不跑 effect ⇒ 测试里候选是空的,
|
||||
* 标签相关的断言请直接打 `PanelFormSection`(可显式传 `options`)或纯函数。
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button, EmptyState, Spinner, Tag } from "../../components/primitives";
|
||||
import type { Role } from "../../lib/types";
|
||||
import { Notice, PageHeader } from "../components";
|
||||
import { PanelFormSection } from "./FormSection";
|
||||
import { PanelLogSection, PanelStatusSection } from "./ReadOnlySections";
|
||||
import { PanelTableSection } from "./TableSection";
|
||||
import { useFieldOptions } from "./formState";
|
||||
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";
|
||||
|
||||
/** 区块分发(闭集):form / table / status / log 四种,未知类型如实说明而不是半渲染。 */
|
||||
@@ -28,6 +32,9 @@ function SectionBlock({
|
||||
onReload,
|
||||
formPrefill,
|
||||
onRowValues,
|
||||
options,
|
||||
optionErrors,
|
||||
optionLabels,
|
||||
}: {
|
||||
pluginId: string;
|
||||
section: PanelSectionDto;
|
||||
@@ -35,6 +42,9 @@ function SectionBlock({
|
||||
onReload: () => void;
|
||||
formPrefill: Record<string, unknown> | null;
|
||||
onRowValues?: (values: Record<string, unknown>) => void;
|
||||
options: Record<string, readonly PanelOptionDto[]>;
|
||||
optionErrors: Record<string, string>;
|
||||
optionLabels: ReadonlyMap<string, string>;
|
||||
}): ReactNode {
|
||||
switch (section.type) {
|
||||
case "form":
|
||||
@@ -45,6 +55,8 @@ function SectionBlock({
|
||||
role={role}
|
||||
onReload={onReload}
|
||||
prefill={formPrefill}
|
||||
options={options}
|
||||
optionErrors={optionErrors}
|
||||
/>
|
||||
);
|
||||
case "table":
|
||||
@@ -55,6 +67,7 @@ function SectionBlock({
|
||||
role={role}
|
||||
onReload={onReload}
|
||||
onValues={onRowValues}
|
||||
optionLabels={optionLabels}
|
||||
/>
|
||||
);
|
||||
case "status":
|
||||
@@ -75,16 +88,31 @@ export interface PluginPanelViewProps {
|
||||
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)在**这里**接线:表格返回一份
|
||||
* 显式值对象时把它交给表单。只在「恰好一个 form 区块」时转交 —— 两个表单就没有归属,
|
||||
* 宁可不填也不猜(猜错就是把 A 条的数据填进 B 条的表单)。 */
|
||||
* 宁可不填也不猜(猜错就是把 A 条的数据填进 B 条的表单)。
|
||||
*
|
||||
* 下拉候选也在**这里**拉一次(`useFieldOptions`)并分发给 form 与 table:表单要它渲染
|
||||
* 下拉,表格要它把行里的裸值翻成显示名。候选是 `{value,label}`,显示用 label、写回只用
|
||||
* value(见 `controls.tsx` 的 SelectControl / MultiSelectControl)。 */
|
||||
export function PluginPanelView({ panel, role, onReload }: PluginPanelViewProps): ReactNode {
|
||||
const [prefill, setPrefill] = useState<Record<string, unknown> | null>(null);
|
||||
const sections = viewerVisibleSections(panel, role);
|
||||
const formSections = sections.filter((s) => s.type === "form");
|
||||
const singleForm = formSections.length === 1 ? formSections[0].id : null;
|
||||
const { options, optionErrors } = useFieldOptions(panel.pluginId, declaredFieldsOf(panel));
|
||||
const optionLabels = useMemo(() => optionLabelIndex(options), [options]);
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
@@ -101,6 +129,9 @@ export function PluginPanelView({ panel, role, onReload }: PluginPanelViewProps)
|
||||
onReload={onReload}
|
||||
formPrefill={section.type === "form" && section.id === singleForm ? prefill : null}
|
||||
onRowValues={singleForm === null ? undefined : (values) => setPrefill(values)}
|
||||
options={options}
|
||||
optionErrors={optionErrors}
|
||||
optionLabels={optionLabels}
|
||||
/>
|
||||
))}
|
||||
{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`(含窗口化与空/错/加载三态),所以这里只做三件事:把列声明翻成
|
||||
* `AdminColumn`、把行内动作接上宿主动作端点、把反馈画出来(含 viewer 的只读说明)。
|
||||
* 状态机在 `tableState.ts`;纯助手 `cellText` / `rowIdentity` 从那里复出口(单测按
|
||||
* 本模块路径 import,换家不必改测试)。
|
||||
* 状态机在 `tableState.ts`;纯助手 `cellText` / `rowIdentity` / `cellDisplayText` 从那里
|
||||
* 复出口(单测按本模块路径 import,换家不必改测试)。
|
||||
*
|
||||
* 单元格的**中文显示名**:行里的 `provider` 这类裸值经 `cellDisplayText` 查一次
|
||||
* `optionLabels`(面板级候选,来自声明的 `optionsFrom`)⇒ 有标签显示标签、没有就原样显示值。
|
||||
* 查找**只影响显示**:行内动作照旧把原始 row(值为 id)送回后端,配置文件里永远不会出现标签。
|
||||
*
|
||||
* 对 viewer:后端快照已摘掉行内动作,这里**再摘一次**(纵深防御,见 `nav.canWrite`)。
|
||||
*/
|
||||
@@ -15,12 +19,23 @@ import { useAuditStore } from "../audit";
|
||||
import { AdminTable, Notice, type AdminColumn } from "../components";
|
||||
import { canWrite } from "./nav";
|
||||
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";
|
||||
|
||||
export { cellText, rowIdentity };
|
||||
export { cellText, rowIdentity, cellDisplayText };
|
||||
export type { PanelRow };
|
||||
|
||||
/** 没有候选时的空索引(常量:避免每次渲染新建 Map 造成列定义无谓变化)。 */
|
||||
const EMPTY_LABELS: ReadonlyMap<string, string> = new Map();
|
||||
|
||||
/** 反馈行:错误码与文案分两处(§5.4.6),成功/失败用不同色。 */
|
||||
function TableFlashRow({ flash }: { flash: TableFlash | null }): ReactNode {
|
||||
if (!flash) return null;
|
||||
@@ -41,19 +56,24 @@ export interface PanelTableSectionProps {
|
||||
onReload: () => void;
|
||||
/** 行内动作返回显式值对象时回调(reauth「把该条带回表单」,见 `formValuesOfResult`)。 */
|
||||
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 { rows, loading, error, load } = useTableRows(pluginId, section.source?.action ?? "");
|
||||
const { busy, flash, run } = useRowActionRunner({ pluginId, onReload, reloadRows: load, log, onValues });
|
||||
|
||||
const rowActions = section.rowActions ?? [];
|
||||
const canAct = canWrite(role) && rowActions.length > 0;
|
||||
const labels = optionLabels ?? EMPTY_LABELS;
|
||||
const columns: AdminColumn<PanelRow>[] = (section.columns ?? []).map((c) => ({
|
||||
key: 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) {
|
||||
columns.push({
|
||||
|
||||
@@ -97,7 +97,15 @@ export interface FieldOptionsState {
|
||||
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 {
|
||||
const [options, setOptions] = useState<Record<string, readonly PanelOptionDto[]>>({});
|
||||
const [optionErrors, setOptionErrors] = useState<Record<string, string>>({});
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { actionFailure } from "./actions";
|
||||
import { runPluginAction } from "./panelsApi";
|
||||
import { decodeRows, formValuesOfResult } from "./types";
|
||||
import { decodeRows, formValuesOfResult, type PanelOptionDto } from "./types";
|
||||
|
||||
export interface PanelRow {
|
||||
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 排序,
|
||||
* 裸下标的自然序("10" < "2")会把后端给的顺序打乱。 */
|
||||
export function rowIdentity(row: Record<string, unknown>, index: number): string {
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
* (AGENTS §10);这里每个控件都是「一个类型、十来行」,分发器留在 `fields.tsx`。
|
||||
*/
|
||||
|
||||
import { Switch } from "../../components/primitives";
|
||||
import { Button, Switch } from "../../components/primitives";
|
||||
import type { SchemaField, SchemaOption } from "./fieldTypes";
|
||||
import { fieldLabel } from "./values";
|
||||
import { fieldLabel, splitListValue, toggleListOption } from "./values";
|
||||
|
||||
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)]";
|
||||
@@ -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 {
|
||||
const opts = options ?? field.options ?? [];
|
||||
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 {
|
||||
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 (
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
aria-label={fieldLabel(field)}
|
||||
className={invalid ? inputClsError : inputCls}
|
||||
/>
|
||||
<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
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
disabled={disabled}
|
||||
aria-label={fieldLabel(field)}
|
||||
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 的哨兵语义)。
|
||||
*/
|
||||
|
||||
import type { SchemaField, SchemaFieldType } from "./fieldTypes";
|
||||
import type { SchemaField, SchemaFieldType, SchemaOption } from "./fieldTypes";
|
||||
|
||||
/** 表单值一律以字符串承载(与渠道配置表单同一口径):控件层只认 string,
|
||||
* 提交时再按声明的 type 还原成后端要的类型。 */
|
||||
@@ -29,6 +29,24 @@ export function fieldLabel(f: SchemaField): string {
|
||||
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)。 */
|
||||
export function isSensitive(f: SchemaField): boolean {
|
||||
return f.type === "secret";
|
||||
@@ -129,7 +147,7 @@ export function parseFieldValue(f: SchemaField, raw: string): { ok: true; value:
|
||||
case "switch":
|
||||
return { ok: true, value: text === "true" };
|
||||
case "multiSelect":
|
||||
return { ok: true, value: text.split(/[,,\n]/).map((s) => s.trim()).filter(Boolean) };
|
||||
return { ok: true, value: splitListValue(text) };
|
||||
case "kv": {
|
||||
let parsed: unknown;
|
||||
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 { Icon } from "../Icon";
|
||||
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
|
||||
@@ -13,6 +13,14 @@ import { renderContent } from "../../lib/renderContent";
|
||||
* 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 {
|
||||
return (
|
||||
<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));
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
switch (mode) {
|
||||
case "code": return <CodeMessageBody message={message} />;
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
* admin.ts /admin/* CRUD (personas, users, llm, rag, iot, automations…)
|
||||
* adminChannels.ts /admin/channels/* (adapters, config, platform views, login)
|
||||
* 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.
|
||||
*/
|
||||
@@ -141,3 +142,25 @@ export type {
|
||||
AdminSelfWakeGate,
|
||||
AdminSelfWakeStatus,
|
||||
} 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
|
||||
* 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
|
||||
* 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 {
|
||||
name: string;
|
||||
resolved?: string;
|
||||
accountId?: string;
|
||||
connected: boolean;
|
||||
pending?: boolean;
|
||||
ready?: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** 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 { 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
|
||||
* `splitBubble` and shown as attachment notes outside the bubble. */
|
||||
export function renderContent(content: string): ReactNode[] {
|
||||
/** 正文渲染口径(两种界面形态各一套,别混用):
|
||||
* · 缺省 / `lineBreaks:false` = **chat 气泡正文** —— `<br>` 是气泡分隔符,在上一层
|
||||
* (`components/chat.tsx` 的 `splitParagraphs`)就切成独立气泡了;这里绝不把 `<br>`
|
||||
* 变成换行(等于把「多条消息」偷偷还原成「一条消息里的换行」,与主人口径冲突)。
|
||||
* · `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)
|
||||
.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/configFacts";
|
||||
export * from "./types/memoryTidy";
|
||||
export * from "./types/memoryTidyApply";
|
||||
export * from "./types/channelEvents";
|
||||
@@ -8,6 +8,17 @@ export interface MemoryRecord {
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** 一条能力的上游来源(GET /api/v1/admin/plugins 的可选 `origin` 键,契约 v0.83)。
|
||||
*
|
||||
* 它回答「这条能力来自哪台上游 server」,来自**注册时的元数据**——绝不是从
|
||||
* `name` 里解析前缀得来的(MCP 工具名里没有任何前缀)。宿主内置能力的该键
|
||||
* **整个缺席**(不是 `{}`),前端据此把它们归「宿主内置」。 */
|
||||
export interface CapabilityOrigin {
|
||||
kind: string;
|
||||
server?: string;
|
||||
transport?: string;
|
||||
}
|
||||
|
||||
/** A capability/plugin as returned by GET /api/v1/admin/plugins. */
|
||||
export interface AdminPluginRecord {
|
||||
name: string;
|
||||
@@ -15,6 +26,8 @@ export interface AdminPluginRecord {
|
||||
description: string;
|
||||
allowInGroup: boolean;
|
||||
allowInStranger: boolean;
|
||||
/** 上游来源;缺席 = 宿主内置 / 来源未知(老后端同样缺席)。 */
|
||||
origin?: CapabilityOrigin;
|
||||
}
|
||||
|
||||
/** One runtime-installed plugin (GET /api/v1/admin/plugins/installed, v0.28).
|
||||
@@ -40,6 +53,19 @@ export interface InstalledPlugin {
|
||||
export interface AdminChannelRecord {
|
||||
name: string;
|
||||
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). */
|
||||
accounts?: AdminChannelAccount[];
|
||||
}
|
||||
@@ -80,6 +106,12 @@ export interface AdminChannelAccount {
|
||||
* 缺省(undefined)= **状态未知**(该渠道没有独立账号状态面,或尚未探到结果),
|
||||
* 面板画「账号状态未知」,**绝不**画成「账号离线」。 */
|
||||
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).
|
||||
@@ -338,6 +370,13 @@ export interface OwnerProfile {
|
||||
defaultPersona: string;
|
||||
identityFile: string;
|
||||
notes: string;
|
||||
/**
|
||||
* Channel account whitelist. While the panel is being edited this array is a **draft**: a row may
|
||||
* legitimately be blank (`accountId: ""`, or no platform picked yet) because every UI gesture
|
||||
* round-trips through it — dropping blank rows here makes a freshly added row disappear, i.e. the
|
||||
* button looks dead. Only the save path narrows it (`accountsForSave`), so owner.yaml and the PUT
|
||||
* contract keep carrying entries with both `platform` and `accountId` set.
|
||||
*/
|
||||
accounts: OwnerAccount[];
|
||||
}
|
||||
export interface OwnerAccount {
|
||||
|
||||
@@ -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"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// cdpCallTimeout 是单条 CDP 命令的兜底超时(handshake / 读超时)。正常调用还会被
|
||||
@@ -30,8 +33,28 @@ type cdpError struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// Error 实现 error 接口。
|
||||
func (e *cdpError) Error() string { return fmt.Sprintf("CDP %d: %s", e.Code, e.Message) }
|
||||
// Error 渲染协议错误(实现 error 接口)。
|
||||
//
|
||||
// 🔴 Message 是**上游自由文本**:Chrome 与各类兼容实现可以往里面放任何东西 —— 包括
|
||||
// 它刚被要求操作的表达式、地址或页面内容。原样进错误等于把那段内容抄进日志与模型
|
||||
// 可见视图,因此默认**不出镜**:
|
||||
// - 只有像协议枚举的短串(trace.ErrorToken:≤64 字节、仅 [A-Za-z0-9_.-])才保留
|
||||
// —— 真正的枚举(context_not_found 之类)能过,散文与回显一律过不了;
|
||||
// - 其余只留**长度**(字节数),够判断「对端给了句话」而不带那句话。
|
||||
//
|
||||
// 方法名由调用点补齐(cdpClient.call / Call 的 %w 文案),诊断面并不只剩一个码。
|
||||
func (e *cdpError) Error() string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "CDP %d", e.Code)
|
||||
switch token := trace.ErrorToken(e.Message); {
|
||||
case token != "":
|
||||
b.WriteString(": ")
|
||||
b.WriteString(token)
|
||||
case e.Message != "":
|
||||
fmt.Fprintf(&b, " (message %d bytes withheld)", len(e.Message))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// cdpClient 是最小 CDP 客户端:一条 WebSocket、一个读协程、按 id 关联的待答表。
|
||||
//
|
||||
|
||||
@@ -3,6 +3,7 @@ package browser
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -105,11 +106,23 @@ func TestCDPClient_CallRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCDPClient_ProtocolErrorIsWrapped 钉住协议错误被 %w 包成 ErrCDP(上层据此
|
||||
// 判定「连接级问题 ⇒ 丢会话重起」)。
|
||||
func TestCDPClient_ProtocolErrorIsWrapped(t *testing.T) {
|
||||
// TestCDPClient_ProtocolErrorKeepsNoFreeText 钉住两件事:协议错误被 %w 包成 ErrCDP
|
||||
// (上层据此判定「连接级问题 ⇒ 丢会话重起」),而 CDP 的 error.message **不出镜**。
|
||||
//
|
||||
// 为什么 message 不能出镜:它是**上游自由文本** —— Chrome 与各类兼容实现可以往里面
|
||||
// 放它刚被要求操作的东西(表达式、地址、页面内容)。只有像协议枚举的短串
|
||||
// (trace.ErrorToken)才保留,其余只留字节数。
|
||||
//
|
||||
// 夹具刻意用**带空格的散文 + 会被回显的页面文本**:真正的 CDP message 就长这样
|
||||
// ("Cannot find context with specified id"),而像令牌的单字("boom")本来就会过
|
||||
// 守卫 —— 用例不该拿它当「自由文本」。
|
||||
//
|
||||
// 变异判据:把 cdpError.Error() 换回 `fmt.Sprintf("CDP %d: %s", e.Code, e.Message)`
|
||||
// ⇒ 哨兵文本出现在错误串里,本用例红。
|
||||
func TestCDPClient_ProtocolErrorKeepsNoFreeText(t *testing.T) {
|
||||
const message = "Cannot resolve context for <script>PAGE-ECHO-SECRET</script>"
|
||||
f := newFakeCDP(t, func(string, map[string]any) (any, *cdpError) {
|
||||
return nil, &cdpError{Code: -32000, Message: "boom"}
|
||||
return nil, &cdpError{Code: -32000, Message: message}
|
||||
})
|
||||
wsURL, _ := pageTargetWS(context.Background(), f.base)
|
||||
c, err := newCDPClient(context.Background(), wsURL)
|
||||
@@ -118,14 +131,47 @@ func TestCDPClient_ProtocolErrorIsWrapped(t *testing.T) {
|
||||
}
|
||||
defer func() { _ = c.Close() }()
|
||||
err = c.Call(context.Background(), "Runtime.evaluate", nil, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "boom") {
|
||||
t.Fatalf("Call 的错误 = %v,应含 CDP 的 message", err)
|
||||
if err == nil {
|
||||
t.Fatal("协议错误应当返回 error")
|
||||
}
|
||||
if strings.Contains(err.Error(), "PAGE-ECHO-SECRET") {
|
||||
t.Fatalf("CDP 的自由文本进了错误串:%v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "CDP -32000") {
|
||||
t.Errorf("错误没带上协议码: %v", err)
|
||||
}
|
||||
if want := fmt.Sprintf("message %d bytes withheld", len(message)); !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("错误没带上被扣下文本的长度(期望 %q): %v", want, err)
|
||||
}
|
||||
// 方法名是代码给的名字,必须仍在 —— 扣掉 message 之后诊断面靠它定位。
|
||||
if !strings.Contains(err.Error(), "Runtime.evaluate") {
|
||||
t.Errorf("错误丢了方法名: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "CDP 调用失败") {
|
||||
t.Errorf("错误没带上 ErrCDP 文案: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCDPClient_ProtocolErrorKeepsProtocolToken 是「协议枚举仍可读」的正向证据:
|
||||
// 取值像令牌(≤64 字节、仅 [A-Za-z0-9_.-])的 message 原样保留。
|
||||
//
|
||||
// 变异判据:把 cdpError.Error() 改成「message 一律不带」⇒ 本用例红。
|
||||
func TestCDPClient_ProtocolErrorKeepsProtocolToken(t *testing.T) {
|
||||
f := newFakeCDP(t, func(string, map[string]any) (any, *cdpError) {
|
||||
return nil, &cdpError{Code: -32602, Message: "context_not_found"}
|
||||
})
|
||||
wsURL, _ := pageTargetWS(context.Background(), f.base)
|
||||
c, err := newCDPClient(context.Background(), wsURL)
|
||||
if err != nil {
|
||||
t.Fatalf("newCDPClient: %v", err)
|
||||
}
|
||||
defer func() { _ = c.Close() }()
|
||||
err = c.Call(context.Background(), "Runtime.evaluate", nil, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "context_not_found") {
|
||||
t.Fatalf("协议枚举形状的 message 应保留:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCDPClient_ContextCancelStopsWaiting 钉住「取消能立刻返回」,不是等对端回话。
|
||||
func TestCDPClient_ContextCancelStopsWaiting(t *testing.T) {
|
||||
f := newFakeCDP(t, func(string, map[string]any) (any, *cdpError) {
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package browser
|
||||
|
||||
// 本文件钉住「浏览器自己说的话」这条自由文本出口的两条约束 —— 既**有界**(600 rune +
|
||||
// 12 行)又**打码**(凭据形状不外泄),而且两者都**不许**伤到排障信息:普通原话必须仍然
|
||||
// 看得见(这是 launch_test / adopt_test 既有的「失败要如实报」口径)。
|
||||
//
|
||||
// 「删实现能打红」的落点:把 preview() 里的 chromeOutputPreview 调用退回原样直吐,本文件
|
||||
// 的凭据断言(以及日志读面那条)会全红。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// TestChromeOutputPreviewRedactsCredentials 是这条出口的**核心断言**:浏览器把带凭据的
|
||||
// 地址/请求头打进 stderr 时,出镜的预览里只有 [redacted],没有凭据本体;同时排障用的
|
||||
// 普通原话一个字都不许少。
|
||||
func TestChromeOutputPreviewRedactsCredentials(t *testing.T) {
|
||||
t.Parallel()
|
||||
const (
|
||||
complaint = "ERROR:process_singleton.cc(343)] 参数被拒: --remote-allow-origins=*"
|
||||
credURL = "ERROR:navigation] 载入失败 https://example.com/cb?access_token=sk-live-ABCDEFGH12345"
|
||||
bearer = "WARNING:net] 请求头 Authorization: Bearer abcdefghij1234567890"
|
||||
)
|
||||
var out chromeOutput
|
||||
for _, line := range []string{complaint, credURL, bearer} {
|
||||
if _, err := out.Write([]byte(line + "\n")); err != nil {
|
||||
t.Fatalf("chromeOutput.Write 永不返回错误: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
got := out.preview()
|
||||
t.Logf("实测预览=%q", got)
|
||||
|
||||
for _, secret := range []string{"sk-live-ABCDEFGH12345", "abcdefghij1234567890", "access_token="} {
|
||||
if strings.Contains(got, secret) {
|
||||
t.Errorf("凭据形状 %q 原样出镜了: %q", secret, got)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(got, "[redacted]") {
|
||||
t.Errorf("凭据应被打成 [redacted](看得见的打码,不是静默删除): %q", got)
|
||||
}
|
||||
// 反面:打码不能顺手把「浏览器为什么拒绝」一起吃掉 —— 那正是这条出口存在的理由。
|
||||
for _, want := range []string{complaint, "载入失败", "请求头 Authorization"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("排障用的普通原话 %q 不该被洗掉: %q", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestChromeOutputPreviewIsBounded 钉住上限的两半:正文被截到 chromeOutputPreviewRunes
|
||||
// 并留 `…`;而「其实说了多少字节」这条**我们自己的**说明必须活着(上限只压正文,不吃
|
||||
// 量级)。没有它,读者会把半截输出误当成完整输出。
|
||||
func TestChromeOutputPreviewIsBounded(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("单行超长只留上限并标截断", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var out chromeOutput
|
||||
if _, err := out.Write([]byte(strings.Repeat("A", 4000) + "\n")); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
got := out.preview()
|
||||
if !strings.HasSuffix(got, "…") {
|
||||
t.Errorf("超上限必须留可见的截断标记: 长度=%d 尾部=%q", utf8.RuneCountInString(got), tailRunes(got, 8))
|
||||
}
|
||||
if n := utf8.RuneCountInString(got); n != chromeOutputPreviewRunes+1 {
|
||||
t.Errorf("正文应正好截到 %d rune + 一个 `…`,实际 %d rune", chromeOutputPreviewRunes, n)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("字节截断时仍报总量", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var out chromeOutput
|
||||
// 5000 字节 > chromeOutputMaxBytes(4096)⇒ 缓存本身就被截,preview 必须点明总量。
|
||||
if _, err := out.Write([]byte(strings.Repeat("B", 5000))); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
got := out.preview()
|
||||
if !strings.Contains(got, "已截断,共 5000 字节") {
|
||||
t.Errorf("字节被截时必须报出真实总量(不能拿留下的那部分当全部): %q", tailRunes(got, 40))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestDial_ChromeOutputOnFailureIsRedacted 走真实失败路径(假浏览器 exit 0 ⇒
|
||||
// profileBusyHint + withChromeOutput),断言**拼接后的整条**(hint 那半 + 浏览器原话那半)
|
||||
// 在用户侧可见的错误串与失败日志里都没有凭据形状,且两半的信息都还在。
|
||||
func TestDial_ChromeOutputOnFailureIsRedacted(t *testing.T) {
|
||||
t.Parallel()
|
||||
const (
|
||||
complaint = "不支持的标记: --remote-allow-origins=*"
|
||||
credLine = "载入失败 https://example.com/cb?access_token=sk-live-ABCDEFGH12345"
|
||||
bearerLine = "请求头 Authorization: Bearer abcdefghij1234567890"
|
||||
)
|
||||
log, buf := newCapturedLog(slog.LevelDebug)
|
||||
l := newFakeChrome(t, fakeBrowserScript{
|
||||
Stderr: []string{complaint, credLine, bearerLine}, ExitCode: 0,
|
||||
}, 5*time.Second, log)
|
||||
|
||||
_, stop, err := l.Dial(context.Background())
|
||||
if stop != nil {
|
||||
t.Error("失败路径不该交出停止函数")
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("进程 exit 0 却不写端口记录 ⇒ 必须报错")
|
||||
}
|
||||
if !errors.Is(err, ErrLaunch) {
|
||||
t.Fatalf("仍须包住 ErrLaunch: %v", err)
|
||||
}
|
||||
msg := err.Error()
|
||||
logs := buf.String()
|
||||
t.Logf("实测 error=%v\n实测日志=%s", err, logs)
|
||||
|
||||
for _, secret := range []string{"sk-live-ABCDEFGH12345", "abcdefghij1234567890", "access_token="} {
|
||||
if strings.Contains(msg, secret) {
|
||||
t.Errorf("返回给调用方的错误里出现了凭据形状 %q(用户侧工具结果直接来自这条串): %v", secret, err)
|
||||
}
|
||||
if strings.Contains(logs, secret) {
|
||||
t.Errorf("失败日志里出现了凭据形状 %q(密钥不落日志):\n%s", secret, logs)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(msg, "[redacted]") {
|
||||
t.Errorf("打码应当看得见([redacted]): %v", err)
|
||||
}
|
||||
|
||||
// 两半都得活着:hint 那半(可操作建议 + profileDir 路径)不能被上限截掉,
|
||||
// 浏览器原话那半是这条出口存在的理由。
|
||||
for _, want := range []string{
|
||||
"绝不杀别人的浏览器", l.profileDir,
|
||||
complaint, "载入失败",
|
||||
} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("整条错误里缺少 %q(过度打码/截断): %v", want, err)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(logs, complaint) {
|
||||
t.Errorf("失败日志里丢了浏览器原话 %q(排障信息):\n%s", complaint, logs)
|
||||
}
|
||||
}
|
||||
|
||||
// tailRunes 返回 s 的末 n 个 rune,供失败断言打印尾部而不是把 4000 字符灌进报告。
|
||||
func tailRunes(s string, n int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= n {
|
||||
return s
|
||||
}
|
||||
return string(r[len(r)-n:])
|
||||
}
|
||||
@@ -162,7 +162,7 @@ func runCompareOnce(t *testing.T, rawURL string, g compareGroup, repeat int, run
|
||||
|
||||
rec := compareRecord{
|
||||
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)
|
||||
Driver: "cyrene-go-cdp", ChromeVersion: chromeVersionOf(t, s.endpoint),
|
||||
UserDataDir: profile, Headless: g.Headless,
|
||||
|
||||
@@ -20,9 +20,12 @@
|
||||
//
|
||||
// 红线(design/28 §3/§4、AGENTS §9/§10):
|
||||
//
|
||||
// - **不新开对外监听端口**:调试端口由 Chromium 自己绑在 `127.0.0.1` 的**随机**
|
||||
// 端口(`--remote-debugging-port=0`,端口从 profile 目录的 `DevToolsActivePort`
|
||||
// 读回),我们是**出站连接方**;容器路径同理(我们连它)。
|
||||
// - **不新开对外监听端口**:调试端口是**我们选号、Chromium 自己绑在 `127.0.0.1`** 的
|
||||
// 显式端口(`--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 工作树内
|
||||
// (`browsercfg.EnsureProfileDir`),内容绝不进上下文与长期记忆。
|
||||
// - **截图可能含凭据/二维码**:文件 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