Author SHA1 Message Date
AskaEth 6c30ca540a fix(memory): 冷启动放宽改为一次性 + 试算轮区分「没候选」与「向量不可用」
review round1 的 p1/p2 收口(commit 5967272 之后)。

p1:旧实现 `warmed` 只在 2xx 之后才翻 ⇒ 注释里写的「只在第一次成功响应之前」
实际是「服务坏着的全部时间」。reviewer 实测「接受连接但永不回包」时**每次**调用
都等满 30s(fail-fast 变 fail-slow):审查 20s 预算被一次吃光、索引写入拖成
8×30s、开机探针阻塞 30s。

- `warmupLeft` 改为**一次性许可**(CompareAndSwap 取走即失效,成败都算花掉)
  ⇒ 挂死时只有第一个请求慢一次,此后一律 DefaultRequestTimeout。
- `warmupTimeout` 30s → 8s:与常态 5s 同一量级(≤2×),且严格小于审查总预算
  20s(一次冷启动不许吃光整轮);两条界都由 TestDefaultRequestTimeout 守住。
- 放宽被花掉且没成功时留一条 WARN(事后可复核「第一个请求」这个判据)。
- 更正两处自述:api.go 的「不会被 30s 拖住」与 budget.go 的不变式说明(批次形状
  永远按稳态超时推导;冷启动只放宽第一个请求的截止时刻)。
- p2:删掉零调用者的 `WithRequestTimeout` —— 单次超时只允许改常量,批次上限与它
  同源并由编译期/常量断言复核。

p2:试算轮在向量源不可用时 `RecallMode` 与「没有候选可比」同为空串。
- `recallLedger` 增 `unavailable` 计数 + `noteUnavailable()`(strictRecall 失败
  那一笔记一笔);试算汇总行新增 `recall_unavailable_partitions`,**成败都记**。
- 不扩 `recallMode` 取值集合:失败那一路不序列化 result(响应是
  500 tidy_recall_unavailable),新取值只会出现在日志里却要动一次契约,不划算。

测试:三种失败形态 × 两次调用的耗时断言(连接被拒 / 500 / 永不回包),
试算轮的「不可用」与「没候选」两态日志断言;两处变异自证(warmupTimeout 调回
30s ⇒ TestDefaultRequestTimeout 红;去掉 noteUnavailable ⇒ 试算汇总行退化成
recall_mode="" + recall_unavailable_partitions=0)。
2026-10-13 20:51:10 +08:00
AskaEth 59672720bd fix(memory): embedding 批上限与超时同源 —— 不再「客户端先放弃、服务端继续算」
M9(EMBRECALL)。故障机制(M6 + 本次复测):客户端 5s 单次超时先放弃,服务端仍在
算那一批 ⇒ 本机 llama.cpp 被自己顶住 ⇒ 后续每个请求(含写向量索引的单条请求)
排队到自己的超时 ⇒ 召回全线降级。

- embedding/budget.go(新):把「一次请求的耗时上界 ≤ 单次超时的一半」写成不变式,
  批上限(条数 4 / 字符 480)与 5s 超时由**同一个不等式**解出;实测依据与复跑命令
  写进注释(空闲态摊销曲线 1→0.19s / 8→1.1s / 12→1.3s / 64→8.5s,约 50ms 固定 +
  2.7~3.9ms/字符,n≥32 起有数倍放大 ⇒ 余量取一半)。
- embedding/api.go:Embed 按同一对上限**自己切片**(不变式与调用方无关);冷启动
  首次调用单独给 warmupTimeout,拿到第一个成功响应后立刻回常态超时。
- memory/tidy_config.go:tidyEmbedBatch = embedding.DefaultBatchLimit(同源),
  并加编译期断言 —— 调回 64 会直接编译失败(constant -60 overflows uint)。
- memory/tidy.go | tidy_recall.go | tidy_dryrun.go:recall_mode 改报**实际发生**
  的来源(""=本轮没候选可比 / embedding / lexical / mixed),新增
  recall_degraded_partitions;试算轮 RecallMode 同口径(M6 medium finding)。
- 测试:embedding 侧用「实测曲线 + 会睡够时间的假客户端」钉住两态
  (批内 < 超时 ⇒ 不降级;旧配对 64 条 ⇒ 必然超时),并断言 abandoned == 0;
  memory 侧钉住降级/健康/没候选三态与试算轮的如实上报。

不改 preinsert*、不改 .env、不动判定规则与任何阈值。
2026-10-13 20:33:26 +08:00
AskaEth 68ba85389b Merge branch 'feat/memgate-read-side-expiry-gate-for-memory' into dev 2026-10-13 19:59:40 +08:00
AskaEth 0c5fa7b2ae docs(memory): timegate.go 登记「审查留痕读侧不过闸」—— 留痕表结构上无该三列,无可加之闸 2026-10-13 19:54:36 +08:00
AskaEth 9a3b3cff8b feat(memory): 读侧时效闸(四条列表读路径)+ Add 落 expires_at 列
主人裁定「临时记忆到期删除 / 任何调用记忆的地方都要正常调用到」的读侧
第 ① 步(隐藏):design/03 §9.6 / §11.2 的时效闸下沉到共享底层,不写在
任何调用点。

- timegate.go(新):timeGateClause = (expires_at = '' OR expires_at > ?)
  AND (valid_to = '' OR valid_to > ?);timeGateNow() 与列值同源(同一个
  formatTime 的定宽 30 字节 UTC 文本),expiryText() 负责写入侧编码。
- Store.ListByPersona / List / ListScope / ListProject 四条列表读路径
  的 WHERE 在既有 status 闸之后拼上时效闸;Retriever.visibleCandidates
  组合前两条,检索入口因此自动继承同一判据(无第二处判据)。
- Store.Get 与整理/审查的内部召回 SQL 刻意不过闸,理由写在各自注释里
  (listTidyPartitionItemsSQL 原位注释在本切片;listReviewCandidatesSQL
  的原位注释按塔裁定改派 M2,timegate.go 头注登记了这条拆分)。
- Store.Add 的 INSERT 补上 expires_at 列(MemoryItem.ExpiresAt):Add 是
  全仓唯一 INSERT 路径,时效列的真生产者只能在这里,否则闸退化成空壳。
- timegate_test.go(新):过期/失效行在四条路径与 visibleCandidates 上
  都不可见、空值与未来值可见、Get 仍能读过期行、Add 真的落库该列;
  外加时间口径同源钉(文本序==时间序 + 跨格式 'T' vs ' ' 陷阱实证)。

验收:go build ./internal/runtime/... 通过;go vet 通过;go test
./internal/runtime/memory/... 全绿;internal 全包 62 个 ok / 0 FAIL;
GOOS=darwin GOARCH=arm64 交叉构建通过。
2026-10-13 19:54:19 +08:00
AskaEth ae68bb0241 docs(api): 契约 v0.90 —— 渠道连接态三态(可选 pending/ready/reason + connected 语义收紧)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
登记 POST /admin/channels/open|close 响应与 GET /admin/channels 平台行/账号行新增的三个可选键,并写明 connected 由「动作被受理」纠正为「真实连接状态」这一行为变更(老客户端若只看 2xx 会继续骗人)。新增 §2.4(d) 字段级说明;C19/C20 两行标注;计数不变(14 + 25 + 40 = 79)。
2026-10-13 19:41:14 +08:00
AskaEth b4d55f412e fix(channel): 连接动作不再乐观 —— 适配器自报连接态三态 + 面板如实呈现
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
根因:adminChannelOpen 在 ch.Connect 返回 nil 后硬编码 connected=true。而 Connect 返回 nil 只说明「这次调用没有致命失败」—— ws+client 首连失败刻意非致命(OneBot 实现可能比 Cyrene 后启动),ws+server 则只需监听就绪,两种情况 IsConnected() 都是 false。前端又只按「请求成功」写死「已连接」,于是管理面把「后端受理了」说成「渠道连上了」。

改法:三态口径落在适配器自己身上(connected/pending/ready,internal/channel/adapter 的纯函数 ConnectStateOf 判定 + ConnectSignals 结构化入参,避免两个 bool 接反),qq_obv11 实现 ConnectOutcome 并记住脱敏后的失败原因;网关经类型断言探测(与 accountStatusAware/rescanAware 同套路,核心 sdk.Channel 契约刻意不扩展),只做映射不猜 transport/mode。

线上投影:open/close 响应与 GET /admin/channels 的平台行/账号行各新增 pending/ready/reason 三个可选键(零值即整键缺席,同 NeedsRescan/LastError 口径);既有四键逐字不变,未实现该状态面的平台输出逐字节不变。close 报真实 IsConnected(),断开也不乐观。

凭据边界:reason 三层脱敏(形状级 → 已知明文替换 → 剥 access_token= 查询参数),日志同样只用脱敏串。

验收:主会话亲自复跑 go build/vet 全绿、全树 go test 无 FAIL、目标三包 test 全绿、前端 tsc --noEmit 干净、esbuild 逻辑测试 34+46 checks 全过;主会话亲自变异摘掉第 3 层脱敏实现,发现原两条脱敏用例其实钉不住它(值恰好等于已知明文/被形状级顺带吃掉)⇒ 补一条钉住它独有窗口的用例(短值+紧邻空白),变异下必红、还原后绿。
2026-10-13 19:38:56 +08:00
AskaEth d81635672e fix(channel): 热重载把新适配器写回能力注册表
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
面板保存渠道配置后 Bridge.Reload 只重建了桥接器自己的适配器表,能力注册表里的条目仍指向保存前的旧实例;而管理面的渠道列表、每账号连接状态、open/close 的目标解析全部读注册表 —— 于是卡片永远「未连接」,点「连接」驱动的是旧实例(按旧 URL 拨号)。

生产日志实证:保存后 bridge 侧新实例连上 ws://localhost:3001 且心跳正常,而点连接时被驱动的适配器拨的是旧地址 ws://localhost:10301。

改法:注册表新增 upsert 语义的 ReplaceChannel(覆盖 Value、保留运行期 Policy;Register 遇重名会 ErrDuplicate,热重载必然同名);桥接器经窄端口在适配器上线后同步,账号被移除时删除条目;组合根注入 a.caps 包装。同步失败只记日志、不中断 Reload;未接线时保持旧行为并只 WARN 一次。

验收:go build ./... + go vet 全绿;registry/channelbridge/gateway 三包 test 全绿;主会话亲自变异(摘掉 Policy 沿用 ⇒ TestReplaceChannelSwapsValueAndKeepsPolicy 红)。linux/darwin 交叉编译通过。
2026-10-13 19:21:49 +08:00
AskaEth 23b6e3fe1e feat(memory): 启动时自动播种面板配置文件(缺失才写、绝不覆盖)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
主人 2026-10-07:「后端自动创建独立的面板配置文件要做」。

- 启动路径(wireMemoryTidy → applyMemoryTidyTuning 之后)若
  $DATA_DIR/config/memory-tidy.json 不存在或全空白 => 写一份「当时生效值」
  (十键:既有六键 + 频率四键),此后面板接管
- 已存在(非空)=> 跳过,该分支没有任何写盘语句;损坏 => WARN + 按 .env 跑、
  一个字不写盘(不「修好它」)
- 幂等(第二次启动必落到跳过分支);原子写(tmp 0600 + rename)
- 落盘前完整性闸:缺键 / 非正周期 / 钟点越界 => WARN + 不落盘
- 值来自 TidyTuningSnapshot(先跑两个归一化再翻回正向键)=> 播种那一刻运行
  行为零变化,有强断言(归一化后配置逐字相等)
- 零新增依赖、零 env 键变更;5 个变异(含「让已存在走写盘」=> 红)
2026-10-07 20:38:37 +08:00
AskaEth d680390300 feat(memory): 整理频率走面板配置(面板 > env > 默认)+ 分时段动态间隔
主人 2026-10-07:「.env 只做兜底…优先从面板配置上取」+「频率必须调…或者动态
分配,比如白天间隔短一点 晚上到了静默时期稍微长一点」。

- memory-tidy.json 新增四键:intervalSec / quietIntervalSec / quietStartHour /
  quietEndHour(*int + 指针判定 => 显式 0 是合法值、缺位才回落)
- 优先级逐键三档:面板 > env(既有键,未增删)> 代码默认;坏值不覆盖 + WARN,
  且不启动失败
- 动态间隔由纯函数 intervalAt(at) 决定,调度侧每一拍现算 => 跑完按当时时段重算
- 🔴 语义变更(有意,代码注释已登记,待补设计修订):安静时段从「唯一可跑时段」
  变成「降频时段」—— 旧的「安静窗外一律 continue」被删,否则 intervalSec 永远
  是空壳(白天唤醒必被跳过);窗口仍决定档位与补跑判据
- 6 个变异(含「加回旧的那道门」=> 端到端用例红)
2026-10-07 19:21:32 +08:00
AskaEth 6deab2e836 fix(io): knowledge 工具不再接受「以他角色身份检索」(红线越权读)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
design/03 §7.1 写死:跨角色读只有一条通路(把 B 当子代理派出去查);
「自己的检索能拿到 B 的角色日常记忆 = 已经越权(不是灰区)」。

缺陷:resolvePersona 让显式 persona 参数优先且零校验 ⇒ 模型(或提示词注入)
传 persona=B 即走 ListByPersona(B),读到 B 的角色日常记忆。

- 角色改为只能来自当前回合(tool.TurnPersonaFrom(ctx));显式 persona 非空
  且不等于本回合角色 ⇒ 明确拒绝(可消化:原因 + 改道 + 不许重复调用)
- 选「严格校验」而非「删参数」:删参数会让模型静默改用本回合角色,把 A 的
  记忆当成 B 的(归属错认)且毫无信号;删键亦属对外可见的 schema 变化
- 工具 schema 键集合与必填项不变(只改 description 文案)⇒ 契约零变化
- 子代理通路未破(子代理回合的 TurnPersonaFrom 本来就是 B,已加用例钉住)
- 顺带核对:FilterSharedVisible 仍在,受限环境过滤未因地塌陷

点名改掉两条「依赖缺陷行为」的既有用例:
- TestKnowledge_ExplicitPersonaWins(原断言把「模型点名即换角色」写成规格)
- TestKnowledge_ReturnsResults(机械依赖,只改调用形状,断言一字未动)
2026-10-07 19:17:07 +08:00
AskaEth 811e463cea refactor(web): chat.tsx 551 行按职责拆成 5 片(守 500 行铁律,零行为变化)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- chat/index.tsx 272 行(编排层:SplitChatBubble + MessageBubble + 各气泡)
- chat/toolviz.tsx 112(工具卡 / 思维链可视件)
- chat/labels.tsx 70(元数据小字)
- chat/chrome.tsx 60(外壳判定与注记)
- chat/split.ts 51(纯函数:br 分隔与段落切分,最易测)
- 旧 chat.tsx 删除;调用方一行未动(沿用既有目录索引解析,与 messages/ 同一模式)

纯搬家证据:把旧文件与新 5 文件做行多重集 diff,差异只有 export 前缀、
一行 re-export(ThinkingBlock / ToolCard)与 import 重写 —— 没有一行
JSX / 样式类名 / 逻辑变化;测试文件一字未改。
对外导出名与形状逐字不变(仍是 ThinkingBlock / ToolCard / MessageBubble / SplitChatBubble)。

验收:tsc 0、render 189 checks、bubble 27 checks、四个邻接套件全绿、
vite build 294 模块;变异(拆后去掉 br 空段过滤 => ⑦ 红)证明测试仍在真测那些逻辑。
2026-10-07 18:56:30 +08:00
AskaEth 789e3af74e fix(multimodal): 视觉工具的空/畸形 data: URI 明确拒绝 + 入参嗅探与可行动报错
根因(从 checkpoints 取到的那一轮 tool_call 实际 ARGS 逐字节核实):
模型交给工具的 image 参数是 "data:image/png;base64," —— 只有前缀、
载荷为空(全长 22 字符)。两个加载器早就会剥 data: 前缀,但把空载荷
解码成零字节后照样挂上合法 MIME 放行:
  image-crop → 零字节进 image.Decode ⇒ image: unknown format
  vision     → DataURI("image/png", nil) 发给上游 ⇒ 400 unsupported image
(模型复述不出入参里那条 58583 字符的 data: URI,只抄下了前缀。)

- 新增 checkInlinePayload:data: URI 解码后做内容嗅探(复用既有裁决)
- 空载荷 / 缺 ;base64 段 / 缺逗号 / 非法 base64 ⇒ 明确拒绝
- 报错含「不是图片」+ 嗅到的类型 + 支持格式 + 十六进制物证 + 可行动提示,
  且不回显正文或 base64
- 裸 base64、http(s) URL、工具 schema、安全策略一律未动
- 测试:新增 16 个用例;全树 61 包 ok;4 个变异(含停用嗅探 ⇒ 5 用例红)
2026-10-07 18:50:45 +08:00
AskaEth da1d23dd23 docs(契约): 补两条口径 —— 非 chat 模式(单卡正文)与 br 不分隔的位置
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 非 chat 模式(工作/代码/文档/首页/视觉/语音/智能体/设置):br 是真换行、
  不切分;行内白名单照常出样式;白名单外逐字原样;不许出现字面标签。
  各模式既有较强排版约定优先(工作模式按行切清单、代码模式 pre 块)
- br 只在普通文本层分隔:角色注记内部与白名单行内元素(尤其 code)内部
  都不分隔 —— 前者会把注记正文漏成聊天气泡,后者会破坏成对元素平衡、
  触发整段降级反而露出裸标签
2026-10-07 18:45:08 +08:00
AskaEth f01fd7db20 feat(web): u 加回白名单 + 空气泡丢弃 + 非 chat 模式不再显示字面标签
主人 2026-10-07 三点裁定:「下划线确实可以保留」「多余的空气泡确实可以丢掉」
「非聊天模式 按照应当有的专业卡片和格式来显示」。

- 白名单回到六项(u 加回,s 仍在外)
- 空 p 块也丢(与 br 碎片同一口径);纯注记回复 ⇒ 零气泡
- 非 chat 模式(8 个单卡正文)给它自己的渲染口径 CARD_RENDER:
  br ⇒ 真换行元素(那些容器没有 whitespace-pre-wrap,文本换行会被 CSS 折叠)、
  行内白名单正常出样式;WorkMessageBody 的按行切清单、CodeMessageBody 的 pre
  等既有较强约定一律保留
- 🔴 发现并修了一个会炸的下游损失:空气泡原本是承载头像/思维链/工具卡/心情/
  「生成中…」的那一行,丢气泡会把这些用户可见内容一起丢 ⇒ 加 hasBubbleChrome,
  补一行无正文外壳
- 测试 99 -> 189 checks;6 个变异(含「外壳行摘掉 ⇒ 工具卡丢失」)
- 改掉一条既有断言:三个 p 中首段被注记剥空 ⇒ 气泡数由 3 改为 2(并补按行数断言)
2026-10-07 18:44:45 +08:00
AskaEth 1107b2a717 docs(契约): 行内白名单定为六项(加回 u,s 仍不在内)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-07 18:37:42 +08:00
AskaEth 74c9f2d8df feat(channelbridge): u 加回 IM 出站的行内白名单(改为六项)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
主人 2026-10-07 裁定「下划线确实可以保留」。

- 行内白名单:b / strong / i / em / code / u(六项)
- s 仍在外(逐字原样保留,含大写 <S>)—— 有一个守门变异专门锁这条
- 行为改动只有一行(case 列表加 "u"),其余是注释与测试同步
2026-10-07 18:37:35 +08:00
AskaEth a19d12b228 feat(markup): br 改为气泡/消息分隔符 + 行内白名单收窄到五项(Web + IM 两侧)
主人 2026-10-07:「对消息内的 <br> 始终处理为多条消息隔断」。

Web 侧(frontend/web):
- chat.tsx 的 splitParagraphs(SplitChatBubble 的切分层)新增 BR_SEPARATOR_RE
  与 splitBubbleBreaks:<br>/<br/>/</br> 等价、属性不解析、<brow>/<br2> 挡掉、
  流式半截 <br 不切;不含 <br> 的段逐字返回原样(既有 p 口径一字节不动)
- inlineMarkup.ts 删掉 break 节点(br 不再是行内元素),白名单收窄为
  b/strong/i/em/code 五项;renderContent.tsx 同步;测试 76 -> 99 checks
- 40 条基线逐字节对照:28 IDENTICAL,12 个 DIFF 恰是含 br/u/s/<BR> 的样例

IM 侧(internal/services/channelbridge):
- 新增 inline_tags.go:成对平衡的白名单行内标签剥标签留文本、白名单外与
  结构不平衡整段原样保留;br 作为第二级分隔符交给 splitBubbles
- 两级切分:p 块为第一级,块内按 br 再切为第二级;空气泡丢弃
- br 切分放在角色注记摘除之后:否则 action 里的 br 会把注记正文漏成聊天气泡
  (变异专门钉住这条顺序)

两路各 4 个自带变异均「改坏 -> 编译过 -> 变红 -> 还原 -> 复绿」。
2026-10-07 17:32:05 +08:00
AskaEth 6ac214366e docs(契约): br 改为「气泡/消息分隔符」(主人口径),行内白名单收窄到五项
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
主人 2026-10-07:「对消息内的 <br> 始终处理为多条消息隔断」。

- br 语义与 p 同级:两边文本成为两条独立消息(IM)/ 两个独立气泡(Web),
  而不是同一气泡内的换行;<br> / <br/> / </br> 三种写法等价
- 行内白名单据此收窄为五项(b/strong/i/em/code),br 移出白名单
- 明确影响面:含 br 的回复在 IM 上会变成多条消息(受既有气泡间隔节流)
- 标注「实现跟进中」:Web 端 2dae469 当时把 br 当行内换行、IM 出站当时
  连行内标签都还没剥,两侧都在改;以契约为准,不以当日实现为准
- IM 出口那节补一句:br 不按行内标签处理,按切分走
2026-10-07 17:20:47 +08:00
AskaEth d27c1d4e33 docs(契约): v0.89 修订 —— 白名单收窄六项 + 边界行为降为实现细节 + 补 IM 出口口径
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
与安卓(客户端)会话对齐后的三处修订:

- 白名单八项 -> 六项(去掉 u / s):它们在她 100 条输出里 0 命中,
  不值得让各端为它们返工;各端可额外容错,但契约只承诺六项
- 结构不平衡的粒度(整段降级 vs 只坏那一对保持字面)降为实现细节:
  契约只承诺「不产生行内渲染 + 字符一个不丢」;这类输入极罕见,
  为它让任一端返工不划算
- 新增 IM 出口口径:成对且平衡的白名单标签 -> 剥标签留文本;
  br -> 换行;白名单外与不平衡 -> 原样保留(逐字不丢)。
  与 action / narration 的「整体剥除」语义不同(那是角色扮演标记)

大小写口径(Web 不敏感 / 安卓只认小写)留待各端自定,已与安卓协商。
2026-10-07 17:19:48 +08:00
AskaEth b989f0bb0c docs(契约): v0.89 登记助手消息的行内标签白名单
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
依据:主人 2026-10-07 要求前端支持这类标签的基本渲染(Web 端实现已推库
2dae469,真机产物已一致)。

- 白名单:b/strong、i/em、code、u、s(成对)+ br(void),大小写不敏感
- 三条硬口径:白名单外按纯文本原样显示(字符一个不丢)/ 属性一律不解析 /
  结构不平衡(未闭合、交错)整段降级为原文
- 安全红线:助手文本是不可信内容,任何端都不许整体当 HTML 注入;
  正确姿势是解析成结构化节点、交给各自的安全渲染原语
- 范围克制:不做块级(段落仍走 §4.16 的 p 机制)、不做 a、不做 Markdown
- 服务端零变更:无新端点 / 帧 / 字段,计数仍 14 + 25 + 40 = 79
2026-10-07 17:15:32 +08:00
AskaEth 2dae4693be feat(web): 消息渲染支持行内标签白名单(修 <b> 等标签裸露显示)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
主人 2026-10-07:「她在聊天消息中插入了 html/md 标签,能否增加网页前端
对此类标签的基本渲染支持?」

实测现场(最近 12 条 assistant 消息):<p> 96 次、<action> 34 次
(这两个前端已支持),<b> 6 次(未支持 => 裸标签直接显示给用户);
Markdown 记号 0 个 => 她不用 Markdown,所以不引 Markdown 引擎。

- 新增 inlineMarkup.ts(纯逻辑,111 行):把文本切成「文本 / br / 行内元素」
  的节点树;渲染与解析分离,不拼 HTML
- renderContent.tsx 沿用既有出入口,把节点树映射成 React 元素;
  既有 <p> / <action> 机制原样不动
- 白名单:b / strong / i / em / code / u / s(成对)+ br(void),
  标签名大小写不敏感;不做块级、不做 a(独立安全面)、不做 Markdown
- 安全:全程无 dangerouslySetInnerHTML(文本走 React 子节点,默认转义);
  属性一律丢弃(节点类型里没有属性位);白名单外 => 纯文本字面量;
  结构不平衡(未闭合/交错)=> 整段降级为原文,字符一个不丢
- 测试 76 条断言(esbuild + node,零新依赖);含 3 条安全性质
- 无回归:改动前后 11 个消息样例过真实 SplitChatBubble 链路逐字节对照,
  7 个 SAME,4 个 DIFF 恰是含白名单标签的样例
2026-10-07 17:11:37 +08:00
AskaEth a684328b76 feat(memory): T1 加保守全局速率限流(防每天数百到上千次调用失控)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
主人 2026-10-07 裁定:按最优解加全局限流。

- 新增 t1RateGate(滑动窗口、可注入时钟,~70 行);默认
  120 次 / 60 分钟(现网 700-1000 条新记忆/天 ≈ 25-42/小时,
  取 120 留约 3-5 倍余量)
- 过闸摆在「t1Enabled + 有灰区候选」两个守卫之后、发请求之前
  ⇒ 不调模型就不占额度;放行才记账
- 超限语义与既有失败路径同源:跳过 T1、决定仍是 create、零改写,
  只多一个 reason=t1_throttled 进留痕 + ReviewStats.T1Throttled 计数
- 配置键 t1PerWindow / t1WindowSec 放 $DATA_DIR/config/memory-tidy.json
  (零新 env 键,启动期读一次);坏配置回落默认 + WARN,不启动失败
- 每窗口只记一条 WARN(超限是预期行为,一天几百条会淹掉真故障)
- IngestRuleVersion 仍为 d3.2:判定规则集与解析契约没变,变的只是
  额度状态,reason 列已可分辨(升版会有 §17.6 记过的幂等键重复行副作用)

自研而非复用 session.NewRateLimiter 的理由(design/12 §N C.5 自己登记过
这条取舍):既有件 Allow() 内部写死 time.Now(),没有时钟注入缝,
而本切片必须能注入可控时钟;且复用会把 channel 层概念拖进 memory 包。
2026-10-07 16:25:54 +08:00
AskaEth 987cefdf28 fix(embedding): 端点地址规范化 + 独立短超时(治入库审查整条超时)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
背景:入库前审查链路一直整条超时(日志每 ~20s 一条 WARN),异步那层
(含刚装好的 T1)从未成功跑过。

- 新增 endpoint.go:把「连接语义上不可靠」的回环别名规范化成 127.0.0.1
  (localhost 大小写不敏感 / 0.0.0.0 / 空主机),只替换 Host,不碰
  path / query / fragment / userinfo;显式 [::1] 与真实主机名逐字不动
- Embed 加独立短超时 DefaultRequestTimeout = 5s(远小于审查 20s 预算):
  卡住的调用快速失败并走既有「降级到词项余弦」的路径,把预算留给 T1
- .env.example 示例地址 localhost -> 127.0.0.1(未注释行键数 69 -> 69,无增删)
- 跨包测试钉住 5s 严格小于审查总预算

诚实边界(子代理实测,推翻了主代理原先的 IPv6 根因推断):
- Go 客户端走 localhost 实测约 57ms、curl 约 0.2s ⇒「localhost -> ::1
  等满 20s 预算」这条链不成立;更可能是单槽 llama.cpp 的吞吐/争用
  (一批 64 条约 2.1~2.9s,审查 201 条约 8~10s,再叠 T1 8s 顶满预算)
- 规范化在任何环境下都无害,5s 快速失败对这个真因对症;但真因
  (容量/争用)需要另排切片,已记入工作小结
2026-10-07 16:22:28 +08:00
AskaEth a4efbffe60 docs(架构): 插件开端口的四条护栏转为「已确认采纳」
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
主人 2026-10-07:「其他几个选项按你的最优解来」⇒ 四条护栏正式采纳,
去掉 AGENTS.md §9 与契约 v0.88 里的「待主人逐条确认」字样。

护栏内容不变:显式配置 / 默认绑 127.0.0.1 / 日志与审计 / 不得绕过身份判定。
2026-10-07 16:16:29 +08:00
AskaEth cc566354fa chore(scripts): embedding 服务改回只绑 127.0.0.1(0.0.0.0 实测无益且有暴露面)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
主人 2026-10-07 最终口径:改回 127.0.0.1。

一个来回的结论登记进脚本注释,免得以后有人再试一遍:
- 0.0.0.0 是 IPv4 通配、不覆盖 IPv6(实测 netstat 只有
  TCP 0.0.0.0:11434 LISTENING,http://[::1]:11434 仍失败)
  ⇒ 它修不了「客户端把 localhost 解析成 ::1 而卡住」那个故障
- llama-server 没有鉴权 ⇒ 绑 0.0.0.0 等于向整个局域网暴露本机 embedding 算力
- EMBED_HOST 保留为可配置项(默认 127.0.0.1),将来真需要对外再打开
2026-10-07 16:16:07 +08:00
AskaEth 71438f666c chore(scripts): embedding 服务改绑 0.0.0.0(主人口径),host 提成 EMBED_HOST
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
主人 2026-10-07:让服务监听 0.0.0.0。

- --host 由写死 127.0.0.1 改为可配置 EMBED_HOST,默认 0.0.0.0
- 实测结论登记进脚本注释:0.0.0.0 是 IPv4 通配、不覆盖 IPv6
  (netstat 只有 TCP 0.0.0.0:11434 LISTENING;http://[::1]:11434 仍失败)
  ⇒ 它修不了「客户端把 localhost 解析成 ::1 而卡住」那个故障;
  客户端侧仍必须用 127.0.0.1(见 internal/runtime/embedding)
- 同时登记暴露面事实:llama-server 没有鉴权,绑 0.0.0.0 等于让局域网内
  任何设备都能白嫖这台机器的 embedding 算力;设 EMBED_HOST=127.0.0.1
  即可收回(脚本已留这条退路)
2026-10-07 16:14:17 +08:00
AskaEth 1b4d0a552f feat(memory): T1 灰区模型判定 —— D3 入库前审查补上 LLM 那一半
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
主人 2026-10-07 裁定「可以做 T1」。此前 D3 只有纯规则 T0,凡灰区(相似但
互不包含)一律保守 create,真机上「同一事实换措辞」类重复结构性治不住
(清完 92 条字面级重复后,active 里含「喵喵七号」的仍有 101 条)。

- 插入点:T0 判定内核只多吐一份结构化 Gray(惰性收集,恒零模型调用);
  T1 挂在异步审查层 Review,同步入库闸不碰它 ⇒ 写入路径零模型调用是
  **数据流结构**而不是承诺
- 只许回 merge / modify / keep + 一句理由;坏 JSON、非法枚举、编号越界
  一律回落 create(绝不猜、绝不挑最近的一条)
- 禁产新内容:模型只给「动作 + 候选编号 + 一句理由」,正文一律取自库里
  既有的两行 ⇒ T1 结构性地不可能把幻觉写进记忆库
- 不越硬门与红线:灰区已滤掉跨角色/作用域/项目/只读来源与时效/数值/冲突/
  低置信;「规则否决一票否决」不可走样,T1 不提供翻案入口
- 有界:每次审查至多一次调用(灰区打包,上限 8 条),子超时 8s 且不超过
  审查总预算;失败只改 reason、决定恒 create、零 UPDATE
- 开关 t1Judge(放 $DATA_DIR/config/memory-tidy.json,零新 env 键),默认开;
  模型出口用闭包晚绑定既有模型栈(零新客户端、零新依赖)
- 留痕 reason 结构化(t1_merge / t1_modify / t1_keep / t1_reply_invalid /
  t1_unavailable),rule_version d3.1 → d3.2;模型的自由文本理由一律丢弃
- preinsert_config.go 为纯搬移(为守 500 行上限,无新逻辑)
2026-10-07 14:10:33 +08:00
AskaEth 5b8c7a3ef3 docs(架构): 澄清单端口红线只约束框架自身,扩展插件可开端口
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
主人 2026-10-07 口径补充:原先把这条红线读成「任何组件都不许开端口」过宽了。

- AGENTS.md §9:红线加限定语(只约束 Cyrene 框架自身);qq-obv11 那条已登记
  例外升格为一般规则的实例;新增插件开端口的四条护栏(显式配置 / 默认绑
  127.0.0.1 / 日志与审计 / 不得绕过身份判定)—— 护栏由主代理拟,待主人逐条确认
- Docs/api-client.md v0.88:澄清「插件监听端口不在契约范围内」;零破坏性变更,
  无新端点 / 无新帧 / 无字段形状变化,路由计数不变(14 + 25 + 40 = 79)
- README.md:用户侧表述同步

DevDocs 侧同步(不进 git):design/13 §0、design/09 §5、design/08 §4、恢复提示词红线节。
2026-10-07 14:09:01 +08:00
AskaEth 33ea7a830f style(search): 修 zhipu.go 的 gofmt 对齐(改动前即存在的格式债)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
spec 字面量的三行对齐空格多余,gofmt -l 一直报它。零语义变更:
只删多余空格,gofmt -l 现已全干净。
2026-10-07 13:42:58 +08:00
AskaEth ce9af42945 feat(search): 新增腾讯云·元宝搜索与秘塔搜索两家 provider
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
接入位表六家扩到八家,端点形状与错误码一字未改(契约 v0.87,仅两处名单)。

- tencent_wsa:POST {base}/SearchPro + Authorization Bearer,零签名。
  两条官方口径决定实现:HTTP 状态码恒 200(判失败只能读 Response.Error.Code);
  Pages 是「JSON 字符串数组」需二次解析。错误码按官方表分类
  (UnauthorizedOperation/AuthFailure.* = 凭据、ResourceUnavailable = 欠费、
  RequestLimitExceeded* = 限流),文案只带 code 不带上游 Message。
- metaso:POST {base}/api/v1/search,响应 schema 仅第三方来源且互相打架,
  实现走宽松解析(数组候选与字段名回退),逐处标「未实测」。
- 零新 env 键:两家只走 $DATA_DIR/config/plugins/web-search.json,
  .env / .env.example 未动;组合根 websearch_providers.go 零功能改动。
- 契约 v0.87:values.provider 合法闭集与 plugin.providers 候选各加两条,
  计数不变(14 + 25 + 40 = 79)。
2026-10-07 13:42:41 +08:00
AskaEth 8e3311cea2 test(cyrene): 补同步入库闸的接线断言(切片连带的漏提交)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 断言组合根真的接了闸的开关:a.memoryIngestGateOff() 与 DisableIngestGate: gateOff
- 断言 store 侧唯一写路径先过闸(不是只投异步队列)
(前一片 90f4302 提交时漏了这个测试文件,工作树因此挂着一处未提交改动;现补齐。)
2026-10-07 13:25:35 +08:00
AskaEth 90f430216a feat(memory): D3 从「异步后置」改成「同步入库闸」—— 由整理层决定入库形态
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
主人 2026-10-07 裁定:「把记忆整理功能接到昔涟和记忆库中间做审查,由整理功能
进行入库」。现状是**异步 + 入库后软合并** ⇒ 行已经落库才审,面板/召回先脏后清,
而写入速率实测 ~25 条/小时(≈600/天)⇒ 清理永远追不上。

落法(三条硬规格,全部落地):
① **同步闸但有界**:闸在 `store.go:247` 的 `planIngest`(INSERT **之前**),
   本体 `preinsert_gate.go`。墙钟预算 **150ms**(实测最坏 p95 仅 4~7ms ⇒ 20~50 倍
   余量)、候选扫描上限 **100**(比异步侧 200 更紧,因为它在写路径上)。
   🔴 **零远端 embedding**:闸恒用词项余弦(`deps.Embedder` 根本不参与)——
   远端 embedding 一次 2.3~3.0s 且对同文本返回不同向量(不可复现),不许摆在同步
   路径上拍板。
② **fail-open,绝不丢记忆**:候选源报错 ⇒ `gate:candidates_unavailable`;超预算 ⇒
   `gate:budget_exceeded` ⇒ **照常 insert(active) + 投既有队列 + 留痕 + WARN**。
③ **三态落库(落 merged,而不是"不插入")**:确定性命中(字面重复 / 归一化等价 /
   真包含)⇒ INSERT **直接带 `status='merged'` + `merged_into=<存活行>`**,存活行
   `merged_from` 追加、`merge_count`+1 ⇒ **新行从不进 active、从不建向量索引**,
   而**行与原文都在表里 ⇒ revert 无损**。若"干脆不插",那条文本从未存在过行,
   revert 无从下手,违反 design/23 §8 的红线。
- **不与异步 D3 重复审**:闸做决定的那条 `Async=false`(留痕由闸自己落,
  审计动作名 `memory.ingest.gate`);真新/拿不准的走原有队列(留痕仍归异步层)。
- **开关**:`$DATA_DIR/config/memory-tidy.json` 的 `{"ingestGate": false}` ⇒ 一键回到
  「只插不审」(= 改动前的异步行为)。**零新 env 键**(`.env` 未碰)。
- 顺手把 `store.go` 的行映射拆到 `store_rows.go`(守 §10 的 ≤500 行)。

验收(主会话复跑 + 亲自变异):`go build ./...` + `go vet` 绿;memory 包测试绿;
我用 -overlay 摘掉闸的预算 ⇒ `TestIngestGate_Add墙钟有界` 与
`…failOpen_超预算绝不丢记忆` 变红("Add 等了 2.0s,越过闸预算 150ms")。
子代理另做 2 个变异(去掉预算 / 把 fail-open 改成 fail-closed)全红,并实测
**闸开 vs 关的 `Add` 额外墙钟:p50 ≈2.5~3.1ms、p95 ≈4~7ms**(基线批均值 35.8µs)。

⚠️ 未部署(要一次 build + restart);⚠️ 已知边界(如实):闸只用词项余弦 ⇒
「同义但字面差得远」拦不住(有意为之,由异步层/夜间整理兜);闸的候选是**最新
100 条**(更老的重复闸看不到);**并发同内容双写**仍可能两条都落 active(既有竞态,
SELECT 与 INSERT 不在同一事务,非本次引入,第二层会清)。
2026-10-07 11:41:44 +08:00
AskaEth f310173b4c feat(memory): reclassify 纳入自动档(主人 2026-10-07「全自动挡吧」)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
自动落地现在三类:merge(字面重复 ∨ 事实等价 ∧ ≥0.95)· archive(one_off_event
且不像习惯)· **reclassify(新增)**。reclassify 只改 `kind` 一列
(`reclassifyTidyItemSQL` 只写 kind+updated_at),**不动正文、不动作用域、不碰第二条行**。

判据(纯函数 `tidyAutoReclassifyKind`,四个条件全中才自动):
① 恰 1 个 source 且无 target;② 理由 = `kind_mismatch`;③ 建议 kind 合法;
④ **改前 kind 合法且 ≠ 建议值** —— ③④ 是 **revert 抓手**:没有它自动落地就不可回滚。
**边界**:只读 ProposedKind / KindA,**一概不读** ProposedContent / MergedContent /
ContentA/B / 分区 / 时间字段。

六条护栏逐条覆盖它:软动作(同一条 apply 路径)/ 每轮上限(三类同占一个池子,
默认 50 硬顶 500,混合时截断如实)/ 审计 `memory.tidy.apply`(`auto=true`,
`reclassified=%d` + stats + 日志 + run 审计,**只带计数与理由、不带正文**)/
**可 revert**(`revertTidyReclassify` 按 KindA 写回,且只在当前 kind 仍等于建议值时生效)/
幂等(候选条件更新 + 行 `AND status='active'` 双闸)/ 开关关闭 ⇒ 连查询都不发。
另补一道本片新增的门:**受限来源**(群聊/陌生人)与跨分区的 reclassify **skipped**
(原先只有 merge/archive 有这道门)。

验收(主会话复跑 + 亲自变异):整树 `go build ./...` + vet 绿;memory/gateway 两包
测试绿;我用 -overlay 禁掉「必须真的变了」那一格 ⇒ `…EligibilityBoundary` **3 条子例变红**
(改前 kind 缺失/非法/与建议值相同 ⇒ 都"没法回滚")。子代理另做 4 个变异(连正文一起写 /
摘受限来源门 / 去掉 reclassified 计数 / 不要求可回滚抓手)全红。

🔴 **未动的红线(一行未改)**:`archive` 的「习惯类不归档」守卫(design/23 §5.2-1)
—— `looksLikeHabit` / `tidyHabitMarkers` / `tidyReasonOneOffEvent` 零改动,
`TestTidyAutoNeverArchivesHabitMisjudgedAsOneOff` 仍绿。要连它也自动请主人明说。

⚠️ 未部署(要一次 build + restart);⚠️ design/23 的 §7「reclassify 交人工」与
§13 开放点 5「全部人工应用」**现已过期**,主会话同步。
⚠️ 已知遗留(需新授权):历史积压里指向**已被吸收行**的 reclassify 候选会每轮被选中
又永远 `source_not_active` 跳过,白占一次每轮名额;彻底解法是「永久不可用的候选自动
标 rejected」(新行为)。
2026-10-07 11:26:34 +08:00
AskaEth 6fe68ecfac feat(browser): 方案 A 落地 —— 显式端口 + 去掉 stealth flag(主人 2026-10-07 拍板)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
依据 `DevDocs/reference/chromium-cdp/`(9 份实测+官方源码):旧姿势传 `--remote-debugging-port=0`
会被 Chromium 当成 ChromeDriver 式自动化启动 ⇒ 主动打开 `AutomationControlled`
⇒ `navigator.webdriver=true`;那个 bad-flag 是承重覆盖,但代价是**每次有头弹横幅**。
改用显式端口后 `webdriver` **本来就是 false** ⇒ flag 变纯冗余 ⇒ 去掉它,**两者兼得**。

- `launch.go`:`--remote-debugging-port=<我们选的端口>` 替掉 `=0`;**删掉**
  `--disable-blink-features=AutomationControlled`(注释写明理由)。
- 新增 `portrecord.go`:端口来源与落点的**唯一出处** —— 取空闲端口(`net.Listen`
  取号后立刻关闭,**本进程不常驻监听**)、写/读/清**我们自己的记录**
  `<profile>/cyrene-debug-port`(选它与 Chrome 的 `DevToolsActivePort` 同目录:
  天然按 profile 隔离、profile 换掉记录自动消失、内容只有端口号不含凭据);
  **兼容老记录**(换二进制时上一版起的浏览器可能还在跑 ⇒ 认不出会把"自己的浏览器"
  误报成"profile 被别人占了");就绪判据换成「我们选的那个端口答得上 `/json/version`」;
  TOCTOU 处理 = 端口被占**换号重试一次**,重试仍失败则如实报错且不留记录。
- 三条认领路径(复用/清陈记录/如实报错保留现场)与三条失败分类(进程立刻退出 /
  等端口超时 / 预算耗尽)**全部保留**;`ErrLaunch` + `errors.Is` 口径未变;
  **绝不杀进程**红线未变。

真机自证(子代理跑,默认 Skip 的 `realchrome_test.go`,`CYRENE_BROWSER_CDP_E2E=1` 开闸):
`webdriver=false` · 命令行**无** flag/`--enable-automation`/`--headless` · UA 无
`HeadlessChrome`、`plugins=5`、`languages=zh-CN,zh`、`window.chrome` 在 ·
**UI Automation 控件树 17 个命名节点里没有「信息栏容器/信息栏」⇒ 无横幅** ·
第二次 Dial **复用同端口、浏览器主进程仍是同一个 PID** · 收尾按 profile 路径筛残留 = 0。

验收(主会话复跑 + 亲自变异):整树 `go build ./...` + 两包 vet 绿;browser 包测试绿;
我用 -overlay 把该 flag 加回参数表 ⇒ `TestLaunchArgs_HeadfulCarriesNoAutomationTraces`
变红(逐字报"不许传的 flag")。

⚠️ 未验(如实):无头档的横幅(无窗口判不了)、`chrome-headless-shell`、风控侧是否把
"端口写法"本身当信号、生产 profile 整链路(未碰)。
2026-10-07 11:18:25 +08:00
AskaEth 74e171bc04 docs(contract): v0.86 —— 搜索条数上限可在面板配(字段 + values 键 + 回显键;无新端点)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-07 11:11:24 +08:00
AskaEth c68387a69a feat(search): 面板上终于能改「结果条数上限」(原来改了会被静默丢弃)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
真缺陷(上一片登记):`maxResults` 在面板上**根本改不了** —— ① 字段表里没有这个
字段(UI 不显示输入框);② `saveProvider` 只并 provider/apiKey/baseUrl/enabled;
③ `mergeSelectionPatch` 只处理 primary/fallback ⇒ **手工塞进 values 也会被静默丢弃**
(文件不变、也不报错)。「我改了它却没变」正是要消灭的那种假话。

- `panel.go`:表单加 `{Key: FieldMaxResults, Type: sdk.FieldNumber, Min: 1}`(**SDK 本就有
  数字类型**,未自造;前端本就渲染 number ⇒ **不需要改前端**)。刻意不设 `Default`
  (设了会让一次「只改 key」的保存把默认值固化进文件、还可能盖掉 `.env`)。
- `websearch_selection.go`:`mergeSelectionPatch` 里按**三态**落 `maxResults` ——
  缺位/null ⇒ **不改动**;正整数(含整数字符串)⇒ 采用;0/负数/小数/空串/非数/
  布尔/数组 ⇒ **400 `invalid_payload`** 且**盘上一字节不动**(绝不静默忽略)。
- `reauth` 回填该值(否则一保存就把上限清掉)。
- `.env.example`:该键的示例值与「默认 5」注释同步为 **10**(代码默认已改;⚠️ `.env` 未动)。

验收(主会话复跑 + 亲自变异):**整树 `go build ./...` 通过**;三包 vet/测试绿;
端到端「面板存 20 ⇒ 模型要 50 也只给 20、要 3 给 3」(走真 action hub → 落盘 →
热重载 → WithSearchMaxResults → 真 dispatcher,未重启后端);
我用 -overlay 把 `f.MaxResults = maxResults` 摘掉 ⇒ 两条用例变红
(「面板给的值必须真的落盘」/「回填的 maxResults = 5, want 20」)。

⚠️ 已知取舍(如实登记):**清空输入框 = 400**(后端按「非数 ⇒ 报错」拒),因此面板上
没有「去掉上限」的手势 —— 要回到 `.env` 兜底得改文件;要支持「清空 = 去掉覆盖」
需另定哨兵语义,未自行发挥。
⚠️ 契约面已变更(面板声明多一个 number 字段 + saveProvider 的 values 多一个可写键 +
响应多一个回显键)⇒ 主会话登记。
2026-10-07 11:10:56 +08:00
AskaEth 8325d1bb8c docs(contract): v0.85 —— B25 按动作批量拒绝候选 + 轮末自动落地(行为变更)+ link 候选停产
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-07 11:08:07 +08:00
AskaEth 6d92536276 feat(memory): 整理不再只产建议 —— 轮末自动落地高置信 + 停产 link 候选
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
主人原话:「那么多 link 是干什么的?该删就删,怎么这个记忆整理机制这么保守呢」。

① **link 停产**(依据 design/23 §14 把它列为「未细化」⇒ 既无落点也无数据模型;
   `applyable()` 也只认 merge/archive/reclassify ⇒ 应用 link 恒 `skipped`,是纯噪音):
   - 规则层 `containNone` 判定改为 `keep`(不再产出 501 条不可应用的待办);
   - `TidyLink` 常量保留,只为**读历史行**与「不可应用」判定;
   - 存量出路:新端点 `POST /api/v1/admin/memories/candidates/reject?action=&limit=`
     (**幂等、只改候选 status、不碰记忆**),主会话用它清掉存量 501 条。
② **轮末自动落地**(默认开,仅两类,判据是纯函数):
   - `merge`:**字面重复** ∨「事实集合等价 ∧ 相似度 ≥ 0.95」;
   - `archive`:理由 = `one_off_event` **且不像习惯**(`design/23 §5.2-1` 红线:
     习惯类会因正文带钟点被误判成一次性事件,归档它是错的)。
   - **不自动**:`reclassify`、低置信 merge、跨分区/跨角色、任何改正文、受限来源
     (群聊/陌生人)—— 新增执行面守卫 `tidySkipSourceRestricted`。
③ **六条护栏**(缺一不可):软动作(复用既有 apply 路径)/ 每轮上限 50(硬顶 500,
   超限如实 `Truncated` + 真总数)/ 审计 `memory.tidy.apply`(`auto=true`,不带正文)/
   **可 revert**(复用既有回滚)/ 幂等(复用候选状态条件更新)/ **一键关回只产建议**
   (`$DATA_DIR/config/memory-tidy.json` 的 `autoApply:false`,**零新 env 键**)。

验收(主会话复跑 + 亲自变异):memory 与 gateway 两包 build/vet/test 全绿;我用
-overlay 短路「习惯不归档」守卫 ⇒ TestTidyAutoNeverArchivesHabitMisjudgedAsOneOff
变红(逐字报「作息习惯被自动归档了 …design §5.2-1 红线」)。

⚠️ 未部署(要 build+restart 才生效);⚠️ 契约欠登记(新端点 B25 + §7.2),主会话补;
⚠️ 真机端到端未跑(不能重启后端)。
2026-10-07 11:07:00 +08:00
AskaEth 353293e807 feat(im): 主动消息「没送达微信」现在说得清(原因码 + 三处可见)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
主人原话:「可以,这有必要,不然我老是收不到」。真机日志:
`backthink IM push failed … weixin_ilink: proactive push outside 24h window`
—— 她的话**送进了 Web 活跃客户端,但没送进微信**,原因是**平台规则**(微信 iLink
只允许用户最后一条消息后 24h 窗口内主动推送),不是崩溃。

本片只做「如实说清」,不绕规则:
- **原因码**:新增契约层分类件 `internal/channel/adapter/delivery.go`
  (`DeliveryCategory` + 4 个码 + 可选自描述接口 `DeliveryFailure` / 读取函数),
  **沿用**既有 voice `Category(err)` 与 `SystemNoticeNormalizer` 的范式,**不造第二套枚举**;
  平台码由产生错误的层声明:`weixin_ilink` 的 `ErrProactiveWindow` 自报
  `proactive_window_closed`(哨兵文本与 `errors.Is` 判定**逐字未变**)。
- **三处可见**:① 结构化 WARN(补齐 category/channel/platform/route);
  ② 审计 `backthink.push.im_undelivered`(与 `backthink.self_wake.*` 同族);
  ③ 推送结果 `Detail` 追加稳定标签 `im_undelivered=<渠道>:<原因码>` —— 面板**已在
  渲染 Detail**(`Backthink.tsx:185`)⇒ 主人现在就能看到,**无需改前端**。
- **不丢消息**:IM 路失败照旧走活跃客户端路,且**只在 Web 真送达时** `pushed=true`;
  有测试钉住「IM 失败不影响另一条路」(同一条消息没被丢)。
- **不做的**:不重试刷屏、不换渠道冒充微信、不伪造送达、不新增 `.env` 键。

验收(主会话复跑 + 亲自变异):**整树 `go build ./...` 通过**;4 个包 vet/测试全绿;
我用 -overlay 把 `ErrProactiveWindow` 退成裸 `errors.New` ⇒ weixin 包两条用例变红
(`category = "failed", want proactive_window_closed`),channelbridge/backthink 亦红。

⚠️ 未落地的建议(已登记,未硬改):给她的"推送回执"(`backthink_records` 加
`push_category`/`push_channel` 两列 + 下一轮注入只读一行)与面板结构化徽标 ——
红线:回执只能用来换渠道/换措辞/等窗口,**不得演变成绕平台规则**。
2026-10-07 10:59:22 +08:00
AskaEth bff57bd1ae feat(search): 结果条数上限 5 → 10,并让「上限」真的是上限
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
主人原话:「返回五条结果会不会太少了?」。裁定:**上限抬高到 10,且它是真上限**
(模型要 50 也只给 10);模型要更少(3)时**尊重模型**;缺省/非法(≤0/非数)⇒ 上限。

- plugins/tools/io/web_search.go:`n := resultLimit(s.maxResults, m)` 一处收口
  (原来是 `if count>0 { n = count }` ⇒ 模型的 count 能顶掉配置上限,与
  .env.example 写的「条数上限」不符 —— 那是一条真口径缺陷,本次一并修正)。
- internal/config/websearch.go:`WEB_SEARCH_MAX_RESULTS` 代码默认值 5 → 10
  (三处口径现已一致:env 默认 10 / io 包 defaultMaxResults 10 / 各家发给上游的
  请求区间是另一回事,未动)。
- 生产:`data/config/plugins/web-search.json` 的 `maxResults` 5 → 10 + 一次
  saveProvider 触发**热重载**(applied+registered),盘上已生效。

验收(主会话复跑 + 亲自打两个变异):
- io 包 / internal/config / cmd/cyrene 相关用例全绿;
- 变异 A(拆掉上限、恒取 50)⇒ **4 条用例变红**(含「默认上限 10」「count 非数值
  ⇒ 上限」「count=0 ⇒ 上限」「既有 MaxResultsOption」);
- 变异 B(默认值退回 5)⇒ internal/config 的默认值断言变红。

🔴 顺带挖出一条真缺陷(**本片只报不修,等派**):`maxResults` **在面板 UI 上
根本改不了** —— ① `search/panel.go` 的字段表里没有这个字段(输入框不显示);
② `saveProvider` 只并 provider/apiKey/baseUrl/enabled;③ `mergeSelectionPatch`
只处理 primary/fallback ⇒ **手工塞进 values 也会被静默丢弃**(「我改了它却没变」
成立)。修它要给面板加一个数字字段 + 三态落 `f.MaxResults`,属**管理面板对外契约
变更**(须同步 Docs/api-client.md),留给主会话。
2026-10-07 10:58:40 +08:00
AskaEth 7f63c5bec4 fix(browser): 同 profile 的浏览器还活着时不再必然启动超时
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
真机复现(我做的):同 profile 再起一次 chrome.exe ⇒ Chrome **把命令委托给已有
实例并 exit 0**,stderr 逐字「正在现有的浏览器会话中打开。」⇒ **不产生新的
DevToolsActivePort** ⇒ 我们的等待只能超时 ⇒ 浏览器降级档整体不可用。

修法:启动前加一跳**探测**,三档各自出路 ——
- 端口记录缺席 / 端口没人听 ⇒ 正常启动(后者先把**陈记录**清掉:那是启动时写下
  的记录、不是锁,删一条指向死端口的记录不会伤到任何活实例);
- 端口在听 + `/json/version` 有合法响应 ⇒ **复用它**(INFO `browser debug
  endpoint reused`),**不再起第二个**;
- 端口在听但握不上手 ⇒ **如实报错**(保留现场,既不起进程也不删记录);
- 真被占用且拿不到端点 ⇒ 报错带 **Chrome 原话**(`withChromeOutput`)+ 可操作处置。

🔴 红线(写进错误文案与注释):**绝不杀别人的浏览器**、不发信号抢 profile、
不新开监听端口;`ErrLaunch` / `errors.Is` 口径未破。

验收(主会话复跑 + 亲自变异):browser 包与 plugins/tools/io 两包 build/vet/test
全绿;我用 -overlay 摘掉「复用」那一支 ⇒ TestLocalChromeAdoptsLiveEndpoint 与
TestDial_ReusesLiveEndpointAndDoesNotSpawnSecondProcess 变红。子代理另跑了真机
端到端(临时 profile):会话 1 拿端口 51732 → 裸起第二次 chrome(exit 0)→
会话 2 **复用同一端口、未起第二个实例**。

⚠️ 已知未修(如实):Chrome 那句委托提示是**控制台代码页(GBK)字节**,我们的
UTF-8 日志里会显示成乱码(字节保真、人看不了);彻底解决要引编码转换,另定。
⚠️ 判据③的取舍:陈记录 +端口恰被别的程序复用时,会持续报错直到人工删
`DevToolsActivePort`(错误文案已写明处置);**未加"报错后自动清理重试"**。
2026-10-07 10:56:16 +08:00
AskaEth 93c5a4c46f fix(browser): 启动失败要说出原因;预算耗尽 ≠ 浏览器坏了
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
真机现象:主人看到 Chrome 窗口内有横幅(他读作「不支持接受的参数」),日志只有
一句「等待调试端口时被取消: context deadline exceeded」,看不出是谁的错。

三件(本片):
- 接住子进程 stdout/stderr(有界:前 4 KiB / 上报 12 行,只缓存不输出)⇒ 失败时
  打一条带 exe / argv / chrome_output / chrome_output_bytes 的 WARN;成功只记字节数
  (不刷屏)。
- 三种启动失败在错误里分开:① 进程立刻退出(profile 被占用)② 等端口超时
  ③ 调用方预算耗尽(ctx 已结束 ⇒ **根本不 spawn**)。③ 与 ② 用两个截止时间比大小
  区分 —— 顺带修掉一个老缺陷:原来 ctx 一结束就报「等待调试端口时被取消」,而那
  几乎总是我们自己的 30s 用满,现被正确归到 ②。
- 降级档不再被**已耗尽**的轮预算连坐:如实报「预算耗尽 ⇒ 未尝试浏览器渲染,
  这不是浏览器故障」,**不许把"没试"说成"试了失败"**。plugins/tools/io/bodyfetch.go
  一字未动(fetchedBy / browserError 语义原样),只在**发起方**决定要不要发起。

对主人那条横幅的结论(有据,非推测):那是 Chrome 的 **bad-flags 警告栏**,触发者
是 `--disable-blink-features=AutomationControlled` —— 它明确在 Chromium 的 kBadFlags
名单里(bad_flags_prompt.cc 列了 kDisableBlinkFeatures/kEnableBlinkFeatures),本机
Chrome 154 zh-CN 的文案是「您使用的是不受支持的命令行标记:$1。稳定性和安全性会
有所下降。」;而「不支持接受的参数」在 Chrome 的 zh-CN/en-US 资源与二进制里**零命中**
⇒ 是意译。⚠️ 该参数是 design/28 §2 要求的(掩盖 navigator.webdriver)⇒ 横幅是
**设计取舍的视觉副作用**,去掉它才消失(但会暴露自动化特征)。登记待主人裁决。

验收(主会话复跑 + 亲自变异):go build ./... + 两包 vet 绿;browser 与 cmd/cyrene
两包测试绿;我用 -overlay 去掉 cmd.Stderr 接线 ⇒ TestDial_ReportsChromeOutputAndArgvOnLaunchFailure
变红(chrome_output="" chrome_output_bytes=0)。

⚠️ 未部署:跑着的产物不含本片,要等下次 build + restart。
2026-10-07 10:37:33 +08:00
AskaEth 23dc9a3e5e docs(contract): 修回 §5 开头被吃掉的 blockquote 首行(v0.84 登记时的编辑失误)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-07 10:29:49 +08:00
AskaEth 77dbf9b1f7 docs(contract): v0.84 —— 显示名形状变更 + 段落标签硬约束 + 浏览器降级档登记
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
① plugin.providers 候选:裸字符串数组 → 对象数组 {value,label};两条硬口径
   (label 仅供显示、value 才是落盘值;前端三形状兼容 ⇒ 其他插件零影响)。
② 新增 §4.16:助手文本不得出现未配对段落标签(一个 <p> = 一段、段落不嵌套),
   含实测根因两条 + 修后处置口径 + 「客户端仍应保留容错」与护栏清单。
③ 模型面登记:web-fetch 浏览器降级档的 fetchedBy(direct|browser)如实标注。
④ provider 配置面去 env 化(对客户端零形状影响)一并登记。
⑤ 计数不变:无新端点 ⇒ 14 + 24 + 40 = 78。

顺带修一处我自己的失误:上一版登记 §4.15 时把 `## 5. env 与模型配置` 标题
误删,已补回(§5 表格内容未受影响)。

诚实边界:web-search 真机工具路径仍有一处未部署的修复(linkless)已登记;
显示名真机点击未跑 + dist 是 go:embed(前端改动必须重新 build);
viewer 角色表格仍显示裸 id;腾讯/秘塔适配未实现。
2026-10-07 10:29:39 +08:00
AskaEth 2c56bc9704 fix(search): 「上游没给链接」不再被当成「没有结果」(真机 no results 的根因)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
真机现象:她自主思考的 4 次 web-search 全部报
`search: no results (provider=zhipu)`,而同一查询直连上游返回 10 条。

根因:`Finalize` 把「link 为空」当成「不是一条结果」整条丢弃。而智谱
`search_std` 的 `search_result[].link` 在真机上**经常整批是空串**(实测同一
payload 连打 6 次:10 条全空 link,title/content 齐全;换查询也见过 6/10 空)。
⇒ 10 条被整批丢光 ⇒ 工具层如实报「一家都没查到」——**那是谎报**。

为什么面板探针是 ok:探针走 `search.Probe → p.Search`,**不过 Finalize**;
工具走 `Execute → p.Search → Finalize`。同一份上游、同一份解析代码,两条路
给出相反结论 —— 探针对这一类缺陷**结构性失明**,以后不能拿探针通不通推断
工具通不通。

修法:无链接条目**照原样保留**(title/content 对模型仍有用,链接空着就空着,
**绝不编一个出来**);判据改为「link/title/content/snippet **全空**的空壳行才
不算结果」;无链接条目不参与按 link 的去重(没有链接就没有判同身份,宁可重复
也不静默吞)。「空结果 ≠ 成功」**未放宽**:真·空 search_result 仍报 ErrNoResults。

验收(主会话复跑 + 亲自变异):go build ./... + vet 绿;io 与 search 两包绿;
我用 -overlay 把判据退回缺陷形态 ⇒ TestWebSearch_ZhipuLinklessResultsAreNotNoResults
变红,报错串与真机逐字相同。子代理另用真 key 走 in-process 工具路径实测:
三条当时失败的查询现在各回 10 条。

⚠️ 本修复要等下次 build + restart 才真机生效(现在跑的还是老产物)。
⚠️ 其他四家是否也有空 link 形态未验(无 key);修复是通用的。
2026-10-07 10:28:36 +08:00
AskaEth bc1dee3f56 refactor(search): provider 配置面去 env 化 —— 加新家不再需要动 .env
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
动因:`.env` 已冻结(基础键不加/不改/不删),而「加一家 provider」原本会逼出
新的 env 键(一家一个 struct 字段 + searchProviderVector 逐家读 + 逐家写死的
switch)⇒ 想接腾讯云元宝/秘塔就只能破红线。面板本来就该是插件配置的一等来源
($DATA_DIR/config/plugins/web-search.json),故让**文件成为权威**。

- plugins/tools/io/search/registry.go:接入位表加 defaultBaseURL(「没配 baseUrl
  回落代码默认值」从此只有一个出处);新增 Descriptor(s)/DefaultBaseURL。
- cmd/cyrene/websearch_providers.go(新):按接入位表逐家算出生效投影 ——
  加一家时这里**零改动**,这正是本片的核心;顺手把 wire_io_tools.go 从 430 行
  降到 342(它逼近 §10 的 500 行上限)。
- cmd/cyrene/wire_plugin_actions.go / websearch_action_data.go /
  websearch_selection.go:基地址判定统一走「文件值 → 接入位默认值」的进位链;
  checkPrimaryUsable 判据不放松(真没配齐仍 400 primary_not_configured)。

🔴 **一处对我任务书的纠正(子代理提出、我采纳)**:我要求「既有 6 家在文件里
没有该条目时仍回落 .env」。仓库现状**没有**这层回落,且有回归用例把它钉死
(TestWireIOTools_PanelConfigClearedProviderDoesNotReviveFromEnv:「文件里没有
的家不得从 .env 复活」)。本片按「不改变既有行为」实现 —— 我的要求才是错的:
文件一旦存在就是权威,env 只在文件缺失/为空时播种。

验收(主会话复跑 + 亲自变异):go build ./... + 两包 vet 绿;search 包与
cmd/cyrene 全绿;我用 -overlay 让文件里的 apiKey 被忽略 ⇒ 4 条用例变红
(含「无 env 字段的家只认文件也能进注册表」这条核心判据)。

⚠️ 新家仍未接:腾讯云元宝(tencent_wsa)/ 秘塔(metaso)两片随后串行落地。
2026-10-07 10:18:58 +08:00
AskaEth e5e5207c94 test(channelbridge): 补跨「渠道桥 → 网关」的 IM 附件整链回归
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
安卓端的 P0(「IM 入站的文件要挂到消息的 documents 上」)其实早已修复
(679cee0):附件引用在落库那一刻写进 chat_messages.document 列,DTO 的
documents 由该列解出。但既有两个用例各据一半(渠道桥侧只验落库、网关侧只验
投影),**没有一条真正跨过这条边界** —— 于是「哪一段断了」只能靠人读代码。

本片补一条真跨边界的端到端:同一个真实 history.Store(:memory: SQLite)
同时喂真渠道桥与真网关,走完整 IM 入站回合(真抽取器 + 真循环 + 假模型),
最后从 HTTP 原始 JSON 逐字断言该消息带 documents。

验收(主会话复跑 + 亲自变异):channelbridge 包测试绿;我把
inboundDocumentColumn 变异成恒回空串(模拟 P0 根因)⇒ 该用例变红,报错文案
正是「带附件的用户消息缺少 documents 键(这正是安卓报的 P0)」;还原后绿。

⚠️ 真渠道/真设备未跑:要主人发一条新的真文件消息才能做设备级复验。
2026-10-07 10:11:19 +08:00
AskaEth c46053e276 feat(admin): 搜索提供商下拉/多选/表格显示中文名(值仍是 ASCII id)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
主人报障:「为什么中文搜索接口……下拉菜单里全给的英文名?你甚至不愿意带个括号
把中文名写上去」。根因不是漏写文案,而是整条链路没有「显示名」的位置:字段
声明只有 optionsFrom,候选动作回的就是 KnownNames() 的纯 id 数组。

- 后端:候选改为对象数组 {value,label};label 表与接入位表同源(键用
  NameSearXNG 等常量),未知 id 原样回。value 逐字等于 KnownNames()。
- 前端:下拉/多选「显示 label、写 value」;表格单元格按 value→label 索引显示,
  查不到回落显示原值。多选从裸输入框改成「候选开关 + 落盘值输入框」。
  解码器本就兼容裸字符串/{value,label}/{id} 三形状 ⇒ 其他插件零影响。

中文名逐字:SearXNG 本地兜底(searxng)/ 智谱 BigModel(zhipu)/
阿里云 IQS 信息查询(aliyun_iqs)/ 博查 Bocha(bocha)/
火山引擎·豆包搜索(volcengine)/ 百度千帆 AI 搜索(baidu_qianfan)。

验收(主会话复跑 + 亲自变异):go build ./... + vet 绿;search 包测试绿;
tsc --noEmit 0 错;新渲染测试 26 checks + 既有 plugin panel 55 / data 90 /
schema values 40 / channel config 70 全绿。我用 sed 把 values.ts 的
option.value 全换成 option.label ⇒ 渲染测试红(「点选把候选项的 id 追加到
降级链末尾」got="bocha, 智谱 BigModel(zhipu)")—— 这正是会把配置文件写坏的
错法;还原后 md5 一致且绿。

⚠️ 契约面变更(我在 Docs/api-client.md 登记):plugin.providers 候选响应形状
字符串数组 → 对象数组;label 仅供显示、value 才是落盘值。
⚠️ 已知边界:viewer 角色看不到 form 区块 ⇒ 表格对 viewer 仍显示裸 id(未做)。
⚠️ 真机浏览器点击未跑(项目无 jsdom)⇒ 建议刷新管理页点一次确认观感。
2026-10-07 10:08:19 +08:00
AskaEth 8051906afa fix(turnreply): 产出侧不再产出未配对的段落标签(安卓报障的回执)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
安卓客户端登记(他们文档 02 末节):一条助手消息的 content 是
`<p><p>正文</p>`,按他们的解析契约(非贪婪)必然把 `<p>正文` 当内容 ⇒ 用户
直接看到字面 `<p>`。任何按该契约定实现的消费方都会踩。

根因(两半都证实,都在同一处):
- (a) 规整器**原样放过**模型吐出的 `<p><p>X</p>`:段块正则非贪婪,把它当成
  一个"已完整包裹"的块逐字保留(旧的覆盖检查用同一个正则 ⇒ 护栏本身是盲的);
- (b) 规整器**亲手制造**该形状:末段漏 `</p>` 时无条件套一层 ⇒ `<p><p>X</p>`。
  实证:模型吐 `<p>那现在…?`(无闭标签)经旧代码规整后与上报串**逐字节相同**;
- (c) 顺带修一个幂等漏洞:落单 `</p>` 旧行为会越跑越多(`<p></p></p>`)。

修法(新增 paragraph_repair.go:配对修复 + 接线):
`<p><p>X</p>`⇒`<p>X</p>`;`<p>X`⇒补 `</p>`;`<p>正文<p>`⇒合并;`<p>A</p><p>`⇒
丢空开标签;`<p></p>`⇒整对丢;落单 `</p>`⇒丢标签正文不动。不吞正文护栏:
`<p`(无 `>`)/`<p2>`/`1 < 2`/`a<b>`/代码围栏/`<action>` 内 `<p>`/自闭合
`<p/>` 一律原样(每条都有用例)。`normalizePiece` 改为"补闭不补开"——在造出
畸形的那一点上根治。

验收(主会话复跑 + 亲自变异):go build ./... + vet 绿;turnreply /
gateway 全量 / channelbridge 三包绿;我用 -overlay 摘掉配对修复接线 ⇒
TestNormalizeParagraphs_* 变红。生产库只读核查:177 行 assistant 里**仅 1 行**
畸形(各消费方已容错)⇒ 不做一次性修复脚本。

⚠️ 未部署:跑着的 :10942 仍是旧产物 ⇒ 畸形仍会产出,安卓端的容错请继续保留。
2026-10-07 10:07:13 +08:00
AskaEth 378faf212e feat(browser): 取正文能力 + web-fetch 浏览器降级档接线(解封 Z5/B5)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
Z5(c2e413e)把 web-fetch 的降级链与注入点做完了,但「取正文」入口缺失 ⇒
组合根拿不到 BodyRenderer、这一档实际关着(当时如实登记)。本片补上:

- internal/capability/browser/render.go:新增 Manager.RenderText(ctx,url)
  (导出的进程内能力)—— 先过 GuardURL(与全仓同一份 SSRF 判定源)、复用
  withSession/session、等 readyState=complete 之后才取 body.innerText;
  渲染失败 = 分类错误,渲染成功但正文为空 = 空串+无错(不冒充成功/失败)。
- cmd/cyrene/wire_body_fallback.go:适配器 + 挂载点,把 Manager 适配成
  io.BodyRenderer 并用 WithBrowserFallback 挂上;web_runtime 里把浏览器
  接线**上移到 io 工具集之前**(顺序颠倒会静默关死这一档)。
- plugins/tools/browser/tool.go:新增 Tool.Manager(),供组合根取驱动句柄。

开关口径(裁决,依据 design/28 §1 M5 / §7 判据 4 / §8 B5):**有可用浏览器
就挂,没有就不挂**,不新增开关 ——
① 设计把 M5/B5 写成要落地生效的降级档,不是永远关着的空壳;
② 设计 §8 的可调项清单里没有这条降级档的开关,`.env` 已冻结 ⇒ 自造开关属
   「自行发挥」;
③ io 库层「不传 = 默认关」的语义一字未改,本片只把组合根从「永远关」改成
   「没来源就关」,并留带来源的 WARN(reason=no-usable-browser)。
⚠️ 若日后要「有浏览器也需显式开启」,得先给设计定开关落点,代码侧一行改回。

验收(主会话复跑 + 亲自变异):go build ./... + 三包 vet 绿;browser/io 两包
测试绿(cmd/cyrene 由子代理跑过全绿);我用 -overlay 把 GuardURL 的判定对象
换成常量 URL(绕过被拒目标)⇒ TestManager_RenderTextBlockedURLRejectedBeforeAnyProcess
变红,证明判定确实**在起任何进程之前**跑。

⚠️ 生产端到端未验:按红线没重启后端、没起真 Chrome ⇒「真页面 → browser 取到
正文」这条链只有假 CDP + 组合根接线级证据。
⚠️ design/28 有 6 处表述与代码现状冲突(归属 web-fetch vs web-search、extract
动作归属、B5 排期、状态过期等),已登记待我同步文档。
2026-10-07 09:59:56 +08:00
AskaEth 25aa50e532 feat(search): 智谱 provider 按官方 schema 加固(请求补确证字段 + 错误码分档)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
官方文档逐页确证后落地的四件(分册 11 的「官方 schema 确证(2026-10-07)」):
- 请求补 search_intent:false(官方标 required,缺发可能触发 1213)与
  content_size:medium;count 仅 1–50 透传,越界不发该字段;
  search_query >70 rune 直接报错且**不发请求**;
- 响应按官方收窄:摘要只认 snippet/summary/abstract 候选(官方响应**没有**
  snippet),publish_date 入 Published;
- 错误码分档:1000/1001/1003⇒unauthorized、1113⇒quota_exceeded、
  1302/1305⇒rate_limited。🔴 1113 与 1302/1305 **同为 HTTP 429** ⇒ 只看状态码
  分不开「该充值」与「该退避」,必须读 error.code(码同时进日志文案);
- media/icon/refer 当前一律不投影(Result 没有「来源站点」位;要投影应给
  Result 加字段 + 同步契约,不许塞进 Snippet —— 否则摘要位会被站点名恒填满)。

另修两处过期注释(providers_test.go 仍写「只发 search_engine/search_query」)。

验收(主会话复跑 + 亲自变异):go vet 绿;search 包与调用方 io 包全绿;
我用 -overlay 删掉 1113⇒quota_exceeded 映射 ⇒ TestZhipuErrorCodeMapping 变红
(欠费被误报成限流、探针码 rate_limited)。

⚠️ 候选键容错暂未删除:官方已排除的键保留到**真机实测通过后**再收窄(现无 key)。
⚠️ 真机未跑:仍只打 httptest,官方真实响应形态未实测。
2026-10-07 09:59:24 +08:00
AskaEth c512609c63 feat(admin): 记忆整理候选的应用/回滚操作面(切片 TIDY-FE)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
主人报障「面板上点不到『应用』」→ 后端 TIDY-1 的五条端点(契约 v0.83 B20–B24)
此前只能 curl 驱动。本片补齐界面:候选表(动作/状态/摘要/理由/时间 + 行内
应用/回滚,回滚仅在 status=applied 时可点)、status 筛选、批量应用全部待定、
手动真跑一轮(两个写动作都走二次确认)。

三条契约硬口径落到界面:
- 幂等不是错误:skipped 用中性色,绝不渲染成失败;
- 超限必须如实:truncated=true 时黄牌「还有 N 条待定未处理」,且该分支绝不
  渲染「已全部处理」;未处理量只由后端权威字段算得(total - 已处理三项);
- 权威键只读:状态与计数全部取自响应,不做「应该成功了」的推断。

一个刻意的偏离(有测试钉住):截断提示不用共用 Notice tone=warn —— 那个组件
的固定前缀是「待实现」,贴在「还有 N 条未处理」上会被读成「功能还没做」。

验收(主会话复跑 + 亲自变异):tsc --noEmit 0 错;vite build 通过;
渲染测试 43 checks、传输层测试 28 checks 全绿;我把 truncated 判断取反 ⇒
渲染测试红在「截断时必须显式点明命中数大于本轮处理数」,还原后 md5 一致且绿。

⚠️ 未跑真机浏览器交互(需 owner 凭据 + 真后端),证据止于渲染层与传输层。
2026-10-07 09:58:21 +08:00
AskaEth aa0bf8b5a0 fix(websearch): 面板「填 key + 同一次选它当主 provider」不再被自己的校验回滚
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
真机报障(2026-10-07 09:50):面板上给 zhipu 填了 API Key,保存却一直回
400 primary_not_configured;盘上配置文件 mtime 一动不动 —— 连刚填的 key
一起被回滚,界面表现为「怎么填都存不进去」。

根因(顺序错误):saveProvider 先 applySelectionPatch(内含「主 provider 必须
已配齐」的校验),后 UpsertProviderEntry。校验看的是**盘上的旧文件**,而这次
提交里的 key 还没落盘 ⇒ 校验判它没配齐,整笔拒绝。

修法:把校验从 applySelectionPatch 里摘出来,改名 mergeSelectionPatch(只合并
文件级字段 + 回报有没有给 primary),由 saveProvider 在 UpsertProviderEntry
**之后**对最终文件校验,再写盘。判据本身一字未改 —— 「真的没配齐的主家照样拒」。

验证:cmd/cyrene 整包测试绿;新增回归 TestWebSearchSaveProviderAndPrimaryInOneSubmit
(同一次提交填 key + 选主家 ⇒ 200 且盘上 key 与 primary 都在;反向:真没配齐仍 400
且文件字节不变);用 -overlay 把校验改回「拿旧文件校验」⇒ 该用例变红且报错文案
与主人看到的逐字相同。
2026-10-07 09:56:37 +08:00
AskaEth ef8fc66e54 docs(contract): v0.83 —— 记忆整理应用面五条端点 + 读侧 status 闸 + 两处过时口径改正
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
新增 §2.2 B20–B24(候选列表 / 单条应用 / 批量应用 / 单条回滚 / 手动真跑)、
§4.15 详解(三条硬约束:幂等不是错误 / 超限必须如实 / 软动作永不物理删除)、
错误码表补 404 tidy_run_not_found、409 tidy_disabled、500 六个 tidy_*、503 tidy_timeout;
计数 writeAuth 19 → 24、总数 73 → 78(按 routes.go 核:writeAuth 挂载 admin 26 条,
减去历史口径的 channels/events 与 counters ⇒ 24)。

改正两处已成假话的 §7.2 条目:
- 「整理候选 apply/revert/列表/运行均未实现」→ 已实现五条,仍缺 reject / 运行历史读口 / 按 run 筛候选
- 「面板记忆页无法过滤」→ 补一句:v0.83 起服务端已按 status 过闸,字段面仍未补齐

诚实边界写进契约:生产库零动作 / 面板真机未跑 / 旧候选 run_id 为空串 /
run_id 列只在存储包前滚 / 语义召回不可复现。
2026-10-07 09:51:57 +08:00
AskaEth f847769698 test(gateway): 面板记忆列表过 status 闸的端到端回归(TIDY-1 补证)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
真实 sql.DB + 真实 memory.Store + 真实 HTTP handler:两条在册 ⇒ 面板 2 条;
软合并一条 ⇒ 只剩被保留那条;再归档另一条 ⇒ 200 + 非 null 空数组。

复核(主会话亲自):目标测试绿;用 overlay 把 store.List 的 status 闸
变异成恒真 ⇒ 该用例变红(证明它真的在测那条闸,而不是在测夹具)。

顺带确证一件口径:GET /admin/memories/reviews(审查留痕)**不该**过 status 闸
—— 留痕表本身无 status 概念(preinsert_schema.go:29-46),且它是软删行的
查看与撤销入口,加闸等于审计自我抹除。
2026-10-07 09:49:57 +08:00
AskaEth 9126d653e5 fix(memory): 读侧过 status 闸 —— 软删行不再被检索、不再进面板
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
整理(tidy)与入库前审查(D3)都只做软动作:status 改 merged/archived,
正文一个字不改、行一行不删。因此只要有一条「能看到什么记忆」的读路径
漏了 status 闸,清理就等于白清 —— 生产库上那 10 条 merged 行就是反例。

- store.go:ListByPersona / List 加 status='active'
- rules.go:ListScope / ListProject 加 status='active'
- Get 刻意不过闸(撤销软合并要先读得到被吸收的行),并在注释里写明
- gateway/admin.go collectMemories 只加注释:口径只有一处,不重复过滤

验证:go build ./... + go vet 绿;memory 包全量测试绿;
新增 status_gate_test.go(4 条读路径逐条断言 + Retriever 端到端
「内容逐字相同的两条,软合并后只召回一条」+ Get 反向护栏);
用 -overlay 把闸门变异成恒真 ⇒ 5 个读路径用例与检索端到端用例全红。
2026-10-07 09:48:37 +08:00
AskaEth c2e413ec38 feat(io): web-fetch 直连→浏览器渲染降级档 + fetchedBy 如实标注(Z5 机制)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 降级链:直连失败 / 判定为动态页 ⇒ 浏览器渲染取正文;返回体带 fetchedBy=direct|browser,
  两条档的失败各自留痕(directError/browserError/browserSource/dynamicSuspect),**绝不静默替换**
- 被 SSRF 拒绝的目标**不换路**(红线);渲染器缺位时如实报双失败
- 3 个变异自检(去掉动态页判据 / 去掉 SSRF 短路 / 掩盖双失败)
⚠️ **生产接不通**:internal/capability/browser 没有「取正文」入口(不在本片文件面)⇒
   开关默认关、不假装可用;解封形状已在报告里给出(加 RenderText ≈40 行)
2026-10-07 09:34:47 +08:00
AskaEth 20f82c9679 docs(contract): v0.82 —— resultSummary(失败也带)+ 自排成本可见面 + browser 面板(Z4)
- tool_execution.end 的 resultSummary(含失败口径、≤500 rune、空 ⇒ 缺席)
- GET /admin/backthink 加 ?trigger=(闭集 + 未知/空 400 invalid_trigger + 下推 SQL)+ 新端点
  GET /api/v1/admin/backthink/self-wake(writeAuth、三态、gate 快照、*IsDefault 语义)
- GET /admin/panels 新增 browser/settings + 六个动作(3 读 3 写)+ 错误码 + turnAttachment
- request_think 可达性改正(已上线);计数 72→73(writeAuth 18→19)
2026-10-07 09:34:46 +08:00
AskaEth 9fa7e65278 feat(loop): tool_execution.end 失败也带 resultSummary(Z3,主会话裁定)
与回看面对齐:失败调用给该次错误文本的脱敏渲染(仍 ≤500 rune、仍走 trace.RedactText),
缺席只剩「真的没有结果」(空结果 / 空错误文本)。2 变异(失败仍缺席 / 绕过脱敏)
2026-10-07 09:34:45 +08:00
AskaEth 8e2441c183 feat(browser): 补上 B3/B4 的两行接线(Z2)—— 面板页与截图进上下文真生效
- wire_runtime.go 加 TransformContext: a.browserShotTransform(visionResolver)(只改本轮所见)
- wire_frontend.go 在 declareBuiltinPanels 之后接 wireBrowserPanel(带 a.cfg != nil 守卫,
  否则 6 处既有夹具 panic;生产路径 cfg 恒非 nil ⇒ 行为与字面写法一致)
- 3 条盯梢用例(1 条 Skip→转正 + 2 条新增),2 变异打红
- 真机(隔离实例):panels 出现 browser/settings、三个只读动作真回执、**真回合内联 image 部件
  61,410 字符** ⇒ design/28 判据 2 在真产物里成立
2026-10-07 09:34:44 +08:00
AskaEth 93fc24cade docs(readme): 用户侧文档按代码重写(Z1)
修正 6 条「本该 ✅ 却标 ⚠️/❌」的能力(checkpoint 恢复/DashScope ASR/异步记忆提取/知识库删除/
全链路 P2/面板 18 模块)+ 快速开始与 .env 冻结口径 + 目录结构 + **新增「已知缺口与路线图」如实列未做**
2026-10-07 09:34:44 +08:00
222 changed files with 28141 additions and 3419 deletions

No files matched your search

+11 -5
View File
@@ -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、开箱即用 —— 默认指向
+4 -2
View File
@@ -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 文本。
+2040 -1634
View File
File diff suppressed because it is too large. Load diff
+189 -109
View File
@@ -1,73 +1,115 @@
# Cyrene v2 — 昔涟
> 自托管的「人格化通用智能体运行时」。一个 Go 单二进制,默认以「全能小女友」的人格陪伴你;在聊天/开发/家庭/工作等模式间无感切换;如 OpenClaw 般接入聊天平台(**KOOK / 微信 iLink 已接通**,QQ / Telegram 待接通);各人格独立记忆与情绪、共享同一套能力。
> 自托管的「人格化通用智能体运行时」。一个 Go 单二进制,默认以「全能小女友」的人格陪伴你;在聊天/开发/家庭/工作等模式间无感切换;如 OpenClaw 般接入聊天平台(**KOOK / 微信 iLink 已接通**,QQ OneBot v11 链路已打通但暂不连,Telegram 待凭据);各人格独立记忆与情绪、共享同一套能力。
**状态**:按里程碑 M0–M12 推进中;每一项能力以「当前能力」三档清单(已实现 / 降级 / 未实现)为准,**不作整体完成宣称**。 | **分支**:`dev` | 设计文档见 `DevDocs/00-overview.md`
**状态**:M0–M12 里程碑主干已落地,现处于「后 M12 增量切片」阶段。每一项能力以本文的三档清单(已实现 / 需凭据·部分 / 未实现)为准,**不作整体完成宣称**;真实缺口见「已知缺口与路线图」。 | **分支**:`dev` | 设计文档见 `DevDocs/00-overview.md`
---
## 当前能力(按真实状态分三档,不虚标)
> 状态以实际开发进度为准;标注「降级 / 占位」的项依赖外部凭据 / endpoint 才能达到完整形态。
> 状态以**代码 + 真机实测**为准。标注「需凭据」的项依赖外部凭据 / endpoint 才能达到完整形态;「未实现」一律如实写出,不当「已修」。
### ✅ 已实现
- **单二进制、进程内协作** — 所有服务同进程打包,仅 gateway `:8080` 对外;模块间走进程内接口 + 事件总线,**零跨进程 IPC**
- **人格化陪伴** — 多人格 / 情绪状态机 / 好感度;per-persona 记忆隔离,能力全局共享
- **通用 Agent 运行时** — 多供应商 LLM(per-role 路由 + 降级)、工具调用(8 轮上限)、子代理编排(生命周期 / 合并 / 上限)、记忆三类作用域 + RAG、会话持久化 / 主·次级会话
- **多平台 IM 渠道插件(已接通)** — KOOK、微信 iLink 已打通;OneBot v11 QQ / Telegram / Webhook 渠道插件均内置、可配置、随二进制分发
- **微信 iLink 扫码绑定 + 自动认主** — 扫码登录(`login-qr` / `login-status`)已打通;绑定确认时**自动把主人 id 登记进 owner 白名单**(热生效,无需人工补录)
- **多渠道主会话同步** — AI 渠道(web / kook / weixin)主人私聊汇聚到主会话,Web 端免刷新镜像;群聊 / 陌生人走独立隔离会话
- **参与度引擎**、**backthink 自主思考**(读写记忆 + 调工具 + 主动推送)、**全链路流程可视化 P1**(`graph_update`)
- **系统设置(4 个可写操作)** — 吊销全部 API key / 轮换 JWT / 备份 / 恢复,后端 + 前端真实操作已接通(owner-only + 审计 + 前端确认弹窗)
- **语境感知 UI** — 同一会话上下文下切换 聊天/开发/家庭/工作/知识/语音/视觉/子代理监控/设置 模式(Miuix 设计语言)
- **超级详尽后台管理面板**(`/admin`)— 15 大模块统一管理(RBAC + 审计)
- **多平台部署** — Linux / Windows / macOS / ARM / Termux;SQLite 默认(PG + pgvector 可选增强,配置后真生效)
- **单二进制、进程内协作** — **框架**的所有服务同进程打包、**框架**仅网关一个对外端口(默认 `: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` 回滚)
- **durable execution / checkpoint** — 每步落盘快照,**崩溃后可从最新 checkpoint 续跑**(`loadCheckpoint` → `Step+1`;仅「崩溃遗留的在途 run」允许恢复,已落定退出一律重开)
- **IM 渠道插件(内置 5 个,随二进制分发)** — **KOOK** ✅、**微信 iLink** ✅(扫码绑定 + 绑定确认时**自动把主人登记进 owner 白名单**,热生效)、**QQ OneBot v11**(正/反 WS × HTTP,四端组合齐;判主 / 收发 / 语音转写均**真机验证过**,但主人 2026-10-05 因风控决定**暂不连**,代码保留)、**Telegram**(长轮询 / webhook,需 Bot token)、**通用 Webhook**(入站,已挂网关)
- **多渠道主会话同步** — 主人私聊(Web / KOOK / 微信 / QQ)汇聚到主会话,Web 端免刷新镜像;群聊 / 陌生人走独立隔离会话,不写记忆、不入主会话上下文
- **后台思考与主动推送** — 自主思考心跳 + 事件触发(安全域 / 急性事件可穿透安静时段)+ 静默时段降频(`THINK_QUIET_INTERVAL_SEC`)+ 主动推送限频;**自排思考 `request_think`**(她可在一次思考里给自己排下一轮,有窗口次数上限)
- **技能(Skill)** — 目录式 YAML(`configs/skills/<id>/skill.yaml`,内含示例 `daily-brief`);**渐进披露**(清单只给 description,命中才展开正文)+ **工具面收缩**(`allowed-tools` 是能力上限,只收不放);**`Runner.Run` 多步执行可用**(步骤拓扑 / 环检测 / 并行上限 / guardrail 强制点)
- **插件声明的管理页面** — `GET /api/v1/admin/panels`(按角色过滤的接入点快照)+ `POST /api/v1/admin/plugins/{id}/actions/{action}`(**真执行**已登记动作,写动作走 owner/admin + 审计);插件生命周期变化推 WS 帧 `panels_changed`
- **网络搜索** — 原生 **6 家 provider**(SearXNG 本机兜底 / 智谱 / 阿里 IQS / 博查 / 火山·豆包 / 百度千帆)+ 降级链 + **配置面板热重载**(改配置即生效、无需重启;会让 web-search 无可用后端的改动会被回滚并报 `409`)
- **文件 / 文档进对话** — 上传 → 抽取正文 sidecar → 按预算注入上下文;**含 PDF 摄取**;引用计数 + 保留期 GC(仍被引用的文件绝不删)
- **语音** — 云端 **STT / TTS 真实接通**(阿里云百炼 DashScope:`qwen-audio-3.0-asr-flash` / `cosyvoice-v3-flash`),含**声音复刻**入口;IM 入站语音自动转写;**语音落库 + 回放**(消息 DTO `voice` + `GET /api/v1/media/{name}`,支持 Range);不可播格式(QQ AMR)**转码成 wav** 后再落副本
- **浏览器工具** — 自研**出站 CDP**(不发 `Runtime.enable`,减少被识别面);**有头 Chrome + 持久化 profile**(过风控口径);动作 `open` / `screenshot` / `info`(owner-only);**截图落盘后自动进本轮上下文**;插件声明页(B4)已落地
- **MCP 客户端** — 接入官方 MCP SDK(Go);stdio 子进程 / SSE / Streamable HTTP;工具**自动发现并注册**进能力面
- **实时全链路流程图(含时间轴)** — `graph_update` 实时帧 + 落盘 `flow_graphs` 多轮历史 + `GET /api/v1/admin/flow/graph`(最近一轮 / 指定 `turnId` / `lanes[]`)+ `GET /api/v1/admin/flow/timeline`(按时间正序的多轮时间轴,支持 `limit` / `before` 分页 + 保留期信号);**真并行每路各发自己的帧**(不再互相覆盖);Flow 页提供「最近一轮 / 时间轴」两种视图
- **超级详尽后台管理面板**(`/admin`,路径可配)— **18 个静态模块**(仪表盘 / 人格 / 会话 / 记忆 / 知识库 / 插件 / 渠道 / 渠道事件 / IoT / LLM / 自动化 / Agent 监控 / 全链路流程 / 后台思考 / 通知 / 可观测性 / 用户与权限 / 系统设置)+ **插件声明的动态页**,统一 RBAC(`owner` > `admin` > `viewer`)+ 审计
- **系统设置(4 个可写操作)** — 吊销全部 API key / 轮换 JWT / 备份 / 恢复(owner-only + 审计 + 前端确认弹窗)
- **语境感知 UI** — 用户端是**一个共享页面**:单页消息流,每条消息带自己的 mode,气泡按 mode 渲染(`chat` / `code` / `home` / `work` / `docs` / `voice` / `vision` / `agents` / `settings` 共 9 种);统一 **Miuix 设计语言** + PWA
- **多端契约** — 用户侧 REST `/api/v1` + 实时 WS `/ws/chat` + 管理 `/api/v1/admin/*`,**版本化契约 v0.81**(权威文档 `Docs/api-client.md`)
- **多平台部署** — Linux / Windows / macOS / ARM / Termux;**SQLite 默认**(纯 Go、单二进制);PG + pgvector **可选增强**(配置后真走语义 / 向量路径)
### ⚠️ 部分实现(降级 / 占位)
### ⚠️ 部分实现 / 需凭据
- **视觉(真实模型未接通)** — vision 分层路由与推理入口已实现,但**真实视觉 / OCR 模型服务未接入**:需配置 `LLM_VISION_MODEL` 及凭据;无凭据时降级为主模型直接看图 / 视觉·OCR 工具文本描述
- **语音(ASR / TTS 真模型未接通)** — STT→chat→TTS 链路已接,但**真实 ASR / TTS provider 未接入**:cloud STT 端点为占位(空 endpoint → 降级),edge-tts / espeak 为未实现占位;无凭据时全程降级(`silent` 返回空音频)。需配 ASR / TTS endpoint
- **RAG 语义 / 向量检索** — 导入文档当前走 keyword / line 路径(知识库 store 无直属向量索引),向量语义为**优雅降级**;配置 PG + pgvector 增强后真走语义路径
- **视觉** — 分层路由(主模型 → 工具化视觉 / OCR)与**真推理入口已落地**;真实视觉 / OCR 模型服务**需配 `LLM_VISION_MODEL`(或 `LLM_OCR_MODEL`)及凭据**。无凭据时降级为主模型直接看图 / 视觉·OCR 工具文本描述
- **语音的真实双向通话(Web 端)** — 上行链路与 STT/TTS 已通;`voice` 模式的「实时通话」交互仍未接(前端标注 deferred)。`edge-tts` / `espeak` 仍是**未实现占位**(无凭据时走降级链到 `silent`)
- **声音复刻** — 代码路径已实现,但仍**需主人提供公网可访问的参考音频 URL** 方能真建自定义音色
- **网络搜索的 5 家云端 provider** — 除本机 SearXNG 兜底外,智谱 / 阿里 IQS / 博查 / 火山 / 百度千帆**均需自配 API key,且尚未真机实测**(无凭据);中文查询在本机兜底上的效果有限
- **RAG 语义 / 向量检索** — 默认走内置 n-gram / FTS 向量(dim=256,离线可用);配置 `EMBEDDING_API_URL` + PG/pgvector 后走**真语义**路径
- **微信 iLink 绑定依赖微信侧资格** — 扫码协议已对齐官方并打通;仍需微信账号侧具备 iLink bot 绑定资格(账号环境依赖)
- **checkpoint(durable execution)** — 仅有 `saveCheckpoint` 保存、**无恢复 / Resume**(长任务中断不可回放,半壳)
- **QQ OneBot v11** — 代码链路与真机验证都成立,但按主人决定**当前不连**(风控原因);接回只需配置并在面板启用
- **主动推送的设备兜底(Getui)** — 自建推送服务的 webhook 出口已实现(`PUSH_EXPORTER_*`);个推(Getui)**SDK 未接入**,仅预留字段
### ❌ 未实现(当前不提供)
- **QQ(OneBot v11)** / **Telegram** — 渠道插件代码存在,但未接通(需真 OneBot/Bot token)
- **系统设置**:**编辑环境变量 / 重启服务 / 检查更新** — 后端未实现,不提供
- **知识库删除**(`POST /api/v1/admin/rag/delete` 写操作)— 当前返回 `501 not_implemented`
- **全链路 P2** — 并行子代理分支 / 泳道 / 节点实时日志(当前 loop 未在 span 上记录事件,`log[]` 常空)
- **LLM 异步记忆提取** — 仅手工提取接线,LLM 异步提取未接入
- **`Runner.Run` 多步工作流未接进 loop** — 技能进入 loop 的目前只有「清单 / 命中展开正文 / 工具面收缩」;多步工作流执行器(`Runner.Run` / `Runner.RunSkill`)本身可用且有单测,但**模型还不能在对话里直接触发一套多步技能**
- **浏览器 MCP 委派未落地** — `browser` 工具只提供 `open` / `screenshot` / `info`;`click` / `type` / `wait` / `extract` 仅在工具描述里指路,实际需经 MCP 浏览器服务补齐(未接)
- **搜索降级档 B5 未做**、浏览器容器 docker 配方(CJK 字体)**未落盘**、浏览器会话**未挂进程关闭链**
- **系统设置**:编辑环境变量 / 重启服务 / 检查更新 — 后端未实现,不提供
- **自排思考 C2 的写路径(面板可调)** — 自排的**成本可见面(读侧:`?trigger=` 筛选)已落地**;把后台思考调优参数做成**面板可保存**(`SaveBackgroundTuning` + admin 端点)的部分仍在做
- **全链路 P2 剩余** — token / 耗时统计、移动端优化(并行路 / 泳道 / 节点实时日志本身已落地)
- **群聊类能力** — 冷避、@ 窗口期、戳一戳回应等**全部依赖有群聊的平台**;因 QQ 暂不连、微信 iLink 只支持绑定微信号私聊,当前暂无活体群聊对象(队列保留,不删)
- **IM 路由主动消息的行 id 回填(design/19 §6.10-2)** — 需扩 `SendProactive` 返回值并改全部 mock,本期不做
---
## 本地部署(用户侧 · 全流程)
## 快速开始
### 0. 前置
- Go 1.26、Node 22(前端构建用)、Docker(用于 PostgreSQL + pgvector;不用 PG 可仅用 SQLite)。
- **Go 1.26**、**Node 22**(前端构建用)、**Docker**(可选:PG + pgvector;不用 PG 可仅用 SQLite)。
### 1. 构建
### 1. 一键启动(最快,推荐)
```bash
bash scripts/dev-up.sh
# 依次拉起:PG+pgvector → llama.cpp embedding(Qwen3) → 前端 build → cyrene
# 访问 http://localhost:8080(按 .env 的 PORT)
# 前端(必做一次:go:embed 需要 dist;构建脚本会同步到 internal/webui/dist)
cd frontend/web && npm ci && npm run build && cd ../..
# 后端二进制(Windows)
scripts\ctrl.bat build
# Linux / macOS(无 build 子命令,直接 go build)
go build -ldflags="-s -w" -trimpath -o cyrene ./cmd/cyrene/
```
> `scripts/dev-up.sh` 约定:llama.cpp 放 `~/apps/llama.cpp/`、GGUF 放 `~/.models/`、cyrene 二进制 `~/apps/cyrene/cyrene`。已运行的服务会自动跳过。
### 2. 配置 `.env`(复制 `.env.example`)
> `scripts\ctrl.bat build` 是 Windows 上的权威构建(带 `-ldflags="-s -w" -trimpath`),产物写到仓库根 `cyrene.exe`。
> **换二进制**:先 `build` 到位,再**一次** `restart`(不留空窗)。
> **纯前端改动不需要重启后端**:运行时**优先读磁盘 `frontend/web/dist`**(存在即覆盖内嵌 dist),刷新浏览器即可。
### 2. 启停(本地服务控制)
```bash
# 认证(JWT_SECRET 必填 ≥32)
# Windows
scripts\ctrl.bat start # 启动(无参 = start;已在跑则跳过)
scripts\ctrl.bat stop # 停止(先优雅、后强杀)
scripts\ctrl.bat restart # 停止 + 启动
scripts\ctrl.bat status # 端口 + PID + /api/v1/health
scripts\ctrl.bat build # 只构建二进制
# Linux / macOS(等价,无 build)
bash scripts/ctrl.sh start|stop|restart|status
```
> 端口取 `.env` 的 `PORT`,缺省回落 `10080`。日志 `%USERPROFILE%\cy.log`(每次启动前把上一份归档为 `cy.log.<时间戳>.bak`,保留最近 20 份),PID 文件 `%USERPROFILE%\.cyrene.pid`。
> 启动默认**分离进程**(`Start-Process -WindowStyle Hidden`):从 Git Bash 调 `restart` 时请把输出重定向到文件(`> /tmp/cy-restart.log 2>&1 < /dev/null`),否则 bash 会等管道 EOF 而「不返回」;**判定成功要看「新 PID + health ok + 启动日志」,不是看命令退没退出**。
### 3. 配置 `.env`(复制 `.env.example`)
```bash
# 认证(JWT_SECRET 必填 ≥32,过短启动即 panic)
JWT_SECRET=<≥32 字符随机串>
ADMIN_USERNAME=admin
ADMIN_PASSWORD=<你的密码>
# LLM(LLM_PROVIDER 显式声明协议:openai / anthropic)
# LLM(LLM_PROVIDER 显式声明协议:openai / anthropic,默认 openai)
LLM_PROVIDER=anthropic
ANTHROPIC_BASE_URL=https://api.deepseek.com/anthropic
ANTHROPIC_API_KEY=<key>
ANTHROPIC_MODEL=deepseek-flash
# 或 OpenAI 兼容:LLM_PROVIDER=openai + LLM_BASE_URL/API_KEY/MODEL
# 或 OpenAI 兼容:LLM_PROVIDER=openai + LLM_BASE_URL / LLM_API_KEY / LLM_MODEL
# 数据库(可选增强:PG + pgvector 向量检索;不配则用 SQLite)
DB_PG_URL=postgres://cyrene:cyrene_pass@localhost:5432/cyrene_ai?sslmode=disable
@@ -77,82 +119,138 @@ EMBEDDING_API_URL=http://localhost:11434/v1/embeddings
EMBEDDING_MODEL=Qwen3-Embedding-0.6B
```
### 3. 数据库(PG + pgvector,Docker)
### 4. 可选依赖
```bash
# PG + pgvector(可选增强:向量索引走这里)
docker compose -f docker-compose.dev.yml up -d pg
# 首次:vector 扩展由应用自动 CREATE(本地 cyrene 用户为 superuser)
```
> 不用 PG 可跳过;SQLite 默认即用。若 Docker 未安装 compose 插件(部分精简版 29.x 只有 `docker run`),
> `scripts/dev-up.sh` 会自动回退 `docker run` 拉起 `cyrene-pg`,无需手动处理。
### 4. 本地 embedding(llama.cpp + Qwen3 GGUF,走 Vulkan/GPU)
```bash
# 一次准备(llama.cpp 放 ~/apps/llama.cpp/,模型放 ~/.models/;勿用 /tmp)
mkdir -p ~/apps/llama.cpp ~/.models
# llama.cpp release(Vulkan)解压到 ~/apps/llama.cpp/
# Qwen3-Embedding-0.6B-Q8_0.gguf(~639MB,ModelScope 下载)→ ~/.models/
chmod 644 ~/.models/Qwen3-Embedding-0.6B-Q8_0.gguf
# 本地 embedding(可选:真语义;llama.cpp + Qwen3 GGUF,CPU/GPU)
scripts\embed.bat start|stop|status # Linux / macOS:见 embed.bat 内注释自行起 llama-server
# 启动
~/apps/llama.cpp/llama-server -m ~/.models/Qwen3-Embedding-0.6B-Q8_0.gguf \
--embedding -ngl 99 --host 127.0.0.1 --port 11434 &
# 本机 SearXNG 兜底搜索(可选;配置见 scripts/searxng/settings.yml)
```
> **模型来源**:HuggingFace 国内不可访问,请从 **ModelScope(魔搭)** 下载 `Qwen3-Embedding-0.6B-Q8_0.gguf`。llama.cpp 只认 **GGUF**(`model.safetensors` 是 transformers/HF 用的,不能直接喂)。
### 5. 构建并启动(单二进制,go:embed 前端)
```bash
cd frontend/web && npm ci && npm run build && cd ../.. # 产 dist(含拷到 internal/webui/dist)
go build -ldflags="-s -w" -trimpath -o cyrene ./cmd/cyrene/
./cyrene # 访问 http://localhost:8080(按 .env 的 PORT)
```
> `scripts/dev-up.sh` 是 Linux / macOS 上的**一键全流程**(PG → llama.cpp embedding → 前端 build → cyrene),约定 llama.cpp 放 `~/apps/llama.cpp/`、GGUF 放 `~/.models/`、二进制 `~/apps/cyrene/cyrene`;已运行的服务自动跳过。
### 5. 访问
- 用户端 SPA:`http://localhost:8080`(按 `.env` 的 `PORT`)
- 管理面板:`http://localhost:8080/<ADMIN_PATH>`(`ADMIN_PATH` 默认 `admin`;REST/admin API 仍是 `/api/v1/admin/*`,安全靠 JWT + 角色门)
### 6. 服务端口 / 路径速查
| 服务 | 地址 / 路径 | 说明 |
|------|------------|------|
| Cyrene 网关 | http://localhost:8080(`.env:PORT`) | SPA + REST/WS + 面板 `/admin` |
| PG + pgvector | `localhost:5432`(docker `cyrene-pg`) | 向量索引在 `cyrene_ai` |
| llama.cpp embedding | `http://localhost:11434/v1/embeddings` | Qwen3,Vulkan |
| Cyrene 网关 | `http://localhost:8080`(`.env:PORT`) | SPA + REST/WS + 面板 |
| PG + pgvector | `localhost:5432`(docker `cyrene-pg`) | 可选;向量索引在 `cyrene_ai` |
| llama.cpp embedding | `http://localhost:11434/v1/embeddings` | 可选;Qwen3 |
---
### 7. 注意事项
- **勿用 `/tmp` 放二进制/模型**(tmpfs 在内存里,堆多了会炸);一律 `~/apps/`、`~/.models/`。
- `EMBEDDING_API_URL` **必须是完整 `/v1/embeddings`**(不是 base),否则 404。
- **切换 embedding 模型会改维度**(Qwen3=1024,n-gram=256)→ 换模型前清一次向量表:`docker exec cyrene-pg psql -U cyrene -d cyrene_ai -c "TRUNCATE vec_items;"`(懒创建的真向量表)。
- LLM 用 anthropic 协议时填 `ANTHROPIC_*`;用 openai 兼容时填 `LLM_*`。`LLM_PROVIDER` 二选一。
## 配置
### `.env` 已冻结(最小化可跑,不加 / 不改 / 不删)
`.env` 只保证「**最小化功能可跑**」的引导面,**不是配置面**。主人 2026-10-06 定红线:
- **基础键已经定好,以后不准改**:不加、不改、不删;模板 `.env.example` 与 `.env` 同进退。
- **新配置一律不进 `.env`**:调优 / 门槛 / 上限 / 开关类参数走 **`$DATA_DIR/config/**`**(管理面板可改、热重载)+ 代码内默认值。
- 既有基础键与既有 `env:` tag **保持现状**(不迁移、不改默认值、不删键)。
> 想要调优项请走面板(如「LLM 模型」页写 `models.json`、「渠道管理」页写 `channels.json`、插件配置写 `$DATA_DIR/config/plugins/<plugin-id>.json`),或直接改 `$DATA_DIR/config/**` 下的文件。
> 口径全文见 `DevDocs/design/13 §2.1`;`AGENTS.md §10` 亦载明。
### 数据目录 `DATA_DIR`
默认 `./data`;显式写 `platform`(保留字哨兵)则走平台标准数据目录(Windows `%APPDATA%\Cyrene`、macOS `~/Library/Application Support/Cyrene`、Linux `~/.local/share/cyrene`)。
---
## Embedding 模型推荐
> 用于长记忆/知识库**语义向量检索**(默认不配 = 内置 n-gram 向量,dim=256,离线但质量一般)。
> 配了 `EMBEDDING_API_URL/MODEL` 后走**真语义**。维度 = 模型 dim,与向量索引一致;**切换模型会改维度,需清空旧向量数据**。
> 用于长记忆 / 知识库的**语义向量检索**(默认不配 = 内置 n-gram 向量,dim=256,离线但质量一般)。
> 配了 `EMBEDDING_API_URL` / `EMBEDDING_MODEL` 后走**真语义**。维度 = 模型 dim,与向量索引一致;**切换模型会改维度,需清空旧向量数据**。
| 模型 | dim | 大小 | 推理内存 | 质量 | 建议 |
|------|-----|------|---------|------|------|
| **Qwen3-Embedding-0.6B** | 1024 | Q8_0 **639MB** / f16 1.2GB | ~0.7–1GB | ★★★★★(多语言、2025 新、MTEB 榜前列) | ⭐ **首选**(ModelScope 国内可下 + GGUF;Q8_0 639MB 契合低内存) |
| **Qwen3-Embedding-0.6B** | 1024 | Q8_0 **639MB** / f16 1.2GB | ~0.7–1GB | ★★★★★(多语言、2025 新、MTEB 榜前列) | ⭐ **首选**(ModelScope 国内可下 + GGUF) |
| **bge-m3** | 1024 | ~2.2GB(fp16) / ~1.2GB(Q4) | ~2–3GB | ★★★★★(跨语言标杆) | 内存 / GPU 充裕时可用 |
| **bge-small-zh-v1.5** | 512 | ~30–100MB | ~几十MB | ★★★★ | 极轻量备选 |
| nomic-embed-text | 768 | ~270MB | ~400–600MB | ★★★☆ | 可 |
| mxbai-embed-large | 768 | ~350–670MB | ~0.8–1GB | ★★★★ | 可 |
> **首选说明**:**Qwen3-Embedding-0.6B**(阿里通义 2025-06 开源,覆盖 100+ 语言,MTEB 多语言榜前列)——**有 GGUF**、**ModelScope(魔搭)国内直连可下**(HuggingFace 在国内已不可访问);`Q8_0` 仅 **639MB**,即使内存紧张(如 ~3GB 可用、无独立显存的 UMA GPU)也能跑,且质量≈f16。比 bge-m3 更新、比 bge-small 强。若内存非常宽裕再考虑 bge-m3。
> **首选说明**:**Qwen3-Embedding-0.6B**(阿里通义开源,覆盖 100+ 语言)——**有 GGUF**、**ModelScope(魔搭)国内直连可下**(HuggingFace 在国内已不可访问);`Q8_0` 仅 **639MB**,内存紧张(如 ~3GB 可用、无独立显存的 UMA GPU)也能跑,质量≈f16。
>
> **llama.cpp 能替代 Ollama 吗?** 能,且更省内存。`llama-server`(服务模式)暴露 **OpenAI 兼容 `/v1/embeddings`**:
> ```bash
> ./llama-server -m Qwen3-Embedding-0.6B-Q8_0.gguf --embeddings -ngl 99 --host 127.0.0.1 --port 11434
> # 然后 EMBEDDING_API_URL=http://localhost:11434/v1/embeddings 照用(dim=1024;切换前清一次向量表)
> ```
> - **Ollama**:模型管理方便(`pull`)、开箱即用,但常驻偏重。
> - **llama.cpp**:单文件更轻、可离线自制 GGUF;但模型要自己下 / 转(无 registry)。**只认 GGUF**,`model.safetensors` 不能直接喂。
`.env` 示例(本地 Ollama / llama.cpp):
```bash
# EMBEDDING_API_URL 必须是【完整 /embeddings 端点】(不是 base)
EMBEDDING_API_URL=http://localhost:11434/v1/embeddings
EMBEDDING_MODEL=Qwen3-Embedding-0.6B
# EMBEDDING_API_KEY=
### 注意事项
- **勿用 `/tmp` 放二进制 / 模型**(tmpfs 在内存里);一律 `~/apps/`、`~/.models/`。
- `EMBEDDING_API_URL` **必须是完整 `/v1/embeddings`**(不是 base),否则 404。
- **切换 embedding 模型会改维度**(Qwen3=1024,n-gram=256)→ 换模型前清一次向量表:`docker exec cyrene-pg psql -U cyrene -d cyrene_ai -c "TRUNCATE vec_items;"`。
- LLM 用 anthropic 协议时填 `ANTHROPIC_*`;用 openai 兼容时填 `LLM_*`。`LLM_PROVIDER` 二选一。
---
## 目录结构
```
Cyrene_v2/
├── cmd/cyrene/ # 唯一入口(wire_*.go 组合根:把各子系统接线 + go:embed 前端)
├── internal/ # 进程内模块(不对外)
│ ├── config/ # .env 加载 + $DATA_DIR/config/** 可变源(面板一等来源)
│ ├── logger/ platform/ db/ bus/ errors/ auth/ user/ # 地基
│ ├── runtime/ # 核心运行时:model / agent / loop / tool / guardrail / trace /
│ │ # persist(checkpoint) / memory / embedding / multimodal /
│ │ # orchestration / exec / task / flowgraph / documents / voice / capacity
│ ├── persona/ capability/ channel/ iot/ urgency/ contract/
│ ├── services/ # gateway(REST/WS/admin) / automations / backthink / approvalreview / channelbridge
│ └── webui/ # 内嵌前端 dist(go:embed)
├── plugins/ # 一等插件:tools(basic/io/browser) + channels(kook/qq_obv11/telegram/webhook/weixin_ilink)
├── pkg/plugins/sdk/ # 插件 SDK(第三方接入)
├── configs/ # 配置模板:personas/ + users/(主人档案)+ skills/(示例技能)
├── frontend/web/ # React SPA(单页消息流 + mode 渲染器)+ 管理面板(Miuix);构建产物 dist/ 供磁盘热覆盖
├── Docs/ # 用户文档(进 git):api-client.md(客户端 API 契约)+ archive/
├── DevDocs/ # 开发文档(**不进 git**,本地锚点:设计 / 时间线 / 工作小结 / 审计)
├── data/ # 运行时数据(DATA_DIR;SQLite / uploads / 面板写入的 *.json)
├── scripts/ # ctrl.bat / ctrl.sh / embed.bat / dev-up.sh / searxng/
└── services/ # 预留(外部服务扩展位)
```
**llama.cpp 能替代 Ollama 吗?** —— 能,且更省内存。llama.cpp 的 `llama-server`(服务模式)同样暴露 **OpenAI 兼容 `/v1/embeddings`**,用单二进制 + 一个 GGUF:
```bash
./llama-server -m Qwen3-Embedding-0.6B-Q8_0.gguf --embedding -ngl 99 --port 11434
# 然后 EMBEDDING_API_URL=http://localhost:11434/v1/embeddings 照用(dim=1024;切换前清一次向量表)
```
- **Ollama**:模型管理方便(`pull`)、开箱即用,但常驻偏重。
- **llama.cpp**:单文件更轻(内存更省,适合资源受限 / 单机部署)、可离线自制 GGUF;但模型要自己下/转(无 registry)。注意:**llama.cpp 只认 GGUF**,`model.safetensors` 是 transformers/HF 用的,不能直接喂 llama.cpp。
- 二者对上层代码**等价**(都是 OpenAI 兼容 embedding 端点);资源受限时 llama.cpp 更省内存,追求易用则选 Ollama。
> `Docs/`(大写 D)**进 git**;`DevDocs/` **被 gitignore,不进 git**。`*.exe`、`dist/`、`data/`、`.env` 等亦不入库(见 `.gitignore`)。
---
## 已知缺口与路线图
> **不美化**:以下是**当前真做不了 / 没做完**的事。
**核心运行时**
1. 🔴 **`Runner.Run` 多步工作流未接进 loop** — 技能的多步执行器可用且有单测,但**模型还不能在对话里触发一整套多步技能**(现只到「清单 / 命中展开正文 / 工具面收缩」)。
2. **自排思考 C2 的写路径未做** — 自排的成本可见读侧(`?trigger=` 筛选)已落地,面板可保存的后台思考调优(`SaveBackgroundTuning` + admin 端点)在做。
3. **全链路 P2 剩余** — token / 耗时统计、移动端优化。
**浏览器**
4. 🔴 **MCP 委派未落地** — `click` / `type` / `wait` / `extract` 尚未接通(只在工具描述里指路)。
5. **搜索降级档 B5 未做**;容器 docker 配方(CJK 字体)未落盘;浏览器会话未挂进程关闭链。
**渠道**
6. **QQ OneBot v11 暂不连**(主人 2026-10-05 因风控决定);**Telegram 需 Bot token**。群聊类能力(冷避 / @ 窗口 / 戳一戳)因此暂无活体平台可验。
7. **搜索 5 家云端 provider 未真机实测**(无 key);微信 iLink 依赖微信侧绑定资格;声音复刻需公网参考音频 URL。
8. **视觉 / OCR 真模型需凭据**;`edge-tts` / `espeak` 仍是占位。
**治理 / 其他**
9. **系统设置**:编辑环境变量 / 重启服务 / 检查更新 — 后端未实现。
10. **Getui(个推)兜底未接入**(SDK 未接);待主人自建推送服务。
11. **部分真机端到端未验**(如实登记):时间轴真机面板联调、搜索 provider 热重载真机端到端、AMR 落副本生产端到端、`graph_update` 真并行真机端到端、`design/19 §6.8` 的 Q1–Q4。
12. **Android 客户端**另会话推进(跨仓协同事项见 `DevDocs`,不入本仓库)。
---
@@ -160,30 +258,12 @@ EMBEDDING_MODEL=Qwen3-Embedding-0.6B
| 层 | 技术 |
|----|------|
| 后端 | Go 1.26(单二进制运行时)、进程内总线(可换 NATS)、slog(日志带 `source` 来源标注) |
| 后端 | Go 1.26(单二进制运行时)、进程内事件总线(API 刻意对齐 NATS,便于日后换出进程 transport)、`slog`(日志带 `source` 来源标注) |
| 前端 | React 19、TypeScript、Vite 6、Tailwind 4、Zustand 5、PWA(Miuix 设计语言) |
| 数据库 | **SQLite 默认**(纯 Go、单二进制);PG + pgvector 可选增强(配置后真生效) |
| 多模态 | vision(分层路由:主/视觉/OCR;真模型未接通,无凭据降级主模型)、async voice(STT→agent→TTS 降级链;真 ASR/TTS 未接通,无凭据降级 silent) |
| LLM | 多供应商(OpenAI 兼容/订阅/本地/网关)、per-role 路由 + 降级 |
---
## 目录
```
Cyrene_v2/
├── cmd/cyrene/ # 唯一入口(mapping 全部子系统 + go:embed 前端)
├── internal/ # config/logger/platform/db/bus/errors/auth/user/
│ # runtime(核心: model/agent/loop/tool/guardrail/trace/persist/
│ # memory/embedding/multimodal/orchestration/exec/task)
│ # persona/capability/channel/services(gateway/automations/backthink)/iot/webui
├── plugins/ # tools(basic/io) + channels(qq_obv11/weixin_ilink/telegram/webhook)
├── pkg/plugins/sdk/ # 插件 SDK(第三方)
├── frontend/web/ # React SPA(mode 系统)+ /admin 面板(Miuix)
├── Docs/ # 用户文档(含 api-client.md 客户端 API 契约)
├── DevDocs/ # 开发文档(本地,不入库)
└── configs/ # 配置模板(personas/ + users/ 主人档案)
```
| 多模态 | vision(分层路由:主 / 视觉 / OCR;真模型需凭据,无凭据降级主模型)、voice(DashScope STT/TTS 真实接通 + 降级链;无凭据降级 `silent`) |
| LLM | 多供应商(OpenAI 兼容 / Anthropic / 本地 / 网关)、per-role 路由 + 降级(主 → 备 → 本地) |
| 插件 / 协议 | 进程内插件 SDK(`pkg/plugins/sdk`)、官方 MCP SDK(stdio / SSE / Streamable HTTP) |
---
@@ -191,10 +271,10 @@ Cyrene_v2/
| 文档 | 位置 | 说明 |
|------|------|------|
| 架构总览/重构蓝图 | `DevDocs/00-overview.md` | 产品定位、架构、渠道插件、后台面板、阶段计划 |
| 开发约束 | `DevDocs/02-development-constraints.md` | 编码规范 + 性能要求 |
| **客户端 API** | `Docs/api-client.md` | 跨平台客户端 API 契约(REST/WS/admin,版本化) |
| 开发铁律 | `AGENTS.md` | 每会话自动注入;含全球红线(单二进制/身份识别/前端 Miuix 等) |
| 架构总览 / 重构蓝图 | `DevDocs/00-overview.md` | 产品定位、架构、渠道插件、后台面板、阶段计划(**本地,不进 git**) |
| 开发约束 | `DevDocs/02-development-constraints.md` | 编码规范 + 性能要求(**本地**) |
| **客户端 API** | `Docs/api-client.md` | 跨平台客户端 API 契约(REST / WS / admin,版本化,当前 **v0.81**) |
| 开发铁律 | `AGENTS.md` | 每会话自动注入;含全球红线(单二进制 / 身份识别 / 前端 Miuix 等) |
---
+38 -33
View File
@@ -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 优先(面板里刚存的就是它),
+286
View File
@@ -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)
}
})
}
}
+436
View File
@@ -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)
}
}
+200
View File
@@ -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)
}
+112 -32
View File
@@ -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。
+46
View File
@@ -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)
}
}
+159
View File
@@ -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()
}
+361
View File
@@ -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)
}
}
+113 -15
View File
@@ -14,10 +14,10 @@ package main
// - 「图 → 内容部件」的路由与裁切由 `internal/runtime/multimodal/vision/shot_test.go` 覆盖;
// - 本文件把两者接起来,并且**断言模型侧确实拿到 image 部件**(判据 2 的原文要求)。
//
// ⚠️ **接线状态(如实)**:本文件把 `a.browserShotTransform(...)` 装进 `loop.Options`
// 来验它;生产是否装上由 `wire_runtime.go` 的一行决定(本片无权改那个文件,见
// `wire_browser_context.go` 的接线状态注释)。`TestWireRuntime_WiresBrowserShotTransform`
// 会盯着那一行:装上之前它 Skip 并写明要加什么,装上之后它就是正向断言。
// 生产侧的**两条挂点**都由本文件盯住(它们删掉之后都没有任何运行期症状):
// `TestWireRuntime_WiresBrowserShotTransform` 钉 `wire_runtime.go` 的 `TransformContext`;
// `TestWireFrontend_WiresBrowserPanel` 钉 `wire_frontend.go` 的 `wireBrowserPanel` 调用
// (`TestWirePanelHub_DeclaresBrowserPanel` 是它的**行为侧**配偶:真装配一遍再读回目录)。
import (
"bytes"
@@ -30,7 +30,10 @@ import (
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
browsercap "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser"
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
@@ -113,7 +116,8 @@ func newB3Fixture(t *testing.T, sighted bool) *b3Fixture {
shot: shotPath,
loop: loop.New(loop.Options{
Agent: ag,
// ← 生产接线就是这一行(见 wire_runtime.go 的接线状态注释)。
// ← 与生产接线逐字同一行(wire_runtime.go 的 loop.Options.TransformContext),
// 由 TestWireRuntime_WiresBrowserShotTransform 源码级盯住。
TransformContext: a.browserShotTransform(resolver),
OnStream: func(model.StreamEvent) {},
}),
@@ -194,21 +198,95 @@ func TestBrowserShotClosure_ShotIsConsumedOnce(t *testing.T) {
}
}
// TestWireRuntime_WiresBrowserShotTransform 盯住生产接线那一行。
// TestWireRuntime_WiresBrowserShotTransform 盯住生产接线那一行(B3 的生产挂点)。
//
// 装上之前**Skip 并写明要加什么**(不是失败:本片(Y4)的文件面不含 wire_runtime.go,
// 那一行由主会话落地)。装上之后本用例就是正向断言 —— 它拦的是「以后有人把那行删了」,
// 因为删掉之后**没有任何运行期症状**:编译照过、回合照跑,只是截图不再进上下文。
// 它拦的是「以后有人把那行删了」:删掉之后**没有任何运行期症状**——编译照过、回合照跑,
// 只是截图不再进上下文。因此断言的是**一整行字段赋值**(行首到行尾),被注释掉的
// (`// TransformContext: …`)或写在别处的同名字符串都不会让它通过。
func TestWireRuntime_WiresBrowserShotTransform(t *testing.T) {
t.Parallel()
src := readSource(t, "wire_runtime.go")
if !strings.Contains(src, "browserShotTransform(") {
t.Skip("接线未应用:请在 wire_runtime.go 的 loop.New(loop.Options{…}) 里加 " +
"`TransformContext: a.browserShotTransform(visionResolver),`" +
"(B3 闭环的生产挂点;本片无权改该文件)")
const assignment = "TransformContext: a.browserShotTransform(visionResolver),"
if !hasNormalizedCodeLine(src, assignment) {
t.Fatalf("wire_runtime.go 的 loop.Options 里找不到 %q —— B3 截图闭环的生产挂点缺失:"+
"截图照落盘、回合照跑,只是**一张图都不会进上下文**", assignment)
}
if !strings.Contains(src, "TransformContext:") {
t.Fatal("wire_runtime.go 里出现了 browserShotTransform,但没挂在 TransformContext 上")
// 钩子捕获的就是 `visionResolver`,因此它必须在 loop.New 之前建好;顺序反过来装上的
// 就是一个 nil 解析器(ShotAttacher 为 nil),截图静默不送——同样是**零症状**的坏法。
idxResolver := strings.Index(src, "visionResolver := vision.NewMessageResolver(")
idxLoop := strings.Index(src, "a.loop = loop.New(loop.Options{")
if idxResolver < 0 || idxLoop < 0 || idxResolver > idxLoop {
t.Fatalf("visionResolver 与 loop.New 的先后不对(偏移 %d vs %d):钩子必须在解析器**之后**装配",
idxResolver, idxLoop)
}
}
// TestWireFrontend_WiresBrowserPanel 盯住 B4 的生产挂点:`wirePanelHub` 必须在
// `declareBuiltinPanels` 之后调用 `wireBrowserPanel`。
//
// 与上面那条同族——删掉之后**没有任何编译期或运行期症状**:只是面板目录里没有 browser 页、
// 六个动作全回 501(`GET /api/v1/admin/panels` 少一条、点击全部 501),而整树测试照绿。
// 断言同样锚定**一整行调用**,注释掉那一行不会通过。
func TestWireFrontend_WiresBrowserPanel(t *testing.T) {
t.Parallel()
src := readSource(t, "wire_frontend.go")
const call = "wireBrowserPanel(hub, a, a.log)"
if !hasNormalizedCodeLine(src, call) {
t.Fatalf("wire_frontend.go 的 wirePanelHub 里找不到 %q —— B4 的 browser 面板声明"+
"与六个动作都进不了目录(面板无 browser 页、动作全 501)", call)
}
// 位置:声明在前、登记在后是 PLG-2 的硬要求(登记期会核对有没有对应声明)。这里钉住
// 它**在 wirePanelHub 内**且**在 declareBuiltinPanels 之后**,防止被挪到别处或提前。
idxDeclare := strings.Index(src, "declareBuiltinPanels(hub, log)")
idxBrowser := strings.Index(src, call)
if idxDeclare < 0 || idxBrowser < idxDeclare {
t.Fatalf("wireBrowserPanel 的调用位置不对(declareBuiltinPanels 偏移 %d,调用偏移 %d):"+
"它必须在声明之后(wirePanelHub 内部)", idxDeclare, idxBrowser)
}
}
// TestWirePanelHub_DeclaresBrowserPanel 是上面那条源码级断言的**行为侧配偶**:真的走一遍
// `wirePanelHub` 装配,再从面板中枢里读回目录与动作白名单 —— 声明在、六个动作也登记上,
// 才算 B4 的接线成立。
//
// 两条合起来才够:源码级那条拦「那一行被删掉」,这条拦「那一行还在、但调用链断了」
// (wireBrowserPanel 被改成早返回、hub 传错、或声明/登记顺序被调反 ⇒ 只会得到一串 WARN)。
// 这台机器上**没有**浏览器也没关系:不可用时面板仍要如实声明(`available=false`),
// 这正是 design/09 §5.2.1 的「该面板不注册 + 显式报错」之外的正常档。
func TestWirePanelHub_DeclaresBrowserPanel(t *testing.T) {
t.Parallel()
mgr := pluginmgr.NewManager(registry.New(), filepath.Join(t.TempDir(), "plugins.json"), testLog())
defer mgr.Close(context.Background())
b := bus.New()
defer b.Close()
wirePanelHub(&app{cfg: &config.Config{DataDir: t.TempDir()}, plugins: mgr, bus: b, log: testLog()})
hub := mgr.Panels()
if hub == nil {
t.Fatal("wirePanelHub 未把面板中枢接到插件管理器上")
}
entries, err := hub.List(auth.RoleOwner)
if err != nil {
t.Fatalf("List: %v", err)
}
found := false
for _, e := range entries {
if e.PluginID == browsertools.PanelPluginID && e.Panel.ID == browsertools.PanelIDSettings {
found = true
}
}
if !found {
t.Fatalf("面板目录里没有 browser/settings(目录=%+v):B4 的挂点没生效", entries)
}
for _, action := range []string{
browsertools.ActionState, browsertools.ActionFingerprint, browsertools.ActionShots,
browsertools.ActionShoot, browsertools.ActionCheck, browsertools.ActionSaveConfig,
} {
if _, ok := hub.Action(browsertools.PanelPluginID, action); !ok {
t.Errorf("动作 %q 没进白名单(声明有、没人执行 ⇒ 面板点下去只会回 501)", action)
}
}
}
@@ -216,6 +294,26 @@ func TestWireRuntime_WiresBrowserShotTransform(t *testing.T) {
* 断言助手
* ------------------------------------------------------------------------- */
// hasNormalizedCodeLine 报告源码里是否存在这样**一行代码**:去掉行首空白、把连续的空白
// 折叠成一个空格之后,与 want 逐字相等。被 `//` 注释掉的行一律不算。
//
// 为什么要折叠空白:字段赋值在 gofmt 里按块对齐(`TransformContext:` 与
// `DocumentMaxInjectChars:` 同一组时中间是 7 个空格,格式而非语义),逐字节比对整行会
// 在**一次无关的字段增删触发重新对齐**时误红;而折叠后逐行相等仍然能挡住
// 「注释掉那一行」与「别处的同名字符串」这两种假绿。
func hasNormalizedCodeLine(src, want string) bool {
for _, line := range strings.Split(src, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "//") {
continue
}
if strings.Join(strings.Fields(line), " ") == want {
return true
}
}
return false
}
// findImagePart 返回请求里的第一个 image 部件。
func findImagePart(msgs []model.Message) (model.Content, bool) {
for _, m := range msgs {
+6 -20
View File
@@ -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}
+29
View File
@@ -17,6 +17,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 +58,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 路由。缺凭据的适配器保持未连接(只记日志,绝不作
@@ -122,6 +147,10 @@ func (a *app) wireChannels() error {
// EngageFor 留空(P2a);按群覆盖随 P2c 到来。
// 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,
+11
View File
@@ -203,6 +203,17 @@ func wirePanelHub(a *app) {
})
a.plugins.AttachPanels(hub)
declareBuiltinPanels(hub, log)
// browser 插件(design/28 §8 B4)的面板声明 + 六个动作:它自己就把「先声明、后登记
// 动作」这一步走完(wire_browser_panel.go 一处),因此与 search 的纯声明并列。
//
// 为什么要挡一下 `cfg == nil`:浏览器面板的配置路径与表单默认值**全部**由
// `$DATA_DIR` 推出(wire_browser_panel.go 里 `resolveDataDir(a.cfg)`),而部分只读
// 夹具(wire_panels_test.go / wire_plugin_actions_test.go 只填了 plugins/bus/log)
// 没有配置 ⇒ 直调会解空指针。生产路径上 wireFrontend 跑在 config.Load 之后,
// `a.cfg` 恒非 nil,因此这一支只为「不完整夹具」存在(同 wireBrowser 的取舍)。
if a.cfg != nil {
wireBrowserPanel(hub, a, a.log)
}
// 登记动作处理器必须在**声明之后**:登记期会核对「登记的处理器有没有对应声明」,
// 顺序反过来只会得到一串「声明还没到」的 WARN。
// 日志来源另标一处(动作接线 ≠ 面板中枢):`source=cmd/cyrene/wire_plugin_actions`
+62 -134
View File
@@ -1,13 +1,15 @@
package main
// 本文件装配 io 工具集(plugins/tools/io)的选项:知识检索器、日志,以及
// **web-search 的后端与超时**(切片 WS1-A 接线 + WS1-B provider 层 + PLG-4 可变源)。
// 本文件装配 io 工具集(plugins/tools/io)的选项:知识检索器、日志、
// **web-search 的后端与超时**(切片 WS1-A 接线 + WS1-B provider 层 + PLG-4 可变源),
// 以及 design/28 §8 B5 的**浏览器降级档挂载**(`bodyFallbackOption()`,实现与口径在
// `wire_body_fallback.go` —— 挂载条件 = 能力面里确实有可用浏览器,详见那个文件)。
//
// 为什么单独成文件而不是塞在 wire_runtime.go 里:那条装配线本就接近文件行数上限,
// 而这里新增的裁决(端点/provider 关闭 ⇒ 工具不进注册面;选定 provider 未登记/未配置
// ⇒ 启动报错;面板配置优先、缺失由 `.env` 播种)需要一个能单测的落点。
//
// 五条口径:
// 六条口径:
//
// 1. **后端由配置选定**:主 provider 选谁(`WEB_SEARCH_PROVIDER` / 面板文件),降级链
// 怎么走,各家的 key 与基地址从哪来(design/27 §3)。
@@ -18,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):面板保存后由
@@ -90,6 +96,10 @@ func (a *app) ioToolOptions() []iotools.Option {
opts := []iotools.Option{
iotools.WithKnowledgeRetriever(a.retriever),
iotools.WithLogger(a.log),
// B5 的挂载点:有可用浏览器就挂上浏览器渲染取正文,没有就不挂(口径与实现
// 都在 wire_body_fallback.go)。挂载条件必须在**浏览器装配之后**求值 ——
// 这就是 wireRuntime 里 `a.wireBrowser()` 排在 io 工具集之前的原因。
a.bodyFallbackOption(),
}
return append(opts, a.webSearchOptions(webSearchCauseStartup)...)
}
@@ -127,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 {
@@ -139,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)
// 绝对地址时**整件工具不注册** —— 那两种取值是「关掉搜索」的显式选择,不是配置
@@ -185,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)
@@ -193,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 的处置一致)。
@@ -226,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,
})
@@ -249,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...)
@@ -270,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。
@@ -331,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 {
@@ -403,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
}
+122 -2
View File
@@ -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,14 @@ 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 分钟。
func (a *app) wireMemoryReview() error {
cfg, err := config.LoadMemoryReviewConfig(envPath)
if err != nil {
@@ -34,6 +52,15 @@ 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()
reviewer, err := memory.NewPreInsertReviewer(memory.ReviewConfig{
Enabled: cfg.Enabled,
QueueSize: cfg.QueueSize,
@@ -42,11 +69,25 @@ 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,
}, 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 +97,85 @@ 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
}
// 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) {
+202 -3
View File
@@ -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,21 @@ 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",
},
"main.go": {
"a.memoryReview.Start(ctx)",
@@ -75,16 +96,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) {
+99 -9
View File
@@ -3,28 +3,44 @@ 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` 一直兜着。
//
// 刻意**不做**的两件事(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,
@@ -37,6 +53,80 @@ func (a *app) wireMemoryTidy() error {
return nil
}
// seedMemoryTidyPanel 在 `$DATA_DIR/config/memory-tidy.json` 缺失/空白时播种一份
// 「当前生效值」快照(`memory.TidyTuningSnapshot`),使面板配置从第一次启动起就自足,
// 而不是每次都靠 `.env` 兜。失败只记日志、**绝不阻断启动**:运行期仍按 `.env` +
// 代码默认跑,行为与播种前逐字相同(与 materializeModels 同一口径)。
//
// 闸与 T1 的四键(ingestGate / t1Judge / t1PerWindow / t1WindowSec)在 `.env` 里
// **没有出处**(`.env` 已冻结,它们只由这个面板文件与代码默认决定,见
// wireMemoryReview 的三个 helper),而播种只发生在**面板文件不存在**时 —— 那正是
// 「零值 + 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) {
+267
View File
@@ -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)
}
}
+120 -1
View File
@@ -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",
@@ -206,3 +215,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)
}
}
+3 -3
View File
@@ -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
}
+21 -3
View File
@@ -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),
// 绝不假装成功地回一份空列表。
+20 -7
View File
@@ -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 都在
@@ -344,6 +350,13 @@ func (a *app) wireRuntime() error {
Thinking: thinking,
ReasoningEffort: reasoningEffort,
ThinkingShowToUser: thinkingShow,
TransformContext: a.browserShotTransform(visionResolver),
// 截图闭环(design/28 §7 判据 2 / B3):browser 拍照落盘后,由上一行那个钩子在
// **每个模型步之前**把该会话待送的图并入本轮视图(取走即消费)。它只改本轮所见 ⇒
// 截图不进 checkpoint、也不进后台记忆提取的输入面(design/28 §4 第 3 条)。
// 钩子捕获的就是上面那个 visionResolver,所以这一行必须在它之后;装配细节与
// 「删掉它没有任何运行期症状」这条理由见 wire_browser_context.go。
//
// 按调用(而非整批)准入:敏感与否取决于该次调用的参数
// (file 的 read 自主、write 代审),见 toolAdmissionHooks。
Hooks: a.toolAdmissionHooks(approvalGate),
+12 -2
View File
@@ -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 {
+7 -3
View File
@@ -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 {
@@ -8,6 +8,7 @@ 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";
const scopeTone: Record<string, "ok" | "warn" | "accent" | "neutral"> = {
user: "accent",
@@ -188,6 +189,8 @@ export function MemoriesModule(): React.ReactNode {
<MemoryTidyDryRunCard />
<MemoryTidyApplyCard />
<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} />;
}
@@ -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 };
}
/** 「本次会作用在哪一条账号」的小字。这是修掉「连了谁」这一困惑的关键文案:
@@ -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>
@@ -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();
},
},
};
}
+19 -6
View File
@@ -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 });
+36 -5
View File
@@ -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`);
+26 -6
View File
@@ -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({
+9 -1
View File
@@ -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>>({});
+24 -1
View File
@@ -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 {
+52 -14
View File
@@ -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>
);
}
+20 -2
View File
@@ -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 {
-492
View File
@@ -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>
);
}
+272
View File
@@ -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>
);
}
+51
View File
@@ -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 "运行中";
}
}
+14 -2
View File
@@ -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} />;
+14
View File
@@ -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,16 @@ 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";
+15 -1
View File
@@ -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,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" });
}
+132
View File
@@ -0,0 +1,132 @@
// 助手文本的**行内白名单标签**解析(主人 2026-10-07 需求「基本渲染支持」)。
//
// 背景:昔涟产出的是**少量 HTML 标签**而不是 Markdown —— 段落用 `<p>`(契约 §4.16,
// 由 `splitParagraphs` 处理),动作/旁白用 `<action>`/`<narration>`(由 `splitBubble`
// 处理,Web 渲染为气泡外的灰斜体注记),**行内**则偶发 `<b>` 之类强调标签。
// 后两类此前没有处理 ⇒ `<b>18:35</b>` 会把字面量 `&lt;b&gt;` 直接显示给用户。
//
// 🔴 **`<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>` 显示成 `&lt;script&gt;`,不丢字符);
// ④ 未闭合 / 交错 / 大小写混写 / 带属性 —— 结构不平衡时**整段降级为纯文本**,
// 保证「不崩、字符一个不丢」(降级输出 = 原始字符串,逐字相同);
// ⑤ 大小写不敏感(`<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>`)没有处理 ⇒ 用户直接看到字面的 `&lt;b&gt;`。
// 这个测试锁住「行内标签渲染」这一层,以及**它不能带来的风险**:产物是不可信文本
// (模型生成、用户可诱导),任何把它当 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(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#x27;/g, "'")
.replace(/&amp;/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, "&lt;b&gt;"), "① 不再把 `<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, "&lt;p&gt;") && !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, "&lt;p&gt;"), "⑥ 正文只剩空 `<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, "*动作:她 &lt;b&gt;笑&lt;/b&gt; 了*"), "⑥ 注记内的标签保持字面量(不改注记行为)");
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, "&lt;script&gt;x&lt;/script&gt;"), "⑥ 用户气泡里的白名单外标签仍是字面量");
}
// ── ⑦ `<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, "&lt;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, "&lt;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, "&lt;brow&gt;"), "⑦ `<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, "&lt;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, "&lt;p&gt;") && !has(pureP, "&lt;action&gt;"), "⑧ 零气泡时也不留任何字面标签");
// 不带 `<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, "&lt;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, "&lt;s&gt;划掉&lt;/s&gt;"), `⑨ ${mode}:白名单外的 \`<s>\` 仍逐字字面`);
ok(has(html, "&lt;script&gt;x&lt;/script&gt;"), `⑨ ${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, "&lt;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>丙"), "&lt;brow&gt;"), "⑨ `<brow>` 按白名单外口径逐字原样");
ok(count(card("settings", "甲<br2>乙</br2>丙"), "<br/>") === 0, "⑨ `<br2>` / `</br2>` 同样不是 br");
// 结构不平衡 ⇒ 仍按既有硬口径:整段降级为原文(连那个 `<br>` 也逐字保留,不换行)。
const broken = card("settings", "日落 <b>18:35 了<br>晚安");
ok(has(broken, "&lt;b&gt;18:35 了&lt;br&gt;晚安"), "⑨ 结构不平衡时整段降级为原文(字符一个不丢)");
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, "&lt;br"), "⑨ 非 chat 模式的字面 `<br>` 彻底消失");
}
console.log(`inline markup render test: ${checks} checks passed`);
+64 -6
View File
@@ -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>
);
}
}
+1
View File
@@ -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";
+19
View File
@@ -40,6 +40,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 +93,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).
@@ -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>;
}
+145
View File
@@ -0,0 +1,145 @@
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`)来自这条错误 —— 不上报原话,用户就只看到我们这一侧的猜测
// (真机踩过:窗口上跳出浏览器的抱怨,日志里像浏览器坏了,工具结果里什么都没有)。
// 没有输出时也如实点明,那本身是线索(参数被拒之类通常会带话)。
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)
}
+347
View File
@@ -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)
}
}
}
+1 -1
View File
@@ -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,
+6 -3
View File
@@ -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,260 @@
package browser
// 本文件是**假浏览器**:启动路径的测试必须真起一个子进程,才能证明
// - 子进程的 stdout/stderr 被接住了(失败时上报、成功时不刷屏);
// - 三种失败情形(进程立刻退出 / 端点没就绪 / 预算没了)分辨得开;
// - 「就绪」判的是**我们自己选的那个端口答不答 CDP**(显式端口下 Chromium 不再写
// DevToolsActivePort,见 portrecord.go),因此假浏览器要真的在 argv 给的端口上监听。
//
// 手法是 Go 里既有的「重跑自己」范式(stdlib `os/exec` 的 testhelper 同款,本仓
// `internal/capability/plugin/manager_test.go` 的 MCP stdio 假 server 也用它):
// 被当成浏览器拉起的可执行文件就是**测试二进制自己**(`os.Args[0]`),行为由 profile
// 目录里的一份脚本文件给出 —— 脚本落在 `Dial` 传 `--user-data-dir` 的那个目录里,
// 因此每个用例各说各话、可以并行。
//
// `TestMain` 是接住它的唯一位置:子进程的 argv 是**浏览器参数**(没有 `-test.run`),
// 到不了任何 Test 函数;不接住就会递归跑整个测试套件。
import (
"bytes"
"encoding/json"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
const (
// fakeBrowserScriptFile 是假浏览器脚本的文件名(落在 profile 目录里)。
fakeBrowserScriptFile = "fake-browser.json"
// fakeBrowserUserDataDirFlag 是脚本的定位方式:profile 目录就是 argv 里
// `--user-data-dir=` 的值(真浏览器的同一个参数)。
fakeBrowserUserDataDirFlag = "--user-data-dir="
// fakeBrowserDebugPortFlag 是假浏览器的调试端口来源(与真浏览器的参数同名);
// 显式端口是我们自己选的,假浏览器照它监听才算「就绪」。
fakeBrowserDebugPortFlag = "--remote-debugging-port="
// fakeBrowserLaunchLogFile 记录**每次**假浏览器被拉起(一行一次)。它是「有没有起
// 第二个进程」这一判据的**直接观察**:复用与「预算耗尽不 spawn」两类用例都数它。
fakeBrowserLaunchLogFile = "fake-browser-launches"
// fakeBrowserNoScriptExit 是「argv 像浏览器、但没有脚本」时的退出码:宁可表现得像
// 一个立刻退出的浏览器,也不递归跑整个测试套件(那是测试基建事故,不是用例失败)。
fakeBrowserNoScriptExit = 7
// fakeBrowserServeIdle 是「要在端口上服务」时的默认存活时长:子进程必须活到用例
// 判定完(真浏览器是被 Kill 才退的),但不能无限活着 —— 无限活着会漏成孤儿进程。
fakeBrowserServeIdle = 30 * time.Second
)
// 假浏览器在调试端口上的三种做派(见 fakeBrowserScript.Serve)。
const (
// serveCDP:监听并答合法的 `/json/version` —— 这就是「真浏览器已就绪」。
serveCDP = "cdp"
// serveForeign:监听但答一个**不是 CDP** 的响应 —— 模拟「我们选的端口被别人占了」
// (TOCTOU 抢占),用于验证换端口重试。
serveForeign = "foreign"
// serveForeignThenCDP:第一次抢先占住端口、第二次才当 CDP —— 验证「重试之后真的成功」。
serveForeignThenCDP = "foreign-then-cdp"
)
// fakeBrowserScript 是一次「假启动」的行为脚本(字段零值 = 不做那件事)。
type fakeBrowserScript struct {
// Stdout / Stderr 是要写出去的若干行(模拟浏览器自己说的话)。
Stdout []string `json:"stdout"`
Stderr []string `json:"stderr"`
// ExitCode 是退出码。
ExitCode int `json:"exitCode"`
// Serve 决定它在 `--remote-debugging-port=` 那个端口上做什么(见上面三个常量);
// 空串 = **不监听**(模拟「起了但从不就绪」)。
Serve string `json:"serve"`
// SleepMS > 0 ⇒ 之后继续活着(模拟浏览器常驻):由 Dial 的停止函数或超时路径 Kill。
// Serve 非空时即使它为 0 也会活 fakeBrowserServeIdle(否则子进程可能在判定前就退了)。
SleepMS int `json:"sleepMs"`
}
// TestMain 让测试二进制可以**兼任**假浏览器。
func TestMain(m *testing.M) {
if dir, ok := fakeBrowserProfileDir(os.Args[1:]); ok {
os.Exit(runFakeBrowser(dir, os.Args[1:]))
}
os.Exit(m.Run())
}
// fakeBrowserProfileDir 从 argv 里取出 profile 目录(找不到 ⇒ 这是普通的测试运行)。
func fakeBrowserProfileDir(args []string) (string, bool) {
for _, a := range args {
if dir, ok := strings.CutPrefix(a, fakeBrowserUserDataDirFlag); ok {
return dir, true
}
}
return "", false
}
// fakeBrowserDebugPort 从 argv 里取出调试端口(找不到 / 不合法 ⇒ 0,表示「不监听」)。
func fakeBrowserDebugPort(args []string) int {
for _, a := range args {
raw, ok := strings.CutPrefix(a, fakeBrowserDebugPortFlag)
if !ok {
continue
}
port, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil || port <= 0 || port > 65535 {
return 0
}
return port
}
return 0
}
// runFakeBrowser 按脚本演一次假浏览器,返回退出码。
func runFakeBrowser(profileDir string, args []string) int {
raw, err := os.ReadFile(filepath.Join(profileDir, fakeBrowserScriptFile))
if err != nil {
fmt.Fprintf(os.Stderr, "fake browser: %v\n", err)
return fakeBrowserNoScriptExit
}
var script fakeBrowserScript
if err := json.Unmarshal(raw, &script); err != nil {
fmt.Fprintf(os.Stderr, "fake browser: %v\n", err)
return fakeBrowserNoScriptExit
}
launchNo := recordFakeLaunch(profileDir)
for _, line := range script.Stdout {
fmt.Fprintln(os.Stdout, line)
}
for _, line := range script.Stderr {
fmt.Fprintln(os.Stderr, line)
}
if script.Serve != "" {
if err := startFakeCDPServer(fakeBrowserDebugPort(args), serveKind(script.Serve, launchNo)); err != nil {
fmt.Fprintf(os.Stderr, "fake browser: %v\n", err)
return fakeBrowserNoScriptExit
}
time.Sleep(fakeBrowserServeIdle)
} else if script.SleepMS > 0 {
time.Sleep(time.Duration(script.SleepMS) * time.Millisecond)
}
return script.ExitCode
}
// serveKind 把脚本里的做派与**这是第几次被拉起**合起来算出本次的实际做派:
// `foreign-then-cdp` 只在第一次抢端口,第二次起老老实实当 CDP —— 于是「换端口重试之后
// 成功」这条路径可被断言,而不是只断言「重试过」。
func serveKind(mode string, launchNo int) string {
if mode == serveForeignThenCDP && launchNo > 1 {
return serveCDP
}
if mode == serveForeignThenCDP {
return serveForeign
}
return mode
}
// recordFakeLaunch 追加一行「我起来了」,返回这是第几次(1 起)。计数落在 profile 目录里,
// 因此每个用例各数各的。
func recordFakeLaunch(profileDir string) int {
path := filepath.Join(profileDir, fakeBrowserLaunchLogFile)
raw, _ := os.ReadFile(path)
n := fakeLaunchLines(string(raw))
n++
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
return n
}
defer func() { _ = f.Close() }()
_, _ = fmt.Fprintf(f, "launch %d\n", n)
return n
}
// fakeLaunchLines 数启动记录的行数(一行一次;空行不算)。
func fakeLaunchLines(raw string) int {
n := 0
for _, line := range strings.Split(raw, "\n") {
if strings.TrimSpace(line) != "" {
n++
}
}
return n
}
// startFakeCDPServer 在 port 上起一个假 CDP HTTP 端点(在后台协程里服务到进程被杀)。
// port ≤ 0(argv 里没有可用端口)不服务:就绪判定因此不会成功,如实走失败路径。
func startFakeCDPServer(port int, mode string) error {
if port <= 0 {
return nil
}
mux := http.NewServeMux()
if mode == serveForeign {
// 「端口在听但不是 CDP」:任何路径都答一段不是 CDP 的正文。
mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("<html>我不是 CDP,只是占着这个端口</html>"))
})
} else {
mux.HandleFunc("/json/version", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"Browser":"Chrome/154.0.0.0","webSocketDebuggerUrl":"ws://127.0.0.1:` +
strconv.Itoa(port) + `/devtools/browser/fake"}`))
})
}
ln, err := net.Listen("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port)))
if err != nil {
return fmt.Errorf("监听调试端口 %d: %w", port, err)
}
srv := &http.Server{Handler: mux}
go func() { _ = srv.Serve(ln) }()
return nil
}
// fakeLaunches 数这个 profile 上假浏览器被拉起过几次(0 = 没起过任何进程)。
func fakeLaunches(profileDir string) int {
raw, err := os.ReadFile(filepath.Join(profileDir, fakeBrowserLaunchLogFile))
if err != nil {
return 0
}
return fakeLaunchLines(string(raw))
}
// newFakeChrome 造一个「可执行文件 = 测试二进制自己」的 localChrome,并把脚本写进
// profile 目录(`Dial` 会把它当 `--user-data-dir` 传下来)。
func newFakeChrome(t *testing.T, script fakeBrowserScript, timeout time.Duration, log *slog.Logger) *localChrome {
t.Helper()
exe, err := os.Executable()
if err != nil {
t.Fatalf("取测试二进制路径: %v", err)
}
dir := t.TempDir()
raw, err := json.Marshal(script)
if err != nil {
t.Fatalf("编码假浏览器脚本: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, fakeBrowserScriptFile), raw, 0o600); err != nil {
t.Fatalf("写假浏览器脚本: %v", err)
}
return &localChrome{
exe: exe, profileDir: dir,
viewportW: 1280, viewportH: 800,
timeout: timeout, log: log,
}
}
// newCapturedLog 造一个把日志收进缓冲的 logger(带来源标签,与 discardLog 同源)。
func newCapturedLog(level slog.Level) (*slog.Logger, *bytes.Buffer) {
var buf bytes.Buffer
base := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: level}))
return logger.WithSource(base, logSource), &buf
}
// escapedInLog 把字符串按 slog TextHandler 的转义规则处理:值里含空格/引号时它整段走
// `strconv.Quote`(反斜杠变双反斜杠);不含时原样输出。断言前把两种形态都认,免得
// 「同一个路径在 exe= 里是单反斜杠、在 argv= 里是双反斜杠」这种细节把用例判红。
func escapedInLog(s string) string { return strings.ReplaceAll(s, `\`, `\\`) }
// containsInLog 报告日志里是否出现了这段文字(原样或 slog 的引号化形态)。
func containsInLog(logs, want string) bool {
return strings.Contains(logs, want) || strings.Contains(logs, escapedInLog(want))
}
+259 -82
View File
@@ -2,12 +2,11 @@ package browser
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
@@ -16,12 +15,7 @@ import (
// 启动参数与就绪等待的具名常量(AGENTS §10:不出现裸魔法数字)。
const (
// devToolsPortFile 是 Chromium 在 `--remote-debugging-port=0` 时写回端口号的
// 文件名(profile 目录下):首行端口、次行 browser_guid
// (reference/browser-stealth/10 §2.4,依据 content/browser/devtools/devtools_http_handler.cc)。
devToolsPortFile = "DevToolsActivePort"
// portPollInterval 是「等调试端口就绪」的轮询间隔:冷启动通常 0.3~1.5s,
// portPollInterval 是「等调试端点就绪」的轮询间隔:冷启动通常 0.3~1.5s,
// 100ms 的粒度既不迟钝也不白烧 CPU。
portPollInterval = 100 * time.Millisecond
@@ -31,9 +25,45 @@ const (
defaultLang = "zh-CN"
)
// adoptProbeTimeout 是「要不要认领已在跑的实例」这一次探测的超时(也是远端端点探活
// 用的那一档):它只问「端口有没有在听」,给得短。
const adoptProbeTimeout = 2 * time.Second
// endpointProbeTimeout 是一次端点探活的超时:「要不要复用已在跑的实例」与「本次启动就绪了
// 没有」两处共用同一档。它先问「端口有没有在听」,再问 `/json/version` 答不答话,两问都给得
// 短 —— 前者是启动前的判定,后者是就绪轮询里的一次尝试,都不该拖住启动路径。
const endpointProbeTimeout = 2 * time.Second
// 启动失败的**分类标记**:几种情形必须在错误消息里一眼可分(design/28 §7 判据 4
// 「如实标注」/ §6 坑 5「失败要如实报」)——
//
// ① 浏览器进程自己退了(参数被拒 / 委托给已跑实例 / profile 锁被占);
// ② 我们自己的等待窗口用完(**我们选的那个**调试端口一直没答上 CDP);
// ③ **调用方的预算先没了**:浏览器没坏,只是没时间了;
// ④ 端口在听、但不是可用的 CDP 端点(握手失败)—— 它既不是「进程退出」也不是「端口没出现」,
// 必须单独说,因为它有**自己的**处置方式(见 endpointBrokenHint)。这一条有三个入口:
// 启动**前**探到旧记录指向坏端点、启动**中**发现选的端口被别人占了、以及会话就绪后的
// 复用判定(都在本包内共用同一个标记)。
//
// 哨兵仍然只有 ErrLaunch 一个(`errors.Is` 口径不变),这些标记是消息里可机读的那一段。
const (
// launchMarkerProcessExited 标记 ①。
launchMarkerProcessExited = "浏览器进程立刻退出"
// launchMarkerPortTimeout 标记 ②。
launchMarkerPortTimeout = "等调试端口就绪超时"
// launchMarkerBudget 标记 ③。
launchMarkerBudget = "调用方预算耗尽"
// launchMarkerEndpointBroken 标记 ④:端口在听却握不上手。它与「陈记录」(端口没人听)
// 是**两回事**:陈记录可以安全清掉后照常启动,这里只能如实报 —— 静默忽略它去另起一个
// 进程,换来的只是「委托给已跑实例后立刻退出」,把原因糊掉。
launchMarkerEndpointBroken = "调试端口在听但不是可用的 CDP 端点"
)
const (
// chromeOutputDrainWait 是「进程退出/被杀之后,把它已经写进管道的输出收干净」的
// 等待上限:诊断不该反过来拖住失败路径。
chromeOutputDrainWait = 500 * time.Millisecond
// launchDeadlineSlack 容忍「调用方的截止时间与我们这一次等待的截止时间几乎同时」
// 这种不可分情形:差值在它之内时按**我们自己等端口超时**报(此刻的事实正是这一次
// 等待用满了配置的 timeout,真机常态如此:外层 openSession 与本次等待同源同值)。
launchDeadlineSlack = 250 * time.Millisecond
)
// dialer 产出一个可用的 CDP HTTP 端点(`http://host:port`)与停止函数。它把
// 「启动本机浏览器」与「连接已跑端点」两条路统一成同一个会话装配入口。
@@ -49,11 +79,14 @@ type dialer interface {
// localChrome 是主路径:在**宿主真桌面**上起一个真 Chromium(默认有头)。
//
// 启动参数按 `DevDocs/reference/browser-stealth/10 §2.1` 的白/黑名单落地:
// 启动参数按 `DevDocs/reference/browser-stealth/10 §2.1` 的白/黑名单落地,并经
// `DevDocs/reference/chromium-cdp/` 的官方源码 + 本机实测修订了两处(见 portrecord.go 的文件头):
// - **要传**:`--user-data-dir`、`--window-size`、`--lang`、`--no-first-run`、
// `--no-default-browser-check`、`--disable-blink-features=AutomationControlled`、
// `--remote-debugging-port=0`;
// - **不传**:`--enable-automation`(会点亮「受自动化控制」,且 Puppeteer 默认就传它)、
// `--no-default-browser-check`、`--remote-debugging-port=<我们选的显式端口>`;
// - **不传**:`--disable-blink-features=AutomationControlled`(显式端口下它对
// `navigator.webdriver` 已是空操作,却仍是整条命令行里唯一命中官方 `kBadFlags` 的参数 ⇒
// 每次有头启动弹一条「不受支持的命令行标记」信息栏)、
// `--enable-automation`(会点亮「受自动化控制」,且 Puppeteer 默认就传它)、
// `--disable-extensions` / `--disable-default-apps` /
// `--disable-component-extensions-with-background-pages` / `--disable-component-update` /
// `--disable-popup-blocking`(Playwright/Puppeteer 的默认参数,真浏览器不会那样)、
@@ -61,9 +94,10 @@ type dialer interface {
//
// 未传 `--remote-debugging-pipe`(reference/10 §2.4 的首选)是**平台限制**而不是取舍:
// 它要求把 CDP 通道给到子进程的 fd 3/4,而 Go 的 `exec.Cmd.ExtraFiles` **在 Windows
// 上不支持**。因此 Windows 主路径用次选 `--remote-debugging-port=0`(内核分配随机
// 回环端口,从 profile 目录的 DevToolsActivePort 读回):**仍是我们出站连它**,
// 不开任何对外监听端口(AGENTS §9)。
// 上不支持**;何况官方源码里 pipe 与 `--headless` 一样会打开 `AutomationControlled`
// (reference/chromium-cdp/02 §4.1),换它并不能解决问题。因此 Windows 主路径用
// `--remote-debugging-port=<显式端口>`:**仍是我们出站连它**,不开任何对外监听端口
// (AGENTS §9;端口由内核分配号、Chromium 自己 bind 在 127.0.0.1,见 portrecord.go)。
type localChrome struct {
exe string
profileDir string
@@ -86,76 +120,180 @@ func (l *localChrome) Describe() string {
// Local 实现 dialer:本机浏览器归我们管,会话收尾可以(也应该)关掉它。
func (l *localChrome) Local() bool { return true }
// Dial 拉起浏览器并等调试端口就绪。任何一步失败都返回带分类原因的 ErrLaunch。
// Dial 拉起浏览器并等调试端点就绪。任何一步失败都返回带分类原因的 ErrLaunch。
//
// 两条**先于启动**的判定(都是为了让「上一次会话留下的浏览器」不至于把功能卡死):
// 四条**先于启动**的判定(前三条都是为了让「上一次会话留下的浏览器」不至于把功能卡死):
//
// 1. **认领已在跑的实例**:profile 目录的 DevToolsActivePort 还活着(端口在听)⇒ 直接
// 出站连接它,**不再起第二个**。这不是优化,是**正确性**:同一个 user-data-dir 上起
// 第二个实例时 Chromium 会「委托给已在跑的那个」然后自己退出,于是新进程永远不写
// DevToolsActivePort ⇒ 功能会一直报启动超时,直到人工杀掉那个进程。认领把这一步
// 变成「复用」,也更贴合 design/28 §2「profile 长期保留、任务间复用」。
// 2. **端口文件先清掉**:只在真的要走启动路径时才清,否则会把旧端口当成本次结果。
// 1. **预算已耗尽就不起进程**:ctx 已经结束(这一轮的工具预算用完 / 会话被取消)时
// 立刻返回「预算耗尽」分类,**不 spawn** —— 起了也只是生一个会被马上杀掉的窗口,
// 并把「没时间了」伪装成「浏览器起不来」(真机踩过:窗口上跳出浏览器的抱怨,日志
// 里却像浏览器坏了)。判定做在**启动之前**,因为它要省掉的正是那次启动。
// 2. **能复用就复用**(`probeExistingEndpoint`):这个 profile 上记的调试端口还在
// (`cyrene-debug-port`,或老二进制留下的 `DevToolsActivePort`)、端口**确实在听**、
// `/json/version` 也**真有合法响应** ⇒ 直接出站连它,**不再起第二个**。
// 这不是优化,是**正确性**:同一个 user-data-dir 上起第二个实例时 Chromium 会把手上的
// 命令**委托给已在跑的那个**然后自己退出(真机实测:新进程 exit 0、不写新的端口记录,
// stderr 只有一句「正在现有的浏览器会话中打开。」),于是新进程永远不就绪 ⇒ 不复用的话,
// 这一档会一直报启动失败,直到人工杀掉那个进程。复用还正好贴合 design/28 §2「profile
// 长期保留、任务间复用」。
// 3. **端口在听、却握不上手 ⇒ 如实报错**(`launchMarkerEndpointBroken`):绝不「忽略掉它、
// 自己起一个」—— 占着这个端口的多半就是同一个 profile 的浏览器,起进程只会让新进程委托
// 出去后立刻退出,把一句本就查不明白的失败再糊一层。
// 4. **陈的记录先清掉**(`clearStalePortRecords`):只在真的要走启动路径时才清;不清的话,
// 上一轮的端口会被当成本次可复用的端点(理由见那个方法的注释)。
//
// 停止函数只对**本次启动**的进程有效(认领来的实例不归我们杀):进程退出后 Chrome 的
// 起进程与「等它就绪」由 launchWithRetry(含一次换端口重试)与 launchOnce 承担;子进程的
// stdout/stderr **接进缓冲**(`chromeOutput`):启动失败时把浏览器自己的话与本次 argv 一起
// 报进**日志**,并把浏览器原话附进**返回的错误**(`withChromeOutput`)—— 用户侧看到的工具
// 结果就是从这条错误来的。成功时只报字节数(Debug),不把 Chrome 的常规输出刷进日志。
//
// 停止函数只对**本次启动**的进程有效(复用来的实例不归我们杀):进程退出后 Chrome 的
// 渲染进程随之退出,因此进程级 Kill 足够。
func (l *localChrome) Dial(ctx context.Context) (string, func(), error) {
if strings.TrimSpace(l.exe) == "" {
return "", nil, fmt.Errorf("%w: 未指定可执行文件", ErrLaunch)
}
if endpoint, ok := l.adoptExisting(ctx); ok {
l.log.Info("browser session adopted", "endpoint", endpoint, "profile", l.profileDir)
if err := ctx.Err(); err != nil {
return "", nil, budgetLaunchErr(ctx)
}
endpoint, err := l.probeExistingEndpoint(ctx)
if err != nil {
// 复用不了、又不许起第二个:如实报(错误里已带处置建议),一条 WARN 留现场。
l.log.Warn("browser launch blocked by unusable debug endpoint",
"profile", l.profileDir, "error", err)
return "", nil, err
}
if endpoint != "" {
l.log.Info("browser debug endpoint reused",
"endpoint", endpoint,
"profile", l.profileDir,
"detail", "复用已存在的调试端点(同 profile 的浏览器仍在跑,不再起第二个)")
return endpoint, func() {}, nil
}
if err := os.MkdirAll(l.profileDir, 0o700); err != nil {
return "", nil, fmt.Errorf("%w: 建 profile 目录 %s: %v", ErrLaunch, l.profileDir, err)
}
_ = os.Remove(filepath.Join(l.profileDir, devToolsPortFile))
if err := l.clearStalePortRecords(); err != nil {
return "", nil, err
}
return l.launchWithRetry(ctx)
}
cmd := exec.Command(l.exe, l.launchArgs()...)
// launchWithRetry 起浏览器,并把**本次实际用的那个端口**记进 profile 目录的端口记录
// (`cyrene-debug-port`,见 portrecord.go:显式端口下 Chromium 不写 DevToolsActivePort,
// 端口只能我们自己记 —— 记不下就等于下一次启动无法复用,所以写失败按硬失败报,并把刚起的
// 进程收掉,不留孤儿)。
//
// 只在一种情形下**换端口**重试:证据表明我们选的端口被别人占了(`portTakenError`,即就绪
// 等待期间那个端口在听、却答不上 CDP)。那是「listen(:0) 取号」与「Chromium bind」之间的
// TOCTOU,换一个号就好,且**没有**碰任何不属于我们的进程。其余失败一律如实上报 —— 无差别
// 重试只会把原因糊掉,还把预算烧在重复等待上。
func (l *localChrome) launchWithRetry(ctx context.Context) (string, func(), error) {
var lastErr error
for attempt := 0; attempt < launchPortAttempts; attempt++ {
if err := ctx.Err(); err != nil {
// 重试之前预算先没了:这一轮的事实是「没时间了」,如实报(不是端口的问题)。
return "", nil, budgetLaunchErr(ctx)
}
port, err := pickDebugPort(ctx)
if err != nil {
return "", nil, fmt.Errorf("%w: %v", ErrLaunch, err)
}
endpoint, stop, err := l.launchOnce(ctx, port)
if err == nil {
if werr := writePortRecord(l.profileDir, port); werr != nil {
stop()
return "", nil, fmt.Errorf("%w: %v", ErrLaunch, werr)
}
return endpoint, stop, nil
}
lastErr = err
var taken *portTakenError
if !errors.As(err, &taken) {
return "", nil, err
}
l.log.Warn("browser launch retrying with another debug port",
"port", port, "profile", l.profileDir, "error", err)
}
// 换端口也还是被别人抢:**如实报同一类**(端口在听但不是 CDP),不冒充别的失败。
// 这时 lastErr 里已带标记与处置建议,直接交回它,不另编一句更好听的。
return "", nil, lastErr
}
// launchOnce 起一次浏览器(用给定的显式端口)并等它就绪。失败时**自己收掉刚起的进程**,
// 并把浏览器原话与本次 argv 报进日志、把原话附进返回的错误。
func (l *localChrome) launchOnce(ctx context.Context, port int) (string, func(), error) {
args := l.launchArgs(port)
cmd := exec.Command(l.exe, args...)
out := &chromeOutput{}
cmd.Stdout = out
cmd.Stderr = out
if err := cmd.Start(); err != nil {
return "", nil, fmt.Errorf("%w: 启动 %s: %v", ErrLaunch, l.exe, err)
}
exited := make(chan struct{})
go func() {
defer close(exited)
_, _ = cmd.Process.Wait()
// 用 cmd.Wait(而不是 cmd.Process.Wait):它会等两个输出复制协程收工,
// 于是 exited 关闭时缓冲里就是子进程说过的**全部**话(下面要在失败时上报它)。
_ = cmd.Wait()
}()
port, err := l.waitDevToolsPort(ctx, exited)
endpoint, err := l.waitDebugReady(ctx, port, exited)
if err != nil {
stopProcess(cmd)
return "", nil, err
l.waitExited(exited)
l.log.Warn("browser launch failed",
"exe", l.exe,
"argv", strings.Join(args, " "),
"chrome_output", out.preview(),
"chrome_output_bytes", out.totalBytes(),
"error", err)
return "", nil, withChromeOutput(err, out.preview())
}
l.log.Debug("browser launched", "exe", l.exe, "port", port, "profile", l.profileDir)
return fmt.Sprintf("http://127.0.0.1:%d", port), func() { stopProcess(cmd) }, nil
l.log.Debug("browser launched",
"exe", l.exe, "port", port, "profile", l.profileDir, "chrome_output_bytes", out.totalBytes())
return endpoint, func() { stopProcess(cmd) }, nil
}
// adoptExisting 报告 profile 目录里记录的调试端点是否**仍然活着**;活着就返回它。
// 探测超时给得很短:这是一个「要不要复用」的判断,不该拖住启动路径。
func (l *localChrome) adoptExisting(ctx context.Context) (string, bool) {
port, ok := readDevToolsPort(filepath.Join(l.profileDir, devToolsPortFile))
if !ok {
return "", false
// waitExited 等子进程真正收尾(管道里的在途字节落进缓冲)再上报:Kill 之后 Wait 还要
// 一点点时间,不等就会漏掉最后几行 —— 而最后几行往往正是原因。等不到就照现有内容报,
// 不把失败路径拖住。
func (l *localChrome) waitExited(exited <-chan struct{}) {
timer := time.NewTimer(chromeOutputDrainWait)
defer timer.Stop()
select {
case <-exited:
case <-timer.C:
}
endpoint := fmt.Sprintf("http://127.0.0.1:%d", port)
probeCtx, cancel := context.WithTimeout(ctx, adoptProbeTimeout)
defer cancel()
var version struct {
Browser string `json:"Browser"`
}
if err := httpGetJSON(probeCtx, endpoint+"/json/version", &version); err != nil {
return "", false
}
return endpoint, true
}
// launchArgs 组装启动参数(**顺序稳定、可断言**:单元测试钉住白/黑名单)。
func (l *localChrome) launchArgs() []string {
// budgetLaunchErr 是 ③「调用方预算耗尽」的**唯一文案**。它必须读起来像「没时间了」,
// 而不是像「浏览器坏了」:取消与截止时间到分别点明(两者都是调用方自己的事)。
func budgetLaunchErr(ctx context.Context) error {
detail := "上下文截止时间到(这一轮的工具预算已耗尽)"
if errors.Is(ctx.Err(), context.Canceled) {
detail = "上下文被取消(会话/这一轮已结束)"
}
return fmt.Errorf("%w: %s:%s,浏览器未启动完成", ErrLaunch, launchMarkerBudget, detail)
}
// 启动前的三条判定与它们的处置文案在 adopt.go:复用活端点(probeExistingEndpoint)、
// 清掉陈的端口记录(clearStalePortRecords)、端点不可用时的如实上报(withChromeOutput 与两条 hint)。
// launchArgs 组装启动参数(**顺序稳定、可断言**:单元测试钉住白/黑名单)。port 是我们
// **已经选好**的显式调试端口(取号与记录见 portrecord.go)。
func (l *localChrome) launchArgs(port int) []string {
args := []string{
// profile 必传:内含 cookie / 登录态,且是「并发上限 1」的锁载体(design/28 §3)。
"--user-data-dir=" + l.profileDir,
// 内核分配随机回环端口;端口从 DevToolsActivePort 读回(见本类型文档)。
"--remote-debugging-port=0",
// **显式**端口(不是 `=0`):Chromium 把 `port==0` 当作 ChromeDriver 式自动化启动,
// 会主动打开 Blink 的 AutomationControlled ⇒ `navigator.webdriver === true`(官方
// runtime_features.cc:389-404;实测 reference/chromium-cdp/07 的 g1 vs g7);显式端口
// 不被这样处理,因此这一条同时替掉了原来的
// `--disable-blink-features=AutomationControlled`(那个 flag 在显式端口下是空操作,
// 却会命中官方 kBadFlags、每次有头启动弹一条「不受支持的命令行标记」信息栏)。
// 代价:Chromium 只在 port==0 时才写 <profile>/DevToolsActivePort,端口改由我们自己记。
fmt.Sprintf("--remote-debugging-port=%d", port),
// 非浏览器 WS 客户端在部分 Chrome 版本上会被 DevTools 的 Origin 校验拒绝;
// reference/07 §4 的命令行里同样有它。它只放宽「谁能连这个回环调试端点」,
// 不开任何对外服务面。
@@ -164,8 +302,6 @@ func (l *localChrome) launchArgs() []string {
"--no-default-browser-check",
fmt.Sprintf("--window-size=%d,%d", l.viewportW, l.viewportH),
"--lang=" + defaultLang,
// 兜底防线:真正的关键是**不传** `--enable-automation`(见类型文档)。
"--disable-blink-features=AutomationControlled",
"about:blank",
}
if l.headless {
@@ -175,46 +311,87 @@ func (l *localChrome) launchArgs() []string {
return args
}
// waitDevToolsPort 等 DevToolsActivePort 出现并解析出端口号。三条出口都要**说清原因**:
// 进程立刻退出(多半是 profile 锁被占 / 浏览器已在运行)、ctx 取消、超时。
func (l *localChrome) waitDevToolsPort(ctx context.Context, exited <-chan struct{}) (int, error) {
path := filepath.Join(l.profileDir, devToolsPortFile)
// waitDebugReady 等**我们自己选的那个端口**真的成为可用的 CDP 端点(就绪判据从「端口文件
// 出现」换成了「端点答得上 `/json/version`」—— 显式端口下 Chromium 不再写端口文件,而且
// 这样判别的是**事实**而不是一条可能陈旧的记录)。三条出口都要**说清原因**,而且三者互不
// 冒充(design/28 §7 判据 4「如实标注」):
//
// ① 浏览器进程自己退了(多半是 profile 锁被占 / 浏览器已在运行)——错误里带
// launchMarkerProcessExited **与可操作的处置建议**(profileBusyHint);子进程说的原话
// 由 launchOnce 追加进这条错误(withChromeOutput),用户侧因此看得见 Chromium 那句
// 「正在现有的浏览器会话中打开。」;
// ② 我们自己的等待窗口用完 —— 带 launchMarkerPortTimeout;若这期间那个端口**在听却答不上
// CDP**,说明它被别的进程抢走了(listen(:0) 取号与 Chromium bind 之间的 TOCTOU),
// 改报 launchMarkerEndpointBroken 并交给 launchWithRetry 换端口重试(portTakenError);
// ③ 调用方的预算先没了(ctx 结束)—— 带 launchMarkerBudget,**不**说浏览器坏。
//
// ② 与 ③ 的分辨靠两者的截止时间:ctx 的截止时间**明显早于**我们这一次等待的截止时间
// 时,结束它的是调用方;几乎同时到点时按 ② 报(那是这一次等待真的用满了配置的 timeout,
// 也是真机常态:外层 openSession 的预算与本次等待同源同值)。
//
// 还有一条**不走这里**的出口:启动前探到「端口在听却握不上手」时,Dial 会在起进程之前就
// 报 launchMarkerEndpointBroken(见 adopt.go)—— 那种情形根本不该起这个进程。
func (l *localChrome) waitDebugReady(ctx context.Context, port int, exited <-chan struct{}) (string, error) {
launchDeadline := time.Now().Add(l.timeout)
deadline := time.NewTimer(l.timeout)
defer deadline.Stop()
ticker := time.NewTicker(portPollInterval)
defer ticker.Stop()
foreign := false
for {
if port, ok := readDevToolsPort(path); ok {
return port, nil
switch state, _ := probeDebugEndpoint(ctx, port); state {
case endpointReady:
return endpointURL(port), nil
case endpointForeign:
foreign = true
case endpointAbsent:
}
select {
case <-exited:
// 浏览器进程自己退了,端口不会再出现 —— **立刻**报,不把超时白等完。
// 最常见的原因是同一 profile 已被另一个实例占用(Chromium 的 profile 锁)
// 或浏览器已被别处启动:委托出去的新进程不写 DevToolsActivePort。
return 0, fmt.Errorf("%w: 浏览器进程立刻退出(多半是该 profile 已被另一个实例占用)", ErrLaunch)
// 浏览器进程自己退了,端点不会再就绪 —— **立刻**报,不把超时白等完。
// 最常见的原因是同一 profile 已被另一个实例占用:Chromium 把命令**委托给已在跑的
// 那个**之后自己退出(真机实测 stderr 原话「正在现有的浏览器会话中打开。」)、新
// 进程不写端口记录。这里给可操作建议,但不碰别人的进程。
return "", fmt.Errorf("%w: %s。%s", ErrLaunch, launchMarkerProcessExited, profileBusyHint(l.profileDir))
case <-ctx.Done():
return 0, fmt.Errorf("%w: 等待调试端口时被取消: %v", ErrLaunch, ctx.Err())
if dl, ok := ctx.Deadline(); !ok || dl.Add(launchDeadlineSlack).Before(launchDeadline) {
return "", budgetLaunchErr(ctx)
}
return "", l.portTimeoutErr(port, foreign)
case <-deadline.C:
return 0, fmt.Errorf("%w: 等 %s 超时(%s)", ErrLaunch, devToolsPortFile, l.timeout)
return "", l.portTimeoutErr(port, foreign)
case <-ticker.C:
}
}
}
// readDevToolsPort 读端口文件的首行;文件还没出现 / 还没写完 / 内容不合法都返回 false
// (交给调用方继续轮询,而不是把「正在写」误判成「配错」)。
func readDevToolsPort(path string) (int, bool) {
raw, err := os.ReadFile(path)
if err != nil {
return 0, false
// portTimeoutErr 是 ②「我们自己的等待窗口用完」的唯一文案(两条出口共用,口径只有一处)。
// foreign=true 时改报「端口在听但不是可用的 CDP 端点」并带上换端口重试的标记:那是被别的
// 进程抢占的样子,与「浏览器起不来」是两回事。
func (l *localChrome) portTimeoutErr(port int, foreign bool) error {
if foreign {
return &portTakenError{err: fmt.Errorf(
"%w: %s(%s):%s 在听,但一直没有答出可用的 CDP 响应(可能被别的进程抢走了端口)。%s",
ErrLaunch, launchMarkerEndpointBroken, l.timeout, endpointURL(port), portTakenHint())}
}
line, _, _ := strings.Cut(strings.TrimSpace(string(raw)), "\n")
port, err := strconv.Atoi(strings.TrimSpace(line))
if err != nil || port <= 0 || port > 65535 {
return 0, false
}
return port, true
return fmt.Errorf("%w: %s(%s):%s 一直没有应答", ErrLaunch, launchMarkerPortTimeout, l.timeout, endpointURL(port))
}
// portTakenError 标记「这一次失败是因为我们选的端口被别人占了」:它只在**进程内**用来决定
// 「换一个端口再试一次」(错误文案仍是 launchMarkerEndpointBroken —— 与启动前探到坏端点
// 共用同一类,因为对用户来说都是「端口在听但不是 CDP」)。
type portTakenError struct{ err error }
// Error 实现 error(文案由 portTimeoutErr 一次写清,这里不另编一句更好听的)。
func (e *portTakenError) Error() string { return e.err.Error() }
// Unwrap 让 errors.Is / errors.As 仍能穿过这一层(ErrLaunch 口径不变)。
func (e *portTakenError) Unwrap() error { return e.err }
// portTakenHint 是「我们选的端口被抢」的处置建议:重试即可(会自动换端口),但绝不代杀别人的进程。
func portTakenHint() string {
return "处置:会自动换一个端口重试一次;若反复出现,先查是谁占着这个端口 —— " +
"本代理不杀任何不属于自己的进程。"
}
// remoteCDP 是次选路径:连接一个**已在跑**的 CDP 端点(容器 + Xvfb + 真 Chromium,
+285 -32
View File
@@ -2,6 +2,8 @@ package browser
import (
"context"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"os"
@@ -14,16 +16,22 @@ import (
// TestLaunchArgs_HeadfulCarriesNoAutomationTraces 是本片**最重要的一条测试**:它把
// `DevDocs/reference/browser-stealth/10 §2.1` 的黑名单钉成可执行判据 ——
// 有头路径**不许**出现任何 `--headless*`,也不许出现「自动化味道」的参数
// (`--enable-automation` 以及 Playwright/Puppeteer 默认传的那一串)。
// (`--enable-automation` 以及 Playwright/Puppeteer 默认传的那一串),
// 更不许出现 `--disable-blink-features` / `--enable-blink-features`:这两条在官方
// `kBadFlags` 名单里(reference/chromium-cdp/01),而改用**显式调试端口**后
// `navigator.webdriver` 本来就是 false(同册 07 的 g7/g8),那条 flag 已成空操作 ——
// 留着只剩「每次有头启动弹一条不受支持的命令行标记」这一个副作用。
// 有人往清单里加回一条,这里立刻红。
func TestLaunchArgs_HeadfulCarriesNoAutomationTraces(t *testing.T) {
t.Parallel()
l := &localChrome{exe: "chrome.exe", profileDir: `C:\tmp\p`, headless: false, viewportW: 1280, viewportH: 800}
args := l.launchArgs()
args := l.launchArgs(9411)
forbidden := []string{
"--headless",
"--enable-automation",
"--disable-blink-features",
"--enable-blink-features",
"--disable-extensions",
"--disable-default-apps",
"--disable-component-extensions-with-background-pages",
@@ -39,20 +47,23 @@ func TestLaunchArgs_HeadfulCarriesNoAutomationTraces(t *testing.T) {
}
}
// TestLaunchArgs_RequiredFlagsPresent 钉住「必须传」的那几条:profile、随机回环调试
// 端口、窗口尺寸、语言、免首次运行、以及 AutomationControlled 兜底防线。
// TestLaunchArgs_RequiredFlagsPresent 钉住「必须传」的那几条:profile、**显式**调试端口、
// 窗口尺寸、语言、免首次运行。显式端口是本片的核心改动:`--remote-debugging-port=0` 会被
// Chromium 当作 ChromeDriver 式自动化启动、主动点亮 `navigator.webdriver`
// (reference/chromium-cdp/02 §4.2、07 的 g1 vs g7),因此这条用例**同时**钉住
// 「端口必须是显式数字」与「不许出现 =0」。
func TestLaunchArgs_RequiredFlagsPresent(t *testing.T) {
t.Parallel()
const port = 9411
l := &localChrome{exe: "chrome.exe", profileDir: "/tmp/prof", headless: false, viewportW: 1024, viewportH: 768}
args := l.launchArgs()
args := l.launchArgs(port)
for _, want := range []string{
"--user-data-dir=/tmp/prof",
"--remote-debugging-port=0",
"--remote-debugging-port=9411",
"--window-size=1024,768",
"--lang=zh-CN",
"--no-first-run",
"--no-default-browser-check",
"--disable-blink-features=AutomationControlled",
"--remote-allow-origins=*",
"about:blank",
} {
@@ -60,47 +71,64 @@ func TestLaunchArgs_RequiredFlagsPresent(t *testing.T) {
t.Errorf("启动参数缺少 %q:%v", want, args)
}
}
for _, a := range args {
if strings.HasPrefix(a, "--remote-debugging-port=0") {
t.Errorf("调试端口必须是显式端口(=0 会被 Chromium 当作自动化启动):%v", args)
}
}
}
// TestLaunchArgs_HeadlessOnlyWhenConfigured 钉住「无头只在显式配置时出现」:
// 默认档是有头(design/28 §2),无头是面板显式降级的结果。
//
// ⚠️ 无头档**必然**点亮 `AutomationControlled`(`--headless` 本身就是四条打开路径之一,
// reference/chromium-cdp/02 §4.1),这是官方行为、不是我们的参数能去掉的:本片只保证
// **有头**档(默认档)不再靠那条 bad flag 去抵消它。无头档的 `webdriver` 读数**未实测**
// (见本片报告「没能证明的」)。
func TestLaunchArgs_HeadlessOnlyWhenConfigured(t *testing.T) {
t.Parallel()
l := &localChrome{exe: "chrome.exe", profileDir: "/tmp/p", headless: true, viewportW: 800, viewportH: 600}
args := l.launchArgs()
args := l.launchArgs(9412)
if len(args) == 0 || args[0] != "--headless=new" {
t.Errorf("无头档的第一个参数应为 --headless=new,实得:%v", args)
}
if !containsArg(args, "--disable-blink-features=AutomationControlled") {
t.Error("无头档也必须保留 AutomationControlled 防线")
if !containsArg(args, "--remote-debugging-port=9412") {
t.Errorf("无头档也必须用显式调试端口:%v", args)
}
}
// TestReadDevToolsPort 钉住端口文件解析:正常两行、只有一行、缺文件、垃圾内容。
func TestReadDevToolsPort(t *testing.T) {
// TestReadPortNumberFile 钉住「首行是端口号」的解析:我们自己的记录(一行端口)与
// Chromium 的 `DevToolsActivePort`(首行端口、次行 browser_guid)共用同一个解析。
func TestReadPortNumberFile(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, devToolsPortFile)
path := filepath.Join(dir, portRecordFile)
if _, ok := readDevToolsPort(path); ok {
if _, ok := readPortNumberFile(path); ok {
t.Error("文件不存在时不该报 ok")
}
if err := os.WriteFile(path, []byte("54321\nbrowser-guid-xyz\n"), 0o600); err != nil {
t.Fatal(err)
}
if port, ok := readDevToolsPort(path); !ok || port != 54321 {
if port, ok := readPortNumberFile(path); !ok || port != 54321 {
t.Errorf("读到 (%d,%v), want (54321,true)", port, ok)
}
if err := os.WriteFile(path, []byte("54322\n"), 0o600); err != nil {
t.Fatal(err)
}
if port, ok := readPortNumberFile(path); !ok || port != 54322 {
t.Errorf("我们自己的一行记录应可解析,实得 (%d,%v)", port, ok)
}
if err := os.WriteFile(path, []byte("not-a-port\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, ok := readDevToolsPort(path); ok {
if _, ok := readPortNumberFile(path); ok {
t.Error("垃圾内容不该报 ok")
}
if err := os.WriteFile(path, []byte("0\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, ok := readDevToolsPort(path); ok {
if _, ok := readPortNumberFile(path); ok {
t.Error("端口 0 不该报 ok")
}
}
@@ -118,10 +146,10 @@ func TestLocalChromeDescribe(t *testing.T) {
}
}
// TestLocalChromeAdoptsLiveEndpoint 钉住「认领已在跑的实例」:profile 目录里的
// DevToolsActivePort 还活着 ⇒ Dial 直接交出那个端点、**不启动任何进程**。
// TestLocalChromeAdoptsLiveEndpoint 钉住「认领已在跑的实例」:profile 目录里我们自己记的
// 调试端点(`cyrene-debug-port`)还活着 ⇒ Dial 直接交出那个端点、**不启动任何进程**。
// 这不是优化:同一 profile 起第二个实例时 Chromium 会委托给已在跑的那个然后自己退出,
// 新进程永远不写端口文件 ⇒ 不做认领,功能会被一个残留实例卡死。
// 新进程永远不会就绪 ⇒ 不做认领,功能会被一个残留实例卡死。
func TestLocalChromeAdoptsLiveEndpoint(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -135,9 +163,7 @@ func TestLocalChromeAdoptsLiveEndpoint(t *testing.T) {
port := strings.TrimPrefix(srv.URL, "http://127.0.0.1:")
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, devToolsPortFile), []byte(port+"\nGUID\n"), 0o600); err != nil {
t.Fatal(err)
}
writePortRecordFile(t, dir, portOfEndpoint(t, srv.URL))
l := &localChrome{exe: "definitely-not-a-browser.exe", profileDir: dir, timeout: time.Second, log: discardLog()}
endpoint, stop, err := l.Dial(context.Background())
if err != nil {
@@ -149,30 +175,33 @@ func TestLocalChromeAdoptsLiveEndpoint(t *testing.T) {
stop() // 认领来的实例不归我们杀:停止函数必须是空操作(调用不 panic 即通过)
}
// TestLocalChromeDoesNotAdoptDeadEndpoint 钉住反面:端口文件在、但没人听 ⇒ **不认领**
// (否则会连到一个死端口,比启动失败更难排查)。
// TestLocalChromeDoesNotAdoptDeadEndpoint 钉住反面:端口记录在、但没人听 ⇒ **不认领**
// (否则会连到一个死端口,比启动失败更难排查)。它同时钉住「没人听」的**分类**:
// 探测要给出 ("", nil) 而不是错误 —— 这是「陈记录,可以清掉后照常启动」的那一条出路。
func TestLocalChromeDoesNotAdoptDeadEndpoint(t *testing.T) {
t.Parallel()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, devToolsPortFile), []byte("1\nGUID\n"), 0o600); err != nil {
t.Fatal(err)
}
writePortRecordFile(t, dir, 1)
l := &localChrome{exe: "x", profileDir: dir, timeout: time.Second, log: discardLog()}
if endpoint, ok := l.adoptExisting(context.Background()); ok {
endpoint, err := l.probeExistingEndpoint(context.Background())
if endpoint != "" {
t.Fatalf("死端口不该被认领(endpoint=%q)", endpoint)
}
if err != nil {
t.Fatalf("死端口是「陈记录」而不是「坏端点」,探测不该报错: %v", err)
}
}
// TestWaitDevToolsPort_ReportsEarlyExit 钉住「进程立刻退出」要**立刻**如实报,并把
// TestWaitDebugReady_ReportsEarlyExit 钉住「进程立刻退出」要**立刻**如实报,并把
// 最可能的原因写进错误(profile 被占用)—— 而不是把超时白等完、只留一句「超时」。
func TestWaitDevToolsPort_ReportsEarlyExit(t *testing.T) {
func TestWaitDebugReady_ReportsEarlyExit(t *testing.T) {
t.Parallel()
dir := t.TempDir()
l := &localChrome{profileDir: dir, timeout: 10 * time.Second, log: discardLog()}
exited := make(chan struct{})
close(exited)
start := time.Now()
_, err := l.waitDevToolsPort(context.Background(), exited)
_, err := l.waitDebugReady(context.Background(), deadPort(t), exited)
if err == nil {
t.Fatal("进程退出后应当报错")
}
@@ -193,3 +222,227 @@ func containsArg(args []string, want string) bool {
}
return false
}
// ── 启动失败的可诊断性与分类(假浏览器见 fakebrowser_test.go) ──────────────────────
// TestDial_ReportsChromeOutputAndArgvOnLaunchFailure 是本片最重要的一条:启动失败时
// **必须**把浏览器自己说的话与本次 argv 一起报进日志。真机踩过的坑正是这条的反面 ——
// 窗口上跳出浏览器的抱怨,我们的日志里却只有自己这一侧的猜测("等待调试端口时被取消"),
// 于是「浏览器为什么拒绝」永远查不到。
func TestDial_ReportsChromeOutputAndArgvOnLaunchFailure(t *testing.T) {
t.Parallel()
const complaint = "不支持的标记: --remote-allow-origins=*"
log, buf := newCapturedLog(slog.LevelDebug)
l := newFakeChrome(t, fakeBrowserScript{Stderr: []string{complaint}, ExitCode: 2}, 5*time.Second, log)
_, stop, err := l.Dial(context.Background())
if stop != nil {
t.Error("失败路径不该交出停止函数")
}
if err == nil {
t.Fatal("假浏览器立刻退出时 Dial 必须报错")
}
if !errors.Is(err, ErrLaunch) {
t.Fatalf("错误必须仍然包住 ErrLaunch(errors.Is 口径不变): %v", err)
}
if !strings.Contains(err.Error(), launchMarkerProcessExited) {
t.Errorf("错误里应点明「进程立刻退出」这一类: %v", err)
}
logs := buf.String()
t.Logf("实测 error=%v\n实测日志=%s", err, logs)
for _, want := range []string{
complaint, // 浏览器自己说的话
l.exe, // 是哪个可执行文件
"--user-data-dir=" + l.profileDir, // 本次 argv(profile 路径不含凭据)
"--remote-debugging-port=", // 端口是**显式**的(具体号码每次不同,按前缀断言)
"chrome_output_bytes=",
} {
if !containsInLog(logs, want) {
t.Errorf("失败日志里缺少 %q:\n%s", want, logs)
}
}
}
// TestDial_SuccessDoesNotSpamChromeOutput 钉住反面:**成功时不刷屏**。正常启动时
// Chrome 也会往 stderr 写常规信息,那些不该进日志;只留一个字节数(Debug,供排查用)。
//
// 这里同时钉住新的就绪口径:假浏览器在**argv 给的那个显式端口**上监听并答 `/json/version`
// ⇒ Dial 成功,且把**这个端口**记进了 profile 目录的端口记录(下一次启动据此复用)。
func TestDial_SuccessDoesNotSpamChromeOutput(t *testing.T) {
t.Parallel()
const chatter = "DevTools listening on ws://127.0.0.1:55543"
log, buf := newCapturedLog(slog.LevelDebug)
l := newFakeChrome(t, fakeBrowserScript{
Stderr: []string{chatter}, Serve: serveCDP, SleepMS: 5000,
}, 5*time.Second, log)
endpoint, stop, err := l.Dial(context.Background())
if err != nil {
t.Fatalf("假浏览器在给定端口上服务了,Dial 应当成功: %v", err)
}
defer stop()
port, ok := readPortRecord(l.profileDir)
if !ok {
t.Fatalf("成功启动后必须记下端口(否则下一次启动无法复用):endpoint=%q", endpoint)
}
if endpoint != endpointURL(port) {
t.Errorf("endpoint = %q, want %q(必须是我们自己选并记下的那个端口)", endpoint, endpointURL(port))
}
if got, dok := DebugEndpoint(l.profileDir); !dok || got != endpoint {
t.Errorf("DebugEndpoint() = (%q,%v), want (%q,true)", got, dok, endpoint)
}
logs := buf.String()
if containsInLog(logs, chatter) {
t.Errorf("成功启动时不该把浏览器自己的输出打进日志(刷屏):\n%s", logs)
}
if !strings.Contains(logs, "browser launched") || !strings.Contains(logs, "chrome_output_bytes=") {
t.Errorf("成功时只应留一条只报规模(字节数)的 Debug:\n%s", logs)
}
}
// TestDial_ExhaustedBudgetDoesNotSpawnBrowser 钉住预算口径的**起点**:ctx 已经结束时
// **不起进程** —— 起了也只是生一个会被马上杀掉的窗口,并把「没时间了」伪装成
// 「浏览器起不来」。
func TestDial_ExhaustedBudgetDoesNotSpawnBrowser(t *testing.T) {
t.Parallel()
log, buf := newCapturedLog(slog.LevelDebug)
l := newFakeChrome(t, fakeBrowserScript{
Stderr: []string{"这一行不该出现:进程根本没被拉起"}, Serve: serveCDP, SleepMS: 5000,
}, 5*time.Second, log)
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, _, err := l.Dial(ctx)
if err == nil {
t.Fatal("预算已耗尽时 Dial 必须报错")
}
t.Logf("实测错误(ctx 已取消):%v", err)
if !errors.Is(err, ErrLaunch) {
t.Fatalf("错误必须仍然包住 ErrLaunch: %v", err)
}
if !strings.Contains(err.Error(), launchMarkerBudget) {
t.Errorf("错误必须读成「预算耗尽」,实得: %v", err)
}
if strings.Contains(err.Error(), launchMarkerPortTimeout) {
t.Errorf("预算耗尽不许被说成「端口等超时」(那是浏览器侧的问题): %v", err)
}
if _, ok := readPortRecord(l.profileDir); ok {
t.Error("预算耗尽时不该起浏览器(端口记录被写下了)")
}
if n := fakeLaunches(l.profileDir); n != 0 {
t.Errorf("预算耗尽时子进程不该存在(假浏览器被拉起 %d 次)", n)
}
if strings.Contains(buf.String(), "这一行不该出现") {
t.Errorf("预算耗尽时子进程不该存在:\n%s", buf.String())
}
}
// TestDial_RecordsExplicitPortAndReusesItOnSecondLaunch 是本片「端口记录 + 复用」的主判据:
// 第一次启动必须记下一个**显式端口**;同一个 profile 上的第二次启动必须**复用**它、**不再起
// 第二个进程**(假浏览器的启动计数是直接观察,不是猜)。
//
// 为什么这条是硬要求:改用显式端口后 Chromium 不再写 `DevToolsActivePort`(reference/
// chromium-cdp/03 §3.3),若我们自己不记端口,第二次启动就会去起第二个进程 —— 而 Chromium
// 会把命令委托给已在跑的那个后自己退出(真机实测),于是「复用」这条正确性路径彻底失灵。
func TestDial_RecordsExplicitPortAndReusesItOnSecondLaunch(t *testing.T) {
t.Parallel()
log, buf := newCapturedLog(slog.LevelDebug)
l := newFakeChrome(t, fakeBrowserScript{Serve: serveCDP}, 5*time.Second, log)
first, stop, err := l.Dial(context.Background())
if err != nil {
t.Fatalf("第一次启动应当成功: %v", err)
}
defer stop()
if n := fakeLaunches(l.profileDir); n != 1 {
t.Fatalf("第一次启动应恰好起 1 个进程,实得 %d", n)
}
port, ok := readPortRecord(l.profileDir)
if !ok || port <= 0 {
t.Fatalf("必须记下显式端口,实得 port=%d ok=%v", port, ok)
}
if first != endpointURL(port) {
t.Fatalf("endpoint = %q, want %q", first, endpointURL(port))
}
second, stop2, err := l.Dial(context.Background())
if err != nil {
t.Fatalf("第二次启动应当复用已存在的端点: %v", err)
}
defer stop2()
if second != first {
t.Errorf("第二次应复用它,实得 %q(第一次 %q)", second, first)
}
if n := fakeLaunches(l.profileDir); n != 1 {
t.Errorf("第二次不该再起进程(假浏览器被拉起 %d 次),日志:\n%s", n, buf.String())
}
if !strings.Contains(buf.String(), "browser debug endpoint reused") {
t.Errorf("复用必须留一条 INFO:\n%s", buf.String())
}
}
// TestDial_RetriesWithAnotherPortWhenPickedPortIsTaken 钉住 TOCTOU 的处置:`listen(:0)` 取号
// 与 Chromium bind 之间,端口可能被别的进程抢走 —— 这时**换一个端口重试一次**(而不是把
// 「端口被占」报成「浏览器起不来」)。假浏览器第一次抢先占住端口答非 CDP、第二次才当 CDP,
// 因此这条用例同时证明「重试发生了」与「重试真的能成功」。
func TestDial_RetriesWithAnotherPortWhenPickedPortIsTaken(t *testing.T) {
t.Parallel()
log, buf := newCapturedLog(slog.LevelDebug)
l := newFakeChrome(t, fakeBrowserScript{Serve: serveForeignThenCDP}, 3*time.Second, log)
endpoint, stop, err := l.Dial(context.Background())
if err != nil {
t.Fatalf("换端口重试之后应当成功,实得: %v\n日志:\n%s", err, buf.String())
}
defer stop()
if n := fakeLaunches(l.profileDir); n != 2 {
t.Errorf("应当恰好重试一次(起 2 个进程),实得 %d:\n%s", n, buf.String())
}
if !strings.Contains(buf.String(), "retrying with another debug port") {
t.Errorf("换端口重试必须留一条 WARN(现场可查):\n%s", buf.String())
}
port, ok := readPortRecord(l.profileDir)
if !ok {
t.Fatal("成功之后必须记下最终用的端口")
}
if endpoint != endpointURL(port) {
t.Errorf("endpoint = %q, want %q(必须是重试后最终成功的那个端口)", endpoint, endpointURL(port))
}
}
// TestDial_ReportsPortTakenAfterRetryExhausted 是上一条的反面:端口**反复**被别人抢走时,
// 如实报「端口在听但不是可用的 CDP 端点」(不冒充「等端口超时」,也不冒充「进程退出」),
// 并且**不**留下端口记录(本次没有可用会话,留下记录会让下一次启动去探一个假端点)。
func TestDial_ReportsPortTakenAfterRetryExhausted(t *testing.T) {
t.Parallel()
log, buf := newCapturedLog(slog.LevelDebug)
l := newFakeChrome(t, fakeBrowserScript{Serve: serveForeign}, 300*time.Millisecond, log)
_, 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)
}
if !strings.Contains(err.Error(), "处置") {
t.Errorf("应给可操作的处置建议: %v", err)
}
if n := fakeLaunches(l.profileDir); n != launchPortAttempts {
t.Errorf("应当试满 %d 次(每次换端口),实得 %d", launchPortAttempts, n)
}
if _, ok := readPortRecord(l.profileDir); ok {
t.Error("没有可用会话就不该留下端口记录(下一次启动会去探一个假端点)")
}
}
// TestDial_CallerDeadlineIsReportedAsBudget 与 TestDial_ThreeLaunchFailuresAreDistinguishable
// 这两条「失败分类」判据在 launchfailure_test.go(拆文件只为守住 AGENTS §10 的 500 行上限)。
@@ -0,0 +1,109 @@
package browser
// 本文件只放「失败怎么分类/怎么报」这一类判据(实现见 launch.go 的 budgetLaunchErr /
// portTimeoutErr 与 lift 里的三条出口)。它们与 launch_test.go 的其余 Dial 用例分开,
// 一是为了每个文件都不超 AGENTS §10 的 500 行上限,二是让「三类失败必须一眼可分」这一组
// 判据集中在一处可读。
import (
"context"
"errors"
"log/slog"
"strings"
"testing"
"time"
)
// TestDial_CallerDeadlineIsReportedAsBudget 钉住**真机那条路**:等待期间调用方的截止
// 时间到了(这一轮的工具预算烧完),错误必须读成「预算耗尽、浏览器没坏」,而不是
// 「等待调试端口时被取消」这种既像浏览器故障、又说不清谁取消的文案。
func TestDial_CallerDeadlineIsReportedAsBudget(t *testing.T) {
t.Parallel()
log, buf := newCapturedLog(slog.LevelDebug)
l := newFakeChrome(t, fakeBrowserScript{Stderr: []string{"浏览器在等,但没人给它时间"}, SleepMS: 5000}, 10*time.Second, log)
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
_, _, err := l.Dial(ctx)
if err == nil {
t.Fatal("调用方截止时间到之后 Dial 必须报错")
}
if !errors.Is(err, ErrLaunch) {
t.Fatalf("错误必须仍然包住 ErrLaunch: %v", err)
}
if !strings.Contains(err.Error(), launchMarkerBudget) {
t.Errorf("错误必须读成「预算耗尽」,实得: %v", err)
}
if strings.Contains(err.Error(), launchMarkerPortTimeout) {
t.Errorf("调用方预算先到时不许报成我们自己的端口等待超时: %v", err)
}
if !containsInLog(buf.String(), l.profileDir) {
t.Errorf("这条失败也要带 argv(否则现场没法复现):\n%s", buf.String())
}
}
// TestDial_ThreeLaunchFailuresAreDistinguishable 钉住「一眼分辨」:三条出口 ——
// ① 进程立刻退出 / ② 我们自己的端口等待超时 / ③ 调用方预算耗尽 —— 各自的错误文案里
// 必须带**互不相同**的标记,谁也别冒充谁。
func TestDial_ThreeLaunchFailuresAreDistinguishable(t *testing.T) {
t.Parallel()
cases := []struct {
name string
script fakeBrowserScript
timeout time.Duration
ctx func() (context.Context, context.CancelFunc)
wantMarker string
forbid string
}{
{
name: "①进程立刻退出",
script: fakeBrowserScript{Stderr: []string{"参数被拒,进程退出"}, ExitCode: 1},
timeout: 5 * time.Second,
ctx: func() (context.Context, context.CancelFunc) { return context.WithCancel(context.Background()) },
wantMarker: launchMarkerProcessExited,
forbid: launchMarkerBudget,
},
{
name: "②等端口就绪超时",
script: fakeBrowserScript{Stderr: []string{"起来了但从不答 CDP"}, SleepMS: 5000},
timeout: 300 * time.Millisecond,
ctx: func() (context.Context, context.CancelFunc) { return context.WithCancel(context.Background()) },
wantMarker: launchMarkerPortTimeout,
forbid: launchMarkerBudget,
},
{
name: "③调用方预算耗尽",
script: fakeBrowserScript{Stderr: []string{"还没就绪预算就没了"}, SleepMS: 5000},
timeout: 10 * time.Second,
ctx: func() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), 200*time.Millisecond)
},
wantMarker: launchMarkerBudget,
forbid: launchMarkerPortTimeout,
},
}
seen := map[string]string{}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
l := newFakeChrome(t, tc.script, tc.timeout, discardLog())
ctx, cancel := tc.ctx()
defer cancel()
_, _, err := l.Dial(ctx)
if err == nil {
t.Fatal("这一档必须报错")
}
msg := err.Error()
t.Logf("%s 实测错误:%v", tc.name, err)
if !strings.Contains(msg, tc.wantMarker) {
t.Errorf("错误里应带标记 %q:%v", tc.wantMarker, err)
}
if strings.Contains(msg, tc.forbid) {
t.Errorf("错误里不该出现 %q(冒充了另一类失败):%v", tc.forbid, err)
}
if prev, dup := seen[tc.wantMarker]; dup {
t.Errorf("标记 %q 与 %q 撞了 —— 三种情形必须可分", tc.wantMarker, prev)
}
seen[tc.wantMarker] = tc.name
})
}
}
+101
View File
@@ -0,0 +1,101 @@
package browser
import (
"strconv"
"strings"
"sync"
"unicode/utf8"
)
// 接住子进程输出的两个上限(AGENTS §10:不出现裸魔法数字)。
const (
// chromeOutputMaxBytes 是缓存的**前缀**字节数。为什么留前 N 而不是后 N:Chrome 的
// 抱怨/崩溃信息总在开头,而尾部的常规日志量最大 —— 留头既省内存又留下原因。
chromeOutputMaxBytes = 4096
// chromeOutputMaxLines 是失败上报时最多打出的行数(前 N 行)。
chromeOutputMaxLines = 12
)
// 本文件只做一件事:**接住浏览器自己说的话**。design/28 §6 坑 5「失败要如实报」
// 的落地前提是「看得见失败」—— 启动失败时浏览器把原因写在自己的 stdout/stderr 上
// (参数被拒、profile 锁、崩溃),不接住它,日志里就只剩我们这一侧的猜测。
//
// 两条口径:
//
// - **有界**:只留前 N 字节 / 上报前 N 行。正常启动时 Chrome 也会往 stderr 写常规
// 信息,全打进日志就是刷屏;缓存也无上限地吃内存。
// - **不主动打**:本类型只缓存,**不输出**。成功时只报字节数(Debug),失败时由
// `localChrome.Dial` 一次性把前 N 行与本次 argv 一起报出来(见 launch.go)。
type chromeOutput struct {
mu sync.Mutex
head []byte
total int
truncated bool
}
// Write 实现 io.Writer。stdout 与 stderr 是两条独立的管道,`os/exec` 为它们各起一个
// 复制协程 ⇒ 必须加锁。它**永不返回错误**:缓存失败不能反过来弄坏子进程的写入。
func (o *chromeOutput) Write(p []byte) (int, error) {
o.mu.Lock()
defer o.mu.Unlock()
o.total += len(p)
room := chromeOutputMaxBytes - len(o.head)
if room <= 0 {
if len(p) > 0 {
o.truncated = true
}
return len(p), nil
}
if len(p) > room {
o.head = append(o.head, p[:room]...)
o.truncated = true
return len(p), nil
}
o.head = append(o.head, p...)
return len(p), nil
}
// totalBytes 报告子进程一共写了多少字节(截断时也照实报,不拿留下的那部分当全部)。
func (o *chromeOutput) totalBytes() int {
o.mu.Lock()
defer o.mu.Unlock()
return o.total
}
// preview 返回失败上报用的「前 N 行」:每行内的空白压成单空格、空行去掉;被截断时
// 在末尾点明**截断与总字节数**(绝不把半截输出说得像完整输出)。没有输出时返回空串,
// 调用方据此判断「浏览器一个字都没说」。
func (o *chromeOutput) preview() string {
o.mu.Lock()
raw := truncateRune(o.head)
truncated := o.truncated
total := o.total
o.mu.Unlock()
kept := make([]string, 0, chromeOutputMaxLines)
for _, line := range strings.Split(raw, "\n") {
line = strings.Join(strings.Fields(line), " ")
if line == "" {
continue
}
kept = append(kept, line)
if len(kept) == chromeOutputMaxLines {
truncated = true
break
}
}
text := strings.Join(kept, " | ")
if truncated && text != "" {
text += " | ...(已截断,共 " + strconv.Itoa(total) + " 字节)"
}
return text
}
// truncateRune 去掉尾部可能被切断的半个 UTF-8 字符(按字节截断的代价):最多回退
// utf8.UTFMax-1 个字节,不因个别非法字节丢掉整段可读输出。
func truncateRune(raw []byte) string {
for i := 0; i < utf8.UTFMax-1 && len(raw) > 0 && !utf8.Valid(raw); i++ {
raw = raw[:len(raw)-1]
}
return string(raw)
}
+176
View File
@@ -0,0 +1,176 @@
package browser
// 本文件是「调试端口的来源与落点」这一件事的**唯一出处**:取一个空闲端口、记在 profile
// 目录里、需要时读回来、陈记录清掉。
//
// 为什么改成自己记端口(DevDocs/reference/chromium-cdp/00 的「方案 A」,主人 2026-10-07 拍板):
//
// - Chromium 把 `--remote-debugging-port=0` 当作 **ChromeDriver 式自动化启动**,会主动打开
// Blink 的 `AutomationControlled` ⇒ `navigator.webdriver === true`(官方
// content/child/runtime_features.cc:389-404;本机实测见 07 的 g1 vs g7);**显式端口**
// 不会被这样处理(官方注释原文:调用方给具体端口时「更可能是挂调试器」,故不动该特性);
// - 我们因此**不再传** `--disable-blink-features=AutomationControlled`:它在显式端口下已是
// 空操作,却仍是整条命令行里唯一命中官方 `kBadFlags` 的参数 ⇒ 每次有头启动都弹一条
// 「不受支持的命令行标记」信息栏(01 的源码 + 07 的 g2/g7 实测)。去掉它,webdriver
// **本来就是 false**,横幅也随之消失;
// - 代价:Chromium **只在 `port==0` 时**才写 `<profile>/DevToolsActivePort`
// (官方 chrome/browser/devtools/remote_debugging_server.cc:363-395;实测 03 §3.3)——
// 改显式端口后那个文件不再出现,端口只能由**我们自己**记。
//
// 落点选 **profile 目录**里的 `cyrene-debug-port`(我们自有的记录:Chrome 既不读也不写它),
// 而不是 `$DATA_DIR/browser/` 下另开一份状态文件:
//
// - 它天然按 profile 隔离 —— 认领判定(adopt.go 的 probeExistingEndpoint)只需看一个目录,
// 不必再维护「profile → 记录」的映射与它们的失效时机;
// - 与 Chrome 自己的 `DevToolsActivePort` 同目录,两者可以并列读(debugPortCandidates);
// - profile 被删 / 被换 / 被搬走时记录随之消失,不会留下一条指向别人的陈端口。
//
// 记录内容只有一行端口号,**不含任何凭据**(profile 里的 cookie / 登录态在别的文件里)。
import (
"context"
"errors"
"fmt"
"net"
"os"
"path/filepath"
"strconv"
"strings"
)
const (
// portRecordFile 是我们自己的调试端口记录(首行端口号)。
portRecordFile = "cyrene-debug-port"
// legacyDevToolsPortFile 是 Chromium 在 `--remote-debugging-port=0` 时写的那个文件
// (reference/chromium-cdp/03 §3.2:首行端口、次行 browser_guid)。改用显式端口后我们
// **不再依赖它**,但仍然读一眼 —— 换二进制时上一版(port=0)起的浏览器可能还在跑:
// 不认它就会把「profile 上已经有我们的浏览器」误报成「profile 被别人占了」。
legacyDevToolsPortFile = "DevToolsActivePort"
// debugPortPickAttempts 是「取一个空闲端口」的尝试次数。做法:`listen(:0)` 让内核分配一个
// 空闲回环端口、随即关闭,把号交给 Chromium 去 bind —— 这中间存在极小的 TOCTOU 抢占窗口
// (AGENTS §9:我们自己**不开**监听端口,这里只是「借内核挑个号」再立刻放掉),抢不到就再取。
debugPortPickAttempts = 5
// launchPortAttempts 是本机启动的尝试次数:1 次正常 + 1 次**换端口**重试。重试只发生在
// 有证据表明「我们选的端口被别人占了」时(见 portTakenError);其余失败一律如实上报 ——
// 无差别重试只会把失败原因糊掉,还把预算烧在重复等待上。
launchPortAttempts = 2
)
// endpointURL 把端口号变成 CDP HTTP 端点的地址(127.0.0.1:调试端点在回环上,我们出站连它)。
func endpointURL(port int) string {
return "http://" + loopbackAddr(port)
}
// loopbackAddr 拼一个回环地址(探查、记录、启停都用同一处拼法,避免两处写法漂移)。
func loopbackAddr(port int) string {
return net.JoinHostPort("127.0.0.1", strconv.Itoa(port))
}
// pickDebugPort 取一个**当前空闲**的回环端口号交给 Chromium:listen(:0) 由内核分配、立刻
// 关闭(本进程不再持有它,AGENTS §9 的「不常驻监听端口」因此成立)。关掉之后再到手之间
// 有极小的被抢窗口,故取到一个「此刻确实没人听」的号才返回,最多试 debugPortPickAttempts 次。
func pickDebugPort(ctx context.Context) (int, error) {
for attempt := 0; attempt < debugPortPickAttempts; attempt++ {
ln, err := net.Listen("tcp", loopbackAddr(0))
if err != nil {
return 0, fmt.Errorf("listen(:0) 取空闲端口: %w", err)
}
port := ln.Addr().(*net.TCPAddr).Port
if err := ln.Close(); err != nil {
continue
}
if !portListening(ctx, port) {
return port, nil
}
}
return 0, fmt.Errorf("连续 %d 次取到的空闲端口都已被占用", debugPortPickAttempts)
}
// portRecordPath 返回这个 profile 的端口记录文件的路径。
func portRecordPath(profileDir string) string {
return filepath.Join(profileDir, portRecordFile)
}
// writePortRecord 记下**本次实际使用的**调试端口。写失败不算成功的一半:记不下端口就等于
// 下一次启动无法复用(会去起第二个进程、然后被 Chromium 委托出去),因此调用方必须把它当
// 硬失败(见 launchWithRetry)。
func writePortRecord(profileDir string, port int) error {
path := portRecordPath(profileDir)
if err := os.WriteFile(path, []byte(strconv.Itoa(port)+"\n"), 0o600); err != nil {
return fmt.Errorf("写调试端口记录 %s: %w", path, err)
}
return nil
}
// readPortRecord 读回我们自己记的端口;文件不在 / 内容不合法都返回 false(交给调用方当
// 「没有可复用的记录」处理,而不是把「正在写」误判成「配错」)。
func readPortRecord(profileDir string) (int, bool) {
return readPortNumberFile(portRecordPath(profileDir))
}
// DebugEndpoint 报告这个 profile 上**已知**的调试端点(我们自己记的端口记录,见本文件头)。
// 它只读文件、不探活、不启动任何进程,供排障与端到端用例取「这一次会话用的是哪个端点」。
// 返回 ("", false) 表示没有记录或记录不合法 —— **不猜**端口。
func DebugEndpoint(profileDir string) (string, bool) {
port, ok := readPortRecord(profileDir)
if !ok {
return "", false
}
return endpointURL(port), true
}
// debugPortCandidates 返回「这个 profile 上可能有 CDP 的端口」候选,按可信度排序:先我们
// 自己的记录,再那一眼兼容的老文件(legacyDevToolsPortFile),去重。
func debugPortCandidates(profileDir string) []int {
var out []int
if port, ok := readPortRecord(profileDir); ok {
out = append(out, port)
}
legacy := filepath.Join(profileDir, legacyDevToolsPortFile)
if port, ok := readPortNumberFile(legacy); ok && (len(out) == 0 || out[0] != port) {
out = append(out, port)
}
return out
}
// readPortNumberFile 读一个「首行是端口号」的文件;文件不在 / 还没写完 / 内容不合法都返回
// false。两种文件都套这个格式:我们自己的 portRecordFile(一行端口)与 Chrome 的
// legacyDevToolsPortFile(首行端口、次行 browser_guid)。
func readPortNumberFile(path string) (int, bool) {
raw, err := os.ReadFile(path)
if err != nil {
return 0, false
}
line, _, _ := strings.Cut(strings.TrimSpace(string(raw)), "\n")
port, err := strconv.Atoi(strings.TrimSpace(line))
if err != nil || port <= 0 || port > 65535 {
return 0, false
}
return port, true
}
// clearStalePortRecords 清掉**上一轮**留下的端口记录(两种都清)—— 只有确认「候选端口都已经
// 没人听」才会走到这里(见 Dial 的调用顺序)。
//
// 为什么必须清:这两条记录是「这个 profile 上有一个 CDP 端点」的**唯一线索**,上一轮的端口
// 留着只会让下一次启动的认领判定去探一个死端口。
//
// 为什么可以清:它们只是浏览器启动时写下的**一条记录**,不是锁。真正在跑、真正拥有这个
// profile 的实例一定有一个**活着的**端口,那种情况上一步(probeExistingEndpoint)已经把它
// 复用走了。因此删掉指向死端口的记录,不可能从任何活着的实例手里拿走什么 —— 它影响的只有
// 我们自己的认领判定。
//
// 文件本来就不在(ErrNotExist)不算失败;别的错误(被占用 / 权限)**如实报**并且**不继续
// 启动** —— 留着一条陈记录继续启动,只会把陈端口读成本次可复用的端点。
func (l *localChrome) clearStalePortRecords() error {
for _, path := range []string{portRecordPath(l.profileDir), filepath.Join(l.profileDir, legacyDevToolsPortFile)} {
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("%w: 清掉上一轮的端口记录 %s 失败: %v(留着它会把陈端口当成本次可复用的端点)",
ErrLaunch, path, err)
}
}
return nil
}
@@ -0,0 +1,401 @@
package browser
// 本文件是**真机判据**(真 Chrome + 真显式端口 + 临时 profile):方案 A
// (reference/chromium-cdp/00)落地后的端到端自证,默认 Skip,只在显式开闸时跑:
//
// CYRENE_BROWSER_CDP_E2E=1 go test ./internal/capability/browser/ -run TestRealChromeCDP -v -timeout 5m
//
// 它证明五件事,每件都取自**真进程 / 真页面**而不是我们的以为:
//
// ① `navigator.webdriver === false`(改前用 `--remote-debugging-port=0` 实测是 true,见
// reference/chromium-cdp/07 的 g1/g7);
// ② 真 Chrome 的**命令行原话**里是显式端口,且没有 `--disable-blink-features` /
// `--enable-automation` / `--headless`(从 Win32_Process 读回);
// ③ 其余判据与 07 §2.2 的矩阵一致(UA 不含 HeadlessChrome、plugins.length=5、
// languages=zh-CN、window.chrome 在)—— 即这次改动**只动了端口写法**;
// ④ 同一个 profile 上的**第二次** Dial 复用同一个端点、**不起第二个浏览器进程**;
// ⑤ 窗口内**没有** bad-flags 横幅(UI Automation 读控件树原文;有横幅时长这样:
// 「您使用的是不受支持的命令行标记:--disable-blink-features=…」,见 07 §三)。
//
// 安全边界(硬要求):profile 一律落在 `t.TempDir()`(%TEMP% 下),**绝不触碰**
// `$DATA_DIR/browser/profile`;收尾只按**这个唯一临时 profile 路径**筛 PID,不按进程名杀。
// assertProfileIsTemporary 是防误杀主人浏览器的闸。
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg"
)
// realPluginsLength 是 reference/chromium-cdp/07 §2.2 实测的 `navigator.plugins.length`
// (九组一致)。它只用来钉「这次改动没动其余判据」,不是新的期望值来源。
const realPluginsLength = 5
// realUIAminNodes 是 UI Automation 控件树的最少命名节点数:低于它说明**没读到窗口**,
// 那种情况下的「没有横幅」是「读不到」而不是「没有」,必须判红(positive control)。
const realUIAminNodes = 5
// cdpE2EEnabled 报告这次运行是否开了真机闸(与 wire_browser_lifecycle_test.go 的
// CYRENE_BROWSER_E2E 同一套路:真机用例不混进普通 go test)。
func cdpE2EEnabled() bool { return os.Getenv("CYRENE_BROWSER_CDP_E2E") == "1" }
// TestRealChromeCDP_WebdriverFalseAndPortReuse 是真机主判据(默认 Skip,用法见文件头)。
func TestRealChromeCDP_WebdriverFalseAndPortReuse(t *testing.T) {
if !cdpE2EEnabled() {
t.Skip("未设 CYRENE_BROWSER_CDP_E2E=1:真机用例默认不跑(用法见本文件顶部)")
}
if os.PathSeparator != '\\' {
t.Skip("本用例依赖 Windows 的 Win32_Process / UI Automation")
}
exe, ok := browsercfg.NewProber().FindLocalBrowser()
if !ok {
t.Skip("本机没有 Chrome/Chromium,真机用例无法进行")
}
profile := filepath.Join(t.TempDir(), "profile-cdp-e2e")
assertProfileIsTemporary(t, profile)
t.Logf("真机前置:chrome=%s\n临时 profile=%s", exe, profile)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
log, logs := newCapturedLog(slog.LevelDebug)
l := &localChrome{
exe: exe, profileDir: profile, headless: false,
viewportW: browsercfg.DefaultViewportWidth, viewportH: browsercfg.DefaultViewportHeight,
timeout: 90 * time.Second, log: log,
}
cfg := configView{
profileDir: profile, shotsDir: t.TempDir(), headless: false,
viewportW: browsercfg.DefaultViewportWidth, viewportH: browsercfg.DefaultViewportHeight,
timeout: 90 * time.Second, idle: time.Minute, shotMaxBytes: browsercfg.DefaultScreenshotMaxBytes,
}
defer killChromeForProfile(t, profile)
s, err := openSession(ctx, l, cfg)
if err != nil {
t.Fatalf("起真 Chrome 会话: %v\n日志:\n%s", err, logs.String())
}
endpoint := s.endpoint
port, ok := readPortRecord(profile)
if !ok {
t.Fatalf("成功启动后必须记下端口(%s)", portRecordFile)
}
if endpoint != endpointURL(port) {
t.Fatalf("endpoint = %q, want %q", endpoint, endpointURL(port))
}
t.Logf("① 启动成功:endpoint=%s(我们自己记下的端口 = %d)", endpoint, port)
// ── 判据 ②:真进程的命令行(Win32_Process 读回的原话) ─────────────────────────
procs := chromeProcsForProfile(t, profile, false)
if len(procs) != 1 {
t.Fatalf("按临时 profile 路径筛出的浏览器主进程应恰好 1 个,实得 %d:%+v", len(procs), procs)
}
browserPID := procs[0].PID
t.Logf("② 真 Chrome 命令行(PID %d,原话):\n%s", browserPID, procs[0].CommandLine)
cmdline := procs[0].CommandLine
for _, want := range []string{
"--remote-debugging-port=" + strconv.Itoa(port),
"--user-data-dir=" + profile,
"--remote-allow-origins=*",
} {
if !strings.Contains(cmdline, want) {
t.Errorf("真 Chrome 命令行里应有 %q", want)
}
}
for _, bad := range []string{
"--disable-blink-features", "--enable-blink-features", "--enable-automation", "--headless",
} {
if strings.Contains(cmdline, bad) {
t.Errorf("真 Chrome 命令行里不该有 %q(reference/chromium-cdp/01/02)", bad)
}
}
// ── 判据 ①/③:页面自报的判据 ──────────────────────────────────────────────────
fp, err := readFingerprint(ctx, s)
if err != nil {
t.Fatalf("读真机指纹: %v", err)
}
extra := readRealPageSignals(t, ctx, s)
t.Logf("① 页面自报 navigator.webdriver=%v\n③ %s", fp.Webdriver, extra.summary())
if fp.Webdriver {
t.Errorf("navigator.webdriver 必须为 false(显式端口不该点亮 AutomationControlled)")
}
if strings.Contains(strings.ToLower(fp.UserAgent), "headless") {
t.Errorf("UA 里不该出现 HeadlessChrome:%q", fp.UserAgent)
}
if !strings.Contains(fp.UserAgent, "Chrome/") {
t.Errorf("UA 看起来不是真 Chrome:%q", fp.UserAgent)
}
if len(fp.Languages) == 0 || !strings.HasPrefix(fp.Languages[0], "zh-CN") {
t.Errorf("languages 应与启动的 --lang=zh-CN 一致,实得 %v", fp.Languages)
}
if extra.PluginsLength != realPluginsLength {
t.Errorf("plugins.length = %d, want %d(与 reference/chromium-cdp/07 §2.2 矩阵一致)",
extra.PluginsLength, realPluginsLength)
}
if !extra.HasWindowChrome {
t.Error("window.chrome 应当存在")
}
// ── 判据 ⑤:窗口内没有 bad-flags 横幅(UIA 控件树原文) ────────────────────────
dump := uiaDumpWhenReady(t, browserPID)
t.Logf("⑤ UI Automation 控件树(PID %d,命名节点 %d 个):\n%s",
browserPID, countNonEmptyLines(dump), dump)
for _, banner := range []string{
"不受支持的命令行标记", "unsupported command-line flag", "自动测试软件正在控制",
} {
if strings.Contains(dump, banner) {
t.Errorf("窗口内出现了横幅 %q:\n%s", banner, dump)
}
}
// ── 判据 ④:同 profile 第二次启动必须复用、不起第二个进程 ─────────────────────
second, stop2, err := l.Dial(ctx)
if err != nil {
t.Fatalf("第二次 Dial 应当复用已存在的端点: %v\n日志:\n%s", err, logs.String())
}
defer stop2()
if second != endpoint {
t.Errorf("第二次 Dial 交出 %q, want %q(复用必须交回同一个端点)", second, endpoint)
}
again := chromeProcsForProfile(t, profile, false)
if len(again) != 1 || again[0].PID != browserPID {
t.Errorf("第二次 Dial 不该换来新进程:before=%+v after=%+v", procs, again)
}
t.Logf("④ 第二次 Dial 复用成功:endpoint=%s,浏览器主进程仍是 PID %d(未起第二个)", second, browserPID)
// ── 收尾:关掉本次会话,并确认无残留(按唯一临时 profile 路径筛) ─────────────
s.Close()
left := killChromeForProfile(t, profile)
if left != 0 {
t.Errorf("收尾后仍按临时 profile 筛到 %d 个进程", left)
}
t.Logf("收尾:会话已关;按 profile %s 筛到的残留 Chrome 进程 = %d", profile, left)
t.Logf("本次真机运行的浏览器侧日志:\n%s", logs.String())
}
// realPageSignals 是页面自报的其余判据(07 §2.2 矩阵里的 plugins / window.chrome 等)。
type realPageSignals struct {
UserAgent string `json:"userAgent"`
PluginsLength int `json:"pluginsLength"`
Languages []string `json:"languages"`
HasWindowChrome bool `json:"hasWindowChrome"`
ChromeKeys []string `json:"chromeKeys"`
ScreenW int `json:"screenW"`
ScreenH int `json:"screenH"`
ColorDepth int `json:"colorDepth"`
Platform string `json:"platform"`
DevicePixelRatio float64 `json:"devicePixelRatio"`
}
// summary 把读数压成一行(进 t.Log,便于与 07 的矩阵逐项对照)。
func (r realPageSignals) summary() string {
return fmt.Sprintf("UA=%q plugins.length=%d languages=%v window.chrome=%v keys=%v "+
"platform=%s screen=%dx%dx%d dpr=%g",
r.UserAgent, r.PluginsLength, r.Languages, r.HasWindowChrome, r.ChromeKeys,
r.Platform, r.ScreenW, r.ScreenH, r.ColorDepth, r.DevicePixelRatio)
}
// realPageSignalsExpr 一次求值取回整组判据(口径与 07 的探针一致,多了 plugins / window.chrome)。
const realPageSignalsExpr = `(() => ({
userAgent: navigator.userAgent,
pluginsLength: navigator.plugins.length,
languages: navigator.languages,
hasWindowChrome: typeof window.chrome !== 'undefined',
chromeKeys: (typeof window.chrome === 'undefined') ? [] : Object.keys(window.chrome),
screenW: screen.width,
screenH: screen.height,
colorDepth: screen.colorDepth,
platform: navigator.platform,
devicePixelRatio: window.devicePixelRatio
}))()`
// readRealPageSignals 在**隔离世界**里读一次页面判据(与生产路径同一口径)。
func readRealPageSignals(t *testing.T, ctx context.Context, s *session) realPageSignals {
t.Helper()
var out realPageSignals
if err := s.evaluate(ctx, realPageSignalsExpr, &out); err != nil {
t.Fatalf("读页面判据: %v", err)
}
raw, _ := json.Marshal(out)
t.Logf("页面判据(原始 JSON):%s", raw)
return out
}
// ── Windows 侧探针:进程表 / UI Automation / 收尾清理(都只按临时 profile 路径或 PID 定位) ──
// chromeProcsPS 列出命令行含指定 profile 的 Chrome 进程(`PID|命令行` 每行一条)。
// includeChildren=1 时连渲染等子进程一起列(子进程 argv 里带 `--type=`)。
const chromeProcsPS = `param([string]$Profile, [string]$IncludeChildren)
$ErrorActionPreference = 'SilentlyContinue'
$wantChildren = ($IncludeChildren -eq '1')
Get-CimInstance Win32_Process | Where-Object {
$_.Name -like 'chrome*' -and $_.CommandLine -and $_.CommandLine.Contains($Profile) -and
($wantChildren -or (-not $_.CommandLine.Contains('--type=')))
} | ForEach-Object { '{0}|{1}' -f $_.ProcessId, $_.CommandLine }
`
// killChromePS 按 profile 路径筛 PID 并强制结束(`$PID` 是 PowerShell 自己的进程号,
// 必须排除 —— 它的命令行里也带着这个 profile 参数)。绝不按进程名杀。
const killChromePS = `param([string]$Profile)
$ErrorActionPreference = 'SilentlyContinue'
$me = $PID
Get-CimInstance Win32_Process | Where-Object {
$_.ProcessId -ne $me -and $_.Name -like 'chrome*' -and $_.CommandLine -and $_.CommandLine.Contains($Profile)
} | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }
`
// uiaDumpPS 用 UI Automation 把该 PID 主窗口的控件树**名字**写到 `-OutPath` 指的 UTF-8 文件
// (07 §三 的手法:纯 ASCII 脚本 + UTF-8 落盘,避开 PowerShell 5.1 按 GBK 解析脚本的问题)。
// ⚠️ 参数不能叫 `$Out`:它会被 PowerShell 当成通用参数 `-OutVariable` 的缩写,于是我自己的
// 参数收不到值(实测:脚本 exit 0 但文件根本没写)。
const uiaDumpPS = `param([int]$BrowserPid, [string]$OutPath)
$ErrorActionPreference = 'SilentlyContinue'
Add-Type -AssemblyName UIAutomationClient
$proc = Get-Process -Id $BrowserPid
$handle = if ($null -eq $proc) { 0 } else { $proc.MainWindowHandle }
$text = ''
if ($handle -eq 0) {
$text = 'NO_WINDOW'
} else {
$root = [System.Windows.Automation.AutomationElement]::FromHandle($handle)
if ($null -eq $root) { $text = 'NO_ROOT' } else {
$walker = [System.Windows.Automation.TreeWalker]::ControlViewWalker
$out = New-Object System.Collections.Generic.List[string]
function Walk($el, $depth) {
if ($null -eq $el) { return }
if ($depth -gt 12) { return }
try {
$name = $el.Current.Name
if ($name -and $name.Trim().Length -gt 0) { $out.Add((' ' * $depth) + $name.Trim()) }
$child = $walker.GetFirstChild($el)
while ($null -ne $child) { Walk $child ($depth + 1); $child = $walker.GetNextSibling($child) }
} catch { }
}
Walk $root 0
$text = [string]::Join([char]10, $out)
}
}
[System.IO.File]::WriteAllText($OutPath, $text, (New-Object System.Text.UTF8Encoding($false)))
`
// runPowerShell 把一段脚本落到临时 .ps1 再执行(args 以 `-Name value` 形式传入)。
func runPowerShell(t *testing.T, name, script string, args ...string) string {
t.Helper()
path := filepath.Join(t.TempDir(), name+".ps1")
if err := os.WriteFile(path, []byte(script), 0o600); err != nil {
t.Fatalf("写 PowerShell 脚本: %v", err)
}
cmdArgs := append([]string{
"-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", path,
}, args...)
cmd := exec.Command("powershell", cmdArgs...)
raw, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("执行 PowerShell 脚本 %s: %v\n输出:%s", name, err, raw)
}
return string(raw)
}
// assertProfileIsTemporary 是本文件的**安全闸**:profile 必须在系统临时目录下,
// 免得任何一步(尤其是收尾的按路径杀进程)碰到主人的真实 profile。
func assertProfileIsTemporary(t *testing.T, profile string) {
t.Helper()
tmp := strings.ToLower(strings.TrimRight(os.TempDir(), `\`))
if !strings.HasPrefix(strings.ToLower(profile), tmp) {
t.Fatalf("拒绝在非临时目录下跑真机用例(防误杀主人的浏览器):%s 不在 %s 下", profile, tmp)
}
}
// chromeProcsForProfile 从 Win32_Process 里筛出「命令行含这个临时 profile」的 Chrome 进程。
// includeChildren=false 只要**浏览器主进程**(子进程 argv 里有 `--type=`)。
func chromeProcsForProfile(t *testing.T, profile string, includeChildren bool) []realChromeArgs {
t.Helper()
children := "0"
if includeChildren {
children = "1"
}
out := runPowerShell(t, "chrome-procs", chromeProcsPS, "-Profile", profile, "-IncludeChildren", children)
procs := make([]realChromeArgs, 0, 1)
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
pidStr, cmdline, ok := strings.Cut(line, "|")
if !ok {
continue
}
pid, err := strconv.Atoi(strings.TrimSpace(pidStr))
if err != nil {
continue
}
procs = append(procs, realChromeArgs{PID: pid, CommandLine: cmdline})
}
return procs
}
// realChromeArgs 是从**进程表里读回**的一行进程信息(原话,不做任何改写)。
type realChromeArgs struct {
PID int
CommandLine string
}
// uiaDumpForPID 用 UI Automation 把这个 PID 主窗口的控件树文本 dump 出来(07 §三 的手法)。
func uiaDumpForPID(t *testing.T, pid int) string {
t.Helper()
outPath := filepath.Join(t.TempDir(), "uia.txt")
runPowerShell(t, "uia-dump", uiaDumpPS, "-BrowserPid", strconv.Itoa(pid), "-OutPath", outPath)
raw, err := os.ReadFile(outPath)
if err != nil {
t.Fatalf("读 UIA 文本 %s: %v", outPath, err)
}
return string(raw)
}
// uiaDumpWhenReady 等窗口画出来再 dump(有界重试):窗口还没出现不是「没有横幅」的证据,
// 因此读不到节点就继续等,等不到就判红(positive control)。
func uiaDumpWhenReady(t *testing.T, pid int) string {
t.Helper()
var dump string
for attempt := 0; attempt < 20; attempt++ {
dump = uiaDumpForPID(t, pid)
if countNonEmptyLines(dump) >= realUIAminNodes {
return dump
}
time.Sleep(500 * time.Millisecond)
}
t.Fatalf("UI Automation 读不到足够的控件节点(最后一次读到 %d 个命名节点,内容见下),"+
"因此不能说「没有横幅」:\n%s", countNonEmptyLines(dump), dump)
return ""
}
// killChromeForProfile 按**唯一临时 profile 路径**筛 PID 并强制结束,返回剩下的个数。
// 绝不按进程名杀;也绝不碰命令行里不含这个 profile 的进程。
func killChromeForProfile(t *testing.T, profile string) int {
t.Helper()
runPowerShell(t, "chrome-kill", killChromePS, "-Profile", profile)
time.Sleep(500 * time.Millisecond)
return len(chromeProcsForProfile(t, profile, true))
}
// countNonEmptyLines 数文本里的非空行(UIA 控件的 positive control 用)。
func countNonEmptyLines(s string) int {
n := 0
for _, line := range strings.Split(s, "\n") {
if strings.TrimSpace(line) != "" {
n++
}
}
return n
}
+83
View File
@@ -0,0 +1,83 @@
package browser
import (
"context"
"unicode/utf8"
)
// bodyTextExpr 取**渲染完成后**页面的可见正文:`document.body.innerText`。
//
// 为什么不是 `documentElement.innerHTML` / `outerHTML`(design/28 §6 坑 4):那是
// **源码**不是正文 —— 动态页的源码里只有骨架与脚本,模型拿到等于没拿到。innerText
// 是浏览器按布局算出来的可见文本(display:none 的内容不算),这才是「渲染后」的读数。
//
// body 可能为 null(about:blank / 空白文档):那种情况如实交空串,由**上层**判空,
// 本层不把它当错误(见 RenderText 的口径)。
const bodyTextExpr = `(() => { const b = document.body; return b ? b.innerText : ""; })()`
// RenderedText 是一次「渲染取正文」的结论。
type RenderedText struct {
// Text 是渲染后页面的可见正文(纯文本,已去标签)。
// **空串是合法读数**:它表示「渲染走通了,但这个页面确实没有可见正文」,不是失败
// —— 是不是成功由上层按它的口径判(io 的降级档把空正文按 `ErrEmptyRenderedBody` 处理)。
Text string `json:"text"`
// Source 是会话来源描述(本机有头 / 已连的 CDP 端点),如实标注、不含凭据。
Source string `json:"source"`
// URL / Title 是页面**自报**的最终地址与标题(可能因重定向与请求的不同)。
URL string `json:"url"`
Title string `json:"title"`
}
// RenderText 导航到 rawURL,等页面就绪后取回可见正文(design/28 §1 M5 / §8 B5 的
// 「浏览器渲染取正文」能力面;消费方是 web-fetch 的降级档,见 plugins/tools/io)。
//
// 三条口径,与 io.BodyRenderer 的红线一一对应:
//
// - **过同一份 SSRF 判定**:走既有的 `GuardURL`(它就是 `io.ValidateRequestURL` 的
// 门面,design/28 §4-1:判定源只有一份),与本包 Open 同一条闸;
// - **不新造第二套 CDP 客户端**:与 Open / Screenshot / Fingerprint 同一条路线
// (`withSession` → `session` → 隔离世界求值,不发 `Runtime.enable`);
// - **渲染完成后才取正文**:等待条件的显式落点是 `session.navigate` → `waitReady`
// (轮询 `document.readyState` 直到 `complete`,见 session.go);就绪之后才求
// `body.innerText`。
//
// 「渲染失败」与「渲染成功但正文为空」在返回值上是**两件事**:前者是 error
// (`ErrBlockedURL` / `ErrUnavailable` / `ErrNavigate` / `ErrEvaluate` / `ErrCDP` 分类),
// 后者是 `Text == ""` 且 `err == nil`。既不把空正文当失败,也不拿空文本冒充成功。
func (m *Manager) RenderText(ctx context.Context, rawURL string) (RenderedText, error) {
if _, err := GuardURL(ctx, rawURL, m.allowed); err != nil {
m.log.Warn("browser text render blocked by URL guard", "reason", ReasonBlockedURL, "error", err)
return RenderedText{}, err
}
out := RenderedText{Source: m.Source()}
err := m.withSession(ctx, func(s *session) error {
state, err := s.navigate(ctx, rawURL)
if err != nil {
return err
}
text, err := s.bodyText(ctx)
if err != nil {
return err
}
out.URL, out.Title, out.Text = state.URL, state.Title, text
return nil
})
if err != nil {
return RenderedText{}, err
}
// 只记来源与规模,**不记 URL / 标题 / 正文**:页面内容可能含凭据,日志不该成为
// 它的第二个副本(AGENTS §10)。没有字符数是「渲染成功但正文为空」唯一的排查线索,
// 所以留着它(0 = 空正文,不是错误)。
m.log.Debug("browser text rendered", "source", out.Source, "chars", utf8.RuneCountInString(out.Text))
return out, nil
}
// bodyText 在**隔离世界**里取一次 `document.body.innerText`(见 bodyTextExpr)。
// 它是 session 的一次普通求值 —— 复用既有的上下文与按值取回路径,不另开会话。
func (s *session) bodyText(ctx context.Context) (string, error) {
var text string
if err := s.evaluate(ctx, bodyTextExpr, &text); err != nil {
return "", err
}
return text, nil
}
+187
View File
@@ -0,0 +1,187 @@
package browser
import (
"context"
"errors"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg"
)
// renderTestManager 造一个「导航目标已过白名单」的管理器:白名单里有 example.com,
// 于是 RenderText 的 SSRF 判定走白名单短路(不依赖外网 DNS,离线可跑)。
func renderTestManager(t *testing.T, avail browsercfg.Availability) *Manager {
t.Helper()
m := NewManager(Options{
Availability: avail,
Config: browsercfg.Defaults(t.TempDir()),
DataDir: t.TempDir(),
AllowedHosts: []string{"example.com"},
})
t.Cleanup(func() { _ = m.Close() })
return m
}
// okBrowserCDP 是「导航得通、正文取得回」的假 CDP:它按方法名应答,并把页面自报的
// state(url/title/readyState)与正文 text 分别交给 pageState 与 innerText 两次求值。
// 两个求值靠表达式区分(一个读 readyState,一个读 body.innerText)。
func okBrowserCDP(t *testing.T, text string) *fakeCDP {
t.Helper()
return newFakeCDP(t, func(method string, params map[string]any) (any, *cdpError) {
switch method {
case "Page.enable", "Page.navigate":
return map[string]any{}, nil
case "Page.getFrameTree":
return map[string]any{"frameTree": map[string]any{"frame": map[string]any{"id": "frame-1"}}}, nil
case "Page.createIsolatedWorld":
return map[string]any{"executionContextId": 1}, nil
case "Runtime.evaluate":
expr, _ := params["expression"].(string)
if strings.Contains(expr, "readyState") {
return map[string]any{"result": map[string]any{"value": map[string]any{
"url": "https://example.com/final", "title": "示例页", "readyState": "complete",
}}}, nil
}
if !strings.Contains(expr, "innerText") {
return nil, &cdpError{Code: -32601, Message: "unexpected expression " + expr}
}
return map[string]any{"result": map[string]any{"value": text}}, nil
}
return nil, &cdpError{Code: -32601, Message: "unexpected " + method}
})
}
// cdpEndpointAvail 把假 CDP 的地址包装成「已配 CDP 端点」的探测结论(走 remoteCDP,
// 不起任何本机进程)。
func cdpEndpointAvail(f *fakeCDP) browsercfg.Availability {
return browsercfg.Availability{
Usable: true, Source: browsercfg.SourceCDP, Endpoint: f.base, Detail: "假 CDP 端点 " + f.base,
}
}
// ① 渲染成功 ⇒ 取回渲染后的可见正文(innerText,不是 HTML 源码),并如实带出来源与
// 页面自报的地址/标题。
func TestManager_RenderTextReturnsVisibleText(t *testing.T) {
t.Parallel()
f := okBrowserCDP(t, "渲染后的正文:Hello 世界")
m := renderTestManager(t, cdpEndpointAvail(f))
got, err := m.RenderText(context.Background(), "https://example.com/")
if err != nil {
t.Fatalf("RenderText: %v", err)
}
if got.Text != "渲染后的正文:Hello 世界" {
t.Errorf("Text = %q", got.Text)
}
if got.URL != "https://example.com/final" || got.Title != "示例页" {
t.Errorf("URL/Title 应取页面自报值,实得 %q / %q", got.URL, got.Title)
}
if !strings.Contains(got.Source, "假 CDP 端点") {
t.Errorf("Source = %q,必须如实标注来源", got.Source)
}
}
// ② 渲染**成功但正文为空** ⇒ 空串 + nil error:它是读数不是失败,由上层按自己的口径
// 判(io 的降级档把空正文按 ErrEmptyRenderedBody 处理)。这里钉住本层不许把它变成 error,
// 也不许拿空串冒充「取到了失败」。
func TestManager_RenderTextEmptyBodyIsNotAnError(t *testing.T) {
t.Parallel()
f := okBrowserCDP(t, " \n ")
m := renderTestManager(t, cdpEndpointAvail(f))
got, err := m.RenderText(context.Background(), "https://example.com/")
if err != nil {
t.Fatalf("正文为空不该是错误(它是读数),实得 %v", err)
}
if strings.TrimSpace(got.Text) != "" {
t.Errorf("Text = %q, want 空/空白", got.Text)
}
}
// ③ 导航失败(CDP 的 errorText)⇒ 如实报错,**不返回空串冒充成功**:判据是
// errors.Is(err, ErrNavigate) 且正文那一次求值根本没发生过。
func TestManager_RenderTextNavigateFailureIsHonestError(t *testing.T) {
t.Parallel()
f := newFakeCDP(t, func(method string, _ map[string]any) (any, *cdpError) {
switch method {
case "Page.enable":
return map[string]any{}, nil
case "Page.navigate":
return map[string]any{"errorText": "net::ERR_NAME_NOT_RESOLVED"}, nil
}
return nil, &cdpError{Code: -32601, Message: "导航失败后不该再发 " + method}
})
m := renderTestManager(t, cdpEndpointAvail(f))
got, err := m.RenderText(context.Background(), "https://example.com/")
if err == nil {
t.Fatal("导航失败必须报错,不许返回空正文冒充成功")
}
if !errors.Is(err, ErrNavigate) {
t.Errorf("err = %v, want ErrNavigate", err)
}
if !strings.Contains(err.Error(), "ERR_NAME_NOT_RESOLVED") {
t.Errorf("错误里应带上 CDP 的原始原因: %v", err)
}
if got.Text != "" || got.Source != "" {
t.Errorf("失败时不该交出半个结论: %+v", got)
}
}
// ④ 页面求值异常(正文那一次)⇒ 归类为 ErrEvaluate,同样不许冒充「取到了空正文」。
func TestManager_RenderTextEvaluateFailureIsHonestError(t *testing.T) {
t.Parallel()
f := newFakeCDP(t, func(method string, params map[string]any) (any, *cdpError) {
switch method {
case "Page.enable", "Page.navigate":
return map[string]any{}, nil
case "Page.getFrameTree":
return map[string]any{"frameTree": map[string]any{"frame": map[string]any{"id": "frame-1"}}}, nil
case "Page.createIsolatedWorld":
return map[string]any{"executionContextId": 1}, nil
case "Runtime.evaluate":
expr, _ := params["expression"].(string)
if strings.Contains(expr, "readyState") {
return map[string]any{"result": map[string]any{"value": map[string]any{
"url": "https://example.com/", "title": "t", "readyState": "complete",
}}}, nil
}
return map[string]any{"exceptionDetails": map[string]any{"text": "TypeError: body is gone"}}, nil
}
return nil, &cdpError{Code: -32601, Message: "unexpected " + method}
})
m := renderTestManager(t, cdpEndpointAvail(f))
if _, err := m.RenderText(context.Background(), "https://example.com/"); !errors.Is(err, ErrEvaluate) {
t.Fatalf("err = %v, want ErrEvaluate", err)
}
}
// ⑤ SSRF 守卫在**起浏览器之前**跑:云元数据地址被拒,且没有任何 CDP 请求发生
// (假 CDP 只被 /json/version 碰过的话就不是这条路径 —— 这里直接用不可达的可执行文件,
// 若顺序颠倒会拿到启动错误而不是守卫错误)。
func TestManager_RenderTextBlockedURLRejectedBeforeAnyProcess(t *testing.T) {
t.Parallel()
m := renderTestManager(t, browsercfg.Availability{
Usable: true, Source: browsercfg.SourceLocalChrome, Executable: "definitely-not-a-browser.exe",
})
_, err := m.RenderText(context.Background(), "http://169.254.169.254/latest/meta-data/")
if !errors.Is(err, ErrBlockedURL) {
t.Fatalf("err = %v, want ErrBlockedURL", err)
}
if !strings.Contains(err.Error(), "安全守卫") {
t.Errorf("错误应来自 URL 守卫: %v", err)
}
}
// ⑥ 没有可用来源时不假装可用:ErrUnavailable(组合根据此**不挂**这条降级档)。
func TestManager_RenderTextUnavailableDoesNotPretend(t *testing.T) {
t.Parallel()
m := renderTestManager(t, browsercfg.Availability{Usable: false, Detail: "本机没有浏览器"})
if _, err := m.RenderText(context.Background(), "https://example.com/"); !errors.Is(err, ErrUnavailable) {
t.Fatalf("err = %v, want ErrUnavailable", err)
}
}
+51
View File
@@ -301,6 +301,57 @@ func (r *Registry) RegisterChannel(c sdk.Channel) error {
})
}
// ReplaceChannel 用**同名**的 c 就地更新已登记的渠道条目(热重载,design/04 §3.1)。
//
// 为什么必须是替换而不是 Register:渠道热重载**必然同名**(同名账号的账号名由
// channels.json 决定,重载前后不变),而 Register 遇重名返回 ErrDuplicate,会把
// 新适配器挡在门外 —— 注册表于是继续指向旧实例,管理面板照旧按旧配置读写它的状态
// (显示「未连接」、点「连接」拨的是旧地址)。
//
// 语义是 **upsert**,与 ReplaceTool 有意不同(工具的窄方法是「只换现有这把」):
// - 名字不存在 ⇒ 按 RegisterChannel 同口径新增(账号被新加进配置后,热重载是它
// 第一次出现在注册表里的时机,没有别处会替它注册);
// - 名字已存在且 Kind == KindChannel ⇒ 覆盖 Value(指向新适配器),**保留既有
// Policy** —— 分组/陌生人的可达标记是运行期可编辑配置(design/11 §3),不属于
// 适配器的构造产出,热重载把它清掉等于「面板保存一次,某个群忽然不能用了」;
// - 名字已存在但 Kind != KindChannel ⇒ 报错,绝不悄悄改掉那条能力的类型。
func (r *Registry) ReplaceChannel(c sdk.Channel) error {
if c == nil || c.Name() == "" {
return fmt.Errorf("registry: replace channel: %w", ErrInvalidArgument)
}
name := c.Name()
r.mu.Lock()
defer r.mu.Unlock()
cur, ok := r.caps[name]
if !ok {
// 新增分支与 Register 同口径:落盘策略覆盖初始标记(DB 是策略的权威载体)。
added := Capability{
Name: name,
Kind: KindChannel,
Description: name,
Value: c,
}
if pol, ok := r.persisted[name]; ok {
added.Policy = pol
}
r.caps[name] = added
return nil
}
if cur.Kind != KindChannel {
return fmt.Errorf("registry: replace %q: 既有能力类型是 %q,不是渠道: %w", name, cur.Kind, ErrInvalidArgument)
}
r.caps[name] = Capability{
Name: name,
Kind: KindChannel,
// Description 与 RegisterChannel 一致地取适配器名(面板按注册名精确匹配)。
Description: name,
// Policy 沿用旧条目:它是可编辑的运行期配置,不属于适配器的构造产出。
Policy: cur.Policy,
Value: c,
}
return nil
}
// validKind reports whether k is one of the known capability kinds.
func validKind(k Kind) bool {
switch k {
@@ -0,0 +1,156 @@
package registry
import (
"context"
"errors"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// 本文件钉住渠道热重载的窄方法 `ReplaceChannel`(upsert,design/04 §3.1):
// 热重载重建出来的适配器**必然同名**,Register 会 ErrDuplicate 把新实例挡在门外,
// 注册表于是继续指向旧实例——管理面板显示「未连接」、点「连接」驱动的是旧实例。
// stubChannel 是名字可指定、连接状态可变的最小渠道(只用于注册/替换的判定)。
type stubChannel struct {
name string
up bool
}
func (s *stubChannel) Name() string { return s.name }
func (s *stubChannel) Connect(context.Context) error { s.up = true; return nil }
func (s *stubChannel) Disconnect(context.Context) error { s.up = false; return nil }
func (s *stubChannel) IsConnected() bool { return s.up }
func (s *stubChannel) HealthCheck(context.Context) error { return nil }
var _ sdk.Channel = (*stubChannel)(nil)
// TestReplaceChannelSwapsValueAndKeepsPolicy 是热重载的主判据:
// - 同名条目换成了**新实例**(调用方随后 Connect/IsConnected 驱动的就是新的那一把);
// - **Policy 原样保留** —— 分组/陌生人可达标记是运行期配置,不属于适配器实现;
// - Kind/Description 与 RegisterChannel 同口径。
func TestReplaceChannelSwapsValueAndKeepsPolicy(t *testing.T) {
const name = "qq-obv11:9616b27ae314253c54419b7541538cdc"
r := New()
old := &stubChannel{name: name, up: true}
if err := r.RegisterChannel(old); err != nil {
t.Fatalf("RegisterChannel: %v", err)
}
if err := r.SetAllowInGroup(name, true); err != nil {
t.Fatalf("SetAllowInGroup: %v", err)
}
next := &stubChannel{name: name, up: true}
// 先钉住「为什么必须是替换而不是 Register」:同名注册必被拒。
if err := r.RegisterChannel(next); !errors.Is(err, ErrDuplicate) {
t.Fatalf("同名的 RegisterChannel err = %v, want ErrDuplicate(热重载必然同名)", err)
}
if err := r.ReplaceChannel(next); err != nil {
t.Fatalf("ReplaceChannel: %v", err)
}
cap, err := r.Get(name)
if err != nil {
t.Fatalf("Get: %v", err)
}
// 判据是接口值同一性:新旧实例的 Name() 完全相同,比名字必然假绿。
if cap.Value != sdk.Channel(next) {
t.Fatalf("替换后 Value = %#v, want 新实例", cap.Value)
}
if cap.Value == sdk.Channel(old) {
t.Fatal("替换后仍指向旧实例:面板会显示未连接、点「连接」驱动旧实例")
}
if !cap.Policy.AllowInGroup {
t.Fatal("替换丢掉了 AllowInGroup:运行期策略不属于适配器构造产出,必须沿用")
}
if cap.Kind != KindChannel {
t.Fatalf("替换后 Kind = %q, want %q", cap.Kind, KindChannel)
}
if cap.Description != name {
t.Fatalf("替换后 Description = %q, want %q(与 RegisterChannel 同口径)", cap.Description, name)
}
}
// TestReplaceChannelRegistersMissingName:名字不存在时按 RegisterChannel 同口径新增
// —— 新加进配置的账号,热重载是它第一次出现在注册表里的时机,没有别处替它注册。
func TestReplaceChannelRegistersMissingName(t *testing.T) {
const name = "kook:conf0"
r := New()
ch := &stubChannel{name: name, up: true}
if err := r.ReplaceChannel(ch); err != nil {
t.Fatalf("ReplaceChannel: %v", err)
}
cap, err := r.Get(name)
if err != nil {
t.Fatalf("Get: %v", err)
}
if cap.Value != sdk.Channel(ch) {
t.Fatalf("新增后 Value = %#v, want 该适配器", cap.Value)
}
if cap.Kind != KindChannel || cap.Description != name {
t.Fatalf("新增条目形状 = %q/%q, want %q/%q", cap.Kind, cap.Description, KindChannel, name)
}
// 面板按 Kind 枚举渠道卡片,新条目必须能列出来。
listed := r.List(KindChannel)
if len(listed) != 1 || listed[0].Name != name {
t.Fatalf("List(KindChannel) = %#v, want 仅 %q", listed, name)
}
}
// TestReplaceChannelUpsertHonorsPersistedPolicy 钉住新增分支与 Register 的同口径性:
// DB 是策略的权威载体(裁决 10.4.2),已落盘的策略要覆盖新条目的零值。
func TestReplaceChannelUpsertHonorsPersistedPolicy(t *testing.T) {
r := New()
r.persisted = map[string]ToolPolicy{"kook": {AllowInGroup: true}}
if err := r.ReplaceChannel(&stubChannel{name: "kook"}); err != nil {
t.Fatalf("ReplaceChannel: %v", err)
}
cap, err := r.Get("kook")
if err != nil {
t.Fatalf("Get: %v", err)
}
if !cap.Policy.AllowInGroup {
t.Fatal("新增分支丢掉了已落盘的策略:与 Register 的口径不一致")
}
}
// TestReplaceChannelRefusesNonChannelEntry:同名条目不是渠道时拒绝,绝不悄悄改掉它的 Kind。
func TestReplaceChannelRefusesNonChannelEntry(t *testing.T) {
r := New()
if err := r.Register(Capability{Name: "kook", Kind: KindMCP, Value: struct{}{}}); err != nil {
t.Fatalf("Register: %v", err)
}
err := r.ReplaceChannel(&stubChannel{name: "kook"})
if !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("err = %v, want ErrInvalidArgument", err)
}
if err == nil || !strings.Contains(err.Error(), string(KindMCP)) {
t.Fatalf("错误消息 %q 里应指出既有类型 %q", err, KindMCP)
}
cap, gerr := r.Get("kook")
if gerr != nil {
t.Fatalf("Get: %v", gerr)
}
if cap.Kind != KindMCP {
t.Fatalf("被拒的替换却改了 Kind:%q", cap.Kind)
}
if _, ok := cap.Value.(*stubChannel); ok {
t.Fatal("被拒的替换却改了 Value")
}
}
// TestReplaceChannelRejectsNilAndEmptyName:两个不该被静默吞掉的调用错误。
func TestReplaceChannelRejectsNilAndEmptyName(t *testing.T) {
r := New()
if err := r.ReplaceChannel(nil); !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("替换成 nil err = %v, want ErrInvalidArgument", err)
}
if err := r.ReplaceChannel(&stubChannel{}); !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("替换成空名 err = %v, want ErrInvalidArgument", err)
}
if got := r.List(""); len(got) != 0 {
t.Fatalf("非法入参不得写进注册表,实际写入 %#v", got)
}
}
@@ -0,0 +1,82 @@
package adapter
// 连接**就绪三态**的取值与判定:一次 Connect 返回之后,适配器到底处于哪一种状态。
//
// 为什么必须三态而不是一个 bool:`Connect` 返回 nil 只说明「这次调用没有致命失败」,
// 它至少覆盖三件互不相同的事——
//
// ConnectStateConnected —— 真连上了(client 拨号成功 / HTTP 传输已装配就绪);
// ConnectStatePending —— 已进入后台重试、当前还没连上(OneBot 实现比 Cyrene 后
// 启动是常态,首连失败刻意非致命);
// ConnectStateReady —— 已绑定监听、就绪等待对端接入(ws+server 模式)。
//
// pending 与 ready 的 IsConnected() 都是 false,但它们**不是**「未连接」这样一个静态
// 结论:前者是「正在连接」,后者是「就绪等接入」。用一个布尔去覆盖三态,管理面板就只能
// 编一个说法——编出来的正是 2026-10-07 那个「点了连接就弹已连接,而卡片一直显示未连接」
// 的谎。口径属于适配器(它才知道自己的 transport × mode 意味着什么),网关只做映射
// (internal/services/gateway 的 connectOutcomeAware,经类型断言探测,核心契约不扩展)。
//
// 设计依据:DevDocs/design/08-渠道接入.md §1(PlatformAdapter 契约)与 §4.1(四端
// transport × mode);立场与 design/26 §2.6「状态面必须区分 WS 连着与账号在线」一致 ——
// 一个布尔说不了两件事,更说不了三件。
//
// 与 SessionState* / AccountStatus 同一口径:核心 PlatformAdapter 契约**刻意不扩展**,
// 需要这条状态面的适配器自行实现该方法,网关用类型断言探测;没实现的适配器行为逐字不变。
const (
// ConnectStateConnected 表示真连上:会话已建立(client 拨号成功),或传输已装配
// 就绪而无可观察的长连接(HTTP)。此时 IsConnected() 应为 true。
ConnectStateConnected = "connected"
// ConnectStatePending 表示已进入后台重试、当前尚未连上。它既不是配置错误,也不是
// 「未连接」,而是「连接中」,Reason 应说明原因(脱敏,绝不含凭据)。
ConnectStatePending = "pending"
// ConnectStateReady 表示已就绪、等待对端接入:server 模式已绑定监听,但还没有
// 对端连上来。IsConnected() 此时为 false,却**不是失败**,因此没有 Reason。
ConnectStateReady = "ready"
)
// ConnectSignals 是判定三态所需的**全部**输入。用结构体而不是几个相邻的布尔参数,
// 一是让调用点自解释,二是避免两个 bool 被接反(那会静默地骗过所有测试)。
type ConnectSignals struct {
// Started 报告 Connect 是否已成功启动该适配器;false 表示尚未启动,无从判断。
Started bool
// Connected 报告当前是否有一条活的会话。
Connected bool
// Retrying 报告未连上时后台是否有重试循环在跑(ws+client 有一条;HTTP 没有会话
// 可重连,不算)。
Retrying bool
// Listening 报告适配器是否已绑定监听、在等对端接入(server 模式)。
Listening bool
// Reason 是最近一次失败的原因(**已脱敏**);没有失败或已连上时为空串。
Reason string
}
// ConnectStateOf 是**纯函数**:由信号判定三态与原因。它不读时钟、不触网、不加锁,
// 因此三态可以逐条单测(见 connect_outcome_test.go);适配器侧只负责在同一把锁下把
// 信号读齐。
//
// 判定顺序(自上而下,先匹配者胜):
//
// !Started → ("", "") —— 未启动的适配器无从判断:回「无意见」而不是
// 随便认一个状态,调用方据此让可选键整键缺席。
// Connected → ConnectStateConnected —— 有活会话就是真连上,先于其它分支。
// Retrying → ConnectStatePending —— 尚未连上但重试在跑,这才是「连接中」。
// Listening → ConnectStateReady —— 监听已就绪、等对端接入,不是失败。
// 否则 → ("", "") —— 已启动、未连上、也没有可解释的就绪面:**不编**
// 状态,让调用方退回只看 IsConnected()。
//
// 只有 pending 会带 Reason:connected 没有任何坏消息要说,ready 本来就不是坏消息。
func ConnectStateOf(sig ConnectSignals) (state, reason string) {
switch {
case !sig.Started:
return "", ""
case sig.Connected:
return ConnectStateConnected, ""
case sig.Retrying:
return ConnectStatePending, sig.Reason
case sig.Listening:
return ConnectStateReady, ""
default:
return "", ""
}
}
@@ -0,0 +1,75 @@
// 本文件只测连接态三态的**纯判定**(ConnectStateOf):它不读时钟、不触网、不加锁,
// 因此三态可以逐条单测。适配器侧的落点(qq_obv11 的 ConnectOutcome 与脱敏)见
// plugins/channels/qq_obv11/connect_outcome_test.go,网关侧的映射见
// internal/services/gateway/admin_channel_connect_outcome_test.go。
package adapter
import "testing"
// TestConnectStateOfNotStartedHasNoOpinion 断言未启动的适配器**不编状态**:回空串,
// 调用方据此退回只看 IsConnected(),而不是随便认一个。
func TestConnectStateOfNotStartedHasNoOpinion(t *testing.T) {
t.Parallel()
for _, sig := range []ConnectSignals{
{},
{Connected: true},
{Retrying: true, Reason: "boom"},
{Listening: true},
} {
if state, reason := ConnectStateOf(sig); state != "" || reason != "" {
t.Errorf("ConnectStateOf(%+v) = (%q,%q), want 无意见", sig, state, reason)
}
}
}
// TestConnectStateOfConnectedWins 断言真连上优先于其它分支(监听与重试信号同时为真
// 也不该改变结论),且**不**带原因 —— 「已连接」旁边不该挂一条过期的失败原因。
func TestConnectStateOfConnectedWins(t *testing.T) {
t.Parallel()
state, reason := ConnectStateOf(ConnectSignals{
Started: true, Connected: true, Retrying: true, Listening: true, Reason: "stale failure",
})
if state != ConnectStateConnected || reason != "" {
t.Fatalf("ConnectStateOf = (%q,%q), want (%q,\"\")", state, reason, ConnectStateConnected)
}
}
// TestConnectStateOfRetryingIsPending 断言「未连上但重试在跑」是 pending 且带上原因。
// 这正是 2026-10-07 事故里被写成「已连接」(或静态「未连接」)的那一格。
func TestConnectStateOfRetryingIsPending(t *testing.T) {
t.Parallel()
state, reason := ConnectStateOf(ConnectSignals{
Started: true, Retrying: true, Reason: "dial tcp 127.0.0.1:10301: connection refused",
})
if state != ConnectStatePending {
t.Fatalf("state = %q, want %q", state, ConnectStatePending)
}
if reason != "dial tcp 127.0.0.1:10301: connection refused" {
t.Fatalf("reason = %q, want the failure reason verbatim", reason)
}
// 三个取值互不相同:把 pending 写成 connected 是这次要根除的谎。
if ConnectStatePending == ConnectStateConnected || ConnectStatePending == ConnectStateReady {
t.Fatal("pending 的取值必须与 connected / ready 都不同")
}
}
// TestConnectStateOfListeningIsReady 断言「已绑定监听、等对端接入」是 ready 而不是
// pending:它不是失败,因此**没有**原因可说(哪怕结构体里带着一条陈旧的 reason)。
func TestConnectStateOfListeningIsReady(t *testing.T) {
t.Parallel()
state, reason := ConnectStateOf(ConnectSignals{
Started: true, Listening: true, Reason: "should not be surfaced",
})
if state != ConnectStateReady || reason != "" {
t.Fatalf("ConnectStateOf = (%q,%q), want (%q,\"\")", state, reason, ConnectStateReady)
}
}
// TestConnectStateOfUnexplainedStaysSilent 断言「已启动、未连上、也没有可解释的就绪面」
// 时**不编**状态:既不说「正在连接」(不知道有没有在连),也不说「就绪」。
func TestConnectStateOfUnexplainedStaysSilent(t *testing.T) {
t.Parallel()
if state, reason := ConnectStateOf(ConnectSignals{Started: true, Reason: "orphan"}); state != "" || reason != "" {
t.Fatalf("ConnectStateOf = (%q,%q), want 无意见", state, reason)
}
}
+90
View File
@@ -0,0 +1,90 @@
package adapter
import "errors"
// 本文件给**出站投递失败**一个稳定的原因码。此前一次失败只有自由文本(真机日志:
// `weixin_ilink: proactive push outside 24h window`),调用方只能把整串原文塞进日志与
// 记录 —— 于是「她的话没送到微信」在面板上只表现为一句人读的英文,既没法稳定地告诉
// 主人「哪个渠道、因为哪条平台规则」,也没法按原因聚合。
//
// 口径与 internal/runtime/voice 的 `Category(err)` 同族(那处给语音转码失败分类):
// **产生错误的那一层**声明码值,读取侧只调一个访问函数;错误**没有自报**更具体的
// 原因码时一律落到兜底码 CategoryFailed —— 绝不替它编一个更精确的码。
//
// 为什么放在 adapter 契约层:能见到真实错误的层(plugin 适配器)与要记录它的层
// (channelbridge / backthink)之间只共享本包。契约层是唯一不需要任何一方反向依赖
// 具体插件的落点。范式与 SystemNoticeNormalizer 逐字同族:**可选接口 + 调用方探测**。
// DeliveryCategory 是出站投递失败的**稳定原因码**(snake_case,可直接进日志字段、
// 记录字段与审计文案)。机器读它,人读文案 —— 所以文案会变,码不变。
type DeliveryCategory string
// 契约层自带的四个码。平台特有的码(如 weixin-ilink 的 proactive_window_closed)
// 由各插件在自己的包内声明并自报,本包不枚举它们。
const (
// CategoryNotConnected 表示适配器当前没有可用会话(ErrNotConnected)。
CategoryNotConnected DeliveryCategory = "not_connected"
// CategoryUnsupported 表示该能力/操作不被平台支持(ErrUnsupported)。
CategoryUnsupported DeliveryCategory = "unsupported"
// CategoryClosed 表示适配器已关闭(ErrClosed)。
CategoryClosed DeliveryCategory = "closed"
// CategoryFailed 是**兜底**码:错误没有自报更具体的原因码时使用。它如实表示
// 「只知道失败了,不知道平台规则层面的原因」,既不是成功,也不是猜测。
CategoryFailed DeliveryCategory = "failed"
)
// DeliveryFailure 是投递错误**可选**的自我描述(适配器按需实现,调用方用
// errors.As 探测)。实现必须无副作用:不做 I/O、不改状态。
type DeliveryFailure interface {
// DeliveryCategory 返回该错误的稳定原因码。返回空串表示「不自报」,读取侧
// 退到共享哨兵判定与兜底码。
DeliveryCategory() DeliveryCategory
// DeliveryChannel 返回出错渠道的平台名(例如 "weixin-ilink")。空串表示
// **不知道**——绝不填一个猜出来的平台名。
DeliveryChannel() string
}
// DeliveryCategoryOf 返回 err 的稳定原因码:err 自报则用自报值,否则按契约层
// 哨兵判定,最后落到 CategoryFailed。err 为 nil 时返回空串 —— 「没有失败」不是
// 一种失败码。
//
// errors.As 沿 %w 链走,因此中间层的包装(channelbridge 的
// `send bubble #1 of 8: …`、`send proactive via "weixin-ilink": …`)都不会把
// 自报的码藏起来。
func DeliveryCategoryOf(err error) DeliveryCategory {
if err == nil {
return ""
}
if f := deliveryFailureOf(err); f != nil {
if c := f.DeliveryCategory(); c != "" {
return c
}
}
switch {
case errors.Is(err, ErrNotConnected):
return CategoryNotConnected
case errors.Is(err, ErrUnsupported):
return CategoryUnsupported
case errors.Is(err, ErrClosed):
return CategoryClosed
default:
return CategoryFailed
}
}
// DeliveryChannelOf 返回 err 自报的出错渠道;未自报时返回空串(= 未知)。
func DeliveryChannelOf(err error) string {
if f := deliveryFailureOf(err); f != nil {
return f.DeliveryChannel()
}
return ""
}
// deliveryFailureOf 在错误链上找出第一个自描述失败;没有则返回 nil。
func deliveryFailureOf(err error) DeliveryFailure {
var f DeliveryFailure
if errors.As(err, &f) {
return f
}
return nil
}
+74
View File
@@ -0,0 +1,74 @@
package adapter
import (
"errors"
"fmt"
"testing"
)
// selfDescribedFailure 是自描述投递失败的替身(适配器实现 DeliveryFailure 的最小形状)。
type selfDescribedFailure struct {
category DeliveryCategory
channel string
text string
}
func (e *selfDescribedFailure) Error() string { return e.text }
func (e *selfDescribedFailure) DeliveryCategory() DeliveryCategory { return e.category }
func (e *selfDescribedFailure) DeliveryChannel() string { return e.channel }
// TestDeliveryCategoryOf 锁定原因码口径:自报优先、契约层哨兵次之、兜底
// CategoryFailed —— 绝不替一个没说清楚原因的错误编一个更精确的码。
func TestDeliveryCategoryOf(t *testing.T) {
t.Parallel()
window := &selfDescribedFailure{
category: "proactive_window_closed",
channel: "weixin-ilink",
text: "weixin_ilink: proactive push outside 24h window",
}
tests := []struct {
name string
err error
want DeliveryCategory
}{
{name: "没有失败就没有码", err: nil, want: ""},
{name: "适配器自报的码被采信", err: window, want: "proactive_window_closed"},
{name: "多气泡包装不藏自报码", err: fmt.Errorf("channelbridge: send bubble #1 of 2: %w", window), want: "proactive_window_closed"},
{name: "桥接器包装不藏自报码", err: fmt.Errorf("channelbridge: send proactive via %q: %w", "weixin-ilink", window), want: "proactive_window_closed"},
{name: "未连接", err: ErrNotConnected, want: CategoryNotConnected},
{name: "不支持", err: fmt.Errorf("adapter: send: %w", ErrUnsupported), want: CategoryUnsupported},
{name: "已关闭", err: ErrClosed, want: CategoryClosed},
{name: "无自报的裸错误落兜底", err: errors.New("boom"), want: CategoryFailed},
{name: "自报空码落兜底(不编更精确的码)", err: &selfDescribedFailure{text: "boom"}, want: CategoryFailed},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := DeliveryCategoryOf(tc.err); got != tc.want {
t.Fatalf("DeliveryCategoryOf(%v) = %q, want %q", tc.err, got, tc.want)
}
})
}
}
// TestDeliveryChannelOf 锁定渠道自报口径:只有自报才有渠道名,未自报是**空串**
// (= 不知道),绝不替它猜一个平台名。
func TestDeliveryChannelOf(t *testing.T) {
t.Parallel()
window := &selfDescribedFailure{category: "proactive_window_closed", channel: "weixin-ilink", text: "closed"}
if got := DeliveryChannelOf(fmt.Errorf("channelbridge: send bubble #1 of 2: %w", window)); got != "weixin-ilink" {
t.Fatalf("DeliveryChannelOf(wrapped) = %q, want weixin-ilink", got)
}
if got := DeliveryChannelOf(fmt.Errorf("channelbridge: no connected owner IM channel for proactive push")); got != "" {
t.Fatalf("DeliveryChannelOf(unreported) = %q, want empty (unknown)", got)
}
if got := DeliveryChannelOf(nil); got != "" {
t.Fatalf("DeliveryChannelOf(nil) = %q, want empty", got)
}
if got := DeliveryChannelOf(ErrNotConnected); got != "" {
t.Fatalf("DeliveryChannelOf(ErrNotConnected) = %q, want empty", got)
}
}
+8 -4
View File
@@ -246,13 +246,17 @@ type MemoryTidyConfig struct {
// 关闭)。整理**只产建议、绝不自动改记忆**,因此默认开启是安全的;
// 它带来的是每轮一次的记忆向量化(召回用)与一批候选行。
Enabled bool `env:"MEMORY_TIDY_ENABLED" default:"true"`
// IntervalSec 是整理周期(秒),默认 86400 = 每天 1 次(design/23 §13
// 开放点 8 的建议值)。它是下限:实际在安静时段起点跑,因此间隔取
// 「安静时段每天一次」与它之中较长者。
// IntervalSec 是**非安静时段**的整理周期(秒),默认 86400 = 每天 1 次
// (design/23 §13 开放点 8 的建议值)。它是**兜底档**:`$DATA_DIR/config/
// memory-tidy.json` 的 `intervalSec` 可覆盖它(面板优先、`.env` 兜底。
// 2026-10-07 主人口径「`.env` 只做兜底用」),安静时段的另一档是同一文件里的
// `quietIntervalSec`(缺省 = 等于它,见 memory 侧的 intervalAt)。
IntervalSec int `env:"MEMORY_TIDY_INTERVAL_SEC" default:"86400"`
// QuietStartHour / QuietEndHour 是低峰窗口(本地钟点),默认复用
// backthink 的 23:00-07:00 语义(窗口可跨午夜;两端相等且非 0 则关闭窗口,
// 改为纯周期)。整理只在低峰跑,且**从不推送**(design/23 §4)。
// 改为纯周期)。2026-10-07 起窗口同时是**降频档位**(安静时段用
// quietIntervalSec)与**补跑判据**(窗口内没跑过就补一轮),不再表示
// 「只有低峰才跑」。整理**从不推送**(design/23 §4)。
QuietStartHour int `env:"MEMORY_TIDY_QUIET_START_HOUR" default:"23"`
QuietEndHour int `env:"MEMORY_TIDY_QUIET_END_HOUR" default:"7"`
// RecallThreshold 是候选召回的余弦**召回门**,默认 0.80(design/23 §5.1
+224
View File
@@ -0,0 +1,224 @@
// memorytidy.go 是记忆整理(design/23)**运行期调优**的可变源:
// `$DATA_DIR/config/memory-tidy.json`(design/13 §2.1「运行期生成的配置进
// `$DATA_DIR/config/`,新增配置家族从一开始就只写这里,不落老位置」)。
//
// 为什么需要它(2026-10-07 主人「那么多 link 是干什么的?该删就删,怎么这个记忆
// 整理机制这么保守呢」):轮末自动落地是一件**改记忆**的动作,它必须有一个
// 「一键回到只产建议」的开关与一个每轮上限,而 `.env` 已被主人冻结
// (`design/13 §2.1`:调优/门槛/上限/开关类参数走 `$DATA_DIR/config/**` + 代码
// 默认值,不进 `.env`)。于是这两项落在这里,**零新 env 键**。
//
// 2026-10-07 追加 `ingestGate`(同步入库闸,主人「把记忆整理功能接到昔涟和记忆库
// 中间做审查,由整理功能进行入库」):同一个理由 —— 闸是一处**改记忆落库形态**的
// 开关,必须能一键回到「只插不审」,而 `.env` 已冻结 ⇒ 它落在同一个文件里,
// 不新开文件家族、不加 env 键。
//
// 2026-10-07 再追加 `t1Judge`(T1 灰区模型判定,design/23 §15.1 D3-2):理由仍然
// 是同一条 —— 它是一处**会花掉模型额度**的开关,必须能一行配置关掉(关掉即回落
// 纯规则、零模型调用),所以同样落在这里,不新开文件家族、不加 env 键。
//
// 2026-10-07 同日追加 `t1PerWindow` / `t1WindowSec`(T1 的**全局限速**,
// design/23 §15.4 主人裁定「加一层保守的全局速率限流」):T1 默认开,每次「有灰区
// 候选的审查」就发一次调用,现网每天数百到上千条新记忆 ⇒ 那是一处**没有刹车**的
// 额度出口。额度必须能按现网节奏调(且不许为它开 env 键),因此落在这里;
// **零值不表示「无限制」**,只表示「没配、用代码默认值」。
//
// 2026-10-07 再追加 `intervalSec` / `quietIntervalSec` / `quietStartHour` /
// `quietEndHour`(**整理频率**·主人两条口径:「**`.env` 只做兜底用** —— 后端自动
// 创建独立的面板配置文件之后就走面板配置文件,这些键优先从面板配置上取」;
// 「**频率必须调**,可以调到 1 小时或者三小时,**或者动态分配**,比如白天间隔短
// 一点,晚上到了静默时期稍微长一点」):
//
// - 这四项**逐键**走「面板 > `.env` > 代码默认」三档(唯一实现点是
// `memory.ApplyTidyScheduleTuning`):面板键在 ⇒ 用面板值;缺位 ⇒ 用 `.env`
// 的 `MEMORY_TIDY_INTERVAL_SEC` / `MEMORY_TIDY_QUIET_*_HOUR`(quietIntervalSec
// 没有 env 键,缺位 = 等于 `intervalSec`);两档都没有 ⇒ 代码默认
// (86400 / 23:00-07:00,见 memory 侧的 normalizeTidyConfig)。
// - 因此**没有新增任何 `.env` 键**(`.env` 已冻结,design/13 §2.1):上面三个
// env 键是既有的,本切片只是让面板能覆盖它们。
// - **坏值不得让整理停摆或忙轮询**:键写了非正数 ⇒ 回落下一档 + WARN
// (≤0 的周期不是「没配」,而是一个会让整理变成空转的值);整个文件解不开
// ⇒ 四个键连同其它键一起回落 `.env` 并记 WARN(既有口径,见 LoadMemoryTidyTuning)。
//
// 边界与 backthink.go 同源(那是同一族的手续):
//
// 1. 本文件只管**文件形状 + 读取**:它不认识 `TidyConfig`,也不认识「零值回落
// 代码默认值」那条裁决 —— 那是 `memory.normalizeTidyConfig` 的事。
// 2. **没有「面板编辑」的写口**:改内容仍要**人手改这个文件 + 重启**(与
// `backthink.json` 同口径:启动期读一次)。唯一的写路径是**启动播种**
// (memorytidy_seed.go)—— 它只在文件缺失/空白时写一次,之后一个字节都不动;
// 面板上的编辑口是另一片,不在本文件。
// 3. **零新 env 键**,也不进面板的配置事实卡(那张表的键名被 `.env.example` 钉住)。
//
// 读取口径:文件缺失或全空白 = 还没配过(零值 + nil,**不是错误**;启动时这种情形
// 会被播种成一份「当时生效值」,见 memorytidy_seed.go);存在但解不开 = 返回错误,
// 调用方记 WARN 并回落**代码默认值**(即:坏配置绝不能把自动档的开关或上限读成
// 「无限制」)。
package runtimeconfig
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
const (
// memoryTidyFileName 是文件名(家族命名 = 服务/模块名 + .json)。
memoryTidyFileName = "memory-tidy.json"
// memoryTidyConfigSubdir 是落点的第一段目录(`$DATA_DIR/config/`)。与
// backthink 各自声明一份而不是共用一个常量:某一族换位置时,共用常量会让
// 另一处**静默**跟着搬家。
memoryTidyConfigSubdir = "config"
)
// MemoryTidyConfigRelPath 是落点相对 `$DATA_DIR` 的路径(`config/memory-tidy.json`)。
// 导出它是为了让面板/文档能**如实**告诉主人改哪个文件,而不必把部署相关的绝对路径
// 塞进接口形状。
const MemoryTidyConfigRelPath = memoryTidyConfigSubdir + "/" + memoryTidyFileName
// ErrInvalidMemoryTidy 是 memory-tidy.json 结构不合法时的分类错误(errors.Is 可判类)。
var ErrInvalidMemoryTidy = errors.New("runtimeconfig: invalid memory tidy config")
// MemoryTidyTuning 是 `memory-tidy.json` 的形状:记忆模块运行期的旋钮。
// 文件以「整理」命名,但它是**记忆模块运行期调优的同一处落点**(整理自动档 +
// 入库闸 + T1 灰区判定),不再为一个开关新开一个文件家族。
//
// AutoApply / IngestGate / T1Judge 用**指针**是刻意的:它们的代码默认值都是「开」,而
// JSON 的 `bool` 零值也是 false —— 用 bool 会让「这个键没写」与「显式写
// false」不可区分,于是任何只写上限的文件都会**静默把开关关掉**。指针让
// 「没写」= nil = 不覆盖默认。
//
// 字段名是 camelCase 的 JSON 键(文件是给人看、给人改的)。
type MemoryTidyTuning struct {
// AutoApply 控制轮末自动落地。nil = 没配(用代码默认值:开);false = 一键
// 回到「只产建议」;true = 显式开(与默认同义,写它只是为了自解释)。
AutoApply *bool `json:"autoApply"`
// AutoApplyPerRound 是每轮自动落地的条数上限。零/负 = 没配 ⇒ 用代码默认值
// (**没有「无上限」语义**,最终还会被硬顶钳位)。
AutoApplyPerRound int `json:"autoApplyPerRound"`
// IngestGate 控制**同步入库闸**(D3 审查接在写入路径上当场拍板,主人
// 2026-10-07 裁定)。nil = 没配(用代码默认值:开);false = 一键回到
// 「只插不审」(= 改动之前的异步行为);true = 显式开(与默认同义)。
IngestGate *bool `json:"ingestGate"`
// T1Judge 控制 **T1 灰区模型判定**(design/23 §15.1 D3-2:灰区 —— 相似
// 但互不包含 —— 调一次轻量模型,只许回 `merge`/`modify`/`keep` + 理由、
// **禁产新内容**)。nil = 没配(用代码默认值:开);false = 一键回到纯规则
// (**零模型调用**,灰区仍然保守回落新建);true = 显式开(与默认同义)。
//
// 2026-10-07 追加,与 IngestGate 同一个文件、同一个理由:它是一处**会花掉
// 模型额度**的开关,必须能一行配置关掉,而 `.env` 已冻结(零新 env 键)。
// 指针也是同一个理由:代码默认值是「开」,用 bool 会让「这个键没写」与
// 「显式写 false」不可区分。
T1Judge *bool `json:"t1Judge"`
// T1PerWindow 是 T1 的**全局**窗口额度(次数)—— 全进程共享一个窗口,超限
// 即跳过 T1 并回落 `create`(`reason=t1_throttled`,design/23 §15.4)。
// 零/负 = 没配 ⇒ 用代码默认值(memory 侧的 defaultT1PerWindow)。
//
// **刻意没有「无上限」语义**(与 autoApplyPerRound 同一口径):≤0 一律回落
// 默认额 —— 「不限流」不该是敲一个 0 就能得到的状态(design/12 §N C.5 G3
// 登记过那条静默失败形态)。要完全不花模型额度,请关 `t1Judge`。
T1PerWindow int `json:"t1PerWindow"`
// T1WindowSec 是上面那个额度的时间窗(**秒**)。零/负 = 没配 ⇒ 用代码默认值
// (memory 侧的 defaultT1Window)。
//
// 用秒而不是 Go 的时长字面量(`"1h"`)是刻意的:这个文件是给人改的,写
// `3600` 比要求主人记住 Go 的时长语法更不容易写错,也与既有
// `$DATA_DIR/config/backthink.json` 的 `*_SEC` 口径一致。
T1WindowSec int `json:"t1WindowSec"`
// IntervalSec 是**非安静时段**的整理周期(秒)。nil = 没配 ⇒ 用 `.env` 的
// `MEMORY_TIDY_INTERVAL_SEC`;两档都没有 ⇒ 代码默认 86400(每天 1 次)。
//
// 用**指针**的理由与 AutoApply 同源,但多一层:数值键的零值(0)在这里是
// **坏值**而不是「一个合法的 0 秒周期」——`0` 会让整理退化成空转,因此
// 「键没写」(静默回落)与「写了 0」(WARN + 回落)必须可区分。
IntervalSec *int `json:"intervalSec"`
// QuietIntervalSec 是**安静时段**的整理周期(秒)——主人要的「动态分配:
// 白天间隔短一点,晚上到了静默时期稍微长一点」就落在这个键上。
//
// nil = 没配 ⇒ **等于 intervalSec**(缺省 = 不因时段换档,而不是「夜里不跑」);
// 写非正数 ⇒ 同一档回落 + WARN(理由同 IntervalSec)。
//
// 它**没有 env 键**(`.env` 已冻结,design/13 §2.1):安静时段分档是本次新增
// 的能力,`.env` 那一档只有 `MEMORY_TIDY_INTERVAL_SEC`,因此它的「下一档」就是
// 上面那个已解析出来的 intervalSec。
QuietIntervalSec *int `json:"quietIntervalSec"`
// QuietStartHour / QuietEndHour 是安静时段的两端(**本地钟点**),语义与既有
// `inQuietHours` 逐字一致:窗口可跨午夜;两端相等且非 0 = 关闭窗口。
//
// 用**指针**是必须的(不像 autoApplyPerRound 那样用零值当「没配」):**0 是
// 合法钟点**(如 `0-7` 的凌晨窗口、`23-0` 到午夜的窗口),用 int 会让「显式
// 写 0」与「没写」不可区分,于是任何只写一端的文件都会被静默改掉另一端。
//
// nil = 没配 ⇒ 用 `.env` 的 `MEMORY_TIDY_QUIET_START_HOUR` / `..._END_HOUR`;
// 两端都没配 ⇒ 代码默认 23:00-07:00。
QuietStartHour *int `json:"quietStartHour"`
QuietEndHour *int `json:"quietEndHour"`
}
// AutoApplyOff 报告自动档是否被这份配置**显式关掉**。nil(没配)返回 false ——
// 「没配」不等于「关掉」,那正是指针存在的理由。
func (t MemoryTidyTuning) AutoApplyOff() bool {
return t.AutoApply != nil && !*t.AutoApply
}
// IngestGateOff 报告**同步入库闸**是否被这份配置显式关掉(口径同 AutoApplyOff:
// nil 是「没配」⇒ 用代码默认的「开」,不是「关」)。
func (t MemoryTidyTuning) IngestGateOff() bool {
return t.IngestGate != nil && !*t.IngestGate
}
// T1JudgeOff 报告 **T1 灰区模型判定**是否被这份配置显式关掉(口径同 AutoApplyOff:
// nil 是「没配」⇒ 用代码默认的「开」,不是「关」)。
func (t MemoryTidyTuning) T1JudgeOff() bool {
return t.T1Judge != nil && !*t.T1Judge
}
// DefaultMemoryTidyPath 返回记忆整理调优的落点:`$DATA_DIR/config/memory-tidy.json`。
func DefaultMemoryTidyPath(dataDir string) string {
return filepath.Join(dataDir, memoryTidyConfigSubdir, memoryTidyFileName)
}
// LoadMemoryTidyTuning 读取 memory-tidy.json(**只读**)。
//
// 文件缺失或全空白 ⇒ 零值 + nil:调用方用代码默认值(normalizeTidyConfig 会把
// 非正的上限回落默认),因此「没配过」与「配成 0」得到同一个结果。
//
// 存在但解不开 ⇒ 返回错误。调用方应记一条带 source 的 WARN 并回落**下一档**
// (调度四键落 `.env` 值、开关类落代码默认)——注意这是**整份文件**的粒度:一个键
// 类型写错(如 `"intervalSec": "3600"`)会让全部键一起回落,这是既有口径
// (`TestApplyMemoryTidyTuning_ReadsDataDirConfig` 的用例③钉的就是它),而不是
// 逐键容错。**不得覆盖**那个文件(损坏的文件可以人工救回,程序盖掉它就丢了人工
// 写的意图)。
//
// 它不建目录、不读老位置:新家族没有老位置(design/13 §2.1 第 5 条)。
func LoadMemoryTidyTuning(path string) (MemoryTidyTuning, error) {
raw, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return MemoryTidyTuning{}, nil
}
return MemoryTidyTuning{}, fmt.Errorf("runtimeconfig: read %s: %w", path, err)
}
if strings.TrimSpace(string(raw)) == "" {
return MemoryTidyTuning{}, nil
}
return decodeMemoryTidy(raw, path)
}
// decodeMemoryTidy 是 LoadMemoryTidyTuning 与**启动播种**(memorytidy_seed.go)共用的
// 解码点:把「解不开」统一分类成 ErrInvalidMemoryTidy。两处对「坏文件」的判定必须
// 出自同一行代码,否则会出现「读取说坏了、播种说没事」这种自相矛盾(播种侧要靠这个
// 结论决定「绝不覆盖」,判错的代价是一份人工配置被盖掉)。
//
// 空内容由调用方先滤掉(空 = 还没配过,与「缺失」同义,不是解码失败)。
func decodeMemoryTidy(raw []byte, path string) (MemoryTidyTuning, error) {
var t MemoryTidyTuning
if err := json.Unmarshal(raw, &t); err != nil {
return MemoryTidyTuning{}, fmt.Errorf("%w: decode %s: %w", ErrInvalidMemoryTidy, path, err)
}
return t, nil
}
@@ -0,0 +1,217 @@
// memorytidy_seed.go 落地主人 2026-10-07 那句「**`.env` 只做兜底用** —— 后端**自动
// 创建**独立的面板配置文件**要做**」里的「自动创建」:进程启动时若
// `$DATA_DIR/config/memory-tidy.json` **不存在**,就按**当时生效的值**播种一份,
// 使这个家族从此有一份自足的一等来源,而不是每次都靠 `.env` 兜。
//
// 与 memorytidy.go(读取)的边界同源:本文件只管**文件形状与落盘**,不认识
// TidyConfig / ReviewConfig,也不知道任何一个默认值是多少 —— 那属于 memory 侧
// normalize 的职责。播种内容由调用方给出(`memory.TidyTuningSnapshot` 拍出的
// 「当前生效值」快照),与 materialize.go 拿到的是 `.env` 合成组同一手法。
//
// 四条纪律,前三条逐字对齐 materialize.go(那是本仓「`.env` → 面板文件」播种的既有
// 施工口径,本文件是同一套搬到 memory-tidy 家族):
//
// 1. **绝不覆盖已存在的文件**:文件在(无论内容是什么、无论解不解得开)⇒ 一个字节
// 都不动。这条是铁律 —— 主人手改过的文件被程序盖回去,就是「面板改一次、重启
// 一次被打回」那个失败形态。
// 2. **损坏不是要「修好」的状态**:解不开时只记 WARN、按 `.env` 跑、**不写盘**。
// 人工写坏的配置可以人工救回,程序盖掉它就丢了那份人工意图。
// 3. **幂等**:只有「文件不存在(或全空白)」这一次会落盘;连续启动多次,第二次起
// 是空操作(文件已存在 ⇒ 走第 1 条)。
// 4. **不阻断启动**:返回的 error 只在「该写却没写成」时非 nil(写盘失败 / 种子
// 残缺);调用方记一条 WARN 即可 —— 播种失败时运行期仍按 `.env` + 代码默认跑,
// 行为与播种前逐字相同。
//
// 日志**只打路径、原因与键名,不打值**:这十键今天都不是凭据,但「配置日志不打值」
// 是本仓既有口径(materialize.go 的「绝不打 apiKey」),这里从一开始就照做,免得
// 日后加一个敏感键时要回头改日志。
package runtimeconfig
import (
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
const (
// memoryTidySeedReasonMissing / ...Blank / ...Exists 是播种日志的 reason 字段值
// (与 materialize.go 的 reason 口径一致:让人一眼看出是「没有」「空」还是
// 「已在、没动它」)。
memoryTidySeedReasonMissing = "面板配置不存在"
memoryTidySeedReasonBlank = "面板配置为空文件"
memoryTidySeedReasonExists = "面板配置已存在,未改动"
// memoryTidySeedFileMode 是播种文件的权限。0600 与 SaveModels / SavePlugins
// 同口径:配置目录可能放凭据,一律不给组/其他读权限。
memoryTidySeedFileMode = 0o600
// memoryTidySeedDirMode 是新建 `$DATA_DIR/config/` 时的目录权限,与 SaveModels
// 的 mkdir 口径一致。
memoryTidySeedDirMode = 0o755
)
// memoryTidySeedKeys 是播种文件应有的键名全集(顺序即 JSON 顺序)。它用于播种日志
// 的「播了哪些键」,并与结构体的 json tag 相互钉住(见
// TestMemoryTidySeedKeysMatchStruct):加了新键却忘了更新它,用例会红。
var memoryTidySeedKeys = []string{
"autoApply", "autoApplyPerRound", "ingestGate", "t1Judge",
"t1PerWindow", "t1WindowSec", "intervalSec", "quietIntervalSec",
"quietStartHour", "quietEndHour",
}
// MemoryTidySeed 是一次播种的可诊断产物(口径对齐 materialize.go 的 Result:
// Written / Warn 的含义逐字相同,Existed 用来区分「跳过」的起因)。
type MemoryTidySeed struct {
// Written 为 true 表示本次真的落盘(只在文件缺失/全空白时可能为 true)。
Written bool
// Existed 为 true 表示文件已存在(含解不开的),本次一个字节都没动。
// 它与 Written 互斥。
Existed bool
// Warn 是非致命异常:文件存在但解不开(按 `.env` 跑,且不写盘)。
Warn error
}
// SeedMemoryTidy 在 path 处播种一份面板配置 —— **仅当它缺失或全空白**。
//
// 「全空白」与「缺失」同义是既有口径(LoadMemoryTidyTuning 把两者都读成「还没配过」
// ⇒ 零值 ⇒ 走 `.env` 兜底),因此对空白文件播种,那一刻的生效值一个字都不变 ——
// 与 materialize.go 对空文件的处置一致。
//
// `null` **不算空白**:它解得出零值,但它是「盘上有一个文件」这个事实本身,按铁律
// 不动它(主人的口径是「不存在才自动创建」,不是「内容看着空就重建」)。
//
// log 由调用方给出(组合根一律传带 source 的日志器):本函数会直接往它上面写日志,
// 因此**不要传 nil**。
func SeedMemoryTidy(path string, seed MemoryTidyTuning, log *slog.Logger) (MemoryTidySeed, error) {
log = logger.WithSource(log, "config/runtimeconfig")
raw, readErr := os.ReadFile(path)
if readErr == nil {
return seedMemoryTidyExisting(log, path, raw, seed)
}
if !os.IsNotExist(readErr) {
log.Warn("memory-tidy.json 不可读,本次跳过播种(按 .env 兜底运行)", "path", path, "error", readErr)
return MemoryTidySeed{}, fmt.Errorf("runtimeconfig: 读取 %s: %w", path, readErr)
}
return writeMemoryTidySeed(log, path, seed, memoryTidySeedReasonMissing)
}
// seedMemoryTidyExisting 处理「文件已存在」的三种形态,三种处置都**不覆盖**:
//
// - 解不开 ⇒ WARN + 按 `.env` 跑(绝不当场「修好」它,见文件头第 2 条);
// - 全空白 ⇒ 按「还没配过」播种(唯一会在文件已存在时写盘的形态,理由见
// SeedMemoryTidy 的说明);
// - 其余(含 `null`)⇒ 跳过。
func seedMemoryTidyExisting(log *slog.Logger, path string, raw []byte, seed MemoryTidyTuning) (MemoryTidySeed, error) {
if strings.TrimSpace(string(raw)) == "" {
log.Info("memory-tidy.json 是空文件(按「还没配过」处理),准备播种", "path", path)
return writeMemoryTidySeed(log, path, seed, memoryTidySeedReasonBlank)
}
if _, err := decodeMemoryTidy(raw, path); err != nil {
// 解不开 ≠ 可以盖掉:只留 WARN,一个字都不写盘。这条 WARN 是主人可查的
// 唯一线索 —— 「这份配置没生效、也没被程序改过」。
log.Warn("memory-tidy.json 无法解析,本次不播种也不覆盖原文件(按 .env 兜底运行)",
"path", path, "error", err)
return MemoryTidySeed{Existed: true, Warn: err}, nil
}
// 跳过也留痕(Info 而不是 Debug:这是**每次启动一行**的交接回执 —— 主人能从日志
// 确认「后端认得我手写的那份文件、没有动它」,而不是只有提升日志级别才看得见)。
log.Info("memory-tidy.json 已存在,跳过播种(面板配置优先,绝不覆盖原文件)",
"path", path, "reason", memoryTidySeedReasonExists)
return MemoryTidySeed{Existed: true}, nil
}
// writeMemoryTidySeed 真正落盘:先把 `$DATA_DIR/config/` 建出来(不存在时),再按
// 「临时文件 + rename」原子写(同 SaveModels / SaveWebSearch:并发读者绝不会看到
// 半个 JSON),权限 0600。
//
// 落盘前先验种子**完整**(见 memoryTidySeedComplete):一份缺键的快照写下去会变成
// `"intervalSec": null` —— 读回来语义上等于「没配」,但主人打开文件会看到一堆 null,
// 且「播种 = 当时的生效值」这句话就不再成立。残缺的种子是调用方的编程错误,宁可
// WARN + 不落盘,也不留一份自相矛盾的文件。
func writeMemoryTidySeed(log *slog.Logger, path string, seed MemoryTidyTuning, reason string) (MemoryTidySeed, error) {
if err := memoryTidySeedComplete(seed); err != nil {
log.Warn("memory-tidy.json 播种内容不完整,本次不落盘(.env 继续兜底)", "path", path, "error", err)
return MemoryTidySeed{}, err
}
raw, err := json.MarshalIndent(seed, "", " ")
if err != nil {
return MemoryTidySeed{}, fmt.Errorf("runtimeconfig: marshal memory tidy seed: %w", err)
}
if dir := filepath.Dir(path); dir != "" && dir != "." {
if err := os.MkdirAll(dir, memoryTidySeedDirMode); err != nil {
return MemoryTidySeed{}, fmt.Errorf("runtimeconfig: mkdir %s: %w", dir, err)
}
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, raw, memoryTidySeedFileMode); err != nil {
return MemoryTidySeed{}, fmt.Errorf("runtimeconfig: write %s: %w", tmp, err)
}
if err := os.Rename(tmp, path); err != nil {
return MemoryTidySeed{}, fmt.Errorf("runtimeconfig: rename %s: %w", path, err)
}
log.Info("memory-tidy.json 已按当时生效值播种落盘(此后以面板配置为准,.env 只兜底)",
"path", path, "reason", reason, "keys", memoryTidySeedKeys)
return MemoryTidySeed{Written: true}, nil
}
// memoryTidySeedComplete 报告种子是否「十键齐全且值可用」。
//
// 为什么要「齐全」而不是「非 nil 就放行」:MemoryTidyTuning 的指针语义是「nil = 没配」,
// 一份缺键的种子写下去虽然与「不写」在读取端等价,但它会让文件里出现 null —— 这份文件
// 的主人(主人 / 面板)看到的东西必须与「生效值」逐字一致。
//
// 数值键要求为**正**:`intervalSec: 0` 在 memory 侧是**坏值**(非正周期让心跳空转,
// 见 ApplyTidyScheduleTuning),播种出一份自己读回都要 WARN 的配置是自相矛盾的;
// 钟点则要求落在 0..23(越界会让 time.Date 静默滚动到别的一天)。
func memoryTidySeedComplete(seed MemoryTidyTuning) error {
missing := make([]string, 0, len(memoryTidySeedKeys))
if seed.AutoApply == nil {
missing = append(missing, "autoApply")
}
if seed.IngestGate == nil {
missing = append(missing, "ingestGate")
}
if seed.T1Judge == nil {
missing = append(missing, "t1Judge")
}
if seed.IntervalSec == nil {
missing = append(missing, "intervalSec")
}
if seed.QuietIntervalSec == nil {
missing = append(missing, "quietIntervalSec")
}
if seed.QuietStartHour == nil {
missing = append(missing, "quietStartHour")
}
if seed.QuietEndHour == nil {
missing = append(missing, "quietEndHour")
}
if len(missing) > 0 {
return fmt.Errorf("runtimeconfig: memory tidy seed missing keys: %s", strings.Join(missing, ", "))
}
if seed.AutoApplyPerRound <= 0 {
return fmt.Errorf("runtimeconfig: memory tidy seed autoApplyPerRound = %d (want > 0)", seed.AutoApplyPerRound)
}
if seed.T1PerWindow <= 0 {
return fmt.Errorf("runtimeconfig: memory tidy seed t1PerWindow = %d (want > 0)", seed.T1PerWindow)
}
if seed.T1WindowSec <= 0 {
return fmt.Errorf("runtimeconfig: memory tidy seed t1WindowSec = %d (want > 0)", seed.T1WindowSec)
}
if *seed.IntervalSec <= 0 {
return fmt.Errorf("runtimeconfig: memory tidy seed intervalSec = %d (want > 0)", *seed.IntervalSec)
}
if *seed.QuietIntervalSec <= 0 {
return fmt.Errorf("runtimeconfig: memory tidy seed quietIntervalSec = %d (want > 0)", *seed.QuietIntervalSec)
}
if *seed.QuietStartHour < 0 || *seed.QuietStartHour > 23 {
return fmt.Errorf("runtimeconfig: memory tidy seed quietStartHour = %d (want 0..23)", *seed.QuietStartHour)
}
if *seed.QuietEndHour < 0 || *seed.QuietEndHour > 23 {
return fmt.Errorf("runtimeconfig: memory tidy seed quietEndHour = %d (want 0..23)", *seed.QuietEndHour)
}
return nil
}
@@ -0,0 +1,337 @@
package runtimeconfig
import (
"bytes"
"encoding/json"
"errors"
"log/slog"
"os"
"path/filepath"
"reflect"
"sort"
"strings"
"testing"
)
// 本文件锁定**启动播种**(memorytidy_seed.go;主人 2026-10-07:「**`.env` 只做兜底
// 用** …… 后端**自动创建**独立的面板配置文件」)的四条纪律:
//
// ① 文件不存在 ⇒ 按调用方给的「当前生效值」播种(**十键齐全**,不是只播老六键);
// ② 文件已存在 ⇒ **一个字节都不动**(也不留 `.tmp`);
// ③ 文件损坏 ⇒ **不写盘**(WARN + 按 `.env` 跑),绝不「修好它」;
// ④ 幂等:连跑两次只在第一次写盘。
//
// 用例全部落在**临时目录**(`t.TempDir()`):生产那份 `data/config/memory-tidy.json`
// 是主人手工建的,播种逻辑只在文件缺失时触发,用例不许依赖它存在与否。
// completeSeedTuning 造一份「十键齐全」的播种内容。值刻意都**不是**代码默认值
// (autoApply=false / 3600 / 10800),这样「播的是调用方给的快照」与「播了一份硬编码
// 的默认值」在断言里一眼可分。
func completeSeedTuning() MemoryTidyTuning {
return MemoryTidyTuning{
AutoApply: boolSeedPtr(false),
AutoApplyPerRound: 7,
IngestGate: boolSeedPtr(true),
T1Judge: boolSeedPtr(false),
T1PerWindow: 240,
T1WindowSec: 1800,
IntervalSec: intSeedPtr(3600),
QuietIntervalSec: intSeedPtr(10800),
QuietStartHour: intSeedPtr(23),
QuietEndHour: intSeedPtr(7),
}
}
// boolSeedPtr / intSeedPtr 造「快照里显式写了这个键」的指针。
func boolSeedPtr(v bool) *bool { return &v }
func intSeedPtr(v int) *int { return &v }
// seedPath 返回一个**尚不存在**的落点(含尚不存在的 `config/` 目录)。
func seedPath(t *testing.T) string {
t.Helper()
return filepath.Join(t.TempDir(), "config", memoryTidyFileName)
}
// decodeSeed 把盘上的文件解成键集合,供「十键齐全」的断言(比结构体更贴近主人看到
// 的那份文件)。
func decodeSeed(t *testing.T, path string) map[string]json.RawMessage {
t.Helper()
var keys map[string]json.RawMessage
if err := json.Unmarshal([]byte(readRaw(t, path)), &keys); err != nil {
t.Fatalf("解 %s 的键集合: %v", path, err)
}
return keys
}
// assertNoTempLeft 断言播种没留下临时文件(原子写的中间态不该留在盘上)。
func assertNoTempLeft(t *testing.T, path string) {
t.Helper()
if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) {
t.Fatalf("留下了临时文件 %s.tmp", path)
}
}
// TestSeedMemoryTidy_SeedsMissingFile 是本任务的主用例:文件不存在 ⇒ 播种,且**内容
// 就是调用方给的快照**(十键齐全、含四项频率键),目录也一并建出来。
func TestSeedMemoryTidy_SeedsMissingFile(t *testing.T) {
t.Parallel()
path := seedPath(t) // 连 config/ 目录都不存在
seed := completeSeedTuning()
res, err := SeedMemoryTidy(path, seed, discardLog())
if err != nil {
t.Fatalf("SeedMemoryTidy: %v", err)
}
if !res.Written || res.Existed || res.Warn != nil {
t.Fatalf("结果 = %+v, want Written 且非 Existed 且无 Warn", res)
}
assertNoTempLeft(t, path)
// 十键齐全(含新增的四项频率键)——「别只播老六键」的断言就在这里。
keys := decodeSeed(t, path)
for _, key := range memoryTidySeedKeys {
if _, ok := keys[key]; !ok {
t.Fatalf("播种文件缺键 %q:%s", key, readRaw(t, path))
}
}
if len(keys) != len(memoryTidySeedKeys) {
t.Fatalf("播种文件的键数 = %d, want %d:%s", len(keys), len(memoryTidySeedKeys), readRaw(t, path))
}
// 回读走的是**运行期同一条**读取路径(LoadMemoryTidyTuning),因此这里同时钉住
// 「写下去的东西读回来就是那份快照」。
got, err := LoadMemoryTidyTuning(path)
if err != nil {
t.Fatalf("LoadMemoryTidyTuning: %v", err)
}
if !reflect.DeepEqual(got, seed) {
t.Fatalf("回读 = %+v, want %+v(播种内容必须逐键等于调用方给的生效值)", got, seed)
}
}
// TestSeedMemoryTidy_NeverTouchesExistingFile 钉住第一条铁律:文件在 ⇒ 一个字节都不动。
// 覆盖四种盘上形态(面板值 / 空对象 / null / 手写带未知键),四种都必须原样保留 ——
// 尤其 `null`:它解得出零值(语义上等于没配),但它是「盘上有一个文件」这个事实本身。
func TestSeedMemoryTidy_NeverTouchesExistingFile(t *testing.T) {
t.Parallel()
bodies := []string{
`{"autoApply": false}`,
`{}`,
`null`,
"{\n \"intervalSec\": 42,\n \"unknownKey\": \"keep me\"\n}\n",
}
for _, body := range bodies {
path := writeMemoryTidyFile(t, body)
before := readRaw(t, path)
res, err := SeedMemoryTidy(path, completeSeedTuning(), discardLog())
if err != nil {
t.Fatalf("%q: SeedMemoryTidy: %v", body, err)
}
if res.Written || !res.Existed || res.Warn != nil {
t.Fatalf("%q: 结果 = %+v, want 跳过(Existed 且非 Written 且无 Warn)", body, res)
}
if after := readRaw(t, path); after != before {
t.Fatalf("%q: 已存在的文件被改了字节:\n before=%s\n after =%s", body, before, after)
}
assertNoTempLeft(t, path)
}
}
// TestSeedMemoryTidy_BrokenFileIsNotRepaired 钉住第二条纪律:损坏 ⇒ WARN + 按 `.env`
// 跑 + **一个字都不写盘**。损坏的文件可以人工救回,程序盖掉它就丢了那份人工意图。
func TestSeedMemoryTidy_BrokenFileIsNotRepaired(t *testing.T) {
t.Parallel()
bodies := []string{
`{"autoApply": "yes"}`,
`{"intervalSec": "3600"}`,
`not json at all`,
`[1,2,3]`,
}
for _, body := range bodies {
path := writeMemoryTidyFile(t, body)
before := readRaw(t, path)
res, err := SeedMemoryTidy(path, completeSeedTuning(), discardLog())
if err != nil {
// 坏文件不是「该写却没写成」:它压根不该被写,因此不是 error。
t.Fatalf("%q: 坏文件不该让播种返回错误(它只是不写盘):%v", body, err)
}
if res.Written || !res.Existed {
t.Fatalf("%q: 结果 = %+v, want 既不写也不当新文件", body, res)
}
if !errors.Is(res.Warn, ErrInvalidMemoryTidy) {
t.Fatalf("%q: Warn = %v, want ErrInvalidMemoryTidy(分类错误必须与读取侧同一个)", body, res.Warn)
}
if after := readRaw(t, path); after != before {
t.Fatalf("%q: 损坏的文件被覆盖/「修好」了:\n before=%s\n after =%s", body, before, after)
}
assertNoTempLeft(t, path)
}
}
// TestSeedMemoryTidy_IsIdempotent 钉住幂等:连跑两次,只有第一次写盘,第二次连字节
// 都不改(这是「连续启动多次只写一次」的可验证版本)。
func TestSeedMemoryTidy_IsIdempotent(t *testing.T) {
t.Parallel()
path := seedPath(t)
first, err := SeedMemoryTidy(path, completeSeedTuning(), discardLog())
if err != nil || !first.Written {
t.Fatalf("第一次: Written=%v err=%v, want 落盘", first.Written, err)
}
afterFirst := readRaw(t, path)
second, err := SeedMemoryTidy(path, completeSeedTuning(), discardLog())
if err != nil {
t.Fatalf("第二次: %v", err)
}
if second.Written || !second.Existed {
t.Fatalf("第二次 = %+v, want 跳过(文件已在)", second)
}
if afterSecond := readRaw(t, path); afterSecond != afterFirst {
t.Fatalf("第二次改动了文件:\n first=%s\n second=%s", afterFirst, afterSecond)
}
}
// TestSeedMemoryTidy_SeedsBlankFile 钉住「全空白」的处置:它与「缺失」同义是既有口径
// (LoadMemoryTidyTuning 把两者都读成「还没配过」),因此也播种 —— 那一刻的生效值
// 一个字都不变。
func TestSeedMemoryTidy_SeedsBlankFile(t *testing.T) {
t.Parallel()
path := writeMemoryTidyFile(t, " \n\t")
res, err := SeedMemoryTidy(path, completeSeedTuning(), discardLog())
if err != nil {
t.Fatalf("SeedMemoryTidy: %v", err)
}
if !res.Written || res.Existed {
t.Fatalf("结果 = %+v, want 按「还没配过」播种", res)
}
if len(decodeSeed(t, path)) != len(memoryTidySeedKeys) {
t.Fatalf("空白文件被播种后应当十键齐全:%s", readRaw(t, path))
}
}
// TestSeedMemoryTidy_RefusesIncompleteSeed 钉住种子的完整性契约:残缺的快照
// (缺指针 / 非正周期 / 钟点越界)宁可 WARN + 不落盘,也不写出一份自己读回都要
// WARN、或满是 null 的文件。
func TestSeedMemoryTidy_RefusesIncompleteSeed(t *testing.T) {
t.Parallel()
cases := []struct {
name string
seed MemoryTidyTuning
}{
{"缺 autoApply", func() MemoryTidyTuning { s := completeSeedTuning(); s.AutoApply = nil; return s }()},
{"缺 intervalSec", func() MemoryTidyTuning { s := completeSeedTuning(); s.IntervalSec = nil; return s }()},
{"缺 quietStartHour", func() MemoryTidyTuning { s := completeSeedTuning(); s.QuietStartHour = nil; return s }()},
{"intervalSec 为 0", func() MemoryTidyTuning { s := completeSeedTuning(); s.IntervalSec = intSeedPtr(0); return s }()},
{"quietIntervalSec 为负", func() MemoryTidyTuning { s := completeSeedTuning(); s.QuietIntervalSec = intSeedPtr(-1); return s }()},
{"autoApplyPerRound 为 0", func() MemoryTidyTuning { s := completeSeedTuning(); s.AutoApplyPerRound = 0; return s }()},
{"t1PerWindow 为 0", func() MemoryTidyTuning { s := completeSeedTuning(); s.T1PerWindow = 0; return s }()},
{"t1WindowSec 为负", func() MemoryTidyTuning { s := completeSeedTuning(); s.T1WindowSec = -60; return s }()},
{"quietStartHour 越界", func() MemoryTidyTuning { s := completeSeedTuning(); s.QuietStartHour = intSeedPtr(24); return s }()},
{"quietEndHour 越界", func() MemoryTidyTuning { s := completeSeedTuning(); s.QuietEndHour = intSeedPtr(-1); return s }()},
{"完全零值", MemoryTidyTuning{}},
}
for _, tc := range cases {
path := seedPath(t)
if _, err := SeedMemoryTidy(path, tc.seed, discardLog()); err == nil {
t.Fatalf("%s: err = nil, want 拒绝残缺种子", tc.name)
}
if _, statErr := os.Stat(path); !os.IsNotExist(statErr) {
t.Fatalf("%s: 残缺种子不得落盘:%s", tc.name, readRaw(t, path))
}
assertNoTempLeft(t, path)
}
}
// TestSeedMemoryTidy_LogsPathAndReasonWithoutValues 钉住「可观测」:写盘与跳过都有
// 日志(带路径与原因、带 source),且**不打任何配置值**(本仓配置日志的既有口径)。
func TestSeedMemoryTidy_LogsPathAndReasonWithoutValues(t *testing.T) {
t.Parallel()
// ① 播种:Info 日志带路径、原因、source 与键名,但没有任何值。
// 日志级别用默认的 Info(不是 Debug):主人要在**默认日志级别**下就能看到这件事
// 发生了 —— 可观测是这条口径的一部分,不是「把级别调高才看得见」。
var buf bytes.Buffer
log := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
path := seedPath(t)
seed := completeSeedTuning()
seed.IntervalSec = intSeedPtr(4242) // 一眼可辨的值:它**不许**出现在日志里
if _, err := SeedMemoryTidy(path, seed, log); err != nil {
t.Fatalf("SeedMemoryTidy: %v", err)
}
out := buf.String()
if !strings.Contains(out, memoryTidyFileName) {
t.Fatalf("播种日志没带路径:%s", out)
}
if !strings.Contains(out, memoryTidySeedReasonMissing) {
t.Fatalf("播种日志没带原因:%s", out)
}
if !strings.Contains(out, "source=config/runtimeconfig") {
t.Fatalf("播种日志没标来源(AGENTS §10 禁止无来源裸日志):%s", out)
}
if !strings.Contains(out, "已按当时生效值播种落盘") {
t.Fatalf("播种日志没说明做了什么:%s", out)
}
for _, value := range []string{"4242", "10800", "1800"} {
if strings.Contains(out, value) {
t.Fatalf("日志里出现了配置值 %s(配置日志只许打键名):%s", value, out)
}
}
// ② 再次调用(文件已在):跳过也要有日志(**默认 Info 级别**可见,含路径与原因),
// 且同样不打值。
buf.Reset()
if _, err := SeedMemoryTidy(path, seed, log); err != nil {
t.Fatalf("第二次 SeedMemoryTidy: %v", err)
}
skipped := buf.String()
if !strings.Contains(skipped, "跳过播种") || !strings.Contains(skipped, memoryTidyFileName) {
t.Fatalf("跳过播种的日志缺路径或说明:%s", skipped)
}
if !strings.Contains(skipped, "绝不覆盖") {
t.Fatalf("跳过播种的日志没说明理由(绝不覆盖):%s", skipped)
}
if !strings.Contains(skipped, memoryTidySeedReasonExists) {
t.Fatalf("跳过播种的日志没带 reason 字段:%s", skipped)
}
for _, value := range []string{"4242", "10800", "1800"} {
if strings.Contains(skipped, value) {
t.Fatalf("跳过日志里出现了配置值 %s:%s", value, skipped)
}
}
// ③ 损坏:WARN 日志带路径与分类错误(主人据此知道「这份配置没生效、也没被改」)。
buf.Reset()
broken := writeMemoryTidyFile(t, `{"autoApply": "yes"}`)
if _, err := SeedMemoryTidy(broken, seed, log); err != nil {
t.Fatalf("坏文件的 SeedMemoryTidy: %v", err)
}
warned := buf.String()
if !strings.Contains(warned, "level=WARN") || !strings.Contains(warned, "无法解析") {
t.Fatalf("损坏文件必须留一条 WARN:%s", warned)
}
if !strings.Contains(warned, memoryTidyFileName) {
t.Fatalf("WARN 没带路径:%s", warned)
}
}
// TestMemoryTidySeedKeysMatchStruct 是**防漂移**断言:播种日志里列的键名与
// MemoryTidyTuning 的 json tag 必须一一对应。加了新键却忘了更新 memoryTidySeedKeys,
// 用例就会红 —— 否则「播了哪些键」的日志会安静地漏报。
func TestMemoryTidySeedKeysMatchStruct(t *testing.T) {
t.Parallel()
typ := reflect.TypeOf(MemoryTidyTuning{})
var tags []string
for i := 0; i < typ.NumField(); i++ {
tag, ok := typ.Field(i).Tag.Lookup("json")
if !ok || tag == "" {
t.Fatalf("字段 %s 没有 json tag", typ.Field(i).Name)
}
tags = append(tags, strings.Split(tag, ",")[0])
}
want := append([]string(nil), memoryTidySeedKeys...)
sort.Strings(tags)
sort.Strings(want)
if !reflect.DeepEqual(tags, want) {
t.Fatalf("结构体的键 %v 与 memoryTidySeedKeys %v 不一致(加了新键就要同步那张表)", tags, want)
}
}
@@ -0,0 +1,280 @@
package runtimeconfig
import (
"errors"
"os"
"path/filepath"
"testing"
)
// 本文件锁定 `$DATA_DIR/config/memory-tidy.json`(memorytidy.go)的读取口径:
//
// ① 文件缺失 / 空白 ⇒ 零值 + nil(「还没配过」不是错误);
// ② `autoApply` **没写** ⇒ 不覆盖(AutoApplyOff=false)—— 这是指针存在的理由:
// 「没配」与「显式配成 false」必须可区分,否则只写上限的文件会静默关掉自动档;
// ③ `autoApply: false` ⇒ AutoApplyOff=true(一键回到只产建议);
// ④ 文件存在但解不开 ⇒ 分类错误(调用方据此记 WARN 并回落代码默认值);
// ⑤ 落点常量与 backthink 那一族同一目录、不同文件名(不共用常量,避免静默搬家)。
func TestLoadMemoryTidyTuning_MissingIsNotAnError(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "config", "memory-tidy.json")
got, err := LoadMemoryTidyTuning(path)
if err != nil {
t.Fatalf("缺失文件的 err = %v, want nil", err)
}
if got.AutoApplyOff() || got.IngestGateOff() || got.AutoApplyPerRound != 0 {
t.Fatalf("缺失文件应当给零值:%+v", got)
}
}
func TestLoadMemoryTidyTuning_BlankIsNotAnError(t *testing.T) {
t.Parallel()
path := writeMemoryTidyFile(t, " \n\t")
got, err := LoadMemoryTidyTuning(path)
if err != nil {
t.Fatalf("空白文件的 err = %v, want nil", err)
}
if got.AutoApplyOff() {
t.Fatal("空白文件不该被读成「关掉自动档」")
}
}
func TestLoadMemoryTidyTuning_AbsentAutoApplyKeepsDefault(t *testing.T) {
t.Parallel()
path := writeMemoryTidyFile(t, `{"autoApplyPerRound": 7}`)
got, err := LoadMemoryTidyTuning(path)
if err != nil {
t.Fatalf("LoadMemoryTidyTuning: %v", err)
}
if got.AutoApply != nil {
t.Fatalf("没写 autoApply ⇒ 指针应为 nil,实际 %v", *got.AutoApply)
}
if got.AutoApplyOff() {
t.Fatal("只写了上限的文件不得把自动档关掉(代码默认是开)")
}
if got.AutoApplyPerRound != 7 {
t.Fatalf("autoApplyPerRound = %d, want 7", got.AutoApplyPerRound)
}
}
func TestLoadMemoryTidyTuning_ExplicitFalseTurnsAutoApplyOff(t *testing.T) {
t.Parallel()
path := writeMemoryTidyFile(t, `{"autoApply": false}`)
got, err := LoadMemoryTidyTuning(path)
if err != nil {
t.Fatalf("LoadMemoryTidyTuning: %v", err)
}
if !got.AutoApplyOff() {
t.Fatal("显式 false 必须被读成「关掉自动档」")
}
on := writeMemoryTidyFile(t, `{"autoApply": true, "autoApplyPerRound": 3}`)
got, err = LoadMemoryTidyTuning(on)
if err != nil {
t.Fatalf("LoadMemoryTidyTuning(on): %v", err)
}
if got.AutoApplyOff() || got.AutoApplyPerRound != 3 {
t.Fatalf("显式 true 的回读 = %+v", got)
}
}
func TestLoadMemoryTidyTuning_BrokenIsClassifiedError(t *testing.T) {
t.Parallel()
path := writeMemoryTidyFile(t, `{"autoApply": "yes"}`)
if _, err := LoadMemoryTidyTuning(path); !errors.Is(err, ErrInvalidMemoryTidy) {
t.Fatalf("坏配置的 err = %v, want ErrInvalidMemoryTidy", err)
}
}
// TestLoadMemoryTidyTuning_IngestGate 钉住**同步入库闸**的开关口径
// (2026-10-07 主人裁定):它与自动档同一个文件、同一个「指针 = 没配」语义,
// 且**零新 env 键**(`.env` 已冻结,design/13 §2.1)。
func TestLoadMemoryTidyTuning_IngestGate(t *testing.T) {
t.Parallel()
// ① 没写 ingestGate ⇒ 不覆盖(代码默认是「开」)。
absent := writeMemoryTidyFile(t, `{"autoApplyPerRound": 3}`)
got, err := LoadMemoryTidyTuning(absent)
if err != nil {
t.Fatalf("LoadMemoryTidyTuning: %v", err)
}
if got.IngestGate != nil || got.IngestGateOff() {
t.Fatalf("没写 ingestGate 不得把闸关掉:%+v", got)
}
// ② 显式 false ⇒ 一键回到「只插不审」。
off := writeMemoryTidyFile(t, `{"ingestGate": false}`)
got, err = LoadMemoryTidyTuning(off)
if err != nil {
t.Fatalf("LoadMemoryTidyTuning(off): %v", err)
}
if !got.IngestGateOff() {
t.Fatal("显式 false 必须被读成「关掉入库闸」")
}
// 闸的开关与自动档的开关互不影响:一个键不能顺手改另一件事。
if got.AutoApplyOff() {
t.Fatal("写 ingestGate 不得顺手把自动档也关掉")
}
// ③ 显式 true ⇒ 与默认同义(写它只是为了自解释)。
on := writeMemoryTidyFile(t, `{"ingestGate": true, "autoApply": false}`)
got, err = LoadMemoryTidyTuning(on)
if err != nil {
t.Fatalf("LoadMemoryTidyTuning(on): %v", err)
}
if got.IngestGateOff() || !got.AutoApplyOff() {
t.Fatalf("两个开关必须各自独立:%+v", got)
}
// ④ 类型错 ⇒ 分类错误(调用方据此记 WARN 并回落代码默认值,绝不静默)。
broken := writeMemoryTidyFile(t, `{"ingestGate": "nope"}`)
if _, err := LoadMemoryTidyTuning(broken); !errors.Is(err, ErrInvalidMemoryTidy) {
t.Fatalf("坏 ingestGate 的 err = %v, want ErrInvalidMemoryTidy", err)
}
}
// TestLoadMemoryTidyTuning_T1RateLimit 钉住 **T1 全局限速**的两个数值键
// (design/23 §15.4,主人 2026-10-07 裁定):它们与 `t1Judge` 同一个文件、
// **零新 env 键**,且**零值只能是「没配」**——绝不能是「无上限」。
func TestLoadMemoryTidyTuning_T1RateLimit(t *testing.T) {
t.Parallel()
// ① 没写 ⇒ 保持零值(调用方据此回落代码默认额,不是「不限流」)。
absent := writeMemoryTidyFile(t, `{"t1Judge": true}`)
got, err := LoadMemoryTidyTuning(absent)
if err != nil {
t.Fatalf("LoadMemoryTidyTuning: %v", err)
}
if got.T1PerWindow != 0 || got.T1WindowSec != 0 {
t.Fatalf("没写限速键应当给零值:%+v", got)
}
// ② 写了 ⇒ 如实回读,且不连带改动别的键(一个键不能顺手关掉另一件事)。
set := writeMemoryTidyFile(t, `{"t1PerWindow": 240, "t1WindowSec": 1800}`)
got, err = LoadMemoryTidyTuning(set)
if err != nil {
t.Fatalf("LoadMemoryTidyTuning(set): %v", err)
}
if got.T1PerWindow != 240 || got.T1WindowSec != 1800 {
t.Fatalf("限速键回读 = %d/%d, want 240/1800", got.T1PerWindow, got.T1WindowSec)
}
if got.T1JudgeOff() || got.IngestGateOff() || got.AutoApplyOff() {
t.Fatalf("限速键顺手改了别的开关:%+v", got)
}
// ③ 显式 0 ⇒ 仍是零值(「没法用 0 关掉限流」是刻意的:那会是一处静默的失效面)。
zero := writeMemoryTidyFile(t, `{"t1PerWindow": 0, "t1WindowSec": 0}`)
got, err = LoadMemoryTidyTuning(zero)
if err != nil {
t.Fatalf("LoadMemoryTidyTuning(zero): %v", err)
}
if got.T1PerWindow != 0 || got.T1WindowSec != 0 {
t.Fatalf("显式 0 的回读 = %+v", got)
}
// ④ 类型错 ⇒ 分类错误(调用方据此记 WARN 并回落代码默认额,绝不静默不限流)。
for _, body := range []string{`{"t1PerWindow": "many"}`, `{"t1WindowSec": "1h"}`} {
broken := writeMemoryTidyFile(t, body)
if _, err := LoadMemoryTidyTuning(broken); !errors.Is(err, ErrInvalidMemoryTidy) {
t.Fatalf("坏配置 %s 的 err = %v, want ErrInvalidMemoryTidy", body, err)
}
}
}
func TestDefaultMemoryTidyPathIsDataDirConfig(t *testing.T) {
t.Parallel()
got := DefaultMemoryTidyPath(filepath.Join("root", "data"))
want := filepath.Join("root", "data", "config", "memory-tidy.json")
if got != want {
t.Fatalf("落点 = %q, want %q", got, want)
}
if MemoryTidyConfigRelPath != "config/memory-tidy.json" {
t.Fatalf("相对路径 = %q, want config/memory-tidy.json", MemoryTidyConfigRelPath)
}
// 与 backthink 那一族同目录、不同文件:共用常量会在某一族搬家时静默带走另一族。
if filepath.Dir(MemoryTidyConfigRelPath) != filepath.Dir(BackthinkConfigRelPath) {
t.Fatalf("两族应当同目录:%q vs %q", MemoryTidyConfigRelPath, BackthinkConfigRelPath)
}
}
// writeMemoryTidyFile 在临时目录里写一份 memory-tidy.json 并返回路径。
func writeMemoryTidyFile(t *testing.T, body string) string {
t.Helper()
dir := filepath.Join(t.TempDir(), "config")
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatalf("mkdir: %v", err)
}
path := filepath.Join(dir, memoryTidyFileName)
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
return path
}
// TestLoadMemoryTidyTuning_ScheduleKeys 钉住 **整理频率**四项
// (intervalSec / quietIntervalSec / quietStartHour / quietEndHour)的读取口径。
//
// 为什么钟点必须能区分「显式 0」与「没写」:0 是**合法钟点**(0-7 的凌晨窗口、
// 23-0 到午夜的窗口),用 int 承载会让只写一端的文件静默改掉另一端。因此这里
// 断言的是「指针是否非 nil」,而不是「值是否非 0」。
//
// 值的**可用性**(非正数 ⇒ 回落)不在这里判:那是 memory 侧
// ApplyTidyScheduleTuning 的职责,本包只管文件形状与读取。
func TestLoadMemoryTidyTuning_ScheduleKeys(t *testing.T) {
t.Parallel()
// ① 文件不存在 ⇒ 四个键都是 nil(= 不覆盖,由 `.env` / 代码默认兜底)。
absent, err := LoadMemoryTidyTuning(filepath.Join(t.TempDir(), memoryTidyFileName))
if err != nil {
t.Fatalf("LoadMemoryTidyTuning(absent): %v", err)
}
if absent.IntervalSec != nil || absent.QuietIntervalSec != nil ||
absent.QuietStartHour != nil || absent.QuietEndHour != nil {
t.Fatalf("没配过时四个调度键都该是 nil,得到 %+v", absent)
}
// ② 四个键都写了 ⇒ 逐键读出来(与 env 无关,本层不比对)。
got, err := LoadMemoryTidyTuning(writeMemoryTidyFile(t,
`{"intervalSec": 3600, "quietIntervalSec": 10800, "quietStartHour": 1, "quietEndHour": 5}`))
if err != nil {
t.Fatalf("LoadMemoryTidyTuning(set): %v", err)
}
if got.IntervalSec == nil || *got.IntervalSec != 3600 {
t.Fatalf("intervalSec = %v, want 3600", got.IntervalSec)
}
if got.QuietIntervalSec == nil || *got.QuietIntervalSec != 10800 {
t.Fatalf("quietIntervalSec = %v, want 10800", got.QuietIntervalSec)
}
if got.QuietStartHour == nil || *got.QuietStartHour != 1 ||
got.QuietEndHour == nil || *got.QuietEndHour != 5 {
t.Fatalf("安静时段 = %v-%v, want 01:00-05:00", got.QuietStartHour, got.QuietEndHour)
}
// ③ 钟点显式写 0 ⇒ **是值**(不是「没配」):0-7 的凌晨窗口是合法配置。
zero, err := LoadMemoryTidyTuning(writeMemoryTidyFile(t, `{"quietStartHour": 0, "quietEndHour": 7}`))
if err != nil {
t.Fatalf("LoadMemoryTidyTuning(zero): %v", err)
}
if zero.QuietStartHour == nil || *zero.QuietStartHour != 0 {
t.Fatalf("显式写 0 的钟点必须原样保留,得到 %v", zero.QuietStartHour)
}
if zero.QuietEndHour == nil || *zero.QuietEndHour != 7 {
t.Fatalf("quietEndHour = %v, want 7", zero.QuietEndHour)
}
// ④ 秒数写 0 ⇒ 仍是「有值」(0 由 memory 侧判为坏值并回落 + WARN,本层不拦)。
explicitZero, err := LoadMemoryTidyTuning(writeMemoryTidyFile(t, `{"intervalSec": 0}`))
if err != nil {
t.Fatalf("LoadMemoryTidyTuning(explicit zero): %v", err)
}
if explicitZero.IntervalSec == nil || *explicitZero.IntervalSec != 0 {
t.Fatalf("显式写 0 的 intervalSec 应当被读出来(好坏由上层判),得到 %v", explicitZero.IntervalSec)
}
// ⑤ 类型写错(字符串)⇒ 整份文件解不开,分类错误 + 不启动失败。
// 这是**既有粒度**:一个键类型写错会让全部键一起回落 `.env`。
if _, err := LoadMemoryTidyTuning(writeMemoryTidyFile(t, `{"intervalSec": "3600"}`)); !errors.Is(err, ErrInvalidMemoryTidy) {
t.Fatalf("intervalSec 写成字符串应当返回 ErrInvalidMemoryTidy,得到 %v", err)
}
}
+2 -2
View File
@@ -49,8 +49,8 @@ const (
)
// webSearchProviderRe 是 provider 名的字符集。本仓内置名全是小写字母 + 下划线
// (searxng / zhipu / aliyun_iqs / bocha / volcengine / baidu_qianfan),限定字符集是为了
// 让名字能直接当端点段、文件名与日志字段用。
// (searxng / zhipu / aliyun_iqs / bocha / volcengine / baidu_qianfan / tencent_wsa /
// metaso),限定字符集是为了让名字能直接当端点段、文件名与日志字段用。
//
// 它**不**判定「这个名字是否已登记」—— 那是 `plugins/tools/io/search` 的事实,本包不去
// 反向依赖插件包(组合根负责核对并记 WARN)。
+10 -3
View File
@@ -10,7 +10,12 @@ import (
// 写法照既有 VoiceConfig 惯例:一组 provider 键 + 未配置则如实降级,绝不硬编码。
type WebSearchConfig struct {
// Provider 选谁当后端(design/27 §3):`searxng`(默认)/ `zhipu` / `aliyun_iqs` /
// `bocha` / `volcengine` / `baidu_qianfan`。
// `bocha` / `volcengine` / `baidu_qianfan` / `tencent_wsa` / `metaso`。
//
// ⚠️ 后两家(`tencent_wsa` / `metaso`)**没有对应的 env 字段**(`.env` 已冻结:不加键)
// —— 它们只在 `$DATA_DIR/config/plugins/web-search.json` 里配(组合根按插件侧的接入位表
// 逐家投影,见 cmd/cyrene/websearch_providers.go)。本结构里的逐家字段仍然**只**服务
// 已有的 6 家播种/覆盖。
//
// **语义硬约束**(design/27 §3,由组合根的 io 工具集在启动期执行):
// - 指向**未登记**(如本期未做的 tavily)或**未配置**(缺 key/端点)的名字
@@ -22,8 +27,10 @@ type WebSearchConfig struct {
// 链上的家**只在「不可用」时**才被跳过(无 key / 网络失败 / 额度错误…);
// 结果里会如实标注**实际应答**的那一家,绝不拿兜底冒充主力。
Fallback []string `env:"WEB_SEARCH_FALLBACK"`
// MaxResults 是单次检索返回的条数上限(design/27 §3 默认 5)。
MaxResults int `env:"WEB_SEARCH_MAX_RESULTS" default:"5"`
// MaxResults 是单次检索返回的条数上限(主人 2026-10-07 裁定:默认由 5 提到 10;
// design/27 §3 记的仍是 5,待同步设计文档)。它是**真上限**:模型给的 count 只能要得
// 更少,顶不掉它(`plugins/tools/io/web_search.go` 的 resultLimit)。
MaxResults int `env:"WEB_SEARCH_MAX_RESULTS" default:"10"`
// Endpoint 是 **searxng provider** 的端点(SearXNG 兼容的 `format=json` GET 接口)。
//
+47 -4
View File
@@ -3,13 +3,15 @@ package config
import (
"os"
"path/filepath"
"strconv"
"strings"
"testing"
)
// 本文件钉住切片 WS1-B 的搜索配置键(design/27 §3):
// WEB_SEARCH_PROVIDER / _FALLBACK / _MAX_RESULTS 与各家独立一组键
// (WEB_SEARCH_<NAME>_API_KEY / _BASE_URL),以及 ResolveProvider 的关闭语义。
// (WEB_SEARCH_<NAME>_API_KEY / _BASE_URL),以及 ResolveProvider 的关闭语义;
// 末尾还钉住 `.env.example` 的取值与代码默认值同口径(模板写错就是一句假话)。
// wsTestLoad 写一份只含必填键加 extra 行的 .env,加载出配置。
func wsTestLoad(t *testing.T, extra ...string) (*Config, error) {
@@ -23,7 +25,7 @@ func wsTestLoad(t *testing.T, extra ...string) (*Config, error) {
}
// TestWebSearchProvider_Defaults 钉住三个新键的默认值:provider=searxng、
// maxResults=5、fallback 为空(= 只管手选那一家)。
// maxResults=10、fallback 为空(= 只管手选那一家)。
func TestWebSearchProvider_Defaults(t *testing.T) {
cfg, err := wsTestLoad(t)
if err != nil {
@@ -32,8 +34,8 @@ func TestWebSearchProvider_Defaults(t *testing.T) {
if cfg.WebSearch.Provider != "searxng" {
t.Fatalf("WEB_SEARCH_PROVIDER 默认值 = %q, want searxng", cfg.WebSearch.Provider)
}
if cfg.WebSearch.MaxResults != 5 {
t.Fatalf("WEB_SEARCH_MAX_RESULTS 默认值 = %d, want 5", cfg.WebSearch.MaxResults)
if cfg.WebSearch.MaxResults != 10 {
t.Fatalf("WEB_SEARCH_MAX_RESULTS 默认值 = %d, want 10", cfg.WebSearch.MaxResults)
}
if len(cfg.WebSearch.Fallback) != 0 {
t.Fatalf("WEB_SEARCH_FALLBACK 未配置时应为空, got %v", cfg.WebSearch.Fallback)
@@ -126,3 +128,44 @@ func TestWebSearchConfig_ResolveProvider(t *testing.T) {
})
}
}
// TestWebSearchMaxResults_EnvExampleMatchesCodeDefault 是「配置事实 ↔ 模板」守卫:
// `.env.example` 是给人照抄的模板,它给出的取值必须等于代码默认值 —— 模板写 5 而代码默认
// 已改成 10(2026-10-07 的口径调整漏改了模板),照抄模板的人拿到的就不是默认行为。
//
// ⚠️ 只读模板;`.env` 已冻结(基础键不加/不改/不删),本用例不碰它。
func TestWebSearchMaxResults_EnvExampleMatchesCodeDefault(t *testing.T) {
cfg, err := wsTestLoad(t)
if err != nil {
t.Fatalf("Load: %v", err)
}
raw, err := os.ReadFile(filepath.Join("..", "..", ".env.example"))
if err != nil {
t.Fatalf("read .env.example: %v", err)
}
const key = "WEB_SEARCH_MAX_RESULTS"
value, err := strconv.Atoi(wsEnvExampleValue(t, string(raw), key))
if err != nil {
t.Fatalf(".env.example 的 %s 取值不是整数: %v", key, err)
}
if value != cfg.WebSearch.MaxResults {
t.Fatalf(".env.example 的 %s = %d,代码默认 = %d(模板与代码必须同口径)",
key, value, cfg.WebSearch.MaxResults)
}
}
// wsEnvExampleValue 取 `.env.example` 里某个键的**生效**取值(跳过注释行)。
func wsEnvExampleValue(t *testing.T, raw, key string) string {
t.Helper()
for _, line := range strings.Split(raw, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "#") {
continue
}
if rest, ok := strings.CutPrefix(line, key+"="); ok {
return strings.TrimSpace(rest)
}
}
t.Fatalf(".env.example 里没有生效的 %s=…(模板必须给出可照抄的取值)", key)
return ""
}
+112 -12
View File
@@ -11,6 +11,7 @@ import (
"net/http"
"strings"
"sync"
"sync/atomic"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
@@ -35,6 +36,29 @@ const (
httpRequestTimeout = 180 * time.Second
)
// DefaultRequestTimeout 是**单次** /embeddings 调用的墙钟上限,独立于 HTTP 客户端
// 那一层的 180s 总超时(httpRequestTimeout)。
//
// 它**不是**单独取值:批次上限(DefaultBatchLimit / DefaultBatchChars)与它由
// budget.go 里**同一个不等式**推出 —— 单次请求的耗时上界不得超过超时的一半。
// 改这一行就等于改了批次上限,反之亦然;两侧永远是配套的。
//
// 为什么每次调用必须有自己的上限:上层「入库前审查」的单条总预算是 20s
// (memory.defaultReviewTimeout),embedding 只是它内部的第一步,后面还有 T1 灰区
// 判定(8s)。一次卡死的 HTTP 不能把整条审查的 20s 都赌掉。真慢就**快速失败**,
// 调用方按**既有语义**降级(审查降级到词项余弦并如实留痕 degraded),
// **绝不静默丢记忆** —— 本常量只砍等待时间,不改任何判定规则。
//
// 5s 保持不变的依据:本次故障(2026-10-13)的成因不是这个数太小,而是批次太大 ——
// 64 条的实测耗时 7.5~8.5s 已经越过它,客户端放弃、服务端还在算,把整台本机
// embedding 服务顶住,连带单条请求(写向量索引)也一起超时。所以修法是**把批次
// 收进预算内**,而不是把预算放大到迁就批次(后者会让真挂死的请求也拖住整轮)。
//
// 冷启动另有 warmupTimeout(见 budget.go),但它**只放宽本进程的第一个请求**、
// 且一次性花掉:失败不会让它续期,因此「服务一直挂死」这件事**不会**变成
// 「每次调用都慢」—— 第二个请求起就回到本常量。
const DefaultRequestTimeout = 5 * time.Second
// openAICompatibleRequest is the JSON body sent to an OpenAI-compatible
// /embeddings endpoint.
type openAICompatibleRequest struct {
@@ -71,6 +95,11 @@ type APIEmbedder struct {
cfg config.EmbeddingConfig
client *http.Client
log *slog.Logger
// warmupLeft 是**一次性**冷启动放宽的开关:为真时下一个请求用 warmupTimeout,
// 取走即置假(CompareAndSwap ⇒ 并发下也只有一个请求能拿到)。
// 它**不是**「未成功就一直放宽」—— 失败也照样花掉这一次,因此服务挂死时
// 只会慢一次,之后每个请求都按 DefaultRequestTimeout 快速失败(见 budget.go)。
warmupLeft atomic.Bool
mu sync.RWMutex
dim int
@@ -109,12 +138,16 @@ func WithLogger(l *slog.Logger) APIOption {
}
// NewAPI builds an APIEmbedder from cfg using the default HTTP client.
// 单次超时不可由选项更换:它是 budget.go 那条推演的输入,批次上限与它同源,
// 因此**只允许改常量**(改了之后批次上限自动跟着改,测试的编译期/常量断言立刻复核)。
func NewAPI(cfg config.EmbeddingConfig, opts ...APIOption) *APIEmbedder {
e := &APIEmbedder{
cfg: cfg,
client: defaultHTTPClient(),
log: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
// 冷启动放宽是一个**一次性**许可:见 APIEmbedder.warmupLeft。
e.warmupLeft.Store(true)
for _, opt := range opts {
opt(e)
}
@@ -132,6 +165,11 @@ func (e *APIEmbedder) Dim() int {
// Embed sends texts to the configured /embeddings endpoint and returns the
// returned vectors aligned by index. It returns ErrNoAPIURL for a blank URL
// and a wrapped error for any HTTP or decode failure.
//
// texts 长度不受调用方控制:本方法按 budget.go 推出来的上限(条数 / 字符总量)
// **自己切片**,分多次请求顺序发出,因此「一次请求的耗时 < 单次超时」这条不变式
// 对任何调用方都成立 —— 调用方给多大的切片都不会把服务端顶住。中途任一段失败即
// 整体失败(返回已发请求的错误),绝不半途改用混合表示。
func (e *APIEmbedder) Embed(ctx context.Context, texts []string) ([]Vector, error) {
if len(texts) == 0 {
return []Vector{}, nil
@@ -143,25 +181,59 @@ func (e *APIEmbedder) Embed(ctx context.Context, texts []string) ([]Vector, erro
return nil, ErrNoAPIURL
}
payload, err := json.Marshal(openAICompatibleRequest{
Model: e.cfg.Model,
Input: texts,
})
if err != nil {
return nil, fmt.Errorf("embedding: marshal request: %w", err)
// 切片用的上限由**稳态**超时推出(budget.go 的 DefaultRequestTimeout):
// 冷启动那一次只放宽**截止时刻**,批形状不变 —— 否则首个请求会按更宽的预算
// 切出一个巨大的批,正好把冷启动的模型顶得更久。
items, chars := batchLimitsFor(DefaultRequestTimeout)
out := make([]Vector, 0, len(texts))
for _, chunk := range chunkTexts(texts, items, chars) {
vectors, err := e.embedChunk(ctx, chunk)
if err != nil {
return nil, err
}
out = append(out, vectors...)
}
return out, nil
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, e.cfg.APIURL, bytes.NewReader(payload))
if err != nil {
return nil, fmt.Errorf("embedding: build request: %w", err)
// callTimeout 返回本次请求该用的单次超时,并**当场花掉**一次性冷启动放宽许可
// (成功、失败都算花掉)。因此:
// - 本进程的第一个请求最多等 warmupTimeout(冷启动),
// - 从第二个请求起一律 DefaultRequestTimeout —— 服务挂死时不会每次都慢。
//
// 返回值 relaxed 表示这一次是不是拿到了放宽,供失败时留痕。
func (e *APIEmbedder) callTimeout() (timeout time.Duration, relaxed bool) {
if e.warmupLeft.CompareAndSwap(true, false) {
return warmupTimeout, true
}
req.Header.Set("Content-Type", "application/json")
if e.cfg.APIKey != "" {
req.Header.Set("Authorization", "Bearer "+e.cfg.APIKey)
return DefaultRequestTimeout, false
}
// embedChunk 发**一次** /embeddings 请求。chunk 必须已经落在 batchLimitsFor 推出来
// 的边界内(Embed 负责切),因此它的耗时上界不超过单次超时的一半。
func (e *APIEmbedder) embedChunk(ctx context.Context, texts []string) ([]Vector, error) {
// 先取超时(取走即花掉那次放宽),再建 ctx:放宽许可与这次请求一一对应。
timeout, relaxed := e.callTimeout()
callCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
req, err := e.newEmbeddingRequest(callCtx, texts)
if err != nil {
return nil, err
}
resp, err := e.client.Do(req)
if err != nil {
// 冷启动放宽被花掉且没成功时留一条 WARN:它是「本进程第一次请求」这个
// 判断唯一能被事后复核的地方(此后一切失败都按常态超时快速失败)。
// 只记两个预算与错误(错误文本里已带端点,不另记 URL,避免重复落一条
// 可能含 userinfo 的地址)。
if relaxed {
e.log.Warn("embedding: one-shot cold-start allowance spent without a successful response",
"warmup_timeout", warmupTimeout.String(),
"steady_timeout", DefaultRequestTimeout.String(),
"error", err)
}
return nil, fmt.Errorf("embedding: request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
@@ -205,6 +277,34 @@ func (e *APIEmbedder) Embed(ctx context.Context, texts []string) ([]Vector, erro
return vectors, nil
}
// newEmbeddingRequest 组装一次 /embeddings 调用:序列化请求体、**规范化端点主机**
// (URL 解析之后、拨号之前;只改主机,path/query 一字不动)、构造 POST 请求并打上
// 鉴权头。它只负责组装,不负责发送。
func (e *APIEmbedder) newEmbeddingRequest(ctx context.Context, texts []string) (*http.Request, error) {
payload, err := json.Marshal(openAICompatibleRequest{
Model: e.cfg.Model,
Input: texts,
})
if err != nil {
return nil, fmt.Errorf("embedding: marshal request: %w", err)
}
endpoint, err := normalizeEndpointURL(e.cfg.APIURL)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
if err != nil {
return nil, fmt.Errorf("embedding: build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
if e.cfg.APIKey != "" {
req.Header.Set("Authorization", "Bearer "+e.cfg.APIKey)
}
return req, nil
}
// readErrorBody drains a small prefix of a non-OK response body into a message,
// capped at maxErrorBodyBytes so an error response cannot exhaust memory.
func readErrorBody(r io.Reader) string {
+287
View File
@@ -0,0 +1,287 @@
package embedding
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// TestNormalizeEndpointURL 钉住「回环别名 → 127.0.0.1」这张表,以及**不该被动**
// 的那些输入。全部是纯字符串断言,不连任何网络。
func TestNormalizeEndpointURL(t *testing.T) {
t.Parallel()
cases := []struct {
name string
in string
want string
}{
{
name: "localhost 改写",
in: "http://localhost:11434/v1/embeddings",
want: "http://127.0.0.1:11434/v1/embeddings",
},
{
name: "localhost 大小写不敏感",
in: "http://LocalHost:11434/v1/embeddings",
want: "http://127.0.0.1:11434/v1/embeddings",
},
{
name: "localhost 无端口",
in: "http://localhost/v1/embeddings",
want: "http://127.0.0.1/v1/embeddings",
},
{
name: "https 同样改写",
in: "https://localhost:8443/v1/embeddings",
want: "https://127.0.0.1:8443/v1/embeddings",
},
{
// 0.0.0.0 是「监听所有接口」的语义,不是可连接的地址(拿它当连接目标会
// 立即失败),因此这里是**防呆**:把写坏的地址掰回真正可连的回环字面量,
// 而不是「支持用 0.0.0.0 去连」。
name: "0.0.0.0 防呆改写",
in: "http://0.0.0.0:11434/v1/embeddings",
want: "http://127.0.0.1:11434/v1/embeddings",
},
{
// 空主机同样只是防呆:原文拼出来的 URL 指向不了任何服务。
name: "空主机防呆改写",
in: "http:///v1/embeddings",
want: "http://127.0.0.1/v1/embeddings",
},
{
name: "显式 IPv6 字面量不改写",
in: "http://[::1]:11434/v1/embeddings",
want: "http://[::1]:11434/v1/embeddings",
},
{
name: "已是 127.0.0.1 原样返回",
in: "http://127.0.0.1:11434/v1/embeddings",
want: "http://127.0.0.1:11434/v1/embeddings",
},
{
name: "远端主机名不改写",
in: "https://embed.example.com/v1/embeddings",
want: "https://embed.example.com/v1/embeddings",
},
{
// 路径与查询串必须逐字不变:包括百分号编码、`+`、重复键与空值。
name: "路径与查询串逐字不变",
in: "http://localhost:8080/v1/emb%65ddings?a=%2F+&b=1&b=&empty#frag",
want: "http://127.0.0.1:8080/v1/emb%65ddings?a=%2F+&b=1&b=&empty#frag",
},
{
// 未改写的主机(含显式 IPv6)必须**原串**返回,不做任何重排。
name: "未改写时逐字返回原串",
in: "https://example.com:443/v1/a%2Fb?q=%7B%7D&r=1#f",
want: "https://example.com:443/v1/a%2Fb?q=%7B%7D&r=1#f",
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got, err := normalizeEndpointURL(tc.in)
if err != nil {
t.Fatalf("normalizeEndpointURL(%q) error: %v", tc.in, err)
}
if got != tc.want {
t.Fatalf("normalizeEndpointURL(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
// TestNormalizeEndpointURL_NonHTTPSchemeUntouched 保证非 http(s) 的输入不被改写:
// 那类地址不是本客户端能拨的,改主机只会掩盖更该暴露的配置错误。
func TestNormalizeEndpointURL_NonHTTPSchemeUntouched(t *testing.T) {
t.Parallel()
const in = "file://localhost/v1/embeddings"
got, err := normalizeEndpointURL(in)
if err != nil {
t.Fatalf("normalizeEndpointURL(%q) error: %v", in, err)
}
if got != in {
t.Fatalf("normalizeEndpointURL(%q) = %q, want unchanged", in, got)
}
}
// TestAPIEmbedder_NormalizesLoopbackHost 从**发出的请求**上验证规范化确实生效:
// 主机拨号目标变成 127.0.0.1,且 path + query 一字未动。用注入的 RoundTripper,
// 不连任何网络。
func TestAPIEmbedder_NormalizesLoopbackHost(t *testing.T) {
t.Parallel()
var gotHost, gotURI string
rt := roundTripFunc(func(r *http.Request) (*http.Response, error) {
gotHost = r.URL.Host
gotURI = r.URL.RequestURI()
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{"data":[{"index":0,"embedding":[0.1]}]}`)),
Header: make(http.Header),
}, nil
})
emb := NewAPI(
config.EmbeddingConfig{APIURL: "http://localhost:11434/v1/embeddings?k=v#frag"},
WithRoundTripper(rt),
)
if _, err := emb.Embed(context.Background(), []string{"hi"}); err != nil {
t.Fatalf("Embed() error: %v", err)
}
if gotHost != "127.0.0.1:11434" {
t.Errorf("dial host = %q, want 127.0.0.1:11434", gotHost)
}
if gotURI != "/v1/embeddings?k=v" {
t.Errorf("request URI = %q, want /v1/embeddings?k=v", gotURI)
}
}
// TestAPIEmbedder_KeepsExplicitIPv6Host 尊重使用者显式写下的 IPv6 字面量:
// 不改写、不重排。同样走注入的 RoundTripper,不连网络。
func TestAPIEmbedder_KeepsExplicitIPv6Host(t *testing.T) {
t.Parallel()
var gotHost string
rt := roundTripFunc(func(r *http.Request) (*http.Response, error) {
gotHost = r.URL.Host
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{"data":[{"index":0,"embedding":[0.1]}]}`)),
Header: make(http.Header),
}, nil
})
emb := NewAPI(
config.EmbeddingConfig{APIURL: "http://[::1]:11434/v1/embeddings"},
WithRoundTripper(rt),
)
if _, err := emb.Embed(context.Background(), []string{"hi"}); err != nil {
t.Fatalf("Embed() error: %v", err)
}
if gotHost != "[::1]:11434" {
t.Errorf("dial host = %q, want [::1]:11434(显式 IPv6 不许被改写)", gotHost)
}
}
// TestAPIEmbedder_LoopbackAliasReachesIPv4OnlyServer 是**真连本机**的场景验证
// (httptest 服务器只绑 127.0.0.1,正是 llama.cpp / ollama 的默认形态):
// 端点写成 localhost / 0.0.0.0 时,请求必须仍然落到那个 IPv4 回环服务上并拿回向量。
//
// 注意它证明的是「场景通」而非「不做规范化就必挂」:2026-10-07 在本机实测,
// Go 客户端的拨号器对 localhost 会很快回落到 127.0.0.1(约 57ms),把这一条测试
// 的规范化关掉它**仍然会通过**。真正把这条测试变红的是 path/query 被破坏之类的
// 回归;「localhost 一定不去碰 ::1」这个保证由上面的字符串断言守住。
// 全程只连本进程起在 127.0.0.1 上的临时服务器,不碰任何外部服务。
func TestAPIEmbedder_LoopbackAliasReachesIPv4OnlyServer(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, `{"data":[{"index":0,"embedding":[0.1,0.2]}]}`)
}))
defer srv.Close()
for _, alias := range []string{"localhost", "0.0.0.0"} {
alias := alias
// 刻意**不用** t.Parallel():子测试若并行,父测试会先返回并执行
// defer srv.Close(),子测试再跑时就只能撞上「连接被拒绝」。
t.Run(alias, func(t *testing.T) {
// srv.URL 形如 http://127.0.0.1:PORT —— 只把主机换成别名,端口保留,
// 这样「服务确实只绑 IPv4」这个前提不变。
raw := strings.Replace(srv.URL, "127.0.0.1", alias, 1) + "/v1/embeddings"
emb := NewAPI(config.EmbeddingConfig{APIURL: raw})
vectors, err := emb.Embed(context.Background(), []string{"hi"})
if err != nil {
t.Fatalf("端点写成 %s 时未落到 IPv4 回环服务:%v", alias, err)
}
if len(vectors) != 1 || len(vectors[0]) != 2 {
t.Fatalf("vectors = %v, want 1 个 2 维向量", vectors)
}
})
}
}
// reviewBudgetRef 是「入库前审查」单条总预算的**参照值**,只用于本文件的断言:
// 权威定义在 internal/runtime/memory/preinsert_config.go 的 defaultReviewTimeout。
// 跨包的真实断言在 memory 包(preinsert_recall_test.go)里,这里钉住同一个口径,
// 免得 embedding 侧悄悄把超时调到 ≥ 审查预算。
const reviewBudgetRef = 20 * time.Second
// TestDefaultRequestTimeout 钉住单次 embedding 调用的超时值,并断言它**严格小于**
// 审查总预算:否则一次卡死的 embedding 连接就能吃光整条审查的预算,排在后面的
// T1 灰区判定连执行机会都没有(2026-10-07 实测故障形态)。同时验证该超时确实
// 打在了发出去的请求上。
//
// 🔴 冷启动放宽(warmupTimeout)必须一并守住同一条界:它只发生一次,但它那一次的
// 等待**也**不能吃光整轮审查 —— 否则「一次挂死 = 整轮审查没有预算给 T1」这个形态
// 会从常态超时挪到放宽预算上原样复现(reviewer round1 的 p1)。因此这里对
// warmupTimeout 做与 DefaultRequestTimeout **同一条**断言。
func TestDefaultRequestTimeout(t *testing.T) {
t.Parallel()
if DefaultRequestTimeout <= 0 {
t.Fatalf("DefaultRequestTimeout = %s, 必须为正", DefaultRequestTimeout)
}
if DefaultRequestTimeout >= reviewBudgetRef {
t.Fatalf("embedding 单次超时 %s 必须严格小于审查总预算 %s",
DefaultRequestTimeout, reviewBudgetRef)
}
if DefaultRequestTimeout != 5*time.Second {
t.Fatalf("DefaultRequestTimeout = %s, want 5s(值变了要连带复核审查预算口径)",
DefaultRequestTimeout)
}
// 冷启动放宽的两条界:仍在常态超时的**同一量级**(不得成倍放大),
// 且严格小于审查总预算(一次冷启动不许吃光整轮)。
if warmupTimeout <= 0 {
t.Fatalf("warmupTimeout = %s, 必须为正", warmupTimeout)
}
if warmupTimeout > 2*DefaultRequestTimeout {
t.Fatalf("冷启动放宽 %s 超过常态超时 %s 的两倍 —— 它必须是同一量级的一次性放宽",
warmupTimeout, DefaultRequestTimeout)
}
if warmupTimeout >= reviewBudgetRef {
t.Fatalf("冷启动放宽 %s 必须严格小于审查总预算 %s(否则一次冷启动就吃光整轮审查)",
warmupTimeout, reviewBudgetRef)
}
var slack time.Duration
rt := roundTripFunc(func(r *http.Request) (*http.Response, error) {
dl, ok := r.Context().Deadline()
if !ok {
t.Error("请求上下文没有 deadline,单次超时未生效")
} else {
slack = time.Until(dl)
}
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{"data":[{"index":0,"embedding":[0.1]}]}`)),
Header: make(http.Header),
}, nil
})
emb := NewAPI(
config.EmbeddingConfig{APIURL: "http://127.0.0.1:11434/v1/embeddings"},
WithRoundTripper(rt),
)
// 第一次是冷启动调用(warmupTimeout),结果由它自己的测试断言;这里连发两次,
// 只看稳态那一次打出去的 deadline。
for i := 0; i < 2; i++ {
if _, err := emb.Embed(context.Background(), []string{"hi"}); err != nil {
t.Fatalf("第 %d 次 Embed() error: %v", i+1, err)
}
}
if slack <= 0 || slack > DefaultRequestTimeout {
t.Fatalf("请求 deadline 余量 = %s, want (0, %s]", slack, DefaultRequestTimeout)
}
}
+145
View File
@@ -0,0 +1,145 @@
package embedding
import "time"
// 本文件是**单次 /embeddings 调用的预算推演**:批次上限与单次超时必须由**同一个
// 式子**得出。任一侧单独调整都会让「一次请求的实测耗时 < 单次超时」这条不变式
// 静默失效 —— 而失效的后果不是报错,是**服务端还在算、客户端已经放弃**:被放弃的
// 那一批仍占着本机 llama.cpp 的算力,后面每一个请求(包括写索引时的单条请求)都
// 排在它后面等到自己的超时,于是整条记忆写入路径被拖住、召回全线降级。
//
// ## 不变式
//
// requestOverhead + 一次请求的字符总量 × perCharBudget ≤ DefaultRequestTimeout / callBudgetShare
//
// 左半是**推演出来的耗时上界**,右半是该超时下允许用掉的一半(另一半是这台机器的
// 抖动余量)。批次上限由解这个不等式得到(见 DefaultBatchLimit / DefaultBatchChars)。
//
// ⚠️ 这条不等式**只按稳态超时(DefaultRequestTimeout)推导,且只约束批次形状**;
// 冷启动那次放宽(warmupTimeout)**不改批形状**,只放宽进程第一个请求的**截止
// 时刻**,而且只发生一次(见 warmupTimeout 的说明)。批形状与超时仍然同源:
// 无论第一个请求的截止多宽,它带的条数/字符数永远按稳态超时算出来。
//
// ## 实测依据(2026-10-13,本机 = AMD RX 6600 XT + llama.cpp b11371 +
// Qwen3-Embedding-0.6B Q8_0,端点 127.0.0.1:11434,**服务器空闲**,正文取自活库
// data/cyrene.db:短正文约 33 汉字、长正文约 168 汉字;每档 5~6 次取最坏)
//
// n=1 短 0.066~0.185s 长 0.740s
// n=8 短 0.86~1.11s
// n=12 短 1.27s 长 5.47s
// n=16 短 2.20~3.31s
// n=64 短 7.49~8.49s(改前的批次上限)
//
// 拟合:单次请求耗时 ≈ 固定开销 + 字符总量 × 每字符成本;固定开销实测约 50ms,
// 每字符成本实测 2.7~3.9ms。**同一批 n≥32 起出现过数倍放大**(8.97s / 26.6s),
// 因此大批次的耗时不可预测 —— 这也是「余量取一半」而不是「卡着边界」的依据。
//
// 复跑命令(读活库正文、只连本机 embedding 服务、不写任何库):
//
// python - <<'PY'
// import json,sqlite3,time,urllib.request
// URL="http://127.0.0.1:11434/v1/embeddings"; MODEL="<model id>"
// t=[r[0] for r in sqlite3.connect("data/cyrene.db").execute(
// "select content from memory_items order by id limit 64")]
// for b in (t[:1],t[:8],t[:12],t[:16],t[:64]):
// body=json.dumps({"model":MODEL,"input":b}).encode()
// r=urllib.request.Request(URL,data=body,headers={"Content-Type":"application/json"})
// s=time.perf_counter(); urllib.request.urlopen(r,timeout=300).read()
// print(len(b), round(time.perf_counter()-s,3))
// PY
const (
// requestOverhead 是单次 HTTP 调用的固定开销上界(实测 ~50ms,向上取整)。
requestOverhead = 100 * time.Millisecond
// perCharBudget 是**每字符**的墙钟预算上界(实测 2.7~3.9 ms/字符,取 5ms)。
perCharBudget = 5 * time.Millisecond
// perItemBudget 是**每条文本**的墙钟预算上界:按活库里最长的正文(168 汉字,
// 最坏摊销 0.456 s/条)向上取整到 500ms。
perItemBudget = 500 * time.Millisecond
// callBudgetShare 表示一次请求最多用掉超时的 1/2:另一半是抖动余量
// (见上面 n≥32 的数倍放大)。调大它 = 余量变小,必须连带复核实测。
callBudgetShare = 2
// DefaultBatchLimit 是单次请求的**条数**上限,由上面三个常量推出来(唯一推演):
// (5s/2 − 100ms) / 500ms = 4。
DefaultBatchLimit = (int(DefaultRequestTimeout/callBudgetShare) - int(requestOverhead)) / int(perItemBudget)
// DefaultBatchChars 是单次请求的**字符总量**上限,同一个不等式换成每字符预算:
// (5s/2 − 100ms) / 5ms = 480。它防的是「条数很少但每条很长」:
// 4 条 × 168 汉字 = 672 字符已越过它,会被再切一刀。
DefaultBatchChars = (int(DefaultRequestTimeout/callBudgetShare) - int(requestOverhead)) / int(perCharBudget)
// warmupTimeout 是**一次性的**冷启动放宽:本 embedder 生命周期里的**第一个**
// 请求可以拿到这个截止(模型加载 / GPU 缓冲分配还没发生过),此后**每一个**
// 请求(含刚失败的那个之后的下一个)一律回到 DefaultRequestTimeout。
//
// 🔴 它**不是**「只要还没成功过就一直放宽」—— 那个口径会在服务挂死时把
// fail-fast 变成 fail-slow(每次都等 warmupTimeout),并把审查的 20s 预算、
// 索引写入的 5s 预算整段吃掉。一次性 + 8s 两条一起才是有界的:
// - 一次性:放宽最多花掉**一个**请求的时长,最坏情形之后立刻回到 5s;
// - 8s:与常态 5s **同一量级**(1.6×),且严格小于审查单条总预算 20s
// (memory.defaultReviewTimeout)⇒ 一次冷启动不可能吃光整轮审查,
// 后面的 T1 灰区判定(8s)仍有预算。这条不等式由测试守住
// (api_url_test.go 的 TestDefaultRequestTimeout)。
//
// 依据:2026-10-13 部署后第一分钟的生产日志里有一条
// `vector embed for index failed ... embed 1 texts ... context deadline exceeded`
// —— 模型还没热时 5s 不够;而稳态实测单条只要 0.03~0.19s(见上面的曲线),
// 所以放宽只需要覆盖「进程的第一个请求」这一刻。
warmupTimeout = 8 * time.Second
)
// callBudget 返回在 timeout 这个单次超时下,一次请求允许用掉的墙钟预算。
func callBudget(timeout time.Duration) time.Duration {
if timeout <= 0 {
timeout = DefaultRequestTimeout
}
return timeout/callBudgetShare - requestOverhead
}
// batchLimitsFor 由**同一个超时**推出一对批次上限(条数 / 字符总量)。
// 它保证:按 (条数 ≤ items 且 字符总量 ≤ chars) 切出来的任何一段,其耗时上界
// 都不超过该超时的一半。两者都至少为 1 —— 单条文本本身越界时独占一段,
// 正文**绝不截断**(截断会让向量描述的不是这条记忆)。
func batchLimitsFor(timeout time.Duration) (items, chars int) {
budget := callBudget(timeout)
items = int(budget / perItemBudget)
if items < 1 {
items = 1
}
chars = int(budget / perCharBudget)
if chars < 1 {
chars = 1
}
return items, chars
}
// chunkTexts 按「条数 ≤ maxItems 且 字符总量 ≤ maxChars」把 texts 切成若干段。
// 单条文本的 rune 数超过 maxChars 时它独占一段(见 batchLimitsFor 的口径)。
// 空输入返回 nil。
func chunkTexts(texts []string, maxItems, maxChars int) [][]string {
if len(texts) == 0 {
return nil
}
if maxItems < 1 {
maxItems = 1
}
if maxChars < 1 {
maxChars = 1
}
var out [][]string
start := 0
chars := 0
for i, text := range texts {
n := len([]rune(text))
// 段内已有内容,再加这一条就越界 ⇒ 先收口,本段从这一条重新开始。
if i > start && (i-start >= maxItems || chars+n > maxChars) {
out = append(out, texts[start:i])
start, chars = i, 0
}
chars += n
}
return append(out, texts[start:])
}
+393
View File
@@ -0,0 +1,393 @@
package embedding
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"sync"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// 本文件把 budget.go 的**不变式**钉成可执行的断言,两态各一条:
//
// - 「批内耗时 < 超时」⇒ 不降级:DerivedBatchCompletesWithinTimeout;
// - 「批内耗时 ≥ 超时」⇒ 超时(本次故障形态):LegacyBatchOf64TimesOut;
//
// 以及**变异自证**:把批上限调回旧值 64(或把推演调松)会让哪条测试红,
// 见 TestBatchBudget_InvariantHolds 与 TestAPIEmbedder_LegacyBatchOf64TimesOut。
//
// 假客户端用的是**实测曲线**(不是随口编的常数):单次请求耗时
// ≈ measuredOverhead + 字符总量 × measuredPerChar,取值见下面两个常量
// (2026-10-13 本机实测:固定开销 ~50ms、每字符 2.7~3.9ms,此处取上界)。
const (
measuredOverhead = 50 * time.Millisecond
measuredPerChar = 4 * time.Millisecond
latencyModelChars = 33 // 活库短正文的平均长度(实测 33 汉字)
)
// latencyTripper 是一个按上面那条曲线**真的睡够时间**的 RoundTripper:
// 它模拟「服务端会算完,不管你客户端还在不在等」。这正是本次故障的形态 ——
// 客户端 5s 放弃、服务端还在算,后面每个请求都排在它后面。
type latencyTripper struct {
mu sync.Mutex
requests int
maxItems int
maxChars int
// abandoned 计数「客户端先放弃、服务端还在算」的次数 —— 本次故障的形态。
// 修好之后它必须是 0(见 TestAPIEmbedder_OversizedBatchIsSplit 的断言)。
abandoned int
// firstSleep 非零时,第一次请求额外多睡这么久(模拟冷启动的模型加载)。
firstSleep time.Duration
}
func (l *latencyTripper) RoundTrip(r *http.Request) (*http.Response, error) {
body, err := io.ReadAll(r.Body)
if err != nil {
return nil, err
}
var req openAICompatibleRequest
if err := json.Unmarshal(body, &req); err != nil {
return nil, err
}
chars := 0
for _, s := range req.Input {
chars += len([]rune(s))
}
l.mu.Lock()
l.requests++
if len(req.Input) > l.maxItems {
l.maxItems = len(req.Input)
}
if chars > l.maxChars {
l.maxChars = chars
}
first := l.requests == 1
extra := l.firstSleep
l.mu.Unlock()
wait := measuredOverhead + time.Duration(chars)*measuredPerChar
if first && extra > 0 {
wait += extra
}
select {
case <-time.After(wait):
case <-r.Context().Done():
// 客户端放弃了:服务端那一侧不会因为客户端走了就把已排队的算力还回来
// (实测的级联堆积正是从这一步开始的),因此这里明确记一笔。
l.mu.Lock()
l.abandoned++
l.mu.Unlock()
return nil, r.Context().Err()
}
resp := openAICompatibleResponse{Data: make([]openAICompatibleDatum, 0, len(req.Input))}
for i := range req.Input {
resp.Data = append(resp.Data, openAICompatibleDatum{Index: i, Embedding: []float32{1, 0}})
}
raw, err := json.Marshal(resp)
if err != nil {
return nil, err
}
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(string(raw))),
Header: make(http.Header),
}, nil
}
// shortTexts 造 n 条与活库短正文等长的文本。
func shortTexts(n int) []string {
out := make([]string, n)
line := strings.Repeat("河", latencyModelChars)
for i := range out {
out[i] = line
}
return out
}
// TestBatchBudget_InvariantHolds 是**推演本身**的断言(不连任何网络):
// 由超时推出来的一对上限,必须使「一次请求的耗时上界 ≤ 超时的一半」成立。
//
// 变异自证:把 DefaultBatchLimit 的推演改成旧口径(条数写回 64)⇒ 第一条断言红;
// 若改成「把超时放大到盖住 64 条」⇒ 最后一条断言红(旧配对必须仍是越界的)。
func TestBatchBudget_InvariantHolds(t *testing.T) {
t.Parallel()
if DefaultBatchLimit < 1 || DefaultBatchChars < 1 {
t.Fatalf("推出来的上限必须 ≥ 1:条数=%d 字符=%d", DefaultBatchLimit, DefaultBatchChars)
}
share := DefaultRequestTimeout / callBudgetShare
byItems := requestOverhead + time.Duration(DefaultBatchLimit)*perItemBudget
if byItems > share {
t.Fatalf("条数上限 %d 的耗时上界 %s 超过超时的一半 %s(推演被调松了)",
DefaultBatchLimit, byItems, share)
}
byChars := requestOverhead + time.Duration(DefaultBatchChars)*perCharBudget
if byChars > share {
t.Fatalf("字符上限 %d 的耗时上界 %s 超过超时的一半 %s(推演被调松了)",
DefaultBatchChars, byChars, share)
}
// 旧配对(批次 64 + 5s 超时)必须仍被判为越界 —— 那正是 2026-10-13 的故障形态:
// 实测 64 条 7.5~8.5s > 5s。这里用同一套预算常量复核它仍然出界。
legacy := requestOverhead + 64*perItemBudget
if legacy <= DefaultRequestTimeout {
t.Fatalf("旧配对(64 条)的耗时上界 %s 竟然落在单次超时 %s 之内 —— "+
"说明超时被放大到迁就批次了,本次修法不允许", legacy, DefaultRequestTimeout)
}
}
// TestChunkTexts_RespectsBothLimits 钉住切片:条数与字符总量两道界都要守,
// 单条本身越界的文本独占一段且**不被截断**。
func TestChunkTexts_RespectsBothLimits(t *testing.T) {
t.Parallel()
got := chunkTexts(shortTexts(9), 2, 100)
if len(got) != 5 {
t.Fatalf("2 条/段 切 9 条 = %d 段,want 5", len(got))
}
for i, seg := range got {
if len(seg) > 2 {
t.Fatalf("第 %d 段有 %d 条,超过条数上限 2", i, len(seg))
}
}
// 字符界绑定:4 条 33 字符 = 132 字符 ⇒ 100 字符/段时必须收口(3 条 = 99 字符满,
// 余下 1 条自成一段)。
byChars := chunkTexts(shortTexts(4), 64, 100)
if len(byChars) != 2 {
t.Fatalf("100 字符/段 切 4×33 字符 = %d 段,want 2", len(byChars))
}
for i, seg := range byChars {
total := 0
for _, s := range seg {
total += len([]rune(s))
}
if total > 100 {
t.Fatalf("第 %d 段 %d 字符,超过字符上限 100", i, total)
}
}
// 超长单条:不切碎、不截断,独占一段。
huge := strings.Repeat("长", 500)
segs := chunkTexts([]string{huge, "短"}, 64, 100)
if len(segs) != 2 || segs[0][0] != huge {
t.Fatalf("超长单条应独占一段且不改写,got %d 段", len(segs))
}
if chunkTexts(nil, 4, 100) != nil {
t.Fatal("空输入应返回 nil")
}
}
// TestAPIEmbedder_DerivedBatchCompletesWithinTimeout 钉**不降级态**:
// 按推出来的上限发一次(Embed 内部切成 1 段),在实测曲线下必须在单次超时内拿到向量。
func TestAPIEmbedder_DerivedBatchCompletesWithinTimeout(t *testing.T) {
t.Parallel()
rt := &latencyTripper{}
emb := NewAPI(config.EmbeddingConfig{APIURL: "http://127.0.0.1:11434/v1/embeddings"},
WithRoundTripper(rt))
texts := shortTexts(DefaultBatchLimit)
vectors, err := emb.Embed(context.Background(), texts)
if err != nil {
t.Fatalf("上限内的批不该超时:%v", err)
}
if len(vectors) != len(texts) {
t.Fatalf("got %d vectors, want %d", len(vectors), len(texts))
}
if rt.requests != 1 {
t.Fatalf("上限内的批应发 1 次请求,实际 %d 次", rt.requests)
}
}
// TestAPIEmbedder_OversizedBatchIsSplit 钉**不变式与调用方无关**:
// 调用方给 3 倍上限的切片时,Embed 自己切成多段、每段都在界内,向量顺序与入参一致。
func TestAPIEmbedder_OversizedBatchIsSplit(t *testing.T) {
t.Parallel()
rt := &latencyTripper{}
emb := NewAPI(config.EmbeddingConfig{APIURL: "http://127.0.0.1:11434/v1/embeddings"},
WithRoundTripper(rt))
texts := shortTexts(DefaultBatchLimit * 3)
vectors, err := emb.Embed(context.Background(), texts)
if err != nil {
t.Fatalf("超大切片应被切开而不是超时:%v", err)
}
if len(vectors) != len(texts) {
t.Fatalf("got %d vectors, want %d(切开后必须按序拼回)", len(vectors), len(texts))
}
if rt.requests < 3 {
t.Fatalf("3 倍上限应至少切成 3 段,实际 %d 段", rt.requests)
}
if rt.maxItems > DefaultBatchLimit {
t.Fatalf("有请求带了 %d 条,超过推出来的条数上限 %d", rt.maxItems, DefaultBatchLimit)
}
if rt.maxChars > DefaultBatchChars {
t.Fatalf("有请求带了 %d 字符,超过推出来的字符上限 %d", rt.maxChars, DefaultBatchChars)
}
// 🔴 本次修复的核心不变式,确定性断言(不依赖机器负载):
// 没有任何一次请求发生「客户端先放弃、服务端还在算」——那正是级联堆积的起点。
if rt.abandoned != 0 {
t.Fatalf("有 %d 次请求被客户端先放弃(服务端还在算)⇒ 不变式没守住", rt.abandoned)
}
// 服务端侧的同一条不变式:任何一次请求的建模耗时(实测曲线)都落在单次超时内。
if cost := measuredOverhead + time.Duration(rt.maxChars)*measuredPerChar; cost > DefaultRequestTimeout {
t.Fatalf("最大一次请求的实测曲线耗时 %s 越过单次超时 %s", cost, DefaultRequestTimeout)
}
}
// TestAPIEmbedder_LegacyBatchOf64TimesOut 是**变异自证**的另一半:同样是实测曲线,
// 旧配对(一次请求 64 条 + 5s 超时)必须**超时**。它证明把上限写回 64 不是「保守」,而是
// 必然把客户端与服务端撕裂(客户端走了、服务端还在算 ⇒ 后续请求全排队)。
func TestAPIEmbedder_LegacyBatchOf64TimesOut(t *testing.T) {
t.Parallel()
rt := &latencyTripper{}
emb := NewAPI(config.EmbeddingConfig{APIURL: "http://127.0.0.1:11434/v1/embeddings"},
WithRoundTripper(rt))
// 先把一次性冷启动放宽花掉,否则第一次调用按 warmupTimeout 算、不会超时。
if _, err := emb.Embed(context.Background(), shortTexts(1)); err != nil {
t.Fatalf("预热调用失败:%v", err)
}
start := time.Now()
_, err := emb.embedChunk(context.Background(), shortTexts(64))
elapsed := time.Since(start)
if err == nil {
t.Fatalf("旧配对(64 条 + %s 超时)在实测曲线下不该成功", DefaultRequestTimeout)
}
if elapsed > DefaultRequestTimeout+2*time.Second {
t.Fatalf("超时保护没生效:等了 %s", elapsed)
}
}
// TestAPIEmbedder_WarmupIsOneShot 钉 p1 的核心:冷启动放宽**一次性**,三种失败形态
// 下都必须是「第一次最多等 warmupTimeout,第二次起一律 DefaultRequestTimeout」。
//
// 这是 reviewer 在 round1 打回的那条:旧实现 `warmed` 只在 2xx 之后才翻,于是
// 「服务接受连接但永不回包」时**每一次**调用都等完整放宽预算(30s),把 fail-fast
// 变成 fail-slow —— 一次挂死就能吃光审查的 20s 预算、把索引写入拖成 8×30s。
func TestAPIEmbedder_WarmupIsOneShot(t *testing.T) {
t.Parallel()
cases := []struct {
name string
rt func(t *testing.T) http.RoundTripper
}{
{
name: "连接被拒",
rt: func(t *testing.T) http.RoundTripper {
return roundTripFunc(func(*http.Request) (*http.Response, error) {
return nil, errors.New("dial tcp 127.0.0.1:11434: connectex: 目标计算机积极拒绝")
})
},
},
{
name: "HTTP 500",
rt: func(t *testing.T) http.RoundTripper {
return roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusInternalServerError,
Body: io.NopCloser(strings.NewReader("server exploded")),
Header: make(http.Header),
}, nil
})
},
},
{
name: "接受连接但永不回包",
rt: func(t *testing.T) http.RoundTripper {
return roundTripFunc(func(r *http.Request) (*http.Response, error) {
<-r.Context().Done() // 只等服务端超时,永不回包
return nil, r.Context().Err()
})
},
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
emb := NewAPI(config.EmbeddingConfig{APIURL: "http://127.0.0.1:11434/v1/embeddings"},
WithRoundTripper(tc.rt(t)))
// 第一次:放宽预算。断言上界 = warmupTimeout(不得再宽),
// 下界只对会真正等到截止的那一种(挂死)有意义。
start := time.Now()
_, err := emb.Embed(context.Background(), shortTexts(1))
first := time.Since(start)
if err == nil {
t.Fatal("这一组失败形态不该成功")
}
t.Logf("首次耗时 = %s", first.Round(time.Millisecond))
if first > warmupTimeout+time.Second {
t.Fatalf("首次耗时 %s 超过冷启动放宽 %s", first, warmupTimeout)
}
// 第二次:**必须**回到常态超时 —— 一次挂死绝不许变成每次都慢。
start = time.Now()
if _, err := emb.Embed(context.Background(), shortTexts(1)); err == nil {
t.Fatal("这一组失败形态不该成功")
}
second := time.Since(start)
t.Logf("第二次耗时 = %s(常态超时 %s)", second.Round(time.Millisecond), DefaultRequestTimeout)
if second > DefaultRequestTimeout+time.Second {
t.Fatalf("第二次耗时 %s 超过常态超时 %s ⇒ 放宽成了长期的", second, DefaultRequestTimeout)
}
// 放宽许可必须已经被花掉(它是一次性的,任何路径都不许给它续期)。
if emb.warmupLeft.Load() {
t.Fatal("第二次请求之后放宽许可仍为真 —— 一次性的约束被绕过了")
}
})
}
}
// TestAPIEmbedder_WarmupCoversColdStartOnce 钉放宽的**正面**作用:第一次请求
// (模型加载 / GPU 缓冲分配)可以比常态超时多等一点,只要它落在 warmupTimeout
// 之内;而这一刻过去之后,请求立刻回到常态超时(deadline 断言)。
func TestAPIEmbedder_WarmupCoversColdStartOnce(t *testing.T) {
t.Parallel()
rt := &latencyTripper{firstSleep: DefaultRequestTimeout + time.Second}
emb := NewAPI(config.EmbeddingConfig{APIURL: "http://127.0.0.1:11434/v1/embeddings"},
WithRoundTripper(rt))
if _, err := emb.Embed(context.Background(), shortTexts(1)); err != nil {
t.Fatalf("冷启动第一次调用应被 warmupTimeout(%s) 兜住:%v", warmupTimeout, err)
}
if emb.warmupLeft.Load() {
t.Fatal("放宽许可应在第一次请求时就被花掉")
}
var slack time.Duration
steady := roundTripFunc(func(r *http.Request) (*http.Response, error) {
dl, ok := r.Context().Deadline()
if !ok {
t.Error("稳态请求没有 deadline")
} else {
slack = time.Until(dl)
}
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{"data":[{"index":0,"embedding":[1]}]}`)),
Header: make(http.Header),
}, nil
})
emb.client = &http.Client{Transport: steady}
if _, err := emb.Embed(context.Background(), shortTexts(1)); err != nil {
t.Fatalf("稳态调用失败:%v", err)
}
if slack <= 0 || slack > DefaultRequestTimeout {
t.Fatalf("稳态单次超时余量 = %s, want (0, %s]", slack, DefaultRequestTimeout)
}
}
+75
View File
@@ -0,0 +1,75 @@
package embedding
import (
"fmt"
"net"
"net/url"
"strings"
)
// 本文件只管**端点地址的规范化**:把「连接语义上不可靠」的回环主机名别名改写
// 成明确的 IPv4 回环字面量。它只改主机,不碰路径、查询串、fragment 与用户信息,
// 也不做任何名字解析或在线可达性探测。
//
// 位置口径:规范化发生在 URL 解析之后、真正拨号之前(见 APIEmbedder.Embed),
// 因此它绝不改变 HTTP 报文里除 Host 之外的任何字节。
// loopbackIPv4 是唯一的 IPv4 回环字面量。选它而不是 0.0.0.0 的理由:
// 0.0.0.0 的语义是「监听所有接口」,**不是**一个可以连接的地址(实测拿它当连接
// 目标会立即失败);而 127.0.0.1 在 Windows / Linux / macOS / Termux 上行为一致,
// 且完全不需要名字解析。
const loopbackIPv4 = "127.0.0.1"
// normalizeEndpointURL 返回规范化后的端点 URL。
//
// 规范化范围只有三类,且都是**防呆**(把写坏的地址掰回可连接的形态),
// 而不是「支持拿这些地址去连」:
// - "localhost":名字解析可能先命中 IPv6 ::1,而本机 embedding 服务通常只绑
// IPv4 回环(llama.cpp / ollama 默认如此)⇒ 那是一次注定无效的拨号。代价视环境
// 而定:可能是几十毫秒的无效尝试(2026-10-07 实测本机 Go 客户端约 57ms,
// curl 约 0.2s),也可能在「::1 被静默丢弃」的防火墙/路由策略下一直等到系统
// 连接超时,把一个 20s 级的调用预算整段吞掉。写死 IPv4 字面量直接绕开这个
// 不确定性;
// - "0.0.0.0":见 loopbackIPv4 的说明,它不是可连接的地址;
// - 空主机(如 "http:///v1/embeddings"):拼出来的 URL 指向不了任何服务。
//
// 不改写的:显式 IPv6 字面量("[::1]" 之类)、真实主机名 / IPv4、非 http(s)
// scheme —— 使用者怎么写就怎么来。未发生改写时**逐字返回原串**;发生改写时只替换
// 主机,path / query / fragment / userinfo 由 net/url 原样重放。
func normalizeEndpointURL(raw string) (string, error) {
u, err := url.Parse(raw)
if err != nil {
return "", fmt.Errorf("embedding: parse api url: %w", err)
}
if u.Scheme != "http" && u.Scheme != "https" {
// 不是本客户端能拨的 scheme:原样返回,让后续的报错如实暴露配置问题,
// 而不是在这里改主机把它掩盖掉。
return raw, nil
}
host, changed := normalizeLoopbackHost(u.Hostname())
if !changed {
return raw, nil
}
if port := u.Port(); port != "" {
u.Host = net.JoinHostPort(host, port)
} else {
u.Host = host
}
return u.String(), nil
}
// normalizeLoopbackHost 判定主机名是否属于「不可靠的回环别名」,是则返回
// 127.0.0.1。判定大小写不敏感("LocalHost" 同样改写)。IPv6 字面量不在表内 ——
// 使用者显式写了 [::1] 就尊重它,不做任何猜测。
func normalizeLoopbackHost(host string) (string, bool) {
switch {
case host == "":
return loopbackIPv4, true
case host == "0.0.0.0":
return loopbackIPv4, true
case strings.EqualFold(host, "localhost"):
return loopbackIPv4, true
default:
return host, false
}
}
+22 -8
View File
@@ -12,8 +12,14 @@ import (
// 客户端回看一条回复时读的是 `toolCalls[].resultSummary`(图节点 `props.result_summary`,
// 见 gateway/history_toolcalls.go),实时帧读的就是这里的产物。
//
// 🔴 为什么必须在 loop 侧算:`.end` 帧发出的那一刻,工具返回值只在这一层存在;
// 网关拿到的 `model.StreamEvent` 里没有原文(也**不该**有 —— 帧一旦携带上游原文,
// 🔴 **失败也是结果**:回看面对一次失败的调用回的是图上的 `error` 摘要
// (`toolCallResultSummary` 在 `props.result_summary` 缺席时回落 `props.error`),
// 所以实时帧在失败时同样带一份**已脱敏**的错误文本 —— 同一份脱敏路径、同一份上限,
// 客户端不必为「实时」与「回看」写两套判据。键**只在「真的没有结果」时缺席**:
// 调用成功但结果为空,或错误文本本身为空(见 `toolEndFrameSummary`)。
//
// 🔴 为什么必须在 loop 侧算:`.end` 帧发出的那一刻,工具的返回值与错误原因只在这一层
// 存在;网关拿到的 `model.StreamEvent` 里没有原文(也**不该**有 —— 帧一旦携带上游原文,
// 脱敏面就被绕过了)。所以摘要只在这里、在**同一份已脱敏文本**上生成,网关只做搬运
// 与截断,绝不反解上游原文。
@@ -33,14 +39,22 @@ func toolFrameResultSummary(result any) string {
return trace.RedactText(renderToolResultSummary(result))
}
// toolEndFrameSummary 是一次调用结束时**要不要带摘要**的取舍点:只有结果真正拿到
// (Invoke 未报错)才给摘要。失败 —— 含工具自身报错、取消、超时(三者对调用方都是
// `Invoke` 返回 error)—— 一律返回空串,帧上该键随之**整键缺席**,绝不用空串顶位,
// 也绝不把错误文本冒充成「结果」:错误的原因属于图与回看面(那里 `status:"error"` 的
// 调用给出的就是图上的 `error` 摘要),实时帧只如实说「这次没拿到结果」。
// toolEndFrameSummary 是一次调用结束时**要不要带摘要**(以及带哪一份)的取舍点。
// **失败也是结果**:调用成功 ⇒ 工具返回值的渲染;失败 —— 含工具自身报错、取消、
// 超时(三者对调用方都是 `Invoke` 返回 error)—— ⇒ **错误文本的脱敏渲染**,因为
// 回看面对失败调用回的就是图上那份 `error` 摘要。两边给同一份文本,「实时」与
// 「回看」不再分叉(这一次调用到底发生了什么,只有一种说法)。
//
// 三条边界:① **超时 / 取消 / 工具报错一视同仁** —— 都是「这次调用有了结论」,错误文本
// 就是结论;失败时 `err` 优先,部分结果不顶掉失败原因(部分结果是过程,失败原因才是结论)。
// ② **脱敏不放宽**:错误文本走的仍是唯一脱敏源 `trace.RedactText`(凭据形状清洗 + 截断),
// 与结果摘要是同一条路径,不新开第二条旁路;下游网关再按与回看面共用的 ≤500 rune 上限兜底
// (loop 侧本就被 trace 收到更短)。③ **只有真的没有结果才留空**:`err == nil` 且结果为空
// (nil / 空串),或 `err` 的文本本身为空时返回空串 ⇒ 帧上该键**整键缺席**,绝不用空串顶位
// (空串会让客户端渲染出一个空的结果区,比没有结果区更糟)。
func toolEndFrameSummary(result any, err error) string {
if err != nil {
return ""
return trace.RedactText(err.Error())
}
return toolFrameResultSummary(result)
}
@@ -9,12 +9,14 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// 本文件钉住 `tool_execution.end` 帧上的 resultSummary(契约 v0.82)的四条口径:
// 本文件钉住 `tool_execution.end` 帧上的 resultSummary(契约 v0.82)的五条口径:
// ① 结果真拿到 ⇒ 帧在场,且文本与客户端**回看**同一次调用所见的
// `toolCalls[].resultSummary`(图节点 props.result_summary)**逐字一致**;
// ② 失败/取消/超时 ⇒ 空串(下游据此让键整键缺席),绝不用空串顶位、绝不把错误
// 冒充成结果;③ 凭据形状必须被清洗(与出图同一条 trace.RedactText);
// ④ nil 与空串结果也不产出「看起来像结果」的占位文本。
// ② **失败也是结果**(取消 / 超时同普通报错,一视同仁):帧带错误文本的已脱敏摘要,
// 与回看面回落的图上 `error` 摘要**逐字一致**;③ 只有真的没有结果(结果为空、或错误
// 文本为空)才留空 ⇒ 下游让键整键缺席,绝不用空串顶位;④ 凭据形状必须被清洗
// (成功与失败两条路径共用同一条 trace.RedactText);⑤ nil 与空串结果不产出
// 「看起来像结果」的占位文本。
// toolNodeResultSummary 反读最后一份图快照里 tool 节点的 props.result_summary ——
// 也就是回看面 `toolCalls[].resultSummary` 的**同一份数据源**。没有该 prop 时返回
@@ -35,6 +37,24 @@ func toolNodeResultSummary(t *testing.T, g *trace.FlowGraph) (string, bool) {
return "", false
}
// toolNodeProp 反读最后一份图快照里 tool 节点的某个字符串 prop —— 回看面
// `toolCalls[]` 各项的取值源。没有该 prop(或它不是字符串)时返回 ("", false)。
func toolNodeProp(t *testing.T, g *trace.FlowGraph, key string) (string, bool) {
t.Helper()
if g == nil {
t.Fatal("nil graph snapshot")
}
for _, n := range g.Nodes {
if n.Kind != "tool" {
continue
}
s, ok := n.Props[key].(string)
return s, ok
}
t.Fatalf("图里没有 tool 节点,无法反读 props[%q](nodes=%+v)", key, g.Nodes)
return "", false
}
// TestRun_ToolEndFrameCarriesResultSummary 覆盖成功路径:end 帧带结果摘要,
// start 帧不带(摘要只在结果落定那一刻存在)。
func TestRun_ToolEndFrameCarriesResultSummary(t *testing.T) {
@@ -53,11 +73,16 @@ func TestRun_ToolEndFrameCarriesResultSummary(t *testing.T) {
}
}
// TestRun_ToolEndFrameOmitsSummaryOnError 覆盖失败/取消/超时路径:三者对循环都是
// `Invoke` 返回 error ⇒ end 帧的 resultSummary **留空**(下游据此让键整键缺席),
// 绝不把错误文本冒充成「结果」。
func TestRun_ToolEndFrameOmitsSummaryOnError(t *testing.T) {
// TestRun_ToolEndFrameCarriesErrorSummaryOnFailure 覆盖失败/取消/超时路径:三者对循环
// 都是 `Invoke` 返回 error,而**失败也是结果** —— end 帧带的是错误文本的**已脱敏**摘要,
// 且与回看面回落的图上 `error` 摘要**逐字一致**(同一次调用,实时与事后只有一种说法)。
// 失败时若还带回部分结果,摘要仍取错误文本:部分结果是过程,失败原因才是结论。
//
// 错误文本故意带凭据形状:这样「同源钉」在**脱敏**上也灵敏 —— 若哪天失败路径绕过
// `trace.RedactText`,图上那份仍是清洗过的、帧上那份不是,逐字比对会立刻红。
func TestRun_ToolEndFrameCarriesErrorSummaryOnFailure(t *testing.T) {
t.Parallel()
const secret = "abcdef1234567890"
cases := []struct {
name string
result any
@@ -68,7 +93,7 @@ func TestRun_ToolEndFrameOmitsSummaryOnError(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
evs, _ := runToolTurn(t, tc.result, errors.New("boom"))
evs, graphs := runToolTurn(t, tc.result, errors.New("boom: token="+secret))
ex := toolExecEvents(evs)
if len(ex) != 2 {
t.Fatalf("tool_execution events = %d, want 2: %+v", len(ex), ex)
@@ -76,14 +101,65 @@ func TestRun_ToolEndFrameOmitsSummaryOnError(t *testing.T) {
if ex[1].Phase != model.ToolPhaseError {
t.Fatalf("end phase = %q, want error(失败路径)", ex[1].Phase)
}
if ex[1].ResultSummary != "" {
t.Fatalf("失败调用的 end 帧带了 %q;失败/取消/超时一律留空,由下游让键缺席",
ex[1].ResultSummary)
got := ex[1].ResultSummary
if got == "" {
t.Fatalf("失败调用的 end 帧没带摘要;失败也是结果,回看面对同一次调用给的是 error 摘要")
}
if !strings.Contains(got, "boom") {
t.Fatalf("end 帧 resultSummary = %q,want 含工具报错文本 boom", got)
}
if strings.Contains(got, secret) {
t.Fatalf("失败帧 resultSummary 里带着凭据原文:%q", got)
}
fromGraph, ok := toolNodeProp(t, graphs[len(graphs)-1], "error")
if !ok || fromGraph == "" {
t.Fatalf("图上 tool 节点没有 error prop (present=%v);回看面会缺席,而实时帧带着错误摘要", ok)
}
if got != fromGraph {
t.Fatalf("实时帧 resultSummary = %q,图上 error = %q;同一份文本才允许两边都出", got, fromGraph)
}
})
}
}
// TestRun_ToolEndFrameErrorSummaryIsRedacted 钉「脱敏不放宽」在失败路径上同样成立:
// 错误文本里带凭据形状时,帧上的摘要必须已经是清洗后的文本 —— 失败不是绕过脱敏的后门。
func TestRun_ToolEndFrameErrorSummaryIsRedacted(t *testing.T) {
t.Parallel()
const secret = "abcdef1234567890"
evs, _ := runToolTurn(t, nil, errors.New("lookup failed: token="+secret))
ex := toolExecEvents(evs)
if len(ex) != 2 {
t.Fatalf("tool_execution events = %d, want 2: %+v", len(ex), ex)
}
got := ex[1].ResultSummary
if strings.Contains(got, secret) {
t.Fatalf("失败帧 resultSummary 里带着凭据原文:%q", got)
}
if !strings.Contains(got, "[redacted]") {
t.Fatalf("失败帧 resultSummary = %q,want 含 [redacted](与结果摘要同一条清洗路径)", got)
}
}
// TestRun_ToolEndFrameErrorSummaryIsBounded 钉上限:超长错误文本不得整段上帧 ——
// 它走的仍是同一条有界脱敏路径(loop 侧被 trace 收得更短),线上再按与回看面共用的
// ≤500 rune 预算兜底。
func TestRun_ToolEndFrameErrorSummaryIsBounded(t *testing.T) {
t.Parallel()
evs, _ := runToolTurn(t, nil, errors.New(strings.Repeat("详", 2000)))
ex := toolExecEvents(evs)
if len(ex) != 2 {
t.Fatalf("tool_execution events = %d, want 2: %+v", len(ex), ex)
}
got := ex[1].ResultSummary
if got == "" {
t.Fatal("超长失败的 end 帧没带摘要;失败也是结果")
}
if n := len([]rune(got)); n > 500 {
t.Fatalf("失败帧 resultSummary 长度 = %d rune,超过与回看面共用的上限 500", n)
}
}
// TestRun_ToolEndFrameSummaryMatchesGraphResultSummary 是**同源钉**:同一个工具结果,
// 实时帧上的 resultSummary 必须与最后一份图快照里 tool 节点的 props.result_summary
// (回看面 `toolCalls[].resultSummary` 的取值源)逐字相等。它用真建图器(真 span →
@@ -140,7 +216,8 @@ func TestRun_ToolEndFrameSummaryIsRedacted(t *testing.T) {
}
// TestToolFrameResultSummary_EmptyResultsStayEmpty 钉住「绝不产出看起来像结果的占位
// 文本」:nil 与空串结果的摘要必须为空 ⇒ 下游让键整键缺席(不是回 "null"/"empty")。
// 文本」与「只有真的没有结果才缺席」:nil 与空串结果的摘要必须为空,**错误文本为空的
// 失败**同样为空 ⇒ 下游让键整键缺席(不是回 "null"/"empty",也不用空串顶位)。
func TestToolFrameResultSummary_EmptyResultsStayEmpty(t *testing.T) {
t.Parallel()
for _, v := range []any{nil, ""} {
@@ -151,4 +228,7 @@ func TestToolFrameResultSummary_EmptyResultsStayEmpty(t *testing.T) {
t.Fatalf("toolEndFrameSummary(%#v, nil) = %q, want 空串", v, got)
}
}
if got := toolEndFrameSummary(nil, errors.New("")); got != "" {
t.Fatalf("toolEndFrameSummary(nil, 空错误) = %q,want 空串(真的没有结果才缺席)", got)
}
}
+72 -103
View File
@@ -28,9 +28,13 @@ import (
// 2. **失败即安全**:条目先落库 ⇒ 审查失败/超时/embedder 不可用时「记忆绝不
// 丢」是结构性成立的,不靠回滚(§15 D3-5)。失败路径**只记 WARN、绝不
// 改写任何一行**;
// 3. **零 LLM**:T0 是纯规则(design §15 D3-2 T0),T1 默认关且**本切片未
// 实现**(§15.2 开放点 1 = T1 默认关),因此「T1 关闭时零模型调用」是
// 结构性成立 —— 本服务的依赖里根本没有模型适配器。
// 3. **规则优先、模型兜底**:T0 是纯规则(design §15 D3-2 T0),**恒为零模型
// 调用**;T1(灰区调一次轻量模型,§15.1 D3-2)默认**开**,但只在「T0 判不出
// 结论且存在灰区候选」时才发出**至多一次**调用(preinsert_t1.go),并且
// 要有模型出口才成立 —— 两者任一缺席即是结构性零模型调用。每一次这样的
// 调用还要先过**全局限速**(默认 120 次/小时,preinsert_t1_limit.go):
// 超限即跳过 T1、回落 create + `t1_throttled`。
// **同步入库闸(preinsert_gate.go)不碰 T1**,写入路径因此仍是零模型调用。
//
// 代价(如实登记):极短窗口内库里会多一行待裁定的重复条目,随后被软合并
// (status='merged' + merged_into,可 revert,绝不物理删除,design §8)。
@@ -55,22 +59,15 @@ const (
// IngestRuleVersion 是入库前审查规则集的版本号,进每条审查留痕(§15 D3-8)。
// **改判定规则必须同时升它**,否则新结论与旧留痕无法区分。
const IngestRuleVersion = "d3.1"
//
// 版本史:
// - `d3.1` 四态规则层(T0)+ 同步入库闸;
// - `d3.2` 加入 **T1 灰区模型判定**(preinsert_t1.go,design/23 §15.1 D3-2):
// 灰区(相似、互不包含、未被规则否决)在模型出口接上时会被改判成
// merge/modify,因此 `reason` 上出现了 `t1_*` 族。
const IngestRuleVersion = "d3.2"
// 默认值与审计口径。运算路径上不出现裸魔法数字(AGENTS §10)。
const (
// defaultReviewQueueSize 是有界审查队列长度:审查是「尽力而为」的旁路,
// 队列满时宁可少审一条(记忆早已落库),也绝不阻塞写入方。
defaultReviewQueueSize = 256
// defaultReviewScanLimit 是每次审查在同一分区内最多比对的既有条目数。
defaultReviewScanLimit = 200
// defaultReviewTimeout 是单条审查的墙钟上限。
defaultReviewTimeout = 20 * time.Second
// reviewAuditWho / reviewAuditVerb 是审查留痕的审计口径:它是**系统任务**,
// 不是任何角色(design §4.1 C 的同一理由)。
reviewAuditWho = "system/memoryreview"
reviewAuditVerb = "memory.review.ingest"
)
// 调优旋钮(ReviewConfig)与它们的默认值常量在 preinsert_config.go。
var (
// ErrReviewDisabled 在审查被配置显式关闭却又被直接调用时返回。
@@ -86,26 +83,7 @@ var (
ErrReviewItemNotActive = errors.New("memory/review: item is no longer active")
)
// ReviewConfig 携带入库前审查的调优旋钮。零值经 normalize 选取安全默认值。
type ReviewConfig struct {
// Enabled 是总开关。关闭时组合根不建、不接、不跑 —— 写入路径行为逐字
// 回到本次改动之前。
Enabled bool
// QueueSize 是有界队列长度(≤0 用默认)。
QueueSize int
// ScanLimit 是单次审查的候选上限(≤0 用默认)。
ScanLimit int
// Timeout 是单条审查的墙钟上限(≤0 用默认)。
Timeout time.Duration
// Threshold 是候选召回的余弦门,**复用记忆整理的既有阈值**
// (config.MemoryTidy.RecallThreshold,design §13 开放点 2 = 0.80):
// 入库前审查与夜间整理必须用同一把尺子,否则同一对条目会有两个结论。
// 非正用设计稿默认值。
Threshold float64
}
// ReviewDeps 是注入给入库前审查的依赖。Store 必需;其余可空并优雅降级。
// 刻意**没有**模型适配器:T0 是零 LLM 判定,T1 未实现(§15.2 开放点 1)。
type ReviewDeps struct {
// Store 是记忆存储。必需。
Store *Store
@@ -118,6 +96,20 @@ type ReviewDeps struct {
Now func() time.Time
// Log 是基础日志器,来源标记为 "memory/review"。
Log *slog.Logger
// GateCandidates 是**同步入库闸**的候选读取出口。为 nil 时用 store 的
// 生产口径(Store.listReviewCandidates,分区过滤写在 SQL 里)。测试用它
// 注入慢/坏的候选源,验证「预算有界」与「超时 fail-open」这两条。
GateCandidates func(ctx context.Context, p tidyPartition, excludeID string, limit int) ([]MemoryItem, error)
// Judge 是 **T1 灰区判定**的一次性模型调用口(design/23 §15.1 D3-2)。
//
// 为 nil 时 T1 **结构性关闭**(零模型调用)—— 这正是「T1 关闭 ⇒ 零模型调用」
// 从承诺变成事实的那条线。它只取模型适配器的 Chat 一面(T1 不流式、不重试、
// 不并发),注入的是**既有** model.Adapter 的调用口(组合根装配的就是模型栈
// 本身,含按角色路由与主→备→本地降级),不新造任何 LLM 客户端。
//
// ⚠️ 它**只被异步审查层使用**(PreInsertReviewer.Review);同步入库闸
// (preinsert_gate.go)的判定链里根本没有它,写入路径因此零模型调用。
Judge T1Judge
}
// IngestReview 是一条入库前审查留痕(design/23 §15 D3-8)。它**不含记忆正文**:
@@ -143,17 +135,6 @@ type IngestReview struct {
CreatedAt time.Time
}
// ReviewStats 是审查的观测计数(进程内累计,供日志/测试断言)。
type ReviewStats struct {
Reviewed int
Skipped int
Merged int
Modified int
Created int
Dropped int
Failed int
}
// PreInsertReviewer 是入库前审查服务。用 NewPreInsertReviewer 构建,用 Start
// 拉起工作协程、Stop 停它;Review 是同步入口(测试与手动核查用)。
type PreInsertReviewer struct {
@@ -162,6 +143,11 @@ type PreInsertReviewer struct {
log *slog.Logger
now func() time.Time
queue chan MemoryItem
// t1Gate 是 T1 的**全局限速**闸(design/23 §15.4)。挂在实例上而不是包级
// 变量:AGENTS §10 禁包级全局变量,而组合根只建一个审查服务 ⇒ 实例字段
// 事实上就是「全进程共享一个窗口额度」。它与 r.now 同源,因此测试可以整体
// 推进时钟(见 preinsert_t1_limit.go)。
t1Gate *t1RateGate
mu sync.Mutex
running bool
@@ -189,24 +175,31 @@ func NewPreInsertReviewer(cfg ReviewConfig, deps ReviewDeps) (*PreInsertReviewer
log: logger.WithSource(baseTidyLog(deps.Log), "memory/review"),
now: now,
queue: make(chan MemoryItem, cfg.QueueSize),
// 限流闸在 normalize **之后**构建:闸把非正的额度读成「恒放行」,因此
// 归一前的 ≤0 一旦直达它,「回落默认」就会静默变成「无上限」
// (design/12 §N C.5 G3 登记过的失败形态)。这里的 cfg 来自上面那行
// normalizeReviewConfig。
t1Gate: newT1RateGate(cfg.T1PerWindow, cfg.T1Window, now),
}, nil
}
// normalizeReviewConfig 用设计稿默认值填充零值字段。
func normalizeReviewConfig(cfg ReviewConfig) ReviewConfig {
if cfg.QueueSize <= 0 {
cfg.QueueSize = defaultReviewQueueSize
// gateEnabled 报告**同步入库闸**是否生效(任务书规格 3 的开关)。
// 总开关关闭或闸被显式关掉时它都为 false,此时写入路径逐字回到「只插不审」。
func (r *PreInsertReviewer) gateEnabled() bool {
return r != nil && r.cfg.Enabled && !r.cfg.DisableIngestGate
}
// gateScanLimit 返回同步闸实际使用的候选上限:配置值与其默认值的较小者 ——
// 写入路径上的扫描必须比异步队列更紧(任务书规格 1)。
func (r *PreInsertReviewer) gateScanLimit() int {
limit := r.cfg.GateScanLimit
if limit <= 0 {
limit = defaultReviewGateScanLimit
}
if cfg.ScanLimit <= 0 {
cfg.ScanLimit = defaultReviewScanLimit
if r.cfg.ScanLimit > 0 && r.cfg.ScanLimit < limit {
return r.cfg.ScanLimit
}
if cfg.Timeout <= 0 {
cfg.Timeout = defaultReviewTimeout
}
if cfg.Threshold <= 0 {
cfg.Threshold = defaultTidyRecallThreshold
}
return cfg
return limit
}
// SessionID 返回审查运行的会话标识:**复用既有的 sess-memorytidy 装配**
@@ -253,6 +246,19 @@ func (r *PreInsertReviewer) Start(ctx context.Context) {
"timeout", r.cfg.Timeout.String(),
"recall_threshold", r.cfg.Threshold,
"rule_version", IngestRuleVersion,
"sync_gate", r.gateEnabled(),
"gate_budget", r.cfg.GateBudget.String(),
"gate_scan_limit", r.gateScanLimit(),
// T1 是否有模型出口:`t1_judge=false` 有两种成因(配置关掉 / 没接模型),
// 分开写让「以为开着其实没接」在启动日志里就看得见。
"t1_judge", r.t1Enabled(),
"t1_off_by_config", r.cfg.DisableT1,
"t1_timeout", r.t1Timeout().String(),
"t1_max_gray", defaultT1MaxGray,
// T1 的全局限速额度(已 normalize 的**生效值**,不是配置原文):
// 「以为不限流其实限着」与反过来的误解都在启动行上即可排除。
"t1_per_window", r.cfg.T1PerWindow,
"t1_window", r.cfg.T1Window.String(),
"sweep_interval", DefaultIngestReviewGCInterval.String(),
"retention", DefaultIngestReviewRetention.String(),
"max_reviews", DefaultIngestReviewMaxRows)
@@ -370,6 +376,10 @@ func (r *PreInsertReviewer) Review(ctx context.Context, item MemoryItem) (Ingest
return IngestReview{}, fmt.Errorf("memory/review: %w", err)
}
verdict := judgeIngest(item, cands)
// T1(灰区模型判定,design/23 §15.1 D3-2)**只挂在异步审查层**:规则层判不出
// 结论且有灰区候选时,用至多一次模型调用改判。未启用 / 无出口 / 失败一律
// 原样回落 create —— 它没有「改写记忆」的代码路径,因此失败绝不可能丢记忆。
verdict = r.maybeJudgeT1(ctx, item, verdict)
merged, err := r.applyVerdict(ctx, verdict, item, now)
if err != nil {
return IngestReview{}, fmt.Errorf("memory/review: apply: %w", err)
@@ -380,7 +390,7 @@ func (r *PreInsertReviewer) Review(ctx context.Context, item MemoryItem) (Ingest
// 一次已经生效的合并(回滚会把审查看成比结论更权威,那更危险)。
r.log.Warn("memory ingest review record insert failed", "item_id", item.ID, "error", err)
}
r.audit(rev)
r.auditWith(reviewAuditVerb, rev)
r.recordDecision(rev)
return rev, nil
}
@@ -432,44 +442,3 @@ func (r *PreInsertReviewer) buildRecord(item MemoryItem, v ingestVerdict, merged
}
return rev
}
// audit 写一条审计。它**只带决定与 id,不带任何记忆正文**。
func (r *PreInsertReviewer) audit(rev IngestReview) {
result := fmt.Sprintf("decision=%s reason=%s item=%s target=%s degraded=%t score=%.3f",
rev.Decision, rev.Reason, rev.ItemID, rev.TargetID, rev.Degraded, rev.Score)
if r.deps.Audit == nil {
r.log.Debug("memory ingest review audit (no sink configured)", "what", reviewAuditVerb, "result", result)
return
}
r.deps.Audit(reviewAuditWho, reviewAuditVerb, result)
}
// recordDecision 累计观测计数(含降级计数,便于运维判读)。
func (r *PreInsertReviewer) recordDecision(rev IngestReview) {
r.bump(func(s *ReviewStats) {
s.Reviewed++
switch rev.Decision {
case IngestSkip:
s.Skipped++
case IngestMerge:
s.Merged++
case IngestModify:
s.Modified++
default:
s.Created++
}
})
}
// bump 在互斥下修改计数(工作协程与手动 Review 都可能调用)。
func (r *PreInsertReviewer) bump(apply func(*ReviewStats)) {
r.statsMu.Lock()
defer r.statsMu.Unlock()
apply(&r.stats)
}
// reviewStatsLabel 把计数渲染成一条稳定日志串。
func reviewStatsLabel(s ReviewStats) string {
return fmt.Sprintf("reviewed=%d skip=%d merge=%d modify=%d create=%d dropped=%d failed=%d",
s.Reviewed, s.Skipped, s.Merged, s.Modified, s.Created, s.Dropped, s.Failed)
}
@@ -97,7 +97,11 @@ func TestReviewNeverCrossesPersonaOrScope(t *testing.T) {
}
// TestReviewIsNonBlockingAndAsyncPathWorks 是 design/23 §15 D3-3/D3-4:
// 写入方**绝不等待**审查;审查在后台(工作协程)按队列跑完。
// 写入方**绝不等待**异步审查;审查在后台(工作协程)按队列跑完。
//
// ⚠️ 2026-10-07 起 D3 是**两层**:写路径上有同步入库闸(preinsert_gate.go,
// 重复当场落 merged),闸没拦下的才进这条异步队列。因此本用例**显式关闸**
// (DisableIngestGate)来单独验异步那一层 —— 否则它测的会是闸而不是队列。
func TestReviewIsNonBlockingAndAsyncPathWorks(t *testing.T) {
t.Parallel()
ctx := context.Background()
@@ -105,9 +109,21 @@ func TestReviewIsNonBlockingAndAsyncPathWorks(t *testing.T) {
base := tidyTestTime(t)
existing := seedTidyItem(t, store, reviewAt(fixtureHabitShort, base, 0.9))
r := newTestReviewer(t, store, stubEmbedder{vecs: map[string]embedding.Vector{
fixtureHabitShort: {1, 0},
}}, 0.80)
asyncOnly, err := NewPreInsertReviewer(ReviewConfig{
Enabled: true, QueueSize: 8, ScanLimit: 50, Timeout: 5 * time.Second,
Threshold: 0.80, DisableIngestGate: true,
}, ReviewDeps{
Store: store,
Embedder: stubEmbedder{vecs: map[string]embedding.Vector{
fixtureHabitShort: {1, 0},
}},
Now: func() time.Time { return tidyTestTime(t) },
Log: testLogger(),
})
if err != nil {
t.Fatalf("NewPreInsertReviewer: %v", err)
}
r := asyncOnly
store.AttachIngestReviewer(r)
// 未 Start:写入方投递后**立即返回**,审查尚未发生(异步性证据)。
start := time.Now()
@@ -116,7 +132,7 @@ func TestReviewIsNonBlockingAndAsyncPathWorks(t *testing.T) {
t.Fatalf("写入等了 %s,审查绝不能阻塞写入方", elapsed)
}
if got := readReviewRow(t, store, dup.ID); got.status != tidyStatusActive {
t.Fatalf("审查在写入方返回前就生效了,说明它是同步的:%+v", got)
t.Fatalf("关闸后审查仍在写入方返回前生效了:%+v", got)
}
r.Start(ctx)
@@ -235,8 +251,12 @@ func TestReviewDisabledChangesNothing(t *testing.T) {
}
// TestReviewReusesTidySessionAndStaysZeroLLM 是 design/23 §15 D3 的装配口径:
// 复用既有 sess-memorytidy 装配(不造第二形态会话),且 T1 默认关 + 本切片
// 未实现 ⇒ 灰区(相似但不包含)**不调任何模型**,只按 T0 回落 create。
// 复用既有 sess-memorytidy 装配(不造第二形态会话),且**没接模型出口**时
// ⇒ 灰区(相似但不包含)**不调任何模型**,只按 T0 回落 create。
//
// ⚠️ 2026-10-07 起 T1(灰区模型判定,preinsert_t1.go)默认**开**:本条用例的
// 「零模型调用」来自**结构性缺席**(ReviewDeps.Judge 为 nil),不是来自默认关。
// 「T1 开启时真的会调一次模型」由 preinsert_t1_test.go 覆盖。
func TestReviewReusesTidySessionAndStaysZeroLLM(t *testing.T) {
t.Parallel()
ctx := context.Background()
+137
View File
@@ -0,0 +1,137 @@
package memory
import "time"
// 本文件是 D3「入库前审查」(design/23 §15.1 D3)的**调优面**:默认值常量、
// ReviewConfig 的旋钮与它们的零值归一化。它从 preinsert.go 拆出来只为守
// AGENTS §10 的文件行数上限(≤500 行)与「模块按职责拆源文件」那条规范 ——
// 口径逐字未变。
//
// 边界:本文件只管「旋钮长什么样 + 零值取什么默认值」,不认识服务本体
// (PreInsertReviewer 的队列/工作协程在 preinsert.go),也不认识任何开关的
// 运维来源(env 与 `$DATA_DIR/config/memory-tidy.json` 都在组合根)。
// 默认值与审计口径。运算路径上不出现裸魔法数字(AGENTS §10)。
const (
// defaultReviewQueueSize 是有界审查队列长度:审查是「尽力而为」的旁路,
// 队列满时宁可少审一条(记忆早已落库),也绝不阻塞写入方。
defaultReviewQueueSize = 256
// defaultReviewScanLimit 是每次审查在同一分区内最多比对的既有条目数。
defaultReviewScanLimit = 200
// defaultReviewTimeout 是单条审查的墙钟上限。
defaultReviewTimeout = 20 * time.Second
// reviewAuditWho / reviewAuditVerb 是审查留痕的审计口径:它是**系统任务**,
// 不是任何角色(design §4.1 C 的同一理由)。
reviewAuditWho = "system/memoryreview"
reviewAuditVerb = "memory.review.ingest"
// defaultT1PerWindow / defaultT1Window 是 T1 **全局限速**的默认额度
// (design/23 §15.4,主人 2026-10-07 裁定「加一层保守的全局速率限流」)。
//
// 推算依据(2026-10-07):现网节奏是每天 700–1000 条新记忆(≈25–42 条/小时),
// 而 T1 只在「T0 判不出结论**且**存在灰区候选」时才发调用 —— 每条新记忆至多换
// 一次调用。取 **120 次 / 60 分钟**,即在最忙的那一小时也留 ≈3–5 倍余量:
// 正常节奏下这个闸几乎碰不到;只有「召回/规则突然把大批条目判成灰区」这类
// 异常形态才会触顶,而那正是要拦的东西。超限不是错误:跳过 T1、回落 create、
// 留痕 `t1_throttled`,记忆一条不丢、一个字不改。
defaultT1PerWindow = 120
defaultT1Window = 60 * time.Minute
)
// ReviewConfig 携带入库前审查的调优旋钮。零值经 normalize 选取安全默认值。
type ReviewConfig struct {
// Enabled 是总开关。关闭时组合根不建、不接、不跑 —— 写入路径行为逐字
// 回到本次改动之前。
Enabled bool
// QueueSize 是有界队列长度(≤0 用默认)。
QueueSize int
// ScanLimit 是单次审查的候选上限(≤0 用默认)。
ScanLimit int
// Timeout 是单条审查的墙钟上限(≤0 用默认)。
Timeout time.Duration
// Threshold 是候选召回的余弦门,**复用记忆整理的既有阈值**
// (config.MemoryTidy.RecallThreshold,design §13 开放点 2 = 0.80):
// 入库前审查与夜间整理必须用同一把尺子,否则同一对条目会有两个结论。
// 非正用设计稿默认值。
Threshold float64
// DisableIngestGate 关闭**同步入库闸**(任务书规格 3 的开关)。
//
// 它是**反向**字段,且这是刻意的:bool 的零值必须落在「默认行为」上 ——
// 同步闸本机制的默认取向(主人 2026-10-07「把记忆整理功能接到昔涟和记忆库
// 中间做审查,由整理功能进行入库」),若写成正向的 EnableIngestGate,任何没
// 显式配过的地方(含测试与组合根构造的一半配置)都会被读成「关掉」,与默认
// 相反。要一键回到「只插不审」(= 本次改动之前的异步行为),写
// `$DATA_DIR/config/memory-tidy.json` 的 `{"ingestGate": false}`(**零新
// env 键**:`.env` 已冻结,design/13 §2.1)。
DisableIngestGate bool
// GateBudget 是同步入库闸的**总墙钟预算**(任务书规格 1:写入路径不许变慢)。
// 非正用代码默认值 defaultReviewGateBudget。超预算一律 fail-open(照常插入 +
// 留痕),绝不静默丢。
GateBudget time.Duration
// GateScanLimit 是同步入库闸的候选扫描上限。它比异步队列更紧(后者
// ScanLimit 默认 200),因为它在**写入路径**上:非正用代码默认值
// defaultReviewGateScanLimit,且最终还会被 cfg.ScanLimit 收紧取小。
GateScanLimit int
// DisableT1 关闭 **T1 灰区模型判定**(design/23 §15.1 D3-2:灰区调一次轻量
// 模型,只许回 merge/modify/keep + 理由、禁产新内容)。
//
// 它是**反向**字段,理由与 DisableIngestGate 逐字相同:bool 的零值必须落在
// 「默认行为」上,而 T1 的代码默认值是**开**(主人 2026-10-07:要的就是「真的
// 走记忆管理会话」,T0 仍是第一道零成本闸)。若写成正向的 EnableT1,任何没
// 显式配过的地方(含测试与组合根构造的一半配置)都会读成「关掉」,与默认相反。
// 要一键回到纯规则(零模型调用),写 `$DATA_DIR/config/memory-tidy.json` 的
// `{"t1Judge": false}`(**零新 env 键**:`.env` 已冻结,design/13 §2.1)。
DisableT1 bool
// T1Timeout 是**单次** T1 模型调用的墙钟上限。非正用代码默认值
// defaultT1Timeout;最终还会被 cfg.Timeout 收紧取小(见 t1Timeout)。
T1Timeout time.Duration
// T1PerWindow 是 T1 的**全局**窗口额度(次)—— 不是每条各自的限制,而是
// 全进程共享的一个窗口:T1Window 内至多这么多次模型调用,超限即跳过 T1 并
// 回落 `create`(reason=`t1_throttled`,design/23 §15.4)。
//
// 非正用代码默认值 defaultT1PerWindow。**刻意没有「无上限」语义**:
// design/12 §N C.5 G3 登记过那条静默失败形态(未归一的 ≤0 直达滑窗 ⇒ 被读成
// 「无限制」,没有报错、没有 WARN);要完全不花钱请用
// `$DATA_DIR/config/memory-tidy.json` 的 `{"t1Judge": false}` 关掉 T1。
//
// 额度**只被真正的模型调用消耗**(闸放行才记账,被拒的那次不占),因此
// 「窗口过了就恢复」是结构性成立的。
T1PerWindow int
// T1Window 是 T1PerWindow 的时间窗。非正用代码默认值 defaultT1Window。
T1Window time.Duration
}
// normalizeReviewConfig 用设计稿默认值填充零值字段。
func normalizeReviewConfig(cfg ReviewConfig) ReviewConfig {
if cfg.QueueSize <= 0 {
cfg.QueueSize = defaultReviewQueueSize
}
if cfg.ScanLimit <= 0 {
cfg.ScanLimit = defaultReviewScanLimit
}
if cfg.Timeout <= 0 {
cfg.Timeout = defaultReviewTimeout
}
if cfg.Threshold <= 0 {
cfg.Threshold = defaultTidyRecallThreshold
}
if cfg.GateBudget <= 0 {
cfg.GateBudget = defaultReviewGateBudget
}
if cfg.GateScanLimit <= 0 {
cfg.GateScanLimit = defaultReviewGateScanLimit
}
if cfg.T1Timeout <= 0 {
cfg.T1Timeout = defaultT1Timeout
}
// 🔴 限流的两项必须在这里回落(design/12 §N C.5 G3 的教训):闸是照着
// 「非正 = 恒放行」实现的,未归一的 ≤0 直达它就会把「回落默认」**静默**变成
// 「无上限」。因此 NewPreInsertReviewer 保证闸在 normalize **之后**构建。
if cfg.T1PerWindow <= 0 {
cfg.T1PerWindow = defaultT1PerWindow
}
if cfg.T1Window <= 0 {
cfg.T1Window = defaultT1Window
}
return cfg
}
Loaded 100 of 222 files, more files were not shown because too many files have changed in this diff. Show more