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
81 changed files with 7825 additions and 749 deletions

No files matched your search

+37 -7
View File
@@ -1,6 +1,6 @@
# Cyrene v2 客户端 API 契约 # Cyrene v2 客户端 API 契约
> **契约版本:`v0.89`**(上一版 `v0.88`,其条目见 §6;`v0.87` / `v0.86` / `v0.85` / `v0.84` / `v0.83` / `v0.82` / `v0.81` / `v0.80` / `v0.79` / `v0.78` / `v0.77` / `v0.76` / `v0.75` / `v0.74` / `v0.73` / `v0.72` / `v0.71` 及更早见本文件 §6,`v0.67` 及更早的基线已归档、只读:`Docs/archive/api-client.v0.65.md`)。 > **契约版本:`v0.90`**(上一版 `v0.89`,其条目见 §6;`v0.88` / `v0.87` / `v0.86` / `v0.85` / `v0.84` / `v0.83` / `v0.82` / `v0.81` / `v0.80` / `v0.79` / `v0.78` / `v0.77` / `v0.76` / `v0.75` / `v0.74` / `v0.73` / `v0.72` / `v0.71` 及更早见本文件 §6,`v0.67` 及更早的基线已归档、只读:`Docs/archive/api-client.v0.65.md`)。
> **读者**:Web 前端 / 移动端原生客户端(含 Android)/ IM 桥接等**跨平台客户端开发者**。 > **读者**:Web 前端 / 移动端原生客户端(含 Android)/ IM 桥接等**跨平台客户端开发者**。
> **唯一真源 = 当前代码**(`internal/services/gateway/*` 等)。本文档由代码逐条核对得出;与代码不符时**以代码为准**,并回改本文档。 > **唯一真源 = 当前代码**(`internal/services/gateway/*` 等)。本文档由代码逐条核对得出;与代码不符时**以代码为准**,并回改本文档。
> **旧的超详细版已归档、只读**:`Docs/archive/api-client.v0.65.md`(412 KB,含 v0.28→v0.65 全部历史条目)与 `Docs/archive/api-reference.v0.65.md`(170 KB,后端全量字段级文档)。归档的原因是**结构性臃肿 + 历史条目并列 + 长期漂移**;本文档**不复制**那段历史,需要考古时去归档。 > **旧的超详细版已归档、只读**:`Docs/archive/api-client.v0.65.md`(412 KB,含 v0.28→v0.65 全部历史条目)与 `Docs/archive/api-reference.v0.65.md`(170 KB,后端全量字段级文档)。归档的原因是**结构性臃肿 + 历史条目并列 + 长期漂移**;本文档**不复制**那段历史,需要考古时去归档。
@@ -255,8 +255,8 @@
| C16 | POST | `/api/v1/admin/llm` | 添加/更新 LLM provider(写 `models.json` + 热重载) | | C16 | POST | `/api/v1/admin/llm` | 添加/更新 LLM provider(写 `models.json` + 热重载) |
| C17 | POST | `/api/v1/admin/llm/test` | 连通性测试 | | C17 | POST | `/api/v1/admin/llm/test` | 连通性测试 |
| C18 | POST | `/api/v1/admin/llm/reset-from-env` | 重置为 env 默认(切片 R1,v0.62;body 必须 `{"confirm":true}`,否则 `400` 且一个字节都不动;审计 `llm.resetFromEnv`) | | C18 | POST | `/api/v1/admin/llm/reset-from-env` | 重置为 env 默认(切片 R1,v0.62;body 必须 `{"confirm":true}`,否则 `400` 且一个字节都不动;审计 `llm.resetFromEnv`) |
| C19 | POST | `/api/v1/admin/channels/open` | 打开渠道(v0.66 增 `accountId`,见下注) | | C19 | POST | `/api/v1/admin/channels/open` | 打开渠道(v0.66 增 `accountId`,见下注;**v0.90 起 `connected` 为真实状态**,并按需回可选 `pending`/`ready`/`reason`) |
| C20 | POST | `/api/v1/admin/channels/close` | 关闭渠道(v0.66 增 `accountId`,见下注) | | C20 | POST | `/api/v1/admin/channels/close` | 关闭渠道(v0.66 增 `accountId`,见下注;**v0.90 起 `connected` 取真实 `IsConnected()`**) |
| C21 | POST | `/api/v1/admin/channels/test` | 渠道连通测试 | | C21 | POST | `/api/v1/admin/channels/test` | 渠道连通测试 |
| C22 | GET | `/api/v1/admin/channels/{platform}/login-qr` | 取登录二维码(获取凭据,故 owner-only) | | C22 | GET | `/api/v1/admin/channels/{platform}/login-qr` | 取登录二维码(获取凭据,故 owner-only) |
| C23 | GET | `/api/v1/admin/channels/{platform}/login-status` | 查扫码登录状态 | | C23 | GET | `/api/v1/admin/channels/{platform}/login-status` | 查扫码登录状态 |
@@ -291,7 +291,7 @@
> - `id` 与 `empty=1` 同时给出 ⇒ `400 bad_request`;**两者都缺 ⇒ `400 bad_request`**(绝不把「id 缺失」默认解释成删无 id 条目 —— 那是一次没有字面证据的批量删除)。`?empty=` / `?empty=0` / `?empty=false` 一律是「否」。 > - `id` 与 `empty=1` 同时给出 ⇒ `400 bad_request`;**两者都缺 ⇒ `400 bad_request`**(绝不把「id 缺失」默认解释成删无 id 条目 —— 那是一次没有字面证据的批量删除)。`?empty=` / `?empty=0` / `?empty=false` 一律是「否」。
> - 响应:`{data:{persisted, path, platform, accountId, empty, removed}}`(`empty`/`removed` 是 v0.66 新增);`removed` = 实际删除的条目数。删空后平台键整个消失(与按 id 删除的收尾口径一致)。 > - 响应:`{data:{persisted, path, platform, accountId, empty, removed}}`(`empty`/`removed` 是 v0.66 新增);`removed` = 实际删除的条目数。删空后平台键整个消失(与按 id 删除的收尾口径一致)。
### 2.4 v0.67 登记的管理面(渠道事件查询 + 账号在线状态)—— **(a) 已实现 / (b) 已实现**;v0.78 追加 **(c) iLink 凭据三态** ### 2.4 v0.67 登记的管理面(渠道事件查询 + 账号在线状态)—— **(a) 已实现 / (b) 已实现**;v0.78 追加 **(c) iLink 凭据三态**;v0.90 追加 **(d) 渠道连接态三态**
> ⚠️ **本节两项都已落地**(账号在线状态 `52dac45`;渠道事件查询面本切片 E6):形状取自设计文档(`design/26 §4.5`、`§2.6`),落地时已按代码核对。**§2.0 的计数(12 + 11 + 40 = 63)原本不含本节端点**;两个渠道事件端点已登记进 `routes.go`(`owner|admin` 档),下版改文档时应把它们并入 §2.1/§2.2 并更新计数。**本行留在此处仅为与 v0.67 冻结版对照**。 > ⚠️ **本节两项都已落地**(账号在线状态 `52dac45`;渠道事件查询面本切片 E6):形状取自设计文档(`design/26 §4.5`、`§2.6`),落地时已按代码核对。**§2.0 的计数(12 + 11 + 40 = 63)原本不含本节端点**;两个渠道事件端点已登记进 `routes.go`(`owner|admin` 档),下版改文档时应把它们并入 §2.1/§2.2 并更新计数。**本行留在此处仅为与 v0.67 冻结版对照**。
@@ -334,6 +334,15 @@
- 🔴 **同域但未实现的面**:`GET /api/v1/admin/channels` 的 `accounts[]` 元素**尚未**带**每账号** `authState`/`authDetail`(缺 `AdminChannelAccount` 两字段与合并器的接线)—— 见 §7.2。 - 🔴 **同域但未实现的面**:`GET /api/v1/admin/channels` 的 `accounts[]` 元素**尚未**带**每账号** `authState`/`authDetail`(缺 `AdminChannelAccount` 两字段与合并器的接线)—— 见 §7.2。
- ⚠️ **未确证(真机)**:真实 `errcode=-14` 至今**从未在日志里观测到** ⇒ `rejected` 分支只有单测 / httptest 证据,**没有真机证据**(见 §7.4-26)。 - ⚠️ **未确证(真机)**:真实 `errcode=-14` 至今**从未在日志里观测到** ⇒ `rejected` 分支只有单测 / httptest 证据,**没有真机证据**(见 §7.4-26)。
#### (d) 渠道连接态三态:`pending` / `ready` / `reason`(**v0.90 新增,已实现**)
- **出现位置**:`POST /api/v1/admin/channels/open` 与 `.../close` 的响应体、`GET /api/v1/admin/channels` 的**平台行**与 `accounts[]` **账号行**(三处同名同义)。
- **三个键都是可选的,取不到就整键缺席**(同 `needsRescan` / `lastError` 口径):`connected:true` 时,或适配器没有这条状态面时,`pending` / `ready` / `reason` 一律不出现。
- `reason` 只可能在 `pending:true` 时出现,是**已脱敏**的原因短句(`access_token` 等凭据绝不出现在其中)。
- **判据是结构化字段**(适配器自报的闭集状态),不是正文、不是提示词 —— 客户端据此把卡片画成「已连接 / 连接中(原因)/ 就绪待接入 / 未连接」四档。
- **口径归属**:**适配器自报**(`ConnectOutcome`,网关经类型断言探测);核心 `sdk.Channel` 契约**刻意不扩展** ⇒ 没实现该状态面的平台响应**逐字节不变**。
- ⚠️ **为什么 `ready` 不带 `reason`**:它本来就不是坏消息(监听已就绪、就等对面来连),给它编一个原因等于把「等接入」说成「出问题了」。
--- ---
## 3. WebSocket `/ws/chat` ## 3. WebSocket `/ws/chat`
@@ -1487,20 +1496,41 @@ curl -s -X POST -H "X-API-Key: $KEY" \
> 只保留最近若干版(**v0.60 起**);更早的全部历史见归档 `Docs/archive/api-client.v0.65.md` §5(v0.28 → v0.59)。 > 只保留最近若干版(**v0.60 起**);更早的全部历史见归档 `Docs/archive/api-client.v0.65.md` §5(v0.28 → v0.59)。
> 每版一句话 + 三档标注。**三档定义见 [§0.4](#04-三档标注惯例)。** > 每版一句话 + 三档标注。**三档定义见 [§0.4](#04-三档标注惯例)。**
### v0.89(当前契约版本) ### v0.90(当前契约版本)
**「渠道连接态三态」版 —— 管理面新增三个可选键,并把 `connected` 的语义从「动作被受理」纠正为「真的连上了」**(依据:2026-10-13 主人报「改了 QQ 适配器配置后点连接,卡片仍显示未连接,却提示已连接」;实现推库 `b4d55f4`。)
- 🔴 **纠正的谎**:`POST /api/v1/admin/channels/open` 此前在 `ch.Connect` 返回 nil 后**硬编码** `connected:true`。而 `Connect` 返回 nil 只说明「这次调用没有致命失败」——**ws+client 首连失败刻意非致命**(OneBot 实现可能比 Cyrene 后启动,重试交给读循环),**ws+server 则只需监听就绪**,这两种情况下 `IsConnected()` 都是 `false`。前端又只按「请求成功」写死「已连接」⇒ 管理面把「后端受理了」说成了「渠道连上了」。
- **三态闭集(口径落在适配器自身,网关只做映射)**:
| 状态 | 含义 | 线上投影 |
|---|---|---|
| `connected` | 真连上(client 拨号成功 / HTTP 传输已装配就绪) | `connected: true`,三个可选键**全缺席** |
| `pending` | 已在后台重试、当前未连上(仅 ws+client 有重试循环) | `connected: false` + `pending: true` + `reason` |
| `ready` | 已绑定监听、就绪等对端接入(ws+server);`IsConnected()` 同样为 `false`,但**不是失败** | `connected: false` + `ready: true`(**不带** `reason`) |
| 无意见 | 未启动,或已启动但无可解释的就绪面 | 退回只看 `IsConnected()`,三个键全缺席 |
- **新增可选键(三处形状同名同义)**:① `POST /api/v1/admin/channels/open` 与 `.../close` 的响应体;② `GET /api/v1/admin/channels` 的**平台行**;③ 同端点的**账号行**(`accounts[]`)。`reason` 已**脱敏**(形状级 → 已知明文替换 → 剥 `access_token=` 查询参数),**凭据绝不出现**。
- 🔴 **行为变更(须客户端跟进)**:`open` 的 `connected` 现在是**真实连接状态**,**不再恒为 `true`**;`close` 报**真实** `IsConnected()`,不再假定「调用成功 ⇒ 一定断开了」。老客户端若只看 HTTP 2xx 就提示「已连接」,在 `pending` / `ready` 下会继续骗人 —— 请改读 `connected` 加上述两个键,把卡片画成「已连接 / 连接中(原因)/ 就绪待接入 / 未连接」四档。
- **退回行为(无破坏性)**:没实现该状态面的平台(kook / telegram / webhook / weixin-iLink)响应**逐字节不变**(既有四键 + 零变换)。
- **计数不变**:无新端点 ⇒ §2.0 维持 `14 + 25 + 40 = 79`。详见 **§2.4(d)**。
### v0.89
**「助手消息的行内标签白名单」版 —— 只影响渲染口径,**服务端 API 形状一字未改****(依据:主人 2026-10-07「她在聊天消息中插入了 html/md 标签,能否增加网页前端对此类标签的基本渲染支持?」;Web 端实现推库 `2dae469`。) **「助手消息的行内标签白名单」版 —— 只影响渲染口径,**服务端 API 形状一字未改****(依据:主人 2026-10-07「她在聊天消息中插入了 html/md 标签,能否增加网页前端对此类标签的基本渲染支持?」;Web 端实现推库 `2dae469`。)
- 🔴 **背景(实测)**:她输出的**不是 Markdown**(最近 12 条 assistant 消息里 `**` 记号 0 个、列表行 0 个),而是**有限的一小组 HTML 标签**:`<p>` 96 次、`<action>` 34 次、**`<b>` 6 次**。`<p>` / `<action>` 各端早已处理,**`<b>` 此前没有任何一端处理 ⇒ 用户直接看到裸标签**。 - 🔴 **背景(实测)**:她输出的**不是 Markdown**(最近 12 条 assistant 消息里 `**` 记号 0 个、列表行 0 个),而是**有限的一小组 HTML 标签**:`<p>` 96 次、`<action>` 34 次、**`<b>` 6 次**。`<p>` / `<action>` 各端早已处理,**`<b>` 此前没有任何一端处理 ⇒ 用户直接看到裸标签**。
- **新增渲染口径(跨端一致)**:助手消息正文里,以下**行内标签**应被渲染成对应样式,而**不是**按字面显示: - **新增渲染口径(跨端一致)**:助手消息正文里,以下**行内标签**应被渲染成对应样式,而**不是**按字面显示:
`b` / `strong`(粗)· `i` / `em`(斜)· `code`(等宽)。 `b` / `strong`(粗)· `i` / `em`(斜)· `code`(等宽)· `u`(下划线)。
⚠️ **同日晚修订:白名单由八项收窄为上面五项**(去掉 `u`/`s`/`br`)—— `u`/`s` 在她 100 条输出里 0 命中,不值得让各端为它们返工;`br` 见下一节(它不是行内标签)。 ⚠️ **白名单最终定为上面六项** —— 主人 2026-10-07 明确「**下划线确实可以保留**」⇒ `u` 加回;`s`(删除线)**仍不在其内**(她 100 条输出里 0 命中);`br` **不是行内标签**,见下一节(它是分隔符)。
- **🔴 `<br>` 是「气泡/消息分隔符」,不是行内标签(同日追加,主人口径)**:主人原话「**对消息内的 `<br>` 始终处理为多条消息隔断**」。 - **🔴 `<br>` 是「气泡/消息分隔符」,不是行内标签(同日追加,主人口径)**:主人原话「**对消息内的 `<br>` 始终处理为多条消息隔断**」。
- **语义与 `<p>` 同级**:`<br>` 两边的文本**必须成为两条独立的消息**(IM 侧)/ **两个独立的气泡**(Web 侧),**而不是**同一条消息里的一个换行。 - **语义与 `<p>` 同级**:`<br>` 两边的文本**必须成为两条独立的消息**(IM 侧)/ **两个独立的气泡**(Web 侧),**而不是**同一条消息里的一个换行。
- `<br>` / `<br/>` / `</br>` 三种写法**等价**(HTML5 口径)。 - `<br>` / `<br/>` / `</br>` 三种写法**等价**(HTML5 口径)。
- **空气泡照旧丢弃**(与既有 `<p>` 空气泡口径一致);`<br>` 两侧都是空白 ⇒ 不产生多余空气泡。 - **空气泡照旧丢弃**(与既有 `<p>` 空气泡口径一致);`<br>` 两侧都是空白 ⇒ 不产生多余空气泡。
- ⚠️ **影响面(各端须知)**:一条含 `<br>` 的回复在 IM 上会变成**多条消息**(与 `<p>` 一样受既有的气泡间隔节流约束)。 - ⚠️ **影响面(各端须知)**:一条含 `<br>` 的回复在 IM 上会变成**多条消息**(与 `<p>` 一样受既有的气泡间隔节流约束)。
- ⚠️ **实现状态**:本条发出时**实现跟进中**(Web 侧 `2dae469` 当时把 `<br>` 当行内换行,正在改;IM 出站侧当时连行内标签都还没剥,正在改)。**以本契约为准,不以当日实现为准。** - ⚠️ **实现状态**:本条发出时**实现跟进中**(Web 侧 `2dae469` 当时把 `<br>` 当行内换行,正在改;IM 出站侧当时连行内标签都还没剥,正在改)。**以本契约为准,不以当日实现为准。**
- **🆕 非 chat 模式(单卡正文)的口径(同日追加)**:工作 / 代码 / 文档 / 首页 / 视觉 / 语音 / 智能体 / 设置 等**单卡正文**界面**不是气泡模型** ⇒ 那些模式里:**`<br>` ⇒ 一个真换行(不切分)**、**行内白名单照常出样式**、白名单外仍逐字原样。⚠️ **这些模式里也不许出现字面标签**。各模式**既有的较强排版约定优先**(例:工作模式的按行切清单、代码模式的 `<pre>` 等宽块 —— 通用规则不得覆盖它们)。
- **🔴 `<br>` 在哪**不**分隔(同日追加,跨端口径)**:`<br>` **只在「普通文本层」分隔**。以下两种位置**不分隔**:① **角色注记内部**(`<action>` / `<narration>` 里)—— 否则注记正文会被切成聊天气泡、把本该隐藏的旁白发给对方;② **白名单行内元素内部**(尤其 `<code>`)—— 它是**成对**元素,从中间切开必然制造不平衡、触发「整段降级」,反而把裸标签露给用户。
- **🔴 三条硬口径(各端必须照此实现,否则跨端显示不一致)**: - **🔴 三条硬口径(各端必须照此实现,否则跨端显示不一致)**:
1. **白名单外一律按纯文本原样显示** —— `<script>` 显示成字面量 `&lt;script&gt;`,**字符一个不丢**; 1. **白名单外一律按纯文本原样显示** —— `<script>` 显示成字面量 `&lt;script&gt;`,**字符一个不丢**;
2. **属性一律不解析** —— `<b class=x onclick=y>` 只取标签名,**属性不得变成任何 DOM/视图属性**; 2. **属性一律不解析** —— `<b class=x onclick=y>` 只取标签名,**属性不得变成任何 DOM/视图属性**;
+29
View File
@@ -17,6 +17,7 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/channelbridge" "git.yeij.top/AskaEth/Cyrene_v2/internal/services/channelbridge"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user" "git.yeij.top/AskaEth/Cyrene_v2/internal/user"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
) )
// ownerRegistrar 实现 gateway.OwnerRegistrar:它把一个渠道账号 id 持久化进主人 // ownerRegistrar 实现 gateway.OwnerRegistrar:它把一个渠道账号 id 持久化进主人
@@ -57,6 +58,30 @@ func (r *ownerRegistrar) RegisterChannelOwner(_ context.Context, platform, accou
// 加载失败则为 nil,gateway 会退回「真实落盘 + 提示需重启」,绝不假装热生效。 // 加载失败则为 nil,gateway 会退回「真实落盘 + 提示需重启」,绝不假装热生效。
func (r *ownerRegistrar) LiveOwnerProfile() *user.OwnerProfile { return r.owner } func (r *ownerRegistrar) LiveOwnerProfile() *user.OwnerProfile { return r.owner }
// channelCaps 把能力注册表适配成 channelbridge.ChannelRegistry(热重载同步注册表的
// 窄端口)。替换时写入的形状与 wireChannels 启动注册**逐字相同**
// (Name/Kind/Description/Value):Description 取适配器名、Value 取适配器本身 ——
// adminListChannels 把 Value 断言成 sdk.Channel、面板按注册名精确匹配,
// 形状一旦不一致,渠道就会从面板上消失。
type channelCaps struct{ caps *registry.Registry }
func (c channelCaps) ReplaceChannel(ch sdk.Channel) error {
if ch == nil {
return fmt.Errorf("wire channel registry: %w", registry.ErrInvalidArgument)
}
if err := c.caps.ReplaceChannel(ch); err != nil {
return fmt.Errorf("wire channel registry %q: %w", ch.Name(), err)
}
return nil
}
func (c channelCaps) RemoveChannel(name string) error {
if err := c.caps.Remove(name); err != nil {
return fmt.Errorf("wire channel registry %q: %w", name, err)
}
return nil
}
// wireChannels 依环境配置构造四个 IM 渠道适配器,把它们注册进能力注册表(管理 // wireChannels 依环境配置构造四个 IM 渠道适配器,把它们注册进能力注册表(管理
// API 据此枚举),并接进渠道 bridge——让已配置的适配器连上,把入站消息按 // API 据此枚举),并接进渠道 bridge——让已配置的适配器连上,把入站消息按
// session→actor→loop→reply 路由。缺凭据的适配器保持未连接(只记日志,绝不作 // session→actor→loop→reply 路由。缺凭据的适配器保持未连接(只记日志,绝不作
@@ -122,6 +147,10 @@ func (a *app) wireChannels() error {
// EngageFor 留空(P2a);按群覆盖随 P2c 到来。 // EngageFor 留空(P2a);按群覆盖随 P2c 到来。
// Factory 让面板保存时热替换某平台的适配器而无需重启(design/04 §3.1)。 // Factory 让面板保存时热替换某平台的适配器而无需重启(design/04 §3.1)。
Factory: factory, Factory: factory,
// Channels 让热重载把新适配器写回能力注册表:管理面板(渠道卡片、账号在线
// 状态、open/close 的目标解析)读的就是注册表,不同步它就只能看到旧实例,
// 于是卡片永远「未连接」、点「连接」驱动的是旧 URL 的那把。
Channels: channelCaps{caps: a.caps},
// ChannelPersona 从工厂构建所用的同一份配置里解析账号的渠道级角色 // ChannelPersona 从工厂构建所用的同一份配置里解析账号的渠道级角色
// (design/22 §2 方案 b);bridge 按已连接的适配器缓存它,并在每次重载时刷新。 // (design/22 §2 方案 b);bridge 按已连接的适配器缓存它,并在每次重载时刷新。
ChannelPersona: factory.Persona, ChannelPersona: factory.Persona,
+65 -18
View File
@@ -11,17 +11,22 @@ import (
// 本文件把记忆整理(DevDocs/design/23-记忆整理与去重 §4)接进组合根。 // 本文件把记忆整理(DevDocs/design/23-记忆整理与去重 §4)接进组合根。
// //
// 四件事只在这里定: // 四件事只在这里定:
// - **形态**:低峰定期后台任务(memory.TidyService 的心跳),不是会话内派 // - **形态**:定期后台任务(安静时段降频)的心跳(memory.TidyService),不是
// subagent。它在 main.go 与 backthink 并列启动、在 close 里先于关库停止; // 会话内派 subagent。它在 main.go 与 backthink 并列启动、在 close 里先于关库停止;
// - **依赖面**:只给它记忆存储与 embedder —— 服务侧拿不到任何会话/历史读数, // - **依赖面**:只给它记忆存储与 embedder —— 服务侧拿不到任何会话/历史读数,
// 因此 design §4.1「绝不碰主会话上下文」是结构性成立的,而不是靠自觉; // 因此 design §4.1「绝不碰主会话上下文」是结构性成立的,而不是靠自觉;
// - **审计**:接到网关的 AuditEvent(design §8 的 memory.tidy.*),出口在调用 // - **审计**:接到网关的 AuditEvent(design §8 的 memory.tidy.*),出口在调用
// 时才解析 a.gw —— 网关比本服务晚组装(wireFrontend),与 onToolAudit // 时才解析 a.gw —— 网关比本服务晚组装(wireFrontend),与 onToolAudit
// 同一手法。未就绪时只记日志,绝不假装写过审计; // 同一手法。未就绪时只记日志,绝不假装写过审计;
// - **运行期调优**(2026-10-07 新增):从 `$DATA_DIR/config/memory-tidy.json` // - **运行期调优**(2026-10-07 新增):从 `$DATA_DIR/config/memory-tidy.json`
// 读「自动落地开关 + 每轮上限」。它是 `.env` 冻结(design/13 §2.1)之后新旋钮 // 读「自动落地开关 + 每轮上限」,以及(同日追加)**整理频率**四项
// 的唯一去处,**零新 env 键**;读不到/解不开一律回落代码默认值并记 WARN // (intervalSec / quietIntervalSec / quietStartHour / quietEndHour)。它是
// (坏配置绝不能让自动档变成「无限制」)。 // `.env` 冻结(design/13 §2.1)之后新旋钮的唯一去处,**零新 env 键**;面板优先、
// `.env` 兜底,键缺位或值不可用一律回落下一档并记 WARN(坏配置绝不能让自动档
// 变成「无限制」,也不能让整理频率变成忙轮询);
// - **启动播种**(2026-10-07 追加,主人「后端**自动创建**独立的面板配置文件**要做**」):
// 那个文件不存在时由后端自己写一份「当前生效值」进去(seedMemoryTidyPanel),
// 使「面板优先」从第一次启动起就成立,而不是靠 `.env` 一直兜着。
// //
// 刻意**不做**的一件事(design §4.1 F): // 刻意**不做**的一件事(design §4.1 F):
// - 不把 sess-memorytidy 加进 flowPub 的图源白名单(默认关:整理是系统任务, // - 不把 sess-memorytidy 加进 flowPub 的图源白名单(默认关:整理是系统任务,
@@ -29,6 +34,12 @@ import (
func (a *app) wireMemoryTidy() error { func (a *app) wireMemoryTidy() error {
cfg := memory.TidyConfigFromConfig(a.cfg.MemoryTidy) cfg := memory.TidyConfigFromConfig(a.cfg.MemoryTidy)
a.applyMemoryTidyTuning(&cfg) a.applyMemoryTidyTuning(&cfg)
// 启动播种(主人 2026-10-07:「后端**自动创建**独立的面板配置文件**要做**」):
// 文件不存在/空白 ⇒ 把**此刻的生效值**写成面板的一等来源,此后 `.env` 只兜底。
// 放在 applyMemoryTidyTuning **之后**是刻意的:那一刻的 cfg 就是
// 「面板 > `.env` > 代码默认」的结果(面板缺失时即 `.env` + 默认),播它 ⇒ 行为
// 零变化。文件已存在(含损坏)⇒ 一个字节都不动(幂等,见 runtimeconfig.SeedMemoryTidy)。
a.seedMemoryTidyPanel(cfg)
svc, err := memory.NewTidyService(cfg, memory.TidyDeps{ svc, err := memory.NewTidyService(cfg, memory.TidyDeps{
Store: a.memStore, Store: a.memStore,
Embedder: a.embedder, Embedder: a.embedder,
@@ -42,29 +53,65 @@ func (a *app) wireMemoryTidy() error {
return nil 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 侧的 // applyMemoryTidyTuning 把 `$DATA_DIR/config/memory-tidy.json` 叠到 env 侧的
// TidyConfig 上(见 runtimeconfig.MemoryTidyTuning 的零值口径): // TidyConfig 上(见 runtimeconfig.MemoryTidyTuning 的零值口径):
// - 文件里**没写** autoApply ⇒ 保持代码默认(开); // - 文件里**没写** autoApply ⇒ 保持代码默认(开);
// - 写 `false` ⇒ DisableAutoApply = true(一键回到「只产建议」,不必改代码); // - 写 `false` ⇒ DisableAutoApply = true(一键回到「只产建议」,不必改代码);
// - autoApplyPerRound ≤ 0 ⇒ 不覆盖,交给 normalizeTidyConfig 填默认上限。 // - autoApplyPerRound ≤ 0 ⇒ 不覆盖,交给 normalizeTidyConfig 填默认上限;
// - **调度四键**(intervalSec / quietIntervalSec / quietStartHour /
// quietEndHour)⇒ 交 memory.ApplyTidyScheduleTuning 逐键解析(面板 >
// `.env` > 代码默认),坏值回落 + WARN。
// //
// 读不到/解不开时只记 WARN 并保持 env 侧的值:那正是「回落代码默认值」。 // 读不到/解不开时只记 WARN 并保持 env 侧的值:那正是「回落下一档」——坏文件时
// tuning 是零值(四个调度键都是 nil),因此 ApplyTidyScheduleTuning 一个键都不覆盖,
// env 值原样留着(`.env` 也缺位时由 normalizeTidyConfig 落到代码默认)。
func (a *app) applyMemoryTidyTuning(cfg *memory.TidyConfig) { func (a *app) applyMemoryTidyTuning(cfg *memory.TidyConfig) {
tuning, path, err := a.readMemoryTidyTuning() tuning, path, err := a.readMemoryTidyTuning()
log := logger.WithSource(a.log, "memory/tidy")
if err != nil { if err != nil {
logger.WithSource(a.log, "memory/tidy").Warn( log.Warn(
"memory tidy tuning unreadable, falling back to code defaults", "memory tidy tuning unreadable, falling back to the env layer (.env values kept)",
"path", path, "error", err) "path", path, "error", err)
return } else {
cfg.DisableAutoApply = tuning.AutoApplyOff()
if tuning.AutoApplyPerRound > 0 {
cfg.AutoApplyPerRound = tuning.AutoApplyPerRound
}
} }
cfg.DisableAutoApply = tuning.AutoApplyOff() // 调度四键在两种情形下都要过一遍:成功时按面板值覆盖,失败时 tuning 是零值
if tuning.AutoApplyPerRound > 0 { // ⇒ 原样返回(= 保持 env 值),因此不必为它再分一条路径。
cfg.AutoApplyPerRound = tuning.AutoApplyPerRound *cfg = memory.ApplyTidyScheduleTuning(*cfg, tuning, log)
} // 这里打的是**归一化之前**的值(quiet_interval_sec 可能还是 0 = 「缺省,等于
logger.WithSource(a.log, "memory/tidy").Info( // intervalSec」):真正生效的两档周期由 TidyService.Start 的启动日志报。
"memory tidy tuning loaded", // panel_decoded = 「文件缺失/空白/解析成功」三者之一(LoadMemoryTidyTuning 把
"path", path, "auto_apply", !cfg.DisableAutoApply, // 前两者都当作「还没配过」而不是错误),为 false 即坏文件 ⇒ 四个键都回落 `.env`。
"auto_apply_per_round", cfg.AutoApplyPerRound) 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`(**只读**)。 // readMemoryTidyTuning 读一次 `$DATA_DIR/config/memory-tidy.json`(**只读**)。
+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)
}
}
+72
View File
@@ -57,6 +57,11 @@ func TestMemoryTidyIsWired(t *testing.T) {
// 来,**零新 env 键**(`.env` 已冻结,design/13 §2.1)。少了这一句, // 来,**零新 env 键**(`.env` 已冻结,design/13 §2.1)。少了这一句,
// 那个文件就成了「写了没人读」的摆设,而自动档仍然开着 —— 静默。 // 那个文件就成了「写了没人读」的摆设,而自动档仍然开着 —— 静默。
"runtimeconfig.LoadMemoryTidyTuning", "runtimeconfig.LoadMemoryTidyTuning",
// 启动播种(2026-10-07 追加,主人「后端**自动创建**独立的面板配置文件
// **要做**」):少了这两句,那个文件在生产里**根本不存在** ⇒「面板优先」
// 永远是空话,生效的仍是 `.env` 兜底那一档 —— 而不会有任何用例变红。
"a.seedMemoryTidyPanel(cfg)",
"runtimeconfig.SeedMemoryTidy(",
// 依赖面里**没有**任何会话/历史读数 —— 这是「绝不碰主会话上下文」 // 依赖面里**没有**任何会话/历史读数 —— 这是「绝不碰主会话上下文」
// 的结构性保证,而不是靠自觉。 // 的结构性保证,而不是靠自觉。
"Audit: a.auditMemoryTidy", "Audit: a.auditMemoryTidy",
@@ -253,3 +258,70 @@ func TestApplyMemoryTidyTuning_ReadsDataDirConfig(t *testing.T) {
t.Fatalf("坏配置时 cfg = %+v, want 回落零值(默认值由 normalize 填)", cfg) 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)
}
}
@@ -2,7 +2,7 @@ import type { AdminChannelAccount } from "../../../lib/types";
import { Button, Tag } from "../../../components/primitives"; import { Button, Tag } from "../../../components/primitives";
import { authKeyOf } from "../channelFieldRules"; import { authKeyOf } from "../channelFieldRules";
import { adapterNameOf } from "./channelUtils"; import { adapterNameOf } from "./channelUtils";
import { accountActionGate, accountOnlineStatus, shortAccountID, type ChannelStatus } from "./channelStatus"; import { accountActionGate, accountOnlineStatus, channelConnectStateOf, connectStateView, shortAccountID, type ChannelStatus, type ChannelStatusTone } from "./channelStatus";
/** 一条渠道账号在活动适配器列表里的行。 /** 一条渠道账号在活动适配器列表里的行。
* *
@@ -46,7 +46,7 @@ export function AccountRow({
const raw = account.config?.[authKey]; const raw = account.config?.[authKey];
const authSet = typeof raw === "string" && raw.trim() !== ""; const authSet = typeof raw === "string" && raw.trim() !== "";
const gate = accountActionGate(status, account.connected); const gate = accountActionGate(status, account.connected);
const state = connectionTag(account.connected); const state = connectionTag(account);
const online = accountOnlineStatus(account.accountOnline); const online = accountOnlineStatus(account.accountOnline);
const blocker = status.kind === "unknown" ? "读取不到连接状态(后端失联),已禁用" : undefined; const blocker = status.kind === "unknown" ? "读取不到连接状态(后端失联),已禁用" : undefined;
@@ -67,7 +67,7 @@ export function AccountRow({
id {short} id {short}
</span> </span>
)} )}
<Tag tone={account.connected === true ? "ok" : account.connected === false ? "err" : "neutral"}>{state}</Tag> <Tag tone={state.tone} title={state.hint}>{state.label}</Tag>
{/* 账号在**平台侧**的在线状态 —— 与上一枚「连接」标签说的是两件事:OneBot 的 {/* 账号在**平台侧**的在线状态 —— 与上一枚「连接」标签说的是两件事:OneBot 的
进程/WS 可以一直连着,而背后的 QQ 账号已掉线(消息收不到也发不出)。 进程/WS 可以一直连着,而背后的 QQ 账号已掉线(消息收不到也发不出)。
两枚标签刻意不合并:合并后「我们连着但账号掉了」这一个事故现场就无迹可寻。 两枚标签刻意不合并:合并后「我们连着但账号掉了」这一个事故现场就无迹可寻。
@@ -127,11 +127,23 @@ export function AccountRow({
); );
} }
/** 账号级连接状态的呈现:已连接 / 未连接 / 状态未知(后端没报告该账号的活适配器)。 */ /** 账号级连接状态的呈现:已连接 / 连接中 / 等待接入 / 未连接 / 状态未知
function connectionTag(connected: boolean | undefined): string { * (后端没报告该账号的活适配器)。
if (connected === true) return "已连接"; *
if (connected === false) return "未连接"; * 「连接中」与「等待接入」来自后端自报的连接态三态(见 ChannelConnectState)。
return "状态未知"; * 没有它们时,一个正在后台重试的 ws+client 账号会被画成红色的「未连接」,主人就会
* 以为它坏了 —— 那正是 2026-10-07 事故的另一半(一半是弹「已连接」,一半是卡片
* 永远显示「未连接」)。两态都用非错误腔:它们不是结论,是进行中。 */
function connectionTag(account: AdminChannelAccount): {
label: string;
tone: ChannelStatusTone;
hint: string | undefined;
} {
if (account.connected === true) return { label: "已连接", tone: "ok", hint: undefined };
const view = connectStateView(channelConnectStateOf(account), account.reason);
if (view !== null) return { label: view.label, tone: view.tone, hint: view.hint };
if (account.connected === false) return { label: "未连接", tone: "err", hint: undefined };
return { label: "状态未知", tone: "neutral", hint: undefined };
} }
/** 「本次会作用在哪一条账号」的小字。这是修掉「连了谁」这一困惑的关键文案: /** 「本次会作用在哪一条账号」的小字。这是修掉「连了谁」这一困惑的关键文案:
@@ -154,4 +154,24 @@ function buttonHTML(html: string, label: string): string {
ok(has(down, "账号在平台上"), "离线标签的悬停说明点明这是账号在平台上的状态"); ok(has(down, "账号在平台上"), "离线标签的悬停说明点明这是账号在平台上的状态");
} }
// ── ⑤ 重试中 / 就绪等接入:不许画成红色的「未连接」 ────────────────────────────
// 2026-10-07 事故的另一半:点了「连接」之后卡片一直显示「未连接」。ws+client 的首连
// 失败刻意非致命(OneBot 实现可能比 Cyrene 后启动),所以那不是故障,是「正在连」;
// ws+server 则是「就绪等对端接入」。两者都必须与静态的「未连接」分开画。
{
const REASON = "qq-obv11: dial onebot ws ws://localhost:10301/: connection refused";
const retrying = renderRow({ id: "9616b27a", config: {}, connected: false, pending: true, reason: REASON });
ok(has(retrying, ">连接中</span>"), "重试中的账号画「连接中」");
ok(!has(retrying, ">未连接</span>"), "重试中不许画成「未连接」(它是进行中,不是结论)");
ok(has(retrying, REASON), "重试中的原因随标签悬停一并给出(说不出为什么就等于没说)");
const ready = renderRow({ id: "9616b27a", config: {}, connected: false, ready: true });
ok(has(ready, ">等待接入</span>"), "监听就绪的账号画「等待接入」");
ok(!has(ready, ">未连接</span>"), "就绪等接入不许画成「未连接」");
// 回归:后端没这条状态面的账号逐字不变。
const plain = renderRow({ id: "9616b27a", config: {}, connected: false });
ok(has(plain, ">未连接</span>"), "没有状态面的账号仍是「未连接」");
}
console.log(`channel account row render test: ${checks} checks passed`); console.log(`channel account row render test: ${checks} checks passed`);
@@ -0,0 +1,213 @@
// 「点了连接之后,面板到底说了什么」的真实断言(2026-10-07 UI 撒谎事故的回归测试)。
//
// 为什么必须存在:旧代码只按「请求成功」判断 —— `adminChannelOpen` 一 resolve 就弹
// 「已连接」,而 ws+client 的首连失败**刻意非致命**(OneBot 实现可能比 Cyrene 后启动),
// 于是「后端受理了」被说成了「渠道连上了」。这里不看组件、不靠记忆:既直接驱动
// `createChannelActions`(stub 掉 globalThis.fetch,读它真正写进 notice 的那句话),
// 也直接驱动卡片用的纯函数 `channelStatus`。
//
// 跑法(与 channelActions.test.ts 同款 esbuild + node,react-dom 的 CJS 需要 require):
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/admin/modules/channels/channelConnectNotice.test.ts \
// --bundle --platform=node --format=cjs \
// --outfile=/tmp/cyrene-channel-connect-notice.test.cjs \
// && node /tmp/cyrene-channel-connect-notice.test.cjs
//
// `tsc --noEmit`(npm run build 的一部分)会对本文件做类型检查,因此它只从项目内
// 导入、不 import node:*。
import type { AdminChannelRecord } from "../../../lib/types";
import { channelConnectStateOf, channelStatus } from "./channelStatus";
import { connectDisconnectNotice, createChannelActions } from "./useChannelActions";
let checks = 0;
function ok(cond: boolean, what: string): void {
checks += 1;
if (!cond) throw new Error(`FAILED: ${what}`);
}
function notOk(cond: boolean, what: string): void {
ok(!cond, what);
}
function eq(got: unknown, want: unknown, what: string): void {
checks += 1;
if (got !== want) throw new Error(`FAILED: ${what}(got ${JSON.stringify(got)}, want ${JSON.stringify(want)})`);
}
const REASON = "qq-obv11: dial onebot ws ws://localhost:10301/: connection refused";
// ── ① 纯函数:提示文案只看**响应**,不看请求是否成功 ─────────────────────────────
{
// 本次事故的正题:请求 200 + connected:false + pending:true ⇒ 绝不是「已连接」。
const pending = connectDisconnectNotice(true, "qq-obv11", {
name: "qq-obv11",
connected: false,
pending: true,
reason: REASON,
});
notOk(pending.includes("已连接"), `重试中不许说「已连接」,got ${JSON.stringify(pending)}`);
ok(pending.includes("正在连接"), `重试中必须说「正在连接」,got ${JSON.stringify(pending)}`);
ok(pending.includes(REASON), `重试中必须带上原因,got ${JSON.stringify(pending)}`);
// 连上了才是「已连接」。
eq(
connectDisconnectNotice(true, "qq-obv11:9616b27a", { name: "qq-obv11", connected: true }),
"已连接 qq-obv11:9616b27a",
"真连上才说「已连接」(并回显实际驱动的适配器)",
);
// server 已就绪、等待对端接入:既不是「已连接」,也不是一句静态的「未连接」。
const ready = connectDisconnectNotice(true, "qq-obv11", { name: "qq-obv11", connected: false, ready: true });
notOk(ready.includes("已连接"), `就绪等接入不许说「已连接」,got ${JSON.stringify(ready)}`);
ok(ready.includes("已就绪"), `就绪等接入必须说清楚,got ${JSON.stringify(ready)}`);
// 后端没有这条状态面(两个键都缺):如实说「尚未连上」,绝不乐观。
const bare = connectDisconnectNotice(true, "kook", { name: "kook", connected: false });
notOk(bare.includes("已连接"), `连不上不许说「已连接」,got ${JSON.stringify(bare)}`);
ok(bare.includes("尚未连上"), `必须如实说尚未连上,got ${JSON.stringify(bare)}`);
// 断开同样不乐观:后端报连接仍在,就不能说「已断开」。
eq(
connectDisconnectNotice(false, "kook", { name: "kook", connected: false }),
"已断开 kook",
"真断开才说「已断开」",
);
const stuck = connectDisconnectNotice(false, "kook", { name: "kook", connected: true });
notOk(stuck.includes("已断开"), `仍未断开不许说「已断开」,got ${JSON.stringify(stuck)}`);
}
// ── ② 卡片状态:三态在**真实渲染逻辑**里与「未连接」分开 ────────────────────────
{
const pendingRecord: AdminChannelRecord = {
name: "qq-obv11",
connected: false,
pending: true,
reason: REASON,
};
const pending = channelStatus([pendingRecord], null, pendingRecord);
eq(pending.kind, "connecting", "重试中的卡片是 connecting,不是 disconnected");
eq(pending.label, "连接中", "重试中的卡片写「连接中」");
eq(pending.tone, "warn", "重试中用警示色而不是错误色(它不是故障)");
ok(pending.hint !== null && pending.hint.includes(REASON), "重试中的卡片带上原因");
notOk(pending.label.includes("未连接"), "重试中不许画成「未连接」");
const readyRecord: AdminChannelRecord = { name: "qq-obv11", connected: false, ready: true };
const ready = channelStatus([readyRecord], null, readyRecord);
eq(ready.kind, "ready", "就绪等接入的卡片是 ready");
eq(ready.label, "等待接入", "就绪等接入的卡片写「等待接入」");
eq(ready.tone, "neutral", "就绪等接入不用错误色");
// 回归:后端没这条状态面的平台逐字不变。
const plain: AdminChannelRecord = { name: "kook", connected: false };
const disconnected = channelStatus([plain], null, plain);
eq(disconnected.kind, "disconnected", "没有状态面的平台仍是 disconnected");
eq(disconnected.label, "未连接", "没有状态面的平台仍写「未连接」");
eq(disconnected.tone, "err", "没有状态面的平台仍用错误色");
// 真连上优先:pending 与 connected 同时给出时不许被降级成「连接中」。
const both: AdminChannelRecord = { name: "qq-obv11", connected: true, pending: true };
eq(channelStatus([both], null, both).kind, "connected", "connected=true 优先于 pending");
eq(channelConnectStateOf({}), null, "两个键都缺省 = 没有这条状态面");
eq(channelConnectStateOf({ pending: true }), "pending", "pending 优先");
eq(channelConnectStateOf({ ready: true }), "ready", "ready 单独给出也认");
}
// ── ③ 真实请求:createChannelActions 写进 notice 的那句话 ───────────────────────
// 与前两段不同,这里驱动的是面板按钮真正调用的那一层,并用 stub 的 fetch 回一个
// 「请求成功但没连上」的响应 —— 旧代码在这条路径上会说「已连接」。
interface Captured {
url: string;
method: string;
}
const captured: Captured[] = [];
// 每次请求各取一个响应:三个动作在同一个 tick 里发出,若共用一个可变变量,
// 先发的两次会读到第三次的响应(那样断言的就是错的东西)。
const payloadQueue: unknown[] = [];
function installStubs(): void {
const store = new Map<string, string>();
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
removeItem: (k: string) => void store.delete(k),
},
});
Object.defineProperty(globalThis, "fetch", {
configurable: true,
value: (url: string, init?: RequestInit) => {
captured.push({ url: String(url), method: String(init?.method ?? "GET") });
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve({ data: payloadQueue.shift() ?? {} }),
} as unknown as Response);
},
});
}
installStubs();
const notices: string[] = [];
const errors: string[] = [];
let reloads = 0;
const actions = createChannelActions({
log: () => {},
setBusy: () => {},
setError: (e) => {
if (e != null) errors.push(e);
},
setNotice: (n) => notices.push(n ?? ""),
reload: () => {
reloads += 1;
},
});
// 场景 A:后端回「请求成功但仍在重试」。
payloadQueue.push({
name: "qq-obv11",
resolved: "qq-obv11:9616b27a",
accountId: "9616b27a",
connected: false,
pending: true,
reason: REASON,
});
actions.toggle("qq-obv11", true, "9616b27a");
// 场景 B:后端回「真连上」。
payloadQueue.push({ name: "qq-obv11", resolved: "qq-obv11:9616b27a", connected: true });
actions.toggle("qq-obv11", true, "9616b27a");
// 场景 C:断开成功。
payloadQueue.push({ name: "qq-obv11", resolved: "qq-obv11:9616b27a", connected: false });
actions.toggle("qq-obv11", false, "9616b27a");
setTimeout(() => {
const pendingNotice = notices.find((n) => n.includes("正在连接"));
ok(pendingNotice !== undefined, `重试中必须提示「正在连接」,got ${JSON.stringify(notices)}`);
ok(
!notices[0]?.includes("已连接"),
`第一个动作(仍在重试)绝不能说「已连接」,got ${JSON.stringify(notices[0])}`,
);
ok(notices[0]?.includes(REASON) === true, `重试提示必须带上原因,got ${JSON.stringify(notices[0])}`);
ok(
notices.some((n) => n.includes("已连接 qq-obv11:9616b27a")),
`真连上时必须回显实际驱动的适配器,got ${JSON.stringify(notices)}`,
);
ok(
notices.some((n) => n.includes("已断开 qq-obv11:9616b27a")),
`断开成功要说「已断开」,got ${JSON.stringify(notices)}`,
);
eq(errors.length, 0, "这些路径都不该产生错误提示");
eq(reloads, 3, "每次动作后都刷新列表(卡片状态要靠它才不再撒谎)");
eq(captured[0].url, "/api/v1/admin/channels/open", "连接仍打到既有路径");
eq(captured[2].url, "/api/v1/admin/channels/close", "断开仍打到既有路径");
console.log(`channel connect notice test: ${checks} checks passed`);
}, 20);
@@ -7,6 +7,8 @@ import type { AdminChannelRecord } from "../../../lib/types";
* as plain node — see channelStatus.test.ts. */ * as plain node — see channelStatus.test.ts. */
export type ChannelStatusKind = export type ChannelStatusKind =
| "connected" | "connected"
| "connecting"
| "ready"
| "disconnected" | "disconnected"
| "unconfigured" | "unconfigured"
| "unknown" | "unknown"
@@ -29,6 +31,55 @@ export interface ChannelStatus {
/** How much of the channel list we actually know right now. */ /** How much of the channel list we actually know right now. */
export type ChannelListState = "ready" | "pending" | "unknown"; export type ChannelListState = "ready" | "pending" | "unknown";
/** 适配器自报的**连接态三态**(后端可选键 `pending` / `ready`,契约 v0.68;见
* internal/services/gateway/admin_channel_connect_outcome.go)。
*
* 为什么需要它:`connected:false` 至少有两种互不相同的现状 —— 「已在后台重试」
* (ws+client 的首连失败刻意非致命:OneBot 实现可能比 Cyrene 后启动)与「已就绪、
* 等待对端接入」(server 模式监听已绑定)。用一句静态的「未连接」覆盖两者,主人就会
* 把「正在连接」读成故障,或者(更糟)反方向把「没连上」读成「已连接」——
* 2026-10-07 的 UI 撒谎事故正是这么来的。
*
* `null` = 适配器没有这条状态面(后端两个键都缺省)⇒ 调用方退回只看 `connected`
* 布尔,**绝不**凭空编一个状态。 */
export type ChannelConnectState = "pending" | "ready" | null;
/** 把后端可选的 `pending` / `ready` 两键映成上面的三态。两者都缺省 ⇒ null。 */
export function channelConnectStateOf(record: {
pending?: boolean;
ready?: boolean;
}): ChannelConnectState {
if (record.pending === true) return "pending";
if (record.ready === true) return "ready";
return null;
}
/** 未连上时那句如实的话:`pending` 说「正在重试」并把原因带上(原因缺失时也不假装
* 知道为什么),`ready` 说「等待接入」。两者都不是失败腔。`null` 表示后端没有这条
* 状态面 ⇒ 调用方退回自己那套说法。 */
export function connectStateView(
state: ChannelConnectState,
reason?: string,
): { kind: ChannelStatusKind; label: string; tone: ChannelStatusTone; hint: string } | null {
if (state === "pending") {
return {
kind: "connecting",
label: "连接中",
tone: "warn",
hint: reason ? `正在后台重试连接:${reason}` : "已在后台重试连接(尚未连上)",
};
}
if (state === "ready") {
return {
kind: "ready",
label: "等待接入",
tone: "neutral",
hint: "监听已就绪,等待对端接入(尚未连上)",
};
}
return null;
}
/** List-level tri-state: a list we never read plus a recorded error is *unknown*, /** List-level tri-state: a list we never read plus a recorded error is *unknown*,
* never "empty". `data == null` with no error is the first load still running. */ * never "empty". `data == null` with no error is the first load still running. */
export function channelListState<T>( export function channelListState<T>(
@@ -91,6 +142,24 @@ export function channelStatus(
} }
const connected = record.connected === true; const connected = record.connected === true;
if (!connected) {
// 「没连上」有三种互不相同的现状,只有后端自报时才敢细说(见 ChannelConnectState):
// pending 是「正在重试」、ready 是「就绪等接入」。两者都不是「未连接」这样一个
// 静态结论,因此不给错误腔,也不把「断开」摆出来(会话本来就没建立)。
const state = channelConnectStateOf(record);
const view = connectStateView(state, record.reason);
if (view !== null) {
return {
kind: view.kind,
label: view.label,
tone: view.tone,
hint: error != null ? `${view.hint};本次刷新失败,数据可能过期:${error}` : view.hint,
stale: error != null,
canConnect: true,
canDisconnect: false,
};
}
}
return { return {
kind: connected ? "connected" : "disconnected", kind: connected ? "connected" : "disconnected",
label: connected ? "已连接" : "未连接", label: connected ? "已连接" : "未连接",
@@ -3,6 +3,8 @@ import type { ChannelStatus, ChannelStatusKind } from "./channelStatus";
const dot: Record<ChannelStatusKind, string> = { const dot: Record<ChannelStatusKind, string> = {
connected: "bg-[var(--mu-ok)]", connected: "bg-[var(--mu-ok)]",
connecting: "bg-[var(--mu-warn)] mu-blink",
ready: "bg-[var(--mu-text-lo)]",
disconnected: "bg-[var(--mu-err)]", disconnected: "bg-[var(--mu-err)]",
unconfigured: "bg-[var(--mu-text-lo)]", unconfigured: "bg-[var(--mu-text-lo)]",
unknown: "bg-[var(--mu-text-lo)]", unknown: "bg-[var(--mu-text-lo)]",
@@ -10,9 +12,10 @@ const dot: Record<ChannelStatusKind, string> = {
}; };
/** The one honest place an adapter's connection state is rendered, so list page, /** The one honest place an adapter's connection state is rendered, so list page,
* detail page and overview can never drift apart: 已连接 / 未连接 / 状态未知. * detail page and overview can never drift apart: 已连接 / 连接中 / 等待接入 /
* `showHint` off renders the compact form for dense surfaces (table row, card) — * 未连接 / 状态未知. `showHint` off renders the compact form for dense surfaces
* the surrounding banner already carries the failure reason there. */ * (table row, card) — there the hint (e.g. the retry reason behind「连接中」) rides
* along as a hover title instead of taking a line of its own. */
export function ChannelStatusTag({ export function ChannelStatusTag({
status, status,
showHint = true, showHint = true,
@@ -24,7 +27,7 @@ export function ChannelStatusTag({
}): React.ReactNode { }): React.ReactNode {
return ( return (
<span className={`inline-flex flex-wrap items-center gap-2 ${className}`}> <span className={`inline-flex flex-wrap items-center gap-2 ${className}`}>
<Tag tone={status.tone}> <Tag tone={status.tone} title={status.hint ?? undefined}>
<span className={`inline-block h-1.5 w-1.5 rounded-full ${dot[status.kind]}`} /> <span className={`inline-block h-1.5 w-1.5 rounded-full ${dot[status.kind]}`} />
{status.label} {status.label}
</Tag> </Tag>
@@ -4,6 +4,7 @@ import {
adminChannelOpen, adminChannelOpen,
adminDeleteChannelAccount, adminDeleteChannelAccount,
adminDeleteEmptyChannelAccounts, adminDeleteEmptyChannelAccounts,
type ChannelActionResult,
} from "../../../lib/api"; } from "../../../lib/api";
import { useAuditStore } from "../../audit"; import { useAuditStore } from "../../audit";
@@ -19,6 +20,35 @@ export interface ChannelActionDeps {
reload: () => void; reload: () => void;
} }
/** 连接/断开返回后要说给主人的那句话。
*
* **只看响应,不看请求是否成功**:请求 200 只说明后端受理了这次调用,不说明渠道真的
* 连上了 —— ws+client 的首连失败刻意非致命(OneBot 实现可能比 Cyrene 后启动),此时
* 后端回 `connected:false` + `pending:true` + `reason`。把这句写死成「已连接」,
* 正是 2026-10-07 那次 UI 撒谎的根因。断开同理:报后端回的真实连接状态,而不是假定
* 「调用成功就一定断了」。
*
* 纯函数、与 React 解耦,因此「响应 connected:false + pending:true ⇒ 提示里没有
* 『已连接』」可以在 node 侧被直接断言(见 channelActions.test.ts)。 */
export function connectDisconnectNotice(
connect: boolean,
who: string,
res?: ChannelActionResult,
): string {
if (!connect) {
return res?.connected === true ? `${who} 仍未断开(后端报告连接仍存在)` : `已断开 ${who}`;
}
if (res?.connected === true) return `已连接 ${who}`;
if (res?.pending === true) {
return res.reason
? `${who} 正在连接(${res.reason})`
: `${who} 正在连接(尚未连上,已在后台重试)`;
}
if (res?.ready === true) return `${who} 已就绪,等待对端接入`;
// 既没连上、也没有可解释的状态面:如实说「尚未连上」——请求被受理不等于连上了。
return `${who} 尚未连上(连接请求已受理,可稍后在列表中查看状态)`;
}
/** 与 React 解耦的渠道动作集合(连接/断开/删除账号/删除无 id 条目)。 */ /** 与 React 解耦的渠道动作集合(连接/断开/删除账号/删除无 id 条目)。 */
export interface ChannelActions { export interface ChannelActions {
/** 连接/断开。`accountId` 给出时**只**作用于该账号(多账号平台逐条操作); /** 连接/断开。`accountId` 给出时**只**作用于该账号(多账号平台逐条操作);
@@ -42,7 +72,7 @@ export function createChannelActions(deps: ChannelActionDeps): ChannelActions {
// 回显**实际被驱动**的适配器:多账号平台上请求里的平台名说明不了「连的是哪一条」, // 回显**实际被驱动**的适配器:多账号平台上请求里的平台名说明不了「连的是哪一条」,
// 后端把解析结果放在 resolved 里,这里如实说出来。 // 后端把解析结果放在 resolved 里,这里如实说出来。
const who = res?.resolved ?? name; const who = res?.resolved ?? name;
setNotice(`${connect ? "已连接" : "已断开"} ${who}`); setNotice(connectDisconnectNotice(connect, who, res));
reload(); reload();
}) })
.catch((e: unknown) => { .catch((e: unknown) => {
-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 { Tag } from "../primitives";
import { Icon } from "../Icon"; import { Icon } from "../Icon";
import { useAppStore } from "../../store/useAppStore"; import { useAppStore } from "../../store/useAppStore";
import { renderContent } from "../../lib/renderContent"; import { CARD_RENDER, renderContent as renderContentIn } from "../../lib/renderContent";
/** /**
* Mode-specific assistant message bodies (design/07 §2). The shared outer * Mode-specific assistant message bodies (design/07 §2). The shared outer
@@ -13,6 +13,14 @@ import { renderContent } from "../../lib/renderContent";
* role under different modes looks different while a single session mixes them. * role under different modes looks different while a single session mixes them.
*/ */
/** 本文件(**非 chat 单卡正文**面)的统一正文入口:`<br>` ⇒ **换行**,不切气泡 ——
* 工作 / 代码 / 文档 / 首页 / 视觉 / 语音 / 智能体 / 设置这些卡片没有「多条气泡」的概念
* (主人 2026-10-07 晚「非聊天模式按照应当有的专业卡片和格式来显示」)。绑定在这里写一次,
* 免得各卡片各写一份口径、日后漂移;chat 气泡不走这里(`components/chat.tsx` 另有一层)。 */
function renderContent(text: string): ReactNode[] {
return renderContentIn(text, CARD_RENDER);
}
function shell(children: ReactNode): ReactNode { function shell(children: ReactNode): ReactNode {
return ( return (
<div className="liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5 text-sm text-[var(--mu-text-hi)]"> <div className="liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5 text-sm text-[var(--mu-text-hi)]">
@@ -218,7 +226,11 @@ export function SettingsMessageBody({ message }: { message: ChatMessage }): Reac
return shell(fallbackText(message)); return shell(fallbackText(message));
} }
/** Map a mode to its message body component for the dispatcher. */ /** Map a mode to its message body component for the dispatcher.
* 除 `chat` 外的八个模式都是**单卡正文**(本文件所有卡片都绑定 `CARD_RENDER`)。`default`
* 落到的就是 `chat` —— 那条路只在 `SplitChatBubble` 把「切不出气泡的空正文」交下来时走
* (见 `components/chat.tsx`),此时 content 必为空/纯空白,因此这里的 `<br>` 口径无从生效;
* chat 模式的 `<br>` 仍由气泡切分层处理,绝不会在这里变成换行。 */
export function modeBody(mode: Mode, message: ChatMessage): ReactNode { export function modeBody(mode: Mode, message: ChatMessage): ReactNode {
switch (mode) { switch (mode) {
case "code": return <CodeMessageBody message={message} />; case "code": return <CodeMessageBody message={message} />;
+15 -1
View File
@@ -23,12 +23,26 @@ export interface ChannelConfigSaveResult {
/** Result of POST /admin/channels/open|close. `resolved` is the adapter that was /** Result of POST /admin/channels/open|close. `resolved` is the adapter that was
* actually driven (`qq-obv11:9616b27a`), which is what the panel must show — * actually driven (`qq-obv11:9616b27a`), which is what the panel must show —
* the request's `name` alone never tells a multi-account platform which account * the request's `name` alone never tells a multi-account platform which account
* was touched. */ * was touched.
*
* `connected` 说的是**现在通不通**,不是「这次请求成没成功」:ws+client 的首连失败
* 刻意非致命(OneBot 实现可能比 Cyrene 后启动),因此 `Connect` 返回 nil 并不等于连上了。
* `pending`/`ready`(后端可选,契约 v0.68)进一步说明「没连上是哪种没连上」:
*
* - `pending: true` → 已在后台重试、当前未连上 ⇒ 提示须是「正在连接(reason)」,
* **绝不是**「已连接」,也不是一句静态的「未连接」;
* - `ready: true` → 已就绪、等待对端接入(server 模式)⇒ 同样不是失败;
* - `reason` → 未连上的原因(已脱敏)。
*
* 三者缺省 = 该适配器没有这条状态面 ⇒ 只看 `connected`。 */
export interface ChannelActionResult { export interface ChannelActionResult {
name: string; name: string;
resolved?: string; resolved?: string;
accountId?: string; accountId?: string;
connected: boolean; connected: boolean;
pending?: boolean;
ready?: boolean;
reason?: string;
} }
/** open/close 的请求体。**accountId 缺省时逐字维持旧契约 `{name}`**(老后端/老客户端 /** open/close 的请求体。**accountId 缺省时逐字维持旧契约 `{name}`**(老后端/老客户端
+41 -20
View File
@@ -5,47 +5,65 @@
// 处理,Web 渲染为气泡外的灰斜体注记),**行内**则偶发 `<b>` 之类强调标签。 // 处理,Web 渲染为气泡外的灰斜体注记),**行内**则偶发 `<b>` 之类强调标签。
// 后两类此前没有处理 ⇒ `<b>18:35</b>` 会把字面量 `&lt;b&gt;` 直接显示给用户。 // 后两类此前没有处理 ⇒ `<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`)。 // 因此**绝不**把它当 HTML 注入(无 `innerHTML` / `dangerouslySetInnerHTML`)。
// 这里的做法是**解析成结构化节点**(`InlineNode[]`),交给 React 元素渲染 —— React // 这里的做法是**解析成结构化节点**(`InlineNode[]`),交给 React 元素渲染 —— React
// 对文本节点默认转义,天然安全。规则: // 对文本节点默认转义,天然安全。规则:
// ① 只认**行内**白名单标签,块级一律不支持(段落仍由 §4.16 的 `<p>` 机制表达); // ① 只认**行内**白名单元素(六项:`b` / `strong` / `i` / `em` / `code` / `u`),块级一律
// 不支持(段落仍由 §4.16 的 `<p>` 机制表达);
// ② **属性一律丢弃**(`<b class=x onclick=y>` 只取标签名 `b`,属性绝不进 DOM); // ② **属性一律丢弃**(`<b class=x onclick=y>` 只取标签名 `b`,属性绝不进 DOM);
// ③ 白名单外标签**逐字原样**当文本(`<script>` 显示成 `&lt;script&gt;`,不丢字符); // ③ 白名单外标签**逐字原样**当文本(`<script>` 显示成 `&lt;script&gt;`,不丢字符);
// ④ 未闭合 / 交错 / 大小写混写 / 带属性 —— 结构不平衡时**整段降级为纯文本**, // ④ 未闭合 / 交错 / 大小写混写 / 带属性 —— 结构不平衡时**整段降级为纯文本**,
// 保证「不崩、字符一个不丢」(降级输出 = 原始字符串,逐字相同); // 保证「不崩、字符一个不丢」(降级输出 = 原始字符串,逐字相同);
// ⑤ 大小写不敏感(`<B>` 等于 `<b>`)。 // ⑤ 大小写不敏感(`<B>` 等于 `<b>`)。
/** 允许渲染的行内**元素**标签(成对出现、有子节点)。`br` 是 void 元素,单独处理。 */ /** 允许渲染的行内**元素**标签(成对出现、有子节点)。主人 2026-10-07 晚先把它收窄为
export type InlineElementTag = "b" | "strong" | "i" | "em" | "code" | "u" | "s"; * 五项(`u` / `s` 在她百条输出里 0 命中),当日复核后又把 **`u` 加回**(「下划线确实
* 可以保留」)⇒ 现为 **六项**。`s` **仍在白名单外**(照旧逐字原样显示);`br` 也**不是**
* 行内元素 —— 它是 void 标签,见文件头(chat 里是气泡分隔符、单卡正文里是换行)。 */
export type InlineElementTag = "b" | "strong" | "i" | "em" | "code" | "u";
/** 解析结果:一段文本被拆成的有序节点序列。 */ /** 解析结果:一段文本被拆成的有序节点序列。
* `break` 只在 `lineBreaks` 打开时产生(非 chat 单卡正文里的一个 `<br>` ⇒ 一次换行)。 */
export type InlineNode = export type InlineNode =
| { kind: "text"; text: string } | { kind: "text"; text: string }
| { kind: "break" } | { kind: "element"; tag: InlineElementTag; children: InlineNode[] }
| { kind: "element"; tag: InlineElementTag; children: InlineNode[] }; | { kind: "break" };
/** 词法单元:文本 / 换行(void)/ 开标签 / 闭标签。 */ /** 词法单元:文本 / 开标签 / 闭标签 / 换行(`<br>`)。 */
type Token = type Token =
| { kind: "text"; text: string } | { kind: "text"; text: string }
| { kind: "break" }
| { kind: "open"; tag: InlineElementTag } | { kind: "open"; tag: InlineElementTag }
| { kind: "close"; tag: InlineElementTag }; | { kind: "close"; tag: InlineElementTag }
| { kind: "break" };
const INLINE_ELEMENT_TAGS = new Set<string>(["b", "strong", "i", "em", "code", "u", "s"]); const INLINE_ELEMENT_TAGS = new Set<string>(["b", "strong", "i", "em", "code", "u"]);
/** 标签候选:`<` 紧跟字母、标签体里不再出现尖括号。因此 `1 < 2`、`a<b`(无 `>`)、 /** 标签候选:`<` 紧跟字母、标签体里不再出现尖括号。因此 `1 < 2`、`a<b`(无 `>`)、
* `<p`(无 `>`)都**不是**标签候选,会原样留在文本里(契约 §4.16 的护栏同一条口径)。 */ * `<p`(无 `>`)都**不是**标签候选,会原样留在文本里(契约 §4.16 的护栏同一条口径)。 */
const TAG_RE = /<\/?([a-zA-Z][a-zA-Z0-9]*)[^<>]*>/g; const TAG_RE = /<\/?([a-zA-Z][a-zA-Z0-9]*)[^<>]*>/g;
/** 把一段文本解析成行内节点序列。结构不平衡时整段降级为单个文本节点(逐字保留原文)。 */ /** 把一段文本解析成行内节点序列。结构不平衡时整段降级为单个文本节点(逐字保留原文)。
export function parseInlineMarkup(text: string): InlineNode[] { * `lineBreaks` 打开时(非 chat 单卡正文)`<br>` 变成 `{kind:"break"}`;关闭时(chat 气泡)
const built = buildTree(tokenize(text)); * 它按白名单外文本处理 —— 因为那里 `<br>` 早被 `splitParagraphs` 当气泡分隔符切掉了。 */
export function parseInlineMarkup(text: string, lineBreaks = false): InlineNode[] {
const built = buildTree(tokenize(text, lineBreaks));
return built ?? [{ kind: "text", text }]; return built ?? [{ kind: "text", text }];
} }
/** 切出标签候选,其余按文本切段。非白名单标签连同其尖括号一起作为**文本**单元。 */ /** 切出标签候选,其余按文本切段。非白名单标签连同其尖括号一起作为**文本**单元。 */
function tokenize(text: string): Token[] { function tokenize(text: string, lineBreaks: boolean): Token[] {
const tokens: Token[] = []; const tokens: Token[] = [];
let last = 0; let last = 0;
TAG_RE.lastIndex = 0; TAG_RE.lastIndex = 0;
@@ -53,19 +71,21 @@ function tokenize(text: string): Token[] {
let m: RegExpExecArray | null; let m: RegExpExecArray | null;
while ((m = TAG_RE.exec(text)) !== null) { while ((m = TAG_RE.exec(text)) !== null) {
if (m.index > last) tokens.push({ kind: "text", text: text.slice(last, m.index) }); if (m.index > last) tokens.push({ kind: "text", text: text.slice(last, m.index) });
tokens.push(classify(m)); tokens.push(classify(m, lineBreaks));
last = m.index + m[0].length; last = m.index + m[0].length;
} }
if (last < text.length) tokens.push({ kind: "text", text: text.slice(last) }); if (last < text.length) tokens.push({ kind: "text", text: text.slice(last) });
return tokens; return tokens;
} }
/** 判定一个标签候选的词法身份。白名单外 ⇒ 原样文本(含尖括号,字符不丢)。 */ /** 判定一个标签候选的词法身份。白名单外 ⇒ 原样文本(含尖括号,字符不丢)。
function classify(m: RegExpExecArray): Token { * `<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 raw = m[0];
const name = m[1].toLowerCase(); const name = m[1].toLowerCase();
// `br` 是 void 元素:`<br>`、`<br/>` 都出换行;`</br>` 按 HTML5 口径等同 `<br>`。 if (lineBreaks && name === "br") return { kind: "break" };
if (name === "br") return { kind: "break" };
if (!isInlineElementTag(name)) return { kind: "text", text: raw }; if (!isInlineElementTag(name)) return { kind: "text", text: raw };
return raw.charAt(1) === "/" ? { kind: "close", tag: name } : { kind: "open", tag: name }; return raw.charAt(1) === "/" ? { kind: "close", tag: name } : { kind: "open", tag: name };
} }
@@ -75,7 +95,8 @@ function isInlineElementTag(name: string): name is InlineElementTag {
} }
/** 栈式建树。返回 `null` 表示结构不平衡(落单闭标签 / 交错 / 未闭合)⇒ 调用方整段降级。 /** 栈式建树。返回 `null` 表示结构不平衡(落单闭标签 / 交错 / 未闭合)⇒ 调用方整段降级。
* 只做「标签名 + 位置」这一层配对,不解析任何属性 —— 属性在 `classify` 里已被丢弃。 */ * 只做「标签名 + 位置」这一层配对,不解析任何属性 —— 属性在 `classify` 里已被丢弃。
* `break` 不参与配对(void 标签),只按位置落进当前节点的子序列。 */
function buildTree(tokens: Token[]): InlineNode[] | null { function buildTree(tokens: Token[]): InlineNode[] | null {
const root: InlineNode[] = []; const root: InlineNode[] = [];
const stack: { tag: InlineElementTag; children: InlineNode[] }[] = []; const stack: { tag: InlineElementTag; children: InlineNode[] }[] = [];
+193 -10
View File
@@ -7,14 +7,25 @@
// 这个测试锁住「行内标签渲染」这一层,以及**它不能带来的风险**:产物是不可信文本 // 这个测试锁住「行内标签渲染」这一层,以及**它不能带来的风险**:产物是不可信文本
// (模型生成、用户可诱导),任何把它当 HTML 注入的写法都必须在这里变红。 // (模型生成、用户可诱导),任何把它当 HTML 注入的写法都必须在这里变红。
// //
// 分六组: // 分九组:
// ① 白名单标签渲染成真元素(不是字面量); // ① 白名单标签渲染成真元素(不是字面量);白名单当天先收窄到五项(`u`/`s` 0 命中),
// 当日复核后又把 **`u` 加回**(主人「下划线确实可以保留」)⇒ 现为**六项**:`b`/`strong`/
// `i`/`em`/`code`/`u`。**`s` 仍在白名单外** ⇒ 从此按白名单外口径逐字原样;
// ② 白名单外(`<script>` / `<img onerror>` / `<div>` / `<a>`)⇒ **纯文本字面量**, // ② 白名单外(`<script>` / `<img onerror>` / `<div>` / `<a>`)⇒ **纯文本字面量**,
// 树里不出现这些元素、不出现任何来自产物的属性; // 树里不出现这些元素、不出现任何来自产物的属性;
// ③ 未闭合 / 落单闭标签 ⇒ 整段降级为纯文本,不崩、字符一个不丢; // ③ 未闭合 / 落单闭标签 ⇒ 整段降级为纯文本,不崩、字符一个不丢;
// ④ 交错标签(`<b>a<i>b</b>c</i>`)⇒ 同上,不崩、字符一个不丢; // ④ 交错标签(`<b>a<i>b</b>c</i>`)⇒ 同上,不崩、字符一个不丢;
// ⑤ 带属性 ⇒ 只出元素本身,属性既不进 markup 也不进 props; // ⑤ 带属性 ⇒ 只出元素本身,属性既不进 markup 也不进 props;
// ⑥ 既有 `<p>` / `<action>` 行为无回归(走真实的 SplitChatBubble 链路)。 // ⑥ 既有 `<p>` / `<action>` 行为(走真实的 SplitChatBubble 链路)—— 含 **2026-10-07 晚
// 的口径变更:空 `<p>` 块不再产出空气泡**(旧的「三个 `<p>` ⇒ 三个气泡」断言随之改为
// 两个气泡);
// ⑦ `<br>` = **气泡/消息分隔符**(主人 2026-10-07 口径「对消息内的 `<br>` 始终处理为
// 多条消息隔断」):与 `<p>` 同级切气泡、空气泡丢弃、**绝不**是气泡内换行;
// ⑧ 🆕 **纯动作 / 纯独白回复 ⇒ 零个气泡**(不崩、不画空气泡),且工具卡 / 思维链 / 心情
// 这些**用户可见内容**不能跟着被丢;
// ⑨ 🆕 **非 chat 单卡正文**(工作 / 代码 / 文档 / 首页 / 视觉 / 语音 / 智能体 / 设置):
// `<br>` ⇒ **换行**(那些界面没有「多条气泡」的概念,所以不切分)、行内白名单出样式、
// 白名单外仍逐字字面;chat 模式的 `<br>` 分隔口径不受影响(⑨ 里有一条回归断言)。
// //
// 跑法(react-dom/server 的 CJS 构建需要 require,所以必须打成 cjs): // 跑法(react-dom/server 的 CJS 构建需要 require,所以必须打成 cjs):
// //
@@ -32,6 +43,7 @@ import { createElement, Fragment, type ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server"; import { renderToStaticMarkup } from "react-dom/server";
import { renderContent } from "./renderContent"; import { renderContent } from "./renderContent";
import { SplitChatBubble } from "../components/chat"; import { SplitChatBubble } from "../components/chat";
import { modeBody } from "../components/messages";
import type { ChatMessage } from "./types"; import type { ChatMessage } from "./types";
let checks = 0; let checks = 0;
@@ -128,28 +140,32 @@ const REAL_SAMPLE =
ok(!has(html, "&lt;b&gt;"), "① 不再把 `<b>` 当字面量显示"); ok(!has(html, "&lt;b&gt;"), "① 不再把 `<b>` 当字面量显示");
ok(seen("日落 <b>18:35</b> 了") === "日落 18:35 了", "① 标签本身不作为文字出现(只剩正文)"); ok(seen("日落 <b>18:35</b> 了") === "日落 18:35 了", "① 标签本身不作为文字出现(只剩正文)");
// 白名单 = **六项**(主人 2026-10-07 晚先收窄到五项,当日复核后把 `u` 加回:
// 「下划线确实可以保留」)⇒ `<u>` 从此渲染成下划线元素,不再是字面量。
const tags: [string, string][] = [ const tags: [string, string][] = [
["<b>粗</b>", "<b>粗</b>"],
["<strong>粗</strong>", "<strong>粗</strong>"], ["<strong>粗</strong>", "<strong>粗</strong>"],
["<i>斜</i>", "<i>斜</i>"], ["<i>斜</i>", "<i>斜</i>"],
["<em>强调</em>", "<em>强调</em>"], ["<em>强调</em>", "<em>强调</em>"],
["<u>下划</u>", "<u>下划</u>"], ["<u>下划</u>", "<u>下划</u>"],
["<s>划掉</s>", "<s>划掉</s>"],
]; ];
for (const [src, want] of tags) ok(has(markup(src), want), `① ${want} 正常渲染`); for (const [src, want] of tags) ok(has(markup(src), want), `① ${want} 正常渲染`);
ok(has(markup("<code>go build</code>"), "<code"), "① `<code>` 渲染成等宽元素"); ok(has(markup("<code>go build</code>"), "<code"), "① `<code>` 渲染成等宽元素");
ok(seen("<code>go build</code>") === "go build", "① 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>`)。 // 大小写混写:按 HTML 口径不敏感(`<B>` 就是 `<b>`)。
ok(has(markup("<B>大写</B>"), "<b>大写</b>"), "① `<B>` 大小写不敏感"); ok(has(markup("<B>大写</B>"), "<b>大写</b>"), "① `<B>` 大小写不敏感");
ok(has(markup("<b>x</b><BR>y"), "<br/>"), "① `<BR>` 也认(标签名大小写不敏感)");
// br 是 void 元素:`<br>` / `<br/>` / `</br>`(HTML5 等价写法)都出换行,两侧文本不丢。
ok(seen("上<br>下") === "上下" && has(markup("上<br/>下"), "<br/>"), "① `<br>` / `<br/>` 出换行");
ok(has(markup("上</br>下"), "<br/>"), "① `</br>` 按 HTML5 口径等同 `<br>`");
// 嵌套白名单标签(正常闭合)照常渲染。 // 嵌套白名单标签(正常闭合)照常渲染。
const nested = markup("<b>外 <i>内</i></b>"); const nested = markup("<b>外 <i>内</i></b>");
ok(has(nested, "<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` 产出元素");
} }
// ── ② 白名单外一律纯文本字面量,且不产生任何节点/属性 ────────────────────────── // ── ② 白名单外一律纯文本字面量,且不产生任何节点/属性 ──────────────────────────
@@ -239,7 +255,14 @@ const REAL_SAMPLE =
const html = render({ id: "a1", role: "assistant" as const, content: REAL_SAMPLE, createdAt: 1 } as ChatMessage); const html = render({ id: "a1", role: "assistant" as const, content: REAL_SAMPLE, createdAt: 1 } as ChatMessage);
ok(has(html, "*动作:赶紧凑过来看你那边,手指在空气里划了下*"), "⑥ action 仍是气泡外的灰斜体注记"); ok(has(html, "*动作:赶紧凑过来看你那边,手指在空气里划了下*"), "⑥ action 仍是气泡外的灰斜体注记");
ok(count(html, "liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5") === 3, "⑥ 三个 <p> ⇒ 三个气泡(首段只有动作注记 ⇒ 空气泡,与改动前逐字一致)"); // 🔴 **断言变更**(主人 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, "花溪呀——人家按贵阳花溪区算的哦,不对的话你得纠正人家♪"), "⑥ 第二段正文照常");
ok(has(html, "今天那儿是晴,13~25℃,日落 <b>18:35</b>,比上海晚了整整一小时呢。"), "⑥ 第三段正文照常,且行内 <b> 渲染为粗体"); ok(has(html, "今天那儿是晴,13~25℃,日落 <b>18:35</b>,比上海晚了整整一小时呢。"), "⑥ 第三段正文照常,且行内 <b> 渲染为粗体");
ok(!has(html, "&lt;p&gt;") && !has(html, "<p>"), "⑥ `<p>` 一个都不进 DOM(段落机制不变)"); ok(!has(html, "&lt;p&gt;") && !has(html, "<p>"), "⑥ `<p>` 一个都不进 DOM(段落机制不变)");
@@ -250,6 +273,17 @@ const REAL_SAMPLE =
ok(count(three, "liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5") === 3, "⑥ 纯 <p> 三段 ⇒ 三个气泡"); 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, "第三段"), "⑥ 三段正文都在"); 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>` 注记**不参与**行内渲染(注记文案是原样文本)—— 既有行为一个字没改。 // 🔴 `<action>` 注记**不参与**行内渲染(注记文案是原样文本)—— 既有行为一个字没改。
const noteWithTag = render({ id: "a3", role: "assistant", content: "<action>她 <b>笑</b> 了</action><p>正文</p>", createdAt: 3 } as ChatMessage); 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, "*动作:她 &lt;b&gt;笑&lt;/b&gt; 了*"), "⑥ 注记内的标签保持字面量(不改注记行为)");
@@ -265,4 +299,153 @@ const REAL_SAMPLE =
ok(!has(user, "<script") && has(user, "&lt;script&gt;x&lt;/script&gt;"), "⑥ 用户气泡里的白名单外标签仍是字面量"); 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`); console.log(`inline markup render test: ${checks} checks passed`);
+31 -11
View File
@@ -2,25 +2,47 @@ import { Fragment, type ReactNode } from "react";
import { parseContent } from "../lib/parseContent"; import { parseContent } from "../lib/parseContent";
import { parseInlineMarkup, type InlineNode } from "../lib/inlineMarkup"; import { parseInlineMarkup, type InlineNode } from "../lib/inlineMarkup";
/** 渲染助手消息正文。`<action>` / `<narration>` 块在这里**不**进入气泡 —— 它们由 /** 正文渲染口径(两种界面形态各一套,别混用):
* `splitBubble` 剥出、以气泡外的注记呈现;剩下的文本段落交给行内白名单渲染。 * · 缺省 / `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 当**文本子节点**渲染(默认转义),全程没有 * 🔴 安全:文本一律交给 React 当**文本子节点**渲染(默认转义),全程没有
* `innerHTML` / `dangerouslySetInnerHTML`,也没有字符串拼 HTML —— 白名单外的标签 * `innerHTML` / `dangerouslySetInnerHTML`,也没有字符串拼 HTML —— 白名单外的标签
* (`<script>` 等)只会变成可见的字面量文本,不产生 DOM 节点、不执行。 */ * (`<script>` 等)只会变成可见的字面量文本,不产生 DOM 节点、不执行。
export function renderContent(content: string): ReactNode[] { * `lineBreaks` 也只多产出一个 `<br/>` 元素(结构节点),不外泄任何来自产物的属性。 */
export function renderContent(content: string, options: RenderOptions = {}): ReactNode[] {
const lineBreaks = options.lineBreaks ?? false;
return parseContent(content) return parseContent(content)
.filter((part) => part.type === "text") .filter((part) => part.type === "text")
.map((part, i) => <span key={i}>{renderInline(part.text)}</span>); .map((part, i) => <span key={i}>{renderInline(part.text, lineBreaks)}</span>);
} }
/** 一段(`<p>` 切分后的)段落文本 ⇒ 行内节点序列 ⇒ React 元素。 */ /** 一段(`<p>` / `<br>` 切分后的单气泡)文本 ⇒ 行内节点序列 ⇒ React 元素。 */
function renderInline(text: string): ReactNode[] { function renderInline(text: string, lineBreaks: boolean): ReactNode[] {
return parseInlineMarkup(text).map((node, i) => renderInlineNode(node, `n${i}`)); return parseInlineMarkup(text, lineBreaks).map((node, i) => renderInlineNode(node, `n${i}`));
} }
/** 单个行内节点 ⇒ React 元素。文本走 Fragment(React 转义),标签走原生元素名, /** 单个行内节点 ⇒ React 元素。文本走 Fragment(React 转义),标签走原生元素名,
* 因此不存在「把标签名当 HTML 执行」的路径;节点树里也没有任何属性位。 */ * 因此不存在「把标签名当 HTML 执行」的路径;节点树里也没有任何属性位。
* `break` 只在单卡正文里出现(那个 `<br>` 是**换行**,不是气泡分隔符)。 */
function renderInlineNode(node: InlineNode, key: string): ReactNode { function renderInlineNode(node: InlineNode, key: string): ReactNode {
if (node.kind === "text") return <Fragment key={key}>{node.text}</Fragment>; if (node.kind === "text") return <Fragment key={key}>{node.text}</Fragment>;
if (node.kind === "break") return <br key={key} />; if (node.kind === "break") return <br key={key} />;
@@ -37,8 +59,6 @@ function renderInlineNode(node: InlineNode, key: string): ReactNode {
return <em key={key}>{children}</em>; return <em key={key}>{children}</em>;
case "u": case "u":
return <u key={key}>{children}</u>; return <u key={key}>{children}</u>;
case "s":
return <s key={key}>{children}</s>;
case "code": case "code":
return ( return (
<code key={key} className="rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-2)] px-1 font-mono"> <code key={key} className="rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-2)] px-1 font-mono">
+19
View File
@@ -40,6 +40,19 @@ export interface InstalledPlugin {
export interface AdminChannelRecord { export interface AdminChannelRecord {
name: string; name: string;
connected: boolean; connected: boolean;
/** 适配器自报的**连接态三态**(后端可选键,契约 v0.68;见 internal/services/
* gateway/admin_channel_connect_outcome.go)。`connected:false` 至少有两种互不相同
* 的现状,缺了它就只能被画成一句静态的「未连接」:
*
* - `pending` → 已在后台重试、当前未连上 ⇒ 面板读作「连接中」(`reason` 说明原因);
* - `ready` → 已就绪、等待对端接入(server 模式监听已绑定)⇒ 不是失败。
*
* 两者都**缺省** = 该适配器没有这条状态面(绝大多数平台)⇒ 面板退回只看 `connected`,
* 绝不凭空编一个状态。 */
pending?: boolean;
ready?: boolean;
/** 未连上时的原因(已脱敏,绝不含凭据)。只有 `pending` 时才会出现。 */
reason?: string;
/** Configured accounts for this channel's platform (token masked). */ /** Configured accounts for this channel's platform (token masked). */
accounts?: AdminChannelAccount[]; accounts?: AdminChannelAccount[];
} }
@@ -80,6 +93,12 @@ export interface AdminChannelAccount {
* 缺省(undefined)= **状态未知**(该渠道没有独立账号状态面,或尚未探到结果), * 缺省(undefined)= **状态未知**(该渠道没有独立账号状态面,或尚未探到结果),
* 面板画「账号状态未知」,**绝不**画成「账号离线」。 */ * 面板画「账号状态未知」,**绝不**画成「账号离线」。 */
accountOnline?: boolean; accountOnline?: boolean;
/** 该账号适配器自报的连接态三态(可选,见 `AdminChannelRecord.pending`)。三者缺省 =
* 该适配器没有这条状态面 ⇒ 退回只看 `connected`。 */
pending?: boolean;
ready?: boolean;
/** 未连上时的原因(已脱敏)。只有 `pending` 时才会出现。 */
reason?: string;
} }
/** The weixin-iLink scan-login QR payload (GET /admin/channels/{platform}/login-qr). /** The weixin-iLink scan-login QR payload (GET /admin/channels/{platform}/login-qr).
+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. // validKind reports whether k is one of the known capability kinds.
func validKind(k Kind) bool { func validKind(k Kind) bool {
switch k { 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)
}
}
+8 -4
View File
@@ -246,13 +246,17 @@ type MemoryTidyConfig struct {
// 关闭)。整理**只产建议、绝不自动改记忆**,因此默认开启是安全的; // 关闭)。整理**只产建议、绝不自动改记忆**,因此默认开启是安全的;
// 它带来的是每轮一次的记忆向量化(召回用)与一批候选行。 // 它带来的是每轮一次的记忆向量化(召回用)与一批候选行。
Enabled bool `env:"MEMORY_TIDY_ENABLED" default:"true"` Enabled bool `env:"MEMORY_TIDY_ENABLED" default:"true"`
// IntervalSec 是整理周期(秒),默认 86400 = 每天 1 次(design/23 §13 // IntervalSec 是**非安静时段**的整理周期(秒),默认 86400 = 每天 1 次
// 开放点 8 的建议值)。它是下限:实际在安静时段起点跑,因此间隔取 // (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"` IntervalSec int `env:"MEMORY_TIDY_INTERVAL_SEC" default:"86400"`
// QuietStartHour / QuietEndHour 是低峰窗口(本地钟点),默认复用 // QuietStartHour / QuietEndHour 是低峰窗口(本地钟点),默认复用
// backthink 的 23:00-07:00 语义(窗口可跨午夜;两端相等且非 0 则关闭窗口, // 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"` QuietStartHour int `env:"MEMORY_TIDY_QUIET_START_HOUR" default:"23"`
QuietEndHour int `env:"MEMORY_TIDY_QUIET_END_HOUR" default:"7"` QuietEndHour int `env:"MEMORY_TIDY_QUIET_END_HOUR" default:"7"`
// RecallThreshold 是候选召回的余弦**召回门**,默认 0.80(design/23 §5.1 // RecallThreshold 是候选召回的余弦**召回门**,默认 0.80(design/23 §5.1
+70 -7
View File
@@ -23,17 +23,37 @@
// 额度出口。额度必须能按现网节奏调(且不许为它开 env 键),因此落在这里; // 额度出口。额度必须能按现网节奏调(且不许为它开 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 同源(那是同一族的手续): // 边界与 backthink.go 同源(那是同一族的手续):
// //
// 1. 本文件只管**文件形状 + 读取**:它不认识 `TidyConfig`,也不认识「零值回落 // 1. 本文件只管**文件形状 + 读取**:它不认识 `TidyConfig`,也不认识「零值回落
// 代码默认值」那条裁决 —— 那是 `memory.normalizeTidyConfig` 的事。 // 代码默认值」那条裁决 —— 那是 `memory.normalizeTidyConfig` 的事。
// 2. **只读**:面板写路径不在本切片内,因此刻意没有 `Save*`。改动仍要 // 2. **没有「面板编辑」的写口**:改内容仍要**人手改这个文件 + 重启**(与
// **人手改这个文件 + 重启**(与 `backthink.json` 同口径:启动期读一次)。 // `backthink.json` 同口径:启动期读一次)。唯一的写路径是**启动播种**
// (memorytidy_seed.go)—— 它只在文件缺失/空白时写一次,之后一个字节都不动;
// 面板上的编辑口是另一片,不在本文件。
// 3. **零新 env 键**,也不进面板的配置事实卡(那张表的键名被 `.env.example` 钉住)。 // 3. **零新 env 键**,也不进面板的配置事实卡(那张表的键名被 `.env.example` 钉住)。
// //
// 读取口径:文件缺失或全空白 = 还没配过(零值 + nil,**不是错误**);存在但解不开 // 读取口径:文件缺失或全空白 = 还没配过(零值 + nil,**不是错误**;启动时这种情形
// = 返回错误,调用方记 WARN 并回落**代码默认值**(即:坏配置绝不能把自动档的开关 // 会被播种成一份「当时生效值」,见 memorytidy_seed.go);存在但解不开 = 返回错误,
// 或上限读成「无限制」)。 // 调用方记 WARN 并回落**代码默认值**(即:坏配置绝不能把自动档的开关或上限读成
// 「无限制」)。
package runtimeconfig package runtimeconfig
import ( import (
@@ -108,6 +128,35 @@ type MemoryTidyTuning struct {
// `3600` 比要求主人记住 Go 的时长语法更不容易写错,也与既有 // `3600` 比要求主人记住 Go 的时长语法更不容易写错,也与既有
// `$DATA_DIR/config/backthink.json` 的 `*_SEC` 口径一致。 // `$DATA_DIR/config/backthink.json` 的 `*_SEC` 口径一致。
T1WindowSec int `json:"t1WindowSec"` 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 —— // AutoApplyOff 报告自动档是否被这份配置**显式关掉**。nil(没配)返回 false ——
@@ -138,8 +187,12 @@ func DefaultMemoryTidyPath(dataDir string) string {
// 文件缺失或全空白 ⇒ 零值 + nil:调用方用代码默认值(normalizeTidyConfig 会把 // 文件缺失或全空白 ⇒ 零值 + nil:调用方用代码默认值(normalizeTidyConfig 会把
// 非正的上限回落默认),因此「没配过」与「配成 0」得到同一个结果。 // 非正的上限回落默认),因此「没配过」与「配成 0」得到同一个结果。
// //
// 存在但解不开 ⇒ 返回错误。调用方应记一条带 source 的 WARN 并回落默认; // 存在但解不开 ⇒ 返回错误。调用方应记一条带 source 的 WARN 并回落**下一档**
// **不得覆盖**那个文件(损坏的文件可以人工救回,程序盖掉它就丢了人工写的意图)。 // (调度四键落 `.env` 值、开关类落代码默认)——注意这是**整份文件**的粒度:一个键
// 类型写错(如 `"intervalSec": "3600"`)会让全部键一起回落,这是既有口径
// (`TestApplyMemoryTidyTuning_ReadsDataDirConfig` 的用例③钉的就是它),而不是
// 逐键容错。**不得覆盖**那个文件(损坏的文件可以人工救回,程序盖掉它就丢了人工
// 写的意图)。
// //
// 它不建目录、不读老位置:新家族没有老位置(design/13 §2.1 第 5 条)。 // 它不建目录、不读老位置:新家族没有老位置(design/13 §2.1 第 5 条)。
func LoadMemoryTidyTuning(path string) (MemoryTidyTuning, error) { func LoadMemoryTidyTuning(path string) (MemoryTidyTuning, error) {
@@ -153,6 +206,16 @@ func LoadMemoryTidyTuning(path string) (MemoryTidyTuning, error) {
if strings.TrimSpace(string(raw)) == "" { if strings.TrimSpace(string(raw)) == "" {
return MemoryTidyTuning{}, nil return MemoryTidyTuning{}, nil
} }
return decodeMemoryTidy(raw, path)
}
// decodeMemoryTidy 是 LoadMemoryTidyTuning 与**启动播种**(memorytidy_seed.go)共用的
// 解码点:把「解不开」统一分类成 ErrInvalidMemoryTidy。两处对「坏文件」的判定必须
// 出自同一行代码,否则会出现「读取说坏了、播种说没事」这种自相矛盾(播种侧要靠这个
// 结论决定「绝不覆盖」,判错的代价是一份人工配置被盖掉)。
//
// 空内容由调用方先滤掉(空 = 还没配过,与「缺失」同义,不是解码失败)。
func decodeMemoryTidy(raw []byte, path string) (MemoryTidyTuning, error) {
var t MemoryTidyTuning var t MemoryTidyTuning
if err := json.Unmarshal(raw, &t); err != nil { if err := json.Unmarshal(raw, &t); err != nil {
return MemoryTidyTuning{}, fmt.Errorf("%w: decode %s: %w", ErrInvalidMemoryTidy, path, err) return MemoryTidyTuning{}, fmt.Errorf("%w: decode %s: %w", ErrInvalidMemoryTidy, path, err)
@@ -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)
}
}
@@ -211,3 +211,70 @@ func writeMemoryTidyFile(t *testing.T, body string) string {
} }
return path 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)
}
}
+76 -20
View File
@@ -11,6 +11,7 @@ import (
"net/http" "net/http"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/config"
@@ -38,23 +39,24 @@ const (
// DefaultRequestTimeout 是**单次** /embeddings 调用的墙钟上限,独立于 HTTP 客户端 // DefaultRequestTimeout 是**单次** /embeddings 调用的墙钟上限,独立于 HTTP 客户端
// 那一层的 180s 总超时(httpRequestTimeout)。 // 那一层的 180s 总超时(httpRequestTimeout)。
// //
// 依据(是实测数据,不是拍脑袋的调优): // 它**不是**单独取值:批次上限(DefaultBatchLimit / DefaultBatchChars)与它由
// - 上层「入库前审查」的单条总预算是 20s(memory.defaultReviewTimeout),embedding // budget.go 里**同一个不等式**推出 —— 单次请求的耗时上界不得超过超时的一半。
// 只是它内部的第一步。2026-10-07 实测(本机 llama.cpp + Qwen3-Embedding-0.6B, // 改这一行就等于改了批次上限,反之亦然;两侧永远是配套的。
// 服务空闲):单条文本约 60ms;**64 条一批约 2.1~2.9s**;审查最坏情况 //
// (ScanLimit=200 + 新条目 1)要 4 批 ⇒ 约 8~10s,已经占掉 20s 预算的一半, // 为什么每次调用必须有自己的上限:上层「入库前审查」的单条总预算是 20s
// 留给后面的 T1 灰区判定(defaultT1Timeout=8s)的余量很薄。一次卡死的 HTTP // (memory.defaultReviewTimeout),embedding 只是它内部的第一步,后面还有 T1 灰区
// 就能把这点余量连同整条审查一起吃掉(实测日志里 // 判定(8s)。一次卡死的 HTTP 不能把整条审查的 20s 都赌掉。真慢就**快速失败**,
// `memory/review: context deadline exceeded` 正是这个形态); // 调用方按**既有语义**降级(审查降级到词项余弦并如实留痕 degraded),
// - 所以每次调用必须有**自己的**上限:卡住的那一批到点就失败并降级,不能把整条 // **绝不静默丢记忆** —— 本常量只砍等待时间,不改任何判定规则。
// 审查的 20s 都赌在一次 HTTP 上; //
// - 5s 的取值:相对实测的 64 条一批 2.9s 留约 1.7 倍余量,能吸收并发争用带来的 // 5s 保持不变的依据:本次故障(2026-10-13)的成因不是这个数太小,而是批次太大 ——
// 抖动,同时远小于 20s 的审查预算。若日后批次变大或服务变慢,先调这里而不是 // 64 条的实测耗时 7.5~8.5s 已经越过它,客户端放弃、服务端还在算,把整台本机
// 调审查总预算; // embedding 服务顶住,连带单条请求(写向量索引)也一起超时。所以修法是**把批次
// - 真慢就**快速失败**:调用方按**既有语义**降级(审查降级到词项余弦并如实留痕 // 收进预算内**,而不是把预算放大到迁就批次(后者会让真挂死的请求也拖住整轮)。
// degraded),**绝不静默丢记忆** —— 本常量只砍等待时间,不改任何判定规则; //
// - 已知代价:llama.cpp 首次加载模型可能超过 5s,那一次调用会失败并降级,模型 // 冷启动另有 warmupTimeout(见 budget.go),但它**只放宽本进程的第一个请求**、
// 常驻后的下一次调用即恢复正常。降级本就是既有行为,因此可接受。 // 且一次性花掉:失败不会让它续期,因此「服务一直挂死」这件事**不会**变成
// 「每次调用都慢」—— 第二个请求起就回到本常量。
const DefaultRequestTimeout = 5 * time.Second const DefaultRequestTimeout = 5 * time.Second
// openAICompatibleRequest is the JSON body sent to an OpenAI-compatible // openAICompatibleRequest is the JSON body sent to an OpenAI-compatible
@@ -93,6 +95,11 @@ type APIEmbedder struct {
cfg config.EmbeddingConfig cfg config.EmbeddingConfig
client *http.Client client *http.Client
log *slog.Logger log *slog.Logger
// warmupLeft 是**一次性**冷启动放宽的开关:为真时下一个请求用 warmupTimeout,
// 取走即置假(CompareAndSwap ⇒ 并发下也只有一个请求能拿到)。
// 它**不是**「未成功就一直放宽」—— 失败也照样花掉这一次,因此服务挂死时
// 只会慢一次,之后每个请求都按 DefaultRequestTimeout 快速失败(见 budget.go)。
warmupLeft atomic.Bool
mu sync.RWMutex mu sync.RWMutex
dim int dim int
@@ -131,12 +138,16 @@ func WithLogger(l *slog.Logger) APIOption {
} }
// NewAPI builds an APIEmbedder from cfg using the default HTTP client. // NewAPI builds an APIEmbedder from cfg using the default HTTP client.
// 单次超时不可由选项更换:它是 budget.go 那条推演的输入,批次上限与它同源,
// 因此**只允许改常量**(改了之后批次上限自动跟着改,测试的编译期/常量断言立刻复核)。
func NewAPI(cfg config.EmbeddingConfig, opts ...APIOption) *APIEmbedder { func NewAPI(cfg config.EmbeddingConfig, opts ...APIOption) *APIEmbedder {
e := &APIEmbedder{ e := &APIEmbedder{
cfg: cfg, cfg: cfg,
client: defaultHTTPClient(), client: defaultHTTPClient(),
log: slog.New(slog.NewTextHandler(io.Discard, nil)), log: slog.New(slog.NewTextHandler(io.Discard, nil)),
} }
// 冷启动放宽是一个**一次性**许可:见 APIEmbedder.warmupLeft。
e.warmupLeft.Store(true)
for _, opt := range opts { for _, opt := range opts {
opt(e) opt(e)
} }
@@ -155,8 +166,10 @@ func (e *APIEmbedder) Dim() int {
// returned vectors aligned by index. It returns ErrNoAPIURL for a blank URL // returned vectors aligned by index. It returns ErrNoAPIURL for a blank URL
// and a wrapped error for any HTTP or decode failure. // and a wrapped error for any HTTP or decode failure.
// //
// 单次调用带独立的、比上层审查总预算(20s)短得多的超时(DefaultRequestTimeout), // texts 长度不受调用方控制:本方法按 budget.go 推出来的上限(条数 / 字符总量)
// 卡住即快速失败,把预算留给后续步骤;失败语义与降级口径不变。 // **自己切片**,分多次请求顺序发出,因此「一次请求的耗时 < 单次超时」这条不变式
// 对任何调用方都成立 —— 调用方给多大的切片都不会把服务端顶住。中途任一段失败即
// 整体失败(返回已发请求的错误),绝不半途改用混合表示。
func (e *APIEmbedder) Embed(ctx context.Context, texts []string) ([]Vector, error) { func (e *APIEmbedder) Embed(ctx context.Context, texts []string) ([]Vector, error) {
if len(texts) == 0 { if len(texts) == 0 {
return []Vector{}, nil return []Vector{}, nil
@@ -168,7 +181,40 @@ func (e *APIEmbedder) Embed(ctx context.Context, texts []string) ([]Vector, erro
return nil, ErrNoAPIURL return nil, ErrNoAPIURL
} }
callCtx, cancel := context.WithTimeout(ctx, DefaultRequestTimeout) // 切片用的上限由**稳态**超时推出(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
}
// callTimeout 返回本次请求该用的单次超时,并**当场花掉**一次性冷启动放宽许可
// (成功、失败都算花掉)。因此:
// - 本进程的第一个请求最多等 warmupTimeout(冷启动),
// - 从第二个请求起一律 DefaultRequestTimeout —— 服务挂死时不会每次都慢。
//
// 返回值 relaxed 表示这一次是不是拿到了放宽,供失败时留痕。
func (e *APIEmbedder) callTimeout() (timeout time.Duration, relaxed bool) {
if e.warmupLeft.CompareAndSwap(true, false) {
return warmupTimeout, true
}
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() defer cancel()
req, err := e.newEmbeddingRequest(callCtx, texts) req, err := e.newEmbeddingRequest(callCtx, texts)
@@ -178,6 +224,16 @@ func (e *APIEmbedder) Embed(ctx context.Context, texts []string) ([]Vector, erro
resp, err := e.client.Do(req) resp, err := e.client.Do(req)
if err != nil { 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) return nil, fmt.Errorf("embedding: request: %w", err)
} }
defer func() { _ = resp.Body.Close() }() defer func() { _ = resp.Body.Close() }()
+24 -2
View File
@@ -222,6 +222,11 @@ const reviewBudgetRef = 20 * time.Second
// 审查总预算:否则一次卡死的 embedding 连接就能吃光整条审查的预算,排在后面的 // 审查总预算:否则一次卡死的 embedding 连接就能吃光整条审查的预算,排在后面的
// T1 灰区判定连执行机会都没有(2026-10-07 实测故障形态)。同时验证该超时确实 // T1 灰区判定连执行机会都没有(2026-10-07 实测故障形态)。同时验证该超时确实
// 打在了发出去的请求上。 // 打在了发出去的请求上。
//
// 🔴 冷启动放宽(warmupTimeout)必须一并守住同一条界:它只发生一次,但它那一次的
// 等待**也**不能吃光整轮审查 —— 否则「一次挂死 = 整轮审查没有预算给 T1」这个形态
// 会从常态超时挪到放宽预算上原样复现(reviewer round1 的 p1)。因此这里对
// warmupTimeout 做与 DefaultRequestTimeout **同一条**断言。
func TestDefaultRequestTimeout(t *testing.T) { func TestDefaultRequestTimeout(t *testing.T) {
t.Parallel() t.Parallel()
@@ -236,6 +241,19 @@ func TestDefaultRequestTimeout(t *testing.T) {
t.Fatalf("DefaultRequestTimeout = %s, want 5s(值变了要连带复核审查预算口径)", t.Fatalf("DefaultRequestTimeout = %s, want 5s(值变了要连带复核审查预算口径)",
DefaultRequestTimeout) 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 var slack time.Duration
rt := roundTripFunc(func(r *http.Request) (*http.Response, error) { rt := roundTripFunc(func(r *http.Request) (*http.Response, error) {
@@ -256,8 +274,12 @@ func TestDefaultRequestTimeout(t *testing.T) {
config.EmbeddingConfig{APIURL: "http://127.0.0.1:11434/v1/embeddings"}, config.EmbeddingConfig{APIURL: "http://127.0.0.1:11434/v1/embeddings"},
WithRoundTripper(rt), WithRoundTripper(rt),
) )
if _, err := emb.Embed(context.Background(), []string{"hi"}); err != nil { // 第一次是冷启动调用(warmupTimeout),结果由它自己的测试断言;这里连发两次,
t.Fatalf("Embed() error: %v", err) // 只看稳态那一次打出去的 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 { if slack <= 0 || slack > DefaultRequestTimeout {
t.Fatalf("请求 deadline 余量 = %s, want (0, %s]", 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)
}
}
+14 -5
View File
@@ -73,6 +73,11 @@ func (s *Store) AddScoped(ctx context.Context, actorID string, item MemoryItem)
// 作用域上也始终声明身份边界。是否允许受限上下文(群聊 / 陌生人,design §5) // 作用域上也始终声明身份边界。是否允许受限上下文(群聊 / 陌生人,design §5)
// 接收这些内容仍由更高层策略决定;本方法只施加 // 接收这些内容仍由更高层策略决定;本方法只施加
// 基础读规则。 // 基础读规则。
//
// 与 ListByPersona/List 同口径:只返回 status='active' 且过**时效闸**
// (expires_at / valid_to 均未到期)的行 —— 闸只有这里与 ListByPersona /
// ListProject / List 四处,检索路径经 Retriever.visibleCandidates 组合调用
// 本方法,因此自动获得同一判据(timegate.go 头注、design/03 §11.2)。
func (s *Store) ListScope(ctx context.Context, scope Scope, personaID string) ([]MemoryItem, error) { func (s *Store) ListScope(ctx context.Context, scope Scope, personaID string) ([]MemoryItem, error) {
if strings.TrimSpace(personaID) == "" { if strings.TrimSpace(personaID) == "" {
return nil, ErrMissingPersona return nil, ErrMissingPersona
@@ -80,10 +85,12 @@ func (s *Store) ListScope(ctx context.Context, scope Scope, personaID string) ([
if !scope.valid() { if !scope.valid() {
return nil, ErrInvalidScope return nil, ErrInvalidScope
} }
now := timeGateNow()
rows, err := s.db.QueryContext(ctx, `SELECT id, persona_id, role_id, project_id, scope, kind, rows, err := s.db.QueryContext(ctx, `SELECT id, persona_id, role_id, project_id, scope, kind,
content, tags, confidence, source_conversation, created_at, updated_at, content, tags, confidence, source_conversation, created_at, updated_at,
decay_weight FROM memory_items WHERE scope = ? AND status = ? decay_weight FROM memory_items WHERE scope = ? AND status = ?`+timeGateClause+`
ORDER BY created_at DESC, rowid DESC`, string(scope), string(tidyStatusActive)) ORDER BY created_at DESC, rowid DESC`,
string(scope), string(tidyStatusActive), now, now)
if err != nil { if err != nil {
return nil, fmt.Errorf("memory: list scope items: %w", err) return nil, fmt.Errorf("memory: list scope items: %w", err)
} }
@@ -96,15 +103,17 @@ func (s *Store) ListScope(ctx context.Context, scope Scope, personaID string) ([
// 层级(design §3);这是深检索入口 —— 调用方把它与 // 层级(design §3);这是深检索入口 —— 调用方把它与
// projectindex 存储结合,沿 项目索引 → 项目描述 → 章节内容 遍历。项目 // projectindex 存储结合,沿 项目索引 → 项目描述 → 章节内容 遍历。项目
// 记忆所有角色可读,因此不施加角色过滤。projectID 为空 // 记忆所有角色可读,因此不施加角色过滤。projectID 为空
// 返回 ErrMissingProjectID。 // 返回 ErrMissingProjectID。与其余三条列表读路径同口径:只返回在册且过
// **时效闸**的行(timegate.go、design/03 §9.6/§11.2)。
func (s *Store) ListProject(ctx context.Context, projectID string) ([]MemoryItem, error) { func (s *Store) ListProject(ctx context.Context, projectID string) ([]MemoryItem, error) {
if strings.TrimSpace(projectID) == "" { if strings.TrimSpace(projectID) == "" {
return nil, ErrMissingProjectID return nil, ErrMissingProjectID
} }
now := timeGateNow()
rows, err := s.db.QueryContext(ctx, `SELECT id, persona_id, role_id, project_id, scope, kind, rows, err := s.db.QueryContext(ctx, `SELECT id, persona_id, role_id, project_id, scope, kind,
content, tags, confidence, source_conversation, created_at, updated_at, content, tags, confidence, source_conversation, created_at, updated_at,
decay_weight FROM memory_items WHERE project_id = ? AND status = ? decay_weight FROM memory_items WHERE project_id = ? AND status = ?`+timeGateClause+`
ORDER BY created_at DESC, rowid DESC`, projectID, string(tidyStatusActive)) ORDER BY created_at DESC, rowid DESC`, projectID, string(tidyStatusActive), now, now)
if err != nil { if err != nil {
return nil, fmt.Errorf("memory: list project items: %w", err) return nil, fmt.Errorf("memory: list project items: %w", err)
} }
+39 -7
View File
@@ -108,6 +108,16 @@ type MemoryItem struct {
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
DecayWeight float64 DecayWeight float64
// ExpiresAt 是**临时记忆**的有效期末(design/03 §9.6、design/23 §18.1)。
// 零值 = 不设过期,落库为空串,四条列表读路径恒可见;非零值落库为定宽 UTC
// 文本,到期后该行即从读路径消失,直到后台清扫物理删除(design/23 §19)。
//
// ⚠️ **本字段目前只写不读**:Add 会把它写进 expires_at 列,但读路径一律不
// SELECT 该列(闸是纯 SQL,见 timegate.go),故 Get/List* 回来的条目
// ExpiresAt 恒为零值。要读回时效需同步 13 列 SELECT 与 scanItem,并连带
// tidy/preinsert 的内部召回 SQL —— 那是改扫描口径,不在本切片授权范围。
ExpiresAt time.Time
} }
// 区分 memory 包各类失败的哨兵错误。每个都在 // 区分 memory 包各类失败的哨兵错误。每个都在
@@ -231,6 +241,11 @@ func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error)
// 关系表里,语义增强的失败绝不使记忆写入失败,检索侧也会回退到 // 关系表里,语义增强的失败绝不使记忆写入失败,检索侧也会回退到
// 内存余弦(见 Retriever.semanticScores)。被闸判为重复的行**不建索引**: // 内存余弦(见 Retriever.semanticScores)。被闸判为重复的行**不建索引**:
// 它不进召回、也不该占掉语义检索 top-K 的一个位置。 // 它不进召回、也不该占掉语义检索 top-K 的一个位置。
//
// `item.ExpiresAt`(临时记忆的时效,design/03 §9.6)同在这一处落库为
// `expires_at` 列:Add 是全仓唯一 INSERT 路径(design/23 §15.1 D3-1),
// 因此时效列的真生产者**只能**在这里 —— 读侧时效闸(timegate.go)比对的
// 就是这一列,若写入侧不写出它,闸就退化成恒真的空壳。
func (s *Store) Add(ctx context.Context, item MemoryItem) (MemoryItem, error) { func (s *Store) Add(ctx context.Context, item MemoryItem) (MemoryItem, error) {
if err := item.normalize(); err != nil { if err := item.normalize(); err != nil {
return MemoryItem{}, err return MemoryItem{}, err
@@ -247,12 +262,13 @@ func (s *Store) Add(ctx context.Context, item MemoryItem) (MemoryItem, error) {
plan := s.planIngest(ctx, item) plan := s.planIngest(ctx, item)
_, err = s.db.ExecContext(ctx, `INSERT INTO memory_items _, err = s.db.ExecContext(ctx, `INSERT INTO memory_items
(id, persona_id, role_id, project_id, scope, kind, content, tags, confidence, (id, persona_id, role_id, project_id, scope, kind, content, tags, confidence,
source_conversation, created_at, updated_at, decay_weight, status, merged_into) source_conversation, created_at, updated_at, decay_weight, status, merged_into,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
item.ID, item.PersonaID, item.RoleID, item.ProjectID, string(item.Scope), string(item.Kind), item.ID, item.PersonaID, item.RoleID, item.ProjectID, string(item.Scope), string(item.Kind),
item.Content, string(tagsJSON), item.Confidence, item.SourceConversation, item.Content, string(tagsJSON), item.Confidence, item.SourceConversation,
formatTime(item.CreatedAt), formatTime(item.UpdatedAt), item.DecayWeight, formatTime(item.CreatedAt), formatTime(item.UpdatedAt), item.DecayWeight,
plan.insertStatus(), plan.absorbedID()) plan.insertStatus(), plan.absorbedID(), expiryText(item.ExpiresAt))
if err != nil { if err != nil {
return MemoryItem{}, fmt.Errorf("memory: insert item: %w", err) return MemoryItem{}, fmt.Errorf("memory: insert item: %w", err)
} }
@@ -276,6 +292,11 @@ func (s *Store) Add(ctx context.Context, item MemoryItem) (MemoryItem, error) {
// Get 返回给定 id 的条目。id 为空或 // Get 返回给定 id 的条目。id 为空或
// 不存在时返回 ErrNotFound。 // 不存在时返回 ErrNotFound。
//
// 🔴 **刻意不过任何读侧闸**(status 与时效都不过;design/03 §11.2 第 3 条):
// 撤销软合并必须先读得到被吸收的行,人工核查 / 清算也要先读得到已过期与
// 已失效的行 —— 加闸等于把审计与撤销的抓手一并抹掉。要「面向用户的可见集」
// 请走 ListByPersona / ListScope / ListProject / List 或 Retriever(那些都过闸)。
func (s *Store) Get(ctx context.Context, id string) (MemoryItem, error) { func (s *Store) Get(ctx context.Context, id string) (MemoryItem, error) {
if strings.TrimSpace(id) == "" { if strings.TrimSpace(id) == "" {
return MemoryItem{}, ErrNotFound return MemoryItem{}, ErrNotFound
@@ -304,14 +325,21 @@ func (s *Store) Get(ctx context.Context, id string) (MemoryItem, error) {
// 只返回**在册**(status='active')条目:整理与入库前审查都只做软动作, // 只返回**在册**(status='active')条目:整理与入库前审查都只做软动作,
// 被合并/被归档的行内容原样留在表里,因此「能看到什么记忆」的每条读路径 // 被合并/被归档的行内容原样留在表里,因此「能看到什么记忆」的每条读路径
// 都必须过 status 闸,否则清理过的行照样会被检索到、照样占满面板。 // 都必须过 status 闸,否则清理过的行照样会被检索到、照样占满面板。
//
// 同时过**时效闸**(timeGateClause):已过期(expires_at ≤ now)与已失效
// (valid_to ≤ now)的行一并不再返回 —— 判据细节与「为什么必须下沉到这里」
// 见 timegate.go 与 design/03 §9.6/§11.2。
func (s *Store) ListByPersona(ctx context.Context, personaID string) ([]MemoryItem, error) { func (s *Store) ListByPersona(ctx context.Context, personaID string) ([]MemoryItem, error) {
if strings.TrimSpace(personaID) == "" { if strings.TrimSpace(personaID) == "" {
return nil, ErrMissingPersona return nil, ErrMissingPersona
} }
// 闸的左右操作数同源:两边都由 formatTime 产出定宽 UTC 文本。
now := timeGateNow()
rows, err := s.db.QueryContext(ctx, `SELECT id, persona_id, role_id, project_id, scope, kind, rows, err := s.db.QueryContext(ctx, `SELECT id, persona_id, role_id, project_id, scope, kind,
content, tags, confidence, source_conversation, created_at, updated_at, content, tags, confidence, source_conversation, created_at, updated_at,
decay_weight FROM memory_items WHERE persona_id = ? AND status = ? decay_weight FROM memory_items WHERE persona_id = ? AND status = ?`+timeGateClause+`
ORDER BY created_at DESC, rowid DESC`, personaID, string(tidyStatusActive)) ORDER BY created_at DESC, rowid DESC`,
personaID, string(tidyStatusActive), now, now)
if err != nil { if err != nil {
return nil, fmt.Errorf("memory: list persona items: %w", err) return nil, fmt.Errorf("memory: list persona items: %w", err)
} }
@@ -329,11 +357,15 @@ func (s *Store) ListByPersona(ctx context.Context, personaID string) ([]MemoryIt
// 管理面板的记忆列表里(design/23 §8/§10 的软动作口径)。要看它们走 // 管理面板的记忆列表里(design/23 §8/§10 的软动作口径)。要看它们走
// 审查留痕 `GET /admin/memories/reviews` 或按 id 用 Get —— 后者刻意 // 审查留痕 `GET /admin/memories/reviews` 或按 id 用 Get —— 后者刻意
// 不过 status 闸,因为撤销软合并必须先读得到被吸收的行。 // 不过 status 闸,因为撤销软合并必须先读得到被吸收的行。
//
// 同样过**时效闸**:临时记忆到期(expires_at ≤ now)与事实失效
// (valid_to ≤ now)的行一并从这里消失(timegate.go、design/03 §9.6)。
func (s *Store) List(ctx context.Context) ([]MemoryItem, error) { func (s *Store) List(ctx context.Context) ([]MemoryItem, error) {
now := timeGateNow()
rows, err := s.db.QueryContext(ctx, `SELECT id, persona_id, role_id, project_id, scope, kind, rows, err := s.db.QueryContext(ctx, `SELECT id, persona_id, role_id, project_id, scope, kind,
content, tags, confidence, source_conversation, created_at, updated_at, content, tags, confidence, source_conversation, created_at, updated_at,
decay_weight FROM memory_items WHERE status = ? decay_weight FROM memory_items WHERE status = ?`+timeGateClause+`
ORDER BY created_at DESC, rowid DESC`, string(tidyStatusActive)) ORDER BY created_at DESC, rowid DESC`, string(tidyStatusActive), now, now)
if err != nil { if err != nil {
return nil, fmt.Errorf("memory: list items: %w", err) return nil, fmt.Errorf("memory: list items: %w", err)
} }
+23 -6
View File
@@ -119,12 +119,17 @@ func (t *Tidier) RunOnce(ctx context.Context) (TidyStats, error) {
return stats, err return stats, err
} }
stats.RunID = runID stats.RunID = runID
// ledger 是**本轮实际发生**的召回来源记账口(不是配置里那一种):生产轮可以
// 多分区混合(有的走语义、有的降级),因此汇总行只能由实测记录推出。
ledger := &recallLedger{}
failures := t.scanPartitions(ctx, partitions, &stats, tidyRoundPolicy{ failures := t.scanPartitions(ctx, partitions, &stats, tidyRoundPolicy{
sink: func(ctx context.Context, cands []TidyCandidate, stats *TidyStats) { sink: func(ctx context.Context, cands []TidyCandidate, stats *TidyStats) {
t.persistCandidates(ctx, runID, cands, stats) t.persistCandidates(ctx, runID, cands, stats)
}, },
recall: t.fallbackRecall, recall: func(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats, budget tidyBudget) ([]tidyPair, error) {
return t.fallbackRecall(ctx, p, items, stats, budget, ledger)
},
}) })
// 轮末自动落地(2026-10-07 主人口径):规则层判为高置信的待定候选直接落地, // 轮末自动落地(2026-10-07 主人口径):规则层判为高置信的待定候选直接落地,
@@ -150,7 +155,7 @@ func (t *Tidier) RunOnce(ctx context.Context) (TidyStats, error) {
failures = append(failures, err) failures = append(failures, err)
} }
t.logRun(stats, run) t.logRun(stats, run, ledger)
t.auditAutoApply(auto) t.auditAutoApply(auto)
switch { switch {
@@ -165,7 +170,14 @@ func (t *Tidier) RunOnce(ctx context.Context) (TidyStats, error) {
// logRun 输出本轮的可观测口径:扫描 N / 候选 M / 建议 K / 否决原因分布。 // logRun 输出本轮的可观测口径:扫描 N / 候选 M / 建议 K / 否决原因分布。
// 它带 source(memory/tidy),因此这条汇总永远可回溯到整理子系统。 // 它带 source(memory/tidy),因此这条汇总永远可回溯到整理子系统。
func (t *Tidier) logRun(stats TidyStats, run TidyRun) { //
// ledger 给出的是**本轮实际发生**的召回来源(不是配置态)。这一条是 M6 2026-10-13
// 的 medium finding 的落点:旧实现按「有没有配 embedder」写 recall_mode,全量降级
// 在汇总行里被写成 embedding。现在:
// - recall_mode = "":没有任何分区真正算过相似度(没有候选可比);
// - recall_mode = lexical ∧ recall_degraded_partitions > 0:语义不可用、降级了;
// - recall_mode = mixed:多分区里一部分走了语义、一部分降级 —— 不再被抹平。
func (t *Tidier) logRun(stats TidyStats, run TidyRun, ledger *recallLedger) {
fields := []any{ fields := []any{
"run_id", run.ID, "run_id", run.ID,
"rule_version", run.RuleVersion, "rule_version", run.RuleVersion,
@@ -181,7 +193,8 @@ func (t *Tidier) logRun(stats TidyStats, run TidyRun) {
"kept", stats.Kept, "kept", stats.Kept,
"errors", run.Errors, "errors", run.Errors,
"duration_ms", run.FinishedAt.Sub(run.StartedAt).Milliseconds(), "duration_ms", run.FinishedAt.Sub(run.StartedAt).Milliseconds(),
"recall_mode", tidyRecallMode(t.deps.Embedder != nil), "recall_mode", ledger.label(),
"recall_degraded_partitions", ledger.degradedPartitions(),
"auto_apply", t.cfg.autoApplyEnabled(), "auto_apply", t.cfg.autoApplyEnabled(),
"auto_applied", stats.AutoApplied, "auto_applied", stats.AutoApplied,
"auto_merge", stats.AutoMerged, "auto_merge", stats.AutoMerged,
@@ -232,11 +245,15 @@ func (t *Tidier) scanPartitions(ctx context.Context, partitions []tidyPartition,
// fallbackRecall 是**生产轮**的召回:相似度来源不可用时降级到零依赖的词项 // fallbackRecall 是**生产轮**的召回:相似度来源不可用时降级到零依赖的词项
// 余弦(离线/无 embedding 配置时可跑,宁可比语义路径更保守),并记一条 WARN。 // 余弦(离线/无 embedding 配置时可跑,宁可比语义路径更保守),并记一条 WARN。
// 两个来源**不可混用阈值**:阈值只有一个(design §13 开放点 2 的 0.80)。 // 两个来源**不可混用阈值**:阈值只有一个(design §13 开放点 2 的 0.80)。
func (t *Tidier) fallbackRecall(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats, budget tidyBudget) ([]tidyPair, error) { //
pairs, err := t.recallPairs(ctx, p, items, stats, budget) // 降级这一笔**必须记进 ledger**:汇总行/面板上的召回来源只能由实测记录推出,
// 否则「全量降级」会被报成「向量正常」(M6 2026-10-13 的 medium finding)。
func (t *Tidier) fallbackRecall(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats, budget tidyBudget, ledger *recallLedger) ([]tidyPair, error) {
pairs, err := t.recallPairs(ctx, p, items, stats, budget, ledger)
if err == nil { if err == nil {
return pairs, nil return pairs, nil
} }
ledger.noteLexical(true)
t.log.Warn("memory tidy embedding recall unavailable, falling back to lexical", t.log.Warn("memory tidy embedding recall unavailable, falling back to lexical",
"partition", p.label(), "error", err) "partition", p.label(), "error", err)
return t.collectPairs(p, items, t.cfg.RecallThreshold, return t.collectPairs(p, items, t.cfg.RecallThreshold,
+81 -10
View File
@@ -4,14 +4,15 @@ import (
"time" "time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
) )
// 本文件收拢记忆整理(design/23-记忆整理与去重)全部的标识、默认值与旋钮, // 本文件收拢记忆整理(design/23-记忆整理与去重)全部的标识、默认值与旋钮,
// 使运行路径上不出现裸魔法数字(AGENTS §10),并让「设计稿默认值」 // 使运行路径上不出现裸魔法数字(AGENTS §10),并让「设计稿默认值」
// 只有一处定义、可被复核。 // 只有一处定义、可被复核。
// //
// 形态(design/23 §4 方案 A/C):整理是一件**低峰定期后台任务** —— 跑在 // 形态(design/23 §4 方案 A/C):整理是一件**定期后台任务**(安静时段降频)——
// 专属隔离会话下、零上下文启动、只产候选**不写记忆**,绝不是会话内派 // 跑在专属隔离会话下、零上下文启动、只产候选**不写记忆**,绝不是会话内派
// subagent。 // subagent。
const ( const (
@@ -37,8 +38,11 @@ const (
defaultTidyIntervalSec = 86400 defaultTidyIntervalSec = 86400
// defaultTidyQuietStartHour / defaultTidyQuietEndHour 复用 backthink 的 // defaultTidyQuietStartHour / defaultTidyQuietEndHour 复用 backthink 的
// 安静时段语义(默认 23:00-07:00,窗口跨午夜;两端相等且非 0 则关闭窗口) // 安静时段语义(默认 23:00-07:00,窗口跨午夜;两端相等且非 0 则关闭窗口)。
// —— 整理只在低峰跑(design/23 §4)。 //
// 🔴 2026-10-07 起窗口不再等于「唯一可跑时段」:它是**降频档位**的判据
// (安静时段用 defaultTidyIntervalSec 之外的另一档,见 intervalAt),并且
// 仍然决定「首次/未跑过的窗口要补跑一轮」(dueNow 判据 1/3)。
defaultTidyQuietStartHour = 23 defaultTidyQuietStartHour = 23
defaultTidyQuietEndHour = 7 defaultTidyQuietEndHour = 7
@@ -53,7 +57,7 @@ const (
// maxTidyCompareItems 是每分区每轮**真正参与两两比对**的条目上限的兜底默认 // maxTidyCompareItems 是每分区每轮**真正参与两两比对**的条目上限的兜底默认
// 值(可直接从 MEMORY_TIDY_COMPARE_LIMIT 调,见 TidyConfig.CompareLimit)。 // 值(可直接从 MEMORY_TIDY_COMPARE_LIMIT 调,见 TidyConfig.CompareLimit)。
// 两两相似度是 O(n²·dim),5000 条会到十亿级乘加,低峰任务也不该跑成那样。 // 两两相似度是 O(n²·dim),5000 条会到十亿级乘加,后台任务也不该跑成那样。
// 2026-10-06 由 400 抬到 2000:首轮真跑 459 条的分区被旧值截到 400, // 2026-10-06 由 400 抬到 2000:首轮真跑 459 条的分区被旧值截到 400,
// 于是 compared(705) < scanned(784) —— 截断确实如实登记了,但护栏成为结 // 于是 compared(705) < scanned(784) —— 截断确实如实登记了,但护栏成为结
// 果的主因本身就是缺陷。超限仍按「最新优先」截断并记一条 WARN(绝不静默), // 果的主因本身就是缺陷。超限仍按「最新优先」截断并记一条 WARN(绝不静默),
@@ -71,7 +75,17 @@ const (
// tidyEmbedBatch 是单次 Embed 调用的文本条数上限,避免把整个分区的文本 // tidyEmbedBatch 是单次 Embed 调用的文本条数上限,避免把整个分区的文本
// 一次性交给远端 embedding API。 // 一次性交给远端 embedding API。
tidyEmbedBatch = 64 //
// 🔴 它**不是**拍出来的数字,而是 internal/runtime/embedding 里**同一条推演**
// 的结果(budget.go:一次请求的耗时上界 ≤ 单次超时的一半 ⇒ 批上限 = 超时的一半
// ÷ 单条预算上界)。2026-10-13:旧值 64 的实测耗时 7.5~8.5s 已越过客户端 5s
// 单次超时 ⇒ 客户端放弃、服务端继续算 ⇒ 本机 embedding 服务被顶住,单条请求
// (写向量索引)也跟着超时。把批上限与超时绑到同一条推演上,就不会再出现
// 「只调一侧、另一侧静默失效」。
//
// 批上限在 embedder 内部**还有一道强制**(Embed 自己切片),因此这里即使被改大
// 也不会把服务端顶住;这一行只是让调用方别去构造注定被切开的超大切片。
tidyEmbedBatch = embedding.DefaultBatchLimit
// minTidyContentRunes 是参与比对的最小内容长度(按 rune)。过短的内容 // minTidyContentRunes 是参与比对的最小内容长度(按 rune)。过短的内容
// (如单字)没有可判定的事实要素,进比对只会产出噪声,直接跳过。 // (如单字)没有可判定的事实要素,进比对只会产出噪声,直接跳过。
@@ -123,20 +137,39 @@ const (
maxTidyRejectBatchLimit = 10000 maxTidyRejectBatchLimit = 10000
) )
// 编译期不变式(2026-10-13):批上限一旦被调回超过 embedding 推出来的值
// (例:写回旧值 64),下面这行常量表达式会变成负数、uint 溢出 ⇒ **编译失败**。
// 这条断言把「批上限与超时同源」从注释里的承诺变成编译器守的门。
const _ = uint(embedding.DefaultBatchLimit - tidyEmbedBatch)
// TidyConfig 携带记忆整理的调优旋钮。零值经 normalize 选取安全默认值, // TidyConfig 携带记忆整理的调优旋钮。零值经 normalize 选取安全默认值,
// 因此半填的 Config 仍可用。 // 因此半填的 Config 仍可用。
type TidyConfig struct { type TidyConfig struct {
// Enabled 是总开关。关闭时组合根不建服务、不跑任何整理 // Enabled 是总开关。关闭时组合根不建服务、不跑任何整理
// (design/23 §4 的 MEMORY_TIDY_ENABLED)。 // (design/23 §4 的 MEMORY_TIDY_ENABLED)。
Enabled bool Enabled bool
// Interval 是整理周期。为零时由 IntervalSec(或其默认值)推导。 // Interval 是整理周期(**非安静时段**)。为零时由 IntervalSec(或其默认值)推导。
Interval time.Duration Interval time.Duration
// IntervalSec 是整理周期秒数,映射自 config.MemoryTidyConfig.IntervalSec。 // IntervalSec 是整理周期秒数(**非安静时段**),映射自
// 设置了 Interval 时忽略。它是**下限**:安静时段每天只有一次, // config.MemoryTidyConfig.IntervalSec(env),并可由
// 因此实际间隔取两者中较长者。 // `$DATA_DIR/config/memory-tidy.json` 的 `intervalSec` 覆盖(面板优先、`.env`
// 兜底,唯一实现点是 ApplyTidyScheduleTuning)。设置了 Interval 时忽略。
IntervalSec int IntervalSec int
// QuietIntervalSec 是**安静时段**的整理周期秒数(2026-10-07 主人「或者动态
// 分配,比如白天间隔短一点,晚上到了静默时期稍微长一点」)。
//
// 非正 ⇒ 等于 IntervalSec:**缺省 = 不因时段换档**,而不是「夜里不跑」
// (normalize 里那一条是唯一落点)。它没有 env 键(`.env` 已冻结),只由
// `$DATA_DIR/config/memory-tidy.json` 的 `quietIntervalSec` 提供。
QuietIntervalSec int
// QuietInterval 是安静时段的整理周期。为零时由 QuietIntervalSec 推导。
QuietInterval time.Duration
// QuietStartHour / QuietEndHour 以本地钟点约束低峰窗口 // QuietStartHour / QuietEndHour 以本地钟点约束低峰窗口
// (复用 backthink 语义:窗口可跨午夜,两端相等且非 0 则关闭窗口)。 // (复用 backthink 语义:窗口可跨午夜,两端相等且非 0 则关闭窗口)。
//
// 🔴 2026-10-07 起窗口的**语义一字未改**(inQuietHours 逐字不动),但它的
// **作用面**收窄了一格:从「整理唯一可跑的时段」变成「整理降频的时段」
// (见 intervalAt)。理由是主人的频率口径要求白天也跑,且白天跑得比夜里勤。
QuietStartHour int QuietStartHour int
QuietEndHour int QuietEndHour int
// RecallThreshold 是候选召回的余弦门(见 defaultTidyRecallThreshold)。 // RecallThreshold 是候选召回的余弦门(见 defaultTidyRecallThreshold)。
@@ -165,6 +198,10 @@ type TidyConfig struct {
// TidyConfigFromConfig 把 config.MemoryTidyConfig(env 侧旋钮)映射成 // TidyConfigFromConfig 把 config.MemoryTidyConfig(env 侧旋钮)映射成
// TidyConfig,env 未暴露的项保持零值,交给 normalize 填默认值。 // TidyConfig,env 未暴露的项保持零值,交给 normalize 填默认值。
//
// 它是**兜底层**:组合根随后用 ApplyTidyScheduleTuning 把
// `$DATA_DIR/config/memory-tidy.json` 的调度旋钮叠上来(面板优先、env 兜底);
// env 没有的项(如 quietIntervalSec)在这里保持零值,由 normalize 取默认。
func TidyConfigFromConfig(cfg config.MemoryTidyConfig) TidyConfig { func TidyConfigFromConfig(cfg config.MemoryTidyConfig) TidyConfig {
return TidyConfig{ return TidyConfig{
Enabled: cfg.Enabled, Enabled: cfg.Enabled,
@@ -186,6 +223,14 @@ func normalizeTidyConfig(cfg TidyConfig) TidyConfig {
if cfg.Interval <= 0 { if cfg.Interval <= 0 {
cfg.Interval = time.Duration(cfg.IntervalSec) * time.Second cfg.Interval = time.Duration(cfg.IntervalSec) * time.Second
} }
if cfg.QuietIntervalSec <= 0 {
// 缺省 = 与普通间隔同档。放在 IntervalSec 归一之后,因此「面板/env 都没给
// quietIntervalSec」时它取的是**已解析的** intervalSec,而不是魔法数字。
cfg.QuietIntervalSec = cfg.IntervalSec
}
if cfg.QuietInterval <= 0 {
cfg.QuietInterval = time.Duration(cfg.QuietIntervalSec) * time.Second
}
if cfg.QuietStartHour == 0 && cfg.QuietEndHour == 0 { if cfg.QuietStartHour == 0 && cfg.QuietEndHour == 0 {
cfg.QuietStartHour = defaultTidyQuietStartHour cfg.QuietStartHour = defaultTidyQuietStartHour
cfg.QuietEndHour = defaultTidyQuietEndHour cfg.QuietEndHour = defaultTidyQuietEndHour
@@ -230,6 +275,32 @@ func (c TidyConfig) inQuietHours(at time.Time) bool {
return h >= c.QuietStartHour || h < c.QuietEndHour return h >= c.QuietStartHour || h < c.QuietEndHour
} }
// intervalAt 按 **at 所在时段** 选整理周期:安静时段用 QuietInterval,其余用
// Interval(= design/12 §N A1 给后台思考心跳定下的「窗口内降频」口径,搬进整理)。
//
// 主人 2026-10-07 的原话是「**频率必须调**,可以调到 1 小时或者三小时,**或者动态
// 分配**,比如白天间隔短一点,晚上到了静默时期稍微长一点」——这个函数就是「动态
// 分配」的唯一判据。
//
// 它是**纯函数**(只读 cfg 与入参),因此心跳可以在**每一拍之前**重算它:跨过
// 安静时段边界之后,下一拍自然换档,而不是启动时定死一次(调度侧的用法见
// TidyService.nextDelay)。
//
// 两条口径:
// - 安静判定**直接复用** inQuietHours,跨午夜口径与其余各处以字节为单位一致,
// 这里不另写一份「越走越偏」的窗口算法;
// - 窗口被显式关闭(两端相等且非 0)时不换档 —— 关闭窗口的意思是「没有低峰
// 这回事」,而不是「全天都算低峰」。
func (c TidyConfig) intervalAt(at time.Time) time.Duration {
if c.quietWindowDisabled() {
return c.Interval
}
if c.QuietInterval > 0 && c.inQuietHours(at) {
return c.QuietInterval
}
return c.Interval
}
// nextQuietStart 返回 at 之后(严格晚于 at)的下一个低峰窗口起点: // nextQuietStart 返回 at 之后(严格晚于 at)的下一个低峰窗口起点:
// 今天/明天的 QuietStartHour 整点。窗口关闭时返回零值时间,调用方据此 // 今天/明天的 QuietStartHour 整点。窗口关闭时返回零值时间,调用方据此
// 改走纯周期路径。 // 改走纯周期路径。
+35 -7
View File
@@ -8,7 +8,7 @@ import (
// 本文件是记忆整理的**试算(dry-run)**入口(契约 v0.75;切片 MT1)。 // 本文件是记忆整理的**试算(dry-run)**入口(契约 v0.75;切片 MT1)。
// //
// 它与低峰心跳跑的是**同一条**规则路径(扫描 → 召回 → 包含判定 → 规则否决 → // 它与心跳跑的是**同一条**规则路径(扫描 → 召回 → 包含判定 → 规则否决 →
// 构造候选),只有两点刻意不同: // 构造候选),只有两点刻意不同:
// //
// 1. **零副作用**:候选不落库 —— 不写 memory_tidy_candidates、不写 // 1. **零副作用**:候选不落库 —— 不写 memory_tidy_candidates、不写
@@ -52,11 +52,15 @@ type TidyDryRun struct {
// 「完整的建议」展示)。 // 「完整的建议」展示)。
func (t *Tidier) DryRun(ctx context.Context) (TidyDryRun, error) { func (t *Tidier) DryRun(ctx context.Context) (TidyDryRun, error) {
started := t.now() started := t.now()
out := TidyDryRun{RecallMode: tidyRecallMode(t.deps.Embedder != nil)} out := TidyDryRun{}
stats, partitions, err := t.beginRound(ctx) stats, partitions, err := t.beginRound(ctx)
if err != nil { if err != nil {
return out, err return out, err
} }
// ledger 是**本轮实际发生**的召回来源:试算会写进 RecallMode 返回给面板。
// 旧实现按「有没有配 embedder」写(tidyRecallMode(deps.Embedder != nil)),
// 那会让面板把「向量不可用」读成「向量正常」(M6 2026-10-13 的 medium finding)。
ledger := &recallLedger{}
// 收集出口:闭包只在 scanPartitions 的**串行**循环里被调用,因此无需加锁。 // 收集出口:闭包只在 scanPartitions 的**串行**循环里被调用,因此无需加锁。
collected := make([]TidyCandidate, 0) collected := make([]TidyCandidate, 0)
@@ -64,25 +68,38 @@ func (t *Tidier) DryRun(ctx context.Context) (TidyDryRun, error) {
sink: func(_ context.Context, cands []TidyCandidate, _ *TidyStats) { sink: func(_ context.Context, cands []TidyCandidate, _ *TidyStats) {
collected = append(collected, cands...) collected = append(collected, cands...)
}, },
recall: t.strictRecall, recall: func(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats, budget tidyBudget) ([]tidyPair, error) {
return t.strictRecall(ctx, p, items, stats, budget, ledger)
},
} }
failures := t.scanPartitions(ctx, partitions, &stats, policy) failures := t.scanPartitions(ctx, partitions, &stats, policy)
out.Stats = stats out.Stats = stats
out.Candidates = collected out.Candidates = collected
// 如实上报**实际发生**的来源:空串 = 没有任何分区真正算过相似度(「没候选」)。
// 「尝试过但向量源不可用」是另一件事,由 recall_unavailable_partitions 单独给出
// (见 logDryRun 的说明与 recallLedger.unavailable)。
out.RecallMode = ledger.label()
out.DurationMS = t.now().Sub(started).Milliseconds() out.DurationMS = t.now().Sub(started).Milliseconds()
// 汇总行**成败都记**(与 RunOnce 同一口径):失败那一路的
// `recall_unavailable_partitions` 正是「向量不可用」与「没候选」的唯一区分点,
// 只在成功时记就等于把这条口径丢掉。
t.logDryRun(out, ledger)
if len(failures) > 0 { if len(failures) > 0 {
return out, fmt.Errorf("memory/tidy: dry-run finished with errors: %w", errors.Join(failures...)) return out, fmt.Errorf("memory/tidy: dry-run finished with errors: %w", errors.Join(failures...))
} }
t.logDryRun(out)
return out, nil return out, nil
} }
// strictRecall 是**试算轮**的召回:语义召回不可用时**不**降级,如实上报。 // strictRecall 是**试算轮**的召回:语义召回不可用时**不**降级,如实上报。
// 日志只记分区与错误,绝不带记忆正文(候选预览里已含正文)。 // 日志只记分区与错误,绝不带记忆正文(候选预览里已含正文)。
func (t *Tidier) strictRecall(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats, budget tidyBudget) ([]tidyPair, error) { //
pairs, err := t.recallPairs(ctx, p, items, stats, budget) // 失败时要往 ledger 记一笔 unavailable:否则「尝试过但向量源不可用」与「没有候选
// 可比」在观测面上同值(reviewer round1 的 p2)。
func (t *Tidier) strictRecall(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats, budget tidyBudget, ledger *recallLedger) ([]tidyPair, error) {
pairs, err := t.recallPairs(ctx, p, items, stats, budget, ledger)
if err != nil { if err != nil {
ledger.noteUnavailable()
t.log.Warn("memory tidy dry-run recall unavailable", t.log.Warn("memory tidy dry-run recall unavailable",
"partition", p.label(), "items", len(items), "error", err) "partition", p.label(), "items", len(items), "error", err)
return nil, fmt.Errorf("%w: %w", ErrTidyRecallUnavailable, err) return nil, fmt.Errorf("%w: %w", ErrTidyRecallUnavailable, err)
@@ -92,7 +109,17 @@ func (t *Tidier) strictRecall(ctx context.Context, p tidyPartition, items []Memo
// logDryRun 输出试算的观测口径。它带 source(memory/tidy),**不带任何记忆 // logDryRun 输出试算的观测口径。它带 source(memory/tidy),**不带任何记忆
// 正文** —— 只记计数、分区数与截断标签。 // 正文** —— 只记计数、分区数与截断标签。
func (t *Tidier) logDryRun(out TidyDryRun) { //
// 🔴 `recall_mode` + `recall_unavailable_partitions` 一起才把三件事分开
// (reviewer round1 的 p2:它们曾同值):
// - mode=""、unavailable=0:本轮**没有候选可比**(压根没算过相似度);
// - mode=""、unavailable>0:**尝试过召回但向量源不可用**(严格口径不降级,
// 调用方拿到的是 `ErrTidyRecallUnavailable` ⇒ HTTP 500 `tidy_recall_unavailable`);
// - mode=embedding/lexical:真的用该来源算过。
//
// 没有为此扩 `recallMode` 的取值集合:试算失败时**不序列化** result(响应是错误
// 体),一个新取值只会出现在这行日志里,却要动一次 API 契约与文档 —— 不划算。
func (t *Tidier) logDryRun(out TidyDryRun, ledger *recallLedger) {
t.log.Info("memory tidy dry-run finished", t.log.Info("memory tidy dry-run finished",
"rule_version", TidyRuleVersion, "rule_version", TidyRuleVersion,
"partitions", out.Stats.Partitions, "partitions", out.Stats.Partitions,
@@ -104,6 +131,7 @@ func (t *Tidier) logDryRun(out TidyDryRun) {
"veto_reasons", tidyReasonsLabel(out.Stats.VetoReasons), "veto_reasons", tidyReasonsLabel(out.Stats.VetoReasons),
"errors", out.Stats.Errors, "errors", out.Stats.Errors,
"recall_mode", out.RecallMode, "recall_mode", out.RecallMode,
"recall_unavailable_partitions", ledger.unavailablePartitions(),
"duration_ms", out.DurationMS) "duration_ms", out.DurationMS)
} }
@@ -0,0 +1,187 @@
package memory
import (
"context"
"testing"
"time"
)
// 本文件锁定**分时段的动态频率**(主人 2026-10-07:「频率必须调 …… 或者动态分配,
// 比如白天间隔短一点,晚上到了静默时期稍微长一点」):
//
// ① 非安静时段用 intervalSec、安静时段用 quietIntervalSec(intervalAt);
// ② 缺省(quietIntervalSec 未配)时两者相等 —— 也就是**行为与改动前一致**;
// ③ 间隔在**每一轮跑完之后按当时时段重算**(不是在启动时算一次定死):
// 否则白天/晚上的切换不会生效;
// ④ 到点判据(dueNow)用**当时时段**的周期,与 nextDelay 同源,不会各说各话;
// ⑤ 安静时段不再是「唯一可跑时段」:窗口外也按 intervalSec 跑(①的直接推论,
// 由 ③ 的用例端到端证明)。
// quietWindowCfg 造一份分档周期配置:安静 22:00-02:00(跨午夜),
// 白天 2h 一次、夜里 6h 一次。
func quietWindowCfg(t *testing.T) TidyConfig {
t.Helper()
cfg := tidyTestCfg(0.80)
cfg.IntervalSec = 2 * 3600
cfg.QuietIntervalSec = 6 * 3600
cfg.QuietStartHour, cfg.QuietEndHour = 22, 2
return cfg
}
// TestTidyConfigIntervalAt_SwitchesByPeriod 钉住 intervalAt 的取档口径与
// inQuietHours 逐字同源(含跨午夜、边界、窗口关闭)。
func TestTidyConfigIntervalAt_SwitchesByPeriod(t *testing.T) {
t.Parallel()
cfg := normalizeTidyConfig(quietWindowCfg(t))
at := func(hour, min int) time.Time {
return time.Date(2026, 10, 7, hour, min, 0, 0, time.Local)
}
cases := []struct {
name string
at time.Time
want time.Duration
}{
{"白天(14:00)用 intervalSec", at(14, 0), 2 * time.Hour},
{"窗口前一分钟(21:59)仍用 intervalSec", at(21, 59), 2 * time.Hour},
{"窗口起点(22:00)用 quietIntervalSec", at(22, 0), 6 * time.Hour},
{"跨午夜的半夜(00:30)用 quietIntervalSec", at(0, 30), 6 * time.Hour},
{"窗口终点前(01:59)用 quietIntervalSec", at(1, 59), 6 * time.Hour},
{"窗口终点(02:00)回到 intervalSec", at(2, 0), 2 * time.Hour},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
got := cfg.intervalAt(tc.at)
if got != tc.want {
t.Fatalf("intervalAt(%s) = %s, want %s", tc.at.Format("15:04"), got, tc.want)
}
// 与既有安静判定**同源**:落在哪一档当且仅当 inQuietHours。
want := cfg.Interval
if cfg.inQuietHours(tc.at) {
want = cfg.QuietInterval
}
if got != want {
t.Fatalf("intervalAt 与 inQuietHours 不同源:%s interval=%s, 期望 %s",
tc.at.Format("15:04"), got, want)
}
})
}
// 缺省(quietIntervalSec 未配)⇒ 两档相等 = 行为与本次改动之前逐字一致。
plain := normalizeTidyConfig(tidyTestCfg(0.80))
if plain.QuietIntervalSec != plain.IntervalSec || plain.QuietInterval != plain.Interval {
t.Fatalf("缺省时两档必须相等:interval=%s quiet=%s", plain.Interval, plain.QuietInterval)
}
if got := plain.intervalAt(at(23, 30)); got != plain.Interval {
t.Fatalf("缺省时分档不得改变频率:得到 %s, want %s", got, plain.Interval)
}
// 窗口被显式关闭(两端相等且非 0)⇒ 不换档:关闭窗口的意思是「没有低峰这回事」,
// 而不是「全天都算低峰」。
closed := normalizeTidyConfig(quietWindowCfg(t))
closed.QuietStartHour, closed.QuietEndHour = 5, 5
for _, h := range []int{0, 5, 12, 23} {
if got := closed.intervalAt(at(h, 0)); got != closed.Interval {
t.Fatalf("窗口关闭时 %02d:00 的间隔 = %s, want %s", h, got, closed.Interval)
}
}
}
// TestTidyServiceDueNowUsesPeriodInterval 钉住「到点」判据用的是**当时时段**的周期。
//
// 用例刻意让「按白天档会判到点、按夜里档不会」:安静 23:00-07:00,白天 1h、
// 夜里 8h。上次跑在 23:05,此刻 01:00 ⇒ 距上次 1h55m:若拿白天的 1h 去比会判
// 「到点」(那是错的),拿夜里的 8h 才判「不到点」。
func TestTidyServiceDueNowUsesPeriodInterval(t *testing.T) {
t.Parallel()
store := newTestStore(t)
cfg := tidyTestCfg(0.80)
cfg.IntervalSec = 3600
cfg.QuietIntervalSec = 8 * 3600
cfg.QuietStartHour, cfg.QuietEndHour = 23, 7
svc := newTidyServiceForTest(t, store, cfg, nil, nil, nil)
set := func(now, last time.Time) {
svc.now = func() time.Time { return now }
svc.lastRun = last
}
last := time.Date(2026, 10, 7, 23, 5, 0, 0, time.Local)
set(time.Date(2026, 10, 8, 1, 0, 0, 0, time.Local), last)
if due, reason := svc.dueNow(); due {
t.Fatalf("夜里距上次 1h55m 不该到点(夜里的档是 8h),得到 due=true(%s)", reason)
}
set(time.Date(2026, 10, 9, 1, 0, 0, 0, time.Local), last)
if due, _ := svc.dueNow(); !due {
t.Fatal("夜里距上次已 25h55m 必须到点(≥ 8h)")
}
// 白天档:上次跑在 09:00,此刻 10:30 ⇒ 1.5h ≥ 1h ⇒ 到点。
set(time.Date(2026, 10, 7, 10, 30, 0, 0, time.Local),
time.Date(2026, 10, 7, 9, 0, 0, 0, time.Local))
if due, _ := svc.dueNow(); !due {
t.Fatal("白天距上次 1.5h 必须到点(白天的档是 1h)")
}
}
// TestTidyServiceRecomputesIntervalAfterEachRound 是**动态频率**的端到端证明:
// 安静 22:00-02:00(跨午夜),白天 2h、夜里 6h。20:00 从零启动(无历史)⇒
// 首次落在窗口起点 22:00;此后:
//
// 22:00 跑完 ⇒ 按**当时**(窗口内)的档 = 6h ⇒ 下一拍 04:00(已在窗口外)
// 04:00 跑完 ⇒ 按白天的档 = 2h ⇒ 06:00 ⇒ 08:00
//
// 断言的是**留痕的时刻序列**与其间隔:22:00 → 04:00 是 6h(夜里的档),
// 04:00 → 06:00 → 08:00 都是 2h(白天的档)。若把间隔在启动时算一次定死
// (或全程按同一个周期跑),这段序列不可能是这个形状 —— 变异用例钉住了它。
func TestTidyServiceRecomputesIntervalAfterEachRound(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
seedTidyItem(t, store, habitItem(fixtureHabitShort, tidyTestTime(t), 0.9))
clock := &schedClock{at: time.Date(2026, 10, 7, 20, 0, 0, 0, time.Local)}
probe := newTidyWaitProbe(clock, 4) // 4 拍之后退出(心跳 #5 返回 false)
svc := newTidyServiceForTest(t, store, quietWindowCfg(t), nil, clock.now, nil)
svc.wait = probe.wait
svc.Start(ctx)
probe.awaitCalls(t, 5)
if err := svc.Stop(ctx); err != nil {
t.Fatalf("Stop: %v", err)
}
first := time.Date(2026, 10, 7, 22, 0, 0, 0, time.Local)
want := []time.Time{
first.Add(10 * time.Hour), // 08:00(次日)
first.Add(8 * time.Hour), // 06:00
first.Add(6 * time.Hour), // 04:00(窗口外)
first, // 22:00(窗口起点)
}
gotRuns := tidyRunTimes(t, store)
if len(gotRuns) != len(want) {
t.Fatalf("运行留痕 = %v, want %d 轮", gotRuns, len(want))
}
for i, w := range want {
if gotRuns[i] != w.UTC().Format(time.RFC3339) {
t.Fatalf("第 %d 轮留痕 = %s, want %s(全部:%v)",
i+1, gotRuns[i], w.UTC().Format(time.RFC3339), gotRuns)
}
}
// 间隔必须**分档**:第一段(夜里)6h,其后(白天)2h。这一条同时证明窗口外也跑。
// want 是「最新在前」,因此这里按时间顺序取差。
gaps := []time.Duration{
want[2].Sub(want[3]), // 22:00 → 04:00:跨过窗口终点,用的是夜里的档
want[1].Sub(want[2]), // 04:00 → 06:00:窗口外,用白天的档
want[0].Sub(want[1]), // 06:00 → 08:00
}
if gaps[0] != 6*time.Hour {
t.Fatalf("夜里那一轮之后的间隔 = %s, want 6h(quietIntervalSec)", gaps[0])
}
if gaps[1] != 2*time.Hour || gaps[2] != 2*time.Hour {
t.Fatalf("白天间隔 = %s / %s, want 2h(intervalSec)", gaps[1], gaps[2])
}
}
+108 -9
View File
@@ -25,8 +25,105 @@ const (
tidyRecallEmbedding = "embedding" tidyRecallEmbedding = "embedding"
// tidyRecallLexical 是本轮相似度来自零依赖的词项余弦。 // tidyRecallLexical 是本轮相似度来自零依赖的词项余弦。
tidyRecallLexical = "lexical" tidyRecallLexical = "lexical"
// tidyRecallMixed 是多分区的一轮里**部分**分区走了语义、部分降级到词项余弦。
// 它取代「挑一个代表值报上去」的做法:降级一旦发生就必须看得见。
tidyRecallMixed = "mixed"
) )
// recallLedger 记录**一轮里实际发生**的召回来源(不是配置里那一种)。
//
// 为什么需要它(M6 2026-10-13 的 medium finding):整理轮与试算轮的 `recall_mode`
// 曾按「有没有配 embedder」写出去(旧实现 tidyRecallMode(hasEmbedder bool)),于是
// **全量降级**在汇总行/面板里被写成 `embedding` —— 汇总与事实相反,运维据此判读会
// 把「向量不可用」读成「向量正常工作」。本类型把口径翻过来:只在**真的用了**那条
// 路的地方记一笔,label() 完全由这些实测记录推出。
type recallLedger struct {
// embedding / lexical 是**真正用该来源算过相似度**的分区数。
embedding int
lexical int
// degraded 是**因相似度来源不可用**而降级到词项余弦的分区数(不是「没配
// embedder」那种配置形态)。它把「没候选」(label 为空串)与「向量不可用」
// (label 为 lexical 且 degraded > 0)分开 —— 这两件事在运维上不是一回事。
degraded int
// unavailable 是**尝试过召回但向量源不可用、且本轮不降级**(试算轮的严格口径)
// 的分区数。它与 degraded 的区别是「有没有改用词项余弦」:生产轮会降级
// (degraded),试算轮只报错。
//
// 🔴 它存在的理由(reviewer round1 的 p2):试算轮在向量源不可用时
// `RecallMode` 与「没有候选可比」**同为空串**,两者在那个字段上分不开。
// 现在 provenance 由 unavailablePartitions() 单独给出,日志行能区分;
// 调用方(面板)那条路本来就有明确错误码(500 tidy_recall_unavailable),
// 因此**没有**为此扩 recallMode 的取值集合(见 logDryRun 的说明)。
unavailable int
}
// noteEmbedding 记一笔「这个分区真的用了语义 embedding」。
func (l *recallLedger) noteEmbedding() {
if l != nil {
l.embedding++
}
}
// noteLexical 记一笔「这个分区用了词项余弦」;degraded 表示它是**降级**来的
// (embedder 报错 / 向量条数错位),而不是「本来就没配 embedder」。
func (l *recallLedger) noteLexical(degraded bool) {
if l == nil {
return
}
l.lexical++
if degraded {
l.degraded++
}
}
// noteUnavailable 记一笔「这个分区尝试召回但向量源不可用,且本轮没有降级」
// (试算轮的严格口径:宁可报错,也不把「没算出来」说成「算过了,没有」)。
func (l *recallLedger) noteUnavailable() {
if l != nil {
l.unavailable++
}
}
// label 返回本轮**实际**的召回来源:
// - "":没有任何分区真正算过相似度(没有候选可比)—— 「没候选」不是「降级」;
// - embedding / lexical:所有算过的分区都走同一条路;
// - mixed:两种都有(多分区里有一部分降级)。
//
// ⚠️ 「尝试过但不可用」**不改变** label:它没有产出任何相似度,报 embedding /
// lexical 都是假的;它的存在由 unavailablePartitions() 单独给出(见 recallLedger
// 的 unavailable 字段说明)。
func (l *recallLedger) label() string {
if l == nil {
return ""
}
switch {
case l.embedding == 0 && l.lexical == 0:
return ""
case l.embedding == 0:
return tidyRecallLexical
case l.lexical == 0:
return tidyRecallEmbedding
default:
return tidyRecallMixed
}
}
// degradedPartitions 返回本轮因向量不可用而降级的分区数。
func (l *recallLedger) degradedPartitions() int {
if l == nil {
return 0
}
return l.degraded
}
// unavailablePartitions 返回本轮「尝试召回但向量源不可用、且没有降级」的分区数。
func (l *recallLedger) unavailablePartitions() int {
if l == nil {
return 0
}
return l.unavailable
}
// tidyPair 是一条被召回的候选对。 // tidyPair 是一条被召回的候选对。
type tidyPair struct { type tidyPair struct {
a, b MemoryItem a, b MemoryItem
@@ -35,18 +132,23 @@ type tidyPair struct {
mode string mode string
} }
// recallPairs 在分区内召回候选对。条目少于 2 条时为空操作(无候选、不报错)。 // recallPairs 在分区内召回候选对。条目少于 2 条时为空操作(无候选、不报错),
// 且**不往 ledger 记任何一笔** —— 「没有候选可比」不是「走哪条路召回」。
// //
// 相似度来源不可用时(embedder 报错、向量条数错位)返回错误 —— 回退决策不在 // 相似度来源不可用时(embedder 报错、向量条数错位)返回错误 —— 回退决策不在
// 这里:生产轮把它降级成词项余弦,试算轮如实上报(见 tidyRecall 策略)。 // 这里:生产轮把它降级成词项余弦,试算轮如实上报(见 tidyRecall 策略)。
// //
// ledger 是**实际发生**的来源记账口(见 recallLedger):它只在真正选定相似度
// 函数的那一行记账,因此谁也无法把它报成「配置里那一种」。
//
// 返回的对按「分数降序 + id」确定性排序,使每分区候选上限的截断可复现 // 返回的对按「分数降序 + id」确定性排序,使每分区候选上限的截断可复现
// (同一批数据两次运行产出同一批候选 ⇒ 幂等,design §4.1 A)。 // (同一批数据两次运行产出同一批候选 ⇒ 幂等,design §4.1 A)。
func (t *Tidier) recallPairs(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats, budget tidyBudget) ([]tidyPair, error) { func (t *Tidier) recallPairs(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats, budget tidyBudget, ledger *recallLedger) ([]tidyPair, error) {
if len(items) < 2 { if len(items) < 2 {
return nil, nil return nil, nil
} }
if t.deps.Embedder == nil { if t.deps.Embedder == nil {
ledger.noteLexical(false)
return t.collectPairs(p, items, t.cfg.RecallThreshold, return t.collectPairs(p, items, t.cfg.RecallThreshold,
lexicalSimFunc(items), tidyRecallLexical, stats, budget), nil lexicalSimFunc(items), tidyRecallLexical, stats, budget), nil
} }
@@ -57,6 +159,7 @@ func (t *Tidier) recallPairs(ctx context.Context, p tidyPartition, items []Memor
if len(vecs) != len(items) { if len(vecs) != len(items) {
return nil, ErrTidyEmbedCount return nil, ErrTidyEmbedCount
} }
ledger.noteEmbedding()
return t.collectPairs(p, items, t.cfg.RecallThreshold, return t.collectPairs(p, items, t.cfg.RecallThreshold,
vectorSimFunc(vecs), tidyRecallEmbedding, stats, budget), nil vectorSimFunc(vecs), tidyRecallEmbedding, stats, budget), nil
} }
@@ -205,10 +308,6 @@ func clampUnit(v float64) float64 {
return v return v
} }
// tidyRecallMode 报告本轮相似度的来源,便于运维判断阈值是否在预期口径上。 // 旧的 tidyRecallMode(hasEmbedder bool) 已删除(M6 2026-10-13 的 medium finding):
func tidyRecallMode(hasEmbedder bool) string { // 它按**配置态**报「本轮相似度来源」,把全量降级掩盖成 embedding。现在的口径是
if hasEmbedder { // recallLedger —— 只报**实际发生**的那一种,见本文件顶部的 recallLedger。
return tidyRecallEmbedding
}
return tidyRecallLexical
}
@@ -0,0 +1,216 @@
package memory
import (
"bytes"
"context"
"errors"
"log/slog"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
)
// 本文件钉 M6(2026-10-13)的 medium finding:整理轮/试算轮的 `recall_mode`
// 必须是**实际发生**的那一种,不是配置态。
//
// 旧实现(tidyRecallMode(hasEmbedder bool))按「有没有配 embedder」写,于是
// 「配了但是向量不可用 ⇒ 全量降级」在汇总行里被写成 embedding —— 汇总与事实相反。
//
// 「配了但不可用」的 embedder 复用 tidy_dryrun_test.go 的 failingEmbedder
// (正是线上那 2568 次 `embedding recall unavailable` 的形态)。
// newLogCaptureTidier 构建一个把汇总行写进 buffer 的整理器(便于断言日志字段)。
func newLogCaptureTidier(t *testing.T, store *Store, cfg TidyConfig, emb embedding.Embedder) (*Tidier, *bytes.Buffer) {
t.Helper()
buf := &bytes.Buffer{}
tr, err := NewTidier(cfg, TidyDeps{
Store: store,
Embedder: emb,
Log: slog.New(slog.NewTextHandler(buf, nil)),
})
if err != nil {
t.Fatalf("NewTidier: %v", err)
}
return tr, buf
}
// TestRecallLedger_LabelComesFromActualUse 钉记账口本身:label 完全由「真的用了
// 哪条路」推出,混合与降级都不许被抹平。
func TestRecallLedger_LabelComesFromActualUse(t *testing.T) {
t.Parallel()
cases := []struct {
name string
notes func(l *recallLedger)
want string
degraded int
}{
{name: "没算过(没候选)", notes: func(*recallLedger) {}, want: ""},
{name: "全部语义", notes: func(l *recallLedger) { l.noteEmbedding() }, want: tidyRecallEmbedding},
{name: "全部词项", notes: func(l *recallLedger) { l.noteLexical(false) }, want: tidyRecallLexical},
{
name: "降级到词项",
notes: func(l *recallLedger) { l.noteLexical(true) },
want: tidyRecallLexical,
degraded: 1,
},
{
name: "多分区混合(部分降级)",
notes: func(l *recallLedger) { l.noteEmbedding(); l.noteLexical(true) },
want: tidyRecallMixed,
degraded: 1,
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var l recallLedger
tc.notes(&l)
if got := l.label(); got != tc.want {
t.Fatalf("label = %q, want %q", got, tc.want)
}
if got := l.degradedPartitions(); got != tc.degraded {
t.Fatalf("degraded = %d, want %d", got, tc.degraded)
}
})
}
}
// TestTidyRunLog_RecallModeReportsDegradation 是 finding 的正面回归:配了 embedder
// 但它报错 ⇒ 汇总行必须写 `recall_mode=lexical` 且 `recall_degraded_partitions=1`,
// **绝不能**写成 embedding。
func TestTidyRunLog_RecallModeReportsDegradation(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
seedDryRunFixture(t, store)
emb := failingEmbedder{err: errors.New("embedding: request: context deadline exceeded")}
tr, buf := newLogCaptureTidier(t, store, tidyTestCfg(0.80), emb)
if _, err := tr.RunOnce(ctx); err != nil {
t.Fatalf("RunOnce 应当降级跑完而不是失败:%v", err)
}
line := lastLogLine(t, buf, "memory tidy run finished")
if !strings.Contains(line, "recall_mode=lexical") {
t.Fatalf("降级轮的汇总行必须写 lexical,实际:%s", line)
}
if strings.Contains(line, "recall_mode=embedding") {
t.Fatalf("降级轮被报成了 embedding(正是本 finding 的形态):%s", line)
}
if !strings.Contains(line, "recall_degraded_partitions=1") {
t.Fatalf("降级分区数没写出来:%s", line)
}
}
// TestTidyRunLog_RecallModeReportsEmbeddingWhenHealthy 是反面:embedder 正常时
// 汇总行必须如实写 embedding、降级数为 0(不能因为「怕误报」而一律写降级)。
func TestTidyRunLog_RecallModeReportsEmbeddingWhenHealthy(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
seedDryRunFixture(t, store)
tr, buf := newLogCaptureTidier(t, store, tidyTestCfg(0.80), dryRunFixtureEmbedder())
if _, err := tr.RunOnce(ctx); err != nil {
t.Fatalf("RunOnce: %v", err)
}
line := lastLogLine(t, buf, "memory tidy run finished")
if !strings.Contains(line, "recall_mode=embedding") {
t.Fatalf("健康轮必须写 embedding,实际:%s", line)
}
if !strings.Contains(line, "recall_degraded_partitions=0") {
t.Fatalf("健康轮的降级数必须为 0:%s", line)
}
}
// TestTidyRunLog_RecallModeEmptyWhenNoPairs 钉「没候选」与「向量不可用」可区分:
// 分区里只有一条(压根没有对可比)时,来源是**空串**,而不是任何一条召回路径。
func TestTidyRunLog_RecallModeEmptyWhenNoPairs(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
seedTidyItem(t, store, habitItem(fixtureHabitShort, base, 0.9))
emb := failingEmbedder{err: errors.New("embedding: request: context deadline exceeded")}
tr, buf := newLogCaptureTidier(t, store, tidyTestCfg(0.80), emb)
if _, err := tr.RunOnce(ctx); err != nil {
t.Fatalf("RunOnce: %v", err)
}
line := lastLogLine(t, buf, "memory tidy run finished")
if !strings.Contains(line, `recall_mode=""`) {
t.Fatalf("没有候选可比时来源应为空串(区别于降级),实际:%s", line)
}
if !strings.Contains(line, "recall_degraded_partitions=0") {
t.Fatalf("没有候选可比不算降级:%s", line)
}
}
// TestTidyDryRun_UnavailableIsDistinctFromNoCandidates 钉 reviewer round1 p2:
// 试算轮「尝试过召回但向量源不可用」与「没有候选可比」**必须能区分**。
//
// 两者的 `RecallMode` 都是空串(都不曾产出相似度,报 embedding/lexical 都是假的),
// 区分点在 `recall_unavailable_partitions`:不可用时为 1,没候选时为 0。调用方
// (面板)另有一条更硬的区分 —— 失败那一路不序列化 result,直接是
// `500 tidy_recall_unavailable`。
func TestTidyDryRun_UnavailableIsDistinctFromNoCandidates(t *testing.T) {
t.Parallel()
t.Run("向量源不可用", func(t *testing.T) {
t.Parallel()
store := newTestStore(t)
seedDryRunFixture(t, store)
emb := failingEmbedder{err: errors.New("embedding: request: context deadline exceeded")}
tr, buf := newLogCaptureTidier(t, store, tidyTestCfg(0.80), emb)
out, err := tr.DryRun(context.Background())
if !errors.Is(err, ErrTidyRecallUnavailable) {
t.Fatalf("试算轮必须如实上报不可用,err = %v", err)
}
if out.RecallMode == tidyRecallEmbedding {
t.Fatalf("向量不可用却把 RecallMode 报成 embedding(配置态口径):%q", out.RecallMode)
}
line := lastLogLine(t, buf, "memory tidy dry-run finished")
if !strings.Contains(line, "recall_unavailable_partitions=1") {
t.Fatalf("汇总行必须写出「尝试过但不可用」的计数,实际:%s", line)
}
})
t.Run("没有候选可比", func(t *testing.T) {
t.Parallel()
store := newTestStore(t)
base := tidyTestTime(t)
seedTidyItem(t, store, habitItem(fixtureHabitShort, base, 0.9))
emb := failingEmbedder{err: errors.New("embedding: request: context deadline exceeded")}
tr, buf := newLogCaptureTidier(t, store, tidyTestCfg(0.80), emb)
if _, err := tr.DryRun(context.Background()); err != nil {
t.Fatalf("没有对可比时试算不该报错:%v", err)
}
line := lastLogLine(t, buf, "memory tidy dry-run finished")
if !strings.Contains(line, "recall_unavailable_partitions=0") {
t.Fatalf("没候选不是「不可用」,计数必须为 0,实际:%s", line)
}
if !strings.Contains(line, `recall_mode=""`) {
t.Fatalf("没候选时来源为空串,实际:%s", line)
}
})
}
// lastLogLine 返回 buffer 里最后一条含 marker 的日志行。
func lastLogLine(t *testing.T, buf *bytes.Buffer, marker string) string {
t.Helper()
lines := strings.Split(strings.TrimSpace(buf.String()), "\n")
for i := len(lines) - 1; i >= 0; i-- {
if strings.Contains(lines[i], marker) {
return lines[i]
}
}
t.Fatalf("日志里没有找到 %q:\n%s", marker, buf.String())
return ""
}
+51 -20
View File
@@ -10,8 +10,8 @@ import (
"time" "time"
) )
// 本文件是记忆整理的**后台任务形态**(design/23 §4 方案 A/C):低峰定期跑、 // 本文件是记忆整理的**后台任务形态**(design/23 §4 方案 A/C):定期跑(安静时段
// 独立运行、零上下文启动、只产建议、绝不推送。 // 降频)、独立运行、零上下文启动、只产建议、绝不推送。
// //
// 与主人的硬约束对齐(design/23 §4.1): // 与主人的硬约束对齐(design/23 §4.1):
// - 零上下文:本服务的依赖里**没有任何会话/历史读数**,输入只有记忆条目 // - 零上下文:本服务的依赖里**没有任何会话/历史读数**,输入只有记忆条目
@@ -30,8 +30,14 @@ import (
// 重启覆盖,于是 memory_tidy_runs 只有 1 行、77 条候选全 pending。 // 重启覆盖,于是 memory_tidy_runs 只有 1 行、77 条候选全 pending。
// - 新口径(到点即跑 + 持久事实):启动时先读**持久事实** // - 新口径(到点即跑 + 持久事实):启动时先读**持久事实**
// (memory_tidy_runs 里最近一轮的开始时刻),满足任一条件就**立刻补跑一次**: // (memory_tidy_runs 里最近一轮的开始时刻),满足任一条件就**立刻补跑一次**:
// ① 距上次 run 已 ≥ interval;② 已在安静窗内且窗口起点晚于上次 run // ① 距上次 run 已 ≥ 当时间隔;② 已在安静窗内且窗口起点晚于上次 run
// (= 这个窗口还没跑过)。此后心跳照旧只在安静窗内跑。 // (= 这个窗口还没跑过)。此后心跳按「上一次 run + **当时时段**的周期」醒。
//
// 2026-10-07(主人「频率必须调……或者动态分配,白天间隔短一点,晚上到了静默时期
// 稍微长一点」)追加**分档周期**:非安静时段用 cfg.Interval,安静时段用
// cfg.QuietInterval(唯一判据 = TidyConfig.intervalAt)。安静时段因此从「唯一可跑
// 时段」变成「降频时段」,心跳不再有「窗口外一律跳过」这一档;而「到点即跑 +
// catch-up」那三条判据一条未动。
// //
// 这样「重启不饿死」是可证的:重启只影响内存,判据只依赖持久事实与墙上时钟。 // 这样「重启不饿死」是可证的:重启只影响内存,判据只依赖持久事实与墙上时钟。
@@ -131,6 +137,7 @@ func (s *TidyService) Start(ctx context.Context) {
s.log.Info("memory tidy started", s.log.Info("memory tidy started",
"interval", s.cfg.Interval.String(), "interval", s.cfg.Interval.String(),
"quiet_interval", s.cfg.QuietInterval.String(),
"quiet_hours", fmt.Sprintf("%02d:00-%02d:00", s.cfg.QuietStartHour, s.cfg.QuietEndHour), "quiet_hours", fmt.Sprintf("%02d:00-%02d:00", s.cfg.QuietStartHour, s.cfg.QuietEndHour),
"recall_threshold", s.cfg.RecallThreshold, "recall_threshold", s.cfg.RecallThreshold,
"scan_limit", s.cfg.ScanLimit, "scan_limit", s.cfg.ScanLimit,
@@ -232,7 +239,14 @@ func (s *TidyService) ManualRun(ctx context.Context) (TidyStats, error) {
} }
// loop 是心跳 goroutine:先做**启动补跑判定**(持久事实 + 墙上时钟),再进入 // loop 是心跳 goroutine:先做**启动补跑判定**(持久事实 + 墙上时钟),再进入
// 「等到下一个低峰起点 → 在窗口内时跑一轮 → 重算」的循环。ctx 被取消时退出。 // 「等到下一次到点 → 跑一轮 → 按**当时时段**重算下一次」的循环。ctx 被取消时退出。
//
// 🔴 2026-10-07(动态频率)**去掉了「安静窗外的唤醒一律跳过」这一档**:安静时段从
// 「整理唯一可跑的时段」变成「整理降频的时段」(design/12 §N A1 给后台思考心跳定下
// 的同一口径),因为主人的频率口径要求白天也跑、且白天跑得比夜里勤(「白天间隔短
// 一点,晚上到了静默时期稍微长一点」)。窗口与补跑**都没有消失**:窗口还决定档位
// (intervalAt)与两条补跑判据(dueNow 的 1/3),「到点即跑 + catch-up」
// (design/23 §4.0)原样保留 —— 每次唤醒仍然要过 dueNow 才跑,不是「醒了就扫」。
func (s *TidyService) loop(ctx context.Context) { func (s *TidyService) loop(ctx context.Context) {
defer s.wg.Done() defer s.wg.Done()
s.catchUp(ctx) s.catchUp(ctx)
@@ -243,9 +257,11 @@ func (s *TidyService) loop(ctx context.Context) {
if ctx.Err() != nil { if ctx.Err() != nil {
return return
} }
if !s.cfg.inQuietHours(s.now()) { // 到点即跑:间隔用的是**此刻**的时段(nextDelay 里现算的),因此跨过
// 低峰窗口外不跑:整理不是随时可做的动作(design/23 §4/§9)。 // 安静时段边界后,下一轮自动换档。判据再次复核是因为等待期间可能有别的
s.log.Debug("memory tidy skipped: outside quiet hours", // 一轮跑过(手动触发):那时 lastRun 已经前移,这一拍就不该再跑。
if due, reason := s.dueNow(); !due {
s.log.Debug("memory tidy heartbeat skipped: not due", "reason", reason,
"now_hour", s.now().Hour()) "now_hour", s.now().Hour())
continue continue
} }
@@ -273,12 +289,16 @@ func (s *TidyService) catchUp(ctx context.Context) {
// memory_tidy_runs 载入)与墙上时钟。满足任一条即应立刻跑: // memory_tidy_runs 载入)与墙上时钟。满足任一条即应立刻跑:
// //
// 1. 从未跑过 **且** 已在安静窗内 —— 「这个窗口还没跑过」的最简情形(全新库); // 1. 从未跑过 **且** 已在安静窗内 —— 「这个窗口还没跑过」的最简情形(全新库);
// 2. 距上次 run 已 ≥ interval —— 周期到点,窗口不再是必要条件(补跑); // 2. 距上次 run 已 ≥ **当时时段**的周期 —— 周期到点,窗口不再是必要条件(补跑);
// 3. 已在安静窗内 **且** 窗口起点晚于上次 run —— 这个窗口还没跑过。 // 3. 已在安静窗内 **且** 窗口起点晚于上次 run —— 这个窗口还没跑过。
// //
// 三条都**不**看「进程活了多久」,因此重启多少次都不会饿死整理。反过来,窗口 // 三条都**不**看「进程活了多久」,因此重启多少次都不会饿死整理。反过来,窗口
// 内跑过一次之后再重启,条件 2/3 都不成立(lastRun 在窗口起点之后),因此也 // 内跑过一次之后再重启,条件 2/3 都不成立(lastRun 在窗口起点之后),因此也
// 不会每次重启都重跑一轮扫描。 // 不会每次重启都重跑一轮扫描。
//
// 判据 2 用 intervalAt(now) 而不是某一个固定周期:周期现在是**分档**的(安静时段
// quietIntervalSec / 其余 intervalSec),用哪一档取决于**现在几点**——这与
// nextDelay 用同一个函数,因此「下一拍的时间」与「到没到点」不可能各说各话。
func (s *TidyService) dueNow() (bool, string) { func (s *TidyService) dueNow() (bool, string) {
now := s.now() now := s.now()
s.mu.Lock() s.mu.Lock()
@@ -291,7 +311,7 @@ func (s *TidyService) dueNow() (bool, string) {
} }
return false, "" return false, ""
} }
if now.Sub(last) >= s.cfg.Interval { if now.Sub(last) >= s.cfg.intervalAt(now) {
return true, "interval_elapsed" return true, "interval_elapsed"
} }
if s.cfg.inQuietHours(now) { if s.cfg.inQuietHours(now) {
@@ -304,22 +324,33 @@ func (s *TidyService) dueNow() (bool, string) {
// nextDelay 返回距下一次整理开跑的等待时长。 // nextDelay 返回距下一次整理开跑的等待时长。
// //
// 口径:下一次 = 下一个低峰窗口起点(窗口关闭时 = 现在 + 周期),并且不早于 // 口径(2026-10-07 动态频率修订):
// 「上一次运行 + 周期」—— 让配置的周期成为真实下限,而不是让安静时段每天 //
// 一次把「每周一次」冲掉。等待恒 ≥ minTidyTickDelay,避免配置异常时空转。 // - **有历史**:下一次 = 「上一次 run + **当时时段**的周期」(intervalAt);
// - **无历史**:锚到下一个安静时段起点(design/23 §4:首次整理落在低峰),窗口
// 被关闭时退化为「现在 + 当时间隔」——与本次改动之前逐字一致;
// - 等待恒 ≥ minTidyTickDelay,避免配置异常(周期 ≤ 1 分钟)时空转。
//
// 🔴 为什么在这里现算,而不是启动时算一次:
// 周期是**分档**的(非安静时段 = intervalSec,安静时段 = quietIntervalSec),而
// 每一轮跑完之后「现在落在哪一档」可能已经变了(例:06:50 跑完那轮落在安静档 ⇒
// 下一拍被推到 10:50;而 10:50 之后应当换回白天的档)。把 intervalAt(now) 放在
// **每一次「等」之前**求值,跨过时段边界后的第一拍就用新档,不必等重启。
// 把它挪回启动时算一次 ⇒ 白天/晚上的切换不会生效(本切片有测试钉住这条)。
func (s *TidyService) nextDelay() time.Duration { func (s *TidyService) nextDelay() time.Duration {
now := s.now() now := s.now()
next := s.cfg.nextQuietStart(now)
if s.cfg.quietWindowDisabled() {
next = now.Add(s.cfg.Interval)
}
s.mu.Lock() s.mu.Lock()
last := s.lastRun last := s.lastRun
s.mu.Unlock() s.mu.Unlock()
if !last.IsZero() {
if floor := last.Add(s.cfg.Interval); next.Before(floor) { var next time.Time
next = floor if last.IsZero() {
next = s.cfg.nextQuietStart(now)
if s.cfg.quietWindowDisabled() {
next = now.Add(s.cfg.intervalAt(now))
} }
} else {
next = last.Add(s.cfg.intervalAt(now))
} }
delay := next.Sub(now) delay := next.Sub(now)
if delay < minTidyTickDelay { if delay < minTidyTickDelay {
+85
View File
@@ -0,0 +1,85 @@
package memory
import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// 本文件是「启动播种」的**值来源**:把 `$DATA_DIR/config/memory-tidy.json` 十键的
// **当前生效值**拍成一份完整快照,交给 runtimeconfig.SeedMemoryTidy 落盘。
//
// 它回应主人 2026-10-07 的两条口径:
//
// - 「**`.env` 只做兜底用** —— 后端**自动创建**独立的面板配置文件之后就走面板
// 配置文件,这些键优先从面板配置上取」⇒ 播种的时机与「此后以面板为准」;
// - 「后端**自动创建**独立的面板配置文件**要做**」⇒ 这份快照就是「自动创建」的
// 内容,**零新 env 键**(`.env` 已冻结,design/13 §2.1)。
//
// 为什么放在 memory 而不是 runtimeconfig:runtimeconfig 只管**文件形状与读写**,
// 「缺位/坏值怎么回落、默认值是多少」属于 TidyConfig / ReviewConfig 的归一化语义
// (与 normalizeTidyConfig / normalizeReviewConfig 同一层)。
//
// 为什么快照取**归一化之后**的值:主人要的是「播种那一刻行为零变化」——把当时真正
// 生效的值写成文件,读回来叠上去之后(面板这一档等于生效值本身)结果逐字相同。
// 若快照取的是「未归一化的中间值」(比如四键还是 0),写下去的就是一份**比现状更空**
// 的配置,只是侥幸等价;取归一化后的值,等价性才是结构性的(见
// TestTidyTuningSnapshot_PanelRoundTripKeepsEffectiveConfig)。
//
// 为什么只有一处实现:十键的默认值在 memory 侧各只有一处定义
// (tidy_config.go / preinsert_config.go 的 default* 常量),快照直接复用那两个
// normalize,**不另写一份默认值** —— 否则播种写下去的就是第二份「差不多」的默认值,
// 会与运行期悄悄分叉。
//
// 十键与它们的生效来源:
//
// 键 生效来源(无面板文件时) 快照值
// autoApply DisableAutoApply 的反向(代码默认开) true
// autoApplyPerRound defaultTidyAutoApplyPerRound 50
// ingestGate DisableIngestGate 的反向(默认开) true
// t1Judge DisableT1 的反向(默认开) true
// t1PerWindow defaultT1PerWindow 120
// t1WindowSec defaultT1Window 3600
// intervalSec TidyConfig.IntervalSec(env 或 86400) 归一化值
// quietIntervalSec 缺省 = intervalSec 归一化值
// quietStartHour TidyConfig.QuietStartHour(env 或 23) 归一化值
// quietEndHour TidyConfig.QuietEndHour(env 或 7) 归一化值
// TidyTuningSnapshot 拍下十键的**当前生效值**,返回一份可直接落盘的面板快照。
//
// 入参是**已经叠过面板层的**两个配置(组合根的次序:TidyConfigFromConfig →
// ApplyTidyScheduleTuning → 本函数;审查侧同理),本函数只做最后一步归一化并翻成
// 文件形状 —— 它不读盘、不读 env、不认识面板文件:
//
// - tidy 侧四键(intervalSec / quietIntervalSec / quietStartHour / quietEndHour)
// 与自动档两键(autoApply / autoApplyPerRound);
// - review 侧四键(ingestGate / t1Judge / t1PerWindow / t1WindowSec):
// 这四项在 `.env` 里**没有出处**(`.env` 已冻结),只由面板文件与代码默认决定,
// 而播种只发生在**面板文件不存在**时 —— 那正是「零值 + normalizeReviewConfig」
// 这一条路径,与运行期(wireMemoryReview 的三个 helper)逐字一致。
//
// 两个开关是**反向**字段(DisableX),快照要把它们翻回正向的 JSON 键:文件里的
// `"autoApply": true` 才是「没配时到底开不开」的如实陈述。
func TidyTuningSnapshot(tidy TidyConfig, review ReviewConfig) runtimeconfig.MemoryTidyTuning {
tidy = normalizeTidyConfig(tidy)
review = normalizeReviewConfig(review)
autoApply := !tidy.DisableAutoApply
ingestGate := !review.DisableIngestGate
t1Judge := !review.DisableT1
intervalSec := tidy.IntervalSec
quietIntervalSec := tidy.QuietIntervalSec
quietStartHour := tidy.QuietStartHour
quietEndHour := tidy.QuietEndHour
return runtimeconfig.MemoryTidyTuning{
AutoApply: &autoApply,
AutoApplyPerRound: tidy.AutoApplyPerRound,
IngestGate: &ingestGate,
T1Judge: &t1Judge,
T1PerWindow: review.T1PerWindow,
T1WindowSec: int(review.T1Window.Seconds()),
IntervalSec: &intervalSec,
QuietIntervalSec: &quietIntervalSec,
QuietStartHour: &quietStartHour,
QuietEndHour: &quietEndHour,
}
}
@@ -0,0 +1,196 @@
package memory
import (
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// 本文件锁定**启动播种的值来源**(tidy_snapshot.go;主人 2026-10-07:「`.env` 只做
// 兜底用 —— 后端**自动创建**独立的面板配置文件**要做**」):
//
// ① 快照十键齐全、值可用(否则 runtimeconfig.SeedMemoryTidy 会拒绝落盘);
// ② 值取「**当时解析出来的生效值**」—— `.env` 给了就用 `.env`,没给就用代码默认
// (默认值来自 normalizeTidyConfig / normalizeReviewConfig,不另写一份);
// ③ 最关键的一条:把快照当成面板值叠回去,**归一化结果与「没有面板文件」逐字相同**
// —— 这就是「播种那一刻行为零变化,之后改文件才生效」。
// snapBoolPtr / snapIntPtr 造「快照里显式写了这个键」的指针。
// (不复用 tidy_tuning_test.go 的 intPtr:那是给**面板**用例造的,两者语义不同,
// 分开命名便于读。)
func snapBoolPtr(v bool) *bool { return &v }
func snapIntPtr(v int) *int { return &v }
// TestTidyTuningSnapshot_CodeDefaults 钉住「什么都没配」时的十键 = 归一化的代码默认值。
// 期望值直接取自默认值常量(本包唯一那处定义),因此这条用例证明的是**接线**:
// 快照确实走了 normalizeTidyConfig / normalizeReviewConfig,而不是另写一份默认值。
func TestTidyTuningSnapshot_CodeDefaults(t *testing.T) {
t.Parallel()
snap := TidyTuningSnapshot(TidyConfig{}, ReviewConfig{})
if snap.AutoApply == nil || !*snap.AutoApply {
t.Fatalf("autoApply = %v, want 显式的 true(代码默认开)", snap.AutoApply)
}
if snap.IngestGate == nil || !*snap.IngestGate {
t.Fatalf("ingestGate = %v, want 显式的 true(代码默认开)", snap.IngestGate)
}
if snap.T1Judge == nil || !*snap.T1Judge {
t.Fatalf("t1Judge = %v, want 显式的 true(代码默认开)", snap.T1Judge)
}
if snap.AutoApplyPerRound != defaultTidyAutoApplyPerRound {
t.Fatalf("autoApplyPerRound = %d, want %d", snap.AutoApplyPerRound, defaultTidyAutoApplyPerRound)
}
if snap.T1PerWindow != defaultT1PerWindow || snap.T1WindowSec != int(defaultT1Window.Seconds()) {
t.Fatalf("T1 限速 = %d/%d, want %d/%d", snap.T1PerWindow, snap.T1WindowSec,
defaultT1PerWindow, int(defaultT1Window.Seconds()))
}
if snap.IntervalSec == nil || *snap.IntervalSec != defaultTidyIntervalSec {
t.Fatalf("intervalSec = %v, want %d", snap.IntervalSec, defaultTidyIntervalSec)
}
// quietIntervalSec 缺省 = intervalSec(normalizeTidyConfig 的既有口径:不因时段换档)。
if snap.QuietIntervalSec == nil || *snap.QuietIntervalSec != defaultTidyIntervalSec {
t.Fatalf("quietIntervalSec = %v, want = intervalSec (%d)", snap.QuietIntervalSec, defaultTidyIntervalSec)
}
if snap.QuietStartHour == nil || *snap.QuietStartHour != defaultTidyQuietStartHour ||
snap.QuietEndHour == nil || *snap.QuietEndHour != defaultTidyQuietEndHour {
t.Fatalf("安静时段 = %v-%v, want %d-%d", snap.QuietStartHour, snap.QuietEndHour,
defaultTidyQuietStartHour, defaultTidyQuietEndHour)
}
}
// TestTidyTuningSnapshot_TakesEnvLayer 钉住「`.env` 给的就用 `.env`」:四键的 env 档
// (MEMORY_TIDY_INTERVAL_SEC / MEMORY_TIDY_QUIET_*_HOUR)必须原样进快照,否则播种写
// 下去的就是代码默认值,等于把主人配的 `.env` 静默抹平。
func TestTidyTuningSnapshot_TakesEnvLayer(t *testing.T) {
t.Parallel()
env := config.MemoryTidyConfig{IntervalSec: 7200, QuietStartHour: 22, QuietEndHour: 6}
snap := TidyTuningSnapshot(TidyConfigFromConfig(env), ReviewConfig{})
if snap.IntervalSec == nil || *snap.IntervalSec != 7200 {
t.Fatalf("intervalSec = %v, want 7200(取 `.env`)", snap.IntervalSec)
}
// env 只给了 intervalSec,quietIntervalSec 没有 env 键 ⇒ 缺省 = intervalSec。
if snap.QuietIntervalSec == nil || *snap.QuietIntervalSec != 7200 {
t.Fatalf("quietIntervalSec = %v, want 7200(缺省 = intervalSec)", snap.QuietIntervalSec)
}
if snap.QuietStartHour == nil || *snap.QuietStartHour != 22 ||
snap.QuietEndHour == nil || *snap.QuietEndHour != 6 {
t.Fatalf("安静时段 = %v-%v, want 22-6(取 `.env`)", snap.QuietStartHour, snap.QuietEndHour)
}
}
// TestTidyTuningSnapshot_KeepsExplicitZeroHour 钉住钟点的零值口径:**0 是合法钟点**
// (0-7 的凌晨窗口)。若快照把「0 起」当成「没配」而写成 23,播种就篡改了主人的窗口。
//
// 注意「两端都是 0」是**另一回事**:那是 normalize 认的「都没配」(回落到 23-07),
// 这与显式写 0-5 的凌晨窗口必须可区分。
func TestTidyTuningSnapshot_KeepsExplicitZeroHour(t *testing.T) {
t.Parallel()
env := config.MemoryTidyConfig{QuietStartHour: 0, QuietEndHour: 5}
snap := TidyTuningSnapshot(TidyConfigFromConfig(env), ReviewConfig{})
if snap.QuietStartHour == nil || *snap.QuietStartHour != 0 || *snap.QuietEndHour != 5 {
t.Fatalf("安静时段 = %v-%v, want 0-5(显式 0 是合法钟点)", snap.QuietStartHour, snap.QuietEndHour)
}
// 两端都是 0 = 「都没配」⇒ 归一成代码默认 23-07(既有口径,快照如实转述)。
both := TidyTuningSnapshot(TidyConfigFromConfig(config.MemoryTidyConfig{}), ReviewConfig{})
if *both.QuietStartHour != defaultTidyQuietStartHour || *both.QuietEndHour != defaultTidyQuietEndHour {
t.Fatalf("两端 0 = 都没配 ⇒ 应归一成 %d-%d,得到 %v-%v",
defaultTidyQuietStartHour, defaultTidyQuietEndHour, *both.QuietStartHour, *both.QuietEndHour)
}
}
// TestTidyTuningSnapshot_PanelRoundTripKeepsEffectiveConfig 是**播种的全部意义**所在:
// 把快照当成面板值叠回去之后,整理与审查两侧归一化出来的配置必须与「面板文件不存在」
// 逐字相同 —— 于是播种那一刻行为零变化,之后改文件才生效。
//
// 两侧各按**运行期真实的叠加路径**走:
// - 整理侧 = 组合根那三步(TidyConfigFromConfig → ApplyTidyScheduleTuning →
// normalizeTidyConfig,即 resolveTidySchedule);
// - 审查侧 = wireMemoryReview 的写法(开关取 IngestGateOff/T1JudgeOff,数值取
// t1PerWindow/t1WindowSec,再交给 normalizeReviewConfig)。
func TestTidyTuningSnapshot_PanelRoundTripKeepsEffectiveConfig(t *testing.T) {
t.Parallel()
envs := []struct {
name string
env config.MemoryTidyConfig
}{
{"`.env` 什么都没配", config.MemoryTidyConfig{}},
{"`.env` 配了频率与窗口", config.MemoryTidyConfig{IntervalSec: 7200, QuietStartHour: 1, QuietEndHour: 5}},
{"`.env` 的窗口从 0 点起(凌晨档)", config.MemoryTidyConfig{IntervalSec: 10800, QuietStartHour: 0, QuietEndHour: 6}},
{"`.env` 把窗口关掉(两端相等且非 0)", config.MemoryTidyConfig{IntervalSec: 3600, QuietStartHour: 5, QuietEndHour: 5}},
{"`.env` 只配了周期", config.MemoryTidyConfig{IntervalSec: 1800}},
}
for _, tc := range envs {
log, _ := captureTidyLog(t)
// ① 没播种前:面板缺失 ⇒ 零值 tuning(= 当前生产里 data/config 下没这个文件的形态)。
before := resolveTidySchedule(tc.env, runtimeconfig.MemoryTidyTuning{}, log)
// ② 快照(这就是要写进面板文件的内容)。
snap := TidyTuningSnapshot(TidyConfigFromConfig(tc.env), ReviewConfig{})
// ③ 播种之后:面板文件就是那份快照,逐键叠回来。
after := resolveTidySchedule(tc.env, snap, log)
if before != after {
t.Fatalf("%s: 播种改变了生效配置:\n before=%+v\n after =%+v", tc.name, before, after)
}
}
// 审查侧同理(闸/T1 四键在 `.env` 里没有出处,只由面板与代码默认决定)。
reviewBefore := normalizeReviewConfig(ReviewConfig{})
snap := TidyTuningSnapshot(TidyConfig{}, ReviewConfig{})
reviewAfter := normalizeReviewConfig(ReviewConfig{
DisableIngestGate: snap.IngestGateOff(),
DisableT1: snap.T1JudgeOff(),
T1PerWindow: snap.T1PerWindow,
T1Window: time.Duration(snap.T1WindowSec) * time.Second,
})
if reviewBefore != reviewAfter {
t.Fatalf("播种改变了审查的生效配置:\n before=%+v\n after =%+v", reviewBefore, reviewAfter)
}
}
// TestTidyTuningSnapshot_IsCompleteAndPositive 钉住快照契约:十键非 nil、数值键为正、
// 钟点在 0..23。runtimeconfig.SeedMemoryTidy 会按这条契约拒收残缺种子,因此这里先
// 在**产生方**把它钉住,免得「播种写不出去」要到启动日志里才发现。
func TestTidyTuningSnapshot_IsCompleteAndPositive(t *testing.T) {
t.Parallel()
snap := TidyTuningSnapshot(TidyConfigFromConfig(config.MemoryTidyConfig{}), ReviewConfig{})
pointers := map[string]*int{
"intervalSec": snap.IntervalSec,
"quietIntervalSec": snap.QuietIntervalSec,
"quietStartHour": snap.QuietStartHour,
"quietEndHour": snap.QuietEndHour,
}
for key, p := range pointers {
if p == nil {
t.Fatalf("%s 是 nil(缺键的种子会被拒收)", key)
}
}
if snap.AutoApply == nil || snap.IngestGate == nil || snap.T1Judge == nil {
t.Fatalf("开关指针不得为 nil:%+v", snap)
}
for key, v := range map[string]int{
"autoApplyPerRound": snap.AutoApplyPerRound,
"t1PerWindow": snap.T1PerWindow,
"t1WindowSec": snap.T1WindowSec,
"intervalSec": *snap.IntervalSec,
"quietIntervalSec": *snap.QuietIntervalSec,
} {
if v <= 0 {
t.Fatalf("%s = %d, want > 0(非正周期会被 runtimeconfig 拒收)", key, v)
}
}
for key, v := range map[string]int{"quietStartHour": *snap.QuietStartHour, "quietEndHour": *snap.QuietEndHour} {
if v < 0 || v > 23 {
t.Fatalf("%s = %d, want 0..23", key, v)
}
}
}
+7
View File
@@ -41,6 +41,13 @@ const tidyStatusActive = "active"
// 最新在前;rowid 打破 created_at 并列,使同一刻写入的条目顺序稳定 // 最新在前;rowid 打破 created_at 并列,使同一刻写入的条目顺序稳定
// (与 ListByPersona 同一口径)。分区过滤写在 SQL 里,而不是读全表再在内存 // (与 ListByPersona 同一口径)。分区过滤写在 SQL 里,而不是读全表再在内存
// 里筛 —— design/23 §6 要求在分区切分层就隔断。 // 里筛 —— design/23 §6 要求在分区切分层就隔断。
//
// 🔴 **刻意不过时效闸**(design/03 §11.2「主会话已裁定:保持不过时效闸」,
// 同一取舍的先例见 design/23 §10.1 ①):整理比对的是**正文**,与「这条对外
// 可不可见」无关;已过期的临时记忆随后由后台清扫物理删除(design/23 §19),
// 绝不会被整理「复活」。可见性判据只有一处 —— 四条列表读路径共用 timegate.go
// 的 timeGateClause。**不要再给这条 SQL 加时效闸。** 本文件另两条分区口径
// 查询(listTidyPartitionsSQL / countTidyPartitionItemsSQL)是同一裁定的实例。
const listTidyPartitionItemsSQL = `SELECT id, persona_id, role_id, project_id, scope, kind, const listTidyPartitionItemsSQL = `SELECT id, persona_id, role_id, project_id, scope, kind,
content, tags, confidence, source_conversation, created_at, updated_at, content, tags, confidence, source_conversation, created_at, updated_at,
decay_weight FROM memory_items decay_weight FROM memory_items
+87
View File
@@ -0,0 +1,87 @@
package memory
import (
"log/slog"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// 本文件是**整理频率**的解析点:把 `$DATA_DIR/config/memory-tidy.json` 的四个调度
// 旋钮叠到 env 侧的 TidyConfig 上,实现主人 2026-10-07 的两条口径:
//
// - 「**`.env` 只做兜底用** —— 后端自动创建独立的面板配置文件之后就走面板配置
// 文件,这些键优先从面板配置上取」⇒ 逐键 **面板 > `.env` > 代码默认**;
// - 「**频率必须调**,可以调到 1 小时或者三小时,**或者动态分配**」⇒ 周期分两档
// (非安静时段的 intervalSec / 安静时段的 quietIntervalSec,见 intervalAt)。
//
// 为什么解析放在 memory 而不是 runtimeconfig:runtimeconfig 只负责**文件形状与
// 读取**(它的头注释把这条边界写死了),而「缺位/坏值怎么回落」属于 TidyConfig 的
// 归一化语义 —— 与 normalizeTidyConfig 同一层,因此两者可以一起被单测覆盖。
//
// 为什么不是「读盘时逐键容错」:整份文件解不开(含某键类型写错)时**四个键一起**
// 回落 `.env` 并记 WARN,这是本文件家族既有的粒度(见
// runtimeconfig.LoadMemoryTidyTuning 与它的用例③);本文件处理的是**类型正确但值
// 不可用**(非正数)那一档。
// ApplyTidyScheduleTuning 把面板调度旋钮叠到 env 侧的 TidyConfig 上,返回叠加后的
// 配置(**未归一化**:调用方随后交给 NewTidier / normalizeTidyConfig 填代码默认值,
// 与组合根的既有顺序一致)。
//
// 逐键独立,三档优先级在四个键上各自成立:
//
// 键 面板(memory-tidy.json) 下一档(.env / 派生) 代码默认
// intervalSec intervalSec MEMORY_TIDY_INTERVAL_SEC 86400
// quietIntervalSec quietIntervalSec 派生:= intervalSec 同左
// quietStartHour quietStartHour MEMORY_TIDY_QUIET_START_HOUR 23
// quietEndHour quietEndHour MEMORY_TIDY_QUIET_END_HOUR 7
//
// 「面板缺位 ⇒ 用 `.env`」这一步在实现上是**不覆盖**:cfg 进函数时已经带着 env 的
// 值(TidyConfigFromConfig),面板没写就原样留着;「`.env` 也缺位 ⇒ 代码默认」由
// 随后的 normalizeTidyConfig 收尾(env 的 default 标签在 config.Load 那一层已经
// 兜过一道,两道都缺时落到 defaultTidyIntervalSec / 23-07)。
//
// 坏值(非正数的秒数)**绝不写进 cfg**:回落下一档 + 记一条 WARN。一个 ≤0 的周期会
// 让心跳退化成空转,因此它不是「没配」而是坏配置 —— 但也不许因此让启动失败。
func ApplyTidyScheduleTuning(cfg TidyConfig, panel runtimeconfig.MemoryTidyTuning, log *slog.Logger) TidyConfig {
log = logger.WithSource(baseTidyLog(log), "memory/tidy")
if sec := panelPositiveSec(panel.IntervalSec, "intervalSec", log); sec > 0 {
cfg.IntervalSec = sec
// Interval 与 IntervalSec 同时归零:两者是「同一旋钮的两种写法」
// (设置了 Interval 时忽略 IntervalSec),只改一个会让旧值报复性生效。
cfg.Interval = 0
}
if sec := panelPositiveSec(panel.QuietIntervalSec, "quietIntervalSec", log); sec > 0 {
cfg.QuietIntervalSec = sec
cfg.QuietInterval = 0
}
// 钟点用「指针是否有值」而不是符号判据:**0 是合法钟点**(0-7 的凌晨窗口、
// 23-0 到午夜的窗口),因此显式写的 0 必须原样生效,缺位才回落 env。
if panel.QuietStartHour != nil {
cfg.QuietStartHour = *panel.QuietStartHour
}
if panel.QuietEndHour != nil {
cfg.QuietEndHour = *panel.QuietEndHour
}
return cfg
}
// panelPositiveSec 解析一个**正数秒数**旋钮,返回 0 表示「不覆盖」(调用方原样留着
// env 值)。三种情形分得很清,因为它们该有不同的观测形状:
//
// - `nil`(键没写)⇒ 0,**静默**:没配不是错,正是「`.env` 兜底」这一档;
// - 非正数(写了 0 或负数)⇒ 0 + **WARN**:它会被读成「一直跑」而不是「不跑」,
// 属于坏配置,必须留下痕迹(否则主人以为配了 0 就等于关了,实际相反);
// - 正数 ⇒ 原值。
func panelPositiveSec(v *int, key string, log *slog.Logger) int {
if v == nil {
return 0
}
if *v <= 0 {
log.Warn("memory-tidy.json 的调度旋钮值不可用,回落 .env(非正周期会让心跳空转)",
"key", key, "value", *v)
return 0
}
return *v
}
+213
View File
@@ -0,0 +1,213 @@
package memory
import (
"bytes"
"log/slog"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// 本文件锁定**整理频率**的解析链(主人 2026-10-07 两条口径):
//
// ①「`.env` 只做兜底用 …… 这些键优先从面板配置上取」⇒ 逐键 **面板 > `.env` > 代码默认**;
// ②「频率必须调 …… 或者动态分配」⇒ 周期分两档(安静/非安静,见 tidy_config.go 的
// intervalAt),quietIntervalSec 缺省 = intervalSec。
//
// 解析点只有一处:ApplyTidyScheduleTuning。用例走的是组合根的次序
// (TidyConfigFromConfig → ApplyTidyScheduleTuning → normalizeTidyConfig),
// 因此「环境变量缺位也缺位时落到代码默认」这一档也是真跑出来的,不是声称。
// resolveTidySchedule 按组合根的次序解析一次调度旋钮。
func resolveTidySchedule(env config.MemoryTidyConfig, panel runtimeconfig.MemoryTidyTuning, log *slog.Logger) TidyConfig {
return normalizeTidyConfig(ApplyTidyScheduleTuning(TidyConfigFromConfig(env), panel, log))
}
// captureTidyLog 收集一次解析期间的日志,供「坏值必须留痕」的断言使用。
func captureTidyLog(t *testing.T) (*slog.Logger, *bytes.Buffer) {
t.Helper()
var buf bytes.Buffer
return slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn})), &buf
}
// intPtr 造一个「面板里显式写了这个键」的指针。
func intPtr(v int) *int { return &v }
// TestApplyTidyScheduleTuning_PriorityChain 是**优先级链**的三档用例:面板有值 ⇒
// 用面板;面板缺位 ⇒ 用 `.env`;两档都缺位 ⇒ 用代码默认。逐键独立。
func TestApplyTidyScheduleTuning_PriorityChain(t *testing.T) {
t.Parallel()
// env 层:与面板值**故意对立**,这样「谁生效」是能一眼看出来的。
env := config.MemoryTidyConfig{IntervalSec: 7200, QuietStartHour: 23, QuietEndHour: 7}
panel := runtimeconfig.MemoryTidyTuning{
IntervalSec: intPtr(3600),
QuietIntervalSec: intPtr(10800),
QuietStartHour: intPtr(1),
QuietEndHour: intPtr(5),
}
t.Run("面板有值 ⇒ 四个键都用面板值", func(t *testing.T) {
log, buf := captureTidyLog(t)
got := resolveTidySchedule(env, panel, log)
if got.IntervalSec != 3600 || got.Interval != time.Hour {
t.Fatalf("intervalSec = %d / %s, want 3600 / 1h", got.IntervalSec, got.Interval)
}
if got.QuietIntervalSec != 10800 || got.QuietInterval != 3*time.Hour {
t.Fatalf("quietIntervalSec = %d / %s, want 10800 / 3h", got.QuietIntervalSec, got.QuietInterval)
}
if got.QuietStartHour != 1 || got.QuietEndHour != 5 {
t.Fatalf("安静时段 = %02d:00-%02d:00, want 01:00-05:00", got.QuietStartHour, got.QuietEndHour)
}
if buf.Len() != 0 {
t.Fatalf("正常配置不该有任何 WARN:%s", buf.String())
}
})
t.Run("面板缺位 ⇒ 用 .env", func(t *testing.T) {
log, buf := captureTidyLog(t)
got := resolveTidySchedule(env, runtimeconfig.MemoryTidyTuning{}, log)
if got.IntervalSec != 7200 || got.Interval != 2*time.Hour {
t.Fatalf("intervalSec = %d / %s, want 7200 / 2h(env 兜底)", got.IntervalSec, got.Interval)
}
if got.QuietStartHour != 23 || got.QuietEndHour != 7 {
t.Fatalf("安静时段 = %02d:00-%02d:00, want 23:00-07:00(env 兜底)",
got.QuietStartHour, got.QuietEndHour)
}
// quietIntervalSec 没有 env 键 ⇒ 缺省等于 intervalSec(不是「夜里不跑」)。
if got.QuietIntervalSec != 7200 || got.QuietInterval != 2*time.Hour {
t.Fatalf("quietIntervalSec = %d / %s, want = intervalSec(缺省 = 不因时段换档)",
got.QuietIntervalSec, got.QuietInterval)
}
if buf.Len() != 0 {
t.Fatalf("键没写不是错,不该有 WARN:%s", buf.String())
}
})
t.Run("两档都缺位 ⇒ 用代码默认", func(t *testing.T) {
log, buf := captureTidyLog(t)
got := resolveTidySchedule(config.MemoryTidyConfig{}, runtimeconfig.MemoryTidyTuning{}, log)
if got.IntervalSec != defaultTidyIntervalSec || got.Interval != time.Duration(defaultTidyIntervalSec)*time.Second {
t.Fatalf("intervalSec = %d / %s, want %d / 24h",
got.IntervalSec, got.Interval, defaultTidyIntervalSec)
}
if got.QuietIntervalSec != defaultTidyIntervalSec {
t.Fatalf("quietIntervalSec = %d, want = intervalSec 的代码默认", got.QuietIntervalSec)
}
if got.QuietStartHour != defaultTidyQuietStartHour || got.QuietEndHour != defaultTidyQuietEndHour {
t.Fatalf("安静时段 = %02d:00-%02d:00, want 代码默认 %02d:00-%02d:00",
got.QuietStartHour, got.QuietEndHour, defaultTidyQuietStartHour, defaultTidyQuietEndHour)
}
if buf.Len() != 0 {
t.Fatalf("没配不是错,不该有 WARN:%s", buf.String())
}
})
t.Run("面板只写一档 ⇒ 另一档仍走 env(逐键独立)", func(t *testing.T) {
log, _ := captureTidyLog(t)
got := resolveTidySchedule(env, runtimeconfig.MemoryTidyTuning{
QuietIntervalSec: intPtr(10800),
QuietEndHour: intPtr(5),
}, log)
if got.IntervalSec != 7200 {
t.Fatalf("intervalSec = %d, want 7200(面板没写这一项)", got.IntervalSec)
}
if got.QuietIntervalSec != 10800 {
t.Fatalf("quietIntervalSec = %d, want 10800(面板写了这一项)", got.QuietIntervalSec)
}
if got.QuietStartHour != 23 || got.QuietEndHour != 5 {
t.Fatalf("安静时段 = %02d:00-%02d:00, want 23:00-05:00(一端面板、一端 env)",
got.QuietStartHour, got.QuietEndHour)
}
})
t.Run("面板写了 intervalSec 但没写 quietIntervalSec ⇒ 缺省跟随**面板**值", func(t *testing.T) {
log, _ := captureTidyLog(t)
got := resolveTidySchedule(env, runtimeconfig.MemoryTidyTuning{IntervalSec: intPtr(3600)}, log)
if got.IntervalSec != 3600 || got.QuietIntervalSec != 3600 {
t.Fatalf("intervalSec = %d, quietIntervalSec = %d, want 两者都是 3600",
got.IntervalSec, got.QuietIntervalSec)
}
})
t.Run("钟点显式写 0 ⇒ 生效(0 是合法钟点)", func(t *testing.T) {
log, _ := captureTidyLog(t)
got := resolveTidySchedule(env, runtimeconfig.MemoryTidyTuning{QuietStartHour: intPtr(0)}, log)
if got.QuietStartHour != 0 || got.QuietEndHour != 7 {
t.Fatalf("安静时段 = %02d:00-%02d:00, want 00:00-07:00", got.QuietStartHour, got.QuietEndHour)
}
})
t.Run("两端相等且非 0 ⇒ 关闭窗口(语义未变)", func(t *testing.T) {
log, _ := captureTidyLog(t)
got := resolveTidySchedule(env, runtimeconfig.MemoryTidyTuning{
QuietStartHour: intPtr(5), QuietEndHour: intPtr(5),
}, log)
if !got.quietWindowDisabled() {
t.Fatalf("5/5 应当被判定为「窗口已关闭」,得到 %02d:00-%02d:00",
got.QuietStartHour, got.QuietEndHour)
}
})
}
// TestApplyTidyScheduleTuning_BadValuesFallBackWithWarn 钉住「坏配置不得导致启动
// 失败」:非正数的秒数是**坏值**(不是「没配」),一律回落下一档 + 记一条 WARN ——
// 它会被读成「一直跑」而不是「不跑」,静默回落会让主人以为配了 0 就等于关了。
func TestApplyTidyScheduleTuning_BadValuesFallBackWithWarn(t *testing.T) {
t.Parallel()
env := config.MemoryTidyConfig{IntervalSec: 7200, QuietStartHour: 23, QuietEndHour: 7}
cases := []struct {
name string
panel runtimeconfig.MemoryTidyTuning
key string
}{
{"intervalSec 写成 0", runtimeconfig.MemoryTidyTuning{IntervalSec: intPtr(0)}, "intervalSec"},
{"intervalSec 写成负数", runtimeconfig.MemoryTidyTuning{IntervalSec: intPtr(-60)}, "intervalSec"},
{"quietIntervalSec 写成 0", runtimeconfig.MemoryTidyTuning{QuietIntervalSec: intPtr(0)}, "quietIntervalSec"},
{"quietIntervalSec 写成负数", runtimeconfig.MemoryTidyTuning{QuietIntervalSec: intPtr(-1)}, "quietIntervalSec"},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
log, buf := captureTidyLog(t)
got := resolveTidySchedule(env, tc.panel, log)
if got.IntervalSec != 7200 {
t.Fatalf("intervalSec = %d, want 回落 env 的 7200", got.IntervalSec)
}
if got.QuietIntervalSec != 7200 {
t.Fatalf("quietIntervalSec = %d, want 回落 7200", got.QuietIntervalSec)
}
logs := buf.String()
if !strings.Contains(logs, tc.key) || !strings.Contains(logs, "level=WARN") {
t.Fatalf("坏值必须留痕(含键名 %q 的 WARN),日志 = %q", tc.key, logs)
}
})
}
// 坏值**不得**让整理停摆:解析出的周期必须是正数,且归一化后一切照旧。
log, _ := captureTidyLog(t)
got := resolveTidySchedule(env, runtimeconfig.MemoryTidyTuning{IntervalSec: intPtr(0)}, log)
if got.Interval <= 0 || got.QuietInterval <= 0 {
t.Fatalf("坏值之后的周期必须仍为正:interval=%s quiet=%s", got.Interval, got.QuietInterval)
}
if got.IntervalSec != env.IntervalSec {
t.Fatalf("坏值应回落 env 值 %d,得到 %d", env.IntervalSec, got.IntervalSec)
}
}
// TestApplyTidyScheduleTuning_NilLoggerIsSafe:解析点可能被没有日志器的调用方
// 直接使用(单测、将来的面板校验),nil 日志器不得 panic。
func TestApplyTidyScheduleTuning_NilLoggerIsSafe(t *testing.T) {
t.Parallel()
got := ApplyTidyScheduleTuning(TidyConfig{}, runtimeconfig.MemoryTidyTuning{
IntervalSec: intPtr(0), QuietIntervalSec: intPtr(-1),
}, nil)
if got.IntervalSec != 0 {
t.Fatalf("坏值不该写进 cfg,得到 %d", got.IntervalSec)
}
}
+73
View File
@@ -0,0 +1,73 @@
package memory
import "time"
// 本文件是**读侧时效闸**的唯一实现(design/03 §9.6 / §11.2),也是「临时记忆到期
// 即从读面消失」链路里的第 ① 步(隐藏):判据就是列值本身,零写入、即时生效。
// 第 ② 步(后台物理删除)与第 ③ 步(删除前审计)是另一条显式、有界、可停的
// 后台路径,**绝不混进读路径**(design/23 §19.1)。
//
// 🔴 为什么必须下沉到共享底层、不许写在调用点(design/03 §11.2 死角分析):
// 记忆读入口有 8 个(对话注入 owner / 群聊陌生人、`knowledge` 工具、capacity L4
// 习惯召回、子代理以 B 身份、admin 面板列表 / 搜索 / RAG),但它们**只走同一批
// 底层** —— Store.ListByPersona / ListScope / ListProject / List(面板与直接调用方)
// 以及 Retriever.visibleCandidates(它组合调用前两条,因此所有复用检索器的入口
// 自动获得同一判据)。闸一旦写在某个调用点上,立刻出现「对话看不见、`knowledge`
// 工具看得见」的死角。**因此判据只有这一处,四条列表读路径共用。**
//
// 🔴 同时登记**两处刻意不过闸**(design/03 §11.2 第 3 条;与 design/23 §10.1 ①
// 已立的先例同构):
//
// 1. 按 id 的 Store.Get(store.go)—— 撤销软合并 / 查看已软删行必须先读得到,
// 加闸等于把撤销与审计的抓手抹掉。过期行同理:清算与人工核查要读得到。
// 2. 整理与审查的**内部**召回 SQL —— listTidyPartitionItemsSQL(tidy_store.go)
// 与 listReviewCandidatesSQL(preinsert_store.go)刻意**只过 status 闸**:
// 它们比对的是正文,与「这条对外可不可见」无关;过期行随后由后台清扫删除,
// 不会复活(design/03 §11.2「主会话已裁定:保持不过时效闸」)。两处都在
// SQL 旁落了原位注释:前者在 tidy_store.go(切片 M1),后者的原位注释由
// 切片 M2 落在 preinsert_store.go —— 此处登记,免得分头实现时漏掉。
// 3. 审查留痕读侧(`GET /admin/memories/reviews`)—— 它读的是留痕表
// memory_ingest_reviews(preinsert_schema.go:29),那张表**没有**
// status / expires_at / valid_to 任何一列(结构与可见性无关,只记判定结果),
// 因此这里根本没有可加的闸;它是软删/被吸收行的**查看入口**,加闸等于审计自我抹除。
//
// **新增 / 变更任何记忆读路径时,先问这一句**:它要不要看见已过期(临时层)与
// 已软删行?答案写进注释(design/03 §11.2)。
// timeGateClause 是时效闸的 WHERE 片段,拼在四条列表读路径既有的 status 闸之后、
// ORDER BY 之前。判据与 design/03 §9.6 逐字一致:
//
// (expires_at = '' OR expires_at > ?) AND (valid_to = '' OR valid_to > ?)
//
// 空串是「从未设过期 / 从未失效」(长期记忆与日常记忆的默认值,见
// store_schema.go:27-29 的 DEFAULT 空串),因此空串**必须可见**;只有非空才比较。
// 两个 ? 都绑定 timeGateNow() 的返回值。
const timeGateClause = ` AND (expires_at = '' OR expires_at > ?) AND (valid_to = '' OR valid_to > ?)`
// timeGateNow 返回时效闸比较用的「当下」。
//
// 🔴 口径必须与**写入格式同源**(本仓历史事故,DevDocs/开发习惯与约定.md §7):
// 列里存的是 formatTime 产出的定宽 30 字节 UTC 文本(timefmt.go:17-21),所以右边
// 的「当下」也必须由同一个 formatTime 产出**同形文本**,两边才落在同一个字母表里、
// 文本序才等于时间序。反例是拿 SQLite 的 datetime('now')("YYYY-MM-DD HH:MM:SS",
// 空格分隔、无小数秒)去比这些列值:'T'(0x54) 恒大于 ' '(0x20),于是**任何当日行
// 都「大于当下」**,过期行反而被判为未过期 —— 时间判断静默错,不报错、不 panic。
// 同理不许改写成 datetime(expires_at) 之类的函数比较:那是另一套口径,
// 一旦写入格式变化就再次静默分叉。timegate_test.go 里有这条反证的实证。
func timeGateNow() string {
return formatTime(time.Now())
}
// expiryText 把 MemoryItem.ExpiresAt 编码为要落库的 expires_at 文本,是
// 「读侧口径与写入格式同源」在**写入侧**的那一半:零值 = 不设过期
// (长期 / 日常记忆),写空串(与 DDL 的默认值一致);非零值走 formatTime。
//
// 它不做任何判据(过去值不在这里被拒、也不被改写):写入侧不决定可见性,
// 一条「出生即过期」的行照常落库,四条列表读路径立刻看不见它,直到后台清扫
// 把它删掉;Get 仍读得到(见 store.go 的 Get 注释)。
func expiryText(t time.Time) string {
if t.IsZero() {
return ""
}
return formatTime(t)
}
+357
View File
@@ -0,0 +1,357 @@
package memory
import (
"context"
"sort"
"strings"
"testing"
"time"
)
// 本文件是**读侧时效闸**的回归(design/03 §9.6 / §11.2)。
//
// 判据:status='active' ∧ (expires_at = '' ∨ expires_at > now) ∧ (valid_to = '' ∨ valid_to > now),
// 它必须同时出现在**四条列表读路径**上(Store.ListByPersona / ListScope / ListProject / List),
// 因为记忆读入口有 8 个而底层只有这四个;Retriever.visibleCandidates 组合调用其中两条,
// 因此检索入口自动继承同一判据(本条亦有测试)。任何一条漏闸 = 过期记忆照样进上下文、
// 照样出现在 admin 面板(design §11.2 的死角分析)。
// setItemExpiryForTest 直接改一行的时效列,模拟整理 / D3 第五态落下的时效裁定,
// 或「事实已失效」的 valid_to 标记。走 SQL 而不是走写入路径,是为了让本条只验证
// **读侧**:写入侧(Add 真的把 ExpiresAt 落成 expires_at 列)由本文件末条测试单独钉。
func setItemExpiryForTest(t *testing.T, s *Store, id, expiresAt, validTo string) {
t.Helper()
if _, err := s.db.ExecContext(context.Background(),
`UPDATE memory_items SET expires_at = ?, valid_to = ? WHERE id = ?`,
expiresAt, validTo, id); err != nil {
t.Fatalf("set expiry (expires_at=%q valid_to=%q) on %s: %v", expiresAt, validTo, id, err)
}
}
// assertReadSet 断言读路径返回的条目集合**恰好**是 want(按 id 比对),
// 且 hidden 里的条目一条都没漏进来。按 id 而不是按正文断言:正文可能重复,
// id 才是行的身份。
func assertReadSet(t *testing.T, name string, got, want, hidden []MemoryItem) {
t.Helper()
gotIDs := idsOfItems(got)
wantIDs := idsOfItems(want)
if !sameIDs(gotIDs, wantIDs) {
t.Fatalf("%s = %v %v, want %v", name, gotIDs, contentsOf(got), wantIDs)
}
for _, h := range hidden {
for _, id := range gotIDs {
if id == h.ID {
t.Errorf("%s 返回了已过期 / 已失效行 %q(时效闸失效)", name, h.Content)
}
}
}
}
// TestStore_读路径_过滤已过期行 逐条走四条列表读路径与检索路径,证明「临时记忆到期」
// (expires_at ≤ now)与「事实已失效」(valid_to ≤ now)两种行在每一处都不可见,
// 而空 expires_at(长期 / 日常记忆的默认值)与未来的 expires_at / valid_to 照常可见。
func TestStore_读路径_过滤已过期行(t *testing.T) {
t.Parallel()
ctx := context.Background()
s := newTestStore(t)
now := time.Now().UTC()
// 可见组:长期(expires_at 空)、临时未到期(Add 直接给 ExpiresAt)、未失效(valid_to 未来)。
longTerm := addItem(t, s, MemoryItem{
PersonaID: "persona.a", Content: "长期-角色", Scope: ScopePersona, Kind: KindRelationship,
})
tempAlive := addItem(t, s, MemoryItem{
PersonaID: "persona.a", Content: "临时-未到期-角色", Scope: ScopePersona, Kind: KindRelationship,
ExpiresAt: now.Add(time.Hour),
})
tempAliveUser := addItem(t, s, MemoryItem{
PersonaID: "persona.a", Content: "临时-未到期-用户", Scope: ScopeUser, Kind: KindWorldEvent,
ExpiresAt: now.Add(time.Hour),
})
validToFuture := addItem(t, s, MemoryItem{
PersonaID: "persona.a", Content: "未失效-项目", Scope: ScopeProject,
Kind: KindWorldEvent, ProjectID: "proj.1",
})
// 不可见组:临时已到期(写入侧落库的过去值)、valid_to 已过、项目域的临时已到期。
tempExpired := addItem(t, s, MemoryItem{
PersonaID: "persona.a", Content: "临时-已过期-角色", Scope: ScopePersona, Kind: KindRelationship,
ExpiresAt: now.Add(-time.Hour),
})
validToPast := addItem(t, s, MemoryItem{
PersonaID: "persona.a", Content: "已失效-用户", Scope: ScopeUser, Kind: KindWorldEvent,
})
tempExpiredProject := addItem(t, s, MemoryItem{
PersonaID: "persona.a", Content: "临时-已过期-项目", Scope: ScopeProject,
Kind: KindWorldEvent, ProjectID: "proj.1",
})
setItemExpiryForTest(t, s, validToPast.ID, "", formatTime(now.Add(-time.Hour)))
setItemExpiryForTest(t, s, tempExpiredProject.ID, formatTime(now.Add(-time.Hour)), "")
setItemExpiryForTest(t, s, validToFuture.ID, "", formatTime(now.Add(time.Hour)))
ownVisible := []MemoryItem{longTerm, tempAlive, tempAliveUser, validToFuture}
hidden := []MemoryItem{tempExpired, validToPast, tempExpiredProject}
for _, tc := range []struct {
name string
read func() ([]MemoryItem, error)
want []MemoryItem
}{
{
name: "ListByPersona",
read: func() ([]MemoryItem, error) { return s.ListByPersona(ctx, "persona.a") },
want: ownVisible,
},
{
name: "List",
read: func() ([]MemoryItem, error) { return s.List(ctx) },
want: ownVisible,
},
{
name: "ListScope/user",
read: func() ([]MemoryItem, error) { return s.ListScope(ctx, ScopeUser, "persona.a") },
want: []MemoryItem{tempAliveUser},
},
{
name: "ListScope/project",
read: func() ([]MemoryItem, error) { return s.ListScope(ctx, ScopeProject, "persona.a") },
want: []MemoryItem{validToFuture},
},
{
name: "ListProject",
read: func() ([]MemoryItem, error) { return s.ListProject(ctx, "proj.1") },
want: []MemoryItem{validToFuture},
},
} {
t.Run(tc.name, func(t *testing.T) {
got, err := tc.read()
if err != nil {
t.Fatalf("%s: %v", tc.name, err)
}
assertReadSet(t, tc.name, got, tc.want, hidden)
})
}
// 检索路径:visibleCandidates 是候选集本身(Retrieve/Inject 与所有复用 Retriever 的
// 入口都只从它取候选),闸加在它组合调用的 ListByPersona + ListScope 上,
// 因此这里必须一条过期行都没有。
r := NewRetriever(s)
cands, err := r.visibleCandidates(ctx, "persona.a")
if err != nil {
t.Fatalf("visibleCandidates: %v", err)
}
assertReadSet(t, "visibleCandidates", cands, ownVisible, hidden)
results, err := r.Retrieve(ctx, "persona.a", "", 20)
if err != nil {
t.Fatalf("Retrieve: %v", err)
}
if len(results) != len(ownVisible) {
t.Fatalf("Retrieve = %d 条 (%v), want %d", len(results), idsOf(results), len(ownVisible))
}
found := make([]MemoryItem, 0, len(results))
for _, res := range results {
found = append(found, res.Item)
}
assertReadSet(t, "Retrieve", found, ownVisible, hidden)
injected, err := r.Inject(ctx, "persona.a", "", 20)
if err != nil {
t.Fatalf("Inject: %v", err)
}
for _, h := range hidden {
if strings.Contains(injected, h.Content) {
t.Errorf("Inject 文本里出现了已过期 / 已失效行 %q(这就是「任何地方都调得到」的反面)", h.Content)
}
}
for _, v := range ownVisible {
if !strings.Contains(injected, v.Content) {
t.Errorf("Inject 文本缺少在有效期内的 %q", v.Content)
}
}
}
// TestStore_Get_不过时效闸 是反向护栏:按 id 的 Get 刻意**不过** status 闸与时效闸。
// 撤销软合并要读得到被吸收的行(design/23 §8 的「无损可逆」),清算与人工核查要读得到
// 已过期 / 已失效的行(design/03 §11.2 第 3 条)。若有人「顺手」给 Get 也加闸,本条会红。
func TestStore_Get_不过时效闸(t *testing.T) {
t.Parallel()
ctx := context.Background()
s := newTestStore(t)
now := time.Now().UTC()
tempExpired := addItem(t, s, MemoryItem{
PersonaID: "persona.a", Content: "临时-已过期-角色", Scope: ScopePersona, Kind: KindRelationship,
ExpiresAt: now.Add(-time.Hour),
})
validToPast := addItem(t, s, MemoryItem{
PersonaID: "persona.a", Content: "已失效-用户", Scope: ScopeUser, Kind: KindWorldEvent,
})
setItemExpiryForTest(t, s, validToPast.ID, "", formatTime(now.Add(-time.Hour)))
for _, want := range []MemoryItem{tempExpired, validToPast} {
got, err := s.Get(ctx, want.ID)
if err != nil {
t.Errorf("Get(%s) err = %v, want readable(撤销 / 审计的抓手)", want.ID, err)
continue
}
if got.Content != want.Content {
t.Errorf("Get(%s).Content = %q, want %q", want.ID, got.Content, want.Content)
}
}
}
// TestStore_Add_落库expires_at列 钉住写入侧:Add 是全仓唯一 INSERT 路径,
// 时效列的**真生产者**只能是它 —— 若 Add 不写这一列,读侧时效闸就退化成恒真空壳
// (expires_at 永远为空 ⇒ 过期行永远可见)。零值必须落空串(= 不设过期),
// 非零值必须走与读侧闸同一个 formatTime。
func TestStore_Add_落库expires_at列(t *testing.T) {
t.Parallel()
ctx := context.Background()
s := newTestStore(t)
expiry := time.Date(2026, 10, 20, 3, 20, 5, 830978900, time.UTC)
withExpiry := addItem(t, s, MemoryItem{
PersonaID: "persona.a", Content: "临时-带时效", Scope: ScopePersona,
Kind: KindRelationship, ExpiresAt: expiry,
})
withoutExpiry := addItem(t, s, MemoryItem{
PersonaID: "persona.a", Content: "长期-无时效", Scope: ScopePersona, Kind: KindRelationship,
})
for _, tc := range []struct {
name string
id string
want string
}{
{"非零 ExpiresAt", withExpiry.ID, formatTime(expiry)},
{"零值 ExpiresAt", withoutExpiry.ID, ""},
} {
var raw string
if err := s.db.QueryRowContext(ctx,
`SELECT expires_at FROM memory_items WHERE id = ?`, tc.id).Scan(&raw); err != nil {
t.Fatalf("%s: read raw expires_at: %v", tc.name, err)
}
if raw != tc.want {
t.Errorf("%s: expires_at = %q, want %q", tc.name, raw, tc.want)
}
}
}
// TestTimeGate_比较口径与写入格式同源 是本仓历史事故的回归(DevDocs/开发习惯与约定.md §7):
// 「SQLite 时间比较用错格式(带 T/Z 的串比 datetime('now'))会把『今天全部』当成『最近一小时』」。
//
// 闸的处理方式写死为「两边都由 formatTime 产出同形文本,文本序即时间序」,本条把这件事钉三下:
// 1. 闸的比较操作数与列值同源(同一个 formatTime,定宽 30 字节 UTC,可被 parseTime 解回);
// 2. 同源文本的**文本序 == 时间序**(这是整个闸成立的前提);
// 3. 跨格式比较会**系统性判错**(用 SQLite 字面量实证 'T' > ' ' 的陷阱),
// 所以任何把闸改成 datetime('now') 之类的第二套口径的改动,都会先在这条上露馅。
func TestTimeGate_比较口径与写入格式同源(t *testing.T) {
t.Parallel()
ctx := context.Background()
s := newTestStore(t)
gateNow := timeGateNow()
if len(gateNow) != storedTimeLen {
t.Fatalf("timeGateNow() = %q (%d 字节), want %d 字节(必须与列值同宽)",
gateNow, len(gateNow), storedTimeLen)
}
back, err := parseTime(gateNow)
if err != nil {
t.Fatalf("timeGateNow() 不能被 parseTime 解回: %v", err)
}
if reformatted := formatTime(back); reformatted != gateNow {
t.Fatalf("timeGateNow() 与列值格式不同源:再格式化得 %q,不等于 %q", reformatted, gateNow)
}
// 文本序 == 时间序(两个都由 formatTime 产出的值)。
earlier := time.Date(2026, 10, 13, 11, 0, 0, 0, time.UTC)
later := earlier.Add(time.Nanosecond)
if !(formatTime(earlier) < formatTime(later)) {
t.Fatalf("formatTime(%v) = %q 不早于 formatTime(%v) = %q —— 文本序已不等于时间序",
earlier, formatTime(earlier), later, formatTime(later))
}
// 同源比较:11:00 的列值小于 12:00 的当下 ⇒ 判为「已过期」。
var sameSource bool
if err := s.db.QueryRowContext(ctx,
`SELECT '2026-10-13T11:00:00.000000000Z' < ?`, formatTime(earlier.Add(time.Hour))).
Scan(&sameSource); err != nil {
t.Fatalf("same-source comparison: %v", err)
}
if !sameSource {
t.Fatal("同源文本比较失效:11:00 的列值未被判为早于 12:00 的当下")
}
// 跨格式比较(历史事故的机制):SQLite 的 datetime('now') 形如
// "YYYY-MM-DD HH:MM:SS",第 11 个字节是空格(0x20),而列值第 11 个字节是 'T'(0x54)。
// 于是**同一 UTC 日**内,任何列值都「大于当下」—— 早一小时的过期行反而被判为未过期。
var crossFormat bool
if err := s.db.QueryRowContext(ctx,
`SELECT '2026-10-13T11:00:00.000000000Z' > datetime('2026-10-13 12:00:00')`).
Scan(&crossFormat); err != nil {
t.Fatalf("cross-format comparison: %v", err)
}
if !crossFormat {
t.Fatalf("陷阱实证失效:跨格式比较不再系统性判错('T' vs ' ')," +
"请重新核对 timegate.go 的口径与 DevDocs/开发习惯与约定.md §7")
}
}
// TestTimeGate_闸不接受第二套时间口径 是结构性护栏:闸的 WHERE 片段里只允许出现
// 参数化比较(?),不得出现 SQLite 的时间函数 —— 一旦出现 datetime(...)/julianday(...)
// 之类,就有第二套口径,写入格式或 SQLite 行为一变就再次静默分叉(历史事故正是如此)。
func TestTimeGate_闸不接受第二套时间口径(t *testing.T) {
t.Parallel()
for _, fn := range []string{"datetime(", "julianday(", "strftime(", "unixepoch(", "date("} {
if strings.Contains(timeGateClause, fn) {
t.Errorf("timeGateClause 含 SQLite 时间函数 %q:闸必须只做「同源定宽文本」的参数化比较(%s)",
fn, timeGateClause)
}
}
if got := strings.Count(timeGateClause, "?"); got != 2 {
t.Errorf("timeGateClause 的占位符 = %d, want 2(expires_at 与 valid_to 各一个)", got)
}
}
// TestExpiryText_零值落空串 钉住写入侧编码:零值 = 不设过期,必须落空串(列默认值),
// 而不是 formatTime(零值)(那会写成 "0001-01-01T00:00:00.000000000Z",反而把长期记忆
// 变成「早已过期」—— 长期记忆会整体从读面消失)。
func TestExpiryText_零值落空串(t *testing.T) {
t.Parallel()
if got := expiryText(time.Time{}); got != "" {
t.Errorf("expiryText(零值) = %q, want 空串(不设过期)", got)
}
at := time.Date(2026, 10, 20, 3, 20, 5, 0, time.UTC)
got := expiryText(at)
if want := formatTime(at); got != want {
t.Errorf("expiryText(%v) = %q, want %q", at, got, want)
}
if len(got) != storedTimeLen {
t.Errorf("expiryText(%v) 长度 = %d, want %d", at, len(got), storedTimeLen)
}
}
// idsOfItems 取条目 id,仅用于失败信息与集合比对。
func idsOfItems(items []MemoryItem) []string {
out := make([]string, 0, len(items))
for _, it := range items {
out = append(out, it.ID)
}
sort.Strings(out)
return out
}
// sameIDs 报告两个**已排序**的 id 切片是否逐项相等。
func sameIDs(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
+37 -18
View File
@@ -34,7 +34,8 @@ func DataURI(mime string, data []byte) string {
// Resolve 加载一个 ImageRef 对应的字节。路径与 base64 载荷在 // Resolve 加载一个 ImageRef 对应的字节。路径与 base64 载荷在
// 本地读取;URL 用 http.Client 抓取(传入的 client,为 nil 时 // 本地读取;URL 用 http.Client 抓取(传入的 client,为 nil 时
// 用默认 client)。对 base64 输入,Data 字段原样作为 // 用默认 client)。对 base64 输入,Data 字段原样作为
// 载荷返回,MIME 类型从 data: 前缀推断。 // 载荷返回,MIME 类型从 data: 前缀推断;data: 载荷解码后还要过
// 内容嗅探(见 checkInlinePayload),不是图片一律否决。
func Resolve(ctx context.Context, ref ImageRef, client *http.Client) (mime string, data []byte, err error) { func Resolve(ctx context.Context, ref ImageRef, client *http.Client) (mime string, data []byte, err error) {
switch ref.Source { switch ref.Source {
case SourcePath: case SourcePath:
@@ -70,23 +71,38 @@ func readFile(path string) (string, []byte, error) {
// decodeBase64 返回 base64/data: 载荷解码后的字节及其 MIME。 // decodeBase64 返回 base64/data: 载荷解码后的字节及其 MIME。
// 长度预检先于解码与 data: 解析(见 checkInlineImageLength):超限载荷在 // 长度预检先于解码与 data: 解析(见 checkInlineImageLength):超限载荷在
// 分配之前就被拦下,而不是先按约 4/3 分配再报错。 // 分配之前就被拦下,而不是先按约 4/3 分配再报错。
//
// data: URI 解码后要过内容嗅探(checkInlinePayload):前缀里的声明与真实字节
// 对不上时否决,绝不把「挂着 image/* 声明、内容却不是图片」的载荷转成 data: URI
// 交给上游(2026-10-07 的实测缺陷:`data:image/png;base64,` 只带前缀不带载荷,
// 旧代码照样报 image/png,空载荷被当成图片发给上游)。
//
// 裸 base64 路径保持原状:它没有声明可对照,包内契约定的是「按 image/png 处理」
// (见 Resolve 的文档),加嗅探就是改这条契约。
func decodeBase64(src string) (string, []byte, error) { func decodeBase64(src string) (string, []byte, error) {
if err := checkInlineImageLength(src); err != nil { if err := checkInlineImageLength(src); err != nil {
return "", nil, err return "", nil, err
} }
mime, encoded, isDataURI := splitDataURI(src) mime, encoded, isDataURI := splitDataURI(src)
if isDataURI { if !isDataURI {
decoded, err := base64.StdEncoding.DecodeString(encoded) // 以 data: 开头却没解析出 (mime, payload) 只有一个成因:**畸形**的
// data: URI(缺逗号分隔符,或没有 ;base64 段 —— 本包只处理 base64 载荷,
// 与 image-crop 同口径)。放它走下面的裸 base64 解码,报出来的是
// base64 语法错误,指不到真正的毛病,故在此明确拒绝。
if strings.HasPrefix(src, "data:") {
return "", nil, fmt.Errorf("vision: %w: malformed data: URI (want data:<mime>;base64,<base64 payload>)", ErrBadImage)
}
decoded, err := base64.StdEncoding.DecodeString(src)
if err != nil { if err != nil {
return "", nil, fmt.Errorf("vision: %w: %v", ErrBadImage, err) return "", nil, fmt.Errorf("vision: %w: %v", ErrBadImage, err)
} }
return mime, decoded, nil return "image/png", decoded, nil
} }
decoded, err := base64.StdEncoding.DecodeString(src) decoded, err := base64.StdEncoding.DecodeString(encoded)
if err != nil { if err != nil {
return "", nil, fmt.Errorf("vision: %w: %v", ErrBadImage, err) return "", nil, fmt.Errorf("vision: %w: %v", ErrBadImage, err)
} }
return "image/png", decoded, nil return checkInlinePayload(mime, decoded)
} }
// fetchURL 通过 http(s) 下载一张图像;只允许 http/https 协议。 // fetchURL 通过 http(s) 下载一张图像;只允许 http/https 协议。
@@ -184,8 +200,14 @@ func inlineImage(ctx context.Context, src string) (inlinedImage, error) {
return inlinedImage{URI: DataURI(mime, data), MIME: mime, Raw: data}, nil return inlinedImage{URI: DataURI(mime, data), MIME: mime, Raw: data}, nil
} }
// splitDataURI 解析 "data:<mime>;base64,<payload>" 前缀。src 不是 // splitDataURI 解析 "data:<mime>;base64,<payload>" 前缀。src 不是 data: URI、
// data: URI 时第二个返回值是 false。 // 或虽是 data: URI 但不带 ;base64 段时返回 false。
//
// 只认 base64 载荷(与 image-crop 的 decodeCropDataURI 同口径):百分号编码的
// data: URI 从来就不是本包的输入,把它当 base64 解码只会得到一段垃圾字节,
// 还挂着 image/png 的假声明送进模型 —— 这类串现在按畸形 data: URI 拒绝。
// 返回的 MIME 按包内统一口径归一化(去参数、转小写,见 mediaType);声明缺失
// (`data:;base64,…`)时给 image/png,与本函数历来的默认一致。
func splitDataURI(src string) (mime, payload string, ok bool) { func splitDataURI(src string) (mime, payload string, ok bool) {
if !strings.HasPrefix(src, "data:") { if !strings.HasPrefix(src, "data:") {
return "", "", false return "", "", false
@@ -196,18 +218,15 @@ func splitDataURI(src string) (mime, payload string, ok bool) {
return "", "", false return "", "", false
} }
meta := rest[:comma] meta := rest[:comma]
payload = rest[comma+1:] idx := strings.Index(meta, ";base64")
mime = "image/png" if idx < 0 {
if strings.HasPrefix(meta, ";base64") { return "", "", false
return mime, payload, true
} }
if idx := strings.Index(meta, ";base64"); idx >= 0 { mime = mediaType(meta[:idx])
if t := strings.TrimSuffix(meta[:idx], ";base64"); t != "" { if mime == "" {
mime = t mime = "image/png"
}
return mime, payload, true
} }
return mime, payload, true return mime, rest[comma+1:], true
} }
// fileExists 报告 path 是否存在于磁盘且为常规文件。 // fileExists 报告 path 是否存在于磁盘且为常规文件。
@@ -9,6 +9,10 @@ import (
"testing" "testing"
) )
// pngSignature 是 PNG 的 8 字节魔数(http.DetectContentType 的判据)。
// 体积口径类用例要在载荷头部带上它,才不会被内容嗅探当成文本否决。
var pngSignature = []byte("\x89PNG\r\n\x1a\n")
// TestInlineImageLengthBoundary 钉住长度预检的边界与错误文案:恰好等于 // TestInlineImageLengthBoundary 钉住长度预检的边界与错误文案:恰好等于
// 上限通过,多一个字符就报 ErrImageTooLarge。比较运算符一旦被挪位 // 上限通过,多一个字符就报 ErrImageTooLarge。比较运算符一旦被挪位
// (>= / <),或口径被换成另一个数,这里立刻红。 // (>= / <),或口径被换成另一个数,这里立刻红。
@@ -90,13 +94,18 @@ func TestInlineImageSrc_RejectsOversizeDataURI(t *testing.T) {
// (否则网关入口与解码层对「一张图能有多大」给出了两个答案),解码结果 // (否则网关入口与解码层对「一张图能有多大」给出了两个答案),解码结果
// 逐字节一致,绝不能误拒 —— 这正是「恰好 = 上限通过」的字节侧半边。 // 逐字节一致,绝不能误拒 —— 这正是「恰好 = 上限通过」的字节侧半边。
// //
// 载荷头部带上 PNG 签名:本用例量的是体积口径,但 data: URI 解码后要过内容嗅探
// (image.go 的 checkInlinePayload),纯填充字节会被嗅成 text/plain 而否决 ——
// 那测的就是另一件事了。字节总长不变,故字符侧的口径换算不受影响。
//
// 判定口径是**字符数**(与网关入口一致,含 64 字符的 data: 前缀余量), // 判定口径是**字符数**(与网关入口一致,含 64 字符的 data: 前缀余量),
// 因此多出几十字节的载荷仍可能落在余量内通过;字符侧的 +1 越界由 // 因此多出几十字节的载荷仍可能落在余量内通过;字符侧的 +1 越界由
// TestInlineImageLengthBoundary 钉住,余量本身不超过 64 字符 // TestInlineImageLengthBoundary 钉住,余量本身不超过 64 字符
// (TestMaxInlineImageCharsMatchesByteBudget)。 // (TestMaxInlineImageCharsMatchesByteBudget)。
func TestDecodeBase64_ExactBudgetImagePasses(t *testing.T) { func TestDecodeBase64_ExactBudgetImagePasses(t *testing.T) {
t.Parallel() t.Parallel()
exact := bytes.Repeat([]byte{0x5a}, MaxInlineImageBytes) filler := bytes.Repeat([]byte{0x5a}, MaxInlineImageBytes-len(pngSignature))
exact := append(append([]byte{}, pngSignature...), filler...)
dataURI := DataURI("image/png", exact) dataURI := DataURI("image/png", exact)
if len(dataURI) > MaxInlineImageChars { if len(dataURI) > MaxInlineImageChars {
t.Fatalf("满额图片编码后 %d 字符,超过上限 %d(口径不一致)", len(dataURI), MaxInlineImageChars) t.Fatalf("满额图片编码后 %d 字符,超过上限 %d(口径不一致)", len(dataURI), MaxInlineImageChars)
@@ -0,0 +1,62 @@
package vision
import (
"fmt"
"strings"
)
// 本文件管的是 **data:/base64 入参**的内容嗅探与可行动报错 —— 与 mime.go 的
// imageMIMEFor 是同一套裁决(真实字节是否决权、声明只作参考),只是换了入口:
// mime.go 管 HTTP 响应与文件读取,这里管「调用方把图片正文直接塞进参数」那条路。
//
// 起因(2026-10-07 18:32:51 实测,客户端从后端逐字节取过证据):模型交给
// vision 工具的 image 是 `data:image/png;base64,` —— **只有前缀、没有载荷**。
// 旧代码把它 base64 解码得到零字节,却照样报 MIME=image/png,于是那串空载荷
// 挂着 image/png 的声明白白发给上游,换回一句「You have uploaded an unsupported
// image」。报错指向不了真正的毛病(参数里根本没有图片字节),模型也无从自纠;
// image-crop 在同一秒也栽在同一处,报的是 `image: unknown format`。
// 现在这类输入在本地就被明确否决,并附上「它看起来是什么」的物证与可行动提示。
// supportedFormats 是视觉上游接受的图像格式(与上游白名单同口径:
// webp / png / jpeg / gif)。写进错误文案,模型据此换格式重试。
const supportedFormats = "webp, png, jpeg, gif"
// reuseHint 是「把非图片当图片传」时的可行动提示。模型复述不出会话里那张图的
// 几万字符 base64(实测就是把前缀抄了下来),正确做法是传它的 URL 或本地路径。
const reuseHint = "pass the image's http(s) URL or local path instead of retyping a data: URI"
// evidenceBytes 是报错时作为物证给出的头部字节数。
const evidenceBytes = 16
// checkInlinePayload 校验 data: URI 解码后的载荷,返回应当声明的 MIME 与载荷
// 本身。判据与路径/URL 来源完全一致(复用 imageMIMEFor):判出文本一律否决,
// 判不出图像而声明也立不住时同样否决 —— 绝不把一段不是图片的字节挂着
// image/* 的声明送进视觉模型。
//
// 返回的 MIME 取 data: 前缀里**声明**的那个,而不是嗅探结果:这条路径的既有
// 契约是「客户端送来的 data: URI 逐字节不变地继续工作」
// (url_test.go 的 TestOpenAICompatibleAdapter_Base64Unchanged),换掉声明
// 就等于改客户端的线上形状;嗅探在这里只当否决权用。
func checkInlinePayload(declared string, data []byte) (string, []byte, error) {
if len(data) == 0 {
return "", nil, fmt.Errorf("vision: %w: %s payload is empty — the data: URI carries no base64 bytes; hint: %s",
ErrNotImage, declaredLabel(declared), reuseHint)
}
if _, err := imageMIMEFor(declared, data); err != nil {
return "", nil, fmt.Errorf("vision: %w: %s payload is %s; supported formats: %s; leading bytes: %s; hint: %s",
ErrNotImage, declaredLabel(declared), mediaType(sniffMIME(data)), supportedFormats, hexHead(data), reuseHint)
}
return declared, data, nil
}
// hexHead 把载荷开头若干字节渲染成十六进制,作为「它看起来是什么」的物证。
// 只给固定长度的头部 —— **绝不回显正文**:载荷可能很大,也可能含隐私内容
// (同 imageMIMEFor 的口径:错误信息只含类型名,不含图像字节)。
func hexHead(data []byte) string {
n := min(len(data), evidenceBytes)
parts := make([]string, 0, n)
for _, b := range data[:n] {
parts = append(parts, fmt.Sprintf("%02x", b))
}
return strings.Join(parts, " ")
}
@@ -0,0 +1,250 @@
package vision
import (
"bytes"
"context"
"encoding/base64"
"errors"
"image"
"image/color"
"image/jpeg"
"strings"
"testing"
)
// jpegBytes 返回一张真实的 JPEG(1x1),与 mime_test.go 的 pngBytes 同用途:
// 让「内容嗅探」在测试里有真实依据,而不是手写常量。
func jpegBytes(t *testing.T) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, 2, 2))
img.Set(0, 0, color.RGBA{R: 0xcc, G: 0x33, B: 0x33, A: 0xff})
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, nil); err != nil {
t.Fatalf("encode jpeg: %v", err)
}
return buf.Bytes()
}
// htmlDataURI 构造「把网页当图片传」的入参:声明 text/html,载荷是一段 HTML。
func htmlDataURI() string {
return "data:text/html;base64," + base64.StdEncoding.EncodeToString(htmlBytes())
}
// TestResolve_DataURIImagesDecode 钉住本缺陷的主用例:带完整 base64 载荷的
// data: URI(jpeg / png)必须照常解出字节与声明的 MIME —— 也就是 2026-10-07
// 那次失败里「本该成立的形状」。MIME 取前缀声明,字节逐字节不变。
func TestResolve_DataURIImagesDecode(t *testing.T) {
t.Parallel()
cases := []struct {
name string
mime string
data []byte
}{
{name: "jpeg", mime: "image/jpeg", data: jpegBytes(t)},
{name: "png", mime: "image/png", data: pngBytes(t)},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
src := DataURI(tc.mime, tc.data)
ref, err := ParseImageRef(src)
if err != nil {
t.Fatalf("ParseImageRef: %v", err)
}
mime, data, err := Resolve(context.Background(), ref, nil)
if err != nil {
t.Fatalf("Resolve(%s): %v", tc.mime, err)
}
if mime != tc.mime {
t.Errorf("mime = %q, want %q", mime, tc.mime)
}
if !bytes.Equal(data, tc.data) {
t.Errorf("bytes = %d, want the %d decoded bytes", len(data), len(tc.data))
}
})
}
}
// TestResolve_EmptyDataURIPayloadRejected 是本缺陷的回归用例(2026-10-07
// 18:32:51 实测):模型传的是 `data:image/png;base64,` —— **只有前缀、没有载荷**。
// 旧代码解出零字节却照样报 image/png,空载荷被当成图片发给上游,换回一句
// 「You have uploaded an unsupported image」。现在必须在本地就明确否决,
// 并且说清病因(载荷为空)而不是甩一句 upstream 400。
func TestResolve_EmptyDataURIPayloadRejected(t *testing.T) {
t.Parallel()
ref, err := ParseImageRef("data:image/png;base64,")
if err != nil {
t.Fatalf("ParseImageRef: %v", err)
}
mime, data, err := Resolve(context.Background(), ref, nil)
if err == nil {
t.Fatalf("空载荷被接受(mime=%q, %d bytes),want 明确报错", mime, len(data))
}
if !errors.Is(err, ErrNotImage) {
t.Fatalf("error = %v, want ErrNotImage", err)
}
if !strings.Contains(err.Error(), "empty") {
t.Errorf("错误文案没点出「载荷为空」:%v", err)
}
if !strings.Contains(err.Error(), "http(s) URL") {
t.Errorf("错误文案没给可行动提示:%v", err)
}
}
// TestResolve_NonImageDataURIRejected 钉住「这不是图片」:声明 text/html 的
// data: URI 必须报明确错误,写清嗅探到的类型与支持的格式,且**不得回显正文**。
func TestResolve_NonImageDataURIRejected(t *testing.T) {
t.Parallel()
ref, err := ParseImageRef(htmlDataURI())
if err != nil {
t.Fatalf("ParseImageRef: %v", err)
}
if _, _, err := Resolve(context.Background(), ref, nil); err == nil {
t.Fatal("text/html 载荷被接受,want ErrNotImage")
} else {
if !errors.Is(err, ErrNotImage) {
t.Fatalf("error = %v, want ErrNotImage", err)
}
msg := err.Error()
if strings.Contains(msg, "502 Bad Gateway") || strings.Contains(msg, "<!DOCTYPE") {
t.Fatalf("错误文案回显了正文:%v", msg)
}
for _, want := range []string{"text/html", "png", "jpeg", "gif", "webp"} {
if !strings.Contains(msg, want) {
t.Errorf("错误文案缺少 %q:%v", want, msg)
}
}
}
}
// TestResolve_MalformedDataURI 钉住畸形 data: URI 不 panic、报可行动错误:
// 缺逗号分隔符、缺 ;base64 段、非法 base64 三种都要落到明确的说法上。
func TestResolve_MalformedDataURI(t *testing.T) {
t.Parallel()
cases := []struct {
name string
src string
}{
{name: "no comma", src: "data:image/jpeg;base64"},
{name: "no base64 marker", src: "data:image/png,plaintext"},
// 这一条专治「漏了 ;base64 段、但载荷恰好是合法 base64 字符表」:
// 旧 splitDataURI 会把它当 base64 解出 3 个零字节,还挂着 image/png
// 的假声明放行(百分号编码的 data: URI 从来不是本包的输入)。
{name: "no base64 marker with base64-shaped payload", src: "data:image/png,AAAA"},
{name: "illegal base64", src: "data:image/png;base64,!!!!"},
{name: "empty after prefix", src: "data:image/jpeg;base64,"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ref, err := ParseImageRef(tc.src)
if err != nil {
t.Fatalf("ParseImageRef: %v", err)
}
if _, _, err := Resolve(context.Background(), ref, nil); err == nil {
t.Fatalf("%q 被接受,want 报错", tc.src)
}
})
}
}
// TestResolve_BareBase64Unchanged 钉住「裸 base64 路径一个字不改」:它没有声明
// 可对照,包内契约定的是按 image/png 处理(见 Resolve 的文档)。合法的 png 与
// jpeg 裸载荷都必须照原样解出、MIME 仍是 image/png。
func TestResolve_BareBase64Unchanged(t *testing.T) {
t.Parallel()
for _, raw := range [][]byte{pngBytes(t), jpegBytes(t)} {
src := base64.StdEncoding.EncodeToString(raw)
ref, err := ParseImageRef(src)
if err != nil {
t.Fatalf("ParseImageRef: %v", err)
}
if ref.Source != SourceBase64 {
t.Fatalf("Source = %q, want base64", ref.Source)
}
mime, data, err := Resolve(context.Background(), ref, nil)
if err != nil {
t.Fatalf("裸 base64 被拒:%v", err)
}
if mime != "image/png" {
t.Errorf("mime = %q, want image/png(裸 base64 的既有契约)", mime)
}
if !bytes.Equal(data, raw) {
t.Errorf("裸 base64 解码结果变了:%d bytes", len(data))
}
}
}
// TestVisionTool_DataURIReachesUpstreamIntact 是端到端的那一条(工具 → 适配器
// → 上游桩):合法的 data:image/jpeg;base64,... 必须原样成为线上 image_url 里的
// 合法 image 块 —— 上游收到的就是一张真 JPEG,所以不会再有 400。
func TestVisionTool_DataURIReachesUpstreamIntact(t *testing.T) {
t.Parallel()
jpg := jpegBytes(t)
src := DataURI("image/jpeg", jpg)
upSrv, rec := newImageURLRecorderServer(t)
tool := NewVisionTool("vision", newAdapter(upSrv.URL))
res, err := tool.Execute(context.Background(), map[string]any{"image": src, "prompt": "what is this"})
if err != nil {
t.Fatalf("Execute: %v", err)
}
if m, _ := res.(map[string]any); m["description"] == nil {
t.Fatalf("tool result = %#v, want a description", res)
}
calls, urls := rec.seen()
if calls != 1 || len(urls) != 1 {
t.Fatalf("upstream calls = %d urls = %d, want exactly one image block", calls, len(urls))
}
if urls[0] != src {
t.Fatalf("upstream image_url = %.40q..., want the input data URI byte for byte", urls[0])
}
payload := urls[0][strings.IndexByte(urls[0], ',')+1:]
raw, err := base64.StdEncoding.DecodeString(payload)
if err != nil {
t.Fatalf("上游收到的载荷不是合法 base64:%v", err)
}
if _, err := jpeg.Decode(bytes.NewReader(raw)); err != nil {
t.Fatalf("上游收到的载荷解不出 JPEG(就是那句 unsupported image 的成因):%v", err)
}
}
// TestVisionTool_EmptyPayloadNeverReachesUpstream 钉住失败发生在**本地**:
// 空载荷的 data: URI 必须在调用上游之前就被拒(旧行为是照发空图,换回 400)。
func TestVisionTool_EmptyPayloadNeverReachesUpstream(t *testing.T) {
t.Parallel()
upSrv, rec := newImageURLRecorderServer(t)
tool := NewVisionTool("vision", newAdapter(upSrv.URL))
_, err := tool.Execute(context.Background(), map[string]any{"image": "data:image/png;base64,"})
if err == nil {
t.Fatal("空载荷被接受,want 报错")
}
if !errors.Is(err, ErrNotImage) {
t.Fatalf("error = %v, want ErrNotImage", err)
}
if calls, _ := rec.seen(); calls != 0 {
t.Fatalf("上游被调用了 %d 次,want 0(缺陷正是「空图照发」)", calls)
}
}
// TestResolve_ErrorMessageLeaksNoPayload 钉住错误文案的边界:给模型的提示只含
// 类型级信息与固定建议,不含 base64 载荷或图片正文(载荷可能很大也可能敏感)。
func TestResolve_ErrorMessageLeaksNoPayload(t *testing.T) {
t.Parallel()
payload := base64.StdEncoding.EncodeToString(htmlBytes())
ref, err := ParseImageRef("data:image/png;base64," + payload)
if err != nil {
t.Fatalf("ParseImageRef: %v", err)
}
mime, data, err := Resolve(context.Background(), ref, nil)
if err == nil {
t.Fatalf("谎报 image/png 的 HTML 载荷被接受(mime=%q, %d bytes)", mime, len(data))
}
if strings.Contains(err.Error(), payload) {
t.Fatalf("错误文案回显了 base64 载荷:%v", err)
}
if !strings.Contains(err.Error(), "text/html") {
t.Errorf("错误文案没点出嗅探到的真实类型:%v", err)
}
}
@@ -1,6 +1,7 @@
package vision package vision
import ( import (
"bytes"
"context" "context"
"strings" "strings"
"testing" "testing"
@@ -68,9 +69,14 @@ func TestParseImageRef(t *testing.T) {
} }
} }
// TestResolve_Base64 钉住 data: URI 那条路:载荷被解码成原始字节,MIME 取前缀
// 里声明的那个。**载荷必须是真的图片字节** —— data: URI 带声明,解码后要过内容
// 嗅探(image.go 的 checkInlinePayload),拿非图片字节(如 "hello")当载荷
// 现在会被明确否决,而不是挂着 image/png 的声明送进模型。
func TestResolve_Base64(t *testing.T) { func TestResolve_Base64(t *testing.T) {
t.Parallel() t.Parallel()
ref, err := ParseImageRef("data:image/png;base64,aGVsbG8=") png := pngBytes(t)
ref, err := ParseImageRef(DataURI("image/png", png))
if err != nil { if err != nil {
t.Fatalf("ParseImageRef error: %v", err) t.Fatalf("ParseImageRef error: %v", err)
} }
@@ -81,8 +87,8 @@ func TestResolve_Base64(t *testing.T) {
if mime != "image/png" { if mime != "image/png" {
t.Errorf("mime = %q, want image/png", mime) t.Errorf("mime = %q, want image/png", mime)
} }
if string(data) != "hello" { if !bytes.Equal(data, png) {
t.Errorf("data = %q, want hello", data) t.Errorf("data = %d bytes, want the decoded payload (%d bytes)", len(data), len(png))
} }
} }
@@ -143,6 +143,10 @@ type Options struct {
// (channels.json → 环境变量回退)。非 nil 时桥接器支持热重载; // (channels.json → 环境变量回退)。非 nil 时桥接器支持热重载;
// 为 nil 时桥接器仅支持启动期装配(Reload 返回 ErrNilFactory)。 // 为 nil 时桥接器仅支持启动期装配(Reload 返回 ErrNilFactory)。
Factory PlatformFactory Factory PlatformFactory
// Channels 是能力注册表的窄端口(见 ChannelRegistry):热重载把新适配器写回
// 同名条目,使管理面板读到的永远是当前实例。为 nil 时热重载照常,只是面板
// 仍读旧实例(首次同步记一次 WARN)。
Channels ChannelRegistry
// ChannelPersona 非 nil 时解析某个渠道账号绑定的角色 // ChannelPersona 非 nil 时解析某个渠道账号绑定的角色
// (channels.json 的 ChannelAccount.PersonaID,design/22 §2 方案 b):调用时传入 // (channels.json 的 ChannelAccount.PersonaID,design/22 §2 方案 b):调用时传入
// 平台与本地账号 id,账号未绑定角色时返回 ""。 // 平台与本地账号 id,账号未绑定角色时返回 ""。
@@ -253,6 +257,10 @@ type Bridge struct {
started bool started bool
closed bool closed bool
factory PlatformFactory factory PlatformFactory
// channels 是热重载同步能力注册表的窄端口(reload.go 的 replaceChannelEntry /
// dropChannelEntry);为 nil 时面板读到的是保存前的旧实例,首次同步记一次 WARN。
channels ChannelRegistry
channelsWarnOnce sync.Once
// channelPersona 从生效配置解析账号的渠道级角色 // channelPersona 从生效配置解析账号的渠道级角色
// (design/22 §2 方案 b);adapterPersona 按已连接的适配器缓存解析结果, // (design/22 §2 方案 b);adapterPersona 按已连接的适配器缓存解析结果,
// 使入站路径永不重读 channels.json。二者 // 使入站路径永不重读 channels.json。二者
@@ -353,6 +361,7 @@ func New(opts Options) (*Bridge, error) {
readCancels: make(map[string]context.CancelFunc), readCancels: make(map[string]context.CancelFunc),
gates: make(map[string]*adapter.FlowGate), gates: make(map[string]*adapter.FlowGate),
factory: opts.Factory, factory: opts.Factory,
channels: opts.Channels,
channelPersona: opts.ChannelPersona, channelPersona: opts.ChannelPersona,
adapterPersona: make(map[string]string), adapterPersona: make(map[string]string),
documentsCfg: opts.DocumentStore, documentsCfg: opts.DocumentStore,
@@ -0,0 +1,126 @@
package channelbridge
import (
"regexp"
"strings"
)
// 本文件承载 IM 出站的**行内标签白名单**处理(契约 §6 v0.89「助手消息的行内标签白名单」)。
//
// 背景(主人 2026-10-07 实测报障):昔涟会在正文里插行内 HTML 标签,真样例
//
// <p>今天那儿是晴,13~25℃,日落 <b>18:35</b>,比上海晚了整整一小时呢。</p>
//
// Web 端已按白名单渲染(`2dae469`),而 IM 出站此前只剥 `<p>` / `<action>` / `<narration>`
// 与星号块 ⇒ `<b>18:35</b>` 连标签一起字面发给微信/QQ,用户看到裸标签。
//
// 🔴 处置口径(必须与契约 v0.89 及 Web 端渲染器逐条一致,否则同一段文本两端显示不同):
// 1. 行内白名单 = **六项** `b` / `strong` / `i` / `em` / `code` / `u`;**成对且平衡**的
// 白名单标签 ⇒ **剥标签、留内部文本**(微信/QQ 不支持 HTML,没有别的选择);
// (`u` 由主人 2026-10-07 裁定加回 —— 下划线要保留渲染;`s` 仍不在白名单内。)
// 2. **白名单外**的标签(`<script>` / `<div>` / `<s>` …)⇒ **逐字原样保留**
// (它们是文本,一个字符不丢);
// 3. **结构不平衡**(未闭合 / 落单闭标签 / 交错)⇒ **整段保持原样**(契约把这一条的
// 粒度列为实现细节,本端与 Web 端取同一粒度,保证两端对同一段文本给出同一结果);
// 4. 属性一律丢弃(`<b class=x onclick=y>` 只取标签名 `b`);标签名大小写不敏感。
//
// ⚠️ 1/2 与既有的 `<p>` / `<action>` 行为**刻意不同**:那两个是「角色扮演/段落标记」,
// 要剥;行内标签是「排版标记」,剥样式留文本;白名单外则**根本不该动**。
//
// ⚠️ **`<br>` 不在这里** —— 它是**气泡/消息分隔符**(与 `<p>` 同级),切分在 splitBubbles,
// 见 sanitize.go 的 splitBubbles / bubblesOfSegment;本文件只负责在清洗层把它摘掉。
//
// 安全口径:产物是不可信文本(模型生成 + 用户可诱导),这里只做**移除**,
// 绝不引入任何属性、标签或 HTML 实体(输出的尖括号一定不多于输入)。
// inlineTagRe 匹配标签候选:`<` 紧跟字母、标签体内不再出现尖括号。
// 与 Web 端 TAG_RE 同一形状 ⇒ `1 < 2`、`a<b`、`<b`(无 `>`)都不是标签候选。
var inlineTagRe = regexp.MustCompile(`</?([a-zA-Z][a-zA-Z0-9]*)[^<>]*>`)
// inlineBreakTag 是气泡分隔符的标签名(`<br>` / `<br/>` / `</br>` 三种写法按 HTML5 口径等价)。
const inlineBreakTag = "br"
// isBreakTagName 报告标签名是否属于 `<br>` 类气泡分隔符(大小写不敏感)。
// **切分层与清洗层共用这一个判据**,两层对 `<br>` 的认法因此不可能漂移。
func isBreakTagName(name string) bool {
return strings.EqualFold(name, inlineBreakTag)
}
// isPairedInlineTag 报告标签名是否属于需配对处理的行内白名单(契约 v0.89:六项)。
func isPairedInlineTag(name string) bool {
switch name {
case "b", "strong", "i", "em", "code", "u":
return true
default:
return false
}
}
// breakSeparatorSpans 返回文本里 `<br>` 类**气泡分隔符**的字节区间 `[start,end)`,
// 用于把一段文本切成多条消息/气泡。它复用行内标签的同一套词法(inlineTagRe)
// 与同一个名字判据(isBreakTagName),因此切分层与清洗层面对 `<br>` 的认法一致。
func breakSeparatorSpans(text string) [][]int {
if !strings.Contains(text, "<") {
return nil
}
var spans [][]int
for _, m := range inlineTagRe.FindAllStringSubmatchIndex(text, -1) {
if isBreakTagName(text[m[2]:m[3]]) {
spans = append(spans, []int{m[0], m[1]})
}
}
return spans
}
// stripInlineTags 按行内标签白名单处理一段气泡文本:剥掉成对白名单标签,
// 白名单外标签原样保留;**只要结构不平衡就整段返回原文**。
//
// 粒度是「整段」(调用方给的一段气泡文本),与 Web 端按气泡调 parseInlineMarkup
// 的粒度一致:整段降级必须整段一起做,否则会丢掉字符。
//
// ⚠️ 生产路径里本函数拿到的文本已经过 splitBubbles 的 `<br>` 切分,因此正常情况下
// 不会见到 `br`;万一见到(有人绕过切分层直接调 stripNarration),这里与 `<p>` 同级地
// **把它摘掉** —— 绝不把分隔符漏成一个可见的字面标签。真正的「切成多条消息」只由
// splitBubbles 负责(见 sanitize.go)。
func stripInlineTags(text string) string {
if !strings.Contains(text, "<") {
return text
}
matches := inlineTagRe.FindAllStringSubmatchIndex(text, -1)
if matches == nil {
return text
}
var (
out strings.Builder
stack []string
last int
)
for _, m := range matches {
name := strings.ToLower(text[m[2]:m[3]])
out.WriteString(text[last:m[0]])
last = m[1]
switch {
case isBreakTagName(name):
// 分隔符:隔离已由 splitBubbles 做过,这一层只摘除标记。
case isPairedInlineTag(name):
if text[m[0]+1] == '/' {
if len(stack) == 0 || stack[len(stack)-1] != name {
// 落单闭标签 / 交错 ⇒ 整段降级为原文。
return text
}
stack = stack[:len(stack)-1]
continue
}
stack = append(stack, name)
default:
// 白名单外:连同尖括号一起当文本,逐字保留。
out.WriteString(text[m[0]:m[1]])
}
}
if len(stack) != 0 {
// 有未闭合的开标签 ⇒ 整段降级为原文。
return text
}
out.WriteString(text[last:])
return out.String()
}
@@ -0,0 +1,312 @@
package channelbridge
import (
"context"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
)
// 本文件覆盖 IM 出站的行内标签白名单(契约 §6 v0.89:`b` / `strong` / `i` / `em` /
// `code` / `u` **六项**)与 `<br>` 的**气泡/消息分隔符**语义(与 `<p>` 同级)。
// 口径与 Web 端渲染器(frontend/web/src/lib/inlineMarkup.ts)逐条对齐。
// TestInlineTags_RealSampleParagraph 复现主人 2026-10-07 报障的真样例:
// `<b>18:35</b>` 此前原样发给微信/QQ,对方看到字面标签。
func TestInlineTags_RealSampleParagraph(t *testing.T) {
t.Parallel()
in := "<p>今天那儿是晴,13~25℃,日落 <b>18:35</b>,比上海晚了整整一小时呢。</p>"
want := "今天那儿是晴,13~25℃,日落 18:35,比上海晚了整整一小时呢。"
got := stripNarration(in)
if got != want {
t.Errorf("stripNarration = %q, want %q", got, want)
}
if strings.Contains(got, "<b") || strings.Contains(got, "</b") {
t.Errorf("stripNarration leaked inline tag: %q", got)
}
// 冒泡切分链路里同样不得带出标签。
bubbles := splitBubbles(in)
if len(bubbles) != 1 || bubbles[0] != want {
t.Errorf("splitBubbles = %#v, want [%q]", bubbles, want)
}
}
// TestInlineTags_PairedWhitelistTagsStripped 报告六项白名单的成对标签只去标签、
// 留内部文本,且大小写不敏感。
func TestInlineTags_PairedWhitelistTagsStripped(t *testing.T) {
t.Parallel()
cases := []struct{ in, want string }{
{"日落 <b>18:35</b> 呢", "日落 18:35 呢"},
{"<strong>重要</strong>", "重要"},
{"<i>斜</i>", "斜"},
{"<em>强调</em>", "强调"},
{"<code>go test</code>", "go test"},
{"<u>下划线</u>", "下划线"},
{"<U>大写下划线</U>", "大写下划线"},
{"<u class=x>属性也丢</u>", "属性也丢"},
{"<B>大写</B>", "大写"},
{"<STRONG>大写</STRONG>", "大写"},
{"<b><i>嵌套</i></b>", "嵌套"},
{"a<b>1</b>b<code>2</code>c", "a1b2c"},
}
for _, c := range cases {
if got := stripNarration(c.in); got != c.want {
t.Errorf("stripNarration(%q) = %q, want %q", c.in, got, c.want)
}
}
}
// TestBreakTags_SplitIntoSeparateMessages 是契约 §6 v0.89「`<br>` 是气泡/消息分隔符」
// 的核心断言:`<br>` 两边的正文必须成为**两条独立的消息**,而不是同一条里的一个换行。
func TestBreakTags_SplitIntoSeparateMessages(t *testing.T) {
t.Parallel()
cases := []struct {
name string
in string
want []string
}{
{"<p> 块内", "<p>甲<br>乙</p>", []string{"甲", "乙"}},
{"无 <p> 标记", "甲<br>乙", []string{"甲", "乙"}},
{"连续 <br> 不留空气泡", "<p>甲<br><br>乙</p>", []string{"甲", "乙"}},
{"两侧都是空白", "<p>甲<br> <br>乙</p>", []string{"甲", "乙"}},
{"只有空白与分隔符", "<br> <br>", nil},
{"首尾分隔符", "<br>甲<br>", []string{"甲"}},
{"与 <p> 混用", "<p>甲<br>乙</p><p>丙</p>", []string{"甲", "乙", "丙"}},
{"<br/> 等价", "甲<br/>乙", []string{"甲", "乙"}},
{"</br> 等价", "甲</br>乙", []string{"甲", "乙"}},
{"大小写不敏感", "甲<BR>乙", []string{"甲", "乙"}},
{"分隔符与行内标签同现", "<p>甲<b>粗</b><br><code>乙</code></p>", []string{"甲粗", "乙"}},
{"<br> 不是标签(无 >)", "甲<br 乙", []string{"甲<br 乙"}},
{"<bravo> 不是分隔符", "甲<bravo>乙</bravo>", []string{"甲<bravo>乙</bravo>"}},
}
for _, c := range cases {
got := splitBubbles(c.in)
if len(got) != len(c.want) {
t.Errorf("%s: splitBubbles(%q) = %#v, want %#v", c.name, c.in, got, c.want)
continue
}
for i := range c.want {
if got[i] != c.want[i] {
t.Errorf("%s: splitBubbles(%q)[%d] = %q, want %q", c.name, c.in, i, got[i], c.want[i])
}
}
}
}
// TestBreakTags_NotSplitInsideRoleplayMarkup 锁住切分与角色扮演摘除的**先后**:
// `<br>` 写在 `<action>` / `<narration>` / 星号注记**内部**时不得把注记劈成聊天气泡
// —— 否则注记正文会漏成一条消息,是对既有「注记整体剥除」口径的回归。
func TestBreakTags_NotSplitInsideRoleplayMarkup(t *testing.T) {
t.Parallel()
cases := []struct {
in string
want []string
}{
{"<p><action>她<br>点点头</action>甲</p>", []string{"甲"}},
{"<p><narration>心里<br>有点甜</narration>甲</p>", []string{"甲"}},
{"<p>*动作:她<br>点点头*甲</p>", []string{"甲"}},
{"<action>他<br>走了</action>", nil},
{"甲<br><action>他<br>走了</action>", []string{"甲"}},
}
for _, c := range cases {
got := splitBubbles(c.in)
if len(got) != len(c.want) {
t.Errorf("splitBubbles(%q) = %#v, want %#v", c.in, got, c.want)
continue
}
for i := range c.want {
if got[i] != c.want[i] {
t.Errorf("splitBubbles(%q)[%d] = %q, want %q", c.in, i, got[i], c.want[i])
}
}
}
}
// TestBreakTags_NoLiteralLeak 掏底断言:即便有人绕过切分层直接调 stripNarration,
// `<br>` 三种写法也**绝不能漏成字面量标签**(清洗层与 `<p>` 同级地摘除它)。
// 真正的「切成多条消息」只由 splitBubbles 负责(见上一条测试)。
func TestBreakTags_NoLiteralLeak(t *testing.T) {
t.Parallel()
for _, in := range []string{"<p>甲<br>乙</p>", "甲<br/>乙", "甲</br>乙", "甲<BR>乙"} {
got := stripNarration(in)
if strings.Contains(strings.ToLower(got), "br") {
t.Errorf("stripNarration(%q) 漏出了分隔符字面量: %q", in, got)
}
}
}
// TestInlineTags_NonWhitelistKeptVerbatim 白名单外标签是文本,逐字不丢 ——
// 这与 `<p>` / `<action>`(角色扮演/段落标记,要剥)刻意不同。
// `s` **不在**白名单内(主人只批了下划线 `u`),因此也按文本逐字保留。
func TestInlineTags_NonWhitelistKeptVerbatim(t *testing.T) {
t.Parallel()
cases := []string{
"<script>alert(1)</script>",
"<div>块级</div>",
"<a href=\"http://x\">链接</a>",
"<img src=x onerror=y>",
"<iframe src=x></iframe>",
"<s>删除线</s>",
"<S>大写删除线</S>",
}
for _, in := range cases {
if got := stripNarration(in); got != in {
t.Errorf("stripNarration(%q) = %q, want verbatim", in, got)
}
}
// `u` 已由主人 2026-10-07 裁定加回白名单 ⇒ 正向断言:剥标签、留文本。
if got := stripNarration("<u>下划线</u>"); got != "下划线" {
t.Errorf(`stripNarration("<u>下划线</u>") = %q, want %q`, got, "下划线")
}
// 白名单与外标签混排:只动白名单那对,其余逐字保留。
in := "她说了 <b>这样</b> 和 <script>alert(1)</script> 两回事"
want := "她说了 这样 和 <script>alert(1)</script> 两回事"
if got := stripNarration(in); got != want {
t.Errorf("stripNarration(%q) = %q, want %q", in, got, want)
}
}
// TestInlineTags_UnbalancedDegradesWholeSegment 结构不平衡(未闭合 / 落单闭标签 /
// 交错)⇒ 整段保持原样(契约把粒度列为实现细节;本端与 Web 端取同一粒度)。
func TestInlineTags_UnbalancedDegradesWholeSegment(t *testing.T) {
t.Parallel()
cases := []string{
"未闭合 <b>x",
"落单闭标签 x</b>",
"交错 <b><i>x</b></i>",
"自闭合 <b/>x",
"a<b>c",
"<code>没收尾",
"混排 <b>开了 <i>也开了</i> 只剩 b 未闭合",
}
for _, in := range cases {
if got := stripNarration(in); got != in {
t.Errorf("stripNarration(%q) = %q, want verbatim (unbalanced)", in, got)
}
}
}
// TestInlineTags_AttributesDropped 属性一律不进结果。
func TestInlineTags_AttributesDropped(t *testing.T) {
t.Parallel()
in := "答案 <b class=x onclick=y>y</b>"
if got := stripNarration(in); got != "答案 y" {
t.Errorf("stripNarration(%q) = %q, want %q", in, got, "答案 y")
}
in2 := `<code data-x="1">ok</code>`
if got := stripNarration(in2); got != "ok" {
t.Errorf("stripNarration(%q) = %q, want %q", in2, got, "ok")
}
}
// TestInlineTags_LookalikesNotTreatedAsTags 标签候选的护栏(契约 §4.16 同一口径):
// 不是标签候选的形状一律原样保留。
func TestInlineTags_LookalikesNotTreatedAsTags(t *testing.T) {
t.Parallel()
cases := []string{
"1 < 2 且 3 > 2",
"a<b",
"a <b 没有尖括号收尾",
"<p2>不是段落标签</p2>",
"<b",
}
for _, in := range cases {
if got := stripNarration(in); got != in {
t.Errorf("stripNarration(%q) = %q, want verbatim", in, got)
}
}
}
// TestInlineTags_NoRegression_ExistingSanitizeBehaviour 既有清洗行为逐字不变
// (角色扮演标签 / 星号块 / 段落标记 / 空行折叠)。
func TestInlineTags_NoRegression_ExistingSanitizeBehaviour(t *testing.T) {
t.Parallel()
cases := []struct{ in, want string }{
{"<action>弯起眼睛笑了</action>\n当然看得到呀,亲爱的。", "当然看得到呀,亲爱的。"},
{"<p>你好呀</p>\n<narration>心里有点甜</narration>\n<p>今天天气不错</p>", "你好呀\n今天天气不错"},
{"就是普通一句话,没有动作。\n第二行。", "就是普通一句话,没有动作。\n第二行。"},
{"<action>拍拍你</action>", ""},
{"*动作:弯起眼睛笑了*\n当然看得到呀,亲爱的。", "当然看得到呀,亲爱的。"},
{"*旁白:心里有点甜*\n这是正文。", "这是正文。"},
{"这是一段 *强调* 文字,不是动作。", "这是一段 *强调* 文字,不是动作。"},
{"*弯起眼睛*", ""},
{"<p>a</p>\n\n\n<p>b</p>", "a\nb"},
{"<p><action>满意地点点头</action></p>\n<p>真乖♪</p>", "真乖♪"},
}
for _, c := range cases {
if got := stripNarration(c.in); got != c.want {
t.Errorf("stripNarration(%q) = %q, want %q", c.in, got, c.want)
}
}
// 注记被剥掉后,注记内部的行内标签也不该参与渲染(契约 §6 v0.89 末条)。
in := "<action>她 <b>点点头</b></action>好呀。"
if got := stripNarration(in); got != "好呀。" {
t.Errorf("stripNarration(%q) = %q, want %q", in, got, "好呀。")
}
}
// TestInlineTags_NoRegression_BubbleSplitting 气泡切分语义不受影响:
// 一个 `<p>` 一个气泡、空气泡丢弃、无 `<p>` 且无 `<br>` 时整段一个气泡。
func TestInlineTags_NoRegression_BubbleSplitting(t *testing.T) {
t.Parallel()
if got := splitBubbles("<p>第一段 <b>粗</b></p><p>第二段</p>"); len(got) != 2 || got[0] != "第一段 粗" || got[1] != "第二段" {
t.Errorf("splitBubbles = %#v", got)
}
if got := splitBubbles("<p><action>点点头</action></p><p>正文</p>"); len(got) != 1 || got[0] != "正文" {
t.Errorf("splitBubbles = %#v", got)
}
in := "无段落标记的一段\n带 <b>粗</b> 的行"
if got := splitBubbles(in); len(got) != 1 || got[0] != "无段落标记的一段\n带 粗 的行" {
t.Errorf("splitBubbles = %#v", got)
}
if got := splitBubbles("<action>拍拍你</action>"); len(got) != 0 {
t.Errorf("splitBubbles = %#v, want empty", got)
}
}
// TestInlineTags_EndToEndThroughRealPipeline 走**真出站链路**:入站消息 → 会话 /
// actor → agent 循环(模型侧是桩)→ 清洗切分 → 适配器出站。断言的正是适配器
// 真正收到的那条消息内容与**条数** —— 也就是用户在微信/QQ 上会看到的字面文本,
// 以及 `<br>` 是否真的变成了多条消息。
func TestInlineTags_EndToEndThroughRealPipeline(t *testing.T) {
t.Parallel()
const reply = "<p>今天那儿是晴,13~25℃,日落 <b>18:35</b>,比上海晚了整整一小时呢。</p>" +
"<p>第二段<br>第三段:<code>go test</code> 跑过了</p>"
chat := &stubChatter{res: &loop.Result{Text: reply}}
sess := &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}}
ad := &fakeAdapter{name: "telegram"}
b, err := New(Options{
Loop: chat, Sessions: sess, Owner: testOwnerProfile("telegram", "100"), History: &captureHistory{},
})
if err != nil {
t.Fatalf("New: %v", err)
}
b.Add(ad)
b.bubbleGapFn = func() time.Duration { return 0 }
b.process(context.Background(), directMessage())
want := []string{
"今天那儿是晴,13~25℃,日落 18:35,比上海晚了整整一小时呢。",
"第二段",
"第三段:go test 跑过了",
}
if len(ad.sends) != len(want) {
t.Fatalf("出站气泡 = %d 条, want %d: %+v", len(ad.sends), len(want), ad.sends)
}
for i, out := range ad.sends {
if out.Content != want[i] {
t.Fatalf("气泡[%d] 内容 = %q, want %q", i, out.Content, want[i])
}
if strings.ContainsAny(out.Content, "<>") {
t.Fatalf("气泡[%d] 仍带标签: %q", i, out.Content)
}
if out.ContentType != adapter.ContentText {
t.Fatalf("气泡[%d] ContentType = %q, want text", i, out.ContentType)
}
}
}
+70 -6
View File
@@ -14,6 +14,7 @@ import (
"fmt" "fmt"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
) )
// PlatformFactory 依据平台当前生效的账号配置 // PlatformFactory 依据平台当前生效的账号配置
@@ -27,6 +28,20 @@ type PlatformFactory interface {
Build(platform string) ([]adapter.PlatformAdapter, error) Build(platform string) ([]adapter.PlatformAdapter, error)
} }
// ChannelRegistry 是能力注册表的**窄端口**,只暴露热重载需要同步的两件事:
// 把某个名字的渠道条目换成新适配器、把已消失的账号条目删掉。桥接器用它让管理面板
// (渠道列表 / 在线状态 / open·close 解析)读到的永远是**当前**适配器实例,而不必
// 依赖 capability/registry 的具体类型 —— 注册表实现与桥接器因此保持解耦。
//
// 未接线(Options.Channels 为 nil)时桥接器照常热重载,但注册表条目会停在被保存前
// 的那个实例上:面板显示「未连接」、点「连接」驱动的是旧实例。此时只记一次 WARN。
type ChannelRegistry interface {
// ReplaceChannel 用同名的新适配器覆盖(或新增)渠道条目。
ReplaceChannel(ch sdk.Channel) error
// RemoveChannel 删除该名字的渠道条目。
RemoveChannel(name string) error
}
// ErrNilFactory 表示请求重载时未注入平台工厂 // ErrNilFactory 表示请求重载时未注入平台工厂
// (没有热重载配置来源)。 // (没有热重载配置来源)。
var ErrNilFactory = errors.New("channelbridge: nil platform factory") var ErrNilFactory = errors.New("channelbridge: nil platform factory")
@@ -112,16 +127,19 @@ func (b *Bridge) Reload(platform string) error {
} }
for _, a := range newAdapters { for _, a := range newAdapters {
b.connectAndRun(a) b.connectAndRun(a)
// 紧跟上线之后同步能力注册表:新实例先连上,面板才改为读它,
// 避免「注册表已指向新实例、它却还没连」的窗口。
b.replaceChannelEntry(a)
} }
for _, o := range old { for _, o := range old {
if newNames[o.Name()] { if newNames[o.Name()] {
// 同名账号被重建后的适配器替换:停止旧的读取 // 同名账号被重建后的适配器替换:只停旧实例的读取循环,
// 循环,但保留注册表条目——它现在指向新适配器。 // 注册表条目已由 replaceChannelEntry 指向新实例。
b.cancelAndDisconnect(o, oldCancels[o.Name()]) b.cancelAndDisconnect(o, oldCancels[o.Name()])
continue continue
} }
// 账号被彻底移除(或更名):停止它并删除其注册表 // 账号被彻底移除(或更名):停止它并删除其注册表
// 条目,后续 snapshot 便不再看到它。 // 条目,后续 snapshot 与面板便不再看到它。
b.removeAdapter(o, oldCancels[o.Name()]) b.removeAdapter(o, oldCancels[o.Name()])
} }
b.log.Info("channel reloaded", "platform", platform, "adapters", len(newAdapters)) b.log.Info("channel reloaded", "platform", platform, "adapters", len(newAdapters))
@@ -148,10 +166,55 @@ func (b *Bridge) adapterSnap(platform string) ([]adapter.PlatformAdapter, map[st
return list, cancels return list, cancels
} }
// replaceChannelEntry 把刚上线的适配器写回能力注册表。注册表里的渠道条目只在启动
// 时写过一次,而热重载重建出来的是同名的新实例:不同步的话,管理面板(渠道列表、
// 账号在线状态、open/close 的目标解析)读到的仍是保存前的**旧实例** —— 卡片显示
// 「未连接」,点「连接」拨的是旧配置里的地址(design/04 §3.1)。
//
// 同步失败只记日志、绝不返回错误:面板保存不该被注册表同步拖垮。
func (b *Bridge) replaceChannelEntry(a adapter.PlatformAdapter) {
if a == nil {
return
}
name := a.Name()
if !b.IsConnected(name) {
// 没上线就不动注册表:换成连不上的实例只会让面板的在线状态更失真。
b.log.Warn("channel registry left unchanged: adapter not connected", "channel", name)
return
}
if b.channels == nil {
b.warnChannelRegistryUnwired()
return
}
if err := b.channels.ReplaceChannel(a); err != nil {
b.log.Warn("channel registry replace failed", "channel", name, "error", err)
}
}
// dropChannelEntry 在账号被移除(或更名)后删掉它的注册表条目,使管理面板不再列出
// 这条已经不存在的账号。同 replaceChannelEntry:失败只记日志。
func (b *Bridge) dropChannelEntry(name string) {
if b.channels == nil {
b.warnChannelRegistryUnwired()
return
}
if err := b.channels.RemoveChannel(name); err != nil {
b.log.Warn("channel registry remove failed", "channel", name, "error", err)
}
}
// warnChannelRegistryUnwired 只在首次同步时提醒一次:未注入 Channels 端口时热重载
// 照常进行(适配器确实换了),只是面板读到的是保存前的旧实例。
func (b *Bridge) warnChannelRegistryUnwired() {
b.channelsWarnOnce.Do(func() {
b.log.Warn("channel registry not wired: admin panel keeps the pre-reload adapter instance")
})
}
// cancelAndDisconnect 停止适配器的读取循环并拆除其会话。 // cancelAndDisconnect 停止适配器的读取循环并拆除其会话。
// 它绝不改动注册表 map:替换同名适配器的调用方 // 它绝不改动桥接器自己的适配器表与能力注册表:替换同名适配器
// 希望(新)条目保留,而移除账号的调用方使用 // 的调用方希望(新)条目保留,而移除账号的调用方使用
// removeAdapter。 // removeAdapter(它会连注册表条目一起清掉)。
func (b *Bridge) cancelAndDisconnect(o adapter.PlatformAdapter, c context.CancelFunc) { func (b *Bridge) cancelAndDisconnect(o adapter.PlatformAdapter, c context.CancelFunc) {
if c != nil { if c != nil {
c() c()
@@ -174,5 +237,6 @@ func (b *Bridge) removeAdapter(o adapter.PlatformAdapter, c context.CancelFunc)
delete(b.readCancels, name) delete(b.readCancels, name)
b.mu.Unlock() b.mu.Unlock()
b.forgetAdapterPersona(name) b.forgetAdapterPersona(name)
b.dropChannelEntry(name)
b.log.Info("channel (reload) removed", "channel", name) b.log.Info("channel (reload) removed", "channel", name)
} }
@@ -0,0 +1,245 @@
package channelbridge
import (
"context"
"errors"
"sync"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// 本文件钉住渠道热重载的**注册表同步**(design/04 §3.1):能力注册表里的渠道条目只在
// 启动时写过一次,而热重载重建出来的是同名**新实例**。不同步的话,管理面板(渠道卡片、
// 账号在线状态、open/close 目标解析)读到的仍是保存前的旧实例——卡片永远「未连接」,
// 点「连接」驱动的是旧配置里的那把(拨旧 URL)。
// errRegistryDown 表示注册表同步失败(被测代码必须吞掉它,只记日志)。
var errRegistryDown = errors.New("registry unavailable")
// errConnectDown 表示新适配器拨号失败。
var errConnectDown = errors.New("dial refused")
// recordingChannelRegistry 是 ChannelRegistry 的记录型实现:按注册名保存**接口值**,
// 使断言能按指针同一性判定新旧实例(新旧名字相同,比名字必然假绿)。
type recordingChannelRegistry struct {
mu sync.Mutex
entries map[string]sdk.Channel
removed []string
replaceErr error
removeErr error
}
func newRecordingRegistry(preset ...sdk.Channel) *recordingChannelRegistry {
reg := &recordingChannelRegistry{entries: make(map[string]sdk.Channel, len(preset))}
for _, ch := range preset {
reg.entries[ch.Name()] = ch
}
return reg
}
func (r *recordingChannelRegistry) ReplaceChannel(ch sdk.Channel) error {
if r.replaceErr != nil {
return r.replaceErr
}
r.mu.Lock()
defer r.mu.Unlock()
r.entries[ch.Name()] = ch
return nil
}
func (r *recordingChannelRegistry) RemoveChannel(name string) error {
if r.removeErr != nil {
return r.removeErr
}
r.mu.Lock()
defer r.mu.Unlock()
r.removed = append(r.removed, name)
delete(r.entries, name)
return nil
}
// entry 返回当前条目(不存在则 nil),模拟注册表的读侧。
func (r *recordingChannelRegistry) entry(name string) sdk.Channel {
r.mu.Lock()
defer r.mu.Unlock()
return r.entries[name]
}
// removedNames 返回所有被要求删除的名字。
func (r *recordingChannelRegistry) removedNames() []string {
r.mu.Lock()
defer r.mu.Unlock()
return append([]string(nil), r.removed...)
}
var _ ChannelRegistry = (*recordingChannelRegistry)(nil)
// downAdapter 是 Connect 必失败的适配器,用来钉住「新实例没上线就不动注册表」。
type downAdapter struct {
*fakeAdapter
err error
}
func (d *downAdapter) Connect(context.Context) error { return d.err }
// newReloadBridgeWithCaps 与 newReloadBridge 同形,额外注入注册表窄端口。
func newReloadBridgeWithCaps(t *testing.T, factory PlatformFactory, caps ChannelRegistry) *Bridge {
t.Helper()
b, err := New(Options{
Loop: &stubChatter{res: &loop.Result{Text: "hi"}},
Sessions: &stubSessions{sess: session.Session{SessionID: "s", Owner: true, MemWritable: true}},
Owner: &user.OwnerProfile{},
Factory: factory,
Channels: caps,
})
if err != nil {
t.Fatalf("New: %v", err)
}
t.Cleanup(func() { b.Stop(context.Background()) })
return b
}
// TestReloadPublishesNewAdapterInstanceToRegistry 是本次修复的主判据:重载后注册表里
// 同名条目必须指向**新实例**,否则面板显示「未连接」、点「连接」驱动旧实例。
func TestReloadPublishesNewAdapterInstanceToRegistry(t *testing.T) {
t.Parallel()
old := &fakeAdapter{name: "kook:conf0", cfg: map[string]any{"bot_token": "old"}}
factory := &stubFactory{}
// 启动注册(wire_channels 的职责)写进注册表的就是这个旧实例,重载要覆盖的正是它。
reg := newRecordingRegistry(sdk.Channel(old))
b := newReloadBridgeWithCaps(t, factory, reg)
b.Add(old)
b.Start()
next := &fakeAdapter{name: "kook:conf0", cfg: map[string]any{"bot_token": "new"}}
factory.adpts = []adapter.PlatformAdapter{next}
if err := b.Reload("kook"); err != nil {
t.Fatalf("Reload: %v", err)
}
got := reg.entry("kook:conf0")
if got == nil {
t.Fatal("重载后注册表里没有 kook:conf0:面板会列出「账号已配置但没有已注册的适配器」")
}
// 指针同一性判据:新旧实例的 Name() 相同,比名字必然假绿。
if got != sdk.Channel(next) {
t.Fatalf("注册表条目 = %#v, want 新实例", got)
}
if got == sdk.Channel(old) {
t.Fatal("注册表仍指向旧实例:卡片会永远显示未连接,点「连接」驱动的是旧 URL 的那把")
}
if !got.IsConnected() {
t.Fatal("注册表里的条目未连接:面板会显示未连接")
}
if !b.IsConnected("kook:conf0") || old.IsConnected() {
t.Fatalf("桥接器应当只让新实例在线:new=%v old=%v", b.IsConnected("kook:conf0"), old.IsConnected())
}
}
// TestReloadRemovesDroppedAccountFromRegistry:账号被删除(或更名)后,它的注册表条目
// 必须一并消失,否则面板会继续列出一条已经不存在的账号。同时钉住「同名重建的账号绝不
// 被按名字删除」——否则会误删刚写进去的新实例。
func TestReloadRemovesDroppedAccountFromRegistry(t *testing.T) {
t.Parallel()
dropped := &fakeAdapter{name: "kook:conf0", cfg: map[string]any{"bot_token": "t0"}}
kept := &fakeAdapter{name: "kook:conf1", cfg: map[string]any{"bot_token": "t1"}}
factory := &stubFactory{}
reg := newRecordingRegistry(sdk.Channel(dropped), sdk.Channel(kept))
b := newReloadBridgeWithCaps(t, factory, reg)
b.Add(dropped)
b.Add(kept)
b.Start()
nextKept := &fakeAdapter{name: "kook:conf1", cfg: map[string]any{"bot_token": "t1b"}}
factory.adpts = []adapter.PlatformAdapter{nextKept}
if err := b.Reload("kook"); err != nil {
t.Fatalf("Reload: %v", err)
}
if reg.entry("kook:conf0") != nil {
t.Fatal("被移除账号的注册表条目还在:面板会列出一条不存在的账号")
}
if got := reg.entry("kook:conf1"); got != sdk.Channel(nextKept) {
t.Fatalf("保留账号的条目 = %#v, want 新实例", got)
}
for _, name := range reg.removedNames() {
if name == "kook:conf1" {
t.Fatal("同名重建的账号被按名字删掉了:会误删刚写回的新实例")
}
}
}
// TestReloadWithoutRegistryPortKeepsAdapterRunning:未注入端口时保持旧行为 —— 热重载
// 照常成功、新实例照常上线,只是不同步注册表(不得因为缺端口而 panic 或中断重载)。
func TestReloadWithoutRegistryPortKeepsAdapterRunning(t *testing.T) {
t.Parallel()
old := &fakeAdapter{name: "kook:conf0", cfg: map[string]any{"bot_token": "old"}}
factory := &stubFactory{}
b := newReloadBridgeWithCaps(t, factory, nil)
b.Add(old)
b.Start()
next := &fakeAdapter{name: "kook:conf0", cfg: map[string]any{"bot_token": "new"}}
factory.adpts = []adapter.PlatformAdapter{next}
if err := b.Reload("kook"); err != nil {
t.Fatalf("未接线注册表不该让 Reload 失败:%v", err)
}
if !next.IsConnected() || !b.IsConnected("kook:conf0") {
t.Fatal("未接线注册表时新适配器仍应照常上线")
}
}
// TestReloadSurvivesRegistryFailures:注册表同步失败只记日志,绝不中断重载、绝不返回
// 错误——面板保存不能被注册表拖垮。
func TestReloadSurvivesRegistryFailures(t *testing.T) {
t.Parallel()
dropped := &fakeAdapter{name: "kook:conf0", cfg: map[string]any{"bot_token": "t0"}}
kept := &fakeAdapter{name: "kook:conf1", cfg: map[string]any{"bot_token": "t1"}}
factory := &stubFactory{}
reg := newRecordingRegistry(sdk.Channel(dropped), sdk.Channel(kept))
reg.replaceErr = errRegistryDown
reg.removeErr = errRegistryDown
b := newReloadBridgeWithCaps(t, factory, reg)
b.Add(dropped)
b.Add(kept)
b.Start()
nextKept := &fakeAdapter{name: "kook:conf1", cfg: map[string]any{"bot_token": "t1b"}}
factory.adpts = []adapter.PlatformAdapter{nextKept}
if err := b.Reload("kook"); err != nil {
t.Fatalf("注册表同步失败不该让 Reload 失败:%v", err)
}
if !b.IsConnected("kook:conf1") || !nextKept.IsConnected() {
t.Fatal("同步失败时新适配器仍应照常上线")
}
}
// TestReloadLeavesRegistryAloneWhenNewAccountFailsToConnect:新实例没上线(拨号失败)
// 就不写注册表——换成连不上的实例只会让面板的在线状态更失真;无关账号的条目不受影响。
func TestReloadLeavesRegistryAloneWhenNewAccountFailsToConnect(t *testing.T) {
t.Parallel()
old := &fakeAdapter{name: "kook:conf0", cfg: map[string]any{"bot_token": "old"}}
down := &downAdapter{fakeAdapter: &fakeAdapter{name: "kook:conf1", cfg: map[string]any{"bot_token": "new"}}, err: errConnectDown}
factory := &stubFactory{}
reg := newRecordingRegistry(sdk.Channel(old))
b := newReloadBridgeWithCaps(t, factory, reg)
b.Add(old)
b.Start()
nextOld := &fakeAdapter{name: "kook:conf0", cfg: map[string]any{"bot_token": "old2"}}
factory.adpts = []adapter.PlatformAdapter{nextOld, down}
if err := b.Reload("kook"); err != nil {
t.Fatalf("单个适配器连不上不该让 Reload 失败:%v", err)
}
if reg.entry("kook:conf1") != nil {
t.Fatal("没连上的新实例不该写进注册表")
}
if got := reg.entry("kook:conf0"); got != sdk.Channel(nextOld) {
t.Fatalf("已上线账号的条目 = %#v, want 新实例", got)
}
}
+70 -12
View File
@@ -41,13 +41,23 @@ var (
const shortRoleplayRunes = 40 const shortRoleplayRunes = 40
// stripNarration 返回去掉角色扮演/动作/ // stripNarration 返回去掉角色扮演/动作/
// 旁白标记、段落标记被抹平、连续空行被折叠后的 // 旁白标记、段落标记被抹平、连续空行被折叠、行内白名单标签被剥掉的
// 纯聊天文本。它对畸形输入绝不报错或 panic;纯动作/独白的 // 纯聊天文本。它对畸形输入绝不报错或 panic;纯动作/独白的
// 回复会变成空字符串(调用方可据此跳过发送)。 // 回复会变成空字符串(调用方可据此跳过发送)。
//
// 它是 finishNarration ∘ stripRoleplayMarkup 两步的组合 —— 拆成两步是为了让
// splitBubbles 能在中间插入 `<br>` 的分隔符切分(见 bubblesOfSegment)。
func stripNarration(text string) string { func stripNarration(text string) string {
if strings.TrimSpace(text) == "" { if strings.TrimSpace(text) == "" {
return "" return ""
} }
return finishNarration(stripRoleplayMarkup(text))
}
// stripRoleplayMarkup 摘除角色扮演/动作/旁白标记:成对与落单的
// `<action>`/`<narration>`、全角星号块、带角色扮演关键词的半角星号块。
// 顺序与参数逐字沿用既有实现(本函数只是把原来的前四步搬出来,行为不变)。
func stripRoleplayMarkup(text string) string {
s := pairedRoleplayRe.ReplaceAllString(text, "") s := pairedRoleplayRe.ReplaceAllString(text, "")
s = strayRoleplayRe.ReplaceAllString(s, "") s = strayRoleplayRe.ReplaceAllString(s, "")
s = starRoleplayRe.ReplaceAllStringFunc(s, func(m string) string { s = starRoleplayRe.ReplaceAllStringFunc(s, func(m string) string {
@@ -56,9 +66,16 @@ func stripNarration(text string) string {
} }
return m return m
}) })
s = halfStarRoleplayRe.ReplaceAllString(s, "") return halfStarRoleplayRe.ReplaceAllString(s, "")
s = paragraphRe.ReplaceAllString(s, "") }
// finishNarration 完成一段**气泡文本**的清洗:抹平 `<p>` 段落标记、折叠连续空行、
// 剥掉行内白名单标签。这三步必须留在切分**之后**:段落与空行折叠是「一条消息内部」
// 的排版整理,行内标签的「整段降级」也以气泡为粒度(与 Web 端同口径)。
func finishNarration(segment string) string {
s := paragraphRe.ReplaceAllString(segment, "")
s = blankLinesRe.ReplaceAllString(s, "\n") s = blankLinesRe.ReplaceAllString(s, "\n")
s = stripInlineTags(s)
return strings.TrimSpace(s) return strings.TrimSpace(s)
} }
@@ -93,31 +110,72 @@ func hasRoleplayPrefix(t string) bool {
var paragraphBlockRe = regexp.MustCompile(`(?is)<p\b[^>]*?>(.*?)</p\s*>`) var paragraphBlockRe = regexp.MustCompile(`(?is)<p\b[^>]*?>(.*?)</p\s*>`)
// splitBubbles 把一条回复按顺序拆成它承载的聊天气泡列表。 // splitBubbles 把一条回复按顺序拆成它承载的聊天气泡列表。
// 每个 <p>...</p> 块为一个气泡,其文本已剥离角色扮演/动作/ //
// 旁白;空气泡被丢弃。没有 <p> 标记的回复按 // **两个同级的分隔符**(契约 §6 v0.89):
// 单个气泡处理(保留换行)。纯动作/独白得到空切片。 // - 每个 `<p>...</p>` 块 = 一个气泡(既有语义,逐字不变);
// - 每个 `<br>` 类分隔符(`<br>` / `<br/>` / `</br>`,大小写不敏感)= 一次
// 「切成多条消息」的边界,与 `<p>` 同级 —— **不是**一个换行字符。
//
// 空气泡(含只由空白构成的气泡)被丢弃。没有 `<p>` 标记的回复**不是**按单个气泡处理
// 了(这一条被 v0.89 改写):它同样会按 `<br>` 切开,例如 `甲<br>乙` ⇒ 两条消息。
// 纯动作/独白得到空切片。
func splitBubbles(text string) []string { func splitBubbles(text string) []string {
if !strings.Contains(strings.ToLower(text), "<p") { if !strings.Contains(strings.ToLower(text), "<p") {
if c := stripNarration(text); c != "" { if c := bubblesOfSegment(text); len(c) != 0 {
return []string{c} return c
} }
return nil return nil
} }
out := make([]string, 0) out := make([]string, 0)
for _, m := range paragraphBlockRe.FindAllStringSubmatch(text, -1) { for _, m := range paragraphBlockRe.FindAllStringSubmatch(text, -1) {
if c := stripNarration(m[1]); c != "" { out = append(out, bubblesOfSegment(m[1])...)
out = append(out, c)
}
} }
if len(out) == 0 { if len(out) == 0 {
// 有 <p> 标记但没有格式良好的块——回退为整段文本。 // 有 <p> 标记但没有格式良好的块——回退为整段文本。
if c := stripNarration(text); c != "" { out = append(out, bubblesOfSegment(text)...)
}
return out
}
// bubblesOfSegment 把一个**段落区段**(`<p>` 块内部文本,或无 `<p>` 时的整段文本)
// 切成它承载的若干气泡文本。
//
// 🔴 两步顺序是本文件的要害:
// 1. 先 `stripRoleplayMarkup` —— 角色扮演/星号标记整块消失。因此 `<br>` 写在
// `<action>` / 星号块**内部**时不会把一段注记劈成两条聊天消息(那样会把注记正文
// 漏成聊天气泡,是对既有「注记整体剥除」口径的回归);
// 2. 再按 `<br>` 切分 —— 此时剩下的 `<br>` 都只可能出现在**真正要对用户说的正文**里,
// 切开正合契约「始终处理为多条消息隔断」。
//
// 切分**之后**的每一段各自走 finishNarration(抹平 `<p>` / 折叠空行 / 剥行内标签),
// 因此「空气泡丢弃」与 `<p>` 空气泡同一套口径:只由空白构成的段会被 TrimSpace 成空串后丢掉。
func bubblesOfSegment(segment string) []string {
parts := splitByBreakTags(stripRoleplayMarkup(segment))
out := make([]string, 0, len(parts))
for _, p := range parts {
if c := finishNarration(p); c != "" {
out = append(out, c) out = append(out, c)
} }
} }
return out return out
} }
// splitByBreakTags 只按 `<br>` 类分隔符切分(分隔符本身丢弃),不做任何清洗。
// 没有分隔符时返回单元素切片,即「整段一个气泡」的既有语义。
func splitByBreakTags(segment string) []string {
spans := breakSeparatorSpans(segment)
if len(spans) == 0 {
return []string{segment}
}
parts := make([]string, 0, len(spans)+1)
last := 0
for _, sp := range spans {
parts = append(parts, segment[last:sp[0]])
last = sp[1]
}
return append(parts, segment[last:])
}
// bubbleGap 返回 IM 出站气泡之间使用的 0.5s..2s 随机延时, // bubbleGap 返回 IM 出站气泡之间使用的 0.5s..2s 随机延时,
// 使连发段落不会刷屏(design/17 §2)。Web 前端 // 使连发段落不会刷屏(design/17 §2)。Web 前端
// 不受影响——它从已持久化的流渲染所有气泡,没有任何延时。 // 不受影响——它从已持久化的流渲染所有气泡,没有任何延时。
+8
View File
@@ -23,11 +23,19 @@ type PluginSummary struct {
// NeedsRescan/LastError(v0.39)报告适配器需要主人重新授权 // NeedsRescan/LastError(v0.39)报告适配器需要主人重新授权
// (见 admin_channels_rescan.go 中的 rescanAware);二者仅在适配器 // (见 admin_channels_rescan.go 中的 rescanAware);二者仅在适配器
// 确实要求时才出现,否则一律省略。 // 确实要求时才出现,否则一律省略。
//
// Pending/Ready/Reason(v0.68)报告 `connected=false` 时**到底是哪种没连上**
// (见 admin_channel_connect_outcome.go 中的 connectOutcomeAware):pending =
// 已在后台重试、当前未连上(面板读作「连接中」),ready = 已就绪、等待对端接入。
// 三者同样只在适配器确实自报时才出现 —— 没有这条状态面的平台逐字节不受影响。
type ChannelSummary struct { type ChannelSummary struct {
Name string `json:"name"` Name string `json:"name"`
Connected bool `json:"connected"` Connected bool `json:"connected"`
NeedsRescan bool `json:"needsRescan,omitempty"` NeedsRescan bool `json:"needsRescan,omitempty"`
LastError string `json:"lastError,omitempty"` LastError string `json:"lastError,omitempty"`
Pending bool `json:"pending,omitempty"`
Ready bool `json:"ready,omitempty"`
Reason string `json:"reason,omitempty"`
Accounts []AdminChannelAccount `json:"accounts,omitempty"` Accounts []AdminChannelAccount `json:"accounts,omitempty"`
} }
@@ -0,0 +1,98 @@
package gateway
// 本文件承载管理面读取**适配器连接态三态**的那一层:把适配器的 ConnectOutcome
// 映射成面板与线上契约能用的形状,使「点了连接之后到底怎么样了」不再只有一个布尔。
//
// 为什么需要它:`Connect` 返回 nil 只说明「这次调用没有致命失败」。ws+client 的首连
// 失败刻意非致命(OneBot 实现可能比 Cyrene 后启动),此时 IsConnected() 为 false ——
// 它与「监听已就绪等对端接入」(server 模式,同样 false)在布尔上无从区分,更与
// 「配置错、起不来」分不开。网关**绝不**按适配器名或 transport 去猜:口径属于适配器,
// 它才知道自己的 transport × mode 意味着什么;网关只做映射。
//
// 与 accountStatusAware / rescanAware / sessionStatusAware 同一契约风格(见
// admin_channel_status.go 与 admin_channels_rescan.go):核心 SDK Channel 契约被刻意
// 不扩展,因此没有这条状态面的平台不受任何改动,也不会凭空多出一个字段。
import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// connectOutcomeAware 是渠道适配器暴露「Connect 之后处于哪种状态」这一可选能力的接口。
// 当前由 *qq_obv11.Adapter 实现(ConnectOutcome):它的 ws+client 首连失败非致命、
// ws+server 只需监听就绪,两种「没连上」都不是同一件事。
//
// 返回的 state 是**闭集**取值(internal/channel/adapter 的 ConnectState* 常量):
//
// "connected" —— 真连上;
// "pending" —— 已进入后台重试、当前未连上,reason 说明原因;
// "ready" —— 已绑定监听、就绪等对端接入,不是失败;
// "" —— 无意见(尚未启动),调用方退回只看 IsConnected()。
//
// 闭集而不是布尔,是因为「没连上」至少还有两种互不相同的现状,而它们对主人的意义
// 完全不同:一种是「还在连」,一种是「就等对面来连」。
type connectOutcomeAware interface {
// ConnectOutcome 报告连接态;state 取值见上,reason 在未连上时说明原因
// (如实的中文/技术短句,**绝不含凭据**)。
ConnectOutcome() (state string, reason string)
}
// asConnectOutcomeAware 报告已注册的渠道值是否暴露连接态三态。与 asRescanAware /
// asAccountStatusAware 同一套路:注册时会把适配器包进 cmd/cyrene 的 namedAdapter ——
// 一个内嵌 adapter.PlatformAdapter 的未导出结构体 —— 它只提升接口的方法,因此具体
// 适配器多出的能力只能通过拆开被内嵌字段才能抵达。直接的适配器值(测试)按原样接受。
func asConnectOutcomeAware(v any) (connectOutcomeAware, bool) {
if co, ok := v.(connectOutcomeAware); ok {
return co, true
}
inner, ok := unwrapNamedAdapter(v)
if !ok {
return nil, false
}
co, ok := inner.(connectOutcomeAware)
return co, ok
}
// channelConnectOutcome 是连接态三态在**线上**的投影:三态里只有两种「没连上」需要
// 额外说明(真连上已经由 connected 布尔说完),reason 只在 pending 时非空。
//
// 刻意用可选键而不是把三态塞进 connected:connected 的语义已被老客户端当作「连上了
// 没有」在读,改写它等于改契约;新增可选键则谁都不吃亏 —— 零值的三态投影让
// pending / ready / reason **整键缺席**,与 NeedsRescan / LastError 同一口径。
type channelConnectOutcome struct {
// Pending 表示「已在后台重试、当前未连上」——面板须把它读作「连接中」,
// 绝不是「未连接」,也绝不是「已连接」。
Pending bool
// Ready 表示「已就绪、等待对端接入」——同样不是失败。
Ready bool
// Reason 是未连上时的原因(已脱敏),仅在 Pending 时可能非空。
Reason string
}
// channelConnectStateOf 报告某渠道适配器当前的连接态:connected 布尔 + 需要额外说明的
// 三态投影。
//
// 适配器实现了 connectOutcomeAware ⇒ **以它为准**:闭集三态优先于 IsConnected() 的
// 单个布尔(pending 与 ready 的 IsConnected() 都是 false,但两者都不是「未连接」这样
// 一个静态结论)。没实现,或返回空串「无意见」⇒ 逐字退回今天的行为(只看
// IsConnected(),三态投影为零值、可选键缺席)。
func channelConnectStateOf(ch sdk.Channel) (bool, channelConnectOutcome) {
if ch == nil {
return false, channelConnectOutcome{}
}
aware, ok := asConnectOutcomeAware(ch)
if !ok {
return ch.IsConnected(), channelConnectOutcome{}
}
state, reason := aware.ConnectOutcome()
switch state {
case adapter.ConnectStateConnected:
return true, channelConnectOutcome{}
case adapter.ConnectStatePending:
return false, channelConnectOutcome{Pending: true, Reason: reason}
case adapter.ConnectStateReady:
return false, channelConnectOutcome{Ready: true}
default:
return ch.IsConnected(), channelConnectOutcome{}
}
}
@@ -0,0 +1,251 @@
package gateway
// 本文件锁定**连接态三态**在管理面上的如实转述(2026-10-07 的 UI 撒谎事故):
//
// - `POST /admin/channels/open` 之后 **不许**无条件报 connected=true;
// 适配器自报「重试中」时必须是 connected=false + pending + reason;
// - 适配器自报「就绪等接入」(server 模式)时是 connected=false + ready,
// 既不是「已连接」,也不是一句静态的「未连接」;
// - 没实现这条状态面的平台(绝大多数)响应逐字节不变 —— 可选键整键缺席。
//
// 断言打在**线上响应**上(不是内部函数),因为「面板读到什么」才是这件事的全部。
import (
"context"
"encoding/json"
"net/http"
"sync/atomic"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// outcomeChannel 是一个会自报连接态三态的假适配器:Connect 不致命(照常返回
// nil),真实状态与原因完全由测试给定,因此「网关有没有照实转述」是可断言的事实。
type outcomeChannel struct {
name string
state string
reason string
connected atomic.Bool
connects atomic.Int32
}
func (c *outcomeChannel) Name() string { return c.name }
func (c *outcomeChannel) IsConnected() bool { return c.connected.Load() }
func (c *outcomeChannel) HealthCheck(context.Context) error { return nil }
func (c *outcomeChannel) ConnectOutcome() (string, string) { return c.state, c.reason }
func (c *outcomeChannel) Connect(context.Context) error {
c.connects.Add(1)
// 「真连上」才置位;pending / ready 都必须保持未连上 —— 这正是三态要区分的。
c.connected.Store(c.state == "connected")
return nil
}
func (c *outcomeChannel) Disconnect(context.Context) error {
c.connected.Store(false)
return nil
}
// openChannelData 发起一次 open 并解出 data 对象(原始 map,好断言「键在不在」)。
func openChannelData(t *testing.T, ch *outcomeChannel) map[string]any {
t.Helper()
caps := capreg.New()
if err := caps.RegisterChannel(ch); err != nil {
t.Fatalf("register: %v", err)
}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: caps})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/channels/open",
map[string]any{"name": ch.name}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("open status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var out map[string]any
if err := json.Unmarshal(decodeBody(t, body)["data"], &out); err != nil {
t.Fatalf("decode open data: %v (body=%s)", err, body)
}
return out
}
// TestChannelOpenReportsConnectedTruthfully 场景①:适配器自报真连上 ⇒ connected=true,
// 且三个可选键一律缺席(没有坏消息要额外说,老客户端形状不变)。
func TestChannelOpenReportsConnectedTruthfully(t *testing.T) {
t.Parallel()
out := openChannelData(t, &outcomeChannel{name: "qq-obv11", state: "connected"})
if out["connected"] != true {
t.Fatalf("connected = %v, want true", out["connected"])
}
for _, key := range []string{"pending", "ready", "reason"} {
if _, present := out[key]; present {
t.Fatalf("%s 不该出现在「真连上」的响应里: %v", key, out)
}
}
}
// TestChannelOpenReportsPendingWithReason 场景②(本次事故的正题):适配器自报
// 「已在后台重试、当前未连上」时,响应**绝不能**是 connected=true —— 那正是面板弹出
// 「已连接」而卡片一直显示「未连接」的根源。必须如实给出 connected=false + pending
// + reason,且 resolved/name 等既有键原样保留。
func TestChannelOpenReportsPendingWithReason(t *testing.T) {
t.Parallel()
const reason = "qq-obv11: dial onebot ws ws://localhost:10301/: dial tcp: connection refused"
out := openChannelData(t, &outcomeChannel{name: "qq-obv11", state: "pending", reason: reason})
if out["connected"] != false {
t.Fatalf("connected = %v, want false(重试中不许写成已连接)", out["connected"])
}
if out["pending"] != true {
t.Fatalf("pending = %v, want true(面板据此显示「正在连接」而不是「未连接」)", out["pending"])
}
if out["reason"] != reason {
t.Fatalf("reason = %v, want %q(必须把「为什么没连上」如实交出去)", out["reason"], reason)
}
if _, present := out["ready"]; present {
t.Fatalf("ready 不该与 pending 同时出现: %v", out)
}
if out["name"] != "qq-obv11" || out["resolved"] != "qq-obv11" {
t.Fatalf("既有键被破坏: %v", out)
}
}
// TestChannelOpenReportsReady 场景③:ws+server 已绑定监听、还没有对端接入。
// IsConnected() 同样是 false,但它**不是失败**,因此报 ready 而不是 pending,
// 也不带 reason(没有坏消息可说)。
func TestChannelOpenReportsReady(t *testing.T) {
t.Parallel()
out := openChannelData(t, &outcomeChannel{name: "qq-obv11", state: "ready"})
if out["connected"] != false {
t.Fatalf("connected = %v, want false(监听就绪 ≠ 已连上)", out["connected"])
}
if out["ready"] != true {
t.Fatalf("ready = %v, want true(就绪等接入不是失败)", out["ready"])
}
for _, key := range []string{"pending", "reason"} {
if _, present := out[key]; present {
t.Fatalf("%s 不该出现在「就绪等接入」的响应里: %v", key, out)
}
}
}
// TestChannelOpenWithoutOutcomeKeepsLegacyShape 守住「不动别人」:没实现连接态三态
// 的适配器(绝大多数平台)响应与今天逐字节一致 —— 三个可选键整键缺席,只有既有四键。
func TestChannelOpenWithoutOutcomeKeepsLegacyShape(t *testing.T) {
t.Parallel()
caps := capreg.New()
only := &spyChannel{name: "kook:3fc8"}
if err := caps.RegisterChannel(only); err != nil {
t.Fatalf("register: %v", err)
}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: caps})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/channels/open",
map[string]any{"name": "kook"}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var out map[string]any
if err := json.Unmarshal(decodeBody(t, body)["data"], &out); err != nil {
t.Fatalf("decode: %v (body=%s)", err, body)
}
if len(out) != 3 || out["connected"] != true {
t.Fatalf("data = %v, want exactly {name,resolved,connected:true}", out)
}
for _, key := range []string{"pending", "ready", "reason"} {
if _, present := out[key]; present {
t.Fatalf("%s 不该出现(适配器没有这条状态面): %v", key, out)
}
}
}
// TestChannelCloseIsNotOptimistic 守住断开路径同样不乐观:报**真实的** IsConnected()。
// 一个「Disconnect 返回 nil 但其实没断开」的适配器必须被照实报成 connected=true,
// 而不是假定调用成功就等于断开了。
func TestChannelCloseIsNotOptimistic(t *testing.T) {
t.Parallel()
stuck := &outcomeChannel{name: "qq-obv11", state: "pending", reason: "still retrying"}
stuck.connected.Store(true) // 假装会话还活着,Disconnect 也没能拆掉它
stuckDisconnect := &stubbornChannel{outcomeChannel: stuck}
caps := capreg.New()
if err := caps.RegisterChannel(stuckDisconnect); err != nil {
t.Fatalf("register: %v", err)
}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: caps})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/channels/close",
map[string]any{"name": "qq-obv11"}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("close status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var out map[string]any
if err := json.Unmarshal(decodeBody(t, body)["data"], &out); err != nil {
t.Fatalf("decode: %v (body=%s)", err, body)
}
if out["connected"] != true {
t.Fatalf("connected = %v, want true(实测仍未断开就必须照实报)", out["connected"])
}
if _, present := out["pending"]; present {
t.Fatalf("关闭路径不该投影三态: %v", out)
}
}
// stubbornChannel 是一个 Disconnect 不真正断开连接的假适配器:用来证明关闭路径读的
// 是真实 IsConnected(),而不是「调用成功 ⇒ 一定断开了」。
type stubbornChannel struct {
*outcomeChannel
}
func (c *stubbornChannel) Disconnect(context.Context) error { return nil }
// TestChannelListExposesConnectOutcome 守住列表侧:面板卡片的状态来自
// GET /admin/channels,因此三态必须在那里也如实出现(平台行与账号行都要),
// 否则点完连接刷新一次列表,「连接中」又会变回一句静态的「未连接」。
func TestChannelListExposesConnectOutcome(t *testing.T) {
t.Parallel()
const reason = "qq-obv11: dial onebot ws ws://localhost:10301/: dial tcp: connection refused"
ch := &outcomeChannel{name: "qq-obv11:9616b27a", state: "pending", reason: reason}
store := channelsStoreOf(t, "qq-obv11",
config.ChannelAccount{ID: "9616b27a", Config: map[string]any{"mode": "client"}})
caps := capreg.New()
if err := caps.RegisterChannel(ch); err != nil {
t.Fatalf("register: %v", err)
}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: caps, Channels: store})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/channels", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("list status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var rows []struct {
Name string `json:"name"`
Connected bool `json:"connected"`
Pending bool `json:"pending"`
Reason string `json:"reason"`
Accounts []struct {
ID string `json:"id"`
Connected *bool `json:"connected"`
Pending bool `json:"pending"`
Reason string `json:"reason"`
} `json:"accounts"`
}
if err := json.Unmarshal(decodeBody(t, body)["data"], &rows); err != nil {
t.Fatalf("decode list: %v (body=%s)", err, body)
}
if len(rows) != 1 {
t.Fatalf("rows = %d, want 1 (body=%s)", len(rows), body)
}
row := rows[0]
if row.Connected || !row.Pending || row.Reason != reason {
t.Fatalf("平台行 = %+v, want connected=false pending=true reason=%q", row, reason)
}
if len(row.Accounts) != 1 {
t.Fatalf("accounts = %d, want 1", len(row.Accounts))
}
acct := row.Accounts[0]
if acct.ID != "9616b27a" || acct.Connected == nil || *acct.Connected || !acct.Pending || acct.Reason != reason {
t.Fatalf("账号行 = %+v, want id=9616b27a connected=false pending=true reason=%q", acct, reason)
}
}
@@ -48,6 +48,58 @@ func enrichAccountsWithAccountOnline(accts []AdminChannelAccount, by map[string]
return enrichAccountBool(accts, by, func(a *AdminChannelAccount, v bool) { a.AccountOnline = &v }) return enrichAccountBool(accts, by, func(a *AdminChannelAccount, v bool) { a.AccountOnline = &v })
} }
// liveAccountConnectOutcomes 收集每个平台各账号的**连接态三态**(适配器自报的
// pending / ready / reason),键与 liveAccountConnections 相同(裸适配器名 ⇒ 空账号 id)。
//
// 只有确实自报了三态的账号才进 map:三态投影为零值(适配器没这条状态面,或它此刻自认
// 「真连上 / 无意见」)就缺席 —— 缺席 = 面板退回只看 connected 布尔,绝不凭空编一个
// 状态。补一个 false 顶上去,与本次要根除的「编一个状态」同属一类。
func (s *Server) liveAccountConnectOutcomes() map[string]map[string]channelConnectOutcome {
out := make(map[string]map[string]channelConnectOutcome)
if s.caps == nil {
return out
}
for _, cap := range s.caps.List(capreg.KindChannel) {
ch, ok := cap.Value.(sdk.Channel)
if !ok {
continue
}
_, outcome := channelConnectStateOf(ch)
if outcome == (channelConnectOutcome{}) {
continue
}
platform, acct := splitAdapterName(ch.Name())
if out[platform] == nil {
out[platform] = make(map[string]channelConnectOutcome)
}
out[platform][acct] = outcome
}
return out
}
// enrichAccountsWithConnectOutcome 把**连接态三态**合并进平台中匹配的账号(按账号 id
// 配对)。不在 by 里的账号保持 Pending/Ready/Reason 全空 ⇒ 整键缺席 ⇒ 面板退回只看
// connected。入参切片不被修改。
func enrichAccountsWithConnectOutcome(
accts []AdminChannelAccount,
by map[string]channelConnectOutcome,
) []AdminChannelAccount {
if len(by) == 0 {
return accts
}
out := make([]AdminChannelAccount, 0, len(accts))
for _, a := range accts {
item := a
if o, ok := by[item.ID]; ok {
item.Pending = o.Pending
item.Ready = o.Ready
item.Reason = o.Reason
}
out = append(out, item)
}
return out
}
// enrichAccountBool 是上面两个「可选布尔状态」装配器的共同实现:只对 by 里出现过的 // enrichAccountBool 是上面两个「可选布尔状态」装配器的共同实现:只对 by 里出现过的
// 账号调用 set(缺席即字段保持 nil = 未知),因此调用方不必各自重写这段三态逻辑 —— // 账号调用 set(缺席即字段保持 nil = 未知),因此调用方不必各自重写这段三态逻辑 ——
// 三态口径只写一遍,谁也别想在其中一处悄悄把「未知」写成 false。 // 三态口径只写一遍,谁也别想在其中一处悄悄把「未知」写成 false。
@@ -135,11 +135,38 @@ func (s *Server) firstConfiguredChannel(name string) (sdk.Channel, bool) {
// channelActionResult 是 open/close 的响应体:name 保持请求里的名字(老客户端 // channelActionResult 是 open/close 的响应体:name 保持请求里的名字(老客户端
// 契约不变),resolved 是**实际被驱动**的适配器注册名(多账号平台上它与请求的 // 契约不变),resolved 是**实际被驱动**的适配器注册名(多账号平台上它与请求的
// 平台名不同,面板据此显示「连的是哪一条」),accountId 在指定时回显。凭据永不出现。 // 平台名不同,面板据此显示「连的是哪一条」),accountId 在指定时回显。凭据永不出现。
func channelActionResult(target channelTarget, resolved string, connected bool) map[string]any { //
// 除既有四个键外,另按适配器自报的**连接态三态**(见 admin_channel_connect_outcome.go)
// 补三个**可选**键,且取不到就整键缺席(同 NeedsRescan / LastError 口径):
//
// pending: true —— 已在后台重试、当前未连上 ⇒ 此时 connected=false。面板须读作
// 「连接中(reason)」,**不是**「未连接」,更不是「已连接」;
// ready: true —— 已就绪、等待对端接入(server 模式)⇒ 同样 connected=false,
// 但它不是失败;
// reason: "<已脱敏>" —— 未连上时的原因,仅在 pending 时可能出现。
//
// 真连上时这三个键一律缺席:connected=true 已经把话说完了,再补一个「没有坏消息」的
// 空串只会让老客户端多出一个要处理的键。适配器没实现这条状态面时同理 —— 响应与今天
// 逐字节一致。
func channelActionResult(
target channelTarget,
resolved string,
connected bool,
outcome channelConnectOutcome,
) map[string]any {
out := map[string]any{"name": target.Name, "resolved": resolved, "connected": connected} out := map[string]any{"name": target.Name, "resolved": resolved, "connected": connected}
if target.AccountID != "" { if target.AccountID != "" {
out["accountId"] = target.AccountID out["accountId"] = target.AccountID
} }
if outcome.Pending {
out["pending"] = true
}
if outcome.Ready {
out["ready"] = true
}
if outcome.Reason != "" {
out["reason"] = outcome.Reason
}
return out return out
} }
+18 -4
View File
@@ -63,6 +63,7 @@ func (s *Server) adminListChannels(w http.ResponseWriter, r *http.Request) {
botByPlatform := s.liveBotIdentity() botByPlatform := s.liveBotIdentity()
connectedByPlatform := s.liveAccountConnections() connectedByPlatform := s.liveAccountConnections()
onlineByPlatform := s.liveAccountOnline() onlineByPlatform := s.liveAccountOnline()
outcomeByPlatform := s.liveAccountConnectOutcomes()
out := make([]ChannelSummary, 0) out := make([]ChannelSummary, 0)
if s.caps != nil { if s.caps != nil {
for _, cap := range s.caps.List(capreg.KindChannel) { for _, cap := range s.caps.List(capreg.KindChannel) {
@@ -87,7 +88,17 @@ func (s *Server) adminListChannels(w http.ResponseWriter, r *http.Request) {
if s.hasChannelSummary(out, platform) { if s.hasChannelSummary(out, platform) {
continue continue
} }
sum := ChannelSummary{Name: platform, Connected: ch.IsConnected()} // 连接态三态(v0.68):`connected=false` 可能只是「正在后台重试」或
// 「监听已就绪、等对端接入」,两者都不是「未连接」这样一个静态结论。
// 只有适配器确实自报了才写这三个键,否则一律省略(同 NeedsRescan 口径)。
connected, outcome := channelConnectStateOf(ch)
sum := ChannelSummary{
Name: platform,
Connected: connected,
Pending: outcome.Pending,
Ready: outcome.Ready,
Reason: outcome.Reason,
}
// 可选的 rescan 能力(weixin-iLink 会话被拒,v0.39):仅在适配器 // 可选的 rescan 能力(weixin-iLink 会话被拒,v0.39):仅在适配器
// 确实要求重新扫码时才报告,因此健康的适配器保持字段省略, // 确实要求重新扫码时才报告,因此健康的适配器保持字段省略,
// 而不是送出一个编造的值。 // 而不是送出一个编造的值。
@@ -98,9 +109,12 @@ func (s *Server) adminListChannels(w http.ResponseWriter, r *http.Request) {
} }
} }
if accts, ok := accountsByPlatform[platform]; ok && len(accts) > 0 { if accts, ok := accountsByPlatform[platform]; ok && len(accts) > 0 {
sum.Accounts = enrichAccountsWithAccountOnline( sum.Accounts = enrichAccountsWithConnectOutcome(
enrichAccountsWithConnection(enrichAccountsWithBot(accts, botByPlatform[platform]), connectedByPlatform[platform]), enrichAccountsWithAccountOnline(
onlineByPlatform[platform], enrichAccountsWithConnection(enrichAccountsWithBot(accts, botByPlatform[platform]), connectedByPlatform[platform]),
onlineByPlatform[platform],
),
outcomeByPlatform[platform],
) )
} }
out = append(out, sum) out = append(out, sum)
@@ -70,6 +70,14 @@ type AdminChannelAccount struct {
// 或尚未探到结果),面板不得画成「离线」,也不得画成「在线」。 // 或尚未探到结果),面板不得画成「离线」,也不得画成「在线」。
// 见 design/24 §10.11 与 Docs/api-client.md §2.4(b)。 // 见 design/24 §10.11 与 Docs/api-client.md §2.4(b)。
AccountOnline *bool `json:"accountOnline,omitempty"` AccountOnline *bool `json:"accountOnline,omitempty"`
// Pending/Ready/Reason 是该账号适配器自报的**连接态三态**(可选能力,见
// internal/services/gateway/admin_channel_connect_outcome.go):Connected=false 时
// 面板据此把「已在后台重试(连接中)」与「已就绪、等待对端接入」从「未连接」里
// 分出来。三者缺省 = 该适配器没有这条状态面 ⇒ 面板退回只看 Connected,
// 绝不凭空编一个状态。
Pending bool `json:"pending,omitempty"`
Ready bool `json:"ready,omitempty"`
Reason string `json:"reason,omitempty"`
// Capabilities 列出该平台适配器暴露的账号级管理操作 // Capabilities 列出该平台适配器暴露的账号级管理操作
// (friends/channels/user-chats/message-test),使面板 // (friends/channels/user-chats/message-test),使面板
// 渲染正确的每账号卡片。没有专用适配器表面的平台 // 渲染正确的每账号卡片。没有专用适配器表面的平台
@@ -303,7 +303,12 @@ func (s *Server) adminChannelOpen(w http.ResponseWriter, r *http.Request) {
return return
} }
s.auditClaimsAccount(r, "channel.open", "ok", target.AccountID) s.auditClaimsAccount(r, "channel.open", "ok", target.AccountID)
writeJSON(w, http.StatusOK, channelActionResult(target, ch.Name(), true)) // `Connect` 返回 nil 只说明「这次调用没有致命失败」——ws+client 的首连失败刻意
// 非致命(OneBot 实现可能比 Cyrene 后启动),ws+server 则只需监听就绪。所以这里
// **绝不**硬编码 connected=true:如实取适配器自报的连接态三态,并把「重试中(含
// 原因)」与「就绪等接入」作为可选键一并交出(见 admin_channel_connect_outcome.go)。
connected, outcome := channelConnectStateOf(ch)
writeJSON(w, http.StatusOK, channelActionResult(target, ch.Name(), connected, outcome))
} }
// adminChannelClose 断开一个已注册渠道适配器(真实的生命周期调用)。accountId // adminChannelClose 断开一个已注册渠道适配器(真实的生命周期调用)。accountId
@@ -324,7 +329,11 @@ func (s *Server) adminChannelClose(w http.ResponseWriter, r *http.Request) {
return return
} }
s.auditClaimsAccount(r, "channel.close", "ok", target.AccountID) s.auditClaimsAccount(r, "channel.close", "ok", target.AccountID)
writeJSON(w, http.StatusOK, channelActionResult(target, ch.Name(), false)) // 断开不乐观:报**真实的** IsConnected(),而不是假定「调用成功 ⇒ 一定断开了」。
// 关闭路径刻意不投影三态:pending / ready 说的是「连的过程」,而这里要回答的是
// 「会话到底还在不在」——那正是 IsConnected() 的语义。
writeJSON(w, http.StatusOK,
channelActionResult(target, ch.Name(), ch.IsConnected(), channelConnectOutcome{}))
} }
// channelTestRequest 是 POST /api/v1/admin/channels/test 的请求体:一个渠道 // channelTestRequest 是 POST /api/v1/admin/channels/test 的请求体:一个渠道
+9 -3
View File
@@ -106,9 +106,13 @@ type Adapter struct {
httpAPI *obHTTPClient httpAPI *obHTTPClient
started bool started bool
connected bool connected bool
cancel context.CancelFunc // lastConnectErr 是最近一次连接失败的**脱敏**原因,供 ConnectOutcome 如实回答
lifeCtx context.Context // 「为什么没连上」(见 connect_outcome.go)。连上或断开即清空:一条已经过期的
newConn chan struct{} // 原因不该继续挂在当前状态旁边。由 mu 保护。
lastConnectErr string
cancel context.CancelFunc
lifeCtx context.Context
newConn chan struct{}
handlerMu sync.RWMutex handlerMu sync.RWMutex
handler adapter.MessageHandler handler adapter.MessageHandler
@@ -297,6 +301,8 @@ func (a *Adapter) Disconnect(ctx context.Context) error {
a.ln = nil a.ln = nil
a.srv = nil a.srv = nil
a.httpAPI = nil a.httpAPI = nil
// 断开即清空上一次失败原因:会话已被拆掉,「为什么没连上」不再是当前事实。
a.lastConnectErr = ""
// 断开即丢弃账号在线状态:新会话的 known=false 由下一次探活重新确立,绝不沿用 // 断开即丢弃账号在线状态:新会话的 known=false 由下一次探活重新确立,绝不沿用
// 上一轮的陈旧结论(AccountStatus 的诚实口径)。 // 上一轮的陈旧结论(AccountStatus 的诚实口径)。
a.account = accountStatus{} a.account = accountStatus{}
@@ -0,0 +1,121 @@
package qq_obv11
// 本文件承载 qq-obv11 的**连接态三态**:一次 Connect 返回之后,该适配器到底处于
// 「真连上 / 正在重试 / 就绪等接入」中的哪一种,以及没连上时的**脱敏原因**。
//
// 为什么需要它:`Connect` 返回 nil 只说明「这次调用没有致命失败」——ws+client 的首连
// 失败是**故意非致命**的(见 ws_client.go 与 reconnect.go:NapCat 等 OneBot 实现可能
// 比 Cyrene 后启动)。于是管理面板拿到的 `connected=false` 既可能是「正在后台重试」,
// 也可能是「监听已就绪等对端接入」,而它与「配置错、起不来」在布尔上无从区分;面板只能
// 编一个说法,编出来的正是 2026-10-07 那个「点了连接就弹已连接」的谎。三态把这三件事
// 分开,并且**由适配器自己回答**——只有它知道自己的 transport × mode 意味着什么。
//
// 设计依据:DevDocs/design/08-渠道接入.md §1(PlatformAdapter 契约)与 §4.1(四端
// transport × mode);三态取值与判定顺序定义在 internal/channel/adapter 的
// ConnectStateOf(跨平台共用一份口径,别的适配器接上时不会各自走样)。立场与
// design/26 §2.6「状态面必须区分 WS 连着与账号在线」一致:一个布尔说不了三件事。
import (
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// maskedCredential 是脱敏后的占位符(与全链路可视化的脱敏标记同形,便于一眼认出
// 「这里原本是凭据」而不是「这里本来就长这样」)。
const maskedCredential = "[redacted]"
// ConnectOutcome 报告一次 Connect 之后该适配器处于哪种就绪状态。
//
// state 是闭集取值(adapter.ConnectStateConnected / ConnectStatePending /
// ConnectStateReady),空串表示「无意见」(尚未启动,无从判断);reason 是未连上时的
// **脱敏**原因,绝不含 access_token。
//
// 它是可选的适配器能力:网关经类型断言读取(internal/services/gateway 的
// connectOutcomeAware),核心 PlatformAdapter 契约刻意不扩展。
func (a *Adapter) ConnectOutcome() (state, reason string) {
a.mu.RLock()
defer a.mu.RUnlock()
return adapter.ConnectStateOf(adapter.ConnectSignals{
Started: a.started,
Connected: a.connected,
// 只有 ws+client 有一条后台重试循环(readLoop 复用 connectClient),因此
// 「未连上 ⇒ 连接中」这条推论只在它上面成立;HTTP 没有会话可重连。
Retrying: a.cfg.Mode == modeClient && a.cfg.Transport != transportHTTP,
// 监听已绑定 ⇒ 就绪等对端接入(ws+server;http+server 也有监听器,但它由
// Connect 直接置 connected=true,走不到这里)。
Listening: a.ln != nil,
Reason: a.lastConnectErr,
})
}
// connectFailureReason 把一次连接失败整理成适配器可以如实回答的脱敏原因。
func (a *Adapter) connectFailureReason(err error) string {
if err == nil {
return ""
}
return sanitizeConnectReason(err.Error(), a.cfg.AccessToken)
}
// sanitizeConnectReason 把一次连接失败的原因整理成可以对外说的短句。三层防线缺一不可:
//
// 1. trace.RedactText 做形状级兜底与截断(与全链路可视化同一道红线):一个不设上限的
// 错误串不该被原样端给面板,而凭据形状(api_key/bearer/JWT/≥6 字符的 token 赋值)
// 在这一层就被抹掉 —— 包括 token 已被换掉、我们并不知道明文的那种情况。
// 2. 配置里的 access_token 明文整体替换 —— ws_client.go 的 appendQueryToken 会把 token
// 拼进拨号 URL,任何把该 URL 写进错误串的实现都会把它带出来;而**短 token** 逃得过
// 第 1 层的形状匹配,只有「已知明文」这一层能兜住。
// 3. 剥掉 URL 里 access_token 查询参数的值(含内嵌在引号/反括号里的那些)。
//
// 顺序是刻意的:先形状级、再逐字剥。反过来(先剥参数再交给形状级)会让第 1 层的贪婪
// 匹配把我们已经写好的 `access_token=` 参数名一起吃掉,留下更难读懂的一句话。
//
// 空原因返回空串 —— 「没有原因」与「原因被脱敏成空」是两件事,绝不互相冒充。
func sanitizeConnectReason(raw, token string) string {
if raw == "" {
return ""
}
raw = trace.RedactText(raw)
if token != "" {
raw = strings.ReplaceAll(raw, token, maskedCredential)
}
return maskAccessTokenQuery(raw)
}
// maskAccessTokenQuery 把 access_token 查询参数的值替换成占位符。大小写不敏感,循环
// 处理多次出现(同一错误串里可能带多条 URL)。
//
// 每轮把已处理的前缀搬进 builder、只对**剩余部分**再查:参数名会被原样保留,因此在同一
// 段文本上原地重查会永远命中同一个位置(占位符不改变它的存在),那是死循环而不是脱敏。
func maskAccessTokenQuery(raw string) string {
const key = "access_token="
var b strings.Builder
rest := raw
for {
i := strings.Index(strings.ToLower(rest), key)
if i < 0 {
b.WriteString(rest)
return b.String()
}
start := i + len(key)
end := start
for end < len(rest) && !isQueryValueDelimiter(rest[end]) {
end++
}
b.WriteString(rest[:start])
b.WriteString(maskedCredential)
rest = rest[end:]
}
}
// isQueryValueDelimiter 报告该字节是否终止一个查询参数值:`&`/`#` 是 URL 分隔符,
// 空白与引号、反括号则是内嵌在错误串里的 URL 的常见收尾。
func isQueryValueDelimiter(c byte) bool {
switch c {
case '&', '#', ' ', '\t', '\n', '\r', '"', '\'', ')', '}', ']':
return true
default:
return false
}
}
@@ -0,0 +1,273 @@
package qq_obv11
// 本文件测连接态三态的**适配器落点**:ConnectOutcome 如实回答「Connect 之后处于哪种
// 状态」,以及没连上时那条原因确实被脱敏(access_token 绝不出现)。
//
// 为什么必须有它:2026-10-07 管理面板「点连接就弹已连接」的根因,就是适配器只对外给
// 一个 IsConnected() 布尔 —— ws+client 的首连失败刻意非致命(NapCat 等可能比 Cyrene
// 后启动),于是「正在重试」「监听就绪」「起不来」三件事在布尔上长得一模一样。
// 纯判定部分(adapter.ConnectStateOf)见 internal/channel/adapter 的测试。
import (
"context"
"errors"
"fmt"
"log/slog"
"math"
"net"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
// tokenLeakDialer 是一个**故意把 access_token 拼进错误串**的拨号替身:真实世界里任何
// 把拨号 URL 写进错误串的实现都会这样(ws_client.go 的 appendQueryToken 会把 token 拼
// 进 URL)。它使「脱敏真的接在了存储与日志前面」成为可断言的事实,而不是一句注释。
type tokenLeakDialer struct{ rawURL string }
func (d tokenLeakDialer) dial(context.Context, string, string) (net.Conn, error) {
return nil, fmt.Errorf("test: dial %s failed", d.rawURL)
}
// TestSanitizeConnectReasonStripsAccessToken 断言原因串里的 access_token 绝不出现,
// 且留下可读的占位符(面板要能看出「这里原本是凭据」,而不是一段乱码)。
func TestSanitizeConnectReasonStripsAccessToken(t *testing.T) {
t.Parallel()
const token = "s3cr3t-token-value"
raw := "websocket: dial ws://hook.example/onebot?access_token=" + token + "&self_id=1: connection refused"
got := sanitizeConnectReason(raw, token)
if strings.Contains(got, token) {
t.Fatalf("脱敏后仍含 access_token 明文: %q", got)
}
if !strings.Contains(got, maskedCredential) {
t.Errorf("脱敏后没有留下占位符: %q", got)
}
}
// TestSanitizeConnectReasonStripsShortToken 断言**短、且紧邻其它参数**的 token 也剥得掉:
// 形状级匹配要求赋值后至少 6 个非空白字符,`access_token=x&y` 这种逃得过它,只有
// 「已知明文 + 剥查询参数」这两层能兜住 —— 这正是它们存在的理由。
//
// (参数名有时会被形状级兜底连同值一起吞掉:少一点诊断信息总好过泄露凭据,
// 所以这里只断言**凭据绝不出现**,不绑定具体措辞。)
func TestSanitizeConnectReasonStripsShortToken(t *testing.T) {
t.Parallel()
const token = "ab12"
for _, raw := range []string{
"dial ws://h/p?access_token=ab12&self_id=1 failed",
"websocket: dial ws://h/p?access_token=ab12: connection refused",
} {
got := sanitizeConnectReason(raw, token)
if strings.Contains(got, token) {
t.Errorf("短 token 泄露: %q → %q", raw, got)
}
if !strings.Contains(got, maskedCredential) {
t.Errorf("脱敏后没有留下占位符: %q → %q", raw, got)
}
}
}
// TestSanitizeConnectReasonStripsUnknownShortQueryToken 钉住第 3 层(剥 `access_token=`
// 查询参数)**独有**的那一小段窗口:值**短于 6 个字符、且紧邻空白/引号等终止符**时,
// 形状级那条规则(要求赋值后至少 6 个非空白字符)匹配不上,而「已知明文替换」又因为
// 这把值**不是**我们配置的 token 而无从下手 —— 两层都落空,只剩这一层能兜住。
//
// 边界是实测出来的,不是想象的:`access_token=ab12&self_id=1` 这种写法会被形状级
// **顺带**吃掉(它的值字符类一直吃到空格为止,于是 ≥6 字符成立),所以那类串钉不住
// 第 3 层;本用例特意让短值后面紧跟一个空白,正好落在第 1、2 层都够不着的那一格。
// 去掉第 3 层实现时本条必须变红 —— 它是那一层唯一的钉子。
func TestSanitizeConnectReasonStripsUnknownShortQueryToken(t *testing.T) {
t.Parallel()
const leaked = "ab12"
raw := "websocket: dial ws://hook.example/onebot?access_token=" + leaked + " rejected during handshake"
got := sanitizeConnectReason(raw, "")
if strings.Contains(got, leaked) {
t.Fatalf("未配置 token 时仍泄露了他处的 access_token 明文: %q", got)
}
if !strings.Contains(got, maskedCredential) {
t.Errorf("脱敏后没有留下占位符: %q", got)
}
}
// TestSanitizeConnectReasonEmptyStaysEmpty 断言「没有原因」与「被脱敏成空」不互相冒充。
func TestSanitizeConnectReasonEmptyStaysEmpty(t *testing.T) {
t.Parallel()
if got := sanitizeConnectReason("", "tok"); got != "" {
t.Fatalf("空原因 = %q, want 空串", got)
}
}
// TestConnectOutcomeClientPendingCarriesSanitizedReason 场景②(事故正题):ws+client
// 首连失败 ⇒ Connect 照旧返回 nil(非致命),但 ConnectOutcome 必须如实报 pending
// 并带上**脱敏后**的原因;日志里同样不许出现 token。
func TestConnectOutcomeClientPendingCarriesSanitizedReason(t *testing.T) {
t.Parallel()
const token = "super-secret-token"
const rawURL = "ws://127.0.0.1:10301/onebot"
store := &testLogStore{}
a := NewAdapter(Config{
Mode: modeClient,
RemoteURL: rawURL,
AccessToken: token,
DialContext: tokenLeakDialer{rawURL: appendQueryToken(rawURL, token)}.dial,
Logger: slog.New(&testLogHandler{store: store}),
})
a.policy = testReconnectPolicy()
if err := a.Connect(context.Background()); err != nil {
t.Fatalf("Connect() error = %v, want nil(首连失败必须非致命)", err)
}
t.Cleanup(func() { _ = a.Disconnect(context.Background()) })
state, reason := a.ConnectOutcome()
if state != adapter.ConnectStatePending {
t.Fatalf("ConnectOutcome state = %q, want %q(未连上且重试在跑)", state, adapter.ConnectStatePending)
}
if state == adapter.ConnectStateConnected {
t.Fatal("首连失败被报成已连接 —— 这正是面板撒谎的根因")
}
if reason == "" {
t.Fatal("pending 必须带上原因:面板要能把「为什么没连上」如实说出来")
}
if strings.Contains(reason, token) {
t.Fatalf("原因串泄露 access_token: %q", reason)
}
assertNoTokenLeak(t, store, token)
}
// TestConnectOutcomeClientConnected 场景①:对面在时拨号成功 ⇒ connected,且没有原因
// 可挂(「已连接」旁边不该留一条过期的失败消息)。
func TestConnectOutcomeClientConnected(t *testing.T) {
t.Parallel()
srv, _ := newFakeOneBotServer(t)
defer srv.Close()
a := NewAdapter(Config{
Mode: modeClient,
RemoteURL: "ws://" + strings.TrimPrefix(srv.URL, "http://"),
DialContext: (&net.Dialer{}).DialContext,
})
a.policy = testReconnectPolicy()
if err := a.Connect(context.Background()); err != nil {
t.Fatalf("Connect() error = %v", err)
}
t.Cleanup(func() { _ = a.Disconnect(context.Background()) })
state, reason := a.ConnectOutcome()
if state != adapter.ConnectStateConnected || reason != "" {
t.Fatalf("ConnectOutcome = (%q,%q), want (%q,\"\")", state, reason, adapter.ConnectStateConnected)
}
if !a.IsConnected() {
t.Fatal("报 connected 却 IsConnected()=false:两个事实面必须一致")
}
}
// TestConnectOutcomeServerReadyThenConnected 场景③:ws+server 绑定监听但没有对端接入时
// 是 ready(IsConnected() 同为 false,却**不是失败**);对端反连上来之后变 connected。
func TestConnectOutcomeServerReadyThenConnected(t *testing.T) {
t.Parallel()
a := NewAdapter(Config{Mode: modeServer, ListenAddr: "127.0.0.1:0"})
if err := a.Connect(context.Background()); err != nil {
t.Fatalf("Connect() error = %v", err)
}
t.Cleanup(func() { _ = a.Disconnect(context.Background()) })
if a.IsConnected() {
t.Fatal("还没有对端接入就报 IsConnected()=true")
}
if state, reason := a.ConnectOutcome(); state != adapter.ConnectStateReady || reason != "" {
t.Fatalf("ConnectOutcome = (%q,%q), want (%q,\"\")", state, reason, adapter.ConnectStateReady)
}
client, err := dialWS(context.Background(), nil, "ws://"+a.Addr()+"/", "")
if err != nil {
t.Fatalf("dial server error: %v", err)
}
defer client.Close()
if !waitUntil(3*time.Second, a.IsConnected) {
t.Fatal("对端接入后仍未报已连接")
}
if state, _ := a.ConnectOutcome(); state != adapter.ConnectStateConnected {
t.Fatalf("对端接入后 ConnectOutcome state = %q, want %q", state, adapter.ConnectStateConnected)
}
}
// TestConnectOutcomeClearsReasonOnSuccess 断言连上时清空失败原因:一条已经过期的原因
// 不该继续挂在「已连接」旁边(面板会把过期的与当前的混为一谈)。
func TestConnectOutcomeClearsReasonOnSuccess(t *testing.T) {
t.Parallel()
srv, _ := newFakeOneBotServer(t)
defer srv.Close()
gate := make(chan struct{})
dial := &countingDialer{failTimes: 1, gate: gate, delegate: (&net.Dialer{}).DialContext}
a := NewAdapter(Config{
Mode: modeClient,
RemoteURL: "ws://" + strings.TrimPrefix(srv.URL, "http://"),
DialContext: dial.dial,
})
a.policy = testReconnectPolicy()
if err := a.Connect(context.Background()); err != nil {
t.Fatalf("Connect() error = %v", err)
}
t.Cleanup(func() { _ = a.Disconnect(context.Background()) })
if state, reason := a.ConnectOutcome(); state != adapter.ConnectStatePending || reason == "" {
t.Fatalf("首连失败后 = (%q,%q), want pending 且带原因", state, reason)
}
close(gate) // 对面起来了
if !waitUntil(3*time.Second, a.IsConnected) {
t.Fatal("第 2 次拨号成功却没有自动连上")
}
state, reason := a.ConnectOutcome()
if state != adapter.ConnectStateConnected || reason != "" {
t.Fatalf("连上后 = (%q,%q), want connected 且原因为空(过期的原因必须清掉)", state, reason)
}
}
// TestConnectOutcomeAfterDisconnectHasNoOpinion 断言断开之后适配器「无意见」:会话已经
// 拆掉,「连的过程」不再是当前事实,网关据此退回只看 IsConnected()。
func TestConnectOutcomeAfterDisconnectHasNoOpinion(t *testing.T) {
t.Parallel()
a := NewAdapter(Config{
Mode: modeClient,
RemoteURL: "ws://127.0.0.1:1/",
DialContext: (&countingDialer{failTimes: math.MaxInt}).dial,
})
a.policy = testReconnectPolicy()
if err := a.Connect(context.Background()); err != nil {
t.Fatalf("Connect() error = %v", err)
}
if err := a.Disconnect(context.Background()); err != nil {
t.Fatalf("Disconnect() error = %v", err)
}
if state, reason := a.ConnectOutcome(); state != "" || reason != "" {
t.Fatalf("Disconnect 后 = (%q,%q), want 无意见", state, reason)
}
}
// TestConnectOutcomeConfigErrorStaysFatalNotPending 守住「配置错仍然是硬失败」:外层的 Connect 报错路径没被动过,
// 因此配置错永远不会走到 ConnectOutcome 这里被说成 pending。
func TestConnectOutcomeConfigErrorStaysFatalNotPending(t *testing.T) {
t.Parallel()
a := NewAdapter(Config{Mode: modeClient})
err := a.Connect(context.Background())
if err == nil {
t.Fatal("缺 remote_url 的 Connect() = nil, want error")
}
if !errors.Is(err, errClientConfig) {
t.Fatalf("Connect() error = %v, want errClientConfig", err)
}
if state, _ := a.ConnectOutcome(); state != "" {
t.Fatalf("未启动的适配器 ConnectOutcome = %q, want 无意见", state)
}
}
+9 -3
View File
@@ -122,9 +122,15 @@ func (s *reconnectState) reset() {
s.failures = 0 s.failures = 0
} }
// noteConnectFailure 记录一次 client 模式的连接失败:推进退避,并写一条带来源的 // noteConnectFailure 记录一次 client 模式的连接失败:推进退避,把**脱敏后的**原因留在
// WARN(含 err 与下次重试间隔)。access_token 绝不入日志。 // 适配器上(供 ConnectOutcome 如实回答「为什么没连上」,见 connect_outcome.go),并写一条
// 带来源的 WARN(含原因与下次重试间隔)。原因与日志都走同一道脱敏,因此 access_token
// 既不落日志、也不进对外文案。
func (a *Adapter) noteConnectFailure(err error) { func (a *Adapter) noteConnectFailure(err error) {
delay := a.reconnect.fail(a.policy) delay := a.reconnect.fail(a.policy)
a.log.Warn("onebot connect failed; retrying", "err", err, "retryIn", delay) reason := a.connectFailureReason(err)
a.mu.Lock()
a.lastConnectErr = reason
a.mu.Unlock()
a.log.Warn("onebot connect failed; retrying", "err", reason, "retryIn", delay)
} }
+3
View File
@@ -330,6 +330,9 @@ func (a *Adapter) setConn(conn *wsConn) {
old := a.conn old := a.conn
a.conn = conn a.conn = conn
a.connected = true a.connected = true
// 会话真的活了 ⇒ 上一次失败的原因不再是当前事实,立刻清掉,免得面板把一条已经
// 过期的原因挂在「已连接」旁边。
a.lastConnectErr = ""
a.mu.Unlock() a.mu.Unlock()
if old != nil && old != conn { if old != nil && old != conn {
_ = old.Close() _ = old.Close()
+27 -2
View File
@@ -104,9 +104,14 @@ func (c *cropTool) Execute(ctx context.Context, args any) (any, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
sniffed, err := checkCropSourceBytes(data)
if err != nil {
return nil, err
}
img, _, err := image.Decode(bytes.NewReader(data)) img, _, err := image.Decode(bytes.NewReader(data))
if err != nil { if err != nil {
return nil, fmt.Errorf("io: image-crop: decode image: %w", err) return nil, fmt.Errorf("io: image-crop: decode image: %w: the bytes look like %s but could not be decoded "+
"— the payload is probably truncated or corrupt", err, sniffed)
} }
bounds := img.Bounds() bounds := img.Bounds()
if err := checkRegion(region, bounds); err != nil { if err := checkRegion(region, bounds); err != nil {
@@ -232,12 +237,23 @@ func (c *cropTool) load(ctx context.Context, src string) ([]byte, error) {
} }
// decodeCropDataURI decodes a "data:<mime>;base64,<payload>" source. // decodeCropDataURI decodes a "data:<mime>;base64,<payload>" source.
//
// 空载荷是**明确拒绝**而不是交给解码器:`data:image/png;base64,` 这种「只有前缀、
// 没有载荷」的入参实测出现过(模型复述不出几万字符的 base64,只把前缀抄了下来),
// 旧路径把它解成零字节再报 `image: unknown format`,指不到真正的毛病。前缀本身
// 不含图片正文,故可以安全回显(按 cropEvidenceBytes 截断,防止畸形长前缀灌进日志)。
func decodeCropDataURI(src string) ([]byte, error) { func decodeCropDataURI(src string) ([]byte, error) {
comma := strings.IndexByte(src, ',') comma := strings.IndexByte(src, ',')
if comma < 0 || !strings.Contains(src[:comma], ";base64") { if comma < 0 || !strings.Contains(src[:comma], ";base64") {
return nil, fmt.Errorf("io: image-crop: unsupported data URI (expected base64)") return nil, fmt.Errorf("io: image-crop: unsupported data URI (expected base64)")
} }
data, err := base64.StdEncoding.DecodeString(src[comma+1:]) payload := src[comma+1:]
if payload == "" {
return nil, fmt.Errorf("io: image-crop: data URI %q carries no payload (the base64 part is empty); "+
"pass the actual image bytes, or pass its http(s) URL / local path instead of retyping a data: URI",
truncateHead(src[:comma+1], cropEvidenceBytes))
}
data, err := base64.StdEncoding.DecodeString(payload)
if err != nil { if err != nil {
return nil, fmt.Errorf("io: image-crop: decode data URI: %w", err) return nil, fmt.Errorf("io: image-crop: decode data URI: %w", err)
} }
@@ -247,6 +263,15 @@ func decodeCropDataURI(src string) ([]byte, error) {
return data, nil return data, nil
} }
// truncateHead 把串截到至多 n 个字符,用于把**有限的、受控的**上下文写进错误
// 文案(如 data: 前缀);图片正文与 base64 载荷一律不进错误信息。
func truncateHead(s string, n int) string {
if len(s) > n {
return s[:n] + "…"
}
return s
}
// readFile reads a path through the file tool's sandbox so cropping can never // readFile reads a path through the file tool's sandbox so cropping can never
// reach a file the file tool could not read (裁决 13.4.4:文件沙箱唯一实现处). // reach a file the file tool could not read (裁决 13.4.4:文件沙箱唯一实现处).
func (c *cropTool) readFile(p string) ([]byte, error) { func (c *cropTool) readFile(p string) ([]byte, error) {
+143
View File
@@ -6,6 +6,7 @@ import (
"encoding/base64" "encoding/base64"
"image" "image"
"image/color" "image/color"
"image/jpeg"
"image/png" "image/png"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -179,6 +180,148 @@ func TestCropTool_RejectsUnsupportedDataURI(t *testing.T) {
} }
} }
// gradientJPEG 与 gradientPNG 同构,供「data: URI 带真 JPEG 载荷」的用例使用。
// JPEG 有损,故用例只钉裁切尺寸,不逐像素比对。
func gradientJPEG(t *testing.T, w, h int) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
img.Set(x, y, color.RGBA{R: uint8(x * 2), G: uint8(y * 2), A: 255})
}
}
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, nil); err != nil {
t.Fatalf("jpeg.Encode: %v", err)
}
return buf.Bytes()
}
// cropArgs 拼一组 image-crop 的参数,省得每个用例重抄区域坐标。
func cropArgs(source string) map[string]any {
return map[string]any{"image": source, "x": 2, "y": 2, "width": 4, "height": 3}
}
// TestCropTool_DataURIRealImages 钉住主用例:带着**完整 base64 载荷**的
// data: URI(png 与 jpeg)都必须照常裁切 —— 也就是 2026-10-07 那次失败里
// 「本该成立的形状」。
func TestCropTool_DataURIRealImages(t *testing.T) {
t.Parallel()
tool, _ := newCropToolForTest(t)
sources := map[string]string{
"png": "data:image/png;base64," + base64.StdEncoding.EncodeToString(gradientPNG(t, 20, 20)),
"jpeg": "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(gradientJPEG(t, 20, 20)),
}
for name, source := range sources {
t.Run(name, func(t *testing.T) {
t.Parallel()
res, err := tool.Execute(context.Background(), cropArgs(source))
if err != nil {
t.Fatalf("Execute(%s data URI): %v", name, err)
}
if img := decodeCropResult(t, res); img.Bounds().Dx() != 4 || img.Bounds().Dy() != 3 {
t.Fatalf("crop size = %dx%d, want 4x3", img.Bounds().Dx(), img.Bounds().Dy())
}
})
}
}
// TestCropTool_RejectsEmptyDataURIPayload 是本缺陷的回归用例(2026-10-07
// 18:32:51 实测):模型传的是 `data:image/png;base64,` —— **只有前缀、没有载荷**。
// 旧路径把它解成零字节再报 `image: unknown format`,指不到真正的毛病。
// 现在必须在本地说清病因,并且**不许**再出现那句 unknown format。
func TestCropTool_RejectsEmptyDataURIPayload(t *testing.T) {
t.Parallel()
tool, _ := newCropToolForTest(t)
_, err := tool.Execute(context.Background(), cropArgs("data:image/png;base64,"))
if err == nil {
t.Fatal("空载荷的 data: URI 被接受,want 报错")
}
msg := err.Error()
if strings.Contains(msg, "unknown format") {
t.Fatalf("错误文案退回旧形状(unknown format):%v", msg)
}
if !strings.Contains(msg, "no payload") {
t.Errorf("错误文案没点出「载荷为空」:%v", msg)
}
if !strings.Contains(msg, "http(s) URL") {
t.Errorf("错误文案没给可行动提示:%v", msg)
}
}
// TestCropTool_RejectsNonImageAsNotAnImage 钉住「这不是图片」:把 HTML 当图片
// 传(无论走 data: URI 还是 URL)都要报明确错误 —— 说清嗅探到的类型、
// 支持的格式与可行动提示,且**不得回显正文**。
func TestCropTool_RejectsNonImageAsNotAnImage(t *testing.T) {
t.Parallel()
html := []byte("<!DOCTYPE html><html><body>502 Bad Gateway</body></html>")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(html)
}))
t.Cleanup(srv.Close)
tool, _ := newCropToolForTest(t, WithAllowedHosts(serverHost(srv)))
sources := map[string]string{
"data uri": "data:text/html;base64," + base64.StdEncoding.EncodeToString(html),
"url": srv.URL + "/page.html",
}
for name, source := range sources {
t.Run(name, func(t *testing.T) {
t.Parallel()
_, err := tool.Execute(context.Background(), cropArgs(source))
if err == nil {
t.Fatal("HTML 被当成图片接受,want 报错")
}
msg := err.Error()
if strings.Contains(msg, "502 Bad Gateway") || strings.Contains(msg, "<!DOCTYPE") {
t.Fatalf("错误文案回显了正文:%v", msg)
}
for _, want := range []string{"not an image", "text/html", "png", "jpeg", "gif"} {
if !strings.Contains(msg, want) {
t.Errorf("错误文案缺少 %q:%v", want, msg)
}
}
})
}
}
// TestCropTool_MalformedDataURI 钉住畸形入参不 panic、报可行动错误。
func TestCropTool_MalformedDataURI(t *testing.T) {
t.Parallel()
tool, _ := newCropToolForTest(t)
cases := map[string]string{
"no comma": "data:image/jpeg;base64",
"no base64": "data:image/png,plaintext",
"illegal base64": "data:image/png;base64,!!!!",
"empty payload": "data:image/jpeg;base64,",
}
for name, source := range cases {
t.Run(name, func(t *testing.T) {
t.Parallel()
if _, err := tool.Execute(context.Background(), cropArgs(source)); err == nil {
t.Fatalf("%q 被接受,want 报错", source)
}
})
}
}
// TestCropTool_BareBase64StaysAPath 钉住既有分类不变:裸 base64 **不是**本工具
// 支持的输入(cropSourceKindOf 只认 data: / http(s) / 路径),它照旧走文件路径那条
// 分支并被沙箱拒绝 —— 这次改动不碰它。
func TestCropTool_BareBase64StaysAPath(t *testing.T) {
t.Parallel()
tool, _ := newCropToolForTest(t)
bare := base64.StdEncoding.EncodeToString(gradientPNG(t, 8, 8))
_, err := tool.Execute(context.Background(), cropArgs(bare))
if err == nil {
t.Fatal("裸 base64 被当成图片接受,want 走路径分支并被拒")
}
if strings.Contains(err.Error(), "not an image") {
t.Fatalf("裸 base64 的失败形状变了(应仍是路径/沙箱报错):%v", err)
}
}
// TestCropTool_RegisteredInSet pins that the tool ships with the default set and // TestCropTool_RegisteredInSet pins that the tool ships with the default set and
// advertises a usable schema (the agent only sees it if it is registered). // advertises a usable schema (the agent only sees it if it is registered).
func TestCropTool_RegisteredInSet(t *testing.T) { func TestCropTool_RegisteredInSet(t *testing.T) {
+98
View File
@@ -0,0 +1,98 @@
package io
import (
"fmt"
"net/http"
"strings"
)
// 本文件是 image-crop 的**入参内容嗅探 + 可行动报错**。此前 Execute 把源字节
// 直接丢给 image.Decode,失败时只报 `decode image: image: unknown format` ——
// 既不说是哪种内容,也不说本工具支持什么,模型无从自纠。
//
// 2026-10-07 18:32:51 的实测就是这个形状:模型传给 image-crop 的 image 是
// `data:image/png;base64,`(只有前缀、没有载荷),解码得到零字节 ⇒ 报
// unknown format;同一秒 vision 工具拿同一个入参,把空载荷当图片发给上游,
// 换回一句「You have uploaded an unsupported image」。两条报错都指不到真正的
// 毛病:参数里根本没有图片字节。现在这类输入在踩到解码器之前就被明确否决。
const (
// cropSniffBytes 是字节嗅探读取的前缀长度(http.DetectContentType 只看前 512 字节)。
cropSniffBytes = 512
// cropEvidenceBytes 是报错时作证给出的头部字节数。**绝不回显正文** ——
// 载荷可能很大,也可能含隐私内容,只给固定长度的头部十六进制。
cropEvidenceBytes = 16
// cropDecodableFormats 是本工具能本地解码的格式:标准库注册了 png / jpeg / gif
// 三个解码器(crop.go 的 import)。webp / bmp 没有标准库解码器,加解码器要引
// 新依赖,故如实报告「本地解不了」而不是含糊的 unknown format。
cropDecodableFormats = "png, jpeg, gif"
// cropUpstreamFormats 是视觉上游接受的格式(与上游白名单同口径),
// 「本地解不了」时给出这条替代路径。
cropUpstreamFormats = "webp, png, jpeg, gif"
// cropReuseHint 是「把非图片当图片传」时的可行动提示。
cropReuseHint = "to reuse an image from earlier in the conversation, pass its http(s) URL or local path instead of a data: URI whose base64 payload you cannot reproduce"
)
// checkCropSourceBytes 在解码之前嗅探源字节,返回嗅探到的媒体类型(供解码
// 失败时作证)。它不是 image.Decode 判断的复述,而是**换一种失败方式**:
// 不可能解出图的输入在这里得到「这不是图片 / 这个格式本地解不了」的明确报错。
func checkCropSourceBytes(data []byte) (string, error) {
if len(data) == 0 {
return "", fmt.Errorf("io: image-crop: source has no bytes, so it is not an image (empty payload); "+
"supported formats: %s; hint: %s", cropDecodableFormats, cropReuseHint)
}
sniffed := cropSniffMIME(data)
switch {
case cropDecodableMIME(sniffed):
return sniffed, nil
case strings.HasPrefix(sniffed, "image/"):
// 嗅探到图像签名,但本地没有它的解码器(webp / bmp / avif…)。
return sniffed, fmt.Errorf("io: image-crop: cannot decode %s locally: this tool decodes %s "+
"and the vision upstream accepts %s; convert the image to one of those formats first",
sniffed, cropDecodableFormats, cropUpstreamFormats)
case sniffed != "" && isTextContentType(sniffed):
// 复用的文本判据(util.go 的 isTextContentType):HTML/PROXY 错误页、
// JSON、SVG、纯文本都落在这里 —— 它们最常来自「把网页地址当图片直链」。
return sniffed, fmt.Errorf("io: image-crop: source is not an image: the bytes look like %s; "+
"supported formats: %s; leading bytes: %s; hint: %s",
sniffed, cropDecodableFormats, hexHead(data), cropReuseHint)
default:
return sniffed, fmt.Errorf("io: image-crop: source is not an image: unrecognised content (%s); "+
"supported formats: %s; leading bytes: %s; hint: %s",
sniffed, cropDecodableFormats, hexHead(data), cropReuseHint)
}
}
// cropSniffMIME 按真实字节嗅探媒体类型,归一化成无参数的小写形式。它是对
// 声明的否决权:扩展名、Content-Type 与 data: 前缀都可能骗人,字节不会。
// 判不出格式时 http.DetectContentType 返回 application/octet-stream。
func cropSniffMIME(data []byte) string {
if len(data) > cropSniffBytes {
data = data[:cropSniffBytes]
}
mt := http.DetectContentType(data)
return strings.ToLower(strings.TrimSpace(strings.Split(mt, ";")[0]))
}
// cropDecodableMIME 报告 mime 是否是本工具能本地解码的格式。
func cropDecodableMIME(mime string) bool {
switch mime {
case "image/png", "image/jpeg", "image/gif":
return true
default:
return false
}
}
// hexHead 把载荷开头 cropEvidenceBytes 个字节渲染成十六进制,作为「它到底像
// 什么」的物证。只给固定的头部长度:错误信息里不得出现图片正文或 base64 载荷。
func hexHead(data []byte) string {
if len(data) > cropEvidenceBytes {
data = data[:cropEvidenceBytes]
}
parts := make([]string, 0, len(data))
for _, b := range data {
parts = append(parts, fmt.Sprintf("%02x", b))
}
return strings.Join(parts, " ")
}
+49 -13
View File
@@ -60,8 +60,11 @@ func (k *knowledgeTool) Parameters() (map[string]any, error) {
return map[string]any{ return map[string]any{
"type": "object", "type": "object",
"properties": map[string]any{ "properties": map[string]any{
"query": map[string]any{"type": "string", "description": "Query topic."}, "query": map[string]any{"type": "string", "description": "Query topic."},
"persona": map[string]any{"type": "string", "description": "Persona scope id (optional; defaults to the persona of the current turn)."}, // persona 只作形状兼容:它必须**等于**本回合的角色,其他值一律被拒绝
// (design/03 §7.1:检索他角色的 scope=persona 无路径)。说明里写死这
// 一点,使合规模型根本不填它,而不是把它当成一个可自由选择的角色旋钮。
"persona": map[string]any{"type": "string", "description": "Must equal the persona of the current turn; any other value is refused. Memory retrieval always runs as the persona of the current turn, so omit this field."},
"top_k": map[string]any{"type": "integer", "description": "Max results."}, "top_k": map[string]any{"type": "integer", "description": "Max results."},
}, },
"required": []any{"query"}, "required": []any{"query"},
@@ -143,24 +146,41 @@ func (k *knowledgeTool) Execute(ctx context.Context, args any) (any, error) {
return map[string]any{"query": query, "count": len(out), "results": out}, nil return map[string]any{"query": query, "count": len(out), "results": out}, nil
} }
// resolvePersona 解析本次检索的角色作用域:显式参数 `persona` 优先;缺席时取 // resolvePersona 解析本次检索的角色作用域:**只认本回合角色**
// **本回合角色**(循环在 Run 顶部经 tool.WithTurnPersona 发布,见 // (tool.TurnPersonaFrom;循环在 Run 顶部经 tool.WithTurnPersona 发布,见
// internal/runtime/tool/turncontext.go)。 // internal/runtime/tool/turncontext.go)。显式参数 `persona` 只作形状兼容 ——
// 它**必须等于**本回合角色,不等即拒绝。
// //
// 为什么必须解析:记忆检索按角色作用域隔离(design/03 §3/§4),拿不到角色就 // 为什么角色不许由参数指定:检索按角色作用域隔离(design/03 §3/§4),而
// 取不到正确的候选集。把空角色透给记忆存储只会换来它自己的 ErrMissingPersona // 「直接检索他角色的 scope=persona(角色日常)」**没有路径**(design/03 §7.1
// 的判据 + §7.6 R4 + retrieve.go 的 visibleCandidates:候选域 = ListByPersona
// ∪ ScopeUser ∪ ScopeProject)。本回合角色由编排层盖章、模型影响不到;`persona`
// 参数则来自模型输出(可被提示词注入操纵)—— 一旦认它,模型只要传 persona=B,
// 这条链就会以 **B 的角色**去检索,把 B 的角色日常读出来,直接越过 §7.1 的红线。
// 跨角色读的**唯一**通路是把 B 当子代理派出去查(design/03 §7.2):那条通路上
// B 本就是子代理回合的角色(orchestration 的 withPersonaEnv → personaFor →
// WithTurnPersona),TurnPersonaFrom(ctx) 天然是 B,因此这里不必也不许开任何口子。
//
// 为什么是「校验 + 拒绝」而不是「直接删掉参数」:删掉参数后,模型传 persona=B
// 时会**静默**改用本回合角色 A —— 结果集虽不越权(A 只拿到 A 的可见域),但它
// 会把 A 的记忆当成 B 的(归属错认),并且没有任何信号让它停止换角色重试。
// 校验则给出可消化的拒绝(见 personaScopeError):写明不一致、要求去掉参数重试。
//
// 为什么拿不到角色也要拒绝:记忆检索按角色作用域隔离(design/03 §3/§4),拿不到
// 角色就取不到正确的候选集。把空角色透给记忆存储只会换来它自己的 ErrMissingPersona
// (memory.Store.ListByPersona 的硬校验)—— 模型既不知道要填哪个角色 id,参数 // (memory.Store.ListByPersona 的硬校验)—— 模型既不知道要填哪个角色 id,参数
// 说明还写着 optional,于是只能对同一堵墙反复重试。两者都拿不到时返回可消化的 // 说明还写着 optional,于是只能对同一堵墙反复重试。角色不可得时返回可消化的
// 拒绝,让模型改道(design/24 §122:knowledge 无库/不可用时**明确报错**, // 拒绝,让模型改道(design/24 §122:knowledge 无库/不可用时**明确报错**,
// 而不是从广告面消失)。 // 而不是从广告面消失)。
func (k *knowledgeTool) resolvePersona(ctx context.Context, m map[string]any) (string, error) { func (k *knowledgeTool) resolvePersona(ctx context.Context, m map[string]any) (string, error) {
if persona := strings.TrimSpace(argString(m, "persona")); persona != "" { persona := tool.TurnPersonaFrom(ctx)
return persona, nil if requested := strings.TrimSpace(argString(m, "persona")); requested != "" && requested != persona {
return "", personaScopeError(requested, persona)
} }
if persona := tool.TurnPersonaFrom(ctx); persona != "" { if persona == "" {
return persona, nil return "", unavailableKnowledgeError("本次会话没有解析出可用角色")
} }
return "", unavailableKnowledgeError("本次会话没有解析出可用角色") return persona, nil
} }
// unavailableKnowledgeError 构造「本工具不可用」这一类失败的可消化拒绝: // unavailableKnowledgeError 构造「本工具不可用」这一类失败的可消化拒绝:
@@ -170,3 +190,19 @@ func unavailableKnowledgeError(reason string) error {
return fmt.Errorf("io: knowledge: %w(%s):重试不会成功,请改用其他工具或直接作答,不要重复调用本工具。", return fmt.Errorf("io: knowledge: %w(%s):重试不会成功,请改用其他工具或直接作答,不要重复调用本工具。",
ErrKnowledgeUnavailable, reason) ErrKnowledgeUnavailable, reason)
} }
// personaScopeError 构造「点名了别的角色」这一类**越权尝试**的拒绝。它与
// unavailableKnowledgeError 同一口径(同样包住 ErrKnowledgeUnavailable,同样
// 是「原因 + 明确要求改道 + 不许重复调用」),只是改道动作不同:本回合的角色
// 仍然可用,模型去掉 persona 参数即可按本回合角色检索,不必放弃本工具。
//
// 拒绝而不是静默改用本回合角色,以及为什么这类调用必须无处可去,见
// resolvePersona 的注释与 design/03 §7.1/§7.6 R4。
func personaScopeError(requested, turn string) error {
if turn == "" {
return fmt.Errorf("io: knowledge: %w(本回合没有解析出可用角色,不能以 persona=%q 的身份检索,记忆检索只以本回合角色的身份进行):去掉 persona 参数后重试,或改用其他工具;不要以其他角色重复调用本工具。",
ErrKnowledgeUnavailable, requested)
}
return fmt.Errorf("io: knowledge: %w(本回合的角色是 %q,persona=%q 与它不一致,记忆检索只以本回合角色的身份进行,不能以其他角色的身份检索):去掉 persona 参数后重试;不要以其他角色重复调用本工具。",
ErrKnowledgeUnavailable, turn, requested)
}
+238 -10
View File
@@ -29,6 +29,49 @@ func (s *stubRetriever) Retrieve(ctx context.Context, personaID, query string, t
return s.results, s.err return s.results, s.err
} }
// personaScopedRetriever 按**检索角色**返回不同的命中集:以 personaID 检索时才会
// 拿到该角色的角色日常记忆。它使「越权」断言可检验 —— 只要检索器收到了 B,B 的
// 记忆就一定出现在结果里;因此「B 的内容一条都不许出现」等价于「不许以 B 检索」。
type personaScopedRetriever struct {
byPersona map[string][]memory.Result
personas []string
}
func (s *personaScopedRetriever) Retrieve(_ context.Context, personaID, query string, topK int) ([]memory.Result, error) {
s.personas = append(s.personas, personaID)
return s.byPersona[personaID], nil
}
// personaScopedResults 给 persona-scoped 替身造一份固定命中:每个角色各有一条
// 只有它自己看得到的角色日常记忆(scope=persona)。
func personaScopedResults(persona, content string) []memory.Result {
return []memory.Result{{
Item: memory.MemoryItem{ID: persona + "1", Content: content, Kind: memory.KindRelationship, Scope: memory.ScopePersona},
Score: 0.9,
}}
}
// knowledgeContentsOrEmpty 尽力从工具返回里抽出 content 集合;返回不是结果对象
// (例如越权被拒时 got 为 nil)时给空集,使「不得出现某条内容」的断言在成功与
// 失败两条路径上都能成立。
func knowledgeContentsOrEmpty(got any) map[string]bool {
out := map[string]bool{}
m, ok := got.(map[string]any)
if !ok {
return out
}
results, ok := m["results"].([]map[string]any)
if !ok {
return out
}
for _, r := range results {
if s, ok := r["content"].(string); ok {
out[s] = true
}
}
return out
}
// knowledgeToolForTest builds a tools set with opts and returns the knowledge // knowledgeToolForTest builds a tools set with opts and returns the knowledge
// tool. // tool.
func knowledgeToolForTest(t *testing.T, opts ...Option) *knowledgeTool { func knowledgeToolForTest(t *testing.T, opts ...Option) *knowledgeTool {
@@ -107,19 +150,154 @@ func TestKnowledge_UsesTurnPersonaFromContext(t *testing.T) {
} }
} }
// TestKnowledge_ExplicitPersonaWins 断言显式参数优先于本回合角色:模型点名角色时 // TestKnowledge_RejectsPersonaArgForAnotherRole 是跨角色检索(design/03 §7.1 判据 +
// 尊重模型的选择(既有语义不变)。 // §7.6 R4)的证据型测试:本回合角色是 A,模型(或提示词注入)在参数里指名 B 时,
func TestKnowledge_ExplicitPersonaWins(t *testing.T) { // **绝不许**以 B 的身份去检索 —— 必须明确拒绝,且结果里不得出现任何只有 B 看得到
// 的角色日常记忆。
//
// 旧用例 TestKnowledge_ExplicitPersonaWins 断言的正是缺陷行为本身(「显式参数优先,
// 检索角色 = [persona-arg]」)—— 它把「模型点名即换角色检索」钉成了规格,本次缺陷
// 修复把它替换为本用例。
func TestKnowledge_RejectsPersonaArgForAnotherRole(t *testing.T) {
t.Parallel() t.Parallel()
sr := &stubRetriever{} const (
personaA = "persona-a"
personaB = "persona-b"
contentB = "角色日常:B 深夜写歌"
)
sr := &personaScopedRetriever{byPersona: map[string][]memory.Result{
personaA: personaScopedResults(personaA, "角色日常:A 周末喜欢爬山"),
personaB: personaScopedResults(personaB, contentB),
}}
kt := knowledgeToolForTest(t, WithKnowledgeRetriever(sr)) kt := knowledgeToolForTest(t, WithKnowledgeRetriever(sr))
ctx := tool.WithTurnPersona(context.Background(), "persona-turn") ctx := tool.WithTurnPersona(context.Background(), personaA)
if _, err := kt.Execute(ctx, map[string]any{"query": "回忆", "persona": "persona-arg"}); err != nil { got, err := kt.Execute(ctx, map[string]any{"query": "回忆", "persona": personaB})
t.Fatalf("Execute: %v", err)
if err == nil {
t.Fatalf("以他角色的身份检索必须被拒绝,got %+v", got)
} }
if len(sr.personas) != 1 || sr.personas[0] != "persona-arg" { if !errors.Is(err, ErrKnowledgeUnavailable) {
t.Fatalf("检索角色 = %v, want [persona-arg]", sr.personas) t.Fatalf("err = %v, want ErrKnowledgeUnavailable(调用方要能以 errors.Is 分类)", err)
}
if len(sr.personas) != 0 {
t.Fatalf("被拒的调用绝不该触达检索器(否则 B 的记忆已被读出): %v", sr.personas)
}
// 硬断言:任何一条来自 persona=B 的内容都不许出现(成功路径也不许)。
if contents := knowledgeContentsOrEmpty(got); contents[contentB] {
t.Fatalf("越权检索泄露了 persona=B 的记忆: %v", contents)
}
msg := err.Error()
for _, want := range []string{personaA, personaB, "不能以其他角色的身份检索", "去掉 persona 参数", "不要以其他角色重复调用本工具"} {
if !strings.Contains(msg, want) {
t.Fatalf("拒绝文案缺少 %q(可消化的拒绝要写明原因 + 改道动作): %q", want, msg)
}
}
}
// TestKnowledge_RejectsPersonaArgWhenTurnHasNoPersona 钉住另一半:本回合 ctx 上
// **没有**角色时,参数里指名一个角色同样被拒绝(旧行为是以该参数当角色去检索,
// 即「凭空颁一个身份」,同样是越权面)。
func TestKnowledge_RejectsPersonaArgWhenTurnHasNoPersona(t *testing.T) {
t.Parallel()
sr := &personaScopedRetriever{byPersona: map[string][]memory.Result{
"persona-b": personaScopedResults("persona-b", "角色日常:B 深夜写歌"),
}}
kt := knowledgeToolForTest(t, WithKnowledgeRetriever(sr))
got, err := kt.Execute(context.Background(), map[string]any{"query": "回忆", "persona": "persona-b"})
if err == nil {
t.Fatalf("本回合没有角色时不许以参数里的角色检索,got %+v", got)
}
if !errors.Is(err, ErrKnowledgeUnavailable) {
t.Fatalf("err = %v, want ErrKnowledgeUnavailable", err)
}
if len(sr.personas) != 0 {
t.Fatalf("被拒的调用不该触达检索器: %v", sr.personas)
}
if !strings.Contains(err.Error(), "本回合没有解析出可用角色") {
t.Fatalf("拒绝文案没有说明原因: %q", err.Error())
}
}
// TestKnowledge_AcceptsPersonaArgMatchingTurn 钉住形状兼容:参数里的角色**等于**
// 本回合角色时照常检索(旧调用形状不被破坏),且检索角色始终是本回合角色。
func TestKnowledge_AcceptsPersonaArgMatchingTurn(t *testing.T) {
t.Parallel()
const personaA = "persona-a"
for _, args := range []map[string]any{
{"query": "回忆", "persona": personaA},
{"query": "回忆", "persona": " " + personaA + " "}, // 前后空白等价
{"query": "回忆"}, // 缺席
} {
sr := &stubRetriever{}
kt := knowledgeToolForTest(t, WithKnowledgeRetriever(sr))
ctx := tool.WithTurnPersona(context.Background(), personaA)
if _, err := kt.Execute(ctx, args); err != nil {
t.Fatalf("args=%v Execute: %v", args, err)
}
if len(sr.personas) != 1 || sr.personas[0] != personaA {
t.Fatalf("args=%v 检索角色 = %v, want [%s]", args, sr.personas, personaA)
}
}
}
// TestKnowledge_SubagentTurnReadsThatRole 钉住设计要保留的**唯一跨角色通路**
// (design/03 §7.2「B 作子代理查 B 的记忆」):子代理回合的角色由编排层盖章
// (orchestration withPersonaEnv → loop personaFor → WithTurnPersona),此时 ctx
// 里的本回合角色本来就是 B,因此去掉参数优先语义**不破坏**这条通路。
func TestKnowledge_SubagentTurnReadsThatRole(t *testing.T) {
t.Parallel()
const (
personaB = "persona-b"
contentB = "角色日常:B 深夜写歌"
)
for _, args := range []map[string]any{
{"query": "最近在忙什么"}, // 省略 persona
{"query": "最近在忙什么", "persona": personaB}, // 形状兼容:与本回合角色一致
} {
sr := &personaScopedRetriever{byPersona: map[string][]memory.Result{
personaB: personaScopedResults(personaB, contentB),
}}
kt := knowledgeToolForTest(t, WithKnowledgeRetriever(sr))
ctx := tool.WithTurnPersona(context.Background(), personaB)
got, err := kt.Execute(ctx, args)
if err != nil {
t.Fatalf("args=%v 子代理通路不该被拒: %v", args, err)
}
if len(sr.personas) != 1 || sr.personas[0] != personaB {
t.Fatalf("args=%v 检索角色 = %v, want [%s]", args, sr.personas, personaB)
}
if !knowledgeContentsOrEmpty(got)[contentB] {
t.Fatalf("args=%v 子代理查不到 B 自己的角色日常: %+v", args, got)
}
}
}
// TestKnowledge_MalformedPersonaArgIsAbsent 钉住畸形输入落到「缺席」分支且不 panic:
// 空串 / 纯空白 / 非字符串(数字、nil、布尔)都不是「点名了别的角色」。
func TestKnowledge_MalformedPersonaArgIsAbsent(t *testing.T) {
t.Parallel()
const personaA = "persona-a"
for _, persona := range []any{"", " ", 42, nil, true, map[string]any{}, []any{"persona-b"}} {
sr := &stubRetriever{}
kt := knowledgeToolForTest(t, WithKnowledgeRetriever(sr))
ctx := tool.WithTurnPersona(context.Background(), personaA)
if _, err := kt.Execute(ctx, map[string]any{"query": "回忆", "persona": persona}); err != nil {
t.Fatalf("persona=%#v 应当按缺席处理: %v", persona, err)
}
if len(sr.personas) != 1 || sr.personas[0] != personaA {
t.Fatalf("persona=%#v 检索角色 = %v, want [%s]", persona, sr.personas, personaA)
}
}
// 没有任何角色可解析时,畸形参数同样落「缺席」分支 ⇒ 可消化的拒绝,而不是 panic。
kt := knowledgeToolForTest(t, WithKnowledgeRetriever(&stubRetriever{}))
for _, persona := range []any{"", " ", 42, nil} {
_, err := kt.Execute(context.Background(), map[string]any{"query": "回忆", "persona": persona})
if !errors.Is(err, ErrKnowledgeUnavailable) {
t.Fatalf("persona=%#v err = %v, want ErrKnowledgeUnavailable", persona, err)
}
} }
} }
@@ -142,7 +320,7 @@ func TestKnowledge_ReturnsResults(t *testing.T) {
}} }}
kt := knowledgeToolForTest(t, WithKnowledgeRetriever(sr)) kt := knowledgeToolForTest(t, WithKnowledgeRetriever(sr))
got, err := kt.Execute(context.Background(), map[string]any{"query": "cyrene", "persona": "p1", "top_k": 5}) got, err := kt.Execute(tool.WithTurnPersona(context.Background(), "p1"), map[string]any{"query": "cyrene", "top_k": 5})
if err != nil { if err != nil {
t.Fatalf("Execute: %v", err) t.Fatalf("Execute: %v", err)
} }
@@ -252,6 +430,56 @@ func TestKnowledge_RestrictedEnv_OnlySharedVisible(t *testing.T) {
} }
} }
// TestKnowledge_RestrictedEnv_PersonaArgDoesNotWidenScope 钉住受限环境的**双层**防护
// 在有 persona 参数时不塌陷:本回合角色 = A 的群聊/陌生人回合里,
// ① 参数点名 B ⇒ 越权拒绝(换了身份也不行);
// ② 参数等于 A(形状兼容)⇒ 照常检索,但返回仍被 loop.FilterSharedVisible 收窄 ——
// 角色日常与 L4 意图记录一条都不许出现(design/03 §5/§7.6 R7)。
func TestKnowledge_RestrictedEnv_PersonaArgDoesNotWidenScope(t *testing.T) {
t.Parallel()
t.Run("他角色被拒", func(t *testing.T) {
t.Parallel()
sr := &personaScopedRetriever{byPersona: map[string][]memory.Result{
"persona-b": personaScopedResults("persona-b", "角色日常:B 深夜写歌"),
}}
kt := knowledgeToolForTest(t, WithKnowledgeRetriever(sr))
ctx := loop.WithSessionEnv(
tool.WithTurnPersona(context.Background(), "persona-a"),
loop.SessionEnv{ActorEnv: restrict.ActorStranger, MemWritable: false},
)
got, err := kt.Execute(ctx, map[string]any{"query": "回忆", "persona": "persona-b"})
if !errors.Is(err, ErrKnowledgeUnavailable) {
t.Fatalf("受限环境里的跨角色尝试必须被拒,got err=%v got=%+v", err, got)
}
if len(sr.personas) != 0 {
t.Fatalf("被拒的调用不该触达检索器: %v", sr.personas)
}
})
t.Run("同角色仍过滤", func(t *testing.T) {
t.Parallel()
kt := knowledgeToolForTest(t, WithKnowledgeRetriever(&stubRetriever{results: restrictedKnowledgeResults()}))
ctx := loop.WithSessionEnv(
tool.WithTurnPersona(context.Background(), "persona-a"),
loop.SessionEnv{ActorEnv: restrict.ActorGroupMember, MemWritable: false},
)
got, err := kt.Execute(ctx, map[string]any{"query": "回忆", "persona": "persona-a"})
if err != nil {
t.Fatalf("Execute: %v", err)
}
if n := got.(map[string]any)["count"].(int); n != 2 {
t.Fatalf("受限环境 count = %d, want 2(只留共享域非敏感项)", n)
}
contents := knowledgeContents(t, got)
for _, leaked := range []string{"角色日常:周末喜欢爬山", "intent:isTask=true;taskKind=task;confidence=0.9;text=帮我订一杯冰美式"} {
if contents[leaked] {
t.Fatalf("受限环境泄露了敏感记忆 %q: %v", leaked, contents)
}
}
})
}
// TestKnowledge_SurfacesLinkedProject 钉住审计 2026-10-05 §4:knowledge 工具此前 // TestKnowledge_SurfacesLinkedProject 钉住审计 2026-10-05 §4:knowledge 工具此前
// 丢掉了 Result.Project,模型因此够不着「项目索引 → 项目描述」层级。修复后项目 // 丢掉了 Result.Project,模型因此够不着「项目索引 → 项目描述」层级。修复后项目
// 作用域命中必须带出链接的项目条目(id/title/desc),非项目命中则不带该键。 // 作用域命中必须带出链接的项目条目(id/title/desc),非项目命中则不带该键。