622 Commits
Author SHA1 Message Date
AskaEth 21b906ffb0 feat(memory): D3 第五态 defer(入库审查)+ 提取留存规则闸 + 临时 TTL 配置面
主人原话:「收窄提取器,确实要给他一个主动式的记忆留存规则,不能想到什么都
往记忆里写」「.env 只做兜底用 …这些键优先从面板配置上取」「临时记忆到期删除」。
按 design/23 §18(D3 第五态 defer)、§19(到期处置)、design/03 §9.6
(tempTtlSec 默认 7 天 + 坏值口径)、§10(提取留存规则)落地。

- IngestDecision 闭集加第五态 defer:照常 active 落库 + 写短 expires_at 落
  临时层(不删行、不改正文,回滚 = 清空该列);IngestRuleVersion d3.2 → d3.3。
- 落点:ingestGatePlan 增 ExpiresAt + expiresAt()(走 expiryText/formatTime 的
  定宽 30 字节 UTC,与读侧时效闸同一套文本口径),由 finishIngest 在 defer 一态
  补写 expires_at(INSERT 与补写之间的短暂窗口如实登记,方向 = 宁可多留)。
  非 defer 三态一笔都不多写。
- T0 提取留存规则闸(extract_retention.go):design/03 §10.5 的 N1~N5 纯规则,
  接进 judgeIngest 的候选比对之前,命中即 defer + reason=t0_low_value,不进 T1;
  两道护栏(显式「记住」/ 持久信号)一票放行,fail-safe 方向 = 宁可多留。
- T1 闭集扩为 merge/modify/keep/defer(同一个 prompt、同一次调用,绝不新增
  第二次模型调用):只有关系判为真新时才问价值;defer 要求 target=0,否则判
  非法回落 create(reason=t1_reply_invalid)。
- 提取器提示词加固(design/03 §10.3 B):正向清单 + 反例清单 + 「允许返回 []」。
- 临时记忆 TTL:代码默认 tempTtlSec = 604800(7 天);面板
  $DATA_DIR/config/memory-tidy.json 的 tempTtlSec 优先(ApplyReviewTuning 逐键
  解析,坏值回落默认 + WARN,绝不写进 cfg)。该键在 .env 里没有出处且不新增
  (.env 已冻结)⇒ 实际生效链是「面板 > 代码默认」。启动播种同步该键(*int +
  omitempty,nil 省略、绝不写 null),保证播种文件与生效值逐字一致。
- preinsert_store.go 的 listReviewCandidatesSQL 原位注释:这条内部召回 SQL
  刻意不过时效闸的裁定与理由(design/03 §11.2 第 3 条)。
- 测试:defer 落库带短 expires_at(闸 + T1 两条路径)、写入口径与时效闸同源、
  TTL 坏值与回落、规则版本 d3.3、整理候选幂等键不含 IngestRuleVersion、
  提取规则正例 11 条 / 反例 11 条。

验收:go build ./... + go vet ./... + go test ./... 全绿;windows/darwin 交叉
编译通过(GOOS=darwin GOARCH=arm64 / GOOS=windows GOARCH=amd64)。
2026-10-13 20:22:48 +08:00
AskaEth 68ba85389b Merge branch 'feat/memgate-read-side-expiry-gate-for-memory' into dev 2026-10-13 19:59:40 +08:00
AskaEth 0c5fa7b2ae docs(memory): timegate.go 登记「审查留痕读侧不过闸」—— 留痕表结构上无该三列,无可加之闸 2026-10-13 19:54:36 +08:00
AskaEth 9a3b3cff8b feat(memory): 读侧时效闸(四条列表读路径)+ Add 落 expires_at 列
主人裁定「临时记忆到期删除 / 任何调用记忆的地方都要正常调用到」的读侧
第 ① 步(隐藏):design/03 §9.6 / §11.2 的时效闸下沉到共享底层,不写在
任何调用点。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

- 应用路径:逐条 / 按 run 批量(默认 100、硬顶 500、超限 truncated)/ revert;**软合并**
  (status + merged_into/merged_from/merge_count,**绝不改 content、行不删**)+ 审计
  (memory.tidy.apply / memory.tidy.revert)+ **幂等**(候选状态条件更新 + 行 status 闸两道)
- 调度:改成**到点即跑**(catchUp:无历史+窗口内 / 距上次 run ≥ interval / 窗口起点晚于上次 run),
  「上次 run」以 memory_tidy_runs 为**持久事实**;注入时钟用例证明**重启不饿死**
- 手动真跑端点 POST /admin/memories/tidy/run(同档鉴权 + 审计 + 回执:新增/合并/软删/耗时)
- 候选表加 run_id 列 + 索引(前滚;顺序=建表→补列→建索引,否则既有库会 no such column)
- 3 个变异自检(删 catchUp / 去掉两道 status 闸 / truncated 恒 false)
⚠️ 生产库零动作:1191 行真跑与 77 条候选应用**未执行**(按红线由主人确认后主会话执行)
⚠️ 契约段在报告里(candidates 列表/apply/revert/tidy.run),Docs/ 待登记
2026-10-06 22:39:35 +08:00
AskaEth 57d0e8c543 feat(admin): 时间轴只读权威键 + 自排筛选轴 + browser 面板图标(Y2+Y3+Y4 前端一并落)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 时间轴:页首「已过保留期」的**唯一**判据改为 retentionBoundaryReached === true(truncated 只出
  「未显示」);缺席(老后端)一句话都不说;删掉旧的 truncated+retentionEnabled+翻到底 启发式
- Backthink:筛选轴接上服务端 ?trigger=(客户端筛选保留作兜底)+ 自排闸用量行(三态文案)
- plugin/nav.ts 加 browser 图标
- 真机(隔离实例,46/46):翻页不重不漏、三种「必须 false」的权威键语义、**重启后 round 不重复**
⚠️ 未验:真机上没构造出权威键 true(需真有更早轮次被清扫)——别读成「真机验过 true」
2026-10-06 21:48:52 +08:00
AskaEth e0a6bca501 feat(browser): B3 截图→上下文闭环 + B4 面板声明页(Y4)
- B3:截图落盘后**自动**并入本轮视图(能收图 ⇒ 真 image 部件挂 user 消息;收不了 ⇒ vision/OCR 文字;
  大图先经 image-crop 裁切;取走即消费、每会话 ≤4 张、TTL 10min、跨会话隔离)
- B4:browser 面板**声明**(配置表单 + 运行状态/指纹自检/最近截图三张只读表 + 拍一张/重新自检)
  + 六个动作走 PLG-2 的通用动作端点(**不开新路由**)+ 保存事务(不可用则回滚 409)+ 热重载
- 读面**不启动浏览器**(计数型假 dialer 证明 0 次拨号);行数据不含路径/URL/内容
- 3 个变异(短路主路/行与列 key 脱钩/保存不回滚)
⚠️ **两行接线未落**(不在我文件面):wire_runtime.go 的 TransformContext + wire_frontend.go 的
   wireBrowserPanel —— 落地前 B3/B4 只是「组件就绪」,跑着的后端里不存在(Y4 已写盯梢用例)
2026-10-06 21:48:50 +08:00
AskaEth 4213b268fe feat(backthink): 自排的成本可见面(C2 读侧,Y3)
- GET /api/v1/admin/backthink 加可选 ?trigger=(闭集=内建触发标签 + 本服务订阅的事件主题;
  未知/空 ⇒ 400 invalid_trigger,不静默忽略;合法值下推到 SQL ⇒ limit 是匹配条数)
- 新端点 GET /api/v1/admin/backthink/self-wake(writeAuth):闸的只读镜像
  {available,running,reason?,configFile?,gate{pending,used,limit,windowSec,default*,*IsDefault}}
  三态分开(未接线 / 有配置未运行 / 运行中),读它**不消耗额度**
- 零新 env 键;旋钮源 = $DATA_DIR/config/backthink.json(**写面未做**,前置=组合根接线,已登记)
- 键名沿用设计与现有代码的 selfWakePerWindow(派工书写错成 selfWakeLimit,未改键名以免废掉主人已写的配置)
- 5 个变异(未知值静默忽略/关掉下推/恒报运行中/白名单摘键/前端把看不到画成 0/0)
⚠️ 本片改了 4 个文件面外文件(admin_query.go/routes.go/api.ts/adminSystem.ts)——已逐条说明理由
2026-10-06 21:48:49 +08:00
AskaEth 215eedf8e9 feat(ws): tool_execution.end 带可选 resultSummary(Y1,答应安卓端的那条)
- 形状:data.resultSummary(可选)。与回看面 toolCalls[].resultSummary 代码级同源:同一个常量
  (500 rune)与同一个截断函数,摘要由 **loop 侧**生成(trace.RedactText + renderToolResultSummary)
  再随 StreamEvent 下发,gateway 只搬运不加工(与回看面同口径)
- 缺席语义:结果真拿到才带;失败/取消/超时/未落图 ⇒ 整键缺席(绝不用空串顶位)
- 线上帧证据:真 WS + 真 JWT + 真 loop ⇒ 客户端收到 resultSummary;同源钉用真图快照反读逐字比对
- 3 个变异(空摘要也写键/去掉脱敏/失败当结果)
⚠️ 待裁:失败路径现按派工书「整键缺席」,但回看面对失败给的是 error 摘要 ⇒ 实时无结果区、事后有;
   若要实时也显示失败原因,一行可改(toolEndFrameSummary 的 return "")
2026-10-06 21:48:48 +08:00
AskaEth a820d9968e docs(contract): v0.81 —— 时间轴 retentionBoundaryReached + round 跨重启单调 + 自排可见面登记(X5)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 新增响应键 retentionBoundaryReached(与 truncated 分意,判据三条 + 三个盲区如实登记)
- round 跨重启不再重复(行为修复,客户端可放心按 round 分组)
- 登记 trigger=self_wake 与 4 条 backthink.self_wake.* 审计动词,并标明入口工具 request_think
  **未接线 ⇒ 生产里目前不会出现**
- §7.1 两行状态;计数不变(无新端点 ⇒ 14+18+40=72)
⚠️ 该键取自 X1 工作树实现 ⇒ 已在文档 4 处标明「提交后按 git show 逐字复核」(本提交即该实现)
2026-10-06 21:16:25 +08:00
AskaEth 2d211c2d16 feat(backthink,browser): 自排工具面 C1-3 + 浏览器两处偏离收口(X2+X3 同文件一并落)
X2 C1-3(自排工具面与提示词):
- request_think(thinktool.go,照 transcode/tool.go 范式)+ reason 必填/urgency 闭集 + 双守门
  (安全闸 + owner 判定:群聊/陌生人一律拒)
- **成对**进两张唤醒态允许集(quiet + awake;CAPACITY_TOOL_SETS 是整体替换)
- 提示词抽共用段落 + 自排前缀优先(不枚举工具清单);面板加 self_wake 标签与筛选轴
- 4 个变异自检(拿掉 owner 判定/只加一张表/放宽 urgency/前端标签恒等)

X3 收口(与 X2 共用 wire_services.go ⇒ 一并落):
- 删掉第三份 SSRF 实现(guard.go),改由 io.ValidateRequestURL 唯一判定 + 薄门面(保两个错误身份)
- 浏览器会话挂进 a.close(真机:真 Chrome 被关、无残留、生产未扰动)
- a.wireBrowser() 归位到 wire_runtime.go,删掉 ioToolOptions 的临时挂点
- 5 个变异自检

顺带补提交 C1-2 漏掉的 internal/config/runtimeconfig/backthink_test.go(ed50053 漏 add)。
2026-10-06 21:16:23 +08:00
AskaEth 2bf3b536dd fix(flow): 时间轴两处诚实性缺陷(X1)
① round 号跨重启被复用 ⇒ 时间轴把多个真实轮次并成一块(真机实测 web 的 r:1 挤了 4 路、
   startedAt 跨 13:52→21:05)。修法:发号前从落盘播种(MaxRound,json_extract + json_valid 挡坏行),
   每会话每进程只播一次、失败只 WARN 且不重试、并发首轮共享一次读;接口形状零变化
② truncated 一个字段兼两个意思 ⇒ 一页没拉完时前端会说「更早的轮次已过保留期」(假话)。
   新增 retentionBoundaryReached(= 保留期启用 且 已翻到底 且 现存最早一轮 round>1 ⇒ 确有轮次被清扫),
   truncated 语义一字未改;盲区如实登记(整段被清扫会说少、最早一轮落盘失败可能多说)
3 个变异自检(不落账/floor>0/去掉 hasMore 守卫)
2026-10-06 21:16:21 +08:00
AskaEth b36798265a docs(comments): proactive 判据键在 data 内、不在帧壳顶层(安卓核对指出)
4 处注释写「帧顶」而代码放在 data[proactive](+ 测试文件 6 处同源表述)。
安卓端两层都读所以不受影响,但注释与代码相反会误导下一个按注释写的人。
零行为改动(仅注释)。
2026-10-06 21:16:20 +08:00
AskaEth f1fbc249fd feat(browser): B1 接线 + B2 薄层工具面(V5)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- browsercfg 接进组合根:配置读 $DATA_DIR/config/plugins/browser.json(零 env)→ 探测(本机
  Chromium → 已在跑的 CDP)→ 不可用就不注册(原因码入日志)
- 自研最小出站 CDP 客户端(不 Enabled Runtime、隔离世界求值、不发 --enable-automation)+
  导航 + 截图 0600 落盘 + 指纹自检;工具 browser(open/screenshot/info,owner-only)
- 3 个变异自检(启动恢复 --enable-automation/无视可用性/空闲回收失效)
- 真机对照(两站各 1 次,**如实结论**):两侧都 pass ⇒ 「headless 被拦 / headful 通过」**未被证明**;
  但测到两处硬差异:无头 UA 带 HeadlessChrome、screen 800 vs 2632;两模式 webdriver 均 false
  (把 L6 的推断变成实测)。跑完 0 残留 Chrome、无对外监听端口
⚠️ 未做:MCP 自动接入(口径委派未落地)/自动注入上下文/面板页/搜索降级/docker 配方/Windows 用
  pipe(Go ExtraFiles 不支持);SSRF 落了第三份同策实现待收口;浏览器会话未挂进程关闭链
2026-10-06 20:25:16 +08:00
AskaEth ea150ceea9 docs(contract): §2.0 端点计数回补(14+18+40=72)+ S6 未确证项收口(V4)
- writeAuth 17→18、总数 71→72(并写明自 v0.76 起 −1 的滞后已修正;历史版本条目保留原写法)
- S6 的未确证项逐条核对:能验的给代码级证据,验不了的写明「为什么验不了」(无凭据/禁重启)
- 契约版本仍 v0.80(同版内记账回补,零形状变更)
2026-10-06 20:25:14 +08:00
AskaEth 4a04ad43fe feat(skills): 示例技能 + 端到端验收(V3)
- configs/skills/daily-brief/skill.yaml:真能跑的工作流(4 步依赖图 + 2 条 guardrail + allowed-tools)
- example_skill_e2e_test.go:真 Loader + 真 registry + 真 loop 三轮断言 —— 第1轮只有清单、
  第2轮命中展开正文且工具面只剩 allowed(B 工具不可见)、第3轮正文保留而工具面恢复、BodyRenders==1
- 3 个变异自检(triggers 清空/关掉收缩/正文漏进清单)
⚠️ 生产未验:当前后端启动早于技能落盘 ⇒ 需一次 restart 才会出现 skills registered;
  隔离实例(临时端口/临时 DATA_DIR)已 GREEN,含面板链四条真实回执
2026-10-06 20:25:14 +08:00
AskaEth 745365c7df fix(admin): 时间轴分页与保留期文案按契约收紧(V2)
- 类型补上后端真在返回的 source/nextBefore/hasMore/retentionEnabled(此前类型里没有)
- 新增纯函数 pageHasOlder(hasMore 权威,老后端退满页启发式)/timelineCursorOf/retentionClaimable/
  timelineHints;取数体抽成可执行导出函数 ⇒ effect 本体首次被真跑(真 fetch + 真状态机 + 真 SSR)
- 🔴 修一处成对契约缺陷:后端 truncated = hasMore || !storeWired,而契约/设计要求 truncated ⇒
  「已过保留期」⇒ 旧前端会对主人说假话。现改为:只有「已翻到底 + 保留期启用 + 非 live-only」
  才说「已过保留期」,其余说「更早的轮次未显示」
- 3 个变异自检(去掉 canLoadMore 短路/恒可翻页/忽略 hasMore)
⚠️ 未验(如实):鉴权后的真机 HTTP 往返拿不到 owner 凭据;前端产物未部署(面板仍是旧文案)
2026-10-06 20:25:06 +08:00
AskaEth ed5005307a feat(backthink): 自排 C1-2 本体 + TriggerUnknown 修 fail-open(V1)
- selfwake.go:WakeNow + 待跑槽容量 1(先探槽合并不过闸、槽空才过闸、同锁)+ 闸 4 轮/小时
  ($DATA_DIR/config/backthink.json,零新 env 键)+ 审计四条 what + 启动日志两键 + 组合根接线
- G-A(主人已批):TriggerUnknown 作零值、既有两值编号后移(语义不动),未知触发走**最紧分支**
- 5 个变异自检(拆闸/≤0 未回落/过闸挪到探槽前=G1 失效形态/槽无界/零值挪走)
⚠️ 服务侧已就绪但**她仍调不到**(自排工具面属 C1-3)⇒ 不得写「C 已完成」
2026-10-06 20:25:04 +08:00
AskaEth 301f8b4276 docs(comments): 清掉两处已过期的状态注释(ErrRunPending 早已不存在)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
S1/S3 大扫除点名的代码侧残留:
- cmd/cyrene/skills.go 注释仍称 Runner.Run 返回 ErrRunPending(哨兵已删,f75e7a9 起真执行)
- internal/services/gateway/admin_plugins_skill.go 注释仍称执行后置到 M12

零行为改动(仅注释)。
2026-10-06 18:46:58 +08:00
AskaEth 94da6bcd6d feat(backthink): 静默期自排 C 的判定侧(C1-1,切片三之一)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 追加 TriggerSelfWake(不动既有编号)+ SelfWakeRequest(走既有 urgency 声明通道,不新造第二条)
- escalateImport 折算收成唯一 foldDeclared(事件轮与自排轮共用、只上抬取较高档)
- DecisionInput.Heartbeat bool → Kind TriggerKind;心跳仍免检、**自排不享心跳豁免**(要求声明 ≥ warning)
- push 与档位分离:push.go 零改动即自动正确(eventDriven 对 self_wake 为 false ⇒ 照旧过 PushGate)
- 4 个变异自检(折算空操作/删自排分支/常量传心跳/非事件一律豁免)
⚠️ 本片只是判定侧:WakeNow/worker/闸属 C1-2,工具面与提示词属 C1-3 ⇒ **不得写『C 已完成』**
⚠️ 遗留 G-A(建议登记进 design/12 C.4):TriggerKind 零值=TriggerHeartbeat ⇒ 漏填=最宽豁免(fail-open);
   本片已在字段注释与单测里显式化,但规格侧应补一句或引入 TriggerUnknown
2026-10-06 18:46:27 +08:00
AskaEth ea43f07bce docs(contract): v0.80 —— 时间轴出口 + provider 热重载 + AMR 落副本
三批登记(S6 按代码逐条核对,30 条 文件:行 证据;我复核版本行与唯一当前版本):
① W1 GET /api/v1/admin/flow/timeline(时间正序/游标/retentionHours/truncated,计数 16→17、70→71)
② W3 热重载 + primary/fallback:新增错误码 primary_not_configured(400)/config_unstartable(409),
   并改正 3 处已变假话的旧口径(『不热替换、需重启』『无主 provider 字段』)
③ W4 VoiceTranscoder 已注入 ⇒ AMR 落 wav 副本;6 处已变假话处改正
   (S6 主动纠正一处口径:微信 iLink 语音刻意不下载字节,不算进『已修』)

⚠️ S6 发现既有记账错误:§2.0 条数自 v0.76(cb232e7) 起少 1(实测应为 14+18+40=72),
已在文档内如实标注请裁决,未擅自回补。
2026-10-06 18:46:26 +08:00
AskaEth 9c9a6d26c9 docs(env): .env 冻结最终口径 —— 基础键已定、以后不准改(不加/不改/不删)
主人 2026-10-06 二次收口:只求『统一不新增』且『基础键已定好、以后不准改』。
- AGENTS §10 与 design/13 §2.1 同步改写:新配置一律走 $DATA_DIR/config/** + 代码默认值;
  既有基础键与 env: tag 保持现状(不迁移/不改默认值/不删键);E6 作废
- 新增一条判据:模板与代码若出现差异 ⇒ 那是**事故**(有人动了被冻结的东西),要回退而不是同步文档
2026-10-06 18:46:25 +08:00
AskaEth ffe2518bf4 feat(memory): 留痕表正式迁移 0025 + 保留期清扫(W5)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 迁移 0025_memory_ingest_reviews(幂等,既有部署重跑不炸/不丢行)
- GC:保留期 720h + 条数上限 10 万行,挂进审查服务生命周期(Start 起 / Stop 停 / 启动先扫)
- 过期即硬删(RevertMerged 只读 memory_items,删留痕不损失回滚能力)
- 偏离一处并给理由:表只留 created_at(先例 0017;append-only;加 updated_at 需三份 DDL 真源)
- 3 个变异自检(边界 <=/元组比较方向/Retention=0)
2026-10-06 15:59:16 +08:00
AskaEth ddd3d00d09 fix: 修「假话但绿」的用例 + 补 VoiceTranscoder 注入(W4)
- history_toolcalls_gap_test.go 改成正向断言:单路回合经 StartRoundWithTurnID 带权威 turnId
  ⇒ 落库行按该标识查得到 ⇒ HTTP 出口 toolCalls 在场;并保留「无权威标识 ⇒ 整体缺席」边界用例
- 组合根补 VoiceTranscoder: a.transcoder ⇒ 微信/QQ 的 AMR 语音在落盘前先转 wav 再落副本
- 4 个变异自检(删注入/摘 ctx 标识/清 turn_id/调换 wiring 顺序,均红)
2026-10-06 15:59:14 +08:00
AskaEth a30f4a49ff feat(search): provider 热重载 + primary/fallback(W3)
- 保存被包成事务:快照 → 落盘 → 按新配置重建工具 → 原子替换注册表条目(registry.ReplaceTool
  窄方法,保留运行期 Policy);下一轮即生效,不要求重启
- 若新配置会让后端下次起不来(主家未配齐 fail-fast)⇒ 文件按字节回滚 + 409 config_unstartable
- 声明面补 primary + fallback[](可配可生效);否决「另开 form」方案(会破坏既有 reauth 回填)
- 主 provider 解析口径核查结论:没被 PLG-4 改坏(顺手修了一处日志缺陷)
- 4 个变异自检(丢 Policy/不换条目/回滚 no-op/忽略 fallback)
2026-10-06 15:59:12 +08:00
AskaEth 5ee5f9126f feat(admin): Flow 页时间轴视图(W2)
- 视图切换:最近一轮(既有 DOM 逐字节未变,7/7 SSR 对照 IDENTICAL)| 时间轴(新)
- 时间轴:竖轴=时间(默认最新在上)、每回合一个可折叠块、块内复用 LaneTracks 泳道、
  块间不画任何连线(不臆造因果);before 游标分页;truncated ⇒ 显式「更早的轮次已过保留期」
- 降级诚实:只拿到最近一轮(老后端)⇒ 明确提示「当前只支持最近一轮」
- 4 个变异自检(排序方向/块间插线/truncated 静默/游标方向);老视图等价性实测
2026-10-06 15:59:11 +08:00
AskaEth 1efe3a6a79 feat(flow): 链路图时间轴出口(W1)—— 所有轮次按时间正序
- 新增 GET /api/v1/admin/flow/timeline?sessionId=&limit=&before=:时间正序多轮,
  每轮 {turnId,round,startedAt,endedAt,lanes[]};limit 缺省 50/上限 200 + before 游标
- 数据源=落盘 flow_graphs + 内存最近一轮(同图 id 去重);保留期 168h 外的轮次不返回,
  但响应给 retentionHours/retentionEnabled/truncated ⇒ 前端如实标注,不悄悄少一块
- 老出口 GET /admin/flow/graph 零 diff;角色门 writeAuth(owner|admin)
- 4 个变异自检(游标 <= 重复、分组降级、去重缺失、truncated 静默)
2026-10-06 15:59:09 +08:00
AskaEth 78e99d9f4c docs(contract): v0.79 —— 面板动作真可用 + 记忆审查/语音可见面 + 回合标识全覆盖
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
四批一并登记(DOC-3 按代码逐条核对,含 文件:行 证据;我复核版本行/唯一当前版本/端点面):
① PLG-2 动作真可用:SetExecutor 已删、生产执行器接线(wire_frontend.go:205 →
   wire_plugin_actions.go:61-89);错误码按代码重写 —— action_not_implemented(501) 只剩
   「已声明未登记处理器」,兜底是 502 action_failed;并纠正一处输入说法:网关层非法 JSON
   回 bad_request(非 invalid_payload),两个码都登记
② CODE-TC-2 回合标识全覆盖:StartRoundWithTurnID 是唯一生产建图点;graph.id 对单路也追加
   #{回合标识} ⇒ 契约明写「一律用响应里的 id,不要自拼」
③ L45:新增 B16 GET /admin/memories/reviews + B17 POST /admin/memories/{id}/revert(writeAuth)
   + U19 GET /api/v1/media/{name} 与消息 DTO voice(覆盖率按实际填充面写,帧/握手回放标注不带)
④ L2/SK-5 零契约变更(已在报告说明)
计数:writeAuth 14→16、总数 68→70。

⚠️ DOC-3 指出的易误用点(已在文档区分):turns[].turnId(=graph.id) 与 lanes[].turnId(=权威 turn-N) 不同源。
未确证:documents.SaveAudio / UpdateMessageVoice / RevertMerged 本体未逐行核(标「转述自 L45」);真机未跑。
2026-10-06 14:20:08 +08:00
AskaEth 1420e8aaa6 feat(gateway): D3 审查读侧/revert 端点 + 语音用户可见面(L45)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
(a) 记忆入库前审查的两条 admin 端点(主人判「有必要就做」):
  GET /api/v1/admin/memories/reviews?limit= (留痕读侧,不含记忆正文)
  POST /api/v1/admin/memories/{id}/revert (回滚软合并,落审计 memory.review.revert)
  两条 writeAuth(裁定 DECISIONS #10:留痕属私有面,不给 viewer)
(b) 语音用户可见面:DTO voice(含服务端派生 url)+ GET /api/v1/media/{name}
  (200/206/304/416/HEAD、Accept-Ranges、非法名/穿越统一 404 media_not_found)
  + documents.Store.SaveAudio(内容寻址、$DATA_DIR 0600)+ history voice 列读写与引用计数差动

缺口(如实):message_persisted/握手回放仍不带 voice(文件面外,需给 history.Message 加字段);
AMR 需一行 VoiceTranscoder 注入(DECISIONS #11 已排)。4 个变异自检(含一条自认的等价变异)。
2026-10-06 14:12:27 +08:00
AskaEth 4a937bd269 feat(flow): toolCalls 覆盖率追到全部编排回合(L3)—— 图带权威回合标识
- 新增 StartRoundWithTurnID(ctx, owner, text):从 ctx 取权威回合标识(与 chat_messages.turn_id
  同值);Run 的唯一生产建图入口切到它;原 StartRound 语义逐字不变(1 行包装)
- 归属桶(并行按路分图)保持不变;落盘查询键 lane_turn_id 对单路也有值 ⇒ 消息面按等值查得到图
- 无权威标识时**保持缺席**(不拿桶里的路标识顶上:不许臆造)
- 不需要迁移 0024(0023 的列+索引已在位;老单路行标识根本没存过,回填=编造)

形状变化(已登记契约):graph.id 对单路也追加 #{回合标识}(web-7 → web-7#turn-…),
顺带修掉「重启后单路轮次 upsert 掉旧行」的隐患。3 个变异自检(退回老入口/无视 ctx/臆造回退)。
2026-10-06 14:12:25 +08:00
AskaEth 6475eb875d feat(skills): 渐进披露接线(L2)—— 清单投影 + 命中才展开 + 工具面只收不放
- 清单只给 name+description(+when-to-use),单条 ≤1536 字符 + 全局条数上限,超限如实标 truncated
- 命中才把正文作为一条消息注入:跨回合保留、按内容指纹缓存不重复读盘、注入 ≤16 KiB 标 .truncated
- invocation 三态与清单逐格对齐(model-only 清单不含 description;user-only 清单有但模型不可调)
- 激活轮的工具面 = allowed-tools ∩ 安全闸 ∩ 注册表 再减 disallowed-tools(owner 也看不见未允许的工具)
- 激活状态进程内按会话(不落库);注入量已界化(压缩器估值未含,超我文件面)

裁定(DECISIONS #3/#4/#5):删掉 paths 启发式(只认显式挂载);多技能并集;收缩作用域=激活的那个回合。
2026-10-06 14:12:24 +08:00
AskaEth 2e1c3ed3fd feat(plugins): 面板动作执行器接线 —— 动作真可用,不再 501 兜底(L1)
- 插件管理器加生产动作处理器登记面(RegisterActions);未登记处理器/未声明动作仍 404
- web-search 四个动作真读写真源 + 真发 HTTP 探针:plugin.listProviders/saveProvider/
  testProvider/reauthProvider(配置落 $DATA_DIR/config/plugins/web-search.json,0600)
- 失败回结构化错误码(不含上游原文)+ 审计带 code 字段;501 文案改为「未登记处理器」
- 拆出 panelactions.go(动作索引推导/OptionsActionName/三闸门分发),panels.go 519→347 行

缺口(如实):面板保存后运行中的工具**不热重载**(下一片做);声明面缺 primary/fallback
(按 DECISIONS-2026-10-06 #1/#2 补)。
2026-10-06 14:12:23 +08:00
AskaEth a150211468 docs(contract): v0.78 登记 16 路已落地的六批形状(含 5 处如实的覆盖率/未实现标注)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
六批:① 消息读取面 toolCalls[] 已实现(迁移 0023 按路键;**覆盖率只到真并行路**)
② 管理面 panels + 动作端点 + panels_changed 帧(**动作执行器无生产实现 ⇒ 现全回 501**)
③ 搜索 provider 可变源(config/plugins/web-search.json;不新增 env 键)
④ 账号上下线落事件 + channel_event 帧(**只覆盖账号状态这一路**)
⑤ iLink 账号三态(-14=被拒非过期;**accounts[] 每账号三态未做**)
⑥ 语音:**只登记落库面已实现**,用户可见面(DTO voice + /media/{name})仍标未实现

我复核并改掉一处 overclaim:v0.78 总述原写「六条全部已实现」与 ⑥ 自述矛盾 ⇒
改为「①~⑤ 已实现;⑥ 只到落库面」。

值得记的一笔:DOC-2 拒绝照抄我任务书里「把语音改成已实现」的要求,回代码核到三条机器证据
(无 /media/ 路由、DTO 无 voice 字段、转码器无注入点)⇒ 没实现的没写成可用。这是对的。

未确证(文档内已标):六批都没在运行中的 :10942 上验过(本批禁换产物/重启);
lane_turn_id == chat_messages.turn_id 仍是代码级推论。
2026-10-06 09:11:48 +08:00
AskaEth 9e63af9063 feat(admin): 前端可接入点 —— 折叠分组 + 动态插件页 + schema 渲染(PLG-3)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- sections.tsx 的分组升级为可折叠(CollapsibleGroup);新增 plugins 分组由
  GET /api/v1/admin/panels 动态填充(navModel.ts 纯函数可单测)
- 插件页走动态段 /admin/plugin/<pluginId>/<panelId>,不进 router.ts 的静态白名单
- 把既有三段式(字段声明表 + 纯函数规则 + 渲染器,含「留空=不改」掩码语义)泛化成
  schema → 控件(src/admin/schema/),复用它渲染插件表单/表格/事实面/日志
- 可测判据:新增插件页不得改动 sections.tsx / router.ts / AdminApp.tsx 的静态清单
- when 不满足 ⇒ 入口不出现;viewer 只看到只读区块

我的复跑:npx tsc --noEmit 通过;npx vite build 通过(3.67s);50 个前端脚本式测试全部
打印 checks passed(含本片新增 pluginData 90 / PluginPanel.render 55 / navModel 62 / schema values 40)。
2026-10-06 09:04:51 +08:00
AskaEth ede00d9928 feat(voice): 语音消息落库 + 转码接入(切片 0020)
- 迁移 0020_chat_message_voice:按 design/24 §10 与契约 §7.2 已登记的 voice 形状建列/索引
- 语音回合进编排并落库;voice 引用与统计如实回填(转码/转写没成功就如实标未成功,不假装)
- 复用既有 internal/capability/transcode:非 wav(微信 AMR 等)先转码再送 ASR;失败可分类且不丢消息

⚠️ 部分完成(如实登记):生产端到端未成立(本片只到落库与转码接入),且**未经验真机**。
2026-10-06 09:04:06 +08:00
AskaEth 120d926cf8 feat(search): 搜索 provider 可变源 + 插件声明面板(PLG-4)
- 新增 $DATA_DIR/config/plugins/web-search.json(0600 + temp+rename,文件名 = 插件 id)
- 组合根改「先读可变源、缺失回落 .env 播种」;**不新增任何 env 键**(.env 已冻结)
- 多实例:每家 provider 独立 key/baseURL/启用态 + 「测试连通」探针
- 用 PLG-1 的声明库给出该面板的 adminPanels 声明;动作名交给 PLG-2 的通用动作端点分发
  (本片自己不开路由)

⚠️ 本片自认越界:改了 cmd/cyrene/wire_web_search_test.go(既有测试,为适配 DataDir);
  另有 design/28 的相关注册留给主会话。
2026-10-06 09:04:04 +08:00
AskaEth 2d21aa553e feat(plugins): 插件管理器接线 —— 面板接入点快照 + 动作分发(PLG-2)
- GET /api/v1/admin/panels:按调用者角色过滤后的接入点快照(PLG-1 的 catalog 挂进插件管理器)
- POST /api/v1/admin/plugins/{id}/actions/{action}:**只接受该插件声明过的动作名**
  (未声明 ⇒ 404);写动作 writeAuth + 落审计;插件只给动作名不给 URL ⇒ 不给插件开任意请求面
- 生命周期:install/uninstall/enable/disable 变化后**重算面板集合**并经进程内 bus 推 panels_changed
  (前端可热更新;不新开端口)

契约 v0.78 登记由主会话随后统一落(本片报告给了可直接粘贴的契约段)。
2026-10-06 09:03:57 +08:00
AskaEth c907a34510 feat(gateway): 面 1 补齐 —— 消息读取面 toolCalls[](工具回看)
前提由 FV2 提供(图自 v0.77 起带每路 turnId、每路各落一行)。本片:
- 迁移 0023:flow_graphs 加「按路查询键」(写入时回填图的 TurnID),老行按缺席处理
- GET /api/v1/sessions/{id}/messages 加**可选** toolCalls[],形状严格照 Docs/api-client.md
  v0.76 ① 已登记的:{name, status(ok|error|unknown), durationMs, argsSummary, resultSummary}
- 🔴 图取不到 ⇒ **字段整体缺席**(绝不回空数组冒充「没调过工具」)
- 一条批量 SQL(按页内 turnId 集合),禁 N+1;单条 ≤20 个工具、resultSummary ≤500 字符(常量+依据)
- 只放图里已有的、已脱敏内容,不放宽脱敏通道
2026-10-06 09:03:55 +08:00
AskaEth 99a644d545 feat(channel): 微信 iLink 账号状态三态(诚实口径)
state ∈ 有效 / 被拒(需重扫)/ 未知;🔴 -14 是**被拒不是过期**,且**未知原因绝不写成「过期」**
(面板文案与错误码都如实)。三态判定为纯函数 + 三态各有用例。

⚠️ 未做(如实登记):每账号逐条三态(accounts[] 数组上的三态)本片未做。
2026-10-06 09:03:53 +08:00
AskaEth 7505c46b70 feat(channel): 微信 iLink 账号状态三态(诚实口径)
state ∈ 有效 / **被拒(需重扫)** / 未知;🔴 -14 是**被拒不是过期**,且**未知原因绝不
写成「过期」**(面板文案与错误码都如实)。三态判定为纯函数 + 三态各有用例。

⚠️ 未做(如实登记): 三态(每账号逐条三态)本片未做。
2026-10-06 09:03:44 +08:00
AskaEth f75e7a9c9b feat(skills): 技能加载/校验/真执行第一片(design/09 §6 定稿落地)
- 加载器/校验:目录式 configs/skills/<id>/skill.yaml(兼容旧单文件 <id>.yaml,先目录后文件);
  新字段 version/when-to-use/invocation(三态)/triggers/paths/allowed-tools/disallowed-tools/
  context(ine|fork)/agent/model/effort/metadata;**依赖环 ⇒ 加载期拒**(运行期绝不挂死);
  depends 指向不存在步骤 ⇒ 拒;同一工具同时在 allowed 与 disallowed ⇒ 拒;错误可分类(哨兵 + %w)
- Runner.Run 真执行(替换 ErrRunPending):步骤拓扑(无依赖可并行、上限 2)+ guardrail 强制点
  (enforced 违反 ⇒ 中止并如实报「哪条、哪一步」;否则 WARN 继续)+ 失败不静默跳过 + ctx 取消传播
- 🔴 能力收缩只收不放:该轮可用工具 = allowed-tools ∩ 安全闸 ∩ 注册表,再减 disallowed-tools
- 另有渐进披露相关的 listing/surface/activation(清单投影与激活判定)

⚠️ 未接线:本片是库与执行器,尚未把技能清单接进模型面(见 report 的分阶段)。
2026-10-06 09:03:35 +08:00
AskaEth 29181245ce feat(browser): 浏览器配置与探测库(internal/capability/browsercfg,纯库零接线)
按 design/28 §2/§3 的「配置不进 .env」口径:读 $DATA_DIR/config/plugins/browser.json
(0600 + temp+rename 范式,**不读 env、不加新键**);有头/无头、CDP 端点、profile 目录、
视口/超时/并发(1)/截图上限全部类型化 + 常量默认值;**探测优先级**(本机 Chrome/Chromium
可执行文件 → 已在跑的容器)并返回可分类的「为什么不可用」(供上层如实降级,不假装可用);
profile 目录含凭据 ⇒ 0600 + 不进 git/不进上下文的注释与校验。

本片零既有文件改动(不注册工具、不碰 cmd/),接线留 B2。
2026-10-06 09:03:34 +08:00
AskaEth b7fb4dd1c7 fix(gateway): 配置事实↔模板的守卫改为「在模板里 / 显式豁免带理由」
由来:主人 2026-10-06 红线把 `.env` 冻结为「只保证最小化功能可跑」的引导面 ⇒
调优旋钮不再进模板(我已把 MEMORY_TIDY_COMPARE_LIMIT 撤出),但既有测试仍要求
「每个事实键必须逐字出现在 .env.example」⇒ HEAD 上红一条。

改法(收窄不是放弃守卫):新增**封闭**的 envExampleExemptKeys(键→理由),
每个事实键满足「在模板里」或「在清单里且理由非空」;并加**反向断言**:
清单里不许有非事实键、也不许有其实已在模板里的键(防清单变垃圾桶)。

我的变异:往清单塞 NOT_A_FACT_KEY ⇒ TestAdminConfigFacts_KeysMatchEnvExample FAIL;
还原后复绿。
2026-10-06 09:03:32 +08:00
AskaEth 180128c207 docs(contract): v0.77 登记 —— graph_update 按路分桶 + MT-A 事实/异步口径 + MT-B 旋钮说明
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
三批一并登记(都由 DOC-1 文档代理按代码逐处核对,含 文件:行 证据;我复核了四个核查点):
① FV2:graph_update 帧加 turnId/round(omitempty)、图按 {会话}#{路} 分桶(单路逐字不变)、
   GET /admin/flow/graph 加 lanes[](恒非 null,graph 仍是「最新一路」⇒ 老读取方零改动)、
   新增 §4.11(d) 专写增量键与缺席语义;§7.4-19「两路互顶未修」→ 已在 v0.77 修复;
   §7.4-23 改写为 4 条(明写「toolCalls[] 前提已具备但本身仍未实现,别读成已实现」);
   新增 §7.4-24(真并行未做真机端到端 + 已知窄竞态)
② MT-A:事实面 12→13(MEMORY_TIDY_COMPARE_LIMIT)、候选默认 20→200、truncated 第 4 档 (budget)、
   异步实现口径(并发复用 runId / 同一 3 分钟 / result 逐字节同形 / running 时双缺席 /
   重启 404 tidy_run_not_found)
③ MT-B:4 个旋钮写清「不进 .env(.env 已冻结)⇒ 有效行为=代码默认值,调优入口待接线」;
   它建议的两条 admin 端点只进「未实现」清单(机器证据:代码与前端 zero 命中)

顺手清掉我早前一次编辑在版本行留下的重复文本(DOC-1 指出但按边界没动)。
2026-10-06 08:23:49 +08:00
AskaEth 3d7af019d4 docs(env): .env 冻结为「只保证最小化功能可跑」的引导面(主人红线)
- 撤出今日新增的 env 键:MEMORY_REVIEW_*(我加的 4 键)与 MEMORY_TIDY_COMPARE_LIMIT
  (MT-A 加的 1 键)⇒ 代码默认值 2000 / true / 256 / 200 / 20 仍然生效,行为零变化
- 既有条目保持现状:MEMORY_TIDY_CANDIDATE_LIMIT 的默认值 20→200 属**既有键**的如实更新,
  模板必须跟(冻结不等于让文档说谎)
- AGENTS §10 的协同 E6 改写:原「新 env 键必须同步模板」作废 ⇒ 「新键不进模板;
  确属最小化引导必需者须主人明确批准」;既有键与既有 env: tag 保持现状(不迁移)
2026-10-06 08:23:48 +08:00
AskaEth 89434eadef fix(flow): graph_update 按路分桶——修真并行两路的图互相覆盖(FV2)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
【取证(症状成立,且比登记描述更糟)】两路真并行时两路最终落盘**同一个 turn_id**,
其中一路的图彻底消失,且 A 路的工具被画在 B 路的图上。逐层定位:发布器 graphs 只按
sessionID 建键(后起的路整张顶掉前者)· 帧载荷基线的键集被既有测试精确断言为
{id,nodes,edges,sessionId,startedAt,endedAt}(无任何区分路的键)· 前端每会话单桶 ·
落盘 (session_id,turn_id) 唯一键两路相撞 · 冷启动 LastGraph 只有一张。
该 bug 早已公开登记(design/15 §2.5.10-1「本轮未解决」、契约 §6-19「仍**未修**」)。

【修法】
- 归属桶 {会话}[#{路}](loop.FlowOwnerKey,与 checkpoint 键同字符口径);路只来自
  ParallelLegFrom(ctx) ⇒ 单路逐字不变(桶 = 会话 id),既有调用点语义零改动
- 图 id 的第二分量改用**建图序号**(每次 +1、永不复用):同一轮里可以有并行路 +
  队首续跑的串行路,用轮次号会让两条串行路撞同一个 web-1
- 轮次号只在上一轮的路全部收尾后推进,并丢掉更早轮次的桶(否则内存随路数无界增长)
- 新增只读出口 LatestLanes ⇒ GET /admin/flow/graph 的 lanes[](最近一轮每一路),
  graph 仍是「最新一路」= 向后兼容
- 同源洞一并修:子代理归属原取 AgentSpec.ParentSession(会话、不含路)⇒ 并行路里派的
  子代理 span 被静默丢弃;新增 AgentSpec.FlowOwner 承载「会话#路」,ParentSession 继续
  只承载会话(总线载荷 subagent_result.sessionId 不受影响)
- 前端:store 增 flowLanes(按 turnId 分格、按轮淘汰、硬上限 4)+ LaneTracks 泳道卡;
  单路/未给 lanes 时渲染路径与改前逐字相同

【无迁移】turn_id 是自由文本、每路 id 唯一 ⇒ 0022 的唯一索引天然分两行(0023 仍空着)

【我的验收】① 隔离 worktree(30 文件)build/vet/linux/darwin 全过、69 包全绿 0 FAIL;
② 我亲手打红变异:让 FlowOwnerKey 忽略路 ⇒ TestFlowPublisher_ParallelLegsDoNotOverwriteEachOther
FAIL;③ 前端独立复跑 npx tsc --noEmit + npm run build 通过(4.12s)

⚠️ 契约 v0.77 的登记在紧随其后的 docs(contract) 提交里(同一个工作时段内)。
2026-10-06 08:16:03 +08:00
AskaEth c9144c1096 feat(plugins): 插件「管理面板声明」库——声明模型 + 闭集校验 + catalog(design/09 §5.2/§5.2.1)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
纯库、零接线:不碰 gateway/frontend/Docs/.env*(本片刻意不接线,避免与在途切片争用文件面)。

- pkg/plugins/sdk:面板声明类型(JSON tag 与 design/09 §5.2 示例逐字同形);
  字段类型与 section 类型都是**闭集**;When 三态 + **严格 UnmarshalJSON**(未知条件键报错,
  不静默丢)
- internal/capability/paneldecl:闭集校验(47 条拒绝用例,断言的是**分类哨兵 + JSON 路径**,
  不是错误字符串)· catalog(并发安全、返回拷贝、按 (group, order, 登记序号) 的**全序**稳定排序)·
  when 求值(上下文由 Resolver 注入,本包不读环境变量/文件)· 角色过滤(viewer 只留
  status/log/table 且摘掉 rowActions,纯写面板整体隐藏;未知角色 fail-closed)
- 拒绝粒度:插件 id 非法 ⇒ 整插件拒绝;单个面板非法 ⇒ 只拒该面板 + 聚合显式报错(理由见报告)

【我的验收】隔离 worktree(19 文件)build/vet/linux/darwin 全过、69 包全绿;
我亲手打红变异:把同组同序的回退比较  反成  ⇒ TestCatalog_StableOrder FAIL。
(记录一条观察:比较器已靠 seq 构成全序,故 sort.SliceStable 的稳定性是冗余保护 ——
我第一次用「SliceStable→Slice」打不红,正是因为全序与算法无关;这不是测试虚护。)
2026-10-06 08:06:20 +08:00
AskaEth 6e28b38b08 feat(memory): 护栏抬高+时间预算闸+试算加性异步 与 D3 入库前审查(耦合两片一起落)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
【MT-A:护栏 + 诚实截断 + 试算异步】
- maxTidyCompareItems 400(写死)→2000 并开放为 MEMORY_TIDY_COMPARE_LIMIT;
  MEMORY_TIDY_CANDIDATE_LIMIT 默认 20→200(语义不变,仍每分区)
- 依据是首跑实测:459 条的分区被 400 截断 ⇒ compared(705) < scanned(784),
  候选数(77)由上限而不是规则决定;三处上限的 WARN 都补齐了总数/上限/分区名
- 新增「上限 + 时间预算」双闸(实测 2000 条/分区 ≈234s > 3 分钟墙钟):
  整分区跳过 / 两两比对中途收手,一律如实记 (budget) + WARN
- ?async=1 ⇒ 202 {runId,status};GET …/{runId} 轮询(writeAuth 同档);
  done 的 result 与同步 data 逐字节同形;并发=复用既有 runId、TTL 30min 自完成起算、
  重启即如实 404 tidy_run_not_found;不带 async 时同步路径逐字不变

【MT-B:D3 入库前审查(design/23 §15.1)】
- 新记忆经 Store.Add(仓库里唯一 INSERT INTO memory_items)落库后,由独立记忆管理
  会话(sess-memorytidy)在有界后台队列异步判定 skip/merge/modify/create
- 软合并回写(status + merged_into/merged_from/merge_count,可 revert、绝不改 content)
- 隔离来源(群聊/陌生人)**概不审查**、召回/超时失败**零改写**(fail-safe 结构性成立)
- 写入侧仅 +62.7µs/条(非阻塞入队);默认 ENABLED=true(主人裁定:入库前先审查)

【耦合说明(重要)】两片同包且互相依赖:MT-B 的 preinsert_recall.go 用 MT-A 的
collectTidyPairs 新签名 + tidyBudget ⇒ **各自单独都 build 不过**,故一并落地。
MT-A 为让整包编译,最小幅度适配了 MT-B 的 preinsert_recall.go / preinsert_test.go
(零值预算=行为不变;符号重名改名)。

【我的验收】隔离 worktree(37 文件)build/vet/linux/darwin 全过,切片包全绿;
我亲手打红两个变异:① 去掉 (compare) 截断标记 ⇒ TestTidyCompareLimit_HitIsLoud 红;
② 中和隔离来源守卫 ⇒ TestReviewRestrictedSourceIsNeverTouched 红。
唯一红是 internal/runtime/tool 的两条超时用例(该包无人改)⇒ 主仓库 -count=8 全绿,
判定为负载敏感 flake(当时两个整树验收 + 子代理并发),已记入开发习惯与约定 §7。

.env.example 补 MEMORY_REVIEW_* 四键(MT-B 无该文件权限,由我按其代码语义补写)。
2026-10-06 08:04:12 +08:00
AskaEth fe4c702f87 feat(gateway): 未读面 unreadCount 进 message_persisted 帧(契约 v0.76 ②)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- broadcastPersisted 是 Web 与 IM 两条路的**共用**函数 ⇒ 两处口径天然一致,不存在分叉
- 口径 = 绝对未读 + 主人单一已读游标(chat_sessions.read_at)⇒ 多端共享
- 可选键 omitempty:只在 >0 且确实算得出来时出现;0 与「算不出来」都不顶位
  (0 只能从「会话有游标」推出,否则那个 0 就是编的)⇒ 缺席 = 老行为
- Docs/api-client.md:v0.76 ① 按实测改标「未实现且无实现路径」(见下),② 已实现,
  并更正「GET /sessions 的 unreadCount 自 D4 起已在」、新增 §4.13 与 §7.4 第 23 条

⚠️ ① toolCalls[] 本版未落地(取证结论,非推断):chat_messages.turn_id(编排器随机路标识
turn-<16hex>)与 flow_graphs.turn_id(trace.FlowGraph.ID = <session>-<轮次序号>,进程内递增)
不同源,真实库中 66 条消息行 vs 21 条图行字面交集为空 ⇒ 无法 join;按时间窗硬凑属于猜,不做。
补齐路径:链路图带回合标识(新键 + 迁移 0023 起)+ 落盘列,之后 toolCalls[] 是一条批量 SQL。

我亲自复跑的变异:去掉帧里的 n > 0 守卫 ⇒ TestBroadcastPersistedUnreadCountAbsentWhenUnknown
与 TestBroadcastPersistedCarriesUnreadCount 双双 FAIL(断言「0 不该顶位」)⇒ 测试是真护栏。
2026-10-06 07:58:42 +08:00
AskaEth cb232e7360 docs(contract): v0.76 登记三面(工具回看 toolCalls[] / 未读 unreadCount / 记忆整理试算加性异步 B15)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- ① 消息读取面 toolCalls[]:数据源 = 已落盘轮次链路图(flow_graphs/0022,168h),
  不从正文反解;图取不到 ⇒ 字段整体缺席(绝不回空数组冒充「没调过工具」)
- ② message_persisted.data.unreadCount + GET /sessions 列表项同键;
  口径 = 绝对未读 + 主人单一已读游标(chat_sessions.read_at)⇒ 多端共享(与安卓已对齐)
- ③ 记忆整理试算加性异步:?async=1 ⇒ 202 + runId,新增 B15 轮询端点;
  不带 async 行为逐字不变;结果仅进程内存(TTL 30min / 并发 1 / 重启即失效 ⇒ 如实 404)
- 三批均标注「契约先行 · 尚未落地」,实现落地后回改并去掉标注
2026-10-06 07:42:19 +08:00
AskaEth 6233ece1fa docs(env): .env.example 统一收两片新键(BT1 静默间隔 + WS1-B 搜索 provider 15 键)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
两片共用该文件 ⇒ 按既有教训(多片共用文件不拖到批次末、也不逐片吞并)单独一处提交;键名与代码逐字一致(E6 自查:WEB_SEARCH_* 代码 15 ↔ 模板 15)。
2026-10-05 22:38:21 +08:00
AskaEth 69a3b73777 feat(search): 原生 provider 层(searxng/智谱/阿里IQS/博查/火山/百度)+ 降级链(契约无改动)
主人定调「先把原生的做进插件,MCP 路线等后端 MCP 完善后再接」。
- 新包 plugins/tools/io/search/:一 provider 一文件 + 共享 http/提取器 + 归一化 Result;域名过滤/去重/排序**归一化后统一做一次**
- 配置 15 键:WEB_SEARCH_PROVIDER(默认 searxng)/_FALLBACK/_MAX_RESULTS + 五家 ×(API_KEY/BASE_URL);WEB_SEARCH_TIMEOUT 语义改为「**每次上游尝试**」(降级链才有意义)
- 硬语义:未注册/未配置的主 provider ⇒ **启动期报错**(不许静默回落);off ⇒ 不注册工具;缺 key 的家不进可用清单;降级链**如实标实际应答的 provider**(变异:拿主力冒充 ⇒ 测试红「provider = zhipu, want bocha」)
- 密钥绝不进日志/响应(有断言);错误分类(鉴权/限流/额度/网络)+ **空结果 ≠ 成功**(变异暴露过一个死分支⇒下移到 searchWith 修掉)
- searxng 真机 e2e 通过(provider=searxng 5 条);**其余五家无 key ⇒ 如实标未实测**;契约无改动(无新端点、工具 schema 不变)
- 注册表留好 B 档接入位(tavily/exa/firecrawl/serper/serpapi),并有「未登记⇒启动报错」的防回归断言
2026-10-05 22:38:20 +08:00
AskaEth 236b202fda feat(backthink): 静默时段心跳降频(A)—— THINK_QUIET_INTERVAL_SEC 默认 3600
主人裁定「先做 A(静默降频)再转向 C」;背景实测:静默期**思考频率不变**(恒定 900s 心跳),只拦打扰 ⇒ 成本照付。
- 新键 THINK_QUIET_INTERVAL_SEC(默认 3600):窗口内用该间隔、窗外仍用 THINK_INTERVAL_SEC;**≤0 = 关闭降频**(不许出现「半夜完全不思考」暗坑);窗口关闭/钟点非法 ⇒ 不降频
- 心跳**每拍按时刻选间隔**(放到循环外就永远不生效——变异实测能打红)
- 🔴 不变量:**事件驱动不被降频影响**(TestBackthink_EventTrigger_NotSlowedByQuietInterval 钉住)
- 事实卡 9→12 条(+思考总开关/普通间隔/静默间隔,标「改需重启」);契约形状未变、未升版
- ⚠️ 如实登记:ENABLE_BACKGROUND_THINKING 是**死配置**(全仓无消费者),已在 env/面板标签/契约三处标注,**是否接线待主人拍**
- 变异 5 条全红;C 的缺口清单(唤醒面 7 处)已回给主会话排期
2026-10-05 22:38:17 +08:00
AskaEth d6ed9cfa2b feat(memory): 记忆整理「试算(dry-run)」入口 + 面板卡(契约 v0.75)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
主人要「现在就想看到在 736 条上它会建议合并哪些」,而不是等 23:00 的低峰首跑。
- 后端:Tidier.DryRun + TidyService.DryRun,**复用同一条扫描→召回→判定→构造候选路径**(抽出 tidyRoundPolicy,生产轮与试算轮只差「候选出口」与「召回降级策略」两处;禁止第二套实现)
- 🔴 零副作用:不写 memory_tidy_candidates / memory_tidy_runs,绝不改 memory_items 任何列;机械证明 = 跑前跑后 cyrene.db 与 -wal 的 SHA256 一致(测试断言「表行数不变」)
- 失败码诚实:500 tidy_unavailable / 409 tidy_disabled / 500 tidy_recall_unavailable(**embedding 不可用不降级成 0 条**)/ 503 tidy_timeout / 500 tidy_dry_run_failed;**空数组唯一含义 = 算过了没建议**
- 端点 POST /api/v1/admin/memories/tidy/dry-run(writeAuth);面板「记忆」页加试算卡(点了才跑、无轮询、三态分开、明写「仅试算,不会改动任何记忆」)
- 真机 e2e(741 条活库副本):76 ms / 15 分区 / 53 候选 / 41 skipped / 1 vetoed(low_confidence);样例 merge reason=fact_subset score=1.0000、link reason=similar_but_not_contained score=0.9248
- 变异:dry-run 顺手写库 ⇒ 两条测试红(行数 0→2);自造阈值 ⇒ 红;失败回 200 空数组 ⇒ 红
- 如实登记:Compared=688 被 400/分区护栏截断、候选上限 20/分区被打满 ⇒ 护栏值需在 741 条量级重定(非 bug)
2026-10-05 22:06:54 +08:00
AskaEth 3d8c30dc99 chore(searxng): 引擎裁剪——mwmbl 转正主力(实测 133 条),bing 静默返空/sogou 会 CAPTCHA
- 2026-10-05 深夜复测:mwmbl 稳定(133/120 条,unresponsive 为空);bing 静默返空;sogou 报 CAPTCHA(此前可用,属上游限流)
- 保留 bing/sogou(会随冷却恢复)但**不是主力**;文档里注明「本机只有 mwmbl 稳定 ⇒ 兜底定位是有总比没有好」
- 端口文档从 8888 修正为实际的 38471(避免照抄撞端口)
2026-10-05 22:06:52 +08:00
AskaEth 8e6c77ad10 feat(search): web-search 接线本机 SearXNG 兜底 + 显式开关(她不再拿一把用不了的锤子)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
主人报的故障:她调 web-search 必失败(no search endpoint configured)。根因两条:组合根从未注入 WithSearchEndpoint + 配置层根本没有搜索配置项。
- internal/config/websearch.go:WEB_SEARCH_ENDPOINT(默认本机兜底 http://127.0.0.1:38471)+ WEB_SEARCH_TIMEOUT;ResolveEndpoint() 三态裁决(可用/显式关闭/非法),非法值启动即报错
- cmd/cyrene/wire_io_tools.go:组合根唯一的 io 选项装配点;端点不可用 ⇒ WithoutSearch() + 带 source 的 WARN(只记键名与原因,不打印 URL 值)
- plugins/tools/io:新增 WithoutSearch()(**根本不进注册面** ⇒ 模型看不见)+ WithSearchTimeout();协议解析逻辑一字未动
- 隐蔽坑(额外查出并修):SearXNG 对 /?q= 回 308 → /search,重定向那一跳走**共享 SSRF 白名单** ⇒ 只注入端点照样被拦。改为**只给 web-search 一件**加宽自己的客户端与白名单,共享白名单与 http/web-fetch 客户端一字未动(有测试钉住它仍拒绝本机网关地址)
- .env.example 新增 2 键 + 注释说明「空串/off/none = 不注册」
- 真机 e2e 两次绿(SearXNG 返回 9 条);变异:不注入端点 / 关闭态仍注册 / 默认值改错 / 不并入端点主机 / 重定向用共享白名单 ⇒ 5 条全红
2026-10-05 20:29:21 +08:00
AskaEth 8e56163418 feat(admin): Flow 页会话选择器 + 修「泳道 0」文案(纯前端)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 新增 flow/flowSession.ts(白名单判定与后端 flowSessionAllowed 逐条对齐 + 选项构造 + 选中回落 web + 取数接线 flowPageData)+ FlowSessionPicker.tsx
- Flow.tsx:图不再读兼容字段 s.flowGraph / activeSessionId,改由 flowPageData 从 flowGraphs[选中会话] 取;选择器只在给了 onSelectSession 时渲染(既有调用形态不变)
- 修文案:0 泳道时统计卡显示「—」+「本回合未派发子代理」(原先 0 紧挨「泳道」会被读成「泳道 0」)
- 标签单一出处:FlowSnapshotHint 改用 flowSessionLabel(避免与选择器漂移)
- 测试 33 + 21 checks;变异:取图改回主会话桶 / 选项去掉 sess-backthink / 泳道卡还原 / 放行白名单外会话 ⇒ 全红
- 如实登记覆盖缺口:组件调用点那一行无法自动化(无 jsdom,zustand SSR 快照取不到真实 state)⇒ 有一条变异打不红,靠「取图路径唯一 + 注释」缓解
- ⚠️ 行为变化(任务书要求):默认选中固定为 web,不再随 activeSessionId 漂移(原先跟着激活会话走)——一行可改
2026-10-05 20:00:01 +08:00
AskaEth e792fe655b chore(deploy): 本机 SearXNG 兜底搜索服务配置(宿主端口 38471,按可达性裁剪引擎)
- 用途:给 web-search 工具一个本机可达、零 API key 的搜索端点(外部服务,如 docker cyrene-pg,不碰单端口红线)
- 实测踩坑都写进注释:① search.formats 必须含 json(默认只给 html)② server.limiter 必须 false(限流器会拒 API)③ **引擎必须按本机网络裁剪**——默认集 google/duckduckgo/brave/qwant/startpage/wikipedia 全部 ConnectTimeout,baidu/quark 被 CAPTCHA,360search 超时 ⇒ 只留真在产出的 bing + sogou
- 宿主端口 38471(避开常见端口);容器内仍是 8080;--restart unless-stopped
- 自检命令写在文件头:要求「非空 results 且 unresponsive_engines 为空」
2026-10-05 20:00:00 +08:00
AskaEth 14e76b6480 feat(admin): 流程图方向——Flow 页自下而上(最新在上),Backthink/Agents 图块最新在左
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
主人要求:① 全链路流程页从下往上渲染(时序自下而上 ⇒ 最新一定在最上)+ 容器高度自动拓高;② 主动思考页保持原渲染方式但最新放最左(容器默认露最左);③ 追加:改布局后多路渲染仍要合适。

- 共享布局唯一入口 flow/layout.ts:新增枚举 FlowOrientation(newest-top|newest-left) + layoutFlow(graph, orientation);排布拆成「抽象网格→像素」两步,方向开关只在 toPixelNode/toPixelBand;泳道成道逻辑零改动
- 边几何抽到新文件 flow/edges.ts:只按方向选锚点,带回 fromId/toId(不重排图数据),悬空边如实丢弃
- FlowGraphView 的 orientation 是**必填** prop(漏传=编译错误);画布不限高、无 overflow-y(不内部裁切,纵向随图生长);图例加方向小字
- 三处调用同一套布局:Flow=newest-top、Backthink=newest-left、Agents=newest-left(后者是我按同一档裁定,一行可改)
- 顺带修复:画布最大列现在把「刚开出、还没跑出步骤的泳道」最小宽度算进去(否则泳道框被裁到画布外)
- 测试 593 checks(orientation 逻辑 131 + 渲染 338 + lanes 40 + 既有回归);变异 7 条全红,含「边端点连到错节点」「容器限高裁切」「空泳道被裁」
- 多路:lanes.test 期望随新方向镜像更新,泳道仍成道;并行路数据互相覆盖的已知问题归后续分桶切片
2026-10-05 19:51:45 +08:00
AskaEth f4adf0b526 feat(notify): IM 路由的主动消息也在非前台补浏览器通知(契约 v0.74)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
主人裁定 C 的必然推论:她该不该提醒我,与后端走了哪条路由无关。P0 如实报回的缺口 = 通知只挂在 proactive_message 帧上,而 auto 提示在有已连接 IM 渠道时优先走 IM ⇒ 最常发生的路径恰恰不弹。

判据(先查现状、不猜):行字段**区分不出**主动推送与普通回复(已用真库证据排除 platform/adapterId/turnId 反推)⇒ 新增**结构化的正向声明**:帧顶 data.proactive === true,只由主动推送那条通道打(gateway.BroadcastProactivePersisted);普通路径永远传 false。
- 后端:channelbridge 抽出唯一写入口径 persistMain + 新增 proactive_notify 接缝;gateway 抽出 broadcastPersisted(msg, proactive) 两入口共用
- 前端:handlers/proactive-notice.ts 两条帧路共用同一套提醒运行时(有界去重 + 降级说明 + DOM/Notification 取值);message.ts 读到判据才走提醒
- 对照断言(必须有):普通 IM 回复/主人提问/显式 proactive:false ⇒ **0 通知、3 气泡**(不误伤)
- 变异实测:去掉非主动保护 / 跨两路去重失效 / 前台也弹 / IM 不发判据帧 / 普通广播带判据 ⇒ 5 条全部打红
- 契约 v0.74(纯增量:message_persisted 新增可选 proactive:true)
- 边界(如实):判据**不落库**(翻历史不弹提醒,也不得从行字段反推);落库失败时不发判据帧(无权威 id 的帧前端整帧丢弃)
2026-10-05 19:47:54 +08:00
AskaEth a6cb44b8a8 feat(flow): 链路图本地落盘 + 多轮历史回放(design/19 §P3,契约 v0.73)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
主人要求「链路流程应该有本地数据,不然每次重载之后页面就没数据了」:
- 落盘:迁移 0022_flow_graphs(session_id/turn_id/graph_json/时间戳 + 唯一键 upsert)+ internal/runtime/flowgraph(RecordRound/Graph/Latest/Turns/Turn + 保留期 GC)
- 写入:loop.EndRound **每轮一条**(锁外深拷贝后写;失败 WARN 且不打印图内容);只落发布会话白名单(复用 flowSessionAllowed)
- 读回:端点加 turnId + source(live/persisted/none) + turns[](最新在前,空为 []);**内存优先、miss 才落盘**(最新一轮永远最新)
- 前端:flowGraphs 纳入 persistOptions.partialize(**刷新即刻有图**,version 3→4 + migrate)+ 历史轮选择器 + 诚实文案;顺带收 L1 转交的 flow/NodeDetail.tsx 节点日志倒序
- 新 env 键:FLOW_GRAPH_RETENTION=168h / FLOW_GRAPH_GC_INTERVAL=1h
- 变异实测:每 span 一行 / 保留期守卫失效 / partialize 去掉 flowGraphs / 去掉内存优先 / 白名单失效 ⇒ 全部打红
- 3 个禁动面文件的纯追加改动(Flow.tsx 挂选择器、FlowSnapshotHint 纠已成假话的文案、其渲染断言随之更新)由主会话裁定认可
- 登记存疑:图桶持久化无体积上限;useFlowTurns 的 effect 无自动化证据(项目无 jsdom)
2026-10-05 16:19:32 +08:00
AskaEth 84c124f5a9 fix(backthink): 她主动找你终于看得见 —— 主动消息落主会话 + 前端消费 + 非前台浏览器通知
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
修主人报的硬故障「后台思考的主动推送一个都没有推出来」(根因两条,缺一不可):
- 后端 gateway/proactive.go 只广播 proactive_message 帧、**从不写会话历史** ⇒ 刷新补不回来
- 前端**零消费点** ⇒ 帧被静默丢弃(既无气泡也无通知)

后端:新增 proactive_persist.go(**先落库、再发帧**;web/assistant/mode=chat;未接线时空操作且不谎称已落库)
  载荷新增 id/createdAt(落库行权威 id);PushResult.Detail 三态如实分开报告「投递到几个连接」与「是否落库」
前端:handlers/proactive.ts(气泡**恒落** + 按落库 id 去重 + **非前台才补通知** + 拒绝授权/畸形载荷可见降级)
  + lib/notify/proactive.ts(纯逻辑 isBackground/decideProactiveNotice/有界去重表)
  + handlers/task.ts(另三个被丢的帧 task_done/automation_result/subagent_result)
  + ws/resync.ts + useWsResync.ts(**重连重跑本页引导读取**,首次连接不跑)
双份核查(带三层证据):现状不双份 —— 路由分叉「一次 Push 至多一个 sink 成功」,且 IM 路由本来就把主动消息镜像回主会话恰好一次
契约 v0.72;变异实测:只发帧不落库 / 写成 user 角色 / 去重失效 / 前台也弹 / IM 成功再打活跃 ⇒ 全部打红
2026-10-05 16:02:36 +08:00
AskaEth 1bf85a955b feat(gateway): 链路图冷启动快照(v0.71)+ 面板来源说明条 —— 修「轮次结束后打开页面一片空白」
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 断点已证 = **时序层**:graph_update 只走实时帧从不重放、flowGraphs 不持久化、补拉通路此前根本不存在(EndRound 注释承诺过「刷新能重放」,代码里从来没有)
  铁证:15:16:38 页面重载抹掉 15:09 那轮图,而下一轮心跳要等 ~15:24 ⇒ 打开后台思考页时桶必然为空
- 新增只读端点 GET /api/v1/admin/flow/graph?sessionId=(owner|admin;省略=主会话;graph 与 graph_update 帧的 data 逐字同形)
  无图 ⇒ 200 + graph:null(不画假图);图源未接线 ⇒ 500 flow_unavailable(不伪装成「没有图」);不审计、不发布
- loop.FlowPublisher.LastGraph:只读快照(不发布/不改图/不动轮次计数),FlowGraph.Clone 深拷贝避免锁外编码撕裂
- 前端 useFlowSnapshot:只在「本会话还没有图」时补拉(避免每行一个请求);Flow/Agents/Backthink 三处接入 + 来源说明条(明确「最近一轮」与「多轮历史回放尚未实现」)
- 变异实测:LastGraph 恒 nil ⇒ 4 条测试红;白名单去掉 sess-backthink ⇒ 对应测试红(首次变异因未使用 import 编译失败 ⇒ 已作废重做)
- ⚠️ 诚实边界:每会话只回**最近一轮**(多轮回放属 F2);白名单外会话(IM 群聊/陌生人)永远没有图;**后端重启后未跑新轮次前端点如实返 null(图是内存资产,不落库)**—— 主人要的「本地数据」正是 F2 要补的
2026-10-05 15:38:53 +08:00
AskaEth a3482ed06e fix(admin): 日志类列表统一「最新在上」+ 顶部前插不顶跑阅读位置
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 审计 15 个日志/事件面后统一口径:改 3 个(渠道总日志 / 每账号消息日志弹窗 / 适配器专属页实时日志),7 个本已合规未动,聊天页**刻意保持正序**(本质是读对话,正序+自动滚底才自然)
- 排序口径唯一出处 channelLogOrder.ts(历史+WS 追帧按 id 去重、时间倒序)+ 展示层不倒序(与 channelEventViews 同分工);三面共用 useChannelMessageLog 取数
- 新增 logScroll.ts:贴顶跟着最新走、已下读按新增高度补偿(**新条目不把正在看的内容顶跑**);窗口化容器的滚动锚定一并接上
- 后端无需改(三处消息日志读取本就是 created_at DESC,已核)
- 诚实边界:滚动锚定的 DOM 那半只有纯函数有自动化证据(项目无 jsdom/Playwright,渲染测试跑 react-dom/server、effect 不执行)⇒ 已如实标注
- 变异实测:排序改回旧→新 / 取消补偿 / 渲染层再倒一次 / 改坏共享文案 —— 四处均打红
- ⚠️ 两处未改(在别的切片文件面内,已转交):flow/NodeDetail 节点实时日志 → 归 F2;Agents 任务日志 → 归 H1
2026-10-05 15:37:04 +08:00
AskaEth c8053b1f36 feat(gateway): 渠道事件查询面 + 面板「渠道事件」页(design/26 E6)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 两个只读端点:GET /admin/channels/events(列表,8 个过滤条件 + 分页钳制)与 .../events/counters(逐类计数,恒带 approx:true 如实标「近似」)
- 诚实边界:>90 天窗口 ⇒ 400(不静默裁剪);未知 platform/档位 ⇒ 400;**未接线 ⇒ 500 channel_events_unavailable**(绝不回空 200 假装「没有事件」);微信平台的空如实说明「该渠道不提供非消息事件」而不借别平台顶替
- 面板:新 section「渠道事件」(计数卡 + 事件行 + 过滤条 + 30s 静默刷新),空/错误态分开且失败显示错误不显示 0
- 组合根接线(主会话补):wire_frontend.go 的 Deps 注入 EventStore: a.channelEventStore()(未接线时为真 nil ⇒ 保留诚实 500)
- 契约不升版(查询面 v0.67 已登记):就地改「未实现 → 已实现 + 端点路径」,并补登记 5 处原先留白的落地值(camelCase / 通用 bad_request / RBAC owner|admin / data.approx / total 语义)
- 顺带把 §5 三个 CHANNEL_EVENT_* env 键由「尚未实现」改「已接线」(E4)
- 变异实测:去掉 90 天上限 ⇒ status 200 want 400;未接线回空 200 ⇒ status 200 want 500
- 债务登记:events.Filter 无 AccountID 列 ⇒ 服务端暂不能按账号筛(列表仍如实下发 accountId),要加需先改 internal/runtime/events
2026-10-05 14:38:37 +08:00
AskaEth a0abf0199a feat(gateway)!: 后台思考档位精确化(+ 面板三态 + 契约 v0.69/v0.70)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- gateway:优先读落库的 rec.Urgency(精确档位,与安静时段窗口无关);仅空串才回落到既有下限推导,并打 urgencyLowerBound 标记(**只在推导值上为 true**)
- 面板三态:精确(例常/紧急(高)/紧急·安全域)|下限(一律加 ≥ 前缀)|未知;下限悬停说明写清「为什么旧行只能是下限」
- 实时帧零协议改动:Record 的 JSON 键就是 urgency ⇒ 帧自然带上;wsParse 早已防御性读取(只加一处 urgencyLowerBound 防御读)
- 契约:v0.69(directMsgPerMin 透传修复,逐字照抄附录 A)与 v0.70(精确化 + 更正 §4.10(b)「实时帧不带这两键」)一起登记
- 变异实测:旧行不标下限 ⇒ 3 条测试红(结构体对比);前端去掉 ≥ ⇒ 渲染测试红
- ⚠️ 诚实边界(§7.3 已登记):实时旧记录刷新前显示「档位未知」、刷新后显示「≥ 例常」——同一条记录两路下的已知差异
2026-10-05 14:15:55 +08:00
AskaEth f9c5b56a2f docs(env): .env.example 统一收三片改动(X1 允许集注释 / P1 directMsgPerMin / E3+E4 CHANNEL_EVENT_* 三键)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 三片各自落地时该文件同时含他片在途行,逐片提交一律排除它,故在这里一次性收(避免任一片吞掉别片的行)
- 修一个真实后果:E3+E4 的测试钉住「新 env 键必须同步模板」(AGENTS §10)⇒ 该文件未进 HEAD 时,检出 HEAD 跑全量会在 cmd/cyrene 红两条
- 键名与代码逐字一致;CHANNEL_EVENT_RETENTION/GC_INTERVAL 写 0 或负数 = 关闭清扫,COUNTER_FLUSH 写 0 回落到 30s(后者不是破坏性动作)
2026-10-05 14:14:14 +08:00
AskaEth 213aff2e33 feat(backthink): 存档落「紧急度」精确档位(裁决 14 的精确化前置)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- backthink_records 自建表加 urgency TEXT NOT NULL DEFAULT ''(**不开迁移号、不动 internal/db**;对既有库幂等补列:PRAGMA 判存在 + 容忍 duplicate column,其它错误照报)
- 落库值 = 该轮**实际生效**的档位(escalatedImport 折叠后的 roundImport;她自报可上抬、事件自带高档不被她压低)——「生效值优先」而非「她的话优先」,避免与既有 escalateImport 口径打架
- Record.Urgency 的 JSON 键刻意是 urgency ⇒ WS 实时帧自然带上、前端防御性读取即刻显示(不必等重开页面)
- 思考报错行 / 未唤醒降级行 / 历史旧行 = **空串(未知)**,绝不反推(变异:【空串当 routine】⇒ 4 条测试红;【补列非幂等】⇒ 41 条红,新库启动即失败)
- 生产影响:下次启动对真实 data/cyrene.db 执行一次单列 ALTER,并记 source=services/backthink 的 Info「backthink records table upgraded」
2026-10-05 14:10:25 +08:00
AskaEth afe9afb1b5 feat(events): 渠道事件桥接接线 + 组合根(design/26 E3+E4)
- E3:channelbridge/events.go 唯一插入点 recordEvent(两轴映射 Category→Tier、摘要恒过 SanitizeNoticeText、角色/账号只认白名单)+ Options.EventStore 注入
  - 未接线 store=nil ⇒ 内存计数 + 首次 DEBUG,绝不中断入站(变异实测:改成直接调 Record ⇒ panic,测试红)
  - 普通聊天消息零副作用(变异:去掉 Applicable 早退 ⇒ rows=1 红)
  - T1 遥测只计数不落行(变异:心跳误判 T2 ⇒ 「逐条落行 1 行,want 0」红)
- E4:wire_channel_events.go(启动期清扫 + 双 ticker + 退出 flush,逐字对齐 wire_document_gc 的三条取舍)、wire_channels 注入、main.go 加一行 startChannelEvents
- internal/config/duration_env.go:新 env 键的读取口径(进程环境 > .env > 默认;「没写」与「写了空」都当没配;非法值回落 + 报错)——避免「只写在 .env 里被静默忽略」
- 三个新 env 键:CHANNEL_EVENT_RETENTION / GC_INTERVAL / COUNTER_FLUSH(模板同批更新,测试钉住键名一致)
- 插入点比设计示意图晚一步(放 preEntryReview 之后,只归一化一次且 NoticeClass 已有结论),「留存无条件、在任何早退之前」这条性质不变
2026-10-05 14:10:24 +08:00
AskaEth 7aaf7731dc fix(gateway): directMsgPerMin 的写入路径 5 处透传(关掉「面板保存把 -1 静默重置为 0」)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 缺口:后端解析与面板都已就绪,但 gateway 没让该字段通过 ⇒ 面板保存一次渠道配置就把主人手配的「关闭流控(-1)」重置为 0=保护性默认 6 条/分
- 5 处:AdminChannelAccount DTO / channelConfigRequest / config.ChannelAccount 拷贝 / adminChannelAccountOf 映射 / 微信扫码重建账号
- 语义零改动(只透传);变异实测:删拷贝 ⇒ channels.json 变 0(want -1)、删微信重建拷贝 ⇒ 同红、删 GET 映射 ⇒ 回显缺失
- 顺带登记一条负载诱发 flake(capability/plugin 的 TestManagerInstall_ConnectFailureDoesNotPersist,靠「端口被拒」断言,满载时端口被抢)⇒ 非本切片引入,已进 00-索引 §四
2026-10-05 14:01:52 +08:00
AskaEth 321cc3b763 feat(backthink): 思考轮工具面补齐(裁决 15)—— 能力面接入 + 只读 capability_list + 本轮状态快照
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 缺口 d:状态在**思考前**读一次,同一份快照既喂思考轮又喂 Decide(读不到 = Known=false,绝不伪装成中性状态)
- 缺口 c:新增只读工具 capability_list(owner-only、三重输出边界、读写隔离),她可自查当下真实可用面
- 缺口 b:内置允许集补 transcode / capability_list(此前 CAPACITY_ENABLED=true 时新工具会从模型可见面消失)
- 缺口 e:guidancePrompt 旧文案承诺的「IoT/日历/提醒」在全仓根本没有对应工具(chat 侧也没有)⇒ 按诚实化改为「与主人对话同等工具面 + 指名自查入口」,不再有空头支票
- (a) 未做且**不自创**:那三把工具无任何设计规格(名字/schema/读写边界/输出上限)⇒ 已报回并登记进 design/20(裁决:走能力/插件/MCP 暴露路线)
- 变异实测:允许集去掉新工具 ⇒ TestDecide_AllowedSets + 组合根跨层测试红;丢掉状态快照 ⇒ TestLoopThinker_Think_InjectsRoundStateBlock 红
- 已知弱项(如实登记):组合根那行 wireCapabilitySurface() 调用本身无测试覆盖(与既有 wireTranscode 同惯例)
2026-10-05 13:54:57 +08:00
AskaEth 34cef95e17 feat(gateway): 配置只读事实面 + 后台思考行「档位/穿透」(契约 v0.68)
- 新增 GET /api/v1/admin/config-facts:记忆整理 7 键 + 安静时段 2 键的**进程生效值** + 声明默认值(反射读 default 标签,不读 .env)
- 面板:记忆整理卡 + 安静时段卡(Miuix 只读),文案按新语义「只服务主动搭话,不静默回复」
- 后台思考行加 urgency(**只能是下限**,存档不可分 high/critical)+ quietHoursBypassed(与既有 bypassed 是两件事)
- 红线实测:往响应里塞 JWT ⇒ TestAdminConfigFacts_NeverLeaksSecretValues 打红(连断言消息都把整个响应打出来了)
- 前端:configFacts 24 checks / ConfigFactsCards 29 / BackthinkUrgencyTags 19 全过
- 契约 v0.68(纯增量:B12 新端点 + B8 两字段 + §4.10 形状与语义 + §7.4-17 下限限定)
2026-10-05 13:54:56 +08:00
AskaEth a057a0f61f feat(channels): 流控可配 —— 陌生人私聊额度走账号配置 directMsgPerMin(裁决 13)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 后端:账号顶层 directMsgPerMin(缺失/0 = 保护性默认 6 条/分;正值 = 该值;负值 = 关闭并留带 source 的 WARN)
- 日志如实报出**实际生效**额度(adapter.(*FlowGate).DirectPerMin()),告警文案不再写死 6
- 面板:渠道配置表单加字段(含帮助文案)+ 回显/写回链路(LIMIT_KEYS 四键)+ 账号卡片摘要
- 文案纠错:AdapterConfigSummary 的 msgPerMin 由「私聊每分钟上限」改为「非主人消息每分钟总上限」(原描述是误导),并列补 directMsgPerMin 行
- 变异实测:缺键丢默认 / 负值当 1 条每分 / 前端吞负值 —— 三个都精确打红并还原
- ⚠️ 已知缺口(下一片补,gateway 面):GET/PUT /admin/channels 尚未下发/解码该键 ⇒ 面板暂时回显空、保存会把 -1 重置为 0
2026-10-05 13:50:29 +08:00
AskaEth ba17820040 feat(channels): QQ 非消息事件全枚举投递 + systemnotice 补 request/interaction/unknown 类
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 适配器不再整类丢弃:notice/request/meta_event/message_sent/未知 post_type/JSON 解析失败( decode_error ) 一律归一化投递并留痕
- notice 映射补 group_ban/sub_type 全枚举,未知类型走 NoticeUnknown 兜底(fail-safe 只登记、绝不猜、绝不放行)
- 不投影 flag/comment/file/duration;归一化 DEBUG 只记结构(脱敏,变异实测可打红)
- 传输层端到端已覆盖(真实 http+server 上报路径:加好友请求/心跳/未知类型)
- ⚠️ 主人 2026-10-05 决定「先不连 QQ」(风控)⇒ 本片为通用红线(绝不静默丢弃)+ 现成证据链,QQ 侧后续增量工作暂停
2026-10-05 13:47:27 +08:00
AskaEth ec3f6e810a feat(events): 渠道事件存储地基(Event/Store + SQLite 实现 + 有界计数聚合 + 保留期 GC + 迁移 0021)
- 新包 internal/runtime/events:Tier/Category/Platform/ChannelType/ActorRole 具名常量 + Event/Filter/Counter + Store 六方法
- 留存口径:T2/T3 逐条落行;T1 只计数;unknown / decode_error / link 三例外逐条落行(链路是否活着是事故定案唯一证据)
- 计数:内存聚合 + 上限 512 的有界缓冲 + 满即整批 upsert,落盘失败回填而不丢;GC:保留期 720h/间隔 1h,≤0 = 关闭
- 迁移 0021_channel_events(两表 + 6 索引,含计数 upsert 唯一键);Event 无 flag/正文/URL 字段(design/26 §4.2 红线)
2026-10-05 13:47:25 +08:00
AskaEth 52dac4575f feat(gateway): 账号在线三态(后端 AccountStatus 可选接口 + 面板第二枚标签 + 契约账目更正)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- known=false ⇒ accountOnline 键**缺席**(未知 ≠ 离线),与既有 connected 语义并列不合并
- 面板:账号在线/账号离线/账号状态未知 三态 + 中性灰 tone + 「这不等于离线」悬停说明
- 测试:gateway 跨层锁「键缺席」(用真 qq_obv11.Adapter)+ 面板 61/40 断言
- 变异:known 当离线 ⇒ 两条测试同时红(已复跑确认)
- Docs/api-client.md:§7.2 那行由未实现改已实现(留表,注明下版移入 §7.1)
2026-10-05 13:29:53 +08:00
AskaEth 7ae4298871 feat(channels): 陌生人私聊正常聊天(跳概率门)+ 强制流控 6条/分与回合槽位 + 安静时段不再硬静默 + 受限车道边界行前置
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 陌生人私聊:不再走「值不值得回」的概率门,唯一闸门 = 每发送者 6 条/分滑窗 + 同会话 1 个在跑回合
- 被限流只登记不回,且留带 source 的可见告警(不静默)
- 安静时段在回复侧只作信号(规则层不再静默),群聊取消夜间静默
- 锁定流控常量:FlowDirectDefault 加字面量钉桩测试(放大它 = 静默关流控,且会让额度循环跑百万轮)
2026-10-05 13:26:15 +08:00
AskaEth c6f5c3fea0 feat(capability): 转码能力装配进组合根(工具真进注册表、日志带 source)+ design/25 非bit-exact 留痕更正
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-05 13:23:52 +08:00
AskaEth f91a83ff81 feat(urgency): 通用紧急度通道(Declarer/Level + 只抬不压折算)+ IoT 首个生产者 + 她自己的结论走结构化标记穿透安静时段
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-05 13:11:04 +08:00
AskaEth 4a2d276881 feat(capability): 转码能力包(vendor 纯 Go AMR-NB 解码器 + 黄金向量逐样本对照 + 8 哨兵 + 注册面)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-05 13:02:28 +08:00
AskaEth 89715d1813 feat(channels): 受限车道回合补自身会话历史(群聊/陌生人能连贯理解一段对话;复用共享 transcript,拒绝主会话 id)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-05 12:58:01 +08:00
AskaEth be6e5f5780 docs(api): 契约升 v0.67(契约先行登记:media 播放端点 + voice DTO + 渠道事件查询面 + accountOnline;三批未实现已如实标注)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-05 12:53:35 +08:00
AskaEth b82870f41e fix(channels): QQ 适配器接上注入 logger(帧取证复活)+ 兜底不再静默降级(Warn 说明 Debug 会被丢)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-05 12:53:17 +08:00
AskaEth e1bd7c3009 fix(channels): 探活失败不再静默 + 回执信封校验(杜绝把失败谎报成账号离线)+ echo 唯一化
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-05 12:29:34 +08:00
AskaEth 8f6a75b7d7 feat(channels): IM 车道生成并写入真实 turn_id(user/assistant 成对 + 同源 WithTurnID;群聊隔离与静默语义不变)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-05 12:22:44 +08:00
AskaEth ba1974038a feat(gateway): 并行提示接线(第二路 + 串行续跑路;OtherMessageID/Queued 取自另一在途路,无其它在途路则不挂)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-05 12:21:24 +08:00
AskaEth 20160c66d8 feat(security): 代审口径 A 落地(仅子代理发起的敏感调用做实质判定 + fail-closed + 理由进审计与小字)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-05 12:12:31 +08:00
AskaEth fa850f9599 feat(loop): 落地 design/15 §2.5.6 并行提示(结构化 ParallelNotice + 独立 system 行,杜绝一题多答/跨端串话) 2026-10-05 12:12:23 +08:00
AskaEth a79fe9684e feat(channels): QQ 账号 get_status 主动探活(复用既有 action 通道 / 状态变化才记日志 / AccountStatus 与 WS 连通分开)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-05 12:10:34 +08:00
AskaEth f44114301e feat(channels): 微信入站媒体脱敏取证日志(形状行记 ref 来源+host、结局行记 err_kind;不记 URL/query/密钥)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-05 12:07:18 +08:00
AskaEth 5139945bfe feat(channels): 非消息事件接入(notice 归一化 + 传入前审查,fail-safe 静默)与入站脱敏帧日志/日志轮转
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- notice:adapter 归一化 seam + 结构化分类 + 净化(ANSI/控制/零宽/双向字符、200 runes 截断)
  + fail-safe 写死为「静默」(未装配审查/超时/失败 ⇒ 仅登记、不驱动 loop)
- 身份面零改动:notice 发送者恒 RoleSystem + SenderID=system
- 复用策略四的同一个审查模型(Options.Review),未另造第二套
- 入站帧脱敏 DEBUG(post_type/message_type/sub_type/self_id/user_id/sender_id/group_id/段类型/at 目标)
- ctrl.bat 启动前归档轮转(保留 20 份)+ bash 侧改追加(不再重启即截断)
2026-10-05 11:55:05 +08:00
AskaEth 85abb90067 feat(security): 敏感清单补齐 design/11 §4 的 7 类(写数据/对外发消息/访问密钥/改配置,结构化判据)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-05 11:53:17 +08:00
AskaEth 509dc412eb feat(memory): 记忆 P2(项目索引物化 / decay 回写 / 作用域写规则 / 静默吞点 + loop 级只读红线测试)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-05 11:47:39 +08:00
AskaEth 9b903a89bc feat(runtime): IM 主人私聊补历史转录(与 Web 主会话同源:抽共享 transcript 包 + OwnerEnvFor 单入口)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-05 11:36:18 +08:00
AskaEth df8fc660c0 docs(agents): §10 补铁律——提交一律逐文件 git add(实测吞过别人在途文件)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-05 11:31:24 +08:00
AskaEth 2b6750b821 docs(channels): 再修 3 处注释里的 design/08 引用(其中 -14 口径无对应节 ⇒ 改指对照清单)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-05 11:31:14 +08:00
AskaEth eeb5f26668 fix(memory): 堵住 knowledge 工具绕过群聊/陌生人过滤的 P0 / owner 召回 hits 如实上报 / 静默吞点补来源日志
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-05 11:30:51 +08:00
AskaEth e792baa316 docs(channels): 修正 6 处指向已不存在 design/08 节号的注释(只改注释)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-05 11:29:54 +08:00
AskaEth 91a3209c9b feat(memory): 记忆整理 v1(design/23)——低峰定期扫描 + 事实包含判定 + 规则否决 + 只产建议队列(不自动应用)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-05 11:29:36 +08:00
AskaEth 012e9f3ad4 feat(web): 前端 P0 收尾(多路忙态收口 / 握手 history 落 store / 清理幽灵键)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-05 11:29:17 +08:00
AskaEth e852e60af9 docs(channels): media_key 注释指向修正(design/08 §3.3 已不存在 → §4 weixin-ilink 条目)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-05 11:26:02 +08:00
AskaEth afd56ffd99 docs(agents): §10 补两条铁律(换二进制前先确认前端变更 / 裁定必须带完成状态)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-05 11:22:27 +08:00
AskaEth 1e0f2c9ec8 docs(api): v0.66 补充说明——chat.image 体积上限按形态写清(描述性更正,不升版本号) 2026-10-05 11:22:26 +08:00
AskaEth b73a1276c6 feat(channels): IM 入站图片接线(纯图片不再被丢 / data URI 进模型视图 / 诚实提示 / 来源白名单) 2026-10-05 11:22:26 +08:00
AskaEth 0cbc9ff886 docs(api): 契约升 v0.66(帧层 + turn_id + 渠道配置 + 修正未实现清单 + 思考流口径更正)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 登记 turn_begin / steer_ack 帧壳 turnId / interrupt.turnId(仅 scope=current)/ 帧序按实现
  (session_busy → message_persisted(user) → turn_begin)/ activeTurns / cancelledTurns /
  pending_ack.reason=parallel_limit / session_idle 并行时收紧
- chat_messages.turn_id:历史 DTO、PATCH 响应、admin 渠道日志、message_persisted /
  channel_persisted / message_edited、握手回放均增可选 turnId(空则键省略)
- 渠道配置:open|close 可选 accountId + resolved/accountId;DELETE config 显式 empty=1 + removed/empty;
  GET /admin/channels 的 accounts[].connected
- 语音:VOICE_STT_TIMEOUT_SEC;IM 入站附件引用落库(行为变更)
- 修正 v0.65 的「未实现」清单(那 5 项都已实现并上线)
- 思考流口径更正:历史 thinking 恒缺席(全仓无写入者),思维链唯一出口是实时 thinking.* 帧
2026-10-05 11:15:25 +08:00
AskaEth eaada3af5d feat(web): 前端与后端同步一批(帧壳 turnId / 并行六态与两级分桶 / documents 渲染 / System 键名 / 主人档案分组)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 帧壳 `turnId` 在 socket 层不再被丢(`lib/ws.ts` 的 dispatchFrame 补 turnId/seq)—— 按路分桶的前提
- 并行帧组接入:`turn_begin` / `steer_ack` / `pending_ack` / `pending_drain` / `pending_full` /
  `chat_duplicate` ⇒ 六态 + 灰态 + `(sessionId, turnId)` 两级分桶;`appendChunk` 不再永远写尾气泡
- 多路忙态:`session_interrupted` 不再无条件当空闲(看 mode/activeTurns);`stream_end` 不再只按会话过滤
- `documents` 渲染(含 status/chars/truncated 与**逐字中文 reason**);`turnId` 历史行分组小标
- `System.tsx` 键名改正(真键 HOST/PORT/ADMIN_USERNAME/ADMIN_PASSWORD,避免照抄静默失效)
- 主人档案**按平台分组** + 平台下拉 + 账号候选;**写盘仍展平数组**(后端契约不变)
- 验证:tsc --noEmit ✅、npm run build ✅、22 个前端测试文件全通过(本次新增 3 文件 108 条断言)
- 未做(如实):握手 history handler 与断线补拉、上行 interrupt 按钮、proactive_message/subagent_result
  等帧落点、文档上传入口与图片预检、voice 管线、System 的 env 读端点
2026-10-05 11:09:24 +08:00
AskaEth 6a545badbd feat(channels): IM 入站图片采集层(图片白名单 + 微信图片来源 + 复用 vision 内联入口)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- mediafetch 新增**图片白名单**(扩展名 + **字节嗅探兜底**;与文档/音频**三份名单互不污染**),
  取回/裁决/上限全部走既有 Fetch(**来源白名单 deny-by-default** + 逐跳重定向校验 + 边读边限 + 超时)
  ⇒ 没有新造第四份取回实现
- 微信:给图片 URL 配来源白名单(沿用该渠道 CDN 根口径)
- vision 的 `data:` URI 内联入口为**只读复用**(未改 vision 一行)
- **行为变更**:图片现在**会**被尝试取回(**字节嗅探是唯一裁决权** —— 不是图片字节就不写 `Data`);
  其它渠道尚无图片来源白名单 ⇒ 由 deny-by-default 拒绝(**安全**,只是暂读不到,走诚实降级)——
  故同步更新了 kook 的既有测试(原断言「图片保持既有路径 = 不取回」已随口径演进)
- ⚠️ **`channelbridge` 一个字没碰**(接线点在切片报告 §7),等监工接线 + 补其它渠道白名单
- 测试:3 个新文件;变异 5 条
2026-10-05 11:04:41 +08:00
AskaEth 5de708a560 fix(gateway,channelbridge,turnreply): p 标签半包写入侧规整(修「整条塌成一段」)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
裁定(已发安卓):**能修**,走**写入侧规整** —— 不指望模型输出、不动客户端。
- 新增 internal/runtime/turnreply/paragraph.go 的 NormalizeParagraphs:
  把未被 <p> 包裹的段落补上 <p>(按空行分段;<action>/<narration> 段也包进 <p>,
  与正确形态 <p><action>…</action>…</p> 一致)
- **幂等**守门断言:已完全包裹的文本逐字不变(防二次加工)
- 落点覆盖 Web 与 IM 两条落库路径(gateway/session_turn_reply.go、channelbridge/turnreply.go、
  proactive.go)⇒ 两边共用同一份规整文本 ⇒ **历史一次性变好、两个客户端零改动**、渲染口径不分叉
(测试与变异详见切片报告)
2026-10-05 10:56:39 +08:00
AskaEth d532eb3fcd fix(session,qq-obv11): 修「判主好了、会话车道没跟上」(QQ 私聊没镜像进主会话)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
真因(agent-221 用三条独立证据推翻了「SenderID 为空」的假设):ev.UserID 有值;
owner.yaml 的 qq 条目是 10:37:57 才加的(此前那几条判陌生人属**正确的 fail-closed**);
而热重载成功后仍不对 —— 因为 internal/channel/session/manager.go 的 GetOrCreate
对**已绑定的渠道直接返回缓存会话**:判主门槛用新 actor 会过(所以她会回话),
但**车道用的是缓存的旧会话**(Owner=false ⇒ 不镜像进 Web 主会话、MemWritable=false、
落盘 actor_role 永不更新)。

修:缓存命中但 ActorRole 与本次解析不一致 ⇒ rebindLocked 刷新车道 + 同步落盘
(updateActorRoleSQL);persist 的 INSERT OR IGNORE 后读回落盘车道、不一致就地纠正
(覆盖上一轮进程留下的旧行);落盘失败只 WARN(不因记账失败丢主人消息);
**身份变化不搬家、不换 session id**;车道只来自 ResolveActor 的结构化白名单判定。

QQ 适配器(防御加固,非本次真机 bug 的修复):senderIDOf 兜底链 顶层 user_id →
sender.user_id;并用完整论证钉住「**群聊绝不能用 ChannelID 兜底**」(否则群里任何人
可顶群号冒充主人);顺手体检:自己发的消息(post_type=message_sent)与 meta_event
已被 handlePayload 过滤。为守 ≤500 行把 id/字符串小工具拆到 ids.go。

测试:session/ownerlane_test + qq/ownerlane_test 新增用例(含「群号等于主人号时
陌生人不得借群号冒充」「群聊发送者 id 缺失时 SenderID 必须为空而非群号」「白名单
热重载后车道立刻刷新且 session id 不变」「白名单移除后立即退回,不是单向提权」
「跨进程继承的旧行被就地纠正」)。
变异 5 条(去 sender.user_id 兜底 / 群聊用群号当 SenderID / 命中却判 unknown /
还原缓存直返 / 去掉旧行纠正),每条先确认可编译、贴红行、还原复绿(md5 逐字节校验)。
2026-10-05 10:53:05 +08:00
AskaEth b16ab2c5e8 chore(config): 主人档案新增 qq-obv11 账号(主人经面板添加,保留)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-05 10:37:57 +08:00
AskaEth 42279b7124 feat(channels): 按账号连接/断开 + 可删空 id 条目(修主人真机踩的两个缺口)
缺口①:「连接」是按平台连、且恒打第一条账号(channelActionRequest 只有 name)⇒ 主人有两条
qq-obv11 时点到的是 mode=server 的历史条目,报 server mode requires listenAddr,而正确的
client 条目轮不到。缺口②:空 id 条目既删不掉(DELETE 要求 id 非空)也改不了(UpsertChannel
空 id = 新建),越改越多。

后端:
- POST /admin/channels/open|close 请求体新增可选 accountId(缺省逐字兼容,老客户端零影响);
  按该账号驱动;未知 id ⇒ 404 且零连接(不静默回落第一条);已配置未注册 ⇒ 409
- DELETE /admin/channels/{platform}/config 新增显式 empty=1(id 与 empty=1 互斥 ⇒ 400;
  无可删 ⇒ 404);语义严格 —— 只删空 id 条目,绝不把「id 为空」默认解释成删空 id
- 响应新增:open/close 的 resolved(实际驱动的适配器名)与 accountId;delete 的 removed/empty;
  GET /admin/channels 的 accounts[].connected(可选 bool,缺省 = 未知)
- 审计动作名不变(channel.open/close/delete),日志行新增 accountId 字段
前端:每条账号一行「连接/断开」(带 accountId);按钮旁明确「会连哪一条」;空 id 行显著标注
+ 删除入口;channelActionBody 携带 accountId;新增 action 测试 33 checks + 空 id 行渲染 31 checks。
测试:后端 14 个用例(真实 HTTP 路由 + JWT + 真注册表 + spy 适配器,断言「哪一条被驱动、
另一条一次都没被碰」)。
变异 6 条(accountId 被忽略 / id 为空当删空 id / 找不到静默回落 / 前端丢 accountId /
删空 id 退化成 ?id= / 空 id 行按普通行渲染)。
2026-10-05 10:37:56 +08:00
AskaEth c4ea02a887 feat(web): 渠道配置面板重做(按模式动态显隐 + 下拉 + 真鉴权键 + 掩码不回填)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
新增:channelFieldRules.ts(纯函数规则层:选择器归一化 / 按 transport×mode 显隐与必填 /
写回补丁 / 模式提示文案)、channelConnFields.tsx(「连接配置」区:只渲染当前组合生效的字段,
逐字段「谁连谁」说明 + 必填/可选标记 + 掩码清除开关)、channelAccountId.tsx(账号 id 与
「覆盖/新建」状态 Tag)、channelPersonaField.tsx(从表单抽出,逻辑逐字保留)。
qq-obv11:transport / mode 改下拉;remote_url 仅 ws+client 显示且必填;listen_addr 按
server / http+client 条件显示;api_base_url 仅 http(http+client 必填);report_path 仅 http;
access_token 掩码;self_id 仅记录。每种组合顶部一句 modeHints;保存前拦截必填缺失并红框点名。
⚠️ 修掉一处数据毁伤:后端把 access_token 掩码成 ****abcd 下发,旧代码回填进表单 ⇒
「打开就保存」会把掩码写成真 token。现在掩码不回填、只记 configuredKeys,并提示
「已配置,留空 = 不改动」;要清空必须显式打开「清除已配置的 access_token」开关
(顺带给掩码秘密补上了以前没有的「清除」能力)。可见字段清空发 ""、隐藏字段不进补丁(缺位 = 保留)。
Token 误导修掉:PLATFORM_GUIDE.authKey 声明平台真正读取的键 —— qq-obv11 不再渲染 bot_token
输入框、不再标必填(改指向 access_token);渠道列表与只读配置卡的凭据行同步改用真实 authKey。
空 id:结论是后端契约(UpsertChannel 空 id = 新建),前端不碰后端修好:记住 savedId
(留空 = 覆盖当前条目)+ 改新 id 时内联二次确认 + 保存后用返回的 accountId 刷新 + 状态 Tag。
测试:新增真组件 SSR 渲染测试(68 checks)+ 规则单测(58 checks);17 个前端测试文件逐跑全绿;
tsc --noEmit 与 npm run build 通过(产物 index-CK_2RXKo.js)。
2026-10-05 10:15:19 +08:00
AskaEth 8c437866e7 feat(qq-obv11): client 模式首连失败不再致命(复用既有重连循环 + 指数退避)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
痛点:mode=client 时首次拨号失败会让 Connect 直接返回 error ⇒ 渠道起不来;
而「连上后再断」本来就有重连。⇒ 把 readLoop 的入口条件放宽为「conn==nil 也算」,
**不新造第二套循环**。
新增 reconnect.go:退避 2s → ×2 → 上限 60s、抖动 ±25%(具名常量),失败计数跨
Connect 与 readLoop 共享。
ws_client:新增 connectFirstClient —— 可重试失败只记 WARN 并返回 nil;
配置缺失(remote_url 空)仍致命(闸门口径不变)。
connected 语义不许撒谎:始终由真实连接决定;HealthCheck 未连上返回 ErrNotConnected;
仅 http 传输保留「已装配即可服务」旧口径(逐字未改)。
ctx 感知等待(sleepCtx)+ 等待后复查 ctx ⇒ Disconnect 能立即中止,不泄漏 goroutine。
server 模式与 http 三端行为逐字不变(既有用例零改动全绿 + TestServerModeUnaffectedByClientRetry)。
测试:client_retry_test.go(428) + reconnect_test.go(83),用可注入 dialer + gate 通道造确定性时序。
变异 5 条(首连 fail-fast / 退避无上限 / 取消失效 / 未连上就报已连 / 抖动未接线)。
诚实边界:60s 上限对聊天渠道可能偏慢(改 30s 是一行常量);仓库内仍存第二份指数退避
(wsutil.Backoff 无抖动能力,收敛需改 internal/**,本切片禁碰)。
2026-10-05 10:12:23 +08:00
AskaEth d804c4e675 feat(gateway): 接线 chat_messages.turn_id 写入(A–D 四点齐)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
A 新起一路:publishLegStart 在 persistUserMessages 之前 withTurnID(ctx, leg.turnID)
(该行随 ef3dcb6 已进 HEAD)。
B 队首续跑路:completeTurn 在 o.mu.Unlock() 之后 assignTurnID(sessionID, runLeg.turnID, next.persisted)。
C 被 steer 吃进:consumePending 认领成功后 assignTurnID(sessionID, s.turnID, steerRows(candidates))。
D 助手回复:persistTurnReply 增 turnID 形参,completeTurn 传 leg.turnID —— 刻意**不用** turnIDOfLeg:
interrupt scope=all 会清空 st.legs,而被打断那一路已产出的正文仍要照原样落库(内容是真的),
按 gen 回查只能拿到空串 ⇒ 配对丢失。
删掉 history_turnid_test.go 的 t.Skip:TestSubmitWiresTurnIDForBothRows 真跑并通过。
新增 session_turnid_wiring_test.go(真 SQLite 内存存储 + 真编排器 + 可逐路放行 runner):
单路两条同值 / 并行两路各自同值且两两不同 / 续跑补写(先钉「补写前为空」)/ steer 补写(用该路自己的转向源)/
TestAssistantTurnIDSurvivesInterruptAll(钉住 D 的取值来源)。
变异 5 条(A/B/C/D 各自 + D′ 改回 turnIDOfLeg —— 只有那条守门用例红,证明它守的正是这个取舍)。
诚实边界:turnIDOfLeg 现无生产调用方(保留为兜底);先到先得竞态未构造对抗用例;
IM 渠道桥不走本编排器 ⇒ 其行 turn_id 留空属预期(契约里键缺席)。
2026-10-05 10:00:17 +08:00
AskaEth 3589f4b14f feat(tools): 真实工具声明文件目标(让文件级互斥真正生效)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
新增 plugins/tools/io/file_targets.go:fileTool 与 cropTool 实现 tool.FileTargeter
(编译期钉住 var _ tool.FileTargeter,声明集中一处可审计)。
file:read/list/search ⇒ 读;其余(含 delete / 未知 / 缺失 action)⇒ 写(保守侧,宁可多挡一次)。
路径取沙箱解析前的声明值(绝对、clean、已确认在 roots 内,纯词法不触盘);沙箱会拒的路径不声明。
search 的缺省 base 与执行路径共用 f.searchBase(避免声明目录 ≠ 实际走查目录)。
image-crop:仅本地路径声明为读(URL/data URI 不落盘);分类与 load 共用一份。
体检清单:file ✅ / image-crop ✅ / document-read ❌(sidecar 走「临时文件 + rename」原子替换,
无撕读风险;且布局属未导出的第二份真相)/ basic、http、knowledge 等不碰盘;
vision·ocr(internal 越界)、terminal(cmd 越界 + 目标不可知)、MCP 外部工具 未做 —— 如实登记为遗留。
测试:单元(action→Op 全表 / 路径归一 / 不声明场景)+ 机制层 6 条(真插件 + 真 Dispatcher + 真占用表,
用闸门工具把并发变确定性事件,不靠 sleep)。
变异 5 条(一切按写 / 未知按读 / 用原始相对串 / crop 不声明 / file 声明被抹掉)。
注:本切片声明正确但生效依赖 ef3dcb6(网关给每条在途路挂互斥身份)。
2026-10-05 09:55:29 +08:00
AskaEth ef3dcb699b fix(gateway,loop,tool): 解耦「工具互斥身份」与「checkpoint 归属」(修普通回合不受保护)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
缺口:「1 普通回合 + 1 并行路」时普通回合既不登记也不被检查 —— 因为互斥身份取自
loop.ParallelLegFrom,而网关只给 parallel=true 那一路盖它。直接给普通回合盖 WithParallelLeg
会把它的 checkpoint 键从 sess 变成 sess#turn-x(改崩溃恢复语义),故改为两条独立通道:
- 工具互斥身份:loop.WithToolLegID / ToolLegIDFrom(新增),网关在 beginLegLocked 内【无条件】盖,
  取值 = 本路 turnID ⇒ 普通回合 / 队首续跑路 / 并行路 三条路都覆盖
- checkpoint 归属:WithParallelLeg 一行未改,仍只有真并行路 ⇒ 恢复语义零变化
守门断言 TestCheckpointOwnerIgnoresToolLegID:普通回合 ctx 下 AgentID 必须逐字仍是 sess。
变异 3 条:互斥身份回退只认 ParallelLegFrom(判忙失效)/ 顺手把 checkpoint 改读新通道
(守门断言红,证明确实解耦)/ 普通回合不盖身份(判忙失效)。
另修 race:-race -count=2 三包全绿;诚实边界含一个既有竞态(同一路并发登记共享,建议后续小切片)。
2026-10-05 09:53:08 +08:00
AskaEth d64b26d499 feat(chat): chat_messages.turn_id 列 + DTO(写入接线留待下一步)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
迁移 0018:ALTER TABLE chat_messages ADD COLUMN turn_id TEXT NOT NULL DEFAULT ''
(与 persona_id / client_msg_id / document 同族:不用 NULL,空串 = 不属于任何已知回合 ⇒ 旧行零迁移、键省略)。
存储层全链路带 turn_id(列清单唯一一份 ⇒ List/ListAfter/Recent/Search/FindByClientMsgID 自动同源);
新增 history/turn_id.go:ensureTurnIDColumn(既有库幂等前滚)+ SetMessagesTurnID
(第二段补写:单事务参数化、先到先得 WHERE turn_id = ''、幂等、未知 id 忽略)。
DTO:historyMessage.TurnID(json turnId,omitempty)⇒ REST 列表 / admin 渠道日志 / WS 握手回放 /
message_persisted / channel_persisted / message_edited 一次全覆盖。
写入面:persistUserMessages 读 ctx 回合标识;persistAssistantReply 增可选尾参(既有调用点零改动);
withTurnID / turnIDFrom / assignTurnID / turnIDOfLeg。
⚠️ 编排器把 turnId 交给落库的 4 处(新起一路 / 续跑路 / 被 steer 吃进 / 助手回复)尚未接线
(那些文件当时被另一路占用);当前生产行为 turn_id 仍为 ''。清单已在切片报告里精确到文件:行。
13 新用例 + 5 条变异(INSERT 不带列 / DTO 不回填 / 去掉 omitempty / 去掉先到先得 / 不回滚前滚)。
为守 session_persist.go ≤500 行,turnId 一节拆到新文件 session_turn_id.go。
2026-10-05 09:46:10 +08:00
AskaEth 354b08a45f feat(tool): 写类工具文件级互斥(读共享 / 写独占)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
新增 tool/fileclaims.go:FileTargeter 可选接口,由工具声明本次调用要碰的文件
(动态声明、不猜正文 —— 同一工具的 path 在读/写语义不同)。
语义:读共享 / 写独占;两个读允许并行(主人「优先性能」口径);未知操作/裸路径按写处理(保守)。
占用键含操作类别(read/write,NUL 分隔),路径 Abs+Clean+Windows 大小写折叠;随最后占用者离场删键(有界)。
Dispatcher 默认装配进程内占用表(无需 cmd 侧接线);Invoke 登记 + defer 释放(成功/报错/超时/panic 四条路径)。
loop/tools.go 唯一接线:tool.WithLegID(ParallelLegFrom(ctx)) 桥接网关路标识。
判忙不阻塞、不排队死等;显式文案回灌(含文件名与状态)让 LLM 稍后重试。
测试 3 个新文件(占用表语义 / 接线确定性并发 / loop 桥与端到端);变异 5 条,另揪出并修一个
真隐患(空串既当「无人占用」哨兵又是合法路标识 ⇒ 改 (leg,busy) 返回)。
诚实边界:①「1 普通回合 + 1 并行路」时普通回合不受保护(网关只给 parallel 路盖 legID;
修法须与 checkpoint 归属一起裁决);② 目前无真实工具实现 FileTargeter ⇒ 机制就绪但占用表恒空,
真实保护待下一片给 file 等工具加声明。
2026-10-05 09:38:12 +08:00
AskaEth c0dc435770 feat(gateway): 忙时并行帧层(turn_begin + steer_ack.turnId + interrupt.turnId)
新下行帧 turn_begin:编排器每启动一路恰好一帧(单路 kind:"turn" / 并行路 "parallel"),
data{kind, messageIds, leg, activeTurns};角色门 fail-closed 仅 owner+admin,未进 frameReachesAll。
steer_ack 增帧壳 turnId(与该路 turn_begin 同源)—— 并行下没有它配不上正确的 turn.end。
上行 interrupt.turnId(仅 scope:"current" 生效):精确停某一路;目标不存在 ⇒ 定向
{type:"error",data:{code:"bad_request"}},不广播、不动任何会话;省略 turnId = v0.65 原口径(全部在途路)。
帧序按实现:session_busy / pending_drain → message_persisted(user) → turn_begin
(规范草案 §2.9 的时序图需按实现更正:messageIds 必须是已落库的权威 id,故只能在落库之后)。
测试:编排器侧 + 真 WS/JWT 端到端;变异 5 条(每路一帧 / 帧壳 turnId / 目标化打断 / 取消筛选)。
Docs 待升 v0.66(由监工登记)。
2026-10-05 09:38:11 +08:00
AskaEth e5cc9c6054 docs(api): 重写 api-client.md(61 KB / 701 行,比归档版缩小 85%)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
以代码为唯一真源重写:概览认证 / 用户侧 REST 表 / 管理侧表+权限矩阵 / WS 协议(握手+回放+上行+下行+角色门)/
契约细节(错误码·游标·幂等·消息 DTO·文档图片语音·忙时并行·interrupt)/ env 与模型配置 /
变更记录(只留 v0.60 起,更早指向 Docs/archive/)。

相对归档 v0.65 的修正(以 routes.go 等代码为准):
1. 管理侧权限矩阵计数错(归档 61 且自相矛盾)⇒ 实测 12 viewer + 11 owner/admin + 40 owner-only = 63
2. WS 握手实际也接受 ?token=<JWT>(仅不接受 X-API-Key)
3. limit>200 是钳到 200,不是 400(非数字/<1 才 400)
4. voice_output 的 sessionId 在 data 里(帧壳没有)
5. WS_MAX_CONNECTIONS 疑似解析未接线(标未确证)
6. 未确证清单 6 条已写入 §7.4
2026-10-05 09:34:08 +08:00
AskaEth 2ba1baf6d0 docs(api): 归档 v0.65 旧版 api-client / api-reference(准备以代码为真源重写干净版)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
旧版积了 v0.28→v0.65 的历史条目、「契约版本」标题并列、双变更记录、mtime 落后等结构性问题;
归档保留完整历史(含 §5 变更记录),新文件将从零重写,只留最近若干版并指向本归档。
2026-10-05 09:29:33 +08:00
AskaEth 879669cc59 docs(api): 契约 v0.65(忙时并行基础管线 + 六条登记)+ 修三条过期陈述
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
升 v0.65(依据 design/15 §2.5 与 commit 89e13cf),一并登记 679cee0(IM 入站附件引用落库)
/ 59a1ac4(llm_usage.session_id 接线)/ a918819(IM 各渠道语音可被读到),
cb564fe / 15181e7 / 7d2ff74 为纯内部修(无契约变化)。
三档:纯增量 = chat.parallel / activeTurns / cancelledTurns / pending_ack.reason /
两条 REST 应答形状 / VOICE_STT_TIMEOUT_SEC;行为变更 = IM 附件引用落库(无新字段)/
IM 语音进上下文 / session_idle 时点收紧(仅并行时);破坏性 = 无。
修正(客户端抓到的三条,属实):① session_id 「恒为 NULL」→ 已接线(端点仍不按会话过滤/分组)
② 「契约版本」标题并列三处 → v0.65 唯一,其余降为「上一版」
③ 按提交逐个核对补齐(mtime 落后问题)。
未实现项写明:turn_begin / steer_ack 帧壳 turnId / interrupt.turnId / chat_messages.turn_id /
graph_update 按路分桶 / 写类工具文件级互斥(tool/legfile.go 仅定死形状,无实现无接线)/ 第二路 system 摘要。
env CHAT_MAX_PARALLEL_TURNS 如实标注「未接线,生产恒为默认 2」。
2026-10-05 09:23:46 +08:00
AskaEth 89e13cf53d feat(gateway,loop): 忙时并行回合基础管线(I1′ 有条件放宽)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- turnId 由编排器生成、loop Run 复用(硬前提:否则 turn_begin 与流帧各铸一个,客户端两级分桶立即失配)
- StateSink 增带 turnId 的变体(未接线时退化为现签名,不丢状态帧)
- 上行 chat.parallel(WS chat 帧 + REST 三入口同形;省略 = 逐字不变)
- 单会话额度默认 2 / 硬上限 3 / <2 关闭;额度满或未声明 ⇒ 落回既有排队语义
- I1′:checkpoint 归属、历史配对、忙态计数三处从会话级单例升为路级
- session_idle 收紧为「所有在途路收尾且 pending 空」(仅并行时生效;单路逐字不变)
- 预留接口(形状定死、无实现无接线):tool/legfile.go 的 FileClaimer / ErrFileBusy / WithLegID
- 顺手修一个真 bug:并行路 ctx 派生自提交请求作用域,REST 同步入口一写响应就把它连坐杀死(改 context.WithoutCancel 派生)
- 23 个新用例(含 -race -count=3);变异 6 条(turnId 复用 / idle 时点 / 并行升级 / checkpoint 键 / 编排器交 id / pending_ack reason)
- 契约变更点 10 条待监工升 v0.65(本人未改 Docs/**)
2026-10-05 09:18:50 +08:00
AskaEth 7d2ff74cfc test(telegram): 补语音内联回归测试(上一条语音切片提交时被 grep 过滤漏掉)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-05 08:37:23 +08:00
AskaEth a918819878 feat(channels,voice): IM 各渠道语音消息可被读到(全渠道云端 ASR + 临时熔断)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
采集面:mediafetch 新增 inline_voice.go(音频白名单,与 documents.ExtAllowed 互不污染;
复用既有来源白名单 / 逐跳校验 / 边读边限 / 超时);四个适配器零改动。
消费面:channelbridge 在流水线最前端把「语音字节 → 文本」归一并写回 msg.Content;
inbound.go 的丢空判据放宽为「无文本且无可用媒体」。
微信优先平台自带转写(Content 非空 ⇒ 不调 ASR,不重复付费)。
降级(主人定的两层):新增 voice/outcome.go 的 STTOutcome 识破静默档位;
errors.go 分类鉴权失败/欠费/限流(CircuitWorthy);voice_breaker.go 临时熔断 + 退避(TTL 恢复);
无论是否熔断,消息面都落一行中文诚实提示(绝不静默、绝不留空消息)。
独立短超时:VOICE_STT_TIMEOUT_SEC(默认 20s,同步 .env.example)。
kook 不再把 CDN 地址写进 Content(地址仍在 Media[].URL)。
设计先行:design/06 §1.1/§1.2、design/08 §4.1、design/24 §1.3/§4.2、开源对照清单。
变异 5 条(静默档位不带 Cause / Transcribed 误判 / 熔断恒放行 / ChatText 退化 / 平台文字判据取反)。
诚实边界:实施时官方音频规格尚未抓到 ⇒ MaxVoiceBytes 自定 5 MiB(保守)、采样率仍 16 kHz;
官方规格随后已抓到(10 MB / 5 min;格式含 amr、无 silk),对齐留待下次改动。
2026-10-05 08:32:37 +08:00
AskaEth 679cee04f7 fix(channelbridge): IM 入站附件引用写进落库的用户消息(安卓报的 P0)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
根因:摄取结果只挂到「内存里的模型可见消息」(inbound.go 的 attachInboundAttachments),
落库时只传了 text、history.Message 没有 Document 字段 ⇒ 该列恒空 ⇒ 读侧 omitempty
导致 documents 键整个缺席 ⇒ 任何客户端只看到一条空消息。Web 侧不这样,是因为
gateway/session_persist.go 有 messageTextImageDocument 把 ContentTypeDocument 部件编码进该列
—— IM 路径缺的就是同一步。
修法:inboundAttachments.documentColumn() 复用 history.EncodeDocuments 同一口径写出该列;
persistToMain/persistIsolated 增 document 参数并返回刚落库的消息;新增两段式第二段
persistTurnDocumentStatus 按 id 回写抽取结论(只补结论、按存储名匹配、匹配不到原样留),
与 gateway/session_document_status.go 逐字对齐。
契约零扩张:无新端点/帧/字段;0 附件 ⇒ 列空 ⇒ 键省略;形状即 web 的同一结构体。
测试 6 条(含元素 JSON 逐字断言、unreadable 带 reason、被拒不编造芯片、群聊隔离分支、
真循环端到端);变异两条(还原挂载 / 关闭结论回写)⇒ 各 4 条红。
2026-10-05 08:10:21 +08:00
AskaEth f8f2e9c54c feat(weixin_ilink): 出站媒体走通(主人已批的三项)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
按官方包 @tencent-weixin/openclaw-weixin 2.4.9 源码对齐:
① 上传由 PUT 改 POST;② 上传凭据读响应头 x-encrypted-param;
③ nativeMediaItem 改为官方 *_item.media.encrypt_query_param 嵌套形状(含 aes_key,
图片优先 image_item.aeskey)。
配合前一版已修的两点(入站按条密钥、媒体失败不阻断文本),机器人现在可主动发出文件/图片。
(逐项测试、变异红行与诚实边界见该切片报告)
2026-10-05 08:04:50 +08:00
AskaEth 59a1ac4056 feat(model): llm_usage.session_id 真正落库(补完 U1 缺口)
通道:新增 model/session_ctx.go 的 WithSessionID / SessionIDFrom(与 tool.WithTurnPersona
同族的 ctx 范式,避开 model 反向 import loop 的环),loop 在 Run 顶部播种;适配器
record(ctx,...) 接会话;usageSessionArg 空串映射为 NULL。
兼容:sink 未注入或会话未播种时行为逐字不变;既有 model 测试零断言改动即全绿。
文件:model/{session_ctx.go(新), usage.go, usage_store.go, openai.go, anthropic.go,
usage_session_test.go(新)} + loop/{run.go, usage_session_test.go(新)}。
变异三处:去 loop 打标 / 写侧不绑会话 / 适配器不读 ctx ⇒ 各自红行,还原复绿。
契约与日志字段集未动;读侧不按 session 过滤,面板汇总口径不变。
2026-10-05 08:04:49 +08:00
AskaEth cb564fe981 fix(memory): 空 query 不再触发语义回退 + 回退有界(修真机 39 秒召回)
真因(agent-197,文件:行):只有附件的消息正文为空 ⇒ queryFromMessages 返回空串 ⇒
retrieve.go 的 vectorIndexScores 在问索引之前就 return ⇒ 上层分不清「没问过索引」与
「索引无命中」⇒ 打出误导的 no-candidate-hit ⇒ 转内存回退,把全库 302 条候选正文塞进
一个 embedding 请求(302 × 约 130ms = 39.3 秒,与实测 duration_ns 吻合)。
索引/维度/表/写入其实全是好的(PG vec_items 302 行全 dim=1024,与 SQLite 302 个 id 集合相同)。
修法:空/纯空白 query 整条语义路径跳过(不问索引、不 embed);回退改为有界
(分批 32 + 1 秒时间预算 + 正文到向量 FIFO 缓存 1024,不再每回合重算)。
文件:retrieve.go / retrieve_semantic.go + 新增 retrieve_fallback.go / retrieve_keyword.go /
retrieve_fallback_test.go(retrieve.go 曾达 501 行,按 ≤500 拆分)。
变异:去掉空 query 门 ⇒ 向量化 43 条文本(want 0);整库单请求 ⇒ 69 条超上限 32;停用缓存 ⇒ 重复嵌入。
2026-10-05 08:04:47 +08:00
AskaEth 6cb41a8794 fix(weixin_ilink): 入站按条密钥 + 出站上传地址(真机双 bug)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
Bug A(入站取不到字节)真因两层:① 引用位置错——真实在
`file_item.media.encrypt_query_param`,旧 mediaOf 只扫平铺 url/cdn_url;
② 密钥来源错——官方口径是**随每条附件下发**的 `*_item.media.aes_key`
(图片优先 `image_item.aeskey`),旧代码却把整个 bot_token 当 base64 取尾 16 字节,
而真机 token 形如 `<id>@im.bot:<38hex>`,第 12 字符 `@` 处即非法。
(对照证据:官方包 @tencent-weixin/openclaw-weixin 2.4.9 源码,只读未入库;
design/08 §3.3 的「token base64 尾 16 字节」据此判定为错误口径,待文档更正。)
Bug B(出站):① sendToOne 遇媒体构建失败直接 return ⇒ 连文本一起丢(改为逐条跳过
+ 带来源 WARN;全部媒体失败仍发文本,无文本且全挂才诚实报错);② getUploadURL 发空
body 且只认 4 个猜测键 ⇒ 补齐官方请求体字段 + 依次认 upload_full_url / upload_param。
测试:media_key_test(6 条,真机形态夹具 + 转义钉住)+ send_media_failure_test(6 条)。
变异 6 条(M-A1/A2/A3/B1/B3a/B3b)均先确认变异体可编译、贴红行、还原复绿。
诚实边界:出站媒体**仍发不出去**(PUT vs 官方 POST、响应头 x-encrypted-param、
nativeMediaItem 嵌套形状三项未做)—— 故意只修到「拿到上传地址」,不扩大切片。
2026-10-04 22:33:41 +08:00
AskaEth 15181e7063 fix(model): Anthropic 并行工具结果合并为同一条 user 消息(修真机 400)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
根因(agent-193,文件:行):loop 的 tool_history 按调用逐条产出 RoleTool 消息,
而 anthropic_encode 把每条各自编码成一条独立 user 消息 ⇒ 上游只认紧邻 assistant
的那一条,第 2..N 个 tool_use id 被判「没有 tool_result」⇒
`messages.1: tool_use ids were found without tool_result blocks immediately after`。
单调用回合只有 1 条结果消息,所以长期潜伏;KB1 让 knowledge 真正成功后它才被暴露。
修法:编码器在**线格式**层把相邻的 RoleTool 合并进同一条 user 消息(块序 = 调用序),
遇到任何非工具结果回合即复位(不同 assistant 回合的结果绝不混入同一条)。
未碰 openai_encode(OpenAI 要求每个 tool_call_id 一条独立 tool 消息)。
测试 3 个新文件:编码器线格式(非流式 + 流式)/ loop 端到端(真适配器打假上游)/
回填侧契约;含配对断言(每个 tool_use 在下一条消息里有同 id 的 tool_result)。
变异:还原旧编码 ⇒ 4 条红,端到端逐字复现真机 400。
是否本轮引入:否(编码与回填两文件在本轮零改动,属基线潜伏缺陷)。
2026-10-04 22:30:04 +08:00
AskaEth fd13b49b24 fix(channelbridge): 出站气泡不再携带入站载荷(真机:发文件后回复全丢)
根因(bubbles.go:43):`out := target` 把【整条入站消息】复制成出站气泡,
入站 Media 被一起带出 ⇒ 适配器去上传那个附件 ⇒ weixin_ilink 的 getUploadURL
拿不到 url ⇒ sendToOne 连已组装好的文本 item 一起丢掉 ⇒ 用户收不到任何回复。
修法:新增 outboundBubble(白名单只带路由字段 + 本气泡文本),一次清掉
Media / RawData / Mentions / ReplyToID / ReplyToText / MessageID / SenderName
六类入站专属字段;将来新加进 UnifiedMessage 的字段默认不外发(失败方向安全)。
测试 2 条:入站带附件 ⇒ 出站 Media 为空;带附件 vs 不带 ⇒ 出站除时间戳逐字节相同。
变异:破坏白名单把 Media 带出 ⇒ 2 条红。
影响面:仅「入站带附件」的消息;纯文本聊天不受影响(已真机确认)。
2026-10-04 22:30:03 +08:00
AskaEth fbbbb4ea64 fix(channelbridge): IM 渠道也走「回合必落可见回复」(与网关同口径)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
根因(agent-194,文件:行):
- inbound.go 的 deliverReply 对空回复直接 return(静默)⇒ 只剩 user 消息;
- runInboundTurn 报错直接 return ⇒ 失败回合连说明都没有。
修法:抽共享**纯函数**包 internal/runtime/turnreply(从网关逐字搬运、零语义改动)+
channelbridge/turnreply.go 的 turnVisibleReply(与网关 persistTurnReply 同构);
取消边界在 IM 用 context.Canceled / ctx 取消表达(渠道桥无代次概念,如实登记差异)。
测试 6 条:空回复/纯空白/轮次用尽/安全网/回合报错/取消不落库/正常逐字不变。
变异:还原旧静默行为 ⇒ 4 条红(含「出站 0 条,want 1」与英文内部诊断外泄)。
2026-10-04 22:29:54 +08:00
AskaEth be38651ba4 feat(llm): 用量落库 + 只读汇总端点 + 面板用量小卡(契约 v0.64)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- model 层新增可选 UsageSink(nil ⇒ 逐字不变,既有用例零改动)
- 组合根注入 UsageWriter:有界队列 1024 + 单写者 + 批量事务(128/批、2s 兜底),
  队列满丢弃 + 限流 WARN,绝不阻塞模型调用;sink 挂 RuntimeLLM(热重载不断)
- 迁移 0017:表 llm_usage(13 列,session_id 可空)+ idx_created_at + idx_session_id
- 新端点 GET /api/v1/admin/llm/usage(owner|admin 读;缺省近 24h、上限 90 天、
  超限 400 不静默裁剪;hitRate = cacheHit/(cacheHit+cacheMiss),分母 0 ⇒ 0;
  绝不含凭据与正文;未接线/查库失败 ⇒ 500 usage_unavailable)
- LLM_USAGE_RETENTION(默认 720h,≤0 = 关闭清理):启动清一次 + 每小时
- 面板 LLM 页「用量(近 24h)」小卡 + Docs 两份同步 v0.64
- 已知缺口(如实登记):session_id 恒 NULL,需 loop 侧在请求上打标,留列后接
2026-10-04 22:16:26 +08:00
AskaEth 18381f65fd fix(gateway,loop,tools): 失败回合必落可见回复 + knowledge 角色可信
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
根因(agent-191,均给出文件:行):
1) knowledge 的 persona 在 schema 里是 optional,模型省略时工具把空串透给
   memory.Retriever → 硬校验 ErrMissingPersona → 模型只能重复调用同一堵墙。
   修:新增 tool/turncontext.go(loop 播种本回合角色),knowledge 依次取
   「参数 → 本回合角色 → 可消化拒绝」,拒绝文案明确「重试不会成功」。
2) persistAssistantReply 对空文本直接 return,而三条正常收尾出口(空回复 /
   轮次用尽 / 回合内部报错)都会给空文本 ⇒ 历史只剩 user 消息、客户端静默。
   修:新增 session_turn_reply.go 的 persistTurnReply(唯一落库入口 + 按原因
   的中文诚实说明);中断 / 陈旧代次 / 静默内部流程(backthink、子代理调度)
   仍不落库,真实文本行为逐字未改。

测试:knowledge_test 改断言 + turn_persona_test(新)+ session_turn_reply_test(新,7 条)
变异:去掉 loop 角色发布 ⇒ loop×2 + gateway「工具执行次数 = 0, want 2」红
2026-10-04 22:14:42 +08:00
AskaEth 3be70f8232 feat(channels): 适配器入站内联附件字节(telegram/kook/qq_obv11/weixin_ilink)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 新增 internal/channel/mediafetch:来源白名单(deny-by-default,支持 *.suffix)
  + 逐跳重定向校验 + 边读边限 + 超时;只回来源主机,绝不回完整 URL
- 四渠道各自 media.go 接通入站内联:只从平台载荷给出的引用取回字节
  (telegram file_id→getFile→file_path 形态校验;kook CDN;qq 文件段 url;
   weixin cdnURL + AES-ECB 解密)
- 常量全部复用既有来源:documents.ExtAllowed / DefaultMaxBytes / MaxPerMessage
- channelbridge 诚实提示文案改为对「渠道只给远程引用」与「代取失败」两种成因都成立
- 18 个测试函数(含四渠道「正文里的 URL ⇒ 0 次命中」红线反证、端到端进模型可见视图)
- 变异验证:去掉尺寸上限 ⇒ 四渠道红(含修正后不再是假绿的 weixin 超量用例)
2026-10-04 22:13:17 +08:00
AskaEth 46caa724f2 docs(agents): 修一处被上一次编辑破坏的条目 + 增「等待子代理不许长 WaitFor」规矩
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 修复:上一条「压测纪律」的编辑把「仓库根产物卫生」的标题吃掉,导致该条只剩半句孤儿文本(已恢复标题与分隔)。

- 新增:等待子代理**不许用长 WaitFor 阻塞回合**(主人反馈:等待期间无法给补充输入)——派完就结束回合(完成通知会自动唤醒);确需同回合等,单次 ≤60s;有并行工作就先做工作。
2026-10-04 21:53:24 +08:00
AskaEth 902b6eecfd feat(api): documents[].reason + messages 对不存在会话回 404(安卓回执九两条;契约 v0.63)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- ① documents[] 元素增可选 reason(omitempty):装配期在给出 status 的**同一处**填充,取的**就是注入模型清单里那份原因文案**(同一字符串两处用,服务端不为客户端另编一句),只在 unreadable 时下发;随 S3.5 的回合后回写落进 document 列 ⇒ 历史接口 / 握手回放都有。文案只含原因、不含落盘路径/存储名以外内容/凭据(有独特目录名反证断言)。

- ② GET /sessions/{id}/messages:不存在/已删除 ⇒ 404 session_not_found;**存在但无消息仍 200 {data:[]}**(空会话绝不能被答成 404);参数形状校验仍先于 404;存在性判定复用既有窄视图(history.Store.SessionPersona,未知 id 报 ErrSessionNotFound),**未新增 SQL/存储方法**;判不出来(未接该视图)保持旧语义(判不出 ≠ 不存在),判定本身失败回 500 不静默。

- 契约 v0.63(三档:reason 纯增量 / messages 404 行为变更 / 无破坏性):api-client §1 行 6 + §1.2④ 的 10 条逐字文案 + §4.4 错误码 + changelog + banner;api-reference §1.2 行 6 + §3.4 + §3.8 DTO + §8.1 错误码表。

- 主会话独立核对的副作用(隔离会话 sess-* 现在也 404):查活库 chat_sessions 里**根本没有 sess-* 行**(只有 web 与 web:<uuid>),sess-backthink 消息数 0,前端唯一提到 sess-backthink 的是 graph_update 的内存桶测试 ⇒ **没有客户端按 id 读隔离会话的 messages**;且这与 POST /sessions/{id}/read(v0.37 起即 404)一致 ⇒ 接受不加 sess- 例外,并在回执里问安卓确认。

- 主会话独立验收:隔离 worktree(S9 的 13 文件)build/vet 绿、整树连跑 2 次 59 ok / 0 FAIL、reason/404 用例 8 PASS、双平台交叉编译绿;**变异亲自复现**(在调用点短路 404 守卫 ⇒ UnknownSessionIs404 与 DeletedSessionIs404 双红:status = 200, want 404)。

- 如实登记:验收过程中出现过**一次不可复现的子集 FAIL**(restore 之后那次 Session|Reason 子集跑;未记到用例名)—— 随后同一子集、那三条用例 ×5、以及整树 ×2 全绿,判为瞬时;已记入账本,后续留意。
2026-10-04 21:50:08 +08:00
AskaEth c1fa1e9b9a feat(admin): 面板「重置为 env 默认」动作(materialize 的反方向)+ 契约 v0.62
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 语义:只把 .env **确实声明过**的键覆盖回 env 值;env 未声明的键(fallback/multimodal/maxTokens/reasoning/personaModels)原样保留;**组集合不增减**(不复活被面板删的组、不删面板新增的组);无键可改时不写盘。成功即热重载 LLM 栈。返回 {reset:[{group,keys}], skipped:[{group,reason}], path},**只回键名、不回显凭据值**。

- 护栏:必须 confirm:true(缺失/false/非法 body ⇒ 400,且**先于任何读写**);**owner-only**(与同页 POST /admin/llm、/admin/llm/test 同门 —— 这是破坏性写,门不比同族更宽);审计动作 llm.resetFromEnv(camelCase,全仓多词动作的既有约定)。失败路径明确报错(env_defaults_unreadable / models_unreadable / llm_unavailable),不假成功。

- 组合根注入 Deps.EnvPath(网关不 fopen .env):请求时按既有解析入口重读,语义 =「退回 .env 现在声明的默认值」;.env 层里的 provider 是同义反复,不计入「env 为该组声明了什么」的判定(有专门用例)。

- 前端:LLM 页「重置为 env 默认」卡片(中文二次确认 → 提交 → 按 reset/skipped 渲染 → 成功后重拉配置),标签 owner 专属(与 System.tsx 同款)。

- 契约 v0.62:api-client(权限矩阵改回 12+10+38=60、端点表 9b、重置契约小节、写操作契约、changelog、banner)与 api-reference(端点表 #70、§5.13a 详情含 curl、§8.2/§8.3/§8.4)同步;字段名三方逐字对齐。

- 主会话独立验收:隔离 worktree(R1 的 19 文件)build/vet 绿、gateway+config 三包绿、R1 用例 10 PASS、reset_from_env 残留 0;**变异亲自复现**(去掉 confirm 校验 ⇒ RequiresConfirm 红:status = 200, want 400,且响应体显示面板配置真的被改)。

- 子代理自报并已处理:测试文件触 500 行上限 ⇒ 拆成语义侧 + 护栏侧两份;门改回 owner-only 后前端标签会误导 admin ⇒ 改「owner 专属」并重建 bundle。任务书本身仍是旧口径(owner|admin / snake_case)—— 已由主会话登记修正说明,避免压缩后有人按任务书回退。

- 未做(诚实边界):不支持按组重置;不纳入 channels.json/plugins.json;不自动重启(contextWindow 的预算推导仍只在启动时算一次)。新端点需下一次 build+restart 才在 :10942 生效。
2026-10-04 21:33:11 +08:00
AskaEth 367abf2c8e docs(agents): 铁律 §10 增「压测纪律」(yes 风暴占满整机 15 分钟的事故)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 事故:子代理做负载验证时用 30 个 yes,第一次 kill 没杀干净 ⇒ 12 核被占满约 15 分钟,所有 go build/test 近乎停滞,并把工具链误判成死锁。

- 规矩:负载限核(GOMAXPROCS/单包并行/-p)或限时,优先用「可控对抗条件 + 注入时钟 + 门控」而不是堆进程;压测结束必须确认无残留进程再出证据;出现「整机变慢/命令不动」先查自己刚起的压测进程。
2026-10-04 21:11:39 +08:00
AskaEth 6066165394 test(flake): 清掉最后三条墙钟/条件竞态(mcp 订阅门控 + vision 注入时钟 + voice 兜底期限)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- #6 capability/mcp TestRegistry_ToolListChangedRefreshes:真因**不是慢,而是条件竞态** —— 本机协商到协议 2026-07-28(SEP-2575)时服务器**只**把 notifications/tools/list_changed 投给已登记 subscriptions/listen 的会话,而客户端订阅是 fire-and-forget(Connect 返回 ≠ 服务器已登记),服务器又在变更后 10ms 发一次、丢了不补 ⇒ 订阅登记晚于那个定时器,该次变更**永远**不生效(旧 waitFor(5s) 只能超时)。修法:显式门控 —— 先等服务器 notifications/subscriptions/acknowledged(服务器先登记再回确认)→ 再触发变更 → 再断言刷新由 tools/list_changed 驱动(既有断言一条未少、另加一条更强的驱动断言)。协议回落旧版时门控自动跳过。

- #7 multimodal/vision TestImageCache_RefetchesAfterTTLWhenContentChanges:真因是把**墙钟当判据**(ttl=20ms 下「TTL 内」成立与否只由调度抖动决定,实测空载≈0、并行负载下 >20ms 会同时打红两条断言)。修法:注入可手动推进的 testClock(生产本就有 cache.now 字段、默认 time.Now,零生产改动),断言一条未少。

- 附带:修掉 G5 批次遗留的第三条墙钟兜底 —— gateway/voice_turn_test.go 的 waitFrame 3s → 15s。**放宽的是兜底期限、不是断言**:它断言的是「帧确实到达」,帧没到照样 Fatal;实测单跑 -count=20 全绿、只在整树并行下偶红(语音回合要过编排器 → 门控假 runner → 假 TTS → 定向回连接)。

- 主会话独立验收:隔离 worktree(F2 三个测试文件)build/vet 绿、两条目标用例各 -count=20 绿;加本条兜底修复后整树连跑 2 次均 59 ok / 0 FAIL(修前那次整树抓到 voice 那条红 → 归因见上)。

- 子代理自报(已核实并清理):它在加压时用 30 个 yes 做负载、第一次 kill 没杀干净 ⇒ 整机 12 核被占满约 15 分钟(期间所有 go 命令近乎停滞,也解释了本轮测试为何异常慢);已全部终止(Get-Process yes = 0),此后证据均在干净环境重跑。它同时**如实说明**:在纯加压下没能直接撞出这两条原始 flake(窗口极窄),确定性论证建立在「可控对抗条件复现 + 根因」上。
2026-10-04 21:11:17 +08:00
AskaEth 3ba31b58ff feat(documents): 引用计数 + 文档/侧车 GC(主人 2.2 裁决;S7)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 引用计数真实维护(documents 表唯一写入面):增减点只有四处 —— history 的 insert / UpdateMessageDocument / DeleteMessage / DeleteSession,且**全在同一事务**内(计数写失败 ⇒ 消息写入整体回滚,有钉住用例);UpdateMessageDocument 按新旧名字**集合差**增减(三阶段回写是常态,重算会重复计数 ⇒ 文件永远回收不掉)。

- GC:候选 = ref_count = 0;判据 = last_referenced_at(NULL 回落 created_at)早于 now − 保留期;动作 = 删 sidecar + 原件 + 元数据行(删行带 AND ref_count = 0 原子再确认);路径双检;汇总日志(含实占字节);单件失败 WARN 并继续;删除顺序 = 先文件后行(失败则行留着下轮重试,不留孤儿)。

- 组合根:启动期清扫一次(不阻塞启动)+ ticker 常驻(可停,close 顺序在关库之前);env DOCUMENT_GC_INTERVAL 默认 1h / DOCUMENT_RETENTION 默认 168h,**任一 ≤0 = 关闭 GC**(RETENTION=0 不解释成立即回收,关闭时记 WARN);.env.example 已同步(AGENTS §10)。

- 主会话独立验收:隔离 worktree(S7 的 18 文件,排除 F2 在途的 mcp/vision)build/vet 绿、全树 59 包 0 FAIL、GC/引用用例 20 PASS、双平台交叉编译绿;**变异亲自复现**(候选查询去掉 ref_count = 0 ⇒ TestGCDoesNotDeleteReferencedDocument 红:candidates = 1, want 0)。

- 未做(任务书允许的「只做后端」):面板手动「清理未引用附件」入口 —— 后端能力已就绪(documents.NewGC + GC.Sweep),接成端点属小切片(含 admin 路由 + 审计 document.gc + 契约登记)。

- 已知窄窗口(已在 gc.go 文件头登记):一轮 GC 与一次落库几乎同时发生时可能删掉刚被引用的文件(行不删、记 WARN);坏 document 列只记 WARN 不阻塞消息写入 ⇒ 计数可能偏低(只会让文件**晚**回收,绝不早删)。
2026-10-04 21:01:30 +08:00
AskaEth 79b1ec36aa fix(auth): API Key 落盘(修「每次重启 key 全失效」)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 缺陷:internal/auth/apikey.go 的 key 表是纯内存 map(注释自认 production should persist)⇒ 一次 restart 后所有 key 一律 401;实测挡住了大附件真机 e2e,也与「key 用于生产救火」的长凭据语义矛盾。

- 迁移 0016_api_keys:表 api_keys(id/name/key_hash/created_at/last_used_at/revoked_at)+ 唯一索引 idx_api_keys_key_hash;**只存 SHA-256 hash,无明文入口**。

- 纪律:Generate 先落库后改内存(失败 ⇒ 内存不变 + 返回错误);AttachKeyStore 启动时把库中未吊销 hash 载入内存(Load 失败仍接上 store ⇒ Generate 会响亮报错,而不是静默退回「不落盘」);Revoke/RevokeAll/RevokeByMask 先落库再移出内存(落库失败 ⇒ WARN + 内存不变,fail-closed:绝不让被吊销的 key 重启后复活)。

- 内存表也改为存 hash(不再存明文)⇒ 面板 id/掩码由 hash 派生:不再泄露明文首尾、且跨重启稳定(重启前记下的 id 重启后仍可吊销)。

- 契约零变化:HTTP 请求/响应形状、网关代码、既有内存用例断言全部未动(网关构造注入点只在组合根 wireAPIKeys)。

- 主会话独立验收:隔离 worktree(K1 的 7 文件)build/vet 绿、全树 59 包 0 FAIL、key 用例 24 PASS、双平台交叉编译绿;变异独立复现(跳过落库 ⇒ 重启存活用例红)。

- 取舍与遗留:吊销落库失败在 HTTP 层仍表现为 404/revoked:0(签名零改动的代价,日志可辨);last_used_at 列已建但恒 NULL(避免把读路径变写路径);旧内存 key 已被此前重启消灭,需重签一次后即长期有效。
2026-10-04 19:55:18 +08:00
AskaEth c3339a5d07 docs(api): 契约 v0.61 —— 登记 .pdf 文本层与成对嗅探裁决(修 B1 落地后的契约漂移)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 漂移:B1(f82c76d)把白名单 22→23 项、嗅探改为 (扩展名, 嗅探) 成对裁决,而 Docs 仍写「22 项 / 仅放行 text/plain」。本切片只改 Docs 两个文件把口径改准。

- 登记要点:白名单 23 项(22 文本+ .pdf);成对裁决双向拒(HTML/文本改名 .pdf ⇒ 415;PDF 改名 .txt ⇒ 415);读侧真 PDF 下发 application/pdf 但仍 attachment+nosniff(不可内联);文本层抽正文、扫描件诚实拒绝(unreadable,文案含「需要 OCR,当前未做」)、加密/损坏可区分;安全阀 5000 页 / sidecar 8 MiB;ExtractorVersion documents/v3 ⇒ 旧 sidecar 一次性重抽(客户端可观察 chars/truncated 重算)。三档标注:行为变更(.pdf 从 415 变为可上传可读 + 裁决收紧)+ 纯增量(第 23 项)。

- 未发明任何未实现行为:OCR / PDF 写 / Office 明确写「未做」;v0.59 历史条目里的「22」加注「⚠️ 历史值;v0.61 起为 23 项」。

- 主会话复核:grep 22 项 仅剩历史 changelog 一处且已标注;v0.61 在 api-client 12 处、api-reference 13 处;给出「项 → 文档位置 → 代码证据(文件:行)」对照表逐条核对。

- 子代理如实报告的结构隐患(已登记):api-client 有**两套平行变更记录**(§1.2 末的大块 + §5 列表)易再漂移;api-reference 无自己的版本条目节。
2026-10-04 19:51:12 +08:00
AskaEth f82c76df27 feat(documents): PDF 文本层抽取(S6 第一段;扫描件仍诚实拒绝)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 依赖:github.com/ledongthuc/pdf(**BSD-3-Clause**,非 MIT —— 子代理核了 LICENSE 并纠正了任务书的写法;零传递依赖;无 cgo/无外部进程;体积 strip 后 +285.5 KiB / +0.74%,在 detached worktree 里量取,产出写到仓库外)。被否候选:pdfcpu 无文本抽取、rsc.io/pdf 停更、pdfium/poppler/mupdf 需 cgo 撞红线。

- 实现:魔数 %PDF- 是唯一入口判定(名字/声明指向 PDF 而字节无魔数 ⇒ ErrUnsupported,绝不退回纯文本抽);逐页 GetPlainText(页间仅去首尾空白);ExtractorVersion v2→v3(所有 sidecar 一次性重抽);页数上限 5000 + 字节安全上限复用;新增哨兵 ErrPDFEncrypted / ErrPDFDamaged。

- 诚实边界:无文本层(扫描件)⇒ ErrNoTextLayer 且文案含「图片型/无文本层 PDF,需要 OCR(当前未做)」;加密/损坏各自可判、绝不 panic、不回空正文假装成功。

- 白名单与成对裁决:.pdf 纳入(22→23),且 ContentAllowed 做双向裁决(.pdf 只收嗅探为 application/pdf;PDF 字节只收在 .pdf 名下);读侧真 PDF 如实下发 application/pdf,仍 attachment + nosniff(不可内联)。

- 1 处 panic 兜底已如实登记:该库对「xref 指向文件外」畸形件会在 NumPage() 直接 panic(已用仓库外程序复现),单二进制常驻后端不能因 587 字节上传件崩掉 ⇒ defer recover 折成 ErrPDFDamaged;损坏类主用例仍走库的真出错路径。

- 主会话独立验收:隔离 worktree(B1 的 14 文件)build/vet 绿、全树 59 包 0 FAIL、PDF 用例 13 PASS、双平台交叉编译绿;变异独立复现(魔数裁决短路 ⇒ 魔数/成对裁决用例红)。

- ⚠️ 契约漂移(已排 C2):Docs 仍写「22 项白名单 / 嗅探仅放行 text/plain」,与本切片的 23 项 + 成对裁决不一致 ⇒ 需升 v0.61 登记 .pdf 与新的边界。
2026-10-04 19:46:36 +08:00
AskaEth c4e09afd38 feat(admin): 面板「存储」卡片接真数据(含 PG 增强状态)+ 契约 v0.60
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 现状:overviewMetrics.ts:188 的「存储」卡是写死的 value:"SQLite",后端也没有对应读端点(/admin/system/* 只有 revoke-keys/rotate-jwt/backup/restore 四个动作端点)。

- 新增只读端点 GET /api/v1/admin/system/storage(ownerAuth):main{kind,path,fileBytes,walBytes,shmBytes,pageSize,pageCount,freelistPages,freeBytes} + documents{count,bytes} + vector{kind,enabled,ready};只读(SELECT/PRAGMA + os.Stat)、复用活连接不开新池、**绝不含凭据**(只回 kind,不回 host/DSN)、失败一律 500 storage_unavailable(不回全 0 假快照;表缺失也 500 而不是静默 0)。

- vector 真源 = 组合根 app.vectorState()(Configured = DB_PG_URL 非空;Live = 向量索引类型断言成功),零新探测 ⇒ 面板现在能区分「未启用(未配置 DB_PG_URL)/ 启用(pgvector·ready)/ 已配置但未就绪(连接失败,检索降级)」,正好解答主人对「面板只写 SQLite」的困惑。

- 前端:卡片显示真实体积 + WAL/页数/空闲页 + documents 件数与占用 + PG 那一行;取数走与已接线卡片同一条加载/错误态(失败显示 '—' + 端点报错,不静默显示 0)。

- 契约 v0.60(项目红线):api-client(版本段 + 端点表 + 存储状态契约小节 + changelog)与 api-reference(版本号 + 端点表 + 详情小节)同步;字段名三方(代码/Docs/前端)逐字对齐。

- 主会话独立验收:隔离 worktree(ST1 的 14 文件,排除 B1 在途的 chat_documents.go/documents_pdf_test.go)build/vet 绿、全树 59 包 0 FAIL、存储端点 7 用例 PASS、双平台交叉编译绿。

- ⚠️ 该切片自首一起事故:为通过 cmd/cyrene 类型检查跑了 go build ./cmd/cyrene/(单 main 包)⇒ 覆盖了仓库根 cyrene.exe(49,675,264 B / 19:32:19,脏树未 strip);上一个权威产物仍在 cyrene.exe~(39,507,968 B / 19:25:13)。运行中的 PID 477964 未受影响;主会话将在整树干净后重跑 ctrl.bat build 恢复权威产物。
2026-10-04 19:44:53 +08:00
AskaEth 137b3e9eaf fix(guardrail): 输入上限与附件注入预算统一换算(多附件不再被拦)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 残留缺口:护栏量整段装配请求(字符),注入预算是单份 ⇒ 1M 窗口下 2×349525 就顶到旧上限 699050、5 份必被拦。根因是两套换算(上限按 1char=1token,注入按 loop 的 4char=1token)。

- 修法:上限 = max(262144, 推导预算 tokens × loop.CharsPerToken) ⇒ 1M 窗口 2,796,200 字符(5 份满额注入 1,747,625 有余量);不变量升级为「N 份」(maxChatDocumentsPerTurn=5,带出处注释);WARN 文案升级为多附件口径(max_documents/document_inject_chars/document_inject_total_chars/guardrail_max_input_chars/hint)。

- 同一换算常量复用:在 loop/compactor.go 的既有无导出常量旁加导出别名 loop.CharsPerToken = charsPerToken(2 行、零行为改动、不重命名)—— 这是本切片唯一触及「不要碰」清单的改动,已由主会话复核接受(替代方案是复制一个字面量 4,会重新造出两套换算)。

- 同步陈旧注释(config/guardrail.go、wire_runtime.go 里的旧公式)与 .env.example(新公式 + 多附件口径 + 1M 算例 2796200/1747625),并有「模板 ↔ 代码现算值」的一致性用例。

- 主会话独立验收:隔离 worktree(G2b 的 7 文件,排除 B1/ST1 在途)build/vet 绿、全树 59 包 0 FAIL、护栏用例 17 PASS、双平台交叉编译绿。

- 残留(未修,需裁决):预算关闭或落在 (0,81920) token 的部署,派生态上限 = 262144 而 5×64K = 327680 ⇒ 满载附件回合仍可能被拦(启动会记 WARN,不静默);真正的下限应取 max(262144, maxDocs×每份注入)。
2026-10-04 19:41:44 +08:00
AskaEth 2a43035f5d refactor+feat(documents,channelbridge): 共享存储核心 + IM 入站附件摄取(主人 2.3)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 抽共享存储核心:internal/runtime/documents/store.go(22 项白名单 + 字节嗅探唯一裁决 + 内容寻址 sha256[:32]+ext + 去重 + 限额 + 路径双检)+ store_meta.go(documents 表唯一写入实现)。

- gateway 改为调用 store,**对外行为零变化**(机器证明:既有 documents_test/chat_documents_test/chat_documents_bounds_test 三个文件 0 行改动且全绿;状态码/响应字段/错误码/审计动作名/限额文档口径全部逐字保留;maxChatDocuments 改为引用 documents.MaxPerMessage)。

- IM 入站附件摄取(channelbridge):文件类媒体 → store.Save → 挂 model.DocumentMessage(RoleUser) 部件 ⇒ 后续 resolveDocuments 自动抽取/注入/清单;挂载点在「会话解析 + 黑白名单 + 最小打扰」三重闸门之后(被拦消息绝不在盘上留文件);**不支持的格式不静默**(诚实提示行 + 带来源日志,只记 name/mime/size,名字做展示净化防伪造行);图片/语音保持既有链路。

- 主会话独立验收:隔离 worktree(M1 的 12 文件)build/vet 绿、全树 59 包 0 FAIL、双平台交叉编译绿、12 条 Inbound 用例 PASS;变异独立复现(关闭 IM 摄取 ⇒ 5 条红,含「落盘文件数 = [] want 1」)。

- ⚠️ 真机边界(需裁决):现网四个适配器上报的文件媒体**只带 URL**(telegram FileID / qq-obv11 file|url / weixin·kook CDN URL),Media.Data 恒空 ⇒ 本切片只摄**内联字节**,URL 一律「诚实提示,不代取远程」(避免第三份 SSRF 实现)。真机要让「她读到 IM 文件」,需各适配器入站时下载并内联(凭据在各自渠道里,推荐)或新增共享受守卫抓取器。

- 另登记:IM 附件暂未写 chat_messages.document 列(二手回合看不到引用,建议并入 S7);channelbridge 既有测试夹具在 -race 下有数据竞争(stubSessions.GetOrCreate,与本次无关)。
2026-10-04 19:25:00 +08:00
AskaEth 51c1672355 fix(guardrail): 输入护栏上限按推导预算派生(修「大附件被拦死」)+ 第 5 条 flake
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 真 bug:文档注入预算(1M 窗口 ⇒ 349525 字符)> 输入护栏硬编码上限(262144)⇒ 带 744KB 附件的 POST /chat 直接 input_length_exceeded/guardrail_input,整个回合作废(主会话活服务实测)。

- 修法:guardrailMaxInputChars → guardrailMinInputChars(256Ki 下限) + 纯函数 deriveGuardrailInputLimit(log, override, budgetTokens, docInjectChars):派生值 = max(下限, 推导预算 tokens)(1M ⇒ 699050);新增 env GUARDRAIL_MAX_INPUT_CHARS(未设/0=派生、>0=覆盖、<0=WARN 回落);预算只在 wire_context 推导一次并经 buildGuardrailPipeline(ctxBudget) 传入(避免「窗口未声明」WARN 记两遍);输出上限 65536 刻意不参与派生(有显式回归钉住)。

- 关系不变量:文档注入预算 ≤ 输入上限,人为破坏 ⇒ 一条 WARN(不静默)。

- 第 5 条 flake(exec/TestTerminal_Timeout_Killed):仅改测试、terminal.go 保持 pristine;根因不是「杀进程失效」而是旧断言的 ~200ms 精度把宿主负载下的进程创建耗时算了进去(重压下 cmd.Start 会直接返回 deadline exceeded,红行却报 process not killed)⇒ 改为「控制组证明同命令不设短超时正常退出」的确定性证明 + 把墙钟断言降级为 10s 语义判别界(子进程自然存活期拉到 30s)。

- 主会话独立验收:隔离 worktree(G2 的 10 个文件,排除 M1 在途的 wire_channels.go)build/vet 绿、全树 59 包 0 FAIL、双平台交叉编译绿、Guardrail* 全 PASS、exec -count=20 绿;变异独立复现(把派生值退回硬编码 ⇒ 4 条红,含 budget=699050 → 262144)。

- 残留缺口(已排 G2b):护栏量的是整段装配请求、注入预算是单份 ⇒ ≥2 份各撞注入预算的大附件仍可能被拦。
2026-10-04 19:15:12 +08:00
AskaEth d37d4a899b docs(gateway): 余下 3 处行尾注释中文化(存量注释债结项)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 事实订正:任务书说「gateway 全包中文注释 ≈ 0」是 2026-10-03 的旧状态;该债早已分批还清(074ffc4 / 355f81d / fe1ff33)。本次清点:70 个非测试文件 / 3493 行 // 注释,仅 6 行无中文。

- 翻 3 行(都是行尾枚举提示,代码前缀逐字节不变):admin_write_iot_automations.go:107 (cron|event)、history.go:20 (main|sub)、history.go:47 (user|assistant);另 3 行是中文多行句子的纯标识符/帧名续行,按规则不翻。

- 机械校验:除这 3 组行尾注释外零输出(即整包 diff 无任何非注释行、非空行改动);gofmt -l 干净(改动前也干净);go build/vet 全树绿;gateway 包测试绿。

- 主会话独立复核:机械校验输出恰为 3 对(代码部分逐字相同)、gofmt 干净、go test ./internal/services/gateway/... 绿。

- 文档订正:开发习惯与约定.md 的「gateway ≈ 0 中文注释」已改写成结项事实(测试文件里的存量注释仍未清点,另记)。
2026-10-04 18:57:58 +08:00
AskaEth b1f9b11d6e test(flake): 4 条负载敏感用例确定性化(只改测试,生产代码零改动)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- #1 TestSpawnTimeoutFromToolDefault:墙钟竞态(Spawn→Wait 的调度抖动 vs 120ms 工具时限)⇒ 用「托底子代理占住唯一槽位 + gate 放行」把 running 变成由门控保证;并新增 DeadlineExceeded 断言。

- #2 TestParallelLimit:真实成因**不是**「同进程迭代状态泄漏」(任务书推测错了)—— 是 transitionRunning 与 runner 内 active++ 之间的**抢占窗口**(复现:-count=200 六次红、三进程并行 1/9)⇒ 改等 runner 内部 activeCount()>=2 这一**稳定态**,并新增「整轮排空峰值==2」。

- #3 TestBackthink_List_NewestFirst:真实成因**不是**时间戳并列(SQL 是 ORDER BY id DESC)—— 是**后台心跳 writer 在两次 List 之间插入新行**(决定性证据:心跳周期缩到 1ms ⇒ 21/200 红,且第二次读到的 id 更大)⇒ 不再 Start() 心跳,改同步 think 写 3 条。

- #4 TestClient_OnConnectErrorTriggersRedial:固定 60ms 睡眠 vs 每周期耗时不定的墙钟竞态(改动前基线 5/9 红)⇒ 改为等第 2 次拨号事件;顺带改正与内容不符的旧注释。

- 证据:逐条 -count=20 绿;整树连跑 3 次 59 ok/0 FAIL;**三进程并行整树 9/9 绿(改动前基线 5/9 红)**;GOMAXPROCS=1 ×4 进程加压;-count=200/500/500/300;-race 三包绿。

- 主会话独立复验:隔离 worktree build/vet 绿、四条 -count=20 全绿、两进程并行整树双 59 ok/0 FAIL;**变异独立复现**(TestParallelLimit 的 MaxParallel 2→3 ⇒ max concurrent runs = 3, want 2 红)。

- 顺带发现第 5 条既有 flake:internal/runtime/exec/TestTerminal_Timeout_Killed(改动前基线 2/9 红);以及 audit/00-总览.md:321/372 对 #2/#3 成因的记载有误(DevDocs 由主会话更正)。
2026-10-04 18:51:25 +08:00
AskaEth b19d798a2d docs(api): 契约 v0.59 —— 文档进对话 + 上下文预算按窗口推导 + 语音进编排 + thinking 口径
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- api-client 顶部 changelog 新增 v0.59(上一版指向 v0.58),三档标注齐全;新增 §1.2 文档存储与摄取契约、§3.15 语音回合、§2 上下文窗口与预算契约(v0.59);api-reference 端点表/帧表/错误码同步(+235/-33)。

- 登记要点:两条 documents 端点(含重复上传 200 + deduped:true、读侧必须 Bearer)、4 个 REST 错误码、chat.documents(≤5、只收存储名、三入口同形)、DTO document→documents 数组(单数形状从未发布 ⇒ 无兼容期)、chars/truncated 改为「本回合注入侧」、document-read 工具面、contextWindow + 3 个新 env 键 + CONTEXT_MAX_TOKENS 三态(1M ⇒ 699050)+ materialize 语义(欠账)、语音进编排/voice_input.clientMsgId/voice_output 归属键与降级信号、pending_full 两键 + 新增定向 error{code:chat_busy}、thinking 实时/回放剥离与历史可查的口径。

- 子代理如实报出并已就地更正的三处:白名单实为 22 项(design/24 §9.2 写 23,已改设计)、document_unreadable 本期无 REST 落点(只登记为工具错误形态,已改设计 §6.2)、§6.1 单数 DTO 作废。

- 主会话复核:Docs 里 v0.59 出现 22/32 处、四个新小节标题齐、引用的 6 个 commit 全为真实提交;另补一句「预算推导只在启动时算一次 ⇒ 改窗口后需重启重算」与面板文案对齐。
2026-10-04 18:11:44 +08:00
AskaEth 47e56ca13c feat(admin): 面板暴露 contextWindow(可见可改)+ DocumentRef 语义注释同步
- 面板读写面与 maxTokens 逐处对齐:读 DTO 增 contextWindow(omitempty)、写请求 ContextWindow *int(缺失=保留、显式 0=未声明同义、负数 400)、回写 models.json;负数红线仍由 runtimeconfig.Group.Validate 唯一裁决,网关守卫只做「更早失败 + 面板可读字段名」。前端:类型 + 表单字段(提交前 /^\d+$/ 校验)+ 分组行角标 + Notice + 目录候选用早已解析却没人用的 contextLimit 预填。文案写明「重启后生效」(推导只在进程启动时算一次)。

- 顺手修既有红线违反:ProviderFields 87→76(抽 ApiKeyAndBudgetField)、LlmProviderForm 113→74(buildRequest 提为模块级纯函数)——本次碰过的文件里已无 >80 的函数,行为/文案逐字未变。

- DocumentRef 的 chars/truncated 注释从「抽取侧」改为「本回合注入侧」(全文长度只在 document-read 的 totalChars、extractTruncated 专指撞 8 MiB 安全上限);机械校验为空 ⇒ 代码零改动。

- 主会话独立验收:隔离 worktree Go 侧 build/vet + gateway/history 两包绿;主树 npx tsc --noEmit exit 0、npm run build 成功并 copy 到 internal/webui/dist;注释机械校验为空;变异由子代理复现(写路径不认该键 ⇒ 3 红;去掉网关负数守卫 ⇒ 400 变 500)。
2026-10-04 18:11:43 +08:00
AskaEth 3a01b4e236 feat(gateway): 语音回合进编排+落库、G5 队列满带回身份、voice_output 归属(安卓 G5/C1/C3)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- G5:pending_full 增 clientMsgId/messageId(仅在可给出时出现、绝不新铸 id —— 队列满的拒绝发生在幂等键认领之前,代铸会让客户端重试被判重复而永远进不来);另定向回发送者 {type:error,data:{code:chat_busy,...}}(与 REST 409 chat_busy 同码),广播帧照旧带同样的键供其它端对齐。

- C1:语音回合改走与文本同一条路径(编排器 Submit → 落库 → 流式 → 跨端同步),deviceId 走既有 withDeviceID、platform 走存储默认 web;未接编排器时保留旧直连路径。音频在回合结算处(persistAssistantReply → persistDocumentStatus → dispatchVoiceReply)合成并定向回原连接,回调各自 goroutine(TTS 延迟不拖住 pending_drain/session_idle);续跑 FIFO 与转向注入两条路径都带走回调 ⇒ 忙时排队/被注入的语音也不丢音频。

- C3:voice_output 增 sessionId/deviceId;三处主动加码(按最优方向):① voice_input 增可选 clientMsgId(语音也有幂等)② 不再拿 stopReason 顶替空正文当语音内容(空正文走 provider 降级信号)③ TTS 失败照旧回帧 + provider:silent(不静默)。

- 拆 chat_documents_test.go(510 行越界)→ 373 + 160,只搬不改(拆前后同 -run 过滤 PASS 集合逐条一致)。

- 主会话独立验收:隔离 worktree(HEAD + 本批次 11 文件)build/vet 绿、gateway -race 绿、全树 59 包(唯一红是既有负载 flake orchestration/TestSpawnTimeoutFromToolDefault)、linux+darwin 交叉编译绿;变异独立复现(pending_full 摘掉 clientMsgId ⇒ 3 条红)。

- 待办(已登记):Docs/api-client.md 与 api-reference.md 的帧/字段登记随 v0.59 一并落笔(本批次刻意未动 Docs,避免与契约批次打架)。
2026-10-04 17:59:28 +08:00
AskaEth 72b95df22b feat(config,runtime): 上下文预算按模型窗口推导 + usage/缓存可观测 + sidecar 存全文(T1)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 窗口声明:models.json 组级 contextWindow + env LLM_CONTEXT_WINDOW(per-group materialize 播种,0 不写盘、负数报错)。

- 预算三态:CONTEXT_MAX_TOKENS 未设置 ⇒ 按窗口推导;>0 显式覆盖;0|负 关闭压缩(不变)。推导 budget = min(窗口×比例, 窗口−输出保留),比例默认 2/3(CONTEXT_BUDGET_RATIO,1M ⇒ 699050 tokens ≈ 683Ki);输出保留 = max(32768, 该组 maxTokens) 且封顶窗口/2(防配置矛盾时退化成 1 token);窗口未声明 ⇒ 回落 131072 + 恰好一条 WARN。接线在组合根 wire_context.go(唯一决策点)。

- usage/缓存可观测(此前**完全不可观测**):Response 增 CacheHit/CacheMiss/Reasoning tokens + CacheHitRate;解析 OpenAI 兼容(prompt_cache_hit_tokens/prompt_cache_miss_tokens/cached_tokens/reasoning_tokens)与 anthropic(cache_read_input_tokens/cache_creation_input_tokens)两种形状;记录点在适配器 record(换入口不丢成本日志),日志无凭据;单次输入 ≥预算 90% 记 WARN(不阻断)。

- 根治清单「续读」假承诺:抽取期不再按注入预算截断(sidecar 存全文,只受 MaxSidecarBytes=8 MiB 安全上限,ExtractorVersion v1→v2 旧 sidecar 失效重抽);注入预算改在装配期生效 ⇒ ref.Chars/Truncated 是注入侧事实、游标真是继续读的起点;document-read 的 limit 上限改为独立具名常量。

- 顺手修越界:config.go 505 行(超 500)拆出 llm.go。

- 主会话独立验收:隔离 worktree(HEAD + 本切片 39 文件,排除在途 gateway)build/vet 绿、全树 59 包 0 FAIL、linux+darwin 交叉编译绿;变异独立复现(比例改 0.25 ⇒ 推导用例红 262144 vs 699050)。
2026-10-04 17:57:08 +08:00
AskaEth 5b2b385524 feat(gateway): 文档多值化 + 抽取结论回写(design/24 S3.5)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 缺口①:「一回合最多 5 个附件、落库只留 1 个」——document 列语义升级为 ≤5 个引用的**数组**(列名/字段名不变、无需迁移,旧单对象行可读回);落库收集全部引用、转录还原全部部件。

- 缺口②:抽取结论 status/chars/truncated 经 completeTurn 回合后**回写**该回合用户消息的同一列(id 按代次配对,忙时消息挂 pendingMsg.persisted 并在 drain 时以新 gen 重新配对);历史 DTO 改为 **documents: []**(omitempty ⇒ 0 个附件时省略)。

- 防御:编/解码超 5 都报 ErrTooManyDocuments(报错、绝不裁剪);坏列走既有不静默路径(转录 WARN + DTO unreadable 最小引用);>5 上限值被独立用具例钉死。

- 顺手修 S3-B 遗留的函数越界:submitInternal 81 → 79 行(播种块抽成 seedTurnLocked);session_orchestrator.go 484 行。

- 主会话独立验收:隔离 worktree(HEAD + 本切片 12 文件)build/vet 绿、全树 59 包 0 FAIL、linux+darwin 交叉编译绿、gateway+history 437 PASS;变异独立复现(只留第一个附件 ⇒ 2 条红,含 HTTP 端到端 want 3 条)。

- 遗留(已登记):chat_documents_test.go 510 行(S3-B 遗留越界,本次未拆);中断/被取代的回合不回写结论;回写后不补发实时帧(status 需重新拉取/回放可见)。
2026-10-04 17:42:16 +08:00
AskaEth 20c1edb36a feat(tools): document-read 按需读取工具(design/24 S4)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 只读 S3-A 的 sidecar 正文(首行元数据行不计入 text),按字符分页,带 truncated/nextOffset 续读语义与 extractTruncated(区分「正文到此为止」与「原文件到此为止」)。

- 越界三态不静默:offset<0 / offset>totalChars → ErrDocumentOffset(碰盘前拦下,文案带总字符数);offset==totalChars 合法(读到末尾)。名非法/无 sidecar/unreadable → 哨兵错误 + 中文文案,绝不返回空正文假装成功。

- 读侧出口收敛到 documents 包:新增 ReadSidecar(含 Sidecar/Readable/ErrSidecarUnavailable),sidecar.go 的 load 变薄壳且对外行为逐字不变 ⇒ sidecar 形状与名字形态校验全仓只有一份实现。

- 注册与既有 6 个 io 工具同一路径(tools.go),策略默认 owner-only(群聊/陌生人默认调不到)。

- 主会话独立验收:隔离 worktree(HEAD + 本切片 6 个路径)build/vet 绿、全树 59 包 0 FAIL、双平台交叉编译绿、io 包 60 PASS;变异独立复现(工具名常量改成 document_read ⇒ 注册名单两条用例红)。

- 报告里发现的真实断裂(清单游标恒等于 sidecar 正文长度 ⇒ 照它续读必空)已登记,并入 T1 以「sidecar 存全文 + 注入预算与抽取预算解耦」根治。
2026-10-04 17:36:56 +08:00
AskaEth a4789ec13e feat(gateway): chat.documents 解析 + 修两处静默吞掉(design/24 S3-B)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- REST(同步 + stream:true)与 WS 两条入口同形解析 documents:只收服务端存储名(形态 + 真实存在 + 嗅探 MIME + 本地 URL + 大小),客户端谎报 mime/url/size 一律被服务端覆盖 ⇒ 不重开 SSRF 面(有「真实可命中 URL,命中次数 0」的用例钉住)。

- 拒绝口径对齐 S1:超过 5 条 → 400 too_many_documents(且先于任何 FS 访问);形态非法/穿越/不存在 → 404 document_not_found。

- 挂载:一回合一条 RoleUser 消息,文档部件追加其后(纯附件回合不塞空文本块,空文本会被 provider 拒)。

- 修 S2 移交的两处静默吞掉:session_transcript 用 history.DecodeDocument 把 h.Document 还原为部件(坏列带来源 WARN 不静默、也不卡会话);session_tail 的 messagesEmpty 计入 ContentTypeDocument(纯附件不再被判空回合)。

- 拆文件/拆函数(AGENTS:文件≤500 行、函数≤80 行):new chat_documents.go(rest.go 原 499/500 行塞不下)、handleChat 尾段 54 行原样搬成 submitChatTurn(handleChat 90→40 行),ws_hub 增 documents 依赖(与既有 envResolver 同接法)。

- 主会话独立验收(隔离 worktree = 当前 HEAD + 仅 B 的文件 ⇒ 正是 A+B 合流态):build/vet 绿、全树 59 包 0 FAIL、13 条新用例 PASS、linux/darwin 交叉编译绿;变异独立复现(摘掉条数上限判定 ⇒ 红)。

- 主会话补的测试钉子:TestMaxChatDocumentsMatchesDesign —— 原条数用例是用常量自身算输入条数的,改常量它照样绿(实测 5→1000 全包仍绿);补钉后改 10 时新钉子红、旧用例仍绿。
2026-10-04 17:25:35 +08:00
AskaEth a8851dc0a2 feat(runtime): 文档摄取①(抽取器 + sidecar + 装配期 resolveDocuments)(design/24 S3-A)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 抽取器零新依赖纯函数:UTF-8 清洗、CSV/TSV 行数列名摘要、64K rune 安全截断;PDF/Office/二进制走哨兵错误诚实拒绝(不引解析库)。

- sidecar:<DataDir>/extracted/<存储名>.txt,首行紧凑 JSON 元数据 + 正文;临时文件 + rename 原子落定;只抽一次(删原文件仍出正文);版本不匹配/坏缓存重抽。

- 装配期 resolveDocuments(resolveImages 同位,每步对模型可见视图注入):清单恒在(解析器缺席/报错也在)、截断带续读游标、失败诚实标记 + status=unreadable、回合继续;文档部件永不抵达编码器(负例钉住)。

- 修 S2 移交的两处静默吞掉:checkpoint 比较键纳入文档引用、compactor token 估算计入文档部件(不是免费的)。

- 主会话独立验收:隔离 worktree(HEAD + 本切片)build/vet 绿、全树 59 包 0 FAIL、linux/amd64 + darwin/arm64 交叉编译绿、documents+loop 两包 167 条 PASS;变异独立复现(resolveDocuments 短路 ⇒ 3 条红,含「文档部件仍抵达下游」)。
2026-10-04 17:22:05 +08:00
AskaEth 834ef3683e docs(agents): 铁律 §10 增「.env.example 与实现同步」固定动作(E6 的根治)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
与「改帧就同步 api-client.md」同一类:新增/改名/删除任何 env 键即同步模板;键名逐字一致、值一律占位符、废弃键显式标注。附自查口径(键集合 comm 差值只许剩已说明噪声)。
2026-10-04 17:14:56 +08:00
AskaEth 042cd586af docs(env): .env.example 与实现同步(协同 E6,主会话已独立复核)
- 补 26 个键:Telegram(2) / OneBot v11(8) / 通用 Webhook(5) / 微信 iLink(5) / CAPACITY_*(3);全部注释占位符、无任何真实凭据(长串扫描为空)。

- CAPACITY_* 核实为**在用**(internal/config/config.go:351,354,361 + cmd/cyrene/wire_capacity.go),按在用补入并写准「0 = 用代码内默认 0.6」。

- MCP_* 扁平单 server env 模式核实为**无生产调用者**(mcp.Load/FromEnv 只被同包单测调用;生产装配只读 MCP_SERVERS_JSON)⇒ 模板里显式标注「未接线,配了不生效,勿用」。

- 复核:代码键 124 / 模板键 110;模板无孤儿键;comm 差值仅剩 OS 变量/测试 helper/注释误命中共 14 个,逐条已说明;CHANNEL_* 22 键与代码逐字一致。
2026-10-04 17:14:56 +08:00
AskaEth f2c12b1921 docs(agents): 铁律 §10 增「仓库根产物卫生」红线(实测事故)
- 事故:子代理跑 go build ./cmd/cyrene/...(单 main 包)把可执行文件写到当前目录,
  静默覆盖正在用的发布产物 cyrene.exe(且未 strip,39.3 MB → 49.1 MB)。
- 规矩:子代理不许跑会在仓库根产出可执行文件的构建;换二进制只允许
  scripts\ctrl.bat build(唯一权威构建)+ 一次 restart,且必须在整树验收通过之后。
2026-10-04 17:08:41 +08:00
AskaEth 6b2b07cbef feat(runtime): document 内容类型与持久化(design/24 S2)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- model 新增 ContentTypeDocument + Content 的四字段 + DocumentMessage 构造器 +
  ValidateDocumentParts(文档部件只允许 RoleUser,非 user ⇒ ErrDocumentOnNonUser,fail-loud)。
- 两个编码器入口(buildMessages / buildAnthropicMessages)对文档部件报 ErrBadRequest:
  本期 document 永不抵达编码器,且绝不静默丢块。
- history:拆出 types.go(Message 增 Document 列),新增 document.go(DocumentRef 同时是
  落库 JSON 与 DTO 形状,6 字段含 status ⇒ §4.4 的「读不到要可见」;坏列回 unreadable 不静默)。
- chat_messages 加 document 列(迁移 0015 + NewStore 幂等前滚);historyMessage.Document DTO;
  session_persist 的 messageTextImage → messageTextImageDocument(保留窄视图以免牵连 steer 配对)。
- 只落「引用 + 统计」,绝不落抽取正文(TestPersistedDocumentHoldsNoBody 把全部列拼起来扫正文)。

验收(主会话独立复跑,非自报):隔离 worktree(HEAD + 本切片)go build/vet 绿、
全树 58 包 0 FAIL、linux/amd64 + darwin/arm64 交叉编译绿、18 条 Document* 测试 PASS。
变异独立复现:ValidateDocumentParts 开头 return nil ⇒ 非 user 负例红。
2026-10-04 17:07:24 +08:00
AskaEth 407c934a05 feat(gateway): 文档存储端点 POST/GET /api/v1/documents(design/24 S1)
- 字节嗅探唯一裁决 + 扩展名白名单 deny-by-default(23 个文本/代码扩展名);
  客户端 Content-Type 与文件名只作参考、可被否决。
- 内容寻址落盘名 sha256[:32]+ext ⇒ 同字节同扩展名天然去重;首次 201/deduped:false,
  命中 200/同一 name/deduped:true。
- 独立限额 CHAT_DOCUMENT_MAX_BYTES(默认 20 MiB,与图片 5 MiB 刻意解耦)+ MaxBytesReader。
- 读侧 Bearer owner|admin + Content-Disposition: attachment + nosniff(与图片读侧刻意不同);
  写侧审计 document.upload;错误码 unsupported_media_type/document_too_large/document_not_found。
- 元数据表 documents(0014,含 idx_documents_name 唯一、idx_documents_sha256);
  guardDocumentPaths 防路径穿越(否则 ServeMux 会 307 泄漏路径)。
- 既有 /api/v1/upload 与 /api/v1/files/{name} 零改动(upload.go 零 diff)。

验收(主会话独立复跑,非自报):隔离 worktree(HEAD + 本切片)go build/vet 绿、
全树 58 包 0 FAIL、linux/amd64 + darwin/arm64 交叉编译绿、9 条新单测 PASS。
变异独立复现 3 条:白名单加 ".js" ⇒ 415 用例红;嗅探裁决一律放行 ⇒ 谎报类型用例红;
摘掉 guardDocumentPaths ⇒ 穿越用例红(307 重定向,正是那道防护存在的理由)。
2026-10-04 17:07:20 +08:00
AskaEth 52f08dc343 docs(cyrene): cmd/cyrene 存量英文注释中文化(22 个非测试文件,仅注释)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 四组并行完成:main/wire*.go、wire_capacity|channels|runtime、channels|persona_*|emotion|observability、
  voice|plugins|stream|skills|vision|subagent_tool|extractor|mcp|sessionread_tool|terminal_tool。
- 机械校验(git diff -U0 去 // 行与空行)仅剩 wire_frontend.go:84 一处行尾注释:代码前缀
  「\ta.gw = gw 」经 cat -A 与 HEAD 逐字节比对一致(allow 清单已声明)。
- 验收:隔离 worktree(HEAD + 本批 patch)go build/vet 绿、全树 58 包 ok、
  linux/amd64 + darwin/arm64 交叉编译绿;gofmt -l 干净。
- 备注:整树两次红均为负载敏感 flake(backthink / orchestration 的 timeout 类用例单独 -count=20 全过)。
2026-10-04 16:58:06 +08:00
AskaEth 5c2189efb0 docs(mcp): 存量英文注释中文化(仅注释,代码零字节改动)
- capability/mcp 整包 17 个文件核查:仅 config.go / registry.go 含英文注释(38 行)→ 全部中文化;
  其余 15 个文件本就全中文,零改动。
- 保留 YAML/Go/JSON 示例行与字符串字面量(非注释,禁止改动)。
- 验收:机械校验为空;gofmt -l 干净;go build/vet/test ./internal/capability/mcp/... 全绿。
2026-10-04 16:58:01 +08:00
AskaEth 2593765add docs(iot): 存量英文注释中文化(仅注释,代码零字节改动)
- 5 个文件:123 行英文注释 → 99 行中文注释(中文更紧凑,内容无删减)。
- 机械校验(git diff -U0 去掉 // 行与空行)输出为空;gofmt -l 干净(改动前亦干净)。
- 验收:go build/vet/test ./internal/iot/... 全绿。
2026-10-04 16:58:00 +08:00
AskaEth 127c32b440 fix(vision): 图像 URL 取回失败的 WARN 带来源主机(可诊断且不外泄路径/查询串/凭据)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- loop 解析点用结构断言取 SourceFailure 的主机(errors.As 穿透 %w 包装),
  不导入 vision 包、不引入方向性依赖;拿不到来源时留空串而不编造。
- 新增 TestImageSourceHost 四例(nil/普通错误/包装后来源/空主机),
  变异验证:把返回值改成 "" 该用例必红(已复跑确认)。
2026-10-04 16:46:57 +08:00
AskaEth 910d154d35 feat(config,cyrene): .env → 面板配置的启动期 materialize(主人裁决:面板是一等来源,.env 退回入口/播种)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 主人原话:「.env 只是基础入口,生产环境下应该在每次启动进程时从 .env 把变量(包括后续更新过的)
  写入面板专用配置文件,而不是仅依赖 .env 这个脆弱的文件。」⇒ 根治「每加一种能力就再加一个 env 键」
- 规则(主会话裁定,已写入 02 回执):
  · **组集合**:面板配置非空 ⇒ 完全由面板决定(env 不新增/不复活被删组/不重排);缺失/空/[]/null ⇒ 由 .env 播种(=今天 env-only 行为)
  · **键级**:面板缺 provider/baseUrl/apiKey/model 某键 ⇒ 用 .env 补;**面板已有该键(含显式 ""/null/false)⇒ 面板优先,绝不覆盖**
  · 判定用原始 JSON 的**键存在性**(故显式空值也算声明);`len(Filled)==0` ⇒ 一个字节都不动(幂等)
  · 面板不可读/解析失败/补齐后不合法 ⇒ **不覆盖原文件**(保人工可救),只 WARN;写盘失败不阻断启动
  · 带 omitempty/omitzero 的 fallback/multimodal/reasoning.kind **不在补齐表内**(面板清空与未声明字节相同,
    补回会撤销面板操作);它们仍以面板为准、文件缺失/无效时由 .env 兜底
- 落点:`internal/config/runtimeconfig/materialize.go`(导出 Materialize + 诊断 Result/Fill)+ 组合根
  `cmd/cyrene/models_materialize.go`,在 `NewRuntimeLLM` **之前**跑一次(**不在热重载路径上**,否则面板刚清空的键会被立刻补回)
- 复核:build/vet 绿 + 全树 `go test ./... -count=1` 全绿 + linux/darwin 交叉编译绿;6 条真断言
  (面板值胜出且字节不变 / 缺键补齐并落盘 / 被删组不复活 / 五态播种 / 幂等逐字节相同 /
  multimodal 回归两路径);**4 条变异反证**(一律 env 覆盖、只首启播种、组集合由 env 决定、无可补也重写)均已逐字节还原;
  另加端到端:`.env`→启动补齐→真 NewRuntimeLLM→真面板写路径改成 panel-model→再启动 ⇒ 仍是面板值
- 契约面:REST/WS/admin 零变更(只改本地面板配置文件 models.json 的语义);待落 Docs v0.59 一条
2026-10-04 16:39:33 +08:00
AskaEth 1728addcfe docs(api): api-client → v0.58 登记 LLM_MULTIMODAL(env-only 部署的主模型收图声明)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- §2 的 .env 键表:9 键 → **10 键**(v0.53 首登 9 键,v0.58 追加 LLM_MULTIMODAL),语义写全
  (默认 false;**仅供 .env 合成组**,有 models.json 时以组为准,与 LLM_REASONING_KIND 同口径;
  true = 图直进主模型上下文;非 bool 启动即报错,不静默回退;与两个工具模型键互不代表)
- §1 行 3(chat.image)补指针;§5 新增 v0.58 条(纯新增 env 键、默认 false ⇒ 老部署行为逐字不变)
- api-reference:版本指针 → v0.58,并在 §0.3 / §3.1 / §4.9 四处补明 env 等价键
- 表计数逐行核对一致(标题 10 键 ↔ 表体 10 行)
2026-10-04 16:33:09 +08:00
AskaEth 94b882f4d9 perf(vision): 图片 URL 的「取回 + 内联」按内容哈希 memo(协同 H1:每回合重复付费/取回)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 起因(Android 注册的成本项 H1):`resolveImages` 每回合都跑,而 `inlineImageSrc` 对 `data:` 短路、
  对 **URL 每回合重新取回并内联** ⇒ ① 同一张图反复网络 I/O;② 来源一旦失效则该回合解析失败,
  而 loop 保留原消息不中断回合 ⇒ **模型这轮静默看不到图**
- 修法:按**内容哈希**记忆「URL → data URI」结果(有界 + 并发安全,独立小文件承载);
  **失败不写缓存**(不把坏结果钉住,第二次仍可重试);命中即不再发起网络请求
- **来源失效不再静默**:取回失败产生可诊断信号(含主机与原因,**不含图片内容/凭据**),回合照旧继续
- 复核:vision 包 build/vet/测试绿(含 `-race`);断言覆盖「同一 URL 连续解析 → 上游只被取回 1 次且结果逐字节相同」
  「内容变化不被旧缓存命中」「失败不写缓存且第二次仍重试」「容量上限生效」,以及既有断言
  (非图片 `ErrNotImage` / SSRF 被拒 / 长度预检)不回归;变异反证已还原
2026-10-04 16:32:13 +08:00
AskaEth b65fe05635 docs(api): §3.5 补 input 节点 props.steered(13.4.5 的图增量键,此前只登记在 v0.57 变更记录里)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- `input.steered: bool` = 本回合在**回合内边界**吃过新到达的用户消息时出现;纯增量、可选,
  标记只并入既有 input 节点(不新建节点、不改标签,图仍一条主干)
- 依据:`internal/runtime/loop/flow.go` 的 MarkInputSteered(nil/白名单外为空操作)
- 同步(DevDocs,不进 git):design/15 末节接口表改为实态 —— 转向源是**按回合挂在 ctx 上**的
  `loop.WithSteerSource`/`SteerSourceFrom`(不是构造期注入的 HistoryReader),理由=「哪些消息是本回合
  新到的」是按回合的状态、单例 loop 无法持有 ⇒ 组合根零改动;`steer_ack` 写法改为帧壳 + data 实态;
  并修 §2.4 的「按 created_at 去重」口径(实现只按转录行 id 去重 + (内容,图片) 配对,**不用时间戳**)
2026-10-04 16:30:57 +08:00
AskaEth 4adfc9a2df feat(config): 新增 LLM_MULTIMODAL —— 让 env-only 部署能声明「主模型自身能收图」(默认 false,行为不变)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 起因(Android 会话质询):其测到生效配置 multimodal:false 却与 DeepSeek 视觉文档矛盾。核实结论:
  **既非设计选择也非漏配,而是 env 路径的表达力限制** —— `data/models.json` 不存在 ⇒ 走 `GroupFromEnv`,
  而它此前硬编 false(理由:不得声称端点可能不具备的能力);该标志的正式载体只有 models.json 组(面板写入)。
  后果:env-only 部署里图像**恒走 KindVisionTool**(每回合多一次完整模型调用 + 模型只见文字描述=有损)。
- 修法:`LLMConfig.Multimodal`(`env:"LLM_MULTIMODAL" default:"false"`)+ `GroupFromEnv` 两个 provider 分叉都接入;
  **只对 env 合成组生效**(有有效 models.json 时组文件优先,与 LLM_REASONING_KIND 同口径);非 bool **启动报错**不静默
- 复核:config 包测试绿(5 条:默认不变 / 显式 true 生效且组仍 Validate / 进程 env 端到端 / 非法值 fail-fast /
  组文件优先两方向);**变异 3 条**(分叉写死 false、default 改 true、env 覆盖组文件)均已还原
- 交付给主人的动作:**在 `.env` 写 `LLM_MULTIMODAL=true`** 即可让图直进主模型上下文(改完需重启后端生效)
2026-10-04 16:30:25 +08:00
AskaEth 6c164f920b docs(api): api-client → v0.57 补登 steer_ack(13.4.5 漏登记,Android 端 diff 版本号时发现)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- §3 下行帧清单 + 角色门第 ② 档加入 steer_ack;新增 **§3.14**(触发/载荷/语义「已吃进·未正面回答 ⇒ 灰态」/
  回合内边界/与 pending_ack·session_busy 的分工/不发的四种情形/只吃本编排器排队过的消息/兼容性/
  **客户端现状=Web 前端尚无 handler** 的诚实边界/经 hub 广播不经 bus)
- §5 新增 v0.57 条目:I1/I2/I3 不变量、注入范围(只进本轮视图,不进 checkpoint 与记忆面)、失败让路、
  `graph_update` input 节点 `steered:true` 增量键、实现落点、兼容性与变异验证
- api-reference §6.4 角色门与下行表补 steer_ack(指向 api-client §3.14,不复制第二份契约);
  §6.5 补一句「回合内 steer」(否则 pending 语义会被读成「只能等下一个回合」)
- 核对方式:逐行「文档口径 ↔ 代码依据」表(帧名/载荷/atStep 1 基/角色门/不经 bus/注入不写回/未接线逐字不变)
- 登记待补:§3.5 的 props 清单缺 `steered`;design/15 的接口表写 HistoryReader 而实现是 SteerSource(已滞后)
2026-10-04 16:29:31 +08:00
AskaEth 3dc1232bc2 docs(comments): 注释中文化 · internal/runtime/model + internal/services/backthink(54 文件,纯替换)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 只翻注释/文档字符串:`go/scanner` 剥注释后按 token 序列+literal 逐 token 比对,**54/54 文件 SAME**;
  改动文件总行数与 HEAD 漂移 = 0(严格 1:1,无合并/拆行)
- 裁决编号与 design 节号逐字保留(03.4.1/03.4.2/03.4.3/14.4.1/14.4.5/07.4.2-4、design/04 §3.1/§3.2 等);
  `chat_template_kwargs` / `enable_thinking` / `reasoning_effort` / `thinking:{type}` 字段名与形状说明未动
- 存疑译法(已登记):`persona` 在叙事语境用「角色」、标识符语境保留 `persona`(仓库既有模块本身混用);
  `rung`→「级」;`provider` 存在「提供商/提供者」两种落笔(可按需一键统一)
- 复核:build/vet 绿 + 两包测试绿;gofmt 干净;残留英文注释 0 条
- 既有存量(未修,只报告):`model/anthropic_test.go` 612 行 >500;`applyAnthropicEvent` 115 行 >80
2026-10-04 16:27:54 +08:00
AskaEth f5cf117482 refactor(gateway): 图片上限单一真源 —— 以 vision 硬顶为准,配置只能收紧(消除「网关照放、vision 拒」)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 原状:网关与 vision 各有一份同值常量,且网关上限可由 CHAT_IMAGE_MAX_BYTES 调大 ⇒ 调大后
  **网关照放、vision 拒绝**(ErrImageTooLarge),base64/URL 路径上「调大配置」失效
- 修法:网关改为**引用 vision 的常量**(`vision.MaxInlineImageBytes` / `InlineImagePrefixAllowance` /
  `MaxInlineImageChars`,import 方向与既有的 voice 同层、不违规);`chatImageLimitBytes` 语义=
  「configured 生效但**钳在 vision 硬顶**」(只收紧不放宽);默认口径直接用 vision 常量(不再自算)
- 复核:gateway 包测试绿(9 条 ChatImage,含默认逐字节同值、配 1 MiB 生效、配 8 MiB 被钳到硬顶且
  同一串在 vision 侧也 ErrImageTooLarge ⇒ 两侧同一答案);**变异两点**(去掉钳位分支 → 断言红;
  再清空解析循环 → 实测复现「配 2× 硬顶时网关 200 放行」)均已还原
- 登记:真要支持 > 5 MiB 需给 vision 可注入上限(并把 /upload 的 5 MiB 一起改);`.env.example`/`config.go`
  注释/`api-reference` 仍写「上限=CHAT_IMAGE_MAX_BYTES」未提钳位,待同步
2026-10-04 16:27:35 +08:00
AskaEth e64ea0b156 fix(vision): SSRF 三层守卫 + base64 长度预检(纵深防御,F3/G3 的收尾)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- SSRF 三层:① 字面主机判定(**链路本地** `169.254.0.0/16`/`fe80::/10`(先 Unmap ⇒ `::ffff:169.254.169.254`
  绕不过)+ 云元数据 `100.100.100.200`、`fd00:ec2::/64`);② 重定向逐跳校验 + 上限 5 跳;
  ③ **建连前校验解析结果并用校验过的 IP 直连**(防 DNS 重绑,拨号器不再二次解析)
- **没打断同源自取**(两条独立证据):`127.0.0.1:<port>/api/v1/files/<32hex>.png` 端到端取回成功、
  私网地址**原样交给底层拨号器**;被拒地址则底层拨号器 0 次调用(无真实连接发出)
- 失败文案**不含 URL/查询串签名**;非图片响应仍 `ErrNotImage` 且不泄漏响应体
- base64 路径长度预检(**先于 DecodeString 与 `data:` 短路**,零分配):口径与网关同值同推导
  (`((5MiB+2)/3)*4+64`),超限 `ErrImageTooLarge`、文案只含上限与本次长度
- 复核:vision 包 build/vet/测试绿(15 条新用例);**4 条变异反证**(守卫整体短路 → 8 条被拒地址全中;
  只短路链路本地分支 → 4 红且实测「无守卫时进程真的去连 169.254.169.254」;预检恒 nil → 3 红;
  去掉重定向校验仍全绿 ⇒ 证明第三层独立兜住)
- 登记:`CHAT_IMAGE_MAX_BYTES` 调大 > 默认时「网关照放、vision 拒」的配置耦合(待单一真源对齐)
2026-10-04 16:24:56 +08:00
AskaEth c96a7db7f1 feat(gateway,config,web,docs): backthink 实时帧显示打扰原因 + 图片上限可配 + api-reference 端点表补齐
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 前端:`lib/wsParse.ts` 的 `parseBackthinkItem` 现在也读原始 `PushResult` 的 route/detail/bypassed(兼容
  camelCase),与历史 DTO 路径**同名同义**(空串 → undefined、未报告的 bool 不编成 false);
  Backthink 页 Notice 改为如实描述。证据=用后端两条路径的**真实序列化**喂同一个 PushResult 做逐字段比对
- 图片上限可配:`CHAT_IMAGE_MAX_BYTES`(默认 5242880 = 与 /upload 同口径;**未配置/0/负 一律用默认**,
  不存在「禁用上限」);删掉重复的字符常量,换算只留一份 `chatImageCharsFor`;补 3 条用例
  (默认值==upload 预算、配 1 MiB 真生效且只有合法那条驱动回合、非正值回落默认)
- `Docs/api-reference.md`:§1 端点表按 `routes.go` **补齐**(auth refresh/logout、upload、files、三支 audio、
  /admin/plugins/installed、/admin/llm/catalog、observability/logs、notifications、owner-profile、reminders),
  既有编号一律未动(保交叉引用);两张 `historyMessage` 字段表补 `personaId`(+ `clientMsgId`);
  修正 §8.1「主人档案保存仍 501」与实现不符(已真实实现)
- 复核:build/vet 绿 + gateway/config 包测试绿 + 前端 tsc 0 错 + vite build 成功
- 登记:§1 各表的编号跨表重复(不重编号以保交叉引用);`admin_plugins.go:217` 注释写错鉴权档(未改);
  §6.4 的 WS 事件帧表仍未补全
2026-10-04 16:24:36 +08:00
AskaEth 9b01efd796 fix(loop): checkpoint 恢复加自洽性判据(纵深防御,堵「旧窗口吞新消息」的最后一层)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 背景:`loadCheckpoint` 恢复时会**整段替换**调用方带来的消息 ⇒ 任何「收尾落定前发起下一回合」的路径都会吞掉
  用户刚发的消息。已有两层防护(收尾 defer 只让 tool_calls 可恢复 + 网关回合围栏),本次补 loop 侧判据
- 判据(保守版):**只有在能证明「窗口已被取代」时才改判**——快照窗口的最后一条用户消息**确实出现在本回合
  带来的消息里**(说明同一对话谱系),且本回合的最后一条用户消息 ≠ 它(说明带来了更新的输入)⇒ 不恢复、全新回合。
  判不出来一律维持既有崩溃语义(fail-open 落在「保留崩溃恢复」一侧)
- 比对键=文本与图片引用按序拼接(纯图片消息也可区分,避免「刚发的一张图」被判成空键而漏判)
- 复核:`internal/runtime/loop` 的 `-count=1`/`-race`/`-shuffle=on` 全绿;**未动任何既有崩溃用例**;
  新增 4 条用例/9 条子用例(一致→仍恢复 / 有取代证据→不恢复且不含旧窗口 / 与网关围栏两条路径结论一致 /
  边界表含重复播种·纯图片·无交集);**变异两点**(判据恒假 → 4 处红;恒真 → 10 处红,精确打红崩溃用例)
- 待主人拍板(已登记):字面版判据(无交集也改判)会与「崩溃 + 用户随后发不相关新消息」的既有语义冲突;
  更彻底的选项是「**恢复旧窗口并追加新消息**(union 而非替换)」——需改那条用例与设计口径
2026-10-04 16:21:20 +08:00
AskaEth 3d8822c922 feat(loop,gateway): 13.4.5 目标态 —— 回合内 steer(不打断、新消息进同一上下文流)+ steer_ack 帧
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
规格=design/15 末尾「13.4.5 目标态规格」(不变量 I1/I2/I3)。调研依据:豆包无公开技术文档(不编造);
同类成熟 Agent 产品无一做同会话并行回合,共同范式= queue + 回合内 steer(Claude Code / Cline PR #13958 /
Codex Queue·Steer / open-webui / LangGraph)。

- 注入点=**回合内边界**(上一模型步 + 它那批工具调用都已结束、下一次模型调用还没开始):
  driveTurn 每 step 组装前调用一次 SteerSource(run.go)——完成 step 只能靠工具批次推进,
  故唯一可注入处就是「工具批次结束之后」,循环里没有任何「起第二个回合」的分支(**I1 结构性成立**)
- **I2 恰好一次**:注入只进**本轮 view、绝不写回 msgs**(checkpoint 与记忆提取面保持干净);
  被注入的消息从 pending 移除,不会由自己的回合再答一次
- **I3 转向不取消工具**:注入调用点在 runToolCalls **之后**(此刻无工具在飞),循环从不因注入 cancel 任何 ctx;
  边界序列有测试钉死(恰为 [1 2 …],无批次中途值)
- 判定口径= **pending ∩ 转录**:候选是本会话 pending 条目,每个边界重读 history.Recent 验证已落库,
  按**行 id** 去重、按 pending FIFO 注入。**不用时间戳**(Windows time.Now() 步长 ~0.44ms,连发常同毫秒并列,
  任何 ms 基线都会漏注入或重复注入);跨重启不成立也不需要(在飞回合与 pending 都在内存)
- 新下行帧 `steer_ack`:`{type, sessionId, data:{messageId, atStep}}`,语义=「已被本回合吃进、尚未被正面回答」
  (驱动前端灰态);**不进 frameReachesAll** ⇒ fail-closed 只发 owner+admin(测试双钉);经 hub 广播、不经 bus
- 组合根**零改动**:per-turn 状态用 ctx 承载(loop.WithSteerSource),编排器在回合启动/续跑时挂
- 守卫:session_orchestrator.go 加完接线后 505 行 ⇒ 按职责纯搬出 session_events.go(常量取值未改、行为零变化)
- 复核:build/vet 绿 + **全树 0 FAIL** + linux/darwin 交叉编译绿;loop 6 例(含 3 子测)+ gateway 2 例 E2E;
  **4 条变异反证**(删 consumePending → 模型步数 4≠3;注入写回 msgs → 快照被污染;批次中途注入 → 边界 [1 1 2];
  把帧加进白名单 → viewer 收到)均已还原
- 未做(登记):IM 自有 Chatter 落库的消息不参与 steer(有意,避免重复作答);pending 条目无自落库 id
  ⇒ 配对用 (内容,图片) 逐行认领;同回合超 20 条用户消息时超出者留 pending 由各自回合作答(不丢)
2026-10-04 14:53:40 +08:00
AskaEth 8c6c796a29 docs(api): api-client → v0.56(协同 A2/G2/G3/G4 契约 + 三个环境变量 + 修正两处错误陈述)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- A2:interrupt 新增 scope({"","all","current"}),三态语义 + 非法值定向 bad_request;
  session_interrupted 形状零变化但 dropped 恒为真丢弃数
- G2:stream:true 直达编排器;响应面补 202 {queued} / 409 chat_busy / 503 chat_dispatch_failed;
  未接 loop 由 200 改 500 chat_unavailable(行为变更已写明兼容性)
- G3:image 解码后 5 MiB 上限 → 400 image_too_large(指路上传);**如实写明 WS 侧到不了这条判定**
  (wsReadLimit=4096 传输层直接断连,发真实图片必须「上传 + URL」)
- G4:新增可选 clientMsgId(两端)⇒ 幂等;REST 200 {duplicate:true,messageId}、WS 新帧 chat_duplicate
  (owner+admin,fail-closed);键回填进 message_persisted/channel_persisted/读接口;迁移 0013
- .env.example + api-client 新增三个环境变量:OWNER_IDENTITY_INJECTION(owner_only 默认) /
  CONTEXT_MAX_TOKENS(32768;0/负 = 关) / GUARDRAIL_KEYWORDS(留空 = 不挂)
- 修正两处错误陈述:api-reference §6.2 与 §6.6 的「OnStream 只填 seq、不带 turnId」(v0.36 起即带
  sessionId+turnId,v0.50 起带 streamId);上传上限 50MB → 5 MiB
2026-10-04 14:42:46 +08:00
AskaEth 7512d593c2 fix(gateway,history): REST 流式不再静默失败(G2)+ clientMsgId 幂等(G4)(协同 §G,均标「中高/中」)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- G2:`stream:true` 原经**有损总线**且**先回 200** ⇒ 总线饱和时客户端拿到 200 却什么都没发生。
  改为**直达编排器**(与 ws chat 帧既有先例同一原则):新文件 chat_stream_dispatch.go;200 变成有意义的
  承诺(同步穿过「编排器接收 + 用户消息落库」才回);顺带对齐 `202 {queued,queueDepth}` / `409 chat_busy`;
  兜底路径(未接编排器才走总线)现在**返回错误**、ctx 与请求脱钩(WithoutCancel + 30min 上界)→ `503 chat_dispatch_failed`;
  未接 loop 由 200 改为 `500 chat_unavailable`(与同步路径同口径,已同步改既有用例并新增钉住用例)
- G4:`chat`(WS 帧 + REST 体)新增**可选** `clientMsgId` ⇒ 幂等。
  原子性两层,均非「先查再插」:① 同进程在 `o.mu` 同一临界区内完成「查认领表→查存储→预分配 id→认领」;
  ② 跨重启靠 `(session_id, client_msg_id)` **部分**唯一索引 + `INSERT … ON CONFLICT DO NOTHING`(命中则读回既有行)。
  重复语义:REST `200 {duplicate:true, messageId}`;WS 新帧 `chat_duplicate`(owner/admin 档);重复**不落库、不起回合**。
  `clientMsgId` 回填进 DTO ⇒ `message_persisted` 与读接口都可见(客户端可据此对齐本地气泡,不再靠文本相等)
- 迁移 `0013_chat_client_msg_id`(up:加列 + 部分唯一索引 `idx_chat_messages_client_msg_id`;down:先删索引再删列),
  已在 `data/cyrene.db` **副本**上验 up → 判重 → down → 再 up
- 拆分:store.go 647→498 行(`log_reads.go` 抽走四个读路径);七个 SELECT 清单抽成 `messageColumns` 一处
- 复核:**全树 `go test ./... -count=1` 0 FAIL** + build/vet 绿 + linux/darwin 交叉编译绿;
  四条真断言(总线饱和仍落库出帧且 `ws.chat` 零发布 / 同键两次仅一回合+并发两条也仅一回合 / 省略键逐字不变 /
  迁移副本 up-down-up);**3 条变异反证**均已还原
- 越界说明(均已报备):`history.go` DTO 字段(否则「读接口可见」不可能)、`policy_store_test.go`
  (0013 使「回滚最后一步 == 0012」前提失效,改为按 schema_migrations 现算)、`extra_test.go`(补 runner)
- 顺带补交 P0-A 遗漏的新测试文件 `loop/memory_writeback_test.go`
2026-10-04 14:37:37 +08:00
AskaEth da3a386daf feat(cyrene,config,loop): 护栏/压缩/身份知识三条落地(P0-5/P0-10/P0-11)+ 身份注入收窄到主人会话
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- P0-5 护栏不再是空壳:挂 WithSensitiveInfo(阻断) + 长度上限(输入 262144 / 输出 65536 字符,依据=
  组装完成的请求体量级与「不误伤正常对话」) + 关键词**仅在配置时**挂;正常对话有用例证明放行
- P0-11 上下文压缩默认启用:CONTEXT_MAX_TOKENS 默认 32768(=loop.DefaultMaxContextTokens,两常量由测试锁死),
  0/负 = 关闭(行为退回接线前);长对话真压缩且摘要消息进请求首位、最近一轮保留
- P0-10 身份说明书(owner.yaml identityFile)注入人格上下文,并且**收窄为仅主人会话**:
  · 新增最小钩子 loop.Options.PersonaKnowledgeFor(env):**按该轮会话环境**解析(不看提示词、不看会话 id),
    块只进每轮视图、**不进 messages**(不进 checkpoint、不进记忆提取面)
  · 开关 OWNER_IDENTITY_INJECTION:owner_only(默认) / always(仅自测) / off;非法值与读不动 → WARN + 回退默认(不静默)
  · **顺手关掉第二条同型泄漏**:channelbridge 群聊参与度判定器的 PersonaPrompt 此前带身份正文 ⇒ 现改为纯人格指令
  · 回归:缺文件/目录/未配置逐字节等于旧输出;静态指令与角色池 Instructions() 全不含正文;
    文件里点名的陌生账号仍 IsOwner==false(身份知识绝不参与权限判定)
- 复核:build/vet 绿 + cmd/config/loop 包测试绿;四类断言(主人含 / **群聊与陌生人不含** / always·off 两档 /
  回归不变)+ 4 条变异反证(含把判定改成恒真 → 提示词里真的出现身份说明书)均已还原
- 未做(登记):knowledge 块启动时读一次(改文件需重启);`.env.example` 的 OWNER_IDENTITY_INJECTION 行随后补;
  `internal/user/owner.go` 注释与 design/01 §2.2、design/11 §0 待同步
2026-10-04 14:30:26 +08:00
AskaEth 19f296a2ab fix(gateway): 回合围栏堵住「中断后立刻发新消息被旧快照吞掉」的时序竞态(13.4.5 最新性)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 根因(已确定性复现,非测试问题):中断把会话立即置 idle ⇒ 紧随的新消息被当成空闲回合**立即开跑**,
  而被取消回合的快照收尾在**它自己的 goroutine** 里、直到 Run 返回前才落定 ⇒ 新回合可能读到上一回合
  中途留下的**可恢复**快照(stop_reason=tool_calls)并把调用方刚传进来的消息**整段替换**
  (现场证据:DB 里 web:1(step=1,tool_calls) 与 web:2(step=2,stop) 并存,LoadLatest 选中 web:1;
  把收尾窗口拉长到 300ms ⇒ 旧码 3/3 必红)
- 修法:新增 session_turn_fence.go —— 把「同一会话最多一个在途回合」从约定变成**可等待的围栏**:
  新消息照旧被立即接受、Submit 立即返回(不阻塞硬约束不变),但其**回合启动**等上一回合真正返回
  (那时被放弃的快照已落定);等待同时盯本回合 ctx,不收敛的上一回合不会永久挂住会话
- 复核:整包 `go test ./internal/services/gateway/ -count=1` 绿(隔离副本 0/12);`-shuffle=on` ×3 绿;
  **全树 `go test ./... -count=1` = 58 包 ok / 0 FAIL**;变异两处(HEAD 原码跑新用例 3/3 红、拿掉等待即红)已还原;
  交叉验证:单独打回 loop 的「只有 tool_calls 可恢复」改动后,新用例仍 3/3 红 ⇒ 两侧修法互补
- 顺带:A2 的 `ws_route.go` 一行(定向回 bad_request 需要发送者的 conn;hub 级无按连接寻址入口)
- 遗留(登记):loop 侧 `checkpoint.go` 的恢复会整段替换调用方消息 ⇒ 建议加一致性判据(checkpoint 窗口
  最后一条用户消息 ≠ 调用方最新消息则不恢复);围栏未覆盖 orchestrator==nil 的 legacy 直连路径
2026-10-04 14:25:13 +08:00
AskaEth 5494c31474 feat(gateway): interrupt 支持 scope(当前/全部),默认维持现状(协同 A2)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 原状:interrupt{sessionId} 取消在飞回合**并清空 pending** ⇒ 客户端「打断模式」连发会被静默丢弃
- 修法:新增 scope 三态(与项目既有「省略=保留现状」口径一致)
  · 省略 或 "all" = 现状语义(取消当前 + 清 pending)
  · "current" = **只取消当前回合、保留 pending**(pending 继续按编排器规则续跑)
  · 非法值 → **定向回错误**(只回发送者,不广播),显式报错而非静默当默认
- 下行 session_interrupted 的 dropped 如实反映**真的被丢弃的条数**(scope=current 时通常为 0)
- 复核:gateway 包 build/vet/测试绿(新增 interrupt_scope_* 两组用例);后端 :10942 全程未停
2026-10-04 14:21:52 +08:00
AskaEth decf59c1b4 feat(gateway,admin-web): Skill 运行时安装 + backthink 打扰原因 + 建提醒入口(P0-19/P0-20/P0-22,审计 §4.3)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- P0-19:POST /admin/plugins/install 支持 `kind:"skill"`(此前一律 400)——yaml 或 path 二选一、
  name 一致性校验、显式错误码(bad_request/name_mismatch/skill_parse_failed/name_conflict)、
  装后以 kind:"skill" 出现在 GET /admin/plugins;**文件来源解析错误绝不回显文件内容**(变异验证实测确认
  该泄露向量真实存在:yaml 解码器会带出出错值前几字符 ⇒ 只回显路径 + 通用原因,细节进日志)
- P0-20:AdminBackthinkItem 补 route/detail/bypassed(DB 早已存,零迁移),面板 Backthink 展示
- P0-22:新增 POST /admin/automations/reminders(owner-only + 审计),at/message 必填、RFC3339 校验、
  409 already_exists;过去时间不作 400(下次轮询立即触发)
- 顺手修真缺陷:新建 id 原用 time.Now().UnixNano(),Windows 时钟粒度粗到毫秒 ⇒ 并行创建撞 id 得假 409;
  改用 crypto/rand 128 位 hex(仓库既有 skills/store.go 范式)
- 复核:gateway 包 build/vet/测试绿 + 前端 tsc 0 错;新增 12 用例(含 8 条非法输入显式码);
  **4 条变异反证**均已回滚
- 未做(登记):WS 帧侧的 route/detail 未在前端展示(`lib/wsParse.ts` 3 行,需放行);skill 未落
  skills.Store(重启后消失,注入点在 cmd/cyrene);提醒无删除入口(CancelReminder 已存在、无路由)
2026-10-04 14:21:41 +08:00
AskaEth 3638ed7708 fix(gateway): chat 的 image 加上限并显式报错(G3,审计协同 §G)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 原状:image(base64/URL 字符串)在 API 层**无长度上限**、原样落库 ⇒ 一张 5 MiB 的图 ≈ 每页历史重传 6.7 MB
- 修法:入口层(handleChat,位于 stream 分叉**之前** ⇒ 同步与 stream:true 两条路径同拦)按**字符串长度零分配**判定,
  超限 → 400 `image_too_large`,文案给出上限并**指路 POST /api/v1/upload**;日志只记 session/chars/limit(不打图片内容)
- 上限口径:**与 POST /upload 的 maxUploadBytes = 5 MiB 取同一个答案**(否则内联 base64 可绕开上传限制);
  字符上限 = ceil(n/3)*4 + 64(覆盖 data URI 前缀),**恰好 5 MiB 的整幅图编码后 = 6,990,508 字符,绝不误拒**
- 复核:6 条用例(上限与 upload 同值、边界 ==limit 通过 / +1 拒、超限**0 个回合被驱动**、
  stream:true 同样被拒、恰好等于上限逐字节到达 runner、小图行为逐字节不变);
  **2 条变异反证**(忘接守卫 / 边界改 `<`)均已还原
- 实测附注:WS 侧今天**已被传输层关死**(wsReadLimit=4096,8KB chat 帧直接断连)⇒ 多 MB base64 无法经 WS 进入;
  若日后放宽该上限,必须在 ws 入口补同一个校验(回报里给了精确落点)
- 未做(登记):上限暂不可配置(等 config.go 空闲)、REST 请求体仍整体读入内存(MaxBytesReader 属行为变更)、
  vision 层 base64 路径无长度预检(纵深防御)、历史 base64 行需离线批处理(方案已出)
2026-10-04 14:21:12 +08:00
AskaEth fbad02899a fix(memory,loop,capacity): 打通记忆链三条空壳(P0-3 / P0-4 / P0-17,审计 §4.3)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- P0-3 持久向量索引**不再空壳**:Store.Add 落库后真做 Embed+Add(失败只记日志、绝不让记忆写入失败);
  NewRetriever 拿到 WithVectorIndex 时**自动接上写入路径**(无需改组合根);索引无命中 → WARN + **回退内存余弦**;
  新增每进程一次的**回填**(索引确实为空时把已落库条目向量化,分批 64、上限 512)
- P0-4 工作记忆**按会话接线**:回合落定时写入(owner 会话 + 带会话 id 的窗口,LRU 上限 64),
  组装上下文时按会话读取 ⇒ 前导块真的出现;原单窗口 API 语义不变
- P0-17 L4 学习回环**真回写**:新增 capacity.Judge(只跑判定、不施人格覆盖/不发事件/不改状态);
  loop 在回合落定"只写 owner 会话 + 跳过子代理分支",写 ScopeUser(L4 的读域)+ 结构化溯源
- 顺带红线加固:意图记录是主人**原话**的机器可读形态 ⇒ **绝不注入群聊/陌生人块**(design/03 §5)
- 复核:三条各自的包 build/vet/test 绿;「真生效」断言含生产构造路径(vector.New + 真实 SQLite 后端 +
  n-gram embedder)、跑完一轮窗口非空、L4 回写后能唤醒一个自身无强信号的表达;
  **5 条变异反证**(其中两条专门打「受限会话零写入」与「群聊不泄露主人原话」)均已还原
- 未做(登记):Index.Search 无 ID 过滤(客户端按候选集过滤,极端情况走回退);每条记忆一次 Embed
2026-10-04 14:20:22 +08:00
AskaEth 983bc49218 fix(vision): 服务端自己取回图片 URL 并内联为 data URI,修自部署下取不到图(F3,优先级「高」)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 原缺陷:vision 适配器把 http(s) URL **原样转给上游 provider**,而 upload 返回的是同源局域网地址
  ⇒ 云 provider 根本取不到 ⇒ **B1(大图绕开 WS 4096 字节上限)在自部署形态下等于没做**
- 修法:① `imageURL` → `localizeRef`:**彻底不再直传 URL**;同源 upload 产物走**本地读取**,
  其余 http(s) 由服务端自己取回 → 一律内联为 `data:` URI 再交 provider(OpenAI 与 Anthropic 双协议都能收图)
- 顺手修「类型瞎猜」:新增 `vision/mime.go`,**字节嗅探是唯一裁决**——判得出白名单光栅图就用字节结果;
  字节是文本(HTML/PROXY 错误页、SVG)→ `ErrNotImage`;判不定但来源明确声明 `image/*` 才采信声明。
  错误信息只含类型名,**不含响应体/URL**(不打印图片内容)
- 防护:取回复用既有 http/https 白名单 + 50MiB 上限,新增 **30s 取回超时**;
  本地读取路径 `localUploadPath` 严格认定 `<host>/api/v1/files/<name>`(无 query/fragment、单段路径)+
  32 位小写十六进制名 + `filepath.Dir` 包含性第二道 ⇒ `../`、`%2e%2e%2f`、反斜杠等编码变体在触碰 FS 前即拒
- 生产接线(主会话补):`cmd/cyrene/vision.go` 传 `vision.WithUploadsDir(<DataDir>/uploads)`
- 复核:build/vet 绿;新增 51 条断言(含 httptest 真取回 → 断言上游收到 data URI 且不含源 URL、
  同源 upload 用不可达 host 证明走本地、HTML 谎报 image/png 被字节否决、越界名 19 例);
  **3 条变异反证**(恢复 URL 直传 8 红 / 文本静默回退 png 红 / 去掉穿越检查 12 红)均已还原
- 未做(已登记):历史里 URL 形态图片每轮重新取回(缓解=持久化时内联/缓存);
  Anthropic 只认 png/jpeg/gif/webp 而白名单含 bmp;`fetchURL` 无 IP 过滤(链路本地 169.254.169.254 可达)→ 需收紧
2026-10-04 14:19:28 +08:00
AskaEth 88d7dd0026 fix(loop): 所有非崩溃退出一律废弃可恢复快照(主人 2026-10-04 裁决「修」)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 原状:只有「被取消」的回合把 checkpoint 落定为不可恢复;**模型/工具报错、预算耗尽**等未落定退出
  同样留下可恢复快照 ⇒ 下一条消息被旧窗口整段顶掉(用户看到自己刚发的消息像丢了)
- 修法:收尾 defer 改为「**回合未落定就废弃快照**」(不再只看 ctx 是否取消),新增 StopReasonAborted;
  checkpoint.resumableStopReason 收紧为「**只有 `tool_calls` 可恢复**」(空 stop reason 不再算);
  终态快照改用 context.WithoutCancel 写(修「取消擦肩最终答案」时终态写失败、中途快照留任最新)
- 语义边界(写进用例注释):**只有崩溃**(进程猝死、函数不返回)才允许恢复;控制流一旦回到调用方
  就不得留可恢复态。原 resume_test 的用例改名为**崩溃恢复**用例并保留断言(含「新消息不被顶掉」的反面断言在新用例里)
- 复核:build/vet 绿 + loop 包测试与 `-race` 全绿;新增 6 条(模型报错/工具失败+模型报错/预算耗尽/
  空 stop reason/取消擦肩最终答案)+ 端到端 TestInterruptThenChatKeepsLatestContextEndToEnd 绿;
  **4 条变异反证**(含把废弃快照写回 tool_calls → 精确复现真实劫持现场)均已还原
- 附:修 session_turn_fence.go 里引用旧函数名的陈旧注释
2026-10-04 14:17:58 +08:00
AskaEth 5c28ace70d fix(agent,loop): 静音路径工具广告面与流式路径统一(P0-12,审计 §4.3)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 原缺陷:流式路径经 loop.toolSchemas(capacity 允许集 + restrict)过滤,而 agent 静音/内部路径
  (RunStep,被 services/automations 等使用)广告 dispatcher **全量** ⇒ 两条路径不一致
- 修法(方案 1):**过滤实现只留一份**(loop.toolSchemas);l.agentStep 调用同一个函数,结果经
  agent.WithToolSurface(ctx, …) 下传,agent 的 buildRequest 原样广告(取不到才回退全量,保持既有回退语义)
  · 按构造相同:同一次函数调用、同一份输入 ⇒ 不存在第二份可漂移的实现
  · 未选「抽共享函数两侧各调一次」:loop 的过滤输入含 loop 私有会话环境 ctx key 与 capacity 判定器,
    agent 侧凑齐等于再造一套 ctx 管线,漂移面更大
- 复核:agent/loop 包测试绿;新增 parity 用例(owner 与 group 两组,静音 == 流式);
  变异(撤掉下传行)→ parity 必红且精确复现「静音=全量 4 个」,已还原
- 附注:全仓仅剩 `services/automations` 的「无 Loop 回退」仍广告全量(没有循环就没有闸可言);
  子代理 ToolWhitelist 是执行期否决、本不参与 schema 过滤(未改)
2026-10-04 14:14:05 +08:00
AskaEth 20d452a51a fix(gateway)+docs(api): F5 区分「字段缺失/值为空白」+ E4/E5 契约文档治理
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- F5:PATCH /sessions/{id} 的 400 文案区分两类——都缺 → at least one of title or pinned is required;
  存在但空白 → title must not be blank(此前空白也回 title is required,误导排查方向)。
  「空串不能清空标题」的既有语义保持不变(用例显式钉住),新增 history_write_test.go 4 段断言
- E4:/auth/logout 的两种真实响应形状(单撤 {revoked} / 全撤 {revoked,revokedCount})+ 错误码;
  /auth/refresh 补 role;登录响应与一次性接入段补 refreshToken(两份文档一致)
- E5:api-reference §3.2 补 pinned/unreadCount/lastMessage/personaId 与准确排序规则
  (pinned DESC, (type='main') DESC, created_at ASC ⇒ 置顶的次级会话可排在主会话之前);
  新增 §3.6a PATCH /sessions/{id}(请求体/错误码/部分更新/title 不可清空)与 §3.6b POST /sessions/{id}/read;
  端点表补 read 行
- api-client 删掉「主会话恒在前」的错误断言(两处),PATCH 错误码文案同步
- DevDocs/02-development-constraints.md:上传上限 50MB → **硬 5 MiB**(常量、无配置项)
2026-10-04 14:13:27 +08:00
AskaEth 5fb0cf5e19 docs(api): api-client → v0.55(安全入口 / OneBot 四端 / 可观测 WS 推送 / 数据目录哨兵)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- ADMIN_PATH 安全入口整段口径(路径式前缀、命中即注入入口页、错前缀 404、API 不藏、用户端永不携带该串)
- DATA_DIR 新增 platform 哨兵(默认/显式路径/显式空值行为不变)
- OneBot v11 四端契约:配置面 transport/api_base_url/report_path + env 兜底 + 四端语义 + mode 取舍 +
  端口豁免边界;api-reference §5.24 同步
- 新增 §3.12 可观测实时推送:三个新帧(observability_log/trace/metrics)载荷-上限-触发、
  角色门 fail-closed(仅 owner+admin)、"读即订阅"语义、节拍、前端落点、兼容性
- 补记 13.4.5 面内三条与 MCP 换官方 SDK(REST/WS/env 零变更)
- 按实现纠正 5 处:api-reference 版本指针滞后两版、上行 chat 已直达编排器(不再经 bus)、
  http+server 的鉴权实为「配了 access_token 才校验」(未配则放行,与 ws+server 同口径,尚无 fail-closed 开关)、
  推送帧实际触达所有 owner/admin 连接(订阅只决定循环跑不跑)、remoteURL 回落口径
2026-10-04 14:08:10 +08:00
AskaEth 852c327360 refactor(web): 随路径式入口清理失效的管理链接(设计:用户端不提供管理入口)
- TopBar / Nav 的 hash 形式管理入口(#/admin、#/admin/system)**删除**(design/07 §0.2:用户端不提供任何
  管理入口、bundle 不含该路径串);面板只经 ADMIN_PATH 路径直入
- AdminApp 三处 window.location.hash = "" 改调 router 导出的 navigateUserShell()
- 复核:tsc 0 错 + vite build 成功;用户端源码 grep "#/admin" 0 命中(仅在 channels/useAdapterNav.ts
  尚有模块内闭环的 hash 字面量,属另一决策,仅报告未改)
- 局限:无 playwright/puppeteer 且不许加依赖 ⇒ 「布局未塌」为结构+静态样式依赖推断(无子元素数量相关样式)
2026-10-04 14:01:43 +08:00
AskaEth a398dc9b92 feat(config,gateway,web): 路径式安全入口 ADMIN_PATH + 数据目录 platform 哨兵(裁决 1.6/16.4.7/12.4.2 + 1.5/12.4.3 核对)
- 1.6 + 16.4.7:安全入口=路径式 `/<ADMIN_PATH>`(env `ADMIN_PATH`,默认 `admin`;`xxx/admin` 亦可,
  非法值回退 `/admin`)。命中前缀 → 送注入了 `<base href="/">` + `window.__CYRENE_ADMIN_ENTRY__` 的面板入口页
  (深链直达);**形如面板入口但前缀不符 → 404**(不回落用户端聊天壳);`/api/*`、`/ws`、webhook 先放行
  ⇒ **API 不藏**。前端路由改**路径式**(`router.ts` 只读注入,无硬编兜底;移除 hash 入口)
- 1.5 / 12.4.3 核对:暴露面默认 `0.0.0.0` 与裁决一致,无需改代码(仅补注释)
- 12.4.2:`DATA_DIR` 支持 `platform` 哨兵(大小写不敏感)→ 平台标准目录;默认 `./data` 与显式空值行为不变
  (显式空值保持原兜底,避免正在运行实例突然换库)
- 复核:build/vet 绿 + config/gateway 包测试绿 + linux/darwin 交叉编译绿;新增 7 条用例,
  并用真实构建产物(`webui.Assets()`)跑通「浏览器直开 /admin 能启动面板」(`<base>` 必须在资源引用之前)
- 变异 2 条(关掉错前缀 404 / 前端默认前缀兜底)已还原
- ⚠️ 运行时序:前端已生效而后端 hatch 未生效期间**面板打不开** ⇒ 需紧接一次「build 到位 + restart」
2026-10-04 14:00:25 +08:00
AskaEth 9253fef92a feat(qq_obv11): OneBot v11 四端齐备(裁决 1.4 / 12.4.1)
- 四端=ws×client / ws×server(原有,补语义注释)+ **http×client**(新增 http_client.go:
  调 OneBot HTTP API、请求-响应、retcode 判成败、token 只走 Authorization 头)+
  **http×server**(新增 http_server.go:监听 listen_addr 收上报、fail-closed 鉴权、10MiB 体上限)
- 配置面:适配器新增 transport / api_base_url / report_path(json tag 沿用既有口径);
  env 兜底 CHANNEL_QQ_OBV11_{TRANSPORT,API_BASE_URL,REPORT_PATH};**默认值=现状**(ws+server)
- 出站分流:session.go 发送路径按 transport 走 WS 写帧或 HTTP POST(不改它则 HTTP 端无法出站)
- 端口豁免边界:http+server 沿用 AGENTS §9 已登记的同一豁免(design/13 §0 原文即含 HTTP server),
  且 listen_addr 为空时报错、**绝不开端口**(与 ws 侧同名校验成对)
- 关键取舍(已上报):OneBot 的 HTTP 是「动作 + 上报」两半,mode 决定**哪一半必需**、
  另一半给齐字段就一并启用 ⇒ 单个适配器即可「收上报 + 回消息」,四端仍各自可独立选择与测试
- 复核:build/vet 绿 + qq_obv11/cmd 包测试绿 + `-race` 干净;10 条新用例(HTTP 两端全部用本地
  httptest 假 OneBot,未连真实 QQ);变异 3 条(伪造 retcode 成功 / 去掉上报鉴权门 / 默认值翻 http)已还原
- 未接:get_group_list(后续)、无 token 时放行(与既有 ws_server 一致,如需 fail-closed 请裁决)
2026-10-04 13:58:18 +08:00
AskaEth 0e1f163ef9 feat(gateway,admin-web): 可观测面从轮询改为 WS 帧推送(裁决 05.4.3 + 16.4.3-WS)
- 新增三个下行帧(纯新增,既有帧名/形状零改动):observability_log / observability_trace /
  observability_metrics,各带 ≤1s 节拍、无新数据不发帧、单帧上限(日志 200 / span 100)
- 帧壳 {type,data} 不带 sessionId;**不进 frameReachesAll 白名单** ⇒ fail-closed 只发 owner+admin
  (变异验证两次:加进白名单 → viewer 用例必红)
- 推送开启条件=owner/admin 凭据「读过一次」对应 REST(引导数据仍由该次读取返回),
  连接全断即退订并停循环(无常驻 goroutine);既有 REST 端点路径/RBAC/形状完全不变
- 前端:删除 logs/metrics/trace 三处 useAutoReload 轮询;新增 observabilityStream(引导+去重合并+窗口)
  与 observabilityViews(三个面板 + WS 徽标),长列表统一走已落地的 VirtualList
- 复核:build/vet 绿 + gateway 全包测试绿(含真 bus→真 /ws/chat 三角色帧断言)+ `-race`;
  前端 tsc 0 错 + vite build 成功
- 待登记契约:api-client 需新增「可观测实时推送」一节
2026-10-04 13:58:03 +08:00
AskaEth 74b12d87ca fix(history,loop,gateway): 上下文续接的最新性/一致性三条(裁决 13.4.5 的面内部分)
本组只落地「在允许文件面内、且能用 design/15 既有规格推出」的部分;13.4.5 的「真并行 + 动态旧上下文」
与 design/15 §2.1/§2.2 的既有串行续接规格存在读法冲突,按规程**停下上报**(未自创语义)。

- history.Recent(续接窗口)并列打破:created_at DESC, rowid DESC —— Windows time.Now() 步长 ~0.44ms,
  用户连发必然同毫秒并列;修复前并列时取**更早**的 ⇒ 丢掉刚发出的最新 n 条且同毫秒内反插入序
- 上行 chat 不再经**可丢弃**的 bus:直达会话编排器的非阻塞 Submit(design/15 §2.1「会话消息不能被 drop」
  + §4「不阻塞是硬约束」);orchestrator 为 nil 时回退 bus,旧断言仍绿
- 被**取消**(中断)的回合把该会话 checkpoint 落定为不可恢复(用 context.WithoutCancel 写,快照保留可查询,
  仅在回合未落定时触发)⇒ 不再顶掉下一条消息的上下文
- 复核:build/vet 绿 + loop/gateway/history 三包测试与 -race 全绿;新增 5 条用例(含一条真 WS+真编排器+
  真 loop+真 persist 的集成用例);**3 条变异反证**精确复现「新消息被丢弃只见旧窗口」「窗口取早不取新」
  「bus 饱和时会话消息静默消失」,均已还原
- 待裁决(已上报主人):① 「同一会话多回合真并行」需改 checkpoint 键(sessionID:step 并发互相覆盖)、
  flow 图按轮键控、前端单槽 busy —— 均在本组文件面外;② 「动态吸收新到消息」在 design/15 **无规格**,
  属新语义,须先写设计;③ 非取消的错误退出同样留可恢复快照(同机制,未扩范围,因 resume_test 正钉住旧行为)
2026-10-04 13:56:35 +08:00
AskaEth a1083a322f feat(mcp): 协议层换成官方 Model Context Protocol SDK,删自研实现(裁决 10.4.6 + AGENTS §9.5)
- 新增依赖:github.com/modelcontextprotocol/go-sdk v1.8.0(direct)+ github.com/google/jsonschema-go v0.4.3
  (SDK 自带依赖,直接用于 schema 校验/默认值,避免再造一份);go.sum 纯新增
- 删除自研协议层:rpc.go / stdio.go / http.go / sse.go 及其测试
- 用 SDK 的成熟逻辑:命令传输(stdio 子进程)/ SSE / Streamable HTTP、协议版本协商与
  -32022 回退、keepalive(含失败阈值)、tools/list_changed 订阅、能力协商(InitializeResult.Capabilities)
- 保留自研的四件事(其余零自研):配置→传输、发现→registry 注册、能力族推断、schema 缓存(Prepare 命中不重复解析)
- 能力闸诚实化:服务器声明 capabilities 但不含 tools 时 ListTools/DiscoverTools 返回
  ErrCapabilityNotDeclared(不静默空集);完全不声明 capabilities 的老式服务器仍按 legacy 处理
- 复核:build 绿 + capability 全包测试绿(mcp 42 用例,含真子进程 stdio / httptest Streamable HTTP /
  内存传输三类假 server,不依赖外网)+ `-race` 干净;变异 2 条(featuresFrom 恒 true / 删 applySchema 调用)已还原
- 无 REST/WS/env/数据目录变更;包内 API 有增删(见回报),仓外调用方签名未变(未改 cmd/cyrene 与 plugin/manager)
2026-10-04 13:53:47 +08:00
AskaEth 7b05b4bfa9 docs(api): api-client → v0.54(渠道配置 patch 语义 + 阶段 6 四项)+ api-reference 同步
- 渠道写路径语义变更:config 从「整条替换」改为**三态补丁**(省略=保留 / 非空=覆盖 / 显式 null 或 ""=清除),
  并写明四类数据安全后果与破坏性提示;api-reference §5.24 同步
- 阶段 6 四项登记:16.4.1 静默/活动态与预约切换、16.4.5 会话级消息缓存(非激活帧按 sessionId 入桶、
  不再丢弃)、16.4.3/16.4.6 虚拟滚动与专属页只读卡、16.4.4 双档 UI 模式(性能档=现状,持久化 cyrene.ui-mode)
- 修正 4 类过时陈述:① 渠道写路径整条替换;②「忽略非激活会话的帧 / 按激活会话过滤」(api-client 四处
  + api-reference 一处);③ graph_update 无会话归属;④ api-reference 漏记 GET /admin/llm/catalog(补 §4.9.1)
- 自证:旧陈述 0 命中;新词 PatchChannel/补丁/ui-mode-fancy/sessionCache/sess-backthink 均命中
2026-10-04 13:48:24 +08:00
AskaEth 4ae714883d fix(gateway,runtimeconfig): 渠道配置写路径改为 patch 语义,修凭据静默丢失(P0 级数据安全)
- 新问题(本轮前端组发现):config 是整条替换,而面板只发送用户本次碰过的平台字段 ⇒
  ① 保存微信(iLink)账号卡(其平台字段只有 cdn_base_url,甚至可为空)会把
  bot_token/bot_id/owner_id/get_updates_buf **全部抹掉**(iLink token 是长期凭据);
  ② Telegram 填 webhook_url 而不重填 token 会因 cleanAccounts「token 空即丢账号」把**整个账号**删掉
- 修法:新增 runtimeconfig.PatchChannel + mergeChannelConfig + isEmptyConfigValue(三态,与 personaId 同构)
  · 键省略 / config 整体缺席 / null = **保留已存值**(未给出的键绝不被抹掉)
  · 给出非空值 = 覆盖 · 显式 null 或 "" = **清除该键**(0/false 不算空)
  · 写路径 UpsertChannel → PatchChannel;非 config 字段(名单/限流/personaId)仍整值替换(不变)
- 回归(真断言落盘内容):微信卡片只带 cdn_base_url / 空卡片三种形态 → 四个凭据键全在;
  Telegram 只带 webhook_url → 账号存活且 token 不变;显式 null/"" → 键真被清除;新账号正常写入;
  既有 TestAdminSaveChannelConfigPersistsAndReloads 保持绿
- 变异反证 3 条(改回 UpsertChannel → 精确复现两缺陷;去掉清除分支;"" 不再算空)均已还原
- 边界未改:token 要求型平台**显式**清 token 仍按 cleanAccounts 丢账号(主人明确清除凭据的语义);
  「本次一个 config 键都没给出」在非 weixin-ilink 平台仍回 400(既有行为,不在授权内放宽)
2026-10-04 13:42:37 +08:00
AskaEth 8d70f6d331 feat(web): 双档 UI 模式(性能档 = 现状 / Smooth Corner 华丽档)+ 开关挂载(裁决 16.4.4)
- design/uiMode.ts:档位纯逻辑(localStorage `cyrene.ui-mode` 持久化、跨标签页同步、未知值回落默认档),
  只改 <html> 类名与 data 属性 ⇒ 不碰 React 树、不重挂载、几何尺寸不变
- 性能档=**现状**:tokens.css / styles/index.css 的改动**全为新增(0 删除)**,且全部限定在
  `:root.ui-mode-fancy` 下;75 元素签名比对证明性能档渲染与改动前逐元素一致
- 华丽档对齐六大核心:连续曲率 corner-shape / 多层 Surface 环境光 / 弹簧动效 / 深色优先(浅色单列补偿)/
  毛玻璃升级 / Liquid Glass(4 折射 + RGB 色差 + 鼠标跟踪 + 双层动态边框)
- 开关挂载:main.tsx 在 createRoot 之前 initUiMode(零闪烁);TopBar/Nav/AdminTopBar 三处挂载,
  窄屏(<640px)按既有约定收起(实测否则 375px 角色块被压到 27px、面板溢出 73px),移动端由侧栏抽屉覆盖
- 复核:tsc 0 错 + vite build 成功;uiMode 38 项 node 测试 + headless Chrome 真渲染 30 项(真点击/真持久化/
  真指针事件);变异反证(去掉读回 → 持久化往返必红)
2026-10-04 13:41:09 +08:00
AskaEth c1a98d7c01 perf(admin-web): 长列表虚拟滚动 + 专属页只读配置卡(裁决 16.4.3/16.4.6)+ 修限流读写成对的数据丢失
- 16.4.3(效率最高):AdminTable 超阈值走窗口化(DOM 行数与数据量解耦,上下占位行撑滚动条);
  新增可复用 VirtualList;适配器消息日志换成窗口化版
- 16.4.6(操作更便利):保留列表页内联编辑,同时把「专属页只读展示该平台完整字段」真正实现
  (AdapterConfigSummary,此前专属页一个配置字段都不显示)
- 🔴 顺带修一条静默数据丢失:列表页限流三项原从 acct.config.* 读,而后端把它们存**账号顶层**
  ⇒ 表单永远回显空、保存时 undefined→0 会把已设限流清空。现改从顶层读、留空则不发该键、非法输入报错中止;
  并顺手改正用户可见的错误文案(0 = 保护性默认 6条/分,负值才是关闭)
- 复核:tsc 0 错 + vite build 成功;channel limits 27 / admin virtual scroll 8023 项断言;3 条变异反证
2026-10-04 13:41:08 +08:00
AskaEth b480b04dd0 feat(web,ws): 会话级消息缓存 + 图桶 + 忙态按会话过滤(裁决 16.4.5/16.4.2 + 修 16.4.1 的帧缺口)
- 16.4.5:非激活会话的帧**不再丢弃**,按自己的 sessionId 落进该会话桶(新增 store/sessionCache.ts 纯函数 +
  useAppStore.sessionMessages/flowGraphs);切会话改为「存回/取出」而非清空;流式增量 O(1) 定位桶、
  只延长已存在的助手气泡(不凭一帧 delta 凭空造消息)
- 16.4.2:图按会话入桶(上限 12 个),Backthink 页取 sess-backthink 的图而不再被用户会话图顶替;
  空态区分「实时通道未连接」与「尚未产生链路图」;wire_runtime 的图源白名单注释按裁决口径重写(零行为改动)
- 修 16.4.1 的缺口:handlers/session.ts 补 `session_idle` / `session_interrupted`(此前落到未知帧兜底 ⇒
  sessionBusy 可能卡 true ⇒ 预约永不落地);忙态**置忙与置闲两侧**统一按 frameIsActive 过滤(只过滤置闲
  会让后台会话的忙帧点亮徽标而它自己的 idle 被丢 ⇒ 永久卡忙)
- 清理:删除 lib/streamScope.ts(口径已被 16.4.5 推翻)与只剩测试在用的 messageEventApplies
- 复核:tsc 0 错 + vite build 成功;messageSync 69 / session 19 项逻辑测试;4 条变异反证
2026-10-04 13:41:02 +08:00
AskaEth f454273c5a feat(web): 静默/活动态切换 + 预约生效(裁决 16.4.1)
- 唯一逻辑入口 components/modeSwitch.ts(纯函数判定 + 预约态 + 落地/撤销)+ ModeSelector 组件
- 活动态:她自己切(下行 mode_switch)直接生效,用户此时切换只登记**预约**、不偷偷改、不上行,
  且不清除预约(两层不打架)
- 静默态:用户可即时切 + 上行 mode_switch
- **用户提前切 → 下次静默态生效**:`active` 由 true→false 时落地;徽章写「已预约 · X(她静默后生效)」,
  不会让人误以为立刻生效
- 「活动/静默」判据=她是否在忙(sessionBusy || 屏上 streaming || sending);若口径要改成 capacity 三态,
  只需改 modeSwitch.ts 的一个谓词(已登记)
- 复核:tsc 0 错 + vite build 成功 + node 逻辑测试 28 项;2 条变异反证(去掉活动态分叉 / 去掉 active 守卫)
2026-10-04 13:41:00 +08:00
AskaEth 093f38d986 docs(comments): 注释中文化 · internal/services/channelbridge(纯替换)
- 只翻注释/文档字符串:剥掉注释后代码行逐字节不变(机械校验只剩允许的行尾注释形态)
- 术语对齐既有译法(owner→主人、persona→角色、engage→参与度判定、quiet hours→安静时段、backoff→退避)
- 复核:go build 绿 + channelbridge 包测试绿;gofmt 干净
2026-10-04 13:33:06 +08:00
AskaEth 61cf80d5ee docs(comments): 注释中文化 · internal/runtime/memory(31 文件,纯替换)
- 只翻注释/文档字符串:820 增 / 820 删 严格 1:1;剥掉整行注释与行尾注释尾巴后,
  逐文件与 HEAD 比对「代码部分差异 = 0」;裁决/design 编号计数逐项与 HEAD 一致
- 保留可执行 SQL 样例、SQLite 报错原文、pgvector/HNSW/ANN/BLOB 等技术词原文
- 复核:go build 绿 + memory/projectindex/vector 三包测试绿;gofmt 仅剩 2 个既有未格式化文件(改动前即如此,未顺手修)
2026-10-04 13:32:54 +08:00
AskaEth eb1828ee7a docs(comments): 注释中文化 · internal/runtime/orchestration(29 文件,纯替换)
- 只翻注释/文档字符串:剥离注释后代码行逐字节不变(机械校验只剩允许的行尾注释形态)
- 裁决编号与 design 节号(02.4.3 / 11.4.2 / design/05 §9 等)逐字保留
- 复核:go build 绿;orchestration 及其子包(audit/comm)测试绿;gofmt 干净
2026-10-04 13:30:04 +08:00
AskaEth fe90f64a56 docs(agents): 登记主人新红线「后端常驻」——下一次构建产物就绪前不准关后端
- AGENTS.md §9 新增一条:运行中的后端(cyrene.exe,:10942)在**下一次构建产物就绪之前不准关**,
  除非出现恶性 Bug(进程致命错误/必须阻断的严重故障)
- 推论写入铁律:① 跑 go test/build/git/验收都不需要停后端;② 换二进制只允许「先 build 到位、
  再一次 restart」,不留空窗;③ 启动不许用前台阻塞式包装(ctrl.bat start/restart 会守着进程轮询健康,
  工具超时会连带杀进程树)——必须确认进程脱离工具;④ 压缩/中断后先探活(netstat + /api/v1/health)再判断
- 同步记进 DevDocs/工作小结/00-当前态.md 的红线区(含本次实测:TaskStop 掉包装后后端存活、PID 文件已写)
2026-10-04 13:18:26 +08:00
AskaEth 7afef8d037 fix(loop,gateway,persona): awakening 逐 turn 按角色生效 + 修「面板保存角色抹掉 5 个块」的数据丢失(裁决 14.4.3)
- per-role 注入补齐:Options.PersonaAwakening(personaID) → Loop.personaAwakening → l.awakeningFor(ctx)
  → capacity.Input.Awakening(toolSchemas 是全仓唯一构造该 Input 处)
  · 按 turn 解析(不绑 Loop 构造期,避免并发会话数据竞争),与既有 personaFor/knobsFor 同构
  · 未知角色/空角色/档案无该块 ⇒ 零值 ⇒ 回落 Config.Awakening(不声明者行为与今天构造性一致)
- **数据丢失修复**:persona PUT 是整块替换,此前只保留 thinking ⇒ 保存一次会同时抹掉
  awakening / visual(头像·背景) / triggerWords(群聊提醒词) / emotion(情绪机参数) / affection(好感度曲线)。
  新增 applyAwakeningEdit + applyPersonaReadOnlyBlocks 逐一保留(cyrene.yaml 五块全写,属必现故障)
- 文件守 500 行:主人档案段原样搬移到 admin_owner_profile.go(零逻辑变更)
- 复核:build/vet 绿 + 全树 `go test ./... -count=1` 0 FAIL;端到端=真 YAML→loader→buildCapacity→真 loop
  流式请求→断言工具面(tasky 声明 force_state 唤醒 → 含 web-search;cyrene 不声明 → 与无解析器对照逐字相同);
  **8 条变异反证**先红后还原(含逐块删除各自的保留逻辑)
- 残留缺口(登记):wire_runtime.go:254 那一行本身无用例覆盖;loader 非 strict(未知 YAML 键静默忽略,
  需主人裁决是否开 KnownFields);POST /admin/personas 仍写不出这五个块
2026-10-04 13:07:52 +08:00
AskaEth 58cba0b8d2 docs(api): api-client → v0.53(阶段 5 全部契约)+ api-reference 同步 + .env.example 补 9 键
- 新端点 GET /admin/llm/catalog 全契约(200/gzip/503 catalog_unavailable/快照副作用/不进热路径/审计 llm.catalog)
- models.json 组级三键:reasoning.kind(形状)+ personaModels/personaProviders(角色级模型/协议路由),
  含「缺省=保留、给出=整块/整表替换」语义、Group.Validate 的 400 口径、回落与 WARN 规则
- GET /admin/llm 新增恒下发的 reasoning 与两个取值域(reasoningKinds/personaProviderOptions)
- 语义修正(此前文档写反):multimodal = 主模型能否收图;LLM_VISION_MODEL/LLM_OCR_MODEL 只配视觉/OCR 工具
- 新工具 image-crop(x/y/width/height、50MiB/16MP/8MiB 上限、越界报错不 clamp、策略默认 owner-only)
- 推理开关按形状下发:替换 v0.41「Qwen 只 WARN 不发」;effort 非法值改为丢弃+WARN(不降档)、effort:none 显式关思考
- .env.example 新增 9 键(LLM_REASONING_KIND / LLM_OCR_MODEL / LLM_LOCAL_* / CAPACITY_L3_*),默认均为保守值
- 权限矩阵计数按 routes.go 校正 56→59(owner/admin 只读档 9→10,含 /admin/llm/catalog)
2026-10-04 13:07:09 +08:00
AskaEth 1ac02bd77e feat(gateway,admin-web): 面板可编辑 provider 推理形状 / 角色级路由 / multimodal 真语义(裁决 03.4.2/03.4.1)
- 后端:AdminLlmProvider 暴露 reasoning{kind}(恒下发,""=未声明=auto)、personaModels、personaProviders;
  顶层新增 reasoningKinds / personaProviderOptions 两个**取值域**(唯一来源= runtimeconfig 导出枚举,
  校验错误文案也由枚举拼接 ⇒ 不可能漂移)
- 读写共用同一映射(admin_llm_provider.go),避免「读侧少一个键 ⇒ 面板一保存就抹掉」
- 写侧语义:缺失=保留、给出=整块/整表替换;非法值一律 runtimeconfig.Group.Validate() ⇒ 400 bad_request
  (实测非法 kind 返回 400 且 models.json 完全不动,而非 500 静默失败)
- 前端:新增 LlmProviderRouting(推理形状下拉 + multimodal 开关 + 按角色路由编辑器);
  目录能力位改三态(true/false/未标注),「未标注」不再谎称「不支持」;目录/角色清单不可用时
  **不阻塞编辑**且与「字段为空」是不同状态;绝无 mock/sample 回退
- 复核:build/vet 绿 + gateway/config 包测试绿 + tsc 0 错 + vite build 成功;2 条变异反证(删 reasoning
  补丁 → 2 例红;删 Group.Validate → 该 400 用例红)已还原
- 注意(主会话已知):npm run build 把新 SPA 写进 frontend/web/dist,而运行中的二进制优先从磁盘读该目录
  ⇒ :10942 目前是「新面板 + 旧后端」,等阶段 5 全绿后按红线「先 build 到位、再一次 restart」切换
2026-10-04 13:06:54 +08:00
AskaEth 8890c30bb5 feat(model): 按 provider 形状独立适配推理开关 + per-persona 提供商链真接线(裁决 03.4.2/08.4.1/03.4.1 余项)
- 新增 reasoning_shape.go:**每个 provider 形状一个 resolver,只发自己形状的字段**
  · deepseek:thinking{type} + reasoning_effort(依据 DevDocs/reference/deepseek 官方本地副本)
  · vllm:chat_template_kwargs.enable_thinking · dashscope:顶层 enable_thinking(**只允许显式声明**)
  · openai:仅 reasoning_effort · anthropic:自己的 thinking{budget_tokens}(未改)
  · 未知 kind → WARN 且一个字段都不发(不许静默失效)
- 形状载体:models.json 组级 `reasoning.kind`(可热更新,未声明则不写该键)+ env `LLM_REASONING_KIND`
- 红线:`effort:none` 在只有布尔开关的形状上翻成**显式关思考**,绝不降成任何强度档
- 03.4.1 余项:`Request.PersonaID` + `RuntimeLLM.ForPersona(pid)` 视图;
  每 persona 协议一条链并消费 `RouteProviderForPersona`;`agent/request.go` 非流式路径同一钩子
- **生产装配(主会话补)**:默认 agent 与 personaAgentPool 都改用 `ForPersona(pid)` 视图——
  否则角色级模型/提供商路由在生产里永不被选中(形同虚设)
- 复核:build/vet 绿 + model/agent/config/cmd 包测试绿;5 条变异反证先红后还原
  (其中一条「红但没钉住」已改用例:通道读取加 2s 超时,避免整包 panic 掩盖信号)
- 未落:DashScope 的 auto 推断(缺官方依据,只允许显式声明);Qwen `/think` 前导消息
  (design/01 §2.1 与 design/11 §3 口径冲突,停手报告)
2026-10-04 12:57:55 +08:00
AskaEth 71fefe5fc3 feat(persona,capacity): persona awakening 档位真生效 + 当前主角色已填(裁决 14.4.3)
- loader 新增可选 awakening 块(default_state/overrides{min_confidence,force_state}),载入期校验 + 归一化,
  非法值显式报错(拒绝「写了却静默无效」);缺失 = nil = 无该档位,不补默认
- 组合根把当前主角色档案的档位填进 capacity.Config.Awakening(未知档位名告警后按无档位处理),
  真的参与 Decide → applyAwakening(MinConfidence 优先于全局 0.6;force_state 钉死态)
- 当前主角色 cyrene.yaml 填 default_state=静息 + min_confidence=0.7(设计 §3 陪伴型取静息;
  0.7 压掉 L2 趋势(0.66)误唤醒、仍低于 L1 强信号(0.9),温柔陪伴不误打扰)
- 复核:build/vet 绿 + persona/capacity/cmd 包测试绿;端到端 4 例锁定「同回合有无档位判定相反」;
  变异两轮(yaml tag 改错 → 8 处红;解析但不接线 → 端到端红)均已还原
- 已知盲区(另派):per-role 注入未接线(loop/request.go 的 Input.Awakening);
  面板整块替换 persona 会抹掉 awakening 块(需 applyAwakeningEdit)
2026-10-04 12:49:40 +08:00
AskaEth 38b4527949 feat(tool,loop): 工具调用软判并回(裁决 14.4.2)
- 落点=Dispatcher.Invoke 入口(唯一执行口,覆盖主循环/子代理/编排全部调用方)
- 语义=**不硬阻断**:越界调用照常执行,把 not_allowed 提示与软判理由挂到真实结果上回灌模型
  (符合 design/14 §1「状态改变不构成能力边界、紧急时也能调」与 §4「入口前置软判」)
- 允许集单一真源:loop 发布 capacity 过滤后的 schema 名单(= 模型本轮实际看到的工具面),
  分发器不在本地另算一套判定
- 审计:复用既有 AuditEvent/OnAudit 通道(what=tool.reject.not_allowed,design/14 §4 原文),
  前端小字与审计日志同源;日志带 source=runtime/tool
- ctx 载体:Run 入口每 Run 播种允许集持有者(run.go +6 行,越界但必需——ctx 只能由 Run 或
  request.go 播种,后者归 G5-A)
- 与既有硬闸门零重叠:restrict(群聊/陌生人)与 ToolWhitelist(子代理)仍在更早处硬拦 + 各自审计
- 复核:`go build`/`go vet`/tool+loop 包测试绿,`-race` 下 SoftJudge/AllowSet 无竞态;
  变异 M1(软判恒空)4 例红、M2(不发布允许集)仅端到端 1 例红 ⇒ 两侧各被独立覆盖
2026-10-04 12:45:48 +08:00
AskaEth 3972ea4007 refactor(admin-web): 模型目录面板改走既有 API 层(adminGetLlmCatalog)
- 新增 lib/api/admin.ts 的 adminGetLlmCatalog({refresh}) + 目录响应类型(lib/types/admin.ts),并由 lib/api.ts 再导出
- LlmCatalogPanel 不再直连 lib/api/core(它曾是全仓唯一这样做的 admin 模块),行为不变:
  五种状态判定、gzip 响应、离线粘贴导入均保持
- 复核:grep lib/api/core 全仓前端 0 命中;npx tsc --noEmit 0 错;npm run build 成功
2026-10-04 12:45:36 +08:00
AskaEth fa33c79e11 feat(model,capacity,vision,io): per-persona 路由 + 本地三级(默认关) + L3 分类 + OCR 独立模型 + image-crop 工具(裁决 03.4.1/03.4.3/14.4.1/14.4.4/14.4.5)
- 03.4.1:per-persona 路由载体选 models.json(组内新增 personaModels/personaProviders + 校验 + PersonaRoute()),
  Router/RuntimeLLM 快照绑定、loop 流式路径按该 turn 的 persona 路由;主步用路由结果、降级步钉自己的模型
- 03.4.3:本地第三级降级链(LLM_LOCAL_*,Configured() 含 Enabled ⇒ **默认关**),warn 标注来源
- 14.4.4:capacity 接入 L3(分类器接口 + TTL 缓存 + 严格 JSON 解析),CAPACITY_L3_ENABLED **默认 false**
- 14.4.1:语义修正 —— group.multimodal 才是「主模型能否收图」(env 路径不再臆断),LLM_VISION_MODEL 只配视觉工具;
  vision/OCR 可各用不同模型;runtime 不再因有图抢路由(vision.Router 是唯一分层权威)
- 14.4.5:新增 image-crop 工具(工具数 5→6,策略默认 owner-only),schema 支持按区域裁切
- 组合根:wire_runtime 读 group.Multimodal;capacity 装配拆出 wire_capacity.go(守 500 行)
- 复核:`go build`/`go vet`/各包 `go test -count=1` 全绿;**6 条变异反证**先红后还原(含作者自查出的 2 条假阳性变异)
- 未落:按 provider 的 thinking 开关(需 openai_encode.go/openai.go 文件面)、persona awakening(需 loader 文件面)
2026-10-04 12:44:52 +08:00
AskaEth 63e5b70e3d feat(gateway,admin-web): 模型目录自动化 —— GET /admin/llm/catalog(裁决 03.4.2 次件)
- 新增 GET /api/v1/admin/llm/catalog(owner|admin,?refresh=1 强制刷新):抓 models.dev api.json 并裁剪
  (226 供应商 / 8392 模型 → 1.31 MB),写落盘快照 data/llm_catalog.json(无凭据,TTL 24h,gitignore)
- 降级诚实:快照优先 → 在线刷新 → stale 快照兜底(标 stale+warning)→ 都不行回 503 catalog_unavailable,
  绝不编造;抓取只发生在该端点内,**不进运行时热路径**
- 前端:新增 llm/LlmCatalogPanel(在线目录 + 粘贴导入落同一份候选 → 一键带出 baseURL/model id/multimodal/
  maxTokens);Llm.tsx 挂载面板、删掉硬编码的「主→备→本地」假状态、表单按 id#seq 重挂
- 诚实性:面板把「加载中 / 端点未实现 / 抓取失败或离线 / 响应结构不认识 / 目录为空」分成五种独立状态,
  全程无 mock/sample 回退
- 复核:新增 9 条零外网用例全绿;**变异**(Stale:true→false / TS 只认 camelCase)各自红后已还原;
  Go 真产出与 TS 解析器双向实测 8392 模型能力位零差异
- 越界说明:routes.go 仅 +3 行(注册该路由);llm/LlmCatalogPanel.tsx 为拆出以守住 Llm.tsx ≤500 行
2026-10-04 12:43:04 +08:00
AskaEth b8fa53e43c docs(api): api-client → v0.52(approval_notice 帧 + 策略落盘 + graph props 新键)+ api-reference 去假陈述
- 新增下行帧 approval_notice(§3.11,主代理代审小字,owner+admin,fail-closed 默认档);
  审计口径 who=角色名 / what=approval.<工具> / result=granted|denied
- 工具策略落盘口径写入端点表(载体 tool_policies,迁移 0012,先落库后改内存)
- graph_update 节点 props 新增四类可选键(tool args_keys/args_summary、guardrail、memory、orchestration),
  纯增量 + 发布前脱敏;帧名/事件名/顶层字段集未动
- api-reference:439 的「仅内存标记,重启复位」改为「落盘 tool_policies,重启保留」;§4.17 补旁路节点 span
- 按实现修正 7 处表述:args_keys 是「全部参数名」(只有 args_summary 限放行表)、explain 带「」引号、
  detail 只截目标部分、allowed=false 还覆盖无代审者/代审报错两条 fail-closed 路径等
2026-10-04 12:34:14 +08:00
AskaEth b99268d019 feat(trace,loop,orchestration): memory/guardrail/orchestration 真 span + 参数摘要脱敏 + Guardrail 输出侧异步(裁决 11.4.2/11.4.3/13.4.1)
- 11.4.2:三类节点首次真进图 —— memory.recall/memory.write(loop/memory.go 召回与写入点)、
  guardrail.input/output(流式主路径两侧 + agent.RunStep 静音路径)、orchestration.spawn
  (scheduler 每次派发/收束);新增 trace.ChildSpan 让不持有 Tracer 的叶子包也能开子 span
- 11.4.3(设计先行,规格先写进 design/19 §1.1):tool 节点参数摘要 —— 放行表 argValueKeys +
  trace.IsSecretKey 统一判据(密钥类键不给值)+ 单值 80 rune / ≤24 条 + 出图前再清洗;
  前端 NodeDetail 新增「参数摘要」区,未放行键显示「值未展示」
- 13.4.1:流式主路径输出侧护栏改后台 goroutine(脱离 context、30s 兜底、不阻塞回合),
  输入侧仍在模型调用前同步拦下;拦截闭环到 guardrail 节点 + 审计事件
- 拆文件:scheduler.go 加 span 后 546 行 → 编排 span 助手拆出 scheduler_spans.go(回到 492)
- 契约:无帧名/事件形状变化;graph_update 节点 props 新增**可选**键(tool args_keys/args_summary、
  guardrail action/allow/elapsed_ms、memory action/scope/hits/…、orchestration subagent/persona),
  文档同步由主会话统一落
2026-10-04 12:31:20 +08:00
AskaEth 0dfad675b8 fix(admin-web): 删掉面板硬编码假数据,改成诚实的空态/错误态(P0-24,审计 §4.3)
- Automations.tsx / Llm.tsx 原有的 sample 回退("后端在线但列表为空 → 渲样例数据")整段删除
- 后端不可达 → 明确错误态;后端可达但没有数据 → 空列表 + 引导文案(对齐 Users.tsx 已修做法)
- 复核:grep sample 零命中;npx tsc --noEmit 0 错误;npm run build 成功
2026-10-04 12:30:38 +08:00
AskaEth 63236c9b00 feat(capability,gateway): 工具策略落盘 + 主代理代审真接线(裁决 1.1 核对/1.3/10.4.2/13.4.3)
- 1.1(核对):registry 默认两布尔 false 已一致,零代码改动(设计文档已按裁决改写)
- 1.3 + 10.4.2:新增迁移 0012_tool_policy(tool_policies 表,表名复数 + idx_tool_policies_name);
  新增 registry.PolicyStore/SQLitePolicyStore,启动 Load 覆盖注册初值、SetPolicy 先落库后改内存
  (落盘失败保持旧值并报错);面板保存路径不变但从此重启仍在
- 13.4.3:代审真接线 —— restrict.SensitiveCall 结构化判定(terminal / file write / http POST)、
  restrict.Gate/Reviewer/Resolver(无代审者或代审报错一律 fail-closed 拦截),
  组合根装进 loop.Options.Hooks 的 CallAdmit;gateway.RecordApproval 落 audit_logs
  (who=角色名 / what=approval.<工具> / result=granted|denied)并广播 approval_notice 帧
- 前端:代审小字「xxx 想要干什么,我审查后批准了」进聊天流顶部(帧→store→组件,文案单一来源
  复用 audit.Approval.Explain()),附纯 node 逻辑测试
- 复核:迁移在 data/cyrene.db **副本**上 up/down/up 各一次通过;TestRegistry_PolicyStoreSurvivesRestart
  与 TestAdminSetPolicySurvivesRestart 证明「重启后策略仍在」;terminal 触发代审 + 审计有行有端到端断言
2026-10-04 12:30:16 +08:00
AskaEth ed5bd92e1b fix(exec): 沙箱子进程不再继承宿主完整环境(P0-7,审计 §4.3)
- buildEnv 不再以 os.Environ() 全集为基,改为「平台最小必需键白名单」裁剪后再叠加 req.Env
  · Windows:PATH/PATHEXT/SYSTEMROOT/WINDIR/COMSPEC/TEMP/TMP/USERPROFILE/HOMEDRIVE/HOMEPATH/APPDATA/LOCALAPPDATA/NUMBER_OF_PROCESSORS/OS/PROCESSOR_ARCHITECTURE
  · Unix:PATH/HOME/TMPDIR/LANG/LC_ALL/USER/SHELL
- 键名匹配在 Windows 上大小写不敏感(避免宿主写 Path 时漏掉),overlay 覆盖 base 时同口径去重
- envAllowed 语义不变:调用方能显式放行的键照旧,仅改变 base 来源
- 新增 TestTerminal_Env_HostNotInherited(非并行,t.Setenv):父进程设非白名单变量,断言子进程读不到;反证=把该键加进白名单立刻 FAIL
2026-10-04 12:29:43 +08:00
AskaEth 09ee561aa7 refactor(exec,io): 文件沙箱单入口收进 io 工具、删 exec.FileSandbox(裁决 13.4.4)+ 修一条假阳性安全断言
- 删除未接线的 exec.FileSandbox(含 5 个用例):文件沙箱从此只有 plugins/tools/io/file.go 一处实现
- io 侧取更严一侧:NUL 拒绝(原 io 缺)、写前按最深存在祖先判定落点(避免越界建目录)、roots 归化到真实路径(修 roots 自身是 symlink 时的全误拒)
- exec/sandbox.go 保留并注释说明:那是**终端工作目录守卫**的原语(terminal.go 在用),不是文件沙箱
- 修假阳性:原 NUL 用例断言错误文本含 "NUL",而 t.TempDir() 目录名自带测试函数名 "…RejectsNULBytePath…"、错误又内嵌整条临时路径 ⇒ 删掉安全检查用例照样绿。改为哨兵错误 ErrNullByte + errors.Is(错误文本逐字节不变),修复后同一变异必红
- 同类排查:file_test.go 三处文本断言判定无风险(含空格的串不可能出现在临时目录名里),保留
2026-10-04 12:29:39 +08:00
AskaEth 95f97f5df3 docs(comments): 注释中文化 · plugins/channels/weixin_ilink(20 文件,纯替换)
- 只翻注释/文档字符串:剥掉行尾注释后,每条「-」都有逐字节相同的「+」对应行(8 组,代码零改动)
- 保留协议/线上标识符原文:errcode、bot_token、context_token、getupdates、binded_redirect、PKCS#7、ECB 等
- 术语对齐既有译法:owner→主人、peer→对端、long poll→长轮询、backoff→退避、sentinel error→哨兵错误
- 已知既有欠债(未顺手修):login_reuse_test.go 在 HEAD 上就未 gofmt 干净;login_test.go 的 TestStartLogin_ScanStatusTransition 91 行 >80(HEAD 同为 91 行)
2026-10-04 12:29:05 +08:00
AskaEth 09fba77f87 docs(comments): 注释中文化 · internal/runtime/history(15 文件,纯替换)
- 只翻注释/文档字符串:剥离注释后代码行逐字节不变(机械校验只剩 4 条行尾注释的翻译,代码前缀全等)
- 交叉引用(design/16 §4.6、D4/B3、0010 迁移文件名)逐字保留
- 保留未译技术词:DDL/rowid/LIKE/LEFT JOIN/DTO/rune/WS/actor 及全部标识符与表名
- 顺带副作用:store.go、session_persona_test.go 由注释文字改变而变得 gofmt 干净(未运行 gofmt -w)
2026-10-04 12:28:48 +08:00
AskaEth b0fbb8bc0d docs(comments): 注释中文化 · internal/runtime/multimodal(21 文件,纯替换)
- 只翻注释/文档字符串,代码零变化:go/scanner 词法流比对 21 文件 token+字面量全等,增删行数 1:1(467/467)
- 刻意保留非散文英文:JSON Schema description、工具 Description() 返回值、adapter prompt、API 路径与命令示例、环境变量名
- 逐行确认代码前缀未变,未触碰其他模块
2026-10-04 12:28:16 +08:00
AskaEth b9e042d07d docs(api): api-client v0.51 补管理面契约 + api-reference 去假陈述 + 帧名口径统一(裁决 15.4.2/15.4.3/10.4.5/08.4.2)
- api-client.md v0.51 同版补记 D 组:X-API-Key 已接线(管理面与用户侧已认证端点同时接受该头,恒 owner 级、fail-closed)、新增 owner-profile GET/PUT(owner-only,写带审计)、persona systemPrompt 改必填;§2.0 owner-only 档 35 → 37 条
- api-client.md 修正 /admin/users 条(签发/吊销 key 已实现,POST 创建用户仍诚实 501)与 M10 待办(主人档案保存已落地)
- api-reference.md §0.1 与 §8.4 两处「X-API-Key 未接入」假陈述改为指针(契约以 api-client 为准),版本指针 v0.49 → v0.51
- gateway 帧可见性测试的虚项改为真实帧名常量 wsSubagentResult(此前写的是 bus 主题拼法 subagent.result),测试说明帧名与 topic 的分工
2026-10-04 12:10:21 +08:00
AskaEth adef36538f feat(auth,gateway,user): X-API-Key 真接线 + key 一律超管 + owner 档案真落盘(裁决 15.4.2/15.4.3/10.4.5/08.4.2)
- 15.4.2:管理 API 与 Bearer 共用同一 Authorize 路径,X-API-Key 命中签发表即过门;未知/已吊销/空值 401 且不回落 Bearer(fail-closed)
- 15.4.3:key 表去掉角色维度,任一有效 key 鉴权后即 owner;签发接口取消 role 分级(不再读请求体)
- 10.4.5:新增 GET/PUT /api/v1/admin/owner-profile(owner-only,PUT 带审计 owner.profile.update),0600 原子落盘 + 活档案热替换;前端主人档案改接真实 API,读不到即禁用保存,删掉硬编码默认档案
- 08.4.2:persona 的 identity.systemPrompt 必填,缺失/空白 400 显式报错,不再派生回落
2026-10-04 12:07:51 +08:00
AskaEth 8c6466cb00 feat(orchestration,gateway): 子代理结果登 WS 帧 subagent_result(裁决 02.4.3)
- 编排层改为在 subagent.result 上发布带父会话归属的 SubagentResultEvent,后台批次结果不再消失在进程内总线
- gateway 中继:新帧常量 wsSubagentResult="subagent_result",eventBridge/bridgePatterns 登记该 topic,normalizeEvent 专用成形(帧壳 sessionId = 派生该批次的父会话)
- 角色门 fail-closed:未登记进 frameReachesAll,owner/admin 可达、viewer 不可达,真 JWT 三角色 WS 断言钉住
- 前景(2s 内跑完)批次仍只回工具结果,不发此帧
2026-10-04 12:07:46 +08:00
AskaEth 0ce498754a fix(memory,loop): 记忆作用域读写成对 + source_conversation 结构化(裁决 1.9/04.4.2/04.4.3)
- 04.4.2/1.9 读侧:Retrieve 候选集并集 = 本人 persona 域 ∪ user 共享域 ∪ project 共享域(他角色 persona 域仍排除),群聊/陌生人取受限收窄
- 写侧:群聊/陌生人整段跳过记忆提取(可查不可写),persona 域仅属主可写
- 04.4.3:source_conversation 不再由 LLM 自由生成(prompt 不索要、候选结构不含该键),落库时无条件盖真实 {session_id,message_id},编解码集中在 memory/source.go
- 新增跨角色种子用例(原用例即使关掉并集也会通过)+ 成对用例「同群聊上下文:读得到共享域、写不进 store」
- 同步 store.go/retrieve.go 文档注释,与 design/03 §3/§4 口径一致
2026-10-04 12:07:44 +08:00
AskaEth af8591b78a fix(channel,user): owner 车道只认白名单,堵住群主/伪造 role 冒充主人(裁决 06.4.2/06.4.3/06.4.4)
- 适配器只描述平台身份:qq_obv11 的 owner/admin 一律上报 RoleMember,不再上报 RoleOwner
- session 层唯一判主点只认白名单(owner.yaml Accounts ∪ 每渠道 adminIds),nil 档案 fail-closed 拒绝
- 新增「每渠道管理员层」:channels.json ChannelAccount.adminIds[] → runtimeconfig.AdminIDs → OwnerProfile.SetPlatformAdmins(整体替换语义,热重载后移除的 id 立即失效)
- 组合根接线:启动即播种、面板保存先灌白名单再重载适配器(cmd/cyrene/wire_channels.go + 4 条端到端单测)
- 06.4.3 节流与参与度状态按会话独立计数(ConversationKey 三处统一,空会话 GC);06.4.4 未知平台主动发言保守默认 1 条/分钟
2026-10-04 12:07:40 +08:00
AskaEth 7e7f53bb41 fix(orchestration,loop): 子代理现在真的收到任务(P0)+ 工具白名单 + 超时可配(裁决 02.4.2/02.4.1/02.4.4)
执行方案阶段 2 · P2-1。**02.4.2 是最伤的一条 P0**:`spawntool` 从不填 `AgentSpec.Context`、
`scheduler` 把 nil 交给 loop ⇒ 子代理模型上下文 = 角色 system + 记忆召回 + **0 条消息**,
goal/attach_info/data 一个字都到不了模型(此前测试用 mock adapter 恒答固定文本,测不出)。

- **02.4.2**:新增 `taskContext/taskUserMessage`(`spec.go`)把 **goal 正文 + attach_info + data
  渲染成同一条 user 消息**(`[任务目标]/[附加信息]/[任务数据]` 独立段落);`Spawn()` 设
  `Context: taskContext(spec)`;**system 只保留角色指令**,任务绝不进 system。
  真断言(捕获请求体的 adapter + 真 loop/agent):首请求恰好 **1 条 user 消息**、同时含 goal 与
  attach_info,且**所有 system 消息不含 goal 文本**。
- **02.4.1**:`AgentSpec.ToolWhitelist`(nil/空 = 继承全量);schema 增 `tool_whitelist`
  (顶层 + 每 task 条目,task 未带时继承批次级);`scheduler.run()` 经 ctx 传递 +
  `scopedRunner` 派生;`loop/options.go` **纯增量(+75 / −0)**:`WithToolWhitelist`/
  `ToolWhitelistFrom` + `(*Loop).WithToolWhitelist` 派生副本(浅拷贝仅替换 `hooks.CallAdmit`,
  链式保留原钩子),名单外调用否决为**软结果**回灌模型。
- **02.4.4**:`SUBAGENT_TIMEOUT_SEC`(默认 600)入 `internal/config` + `.env.example`;
  优先级 = 按调用 `timeout_seconds`(1s–24h 校验)> `WithSpawnTimeout`(生产由 cfg 填充)>
  调度器 `PerAgentTimeout`(`wire*.go` 仍是 10min 常量,未动);迟到 span 续根由测试锁定。

验证:**全树 `go build`/`go vet`/`go test ./... -count=1` = 58 包 ok / 0 FAIL**;
`GOOS=linux/amd64` + `GOOS=darwin/arm64` 交叉编译绿;前端 `tsc --noEmit` + `npm run build` 绿;
本批文件全部 ≤500 行、函数 ≤80 行。
变异验证(四条,均带还原证明):① `Context` 改回 `cloneMessages(spec.Context)` →
「1 条 user 消息」断言 FAIL(`user messages = 0`,即 P0 症状);② `WithToolWhitelist` 原样返回 →
显式白名单测试 FAIL(`alpha executed 1 times despite not being whitelisted`);
③ `subCtx` 改回全局默认 → 3 个超时测试 FAIL;④ `foldSpanFinish` 丢掉 `SpanFinished` →
迟到 span 状态断言 FAIL。

⚠️ 已知缺口(**登记,未假装已解决**):① 无真实模型端到端(全部捕获请求体 mock);
② 白名单**只挡执行、不过滤工具广告面**(模型仍能看见未列工具,调用即被拒并拿到可读原因;
过滤需改 `loop/request.go`);③ **迟到 span 的"新一轮已开始"窗口未修**(`loop/flow.go` 的
`StartRound` 会整体替换 `graphs[session]` ⇒ 新轮开始后迟到的 span 仍落进新轮图;需按轮键控图,
属后续);④ per-role 超时覆盖未实现(需 persona 旋钮管道)。
2026-10-04 11:30:39 +08:00
AskaEth 6e7ec56f9a feat(loop,gateway): 事件批 — tool_execution 三事件 + streamId + turn.start/end(裁决 01.4.1/01.4.2/01.4.3/01.4.5/03.4.4/1.12)
执行方案阶段 2 · P2-3(`DevDocs/audit/_plan.md`)。契约 `Docs/api-client.md` → **v0.50**。

- **01.4.1 `tool_execution` 拆三事件**:`model/stream.go` 删旧 `StreamKindToolExecution`,新增
  `tool_execution.start/update/end` + `ToolPhaseRunning/Done/Error`(与 design/19 status 同值);
  `StreamEvent` 增 `Tool`/`Phase`;`loop/tools.go` 的 `emitToolExecution` 重写为结构化字段、
  **Delta 恒空**(断言锁死),`ID` 复用 provider tool call id(可与 `tool_call.*` 关联)。
  ⚠️ **诚实边界**:start/end 有真实生产者;`update` 的 wire 映射已实现并单测锁定,但**工具分发器
  无中途进度上报源 ⇒ loop 暂不产出 update 帧**(已在 api-client §3.1 如实登记)。
- **03.4.4 phase 驱动图节点状态**:`invokeOne` 用**单一 phase 决策**同时决定 end 帧 phase、
  span 的 `phase` prop 与终态;`trace/graph.go` 新增 `FlowStatusForPhase`,`graph_build.go` 的
  `AddSpan` 对 tool 节点**优先消费 phase prop**(缺省回落 span 终态 ⇒ 两路不可能矛盾)。
- **01.4.2 `streamId`**:`StreamEvent` 增 `StreamID`;`loop/stream.go` 每次 ChatStream 生成
  `stream-<hex>`(crypto/rand,降级时间戳),该流所有帧共享、多步工具轮取新 id;`ws_bridge`
  把它并进**模型流帧** `data`(`tool_execution.*`/`turn.*` 不带)。语义区分:`turnId` = 哪轮回答,
  `streamId` = 这条流式消息。
- **01.4.3 `turn.start`/`turn.end`**:`loop/run.go` 入口发 start、defer 发 end,**覆盖全部出口**
  (最终答案/安全网终止/预算耗尽/步骤错误);帧**无 data 载荷**;静音的内部 run(backthink/子代理)
  不发。顺带把 91 行的 `Run` 拆成 `driveTurn` + `settleAndReturn`(清掉一处既有 >80 行违规)。
- **01.4.5**:仅 `Docs/api-client.md` §3.5 补 `graph_update` 三范围(`web`/`web:<uuid>`/`sess-backthink`),
  与 `wire_runtime.go` 的 allow 谓词一致,**零代码变更**。

验证:`go build ./...` 绿;本批四包(loop/model/trace/gateway)vet + 测试绿(含 `-count=1`);
linux/amd64 + darwin/arm64 交叉编译绿;本批 16 文件 ≤500 行、函数 ≤80 行。
**真 WS 帧断言 8 条**:帧序 `turn.start → message.delta → tool_execution.start →
tool_execution.end → message.delta → turn.end`(+`stream_end`)、turn 边界无载荷且整轮 turnId 一致、
两条模型流 `streamId` 互异、tool 帧 `{tool,phase,id}` 且无 `delta`/`streamId` 键、
`turn.end` 在步骤出错时也发出、内部静音 run 不发边界帧。
**变异验证**:把 `ev.StreamID = streamID` 改为赋空 → 两条断言 FAIL(`streamId` 为空/两流同空),
还原后复绿(直接删行会先被编译期 `declared and not used` 拦截)。

未验证:前端未消费这些帧(本轮未动 `frontend/**`,「前端能渲染」待前端批);无真实模型端到端。
2026-10-04 11:26:35 +08:00
AskaEth afef75f640 feat(backthink): 安全域穿透安静时段 + 长静默不拦心跳 + 启动即思考(裁决 1.8/07.4.2/07.4.3/07.4.4/07.4.5)
执行方案阶段 2 · P2-2(`DevDocs/audit/_plan.md`)。四条裁决:
- 1.8/07.4.2:`think()` 不再走忽略 importance 的 `disturbAllowed`(已删),**接回 `Decide` 级联**;
  安静时段只挡常规推送、**critical 安全域穿透**;`DecisionInput` 增 `ConclusionPush`/`Heartbeat`。
  睡眠仍绝对不打扰(裁决只豁免安静时段,此点已在设计与报告写明)。
- 07.4.3:长静默(>2h)分支对**心跳类**放行(常规事件仍需 ≥high),刚聊过天 10min 内扣留不变。
- 07.4.4:`tickLoop` **启动立即先思考一轮** + 拟人化启动消息;启动轮与心跳**同门同限频**
  (重启风暴不会刷屏),ctx 已取消则跳过。
- 07.4.5:核对一致(`THINK_INTERVAL_SEC` 默认 900s 与 design/20 §0 权威口径一致)⇒ **代码零改动**。

验证:go build 绿;backthink 包 vet/测试绿;`-count=20`(抗 flake)+ `-race` 绿;
linux/amd64 + darwin/arm64 交叉编译绿;gofmt 干净;文件 ≤500 行、无 >80 行函数。
变异验证(三条,均带还原证明):① 安静时段改回"全拦" → 安全域穿透断言 FAIL(首次只删 critical
分支时仍绿——`critical >= high` 会从 high 分支漏过,故真实变异必须是"全拦");
② 删心跳豁免 → 长静默心跳断言 FAIL;③ 删启动思考 → 启动即思考断言 FAIL。

未验证:无真实模型凭据 ⇒ 启动轮问候与 `[我决定:…]` 解析仅经 mock + 真 loop 验证,端到端 LLM 未验。
2026-10-04 11:21:44 +08:00
AskaEth a35bcb8aa9 docs(agents): 锚点路径统一为 DevDocs/工作小结/00-当前态.md(目录规范化:入口文件按本目录 NN- 编号约定改名)
配合工作小结目录规范化(agent-79):当前态文件由自造名 当前态.md 改为 00-当前态.md,
与 00-索引.md 同属入口类编号;本提交只改 AGENTS.md 里 5 处路径文本,其它一字未动。
DevDocs 侧引用(恢复提示词/开发时间线/开发习惯与约定/02-constraints/00-索引)同批更新;
DevDocs/audit/** 与重构前归档里的旧文件名是历史证据,故意保留。
2026-10-04 11:17:32 +08:00
AskaEth e3ae0129c9 docs(agents): 锚点路径更新为 DevDocs/工作小结/当前态.md(工作小结已整体收进其目录)
主人 2026-10-04 指出:工作小结应当整体住在 `DevDocs/工作小结/` 目录里,不该有一个入口文件留在目录外。
据此把 `DevDocs/工作小结.md` 搬进目录、改名为 `当前态.md`(该目录因此自洽:
`当前态.md`(当前态)+ `00-索引.md`(历史索引)+ 按时间分片的历史文档 + 重构前全量归档)。

本提交只更新 AGENTS.md 里 5 处引用路径(§2 先读锚点 / §5 每步更新 / §7 关键决策位置 /
§8 监工动作 / §8 压缩后恢复);DevDocs 侧的引用由并行任务统一修正;
`DevDocs/audit/**` 里出现的旧文件名是当时的现场证据快照,故意保留不改。
2026-10-04 11:08:35 +08:00
AskaEth 5a9f161fe3 docs(agents): 登记两处已裁决例外(OneBot 反连端口 / 表名复数命名)
主人 2026-10-04 裁决后按执行方案(DevDocs/audit/_plan.md P1-G1F)登记,两处均为
「按例外处理并写清边界」,不放松其它情形:

- §9 单端口红线 ♢ 例外:仅 `qq-obv11` 渠道的 OneBot **反连(server)模式**可另开
  监听端口,且必须由配置显式给出(`listen_addr`)、**不得默认开启**;其余渠道/模式
  一律不得新开端口。(对应裁决 1.4 / 12.4.1;规格见 design/08 §4、design/13 §0)
- §10 命名条款 ♢ 例外:表名**统一用复数**、以既有迁移为准——原「表名单数」**不再作为
  硬约束**,命名口径以 design/13 / design/16 为准(对应裁决 1.11 / 09.4.2)。
2026-10-04 11:01:12 +08:00
AskaEth 89434e4f99 fix(contract): enums 的 MODES 漂移守卫改按目录收集,抗前端类型拆分
TestModeTableMatchesFrontendModes 原先写死读 frontend/web/src/lib/types.ts;阶段 0 把该
文件拆成 barrel + lib/types/*.ts 后 MODES 住进了 types/chat.ts ⇒ 测试红。

改为读 barrel + 目录下全部 .ts(新增 frontendTypesDir 常量与 readFrontendTypes 辅助):
「MODES 住哪个文件」不再是本测试的耦合点,以后再搬也不用改它;barrel 缺失
(backend-only checkout)仍按 fs.ErrNotExist 跳过。

验证:go test ./internal/contract/enums/ 绿;全树 go test ./... = 58 包 ok / 0 FAIL;
gofmt -l 干净。
2026-10-04 10:54:07 +08:00
AskaEth 6e0323b446 refactor(frontend): 拆 types.ts(871) / ws.ts::dispatchFrame(266)(纯搬移 + barrel)
执行方案 DevDocs/audit/_plan.md 阶段 0 · G0-D:
- lib/types.ts(871) → lib/types/ 11 个域文件 + 14 行 `export *` barrel(仿 lib/api.ts 先例)
  chat / ws / session / agent / flow / persona / api / admin(269) / adminWrite(176) / kook /
  notification
- lib/ws.ts(494) → 203 行入口 + lib/ws/handlers/ 8 文件;dispatchFrame(266 行) 改为按组
  分发链(7 个 handler 各持一组 case,命中 return true,未命中走原 default 兜底),
  33 个 case 标签一一守恒

机械证明:type 导出集 86 个守恒(拆后并集 diff 为空);去掉空行与 import/export 语句后
**784=784 行、排序 diff 为空**;dispatchFrame 的 case 标签集 33=33 守恒;ws.ts 的
scheduleReconnect 至文件尾与 HEAD 字节级一致。
65 个 lib/types 消费方 + 3 个 lib/ws 消费方**零改动**通过 —— tsc 0 错误正是这一点的强判据。

验证:npx tsc --noEmit 0 错误;npm run build 成功;9 个前端逻辑测试全绿(含真实驱动
拆后 dispatchFrame 的 messageSync 54 checks 与 personaSession 23 checks);
最大文件 admin.ts 269 行、最大函数 openSocket 72 行(均达标)。
2026-10-04 10:54:06 +08:00
AskaEth e9afca9b29 refactor(cyrene): 拆 wire.go 926 行 → 5 文件(纯搬移,零行为变化)
执行方案阶段 0 · G0-A:组合根 926 行是全仓第一大超限文件,且被 10+ 条裁决争用
(1.4/12.4.1、03.4.3、06.4.2、07.4.4、10.4.5、12.4.2、13.4.3、14.4.1、14.4.3、16.4.2)
⇒ 不先拆,后续全部被迫串行且必然破 500 行红线。

- wire.go(181):入口与共享 —— const 块、app struct、newApp
- wire_runtime.go(362):loop/agent/model/memory/capacity/trace(buildPersonas、wireMemory、
  wireRuntime、buildCapacity、onCapacity、onToolAudit、defaultPersonaID、defaultInstructions…)
- wire_services.go(158):exec/task/iot/session/history/automations/backthink
  (wireAuxiliary、pushOutletConfig、close、resolveDataDir、resolveSQLitePath)
- wire_channels.go(172):渠道/桥(ownerRegistrar、wireChannels、runtimeChannels、reviewFor…)
- wire_frontend.go(117):gateway/webui/观测扇出(capacityEmitter、wireFrontend)

纯搬移机械证明:去 package/import/空行后两侧 **811=811 行、排序后 diff 为空、md5 相同**;
顶层声明集合 30 条守恒;唯一新增 = 各文件的 package 行与 import 块(原 import 块按声明归属
重分配给新文件)。未重命名/重排/改注释。

验证:go build/vet 绿;go test ./cmd/... 绿;linux/amd64 + darwin/arm64 交叉编译绿;
五文件 181/362/158/172/117 行均 ≤500;gofmt -l cmd/cyrene/ 无输出。
注:全量 go test 当前有 1 处红 —— internal/contract/enums 的
TestModeTableMatchesFrontendModes 读 frontend/web/src/lib/types.ts 找 MODES,而该文件
正被并行的前端拆分任务搬走;与本提交无关,随后修正该测试的读取路径。
2026-10-04 10:50:35 +08:00
AskaEth deb0a3cb97 refactor(gateway): 拆 admin.go 666 行 → admin.go / admin_query.go / admin_mutate.go(纯搬移)
执行方案 DevDocs/audit/_plan.md 阶段 0 · G0-C:该文件是全仓第二大超限文件(666 行),
且后续多条裁决要改它(工具策略落盘、主人档案 GET/PUT、admin API 补推送结果、模型配置
自动化)⇒ 先拆。

- admin.go(169):共享地基 —— backthinkListLimit 常量、全部 DTO 类型、
  collectSessions/collectMemories 收集器、iotDeviceSummary
- admin_query.go(467):只读 GET 聚合(personas/sessions/memories/plugins/channels/iot/
  llm/automations/users/backthink/rag 的列表与查询 handler + 渠道账号脱敏等私有辅助)
- admin_mutate.go(41):写操作(adminSetPolicy,带审计的 owner 写)

纯搬移机械证明:拆前与拆后(非空、非 package、非 import 块行)排序后 **md5 相同**
(605=605 行);顶层声明集合 43 条守恒;唯一新增 = 两个新文件的 package 行与 import 块。
未触碰 admin_write_*.go / admin_channels_*.go 及任何 _test.go。

验证:go build/vet 绿;internal/services/gateway 测试绿(含 -run Admin 定向);
linux/amd64 + darwin/arm64 交叉编译绿;三文件 169/467/41 行均 ≤500;gofmt -l 无输出。
2026-10-04 10:46:51 +08:00
AskaEth ec292081b2 refactor(trace): 拆 graph.go 471 行 → graph.go / graph_build.go / graph_props.go(纯搬移)
执行方案 DevDocs/audit/_plan.md 阶段 0 · G0-B:该文件距 500 行红线仅 29 行,而后续
11.4.2(memory/guardrail/orchestration 三类 span 生产者)等改动必然推进它 ⇒ 先拆。

- graph.go(159):对外类型与快照 API(FlowStatus* / FlowNode / FlowEdge / FlowGraph /
  NodeSpec / RunGraph;NewRunGraph / Start / SeedInput / SeedOutput / Snapshot)
- graph_build.go(245):构图与折叠(Upsert / rememberParent / laneOf / merge / addEdge /
  AddSpan / AddSpanStart / kindForSpan / labelForSpan / statusForFinished)
- graph_props.go(77):prop 白名单与脱敏(safeSpanProps / safePropKeys / summarize / truncate)

纯搬移机械证明:拆前与拆后(取非空、非 package、非 import 块行)排序后 **md5 相同、
diff 为空**;声明集合 28 条守恒;唯一新增文本 = 两个新文件的 package 行与 import 块。
`safePropKeys`(含 4bf1f7c 刚加入的 "persona")原样落在 graph_props.go,逐字节未动。

验证:go build/vet 绿;internal/runtime/trace + internal/services/gateway 测试绿(含
graph_persona_test 与 flow_update_ws_test 的 graph_update 线上帧成对契约回归);
linux/amd64 + darwin/arm64 交叉编译绿;三文件 159/245/77 行均 ≤500;gofmt -l 无输出。
2026-10-04 10:46:13 +08:00
AskaEth 32634feefa docs(api-reference): 修正与实现相反/滞后的陈述 + 明确权威归属(契约以 api-client 为准)
审计(DevDocs/audit/00-总览.md §4④ 及 02/05/09/10/11/15/16 报告)多路独立发现:本文件
自称「唯一详细参考 + 每变更必同步」,实际系统性滞后于实现,且有整段与实现逐条相反。

修正 15 类(每处都回代码核对行号):
- §0.1 与 §6(:986/:992/:1030/:1052/:1130/:1246):「WS 上行不鉴权 / history 回放对任何
  已认证连接」→ 角色门口径(`route` 首行默认拒;`replayHistory` 首行门;下行仅 7 个状态机
  信号全发),指向 design/11 §7.1/§7.2 与 api-client §3
- §5.26/§5.27 plugins install:501 → 真实 200 {installed,name,kind,transport,toolCount}
- §1.3 鉴权列:9 行 owner|admin → owner|admin|viewer(routes.go 实为 viewerAuth)
- §3.5 POST /sessions:「无 body」→ 可选 {personaId?}(未知角色 400;角色创建后不可变)
- §3.3 PersonaSummary:补 mood/thinking、修正 memoryNamespace(读侧恒 "")与示例
- §3.1 chat:补可选 mode;stream 非必填 | §4.4 kind 枚举改为 tool|skill|channel|mcp|iot
- §5.8/§5.9 IoT 501 文案、§5.29 personaId 缺省与 semantic 原因、§5.32 rotate-jwt 响应
- §7:「无 /upload 端点 / 无 REST 语音端点」→ 两者都存在并给形状(原与本文 §0.3 自相矛盾)
- §8.1/§8.4:移除已实现项;observability ×3 → ×4

结构性(根因治理):文件头新增**权威归属声明** —— 契约以 api-client 为准,本文件是内部/
运维参考,凡重复的契约描述改为引用 api-client(复述就是两份真相);并如实降级其自封的
「唯一详细参考」称号,注明"字段示例可能滞后"。

未补(留给后续「文档补全」批次,已登记):session_read、message_edited/deleted、
/admin/plugins/installed、/admin/observability/logs、/admin/notifications、
/sessions/{id}/read、upload/files/audio 概览行,以及若干字段示例滞后。

验证:git diff --stat -- Docs/ 仅本文件(+65/−46);未触碰任何 .go/.ts。
2026-10-04 10:37:45 +08:00
AskaEth 4bf1f7cac6 fix(trace): 让 graph_update 的分支根节点 props.persona 真的前送(白名单漏登记)
审计(DevDocs/audit/11-trace-observability.md 发现 #1):scheduler 用
span.SetProp("persona", sub.PersonaID) 设了这个 prop,api-client v0.47 也已把它写进
对外承诺,但 trace/graph.go 的 safePropKeys 白名单不含 "persona",而 safeSpanProps
只遍历白名单 ⇒ 该键在到达客户端前被静默丢弃(实测:整个 props 键都消失)。

修法:把 "persona" 加进 safePropKeys,并在同处写清它为什么可以前送 ——
① 它必然是注册表校验过的角色 id(scheduler.checkPersona 未知 id 一律 ErrUnknownPersona
拒绝整批派发);② 它本来就随 GET /personas 与 message_persisted.personaId 对外可见;
③ 值仍过 redactValue → RedactText(凭据形状清洗 + 80 rune 截断)。

测试:新增 trace/graph_persona_test.go(真 Tracer → 真 SetProp → 真 Snapshot,两条断言:
有 persona / 不指定角色时键不出现 = 钉住 v0.47 的"纯增量")+ 网关 flow_update_ws_test.go
追加 TestFlowGraphUpdateRelaysPersonaProp(真 bus Publish → 真 WS 帧 → 断言帧内节点
props.persona=="yuki")。

验证:go build/vet 绿;trace + gateway 包测试绿;全树 go test ./... = 58 包 ok / 0 FAIL;
linux/amd64 + darwin/arm64 交叉编译绿;gofmt -l 干净;三文件 471/57/370 行、最长函数 64 行。
变异验证:把 persona 从白名单摘掉 → 两条断言分别 FAIL(trace 层 props 为空;
WS 帧里 span-sub 节点的 props 键整个消失),还原后复绿。

遗留(登记):前端尚未消费该键(flow 节点未显示"以谁的身份替班")。
2026-10-04 10:37:22 +08:00
AskaEth 074ffc4ebf docs(gateway): gateway 剩余文件注释改中文(逐行 1:1,代码零字节改动)
主人 2026-10-03 拍板「注释一律中文」;gateway 至此整包注释中文化完成(本会话共 3 批:
已推库 16 个文件 + 本批剩余文件)。
校验:机械校验(非注释改动行)= 空;剥注释后代码文本逐行比对全等(不一致文件数 0);
全树 go test ./... 58 包 ok / 0 FAIL;交叉编译绿。
2026-10-03 22:28:25 +08:00
AskaEth 91e8d7a029 docs(runtime): internal/runtime/loop 44 个文件注释改中文(逐行 1:1,代码零字节改动)
主人 2026-10-03 拍板「注释一律中文」;本模块按长期分批协议(一个模块一次派工)执行。
注释行数 1329 → 1329 逐行 1:1(含中文 33 → 1304);行尾注释 5 条一并译。
校验:机械校验(非注释改动行)= 空;独立 Go AST 程序(剥注释复渲)证明 44/44 文件代码全等;
全树 go test ./... 58 包 ok / 0 FAIL;交叉编译绿。
gofmt:capacity_loop_test.go / flow.go 改动前即未格式化,按铁律保留原样未动。
2026-10-03 22:28:21 +08:00
AskaEth 355f81d04a docs(gateway): gateway 第 2 批 5 个文件注释改中文(只改注释,代码零字节改动)
主人 2026-10-03 拍板「注释一律中文」。本批 = WS 上行门与握手回放那批文件:
ws_hub.go / ws_history.go / ws_route.go / ws_role_gate_test.go / voice_signal_test.go
含中文的整行注释 6 → 148;无行尾注释。

校验(判据 = "只改注释、零代码字节改动"):
- git diff -U0 | 排除 +++/--- / 注释行 / 空行 / 行尾注释 → 为空
  (含"把纯空行变动也视为违规"的更严变体,同样为空)
- 独立实现:两侧剥掉注释后逐行比对代码文本 → 5/5 全等
- gofmt -l 干净(改动前同样干净,不存在"顺手格式化")
- go test ./... 全绿;go build/vet 绿;linux/amd64 + darwin/arm64 交叉编译绿
安全约束强度逐句保留:ws_route.go 的「默认拒(非黑名单)/ 删掉它等于放开一切」、
ws_history.go 的「门在 replayHistory 第一条语句 / 扩角色须先登记 design/11 §7.2 /
回放不含 thinking」、ws_role_gate_test.go 的测试名与断言字面量一字符未改。
2026-10-03 22:10:33 +08:00
AskaEth fe1ff33115 docs(gateway): WS 帧门相关 11 个文件的注释改中文(只改注释,代码零字节改动)
主人 2026-10-03 拍板「注释一律中文」。本次先把本会话动过的文件就地翻;
不做全仓一次性翻译,存量按包分批排期(见 工作小结.md 待办)。

范围:ws_frame_scope.go / ws_frame_scope_test.go / broadcast.go / ws_hub_turn.go /
ws_hub_message_event.go / session_read.go / server.go / ws_hub_stream_scope_test.go /
history_write_broadcast_test.go / mood_test.go / extra_test.go
含中文的整行注释 18 → 382。

校验(判据 = "只改注释、零代码字节改动"):
- `git diff -U0 | 排除 +++/--- | 排除注释行/空行/行尾注释` → 为空
- 独立实现:两侧剥掉注释后逐行比对代码文本 → 11/11 文件全等,不一致数 0
- 行尾注释 7 行逐条列出,代码列与原文逐字节相同(gofmt 未要求重排)
- gofmt -l 干净;全树 go build/vet 绿;go test ./... = 58 包 ok / 0 FAIL;
  linux/amd64 + darwin/arm64 交叉编译绿
语义未删减:部分折行英文长句合并为更少行,故整行注释数 438 → 399。
2026-10-03 22:07:56 +08:00
AskaEth 770a83c3fb feat(gateway,security): WS 上行帧角色门 + 握手回放仅 owner/admin(design/11 §7.2)
主人 2026-10-03 拍板"这一类要收紧"后,补齐 §7.1 下行门之外的两处同类缺口:
viewer 是只读监控档,却能通过 WS 拿到内容、甚至发起写入。

A. 握手 history 回放仅 owner/admin(ws_history.go)
   连接建立时回放主会话最近 50 条消息正文,此前对任意角色照发 ⇒ viewer 一连上就
   拿走主人对话内容。门放在 hub.replayHistory 的第一行 —— 它是唯一发出 history 帧的
   函数,将来多一个调用者也绕不过去。

B. 上行 JSON 帧按角色授权(ws_route.go,fail-closed)
   此前 route 完全没有角色检查。现在 route 的第一条语句即:非 owner/admin → 回定向
   {type:"error",data:{code:"forbidden"}} 并 return,因此"不广播、不驱动模型、不落库、
   不返回任何内容"是结构性的,而不是每个 handler 各自记得检查。写成默认拒而非黑名单 ——
   chat / interrupt / subagent_ask / task_output_query / voice_input / mode_switch 以及
   将来任何新帧都自动被拒。协议级帧(ping/pong/close)由 gorilla 在 ReadMessage 内处理,
   不受影响(已实测:被拒后 ping→pong 正常、close 后服务端注销连接)。
   被修掉的真实越权:viewer 发 chat 会真的驱动模型并把消息落库;subagent_ask /
   task_output_query 的定向应答会绕过 §7.1 下行门,把子代理上下文/任务输出回给 viewer。

契约:Docs/api-client.md → v0.49(对 viewer 收紧;owner/admin 逐帧形状不变)

验证:go build/vet 绿;go test ./... = 58 包 ok / 0 FAIL;-race ./internal/services/gateway/...
ok;linux/amd64 + darwin/arm64 交叉编译绿;新测试 -count=3 稳定。
真 httptest WS + 真 JWT 逐帧实测:viewer 收不到 history 回放、上行一律定向 forbidden
且 runner 计数 = 0 / 落库 = 0;owner 侧 chat 驱动 runner = 1 且落库 2 条。
变异验证:① 去掉 A 的门 → viewer 收到 {"type":"history",...,"content":"主人的私密提问"},FAIL;
② 去掉 B 的门 → viewer 帧驱动 runner 1 次并落库 2 条、且直接收到 subagent_event / task_output,
FAIL。均已还原;且撤掉 B 时 A 的测试仍独立 PASS(两把门不互相代偿)。

遗留(如实登记):非 JSON 的上行字节不经过 route 的角色门(零副作用:不带 type、不触发
handler、不落库);admin 上行未单独测一格(与 owner 共用同一分支)。
2026-10-03 22:02:16 +08:00
AskaEth bc0047b449 feat(gateway,security): WS 下行帧角色门 — 内容帧仅 owner+admin,viewer 只收状态机信号
design/11 §7.1(主人 2026-10-03 拍板)。此前 hub.broadcast 是全连接广播,而任意角色
(含 viewer)都能连 /ws/chat ⇒ viewer 能收到角色回复正文(message.*)、推理内容
(thinking.*)、消息增量、graph_update、mood 等。这与 design/19 §1/§3「flow 可视化仅
owner/admin 可见」冲突,也与 GET /sessions/{id}/messages 的 owner/admin 档位不一致。

收口:唯一分类点 internal/services/gateway/ws_frame_scope.go 的 frameReachesAll(typ)
(fail-closed switch,默认 false);hub.broadcast 投递前查表,白名单外对非 owner/admin
连接直接跳过。**不给 broadcast 加 bool 参数、不让调用点自己判断** —— 那正是"漏一处就是
泄漏"的形状。全包只有一个 fan-out 循环,14 个 .broadcast( 调用点全部落进该入口。

- 白名单只有 7 个状态机信号帧(不含任何内容):session_busy / pending_ack / pending_drain /
  session_idle / session_interrupted / pending_full / session_read
- 其余全部(流式 message/thinking/tool_call、message_persisted/edited/deleted、graph_update、
  subagent_event/result、task_output/task_done、automation_result、tool_used、capacity_awaken、
  mode_switch、mood、device_update、reminder)仅 owner+admin
- 语义澄清:本门管「谁被主动推送」,数据可见性仍由 REST 决定(viewer 的 12 条只读监控端点
  不变,含 GET /personas 的 mood);/ws/chat 连接鉴权与 voice_output 定向帧不动

契约:Docs/api-client.md → v0.48(对 viewer 是收紧;owner/admin 零变化;无端点/字段形状变更)

验证:go build/vet 绿;go test ./... = 58 包 ok / 0 FAIL;-race ./internal/services/gateway/... ok;
linux/amd64 + darwin/arm64 交叉编译绿。真 httptest WS + 真 JWT 按角色逐帧实测 37 帧:
viewer 只收到 7 个状态信号,owner/admin 37/37。
变异验证:① graph_update 加回白名单 → 真 WS 抓到 viewer 收到 graph_update,3 个测试 FAIL;
② session_idle 移出白名单 → 3 个测试 FAIL(viewer 等不到哨兵帧而超时)。均已还原。

遗留(已登记,待主人裁决):① 握手 history 回放对任意角色照发(viewer 连上即得主会话最近
50 条正文);② /ws/chat 上行未按角色授权(viewer 可发 chat = 真驱动模型并落库;
subagent_ask / task_output_query 的定向应答绕过本门)。
2026-10-03 21:50:29 +08:00
AskaEth 28b663231e test(gateway): 补 graph_update 的 WS 中继与 wire 契约测试(此前零覆盖)
`#/admin/flow` 报障调查发现:flow 链路每一环都是对的(loop 的 FlowPublisher 三入口
在真实回合里都被调用 / wire.go 的 allow 白名单正确 / parseGraph 与 Snapshot 字段逐条对得上),
但**「bus 的 ws.graph_update 经 ws_bridge 中继成 WS 帧」这一跳此前完全没有测试**——
逐环人工核对永远核不到它。补两个契约测试把它钉死:

- TestFlowGraphUpdateRelayedToWS:真 bus Publish("ws.graph_update", <真 trace.RunGraph.Snapshot()>)
  → 断言 WS 收到 graph_update 且数据逐字节未被改写
- TestFlowGraphUpdateWireFields:精确断言 graph/node/edge/log 四层的 key 集合
  = 前端 parseGraph 读取的那一套(改名/多字段/少必填都 FAIL)

验证:go build/vet 绿;-race 网关 ok;全量 58 包 ok / 0 FAIL;交叉编译绿。
变异验证:① 改 trace.FlowNode.Label 的 json tag → 键集断言 FAIL;
② 让 ws_bridge 的 normalizeEvent 丢掉 ws.graph_update → 中继测试 i/o timeout FAIL。
2026-10-03 21:39:03 +08:00
AskaEth 5847705e35 fix(gateway): 修 Snapshot 交出活 map 导致进程被 fatal 打死(并发 map 迭代+写入)
现场:cy.log:560,2026-10-03 21:00:28.403,栈顶
encoding/json.mapEncoder.encode ← writeJSON(rest.go:383) ← adminObservabilityMetrics(observability.go:89),
不可 recover,网关 recover 中间件接不住 ⇒ 单二进制进程整体退出(端口 10942 零监听、
所有后台服务含 backthink 心跳一起没;比第一次心跳 tick 早 32.6 秒)。

根因:MetricsCollector.Snapshot 在锁内只新造了 StatusCounts,却把 collector 自己的
活 map m.perMethod / m.perPath 直接交出去;锁外(handler 的 writeJSON)才开始遍历,
而每个请求的 logging 中间件(middleware.go:79)在别的 goroutine 里 Record 写这两个 map
⇒ 写有锁、迭代没锁。面板并行轮询十几个端点,/admin/observability/metrics 被打了 29 次
且常见同毫秒两个请求,必踩。

修法:Snapshot 改为交出副本(新增 copyCounts / copyStatusCounts,都在锁内遍历),
并把「必须在锁外可安全编码」的原因写进文档注释。对外读侧语义不变(仍是全量累计)。

测试(新增 observability_metrics_test.go,242 行):① 快照与 collector 双向隔离(确定性)
② 并发「写 + 编码快照」(-race 下复现生产竞态)③ 真 HTTP + 中间件 + 并行轮询
/configmetric 端点(= 21:00:28 现场)。

验证:go build/vet 绿;-race ./internal/services/gateway/... ok;全量 go test ./... 58 包 ok / 0 FAIL;
linux/amd64 + darwin/arm64 交叉编译绿。
变异验证:把 Snapshot 改回交活 map → 确定性测试 FAIL("snapshot shares the collector's perPath map")
+ -race 报出与生产同一处的 DATA RACE(Record 写在 observability_metrics.go:64)。
2026-10-03 21:38:59 +08:00
AskaEth 08b3a01752 feat(runtime,ai): session_read — 跨会话只读「主动读取」能力(design/22 §8-3/§8.1)
主人 2026-10-03 过目 design/22 §8.1 草案并点头后实现。

- 新包 internal/runtime/sessionread:工具壳 + 只读取数 + 归属排版(按职责拆 8 文件)
- 只读由类型保证:注入的 HistoryReader 只声明 List/ListSessions —— 本包结构上无法写入
  任何会话、也推进不了目标会话的 read_at
- fail-closed 授权,顺序 = 环境 → 白名单 → 存在性:
  非 owner 环境拒(ErrNotOwner)→ 白名单只许 web / web:<uuid>(ErrSessionNotAllowed)
  → 会话不存在是硬错误(ErrSessionNotFound,绝不返回空材料,防"读不到"被当成"对方没说")
- 排版:头部三行恒定(外部会话材料 · 只读 · 不属于你的记忆 / 来源角色 / 来源会话)
  + 时间跨度与翻页游标 + 逐条「[时间] 说话人:」归属
- 说话人取消息自身 persona_id;'' → 全局默认角色;未知 id 原样显示;user → SenderName(空=主人)
- Execute 返回 string:loop 的 formatToolResult 对 string 原样透传,换行不被 json 转义
- 接线 cmd/cyrene/wire.go 一行 + wireSessionReadTool():能力默认标记(双 false)= owner-only,
  不新增任何 bypass 路径;审计仍走 loop 的 gate
- 日志 source=runtime/sessionread,只记 actor/target/count/limit/before/has_older,不含正文

验证:go build/vet ./... 绿;internal/runtime/sessionread(25 测试)+ cmd/cyrene 绿;
      linux/amd64 + darwin/arm64 交叉编译绿;
      TestWireSessionReadToolRegistersOwnerOnly 断言 owner 放行 / group_member / stranger 拒绝

遗留(登记,未解决):§8.1 约束 4 要求"每次调用落审计",但 loop 的 gate 只在拒绝时发
AuditEvent ⇒ 成功读取目前只有日志可追溯,未进审计库;补 loop 侧成功路径钩子需先定口径。
2026-10-03 21:33:40 +08:00
AskaEth 05d34853f0 feat(orchestration,runtime): subagent_spawn 支持指定目标角色(以该角色 instructions + 记忆域运行)
design/22 §8-2(主人 2026-10-03 拍板):跨角色协作 = 把其他角色以子代理形式派出去,
该子代理以目标角色的 instructions 运行、并按目标角色的记忆作用域读写。

- AgentSpec / SubAgent / SubAgentResult 新增 PersonaID;Options.PersonaExists 作角色解析器
- scheduler:派发前校验角色(ErrUnknownPersona),批量 all-or-nothing;
  run ctx 经 withPersonaEnv 只覆盖 SessionEnv.PersonaID —— ActorEnv/MemWritable 原样复制
  (身份识别靠 channel 元数据,persona 决定她怎么答、不决定她能碰什么:项目红线)
- 记忆归属键改为 PersonaID 优先(回落 RoleID)→ 替班者写的是目标角色的记忆域
- spawntool:schema 顶层与 tasks[] 新增可选 persona;省略 = 与旧行为逐字节一致
- 分支根节点新增 props.persona(可观测性);SinkMemory 移入 memory.go(守 500 行红线)
- 组合根:personaExists(agent 池);cmd/cyrene/wire.go +5 行

契约:Docs/api-client.md -> v0.47(graph_update 新增可选 props.persona,纯增量)

验证:go build/vet ./... 绿;internal/runtime/orchestration + cmd/cyrene 测试绿;
      go test -race ./internal/runtime/orchestration -run Persona 绿;
      linux/amd64 + darwin/arm64 交叉编译绿
变异验证:① 目标角色换回父/默认角色(3 处断言 FAIL)② 关调度器侧角色校验 ③ 关工具侧预检
2026-10-03 21:30:45 +08:00
AskaEth c7ecc3a249 feat(session,web): 会话角色不可变 — PATCH 拒 personaId + 移除前端换角色入口
design/22 §8-1(主人 2026-10-03 拍板):会话角色在创建时确定、之后不可变,
要换角色必须新开会话。

后端:
- PATCH /api/v1/sessions/{id} 的 personaId 改为 json.RawMessage 判「键出现」,
  出现即 400(null/""/未知 id 一律拒),且先于会话存在性检查;错误消息点名 personaId
- 删除 Server.setSessionPersona 与 PATCH 的 501 分支;title/pinned 行为逐字不变
- sessionPersonaWriter 接口与 history.Store.SetSessionPersona 保留(存储能力)

前端:
- 移除会话行「切换角色」入口与面板「挂角色」下拉(含 setSessionPersona action、
  lib/sessionPersona.ts);保留「新建会话选角色」;面板「角色」列改为只读展示

契约:Docs/api-client.md -> v0.46(破坏性:依赖 PATCH 换角色的客户端须改为新建会话)

验证:go build/vet ./... 绿;gateway+history 包测试绿(-count=1);前端 npm run build
(tsc --noEmit + vite build)绿;9 个前端逻辑测试全绿。
变异验证:改回「静默忽略」/「写绑定」两种反向实现,新断言均 FAIL。
2026-10-03 21:24:51 +08:00
AskaEth f9430dee81 fix(admin): 渠道 personaId 改为三态语义(省略=保留 / ""=解绑 / id=绑定)——修「保存其它配置即静默清空绑定」
跨端语义相反:后端 5969bba 实现的是「省略/\"\"=解绑」,而管理面板只在用户改动下拉时才发该键 ⇒ owner 打开已绑账号、不动下拉、保存其它配置就会静默清空渠道角色绑定。
- 请求 DTO 改 *string 区分「省略」与「显式空串」:省略 → 保留现状;显式 "" → 解绑;非空 id → 绑定(未知角色 400);沿用读-改-写 + bridge.Reload 热更新 + 审计
- GET /admin/channels 继续回显 account.personaId(已确认)
- Docs/api-client 三态语义写清(省略=保留是本次的关键修正)
- 测试:省略保留 / 显式空串解绑 / id 绑定 / 未知 id 400 + 变异验证(把省略改回解绑 → 断言 FAIL)
2026-10-03 21:06:22 +08:00
AskaEth 5969bba3bd feat(channel,runtime): 批次2 后端 — 消息级角色归属进上下文 + IM 私聊随主会话角色 + 渠道级绑定
按 design/22 §2/§3/§4.4/§7(3/4/5)。
- ①「消息级角色归属」进入模型上下文(批次 1 只做存储+DTO):历史消息的发送角色现在对模型可见,且**不把系统元数据塞进用户正文**(design/11 §10 红线)
- ② IM owner 私聊镜像解析出的角色 = **主会话绑定的角色**(§7-3)
- ③ 渠道级绑定:ChannelAccount 加 personaId(channels.json 读写/校验/脱敏沿用既有约定)+ 角色解析优先级 **会话 > 渠道 > 默认** + **群聊/陌生人强制默认角色**(§7-4);admin 渠道配置端点支持 personaId
- Docs/api-client 版本 +1
- 验收:go build/vet/test 全绿 + linux/darwin 交叉编译
2026-10-03 21:01:33 +08:00
AskaEth 3732c538cc feat(admin): 人格页「绑定会话数」+ 渠道账号绑定角色(B 批次 2 前端)
按 design/22 §4.4/§4.6 + §7-4/7-7(契约先行,后端由并行代理实现)。
- 人格页:展示每个角色绑定的会话数——面板侧用 GET /admin/sessions 的 personaId 聚合(personaId 为空 = 继承默认角色,已计入默认角色并在 UI 写明口径),随既有 useAutoReload 刷新;**未新增后端字段**
- 渠道级绑定 UI:每个渠道账号加「角色」下拉(选项取 GET /admin/personas),保存走 PUT /admin/channels/{platform}/config 的 personaId;已绑角色回填、未绑定显示「跟随默认角色」(不冒充某个角色);提示语写明**群聊/陌生人会话始终用默认角色**,避免暗示可覆盖隔离会话
- 验收:npm run build 绿 + 逻辑测试(personaSession/messageSync)通过
2026-10-03 20:50:07 +08:00
AskaEth 2aa3233f84 feat(persona,web): 人格读侧(thinking 三态 + 角色级 mood)+ 面板表单 + mood 播种
按 design/04 §3.2(thinking 语义,showToUser 三态)+ design/01 §2.1 + 主人两条定论(心情跟角色不跟会话;mood 初值走口径 ①:由角色读端点暴露、前端加载播种)。
- 管理侧 GET /admin/personas 的 PersonaSummary 增 thinking(budgetTokens/effort/mode/showToUser)——**showToUser 保持三态**(未配置 ≠ false)
- 用户侧 GET /personas 增**角色级 mood**(取自 emotion 层该角色当前状态,非从消息推断)→ 前端加载播种 currentMood,消除「刷新后心情徽标短暂为空」
- 前端 Personas 页加 thinking 四控件(预算/effort/模式/showToUser 三态):只做输入校验,**provider 是否真支持由后端 WARN 决定,前端不假装能力**
- 先拆 useAppStore 选择器(原 494/500)到独立模块再做,避免越线
- Docs/api-client 版本号 +1(两处契约 + 三态与角色级 mood 语义)
- 验收:go build/vet/test 全绿 + linux/darwin 交叉编译 + 前端 build 绿 + personaSession/messageSync 逻辑测试通过
2026-10-03 20:40:44 +08:00
AskaEth 6dc1f218d5 test(gateway): 修既有 flake — 根因在测试替身的取消契约,非产品 bug
TestInterruptCancelsTurnAndClearsPending 在纯 HEAD 与新代码上都约 7–15/50 失败(既有 flake,污染每次验收)。
- **根因(测试侧)**:测试替身 gatedRunner 阻塞在 select{tickets | ctx.Done()};A 轮被 interrupt 取消时它已 park,ticket 释放后**同一调度步内两个 case 同时就绪**,Go 的 select 随机选 → 有时把**已被取消**的 turn 记成执行过(诊断重放 300 次:18 次失败,且每次被记执行的 ctx 都已 canceled)
- **产品侧复核无 bug**:interruptLocked 在锁内 cancel + 清 pending + gen++;被取消/顶替的轮 completeTurn 命中 stale gen 直接返回,不碰状态、不续排 pending
- **修法(仅测试)**:替身忠于真 runner 的取消契约(入口先查 ctx.Err()、两条取消路径都 recordCanceled、新增 waitCanceled);测试在 Interrupt 后、释放 ticket 前**先等被取消轮确实停下**,断言不再依赖调度巧合;另加 waitIdle / run.count()==1 / session_idle 事件数==1(原有 4 处断言一字未删)
- **稳定性**:修前 -count=50 四次 15/9/7/10 失败 → 修后 -count=50×8 + -count=100 + -count=300 **0/750**;-race 目标测试与整包均 ok
- **变异验证**(防改断言变绿):去掉 interrupt 的 cancel → 10/10 FAIL;不清 pending → FAIL;去掉 completeTurn 的 stale-gen 判断 → 10/10 FAIL
2026-10-03 20:30:38 +08:00
AskaEth b74de9fd86 docs(readme): 示例里的 DeepSeek 模型名更新为当前名(deepseek-chat 已于 2026-07-24 停用)
按 DevDocs/reference/deepseek/facts-for-cyrene.md 的时效性事实:旧名 deepseek-chat/deepseek-reasoner 已停用,且 claude-* 指向 DeepSeek 会被静默映射成 deepseek-flash。仅文档示例,未改任何真实 .env。
2026-10-03 20:22:41 +08:00
AskaEth 0de68bc960 fix(model): DeepSeek 推理参数语义反转 + thinking 开关真发(按官方文档核对结果)
依据 DevDocs/design/04 §3.2「官方核对后的修正」9 条 + 本地 DevDocs/reference/deepseek/facts-for-cyrene.md(官方原句/URL)。
- 🔴 reasoning_effort 白名单改为官方集合 [none, low, high, max] + 别名映射(minimal→low, medium→high, xhigh→high, ultra→max);**删除就近降级**,非法值一律丢弃+WARN。此前 none(关思考) 被降级成 low(开思考) 属语义反转
- 🔴 DeepSeek thinking 开关真发顶层 {"thinking":{"type":"enabled|disabled"}}(官方默认 enabled,不传≠关);此前只打 WARN 导致 mode:off 静默无效
- DeepSeek 的 /anthropic 端点忽略 budget_tokens → 不再对其抬 max_tokens(会误导预算/日志),改为按官方用 output_config.effort
- 注释纠正:penalty 属接口级 deprecated(非仅思考模式);top_p 仅思考模式生效且下限 0.95(不加该字段)
- tool_choice 雷登记:思考模式下 required/指定 tool 会 400(思考默认开)
- 旧模型名清理(deepseek-chat/reasoner 已于 2026-07-24 停用)
- 请求体级测试 + 变异验证(把 none 改回 low / 把 thinking 改回只 WARN → 对应断言 FAIL)
2026-10-03 20:22:31 +08:00
AskaEth 0624d9ee36 feat(session,runtime): 多角色并存—按会话选角(迁移0011 + 按回合解析 + agent池 + 提取器修正)
按 DevDocs/design/22 批次 1 后端(§2/§3/§4.1-4.5/§4.7/§5 验收 1-6)。
- 迁移 0011:chat_sessions.persona_id + chat_messages.persona_id(均 DEFAULT '',无回填 → 升级零风险);同步包内幂等 DDL;**真实库升级验证**(拷贝 data/cyrene.db 到临时目录跑 MigrateUp:既有 1 会话/109 消息 persona 仍为 '',原库未被触碰)
- history:SessionMeta/Message 加 PersonaID + SetSessionPersona;ListSessions/List/scan/Append 带上
- runtime:SessionEnv.PersonaID + loop.personaFor(ctx) 替换固定 l.personaID(memory/request/turn)+ **agent 池**(Options.Agents,按角色取 instructions,未命中回落)+ **提取器不再把 persona 烤死在构造期**(多角色下记忆不再永远记在 cyrene 名下)+ **thinking 旋钮改为按会话角色解析**(否则角色 Y 会用角色 X 的预算/展示)
- gateway:会话 DTO/POST/PATCH 支持 personaId;ws/rest/voice 的 OwnerEnv 硬编改为**按会话解析角色**(actor 元数据语义不变);admin memories 搜索去掉硬编 cyrene
- Docs/api-client v0.42;**两处有意的行为收紧**已写明:POST /sessions body 非法 JSON → 400(原先完全忽略 body;空/null/{} 仍 201);POST /admin/memories/search 省略 personaId 且无默认角色 → 400(原先默认查 cyrene)
- 测试:design/22 §5 验收 1-6(记忆隔离/提示词首条 system/情绪按角色/提取 persona 归属/迁移兼容)+ 进程内 e2e(断言 adapter 收到的 model.Request.Messages[0] 即该角色的 systemPrompt)
- **已知未做(留批次 2 / 待确认)**:① IM 与渠道级未做 → **§7-3「owner 私聊镜像随主会话角色」尚未生效**;② 请求级覆盖未实现(本批只做会话级);③ **§7-5 的「上下文发送角色标记」只完成存储+DTO 半边**——消息 persona 已落库且 DTO 已带,但模型上下文不从 chat_messages 重建,「第 N 条是谁说的」尚未进上下文(需确认是否属批次 2);④「切角色重建摘要」当前是空操作(压缩未接线);⑤ thinking 旋钮仅在流式路径生效(非流式 agent.RunStep 不带,A 批次既有性质)
- 验收:go build/vet/test 全绿 + linux/amd64 与 darwin/arm64 交叉编译(既有 flake 本轮未触发;同批 session_orchestrator.go 一度 501 行 → 抽出 session_tail.go)
2026-10-03 20:12:29 +08:00
AskaEth 66a96d2f93 feat(web): 会话级角色 — 角色跟随激活会话 + 选角入口 + 面板挂角色(B 批次 1 前端)
按 DevDocs/design/22 §4.6/§5(7) 实现(后端由并行代理在做,按已定契约先行)。
- currentPersona 由「全局单值」改为「跟随激活会话」:新增 lib/sessionPersona.ts 解析(personaId 空 → 回落默认角色),App/TopBar/chat/lib/persona.ts 全部改为按激活会话取角色(头像/背景/名字/心情随会话切换)
- 交互:新建次级会话可选角色;会话换角色入口;管理面板 Sessions 页加「挂角色」下拉(走 PATCH /sessions/{id})
- 组件拆分:Nav.tsx 228→143(抽出 NavItem.tsx 53)、新增 SessionRail.tsx 275(会话侧栏)
- 契约:types.ts / lib/api/sessions.ts 加 personaId?(建会话/改会话支持)
- mood 改为**按角色分桶**(同一角色的多个会话共享该角色心情);升级后旧 mood 值不保留(刷新即空)——属行为变化,已在报告注明
- 测试:新增 personaSession.test.ts 40 checks(角色解析/回落/乐观更新与回滚)+ 既有 messageSync 54 checks 回归
- 验收:npm run build 绿;**未做浏览器端到端**(无 jsdom/渲染测试底座),UI 交互与失败路径提示未渲染验证
- 已知余量:useAppStore.ts 494/500 行(下次改动前应先拆 selectCurrentPersona/useCurrentPersona/useCurrentMood)
2026-10-03 20:05:40 +08:00
AskaEth 38a403afca feat(persona,model,loop): 人格 thinking 配置 + 5 个死字段接线 + 推理旋钮(按 provider 映射)
按 DevDocs/design/01 §2.1/§2.2 与 design/04 §3.2 实现(规格含联网核过的官方依据:思考内容刻意不角色化、不写分步思考指令、思考控制只走参数级)。
- 人格 schema 新增 thinking:budgetTokens / effort / showToUser(*bool,省略=nil=保持现行为,显式 false=只落库) / mode(default|on|off→Enable 三态);非法 mode/负预算 → ErrInvalidThinking(不静默)
- 5 个死字段接上注入:personality→values→speechStyle→catchphrase→background 拼为独立段落(带【】小标题,空值不拼);**全空时输出与旧实现逐字节一致**(有专门测试)
- model.Request 新增 Thinking{ BudgetTokens, Enable } 与 ReasoningEffort;新增 model/reasoning.go:厂商识别(模型名→baseURL 兜底)、取值白名单(OpenAI 系 none…max / DeepSeek low|high|max)、非法值降级+WARN、**不支持一律 WARN 而非静默**;Anthropic 映射 thinking{budget_tokens} 并**保证 budget<max_tokens**(不足时抬高 max_tokens+WARN,正是历史上思考吃光预算→空回复那个坑)
- loop:新增 Options.ThinkingShowToUser(*bool) 仅门控 thinking_* 帧(正文/工具帧不受影响);场景级 mute 语义不变
- 补一处任务清单外但必需的接线:persona→model.Request 的 thinking 映射(否则 budget/effort/mode 会变成新的死字段);showToUser 刻意不产生 Thinking(纯呈现开关)
- 管理端:personaUpsertRequest 补 thinking DTO + 校验(400);PUT 省略时**保留已存块**(防旧客户端抹配置);写侧 yaml round-trip 已验证
- Docs/api-client v0.40→v0.41(可选请求字段)
- 测试:26 个新用例(loader 解析/拼接逐字节/映射、anthropic 非流式+流式预算约束、openai 透传/降级/丢弃、Qwen 诚实 WARN、loop 门控、DTO round-trip);3 次变异验证(省略与 false 混同 / 去掉预算约束 / 短路门控 各自 FAIL)
- 验收:go build/vet/test 全绿(含 57 包)+ linux/darwin 交叉编译 + 前端 build;唯一 FAIL 是既有 flake TestInterruptCancelsTurnAndClearsPending(3/20 新代码 vs 3/20 纯 HEAD,已归因非本次引入)
2026-10-03 19:54:22 +08:00
AskaEth e4e18ce2a2 chore(gitignore): 按「类」忽略文件管理器/编辑器备份与临时文件
主人说明:.env.bak 是其远程文件管理 App 修改文件时自动生成的(会重复出现)。故除已有 .env.* 规则外,再统一忽略 *.bak / *.bak-* / *.orig / *.tmp / *.swp / *~。
- 含密钥的 channels.json / models.json / plugins.json 与 cyrene.db* 本就在被忽略的 data/ 下,已安全
- `git ls-files` 确认无任何已跟踪文件命中这些模式(不会误伤)
- .env.bak 本身未删除/未读取(属主人资产)
2026-10-03 19:40:23 +08:00
AskaEth 0519bb3c2e chore(gitignore): 忽略 .env.* 备份(保留模板)—— 防密钥误提交
仓库根出现未跟踪的 .env.bak(编辑器/工具生成的密钥备份,3876B),而 .gitignore 只忽略 .env,git add -A 会把它提交上去。现加 `.env.*` + 放行 `.env.example/.sample/.template`(当前被跟踪的 .env* 只有模板,故无副作用)。文件本身未删除/未读取(属主人资产)。
2026-10-03 19:39:39 +08:00
AskaEth de1fa83b79 feat(orchestration,loop,flow): 接线子代理编排 — 派发入口 + 分支归父会话 + 节点日志(含脱敏)
修的是「文档说 ✅、生产不可达」:此前 Scheduler.Spawn 只有测试调用方、无 bus 入口、无派发工具;Span.Record 无生产调用方 → 泳道画不出、节点日志恒空。
- 入口:subagent_spawn 工具(internal/runtime/orchestration/spawntool.go,同包便于用真 Scheduler/loop/Tracer/Flow 做集成测试;注册在 cmd/cyrene/subagent_tool.go 照 terminal_tool 范式)。默认 owner-only,不设 AllowInGroup/AllowInStranger;安全仍由 restrictSchemas + applyToolSecurityGate 双层保证,未绕代审
- 父会话归属:AgentSpec.ParentSession(请求语义)→ Scheduler 转运行期 ctx 标记 loop.WithFlowBranch → 分支 span 以父 session 发布;**allow 谓词一行未改**;单图语义靠「分支 Run 不调 StartRound/EndRound」+ FlowPublisher 不新建图;未归属 spawn 一张图都不发(有测试锁)
- 前台最多等 2s(对齐 task.autoBackgroundThreshold)→ 合并结果作工具结果;转后台则广播 bus subagent.result(design/05 §9 回填 hook);新增 Scheduler.Wait(ids) 按批等待(Join 会串并发批次,故不重用)
- 子代理跑在 WithoutCancel(ctx):保留 span 归属、不被工具超时/回合结束顺手杀掉;生命周期由 PerAgentTimeout + CancelAll 约束;内部 WithMutedBroadcast 静音(不进前台 stream)
- 节点日志:loop/spanlog.go 新增 llm.done / tool.done / subagent.done;工具参数**只记键名不记值**;仅步骤真正结束才追加(跳过/被拒路径不发,不伪造)
- 脱敏(红线):新增 trace/redact.go(形状规则 sk-/Bearer/JWT/key:value + 键名规则 api_key/token/authorization/password…,递归嵌套、不改调用方事件),graph.go 发布前必经;**顺带修掉一个既有泄漏**:result_summary prop 原先未脱敏(工具结果里的密钥会进图),由图级变异测试抓出
- 测试:新增 22 个(工具级 / 图级 / 日志级),含 4 次变异验证(去掉 WithFlowBranch、去掉 record*、还原未脱敏、去掉静音 各自 FAIL);-race 过 orchestration+trace+loop
- 已知限制:真实模型端到端未验(无凭据/不起服务);工具白名单是 design gap 未做;subagent.result 暂无消费者(design/15 未实现);后台子代理跨轮次边界迟到 span 会丢泳道根;PerAgentTimeout=10min 待确认
2026-10-03 19:39:38 +08:00
AskaEth 3f79159fbd feat(auth,gateway): 接线 viewer 只读门 — 12 条监控 GET 对 viewer 开放,敏感档保持 owner/admin
背景:auth.go 注释写着 RoleViewer 是只读监控档,但实现从未接线 —— 管理侧 24 个 GET 全是 owner/admin,viewer 全 403。本批把它接上(手机端「全功能管理」的前置之一)。
- routes.go:新增第四道门 viewerAuth = Authorize(owner, admin, viewer);registerAdminRoutes 传入。**只**把 12 条只读监控 GET 从 writeAuth 换过来:personas / sessions / plugins / plugins-installed / channels / iot / automations / rag / observability{metrics,trace,logs} / notifications;其余 12 条读与 32 条写**一行不动**
- 保持 owner/admin(viewer 403):memories / llm / users / backthink / audit / sessions-export / channels-messages ×2;owner-only(35 条)含凭据获取(login-qr/login-status)、channels view/friends、全部写操作
- 三处「编码旧策略」的既有测试按**新权限矩阵**改写(不是放宽):gateway_test 的 /admin/plugins viewer 403→200 且补一条 memories 403;observability_logs 的 ViewerForbidden→ViewerReadsTail;notifications 的 viewer 403→200
- 新增 rbac_viewer_test.go:5 个顶层 / 185 个子测试(12 条×3 角色 200、9+35 条 viewer 403、admin 在读档非 401/403 且在 owner-only 403、56 条匿名 401、写路由 viewer 403);含 3 次变异验证(含**反向变异**:把 memories 误放开为 viewerAuth 会 FAIL)
- auth.go 的 RoleViewer 注释改为**实际**可见面;Docs/api-client 新增 §2.0 管理侧权限矩阵 + 变更记录 v0.40;顺带修 §0 一处不实陈述(原写「管理端另支持 X-API-Key」→ 标注尚未接线)
- 验收:**隔离 worktree(HEAD + 本片)** go build/vet/test 全绿 + linux/amd64 与 darwin/arm64 交叉编译 + gofmt 干净(主工作树此时被另一代理的在途改动占用,见下条提交)
- 已知遗留(未改前端,按范围只记录):管理面板未按角色隐藏分区 → viewer 打开 memories/users/llm/审计等页会吃 403(应做能力降级)
2026-10-03 19:27:25 +08:00
AskaEth 2c100fe3d9 feat(flow,web): 全链路可视化 P2 — 泳道/并行分支 + 节点活动状态 + 节点实时日志
- 后端 trace/graph.go(395→470):泳道感知游标 —— parents 记住被跳过容器(state_transition)的父链、laneLast 按泳道分别的末节点游标;泳道首节点挂 subagent 根、后续步骤在本泳道内串链;**子代理根不再推进主干游标**(否则 spawn 之后的主干节点会被算成最后一条分支的步骤);node kind 抽具名常量。+ graph_lane_test.go(并行双泳道断言精确边集/嵌套泳道/主干游标不回归/laneOf 环保护)
- 前端新增 flow/{tone,lanes,layout,NodeDetail,LaneSummary}:分组=沿入边反向 BFS 取最近 kind=subagent 祖先(纯 edges,故无需新增 wire 字段);布局=主干沿用 P1 最长路径分层 + 每泳道一条独立横带(带内最长路径深度、逐带下推 y 不重叠、横向滚动);Flow.tsx/FlowGraph.tsx 加泳道框/虚线泳道边/运行闪烁(既有 mu-blink)/选中高亮/图例
- 实时日志:选中态按 node id 保存,随每帧 graph_update 重新 find → 自动显示追加后的 log[];空时区分「运行中(事件随 span 结束回填)」与「未记录事件」,不合成事件
- 未新增 wire 字段 → 无契约变化(因此未动 lib/types.ts / wsParse.ts);<FlowGraphView> 旧签名兼容,Agents/Backthink 页不受影响
- 验收:go build/vet/test 全绿 + linux/darwin 交叉编译 + npm run build(177 模块) + 逻辑测试 lanes 40 / channelStatus 45 / enums 47 / messageSync 54
- ⚠️ 已核实、未伪造的缺口:生产路径当前产不出子代理数据 —— Scheduler.Spawn 全仓仅测试调用方(0 生产)、编排包无 bus 订阅入口、无「派子代理」工具/capability → 泳道要真的亮需先接线;Span.Record 同样无生产调用方 → log[] 恒空(UI 已诚实显示空态)
2026-10-03 19:21:10 +08:00
AskaEth 49d12d9a64 fix(admin): 修上一提交的类型错误 — channelListState 改为泛型;仪表盘三态化
上一提交(52ee8e5)带着 tsc 错误被推上去了(我的校验顺序写错:set -e 在 build 之后)。本提交修掉并把「先绿后提」做对。
- channelStatus.ts:channelListState 改为泛型 <T>(它只判 null/error,与元素类型无关),使仪表盘能对任意列表复用同一套「读不到 ≠ 0」判定;既有调用点行为不变
- Dashboard.tsx:六个 HTTP 卡片(人格/会话/记忆/插件/渠道/IoT)由 data ?? [] + ?? 0 改为复用该三态:unknown → 「—」+「读取失败(与后端失联)」(warn)、pending → 「—」+「读取中…」;WS 驱动的 Agent/后台思考卡片不变
- 验收:npm run build 绿(先跑后提)+ channelStatus 45 断言 + messageSync 54 断言
2026-10-03 19:17:26 +08:00
AskaEth 52ee8e53aa fix(admin): 仪表盘读不到数据时不再画成 0/「全部在线」
与渠道页同一类误导(主人本次误读的同源问题):六个 HTTP 卡片(人格/会话/记忆/插件/渠道/IoT)原用 `data ?? []` + `?? 0`,后端不可达时显示 0 且「全部在线」(tone=ok),把「读不到」画成好消息。
- 改为复用 channels/channelStatus 的列表三态:unknown → 「—」+「读取失败(与后端失联)」(warn);pending → 「—」+「读取中…」(neutral)
- WS 驱动的 Agent / 后台思考卡片不依赖 HTTP,保持不变
- 验收:npm run build 绿 + channelStatus 45 断言 + messageSync 54 断言回归
2026-10-03 19:17:04 +08:00
AskaEth 37fb3cd265 feat(admin): 渠道状态三态化 + 顶栏后端失联指示 — 不再把「读不到」画成「未连接」
起因:面板把「取数失败(后端不可达)」渲染成红色「未连接」,主人因此把「后端没在跑」误读成「微信适配器挂了」(AdapterDetailPage 的 record?.connected ?? false)。
- 新增 channels/channelStatus.ts(纯逻辑三态:ready/pending/unknown + 记录级 connected/disconnected/unconfigured/unknown/pending;局部类型,未动 lib/types.ts)
- 新增 channelStatusView.tsx:ChannelStatusTag(三态 Tag + 「数据可能过期」)+ ChannelLoadError(失败/过期横幅 + 重试)
- 覆盖 13 处渲染点(channels/ 全目录 + AdminApp):未知态一律灰色「状态未知」+ 原因,绝不落红色「未连接」;未知态禁用连接/断开与「新建/编辑配置」(避免 prefill 空列表误建/覆盖);读取失败不再冒充「尚未保存任何渠道配置」「暂无消息」「未声明能力」等事实性谎言
- 顺带修:WeixinLogin 首次 login-status 读取失败被 .catch 静默吞成「未登录」,现如实报「读取失败(与后端失联)」
- AdminApp 顶栏新增 useBackendHealth + BackendStatusTag:HTTP /api/v1/health 探活(15s 循环),**探测成功前不显示「在线」**;WS 状态作为独立 Tag 展示,两者不混算
- 验收:npm run build 绿(177 模块)+ 新增 45 断言 + 既有 54 断言回归 + renderToStaticMarkup 真渲染冒烟(unknown 渲染不含「未连接」也不含 err 色;未探测成功不显示在线)
- ListPage 主函数 125→71 行(改前已超 80 行红线,行为等价)
2026-10-03 19:16:22 +08:00
AskaEth a51aadeef4 refactor(weixin-ilink): 措辞纠正 —「会话过期」→「会话被 iLink 拒绝」(零逻辑改动)
事实(主人 2026-10-03 确认):iLink 的 bot token 是长期凭据(一个月前扫码的仍可用);errcode=-14 是 iLink 侧拒绝/作废该会话,不是时间性过期;且至今未观测到真实 -14(当日全部日志 0 次)。此前代码把这些写成「过期」,属沿用的旧命名,现纠正。
- 纯改名/文案:ErrSessionExpired→ErrSessionRejected、errSessionExpired→errcodeSessionRejected、expired/expiredToken→rejected/rejectedToken、markExpired/expiredLocked/expiredState→markRejected/rejectedLocked/rejectedState;409 错误码 session_expired→session_rejected
- 行为一字未改:显式标记 / Connect 不假 OK / 面板 needsRescan+lastError / 扫码或换新 token 即清除 全部保留
- 微信协议自身的「二维码过期」与 auth 的 JWT/refresh 过期一律未动(那些是真·时间性过期)
- Docs/api-client v0.39 补事实说明;顺手收尾 3 处残留注释(admin.go / channelbridge/inbound.go / readloop_test.go)
2026-10-03 18:26:27 +08:00
AskaEth cdc370dc82 feat(weixin-ilink,gateway): 会话过期显式化 — 不再静默变哑/假 OK 重连,面板可见「需重扫」
- 适配器新增过期状态机(plugins/channels/weixin_ilink/session_state.go):getupdates/sendmessage/getuploadurl 收到 errcode=-14 即 markExpired(ERROR 仅一次,只带 bot_id,token 永不入日志),扫码登录拿到新 token 或配置换新 token 时清除(惰性比对)
- Connect/HealthCheck 在过期时返回 ErrSessionExpired(可与 ErrNotConnected 区分):不再对死 token 设 connected=true 报假成功;发送路径同样返回该错误而不是笼统 ErrNotConnected
- 网关:POST /admin/channels/open 遇过期 → 409 session_expired + 明确文案(不再吞错回 200);GET /admin/channels 摘要新增 needsRescan/lastError(经可选接口类型断言读取,取不到不伪造)
- channelbridge:读循环非取消退出时把自身记账的 connected 置 false(热重载被替换者退出不抹掉新实例)
- login/login_admin:过期时 StartLogin 允许重新出码、LoginStatus 如实报 failed(原先可能把死 token 当 confirmed 重新落盘)
- Docs/api-client v0.39;新增 5 组测试 + 变异验证(去掉标记/恢复假成功/网关不填字段 均 FAIL)
2026-10-03 18:00:01 +08:00
AskaEth 2092094d0a fix(platform): 内嵌 IANA tzdata,Windows 也能解析 TZ;新增本机 embedding 服务脚本
- cmd/cyrene/main.go: import _ "time/tzdata" —— Windows 无系统 tzdata,导致 config.TZ(默认 Asia/Shanghai) 解析失败、loop 时间注入被静默禁用;内嵌后单二进制自洽(约 +450KB)
- 实测:迁移到 Windows 后启动日志不再出现 "timezone load failed; time injection disabled"
- scripts/embed.bat(新):本机 llama.cpp embedding 服务启停(start/stop/status),OpenAI 兼容 /v1/embeddings,跑 Qwen3-Embedding-0.6B-Q8_0(dim 1024)
- 实测:start→Ready→status running→POST /v1/embeddings 200 dim=1024;配合 docker compose 的 cyrene-pg(pgvector) 后端日志变为 pgvector index ready dim=1024(不再回落内存语义)
2026-10-03 17:58:45 +08:00
AskaEth 17c9fb7545 chore(scripts): 新增 Windows 等价控制脚本 ctrl.bat
- 与 scripts/ctrl.sh 同语义:start(默认)/stop/restart/status + 额外 build
- 端口读 .env 的 PORT(回退 10080);日志 %USERPROFILE%\cy.log;PID %USERPROFILE%\.cyrene.pid;Start-Process 隐藏窗口脱离控制台(等价 setsid nohup & disown);停止先优雅后强杀
- 刻意 ASCII + CRLF(cmd 解析对编码/换行敏感),并显式走 %SystemRoot%\System32 绝对路径,避免 MSYS(Git Bash) 的同名工具抢占
- 实测:status/stop/start/restart 全流程通过(后端 10942 health ok)
2026-10-03 17:44:35 +08:00
AskaEth f42ea1e41c feat(web): B4 枚举标签映射 — labelFor 未知值原样显示
- 新增 lib/enums.ts:platform/mood 标签 + labelFor(kind,value),未知值原样返回(display-raw 红线),注释标明权威来源是 GET /api/v1/meta/enums
- 4 处 mood 渲染改用标签(Nav/TopBar/chat×2);未动 types.ts 的 MODES/Mode
- 新增纯逻辑测试 enums.test.ts(47 项,esbuild+node,零新依赖)
- 未动 admin channelFields 的第二份 PLATFORM_LABEL(记入待办)
2026-10-03 16:55:22 +08:00
AskaEth 22e8398110 feat(contract,gateway): B4 枚举权威表 — GET /api/v1/meta/enums + 未知值回落约定
- 新增 internal/contract/enums 叶子包:platform/mood/mode 三表 + UnknownFallback="display-raw";每次调用返回新切片,无包级可变全局
- GET /api/v1/meta/enums(anyAuth 只读、纯投影、无 store/无错误路径)
- 反漂移 4 处:真构造内置适配器断言 Name() 与表双向相等、真工厂 buildAccountAdapter 逐项可建、mood 对齐 emotion 常量 + 默认转移表、mode 对齐前端 MODES(跨语言,文件缺失则 skip)
- Docs/api-client §1.1 枚举权威表 + 行15 + v0.38;api-reference §3.7 + 行12
2026-10-03 16:55:22 +08:00
AskaEth a828edacc5 feat(web): D4 会话未读徽标 — 打开即已读 + 消费 session_read
- SessionMeta.unreadCount(可选,兼容旧后端)+ api/sessions.markSessionRead
- lib/sessionRead.ts:会话历史落地后再推游标(best-effort,失败静默,下次 GET /sessions 收敛)+ 帧归属判定(ws.ts 因此留在 500 行内)
- store.clearUnread 无变化时返回同一 state,避免多余重渲染;Nav 徽标沿用 Miuix token(mu-radius-pill/mu-err/mu-rise-in)
- 逻辑测试 35 → 54 项,含变异校验(改成 clearUnread(activeSessionId) 即 FAIL)
2026-10-03 16:47:56 +08:00
AskaEth e8eccd2bb2 feat(history,gateway): D4 会话未读/已读游标 — unreadCount + POST /sessions/{id}/read + session_read 广播
- chat_sessions 增 read_at(毫秒,与 chat_messages.created_at 同单位);迁移 0010 并把既有会话回填为各自最新消息时间(升级当天不误报)
- ListSessions 单查询派生 unreadCount(无 N+1,复用 idx_chat_msgs_session_created 覆盖索引)
- MarkSessionRead 只前进 MAX(read_at, ?);未知会话 ErrSessionNotFound;游标方法挂在网关消费侧窄接口(沿用 GetMessage/messageLookup 先例,避免污染 runtime 接口与其 fake)
- POST /api/v1/sessions/{id}/read:writeAuth + 400/404/501,成功广播 session_read、失败路径不广播
- Docs/api-client v0.37(§1 unreadCount / 新行 8a / §3.9 session_read / §4.2 用法)
- 含迁移回填测试、store/handler 测试与变异验证;顺带把 migrate_pinned_test 的「回退一步=0009」硬编码改为对迁移数量免疫
2026-10-03 16:47:55 +08:00
AskaEth 04a8ee0727 feat(web): 流式帧按会话过滤,非激活会话不再串入当前气泡
- WsDownEvent 增 sessionId;流式分支(message/thinking/tool_call/tool_execution/stream_chunk/stream_end 及 legacy thinking/tool_used)统一过 streamFrameApplies
- 会话列表未解析(activeSessionId 为空)时保持改前行为,避免误丢主人自己的流
- 抽出 lib/streamScope.ts 承载该判断,ws.ts 回到 500 行红线内
- messageSync.test.ts 增 21 项断言(跨会话丢弃/激活追加/无 session 兼容/未解析回退)
2026-10-03 16:08:53 +08:00
AskaEth 61cc32c44b feat(gateway,loop): A1 流式帧附加 sessionId/turnId — 多会话并发流式可路由
- model.StreamEvent 增 SessionID/TurnID(适配器在 loop 之下,恒留零值)
- loop 在回合边界注入;覆盖 stream.go 与 tools.go 的 tool_execution 发射点
- 网关 runTurn 每回合生成 turnID 并经 loop.WithTurnID 挂进运行上下文(编排层与 legacy 直连的唯一收口点);broadcastStream 填充 wsEvent
- Docs/api-client v0.36 契约与变更日志;v0.10 条目标注为已被补齐
- 新增 loop/gateway 测试,含变异验证
2026-10-03 16:08:52 +08:00
AskaEth 29aa60d0eb test(platform): 修 Windows 上平台脆弱的断言(不削弱测试意图)
- trace/task: 亚 tick 耗时合法读到 0(本机时钟步长约 0.44ms),改为断言非负且 Duration == EndedAt-StartedAt
- user/runtimeconfig: Windows 无法表示普通文件的 POSIX 权限位(Stat 恒 0666),0600 断言仅在非 Windows 生效,注释写明实现确实请求 0600
- exec: MSYS pwd 把同一目录渲染成 /tmp/...;改用「标记文件相对名解析」+「与显式 root 的 pwd 输出逐字相等」两条跨平台断言 + 变异验证
- 只读排查全仓同类脆弱断言,结果记入 DevDocs/工作小结
2026-10-03 16:08:52 +08:00
AskaEth fbbfcc95ee fix(persist,channel): 同类定宽时间戳修复 — checkpoint 恢复与渠道会话列表排序
- persist: formatTime 定宽;loadLatestSQL 补 rowid 兜底;New 时归一化历史行(避免 LoadLatest 挑到更旧检查点)
- channel/session: 写入定宽;listSessionSQL 补 rowid 兜底;核实无 INSERT 吃 DDL 默认值,故只加注释不改默认值
- 新增回归测试并做变异验证(改回 RFC3339Nano 即 FAIL)
2026-10-03 16:08:51 +08:00
AskaEth 31086ed1ab fix(memory): 定宽时间戳存储 + rowid 确定性排序 + 遗留数据归一化
- formatTime 改用固定 30 字节布局(9 位小数秒),让 TEXT 列的字典序与时间序一致(RFC3339Nano 会去掉尾随零产生变宽字符串,秒内会反序)
- memory_items / project / project_chapter 的 ORDER BY 全部补 rowid 兜底,消除同 tick 打平时的顺序不确定
- 开库时幂等归一化变宽的历史值;无法解析的行原样保留,不阻塞开库
- 回归测试:变宽排序反例、同一时间戳插入序、归一化幂等与跳过
2026-10-03 16:08:50 +08:00
AskaEth 1ac126fc9e feat(web): D 消息编辑/删除前端入口 + 日志聚合前端(Observability后端tail) + 修api-client契约
- D前端: lib/api/messages.ts(patchMessage/deleteMessage,兼容裸DTO与{message}) + MessageActions.tsx(仅user消息; 单击/右键/450ms长按开Miuix玻璃菜单; 编辑弹窗预填+字数→乐观updateMessageById→patchMessage→失败回滚; 删除确认→deleteMessage→removeMessageById; 乐观气泡禁用) + chat.tsx挂载 + Icon加edit/trash/more
- 日志前端: types(AdminLogLine time:string|number) + adminSystem.adminObservabilityLogs + Observability.tsx 后端分级tail(level筛选chip注明'该级及以上'+等宽行HH:mm:ss.SSS·level着色·source·msg+30s刷新) 下半保留WS回显
- Docs/api-client §6a/§4.2 修正: PATCH /messages/{id} 实际返回 {data:<DTO>} 非 {data:{message}}(前端已双向兼容,避免误导Android)
- 前端 build 绿(167 modules); SSR冒烟11 checks
- 遗留: 未实机登录; 菜单absolute理论上边缘可裁切(可改portal)
2026-10-03 15:23:27 +08:00
AskaEth 025dd1796b feat(observability): 日志聚合后端 — logger环形缓冲 + GET /admin/observability/logs
- internal/logger/ring.go: Entry{time,level,source,msg} + RingBuffer(2000,满覆盖最旧,不阻塞)+ringHandler(slog旁路,Enabled委托内层,sourceOf提取source=再回落Scoped); Options.Ring非nil才包
- gateway/observability_logs.go: GET /admin/observability/logs?level=&limit= (writeAuth owner/admin) → {data:[{time(RFC3339 UTC),level,source,msg}]} 最新在前; level阈值过滤(warn=warn+error,已标注取舍); limit缺省200上限1000; 未知level400/未注入501
- routes.go注册 + observability.go SetLogBuffer(atomic.Pointer) + cmd/cyrene main.go/wireGatewayLogBuffer接线(logger先于gateway)
- 控制台/文件输出零变化; 记录只源自已产出日志,不额外打印敏感
- Docs/api-client v0.35; 测试(ring 10 + observability_logs 8 + cmd端到端2); build/vet/test绿(56)+win/darwin交叉编译
2026-10-03 15:22:03 +08:00
AskaEth 79589b5c9f refactor(gateway): 拆 ws_hub.go 589→182(纯搬移零逻辑) — conn/turn/origin/message_event 四文件
- ws_hub.go 182(hub核心/常量/wsEvent/newHub/broadcast) + ws_hub_conn.go 165(连接层handleWS/read·writePump) + ws_hub_turn.go 168(relay/streamChat/runTurn/interrupt/broadcastStream) + ws_hub_origin.go 73(Origin校验) + ws_hub_message_event.go 30(message_edited/deleted广播)
- 代码行多重集 identical + 顶层声明diff空; 每文件≤500/函数≤80(最大handleWS 28/streamChat 25)
- gateway测试绿 + build/vet + win/darwin交叉编译绿
2026-10-03 15:19:57 +08:00
AskaEth 2720d77331 feat(gateway+web): D 消息编辑/删除 WS 实时广播 — message_edited/message_deleted
- 后端: ws_hub.go broadcastMessageEdited(完整DTO,thinking剥离)/broadcastMessageDeleted(全广播+sessionId,前端过滤); history_write.go PATCH/DELETE成功后触发(404/400/501不广播); history/message_ops.go GetMessage(导出壳,取删除前sessionId)
- 前端: useAppStore.updateMessageById/removeMessageById + messageEventApplies(同会话过滤); ws.ts 消费两事件(缺content丢弃); dispatchFrame导出(可测)
- 真机e2e: PATCH/DELETE → WS收到 message_edited/message_deleted, 二次删404无帧
- Docs/api-client v0.34 §3.8; 测试 history_write_broadcast_test + messageSync.test(14 checks)
- 遗留: 前端编辑/删除UI入口+api客户端函数未接(lib/api 无 PATCH/DELETE messages); ws_hub.go 589行>500(既有)
2026-09-30 11:58:49 +08:00
AskaEth 4ea68d17f3 refactor(gateway+model): 拆 gateway/server.go 719→204 + 重构 scanSSE/processFrom 到 ≤80行
- server.go 204 + 新 routes.go(193,路由注册/Handler/withMiddleware)/deps.go(108)/contracts.go(231,17依赖接口)/broadcast.go(78,BroadcastPersisted/CapacityAwaken/Mood); 导出面不变; 74条路由pattern+RBAC门逐条比对一致
- model/openai_stream.go: scanSSE 138→25(parse/apply/finalize 对齐 anthropic_stream); openaiStreamState结构体
- channelbridge/inbound.go: processFrom 112→46(blacklistDrop/inboundTargetFor/runInboundTurn/deliverReply)
- 纯行为等价; build/vet/test绿(56包)+win/darwin交叉编译
2026-09-30 11:58:49 +08:00
AskaEth 4c3c98d5d4 refactor(web): 拆 lib/api.ts 789→109 barrel + lib/api/ 8域文件(纯搬移零逻辑)
- lib/api/{core,auth,chat,sessions,personas,admin,adminChannels,adminSystem}.ts
- api.ts 改纯 barrel 显式 re-export(export type for types, isolatedModules); 导出名 77→77, 36处导入点零改动
- 代码行多重集比对一致(仅 request/limitQuery 各加一 export 供兄弟模块); 每文件≤500/函数≤80
- 前端 build 绿(165 modules)
2026-09-30 11:52:35 +08:00
AskaEth 153a9d6211 feat(runtime+admin): A2 LLM异步提取接线 + A4 Tracing接线 + A6通知后端聚合
A2 异步记忆提取:
- loop/memory.go: asyncExtractor接口(ExtractAsync)+extractMemoryAsync(60s超时,WithOutCancel,dispatch即返回,后台落库,失败仅Warn)+persistMemories共用
- run.go 三个回合落定点改异步提取; 删除run.go逐步提取(design/03'每轮后', 避免多轮工具N次LLM调用/重复写)
- cmd/cyrene/extractor.go buildMemoryExtractor: llmGroupUsable为真→LLMExtractor, 否则回落ManualExtractor(日志说明); keylessEndpoint放行loopback/私网

A4 可观测性 Tracing:
- cmd/cyrene/observability.go: newAppTraceWriter+traceFanout(多sink)+gatewaySpanSink(走gw.ObserveSpan,喂环形缓冲+LLM/subagent指标)
- wire.go 注入; 未改trace/gateway数据结构

A6 通知后端:
- internal/services/gateway/notifications.go(311行): GET /admin/notifications?type=&before=&limit= (adminAuth)
- 聚合已有源(obs.audit SQLite + s.backthink), createdAt倒序, id=source:id; 类型派生(channel/system/audit/backthink); 无新建表/无新Deps/wire零改动
- 诚实: backthink仅shouldPush计, 未注入源为空, 报错500, 未读游标后置
- 契约与前端Y一致: {id,type,title,body,source,createdAt}, before含游标(<=), type未知400
- Docs/api-client v0.33

全仓 build/vet/test绿(56包)+win/darwin/linux-arm64交叉编译绿; 新增测试(memory_async/extractor/observability/notifications)
2026-09-30 09:03:16 +08:00
AskaEth 34c8cc4dca feat(admin): A6 通知页前端 — 统一通知/事件中心(管理面板#16)
- Notifications.tsx(254行): 类型筛选(全部/system/channel/backthink/audit,服务端重拉) + before游标加载更多(id去重/到底判定) + 空态/错误(后端文案) + 30s静默刷新; 卡片行(非AdminTable,避其rowKey升序与倒序feed冲突); Miuix
- api.ts adminListNotifications + types.ts AdminNotification(Query) + sections.tsx 侧栏项{id:notifications,group:运行时,icon:light} + AdminApp renderSection + router.ts VALID_SECTIONS
- 契约: GET /admin/notifications?type=&before=&limit= → {data:[{id,type,title,body,source,createdAt}]}; 未读/清空需后端read端点(诚实Notice,不做前端假实现)
- 前端 build 绿(157 modules); 后端端点由并发代理[194]落地
- icon 用现有 light(Icon.tsx 无 bell; 如需铃铛另加 path)
2026-09-30 08:57:15 +08:00
AskaEth 1a85de555d feat(emotion): A1 情绪系统接线 + refactor: 拆分 channelbridge.go
A1 情绪接线:
- loop 回合边界钩子(TurnEnd/OnTurnEnd/settleTurn, Result.Mood); 每轮一次非每步
- persona/emotion: React(valence五档) + Machine.Move(BFS最短合法路径一步) + Observer(observe/decay/好感度,仅变化才emit); 好感度步长读角色配置(cyrene delta=10, 0..700)
- 落库 Mood(web session_persist.res.Mood + IM channelbridge/persist; user消息不带mood); WS mood事件(BroadcastMood,仅变化); per-persona隔离; 群聊/陌生人只读不写不发
- cmd/cyrene/emotion.go 生产接线 + wire.go 注入 loop.Options.OnTurnEnd
- 22 测试(状态机被loop调用/Mood非空落库/mood帧shape/只变化才发/群聊只读) + 真二进制冒烟
- Docs: api-client v0.32 §3.7 情绪事件 + 开源对照清单(companion 应用对照)
- 诚实: 本机无LLM凭据→未活体验证'模型返回后完整轮次'一环

拆分 channelbridge.go 816→257(纯搬移零逻辑): connect/inbound/outbound/blacklist/helpers.go; A1 的 IM mood 调用点落在新 inbound.go, 故与本提交同批

回滚 6 个 gofmt 顺手格式化文件(capacity_loop_test/flow/source_test/findchannel_test/admin_channels_platform_test/history) 缩 diff, 含 history.go 注释被转弯引号
2026-09-30 08:51:38 +08:00
AskaEth 610b330025 refactor(model): 拆 openai.go 652→216行(纯搬移零逻辑改动) — encode/response/stream 三件套对齐 anthropic 布局
- openai.go 216(适配器+Chat/ChatStream+共享助手); openai_encode.go 202(请求wire+build*); openai_response.go 79(响应wire+responseFromWire); openai_stream.go 174(scanSSE)
- 字节级比对 body identical; 导出API不变(go doc diff 空); gofmt 干净
- 纯净基线(HEAD+本改)全仓 go build/vet/test 绿 + win/darwin 交叉编译绿
- 遗留: scanSSE 138行(>80, 拆分前既有,零逻辑改动未动), 另开步骤重构
2026-09-30 08:45:04 +08:00
AskaEth ca2faa0f95 feat(admin): 仪表盘次级概览卡接真数据(A5) — 8卡聚合,存储卡诚实占位
- Dashboard.tsx 去掉占位块 → OverviewSection; 新增 admin/modules/dashboard/(MiniMetric/useDashboardExtras/overviewMetrics/OverviewSection)
- 8卡用现有 lib/api 导出: LLM组/RAG文档/自动化/已装插件(含toolCount)/用户/网关请求/LLM调用+子代理/存储(缺只读端点→诚实占位)
- 端点报错→值'—'+徽标'端点报错'+hint后端原文(不崩不伪造)
- 仅碰 Dashboard.tsx+dashboard/*, 未动 api.ts/types.ts(避免并发冲突)
- 前端 npm run build 绿(156 modules)
2026-09-30 08:44:00 +08:00
AskaEth f28995c1e5 feat(admin): 插件能力页按类型分组展示 — 工具/技能/渠道分开,渠道不再显示工具策略开关
- GET /admin/plugins 后端仍平铺返回(契约不变), 前端 Plugins.tsx 按 kind 分组渲染
- 工具+技能: 保留「群聊/陌生人」环境授权开关(ToolPolicy, owner可编辑)+卸载; 渠道: 仅名称/描述/『见渠道管理页』, 无策略开关(渠道走自身配置)
- 渠道块文案tone=neutral, 工具技能accent; Miuix 原语复用
2026-09-09 02:57:13 +08:00
AskaEth 2dee372fb2 feat(backthink/admin): ③b — rag/delete 真实现 + D1 推送出口钩子(自建优先→个推兜底) + api-reference 补 D2/D3/D5 补丁
- B5: POST /admin/rag/delete 真做(DeleteDocument→DeleteProject 级联删章节+索引清理, owner+审计, 200/404/500, 500降级兜底); users/iot scenarios+rules 诚实501(无用户表/IoT场景规则模型, 消息写明缺什么, 不伪造), 锁住 501+not_implemented
- D1: backthink/push_outlet.go PushExporter{Export}; pushOutlet 自建HTTP(2xx=送达, token仅Authorization头+error脱敏)优先→Getui兜底(未接入, 诚实日志'getui not integrated'+(false,nil)); backthink.Deps.Exporter → pusher.withExporter, Push 在WS/IM后 exportAsync(detached+5s超时, 失败仅日志, 不破坏现有投递, 限流不上报); config PushOutletConfig(PUSH_EXPORTER_* + GETUI_*占位)
- 前端 Rag.tsx/api.ts 删除后 reload + 类型; Docs/api-client v0.31 + api-reference(#23 owner/200-404, §8.4, 端点表PATCH/DELETE messages + pinned + messages?q=)
- 测试(push_outlet/admin_rag_delete/admin_honest501/knowledge store), 后端+前端build全绿+3平台交叉编译
- 刻意: Getui SDK 未接入(无SDK不硬造), 主人自建服务填 PUSH_EXPORTER_URL/TOKEN 即可
2026-09-09 00:08:29 +08:00
AskaEth 844b2a74c1 feat(history): 消息搜索/编辑/删除 + 会话置顶(D2/D3/D5)
- history/message_ops.go(新): Search(会话内, q/after互斥, LIKE参数化+%/_转义+大小写不敏感, 严格限定会话doc) / UpdateMessage(返回更新后Message) / DeleteMessage / messageByID / escapeLike
- history/session_pin.go(新): SetPinned; 迁移0009 chat_sessions加pinned INTEGER DEFAULT 0; store.go SessionMeta.Pinned + createSessionsTable加列
- session_preview.go: 查询加s.pinned + ORDER BY pinned DESC,(type=main) DESC,created_at ASC
- gateway history_write.go(新,保持history.go≤500): handlePatchSession(pinned+title) / handlePatchMessage / handleDeleteMessage; server.go加PATCH/DELETE /api/v1/messages/{id} + /sessions/{id}置顶
- history.go: handleListMessages读q分流Search; 契约PATCH title/置顶
- D2会话内搜索(不做跨会话,隔离会话不外泄红线); D3不做重发端点(客户端编辑后再发); D5用pinned bool
- 测试(message_ops/history_messages/migrate_pinned), build/vet/test绿+3平台交叉编译; Docs/api-client v0.30
- 未做: 编辑/删除WS实时广播(message_edited/deleted), 下批
2026-09-08 23:59:13 +08:00
AskaEth c7b2cd8ae0 fix(model): 解析 stop_reason + max_tokens 截断重试/非空兜底 — 不再静默空回复
- model.go: Response.StopReason; anthropic wire加stop_reason; openai finish_reason映射(两协议统一,原值透传)
- truncation.go(新,159行): effectiveMaxTokens/retryMaxTokens(×2封顶32768)/isTruncatedEmpty(空正文+无tool_use+stop_reason∈{max_tokens,length})/withEmptyBodyHint/callWithTruncationRetry/callStreamWithTruncationRetry/dropThinking/emitTextSegment
- anthropic.go Chat/ChatStream 走重试+兜底; anthropic_encode.go(新)拆编码(anthropic.go 529→357合规); anthropic_stream message_delta落StopReason
- agent/loop: 把模型层StopReason带到llm.call span的model_stop_reason属性(修掉trace硬编码stop的误导); loop.Result.StopReason语义未动
- 触发仅当截断空正文; tool_use空正文永不触发(native_toolcall_test仍过); 至多1次重试; 兜底回'(思考过长,未能生成回复)'绝不用thinking
- 新增7测试(非流式/流式重试+兜底+tool_use不触发+封顶+openai映射), build/vet/test绿+3平台交叉编译
2026-09-08 23:52:47 +08:00
AskaEth 18b2040c95 feat(admin): 面板补全 — 插件安装表单+已装列表 / LLM组maxTokens展示编辑 / POST admin/llm 部分编辑合并
- 前端: api.ts(adminPluginInstall带config/uninstall/listInstalledPlugins) + types + plugins/InstalledPlugins.tsx(已装列表+安装表单, Miuix) + llm/LlmProviderForm.tsx(maxTokens输入) ; Plugins.tsx去假安装按钮
- 后端 admin_write_llm_channels.go: llmProviderRequest字段改指针, mergeLlmProvider部分合并(省略保留原值), runtimeconfig/models.go Find
- 新增测试 admin_write_test TestAdminAddLlmProviderPartialEditPreservesFields
- 前端 build + 后端 build/vet/test 全绿; Docs/api-client v0.29
- (面板无法实机登录, 用 react-dom/server 冒烟渲染 LlmModule/PluginsModule)
2026-09-08 23:47:09 +08:00
AskaEth 70c9fba529 feat(plugin): 插件热安装(MCP server) — POST /admin/plugins/install 连接+发现+注册工具进registry+落盘plugins.json
- capability/plugin/manager.go: Manager(Install/Uninstall/List/Restore/Close); Install=连接+发现+mcp:name注册工具,失败不落盘; holder持有*mcp.Registry; boot Restore重装
- config/runtimeconfig/plugins.go: PluginConfig(Normalize/Validate), LoadPlugins/SavePlugins(0600+tmp+rename)/Upsert/Delete/Masked(apiKey+敏感env掩码)
- mcp: MCPConfig加APIKey/Env, http/sse Bearer应用, stdio子进程env合并; registry.ToolNames()
- gateway: adminPluginInstall/Uninstall/ListInstalledPlugins; Deps.Plugins注入
- cmd/cyrene/plugins.go: wirePlugins(boot Restore+close)
- 契约: body{kind:mcp,name,config{transport, endpoint?/command?,args?,apiKey?,env?}} → {installed,name,kind,transport,toolCount}; 失败502不落盘/非法400/未注入501; 卸载连带unregister+删配置
- 测试(manager/plugins/admin/mcp_auth/组合根), build/vet/test绿+3平台交叉编译
- Docs/api-client v0.28 + api-reference install=501改真实; .gitignore加plugins.json
- 前端api.ts仍发旧body(无config)会400, 面板安装表单待后续
2026-09-08 23:20:12 +08:00
AskaEth 53002f1e11 feat(model): LLM token 预算可配置化 — models.json每模型maxTokens + LLM_FALLBACK_MAX_TOKENS回退兜底
- config.go: LLMConfig.FallbackMaxTokens(LLM_FALLBACK_MAX_TOKENS, 默认8192)
- runtimeconfig/models.go: Group.MaxTokens(models.json每模型独立预算, 负值拒绝)
- model/fallback.go: Chain.WithTokenBudget + applyBudget; runtime.go buildSnapshot接入(active.MaxTokens, env.FallbackMaxTokens)
- 优先级: 显式Request.MaxTokens > 组maxTokens > 仅多步链末端的LLM_FALLBACK_MAX_TOKENS > 适配器默认8192; 单步链不动env兜底(避免OpenAI主模型行为回归)
- gateway admin: AdminLlmProvider.MaxTokens 透传/回显/负值400
- .env.example + .env(LLM_FALLBACK_MAX_TOKENS=8192); Docs/api-client v0.27 + api-reference + design/04
- 新增测试(config/runtimeconfig/budget_test/admin_write), build/vet/test绿+3平台交叉编译
2026-09-08 22:42:27 +08:00
AskaEth f347681034 fix(loop): 原生工具调用从 Response.ToolCalls 解码 + 逐次回填工具结果 — 修复工具消息无回复
- 根因: loop/agent.decodeToolCalls 只读 resp.Message.ToolCallName/resp.Content JSON信封, 从不看 api 原生解析出的 resp.ToolCalls(Anthropic tool_use / OpenAI tool_calls)
  → 模型返回 thinking+tool_use(无text) 被判定'无工具调用' → 空 Content 当最终 text → res.Text="" → no reply produced; 纯聊天消息不调工具直接出 text, 故表现为'部分消息无回复'
- agent/step.go: ToolCall 增 ID; ExtractToolCalls 导出并优先 resp.ToolCalls(保留原生id+归一化nil Args), 回落两旧约定
- loop/request.go: decodeToolCalls 委托 agent.ExtractToolCalls(流式/非流式共用)
- loop/tool_history.go(新): 原生批次回填 assistant tool_use + 带tool_use_id的tool_result; 旧JSON信封批次保持 RoleTool 纯文本(向后兼容)
- openai.go: tool_call parts 编成 tool_calls + tool_call_id
- 新增 native_toolcall_test + native_toolcall_http_test(httptest假Anthropic SSE: round1 tool_use(无text)→round2含tool_use_id/tool_result→res.Text非空)
- build/vet/test绿+交叉编译
2026-09-08 22:37:05 +08:00
AskaEth 57d5d22d87 fix(model): anthropic默认max_tokens 1024→8192 — thinking+text共享预算,长思考不再截断正文为空
- 根因: deepseek-v4-flash-vision-exp 为 thinking 模型, content=[thinking,text] 共享 max_tokens,默认1024被长思考吃光→仅thinking块无text块, stop_reason=max_tokens → resp.Content="" → no reply produced
- 仅当 req.MaxTokens==0 套用默认(显式值不受影响); loop→agent→anthropic 传递链无截断
- 新增 anthropic_budget_test(默认发8192 + 长思考短text断言Content=text+thinking不泄漏)
- build/vet/test绿+交叉编译
2026-09-08 22:09:29 +08:00
AskaEth f43751fd07 feat(history): 消息正向增量拉取(A5) — GET /sessions/{id}/messages?after=
- history/list_after.go: ListAfter(升序, created_at>after, 走覆盖索引, rowid兜底同毫秒)
- HistoryStore 新增 ListAfter(store+fakes同步)
- gateway history.go: handleListMessages解析after分流; after+before互斥400; 负数/非数字400
- 新增测试(list_after/history_after), build/vet/test绿+交叉编译
- Docs/api-client v0.26 + api-reference §3.4 同步
2026-09-08 21:49:12 +08:00
AskaEth 026b27b310 feat(auth): refresh token 续期(B2) — 不透明哈希+单次旋转+/auth/refresh+/auth/logout
- auth/refresh.go: RefreshStore(Issue/Rotate/Revoke/RevokeAll/RevokeByUser), SHA-256哈希256-bit随机串, 单次旋转事务(并发重放fail-closed)
- 迁移0008_refresh_tokens(id/user_id/role/token_hash UNIQUE/expires_at/revoked_at + 索引)
- gateway: POST /v1/auth/refresh(旋转/续期) + /v1/auth/logout(单/全设备); login响应增refreshToken
- config: AUTH_REFRESH_TTL(720h)/AUTH_REFRESH_ROTATE_DISABLED; decode.go 支持time.Duration
- rotate-jwt成功后RevokeAll(响应refreshTokensRevoked), revoke-keys不动refresh
- 14测试+真实二进制e2e, build/vet/test绿+交叉编译
- Docs/api-client v0.25 + api-reference §2.3/2.4 同步
2026-09-08 21:45:00 +08:00
AskaEth 7ecb43bd5d feat(gateway): 图片上传端点(B1) — POST /v1/upload + GET /v1/files/{name}
- upload.go: multipart上传(owner/admin+审计)→ {url,size,mime,name}; MaxBytesReader 5MB + DetectContentType嗅探 + 白名单raster(拒SVG/HTML) + crypto/rand 128-bit随机名 + 0700目录
- 静态服务: 同端口 GET /v1/files/{name}, 能力URL, 路径穿越全形态拒绝(.. / %2f / %2e%2e), nosniff, Range
- errors: ErrPayloadTooLarge(413)/ErrUnsupportedMediaType(415)
- server.go 路由+Deps.DataDir; wire.go resolveDataDir 注入
- 8测试+真实二进制E2E, build/vet/test绿+交叉编译
- Docs/api-client v0.24 + api-reference §0.3 同步
2026-09-08 21:36:03 +08:00
AskaEth 3fc10fb86f docs(cors): X-API-Version 明确为 advisory 惯例头(E1) — 消除版本协商假象
- middleware: CORS Allow-Headers 加 X-API-Version(跨域客户端不再预检失败)
- api-reference §0.2 + api-client §4.1: 声明服务端不校验、不据此协商版本
- 后端/前端零行为改动(前端仍在发,现CORS放行)
2026-09-08 21:24:36 +08:00
AskaEth 99f222761e feat(voice): 语音silent降级信号(C2) — provider仅在结果为空时出现
- voice/Chain+Service: STTWithProvider/TTSWithProvider(真实档位cloud/local/edge-tts/espeak/silent), 旧方法改薄包装
- gateway: transcribe空文本带{text,provider} / voice_output空音频带provider / speech空音频tts_empty+X-Voice-Provider头
- middleware: CORS Access-Control-Expose-Headers: X-Voice-Provider
- 可选接口 voiceProviderReporter(type断言透传), 自定义实现回退silent
- 新增测试10个(provider_signal/voice_signal), build/vet/test全绿+交叉编译
- Docs/api-client v0.23 + api-reference CORS 同步
2026-09-08 21:23:12 +08:00
AskaEth 00f784b55f docs(api-client): 补 proactive_message 主动推送事件(B6) — §3.6 触发/载荷/与message_persisted区别/owner-only+脱敏红线 2026-09-08 21:18:53 +08:00
AskaEth d102f75469 feat(history): 会话列表lastMessage预览(B3) — 单查询无N+1
- history/session_preview.go: SessionMeta.LastMessage + 单条SQL(LEFT JOIN取每会话ORDER BY created_at DESC,rowid DESC LIMIT 1,走覆盖索引)
- ListSessions 迁出重写(签名不变), content折叠空白截前60rune
- gateway: historySessionSummary 增 lastMessage{role,content,createdAt}(omitempty), /sessions与/admin/sessions共用
- 新增测试(session_preview_test, history_lastmessage_test)
- Docs/api-client.md v0.22 同步
2026-09-08 21:17:26 +08:00
AskaEth 89c5fe8027 feat(gateway): 会话title生成+PATCH改名(A4) — 首条用户消息自动命名+可重命名
- history/title.go: RenameSession(改名字段)+ensureTitle(首条用户消息自动命名,折叠空白取前20rune,纯空白回落主会话/次级会话)
- store.go insert() 前调用 ensureTitle(覆盖Web+IM,最小侵入), HistoryStore 新增 RenameSession
- gateway: PATCH /api/v1/sessions/{id} (owner/admin, ≤80rune, 主会话可改名), handleRenameSession
- 新增/扩展测试(title_test, history_handler_test, fake同步)
- Docs/api-client.md v0.21 同步
2026-09-08 21:13:36 +08:00
AskaEth 747b9ad414 feat(web): 聊天请求携带行为模式 mode(A3) — useChat 读当前 mode 经 REST 发送
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- api.ts chatStream 新增可选 mode,空则不写键(后端回落chat)
- useChat.ts 发送时带上 useModeStore 当前 mode,使非chat模式消息在Web端真正落库
- 修正 docblock(移除虚构的WS chat回退描述)
2026-09-08 21:07:59 +08:00
AskaEth b686d5fec8 feat(gateway): 消息mode落库(A3) — 持久化真实写入渲染模式
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- POST /api/v1/chat 与 ws.chat 上行新增可选 mode(行为模式 design/01 §3/design/16 §5.1)
- Web路径经ctx透传本轮mode到编排器持久化(persistUserMessages/persistAssistantReply),缺省回落chat
- pendingMsg记录mode,恢复/续接路径沿用发起轮次mode
- IM路径(persistToMain/persistIsolated)恒为chat
- 新增/扩充测试:REST/WS/orchestrator/channelbridge 覆盖mode写入
- Docs/api-client.md v0.20 同步mode字段
2026-09-08 21:05:50 +08:00
Aska fe5dbcddba fix(web): 项目主图标统一替换为昔涟3rd_Form-Cyrene-3F-Q-Happy-1.png
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- favicon/apple-touch-icon (index.html)
- PWA manifest 图标 (manifest.json)
- PWA precache (sw.js)
- 用户端侧栏品牌头像 (Nav.tsx)
- 管理面板顶栏品牌头像 (AdminApp.tsx)
- 删除已被替换的 icon.svg 残留引用
2026-09-08 20:38:52 +08:00
Aska bcf7fe5de0 refactor(channelbridge): 多气泡分割+旁白剥离+随机延迟上移为IM公共出口——sendReply/SendProactive统一(微信主动推送也分割)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-09-08 16:26:35 +08:00
Aska d09b25071d feat(backthink): 事件驱动主动消息绕过PushGate——IoT等事件该发就发(heartbeat仍受60min节流)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- trigger.go: 新增 TriggerKind(TriggerHeartbeat/TriggerEvent) + Trigger.Kind 标记触发来源
- push.go: PushRequest.TriggerKind 分流——event 跳过 gate.Allow/RecordPush(该发就发),heartbeat 仍受 60min≤1/冷却/分类限频;PushResult.Bypassed 标记 + detail=event_bypass 区分
- backthink.go: think() 传 TriggerKind + 过同事件去重门
- event_dedup.go(新): 同事件(Source+Summary)去重防刷,无 60min 节流;不同事件/心跳后重置链
- 测试: event 连续 3 次全推 bypass / heartbeat 第2次 rate_limited / event 不消耗心跳限 / 同事件去重
- build/vet/test 全绿 + windows/darwin 交叉编译通过
2026-09-08 13:46:43 +08:00
Aska 3e4efa0e41 feat(backthink): 主动推送每次只发一个平台——决策渠道/最近活跃优先,平台含微信
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
主动推送目标不再取第一个白名单渠道(kook),也不再双目标 web+kook;改为每次只发一个平台,范围=全部已连接白名单渠道(含微信 weixin-ilink)。

backthink/push.go: pusher 单目标路由——web hint 一次 pushActive;im/auto/实体平台 hint 一次性 pushIM (msgs 置哨兵/实体平台),无 IM sink 时诚实降级 pushActive;保留 PushGate 降频与成功 RecordPush。
channelbridge/proactive.go: proactiveTarget 三级选择——①决策 [渠道:xxx] 实体平台且白名单+已连接优先;②最近活跃(主会话最近20条 newest-first 找白名单+已连接平台,web 会话自动跳过);③回退 NextConnectedAccount 用旋转游标避免永远 kook。微信不再排除、可被选中;决策 hint 指定非白名单渠道回退(不报错)。Bridge 新增 proactiveCursor 字段(mu 保护)。
context.go: guidancePrompt 描述改为每次挑一个合适渠道(含微信)。
测试: 决策优先/最近活跃/微信可选中/无信号回退旋转/单目标 等,go build/vet/test 全绿 + windows/darwin 交叉编译。stash@{0} 保留被取代的'web+kook双目标'WIP以备回滚。
2026-09-08 13:34:22 +08:00
Aska 12031bd13c fix(backthink): 主动推送降频(15min心跳+60min≤1次+推后30min冷却+刚聊过10min冷避) + 情绪约束(分类限频/活跃感知) + 剥离'我决定:push'内部标记
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-09-08 13:08:57 +08:00
Aska 03d7050e1e feat(voice): 百炼声音复刻create_voice + TTS instruction情绪驱动(主TTS-昔涟复刻音色)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-09-08 06:48:15 +08:00
Aska 73dabb9dde feat(voice): 阿里百炼 STT/TTS 主链路接通(base64 STT + cosyvoice TTS按voice_id + REST /audio/* + voice_input.deviceId)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- voice/dashscope.go 新建:DashscopeSTT(qwen-audio-3.0-asr-flash, base64 data URI→text) + DashscopeTTS(cosyvoice 按 voice_id, 取非流式 24h URL 下载字节)
- cloud.go newDashboardSTT/TTS 由空 endpoint stub 改为真实百炼 adapter
- chain.go gating 从 DashscopeAPIKey!="" 改为 voiceConfigured(cfg); silent 兜底保留
- config.VoiceConfig 扩展 DASHSCOPE_BASE_URL/VOICE_STT_*/VOICE_TTS_* 字段(必填 DASHSCOPE_API_KEY)
- gateway: POST /api/v1/audio/{transcribe,speech} (owner+审计+MaxBytesReader); voiceInputRequest 增 deviceId 并透传 runner.Run
- 实测: cosyvoice-v3.5-plus+longxiaochun 回 418(引擎拒绝), 改用 cosyvoice-v3-flash+longxiaochun_v3; TTS 你好我是昔涟->106124B wav -> STT 回 你好我是西联, 链返回真实字节/文本
- 单测: dashscope_test/config voice defaults/gateway audio_test; go build/vet/test 全绿
- 声音复刻(响昔涟音色)待公网参考音频; 不做 create_voice
2026-09-08 06:06:02 +08:00
Aska d8044763ac docs(api): 后端全部API超详细调用文档(api-reference.md)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-09-08 05:25:38 +08:00
Aska 9b3d1d4b44 docs: 撤销误入的未审阅 api-reference.md(非本任务产物,退回工作树未跟踪)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-09-08 05:21:20 +08:00
Aska f46692c329 docs: 按实际进度诚实化能力清单(已实现/降级/未实现三档)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-09-08 05:20:48 +08:00
Aska cf25c6643d feat(admin): 系统设置接入已实现的后端4端点(吊销key/轮换JWT/备份/恢复),未实现项诚实标注
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-09-08 04:53:22 +08:00
Aska 5a7ee9d772 feat(owner): 扫码绑定确认时自动把主人id登记进owner.yaml白名单(热生效)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-09-08 02:53:01 +08:00
Aska 0aa387f83c config(owner): 登记 weixin-ilink 主人 id——扫码绑定账号自动认主(人工补白名单基线)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-09-08 02:45:35 +08:00
Aska fb646552b9 fix(weixin): 扫码确认时把主人user_id并入allow_users白名单——修复主人消息被当stranger静默不回复
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-09-08 02:41:33 +08:00
Aska 5dfaa75a06 fix(scripts): ctrl.sh restart 不再挂起超时——启动后 disown 剔除后台 job,脚本立即返回
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-09-08 02:34:56 +08:00
Aska 9e4e6e623f fix(admin): 微信扫码连接成功后不再残留二维码(showQR仅等待时)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-09-08 01:53:59 +08:00
Aska 8d44bba14a fix(weixin_ilink): 换码阈值5s->60s——码未过期一律复用,防前端高频login-qr砍确认会话
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-09-08 01:49:20 +08:00
Aska 851c8a31ce fix(admin): 微信扫码'已扫码待确认'提示Tag改用合法tone=neutral
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-09-08 01:15:35 +08:00
Aska 2baa2cc7ca feat(admin): 微信扫码仅在'超45s且未扫到'才换码,已扫待确认固定不换
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-09-08 01:14:59 +08:00
Aska c515de28bf fix(weixin_ilink): 已扫待确认不重新取码 + scaned 状态暴露 + 扫码链路日志
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
StartLogin 换码保护: 当 runLogin 观测到 wire 状态已进入
scaned/scaned_but_redirect/need_verifycode(confirmingPhase)时,
复用当前 loginSession 的 QR 而不重新 fetchQRCode/cancel 旧 poll,
避免 45s 自动刷新/恢复前台把已扫未确认的会话砍掉导致微信 confirm 丢失。
仍保留 qrReuseWindow: 未扫描(等待中)且超窗口才重取, 保留显式刷新二维码要新码。

runLogin 补 case scaned: setLoginState(LoginStatusWaiting, loginScannedDetail),
沿用 Waiting 不新增破坏性状态; 顺带在会话上记录 wire phase 供 StartLogin 判别。

可观测性: fetchQRCode 取码成功记 get_bot_qrcode ok(url 脱敏, 不含 qrcode),
pollQRStatus 状态变化才记 get_qrcode_status(status), completeLogin/failLogin/
completeBindedRedirect 记结果; 均不落 qrcode/bot_token/user_id。

failLogin 修正: 被 re-mint superseded 的旧会话不再 clobber 新会话状态(不置 failed)。

新增测试: scaned 暴露到 LoginStatus; scaned 超窗口复用 QR 不重取; 等待中超窗口
重取; 日志脱敏断言。
2026-09-08 01:12:59 +08:00
Aska 4223f5a19a fix(admin): 微信扫码恢复前台不再换码——修复'已扫未确认被换码丢确认'竞态
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-09-08 01:08:42 +08:00
Aska d970884ff8 feat(admin): 微信扫码二维码每45s自动刷新+后台垫'过期'叠加层不换码
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-09-08 00:59:01 +08:00
Aska f5950e4bde feat(weixin_ilink): 扫码协议与腾讯官方对齐——POST local_token_list + iLink-App-Id/ClientVersion 头 + need_verifycode/redirect 状态机
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-09-07 22:49:25 +08:00
Aska bef398d47a fix(weixin): 二维码回退用qrUrl生成 + 每次点击重新发全新码(修'过期')
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
用户实测: qrcode原始串编码微信扫出纯字符串(qrcode得用qrUrl liteapp链接); 过期的真正原因
是复用旧二维码(微信码有效期极短)。修: 二维码改回用 qrUrl(liteapp链接)生成; StartLogin
把复用窗口从90s降到5s(仅防连点),否则每次'扫码登录/刷新'都取消旧会话重发全新码——永远新鲜可扫。
build/vet/test+前端build 全绿,已部署。
2026-09-07 21:54:08 +08:00
Aska b6b258498c fix(weixin): 二维码改用 qrcode 原始标识串生成——qrUrl(liteapp页面)编码不能被微信识别
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
把 qrUrl(liteapp HTML页面URL)编码成二维码,微信扫它当普通链接打开→页面不认→'过期'。
改用 qrcode 原始标识串生成二维码(get_qrcode_status 轮询的 key, 微信扫码后解析), 并保留
qrUrl 作为'在微信/浏览器打开扫码页'的备用链接。build/部署。
2026-09-07 21:51:30 +08:00
Aska a9dfae4d5f fix(weixin): 扫码QR超时自动重发——修'二维码过期'(旧码被幂等返回)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
StartLogin 幂等会一直返回同一二维码,但微信bot绑定码有效期极短;此前测试触发过一次登录
会话,旧码过了有效期,用户刷新仍拿到它→扫即'过期'。修: loginSession 加 startedAt,
超过 qrRefreshTTL(90s) 时 StartLogin 取消旧会话并重发新鲜二维码(panel'刷新二维码'永远可扫)。
重启清除内存中旧会话。build/test绿,已部署.
2026-09-07 21:41:43 +08:00
Aska 36bd19e997 fix(weixin): 扫码登录二维码真实可扫——前端生成QR(修坏图) + DecodeConfig容忍空config
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
weixin qrcode_img_content 是微信liteapp HTML页(非图片),后端fetch转base64只得<!doctype>→前端img坏图。
- 后端 login-qr 改返回 qrUrl(qrcode_img_content URL)+qrCode(原始qrcode),去掉坏 qrImage
- 前端用 qrcode 库从 qrUrl 生成真实二维码图片(微信扫https链接即开连接页), 新增 qrcode+@types/qrcode(纯UI依赖)
- DecodeConfig: nil config map 当空map解码(不报错), 否则 pending账号(空config)构建被跳过→未注册→登录端点404
- 测试更新(DecodeConfigNil/admin QR端点断言 qrUrl+qrCode)
- 另有 weixinAccountFor 裸weixin兜底(此前提交 d3b0921) 保证 accountId 命中
- 实测: login-qr 200{waiting, qrUrl=89}, 前端能看到可扫二维码
build/vet/test 全绿, 已部署.
2026-09-07 21:32:19 +08:00
Aska d3b0921491 fix(weixin): 扫码登录端点兼容裸weixin适配器——accountId不再404
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
weixin 适配器可能注册为裸 `weixin-ilink`(单账号)而非 `weixin-ilink:<acct>`, weixinAccountFor
只认复合名导致 accountId=05487->404。修为: 按平台匹配 + 优先复合名 + 找不到时兜底用裸
weixin 适配器(面板传config账号id)。实测 accountId=05487 登录端点 200{idle}。build/test绿。
2026-09-07 21:14:54 +08:00
Aska 3f2c981cf8 fix(weixin): 空token''连接''不再阻塞+409请先扫码登录;Connect空token返回ErrNeedsLogin
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
面板点''连接''此前触发阻塞式扫码(最长8min)不展示二维码→挂起。修:
- weixin adapter Connect: 空token返回 ErrNeedsLogin(非阻塞), 扫码走独立 login-qr/login-status
- adminChannelOpen: errors.Is(ErrNeedsLogin)→409'该渠道尚未登录:请先扫码登录再连接'(不再500/挂起)
- login_test.go 改用 StartLogin/LoginStatus 驱动(与旧Connect阻塞行为解耦)
build/vet/test 全绿, 已部署.
2026-09-07 21:06:10 +08:00
Aska abb137cfe2 fix(weixin): 面板空token保存微信iLink放行——为扫码登录先建账号
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
adminSaveChannelConfig 的 len(cfg)==0 校验要求至少 token/baseUrl/address, 但微信扫码登录
需先建空token账号(再扫码回填 token/bot_id/owner_id)。对 weixin-ilink 豁免该校验,允许空配置
账号保存; 其余平台保持必填。PUT 空token 保存已验证 201。build/test 全绿, 已部署.
2026-09-07 20:58:48 +08:00
Aska e037fab7d0 feat(weixin): 微信iLink扫码登录——面板触发+二维码显示+状态轮询+确认热连接
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
适配器: login_admin.go(StartLogin 幂等取二维码/LoginStatus idle-waiting-confirmed-failed);
       login.go 重写 Connect 委托 StartLogin+等待,后台 runLogin 轮询,completeLogin/failLogin
       (锁序 loginMu->a.mu 不嵌套反向无死锁); adapter.go 加登录会话字段
网关: admin_channels_weixin.go(2 端点 login-qr/login-status owner+审计, confirmed 合并写回
       channels.json 保留 base_url/cdn/黑白名单 + Reload 热重载连接; qrImage best-effort Base64,
       token 掩码) + server.go 路由
前端: WeixinLogin.tsx(token空→扫码按钮→二维码→1.5s轮询→confirmed提示已登录+显示bot_id),
       AdapterDetailPage 渲染, api/types 补类型
必需偏离(最小化): 允许空token的weixin账号被构建/持久化(channels.go+runtimeconfig移出
       tokenRequiringPlatforms),否则空token账号无法注册调用StartLogin; 空token保持断开(configured
       仍要求token非空),确认后才持久化连接——守红线。
测试: login_admin/admin端点用例; 后端 build/vet/test 全绿 + 前端 build 101模块绿.
2026-09-07 20:50:28 +08:00
Aska 0a597cfa42 feat(weixin): 微信iLink面板配置/启用逻辑对齐真实桥接(token即用)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
面板此前让用户手填 bot_id/owner_id/cursor(实为扫码登录后自动获取/自动落盘),与参考
weixin_ClawBot_Bridge 流程不符。对齐成 kook/telegram 的'token即用、其余自动获取':
- channelFields.ts: weixin-ilink 字段只留 cdn_base_url(可选,留空自动),移除 bot_id/owner_id/
  cursor; guide/hint 改为'只需填 bot_token(必填,或连接时触发扫码登录自动获取),其余自动获取'
- AdapterDetailPage/GenericAdapterPanel: 账号身份区显示自动获取的 bot_id(account.botId)
- 后端确认无需改: cmd/cyrene 仅校验 bot_token;Connect(BotToken空)自动扫码并持久化 bot_token/
  bot_id/owner_id;游标自动落盘;与参考一致
验收: 前端 build 100模块绿 + 后端相关包 build/test ok(无Go改动)
2026-09-07 20:29:56 +08:00
Aska 817f1e6355 feat(chat): 动作/旁白格式统一——兼容星号(*…*/*…) + 人设强制 XML
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
模型常漂移成 *动作:弯起眼睛*(星号),前端/后端只认 <action>/<narration>,导致星号版
没被剥成灰斜体注记/没从 IM 剥除→裸出在气泡、上下文不连贯。
- parseContent.ts(新): 把 XML <action>/<narration>、全角*…*(带'动作/旁白/内心/神态/轻声'
  关键词或整段≤40字)、半角*词…*(仅关键词前缀)统一解析成 ContentPart,剥出正文进 BubbleNotes
  灰斜体注记(*动作:…*); 超长无关键词*…*保留为文本; <p>拆气泡不变
- sanitize.go: stripNarration 同样剥星号动作(与 XML 一致); splitBubbles 不变
- cyrene.yaml: 新增格式铁律——动作/旁白必须用 <action>/<narration>,禁用*/Markdown等
验收: 后端 build/vet/test 全绿 + 前端 build 绿 + Node parseContent/splitBubble 实测(5组)
2026-09-07 20:29:47 +08:00
Aska 06fd2f4aaf fix(backthink): 主动推送也落主会话上下文——修复回复时上下文断片
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
bridge.SendProactive 之前只发送不落历史,导致她主动说的话不在主会话 chat_messages,
你回复时 LLM 看不到她的主动消息→上下文不连贯。现发送后 persistToMain 镜像到主会话
(assistant+platform+deviceID),你回复时上下文带上她的主动消息。build/test 全绿。
2026-09-07 20:18:03 +08:00
Aska c4fe5a0cfd fix(kook): 私聊(临时会话)以对端用户为回复目标——修'不能是自己'40000
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
KOOK 临时私聊里用户→bot 事件的 target_id=bot 自己、author_id=对端用户;原 ChannelID=
target_id(bot id),导致回复发给自己→KOOK 40000 不能是自己。修:非群聊时 ChannelID=发送者
(对端)id,回复即发给对端、每 DM 独立会话。加 TestToUnified_PrivateDmUsesPeerAsChannelID。
build/vet/test 全绿, 已重建部署.
2026-09-07 20:09:32 +08:00
Aska 64efb569c3 fix(flow): 发布 backthink 会话的 graph_update——后台思考页链路导图不再是'等待事件'
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
FlowPublisher allow 谓词只允许 web/web:*,sess-backthink 被排除→backthink 的
输入→思考→输出图从不发布,后台思考页'查看链路导图'恒为'等待 graph_update 事件'。
- backthink.go: 导出 DefaultSessionID="sess-backthink"
- wire.go: 谓词追加 sessionID==backthink.DefaultSessionID(web + backthink 都发;sess-xxx 隔离仍过滤)
go build/vet/test 全绿; 已重建部署. 下次 backthink(5min心跳)运行时即发布该会话图.
2026-09-07 19:57:03 +08:00
Aska 9f35b2663e fix(wire): a.gw 在接线 onPersisted 前赋值——防 nil Server panic
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
wire.go 原在 a.channelBridge.SetOnPersisted(a.gw.BroadcastPersisted) / AuditEvent 之后才
a.gw=gw(663),导致网关 Server 字段为 nil;一旦有 IM 主人私聊落主会话→notifyPersisted→
BroadcastPersisted(nil receiver)→panic 崩溃,面板 adminBackthink 请求失败显示'暂无后台思考'。
把 a.gw=gw 提前到 gw.SetVoice 后(接线前),移除冗余复制。go build/test 全绿, 已重建部署.
2026-09-07 19:54:14 +08:00
Aska 6a2b8b9f12 feat(backthink): 主动推送 sink 打通——hub.SendProactive + bridge.SendProactive + wire 接线
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
backthink 决策+路由(P2)就绪但无投递 sink(ActiveClient/IMAdapter 无实现)。补齐:
- gateway/proactive.go: hub.SendProactive 向 owner/admin 连接广播 proactive_message WS事件,
  内容脱敏(去RawData/media/token),返回接收数,count=0→IM兜底;Server.SendProactive 委托
- channelbridge/proactive.go: Bridge.SendProactive 解析 owner 账户(msg.Platform→匹配白名单账号,
  否则首个已配置主人频道),ChannelType=Direct+ChannelID/SenderID=owner id,走 adapter.SendProactive
- backthink/sinks.go: SetActiveSink/SetIMSink 晚注入(New后重建pusher,currentPusher 并发安全)
- wire.go: 在 gw/bridge 建好后 bt.SetActiveSink(gw)/SetIMSink(bridge)
- 投递链: think→parseProactive→ShouldPush→currentPusher.Push→gate→active(active_client)/IM(im_bridge)
  →record(route+送达数)
- 测试: sinks/gateway proactive/channelbridge proactive; 后端 build/vet/test 全绿
- 注: -race 本机 TSan 不可用(环境); 多平台IM按msg.Platform匹配 owner 账号(单sink诚实);
  weixin 24h窗口按 ErrProactiveWindow 记录为 im_bridge 错误不静默
2026-09-07 19:43:48 +08:00
Aska 2283383b9e feat(backthink): 自主思考 P2——结构化推送决策+渠道选择(design/20)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- proactive.go(新): parseProactive 从结论解析 [我决定:push|no_push][渠道:...]<正文>;
  兼容大小写/全半角冒号; 无标记回退(无/nothing=不推); 畸形/孤标记 fail-safe 不推
- context.go: guidancePrompt 引导结构化结尾+保留P1温暖引导
- push.go: PushRequest{Message,ChannelHint}; Pusher.Push 路由(auto→active优先/IM兜底,
  web→仅active, im→仅IM, 具体平台→IM+platform打标+缺IM回退默认记detail); buildProactiveMessage
- backthink.go: think() 用 parseProactive→ShouldPush=push&&disturbAllowed(不再被tr.Import卡死)
- decide.go: disturbAllowed(asleep/working/quiet 硬性不打扰门) 取代旧import门(保留旧Decide兼容)
- 测试: parseProactive 6组+think端到端(真实pusher:kook→IM/无标记→active/no_push→不推/睡眠→门拦截)
   +buildProactiveMessage+具体平台无IM回退; 后端 build/vet/test 全绿
- 注: 生产 Active/IM sink 未注入 wire.go backthink.Deps(另一步); 多平台IM受单IMAdapter接口限制
2026-09-07 19:34:51 +08:00
Aska 93aff2fea5 feat(backthink): 自主思考强化 P1——主会话上下文+owner env+去压制(design/20)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
backthink 从"心跳空转(独立sess-backthink+写死nothing to report宏)"改成"看得见主人、
能读写记忆、能调工具":think 读主会话最近20条(history.Recent,倒序映射)作为上下文 +
 重写引导prompt(温暖/有针对性/主动表达,删掉 Do not produce chat-style message) + 注入
 loop.OwnerEnv(MemWritable=true/ActorOwner/全工具)。
- context.go: mainContext/recentProvider/thinkInput/guidancePrompt
- thinker.go: LoopThinker 加 recent;Think 读主会话上下文+OwnerEnv+MutedBroadcast
- wire.go: NewLoopThinker(a.loop, a.history)
- 刻意偏离(安全): loop.Run session id 仍用 sess-backthink(不写"web"),避免与用户实时回合
  checkpoint/中断窗口复用污染;上下文来源+运行环境=主会话/owner env,目标达成。红线靠actor元数据。
- 测试: prompt无压制/有鼓励;主会话消息映射;nil退化;端到端Think(owner env 生效)
验收: build/vet/test 全绿+windows/darwin交叉编译绿. P2(主动推送+渠道选择)后置.
2026-09-07 19:21:09 +08:00
Aska 44e7a5c2c5 feat(flow): 实时全链路流程可视化 P1——trace→graph_update→前端全链路流程页(design/19)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
后端: trace/graph.go(RunGraph 图构建器,kind/status 映射,父子优先连线,脱敏白名单+截断);
       loop/flow.go(FlowPublisher 按会话增量发布 ws.graph_update,仅主人web会话); loop span
       start/finish 折入图; run.go StartRound/EndRound; wire.go 组装+注入.
前端: Flow.tsx(全链路流程页:FlowGraphView+会话/节点/开始/耗时卡片+状态计数+节点点开日志);
       FlowGraph 组件加 onNodeClick; types/wsParse 扩展; router/sections/AdminApp 注册 flow;
       api-client.md §3.5 graph_update 契约.
验证: 后端 build/vet/test 全绿+flow_test.go 集成(span→graph→ws.graph_update主干); 前端 build 绿.
红线: 单二进制零新依赖; 工具参数/结果脱敏; 仅主人web会话; 身份靠actor元数据; 只读不虚构.
P2(并行子代理分支/泳道/节点实时日志)后置.
2026-09-07 19:10:57 +08:00
Aska 7a81071ec8 feat(backthink): 后台思考页接 WS 实时——ws.ts 处理 backthink 事件头插 store
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
后端每次思考落库发 ws.backthink(bus→hub→WS),此前 ws.ts 无 case、addBackthink 是死方法。
新增 case `backthink`→parseBackthinkItem→s.addBackthink 头插;parseBackthinkItem 兼容
Go默认字段(ID/Trigger/At/Conclusion/ShouldPush/PushResult)与admin camelCase。
配合页面(行key=id)实现增量插入、不整页重渲。build 99模块绿。
2026-09-07 18:53:26 +08:00
Aska 3386679d45 feat(channels): 适配器日志接 WS 实时推送(落库即秒推, channel_persisted)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
后端: persistIsolated 由 _ = full(不广播)改为 notifyPersisted(full); BroadcastPersisted 按
sessionId 分流——main/web:<uuid> → message_persisted(Web聊天同步), 隔离 sess-xxx → channel_persisted
(适配器日志专用, 永不写主会话 web, 红线). payload data:{message:historyMessage}(strip thinking).
前端: ws.ts 新增 channel_persisted case→parseAdminChannelMessage→pushChannelEvent; wsParse.ts 纯函数;
useAppStore 新增 channelEvents(最新在前,按id去重,cap200,不持久化); channelLogList 订阅过滤
(单adapter按adapterId/总览取adapterId非空)merge进列表(仅追加、id去重、不整页重载),保留拉历史+10s兜底.
Docs/api-client.md §3.4 补 channel_persisted 契约.
验收: 后端 build/vet/test 全绿+windows/darwin交叉编译绿; 前端 npm run build 99模块绿.
注: 偏离=web:<uuid> 归 message_persisted(保Web副会话同步), 隔离永不带web前缀红线仍守.
2026-09-07 18:51:00 +08:00
Aska 9e7b4ebaac feat(backthink): 后台思考页全 WS 实时驱动——去掉轮询整页重渲
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
后端每次思考落库发 ws.backthink → useAppStore.backthink head-insert。页面临近打开时
冷拉一次历史(adminBackthink),此后靠 WS 事件增量更新;去掉 useAutoReload(10s)轮询替换
数组(会整页重渲)。行 key=id → React 只插入新记录、不动已有行。build 99模块绿。
2026-09-07 18:46:04 +08:00
Aska 0a645b7212 feat(backthink): 后台思考面板并入已存记录——不再只读WS实时事件
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
后台思考页此前只读 /ws/chat 的 backthink/thinking WS 实时事件(后台任务完成才有),DB里
270 条 backthink_records 看不到。新增 adminBackthink() 拉 GET /api/v1/admin/backthink +
useAutoReload(10s),与 WS 事件按 id 去重、按 at 倒序展示;空态/数据源说明更新。
验收: npm run build 99模块全绿
2026-09-07 18:44:58 +08:00
Aska db8465f68b fix(kook): 丢弃自我回声消息,防 bot 把自己的回复当入站用户消息
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
KOOK 网关会把 bot 自己发的消息又推回给 bot(author_id=bot uid),导致 bot 把自己的
回复当成入站用户消息处理(循环/浪费token)。toUnifiedMessage 计算 senderID 后调用
isSelf(senderID) 比对 a.botID(user/me 解析出的 bot 账号 id),命中即返回 ok=false 丢弃。
- identity.go: 新增 isSelf(并发安全,botMu)
- message.go: self-echo 丢弃 + debug 日志
- message_test.go: TestToUnified_DropsSelfEcho
验收: go build/vet/test ./... 全绿
2026-09-07 18:38:15 +08:00
Aska 2c4ade5f28 feat(engage): 通道来源上下文注入(design/16 §4.6.1)——system行+结构化字段,防注入
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- source.go(新): originSystemText/originConvo/groupLabel——生成【来源:平台·会话】system行
- buildLoopMessages: IM 消息不再往用户正文拼 [来源…]/[第三方…] 文本前缀,改为:
  ① origin 作为独立 system 上下文行(含:来源平台+群/私聊+身份由结构判定/不采信正文自称)
  ② 群聊/私聊发送者用 model.Message.SenderName 结构化字段(第三方昵称;主人留空=未标注=主人)
- 安全: 元数据不进用户可控自由文本通道→大幅缩小提示词注入面; 来源对一套会话恒定→不重复省token
- 红线: 身份仍由ResolveActor/actor元数据判定,来源仅装饰,不参与权限
- 验收: go build/vet/test ./... 全绿; source_test 表驱动+集成用例已按新设计重写
2026-09-07 18:35:50 +08:00
Aska c43b2f1d5d feat(channels): 其它适配器专属通用壳(design/17 §2)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 新增 GenericAdapterPanel.tsx: 非KOOK平台专属壳(平台标签+能力徽标+平台专属功能区诚实占位+
  主动推送能力提示),读 record.accounts[].capabilities,未实装区标「待实现」不伪造数据
- 新增 channelCapabilities.ts: 能力标签元数据+辅助(friends/user_chats/channels/guilds/
  message_test 与 Markdown/Image/Voice/Reaction/TypingHint/Proactive 中文映射;未知回退原值)
- AdapterDetailPage.tsx: platform==='kook' 渲染 KookPanel, 否则渲染 GenericAdapterPanel
- 前向兼容: 后端后续为其它平台补 capabilities 声明即点亮; 零新依赖; 移动端flex-wrap/overflow
- 验收: npm run build(tsc+vite) 99模块全绿; 文件均<500行
2026-09-07 18:28:21 +08:00
Aska 5ec4c9814d feat(kook): P2 KOOK 专属页+后端 admin 端点(design/17 §2/§4)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
后端(KOOK REST, 复用 httpclient WithAuthScheme(Bot), token不打印):
- kook/admin.go(新): ListFriends/HandleFriendRequest/DeleteFriend/BlockFriend/
  ListUserChats/CreateUserChat/ListDirectMessages
- kook/admin_guild.go(新): ListGuilds/ListGuildChannels/ListGuildUsers/ListChannelMsgs/SendTestMessage
- gateway/admin_channels_platform.go(新): kookAdmin(反射解包namedAdapter命中内嵌PlatformAdapter)
  + 4 handler + adminKookView聚合(子项失败记errors不伪造)
- server.go 注册 4 条 ownerAuth 路由: /view /friends /friend/handle /message/test
- admin.go/admin_write_channel_config.go: AdminChannelAccount 加 Capabilities
前端(KOOK 专属页):
- KookPanel.tsx(新): 好友&申请/服务器/频道/成员/私信会话/发消息测试卡片
- AdapterDetailPage.tsx: platform==='kook' 渲染 KookPanel, 否则通用
- types.ts/api.ts: KOOK 类型 + adminChannelPlatformView/Friends/FriendHandle/MessageTest
文档: Docs/api-client.md 新增4端点契约(owner-only)+capabilities+v0.17
验收: go build/vet/test ./... 全绿 + npm run build 97模块全绿; 零新依赖
注: api.ts/types.ts 集中契约文件>500行属既有结构(未新增违规); KOOK其余REST方法留能力面待用
2026-09-07 18:19:31 +08:00
Aska 8eaf81bd0d feat(engage): 群聊自主参与度引擎核心(design/18 P2a+P2b)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- persona 加 triggerWords(昔涟/Cyrene/有瑕的追忆/追忆);Loader 解析
- light.go: shouldRespond 拆分 legacy 与 engage 路径;LightInput/LightDecision 扩展
- engage.go(新): EngageConfig + IDLE/PROBING/ACTIVE/COOLDOWN 状态机(按 platform:groupID 内存) +
  概率触发(可注入rand) + 角色名提词触发 + 活动期跟随(话题转移/连续无感/窗口) + 负面态度冷避
- engagejudge.go(新): NewLLMLightJudge,一次成本受控轻LLM判 值不值得回(不判身份),
  解析失败/超时 fail-closed 静默
- channelbridge.go: Options(TriggerWords/PersonaPrompt/Engage/EngageFor/EngageRand) + 字段初始化
- wire.go: 接线 Light+TriggerWords+PersonaPrompt+Engage(默认概率0.10)
- 红线: 未触发/未命中概率绝不调LLM; 身份靠actor元数据; 敏感不落日志; 零新依赖
- 测试: engage_test.go(触发/状态机/冷避/成本受控/JUDGE输入) + persona triggerWords 用例
- 验收: go build/vet/test ./... 全绿
2026-09-07 18:16:55 +08:00
Aska 2d7a377d74 feat(channels): 适配器面板重构 P1——子侧边栏+4页+每配置专属页(design/17)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- Channels.tsx 改为 re-export 到 ./channels/ChannelsPage(入口不变)
- 新增 channels/ 模块:子侧边栏(桌面rail+移动端抽屉复用mu-drawer-in) + 子路由
  #/admin/channels(<page>|detail/<adapter_id>),刷新/返回可还原
- 4页:①适配器活动总览 ②添加/新建 ③活动适配器列表(每账号可点进专属页)
  ④适配器模块总日志(adminChannelLogOverview总览)
- 每配置一个页面(绑adapter_id, 不同配置不共用):实时日志+功能操作+身份测试
- 迁移保留原消息日志弹窗/身份测试/配置表单; 全部文件<500行
- 验收: tsc+vite build 96模块全绿
- 注: 另8个channels/*文件于5a0c1e2一并入库(该提交信息为后端修复,历史遗留混提交)
2026-09-07 18:01:11 +08:00
Aska 5a0c1e2fd1 fix(channelbridge): IM 发送过滤 action/narration + <p>拆多气泡 + 0.5-2s 间隔
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
把回复转发给 IM 适配器(sendReply)时剥掉 <action>/<narration> 动作/内心独白(Web 端
才渲染灰斜体,IM 只能收纯文本),并按 <p> 段落拆成多条气泡分别发送;多气泡发送间隔
随机 0.5~2s(仅 IM,Web 前端不延迟)。新增 sanitize.go stripNarration/splitBubbles/
bubbleGap + 测试。
2026-09-07 17:50:56 +08:00
Aska dcb0b33954 feat(identity): 主人白名单配 KOOK owner 账号(329284282)+ 各适配器占位说明
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
owner.yaml accounts 此前整段注释(默认 deny 无人认主),导致 KOOK 群聊里主人的
消息被最小打扰静默、不回复。补齐 accounts 并注明其它适配器连上后需各配各自的
主人 uid(从渠道消息日志 deviceId 尾部 / 身份测试可得)。
2026-09-07 17:34:39 +08:00
Aska 2e044d1d19 feat(channels): 管理面板消息日志——chat_messages 加 adapter_id + 配置级/平台/总览三粒度端点
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
后端:
- 迁移 0007:chat_messages 加 adapter_id 列 + 索引
- history.Message 加 AdapterID;persistToMain/persistIsolated 传适配器名(a.Name())
- channelbridge: 4 个持久化点 + registerSilenced 传 a;light.go registerSilenced 签名加 a
- Store 新增 ListAdapterLog(?adapter=, 不排除主会话)/ListAllLog(adapter_id!='', 总览);ListPlatformLog 保留
- 新端点 GET /api/v1/admin/channels/messages:?adapter= 配置级 / ?platform= 平台 / 无参=总览,historyMessage 加 adapterId
- 测试:ListAdapterLog/ListAllLog 过滤用例 + 端点粒度用例 + 桥接持久化断言

前端:渠道管理每配置(账号)「消息日志」按钮 + 页顶「适配器总览日志」入口 + ChannelLogPanel 弹窗(总览/配置级),Miuix 风格,build 绿。
2026-09-07 17:25:23 +08:00
Aska c1bd740f7c fix(kook): KOOK 机器人鉴权用 Authorization: Bot <token> 而非 Bearer
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
httpclient 硬编码 Bearer,导致 KOOK bot token 被当 OAuth2 用户令牌发,
/ gateway/index 返回 401『登录环境异常』。按 KOOK reference 鉴权规范,
给共享 httpclient 增加可配置 authScheme(默认 Bearer),KOOK 用 Bot。

- httpclient: 新增 WithAuthScheme 选项 + authScheme 字段,do() 按 scheme 组装
- kook/adapter: 装配时传 WithAuthScheme("Bot")
- 同步修正 config.go 注释与 send/identity 测试断言
2026-09-07 16:59:53 +08:00
Aska e9df928072 feat(web): 定时拉取优化——静默刷新不闪loading + AdminTable稳定排序/键控不重排
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- useAsyncData: dataRef跟踪,首次setLoading(骨架),已有data时不设loading(静默后台拉),成功setData/失败保留旧data
- AdminTable: rows 按 rowKey 升序稳定排序(useMemo,同批数据顺序稳定不跳),稳定key diff复用DOM不整页重绘,error&&!hasRows才显示错误(保留旧表)
- 受益: 所有useAsyncData/AdminTable面板页(会话/记忆/渠道/IoT/插件/人格/用户/RAG/LLM/自动化/可观测/仪表盘)
- npm run build绿(83模块)
2026-09-07 16:27:49 +08:00
Aska eaca8dc8cc fix(gateway): adminListChannels 按平台聚合——bare+kook多个adapter不再拆成多行导致重复账号
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 根因: caps循环把 kook(bare)+kook:fc2048(多账号) 分别append, 每行Accounts都是完整platform账号集→前端显示两个kook且fc2048重复(主人'只点一次创建出两个kook')
- 修: 按platform聚合(hasChannelSummary检查), 同platform只一行(Name=platform), Accounts为整个platform账号集; 保留agent-121删除同步(平台有账号才显示)
- 测试断言: kook:3fc8/conf0 → kook(聚合行) 更新
- 全仓 go build/vet/test绿; 前端无涉
2026-09-07 16:21:49 +08:00
Aska 0ed7984de3 feat: 渠道管理分两块(.env必要回退+删除同步+可用类型/已保存分开)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
agent-120 .env只留必要回退:
- .env.example KOOK移4可选业务字段(RECEIVE_MODE/WEBHOOK_PATH/VERIFY_TOKEN/ENCRYPT_KEY),只留token/base_url; channelAccountsFromEnv不读这4个; 说明业务字段走channelConfigForm.channels.json
- 黑名单/限流/receive_mode等走管理面板热更新, env仅必要连接回退

agent-121 删除渠道列表同步(根因: adminListChannels枚举caps,删除不同步caps):
- adminListChannels 以channels.json为准: 遍历caps时已删账号adapter跳过(s.channels注入时), 删除/保存后列表=channels.json, 删除即消失
- (capReg Remove后续可加,范围外标注)

agent-122 渠道管理分两块显示(主人:不割裂):
- ①可用适配器类型(顶部PLATFORM_TYPES全集grid卡片,已配置/未配置Tag,点新建配置)
- ②已保存配置(下方AdminTable过滤accounts>0,账号+bot身份+掩码token提示+连接/断开/删除)
- 拆 channelConfigForm.tsx(248行) 表单抽出; Channels.tsx 463→306行; token只回填hint不写值(保存留空不改secret)
全仓 build/vet/test绿+前端build绿(356KB)
2026-09-07 16:16:50 +08:00
Aska b614633432 fix: KOOK 连不上根因——空token裸账号不注册/占名 + open指向带token账号 + 前端token不覆盖真值
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
agent-119 后端:
- buildAccountAdapter: token平台(kook/telegram/weixin)BotToken空→跳过build(不注册不占名), channelAccountsFromEnv 无tokenenv不合成账号(杜绝裸kook id="")
- runtimeconfig cleanAccounts: LoadChannels/SaveChannels 丢弃token平台空token账号(清理channels.json现有裸kook, 下次保存抹掉)
- findChannel: 裸平台名(kook)回退到该平台第一个带token账号, open"kook"不再命中裸的missing token

我补 前端token掩码不就覆真值:
- openConfig 不再把'(已配置,仅掩码显示)'当token回填(setToken空)→save token空不写bot_token保留真值
- 加 tokenHint(显示'已配置留空不改'/重填换); 修之前channels.json kook:3fc8 token变掩码串的bug
全仓 build/vet/test绿+前端build绿(463行)
2026-09-07 16:01:47 +08:00
Aska 5398486fdf feat(web): 渠道配置表单排版+引导——核心必填(token)+高级折叠+业务策略独立区
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
agent-118 排版引导:
- 核心必填区: 高亮 Callout(各平台'只需填X'头条)+Token必填badge(红底)+自动获取旁注, 不再藏段落
- 高级选项折叠(Miuix arrow折叠): base_url/平台特定字段/额外 config(连接配置), 收起默认
- 各平台引导头条: kook'只需填bot_token其余自动获取'/telegram/qq(mode接入方式)/weixin/webhook

我补(主人原则: 连接配置 vs 业务策略分开):
- 黑白名单(allow/deny groups/users)+限流(groupMsgPerMin/msgPerMin/maxMessages) 从'高级选项'抽离成独立'业务策略'折叠区(不参与端到端连接)
- openConfig 重置 polOpen; 业务策略默认收起
- 拆 channelFields.ts(PlatformField/PLATFORM_FIELDS/PLATFORM_GUIDE) → Channels.tsx 454行<500红线合规

前端 build 绿; 敏感字段password掩码/不回显
2026-09-07 15:45:04 +08:00
Aska 65091f6497 feat: KOOK自动获取bot身份 + action/narration剥出气泡 + 渠道配置表单回填
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
agent-116 KOOK token即用+自动获取:
- kook/identity.go: Connect 调 GET /user/me 拿 bot 身份(bot_id/bot_username/bot_nickname), 失败容错
- 渠道列表 enrichAccountsWithBot 附 bot 身份; base_url空用默认/receive_mode默认ws(只填token可跑)

agent-117 action/narration 剥出气泡(主人需求: 动作/旁白不进气泡,按位置上/下):
- parseContent.splitBubble(content)->{body,head,tail}: body=纯文本(去标签), head=开头/中间action/narr(气泡上), tail=结尾(气泡下)
- renderContent 只渲染text(去内联灰斜体); chat.tsx BubbleNotes 泡外 *动作/旁白* 灰斜体小字(气泡外依附), SplitChatBubble 先剥splitBubble再splitParagraphs(body), <p>/\n\n/毛玻璃/SourceLabel不破坏
- 注: 非chat mode renderContent 现在丢弃action/narr(可接受)

Channels 配置表单回填(修save后输入框空):
- openConfig 从 data.accounts[0].config 回填 token(掩码提示)/base_url/平台字段/黑白名单/限流, 不再硬清空
全仓 build/vet/test绿+前端build绿
2026-09-07 15:31:14 +08:00
Aska bdd9d752e1 fix(web): chat 气泡按段落拆分——兼容 no<p> 的 \n\n 双换行多段, 不再挤一个气泡
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 她(LLM)常产出 \n\n(双换行)分隔多段 而非 design 约定的 <p> 标签(看最近消息原文 <p>数0, 全\n\n)
- splitParagraphs: 优先按 <p> 提取; 无完整<p>时按 /\n\s*\n/ 拆段(保留action/narration块, 不拆单\n句内换行)
- 修: 之前只认<p>, 她\n\n多段被塞进一个气泡(whitespace-pre-wrap 显示双换行挤一个气泡), 主人反馈几个双换行挤一个气泡
- npm run build 绿
2026-09-07 15:21:52 +08:00
Aska a054a55ce9 fix(web): 气泡毛玻璃修正——去掉纯色bg覆盖,双气泡都半透明透背景,边线弱化
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 根因: 气泡 className 里 bg-[var(--mu-surface-3)]/bg-[var(--mu-accent)]纯色 覆盖了 .liquid-glass-* 的半透明background → assistant 气泡不透背景(主人看到她的没毛玻璃), border 太亮
- 修: 气泡容器去掉纯色 bg(交给液态玻璃类) → assistant(soft)/用户(accent) 都半透明+backdrop-blur 透背景
- 边线弱化: rim 0.06→0.03/0.04 白, 内高光 0.14→0.07, 去掉 className 亮 border(deep色 theme 尤其明显)
- 目的: 双气泡毛玻璃一致透背景, 边框细腻不刺眼; 文字色不变
- 前端 build 绿
2026-09-07 15:12:53 +08:00
Aska 4b05f219b9 fix(web): 聊天气泡改半透明毛玻璃——气泡能隐约看到底层(chatBackground)背景
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- .liquid-glass-soft(她)/.liquid-glass-accent(你) 从'94%/96%近不透明+1.5px微blur'改为'68%/76%半透明+8px blur+saturate'
- 目的: 气泡半透明+backdrop-blur, 让底层聊天背景透过气泡形成毛玻璃(隐约见背景), 而非纯平面色
- 保留内高光/折射描边(liquid-glass 精髓); 文字色不变(assistant surface3白字/用户 accent 白字), blur 8px 柔化透出背景不糊文字
- 前提: ChatShell 背景层(absolute inset-0)衬在气泡底层, backdrop-filter 生效
- 前端 build 绿
2026-09-07 15:10:29 +08:00
Aska 6489910f64 feat(web): 聊天气泡加轻量 Miuix 液态玻璃(不动底色/文字,不糊观感)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- index.css 加 .liquid-glass-soft(assistant: surface-3 底+内高光+折射描边+极轻blur) + .liquid-glass-accent(用户: accent 主色+内高光+折射描边,不加blur保留白字可读)
- chat.tsx: ChatBody(她)/UserParaBubble+UserBubble(你) 气泡容器加对应轻玻璃类
- 设计原则: 只叠'内高光+折射'精髓(inset rim), 不用厚重blur/shadow, 保文字观感; 与现有 .liquid-glass(重) 区分
- 修: 之前气泡纯 surface/accent 平面; 前端 build 绿
注: 工作树中 kook identity.go/admin.go 是 agent-116(KOOK自动获取)进行中, 未触碰
2026-09-07 15:09:48 +08:00
Aska aeb9fea64c fix(web): 来源小字按消息角色区分语义——用户'来自',她'发送至'
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- SourceLabel 加 role: user 消息显示'来自 <deviceId>'(她收到,来自主人端), assistant 消息显示'发送至 <deviceId>'(她回复到的端)
- 修复: 之前她发的消息也标'来自 web:chrome:...' 语义错误,不是她来自用户设备
- 4 处调用点传 message.role; 前端 build 绿
2026-09-07 15:07:06 +08:00
Aska 1b0b5bf99c fix(web): 项目图标统一用昔涟头像——侧边栏/管理面板顶栏 logo 从旧占位 /icon.svg 改为昔涟
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- Nav.tsx(用户端侧边栏)/AdminApp.tsx(管理面板顶栏) 的 <img src="/icon.svg"> 占位 → 昔涟头像(Cyrene-2F-N-Happy-1-192x192.png, 与 favicon/项目图标一致)
- 与角色视觉分离红线: 项目图标/favicon 用昔涟(全局), 聊天头像走 personaAvatar(按角色取, 绝不 fallback 昔涟)
- 修复: 之前定了项目图标用昔涟, 但侧边栏/顶栏仍硬编码旧占位 /icon.svg
- npm run build 绿
2026-09-07 15:04:43 +08:00
Aska f5ae953ddc fix(web): 渠道配置表单按平台动态渲染字段——不再混显其他适配器字段
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 建 PLATFORM_FIELDS 平台→字段映射(key 严格用各 adapter Config json tag,webhook 实为 camelCase 已校正)
- 表单按 cfgPlatform 只渲染该平台特有字段(kook: receive_mode/webhook_path/verify_token/encrypt_key + 通用; qq: mode/listen_addr/remote_url/access_token/self_id + 通用; telegram/weixin 各自) + 通用(账号id/Token(密码掩码)/baseURL) + 黑白名单/限流 + extraConfig
- 删无条件渲染的 mode/remoteURL/listenAddr(原 bug); 保存按 cfgPlatform 组装 config 含平台特有字段; 敏感字段 type=password 掩码
- fix: 点开 kook 配置不再出现 qq 的 mode/remoteURL/listenAddr 等
- npm run build 绿(81模块)
2026-09-07 14:48:13 +08:00
Aska 617157262b feat(channel): KOOK Webhook(WH) 接收模式——与 WS 配置切换(ReceiveMode)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- kook.Config 加 ReceiveMode("ws"默认/"webhook") + WebhookPath(默认/webhook/kook) + WebhookVerifyToken + WebhookEncryptKey(AES-256-CBC)
- 按权威文档(Reference/kaiheila webhook.md)实现: 无X-Signature HMAC, 真实校验=d.verify_token比对(不匹配403)+可选AES解密+zlib解压+challenge回显
- webhook.go: InboundHandler+WebhookPath, POST→zlib解压→AES解密→verify_token校验→challenge回显→归一UnifiedMessage→SetMessageHandler; WS模式Connect不拨网关仅标记connected
- channelbridge/webhookAdapter 接口改用 WebhookPath(); WebhookHandlers 收KOOK WH(ws模式跳过); 既有webhook通道补WebhookPath照常挂载
- namedAdapter 转发InboundHandler/WebhookPath; kook env兜底 +4个CHANNEL_KOOK_*键
- 测试: verify_token 403/challenge/zlib/AES往返/WebhookPath按模式/WH不拨gateway/WebhookHandlers收KOOK; 全仓build/vet/test绿
- 待真token端到端实测(需主人提供CHANNEL_KOOK_TOKEN+开发者后台切WebHook配回调URL)
2026-09-07 14:10:03 +08:00
Aska 1ebc82fffd feat: JWT过期自动重置 + 用户端登录页 + 系统设置前三个真实实现
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
agent-111 JWT过期重置+系统设置去占位:
- ws.ts 识别401/过期 markAuthExpired(清token+停重连+expired态),isTokenExpired;wsParse.ts拆出压行371;scheduleReconnect有token未过期才重连
- api.ts REST 401→auth_expired+清token+引导重登;login走skipAuthExpired密码错不误清
- types.ts WsStatus加expired;TopBar/Nav expired显示登录过期;System.tsx去alert占位改4张事实卡+合并Notice

agent-113 用户端登录页:
- components/Login.tsx Miuix登录卡(用户名/密码→login()→setAuthToken);App !getAuthToken()→<Login>;AdminApp LoginGate指向

agent-112 系统设置前三个(owner+审计):
- auth.APIKey.RevokeAll(吊销全部key);auth.JWTService.RotateSecret(≥32校验+并发安全+新密钥不回显)
- gateway admin_system.go: POST revoke-keys/rotate-jwt/backup(VACUUM INTO)/restore(防路径穿越+恢复前自动备份+原子替换+需重启)
- wire.go 传DatabasePath;全仓 build/vet/test绿+交叉编译;api-client v0.17系统设置契约

实机验证: 登录(role=owner)+revoke-keys 200+渠道列表含kook; 全仓绿+前端build绿; .env密码仅登录验证不打印
2026-09-07 13:57:36 +08:00
Aska 7e39153a69 feat: 修复审计落差 #1/#2/#3/#6 + 管理面板移动端适配
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
#1 工具布尔标记生效(agent-106, 安全红线): loop toolSchemas/runToolCalls 加 allow-set 安全门 restrictSchemas/applyToolSecurityGate——群聊/陌生人只暴露允许工具(默认关), 被拒not_allowed软提示+AuditEvent, owner全工具不变; 身份靠 ActorEnv(非提示词)
#2 checkpoint 恢复(agent-109): run.go LoadLatest + resumableStopReason门控(只恢复in-flight工具循环,不出答案不续跑), 幂等不重放副作用, 新会话不误恢复; resume_test 4用例
#3 tool_result(agent-108, 协议层): Anthropic 原生tool_result块(tool_use_id/is_error)非user纯文本 + OpenAI tool消息补tool_call_id; Content加ToolCallID/IsError等; (loop接线层2处留待,见备注)
#6 capacity_awaken推送(agent-107): onCapacity从只Debug改推WS hub(BroadcastCapacityAwaken, sessionId定向+payload对齐前端AwakenPayload)
管理面板移动端(agent-110): AdminApp 汉堡菜单+抽屉侧栏+标题truncate防换行, RBAC守卫不受影响
全仓 build/vet/test绿 + 前端build绿; 前端无涉差异已对齐

备注: #3 loop接线层2处(assistant工具轮append历史 + Message.ToolCallID/IsError)留给后续; #4代审/#5子代理触发/#7拆文件后置(主人说后三不急)
2026-09-07 13:43:18 +08:00
Aska aedae2190b feat(config): 运行时配置体系②——渠道配置落 channels.json + 面板落盘 + 热更新
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- runtimeconfig/channels.go: channels.json 承载 map[platform][]ChannelAccount(token/baseURL/黑白名单/群聊限流), LoadChannels 优先文件/回退env, SaveChannels(0600+tmp原子写), Upsert/Delete/Masked脱敏
- channelbridge/reload.go: adapterManager 热替换——工厂重建新adapter先连接, 再原子断旧adapter(独立readCtx/进行中不中断); Bridge.Reload(platform) 逐账号增删
- 面板 PUT/DELETE /admin/channels/{platform}/config(owner+审计) 落盘channels.json+Reload热更新; GET /admin/channels 附账号掩码不泄裸token
- 前端 Channels.tsx 配置表单(id/token/baseURL/黑白名单/限流+config JSON)+保存热更新提示+掩码展示/删除
- .gitignore 加 channels.json; .env 退兜底(.env.example注明); token不打印/Config()脱敏
- channelbridge.go 拆 logic→reload.go(742→731); 全仓 go build/vet/test绿+前端build绿
- 未做真token端到端(需渠道凭据,不读.env)——如实标注待凭据
2026-09-07 13:29:41 +08:00
Aska 2d7151f979 feat(config): 运行时配置体系①——LLM 多厂商独立组落 models.json + 面板落盘 + 热更新
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- runtimeconfig/models.go: models.json 承载 [{id,provider,baseUrl,apiKey,model,fallback?,multimodal?}] 多厂商独立组(首项=生效)
- LoadModels 优先文件/回退env(GroupFromEnv), SaveModels(0600+tmp原子写), Activate/Upsert/MaskedKey脱敏
- 面板 POST /admin/llm 落盘 models.json(取代temporary内存) + GET 列组(脱敏)
- runtime/model/runtime.go: RuntimeLLM(adapter+router) atomic快照可热替换, Reload 重建不中断进行中请求; per-role路由用接口
- wire 用 NewRuntimeLLM(adapter+router), 保留 a.modelAdapter(=runtime stack, 审查模型随热更新)
- .env 退兜底(.env.example注明); apiKey不打印/Config()脱敏/热更新不中断
- 全仓 go build/vet/test 绿(54包)+交叉编译+前端build绿(79模块); api-client v0.15已同步
- 遗留: vision ModelInfo 仍读静态env(后续纳入); 保存组=生效组(第一版语义)
2026-09-07 13:07:58 +08:00
Aska 8cde0162e6 feat: 公共平台最小化打扰(design/12 §6)——5条策略: 分级+轻思考+限流+审查剔噪
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
降噪A(策略一/二/五):
- channelbridge/light.go: 非owner分级门——小事件当系统消息登记(不进loop)、非owner轻响应(她判断回/不回,规则优先mention/关键词+轻LLM兜底fail-closed静默)
- backthink 触发降噪: SubjectSystemEvent 低价值不唤醒thinking(仅高价值/owner才think), iot/reminder不受影响
- quiet hours: 安静时段非owner无信号静默,被@才回(非硬性静默); config QUIET_START/END_HOUR 单源共用

限流B(策略三/四):
- adapter/flowgate.go: per-adapter 滑窗限流器+总上限+审查剔噪闸门(Admit两连:速率+上限/审查), owner私聊绕过
- channelbridge/flowgate.go: onMessage入站最前挂闸, 超限/被剔不占worker槽; 审查模型NewLLMReviewer复用model.Adapter(仅>80%阈值触发+跨分钟节流,失败回退FIFO,绝不判身份)
- config: ChannelAccount 加 GroupMsgPerMin(默认6)/MsgPerMin/MaxMessages(默认50), CHANNELS_JSON每账号, 向后兼容
- 被剔消息走OnRejected审计不静默丢

main wire接线: 加modelAdapter字段+Review注入(reviewFor nil-safe,无LLM回退FIFO)
全仓 go build/vet/test 绿(53包)+交叉编译, 零新依赖, 前端无涉
2026-09-07 12:18:21 +08:00
Aska 764d592859 feat(channel): KOOK 渠道适配器 + 框架兼容增强(wsutil/httpclient)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- plugins/channels/kook: config/adapter/gateway/websocket/send/message/errors — 实现 PlatformAdapter 13方法
  - WS 有状态网关: 取gateway地址→连WS→信令0/1/2/3/4/6 + 30s心跳/6s超时 + resume(拼接sn/session_id) + 指数退避重连
  - 收事件: 信令0 → UnifiedMessage(word注 channel_type/type/target_id/extra.author→字段)
  - 发消息: /message/create(频道) + /direct-message/create(私聊,修正正确端点,非user-chat) + Bearer鉴权
  - 注册 channels.go + CHANNELS_JSON 多账号 + channelbridge.configured 加 kook(bot_token非空才连)
- 框架增强: internal/channel/adapter/wsutil(连接管理/心跳/重连/resume骨架,复用gorilla,KOOK用上) + httpclient(Bearer+envelope归一,KOOK用)
- 全仓 go build/vet/test 绿, 零新依赖(复用餐gorilla), 未动telegram/qq/weixin/webhook契约
- §4.6 隔离/黑白名单/第三方边界未破坏

注: KOOK WS/WH 双模式待补(ReceiveMode), 实测需主人提供 CHANNEL_KOOK_TOKEN
2026-09-07 12:01:35 +08:00
Aska eab6b63eb0 docs(api): 扩充客户端接入指引 §4——三步登录/会话消息用法/deviceId·platform·senderName规范/错误码/离线缓存,便于跨平台客户端开发
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-09-07 05:16:37 +08:00
Aska 1a6ea45b9e docs: 强化改动前后推仓库纪律(用户红线,留恢复误操作余地)——每个模块/功能改动前后都 push,绝不攒到阶段末
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-09-07 05:15:54 +08:00
Aska 9c2a37c4ac feat: 管理面板补齐(RAG导入/记忆添加/会话接history/诚实表述) + design/16 §4.6 IM多会话隔离+第三方身份+黑白名单
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
agent-98 面板批次:
- 会话管理面板改接 history.ListSessions(主web+次级) + 新建次级会话按钮
- 记忆面板手动添加入口 POST /admin/memories(owner)
- 知识库真正实现 IndexDocument(分块+落projectindex) + POST /admin/rag 导入端点
- 三面板空态诚实表述(去除'后端M10+提供')
- RAG嵌入降级到keyword/line(无直属embedder,不伪造语义,符合红线)

agent-99 design/16 §4.6:
- model.Message.SenderName + 迁移0006 sender_name 列
- 第三方身份边界: session.ResolveActor判定(非LLM猜) + [第三方·昵称]前缀(不改源content)
- 群聊/陌生人落独立sess-xxx(persistIsolated),不写主会话,不广播
- 黑/白名单(allowGroups/denyGroups/allowUsers/denyUsers)最前拦截,不进LLM
- 多账号 CHANNELS_JSON 配置 + 每平台多实例
- 管理只读 GET /admin/channels/{platform}/messages(该平台非主人消息流)

实机验证(JWT): 会话管理返回主会话web / 记忆添加成功 / RAG导入documents非空+Cyrene命中; 后端build/vet/test全绿 + 前端build绿
2026-09-07 05:13:36 +08:00
Aska 37e98ec584 feat: IM平台列 + deviceID标识 + 会话持久化完善
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- agent-96: chat_messages 加 platform 列(迁移0004) + IM主人私聊落主会话web(带platform) + 前端按平台过滤契约 + message_persisted 带 platform(复用 BroadcastPersisted)
- agent-97: chat_messages 加 device_id 列(迁移0005) + history.Message.DeviceID + Web发消息带deviceId(localStorage getClientId) + IM persistToMain 带 deviceID + 前端气泡展示 SourceLabel + 修主会话基础2个bug(fn scanMessage语法/DDL缺列)
- design/16 §4.4(平台) + §4.5(deviceID) 已入档
- 实机: POST /chat 带 deviceId 读回 user/assistant 均带 deviceId+platform; 后端 build/vet/test 绿 + 前端 build 绿
- 红线: 身份靠 actor 元数据; 禁止 shell 读 .env(JWT冒烟由主会话跑)
2026-09-07 04:51:41 +08:00
Aska 1bf60c1ae4 feat: IM式跨端实时同步——message_persisted 广播 + 前端按id去重追加
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- history.Store 新增 AppendFull(返回含 id/createdAt 的完整消息),Append 签名不变
- session_orchestrator 加可空 OnPersisted;persistUserMessages/persistAssistantReply 落库后回调广播
- server.go newOrchestrator 接 OnPersisted → hub.broadcast message_persisted(thinking 剥除)
- 前端 ws.ts 加 message_persisted case(仅 activeSessionId 匹配才处理);appendOrUpdateMessage 升级为 id 精确upsert+内容/时间归一
- 实测:双 WS,A stream 发消息,B 免刷新收到 user+assistant 各1条(count=2 PASS)
- 红线: 身份仍靠 actor 元数据,不靠提示词;用户/助手消息、本端/异端统一路径
2026-09-07 04:16:29 +08:00
Aska bcc53d68f3 feat(web): 前端接会话持久化——拉历史热分页 + 侧栏会话列表
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- store: 消息真源移到后端(sessions元数据map + activeSessionId,partialize 移除 messages 改存会话元数据,version:2 migrate 清旧数据)
- api: fetchSessions/createSubSession/fetchMessages(热分页before游标)/deleteSession + MessageDto→toChatMessage
- Nav: 侧栏「会话」区——主会话置顶 + 次级列表 + +新建会话(Miuix)
- 新增 HistoryList: 顶部「加载更早」向上分页 + 保守虚拟化(>200只渲染最近200)
- useChat: send 用 activeSessionId;App: token 后 fetchSessions→激活主会话→拉近50条
- Docs/api-client: v0.10 补 WS sessionId + history 回放事件契约
2026-09-07 03:52:50 +08:00
Aska 48421923f6 feat(history): 会话消息持久化 + 主/次级会话(design/16)
- runtime/history: Store + HistoryStore 接口(Append/List 游标分页/Recent/DeleteSession/NewSubSession/ListSessions),主会话固定 web、次级 web:<uuid>
- db 迁移 0003: chat_sessions + chat_messages + idx_chat_msgs_session_created(事务幂等)
- gateway: GET/POST /sessions、GET /sessions/{id}/messages 热分页、DELETE /sessions/{id}(主不可删 403);Deps.History 可空注入
- 编排层落库: session_persist.go(submitInternal 写用户消息、completeTurn 写助手消息,拒绝的消息不落库)
- WS: wsEvent 加 sessionId 定向 + history 回放帧(握手上行后回放主会话最新 50 条,thinking 剥离)
- wire.go 装配 History 到 gateway.Deps.History
- 端到端实测: POST /sessions 建次级 201 / GET /sessions 列表 / POST /chat 后 /messages 读到 user+assistant / DELETE 次级成功 / DELETE 主会话 403
2026-09-07 03:52:43 +08:00
Aska cc3eecacac feat(web): 移动端适配——顶栏防换行 + 抽屉式侧边栏
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- TopBar: 标题区 min-w-0+truncate 防换行;移动端(sm 下)隐藏心情/WS文字tag、管理/主题按钮shrink-0;新增汉堡按钮(md:hidden)
- Nav: 抽共享 NavContent;桌面 Sidebar(dd:flex) 保持;移动端新增 MobileNav 抽屉(遮罩+弹簧滑入 md:hidden)
- App: 加 menuOpen state,渲染 MobileNav + TopBar 传 onMenu
- Icon: 新增 menu 图标;index.css: 加 mu-drawer-in 滑入动画
- npm run build 绿(77模块);后端未动
2026-09-07 03:13:24 +08:00
Aska c42aae7797 fix(web): currentPersona 稳定加载——不再因 mount 时无 token/一次失败就退回默认头像
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- useAppStore.partialize 持久化 currentPersona:刷新后保持本角色头像/背景
- App.tsx:token 出现即重拉 + 失败 3s 重试,替代一次性 mount 拉取
- 红线:仍只显示当前角色自己的头像/背景,绝不 fallback 其它角色
2026-09-07 03:03:48 +08:00
Aska ba8ff835cd feat(admin): Agent监控/后台思考接WS实时 + 真流程图
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 新增 FlowGraph.tsx: SVG 自绘 DAG(分层布局/bezier边/箭头/按status着色,零新增依赖)
- Agents.tsx: 上下文查询接真后端(task_output_query→task_output);终止/重派诚实标需后端M10+,绝不伪造
- Backthink.tsx: 链路导图接 FlowGraphView;干预诚实标需后端M10+
- 控制项一律 useAuditStore.log 记 ok/error,未接入不伪造成功
2026-09-07 02:57:41 +08:00
Aska 4ad1e465da feat(web): 聊天背景渲染 + 思维链/工具卡富化
- persona.ts: 新增 personaChatBackground(p, orientation),只取本角色对应方向的图
- ChatShell.tsx: 按横/竖屏渲染角色聊天背景(衬底+scrim),null/无图回退纯 Miuix 面
- chat.tsx: 思维链块加图标 chip+spinner+收起预览;工具卡加状态色 chip+ok/fail Tag
- 红线: 绝不 fallback 昔涟/其它角色;TemplateCard detail 折叠判定等行为不变
2026-09-07 02:57:37 +08:00
Aska 7455d09d05 feat: 角色视觉体系(头像按角色取)+ 时间注入(loop 每轮当前时间)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
【角色视觉体系(红线:每个角色自己的头像/背景,绝不串用昔涟)】
- persona.go:Persona 加 Visual{avatar, chatBackground{landscape,portrait}} + DefaultAvatar;每个角色各自配。
- cyrene.yaml:昔涟配 avatar(3F-Q-Happy-1) + chatBackground(横/竖屏)。
- PersonaStore 加 Persona(id);PersonaSummary 加 avatar/chatBackground;collectPersonas 按角色取
  Persona(id) 视觉(无视觉→空,前端回退默认占位,绝不串角色)。
- 前端:types PersonaRecord 加 avatar/chatBackground;persona.ts personaAvatar(p)=p?.avatar||默认占位
  (殊无昔涟 fallback);store 加 currentPersona;App 挂载 fetchPersonas 取默认角色;
  TopBar/chat 头像改 personaAvatar(currentPersona)(写死昔涟头像已清除)。

【时间注入(她自然知道现在几点,深夜催睡交给人格/capacity)】
- options.go Options 加 Timezone;loop.go Loop 加 timeLocation/timeZoneName + New 解析 + loadTimezone;
  time.go currentTimeBlock()(当前时间+周几+时区,每轮刷新);memory.go assembleContext 在「工作记忆」
  前注入时间 system(keyed 在 timeLocation!=nil,无时区不注入不破坏测试);wire.go Timezone=a.cfg.Timezone。

go build/vet/test 全绿(gateway/loop/persona);前端 tsc/vite build 75模块绿。红线无写死昔涟头像。
2026-09-07 02:46:40 +08:00
Aska 950bea9758 feat(web): v1 图标头像迁移 + 仅 chat 按 <p> 拆气泡 + 一键控制脚本 + <p> 分段约定
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
【v1 图标/头像迁移】
- 复制 v1 前端资源到 v2:Cyrene_Avatar(2ndForm 192x192 + 3rdForm 3F-Q-Happy-1/2)、User_Avatar。
- favicon/apple-touch-icon → Cyrene-2F-N-Happy-1-192x192.png;TopBar 品牌图标 + 聊天助手头像 →
  Cyrene-3F-Q-Happy-1.png(圆形头像)。icon.svg 保留作 MD3 组件引用不变。

【仅 chat 按 <p> 拆气泡(主人约束:仅 chat 生效,其他 mode 不解析)】
- cyrene.yaml 聊天节奏:想分多条用 <p>第一条</p><p>第二条</p>(每个=<p> 一条独立消息),
  \n 是句内换行;每段尽量短。
- chat.tsx:SplitChatBubble 仅 mode===chat 时拆 <p>;非 chat → MessageBubble 原样(不解析)。
  <p> 是拆气泡信号(先拆段),段内 renderContent 把 <action>/<narration> 变灰斜体,两层分层。
  首段完整(头像/思维链/工具卡)、后续段精简(仅正文+灰斜体);user 同理;流式未闭合<p>净化不闪标签。
- ChatShell.tsx:MessageBubble → SplitChatBubble。

【scripts/ctrl.sh 一键控制】
- 无参=start;支持 start/stop/restart/status;setsid 脱离防误清、PID 文件+端口探测、health 确认。

go build 全绿(后端未动);前端 tsc/vite build 75模块绿。
2026-09-07 02:23:17 +08:00
Aska 33eeadfc38 fix(backthink): 后台思考静默广播——不再把内部推理泄到聊天思维链 + XML 动作/旁白解析
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
【静默广播修复(bug:思维链露出 backthink 推理)】
- 根因:LoopThinker.Think 复用同一 loop.Loop 且 loop 全局带 OnStream,backthink 后台思考时
  把 [background autonomous reflection] 的 thinking/流式事件广播到前端,被当聊天思维链显示。
- loop/mute_ctx.go:WithMutedBroadcast(ctx) + isMutedBroadcast(ctx)——context 上静默标志。
- loop 四处广播抑制(stream.go 流式 / step.go 静默走非流式 agentStep / request.go capacity 事件 /
  tools.go tool_execution),静默时跳过 UI 广播,推理照常返回结论。
- thinker.go LoopThinker.Think 用 loop.WithMutedBroadcast(ctx),backthink 不再泄 thinking。

【XML 动作/旁白结构化解析】
- cyrene.yaml systemPrompt 加「表达方式:动作与旁白」——动作 <action>…</action>、
  旁白 <narration>…</narration>;克制≤1-2个/段、贴合唤醒态、重要信息不加、朗读正文忽略标签。
- 前端 parseContent.ts:ContentPart[](text/action/narration),非贪婪+反向引用、跨行、非法/
  未闭合兜底纯文本不崩;summarizeContent 供思维导图/状态。
- 前端 renderContent.tsx:action/narration → 灰斜体(mu-text-lo italic)句中原位渲染。
- 应用到 chat.tsx UserBubble/AssistantBubble + messages 各 mode body。

go build/vet/test(loop/backthink)绿 + 前端 tsc/vite 绿。
2026-09-06 22:09:51 +08:00
Aska 6dbb4a2e18 feat: 唤醒式架构批次——capacity/L4/会话编排/管理面板写CRUD/前端集成(77-81)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
【capacity 唤醒式核心(77)】internal/runtime/capacity:(Decider L1启发式+L2上下文+人格覆盖→三态静息/唤醒/全能→允许工具集+capacity_awaken事件)。loop 外围允许集过滤(不硬阻断=能力≠边界红线)。config 加 Capacity(Enabled/MinConfidence/ToolSets)。wire buildCapacity 接入 loop。

【L4 记忆驱动意图(80)】recall.go:从记忆召回用户习惯(搞一下xx=办事)→唤醒L1/L2漏掉/升置信;群聊陌生人不读owner习惯(隔离红线);RecordIntent闭环(判定写回记忆越用越准);默认保守静息。

【会话消息编排(78)】gateway/session_orchestrator.go:每会话(模式/pending有界5/tail上下文尾巴20);Submit非阻塞(idle立即/busy入队ack/满拒绝);完成回调按序续接(tail+新消息合并增量注入非全量重放);Interrupt中断;REST 202/409+WS session_busy/pending_ack事件;不重造任务引擎复用loop。

【前端集成(79)】ws.ts 加 capacity_awaken/session_busy/pending_ack 解析;store 加 awakenState/sessionBusy;TopBar/ChatShell 加唤醒态徽标+忙态提示(不阻塞输入框);状态组件 status.tsx;Miuix风格。

【管理面板写CRUD(81)】20个写端点(POST/PUT/DELETE /admin/*,owner+审计落库):personas(真持久化YAML)/users apikey(签发掩码+撤销)/iot控制(真下发)/automations(建/触发/删)/llm(test真调用+临时)/channels(connect/close/身份测试actor白名单)/plugins(卸载)/memories(search真检索)/sessions(export)。无存储如实501不伪造。拆4文件满足≤500红线。前端9模块接真写。

【wire收口】buildCapacity接入loop+OnCapacity事件;server.go newOrchestrator接hub。

实机验证:iot ac.living control 200 真实改变 on:true;observability/metrics 200、health ok;全仓 build/vet/test 47包绿+前端tsc/vite。
2026-09-06 21:54:40 +08:00
Aska e0a543cc37 feat(observability): 管理面板可观测性接真实数据——metrics/trace/audit 三端点 + 审计持久化
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- observability.go/observability_metrics.go/observability_trace.go/observability_audit.go:
  拆分保证 ≤500行/函数≤80。metrics 并发安全 MetricsCollector(方法/路径/状态计数、平均/最大延迟、
  近1s QPS窗口);trace 并发安全 RingBuffer(默认512, newest-first)+traceSink+MultiTraceWriter;
  audit AuditStore 接口+内存回退+SQLite 持久化(NewSQLiteAuditStore 参数化 SQL+audit_logs 表)。
- server.go:Deps.AuditStore + Server.obs + 3 路由 GET /admin/observability/{metrics,trace,audit};
  ObserveSpan/TraceSink 注入点;middleware logging recordMetric;audit 落库(仅 slog→Append)。
- 前端 Obs.tsx:metrics 接 4 张真实 StatCard+请求分布、trace 接 span 列表(展开 props/events)、
  audit 从后端拉(前端内存审计作回退);api.ts/types.ts 加 obs 端点/类型(AdminObservabilityMetrics/
  AdminTraceSpan/AuditRecord.id 放宽)。成本指标不伪造(保留'—'+注明)。
- wire.go 收口:Deps.AuditStore 注入 gateway.NewSQLiteAuditStore(ctx, a.db)(审计真落库),
  未注入回退内存。
- Docs/api-client.md §2/v0.6。

实机验证:三端点 200,metrics 返回 totalRequests:4/qps:4/perPath/statusCounts:{200:4}。
go build/vet/test 全绿 + 前端 tsc。trace 数据源对接(gw.TraceSink→loop tracer)因 gw/tracer
构造顺序留作可选后置(端点已就绪, 未对接 span 空)。
2026-09-06 21:02:46 +08:00
Aska 76134ed47e feat(gateway): webhook 渠道 InboundHandler 挂载到网关(渠道遗留2)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
让 webhook 渠道的入站 HTTP 回调真正进运行时(此前 handler 建了但未挂载,回调不达 runtime)。

- channelbridge:Bridge.WebhookHandlers() map[string]http.Handler——遍历适配器,对暴露
  InboundHandler()/Config() 的(*webhook.Adapter)收集 endpointPath→handler。
- gateway/server.go:Deps 加 Webhooks map[string]http.Handler;Server 存 webhooks;
  Handler() 挂载到 mux(无网关鉴权,各 handler 自带 HMAC 签名校验)。
- gateway/static.go:spa 加 isWebhookRoute——已注册的 webhook 路径交给 API mux(否则被 SPA
  fallback 吞成 index.html,webhook 回调不达)。
- wire.go:gateway.Deps 传 Webhooks: a.channelBridge.WebhookHandlers()(wireChannels 在
  wireFrontend 前执行,bridge 已建好)。

实机验证:POST /webhook 空体→bad_request(400,webhook 解码响应非 SPA)、带 JSON→accepted(200)、
GET /webhook 被 webhook 路由处理。go build/vet/test 全绿。
2026-09-06 20:40:49 +08:00
Aska adad4e1f62 fix(session): 群聊/陌生人记忆隔离——MemWritable 贯穿 loop(AGENTS.md 红线)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
群聊/陌生人查记忆只读不写、不泄露主人敏感;owner 默认行为完全不变。

- loop/sessionenv.go:SessionEnv 类型 + WithSessionEnv/sessionEnvFrom/OwnerEnv——每次 Run 携带会话
  隔离上下文,经 context.Context 传递(不改 Run 签名、不存 Loop/Options 字段,避免 16 并发渠道
  消息共享 Loop 的数据竞争;Chatter 接口与测试桩签名不变)。
- loop/memory.go 门控:injectMemory 读——MemWritable=true(owner)走原 Retriever.Inject 全量不变;
  false(群聊/陌生人)改 Retrieve 后只留 ScopeProject(人人可读)、剔除 ScopeUser(owner习惯私有)与
  ScopePersona(角色日常=owner敏感),块首加「群聊隔离」标记,无项目记忆不注入;extractMemory 写
  false 直接 return 跳过(不调用 extractor 不落库)。
- 渠道入站(Channelbridge)传真实会话 MemWritable/Env;Web/REST/WS/voice chat 绑 OwnerEnv(JWT=owner
  行为不变);backthink/automations/orchestration 默认 owner 全量。
- 测试:memory_gating_test 6 用例(owner/群聊只读不写/陌生人受限/无项目记忆不注入/默认owner全量)全绿;
  loop/session/gateway 既有测试无回归。go build/vet/test 47 包全绿。

遗留:ScopeProject 过滤在 loop 内后处理实现(未改 memory 包),若后续允许可在 Retriever 加 scope
过滤选项消除与 Inject 模板的重复(2处)。
2026-09-06 20:34:06 +08:00
Aska dbcf3df8a3 feat: RAG 知识库真实数据 + 渠道端到端接线(并行∦wire.go)+ 主会话收口
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
【渠道端到端·接线(agent-72)】
- 新建 internal/services/channelbridge/:桥接服务(Start 对配置渠道 Connect(10s超时/warn)+SetMessageHandler+ReadMessages;
  入站 UnifiedMessage→sessions.GetOrCreate(ResolveActor 白名单结构性识别)→loop.Run→SendMessage 回执,chat 文本)。
- cmd/cyrene/channels.go:从 CHANNEL_* env 读 4 渠道凭据(telegram/qq_obv11/weixin_ilink/webhook)。
- wire.go:app 加 channelBridge 字段,wireChannels 构造适配器+bridge.Start(),close() 收尾。
- 无真凭据降级:无 CHANNEL_* → configured()false 跳过不 dial;Connect 失败 Warn 不阻启动;
  webhook 仅入站(回执 ErrUnsupported 记 debug)。身份识别绝不靠提示词。收发端到端待凭据验证。

【RAG 知识库(agent-71)】
- internal/runtime/knowledge/:真实数据源(doc=资料/记忆=经历 分离 design/03 §3③)。
  ListDocuments 走 projectindex.ListProjects(+ListChapters)、Search 走 projectindex.Search(标题/描述+章节
  命中行+上下文)+retriever.Retrieve(配 vector 真语义,命中标 semantic:true)。
- gateway/server.go:RagStore 接口+Deps.RAG 可空+3 路由(GET /admin/rag、POST /admin/rag/query、/delete)。
- admin.go:adminListRag/adminSearchRag(空 query 400)/adminDeleteRag(写 501 not_implemented,M10)。
- projectindex/store.go:ListProjects/ListChapters。
- 前端 Rag.tsx:docs 从 GET /admin/rag 拉、检索走 POST /admin/rag/query、删除调真端点(501 显示 Notice),
  上传/分块/嵌入显示 M10 Notice;api.ts/types.ts 加 adminListRag/adminSearchRag/adminDeleteRag。

【主会话收口 wire.go】projStore 提到 app struct+wireMemory 赋值;knowledge.New(...) 注入 gateway.Deps.RAG。

实机验证:admin/rag 200(documents[] 空表非壳)、channels 枚举 4 渠道、health ok。
go build/vet/test 全绿 + 前端 tsc。wire.go 567 行。

遗留(记录):① 群聊/陌生人 MemWritable 记忆隔离未接 loop(AGENTS.md 红线,待修) ② webhook InboundHandler
需 gateway 挂载 ③ RAG 语义路径 persona-scoped(project 记忆其他 persona 不浮现)。
2026-09-06 20:09:09 +08:00
Aska 518bd0a230 feat: admin 端点补齐 + 视觉真推理 + 语音链路 + wire.go 收口(A+B+第3步)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
批次A admin 端点(原本 404 变 200 真实数据):
- GET /admin/llm:读 s.cfg.LLM 组 openai+anthropic 两 provider(enabled=当前 Provider、multimodal=配 VisionModel),APIKey 不进 wire 零泄露。
- GET /admin/automations:*automations.Service.List()(reminders+event rules+cron schedules,cron 加只读登记)。
- GET /admin/users:cfg.AdminUser + *auth.APIKey.List()(密钥掩码);无用户存储,恒先返回当前 admin 账号。
- GET /admin/backthink:*backthink.Backthink.List(ctx,50)。均只读、owner/admin RBAC、{data} 信封。

批次B 视觉真推理 + 语音:
- rest.go chatRequest 加 Image?;流式 publishChatStream 带 image;userChatMessages 构造 ImageMessage(model.go:71)。
- loop/vision.go:ImageResolver 可注入(MVP 先看图再对话);run.go Run 开头 resolveImages,无 resolver 透传直交主模型。
- vision/resolver.go:MessageResolver 调 vision.Router.Route——KindMain 保留图片交主模型多模态、KindVisionTool/OCRTool 经 dispatcher.Invoke 折叠成 [图片内容] 文本、KindUnsupported 记日志保留原图不崩。
- gateway/voice.go:VoiceService 接口 + SetVoice 注入点 + handleVoiceInput(未注入回 error 不崩)+ runVoiceTurn 异步 STT→loop chat→TTS→定向 voice_output 帧。
- ws_hub/ws_route:voice_input 接 handleVoiceInput;前端 ws.ts decodeBase64/playVoiceAudio/case voice_output。

第3步 wire.go 收口(唯一碰 wire.go 的一步):
- Deps 补传 Automations:a.automation / Backthink:a.backthink / Users:auth.NewAPIKey(a.cfg.Auth)(*auth.APIKey 原生满足 UserStore,避免 admin 重复的复合类型)。
- gw.SetVoice(a.voice);loop.Options.Vision = vision.NewMessageResolver(router, info, dispatcher.Invoke)(router=NewRouter(WithVisionTool/WithOCRTool)、info=cfg.LLM.VisionModel)。

实机验证:四 admin 端点 200 真实数据(llm 双 provider 脱敏、users=admin,yeij0942,owner);WS 握手正常。go build/vet/test 全绿 + 前端 tsc。wire.go 557 行。
2026-09-06 18:54:05 +08:00
Aska 648292d035 feat(loop): Top5#5 上下文溢出压缩 + 每轮 assemble(对齐 OpenClaw context-engine)——Top5 全完成
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 每轮 assemble(transformContext 等价):run.go 每 step 前 assembleContext——依次 Inject(每轮
  重新 Retriever.Inject 不缓存首轮)、WorkingMemory 前置 Summarize、可插拔 TransformContext 投影/
  过滤;生成的 view 交 step,msgs 保持纯对话供工具结果追加,记忆不重复注入、不写回 checkpoint。
- Compactor:结构分支感知压缩——estimateContextTokens 启发式超 MaxContextTokens 且消息≥3 才动;
  findKeepStart 按 user 消息切整轮从后往前累加(至少保留最后一整轮→尾随工具结果绝不切片/不跨轮);
  旧轮 user/assistant 对压成一条 RoleSystem 摘要置顶,既有摘要前缀识别合并防叠;capSummary 上限
  4000 rune;ctx 即 abortSignal(取消返回 ctx.Err,压缩失败仅记日志不中断 run)。
- 选项:Options 增 MaxContextTokens/Compactor/TransformContext,均 nil-tolerant(默认=现有行为,
  零值不压缩);DefaultMaxContextTokens=32768。Compactor 接口预留注入 LLM 摘要。

go build/vet/test -count=1 全绿 + windows/darwin 交叉编译。12 断言覆盖尾随工具结果保留/不跨轮切割/
摘要上限/摘要合并/ctx 取消/每轮重检索/TransformContext 逐轮/超限压缩/默认关闭。-race 受宿主
TSan VMA 限制无法运行(与代码无关)。

**至此 Top5(1~5) 全部完成并推库。**
2026-09-06 18:08:55 +08:00
Aska 35cb2d3942 feat(loop): Top5#4 工具循环增强(对齐 OpenClaw agent-loop)——重复检测/并行串行/钩子/errorKind/repair
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 重复调用安全网(critical-tool-loop):指纹=name+canonical JSON(args);批内重复第二次跳过回喂
  「duplicate call」;跨轮关键重复(同指纹重现)→首次 block+criticalSeen,再次连续重复→终止整轮
  StopReasonToolLoopTerminated(替代裸 8 轮针对同调用的硬截止)。
- 并行/串行混合:invokeParallel 默认并行,信号量(ToolParallelism 默认4)限并发,结果按调用索引
  写回按原序渲染;串行触发=Options.ToolExecution:sequential 或 SetToolExecutionMode(name,sequential)
  (对齐 OpenClaw「一个 sequential 标记→整组串行」);goroutine 不泄漏、ctx 取消快速返回。
- 工具钩子:ToolLoopHooks(BatchAdmit/CallAdmit/AfterCall/AfterOutcome) 经 Options.Hooks 注入,
  均 nil-tolerant;供后续敏感操作代审复用(design/11)。terminate 用 every 语义。
- status 分类:ToolError.Kind() 区分 argument-validation(JSON-Schema 失败)/execution;
  失败消息渲染 {kind,message} 让模型区分重发参数 vs 执行失败。
- tool-call-repair:LenientJSONUnmarshal 修 fence 包裹/尾逗号畸形;agent.extractToolCalls 与
  loop.decodeToolCalls 走它;不可修复退化为文本不崩整轮。意图调用未区分回喂提示(后置)。
- tool_execution_update:流式路径 emitToolExecution 发 StreamKindToolExecution(start/end/end:error)。

go build/vet/test 全绿(46包)。TestRun_MaxRounds 改每轮不同参(原同 echo 被安全网拦,预期新行为)。
-race 受宿主 TSan VMA 限制无法运行(与代码无关)。
2026-09-06 17:57:18 +08:00
Aska 2248ddce15 feat(web): 用户侧体验补齐——思维链/工具卡数据源 + 图片附件 + 语音上行 + home/vision/voice 真表现
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 打通 thinking/tool_call 运行时数据源:openai 发 thinking_*(reasoning_content)/tool_call_*
  (delta.tool_calls 累积重新组装 Args 非 JSON 串);anthropic_stream 发 thinking_*(content_block
  thinking start/delta/stop)。ws_bridge 已映射 thinking.*/tool_call.*,前端渲染活。
- 前端消息模型 + 附件:ChatMessage 加 image?;ChatInput 加附件按钮(file→base64)、预览/移除、
  image-aware onSend(text,image);VisionMessageBody 升级图像卡+识别 chip;VoiceMessageBody
  升级通话控制卡(live2D预留/波形/静音/挂断);HomeMessageBody 读真实 deviceUpdates(去掉
  sampleDevices 猜)。
- 激活 voice_input:ChatShell onVoice 不再 no-op → sendUp voice_input + 切 voice mode。
- addTool 改按工具名 upsert(修复 tool_call.start/end 重复卡);ws_mode_switch 应用 agent 驱动
  mode;addUserMessage/beginAssistant 带 image/mode;chatStream/useChat.send 带 image。
- 新增 image/phone 图标。

已知限制(标注):图片仅前端收集(后端 chatRequest 无 image 字段未接真推理);流式工具卡无真实
工具名(StreamEvent 只带 ID,回退 tool_<id前缀>);mode 由 agent 驱动但后端未逐消息下发 mode
(mode_switch 回显+默认当前 mode 兜底);voice 为 UI+voice_input 上行(真实通话后置)。
go build/vet/test + 前端 tsc 绿。
2026-09-06 17:43:03 +08:00
Aska b0bd93b15c feat(mcp): Top5#3 对齐 MCP Go SDK——协议版本协商 + list_changed 差量刷新 + keepalive + Streamable HTTP
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 协议版本协商:initialize 先发最高版本(2025-11-25)并声明能力集合,遇
  UnsupportedProtocolVersionError(-32022)/服务器不支持版本 → negotiateMutuallySupportedVersion
  求出双方最高共同版本降级重试(最多2次);无共同版本→ErrUnsupportedProtocolVersion。
  协商结果记 negotiatedVersion,HTTP 传输回填 Mcp-Protocol-Version 头。
- tools/list_changed 订阅:capabilities 声明 tools.listChanged:true;传输读无 id 帧路由到
  handleServerNotification;refreshPending 原子旗标合并突发 + 新 goroutine 调 onToolListChanged
  (避免 stdio lineRPC 重入自锁);Registry 真差量刷新(重新 DiscoverTools→移除消失/注册新增,
  跳过同名冲突保外来工具)。
- keepalive:WithKeepAlive(interval,failures) 起 goroutine 定时 Call(ping),连续失败≥阈值关
  session(默认3,可配);服务器不支持 ping(-32601)停 keepalive 不误关;ctx+Close 取消不泄漏。
- Streamable HTTP 传输:newTransport 支持 TransportType=http|streamable(保留 stdio/SSE 推断,
  空值走旧逻辑不破坏);Call POST+Accept json/event-stream 响应可单 JSON 或 SSE 流找匹配 id;
  Mcp-Session-Id 会话 + 独立 GET SSE 流收 server 通知;config 读 MCP_TRANSPORT。
- schema 缓存判定不做:客户端透传 map[string]any 无重复编译,SDK SchemaCache 是服务端
  按 Go 类型反射场景,客户端无对应。

go build/vet/test 全绿(count=2 无 flaky) + windows/darwin 交叉编译。
2026-09-06 17:27:14 +08:00
Aska 464ed2cad3 feat(tool): Top5#2 完整 JSON-Schema 校验 + 类型强转 + 错误路径 + defaults(对齐 OpenClaw validation)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 引入 github.com/santhosh-tekuri/jsonschema/v5(纯 Go,OpenClaw/MCP SDK 同思路)。
- validate.go 重写:validateArgs 用 jsonschema Compile+Validate 替换手写子集(required单层),
  支持 required/properties/嵌套 object/array items/additionalProperties/allOf/anyOf/oneOf/
  类型联合/enum/min-max/pattern/defaults;编译器缓存 sync.Map;Draft7。
- coerce.go:对齐 OpenClaw coerceWithJsonSchema 类型强转(string→number/integer/boolean/
  array/object,number/bool→string;union 处理;object/array 递归)。
- defaults.go:applyDefaults 缺失属性按 default 填(含嵌套),先填默认再强转再校验。
- 错误路径:ValidationError.InstanceLocation → 带路径报告(/count: expected integer...)。
- 测试:tool_test.go 更新 required 语义 + 补强转/default/错误路径用例。
go build/vet/test 全绿 + windows/darwin 交叉编译 + gofmt。
2026-09-06 17:11:52 +08:00
Aska 24d5924cee feat(model): Top5#1 结构化流事件 + WS 事件体系(对齐 OpenClaw AssistantMessageEvent)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 新增 model/stream.go:StreamKind(text/thinking/tool_call 各 start/delta/end + tool_execution)
  + StreamEvent{Kind,Delta,Index,ID}。
- ChatStream 签名 onChunk func(delta string)→onEvent func(StreamEvent);loop OnStream 同改;
  streamStep 转发结构化事件。
- openai:text_delta + text_start/text_end(正文三段式);anthropic:文本块 text_*/tool_call_*
  按块 Index(content_block_stop 分支)真分段;fallback:有事件即交付。
- ws_hub:wsEvent 壳补 turnId/seq;新增 broadcastStream;streamChat 每帧打 turnId+seq;
  stream.go 把事件 publish 到 bus ws.stream。
- ws_bridge:streamEventData 映射 text_*→message.start/delta/end、thinking_*→thinking.*、
  tool_call_*→tool_call.*、tool_execution_update→tool_execution.update,未知回退 stream_chunk。
- 前端 ws.ts:解析 message.start/delta/end、thinking.start/delta/end(pendingThinking)、
  tool_call.start/end→addTool、tool_execution.update;保留旧 stream_chunk/thinking/tool_used 兼容;
  types.ts 补 turnId/seq。Docs/api-client.md 增 §3.1 v0.4。

已知限制(注释/文档标注):turnId 在 OnStream→bus→relay 路径暂为空(StreamEvent 无 turnId 字段,
bus ws.stream 载荷不带 turn);OpenAI adapter 未解析 tool_call 段;thinking 段两 adapter 暂不发;
tool_execution.update 是保留项(随工具循环钩子后置)。go build/vet/test + 前端 tsc 全绿。
2026-09-06 16:31:28 +08:00
Aska 54f0b75646 docs(AGENTS): 新增 §9.5 学习成熟开源实现/禁止闭门造车铁律
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
已成熟能力/模块必须优先学习并复用现有开源项目业务逻辑(OpenClaw/Claude Code/MCP SDK/
Swarm/CrewAI/OneBot/Miuix 等),严禁凭空推演。每个 design 主题实现前先查开源对照清单
(DevDocs/开源对照清单.md),未对照不得开工。上下文压缩后本铁律强制生效。
2026-09-06 16:07:11 +08:00
Aska eddb9b9891 feat(wire): 地基④ wire.go 接 vision/voice/mcp/skill/exec 五个子系统,从死代码变可用
- vision:model.Router 绑 WithVisionModel(LLM_VISION_MODEL);vision.RegisterTools(VisualTool/OCRTool)
  用同一 OpenAICompatibleAdapter(LLM_BASE_URL 为空则跳过并 Warn)。
- voice:voice.New(cfg.Voice) Chain(cloud→local→edge-tts/espeak→silent)存 a.voice。
- mcp:读 cfg.MCP.ServersJSON → 逐 Enabled server mcp.NewRegistry(...).Register(ctx)(Connect+
  DiscoverTools+RegisterTool),注册 KindMCP 能力 mcp:<name> 供 adminListPlugins 枚举;
  close() 中 Unregister 防 stdio 子进程孤儿;每 server 15s connect 超时,失败 Warn 跳过。
- skills:LoadDir(configs/skills) → skills.Register 为 KindSkill(Runner.Run 仍 ErrRunPending,
  执行留后续)。
- exec/terminal:新 terminalTool(sdk.Tool) 内部调 execProv.Execute,注册进 dispatcher;
  ExecSandbox.Enabled=false 默认拦截(授权默认关)。
- config:LLMConfig 增 VisionModel(LLM_VISION_MODEL);新增 MCPConfig/ServersJSON(MCPSERVERS_JSON)。
- 拆 5 个辅助文件(vision.go/voice.go/mcp.go/skills.go/terminal_tool.go)避免 wire.go 膨胀;
  wire.go 最终 535 行。

默认配置下各子系统干净跳过不阻断启动(LLM_BASE_URL 已配则 vision 用主模型降级)。
go build/vet/test 全绿(46 包),gofmt 干净。
2026-09-06 16:07:04 +08:00
Aska 9b207a31e8 feat(gateway): 地基③ 打通实时信号 producer→前端WS + loop 真流式 + 上行路由补全
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
打通前端 ws.ts 里那批死消费者事件(thinking/mood/tool_used/subagent_event/
task_output/device_update/reminder/backthink),并给 loop 接真 token 流式。

- wire.go:loop 建时传 OnStream(streamChunk 闭包把 delta 发 bus ws.stream_chunk);
  gateway.Deps 注入 Scheduler/Tasks/Streaming;iot 接共享总线 iot.WithBus(a.bus)。
- ws_bridge.go:hub 额外订阅 iot.>/task.>/automation.>/reminder.>/cyrene.agent.*.event,
  normalizeEvent 归一为 ws.*(device_update/task_output/task_done/automation_result/
  reminder/subagent_event);不订阅 cyrene.agent.> 以免 ask/reply 内部事件进 relay。
- ws_route.go:voice_input→ws.voice_input;subagent_ask→Scheduler.AuditContext(真实
  上下文审计,替换 pending stub);task_output_query→Manager.TaskOutput(真实输出,
  替换空 {})。
- server.go:新增 SubagentAuditor/TaskOutputStore 接口 + Deps.Scheduler/Tasks/Streaming。
- orchestration/auditcontext.go:Scheduler.AuditContext(full/tail/keyword)。
- task/manager.go:Manager.TaskOutput(id)。
- backthink:record() 补发 ws.backthink 事件。
- json tag 修正(device_update/reminder/task output 结构体加小写 tag + DeviceUpdate
  补 message),使前端分支能真正渲染。

实机验证:WS 握手 101、task manager ready、iot manager initialized(WithBus 生效)。
go build/vet/test 全绿;TestHubBridgeDeviceUpdate/TestHubBridgeSubagentProgress 覆盖
映射。dev 推至后续版本。
2026-09-06 15:53:48 +08:00
Aska 04bfa2c7a7 feat(gateway): admin 列表端点接真实数据——persona/session/memory 补 List() 并注入 gateway.Deps
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
根因:gateway.Server 定义 SessionStore/PersonaStore/MemoryStore 接口但生产从未满足:
- 底层 manager 无 List() 方法;
- wireFrontend 的 gateway.Deps{} 未传 Session/Personas/Memories。
→ /api/v1/sessions|personas 与 /api/v1/admin/{sessions,personas,memories} 恒返回 []。

改动:
- persona/registry:Registry.List() []Character(并发安全、值拷贝)。
- channel/session:SessionManager.List(ctx)(查 channel_sessions 表+按 actor_role 重建)。
- runtime/memory:Store.List(ctx)(全表,复用 scanItems)。
- gateway 三 Store 接口改为返回域类型(Character/MemoryItem),映射统一放 collect* 侧。
- cmd/cyrene/wire.go:Deps 补传 Session/Personas/Memories。
- .gitignore:cyrene→/cyrene(锚定根,不再误伤 cmd/cyrene 目录)。

实机验证:/api/v1/admin/personas 返回 {id:cyrene,name:昔涟,isDefault:true}。
2026-09-06 14:05:34 +08:00
Aska 5dae2b9741 fix(gateway): 收敛 sdk.Channel 与 adapter.PlatformAdapter 接口漂移,/admin/channels 恢复枚举
根因:sdk.Channel.Connect 为 2 参 (ctx, config any),adapter.PlatformAdapter.Connect 为
1 参 (ctx),Go 无重载 → 4 个渠道(qq-obv11/weixin-ilink/telegram/webhook)只实现 1 参
Connect,类型断言 cap.Value.(sdk.Channel) 恒失败 → /api/v1/admin/channels 恒返回 []。

收敛方向:sdk.Channel.Connect 改 1 参 (ctx),与 PlatformAdapter 生命周期子集一致;
4 个真实适配器自动满足新接口,adminListChannels 枚举成功。最小改动(不改渠道实现、
不引依赖)。同步修正 sdk_test/registry_test/extra_test 的 mock。
2026-09-06 14:04:52 +08:00
Aska 77f75b0d99 refactor(web): 前端单页收敛修正——导航式侧边栏 + 去输入模式 chip,mode 由后端自切
按设计/01 §3 + 主人要求:行为模式切换由后端/agent 自切(热切换),前端不再提供
手动切换的行为模式按钮列表。
- 侧边栏改为导航式(连接状态 / 设置 / 管理面板 / 主题),不再列行为模式按钮;
  App.tsx 还原 <Sidebar/> 布局。
- ChatInput 去掉行为模式 chip 切换条(模式不由用户手选)。
- useChat.send/chatStream 不再传 mode;消息 mode 由后端在回复时决定,
  MessageBubble 仍按 message.mode 渲染。
- ChatShell onVoice 为占位(语音处理在 agent)。
2026-09-06 14:04:46 +08:00
Aska 4aab0f501a feat(web): 前端收敛为「共享消息流 + 按消息 mode 渲染」,并补全 emoji→MD3 图标
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
架构变更(主人要求,design/07 §1/§2 已改定稿):把「9 个行为模式 = 9 个独立页面」改为
「一个共享页面 + 每条消息绑 mode,消息框按对应 mode 渲染」。
- ChatMessage 增 mode?(types.ts);useAppStore addUserMessage/beginAssistant 带 mode;
  useSendChat.send(text, mode) / chatStream body 带 mode(通知后端按 mode 回复)。
- MessageBubble 按 message.mode 分发到 components/messages/ 各形态渲染器
  (chat 气泡 / code 等宽代码块 / home 设备卡 / docs 文档卡 / work 任务卡 / vision 视觉卡)。
- ChatInput 顶部加「行为模式」chip 切换下一条消息的 mode(默认 chat);输入框上方显示当前模式。
- ModeRouter 收敛为只渲染 ChatShell;删除 9 个旧独立 mode 页面;Nav/TopBar 的 mode 改为
  「输入模式」语义;settings 归管理面板(design/10)。

同时补全上一轮 emoji→MD3 图标(约 23 文件):新增 Icon.tsx(MD3 SVG、IconName、零依赖、
离线优先),全部界面 emoji 替换为 <Icon name=... />;正文说明箭头保留。

npm run build 绿(tsc 0 错,79→71 模块,JS 284KB/gzip 86KB)。
2026-09-06 13:32:44 +08:00
Aska 265b207699 chore(deploy): dev-up.sh 的 gateway 端口优先读 .env PORT,支持自定义端口
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-09-06 13:12:22 +08:00
Aska a482bc2ea7 feat(web): MD3 矢量图标组件 + 全量 emoji 替换为新图标
新增 src/components/Icon.tsx(Material Symbols Rounded 风格,24 viewBox / currentColor /
零运行时依赖 / 可 tree-shake / 离线优先),导出 <Icon name size /> 与 IconName 类型,
覆盖全部现有界面图标的语义映射(chart/dna/chat/memory/docs/plugin/channel/device/robot/
clock/agent/night/trend/users/settings/light/ac/mic/micOff/sun/moon/lock/block/check/close/
gear/file/package/folder/shield/storage/refresh/vision/home/arrow/terminal)。

将用户端(Nav/TopBar/ChatInput/chat + 各 modes)与管理面板(sidebar 15 模块/Dashboard
卡片/System SectionTitle/Observability StatCard/Agents/Backthink/Automations/AdminTable
empty)中的 emoji 图标全部替换为 <Icon name=... />,icon 字段由 string 改为 IconName /
ReactNode。保留正文说明性箭头(如“设置→连接”)与流程方向箭头。

npm run build(tsc --noEmit + vite build)通过:79 模块,JS 300KB/gzip 90KB。
2026-09-06 13:12:22 +08:00
Aska 20305631c5 fix(gateway): WebSocket 同源/loopback 等价放行,修复生产同源跨源 403
后端 go:embed 自服务前端时,页面 origin 与网关 Host 一致(production 模式),
但不在 dev 阶段 ALLOWED_ORIGINS(5173/3000)白名单内,originChecker 记 403
导致 WS /ws/chat 握手失败。

改为:同源(origin host == request host)及 loopback 别名(localhost<->127.0.0.1)
自动放行;跨源(不同主机名)仍走白名单。安全不降级:跨源非白名单依旧拒绝。
新增 TestOriginCheckerSameHost 覆盖同源/loopback等效/跨源拒绝/同主机异端口。
2026-09-06 13:12:22 +08:00
Aska 13c7c52860 chore(deploy): dev-up.sh 兼容无 compose 插件的 Docker,回退 docker run 拉起 cyrene-pg
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
本机 Docker 29.1.3 无 compose 插件(docker compose unknown command / docker-compose 未装),
原脚本每次启动都因 docker compose up 报错而被忽略继续掩盖。改为:
  - PG 已在跑 -> 跳过
  - 有 compose 插件 -> compose
  - 否则回退 docker run 拉起 cyrene-pg
README §3 同步补注该回退行为。脚本幂等,bash -n 通过。
2026-09-06 12:15:53 +08:00
Aska 0c93bb4497 docs(deploy): README 全流程本地部署方案 + scripts/dev-up.sh 一键启动(PG→llama.cpp embedding→cyrene),路径固定 ~/apps 与 ~/.models,勿用 /tmp
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-09-06 11:39:59 +08:00
Aska 7c662511ba docs(embedding): EMBEDDING_API_URL 为完整 /v1/embeddings 端点(非 base);示例改 Qwen3-Embedding-0.6B
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-09-06 11:34:41 +08:00
Aska 6ea5affc8a test(vector): mock 对 dim<=0 不再除零(保持 dim0→ErrInvalidDim 意图) 2026-09-06 11:31:17 +08:00
Aska 111ad598be fix(vector): New 在 embedder 维度未知(API 首请求前为0)时先 probe 一次学真实维度,避免 PG 向量索引 fallback 2026-09-06 11:30:26 +08:00
Aska 74f8eada47 docs(README): Embedding 推荐更新为 Qwen3-Embedding-0.6B(Q8_0 639MB 首选,ModelScope 国内可下/GGUF,dim 1024 注意清向量表)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-09-06 11:19:17 +08:00
Aska 2d256264ec docs(README): 去除用户侧文档中的本机开发环境细节,改用通用/资源分级表述
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-09-06 10:47:48 +08:00
Aska 3ed7fbbc69 docs(README): 用户侧本地部署指南 + Embedding 模型推荐列表(bge-m3 首选,dev 环境用 bge-small-zh-v1.5)+ llama.cpp 替代说明
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-09-06 10:45:38 +08:00
Aska efc1b115c9 fix(gitignore): 修正行尾注释导致 data/cyrene 未忽略(改为纯 pattern + 独立注释行)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-09-06 10:41:32 +08:00
Aska 16ae72f24e chore(gitignore): 追加开发/运行本地产出(data/ SQLite 目录、cyrene 根构建产物;internal/webui/dist 由 dist/ 覆盖) 2026-09-06 10:41:13 +08:00
Aska cb6fac587b test(db): Open 自动建父目录改为断言成功(行为改进)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-09-06 10:40:06 +08:00
Aska 1d9c14300d fix(db): Open 自动创建父目录(自定义 DATA_DIR 首启不报错);add docker-compose.dev.yml(PG+pgvector 必需 + Ollama embedding 可选) 2026-09-06 10:39:27 +08:00
Aska cee2d26205 fix(wire): resolveDataDir 改用 cfg.DataDir(.env 的 DATA_DIR 真正生效,不再落平台默认目录)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-09-06 10:32:20 +08:00
Aska 3632e04733 feat(model): 双协议 LLM Provider(AnthropicChNativeAdapter /v1/messages + tool_use.input→Args 直接映射 + 流式重组)+ Provider 感知工厂/路由 + LLM_PROVIDER/anthropic 配置(OpenAI 兼容保留)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-09-06 09:41:06 +08:00
Aska f9650185f1 test(gateway): 补 handler/WS/admin/static/middleware 测试至覆盖率 95.9%(≥70% 达标)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-09-06 08:19:31 +08:00
Aska 3146b515f3 chore(ci): 新增 CI(前端 build→go build/vet/test→cross-compile 矩阵 + 覆盖率上报);README 同步 M0-M11 状态/快速开始/技术栈/文档索引 2026-09-06 08:13:12 +08:00
Aska a7feaeb2cb feat(deploy): M11 单二进制装配(cmd/cyrene 全链路 wire + go:embed 前端 + 外置 dist 覆盖 + 优雅关闭 + SPA 静态服务);前端 build 拷到 internal/webui/dist 2026-09-06 08:10:35 +08:00
Aska f43eb17030 feat(frontend/admin): M10 后台管理面板(/admin hash 路由 + 15 大模块 + RBAC(owner/admin/viewer) + 审计 + Miuix);Docs/api-client.md v0.3 契约补全;tsc+vite build 通过 2026-09-06 07:55:04 +08:00
Aska 4c48e94659 feat(frontend): M9 模态 UI(mode 系统+共享 store+WS 客户端 + chat(思维链/工具回显/情绪徽标)/code/Agent 监控/后台思考页 + Miuix 设计 token + PWA);tsc+vite build 通过 2026-09-06 07:44:40 +08:00
Aska b3ffd51736 docs(api): M8 gateway 端点落地——login/health/chat/sessions/personas + admin 只读 + plugins/policy + WS /ws/chat 契约补全 2026-09-06 07:34:04 +08:00
Aska df70313d6d chore(deps): 添加 gorilla/websocket(WS Hub)+ robfig/cron(自动化调度) 2026-09-06 07:33:11 +08:00
Aska 6f97fded3a feat(backthink): 后台自主思考(heartbeat+事件触发 + 打扰决策 fail-closed + 主动推送活跃→IM 兜底 + 限制 + 思考记录) 2026-09-06 07:33:06 +08:00
Aska 05e23ea590 feat(gateway): 网关(REST /api/v1 + WS Hub /ws/chat + admin /api/v1/admin/* + JWT/RBAC + 审计,单端口 :8080) 2026-09-06 07:32:40 +08:00
Aska ff34b62079 feat(automations): 自动化引擎(cron 调度 + 事件触发 + AI 管线动作 + 提醒调度,bus automation.result/reminder.trigger) 2026-09-06 07:32:29 +08:00
Aska a3f6f4f687 feat(auth): JWT(HS256 stdlib) + RBAC(owner/admin/viewer) + API key + 密码 hash;config 校验 2026-09-06 07:19:28 +08:00
Aska 7f7cb8fbf7 feat(errors): 统一 AppError(Code/Message/Cause/HTTPCode + Wrap %w + HTTPStatus + ToJSON {error:{code,message}}) 2026-09-06 07:15:34 +08:00
Aska ebeecc7a32 feat(task): 任务执行模型(后台声明/前台2s转后台/bus task.done 回输出/实时输出读取/Abort 防失控) 2026-09-06 07:11:03 +08:00
Aska 430fc4666b feat(exec): 本地执行 Provider + 放权终端沙箱(白名单/工作目录/超时/输出截断/默认关)+ 文件沙箱;config 增 ExecSandbox(默认关) 2026-09-06 06:59:23 +08:00
Aska 88dc12466d feat(channels/weixin_ilink): 微信 iLink 渠道插件(扫码登录+长轮询+游标+context_token+AES-128-ECB 媒体+24h 主动推送窗口) 2026-09-06 06:49:35 +08:00
Aska 7831b0ea1b feat(channels/qq_obv11): OneBot v11 渠道插件(正/反 WS 双模式 + CQ 媒体 + 限频,stdlib 自研 WS) 2026-09-06 06:46:43 +08:00
Aska f9f5a78a02 feat(channels/telegram): Telegram 渠道插件(Bot API 长轮询/Webhook + file_id 媒体 + 主动推送强) 2026-09-06 06:38:13 +08:00
Aska 0b036645bb feat(channels/webhook): 入站回调渠道插件(JSON/Form 解析 + HMAC 签名 + InboundHandler 供 gateway 挂载,主动推送不支持) 2026-09-06 06:37:16 +08:00
Aska 7adaf490c7 feat(channel/session): 渠道↔会话映射 + 主人识别(actor元数据+白名单,不读消息内容) + 群聊隔离 + 主动推送策略(iLink24h/OneBot限频) 2026-09-06 06:36:27 +08:00
Aska 3ce9489f8e feat(tools/io): 内置 IO/网络工具(file(路径安全)/http(SSRF防护)/web-search/web-fetch/knowledge)+ 大小截断 2026-09-06 06:29:03 +08:00
Aska 488cf9c79c feat(capability/mcp): MCP 客户端(stdlib JSON-RPC over stdio/SSE)+ Registry 自动注册/Unregister(registry 增 Remove) 2026-09-06 06:28:38 +08:00
Aska 08223c06a5 feat(tools/basic): 内置基础工具(calculator(注入安全)/datetime/random/text/json/markdown)+ 批量注册 2026-09-06 06:26:59 +08:00
Aska b3c61aa1ff feat(iot): IoT 插件化(Provider/Manager/mock 8设备/能力约束/bus 事件);feat(capability/restrict): 工具级布尔标记(群聊/陌生人可用,默认关,策略引擎雏形)+ registry ToolPolicy 2026-09-06 06:26:21 +08:00
Aska 915aa8a100 feat(capability/skills): 可复用工作流技能(Skill 模型 + Store + YAML Loader + Runner 展开 + 注册) 2026-09-06 06:25:42 +08:00
Aska d947ec41a9 feat(memory): M4 记忆完善(三类作用域访问规则+项目索引分层+LLM异步提取+衰减溯源+向量接入,兼容既有57用例) 2026-09-06 06:15:18 +08:00
Aska 5b37f5a551 chore(deps): 添加 pgx/v5 驱动(memory/vector 的 PG pgvector 真生效路径) 2026-09-06 06:15:18 +08:00
Aska fb880572a4 feat(memory/vector): 向量检索引擎(SQLite BLOB 内积默认 + PG pgvector 真语义增强,配置后真生效) 2026-09-06 06:04:44 +08:00
Aska ef79edd96a feat(memory/projectindex): 项目索引分层(索引+描述+章节,Search 定位行+上下文,级联删除) 2026-09-06 06:01:20 +08:00
Aska d90ec7bd4f feat(orchestration): 子代理编排调度器(创建/生命周期/并行上限4/超时/防失控/结果合并/进度事件/隔离上下文+独立工作记忆) 2026-09-06 05:53:54 +08:00
Aska 20f4a91d02 feat(orchestration/comm): agent-to-agent 通信(bus ask/reply/event,私密 reply 主题 + 超时) 2026-09-06 05:41:54 +08:00
Aska 8b723009f9 feat(orchestration/audit): 子代理上下文可审计查询(全量/尾部/关键词定位)+ 主代理代审授权(审计日志+小字) 2026-09-06 05:39:06 +08:00
Aska 71697ca4f8 feat(loop): agent loop(8 轮 + checkpoint + 流式 + 记忆注入/提取 + trace + 工具超时回喂) 2026-09-06 05:33:38 +08:00
Aska 01a4e29f84 docs(api): 新增用户侧跨平台客户端 API 文档(契约+端点登记表);AGENTS 立「随实现更新 API 文档」铁律 2026-09-06 05:32:57 +08:00
Aska 10c0069fa7 feat(logger): WithSource/Scoped 日志来源标注;AGENTS 追加日志标来源+客户端API契约红线 2026-09-06 05:22:00 +08:00
Aska be67d74822 feat(agent): Agent 实体(指令/工具/guardrail/handoff + 单步 RunStep,含多模态路由/调用记录) 2026-09-06 05:20:32 +08:00
Aska b798e129c1 feat(tool): Dispatcher.ToolSchemas() 暴露工具 schema(供 agent 组装请求) 2026-09-06 05:20:32 +08:00
Aska 3da189e856 docs(AGENTS): 追加 §9 全球红线 + §10 代码/流程硬性规范(模块拆多文件/每阶段 push/遇设计冲突阻塞询问) 2026-09-06 05:14:17 +08:00
Aska aa697c385f feat(memory): 长记忆 store(SQLite+persona 隔离)/工作记忆滑动窗口/检索注入/手工提取钩子 2026-09-06 05:12:42 +08:00
Aska 285e1e9911 feat(db): 新增 0002_memory 迁移;迁移测试改用嵌入式迁移集合(数量无关,避免卡死后续迁移) 2026-09-06 05:12:42 +08:00
Aska 0a936a3202 feat(multimodal): 视觉分层路由(多模态检测→主/视觉/OCR 工具)+ 语音 STT→TTS 降级链(异步) 2026-09-06 05:12:36 +08:00
Aska 02aae2bbb4 feat(guardrail): 输入/输出护栏(关键词/敏感信息/长度,pipeline fail-fast) 2026-09-06 05:07:13 +08:00
Aska e33b005612 feat(trace): 深追踪(Span 生命周期/父子链/JSONL recorder,支持 llm/tool/state 等类别) 2026-09-06 05:06:41 +08:00
Aska 011d25a53d feat(persist): durable checkpoint(SQLite upsert/LoadLatest/List/Delete,ErrNotFound) 2026-09-06 05:05:24 +08:00
Aska ecd3cdf28c docs(persona): 游戏身份(昔涟/翁法罗斯/开拓者设定)与亲昵称呼(小女友/亲爱的)统一共存,清除残留「姐姐」 2026-09-06 04:58:09 +08:00
Aska 95e058094a docs(persona): 默认人格表述对齐「全能小女友」、称呼用户「亲爱的」 2026-09-06 04:55:18 +08:00
Aska b9253a4980 feat(user): 主人档案/白名单(OwnerProfile+loader)+ 给 LLM 的身份说明书(owner_identity.md) 2026-09-06 04:55:18 +08:00
Aska c647d33a00 feat(persona): 迁移默认人格「昔涟」并适配新 schema(configs/personas/cyrene.yaml) 2026-09-06 04:41:04 +08:00
Aska 8317f370cd chore(deps): go mod tidy 整理依赖图(yaml.v3 转正为直接依赖) 2026-09-06 04:38:21 +08:00
Aska d09ccc7570 feat(model): LLM 多供应商适配(Adapter/OpenAI 非流式+SSE 流式/per-role 路由/主备降级/多模态检测/调用记录) 2026-09-06 04:37:27 +08:00
Aska 51b2bdfba2 feat(embedding): 嵌入(n-gram 哈希向量 + 关键词 FTS + OpenAI 兼容 API 钩子 + Manager 选择) 2026-09-06 04:37:10 +08:00
Aska 70fd915242 feat(persona): 人格系统(YAML loader / 角色人格注册+记忆隔离 / 情绪状态机+好感度) 2026-09-06 04:36:31 +08:00
Aska 653c2489bf feat(capability): 能力注册中心(类型化注册/按类查询/并发安全) 2026-09-06 04:35:31 +08:00
Aska 7b8cfc664a feat(sdk): 插件 SDK(Tool/Skill/Channel 接口 + 注册辅助 + 配置 hook) 2026-09-06 04:35:31 +08:00
Aska c52ddfd9c7 feat(channel): 跨平台 IM 接入抽象契约(UnifiedMessage/PlatformAdapter/Capabilities/Actor 身份) 2026-09-06 04:33:59 +08:00
Aska a21176ec0a chore(deps): 添加 gopkg.in/yaml.v3(人格 YAML 加载) 2026-09-06 04:33:59 +08:00
Aska c730b05621 chore(deps): 添加 modernc.org/sqlite 纯 Go SQLite 驱动(无 CGO、跨平台) 2026-09-06 04:27:45 +08:00
Aska 34cac241c1 feat(db): SQLite 连接与迁移框架(embed 迁移/事务校验/PG 预留 M4) 2026-09-06 04:26:02 +08:00
Aska dd16176bb7 feat(bus): 进程内事件总线(通配订阅/非阻塞扇出/Close 幂等) 2026-09-06 04:26:02 +08:00
Aska c1fb20bd6b docs(config): 更新 .env.example 对齐新架构 schema 2026-09-06 04:19:20 +08:00
Aska 1defd46d14 docs(AGENTS): 补充「可并行项交后台子代理、主会话当监工」的上下文保护铁律 2026-09-06 04:17:54 +08:00
Aska 1d4bed415f feat(platform): 多平台检测/路径/信号/系统信息(含跨平台构建验证) 2026-09-06 04:17:20 +08:00
Aska efed5f05b1 feat(config): 配置加载与 schema 校验(.env/env 注入 + 反射解码 + 校验) 2026-09-06 04:15:31 +08:00
Aska 0236010136 feat(logger): slog 结构化日志封装(text/json + 级别解析) 2026-09-06 04:13:19 +08:00
Aska 62c7a2aa12 docs: 新增项目级开发铁律 AGENTS.md 2026-09-06 04:12:37 +08:00
Aska 4506f639dd chore: 清理 v2 过时残留(旧 API 文档/人格配置/部署目录/compose/启动脚本/密钥备份/旧CI),更新 gitignore 2026-09-06 03:52:27 +08:00
Aska 8b310feceb docs: 更新 README 为新架构(人格化通用 Agent 运行时) 2026-09-05 23:13:27 +08:00
Aska 4be3826af7 chore: 清空 v2 实现,重建新架构骨架(人格化通用 Agent 运行时) 2026-09-05 23:09:26 +08:00
Aska 9c8d5a9e8d feat(rag): pgvector knowledge base with API and LLM tools
Cyrene CI / backend (push) Has been cancelled
Cyrene CI / frontend (push) Has been cancelled
- services/rag: knowledge_docs store — vector search via the shared
  embedder (HNSW index, graceful ILIKE fallback), list/delete scoped to
  the user, search intentionally global for the single-user deployment
- gateway: GET/POST /knowledge/docs, DELETE /knowledge/docs/:id,
  POST /knowledge/search
- plugins/knowledge: knowledge_search and knowledge_ingest tools so the
  persona can save and recall information mid-conversation
- main: migrations, store wiring, plugin registration
2026-08-21 18:40:01 +08:00
Aska ac0c2b2c73 feat(ai-core): real vision image analysis
- llm: Adapter.Vision sends multimodal chat requests (text + image_url,
  data URLs supported) and logs the call as purpose 'vision'
- gateway /images/analyze now accepts image_url or image_base64 and
  returns real analysis instead of a placeholder message
- main wires the default LLM adapter as the vision analyzer
- httptest coverage for the vision request/response shape
2026-08-21 18:33:44 +08:00
Aska 2104fc0c6c feat(frontend): voice input and read-aloud UI
- assistant messages get a 🔊 button that fetches TTS audio and plays
  it, toggling to stop
- ChatInput gains a mic button: MediaRecorder capture, base64 upload to
  /voice/stt, recognized text fills the input
- api client: voice.tts (blob) and voice.stt helpers
2026-08-21 18:31:24 +08:00
Aska a0357a7bbd feat(voice): real TTS fallback chain and whisper-compatible STT
- TTS providers: cloud (OpenAI-compatible /audio/speech, e.g. Bailian
  compatible mode with cosyvoice), local self-hosted HTTP server,
  edge-tts CLI, espeak CLI, silent — ordered by TTS_PROVIDER list,
  skipping providers whose binary/config is missing
- STT: whisper-compatible /audio/transcriptions endpoint (cloud or
  self-hosted faster-whisper), replacing the empty dashscope stub
- config: TTS_CLOUD_URL/KEY/MODEL/VOICE, TTS_LOCAL_URL, STT_API_URL/
  KEY/MODEL; cloud keys fall back to DASHSCOPE_API_KEY
- gateway /voice/tts now returns real audio/mpeg bytes instead of a
  byte count
- tests: cloud/local/whisper round-trips via httptest, chain skip
  behavior, fallback order; kept and adapted prior service tests
2026-08-21 18:28:39 +08:00
Aska b8fb7feb86 feat(ai-core): mood state machine and affection level
Cyrene CI / backend (push) Has been cancelled
Cyrene CI / frontend (push) Has been cancelled
- new emotion package: rule-based sentiment (positive/negative lexicon)
  drives mood transitions (开心/平静/难过) with 30min inactivity decay;
  affection 0-100 grows with positive interaction, with 初识→熟悉→信赖→
  羁绊 tiers
- pipeline records each user message and injects a mood line into the
  system prompt so the persona's tone follows her mood
- mood changes broadcast over WS; frontend shows a mood badge with
  affection tooltip; also handles the 'reminder' message type from the
  reminder scheduler
- tests: sentiment scoring, mood shift, affection bounds, prompt line,
  onChange callback
2026-08-21 18:13:23 +08:00
Aska 26d65dd6bb feat(frontend): message search and session export entry points
Cyrene CI / backend (push) Has been cancelled
Cyrene CI / frontend (push) Has been cancelled
- SearchModal queries GET /messages/search and jumps to the matching
  session; opened from the sidebar button or Ctrl+K
- session items in the sidebar gain a hover export button that
  downloads the markdown export via an authenticated fetch + blob
  (plain links cannot send the JWT)
2026-08-21 12:59:30 +08:00
Aska 9d040fde0b feat(plugins): implement web-search, web-fetch, http and file tools
- web-search: real SearXNG /search?format=json query, top-5 formatted
  results (was a 'not configured' stub)
- web-fetch: fetch page, strip scripts/styles/tags, truncate to 4000
  runes for the LLM context
- http: generic request tool (GET/POST/PUT/DELETE/PATCH) with scheme
  and method validation, status + truncated body
- file: sandboxed read/write/list inside the data directory with path
  escape rejection; split into file_read/file_write/file_list
- all tool schemas converted to proper JSON Schema; httptest/tempdir
  tests for each plugin
2026-08-21 12:57:19 +08:00
Aska d80710d85a feat(gateway): automation rule engine and reminder scheduler
- engine: 5-field cron parser (*/n, ranges, lists) firing at most once
  per minute; device-state triggers with edge detection; manual trigger
  via POST /automation/rules/:id/trigger; actions run through the AI
  pipeline so rules can call any tool (IoT control, search, ...)
- reminders: due-scan scheduler delivering via WebSocket broadcast,
  optional LLM-polished reminder text with plain-title fallback;
  Reminder gains Done flag with ListDue/MarkDone
- tests: cron matching and validation, once-per-minute dedup, device
  edge triggering, manual trigger, reminder delivery
2026-08-21 12:54:11 +08:00
Aska 9f30cdd086 feat(bridge): QQ adapter reconnect and send rate limiting
- supervisor loop keeps the OBv11 WebSocket alive, reconnecting with
  exponential backoff (1s→30s) when the connection drops; Start no
  longer fails permanently when NapCat is temporarily unreachable
- Send enforces MinGap between messages (from SendOptions or the
  adapter's send_interval_ms setting, now wired from the admin panel)
- parseMessage no longer panics on events missing sender/time fields
- tests: reconnect after drop, rate limit enforcement, malformed events,
  send while disconnected
2026-08-21 12:48:04 +08:00
Aska 8603839711 feat(ai-core): trace instrumentation, LLM thinker, decay scheduler
Cyrene CI / backend (push) Has been cancelled
Cyrene CI / frontend (push) Has been cancelled
- orchestrator: Tracer interface with spans for intent analysis, persona,
  subsession dispatch, context build, LLM streaming and each tool call;
  traces finish when the stream completes — the admin Trace waterfall
  finally receives data
- background: Thinker generates real LLM thoughts (post-chat + silence
  triggers, recent history as context, empty/overlong outputs discarded);
  thoughts broadcast to web clients as 'thinking' WS messages; pipeline
  postChatHook triggers post-chat thinking after each round
- memory: ApplyDecayAll + ListUserIDs with a 6h decay/purge cron in main
  (was implemented but never scheduled)
2026-08-20 20:57:27 +08:00
Aska 8495a4b055 feat(memory): hybrid vector search via embedder
- repository: optional pgvector embedding column (migration with
  CREATE EXTENSION guarded by main), SearchByVector cosine search,
  vector-enabled insert path
- service: SetEmbedder — memories are embedded on Add; Search merges
  vector hits with n-gram keyword matches (vector first, deduped)
- embedder was already implemented (OpenAI-compatible API + n-gram FNV
  fallback) but never instantiated; main now wires it from
  EMBEDDING_API_URL/EMBEDDING_MODEL config
2026-08-20 20:57:13 +08:00
Aska 8190007865 feat(frontend): session list sidebar and persisted history loading
Cyrene CI / backend (push) Has been cancelled
Cyrene CI / frontend (push) Has been cancelled
- Sidebar lists sessions with title/relative time, click to switch,
  hover-to-delete; refreshes after each round so new titles appear
- ChatPage loads message history from GET /sessions/:id/messages when
  entering a session
- WS connect now passes user_id so persisted messages have an owner
2026-08-20 20:34:08 +08:00
Aska f8196c111c feat(gateway): persist chat sessions and messages in PostgreSQL
- store: new chat_sessions/chat_messages tables with CRUD, ILIKE message
  search, and auto-titling from the first user message
- context: optional Persister on the sliding-window store — messages are
  saved asynchronously on Add, sessions not in memory are restored from
  the database on Get
- orchestrator: ProcessInput/FinishRound now carry userID so persisted
  messages have an owner
- api: real session handlers (list/create/delete), message history and
  clear endpoints, working message search and three-format export
  (markdown/txt/json), fixing the short-ID panic in export
- main: migration, store wiring, ctxStore persister adapter

Tests: chat store handler lifecycle (fake store), context persister
save/restore, stub auth login flow updated.
2026-08-20 20:34:08 +08:00
Aska f44bf26050 fix(build): stop ignoring cmd/cyrene sources
Cyrene CI / backend (push) Has been cancelled
Cyrene CI / frontend (push) Has been cancelled
The bare 'cyrene' .gitignore pattern matched the cmd/cyrene directory,
so trace_adapter.go was never committed and a fresh clone failed to
build (undefined: traceAdapter). Scope the pattern to the root binary
and add the missing file.
2026-08-20 19:42:32 +08:00
Aska cc040b861d feat(ai-core): trigger memory extraction after each conversation round
The WS hub and platform bridge now call Pipeline.FinishRound when the
response stream completes, recording the assistant reply and invoking
the LLM memory extractor asynchronously.
2026-08-20 19:40:58 +08:00
Aska 7c02d31194 feat(ai-core): wire function calling into the LLM pipeline
- llm: ChatStreamWithTools sends OpenAI-format tools; streamed tool_call
  deltas are reassembled by index into complete ToolCalls on the Done token
- model: LLMMessage gains ToolCallID/ToolCalls; StreamToken gains ToolCalls
- orchestrator: new ToolExecutor interface and streamWithTools loop —
  execute requested tools, feed results back, up to 3 rounds, final round
  forces a plain text answer; normal chats still stream token by token.
  Also adds Pipeline.FinishRound (assistant reply recording + post-round
  hook), wired to transports in the next commit.
- main: inject the plugin registry via toolExecAdapter

Includes an httptest-based test covering delta reassembly, tool
execution, and result feedback.
2026-08-20 19:40:58 +08:00
Aska 9a3c77dbd3 fix(bridge): align admin panel field keys with backend adapter settings
Frontend sent remote_url/bot_token/webhook_url while the backend reads
ws_url/token/listen_addr, so QQ adapters could never connect. Also add
the missing bot_qq field and wire the webhook adapter's APIKey.
2026-08-20 19:40:32 +08:00
Aska a58ec7325d refactor(logging): replace all print/log with structured slog logger
Cyrene CI / backend (push) Has been cancelled
Cyrene CI / frontend (push) Has been cancelled
- main.go: 30+ log.Printf → logger.Infof
- plugin/registry: 12 log.Printf → logger.Infof
- db/db.go, ws/hub.go: log → logger
- platform-bridge: fmt.Printf/println → logger.Info
- Logs now: time=... level=INFO msg=...
- 16 packages pass, vet clean
2026-08-12 21:31:06 +08:00
Aska 628d416dcd feat(admin): add authenticated restart endpoint + fix admin username
Cyrene CI / backend (push) Has been cancelled
Cyrene CI / frontend (push) Has been cancelled
- POST /api/v1/admin/restart: admin-only restart (forks new process)
- Fix: MemUserStore now uses cfg.AdminUsername instead of hardcoded 'admin'
- RestartFunc injected by main.go for clean process fork + exit
2026-08-12 21:26:58 +08:00
Aska dee48b6766 feat: add automation, reminders, notifications, msg search, session export, image analysis, host exec, PWA, Docker, E2E, CI/CD
Cyrene CI / backend (push) Has been cancelled
Cyrene CI / frontend (push) Has been cancelled
- Automation: rules CRUD (in-memory store + API)
- Reminders: CRUD (in-memory store + API)
- Notifications: push interface for future push server
- Message search: API endpoint with DB-backed query
- Session export: /sessions/:id/export → Markdown
- Image analysis: API stub for multimodal LLM
- Host exec: API stub for Docker sandbox
- PWA: manifest.json + service worker
- Docker: Dockerfile + docker-compose.yml
- E2E: Playwright test scaffold
- CI/CD: GitHub Actions workflow (go test + tsc)
2026-08-12 19:43:46 +08:00
Aska 3f9c4ca3c3 fix(prompt): prevent LLM from confusing context with user input
- Context merged into single system message with clear delimiters
- Prefix: 「以下是系统上下文,不是用户说的话」
- Memory → 【相关记忆】, IoT → 【设备状态】
- Filter empty memories, skip raw intent data
- Persona rule: context markers are internal data, not user input
2026-08-12 19:28:32 +08:00
Aska 585dfa1e38 fix(prompt): clean persona system prompt — remove YAML dump, filter empty subsessions
- Persona injector: concise natural language rules, not raw YAML
- Context builder: skip empty memory, skip raw intent data
- Prompt reduced from 2000+ tokens to ~500 tokens
- LLM response quality improved (natural tone, no confusion)
2026-08-12 19:27:12 +08:00
Aska 14a8eecfdb fix(memory): improve dedup thresholds and keyword priority
- Dedup: OR logic with 28%/0.45 thresholds (was 25%/0.4)
- Keywords: segments first → 3-grams → 2-grams
- False positive: 10% → acceptable level
- Accuracy: 80% correct dedup, 71% real rate
2026-08-12 19:20:14 +08:00
Aska dc3671eb8d feat(bench): add memory benchmark tool + export keywords
- tools/memory-bench: 6 metrics (write/search/dedup/keyword/bulk/rate)
- memory: export ExtractKeywordsPublic for benchmarking
- Results: 1000+ 条/秒 write, 380µs search, 80% dedup accuracy, 71% real rate
2026-08-12 19:15:15 +08:00
Aska 6a4887e52a feat(llm): add LLM call recording with admin panel integration
- llm.Adapter: SetCallLogger hook for recording every API call
- llm.CallLogger: ring-buffer with Recent+Stats for admin API
- main.go: wire DeepSeek adapter to CallLogger
- Fix: single CallLogger instance for both recording and display
- Metrics: model name, duration, success/fail, tokens
- All 16 packages pass
2026-08-12 19:10:51 +08:00
Aska 5b2bcc9441 fix(frontend): resolve TypeScript errors in TracePage
- Fix if-else missing semicolon (TS1005)
- Fix useRef missing initial value (TS2554)
2026-08-12 18:57:23 +08:00
Aska 4e2e81f684 feat(trace): waterfall visualization + persona YAML sync
- trace.Logger: Trace+Span model with 14+ hop tracking
- trace.SpanTimer: convenience API for instrumentation
- trace_adapter.go: serialize Trace+Span for frontend
- Frontend TracePage: colored waterfall bars, expand/collapse, detail popup
- persona.go: full v1 YAML parser (identity/traits/forbidden/sections)
- cyrene_persona.yaml: v1 persona config copied to v2
- Orchestrator: TraceHook + MemoryExtractor injection points
2026-08-12 18:52:39 +08:00
Aska 47047c903c feat(memory): add LLM-driven memory extraction from conversations
- memory/extractor: async LLM-based memory extraction from chat history
- Parses JSON lines from LLM response as memory entries
- Wire into orchestrator pipeline via PostProcess hook
- Non-blocking: runs in goroutine with 30s timeout
- Automatic dedup via existing keyword overlap logic
2026-08-12 18:43:04 +08:00
Aska 886fcf2216 fix(memory): rewrite dedup with n-gram keyword overlap + embedder
- Keyword extraction: 2-3 char sliding window + segment splitting
- Dedup: keyword overlap (>25%) OR Jaccard (>0.4) triggers merge
- SearchByNGrams: pgvector GIN index on keywords array
- Embedder: n-gram FNV hash (zero dep), OpenAI API with auto-fallback
- GIN index on keywords column for fast overlap search
- All tests pass
2026-08-12 18:24:04 +08:00
Aska ba42ea2b45 fix(bridge): add complete OBv11 fields matching v1 ethend
- OBv11: mode (client/server), remote_url, access_token, http_url,
  http_token, bot_port, send_interval_ms, admin_uids (8 fields)
- Telegram: bot_token, webhook_url, admin_uids
- Discord/Webhook: proper fields with labels
- Mode dropdown selector for client/server
- Field labels and placeholders matching v1
2026-08-12 18:12:39 +08:00
Aska 9eaed2883f feat(bridge): separate bridge config from .env, web panel + hot-reload
- BridgeConfigStore: file-backed JSON config (configs/bridge.json)
- Auto-creates config file only when user saves via admin panel
- Hot-reload: watches config file every 5s, reloads adapters live
- Admin API: GET/PUT/DELETE /api/v1/admin/bridge/config/:name
- Frontend: full CRUD page with platform type selector + toggle
- Removed bridge env vars from .env.example
2026-08-12 17:55:15 +08:00
Aska 217319eabc chore: add .env.example config template with all options
- Full config template with comments for every setting
- start.sh auto-loads .env if present
- Covers: LLM, DB, Auth, IoT, Voice, Platform Bridge, Search, CORS, WS
2026-08-12 17:46:23 +08:00
Aska 2a23ecdbdb fix(ui): remove blue background behind avatar image
- Remove bg-primary from avatar containers in Login/Chat/MessageList/Header
- Avatar image now fills container cleanly with object-cover
- Preserve shadow glow effect
2026-08-12 17:44:31 +08:00
Aska 5faa955bce feat(ui): add light theme with system auto-switch + Cyrene avatar
- Miuix day/night dual theme CSS variables
- themeStore: light default, follows prefers-color-scheme
- Header: sun/moon toggle button
- All components migrated to CSS variables
- Cyrene avatar image replaces 'C' logo everywhere
- Favicon added
2026-08-12 17:42:18 +08:00
Aska 78c5f0f1b0 chore: add cross-platform startup scripts
- start.sh: Linux/macOS一键启动 (编译+后端+前端+健康检查)
- start.bat: Windows批处理 (两个终端窗口)
- Ctrl+C优雅退出,环境变量可覆盖
2026-08-12 17:36:22 +08:00
Aska e8b5679cf4 feat(admin): add full-chain trace and performance monitoring
- Backend: trace.Logger ring-buffer for request chain events
- Backend: perf.Monitor with runtime.MemStats sampling every 10s
- Backend: /api/v1/admin/trace and /api/v1/admin/perf endpoints
- Frontend: TracePage — timeline with hop icons and status dots
- Frontend: PerformancePage — memory/GC bar charts + live stats
- Admin nav expanded to 11 items
2026-08-12 17:16:33 +08:00
Aska 8420691438 feat(admin): add model config CRUD and platform bridge pages
- Backend: ModelConfigStore with Provider/Model/Routing CRUD
- Backend: /api/v1/admin/models/config endpoints
- Frontend: ModelsPage with 3-tab editor (providers/models/routing)
- Frontend: BridgePage showing adapter status + group chat rules
- Admin nav expanded to 9 items
2026-08-12 17:13:29 +08:00
Aska 1a80ac2a00 feat(admin): add LLM call logs, system info, plugin list pages
- ai-core/llm: ring-buffer call logger with stats
- Gateway: /api/v1/admin/llm-calls, /system, /plugins, /sessions endpoints
- Frontend: LLMCallsPage (table + stats), SystemPage (version/plugins/sessions)
- Admin nav expanded to 7 items
2026-08-12 16:56:52 +08:00
Aska 8bfda096f7 feat(admin): rebuild admin panel with full management features
- AdminLayout: sidebar navigation (5 pages)
- Dashboard: real-time service/plugin/uptime stats
- Services: status display for all 4 services
- IoT Management: 8 devices with toggle controls
- Plugins: 11 plugins grid with type badges
- Memory: full CRUD with search, add, delete
- 60 modules, TS zero errors, 256KB JS
2026-08-12 16:45:21 +08:00
Aska bea29d3b73 style(frontend): redesign with Miuix HyperOS design language
- Color system: Miuix dark tokens (primary #277AF7, bg #242424, surface #000)
- Components: rounded-full buttons, squircle cards (18px), glass header
- Messages: blue user bubbles, dark AI bubbles with typing animation
- Admin dashboard: stat cards with real-time service status
- Login: centered logo with gradient shadow, HyperOS input fields
- Smooth transitions and scale-on-press interactions
2026-08-12 16:41:34 +08:00
Aska 17ef73bcbe feat(bridge): add multi-platform bridge — QQ, Telegram, Discord, Webhook
- platform-bridge: unified message model + Adapter interface
- qq/adapter: OBv11 WebSocket protocol with @mention detection
- telegram/discord: stub adapters with send capability
- webhook: HTTP POST receiver for generic webhook integration
- Router: message routing from platform → AI-Core → back
- Group chat rules: 500 char limit, 3s gap, silence keywords
- main.go: conditional adapter loading via env vars
2026-08-12 12:30:55 +08:00
Aska 7e3ec8da93 docs(api): add REST API reference and WebSocket protocol docs
- Docs/api/rest-api.md: all 15 endpoints with curl examples
- Docs/api/websocket.md: WS protocol, message types, reconnect strategy
2026-08-12 12:22:27 +08:00
Aska fbb331ed4b feat(auth): replace stub auth with real JWT + bcrypt
- RealAuthManager: JWT signing/validation + bcrypt password hashing
- MemUserStore: in-memory user store with default admin
- Register endpoint: user creation with hashed password
- Login endpoint: bcrypt verification + JWT generation
- AuthManager interface extended with Login/Register methods
2026-08-12 12:21:36 +08:00
Aska 6811480786 feat(gateway): wire real services into Gateway API handlers
- Router accepts full Dependencies (memory/voice/orchestrator/WShub)
- Memory API: CRUD + search endpoints with PostgreSQL persistence
- Voice API: STT/TTS endpoints
- Admin dashboard: real uptime + service status
- WebSocket chat: routes to AI-Core orchestrator pipeline
- APIAdapter bridges memory domain model to REST interface
- Smoke test: memory CRUD + session creation verified
2026-08-12 12:04:31 +08:00
Aska 4a71965686 feat(frontend): add React SPA with chat, auth, and admin pages
- Vite 6 + React 19 + TypeScript + Tailwind 4 + Zustand 5
- Login page with JWT auth
- Chat interface: MessageList, ChatInput, ChatContainer
- WebSocket hook with auto-reconnect and exponential backoff
- Sidebar with new chat button + session navigation
- Admin dashboard placeholder
- 57 modules, 242KB JS, TypeScript zero errors
2026-08-12 11:47:22 +08:00
Aska 192144563b feat(plugins): add 10 tool plugins and wire full service stack in main.go
- plugins: calculator, datetime, crypto, random, text, json, markdown
- plugins: web-search (SearXNG stub), web-fetch, file, http (stubs)
- main.go: full wiring — config→db→nats→plugins→voice→memory→
  persona→llm→context→subsession→orchestrator→gateway→ws
- 15 packages passing, 11 plugins loaded at startup
2026-08-12 11:37:36 +08:00
Aska 3b3e5a0697 feat(ai-core): add LLM orchestration engine
- model: shared data types (Message, Intent, StreamToken, LLMMessage)
- persona: YAML/Raw loading + system prompt injection
- llm: OpenAI-compatible adapter with streaming + semantic cache
- context: sliding window context store with token budgeting
- subsession: parallel provider dispatch + review message parser
- orchestrator: intent analysis pipeline (IoT/greeting/chat/memory)
- background: autonomous thinking engine
- 20+ tests, build + vet clean, ~130 total tests
2026-08-09 21:21:27 +08:00
Aska 99806fefad feat: add gateway API, voice service, WebSocket hub, and startup entry
- services/gateway/api: net/http router with JWT auth, stub login, 6 tests
- services/gateway/ws: WebSocket hub with echo support
- services/voice: STT/TTS provider chain with fallback, 6 tests
- cmd/cyrene/main.go: single binary startup, 108 tests total
- Smoke test: health check + login verified on :8080
2026-08-09 21:09:43 +08:00
Aska 81ebdb910c feat(memory): complete memory service with passing tests
- model: Memory struct with categories/decay/keywords
- repository: PG CRUD + keyword search + table migration SQL
- service: dedup (Jaccard), keyword extraction, decay formula
- 7 tests (3 DB integration + 4 unit), 96 total, vet clean
2026-08-09 20:51:01 +08:00
Aska 7295556c59 feat(services): add memory service — model, repository, service layer
- services/memory/model: Memory struct with categories and decay
- services/memory/repository: PostgreSQL CRUD + keyword search
- services/memory/service: dedup, decay, keyword extraction
- tests coming in follow-up commit
2026-08-09 20:50:01 +08:00
Aska aee8db9b3f feat(internal): add platform detection and auth packages
- internal/platform: cross-platform path detection, 6 tests
- internal/auth: JWT generation/validation + bcrypt, 8 tests
- 89 total tests, build + vet clean
2026-08-09 20:46:41 +08:00
Aska ca106f71ae feat(internal): add NATS abstraction and DB connection manager
- internal/nats: Connection interface + InProcessConn, 7 tests
- internal/db: PostgreSQL connection pool + migration runner, 5 tests
- 75 total tests, build + vet clean
2026-08-09 20:45:19 +08:00
Aska 750d594055 feat(plugin): add plugin interface system and iot-mock implementation
- internal/plugin: Plugin/IoTProvider/ToolProvider/RAGProvider interfaces
- internal/plugin: Registry with type-safe auto-detection, 11 tests
- plugins/iot-mock: 8 simulated smart home devices, 11 tests
- 63 total tests, build + vet clean
2026-08-09 20:38:37 +08:00
Aska 7943e290ec feat(internal): add foundational packages — config, logger, errors
- config: env-based loading with startup validation, 13 tests
- logger: structured slog wrapper with redaction, 10 tests
- errors: unified AppError model with HTTP mapping, 18 tests
- 41 tests total, build + vet clean
2026-08-09 20:35:42 +08:00
Aska b69b74435a docs: add comprehensive README and update project structure
- Replace placeholder README with full project overview
- All DevDocs modules completed (7 backend + 4 cross-cutting)
- Development constraints document added
2026-08-09 20:21:54 +08:00
Aska 14d5d6e555 chore: init Cyrene_v2 project structure and DevDocs
- Create directory structure for backend, frontend, deployment, tests
- .gitignore with DevDocs/ excluded from version control
- DevDocs: 00-overview, 01-cross-platform, 02-dev-constraints
- DevDocs decisions: 002-communication (NATS), 003-plugin-architecture
- DevDocs modules: ai-core analysis, gateway analysis
2026-08-09 20:18:56 +08:00
1908 changed files with 357694 additions and 2 deletions

No files matched your search

+551
View File
@@ -0,0 +1,551 @@
# ============================================
# Cyrene v2 配置文件 (.env)
# 复制为 .env 后修改。所有配置均从环境变量注入,代码内禁硬编码。
# 注意:.env 仅用于本地开发;生产用 systemd / env_file。
# 敏感信息(API Key / JWT_SECRET)一律不要写进日志。
# ============================================
# ---- 运行环境 ----
ENV=development
LOG_LEVEL=debug
TZ=Asia/Shanghai
# ---- 数据目录(裁决 12.4.2/15.4.1,design/13 §2) ----
# 两种取法都实现:默认 ./data;显式写 platform(保留字哨兵)则走平台标准数据目录
# (Windows %APPDATA%\Cyrene、macOS ~/Library/Application Support/Cyrene、Linux ~/.local/share/cyrene)。
DATA_DIR=./data
# ---- 服务器(网关,唯一对外端口) ----
# HOST 默认 0.0.0.0,暴露面由 .env 决定(本机自托管要收紧就改 127.0.0.1)。
HOST=0.0.0.0
PORT=8080
# 管理面板的安全入口路径(裁决 1.6/16.4.7,design/07 §0.2):
# 管理面板前端挂在这个路径下,默认 admin(即 /admin);想藏起来就自定义,例如
# ADMIN_PATH=my-secret/admin → 面板在 /my-secret/admin,猜 /admin 直接 404。
# 它只影响面板前端路由;REST 仍是 /api/v1/admin/*(API 不藏,安全靠 JWT + 角色门),
# 且该路径串不会出现在用户端页面里。
ADMIN_PATH=admin
# chat.image 的【解码后图像字节】上限(协同文档 G3),默认 5242880(5 MiB,与 POST /api/v1/upload 同口径)。
# 图片有「上传」与「base64 内联」两条入口,两边必须给出同一个「一张图能有多大」的答案,否则内联可绕开上传限制。
# 调大请谨慎:整段 base64 会被落库,并随每页历史(GET /sessions/{id}/messages)与每次 WS history 回放重传一遍。
# ≤0(写 0 或负数)= 用默认值 5 MiB,不是「禁用上限」——一张图能有多大只能有一个答案。
CHAT_IMAGE_MAX_BYTES=5242880
# POST /api/v1/documents 的单文件字节上限,默认 20971520(20 MiB)。
# 它与上面的图片上限【刻意解耦】:图片上限被 vision 解码层钳住,文档上限由本项目的解析与上下文预算决定;
# 合用一个数会让「调文档上限」静默改掉图片行为。
# 文档读侧(GET /api/v1/documents/{name})要 Bearer owner/admin,且一律 attachment + nosniff(永不可内联渲染)。
# ≤0(写 0 或负数)= 用默认值 20 MiB,不是「禁用上限」。
CHAT_DOCUMENT_MAX_BYTES=20971520
# ---- 文档回收(GC:引用计数 + 保留期,design/24 §3.2(配额与清理);切片 S7) ----
# 判据:**未被任何消息引用**(ref_count = 0)且最后一次被引用(从未被引用则看上传时间)
# 早于「现在 − 保留期」。动作:原件(<DATA_DIR>/uploads/documents/<存储名>)+ 抽取正文
# sidecar(<DATA_DIR>/extracted/<存储名>.txt)+ documents 表里的元数据行**三样一起删**。
# 引用计数由消息落库/编辑/删除时维护(都在同一个事务里),因此**仍被引用的文件绝不删**。
# 启动时清扫一次 + 此后按间隔定时清扫;每轮只记一条汇总(删了几件/释放多少字节,不含正文)。
# DOCUMENT_GC_INTERVAL:定时清扫间隔(Go duration,如 30m / 1h / 6h),默认 1h。
# DOCUMENT_RETENTION:保留期(Go duration),默认 168h(7 天)。
# ⚠️ 任一键写 0 或负数 = **关闭 GC**(同时关闭启动期那一次清扫,日志会写明原因)。
# `DOCUMENT_RETENTION=0` 不是「立即回收」——那种解释会让一次笔误清空未引用的附件。
DOCUMENT_GC_INTERVAL=1h
DOCUMENT_RETENTION=168h
# ---- 渠道事件留存(非消息事件 / 请求 / 未知帧;design/26 §4.4;切片 E3+E4) ----
# 目的:让**到达过适配器的每一帧**至少留下可查(逐条落行)、可计数(按平台+类型聚合)的
# 脱敏结构痕迹 —— 事故排查时「上游推过没有 / 是不是被我们过滤了」必须答得上来。
# 落库口径:T2/T3 事件与「未知类型 / 解析失败」逐条落 channel_events(30 天保留期),
# 纯遥测(心跳 / 生命周期 / 自身回声)只进 channel_event_counters(不参与 GC)。
# 摘要一律走 adapter.SanitizeNoticeText;**消息正文 / 媒体 URL / OneBot request.flag
# 等凭据绝不入库、绝不进日志**。
# CHANNEL_EVENT_RETENTION:channel_events 行的保留期(Go duration),默认 720h(30 天)。
# 它比文档保留期(168h)长:事件表的核心用途是**事后定案**,一周太短。
# CHANNEL_EVENT_GC_INTERVAL:保留期清扫间隔(Go duration),默认 1h。
# ⚠️ 上面任一键写 0 或负数 = **关闭清扫**(同时关闭启动期那一次,日志会写明原因);
# `CHANNEL_EVENT_RETENTION=0` 不是「立即清空」——那会让一次笔误抹掉唯一的取证面。
# CHANNEL_EVENT_COUNTER_FLUSH:内存计数落盘间隔(Go duration),默认 30s。
# 计数是**近似值**(crash 会丢 ≤ 一个间隔的计数,面板须如实标注 approx)。
# 该键不做「显式关闭」:写 0 或负数一律回落 30s(计数落盘不是破坏性动作)。
CHANNEL_EVENT_RETENTION=720h
CHANNEL_EVENT_GC_INTERVAL=1h
CHANNEL_EVENT_COUNTER_FLUSH=30s
# ---- 链路图本地落盘(flow_graphs 表;切片 F2,design/19 §4 P3) ----
# 每个会话**每轮收尾落一条**链路图(web / web:<uuid> / sess-backthink 三个白名单会话;
# 隔离的群聊 / 陌生人会话绝不入库)。让「刷新 / 重启后翻历史轮」有本地数据,
# 而不是图只活在进程内存里。
# FLOW_GRAPH_RETENTION:flow_graphs 行的保留期(Go duration),默认 168h(7 天)。
# 图的用途是「回看最近若干轮」,不是事后定案证据(后者是 channel_events 的 720h)。
# FLOW_GRAPH_GC_INTERVAL:保留期清扫间隔(Go duration),默认 1h。
# ⚠️ 任一键写 0 或负数 = **关闭清扫**(同时关闭启动期那一次,日志会写明原因)⇒
# 落盘的轮次图将**无限增长**;`FLOW_GRAPH_RETENTION=0` 不是「立即清空」。
FLOW_GRAPH_RETENTION=168h
FLOW_GRAPH_GC_INTERVAL=1h
# ---- LLM 用量样本保留期(llm_usage 表;切片 U1,主人裁决「2.4 落」) ----
# 每次成功模型调用都会把一条**非敏感**用量样本(token 计数 / 缓存两档 / 模型名 /
# 用途 / 耗时)异步落进 llm_usage 表,供 GET /api/v1/admin/llm/usage 与面板的
# 用量小卡聚合。**凭据与消息正文绝不入库、绝不进日志**。
# 写入是有界队列 + 单写者 + 批量事务:队列满就丢弃并限流 WARN,**绝不阻塞模型调用**。
# LLM_USAGE_RETENTION:保留期(Go duration),默认 720h(30 天)。
# 启动时清理一次 + 此后每小时清理一次(清扫间隔无 env 键:保留期才是旋钮)。
# ⚠️ 写 0 或负数 = **关闭清理**(样本全留,日志会写明「llm usage retention disabled」)。
# `LLM_USAGE_RETENTION=0` 不是「立即清空」——那种解释会让一次笔误把成本视图清空。
LLM_USAGE_RETENTION=720h
# ---- 认证 / 管理员 ----
# JWT_SECRET 必填,长度 >= 32;缺失或过短会在启动时 panic。
JWT_SECRET=change-me-to-a-random-string-at-least-32-characters
ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin12345
REGISTRATION_ENABLED=true
# 不透明 refresh token 有效期(Go duration,如 720h = 30 天);access token 固定 24h。
AUTH_REFRESH_TTL=720h
# refresh token 单次使用轮换默认开启(用旧换新、防重放);仅当客户端无法保存轮换后的
# 新 token 时才设为 true 关闭轮换。
AUTH_REFRESH_ROTATE_DISABLED=false
# ---- 数据库 ----
# SQLite 为默认(单二进制、无需外部服务)。
DB_SQLITE_PATH=./data/cyrene.db
# PostgreSQL + pgvector 为可选增强;未配置时走 SQLite。相关向量能力在 M4 接入。
# DB_PG_URL=postgres://cyrene:cyrene_pass@localhost:5432/cyrene_ai?sslmode=disable
# ---- LLM 多供应商(LLM_PROVIDER 显式声明协议:openai / anthropic,默认 openai) ----
# ⚠️ 本节是【兜底】:管理面板「LLM 模型」页保存会写入本地 models.json(默认 <DATA_DIR>/models.json,已 gitignore),
# 只要 models.json 存在且有效,运行中的 LLM 就优先用它的多厂商多模型组,并支持面板实时保存→热更新(无需重启)。
# 仅当 models.json 不存在/无效时才回退到下面的 .env。
# Provider = openai:OpenAI 兼容 /chat/completions(含 OpenAI 兼容网关,也可经它承载 Anthropic 模型,但失去原生 tool input 对象优势)
LLM_PROVIDER=openai
LLM_BASE_URL=https://api.openai.com/v1
LLM_API_KEY=sk-xxxxx
LLM_MODEL=gpt-4o
LLM_FALLBACK_MODEL=gpt-4o-mini
# 最后一道回退模型(fallback 链末端)的 token 预算上限。仅当链确有回退模型、
# 且该模型组未在 models.json 设 maxTokens、请求也未显式给 MaxTokens 时生效;
# 0 = 关闭该兜底(改用适配器默认,如 anthropic 8192)。每模型组预算见 models.json 的 maxTokens。
LLM_FALLBACK_MAX_TOKENS=8192
# 推理开关的【提供商形状】声明(裁决 03.4.2 / 08.4.1):auto(默认,= 留空)/ deepseek /
# openai / vllm / dashscope。形状决定把思考开关下发改成哪个字段(如 vllm =
# chat_template_kwargs.enable_thinking、dashscope = 顶层 enable_thinking),必须按端点真实形状
# 声明——发错形状会被上游拒。留空/auto = 按模型名与 base URL 推断厂商,形状有歧义时一个字段都不发。
# ⚠️ 本键只作用于 .env 合成的模型组:存在有效 models.json 时,以该组的 reasoning.kind 为准
# (面板可编辑,见「LLM 模型」页)。
LLM_REASONING_KIND=
# ---- LLM 视觉模型(视觉/OCR 工具用的模型;与「主模型能否收图」无关) ----
# 语义(v0.53 修正):主模型自己能不能收图,看 models.json 生效组的 multimodal 字段(面板可勾选),
# 不是看这里;本节的模型【只供 vision / ocr 工具】调用。都不配置时工具回落用主模型(LLM_MODEL)。
# .env 侧的等价键是下面的 LLM_MULTIMODAL——它同样只作用于 .env 合成的模型组。
# LLM_VISION_MODEL=gpt-4o-mini
# OCR 工具单独用另一个模型(可选):留空 = 复用 LLM_VISION_MODEL。
# LLM_OCR_MODEL=qwen-vl-ocr
# 声明【主模型自身】能否直接接收图片(= models.json 组 multimodal 的 .env 等价物):
# true = 图片直接进主模型上下文(省掉工具那一轮模型调用、不做有损文本摘要);
# 留空 / false(默认)= 图片交给 vision / ocr 工具,由工具返回文本描述。
# 默认 false 与引入本键之前【逐字节一致】——扁平 env 块只有这一个键能声明收图能力,
# 而声称端点可能不具备的能力,比把图交给视觉/OCR 工具更糟(design/06 §0 首条);
# 绝不凭 base URL / 模型名猜测端点是否收图。
# ⚠️ 只作用于 .env 合成的模型组:存在有效 models.json 时以该组的 multimodal 为准(面板可勾选)。
# ⚠️ 非法值(非 bool,如 maybe)= 启动即报错,不静默回退。
# LLM_MULTIMODAL=false
# 声明该端点的【模型上下文窗口】(token)。窗口是上下文压缩预算的推导依据:
# 未设置 CONTEXT_MAX_TOKENS 时 budget = min(窗口 × CONTEXT_BUDGET_RATIO, 窗口 − 输出保留)。
# ⚠️ 本仓没有任何 provider 侧的窗口发现代码(GET /models 的自动发现另立设计),窗口只能声明:
# 不声明就回落 131072(128 Ki)并记一条 WARN,默认模型的 1M 窗口因此吃不到。
# 它必须与 models.json 组的 contextWindow 一致(那一处是面板一等来源,本键只给 .env 合成的组播种)。
# 负数 = 启动即报错。
# LLM_CONTEXT_WINDOW=1048576
# Provider = anthropic:Anthropic Messages /v1/messages(tool_use.input 为结构化对象,工具循环更稳)
# ANTHROPIC_BASE_URL=https://api.anthropic.com
# ANTHROPIC_API_KEY=sk-ant-xxxxx
# ANTHROPIC_MODEL=claude-sonnet-4-20250514
# ANTHROPIC_FALLBACK_MODEL=claude-haiku-3-5
# ⚠️ 若 ANTHROPIC_BASE_URL 指向 DeepSeek(https://api.deepseek.com/anthropic),模型名须用 DeepSeek 当前名称
# (deepseek-flash / deepseek-v4-pro):不认识的名称(如 claude-*)会被服务端【静默映射成 deepseek-flash】,
# 配置错了不会报错。旧的 deepseek-chat / deepseek-reasoner 已于 2026-07-24 停用。
# ---- LLM 本地第三级(降级链末端;默认关,裁决 03.4.3) ----
# 降级链为「主 → 备 → 本地」;本地这一级默认关闭(当前设备跑本地模型困难),链保持两级。
# 开启需要三个条件同时满足:LLM_LOCAL_ENABLED=true + LLM_LOCAL_BASE_URL + LLM_LOCAL_MODEL;
# 只开开关不配端点/模型会被记 WARN 并跳过(不会半配成一个只会失败的链尾步)。
LLM_LOCAL_ENABLED=false
# 本地端点协议:openai(llama.cpp / ollama / vLLM 兼容)或 anthropic;留空 = openai。
# LLM_LOCAL_PROVIDER=openai
# LLM_LOCAL_BASE_URL=http://127.0.0.1:8080/v1
# 本地服务通常不需要 key;留空即可。
# LLM_LOCAL_API_KEY=
# LLM_LOCAL_MODEL=qwen3-4b-instruct
# ---- 记忆嵌入(可选,真语义) ----
# 不配置时使用内置 n-gram / FTS 向量(dim=256)。配置后必须真实走语义路径。
# EMBEDDING_API_URL 是【完整 /embeddings 端点】(不是 base)——例:Ollama / llama.cpp = http://127.0.0.1:11434/v1/embeddings
# ⚠️ 回环地址请写具体的 IPv4 字面量 127.0.0.1,**不要写 localhost**:本机 embedding 服务
# (llama.cpp / ollama)默认只绑 IPv4 回环,而 localhost 会先解析到 IPv6 ::1 —— 那里没有
# 监听,轻则多一次无效拨号(实测 localhost 约 0.2s,127.0.0.1 约 0.001s),重则在「::1 被
# 静默丢弃」的环境里等到系统连接超时,白白吃掉整条入库审查的预算。
# (客户端已对 localhost / 0.0.0.0 / 空主机做兜底规范化,但直接写对更省事、更好排查。)
# EMBEDDING_API_URL=http://127.0.0.1:11434/v1/embeddings
# EMBEDDING_API_KEY=
# EMBEDDING_MODEL=Qwen3-Embedding-0.6B # 推荐(Q8_0 ~639MB、dim=1024、ModelScope 国内可下)
# ---- IoT 设备提供者 ----
# mock = 模拟智能家居设备(开发测试用);none = 禁用 IoT。
IOT_PROVIDER=mock
# ---- 语音服务 ----
TTS_PROVIDER=edge-tts
STT_PROVIDER=dashscope
# DASHSCOPE_API_KEY=sk-xxxxx # ⚠️ 必填才能接通阿里百炼语音;未填时走降级链 (silent)。绝不打日志。
# --- 阿里百炼 DashScope 语音(STT + TTS 主链路)---
# DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/api/v1
# VOICE_STT_MODEL=qwen-audio-3.0-asr-flash
# VOICE_STT_LANGUAGE=zh
# IM 入站语音转写的超时(秒,默认 20):桥接器会为这次调用举着一个入站并发槽,
# 因此上游卡住不能拖死整条流水线。≤0 或未配置时用桥接器默认值 20s。
# 只影响 IM 渠道的入站语音;Web 客户端的语音输入不受它约束。
# VOICE_STT_TIMEOUT_SEC=20
# 已实测:cosyvoice-v3.5-plus / cosyvoice-v2 走 HTTP SpeechSynthesizer 会回 418(引擎拒绝);
# 可用组合为 cosyvoice-v3-flash + 对应 v3 音色(longxiaochun_v3 龙小淳·女助手等)。
# 各模型音色不可混用(v2 名如 longxiaochun 用于 v3 模型会 418)。
# VOICE_TTS_MODEL=cosyvoice-v3-flash
# VOICE_TTS_VOICE_ID=longxiaochun_v3
# VOICE_TTS_FORMAT=wav
# VOICE_TTS_SAMPLE_RATE=24000
# 情绪/语气指令(TTS instruction):只对「复刻/设计音色」生效(可任意指令,如
# "语气开朗俏皮,像女朋友撒娇"),系统音色 longxiaochun_v3 必须用固定格式故不传。
# 长度≤100 字符(汉字记 2)。也可在 POST /api/v1/audio/speech 用 instruction 按请求覆盖。
# VOICE_TTS_INSTRUCTION=语气开朗俏皮,像女朋友撒娇
# 声音复刻前缀(create_voice 的 prefix,≤10 字母/数字,空则用 cyrene)。target_model
# 固定为 VOICE_TTS_MODEL(须与合成模型一致)。
# VOICE_CLONE_PREFIX=cyrene
# 声音复刻(响昔涟音色):先用参考音频(公网 URL)调 POST /api/v1/audio/voice-clone
# 建自定义音色,再把返回的 voice_id 填到 VOICE_TTS_VOICE_ID。
# ⚠️ 建音色必须公网可访问的参考音频 URL(create_voice 只收 input.url,不收 base64);
# 待主人提供参考音频公网 URL 方能真建音色。
# ---- 网络搜索(web-search 工具) ----
# WEB_SEARCH_PROVIDER = 选谁当搜索后端。可选:searxng(默认,本机兜底)/ zhipu(智谱
# BigModel)/ aliyun_iqs(阿里云 IQS)/ bocha(博查)/ volcengine(火山·豆包搜索)/
# baidu_qianfan(百度千帆)。**未配置的家不会出现在可用清单里**(fail-closed)。
# ⚠️ 指向未登记(如本期未做的 tavily)或未配置(缺 key)的名字 ⇒ **启动期报错**,
# 绝不静默回落到兜底;写空串 / off / none ⇒ **web-search 不注册**,模型看不见它。
WEB_SEARCH_PROVIDER=searxng
# WEB_SEARCH_FALLBACK = 降级链(逗号分隔,从左到右试;空 = 只用手选的那一个)。
# 只在该家「不可用」(无 key / 鉴权失败 / 限流 / 额度 / 网络)时前进一格;结果里会
# 如实标注**实际应答**的 provider,绝不拿兜底冒充主力。空结果**不**触发前进。
# WEB_SEARCH_FALLBACK=zhipu,bocha,searxng
# WEB_SEARCH_MAX_RESULTS = 单次检索返回条数上限(**播种层**的键:面板文件一旦落盘,
# 就以 $DATA_DIR/config/plugins/web-search.json 的 maxResults 为准,改面板即生效、不用重启)。
# 默认 10;provider 自身的条数上限(如博查 50、IQS 固定 10)优先于本键的下限。
WEB_SEARCH_MAX_RESULTS=10
# WEB_SEARCH_ENDPOINT = **searxng provider** 的端点(SearXNG 兼容,GET + format=json)。
# 默认值 http://127.0.0.1:38471 是**本机兜底**:scripts/searxng/settings.yml 就是它的
# 配套配置(docker 起法见该文件头注释),无需任何第三方 API key、开箱即用 —— 默认指向
# 本机而不是某个云搜索服务,原因就在这。它只在 WEB_SEARCH_PROVIDER=searxng 时起作用。
WEB_SEARCH_ENDPOINT=http://127.0.0.1:38471
# 显式关闭:把上面这行写成空串 / off / none(大小写不敏感)⇒ **web-search 不注册**,
# 模型根本看不见它(不给一把一调就报错的锤子)。值不是 http(s) 绝对地址时同样不注册,
# 启动日志会有一条带键名的 WARN 说明原因(绝不静默半开)。
# WEB_SEARCH_ENDPOINT=off
# WEB_SEARCH_TIMEOUT = 单次搜索请求的超时(Go duration,如 20s)。
# 不配置时**沿用 io 包既有超时(15s)** —— 这里刻意不写第二个默认值。
# 它只影响 web-search 一次调用,不改 http / web-fetch 用的那个共享客户端。
# ⚠️ 降级链下它是**每一次上游尝试**的预算(主家超时后备家仍有自己的份额)。
# WEB_SEARCH_TIMEOUT=20s
#
# ---- 各搜索后端独立一组键(未配置的家不出现;密钥绝不进日志/响应/提示词)----
# 智谱 BigModel(Bearer;官方定价已确证:Search-Std 0.01 元/次起)。
# 申请 key:https://open.bigmodel.cn
# WEB_SEARCH_ZHIPU_API_KEY=<your-zhipu-api-key>
WEB_SEARCH_ZHIPU_BASE_URL=https://open.bigmodel.cn
# 阿里云信息查询服务 IQS(GET + X-API-Key,免签名免 SDK;默认返回 10 条且不可调)。
# 申请 key:https://www.aliyun.com/product/iqs
# WEB_SEARCH_ALIYUN_IQS_API_KEY=<your-aliyun-iqs-api-key>
WEB_SEARCH_ALIYUN_IQS_BASE_URL=https://cloud-iqs.aliyuncs.com
# 博查 Bocha(POST + Bearer;中文优先)。默认域名取官方文档口径 api.bocha.cn
# (官网首页样例仍用 api.bochaai.com,两域关系官方未说明,可在此键切换)。
# 申请 key:https://open.bocha.cn
# WEB_SEARCH_BOCHA_API_KEY=<your-bocha-api-key>
WEB_SEARCH_BOCHA_BASE_URL=https://api.bocha.cn
# 火山引擎·豆包搜索 SearchInfinity(Bearer;含每月 500 次免费额度,官方原文确证)。
# 申请 key:https://console.volcengine.com/search-infinity/api-key
# WEB_SEARCH_VOLCENGINE_API_KEY=<your-volcengine-api-key>
WEB_SEARCH_VOLCENGINE_BASE_URL=https://open.feedcoopapi.com
# 百度·千帆 AI 搜索(Bearer;标准版 0.036 元/次,每月免费 1500 次,官方原文确证)。
# 申请 key:https://console.bce.baidu.com/qianfan/
# WEB_SEARCH_BAIDU_QIANFAN_API_KEY=<your-baidu-qianfan-api-key>
WEB_SEARCH_BAIDU_QIANFAN_BASE_URL=https://qianfan.baidubce.com
# ---- 后台思考 ----
# ⚠️ 未接线(全仓无消费者),配了不生效;是否接线待主人拍板
# (DevDocs/audit/07-backthink-automation-iot.md §1-17)。
ENABLE_BACKGROUND_THINKING=true
# 自主思考心跳周期(秒)。默认 900(15 分钟一次);数值越小越频繁打扰。
THINK_INTERVAL_SEC=900
# 静默时段内的心跳周期(秒)。默认 3600(1 小时):当前时刻落在安静时段
# (QUIET_START_HOUR–QUIET_END_HOUR)内时,周期性心跳改用该间隔降频,窗外仍用
# THINK_INTERVAL_SEC;≤0 = 关闭降频(回落普通间隔,不是「半夜不思考」)。
# ⚠️ 只作用于周期性心跳:事件驱动(安全域/急性事件)照旧立刻触发、穿透安静时段。
THINK_QUIET_INTERVAL_SEC=3600
THINK_SILENCE_TIMEOUT_SEC=120
# 主动推送频率(design/12 §6):窗口内最多 PushLimit 次;推后冷却 Cooldown 秒;
# 同一风味(问候/提醒/分享/关心)在 KindWindow 秒内最多一次;主人最近消息在
# RecentChatCooldown 秒内则冷避不推。收敛默认值:1 次 / 60 分钟 + 推后 30 分钟冷却。
THINK_PUSH_LIMIT=1
THINK_PUSH_WINDOW_SEC=3600
THINK_PUSH_COOLDOWN_SEC=1800
THINK_RECENT_CHAT_COOLDOWN_SEC=600
THINK_KIND_WINDOW_SEC=7200
# 安静时段(本地时钟小时,默认 23:00-07:00;两端相等且非 0 则关闭)。
# 同时用于 backthink 后台思考与 channelbridge 公共平台安静时段轻思考(design/12 §6 策略五)。
QUIET_START_HOUR=23
QUIET_END_HOUR=7
# ---- 记忆整理与去重(design/23;主题 N · 长期记忆维护) ----
# **低峰定期后台任务**(不是会话内派 subagent):逐分区扫描 → 相似度**召回**候选对 →
# 「事实集合包含关系」判定 → 规则否决 → 落 `memory_tidy_candidates` 建议队列。
# 第一版**只产建议**:绝不自动合并/删除/改写任何一条记忆,也**不推送**、不进 chat、
# 不碰主会话上下文(跑在专属隔离会话 sess-memorytidy,与 backthink 同一范式)。
# 每轮汇总量(扫描/候选/否决原因分布)进日志与 `memory_tidy_runs` 表,绝不静默。
# 关闭开关:写 false 即完全不跑(日志会写明)。
MEMORY_TIDY_ENABLED=true
# 整理周期(秒),默认 86400 = 每天 1 次。它是**下限**:实际在低峰窗口起点跑,
# 因此真实间隔取「低峰窗口每天一次」与本值中较长者。
MEMORY_TIDY_INTERVAL_SEC=86400
# 低峰窗口(本地钟点),默认与 backthink 一致的 23:00-07:00(窗口可跨午夜;
# 两端相等且非 0 = 关闭窗口,改为按上面周期跑)。整理只在窗口内开跑。
MEMORY_TIDY_QUIET_START_HOUR=23
MEMORY_TIDY_QUIET_END_HOUR=7
# 候选召回的余弦**召回门**,默认 0.80。⚠️ 它只决定「哪些对值得看一眼」,
# **不是**合并判据 —— 能否合并由「事实集合包含关系(A ⊆ B)+ 规则否决」决定
# (实据:必合对 0.8330 与必不合对 0.7100 只差 0.12,单阈值双向失效)。
MEMORY_TIDY_RECALL_THRESHOLD=0.80
# 每分区每轮的扫描上限(默认 5000)。
MEMORY_TIDY_SCAN_LIMIT=5000
# 每分区每轮的**候选**上限,默认 200。⚠️ 它是**每分区**的量,不是全库总量。
# 2026-10-06 由 20 抬到 200:首轮真跑时四个分区同时打满旧值,候选数(77)由上限
# 而不是规则决定;命中上限时响应里的 `truncated` 会带 `<分区>(candidates)`。
MEMORY_TIDY_CANDIDATE_LIMIT=200
# ⚠️ 2026-10-06 起(主人红线):**`.env` 冻结为「只保证最小化功能可跑」的引导面** ——
# **新旋钮一律不再进 `.env`**(本块此前收过的「两两比对上限」与「入库前审查」四键**已撤出**:
# 它们有代码默认值 2000 / true / 256 / 200 / 20,真正入口是 `$DATA_DIR/config/**` + 面板,
# 迁移切片已排队)。规矩全文见 `design/13 §2.1`;`AGENTS §10` 的 E6 已按此改写。
# ---- 唤醒式能力渐进(capacity 意图判定,design/14 §2.2) ----
# 总开关(默认 false):关闭时 loop 向模型暴露【全部】已注册工具,行为与接线前逐字节一致;
# 置 true 才按判定出的状态裁剪可用工具集(判定只在 loop 外围,不阻塞 Invoke)。
CAPACITY_ENABLED=false
# 唤醒阈值(默认 0.6):判定置信度低于它的状态强制回落到角色默认态(保守、不打扰);
# 角色 YAML 的 awakening.minConfidence 为正数时覆盖本全局值。
# 写 0 = 用代码内默认 0.6,不是「阈值归零」。
CAPACITY_MIN_CONFIDENCE=0.6
# 状态 → 允许工具名的 JSON 映射,例如
# {"quiet":["terminal","file"],"awake":["terminal","file","web-search"],"full":null}
# 缺失的状态用内置默认集(quiet = terminal/file/knowledge/text/transcode/capability_list;
# awake = quiet + web-search/web-fetch/http;full 不限制);null(如 "full")= 不限制。
# 解析失败记 WARN 并回落到内置默认。
# ⚠️ 自己写映射时,请把你要用的新能力一并列进去:不在允许集里的工具在
# CAPACITY_ENABLED=true 时会从模型可见工具面里消失(Invoke 仍放行,见 design/14 §4)。
# CAPACITY_TOOL_SETS=
# L3 = 模型辅助的意图分类:仅当 L1 启发式与 L2 上下文仍拿不准时介入一次轻量模型调用。
# 默认关闭(纯启发式够日常,且该层要多花一次模型调用);关闭时该层从不运行,
# L1/L2/L4 的行为与接线前逐字节一致。
CAPACITY_L3_ENABLED=false
# L3 判定结果的缓存 TTL(Go duration,如 5m):同一段回合文本在此窗口内复用判定,避免每轮都问模型;
# 留空 = 5m;0 或解析不出 = 不缓存。
CAPACITY_L3_CACHE_TTL=5m
# ---- 子代理(orchestration,design/05 §7) ----
# 单个子代理整次运行的时限(秒)。默认 600 = 10 分钟(裁决 02.4.4:agent_timeout
# 可配置);模型派发时可用 subagent_spawn 的 timeout_seconds 参数按调用覆盖。
SUBAGENT_TIMEOUT_SEC=600
# ---- 护栏与上下文预算(design/04 §1.3 / §5、附录 A.3) ----
# 上下文溢出压缩预算(token,离线估算值)。会话估算上下文超过它时,更早的轮次被摘要成
# 一条前导摘要消息,近期尾部(含尾部工具结果)原样保留。三种取值:
# · 未设置(默认,即把本行注释掉)= 按模型窗口推导:
# budget = min(窗口 × CONTEXT_BUDGET_RATIO, 窗口 − 输出保留)
# 输出保留 = max(32768, 本组 maxTokens);窗口未声明 ⇒ 回落 131072 并记一条 WARN。
# · > 0 = 显式覆盖(原样使用)
# · 0 / 负数 = 关闭压缩(行为与本条接线前逐字节一致)
# ⚠️ 旧模板里的 CONTEXT_MAX_TOKENS=32768 取自「gpt-4o 128K 窗口的四分之一」,那个前提已失效
# (默认模型窗口 1M)。若你的 .env 里还留着它,它就是【显式覆盖 32768】,会盖掉按窗口推导
# (1M 窗口 ⇒ 699050 token)——要吃到推导,把那一行注释掉。固定低阈值会既丢上下文、又因反复
# 压缩作废 KV 缓存前缀而反复烧钱(附录 A.2)。
# CONTEXT_MAX_TOKENS=
# 推导比例:预算占窗口的比例,主人 2026-10-04 裁决取 1/2–3/4 之间,默认 2/3。
# 写法:小数(0.5)或分数(1/2)都接受;非法值 = 记一条 WARN 并回落 2/3。
# CONTEXT_BUDGET_RATIO=2/3
# 单份附件注入模型可见上下文的字符上限(sidecar 仍存全文,只受 8 MiB 安全上限约束)。
# 留空 = 按推导出的预算算:推导预算的 1/8(按一个回合最多 5 个附件留余量),下限 64K 字符。
# 它决定「每回合往上下文里放多少」,也因此决定清单行给的续读游标从哪里开始。
# ⚠️ 它必须**不超过**下面 GUARDRAIL_MAX_INPUT_CHARS 生效的输入上限 —— 严格说是
# 「单份注入 × 单回合附件数(最多 5)」不超过上限,否则满载附件的回合会在装配完之后
# 被输入护栏整回合拦死(input_length_exceeded)。改了本键就顺手核对那一项。
# DOCUMENT_MAX_INJECT_CHARS=
# 请求侧字符上限(输入护栏跑在**组装完成的请求**上:系统提示词 + 记忆块 + 整段对话 + 工具结果)。
# 三种取值:
# · 未设置 / 0(默认)= 由推导出的上下文预算派生:
# 上限 = max(262144, 推导预算 token 数 × 4)
# 那个 4 与附件注入预算用的是**同一个换算常量**(loop.CharsPerToken = 4 字符 ≈ 1 token)。
# 上限量的是整段装配请求,而注入预算是单份的,两边必须是同一套换算,否则**多附件**回合
# 会被拦死(G2b 修的正是这个:旧口径按 1 字符 ≈ 1 token 兑,2 份满额注入就顶到上限):
# 1M 窗口 ⇒ 预算 699050 token ⇒ 上限 2796200 字符;
# 单份附件注入预算 349525 字符 ⇒ 5 份(单回合附件上限)满额注入合计 1747625 字符,
# 关系不变量「单回合全部附件的注入预算合计 ≤ 输入上限」成立,「分享大文件」才走得通。
# 预算关闭(CONTEXT_MAX_TOKENS=0)或预算很小时,上限落回 262144。
# · > 0 = 显式覆盖(原样使用;压到「单份注入 × 单回合附件数」之下会让多附件回合被拦,
# 届时启动日志里有 WARN)
# · < 0 = 非法:记一条 WARN 并回落派生值(绝不静默生效)
# 它只动**输入**侧;输出上限(65536 字符)防的是模型输出失控,与上下文预算无关,不开放配置。
# 敏感信息(API key / bearer token / 密码 / 私钥)为阻断档,同样是代码内定的安全默认值。
# (取值与关系不变量的落点在 cmd/cyrene/wire_guardrail.go)
# GUARDRAIL_MAX_INPUT_CHARS=
# 内容策略关键词表(逗号分隔,大小写不敏感的子串匹配)。留空 = 不挂关键词护栏
# (项目不内置任何词表,只有你显式声明过的词才会被拦)。
# GUARDRAIL_KEYWORDS=
# ---- 主人身份说明书注入范围(design/11 §0 红线) ----
# 主人身份说明书(owner.yaml identityFile)注入范围:owner_only(默认,仅主人会话) | always(所有会话,含群聊/陌生人,仅自测) | off。
# owner_only 按渠道层强类型 actor 元数据判定;非法值回退 owner_only 并记 WARN(绝不静默放行)。
# 它只决定那段知识进不进该会话的上下文,不参与任何身份/权限判定(身份始终看 actor 元数据 + 白名单)。
OWNER_IDENTITY_INJECTION=owner_only
# ---- 推送出口(D1,主动推送的两级投递)----
# 主动消息在 WS/IM 投递之外,异步镜像到「主人自建统一推送服务」(主通道)。
# 该服务不可达/未配时,日志注明「个推(Getui)回退待接入」——当前无 Getui SDK,不伪造投递。
# 填写自建服务的 webhook 地址并置 ENABLE=true 即启用;token 只走 Authorization 头,不落日志。
PUSH_EXPORTER_ENABLED=false
# PUSH_EXPORTER_URL=https://push.example.com/webhook
# PUSH_EXPORTER_TOKEN=change-me
# PUSH_EXPORTER_TIMEOUT_SEC=5
# 个推(Getui)回退字段:预留占位,SDK 未接入,当前不生效。
# GETUI_APP_ID=
# GETUI_APP_KEY=
# GETUI_MASTER_SECRET=
# GETUI_BASE_URL=
# ---- WebSocket ----
WS_MAX_CONNECTIONS=1000
SESSION_IDLE_TIMEOUT_MIN=30
# ---- MCP 服务器(可选,JSON 数组字符串;每台 server 含 name/command/args/url/enabled) ----
# stdio 子进程示例:
# MCP_SERVERS_JSON=[{"name":"calc","command":"mcp-calc","args":["--stdio"],"enabled":true}]
# SSE 端点示例:
# MCP_SERVERS_JSON=[{"name":"weather","url":"http://localhost:3001/sse","enabled":true}]
# ⚠️ 旧键(未接线,配了不生效,勿用):`MCP_NAME` / `MCP_COMMAND` / `MCP_ARGS` / `MCP_URL` /
# `MCP_TRANSPORT` / `MCP_ENABLED` 这类「单 server 扁平 env 模式」(含带编号的
# `MCP_0_*`、`MCP_1_*` …)只在 internal/capability/mcp 的单测里被解析;生产装配
# (cmd/cyrene/mcp.go)只读 `MCP_SERVERS_JSON`。`MCP_API_KEY` / `MCP_ENV` 更连那套
# 解析都不读(结构体上的残留 tag)。要接 MCP server 请只用上面的 MCP_SERVERS_JSON。
# ---- 本地执行沙箱(exec/terminal 工具;默认关闭,授权放权) ----
# 只有 Enabled=true 时 agent 才可执行命令;仍需命令白名单与根目录边界。
# EXEC_SANDBOX_ENABLED=false
# EXEC_SANDBOX_COMMANDS=git,ls,cat,pwd
# EXEC_SANDBOX_ROOTS=./data
# EXEC_SANDBOX_ENV=PATH
# ---- 跨域白名单 ----
ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000
# ---- KOOK(KaiHeiLa)渠道 ----
# 单账号走环境变量;多账号走下方 CHANNELS_JSON。
# .env 仅提供【必要连接兜底】;接收模式 / webhook 回调路由 / verify_token / encrypt_key 等
# 业务·高级项一律在管理面板「渠道管理」配置(写入 channels.json,热更新),不落 .env。
# CHANNEL_KOOK_TOKEN 为 KOOK 开发者中心机器人 token(敏感,勿写日志/提交)。
# CHANNEL_KOOK_BASE_URL 可选,默认 https://www.kookapp.cn/api/v3。
# CHANNEL_KOOK_TOKEN=
# CHANNEL_KOOK_BASE_URL=
# ---- Telegram 渠道 ----
# 同 KOOK 口径:单账号走环境变量、多账号走下方 CHANNELS_JSON,业务·高级项一律在面板配置。
# bot_token 为空时该账号【完全不存在】(空占位符绝不占用裸平台名),所以不用的平台留空即可。
# CHANNEL_TELEGRAM_BOT_TOKEN 为 BotFather 签发的 token(敏感,勿写日志/提交)。
# CHANNEL_TELEGRAM_WEBHOOK_URL 可选:填公网 HTTPS 回调地址走 webhook 投递;
# 留空则适配器改用 getUpdates 长轮询(本地开发无需公网地址)。
# CHANNEL_TELEGRAM_BOT_TOKEN=
# CHANNEL_TELEGRAM_WEBHOOK_URL=
# ---- OneBot v11(QQ)渠道 ----
# 四端由 transport(ws|http) × mode(client|server) 组合决定(裁决 1.4 / design/08 §4):
# ws+client Cyrene 拨号 OneBot,收发同一 WS
# ws+server OneBot 反连 Cyrene(需 LISTEN_ADDR),收发同一 WS
# http+client Cyrene → OneBot(纯出站,POST {API_BASE_URL}/{action})
# http+server OneBot → Cyrene(纯入站,POST 到 LISTEN_ADDR + REPORT_PATH)
# 该平台【无 token 门槛】:一个键都不配也会合成账号,是否真拨号由渠道桥的 configured 闸门
# 决定(server 模式要 LISTEN_ADDR 非空,client 模式要 REMOTE_URL 非空)。
# transport 留空或未知值一律回落 ws;mode 默认 server(即不给任何新配置时行为与改造前一致)。
# CHANNEL_QQ_OBV11_TRANSPORT=ws
# CHANNEL_QQ_OBV11_MODE=server
# ⚠️ LISTEN_ADDR 落在 AGENTS §9 登记的唯一「可开监听端口」豁免(OneBot 反连):
# 必须由配置显式给出,为空则 server 模式 Connect 直接失败、绝不开端口。
# CHANNEL_QQ_OBV11_LISTEN_ADDR=127.0.0.1:5700
# ws+client 的拨号端点(ws:// 或 wss://)。
# CHANNEL_QQ_OBV11_REMOTE_URL=
# OneBot HTTP API 基址(如 http://127.0.0.1:3000):http+client 必填;http+server 可选,
# 给了才有出站(回消息)能力。必须显式给出,不从 REMOTE_URL 推导。
# CHANNEL_QQ_OBV11_API_BASE_URL=
# http+server 接收上报的路径,默认 /(缺前导 / 会自动补齐)。
# CHANNEL_QQ_OBV11_REPORT_PATH=/
# 可选 OneBot access_token,四端通用:出站走 Authorization: Bearer;http+server 入站侧
# fail-closed——配了则 Bearer 或 ?access_token= 不匹配回 403,为空则放行(勿写日志/提交)。
# CHANNEL_QQ_OBV11_ACCESS_TOKEN=
# 机器人自己的 QQ 号(已知时填写,用于识别自消息)。
# CHANNEL_QQ_OBV11_SELF_ID=
# ---- 通用 Webhook 渠道(入站专用) ----
# 回调由网关挂在上面的唯一端口上,不新开监听。全部字段可选且都有可用默认值,
# 空配置也是一个能接请求的端点;该平台无 token 门槛,configured 闸门对 webhook 恒放行。
# ENDPOINT_PATH 是该适配器在网关上的路由,默认 /webhook。
# CHANNEL_WEBHOOK_ENDPOINT_PATH=/webhook
# CHANNEL_ID / CHANNEL_NAME 只用于向 ListChannels 报告该渠道(留空 = 不预注册渠道)。
# CHANNEL_WEBHOOK_CHANNEL_ID=
# CHANNEL_WEBHOOK_CHANNEL_NAME=
# ⚠️ SECRET 非空才对请求体做 HMAC-SHA256 验签;留空 = 【关闭验签】,任何能访问该路径的请求
# 都会被当消息收下。对公网暴露时务必填写(敏感,勿写日志/提交)。
# CHANNEL_WEBHOOK_SECRET=
# 承载签名的请求头名,默认 X-Webhook-Signature。
# CHANNEL_WEBHOOK_SIGNATURE_HEADER=X-Webhook-Signature
# ---- 微信 iLink 渠道 ----
# bot_token 为空时该账号【不存在】(同 Telegram),configured 闸门要求它非空才连接。
# CHANNEL_WEIXIN_ILINK_BOT_TOKEN 是扫码登录得到的长期 bearer token:非空即跳过二维码流程
# (敏感,勿写日志/提交)。
# CHANNEL_WEIXIN_ILINK_BOT_TOKEN=
# BOT_ID = 登录确认返回的 ilink_bot_id;OWNER_ID = 已绑定主人的 ilink_user_id
# (用于把该渠道的主人身份对上:身份判定仍只看渠道层 actor 元数据,不看提示词)。
# CHANNEL_WEIXIN_ILINK_BOT_ID=
# CHANNEL_WEIXIN_ILINK_OWNER_ID=
# API 根地址与媒体 CDN 根地址;留空用协议默认值:
# https://ilinkai.weixin.qq.com 与 https://novac2c.cdn.weixin.qq.com/c2c。
# CHANNEL_WEIXIN_ILINK_BASE_URL=
# CHANNEL_WEIXIN_ILINK_CDN_BASE_URL=
# ---- IM 多账号 / 黑白名单(可选,design/16 §4.6 §5-6) ----
# ⚠️ 本节是【兜底】:管理面板「渠道管理」页保存会写入本地 channels.json(默认 <DATA_DIR>/channels.json,已 gitignore),
# 只要 channels.json 存在且有效,运行中的渠道 adapter 就优先用它的多平台账号配置,并支持面板实时保存→热更新(无需重启)。
# 仅当 channels.json 不存在/无效时才回退到下面的 CHANNELS_JSON 与 CHANNEL_<NAME>_*。
# 业务·高级项(接收模式 receive_mode / webhook 回调路由与 verify_token、encrypt_key / 黑白名单 / 限流)
# 一律在管理面板「渠道管理」配置:写 channels.json、热更新;.env 仅作无文件时的必要连接回退(token/base_url 等)。
# 单账号仍走上面 CHANNEL_<NAME>_* 环境变量;这里提供独立的 JSON 注册表,
# 支持同一平台配多个接入配置(多账号),并配群聊/私聊黑白名单(代码层拦截,
# 被拉黑/不在白名单的群或用户消息不进 LLM,且有审计)。形如:
# CHANNELS_JSON={"telegram":[{"id":"conf0","config":{"bot_token":"..."},"denyUsers":["12345"],"denyGroups":["-100"]}]}
# 每个 account 字段:id(平台内唯一)、config(平台特有凭据 map)、
# allowGroups/denyGroups(群黑白名单,allowGroups 非空则成为白名单)、
# allowUsers/denyUsers(私聊用户黑白名单)。未配置时按单账号 env 路径处理。
# 群/总限流与总上限(可选,design/12 §6 策略三/四,公共平台防刷屏/最小化打扰):
# groupMsgPerMin 群聊每分钟上限(0=用默认6,-1=不限,正整数=该值)
# directMsgPerMin 非主人(陌生人)私聊每分钟每发送者上限(0=用默认6,-1=关闭,正整数=该值)
# msgPerMin 非主人消息每分钟总上限(>0 生效,0/-1=不限)
# maxMessages 待处理上限,接近上限时由审查模型剔除低价值消息(0=用默认50,-1=不限,正整数=该值)
# 旧账号不配这些字段默认走「保护性默认/不限」,保持向后兼容。
# CHANNELS_JSON=
+69
View File
@@ -0,0 +1,69 @@
name: CI
on:
push:
branches: [main, dev]
pull_request:
branches: [main, dev]
jobs:
backend:
name: Backend (build / vet / test / cross-compile)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.26"
# `go:embed` 需要 internal/webui/dist 存在(前端先构建并拷贝)。
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Build frontend (produces internal/webui/dist)
working-directory: frontend/web
run: |
npm ci
npm run build
- name: go build
run: go build ./...
- name: go vet
run: go vet ./...
- name: go test (with coverage)
run: go test ./... -count=1 -coverprofile=coverage.out
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage.out
- name: Cross-compile matrix
run: |
set -e
for t in "linux amd64 " "linux arm64 " "linux arm 7" "darwin amd64 " "darwin arm64 " "windows amd64 " "windows arm64 "; do
set -- $t
echo "== building $1/$2/$3 =="
GOOS=$1 GOARCH=$2 GOARM=$3 go build ./...
done
frontend:
name: Frontend (tsc + vite build)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
- name: Build
working-directory: frontend/web
run: |
npm ci
npm run build
+69
View File
@@ -0,0 +1,69 @@
# 开发设计文档(本地参考,不入库)
DevDocs/
# 环境变量与密钥
.env
# 编辑器/工具生成的密钥备份(.env.bak / .env.old / .env.prod…)一律忽略,只放行模板
.env.*
!.env.example
!.env.sample
!.env.template
models.json
channels.json
plugins.json
*.local
*.pem
*.key
# 文件管理器/编辑器自动备份与临时文件(主人的远程文件管理 App 改文件时会自动生成 .bak)
*.bak
*.bak-*
*.orig
*.tmp
*.swp
*~
# 构建产物
*.exe
*.exe~
*.dll
*.so
*.dylib
# Go
# 前端
node_modules/
dist/
.vite/
# Python
__pycache__/
*.pyc
.venv/
venv/
*.egg-info/
# IDE
.idea/
.vscode/
*.swp
*.swo
# 系统
.DS_Store
Thumbs.db
desktop.ini
# 日志
*.log
# 临时文件
tmp/
temp/
node_modules/
dist/
# 开发/运行本地产出(不入库)
# data/ = SQLite 数据目录(DATA_DIR=./data);/cyrene = 根目录构建产物(锚定,不误伤 cmd/cyrene)
data/
/cyrene
+86
View File
@@ -0,0 +1,86 @@
# Cyrene v2 开发铁律(AGENTS.md)
> 本文件是**项目级开发铁律**,由运行环境在**每次工作会话时自动注入**。即使发生上下文压缩/丢失,以下约束仍会强制生效。**必须遵守**,违反即偏离。
## 1. 先读设计,再写代码
- 动任何代码**前**:先读 `DevDocs/开发时间线.md`(当前里程碑 + §3 功能状态清单),确定这一步属于哪个里程碑/功能。
- 具体功能实现:先读对应 `DevDocs/design/*.md`,**按其规格实现,不得自行发挥**(design/01~13 是权威设计)。
## 2. 按里程碑推进,不跳步、不漂移
- 严格按 **M0 → M12** 顺序。每个功能的依赖与归属见时间线 §2/§3。
- 不得「想到什么做什么」。**新需求/新想法:先写入设计或时间线清单,再开发**,不脱离清单。
- 上下文压缩后:**先读 `DevDocs/工作小结/00-当前态.md`** 恢复锚点,再继续,**不得凭印象跳走**。
## 3. 每阶段审查并勾选
- 阶段完成:后端 `go build ./...` + `go vet ./...` + `go test ./...` 全绿;前端 `tsc --noEmit` + `vite build` 通过。
- 在 `DevDocs/开发时间线.md` §3 勾选状态:`✅ 已完成` / `⚠️ 未完成/阻塞`(⚠️ 必须记录原因与延后里程碑)。
- 与 `DevDocs/design/*` 比对:不一致时以设计为准——先改设计文档或修正代码,**不允许带着偏离堆叠**。
## 4. 改动前确认归属
- 修改任一文件/功能前,明确它在哪个里程碑、对应哪份 design 文档;改完同步该文档(若设计变了)。
## 5. 实时同步工作小结
- 每完成一步,更新 `DevDocs/工作小结/00-当前态.md`:当前里程碑 / 进度 / 已完成+未完成 / 关键决策 / 变更日志。
## 6. 小步提交 + 频繁验证
- 小粒度 commit;每步 build/test 通过再提交;提交信息按 Git 规范(feat/fix/refactor/docs/chore,scope 用模块名)。
## 7. 不信任记忆,信任清单
- 关键决策、架构铁律、用户红线(如身份识别靠元数据、不靠提示词)都记录在 `DevDocs/工作小结/00-当前态.md` 的「关键决策」区;压缩后回读,不得丢失。
## 8. 上下文保护:可并行项交给后台子代理,主会话当监工
- **相互独立、不依赖主会话开放状态**的开发任务(如 M0 地基各件、层 1 并行件),用 `Agent(run_in_background=true)` 派后台子代理**并行实现**。
- 主会话**只当监工**:等子代理回报 → 验收(`go build/vet/test`)→ 更新 `DevDocs/工作小结/00-当前态.md` + 时间线 → 小步 commit → 派下一批。**不在主会话里重复子代理已做的搜索/实现**。
- 子代理 prompt 必须**自包含**:写清目标、已知信息、精确文件路径、必须遵守的编码约束(02-development-constraints.md)、验收命令;不要让它去搜已经知道的东西。
- **只有真正独立才并行**;有依赖的串行。避免两个子代理同时改 `go.mod/go.sum`——若都要改,指定其中一个负责或先把依赖拉好。
- 目的:防主会话上下文超限触发压缩;压缩后靠本文件 + `DevDocs/工作小结/00-当前态.md` 恢复锚点,继续当监工。
## 9. 全球红线(跨里程碑,必须遵守;违反即架构偏离)
- **单二进制、单端口 `:8080`、零跨进程 IPC**(🔴 **本条约束的是 Cyrene 框架自身,不约束扩展插件**):框架模块间同步走**进程内接口 + 依赖注入**(直接方法调用),异步走**进程内 `bus`**;**框架自身不另开监听端口、不 HTTP 互调**。
- 🆕 **2026-10-07 主人口径补充(原「豁免」升格为一般规则)**:**扩展插件不受本条约束** —— `plugins/` 下的渠道适配器与工具、MCP server、以及插件自己拉起的本地伴随进程/服务,**可以开自己的监听端口、可以有自己的进程形态**。这**不是「例外」,是插件的正当能力**;原先只为 `qq-obv11` 登记的那条例外,**随之由「例外」变为本条一般规则的一个实例**(该渠道规格仍见 `design/08 §4`)。
- 🔴 **插件开端口的四条护栏(主人 2026-10-07 确认采纳)**:① 端口/地址**必须由配置显式给出**,**不得默认开启、不得写死**;② **默认绑 `127.0.0.1`**,要对外暴露必须由 `.env` 显式决定(与框架暴露面同一套口径);③ **必须登记日志来源与审计**(谁开的、绑在哪);④ **端口不得成为绕过身份/权限判定的入口** —— actor 元数据 + 白名单 + 策略引擎仍是唯一判据,端口不是后门。
- 「**零跨进程 IPC**」的原意**仍然有效**:**框架内部模块**不得为解耦而拆成多进程/多服务、不得 HTTP 硬编码 URL 互调(详见 `DevDocs/00-overview.md`)。**框架与插件之间的边界通信不在本条约束内**;但框架**不得**因此把自身模块拆出去变成插件。
- **身份识别靠 channel 层强类型 `actor` 元数据 + 策略引擎**,**绝不靠提示词/LLM 判断**(主人/群聊/陌生人判定与权限永远由 actor 元数据 + 白名单 + policy engine 决定)。
- **人格 = 身份**(**不限制能力**);**行为模式 = 呈现**(≠边界,可热切换、不构成能力边界);**能力全局全能**;**前端(Web)是第一公民**。
- **IM 是镜像**:只 chat 文本,Web 不回流 IM;输出一律 chat 文本。
- **前端(用户端 + 管理面板)统一 Miuix 设计语言**(小米 HyperOS Miuix / Miuix Console),实现须对齐六大核心(Smooth Corner/多层 Surface/弹簧动效/深色优先/毛玻璃/Liquid Glass),不混用其他 UI 语言。
- **存储**:SQLite 默认 + PG/pgvector **可选增强**;**配置了增强必须真实走语义/向量路径**(非空壳)。
- **工具循环上限 8 轮**;任务:后台声明 / 前台 2s 转后台 / 消息队列回输出。
- **记忆三类作用域隔离**:用户习惯(全局共享)/ 角色日常(他角色只读)/ 项目(人人读写 + 项目索引分层);**群聊 / 陌生人:查记忆只读不写、不入主会话上下文、不泄露主人敏感信息**。
- **敏感操作主代理代审 + 审计日志 + 前端小字**(「xxx 想要干什么,我审查后批准了」)。
- **模型**:per-role 路由 + 接口报错自动降级(主→备→本地);多模态/流式。
- 🔴 **后端常驻(主人 2026-10-04 追加要求)**:在**下一次构建产物就绪之前,正在运行的后端(`cyrene.exe`,`:10942`)不准关**,除非出现**恶性 Bug**(进程致命错误/必须阻断的严重故障)。推论:① **禁止**为「跑全树验收 / 重启清理 / 图省事」而停它——`go test`/`go build`/`git` 都不需要停后端;② 换二进制**只允许**在**新产物已构建好之后**一次性 `restart`(即「先 build 到位,再一次切换」,不得留空窗);③ **启动后端不许用前台阻塞式命令**(`scripts\ctrl.bat start` 会守着进程不返回)——必须确认它**脱离工具进程**,否则工具超时会**连带杀掉后端**(2026-10-04 实测:`TaskStop` 掉阻塞包装后进程存活、PID 文件已写 `757788`,属脱离式启动);④ 上下文压缩/会话中断后:**先探活**(`netstat -ano | grep 10942` + `curl /api/v1/health`)再决定,不要凭印象以为它停了。
## 9.5 学习成熟开源实现,禁止闭门造车(⚠️ 用户强制红线)
- **已成熟的能力/模块,必须优先学习并复用现有开源项目的业务逻辑与交互范式**,**严禁闭门造车/按设计文档凭空推演**。对照基准(按 design 主题):
- **agent 工具循环 / 工具分发** → Claude Code、OpenClaw、Cursor(工具 schema、循环上限、权限门、流式输出)
- **子代理编排 / 隔离上下文 / 审计代审** → Claude Code 的 subagent、OpenAI Swarm、CrewAI
- **MCP** → **官方 Model Context Protocol SDK**(Go/Python/TS 客户端接入、工具自动发现)
- **WS 实时协议 / 流式 token** → OpenAI/Anthropic 官方 SDK 的流式惯例、Claude Code 的实时事件
- **checkpoint 恢复 / 长任务持久化** → Claude Code 的 resume / Cursor 的会话恢复
- **跨平台渠道接入(QQ/微信/Telegram)** → OneBot v11、Telegram Bot API、微信 iLink 既有客户端
- **管理面板 / 前端交互** → Miuix Console(本项目设计基准)、以及成熟 admin 面板范式
- **实现前**:先在该主题的 design 文档下**补一行「开源对照」**——列出参考项目 + 借鉴点(模板:`## 开源对照(实现前必读)`)。实现时逐点对齐其成熟逻辑。
- 若某个能力**已有成熟开源实现**而项目选择了自研,必须**写明理由**(如协议差异、单二进制约束),不得默认自研。
- 上下文压缩后:**本铁律强制生效**;每个模块补全前**先查 `DevDocs/开源对照清单.md`**(集中记录各主题的参考项目与已完成对照),未对照不得开工。
## 10. 代码与流程硬性规范(压缩后仍需遵守)
- 🔴 **文件 ≤500 行、函数 ≤80 行**;**模块按职责拆成多个源文件,严禁把整个模块塞进一个超大文件**;**三次复制必须抽取**共享。
- 🔴 禁 `interface{}`(用 `any`)、禁包级全局变量(除 `var ErrXxx`)、禁裸 `return err`、禁魔法数字;错误一律 `%w` wrap;SQL 全参数化、主键 `id`、时间戳 `created_at/updated_at`、索引 `idx_{table}_{col}`(♢ **已登记例外(主人 2026-10-04 裁决)**:表名**统一用复数**、以既有迁移为准——本条原「表名单数」**不再作为硬约束**,命名口径以 `DevDocs/design/13`/`design/16` 为准);敏感信息打日志。
- 🔴 **日志必须标来源**:模块/子功能用 `internal/logger.WithSource`/`Scoped`(`source=module/subfeature`)标注,跨模块可追溯定位;**禁止无来源裸日志**。
- 🔴 **客户端 API 契约先行**:REST `/api/v1` + WS `/ws` + admin `/api/v1/admin/*` 统一**版本化契约**,供跨平台客户端(Web/移动/IM bridge)接入与治理;JSON 响应 `{data}` / `{error:{code,message}}`。
- 🔴 **用户侧 API 文档随实现更新**:每实现/变更一个 REST 端点、WS 事件、admin API,**立即同步更新 `Docs/api-client.md`**(用户侧跨平台客户端 API 文档),供后期原生客户端开发;契约变更版本化。
- 🔴 **`.env` 冻结(主人 2026-10-06,二次收口后为最终口径)**:`.env` 只保证「**最小化功能可跑**」——**基础键已经定好了,以后不准改**(**不加、不改、不删**;模板 `.env.example` 与 `.env` 同进退)。
① **新配置一律不进 `.env`**:调优/门槛/上限/开关类参数走 **`$DATA_DIR/config/**`(面板可改)+ 代码默认值**;
② **既有基础键与既有 `env:` tag 保持现状**(不迁移、不改默认值、不删键);
③ 原协同 `E6`「新 env 键必须同步 `.env.example`」**作废**;
④ **凡文档里写「新增 env 键须同步 `.env.example`」的段落一律是过期文本**,按本条改正(口径全文见 `design/13 §2.1`)。
- 🔴 **每次模块/功能改动「前」与「后」都推仓库**(用户红线,留恢复误操作余地):改动前若工作树有未推改动,先 `git push origin dev` 存一个干净基线;改动完成验收通过后,立即 `git commit` + `git push origin dev` 存档。**绝不攒一堆改动到阶段末一次性推**——坏操作可随时 `git reset`/`rebase` 到任一推过的 commit 回滚。每个模块/功能提交(或里程碑归档)后均 `git push origin dev` 同步远端。
- 🔴 **压测纪律(2026-10-04 实测事故,必守)**:子代理做负载/竞态验证时**不许**用无界进程风暴占机(如 `yes` ×30、`sleep` 风暴)—— 实测一次「`yes` 没杀干净」把 12 核占满约 **15 分钟**,期间所有 `go build/test` 近乎停滞,并把工具链误判成死锁。规矩:① 负载**限核**(`GOMAXPROCS`/单包并行/`-p`)或**限时**(几分钟级),优先用「可控对抗条件 + 注入时钟 + 门控」而不是堆进程;② 压测结束**必须确认无残留进程**(`Get-Process yes` 之类计数为 0)再出证据;③ 出现「整机变慢/命令不动」先查**是不是自己刚起的压测进程**,再怀疑工具链。
- 🔴 **仓库根产物卫生(2026-10-04 实测事故,必守)**:`go build ./cmd/cyrene/...` 这类**单 main 包**命令会把可执行文件写到**当前工作目录** ⇒ 实测**静默覆盖**了正在用的发布产物 `cyrene.exe`(且不带 `-ldflags "-s -w"`,体积 39.3 MB → 49.1 MB)。规矩:**子代理一律不许跑会在仓库根产出可执行文件的构建** —— 只跑 `go build ./...`(多包 ⇒ 丢弃产物)、`go build ./<非 main 包>/...`、`go vet`、`go test`。**换二进制只允许**「`scripts\ctrl.bat build`(唯一权威构建,带 strip)+ **一次** `restart`」,且必须在**整树验收通过之后**执行(对齐「后端常驻」红线:换产物不留空窗)。
- 🔴 **等待子代理:不许用长 `WaitFor` 阻塞回合(主人 2026-10-04 反馈)**:主会话在 `WaitFor` 里等子代理时,**主人无法给任何补充输入** —— 等于把主人锁在门外。规矩:**派完就结束回合**(子代理的完成通知会自动唤醒主会话);确需同回合内等,**单次 `WaitFor` ≤ 60s**,且只用于「马上就要用它的结果」;等待期间若有并行工作,**先做工作再结束回合**,不要空等。
- 🔴 **遇到开发文档冲突 / 意义不明 / 规格矛盾时**:**阻塞会话**,用 `AskUserQuestion` 询问主人,**不得自行猜测**;文档清、不冲突则自主推进。
- 🔴 阶段验证:后端 `go build ./...` + `go vet ./...` + `go test ./...` 全绿 + windows/darwin 交叉编译;前端(M9+)`tsc --noEmit` + `vite build` 通过。
- 🔴 **换二进制前先确认前端是否变更(2026-10-05 登记)**:`scripts\ctrl.bat build` **只跑 `go build`,不含前端**。若本次改动触及 `frontend/web/**`,必须先在 `frontend/web` 跑 `npm run build`(会把 `dist` 拷到 `internal/webui/dist`),**再** `ctrl.bat build` + **一次** `restart`;否则后端产物里带的是旧前端。反过来:**纯前端改动不需要重启后端**(运行时优先读磁盘 `frontend/web/dist`),刷新浏览器即可。
- 🔴 **裁定必须带完成状态(2026-10-05 登记)**:凡是给主人 / 给安卓端的裁定、承诺、回执,**每一条都必须标注「已完成 / 在办 / 未开始」**;标「已完成」须附 commit 号或验收证据,且**完成后回原条目处补记「已完成 + commit」**——不得只在别处口头称完成。**禁止把「未验证」表述成「已修好」**。
- 🔴 **提交一律逐文件 `git add <显式路径>`(2026-10-05 实测事故)**:**禁止**在「同一目录下有多路在途切片」时用 `git add -A -- <目录>` —— 实测它会**把别的子代理的在途文件一起吞进提交**(本次误把 237 的 `sessionenv.go` 提交并推了)。修复套路:`git reset --soft HEAD~1` → `git restore --staged <误入文件>` → 重新 commit → `git push --force-with-lease origin dev`(工作树内容不丢)。
+2040
View File
File diff suppressed because it is too large. Load diff
+862
View File
@@ -0,0 +1,862 @@
# Cyrene v2 客户端 API 文档(用户侧)
> **完整版见 `Docs/api-reference.md`**(后端全部 API(REST + WebSocket)超详细调用文档,字段/鉴权/状态码以代码为准)。本文档为**摘要**,契约如有出入以 `api-reference.md` 为准。
> **面向**: Web 前端 / 移动端原生客户端 / IM 桥接等**跨平台客户端开发者**。
> **维护铁律**: 每实现/变更一个 API(REST 端点、WS 事件、admin API)**立即更新本文件**;契约变更必须版本化,不破坏既有客户端。
> **状态**: gateway 已在 M8 落地核心端点;M9 前端按此开发。**`✅` = 已实现**,`🕐` = 规划。
---
## 0. 基础约定
- **Base URL**: `http://<host>:8080`(网关唯一对外端口;生产一般经反代/HTTPS)。
- **版本**: REST 全部挂在 `/api/v1/{resource}`;未来破坏性变更升 v2。
- **认证**: `Authorization: Bearer <JWT>`(RBAC:`owner`/`admin`/`viewer`,权限矩阵见 §2.0);**`X-API-Key` 已接线(v0.51)**:所有已认证 API(管理 `/api/v1/admin/*` 与用户侧 `/api/v1/*`)同时接受 `X-API-Key`;命中签发表即通过,且**一律 `owner` 级**(不再按角色分档,裁决 15.4.3)。请求带该头时**只走 key 分支**——未知/已吊销/空值回 `401` 且**不回落 Bearer**(fail-closed)。**WS 握手仍只认 Bearer**。
- **响应统一**: `{"data": ...}` / `{"error": {"code": <string>, "message": <string>}}`;状态码 200/201/204/400/401/403/404/413/415/429/500(**413/415 为 v0.59 文档端点新增**)。
- **错误码**:业务错误带 `code` 字符串(如 `unauthorized` / `not_found`),便于客户端分支。
- **数据目录 `DATA_DIR`(v0.55)**:默认 `./data`;**新增接受保留字 `platform`(大小写不敏感)** → 平台标准数据目录(Windows `%APPDATA%\Cyrene`、macOS `~/Library/Application Support/Cyrene`、Linux `~/.local/share/cyrene`)。**默认 `./data` 与显式路径行为不变**;**显式空值行为也不变**(仍走原兜底,不会因升级突然换库)。
- **管理面板安全入口 `ADMIN_PATH`(v0.55,前端路由面,不是 REST 契约)**:面板**前端**路由前缀 = `/` + `ADMIN_PATH`(默认 `admin` → `/admin`;`xxx/admin` → `/xxx/admin`;**非法值回退 `/admin`**)。HTTP 行为:`/<ADMIN_PATH>` 与其深链返回**注入了 `<base href="/">` 与 `window.__CYRENE_ADMIN_ENTRY__={"prefix":"..."}` 的 index.html**(`Cache-Control: no-store`);**形如面板入口(路径里出现 `admin` 段)但前缀不符的路径 → `404`**(**不回落用户端聊天壳**);`/<ADMIN_PATH>` 若恰好命中 `dist` 真实文件则仍走文件服务器。**API 不藏**:`/api/v1/*`、`/api/v1/admin/*`、`/ws/chat`、webhook 一律原样,**无新端点、无帧变更**(安全靠 JWT + 角色门,design/07 §0.1)。**口径**:用户端 HTML **永不携带该前缀串**(后端只在命中前缀时注入)⇒ 用户端既无管理入口链接、也解析不出面板路由(design/07 §0.2)。
---
## 1. REST 端点(公开)
| # | 端点 | 方法 | 认证 | 状态 | 说明 |
|---|------|------|------|:---:|------|
| 1 | `/api/v1/auth/login` | POST | 否 | ✅ | `{username,password}` → `{data:{token,expiresAt,role,refreshToken?}}`(access 24h;**新增 `refreshToken`**——不透明随机串,仅哈希落库,`AUTH_REFRESH_TTL` 默认 720h,用于静默续期;role=owner) |
| 1a | `/api/v1/auth/refresh` | POST | refreshToken | ✅ | **静默续期**(B2):body `{refreshToken}` → `{data:{token,expiresAt,role,refreshToken?}}`(新 access + 新 refresh;**`role` 恒返回** = 该 refresh grant 记录的角色 `owner|admin|viewer`),**默认单次使用旋转**——旧 refresh 立即作废,并发重放 fail-closed `401 invalid_grant`)。`AUTH_REFRESH_ROTATE_DISABLED=true` 关闭轮换时旧 token 保持有效、不返回新 refresh。过期/已撤销/乱串统一 `401 {error:{code:"invalid_grant"}}`;成功才审计 |
| 1b | `/api/v1/auth/logout` | POST | refreshToken 或 JWT | ✅ | **登出**(B2):body `{refreshToken}` 撤**单个** grant(幂等)→ `{data:{revoked:<bool>}}`;带 `Authorization: Bearer <jwt>` 且无 body → 撤**该账号全部** grant(全设备登出)→ `{data:{revoked:<bool>,revokedCount:<n>}}`。**两种响应形状不同**——单撤**没有** `revokedCount`;两者都不给回 `400 bad_request`。refresh 相关操作成功才审计 |
| 2 | `/api/v1/health` | GET | 否 | ✅ | 健康检查 `{data:{status:"ok"}}` |
| 3 | `/api/v1/chat` | POST | JWT | ✅ | `{text,image?,deviceId?,mode?,clientMsgId?,documents?,parallel?,stream?}` → `{data:{reply}}`;**`documents?`(v0.59)**=本回合文档附件引用数组(**≤5 条**、**只收服务端存储名**、与 REST `stream:true` / WS `chat` 同形),单回合超限 `400 too_many_documents`(先于任何文件系统访问)、名非法/穿越/不存在 `404 document_not_found`,详见 §1.2;`stream:true` 时**直达会话编排器**(v0.56/G2:不再经可丢弃的 bus,成功受理后才回,见本行末尾「`stream:true` 响应面」)。`image` 为 base64 data: URI 或 http(s) URL,入视觉路由(design/06 §0;**v0.53 语义修正**)——**主模型能不能收图看生效组的 `multimodal`**(`models.json` 组键,面板可保存、热更新);`LLM_VISION_MODEL`/`LLM_OCR_MODEL` **只配视觉/OCR 工具用的模型**,**与主模型能否收图无关**(裁决 14.4.1)——**走 `.env` 合成组(无有效 `models.json`)时,「主模型能否收图」该由 `LLM_MULTIMODAL` 声明(默认 `false`)**(**v0.58**,见 §2「模型配置契约」表);主模型不收图时由视觉·OCR 工具的**文本描述**替代(图像不占上下文,只有工具结果占);**`image` 上限(v0.56,G3)**:解码后 **5 MiB**(与 `POST /api/v1/upload` 同口径;base64 字符串上限 = `ceil(n/3)*4 + 64` 字符,已含 data: URI 前缀余量),超限一律 `400 {error:{code:"image_too_large", message}}`(文案指路 `POST /api/v1/upload`);**WS `chat` 帧到不了这条判定**——WS 读上限 `wsReadLimit = 4096` 字节/帧,超限由传输层直接断连,故 WS 上发真实图片本就必须「上传 + URL」;`deviceId` 为**客户端生成的具体端标识**(design/16 §4.5,如 `web:chrome:a1b2c3`,可选),落库到消息、便于主人知道从哪个端发来;`mode` 为**行为模式**(design/01 §3 / design/16 §5.1,如 `chat`/`code`/`home`/`docs`/…,可选),随该轮次落库——IM 恒 `chat`、缺省回落 `chat`,供前端按模式渲染气泡(仅 `chat` 才解析 `<p>` 拆气泡)。`clientMsgId`(v0.56,G4)为**客户端自带的幂等键**(可选):同一会话内同一个非空值只承认第一条,重复到达回 `200 {data:{duplicate:true,messageId,sessionId}}` 且**不落库、不起回合、不发新内容帧**;省略 = 与加入该字段前逐字相同(详见 §3.13)。**会话编排(design/15)**:非流式且该会话「正忙」时**不阻塞**,返回 `202` `{data:{queued:true,queueDepth}}`(稍后按上下文续接);待处理队列满时返回 `409` `{error:{code:"chat_busy"}}`。**忙时并行(v0.65,`parallel:true`,仅显式声明时生效)**:该会话**已有在途回合**、且单会话并发额度未满(默认 **2**、硬上限 **3**)⇒ 本条**升为一条独立的并行路立即起**,同步入口回 `202` `{data:{parallel:true,turnId:"turn-…"}}`(**拿不到同步 reply**,文本照旧经 WS 流式到达;`turnId` 就是那一路的权威回合标识、与该路全部流帧同值);**空闲时 `parallel` 无效果**(照常起第 1 路);**省略 `parallel` = 与加入该字段前逐字不变**(服务端**不做**启发式自动升级);额度已满、**或真并行被 `<2` 关掉** ⇒ 落回既有排队语义 `202 {queued:true,queueDepth}` **并在 `pending_ack` 带 `reason:"parallel_limit"`**(见 §3.2)。**`stream:true` 响应面(v0.56,G2,行为变更)**:受理成功后才回——空闲已起回合 = `200 {data:{stream:true}}`、正忙已续接排队 = `202 {data:{stream:true,queued:true,queueDepth}}`、队列满 = `409 {error:{code:"chat_busy"}}`、幂等键重复 = `200 {data:{duplicate:true,messageId,sessionId}}`、**已升为独立并行路 = `200 {data:{stream:true,parallel:true,turnId:"turn-…"}}`**(v0.65;仅 `parallel:true` 且额度未满时出现);**未接 loop 改为 `500 {error:{code:"chat_unavailable"}}`**(旧文写 `200 {stream:true}`,现与同步路径同口径);仅当**未接编排器**才回退总线派发,其失败回 `503 {error:{code:"chat_dispatch_failed"}}`——**不再有「先回 200 再看有没有落地」**,故 `202`/`409` 从此**真的会出现**(修复前因提前 return 永不出现) |
| 4 | `/api/v1/sessions` | GET | JWT | ✅ | `{data:[session...]}`。**会话持久化(design/16)**:注入 HistoryStore 后返回持久化会话元数据 `{data:[{id,type("main"|"sub"),title?,createdAt,pinned,unreadCount,lastMessage?,personaId?}]}`(**排序(D5):以代码为准 = `pinned DESC, (type='main') DESC, created_at ASC`**——`pinned=true` 最前,其次是未置顶的主会话,最后是未置顶子会话(按 `createdAt` 升序);⚠️ **置顶的次级会话会排在主会话之前**,客户端定位主会话请按 `id=="web"` / `type=="main"`,**不要假定 index 0 就是主会话**;次级 `web:<uuid>`;`pinned` 恒返回 bool;**`unreadCount`(D4)恒返回整数**——该会话中 `createdAt >` 已读游标的消息条数(0 = 无未读),与 `lastMessage` **同一条 SQL 派生**、无 N+1;`lastMessage` 为**最后一条消息预览** `{content,role(user|assistant),createdAt}`,content 折叠空白截前 60 rune,空会话省略;**`personaId`(v0.42,可选)**=该会话绑定的角色 id(design/22 §2/§4.5),**空/缺省 = 继承全局默认角色**(`owner.yaml:defaultPersona`),客户端据此渲染激活会话的角色名/头像);未注入时退化回渠道会话列表 |
| 5 | `/api/v1/sessions` | POST | JWT(owner/admin) | ✅ | **新建次级会话** → `201 {data:{sessionId,personaId?}}`(`sessionId="web:<uuid>"`)。body **可选**(可完全省略或空 body):`{personaId?}`(v0.42)在创建时就把新会话绑到该角色(design/22 §2:渠道级只作"新会话的初始角色提供者";**会话角色只在创建时写入、之后不可变**,见 §1 行 8 / design/22 §8-1);`personaId` 为**未知角色**回 `400 bad_request`;省略/空 = 不绑(`persona_id=''`,继承全局默认);**`personaId` 非空时响应回显它**(省略时响应形状与 v0.41 完全一致);未注入 HistoryStore 回 `501 not_implemented`;注入了**不支持角色绑定**的 HistoryStore 且显式传 `personaId` 时也回 `501`(不假装写入) |
| 6 | `/api/v1/sessions/{id}/messages` | GET | JWT(owner/admin) | ✅ | **热分页拉历史**:`?limit=&before=&platform=&after=`(A5)默认 `limit=50`(上限 200)。**`before`**=上一页最旧 `createdAt`(毫秒),按时间**倒序**;**`after`**=<`createdAt` 毫秒>,按时间**升序**返回 `created_at > after` 的消息(**正向增量**,客户端把上次看到的 `createdAt` 当游标补断线期间漏的,解决重连只回放 50 条的缺口);**`after` 与 `before` 互斥**,同传回 `400`;负数/非数字回 `400`。`platform` 为**可选**来源平台过滤(design/16 §4.4)——Web 端不传(=全量共享主会话),IM 适配器传**本平台**(如 `telegram`)只拉本渠道消息;`deviceId` 为**客户端具体端标识**;`senderName` 为**第三方发送者昵称**;**`personaId`(v0.42,可选)**=**该消息说话时的角色 id**(design/22 §7-5:逐条记录"谁说的"——会话角色在创建时固定,§8-1;assistant 消息 = 生成它的角色,user 消息 = 当回合角色;**空 = 全局默认角色**,即 v0.42 之前的全部历史消息),客户端据此按角色渲染名字/头像;**`clientMsgId`(v0.56,可选,G4)**=该消息的**客户端幂等键**(带 `clientMsgId` 发的 user 消息会回填它;空则省略),客户端据此把本地乐观气泡对齐到服务端真相;**`q`**(D2)=**会话内内容搜索**(大小写不敏感 ASCII LIKE,`%`/`_`/`\` 已转义为字面量),仍按时间**倒序**、`before`/`limit` 同分页语义;`q` 与 `after` **互斥**(同传 `400`),空 `q` 视为未传(走普通分页),搜索**严格限定在 `{id}` 会话内**(绝不跨会话,群聊/陌生人隔离会话不会外泄);非法 `limit`/`before`/`after` 回 `400`;未注入 HistoryStore 回 `501`。**(v0.63,行为变更)会话不存在 / 已删除 ⇒ `404 {"error":{"code":"session_not_found","message":"session \"<id>\" not found"}}`**——与 `PATCH /sessions/{id}`、`POST /sessions/{id}/read` 对未知会话的口径一致,客户端据此把「当前会话已经不在了」(退回主会话 + 一句提示)与「这是一条空会话」分开;**存在但确实没有消息仍回 `200 {"data":[]}`**。⚠️ **判据是会话注册表 `chat_sessions`**:隔离的 IM 会话(群聊/陌生人的 `sess-xxx`、内部 `sess-backthink`)**不在其中**,因此与 `POST /sessions/{id}/read` 拿到同一个答案(`404`)——它们的消息请走 `GET /api/v1/admin/channels/{platform}/messages`。⚠️ 请求形状(`limit`/`before`/`after` 非法、`before`+`after` 同传、`q`+`after` 同传)**仍先于** 404 判定(形状错了就是 `400`,与目标存不存在无关) |
| 6a | `/api/v1/messages/{id}` | PATCH | JWT(owner/admin) | ✅ | **编辑单条消息**(D3):body `{content}`(非空、≤4000 rune,空/纯空白/超长回 `400`)→ **`{data:<消息DTO>}`**(`data` **直接就是**更新后的完整消息 DTO,含 `id/sessionId/role/content/.../createdAt`,**无 `message` 包一层**;**只改 `content`**,role/时间戳/平台保持不变);消息不存在回 `404 not_found`;未注入 HistoryStore 回 `501`。**成功后广播下行 `message_edited`**(§3.8,账号其它端免刷新同步)。**重发不单独设端点**——客户端用「编辑后的内容再 `POST /chat`」即可(避免重复轮次语义) |
| 6b | `/api/v1/messages/{id}` | DELETE | JWT(owner/admin) | ✅ | **删除单条消息**(D3)→ `{data:{deleted:<id>}}`;不存在回 `404 not_found`;未注入 HistoryStore 回 `501`。**成功后广播下行 `message_deleted`**(§3.8,账号其它端按 `id` 移除) |
| 7 | `/api/v1/sessions/{id}` | DELETE | JWT(owner/admin) | ✅ | **删除次级会话及其消息** → `{data:{deleted:<id>}}`;主会话不可删回 `403 forbidden`;未注入 HistoryStore 回 `501` |
| 8 | `/api/v1/sessions/{id}` | PATCH | JWT(owner/admin) | ✅ | **会话元数据更新**(A4 改名 + D5 置顶):body `{title?,pinned?}`(**至少一个字段**;都缺回 `400 {"code":"bad_request","message":"at least one of title or pinned is required"}`——说明「缺字段」)。`title` trim、≤80 rune:**存在但为空/仅空白回 `400 "title must not be blank"`**(**不是** `title is required`——字段在、只是值非法)、超长回 `400 "title is too long (max 80 characters)"`;**空串不能清空标题**(不回落自动命名,故客户端不提供「恢复自动命名」入口);`pinned` 为 bool(置顶/取消,主会话与次级会话均可);**`personaId` 出现即 `400`**(v0.46,design/22 §8-1:**会话角色在创建时确定、之后不可变,要换角色请新开会话**)——**不是静默忽略、也不是 200 空操作**:**键出现**(任何取值:`"yuki"` / `""` / `null` / 未知 id / 空白串)一律 `400 bad_request`,错误消息点名 `personaId`,且该检查**先于**会话存在性检查(**未知会话 + `personaId` 也回 `400`**,不是 `404`;连 `title` 同传也整体 `400`、不改任何字段)。响应只回显**本次变更**的字段:`{data:{id,title?}}` / `{data:{id,pinned?}}` / 两项同改则都有(**title-only 保持 A4 原 `{id,title}` 形状**);不存在回 `404`;主会话(`id="web"`)可改名/置顶但不可删;未注入 HistoryStore 回 `501`。另:会话**首条用户消息到达时自动命名** title(取前 20 rune 折叠空白;纯空白回落 `主会话`/`次级会话`),列表不再空名 |
| 8a | `/api/v1/sessions/{id}/read` | POST | JWT(owner/admin) | ✅ | **推进已读游标**(D4 / Android 协同 B3):body `{before?}`——`before` 为**可选**的毫秒 Unix 时间戳,**缺省(或空 body)= 现在**,即「我看到的都算已读」。→ `200 {data:{sessionId,readAt}}`(`readAt` 为推进后的游标毫秒)。**判定**:`createdAt <= readAt` 的消息算已读(列表 `unreadCount` 只数 `createdAt > readAt` 的);**语义**:**会话级单一游标、单 owner 账号**——读一处 = **该账号所有端已读**(不做 per-device 游标,与「同一账号所有浏览器都属于同一 owner」一致);**只前进不后退**(SQL `read_at = MAX(read_at, before)`),所以乱序/并发/重试请求幂等安全、绝不会把已读变回未读。`before` 为 `0`/负数/非数字、或 body 不是 JSON 对象 → `400`;未知会话(含隔离 `sess-xxx`,不在 `chat_sessions`)→ `404`;未注入 HistoryStore → `501 not_implemented`。**成功后广播下行 `session_read`**(§3.9)让同账号其它端清红点;**失败路径不广播**。**升级回填**:迁移 `0010` 把既有会话游标回填为「该会话现有最新消息时间」,升级当天不误报未读 |
| 9 | `/api/v1/personas` | GET | JWT | ✅ | **角色目录**(任意已认证角色可读)→ `{data:[PersonaSummary...]}`,每项 `{id,name,character,isDefault,mode,memoryNamespace,avatar?,chatBackground?,mood}`。**`mood`(v0.43)** = 该角色**当前情绪状态**(角色级,取值见 `GET /meta/enums` 的 `moods`;`""` = 该角色暂无被跟踪的情绪,**不伪造默认值**)。**语义:情绪跟随角色,与会话无关**——同一角色的所有会话看到同一 `mood`;客户端登录/加载角色目录时用它**播种心情徽标**,避免刷新后短暂空白(随后以 WS `mood` 帧为准)。**不含 admin 专属的 `thinking` 旋钮**(用户侧不暴露模型调用参数)。 |
| 10 | `/api/v1/audio/transcribe` | POST | JWT(owner) | ✅ | **语音→文本**:body `{audio:"<base64>",format?,deviceId?}` → `{data:{text, provider?}}`(走后端 STT,百炼 `qwen-audio-3.0-asr-flash`;**当结果为空文本/silent 降级时响应体带 `provider`**(如 `"silent"`,非空文本省略),客户端据此提示「没识别到声音」) |
| 11 | `/api/v1/audio/speech` | POST | JWT(owner) | ✅ | **文本→音频**:body `{text,voice?,format?,instruction?}` → `200` 音频字节(`Content-Type: audio/<format>`,非 JSON envelope,走后端 TTS,百炼 `cosyvoice-v3-flash`,复刻音色可带 `instruction` 情绪指令;**静默降级空音频时仍 `500 tts_empty`,并附 `X-Voice-Provider: silent` 响应头**,客户端据此提示「没生成声音」) |
| 12 | `/api/v1/audio/voice-clone` | POST | JWT(owner) | ✅ | **声音复刻建音色**:body `{url:"<公网参考音频URL>",prefix?}` → `{data:{voiceId}}`(走百炼 `voice-enrollment` create_voice) |
| 13 | `/api/v1/upload` | POST | JWT(owner/admin) | ✅ | **图片上传**(B1):multipart/form-data,字段 `file`(raster image,5MB 上限)→ `201 {data:{url,size,mime,name}}`;`url` 为**同源访问地址**(`<host>/api/v1/files/<name>`,可直接作 `chat.image`)。校验:`MaxBytesReader` 防超大 → `DetectContentType` 嗅探(**不信客户端 Content-Type**)→ 白名单 png/jpeg/gif/webp/bmp(**拒 SVG/HTML**);文件名用 **crypto/rand 128-bit 随机名**(原始文件名丢弃),存 `<dataDir>/uploads/`(0700)。错误码:缺文件 `400 bad_request`、超限 `413 payload_too_large`、非白名单 `415 unsupported_media_type`、未授权 `401/403` |
| 14 | `/api/v1/files/{name}` | GET | 无需(能力URL) | ✅ | **上传文件访问**(B1):从 `<dataDir>/uploads/` 读取,同名端口服务。`name` 仅接受「32 位小写 hex + 白名单扩展名」;**路径穿越全形态拒绝**(`..`/`%2f`/`%2e%2e` 等)。返回 `Content-Type`/`Content-Length`/`Cache-Control: private,max-age=86400,immutable`/`X-Content-Type-Options: nosniff`(支持 Range)。**设计取舍**:GET 不鉴权——该 URL 是**128-bit 随机能力地址**(`chat.image`/`<img>` 无法带 Bearer),POST 仍 owner/admin+审计;任何持有该 URL 者都能取文件,勿外泄到他人 |
| 15 | `/api/v1/meta/enums` | GET | JWT(任意角色) | ✅ | **共享枚举权威表**(B4):`{data:{platforms,moods,modes,unknown}}`,每项 `{id,label}`——`platform`/`mood`/`mode` 的**唯一事实来源**,客户端**不必硬编码**即可拿到稳定映射(来源角标 / 情绪标签不再漂移)。只读、无敏感信息,与 `GET /sessions` 同级(owner/admin/viewer 均可读;无 token 回 `401`)。详见 §1.1 |
| 16 | `/api/v1/documents` | POST | JWT(owner/admin) | ✅ | **文档上传**(design/24 §3,**v0.59**;**v0.61 纳入 `.pdf`**):multipart/form-data,字段 `file` → `201 {data:{name,url,mime,size,sha256,deduped:false}}`;**同字节同扩展名重复上传 → `200` + 同一 `name` + `deduped:true`**(可当成功处理)。单文件上限 **`CHAT_DOCUMENT_MAX_BYTES`(默认 20 MiB,`≤0` 视为用默认)**。类型裁决 = **字节嗅探(唯一裁决)∧ 扩展名白名单(23 项,含 `.pdf`)**,自 **v0.61** 起为**成对裁决**(`.pdf` 只收嗅探为 `application/pdf` 的字节,PDF 字节也只收在 `.pdf` 名下;HTML/纯文本改名成 `.pdf` ⇒ `415`,PDF 改名成 `.txt` ⇒ `415`);`mime` 为**嗅探基类型**(`data.json` → `text/plain`,真 PDF → `application/pdf`);`url` 为**相对路径** `/api/v1/documents/<32hex>.<ext>`。错误码:`415 unsupported_media_type` / `413 document_too_large` / `400 bad_request`(缺 `file` / 空文件)。详见 §1.2 |
| 17 | `/api/v1/documents/{name}` | GET | **JWT(owner/admin)** | ✅ | **文档读取**(**v0.59**;**v0.61 增 PDF MIME**):**必须带 Bearer**——与图片读侧「128-bit 随机名即凭据、免鉴权」**刻意相反**;`name` 仅接受「32 位小写 hex + 白名单扩展名」,**路径穿越全形态拒绝**;响应带 `Content-Disposition: attachment` + `X-Content-Type-Options: nosniff` + `Cache-Control: private,max-age=86400,immutable`;嗅探不出放行类型时下发 `application/octet-stream`(真 PDF 按成对裁决如实下发 `application/pdf`,仍因 `attachment` + `nosniff` 永不可内联);名字非法 / 逃出目录 / 文件不存在统一回 `404 document_not_found`(不泄露差异)。详见 §1.2 |
> **语音 REST 源(`/api/v1/audio/*`,均 owner-only + 审计)**:原生/移动端用它上传/获取音频,**绕开 WS 4KB 帧限**。
> - `POST /api/v1/audio/transcribe`:body `{audio:"<base64>", format?:"wav", deviceId?}` → `200 {data:{text}}`。`audio` 为 base64(或 data: URI);`format` 为编码(`wav`/`mp3`/`ogg`,缺省 `wav`);空 audio 回 `400`;未注入语音服务 `500 voice_unavailable`、识别失败 `500 stt_failed`。
> - `POST /api/v1/audio/speech`:body `{text, voice?, format?, instruction?}` → `200` 原始音频字节(`Content-Type: audio/<format>`),**非 JSON envelope**。`voice`/`format` 目前遵循服务端 `VOICE_TTS_VOICE_ID`/`VOICE_TTS_FORMAT` 配置(后续支持按请求覆盖);`instruction` 为**可选** per-request 情绪/语气指令(如 `"语气开朗俏皮,像女朋友撒娇"`),只对**复刻/设计音色**生效——**系统音色 `longxiaochun_v3` 被服务端忽略,永不发送自由指令**(CosyVoice 系统音色需固定格式);长度 ≤100 字符(汉字记 2);未传时回退到服务端 `VOICE_TTS_INSTRUCTION`。空 text 回 `400`;未注入语音服务 `500 voice_unavailable`、合成失败 `500 tts_failed`、静默空音频 `500 tts_empty`。
> - `POST /api/v1/audio/voice-clone`:body `{url:"<公网参考音频URL>", prefix?:"cyrene"}` → `200 {data:{voiceId}}`。`url` 为**公网可访问的参考音频 URL**(create_voice **只收 `input.url`,不收 base64**),音频要求 10~20s 连续清晰朗读、≤10MB、≥16kHz;`prefix` 为音色名前缀(**≤10 字母/数字**,缺省 `cyrene`)。返回的 `voiceId` 填到 `VOICE_TTS_VOICE_ID` 即可用该复刻音色合成(`target_model` 固定为 `VOICE_TTS_MODEL`,须与合成模型一致)。**URL 不写审计/日志(红线,绝不打日志)**。空 url 回 `400`;未注入语音服务 `500 voice_unavailable`;建音色失败 `500 voice_clone_failed`。**⚠️ 真建音色需主人提供公网参考音频 URL(当前无,断言待提供);且需北京地域 API Key(CosyVoice 声音复刻仅华北2北京地域)。**
### 1.1 枚举权威表(`GET /api/v1/meta/enums`,B4)
> **背景**:`platform` / `mood` / `mode` 此前只在文档里给示例(`web`/`telegram`/…;`calm`;`normal`),客户端只能自建映射表——来源角标与情绪标签因此会**随客户端漂移**。本端点发布**权威枚举表 + 未知值回落约定**:Web / Android / IM bridge **共用同一份**,客户端不必硬编码。
- **鉴权**:`Authorization: Bearer <JWT>`,**任意已认证角色可读**(`owner`/`admin`/`viewer`),与 `GET /api/v1/sessions` 同级——只读、无敏感信息。无 token / 令牌无效回 `401`。无其它错误码(纯内存投影,无存储、无 501)。
- **响应**:`200`
```json
{"data":{
"platforms":[{"id":"web","label":"网页"}, ...],
"moods":[{"id":"calm","label":"平静"}, ...],
"modes":[{"id":"chat","label":"对话"}, ...],
"unknown":"display-raw"
}}
```
每项固定为 `{"id":"<枚举 id>","label":"<默认中文标签>"}`。`label` 是**默认值**,自带 i18n 的客户端可覆盖显示;`id` 才是必须比对的值。
- **`unknown`(未知值回落约定,机器可读)**:恒为 `"display-raw"`。语义见下。
**platform(消息来源平台,`{id:label}` 逐值)**
| id | label | 说明 |
|----|-------|------|
| `web` | 网页 | Web 主会话来源(`chat_messages.platform` 默认值) |
| `kook` | KOOK | KOOK 适配器 |
| `qq-obv11` | QQ | OneBot v11(QQ)适配器 |
| `weixin-ilink` | 微信 | 微信 iLink 适配器 |
| `telegram` | Telegram | Telegram Bot API 适配器 |
| `webhook` | Webhook | 通用 Webhook 适配器 |
**mood(情绪,`{id:label}` 逐值)**
| id | label |
|----|-------|
| `calm` | 平静 |
| `happy` | 开心 |
| `excited` | 兴奋 |
| `sad` | 低落 |
| `angry` | 生气 |
> ⚠️ 情绪状态机**可扩展**(persona 可注册更多状态),因此上表**不是封闭集合**——未知 mood 必须按回落约定原样显示。
**mode(行为/呈现模式,`{id:label}` 逐值)**
| id | label |
|----|-------|
| `chat` | 对话 |
| `code` | 代码 |
| `home` | 家居 |
| `work` | 工作 |
| `docs` | 文档 |
| `voice` | 语音 |
| `vision` | 视觉 |
| `agents` | 智能体 |
| `settings` | 设置 |
**未知值回落约定(红线)**
- 客户端遇到**不在表中**的取值(`platform` / `mood` / `mode` 任一):**原样显示该原始字符串**——**不丢弃、不报错、不替换占位符、不猜测**。
- **服务端新增枚举取值是非破坏性变更**(forward-compatible):表中新增一项不改变既有字段/端点形状,旧客户端无需升级;**不要**因为读到未知值而清空角标、抛异常或中断渲染。
- 判定**只比对 `id`**(大小写敏感,契约 id 为小写);`label` 仅作显示默认值。
- Web 端镜像实现在 `frontend/web/src/lib/enums.ts`(`labelFor(kind, value)` 对未知值返回原值),权威仍以本端点为准。
- **反漂移保证(后端)**:后端有测试断言「每个内置适配器的 `Name()` 都在 `platforms` 表内」「`moods` 与 persona 情绪状态机的内置状态一致」「`modes` 与前端 `MODES` 九模式一致」——漏登记即 CI FAIL,客户端拿到的表不会滞后于代码。
### 1.2 文档存储与摄取契约(v0.61,design/24 §3/§4/§6 + §7 S6 第一段)
> **一句话**:文档 = 「内容寻址的存储 + 服务端抽取 + 清单/正文注入」三段;**读侧要 Bearer**(与图片读侧刻意相反),客户端只需要**两个端点 + `chat.documents` 一个字段**,外加一个 `documents` 数组 DTO。
**① 上传 `POST /api/v1/documents`(owner|admin + 审计 `document.upload`)**
- 请求:`multipart/form-data`,字段 `file`(单个)。**上限** `CHAT_DOCUMENT_MAX_BYTES`(默认 **20971520 = 20 MiB**;`≤0` 视为用默认,**不存在「禁用上限」的取值**);请求体额外留 1 MiB multipart 余量,**文件本身按确切上限校验两次**(部件大小 + 流式写入时多读 1 字节)。
- 响应:
```json
{"data":{"name":"a1b2…(32位hex).md","url":"/api/v1/documents/a1b2….md","mime":"text/plain","size":12345,"sha256":"<64位hex>","deduped":false}}
```
首次 `201` + `deduped:false`;**同字节同扩展名重复上传 → `200` + 同一个 `name` + `deduped:true`**(落盘名 = `sha256(bytes)[:32] + ext`,**内容寻址**;客户端原始文件名只用来取扩展名,**永不参与落盘路径**)。**200 可当成功处理**——内容与首次逐字一致。
- **`url` 是相对路径**(`/api/v1/documents/<32hex>.<ext>`),**不是**图片侧那种绝对同源 URL:读侧要 Bearer,贴进 `<img>` 也取不到;客户端真正要用的是 **`name`**(`chat.documents` 只吃它)。
- **类型裁决(唯一裁决权 = 字节嗅探;v0.61 起为成对裁决)**:
- **扩展名白名单(deny-by-default,23 个)**:`.txt .md .markdown .csv .tsv .json .jsonl .yaml .yml .log .ini .cfg .toml .go .ts .tsx .java .c .h .cpp .rs .sql`,**外加 `.pdf`(v0.61 新增,第 23 项)**;
- **字节嗅探(`http.DetectContentType`)与扩展名成对判定**(代码里就一个裁决函数 `documents.ContentAllowed`):**纯文本类**(上列除 `.pdf` 外的全部文本类扩展名)嗅探基类型必须恰为 **`text/plain`**;**`.pdf`** 嗅探基类型必须恰为 **`application/pdf`**(PDF 除外说明:它是白名单里**唯一**签名与纯文本不同的格式);
- 两者是**合取**:`.js` / `.py` 即便嗅探成文本也拒(不在白名单);改扩展名也换不了类型(**嗅探说了算**),且自 v0.61 起**双向**——**HTML/纯文本改名成 `.pdf` ⇒ `415`**(嗅探不是 PDF),**PDF 改名成 `.txt` ⇒ `415`**(PDF 字节也只收在 `.pdf` 名下);
- **明令拒绝**:`text/html`、`image/svg+xml` 与 XML、`application/zip` 及其封装(docx/xlsx)、可执行/脚本(exe/dll/bat/cmd/ps1/js/mjs/sh/py)、判不定者;
- **`mime` 是嗅探所得基类型**(去掉 `; charset=…`):`data.json` 的 `mime` 是 `text/plain`,**不是** `application/json`;真 PDF 的 `mime` 是 `application/pdf` —— 客户端按 `name` 的扩展名判语义,别拿 `mime` 当类型。
- 错误码:`415 unsupported_media_type`(**扩展名白名单(23 项)与字节嗅探的成对裁决**任一不过——如 HTML 改名成 `.pdf`、或 PDF 改名成 `.txt`)/ `413 document_too_large`(文件或 multipart 体超限)/ `400 bad_request`(缺 `file` 部件、空文件、非 multipart)。**审计**:成功 `document.upload`+`ok`、失败 +`error`(成对发生,失败分支不遗漏)。
**② 读取 `GET /api/v1/documents/{name}`(owner|admin,无审计)**
- **必须带 `Authorization: Bearer <JWT>`**:无 token → `401`、角色不足 → `403`。这是与 `GET /api/v1/files/{name}`(免鉴权能力 URL)的**关键差异**——文档摄取在服务端完成,不需要被 `<img>` / provider 直接消费。
- `name` 形态 = `32 位小写十六进制 + '.' + 白名单扩展名`;**路径穿越全形态拒绝**(`..` / `%2e%2e` / `%2f` 等)。
- 响应头:`Content-Type` = **读时重新嗅探**的基类型(**同一对裁决**:纯文本类 → `text/plain`;**真 PDF 如实下发 `application/pdf`**;嗅不出放行类型 → `application/octet-stream`)、`Content-Disposition: attachment; filename="<name>"`、`X-Content-Type-Options: nosniff`、`Cache-Control: private, max-age=86400, immutable`(内容寻址 ⇒ 名字变则内容变);支持 Range。⚠️ **PDF 也永不内联**:`attachment` + `nosniff` 一字未变,浏览器不会把 `.pdf` 当插件文档渲染。
- 错误:`404 document_not_found` —— **名字非法 / 路径逃逸 / 文件不存在三种情况同一响应**(不泄露「是否存在」以外的信息)。
**③ `chat.documents`(REST 同步 / REST `stream:true` / WS `chat` 三处同形)**
```json
{"sessionId":"web","text":"帮我看看这份","documents":[{"name":"a1b2….md"},{"name":"c3d4….csv"}]}
```
- **只给 `name`**(服务端存储名)。客户端多带的 `mime` / `url` / `size` **一律被服务端覆盖**(MIME 与大小取自落盘字节);**绝不接受任意 http(s) URL**——不重开 F3 那种「服务端代取 URL」的 SSRF 面。
- **≤5 条/回合**:超出 → `400 too_many_documents`,且该判定**先于任何文件系统访问**。
- 名非法 / 穿越 / 文件不存在 → `404 document_not_found`;**任一条不合规都让整个请求被拒**——绝不「丢掉这条附件但照样起回合」(静默丢附件正是 design/24 §4.4 禁止的「传上去了但双方都不知道」)。WS 侧经 `error` 帧带 `data.code` 下发并中止本回合。
- WS 上行帧仍有 **4096 字节**硬上限 ⇒ 现实中文档走 REST;两处**共用同一份解析与校验**,不会分叉。
- 附件部件挂在**同一条 user 消息**上;**纯附件回合**(无文本、无图片)同样成立,不会被判成空回合。
**④ DTO:`documents` 数组(历史与实时同形)**
`GET /sessions/{id}/messages`、`message_persisted`、`channel_persisted`、`message_edited`、握手 `history` 回放的 `historyMessage` 上是**同一个键**(同一结构体):
```json
"documents":[{"name":"a1b2….md","mime":"text/plain","size":12345,"chars":8000,"truncated":false,"status":"extracted"}]
```
读不到时(v0.63)多一个**可选** `reason`:
```json
"documents":[{"name":"a1b2….pdf","mime":"application/pdf","size":2048,"status":"unreadable","reason":"无法抽取正文:这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)"}]
```
- **≤5 条**,顺序 = 客户端发送顺序;**0 个附件时该键省略**(不是 `[]`,与引入文档之前的形状一致)。
- `status` ∈ `extracted`(正文已进上下文)/ `listed`(只给了模型一份附件清单,正文没进上下文)/ `unreadable`(读不到正文)。**PDF(v0.61)**:**文本层 PDF ⇒ `extracted`**;**扫描件/图片型 PDF(无文本层)⇒ 诚实拒绝 `unreadable`**,原因文案含「这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)」——**绝不假装成功、也绝不返回空正文**;**加密 PDF** 与**损坏/截断 PDF** 是**可区分**的两类失败(加密的提示解除密码后重试)。
- **`reason`(v0.63,可选,`omitempty`)**=**读不到的原因文案**,**只在 `status:"unreadable"` 时下发**(`extracted` / `listed` 时该键**缺席**,客户端按「缺失 ⇒ 回落现有通用句」实现即可)。它**逐字就是服务端注入给模型的那份原因**(同一字符串,两处同源),因此客户端**不必自己拼**「图片型 PDF,需要 OCR」这类服务端没说过的话。当前文案集合(服务端常量,逐字):
- `无法抽取正文:这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)`(扫描件/图片型 PDF,`ErrNoTextLayer`)
- `无法抽取正文:PDF 已加密(无法用空口令打开),请解除密码后重新上传`
- `无法抽取正文:PDF 损坏或无法解析`
- `无法抽取正文:本期不支持 Word/Excel/PPT 解析(无文本层)`(Office)
- `无法抽取正文:内容不在可抽取的纯文本范围内`(非文本/二进制)
- `无法抽取正文:文档文件不存在或已被清理` / `无法抽取正文:读取文档失败` / `无法抽取正文:文档引用形态非法` / `无法抽取正文:文档上传目录未配置`
- `无法抽取正文:读取或解析失败`(解析器整条消息失败时的诚实兜底)
⚠️ **只放文案**:不含落盘路径、不含凭据、不含存储名以外的内容;原始错误(可能带路径)**只进服务端日志**,绝不透出(有端到端用例用「独特临时目录名」做反证断言)。
⚠️ **时点**:原因与 `status`/`chars`/`truncated` 同批,由**回合结束后**的回写写进 `document` 列(S3.5)——因此它**只在读取面**(`GET /sessions/{id}/messages`、握手回放)可见;`message_persisted` 帧在用户消息落库时就发出(那时还没有结论,这与 `status` 的既有行为一致)。
- ⚠️ **`chars` / `truncated` 是「本回合注入侧」事实**:`chars` = **本回合注入**模型上下文的字符数、`truncated` = **注入**是否被截断。**它不是「文件有多少字」、也不是「文件是否被截断」**——全文长度只有 `document-read` 的 `totalChars` 能看到;「抽取期撞上 8 MiB 安全上限」只有 `extractTruncated` 能看到。
- **不含正文**(结构上保证:正文若从历史回传,每翻一页都要背着它)。
- ⚠️ **破坏性提示**:旧文档里出现过单数 `document`(对象)的形状,**但那个形状从未随任何已发布契约版本号发布过**(客户端无从实现)⇒ 本版**不留兼容期**——客户端按 `documents`(数组、可缺失)读取即可。
**⑤ 内置工具 `document-read`(模型侧工具;客户端一般只需知道它存在)**
- 注入被截断时,模型可见的清单行给出**续读游标**(`offset=N`),模型用 `{name, offset?, limit?}` 继续读;
- 返回(JSON 文本):`{name, status, offset, limit, totalChars, truncated, nextOffset, extractTruncated?, text}`;
- `limit` 默认 **8000** 字符、上限 **64K** 字符(`≤0` 按缺省、超上限**夹取**);
- `offset` 单位是**字符(rune)**、**0 基**、起点 = sidecar 正文(**含 CSV/TSV 摘要行**)第 0 个字符;
- `offset < 0` 或 `offset > totalChars` → 工具错误(`offset == totalChars` 合法,读到末尾的空片段);`truncated=true` 时 `nextOffset` 是续读起点;
- **`extractTruncated=true` 专指「抽取期撞上 8 MiB sidecar 安全上限」**——此后「正文到此为止」≠「原文件到此为止」(它是与「本回合注入被截断」**完全不同的两件事**)。
- **PDF 文本层(v0.61,切片 B1)**:**文本层 PDF 抽正文**(页内容流里的 `Tj`/`TJ`/`'`/`"` 文本算子,页与页之间以换行相接),与纯文本类走**同一条** sidecar / 注入路径;**扫描件/图片型 PDF ⇒ `unreadable`**(见上 ④,需要 OCR,当前未做);**加密**与**损坏/截断**各自成类、可区分。**安全阀**:单份最多抽 **5000 页**、sidecar 正文上限仍是 **8 MiB**(撞上即 `truncated:true`)。**⚠️ 客户端可见的一次性重算**:抽取器版本内部升到 **`documents/v3`** ⇒ **旧 sidecar 一次性失效重抽**,同一份**旧附件**的 `chars`/`truncated` 结论会在**某次回合之后**重新计算(原先被诚实拒为 `unreadable` 的旧 PDF,若是文本层 PDF 就会变成 `extracted`)——这不是附件被改写,是结论被重算。
- **只读 sidecar、绝不重抽**:读取不会打乱 `status`/`chars`/`truncated` 的持久统计;原文件在抽取之后被删除也不影响读取。
- **策略默认 owner-only**(`tool_policies` 无行时 fail-closed):群聊/陌生人**根本调不到**——是安全增益。
- **失败必须可见**:名非法 / 没有抽取结果 / 抽取结论为 `unreadable` → 明确报错,绝不返回空正文假装成功。
**⑥ 与图片链路的边界(一张表)**
| 面 | 图片(B1) | 文档(v0.59) |
|---|---|---|
| 上传 | `POST /api/v1/upload`,字段 `file`,5 MiB | `POST /api/v1/documents`,字段 `file`,20 MiB(`CHAT_DOCUMENT_MAX_BYTES`) |
| 读侧鉴权 | **无**(128-bit 随机名即能力 URL) | **必须 Bearer(owner\|admin)** |
| 读侧响应 | 按扩展名回 image MIME,可内联 | `Content-Disposition: attachment` + `nosniff`,**永不内联** |
| 响应 `url` | **绝对**同源 URL | **相对路径** |
| 客户端字段 | `chat.image`(base64 / URL) | `chat.documents[].name`(服务端存储名) |
| 去重 | 不适用(每次新随机名) | **内容寻址**:同字节同扩展名 → 同一 `name` |
> **发现的不一致(如实登记,2026-10-04 复核)**:design/24 §9.2 与 §9.13 写「白名单 **22** 项——原文的 23 项是笔误,以代码为准」,但 **B1(commit `f82c76d`)纳入 `.pdf` 后代码已是 23 项**(`internal/runtime/documents/store.go` 的 `ExtAllowed` = 22 个文本类 + `.pdf`;`internal/services/gateway/documents.go` 的名字形态校验经它判定)——**以代码的 23 项为准**;design/24 §9.2/§9.13/§9.4 第 1 条(「不引解析库」)**均已滞后于 B1 实现**(PDF 文本层已用纯 Go 解析库落地;扫描件与 Office 仍诚实拒绝),本次只改 Docs、**未改 DevDocs**。另:design/24 §6.2 的 `document_unreadable`(400) **在代码里没有对应 REST 错误码**,它只以 `document-read` 的工具错误形态存在(见本节 ⑤),客户端可在 DTO 里看到该文档的 `documents[].status = "unreadable"`。
> **契约版本 `v0.65`**(**忙时并行回合基础管线(I1′ 有条件放宽)+ 六条登记**,依据 design/15 §2.5(主人 2026-10-05 裁决 B)与 commit `89e13cf`,并一并登记 `679cee0`(IM 入站附件引用落库)/ `59a1ac4`(`llm_usage.session_id` 接线)/ `a918819`(IM 各渠道语音可被读到)与 `cb564fe` / `15181e7` / `7d2ff74`(纯内部修,无契约变化),2026-10-05 落实)。**一句话**:会话**正忙**时,客户端可**显式声明**把新消息作为**独立一路**立即起(单会话默认 **2** 路、硬上限 **3**、`<2` = 关闭真并行),忙态帧因此开始报「在途路数」;同版把三条过期陈述与两条 IM 链路事实拉回与代码同步。**三档标注**:**纯增量** = ① 上行 `chat.parallel?:bool`(WS `chat` 帧 + REST 三入口**同形**)、② `session_busy` / `session_idle` / `pending_drain` / `session_interrupted` 的 `data` 增 `activeTurns:int`、③ `session_interrupted.data` 增 `cancelledTurns:int`、④ `pending_ack.data` 增**可选** `reason:"parallel_limit"`、⑤ REST 两条新应答形状(同步 `202 {data:{parallel:true,turnId}}` / `stream:true` 的 `200 {data:{stream:true,parallel:true,turnId}}`)、⑥ 新 env 键 `VOICE_STT_TIMEOUT_SEC`(默认 20s);**行为变更** = ⑦ IM 入站附件引用**真的写进落库的用户消息**(`documents` 列,**同一 DTO、无新字段**)、⑧ **IM 各渠道语音进上下文**(云端 ASR 转写为文本;不成功时落一行中文诚实提示,**绝不静默/空消息**)、⑨ **`session_idle` 时点收紧**为「所有在途路收尾且 pending 空」(**仅并行时生效**,单路逐字不变);**破坏性** = 无(**省略 `parallel` / 不读新键的客户端行为逐字不变**)。**A. `chat.parallel`**——`POST /api/v1/chat` body 与 WS `chat` 帧 `data` 同形新增可选 `parallel:bool`(`omitempty`),语义 = 「把本条作为**独立一路**处理」;**只在会话已有在途回合、且并发额度未满时照准**,**空闲时无效果**(照常起第 1 路),**服务端不做任何启发式自动升级**(触发权在客户端,design/15 §2.5.1)。**B. `activeTurns` / `cancelledTurns`**——均**纯增量整数**、落在既有「状态机信号」白名单帧内(不含内容,所有已认证连接含 `viewer` 都收得到);`activeTurns` = 该会话当前**在途路数**(含正在收尾的路;单路时非 0 即 1),`cancelledTurns` = 本次真的取消了几路。**C. `pending_ack.reason`**——**只在「客户端显式声明了并行、本条却落回排队」时出现**(额度已满,**或**真并行被 `<2` 关掉——客户端问了并行而没拿到),其它情况该键**完全缺席**(绝不伪造)。**D. REST 新应答形状**——**只在 `parallel:true` 且被照准时才可能出现**;`turnId` 就是那一路的权威回合标识、与该路全部流帧同值;**未照准**(未声明 / 空闲 / 额度满)时形状与既有逐字相同。**E. `interrupt` 多路口径**——`scope:"current"` 省略 `turnId` 时取消的是**「当前全部在途路」**(不是只停一路);`scope:"all"` 取消全部在途路 + 清队列、且**不发 `session_idle`**(与今天一致,客户端靠 `session_interrupted` 收口);`interrupt.turnId`(精确停某一路)**本版未实现**。**F. `turnId` 生成方变更**——由「网关每回合内部新生成」改为「**编排器生成 → loop 复用**」;帧形状与取值分布不变(单路仍是每回合全新的 `turn-<hex>`)⇒ **对外无可见差异**,写在此处只为让「谁生成」的口径与实现一致。**G. IM 附件引用落库(`679cee0`)**——此前 IM 路径只把引用挂在**内存里的模型可见消息**上、落库那一步没写 `documents` 列 ⇒ 客户端读到的那条消息**一片空白**(安卓报的 P0);现在复用 `history.EncodeDocuments` 同一口径写该列,并在回合结束后按 id 回写抽取结论。**契约零扩张**:无新端点 / 帧 / 字段;**0 个附件 ⇒ 键省略**;形状即 Web 的**同一个结构体**。**H. IM 语音(`a918819`)**——入站语音在流水线**最前端**归一为文本:**平台自带转写优先**(微信 iLink 非空 ⇒ 不调 ASR,不重复付费),否则走云端 ASR;**降级绝不静默**——静默档位被识破(只认 `STTOutcome.Transcribed()`),鉴权失败 / 欠费 / 限流 / 超时按原因分类并做**临时熔断 + 退避**(TTL 恢复),无论是否熔断,消息面都落**一行中文诚实提示**。**新 env 键 `VOICE_STT_TIMEOUT_SEC`**(默认 **20** 秒;`≤0` 用默认):**只约束 IM 渠道入站语音的这一次转写**(流水线要为它一直举着一个入站并发槽),**Web 客户端的 `voice_input` 不受它约束**;**无新端点 / 新帧**。**I. `llm_usage.session_id` 修正(`59a1ac4`)**——v0.64 的「Line truncated
> **上一版 `v0.64`**(**LLM 用量落库 + 只读汇总端点 + 面板用量小卡**,依据主人 2026-10-04 夜裁决「**2.4 落**」(在「usage 落库」「只看日志」之间选前者)与 T1(commit `72b95df`,已在每次模型调用后记结构化用量日志但**只落日志**)。**一句话**:把那条日志里的**非敏感**样本落进新表 `llm_usage`(迁移 `0017`),并新增一个只读汇总端点与一张面板小卡 —— 从此看得见趋势与按用途/模型的成本,而**写入绝不阻塞模型调用**。**三档标注**:**纯增量** = 新端点 `GET /api/v1/admin/llm/usage`、新迁移 `0017_llm_usage`、新 env 键 `LLM_USAGE_RETENTION`(默认 `720h`,**≤0 = 关闭清理**)、面板 LLM 页新小卡、前端新 API `adminLlmUsage()`;**行为变更** = 无(既有端点 / 帧 / 字段形状一字未动;`model` 层新增的是**可选** sink,**不注入时行为逐字不变**——既有 model 用例零改动即全绿);**破坏性** = 无。**A. 写入(关键取舍)**:`model` 层**不直接开库**,只在既有的 `logUsage` 处多一个**可选**接口 `model.UsageSink`(`nil` = 只记日志,逐字等于引入本版之前);组合根注入 `model.UsageWriter` —— **有界队列(1024)+ 单写者 + 批量事务(128/批,2s 兜底)**,队列满**丢弃并限流 WARN(30s 一条)**,**绝不阻塞、绝不 panic、绝不无界增长**;整批落库失败只记一条带来源的 WARN。sink 挂在 `RuntimeLLM` 上(而不是每个适配器上),因此面板每次热重载 models.json 重建适配器时,落库**不会静默断一次**;**每一条链**(主/备/本地第三级/按角色跨协议)都带上它。**B. 表与索引**:`llm_usage`(`id/session_id/purpose/adapter/model/prompt_tokens/completion_tokens/cache_hit_tokens/cache_miss_tokens/reasoning_tokens/total_tokens/took_ms/created_at`)+ `idx_llm_usage_created_at`(汇总与清理的依赖)+ `idx_llm_usage_session_id`。**`session_id` 当前恒为 `NULL`**(模型层无会话概念;要填需 loop 在请求上打标,属本轮并行切片在途文件,故留列后接)。⚠️ **该状态已由 v0.65 更正**:loop 已把会话 id 播种进运行 ctx、适配器落库时带上它(见 §1.2 与 §5 v0.65 条目 I)——**该列不再恒为 `NULL`**。**C. 读取**:`GET /api/v1/admin/llm/usage?since=&until=&purpose=`(**owner|admin 读**,与同页 `/admin/llm` 同门;**不审计**——它是查询不是动作)→ `{data:{window:{since,until,purpose},totals:{calls,prompt,completion,cacheHit,cacheMiss,hitRate},byPurpose:[…],byModel:[…]}}`;缺省窗口 = **近 24h**,跨度上限 **90 天**(超过 → `400`,**不静默裁剪**);`since`/`until` 接受 **RFC3339 或 Unix 毫秒**;**`hitRate` = cacheHit/(cacheHit+cacheMiss),分母 0 ⇒ `0`**(口径已写进文档与前端展示规则:分母 0 显示 `—`,不显示 `0%`);分解按**调用次数降序、同次数按键升序**;**绝不含凭据与消息正文**。**D. 保留期**:`LLM_USAGE_RETENTION`(默认 `720h` = 30 天;**≤0 = 关闭清理**,日志写明)→ 启动期清一次 + 每小时清一次(复用文档 GC 的既有模式;清扫间隔不做成 env 键)。**E. 面板**:LLM 页新增「用量(近 24h)」小卡(`frontend/web/src/admin/modules/llm/LlmUsageCard.tsx`)——调用数 / 输入 token / 缓存命中率;取数与其它卡片同一条 `useAsyncData` 加载/错误态(**读不到显示错误,绝不显示 0**)。**F. 失败不伪装**:汇总出口未接线或查库失败 → `500 usage_unavailable`,**不回全 0 假汇总**。详见 §2「LLM 用量汇总契约(v0.64)」。
> **上一版 `v0.63`**(**客户端登记两条落地:`documents[]` 增可选 `reason` + `GET /sessions/{id}/messages` 对不存在的会话回 `404 session_not_found`**,依据 `Cyrene-For-Android/devdocs/02-前后端协同事项.md` 的「登记(客户端会话,2026-10-05 凌晨 · 跟上 v0.61 的 `.pdf` 之后的两条)」与「登记(… · 一条 API 语义建议:**已删除的会话「看起来像空会话」**)」两条,以及后端「回执九」(2026-10-05 凌晨)的选择与承诺,2026-10-05 落实)。**三档标注**:**纯增量** = ① `documents[]` 元素增**可选** `reason`(`omitempty`,只在 `status:"unreadable"` 时下发;不读该键的老客户端行为**逐字不变**);**行为变更** = ② `GET /api/v1/sessions/{id}/messages` 对**不存在 / 已删除**的会话由 `200 {"data":[]}` 改为 **`404 {"error":{"code":"session_not_found"}}`**(**客户端需处理 404** —— 回执九已告知,客户端此前先用自建兜底);**破坏性** = 无(既有字段形状、既有成功响应一字未动)。详见 §1 行 6、§1.2 ④ 与 §5 变更记录。
> **上一版 `v0.62`**(**面板「重置为 env 默认」动作落地:materialize 的反方向显式动作 + 一个新的 owner-only 管理写端点**,依据任务书 `DevDocs/_dispatch/R1-面板重置为env默认.md`、design/04 §3.1(`.env` 为默认/播种层、面板文件为一等来源)与 `DevDocs/开发时间线.md` 的欠账项(「Materialize 已导出、未接端点」),2026-10-04 落实)。**一句话**:materialize 是「面板缺该键才用 `.env` 补」,本版新增的 `POST /api/v1/admin/llm/reset-from-env` 是**反方向的显式动作**——部署者要求「把面板里 `.env` 声明过的键退回 env 值」。**A. 语义(与补齐表同源)**:只覆盖 `.env` 声明了非空值的键(`provider`/`baseUrl`/`apiKey`/`model`/`contextWindow`)、**env 未声明的键一律保留面板值**(`fallback`/`multimodal`/`maxTokens`/`reasoning`/`personaModels` 永不被改)、**组集合不增减**(不复活被删的组、不删新增的组,顺序也不动)、面板值已与 env 一致的键不算「改回」、**无键可改时不写盘**(幂等);真的改了盘就**热重载**运行中的 LLM 栈(`contextWindow` 的预算推导仍只在启动时算一次)。**B. 护栏 / 响应 / 审计**:**owner-only**(与同页两条 LLM 写端点同门;破坏性写不比同族更宽),body 必须 `{"confirm":true}`(缺失 / `false` / 非法 body ⇒ `400 bad_request` 且**一个字节都不动**)→ `200 {data:{reset:[{group,keys:[…]}], skipped:[{group,reason}], path}}`;**只回键名与中文原因,凭据值(尤其 `apiKey`)绝不回显、绝不进日志**;审计动作 `llm.resetFromEnv`(`ok`/`error`)。**C. 基准与失败**:对照基准 = 请求时刻按**既有解析入口**读到的 `.env` 默认层(新增 `config.LoadLLMDefaults`,路径经 `Deps.EnvPath` 注入,**网关绝不 fopen `.env`**);`.env` 源不可读 → `500 env_defaults_unreadable`、面板配置不存在/不可解析 → `500 models_unreadable`、写盘失败 → `500 llm_persist_failed`、已写盘但热重载失败 → `500 llm_reload_failed`、LLM 栈未注入 → `500 llm_unavailable`——**都不假装成功**。**D. 面板**:LLM 页新增「重置为 env 默认」卡片(`admin/modules/llm/LlmResetFromEnv.tsx`,行上标 `owner 专属`,**中文二次确认**后才提交)。**E. 兼容性**:**纯增量**(新端点 + 新前端卡片 + 新审计动作 + 2 个新错误码 `env_defaults_unreadable` / `models_unreadable`,另复用既有 `llm_persist_failed` / `llm_reload_failed` / `llm_unavailable`),既有端点 / WS 帧 / 字段形状**一字未动**;管理侧权限矩阵因此为 12(viewer 只读)+ 10(owner/admin 读)+ **38**(owner-only)= **60** 条路由。详见 §2「重置为 env 默认契约(v0.62)」。**上一版 `v0.61`**(**PDF 文本层抽取落地:白名单第 23 项 `.pdf` + 类型裁决由「嗅探只放行 `text/plain`」改为 `(扩展名, 嗅探)` 成对裁决**,依据 design/24 §7 的 **S6 第一段**(PDF/Office 的裁决:PDF 文本层引库,扫描件与 Office 仍诚实拒绝)与 commit `f82c76d`,2026-10-04 落实)。**三档标注**:**行为变更** = ① **`.pdf` 由「一律 `415`」变为「可上传 + 可读取 + 文本层可抽取进上下文」**(把「`.pdf` 必被拒」写死的客户端必须改);② **类型裁决收紧为成对判定**(`documents.ContentAllowed`)——**方向是收紧/细化,不是放宽**:HTML/纯文本改名成 `.pdf` ⇒ `415`(嗅探不是 PDF),**PDF 字节改名成 `.txt` ⇒ `415`**(PDF 字节也只收在 `.pdf` 名下);**纯增量** = 扩展名白名单 **22 → 23 项**(唯一新增 `.pdf`),其余 22 个文本类的判定与既有响应**一字未变**。**A. 上传(`POST /api/v1/documents`,owner|admin)**——白名单加 `.pdf`;裁决 = **字节嗅探是唯一裁决权 ∧ 扩展名白名单**,自本版起**按对判定**(纯文本类必须 `text/plain`、`.pdf` 必须 `application/pdf`;落点 `internal/runtime/documents/store.go` 的 `ExtAllowed`(23 项)/ `ContentAllowed`);`mime` 仍是**嗅探基类型**(真 PDF → `application/pdf`);`415` 文案同时点明两条口径。**B. 读取(`GET /api/v1/documents/{name}`,必带 Bearer owner|admin)**——**真 PDF 如实下发 `Content-Type: application/pdf`**(读时按同一对裁决重嗅探,判不定者仍退 `application/octet-stream`),`Content-Disposition: attachment` + `X-Content-Type-Options: nosniff` **一字未变** ⇒ **PDF 永不被内联渲染**(与图片读侧刻意相反)。**C. 抽取语义(DTO `status`)**——**文本层 PDF 抽正文**(`Tj`/`TJ`/`'`/`"` 文本算子,页间以换行相接)⇒ `status:"extracted"`,与纯文本类走同一条 sidecar/注入路径;**扫描件/图片型 PDF ⇒ 诚实拒绝 `status:"unreadable"`**,原因含「这是图片型/无文本层 PDF,需要Line truncated
## 2. REST 端点(管理,`/api/v1/admin/*`)
> 仅供管理面板与授权客户端;RBAC:`owner`/`admin`/`viewer`;**写操作全程审计**。角色分档见下方「管理侧权限矩阵」(v0.40 起 `viewer` 真正可用:只读监控档,其余一律 `403`)。
### 2.0 管理侧权限矩阵(v0.40 起,与 `internal/services/gateway/routes.go` 逐条一致)
- **匿名(无 token / token 无效)**:任意 `/api/v1/admin/*` → `401 {error:{code:"unauthorized"}}`(fail-closed,全部 **61** 条管理路由无一例外 —— 12 条 viewer 档 + 11 条 owner/admin 档 + 38 条 owner-only 档)。
- **角色分档**(`✅` 放行,`❌` 回 `403 forbidden`;owner>admin>viewer 递增):
| 端点组 | owner | admin | viewer |
|------|:---:|:---:|:---:|
| **只读监控档**(12 条 GET,见下①) | ✅ | ✅ | ✅ |
| **owner/admin 只读档**(11 条,见下②) | ✅ | ✅ | ❌ |
| **owner-only 写/凭据档**(38 条,见下③) | ✅ | ❌ | ❌ |
① **`viewer` 可读的 12 条只读监控档**(`viewerAuth` = owner/admin/viewer)——模块清单 + 运行时指标/日志/通知,全部是**非敏感投影**(无凭据、无他人账号数据、无消息正文):
`GET /admin/personas`、`GET /admin/sessions`、`GET /admin/plugins`、`GET /admin/plugins/installed`、`GET /admin/channels`、`GET /admin/iot`、`GET /admin/automations`、`GET /admin/rag`、`GET /admin/observability/metrics`、`GET /admin/observability/trace`、`GET /admin/observability/logs`、`GET /admin/notifications`。
② **仅 `owner`/`admin` 的 11 条**(`viewer` → `403`;理由:含**他人数据 / 隐私正文 / 完整导出 / 审计流水 / 全局凭据面 / 成本面**):
`GET /admin/memories`(私人记忆正文)、`GET /admin/channels/messages`、`GET /admin/channels/{platform}/messages`(完整 IM 消息流/转录)、`GET /admin/llm`(provider/模型配置面)、`GET /admin/llm/catalog`(模型目录候选面,**v0.53 新增**)、**`GET /admin/llm/usage`(LLM 用量/成本汇总面,**v0.64 新增**——含 token 成本与模型/用途分解,故与 `/admin/llm` 同门,**不放给 viewer**)**、`GET /admin/users`(账号与 API key 列表)、`GET /admin/backthink`(后台思考记录)、`GET /admin/observability/audit`(审计流水)、`GET /admin/sessions/{id}/export`(会话导出)、`POST /admin/rag/query`(知识库检索,语义上是读,但按「全局知识面」归此档)。
③ **仅 `owner` 的 38 条**(`admin` 与 `viewer` 均 `403`)——全部写操作,外加**凭据获取路径**(`login-qr`/`login-status`,`view`/`friends`,渠道连接/测试/配置)与**主人身份档案**(决定谁能进 owner 车道,连读都不给 admin):
`GET /admin/channels/{platform}/login-qr`、`login-status`、`view`、`friends`;`POST /admin/channels/{platform}/friend/handle`、`message/test`;`PUT|DELETE /admin/channels/{platform}/config`;`POST /admin/channels/open|close|test`;`POST /admin/plugins/policy|install|uninstall`;`POST /admin/personas`、`PUT /admin/personas/{id}`;`GET|PUT /admin/owner-profile`(v0.51);`POST /admin/users`、`POST /admin/users/apikey`、`DELETE /admin/users/apikey/{id}`;`POST /admin/iot/{id}/control`、`POST /admin/iot/scenarios`、`POST /admin/iot/rules`;`POST /admin/automations`、`POST /admin/automations/{id}/run`、`DELETE /admin/automations/{id}`;`POST /admin/llm`、`POST /admin/llm/test`、**`POST /admin/llm/reset-from-env`(v0.62 新增,破坏性写——覆盖面板对若干键的修改,因此门与同页两条 LLM 写端点一致,**不比同族更宽**)**;`POST /admin/memories`、`POST /admin/memories/search`;`POST /admin/rag`、`POST /admin/rag/delete`;`POST /admin/system/revoke-keys|rotate-jwt|backup|restore`。
> **用户侧同级只读**(非 admin 前缀,同样任意已认证角色可读):`GET /api/v1/sessions`、`GET /api/v1/personas`、`GET /api/v1/meta/enums`(§1 行 4/9/15)。
> **已知遗留**:前端管理面板**尚未**按角色隐藏/禁用分区——`viewer` 打开②③档页面会拿到 `403`,客户端需按本矩阵自行降级(后端契约已定,前端适配后置)。
| # | 端点 | 方法 | 状态 | 说明 |
|---|------|------|:---:|------|
| 1 | `/api/v1/admin/personas` | GET | ✅ | 人格列表(`viewer` 可读)→ `{data:[PersonaSummary...]}`,每项含用户侧全部字段,**另加 admin 专属 `thinking?`(v0.43)**:`{budgetTokens,effort,showToUser,mode}`——见下方「人格 `thinking` 读侧契约」 |
| 2 | `/api/v1/admin/sessions` | GET | ✅ | 持久化会话列表(主会话 + 次级会话,design/16 §2);未注入 HistoryStore 时回退渠道会话视图。**v0.42 起每项含可选 `personaId`**(与会话级选角同源,见 §1 行 4) |
| 3 | `/api/v1/admin/memories` | GET | ✅ | 记忆列表(手动添加/检索已实现;编辑/删除/衰减随 M10 扩展) |
| 4 | `/api/v1/admin/plugins` | GET | ✅ | 插件/能力列表(含 MCP/渠道) |
| 5 | `/api/v1/admin/plugins/policy` | POST | ✅ | 写工具策略(owner-only,审计);**策略落盘 SQLite `tool_policies`,重启保留**(迁移 `0012_tool_policy`)——`registry.SetPolicy` **先落库后改内存**,落盘失败则内存保持旧值并返回错误 |
| 5a | `/api/v1/admin/plugins/installed` | GET | ✅ | **已装运行时插件列表**(owner/admin 读):`{data:[{id,name,kind,transport,endpoint?,command?,args?,apiKey?,env?,createdAt?,live,toolCount}]}`,`apiKey`/敏感 `env` 值**掩码**(如 `****1234`),`live` = 是否已连接注册,`toolCount` = 该 server 注册工具数 |
| 6 | `/api/v1/admin/channels` | GET | ✅ | 渠道列表/健康 + 每平台已配账号(脱敏 token 掩码,design/04 §3.1);已连接渠道的账号附 **bot 身份**(`botId`/`botUsername`/`botNickname`,如 KOOK `GET /api/v3/user/me` 自动获取);适配器要求重新授权时附 **`needsRescan`** + **`lastError`**(v0.39,仅微信 iLink 返回 `errcode=-14`、会话被 iLink 侧拒绝这一种);**每账号附可选 `personaId`(v0.44)**=该账号的**渠道级角色绑定**(design/22 §2 方案 b;缺省 = 未绑定,非敏感、不掩码) |
| 7 | `/api/v1/admin/iot` | GET | ✅ | IoT 设备列表 |
| 8 | `/api/v1/admin/users` | GET/POST | ✅(GET) | 用户/角色/API key(GET 已实现;**key 的签发/吊销已实现**——`POST`/`DELETE /admin/users/apikey`,见下方写操作契约;`POST` 创建**用户**仍 `501`) |
| 9 | `/api/v1/admin/llm` | GET/POST | ✅ | LLM 多厂商多模型(models.json):GET 列表(APIKey 脱敏,含 `provider` 协议与 `maxTokens` 预算);POST 保存组→落盘 `models.json`→**热更新**运行中的 LLM stack(design/04 §3.1,无需重启)。**v0.53**:组级新增 `reasoning` / `personaModels` / `personaProviders` 三键(见下方「模型配置契约(v0.53)」) |
| 9a | `/api/v1/admin/llm/catalog` | GET | ✅ | **模型目录候选**(owner/admin 读,v0.53;`?refresh=1` 强制在线刷新)——面板「添加提供商 / 添加模型」的 baseURL / 模型清单 / 能力位来源,服务端快照 + 离线兜底;失败 `503 catalog_unavailable`。见下方「模型配置契约(v0.53)」 |
| 9b | `/api/v1/admin/llm/reset-from-env` | POST | ✅ | **重置为 env 默认**(**owner-only 写**,v0.62,审计 `llm.resetFromEnv`):body `{"confirm":true}`(缺 = `400`)→ `200 {data:{reset:[{group,keys}], skipped:[{group,reason}], path}}`——**只覆盖 `.env` 声明过的键**、env 未声明的键原样保留、**组集合不增减**,无键可改时**不写盘**;**只回键名、绝不回显凭据值**。见下方「重置为 env 默认契约(v0.62)」 |
| 10 | `/api/v1/admin/automations` | GET/POST | ✅(GET) | 自动化与提醒(GET 已实现;POST 创建 M10) |
| 11 | `/api/v1/admin/backthink` | GET | ✅ | 后台思考记录 |
| 12 | `/api/v1/admin/rag` | GET | ✅ | 知识库文档列表(分块/嵌入状态);带 `?query=` 时并入检索结果 |
| 13 | `/api/v1/admin/rag/query` | POST | ✅ | 知识库检索(真实检索;配置向量增强走语义路径) |
| 14 | `/api/v1/admin/rag/delete` | POST | ✅(写) | 删除知识库文档(**真实删除**:项目索引条目 + 级联章节);owner-only,`{id}` → `{id,deleted:true}`,不存在 `404` |
| 15 | `/api/v1/admin/observability/metrics` | GET | ✅ | 网关请求指标(总数/QPS/延迟/方法·路径·状态计数/LLM·子代理计数) |
| 16 | `/api/v1/admin/observability/trace` | GET | ✅ | 最近 trace span(环形缓冲,默认 512 条;**loop/工具/编排的真实 span**) |
| 17 | `/api/v1/admin/observability/audit` | GET | ✅ | 审计记录(who/what/when/result,SQLite 持久化) |
| 17b | `/api/v1/admin/observability/logs` | GET | ✅ | **后端日志 tail**(logger 环形缓冲,默认 2000 条;`?level=`/`?limit=`,见下方「可观测性契约」) |
| 17a | `/api/v1/admin/notifications` | GET | ✅ | **统一通知 feed**(审计 + 主动推送聚合为只读 feed,design/10 §3 #16;见下方「通知聚合契约」) |
| 18 | `/api/v1/admin/memories` | POST | ✅(写) | 手动添加记忆(owner-only,审计);body `{personaId?,content,scope?}`,无 store 回 `501` |
| 19 | `/api/v1/admin/rag` | POST | ✅(写) | 导入知识文档(owner-only,审计);body `{name,desc?,content}`,分块后落项目索引可检索;无 store 回 `501` |
| 20 | `/api/v1/admin/system/revoke-keys` | POST | ✅(写) | 吊销**全部** API key(owner-only,审计);返回 `{revoked:n}`——key 表清空,先前签发的所有 key 立即失效 |
| 21 | `/api/v1/admin/system/rotate-jwt` | POST | ✅(写) | 轮换 JWT 密钥(owner-only,审计);body `{newSecret}`(≥32 位,不强校验熵)→ `{rotated:true,note,refreshTokensRevoked}`——**现有 access token 立即失效需重新登录,且**所有 refresh token 一并作废(`refreshTokensRevoked` 为删除条数);新密钥**不回显、不落日志** |
| 22 | `/api/v1/admin/system/backup` | POST | ✅(写) | 备份数据(owner-only,审计);用 SQLite `VACUUM INTO` 快照(含已提交 WAL 内容)到 `<dataDir>/backup-<ts>.db` → `{path,size}` |
| 23 | `/api/v1/admin/system/restore` | POST | ✅(写) | 恢复数据(owner-only,审计);body `{path}` → 校验路径在 dataDir 内→**先备份当前**→替换 DB 文件→`{restored:true,backup,note:"需重启后生效"}`(运行进程持有 DB 句柄,热替换需重启可见) |
| 24 | `/api/v1/admin/system/storage` | GET | ✅ | **存储状态(只读,v0.60)**:owner-only、**无审计动作**;`{data:{main:{kind:"sqlite",path,fileBytes,walBytes,shmBytes,pageSize,pageCount,freelistPages,freeBytes},documents:{count,bytes},vector:{kind:"pgvector"|"none",enabled,ready}}}`——**绝不含凭据**(DSN/密码/`postgres://` 全文一个字都不回);未接活连接 / 路径未配置 / `documents` 表读不到 → `500 storage_unavailable`(**不回全 0 假快照**)。详见下方「系统设置契约」 |
| 24a | `/api/v1/admin/llm/usage` | GET | ✅ | **LLM 用量汇总(只读,v0.64,切片 U1)**:**owner\|admin 读**、**无审计动作**;`?since=&until=&purpose=`(时间接受 RFC3339 或 Unix 毫秒;缺省窗口 = 近 24h;跨度上限 **90 天**,超过 → `400`,**不静默裁剪**)→ `{data:{window:{since,until,purpose},totals:{calls,prompt,completion,cacheHit,cacheMiss,hitRate},byPurpose:[…],byModel:[…]}}`;**`hitRate` = cacheHit/(cacheHit+cacheMiss),分母 0 ⇒ `0`**;**绝不含凭据与消息正文**;未接线 / 查库失败 → `500 usage_unavailable`(**不回全 0 假汇总**)。详见下方「LLM 用量汇总契约(v0.64)」 |
> **M10 前端契约补充(管理面板)**:`GET /api/v1/admin/users` · `GET /api/v1/admin/llm` · `GET /api/v1/admin/automations` · `GET /api/v1/admin/backthink` · `GET /api/v1/admin/rag` · `POST /api/v1/admin/rag/query` · `GET /api/v1/admin/observability/metrics` · `GET /api/v1/admin/observability/trace` · `GET /api/v1/admin/observability/audit` · `GET /api/v1/admin/observability/logs` · `GET /api/v1/admin/notifications` 已实现,返回真实数据(**日志 tail 见下方「可观测性契约」**;未注册 `GET /api/v1/admin/logs`)。**写操作已补齐**(见下方「管理面板写操作契约」):人格/API key/设备控制/自动化/LLM 测试/渠道连接与识别测试/记忆检索/会话导出/插件卸载**真实持久化或真实生效**;**系统设置安全与存储类**(吊销 API key / 轮换 JWT 密钥 / 备份 / 恢复)也已**真实实现**(见下方「系统设置契约」);**主人档案保存(v0.51)同样已实现**——`GET`/`PUT /api/v1/admin/owner-profile` 真实落盘 + 热替换活档案,面板的「保存」不再是假成功;仍为后置 M10:**新增用户**、**IoT 场景与规则**、**编辑环境变量/重启服务/检查更新**——后端如实返回 `501 not_implemented`,不伪造成功。(**知识库删除已实现**:`POST /admin/rag/delete` 真实删除,见下方写操作契约;**插件安装已实现**:v1 为 MCP server 热安装。)
**模型配置契约(v0.53)**(依据 design/04 §3/§3.1/§3.2(裁决 03.4.1 / 03.4.2 / 03.4.3 / 08.4.1)与 design/06 §0/§3(裁决 14.4.1 / 14.4.5);判定与校验以 `internal/config/runtimeconfig` + `internal/services/gateway/admin_llm*.go` 为准):
- **`GET /api/v1/admin/llm/catalog`(owner|admin 读,v0.53)** → `200 {data:{...}}`:
- **请求**:`?refresh=1` = 强制在线刷新;不带时,本地快照仍在 **24h TTL** 内就直接用它(不每次打开面板都打外网)。
- **响应 `data`**:`{source:"cache"|"models.dev", fetchedAt?, stale?, warning?, providers:[{id,name,api?,doc?,env?,npm?,models:[{id,name,attachment,reasoning,toolCall,contextLimit?,outputLimit?}]}]}`
- `source` 说明这份候选从哪来(`models.dev` = 本次在线抓取,`cache` = 本地快照);`fetchedAt` 为快照抓取时间(RFC3339,无快照时省略);**`stale:true` + `warning` 只在「在线刷新失败、返回上次快照」时出现**(离线兜底,**绝不编造候选**)。
- `api` = 该供应商 baseURL(目录未标注时省略,面板提示需手填);`doc` = 文档地址、`env` = 目录标注的官方 key 环境变量名(仅提示,面板不写 `.env`)、`npm` = SDK 包名。
- 每个模型:`attachment`(接受图片/文件输入 → 面板预填 `multimodal`)、`reasoning`、`toolCall`、`contextLimit?`/`outputLimit?`(目录未标注时为 `0` 而省略;`outputLimit` 直接作 `maxTokens` 建议值)。
- 供应商与模型都**按 id 排序**(输出确定、便于前端 diff);**没有模型清单的供应商不进列表**(避免噪声行)。
- **失败**:**既无本地快照又抓不到** → `503 {error:{code:"catalog_unavailable", message}}`(message 给出「可改用粘贴导入」的指引);**有快照但刷新失败** → **仍 `200`**(`source:"cache"` + `stale:true` + `warning`);快照文件损坏/为空只记 WARN 并按「无快照」继续在线抓取,**不影响本次回答**。
- **压缩**:客户端带 `Accept-Encoding: gzip` 时响应带 **`Content-Encoding: gzip`**(目录约 1MB 量级);**信封 `{data}` 形状不变**,浏览器与 Go 客户端透明解压;不带该头时返回明文并带 `Content-Length`。
- **副作用(只有这一处)**:落盘快照 `<DATA_DIR>/llm_catalog.json`(与 `models.json` 同目录、`data/` 已 gitignore、**不含任何凭据**、TTL 24h);**不修改 `models.json`**。LLM stack 未注入时快照落到系统临时目录,**绝不往工作树写文件**。
- **抓取只发生在本端点内**(数据源 `https://models.dev/api.json`,单次超时 20s、体积上限 64MiB):**不进运行时热路径**——模型请求链路从不读这份外网目录。
- **审计**:成功记 `llm.catalog` / `ok`,返回过期快照记 `stale`,失败记 `error`(可用 `GET /admin/observability/audit` 查)。
- **`models.json` 组级新键(v0.53,可热更新;`GET`/`POST /api/v1/admin/llm` 已同步这三个键的读与写)**:
- **`reasoning: {kind}`**:`kind` ∈ `"auto"`(= 缺省)/`"deepseek"`/`"openai"`/`"vllm"`/`"dashscope"`,决定该组用哪种**提供商开关形状**下发推理指令(vLLM → `chat_template_kwargs.enable_thinking`;DashScope → 顶层 `enable_thinking`;DeepSeek → 顶层 `thinking:{type}` + `reasoning_effort`;OpenAI 系 → 只有 `reasoning_effort`;`auto` = 按模型名/baseURL 推断厂商,**形状有歧义就不下发任何字段**)。
- **校验**:不在上列取值 → 组校验失败(`POST /admin/llm` 回 `400 bad_request`);**anthropic 组上声明 `deepseek`/`openai`/`vllm`/`dashscope` 同样拒绝**(Anthropic 用自己的 `thinking` 形状)。
- **请求语义**:`reasoning` 键**缺失 = 保留已存形状**;给出(哪怕 `{}`)= 整块替换(`kind` 缺失/空串 = 回到「未声明」)。
- **响应语义**:每组的 `reasoning` **恒下发**(`kind:""` = 未声明形状,不是「后端没报告」);顶层另下发 **`reasoningKinds`**(合法形状取值域,**不含空串**——`""` 是「不声明形状」而非一个形状名,面板另行给出该选项)与 **`personaProviderOptions`**(合法协议名域,即 `openai`/`anthropic`)供面板渲染下拉框——取值域由后端常量直接生成,**面板无需硬编码**。
- **`personaModels: {personaId: modelId}`** = **角色级模型路由**(「角色」= 该回合答话的 persona,**不是**消息作者的 role);**`personaProviders: {personaId: "openai"|"anthropic"}`** = **角色级协议路由**(跨协议换 adapter,配合 `personaModels` 使用)。
- **校验**:persona id / 值不得为空,`personaProviders` 的值必须是 `openai` 或 `anthropic`,否则组校验失败(`400`);键值首尾空白会被规整,**规整后撞键直接报错**(不静默丢一条)。
- **回落**:未绑定 / 绑定到未知 persona → 该组默认模型;`personaProviders` 声明的协议在 `models.json` 里**没有对应组** → 记 WARN 并**跳过**(该角色仍走默认链,**不半建一条只会失败的链**)。
- **请求语义**:两个键**缺失 = 保留已存表**;给出(含 `{}`)= **整表替换**(用整表替换而非合并,面板才能删除一条已存路由);未声明时响应中该键不出现(`omitempty`)。
- **语义修正(重要)**:`multimodal` = **主模型自身能否收图**(**不是**「本部署有没有视觉能力」);`LLM_VISION_MODEL` / `LLM_OCR_MODEL` **只配视觉/OCR 工具用的模型**,与 `multimodal` 互不代表(裁决 14.4.1,见 §1 行 3)。
- **`.env` 新增 10 键(v0.53 首登 9 键,v0.58 追加 `LLM_MULTIMODAL`;`.env` 是 `models.json` 的兜底,对齐 `.env.example`)**:
| 键 | 默认 | 语义 |
|---|---|---|
| `LLM_REASONING_KIND` | 空(= `auto`) | **仅供 `.env` 合成组**的推理开关形状声明(**无有效 `models.json` 时**才生效,取值同 `reasoning.kind`;有 `models.json` 时以该组的 `reasoning.kind` 为准) |
| `LLM_OCR_MODEL` | 空 | **OCR 工具**用的模型;空 = 复用 `LLM_VISION_MODEL`,两者都空 = 复用主模型(`LLM_MODEL`) |
| `LLM_MULTIMODAL` | `false` | **声明「主模型自身能否收图」**(`bool`,v0.58 新增)——即 `models.json` 组 `multimodal` 的 **env 等价物**。**仅供 `.env` 合成的模型组**(**无有效 `models.json` 时才生效**;有组文件时以该组的 `multimodal` 为准,与 `LLM_REASONING_KIND` 同口径)。`true` = 图片**直进主模型上下文**;**留空 / `false`(默认)= 把图交给 vision/OCR 工具,换成文本描述**(图像不占上下文,只有工具结果占)。**绝不凭 base URL / 模型名猜测**端点是否收图;**非 bool 值(如 `maybe`)启动即报错**(fail-fast,不静默回退成 `false`)。`LLM_VISION_MODEL` / `LLM_OCR_MODEL` 配的是**视觉/OCR 工具调用的模型**,与本键互不代表 |
| `LLM_LOCAL_ENABLED` | `false` | 降级链**第三级(本地模型)**总开关;默认关,链保持「主 → 备」两级 |
| `LLM_LOCAL_PROVIDER` | `openai` | 本地端点协议(`openai`/`anthropic`) |
| `LLM_LOCAL_BASE_URL` | 空 | 本地端点 base URL(如 `http://127.0.0.1:8080/v1`) |
| `LLM_LOCAL_API_KEY` | 空 | 本地端点 key(本地服务通常为空) |
| `LLM_LOCAL_MODEL` | 空 | 本地模型名 |
| `CAPACITY_L3_ENABLED` | `false` | capacity 意图判定的 **L3(模型辅助分类)**开关;默认关,只走 L1/L2 启发式 |
| `CAPACITY_L3_CACHE_TTL` | `5m` | L3 意图判定结果缓存 TTL |
- `LLM_LOCAL_*` **三个条件缺一不可**:`LLM_LOCAL_ENABLED=true` **且** `LLM_LOCAL_BASE_URL` 非空 **且** `LLM_LOCAL_MODEL` 非空;只开开关而不配端点/模型时**记 WARN 并跳过该级**(绝不半配一个只会失败的链尾步)。
- **内置工具新增 `image-crop`(v0.53)**:按区域裁切图片(参数 `image` + `x`/`y`/`width`/`height`,**0 基像素、区域须完整落在图内**)→ 返回 `{image:"data:image/png;base64,…", format:"image/png", width, height, region:{x,y,width,height}, source:{width,height}}`;用途是**上游可能压缩分辨率、大图细节会丢**,让模型自行裁出区域再交给 vision/OCR 工具精读(design/06 §3,裁决 14.4.5)。上限:源图 ≤50MiB、区域 ≤16MP、返回载荷 ≤8MiB(超出**报错**而非截断/降采样);源可以是本地路径(走 file 工具同一沙箱)/ http(s) URL(同 SSRF 守卫)/ base64 data URI。**策略与其它工具一致**:`tool_policies` 无行时按最严默认 **owner-only**(群聊/陌生人的调用被策略层拒,见 §2 行 5)。
**安全 / 上下文环境变量(v0.56)**(`.env` / 环境变量;与 `.env.example` 逐键对齐):
| 键 | 默认 | 语义 |
|---|---|---|
| `OWNER_IDENTITY_INJECTION` | `owner_only` | **主人身份说明书(`owner.yaml:identityFile`)注入范围**:`owner_only`(默认,**仅主人会话**,按**渠道层强类型 actor 元数据**判定)/ `always`(**所有会话**,含群聊与陌生人私聊,**仅本地自测/排障**——它显式开启红线例外)/ `off`(完全关闭,连主人会话也不注入)。取值**大小写不敏感、容忍首尾空白**;**非法值回退 `owner_only` 并记 WARN**(绝不静默放行、也绝不静默失效)。它只决定「那段知识进不进该会话的上下文」,**不参与任何身份/权限判定**(design/11 §0):文件内容既不参与 `IsOwner`,也不改变授权。 |
| `CONTEXT_MAX_TOKENS` | `32768` | **上下文溢出压缩预算**(token,离线估算;默认值与 `loop.DefaultMaxContextTokens` 同源,有测试锁死两者不许漂移)。会话估算上下文超过它时,更早的轮次被摘要成一条前导摘要消息,近期尾部(含尾部工具结果)原样保留;**`0` 或负数 = 关闭压缩**(行为与接线前一致)。 |
| `GUARDRAIL_KEYWORDS` | 空 | **内容策略关键词表**(逗号分隔,大小写不敏感的子串匹配)。**留空 = 不挂关键词护栏**(项目不内置任何词表,只有你显式声明过的词才会被拦)。敏感信息(API key / bearer token / 密码 / 私钥)走**阻断档**、请求与响应各有长度上限——这两项是代码内定的安全默认值,**不对外开放配置**(`cmd/cyrene/wire_guardrail.go`)。 |
**上下文窗口与预算契约(v0.59)**(design/04 §1.3 + 附录 A.3 的主人裁决,2026-10-04;判定以 `internal/config/context_budget.go` + `cmd/cyrene/wire_context.go` 为准):
- **`models.json` 组级新键 `contextWindow`**:正整数 = 该组模型的**上下文窗口**(token)。本仓**没有任何 provider 侧窗口发现代码**,窗口只能由人声明。`0`(省略该键)= **未声明** ⇒ 后端按 **`DefaultModelContextWindow = 131072`** 保守估算推导预算,并记**恰好一条** WARN(提示「声明窗口才能吃满模型的真实窗口」);**负数 = 组校验失败**(写端点回 `400`,载入期同样拒绝)。
- **面板读写面**:`GET /api/v1/admin/llm` 的组 DTO **含 `contextWindow`**(`omitempty`,未声明时该键不出现——与 `maxTokens` 同形),`POST /api/v1/admin/llm` **接受可选 `contextWindow`**:**缺失 = 保留已存窗口**(面板的局部编辑绝不复位它)、**显式 `0` 与缺失同义**(都 = 未声明)、**负值 `400 bad_request` 且不落盘**。因此面板可以显示并编辑该键,不是「只能手改文件」。
- **`.env` 4 键**(与 `.env.example` 逐键对齐):
| 键 | 默认 | 语义 |
|---|---|---|
| `LLM_CONTEXT_WINDOW` | 空(= 未声明) | **per-group 播种**:`models.json` 组 `contextWindow` 的 **env 等价物**(同一个数、同一个含义)。它作用于 `.env` 合成组与 materialize 的播种层;**负数启动报错**。 |
| `CONTEXT_BUDGET_RATIO` | `2/3` | 推导比例,取值域 `(0, 1]`,**分数(`2/3`)与小数(`0.6667`)两种写法都接受**;**非法值记一条带原始取值的 WARN 并回落 `2/3`**(绝不「配了当没配」)。 |
| `DOCUMENT_MAX_INJECT_CHARS` | 空(= 自动) | **单份附件注入上下文**的字符上限。未设 = 按推导出的上下文预算自动算(缺省下限 **64K 字符**);`>0` = 显式覆盖。它与「sidecar 存多少」是**两个数**:sidecar 存抽取全文(只受 **8 MiB** 安全上限约束),本键只决定每回合注入多少 ⇒ **截断发生在装配期**,清单行的续读游标因此真是「继续读的起点」。 |
| `CONTEXT_MAX_TOKENS` | **未设置**(≠ `0`) | **三态(v0.59 语义变更)**:**未设置 ⇒ 按窗口推导**;`>0` ⇒ **显式覆盖**(原样使用);`0` / 负数 ⇒ **关闭压缩**。「未设置」与「显式 `0`」必须区分(按环境变量 / `.env` 的**存在性**判定,不靠 0),因为 `0` 已被「关闭压缩」占用。 |
- **推导式**:`budget = min(窗口 × 比例, 窗口 − 输出保留)`;**输出保留 = `max(32768, 该组 maxTokens)`,并封顶为 `窗口/2`**(组把 `maxTokens` 声明得比窗口还大时,不让「窗口 − 输出」退化到 0 或负数)。**1M 窗口 ⇒ `699050` tokens**(`1048576 × 2/3` 取整)。
- ⚠️ **`CONTEXT_MAX_TOKENS=32768` 属「显式覆盖」**:`.env` 里若仍写着这个数(旧模板的默认值取自「gpt-4o 的 128K 窗口 ÷ 4」,该前提已随默认模型 1M 窗口失效),**推导不生效**——这是设计语义,**不是 bug**。要吃到推导就**删掉 / 注释掉该行**(本次 `.env.example` 已把该行改成注释形式,并在旁注写明旧值来历)。**本次不改客户端的任何行为**:它只是一个 env 键的语义说明。
- **`.env` → 面板配置的 materialize 语义(v0.59 补登记的欠账,此前未成文)**:进程**每次启动**跑一次(`runtimeconfig.Materialize`,在模型栈构建之前):
- **组级**:面板配置**非空** ⇒ **组集合完全由面板决定**(env 不新增组、**不复活**被面板删掉的组,也不删面板的组);面板配置**缺失 / 空文件 / 空数组** ⇒ 由 env 播种出合成组并落盘(让面板从此有一份一等来源)。
- **键级**:**只在面板缺该键时用 env 补**;面板**已有**该键(含**显式空值 / 显式 `false` / 显式 `null`**)⇒ **面板优先,绝不覆盖**(靠「原始键是否出现」区分「从未写过」与「显式清空」——类型化解码做不到这件事)。
- **硬约束**:「面板改一次、重启被打回」**绝不成立**(有专门用例钉住)。无改动时**一个字节都不动**(幂等)。
- **日志**:补齐写 Info(补了哪几个键、来源 env),因面板优先而跳过的写 Debug;**只打组 id 与键名,`apiKey` 等凭据绝不进日志**。
- **范围**:目前**仅 `models.json` 一族**(`channels.json` / `plugins.json` **未纳入**)。
- **与面板保存路径的分工**:materialize **只在启动时**跑;面板保存走「整组写入 + 热重载」,**那里不跑补齐**——否则面板刚显式清空的键会被立刻补回。
**「重置为 env 默认」契约(v0.62,`POST /api/v1/admin/llm/reset-from-env`)**(任务书 R1;判定以 `internal/config/runtimeconfig/reset.go` + `internal/services/gateway/admin_write_llm_reset.go` 为准):
- **它是什么**:materialize(上一节)的方向是「面板缺该键 ⇒ 用 `.env` 补」;本动作是它的**反方向显式动作**——部署者明确要求「**把面板里这些键退回 `.env` 声明的默认值**」(例如面板把 `apiKey`/`baseUrl` 改坏了,一键回到 env 基线)。两者**共用同一张键表**(`runtimeconfig` 的 `groupFillSpecs`),因此「本动作能改回的键集合」与「materialize 会补齐的键集合」结构性一致。
- **鉴权 / 审计**:**`owner` 专属**(与同页 `POST /admin/llm`、`POST /admin/llm/test` **同门**;`admin` 与 `viewer` 均 `403`)——它是破坏性写(覆盖面板对若干键的修改),**门不比同族写端点更宽**;带审计动作 **`llm.resetFromEnv`**(成功 `ok`、被拒/失败 `error`;多词动作沿用本仓既有 camelCase 约定,与 `llm.addProvider` / `persona.update` 同风格)。
- **请求**:body **必须** `{"confirm":true}`。**缺失 / `false` / 非法 body 一律 `400 {error:{code:"bad_request"}}`**,且**一个字节都不动**——本动作用 env 值覆盖面板修改,属破坏性操作,缺护栏的默认必须是「不执行」。
- **语义(逐条)**:
- **只动 `.env` 声明了非空值的键**(`provider` / `baseUrl` / `apiKey` / `model` / `contextWindow`——与 materialize 的补齐表同集;`contextWindow` 只认正数,`fallback` / `multimodal` / `maxTokens` / `reasoning` / `personaModels` 在 `.env` 里没有出处,**永不被本动作改动**)。缺省协议族按**组自己的 `provider`** 选(`.env` 同时配了扁平块与 `ANTHROPIC_*` 时,两族各拿自己的值)。
- **env 未声明的键一律保留面板值**(含面板显式清空/`false`/`null` 的键)。
- **组集合不增减**:不复活被面板删掉的组、不删面板新增的组,**顺序也不动**(本动作只改键值)。
- **面板值已与 env 一致的键不算「改回」**,因此不出现在 `reset` 里(`provider` 恒属这种情形:env 默认层总为两族各声明一次协议名,而面板组本来就与之相同)。
- **没有任何键需要改时一个字节都不写**(幂等;重复调用第二次会返回 `reset:[]` + 每组一条「已一致」的 `skipped`)。
- **真的改了盘就热重载**运行中的 LLM 供应商栈(design/04 §3.1,无需重启)——但注意 `contextWindow` 的**预算推导**仍只在进程启动时算一次(与 `POST /admin/llm` 同一口径,面板文案已写明「重启后生效」)。
- **响应**:`200 {data:{reset:[{group,keys:[…]}], skipped:[{group,reason}], path}}`
- `reset`:**真的被改回**的分组,`group` = 组 id、`keys` = `models.json` 的 JSON 键名数组(顺序 = 补齐表顺序)。**只回键名,值(尤其 `apiKey`)绝不回显。**
- `skipped`:未被改动的分组 + 中文原因(`"面板值已与 `.env` 默认一致,无需重置"` / `` "`.env` 未声明该组的默认键(面板值原样保留)" ``)。两组键**恒为数组**(无记录时是 `[]`,不是 `null`)。
- `path`:被改写(或本应改写)的 `models.json` 路径,与 `POST /admin/llm` 同源。
- **对照基准**:**请求时刻按既有 config 解析入口读到的 `.env` 默认层**(`config.LoadLLMDefaults`;优先级与启动时 `config.Load` 逐字一致:进程环境 > `.env` > 标签默认)。⇒ 部署形态(systemd/env_file,无 `.env` 文件)下读到的是进程环境里那一份;`.env` 改了而没重启时,本动作按**文件当前内容**回退,而重启后 materialize 也用同一份——两者不冲突。
- **错误(都绝不改文件,且都带审计 `error`)**:`.env` 源不可读 → `500 {error:{code:"env_defaults_unreadable"}}`;面板配置不存在/不可解析 → `500 {error:{code:"models_unreadable"}}`;写盘失败 → `500 {error:{code:"llm_persist_failed"}}`;已写盘但热重载失败 → `500 {error:{code:"llm_reload_failed"}}`(说明文件已改、未生效);LLM 栈未注入 → `500 {error:{code:"llm_unavailable"}}`。
- **面板**:LLM 页的「**重置为 env 默认**」卡片(`frontend/web/src/admin/modules/llm/LlmResetFromEnv.tsx`,行上标 `owner 专属`)——点按钮先展开中文**二次确认**文案,确认后才带 `confirm:true` 提交;结果按 `reset`/`skipped` 渲染成可复核的组名 + 键名摘要。
**管理面板写操作契约**(`POST/PUT/DELETE /api/v1/admin/*`,均 **owner-only**,读为 owner/admin;写操作经 audit 落 `audit_logs`;成功 `{data}` / 失败 `{error:{code,message}}`):
- `POST /admin/personas` body `{id,name,role,systemPrompt,characterRef?,modes?/mode?,memoryNamespace?,thinking?}` → `201 {data:PersonaSummary}`(**v0.43 起与读端点同形状:含 `mood` + `thinking?`**;注册进 persona registry + 持久化到 `configs/personas/<id>.yaml`);id 已存在回 `409 duplicate`。**`systemPrompt` 自 v0.51 起必填**(落的 YAML 键是 `identity.systemPrompt`):**缺失/空白 → `400 bad_request`**(消息 `"systemPrompt is required (identity.systemPrompt must not be empty)"`),**不再派生默认说明串**回落——载入期(`loader.Load`)执行同一规则,因此写出的 YAML 必能被重新载入。
- `PUT /admin/personas/{id}` body 同上(不含 id)→ `200 {data:PersonaSummary}`(**v0.43 起与读端点同形状:含 `mood` + `thinking?`**,面板可直接用它回填);不存在回 `404`。**`systemPrompt` 与创建同一把门**:v0.51 起同样必填,缺失/空白回 `400 bad_request`(同一条消息)——**不派生默认串、也不静默沿用旧值**,请求被拒即不落盘。
- `thinking`(可选,design/01 §2.1)`{budgetTokens?,effort?,showToUser?,mode?}`:写入 YAML 的 `thinking` 节,驱动该人格的思考预算/开关/呈现。`mode` ∈ `default|on|off`(缺省 `default`),非法 `mode` 或负 `budgetTokens` 回 **`400 bad_request`**(`invalid thinking config: …`)。**`showToUser` 是 bool 且可缺省**:**缺省 = 保持现行为(thinking 帧照常推客户端)**;显式 `false` = 只落库不回显。**`PUT` 省略整个 `thinking` 对象时保留已存的 `thinking` 节**(不因客户端不认识该字段而抹掉);显式传对象则整体替换(字段全缺省即清空)。该字段**不构成能力边界**。
- **人格 `thinking` 读侧契约(v0.43,回读形状)**:`GET /admin/personas` 每项按**与写侧同构**的形状回读 `thinking`,供面板回填:
- **未配置的人格:`thinking` 键完全不出现**(不是全零对象)——与写侧「省略 `thinking` 对象 = 保留已存」互为镜像,客户端可有样学样地「原样回填 / 原样省略」。
- 已配置时 **四个键都出现**:`budgetTokens`(number,未设为 `0`)、`effort`(string,未设为 `""`)、`showToUser`、`mode`(string,未设为 `""`)。
- **`showToUser` 是三态、且读侧如实三态**:**`null` = 未配置(保持现行为:thinking 帧照常推客户端)**;`true`/`false` = 显式选择(`false` = 只落库不回显)。**服务端不会把 `null` 拍平成 `false`**(写侧用的是 `*bool`,读侧同样保留指针语义),否则「没配过」会被面板误显示成「关」。
- **能力提示由服务端负责,客户端不要假装**:`effort` 的**有效白名单随当前生效 provider** 而定(OpenAI 兼容系 `none/minimal/low/medium/high/xhigh/max`;DeepSeek 仅 `none/low/high/max`,别名 `minimal→low`/`medium→high`/`xhigh→high`/`ultra→max`;未知 provider 不支持)。**服务端在模型层按 provider / 形状校验,非法或该形状不支持的字段一律丢弃并记 WARN(不降档、也不静默无效)**(design/04 §3.2 / v0.53);读端点只回读**已存配置**,**不承诺**该值在上游真的生效。面板可给出提示与前端枚举校验,但**不得**把「配了 effort」呈现为「上游已启用」。
- `POST /admin/users` → `501 not_implemented`(**无用户表**:登录仅用 `.env` 单 admin `ADMIN_USERNAME`/`ADMIN_PASSWORD`,角色为 `owner>admin>viewer` 常量;多用户存储 + 登录接入需先设计——design/11 单用户为主。不做「建了却登不进」的假用户)。
- `POST /admin/users/apikey`(**无请求体**,v0.51 取消 role 分级)→ `201 {data:{id:<掩码>, key:<明文·仅此一次>, role:"owner"}}`;`key` **仅此响应出现一次**,任何有效 key 鉴权后即 `owner`(裁决 15.4.3:key 不做角色分档;旧的 `{"role":…}` 请求体被忽略,表里也没有角色维度)。
- `DELETE /admin/users/apikey/{id}`(`id`=列表掩码)→ `200 {data:{id,revoked:true}}`;未命中 `404`。
- `GET /api/v1/admin/owner-profile`(**owner-only**,**读不审计**)→ `200 {data:{id,displayName,defaultPersona,identityFile,notes,accounts:[{platform,accountId}]}}`(后三个可选字段为 `omitempty`——为空时**键不出现**,客户端按缺省处理即可);**文件不存在(全新安装)→ `200` + 空白档案**(`accounts:[]`,**非 `null`**,面板据此渲染空表单让主人首次填写);**档案损坏/读不出 → `500 {error:{code:"owner_profile_unreadable"}}`**(如实报错,不返回一份看似正常的空档案——否则「白名单是空的」与「档案读不出来」长得一模一样)。
- `PUT /api/v1/admin/owner-profile`(**owner-only** + 审计 `owner.profile.update`)body 同形状(`accounts` 可空/省略;各字段**首尾空白会被去掉**——面板里手打的平台名/账号 id 常带空格,带进白名单就永远匹配不上)→ `200 {data:<落盘后档案>}`;`id`/`displayName` 缺失或 `accounts` 项**半空**(只给 platform 或只给 accountId)→ `400 {error:{code:"bad_request", message:"invalid owner profile: …"}}` 且**不落盘**(整条全空的占位项被跳过,不算错);写盘失败 → `500 {error:{code:"owner_profile_persist_failed"}}`。落盘 `configs/users/owner.yaml`(**0600 原子写**:临时文件 + rename)+ **热替换活档案**,新白名单**立即**参与渠道层 actor 判定、无需重启;组合根未暴露活档案时只记一条 WARN(已落盘、需重启生效),**不假装热重载成功**。它**不是**提示词/LLM 判定的输入——谁能当主人始终由这份档案的 `accounts` 白名单 + 每渠道 `adminIds` 决定(design/11 §0)。
- `POST /admin/iot/{id}/control` body `{action,params?}` → `200 {data:{id,name,type,state,online}}`(真实下发到 IoT provider);`404` 设备不存在 / `400` 动作不支持或参数错 / `409` 设备离线。
- `POST /admin/iot/scenarios`、`POST /admin/iot/rules` → `501 not_implemented`(**IoT 无场景/规则模型与存储**:`iot.Provider` 只有设备列表/控制/状态;通用 `automations` 只跑 cron/事件规则,不含设备状态条件 + 设备动作执行器。场景/规则 CRUD 需先新增模型 + 执行器)。
- `POST /admin/automations` body `{name,kind:"cron|event",expression}` → `201 {data:{id,name,kind}}`(真实注册规则);无效 cron/规则回 `400 invalid_cron|invalid_rule`。
- `POST /admin/automations/{id}/run` → `200 {data:{id,output}}`(手动触发);`404`。
- `DELETE /admin/automations/{id}` → `200 {data:{id,deleted:true}}`;`404`。
- `POST /admin/llm` body `{id?,provider?,baseUrl,apiKey?,model,multimodal?,fallback?,maxTokens?,contextWindow?,reasoning?,personaModels?,personaProviders?}` → `201 {data:{provider:{id,name,provider,baseUrl,model,multimodal,enabled,fallback?,maxTokens?,contextWindow?,reasoning,personaModels?,personaProviders?}, persisted:true, path}}`。**真实持久化**:读-改-写 `models.json`(本地、gitignore),保存组提升为**生效组**(index 0),并**热更新**运行中的 LLM provider(`path` 为落盘文件路径);API key 写入文件但**永不回显/不落日志**。`provider` 为空默认 `openai`;缺 `baseUrl`/`model` 回 `400`;`maxTokens` / `contextWindow` 为负回 `400`;未注入 LLM stack 回 `500 llm_unavailable`、写盘失败 `500 llm_persist_failed`、热重载失败 `500 llm_reload_failed`。`maxTokens` 缺省/`0` = 未设(回落 `LLM_FALLBACK_MAX_TOKENS` / 适配器默认);`contextWindow` 缺省/`0` = 未声明(按回落窗口 131072 推导)。⚠️ **时机**:预算推导**只在进程启动时算一次**(`cmd/cyrene/wire_context.go` 组合期)⇒ 面板改窗口后**需重启**才重算(provider 栈本身是热更新的,两者口径不同,面板文案已写明「重启后生效」)。`.env` 为兜底。**v0.53 新增三键**(`reasoning.kind` / `personaModels` / `personaProviders`,语义与校验见上方「模型配置契约(v0.53)」):三者**缺省 = 保留已存值**,给出则 `reasoning` 整块替换、两张 persona 表**整表替换**;非法 `kind` / 非法协议名 / 未知 anthropic 形状 → `400 bad_request`。**v0.59 新增 `contextWindow`**(组级模型窗口,语义见上方「上下文窗口与预算契约(v0.59)」):**缺省 = 保留已存窗口**、显式 `0` 与缺省同义、负值 `400`。
- `POST /admin/llm/test` body `{provider?,baseUrl,model,apiKey?,prompt?}` → `200 {data:{ok,provider,model,reply,tokens}}`(**真实模型调用**);无效/失败回 `400 invalid_provider` / `500 llm_test_failed`。API key 仅本次请求内使用,不落库、不进 wire。
- `POST /admin/llm/reset-from-env`(**v0.62,owner-only**,审计 `llm.resetFromEnv`)body `{confirm:true}`(缺 = `400 bad_request`)→ `200 {data:{reset:[{group,keys}], skipped:[{group,reason}], path}}`:把面板里 `.env` 声明过的键退回 env 值,**组集合不增减**、env 未声明的键原样保留、无键可改时不写盘,**只回键名不回显凭据值**。失败:`500 env_defaults_unreadable` / `models_unreadable` / `llm_persist_failed` / `llm_reload_failed` / `llm_unavailable`。完整契约见上方「重置为 env 默认契约(v0.62)」。
- `POST /admin/channels/open`、`POST /admin/channels/close` body `{name}` → `200 {data:{name,connected}}`(真实 Connect/Disconnect);`404`(未注册渠道)/`409 session_rejected`(微信 iLink 侧拒绝/作废了该会话,重连无法修复,须先「扫码登录」重新绑定,v0.39)/`409 needs_login`(微信 iLink 尚无 token)/`500`(其它凭据缺失等连接失败)。
- `POST /admin/channels/test` body `{platform?,accountId,isGroup}` → `200 {data:{role,owner,isGroup,env,verdict}}`。**结构性判定**:channel 层 actor 元数据 × `configs/users/owner.yaml` 白名单,绝不靠提示词。
- **微信 iLink「会话被拒」契约**(v0.39,适配器级,非端点):iLink 返回 `errcode=-14` 时适配器进入**显式被拒态**——`IsConnected()` 恒 `false`;`Connect()` **不再返回成功**,而是返回「需要重新扫码」类错误(`POST /admin/channels/open` 对应 `409 session_rejected`);`HealthCheck` 同样把「被拒」与「未连接」区分开。`GET /admin/channels` 该平台行带 **`needsRescan:true`** + **`lastError`**(人类可读原因,**绝不含 token**)。**清除条件**:扫码登录拿到新 token(`login-status` 报 `confirmed`)或配置里换了新 token(保存后热重载)。被拒期间:`login-status` **不**假报 `confirmed` 而是 `failed` + 被拒原因(避免面板把已作废的凭据当成功重新落盘);`login-qr` 仍可正常出码(**不**回 `already_logged_in` 挡住重新扫码);`get_bot_qrcode` 不再携带已被拒的 token(否则服务端会以「已绑定」应答而不发新凭据)。未实现该能力的平台:`needsRescan`/`lastError` **不出现**(`omitempty`),客户端按缺省处理。
- **事实说明(勿误读为「过期」)**:iLink 的 bot token 是**长期凭据**(实测一个月前扫码得到的 token 仍可用),`errcode=-14` 表示 **iLink 侧拒绝/作废了该会话凭据**(解绑、在别处重新登录、协议层拒绝等),**不是时间性过期**;恢复方式为**重新扫码绑定**(或配置换新 token)。当前尚未在真实日志中观测到 `-14`,此处按「被拒」语义处理以防静默变哑。
- **KOOK 适配器面板端点**(design/17 §2,**owner-only + 审计**,仅 `kook` 平台实现;其它平台回 `501 not_implemented`,账号未找到回 `404`):这些端点均带可选 `?accountId=` 定位到具体 channels.json 账号(不传=该平台首个账号)。**token/密钥不打印、不落日志**;子项拉取失败不伪造、记入 `errors`。
- `GET /admin/channels/{platform}/view?accountId=` → `200 {data:{platform,accountId?,friends?,userChats?,guilds?,channels?,members?,errors?}}`。聚合该账号真实数据(好友/申请/屏蔽、服务器、首个服务器频道与成员、私信会话);`errors` 为 `{子项:原因}` 记录失败的子项。
- `GET /admin/channels/{platform}/friends?type=&accountId=` → `200 {data:{friends?,requests?,blocked?}}`。`type` ∈ `friend|request|block`(空=全部),调用 KOOK `GET /friend?type=`。
- `POST /admin/channels/{platform}/friend/handle?accountId=` body `{id,accept}` → `200 {data:{handled:true,id,accept}}`。同意/拒绝好友申请(KOOK `POST /friend/handle-request`)。
- `POST /admin/channels/{platform}/message/test?accountId=` body `{targetId,content}` → `200 {data:{msg_id,msg_timestamp}}`。真实向 `targetId`(频道 id)发一条 kmarkdown 测试消息(KOOK `POST /message/create`),验证 bot 可达性。
- `GET /admin/channels` 每条账号附 **`capabilities`**(可选 `[]string`,如 kook 的 `["friends","user_chats","channels","guilds","message_test"]`),供面板按平台渲染专属功能卡片。
- **KOOK「token 即用、其余自动获取」**(适配器契约,非端点):填 KOOK 渠道时**只必填 `token`**(bot_token)。`base_url` 留空即用官方默认 `https://www.kookapp.cn/api/v3`;`receive_mode` 空默认 `ws`(WS 模式地址由 `GET /api/v3/gateway/index?compress=0` 自动获取,已接入);`webhook_path` 空默认 `/webhook/kook`。高级值(改用 webhook、`webhook_verify_token`、`webhook_encrypt_key`,仅 webhook 模式需要)经 PUT 的 `config` JSON 传入(前端走「额外 config」,如 `{"receive_mode":"webhook","webhook_verify_token":"..."}`)。**Connect 拿到 token 后自动调用 `GET /api/v3/user/me`**(Bearer 鉴权)解析 bot 账号(id/username/nickname),存到 adapter 并随 `GET /admin/channels` 账号的 `botId`/`botUsername`/`botNickname` 显示;user/me 失败仅记日志、bot 身份为空,**不中断**连接/收发。token 只在 header / 写文件,**不打印、不进日志**。
- **OneBot v11 四端契约(v0.55,适配器契约 + 配置面;design/08 §4 / design/13 §0,裁决 1.4 / 12.4.1)**:`qq-obv11` 适配器按 `transport`(ws|http) × `mode`(client|server) 提供**四个端**,谁向谁发数据各不相同:
- **`ws + client`**:Cyrene 主动拨号 OneBot 的 WS 端点,收发同一个长连接;**`ws + server`**:OneBot 反连 Cyrene 的 `listen_addr`,收发同一个长连接(两端均沿用改造前语义)。
- **`http + client`**:Cyrene 作为 HTTP 客户端调 OneBot 的 HTTP API——`POST {api_base_url}/{action}`、等响应、按 `retcode` 判成败(**纯出站**,无长连接、无监听端口);token 只走 `Authorization: Bearer`。
- **`http + server`**:OneBot 主动 `POST` 到 Cyrene 的 `listen_addr` + `report_path`(**纯入站**);**fail-closed 鉴权**(配了 `access_token` 时 `Authorization: Bearer` 或 `?access_token=` 必须匹配,否则 `403`)、**10MiB 请求体上限**(超出 `413`)、方法非 `POST` 回 `405`,成功回 `204`。
- **配置面**(channels.json 账号 `config` + 适配器 `Config()` 快照)新增:`transport`(`ws`|`http`,默认 `ws`,**未知值一律回落 `ws`**)、`api_base_url`、`report_path`(默认 `/`,无前导 `/` 时补上)。快照里 camelCase 为 `transport`/`apiBaseURL`/`reportPath`;`remoteURL` 快照值 = `remote_url`,**为空时回落 `api_base_url`**(语义即「出站远端地址」)。**既有键与值不变**(不给出任何新配置时行为与改造前一致:`transport=ws`、`mode=server`)。
- **env 兜底**:`CHANNEL_QQ_OBV11_TRANSPORT`(默认 `ws`)/ `CHANNEL_QQ_OBV11_API_BASE_URL` / `CHANNEL_QQ_OBV11_REPORT_PATH`。
- **关键取舍**:`mode` 决定**哪一半必需**(`server` 必需 `listen_addr`、`client` 必需 `api_base_url`),另一半只要字段给齐就**一并启用** ⇒ 单个适配器即可完成「收上报 + 回消息」闭环;四个端仍各自可独立选择与测试。任一端配置有误即 `Connect` 失败,**绝不留下半开的监听端口**。
- **端口例外**:`http + server` 会用 `listen_addr`,属 `AGENTS.md` §9 已登记的**同一豁免**(design/13 §0 原文即写「WS server / HTTP server」);`listen_addr` 为空则报错、**绝不开端口**、**无默认值**。
- **未接**:`get_group_list` 枚举(后续);`access_token` 为空时上报路径**放行**(与既有 `ws + server` 同口径,尚无 fail-closed 开关)。
- `PUT /admin/channels/{platform}/config`(owner-only,审计)body `{id?,token?,baseURL?,remoteURL?,listenAddr?,mode?,accessToken?,selfID?,config?,allowGroups?,denyGroups?,allowUsers?,denyUsers?,groupMsgPerMin?,msgPerMin?,maxMessages?,personaId?}` → `201 {data:{persisted:true,path,platform,accountId}}`。**真实持久化 + 热更新**(design/04 §3.1,无需重启):读当前有效配置(channels.json→env 兜底)→ **按 id 打补丁**(`PatchChannel`)→ 落盘 `channels.json`(本地、gitignore,0600)→ `bridge.Reload(platform)` 热替换该平台 adapter(进行中消息用旧、新消息用新);token 写入文件但**永不在响应/日志回显**(GET 列表为掩码)。`platform` 必填;空配置回 `400`;未注入渠道配置服务回 `500 channels_unavailable`、写盘失败 `500 channel_persist_failed`、热重载失败 `500 channel_reload_failed`。`.env`(`CHANNELS_JSON` / `CHANNEL_*_TOKEN`)为兜底。
- **`config`(v0.54 起为 patch 语义,与 `personaId` 同构)**:`config` 是**只含本次请求显式给出的键**的补丁,**不是**一整套完整配置。**取值三态**:**键省略(该键不在 `config` 里,或 `config` 整体缺席 / 为 `null`)= 保留该账号已存值**(未给出的键**绝不被抹掉**);**给出非空值 = 覆盖该键**;**显式 `null` 或 `""` = 清除该键**(适配器随后回落到自己的零值;`0` / `false` 是真实取值,**不算空**)。顶层便捷字段(`token` / `baseURL` / `remoteURL` / `listenAddr` / `mode` / `accessToken` / `selfID`)是普通 `string`,**只在非空时**并入补丁(普通 string 分不出「缺席」与「显式空串」)⇒ **要清除 `bot_token` 之类的键,必须走 `config` 里的显式 `null` / `""`**。**为什么**:面板只发送用户本次**碰过**的平台字段——若把「键不存在」当成清除,主人打开一张账号卡直接保存就会**静默抹掉**面板不渲染的凭据(**微信(iLink)**的 `bot_token`/`bot_id`/`owner_id`/`get_updates_buf`,iLink token 是长期凭据;**Telegram** 只填 `webhook_url` 会连 token 一起丢、账号被 `cleanAccounts` 整个删掉)。**仍是整值替换(不走三态)**:`allowGroups` / `denyGroups` / `allowUsers` / `denyUsers` 与 `groupMsgPerMin` / `msgPerMin` / `maxMessages`(线上是值语义)——**省略即清空**,客户端须回传**完整列表 / 非零值**。**未改**:token 要求型平台(kook / telegram)上**显式**清 `bot_token` 仍按 `cleanAccounts` 丢掉该账号(这是明确要求清除凭据,不是误删);「本次请求一个 `config` 键都没给出」在**非 `weixin-ilink`** 平台仍回 `400`(`weixin-ilink` 豁免,以便先建账号再扫码)。
- **`personaId`(v0.44 新增,v0.45 起为三态)**:=该账号**答话时的角色 id**(design/22 §2 方案 b,取值来自 `GET /personas`)。**取值三态**:**键省略(或 `null`)= 保留该账号已存绑定**(不动);**显式 `""` = 解绑**(写盘时该账号不带 `personaId` 键);**非空 id = 绑定该角色**。**未知角色 id 回 `400 bad_request`**(不绑一个答不了话的角色;校验只针对**本次真的传了值**的情形,省略继承的已存绑定在写入时已校验过)。**为什么是三态**:面板只在主人**动过**角色下拉时才发这个键——若把「键不存在」也当成解绑,主人只改 token 就会**静默清掉**账号的角色绑定(两端语义相反即丢数据)。**写盘即生效**:保存后 `Reload(platform)` 会重建该平台 adapter,新角色在下一批入站消息上生效(无需重启)。**它不是身份/权限输入**——谁能做什么仍由 channel 层 actor 元数据 + 白名单决定(design/11 §0)。**优先级见 §4.2「角色解析优先级」**:渠道绑定**只**作用于该账号的**主人私聊**,且仅当主会话自身未绑角色;群聊/陌生人会话**强制默认角色**。
- `DELETE /admin/channels/{platform}/config?id={accountId}`(owner-only,审计)→ `200 {data:{persisted:true,path,platform,accountId}}`。删除该平台指定账号 → 落盘 `channels.json` → `bridge.Reload(platform)` 热更新(该账号 adapter 优雅关停)。`id` 必填;未注入服务回 `500 channels_unavailable`。
- `GET /admin/channels/{platform}/messages?limit=&before=`(owner/admin 读)→ `200 {data:[{id,sessionId,role,content,mode?,mood?,image?,platform?,deviceId?,senderName?,createdAt}]}`。**IM 适配器日志流**(design/16 §4.6 §4):该平台**所有非主人私聊/群聊消息**(隔离会话 `sess-xxx`,非主会话 `web`)以消息流显示,`senderName` 标发送者昵称;未注入 `HistoryStore` 回 `501`。
- `POST /admin/plugins/install`(**owner-only + 审计 `plugin.install`**)body `{kind:"mcp",name,config:{transport:"stdio|http|sse",endpoint?|command?,args?,apiKey?,env?}}` → `200 {data:{installed:true,name,kind:"mcp",transport,toolCount}}`。**v1 只装 MCP server**(单二进制无法热装 Go 插件):真实 connect+discover,把发现的工具**热注册**进 capability registry(下次模型请求即注入,无需重启)→ 落盘 `plugins.json`(本地、gitignore、0600,`apiKey` 写入但**永不回显/不落日志**)→ 持有 live server 供后续刷新/卸载。**同名重复安装 = 刷新**(先卸旧再装新,失败回滚旧)。校验:`name` 必填、`kind` 非 `mcp` 回 `400`、transport 非法/`stdio` 缺 `command`/`http|sse` 缺 `endpoint` 回 `400`;**连接/发现/注册失败回 `502 plugin_install_failed` 且不落盘**(诚实,不静默装一半)。未注入 plugin manager 回 `501 not_implemented`。
- `POST /admin/plugins/uninstall`(**owner-only + 审计 `plugin.uninstall`**)body `{name}` → `200 {data:{name,uninstalled:true,kind:"mcp"}}`(已装 MCP:`Unregister` 移除其工具 + close + 删 `plugins.json` 条目;**非 MCP/普通能力**走原 `registry.Remove`,返回 `{data:{name,uninstalled:true}}`);未命中回 `404`。
- `POST /admin/memories` body `{personaId?,content,scope?}` → `201 {data:{id,scope,text}}`。**手动添加记忆**(owner 私聊直写);`scope` ∈ `user|persona|project`,缺省 `persona`(与「记住…」同路径);无效非空 `scope` 回 `400`;未注入 `memory.Store` 回 `501`。
- `POST /admin/memories/search` body `{query,personaId?,topK?}` → `200 {data:{query,personaId,semantic,hits:[{id,scope,text,score}]}}`。真实检索(keyword×衰减);**语义/向量路径**需主会话注入 vector index(`semantic` 当前恒 `false`,诚实反映未接向量器)。**`personaId` 省略时(v0.42 起)回落到库里注册的默认角色**(`default` 标记的那个 character → 其 persona),**不再写死 `"cyrene"`**;若既未传 `personaId` 又没有任何默认角色 → `400 bad_request`(诚实报错,而不是拿一个不可能命中的 id 去查)。
- `GET /admin/sessions/{id}/export` → `200 {data:{session:{platform,channelId,sessionId,actorRole,owner,isGroup,memWritable}}}`;`404`。当前导出会话身份元数据(完整消息转录由 M10 提供)。
**系统设置契约**(`POST /api/v1/admin/system/*`,均 **owner-only**,审计;成功 `{data}` / 失败 `{error:{code,message}}`):
- `POST /system/revoke-keys`(无 body)→ `200 {data:{revoked:n}}`。**吊销全部 API key**:清空 key 表(先前签发的所有 key 立即失效),`revoked` 为吊销数量;未注入 API key 服务回 `500 users_unavailable`。
- `POST /system/rotate-jwt` body `{newSecret}` → `200 {data:{rotated:true,note:"现有 token 已全部失效,需重新登录"}}`。**轮换 JWT 密钥**:`newSecret` ≥32 位,替换当前签名密钥,**先前 token 全部失效**;`newSecret` 缺失/空白回 `400`;长度 <32 回 `400`;认证服务未配置回 `500 auth_unavailable`。**新密钥永不回显、不落日志**。
- `POST /system/backup`(无 body)→ `200 {data:{path,size}}`。**备份数据**:经 SQLite `VACUUM INTO`(含已提交 WAL 内容)快照到 `<dataDir>/backup-<ts>.db`,`path` 为备份文件绝对路径、`size` 为字节数;数据库路径未配置回 `500 backup_unavailable`、快照失败回 `500 backup_failed`。
- `POST /system/restore` body `{path}` → `200 {data:{restored:true,backup,note:"已安排恢复,需重启服务后生效"}}`。**恢复数据**:`path` 须为 **dataDir 内**的备份文件(**路径穿越防护**——`filepath.Rel` 校验,`..` 逃逸回 `400`);文件不存在回 `404`;**先自动备份当前数据库**(`backup` 为快照路径)再替换 DB 文件;运行进程持有 DB 句柄,**替换后需重启服务生效**;数据库路径未配置回 `500 backup_unavailable`、替换失败回 `500 restore_failed`。
**存储状态契约(`GET /api/v1/admin/system/storage`,v0.60)** —— 上面四条是**动作**(写 + 审计),这一条是**只读查询**:owner-only、**不审计**、**只读**(只有 `SELECT`/`PRAGMA` + `os.Stat`,复用组合根注入的活连接——不新开连接池、不写、不迁移、不备份)。它回答「现在这台机器上到底存了什么」,是面板首页「存储」卡的数据源。
- **请求**:无 body、无 query。`Authorization: Bearer <JWT>`(role=owner)或 `X-API-Key`;**无凭据 / 非 owner → `401`**。
- **响应 `200 {data:…}`**(三块,字段名逐字对齐 `internal/services/gateway/admin_system_storage.go`):
```json
{"data":{
"main":{"kind":"sqlite","path":"/srv/cyrene/data/cyrene.db","fileBytes":12894208,
"walBytes":107872,"shmBytes":32768,"pageSize":4096,"pageCount":3148,
"freelistPages":12,"freeBytes":49152},
"documents":{"count":37,"bytes":2143567},
"vector":{"kind":"none","enabled":false,"ready":false}
}}
```
- `main.kind` 恒为 `"sqlite"`(当前唯一的主库引擎);`main.path` = 组合根实际打开的库文件路径(与备份/恢复端点同一个来源)。
- `main.fileBytes` = 主库文件字节数(`os.Stat`);`walBytes`/`shmBytes` = `<db>-wal` / `<db>-shm` 边车文件字节数,**合法地不存在时如实为 `0`**(已 checkpoint 或库未开 WAL)。
- `main.pageSize`/`pageCount`/`freelistPages` 来自 `PRAGMA page_size` / `page_count` / `freelist_count`(一条 SELECT 取完);`freeBytes = pageSize × freelistPages`,即「已分配但空闲、VACUUM 可回收」的量级。
- `documents` = `documents` 表的**行数**与 `SUM(size)`(读表,**不 walk 目录**);空表时 `bytes` 为 `0`(`COALESCE`),**不是** `null`。
- `vector` = 可选 pgvector 增强的**运行时真相**(主人 2.4 的困惑点:面板只写「SQLite」会让人以为 PG 没生效):
- `kind`:`"pgvector"` = 已配置 `DB_PG_URL`;`"none"` = 未配置(前端显示「未启用(未配置 `DB_PG_URL`)」)。
- `enabled`:开关在不在(等价于 `kind != "none"`)。
- `ready`:**活着的**向量索引确实是 PostgreSQL+pgvector 后端。配了 PG 但连接/schema 准备失败 ⇒ `enabled:true` + `ready:false`(检索已降级),客户端应显示「已配置但未就绪」,**不要**把 `enabled` 当成「生效了」。
- 真源 = 组合根的运行时状态(`DB_PG_URL` 非空 + 活索引的具体类型),网关**不自行探测**。
- **不含凭据(红线)**:DSN / 用户名 / 密码 / `postgres://` 全文**一个字都不回**,`vector` 段只有 `kind`/`enabled`/`ready` 三个键(有专门用例断言响应体里搜不到这些串)。
- **错误**:
- `401`(无凭据 / 非 owner / 凭据无效——与其它 `ownerAuth` 端点同档);
- `500 {error:{code:"storage_unavailable",message}}`:数据库**路径**未配置、活**连接**未注入、或页级统计 / `documents` 表读不到(例如库没跑迁移)。**诚实边界**:此时**不返回**任何全 0 的假快照,客户端应显示错误而不是 `0`。
- **只读保证**:调用前后 `documents` 行数与数据目录内的文件集合均不变(有用例钉住:既没有写、也没有顺手备份)。
- **前端用法**:`adminSystemStorage()`(`frontend/web/src/lib/api/adminSystem.ts`)→ 卡片 `value` = `SQLite 12.3 MiB`、`hint` = WAL/页数/空闲页、详情行 = `documents N 件` 与 `PG 增强 …`;取数走与其它已接线卡片同一条加载/错误态。
**LLM 用量汇总契约(`GET /api/v1/admin/llm/usage`,v0.64,切片 U1)** —— 只读查询:**owner|admin 读**(与同页 `GET /admin/llm` 同门;含成本与模型/用途分解,**不是**给 `viewer` 的监控投影)、**不审计**、**只读**(只有 `SELECT`,复用组合根注入的活连接——不新开池、不写、不迁移)。它回答「这些模型调用到底花了多少」,是面板 LLM 页「用量」小卡的数据源。字段名逐字对齐 `internal/services/gateway/admin_llm_usage.go`。
- **请求**:`GET /api/v1/admin/llm/usage?since=&until=&purpose=`,`Authorization: Bearer <JWT>`(role=owner|admin)或 `X-API-Key`。
- `since`/`until`:**接受两种形态** —— **RFC3339**(如 `2026-10-05T00:00:00Z`,JS 的 `new Date().toISOString()` 就是这个)或 **Unix 毫秒**(纯数字,如 `1759622400000`;与 `/sessions/{id}/messages` 的 `before`/`after` 游标同一种口径,便于客户端复用)。其它形态 → `400 bad_request`(**绝不静默当成「现在」**)。
- **缺省窗口 = 近 24 小时**(`until` 缺省 = 现在,`since` 缺省 = `until − 24h`);`until` 必须**严格晚于** `since`,否则 `400`。
- **窗口上限 = 90 天**:跨度超过上限回 `400 {error:{code:"bad_request"}}`(文案点名上限)——**不静默裁剪**,静默裁剪会让客户端把「我没查那么久」误读成「就那么少」。
- `purpose`:空格截断后**精确匹配**用途标签(如 `chat` / `memory_extract` / `noise_review`);缺省/空 = **不过滤**。
- **响应**:`200`
```json
{"data":{
"window":{"since":"2026-10-04T00:00:00Z","until":"2026-10-05T00:00:00Z","purpose":""},
"totals":{"calls":128,"prompt":412000,"completion":38000,
"cacheHit":356000,"cacheMiss":56000,"hitRate":0.8641},
"byPurpose":[{"purpose":"chat","calls":120,"prompt":410000,"completion":37000,
"cacheHit":355000,"cacheMiss":55000,"hitRate":0.8659}],
"byModel":[{"model":"deepseek-chat","calls":128,"prompt":412000,"completion":38000,
"cacheHit":356000,"cacheMiss":56000,"hitRate":0.8641}]
}}
```
- `window` 回显**补齐后**的时间窗(缺省值也回显,客户端不必自己猜)与用途过滤(`""` = 未过滤);时间一律 **UTC RFC3339**。
- `totals`:整窗合计,键**恰好六个**——`calls`(成功调用次数)、`prompt`(输入侧 token)、`completion`(输出侧 token)、`cacheHit`/`cacheMiss`(输入侧两档)、`hitRate`。
- **`hitRate` 口径(重要)**:`cacheHit / (cacheHit + cacheMiss)`,**分母为 0 时下发 `0`**(口径与 `model.Response.CacheHitRate` 同源,绝不下发 `NaN`/`Inf`/编造的 100%)。⚠️ 客户端展示时建议把「分母为 0」与「真 0% 命中」分开:前者显示 `—` + 说明「本窗口没有输入侧缓存明细」,后者才是 `0.0%`(Web 面板的用量小卡就是这么做的)。
- `byPurpose` / `byModel`:按用途 / 按模型分解,**元素键 = 分组键 + 上面六个合计键**(分别是 `purpose` / `model`);排序 = **调用次数降序、同次数按键升序**(输出确定,客户端可安全 diff);**无数据时是 `[]` 而非 `null`**。
- **不含凭据(红线)**:响应里既没有 `apiKey` / token,也**没有消息正文** —— 那些东西根本没进 `llm_usage` 表(写入方的样本结构里就没有这些字段)。
- **错误**:`401`(无凭据 / 凭据无效);`403`(`viewer`;与 `/admin/llm` 同门);`400 bad_request`(`since`/`until` 形态非法、`since` 不早于 `until`、跨度超 90 天);`500 {error:{code:"usage_unavailable"}}`(汇总出口未接线,或查库失败)——**诚实边界**:此时**不返回**全 0 的假汇总,客户端应显示错误而不是 `0`。
- **数据来源与写入口径(谁在写这张表)**:每次**成功**的模型调用都会把一条**非敏感**样本(`adapter` / `model` / `purpose` / 六个 token 计数 / `tookMs`)异步落进本机 `llm_usage` 表(迁移 `0017`)。写入是**有界队列 + 单写者 + 批量事务**:队列满即**丢弃并限流 WARN**,**绝不阻塞模型调用**;整批失败只记 WARN,不影响任何一次对话。**凭据与消息正文绝不入库、绝不进日志**。
- ✅ **`session_id` 已接线(v0.65 更正)**:loop 在 `Run` 顶部把会话 id 播种进运行 ctx(`model.WithSessionID`),适配器落库时带上它(空串映射为 `NULL`)——**v0.64 写的「当前恒为 `NULL`」已过期**,该列从此真的有值。但**本端点仍不按会话过滤 / 分组**(只按 `purpose` / `model` 分解),**响应形状不变**;sink 未注入或会话未播种时行为逐字不变。
- **保留期**:`LLM_USAGE_RETENTION`(默认 `720h` = 30 天;**≤0 = 关闭清理**,样本全留,启动日志会写明「llm usage retention disabled」);启动期清一次 + 此后每小时清一次。
- **鉴权/审计档位说明**:它是**查询不是动作**,因此**无审计记录**;矩阵归类见 §2.0 的 ②(owner/admin 只读档)。
- **前端用法**:`adminLlmUsage()`(`frontend/web/src/lib/api/admin.ts`)→ 卡片「用量(近 24h)」显示**调用数 / 输入 token / 缓存命中率**(`cacheHit + cacheMiss = 0` 时命中率显示 `—`);取数走与其它已接线卡片同一条加载/错误态(**读不到就显示错误,不显示 0**)。
**知识库 RAG 契约**:
- `GET /admin/rag` → `{"data":{"documents":[{id,name,desc?,chunks,embedded}],"hits"?:[{docId,docName,chapter?,text,score,semantic}]}}`。`hits` 仅在携带 `?query=` 时出现;`embedded` 反映部署是否配置向量增强(未配置为待嵌入)。
- `POST /admin/rag` body `{"name","desc?","content"}` → `201 {"data":{"name"}}`。**导入文档**:后端分块(空行分段,超长段按固定长度切)并落项目索引(项目+章节),立即可被关键词/行上下文检索;未注入 `RagStore` 回 `501`。语义/向量嵌入为优雅降级(知识库 Store 不直属向量索引,导入文档走 keyword/line 路径;语义检索仍对既有 project-linked 记忆生效)。
- `POST /admin/rag/query` body `{"query":"..."}` → `{"data":{"query":"...","hits":[{docId,docName,chapter?,text,score,semantic}]}}`。检索走项目索引关键词+行上下文;配置 pgvector/向量增强时并入语义/向量命中(`semantic:true`)。
- `POST /admin/rag/delete` body `{"id":"..."}` → `200 {"data":{"id":"...","deleted":true}}`。**真实删除**:从项目索引删除该文档条目并**级联删除其全部章节**(单事务),删除后 `GET /admin/rag` 不再出现、检索不再命中。`id` 缺失/空白回 `400`;文档不存在回 `404 not_found`;未注入 `RagStore` 回 `501`(**owner-only + 审计 `rag.delete`**)。
**可观测性契约**(`GET /api/v1/admin/observability/*`,均需 owner/admin;`metrics`/`trace`/`audit` 的 `?limit=N` 缺省 50、上限 500):
- `GET /metrics` → `{"data":{"totalRequests","qps","avgLatencyMs","maxLatencyMs","perMethod{}","perPath{}","statusCounts{}","llmCalls","subagentEvents"}}`。`qps` 为近 1 秒窗口计数;`llmCalls`/`subagentEvents` 由实际观测到的 trace span 按 `kind` 累计(Tracer 未注入时为 0)。**Token/成本本端点不统计、不返回**(不伪造指标)——**token 与成本由 v0.64 新增的 `GET /admin/llm/usage` 单独回答**(见 §2「LLM 用量汇总契约(v0.64)」)。
- `GET /trace?limit=100` → `{"data":[{id,parentId?,name,kind,status,startedAt,endedAt?,durationNs,props?,events?]}`。`kind` ∈ `llm_call|tool_call|orchestration|state_transition|subagent|memory|guardrail`;`durationNs` 为纳秒;`events` 为 `[{time,type,props?}]`。
- `GET /audit?limit=100` → `{"data":[{id,who,what,when,result}]}`。`id` 为整数自增;`when` 为 RFC3339;来源为 SQLite `audit_logs` 表(未注入 AuditStore 时退化为进程内存)。
- `GET /logs?level=&limit=` → `{"data":[{time,level,source,msg}]}`(**后端日志 tail**,v0.35)。
- **来源**:logger 进程内**有界环形缓冲**(容量 2000,满则丢弃最旧),只存 logger 已产生的记录,**不额外打印、不重渲染、不落库**;控制台/文件输出完全不受影响。`source` 取自 `logger.WithSource` 的 `source=` 属性(`logger.Scoped` 的记录回退为 `module/sub`),无来源记 `""`。
- **排序**:`time` **倒序**(最新在前)。`time` 为 **RFC3339(UTC,秒精度)**,与 `trace.startedAt` / `audit.when` 一致;`level` ∈ `debug|info|warn|error`;`msg` 为记录消息。
- **查询参数**:`level` ∈ `debug|info|warn|error`(大小写不敏感)为**最低等级过滤**(如 `level=warn` 返回 warn + error),**空/缺省 = 全部**,未知值回 `400 invalid_level`;`limit` 缺省 `200`、上限 `1000`(小于等于 0 或非法值回落缺省)。日志量超过 `limit` 时按时间倒序截断(先按 level 过滤再取 `limit`,缓冲全量 2000 条内扫描)。
- **诚实边界**:组合根未注入日志环形缓冲(非二进制主链路启动)时回 **`501 not_implemented`**,不返回伪造/空的成功 tail。日志内容为 logger 已产出内容,**敏感信息按既定规范本就不落日志**(本端点不新增任何打印)。
**通知聚合契约**(`GET /api/v1/admin/notifications`,**owner/admin 读**;design/10 §3 #16):
- `GET /admin/notifications?type=&before=&limit=` → `200 {"data":[{id,type,title,body?,source,createdAt}]}`,按 `createdAt` **倒序**(同毫秒按 `id` 倒序稳定)。
- **查询参数**:`type` ∈ `system|channel|backthink|audit`(**空/缺省 = 全部**,未知值回 `400 invalid_type`);`limit` 缺省 `50`、上限 `200`;`before` 为 `createdAt`(epoch 毫秒)游标,**含游标本身**(`createdAt <= before`——两源时间戳精度不同(审计秒/纳秒、backthink 毫秒),页边界可能落在同一毫秒,**客户端按 `id` 去重**)。缺省/非法 `before` = 从最新开始。
- **字段**:`id` = `<source>:<原生 id>`(`audit:<audit_logs.id>` / `backthink:<backthink_records.id>`),**跨页稳定、可去重**;`source` ∈ `audit|backthink`;`createdAt` 为 epoch 毫秒(无法解析的审计时间戳记 `0`,不伪造时间)。`title`/`body` 由各自源派生:审计项 `title = what`(如 `channel.open`)、`body = "<who> · <result>"`;backthink 项 `title = "主动推送(<trigger> · 已送达|未送达)"`、`body =` 结论摘要(≤200 rune,超出加 `…`)。
- **类型归类**(审计源按操作前缀派生):`channel.*` → `channel`;`system.*` / `plugin.*` / `policy.*` → `system`;其余审计操作(persona/user/memory/rag/session/upload/audio/…)→ `audit`;backthink 主动推送 → `backthink`。
- **数据源与诚实边界**:**只聚合现有源**(审计 `audit_logs` + backthink 记录),**不新建 notifications 表**。backthink 仅 `shouldPush=true` 的记录算通知(纯思考记录不推给主人);**未注入的源 → 该类型为空,不伪造**(无 backthink 服务即无 `backthink` 项);源报错回 `500 notifications_failed`(**不静默少给**)。本版为**只读 feed**:未读/已读游标后置(不建重表)。
## 3. WebSocket `/ws/chat`(实时)
- **握手**: `ws://<host>:8080/ws/chat?token=<JWT>` 或 `Authorization: Bearer`;未认证在握手前回 `401` JSON。
- **下行(服务端→客户端)**: 经 bus 广播 `{type:"<event>", data:..., turnId?, seq?, sessionId?}`(`turnId`/`seq` 为流式关联字段,见 §3.1;`sessionId` 为**会话持久化(design/16)新增**,能确定会话的事件带上,供客户端把该帧路由到**它自己那个会话**的消息桶(**v0.54 起:非激活会话的帧不再被丢弃**);**v0.36 起结构化流帧 `message.*` / `thinking.*` / `tool_call.*` / `tool_execution.*` 也带 `sessionId` + `turnId`**——多会话并发流式时客户端据此把 delta 落到各帧自身 `sessionId` 的消息桶,见 §3.1)——`message.start/delta/end` / `thinking.start/delta/end` / `tool_call.start/delta/end`(以上模型流帧的 `data` 另带 `streamId`,v0.50)/ `tool_execution.start/update/end`(工具执行三事件,v0.50)/ `turn.start` / `turn.end`(回合边界帧,v0.50)(结构化流,design/04 §1.1),以及向后兼容的 `stream_chunk` / `stream_end` / `thinking`(思维链) / `mood`(情绪变化,见 §3.7) / `reminder` / `device_update` / `tool_used`(工具回显) / `subagent_event`(逐代理进度) / **`subagent_result`**(后台批次的最终结果,见 §3.10) / **`approval_notice`**(主代理代审小字,见 §3.11) / `graph_update` / **`message_persisted`**(会话持久化增量,见 §3.3)/ **`message_edited`**、**`message_deleted`**(消息编辑/删除增量,见 §3.8)/ **`session_read`**(已读游标推进,见 §3.9)/ **`observability_log`**、**`observability_trace`**、**`observability_metrics`**(可观测实时推送,见 §3.12,v0.55)。**会话编排状态(design/15,均为广播,`data.sessionId` 区分会话)**:`session_busy` / `pending_ack` / `pending_drain` / `session_idle` / `session_interrupted` / `pending_full`(见 §3.2)、**`chat_duplicate`**(客户端幂等键重复到达的明确回执,v0.56 新增,见 §3.13);另有 **`steer_ack`**(忙时会话里排队的那条用户消息**已被在飞回合吃进同一回合**的回执,v0.57 新增,见 §3.14)——它经**同一状态通道**,但由 **hub 直接广播、不经 bus**,帧壳带 `sessionId`(`data` 内不重复);因它**携带具体消息身份**,**不属**上面这组「状态机信号」。语音回复走 `voice_output`(定向回请求连接,非广播);**队列满的定向拒绝**走 `{type:"error", data:{code:"chat_busy", …}}`(只发发送者,见 §3.2);语音回合本身走与文本相同的编排路径(见 §3.15)。
- **下行帧的角色门(v0.48,design/11 §7.1)**: 下行帧按**类型**分两类,由后端**唯一一处**收口判定(**不在白名单里的帧默认只发 `owner` + `admin`**,fail-closed):① **状态机信号**(白名单,**不含任何内容**)→ **所有已认证连接**(含 `viewer`):`session_busy` / `pending_ack` / `pending_drain` / `session_idle` / `session_interrupted` / `pending_full`(§3.2)、`session_read`(§3.9);② **其余全部** → **仅 `owner` + `admin`**:流式内容 `message.*` / `thinking.*` / `tool_call.*` / `tool_execution.start/update/end` / `turn.start` / `turn.end`(**v0.50 新增的这几种同属本档**——工具进度与回合边界对 `viewer` 也是内容相邻信息,fail-closed 默认即只发 owner+admin,未扩白名单)/ `stream_chunk` / `stream_end` / `thinking`(§3.1)、持久化 `message_persisted`(§3.3)/ `channel_persisted`(§3.4)/ `message_edited` / `message_deleted`(§3.8)、系统内部 `graph_update`(§3.5)/ `subagent_event` / **`subagent_result`(§3.10,v0.51 新增的这帧同属本档)** / **`approval_notice`(§3.11,v0.52 新增,同属本档)** / `task_output` / `task_done` / `tool_used` / `capacity_awaken` / `mode_switch`、私有状态 `mood`(§3.7)/ `device_update` / `reminder`、**可观测推送 `observability_log` / `observability_trace` / `observability_metrics`(§3.12,v0.55 新增,同属本档——未进 7 个状态机信号白名单,fail-closed 只发 owner+admin)**、**`chat_duplicate`(§3.13,v0.56 新增,同属本档——它虽由编排器状态通道下发,但未进那 7 个状态机信号白名单,故 fail-closed 只发 owner+admin)**、**`steer_ack`(§3.14,v0.57 新增,同属本档——它携带具体消息 id 与模型步序号,未进那 7 个状态机信号白名单,故 fail-closed 只发 owner+admin;经 hub 直接广播、不经 bus)**。
- **上行帧与握手回放的角色门(v0.49,design/11 §7.2)**: 与上面那条下行门**成对**,收的是同一个缺口的两处残余——① **客户端→服务端(上行)JSON 帧**:只有 `owner` / `admin` 连接可以发;非 `owner`/`admin`(即 `viewer`)连接发来的**任何**上行 JSON 帧都**默认被拒**,回复是**定向**(只回发送者)的 `{type:"error", data:{code:"forbidden"}}`,**不广播、不驱动模型、不落库、不返回任何内容**。被拒帧:`chat` / `interrupt` / `subagent_ask` / `task_output_query` / `voice_input` / `mode_switch`——**未登记的新上行帧同样默认拒**,要放行必须先在 design/11 §7.2 登记白名单(实现上那把「默认拒」也必须同步改为登记表,不能直接删)。② **握手 `history` 回放**:仅 `owner` + `admin` 连接收到;`viewer` 连接建立后**不收**回放。**协议级帧不受影响**:WS ping/pong 与正常 close 照常(否则连接会掉),被拒的上行帧也不会关掉连接。
> **这是「谁被主动推送」的门,不是数据可见性**:`viewer` 的 12 条只读监控端点(`viewerAuth`,v0.40)**照旧**(含 `GET /personas` 的角色级 `mood`);`viewer` **仍可连** `/ws/chat`(连接鉴权不变),只是收不到第 ② 类帧。定向回请求连接的帧(`voice_output` 等)不受影响。**上一条的门只管「推给谁」;「谁能发、谁能拿回放」见上面 v0.49 那条**——`viewer` 连上后**发不出任何指令**、也**收不到 `history` 回放**。
> **`history` 回放事件(design/16,新增)**:WS 握手升级成功后、该连接下一帧即收到 `{type:"history", sessionId:"web", data:{messages:[{id,sessionId,role,content,mode?,mood?,image?,platform?,deviceId?,personaId?,clientMsgId?,createdAt}]}}`——回放主会话最新 50 条(`thinking` 已剥离;默认主会话,次级会话由客户端按激活会话走 `GET /sessions/{id}/messages` 拉取)。客户端据此在重连/新开连接后补全上下文。消息体含 `senderName?`(第三方昵称,design/16 §4.6,主人私聊为空)与 `personaId?`(v0.42,说话时的角色,design/22 §7-5,空 = 默认角色)。**角色门(v0.49,design/11 §7.2 A)**:这条回放**只发 `owner` + `admin` 连接**;`viewer` 连接建立后**不会**收到 `history` 帧(它若需要只读数据,走 §1 的只读 REST 端点)。
### 3.1 结构化流事件(`message.*` / `thinking.*` / `tool_call.*` / `tool_execution.*` / `turn.start/end`)
- **事件壳**:`{type, sessionId?, turnId?, seq?, data}`, `data` 为**类型化结构**(非 `any`)。`seq` 为 hub 级单调递增序号,供客户端将一次突发的流式事件排序关联。**`sessionId` 为事件所属会话、`turnId` 为该轮助手回合 id(均 v0.36 起带上)**:`sessionId` 由 loop 在回合边界注入(模型适配器在 loop 之下、不感知会话,恒留零值;loop 外无会话上下文的事件该字段为空),`turnId` 由网关每个回合新生成并挂进运行上下文——同一会话同时只有一个 in-flight 回合,故**同轮内所有帧的 `turnId` 一致**。客户端据此把 `message.delta` / `thinking.delta` / `tool_call.*` / `tool_execution.*` **路由到该帧自己 `sessionId` 的会话消息桶**(**v0.54 起:非激活会话的帧不再被丢弃**——后台会话的正文继续累积、切过去即可看到完整这一轮);**只有「单槽忙态」**(`session_busy` / `session_idle` / `session_interrupted` 与 `stream_end` 的 `sessionBusy` 收口,见 §3.2)**仍按 `sessionId === activeSessionId` 判定**,以免别的会话的忙/闲帧串到屏幕上的会话。
- **`streamId`(v0.50,模型流帧 `data` 内可选字段)**:`message.*` / `thinking.*` / `tool_call.*` 帧的 `data` 带 `streamId`(如 `{"delta":"…","index":0,"streamId":"stream-a1b2…"}`)——**同一次模型流的多帧共享同一 id**,loop 在每次模型流(一次 ChatStream 调用)开始时生成;多步工具轮的第二次模型调用取**新** id。`turnId` 标记「这轮回答」(整轮恒定),`streamId` 标记「这轮里的哪一条流式消息」;一个回合含工具调用时会有多个 `streamId`。`tool_execution.*` 与 `turn.*` 帧不属于任何模型流,**不带** `streamId`。
- **`message.start`**(`{index, streamId?}`) / **`message.delta`**(`{delta, index, streamId?}`) / **`message.end`**(`{index, streamId?}`):正文段。`delta` 为正文 token 增量;客户端对 `message.delta` 累加到当前消息正文;`message.start/end` 划定正文段边界,**不**结束整轮(结束由 `stream_end` 负责)。
- **`thinking.start`**(`{index, streamId?}`) / **`thinking.delta`**(`{delta, index, streamId?}`) / **`thinking.end`**(`{index, streamId?}`):思维链段(前端折叠块)。`thinking.delta` 累加增量。
- **`tool_call.start`**(`{id, index, streamId?}`) / **`tool_call.delta`**(`{id, index, delta, streamId?}`) / **`tool_call.end`**(`{id, index, streamId?}`):工具调用中间态;`tool_call.delta` 携带参数增量(如 Anthropic `input_json_delta`)。
- **`tool_execution.start`**(`{tool, phase:"running", id?}`):工具**进入执行**(v0.50)。`tool` 为工具名;`phase` 为细粒度执行阶段(枚举与 §3.5 流程图节点 status 同一套 `running/done/error`);`id` 为 provider 工具调用 id(可关联同轮的 `tool_call.*` 帧,provider 未给 id 时省略)。
- **`tool_execution.update`**(`{tool, phase:"running", delta?, id?}`):工具**运行中**的进度/局部输出,一次执行可多帧(v0.50)。**现状**:wire 映射已实现并验证,但工具分发器尚无中途进度上报源,loop 目前不产出该帧(start/end 有真实生产者)——出现生产者后无需再改契约。
- **`tool_execution.end`**(`{tool, phase:"done"|"error", id?}`):工具执行**完成或失败**(v0.50);`phase:"error"` 即失败(design/19 流程图的 tool 节点据此翻 `error` 态;错误详情不进该帧,走流程图节点日志的脱敏摘要)。
- **`turn.start` / `turn.end`**(无 `data` 载荷):回合边界帧(v0.50),loop 在 Run 的起止发出、经 ws_bridge 下行;帧壳的 `sessionId`/`turnId`/`seq` 即边界信号,客户端用它括起「一轮回答」的完整区间(含其全部模型流与工具执行)。`turn.end` 覆盖回合所有出口(最终答案 / 工具循环安全网终止 / 预算耗尽 / 步骤出错);**回合终态信息(`stopReason`/`steps`)仍由既有 `stream_end` 携带**,两帧分工不重叠。内部自主流程(backthink / 子代理)被静音,不发出这两帧。
- **向后兼容(v0.50 对 `tool_execution` 的替换说明)**:v0.49 及之前的单帧 `tool_execution.update {id,index,delta}`(`delta` 形如 `"start:工具名"` / `"end:error:工具名"` 的拼串)**不再由 loop 产生**——按旧拼串解析的客户端必须迁移到三事件(`tool_call.*` 帧形状不变)。`turn.start/end` 与各帧 `data` 里的 `streamId` 为纯新增键,老客户端按未知帧/未知键忽略即可。现有 Web 前端未消费这些新帧,行为不变。
- **向后兼容**:未映射/无法确定的事件仍走 `stream_chunk` + `stream_end`(非流式回退路径)。
- **上行(客户端→服务端)**: `mode_switch` / `chat`(跑 loop 并广播流式) / `voice_input` / `subagent_ask` / `task_output_query` / `interrupt`。
- **`interrupt`**(上行,客户端→服务端):`{sessionId, scope?}`(**`scope` 为 v0.56/A2 新增**)→ 会话编排器中断(design/15 §3)——取消该会话当前在跑的任务(context cancel)并回 `session_interrupted`;用户说「停下/算了/换个话题」时发。**`scope` 取值域 `{"", "all", "current"}`**(**大小写敏感,只容忍首尾空白**):
- **省略 / 空串 / `"all"`**(三者同义)= 取消**当前全部在途路** **+ 清空待处理队列**,会话立即回到空闲(**逐字不变的现状语义**,老客户端只发 `{sessionId}` 的行为完全不变);`session_interrupted.dropped` = 被清空的 pending 条数、`mode` = `idle`、`activeTurns` = `0`。
- **`"current"`** = **只取消在途路、保留待处理队列**(多路时**每一路都取消**——省略 `turnId` 的 scope 就是「作用于每一路」):队列里的消息按编排器正常续跑规则继续执行(带上会话尾部上下文),一条都不丢。此时 `dropped` 恒为 **`0`**,`mode` 如实报告该会话当时的**忙碌通道**(`thinking`/`executing`/`subagent`,**不是 `idle`**)。用途:客户端「打断」与「追加队列」是两种模式,若打断一律连队列清空,用户选「打断」时连发的多条消息会被静默丢弃、只剩一个 `dropped` 数字。
- **计数键(v0.65,纯增量)**:`session_interrupted.data` 增 `cancelledTurns`(本次**真的取消了几路**)与 `activeTurns`(清点后仍在途的路;`"all"` 时恒 `0`);并**不发 `session_idle`**(与今天一致,客户端靠本帧收口)。
- **未实现(v0.65 诚实边界)**:精确停**某一路**的 `interrupt.turnId` **本版未实现**(省略 `turnId` 即作用于全部在途路,见上);`{sessionId, turnId}` 里的 `turnId` 今天**会被忽略**。
- **其它取值** → **只回发送者** `{type:"error",data:{code:"bad_request"}}`(**定向**:不广播、**不打断任何会话**);显式报错,绝不静默按默认执行(否则客户端以为队列保住了、实际被清空,正是 A2 要修掉的那类缺陷)。
- 命中空闲会话为 no-op(两种 scope 都是)。
- **`chat` 帧的上行字段**(v0.56/G4 + v0.59 + v0.65):`{sessionId, text, image?, deviceId?, mode?, clientMsgId?, documents?, parallel?}`——`clientMsgId` 为**客户端自带的幂等键**(可选):同一会话内同一个非空值只承认第一条,重复到达**不落库、不起回合**,回新下行帧 **`chat_duplicate`**(§3.13);省略 = 与加入该字段之前逐字相同。`documents`(v0.59)为文档附件引用数组(§1 行 16/17 与 §1.2)。**`parallel`(v0.65,可选)** = 「把本条作为**独立一路**处理」的显式声明:**只有会话正忙、且单会话并发额度未满(默认 2 / 硬上限 3 / `<2` 关闭真并行)才照准**——照准则立即起第二条路(帧壳 `turnId` 即那一路),额度满、或真并行被关掉、或未声明则落回既有排队语义并在 `pending_ack` 带 `reason:"parallel_limit"`(见 §3.2);**空闲时无效果**;**服务端不做启发式自动升级**。**REST `POST /api/v1/chat` 这几个可选项语义完全相同**(重复回 `200 {data:{duplicate:true,messageId,sessionId}}`、照准并行回 `202 {data:{parallel:true,turnId}}`,见 §1 行 3)。
- **`voice_input`**(上行,客户端→服务端):`{audio?, format?, text?, sessionId?, deviceId?, clientMsgId?}`。`audio` 为 base64(或 data: URI)音频,`format` 为其编码(`wav`/`mp3`/`ogg`);`text` 为已转写文本时跳过 STT;`deviceId` 为**客户端具体端标识**(design/16 §4.5,如 `voiceassist:myapp:v1`,可选),随该语音轮次落库、聊天气泡可标注来源。**`clientMsgId`(v0.59)=客户端自带的幂等键**(可选,**与 `chat` 帧同形** ⇒ 语音也有幂等):同一会话内同一个非空值只承认第一条,重复到达**不落库、不起回合、不发音频**,回既有下行帧 `chat_duplicate`(§3.13);省略 = 与加入该字段之前逐字相同。**语音回合现在走与文本回合同一条路**(进编排器 → 落库 → 流式 → 跨端同步 → 忙时排队),详见 **§3.15**。服务端走 STT→agent 聊天→TTS(design/06 §1),回复经 `voice_output` 定向回该连接。**真 STT/TTS 已接通阿里百炼**(`DASHSCOPE_API_KEY` 必填:STT=`qwen-audio-3.0-asr-flash`、TTS=`cosyvoice-v3-flash`+`longxiaochun_v3`);无凭据时后端走降级链(cloud → edge-tts/espeak(未实现)→ `silent` 空音频)。无注入语音服务时回 `error` 帧(`voice unavailable`)。
- **`voice_output`**(下行,服务端→客户端,**定向**):`{audio, format, text, reply, sessionId, deviceId?, provider?}`。`audio` 为 base64 合成音频,`format` 为其编码,`text` 为识别文本,`reply` 为助手回复文本;**v0.59 新增 `sessionId`(恒有)与 `deviceId`(非空才有)**——多端同时说话时客户端据此区分归属。**当合成音频为空(silent 降级)或 TTS 失败时带 `provider`**(如 `"silent"`,非空音频时省略)——客户端据此提示「没生成声音」,但该帧**仍照常发出、不算业务错误**;**空正文一律走同一条降级信号,绝不拿 `stopReason` 顶替成语音内容**。**时点与投递口径见 §3.15**(回合结算时才到、定向目标 = 发起那条连接、断线不补投)。
- **向后兼容**:未映射/无法确定的事件仍走 `stream_chunk` + `stream_end`(非流式回退路径)。
---
### 3.2 会话编排状态事件(design/15)
> **角色门(v0.48,design/11 §7.1)**:本节的 6 个状态信号**都在白名单内**——它们只报「会话忙/闲/排队」,**不含任何内容**,所以**所有已认证连接**(含 `viewer`)都收得到;真正的正文/推理内容走 §3.1 的流帧,那些**只发 `owner` + `admin`**。
- **触发**:同一会话内,`chat` 到达时若「她忙」(正在跑 loop/工具循环/子代理/长回复)→ 新消息**不阻塞**、入该会话 pending;这一层的状态经以下事件推给前端,供「正在做 xx… 有新消息稍后回」轻状态展示。
- **`session_busy`**:会话进入忙碌态(`{sessionId, mode, activeTurns}`,`mode` ∈ `idle|thinking|executing|subagent`)。**`activeTurns`(v0.65,纯增量)** = 该会话当前**在途路数**(含正在收尾的路;单路时非 0 即 1)——并行时它才是真正的路数。
- **`pending_ack`**:忙时新消息已入队(`{sessionId, mode, queueDepth, reason?}`)——「收到,稍后回」。**`reason`(v0.65,纯增量、可选)**:**只在「客户端显式声明了并行(`parallel:true`)、本条却落回排队」时出现**,取值恒为 `"parallel_limit"`(额度已满,**或**真并行被 `<2` 关掉——客户端问了并行而没拿到);**其它情况该键完全缺席**,帧与今天逐字相同(绝不伪造)。
- **`pending_drain`**:忙完开始消费下一待处理消息(`{sessionId, mode, queueDepth, activeTurns}`)。
- **`session_idle`**:会话回到空闲(`{sessionId, mode, activeTurns}`)。⚠️ **时点(v0.65,行为变更,仅并行时生效)**:从「队列清空」收紧为「**所有在途路都已收尾,且 pending 为空**」——单路时这个时点与今天**逐字相同**(此刻在途路数必为 0);真并行下,只要还有任一路在飞(哪怕它正在续跑队列里的下一条)就**不报空闲**。
- **`session_interrupted`**:用户 `interrupt` 中断(`{sessionId, mode, dropped, cancelledTurns, activeTurns}`)——语义随 `scope`(v0.56/A2):`scope:"all"`(**也是省略 / 空串**)= 取消**当前全部在途路** + pending 清空,此时 `mode = idle`、`dropped` = 被清空的 pending 条数、`activeTurns = 0`;**`scope:"current"` = 只取消在途路、pending 保留**(多路时**每一路都取消**,因为省略 `turnId` 的 scope 就是「作用于每一路」),此时 `dropped` 恒为 **`0`**、`mode` 为该会话当时的**忙碌通道**(`thinking`/`executing`/`subagent`,**非 `idle`**)。`dropped` 恒等于**真的被丢弃**的条数(绝不把「保留」报成丢弃)。**两个计数键(v0.65,纯增量)**:`cancelledTurns` = 本次**真的取消了几路**,`activeTurns` = 清点之后仍在途的路(`scope:"all"` 恒为 `0`)。**`scope:"all"` 不发 `session_idle`**(与今天一致,客户端靠本帧收口)。
- **多路下的未实现项(v0.65,诚实边界)**:精确停某一路的 `interrupt.turnId`、编排器发出的 **`turn_begin`** 帧、`steer_ack` 的**帧壳 `turnId`** **本版均未实现**(见 §5 v0.65 条目 J 段);`graph_update` 亦**未按路分桶**(两路会同会话互相顶掉)。客户端**不要**依赖这些帧/键。
- **`pending_full`**:单会话 pending 达上限(默认 5)拒绝入队(`{sessionId, mode, max}`)——防无限积压(design/15 §4)。**v0.59(G5)载荷增两个可选键**:`clientMsgId?`(= **被拒那条消息**的客户端幂等键;该消息未携带则**整个键缺席**,绝不伪造)与 `messageId?`(**仅当该消息此前已落库 / 已认领时**才回填)。⚠️ **服务端绝不在此处新铸 id**:队列满的拒绝发生在幂等键**认领之前**,代铸一个 id 会让客户端拿同一 `clientMsgId` 重试时被判成「重复到达」而**永远进不来**。
- **`pending_full` 的定向伴生帧(v0.59 / G5,此前无文档登记)**:队列满时**除广播 `pending_full` 外**,服务端还会向**发送这条消息的那条连接**单独发一条定向错误帧 `{type:"error", data:{code:"chat_busy", message, sessionId, clientMsgId?, messageId?}}`——**只发发送者、不广播**(其它端按广播帧里的 `clientMsgId` 自行对齐即可)。`code` 与 REST 的 `409 chat_busy` **同码**(客户端**一个 code 覆盖两条入口**)。用途:WS 路径此前没有与 REST `409` 等价的回执,本地乐观气泡只能永远挂在「发送中…」;有了它就能精确回滚那一条。
- **上下文续接**:忙完重开时新回合**带上一轮上下文**(`{mode, queueDepth}` 之外的续接由服务端完成),非清空重来(design/15 §2.2)。
- **客户端侧(v0.54)**:`sessionBusy` / `pendingCount` 是**单槽全局**状态(不按会话分桶)——前端只在帧的 `sessionId === activeSessionId` 时置忙/置闲(`frontend/web/src/lib/ws/handlers/session.ts`,置忙与置闲两侧同一把尺子),与「消息按自己的 `sessionId` 入桶、非激活会话不丢弃」不同(见 §3.1)。这是 `design/16 §4.2` 已登记的**单槽债**(多会话并发时忙态徽标只反映屏幕上的会话)。
### 3.3 消息持久化增量事件(`message_persisted`,design/16)
- **触发**:每一条 user / assistant 消息**落库后**由编排层广播——Web 主会话路径走 `persistUserMessages`/`persistAssistantReply`,**IM 渠道主人私聊**(design/16 §4.4)由 channelbridge 把消息**落主会话 `web`**(带 `platform` 标记)后复用**同一** `message_persisted` 广播(同一 hub 的 `BroadcastPersisted`),保证 Web 端实时镜像 IM 消息。
- **载荷**:`{type:"message_persisted", sessionId, data:{message:{id, sessionId, role, content, mode?, mood?, image?, platform?, deviceId?, senderName?, personaId?, createdAt}}}`——`role` ∈ `user|assistant`;`id` 为**后端生成的消息 id**(客户端据此去重);`platform` 为**来源平台**(`web` 或渠道如 `telegram`/`weixin-ilink`/`qq-obv11`);`deviceId` 为**具体端/客户端标识**(design/16 §4.5,如 `web:chrome:a1b2c3` / `weixin-ilink:conf0:wx_uid`);`senderName` 为**第三方发送者昵称**(design/16 §4.6,群聊/陌生人消息带,主人私聊为空,`omitempty`);**`personaId`(v0.42,`omitempty`)=该消息说话时的角色 id**(design/22 §7-5;空 = 全局默认角色,即升级前的全部历史),客户端据此按角色渲染名字/头像;**`clientMsgId`(v0.56,`omitempty`,G4)= 客户端自带的幂等键**(空 = 该消息没有幂等键:老客户端、IM 镜像、历史行),客户端据此把本地乐观气泡对齐到服务端真相;`thinking` 已剥离(design/16 §6)。**`documents?`(v0.59 起;v0.63 补 `reason`)**:载荷里的消息与 `historyMessage` **同结构**(同一结构体),因此带附件的回合会带 `documents` 数组(`[{name,mime,size,chars?,truncated?,status?,reason?}]`,0 个附件时省略该键)。⚠️ **时点**:本帧在消息**落库那一刻**发出(用户消息早于该回合的抽取),因此帧里的 `status`/`reason` **可能还没有结论**;抽取结论由回合结束后的回写补进库里,**以读取面(`GET /sessions/{id}/messages`、握手回放)为准**(有附件的回合请按读接口渲染「读到了 / 读不到 + 原因」)。
- **注:群聊/陌生人(非主人)消息**只在**该 IM 适配器日志界面**以消息流显示,**不**经 `message_persisted` 进 Web 主会话(design/16 §4.6 §3-4);该日志流由 `GET /api/v1/admin/channels/{platform}/messages` 拉取,落库后另经 `channel_persisted`(§3.4)实时推送。
- **客户端处理**:把帧按 `data.message.sessionId`(缺省时用帧壳 `sessionId`)写进**该会话自己的消息桶**(**v0.54 起:非激活会话的消息不再被丢弃**,切过去即可见,见 §3.1);按 `id` upsert——本端已预建的**同 role 乐观气泡**(本地 `uid` 无后端 `id`)被**归一**(`uid` → 后端 `id`),异端无本地气泡则直接追加,两者最终收敛为**同一条 `id` 相同的消息**,「A 发消息、A 与 B 都无需刷新且各出现一次」。
### 3.4 适配器消息实时事件(`channel_persisted`,design/16)
- **触发**:每一条**群聊/陌生人(非主人 / 隔离 sess-xxx)消息**落库后由 channelbridge `persistIsolated` 广播;该消息**只写自己的隔离会话**,**绝不**写主会话 `web`,`channel_persisted` 仅是事件通知,不是把内容塞进主会话/Web 上下文(design/16 §4.6 §3-4)。
- **载荷**:`{type:"channel_persisted", sessionId, data:{message:{id, sessionId, role, content, mode?, platform?, deviceId?, adapterId?, senderName?, createdAt}}}`——与 `historyMessage` wire 一致;`adapterId` 为**产生该消息的适配器注册名**(如 `kook:3bef...`,`adapter != ''`);`sessionId` 为隔离会话 id(`sess-xxx`,**非** `web`);`role` ∈ `user|assistant`;`senderName` 为第三方昵称;**`clientMsgId`(v0.56,`omitempty`,G4)= 客户端自带的幂等键**(空 = 无幂等键,如渠道入站消息)。
- **事件类型分流**(`BroadcastPersisted` 按 `sessionId` 分流):`sessionId == "web"` 或其 `web:<uuid>` 前缀 → **`message_persisted`**(Web 聊天实时同步);**其它**(隔离 `sess-xxx`)→ **`channel_persisted`**(适配器日志专用)。
- **客户端处理**:Web 聊天**无** `channel_persisted` handler,天然忽略、不进会话;**IM 适配器日志页**订阅该事件,按 `adapterId === 当前适配器`(或总览 `adapterId != ''`)过滤后**仅追加**(按 `id` 去重、不整页重载),首次拉取仍由 `GET /api/v1/admin/channels/messages` + 轮询兜底。
### 3.5 实时全链路流程事件(`graph_update`,design/19)
> **角色门(v0.48,design/11 §7.1)**:`graph_update` 属「系统内部」帧 → **仅 `owner` + `admin`**(`viewer` 收不到,与本节「仅主人 Web 会话发布」及 design/19 §1「flow 可视化仅 owner/admin」一致)。REST 侧可见性不变。
- **触发**:每次会话轮次中,loop 的每个 trace span **开始/结束时**由 `loop.FlowPublisher` 经 bus `ws.graph_update` 广播增量 **FlowGraph**。**发布范围为三类会话(v0.50 口径,裁决 01.4.5)**:`web`(Web 主会话) / `web:<uuid>`(Web 子会话,含 IM 主人私聊镜像落主会话的形态) / `sess-backthink`(后台思考的固定内部会话,design/20 §2 的「思考链路图」由此取数)——**其余隔离会话(IM 群聊/陌生人的 `sess-xxx`)与子代理轮次不发布**,防敏感信息泄向面板。管理员/主人 Web 端订阅后实时渲染「输入 → 思考(LLM) → 工具 → 输出」主干图。**tool 节点的 `status` 由细粒度 phase 驱动**(v0.50,design/04 §1.1 + design/19 §1):工具执行期间为 `running`,执行结束按成败翻 `done` / `error`——与 §3.1 `tool_execution.*` 帧的 `phase` 同一套枚举、同一处决策。
- **载荷**:`{type:"graph_update", data:{id, nodes:[{id,label,kind?,status?,startedAt?,endedAt?,props?,log?}], edges:[{from,to}], sessionId, startedAt, endedAt}}`。
- **子代理分支节点的 `props.persona`(v0.47,design/22 §8-2)**:`subagent_spawn` 指定了目标角色派发时,该子代理在父会话图里的**分支根节点** `props` 会多一个 `persona` 键(值 = 目标角色 id),标注它**正在扮演的角色**;**不指定角色派发时该键不出现**(与旧版逐字一致),客户端按未知键忽略即可。
- `id` 为 `sessionId-round`(每轮一图);`sessionId` 为会话 id;`startedAt/endedAt` 为 RFC3339。
- `kind` ∈ `input|llm|tool|subagent|memory|guardrail|orchestration|output`。
- `status` ∈ `idle|running|done|error`(`running` = span 在跑 / `done` = 成功 / `error` = 失败)。
- `props` 仅含白名单摘要(如 `stop_reason` / `error_kind` / `result_summary`),**工具原始参数与密钥绝不前送**;长文本截断到 ~80 字。
- **节点 `props` 的可选键(v0.52,裁决 11.4.2 / 11.4.3)**——旁路节点(`memory` / `guardrail` / `orchestration`)的 span 现已**真实产生**,其 `props` 新增下列键。**全部为纯增量、可选**;值在**发布前已脱敏**(凭据形状 → `[redacted]`、超长截断);**帧名、事件名、顶层字段集一字未动**:
- **tool 节点**:`args_keys: string[]`(该次调用的**全部参数名**,字典序,**值一律不出现**)、`args_summary: {[key]: string}`(**仅放行表内、且非密钥类**的键才给值——密钥类键由 `trace.IsSecretKey` 判定(凭据形状 / `*_key` / `*token*` 等),**连值都不给**;单值 ≤**80 rune**、最多 **24** 条,表外键与超出条数的键连条目都没有,客户端按 `args_keys` 逐键列表、未放行的显示「值未展示」)。
- **guardrail 节点**:`action`(`guardrail.input` | `guardrail.output`)、`allow: bool`、`elapsed_ms`;检查本身报错时另带 `error`。
- **memory 节点**:`action`(`recall` | `write`)、`scope`(`owner` | `restricted`)、`hits` / `bytes` / `count` / `failed`、`elapsed_ms`——**只记元数据,不含任何记忆正文**。
- **orchestration 节点**:`subagent`(被派发的子代理 id)、`persona`(指定目标角色时才有)、`elapsed_ms`。
- **input 节点(v0.57,裁决 13.4.5 目标态)**:`steered: bool`——本回合在**回合内边界**吃过新到达的用户消息(steer)时出现;**纯增量、可选**,老客户端忽略即可(标记只并入既有 input 节点,不新建节点、不改标签,图仍只有一条主干)。
- `log` 为该节点累积的 span 事件 `[{time,type,props?}]`(设计 §2「节点实时日志」)。
- **客户端处理**:`graph_update` → `setFlowGraph(parseGraph(data))`(`src/lib/wsParse.ts`)——**图按 `data.sessionId` 落进该会话的「图桶」**(v0.54,裁决 16.4.2;前端 `frontend/web/src/store/sessionCache.ts` 的 `putGraph` / `currentGraph`,桶上限 12 张、超出时丢最久未更新的一张)。管理端「全链路流程」页(`admin/modules/Flow.tsx`)与 Agents 页取**激活会话**的图(该会话还没有图时回落主会话最近一张);**Backthink 页**(`admin/modules/Backthink.tsx`)取 `sess-backthink` 的图——后台思考的图**不再顶掉**管理员看的「当前会话」图。节点可点开看参数/结果/实时日志;无图时诚实显示「暂无流程」。
### 3.6 主动推送事件(`proactive_message`,design/12)
- **触发**:后台思考(backthink)自主决定「此刻主动发一条」时,经 `SendProactive` 投递给**当前所有在线 owner/admin 连接**(viewer / 未登录连接**永不**投递)。`SendProactive` 返回实际送达的连接数——**0 时调用方回退 IM 桥**(`im_bridge`),把该消息经渠道主动推送发给你(KOOK/微信等已接)。
- **载荷**:`{type:"proactive_message", data:{platform?, channelId?, channelType?, content, contentType?, sender?, timestamp?, messageId?}}`(由 `adapter.UnifiedMessage` 脱敏投影:**不含 RawData / 媒体字节 / token**,绝不让凭据漏到前端)。
- **客户端处理**:仅 owner/admin 收到;渲染成一条「她从某平台主动发来」的气泡(内容 + platform + sender + timestamp)。**与 `message_persisted` 的区别**:`message_persisted` 是你与她的对话回显;`proactive_message` 是**她未经你输入**主动推的消息(如 IoT 完成事件、后台思考结论),两路不同、不要混用去重。
- **红线**:owner-only;payload 已脱敏;backthink 每次主动推送**只发一个决策平台**(决策渠道→最近活跃→旋转回退),Web 端与 IM 桥为两条投递路,客户端不要据此断言「所有平台都发了」。
- **推送出口(D1,对客户端无新端点)**:在 WS/IM 投递之外,后端会把主动消息**异步镜像**到「推送出口」——主通道是**主人自建统一推送服务**(`PUSH_EXPORTER_URL` webhook,HTTP POST JSON `{source,title,content,platform,timestamp}`,可带 `Authorization: Bearer <PUSH_EXPORTER_TOKEN>`),回退是**个推 Getui**(字段预留、SDK 未接入)。该出口只影响**出站投递**,不改变 WS 事件契约;未配/不可达时只记日志(注明「个推回退待接入」),**不影响**上面的 WS/IM 投递。移动端接入自建推送服务即可收到主人主动消息。
### 3.7 情绪事件(`mood`,design/01 §2 情绪状态机 + 好感度)
> **角色门(v0.48,design/11 §7.1)**:`mood` 属「私有状态」帧 → **仅 `owner` + `admin`**,`viewer` 不再收到它;但角色当前情绪**仍可读**——`viewer` 照旧调 `GET /personas` 拿到该角色的 `mood` 字段(REST 只读档不变,见 §1)。
- **触发**:每轮对话结束(loop 回合边界,每轮一次、**非**每步)把该轮用户消息的情绪信号应用到**该角色自己的**情绪状态机(按 persona 隔离),且**状态真的变化时**才广播一次 `mood`;状态不变(含「一直 calm」)**不发**,避免每轮噪声。
- **载荷**:`{type:"mood", data:{mood, personaId, affection}}`——`mood` ∈ `calm|happy|excited|sad|angry`(情绪状态机 state id,与消息体 `mood` 同源同值);`personaId` 为该角色 id(情绪/好感度跟随角色,不跟随行为模式);`affection` 为该角色当前好感度(受角色配置 `affection.min/max` 约束)。角色级数值,**不带** `sessionId`。
- **与消息体 `mood` 的关系**:assistant 消息体的 `mood` 是该轮结束后的**当前情绪**(每轮都写,见 §3.3),而 `mood` 事件只在**变化时**发——前者答「这条回复时她什么心情」,后者答「她的心情刚刚变了」。
- **群聊/陌生人(`MemWritable=false`)**:情绪**只读不写**——第三方消息**不**改变角色情绪与好感度、**不**发 `mood` 事件、该轮 assistant 消息 `mood` 为空(与记忆只读同一隔离边界,design/02 §3 / design/11 §2)。
- **状态转移与衰减**:按角色配置的转移表(缺省内置表)与 `emotion.decayRate`——有情绪信号的轮次**沿合法转移走一步**指向对应状态;无信号轮次**衰减一步**趋向 `calm`;`decayRate ≤ 0` 关闭衰减。好感度按角色 `affection.delta` 随情绪轮次增减(warm `+delta` / cold `−delta`,clamped 到 `[min,max]`)。
- **客户端处理**:`mood` → `setMood(data.mood)`(`src/lib/ws.ts`),顶栏/侧栏显示「心情 · <mood>」;消息体内 `mood` 由聊天气泡显示(`chat.tsx`)。
- **状态持久性**:情绪与好感度为**进程内、按角色**状态,**重启回到角色配置的 `initialState`**(design/01 未要求落库;如需跨重启保留,待主人确认后加表)。
### 3.8 消息编辑 / 删除实时事件(`message_edited` / `message_deleted`,D3 实时同步)
- **触发**:`PATCH /api/v1/messages/{id}`(改 content)或 `DELETE /api/v1/messages/{id}` **成功写库后**由网关广播(与 `message_persisted` 同一条 hub 广播路径,**全连接广播 + 客户端按会话过滤**——hub 无按会话订阅;消息体的唯一真源始终在后端,这两个事件只驱动渲染同步,不携带新真源)。
- **`message_edited`**:`{type:"message_edited", sessionId, data:{id, content, mode?, mood?, createdAt?, ...}}`——`data` 为**编辑后的完整消息 DTO**(与 §3.3 的 `message.message` 同形,至少含 `id`/`content`/`sessionId`/`createdAt`,另带 `role`/`mode?`/`mood?`/`image?`/`platform?`/`deviceId?`);`thinking` 已剥离(与 `message_persisted` 一致)。**只改 `content`**,`role`/时间戳/平台等保持原值。
- **`message_deleted`**:`{type:"message_deleted", sessionId, data:{id}}`——只带被删除消息的 `id`(`sessionId` 为该消息所属会话,由后端在删除前读取后填入;客户端按它过滤,**不**据此删除别的会话的行)。
- **客户端处理**:与 `message_persisted` **同一路由规则**——按帧的 `sessionId` 写进**该会话自己的消息桶**(**v0.54 起:不再按激活会话丢弃**)(`message_deleted` 的 `sessionId` 缺失时按 `id` 兜底:消息 `id` 全局唯一,逐桶搜索、只会命中它自己那条)。`message_edited` → **按 `id` 原位更新该条的 `content`**(不新增行:找不到该 `id` 即忽略,避免把别的会话的消息注入当前列表);`message_deleted` → **按 `id` 从列表移除**。两个操作都**不触发整页重拉**——这正是它相对「重拉绕行」的收益。
- **失败不广播**:`404`(消息不存在)/ `400`(内容非法)/ `501`(未注入 HistoryStore)**不**产生任何事件,客户端不会因一次失败请求而丢掉本地消息。
### 3.9 已读游标事件(`session_read`,D4)
- **触发**:`POST /api/v1/sessions/{id}/read` **成功推进游标后**由网关广播(与 `message_persisted` 同一条 hub 广播路径:**全连接广播 + 客户端按 `sessionId` 过滤**,hub 无按会话订阅)。用途是**同账号多端清红点**:一端读了,其它端不用重拉列表就把该会话的未读清零。
- **载荷**:`{type:"session_read", sessionId, data:{readAt}}`——`readAt` 为该会话推进后的**已读游标**(毫秒 Unix 时间戳,与该会话消息的 `createdAt` 同单位)。
- **客户端处理**:仅当 `sessionId === activeSessionId`(或该会话在自己维护的会话列表里)时处理 → 把该会话 `unreadCount` 置 0(更精确的做法是按 `readAt` 重算本地未读;本地没有更完整的数据时直接清零也不会错,因为 `readAt` 只前进)。**它只驱动渲染**:唯一真源仍是后端 `chat_sessions.read_at`,漏收该帧的端在下一次 `GET /api/v1/sessions` 自然收敛(不需要补发、不需要重连)。
- **失败不广播**:`400`(`before` 非法)/ `404`(会话不存在)/ `501`(未注入 HistoryStore)**不**产生事件,其它端不会误清红点。
### 3.10 后台子代理结果事件(`subagent_result`,design/05 §9)
> **角色门(v0.48 下行门的默认档,design/11 §7.1)**:`subagent_result` 属**内容帧**(含子代理的结论正文)→ **仅 `owner` + `admin`**,`viewer` 收不到;它**不在**那 7 个状态机信号白名单里(要进白名单须先在 design/11 §7.1 登记「不含内容」的理由)。
- **触发**:`subagent_spawn` 派发的批次**转后台后**(前台只等 2 秒,design/04 §8.5)批次结算时,编排层把合并结果发布到 bus 主题 `subagent.result`,经 `ws_bridge` 中继成这一帧。**在 2 秒窗口内跑完的批次不发此帧**——它的结果已作为工具结果回给了模型(与本节同一份投影)。
- **载荷**:`{type:"subagent_result", sessionId, data:{sessionId, status:"done", summary, agents:[{id, goal, role?, mode?, status, summary?, text?, steps?}]}}`
- **帧壳 `sessionId` = 派生该批次的父会话**(`data.sessionId` 同值)——客户端据此把结果归到那个会话的消息流,多会话并发时不串话。派发回合没有会话归属时(如循环外派发)两处都省略(该帧即成无归属帧)。
- `data.status` 恒为 `"done"`(帧只在批次结算后发出,无论批次里有没有失败/中止的子代理——逐代理结论看 `agents[].status`);`summary` 是「子代理汇总: 共 N 个, M 成功…」的一行汇总。
- `agents[]` 与派发工具返回给模型的结果**同形同值**:`id`(同时也是 `subagent_event` 帧与流程图泳道节点的 id)、`goal`、`role` / `mode`(绑定的角色/行为模式,未指定时省略)、`status` ∈ `done|failed|aborted`、`summary`(该子代理的执行总结)、`text`(结论正文,**截断到 4000 字**)、`steps`(循环步数)。
- **与 `subagent_event` 的分工**:`subagent_event` 是**逐代理进度**(派发/开始/完成/失败/中止,`{id,name,status,role,mode}`,**不含结论**);`subagent_result` 是**整批的最终结果**(含每个子代理的总结与结论文本),一个批次只发一次。
- **客户端处理(现状)**:后端已按上述形状推送;**现有 Web 前端尚未消费该帧**(无 handler,按未知帧忽略),因此这一版的可见效果是「帧到达 `owner`/`admin` 连接」,而**不是**「聊天气泡里出现一条后台结果」。要渲染成「后台任务完成」提示,按 `sessionId` 过滤后追加即可(读 `data.summary` 与 `data.agents[].summary`/`text`);**不要**把它写进本地消息列表当持久消息——该结果**不落库**,后端转录里没有对应行,重连回放不会补发。
- **边界(诚实说明,v0.51)**:本帧只做「**会话作用域的实时推送**」——**不**在父会话的持久化转录中新增消息,也**未**经 design/15 的会话消息编排器排队/续接;所以 design/05 §9 的「结果回填主会话」目前停在**实时可见**这一层(此前该结果在 `ws_bridge` 处被静默丢弃,前台只剩进度帧)。
### 3.11 主代理代审小字事件(`approval_notice`,design/11 §4)
> **角色门**:内容帧(含角色名/动作/文案)→ 仅 `owner` + `admin`;未进 7 个状态机信号白名单(fail-closed 默认)。
- **触发**:loop 的工具准入门判定某次调用属敏感操作(design/11 §4 清单,当前实现的三个操作类别:执行命令 / 修改文件 / 网络写方法——`GET`/`HEAD`/`OPTIONS` 与读文件、检索属非敏感)并经主代理代审后广播。非敏感调用**不**产生该帧。
- **载荷**:`{type:"approval_notice", sessionId, data:{actor, tool, op, detail, explain, allowed, at}}`
- `sessionId` = 该次 Run 所属会话(子代理的敏感调用归属到**派生它的父会话**,与 §3.10 同口径)。
- `explain` = 固定文案 `「<actor> 想要干什么,我审查后批准了」` / `「<actor> 想要干什么,我审查后拒绝了」`(**由后端产出**,客户端**原样渲染**,不要自拼)。
- `detail` = 动作摘要(工具 + 关键参数,**目标部分截断到 80 rune**);`allowed=false` 表示该敏感调用**被拦截未执行**(无代审者/代审报错也走 fail-closed 拦截,小字同样是「拒绝了」)。
- `op` ∈ `执行命令|修改文件|网络访问`;`actor` = 想动手的角色名;`at` = RFC3339Nano。
- **审计**:每次代审在 `audit_logs` 落一行:`who`=角色名、`what`=`approval.<工具名>`、`result`=`granted|denied`;查询走既有 `GET /api/v1/admin/observability/audit` 与 `/admin/notifications`。
- **客户端处理**:与流式帧同一会话过滤规则(`sessionId === activeSessionId`),把 `explain` 作为聊天流里的一行小字渲染、`detail` 作副文本(现 Web 前端已按此实现,见 `frontend/web/src/components/status.tsx` 的 `ApprovalNoticeHint`)。
- **约束**:不新增端点、不改既有帧;老客户端按未知帧忽略即可。
### 3.12 可观测实时推送(`observability_log` / `observability_trace` / `observability_metrics`,v0.55)
> **角色门**:三个帧**都不在** `frameReachesAll` 白名单 ⇒ fail-closed **只发 `owner` + `admin`**;`viewer` 收不到,继续走既有 12 条只读监控 REST 档(v0.40)。未进 7 个状态机信号白名单(要进须先在 design/11 §7.1 登记「不含内容」的理由——它们是**运行时数据面**,不属于状态信号)。
- **取代关系**:可观测面(日志 / 指标 / trace)从「面板 1s 轮询 REST」改为 **WS 帧推送**(裁决 05.4.3 + 16.4.3-WS,design/10 §5)。**既有 REST 端点完全不变**(路径 / RBAC / `level` / `limit` 语义 / 响应形状一字未动),仍然可用,并在被读取时兼任「订阅握手」。
- **订阅(推送开启条件)**:owner/admin 凭据**读取** `GET /api/v1/admin/observability/{logs,metrics,trace}` **任一个**即建立订阅(绑定**该次登录凭据**,键 = sub+role+iat)。该凭据的 WS 连接**全部断开**即退订;订阅表空后推送循环自行退出(**无常驻 goroutine、无周期性开销**)。**这不是轮询**:那次读取本身照常返回完整引导数据,此后吃推送。
- **节拍**:≤1s 一拍,**无新数据不发帧**(空转时一帧都不发)。
- **三个下行新帧**(**纯新增**,既有帧名/形状零改动;帧壳 `{type,data}`,**不带** `sessionId` / `turnId` / `seq`——它们不属任何会话):
| 帧名 | 载荷 `data` | 上限 | 触发 |
|---|---|---|---|
| `observability_log` | `{entries:[{time,level,source,msg}…]}`(**最新在前**,与 `GET /admin/observability/logs` 同形) | 200 条 | 订阅后**新增**日志;一拍最多一帧,无新条目不发 |
| `observability_trace` | `{spans:[…]}`(**最新在前**,与 `GET /admin/observability/trace` 同形) | 100 条 | 订阅后**新完成**的 span |
| `observability_metrics` | `AdminMetrics` **本体(扁平)**,与 REST 同形:`totalRequests,qps,avgLatencyMs,maxLatencyMs,perMethod,perPath,statusCounts,llmCalls,subagentEvents` | — | 与**上次发出的**不同才发(序列化指纹比较,键排序确定) |
- **初始锚点**:订阅建立时以「当前最新」为锚(日志取最新条目时间、trace 取当前写入序号、指标取当前快照指纹),因此循环**不会**把环形缓冲里的历史整段灌成巨型帧——历史由面板那次引导读取提供。
- **单帧上限的语义**:一拍的突发超过上限时只发**最新**的那些(更旧的条目本就落在面板保留窗口之外,与 REST 尾部 `limit` 上限同口径),不丢「看得见」的数据。
- **分级过滤改在客户端本地做**:这三个帧经 bus 广播给**所有 `owner`/`admin` 连接**(hub 无按连接订阅、也无按连接分级——订阅只决定「推送循环跑不跑」),因此 `?level=` 那种服务端过滤不适用于推送;面板按本地等级选择过滤即可。
- **前端落点**:`frontend/web/src/lib/ws.ts` 的 `subscribeObservabilityFrames()`(帧**不进应用 store**,只有打开着面板的视图在听,其余时候安静丢弃);面板挂载时各读一次对应 REST 作为引导(logs 用 `limit=200`),推送帧**按业务键去重合并、倒序、窗口 200**(`frontend/web/src/admin/modules/observabilityStream.ts`)。
- **向后兼容**:纯新增帧,老客户端按未知帧忽略即可;既有 REST 与其它帧/端点/鉴权一律不变。
### 3.13 客户端幂等回执(`chat_duplicate`,v0.56 / 协同 G4)
> **角色门**:`chat_duplicate` **不在** `frameReachesAll` 的 7 个状态机信号白名单里 ⇒ fail-closed **只发 `owner` + `admin`**(`viewer` 收不到)。它由编排器的状态通道(与 `session_busy` 等同一出口)下发,因此**帧壳带 `sessionId`,没有 `turnId` / `seq`**。
- **触发**:`chat` 上行(REST `POST /api/v1/chat` 或 WS `chat` 帧)带了**非空** `clientMsgId`,而同一会话内**已经认领过同一个值**——即弱网 / 切后台下的客户端超时重发。判重由 `(session_id, client_msg_id)` 的**部分唯一索引**原子裁决(只覆盖非空键),因此并发重发只有一个赢家,不存在「先查再插」的竞态窗口。
- **载荷**:`{type:"chat_duplicate", sessionId, data:{sessionId, clientMsgId, messageId}}`——`messageId` = **既有那条用户消息的 id**(客户端据此把本地乐观气泡对齐到服务端真相,替代「靠文本相等」的脆弱匹配)。它是**广播给所有 `owner` / `admin` 连接**的状态信号(hub 无按会话订阅),没发过该 `clientMsgId` 的客户端按值自行忽略即可。
- **不做什么(红线)**:重复到达**不落库、不起回合、不发任何内容 / 流式新帧**——第二次绝不产生第二条用户消息、第二个模型调用。
- **REST 侧同义**:`POST /api/v1/chat` 的**同步**与 **`stream:true`** 两条路径重复到达都回 `200 {data:{duplicate:true,messageId,sessionId}}`(见 §1 行 3)。
- **回填与可见性**:`clientMsgId` 随消息落库并**回填进消息 DTO** ⇒ `message_persisted`(§3.3)/ `channel_persisted`(§3.4)/ 握手 `history` 回放 / `GET /sessions/{id}/messages`(§1 行 6)都能看到(**空则省略**),客户端可在重连后从读接口收敛本地乐观消息。
- **向后兼容**:上行 `clientMsgId` 是**可选**字段——**省略时行为与加入该字段之前逐字相同**(老客户端零影响,历史行该键为空);`chat_duplicate` 为**纯新增帧**,老客户端按未知帧忽略即可。
- **迁移**:新增 `0013_chat_client_msg_id`(up:`ALTER TABLE chat_messages ADD COLUMN client_msg_id TEXT NOT NULL DEFAULT ''` + 部分唯一索引 `idx_chat_messages_client_msg_id ON chat_messages (session_id, client_msg_id) WHERE client_msg_id != ''`;down:**先删索引再删列**——SQLite 的 `DROP COLUMN` 在列仍被索引引用时会失败;全新库由 `history.NewStore` 幂等前滚同一列与索引)。
### 3.14 回合内 steer 的消费回执(`steer_ack`,v0.57 / 裁决 13.4.5 目标态)
> **角色门**:`steer_ack` **不在** `frameReachesAll` 的 7 个状态机信号白名单里 ⇒ fail-closed **只发 `owner` + `admin`**(`viewer` 收不到)——它**携带具体消息身份**(客户端据此定位到某一条气泡),而不只是会话忙闲。它由编排器的状态通道(与 `session_busy` 等同一出口)**经 hub 直接广播,不经 bus** 下发,因此**帧壳带 `sessionId`,没有 `turnId` / `seq`**。
- **触发**:一个**忙时会话**收到了新用户消息(已入该会话 pending、且**已落库**),在飞回合在**回合内边界**把这条消息**吃进同一回合**的那一刻。**没有可注入消息的边界一帧都不发**。
- **载荷**:`{type:"steer_ack", sessionId, data:{messageId, atStep}}`——帧壳带 `sessionId`(`data` 内**不重复**该键)。
- `messageId` = **那条用户消息的权威 id**(后端生成,与 `message_persisted` 的 `data.message.id` 同源),客户端据此把本地乐观气泡对齐到服务端真相,**不要靠文本相等**这类脆弱匹配。
- `atStep` = **吃进它的模型步序号,从 `1` 开始(1 基)**:被注入的消息出现在**这一模型步**的可见视图里。
- **一批多帧**:同一批次吃进 N 条消息就发 N 帧(每帧一条消息的 `messageId`),**不合并**——FIFO 保持(design/15 §2.1)。
- **语义(客户端怎么渲染)**:该消息**已被在飞回合吃进**(已从待处理队列移除),**但尚未被正面回答** ⇒ 客户端应把对应气泡渲染为「**已读、未答**」的**灰态**,直到该回合的 `message.*` / `turn.end`(§3.1)到达;**不需要第二个确认帧**。它与「忙『稍后回』」的 pending 气泡**不是同一个态**:灰态表示**这条已经在被处理的那一轮里了**。
- **触发时机**:只在**回合内边界**产生——loop **每一步开头**(上一次模型步与它那一批工具调用**都已结束**、下一次模型调用**尚未开始**,因此此刻没有工具在飞,注入**不可能取消**任何已发起的工具)回调转向源;**只有该边界真的匹配到 pending 里、且已落库的用户消息时才发帧**(无新消息则一帧不发,这是常态),**不是每个 step 都发**。
- **与 `pending_ack` / `session_busy` 的关系**:后两者描述**队列深度与忙闲**(「收到,稍后回」「她正忙」);`steer_ack` 描述**队列里哪一条已被本回合吃进**(精确到 `messageId`)。客户端可用「pending 条数 − 已收 `steer_ack` 条数」表达 design/15 §5 的「队列里有 N 条、其中 M 条已被本回合吃进」(忙态本身仍是**单槽**,见 §3.2)。
- **不发的四种情形(失败一律让路)**:未接转录存储 / 读该会话转录失败 / 该消息**尚未落库** / 本回合世代已被打断或被更新的回合取代(认领 `pending` 失败)——一律**不发帧、也不注入**,该消息**留在 pending**,照旧作为自己的回合被回答,**绝不静默消失**。**未接转向源的运行**(子代理、后台思考、未接网关的循环)**不产生该帧**。
- **只吃本编排器排队过的消息**:候选必须在**该会话转录的最近 20 条窗口内**(每次边界重读转录取权威 `id`),且**必须在本编排器的 pending 里**——由别的写入方(如 IM 适配器自有路径)落库、本编排器从未排队的消息**保持不注入**(那条路径正在自己作答它,注入会让同一条被答两次);超出窗口的 pending 消息同样留队、由各自的回合作答(**不丢**)。
- **兼容性**:**纯新增帧**,老客户端按未知帧忽略即可;既有帧 / 端点 / 鉴权 / REST / `.env` 一律不变。**多路下的已知缺口(v0.65 诚实边界)**:本帧的**帧壳仍不带 `turnId`**(v0.57 起如此,v0.65 的并行基础管线**未**接线它)——真并行时同一会话有两条交错的路,客户端**无法从本帧判断那条消息被「哪一路」吃进**;按 `turnId` 两级分桶是后续切片的事(见 §5 v0.65 条目 J 段)。
- **客户端现状(诚实边界)**:**Web 前端目前尚未消费该帧**(`frontend/web/src` 内无 `steer_ack` handler),故今天的可见效果是「帧到达 `owner` / `admin` 连接」;要落成灰态,按本节口径加 handler 即可(`messageId` 定位气泡、`message.*` / `turn.end` 收灰)。
### 3.15 语音回合(`voice_input` / `voice_output`,v0.59:进编排 + 归属 + 降级信号)
> **行为变更(对语音客户端是实质变化)**:语音回合**不再「转写完直连 loop 就跑完」**——它进**会话编排器**,与文本回合**走同一条路**。因此语音问过的事**进历史**、**跨端可见**、**参与忙/排队**。帧形状的变化只有两处(§3.1 已改):`voice_input` 增可选 `clientMsgId`、`voice_output` 增 `sessionId`(恒有)/ `deviceId`(非空才有)。
- **同一条路(v0.59)**:一次 `voice_input` 在 STT 之后产出的用户消息 = **转写文本**,经编排器提交:
- **落库**:用户消息(转写文本)与助手回复都进 `chat_messages` ⇒ `GET /sessions/{id}/messages` 拉得到、握手回放补得到;
- **实时帧**:照发 `message_persisted`、流式 `message.*` / `thinking.*` / `tool_call.*` / `turn.start` / `turn.end`、状态帧等(与文本回合零差别);
- **跨端同步**:另一端连着 WS 也能看到这一轮(按各自 `sessionId` 入桶);
- **忙时排队**:目标会话正忙 ⇒ 该语音消息**按编排器既有规则入 pending**——`pending_ack` / `pending_drain` / `pending_full` / `session_interrupted` 的**语义与形状零变化**,只是多了一个消费者;**排队满**同样回 §3.2 的广播 `pending_full` + **定向 `error{code:"chat_busy"}`**(带 `clientMsgId?`/`messageId?`)。
- `deviceId` 照旧落库(design/16 §4.5);`platform` 走存储默认 `web`。
- **幂等(v0.59)**:`voice_input` 增可选 `clientMsgId`(**与 `chat` 帧同形**)。重复到达 → 编排器回既有下行帧 `chat_duplicate`(§3.13),**不产生新回合、不发音频、不落库**。客户端重连后带**同一个** `clientMsgId` 重试是安全的。
- **`voice_output` 的时点(重要,别当成「发出去就立刻有音频回来」)**:
- 它在**该回合结算时**才到——可能**晚于若干状态帧**(`pending_ack` → `session_busy` → … → `session_idle`),也可能由**续跑回合**(忙完消费 pending 的那一轮)或**被 steer 注入的那一回合**(§3.14)产生;
- 忙时先收 `pending_ack`;`steer_ack` 可作「已读未答」的灰态(§3.14);
- 因此**不能用「发完 N 秒没音频」判定失败**,也不要用到达顺序推断回合归属——用帧里的 `sessionId` / `deviceId` 判归属。
- **定向目标与断线(诚实边界)**:
- `voice_output` **只发「发起这条 `voice_input` 的那条 WS 连接本身」**——服务端**不按 `deviceId` 路由**(`deviceId` 只是载荷里的归属标注),也**不会**发给同账号的其它端;
- **连接断开后不补投**:服务端的收尾回调发现该连接已关就**记一条带来源的 WARN 并丢弃**(音频是「回复已落库」之后的增强,绝不静默假成功);**助手回复本身已经落库**,所以重连后从历史 / 回放能拿到正文,只是拿不到那段音频;
- 重连后带同一 `clientMsgId` 重发**不会**重复起回合(幂等),但**也不会**补回那次音频(音频不落库)。
- **降级信号(与 §3.1 同一口径)**:`provider` 只在**没有真实音频**时出现(`"silent"` 或实际档位),覆盖两种情况——**空正文**(回合没产出文本,如被取消)与 **TTS 失败 / 降级为空音频**。客户端据此提示「没生成声音」,但**该帧仍照常发出**:客户端总能看到这条语音回合的终点,而不是永远等一个不会来的 `voice_output`。**绝不把 `stopReason`(`stop` / `idle`)当语音内容**。
- **未接编排器时**(未接线的构建 / 裸 hub 单元测试)保留旧直连路径:STT → 直接跑 loop → 立即合成并回音频。两条路径共用同一个帧构造,`voice_output` 契约一致。
- **引擎与降级链**:真 STT/TTS 走阿里百炼(STT `qwen-audio-3.0-asr-flash`、TTS `cosyvoice-v3-flash` + `longxiaochun_v3`);无凭据时 cloud → edge-tts/espeak(未实现)→ `silent` 空音频。
- **IM 渠道入站语音(v0.65,与上面这条 Web 语音回合是两条路径)**:IM 适配器收到的语音消息在**入站流水线最前端**被归一为文本(写回 `msg.Content`),因此**丢空判据 / 最小打扰判定 / 落库 / 模型可见消息看到的是同一份文本**——语音内容从此**进上下文**(此前纯语音消息会在丢空判据处被整条丢掉)。三条口径:① **平台自带转写优先**(微信 iLink 的 `voice_item.text` 非空 ⇒ **不调 ASR**,不重复付费);② **静默档位必须被识破**(语音链末端总是成功,只看文本会把「没有可用的识别后端」当成一次成功的空转写)⇒ 只认 `STTOutcome.Transcribed()`;③ **绝不静默、绝不留空消息**——转写不成功时落**一行中文诚实提示**(讲清没转写成功 + 原因方向:未配置 / 鉴权失败 / 欠费 / 限流 / 超时 / 静默降级 / 静默音频),**不含上游错误原文与密钥**。鉴权失败 / 欠费 / 限流按原因分类做**临时熔断 + 退避**(TTL 恢复;熔断打开时不再发起上游调用,提示里带上退避剩余时间)。**超时旋钮 `VOICE_STT_TIMEOUT_SEC`**(秒,默认 **20**;`≤0` 用默认)**只约束这一次 IM 入站转写**(要一直举着一个入站并发槽),**Web 客户端的 `voice_input` 不受它约束**。**无新端点 / 新帧**;客户端看到的是普通的用户消息(正文 = 转写文本或那行提示),在 IM 适配器日志 / 主人私聊镜像里与其它消息一致。
## 4. 客户端接入指引(Web / 移动 / 原生 / IM bridge 通用)
### 4.1 一次性接入流程(三步走)
1. **拿令牌**:`POST /api/v1/auth/login` `{username,password}` → `{data:{token,expiresAt,role,refreshToken}}`(JWT,默认 24h,`role` ∈ `owner|admin|viewer`;`refreshToken` 为 B2 不透明长期凭据,用于静默续期,未接线 refresh 存储时**省略该键**)。
2. **声明版本(可选/advisory)**:可带 `X-API-Version: 1` + `Authorization: Bearer <token>`;WS 握手用 `ws://<host>:<port>/ws/chat?token=<token>`(或 `Authorization: Bearer`)。**`X-API-Version` 是**advisory**惯例头——服务端当前不校验、不据此协商版本,带不带行为不变**;`viewer` 只能读会话/人格元数据;读写会话消息、管理写操作需 `owner`/`admin`(**WS 上 `viewer` 也只收 7 个状态机信号帧:既收不到 `history` 回放,也发不出任何上行指令**,见 §3 顶部两把角色门,v0.48 + v0.49)。
3. **带端标识**(可选但推荐):每条消息/请求可带 `deviceId`(见 §4.3),让她知道你从哪个端来。
### 4.2 会话与消息用法(聊天)
- **列出会话**:`GET /api/v1/sessions` → `[{id,type:"main"|"sub",title?,createdAt,pinned,unreadCount,lastMessage?,personaId?}]`。**排序:置顶会话(`pinned=true`)最前,其次是未置顶的主会话 `id="web"`**(她与你的主线,最大化继承上下文),再按创建时间升序;⚠️ **置顶的次级会话可以排在主会话之前**,别把列表第一项当主会话(按 `id=="web"` / `type=="main"` 定位);次级 `web:<uuid>` 由 `POST /api/v1/sessions` 新建(临时小事/专业问题,独立上下文回溯)。**`personaId`(v0.42)=该会话绑定的角色**;空/缺省 = **继承全局默认角色**,客户端渲染激活会话的名字/头像用它(后端唯一真源是 `chat_sessions.persona_id`,localStorage 之类只作缓存)。
- **新建时选角(v0.42,v0.46 收口)**:`POST /api/v1/sessions` body `{personaId?}` 在**新建时**就绑角色(省略/空 = 跟随全局默认角色)。**会话角色在创建时确定、之后不可变**(design/22 §8-1):**没有"会话内换角色"这回事**——`PATCH /api/v1/sessions/{id}` 一旦收到 `personaId` 键就回 `400`(见 §1 行 8),**要换角色请新开会话**。**记忆按角色隔离**:不同角色的会话各记各的,同一角色的多个会话互相可见(design/22 §3 口径 1);群聊/陌生人会话仍**只读**、且强制默认角色。
- **角色解析优先级(v0.44,design/22 §2 裁决规则 + §7-3/§7-4)**:一个回合到底以哪个角色答话,**自上而下**取第一个命中的:
1. **请求级覆盖** —— 仍**未实现**(design/22 §2 裁决规则 1,留待后续批次);
2. **会话绑定** `chat_sessions.persona_id`(`POST /sessions {personaId}` 在**创建**时写入;之后不可变,design/22 §8-1)—— 非空即赢;
3. **渠道绑定** —— 该 IM 账号的 `personaId`(`PUT /admin/channels/{platform}/config`,v0.44)。**仅**作用于该账号的**主人私聊**,且仅当该会话(=镜像目标主会话 `web`)**自身未绑角色**时;
4. **全局默认角色** `owner.yaml:defaultPersona` —— 兜底(`''` 始终 = 当前默认角色)。
**群聊 / 陌生人隔离会话强制默认角色**(§7-4,更保守):这两类会话**跳过** 2/3 两条渠道与主会话绑定,一律用默认角色,避免把第三方会话挪进某个角色的记忆域;记忆仍是**只读**。**Web 主会话与 IM 主人私聊共享同一角色**(§7-3):主会话绑 Y 时,同一主会话里 IM 镜像进来的主人私聊回合也以 Y 答话。
- **模型上下文里的「谁说的」(v0.44,design/22 §7-5)**:一回合的上下文由该会话**最近 20 条**持久化消息(`chat_messages`,含 `persona_id`)+ 本回合新消息组成(此前只带新消息本身,模型看不到会话前文);**每条 assistant 历史消息按其说话角色标注**,模型收到形如 `[发言归属] 以下发言来自「昔涟」(xilian),不是你说的。` 的独立 system 标注,以及一条声明当前角色的 `[会话角色] …` 段。**正文永不被改写**:标注只走 system 消息,用户正文与 assistant 正文一字不改(design/11 §10:不给用户正文冒充系统指令的机会)。**对客户端透明**(无新字段、无新端点)。
- **改名 / 置顶**:`PATCH /api/v1/sessions/{id}` body `{title?}`(≤80 rune,空白标题回 `400 "title must not be blank"`——**空串不能清空标题**/恢复自动命名)和/或 `{pinned?:bool}`(至少一个字段;都缺回 `400`)→ `{data:{id,title?,pinned?}}`(只回显变更字段)。**body 里出现 `personaId` 键一律 `400`**(会话角色不可变,v0.46 / design/22 §8-1)。
- **未读 / 已读游标(D4)**:列表项的 **`unreadCount`** 直接用于会话列表红点(恒返回整数,0 = 无未读)。客户端进入会话、或滚到底看到最新消息后调用 **`POST /api/v1/sessions/{id}/read`**:body `{"before":<毫秒>}`(如「本次已渲染的最新一条的 `createdAt`」),或**省略 body** = 标记到**现在**。后端**只前进**地推进该会话**唯一**游标(单 owner 账号:读一处 = 所有端已读),并广播 `session_read`(§3.9)——同账号其它端**免刷新**清红点。列表 → 读 → 再看列表即可自洽(漏收广播的端下次拉列表自然收敛)。
- **拉取激活会话历史**:`GET /api/v1/sessions/{id}/messages?limit=50&before=<ms>&platform=&q=`。
- **热分页**:`limit` 默认 50(上限 200),`before` = 当前最旧一条的 `createdAt`(毫秒),向上翻页。`platform` 可选(IM 适配器传本平台只拉本渠道;Web/通用客户端不传 = 全量共享主会话)。
- **搜索**:`q` 非空 = **会话内**内容搜索(倒序,仍支持 `before`/`limit` 分页);空 `q` = 普通分页;`q` 与 `after` 互斥。**不跨会话**。
- 返回**按时间倒序**,需 `reverse()` 成旧→新用于渲染。
- **编辑 / 删除单条消息**:`PATCH /api/v1/messages/{id}` `{content}`(≤4000 rune)→ `{data:<消息DTO>}`(裸 DTO,无 `message` 包裹);`DELETE /api/v1/messages/{id}` → `{data:{deleted:<id>}}`。不存在回 `404`。**重发**用「编辑后内容再 `POST /chat`」即可(无独立重发端点)。两者成功后都会广播(`message_edited` / `message_deleted`,§3.8),同账号其它端**免刷新**同步。
- **实时增量**:连接 WS 后,消息落库会广播 `message_persisted`(§3.3)——**用其中的 `id` 去重**,按 `sessionId` 写进该会话自己的桶(**v0.54 起非激活会话的帧不再丢弃**);新开/重连连接首帧即 `history` 回放(主会话最近 50 条)。**消息被编辑/删除**则分别广播 `message_edited` / `message_deleted`(§3.8)——同一 `sessionId` 路由规则,前者按 `id` 原位改 `content`、后者按 `id` 移除,都不需要重拉整页。
- **发消息**:`POST /api/v1/chat` `{text, deviceId?, image?, mode?, clientMsgId?, documents?, parallel?, stream?}`。`stream:true` **直达会话编排器**(v0.56/G2):受理成功后才回 `200 {stream:true}`(**已升为独立并行路** = `200 {stream:true,parallel:true,turnId}`,v0.65);会话正忙已续接排队 `202 {stream:true,queued:true,queueDepth}`、队列满 `409 chat_busy`、**未接 loop `500 chat_unavailable`**、仅未接编排器时的兜底派发失败 `503 chat_dispatch_failed`。非流式返回完整 `reply`(会话正忙时 `202 {queued:true,queueDepth}`、**已升为独立并行路** `202 {parallel:true,turnId}`(v0.65)、排队满 `409 chat_busy`,见 §3.2)。**忙时并行(v0.65)**:带 **`parallel:true`** 且会话已有在途回合、单会话额度未满 ⇒ 本条**独立一路立即起**(默认 2 路 / 硬上限 3 / `<2` 关闭真并行),REST 拿不到同步 reply、文本照旧经 WS 到达;**省略 = 逐字不变**,服务端**不做**启发式升级。`image` 解码后上限 **5 MiB**(超限 `400 image_too_large`,文案指路 `POST /api/v1/upload`);**WS `chat` 帧受 4096 字节读上限约束**(超限断连),故 WS 上发大图必须走「上传 → URL」。**文档附件(v0.59)**:先 `POST /api/v1/documents`(multipart `file`,上限 20 MiB)拿到 `name`,再在 `documents:[{"name":…}]` 里引用(**≤5 条、只给存储名**;超限 `400 too_many_documents`、名非法/不存在 `404 document_not_found`,契约见 §1.2)。`text`/`image`/`documents` **至少一项非空**(纯附件回合成立)。
- **幂等发送(v0.56/G4)**:给自己生成的每条消息带一个**非空** `clientMsgId`(如本地乐观气泡的本地 id),服务端在同一会话内只承认第一条:重复(超时重发 / 切后台重发)**不再落库、不再起回合**——WS 回 `chat_duplicate`(§3.13)、REST 回 `200 {data:{duplicate:true,messageId,sessionId}}`;该键也会**回填进消息 DTO**,供重连后对齐本地气泡。**省略它 = 老行为逐字不变**。
### 4.3 deviceId 与 platform 规范(design/16 §4.4-4.5)
- **`platform`**(来源平台):`web` / 渠道如 `telegram`、`weixin-ilink`、`qq-obv11`。Web 端恒 `web`;IM 渠道消息带各自平台。**同一平台多个端 → 相同 platform、不同 deviceId。**
- **`deviceId`**(具体端,客户端自己生成):Web 每个浏览器 `localStorage` 持久化 `web:<browser>:<随机短id>`(如 `web:chrome:a1b2c3`);IM 按 `<im平台名>:<本地配置唯一id>:<用户平台唯一uid>`;某 APP 可自定可读前缀。发送时带上,服务端落库消息,聊天气泡可标注来源。
- **`senderName`**(第三方昵称):只有群聊/陌生人(非主人)消息带,主人私聊恒空(`omitempty`)。**IM 适配器_log**:该平台非主人私聊在 `GET /api/v1/admin/channels/{platform}/messages` 以消息流显示。
### 4.4 常见错误码 / 边界
- `401` 未认证/令牌失效 → 重新登录。`403` 权限不足(viewer 读管理写/主会话删除)。
- `404` 资源不存在(消息/会话 id 未找到)或端点未实现;`501 not_implemented` 后端明确未提供(如知识库删除、新增用户)。**会话相关(v0.63)**:`GET /sessions/{id}/messages` 对**不存在/已删除**的会话回 `404 {"error":{"code":"session_not_found"}}`(此前是 `200 {"data":[]}`,客户端因此分不清「空会话」与「会话已不在」);`PATCH /sessions/{id}` 的未知会话仍是 `404 not_found`(**码不同**:前者点明是会话,后者是通用资源),`POST /sessions/{id}/read` 与 `PATCH /messages/{id}` 沿用各自既有码。
- `400` 请求体/参数非法(`limit`/`before` 越界、空 content)。`409 chat_busy` 排队满(**REST 响应体与 WS 定向 `error` 帧同码**,见 §3.2)。
- **文档相关(v0.59 起;v0.61 更新白名单与裁决口径)**:`415 unsupported_media_type`(**扩展名白名单(23 项,含 `.pdf`)与字节嗅探的成对裁决**任一不过——HTML/纯文本改名成 `.pdf`、或 PDF 改名成 `.txt` 都走它)、`413 document_too_large`(超 `CHAT_DOCUMENT_MAX_BYTES`,默认 20 MiB)、`404 document_not_found`(文档名非法/穿越/不存在;`chat.documents` 里引用不存在的名也是它)、`400 too_many_documents`(单回合 >5 条)。⚠️ **诚实说明**:design/24 §6.2 列的 `document_unreadable`(400) 目前**没有对应的 REST 错误码**——它只以 `document-read` 工具的错误形态存在(模型侧可见;客户端在 DTO 里看到的是该文档的 `documents[].status = "unreadable"`,**扫描件/图片型 PDF 就是它**,v0.61)。
- **红线**:身份判定永远来自后端 channel 层 actor 元数据 + 白名单,**客户端不要用提示词/文本判身份**;消息体的 **`thinking` 字段**在**实时帧**(`message_persisted` / `channel_persisted` / `message_edited`)与**握手回放**中**恒为空**(**有意的隐私与带宽默认**,不是漏发);实时思维链仍由 §3.1 的 `thinking.*` 流帧按 owner/admin 档下发;**历史消息的思维链**只有 `GET /sessions/{id}/messages`(owner/admin)能拿到,客户端不应依赖其它入口。
### 4.5 离线 / 缓存建议(原生端)
- PWA / 原生端缓存 `sessions`/`personas` 列表 + 最近一屏消息;重连先本地渲染再拉增量。
- 消息**真源在后端**,本地只做渲染缓存;用 `message_persisted.id` 去重,不要用本地自增 id。
- 语音:上行 `voice_input`(base64 音频或已转写 text,**v0.59 起可带 `clientMsgId` 幂等**),下行 `voice_output`(合成音频 base64 + text + **`sessionId`/`deviceId?`**,**定向回发起那条连接、回合结算时才到**,空正文/TTS 失败带 `provider` 降级信号);**语音回合与文本回合走同一条路**(落库 / 跨端 / 忙时排队),见 §3.15。
## 5. 变更记录
- `v0.65`(**忙时并行回合基础管线(I1′ 有条件放宽)+ 六条登记**,依据 design/15 §2.5(主人 2026-10-05 裁决 B,commit `89e13cf`),并一并登记 `679cee0`(IM 入站附件引用落库,安卓报的 P0)/ `59a1ac4`(`llm_usage.session_id` 真正落库)/ `a918819`(IM 各渠道语音可被读到)与 `cb564fe`(memory 空 query 不触发语义回退,纯内部)/ `15181e7`(Anthropic 并行工具结果合并为同一条 user 消息,修真机 400,纯内部)/ `7d2ff74`(telegram 语音内联回归测试,无契约),2026-10-05 落实)。**三档标注**:**纯增量** = ① 上行 `chat.parallel?:bool`(WS `chat` 帧 + REST 三入口**同形**)、② `session_busy` / `session_idle` / `pending_drain` / `session_interrupted` 的 `data` 增 `activeTurns:int`(均在既有白名单帧内)、③ `session_interrupted.data` 增 `cancelledTurns:int`、④ `pending_ack.data` 增**可选** `reason:"parallel_limit"`、⑤ REST 两条新应答形状(同步 `202 {data:{parallel:true,turnId}}` / `stream:true` 的 `200 {data:{stream:true,parallel:true,turnId}}`)、⑥ 新 env 键 `VOICE_STT_TIMEOUT_SEC`(默认 20s);**行为变更** = ⑦ IM 入站附件引用**真的写进落库的用户消息**(`documents` 列;**同一 DTO、无新字段**)、⑧ IM 各渠道语音进上下文(转写不成功落一行中文诚实提示,**绝不静默/空消息**)、⑨ **`session_idle` 时点收紧**为「所有在途路收尾且 pending 空」(**仅并行时生效**,单路逐字不变);**破坏性** = 无(**省略 `parallel` / 不读新键的客户端行为逐字不变**;`session_id` 有值属既有列,端点响应形状不变)。**A. `chat.parallel`(纯增量,省略 = 逐字不变)**——`POST /api/v1/chat` body 与 WS `chat` 帧 `data` 同形新增可选 `parallel:bool`(`omitempty`),语义 = 「把本条作为**独立一路**处理」;**只在会话已有在途回合、且单会话并发额度未满时照准**(额度:默认 **2**、硬上限 **3**、`<2` = 关闭真并行);**空闲时无效果**(照常起第 1 路);**服务端不做任何启发式自动升级**(触发权在客户端,design/15 §2.5.1:启发式会让「同一条消息被如何作答」随负载漂移,破坏「恰好一次」的直觉)。真并行路**立即起**(不是「排到队首再起」),因此 REST 同步入口拿不到同步 reply,文本照旧经 WS 流式到达。**B. 忙态帧的 `activeTurns` / `cancelledTurns`(纯增量,均在既有白名单帧内)**——`session_busy` / `session_idle` / `pending_drain` / `session_interrupted` 的 `data` 各增整数 `activeTurns`(该会话当前**在途路数**,含正在收尾的路;单路时非 0 即 1);`session_interrupted.data` 另增 `cancelledTurns`(本次真的取消了几路)。四个帧都**不含任何内容**,因此仍属「状态机信号」白名单、所有已认证连接(含 `viewer`)都收得到。**C. `pending_ack.reason`(纯增量,可选,绝不伪造)**——`pending_ack.data` 增可选 `reason:"parallel_limit"`,**只在「客户端显式声明了并行、本条却落回排队」时出现**(额度已满,**或**真并行被 `<2` 关掉——客户端问了并行而没拿到);其它情况该键**完全缺席**,帧与今天逐字相同。**D. REST 两条新应答形状(纯增量,只在 `parallel:true` 且被照准时才可能出现)**——① 同步入口(`stream:false`)照准并行 ⇒ `202 {"data":{"parallel":true,"turnId":"turn-…"}}`(**不是 `queued`**:它不排队、是立即起第二条路);② `stream:true` 照准并行 ⇒ `200 {"data":{"stream":true,"parallel":true,"turnId":"turn-…"}}`。`turnId` 就是那一路的**权威回合标识**、与该路全部流帧的 `turnId` 同值。**未照准**(未声明 / 空闲 / 额度满)时形状与既有逐字相同(`202 queued` / `200 {stream:true}`)。**E. `interrupt` 的多路口径(口径确认,无新字段)**——`scope:"current"` 省略 `turnId` 时取消的是**「当前全部在途路」**(不是只停一路,因为省略 `turnId` 的 scope 就是「作用于每一路」);`scope:"all"` 取消全部在途路 + 清队列、且**不发 `session_idle`**(与今天一致,客户端靠 `session_interrupted` 收口);`interrupt.turnId`(精确停某一路)**本版未实现**。**F. `turnId` 的生成方变更(对外无可见差异)**——`turnId` 由「网关每回合内部新生成(`runTurn` 的 `newTurnID()`)」改为「**编排器生成 → loop 复用**」(`loop.WithTurnID`;未挂载时如子代理 / backthink / 未接编排器仍新铸)。帧形状与取值分布不变(单路时仍是每回合全新的 `turn-<hex>`),因此**对客户端无可见差异**;写在此处只为让「谁生成」的口径与实现一致(这也是后续 `turn_begin` 帧与流帧必须同值的**硬前提**—Line truncated
- `v0.64`(**LLM 用量落库 + 只读汇总端点 + 面板用量小卡**,依据主人 2026-10-04 夜裁决「**2.4 落**」与 T1(`72b95df`:每次模型调用已记结构化用量日志、但**只落日志**),2026-10-04 落实)。**三档标注**:**纯增量** = 新端点 `GET /api/v1/admin/llm/usage`、新迁移 `0017_llm_usage`、新 env 键 `LLM_USAGE_RETENTION`(默认 `720h`;`≤0` = 关闭清理)、面板 LLM 页新小卡、前端新 API `adminLlmUsage()`;**行为变更** = 无;**破坏性** = 无。**A. 为什么落库**:日志能看单次、看不了趋势,也做不出按用途/模型/会话的成本视图,面板更无数据源 —— 主人要的是「这些调用到底花了多少」。**B. 写入绝不阻塞调用(本版最关键的一处设计)**:`model` 层**不直接开库**,只在既有 `logUsage` 处增加**可选**接口 `model.UsageSink`(**`nil` ⇒ 仅日志、行为逐字不变**,因此既有 model 用例**零改动**即全绿);组合根注入 `model.UsageWriter` = **有界队列(1024)+ 单写者 + 批量事务(128 行/批、2s 兜底冲刷)**:队列满 ⇒ **丢弃 + 限流 WARN(30s 一条,带累计丢弃数)**,**绝不阻塞、绝不 panic、绝不无界增长**;整批失败只记一条带来源 WARN(不静默);停机时先 drain 写完再退。sink 挂在 `RuntimeLLM`(热重载时稳定)而非适配器上,因此面板每次保存 models.json 都不会把落库静默断掉;**每一条链**(主/备/本地第三级/按角色跨协议)都带上它。**C. 表**:`llm_usage`(`id/session_id/purpose/adapter/model/prompt_tokens/completion_tokens/cache_hit_tokens/cache_miss_tokens/reasoning_tokens/total_tokens/took_ms/created_at`)+ `idx_llm_usage_created_at` + `idx_llm_usage_session_id`。✅ **`session_id` 已接线(v0.65 更正)**:模型层「不直接开库」的取舍不变,但 loop 现在在 `Run` 顶部把会话 id 播种进 ctx(`model.WithSessionID`),适配器 `record(ctx,…)` 带上它、空串映射 `NULL` ⇒ **该列从此真的有值**(v0.64 写的「恒为 `NULL`」**已过期**)。**本端点仍不按 `session_id` 过滤 / 分组**,响应形状不变;sink 未注入或会话未播种时行为逐字不变。**D. 读取**:`GET /api/v1/admin/llm/usage?since=&until=&purpose=`(**owner|admin 读**,与同页 `/admin/llm` 同门;**不审计** —— 查询不是动作)→ `{data:{window:{since,until,purpose},totals:{calls,prompt,completion,cacheHit,cacheMiss,hitRate},byPurpose:[…],byModel:[…]}}`;**缺省窗口 = 近 24h**;`since`/`until` 接受 **RFC3339 或 Unix 毫秒**;**跨度上限 90 天,超过 ⇒ `400`(不静默裁剪)**;**`hitRate` = cacheHit/(cacheHit+cacheMiss),分母为 0 ⇒ `0`**(口径已写进 §2 契约与前端展示规则:**分母 0 显示 `—`,不显示 `0%`**);分解**按调用次数降序、同次数按键升序**;**绝不含凭据与消息正文**(表里根本没有这些列)。**E. 保留期**:`LLM_USAGE_RETENTION` 默认 `720h`(30 天),**`≤0` = 关闭清理**(启动日志写明「llm usage retention disabled」);**启动期清一次 + 每小时清一次**(复用 `cmd/cyrene/wire_document_gc.go` 的既有模式;清扫间隔**不做成 env 键** —— 保留期才是部署者的旋钮)。**F. 面板**:LLM 页「用量(近 24h)」小卡 = **调用数 / 输入 token / 缓存命中率**,取数走既有 `useAsyncData` 加载/错误态(**读不到显示错误,绝不显示 0**)。**G. 失败不伪装**:汇总出口未接线或查库失败 ⇒ `500 {error:{code:"usage_unavailable"}}`,**不回全 0 假汇总**。**H. 可观测性端点的口径不变**:`GET /admin/observability/metrics` 仍只报 `llmCalls`/`subagentEvents`(它不统计 token/成本),token 与成本由本版端点单独回答。**实现落点**:`internal/runtime/model/{usage.go,usage_store.go,usage_query.go,openai.go,anthropic.go,factory.go,runtime.go}`、`internal/db/migrations/0017_llm_usage.{up,down}.sql`、`cmd/cyrene/{wire_usage.go,wire_runtime.go,wire_frontend.go,wire_services.go,main.go}`、`internal/services/gateway/{admin_llm_usage.go,routes.go,deps.go,contracts.go,server.go}`、`frontend/web/src/admin/modules/llm/LlmUsageCard.tsx`、`.env.example`。**验证**:sink 为 nil 逐字不变、队列满不阻塞(有超时判定)、批量写入→读回(含 hitRate 公式)、时间窗/用途过滤与超限 400、保留期清理(过期删 / 未过期留 / ≤0 不清理)、鉴权(无凭据 401 / viewer 403 / admin 放行)、不回显凭据(键集恰好六个);**两条变异**:① `RecordUsage` 改阻塞发送 ⇒ 「队列满不阻塞」红;② `hitRate` 分母写成 `cacheMiss` ⇒ 命中率断言红(还原后均复绿)。
- `v0.63`(**客户端登记的两条落地:`documents[]` 增可选 `reason`(读不到的原因)+ `GET /sessions/{id}/messages` 对不存在/已删除的会话回 `404 session_not_found`**,依据 `Cyrene-For-Android/devdocs/02-前后端协同事项.md` 的两条登记(2026-10-05 凌晨)与后端「回执九」的接受与选择,2026-10-05 落实)。**三档标注**:**纯增量** = ① `documents[]` 元素增可选 `reason`(`omitempty`,**只在 `status:"unreadable"` 时下发**;不读该键的客户端行为逐字不变);**行为变更** = ② `messages` 对**不存在/已删除**的会话由 `200 {"data":[]}` 改为 **`404 {"error":{"code":"session_not_found"}}`**(**客户端需按 404 处理**:退回主会话 + 一句提示;回执九已提前告知);**破坏性** = 无。**A. `reason`(§1.2 ④)**——数据来源 = 服务端**已有的那份抽取失败原因文案**(就是注入模型清单里那条,`internal/runtime/documents/sidecar.go` 的 `reason*` 常量);装配期在给出 `status` 的同时给出 `Reason`(`internal/runtime/loop/document.go` 的 `documentReason`),**同一个字符串两处用**:既进模型可见上下文,也作为客户端可见的 `reason` —— 服务端因此不必为客户端另编一句,两处口径也不可能分叉。**只在「读不到」时下发**(`extracted`/`listed` 为空 ⇒ 键缺席;客户端按「缺失 ⇒ 回落通用句」实现)。**文案集合(逐字)**:`无法抽取正文:这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)` / `…PDF 已加密(无法用空口令打开),请解除密码后重新上传` / `…PDF 损坏或无法解析` / `…本期不支持 Word/Excel/PPT 解析(无文本层)` / `…内容不在可抽取的纯文本范围内` / `…文档文件不存在或已被清理` / `…读取文档失败` / `…文档引用形态非法` / `…文档上传目录未配置` / 解析器整条消息失败时的诚实兜底 `无法抽取正文:读取或解析失败`。**只放文案**:不含落盘路径、不含凭据、不含存储名以外的内容;原始错误(可能带路径)**只进日志** —— 端到端用例用一个**独特的临时目录名**做反证断言(响应体里搜不到它,且 reason 不含 `uploads`、`\documents` 等路径片段)。**抵达面**:它随 `history.DocumentRef`(**与落库同一个结构体**)由 S3.5 的回合后回写写进 `document` 列(`mergeDocumentStatus` 把 `reason` 与 `status`/`chars`/`truncated` **同批**搬运、并计入「有无变化」判据),因此**所有读取面**(`GET /sessions/{id}/messages`、握手回放)都有它;⚠️ `message_persisted` 帧在用户消息落库时就发出,那时抽取还没发生(**不带** `status`/`reason`,与既有 `status` 行为一致 —— 客户端以读接口为准)。**B. `404 session_not_found`(§1 行 6)**——现象:此前对**已删除/凭空编造**的会话都回 `200 {"data":[]}`,客户端分不清「空会话」与「会话已不在」(主人的真实遭遇:正看着的临时会话被清理,界面上只剩一片空白)。**理由(与 API 自身一致)**:`PATCH /sessions/{id}`、`POST /sessions/{id}/read` 对未知会话本来就是 `404`,让 `messages` 单独例外会让每个新客户端都踩一次。**实现**:判定**复用既有存储面**(`history.Store.SessionPersona` —— 既有存储里唯一一个「按 id 读**单个**会话 + 未知 id 报 `history.ErrSessionNotFound`」的方法;`ListSessions` 是全量列表扫描,在热分页端点上代价不成比例),**不新写 SQL、不新增存储方法**,落点 `internal/services/gateway/history_session_guard.go`(新文件)+ `handleListMessages` 一行收口。**存在但没有消息仍是 `200 {"data":[]}`**(空会话绝不能被答成 404);参数校验(`limit`/`before`/`after`/`q`)**先于**存在性判定(形状错了就是 `400`,与目标存不存在无关);存储**没有该视图**(测试替身 / 未接库的窄存储)视为**无从判定** ⇒ 保持旧的空集合语义(判不出来绝不等于「不存在」)。**C. 会话注册表口径(重要,随本版更正 §3.4 旧文)**:`chat_sessions` 是会话注册表;**隔离的 IM 会话(群聊/陌生人的 `sess-xxx`、内部 `sess-backthink`)不在其中** ⇒ 它们在本端点与 `POST /sessions/{id}/read` 拿到**同一个答案**(`404`),这是「与 API 自身一致」的必然推论;它们的消息仍可由 `GET /api/v1/admin/channels/{platform}/messages` 读出(`Docs/api-reference.md` §3.4 那句「IM 隔离会话 `sess-xxx` 亦可用」**随本版更正**)。**D. 错误码风格**:新码 **`session_not_found`**(点明是**会话**,与 `chat` 的 `document_not_found` 同风格),与 `PATCH /sessions/{id}` 的既有通用码 `not_found` **不同码** —— 本版**不动** PATCH 的既有Line truncated
- `v0.62`(**面板「重置为 env 默认」动作落地:materialize 的反方向显式动作 + 一个新的 owner-only 管理写端点**,依据任务书 `DevDocs/_dispatch/R1-面板重置为env默认.md` 与 design/04 §3.1(`.env` 为默认/播种层、面板文件为一等来源)和 `DevDocs/开发时间线.md` 的欠账项(「Materialize 已导出、未接端点」),2026-10-04 落实)。**三档标注**:**纯增量** = 新端点 `POST /api/v1/admin/llm/reset-from-env`、面板 LLM 页的新卡片、审计动作 `llm.resetFromEnv`、2 个新错误码(`env_defaults_unreadable` / `models_unreadable`,另复用既有 `llm_persist_failed` / `llm_reload_failed` / `llm_unavailable`);**行为变更** = 无(既有端点 / 帧 / 字段一字未动);**破坏性** = 无(新端点的新用途;它本身的破坏性由请求体 `confirm:true` 强制)。**A. 新端点**(**owner-only**——与同页 `POST /admin/llm`、`POST /admin/llm/test` **同门**,破坏性写不比同族更宽;带审计 `llm.resetFromEnv`)body **必须** `{"confirm":true}`(缺失 / `false` / 非法 body ⇒ `400 bad_request` 且**一个字节都不动**),→ `200 {data:{reset:[{group,keys:[…]}], skipped:[{group,reason}], path}}`。**B. 语义(与 materialize 共用同一张键表 `runtimeconfig.groupFillSpecs`,方向相反)**:① **只覆盖 `.env` 声明过非空值的键**(`provider` / `baseUrl` / `apiKey` / `model` / `contextWindow`;协议族按**组自己的 `provider`** 选,`.env` 同时配扁平块与 `ANTHROPIC_*` 时两族各取自己的值);② **env 未声明的键一律保留面板值**(`fallback` / `multimodal` / `maxTokens` / `reasoning` / `personaModels` 在 `.env` 里没有出处 ⇒ 永不被本动作改动);③ **组集合不增减**(不复活被面板删掉的组、不删面板新增的组,**顺序也不动**);④ **面板值已与 env 一致的键不算「改回」**,不出现在 `reset` 里(`provider` 恒属此种情形);⑤ **无键可改时不写盘**(幂等,重复调用第二次回 `reset:[]` + 每组一条「已一致」的 `skipped`);⑥ **真的改了盘就热重载**运行中的 LLM 栈(无需重启;`contextWindow` 的**预算推导**仍只在启动时算一次,与 `POST /admin/llm` 同口径)。**C. 响应与凭据纪律**:`reset[].keys` 只有 **`models.json` 的 JSON 键名**、`skipped[].reason` 是中文原因,**凭据值(尤其 `apiKey`)绝不回显、绝不进日志**(`reset` / `skipped` 恒为数组,无记录时是 `[]` 而非 `null`;`path` 与 `POST /admin/llm` 同源)。**D. 对照基准**:**请求时刻按既有解析入口读到的 `.env` 默认层**(新增 `config.LoadLLMDefaults`,复用 `parseDotEnv` + 同一份 `env:"…"` 解码器 ⇒ 优先级与启动期 `config.Load` 逐字一致:进程环境 > `.env` > 标签默认);路径由组合根经 `Deps.EnvPath` 注入(与 `main.go` 的 `config.MustLoad(envPath)` 同一条),**网关自己绝不 fopen `.env`**。**E. 失败不伪装**(都**不改文件**且带审计 `error`):`.env` 源不可读 → `500 env_defaults_unreadable`;面板配置不存在/不可解析 → `500 models_unreadable`;写盘失败 → `500 llm_persist_failed`;已写盘但热重载失败 → `500 llm_reload_failed`(说明文件已改、未生效);LLM 栈未注入 → `500 llm_unavailable`。**F. 面板**:`frontend/web` 的 LLM 页新增「重置为 env 默认」卡片(`admin/modules/llm/LlmResetFromEnv.tsx`,标 `owner 专属`)——点按钮先展开**中文二次确认**文案,确认后才带 `confirm:true` 提交;结果按 `reset`/`skipped` 渲染成可复核的组名 + 键名摘要,失败原样显示后端错误文案。**G. 实现落点**:`internal/config/llm_defaults.go`(env 默认层读取)+ `internal/config/runtimeconfig/reset.go`(`ResetFromEnv` 语义,新文件)+ `internal/services/gateway/admin_write_llm_reset.go`(端点,新文件)+ `routes.go`(注册为 `ownerAuth`)+ `deps.go`/`server.go`(`Deps.EnvPath`)+ `cmd/cyrene/wire_frontend.go`(组合根接线)+ 前端 `lib/types/adminWrite.ts` / `lib/api/admin.ts` / `admin/modules/llm/LlmResetFromEnv.tsx` / `admin/modules/Llm.tsx`。**H. 未做 / 诚实边界**:**不支持「只重置某一组」**(一次调用对全部组生效;`skipped` 会如实报出没被改动的组与原因);**不做「重置 channels.json / plugins.json」**(materialize 本身也只覆盖 `models.json` 一族);**不做「重置后自动重启」**(provider 栈本身热更新,窗口推导需重启由面板文案写明)。
- `v0.61`(**PDF 文本层抽取落地:白名单第 23 项 `.pdf` + 类型裁决由「嗅探只放行 `text/plain`」改为 `(扩展名, 嗅探)` 成对裁决**,依据 design/24 §7 的 **S6 第一段**(PDF/Office 的裁决:PDF 文本层引库,扫描件与 Office 仍诚实拒绝)与 commit `f82c76d`,2026-10-04 落实)。**三档标注**:**行为变更** = ① **`.pdf` 由「一律 `415`」变为「可上传 + 可读取 + 文本层可抽取进上下文」**(把「`.pdf` 必被拒」写死的客户端必须改);② **类型裁决收紧为成对判定**(`documents.ContentAllowed`)——**方向是收紧/细化,不是放宽**:HTML/纯文本改名成 `.pdf` ⇒ `415`(嗅探不是 PDF),**PDF 字节改名成 `.txt` ⇒ `415`**(PDF 字节也只收在 `.pdf` 名下);**纯增量** = 扩展名白名单 **22 → 23 项**(唯一新增 `.pdf`),其余 22 个文本类的判定与既有响应**一字未变**。**A. 上传(`POST /api/v1/documents`,owner|admin)**——白名单加 `.pdf`;裁决 = **字节嗅探是唯一裁决权 ∧ 扩展名白名单**,自本版起**按对判定**(纯文本类必须 `text/plain`、`.pdf` 必须 `application/pdf`;落点 `internal/runtime/documents/store.go` 的 `ExtAllowed`(23 项)/ `ContentAllowed`,网关名字形态校验经它判定);`mime` 仍是**嗅探基类型**(真 PDF → `application/pdf`);`415` 文案同时点明两条口径(「只接受白名单内的文档(如 .txt/.md/.csv/.json,或内容确为 PDF 的 `.pdf`),且内容必须与扩展名相符」)。**B. 读取(`GET /api/v1/documents/{name}`,必带 Bearer owner|admin)**——**真 PDF 如实下发 `Content-Type: application/pdf`**(读时按同一对裁决重嗅探,判不定者仍退 `application/octet-stream`),`Content-Disposition: attachment` + `X-Content-Type-Options: nosniff` **一字未变** ⇒ **PDF 永不被内联渲染**(与图片读侧刻意相反);`Cache-Control: private, max-age=86400, immutable` 与 Range 支持不变。**C. 抽取语义(DTO `status`)**——**文本层 PDF 抽正文**(页内容流里的 `Tj`/`TJ`/`'`/`"` 文本算子,页间以换行相接)⇒ `status:"extracted"`,与纯文本类走**同一条** sidecar / 注入路径;**扫描件/图片型 PDF(无文本层)⇒ 诚实拒绝 `status:"unreadable"`**,原因文案含「这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)」,**绝不假装成功、也绝不返回空正文**(`ErrNoTextLayer`);**加密 PDF** 与**损坏/截断 PDF** 是**可区分**的两类失败(`ErrPDFEncrypted` / `ErrPDFDamaged`,加密的提示解除密码后重试);安全阀 = 单份最多 **5000 页** + sidecar 正文 **≤8 MiB**(撞上即 `truncated`)。**D. 客户端可见的一次性重算**——抽取器版本内部升到 **`documents/v3`**(`internal/runtime/documents/extract.go`)⇒ **旧 sidecar 一次性失效重抽**:同一份**旧附件**的 `chars`/`truncated` 结论会在**某次回合之后**重新计算(旧 PDF 若是文本层 PDF,将从 `unreadable` 变为 `extracted`)——不是附件被改写,是结论被重算。**E. 未做(诚实边界)**——**不做 OCR / 扫描件栅格化**(纯 Go 无可靠实现,会撞「单二进制 + 交叉编译」「零跨进程 IPC」两条红线)、**不做 PDF 写/生成**(只读)、**不做 `.docx`/`.xlsx` 等 Office**(仍诚实拒绝);**无新端点、无新 WS 帧、无鉴权变更、无 `.env` 新增键**。**实现落点**:`internal/runtime/documents/extract_pdf.go`(文本层抽取 + 加密/损坏分类)+ `internal/runtime/documents/{extract.go,store.go,sidecar.go}`(版本号 / 白名单与成对裁决 / unreadable 原因文案)+ 依赖 `github.com/ledongthuc/pdf`(纯 Go、无 cgo)。
- `v0.60`(**管理侧只读「存储状态」端点 + 面板存储卡接真数据**,依据主人 2026-10-04 实测反馈(面板首页「存储」卡一直是占位「SQLite」,看不出 PG 增强有没有生效)与 design/10 §3 模块 14(系统设置)/ design/03 §3(pgvector 可选增强),2026-10-04 落实)。**一句话**:**纯新增一个只读端点**,既有端点 / WS 帧 / 字段形状**一字未动**;面板那张假卡换成真数据。**A. 新端点** `GET /api/v1/admin/system/storage`(**owner-only**、**只读**、**无审计动作**,与四条 `POST /system/*` 动作端点同门不同档)→ `200 {data:{main,documents,vector}}`:`main` = `{kind:"sqlite",path,fileBytes,walBytes,shmBytes,pageSize,pageCount,freelistPages,freeBytes}`(`path` 与备份/恢复端点同源;WAL/SHM 边车**合法地不存在**时如实为 `0`;`freeBytes = pageSize × freelistPages`)、`documents` = `{count,bytes}`(读 `documents` 表,**不 walk 目录**,空表 `bytes` 为 `0` 而非 `null`)、`vector` = `{kind:"pgvector"|"none",enabled,ready}`。**B. `vector` 的语义(主人 2.4 的困惑点)**:`kind` 说「配没配 `DB_PG_URL`」,`enabled` 说「开关在不在」(等价于 `kind != "none"`),`ready` 说「**活着的**向量索引真的是 pgvector 后端吗」—— 配了但连接/schema 准备失败 ⇒ `enabled:true` + `ready:false`,客户端必须显示「已配置但未就绪(检索已降级)」,**不要把 `enabled` 当成生效**。客户端显示建议:`none` → 「未启用(未配置 `DB_PG_URL`)」、`enabled && ready` → 「启用(`pgvector` · ready)」、`enabled && !ready` → 未就绪。**C. 两条纪律**:① **不含凭据**——DSN / 用户名 / 密码 / `postgres://` 全文**一个字都不回**(`vector` 段只有 `kind`/`enabled`/`ready` 三个键;有用例断言响应体里搜不到这些串);② **只读**——只有 `SELECT`/`PRAGMA` + `os.Stat`,且复用组合根注入的**活连接**(不新开连接池、不写、不迁移、不备份;调用前后 `documents` 行数与数据目录内文件集合均不变,有用例钉住)。**D. 真源(`vector`)**:组合根 `cmd/cyrene/wire_frontend.go` 的 `app.vectorState()` —— `Configured` 取 `DB_PG_URL` 非空、`Live` 取活索引的具体类型(`*vector.PGVectorIndex`);网关**只读这份注入值**,不读环境变量、不新开 PG 连接、不自行探测(否则面板与运行时会给出两个答案)。**E. 失败不伪装**:未接活连接 / 数据库路径未配置 / 读不到页级统计或 `documents` 表(例如库没跑迁移)⇒ `500 {error:{code:"storage_unavailable",message}}`,**绝不回一个全 0 的假快照**;无凭据 → `401`。**F. 面板**:`frontend/web` 的存储卡(`admin/modules/dashboard/overviewMetrics.ts`)改为真数据 —— `value` = `SQLite 12.3 MiB`(`main.fileBytes` 的人类可读量级)、`hint` = `WAL … · N 页(空闲 M 页 / …)`、新增两行详情 = `documents N 件 · …` 与 `PG 增强 …`;取数走与其它已接线卡片**同一条** `useAsyncData` 加载/错误态(数据没到就显示「端点报错:…」,**不回退成 0、不假装成功**)。**G. 实现落点**:`internal/services/gateway/admin_system_storage.go`(新文件)+ `routes.go`(注册)+ `deps.go`/`server.go`(注入 `Deps.Database` / `Deps.Vector`)+ `cmd/cyrene/wire_frontend.go`(组合根真源)+ 前端 `lib/types/adminWrite.ts` / `lib/api/adminSystem.ts` / `admin/modules/dashboard/{useDashboardExtras,overviewMetrics,MiniMetric}`。**H. 兼容性**:**纯新增**——新端点为纯增量;前端 `MiniMetricSpec` 新增的 `extra?: string[]` 是**可选**补充详情行,不传时卡片渲染与旧版**逐字等价**(其它卡片一字未改)。**未做 / 诚实边界**:`main` 只报 SQLite(当前唯一主库引擎);`vector` 只报 `kind`/`enabled`/`ready`(**刻意不回 host**,把泄漏面压到最小);不报表级明细、不报备份文件列表(`documents` 之外的占用仍要下钻到备份/文件系统看)。
- `v0.59`(**文档进对话 + 上下文预算按窗口推导 + 语音回合进编排 + `thinking` 可达性口径**,依据 design/24 §3/§4/§6(切片 S1–S5)、design/04 §1.3 + 附录 A.3(主人裁决 2026-10-04)与 `Cyrene-For-Android/devdocs/02-前后端协同事项.md` 的 G5 / C1 / C3;commit `a8851dc` / `a4789ec` / `20c1edb` / `5b2b385` / `72b95df` / `3a01b4e`,2026-10-04 落实)。**三档标注**:**纯增量** = 两个 documents 端点、`chat.documents` 可选字段、4 个文档错误码、`models.json` 组键 `contextWindow`、4 个 `.env` 键、`document-read` 工具、`pending_full` 的 `clientMsgId?`/`messageId?`、定向 `error{code:"chat_busy"}` 帧、`voice_input.clientMsgId`、`voice_output.sessionId`/`deviceId`;**行为变更** = ① 语音回合改走编排器(落库 / 流式 / 跨端 / 忙时排队)② `CONTEXT_MAX_TOKENS` **未设置时改为按窗口推导**(`.env` 里写着 `32768` 仍是显式覆盖)③ 队列满时新增**定向回执**(此前 WS 无等价物);**破坏性** = ① `historyMessage` 的 `document`(单对象)→ **`documents`(数组,≤5)** ——**但单数形状从未随任何已发布版本号发布过**,故**无兼容期** ② `chars` / `truncated` 的语义由「抽取侧」改为 **「本回合注入侧」**(全文长度只有 `document-read` 的 `totalChars` 能看到)。**A. 文档(新增 §1.2 + §1 行 16/17)**——`POST /api/v1/documents`(multipart `file`,上限 `CHAT_DOCUMENT_MAX_BYTES` 默认 20 MiB)→ `201 {name,url,mime,size,sha256,deduped:false}`;**同字节同扩展名重复上传 → `200` + 同一 `name` + `deduped:true`**;`GET /api/v1/documents/{name}` **必须带 Bearer(owner|admin)**(与图片读侧的免鉴权能力 URL 刻意相反)+ `Content-Disposition: attachment` + `nosniff`;**字节嗅探是唯一类型裁决**(**当时**放行基类型只有 `text/plain`;**v0.61 起**改为 `(扩展名, 嗅探)` 成对裁决、`.pdf` 走 `application/pdf`),**扩展名白名单 22 项**(⚠️ 历史值;**v0.61 起为 23 项**,纳入 `.pdf`)deny-by-default,`mime` 是嗅探基类型(`data.json` → `text/plain`)、`url` 是**相对路径**;错误码 `unsupported_media_type`(415) / `document_too_large`(413) / `document_not_found`(404) / `too_many_documents`(400);`chat` 请求(REST 同步 / REST `stream:true` / WS **三处同形**)增可选 `documents:[{name}]`(**≤5、只收服务端存储名、不收 URL**,先于任何 FS 访问判条数,客户端多带的 `mime/url/size` 被服务端覆盖);DTO 四处同源(`GET /sessions/{id}/messages` / `message_persisted` / `message_edited` / 握手回放)改 `documents` 数组,元素 `{name,mime,size,chars,truncated,status}`、`status ∈ extracted|listed|unreadable`、无附件时省略该键;内置工具 `document-read`(`{name,offset?,limit?}` → `totalChars`/`truncated`/`nextOffset`/`text`/`extractTruncated?`,`limit` 默认 8000、上限 64K、`offset` 为 0 基 rune,策略默认 owner-only)。**B. 配置(§2「上下文窗口与预算契约(v0.59)」)**——`models.json` 组级新增 `contextWindow`(0/省略 = 未声明 ⇒ 131072 保守估算 + **恰好一条 WARN**;负数 = 校验失败);`.env` 新增 `LLM_CONTEXT_WINDOW` / `CONTEXT_BUDGET_RATIO`(默认 `2/3`)/ `DOCUMENT_MAX_INJECT_CHARS`,并**变更 `CONTEXT_MAX_TOKENS` 语义**(未设置 ⇒ 按窗口推导;`>0` 覆盖;`0`/负 = 关闭压缩)——推导 `budget = min(窗口 × 比例, 窗口 − 输出保留)`,输出保留 = `max(32768, 该组 maxTokens)` 封顶 `窗口/2`,**1M 窗口 ⇒ 699050 tokens**;另**补登记 `.env` → 面板的 materialize 语义**(每次启动一次;面板非空 ⇒ 组集合由面板决定、env 不复活被删的组;键级只在面板缺该键时补,面板的**显式空值/`false`/`null` 绝不覆盖**;「面板改一次、重启被打回」为硬约束;凭据不进日志;范围仅 `models.json` 一族)。**C. 语音(新增 §3.15)**——语音轮次**现在走与文本同一条路径**(落库 = 转写文本 + 助手回复、发 `message_persisted` 与流式帧、跨端同步、忙时按既有规则排队,`pending_ack`/`pending_drain`/`session_busy` 语义零变化);`deviceId` 照旧落库、`platform` 走存储默认 `web`;`voice_input` 增可选 `clientMsgId`(重复到达回 `chat_duplicate`,不产生新回合、不发音频);`voice_output` 增 `sessionId`(恒有)/ `deviceId`(非空才有),**空正文与 TTS 失败/降级都走既有 `provider` 降级信号**(不再把 `stopReason` 当语音内容);**时点** = 回合结算时才到(可能晚于若干状态帧、也可能由续跑或被 steer 注入的那一回合产生);**定向目标 = 发起 `voice_input` 的那条连接本身**(不按 `deviceId` 路由)、**断线不补投**(带来源 WARN),重连带同一 `clientMsgId` 重试安全Line truncated
- `v0.58`(**纯新增 env 键:`LLM_MULTIMODAL`——让 env-only 部署也能声明「主模型自身能收图」**,依据裁决 14.4.1 / design/06 §0(commit `4adfc9a`),2026-10-04 落实)。**新增 1 个 `.env` 键**:`LLM_MULTIMODAL`(`bool`,**默认 `false`**)= `models.json` 组 `multimodal` 的 **env 等价物**,**只在没有有效 `models.json`(走 `.env` 合成组)时生效**(有组文件时以该组的 `multimodal` 为准,与 `LLM_REASONING_KIND` 同口径);`true` = 图片**直进主模型上下文**;**留空 / `false` = 把图交给 vision/OCR 工具换成文本描述**;**非 bool 值(如 `maybe`)启动即报错**(fail-fast,不静默回退)。**兼容性**:默认 `false` ⇒ **老部署行为逐字不变**(引入本键之前,env-only 部署没有任何键能声明该能力,图片恒走 vision 工具);**`LLM_MULTIMODAL=true` 后图片走主模型直收,属部署者显式选择**。**无端点 / 帧 / 字段形状变更**(纯新增 env 键,见 §2「模型配置契约」表与 §1 行 3)。
- `v0.57`(**13.4.5 目标态 steer 落地登记:新下行帧 `steer_ack`(回合内转向的消费回执)**,依据 design/15 §2.0 / §2.1 / §2.4 / §3.x / §5(**13.4.5 目标态规格**,commit `3d8822c`),2026-10-04 落实;**本版是已实现并已推库行为的登记,只有一处纯新增帧 + 一处链路图纯增量键,无其它契约变更**)。**A. 新下行帧 `steer_ack`(见 §3.14)**——`{type:"steer_ack", sessionId, data:{messageId, atStep}}`:忙时会话里新到的用户消息**被在飞回合吃进同一回合**时下发,`messageId` 是那条用户消息的**权威 id**(与 `message_persisted` 的 `data.message.id` 同源)、`atStep` 是吃进它的**模型步序号(1 基)**;同一批次吃进 N 条就发 N 帧、**不合并**。语义=「**已被本回合吃进(已从 pending 移除)、尚未被正面回答**」⇒ 客户端应把对应气泡渲染为「**已读、未答**」的**灰态**,直到该回合的 `message.*` / `turn.end` 到达,**不需要第二个确认帧**(与「忙『稍后回』」的 pending 气泡不是同一个态)。**角色门**:它**携带具体消息身份**,因此**不登记**进 `frameReachesAll` 的 7 个状态机信号白名单,落 fail-closed 默认档——**只发 `owner` + `admin`**(`viewer` 收不到);经编排器状态通道**由 hub 直接广播、不经 bus**;帧壳带 `sessionId`、**不带** `turnId`/`seq`。**触发时机**:只在**回合内边界**(loop 每一步开头:上一次模型步与它那一批工具调用都已结束、下一次模型调用尚未开始,此刻没有工具在飞)产生,且**只有该边界真的匹配到 pending 里、且已落库的消息时才发**——**不是每个 step 都发**(无可注入消息的边界一帧都不发)。**B. 转向的三条不变量(design/15 §2.0)**——**I1(同会话在途回合数恒为 1)**:转向只注入、不取消,**绝不新开第二个回合**(loop 侧结构性地没有任何「起新回合」的分支,注入只是把消息并进**当前回合**下一次模型调用的视图);**I2(消息恰好被处理一次)**:注入=**已消费**——被注入的消息立刻从该会话 pending **移除**(按 pending 序号认领),绝不被第二个回合再答一遍;去重键=后端 `message.id`,候选必须**同时**满足「在该会话转录里」与「在本编排器的 pending 里」(**不用时间戳**:Windows `time.Now()` 步长约 0.44ms,连发常同毫秒并列,任何毫秒基线都会漏注入或重复注入);**I3(转向不取消已发起的工具)**:注入点只在回合内边界,此刻没有任何工具在飞,正在执行的工具**永远跑完**(与 Cline PR #13958「只打断在飞的模型请求、工具与整轮继续」同口径)。**C. 注入的范围(重要口径)**——注入**只影响本轮视图**(每一步边界重新并入),**不写回运行窗口** ⇒ 既不进该回合的 **checkpoint 快照**、也不进**后台记忆提取的输入面**;被注入的消息**本身早在到达时已落库**(pending 路径即时持久化),注入**不新增、也不改写任何行**(它与 `message_persisted` 是两回事:后者在该消息落库时发,前者在该消息被吃进时发)。最新性=**每个边界重读该会话转录**(`history.Recent`),不靠回合开始那一次播种(回合开始前已有的消息由 `startTurnMessages` 播种,**绝不重复注入**)。**D. 失败一律让路(诚实边界)**——未接转录存储 / 读转录失败 / 该消息尚未落库 / 本回合世代已被打断或被更新的回合取代(认领 `pending` 失败)时,**不发 `steer_ack` 也不注入**:该消息**留在 pending**、照旧作为自己的回合被回答,**绝不静默消失**(转向是增益,任何一处失败都不该让已经在跑的回合失败)。**未做**:**没有「取回 / 撤回 pending 消息」的上行帧**(design/15 §5 提到的队列取回暂无协议面),也没有「已撤回」类下行帧;`steer_ack` 只报「**吃进**」,**不报「回答」**(回答由既有的 `message.*` / `turn.end` 表达)。**E. 一处随实现落地的链路图纯增量键**——注入时给该轮图的 **input 节点**打 `props.steered:true`(`loop.FlowPublisher.MarkInputSteered`),让流程图如实表达「这一轮的输入在回合中途被追加过」;图仍**只有一条主干**(**不是**被画成第二轮,与 I1 一致),标记只并入既有 input 节点、**不新建节点、不改标签**。**F. 实现落点**——loop 侧 `internal/runtime/loop/steer.go`(`SteerRequest` / `SteerSource` / `WithSteerSource` / `steerAtBoundary`)+ `run.go` 的边界调用点 + `flow.go` 的 `MarkInputSteered`;网关侧 `internal/services/gateway/session_steer.go`(每回合一份的 `steerTurn` 游标 / `match` / `consumePending`)+ `session_orchestratLine truncated
- `v0.56`(**契约债一次补齐(协同项 A2 / G1 / G2 / G3 / G4 / F3):`interrupt` 支持 `scope` + REST `stream:true` 不再静默失败 + `chat.image` 上限 + `clientMsgId` 幂等 + 视觉链路 URL 取回事实 + 既有错误陈述修正**,依据 `Cyrene-For-Android/devdocs/02-前后端协同事项.md` 的 A2 / G1 / G2 / G3 / G4 / F3(commit `7512d59` 等),2026-10-04 落实)。**A. `interrupt` 支持 `scope`(A2,纯新增可选字段)**——上行 `interrupt` 由 `{sessionId}` 扩为 `{sessionId, scope?}`,取值域 `{"", "all", "current"}`(省略 / 空串 / `"all"` **三者同义**,= 现状语义:取消在跑回合 + **清空**待处理队列;老客户端只发 `{sessionId}` 的行为**逐字不变**)。**新增 `"current"` = 只取消在跑回合、保留待处理队列**,队列消息按编排器正常续跑规则继续执行(带上会话尾部上下文)——修的是「客户端『打断』与『追加队列』两种模式被后端当成一种」:打断一律连队列清空时,用户选「打断」后连发的多条消息会被**静默丢弃**,只剩一个 `dropped` 数字。**其余取值 → 定向回** `{type:"error",data:{code:"bad_request"}}`(只回发送者,**不广播、不打断任何会话**),显式报错而绝不静默按默认执行。**下行 `session_interrupted` 帧形状零变化**(`{sessionId, mode, dropped}`),语义改为「如实上报」:`dropped` 恒等于**真的被丢弃**的待处理条数(`scope:"current"` 时为 `0`),且该档 `mode` 为会话当时的**忙碌通道**(`thinking|executing|subagent`,**不是 `idle`**)。实现落点 `internal/services/gateway/session_interrupt.go`(`InterruptScope` / `InterruptScope()`,从编排器主文件拆出以守住 ≤500 行)与 `ws_hub_turn.go` 的 `interruptRequest.Scope` + `parseInterruptScope`(**大小写敏感**、只容忍首尾空白)。**B. REST `stream:true` 不再静默失败(G2,行为变更,客户端必读)**——见 §1 行 3 与 §4.2:该分支改为与 WS `chat` 帧**同一条直达编排器的路径**(design/15 §2.1「会话消息不得被 drop」)。**响应面**:受理成功后才回 `200 {data:{stream:true}}`;正忙已续接 `202 {data:{stream:true,queued:true,queueDepth}}`;队列满 `409 {error:{code:"chat_busy"}}`;幂等键重复 `200 {data:{duplicate:true,messageId,sessionId}}`;**未接 loop 由 `200 {stream:true}` 改为 `500 {error:{code:"chat_unavailable"}}`**(与同步路径同口径);仅**未接编排器**时回退总线派发,其失败回 `503 {error:{code:"chat_dispatch_failed"}}`(修复前 `Publish` 的错误被吞掉、且传的是**请求作用域** ctx)。**`202` / `409` 从此真的会出现**(修复前因「先 return 200」永不出现)。派发用的回合上下文与请求作用域脱钩(`context.WithoutCancel` + 30 分钟上限),回合不会在客户端刚收到 200 时被连带取消。**兼容性**:`stream:false` 与既有响应形状**一字未动**;**依赖「任何情况都拿到 200」的客户端必须按上表分支**。**C. `chat.image` 上限(G3,纯新增校验)**——`POST /api/v1/chat` 的 `image` 新增硬上限:**解码后 5 MiB**(与 `POST /api/v1/upload` 同口径;base64 字符串上限 = `ceil(n/3)*4 + 64` 字符,覆盖 data: URI 前缀余量),超限 → `400 {error:{code:"image_too_large", message}}`(文案指路 `POST /api/v1/upload`),在**任何解码 / 落库 / 入模型之前**拦下(超大 base64 因此不进历史、也不会被重连回放重传;校验按**字符串长度**做——入口层唯一零分配的一步,先拦再接)。**事实(诚实边界)**:**WS `chat` 帧到不了这条 400**——WS 读上限 `wsReadLimit = 4096` 字节/帧,超限由传输层直接断连 ⇒ **WS 上发真实图片本就必须「上传 + URL」**(图片有「上传」与「内联」两条入口,两边给出同一个「一张图能有多大」的答案)。**D. `clientMsgId` 幂等(G4,纯新增可选字段 + 纯新增帧)**——见 §3.13:上行新增**可选** `clientMsgId`(REST `chat` 体与 WS `chat` 帧都支持),同一会话内同一非空值只承认第一条;重复到达 REST 回 `200 {data:{duplicate:true,messageId,sessionId}}`、WS 回**新下行帧 `chat_duplicate`**(`{type:"chat_duplicate", sessionId, data:{sessionId, clientMsgId, messageId}}`,**未进 7 个状态机信号白名单 ⇒ fail-closed 只发 `owner` + `admin`**),且**不落库、不起回合、不发任何内容 / 流式帧**。判重由 `(session_id, client_msg_id)` 的**部分唯一索引**(`WHERE client_msg_id != ''`,空键刻意排除在外——否则同一会话第二条空键消息就会撞唯一约束)**原子裁决**,并发重发只有一个赢家;非空键的写入走 `ON CONFLICT DO NOTHING`。`clientMsgId` 另**回填进消息 DTO** ⇒ `message_persisted` / `channel_persisted` / 握手 `hisLine truncated
- `v0.55`(**阶段 7 落地项登记:路径式安全入口 + 数据目录 `platform` + OneBot v11 四端 + 可观测实时推送**,依据裁决 1.6 / 16.4.7 / 12.4.2(commit `a398dc9`)、1.4 / 12.4.1(commit `9253fef`)、05.4.3 + 16.4.3-WS(commit `0e1f163`),以及 13.4.5 面内三条(commit `74b12d8`)、MCP 官方 SDK(commit `a1083a3`),2026-10-04 落实;**本版全部是已实现并已推库行为的登记,无新代码**)。**A. 安全入口(`ADMIN_PATH`,裁决 1.6 / 16.4.7,**前端路由面——不是 REST 契约**)**——① **新增 env `ADMIN_PATH`**(默认 `admin`):面板前端路由前缀 = `/` + 值(`admin` → `/admin`;`xxx/admin` → `/xxx/admin`);**非法值回退 `/admin`**——容忍首尾斜杠与空白,拒绝空段、`.`/`..` 段与含 `?`/`#` 的段(宁可挂回默认,也不挂到不可预测的路径上)。② **HTTP 行为**:`/<ADMIN_PATH>` 与其深链(`/<前缀>/<section>`)返回**注入了 `<base href="/">` 与 `window.__CYRENE_ADMIN_ENTRY__={"prefix":"..."}` 的 index.html**(`Cache-Control: no-store`;`<base href="/">` 必在资源引用之前,否则多段前缀/深链下 Vite 的 `./assets/...` 会解析到错误目录);**形如面板入口但前缀不符的路径 → `404`**(判定 = 路径里出现 `admin` 段;既不回落用户端聊天壳、也不回显正确前缀);`/<ADMIN_PATH>` 若**恰好命中 `dist` 真实文件**则仍走文件服务器(面板入口页绝不顶掉 SPA 资源)。③ **API 不藏**:`/api/v1/*`、`/api/v1/admin/*`、`/ws/chat`、webhook 一律原样——**无新端点、无帧变更**,安全靠 JWT + 角色门(design/07 §0.1)。④ **口径**:**用户端 HTML 永不携带该前缀串**(后端只在命中前缀时注入)⇒ 用户端既无管理入口链接、也解析不出面板路由(design/07 §0.2)。前端路由随之改**路径式**(`frontend/web/src/admin/router.ts` 只读注入、**无硬编兜底**、移除 hash 入口;模块切换走 history)。**B. 数据目录(同 commit,裁决 12.4.2)**——`DATA_DIR` 新增可接受值 **`platform`**(大小写不敏感)→ 平台标准数据目录(Windows `%APPDATA%\Cyrene`、macOS `~/Library/Application Support/Cyrene`、Linux `~/.local/share/cyrene`);**默认 `./data` 与显式路径行为不变**;**显式空值行为不变**(仍走组合根原兜底,避免正在运行的实例突然换库)。**C. OneBot v11 四端(`9253fef`,裁决 1.4 / 12.4.1)**——① **配置面**(channels.json 账号 `config` + 适配器 `Config()` 快照)新增:`transport`(`ws`|`http`,默认 `ws`;**未知值一律回落 `ws`**,绝不静默改走 HTTP)、`api_base_url`(OneBot HTTP API 基址,必须显式给出——**不从 `remoteURL` 推导**,v1 那种「剥掉最后一段路径」的推导是有损的)、`report_path`(默认 `/`,无前导 `/` 时补齐);快照里 camelCase 为 `transport` / `apiBaseURL` / `reportPath`;`remoteURL` 快照值 = `remote_url`、为空时回落 `api_base_url`(语义即「**出站远端地址**」)。**既有键与值不变**——不给出任何新配置时行为与改造前逐字节一致(`transport=ws`、`mode=server`)。② **env 兜底**:`CHANNEL_QQ_OBV11_TRANSPORT`(默认 `ws`)/ `CHANNEL_QQ_OBV11_API_BASE_URL` / `CHANNEL_QQ_OBV11_REPORT_PATH`。③ **四端语义(谁向谁发数据)**:`ws+client` Cyrene 拨号 OneBot、收发同一 WS;`ws+server` OneBot 反连 Cyrene、收发同一 WS;`http+client` Cyrene→OneBot(`POST {api_base_url}/{action}`、等响应、按 `retcode` 判成败,**纯出站**、无长连接、token 只走 `Authorization: Bearer`);`http+server` OneBot→Cyrene(POST 到 `listen_addr` + `report_path`,**纯入站**;**fail-closed 鉴权**——配了 `access_token` 时 `Authorization: Bearer` 或 `?access_token=` 不匹配回 `403`,`access_token` 为空则放行,与既有 `ws+server` 同口径)、**10MiB 请求体上限**(超出 `413`)、方法非 `POST` 回 `405`、成功回 `204`。④ **关键取舍**:`mode` 决定**哪一半必需**(server 必需 `listen_addr`、client 必需 `api_base_url`),另一半只要给齐字段就**一并启用** ⇒ **单个适配器即可完成「收上报 + 回消息」闭环**,四端仍各自可独立选择与测试;任一端配置有误即 `Connect` 失败(先做纯配置校验并构造出站客户端,再绑监听端口),**绝不留下半开的端口**。⑤ **端口例外**:`http+server` 会用 `listen_addr`,属 `AGENTS.md` §9 已登记的**同一豁免**(design/13 §0 原文即写「WS server / HTTP server」);`listen_addr` 为空则报错、**绝不开端口**(无默认值)。⑥ **未接**:`get_group_list` 枚举(后续)。**D. 可观测实时推送(`0e1f163`,裁决 05.4.3 + 16.4.3-WS)——新增 §3.12**:日志 / 指标 / trace 从「面板 1s 轮询 REST」改为 **WS Line truncated
- `v0.54`(**契约债一次补齐:渠道配置写路径改 patch 语义(修 P0 级凭据静默丢失) + 阶段 6 前端批登记 + 三处过时陈述修正**,依据 design/04 §3.1、design/22 §2 方案 b 与裁决 16.4.1 / 16.4.2 / 16.4.3 / 16.4.4 / 16.4.5 / 16.4.6;commit `4ae7148` / `f454273` / `b480b04` / `c1a98d7` / `8d70f6d`,2026-10-04 落实)。**A. 渠道配置写路径由「整条替换」改为「按 id 打补丁」(P0 级数据安全,破坏性语义变更)**——① **语义变更**:`PUT /api/v1/admin/channels/{platform}/config` 的 `config` 从「整条替换该账号配置」改为「**按 id 打补丁**」(`runtimeconfig.PatchChannel`,**与 `personaId` 同构的三态**):**键省略(该键不在 `config` 里,或 `config` 整体缺席 / 为 `null`)= 保留该账号已存值**、**给出非空值 = 覆盖该键**、**显式 `null` 或 `""` = 清除该键**(适配器回落零值;`0` / `false` 是真实取值,**不算空**)。**破坏性提示**:按旧文实现、依赖「省略某 `config` 键 = 删掉它」的客户端,**必须改为显式传 `null` / `""`** 才能清除该键。② **修掉的数据安全问题**:面板只发送用户本次**碰过**的平台字段,后端却整条替换 ⇒ ⓐ 保存**微信(iLink)**账号卡会抹掉 `bot_token` / `bot_id` / `owner_id` / `get_updates_buf`(**iLink token 是长期凭据**,被抹掉即掉线且不可恢复);ⓑ **Telegram** 只填 `webhook_url` 而不重填 token ⇒ `bot_token` 变空 ⇒ `cleanAccounts` **丢掉整个账号**。两条在修复前都是「面板保存成功、盘上凭据没了」的静默丢失。③ **保留清除能力**:显式 `null` / `""` 仍能真正删除该键,未触碰的兄弟键不受影响。④ **未改**:token 要求型平台(kook / telegram)上**显式**清 `bot_token` 仍按 `cleanAccounts` 丢掉该账号(明确要求清除凭据,不是误删);「本次请求一个 `config` 键都没给出」在**非 `weixin-ilink`** 平台仍回 `400`(`weixin-ilink` 豁免以便先建账号再扫码)。⑤ **无新端点 / 新字段 / 响应形状变更**:请求体与 `201 {data:{persisted,path,platform,accountId}}` 一字未动,只有 `config` 的解释规则变了;`allowGroups` / `denyGroups` / `allowUsers` / `denyUsers` 与 `groupMsgPerMin` / `msgPerMin` / `maxMessages` **仍是整值替换**(省略 = 清空,客户端须回传完整列表 / 非零值)。⑥ **实现落点**:`internal/config/runtimeconfig/channels.go` 新增 `PatchChannel` / `mergeChannelConfig` / `isEmptyConfigValue`;`internal/services/gateway/admin_write_channel_config.go` 的 `adminSaveChannelConfig` 改调 `PatchChannel`(`channelConfigFromRequest` 返回**补丁**而非完整配置)。**验证**:`go test ./internal/services/gateway/ -run TestAdminSaveChannelConfigKeeps -v`(微信 iLink 凭据保留 / 空补丁三形态 / Telegram 账号存活 / 显式清除 / 无基线新建)+ `go test ./internal/config/... -run TestPatchChannel -v`;**变异验证 3 条**——把写路径改回**整条替换**(旧 upsert 语义)后,「省略键保留」「Telegram 账号存活」「空补丁不改凭据」三类断言**精确复现** `bot_token` / `get_updates_buf` 被抹掉与账号被 `cleanAccounts` 删掉的旧缺陷 ⇒ 变异必红。**B. 阶段 6 前端批(纯前端行为,无 API 变化)**——① **16.4.1 静默 / 活动态与预约切换**(`f454273`,帧缺口补 `b480b04`):输入模式切换的唯一入口 `frontend/web/src/components/modeSwitch.ts`——**她活动**(`sessionBusy || 屏上 streaming || sending`)时用户切换只登记「**已预约**」(不改当前模式、不上行、不清除既有预约),**她静默**时立即生效并上行 `mode_switch`;提前切 → **下次静默态落地**(徽章「已预约 · X(她静默后生效)」);预约状态**纯前端**、不进后端契约。② **16.4.5 会话级消息缓存**(`b480b04`):**帧按自己的 `sessionId` 落进该会话的消息桶**(新增 `frontend/web/src/store/sessionCache.ts` 纯函数 + `useAppStore.sessionMessages`),**切会话「存回 / 取出」不再清空**、非激活会话的帧**不再被丢弃**;**只有「单槽忙态」**(`session_busy` / `session_idle` / `session_interrupted` / `stream_end` 的 `sessionBusy` 收口)仍按激活会话判定(见 §3.1 / §3.2)。③ **16.4.2 导图口径**(`b480b04`):`graph_update` 按 `data.sessionId` 入**图桶**(上限 12 张)——管理端 Flow / Agents 页取**激活会话**的图、**Backthink 页取 `sess-backthink`** 的图,后台思考图不再顶掉「当前会话」图(见 §3.5)。④ **16.4.3 / 16.4.6 虚拟滚动与专属页只读卡**(`c1a98d7`):管理端长列表超过阈值走**窗口化**(`AdminTable` / 新增可复用 `VirtualList` / 适配器消息日志,DOM 行数与数据量解耦);专属页**只读展示该平台Line truncated
- `v0.53`(**阶段 5 落地:模型目录端点 + per-persona 路由 / 本地三级 / capacity L3 / 视觉·OCR 独立模型 / `image-crop` 工具 / 按 provider 形状下发推理开关**,依据 design/04 §3/§3.1/§3.2(裁决 03.4.1 / 03.4.2 / 03.4.3 / 08.4.1)、design/06 §0/§3(裁决 14.4.1 / 14.4.5)、design/14 §2.2/§3/§4(裁决 14.4.2 / 14.4.3 / 14.4.4),2026-10-04 落实):① **新增管理只读端点 `GET /api/v1/admin/llm/catalog`(owner|admin)**——面板「添加提供商 / 添加模型」的候选目录(baseURL / 模型 id / 能力位 / 输出上限),数据源 `https://models.dev/api.json`;`?refresh=1` 强制在线刷新,否则 24h TTL 内的本地快照直接复用。`200 {data:{source:"cache"|"models.dev", fetchedAt?, stale?, warning?, providers:[{id,name,api?,doc?,env?,npm?,models:[{id,name,attachment,reasoning,toolCall,contextLimit?,outputLimit?}]}]}}`;**既无快照又抓不到** → `503 {error:{code:"catalog_unavailable"}}`;**有快照但刷新失败** → 仍 `200` + `stale:true` + `warning`(离线兜底,不编造候选);带 `Accept-Encoding: gzip` 时响应带 `Content-Encoding: gzip`(信封形状不变)。**副作用只有一处**:落盘快照 `<DATA_DIR>/llm_catalog.json`(**无凭据**、`data/` 已 gitignore、TTL 24h),**不改 `models.json`**;**抓取只发生在本端点内,不进运行时热路径**。审计动作 `llm.catalog`(`ok`/`stale`/`error`)。详见 §2「模型配置契约(v0.53)」。② **`models.json` 组级新增三键**(可热更新,`GET`/`POST /api/v1/admin/llm` 已同步读写;**未声明时该键不出现**):`reasoning: {kind}` 声明该组的推理开关**形状**;`personaModels: {personaId: modelId}` 与 `personaProviders: {personaId: "openai"|"anthropic"}` 是**角色级模型/协议路由**(「角色」= 该回合答话的 persona,不是消息作者 role)。**校验在 `Group.Validate`**:非法 `kind`(只认 `auto`/`deepseek`/`openai`/`vllm`/`dashscope`)、anthropic 组上声明 openai 系形状、`personaProviders` 的非法协议名、空 persona id/空值 → **写端点回 `400 bad_request` 且不落盘**;persona 表键值会 trim,**trim 后撞键直接报错**(不静默丢条目)。**请求语义**:三键缺省 = 保留已存值(`reasoning` 整块给出即整块替换,persona 表给出即**整表替换**——否则面板无法删除一条已存路由);**响应语义**:每组的 `reasoning` **恒下发**(`kind:""` = 未声明形状,不是「后端没报告」),`personaModels`/`personaProviders` 为空则省略;顶层新增 **`reasoningKinds`** 与 **`personaProviderOptions`** 两个取值域数组(由后端常量直接生成,面板无需硬编码枚举)。**回落**:未绑定/未知 persona → 该组默认模型;`personaProviders` 声明的协议在 `models.json` 里没有对应组 → 记 WARN 并跳过(该角色仍走默认链,**不半建一条只会失败的链**)。③ **语义修正(重要,此前文档写反了)**:**`multimodal` = 主模型自身能否收图**(不是「本部署有没有视觉能力」);`LLM_VISION_MODEL` / `LLM_OCR_MODEL` **只配视觉/OCR 工具用的模型**(OCR 空则复用视觉模型,两者都空则复用主模型),与 `multimodal` **互不代表**(裁决 14.4.1)。④ **`.env` 新增 9 键**:`LLM_REASONING_KIND`(默认空 = `auto`;**只在无有效 `models.json`(走 env 合成组)时生效**,有 `models.json` 时以该组 `reasoning.kind` 为准)、`LLM_OCR_MODEL`、`LLM_LOCAL_ENABLED`(默认 `false`)/`LLM_LOCAL_PROVIDER`(默认 `openai`)/`LLM_LOCAL_BASE_URL`/`LLM_LOCAL_API_KEY`/`LLM_LOCAL_MODEL`(本地第三级:`Enabled` + `BaseURL` + `Model` 三者齐备才成为链尾一步,只开开关则 WARN 并跳过)、`CAPACITY_L3_ENABLED`(默认 `false`)/`CAPACITY_L3_CACHE_TTL`(默认 `5m`)。⑤ **内置工具新增 `image-crop`**:按区域裁切图片(`image` + `x`/`y`/`width`/`height`,0 基像素、区域须完整落在图内)→ PNG data URI + 裁切尺寸 + 源图尺寸;供**大图被上游压缩后分区精读**(design/06 §3)。上限:源图 ≤50MiB、区域 ≤16MP、载荷 ≤8MiB(超出**报错**,不截断);源可为本地路径(file 工具同一沙箱)/ http(s) URL(同 SSRF 守卫)/ base64 data URI。**策略默认 owner-only**(`tool_policies` 无行时 fail-closed)。⑥ **推理开关按 provider 形状下发(替换 v0.41 的「只 WARN 不发」)**——形状由 `reasoning.kind` 声明(未声明 = `auto`:按模型名/baseURL 推断厂商,**形状有歧义就不下发**),字段名映射表:
| 形状 `kind` | 线上字段 | 说明 |
|---|---|---|
| `deepseek` | 顶层 `thinking:{type:"enabled"\|"disabled"}` + `reasoning_effort` | 思考**默认开**,「不传」≠「关」:要关必须显式 `disabled` 或 `effort:"none"`;`none` 胜过 `enable:true`(关思考优先);`budget_tokens` 在该协议上无对应字段,记 WARN 丢弃 |
| `openai` | 仅 `reasoning_effort` | 该系没有布尔思考开关;`thinking.enable` 记 WARN 丢弃(官方关推理路径 = `effort:"none"`) |
| `vllm` | 顶层 `chat_template_kwargs.enable_thinking` | 只有布尔开关,无 `reasoning_effort`;**`effort:"none"` 一律翻译成显式关思考**(不得降成任何强度档) |
| `dashscope` | 顶层 `enable_thinking` | 同上;**只在显式声明该形状时下发**,绝不从模型名/BaseURL 推断 |
| `auto`/未声明 | 无 | `deepseek`/`openai` 可推断时按其形状下发;**形状有歧义(如 Qwen 可能由 vLLM 或 DashScope 承载)或厂商未知 → 一个字段都不发**,逐项 WARN 并给出配置指引 |
- `reasoning_effort` 校验:按**厂商白名单**(OpenAI 系 `none/minimal/low/medium/high/xhigh/max`;DeepSeek `none/low/high/max`)——非法值**丢弃并记 WARN,不再「降级到最近一档」**(降档会反转调用方意图);DeepSeek 的别名 `minimal→low`/`medium→high`/`xhigh→high`/`ultra→max` 归一化后发送并记一条 WARN。Anthropic 组继续走自己的 `thinking:{type,budget_tokens}`(`reasoning.kind` 在其上被拒)。
- 同批另含两条**对客户端无端点/帧/字段变更**的实现:**persona `awakening` 档位真生效**(`configs/personas/*.yaml` 的 `awakening` 块归一化为 `quiet|awake|full` 并进 capacity 决策;非法档位/越界阈值载入期报错、块缺席 = 无覆盖继续走全局默认,design/14 §3)、**工具越出本轮允许集的软判并回**(越界调用**仍然执行**——能力≠边界;结果里带 `not_allowed` 提示回灌模型,并记审计 `tool.reject.not_allowed`,design/14 §4)。`capacity_awaken` / `approval_notice` 等既有帧形状**一字未动**。
⑦ **向后兼容**:新端点、新组键、新 `.env` 键、新工具都是**纯增量**;既有端点 / WS 帧 / 字段形状**无破坏性变更**(`GET /admin/llm` 只多出 `reasoning`/`personaModels`/`personaProviders`/`reasoningKinds`/`personaProviderOptions` 这些新键,消费旧键的客户端不受影响)。**未做(诚实边界)**:DashScope 形状的 `auto` 推断(需显式声明 `reasoning.kind=dashscope`)、Qwen 的 `/think` 前导消息软开关(与 design 口径冲突,本批未实现)。⑧ **实现落点**:目录 `internal/services/gateway/admin_llm_catalog{,_cache,_source}.go` + 路由 `routes.go`;组键与读侧 `internal/config/runtimeconfig/models.go`(`ReasoningOptions`/`PersonaModels`/`PersonaProviders`/`Validate`)、写读端点 `internal/services/gateway/admin_llm_provider.go` + `admin_write_llm_channels.go` + `admin.go`/`admin_query.go`;形状下发 `internal/runtime/model/reasoning_shape.go`(+ `reasoning.go` 的厂商白名单/别名、`openai_encode.go` 的线上字段);per-persona 路由 `internal/runtime/model/{router,persona_stack,runtime}.go`;本地第三级 `internal/runtime/model/factory.go`(`localChainStep`)+ `internal/config/config.go`;capacity L3 `internal/runtime/capacity/{capacity,classifier,l3}.go`;awakening `internal/persona/loader/awakening.go`;软判 `internal/runtime/tool/allowset.go` + `internal/runtime/loop/tool_policy.go`;工具 `plugins/tools/io/crop.go`。对应提交:`63e5b70`(目录端点)、`fa33c79`(per-persona 路由 / 本地三级 / L3 / OCR 独立模型 / image-crop)、`38b4527`(工具软判)、`71fefe5`(persona awakening)、`8890c30`(按形状下发推理开关 + per-persona 提供商链)。
- `v0.52`(**阶段 4 三组落地:工具策略落盘 + 主代理代审小字 + 流程图节点参数摘要**,依据 design/11 §3/§4/§8(裁决 1.3 / 10.4.2 / 13.4.3)与 design/19 §1/§1.1(裁决 11.4.2 / 11.4.3 / 13.4.1),2026-10-04 落实):① **工具策略落盘**:`allowInGroup` / `allowInStranger` 的载体改为 SQLite 表 **`tool_policies`**(迁移 `0012_tool_policy`,唯一索引 `idx_tool_policies_name` 承载 upsert 冲突目标),`POST /api/v1/admin/plugins/policy` 从此**重启保留**——不再是「面板保存成功、一重启就复位」的假持久化(见 §2 行 5)。**写入路径先落库、后改内存**(`registry.SetPolicy` → 落盘成功才改内存并记入快照):**落盘失败则内存保持旧值并返回错误**,不会出现「面板说成功、内存已改、库里没有」的分叉;启动时 `AttachPolicyStore` 立即 Load 全量策略套到**已注册**能力上,此后 `Register` 进来的能力(MCP 热装、插件刷新)同样套上落盘值;**没有行的能力按默认(两布尔均 false = owner-only)处理**(fail-closed)。策略存储不可用/加载失败时**保持最严默认并记 WARN**,不静默降级成内存态、也不假装读出了策略。**对客户端无端点、无字段形状变更**。② **新增下行帧 `approval_notice`**(§3.11,design/11 §4):loop 的工具准入门(`ToolLoopHooks.CallAdmit`)判定某次调用属敏感操作(执行命令 / 修改文件 / 网络**写**方法)→ 经主代理代审后广播 `{type:"approval_notice", sessionId, data:{actor, tool, op, detail, explain, allowed, at}}`;`explain` 是**后端产出的固定文案**「<actor> 想要干什么,我审查后批准了」/「…我审查后拒绝了」(客户端**原样渲染**,不要自拼),`detail` 为动作摘要(工具 + 关键参数,目标部分截断到 80 rune),`allowed=false` 表示该敏感调用**被拦截未执行**(无代审者 / 代审报错同样 fail-closed 拦截,小字同为「拒绝了」)。**非敏感调用不产生该帧**;**子代理的敏感调用归属到派生它的父会话**(与 §3.10 同口径),小字因此落在主人正在看的那个会话里,而不是一个没人看的子代理 id 下。**审计**:每次代审在 `audit_logs` 落一行(批准与拒绝**各一行**)——`who`=角色名、`what`=`approval.<工具名>`、`result`=`granted|denied`,查询走既有 `GET /api/v1/admin/observability/audit` 与 `GET /api/v1/admin/notifications`。**角色门**:内容帧 → **仅 `owner` + `admin`**(fail-closed 默认,未扩那 7 个状态机信号白名单)。**前端已渲染**(`frontend/web/src/lib/ws/handlers/approval.ts` + `frontend/web/src/components/status.tsx` 的 `ApprovalNoticeHint`):按同一会话过滤规则把 `explain` 作为聊天流里的一行小字、`detail` 作副文本。**向后兼容**:纯新增帧,不改既有帧 / 端点 / 鉴权,老客户端按未知帧忽略即可。③ **`graph_update` 节点 `props` 新增四类可选键**(裁决 11.4.2 / 11.4.3,见 §3.5):**旁路节点的 span 首次真进图**——`memory.recall` / `memory.write` / `guardrail.input` / `guardrail.output` / `orchestration.spawn` 现在都由**真实生产者**产生,`kind` 因此会实际出现 `memory` / `guardrail` / `orchestration`;tool 节点另带 `args_keys`(该次调用的**全部参数名**)与 `args_summary`(**仅放行表内、且非密钥类**的键才给值,**密钥类键连值都不给**,单值 ≤**80 rune**、最多 **24** 条);旁路节点只带元数据(`action` / `scope` / `hits` / `bytes` / `count` / `failed` / `allow` / `subagent` / `elapsed_ms`),**不含记忆正文、不含提示词、不含密钥**。值在**发布前已脱敏**(凭据形状 → `[redacted]`、长文本截断),前端对未放行的键显示「值未展示」。**帧名、事件名、顶层字段集一字未动**——以上只是节点 `props` 内的**纯增量可选键**。④ **本版未做 / 诚实边界**:**代审是「记录并批准」**——「敏感与否」与「是否放行」仍由上层策略层判定,`orchestration/audit.Approver` 只做校验、记录、叙述并返回结果模型,**不构成新的权限边界**(身份/权限判定始终来自 channel 层 `actor` 元数据 + 白名单 + 策略引擎);**`agent.RunStep`(静音 / 非流式内部流程,如 backthink)的输出侧护栏仍同步内联**(其裁决是 `StepResult` 的一部分、不落在主会话关键路径上),只有**主会话流式关键路径**的输出侧护栏改为**后台异步**(`loop.checkOutputAsync`,裁决 13.4.1:真并发、不阻塞回合,拦截仍写审计留痕;**输入侧前置门依旧同步拦下**)。⑤ **实现落点**:策略落盘 `internal/capability/registry/policy_store.go` + `registry.go` 的 `SetPolicy`、迁移 `internal/db/migrations/0012_tool_poLine truncated
- `v0.51`(**后台子代理结果登 WS 帧 + 回填父会话**:新增下行帧 `subagent_result`;依据 design/05 §9「子代理完成 → 结果回填主会话并提示用户」,裁决 02.4.3,2026-10-04 落实):① **问题**:`subagent_spawn` 的批次前台只等 2 秒(design/04 §8.5),转后台后结算时编排层已把合并结果发布到 bus 主题 `subagent.result`,但 gateway 的中继**不认这个 subject**(既非 `ws.*`,也不是已登记的 4 段广播形态 `cyrene.agent.*.event`)⇒ 结果**永远到不了前台**,前端唯一可见的是逐代理进度帧 `subagent_event`(「跑起来了/跑完了」,但**拿不到结论**)。② **新增帧 `subagent_result`**:`{type:"subagent_result", sessionId, data:{sessionId, status:"done", summary, agents:[{id, goal, role?, mode?, status, summary?, text?, steps?}]}}`——`data` 与派发工具返回给模型的结果**同形同值**(同一处投影、同一处 4000 字截断,`text` 即子代理的结论正文);**帧壳 `sessionId` = 派生该批次的父会话**,客户端据此把结果归到**那个会话**的实时流(多会话并发不串话),而不是当成一张全局帧。详见 §3.10。③ **回填边界(诚实说明)**:本版把「结果可见」做到了**会话作用域的实时帧**——结果现在真的到达父会话的 WS 流(此前被中继静默丢弃)。**未做**:不落库(父会话的持久化转录里**没有**新增一条消息,重连/回放不会补发它),也**未**经 design/15 的会话消息编排器排队续接;**现有 Web 前端也没有该帧的 handler**(按未知帧忽略,故今天的可见效果是「帧到达 owner/admin 连接」而非「聊天气泡里出现结果」)。REST/历史接口**逐条不变**:`GET /sessions/{id}/messages` 不会多出这条结果。④ **角色门(fail-closed)**:新帧属**内容帧** → **只发 `owner` + `admin`**,`viewer` 收不到;**未**扩那 7 个状态机信号白名单(design/11 §7.1,白名单要进须先在设计登记「不含内容」的理由)。⑤ **向后兼容**:**纯新增帧**——老客户端按未知帧忽略即可;不改任何既有帧的类型/字段、不改端点、不改鉴权;`owner`/`admin` 只是多收一帧。⑥ **实现落点**:生产者 `internal/runtime/orchestration/spawntool.go`(`SubagentResultEvent` 载荷带上派发回合的父会话);中继 `internal/services/gateway/ws_bridge.go`(`bridgePatterns` 订阅该 subject + `normalizeEvent` 专用成形:帧壳 `sessionId` 取自载荷父会话;载荷形状不符预期时退回通用映射,两路都只发 `owner`+`admin`)。⑦ **验证**:进程内两端各一条——编排侧断言后台批次发布的载荷**类型/键集/父会话/逐代理结果**;gateway 侧**真 bus Publish → 真 `/ws/chat` 连接**(真 JWT × `owner`/`admin`/`viewer`)断言 `owner` 与 `admin` 各收到**恰好 1 帧** `subagent_result`(帧壳 `sessionId` = 父会话,`data` 的汇总与逐代理字段逐项对得上)+ 1 帧哨兵状态信号,而 `viewer` **只**收到哨兵(收不到结果帧)。**变异验证**:① 把 `orchestration.SubagentResultSubject` 从 `bridgePatterns` 去掉 → 「owner 收到 `subagent_result`」必须 FAIL;② 把 `subagent_result` 加进 `frameReachesAll` → 「`viewer` 收不到」必须 FAIL(两条均已跑,还原后复绿)。⑧ **同版另含管理面(D 组,2026-10-04 落实,与上列同属 v0.51 一版)**:**① `X-API-Key` 真正接线**(裁决 15.4.2)——`Authorize` 之上叠加 key 分支,管理/用户侧已认证端点同时接受该头,签发出去的 key **真的能用**;**且 key 一律超管、取消 role 分级**(裁决 15.4.3,主人原话「key 始终给超级管理员权限」):签发端点**不再读 `role` 请求体**、响应恒 `role:"owner"`、任何有效 key 鉴权后即 `owner`。**fail-closed**:请求带该头时**只走 key 分支**,未知/已吊销/空值(或 key 表未注入)一律 `401`,**不回落 Bearer**;不带该头时 Bearer 路径**一字不变**;**WS 握手仍只认 Bearer**。**破坏性边界**:此前文档写的 `{"role":"admin|viewer"}` 请求体自本版起**被忽略**——若曾有客户端指望签一把只读 key,它拿到的其实是 owner key(这正是裁决要的效果:取消分级,不给「以为只读、实则超管」的错觉)。**② 新增主人档案读写 `GET`/`PUT /api/v1/admin/owner-profile`**(裁决 10.4.5,**owner-only**——这份档案决定谁能进 owner 车道,故连读都不给 `admin`;写带审计 `owner.profile.update`,读不审计;全新安装 `GET` 回 `200` + 空白档案而非 `404`):消灭前端**假保存**——面板的「保存」从此真的落盘 `configs/users/owner.yaml`(0600 原子写)并**热替换活档案**,渠道层 actor 判定立即生效、无需重启;校验失败(`idLine truncated
- `v0.50`(**事件批:流式帧的结构化补齐**——`tool_execution` 三事件 + `streamId` + `turn.start/end` + `graph_update` 三范围口径;依据 design/04 §1.1(裁决 01.4.1 / 01.4.2 / 01.4.3 / 03.4.4)与裁决 01.4.5(design/20 §2),2026-10-04 落实):① **`tool_execution` 拆三事件(破坏性替换)**:旧单帧 `tool_execution.update {id,index,delta}`(`delta` 为 `"start:工具名"` / `"end:error:工具名"` 拼串)**不再由 loop 产生**,替换为 `tool_execution.start`(`{tool, phase:"running", id?}`,进入执行)/ `tool_execution.update`(`{tool, phase:"running", delta?, id?}`,运行中进度,可多帧)/ `tool_execution.end`(`{tool, phase:"done"|"error", id?}`,完成或失败)——**phase 与工具名是结构化字段,不再塞进 `delta`**;`id` 复用 provider 工具调用 id(可关联 `tool_call.*` 帧,无 id 时省略该键)。**迁移**:按旧 `delta` 拼串解析的客户端必须改读 `phase`/`tool` 字段;`tool_call.*` 帧形状不变。**诚实边界**:start/end 由 loop 在每次工具执行的首尾真实发出;`update` 的 wire 映射已实现并有单测锁定,但工具分发器尚无中途进度上报源,**loop 暂不产出 update 帧**(未来接入进度上报无需再改契约)。② **`streamId`(新增可选字段,位于模型流帧的 `data` 内)**:`message.*` / `thinking.*` / `tool_call.*` 帧的 `data` 带 `streamId`——loop 在**每次模型流**(一次 ChatStream 调用)开始时生成,同流多帧共享,多步工具轮的下一个模型调用取新 id;与帧壳 `turnId`(整轮恒定)分工:`turnId` = 哪轮回答,`streamId` = 这轮里的哪条流式消息。`tool_execution.*` / `turn.*` 帧不带。③ **`turn.start` / `turn.end`(新帧,无 `data` 载荷)**:loop 在 Run 起止发出、经 ws_bridge 下行(`model.StreamEvent` 新 kind → hub `broadcastStream` 映射);帧壳 `sessionId`/`turnId`/`seq` 即边界信号;`turn.end` 覆盖所有出口(最终答案 / 工具循环安全网终止 / 预算耗尽 / 步骤出错);回合终态仍由 `stream_end` 携带(分工不重叠);被静音的内部流程(backthink / 子代理)不发出。④ **`graph_update` 三范围口径**(01.4.5,随实现事实补记):发布范围为 `web` / `web:<uuid>` / `sess-backthink` 三类(此前文档只写「web 及其子会话」,漏了 backthink 思考链路图这一实现事实;实现侧 `cmd/cyrene/wire_runtime.go` 的 allow 谓词本就如此,本批零代码变更);并补记 **tool 节点 `status` 由 phase 驱动**(与 §3.1 `tool_execution.*` 的 `phase` 同枚举同决策:`trace.FlowStatusForPhase` 消费 tool span 上的 phase,缺省回落 span 终态)。⑤ **角色门**:本批新帧(`tool_execution.start/end`、`turn.start/end`)全部落 v0.48 下行门的「仅 `owner` + `admin`」档(fail-closed 默认,白名单未扩);`viewer` 行为不变。⑥ **向后兼容总结**:新增帧与新增键(`streamId`、`data.tool`、`data.phase`)可被老客户端安全忽略;唯一破坏点是旧 `tool_execution.update` 单帧消失(见 ①);**现有 Web 前端未消费 `tool_execution.*` / `turn.*` / `streamId`,本批对它零影响**。⑦ **验证**:loop 包断言三事件顺序/phase/工具名/id/Delta 为空、同流共享 streamId 跨流相异、turn 边界成对且覆盖错误出口、静音 run 零事件、flow 图 tool 节点 running→done/error 与 phase 一致;gateway 包真 WS 帧端到端(真 loop + 真 dispatcher + bus → relay → broadcastStream):按 seq 排序断言完整帧序 `turn.start → message.delta → tool_execution.start → tool_execution.end → message.delta → turn.end`(+`stream_end`)、`streamId` 每流独立、`tool_execution` 帧无 `delta`/`streamId` 键、turn 帧无载荷;帧门矩阵补 5 行(三事件 + turn 两帧 × 三角色)。**变异验证**:去掉 loop 的 `ev.StreamID = streamID` 透传 → 「streamId 每流独立/前缀」断言 FAIL(已跑,还原后复绿)。
- `v0.49`(**WS 握手回放 + 上行帧的角色门**:把 design/11 §7.1 下行门落地时查出的**同一类缺口的另两处**一并收口——**对 `viewer` 继续收紧**,**`owner` / `admin` 行为不变**,**无端点、无字段形状变更**;依据 design/11 §7.2,2026-10-03 落实):① **问题**:本质与 §7.1 同一个——`viewer` 是**只读监控档**,却能通过 WS 拿到内容、甚至发起写入。(a) 握手 `history` 回放**对任意角色照发**(回放主会话最近 50 条**消息正文**,`thinking` 已剥离但正文本身即主人的对话),`viewer` 一连上就把对话拿走了;(b) `route` 的**上行分发完全没有角色检查**,`viewer` 发 `chat` 能**真的驱动模型并把消息落库**(只读档拿到写权限),`subagent_ask` / `task_output_query` 的应答是**定向帧**(`enqueueJSON`)因此**绕过 §7.1 的下行门**,把子代理上下文 / 任务输出直接回给 `viewer`。② **规则(fail-closed,默认拒)**:**A** 握手 `history` 回放**仅 `owner` + `admin`**(`ownerAdminClaims`,与主动推送同一个判据);**B** 非 `owner`/`admin` 连接发来的**任何**上行 JSON 帧一律拒绝——**定向**(只回发送者)回 `{type:"error",data:{code:"forbidden"}}`,**不广播、不驱动模型、不落库、不返回任何内容**;被拒帧:`chat` / `interrupt` / `subagent_ask` / `task_output_query` / `voice_input` / `mode_switch`(**默认拒**,未登记的一律拒)。**协议级帧不受影响**:WS ping/pong 与正常 close 照常(否则连接会掉),被拒帧也不关连接。③ **实现**:A 收口在**唯一**发出回放的 `hub.replayHistory` 顶部(**不放调用点**,没有第二个调用者能绕过);B 收口在 `wsConn.route` 的**第一条语句**,且写成「**默认拒**」而**不是被拒类型黑名单**——新加的上行帧类型自动被拒,漏一处也不会放行。④ **合口径(与 §7.1 一起读)**:`viewer` = **可以连接、可以读 12 条只读监控 REST(v0.40)、可以收 7 个状态机信号帧(v0.48)**;**除此以外 WS 上拿不到任何内容,也发不出任何指令**。⑤ **未来放宽的要求**:将来若真需要 `viewer` 上行(如手机端以 viewer 凭据做只读订阅之外的交互),**必须先在 design/11 §7.2 给它开口并登记白名单**;代码里那把「默认拒」必须**改造成登记表**,**不得直接删掉**。⑥ **测试与变异验证**:真 WS 连接 + 真 JWT(复用 v0.48 的测试装置)。A:`viewer` 连接**收不到** `history` 回放,`owner` / `admin` **收得到**(且回放里 `thinking` 仍为空);B:`viewer` 依次发 `chat` / `interrupt` / `subagent_ask` / `task_output_query` / `voice_input` / `mode_switch`,**每次都收到定向 `forbidden` error**,且其后**无任何内容帧**(对 `subagent_ask` / `task_output_query` 尤其断言「不得收到定向的 `subagent_event` / `task_output`」),全程**计数 runner = 0 且落库 = 0**;另一条 `viewer` 连接作为观察者**不收**这些 error(证明是**定向**而非广播);回归:`owner` 发同一个 `chat` 仍然**真的驱动 runner(= 1)**并把 user + assistant 两条**落库**;协议级:被拒后 ping/pong 往返与正常 close(服务端注销连接)**均正常**。**变异验证**:① 去掉 A 的门 → `viewer` 立刻收到 `history`(A 断言 FAIL,已跑);② 去掉 B 的门 → `viewer` 的 `chat` 驱动 runner **1 次**并**落库 2 条**、`subagent_ask` 直接回 `subagent_event`(B 断言 FAIL,已跑)。⑦ **影响面**:`owner` / `admin` **逐字不变**;`/ws/chat` **连接鉴权不变**(`viewer` 仍可连);无新端点、无字段形状变更;今天**没有 `viewer` 客户端在跑**(`/auth/login` 仍写死签发 `RoleOwner`,除测试外无 viewer 凭据),故线上实际影响为 0。
- `v0.48`(**WS 下行帧的角色门**:`/ws/chat` 的下行帧改为**按帧类型分档**——**对 `viewer` 是收紧**(此前它连上就能收到角色回复正文与推理内容,现在收不到),**`owner` / `admin` 行为不变**,**无端点、无字段形状变更**;依据 design/11 §7.1,主人 2026-10-03 拍板「按建议收紧」):① **问题**:hub 的下行是**全连接广播**,而 `/ws/chat` 只验 JWT(任意角色可连),于是 `viewer` 连接会收到 `message.*` / `thinking.*` / `tool_call.*`(**含角色回复正文与推理内容**)、`message_persisted` / `message_edited` / `message_deleted`、`graph_update`、`mood`、`subagent_event` / `task_output` / `device_update` / `reminder` 等——与 design/19 §1/§3「flow 可视化仅 owner/admin」直接冲突,也与 `GET /sessions/{id}/messages` 的 owner/admin 档位不一致(**同一批内容从 WS 旁路漏出**)。② **规则(fail-closed,默认拒)**:**只有白名单里的 7 个「状态机信号」**(`session_busy` / `pending_ack` / `pending_drain` / `session_idle` / `session_interrupted` / `pending_full` / `session_read`,**均不含任何内容**)发给**所有已认证连接**(含 `viewer`);**其余全部**(`stream_chunk` / `stream_end` / `thinking`、`message_persisted` / `message_edited` / `message_deleted` / `channel_persisted`、`graph_update` / `subagent_event` / `subagent.result` / `task_output` / `task_done` / `tool_used` / `capacity_awaken` / `mode_switch`、`mood` / `device_update` / `reminder`)**只发 `owner` + `admin`**;**未登记的新帧类型默认只发 owner/admin**(要进白名单须先在 design/11 §7.1 登记理由「不含内容」)。③ **实现**:收口为**唯一一处**「按帧类型分类后投递」的入口(`internal/services/gateway/ws_frame_scope.go` + `hub.broadcast`),**不给每个调用点加开关**(那正是「漏一处即泄漏」的写法)——流式 `message.*` / `thinking.*` / `tool_call.*`(`broadcastStream`)、`stream_chunk` / `stream_end`(`runTurn` / `streamChat`)、`message_persisted` / `message_edited` / `message_deleted`、`mood` / `capacity_awaken`、编排状态信号(`StateSink`)、`graph_update` / `subagent_event` / `device_update` / `reminder`(`relay` 归一化)**全部经同一入口**,不存在第二条投递路径。④ **语义澄清(不是数据从此不可见)**:这条门管「**谁该被主动推送**」——`viewer` 的 **12 条只读监控端点**(v0.40 `viewerAuth`,含 `GET /personas` 的角色级 `mood`)**逐条照旧**;`/ws/chat` 的**连接鉴权不变**(`viewer` 仍可连,只是收不到上列帧);**定向回请求连接的帧**(`voice_output` / `error` / `subagent_ask`、`task_output_query` 的应答)不进这道门、行为不变。⑤ **测试与变异验证**:真 WS 连接 + 真角色 token 的**逐帧表驱动**断言(design/11 §7.1 表里**每一个**帧类型 × 三种角色:`owner` / `admin` 两类都收得到,`viewer` 只收白名单)+ 帧类型 × 角色的直接矩阵 + 「白名单精确等于 7 个状态信号」与「未登记类型默认拒」的锁定;变异验证:把 `graph_update` 改回全广播 → 「viewer 收不到 `graph_update`」必须 FAIL(已跑,见提交说明),把 `session_idle` 改走受限路径 → 「viewer 收得到 `session_idle`」必须 FAIL(已跑)。⑥ **已知残留(本次未动,留待主人裁决)**:`/ws/chat` 的**上行**仍未按角色授权(`viewer` 仍可发 `chat` / `interrupt` / `subagent_ask` / `task_output_query` / `voice_input`,其应答是**定向**帧、不经过本门),且握手时的 `history` 回放仍对任意角色照发(`thinking` 已剥离)——二者都在 design/11 §7.1 本次门的作用域之外。**(本残留已由 `v0.49` 收口:上行帧默认拒、握手回放仅 `owner`/`admin`,见上一条变更记录与 §3 顶部第二把角色门。)**
- `v0.47`(**`graph_update` 子代理泳道根节点新增可选 `props.persona`**,对客户端**纯增量**;实现依据 design/22 §8-2「跨角色派发 = 把其他角色以子代理形式派出去」):① **`subagent_spawn` 指定目标角色派发时**,该子代理在父会话图里的**分支根节点** `props` 多一个 `persona` 键(值 = 目标角色 id),供流程图显示"这个子代理是以谁的身份在替班",而不只是子代理 id。② **不指定角色派发时该键不出现**:帧形状与 v0.46 逐字一致,老客户端忽略未知键即可。③ **无新端点、无字段改名、无鉴权变更**;`subagent_event` 帧**未新增字段**(其 `role` 会等于目标角色——既有字段的取值语义变化,非形状变更)。④ **`subagent_spawn` 的 `persona` 参数不在本文档契约内**:那是给**模型**用的工具 schema(工具层以「进程能否按该角色运行」为判据,未知角色整批拒绝且不产生任何子代理),不是给客户端的 HTTP/WS 契约。⑤ **验证**:进程内 e2e 断言「子代理实际收到的 system prompt = 目标角色 instructions」+「其记忆写入落在**目标角色**域」+「未知角色整批拒绝」+「**省略 `persona` 时结果 JSON 键集与旧版完全相同**」;变异验证(把目标角色换回父/默认角色、关掉角色校验)对应断言均 FAIL。
- `v0.46`(**会话角色不可变:`PATCH /sessions/{id}` 拒绝 `personaId`**,对客户端**是破坏性变更**——按 v0.42–v0.45 旧文实现、依赖「PATCH 换角色」的客户端必须改为**新建会话**选角;design/22 §8-1 主人拍板定稿):① **`PATCH /api/v1/sessions/{id}` 的 `personaId` 改为「**出现即 `400`**」**:只要请求体里**有 `personaId` 键**(任何取值:`"yuki"` / `""` / `null` / 未知 id / 空白串)就回 `400 bad_request`,**错误消息点名 `personaId`** 并说明「会话角色在创建时确定、之后不可变,要换角色请新开会话」。**实现取「明确拒绝」而非静默忽略或 200 空操作**:静默忽略会让客户端以为绑定已改。② **顺序保证**:该检查在解析完 body 后**立即**执行、**先于**会话存在性检查 —— **未知会话 + `personaId` 回 `400`(不是 `404`)**;`title` + `personaId` 同传也整体 `400`,**其余字段一个都不落库**。③ **不变的部分**:`title` / `pinned` 的语义、边界(≤80 rune 等)与响应形状**逐字不变**;`POST /api/v1/sessions {personaId}`(**创建时**绑定、未知角色 `400`、非空回显)**完全不变**;`GET /sessions` 与消息 DTO 的只读 `personaId` 不变。④ **移除的路径**:PATCH 上再也没有 `{personaId}` 成功响应、也没有「不支持角色绑定的 HistoryStore → `501`」这条路(网关的 `Server.setSessionPersona` 及其中间层随之删除;`history.Store.SetSessionPersona` 作为存储能力保留)。⑤ **前端同步**:用户端**移除**「会话行 → 切换角色」菜单与面板「挂角色」下拉,**保留**「新建会话时选角色」;管理面板「角色」列改为**只读**展示创建时绑定的角色。⑥ **测试与变异验证**:新增断言覆盖「键出现即 400(六种取值)+ 绑定未变 + 未知会话仍 400 + `title`/`pinned` 不变」,并把「静默忽略」与「改回写绑定」两种反向实现各跑一次(两处断言均必须 FAIL,见提交说明)。
- `v0.45`(**渠道 `personaId` 三态修订:修「省略 = 解绑」会静默清空绑定**,对客户端**无字段/端点形状变更**,但**语义收紧**——按 v0.44 旧文实现的客户端若靠「省略 `personaId`」来解绑,必须改为**显式传 `""`**;design/22 §2 方案 b):① **`PUT /api/v1/admin/channels/{platform}/config` 的 `personaId` 改为三态**:**键省略(或 JSON `null`)= 保留该账号已存绑定**(不动);**显式 `""` = 解绑**(写盘时该账号不带 `personaId` 键);**非空 id = 绑定该角色**;**未知角色 id 仍 `400 bad_request`**(且**只校验本次真的传了值**的情形——省略时继承的已存绑定在当初写入时已校验)。② **修的问题(数据安全)**:管理面板只在主人**动过**角色下拉时才发这个键(前端 `channelPersona.ts` / `channelPersonaRequest`),而 v0.44 后端把「键不存在」也当成解绑 ——**主人打开一个已绑角色的账号、不动下拉、只改 token 并保存 → 该账号的角色绑定被静默清空**(无提示、无审计可见的理由),两端语义相反。现在后端的「省略」与前端一致 = 保留。③ **实现**:请求 DTO 的该字段由 `string` 变 `*string`(指针区分「省略」与「显式空串」),应用逻辑在既有「读当前配置(channels.json)→ 写入该平台账号 → 落盘 → `bridge.Reload(platform)`」路径内按三态取值(**写入路径自 v0.54 起改为按 id 打补丁 `PatchChannel`**)(省略时从**同平台同 id** 的已存账号继承,故保存一个账号不会动到同平台其它账号的绑定);**无新端点、无新字段、无响应形状变更**。④ **`GET /api/v1/admin/channels` 每账号的 `personaId` 回显不变**(v0.44 新增,非敏感、不掩码),面板据此回填角色下拉。⑤ **测试**:省略 → 绑定保留、显式 `""` → 清除、非空 id → 绑定、未知 id → `400`(含「省略时改其它字段后读回确认」与「同平台多账号互不影响」),并把「省略 = 解绑」作为变异反向验证(该断言必须失败)。
- `v0.44`(**渠道级选角 + 「谁说的」进模型上下文**,对客户端**新增两个可选字段**,向后兼容;design/22 批次 2 后端 = §2 裁决规则 + §4.4 + §7-3/§7-4/§7-5):① **`PUT /api/v1/admin/channels/{platform}/config` body 新增可选 `personaId`**(渠道级绑定,design/22 §2 方案 b):=该账号答话时的角色 id;**未知角色 `400 bad_request`**,~~**省略/`""` = 解绑**(写盘时该账号不带 `personaId` 键)~~(**本条的「省略」一项已由 v0.45 修订为「保留已存绑定」;只有显式 `""` 才解绑**,见上);沿用既有「读当前配置 → 写入该平台账号 → 落盘 `channels.json`(0600) → `bridge.Reload(platform)` 热更新」路径(**写入路径自 v0.54 起改为按 id 打补丁 `PatchChannel`**),保存即生效(下一批入站消息),**无新端点**。② **`GET /api/v1/admin/channels` 每账号新增可选 `personaId`**(非敏感,不掩码),供面板回填。③ **角色解析优先级明确化(对客户端语义澄清,无形状变更)**:**会话绑定 > 渠道绑定 > 全局默认角色**;渠道绑定**只**作用于该账号的**主人私聊**且仅当主会话自身未绑角色(§7-3:IM 主人私聊镜像随主会话角色);**群聊/陌生人隔离会话强制默认角色**(§7-4,更保守,仍只读)。④ **模型上下文新增角色归属标注(§7-5 的剩下一半)**:一回合上下文改为该会话**最近 20 条**持久化消息 + 本回合新消息(此前只带新消息本身),每条 assistant 历史消息按其说话角色出具 `[发言归属] 以下发言来自「<名字>」(<id>),不是你说的。` 独立 system 标注 + 一条 `[会话角色] …` 当前角色声明;**消息正文一字不改**(标注只走 system 消息,design/11 §10),**对客户端透明**(无新字段)。前端无需改动即可继续工作;面板可选用 ① 让每个 IM 账号挂角色。⑤ **向后兼容**:纯新增可选字段;未配置 `personaId` 的账号与不读新字段的客户端行为不变(渠道绑定缺省 = 全局默认角色,与 v0.43 完全一致)。
- `v0.43`(**人格读侧合并批:admin 回读 `thinking` + 用户侧角色级 `mood`**,对客户端**新增两个可选字段**,向后兼容;design/01 §2/§2.1、design/04 §3.2):① **`GET /api/v1/admin/personas` 每项新增可选 `thinking`**(`{budgetTokens,effort,showToUser,mode}`),与写侧同构、供面板回填编辑。**未配置的人格不带 `thinking` 键**(不是全零对象),与 `PUT` 的「省略 `thinking` = 保留已存」互为镜像。② **`showToUser` 三态原样回读**:**`null` = 未配置 → 保持现行为(thinking 帧照常推客户端)**,`true`/`false` = 显式开关;**服务端不把 `null` 拍平成 `false`**(否则面板会把「没配过」误显示为「关」,v0.41 的语义会被读侧丢掉)。③ **`GET /api/v1/personas` 每项新增 `mood`(角色级)**:= 该角色**当前情绪状态**(取值同 `GET /meta/enums` 的 `moods`;`""` = 暂无被跟踪情绪,不伪造)。**情绪跟随角色、与会话无关**——同一角色的所有会话看到同一 `mood`;客户端登录/加载角色目录时用它**播种心情徽标**(按角色 id 分桶),消除「刷新后徽标短暂为空」,随后仍以 WS `mood` 帧为准。**用户侧不暴露 `thinking`**(模型调用旋钮属管理侧)。④ **`effort` 的能力提示归服务端**:读端点只回读**已存配置**,**不承诺**该值在当前 provider 上生效——白名单校验/降级/WARN 仍在模型层(design/04 §3.2),客户端不得把「配了」呈现为「已启用」。⑤ **向后兼容**:纯新增字段;不读新字段的客户端行为逐字节不变(旧面板仍可编辑并保留已存 `thinking`)。
- `v0.42`(**多角色并存 + 按会话选角**,对客户端**新增可选字段/可选请求字段**,向后兼容;design/22 批次 1 = 后端会话级绑定):① **`chat_sessions` 新增 `persona_id TEXT NOT NULL DEFAULT ''`、`chat_messages` 新增 `persona_id TEXT NOT NULL DEFAULT ''`**(同一迁移 **0011**,down 可逆,**不回填**)。`''` **= 继承全局默认角色**(`owner.yaml:defaultPersona`),所以**既有一切行升级后行为逐字节不变**,且默认角色改名后这些行跟着变(design/22 §4.7/§7-6)。② **`GET /api/v1/sessions` 每项新增可选 `personaId`**(=会话绑定角色);**消息 DTO(REST `GET /sessions/{id}/messages`、WS `history` 回放、`message_persisted`/`message_edited` 广播)新增可选 `personaId`**(=**说话时的角色**,design/22 §7-5)。③ **`POST /api/v1/sessions` body 新增可选 `{personaId}`**(创建即绑定;未知角色 `400`;响应仅在非空时回显 `personaId`,故**省略时响应形状与 v0.41 完全一致**);~~**`PATCH /api/v1/sessions/{id}` body 新增可选 `{personaId}`**(重新绑定,**显式 `""` 解绑**,未知角色 `400`;只回显变更字段)~~(**本条的「PATCH 重新绑定/解绑」已由 v0.46 作废**:会话角色在创建时确定、之后不可变,PATCH 一旦收到 `personaId` 即回 `400`,见 §1 行 8 / §8-1)。④ **回合语义**:角色决定**系统提示词 / 记忆命名空间 / 情绪与好感度 / 思考预算**四者**同源**解析(design/22 §4.2);~~换角色**保留上下文**、**不动已存消息**,**下一回合**起生效~~(**已由 v0.46 作废**:会话内不再换角色——角色在创建时定死,`chat_messages.persona_id` 仍逐条记录"谁说的");**记忆口径 = 保持 persona 隔离**(design/22 §3):不同角色的会话互不浮现,同角色的多个会话互相可见。⑤ **请求级覆盖(每次发送临时指定角色)本批未实现**(design/22 §2 裁决规则 1 仍待批次 2/后续);**渠道级绑定(IM 账号/群默认角色)本批未实现**(design/22 §4.4 批次 2),故 IM 路径仍按默认角色解析。⑥ **契约内新增内部读接口**:网关经窄接口类型断言读取 `history.Store.SessionPersona/SetSessionPersona/NewSubSessionFor`(**v0.46 起**请求路径不再调用 `SetSessionPersona`——PATCH 的 persona 写路径已整体移除;「注入的 HistoryStore **不支持**角色绑定」只在 `POST /sessions` **显式传** `personaId` 时回 `501`,而非假成功)。
- `v0.41`(**人格 `thinking` 配置 + 推理旋钮按 provider 映射**,对客户端**新增一个可选请求字段**,向后兼容):① `POST /admin/personas` / `PUT /admin/personas/{id}` body 新增可选 **`thinking`** 对象 `{budgetTokens?,effort?,showToUser?,mode?}`(design/01 §2.1),原样写入并持久化到 `configs/personas/<id>.yaml` 的 `thinking` 节;非法 `mode`/负预算回 **`400 bad_request`**;**`PUT` 省略该对象时保留已存 `thinking`**(旧面板/脚本不会抹掉它);该字段只描述**呈现/资源**,不构成能力边界。② **`showToUser` 语义**:**缺省 = 照常把 `thinking.*` 流帧推给客户端**(现行为),显式 `false` = 仅落库/剥离语义不变、**不推帧**(展示与落库解耦)。③ **服务端推理映射**(对客户端透明,仅影响上游请求):Anthropic 系在 persona 配置 `budgetTokens`/`mode: on` 时发 `thinking:{type:"enabled",budget_tokens:N}`,并**保证 `budget_tokens + 正文留量 ≤ max_tokens`**(不足则抬高 `max_tokens` 并记 WARN,绝不把正文吃空);OpenAI 兼容系按**识别到的厂商白名单**校验 `reasoning_effort`(OpenAI 系 `none…max`、DeepSeek 仅 `low/high/max`),非法值~~**降级到最近一档并记 WARN**~~(**已由 v0.53 修订为「丢弃并记 WARN、不降档」**——降档会反转调用方意图,`none` = 关思考,绝不翻成任何强度档);~~**未知 provider/未识别模型、以及 Qwen 的 `enable_thinking` 硬开关(协议无统一 wire 形状)** 一律记 **WARN(含 provider/字段/取值)并保持请求不变**,绝不静默无效~~(**本条已由 v0.53 作废**:开关形状改由组键 `reasoning.kind` 显式声明并**真下发**,**只有形状未知/有歧义时才 WARN 且一个字段都不发**,见 v0.53 变更记录)。详见 design/04 §3.2。
- `v0.40`(**`viewer` 角色真正接线:只读监控档**,对客户端**收紧/放宽若干 admin 读的鉴权**,无端点/字段形状变更):此前 `viewer` 只是常量(`internal/auth` 的 `RoleViewer` 注释写着「只读 dashboard/logs/monitoring」)而**从未接线**——管理侧 24 条 GET 全走 owner/admin,`viewer` 一条都读不到(**全 403**)。本版新增第四道门 `viewerAuth = owner+admin+viewer`,并把 **12 条只读监控 GET** 从 owner/admin 放宽到 owner/admin/**viewer**:`GET /admin/personas`、`/admin/sessions`、`/admin/plugins`、`/admin/plugins/installed`、`/admin/channels`、`/admin/iot`、`/admin/automations`、`/admin/rag`、`/admin/observability/metrics`、`/admin/observability/trace`、`/admin/observability/logs`、`/admin/notifications`。**明确不放给 viewer**(含凭据获取路径、他人数据、完整消息/导出明文、审计流水,或 `admin` 也不给):`GET /admin/memories`、`/admin/llm`、`/admin/users`、`/admin/backthink`、`/admin/observability/audit`、`/admin/channels/messages`、`/admin/channels/{platform}/messages`、`/admin/sessions/{id}/export`、`POST /admin/rag/query`(这 9 条 owner/admin)以及其余 **35 条 owner-only**(全部写操作 + `login-qr`/`login-status`/`view`/`friends`/渠道连接与配置/`plugins/*`/`users/*`/`system/*`)。**匿名**:任意 admin 路由仍 `401`(fail-closed)。**向后兼容**:`owner`/`admin` 语义不变(放行集合是超集),仅 `viewer` 的可见面从「全 403」变为 12 条只读监控;不依赖 `viewer` 的客户端行为不变。完整分档见 §2.0「管理侧权限矩阵」。
- `v0.39`(**微信 iLink 会话被拒可见化 + 不再假 OK**,对客户端**新增两个可选字段、一处错误码**,向后兼容):① **`GET /api/v1/admin/channels` 每平台行新增可选 `needsRescan`(bool,`omitempty`)+ `lastError`(string,`omitempty`)**——仅当适配器**真的**要求重新授权时出现(今天只有微信 iLink 返回 `errcode=-14` 这一种),取值来自适配器新增的**可选能力接口** `RescanRequired() (bool, string)`,网关**经类型断言**读取(主 `PlatformAdapter` 契约**不变**;未实现该能力的平台字段**缺省不出现**)。用途:把「需要重新扫码」与「主人自己关掉的未连接」区分开——后者仅 `connected:false`。② **`POST /admin/channels/open`** 对「iLink 已拒绝该会话」回 **`409 session_rejected`**(message 明确要求用「扫码登录」重新绑定),**不再**吞掉错误回 200;`409 needs_login`(尚无 token,v0.8 起)不变。③ **适配器行为**:iLink `errcode=-14` 进入**显式被拒态**——`IsConnected()` 恒 `false`;`Connect()` 返回被拒错误(**不再**对被拒会话报成功);`HealthCheck` 区分「会话被拒」与「未连接」;并以 **`ERROR`** 级日志写明「本次会话已被 iLink 拒绝,需要重新扫码绑定」(只带 `bot_id` 等非敏感标识,**token 永不入日志**)。被拒态在读循环与发送两条路径**都会**置位,并仅在**扫码登录拿到新 token** 或**配置换了新 token**(保存 + 热重载)时清除。④ 扫码登录侧:被拒期间 `login-status` 报 **`failed` + 被拒原因**(**不再**假报 `confirmed`,否则面板会把已作废凭据当成功重新落盘);`login-qr` 仍可正常出码(**不**回 `already_logged_in`,否则重新扫码这条路被堵死);`get_bot_qrcode` 不再携带已被拒的 token(否则服务端以「已绑定」应答、**不**发新凭据)。**向后兼容**:只新增可选字段与一处更诚实的错误码;不读新字段、不处理 409 的客户端行为不变(此时面板显示「未连接」,仍可用「扫码登录」恢复)。**语义澄清(本次仅措辞,不改契约形状、不另起版本号——v0.39 尚未被任何客户端采用)**:iLink 的 bot token 是**长期凭据**(实测一个月前扫码得到的 token 仍可用),`errcode=-14` 表示 **iLink 侧拒绝/作废了该会话凭据**,**不是时间性过期**。详见 §2 行 6 与「微信 iLink『会话被拒』契约」。
- `v0.38`(**B4 客户端共享枚举权威表 + 未知值回落约定**,对客户端**新增一个只读 REST 端点**):新增 **`GET /api/v1/meta/enums`**(**任意已认证角色可读**,与 `GET /sessions` 同级;无 token `401`)→ `200 {data:{platforms,moods,modes,unknown}}`,每项 `{id,label}`:① **`platforms`**(6)= `web`/`kook`/`qq-obv11`/`weixin-ilink`/`telegram`/`webhook`——即各内置适配器 `Name()` + Web 主会话来源 `web`;② **`moods`**(5)= `calm`/`happy`/`excited`/`sad`/`angry`,与 persona 情绪状态机内置状态一致(该状态机**可扩展**,故非封闭集合);③ **`modes`**(9)= `chat`/`code`/`home`/`work`/`docs`/`voice`/`vision`/`agents`/`settings`(design/07 §1);④ **`unknown`** 恒 `"display-raw"` = **未知值回落约定机器可读**:客户端遇不在表中的取值**原样显示原始字符串**(**不丢弃、不报错、不占位符**),**服务端新增取值视为非破坏性变更**(forward-compatible)。**单一事实来源在 Go 侧**(`internal/contract/enums`),并有**反漂移测试**断言适配器 `Name()` / 情绪内置状态 / 前端九模式与三张表一致,漏登记即 CI FAIL。Web 侧镜像 `frontend/web/src/lib/enums.ts`(`labelFor` 未知值返回原值),顶栏/侧栏/消息气泡的「心情」标签已改用它渲染。**向后兼容**:仅新增端点,既有契约形状不变。详见 §1 行 15 与 §1.1。
- `v0.37`(**D4 未读状态 / 已读游标**,对客户端**新增一个语义字段、一个 REST 端点、一个下行事件**):① `chat_sessions` 新增 **`read_at INTEGER NOT NULL DEFAULT 0`**(迁移 **0010**,down 可逆;**毫秒 Unix 时间戳**,与 `chat_messages.created_at` **同单位**)。② **语义**:**会话级单一已读游标 + 单 owner 账号**——同一账号所有端共用一个游标,**读一处 = 全端已读**(不做 per-device/per-user 游标,与「同一账号所有浏览器都属于同一 owner」一致);`createdAt <= readAt` 的消息算已读,未读数 = `createdAt > readAt` 的条数。③ **`GET /api/v1/sessions` 每项新增恒返回的 `unreadCount`**(整数,0 = 无未读):与 `lastMessage` **同一条 SQL 派生**(关联子查询 `COUNT(*) WHERE session_id = s.id AND created_at > s.read_at`,走已有 `idx_chat_msgs_session_created`,**无 N+1、不新增索引**)。④ **新增 `POST /api/v1/sessions/{id}/read`**(owner/admin,与 `PATCH /sessions/{id}` 同鉴权):body `{before?}`(可选毫秒;缺省= 现在)→ `200 {data:{sessionId,readAt}}`;`before` 为 0/负数/非数字/body 非对象 → `400`,未知会话(含隔离 `sess-xxx`)→ `404`,未注入 HistoryStore → `501`。**游标只前进不后退**(`read_at = MAX(read_at, before)`),乱序/并发/重试请求幂等安全。⑤ **新增下行事件 `session_read`**:`{type:"session_read", sessionId, data:{readAt}}`,全连接广播 + 客户端按 `sessionId` 过滤,用于同账号其它端清红点;失败路径不广播(§3.9)。⑥ **升级回填**:0010 把既有会话游标回填为「该会话现有最新消息时间」,**升级当天不误报未读**(既有历史全部视为已读)。⑦ `HistoryStore`(网关消费侧契约)新增 `MarkSessionRead(ctx, sessionID, before)`;`SessionMeta` 新增 `UnreadCount`。**向后兼容**:仅新增字段/端点/事件,既有契约形状不变;不消费 `unreadCount`/`session_read` 的客户端行为不变。详见 §1 行 4/8a 与 §3.9。
- `v0.36`(**A1 流式帧附加会话 / 回合标识**,对客户端**新增两个可选字段、无端点变更**):结构化流帧 `message.start/delta/end`、`thinking.start/delta/end`、`tool_call.start/delta/end`、`tool_execution.update` 现在带 `sessionId` + `turnId`(**向后兼容**:只新增帧字段,`data` 结构与既有事件形状不变)。`sessionId` 由 loop 在**回合边界**从运行上下文注入(模型适配器在 loop 之下、天然不感知会话,保持零值;loop 外无会话上下文时该字段为空);`turnId` 由网关**每回合**新生成(`newTurnID`)并经 `loop.WithTurnID` 挂进运行上下文,`runTurn` 是编排层与 legacy 直连路径**唯一**的回合收口点,两路都覆盖——同一会话同时只有一个 in-flight 回合,故同轮内所有帧的 `turnId` 一致。此前这几帧属于 design/16 §4.2 标注的「若知道就带上」缺口(v0.10 起记录):**多会话并发流式时前端会把别的会话的 delta 接进当前会话气泡**,现在客户端按 `sessionId === activeSessionId` 过滤即可路由到激活会话。`stream_chunk` / `stream_end` 的 `sessionId`/`turnId` 行为不变。详见 §3.1。
- `v0.35`(**可观测性收尾:后端日志 tail 端点**,对客户端**新增一个 admin 只读端点**):`GET /api/v1/admin/observability/logs?level=&limit=`(owner/admin)→ `200 {data:[{time,level,source,msg}]}`,**最新在前**。数据源为 logger 新增的**有界环形缓冲**(`internal/logger`,容量 2000,满则丢最旧、并发安全、不阻塞、不改动既有控制台/文件输出),组合根在构建 logger 时挂上、在网关就绪后经 `SetLogBuffer` 注入;缓冲未注入回 `501 not_implemented`(不伪造空 tail)。`time` 为 **RFC3339 UTC(秒精度)**,与 `trace.startedAt`/`audit.when` 一致;`level ∈ debug|info|warn|error`(大小写不敏感)为**最低等级过滤**,空 = 全部,未知值 `400 invalid_level`;`limit` 缺省 `200`、上限 `1000`。`source` 取 `logger.WithSource` 的 `source=`(`Scoped` 记录回落 `module/sub`),无来源为空串。详见 §2「可观测性契约」。
- `v0.34`(**D3 收尾:消息编辑 / 删除的 WS 实时广播**,对客户端**新增两个下行事件、无 REST 契约变更**):`PATCH /api/v1/messages/{id}` 成功后广播 **`message_edited`**(`{type, sessionId, data:<更新后的完整消息DTO>}`,`thinking` 已剥离)、`DELETE /api/v1/messages/{id}` 成功后广播 **`message_deleted`**(`{type, sessionId, data:{id}}`);两者复用 `message_persisted` 的同一 hub 广播路径(**全连接广播 + 客户端按 `sessionId` 过滤**,hub 无按会话订阅),客户端**按 `id` 原位改 `content` / 按 `id` 移除**,不再需要「改/删后重拉」的绕行。`message_deleted` 的 `sessionId` 由后端在删除前读取消息所属会话填入(`HistoryStore` 新增 `GetMessage`;读不到时客户端按全局唯一的 `id` 兜底)。`404`/`400`/`501` 等失败路径**不广播**。Web 端已消费(`lib/ws.ts` + `store/useAppStore` 新增 `updateMessageById`/`removeMessageById`)。详见 §3.8。
- `v0.33`(**A2 记忆异步提取 / A4 可观测性 Tracing 接线 / A6 通知页后端**):① **A6 新增 `GET /api/v1/admin/notifications`**(owner/admin 读,**全新端点**):把**现有源**(审计 `audit_logs` + backthink 主动推送记录)聚合为**统一只读通知 feed**,`type`/`before`/`limit` 过滤与游标见 §2「通知聚合契约」;**不新建表**,未注入的源该类型为空、不伪造;未读游标后置。② **A4 trace 端点由恒空变真实数据**(**无契约变更**,`GET /admin/observability/trace` 与 `metrics` 的 `llmCalls`/`subagentEvents` 从此反映 loop/工具/编排的真实 span):此前 `TraceSink` 从未接线、环形缓冲无人喂;现在组合根把 loop 产生的 span 扇出到网关观测缓冲。响应字段/形状不变。③ **A2 LLM 异步记忆提取接线**(**对客户端无变更**):loop 回合结束后改由 LLM 提取器**后台异步**抽取记忆(不阻塞回复、失败仅记日志),**群聊/陌生人仍只读不写**;无可用模型凭据时回落确定性 `ManualExtractor`(`记住 X` 路径不变)。
- `v0.32`(**A1 情绪系统接线:情绪/好感度真跑起来**):后端把既有的 `persona/emotion` 状态机接进 loop 回合边界——每轮结束按该轮用户消息的情绪信号移动**该角色自己**的情绪状态机(沿配置转移表走一步)并调整好感度(按角色 `affection.delta`);无信号轮次衰减一步趋向 `calm`。① **消息 `mood` 不再恒空**:assistant 消息落库带该轮情绪,`message_persisted` / `history` 回放 / `GET /sessions/{id}/messages` 的 `mood` 为真实值(user 消息不带)。② **新增下行 `mood` 事件**(`{data:{mood,personaId,affection}}`,**仅情绪变化时**广播,见 §3.7),前端顶栏「心情 · x」与消息气泡接真数据。③ **隔离**:群聊/陌生人(`MemWritable=false`)情绪只读不写——不改情绪/好感度、不发事件、该轮 `mood` 空。④ **持久性**:情绪/好感度为进程内按角色状态,重启回 `initialState`(未落库;design/01 未要求)。**触发词表与「每轮一步」为 design/01 未规定处的最小实现,待主人确认**。**契约新增(向后兼容):`mood` 事件 + 消息 `mood` 变真实值。**
- `v0.31`(**Android 协同批次 ③b:B5 剩余 501 + D1 推送出口钩子**):① **B5-知识库删除由 501 改为真实实现**:`POST /api/v1/admin/rag/delete` body `{"id"}` → `200 {data:{id,deleted:true}}`——从项目索引删除该文档条目并**级联删除全部章节**(单事务);不存在回 `404 not_found`,`id` 空回 `400`,未注入 store 回 `501`;路由由 admin 收紧为 **owner-only**(与其它 admin 写一致)+ 审计 `rag.delete`。② **B5-新增用户 / IoT 场景 / IoT 规则仍诚实 501**:无用户表(登录仅 `.env` 单 admin)、IoT 无场景/规则模型与存储(`iot.Provider` 只有设备列表/控制/状态),错误消息明确缺什么,**不伪造 store/假成功**。③ **D1 推送出口钩子**:主动消息在 WS/IM 投递之外异步镜像到外部推送出口,**自建统一推送服务优先 → 个推(Getui)兜底**(个推 SDK 未接入,仅预留 `GETUI_*` 字段,不可达时日志注明待接入);`.env` 新增 `PUSH_EXPORTER_ENABLED/URL/TOKEN/TIMEOUT_SEC`,token 只走 `Authorization` 头、不落日志;**对客户端无新端点**(详见 §3.6)。
- `v0.30`(**Android 协同批次 ③a:D2 消息搜索 / D3 消息编辑删除 / D5 会话置顶**):① **D2**:`GET /api/v1/sessions/{id}/messages` 新增可选 **`q`**——**会话内**内容搜索(大小写不敏感 ASCII LIKE,`%`/`_`/`\` 转义为字面量),仍倒序 + `before`/`limit` 游标;`q` 与 `after` 互斥(同传 `400`),空 `q` 走普通分页;**严格限定单会话,不新增跨会话搜索**(避免群聊/陌生人隔离会话外泄,红线)。`HistoryStore` 新增 `Search`。② **D3**:新增 `PATCH /api/v1/messages/{id}`(body `{content}`,非空 ≤4000 rune → `{data:{message}}`,只改 content)与 `DELETE /api/v1/messages/{id}`(→ `{data:{deleted:id}}`);不存在回 `404 not_found`;`HistoryStore` 新增 `UpdateMessage`/`DeleteMessage` + `ErrMessageNotFound`。**不新增重发端点**(客户端用编辑后的 content 再发)。③ **D5**:`chat_sessions` 新增 `pinned INTEGER NOT NULL DEFAULT 0`(迁移 **0009**,down 可逆);`GET /sessions` 排序改为 **`pinned DESC` → 主会话优先 → `createdAt ASC`**,会话 DTO 新增恒返回的 **`pinned`** bool;`PATCH /sessions/{id}` 扩展为 body `{title?,pinned?}`(至少一个字段,只回显变更字段,title-only 保持 A4 形状);`HistoryStore` 新增 `SetPinned`。全部为 owner/admin 写、读消息 owner/admin;未注入 HistoryStore 回 `501`。
- `v0.29`(**/admin/llm 读-改-写合并**):`POST /api/v1/admin/llm` 由「全量替换」改为**部分编辑合并**——请求体字段用指针区分「未给」与「显式零值」;`maxTokens` / `apiKey` / `provider` / `baseUrl` / `model` / `fallback` / `multimodal` **省略即保留原值**(不再把省略的 `maxTokens` 重置为 0),新组仍要求 `baseUrl`+`model`、非法 provider / 负 maxTokens 仍 `400`。面板 LLM 页新增每组 `maxTokens` 展示/编辑 + 新增/编辑表单;面板插件页新增「已装插件列表」+「安装 MCP 插件」表单(kind/name/transport/endpoint|command+args/apiKey/env),api.ts 请求体带 `config`(修掉旧 body 400)。`runtimeconfig/models.go` 新增 `Find`。
- `v0.28`(**MCP 插件热安装/卸载**):`POST /api/v1/admin/plugins/install`(**由 501 改为真实实现**,owner-only + 审计)body `{kind:"mcp",name,config:{transport,endpoint?|command?,args?,apiKey?,env?}}` → `200 {data:{installed,name,kind,transport,toolCount}}`——真实连接 MCP server、发现并**热注册工具**进 capability registry(下一轮模型请求即注入),配置落盘 `plugins.json`(gitignore、0600);连接失败回 `502` 且**不落盘**;同名重复 = 刷新。`POST /api/v1/admin/plugins/uninstall` 扩展:MCP 插件走 `Unregister`(移除工具 + close + 删配置),普通能力保持原 `registry.Remove`。新增 `GET /api/v1/admin/plugins/installed`(owner/admin 读)列出已装插件(`apiKey`/敏感 `env` 掩码 + `live`/`toolCount`)。`mcp.MCPConfig` 新增 `APIKey`(http/sse 以 `Authorization: Bearer` 发送)与 `Env`(stdio 子进程环境);`plugins.json` 于启动时 `Restore` 重新安装。**契约版本 `v0.28`**。
- `v0.27`(**LLM token 预算可配置化**):`models.json` 模型组新增可选 **`maxTokens`**(`int`,缺省/`0` = 未设),作为该组主/备链的 token 预算;`GET /api/v1/admin/llm` 的 `AdminLlmProvider` 与 `POST /api/v1/admin/llm` body/回显同步该字段(`omitempty`,未设为 0 时不出现)。`.env` 新增 **`LLM_FALLBACK_MAX_TOKENS`**(`int`,默认 `8192`,`0` = 关闭兜底),只作用于**确有回退模型**时链末端的回退步。**优先级**:显式 `Request.MaxTokens`(审查/判定类小预算)> 生效组 `maxTokens` > `LLM_FALLBACK_MAX_TOKENS`(仅链末端、且组未设时)> 适配器默认(anthropic 8192)。单步链(无 fallback)不套用 env 兜底。`maxTokens` 为负回 `400 bad_request`。**契约版本 `v0.27`**。
- `v0.26`(**A5 消息正向增量**):`GET /api/v1/sessions/{id}/messages` 新增 **`after`** 查询参数(`<createdAt 毫秒>`,升序返回 `created_at > after` 的消息,默认 `limit=50`/上限 200)——解决「WS 重连只回放主会话最近 50 条、断线超 50 条永久丢」的缺口;`before` 现行为不变;**`after` 与 `before` 互斥**(同传 `400`);负数/非数字 `400`。`HistoryStore` 新增 `ListAfter`(走 `idx_chat_msgs_session_created`,`rowid` 兜底同毫秒并列)。**游标契约**:客户端用「本页最后一条 `createdAt`」作下一页 `after`;同毫秒消息 > limit 时纯 `createdAt` 游标会跨页重读(超罕见,客户端按 `id` 去重兜底,与现有绕行一致)。
- `v0.25`(**B2 refresh token**):`POST /api/v1/auth/refresh`(**新增**,`{refreshToken}` → 新 access + 新 refresh,默认单次使用旋转,并发重放 fail-closed `invalid_grant`)+ `POST /api/v1/auth/logout`(**新增**,撤单个或全设备)。`POST /api/v1/auth/login` 在 `{token,expiresAt,role}` 之外**新增 `refreshToken`**(不透明 256-bit 随机串,**仅 SHA-256 哈希落库**,`AUTH_REFRESH_TTL` 默认 `720h`,`AUTH_REFRESH_ROTATE_DISABLED` 关旋转)。新表 `refresh_tokens`(迁移 0008,id/user_id/role/token_hash UNIQUE/expires_at/revoked_at;含过期/轮换原子事务);`rotate-jwt` 成功即删除全部 refresh(返回 `refreshTokensRevoked`),`revoke-keys` 不动 refresh。**密码/secret 记录见 `internal/auth`;refresh 全文绝不落日志/审计。**
- `v0.24`(**B1 图片上传端点**):`POST /api/v1/upload`(**新增**,multipart `file`,owner/admin+审计 → `201 {data:{url,size,mime,name}}`)+ `GET /api/v1/files/{name}`(**新增**,同端口静态服务)。安全:`MaxBytesReader`(5MB)、`DetectContentType` 嗅探(不信客户端 Content-Type)、白名单 raster image(拒 SVG/HTML)、`crypto/rand` 128-bit 随机名(丢弃原始文件名)、非可执行上传目录(0700)、`nosniff`;路径穿越全形态拒绝(含编码 `%2f`/`%2e%2e`)。错误码 `400/413/415`(`internal/errors` 增 `ErrPayloadTooLarge`/`ErrUnsupportedMediaType`)。**设计取舍**:GET 不鉴权(能力 URL,`chat.image`/`<img>` 无法带 Bearer),POST 仍 owner/admin。`url` 为绝对地址(`X-Forwarded-Proto`/TLS + `r.Host`,因 `ParseImageRef` 只认 `http(s)://`)。`Deps.DataDir` 由 wire 注入(`resolveDataDir`)。
- `v0.23`(**C2 语音 silent 降级信号**):三处统一用 **`provider`** 只在「结果为空」时出现(非空/成功时才省略)——`POST /api/v1/audio/transcribe` 空文本响应体带 `{text:"", provider:"silent"}`;WS `voice_output` 空音频 payload 带 `provider`;`POST /api/v1/audio/speech` 空音频仍 `500 tts_empty` 并加 `X-Voice-Provider: silent` 响应头(CORS `Access-Control-Expose-Headers` 已放开)。`voice.Chain/Service` 新增 `STTWithProvider/TTSWithProvider`(返回真实档位 cloud/local/edge-tts/espeak/silent),网关用可选接口 `voiceProviderReporter` 透传,自定义实现回退 `silent`。**含 `X-Voice-Provider`(`apperr` 包装、`providerSignal`);`DASHSCOPE_API_KEY` 无/降级时信号为 `silent`。**(`go test -race` 在本机受内核 TSAN VMA 限制失败,非本代码问题。)
- `v0.22`(**B3 会话列表 lastMessage 预览**):`GET /api/v1/sessions` 与 `/api/v1/admin/sessions` 每个会话新增可选 **`lastMessage`** `{content,role,createdAt}`(content 折叠空白截前 60 rune + `…`;空会话省略),**单条 SQL**(LEFT JOIN 取每会话 `ORDER BY created_at DESC,rowid DESC LIMIT 1`,无 N+1,走 `idx_chat_msgs_session_created` 覆盖索引)。`unreadCount` 需已读游标设计,后置。
- `v0.21`(**A4 会话 title**):`PATCH /api/v1/sessions/{id}`(**新增**,body `{title}` ≤80 rune → `{data:{id,title}}`;空/超长 400、不存在 404、主会话可改名);会话**首条用户消息到达时自动命名**(`history.append` 前 `ensureTitle`:折叠空白取前 20 rune,纯空白回落 `主会话`/`次级会话`),仅首次命名、不覆盖。`HistoryStore` 新增 `RenameSession`;隔离会话(`sess-xxx`)不在 `chat_sessions` 有行、不自动命名(其本不在 `/sessions` 列表)。**边界**:前端 `createSubSession(title?)` 的 POST body 含 `title` 但后端暂忽略(会被首条消息自动命名覆盖),未处理。
- `v0.20`(**A3 消息 mode 落库**):`POST /api/v1/chat` 与 `ws.chat` 上行新增**可选 `mode`**(行为模式,design/01 §3 / design/16 §5.1)。后端把每条持久化消息的 `Mode` **真实写入**(IM 路径恒 `chat`;Web 路径经 ctx 透传本轮 `mode`,缺省回落 `chat`;恢复/续接路径沿用发起轮次的 `mode`),消息 DTO、`message_persisted`、`channel_persisted` 的 `mode` 不再恒空,供前端按模式渲染气泡(**仅 `chat` 解析 `<p>` 拆气泡**,其它模式原样整条)。
- `v0.19`(**百炼声音复刻 create_voice + TTS instruction 情绪驱动,主 TTS 用昔涟复刻音色**):`POST /api/v1/audio/voice-clone`(**新增**,owner-only + 审计,body `{url:"<公网参考音频URL>", prefix?}` → `{data:{voiceId}}`)——走后端 `voice.DashscopeCloner`(百炼 `voice-enrollment` create_voice,`POST {base}/services/audio/tts/customization`,`input{action:create_voice, target_model:VOICE_TTS_MODEL, prefix, url}`,**只收公网 URL 不收 base64**,返回 `output.voice_id`;**URL 不打日志不写审计**)。`POST /api/v1/audio/speech` 新增**可选 `instruction`**(per-request 情绪/语气指令,如 `"语气开朗俏皮,像女朋友撒娇"`),经 ctx 透传给 TTS adapter;**只对复刻/设计音色生效**(`cosyvoice-v3-flash` 复刻音色可任意指令;系统音色 `longxiaochun_v3` 需固定格式,服务端 `canInstruct` 判定后**永不发送**)。config 新增 `VOICE_TTS_INSTRUCTION`(默认 `""`)、`VOICE_CLONE_PREFIX`(默认 `cyrene`,≤10 字母/数字);`voice.Chain`/`voice.Service` 新增 `CloneVoice`,`VoiceCloner` 接口(cloud rung 提供;未配 cloud 回 `voice_unavailable`)。**⚠️ 真建昔涟音色仍待主人提供公网参考音频 URL(当前无)**,且 CosyVoice 声音复刻仅华北2北京地域(确认 key 地域);TTS instruction 用现有复刻音色或先仅单测请求体,避免改系统音色行为。
- `v0.18`(**后端语音接通阿里百炼 STT + TTS 主链路**):`POST /api/v1/audio/transcribe`(**新增**,owner + 审计,body `{audio,format?,deviceId?}` → `{data:{text}}`,走 STT);`POST /api/v1/audio/speech`(**新增**,owner + 审计,body `{text,voice?,format?}` → 音频字节 `audio/<format>`,走 TTS)——**REST 语音源**,原生/移动端经它绕开 WS 4KB 帧限上传/获取音频;WS `voice_input` 上行新增 **`deviceId`**(随语音轮次落库,design/16 §4.5)。后端 `internal/runtime/multimodal/voice` 的 cloud rung 由空 endpoint stub 改为**真实百炼 adapter**:STT `qwen-audio-3.0-asr-flash`(base64 原始字节→文本)、TTS `cosyvoice-v3-flash`(按 `voice_id` 合成,取非流式返回的 24h 音频 URL 下载字节)。**实测**(`DASHSCOPE_API_KEY` 实跑):TTS「你好,我是昔涟」→ 106124 字节 wav;把该 wav 喂给 STT → 文本「你好,我是西联。」(TTS 发音把「昔涟」读得近似「西联」,STT 识别正确);语音链 (cloud→…→silent) 返回真实字节/文本。**已实测修正**:`cosyvoice-v3.5-plus`/`longxiaochun`(v2 音色)走 HTTP `SpeechSynthesizer` 会回 `418`(引擎拒绝),改用 **`cosyvoice-v3-flash` + `longxiaochun_v3`**(v3 音色,各模型音色不可混用)。**声音复刻(响昔涟音色)仍待**:需参考音频(公网 URL)在百炼建自定义音色后把 `voice_id` 填入 `VOICE_TTS_VOICE_ID`(本项目不做 create_voice)。config 新增 `DASHSCOPE_BASE_URL`/`VOICE_STT_\*`/`VOICE_TTS_\*`(`VOICE_TTS_SAMPLE_RATE` 默认 24000)。
- `v0.17`(系统设置安全与存储:吊销全部 API key / 轮换 JWT 密钥 / 备份 / 恢复,design/11 §4 + design/10 §3 模块14):新增 `POST /api/v1/admin/system/{revoke-keys,rotate-jwt,backup,restore}`(均 **owner-only** + 审计)。`revoke-keys` 走 `auth.APIKey.RevokeAll()`(清空 key 表,返回 `{revoked:n}`);`rotate-jwt` 走 `auth.JWTService.RotateSecret`(≥32 位,**新密钥不回显/不落日志**,现有 token 立即失效);`backup` 用 SQLite `VACUUM INTO` 快照到 `<dataDir>/backup-<ts>.db`(含已提交 WAL 内容)返回 `{path,size}`;`restore` 先校验路径在 dataDir 内(**`filepath.Rel` 防路径穿越**,逃逸回 `400`)→ **先自动备份当前 DB** → 替换 DB 文件 → 返回 `{restored:true,backup,note:"需重启后生效"}`(运行进程持有 DB 句柄,热替换需重启可见)。gateway 新增 `Deps.DatabasePath`(composition root 传入实际打开的 SQLite 路径)。`编辑环境变量 / 重启服务 / 检查更新` 仍为后置,未实现、不伪造。
- `v0.17`(KOOK 适配器面板 + 每适配器专属页,design/17 §2):`GET /api/v1/admin/channels/{platform}/view`、`GET /api/v1/admin/channels/{platform}/friends?type=`、`POST /api/v1/admin/channels/{platform}/friend/handle`、`POST /api/v1/admin/channels/{platform}/message/test`(**新增**,均 owner-only + 审计,带可选 `?accountId=`,仅 `kook` 实现,其它平台 `501`);`GET /api/v1/admin/channels` 每条账号附 `capabilities`(面板按平台渲染专属卡片)。KOOK 面板:好友/申请、服务器/频道/成员、私信会话、发消息测试——经适配器 REST(`Authorization: Bot <token>`)拉真实数据,token 不打印不落日志,子项失败记入 `errors` 不伪造。
- `v0.16`(运行时配置体系:渠道适配器配置落盘 + 热更新,design/04 §3.1):`PUT /api/v1/admin/channels/{platform}/config`(**新增**,owner-only + 审计)body `{id?,token?,baseURL?,remoteURL?,listenAddr?,mode?,accessToken?,selfID?,config?,allowGroups?,denyGroups?,allowUsers?,denyUsers?,groupMsgPerMin?,msgPerMin?,maxMessages?}` → `201 {persisted:true,path,platform,accountId}`——读当前有效配置 → 写入该平台账号(**自 v0.54 起改为按 id 打补丁 `PatchChannel`**)→ 落盘 `channels.json`(本地、gitignore、0600)→ `bridge.Reload(platform)` **热替换运行中 adapter**(进行中消息用旧、新消息用新),无需重启;token 写入文件但**不回显/不落日志**。`DELETE /api/v1/admin/channels/{platform}/config?id=`(**新增**)删除账号并热更新。`GET /api/v1/admin/channels` 每条渠道附 `accounts`(每平台已配账号,token 脱敏掩码)。`.env`(`CHANNELS_JSON`/`CHANNEL_*_TOKEN`)退为兜底。
- `v0.15`(运行时配置体系:LLM 多厂商多模型独立组落盘 + 热更新,design/04 §3.1):`GET /api/v1/admin/llm` 返回 **models.json 的全部组**(`provider` 协议字段新增,index 0 = 生效组;APIKey 仍脱敏不回显);`POST /api/v1/admin/llm` 由「临时记录」改为**真实持久化 + 热更新**(body `{id?,provider,baseUrl,apiKey?,model,multimodal?,fallback?}` → `201 {provider,persisted:true,path}`,读-改-写 `models.json`、保存组提升为生效组并热重载运行中的 LLM stack,无需重启)。`.env` 退为兜底(无 `models.json` 时按 `LLM_PROVIDER`/`LLM_*`/`ANTHROPIC_*` 合成单组)。
- `v0.14`(IM 多会话隔离 + 第三方身份 + 黑白名单,design/16 §4.6):**`chat_messages` 增 `sender_name` 列**(迁移 0006,默认 `''`);消息 DTO(REST 消息、WS `history` 回放、WS `message_persisted`)均带 **`senderName`**(第三方发送者昵称,主人私聊为空 `omitempty`)。**第三方身份边界**:IM 群聊/陌生人会话在上下文构建时对第三方消息注入 `[第三方 · <昵称>]` 前缀(**不改源消息正文**,落库保留原文)+ 前置系统提示词"每个 IM 的非管理员账户 = 第三方人员,和主人无关"——身份判定基于 channel 层 actor 元数据 + 白名单(`MemWritable=false` 即群聊/陌生人),**绝不靠 LLM 猜**。**会话隔离**:群聊/陌生人消息**落独立隔离会话**(`sess-xxx`,带 `platform`+`senderName`,**不**并入主会话 `web`、**不**走 `message_persisted` 广播),供 **`GET /api/v1/admin/channels/{platform}/messages`**(owner/admin,management 面板渠道日志区)以消息流显示;**前端主会话**只显示各平台管理员(owner)私聊;群聊/陌生人记忆只读不写主会话(`memory_gating` 已按 `MemWritable` 门控)。**多账号**:`CHANNELS_JSON` 支持同一平台多个接入配置(每个 account 独立 session/账号,见 §2 渠道契约)。**黑白名单**:每平台每账号可配 `allowGroups/denyGroups/allowUsers/denyUsers`,channelbridge **入站即拦截**(黑名单/不在白名单 → 丢弃,`audit` 可查,**不进 LLM**);白名单按 `platform+account`(群 id / 用户 uid)。
- `v0.13`(每条消息带 deviceID/client 标识,design/16 §4.5):**`chat_messages` 增 `device_id` 列**(迁移 0005,默认 `''`);`POST /api/v1/chat` 请求体新增**可选 `deviceId`**(客户端生成的端标识,如 `web:chrome:a1b2c3`);消息 DTO(REST 消息、WS `history` 回放、WS `message_persisted`)均带 **`deviceId`**。`platform`(平台)与 `deviceId`(具体端)互补:同一平台多个端 → 相同 `platform`、不同 `deviceId`。**Web 端**由 `lib/client.ts` 用 `localStorage` 持久化每个浏览器一个的 `web:<browser>:<随机短id>`,发送时自动带上,聊天气泡默认小字标注来源(仅当 `deviceId` 存在才显示);**IM 端**按 `<im平台名>:<本地配置唯一id>:<用户平台唯一uid>` 构造(当前 `UnifiedMessage` 尚无 deviceId 概念,channelbridge 以 `<platform>:<channelID>:<senderID>` 占位,`<本地配置唯一id>` 待适配器提供后替换);Web 端聊天不回流 IM。
- `v0.12`(IM 渠道并入主会话 + 平台标记,design/16 §4.4):**`chat_messages` 增 `platform` 列**(迁移 0004,默认 `'web'`);`GET /api/v1/sessions/{id}/messages` 支持可选 **`?platform=`** 过滤(Web 端不传=全量共享主会话;IM 适配器传本平台只拉本渠道);消息 DTO(REST 与 WS `message_persisted`)均带 `platform`。**IM 渠道主人私聊**(`!IsGroup && Owner`)经 channelbridge 落**主会话 `web`**(`platform=<渠道>`)并广播 `message_persisted` → Web 端免刷新镜像;**群聊/陌生人**保持独立隔离上下文、不落主会话。Web 端聊天(`platform=web`)不回流 IM。
- `v0.11`(跨端实时同步,design/16 §4.2):WS 下行新增 **`message_persisted`** 事件——每条 user/assistant 消息落库后广播 `{type, sessionId, data:{message:{id,sessionId,role,content,mode?,mood?,image?,createdAt}}}`(`id` 为后端生成,`thinking` 已剥离)。客户端按 `sessionId === activeSessionId` 过滤 + 按 `id` upsert(含本端乐观气泡 `uid`→后端 `id` 归一)去重,实现「A 发消息、A 与 B 都无需刷新且各出现一次」。后端 `history.Store` 新增 `AppendFull` 供编排层取含 `id` 的落库消息。
- `v0.10`(会话消息持久化 — WS 契约,design/16,后端-2):WS 下行帧新增可选 `sessionId` 字段(能确定会话的事件带上);新增 **`history` 回放事件**——WS 握手成功即回放主会话最新 50 条(`{type:"history", sessionId:"web", data:{messages:[...]}}`,`thinking` 已剥离)。`stream_end`/`stream_chunk`/会话编排状态帧(`session_busy` 等)在能确定 session 时带 `sessionId`;`message.delta` 等结构化流帧当时因 `model.StreamEvent` 无 session 上下文**不带**(即"若知道就带上"边界)——**该缺口已在 v0.36 补齐**(结构化流帧现在带 `sessionId` + `turnId`,见 §3.1)。
- `v0.9`(会话消息持久化 + 主/次级会话,design/16,后端-1):新增 `POST /api/v1/sessions`(新建次级会话)、`GET /api/v1/sessions/{id}/messages?limit=&before=`(游标热分页拉历史,时间倒序)、`DELETE /api/v1/sessions/{id}`(删次级会话及其消息);`GET /api/v1/sessions` 在注入 `HistoryStore` 后改返回持久化会话元数据数组(主 + 次级,`type` 区分),未注入时保持原渠道会话列表向后兼容。读消息/写会话为 owner/admin(用户侧私有历史,thinking 不达 viewer);会话元数据列表保持任意认证角色可读。
- `v0.8`(管理面板写操作 CRUD):补齐管理面板**写端点**(见 §2「管理面板写操作契约」,均 owner-only + 审计)——`POST/PUT /admin/personas`(真实注册 + 持久化 `configs/personas/<id>.yaml`)、`POST /admin/users/apikey` + `DELETE /admin/users/apikey/{id}`(真实签发/撤销)、`POST /admin/iot/{id}/control`(真实设备控制)、`POST /admin/automations` + `/run` + `DELETE`(真实注册/触发/移除规则)、`POST /admin/llm`(持久化 `models.json` + 热更新,v0.15 起)+ `/admin/llm/test`(真实模型调用)、`POST /admin/channels/{open,close,test}`(真实连接/断开 + 结构性身份识别)、`POST /admin/plugins/uninstall`(真实 registry 移除)、`POST /admin/memories/search`(真实检索)、`GET /admin/sessions/{id}/export`。无存储的写(新增用户 / IoT 场景·规则 / 插件安装)如实返回 `501 not_implemented`。
- `v0.7`(会话消息编排 design/15):`POST /api/v1/chat` 非流式且会话「正忙」时改回 `202 {data:{queued:true,queueDepth}}`(原同步 `{data:{reply}}` 仍为空闲时返回);pending 满回 `409 chat_busy`。WS 上行新增 `interrupt`(`{sessionId}`,中断当前任务+清 pending);WS 下行新增会话编排状态事件 `session_busy` / `pending_ack` / `pending_drain` / `session_idle` / `session_interrupted` / `pending_full`(见 §3.2)。既有 `stream_chunk`/`stream_end` 与结构化流事件不变,契约向后兼容。
- `v0.6`(可观测性):新增 `GET /api/v1/admin/observability/{metrics,trace,audit}`(owner/admin)——metrics 返回网关实时请求计数/QPS/延迟/方法·路径·状态分布 + LLM/子代理 span 计数;trace 返回 gateway 环形缓冲里的最近 span(经 TraceSink 对接 runtime tracer,映射为 `AdminTraceSpan`);audit 返回持久化审计(SQLite `audit_logs`,未注入时退化为内存)。`llmCalls`/`subagentEvents` 由实际观测 span 累计;Token/成本后端不统计、不返回(不伪造指标)。
- `v0.5`(批次B·视觉真推理 + 语音端):`POST /api/v1/chat` 增 `image` 字段(base64 data: URI 或 URL),入 loop 视觉路由(vision/OCR 工具或主模型多模态,design/06 §0「先看图再对话」);WS 上行 `voice_input` 接 STT→聊天→TTS 管道,下行新增 `voice_output`(定向回请求连接,含合成音频 + 识别文本 + 回复文本)。
- `v0.4`(结构化流):WS 下行新增 `message.*` / `thinking.*` / `tool_call.*` / `tool_execution.update` 结构化流事件(event shell 补 `turnId`/`seq`,`data` 类型化),对齐 OpenClaw AssistantMessageEvent;保留 `stream_chunk`/`stream_end` 向后兼容。
- `v0.3`(M10):管理面板 `/admin` 落地——按 §2 契约封装 admin 只读端点(personas/sessions/memories/plugins/channels/iot)+ owner-only `plugins/policy` 写;其余 M10 端点以契约路径封装、后端未实现时前端显式"待实现"壳。(**注**:面板**前端**入口自 v0.55 起为**路径式安全入口**,前缀由 `ADMIN_PATH` 决定、默认即 `/admin`;本节其余关于面板的叙述都不含前端路由前缀语义。)
- `v0.2`(M8):gateway 落地——登录/health/chat/sessions/personas + admin 只读端点 + `plugins/policy` 写 + WS `/ws/chat`(JWT + bus 广播)。
- `v0.1`(契约先行):骨架 + 端点登记表。
File diff suppressed because it is too large. Load diff
+282 -2
View File
@@ -1,3 +1,283 @@
# Cyrene_v2
# Cyrene v2 — 昔涟
喵
> 自托管的「人格化通用智能体运行时」。一个 Go 单二进制,默认以「全能小女友」的人格陪伴你;在聊天/开发/家庭/工作等模式间无感切换;如 OpenClaw 般接入聊天平台(**KOOK / 微信 iLink 已接通**,QQ OneBot v11 链路已打通但暂不连,Telegram 待凭据);各人格独立记忆与情绪、共享同一套能力。
**状态**:M0–M12 里程碑主干已落地,现处于「后 M12 增量切片」阶段。每一项能力以本文的三档清单(已实现 / 需凭据·部分 / 未实现)为准,**不作整体完成宣称**;真实缺口见「已知缺口与路线图」。 | **分支**:`dev` | 设计文档见 `DevDocs/00-overview.md`
---
## 当前能力(按真实状态分三档,不虚标)
> 状态以**代码 + 真机实测**为准。标注「需凭据」的项依赖外部凭据 / endpoint 才能达到完整形态;「未实现」一律如实写出,不当「已修」。
### ✅ 已实现
- **单二进制、进程内协作** — **框架**的所有服务同进程打包、**框架**仅网关一个对外端口(默认 `:8080`);框架模块间走进程内接口 + 事件总线,**零跨进程 IPC**。🔴 **2026-10-07 口径补充:该红线只约束框架自身,不约束扩展插件** —— `plugins/` 下的渠道适配器与工具、MCP server、以及插件自己拉起的本地伴随服务**可以开自己的监听端口、可以有自己的进程形态**(原「`qq-obv11` 反连 server 模式」那条例外随之升格为一般规则的实例)。插件端口仍须:**由配置显式给出**、**默认绑 `127.0.0.1`**、**登记日志与审计**、**不得绕过身份判定**
- **人格化陪伴** — 多人格(`configs/personas/*.yaml`)/ 情绪状态机 / 好感度;**人格 = 身份**(不限制能力)、per-persona 记忆隔离、能力全局共享
- **通用 Agent 运行时** — 多供应商 LLM(per-role 路由 + 降级:主 → 备 → 本地,本地级默认关)、工具调用(**8 轮上限**)、流式输出、子代理编排(生命周期 / 结果合并 / 并行上限 / 超时 / 防失控)、会话持久化 + 主·次级会话、上下文压缩(预算按模型窗口推导)
- **记忆系统** — **三类作用域隔离**(用户习惯全局共享 / 角色日常他角色只读 / 项目人人读写 + 项目索引分层)、相关性 + 最近度检索、衰减、溯源、RAG;**LLM 异步提取**(每轮落定后后台提取,手工提取兜底);**定期记忆整理**(低峰后台任务,只产候选建议、绝不自动改写);**入库前审查**(D3:新记忆落库前先判定 merge / modify / create / skip,软合并可 `revert` 回滚)
- **durable execution / checkpoint** — 每步落盘快照,**崩溃后可从最新 checkpoint 续跑**(`loadCheckpoint` → `Step+1`;仅「崩溃遗留的在途 run」允许恢复,已落定退出一律重开)
- **IM 渠道插件(内置 5 个,随二进制分发)** — **KOOK** ✅、**微信 iLink** ✅(扫码绑定 + 绑定确认时**自动把主人登记进 owner 白名单**,热生效)、**QQ OneBot v11**(正/反 WS × HTTP,四端组合齐;判主 / 收发 / 语音转写均**真机验证过**,但主人 2026-10-05 因风控决定**暂不连**,代码保留)、**Telegram**(长轮询 / webhook,需 Bot token)、**通用 Webhook**(入站,已挂网关)
- **多渠道主会话同步** — 主人私聊(Web / KOOK / 微信 / QQ)汇聚到主会话,Web 端免刷新镜像;群聊 / 陌生人走独立隔离会话,不写记忆、不入主会话上下文
- **后台思考与主动推送** — 自主思考心跳 + 事件触发(安全域 / 急性事件可穿透安静时段)+ 静默时段降频(`THINK_QUIET_INTERVAL_SEC`)+ 主动推送限频;**自排思考 `request_think`**(她可在一次思考里给自己排下一轮,有窗口次数上限)
- **技能(Skill)** — 目录式 YAML(`configs/skills/<id>/skill.yaml`,内含示例 `daily-brief`);**渐进披露**(清单只给 description,命中才展开正文)+ **工具面收缩**(`allowed-tools` 是能力上限,只收不放);**`Runner.Run` 多步执行可用**(步骤拓扑 / 环检测 / 并行上限 / guardrail 强制点)
- **插件声明的管理页面** — `GET /api/v1/admin/panels`(按角色过滤的接入点快照)+ `POST /api/v1/admin/plugins/{id}/actions/{action}`(**真执行**已登记动作,写动作走 owner/admin + 审计);插件生命周期变化推 WS 帧 `panels_changed`
- **网络搜索** — 原生 **6 家 provider**(SearXNG 本机兜底 / 智谱 / 阿里 IQS / 博查 / 火山·豆包 / 百度千帆)+ 降级链 + **配置面板热重载**(改配置即生效、无需重启;会让 web-search 无可用后端的改动会被回滚并报 `409`)
- **文件 / 文档进对话** — 上传 → 抽取正文 sidecar → 按预算注入上下文;**含 PDF 摄取**;引用计数 + 保留期 GC(仍被引用的文件绝不删)
- **语音** — 云端 **STT / TTS 真实接通**(阿里云百炼 DashScope:`qwen-audio-3.0-asr-flash` / `cosyvoice-v3-flash`),含**声音复刻**入口;IM 入站语音自动转写;**语音落库 + 回放**(消息 DTO `voice` + `GET /api/v1/media/{name}`,支持 Range);不可播格式(QQ AMR)**转码成 wav** 后再落副本
- **浏览器工具** — 自研**出站 CDP**(不发 `Runtime.enable`,减少被识别面);**有头 Chrome + 持久化 profile**(过风控口径);动作 `open` / `screenshot` / `info`(owner-only);**截图落盘后自动进本轮上下文**;插件声明页(B4)已落地
- **MCP 客户端** — 接入官方 MCP SDK(Go);stdio 子进程 / SSE / Streamable HTTP;工具**自动发现并注册**进能力面
- **实时全链路流程图(含时间轴)** — `graph_update` 实时帧 + 落盘 `flow_graphs` 多轮历史 + `GET /api/v1/admin/flow/graph`(最近一轮 / 指定 `turnId` / `lanes[]`)+ `GET /api/v1/admin/flow/timeline`(按时间正序的多轮时间轴,支持 `limit` / `before` 分页 + 保留期信号);**真并行每路各发自己的帧**(不再互相覆盖);Flow 页提供「最近一轮 / 时间轴」两种视图
- **超级详尽后台管理面板**(`/admin`,路径可配)— **18 个静态模块**(仪表盘 / 人格 / 会话 / 记忆 / 知识库 / 插件 / 渠道 / 渠道事件 / IoT / LLM / 自动化 / Agent 监控 / 全链路流程 / 后台思考 / 通知 / 可观测性 / 用户与权限 / 系统设置)+ **插件声明的动态页**,统一 RBAC(`owner` > `admin` > `viewer`)+ 审计
- **系统设置(4 个可写操作)** — 吊销全部 API key / 轮换 JWT / 备份 / 恢复(owner-only + 审计 + 前端确认弹窗)
- **语境感知 UI** — 用户端是**一个共享页面**:单页消息流,每条消息带自己的 mode,气泡按 mode 渲染(`chat` / `code` / `home` / `work` / `docs` / `voice` / `vision` / `agents` / `settings` 共 9 种);统一 **Miuix 设计语言** + PWA
- **多端契约** — 用户侧 REST `/api/v1` + 实时 WS `/ws/chat` + 管理 `/api/v1/admin/*`,**版本化契约 v0.81**(权威文档 `Docs/api-client.md`)
- **多平台部署** — Linux / Windows / macOS / ARM / Termux;**SQLite 默认**(纯 Go、单二进制);PG + pgvector **可选增强**(配置后真走语义 / 向量路径)
### ⚠️ 部分实现 / 需凭据
- **视觉** — 分层路由(主模型 → 工具化视觉 / OCR)与**真推理入口已落地**;真实视觉 / OCR 模型服务**需配 `LLM_VISION_MODEL`(或 `LLM_OCR_MODEL`)及凭据**。无凭据时降级为主模型直接看图 / 视觉·OCR 工具文本描述
- **语音的真实双向通话(Web 端)** — 上行链路与 STT/TTS 已通;`voice` 模式的「实时通话」交互仍未接(前端标注 deferred)。`edge-tts` / `espeak` 仍是**未实现占位**(无凭据时走降级链到 `silent`)
- **声音复刻** — 代码路径已实现,但仍**需主人提供公网可访问的参考音频 URL** 方能真建自定义音色
- **网络搜索的 5 家云端 provider** — 除本机 SearXNG 兜底外,智谱 / 阿里 IQS / 博查 / 火山 / 百度千帆**均需自配 API key,且尚未真机实测**(无凭据);中文查询在本机兜底上的效果有限
- **RAG 语义 / 向量检索** — 默认走内置 n-gram / FTS 向量(dim=256,离线可用);配置 `EMBEDDING_API_URL` + PG/pgvector 后走**真语义**路径
- **微信 iLink 绑定依赖微信侧资格** — 扫码协议已对齐官方并打通;仍需微信账号侧具备 iLink bot 绑定资格(账号环境依赖)
- **QQ OneBot v11** — 代码链路与真机验证都成立,但按主人决定**当前不连**(风控原因);接回只需配置并在面板启用
- **主动推送的设备兜底(Getui)** — 自建推送服务的 webhook 出口已实现(`PUSH_EXPORTER_*`);个推(Getui)**SDK 未接入**,仅预留字段
### ❌ 未实现(当前不提供)
- **`Runner.Run` 多步工作流未接进 loop** — 技能进入 loop 的目前只有「清单 / 命中展开正文 / 工具面收缩」;多步工作流执行器(`Runner.Run` / `Runner.RunSkill`)本身可用且有单测,但**模型还不能在对话里直接触发一套多步技能**
- **浏览器 MCP 委派未落地** — `browser` 工具只提供 `open` / `screenshot` / `info`;`click` / `type` / `wait` / `extract` 仅在工具描述里指路,实际需经 MCP 浏览器服务补齐(未接)
- **搜索降级档 B5 未做**、浏览器容器 docker 配方(CJK 字体)**未落盘**、浏览器会话**未挂进程关闭链**
- **系统设置**:编辑环境变量 / 重启服务 / 检查更新 — 后端未实现,不提供
- **自排思考 C2 的写路径(面板可调)** — 自排的**成本可见面(读侧:`?trigger=` 筛选)已落地**;把后台思考调优参数做成**面板可保存**(`SaveBackgroundTuning` + admin 端点)的部分仍在做
- **全链路 P2 剩余** — token / 耗时统计、移动端优化(并行路 / 泳道 / 节点实时日志本身已落地)
- **群聊类能力** — 冷避、@ 窗口期、戳一戳回应等**全部依赖有群聊的平台**;因 QQ 暂不连、微信 iLink 只支持绑定微信号私聊,当前暂无活体群聊对象(队列保留,不删)
- **IM 路由主动消息的行 id 回填(design/19 §6.10-2)** — 需扩 `SendProactive` 返回值并改全部 mock,本期不做
---
## 快速开始
### 0. 前置
- **Go 1.26**、**Node 22**(前端构建用)、**Docker**(可选:PG + pgvector;不用 PG 可仅用 SQLite)。
### 1. 构建
```bash
# 前端(必做一次:go:embed 需要 dist;构建脚本会同步到 internal/webui/dist)
cd frontend/web && npm ci && npm run build && cd ../..
# 后端二进制(Windows)
scripts\ctrl.bat build
# Linux / macOS(无 build 子命令,直接 go build)
go build -ldflags="-s -w" -trimpath -o cyrene ./cmd/cyrene/
```
> `scripts\ctrl.bat build` 是 Windows 上的权威构建(带 `-ldflags="-s -w" -trimpath`),产物写到仓库根 `cyrene.exe`。
> **换二进制**:先 `build` 到位,再**一次** `restart`(不留空窗)。
> **纯前端改动不需要重启后端**:运行时**优先读磁盘 `frontend/web/dist`**(存在即覆盖内嵌 dist),刷新浏览器即可。
### 2. 启停(本地服务控制)
```bash
# Windows
scripts\ctrl.bat start # 启动(无参 = start;已在跑则跳过)
scripts\ctrl.bat stop # 停止(先优雅、后强杀)
scripts\ctrl.bat restart # 停止 + 启动
scripts\ctrl.bat status # 端口 + PID + /api/v1/health
scripts\ctrl.bat build # 只构建二进制
# Linux / macOS(等价,无 build)
bash scripts/ctrl.sh start|stop|restart|status
```
> 端口取 `.env` 的 `PORT`,缺省回落 `10080`。日志 `%USERPROFILE%\cy.log`(每次启动前把上一份归档为 `cy.log.<时间戳>.bak`,保留最近 20 份),PID 文件 `%USERPROFILE%\.cyrene.pid`。
> 启动默认**分离进程**(`Start-Process -WindowStyle Hidden`):从 Git Bash 调 `restart` 时请把输出重定向到文件(`> /tmp/cy-restart.log 2>&1 < /dev/null`),否则 bash 会等管道 EOF 而「不返回」;**判定成功要看「新 PID + health ok + 启动日志」,不是看命令退没退出**。
### 3. 配置 `.env`(复制 `.env.example`)
```bash
# 认证(JWT_SECRET 必填 ≥32,过短启动即 panic)
JWT_SECRET=<≥32 字符随机串>
ADMIN_USERNAME=admin
ADMIN_PASSWORD=<你的密码>
# LLM(LLM_PROVIDER 显式声明协议:openai / anthropic,默认 openai)
LLM_PROVIDER=anthropic
ANTHROPIC_BASE_URL=https://api.deepseek.com/anthropic
ANTHROPIC_API_KEY=<key>
ANTHROPIC_MODEL=deepseek-flash
# 或 OpenAI 兼容:LLM_PROVIDER=openai + LLM_BASE_URL / LLM_API_KEY / LLM_MODEL
# 数据库(可选增强:PG + pgvector 向量检索;不配则用 SQLite)
DB_PG_URL=postgres://cyrene:cyrene_pass@localhost:5432/cyrene_ai?sslmode=disable
# 记忆嵌入(真语义;EMBEDDING_API_URL 必须是【完整 /v1/embeddings 端点】,不是 base)
EMBEDDING_API_URL=http://localhost:11434/v1/embeddings
EMBEDDING_MODEL=Qwen3-Embedding-0.6B
```
### 4. 可选依赖
```bash
# PG + pgvector(可选增强:向量索引走这里)
docker compose -f docker-compose.dev.yml up -d pg
# 本地 embedding(可选:真语义;llama.cpp + Qwen3 GGUF,CPU/GPU)
scripts\embed.bat start|stop|status # Linux / macOS:见 embed.bat 内注释自行起 llama-server
# 本机 SearXNG 兜底搜索(可选;配置见 scripts/searxng/settings.yml)
```
> `scripts/dev-up.sh` 是 Linux / macOS 上的**一键全流程**(PG → llama.cpp embedding → 前端 build → cyrene),约定 llama.cpp 放 `~/apps/llama.cpp/`、GGUF 放 `~/.models/`、二进制 `~/apps/cyrene/cyrene`;已运行的服务自动跳过。
### 5. 访问
- 用户端 SPA:`http://localhost:8080`(按 `.env` 的 `PORT`)
- 管理面板:`http://localhost:8080/<ADMIN_PATH>`(`ADMIN_PATH` 默认 `admin`;REST/admin API 仍是 `/api/v1/admin/*`,安全靠 JWT + 角色门)
### 6. 服务端口 / 路径速查
| 服务 | 地址 / 路径 | 说明 |
|------|------------|------|
| Cyrene 网关 | `http://localhost:8080`(`.env:PORT`) | SPA + REST/WS + 面板 |
| PG + pgvector | `localhost:5432`(docker `cyrene-pg`) | 可选;向量索引在 `cyrene_ai` |
| llama.cpp embedding | `http://localhost:11434/v1/embeddings` | 可选;Qwen3 |
---
## 配置
### `.env` 已冻结(最小化可跑,不加 / 不改 / 不删)
`.env` 只保证「**最小化功能可跑**」的引导面,**不是配置面**。主人 2026-10-06 定红线:
- **基础键已经定好,以后不准改**:不加、不改、不删;模板 `.env.example` 与 `.env` 同进退。
- **新配置一律不进 `.env`**:调优 / 门槛 / 上限 / 开关类参数走 **`$DATA_DIR/config/**`**(管理面板可改、热重载)+ 代码内默认值。
- 既有基础键与既有 `env:` tag **保持现状**(不迁移、不改默认值、不删键)。
> 想要调优项请走面板(如「LLM 模型」页写 `models.json`、「渠道管理」页写 `channels.json`、插件配置写 `$DATA_DIR/config/plugins/<plugin-id>.json`),或直接改 `$DATA_DIR/config/**` 下的文件。
> 口径全文见 `DevDocs/design/13 §2.1`;`AGENTS.md §10` 亦载明。
### 数据目录 `DATA_DIR`
默认 `./data`;显式写 `platform`(保留字哨兵)则走平台标准数据目录(Windows `%APPDATA%\Cyrene`、macOS `~/Library/Application Support/Cyrene`、Linux `~/.local/share/cyrene`)。
---
## Embedding 模型推荐
> 用于长记忆 / 知识库的**语义向量检索**(默认不配 = 内置 n-gram 向量,dim=256,离线但质量一般)。
> 配了 `EMBEDDING_API_URL` / `EMBEDDING_MODEL` 后走**真语义**。维度 = 模型 dim,与向量索引一致;**切换模型会改维度,需清空旧向量数据**。
| 模型 | dim | 大小 | 推理内存 | 质量 | 建议 |
|------|-----|------|---------|------|------|
| **Qwen3-Embedding-0.6B** | 1024 | Q8_0 **639MB** / f16 1.2GB | ~0.7–1GB | ★★★★★(多语言、2025 新、MTEB 榜前列) | ⭐ **首选**(ModelScope 国内可下 + GGUF) |
| **bge-m3** | 1024 | ~2.2GB(fp16) / ~1.2GB(Q4) | ~2–3GB | ★★★★★(跨语言标杆) | 内存 / GPU 充裕时可用 |
| **bge-small-zh-v1.5** | 512 | ~30–100MB | ~几十MB | ★★★★ | 极轻量备选 |
| nomic-embed-text | 768 | ~270MB | ~400–600MB | ★★★☆ | 可 |
| mxbai-embed-large | 768 | ~350–670MB | ~0.8–1GB | ★★★★ | 可 |
> **首选说明**:**Qwen3-Embedding-0.6B**(阿里通义开源,覆盖 100+ 语言)——**有 GGUF**、**ModelScope(魔搭)国内直连可下**(HuggingFace 在国内已不可访问);`Q8_0` 仅 **639MB**,内存紧张(如 ~3GB 可用、无独立显存的 UMA GPU)也能跑,质量≈f16。
>
> **llama.cpp 能替代 Ollama 吗?** 能,且更省内存。`llama-server`(服务模式)暴露 **OpenAI 兼容 `/v1/embeddings`**:
> ```bash
> ./llama-server -m Qwen3-Embedding-0.6B-Q8_0.gguf --embeddings -ngl 99 --host 127.0.0.1 --port 11434
> # 然后 EMBEDDING_API_URL=http://localhost:11434/v1/embeddings 照用(dim=1024;切换前清一次向量表)
> ```
> - **Ollama**:模型管理方便(`pull`)、开箱即用,但常驻偏重。
> - **llama.cpp**:单文件更轻、可离线自制 GGUF;但模型要自己下 / 转(无 registry)。**只认 GGUF**,`model.safetensors` 不能直接喂。
### 注意事项
- **勿用 `/tmp` 放二进制 / 模型**(tmpfs 在内存里);一律 `~/apps/`、`~/.models/`。
- `EMBEDDING_API_URL` **必须是完整 `/v1/embeddings`**(不是 base),否则 404。
- **切换 embedding 模型会改维度**(Qwen3=1024,n-gram=256)→ 换模型前清一次向量表:`docker exec cyrene-pg psql -U cyrene -d cyrene_ai -c "TRUNCATE vec_items;"`。
- LLM 用 anthropic 协议时填 `ANTHROPIC_*`;用 openai 兼容时填 `LLM_*`。`LLM_PROVIDER` 二选一。
---
## 目录结构
```
Cyrene_v2/
├── cmd/cyrene/ # 唯一入口(wire_*.go 组合根:把各子系统接线 + go:embed 前端)
├── internal/ # 进程内模块(不对外)
│ ├── config/ # .env 加载 + $DATA_DIR/config/** 可变源(面板一等来源)
│ ├── logger/ platform/ db/ bus/ errors/ auth/ user/ # 地基
│ ├── runtime/ # 核心运行时:model / agent / loop / tool / guardrail / trace /
│ │ # persist(checkpoint) / memory / embedding / multimodal /
│ │ # orchestration / exec / task / flowgraph / documents / voice / capacity
│ ├── persona/ capability/ channel/ iot/ urgency/ contract/
│ ├── services/ # gateway(REST/WS/admin) / automations / backthink / approvalreview / channelbridge
│ └── webui/ # 内嵌前端 dist(go:embed)
├── plugins/ # 一等插件:tools(basic/io/browser) + channels(kook/qq_obv11/telegram/webhook/weixin_ilink)
├── pkg/plugins/sdk/ # 插件 SDK(第三方接入)
├── configs/ # 配置模板:personas/ + users/(主人档案)+ skills/(示例技能)
├── frontend/web/ # React SPA(单页消息流 + mode 渲染器)+ 管理面板(Miuix);构建产物 dist/ 供磁盘热覆盖
├── Docs/ # 用户文档(进 git):api-client.md(客户端 API 契约)+ archive/
├── DevDocs/ # 开发文档(**不进 git**,本地锚点:设计 / 时间线 / 工作小结 / 审计)
├── data/ # 运行时数据(DATA_DIR;SQLite / uploads / 面板写入的 *.json)
├── scripts/ # ctrl.bat / ctrl.sh / embed.bat / dev-up.sh / searxng/
└── services/ # 预留(外部服务扩展位)
```
> `Docs/`(大写 D)**进 git**;`DevDocs/` **被 gitignore,不进 git**。`*.exe`、`dist/`、`data/`、`.env` 等亦不入库(见 `.gitignore`)。
---
## 已知缺口与路线图
> **不美化**:以下是**当前真做不了 / 没做完**的事。
**核心运行时**
1. 🔴 **`Runner.Run` 多步工作流未接进 loop** — 技能的多步执行器可用且有单测,但**模型还不能在对话里触发一整套多步技能**(现只到「清单 / 命中展开正文 / 工具面收缩」)。
2. **自排思考 C2 的写路径未做** — 自排的成本可见读侧(`?trigger=` 筛选)已落地,面板可保存的后台思考调优(`SaveBackgroundTuning` + admin 端点)在做。
3. **全链路 P2 剩余** — token / 耗时统计、移动端优化。
**浏览器**
4. 🔴 **MCP 委派未落地** — `click` / `type` / `wait` / `extract` 尚未接通(只在工具描述里指路)。
5. **搜索降级档 B5 未做**;容器 docker 配方(CJK 字体)未落盘;浏览器会话未挂进程关闭链。
**渠道**
6. **QQ OneBot v11 暂不连**(主人 2026-10-05 因风控决定);**Telegram 需 Bot token**。群聊类能力(冷避 / @ 窗口 / 戳一戳)因此暂无活体平台可验。
7. **搜索 5 家云端 provider 未真机实测**(无 key);微信 iLink 依赖微信侧绑定资格;声音复刻需公网参考音频 URL。
8. **视觉 / OCR 真模型需凭据**;`edge-tts` / `espeak` 仍是占位。
**治理 / 其他**
9. **系统设置**:编辑环境变量 / 重启服务 / 检查更新 — 后端未实现。
10. **Getui(个推)兜底未接入**(SDK 未接);待主人自建推送服务。
11. **部分真机端到端未验**(如实登记):时间轴真机面板联调、搜索 provider 热重载真机端到端、AMR 落副本生产端到端、`graph_update` 真并行真机端到端、`design/19 §6.8` 的 Q1–Q4。
12. **Android 客户端**另会话推进(跨仓协同事项见 `DevDocs`,不入本仓库)。
---
## 技术栈
| 层 | 技术 |
|----|------|
| 后端 | Go 1.26(单二进制运行时)、进程内事件总线(API 刻意对齐 NATS,便于日后换出进程 transport)、`slog`(日志带 `source` 来源标注) |
| 前端 | React 19、TypeScript、Vite 6、Tailwind 4、Zustand 5、PWA(Miuix 设计语言) |
| 数据库 | **SQLite 默认**(纯 Go、单二进制);PG + pgvector 可选增强(配置后真生效) |
| 多模态 | vision(分层路由:主 / 视觉 / OCR;真模型需凭据,无凭据降级主模型)、voice(DashScope STT/TTS 真实接通 + 降级链;无凭据降级 `silent`) |
| LLM | 多供应商(OpenAI 兼容 / Anthropic / 本地 / 网关)、per-role 路由 + 降级(主 → 备 → 本地) |
| 插件 / 协议 | 进程内插件 SDK(`pkg/plugins/sdk`)、官方 MCP SDK(stdio / SSE / Streamable HTTP) |
---
## 文档
| 文档 | 位置 | 说明 |
|------|------|------|
| 架构总览 / 重构蓝图 | `DevDocs/00-overview.md` | 产品定位、架构、渠道插件、后台面板、阶段计划(**本地,不进 git**) |
| 开发约束 | `DevDocs/02-development-constraints.md` | 编码规范 + 性能要求(**本地**) |
| **客户端 API** | `Docs/api-client.md` | 跨平台客户端 API 契约(REST / WS / admin,版本化,当前 **v0.81**) |
| 开发铁律 | `AGENTS.md` | 每会话自动注入;含全球红线(单二进制 / 身份识别 / 前端 Miuix 等) |
---
## License
Apache-2.0 © 2026 Aska
View File
Whitespace-only changes.
+309
View File
@@ -0,0 +1,309 @@
package main
// 本文件从运行时可变配置构建四个内置 IM 渠道适配器。有效来源是 channels.json
// (存在且合法时优先,design/04 §3.1);否则回退到 env 渠道块(CHANNELS_JSON
// 多账号 + 单账号 CHANNEL_<NAME>_* 变量),后者是最终兜底,使裸装环境在没有任何
// 配置文件时也能启动。
//
// 每个账号各自成为一个适配器实例。单账号 env 适配器以裸平台名命名(如 "kook");
// 多账号条目命名为 "kook:confID",并包在 namedAdapter 里,通过 Config() 暴露其
// allow/deny 名单,供渠道桥的黑/白名单闸门使用(design/16 §4.6 §5-6)。
// channelFactory 让桥在面板保存新配置时热替换某平台的适配器(Bridge.Reload),
// 因此 token/地址/速率变更无需重启即可生效。
import (
"fmt"
"log/slog"
"net/http"
"os"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/kook"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/qq_obv11"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/telegram"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/webhook"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/weixin_ilink"
)
// buildChannelAdapters 从有效账号配置(channels.json → env 兜底)构建全部渠道
// 适配器,并返回构建成功的子集。适配器构建从不发起网络连接,真正的连接发生在渠道桥
// 里。f 持有路径/兜底/logger,使启动路径与热重载工厂读取同一来源。
func buildChannelAdapters(f *channelFactory) []adapter.PlatformAdapter {
accounts, warn := runtimeconfig.LoadChannels(f.path, f.fallback)
if warn != nil {
f.log.Warn("channels.json load fell back to env", "error", warn)
}
out := make([]adapter.PlatformAdapter, 0)
for platform, accts := range accounts {
out = append(out, buildPlatformAdapters(platform, accts, f.log)...)
}
return out
}
// channelFactory 是接入渠道桥的 channelbridge.PlatformFactory。它从**当前**有效
// 配置(文件 → env)重建某平台的适配器,因此面板保存后调用 Bridge.Reload(platform)
// 即可让新配置生效,无需重启。
type channelFactory struct {
path string
fallback map[string][]config.ChannelAccount
log *slog.Logger
}
// Build 返回某平台按其有效账号配置构建出的适配器。
func (f *channelFactory) Build(platform string) ([]adapter.PlatformAdapter, error) {
accounts, warn := runtimeconfig.LoadChannels(f.path, f.fallback)
if warn != nil {
f.log.Warn("channels.json reload fell back to env", "error", warn)
}
return buildPlatformAdapters(platform, accounts[platform], f.log), nil
}
// Persona 返回某平台某个渠道账号所绑定的角色(design/22 §2 方案 b)。它读取与
// Build 相同的有效配置 —— channels.json 优先、env 兜底 —— 因此「面板保存 + 桥
// 热重载」始终是唯一的更新路径;它每次适配器连接调用一次,而不是每条消息调用一次。
// 平台/账号未知,或账号未配 personaId 时返回 ""(无渠道级绑定:改用会话绑定或全局
// 默认)。
func (f *channelFactory) Persona(platform, accountID string) string {
accounts, warn := runtimeconfig.LoadChannels(f.path, f.fallback)
if warn != nil {
f.log.Warn("channels.json persona lookup fell back to env", "error", warn)
}
for _, acct := range accounts[platform] {
if strings.TrimSpace(acct.ID) == strings.TrimSpace(accountID) {
return strings.TrimSpace(acct.PersonaID)
}
}
return ""
}
// buildPlatformAdapters 把某平台的全部账号各构建成一个适配器实例。某个账号构建
// 失败只记一条 WARN 并跳过(绝不致命),与桥的「没有凭据 → 告警并继续」约定一致。
func buildPlatformAdapters(platform string, accts []config.ChannelAccount, log *slog.Logger) []adapter.PlatformAdapter {
out := make([]adapter.PlatformAdapter, 0, len(accts))
for _, acct := range accts {
built, berr := buildAccountAdapter(platform, acct, log)
if berr != nil {
log.Warn("channel account build skipped", "platform", platform, "account", acct.ID, "error", berr)
continue
}
out = append(out, built)
}
return out
}
// channelAccountsFromEnv 合成 env 兜底:单账号 CHANNEL_<NAME>_* 适配器,加上
// CHANNELS_JSON 里的多账号条目。它是 channels.json 缺失或非法时传给
// runtimeconfig.LoadChannels 的 .env 兜底。.env 只承载必要的连接兜底
// (token/base_url/...);业务与高级的按账号字段(receive_mode、webhook
// path/verify_token/encrypt_key、allow/deny 名单、速率限制)在面板的渠道管理里
// 配置,落在 channels.json,绝不出现在 .env。
//
// 需要 token 的平台(kook/telegram/weixin-ilink)只有在其 token 环境变量已设置时
// 才合成;token 未设置就完全不存在该账号,因此空 token 的占位符绝不会占用裸平台名。
// qq-obv11 与 webhook 始终存在(它们不需要 token;由桥的 configured() 闸门决定是否
// 拨号),所以渠道列表保持稳定。
func channelAccountsFromEnv() map[string][]config.ChannelAccount {
out := map[string][]config.ChannelAccount{}
// KOOK:只放必要的连接字段。receive_mode / webhook_path /
// webhook_verify_token / webhook_encrypt_key 属于业务·高级字段,经由面板写入
// channels.json —— .env 不再对它们做兜底。
if tok := os.Getenv("CHANNEL_KOOK_TOKEN"); tok != "" {
out["kook"] = append(out["kook"], config.ChannelAccount{Config: map[string]any{
"bot_token": tok,
"base_url": os.Getenv("CHANNEL_KOOK_BASE_URL"),
}})
}
// Telegram:token(必要)+ 可选的 webhook_url(连接)。业务调优写入
// channels.json。
if tok := os.Getenv("CHANNEL_TELEGRAM_BOT_TOKEN"); tok != "" {
out["telegram"] = append(out["telegram"], config.ChannelAccount{Config: map[string]any{
"bot_token": tok,
"webhook_url": os.Getenv("CHANNEL_TELEGRAM_WEBHOOK_URL"),
}})
}
// qq-obv11 的四个端(裁决 1.4):transport(ws|http) × mode(client|server)。
// 默认 transport=ws、mode=server,与改造前的行为完全一致(保守:不改变已部署
// 现状);listen_addr 是 server 端的显式监听地址,留空即不开端口(AGENTS §9)。
out["qq-obv11"] = append(out["qq-obv11"], config.ChannelAccount{Config: map[string]any{
"transport": envString("CHANNEL_QQ_OBV11_TRANSPORT", "ws"),
"mode": envString("CHANNEL_QQ_OBV11_MODE", "server"),
"listen_addr": os.Getenv("CHANNEL_QQ_OBV11_LISTEN_ADDR"),
"remote_url": os.Getenv("CHANNEL_QQ_OBV11_REMOTE_URL"),
"api_base_url": os.Getenv("CHANNEL_QQ_OBV11_API_BASE_URL"),
"report_path": os.Getenv("CHANNEL_QQ_OBV11_REPORT_PATH"),
"access_token": os.Getenv("CHANNEL_QQ_OBV11_ACCESS_TOKEN"),
"self_id": os.Getenv("CHANNEL_QQ_OBV11_SELF_ID"),
}})
out["webhook"] = append(out["webhook"], config.ChannelAccount{Config: map[string]any{
"endpointPath": os.Getenv("CHANNEL_WEBHOOK_ENDPOINT_PATH"),
"channelId": os.Getenv("CHANNEL_WEBHOOK_CHANNEL_ID"),
"channelName": os.Getenv("CHANNEL_WEBHOOK_CHANNEL_NAME"),
"secret": os.Getenv("CHANNEL_WEBHOOK_SECRET"),
"signatureHeader": os.Getenv("CHANNEL_WEBHOOK_SIGNATURE_HEADER"),
}})
if tok := os.Getenv("CHANNEL_WEIXIN_ILINK_BOT_TOKEN"); tok != "" {
out["weixin-ilink"] = append(out["weixin-ilink"], config.ChannelAccount{Config: map[string]any{
"base_url": os.Getenv("CHANNEL_WEIXIN_ILINK_BASE_URL"),
"cdn_base_url": os.Getenv("CHANNEL_WEIXIN_ILINK_CDN_BASE_URL"),
"bot_token": tok,
"bot_id": os.Getenv("CHANNEL_WEIXIN_ILINK_BOT_ID"),
"owner_id": os.Getenv("CHANNEL_WEIXIN_ILINK_OWNER_ID"),
}})
}
if raw := os.Getenv("CHANNELS_JSON"); raw != "" {
if accounts, err := config.ParseChannelAccounts(raw); err == nil {
for platform, accts := range accounts {
out[platform] = append(out[platform], accts...)
}
}
}
return out
}
// namedAdapter 包装平台适配器,使多账号实例获得唯一名字(platform:confID),并
// 通过 Config() 暴露其 allow/deny 名单 —— 渠道桥读取它来按账号执行平台的黑/白名单
// (design/16 §4.6 §5-6)。其余方法全部委派给内层适配器。
type namedAdapter struct {
adapter.PlatformAdapter
name string
allowGroups []string
denyGroups []string
allowUsers []string
denyUsers []string
// 流控限制(design/12 §6 策略三/四)。它们始终经 Config() 暴露,使桥能构建按账号
// 的入口闸门;桥把零值默认映射为保护性取值,把负值映射为「关闭」。
groupMsgPerMin int
directMsgPerMin int
msgPerMin int
maxMessages int
}
func (n *namedAdapter) Name() string { return n.name }
func (n *namedAdapter) Config() map[string]any {
cfg := n.PlatformAdapter.Config()
out := make(map[string]any, len(cfg)+8)
for k, v := range cfg {
out[k] = v
}
out["allowGroups"] = n.allowGroups
out["denyGroups"] = n.denyGroups
out["allowUsers"] = n.allowUsers
out["denyUsers"] = n.denyUsers
out["groupMsgPerMin"] = n.groupMsgPerMin
out["directMsgPerMin"] = n.directMsgPerMin
out["msgPerMin"] = n.msgPerMin
out["maxMessages"] = n.maxMessages
return out
}
// InboundHandler 在内层适配器暴露入站 HTTP 回调时转发给它;桥会对包装器做类型
// 断言,并把该 handler 挂到 WebhookPath。内层适配器不是 webhook 时返回 nil。
func (n *namedAdapter) InboundHandler() http.Handler {
if inner, ok := n.PlatformAdapter.(interface{ InboundHandler() http.Handler }); ok {
return inner.InboundHandler()
}
return nil
}
// WebhookPath 转发内层适配器的 webhook 路由;内层不暴露该路由时返回 ""(桥据此
// 跳过它)。
func (n *namedAdapter) WebhookPath() string {
if inner, ok := n.PlatformAdapter.(interface{ WebhookPath() string }); ok {
return inner.WebhookPath()
}
return ""
}
// buildAccountAdapter 为某平台构建单个账号的适配器:把账号的通用 Config map 解码
// 成该平台的强类型配置,并包装起来,使桥看到唯一的账号名及其 allow/deny 名单。
func buildAccountAdapter(platform string, acct config.ChannelAccount, log *slog.Logger) (adapter.PlatformAdapter, error) {
name := platform
if acct.ID != "" {
name = platform + ":" + acct.ID
}
var inner adapter.PlatformAdapter
switch barePlatform(platform) {
case "telegram":
var cfg telegram.Config
if err := adapter.DecodeConfig(acct.Config, &cfg); err != nil {
return nil, fmt.Errorf("decode telegram config: %w", err)
}
if strings.TrimSpace(cfg.BotToken) == "" {
return nil, fmt.Errorf("%s: missing bot token", platform)
}
inner = telegram.New(cfg, telegram.WithLogger(log))
case "weixin-ilink":
var cfg weixin_ilink.Config
if err := adapter.DecodeConfig(acct.Config, &cfg); err != nil {
return nil, fmt.Errorf("decode weixin-ilink config: %w", err)
}
// weixin-ilink 账号允许 bot_token 为空:这是一条待扫码登录的账号,面板通过
// login-qr/login-status 管理端点驱动它(design/08 §4)。桥的 configured() 闸门
// 会跳过它,直到扫码确认且 token 落库,因此这里空 token 是合法的 —— 应当构建
// 该适配器(未连接),而不是丢弃它。
w, err := weixin_ilink.New(cfg, nil, logger.WithSource(log, "plugins/channels/weixin_ilink"))
if err != nil {
return nil, fmt.Errorf("build weixin-ilink: %w", err)
}
inner = w
case "qq-obv11":
var cfg qq_obv11.Config
if err := adapter.DecodeConfig(acct.Config, &cfg); err != nil {
return nil, fmt.Errorf("decode qq-obv11 config: %w", err)
}
inner = qq_obv11.NewAdapter(cfg, qq_obv11.WithLogger(log))
case "kook":
var cfg kook.Config
if err := adapter.DecodeConfig(acct.Config, &cfg); err != nil {
return nil, fmt.Errorf("decode kook config: %w", err)
}
if strings.TrimSpace(cfg.BotToken) == "" {
return nil, fmt.Errorf("%s: missing bot token", platform)
}
inner = kook.New(cfg, kook.WithLogger(log))
case "webhook":
var cfg webhook.Config
if err := adapter.DecodeConfig(acct.Config, &cfg); err != nil {
return nil, fmt.Errorf("decode webhook config: %w", err)
}
inner = webhook.New(webhook.WithConfig(cfg), webhook.WithLogger(logger.WithSource(log, "plugins/channels/webhook")))
default:
return nil, fmt.Errorf("unknown platform %q", platform)
}
return &namedAdapter{
PlatformAdapter: inner,
name: name,
allowGroups: acct.AllowGroups,
denyGroups: acct.DenyGroups,
allowUsers: acct.AllowUsers,
denyUsers: acct.DenyUsers,
groupMsgPerMin: acct.GroupMsgPerMin,
directMsgPerMin: acct.DirectMsgPerMin,
msgPerMin: acct.MsgPerMin,
maxMessages: acct.MaxMessages,
}, nil
}
// barePlatform 返回可能带后缀的 "platform:confID" 的平台名。
func barePlatform(name string) string {
for i := 0; i < len(name); i++ {
if name[i] == ':' {
return name[:i]
}
}
return name
}
// envString 返回 key 的值;环境变量未设置或为空白时返回 fallback。
func envString(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
@@ -0,0 +1,61 @@
package main
// 覆盖裁决 1.4 在组合根侧的落点:qq-obv11 的四个端所需配置项由 .env 兜底进适配器配置,
// 且默认值保持现状(transport=ws、mode=server),不改变已部署行为。
import "testing"
// TestChannelAccountsFromEnv_QQObv11FourEnds 验证 env 兜底把 transport / mode /
// listen_addr / remote_url / api_base_url / report_path 带进账号配置,并且默认口径不变。
func TestChannelAccountsFromEnv_QQObv11FourEnds(t *testing.T) {
for _, k := range []string{
"CHANNEL_QQ_OBV11_TRANSPORT", "CHANNEL_QQ_OBV11_MODE", "CHANNEL_QQ_OBV11_LISTEN_ADDR",
"CHANNEL_QQ_OBV11_REMOTE_URL", "CHANNEL_QQ_OBV11_API_BASE_URL", "CHANNEL_QQ_OBV11_REPORT_PATH",
} {
t.Setenv(k, "")
}
acct := qqObv11EnvAccount(t)
if acct["transport"] != "ws" || acct["mode"] != "server" {
t.Fatalf("defaults = transport:%v mode:%v, want ws/server", acct["transport"], acct["mode"])
}
t.Setenv("CHANNEL_QQ_OBV11_TRANSPORT", "http")
t.Setenv("CHANNEL_QQ_OBV11_MODE", "client")
t.Setenv("CHANNEL_QQ_OBV11_API_BASE_URL", "http://127.0.0.1:3000")
t.Setenv("CHANNEL_QQ_OBV11_REPORT_PATH", "/onebot/report")
list := channelAccountsFromEnv()["qq-obv11"]
if len(list) != 1 {
t.Fatalf("qq-obv11 env accounts = %d, want 1", len(list))
}
acct = list[0].Config
if acct["transport"] != "http" || acct["mode"] != "client" {
t.Errorf("transport/mode = %v/%v, want http/client", acct["transport"], acct["mode"])
}
if acct["api_base_url"] != "http://127.0.0.1:3000" || acct["report_path"] != "/onebot/report" {
t.Errorf("api_base_url/report_path = %v/%v, want the env values", acct["api_base_url"], acct["report_path"])
}
// 端到端一次:env 配置经 DecodeConfig 进适配器后,四个端的开关仍然在。
built, err := buildAccountAdapter("qq-obv11", list[0], testLog())
if err != nil {
t.Fatalf("buildAccountAdapter error: %v", err)
}
cfg := built.Config()
if cfg["transport"] != "http" || cfg["mode"] != "client" {
t.Errorf("adapter transport/mode = %v/%v, want http/client", cfg["transport"], cfg["mode"])
}
if cfg["apiBaseURL"] != "http://127.0.0.1:3000" || cfg["reportPath"] != "/onebot/report" {
t.Errorf("adapter apiBaseURL/reportPath = %v/%v, want the env values", cfg["apiBaseURL"], cfg["reportPath"])
}
}
// qqObv11EnvAccount 取出 env 兜底里合成的 qq-obv11 账号配置。
func qqObv11EnvAccount(t *testing.T) map[string]any {
t.Helper()
list := channelAccountsFromEnv()["qq-obv11"]
if len(list) != 1 {
t.Fatalf("qq-obv11 env accounts = %d, want 1", len(list))
}
return list[0].Config
}
+106
View File
@@ -0,0 +1,106 @@
package main
// 钉住组合根侧的接线契约:buildAccountAdapter 构造 qq-obv11 适配器时必须把 channelFactory
// 手上的 logger 传进去(与 kook/telegram/webhook 同口径,见 cmd/cyrene/channels.go)。缺陷
// 形态:漏传 ⇒ 适配器自造默认 INFO 兜底 ⇒ 入站帧取证(DEBUG)全部静默丢弃,主人要查的
// 「群友 @ 没回 / 戳一戳没反应」在日志里一片空白,且与「上游压根没上报」无法区分。
//
// 断言走**真实入站路径**:真开一次 http+server 端、真 POST 一条 OneBot 上报,再看注入的
// 捕获器里有没有 `msg="inbound frame"`。所以只要接线一断,这条测试必红。
import (
"bytes"
"context"
"log/slog"
"net/http"
"strings"
"sync"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/qq_obv11"
)
// logSyncBuf 是并发安全的日志捕获目标:上报由 http.Server 自己的 goroutine 处理,断言在
// 测试 goroutine 上读。
type logSyncBuf struct {
mu sync.Mutex
buf bytes.Buffer
}
func (b *logSyncBuf) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.Write(p)
}
func (b *logSyncBuf) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.String()
}
// qqObv11WiringFrame 是一条最普通的群消息事件(与 plugins/channels/qq_obv11 的取证测试
// 同口径):走到 handlePayload 就必然留一行入站帧。
const qqObv11WiringFrame = `{"post_type":"message","message_type":"group","sub_type":"normal",
"self_id":10001,"user_id":20002,"group_id":1070898847,
"raw_message":"hello","message":[{"type":"text","data":{"text":"hello"}}]}`
// TestBuildAccountAdapter_QQObv11WiresLoggerIntoInboundForensics 断言注入的 logger 真的
// 到了适配器手里。捕获器级别设为 DEBUG:取证日志本身是 DEBUG 级,用默认 INFO 的捕获器
// 「丢了」与「没丢」都看不见,测不出这条接线。
func TestBuildAccountAdapter_QQObv11WiresLoggerIntoInboundForensics(t *testing.T) {
buf := &logSyncBuf{}
log := slog.New(slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
acct := config.ChannelAccount{Config: map[string]any{
"transport": "http",
"mode": "server",
"listen_addr": "127.0.0.1:0",
"report_path": "/onebot/report",
}}
built, err := buildAccountAdapter("qq-obv11", acct, log)
if err != nil {
t.Fatalf("buildAccountAdapter: %v", err)
}
ctx := context.Background()
if cerr := built.Connect(ctx); cerr != nil {
t.Fatalf("Connect: %v", cerr)
}
t.Cleanup(func() { _ = built.Disconnect(context.Background()) })
post := "http://" + qqObv11ListenAddr(t, built) + "/onebot/report"
resp, perr := http.Post(post, "application/json", strings.NewReader(qqObv11WiringFrame))
if perr != nil {
t.Fatalf("POST %s: %v", post, perr)
}
if cerr := resp.Body.Close(); cerr != nil {
t.Fatalf("close response body: %v", cerr)
}
if got := buf.String(); !strings.Contains(got, `msg="inbound frame"`) {
t.Fatalf("注入的 logger 没收到入站帧日志——组合根没把 logger 接进 qq-obv11 适配器?\n捕获到的日志:\n%s", got)
}
if got := buf.String(); !strings.Contains(got, "source=plugins/channels/qq_obv11") {
t.Errorf("入站帧日志缺来源标注:\n%s", got)
}
}
// qqObv11ListenAddr 取出适配器实际绑定的监听地址(listen_addr 用 :0 让内核挑端口)。
func qqObv11ListenAddr(t *testing.T, built adapter.PlatformAdapter) string {
t.Helper()
named, ok := built.(*namedAdapter)
if !ok {
t.Fatalf("适配器类型 = %T, want *namedAdapter", built)
}
inner, ok := named.PlatformAdapter.(*qq_obv11.Adapter)
if !ok {
t.Fatalf("内层适配器类型 = %T, want *qq_obv11.Adapter", named.PlatformAdapter)
}
addr := inner.Addr()
if addr == "" {
t.Fatal("适配器没绑定监听地址:http+server 端没起来")
}
return addr
}
+222
View File
@@ -0,0 +1,222 @@
package main
import (
"io"
"log/slog"
"os"
"path/filepath"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
func testLog() *slog.Logger {
return logger.New(logger.Options{Out: io.Discard})
}
// testFactory builds a channelFactory pointing at a temp (absent) channels.json,
// seeded by the env fallback, so the test reads the env the same way boot does.
func testFactory(t *testing.T) *channelFactory {
t.Helper()
return &channelFactory{
path: filepath.Join(t.TempDir(), "channels.json"),
fallback: channelAccountsFromEnv(),
log: testLog(),
}
}
func TestBuildChannelAdapters_IncludesKook(t *testing.T) {
t.Setenv("CHANNEL_KOOK_TOKEN", "kook-secret")
adapters := buildChannelAdapters(testFactory(t))
if !hasAdapter(adapters, "kook") {
t.Fatalf("expected a kook adapter, got %v", names(adapters))
}
}
func TestBuildChannelAdapters_KookMultiAccount(t *testing.T) {
t.Setenv("CHANNELS_JSON", `{"kook":[{"id":"conf0","config":{"bot_token":"kook-secret"},"allowUsers":["u-1"],"denyGroups":["g-9"]}]}`)
adapters := buildChannelAdapters(testFactory(t))
if !hasAdapter(adapters, "kook:conf0") {
t.Fatalf("expected a kook:conf0 adapter, got %v", names(adapters))
}
// The named adapter must expose its allow/deny lists via Config().
for _, a := range adapters {
if a.Name() == "kook:conf0" {
cfg := a.Config()
if got, _ := cfg["allowUsers"].([]string); len(got) != 1 || got[0] != "u-1" {
t.Errorf("allowUsers = %v, want [u-1]", cfg["allowUsers"])
}
}
}
}
// TestBuildChannelAdapters_SkipsEmptyTokenKookFromFile verifies a stale
// channels.json with both a bare empty-token kook and a real token account builds
// only the token account, so the bare "kook" never registers (and never wins a
// bare-name open).
func TestBuildChannelAdapters_SkipsEmptyTokenKookFromFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "channels.json")
raw := `{"kook":[
{"id":"","config":{"base_url":"","bot_token":""}},
{"id":"3fc8","config":{"bot_token":"real-token"}}
]}`
if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
f := &channelFactory{path: path, fallback: channelAccountsFromEnv(), log: testLog()}
adapters := buildChannelAdapters(f)
if hasAdapter(adapters, "kook") {
t.Fatalf("bare empty-token kook should not be built, got %v", names(adapters))
}
if !hasAdapter(adapters, "kook:3fc8") {
t.Fatalf("expected kook:3fc8, got %v", names(adapters))
}
}
// TestChannelAccountsFromEnv_NoEmptyTokenPlaceholders verifies the env fallback
// does not synthesize an empty-token kook/telegram/weixin account (only a real
// token produces the account), so a bare placeholder is never registered.
func TestChannelAccountsFromEnv_NoEmptyTokenPlaceholders(t *testing.T) {
for _, k := range []string{"CHANNEL_KOOK_TOKEN", "CHANNEL_TELEGRAM_BOT_TOKEN", "CHANNEL_WEIXIN_ILINK_BOT_TOKEN", "CHANNELS_JSON"} {
t.Setenv(k, "")
}
env := channelAccountsFromEnv()
for _, platform := range []string{"kook", "telegram", "weixin-ilink"} {
if _, ok := env[platform]; ok {
t.Fatalf("empty-token %s should not be synthesized", platform)
}
}
}
// TestChannelAccountsFromEnv_KookPresentWhenTokenSet verifies a set token yields
// the account.
func TestChannelAccountsFromEnv_KookPresentWhenTokenSet(t *testing.T) {
t.Setenv("CHANNEL_KOOK_TOKEN", "kook-secret")
env := channelAccountsFromEnv()
list, ok := env["kook"]
if !ok || len(list) != 1 {
t.Fatalf("expected a synthesized kook account, got %+v", env["kook"])
}
if list[0].Config["bot_token"] != "kook-secret" {
t.Fatalf("bot_token = %v, want kook-secret", list[0].Config["bot_token"])
}
}
func hasAdapter(adapters []adapter.PlatformAdapter, name string) bool {
for _, a := range adapters {
if a.Name() == name {
return true
}
}
return false
}
// TestChannelFactoryBuildFromFile exercises the reload factory against a real
// channels.json file: the factory reads the file (file beats env) and builds a
// named adapter carrying the account's token and black-or-white lists, so a panel
// save → Reload path uses exactly this builder.
func TestChannelFactoryBuildFromFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "channels.json")
if err := os.WriteFile(path, []byte(`{"kook":[{"id":"conf0","config":{"bot_token":"bt"},"denyGroups":["g-1"],"allowUsers":["u-1"],"groupMsgPerMin":3,"directMsgPerMin":-1}]}`), 0o600); err != nil {
t.Fatalf("write channels.json: %v", err)
}
f := &channelFactory{path: path, fallback: channelAccountsFromEnv(), log: testLog()}
adpts, err := f.Build("kook")
if err != nil {
t.Fatalf("Build: %v", err)
}
if !hasAdapter(adpts, "kook:conf0") {
t.Fatalf("kook:conf0 not built, got %v", names(adpts))
}
for _, a := range adpts {
if a.Name() != "kook:conf0" {
continue
}
cfg := a.Config()
if cfg["bot_token"] != "bt" {
t.Errorf("bot_token = %v, want bt", cfg["bot_token"])
}
if got, _ := cfg["denyGroups"].([]string); len(got) != 1 || got[0] != "g-1" {
t.Errorf("denyGroups = %v, want [g-1]", cfg["denyGroups"])
}
if got, _ := cfg["allowUsers"].([]string); len(got) != 1 || got[0] != "u-1" {
t.Errorf("allowUsers = %v, want [u-1]", cfg["allowUsers"])
}
if got, _ := cfg["groupMsgPerMin"].(int); got != 3 {
t.Errorf("groupMsgPerMin = %v, want 3", cfg["groupMsgPerMin"])
}
// directMsgPerMin 同一条链:channels.json 的账号顶层字段 → namedAdapter.Config() →
// 桥的 gateOptions。断桥这一环就等于「面板/文件配了却没人读」(负值必须原样透出)。
if got, _ := cfg["directMsgPerMin"].(int); got != -1 {
t.Errorf("directMsgPerMin = %v, want -1", cfg["directMsgPerMin"])
}
}
}
// TestNamedAdapterForwardsWebhook verifies the production wrapper forwards a
// kook webhook-mode adapter's InboundHandler/WebhookPath to the channel bridge,
// so bridge.WebhookHandlers() mounts the KOOK callback route on the gateway. The
// webhook business fields (receive_mode/webhook_path) are sourced from
// channels.json — .env only carries the necessary connection fallback.
func TestNamedAdapterForwardsWebhook(t *testing.T) {
path := filepath.Join(t.TempDir(), "channels.json")
raw := `{"kook":[{"id":"conf0","config":{"bot_token":"kook-secret","receive_mode":"webhook","webhook_path":"/webhook/kook"}}]}`
if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
t.Fatalf("write channels.json: %v", err)
}
f := &channelFactory{path: path, fallback: channelAccountsFromEnv(), log: testLog()}
adpts, err := f.Build("kook")
if err != nil {
t.Fatalf("Build: %v", err)
}
var found *namedAdapter
for _, a := range adpts {
if nwa, ok := a.(*namedAdapter); ok && barePlatform(nwa.Name()) == "kook" {
found = nwa
break
}
}
if found == nil {
t.Fatalf("kook named adapter not found, got %v", names(adpts))
}
if p := found.WebhookPath(); p != "/webhook/kook" {
t.Errorf("WebhookPath = %q, want /webhook/kook", p)
}
if found.InboundHandler() == nil {
t.Error("InboundHandler() = nil, want a mounted handler")
}
}
// TestNamedAdapterNoWebhookInWSMode verifies a ws-mode adapter (all non-webhook
// platforms) does not surface a webhook handler through the wrapper.
func TestNamedAdapterNoWebhookInWSMode(t *testing.T) {
path := filepath.Join(t.TempDir(), "channels.json")
raw := `{"kook":[{"id":"conf0","config":{"bot_token":"kook-secret","receive_mode":"ws"}}]}`
if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
t.Fatalf("write channels.json: %v", err)
}
f := &channelFactory{path: path, fallback: channelAccountsFromEnv(), log: testLog()}
adpts, err := f.Build("kook")
if err != nil {
t.Fatalf("Build: %v", err)
}
for _, a := range adpts {
if barePlatform(a.Name()) != "kook" {
continue
}
if nwa, ok := a.(*namedAdapter); ok {
if p := nwa.WebhookPath(); p != "" {
t.Errorf("ws-mode kook WebhookPath = %q, want empty", p)
}
}
}
}
func names(adapters []adapter.PlatformAdapter) []string {
out := make([]string, 0, len(adapters))
for _, a := range adapters {
out = append(out, a.Name())
}
return out
}
+84
View File
@@ -0,0 +1,84 @@
package main
import (
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/emotion"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
)
// 本文件把 persona 情绪状态机(design/01 §2:情绪状态机 + 好感度曲线跟随
// persona/角色,绝不跟随行为模式)接到 agent 循环的轮次边界上。循环保持与情绪无关:
// 它上报每个已结算轮次,并把返回的心情标签带到 Result.Mood,由持久化层写到助手消息上
// (design/16 §3.3)。
//
// 本里程碑(A1)端到端接通的范围:
// - 每个已结算的主人轮次推进所绑定 persona 的情绪状态机与好感度(内存态,按 persona
// 为键,由 registry 的 emotion Manager 持有);
// - 由此得到的心情随助手消息进入 chat_messages 与 `message_persisted` 帧,而每次
// *变化* 都作为 `mood` 帧广播。
//
// 未接通(design/01 未作规定):心情尚未反注入 system 提示,因此不会左右回复文本;
// 情绪状态也不跨重启持久化(design/01 两者都不要求;启动时状态重置为 persona 配置的
// initialState)。
// moodEmitter 是面向 gateway 的 `mood` 广播出口。*gateway.Server 实现它
// (BroadcastMood);这里持有接口是为了让组合根能用假实现做单元测试,与
// capacityEmitter 一致。
type moodEmitter interface {
BroadcastMood(personaID, mood string, affection int)
}
// buildEmotionObserver 装配 persona 情绪观察者:registry 的 emotion Manager 提供
// 每个 persona 的状态机与好感度,registry 提供 persona 配置的好感度步长
// (好感度曲线),每次变化都经 publishMood 推送到 Web UI。
func (a *app) buildEmotionObserver() *emotion.Observer {
return emotion.NewObserver(a.personas.Emotions(), a.personas.AffectionStep, a.publishMood)
}
// onTurnEnd 是循环的轮次边界钩子(design/01 §2):已结算的轮次被施加到循环所绑定的
// persona 上,并把该轮之后的心情返回给助手消息。persona id 缺失时退回默认 persona,
// 与循环的绑定方式一致。
//
// 两道闸门确保只有真实对话轮次才触发:
// - 只有面向用户的对话车道(Web 主会话及其子会话;IM 主人私聊走主会话,
// design/16 §4.4)会推动心情。自主与委派的循环调用 —— 后台思考(sess-backthink)、
// 子代理工作、隔离的 IM 会话 —— 不是主人对话的轮次,因此不得改写她的心情;
// - 受限(群聊/陌生人)对话是只读的:观察者拒绝写入,因此第三方永远无法左右她的
// 心情或好感度(design/02 §3、design/11 §2)。
func (a *app) onTurnEnd(end loop.TurnEnd) string {
if a.emotion == nil || !isConversationLane(end.SessionID) {
return ""
}
personaID := end.PersonaID
if personaID == "" {
personaID = a.defaultPersonaID()
}
return string(a.emotion.Observe(personaID, end.UserText, !end.Env.Restricted()))
}
// isConversationLane 报告某会话是否属于主人的实时对话:固定的主会话 "web" 及其
// "web:<uuid>" 子会话(design/16 §2.1 —— IM 主人私聊被路由进主会话)。
func isConversationLane(sessionID string) bool {
return sessionID == history.MainSessionID || strings.HasPrefix(sessionID, history.MainSessionID+":")
}
// publishMood 把一次情绪变化作为 `mood` 帧广播(design/07 §3、design/01 §2),
// 使 Web 的心情徽标无需刷新即可跟随 persona。它只在心情确实变化时调用,因此该帧
// 是新闻而不是每轮噪声。出口在调用时读取,因为 gateway 构建在循环之后;它还不存在
// 时该变化只记日志并丢弃。
func (a *app) publishMood(update emotion.Update) {
log := logger.WithSource(a.log, "cmd/cyrene")
if a.moodSink == nil {
log.Debug("mood change dropped: gateway not ready",
"persona", update.PersonaID, "from", string(update.Before), "to", string(update.After),
"affection", update.Affection, "signal", update.Signal)
return
}
log.Info("persona mood changed",
"persona", update.PersonaID, "from", string(update.Before), "to", string(update.After),
"affection", update.Affection, "signal", update.Signal)
a.moodSink.BroadcastMood(update.PersonaID, string(update.After), update.Affection)
}
+249
View File
@@ -0,0 +1,249 @@
package main
import (
"context"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/emotion"
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// fakeMoodEmitter captures the `mood` broadcasts the composition root forwards,
// so tests can assert an emotion change actually reaches the Web UI.
type fakeMoodEmitter struct {
calls int
personaID string
mood string
affection int
}
func (f *fakeMoodEmitter) BroadcastMood(personaID, mood string, affection int) {
f.calls++
f.personaID = personaID
f.mood = mood
f.affection = affection
}
// moodAdapter is a model.Adapter returning fixed text, so a test can drive a real
// loop turn without a provider.
type moodAdapter struct{ reply string }
func (m moodAdapter) Name() string { return "mood-test" }
func (m moodAdapter) Chat(context.Context, model.Request, model.Options) (model.Response, error) {
return model.Response{Content: m.reply}, nil
}
func (m moodAdapter) ChatStream(ctx context.Context, req model.Request, _ func(model.StreamEvent)) (model.Response, error) {
return m.Chat(ctx, req, model.Options{})
}
// testEmotionApp builds an app with one registered persona (emotion calm, decay
// enabled, affection 50/700 step 10) and the emotion observer wired exactly as
// the composition root wires it, plus a capturing mood sink.
func testEmotionApp(t *testing.T) (*app, *fakeMoodEmitter) {
t.Helper()
reg := personareg.New()
persona := &loader.Persona{
ID: "cyrene",
Name: "昔涟",
Emotion: loader.EmotionParams{
InitialState: emotion.Calm,
DecayRate: 0.5,
},
Affection: loader.AffectionParams{Initial: 50, Min: 0, Max: 700, Delta: 10},
}
if err := reg.RegisterPersona(persona); err != nil {
t.Fatalf("RegisterPersona: %v", err)
}
if err := reg.RegisterCharacter(&personareg.Character{ID: "cyrene", Name: "昔涟", Default: true, PersonaID: "cyrene"}); err != nil {
t.Fatalf("RegisterCharacter: %v", err)
}
sink := &fakeMoodEmitter{}
a := &app{log: testLog(), personas: reg, moodSink: sink}
a.emotion = a.buildEmotionObserver()
return a, sink
}
// testMoodLoop builds a real loop whose turn boundary is the app's hook.
func testMoodLoop(t *testing.T, a *app, reply string) *loop.Loop {
t.Helper()
ag, err := agent.New("你是昔涟。", nil, nil, agent.WithModel(moodAdapter{reply: reply}, nil))
if err != nil {
t.Fatalf("agent.New: %v", err)
}
return loop.New(loop.Options{Agent: ag, PersonaID: "cyrene", OnTurnEnd: a.onTurnEnd})
}
// TestOnTurnEndDrivesEmotionThroughLoop is the A1 end-to-end lock: a real loop
// turn is applied to the bound persona's emotion machine, the mood lands on
// Result.Mood (which the persistence layer writes onto the assistant message),
// affection steps by the persona's configured magnitude, and the change is
// broadcast once as a `mood` frame.
func TestOnTurnEndDrivesEmotionThroughLoop(t *testing.T) {
a, sink := testEmotionApp(t)
l := testMoodLoop(t, a, "人家也开心♪")
res, err := l.Run(loop.WithSessionEnv(context.Background(), loop.OwnerEnv()), "web",
[]model.Message{model.TextMessage(model.RoleUser, "今天谢谢你呀,好开心")})
if err != nil {
t.Fatalf("Run: %v", err)
}
if res.Mood != string(emotion.Happy) {
t.Fatalf("Result.Mood = %q, want happy", res.Mood)
}
machine, affection, ok := a.personas.Emotions().Get("cyrene")
if !ok {
t.Fatal("persona cyrene not registered in the emotion manager")
}
if machine.Current() != emotion.Happy {
t.Fatalf("machine state = %s, want happy", machine.Current())
}
if affection.Value() != 60 {
t.Fatalf("affection = %d, want 60 (50 + the persona's step 10)", affection.Value())
}
if sink.calls != 1 || sink.mood != string(emotion.Happy) || sink.personaID != "cyrene" || sink.affection != 60 {
t.Fatalf("mood broadcast = %+v, want one happy/cyrene/60 frame", sink)
}
}
// TestOnTurnEndEvolvesAndDecaysAcrossTurns locks how the emotion walks over a
// conversation: each emotional turn moves one legal edge toward the state the cue
// aims at (so a strong warm cue reaches excited on the second turn, not the
// first), a cue-free turn lets it decay one step toward calm (design/01 §2
// decayRate), and each real move is broadcast once while an unchanged mood
// broadcasts nothing.
func TestOnTurnEndEvolvesAndDecaysAcrossTurns(t *testing.T) {
a, sink := testEmotionApp(t)
l := testMoodLoop(t, a, "嗯")
ctx := loop.WithSessionEnv(context.Background(), loop.OwnerEnv())
res, err := l.Run(ctx, "web", []model.Message{model.TextMessage(model.RoleUser, "我太开心了,最喜欢你了")})
if err != nil {
t.Fatalf("Run: %v", err)
}
if res.Mood != string(emotion.Happy) {
t.Fatalf("first strong warm turn mood = %q, want the first legal step happy", res.Mood)
}
// A second strong warm turn escalates to excited.
res, err = l.Run(ctx, "web", []model.Message{model.TextMessage(model.RoleUser, "最喜欢你了")})
if err != nil {
t.Fatalf("Run: %v", err)
}
if res.Mood != string(emotion.Excited) {
t.Fatalf("second strong warm turn mood = %q, want excited", res.Mood)
}
if sink.calls != 2 || sink.mood != string(emotion.Excited) {
t.Fatalf("broadcasts = %d/%s, want 2 with excited", sink.calls, sink.mood)
}
// Same mood again: no change, so no third frame.
res, err = l.Run(ctx, "web", []model.Message{model.TextMessage(model.RoleUser, "还是最喜欢你")})
if err != nil {
t.Fatalf("Run: %v", err)
}
if res.Mood != string(emotion.Excited) {
t.Fatalf("third warm turn mood = %q, want excited unchanged", res.Mood)
}
if sink.calls != 2 {
t.Fatalf("broadcasts = %d, want 2 (unchanged mood is not news)", sink.calls)
}
// A cue-free turn carries no signal, so the emotion decays excited → happy.
res, err = l.Run(ctx, "web", []model.Message{model.TextMessage(model.RoleUser, "帮我看下这个报错")})
if err != nil {
t.Fatalf("Run: %v", err)
}
if res.Mood != string(emotion.Happy) {
t.Fatalf("neutral turn mood = %q, want decayed happy", res.Mood)
}
if sink.calls != 3 || sink.mood != string(emotion.Happy) {
t.Fatalf("broadcasts = %d/%s, want 3 with happy", sink.calls, sink.mood)
}
}
// TestOnTurnEndRestrictedConversationIsReadOnly locks the isolation boundary at
// the wiring level: a group/stranger turn never moves the persona's mood or
// affection and never broadcasts, matching the read-only memory lane (design/02
// §3, design/11 §2).
func TestOnTurnEndRestrictedConversationIsReadOnly(t *testing.T) {
a, sink := testEmotionApp(t)
l := testMoodLoop(t, a, "你好呀")
restricted := loop.SessionEnv{MemWritable: false}
ctx := loop.WithSessionEnv(context.Background(), restricted)
res, err := l.Run(ctx, history.MainSessionID, []model.Message{model.TextMessage(model.RoleUser, "我好喜欢你")})
if err != nil {
t.Fatalf("Run: %v", err)
}
if res.Mood != "" {
t.Fatalf("Result.Mood = %q, want empty for a read-only conversation", res.Mood)
}
machine, affection, _ := a.personas.Emotions().Get("cyrene")
if machine.Current() != emotion.Calm || affection.Value() != 50 {
t.Fatalf("restricted turn moved the persona to %s/%d, want calm/50", machine.Current(), affection.Value())
}
if sink.calls != 0 {
t.Fatalf("broadcasts = %d, want 0 for a restricted conversation", sink.calls)
}
}
// TestOnTurnEndFallsBackToDefaultPersona locks the persona binding: a loop bound
// without an explicit persona id advances the library's default persona instead
// of silently tracking nothing.
func TestOnTurnEndFallsBackToDefaultPersona(t *testing.T) {
a, sink := testEmotionApp(t)
if got := a.onTurnEnd(loop.TurnEnd{SessionID: "web", UserText: "谢谢你", Env: loop.OwnerEnv()}); got != string(emotion.Happy) {
t.Fatalf("mood = %q, want happy on the default persona", got)
}
if sink.personaID != "cyrene" {
t.Fatalf("broadcast persona = %q, want cyrene", sink.personaID)
}
}
// TestOnTurnEndSkipsNonConversationLanes locks the lane gate: only the owner's
// conversation (main session and its sub-sessions) moves the persona's mood.
// Autonomous / delegated loop calls — background thinking, sub-agent work,
// isolated IM sessions — are not conversation turns and must leave her state
// alone.
func TestOnTurnEndSkipsNonConversationLanes(t *testing.T) {
a, sink := testEmotionApp(t)
for _, sessionID := range []string{"sess-backthink", "sess-group-abc", "task-42", ""} {
if got := a.onTurnEnd(loop.TurnEnd{SessionID: sessionID, UserText: "我好喜欢你", Env: loop.OwnerEnv()}); got != "" {
t.Fatalf("mood for session %q = %q, want empty", sessionID, got)
}
}
machine, affection, _ := a.personas.Emotions().Get("cyrene")
if machine.Current() != emotion.Calm || affection.Value() != 50 {
t.Fatalf("non-conversation lane moved the persona to %s/%d, want calm/50", machine.Current(), affection.Value())
}
if sink.calls != 0 {
t.Fatalf("broadcasts = %d, want 0", sink.calls)
}
// The main session and its sub-sessions are the conversation lane.
for _, sessionID := range []string{history.MainSessionID, history.MainSessionID + ":abc"} {
if got := a.onTurnEnd(loop.TurnEnd{SessionID: sessionID, UserText: "谢谢你", Env: loop.OwnerEnv()}); got != string(emotion.Happy) {
t.Fatalf("mood for session %q = %q, want happy", sessionID, got)
}
}
}
// TestOnTurnEndNilDependenciesNoPanic locks the degraded boots: no observer and
// no gateway sink must not panic (the gateway is built after the loop).
func TestOnTurnEndNilDependenciesNoPanic(t *testing.T) {
empty := &app{log: testLog()}
if got := empty.onTurnEnd(loop.TurnEnd{UserText: "我爱你", Env: loop.OwnerEnv()}); got != "" {
t.Fatalf("mood without an observer = %q, want empty", got)
}
empty.publishMood(emotion.Update{PersonaID: "cyrene", Before: emotion.Calm, After: emotion.Happy})
a, _ := testEmotionApp(t)
a.moodSink = nil
a.publishMood(emotion.Update{PersonaID: "cyrene", Before: emotion.Calm, After: emotion.Happy})
}
+46
View File
@@ -0,0 +1,46 @@
package main
// Wiring-level anti-drift test for the shared client enum table (design/07 §6).
// The enums package test pins the platform table to the adapters' Name() values;
// this one pins it to the composition root's factory — every platform the table
// publishes must be one buildAccountAdapter actually knows how to build, so a
// phantom entry (a platform listed for clients but never wired up) fails here.
import (
"io"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/contract/enums"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
)
// TestChannelFactoryBuildsEnumeratedPlatforms builds every adapter the published
// platform table lists (other than the Web main-session source, which is not an
// adapter) through the real factory and asserts the built adapter's Name()
// resolves back to that platform. Adapter construction never dials out, so the
// minimal configs below are enough.
func TestChannelFactoryBuildsEnumeratedPlatforms(t *testing.T) {
t.Parallel()
log := logger.New(logger.Options{Out: io.Discard})
minimalConfig := map[string]map[string]any{
"kook": {"bot_token": "test"},
"telegram": {"bot_token": "test"},
}
for _, p := range enums.Platforms() {
if p.ID == history.PlatformWeb {
continue
}
acct := config.ChannelAccount{Config: minimalConfig[p.ID]}
built, err := buildAccountAdapter(p.ID, acct, log)
if err != nil {
t.Errorf("platform %q is published to clients but the factory cannot build it: %v", p.ID, err)
continue
}
if got := barePlatform(built.Name()); got != p.ID {
t.Errorf("buildAccountAdapter(%q).Name() = %q, want %q", p.ID, got, p.ID)
}
}
}
+77
View File
@@ -0,0 +1,77 @@
package main
import (
"net"
"net/url"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// memoryOwnerRoleID 是组合根写入提取出的记忆时所用的角色 id;主人是唯一会写入
// 私有记忆的 actor(群聊/陌生人会话根本不提取,design/11 §2)。
const memoryOwnerRoleID = "owner"
// buildMemoryExtractor 选择循环在每轮落定后运行的记忆提取器(design/03 §1
// LLM 异步自动提取):
//
// - provider 栈存在可用的活动组时用 LLM 提取器,由模型去噪并去重真正值得记的内容;
// - 否则用确定性的 ManualExtractor,使无 key 的部署仍能记录主人显式的「记住 X」
// 指令,而不是每轮都让模型调用失败。
//
// 该提取器刻意不持有任何人设:一个进程现在会以多个角色作答(design/22 §4.2),
// 若在此固化某个人设,就会把所有提取出的记忆都归到那一个角色下。循环在持久化时
// 用产生该轮的角色逐条打标(loop.persistMemories)。
//
// 循环会把 LLM 提取器分离到后台 goroutine(ExtractAsync);见
// internal/runtime/loop/memory.go。
func (a *app) buildMemoryExtractor() memory.Extractor {
if llmGroupUsable(a.llm) {
a.log.Info("memory extractor: llm", "model", a.llm.Model(), "provider", a.llm.Provider())
return memory.NewLLMExtractor(a.llm, "", memoryOwnerRoleID)
}
a.log.Warn("memory extractor: manual (no usable llm model group)")
return memory.NewManualExtractor("", memoryOwnerRoleID)
}
// llmGroupUsable 报告运行时栈的活动模型组是否真的可调用:模型与 base URL 是必需
// 项,且要么配置了 API key,要么端点是本地/内网地址 —— 后者无 key 调用属正常情况
// (自托管的 OpenAI 兼容服务)。
//
// 它刻意只做廉价的静态检查,绝不发探测请求:启动不该花掉一次模型调用。看起来可用
// 但调用时才失败的组,只会记一条提取错误日志(提取失败从不致命)。
func llmGroupUsable(llm *model.RuntimeLLM) bool {
if llm == nil {
return false
}
group := llm.Group()
if strings.TrimSpace(group.Model) == "" || strings.TrimSpace(group.BaseURL) == "" {
return false
}
if strings.TrimSpace(group.APIKey) != "" {
return true
}
return keylessEndpoint(group.BaseURL)
}
// keylessEndpoint 报告 baseURL 是否指向回环或内网主机 —— 这类地址上缺少 API key
// 是预期情况,而非配置错误。
func keylessEndpoint(baseURL string) bool {
parsed, err := url.Parse(strings.TrimSpace(baseURL))
if err != nil {
return false
}
host := parsed.Hostname()
if host == "" {
return false
}
if strings.EqualFold(host, "localhost") {
return true
}
ip := net.ParseIP(host)
if ip == nil {
return false
}
return ip.IsLoopback() || ip.IsPrivate()
}
+82
View File
@@ -0,0 +1,82 @@
package main
import (
"path/filepath"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
modelpkg "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// testRuntimeLLM builds a provider stack from env alone (the pointed-at
// models.json does not exist), so a test controls the active group through the
// flat LLM config exactly as a keyless .env boot would.
func testRuntimeLLM(t *testing.T, env config.LLMConfig) *modelpkg.RuntimeLLM {
t.Helper()
return modelpkg.NewRuntimeLLM(filepath.Join(t.TempDir(), "models.json"), env, testLog())
}
// TestBuildMemoryExtractor_LLMWhenCredentials proves a configured provider group
// selects the LLM extractor (design/03 §1). The extractor is role-agnostic
// (design/22 §4.2): it is built without a persona and the loop stamps the turn's
// role onto each extracted item, so multi-role extraction cannot be filed under
// the default role.
func TestBuildMemoryExtractor_LLMWhenCredentials(t *testing.T) {
t.Parallel()
a := &app{
log: testLog(),
llm: testRuntimeLLM(t, config.LLMConfig{
BaseURL: "https://api.example.com/v1",
APIKey: "sk-test",
Model: "gpt-4o-mini",
}),
}
if _, ok := a.buildMemoryExtractor().(*memory.LLMExtractor); !ok {
t.Fatalf("extractor = %T, want *memory.LLMExtractor (credentials configured)", a.buildMemoryExtractor())
}
}
// TestBuildMemoryExtractor_ManualWhenNoCredentials proves the keyless fallback: a
// boot without an API key (and without a local endpoint) keeps the deterministic
// ManualExtractor instead of firing a doomed model call on every turn.
func TestBuildMemoryExtractor_ManualWhenNoCredentials(t *testing.T) {
t.Parallel()
a := &app{log: testLog(), llm: testRuntimeLLM(t, config.LLMConfig{})}
if _, ok := a.buildMemoryExtractor().(*memory.ManualExtractor); !ok {
t.Fatalf("extractor = %T, want *memory.ManualExtractor (no usable llm group)", a.buildMemoryExtractor())
}
bare := &app{log: testLog()}
if _, ok := bare.buildMemoryExtractor().(*memory.ManualExtractor); !ok {
t.Fatalf("extractor = %T, want *memory.ManualExtractor (nil provider stack)", bare.buildMemoryExtractor())
}
}
// TestLLMGroupUsable_KeylessLocalEndpoint locks the ollama-style case: a local
// OpenAI-compatible server needs no API key, so extraction stays LLM-backed.
func TestLLMGroupUsable_KeylessLocalEndpoint(t *testing.T) {
t.Parallel()
cases := []struct {
name string
env config.LLMConfig
ready bool
}{
{name: "api key", env: config.LLMConfig{BaseURL: "https://api.example.com/v1", APIKey: "sk", Model: "m"}, ready: true},
{name: "loopback keyless", env: config.LLMConfig{BaseURL: "http://127.0.0.1:11434/v1", Model: "llama3"}, ready: true},
{name: "localhost keyless", env: config.LLMConfig{BaseURL: "http://localhost:1234/v1", Model: "m"}, ready: true},
{name: "private keyless", env: config.LLMConfig{BaseURL: "http://192.168.1.20:11434/v1", Model: "m"}, ready: true},
{name: "remote keyless", env: config.LLMConfig{BaseURL: "https://api.example.com/v1", Model: "m"}, ready: false},
{name: "no model", env: config.LLMConfig{BaseURL: "https://api.example.com/v1", APIKey: "sk"}, ready: false},
{name: "no base url", env: config.LLMConfig{APIKey: "sk", Model: "m"}, ready: false},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := llmGroupUsable(testRuntimeLLM(t, tc.env)); got != tc.ready {
t.Fatalf("llmGroupUsable = %v, want %v", got, tc.ready)
}
})
}
}
+144
View File
@@ -0,0 +1,144 @@
// cyrene 命令是 Cyrene v2 的唯一二进制入口:它把全部子系统(配置、日志、数据库、
// bus、人格、模型适配器、工具分发器、记忆栈、agent 循环、编排、执行、IoT、渠道
// 会话与适配器)组装进同一个进程,在单端口(:8080)上同时提供网关与内嵌前端,
// 并在收到终止信号时优雅退出。
//
// 构建:`go build -ldflags="-s -w" -trimpath -o cyrene ./cmd/cyrene/`。
// 前端必须先构建,SPA 才能被内嵌:`cd frontend/web && npm run build`。
package main
import (
"context"
"fmt"
"io/fs"
"log/slog"
"os"
"path/filepath"
"time"
// 内嵌 IANA 时区数据库,让配置的 TZ(config.TZ,默认 "Asia/Shanghai")
// 在任何平台都能解析:Windows 不自带系统 tzdata,Go 也刻意不捆绑,缺了它
// 循环的时间注入在 Windows 上会静默失效(internal/runtime/loop/time.go 会记
// "timezone load failed")。代价约 450 KB,换来二进制自包含。
_ "time/tzdata"
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/platform"
"git.yeij.top/AskaEth/Cyrene_v2/internal/webui"
)
// envPath 是启动时合并的 .env 文件(仅开发用;生产走 systemd/env_file)。
// .env 不存在不算错误。
const envPath = ".env"
// shutdownTimeout 是优雅退场的时长上界。
const shutdownTimeout = 15 * time.Second
// externalDistCandidates 是前端覆盖目录的候选清单:按序检查,都不存在才回落到
// 内嵌 dist;命中的第一个生效。路径相对进程工作目录。
func externalDistCandidates() []string {
return []string{
filepath.Join("frontend", "web", "dist"),
filepath.Join("frontend", "dist"),
}
}
// resolveFrontendFS 返回用于伺服 SPA 的文件系统:存在外部 dist 目录时用它
// (部署期热覆盖),否则用内嵌 dist。
func resolveFrontendFS(log *slog.Logger) fs.FS {
for _, dir := range externalDistCandidates() {
if info, err := os.Stat(dir); err == nil && info.IsDir() {
log.Info("serving external frontend dist", "dir", dir)
return os.DirFS(dir)
}
}
log.Info("serving embedded frontend dist")
return webui.Assets()
}
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "cyrene:", err)
os.Exit(1)
}
}
// run 启动应用、拉起全部服务与网关,然后阻塞等待终止信号或网关失败。
func run() error {
cfg := config.MustLoad(envPath)
format := logger.FormatText
if cfg.Env == "production" {
format = logger.FormatJSON
}
logRing := logger.NewRingBuffer(logger.DefaultRingCapacity)
log := logger.New(logger.Options{Level: cfg.LogLevel, Format: string(format), Ring: logRing})
log = logger.WithSource(log, "main")
ctx, stop := platform.SignalContext()
defer stop()
a, err := newApp(ctx, cfg, log)
if err != nil {
return err
}
// 可观测性日志追尾:logger 的环形缓冲在上面(网关之前)就建好了,
// 现在网关已存在,把它交给网关。
wireGatewayLogBuffer(a, logRing)
if err := a.automation.Start(ctx); err != nil {
return fmt.Errorf("start automations: %w", err)
}
a.backthink.Start(ctx)
// 记忆整理(design/23 §4):低峰定期后台任务。它不做启动即跑 —— 只在
// 安静时段起点开跑,因此进程重启不会触发一次全库扫描;配置关闭时只记
// 一条日志并立即返回。
if a.memoryTidy != nil {
a.memoryTidy.Start(ctx)
}
// 入库前审查(design/23 §15.1 D3):有界后台队列,在 store 的写入路径上
// 接住每条新记忆。启动即就绪(它不做「启动即跑」,只等写入侧投递);
// 配置关闭时组合根根本没建它(nil),写入路径逐字与改动前一致。
if a.memoryReview != nil {
a.memoryReview.Start(ctx)
}
// 文档回收(切片 S7):启动期清扫一次 + 此后按 DOCUMENT_GC_INTERVAL 定时清扫。
// 它不等这一次跑完就去拉网关(见 wire_document_gc.go 的取舍 1)。
a.startDocumentGC(ctx)
// 渠道事件留存维护(切片 E3+E4):保留期清扫一次 + 此后定时清扫,内存计数
// 按 CHANNEL_EVENT_COUNTER_FLUSH 定时落盘(退出前最后一次落盘在循环里)。
a.startChannelEvents(ctx)
// 链路图留存维护(切片 F2):保留期清扫一次 + 此后按 FLOW_GRAPH_GC_INTERVAL 定时清扫。
// 没有它,落盘的轮次图会**无限增长**(design/19 §4 P3 的 GC 口径)。
a.startFlowGraphs(ctx)
// LLM 用量落库与保留期清理(切片 U1):单写者 + 有界队列的异步写入器,
// 加上按 LLM_USAGE_RETENTION 的过期样本清理。
a.startUsage(ctx)
a.log.Info("cyrene subsystems started",
"personas", a.personas.CountPersonas(),
"tools", len(a.caps.List(capreg.KindTool)),
"channels", len(a.caps.List(capreg.KindChannel)))
a.log.Info("cyrene ready", "data_dir", resolveDataDir(cfg))
errCh, err := a.gw.Start(ctx)
if err != nil {
return fmt.Errorf("start gateway: %w", err)
}
select {
case <-ctx.Done():
a.log.Info("cyrene signal received, shutting down")
case gwErr := <-errCh:
if gwErr != nil {
shutCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()
a.close(shutCtx)
return fmt.Errorf("gateway failed: %w", gwErr)
}
}
shutCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()
a.close(shutCtx)
return nil
}
+85
View File
@@ -0,0 +1,85 @@
package main
import (
"context"
"encoding/json"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/mcp"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// mcpConnectTimeout 约束每个 MCP server 的连接+发现握手,使挂死的 server 降级为
// 一条告警,而不是阻塞启动。
const mcpConnectTimeout = 15 * time.Second
// wireMCP 读取 cfg.MCP.ServersJSON,连接每个已启用的 server,把发现到的工具注册进
// 能力注册表(KindTool,使 agent 可调用它们),并暴露一个 KindMCP 能力,让管理面板
// 的插件列表能枚举该 server。连接失败或未配置的 server 降级为一条告警,绝不阻塞
// 启动。
func (a *app) wireMCP() {
log := logger.WithSource(a.log, "cmd/cyrene")
servers, err := a.mcpServers()
if err != nil {
log.Warn("mcp config parse skipped", "error", err)
return
}
if len(servers) == 0 {
log.Debug("no mcp servers configured")
return
}
for _, srv := range servers {
if !srv.Enabled {
continue
}
ctx, cancel := context.WithTimeout(context.Background(), mcpConnectTimeout)
err := a.registerMCPServer(ctx, srv)
cancel()
if err != nil {
log.Warn("mcp server skipped", "name", srv.Name, "error", err)
}
}
}
// mcpServers 从 cfg.MCP.ServersJSON 解析出已配置的 MCP server。
func (a *app) mcpServers() ([]config.MCPServer, error) {
raw := strings.TrimSpace(a.cfg.MCP.ServersJSON)
if raw == "" {
return nil, nil
}
var servers []config.MCPServer
if err := json.Unmarshal([]byte(raw), &servers); err != nil {
return nil, err
}
return servers, nil
}
// registerMCPServer 连接单个 server,注册其工具,并以 "mcp:<name>" 发布一个
// KindMCP 能力。它返回第一个错误以便调用方分类;注册中途失败时会清理已加入的内容。
func (a *app) registerMCPServer(ctx context.Context, srv config.MCPServer) error {
log := logger.WithSource(a.log, "cmd/cyrene")
reg := mcp.NewRegistry(a.caps, mcp.MCPConfig{
Name: srv.Name,
Command: srv.Command,
Args: srv.Args,
URL: srv.URL,
}, mcp.WithLogger(a.log))
if err := reg.Register(ctx); err != nil {
_ = reg.Unregister(context.Background())
return err
}
if err := a.caps.Register(registry.Capability{
Name: "mcp:" + srv.Name,
Kind: registry.KindMCP,
Description: "MCP server " + srv.Name,
Value: reg,
}); err != nil {
log.Warn("mcp capability register skipped", "name", srv.Name, "error", err)
}
a.mcpRegs = append(a.mcpRegs, reg)
log.Info("mcp server registered", "name", srv.Name)
return nil
}
+29
View File
@@ -0,0 +1,29 @@
package main
import (
"log/slog"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// materializeModels 在进程启动时跑一次「`.env` 默认层 → models.json 补齐」。
//
// 语义(主人裁决,规则见 runtimeconfig.Materialize):`.env` 只是基础入口/播种层,
// models.json(面板专用配置文件)才是一等来源。每次启动把 `.env` 的 LLM 块补进面板
// 配置里**缺失**的键,面板已有的键(含显式空值)绝不覆盖,组集合完全由面板决定
// (面板删掉的组不会因 `.env` 仍声明它而复活);面板配置缺失/为空时才由 `.env`
// 播种出合成组(既有行为)。幂等,重复启动不再改动文件。
//
// 失败一律只记日志、**不阻断启动**:模型栈的既有兜底(LoadModels 回退 `.env`)仍然
// 兜住,配置文件的一次写失败不该让服务起不来。
func materializeModels(path string, env config.LLMConfig, log *slog.Logger) {
res, err := runtimeconfig.Materialize(path, env, log)
if err != nil {
log.Warn("models.json 补齐失败(模型栈仍按现状运行)", "path", path, "error", err)
return
}
if res.Warn != nil {
log.Warn("models.json 补齐未完成(模型栈仍按现状运行)", "path", path, "error", res.Warn)
}
}
+83
View File
@@ -0,0 +1,83 @@
package main
import (
"bytes"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 启动补齐接线(materializeModels)的契约:成功时面板专用配置文件在**模型栈构建
// 之前**就位(NewRuntimeLLM 紧接着读它);任何失败都只记日志,**不阻断启动**。
func TestMaterializeModelsCreatesPanelFileAtBoot(t *testing.T) {
path := filepath.Join(t.TempDir(), "models.json")
env := config.LLMConfig{Provider: "openai", BaseURL: "https://x/v1", APIKey: "sk-x", Model: "m"}
var buf bytes.Buffer
materializeModels(path, env, slog.New(slog.NewJSONHandler(&buf, nil)))
if _, err := os.Stat(path); err != nil {
t.Fatalf("启动补齐应写出 models.json: %v", err)
}
groups, warn := runtimeconfig.LoadModels(path, env)
if warn != nil {
t.Fatalf("LoadModels: warn = %v", warn)
}
if len(groups) != 1 || groups[0].Model != "m" || groups[0].APIKey != "sk-x" {
t.Fatalf("组 = %+v, want .env 播种的合成组", groups)
}
if strings.Contains(buf.String(), "sk-x") {
t.Fatalf("启动日志泄漏了凭据: %s", buf.String())
}
}
func TestMaterializeModelsNeverBlocksBoot(t *testing.T) {
// 路径不可读/不可写时只记日志:模型栈的既有兜底(LoadModels 回退 .env)仍成立,
// 配置文件的一次失败不该让服务起不来。
var buf bytes.Buffer
materializeModels(t.TempDir(), config.LLMConfig{Provider: "openai"}, slog.New(slog.NewJSONHandler(&buf, nil)))
if !strings.Contains(buf.String(), "models.json 补齐") {
t.Fatalf("失败时应有可诊断日志: %s", buf.String())
}
}
// TestModelsBootPathEndToEnd 复现主人的原始场景(去掉 DB/HTTP 接线,端口与 data/ 都不碰):
// `.env` → 启动补齐 → 面板专用配置文件 → **真**模型栈(NewRuntimeLLM);面板改一次
// (经真面板保存用的 runtimeconfig.SaveModels 写回),**再启动一次**不得被 `.env` 打回。
func TestModelsBootPathEndToEnd(t *testing.T) {
path := filepath.Join(t.TempDir(), "models.json")
env := config.LLMConfig{Provider: "openai", BaseURL: "https://env.example/v1", APIKey: "sk-env", Model: "env-model"}
log := slog.New(slog.NewJSONHandler(io.Discard, nil))
// 第一次启动:面板文件不存在 ⇒ 由 .env 播种,模型栈直接读到 env 的模型名。
materializeModels(path, env, log)
stack := model.NewRuntimeLLM(path, env, log)
if got := stack.Model(); got != "env-model" {
t.Fatalf("首次启动模型名 = %q, want env 的 env-model", got)
}
// 面板把该组改成 panel-model(与面板保存端点同一条写路径),随后热重载。
groups := stack.Groups()
groups[0].Model = "panel-model"
groups[0].APIKey = "sk-panel"
if err := runtimeconfig.SaveModels(path, groups); err != nil {
t.Fatalf("面板保存: %v", err)
}
// 第二次启动:补齐只看**缺失**的键,面板的改动不得被打回 .env 值。
materializeModels(path, env, log)
restarted := model.NewRuntimeLLM(path, env, log)
if got := restarted.Model(); got != "panel-model" {
t.Fatalf("重启后模型名 = %q, want 面板改的 panel-model(被 .env 打回即失败)", got)
}
if got := restarted.Group().APIKey; got != "sk-panel" {
t.Fatalf("重启后 apiKey = %q, want 面板的 sk-panel", got)
}
}
+107
View File
@@ -0,0 +1,107 @@
package main
import (
"fmt"
"os"
"sync"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway"
)
// newAppTraceWriter 构建组合根的 trace writer:JSONL stderr 记录器(每个 span 的
// 本地结构化日志),外加为 gateway 可观测性出口预留的位置 —— 后者只能在 gateway
// 存在之后注册(见 wireGatewayTraceSink)。
func newAppTraceWriter() *traceFanout {
return newTraceFanout(trace.NewJSONRecorder(os.Stderr))
}
// wireGatewayTraceSink 把 gateway 的可观测性出口注册到应用的 trace 扇出上。它由
// wireFrontend 调用 —— 那是 gateway 首次存在的时刻 —— 因为 gateway 构建在发出 span
// 的循环之后。没有它,管理面板的 trace 环形缓冲区永远不会被喂数据,
// GET /api/v1/admin/observability/trace 会永远返回空列表(design/19 §2)。在没有
// tracer 或 gateway 的启动中是空操作。
func wireGatewayTraceSink(a *app, gw *gateway.Server) {
if a.traceWriter == nil || gw == nil {
return
}
a.traceWriter.Add(gatewaySpanSink{srv: gw})
a.log.Info("observability trace sink wired")
}
// traceFanout 是组合根的 trace.Writer。它把每个已完成的 span 扇出到启动时安装的
// 出口(JSONL stderr 记录器),以及启动流程中稍后注册的出口:gateway 的可观测性
// 缓冲区要等 wireFrontend 构建好 gateway 才存在,而发出 span 的循环在 wireRuntime
// 中更早就构建了。
//
// 它可安全并发使用 —— 循环会从多个 goroutine 结束 span(按步、按工具调用、子代理)。
type traceFanout struct {
mu sync.RWMutex
writers []trace.Writer
}
// newTraceFanout 在给定 writers 之上构建扇出。nil writer 会被丢弃。
func newTraceFanout(writers ...trace.Writer) *traceFanout {
f := &traceFanout{}
for _, w := range writers {
f.Add(w)
}
return f
}
// Add 再安装一个出口。nil writer 被忽略。
func (f *traceFanout) Add(w trace.Writer) {
if w == nil {
return
}
f.mu.Lock()
defer f.mu.Unlock()
f.writers = append(f.writers, w)
}
// WriteSpan 把 rec 写给每个出口并返回第一个错误。即使失败也仍会尝试所有出口,
// 因此单个坏掉的出口无法把 span 从其他出口那里藏起来;tracer 只记录返回的错误
// (trace.Tracer.emit)。
func (f *traceFanout) WriteSpan(rec trace.SpanRecord) error {
f.mu.RLock()
defer f.mu.RUnlock()
var firstErr error
for _, w := range f.writers {
if w == nil {
continue
}
if err := w.WriteSpan(rec); err != nil && firstErr == nil {
firstErr = fmt.Errorf("trace fanout: %w", err)
}
}
return firstErr
}
// wireGatewayLogBuffer 把组合根的日志环形缓冲区交给 gateway,使
// GET /api/v1/admin/observability/logs 能提供最近的后端日志尾部。logger 构建在
// gateway 之前(其记录必须从第一行日志起就被捕获),因此缓冲区在这里注入 ——
// gateway 首次存在的时刻 —— 与 wireGatewayTraceSink 对 span 的做法一致。在没有
// gateway 的启动中是空操作。
func wireGatewayLogBuffer(a *app, buf *logger.RingBuffer) {
if a == nil || a.gw == nil || buf == nil {
return
}
a.gw.SetLogBuffer(buf)
a.log.Info("observability log buffer wired", "capacity", buf.Len())
}
// gatewaySpanSink 通过 *gateway.Server 的 ObserveSpan 入口把它适配成 trace.Writer,
// 使发出的 span 落在管理面板的 trace 环形缓冲区**并且**计入 LLM/子代理指标计数器
// (ObserveSpan 既追加到缓冲区,也观测指标;缓冲区正是
// GET /api/v1/admin/observability/trace 所提供的内容)。
type gatewaySpanSink struct{ srv *gateway.Server }
// WriteSpan 把 rec 喂给 gateway。server 缺失时是空操作。
func (g gatewaySpanSink) WriteSpan(rec trace.SpanRecord) error {
if g.srv == nil {
return nil
}
g.srv.ObserveSpan(rec)
return nil
}
+239
View File
@@ -0,0 +1,239 @@
package main
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway"
)
const obsTestSecret = "0123456789abcdef0123456789abcdef"
// recordingWriter captures the spans a fanout hands it.
type recordingWriter struct {
spans []trace.SpanRecord
}
func (r *recordingWriter) WriteSpan(rec trace.SpanRecord) error {
r.spans = append(r.spans, rec)
return nil
}
// TestTraceFanout_LateSinkReceivesSpans proves the composition root can register
// a sink after the loop's tracer is already running: spans finished before the
// sink is added do not reach it, every span after does (the gateway is built
// after the loop, so this ordering is what A4 depends on).
func TestTraceFanout_LateSinkReceivesSpans(t *testing.T) {
t.Parallel()
early := &recordingWriter{}
fanout := newTraceFanout(early)
tracer := trace.New(fanout)
_, first := tracer.StartSpan(context.Background(), "before", trace.SpanKindStateTransition)
first.Finish(trace.SpanStatusOK)
late := &recordingWriter{}
fanout.Add(late)
_, second := tracer.StartSpan(context.Background(), "after", trace.SpanKindLLM)
second.Finish(trace.SpanStatusOK)
if len(early.spans) != 2 {
t.Fatalf("early sink spans = %d, want 2", len(early.spans))
}
if len(late.spans) != 1 || late.spans[0].ID != second.ID {
t.Fatalf("late sink spans = %+v, want exactly the span %q", late.spans, second.ID)
}
}
// TestWireGatewayTraceSink_Guards proves the boot helper tolerates a missing
// tracer or gateway, so a degraded boot cannot panic on the observability path.
func TestWireGatewayTraceSink_Guards(t *testing.T) {
t.Parallel()
wireGatewayTraceSink(&app{log: testLog()}, nil)
a := &app{log: testLog(), traceWriter: newAppTraceWriter()}
wireGatewayTraceSink(a, nil)
if len(a.traceWriter.writers) != 1 {
t.Fatalf("writers = %d, want 1 (the stderr recorder only)", len(a.traceWriter.writers))
}
}
// TestGatewaySpanSink_FeedsTraceEndpointAndMetrics is the A4 acceptance: a span
// emitted by the tracer is written through the gateway sink (registered by the
// same helpers wire.go calls) and surfaces both in
// GET /api/v1/admin/observability/trace (a real span, not an empty buffer) and in
// the LLM metric counter.
func TestGatewaySpanSink_FeedsTraceEndpointAndMetrics(t *testing.T) {
t.Parallel()
jwt := auth.New(config.AuthConfig{JWTSecret: obsTestSecret})
gw, err := gateway.New(&config.Config{}, gateway.Deps{Log: testLog(), Auth: jwt})
if err != nil {
t.Fatalf("gateway.New: %v", err)
}
t.Cleanup(gw.Close)
ts := httptest.NewServer(gw.Handler())
t.Cleanup(ts.Close)
// Exactly the boot path: the app's fanout is built in wireRuntime and the
// gateway sink is registered in wireFrontend.
a := &app{log: testLog(), traceWriter: newAppTraceWriter()}
wireGatewayTraceSink(a, gw)
tracer := trace.New(a.traceWriter)
ctx := context.Background()
_, llmSpan := tracer.StartSpan(ctx, "llm.chat", trace.SpanKindLLM)
llmSpan.Finish(trace.SpanStatusOK)
_, stepSpan := tracer.StartSpan(ctx, "loop.step", trace.SpanKindStateTransition)
_ = stepSpan.SetProp("session", "web")
stepSpan.Finish(trace.SpanStatusOK)
tok, err := jwt.Sign("acct", auth.RoleOwner, time.Hour)
if err != nil {
t.Fatalf("sign token: %v", err)
}
var traceBody struct {
Data []struct {
ID string `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"`
} `json:"data"`
}
status, raw := obsGet(t, ts.URL+"/api/v1/admin/observability/trace", tok)
if status != http.StatusOK {
t.Fatalf("trace status = %d, want 200 (body=%s)", status, raw)
}
if err := json.Unmarshal(raw, &traceBody); err != nil {
t.Fatalf("decode trace: %v (body=%s)", err, raw)
}
seen := map[string]string{}
for _, sp := range traceBody.Data {
seen[sp.ID] = sp.Kind
}
if seen[stepSpan.ID] != string(trace.SpanKindStateTransition) {
t.Fatalf("trace endpoint = %+v, want the loop.step span %q", traceBody.Data, stepSpan.ID)
}
if seen[llmSpan.ID] != string(trace.SpanKindLLM) {
t.Fatalf("trace endpoint = %+v, want the llm span %q", traceBody.Data, llmSpan.ID)
}
var metricsBody struct {
Data struct {
LLMCalls int64 `json:"llmCalls"`
} `json:"data"`
}
status, raw = obsGet(t, ts.URL+"/api/v1/admin/observability/metrics", tok)
if status != http.StatusOK {
t.Fatalf("metrics status = %d, want 200 (body=%s)", status, raw)
}
if err := json.Unmarshal(raw, &metricsBody); err != nil {
t.Fatalf("decode metrics: %v (body=%s)", err, raw)
}
if metricsBody.Data.LLMCalls != 1 {
t.Fatalf("llmCalls = %d, want 1 (ObserveSpan counts the emitted llm span)", metricsBody.Data.LLMCalls)
}
}
// TestWireGatewayLogBuffer_Guards proves the boot helper tolerates a missing
// gateway or buffer, so a degraded boot cannot panic on the log-tail path.
func TestWireGatewayLogBuffer_Guards(t *testing.T) {
t.Parallel()
wireGatewayLogBuffer(nil, nil)
wireGatewayLogBuffer(&app{log: testLog()}, logger.NewRingBuffer(4))
wireGatewayLogBuffer(&app{log: testLog()}, nil)
jwt := auth.New(config.AuthConfig{JWTSecret: obsTestSecret})
gw, err := gateway.New(&config.Config{}, gateway.Deps{Log: testLog(), Auth: jwt})
if err != nil {
t.Fatalf("gateway.New: %v", err)
}
t.Cleanup(gw.Close)
wireGatewayLogBuffer(&app{log: testLog(), gw: gw}, nil)
}
// TestWireGatewayLogBuffer_FeedsLogEndpoint is the log-tail acceptance: the
// process logger's ring buffer is handed to the gateway by the same helper run()
// calls, and the records the app logged surface newest-first on
// GET /api/v1/admin/observability/logs.
func TestWireGatewayLogBuffer_FeedsLogEndpoint(t *testing.T) {
t.Parallel()
jwt := auth.New(config.AuthConfig{JWTSecret: obsTestSecret})
gw, err := gateway.New(&config.Config{}, gateway.Deps{Log: testLog(), Auth: jwt})
if err != nil {
t.Fatalf("gateway.New: %v", err)
}
t.Cleanup(gw.Close)
ts := httptest.NewServer(gw.Handler())
t.Cleanup(ts.Close)
// Exactly the boot path: run() builds the logger with the ring buffer before
// newApp and hands it to the gateway afterwards.
ring := logger.NewRingBuffer(8)
log := logger.WithSource(logger.New(logger.Options{
Level: "debug", Format: "text", Out: io.Discard, Ring: ring,
}), "main")
log.Info("boot ok")
log.Warn("disk filling")
a := &app{log: testLog(), gw: gw}
wireGatewayLogBuffer(a, ring)
tok, err := jwt.Sign("acct", auth.RoleOwner, time.Hour)
if err != nil {
t.Fatalf("sign token: %v", err)
}
var body struct {
Data []struct {
Time string `json:"time"`
Level string `json:"level"`
Source string `json:"source"`
Msg string `json:"msg"`
} `json:"data"`
}
status, raw := obsGet(t, ts.URL+"/api/v1/admin/observability/logs", tok)
if status != http.StatusOK {
t.Fatalf("logs status = %d, want 200 (body=%s)", status, raw)
}
if err := json.Unmarshal(raw, &body); err != nil {
t.Fatalf("decode logs: %v (body=%s)", err, raw)
}
if len(body.Data) != 2 {
t.Fatalf("logs = %+v, want the two records logged above", body.Data)
}
if body.Data[0].Msg != "disk filling" || body.Data[0].Level != "warn" || body.Data[0].Source != "main" {
t.Fatalf("logs[0] = %+v, want the newest record (warn/disk filling/main)", body.Data[0])
}
if body.Data[1].Msg != "boot ok" || body.Data[1].Level != "info" {
t.Fatalf("logs[1] = %+v, want the older info record", body.Data[1])
}
}
// obsGet performs an authenticated owner GET and returns the status plus the raw
// body.
func obsGet(t *testing.T, url, token string) (int, []byte) {
t.Helper()
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
t.Fatalf("new request: %v", err)
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("GET %s: %v", url, err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read %s: %v", url, err)
}
return resp.StatusCode, raw
}
+80
View File
@@ -0,0 +1,80 @@
package main
import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/guardrail"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
)
// 本文件构建按角色的 agent 池(design/22 §4.2 方案 A)。agent.Agent 的指令在构造
// 之后不可变,因此「会话的角色决定 system 提示」被实现为每个角色一个 agent:它们
// 共享同一个 dispatcher、guardrail 流水线、模型适配器与选项 —— 只有指令字符串不同。
// 循环在每一步为当轮角色挑选池中条目(internal/runtime/loop/persona.go),因此角色
// 切换在下一轮即生效,无需重建循环。
//
// design/22 §5 的要求:提示、记忆、情绪与 thinking 必须全部由同一个角色解析。
// 池拥有提示,循环拥有另外三者(personaFor / knobsFor),因此一轮绝不会混用两个
// 角色。
// personaAgentPool 返回按角色的 agent 池,以及用来跑轮次的 agent:池中有默认角色时
// 用池里的那个,否则用调用方的兜底 agent,因此未注册任何角色的部署仍保持与多角色
// 之前完全一致的单 agent 形态。
func (a *app) personaAgentPool(llm *model.RuntimeLLM, dispatcher *tool.Dispatcher, guards *guardrail.Pipeline, fallback *agent.Agent) (map[string]*agent.Agent, *agent.Agent) {
pool := a.buildPersonaAgents(llm, dispatcher, guards)
if pooled, ok := pool[a.defaultPersonaID()]; ok {
return pool, pooled
}
return pool, fallback
}
// buildPersonaAgents 返回「角色 → agent」池:每个已注册角色对应的 persona 都得到
// 一个携带该 persona system 指令的 agent(instructionsFromPersona,与默认 agent
// 是同一个装配点 —— 见 personaInstructions),design/01 §2.2。未注册 persona 时
// 返回空 map,循环便保持其单一默认 agent。
//
// 池里的指令是**静态**的,因此主人身份说明书不在其中:那段知识按会话注入
// (loop.Options.PersonaKnowledgeFor),否则它会跟着每个角色进入群聊与
// 陌生人私聊(design/11 §0 红线)。
//
// 只有通过已注册角色可达的 persona 才入池:角色才是可选单位(1 角色 ↔ 1 persona),
// 因此没有角色的 persona 永远无法绑定到会话。绑定到未知 id 的会话于是退回默认
// agent,而其记忆作用域仍跟随所绑定的 id —— 过期的绑定退化为默认提示,而不是让
// 该轮失败。
func (a *app) buildPersonaAgents(llm *model.RuntimeLLM, dispatcher *tool.Dispatcher, guards *guardrail.Pipeline) map[string]*agent.Agent {
log := logger.WithSource(a.log, "cmd/cyrene")
pool := make(map[string]*agent.Agent, a.personas.CountPersonas())
for _, c := range a.personas.List() {
if _, ok := pool[c.PersonaID]; ok {
continue
}
p, ok := a.personas.Persona(c.PersonaID)
if !ok || p == nil {
continue
}
// 该角色的 agent 必须绑它自己的 persona 视图:否则 models.json 的 personaModels /
// personaProviders(角色级模型/提供商路由)在生产里不会被消费,形同虚设。
stack := llm.ForPersona(c.PersonaID)
ag, err := agent.New(a.personaInstructions(p), dispatcher, guards,
agent.WithModel(stack, stack),
agent.WithOptions(agent.Options{MaxRounds: defaultMaxRounds, Purpose: "chat"}))
if err != nil {
log.Warn("persona agent skipped", "persona", c.PersonaID, "error", err)
continue
}
pool[c.PersonaID] = ag
}
return pool
}
// personaExists 返回编排层用来校验被派发的子代理目标角色的解析器(design/22 §8-2)。
// 某角色可派发,当且仅当池中有它的 agent,而这正是循环会用该角色指令作答的条件。
// 其他任何情况 —— 未注册的 persona、没有角色的 persona、空池 —— 都会让派发以
// orchestration.ErrUnknownPersona 失败,而不是悄悄按默认角色运行子代理。
func personaExists(pool map[string]*agent.Agent) func(personaID string) bool {
return func(personaID string) bool {
_, ok := pool[personaID]
return ok
}
}
+111
View File
@@ -0,0 +1,111 @@
package main
import (
"context"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/emotion"
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// This file locks design/22 acceptance 4 at the composition-root level: with two
// roles registered, a turn of role Y must advance ONLY Y's emotion machine and
// affection (design/01 §2: 情绪/好感度 follow the persona), and the `mood` frame it
// broadcasts must name Y — never the default role. The turn's role reaches the hook
// through the loop's per-turn SessionEnv resolution (loop.TurnEnd.PersonaID), so a
// regression to a construction-time persona shows up as the wrong machine moving.
// testTwoPersonaApp registers two personas (each with its own emotion machine and
// affection curve) and wires the composition root's emotion observer exactly as
// wire.go does.
func testTwoPersonaApp(t *testing.T) (*app, *fakeMoodEmitter) {
t.Helper()
reg := personareg.New()
for _, p := range []*loader.Persona{
{
ID: "xilian",
Name: "昔涟",
Emotion: loader.EmotionParams{InitialState: emotion.Calm, DecayRate: 0.5},
Affection: loader.AffectionParams{Initial: 50, Min: 0, Max: 700, Delta: 10},
},
{
ID: "yuki",
Name: "由纪",
Emotion: loader.EmotionParams{InitialState: emotion.Calm, DecayRate: 0.5},
Affection: loader.AffectionParams{Initial: 20, Min: 0, Max: 700, Delta: 5},
},
} {
if err := reg.RegisterPersona(p); err != nil {
t.Fatalf("RegisterPersona(%s): %v", p.ID, err)
}
}
if err := reg.RegisterCharacter(&personareg.Character{ID: "xilian", Name: "昔涟", Default: true, PersonaID: "xilian"}); err != nil {
t.Fatalf("RegisterCharacter: %v", err)
}
if err := reg.RegisterCharacter(&personareg.Character{ID: "yuki", Name: "由纪", PersonaID: "yuki"}); err != nil {
t.Fatalf("RegisterCharacter: %v", err)
}
sink := &fakeMoodEmitter{}
a := &app{log: testLog(), personas: reg, moodSink: sink}
a.emotion = a.buildEmotionObserver()
return a, sink
}
// TestTurnEndMovesOnlyTheTurnPersona is acceptance 4: a Y turn moves Y's emotion and
// affection and leaves the default role's untouched, and the mood broadcast carries
// Y's id (so the frontend's 心情 badge is the active role's, not a global one).
func TestTurnEndMovesOnlyTheTurnPersona(t *testing.T) {
t.Parallel()
a, sink := testTwoPersonaApp(t)
ag, err := agent.New("你是由纪。", nil, nil, agent.WithModel(moodAdapter{reply: "由纪在呢~"}, nil))
if err != nil {
t.Fatalf("agent.New: %v", err)
}
l := loop.New(loop.Options{
Agent: ag,
Agents: map[string]*agent.Agent{"yuki": ag},
PersonaID: "xilian", // the configured default role is NOT the turn's role
OnTurnEnd: a.onTurnEnd,
})
env := loop.OwnerEnv()
env.PersonaID = "yuki"
res, err := l.Run(loop.WithSessionEnv(context.Background(), env), "web", []model.Message{model.TextMessage(model.RoleUser, "好开心呀,最喜欢你了")})
if err != nil {
t.Fatalf("Run: %v", err)
}
if res.Mood == "" {
t.Fatal("Result.Mood is empty, want Y's mood for the turn")
}
yukiMachine, yukiAffection, ok := a.personas.Emotions().Get("yuki")
if !ok {
t.Fatal("persona yuki has no emotion machine")
}
if yukiMachine.Current() == emotion.Calm {
t.Fatalf("yuki machine state = %s, want it moved by her own turn", yukiMachine.Current())
}
if yukiAffection.Value() != 25 {
t.Fatalf("yuki affection = %d, want 25 (20 + her own step 5)", yukiAffection.Value())
}
xilianMachine, xilianAffection, ok := a.personas.Emotions().Get("xilian")
if !ok {
t.Fatal("persona xilian has no emotion machine")
}
if xilianMachine.Current() != emotion.Calm {
t.Fatalf("default role machine = %s, want it untouched (calm)", xilianMachine.Current())
}
if xilianAffection.Value() != 50 {
t.Fatalf("default role affection = %d, want it untouched (50)", xilianAffection.Value())
}
if sink.calls != 1 || sink.personaID != "yuki" {
t.Fatalf("mood broadcast = %+v, want exactly one frame naming yuki", sink)
}
}
+141
View File
@@ -0,0 +1,141 @@
package main
import (
"io"
"log/slog"
"os"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
)
// identityKnowledgeHeader 是主人身份说明书注入上下文时的段头:
// 与【性格】【价值观】等并列,使模型能把它读成「人格知识」,而不是一条指令。
const identityKnowledgeHeader = "【主人身份说明书】"
// ownerIdentityKnowledge 读取 owner.yaml 的 identityFile 指向的正文
// (configs/users/owner_identity.md),供按会话注入。
//
// 它是**人格知识**,不是权限来源:该文件只描述「你在陪伴谁、你们是什么关系」,
// 身份识别始终只看渠道层 actor 元数据 + 白名单(design/11 §0),文件内容
// 既不参与 IsOwner,也不改变任何授权判定。文件里自己也把这条边界写在开头。
//
// fail-soft:未配置、文件缺失或读不动(含路径指向目录)都返回空串 —— 只记一条
// WARN,绝不报错、绝不阻断启动或对话。这是「宁可少一段知识,也不能因为一个
// 可选文件起步失败」的取舍。
func (a *app) ownerIdentityKnowledge() string {
if a.owner == nil {
return ""
}
path := strings.TrimSpace(a.owner.IdentityFile)
if path == "" {
return ""
}
raw, err := os.ReadFile(path)
if err != nil {
if a.log != nil {
logger.WithSource(a.log, "cmd/cyrene").Warn(
"owner identity file skipped", "path", path, "error", err)
}
return ""
}
return strings.TrimSpace(string(raw))
}
// identityKnowledgeBlock 把身份说明书正文渲染成一个可独立注入的系统块;
// knowledge 为空(未配置 / 读不出)时返回空串 = 不注入任何块。
func identityKnowledgeBlock(knowledge string) string {
if strings.TrimSpace(knowledge) == "" {
return ""
}
return identityKnowledgeHeader + "\n" + knowledge
}
// personaInstructions 装配某个角色 agent 的**静态** system 指令:人格指令
// (instructionsFromPersona,design/01 §2.2)。默认 agent 与角色池里的每个
// agent 都走这里,因此两个装配点不会漂移。
//
// 主人身份说明书**刻意不在这里**:静态指令在装配期定死,会让那段知识进入该
// 角色的每一个会话(含群聊与陌生人私聊)并随请求发给模型供应商。它现在按
// 会话注入 —— 见 personaKnowledgeFor 与 loop.Options.PersonaKnowledgeFor。
func (a *app) personaInstructions(p *loader.Persona) string {
if p == nil {
return ""
}
return instructionsFromPersona(p)
}
// personaKnowledgeFor 返回循环的按会话「人格知识」解析器
// (loop.Options.PersonaKnowledgeFor):该 turn 的会话在注入范围内时返回主人
// 身份说明书整块(段头 + 正文),否则返回空串 —— 空串意味着那段知识
// **完全不进**该会话的上下文,也就绝不随请求发给模型供应商。
//
// 默认档 owner_only:只有渠道层 actor 元数据判定的主人车道拿得到它。群聊与
// 陌生人私聊(design/11 §0 隔离红线)返回空串。开关见 OWNER_IDENTITY_INJECTION。
//
// 正文在装配期读一次并捕获进闭包(不是每轮读盘):它只在启动时变一次,
// 每轮重读一个启动期就不变的可选文件纯属浪费。
func (a *app) personaKnowledgeFor(envFilePath string) func(loop.SessionEnv) string {
log := sourcedWiringLog(a.log)
mode := ownerIdentityInjectionMode(log, envFilePath)
block := identityKnowledgeBlock(a.ownerIdentityKnowledge())
log.Info("owner identity injection",
"mode", mode, "env", config.OwnerIdentityInjectionEnv, "knowledge", block != "")
return func(env loop.SessionEnv) string {
if !injectsIdentityKnowledge(mode, env) {
return ""
}
return block
}
}
// sourcedWiringLog 返回带来源标记 "cmd/cyrene" 的 logger;nil 基础 logger
// 换成丢弃 logger(与 loop 包的 sourcedLog 同一约定),使这里的日志调用
// 永远不会因为「没接 logger」而空指针。
func sourcedWiringLog(base *slog.Logger) *slog.Logger {
if base == nil {
base = slog.New(slog.NewTextHandler(io.Discard, nil))
}
return logger.WithSource(base, "cmd/cyrene")
}
// ownerIdentityInjectionMode 解析注入范围开关,任何读不出/不认识的情形都
// **明确回退**到默认的 owner_only 并记一条带原始取值的 WARN —— 回退方向永远
// 是最安全的一侧,且绝不静默(非法取值不会被当成默认值悄悄放行)。
func ownerIdentityInjectionMode(log *slog.Logger, envFilePath string) string {
raw, err := config.LoadOwnerIdentityInjection(envFilePath)
if err != nil {
log.Warn("owner identity injection: cannot read config, falling back",
"env", config.OwnerIdentityInjectionEnv,
"fallback", config.OwnerIdentityOwnerOnly, "error", err)
return config.OwnerIdentityOwnerOnly
}
mode, perr := config.ParseOwnerIdentityInjection(raw)
if perr != nil {
log.Warn("owner identity injection: unknown value, falling back",
"env", config.OwnerIdentityInjectionEnv, "value", raw,
"fallback", config.OwnerIdentityOwnerOnly, "error", perr)
return config.OwnerIdentityOwnerOnly
}
return mode
}
// injectsIdentityKnowledge 报告该会话是否在注入范围内。
//
// owner_only 的判定读**强类型的渠道层 actor 元数据**(SessionEnv.ActorEnv),
// 与 memory / 工具准入门用的是同一个车道 —— 绝不从提示词文本或会话 id 猜。
// 注意反向也成立:身份说明书的内容永远不参与这个判定,也不参与任何授权。
func injectsIdentityKnowledge(mode string, env loop.SessionEnv) bool {
switch mode {
case config.OwnerIdentityAlways:
return true
case config.OwnerIdentityOff:
return false
default: // owner_only(默认,也是未知取值的回退档)
return env.ActorEnv == restrict.ActorOwner
}
}
+377
View File
@@ -0,0 +1,377 @@
package main
import (
"bytes"
"context"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
)
// 本文件锁定 P0-10 的**收窄**(design/11 §0 红线):
//
// - owner.yaml 的 identityFile(主人身份说明书)是**人格知识**,现在按
// **会话**注入:只有主人车道拿得到,群聊与陌生人私聊的上下文里一个字
// 都没有(因此也不会随请求发给模型供应商);
// - 它不再进静态 system 指令(那会让它跟着角色进入每个会话);
// - 它绝不参与主人判定 —— 判主只看 actor 元数据 + 白名单。
// identityFixture 是写进临时身份说明书的一段正文,含一个「陌生人 id」,
// 用来证明「文件里写了什么」与「谁被认成主人」毫无关系。
const identityFixture = "# 身份说明书 · 我的「人」\n\n" +
"- 称呼:你叫他「亲爱的」。\n" +
"- 陌生账号 kook:stranger-999 与本文件无关。\n" +
"- 本说明书只描述人设/关系,不授权任何权限。"
// identityProbeAdapter 记下每次请求,使测试能读回模型实际看到的上下文。
type identityProbeAdapter struct{ reqs []model.Request }
func (a *identityProbeAdapter) Name() string { return "identity-probe" }
func (a *identityProbeAdapter) Chat(_ context.Context, req model.Request, _ model.Options) (model.Response, error) {
a.reqs = append(a.reqs, req)
return model.Response{Content: "好呀♪"}, nil
}
func (a *identityProbeAdapter) ChatStream(_ context.Context, req model.Request, _ func(model.StreamEvent)) (model.Response, error) {
a.reqs = append(a.reqs, req)
return model.Response{Content: "好呀♪"}, nil
}
// identityApp 装配一个最小组合根切片:真实默认角色档案 + 给定身份说明书的
// owner 档案。identityFile 为空串表示「没配」。
func identityApp(t *testing.T, identityFile string) (*app, *loader.Persona) {
t.Helper()
return identityAppLogged(t, identityFile, testLog())
}
// identityAppLogged 同上,但用调用方给的 logger(用于断言日志不静默)。
func identityAppLogged(t *testing.T, identityFile string, log *slog.Logger) (*app, *loader.Persona) {
t.Helper()
p, err := loader.Load(filepath.Join("..", "..", "configs", "personas", "cyrene.yaml"))
if err != nil {
t.Fatalf("loader.Load(cyrene.yaml): %v", err)
}
reg := personareg.New()
if err := reg.RegisterPersona(p); err != nil {
t.Fatalf("RegisterPersona: %v", err)
}
if err := reg.RegisterCharacter(&personareg.Character{
ID: p.ID, Name: p.Name, Default: true, PersonaID: p.ID,
}); err != nil {
t.Fatalf("RegisterCharacter: %v", err)
}
owner := &user.OwnerProfile{
ID: "owner",
DisplayName: "叶酱",
DefaultPersona: p.ID,
Accounts: []user.OwnerAccount{{Platform: "kook", AccountID: "owner-1"}},
IdentityFile: identityFile,
}
a := &app{log: log, personas: reg, owner: owner, cfg: &config.Config{}}
return a, p
}
// writeIdentity 把 identityFixture 写进临时文件并返回路径。
func writeIdentity(t *testing.T) string {
t.Helper()
path := filepath.Join(t.TempDir(), "owner_identity.md")
if err := os.WriteFile(path, []byte(identityFixture), 0o600); err != nil {
t.Fatalf("write identity file: %v", err)
}
return path
}
// absentIdentityEnvFile 返回一个不存在的 .env 路径,使测试不受仓库 .env 影响。
func absentIdentityEnvFile(t *testing.T) string {
t.Helper()
return filepath.Join(t.TempDir(), "absent.env")
}
// unsetInjectionEnv 真正清掉注入范围开关(不是设成空串 —— 空串会遮住 .env
// 里的取值),并在测试结束还原。
func unsetInjectionEnv(t *testing.T) {
t.Helper()
key := config.OwnerIdentityInjectionEnv
prev, had := os.LookupEnv(key)
if err := os.Unsetenv(key); err != nil {
t.Fatalf("os.Unsetenv(%s): %v", key, err)
}
t.Cleanup(func() {
if had {
_ = os.Setenv(key, prev)
return
}
_ = os.Unsetenv(key)
})
}
// envOwnerTurn / envGroupTurn / envStrangerTurn 与生产路径构造的取值一致
// (Web/语音/主人私聊 = OwnerEnv,群聊 / 陌生人 = 受限环境)。
func envOwnerTurn() loop.SessionEnv { return loop.OwnerEnv() }
func envGroupTurn() loop.SessionEnv {
return loop.SessionEnv{ActorEnv: restrict.ActorGroupMember, MemWritable: false}
}
func envStrangerTurn() loop.SessionEnv {
return loop.SessionEnv{ActorEnv: restrict.ActorStranger, MemWritable: false}
}
// runIdentityTurn 用**生产解析器**(a.personaKnowledgeFor)驱动一次真实循环,
// 返回模型实际收到的消息列表。
func runIdentityTurn(t *testing.T, resolve func(loop.SessionEnv) string, env loop.SessionEnv) []model.Message {
t.Helper()
adapter := &identityProbeAdapter{}
ag, err := agent.New("你是昔涟。", nil, nil, agent.WithModel(adapter, nil))
if err != nil {
t.Fatalf("agent.New: %v", err)
}
l := loop.New(loop.Options{Agent: ag, PersonaKnowledgeFor: resolve, MaxRounds: 8})
if _, err := l.Run(loop.WithSessionEnv(context.Background(), env), "sess",
[]model.Message{model.TextMessage(model.RoleUser, "亲爱的,今天想我了没?")}); err != nil {
t.Fatalf("Run: %v", err)
}
if len(adapter.reqs) == 0 {
t.Fatal("适配器没有收到任何请求")
}
return adapter.reqs[0].Messages
}
// identityMessagesContain 报告消息里是否有任一携带给定文本。
func identityMessagesContain(messages []model.Message, want string) bool {
for _, m := range messages {
for _, part := range m.Content {
if strings.Contains(part.Text, want) {
return true
}
}
}
return false
}
// TestPersonaInstructions_StaticPromptHasNoIdentityKnowledge 是本收窄的
// 反证断言:静态 system 指令里**不再**有身份说明书 —— 默认 agent 与角色池
// 里每个 agent 的指令都逐字节等于人格指令本身。静态注入正是泄漏面
// (它会跟着角色进入群聊与陌生人私聊),因此它必须消失。
func TestPersonaInstructions_StaticPromptHasNoIdentityKnowledge(t *testing.T) {
t.Parallel()
a, p := identityApp(t, writeIdentity(t))
base := instructionsFromPersona(p)
if got := a.defaultInstructions(); got != base {
t.Fatalf("默认静态指令被身份说明书污染:\n%q\nwant\n%q", got, base)
}
if pooled := a.personaInstructions(p); pooled != base {
t.Fatalf("角色池静态指令被身份说明书污染:\n%q\nwant\n%q", pooled, base)
}
if strings.Contains(a.defaultInstructions(), identityFixture) {
t.Fatal("静态指令里仍有身份说明书正文 —— 它会被带进每一个会话")
}
llm := model.NewRuntimeLLM(filepath.Join(t.TempDir(), "models.json"), config.LLMConfig{}, testLog())
for id, ag := range a.buildPersonaAgents(llm, nil, a.buildGuardrailPipeline(noWindowBudget())) {
if ag.Instructions() != base {
t.Fatalf("角色 %q 的静态指令与人格指令不一致(含身份说明书?):\n%q", id, ag.Instructions())
}
}
}
// TestOwnerIdentityKnowledge_MissingFileIsFailSoft 是真断言(既有回归):文件
// 缺失、路径指向目录、未配置都只 WARN,读出来是空串 —— 此时连主人会话也
// 什么都不注入,输出与「接线前」逐字节一致,绝不报错、绝不阻断对话。
func TestOwnerIdentityKnowledge_MissingFileIsFailSoft(t *testing.T) {
t.Parallel()
unsetInjectionEnv(t)
missing := filepath.Join(t.TempDir(), "nope.md")
for _, tc := range []struct {
name string
path string
}{
{name: "文件不存在", path: missing},
{name: "路径是目录", path: t.TempDir()},
{name: "没配 identityFile", path: ""},
} {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
a, p := identityApp(t, tc.path)
if got, want := a.defaultInstructions(), instructionsFromPersona(p); got != want {
t.Fatalf("身份说明书不可用时静态指令变了:\n%q\nwant\n%q", got, want)
}
if a.ownerIdentityKnowledge() != "" {
t.Fatalf("ownerIdentityKnowledge() = %q,want 空串", a.ownerIdentityKnowledge())
}
if block := identityKnowledgeBlock(a.ownerIdentityKnowledge()); block != "" {
t.Fatalf("没有正文时不该渲染出任何块:%q", block)
}
resolve := a.personaKnowledgeFor(absentIdentityEnvFile(t))
if got := resolve(envOwnerTurn()); got != "" {
t.Fatalf("没有正文时主人会话也被注入了:%q", got)
}
})
}
}
// TestOwnerIdentityInjection_ScopeIsPerSession 是本任务的验收核心:
// 默认档(owner_only)下主人会话拿到整块身份说明书,群聊与陌生人私聊拿到
// 空串 —— 那段知识不进它们的上下文,也就不随请求发给模型供应商。
func TestOwnerIdentityInjection_ScopeIsPerSession(t *testing.T) {
t.Parallel()
unsetInjectionEnv(t)
a, _ := identityApp(t, writeIdentity(t))
resolve := a.personaKnowledgeFor(absentIdentityEnvFile(t))
owner := resolve(envOwnerTurn())
if owner == "" || !strings.Contains(owner, identityFixture) {
t.Fatalf("主人会话没有拿到身份说明书:%q", owner)
}
if !strings.HasPrefix(owner, identityKnowledgeHeader) {
t.Fatalf("主人会话的知识块没有段头:%q", owner)
}
for _, tc := range []struct {
name string
env loop.SessionEnv
}{
{name: "群聊会话", env: envGroupTurn()},
{name: "陌生人私聊会话", env: envStrangerTurn()},
} {
if got := resolve(tc.env); got != "" {
t.Fatalf("%s 拿到了身份说明书(红线泄漏):%q", tc.name, got)
}
}
}
// TestOwnerIdentityInjection_AlwaysAndOff 证明开关真的生效:
// always 档下群聊也有(明确开启的红线例外),off 档下连主人会话也没有。
func TestOwnerIdentityInjection_AlwaysAndOff(t *testing.T) {
cases := []struct {
name string
mode string
env loop.SessionEnv
wantContent bool
}{
{name: "always-群聊", mode: config.OwnerIdentityAlways, env: envGroupTurn(), wantContent: true},
{name: "always-陌生人", mode: config.OwnerIdentityAlways, env: envStrangerTurn(), wantContent: true},
{name: "off-主人", mode: config.OwnerIdentityOff, env: envOwnerTurn(), wantContent: false},
{name: "off-群聊", mode: config.OwnerIdentityOff, env: envGroupTurn(), wantContent: false},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
unsetInjectionEnv(t)
t.Setenv(config.OwnerIdentityInjectionEnv, tc.mode)
a, _ := identityApp(t, writeIdentity(t))
got := a.personaKnowledgeFor(absentIdentityEnvFile(t))(tc.env)
if tc.wantContent != strings.Contains(got, identityFixture) {
t.Fatalf("%s:解析结果 %q,want 含正文 = %v", tc.name, got, tc.wantContent)
}
})
}
}
// TestOwnerIdentityInjection_IllegalValueIsNotSilent 钉住「非法取值不静默」:
// 回退到最安全的 owner_only,且日志里带上原始取值与回退档。
func TestOwnerIdentityInjection_IllegalValueIsNotSilent(t *testing.T) {
unsetInjectionEnv(t)
t.Setenv(config.OwnerIdentityInjectionEnv, "everyone")
var buf bytes.Buffer
log := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
a, _ := identityAppLogged(t, writeIdentity(t), log)
resolve := a.personaKnowledgeFor(absentIdentityEnvFile(t))
if got := resolve(envGroupTurn()); got != "" {
t.Fatalf("非法取值下群聊被注入了:%q", got)
}
if got := resolve(envOwnerTurn()); !strings.Contains(got, identityFixture) {
t.Fatalf("非法取值应回退到 owner_only(主人会话仍应有正文):%q", got)
}
logged := buf.String()
for _, want := range []string{config.OwnerIdentityInjectionEnv, "everyone", config.OwnerIdentityOwnerOnly, "WARN"} {
if !strings.Contains(logged, want) {
t.Fatalf("日志里没有 %q(非法取值不得静默):\n%s", want, logged)
}
}
if strings.Contains(logged, identityFixture) {
t.Fatal("日志里出现了身份说明书正文(不得把正文打进日志)")
}
}
// TestOwnerIdentityInjection_EndToEnd 是端到端真断言:真正发给模型的上下文
// 里,主人会话含身份正文(作为 agent 指令之后的第 2 条 system),群聊与
// 陌生人私聊的每条消息都不含它。
func TestOwnerIdentityInjection_EndToEnd(t *testing.T) {
t.Parallel()
unsetInjectionEnv(t)
a, _ := identityApp(t, writeIdentity(t))
resolve := a.personaKnowledgeFor(absentIdentityEnvFile(t))
ownerMsgs := runIdentityTurn(t, resolve, envOwnerTurn())
if !identityMessagesContain(ownerMsgs, identityFixture) {
t.Fatalf("主人会话的模型上下文里没有身份说明书:%+v", ownerMsgs)
}
if len(ownerMsgs) < 2 || !strings.Contains(ownerMsgs[1].Content[0].Text, identityKnowledgeHeader) {
t.Fatalf("身份知识块的位置不对(want 紧接 agent 指令之后):%+v", ownerMsgs)
}
for _, tc := range []struct {
name string
env loop.SessionEnv
}{
{name: "群聊会话", env: envGroupTurn()},
{name: "陌生人私聊会话", env: envStrangerTurn()},
} {
msgs := runIdentityTurn(t, resolve, tc.env)
if identityMessagesContain(msgs, identityFixture) {
t.Fatalf("%s 的模型上下文里出现了身份说明书:%+v", tc.name, msgs)
}
if identityMessagesContain(msgs, identityKnowledgeHeader) {
t.Fatalf("%s 的模型上下文里出现了身份说明书段头:%+v", tc.name, msgs)
}
}
}
// TestOwnerIdentity_NeverChangesOwnerJudgement 是设计红线(design/11 §0)的真
// 断言:说明书正文会作为人格知识进入主人上下文,但主人判定只认 actor 元数据
// + 白名单 —— 连文件里点名写到的那个陌生账号也照样不是主人。
func TestOwnerIdentity_NeverChangesOwnerJudgement(t *testing.T) {
t.Parallel()
a, _ := identityApp(t, writeIdentity(t))
// 正文确实会被注入(只是限定在主人会话),且里面写着「kook:stranger-999」。
block := identityKnowledgeBlock(a.ownerIdentityKnowledge())
if !strings.Contains(block, "stranger-999") {
t.Fatalf("测试前提不成立:说明书正文没被读出来")
}
if !a.owner.IsOwner("kook", "owner-1") {
t.Fatal("白名单里的账号未被认成主人")
}
if a.owner.IsOwner("kook", "stranger-999") {
t.Fatal("身份说明书里出现过的账号被判成了主人 —— 身份不能被文本左右")
}
if a.owner.IsOwner("kook", "") {
t.Fatal("空账号被判成了主人")
}
}
+167
View File
@@ -0,0 +1,167 @@
package main
import (
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 以下段头对应 design/01 §2.2 中此前未被消费的人格字段。它们作为独立的 system
// 提示段追加在身份块之后,使每一段都能单独调优而不必改动身份正文。
const (
personalityHeader = "【性格】"
valuesHeader = "【价值观】"
speechStyleHeader = "【说话风格】"
catchphraseHeader = "【口头禅】"
backgroundHeader = "【背景】"
)
// personaSection 是一个追加块:方括号段头 + 正文。
type personaSection struct {
header string
body string
}
// defaultPersona 返回默认角色的 persona;没有注册角色/persona 时返回 false。
func (a *app) defaultPersona() (*loader.Persona, bool) {
c, ok := a.personas.DefaultCharacter()
if !ok {
return nil, false
}
p, ok := a.personas.Persona(c.PersonaID)
if !ok || p == nil {
return nil, false
}
return p, true
}
// identityInstructionFor 返回 persona 的身份块 —— design/01 §2.2 之前唯一注入的
// 部分:优先 identity.systemPrompt,其次 identity.role,再退回最小兜底文本。
func identityInstructionFor(p *loader.Persona) string {
if p.Identity.SystemPrompt != "" {
return p.Identity.SystemPrompt
}
if p.Identity.Role != "" {
return p.Identity.Role
}
return fallbackInstruction
}
// instructionsFromPersona 构建 persona 的 system 指令:身份块,后接此前无人消费的
// personality/values/speechStyle/catchphrase/background 各段(design/01 §2.2)。
// 空白字段会被跳过,因此不会产生空段;这些字段全空的 persona 产出与身份块完全一致
// —— 与既有输出逐字节相同。
func instructionsFromPersona(p *loader.Persona) string {
base := identityInstructionFor(p)
sections := personaSections(p)
if len(sections) == 0 {
return base
}
var b strings.Builder
b.WriteString(base)
for _, s := range sections {
b.WriteString("\n\n")
b.WriteString(s.header)
b.WriteString("\n")
b.WriteString(s.body)
}
return b.String()
}
// personaSections 按固定顺序渲染五个可选段(personality → values → speechStyle
// → catchphrase → background),跳过其中为空的。
func personaSections(p *loader.Persona) []personaSection {
out := make([]personaSection, 0, 5)
appendSection := func(header, body string) {
if body = strings.TrimSpace(body); body == "" {
return
}
out = append(out, personaSection{header: header, body: body})
}
appendSection(personalityHeader, p.Personality)
appendSection(valuesHeader, bulletList(p.Values))
appendSection(speechStyleHeader, p.SpeechStyle)
appendSection(catchphraseHeader, p.Catchphrase)
appendSection(backgroundHeader, p.Background)
return out
}
// bulletList 把字符串项渲染成 markdown 无序列表,跳过空白项;全空的列表产出 ""
// 使对应段彻底消失。
func bulletList(items []string) string {
lines := make([]string, 0, len(items))
for _, item := range items {
if item = strings.TrimSpace(item); item != "" {
lines = append(lines, "- "+item)
}
}
return strings.Join(lines, "\n")
}
// defaultThinkingOptions 返回循环应为默认 persona 注入的推理开关:thinking 指令、
// reasoning_effort 取值与 thinking 广播开关。它是循环每轮所做的按角色解析的默认
// 一半(Options.PersonaKnobs,见下方 personaKnobs),因此配置的默认值与每轮查询
// 永远不会漂移。
//
// 只有当 persona 配置了会影响请求的内容(预算、effort,或显式的开关模式)时
// thinking 指令才非 nil。showToUser 刻意不产生指令:它只是呈现开关,而一个没有
// 开关的非 nil 指令会顺带打开供应商侧 thinking。未注册 persona 时三者均为零值,
// 循环既有行为不受影响。
func (a *app) defaultThinkingOptions() (*model.Thinking, string, *bool) {
knobs, ok := a.personaKnobs(a.defaultPersonaID())
if !ok {
return nil, "", nil
}
return knobs.Thinking, knobs.ReasoningEffort, knobs.ThinkingShowToUser
}
// personaKnobs 为循环解析某个角色的推理开关(loop.Options.PersonaKnobs,
// design/22 §4.2):循环会为即将构建请求的那一轮询问其角色,因此绑定角色 Y 的
// 会话运行时用的是 Y 的 thinking 预算 / effort / 广播开关,而不是默认角色的。
// 从角色 id 解析,而不是把取值推入每个调用方的 SessionEnv,意味所有路径 —— Web
// REST/WS/语音、IM、backthink、子代理 —— 都由同一个输入拿到正确的开关。
// ok=false 表示「角色未知」,循环沿用其配置的默认值。
func (a *app) personaKnobs(personaID string) (loop.LoopKnobs, bool) {
if personaID == "" {
return loop.LoopKnobs{}, false
}
p, ok := a.personas.Persona(personaID)
if !ok || p == nil {
return loop.LoopKnobs{}, false
}
thinking, effort := personaThinking(p)
return loop.LoopKnobs{
Thinking: thinking,
ReasoningEffort: effort,
ThinkingShowToUser: p.Thinking.ShowToUser,
}, true
}
// personaName 把角色 id 解析成其展示名,供模型上下文的说话人归属使用
// (loop.Options.PersonaName,design/22 §7-5)。循环把发话角色写成
// 「「昔涟」(xilian)」而不是裸 id,使模型在会话切换角色后仍能跟上谁说了什么。
// id 为空或未知时解析为 "",循环退回只写 id —— 归属不会因为名字解析不出就消失,
// 且这里没有任何身份或权限输入。
func (a *app) personaName(personaID string) string {
if personaID == "" {
return ""
}
p, ok := a.personas.Persona(personaID)
if !ok || p == nil {
return ""
}
return strings.TrimSpace(p.Name)
}
// personaThinking 把 persona 的 `thinking` 段映射到模型层开关(design/01 §2.1 /
// design/04 §3.2)。没有任何内容影响请求时返回 (nil, "")。
func personaThinking(p *loader.Persona) (*model.Thinking, string) {
effort := strings.TrimSpace(p.Thinking.Effort)
enable := p.Thinking.Enable()
if p.Thinking.BudgetTokens == 0 && effort == "" && enable == nil {
return nil, ""
}
return &model.Thinking{BudgetTokens: p.Thinking.BudgetTokens, Enable: enable}, effort
}
+240
View File
@@ -0,0 +1,240 @@
package main
import (
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
)
// boolPtr returns a pointer to v, for the tri-state switch fields.
func boolPtr(v bool) *bool { return &v }
// TestInstructionsFromPersona_AllEmptyIsByteIdenticalToTheOldOutput locks the
// backward-compatibility promise of design/01 §2.2: a persona that sets none of
// the five previously-inert fields produces exactly what the old code produced
// (the identity block alone, in its own precedence order).
func TestInstructionsFromPersona_AllEmptyIsByteIdenticalToTheOldOutput(t *testing.T) {
t.Parallel()
cases := []struct {
name string
p loader.Persona
want string
}{
{
name: "system prompt wins",
p: loader.Persona{Identity: loader.Identity{SystemPrompt: "身份块", Role: "人设"}},
want: "身份块",
},
{
name: "role fallback",
p: loader.Persona{Identity: loader.Identity{Role: "人设"}},
want: "人设",
},
{
name: "minimal fallback",
p: loader.Persona{},
want: fallbackInstruction,
},
}
for _, tt := range cases {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := instructionsFromPersona(&tt.p); got != tt.want {
t.Fatalf("instructionsFromPersona() = %q, want %q (byte-identical)", got, tt.want)
}
})
}
}
// TestInstructionsFromPersona_SectionOrderAndFormat pins the exact concatenation:
// identity block first, then personality → values → speechStyle → catchphrase →
// background, each with its bracketed header and no blank section.
func TestInstructionsFromPersona_SectionOrderAndFormat(t *testing.T) {
t.Parallel()
p := loader.Persona{
Identity: loader.Identity{SystemPrompt: "你是昔涟。"},
Personality: "温柔体贴",
Values: []string{"陪伴", "真诚"},
SpeechStyle: "轻声细语",
Catchphrase: "嘿嘿♪",
Background: "来自翁法罗斯。",
}
want := "你是昔涟。" +
"\n\n【性格】\n温柔体贴" +
"\n\n【价值观】\n- 陪伴\n- 真诚" +
"\n\n【说话风格】\n轻声细语" +
"\n\n【口头禅】\n嘿嘿♪" +
"\n\n【背景】\n来自翁法罗斯。"
got := instructionsFromPersona(&p)
if got != want {
t.Fatalf("instructionsFromPersona() = %q\nwant %q", got, want)
}
// The identity block must remain the first section, unchanged.
if !strings.HasPrefix(got, "你是昔涟。\n\n") {
t.Fatalf("identity block is not the leading section: %q", got)
}
}
// TestInstructionsFromPersona_BlankFieldsProduceNoSection asserts an unset (or
// blank) field contributes no header and no empty paragraph, and that the
// sections keep their relative order when only some are set.
func TestInstructionsFromPersona_BlankFieldsProduceNoSection(t *testing.T) {
t.Parallel()
p := loader.Persona{
Identity: loader.Identity{SystemPrompt: "身份"},
Values: []string{"", " ", "真诚"},
Background: " ",
}
got := instructionsFromPersona(&p)
want := "身份\n\n【价值观】\n- 真诚"
if got != want {
t.Fatalf("instructionsFromPersona() = %q, want %q", got, want)
}
for _, header := range []string{personalityHeader, speechStyleHeader, catchphraseHeader, backgroundHeader} {
if strings.Contains(got, header) {
t.Errorf("output %q contains %s for a blank field", got, header)
}
}
}
// TestInstructionsFromPersona_RealDefaultPersona checks the shipped default
// persona end to end: its five fields are now injected, in order, after the
// identity block — and the identity block alone is still a strict prefix (so the
// pre-existing prompt is preserved verbatim, nothing rewritten).
func TestInstructionsFromPersona_RealDefaultPersona(t *testing.T) {
t.Parallel()
p, err := loader.Load("../../configs/personas/cyrene.yaml")
if err != nil {
t.Fatalf("Load(cyrene.yaml): %v", err)
}
got := instructionsFromPersona(p)
if !strings.HasPrefix(got, p.Identity.SystemPrompt) {
t.Fatal("identity.systemPrompt is not the leading block")
}
last := -1
for _, header := range []string{personalityHeader, valuesHeader, speechStyleHeader, catchphraseHeader, backgroundHeader} {
idx := strings.Index(got, header)
if idx < 0 {
t.Fatalf("output is missing %s", header)
}
if idx <= last {
t.Fatalf("%s is out of order (at %d, previous at %d)", header, idx, last)
}
last = idx
}
}
// TestPersonaThinking_MappingPinsTheKnobs asserts the persona → model.Thinking
// mapping: only request-affecting fields create a directive (so a bare
// showToUser, which is presentation-only, can never switch provider thinking on),
// and mode=default never becomes an explicit switch.
func TestPersonaThinking_MappingPinsTheKnobs(t *testing.T) {
t.Parallel()
show := false
cases := []struct {
name string
thinking loader.ThinkingParams
wantNil bool
wantBudget int
wantEnable *bool
wantEffort string
}{
{name: "nothing", thinking: loader.ThinkingParams{}, wantNil: true},
{name: "default mode only", thinking: loader.ThinkingParams{Mode: loader.ThinkingModeDefault}, wantNil: true},
{name: "showToUser alone is presentation only", thinking: loader.ThinkingParams{ShowToUser: &show}, wantNil: true},
{name: "budget", thinking: loader.ThinkingParams{BudgetTokens: 4096}, wantBudget: 4096},
{name: "effort", thinking: loader.ThinkingParams{Effort: " medium "}, wantEffort: "medium"},
{name: "mode on", thinking: loader.ThinkingParams{Mode: loader.ThinkingModeOn}, wantEnable: boolPtr(true)},
{name: "mode off", thinking: loader.ThinkingParams{Mode: loader.ThinkingModeOff}, wantEnable: boolPtr(false)},
}
for _, tt := range cases {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
p := &loader.Persona{Thinking: tt.thinking}
got, effort := personaThinking(p)
if tt.wantNil {
if got != nil {
t.Fatalf("personaThinking() = %+v, want nil", got)
}
if effort != "" {
t.Fatalf("effort = %q, want empty", effort)
}
return
}
if got == nil {
t.Fatal("personaThinking() = nil, want a directive")
}
if got.BudgetTokens != tt.wantBudget {
t.Errorf("BudgetTokens = %d, want %d", got.BudgetTokens, tt.wantBudget)
}
if effort != tt.wantEffort {
t.Errorf("effort = %q, want %q", effort, tt.wantEffort)
}
switch {
case tt.wantEnable == nil && got.Enable != nil:
t.Errorf("Enable = %v, want nil", *got.Enable)
case tt.wantEnable != nil && got.Enable == nil:
t.Errorf("Enable = nil, want %v", *tt.wantEnable)
case tt.wantEnable != nil && *got.Enable != *tt.wantEnable:
t.Errorf("Enable = %v, want %v", *got.Enable, *tt.wantEnable)
}
})
}
}
// TestDefaultThinkingOptions_FromRegistry walks the wiring the composition root
// uses: the default character's persona supplies the loop's knobs, and an
// unregistered persona yields the zero options (pre-existing behavior).
func TestDefaultThinkingOptions_FromRegistry(t *testing.T) {
t.Parallel()
reg := personareg.New()
show := false
p := &loader.Persona{
ID: "xilian",
Name: "昔涟",
Identity: loader.Identity{Role: "r", SystemPrompt: "s"},
Thinking: loader.ThinkingParams{BudgetTokens: 2048, Effort: "high", ShowToUser: &show, Mode: loader.ThinkingModeOn},
}
if err := reg.RegisterPersona(p); err != nil {
t.Fatalf("RegisterPersona: %v", err)
}
if err := reg.RegisterCharacter(&personareg.Character{ID: "xilian", Name: "昔涟", Default: true, PersonaID: "xilian"}); err != nil {
t.Fatalf("RegisterCharacter: %v", err)
}
a := &app{personas: reg}
thinking, effort, gotShow := a.defaultThinkingOptions()
if thinking == nil || thinking.BudgetTokens != 2048 {
t.Fatalf("thinking = %+v, want BudgetTokens 2048", thinking)
}
if thinking.Enable == nil || !*thinking.Enable {
t.Fatalf("thinking.Enable = %v, want true (mode: on)", thinking.Enable)
}
if effort != "high" {
t.Fatalf("effort = %q, want high", effort)
}
if gotShow == nil || *gotShow {
t.Fatalf("showToUser = %v, want an explicit false", gotShow)
}
}
// TestDefaultThinkingOptions_NoPersona keeps the no-registry case inert.
func TestDefaultThinkingOptions_NoPersona(t *testing.T) {
t.Parallel()
a := &app{personas: personareg.New()}
thinking, effort, show := a.defaultThinkingOptions()
if thinking != nil || effort != "" || show != nil {
t.Fatalf("defaultThinkingOptions() = %+v/%q/%v, want the zero options", thinking, effort, show)
}
}
+19
View File
@@ -0,0 +1,19 @@
package main
import (
"context"
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// wirePlugins 在能力注册表与被 gitignore 的 plugins.json 之上构建运行时插件管理器,
// 再恢复每个已持久化的 MCP server,使已安装的插件在重启后依然存在。启动时连不上的
// server 只记日志并保留在 plugins.json 中(主人可重试或卸载它);启动绝不因某个
// 外部 server 不可用而失败。
func (a *app) wirePlugins(ctx context.Context) {
path := runtimeconfig.DefaultPluginsPath(resolveDataDir(a.cfg))
a.plugins = pluginmgr.NewManager(a.caps, path, logger.WithSource(a.log, "capability/plugin"))
a.plugins.Restore(ctx)
}
+97
View File
@@ -0,0 +1,97 @@
package main
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// pluginFakeMCP is a minimal Streamable HTTP MCP server for the composition-root
// test: it answers initialize and tools/list over POST.
type pluginFakeMCP struct{ tools []string }
func (f pluginFakeMCP) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
if fl, ok := w.(http.Flusher); ok {
fl.Flush()
}
<-r.Context().Done()
return
}
body, _ := io.ReadAll(r.Body)
var req struct {
ID json.RawMessage `json:"id"`
Method string `json:"method"`
}
_ = json.Unmarshal(body, &req)
if len(req.ID) == 0 || string(req.ID) == "null" {
w.WriteHeader(http.StatusAccepted)
return
}
var result any
switch req.Method {
case "initialize":
result = map[string]any{"protocolVersion": "2025-11-25", "capabilities": map[string]any{}, "serverInfo": map[string]any{"name": "fake", "version": "1.0"}}
case "tools/list":
list := make([]map[string]any, 0, len(f.tools))
for _, name := range f.tools {
list = append(list, map[string]any{"name": name, "description": name, "inputSchema": map[string]any{"type": "object"}})
}
result = map[string]any{"tools": list}
default:
result = map[string]any{}
}
resp, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": json.RawMessage(req.ID), "result": result})
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(resp)
}
// TestWirePlugins_RestoresPersistedServer proves the composition root builds the
// plugin manager over the resolved data dir (plugins.json) and reinstalls a
// persisted MCP server at boot, so a panel install survives a restart.
func TestWirePlugins_RestoresPersistedServer(t *testing.T) {
dir := t.TempDir()
mcpSrv := httptest.NewServer(pluginFakeMCP{tools: []string{"add"}})
defer mcpSrv.Close()
plugins := []runtimeconfig.PluginConfig{{
ID: "p1",
Name: "calc",
Kind: runtimeconfig.PluginKindMCP,
Transport: runtimeconfig.TransportHTTP,
Endpoint: mcpSrv.URL,
CreatedAt: "2026-09-08T00:00:00Z",
}}
raw, err := json.Marshal(plugins)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "plugins.json"), raw, 0o600); err != nil {
t.Fatalf("write plugins.json: %v", err)
}
caps := registry.New()
a := &app{caps: caps, cfg: &config.Config{DataDir: dir}, log: testLog()}
a.wirePlugins(context.Background())
if a.plugins == nil {
t.Fatal("plugins manager not wired")
}
defer a.plugins.Close(context.Background())
if _, err := caps.Get("add"); err != nil {
t.Fatalf("restored tool add not registered: %v", err)
}
if _, err := caps.Get("mcp:calc"); err != nil {
t.Fatalf("mcp:calc capability not registered: %v", err)
}
}
+91
View File
@@ -0,0 +1,91 @@
package main
import (
"log/slog"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/sessionread"
)
// wireSessionReadTool 注册 design/22 §8-3 的跨会话读取工具,使运行中的角色能把
// 另一个 Web 会话的上下文作为外部只读材料拉取过来(design/22 §8.1)。
//
// 该能力以默认的可达性标记(两者皆 false)注册,从而保持仅主人可用:design/11 §3 的
// 策略引擎会对群聊/陌生人会话隐藏其 schema,循环的安全门也会在那里拒绝调用。工具
// 自身的 fail-closed 检查(主人环境 + web/web:<uuid> 白名单)运行在那道门之后,
// 因此未授权的读取会被双重拒绝。审计链路就是循环的 gate/OnAudit,与其他每个工具
// 完全一致 —— 这里不添加任何绕过路径。
func (a *app) wireSessionReadTool() {
log := logger.WithSource(a.log, "cmd/cyrene")
if a.history == nil {
log.Warn("session read tool skipped: no history store")
return
}
if a.dispatcher == nil {
log.Warn("session read tool skipped: no tool dispatcher")
return
}
reader := sessionread.New(
sessionread.WithHistory(a.history),
sessionread.WithPersonas(sessionReadPersonas{reg: a.personas}),
sessionread.WithLocation(a.sessionReadLocation(log)),
sessionread.WithLogger(a.log),
)
if err := a.dispatcher.Register(reader); err != nil {
log.Warn("session read tool register skipped", "error", err)
return
}
log.Info("session read tool registered", "tool", sessionread.ToolName, "owner_only", true)
}
// sessionReadLocation 解析材料时间戳所用的已配置 IANA 时区,使外部 transcript 与
// 循环逐轮注入的「当前时间」读同一个时钟(internal/runtime/loop/time.go)。未设置
// 时区或加载失败时返回 nil,保持工具的进程本地默认值 —— 读取不该因为一个展示设置
// 而失败。
func (a *app) sessionReadLocation(log *slog.Logger) *time.Location {
if a.cfg == nil || a.cfg.Timezone == "" {
return nil
}
loc, err := time.LoadLocation(a.cfg.Timezone)
if err != nil {
log.Warn("session read tool timezone load failed; using the process-local zone",
"tz", a.cfg.Timezone, "error", err)
return nil
}
return loc
}
// sessionReadPersonas 把人格注册表适配为 sessionread.PersonaNamer。该工具包刻意不
// 导入 internal/persona/registry(而是持有一个窄接口,保持分层单向),因此由组合根
// 提供显示名查询。
type sessionReadPersonas struct {
reg *personareg.Registry
}
// PersonaName 实现 sessionread.PersonaNamer:返回人格的显示名;对未知/未注册的角色
// 返回 ok=false,使工具展示原始 id 而不是编造一个名字。
func (p sessionReadPersonas) PersonaName(id string) (string, bool) {
if p.reg == nil || id == "" {
return "", false
}
persona, ok := p.reg.Persona(id)
if !ok || persona == nil || persona.Name == "" {
return "", false
}
return persona.Name, true
}
// DefaultPersonaID 实现 sessionread.PersonaNamer:返回库默认角色所绑定的人格,与
// (*app).defaultPersonaID 保持一致。空 id 表示材料渲染显式的「未设置角色」标记,
// 而不是靠猜。
func (p sessionReadPersonas) DefaultPersonaID() string {
if p.reg == nil {
return ""
}
if c, ok := p.reg.DefaultCharacter(); ok && c != nil {
return c.PersonaID
}
return ""
}
+182
View File
@@ -0,0 +1,182 @@
package main
import (
"context"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/emotion"
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/sessionread"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
)
// testConfigWithTimezone builds the minimal config the location lookup reads.
func testConfigWithTimezone(tz string) *config.Config {
return &config.Config{Timezone: tz}
}
// newTestHistory opens an isolated in-memory chat-history store for the wiring
// tests, mirroring what wire.go hands the composition root.
func newTestHistory(t *testing.T) *history.Store {
t.Helper()
ctx := context.Background()
conn, err := db.Open(ctx, ":memory:", testLog())
if err != nil {
t.Fatalf("open memory db: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
st, err := history.NewStore(ctx, conn, testLog())
if err != nil {
t.Fatalf("new history store: %v", err)
}
return st
}
// newTestPersonas registers one explicit role plus the default character, so the
// adapter's two answers (a role's display name and the default role id) are real.
func newTestPersonas(t *testing.T) *personareg.Registry {
t.Helper()
reg := personareg.New()
for _, p := range []*loader.Persona{
{
ID: "cyrene", Name: "昔涟",
Emotion: loader.EmotionParams{InitialState: emotion.Calm, DecayRate: 0.5},
Affection: loader.AffectionParams{Initial: 50, Min: 0, Max: 700, Delta: 10},
},
{
ID: "yuki", Name: "雪",
Emotion: loader.EmotionParams{InitialState: emotion.Calm, DecayRate: 0.5},
Affection: loader.AffectionParams{Initial: 20, Min: 0, Max: 700, Delta: 5},
},
} {
if err := reg.RegisterPersona(p); err != nil {
t.Fatalf("RegisterPersona(%s): %v", p.ID, err)
}
}
if err := reg.RegisterCharacter(&personareg.Character{ID: "cyrene", Name: "昔涟", Default: true, PersonaID: "cyrene"}); err != nil {
t.Fatalf("RegisterCharacter(cyrene): %v", err)
}
if err := reg.RegisterCharacter(&personareg.Character{ID: "yuki", Name: "雪", PersonaID: "yuki"}); err != nil {
t.Fatalf("RegisterCharacter(yuki): %v", err)
}
return reg
}
// TestWireSessionReadToolRegistersOwnerOnly locks the composition-root wiring: the
// cross-session read tool must land in the capability registry AND carry the
// default reachability markers, so a group/stranger session can neither see nor
// call it. That is the auditable gate design/22 §8.1 约束 4 relies on — the tool
// itself writes no audit record.
func TestWireSessionReadToolRegistersOwnerOnly(t *testing.T) {
reg := registry.New()
d, err := tool.New(reg)
if err != nil {
t.Fatalf("tool.New: %v", err)
}
a := &app{log: testLog(), dispatcher: d, history: newTestHistory(t), personas: newTestPersonas(t)}
a.wireSessionReadTool()
if _, gerr := reg.Get(sessionread.ToolName); gerr != nil {
t.Fatalf("capability %q is not registered: %v", sessionread.ToolName, gerr)
}
advertised := false
for _, s := range d.ToolSchemas() {
if s.Name == sessionread.ToolName {
advertised = true
}
}
if !advertised {
t.Fatalf("tool schemas %v, want %q advertised", d.ToolSchemas(), sessionread.ToolName)
}
if !restrict.SetContext(reg, restrict.ActorOwner).Allow(sessionread.ToolName) {
t.Fatal("owner was denied the session read tool")
}
if restrict.SetContext(reg, restrict.ActorGroupMember).Allow(sessionread.ToolName) {
t.Fatal("group member may invoke the session read tool; want owner-only")
}
if restrict.SetContext(reg, restrict.ActorStranger).Allow(sessionread.ToolName) {
t.Fatal("stranger may invoke the session read tool; want owner-only")
}
// The registered capability must be the tool itself, not a placeholder.
cap, _ := reg.Get(sessionread.ToolName)
if _, ok := cap.Value.(*sessionread.Tool); !ok {
t.Fatalf("capability value = %T, want *sessionread.Tool", cap.Value)
}
}
// TestWireSessionReadToolDegradedBoot asserts a boot without a history store or a
// dispatcher logs and skips the registration instead of panicking — the tool must
// never be registered half-wired and then fail on the model's first call.
func TestWireSessionReadToolDegradedBoot(t *testing.T) {
t.Parallel()
reg := registry.New()
d, err := tool.New(reg)
if err != nil {
t.Fatalf("tool.New: %v", err)
}
for name, a := range map[string]*app{
"no history": {log: testLog(), dispatcher: d},
"no dispatcher": {log: testLog(), history: newTestHistory(t)},
} {
t.Run(name, func(t *testing.T) {
a.wireSessionReadTool()
if _, gerr := reg.Get(sessionread.ToolName); gerr == nil {
t.Fatalf("the tool was registered in a degraded boot (%s)", name)
}
})
}
}
// TestSessionReadPersonasAdapter locks the adapter the tool's attribution depends
// on: a known role answers its display name, an unknown one is reported unknown
// (so the raw id is shown instead of a wrong name), and the default is the persona
// of the default character.
func TestSessionReadPersonasAdapter(t *testing.T) {
t.Parallel()
adapter := sessionReadPersonas{reg: newTestPersonas(t)}
if got, ok := adapter.PersonaName("yuki"); !ok || got != "雪" {
t.Fatalf("PersonaName(yuki) = %q, %v; want 雪, true", got, ok)
}
if _, ok := adapter.PersonaName("nobody"); ok {
t.Fatal("PersonaName(nobody) reported a name; want unknown")
}
if got, ok := adapter.PersonaName(""); ok || got != "" {
t.Fatalf("PersonaName(\"\") = %q, %v; want \"\", false", got, ok)
}
if got := adapter.DefaultPersonaID(); got != "cyrene" {
t.Fatalf("DefaultPersonaID() = %q, want cyrene", got)
}
// A nil registry must degrade quietly, not panic: the wiring tolerates a
// persona-less boot.
empty := sessionReadPersonas{}
if _, ok := empty.PersonaName("yuki"); ok || empty.DefaultPersonaID() != "" {
t.Fatal("a nil registry answered a role lookup")
}
}
// TestSessionReadLocation asserts the configured zone is honored and an
// unresolvable one degrades to nil (the tool's process-local default) instead of
// failing the wiring.
func TestSessionReadLocation(t *testing.T) {
t.Parallel()
a := &app{log: testLog(), cfg: testConfigWithTimezone("Asia/Shanghai")}
if loc := a.sessionReadLocation(testLog()); loc == nil || loc.String() != "Asia/Shanghai" {
t.Fatalf("sessionReadLocation() = %v, want Asia/Shanghai", loc)
}
bad := &app{log: testLog(), cfg: testConfigWithTimezone("Not/AZone")}
if loc := bad.sessionReadLocation(testLog()); loc != nil {
t.Fatalf("sessionReadLocation() = %v, want nil for an unresolvable zone", loc)
}
none := &app{log: testLog()}
if loc := none.sessionReadLocation(testLog()); loc != nil {
t.Fatalf("sessionReadLocation() = %v, want nil without config", loc)
}
}
+35
View File
@@ -0,0 +1,35 @@
package main
import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/skills"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// defaultSkillsDir 是技能 YAML 定义的磁盘目录。它是可选的:目录缺失时只告警并
// 跳过技能加载。
const defaultSkillsDir = "configs/skills"
// wireSkills 从 configs/skills 加载技能定义,并把每个技能以 KindSkill 能力注册进
// 能力注册表,供插件生态枚举。加载+注册是 M5 的交付物;Runner.Run(端到端执行
// 技能)**已实现**(切片 CODE-SK/L2:`Runner.Run` 真执行 + 渐进披露接线;哨兵 `ErrRunPending` 已删除)。
func (a *app) wireSkills() {
log := logger.WithSource(a.log, "cmd/cyrene")
list, err := skills.NewLoader(a.log).LoadDir(defaultSkillsDir)
if err != nil {
log.Warn("skills load skipped", "dir", defaultSkillsDir, "error", err)
return
}
if len(list) == 0 {
log.Debug("no skills defined", "dir", defaultSkillsDir)
return
}
registered := 0
for _, s := range list {
if err := skills.Register(a.caps, s); err != nil {
log.Warn("skill register skipped", "skill", s.Name(), "error", err)
continue
}
registered++
}
log.Info("skills registered", "registered", registered, "loaded", len(list))
}
+29
View File
@@ -0,0 +1,29 @@
package main
import (
"context"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// streamSubject 是循环的 OnStream 闭包发布每个结构化流事件所用的 bus subject。
// gateway hub 订阅 ws.>,并为该 subject 保留一条专用分支,把 model.StreamEvent
// 映射为带 seq 序号的下游强类型 WS 事件(message.*/thinking.*/tool_call.*)。
//
// 它必须与 internal/services/gateway/ws_hub.go 中的 wsStreamSubject 保持一致;
// 两个包若共用同一个 const,会把 gateway 拉进主二进制的导入图,因此该字面量
// 在此重复一份并加以说明。
const streamSubject = "ws.stream"
// streamChunk 是循环的 OnStream 回调。它把一个结构化流事件发布到 gateway 的流式
// subject,由 hub 以对应的强类型 WS 事件广播给每个客户端。未接 bus 时它为空操作,
// 且绝不阻塞循环:bus.Publish 是异步的,单个订阅缓冲写满时丢弃事件而不是让模型
// 流停摆。
func (a *app) streamChunk(ev model.StreamEvent) {
if a.bus == nil {
return
}
if err := a.bus.Publish(context.Background(), streamSubject, ev); err != nil {
a.log.Warn("stream event publish", "err", err)
}
}
+50
View File
@@ -0,0 +1,50 @@
package main
import (
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration"
)
// defaultSubagentTimeout 在别处都未配置时,约束单个子代理的整轮运行。
// DevDocs/design/05-子代理.md §7 要求有超时与防失控上限;调度器两者都已实现
// (PerAgentTimeout、Abort/CancelAll),因此生产必须给出一个值,否则每代理超时
// 形同虚设(调度器里的 0 表示「无超时」)。该值刻意给得宽松 —— 子代理与任何循环
// 一样最多跑 8 轮工具 —— 并且是 SUBAGENT_TIMEOUT_SEC 之后的兜底(裁决 02.4.4:
// 默认 10min,可配置;subagentToolTimeout 解析出生效值)。
const defaultSubagentTimeout = 10 * time.Minute
// subagentToolTimeout 解析分派工具的默认子代理超时:SUBAGENT_TIMEOUT_SEC 为正时
// 以它为准,否则采用代码里的 10 分钟默认值(design/05 §7 裁决 02.4.4)。每次分派
// 传入的 timeout_seconds 参数仍会在工具层按次覆盖此值。
func (a *app) subagentToolTimeout() time.Duration {
if a.cfg != nil && a.cfg.Subagent.TimeoutSec > 0 {
return time.Duration(a.cfg.Subagent.TimeoutSec) * time.Second
}
return defaultSubagentTimeout
}
// wireSubagentTool 向分发器注册子代理分派工具,让 agent 在运行时终于能触达编排层
// (在此之前 (*orchestration.Scheduler).Spawn 没有生产调用方,design/05 只是库)。
//
// 该能力以默认的可达性标记(两者皆 false)注册,从而保持仅主人可用:design/11 §3 的
// 策略引擎会对群聊/陌生人会话隐藏其 schema,循环的安全门也会在那里拒绝调用。
// design/05 §6/§7 未给群聊/陌生人分派后台 agent 的许可,且能生成子代理属于重操作,
// 因此刻意选择安全默认值,而不是让它被意外开启。
func (a *app) wireSubagentTool() {
log := logger.WithSource(a.log, "cmd/cyrene")
if a.scheduler == nil {
log.Warn("sub-agent tool skipped: no scheduler")
return
}
spawner := orchestration.NewSpawnTool(a.scheduler,
orchestration.WithSpawnLogger(a.log),
orchestration.WithSpawnTimeout(a.subagentToolTimeout()))
if err := a.dispatcher.Register(spawner); err != nil {
log.Warn("sub-agent tool register skipped", "error", err)
return
}
log.Info("sub-agent tool registered", "tool", orchestration.SpawnToolName,
"owner_only", true)
}
+131
View File
@@ -0,0 +1,131 @@
package main
import (
"context"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
)
// TestWireSubagentToolRegistersOwnerOnly locks the composition-root wiring: the
// dispatch tool must actually land in the capability registry (otherwise
// orchestration stays library-only), and it must carry the default reachability
// markers so a group/stranger session can neither see nor call it.
func TestWireSubagentToolRegistersOwnerOnly(t *testing.T) {
reg := registry.New()
d, err := tool.New(reg)
if err != nil {
t.Fatalf("tool.New: %v", err)
}
a := &app{log: testLog(), dispatcher: d, scheduler: orchestration.New(orchestration.Options{})}
a.wireSubagentTool()
if _, gerr := reg.Get(orchestration.SpawnToolName); gerr != nil {
t.Fatalf("capability %q is not registered: %v", orchestration.SpawnToolName, gerr)
}
names := make([]string, 0)
for _, s := range d.ToolSchemas() {
names = append(names, s.Name)
}
found := false
for _, n := range names {
if n == orchestration.SpawnToolName {
found = true
}
}
if !found {
t.Fatalf("tool schemas = %v, want the spawn tool advertised", names)
}
if !restrict.SetContext(reg, restrict.ActorOwner).Allow(orchestration.SpawnToolName) {
t.Fatal("owner was denied the spawn tool")
}
if restrict.SetContext(reg, restrict.ActorGroupMember).Allow(orchestration.SpawnToolName) {
t.Fatal("group member may invoke the spawn tool; want owner-only")
}
if restrict.SetContext(reg, restrict.ActorStranger).Allow(orchestration.SpawnToolName) {
t.Fatal("stranger may invoke the spawn tool; want owner-only")
}
// The registered capability must be the dispatch tool itself, not a placeholder.
cap, _ := reg.Get(orchestration.SpawnToolName)
sp, ok := cap.Value.(*orchestration.SpawnTool)
if !ok {
t.Fatalf("capability value = %T, want *orchestration.SpawnTool", cap.Value)
}
if _, err := sp.Execute(context.Background(), map[string]any{}); err == nil {
t.Fatal("the registered tool accepted a goal-less call")
}
}
// TestWireSubagentToolNilScheduler asserts a degraded boot (no scheduler) logs and
// skips the registration instead of panicking.
func TestWireSubagentToolNilScheduler(t *testing.T) {
reg := registry.New()
d, err := tool.New(reg)
if err != nil {
t.Fatalf("tool.New: %v", err)
}
a := &app{log: testLog(), dispatcher: d}
a.wireSubagentTool()
if _, gerr := reg.Get(orchestration.SpawnToolName); gerr == nil {
t.Fatal("the tool was registered without a scheduler")
}
}
// TestPersonaExistsMirrorsAgentPool locks the composition-root half of design/22 §8-2:
// the resolver the scheduler validates a dispatched sub-agent's target persona with
// must answer exactly like the per-role agent pool — a role is dispatchable precisely
// when the loop has an agent carrying its instructions, and nothing else is. So a
// persona with no agent is rejected at dispatch time rather than silently answering as
// the default role.
func TestPersonaExistsMirrorsAgentPool(t *testing.T) {
t.Parallel()
pool := map[string]*agent.Agent{"yuki": {}, "xilian": {}}
exists := personaExists(pool)
if !exists("yuki") || !exists("xilian") {
t.Fatal("a pooled role was reported as unrunnable")
}
if exists("nobody") || exists("") {
t.Fatal("an unpooled role id was accepted as runnable")
}
// The resolver is what orchestration gates the dispatch on, so wire it the same
// way wire.go does and check the gate agrees.
sched := orchestration.New(orchestration.Options{PersonaExists: personaExists(map[string]*agent.Agent{})})
if sched.PersonaKnown("yuki") {
t.Fatal("an empty pool accepted a persona; want the dispatch rejected")
}
if !sched.PersonaKnown("") {
t.Fatal("a persona-less dispatch was rejected by an empty pool; want it unaffected")
}
}
// TestSubagentToolTimeoutFromConfig 锁定组合根的配置接法(design/05 §7 裁决
// 02.4.4):未配置时回落 10min 代码默认,SUBAGENT_TIMEOUT_SEC 生效时被真实
// 采用——派发工具拿到的是配置值而不是常量。
func TestSubagentToolTimeoutFromConfig(t *testing.T) {
a := &app{log: testLog()}
if got := a.subagentToolTimeout(); got != 10*time.Minute {
t.Fatalf("subagentToolTimeout() = %v, want the 10min code default", got)
}
a.cfg = &config.Config{}
a.cfg.Subagent.TimeoutSec = 90
if got := a.subagentToolTimeout(); got != 90*time.Second {
t.Fatalf("subagentToolTimeout() = %v, want 90s from config", got)
}
// 非法(非正)配置不缩小时限:保持代码默认而不是把子代理变成「立即超时」。
a.cfg.Subagent.TimeoutSec = -5
if got := a.subagentToolTimeout(); got != 10*time.Minute {
t.Fatalf("subagentToolTimeout() = %v for a non-positive config, want the code default", got)
}
}
+118
View File
@@ -0,0 +1,118 @@
package main
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/exec"
)
// terminalToolName 是 exec/terminal 工具的注册名。
const terminalToolName = "terminal"
// terminalTool 是 sdk.Tool,通过基于授权的 exec 沙箱(exec.Provider)运行命令。
// 即使沙箱关闭它也会被注册:此时 Execute 返回 exec.ErrExecutionDisabled,因此
// agent 只有在主人显式启用沙箱后才获得执行代码的能力(默认关闭)。
type terminalTool struct {
prov exec.Provider
log *slog.Logger
}
// Name 返回工具标识符。
func (t *terminalTool) Name() string { return terminalToolName }
// Description 说明工具做什么。
func (t *terminalTool) Description() string {
return "Run a terminal command through the sandboxed exec provider and return its stdout, stderr and exit code."
}
// Parameters 返回输入 JSON Schema。
func (t *terminalTool) Parameters() (map[string]any, error) {
return map[string]any{
"type": "object",
"properties": map[string]any{
"command": map[string]any{
"type": "string",
"description": "The command line to run; split into argv by whitespace (no shell interpretation).",
},
"workdir": map[string]any{
"type": "string",
"description": "Optional working directory, confined to an allowed sandbox root.",
},
"timeout": map[string]any{
"type": "number",
"description": "Optional timeout in seconds (default 30).",
},
},
"required": []any{"command"},
}, nil
}
// Execute 通过 exec provider 运行命令并返回结果。沙箱关闭时 provider 返回
// exec.ErrExecutionDisabled,分发器会把它包装成可安全回喂给 LLM 的工具错误。
func (t *terminalTool) Execute(ctx context.Context, args any) (any, error) {
m, ok := args.(map[string]any)
if !ok {
return nil, fmt.Errorf("terminal: args must be an object")
}
cmd, _ := m["command"].(string)
argv := strings.Fields(cmd)
if len(argv) == 0 {
return nil, fmt.Errorf("terminal: command is required")
}
if t.log != nil {
t.log.Debug("terminal tool invoked", "cmd", strings.Join(argv, " "))
}
req := exec.ExecRequest{Command: argv, Timeout: timeoutArg(m["timeout"])}
if wd, ok := m["workdir"].(string); ok {
req.WorkDir = wd
}
res, err := t.prov.Execute(ctx, req)
if err != nil {
return nil, err
}
return map[string]any{
"stdout": res.Stdout,
"stderr": res.Stderr,
"exit": res.ExitCode,
"truncated": res.Truncated,
}, nil
}
// timeoutArg 把可选的 timeout 参数(秒)转换为 duration。缺失或非正值返回 0,
// 由 provider 应用自己的默认值。
func timeoutArg(v any) time.Duration {
switch n := v.(type) {
case float64:
if n > 0 {
return time.Duration(n * float64(time.Second))
}
case int:
if n > 0 {
return time.Duration(n) * time.Second
}
}
return 0
}
// wireTerminalTool 向分发器注册 terminal 工具,使 agent 能发出沙箱内终端命令。
// 未接入 exec provider 时它只告警并空操作,以保证调用方安全。
func (a *app) wireTerminalTool() {
log := logger.WithSource(a.log, "cmd/cyrene")
if a.execProv == nil {
log.Warn("terminal tool skipped: no exec provider")
return
}
if err := a.dispatcher.Register(&terminalTool{
prov: a.execProv,
log: logger.WithSource(a.log, "cmd/cyrene"),
}); err != nil {
log.Warn("terminal tool register skipped", "error", err)
return
}
log.Info("terminal tool registered", "sandbox_enabled", a.cfg.ExecSandbox.Enabled)
}
+45
View File
@@ -0,0 +1,45 @@
package main
import (
"path/filepath"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/vision"
)
// visionToolName 与 ocrToolName 是 vision 与 OCR 工具的注册名。它们在此与工具注册
// 绑定,并由工具分发器通告给 agent。
const (
visionToolName = "vision"
ocrToolName = "ocr"
)
// wireVision 注册 vision 与 OCR 工具以及 vision 模型路由。
//
// vision 适配器是 OpenAI 兼容的 /chat/completions 端点,与聊天 LLM 共用同一 base
// URL(LLM_BASE_URL),配置了专用模型(LLM_VISION_MODEL)时使用该模型。未配置
// base URL 时只告警并跳过这些工具,启动因此绝不阻塞;只要配置了 vision 模型
// (在 wireRuntime 中),模型路由器仍会绑定到它。
func (a *app) wireVision() {
log := logger.WithSource(a.log, "cmd/cyrene")
if strings.TrimSpace(a.cfg.LLM.BaseURL) == "" {
log.Warn("vision/ocr tools skipped: no LLM base url configured")
return
}
adapter := vision.NewOpenAICompatible(a.cfg.LLM,
vision.WithModel(a.cfg.LLM.VisionModel),
// 同源 upload 产物(`/api/v1/files/<name>`)走**本地读取**:与自己取回相比更快,
// 且在「服务端路由不到自己的广告 host」(如安卓模拟器 10.0.2.2、反向代理改写)
// 的部署下才取得到图。目录必须与 upload 的落盘位置一致 = `<DataDir>/uploads`。
vision.WithUploadsDir(filepath.Join(resolveDataDir(a.cfg), "uploads")),
vision.WithLogger(a.log))
if err := vision.RegisterTools(a.caps,
vision.NewVisionTool(visionToolName, adapter),
vision.NewOCRTool(ocrToolName, adapter),
); err != nil {
log.Warn("vision/ocr tool register skipped", "error", err)
return
}
log.Info("vision/ocr tools registered", "vision_model", a.cfg.LLM.VisionModel)
}
+17
View File
@@ -0,0 +1,17 @@
package main
import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
)
// wireVoice 从配置构建语音 STT/TTS 降级链并挂到 app 上,供 WebSocket 的
// voice_input/voice_output 路径使用。降级链依次为 云 → 本地 → edge-tts/espeak →
// 静默,因此端点或 API key 缺失绝不会阻塞启动(设计上接受静默兜底)。
func (a *app) wireVoice() {
a.voice = voice.New(a.cfg.Voice)
logger.WithSource(a.log, "cmd/cyrene").Info("voice chain ready",
"tts_provider", a.cfg.Voice.TTSProvider,
"stt_provider", a.cfg.Voice.STTProvider,
"dashscope_configured", a.cfg.Voice.DashscopeAPIKey != "")
}
+436
View File
@@ -0,0 +1,436 @@
package main
// 本文件是「搜索提供商」页**五个动作本体**的数据面(切片 L1/PLG-2 的执行面接线):
// 可变源的读写(含失败分类)、表格行 / 下拉候选 / 掩码回填的翻译、以及请求体形状的解析。
//
// 上游事实(`plugin.listProviders` / `plugin.saveProvider` / `plugin.testProvider` /
// `plugin.reauthProvider` / `plugin.providers`)由组合根登记到插件管理器上,登记入口在
// `wire_plugin_actions.go`,探针的传输层绑定在 `websearch_probe.go`。
//
// 三条边界(与登记面同):只经 runtimeconfig 读写可变源;凭据只出掩码;形状不对就报错。
import (
"context"
"fmt"
"net/http"
"strings"
"time"
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
)
// --- 四个动作 + 一个候选来源 -------------------------------------------------
// listProviders 是表格区块的数据源:把可变源里的各条配置翻译成面板行
// (provider / state / enabled;**凭据与地址都不进行数据**)。
func (e webSearchActionEnv) listProviders(_ context.Context, _ pluginmgr.ActionRequest) (any, error) {
f, err := e.loadFile()
if err != nil {
return nil, err
}
return map[string]any{"rows": providerRows(f)}, nil
}
// listProviderOptions 给出下拉候选:本期全部已登记的 provider(含尚未配置的家)。
//
// 每项是 `{value,label}`(search.ProviderOption):**`value` 才是落盘的值** —— 它逐字等于
// search.KnownNames(),会被写进 web-search.json 并被注册表按名字取用;`label` 只是显示名
// (中文 + 括注 id),前端拿它渲染下拉与表格,任何写盘路径都不该碰它。
//
// 给全部而不是「已配置的家」的理由见 search.ProviderOptions:表单的用途就是**新增**
// 一家,只列已配置的会让第一次配置无从选起。
func (e webSearchActionEnv) listProviderOptions(_ context.Context, _ pluginmgr.ActionRequest) (any, error) {
return map[string]any{"options": search.ProviderOptions()}, nil
}
// saveProvider 新增 / 修改一条 provider 配置(表单提交,写动作)。
//
// 三态写回(与前端 `schema/values.ts:planSubmit` 逐条同义):
// - apiKey **缺位** ⇒ 保留原值(面板只持有掩码,「留空 = 不改」必须是真的);
// - apiKey **空串** ⇒ 显式清空(前端只有勾了「清除」才会送出空串);
// - baseUrl / enabled 缺位或空串 ⇒ 保留;给出(含 false / 空地址)⇒ 采用。
//
// 同一次提交里还可能带三个**文件级**字段(`primary` / `fallback` / `maxResults`):它们不是
// 这一条 provider 的属性,而是「谁先用 / 失败后按什么顺序降级 / 单次最多几条」,因此合并到
// 文件级而不是条目上(解析与校验在 websearch_selection.go)。缺位 = 不改动;但给出了**不合法**
// 的值(如 `maxResults` ≤0 / 非整数)⇒ 整笔 400,**绝不静默丢弃** —— 静默丢弃会让用户以为
// 上限已经改了,而跑的还是旧的那个(那正是这一片要消灭的那条「我改了它却没变」)。
//
// 落盘之后由组合根的热重载事务接管(`websearch_reload.go`:重建工具 + 换注册表条目,
// 失败则连同本次落盘一起回滚)—— 本函数只负责「把改动存进去」这一件事。
//
// 只接受**已登记**的名字:未登记的家存进去也永远不会被选中(注册表按名字取),
// 那会让「我明明配了 tavily」变成一句假话 —— 所以在这里就拒。
func (e webSearchActionEnv) saveProvider(_ context.Context, req pluginmgr.ActionRequest) (any, error) {
values, err := actionValuesObject(req.Payload)
if err != nil {
return nil, err
}
name, err := requiredProviderName(values)
if err != nil {
return nil, err
}
if err := requireKnownProvider(name); err != nil {
return nil, err
}
cur, err := e.loadFile()
if err != nil {
return nil, err
}
prev, existed := findProviderEntry(cur, name)
entry, err := mergeProviderEntry(prev, values)
if err != nil {
return nil, err
}
entry.Provider = name
next, setPrimary, err := e.mergeSelectionPatch(cur, values)
if err != nil {
return nil, err
}
final := runtimeconfig.UpsertProviderEntry(next, entry)
// 🔴 校验必须在**并条之后**(顺序理由见 websearch_selection.go 的 mergeSelectionPatch):
// 主 provider 配没配齐,取决于**这次刚落进文件的凭据** —— 拿旧文件校验会把
// 「第一次填 key + 同一次提交里选它」整笔回滚,连 key 一起丢。
if setPrimary {
if err := e.checkPrimaryUsable(final, final.Provider); err != nil {
return nil, err
}
}
if err := runtimeconfig.SaveWebSearch(e.path, final); err != nil {
e.log.Error("web-search panel config write failed", "path", e.path, "provider", name, "error", err)
return nil, pluginmgr.NewActionError(actionCodeConfigWriteFailed,
"面板配置写不进去(磁盘或权限问题),本次改动**没有**生效", http.StatusInternalServerError, err)
}
e.log.Info("web-search provider saved from panel",
"path", e.path, "provider", name, "created", !existed, "enabled", entry.IsEnabled())
return map[string]any{
"saved": true,
"provider": name,
"created": !existed,
"enabled": entry.IsEnabled(),
// 文件级取值的**落盘后**样子(没给这几个键时也如实回显当前值):面板按它回显
// 「谁先用」,也让「这次保存有没有动到选择 / 上限」有可机读的答案。
"primary": strings.ToLower(strings.TrimSpace(final.Provider)),
"fallback": selectionNames(final.Fallback),
// maxResults 的 0 = 文件里未声明(由 `.env` / 代码默认兜底),原样回显,不编一个假值。
"maxResults": final.MaxResults,
}, nil
}
// testProvider 对一条配置**真发一次最小检索**(行内动作「测试连通」)。
//
// 凭据与地址都从可变源读(面板里刚存下的就是它),不从前端传上来的行数据读
// —— 行数据里没有 key(列表列刻意不带凭据),也不该由客户端指定要探的目标。
//
// 失败一律回**结构化非 2xx**:面板的行内动作只在失败时显示文案(成功分支只说
// 「已完成」),把「探不通」放进 200 的 data 里等于让界面报一句假成功。
func (e webSearchActionEnv) testProvider(ctx context.Context, req pluginmgr.ActionRequest) (any, error) {
name, err := providerNameOf(req.Payload)
if err != nil {
return nil, err
}
if err := requireKnownProvider(name); err != nil {
return nil, err
}
f, err := e.loadFile()
if err != nil {
return nil, err
}
entry, _ := findProviderEntry(f, name)
base := e.effectiveBaseURL(name, entry)
target, err := newProbeTarget(base)
if err != nil {
// 地址不合法 / 为空 ⇒ 这条没配齐(探针自己也会这么判,但这里能给出更准的话)。
return nil, pluginmgr.NewActionError(search.ProbeNotConfigured,
"这条没配齐:接口地址为空或不是 http(s) 绝对地址", http.StatusBadRequest, err)
}
cfg := search.Config{
APIKey: entry.APIKey,
BaseURL: base,
Client: target.client(e.probeTimeout(f)),
MaxBytes: webSearchProbeMaxBytes,
Guard: target.guard,
}
res := search.Probe(ctx, name, cfg, 0)
if !res.OK {
e.log.Warn("web-search panel probe failed",
"provider", name, "code", res.ErrorCode, "latencyMs", res.LatencyMS)
return nil, pluginmgr.NewActionError(res.ErrorCode, res.Error, probeErrorStatus(res.ErrorCode), nil)
}
e.log.Info("web-search panel probe ok", "provider", name, "count", res.Count, "latencyMs", res.LatencyMS)
return res, nil
}
// reauthProvider 把一条配置**带回表单**(行内动作「重配凭据」,design/09 §5.2.1 示例的
// reauth 语义):返回 `{values:{...}}`,前端把它灌进唯一的 form 区块。
//
// 两处口径:
// - apiKey 回**掩码**(读侧无明文,§5.4.2):前端对 secret 字段一律不回填输入框,
// 只把它算作「盘上已有值」⇒ 留空 = 不改动、要清空必须显式勾选;
// - 其余字段回**盘上那条的原样**,不做默认值回填:回填代码默认地址会让一次
// 「只改 key」的保存把默认地址固化进文件(面板显示的是「该条」,不是「生效值」)。
//
// 文件级字段(`maxResults` 在内)也按同一条口径回填:**盘上有值才回**。上限不回填的话表单
// 里那个框是空的,用户既看不见当前上限、也无从改起;而盘上没写(0 = 未声明)时**不回** 0 ——
// 0 会被前端当成一个值送回来,写侧又拒 ≤0,那会让「只改 key」的保存整笔进不去。
func (e webSearchActionEnv) reauthProvider(_ context.Context, req pluginmgr.ActionRequest) (any, error) {
name, err := providerNameOf(req.Payload)
if err != nil {
return nil, err
}
if err := requireKnownProvider(name); err != nil {
return nil, err
}
f, err := e.loadFile()
if err != nil {
return nil, err
}
entry, exists := findProviderEntry(f, name)
values := map[string]any{
"provider": name,
"baseUrl": entry.BaseURL,
"enabled": !exists || entry.IsEnabled(),
}
// 三个文件级字段一并回填(主 provider / 降级链 / 条数上限):不然用户「清空降级链」在前端
// 的三态写回里会落成缺位(= 不改动),「我清掉了它」就成了一句假话;上限同理 ——
// 不回填就看不见盘上的值,改上限也就无从下手。
addSelectionValues(values, f)
if masked, ok := maskedProviderEntry(f, name); ok && strings.TrimSpace(masked.APIKey) != "" {
values["apiKey"] = masked.APIKey
}
return map[string]any{"values": values}, nil
}
// --- 可变源读写(含失败分类) -------------------------------------------------
// loadFile 读可变源。
//
// 文件**缺失或为空** = 「还没配过」(启动期已由 `.env` 播种;极端情况下面板先于启动期
// 被调用)⇒ 返回空文件,不是错误。
// 文件**存在但不合法** ⇒ 结构化失败:绝不把损坏配置当成空配置 —— 那会让一次保存
// 静默盖掉一份人工写坏但可救回的内容(与启动期的处置同向:回落但不覆盖)。
func (e webSearchActionEnv) loadFile() (runtimeconfig.WebSearchFile, error) {
f, found, err := runtimeconfig.LoadWebSearch(e.path)
if err != nil {
e.log.Error("web-search panel config unreadable", "path", e.path, "error", err)
return runtimeconfig.WebSearchFile{}, pluginmgr.NewActionError(actionCodeConfigUnreadable,
"面板配置读不出来(文件损坏或权限不足),本次**没有**改动它;详见服务端日志",
http.StatusInternalServerError, err)
}
if !found {
return runtimeconfig.WebSearchFile{
Version: runtimeconfig.WebSearchVersion,
Providers: []runtimeconfig.ProviderEntry{},
}, nil
}
return f, nil
}
// providerRows 把各条配置翻译成面板表格行(凭据与地址都不进行数据)。
func providerRows(f runtimeconfig.WebSearchFile) []search.ProviderRow {
inputs := make([]search.ProviderInput, 0, len(f.Providers))
for _, p := range f.Providers {
inputs = append(inputs, search.ProviderInput{
Provider: p.Provider,
APIKey: p.APIKey,
BaseURL: p.BaseURL,
Enabled: p.IsEnabled(),
})
}
return search.BuildRows(inputs)
}
// findProviderEntry 按名字取一条配置(大小写与首尾空白不敏感,与
// UpsertProviderEntry 的匹配键同一口径)。
func findProviderEntry(f runtimeconfig.WebSearchFile, name string) (runtimeconfig.ProviderEntry, bool) {
want := strings.ToLower(strings.TrimSpace(name))
for _, p := range f.Providers {
if strings.EqualFold(strings.TrimSpace(p.Provider), want) {
return p, true
}
}
return runtimeconfig.ProviderEntry{}, false
}
// maskedProviderEntry 取某条配置的**掩码副本**(复用 runtimeconfig 的唯一掩码出口,
// 不在这里另写一份 maskToken)。
func maskedProviderEntry(f runtimeconfig.WebSearchFile, name string) (runtimeconfig.ProviderEntry, bool) {
return findProviderEntry(runtimeconfig.MaskedWebSearchFile(f), name)
}
// effectiveBaseURL 给出一条配置**实际会用的**基地址:盘上有就用盘上的,没有就回落到
// **该接入位的代码默认基地址**(`plugins/tools/io/search` 的接入位表,唯一出处)。
//
// 进位链与装配期**同一个函数**(resolveBaseURL):searxng 的默认基地址是空 —— 它的地址
// 为空本身就是「关掉这家」,回落会让「清空地址」变成假话。
func (e webSearchActionEnv) effectiveBaseURL(name string, entry runtimeconfig.ProviderEntry) string {
return resolveBaseURL(name, entry.BaseURL)
}
// probeTimeout 给出一次探针的超时上限:面板文件的 timeout 优先(面板里刚存的就是它),
// 其次 `.env`/代码默认值,最后是包内兜底 —— 与工具运行时的裁决顺序一致。
func (e webSearchActionEnv) probeTimeout(f runtimeconfig.WebSearchFile) time.Duration {
if d, err := f.TimeoutDuration(); err == nil && d > 0 {
return d
}
if e.envCfg.Timeout > 0 {
return e.envCfg.Timeout
}
return webSearchProbeTimeout
}
// --- 载荷解析(形状不对就报错,不静默当空) ------------------------------------
// actionObject 取请求体的 JSON 对象。空体(nil)返回 (nil, nil):少数动作不需要参数。
func actionObject(payload any) (map[string]any, error) {
if payload == nil {
return nil, nil
}
obj, ok := payload.(map[string]any)
if !ok {
return nil, invalidPayloadActionError("请求体必须是一个 JSON 对象")
}
return obj, nil
}
// actionValuesObject 取 `{values:{...}}`(表单提交的形状,见前端 formState.useFormSubmit)。
func actionValuesObject(payload any) (map[string]any, error) {
obj, err := actionObject(payload)
if err != nil {
return nil, err
}
if obj == nil {
return nil, invalidPayloadActionError("缺少 values 对象(表单提交的形状是 {values:{...}})")
}
values, ok := obj["values"].(map[string]any)
if !ok {
return nil, invalidPayloadActionError("缺少 values 对象(表单提交的形状是 {values:{...}})")
}
return values, nil
}
// providerNameOf 从载荷里取 provider 名(三种**显式**来源,不猜字段):
// 行内动作给 `{row:{provider}}`(前端 tableState),也可给 `{values:{provider}}`
// (与表单同一形状),或顶层直接给 `provider`。
func providerNameOf(payload any) (string, error) {
obj, err := actionObject(payload)
if err != nil {
return "", err
}
if obj == nil {
return "", invalidPayloadActionError("缺少 provider 名(行内动作给 row.provider)")
}
for _, key := range []string{"row", "values", "provider"} {
switch v := obj[key].(type) {
case string:
if key == "provider" && strings.TrimSpace(v) != "" {
return normalizeProviderName(v), nil
}
case map[string]any:
if name := actionString(v, "provider"); name != "" {
return normalizeProviderName(name), nil
}
}
}
return "", invalidPayloadActionError("缺少 provider 名(行内动作给 row.provider,表单给 values.provider)")
}
// requiredProviderName 取表单里的 provider 名(必填)。
func requiredProviderName(values map[string]any) (string, error) {
name := actionString(values, "provider")
if name == "" {
return "", invalidPayloadActionError("provider 必填:请选择 / 填写要配置的提供商名")
}
return normalizeProviderName(name), nil
}
// requireKnownProvider 拒掉本期没登记的名字(存了也永远不会被选中;探它只会得到
// 「未登记」)。错误码与文案与 search 的探针口径一致,前端可按同一个码本地化。
func requireKnownProvider(name string) error {
if search.Known(name) {
return nil
}
return pluginmgr.NewActionError(actionCodeUnknownProvider,
fmt.Sprintf("未登记的搜索提供商 %q(本期接入位:%s)", name, strings.Join(search.KnownNames(), ", ")),
http.StatusBadRequest, nil)
}
// actionString 取一个字符串字段(去首尾空白;非字符串与空串都算「没给」)。
func actionString(m map[string]any, key string) string {
s, ok := m[key].(string)
if !ok {
return ""
}
return strings.TrimSpace(s)
}
// normalizeProviderName 归一 provider 名(小写 + 去空白),与注册表/可变源的匹配键同口径。
func normalizeProviderName(name string) string {
return strings.ToLower(strings.TrimSpace(name))
}
// mergeProviderEntry 把表单补丁合并进一条配置(三态写回,见 saveProvider 的注释)。
// enabled 给了非布尔值时**显式报错**:静默保留会让「我关了它」变成假话。
func mergeProviderEntry(prev runtimeconfig.ProviderEntry, values map[string]any) (runtimeconfig.ProviderEntry, error) {
out := prev
if v, ok := values["apiKey"]; ok {
key, err := actionOptionalString(v, "apiKey")
if err != nil {
return runtimeconfig.ProviderEntry{}, err
}
out.APIKey = key
}
if v, ok := values["baseUrl"]; ok {
base, err := actionOptionalString(v, "baseUrl")
if err != nil {
return runtimeconfig.ProviderEntry{}, err
}
out.BaseURL = base
}
on, set, err := actionOptionalBool(values["enabled"], "enabled")
if err != nil {
return runtimeconfig.ProviderEntry{}, err
}
if set {
out.Enabled = runtimeconfig.EnableFlag(on)
}
return out, nil
}
// actionOptionalString 校验一个可选字符串字段:缺位 ⇒ 空串(调用方按「未给」处理);
// 非字符串 ⇒ 报错(把类型错静默当成空串 = 一次保存悄悄清空了某个字段)。
func actionOptionalString(v any, key string) (string, error) {
if v == nil {
return "", nil
}
s, ok := v.(string)
if !ok {
return "", invalidPayloadActionError(fmt.Sprintf("%s 必须是字符串", key))
}
return strings.TrimSpace(s), nil
}
// actionOptionalBool 解析可选开关:缺位 / nil / 空串 ⇒ 未改动;bool ⇒ 采用;
// 其它类型 ⇒ 报错(理由同 actionOptionalString)。
func actionOptionalBool(v any, key string) (value bool, set bool, err error) {
switch raw := v.(type) {
case nil:
return false, false, nil
case bool:
return raw, true, nil
case string:
if strings.TrimSpace(raw) == "" {
return false, false, nil
}
return false, false, invalidPayloadActionError(fmt.Sprintf("%s 必须是布尔值", key))
default:
return false, false, invalidPayloadActionError(fmt.Sprintf("%s 必须是布尔值", key))
}
}
// invalidPayloadActionError 造一个 400 invalid_payload(形状错 = 调用方错,不是上游错)。
func invalidPayloadActionError(message string) error {
return pluginmgr.NewActionError(pluginmgr.ActionCodeInvalidPayload, message, http.StatusBadRequest, nil)
}
@@ -0,0 +1,88 @@
package main
// 本文件钉住搜索提供商动作面的**危险/边界结局**(切片 L1/PLG-2):损坏配置不许被覆盖、
// 装配期要如实点名「声明了却没实现」的动作。它们与主用例集分开成文件的理由只有一条:
// `wire_plugin_actions_test.go` 已接近「文件 ≤500 行」的上限(AGENTS §10)。
import (
"bytes"
"context"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
)
// TestWebSearchActionConfigUnreadableIsReported 锁定「文件存在但不合法」的口径:
// 结构化失败(500 config_unreadable),**不覆盖**原文件 —— 损坏的配置可以人工救回。
func TestWebSearchActionConfigUnreadableIsReported(t *testing.T) {
env := newActionTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
const broken = `{"version":1,"providers":[{"provider":"bocha"},{"provider":"BOCHA"}]}`
wsWriteRawPanel(t, env.path, broken)
_, err := env.dispatch(t, search.ActionListProviders, map[string]any{})
ae, ok := pluginmgr.AsActionError(err)
if !ok || ae.Code != actionCodeConfigUnreadable || ae.Status != http.StatusInternalServerError {
t.Fatalf("err = %#v, want 500 config_unreadable", err)
}
if _, err := env.dispatch(t, search.ActionSaveProvider, map[string]any{
"values": map[string]any{"provider": search.NameBocha, "apiKey": "sk-x"},
}); err == nil {
t.Fatal("配置不合法时必须拒绝保存(否则会静默盖掉可救回的原文件)")
}
raw, rerr := os.ReadFile(env.path)
if rerr != nil {
t.Fatalf("read: %v", rerr)
}
if string(raw) != broken {
t.Fatalf("原文件被改动了:%q", raw)
}
}
// TestWarnImpliedButMissingNamesGaps 锁定装配期的**核对**:声明里有、登记里没有的动作
// 必须在日志里点名(否则用户只会看到一个点了回 501 的按钮)。这里直接覆盖核对函数本身。
func TestWarnImpliedButMissingNamesGaps(t *testing.T) {
cfg := wsLoadConfig(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
b := bus.New()
defer b.Close()
mgr := pluginmgr.NewManager(registry.New(), filepath.Join(cfg.DataDir, "plugins.json"), testLog())
defer mgr.Close(context.Background())
var buf bytes.Buffer
log := logger.New(logger.Options{Out: &buf, Level: "debug"})
wirePanelHub(&app{cfg: cfg, log: log, bus: b, plugins: mgr})
hub := mgr.Panels()
// 模拟「插件声明新增了一个动作,但宿主忘了给它接线」:补一条声明(Declare 是整体
// 替换语义 ⇒ 原有四个动作 + 候选来源仍在),只多一个没人实现的名字。
extra := sdk.Panel{
ID: "extra", Title: "额外页",
Sections: []sdk.Section{{
Type: sdk.SectionTable, ID: "t", Title: "额外表",
Source: &sdk.Source{Action: "plugin.notWired"},
Columns: []sdk.Column{{Key: "k", Label: "K"}},
}},
}
if _, err := hub.Declare(search.PanelPluginID, append(search.AdminPanels(), extra)); err != nil {
t.Fatalf("Declare: %v", err)
}
warnImpliedButMissing(hub, log)
out := buf.String()
if !strings.Contains(out, "plugin.notWired") || !strings.Contains(out, "no handler") {
t.Fatalf("声明了却没实现的动作必须被点名(否则只剩一个回 501 的按钮): %q", out)
}
// 已登记的动作不得被误报。
if idx := strings.Index(out, "action=plugin.listProviders"); idx >= 0 &&
strings.Contains(out[idx:], "no handler") {
t.Fatalf("已登记的动作被误报成缺处理器: %q", out)
}
}
+286
View File
@@ -0,0 +1,286 @@
package main
// 本文件钉住「面板文件的 `maxResults` 就是运行期的条数上限」这条路**通了**
// (主人 2026-10-07 裁定;口径见 design/27 §3 与 `cmd/cyrene/wire_io_tools.go` 的
// `effectiveSearchConfig` —— 面板文件是可变源,`.env` 只播种)。
//
// 上半部分直接写盘(测投影:文件里的 `maxResults` 就是真上限);下半部分走**面板动作**
// (`plugin.saveProvider` 带上 `maxResults` ⇒ 热重载 ⇒ 下一次搜索真的最多那么多条),
// 并钉住三态:缺位 = 不改动、正整数 = 采用、其余 = 400 invalid_payload(绝不静默丢弃)。
//
// 夹具一律写进**本用例自己的** `$DATA_DIR`(`wsLoadConfig` 的 `t.TempDir()`):
// 生产文件 `data/config/plugins/web-search.json` 不在测试的触碰范围内。
import (
"bytes"
"context"
"fmt"
"net/http"
"strings"
"testing"
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
)
// wsManyResultsBody 造一份 searxng 形状、n 条结果的假上游应答。
// link 逐条不同:`search.Finalize` 按 link 去重,重复的 link 会让「截断到 N」看不出效果。
func wsManyResultsBody(n int) string {
items := make([]string, 0, n)
for i := 0; i < n; i++ {
items = append(items, fmt.Sprintf(
`{"title":"T%d","url":"https://r%d.example/%d","content":"c%d"}`, i, i, i, i))
}
return `{"results":[` + strings.Join(items, ",") + `]}`
}
// wsPanelWithMaxResults 把一份夹具面板配置写进 cfg 的数据目录(maxResults 给 0 = 未声明)。
func wsPanelWithMaxResults(t *testing.T, cfg *config.Config, maxResults int, upstream string) {
t.Helper()
if err := runtimeconfig.SaveWebSearch(wsPanelPath(cfg), runtimeconfig.WebSearchFile{
Version: runtimeconfig.WebSearchVersion,
Provider: search.NameSearXNG,
Fallback: []string{},
MaxResults: maxResults,
Providers: []runtimeconfig.ProviderEntry{
{Provider: search.NameSearXNG, BaseURL: upstream, Enabled: runtimeconfig.EnableFlag(true)},
},
}); err != nil {
t.Fatalf("写面板配置: %v", err)
}
}
// wsSearchCount 走注册面里那把 web-search(组合根装配出来的那一把)打一次,返回条数。
func wsSearchCount(t *testing.T, a *app, args map[string]any) int {
t.Helper()
got, err := a.dispatcher.Invoke(context.Background(), webSearchToolName, args)
if err != nil {
t.Fatalf("Invoke: %v", err)
}
m, ok := got.(map[string]any)
if !ok {
t.Fatalf("工具返回值类型 = %T", got)
}
n, ok := m["count"].(int)
if !ok {
t.Fatalf("count 不是 int: %#v", m["count"])
}
return n
}
// TestWebSearchPanelMaxResultsIsTheRuntimeLimit 是这条路的主判据:`.env` 写 5、
// 面板文件写 20 ⇒ 生效值必须是 **20**(面板优先),且它仍是**真上限**
// (模型要 50 也只给 20、要 3 就给 3)。
func TestWebSearchPanelMaxResultsIsTheRuntimeLimit(t *testing.T) {
t.Parallel()
up := newWSFakeUpstream(t, http.StatusOK, wsManyResultsBody(30))
cfg := wsLoadConfig(t,
"WEB_SEARCH_PROVIDER="+search.NameSearXNG,
"WEB_SEARCH_MAX_RESULTS=5",
)
wsPanelWithMaxResults(t, cfg, 20, up.url)
a := wsSurfaceApp(t, cfg, nil)
if got := wsSearchCount(t, a, map[string]any{"query": "cyrene", "count": 50}); got != 20 {
t.Fatalf("count = %d, want 20(模型要 50,上限是面板文件里的 20)", got)
}
if got := wsSearchCount(t, a, map[string]any{"query": "cyrene", "count": 3}); got != 3 {
t.Fatalf("count = %d, want 3(模型要得更少时尊重模型)", got)
}
if got := wsSearchCount(t, a, map[string]any{"query": "cyrene"}); got != 20 {
t.Fatalf("count = %d, want 20(模型没给 count ⇒ 用面板文件的上限)", got)
}
}
// TestWebSearchPanelMaxResultsUnsetFallsBackToEnv 钉住投影的另一半:
// 面板文件里 `maxResults` **缺位**(0 = 未声明)⇒ 沿用 `.env` 那条既有键的值。
func TestWebSearchPanelMaxResultsUnsetFallsBackToEnv(t *testing.T) {
t.Parallel()
up := newWSFakeUpstream(t, http.StatusOK, wsManyResultsBody(30))
cfg := wsLoadConfig(t,
"WEB_SEARCH_PROVIDER="+search.NameSearXNG,
"WEB_SEARCH_MAX_RESULTS=7",
)
wsPanelWithMaxResults(t, cfg, 0, up.url)
a := wsSurfaceApp(t, cfg, nil)
if got := wsSearchCount(t, a, map[string]any{"query": "cyrene", "count": 50}); got != 7 {
t.Fatalf("count = %d, want 7(面板 maxResults 未声明 ⇒ 回落 .env 的 7)", got)
}
}
// TestWebSearchDefaultsToTenWithoutPanelOrEnvGivesTen 钉住代码默认值这一档:
// `.env` 与面板文件都没给条数 ⇒ io 包的共享上限(= 10),且它同样是真上限。
func TestWebSearchDefaultsToTenWithoutPanelOrEnvGivesTen(t *testing.T) {
t.Parallel()
up := newWSFakeUpstream(t, http.StatusOK, wsManyResultsBody(30))
cfg := wsLoadConfig(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
wsPanelWithMaxResults(t, cfg, 0, up.url) // 0 = 未声明;.env 也没给
a := wsSurfaceApp(t, cfg, nil)
if got := wsSearchCount(t, a, map[string]any{"query": "cyrene", "count": 50}); got != 10 {
t.Fatalf("count = %d, want 10(代码默认值)", got)
}
}
// --- 面板动作这一侧:存得进去、真的生效、非法值如实报错 -------------------------
// wsMaxResultsEnv 是一份「`.env` 给 envMaxResults、假上游答 30 条」的面板动作夹具
// (热重载面 + 分派器都在:`newReloadTestEnv`)。`.env` 那个值是留给「面板压过 `.env`」
// 这条判据当对照用的。
func wsMaxResultsEnv(t *testing.T, envMaxResults int) (*reloadTestEnv, *wsFakeUpstream) {
t.Helper()
up := newWSFakeUpstream(t, http.StatusOK, wsManyResultsBody(30))
env := newReloadTestEnv(t,
"WEB_SEARCH_PROVIDER="+search.NameSearXNG,
"WEB_SEARCH_ENDPOINT="+up.url,
fmt.Sprintf("WEB_SEARCH_MAX_RESULTS=%d", envMaxResults),
)
return env, up
}
// TestWebSearchPanelSaveAppliesMaxResults 是本片的主判据(端到端):面板提交里带上
// `maxResults: 20` ⇒ 落盘 20 ⇒ **热重载**后下一次搜索真的最多 20 条 —— `.env` 的 5 不作数,
// 也没有重启后端。
func TestWebSearchPanelSaveAppliesMaxResults(t *testing.T) {
env, up := wsMaxResultsEnv(t, 5)
outcome := env.mustSave(t, map[string]any{
"provider": search.NameSearXNG, "baseUrl": up.url, "enabled": true,
"maxResults": 20,
})
if !outcome.Applied || !outcome.Registered {
t.Fatalf("hotReload = %+v, want applied 且 registered", outcome)
}
if got := env.loadFile(t).MaxResults; got != 20 {
t.Fatalf("盘上 maxResults = %d, want 20(面板给的值必须真的落盘)", got)
}
if got := wsSearchCount(t, env.app, map[string]any{"query": "cyrene", "count": 50}); got != 20 {
t.Fatalf("count = %d, want 20(模型要 50,上限是面板刚存下的 20 —— 且没重启)", got)
}
if got := wsSearchCount(t, env.app, map[string]any{"query": "cyrene", "count": 3}); got != 3 {
t.Fatalf("count = %d, want 3(模型要得更少时尊重模型)", got)
}
}
// TestWebSearchPanelSaveWithoutMaxResultsKeepsStoredValue 钉住三态的第一态:提交里**缺位**
// ⇒ 盘上那个上限一个字节都不动(「我没改它」必须真的是没改),运行中的生效值也照旧。
//
// 前置的 20 是**直接写盘**的(不经过写侧):这条判据只关心「缺位的那次保存有没有动它」,
// 不该依赖另一条用例的能力。
func TestWebSearchPanelSaveWithoutMaxResultsKeepsStoredValue(t *testing.T) {
env, up := wsMaxResultsEnv(t, 5)
if err := runtimeconfig.SaveWebSearch(env.path, runtimeconfig.WebSearchFile{
Version: runtimeconfig.WebSearchVersion,
Provider: search.NameSearXNG,
MaxResults: 20,
Providers: []runtimeconfig.ProviderEntry{
{Provider: search.NameSearXNG, BaseURL: up.url, Enabled: runtimeconfig.EnableFlag(true)},
},
}); err != nil {
t.Fatalf("写面板配置: %v", err)
}
env.mustSave(t, map[string]any{
"provider": search.NameSearXNG, "baseUrl": up.url, "enabled": true,
})
if got := env.loadFile(t).MaxResults; got != 20 {
t.Fatalf("缺位的一次保存把上限改成了 %d, want 20(缺位 = 不改动)", got)
}
if got := wsSearchCount(t, env.app, map[string]any{"query": "cyrene", "count": 50}); got != 20 {
t.Fatalf("count = %d, want 20(上限没被动过,热重载后的生效值也该照旧)", got)
}
}
// TestWebSearchPanelSaveRejectsInvalidMaxResults 钉住三态的第三态:给了值却用不了 ⇒
// **400 invalid_payload**,盘上一个字节都不动 —— 绝不静默忽略。静默忽略正是这一片要消灭的
// 那条「我改了它却没变」(旧行为就是:手塞进去的值被写侧无声丢掉,文件不变、也不报错)。
func TestWebSearchPanelSaveRejectsInvalidMaxResults(t *testing.T) {
cases := []struct {
name string
value any
}{
{"零", 0},
{"负数", -3},
{"小数", 2.5},
{"空串(前端清空输入框送的就是它)", ""},
{"非数字字符串", "twenty"},
{"布尔", true},
{"数组", []any{20}},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
env, up := wsMaxResultsEnv(t, 5)
before := env.rawFile(t)
_, err := env.save(map[string]any{
"provider": search.NameSearXNG, "baseUrl": up.url, "enabled": true,
"maxResults": tc.value,
})
ae, ok := pluginmgr.AsActionError(err)
if !ok {
t.Fatalf("err = %v, want 结构化 ActionError(非法上限绝不能被静默忽略)", err)
}
if ae.Code != pluginmgr.ActionCodeInvalidPayload || ae.Status != http.StatusBadRequest {
t.Fatalf("失败 = %d/%s, want 400/%s", ae.Status, ae.Code, pluginmgr.ActionCodeInvalidPayload)
}
if got := env.rawFile(t); !bytes.Equal(got, before) {
t.Fatalf("被拒的保存动了盘上的文件:\nbefore=%s\nafter =%s", before, got)
}
})
}
}
// TestWebSearchReauthEchoesMaxResults:回填必须带上盘上的上限 —— 不回填的话表单里那个框是空的,
// 用户既看不见当前上限,也无从改起。
func TestWebSearchReauthEchoesMaxResults(t *testing.T) {
env, up := wsMaxResultsEnv(t, 5)
env.mustSave(t, map[string]any{
"provider": search.NameSearXNG, "baseUrl": up.url, "enabled": true, "maxResults": 20,
})
values := wsReauthValues(t, env, search.NameSearXNG)
if got := values[search.FieldMaxResults]; got != 20 {
t.Fatalf("回填的 %s = %#v, want 20", search.FieldMaxResults, got)
}
}
// TestWebSearchReauthOmitsUnsetMaxResults:盘上**没给**上限(`maxResults` 缺位 ⇒ Go 侧 0 =
// 未声明,由 `.env` 兜底)时不得回填一个 0 —— 0 会被前端当成一个值送回来,而写侧拒 ≤0,
// 那会让「只改 key」的一次保存整笔进不去。
func TestWebSearchReauthOmitsUnsetMaxResults(t *testing.T) {
env, up := wsMaxResultsEnv(t, 5)
if err := runtimeconfig.SaveWebSearch(env.path, runtimeconfig.WebSearchFile{
Version: runtimeconfig.WebSearchVersion,
Provider: search.NameSearXNG,
// 刻意不写 maxResults(= 未声明)。
Providers: []runtimeconfig.ProviderEntry{
{Provider: search.NameSearXNG, BaseURL: up.url, Enabled: runtimeconfig.EnableFlag(true)},
},
}); err != nil {
t.Fatalf("写面板配置: %v", err)
}
if got := env.loadFile(t).MaxResults; got != 0 {
t.Fatalf("前置不成立:盘上 maxResults = %d, want 0(未声明)", got)
}
values := wsReauthValues(t, env, search.NameSearXNG)
if raw, ok := values[search.FieldMaxResults]; ok {
t.Fatalf("未声明时回填了 %#v(前端会把它当值送回,写侧又拒 ≤0)", raw)
}
}
// wsReauthValues 走 reauth 动作,取出回填的 values。
func wsReauthValues(t *testing.T, env *reloadTestEnv, provider string) map[string]any {
t.Helper()
out, err := env.hub.Dispatch(context.Background(), pluginmgr.ActionRequest{
PluginID: search.PanelPluginID, Action: search.ActionReauthProvider,
Payload: map[string]any{"row": map[string]any{"provider": provider}},
})
if err != nil {
t.Fatalf("reauth: %v", err)
}
values, _ := out.(map[string]any)["values"].(map[string]any)
if values == nil {
t.Fatalf("reauth 结果缺 values: %#v", out)
}
return values
}
@@ -0,0 +1,91 @@
package main
// 本文件钉住本片新增的两家(`tencent_wsa` / `metaso`)在**组合根里真的接通了**:
// 它们**没有 `.env` 字段**(`.env` 已冻结),因此凭据与地址只能来自面板文件
// `$DATA_DIR/config/plugins/web-search.json`,而组合根**零改动**地按接入位表把它们投影出来
// (`resolveSearchProviders` 逐家走 `search.Descriptors()`)。
//
// 为什么值得端到端一条:`cmd/cyrene/websearch_providers.go` 里**一行都没改**——这正是
// 「按接入位表投影」的设计意图;没有这条用例,「零改动」就只是一句没有证据的话。
//
// 夹具(假上游、`wsSurfaceApp`、`wsExecuteSearch`)在 websearch_testenv_test.go /
// websearch_projection_test.go;`.env` 字段表的完整性由 wire_web_search_*_test.go 钉住。
import (
"net/http"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
)
// TestWebSearchPanelOnlyProvidersWiredEndToEnd 是判据:面板文件里配好一家**没有 env 字段**
// 的家 ⇒ 工具进注册面 ⇒ 真的把请求打到面板里那个地址、带上面板里那把 key,且结果里如实
// 标注 provider。
func TestWebSearchPanelOnlyProvidersWiredEndToEnd(t *testing.T) {
t.Parallel()
cases := []struct {
name string
path string
fixture string
}{
{
name: search.NameTencentWSA,
path: "/SearchPro",
// Pages 的元素是 JSON **字符串**(官方 Array of String 形状)。
fixture: `{"Response":{"Pages":["{\"title\":\"元宝结果\",\"url\":\"https://t.example/1\",` +
`\"passage\":\"元宝摘要\"}"],"Query":"昔涟","RequestId":"r"}}`,
},
{
name: search.NameMetaso,
path: "/api/v1/search",
fixture: `{"webpages":[{"title":"秘塔结果","link":"https://m.example/1","snippet":"秘塔摘要"}]}`,
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
const key = "test-panel-only-key"
up := newWSFakeUpstream(t, http.StatusOK, tc.fixture)
// `.env` 只给「谁当主家」:这两家在 `.env` 里没有字段,凭据与地址只能来自面板文件。
cfg := wsLoadConfig(t, "WEB_SEARCH_PROVIDER="+tc.name)
// 前提:它确实不在 `.env` 字段表里(否则这条用例会退化成既有 6 家的回归)。
for _, p := range searchProviderVector(cfg.WebSearch) {
if strings.EqualFold(p.Name, tc.name) {
t.Fatalf("前提被破坏:%s 不该出现在 .env 的字段表里", tc.name)
}
}
// 前提:接入位表里确有它(否则组合根根本投影不出来)。
if !search.Known(tc.name) {
t.Fatalf("前提被破坏:%s 应已在接入位表里登记", tc.name)
}
wsSavePanel(t, cfg, runtimeconfig.WebSearchFile{
Provider: tc.name,
Providers: []runtimeconfig.ProviderEntry{
{Provider: tc.name, APIKey: key, BaseURL: up.url, Enabled: runtimeconfig.EnableFlag(true)},
},
})
a := wsSurfaceApp(t, cfg, nil)
if !wsHasTool(a, webSearchToolName) {
t.Fatalf("面板文件里配好的 %s 应让工具进注册面,实际工具面 = %v", tc.name, toolNames(a))
}
if got := wsExecuteSearch(t, a, "昔涟"); got["provider"] != tc.name {
t.Fatalf("provider = %v, want %q(如实标注实际应答的家)", got["provider"], tc.name)
}
select {
case r := <-up.hits:
if r.URL.Path != tc.path {
t.Errorf("请求路径 = %q, want %q(各家固定路径)", r.URL.Path, tc.path)
}
if got := r.Header.Get("Authorization"); got != "Bearer "+key {
t.Errorf("Authorization = %q,want 面板文件里那把 key(凭据必须以文件为准)", got)
}
default:
t.Fatalf("%s 一个请求都没收到 —— 面板里的配置没接到 provider 上", tc.name)
}
})
}
}
+117
View File
@@ -0,0 +1,117 @@
package main
// 本文件是「搜索提供商」页**探针**的传输层绑定(切片 L1/PLG-2 的执行面接线的一部分):
// 探针要用的 HTTP 客户端、目标约束(同主机守卫)与失败状态映射。
// 动作本体的语义在 `websearch_action_data.go`,登记面在 `wire_plugin_actions.go`。
import (
"context"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
)
// 探针的边界值。名字化的理由与 io 包同一套:不许裸魔法数字(项目规范 §10)。
const (
// webSearchProbeTimeout 是探针的兜底超时(面板文件与 `.env` 都没声明时用它)——
// 取 io 工具集的默认 http 超时同值(15s),免得「面板测通了、工具超时」这种怪象。
webSearchProbeTimeout = 15 * time.Second
// webSearchProbeMaxBytes 是探针响应体的读取上限(与 io 包的 http 响应上限同口径 1 MiB)。
webSearchProbeMaxBytes = 1 << 20
// webSearchProbeMaxRedirects 是探针允许的跳转次数(与 io 的 maxRedirects 同值)。
webSearchProbeMaxRedirects = 3
)
// probeErrorStatus 按探针码给出 HTTP 状态:调用方/配置问题 ⇒ 400;上游或网络 ⇒ 502。
//
// 它**不**改变探针的语义(no_results 仍然是 no_results,不是「成功」也不是「该家不可用」),
// 只是给前端一个非 2xx 的信号,好让面板把结论显示出来(见 testProvider 的注释)。
func probeErrorStatus(code string) int {
switch code {
case search.ProbeUnknownProvider, search.ProbeNotConfigured:
return http.StatusBadRequest
default:
return http.StatusBadGateway
}
}
// probeTarget 是一次探针的目标约束:**只允许打到配置里那台主机**。
//
// 与 io 包 SSRF 守卫的关系(写清楚,免得被当成第二份安全策略):地址来自面板里存下的
// provider 基地址 —— 它是**运维输入**(owner 通过 owner/admin 写动作落下的),不是模型
// 输入。io 的守卫用「私网/回环黑名单 + 允许清单」,而那个守卫在 `plugins/tools/io`
// 内部未导出,本片**不改他人文件**(硬边界),因此这里用一条**更严**的规则替代:
// 探针只与配置主机通话,跨主机(含跨主机重定向)一律拒绝。
// 缺口如实登记:两处策略不逐字同源(见报告 §未做)。
type probeTarget struct {
// host 是配置主机名(小写、无端口)。
host string
}
// newProbeTarget 解析出配置主机;地址为空 / 非 http(s) / 没有主机名时返回错误。
func newProbeTarget(baseURL string) (probeTarget, error) {
raw := strings.TrimSpace(baseURL)
if raw == "" {
return probeTarget{}, fmt.Errorf("web-search probe: empty base url")
}
u, err := url.Parse(raw)
if err != nil {
return probeTarget{}, fmt.Errorf("web-search probe: parse base url: %w", err)
}
if !isHTTPURL(u) {
return probeTarget{}, fmt.Errorf("web-search probe: unsupported scheme %q", u.Scheme)
}
host := strings.ToLower(u.Hostname())
if host == "" {
return probeTarget{}, fmt.Errorf("web-search probe: base url has no host")
}
return probeTarget{host: host}, nil
}
// client 造这次探针要用的 HTTP 客户端:超时上限 + 同主机的跳转策略。
// 它是**每次调用新建**的(探针很少发生,不值得为它维护一份共享客户端与连接池);
// 也因此不牵动 http / web-fetch / web-search 共用的那份客户端与白名单。
func (t probeTarget) client(timeout time.Duration) *http.Client {
return &http.Client{Timeout: timeout, CheckRedirect: t.checkRedirect}
}
// guard 是注入给 search.Config 的地址判定(发请求前对最终地址做一次判定)。
func (t probeTarget) guard(_ context.Context, rawURL string) error { return t.check(rawURL) }
// checkRedirect 是跳转策略:次数上限 + 每一跳都要过同一条判定。
func (t probeTarget) checkRedirect(req *http.Request, via []*http.Request) error {
if len(via) >= webSearchProbeMaxRedirects {
return fmt.Errorf("web-search probe: too many redirects")
}
if req.URL == nil {
return fmt.Errorf("web-search probe: nil redirect target")
}
return t.check(req.URL.String())
}
// check 判定一个地址能否访问:必须是 http(s) 且主机等于配置主机。
func (t probeTarget) check(rawURL string) error {
u, err := url.Parse(rawURL)
if err != nil {
return fmt.Errorf("web-search probe: parse target: %w", err)
}
if !isHTTPURL(u) {
return fmt.Errorf("web-search probe: unsupported scheme %q", u.Scheme)
}
if !strings.EqualFold(u.Hostname(), t.host) {
return fmt.Errorf("web-search probe: refused cross-host target %q", u.Hostname())
}
return nil
}
// isHTTPURL 报告地址是否是可访问的 http(s) 绝对地址。
func isHTTPURL(u *url.URL) bool {
if u == nil {
return false
}
return u.Scheme == "http" || u.Scheme == "https"
}
+436
View File
@@ -0,0 +1,436 @@
package main
// 本文件钉住 web-search「各家 provider 配置投影」的新形状(切片 WS-P2 的「去 env 化」)。
//
// 为什么要有这一片:旧形状是**一家一个 `.env` 字段**(`config.WebSearchConfig` 的
// `ZhipuAPIKey` / `BochaAPIKey` / …)+ 组合根里一个逐家写死的 switch。那个形状的后果是
// 「加一家 provider 就必须先加一个 env 键」—— 直接破主人红线(`.env` 已冻结)。
// 新形状把名字表收回到插件自己的接入位表(`plugins/tools/io/search`),组合根只按名字
// 从**面板文件**里取值;`.env` 退居**播种层**(只在文件缺失/为空时落盘一次)。
//
// 四条判据(任务书 ①②③④)与一条边界(文件权威 vs `.env` 字段表)都在这里:
// ① 文件里有 zhipu 的 apiKey ⇒ 该家进注册表;
// ② 文件里没有 zhipu、但 `.env` 有(播种层)⇒ 与改动前逐字一致;
// ③ **没有 env 字段的家**在文件里有 key ⇒ 也能生效(本片的核心判据,用假接入位驱动);
// ④ 文件里的 baseUrl 缺位 ⇒ 回落该接入位的代码默认基地址。
//
// 夹具(假上游、日志字段读取、`wsSurfaceApp`)在 wire_web_search_test.go 与
// websearch_testenv_test.go;`.env` 与面板文件的既有语义在各 wire_web_search_*_test.go。
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
)
// wsFakeNewProviderDef 造一个**测试专用的假接入位**:模拟「下一片要接的新家」。
//
// 它在 search 包里**没有**登记(`search.Known` 为 false),也没有对应的 `.env` 字段 ——
// 于是它正好是判据 ③ 的载体:「没有 env 字段的家,只认文件也能被投影出生效配置」。
//
// ⚠️ 名字取 `fake_next_provider` 而不是某个真接入位(早先这里写的是 `tencent_wsa`):
// `tencent_wsa` 已经**真的登记**(本片新增),沿用旧名会让这条用例的前提断言直接失败 ——
// 假接入位必须是一个**永远不会被登记**的名字,用例才钉得住「没 env 字段的家」这件事。
func wsFakeNewProviderDef() search.Descriptor {
return search.Descriptor{
Name: "fake_next_provider",
NeedKey: true,
DefaultBaseURL: "https://fake-next.example",
}
}
// wsSavePanel 写一份面板文件(临时 DataDir 里的真实落点,与面板保存走同一个写口)。
func wsSavePanel(t *testing.T, cfg *config.Config, f runtimeconfig.WebSearchFile) {
t.Helper()
if f.Version == 0 {
f.Version = runtimeconfig.WebSearchVersion
}
if err := runtimeconfig.SaveWebSearch(wsPanelPath(cfg), f); err != nil {
t.Fatalf("写面板配置: %v", err)
}
}
// wsEffectiveProviders 走组合根的**同一条解析路**(effectiveWebSearch),返回生效配置与来源标签。
func wsEffectiveProviders(t *testing.T, cfg *config.Config) (effectiveWebSearchConfig, string) {
t.Helper()
a := &app{cfg: cfg, log: testLog()}
return a.effectiveWebSearch(a.log)
}
// wsFindWire 在投影里按名字取一家(名字大小写与空白不敏感,与注册表同口径)。
func wsFindWire(providers []searchProviderWire, name string) (searchProviderWire, bool) {
for _, p := range providers {
if strings.EqualFold(p.Name, name) {
return p, true
}
}
return searchProviderWire{}, false
}
// wsZhipuSearchBody 造一份智谱官方形状的应答(`search_result[]`,见 search 包的 zhipuSpec)。
func wsZhipuSearchBody(title string) string {
return `{"search_result":[{"title":"` + title + `","link":"https://z.example/1","content":"` +
title + ` 正文","publish_date":"2025-05-23"}]}`
}
// wsExecuteSearch 从注册面取出 web-search 真打一次(返回线格式结果)。
func wsExecuteSearch(t *testing.T, a *app, query string) map[string]any {
t.Helper()
tl, err := a.caps.Get(webSearchToolName)
if err != nil {
t.Fatalf("web-search 不在注册面里: %v(工具面 = %v)", err, toolNames(a))
}
exec, ok := tl.Value.(interface {
Execute(context.Context, any) (any, error)
})
if !ok {
t.Fatalf("注册表里的值类型 = %T, 不是可执行工具", tl.Value)
}
got, err := exec.Execute(context.Background(), map[string]any{"query": query})
if err != nil {
t.Fatalf("Execute(%q): %v", query, err)
}
m, ok := got.(map[string]any)
if !ok {
t.Fatalf("返回值类型 = %T", got)
}
return m
}
// --- ④ 默认基地址只有一个出处:接入位表 vs `.env` 标签默认值,逐字对齐 ----------
// TestWebSearchAccessSlotDefaultsAlignWithEnvDefaults:接入位表里的默认基地址必须与
// `.env` 解析出的**标签默认值**逐字一致。
//
// 只钉一侧不够:`.env` 的那几个键仍然存在(冻结的基础键,仍然参与播种),接入位表是本片
// 新增的出处。两侧漂移时,同一家 provider 会因「值从哪来」指向两个不同的上游。
func TestWebSearchAccessSlotDefaultsAlignWithEnvDefaults(t *testing.T) {
t.Parallel()
// 一个 WEB_SEARCH_*_BASE_URL 都不写 ⇒ 下面每项取的都是标签默认值。
cfg := wsLoadConfig(t)
cases := []struct {
name string
envDefault string
}{
{search.NameZhipu, cfg.WebSearch.ZhipuBaseURL},
{search.NameAliyunIQS, cfg.WebSearch.AliyunIQSBaseURL},
{search.NameBocha, cfg.WebSearch.BochaBaseURL},
{search.NameVolcengine, cfg.WebSearch.VolcengineBaseURL},
{search.NameBaiduQianfan, cfg.WebSearch.BaiduQianfanBaseURL},
}
for _, tc := range cases {
// 进程环境里的同名覆盖会让「标签默认值」这一侧变成用户值 —— 那不是本表要钉的事实
// (缺省值),如实跳过而不是报假红。
envKey := "WEB_SEARCH_" + strings.ToUpper(tc.name) + "_BASE_URL"
if os.Getenv(envKey) != "" {
t.Skipf("%s 在进程环境里有覆盖值,跳过默认值对齐(本表只钉缺省值)", envKey)
}
if tc.envDefault == "" {
t.Fatalf("%s 的标签默认值解析出来是空的(.env 冻结键不许被清空)", tc.name)
}
if got := search.DefaultBaseURL(tc.name); got != tc.envDefault {
t.Errorf("接入位表 %s 的默认基地址 = %q,.env 的标签默认值 = %q(必须逐字一致)",
tc.name, got, tc.envDefault)
}
}
// searxng 的「默认地址」是 WEB_SEARCH_ENDPOINT 的标签默认值,且**不进**接入位表的默认表
// ——它的地址为空是「关掉这家」的开关语义。
if got := search.DefaultBaseURL(search.NameSearXNG); got != "" || cfg.WebSearch.Endpoint == "" {
t.Errorf("searxng: 接入位默认地址 = %q(want 空),.env 端点默认值 = %q(want 非空)",
got, cfg.WebSearch.Endpoint)
}
}
// --- ① 文件里有 key ⇒ 进注册表 ----------------------------------------------
// TestWebSearchFileProviderEntersRegistry 是判据 ①:**文件里有 zhipu 的 apiKey** ⇒ 该家进
// 注册表(`.env` 一个 zhipu 凭据键都没写),且请求真的带着文件里那把 key 打到文件里那个地址。
func TestWebSearchFileProviderEntersRegistry(t *testing.T) {
t.Parallel()
const key = "test-file-zhipu-key"
up := newWSFakeUpstream(t, http.StatusOK, wsZhipuSearchBody("文件里的智谱"))
cfg := wsLoadConfig(t, "WEB_SEARCH_PROVIDER="+search.NameZhipu)
wsSavePanel(t, cfg, runtimeconfig.WebSearchFile{
Provider: search.NameZhipu,
Providers: []runtimeconfig.ProviderEntry{
{Provider: search.NameZhipu, APIKey: key, BaseURL: up.url, Enabled: runtimeconfig.EnableFlag(true)},
},
})
eff, source := wsEffectiveProviders(t, cfg)
if source != webSearchSourcePanel {
t.Fatalf("configSource = %q, want %q(文件存在时它是唯一来源)", source, webSearchSourcePanel)
}
if got := availableProviderNames(searchProviderSpecs(eff.Scalar, eff.Providers)); len(got) != 1 || got[0] != search.NameZhipu {
t.Fatalf("可用清单 = %v, want [%s](凭据来自面板文件)", got, search.NameZhipu)
}
a := wsSurfaceApp(t, cfg, nil)
if !wsHasTool(a, webSearchToolName) {
t.Fatalf("文件里配好的主家应让工具进注册面: %v", toolNames(a))
}
if got := wsExecuteSearch(t, a, "昔涟"); got["provider"] != search.NameZhipu {
t.Fatalf("provider = %v, want %q", got["provider"], search.NameZhipu)
}
select {
case r := <-up.hits:
if r.Header.Get("Authorization") != "Bearer "+key {
t.Errorf("Authorization = %q,want 文件里那把 key(凭据必须以文件为准)", r.Header.Get("Authorization"))
}
default:
t.Fatal("文件里配好的家一个请求都没收到")
}
}
// --- ② `.env` 是播种层:文件缺失时与改动前逐字一致 ----------------------------
// TestWebSearchProviderFromEnvSeedLayer 是判据 ②:**文件里没有 zhipu、但 `.env` 有**
// ⇒ 与改动前逐字一致:文件缺失/为空时由 `.env` 播种落盘,本次就按 `.env` 运行。
//
// 「与改动前一致」的落点有三处:播种内容、来源标签(env)、生效凭据。
func TestWebSearchProviderFromEnvSeedLayer(t *testing.T) {
t.Parallel()
cfg := wsLoadConfig(t,
"WEB_SEARCH_PROVIDER="+search.NameZhipu,
"WEB_SEARCH_ZHIPU_API_KEY=sk-env-001",
)
// 第一次解析发生在文件缺失时 ⇒ 它就是「播种那一次」。
eff, source := wsEffectiveProviders(t, cfg)
if source != webSearchSourceEnv {
t.Fatalf("configSource = %q, want %q(文件缺失时按 .env 运行)", source, webSearchSourceEnv)
}
z, ok := wsFindWire(eff.Providers, search.NameZhipu)
if !ok || z.APIKey != "sk-env-001" || z.BaseURL != search.DefaultBaseURL(search.NameZhipu) {
t.Fatalf("播种层的 zhipu 投影 = %+v, want 凭据来自 .env、基地址回落默认值", z)
}
if got := availableProviderNames(searchProviderSpecs(eff.Scalar, eff.Providers)); len(got) != 2 {
t.Fatalf("可用清单 = %v, want searxng + zhipu 两家", got)
}
// 播种真的落了盘:条目里带 `.env` 的凭据(`.env` 是播种层的输入)。
f, found, err := runtimeconfig.LoadWebSearch(wsPanelPath(cfg))
if err != nil || !found {
t.Fatalf("文件缺失时必须按 .env 播种落盘: found=%v err=%v", found, err)
}
entry, ok := findProviderEntry(f, search.NameZhipu)
if !ok || entry.APIKey != "sk-env-001" {
t.Fatalf("播种的 zhipu 条目 = %+v, want 带 .env 的凭据", entry)
}
if f.Provider != search.NameZhipu {
t.Fatalf("播种的主 provider = %q, want %q", f.Provider, search.NameZhipu)
}
// 播种之后工具照常在注册面里(`.env` 走的是与面板键同一条装配路)。
if !wsHasTool(wsSurfaceApp(t, cfg, nil), webSearchToolName) {
t.Fatal("按 .env 播种后 web-search 应在注册面里")
}
}
// --- ③ 没有 env 字段的家:只认文件(本片的**核心判据**) ----------------------
// TestWebSearchProviderWithoutEnvFieldIsProjected 是判据 ③ 的投影层:**没有 env 字段的家**
// (测试专用的假接入位 `fake_next_provider`)在文件里有 key ⇒ 照样被投影出生效配置。
//
// 这条判据就是本片存在的理由:旧形状的逐家 switch 只认 `.env` 字段表里的 6 家,第 7 家
// 想进来只能先加 env 键(破红线)。新形状按**接入位表**逐家投影,因此第 7 家只需在
// `plugins/tools/io/search` 登记(加一个接入位文件 + 表里一行),组合根零改动
// —— 本片新接的 `tencent_wsa` / `metaso` 走的正是这条路(它们没有 env 键)。
//
// 最后一跳(进注册表)由同文件的 TestWebSearchFileOnlyProviderEntersRegistry 用真实接入位
// 走完:注册表按名字查的正是同一张接入位表。
func TestWebSearchProviderWithoutEnvFieldIsProjected(t *testing.T) {
t.Parallel()
fake := wsFakeNewProviderDef()
cfg := wsLoadConfig(t)
// 前提一:它确实不在 `.env` 的字段表里(否则这条用例会退化成「既有 6 家」的回归)。
for _, p := range searchProviderVector(cfg.WebSearch) {
if strings.EqualFold(p.Name, fake.Name) {
t.Fatalf("前提被破坏:%s 不该出现在 .env 的字段表里", fake.Name)
}
}
// 前提二:它确实还没登记(假接入位的全部意义就在于此)。
if search.Known(fake.Name) {
t.Fatalf("前提被破坏:假接入位 %s 不该已经登记", fake.Name)
}
f := runtimeconfig.WebSearchFile{
Version: runtimeconfig.WebSearchVersion,
Providers: []runtimeconfig.ProviderEntry{
// 连 baseUrl 都不给:顺带钉住判据 ④ 在「没有 env 字段」的家上同样成立。
{Provider: fake.Name, APIKey: "sk-wsa-001"},
},
}
got := resolveSearchProviders(append(search.Descriptors(), fake), f)
w, ok := wsFindWire(got, fake.Name)
if !ok {
t.Fatalf("没有 env 字段的家在文件里有 key 时也必须被投影出来,实际 = %+v", got)
}
if w.APIKey != "sk-wsa-001" {
t.Fatalf("投影的凭据 = %q, want 文件里的 sk-wsa-001", w.APIKey)
}
if w.BaseURL != fake.DefaultBaseURL {
t.Fatalf("投影的基地址 = %q, want 接入位默认值 %q", w.BaseURL, fake.DefaultBaseURL)
}
// 没配的家照样占位(它带着默认基地址进 SSRF 白名单)。
if _, ok := wsFindWire(got, search.NameBaiduQianfan); !ok {
t.Fatalf("未配置的家也要出现在投影里(否则 SSRF 白名单会少一家): %+v", got)
}
}
// TestWebSearchFileOnlyProviderEntersRegistry 是判据 ③ 的端到端:文件里有 key、
// `.env` 里没有任何该家的值 ⇒ 该家真的进注册表,且请求带着**文件里那把** key 打出去。
//
// 落在真实接入位(bocha)上,是因为注册表按名字查的正是那张接入位表:这条路径一旦通了,
// 「没有 env 字段的家」登记之后走的就是同一条路。
func TestWebSearchFileOnlyProviderEntersRegistry(t *testing.T) {
t.Parallel()
const key = "test-file-only-key"
got := make(chan *http.Request, 1)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got <- r
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"webPages":{"value":[{"name":"博查结果","url":"https://b.example/1","summary":"正文"}]}}`))
}))
defer srv.Close()
// `.env` 只给「谁当主家」,凭据与地址一个都不给。
cfg := wsLoadConfig(t, "WEB_SEARCH_PROVIDER="+search.NameBocha)
wsSavePanel(t, cfg, runtimeconfig.WebSearchFile{
Provider: search.NameBocha,
Providers: []runtimeconfig.ProviderEntry{
{Provider: search.NameBocha, APIKey: key, BaseURL: srv.URL, Enabled: runtimeconfig.EnableFlag(true)},
},
})
a := wsSurfaceApp(t, cfg, nil)
if !wsHasTool(a, webSearchToolName) {
t.Fatalf("文件里配好的家应让工具进注册面: %v", toolNames(a))
}
if m := wsExecuteSearch(t, a, "昔涟"); m["provider"] != search.NameBocha {
t.Fatalf("provider = %v, want %q", m["provider"], search.NameBocha)
}
select {
case r := <-got:
if r.Header.Get("Authorization") != "Bearer "+key {
t.Errorf("Authorization = %q,want 文件里那把 key", r.Header.Get("Authorization"))
}
default:
t.Fatal("文件里配好的家一个请求都没收到")
}
}
// --- ④ 基地址的进位链:文件值 → 接入位默认值 ---------------------------------
// TestWebSearchBaseURLFallbackChain 是判据 ④ 的表驱动:
// 文件里的 baseUrl 缺位 ⇒ 回落**该接入位的代码默认基地址**(逐字对上 registry 里的值)。
func TestWebSearchBaseURLFallbackChain(t *testing.T) {
t.Parallel()
cases := []struct {
name string
entries []runtimeconfig.ProviderEntry
wantKey string
wantURL string
}{
{
"文件没给地址 ⇒ 回落接入位默认值",
[]runtimeconfig.ProviderEntry{{Provider: search.NameZhipu, APIKey: "sk-1"}},
"sk-1", search.DefaultBaseURL(search.NameZhipu),
},
{
"文件给了地址 ⇒ 用文件的(进位链第一跳)",
[]runtimeconfig.ProviderEntry{{Provider: search.NameZhipu, APIKey: "sk-1", BaseURL: "https://proxy.example"}},
"sk-1", "https://proxy.example",
},
{
"空串地址等同于没给 ⇒ 回落接入位默认值",
[]runtimeconfig.ProviderEntry{{Provider: search.NameZhipu, APIKey: "sk-1", BaseURL: " "}},
"sk-1", search.DefaultBaseURL(search.NameZhipu),
},
{
"被停用的家仍带默认地址(白名单口径),但没有凭据",
[]runtimeconfig.ProviderEntry{
{Provider: search.NameZhipu, APIKey: "sk-1", Enabled: runtimeconfig.EnableFlag(false)},
},
"", search.DefaultBaseURL(search.NameZhipu),
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
f := runtimeconfig.WebSearchFile{Version: runtimeconfig.WebSearchVersion, Providers: tc.entries}
got := resolveSearchProviders(search.Descriptors(), f)
w, ok := wsFindWire(got, search.NameZhipu)
if !ok {
t.Fatalf("zhipu 必须出现在投影里: %+v", got)
}
if w.BaseURL != tc.wantURL {
t.Errorf("基地址 = %q, want %q", w.BaseURL, tc.wantURL)
}
if w.APIKey != tc.wantKey {
t.Errorf("凭据 = %q, want %q", w.APIKey, tc.wantKey)
}
})
}
// 逐字对齐:这个「接入位默认值」必须就是 registry 里那张表的值(不是从 `.env` 抄来的)。
if got := search.DefaultBaseURL(search.NameZhipu); got != "https://open.bigmodel.cn" {
t.Fatalf("registry 的默认基地址 = %q, want 逐字 https://open.bigmodel.cn", got)
}
}
// --- 边界:文件存在时它**就是**权威(`.env` 不复活任何条目) ------------------
// TestWebSearchFileAuthorityOverEnvFieldTable 钉住投影层的权威边界:文件里**没有** zhipu
// 条目 ⇒ 即使 `.env` 里有 zhipu 的 key,这家也不生效(凭据不复活)。`.env` 只在**文件
// 缺失/为空**时经 seedWebSearchFile 播种(判据 ②)—— 文件一旦存在,权威就在文件那边。
//
// 这是工具层既有回归用例(`wire_web_search_panel_test.go` 的
// `TestWireIOTools_PanelConfigClearedProviderDoesNotReviveFromEnv`:主家因此没配齐 ⇒ 启动
// 报错)在**投影层**的同一条口径,也是与本体形状契约(runtimeconfig:providers 为空
// 数组 ⇒ 一家都没配)一致的行为。
func TestWebSearchFileAuthorityOverEnvFieldTable(t *testing.T) {
t.Parallel()
cfg := wsLoadConfig(t,
"WEB_SEARCH_PROVIDER="+search.NameZhipu,
"WEB_SEARCH_ZHIPU_API_KEY=sk-env-stale",
)
f := runtimeconfig.WebSearchFile{
Version: runtimeconfig.WebSearchVersion,
Provider: search.NameZhipu,
Providers: []runtimeconfig.ProviderEntry{
{Provider: search.NameSearXNG, BaseURL: "http://127.0.0.1:38471"},
},
}
// 文件真的落到盘上(否则「文件里没有这家」这条用例会退化成「文件缺失 ⇒ 播种」)。
wsSavePanel(t, cfg, f)
got := resolveSearchProviders(search.Descriptors(), f)
w, ok := wsFindWire(got, search.NameZhipu)
if !ok {
t.Fatalf("未配置的家也要在投影里占位(它带着默认基地址进 SSRF 白名单): %+v", got)
}
if w.APIKey != "" {
t.Fatalf("文件里没有的家不得从 .env 复活凭据,得到 %q(.env 只是播种层)", w.APIKey)
}
if w.BaseURL != search.DefaultBaseURL(search.NameZhipu) {
t.Fatalf("基地址 = %q, want 接入位默认值 %q", w.BaseURL, search.DefaultBaseURL(search.NameZhipu))
}
// 与 `.env` 无关的旁证:那份 `.env` 里确实有 key(否则这条用例是空转)。
if cfg.WebSearch.ZhipuAPIKey != "sk-env-stale" {
t.Fatalf(".env 里的 zhipu key = %q(用例前提不成立)", cfg.WebSearch.ZhipuAPIKey)
}
// 工具层:主家没配齐 ⇒ 启动报错(既不复活,也不静默换一家)。
if err := wsNewTools(t, cfg); !errors.Is(err, search.ErrNotConfigured) {
t.Fatalf("err = %v, want errors.Is(..., ErrNotConfigured):文件里没有的家不得从 .env 复活", err)
}
}
+200
View File
@@ -0,0 +1,200 @@
package main
// 本文件是 web-search「**各家 provider 的配置投影**」—— 组合根里唯一把配置翻译成各家接线
// 的地方。它存在的理由是一条硬约束:**`.env` 已冻结**(基础键不加/不改/不删)。
//
// 旧形状是「一家一个 env 字段」:`config.WebSearchConfig` 有 `ZhipuAPIKey` / `BochaAPIKey` / …,
// 组合根再逐家读这些字段(外加一个逐家写死的 switch 把面板文件的值搬回去)。那个形状的
// 后果是**加一家 provider 就要先加一个 env 键** —— 直接破 `.env` 冻结红线。
//
// 新形状把**名字表收回到插件自己手里**(`plugins/tools/io/search` 的接入位表):
//
// - 各家叫什么、要不要 key、没给基地址时回落哪个默认地址 ⇒ 全在 `search.Descriptors()`;
// - 组合根只做一件事:**按名字**在面板文件里找同名条目,有就取文件的值,没有就是没配;
// - `.env` 退居**播种层**:只有「文件缺失/为空」时才由 `seedWebSearchFile` 落盘一次
// (既有 6 家的键与值逐字不变,主人已在用的配置不受影响)。
//
// ⇒ 加一家 provider = `plugins/tools/io/search` 加一个接入位文件 + 表里一行,**本文件一行
// 都不用改**,`.env` 一个键都不用加。`tencent_wsa` / `metaso` 就是这么进来的(两家都
// **没有** env 字段:`.env` 已冻结,它们的 key / 基地址只从面板文件取)。
import (
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
)
// effectiveWebSearchConfig 是一次装配的**生效配置**,分两层:
//
// - Scalar:文件级标量(主 provider / 降级链 / 条数上限 / 超时)与 searxng 的端点。
// 它沿用 config.WebSearchConfig,因为那几个裁决函数都挂在它上面
// (ResolveProvider / ResolveEndpoint),而它们是 `.env` 与面板**共用**的一套语义。
// - Providers:各家(**不含 searxng**)的生效投影,按**接入位表**逐家给出。
//
// 为什么各家的 key/基地址不再写回 Scalar 的逐家字段:那张字段表是 `.env` 的形状,碰它就要
// 动 env 键(见文件头)。逐家字段在这个结构里**不再承载生效值**(清零),免得被读成
// 「生效配置里的凭据」—— 那正是旧形状最容易被误用的地方。
type effectiveWebSearchConfig struct {
// Scalar 是文件级标量与 searxng 端点(逐家字段一律清零,见上方说明)。
Scalar config.WebSearchConfig
// Providers 是各家的生效投影(不含 searxng),顺序 = 接入位表的展示顺序。
Providers []searchProviderWire
}
// searchProviderWire 是「一家云 provider 的配置投影」:名字 + 凭据 + 基地址。
// searxng 不在这张表里(它的「基地址」就是端点,由 ResolveEndpoint 裁决整把工具的存亡)。
type searchProviderWire struct {
Name string
APIKey string
BaseURL string
}
// searchProviderVector 是 **`.env` 播种层**给出的各家投影(既有 6 家的 env 字段表,
// 云家部分)。它只服务两处:`.env` 缺失时的播种内容(seedWebSearchFile),以及
// 「没有可用文件」时的生效投影(envEffectiveWebSearch)。
//
// ⚠️ 它**不是**「本期内置几家」的名单:登记表里还有两家**没有 env 字段**的家
// (tencent_wsa / metaso,`.env` 已冻结 ⇒ 不给它们加键),它们只从面板文件生效
// (`resolveSearchProviders` 按 `search.Descriptors()` 逐家投影,不经过本函数)。
// 它也**不是**逐条的兜底层:文件存在时,文件里没有的家就是没有(见 resolveSearchProviders)。
// 这条口径由 `wire_web_search_panel_test.go` 的
// `TestWireIOTools_PanelConfigClearedProviderDoesNotReviveFromEnv` 钉住。
func searchProviderVector(ws config.WebSearchConfig) []searchProviderWire {
return []searchProviderWire{
{Name: search.NameZhipu, APIKey: ws.ZhipuAPIKey, BaseURL: ws.ZhipuBaseURL},
{Name: search.NameAliyunIQS, APIKey: ws.AliyunIQSAPIKey, BaseURL: ws.AliyunIQSBaseURL},
{Name: search.NameBocha, APIKey: ws.BochaAPIKey, BaseURL: ws.BochaBaseURL},
{Name: search.NameVolcengine, APIKey: ws.VolcengineAPIKey, BaseURL: ws.VolcengineBaseURL},
{Name: search.NameBaiduQianfan, APIKey: ws.BaiduQianfanAPIKey, BaseURL: ws.BaiduQianfanBaseURL},
}
}
// envSearchProviders 是 `.env` 播种层的**全量**投影:searxng(基地址 = WEB_SEARCH_ENDPOINT)
// 打头,其余各家跟随 —— 顺序与既有播种逐字一致。
func envSearchProviders(ws config.WebSearchConfig) []searchProviderWire {
out := make([]searchProviderWire, 0, len(searchProviderVector(ws))+1)
out = append(out, searchProviderWire{Name: search.NameSearXNG, BaseURL: seedEndpoint(ws.Endpoint)})
return append(out, searchProviderVector(ws)...)
}
// effectiveSearchProviders 是各家(**不含 searxng**)在本次装配里的生效投影。
//
// 生产路径只有薄薄这一层:接入位表从插件自己那里取(`search.Descriptors()`)。加一家
// provider 时这里**零改动** —— 这正是本片「去 env 化」的核心。
func effectiveSearchProviders(f runtimeconfig.WebSearchFile) []searchProviderWire {
return resolveSearchProviders(search.Descriptors(), f)
}
// resolveSearchProviders 逐家算出生效投影。**判定顺序(文件权威)**:
//
// 1. 面板文件里有同名条目且**启用** ⇒ 取条目里的 apiKey / baseUrl(凭据以文件为准:
// 条目里 key 为空 = 这家没配,绝不拿 `.env` 的旧 key 顶上,否则「我在面板里删了它」
// 会变成一句假话);
// 2. 面板文件里有同名条目但**被显式停用** ⇒ 这家不生效(停用是显式选择,不回落别处);
// 3. 面板文件里**没有**该条目 ⇒ 这家不生效(`.env` 只在**文件缺失/为空**时播种,文件一旦
// 存在就是权威;见 searchProviderVector 的注释与那条既有回归用例)。
//
// ⚠️ **一处经核对的偏差(记在这里,免得后人踩)**:任务书里有一句「既有 6 家在文件里没有该
// 条目时仍按现状回落 `.env` 值」。本仓现状**没有**这层回落,且有两条既有约束钉着它:
// ① `runtimeconfig.WebSearchFile.Providers` 的形状契约写明「允许为空数组(= 一家都没配 ⇒
// 组合根不注册 web-search)」;② 回归用例 `TestWireIOTools_PanelConfigClearedProviderDoesNot
// ReviveFromEnv` 明确要求「文件里没有的家不得从 `.env` 复活」。本片按「**不改变既有行为**」
// 实现(既有 websearch 系列用例逐条未改语义、全绿),需要那层回落时改的就是下面这个循环。
//
// 被跳过与未配置的家**仍带着基地址**(回落到接入位的默认值):接线表的 BaseURL 会被组合根
// 逐个加进 SSRF 白名单(io 包的 withSearchEndpointHost),这里悄悄少一家等于改了那份白名单。
//
// defs 由调用方给(生产 = `search.Descriptors()`):测试可注入一个**假接入位**,从而驱动
// 「没有 env 字段的家也能只认文件生效」这条判据 —— 那是本片存在的理由,必须可证。
func resolveSearchProviders(defs []search.Descriptor, f runtimeconfig.WebSearchFile) []searchProviderWire {
out := make([]searchProviderWire, 0, len(defs))
for _, d := range defs {
// searxng 的「基地址」是端点(`WEB_SEARCH_ENDPOINT` / 文件里那条的 baseUrl),
// 由 effectiveSearchConfig 单独裁决:空地址 = 关掉这家,语义与云家不同。
if strings.EqualFold(d.Name, search.NameSearXNG) {
continue
}
entry, found := findProviderEntry(f, d.Name)
if !found || !entry.IsEnabled() {
out = append(out, searchProviderWire{Name: d.Name, BaseURL: baseURLChain("", d.DefaultBaseURL)})
continue
}
out = append(out, searchProviderWire{
Name: d.Name,
APIKey: strings.TrimSpace(entry.APIKey),
BaseURL: baseURLChain(entry.BaseURL, d.DefaultBaseURL),
})
}
return out
}
// baseURLChain 是「一家的基地址」的**唯一进位链**:条目里的值 → 该接入位的代码默认基地址
// → 空。
//
// 空只可能出现在没有默认地址的家上(searxng):它的地址为空本身就是「关掉这家」的开关语义,
// 回落一个本机地址会让「清空地址」变成假话。
func baseURLChain(fromEntry, accessSlotDefault string) string {
if s := strings.TrimSpace(fromEntry); s != "" {
return s
}
return accessSlotDefault
}
// resolveBaseURL 按**名字**走同一条进位链(动作面只拿得到名字与条目):默认基地址由接入位表
// 给出(`search.DefaultBaseURL`,未登记的名字 ⇒ 空)。
//
// 装配期**不用**这个入口而是直接用 baseURLChain:那里手里已经有接入位描述,而「没有 env
// 字段的家」在登记之前按名字查不到默认地址 —— 那正是本片要打通的那一类家。
func resolveBaseURL(name, fromEntry string) string {
return baseURLChain(fromEntry, search.DefaultBaseURL(name))
}
// searchProviderSpecs 把生效配置翻译成各家接线。
//
// searxng 单独一行:它的基地址是端点、可用性由 ResolveEndpoint 裁决(不是「有没有 key」);
// 其余各家按接入位表逐家给出,Configured 的口径仍由 `search.Configured` 统一判定
// (searxng 看端点、云家看 key;false ⇒ 该家不进注册表、不出现在可用清单里)。
//
// **密钥只在这里流转,绝不写日志。**
func searchProviderSpecs(ws config.WebSearchConfig, providers []searchProviderWire) []iotools.SearchProviderSpec {
endpoint := ws.ResolveEndpoint()
specs := make([]iotools.SearchProviderSpec, 0, len(providers)+1)
specs = append(specs, iotools.SearchProviderSpec{
Name: search.NameSearXNG,
BaseURL: endpoint.URL,
Configured: endpoint.Enabled,
})
for _, p := range providers {
specs = append(specs, iotools.SearchProviderSpec{
Name: p.Name,
APIKey: p.APIKey,
BaseURL: p.BaseURL,
Configured: search.Configured(p.Name, p.APIKey, p.BaseURL),
})
}
return specs
}
// availableProviderNames 返回已配置的家(即「可用清单」),供启动日志如实展示。
// **不含任何密钥** —— 只回名字。
func availableProviderNames(specs []iotools.SearchProviderSpec) []string {
out := make([]string, 0, len(specs))
for _, s := range specs {
if s.Configured {
out = append(out, s.Name)
}
}
return out
}
// seedEndpoint 把 `WEB_SEARCH_ENDPOINT` 的取值播进 searxng 条目:显式关闭的哨兵落成空串
// (两者语义相同:都表示不出这家,但面板里不该显示一个不是地址的 "off")。
func seedEndpoint(endpoint string) string {
if config.IsWebSearchOffSentinel(endpoint) {
return ""
}
return strings.TrimSpace(endpoint)
}
+257
View File
@@ -0,0 +1,257 @@
package main
// 本文件是「搜索提供商」页的**热重载**(切片 W3;`DECISIONS-2026-10-06` 裁决 #1):
// 面板保存后,**重建** web-search 工具并**替换**能力注册表里的条目,使改动在下一轮对话
// 即生效 —— 不再要求「重启后端」。
//
// 为什么必须做(而不是把「重启后生效」写进面板文案):面板的意义就是「不改 `.env`、
// 不重启」,留着「重启后生效」等于把配置面做成摆设(裁决 #1 的理由原文)。design/09 §5.5
// 的端到端判据也正是「面板加一家 ⇒ 不改 `.env`、不重启 ⇒ 下一次搜索真的走它」。
//
// 三条口径:
//
// 1. **一次保存 = 一次事务**:写可变源 → 重建工具 → 替换注册表条目。任何一步失败都把
// 文件**回滚**到保存前那一份,绝不留「盘上是新的、内存是旧的」或「盘上的配置让后端
// 下次起不来」这两种半状态。
// 2. **重建走启动期同一条路**(`buildWebSearchTool` → `webSearchOptions`):重载后的工具
// 与启动期构造的逐字段同源,不是第二份接线逻辑(避免两处语义漂移)。
// 3. **替换是原子的、保留策略**:走 `registry.ReplaceTool` 而不是 Remove+Register
// (详见该方法注释:中间无空窗,且分组/陌生人可达标记不被重置)。
//
// 触发面只有一处:`wire_plugin_actions.go` 把**写可变源**的那个动作(`plugin.saveProvider`)
// 包上 `webSearchReloader.wrap`。动作本体(websearch_action_data.go 的 saveProvider)保持
// 「只负责把这条存进去」的单一职责,重载与回滚都在本文件。
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"sync"
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
)
// webSearchReloadLogSource 是本文件日志的来源标签(项目规范 §10:禁止无来源裸日志)。
const webSearchReloadLogSource = "cmd/cyrene/websearch_reload"
// actionCodeConfigUnstartable 报告「这次保存的结果会让后端**下一次启动直接失败**」——
// 文件已回滚,改动**没有**生效(406 / 409 这类「与当前状态冲突」的档,不是 500 内部错)。
const actionCodeConfigUnstartable = "config_unstartable"
// webSearchRollbackFileMode 是回滚写回时的文件权限:与 runtimeconfig 落盘可变源的 0600
// 同口径(那份文件带 API Key)。硬编码成 0600 而不是读对方的常量,是因为那个常量未导出,
// 而变化方向是「收紧」,不存在比 0600 更松的风险。
const webSearchRollbackFileMode os.FileMode = 0o600
// webSearchReloadOutcome 报告一次热重载的结局,并作为动作响应里可机读的事实。
type webSearchReloadOutcome struct {
// Applied 报告这次保存**真的触发了**热重载(false = 没有能力注册表可换,
// 例如测试夹具;此时只落了盘,运行中的工具没动)。
Applied bool `json:"applied"`
// Registered 报告保存之后 web-search **在不在模型的工具面里**(Applied 为 true
// 时才有意义):关掉主 provider / 端点会让它整把消失,这是设计语义,不是失败。
Registered bool `json:"registered"`
}
// webSearchReloader 把「保存可变源 → 重建工具 → 换注册表条目」做成一次事务。
//
// 锁的粒度是**整次保存**(含动作本体那一步):两个并发的面板保存如果交错,可能出现
// 「A 的写 + B 的重载」这种盘上与内存对不上的组合。面板保存是低频动作,串行化的代价
// 可以忽略,换来的是「最后一次保存定终局」这条可证的顺序。
type webSearchReloader struct {
mu sync.Mutex
app *app
path string
log *slog.Logger
}
// webSearchActionHandlersWithHotReload 在既有的动作处理器表之上,给**写可变源**的动作
// 套上热重载事务(其余动作不动:`testProvider` / `reauthProvider` 不改文件,无需重载)。
//
// 包在 `wireBuiltinPluginActions` 里调用(登记入口一处决定「谁会被热重载」),因此
// 动作名到处理器的映射关系在这里仍然可见,不额外引入第二张表。
func webSearchActionHandlersWithHotReload(
env webSearchActionEnv, a *app, log *slog.Logger,
) map[string]pluginmgr.ActionHandler {
handlers := webSearchActionHandlers(env)
r := &webSearchReloader{app: a, path: env.path, log: logger.WithSource(log, webSearchReloadLogSource)}
for _, action := range []string{search.ActionSaveProvider} {
if inner, ok := handlers[action]; ok && inner != nil {
handlers[action] = r.wrap(inner)
}
}
return handlers
}
// wrap 把一次「写可变源的保存」变成事务:
//
// ① 快照文件(回滚的凭据);
// ② 跑动作本体(它负责把改动落盘);
// ③ 按**落盘后的配置**重建工具并替换注册表条目;
// ④ ③ 失败 ⇒ 回滚文件(内存没动过,因此回滚后两处一致)并回结构化错误。
func (r *webSearchReloader) wrap(inner pluginmgr.ActionHandler) pluginmgr.ActionHandler {
return func(ctx context.Context, req pluginmgr.ActionRequest) (any, error) {
r.mu.Lock()
defer r.mu.Unlock()
snap, err := r.snapshot()
if err != nil {
return nil, pluginmgr.NewActionError(actionCodeConfigUnreadable,
"面板配置读不出来,本次**没有**改动它;详见服务端日志", http.StatusInternalServerError, err)
}
res, err := inner(ctx, req)
if err != nil {
return nil, err
}
outcome, rerr := r.reload()
if rerr == nil {
return withReloadOutcome(res, outcome), nil
}
if rberr := r.rollback(snap); rberr != nil {
// 回滚都失败了 ⇒ 只能如实说「盘上可能已经变了」,绝不假装改动没发生。
r.log.Error("web-search config rollback failed after an unstartable save",
"path", r.path, "reloadError", rerr, "rollbackError", rberr)
return nil, pluginmgr.NewActionError(actionCodeConfigWriteFailed,
"这次改动会让 web-search 起不来,且回滚也失败了:请人工检查 "+
r.path+"(详见服务端日志)", http.StatusInternalServerError, errors.Join(rerr, rberr))
}
r.log.Warn("web-search panel save rolled back: the resulting config cannot start",
"path", r.path, "error", rerr)
return nil, pluginmgr.NewActionError(actionCodeConfigUnstartable,
unstartableMessage(rerr), http.StatusConflict, rerr)
}
}
// reload 按落盘后的配置重建工具并替换注册表条目。
//
// 三种结局:换掉(工具该在)/摘掉(当前配置下它不该在注册面里,与启动期语义一致)/
// 报错(这份配置起不来 ⇒ 调用方回滚)。没有能力注册表时如实报告 Applied=false。
func (r *webSearchReloader) reload() (webSearchReloadOutcome, error) {
if r.app == nil || r.app.cfg == nil || r.app.caps == nil {
// 没有注册表可换(测试夹具 / 装配未完成):只落了盘,工具没动。
// 不静默当成「换过了」——否则响应里的 registered 会是一句假话。
r.log.Debug("web-search hot reload skipped: no capability registry",
"path", r.path, "effect", "the running tool keeps the startup wiring")
return webSearchReloadOutcome{}, nil
}
tool, err := r.app.buildWebSearchTool(webSearchCausePanelSave)
if err != nil {
return webSearchReloadOutcome{}, err
}
if tool == nil {
return webSearchReloadOutcome{Applied: true}, r.app.removeSearchTool(r.log)
}
if err := r.app.replaceSearchTool(tool); err != nil {
return webSearchReloadOutcome{}, err
}
r.log.Info("web-search tool hot-reloaded after panel save",
"path", r.path, "action", search.ActionSaveProvider,
"effect", "the next turn uses the new wiring; no restart needed")
return webSearchReloadOutcome{Applied: true, Registered: true}, nil
}
// replaceSearchTool 把重建出来的工具放进注册面:**已登记就就地替换**(原子、保留策略),
// 之前不在注册面里(例如上一次保存把它关了)就新增。
func (a *app) replaceSearchTool(t sdk.Tool) error {
err := a.caps.ReplaceTool(t)
if err == nil {
return nil
}
if !errors.Is(err, registry.ErrNotFound) {
return fmt.Errorf("web-search: replace tool: %w", err)
}
if rerr := a.caps.RegisterTool(t); rerr != nil {
return fmt.Errorf("web-search: register tool: %w", rerr)
}
return nil
}
// removeSearchTool 把 web-search 从注册面摘掉(当前配置下它不该在:端点/主 provider 被
// 显式关闭,或选定 provider 没配齐)。本来就不在时是幂等的。
func (a *app) removeSearchTool(log *slog.Logger) error {
err := a.caps.Remove(webSearchToolName)
if err != nil && !errors.Is(err, registry.ErrNotFound) {
return fmt.Errorf("web-search: remove tool: %w", err)
}
log.Info("web-search tool removed from the registry after panel save",
"action", search.ActionSaveProvider,
"effect", "the model no longer sees a web-search tool aimed at a closed provider")
return nil
}
// fileSnapshot 是可变源在**一次保存之前**的样子(回滚的凭据)。
//
// 存原始字节而不是解析后的结构:回滚的语义是「一字不差地回到那一份」。经结构往返
// (Load + Save)会丢掉人工写在文件里的、本包不认识的键 —— 那正是「损坏配置可人工救回」
// 场景里最不该丢的东西。
type fileSnapshot struct {
// exists 报告保存前文件在不在(不存在时回滚 = 把它删掉)。
exists bool
// raw 是保存前的原始内容(exists 为 false 时无意义)。
raw []byte
}
// snapshot 读一份保存前的快照。文件不存在 = 合法的「还没配过」状态,不是错误。
func (r *webSearchReloader) snapshot() (fileSnapshot, error) {
raw, err := os.ReadFile(r.path)
if err != nil {
if os.IsNotExist(err) {
return fileSnapshot{}, nil
}
return fileSnapshot{}, fmt.Errorf("web-search: snapshot %s: %w", r.path, err)
}
return fileSnapshot{exists: true, raw: raw}, nil
}
// rollback 把文件恢复成快照那一份(temp+rename,与 runtimeconfig 的落盘口径同源:
// 并发读者永远看不到半个文件)。
func (r *webSearchReloader) rollback(snap fileSnapshot) error {
if !snap.exists {
if err := os.Remove(r.path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("web-search: rollback remove %s: %w", r.path, err)
}
return nil
}
tmp := r.path + ".rollback.tmp"
if err := os.WriteFile(tmp, snap.raw, webSearchRollbackFileMode); err != nil {
return fmt.Errorf("web-search: rollback write %s: %w", tmp, err)
}
if err := os.Rename(tmp, r.path); err != nil {
return fmt.Errorf("web-search: rollback rename %s: %w", r.path, err)
}
return nil
}
// unstartableMessage 把「重建失败」的病因翻成**能照着改**的一句话:主 provider 未配齐
// 与名字未登记是两种不同的修法,笼统说一句「配置不可用」等于让用户自己猜。
func unstartableMessage(err error) string {
switch {
case errors.Is(err, search.ErrNotConfigured):
return "这次改动会让 web-search 没有可用的后端(主提供商未配齐:缺 API Key 或接口地址)," +
"后端下次启动会直接失败 ⇒ 已回滚,改动**没有**生效。请先给主提供商补齐凭据,或改选一家已配齐的。"
case errors.Is(err, search.ErrUnknownProvider):
return "这次改动指向了一个未登记的搜索提供商(名字写错了 / 本期没做这一家)," +
"后端下次启动会直接失败 ⇒ 已回滚,改动**没有**生效。"
default:
return "这次改动会让 web-search 起不来(后端下次启动会直接失败)⇒ 已回滚,改动**没有**生效;" +
"详见服务端日志。"
}
}
// withReloadOutcome 把热重载的结局挂到动作响应上(响应体是对象时追加一个 `hotReload`
// 字段;不是对象时原样返回 —— 宁可少一个字段,也不把结果包成另一种形状)。
func withReloadOutcome(res any, outcome webSearchReloadOutcome) any {
m, ok := res.(map[string]any)
if !ok {
return res
}
m["hotReload"] = outcome
return m
}
+406
View File
@@ -0,0 +1,406 @@
package main
// 本文件钉住切片 W3 的**热重载**(`DECISIONS-2026-10-06` 裁决 #1):
//
// - 面板保存 ⇒ 重建 web-search 工具 ⇒ 替换注册表条目 ⇒ **下一轮即生效**
// (design/09 §5.5 的端到端判据:不改 `.env`、不重启,下一次搜索真的走新配置);
// - 主 provider / 降级链经同一个表单落到文件级字段后**立刻接上工具**;
// - 保存结果会让后端**下次起不来**时:非 2xx + 结构化码 + **按字节回滚**;
// - 用户真正拿到的那一面(管理端点 HTTP)也逐条核对。
//
// 夹具与假上游在 websearch_testenv_test.go;面板选择字段与解析口径在
// websearch_selection_test.go。
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
)
// --- ① 保存后热重载:下一次搜索真的走新配置 -------------------------------
// TestWebSearchHotReloadPointsAtSavedEndpoint 是热重载的主判据(design/09 §5.5):
// 面板把 searxng 的地址改成另一个上游 ⇒ **不重启** ⇒ 下一轮搜索必须打到新那家,
// 且旧那家不再收到请求。同时钉住「注册表里的值是真的换了一把」(不是改了旧对象)。
func TestWebSearchHotReloadPointsAtSavedEndpoint(t *testing.T) {
old := newWSFakeSearxng(t, "old")
fresh := newWSFakeSearxng(t, "fresh")
env := newReloadTestEnv(t,
"WEB_SEARCH_PROVIDER="+search.NameSearXNG,
"WEB_SEARCH_ENDPOINT="+old.url,
)
if _, err := env.search(t, "cyrene"); err != nil {
t.Fatalf("启动期搜索失败: %v", err)
}
if len(old.hits) == 0 {
t.Fatal("启动期没打到配置里的上游 —— 接线本身有问题")
}
before := env.toolValue(t)
old.drain()
outcome := env.mustSave(t, map[string]any{
"provider": search.NameSearXNG, "baseUrl": fresh.url, "enabled": true,
})
if !outcome.Applied || !outcome.Registered {
t.Fatalf("hotReload = %+v, want applied 且 registered", outcome)
}
if env.toolValue(t) == before {
t.Fatal("注册表里的 web-search 还是启动期那一把(没被替换)")
}
got, err := env.search(t, "cyrene")
if err != nil {
t.Fatalf("保存后搜索失败: %v", err)
}
if len(fresh.hits) == 0 {
t.Fatal("保存后的搜索没打到新上游 —— 改动没生效(还得重启)")
}
old.noHit(t)
if got["provider"] != search.NameSearXNG {
t.Fatalf("provider = %v, want %q", got["provider"], search.NameSearXNG)
}
}
// TestWebSearchHotReloadKeepsCapabilityPolicy:热替换必须**保留**能力面策略
// (分组/陌生人可达标记是运行期配置,不属于工具实现)。丢了它 = 一次面板保存让某个群的
// 工具面悄悄缩水。
func TestWebSearchHotReloadKeepsCapabilityPolicy(t *testing.T) {
up := newWSFakeSearxng(t, "a")
env := newReloadTestEnv(t,
"WEB_SEARCH_PROVIDER="+search.NameSearXNG,
"WEB_SEARCH_ENDPOINT="+up.url,
)
if err := env.app.caps.SetAllowInStranger(webSearchToolName, true); err != nil {
t.Fatalf("SetAllowInStranger: %v", err)
}
env.mustSave(t, map[string]any{
"provider": search.NameSearXNG, "baseUrl": up.url, "enabled": true,
})
pol, err := env.app.caps.Policy(webSearchToolName)
if err != nil {
t.Fatalf("Policy: %v", err)
}
if !pol.AllowInStranger {
t.Fatal("热替换丢掉了 AllowInStranger(策略是运行期配置,必须沿用)")
}
}
// --- ② 主 provider / 降级链:面板可配且立刻生效 -----------------------------
// TestWebSearchHotReloadSwitchesPrimaryAndFallback 钉住裁决 #2 的端到端语义:
// 面板选中谁当主家,下一次搜索就走谁;主家不可用时按降级链前进并**如实标注**实际应答的家。
func TestWebSearchHotReloadSwitchesPrimaryAndFallback(t *testing.T) {
searxng := newWSFakeUpstream(t, http.StatusInternalServerError, `{"error":"boom"}`)
bocha := newWSFakeUpstream(t, http.StatusOK,
`{"webPages":{"value":[{"name":"博查结果","url":"https://b.example/1","summary":"正文"}]}}`)
env := newReloadTestEnv(t,
"WEB_SEARCH_PROVIDER="+search.NameSearXNG,
"WEB_SEARCH_ENDPOINT="+searxng.url,
"WEB_SEARCH_BOCHA_API_KEY=sk-bocha",
"WEB_SEARCH_BOCHA_BASE_URL="+bocha.url,
)
// 选 bocha 当主家(文件级 primary),降级链留 searxng:两者都在盘上、都已配齐。
outcome := env.mustSave(t, map[string]any{
"provider": search.NameBocha, "apiKey": "sk-bocha", "baseUrl": bocha.url, "enabled": true,
"primary": search.NameBocha,
"fallback": []any{search.NameSearXNG},
})
if !outcome.Applied {
t.Fatalf("hotReload = %+v, want applied", outcome)
}
got, err := env.search(t, "cyrene")
if err != nil {
t.Fatalf("切换主家后搜索失败: %v", err)
}
if got["provider"] != search.NameBocha {
t.Fatalf("provider = %v, want %q(面板选的主家必须立刻生效)", got["provider"], search.NameBocha)
}
if len(bocha.hits) == 0 {
t.Fatal("选中的新主家一个请求都没收到")
}
// 把主家换回 searxng(它 500)⇒ 必须**降级到 bocha** 并如实标注是谁答的。
bocha.drain()
searxng.drain()
env.mustSave(t, map[string]any{
"provider": search.NameSearXNG, "baseUrl": searxng.url, "enabled": true,
"primary": search.NameSearXNG,
"fallback": []any{search.NameBocha},
})
got, err = env.search(t, "cyrene")
if err != nil {
t.Fatalf("降级链搜索失败: %v", err)
}
if got["provider"] != search.NameBocha {
t.Fatalf("provider = %v, want %q(主力 500 时必须前进一格并如实标注)", got["provider"], search.NameBocha)
}
if len(searxng.hits) == 0 {
t.Fatal("降级链没先试主力 searxng")
}
}
// TestWebSearchHotReloadTogglesToolByPrimary 钉住「关掉就整把消失、再开就回来」:
// 主 provider 写成 off 哨兵 ⇒ 模型看不见这把工具(与启动期语义一致);改回一家可用的
// ⇒ 同一进程内它又回到注册面。
func TestWebSearchHotReloadTogglesToolByPrimary(t *testing.T) {
up := newWSFakeSearxng(t, "a")
env := newReloadTestEnv(t,
"WEB_SEARCH_PROVIDER="+search.NameSearXNG,
"WEB_SEARCH_ENDPOINT="+up.url,
)
off := env.mustSave(t, map[string]any{
"provider": search.NameSearXNG, "baseUrl": up.url, "enabled": true,
"primary": "off",
})
if !off.Applied || off.Registered {
t.Fatalf("hotReload = %+v, want applied=true registered=false", off)
}
if wsHasTool(env.app, webSearchToolName) {
t.Fatalf("主 provider 写成 off 后 web-search 不得留在注册面里: %v", toolNames(env.app))
}
back := env.mustSave(t, map[string]any{
"provider": search.NameSearXNG, "baseUrl": up.url, "enabled": true,
"primary": search.NameSearXNG,
})
if !back.Applied || !back.Registered {
t.Fatalf("hotReload = %+v, want applied=true registered=true", back)
}
if _, err := env.search(t, "cyrene"); err != nil {
t.Fatalf("改回可用主家后搜索应当成功: %v", err)
}
}
// --- ③ 回滚:不让面板存出一份「下次起不来」的配置 ---------------------------
// TestWebSearchHotReloadRollsBackUnstartableSave 是安全网的判据:面板把**当前主家**的
// 凭据清空后,这份配置会让后端下次启动直接失败(design/27 §3 的 fail-fast)⇒ 保存必须
// 诚实失败、把文件**按字节**回滚,并且运行中的工具必须仍是原来那一把。
func TestWebSearchHotReloadRollsBackUnstartableSave(t *testing.T) {
bocha := newWSFakeUpstream(t, http.StatusOK,
`{"webPages":{"value":[{"name":"博查","url":"https://b.example/1","summary":"正文"}]}}`)
env := newReloadTestEnv(t,
"WEB_SEARCH_PROVIDER="+search.NameBocha,
"WEB_SEARCH_BOCHA_API_KEY=sk-keep",
"WEB_SEARCH_BOCHA_BASE_URL="+bocha.url,
)
before := env.rawFile(t)
toolBefore := env.toolValue(t)
_, err := env.save(map[string]any{"provider": search.NameBocha, "apiKey": ""})
ae, ok := pluginmgr.AsActionError(err)
if !ok {
t.Fatalf("err = %v,期望结构化 ActionError(清空主家凭据会让后端起不来)", err)
}
if ae.Code != actionCodeConfigUnstartable {
t.Fatalf("失败码 = %q, want %q", ae.Code, actionCodeConfigUnstartable)
}
if ae.Status < 400 || ae.Status > 599 {
t.Fatalf("失败状态 = %d,必须是非 2xx 的结构化失败", ae.Status)
}
if strings.Contains(ae.Message, "sk-keep") {
t.Fatalf("失败文案泄漏了凭据: %q", ae.Message)
}
if got := env.rawFile(t); !bytes.Equal(got, before) {
t.Fatalf("回滚后文件与保存前不逐字节相同:\nbefore=%s\nafter =%s", before, got)
}
if env.toolValue(t) != toolBefore {
t.Fatal("被回滚的保存却把注册表里的工具换掉了(盘上与内存不一致)")
}
got, err := env.search(t, "cyrene")
if err != nil {
t.Fatalf("回滚后搜索应当照常成功: %v", err)
}
if got["provider"] != search.NameBocha {
t.Fatalf("回滚后 provider = %v, want %q", got["provider"], search.NameBocha)
}
if out := env.out.String(); !strings.Contains(out, "rolled back") {
t.Fatalf("回滚必须留下可诊断的日志: %q", out)
}
}
// TestWebSearchReloadWithoutRegistryIsHonest:没有能力注册表(夹具 / 装配未完成)时,
// 热重载**如实报告 applied=false**,绝不假装换过了 —— 否则响应里的 registered 会是一句假话。
func TestWebSearchReloadWithoutRegistryIsHonest(t *testing.T) {
cfg := wsLoadConfig(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
b := bus.New()
defer b.Close()
mgr := pluginmgr.NewManager(registry.New(), filepath.Join(cfg.DataDir, "plugins.json"), testLog())
defer mgr.Close(context.Background())
// 故意不设 caps:这正是既有动作夹具(actionTestEnv)的形状。
a := &app{cfg: cfg, log: testLog(), bus: b, plugins: mgr}
wirePanelHub(a)
res, err := mgr.Panels().Dispatch(context.Background(), pluginmgr.ActionRequest{
PluginID: search.PanelPluginID, Action: search.ActionSaveProvider,
Payload: map[string]any{"values": map[string]any{
"provider": search.NameSearXNG, "baseUrl": "http://127.0.0.1:38471", "enabled": true,
}},
})
if err != nil {
t.Fatalf("保存失败: %v", err)
}
outcome, ok := res.(map[string]any)["hotReload"].(webSearchReloadOutcome)
if !ok {
t.Fatalf("响应里没有 hotReload: %#v", res)
}
if outcome.Applied || outcome.Registered {
t.Fatalf("没有注册表可换时必须如实报 applied=false: %+v", outcome)
}
}
// --- ④ 用户真正拿到的那一面:管理端点(HTTP)---------------------------------
// adminActionHTTP 把组合根装配出来的动作端点架起来,返回 base URL 与 owner 令牌。
//
// 网关这一段(鉴权 → 声明 → 分发 → 状态码映射)不是本片改的,但用户拿到的是它:
// 只在动作中枢上验,没法证明「契约里那个 409 真的到得了客户端」。
func adminActionHTTP(t *testing.T, env *reloadTestEnv) (string, string) {
t.Helper()
jwtSvc := auth.New(config.AuthConfig{JWTSecret: obsTestSecret})
gw, err := gateway.New(&config.Config{
Auth: config.AuthConfig{JWTSecret: obsTestSecret},
}, gateway.Deps{Log: testLog(), Auth: jwtSvc, Plugins: env.app.plugins})
if err != nil {
t.Fatalf("gateway.New: %v", err)
}
t.Cleanup(gw.Close)
ts := httptest.NewServer(gw.Handler())
t.Cleanup(ts.Close)
tok, err := jwtSvc.Sign("acct", auth.RoleOwner, time.Hour)
if err != nil {
t.Fatalf("sign token: %v", err)
}
return ts.URL + "/api/v1/admin/plugins/" + search.PanelPluginID + "/actions/" + search.ActionSaveProvider, tok
}
// TestWebSearchHotReloadOverAdminHTTP 核对成功路径的 HTTP 契约:200 信封里的
// `result.hotReload` 必须如实,而且保存后下一次搜索**真的**打到新上游。
func TestWebSearchHotReloadOverAdminHTTP(t *testing.T) {
cloud := newWSFakeUpstream(t, http.StatusOK,
`{"webPages":{"value":[{"name":"博查","url":"https://b.example/1","summary":"正文"}]}}`)
env := newReloadTestEnv(t,
"WEB_SEARCH_PROVIDER="+search.NameBocha,
"WEB_SEARCH_BOCHA_API_KEY=sk-keep",
"WEB_SEARCH_BOCHA_BASE_URL="+cloud.url,
)
url, tok := adminActionHTTP(t, env)
status, raw := wsPostAction(t, url, tok, map[string]any{"values": map[string]any{
"provider": search.NameSearXNG, "baseUrl": cloud.url, "enabled": true,
"primary": search.NameBocha,
}})
if status != http.StatusOK {
t.Fatalf("保存状态 = %d, want 200(body=%s)", status, raw)
}
var body struct {
Data struct {
Action string `json:"action"`
Kind string `json:"kind"`
Result struct {
Saved bool `json:"saved"`
HotReload struct {
Applied bool `json:"applied"`
Registered bool `json:"registered"`
} `json:"hotReload"`
} `json:"result"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &body); err != nil {
t.Fatalf("decode: %v (body=%s)", err, raw)
}
if body.Data.Kind != string(pluginmgr.ActionWrite) {
t.Errorf("kind = %q, want %q(写动作必须按写面鉴权)", body.Data.Kind, pluginmgr.ActionWrite)
}
if !body.Data.Result.Saved || !body.Data.Result.HotReload.Applied || !body.Data.Result.HotReload.Registered {
t.Fatalf("响应 envelope 里的结果不如实: %s", raw)
}
got, err := env.search(t, "cyrene")
if err != nil {
t.Fatalf("HTTP 保存后搜索失败: %v", err)
}
if got["provider"] != search.NameBocha {
t.Fatalf("provider = %v, want %q(HTTP 保存也必须热重载)", got["provider"], search.NameBocha)
}
}
// TestWebSearchSaveOverAdminHTTPRollsBackUnstartable 核对失败路径的 HTTP 契约:
// **非 2xx** + `{error:{code,message}}`,且盘上文件真的回到了保存前。
func TestWebSearchSaveOverAdminHTTPRollsBackUnstartable(t *testing.T) {
cloud := newWSFakeUpstream(t, http.StatusOK,
`{"webPages":{"value":[{"name":"博查","url":"https://b.example/1","summary":"正文"}]}}`)
env := newReloadTestEnv(t,
"WEB_SEARCH_PROVIDER="+search.NameBocha,
"WEB_SEARCH_BOCHA_API_KEY=sk-keep",
"WEB_SEARCH_BOCHA_BASE_URL="+cloud.url,
)
url, tok := adminActionHTTP(t, env)
before := env.rawFile(t)
status, raw := wsPostAction(t, url, tok, map[string]any{"values": map[string]any{
"provider": search.NameBocha, "apiKey": "",
}})
if status != http.StatusConflict {
t.Fatalf("状态 = %d, want 409(body=%s)", status, raw)
}
var body struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(raw, &body); err != nil {
t.Fatalf("decode error body: %v (body=%s)", err, raw)
}
if body.Error.Code != actionCodeConfigUnstartable {
t.Fatalf("错误码 = %q, want %q(body=%s)", body.Error.Code, actionCodeConfigUnstartable, raw)
}
if strings.Contains(body.Error.Message, "sk-keep") || body.Error.Message == "" {
t.Fatalf("错误文案 = %q(不得为空、不得含凭据)", body.Error.Message)
}
if got := env.rawFile(t); !bytes.Equal(got, before) {
t.Fatalf("HTTP 层的回滚不彻底:\nbefore=%s\nafter =%s", before, got)
}
}
// wsPostAction 向管理动作端点发一次 POST(Bearer + JSON 体),返回状态码与响应体。
func wsPostAction(t *testing.T, url, token string, payload any) (int, []byte) {
t.Helper()
raw, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal payload: %v", err)
}
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(raw))
if err != nil {
t.Fatalf("new request: %v", err)
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("POST %s: %v", url, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read %s: %v", url, err)
}
return resp.StatusCode, body
}
+283
View File
@@ -0,0 +1,283 @@
package main
// 本文件是「搜索提供商」页表单里**文件级字段**的解析与落盘(切片 W3;
// `DECISIONS-2026-10-06` 裁决 #2:声明面必须能配「谁先用」——`primary` + `fallback[]`;
// 后续切片又加了 `maxResults`:结果条数上限也得能在面板上改)。
//
// 它们与其余字段(provider / apiKey / baseUrl / enabled)的区别:
//
// - 其余字段描述**一条 provider 条目**(providers[] 里的一个元素);
// - 这三个描述**整份配置**:`primary` / `fallback` 是 design/27 §3 的「主后端 + 降级链」,
// `maxResults` 是单次检索的条数上限 —— 三者都是可变源顶层的同名键。
//
// 为什么挂在**同一个** form 区块里,而不是另开一个:前端的「带回表单」(reauth)只在面板
// **恰好一个** form 区块时转交(`frontend/web/src/admin/plugin/PluginPanel.tsx` 的
// `singleForm`),加第二个 form 会让既有的「重配凭据」回填整条失灵。因此这些项作为文件级
// 字段进既有表单,回填值由 `reauthProvider` 一并给出(见 addSelectionValues 的调用点)。
//
// 字段 key 刻意不叫 `provider`:表单里的 `provider` 已经是「这一条 provider 的名字」,
// 两个语义不同的东西共用一个 key 会让「我在改谁」变成一句要猜的话。
import (
"fmt"
"math"
"net/http"
"strconv"
"strings"
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
)
// 表单里三个文件级字段的 key 由**声明侧**给出(search.FieldPrimary / FieldFallback /
// FieldMaxResults):它们必须与 panel.go 里写下的字段声明逐字一致(本文件读的正是这些 key),
// 而「声明里写一个 key、处理器里读另一个」这类漂移只有共用一个常量才拦得住 ——
// 在 cmd/cyrene 里再写一遍字面量就等于第二份真源。
const (
// selectionPrimaryKey 是主 provider 的字段 key(落到文件级 `provider`)。
selectionPrimaryKey = search.FieldPrimary
// selectionFallbackKey 是降级链的字段 key(落到文件级 `fallback`)。
selectionFallbackKey = search.FieldFallback
// selectionMaxResultsKey 是结果条数上限的字段 key(落到文件级 `maxResults`)。
selectionMaxResultsKey = search.FieldMaxResults
)
// actionCodePrimaryNotConfigured 报告「选中的主 provider 在本文件里还没配齐」——
// 这不是一次可以放着不管的保存:那份配置会让后端**下次启动直接失败**(design/27 §3 的
// fail-fast),所以在写盘之前就拒掉,而不是先写进盘里等着下次起不来。
const actionCodePrimaryNotConfigured = "primary_not_configured"
// mergeSelectionPatch 把表单里的三个**文件级**字段(主 provider / 降级链 / 结果条数上限)
// 合并进文件(三态:**缺位 = 不改动**),并回报「这次有没有给出 `primary`」
// —— **校验不在这里**。
//
// 与其余字段同一套哨兵语义(websearch_action_data.go 的 saveProvider 注释):
// `primary` 缺位 ⇒ 保留盘上的;给了值(含显式空串 / off / none)⇒ 采用 —— 空串与
// `off`/`none` 是本项目**唯一一套**关闭语义(config.IsWebSearchOffSentinel),面板不另造一套。
//
// `maxResults` 的第三态**比另外两个严**:它没有「空串 = 关闭」这种解释,因此 ≤0 与非整数
// 一律 400(口径与理由见 selectionMaxResults)—— 那正是这一片要消灭的「我改了它却没变」。
//
// 🔴 **顺序硬约束(缺陷修复 2026-10-07,真机报障)**:主 provider 的可用性校验必须由调用方
// 在「**本次这条 provider 条目已经并进文件之后**」做(`checkPrimaryUsable(final, …)`)。
// 本函数此前自己顺手校验,用的是**盘上的旧文件** ⇒ 面板上第一次给某家填 key、又在**同一次
// 提交**里把它选为主 provider 时,校验看不到那个还没落盘的 key,于是整笔(**连同刚填的 key**)
// 被 400 `primary_not_configured` 回滚 —— 界面上表现为「怎么填都存不进去」,而盘上文件
// mtime 一动不动(2026-10-07 09:50 日志连续两次 `plugin.saveProvider … code=primary_not_configured`)。
// 调用点见 websearch_action_data.go 的 saveProvider:先 `UpsertProviderEntry`,再校验,最后写盘。
func (e webSearchActionEnv) mergeSelectionPatch(
f runtimeconfig.WebSearchFile, values map[string]any,
) (runtimeconfig.WebSearchFile, bool, error) {
primary, setPrimary, err := selectionPrimary(values)
if err != nil {
return f, false, err
}
fallback, setFallback, err := selectionFallback(values)
if err != nil {
return f, false, err
}
maxResults, setMaxResults, err := selectionMaxResults(values)
if err != nil {
return f, false, err
}
if setPrimary {
f.Provider = primary
}
if setFallback {
f.Fallback = fallback
}
if setMaxResults {
f.MaxResults = maxResults
}
return f, setPrimary, nil
}
// checkPrimaryUsable 判定一个主 provider 名在**落盘后的文件**里是否可用。
//
// 判定口径与工具接线同源(resolveSearchProviders + resolveBaseURL):云家看 API Key、
// searxng 看接口地址,基地址缺位时回落**该接入位的代码默认基地址**;条目被显式停用
// 视同没配(装配期会跳过它)。判据本身**不放松**:真的没配齐的主家仍必须被拒。
func (e webSearchActionEnv) checkPrimaryUsable(f runtimeconfig.WebSearchFile, name string) error {
// 显式关闭(空串 / off / none)永远合法:它表示「不要这把工具」,工具不进注册面,
// 后端照常启动(与 WEB_SEARCH_PROVIDER 的既有语义逐字一致)。
if config.IsWebSearchOffSentinel(name) {
return nil
}
if err := requireKnownProvider(name); err != nil {
return err
}
entry, found := findProviderEntry(f, name)
if !found || !entry.IsEnabled() {
return primaryNotConfiguredError(name)
}
// 基地址的进位链与装配期**同一个函数**:条目里的值 → 该接入位的默认基地址 → 空。
// 云家的可用性只看 key(地址取默认值也照样能打通),searxng 的默认值是空
// —— 因此「searxng 主家必须有地址」这条判据与既往逐字一致。
if !search.Configured(name, entry.APIKey, resolveBaseURL(name, entry.BaseURL)) {
return primaryNotConfiguredError(name)
}
return nil
}
// primaryNotConfiguredError 造「主家没配齐」这一条的 400:文案与码只有一份
// (两个判据分支共用,免得两处措辞漂移)。
func primaryNotConfiguredError(name string) error {
return pluginmgr.NewActionError(actionCodePrimaryNotConfigured,
fmt.Sprintf("主提供商 %q 还没配齐(缺 API Key / 接口地址,或这条被停用了):"+
"先把它配好再选它 —— 否则这份配置会让后端下一次启动直接失败", name),
http.StatusBadRequest, nil)
}
// selectionNames 返回降级链的线形状(**非 nil 空切片**:nil 会在 JSON 里变成 null,
// 而「一条降级都没有」与「这个字段没值」在前端不该长得不一样)。
func selectionNames(names []string) []string {
if names == nil {
return []string{}
}
return names
}
// addSelectionValues 把盘上的**文件级取值**回填进 `reauth` 的 values。
//
// 回填的必要性在前端的三态写回里:只有 `initial` 有值,用户「清空降级链」才会被送成
// 显式空(`planSubmit`),否则会落成「缺位 = 不改动」——「我清掉了它」就成了一句假话。
// `maxResults` 同理:不回填的话表单里那个框是空的,用户看不见当前上限、也就无从改起。
func addSelectionValues(values map[string]any, f runtimeconfig.WebSearchFile) {
values[selectionPrimaryKey] = f.Provider
fb := f.Fallback
if fb == nil {
fb = []string{}
}
values[selectionFallbackKey] = fb
// 上限只在盘上**真的给过**时才回填(`maxResults` 的 0 = 未声明):回填一个 0 会被前端
// 当成一个值送回来,而写侧拒 ≤0 —— 用户「什么都没改」的一次保存就整笔进不去了。
if f.MaxResults > 0 {
values[selectionMaxResultsKey] = f.MaxResults
}
}
// selectionMaxResults 解析结果条数上限字段(落点:文件级 `maxResults`)。**三态**:
//
// - 缺位 / JSON null ⇒ 不改动;
// - 正整数 ⇒ 采用(落盘后由热重载事务接上工具,下一次搜索就用它);
// - 其余(0、负数、小数、空串、非数字字符串、布尔、数组…)⇒ **400 invalid_payload**。
//
// 为什么第三态必须报错,而不是像 `enabled` 那样「空串 = 未改动」:这一片的来由正是
// 「我改了它却没变」—— 手工塞进提交体的上限被写侧静默丢弃(文件不变、也不报错)。既然
// 面板上给了这个输入框,任何**给了值却没被采用**的提交都必须说得出原因;否则用户以为上限
// 是 20,跑的还是 10。0 / 负数在这里也没有第二种解释:文件形状里 `maxResults` 的 0 =
// 「未声明」(`omitempty` 不落盘),把 0 当「去掉上限」会让「去掉」与「从没配过」长得一模一样。
func selectionMaxResults(values map[string]any) (int, bool, error) {
raw, ok := values[selectionMaxResultsKey]
if !ok || raw == nil {
return 0, false, nil
}
n, ok := wholeNumber(raw)
if !ok || n < 1 {
// %q + Sprint:字符串会带引号(空串才看得见),数字/布尔照原样。
return 0, false, invalidPayloadActionError(fmt.Sprintf(
"%s 必须是正整数(≥1),收到 %q:这个框不能靠留空或 0 去掉上限,请填一个具体条数",
selectionMaxResultsKey, fmt.Sprint(raw)))
}
return n, true, nil
}
// wholeNumber 把一个补丁值还原成整数:JSON 数字(float64,必须**没有小数部分**)、Go 侧构造的
// int、以及整数字符串(与 `wire_browser_panel_data.go` 的 patchInt 同一取舍:某些控件会把值以
// 字符串送出)。其余类型一律拒 —— 不做静默转换,那正是本片要消灭的那类假成功。
func wholeNumber(raw any) (int, bool) {
switch v := raw.(type) {
case float64:
if v != math.Trunc(v) {
return 0, false
}
return int(v), true
case int:
return v, true
case string:
n, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil {
return 0, false
}
return n, true
default:
return 0, false
}
}
// selectionPrimary 解析主 provider 字段:缺位 ⇒ (…, false, nil);非字符串 ⇒ 400。
func selectionPrimary(values map[string]any) (string, bool, error) {
raw, ok := values[selectionPrimaryKey]
if !ok || raw == nil {
return "", false, nil
}
s, ok := raw.(string)
if !ok {
return "", false, invalidPayloadActionError(
fmt.Sprintf("%s 必须是字符串(提供商名)", selectionPrimaryKey))
}
return normalizeProviderName(s), true, nil
}
// selectionFallback 解析降级链字段:缺位 ⇒ (…, false, nil);形状不对 / 名字未登记 ⇒ 400。
func selectionFallback(values map[string]any) ([]string, bool, error) {
raw, ok := values[selectionFallbackKey]
if !ok || raw == nil {
return nil, false, nil
}
items, err := selectionItems(raw)
if err != nil {
return nil, false, err
}
out := make([]string, 0, len(items))
seen := make(map[string]bool, len(items))
for _, item := range items {
name := normalizeProviderName(item)
if name == "" || seen[name] {
continue
}
// 未登记的家存进降级链也永远不会被选中(注册表按名字取)⇒ 这里就拒,与
// 条目保存的 requireKnownProvider 同一个码与同一句文案。
if err := requireKnownProvider(name); err != nil {
return nil, false, err
}
seen[name] = true
out = append(out, name)
}
return out, true, nil
}
// selectionItems 把多选值规范化成字符串切片。
//
// 三种形状都接受:
// - `[]string`(Go 侧直接构造的载荷,测试与内部调用用);
// - `[]any`(JSON 解码后的数组 —— 前端 multiSelect 送的就是它);
// - 逗号 / 顿号 / 换行分隔的字符串(与前端控件「逗号分隔」的呈现同源,人工 curl 友好)。
//
// 其余类型一律 400:静默当空数组 = 一次保存悄悄清空降级链(与 apiKey 的类型错同一条口径)。
func selectionItems(raw any) ([]string, error) {
switch v := raw.(type) {
case []string:
return v, nil
case []any:
out := make([]string, 0, len(v))
for i, item := range v {
s, ok := item.(string)
if !ok {
return nil, invalidPayloadActionError(
fmt.Sprintf("%s[%d] 必须是字符串(提供商名)", selectionFallbackKey, i))
}
out = append(out, s)
}
return out, nil
case string:
return strings.FieldsFunc(v, func(r rune) bool {
return r == ',' || r == ',' || r == '、' || r == ';' || r == '\n'
}), nil
default:
return nil, invalidPayloadActionError(
fmt.Sprintf("%s 必须是字符串数组(如 [\"bocha\",\"zhipu\"])", selectionFallbackKey))
}
}
+328
View File
@@ -0,0 +1,328 @@
package main
// 本文件钉住切片 W3 的另外三条判据(`DECISIONS-2026-10-06` 裁决 #2 / 点 ③ / 点 ④):
//
// - **主 provider / 降级链可配**:两个文件级字段(`primary` / `fallback`)经表单落到
// 可变源,回填(reauth)也带着它们;没配齐的主家**写盘之前**就拒掉;
// - **主 provider 解析口径**(配了可变源 / 只配 env / 都没配)各选谁;
// - **探针失败如实**:非 2xx + 结构化码,不存在「200 带 ok:false」这条路径。
//
// 夹具与假上游在 websearch_testenv_test.go;热重载本体在 websearch_reload_test.go。
import (
"bytes"
"context"
"encoding/json"
"net/http"
"strings"
"testing"
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
)
// --- ① 面板校验:选一个没配齐的主家当场拒掉 ---------------------------------
// TestWebSearchSelectionRejectsUnusablePrimary 钉住「写盘之前就拦」:主 provider 选了
// 一家没配齐的(缺 key)必须回 400 primary_not_configured,且**盘上一个字节都没变**
// (这不是「存下来再说」,那份配置会让后端下次起不来)。
func TestWebSearchSelectionRejectsUnusablePrimary(t *testing.T) {
env := newReloadTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
before := env.rawFile(t)
_, err := env.save(map[string]any{
"provider": search.NameSearXNG, "enabled": true, "primary": search.NameZhipu,
})
ae, ok := pluginmgr.AsActionError(err)
if !ok || ae.Code != actionCodePrimaryNotConfigured || ae.Status != http.StatusBadRequest {
t.Fatalf("err = %#v, want 400 %s", err, actionCodePrimaryNotConfigured)
}
if got := env.rawFile(t); !bytes.Equal(got, before) {
t.Fatalf("被拒的保存动了盘上的文件:\nbefore=%s\nafter =%s", before, got)
}
// 未登记的名字:与条目级保存同一个码同一句口径。
_, err = env.save(map[string]any{
"provider": search.NameSearXNG, "enabled": true, "primary": "tavily",
})
if ae, ok := pluginmgr.AsActionError(err); !ok || ae.Code != actionCodeUnknownProvider {
t.Fatalf("err = %#v, want %s", err, actionCodeUnknownProvider)
}
// 降级链里的未登记名字同样拒(它永远不会被选中,存下去就是假话)。
_, err = env.save(map[string]any{
"provider": search.NameSearXNG, "enabled": true, "fallback": []any{"tavily"},
})
if ae, ok := pluginmgr.AsActionError(err); !ok || ae.Code != actionCodeUnknownProvider {
t.Fatalf("err = %#v, want %s", err, actionCodeUnknownProvider)
}
// 形状错(不是数组)⇒ 400,绝不静默当空数组清空降级链。
_, err = env.save(map[string]any{
"provider": search.NameSearXNG, "enabled": true, "fallback": 42,
})
if ae, ok := pluginmgr.AsActionError(err); !ok || ae.Code != pluginmgr.ActionCodeInvalidPayload {
t.Fatalf("err = %#v, want %s", err, pluginmgr.ActionCodeInvalidPayload)
}
}
// TestWebSearchSelectionAcceptsConfiguredCloudFallback 钉住「降级链允许放还没配齐的家」:
// 未配置的家在运行期会被跳过(不是错误),所以面板存得下去;而主家必须是配齐的。
// 同时钉住归一化:去空白、去重、丢空项。
func TestWebSearchSelectionAcceptsConfiguredCloudFallback(t *testing.T) {
up := newWSFakeSearxng(t, "a")
env := newReloadTestEnv(t,
"WEB_SEARCH_PROVIDER="+search.NameSearXNG,
"WEB_SEARCH_ENDPOINT="+up.url,
)
env.mustSave(t, map[string]any{
"provider": search.NameSearXNG, "baseUrl": up.url, "enabled": true,
"fallback": []any{search.NameZhipu, " bocha ", search.NameZhipu, ""},
})
if got := env.loadFile(t).Fallback; strings.Join(got, ",") != search.NameZhipu+","+search.NameBocha {
t.Fatalf("降级链落盘 = %v, want [zhipu bocha]", got)
}
}
// TestWebSearchReauthEchoesSelection:表单回填必须带上两个文件级字段,否则用户
// 「清空降级链」在前端的三态写回里会落成「缺位 = 不改动」。
func TestWebSearchReauthEchoesSelection(t *testing.T) {
up := newWSFakeSearxng(t, "a")
env := newReloadTestEnv(t,
"WEB_SEARCH_PROVIDER="+search.NameSearXNG,
"WEB_SEARCH_ENDPOINT="+up.url,
)
env.mustSave(t, map[string]any{
"provider": search.NameSearXNG, "baseUrl": up.url, "enabled": true,
"primary": search.NameSearXNG, "fallback": []any{search.NameBocha},
})
out, err := env.hub.Dispatch(context.Background(), pluginmgr.ActionRequest{
PluginID: search.PanelPluginID, Action: search.ActionReauthProvider,
Payload: map[string]any{"row": map[string]any{"provider": search.NameSearXNG}},
})
if err != nil {
t.Fatalf("reauth: %v", err)
}
values, _ := out.(map[string]any)["values"].(map[string]any)
if values == nil {
t.Fatalf("reauth 结果缺 values: %#v", out)
}
if values[search.FieldPrimary] != search.NameSearXNG {
t.Fatalf("回填的 primary = %v, want %q", values[search.FieldPrimary], search.NameSearXNG)
}
fb, ok := values[search.FieldFallback].([]string)
if !ok || strings.Join(fb, ",") != search.NameBocha {
t.Fatalf("回填的 fallback = %#v, want [%s]", values[search.FieldFallback], search.NameBocha)
}
}
// --- ② 主 provider 解析口径(三情形 + 清空)---------------------------------
// TestWebSearchPrimaryResolutionFromEnvOnly:**只配 `.env`**(可变源文件缺失)⇒ 选 `.env`
// 那家,来源标 `env`,且播种真的落了盘。
func TestWebSearchPrimaryResolutionFromEnvOnly(t *testing.T) {
var out bytes.Buffer
cfg := wsLoadConfig(t,
"WEB_SEARCH_PROVIDER="+search.NameBocha,
"WEB_SEARCH_BOCHA_API_KEY=sk-env",
)
wsSurfaceApp(t, cfg, &out)
if got := wsLogField(t, out.String(), "primary"); got != search.NameBocha {
t.Fatalf("primary = %q, want %q(.env 是唯一来源)", got, search.NameBocha)
}
if got := wsLogField(t, out.String(), "configSource"); got != webSearchSourceEnv {
t.Fatalf("configSource = %q, want %q", got, webSearchSourceEnv)
}
if _, found, err := runtimeconfig.LoadWebSearch(wsPanelPath(cfg)); err != nil || !found {
t.Fatalf("文件缺失时必须按 .env 播种落盘: found=%v err=%v", found, err)
}
}
// TestWebSearchPrimaryResolutionPanelWins:**配了可变源** ⇒ 选文件里那家(`.env` 说的
// 另一家不作数),来源标 panel。
func TestWebSearchPrimaryResolutionPanelWins(t *testing.T) {
var out bytes.Buffer
cfg := wsLoadConfig(t,
"WEB_SEARCH_PROVIDER="+search.NameZhipu,
"WEB_SEARCH_ZHIPU_API_KEY=sk-env-zhipu",
)
if err := runtimeconfig.SaveWebSearch(wsPanelPath(cfg), runtimeconfig.WebSearchFile{
Version: runtimeconfig.WebSearchVersion,
Provider: search.NameBocha,
Providers: []runtimeconfig.ProviderEntry{
{Provider: search.NameBocha, APIKey: "sk-panel", Enabled: runtimeconfig.EnableFlag(true)},
},
}); err != nil {
t.Fatalf("写面板配置: %v", err)
}
a := wsSurfaceApp(t, cfg, &out)
if !wsHasTool(a, webSearchToolName) {
t.Fatalf("面板里 bocha 已配齐,工具应在注册面里: %v", toolNames(a))
}
if got := wsLogField(t, out.String(), "primary"); got != search.NameBocha {
t.Fatalf("primary = %q, want %q(面板文件是唯一来源,.env 说的 zhipu 不作数)", got, search.NameBocha)
}
if got := wsLogField(t, out.String(), "configSource"); got != webSearchSourcePanel {
t.Fatalf("configSource = %q, want %q", got, webSearchSourcePanel)
}
}
// TestWebSearchPrimaryResolutionDefaultsToSearxng:**都没配** ⇒ 代码默认 searxng
// (本机兜底端点),工具照常在注册面里。
func TestWebSearchPrimaryResolutionDefaultsToSearxng(t *testing.T) {
var out bytes.Buffer
cfg := wsLoadConfig(t)
a := wsSurfaceApp(t, cfg, &out)
if !wsHasTool(a, webSearchToolName) {
t.Fatalf("都没配时代码默认(searxng 本机兜底)应当生效: %v", toolNames(a))
}
if got := wsLogField(t, out.String(), "primary"); got != search.NameSearXNG {
t.Fatalf("primary = %q, want %q(代码默认值)", got, search.NameSearXNG)
}
}
// TestWebSearchPrimaryResolutionClearedFileMeansOff:文件里把 provider 清空 ⇒ **关闭**
// (不是回落 `.env`)——「清空 = 关」是文件形状里写死的语义,面板与 `.env` 不许有两套。
func TestWebSearchPrimaryResolutionClearedFileMeansOff(t *testing.T) {
var out bytes.Buffer
cfg := wsLoadConfig(t,
"WEB_SEARCH_PROVIDER="+search.NameBocha,
"WEB_SEARCH_BOCHA_API_KEY=sk-env",
)
if err := runtimeconfig.SaveWebSearch(wsPanelPath(cfg), runtimeconfig.WebSearchFile{
Version: runtimeconfig.WebSearchVersion,
// 显式清空主 provider:哨兵语义 = 关掉整把工具。
Provider: "off",
Providers: []runtimeconfig.ProviderEntry{
{Provider: search.NameBocha, APIKey: "sk-panel", Enabled: runtimeconfig.EnableFlag(true)},
},
}); err != nil {
t.Fatalf("写面板配置: %v", err)
}
a := wsSurfaceApp(t, cfg, &out)
if wsHasTool(a, webSearchToolName) {
t.Fatalf("文件里显式关闭(off)⇒ 工具不得进注册面: %v", toolNames(a))
}
if out := out.String(); !strings.Contains(out, "web-search tool not registered") {
t.Fatalf("关闭必须留下可诊断的 WARN: %q", out)
}
}
// --- ③ 探针失败如实:非 2xx + 结构化码,绝不「200 带 ok:false」----------------
// TestWebSearchProbeFailuresAreStructuredNon2xx 钉住裁决 #4(点 ④):每一次探针失败都必须是
// **结构化非 2xx**。前端行内动作只在失败时显示文案,把「探不通」放进 200 的 data 里
// 等于让界面报一句假成功。
//
// 探针的成功/失败分类由 search.Probe 给出(那里已有单测),这里查的是**动作面**有没有
// 把失败原样交出去:码稳定、状态码落在 4xx/5xx、文案不带上游原文。
func TestWebSearchProbeFailuresAreStructuredNon2xx(t *testing.T) {
cases := []struct {
name string
status int
body string
wantCode string
want int
}{
{"凭据被拒", http.StatusUnauthorized, `{"error":"bad key sk-leak"}`, search.ProbeUnauthorized, http.StatusBadGateway},
{"被限流", http.StatusTooManyRequests, `{}`, search.ProbeRateLimited, http.StatusBadGateway},
{"上游 5xx", http.StatusInternalServerError, `{}`, search.ProbeUpstream, http.StatusBadGateway},
{"应答不是预期结构", http.StatusOK, `not json`, search.ProbeBadResponse, http.StatusBadGateway},
{"连通但零结果", http.StatusOK, `{"results":[]}`, search.ProbeNoResults, http.StatusBadGateway},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
up := newWSFakeUpstream(t, tc.status, tc.body)
env := newReloadTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
env.mustSave(t, map[string]any{
"provider": search.NameSearXNG, "baseUrl": up.url, "enabled": true,
})
out, err := env.hub.Dispatch(context.Background(), pluginmgr.ActionRequest{
PluginID: search.PanelPluginID, Action: search.ActionTestProvider,
Payload: map[string]any{"row": map[string]any{"provider": search.NameSearXNG}},
})
if err == nil {
// 「200 带 ok:false」正是这条判据要堵的路:要么成功(ok=true 的结果),
// 要么结构化失败,没有第三种。
if res, ok := out.(search.ProbeResult); ok && !res.OK {
t.Fatalf("探针失败却回了成功响应(200 + ok:false):%+v", res)
}
t.Fatalf("探针失败却回了 200(结果 = %#v)", out)
}
ae, ok := pluginmgr.AsActionError(err)
if !ok {
t.Fatalf("err = %v,期望结构化 ActionError", err)
}
if ae.Code != tc.wantCode {
t.Fatalf("失败码 = %q, want %q", ae.Code, tc.wantCode)
}
if ae.Status != tc.want {
t.Fatalf("失败状态 = %d, want %d(非 2xx 才算如实)", ae.Status, tc.want)
}
raw, _ := json.Marshal(map[string]any{"code": ae.Code, "message": ae.Message})
if strings.Contains(string(raw), "sk-leak") {
t.Fatalf("失败文案泄漏了上游原文: %s", raw)
}
})
}
}
// TestWebSearchProbeNotConfiguredIs400:没配齐是**调用方/配置**的问题(400),
// 不是上游不可用(502)—— 前端据此区分「该改配置」还是「该重试」。
func TestWebSearchProbeNotConfiguredIs400(t *testing.T) {
env := newReloadTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
_, err := env.hub.Dispatch(context.Background(), pluginmgr.ActionRequest{
PluginID: search.PanelPluginID, Action: search.ActionTestProvider,
Payload: map[string]any{"row": map[string]any{"provider": search.NameZhipu}},
})
ae, ok := pluginmgr.AsActionError(err)
if !ok || ae.Code != search.ProbeNotConfigured || ae.Status != http.StatusBadRequest {
t.Fatalf("err = %#v, want 400 %s", err, search.ProbeNotConfigured)
}
}
// TestWebSearchSaveProviderAndPrimaryInOneSubmit 是 2026-10-07 真机报障的回归:
// 面板上第一次给某家填 key、又在**同一次提交**里把它选为主 provider,必须**一次成功**。
//
// 缺陷原形:主 provider 的可用性校验拿的是**盘上的旧文件**(那条 provider 还没 key)
// ⇒ 整笔(**连同刚填的 key**)被 400 primary_not_configured 回滚,界面上表现为
// 「怎么填都存不进去」,而盘上文件 mtime 一动不动。修法:校验移到「本次条目已并进
// 文件之后」(websearch_action_data.go 的 saveProvider 先 UpsertProviderEntry 再 check)。
func TestWebSearchSaveProviderAndPrimaryInOneSubmit(t *testing.T) {
env := newReloadTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
// 前置:盘上那条 zhipu 没有 key —— 面板第一次配置它时正是这个状态。
if e, ok := findProviderEntry(env.loadFile(t), search.NameZhipu); !ok || strings.TrimSpace(e.APIKey) != "" {
t.Fatalf("前置不成立:盘上 zhipu 应存在且尚无 key(found=%v, key=%q)", ok, e.APIKey)
}
if _, err := env.save(map[string]any{
"provider": search.NameZhipu, "apiKey": "test-key-001", "enabled": true,
"primary": search.NameZhipu,
}); err != nil {
t.Fatalf("同一次提交里填 key + 选它当主家被拒(键与选择必须能一起落盘): %v", err)
}
after := env.loadFile(t)
if got := strings.ToLower(strings.TrimSpace(after.Provider)); got != search.NameZhipu {
t.Fatalf("盘上 primary = %q, want %q", got, search.NameZhipu)
}
entry, ok := findProviderEntry(after, search.NameZhipu)
if !ok || entry.APIKey != "test-key-001" {
t.Fatalf("盘上 zhipu 条目 = %+v, want 带本次填的 key", entry)
}
// 反向护栏:**真的没配齐**的主家仍必须被拒,且盘上一个字节都不动。
// (本次修复只改「校验看哪一份文件」,不改判据。)
beforeReject := env.rawFile(t)
_, err := env.save(map[string]any{
"provider": search.NameBocha, "enabled": true, "primary": search.NameBocha,
})
ae, ok := pluginmgr.AsActionError(err)
if !ok || ae.Code != actionCodePrimaryNotConfigured {
t.Fatalf("err = %#v, want 400 %s(bocha 这次没填 key)", err, actionCodePrimaryNotConfigured)
}
if got := env.rawFile(t); !bytes.Equal(got, beforeReject) {
t.Fatalf("被拒的保存动了盘上的文件:\nbefore=%s\nafter =%s", beforeReject, got)
}
}
+211
View File
@@ -0,0 +1,211 @@
package main
// 本文件是切片 W3 那几组测试的**夹具**(`newReloadTestEnv` / 假上游 / 日志字段读取):
// 单独成一个测试文件,是为了让三个主题文件(热重载、面板选择字段、解析口径与探针)
// 各自都在 AGENTS §10 的文件行数之内,且共享的接线只写一份。
//
// 夹具的关键差别(相对 wire_plugin_actions_test.go 的 actionTestEnv):它带
// `a.caps` / `a.dispatcher` —— 热重载换的就是注册表里的条目,没有能力面的夹具
// 根本证明不了这件事(那正是 L1/PLG-2 当时如实登记的缺口)。
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
)
// reloadTestEnv 是一个「组合根真的接好了能力面 + 面板动作面」的夹具。
type reloadTestEnv struct {
app *app
hub *pluginmgr.Panels
disp *tool.Dispatcher
path string
out *bytes.Buffer
}
func newReloadTestEnv(t *testing.T, extra ...string) *reloadTestEnv {
t.Helper()
cfg := wsLoadConfig(t, extra...)
b := bus.New()
t.Cleanup(func() { _ = b.Close() })
mgr := pluginmgr.NewManager(registry.New(), filepath.Join(cfg.DataDir, "plugins.json"), testLog())
t.Cleanup(func() { mgr.Close(context.Background()) })
var out bytes.Buffer
caps := registry.New()
disp, err := tool.New(caps)
if err != nil {
t.Fatalf("tool.New: %v", err)
}
a := &app{
cfg: cfg,
log: logger.New(logger.Options{Out: &out, Level: "debug"}),
bus: b,
plugins: mgr,
caps: caps,
dispatcher: disp,
}
// 启动期那条路(与 wsSurfaceApp 同源):ioToolOptions → iotools.New → 注册面。
set, err := iotools.New(cfg, a.ioToolOptions()...)
if err != nil {
t.Fatalf("iotools.New: %v", err)
}
for _, tl := range set {
if rerr := disp.Register(tl); rerr != nil {
t.Fatalf("register %s: %v", tl.Name(), rerr)
}
}
wirePanelHub(a)
if mgr.Panels() == nil {
t.Fatal("wirePanelHub 未把面板中枢接到插件管理器上")
}
return &reloadTestEnv{
app: a, hub: mgr.Panels(), disp: disp,
path: runtimeconfig.WebSearchConfigPath(cfg.DataDir), out: &out,
}
}
// save 走保存动作(表单形状 `{values:{...}}`),返回结果的**原始 any 与错误**。
func (e *reloadTestEnv) save(values map[string]any) (any, error) {
return e.hub.Dispatch(context.Background(), pluginmgr.ActionRequest{
PluginID: search.PanelPluginID,
Action: search.ActionSaveProvider,
Payload: map[string]any{"values": values},
})
}
// mustSave 要求保存成功,并返回响应里的 `hotReload` 块(缺了就失败:它是本片新增的
// 「这次保存有没有热重载」的可机读事实)。
func (e *reloadTestEnv) mustSave(t *testing.T, values map[string]any) webSearchReloadOutcome {
t.Helper()
res, err := e.save(values)
if err != nil {
t.Fatalf("保存失败: %v", err)
}
m, ok := res.(map[string]any)
if !ok {
t.Fatalf("保存结果类型 = %T", res)
}
outcome, ok := m["hotReload"].(webSearchReloadOutcome)
if !ok {
t.Fatalf("响应里没有 hotReload(热重载没被接线): %#v", m)
}
return outcome
}
// search 走**分派器**(模型真正调工具的那条路)打一次 web-search,返回线格式结果。
func (e *reloadTestEnv) search(t *testing.T, query string) (map[string]any, error) {
t.Helper()
got, err := e.disp.Invoke(context.Background(), webSearchToolName, map[string]any{"query": query})
if err != nil {
return nil, err
}
m, ok := got.(map[string]any)
if !ok {
t.Fatalf("工具返回值类型 = %T", got)
}
return m, nil
}
// toolValue 取注册面里那一把 web-search 的**当前**值(不在注册面里就失败)。
func (e *reloadTestEnv) toolValue(t *testing.T) any {
t.Helper()
cap, err := e.app.caps.Get(webSearchToolName)
if err != nil {
t.Fatalf("web-search 不在注册面里: %v(工具面 = %v)", err, toolNames(e.app))
}
return cap.Value
}
// rawFile 读可变源的**原始字节**(「字节级没变」是回滚判据里最强的那一种)。
func (e *reloadTestEnv) rawFile(t *testing.T) []byte {
t.Helper()
raw, err := os.ReadFile(e.path)
if err != nil {
t.Fatalf("read %s: %v", e.path, err)
}
return raw
}
// loadFile 读盘上的可变源。
func (e *reloadTestEnv) loadFile(t *testing.T) runtimeconfig.WebSearchFile {
t.Helper()
f, found, err := runtimeconfig.LoadWebSearch(e.path)
if err != nil || !found {
t.Fatalf("LoadWebSearch(%s): found=%v err=%v", e.path, found, err)
}
return f
}
// wsFakeUpstream 是一个假搜索上游:记下每一次命中,按预设状态码与响应体作答。
type wsFakeUpstream struct {
url string
hits chan *http.Request
}
func newWSFakeUpstream(t *testing.T, status int, body string) *wsFakeUpstream {
t.Helper()
hits := make(chan *http.Request, 8)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits <- r
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
t.Cleanup(srv.Close)
return &wsFakeUpstream{url: srv.URL, hits: hits}
}
// newWSFakeSearxng 是一个「答一条结果」的 searxng 假上游(成功路径用)。
func newWSFakeSearxng(t *testing.T, title string) *wsFakeUpstream {
t.Helper()
return newWSFakeUpstream(t, http.StatusOK,
`{"results":[{"title":"`+title+`","url":"http://x.example/1","content":"`+title+`"}]}`)
}
// drain 清掉已经排队的命中(用于「重载之后打的是不是新那家」的判定)。
func (f *wsFakeUpstream) drain() {
for {
select {
case <-f.hits:
default:
return
}
}
}
// noHit 断言此刻没有新的命中(重载后**不该**再打到旧那家)。
func (f *wsFakeUpstream) noHit(t *testing.T) {
t.Helper()
select {
case r := <-f.hits:
t.Fatalf("旧上游又收到一个请求(%s)—— 重载没真的换掉工具", r.URL)
default:
}
}
// wsLogField 从启动日志里取一个字段值(slog TextHandler 的 `key=value`)。
func wsLogField(t *testing.T, out, key string) string {
t.Helper()
re := regexp.MustCompile(`(?:^|\s)` + regexp.QuoteMeta(key) + `=(\S+)`)
m := re.FindStringSubmatch(out)
if m == nil {
t.Fatalf("启动日志里找不到 %s=…: %q", key, out)
}
return strings.Trim(m[1], `"`)
}
+224
View File
@@ -0,0 +1,224 @@
package main
import (
"context"
"database/sql"
"fmt"
"log/slog"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/mcp"
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/transcode"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
"git.yeij.top/AskaEth/Cyrene_v2/internal/iot"
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/emotion"
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/events"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/exec"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/flowgraph"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/projectindex"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/vector"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/task"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/automations"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/backthink"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/channelbridge"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
)
// defaultOwnerPath 与 defaultPersonasDir 指向随仓库分发的磁盘上人格/主人定义,
// 路径相对工作目录。
const (
defaultOwnerPath = "configs/users/owner.yaml"
defaultPersonasDir = "configs/personas"
defaultSQLitePath = "./data/cyrene.db"
fallbackInstruction = "You are Cyrene, a warm and attentive companion."
// 上界与旋钮常量。编译期定值,运行时路径上不出现裸魔法数字(项目规范 §10)。
defaultMaxRounds = 8
defaultMaxParallel = 4
defaultAgentID = "orchestrator"
defaultToolTimeout = 30 * time.Second
)
// app 持有单二进制 Cyrene 组装的全部子系统:由 newApp 构建,关闭时由 close 拆除。
type app struct {
cfg *config.Config
log *slog.Logger
db *sql.DB
dbPath string
bus bus.Bus
modelAdapter model.Adapter
llm *model.RuntimeLLM
personas *personareg.Registry
caps *registry.Registry
// transcoder 是本地转码引擎(design/25):进程内、同步、纯本地,供渠道与其它
// 模块经依赖注入调用;同一个引擎也以 `transcode` 工具注册进 caps。装配只告警、
// 绝不阻塞启动,见 wire_transcode.go。
transcoder transcode.Transcoder
dispatcher *tool.Dispatcher
memStore *memory.Store
retriever *memory.Retriever
// embedder 与 memoryTidy 属于记忆整理(design/23):整理只在 embedder
// 上算召回相似度,绝不接触会话/历史读数。
embedder embedding.Embedder
memoryTidy *memory.TidyService
// memoryReview 是 D3「入库前审查」(design/23 §15.1 D3):新记忆落库后由
// 独立的记忆管理会话判定 merge/modify/create/skip。它同样只依赖记忆存储
// 与 embedder,拿不到任何会话/历史读数。未启用时为 nil(写入路径不变)。
memoryReview *memory.PreInsertReviewer
projStore *projectindex.Store
loop *loop.Loop
scheduler *orchestration.Scheduler
tasks *task.Manager
execProv exec.Provider
iot *iot.Manager
voice *voice.Chain
mcpRegs []*mcp.Registry
plugins *pluginmgr.Manager
sessions *session.SessionManager
automation *automations.Service
backthink *backthink.Backthink
vindex vector.Index
owner *user.OwnerProfile
history *history.Store
gw *gateway.Server
channelBridge *channelbridge.Bridge
channels gateway.ChannelConfigStore
// docGC 是文档回收的常驻任务(切片 S7):未被引用且超过保留期的文档连同
// sidecar 与元数据行一起回收。GC 被配置关闭时为 nil(见 wire_document_gc.go)。
docGC *documentGC
// channelEvents 是渠道事件留存面(design/26 §4;切片 E3+E4):非消息事件逐条
// 落行 / T1 计数都经它,桥接层由 wire_channels.go 注入。库未接时为 nil
// (桥接层随即走内存计数降级,见 wire_channel_events.go)。
channelEvents *events.SQLStore
// channelEventKeeper 是事件留存的常驻维护任务(保留期清扫 + 计数落盘),
// 由 main 的 startChannelEvents 拉起(见 wire_channel_events.go)。
channelEventKeeper *channelEventKeeper
// usage 是 LLM 用量的异步落库出口(切片 U1):注入模型栈后,每次成功模型
// 调用都会把非敏感计数样本塞进它的有界队列。库未接时为 nil(= 只记日志)。
usage *model.UsageWriter
// usageGC 是用量样本的保留期清理常驻任务。`LLM_USAGE_RETENTION ≤ 0` 时为
// nil(清理关闭,见 wire_usage.go)。
usageGC *usageRetentionGC
// capacitySink 把 capacity_awaken 状态迁移广播给 Web UI(design/14 §5)。
// 由 *gateway.Server 满足(BroadcastCapacityAwaken);以接口形式持有,
// 组合根才能用假出口做单元测试。
capacitySink capacityEmitter
// emotion 把每一轮落定的对话施加到所绑人格的情绪机与好感度上(design/01 §2),
// 并把变化交给 moodSink。没有注册人格时为 nil,此时不追踪、不持久化任何心情。
emotion *emotion.Observer
// moodSink 把心情变化广播给 Web UI(design/07 §3)。由 *gateway.Server 满足
// (BroadcastMood);以接口形式持有,组合根才能用假出口做单元测试。
moodSink moodEmitter
// traceWriter 把完成的 trace span 扇出到 JSONL stderr 记录器,网关出现后也送往
// 网关的可观测性缓冲(挂在 GET /api/v1/admin/observability/trace 后面,
// design/19 §2)。持有它是为了让 wireFrontend 能往已在运行的 tracer 上注册
// 网关出口。
traceWriter *traceFanout
// flow 是 loop 的实时链路图发布器(design/19 §1)。持有它是因为网关还要一个
// **只读**出口:`GET /api/v1/admin/flow/graph` 回「最近一轮」的快照,补上
// 「轮次结束后才打开的页面」看不到图的那个洞(契约 v0.71)。组合根把**同一个**
// 发布器同时交给 loop/orchestration(写入面)与网关(查询面),因此查询到的
// 就是实时发布的那张图,不会出现两份状态。
flow *loop.FlowPublisher
// flowStore 是链路图的**本地落盘**(design/19 §4 P3;切片 F2;迁移 0022):发布器
// 每轮收尾经它落一条,网关按 turnId / turns 读回。库未接时为 nil(发布器随即走
// 纯内存路径,见 wire_flow_graphs.go)。它与 flow 是两件事:flow 是实时内存图,
// flowStore 是收尾存档。
flowStore *flowgraph.SQLStore
// flowGraphKeeper 是链路图留存的常驻维护任务(保留期清扫),由 main 的
// startFlowGraphs 拉起(见 wire_flow_graphs.go)。
flowGraphKeeper *flowGraphKeeper
}
// newApp 依据 cfg 与 log 组装完整的 Cyrene 运行时:打开数据库、迁移表结构,接线
// 人格注册表、模型适配器、工具分发器、记忆栈、agent 循环、编排、执行、IoT、
// 渠道会话/适配器与网关,最后返回就绪的 app。
//
// 致命的接线失败(数据库打开/迁移、必需的认证、网关)以错误返回;可选子系统则
// 优雅降级,使得未配置模型密钥或外部渠道时二进制仍能启动。
func newApp(ctx context.Context, cfg *config.Config, log *slog.Logger) (*app, error) {
dataDir := resolveDataDir(cfg)
databasePath := resolveSQLitePath(cfg, dataDir)
dbCfg := cfg.DB
dbCfg.SQLitePath = databasePath
sqlDB, err := db.Open(ctx, databasePath, log)
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
if err := db.MigrateUp(ctx, sqlDB, log); err != nil {
_ = sqlDB.Close()
return nil, fmt.Errorf("migrate database: %w", err)
}
eventBus := bus.New()
a := &app{
cfg: cfg,
log: log,
db: sqlDB,
dbPath: databasePath,
bus: eventBus,
caps: registry.New(),
}
// 后续任一接线步骤失败时,释放已经打开的资源,但不打整套服务关闭日志
// (半途启动不等同于一次干净退出)。
completed := false
defer func() {
if completed {
return
}
if a.vindex != nil {
_ = a.vindex.Close()
}
if a.tasks != nil {
_ = a.tasks.Close()
}
if a.bus != nil {
_ = a.bus.Close()
}
if a.db != nil {
_ = a.db.Close()
}
}()
if err := a.buildPersonas(); err != nil {
return a, err
}
if err := a.wireMemory(ctx); err != nil {
return a, err
}
if err := a.wireRuntime(); err != nil {
return a, err
}
if err := a.wireAuxiliary(); err != nil {
return a, err
}
a.wirePlugins(ctx)
// 渠道事件留存面(design/26 §4;切片 E3+E4):必须在 wireChannels 之前构造 ——
// 桥接层在构造时就把它注入(Options.EventStore),运行时不再重取。
a.wireChannelEvents()
if err := a.wireChannels(); err != nil {
return a, err
}
if err := a.wireFrontend(); err != nil {
return a, err
}
completed = true
return a, nil
}
+134
View File
@@ -0,0 +1,134 @@
package main
import (
"context"
"fmt"
"log/slog"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration/audit"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/approvalreview"
)
// 本文件是 design/11 §4「敏感操作代审」在组合根上的全部接线(裁决 13.4.3 +
// 主人 2026-10-05 裁决的口径 A):
// - 装配**实质判定**(是否子代理发起、判「有没有越界」、fail-closed);
// - 构建代审门与其归属解析;
// - 构建 loop 的准入钩子,把代审记录交网关落 audit_logs 并广播前端小字,
// 并让判定理由出现在那行小字里。
//
// 判定本身与拒绝语义在 audit.Approver(边界判定、fail-closed)与
// services/approvalreview(轻量审查模型调用)。
// approvalJudgeFor 构建敏感操作代审的实质判定器:复用既有审查模型范式
// (design/12 §6 策略四的轻量审查模型,见 services/approvalreview 与
// wire_channels.go 的 reviewFor),注入**同一个** model.Adapter,不另造 LLM 包装。
//
// 无模型适配器时返回 nil:门对**子代理**的敏感调用随即 fail-closed(判不出来不放行),
// 而主会话自身的调用不受影响(口径 A 只管子代理)。
func approvalJudgeFor(llm model.Adapter, log *slog.Logger) audit.CallJudge {
if llm == nil {
return nil
}
return approvalreview.NewLLMJudge(llm, logger.WithSource(log, "services/approvalreview"))
}
// approvalExplainWithReason 把小字与判定理由拼在一起(design/11 §4 的固定句式在前,
// 理由作括号后缀在后),使前端那行小字能说清「为什么被拒」。reason 为空时原样返回,
// 因此放行与未判定的路径与加入判定之前逐字一致。
func approvalExplainWithReason(explain, reason string) string {
reason = strings.TrimSpace(reason)
if reason == "" {
return explain
}
return explain + "(" + reason + ")"
}
// buildApprovalGate 构建敏感操作代审门(design/11 §4;裁决 13.4.3)。
//
// 归属解析取 Run 上下文里的会话 id 与该 turn 的角色名,使前端小字既投到
// 正确的会话、也写明是谁想动手(design/11 §4 的「xxx(角色名)」)。会话 id 用
// loop.FlowOwnerFromContext:子代理的敏感调用归属到**派生它的那个会话**
// (与 design/19 §2 的 flow 归属同一口径),所以小字出现在主人正在看的对话里,
// 而不是一个没人查看的子代理 id 下。
func (a *app) buildApprovalGate(approver *audit.Approver) *restrict.Gate {
return restrict.NewGate(
auditReviewer{approver: approver},
func(ctx context.Context) (string, string) {
return loop.FlowOwnerFromContext(ctx), a.approvalActor(ctx)
},
restrict.WithGateLogger(a.log),
)
}
// toolAdmissionHooks 构建 loop 的工具准入钩子(design/11 §4;裁决 13.4.3):
// 敏感调用先经主代理代审,代审记录交给网关落 audit_logs 并广播前端小字;
// 未获代审的调用以软结果回灌给模型(绝不执行,绝不静默)。非敏感调用直接放行。
//
// 只装 CallAdmit:敏感与否取决于**该次调用的参数**(file 的 read 自主、
// write 代审),批量钩子(BatchAdmit)看不到参数,做不出这个区分。
// 组合根按 loop 的既有契约装配,因此这条链也覆盖子代理的敏感调用
// (编排层派生的 loop 共享同一组钩子)——而那正是口径 A 的判定对象。
func (a *app) toolAdmissionHooks(gate *restrict.Gate) loop.ToolLoopHooks {
return loop.ToolLoopHooks{
CallAdmit: func(ctx context.Context, call agent.ToolCall) *loop.ToolCallVeto {
// 判定理由经按调用的理由盒带出来:代审门的 Reviewer 契约只回
// (allowed, error),而小字要说清「为什么被拒」(口径 A)。
ctx, note := audit.WithDecisionNote(ctx)
decision := gate.Admit(ctx, call.Name, call.Args)
if decision.Approval != nil {
decision.Approval.Explain = approvalExplainWithReason(decision.Approval.Explain, note.Reason())
// a.gw 在 wireFrontend 才建好;RecordApproval 对 nil 接收者安全。
a.gw.RecordApproval(ctx, *decision.Approval)
}
if !decision.Allow {
return &loop.ToolCallVeto{Block: true, Reason: decision.Reason}
}
return nil
},
}
}
// auditReviewer 把 orchestration/audit 的 Approver 适配成代审门的 Reviewer
// (design/11 §4;裁决 13.4.3 要求复用该 Approver,而不是另起一套代审)。
// 它把 Approval 收敛成 (allowed, error),并把被拒时的判定理由写进 ctx 上的
// 理由盒(audit.DecisionNote),使冻结的 Reviewer 契约之外仍有一条结构化通道
// 把「为什么被拒」送到前端小字。前端小字文案本身仍由 restrict 复用
// audit.Approval.Explain 的固定句式产出。
type auditReviewer struct{ approver *audit.Approver }
// ReviewAndApprove 执行一次代审;approver 缺席时复用编排层的 ErrNoApprover
// (门据此 fail-closed 拦截敏感操作,绝不静默放行)。
func (r auditReviewer) ReviewAndApprove(ctx context.Context, actor, action, describe string) (bool, error) {
if r.approver == nil {
return false, fmt.Errorf("review-and-approve: %w", orchestration.ErrNoApprover)
}
ap, err := r.approver.ReviewAndApprove(ctx, actor, action, describe)
if err != nil {
return false, fmt.Errorf("review-and-approve: %w", err)
}
if !ap.Allowed {
audit.DecisionNoteFrom(ctx).SetReason(ap.Reason)
}
return ap.Allowed, nil
}
// approvalActor 解析代审小字里的「谁想动手」:该 turn 绑定的角色名,
// 未绑定时回落到默认角色;角色名解析不出来就用角色 id(小字宁可用 id
// 也要说清是谁,但绝不编造一个名字)。
func (a *app) approvalActor(ctx context.Context) string {
pid := loop.SessionEnvFrom(ctx).PersonaID
if pid == "" {
pid = a.defaultPersonaID()
}
if name := a.personaName(pid); name != "" {
return name
}
return pid
}
+181
View File
@@ -0,0 +1,181 @@
package main
import (
"context"
"strings"
"sync"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration/audit"
)
// 本文件锁定组合根上口径 A(主人 2026-10-05 裁决)的接线:
// - 子代理发起的敏感调用 ⇒ 进判定,判越界即拦下(绝不放行、绝不静默);
// - 主会话自身的敏感调用 ⇒ 不进判定(零模型调用)、照旧放行;
// - 判定理由经按调用的理由盒带出代审门,最终出现在前端小字里。
// stubJudge 是代审判定器桩:记录调用次数并给出既定结论。
type stubJudge struct {
verdict audit.JudgeVerdict
err error
mu sync.Mutex
calls int
}
func (j *stubJudge) JudgeSubagentCall(context.Context, audit.JudgeRequest) (audit.JudgeVerdict, error) {
j.mu.Lock()
j.calls++
j.mu.Unlock()
return j.verdict, j.err
}
func (j *stubJudge) callCount() int {
j.mu.Lock()
defer j.mu.Unlock()
return j.calls
}
// noteSpy 包住真实的代审者,读出代审链写进 ctx 理由盒的内容——它就是
// 「为什么被拒」带出冻结契约的那条通道的证据。
type noteSpy struct {
inner restrict.Reviewer
mu sync.Mutex
seen string
}
func (s *noteSpy) ReviewAndApprove(ctx context.Context, actor, action, describe string) (bool, error) {
allowed, err := s.inner.ReviewAndApprove(ctx, actor, action, describe)
s.mu.Lock()
s.seen = audit.DecisionNoteFrom(ctx).Reason()
s.mu.Unlock()
return allowed, err
}
func (s *noteSpy) reason() string {
s.mu.Lock()
defer s.mu.Unlock()
return s.seen
}
// terminalCall 是一次真实的敏感调用(exec command 类,见 restrict.SensitiveCall)。
func terminalCall() agent.ToolCall {
return agent.ToolCall{Name: "terminal", Args: map[string]any{"command": "rm -rf /tmp/x"}}
}
// admit 用一条生产同形的准入链跑一次调用。
func admit(t *testing.T, ctx context.Context, judge audit.CallJudge, call agent.ToolCall) (*loop.ToolCallVeto, string) {
t.Helper()
if _, _, sensitive := restrict.SensitiveCall(call.Name, call.Args); !sensitive {
t.Fatalf("%s 不是敏感调用,用例前提不成立", call.Name)
}
approver := audit.NewApprover(testLog(),
audit.WithSubagentOrigin(loop.SubagentTaskFrom),
audit.WithCallJudge(judge),
)
spy := &noteSpy{inner: auditReviewer{approver: approver}}
gate := restrict.NewGate(spy, func(context.Context) (string, string) { return "web:main", "昔涟" })
a := &app{log: testLog()}
return a.toolAdmissionHooks(gate).CallAdmit(ctx, call), spy.reason()
}
// TestToolAdmission_SubagentOutOfScopeIsBlockedWithReason:子代理发起的敏感调用被
// 判定为越界 ⇒ 拦下(软结果回灌,绝不执行),且理由经理由盒带出代审门(会进小字)。
func TestToolAdmission_SubagentOutOfScopeIsBlockedWithReason(t *testing.T) {
t.Parallel()
judge := &stubJudge{verdict: audit.JudgeVerdict{Allowed: false, Reason: "任务只要求整理导出文件,不需要执行 shell 命令"}}
ctx := loop.WithSubagentTask(context.Background(), "整理 data/ 下的导出文件")
veto, reason := admit(t, ctx, judge, terminalCall())
if veto == nil || !veto.Block {
t.Fatalf("veto = %+v, want 拦下(越界的敏感调用绝不执行)", veto)
}
if !strings.Contains(veto.Reason, "not_approved") {
t.Fatalf("回灌给模型的原因 = %q, want 既有 not_approved 体例", veto.Reason)
}
if judge.callCount() != 1 {
t.Fatalf("判定调用次数 = %d, want 1", judge.callCount())
}
if !strings.Contains(reason, "不需要执行 shell 命令") {
t.Fatalf("理由盒 = %q, want 判定给出的理由(小字要说清为什么被拒)", reason)
}
}
// TestToolAdmission_SubagentInScopePasses:判定放行的子代理敏感调用照常执行。
func TestToolAdmission_SubagentInScopePasses(t *testing.T) {
t.Parallel()
judge := &stubJudge{verdict: audit.JudgeVerdict{Allowed: true}}
ctx := loop.WithSubagentTask(context.Background(), "清理临时目录 /tmp/x")
veto, reason := admit(t, ctx, judge, terminalCall())
if veto != nil {
t.Fatalf("veto = %+v, want 放行", veto)
}
if judge.callCount() != 1 {
t.Fatalf("判定调用次数 = %d, want 1", judge.callCount())
}
if reason != "" {
t.Fatalf("放行时理由盒 = %q, want 空(小字不带理由)", reason)
}
}
// TestToolAdmission_MainSessionIsNotJudged:主会话自身的敏感调用**不进判定**
// (零模型调用)且放行——主人自己显式要求的操作(例如读 models.json / cyrene.db)
// 绝不能被这套判定拦死(口径 A 的另一半)。
func TestToolAdmission_MainSessionIsNotJudged(t *testing.T) {
t.Parallel()
judge := &stubJudge{verdict: audit.JudgeVerdict{Allowed: false, Reason: "无论如何都拒绝"}}
veto, reason := admit(t, context.Background(), judge, terminalCall())
if veto != nil {
t.Fatalf("veto = %+v, want 主会话调用放行", veto)
}
if judge.callCount() != 0 {
t.Fatalf("判定调用次数 = %d, want 0(主会话调用零模型调用)", judge.callCount())
}
if reason != "" {
t.Fatalf("理由盒 = %q, want 空", reason)
}
}
// TestToolAdmission_FailClosedWhenJudgeUnavailable:未装配判定器(或无模型适配器)
// 时,子代理的敏感调用一律拦下——绝不因为「没得判」就放行。
func TestToolAdmission_FailClosedWhenJudgeUnavailable(t *testing.T) {
t.Parallel()
ctx := loop.WithSubagentTask(context.Background(), "整理导出文件")
veto, reason := admit(t, ctx, nil, terminalCall())
if veto == nil || !veto.Block {
t.Fatalf("veto = %+v, want 拦下(fail-closed)", veto)
}
if strings.TrimSpace(reason) == "" {
t.Fatal("拒绝没有理由(静默拒绝)")
}
}
// TestApprovalExplainWithReason:判定理由拼进小字固定句式之后;没有理由时逐字不变
// (放行与主会话路径的小字与加入判定之前完全一致)。
func TestApprovalExplainWithReason(t *testing.T) {
t.Parallel()
fixed := "「昔涟 想要干什么,我审查后拒绝了」"
if got := approvalExplainWithReason(fixed, ""); got != fixed {
t.Fatalf("无理由时 = %q, want 原样 %q", got, fixed)
}
if got := approvalExplainWithReason(fixed, " "); got != fixed {
t.Fatalf("空白理由时 = %q, want 原样 %q", got, fixed)
}
got := approvalExplainWithReason(fixed, "这次调用与派给它的任务不一致")
want := fixed + "(这次调用与派给它的任务不一致)"
if got != want {
t.Fatalf("小字 = %q, want %q", got, want)
}
if !strings.HasPrefix(got, fixed) {
t.Fatalf("小字 = %q, want 以 design/11 §4 的固定句式开头", got)
}
}
+159
View File
@@ -0,0 +1,159 @@
package main
// 本文件是 design/28 §1 M5 / §8 B5「浏览器渲染取正文」降级档的**组合根挂载点** ——
// 切片 Z5 把降级链做好了、但生产接不通(能力面没有取正文入口 ⇒ 没东西可注入 ⇒
// 这一档实际关着),本文件就是那次收口留下的缺口的解封。
//
// 六条口径:
//
// 1. **挂载条件 = 能力面里确实有可用浏览器**(注册面里那把 browser 工具的驱动
// `Available() == true`)。这条判据来自 design/28 §7 判据 1 的诚实口径
// 「都没有 ⇒ 工具不注册(不假装可用)」:拿不到来源就不该把一条注定走不通的档挂上。
// 2. **不新增开关**。设计**没有**为这条降级档定义配置项(§8 的配置面清单是「端点、
// 有头/无头、视口、超时、并发、截图上限」,§8 末的配置面约束又写死「`.env` 已冻结、
// 可调项只落配置文件 + 面板」)⇒ 自造一个 `bodyFallback: bool` 字段属于 AGENTS §1 的
// 「自行发挥」。挂载条件因此由能力面自身给出,而不是第二处开关。
// 3. **io 包的「默认关」口径没有被改写**:`iotools.WithBrowserFallback` 不传 / 传 nil
// 仍然是关(`plugins/tools/io` 的库默认)。本文件只把「确实有来源」的那一种情形
// 打开 —— 从「永远关」变成「没来源就关」,这正是 design/28 §2 把 M5 列进推荐组合、
// §8 把 B5 列为实施项的意思。
// 4. **每次调用现取管理器**:理由见 browserBodyRenderer 的文档(面板保存会换掉那一把
// 工具与它背后的管理器)。
// 5. **只读装配**:本文件不发请求、不起进程、不注册工具 —— `ioToolOptions()` 是只读
// 装配,探测的副作用归 `wireBrowser()`(`wire_browser_test.go` 钉住不许在这里探测)。
// 6. **失败如实**:挂不上只记一条**带来源**的 WARN(原因码 + 这一档对模型面的实际含义),
// 不阻断启动 —— 浏览器是可选增强(design/28 §8 逐片落地状态)。
import (
"context"
"errors"
"fmt"
"log/slog"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
browsertools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/browser"
iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io"
)
// 挂载点的文案常量(进日志与结果,不美化)。
const (
// browserFallbackReasonNoSource 是「这一档没挂上」的原因码:能力面里没有可用浏览器。
// 分类原因码而不是「失败」两个字,照 web-search「拿不到后端就不注册」的口径。
browserFallbackReasonNoSource = "no-usable-browser"
// browserFallbackTrigger 写清这一档在什么条件下被走(进启动日志,免得「它到底管什么」
// 只能靠读代码)。
browserFallbackTrigger = "direct fetch failed or the page was judged dynamic"
// browserFallbackOffEffect 写清「没挂」对模型面的**实际**含义:不是说结果会假,
// 而是说这一档没有第二次机会(直连的真实遭遇仍然照实报出来)。
browserFallbackOffEffect = "web-fetch keeps the direct path only; the real outcome is still reported (dynamicSuspect / directError), there is just no browser second look"
)
// bodyFallbackOption 是 design/28 §8 B5 的**挂载点**,落地本文件顶部的六条口径。
//
// 两种去处都只有一个出口,口径不会在分支里漂移:
// - 没有可用浏览器 ⇒ 不挂(`WithBrowserFallback(nil)`)+ 一条带来源的 WARN,说明原因;
// - 有可用浏览器 ⇒ 挂上适配器,并记一条 INFO 说明这一档什么时候会被走。
func (a *app) bodyFallbackOption() iotools.Option {
log := logger.WithSource(a.log, ioLogSource)
mgr := browserManagerOf(a.caps)
if mgr == nil || !mgr.Available() {
log.Warn("web-fetch browser fallback not wired",
"reason", browserFallbackReasonNoSource,
"effect", browserFallbackOffEffect)
return iotools.WithBrowserFallback(nil)
}
log.Info("web-fetch browser fallback wired", "trigger", browserFallbackTrigger)
return iotools.WithBrowserFallback(&browserBodyRenderer{caps: a.caps, log: log})
}
// browserFallbackReasonBudget 是「这一档**没有发起**」的原因码:调用方这一轮的预算已经
// 耗尽。它与 `no-usable-browser` 是两件事(一个是「没时间了」,一个是「没有浏览器」),
// 混成一句会把人带偏(真机就出现过:窗口上是浏览器的抱怨,日志里像浏览器坏了)。
const browserFallbackReasonBudget = "budget-exhausted"
// browserFallbackBudgetNote 写清这一刻的**实际**含义:浏览器没坏、这一档没被尝试。
// 它进错误文本(顺 web-fetch 的 browserError 面出来),因此必须一眼可读、不含猜测。
const browserFallbackBudgetNote = "本轮工具预算已耗尽(调用方上下文已结束)⇒ 未尝试浏览器渲染,这不是浏览器故障"
// browserBodyRenderer 把浏览器能力面(`*browsercap.Manager.RenderText`)适配成 io 包的
// `iotools.BodyRenderer` —— 这是「进程内接口 + 依赖注入」的适配器,不引入第二个浏览器栈、
// 不新开任何监听端口(AGENTS §9)。
//
// **为什么每次调用现取管理器**:面板保存 browser 配置会重建那把工具并换掉它背后的管理器
// (`wire_browser_panel.go` 的热重载 + `ReplaceTool`),装配时抓住的旧指针会在一次保存之后
// 变成一具尸体 —— 那条降级档会静默退化成「每次都如实失败」,而这是最难查的一类失效。
// 从**注册面**取当前那一把,拿到的就是正在跑的那一个。
//
// 红线(与 `iotools.BodyRenderer` 的注释同源):只做对外连接;SSRF 判定由浏览器侧用
// **同一份** `io.ValidateRequestURL` 自己再过一遍(本适配器不替它免检,原始 URL 原样交过去);
// 正文在渲染完成后才取(由 `Manager.RenderText` 保证)。
//
// 空正文**不在这里**判:`RenderText` 的「渲染成功但正文为空」是读数不是错误,而 io 的
// 降级档有自己的口径(`ErrEmptyRenderedBody`)—— 本适配器原样交出去,不替上层下结论。
type browserBodyRenderer struct {
caps *registry.Registry
// log 是挂载点的日志(已带来源)。可为 nil(单测直接构造):那就不记,不做替代语义。
log *slog.Logger
}
// RenderBody 实现 iotools.BodyRenderer。
//
// **预算口径**(design/28 §1 M5「降级档」+ §7 判据 4「如实标注」+ §6 坑 5「失败要如实报」,
// 设计没有为这一档规定独立预算 ⇒ 取最小的诚实口径):ctx 已经结束(这一轮的工具预算耗尽 /
// 会话被取消)时**不发起**,如实报「预算耗尽 ⇒ 未尝试」,而**不是**报一个看起来像
// 「浏览器坏了」的错误。三条理由:
//
// - 这一档要冷启动真浏览器(秒级),启动所需的预算此刻已经不存在;
// - 工具派发层在 ctx 结束时就要把结果丢掉(`internal/runtime/tool` 的 callTool 在
// ctx.Done 与结果之间二选一,且 ctx 结束时返回 ctx.Err())⇒ 硬起一个浏览器只会留下
// 一个被立刻杀掉的窗口,**结果没人读**(真机踩过:窗口上跳出浏览器的抱怨);
// - 没试过就不能报成「试了失败」:`fetchedBy` / `browserError` 的语义(见
// `plugins/tools/io/bodyfetch.go`)一字不动,本适配器只交「没发起」这个事实。
//
// ctx 还活着时**不动**它的预算(不另造一个第二预算):浏览器会话可能是热的(同一次
// 会话里刚渲染过),渲染只要一两秒;真有取消,浏览器侧会把它如实分类成「预算耗尽」
// (见 `internal/capability/browser` 的分类标记),不会再冒充「浏览器坏了」。
func (r *browserBodyRenderer) RenderBody(ctx context.Context, rawURL string) (iotools.RenderedBody, error) {
mgr := browserManagerOf(r.caps)
if mgr == nil || !mgr.Available() {
// 诚实降级:此时是「这一档没有来源」,**不是**「取到了空正文」。
return iotools.RenderedBody{}, errors.New("web-fetch browser fallback: no usable browser available")
}
if err := ctx.Err(); err != nil {
if r.log != nil {
r.log.Info("web-fetch browser fallback not attempted",
"reason", browserFallbackReasonBudget, "error", err)
}
return iotools.RenderedBody{}, fmt.Errorf("web-fetch browser fallback: %s: %s: %w",
browserFallbackReasonBudget, browserFallbackBudgetNote, err)
}
rendered, err := mgr.RenderText(ctx, rawURL)
if err != nil {
return iotools.RenderedBody{}, fmt.Errorf("browser render: %w", err)
}
return iotools.RenderedBody{Text: rendered.Text, Source: rendered.Source}, nil
}
// browserManagerOf 从能力面取回**当前**的浏览器管理器;没有可用来源(没注册 / 注册的
// 不是这把工具 / 驱动不是本机的管理器,例如单测夹具)时返回 nil。
//
// 它**只读**注册面:不发请求、不起进程、不注册工具 —— 本文件被 `ioToolOptions()` 调用,
// 那是一条只读装配路径(口径 5)。
func browserManagerOf(caps *registry.Registry) *browser.Manager {
if caps == nil {
return nil
}
entry, err := caps.Get(browsertools.ToolName)
if err != nil {
return nil
}
tool, ok := entry.Value.(*browsertools.Tool)
if !ok {
return nil
}
return tool.Manager()
}
+361
View File
@@ -0,0 +1,361 @@
package main
// 本文件钉住切片 Z5 解封的那条接线(design/28 §1 M5 / §8 B5):把浏览器能力面的
// 「渲染取正文」适配成 web-fetch 的降级档,并在组合根决定**挂不挂**。
//
// 三件事必须有测试拦(它们删掉之后运行期都没有症状):
//
// 1. 能力面里**没有**可用浏览器 ⇒ 这一档**不挂**,且如实留一条带来源的原因;
// 2. 能力面里**有**可用浏览器 ⇒ 这一档真的被挂上、被调用(判据是浏览器那一档的
// 真实遭遇出现在返回体里,而不是「某个函数被调过」);
// 3. 适配器在「取正文时已经没有可用浏览器」时如实报错,**不返回空串冒充成功**。
//
// 另外两条是接线形状的钉子(照 `TestWireRuntime_CallsWireBrowser` 的先例):挂载点不能
// 从生产组装里消失,装配顺序不能把浏览器排在 io 工具集之后。
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
browsercap "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
browsertools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/browser"
iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io"
)
// webFetchToolName 是 web-fetch 在注册面里的名字(io 包里的字面量,这里按同一份契约引用)。
const webFetchToolName = "web-fetch"
// serveSkeletonPage 起一个「直连能成功、但只有前端框架挂载点」的页面:它是这条降级档的
// 入口条件之一(直连成功却 `dynamicSuspect`)。
func serveSkeletonPage(t *testing.T) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(`<html><body><div id="app"></div><script src="/x.js"></script></body></html>`))
}))
t.Cleanup(srv.Close)
return srv
}
// endpointHost 是白名单条目的写法:host:port(判定源会剥端口比对)。
func endpointHost(srv *httptest.Server) string { return strings.TrimPrefix(srv.URL, "http://") }
// fallbackFetchTool 造一把**走挂载点**的 web-fetch:选项里带 `bodyFallbackOption()`,
// 测试服务放进 SSRF 白名单(否则直连那一档先被守卫拒掉,降级档根本不会被触发)。
//
// 选项走**生产路径**:`a.ioToolOptions()`(挂载点就在里面),而不是只把 `bodyFallbackOption()`
// 单独拿出来 —— 这样两个用例证明的就是「启动期真正交出去的那份选项」。
func fallbackFetchTool(t *testing.T, a *app, srv *httptest.Server) sdk.Tool {
t.Helper()
opts := append(a.ioToolOptions(),
iotools.WithAllowedHosts(endpointHost(srv)), iotools.WithHTTPClient(srv.Client()))
set, err := iotools.New(a.cfg, opts...)
if err != nil {
t.Fatalf("iotools.New: %v", err)
}
for _, tool := range set {
if tool.Name() == webFetchToolName {
return tool
}
}
t.Fatalf("工具集里没有 %s", webFetchToolName)
return nil
}
// execFetch 跑一次 web-fetch 并把它返回的 map 取出来。
func execFetch(t *testing.T, tool sdk.Tool, rawURL string) map[string]any {
t.Helper()
got, err := tool.Execute(context.Background(), map[string]any{"url": rawURL})
if err != nil {
t.Fatalf("web-fetch Execute: %v", err)
}
m, ok := got.(map[string]any)
if !ok {
t.Fatalf("web-fetch 返回的不是对象: %#v", got)
}
return m
}
// newWiredBrowserManager 造一个**报告可用**的浏览器管理器:探测结论说「本机有 Chrome」,
// 但可执行文件故意不存在 —— `Available()` 为 true(于是降级档会被挂上),而任何一次真实
// 会话都会以 ErrLaunch 如实失败。
func newWiredBrowserManager(t *testing.T, cfg *config.Config, allowedHost string) *browsercap.Manager {
t.Helper()
mgr := browsercap.NewManager(browsercap.Options{
Availability: browsercfg.Availability{
Usable: true, Source: browsercfg.SourceLocalChrome,
Executable: "definitely-not-a-browser.exe", Detail: "假来源(本用例不真起浏览器)",
},
Config: browsercfg.Defaults(cfg.DataDir),
DataDir: cfg.DataDir,
AllowedHosts: []string{allowedHost},
})
t.Cleanup(func() { _ = mgr.Close() })
return mgr
}
// ① 没有可用浏览器 ⇒ 这一档不挂:直连的结论照实交出来,并如实说明「没有浏览器第二眼」;
// 日志里留下分类原因码与来源标签。
func TestBodyFallback_NoBrowserMeansTierNotWired(t *testing.T) {
t.Parallel()
cfg := wsLoadConfig(t)
var out bytes.Buffer
a := browserTestApp(t, cfg, &out) // 注册面里没有 browser 工具 = 这台机器没有可用来源
srv := serveSkeletonPage(t)
m := execFetch(t, fallbackFetchTool(t, a, srv), srv.URL)
if m["fetchedBy"] != iotools.FetchedByDirect {
t.Errorf("fetchedBy = %v, want %q(浏览器没取到,来源仍是直连)", m["fetchedBy"], iotools.FetchedByDirect)
}
if m["dynamicSuspect"] != true {
t.Errorf("dynamicSuspect = %v,骨架页必须被判成可疑", m["dynamicSuspect"])
}
be, _ := m["browserError"].(string)
if !strings.Contains(be, "browser fallback disabled") {
t.Errorf("这一档没挂时必须如实说明(browserError = %q)", be)
}
if !strings.Contains(out.String(), "reason="+browserFallbackReasonNoSource) {
t.Errorf("日志里必须留下分类原因码 reason=%s(实得:%s)", browserFallbackReasonNoSource, out.String())
}
if !strings.Contains(out.String(), "source=cmd/cyrene/wire_io_tools") {
t.Errorf("这条日志必须带来源标签(AGENTS §10):%s", out.String())
}
}
// ② 有可用浏览器 ⇒ 这一档真的挂上并被调用:浏览器那一档的**真实遭遇**(这里是不存在的
// 可执行文件 ⇒ ErrLaunch)出现在返回体里,而不是被吞掉。
func TestBodyFallback_AvailableBrowserIsWiredAndHonest(t *testing.T) {
t.Parallel()
cfg := wsLoadConfig(t)
var out bytes.Buffer
a := browserTestApp(t, cfg, &out)
srv := serveSkeletonPage(t)
if err := a.caps.RegisterTool(browsertools.New(newWiredBrowserManager(t, cfg, endpointHost(srv)))); err != nil {
t.Fatalf("注册 browser 工具: %v", err)
}
m := execFetch(t, fallbackFetchTool(t, a, srv), srv.URL)
be, _ := m["browserError"].(string)
if !strings.Contains(be, "启动本机浏览器失败") {
t.Errorf("浏览器这一档被挂上后就该被真调用,并把失败如实写进结果;browserError = %q", be)
}
if m["fetchedBy"] != iotools.FetchedByDirect {
t.Errorf("fetchedBy = %v, want %q(浏览器没取到正文)", m["fetchedBy"], iotools.FetchedByDirect)
}
if !strings.Contains(out.String(), "web-fetch browser fallback wired") {
t.Errorf("挂上时应有带来源的 INFO:%s", out.String())
}
}
// ③ 适配器在「取正文时已经没有可用浏览器」时如实报错,不返回空串冒充成功。
func TestBrowserBodyRenderer_NoUsableBrowserIsNotSilentSuccess(t *testing.T) {
t.Parallel()
cfg := wsLoadConfig(t)
withUnavailable := registry.New()
if err := withUnavailable.RegisterTool(browsertools.New(
newUnavailableBrowserManager(t, cfg))); err != nil {
t.Fatalf("注册 browser 工具: %v", err)
}
cases := []struct {
name string
caps *registry.Registry
}{
{"注册面为 nil", nil},
{"注册面里没有 browser", registry.New()},
{"工具在但驱动不可用", withUnavailable},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
r := &browserBodyRenderer{caps: tc.caps}
got, err := r.RenderBody(context.Background(), "https://example.com/")
if err == nil {
t.Fatalf("没有可用浏览器时必须报错(实得 %+v)", got)
}
if !strings.Contains(err.Error(), "no usable browser") {
t.Errorf("err = %v,应说清是「这一档没有来源」", err)
}
if got.Text != "" || got.Source != "" {
t.Errorf("失败时不该交出半个结论: %+v", got)
}
})
}
}
// newUnavailableBrowserManager 造一个「探测不到来源」的管理器(Available() == false)。
func newUnavailableBrowserManager(t *testing.T, cfg *config.Config) *browsercap.Manager {
t.Helper()
mgr := browsercap.NewManager(browsercap.Options{
Availability: browsercfg.Availability{Usable: false, Detail: "本机没有浏览器"},
Config: browsercfg.Defaults(cfg.DataDir),
DataDir: cfg.DataDir,
})
t.Cleanup(func() { _ = mgr.Close() })
return mgr
}
// ④ 适配器跟的是**注册面里的当前那一把**,不是装配时的那一个:面板保存 browser 配置会
// 用 `ReplaceTool` 换掉工具与它背后的管理器,抓着旧指针会让这条降级档在一次保存之后变成
// 一具尸体(而且只在「取正文时」才暴露)。
func TestBrowserBodyRenderer_FollowsRegistryReplacement(t *testing.T) {
t.Parallel()
cfg := wsLoadConfig(t)
var out bytes.Buffer
a := browserTestApp(t, cfg, &out)
if err := a.caps.RegisterTool(browsertools.New(
newWiredBrowserManager(t, cfg, "example.com"))); err != nil {
t.Fatalf("注册 browser 工具: %v", err)
}
r := &browserBodyRenderer{caps: a.caps}
if _, err := r.RenderBody(context.Background(), "https://example.com/"); err == nil ||
strings.Contains(err.Error(), "no usable browser") {
t.Fatalf("替换前应当用**可用的**那一把(真去渲染,因假 exe 而失败);实得 %v", err)
}
// 面板保存 → 换掉注册面里的那一把(新的管理器不可用)。
if err := a.caps.ReplaceTool(browsertools.New(newUnavailableBrowserManager(t, cfg))); err != nil {
t.Fatalf("替换 browser 工具: %v", err)
}
if _, err := r.RenderBody(context.Background(), "https://example.com/"); err == nil ||
!strings.Contains(err.Error(), "no usable browser") {
t.Fatalf("替换后适配器必须改口成「没有可用来源」,实得 %v", err)
}
}
// ④ 挂载点不能从生产组装里消失:`ioToolOptions()` 是启动期唯一把降级档交给 io 包的地方,
// 它被删掉之后编译照过、启动照起,只有模型那边少一次「浏览器第二眼」。
func TestIOToolOptions_IncludesBodyFallback(t *testing.T) {
t.Parallel()
src := readSource(t, "wire_io_tools.go")
if !strings.Contains(src, "\t\ta.bodyFallbackOption(),") {
t.Fatal("wire_io_tools.go 里找不到 `a.bodyFallbackOption()` —— 浏览器降级档不会被挂上" +
"(J5 在 wire_body_fallback.go,挂载点必须留在生产组装里)")
}
}
// ⑤ 装配顺序:浏览器必须在 io 工具集**之前**装配。降级档的挂载条件读的就是注册面,
// 反过来写会让它永远读到「没有浏览器」⇒ 静默关死(没有任何运行期症状)。
func TestWireRuntime_BrowserWiredBeforeIoTools(t *testing.T) {
t.Parallel()
src := readSource(t, "wire_runtime.go")
browse := strings.Index(src, "\ta.wireBrowser()")
tools := strings.Index(src, "ioTools, ioErr := iotools.New(a.cfg, a.ioToolOptions()...")
if browse < 0 || tools < 0 {
t.Fatalf("wire_runtime.go 里找不到装配点(browser=%d, ioTools=%d)", browse, tools)
}
if browse > tools {
t.Fatal("wire_runtime.go 把浏览器装配排在了 io 工具集之后 —— web-fetch 的浏览器降级档" +
"会静默关死(挂载条件读的是注册面)")
}
}
// ⑥ 预算口径:这一轮的预算已经耗尽(ctx 已结束)时,这一档**不发起**,如实报
// 「预算耗尽 ⇒ 未尝试浏览器渲染」。判据是两句话都要在,而那句误导性的
// 「启动本机浏览器失败」绝不能出现 —— 假 exe 一定启动失败,它出现就证明我们真去起了
// 浏览器(那正是真机踩的坑:窗口上跳出浏览器的抱怨,日志里像浏览器坏了)。
func TestBrowserBodyRenderer_ExhaustedBudgetIsNotABrowserFailure(t *testing.T) {
t.Parallel()
cfg := wsLoadConfig(t)
var out bytes.Buffer
a := browserTestApp(t, cfg, &out)
if err := a.caps.RegisterTool(browsertools.New(
newWiredBrowserManager(t, cfg, "example.com"))); err != nil {
t.Fatalf("注册 browser 工具: %v", err)
}
r := &browserBodyRenderer{caps: a.caps, log: logger.WithSource(a.log, ioLogSource)}
cases := []struct {
name string
ctx func() (context.Context, context.CancelFunc)
}{
{"已取消", func() (context.Context, context.CancelFunc) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
return ctx, cancel
}},
{"已到期", func() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), 0)
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ctx, cancel := tc.ctx()
defer cancel()
got, err := r.RenderBody(ctx, "https://example.com/")
if err == nil {
t.Fatalf("预算耗尽时必须报错(不许拿空正文冒充成功): %+v", got)
}
t.Logf("实测 err=%v", err)
msg := err.Error()
if !strings.Contains(msg, browserFallbackReasonBudget) {
t.Errorf("err = %v,应带原因码 %q", err, browserFallbackReasonBudget)
}
if !strings.Contains(msg, "未尝试浏览器渲染") {
t.Errorf("err = %v,应说清「没有试过」", err)
}
if strings.Contains(msg, "启动本机浏览器失败") {
t.Errorf("预算耗尽不许被报成浏览器故障: %v", err)
}
if strings.Contains(msg, "no usable browser") {
t.Errorf("「预算耗尽」与「没有可用来源」是两件事,不许混成一句: %v", err)
}
if got.Text != "" || got.Source != "" {
t.Errorf("失败时不该交出半个结论: %+v", got)
}
if !strings.Contains(out.String(), "reason="+browserFallbackReasonBudget) {
t.Errorf("日志里必须留下原因码(实得:%s)", out.String())
}
if !strings.Contains(out.String(), "source="+ioLogSource) {
t.Errorf("这条日志必须带来源标签(AGENTS §10):%s", out.String())
}
})
}
}
// ⑦ 端到端(走生产接线的选项):预算耗尽的那一轮,web-fetch 的错误里**两条档的真实遭遇
// 都要在** —— 直连为什么没成 + 浏览器这一档为什么**没试**(预算耗尽)。io 层的
// `fetchedBy` / `browserError` 语义不许变(那是它的红线),所以这里断言的是它们的**输出**
// 仍然照实:两条失败各自出现,谁也别替谁背锅。
func TestBodyFallback_ExhaustedBudgetIsHonestEndToEnd(t *testing.T) {
t.Parallel()
cfg := wsLoadConfig(t)
var out bytes.Buffer
a := browserTestApp(t, cfg, &out)
srv := serveSkeletonPage(t)
if err := a.caps.RegisterTool(browsertools.New(
newWiredBrowserManager(t, cfg, endpointHost(srv)))); err != nil {
t.Fatalf("注册 browser 工具: %v", err)
}
tool := fallbackFetchTool(t, a, srv)
ctx, cancel := context.WithCancel(context.Background())
cancel()
got, err := tool.Execute(ctx, map[string]any{"url": srv.URL})
if err == nil {
t.Fatalf("两条档都没成时必须报错(实得 %#v)", got)
}
t.Logf("实测 web-fetch 错误:%v", err)
msg := err.Error()
if !strings.Contains(msg, "browser fallback failed too") {
t.Errorf("io 层「两条失败都照实报」的口径不许变: %v", err)
}
if !strings.Contains(msg, browserFallbackReasonBudget) {
t.Errorf("浏览器那一档要如实说「预算耗尽 ⇒ 未尝试」: %v", err)
}
if strings.Contains(msg, "启动本机浏览器失败") {
t.Errorf("预算耗尽不许被说成浏览器故障(真去起了浏览器): %v", err)
}
if got != nil {
t.Errorf("失败时不该交出结果: %#v", got)
}
}
+193
View File
@@ -0,0 +1,193 @@
package main
// 本文件是 design/28(浏览器与页面截图)B1→B2 的**组合根接线**:把纯库
// `internal/capability/browsercfg`(配置读写 + 可用性探测)接进能力面,并在可用时
// 注册 `browser` 工具(`plugins/tools/browser` 的薄层)。
//
// 六条口径:
//
// 1. **配置只从文件来**:`$DATA_DIR/config/plugins/browser.json`(design/13 §2.1 的
// 插件配置落点、design/28 §1 M2)。**不读任何新增 env 键** —— `.env` 已冻结为
// 「最小化功能可跑」的引导面(AGENTS §10);本文件通篇没有 `os.Getenv`。
// 2. **文件缺失 = 交给自动探测**(`browsercfg.Resolve(nil, …)` 走「本机
// Chrome/Chromium → 已在跑的 CDP 端点」);**文件非法 = 不注册**(如实报错,
// 不静默拿默认值顶替 —— 那会让「面板里那份坏配置」永远看不见)。
// 3. **不可用就不注册**(照 web-search `off` 的诚实口径,design/28 §7 判据 1):
// 模型根本看不见这个工具,而不是看得见、一调就报错。原因**分类**打进日志
// (`reason=no_source` / `executable_missing` / `cdp_unreachable`),不糊成
// 「失败」两个字。
// 4. **profile 目录含凭据**:注册之前先过 `EnsureProfileDir`(0700 + 不得位于 git
// 工作树内,design/28 §2 三件套之 2、§4);不过关就不注册。
// 5. **两条失败都不阻塞启动**(与 wireVision / wireTranscode 同族):浏览器是**可选
// 增强**,磁盘上没有 Chrome 的机器照样得把整个进程拉起来。
// 6. **幂等**:重复装配只发现工具已在注册表里并跳过,不重复注册、不重复探测。
//
// 关于挂点(切片 X3 归位):本文件的 `wireBrowser()` 由 `wireRuntime` 调用,位置与
// `wireVision()` / `wireTranscode()` 并列 —— 三者同类(可选增强,不可用就不注册)。
// 它**不再**借 `ioToolOptions()` 转交:那件函数是「组装 io 选项」的只读装配,顺带探测
// 并注册浏览器会让所有只为要一份选项而调它的路径(web-search 的接线测试就是)连带起
// 一次浏览器探测。它的关闭挂点在 `a.close`(wire_services.go),与这一行成对。
//
// 构造路径**只有一条**(`buildBrowserTool`):启动期注册(本文件)与面板保存后的
// 热重载(`wire_browser_panel.go`)走的是同一段代码,两处语义不可能漂移。
import (
"context"
"log/slog"
"time"
browsercap "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
browsertools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/browser"
)
const (
// browserLogSource 是本文件日志的来源标签(AGENTS §10:禁止无来源裸日志)。
browserLogSource = "cmd/cyrene/wire_browser"
// browserProbeTimeout 约束可用性探测。探测问的是「有没有在听」(本机可执行文件
// 是否存在、CDP 端点是否回话),不是「页面能不能加载」,所以给得短:最坏情况
// 也只是启动路径上多等 3 秒,而不是拖住整个组合根。
browserProbeTimeout = 3 * time.Second
// 生效配置的来源标签(只进日志):一眼看出本次结论是「面板文件说的」还是
// 「没有配置文件、自动探测出来的」。
browserConfigSourceFile = "panel-file"
browserConfigSourceProbe = "probe"
// 不注册工具时的原因码(进日志的 reason 字段,与 browsercfg.Reason 同风格)。
browserReasonInvalidConfig = "invalid-config"
browserReasonProfileRejected = "profile-dir-rejected"
// browserNotRegisteredEffect 是「不注册」这件事对模型面**实际**意味着什么:
// 写死在日志里,免得读日志的人以为只是「降级运行」。
browserNotRegisteredEffect = "the model will not see a browser tool at all"
)
// browserBuild 是「按当前配置把 browser 工具建出来」的结论(成功与失败共用一个形状,
// 使每一条失败路径都只能经**同一个出口**报原因,不会在两条分支里各写各的文案)。
type browserBuild struct {
// tool / mgr 是成功时的产物(失败时两者皆 nil)。
tool *browsertools.Tool
mgr *browsercap.Manager
// reason 失败时非空:分类原因码(`browsercfg.Reason*` 或本文件的 browserReason*)。
reason string
// detail 是给人看的一句话(失败原因细节;不含凭据、不含图片内容)。
detail string
// configPath / configSource 是本次生效配置的来路(进日志,便于对照面板)。
configPath string
configSource string
// source / headless 是探测结论(只进日志)。
source string
headless bool
// available 报告这次是否真的建出了可用的工具。
available bool
}
// buildBrowserTool 走「读文件 → 探测可用性 → profile 守卫 → 建管理器与工具」这四步。
//
// carry 是「要沿用的截图附件箱」:面板保存触发热重载时,重建前的待送截图不能丢
// (模型刚拍完还没送出去的那一张)。为 nil 时工具自带一个新箱子。
func (a *app) buildBrowserTool(carry *browsertools.ShotOutbox) browserBuild {
dataDir := resolveDataDir(a.cfg)
configPath := browsercfg.DefaultConfigPath(dataDir)
fileCfg, err := browsercfg.Load(configPath, dataDir)
if err != nil {
return browserBuild{
reason: browserReasonInvalidConfig,
detail: err.Error(),
configPath: configPath,
}
}
effective := browsercfg.Defaults(dataDir)
configSource := browserConfigSourceProbe
if fileCfg != nil {
effective = *fileCfg
configSource = browserConfigSourceFile
}
ctx, cancel := context.WithTimeout(context.Background(), browserProbeTimeout)
avail := browsercfg.Resolve(ctx, fileCfg, browsercfg.NewProber())
cancel()
if !avail.Usable {
return browserBuild{
reason: string(avail.Reason),
detail: avail.Detail,
configPath: configPath,
configSource: configSource,
}
}
// profile 含 cookie / 登录态:0700,且不得落在 git 工作树内(会被 git add 收走)。
if err := browsercfg.EnsureProfileDir(effective.ProfileDir, dataDir); err != nil {
return browserBuild{
reason: browserReasonProfileRejected,
detail: err.Error(),
configPath: configPath,
configSource: configSource,
}
}
mgr := browsercap.NewManager(browsercap.Options{
Availability: avail,
Config: effective,
DataDir: dataDir,
Logger: a.log,
})
opts := []browsertools.Option{browsertools.WithLogger(a.log)}
if carry != nil {
opts = append(opts, browsertools.WithShotOutbox(carry))
}
return browserBuild{
tool: browsertools.New(mgr, opts...),
mgr: mgr,
configPath: configPath,
configSource: configSource,
source: string(avail.Source),
headless: avail.Headless,
available: true,
}
}
// wireBrowser 装配 `browser` 工具。它没有错误返回值:一切失败都是**如实降级**
// (记分类原因 + 不注册),绝不阻塞启动。
func (a *app) wireBrowser() {
log := logger.WithSource(a.log, browserLogSource)
if a.caps == nil || a.cfg == nil {
// 部分测试夹具只填了 app 的几个字段;没有注册面就没有「注册」这件事。
return
}
if _, err := a.caps.Get(browsertools.ToolName); err == nil {
return // 同一 app 上重复装配:已经注册过,不重复探测
}
build, ok := a.registerBrowserBuild(log, nil)
if !ok {
return
}
log.Info("browser tool registered",
"source", build.source, "headless", build.headless,
"configSource", build.configSource, "configPath", build.configPath,
"shotsDir", browsercfg.DefaultShotsDir(resolveDataDir(a.cfg)),
"richActions", "click/type/wait/extract 由 MCP(Playwright/Puppeteer server)提供,本工具不重造")
}
// registerBrowserBuild 建出工具并注册进能力面;任何一步失败都**只**在日志里报分类原因。
// 抽成独立函数是为了让每一处「不注册」都只有一个出口(口径不会在两条分支里漂移)。
func (a *app) registerBrowserBuild(log *slog.Logger, carry *browsertools.ShotOutbox) (browserBuild, bool) {
build := a.buildBrowserTool(carry)
if !build.available {
log.Warn("browser tool not registered",
"reason", build.reason, "detail", build.detail,
"configPath", build.configPath, "configSource", build.configSource,
"effect", browserNotRegisteredEffect)
return browserBuild{}, false
}
if err := a.caps.RegisterTool(build.tool); err != nil {
// 注册失败就把刚建的管理器关掉:它可能已经持有 profile 锁与清扫协程,
// 留着就是一次凭据目录的泄漏(registerBrowserBuild 的失败路径只有这一条)。
if cerr := build.mgr.Close(); cerr != nil {
log.Warn("browser manager close failed after register error", "error", cerr)
}
log.Warn("browser tool register skipped", "tool", browsertools.ToolName, "error", err)
return browserBuild{}, false
}
return build, true
}
+214
View File
@@ -0,0 +1,214 @@
package main
// 本文件是 design/28 §7 判据 2(M4 闭环)的**组合根落点**:把「截图 → 进本回合上下文」
// 接成一条自动的路。三件事各归各家,本文件只做中间那一段接线:
//
// plugins/tools/browser(交图) → 本文件(装配期并入本轮视图) → multimodal/vision(成图/成文)
//
// 具体地:
//
// 1. `browser` 工具的 `screenshot` 动作落盘后把这张图放进它**自己的附件箱**
// (`plugins/tools/browser/attach.go`,按 `loop.SessionFromContext` 的会话分桶);
// 2. 本文件给出的 `TransformContext` 钩子在**每个模型步**之前被循环调用
// (`internal/runtime/loop/memory.go` 的 assembleContext 末尾),把该会话的待送截图
// **取走即消费**,经 `vision.ShotAttacher` 变成一条带图(或带文字描述)的 user 消息,
// 追加进本轮视图;
// 3. `ShotAttacher` 用**既有**的视觉路由决定形态:主模型能收图 ⇒ 内联成真的 image 部件;
// 收不了图 ⇒ vision/OCR 工具产出的文字(design/28 §5 的那一行)。
//
// 为什么用 `TransformContext` 而不是自己往 msgs 里写:循环对这个钩子的契约就是
// 「投影、过滤或重排模型所见的消息」(`loop.Options.TransformContext`),而且它
// **只影响本轮视图**、不写回运行窗口 —— 截图因此不会进 checkpoint 快照,也不会进
// 后台记忆提取的输入面(design/28 §4 第 3 条:「默认不进长期记忆」,截图不是记忆)。
//
// ⚠️ **接线状态(如实)**:本文件提供 `a.browserShotTransform(...)`,生产接线需要
// `wire_runtime.go` 的 `loop.New(loop.Options{…})` 里加一行:
//
// TransformContext: a.browserShotTransform(visionResolver),
//
// 本片(Y4)的文件面**不含** `wire_runtime.go`,因此那一行由主会话落地;落地前本文件
// 只在测试里被使用(`wire_browser_context_test.go` 用真实 loop + 探针适配器验证
// 「装上它之后,模型请求里确实出现 image 部件」)。
import (
"context"
"fmt"
"io"
"log/slog"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/vision"
browsertools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/browser"
)
// browserShotLogSource 是本文件日志的来源标签(AGENTS §10:禁止无来源裸日志)。
const browserShotLogSource = "cmd/cyrene/wire_browser_context"
// cropToolName 是裁切能力的工具名(`plugins/tools/io/crop.go` 的 Name)。
// 「大图先过 crop」这条要求落在这里:视觉层按区域裁一块再内联,不自己实现裁剪。
const cropToolName = "image-crop"
// browserShotTransform 造出装给 `loop.Options.TransformContext` 的钩子。
//
// resolver 就是运行时那一个 `*vision.MessageResolver`(组合根已经建好的那个实例),
// 因此截图与用户附图共用同一个 Router、同一份 data: URI 缓存 —— 两处各建一个会让
// 「同一种图两种处理」变成可能。
func (a *app) browserShotTransform(resolver *vision.MessageResolver) func(context.Context, []model.Message, int) []model.Message {
injector := &browserShotInjector{
app: a,
log: browserShotLogger(a.log),
}
if resolver != nil {
injector.attacher = vision.NewShotAttacher(resolver,
vision.WithShotCropper(browserShotCropper{invoke: a.browserInvoker()}))
}
return injector.apply
}
// browserShotLogger 给日志补来源标签并兜住 nil:`a.log` 为 nil 的夹具下
// `logger.WithSource` 会返回 nil,而注入器会直接调用它(nil 的 *slog.Logger 会 panic)。
func browserShotLogger(base *slog.Logger) *slog.Logger {
if base == nil {
return logger.WithSource(slog.New(slog.NewTextHandler(io.Discard, nil)), browserShotLogSource)
}
return logger.WithSource(base, browserShotLogSource)
}
// browserShotInjector 是钩子的实现体(把状态与函数分开,便于单测直接构造)。
type browserShotInjector struct {
app *app
attacher *vision.ShotAttacher
log *slog.Logger
}
// apply 在每个模型步之前把待送截图并入本轮视图。
//
// 它**绝不**因为一张图的问题让整个回合失败:任何一步失败都如实降级(图读不出来就只发
// 一句「没送成」的说明,模型仍能按路径自己读),因为「截图送不进去」远比「回合直接报错」
// 轻(同 vision 的既有口径:图像解析失败保留原消息、回合继续)。
func (in *browserShotInjector) apply(ctx context.Context, msgs []model.Message, _ int) []model.Message {
outbox := in.outbox()
if outbox == nil || in.attacher == nil {
return msgs
}
shots := outbox.Drain(loop.SessionFromContext(ctx))
if len(shots) == 0 {
return msgs
}
out := msgs
for _, shot := range shots {
att, err := in.attacher.Attach(ctx, vision.ShotRef{
Path: shot.Path,
Bytes: shot.Bytes,
Width: shot.Width,
Height: shot.Height,
Format: shot.Format,
})
if err != nil {
// 如实说明「这张图没送成」,并给出路径:模型仍可用 vision / image-crop 自己读。
in.log.Warn("browser screenshot not attached to the turn",
"path", shot.Path, "bytes", shot.Bytes, "error", err)
out = append(out, attachmentFailureMessage(shot.Path, err))
continue
}
in.log.Info("browser screenshot attached to the turn",
"path", shot.Path, "bytes", shot.Bytes, "route", att.Route, "cropped", att.Cropped)
out = append(out, attachmentMessage(att))
}
// 触发一次 len 变化后返回新切片(不修改入参切片:钩子的契约是「可以投影」,
// 而原地追加会改到循环持有的 msgs 底层数组)。
return append([]model.Message(nil), out...)
}
// outbox 取回 browser 工具的附件箱。工具没注册(本机没有浏览器)时返回 nil。
//
// 用**结构断言**而不是具体类型:将来若给工具套一层装饰(例如审计包装),
// 只要它还暴露 ShotOutbox 就仍然接得上,不必改这里。
func (in *browserShotInjector) outbox() *browsertools.ShotOutbox {
if in.app == nil || in.app.caps == nil {
return nil
}
entry, err := in.app.caps.Get(browsertools.ToolName)
if err != nil {
return nil
}
holder, ok := entry.Value.(interface {
ShotOutbox() *browsertools.ShotOutbox
})
if !ok {
return nil
}
return holder.ShotOutbox()
}
// browserInvoker 返回工具调用器(`image-crop` 经它调)。dispatcher 未接时返回 nil,
// 视觉层据此判断「没有裁切能力」并如实降级(见 vision.ShotAttacher.cropped)。
func (a *app) browserInvoker() vision.ToolInvoker {
if a == nil || a.dispatcher == nil {
return nil
}
return a.dispatcher.Invoke
}
// attachmentMessage 把一条附件结论渲染成**一条 user 消息**。
//
// 为什么是 user 角色:DeepSeek / Anthropic 协议的图像部件**只允许出现在 user 消息**里
// (design/24 §2.3 第 3 条),而工具结果回合在本项目是 `RoleTool`(文本-only 形状,
// `loop.toolResultMessage`)—— 往那里塞 image 部件会被编码器静默丢掉。
func attachmentMessage(att vision.ShotAttachment) model.Message {
note := strings.TrimSpace(att.Note)
if note == "" {
note = "[截图附件]这一步在浏览器里拍下的一页。"
}
parts := make([]model.Content, 0, 2)
parts = append(parts, model.Content{Type: model.ContentTypeText, Text: note})
if part := att.Content; part.Type == model.ContentTypeImage || strings.TrimSpace(part.Text) != "" {
parts = append(parts, part)
}
return model.Message{Role: model.RoleUser, Content: parts}
}
// attachmentFailureMessage 是「这张图没送成」的如实说明(**不带**图片内容,
// 只带路径与病因分类),使模型不会以为页面里什么都没有。
func attachmentFailureMessage(path string, err error) model.Message {
text := fmt.Sprintf("[截图附件]刚才那张截图**没有**并入本轮上下文(%v)。完整图在 %s,"+
"可用 vision、OCR 或 image-crop 工具按路径自行读取。", err, path)
return model.Message{Role: model.RoleUser, Content: []model.Content{
{Type: model.ContentTypeText, Text: text},
}}
}
// browserShotCropper 是「大图先过 crop」的执行者:把区域裁切交给既有的 `image-crop`
// 工具(同一份实现、同一份上限与同一套坐标语义),本层不重写裁剪。
type browserShotCropper struct {
invoke vision.ToolInvoker
}
// CropRegion 实现 vision.ShotCropper。
func (c browserShotCropper) CropRegion(ctx context.Context, path string, x, y, w, h int) (string, error) {
if c.invoke == nil {
return "", fmt.Errorf("browser: 没有可用的裁切能力(工具分发器未接线)")
}
res, err := c.invoke(ctx, cropToolName, map[string]any{
"image": path,
"x": x,
"y": y,
"width": w,
"height": h,
})
if err != nil {
return "", fmt.Errorf("browser: %s: %w", cropToolName, err)
}
m, ok := res.(map[string]any)
if !ok {
return "", fmt.Errorf("browser: %s 返回了非对象结果(%T)", cropToolName, res)
}
uri, _ := m["image"].(string)
if strings.TrimSpace(uri) == "" {
return "", fmt.Errorf("browser: %s 没返回裁切结果", cropToolName)
}
return uri, nil
}
+359
View File
@@ -0,0 +1,359 @@
package main
// 本文件是 design/28 §7 **判据 2(M4 闭环)的真跑**(切片 Y4 / B3):
//
// 一张**图里才有、HTML 里没有**的内容(这里由一张真实 PNG 代表)⇒ 它进了模型请求。
//
// 走的是**真实循环**(`loop.New` + 探针适配器,与组合根同一个装配形状),因此它验的是
// 完整那一段:截图箱 → `TransformContext` 钩子 → 视觉路由(主模型能收图 ⇒ 真 image 部件;
// 收不了图 ⇒ vision 工具产出的文字)→ 模型请求。
//
// 三段各自归各家、这里只做端到端:
// - 截图**落盘**(真 Chrome、真 CDP)由 `wire_browser_e2e_test.go` 与
// `internal/capability/browser/compare_test.go` 覆盖(需要真机,默认 Skip);
// - 「图 → 内容部件」的路由与裁切由 `internal/runtime/multimodal/vision/shot_test.go` 覆盖;
// - 本文件把两者接起来,并且**断言模型侧确实拿到 image 部件**(判据 2 的原文要求)。
//
// 生产侧的**两条挂点**都由本文件盯住(它们删掉之后都没有任何运行期症状):
// `TestWireRuntime_WiresBrowserShotTransform` 钉 `wire_runtime.go` 的 `TransformContext`;
// `TestWireFrontend_WiresBrowserPanel` 钉 `wire_frontend.go` 的 `wireBrowserPanel` 调用
// (`TestWirePanelHub_DeclaresBrowserPanel` 是它的**行为侧**配偶:真装配一遍再读回目录)。
import (
"bytes"
"context"
"image"
"image/color"
"image/png"
"os"
"path/filepath"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
browsercap "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser"
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/vision"
browsertools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/browser"
)
// b3Session 是本文件用的会话 id(截图箱按会话分桶,测试与循环必须用同一个)。
const b3Session = "sess-b3"
// b3RenderOnlyText 是「图里才有、HTML 里没有」的那句话:走工具形态时它必须出现在
// 模型请求里(纯文本路径的正面证据)。
const b3RenderOnlyText = "计费周期:2026-09 至 2026-10(这是渲染后才出现的文字)"
// b3PNG 造一张真实 PNG(视觉层对字节**嗅探**,假字节会被拒)。
func b3PNG(t *testing.T) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, 6, 4))
img.Set(0, 0, color.RGBA{R: 0x10, G: 0x20, B: 0x30, A: 0xff})
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatalf("编码 PNG: %v", err)
}
return buf.Bytes()
}
// b3Fixture 是一次闭环验证的夹具:真 loop + 探针适配器 + 装着截图的 browser 工具。
type b3Fixture struct {
adapter *compactProbeAdapter
loop *loop.Loop
outbox *browsertools.ShotOutbox
shot string
}
// newB3Fixture 装配夹具。sighted 决定主模型能不能收图(两条闭环路径的分岔点就在这里)。
func newB3Fixture(t *testing.T, sighted bool) *b3Fixture {
t.Helper()
data := b3PNG(t)
shotPath := filepath.Join(t.TempDir(), "shot-b3.png")
if err := os.WriteFile(shotPath, data, 0o600); err != nil {
t.Fatalf("写截图: %v", err)
}
outbox := browsertools.NewShotOutbox()
outbox.Put(b3Session, browsercapShot(shotPath, len(data)))
// 只接**附件箱**这一面:本用例验的是「截图落盘之后」那一段,真浏览器由真机 e2e 覆盖。
tool := browsertools.New(nil, browsertools.WithShotOutbox(outbox))
var out bytes.Buffer
a := &app{
cfg: &config.Config{DataDir: t.TempDir()},
log: logger.New(logger.Options{Out: &out, Level: "debug"}),
caps: registry.New(),
}
if err := a.caps.RegisterTool(tool); err != nil {
t.Fatalf("注册 browser 工具: %v", err)
}
adapter := &compactProbeAdapter{}
resolver := vision.NewMessageResolver(
vision.NewRouter(vision.WithVisionTool("vision"), vision.WithOCRTool("ocr")),
func() vision.ModelInfo {
return vision.ModelInfo{Name: "main", SupportsVision: sighted}
},
func(_ context.Context, _ string, _ map[string]any) (any, error) {
// 工具形态的假实现:返回「只有渲染后才看得见」的那句话。
return map[string]any{"description": b3RenderOnlyText, "text": b3RenderOnlyText}, nil
},
)
ag, err := agent.New("你是昔涟。", nil, nil, agent.WithModel(adapter, nil))
if err != nil {
t.Fatalf("agent.New: %v", err)
}
return &b3Fixture{
adapter: adapter,
outbox: outbox,
shot: shotPath,
loop: loop.New(loop.Options{
Agent: ag,
// ← 与生产接线逐字同一行(wire_runtime.go 的 loop.Options.TransformContext),
// 由 TestWireRuntime_WiresBrowserShotTransform 源码级盯住。
TransformContext: a.browserShotTransform(resolver),
OnStream: func(model.StreamEvent) {},
}),
}
}
// browsercapShot 造与落盘结论同形的截图值。
func browsercapShot(path string, size int) browsercap.Shot {
return browsercap.Shot{Path: path, Bytes: size, Width: 6, Height: 4, Format: "png",
DimensionsKnown: true}
}
// TestBrowserShotClosure_SightedModelReceivesTheImage 是判据 2 的正面:
// 主模型能收图 ⇒ 模型请求里**确实有 image 部件**,且是服务端内联的 data: URI。
func TestBrowserShotClosure_SightedModelReceivesTheImage(t *testing.T) {
t.Parallel()
fx := newB3Fixture(t, true)
if _, err := fx.loop.Run(context.Background(), b3Session,
[]model.Message{model.TextMessage(model.RoleUser, "这一页写了什么?")}); err != nil {
t.Fatalf("Run: %v", err)
}
if len(fx.adapter.reqs) == 0 {
t.Fatal("没有捕获到模型请求")
}
req := fx.adapter.reqs[len(fx.adapter.reqs)-1]
part, ok := findImagePart(req.Messages)
if !ok {
t.Fatalf("模型请求里没有 image 部件 —— 截图没有进上下文(判据 2 不成立);消息数=%d",
len(req.Messages))
}
if !strings.HasPrefix(part.ImageURL, "data:image/png;base64,") {
t.Errorf("image 部件不是内联的 data: URI(云 provider 取不到本地路径):%.60s", part.ImageURL)
}
if !imagePartIsOnUserMessage(req.Messages) {
t.Error("image 部件不在 user 消息上:Anthropic/DeepSeek 只接受 user 消息里的图像部件")
}
if note := lastUserText(req.Messages); !strings.Contains(note, fx.shot) {
t.Errorf("附件说明里没给出截图路径(模型无法据此裁区域精读):%q", note)
}
}
// TestBrowserShotClosure_TextOnlyModelGetsToolFormText 是判据 2 的另一条路:
// 主模型读不了图 ⇒ 图**不进**上下文,进去的是 vision 工具的读数(「渲染后才出现的文字」)。
func TestBrowserShotClosure_TextOnlyModelGetsToolFormText(t *testing.T) {
t.Parallel()
fx := newB3Fixture(t, false)
if _, err := fx.loop.Run(context.Background(), b3Session,
[]model.Message{model.TextMessage(model.RoleUser, "这一页写了什么?")}); err != nil {
t.Fatalf("Run: %v", err)
}
req := fx.adapter.reqs[len(fx.adapter.reqs)-1]
if _, hasImage := findImagePart(req.Messages); hasImage {
t.Error("主模型读不了图时不该把 image 部件塞进上下文(那是无意义的 token 与 400 风险)")
}
text := allText(req.Messages)
if !strings.Contains(text, b3RenderOnlyText) {
t.Errorf("工具形态的文字没进上下文:%q", text)
}
}
// TestBrowserShotClosure_ShotIsConsumedOnce 钉住「取走即消费」:同一张截图不会被送到两次
// (重复送图既费预算,又会诱导模型以为「又拍了一张」)。
func TestBrowserShotClosure_ShotIsConsumedOnce(t *testing.T) {
t.Parallel()
fx := newB3Fixture(t, true)
msgs := []model.Message{model.TextMessage(model.RoleUser, "看看这一页")}
if _, err := fx.loop.Run(context.Background(), b3Session, msgs); err != nil {
t.Fatalf("第一次 Run: %v", err)
}
if fx.outbox.Pending(b3Session) != 0 {
t.Fatalf("第一次之后箱子里还剩 %d 张(取走即消费被破坏)", fx.outbox.Pending(b3Session))
}
if _, err := fx.loop.Run(context.Background(), b3Session, msgs); err != nil {
t.Fatalf("第二次 Run: %v", err)
}
if _, ok := findImagePart(fx.adapter.reqs[len(fx.adapter.reqs)-1].Messages); ok {
t.Error("同一张截图在第二次回合里又被送了一次")
}
}
// TestWireRuntime_WiresBrowserShotTransform 盯住生产接线那一行(B3 的生产挂点)。
//
// 它拦的是「以后有人把那行删了」:删掉之后**没有任何运行期症状**——编译照过、回合照跑,
// 只是截图不再进上下文。因此断言的是**一整行字段赋值**(行首到行尾),被注释掉的
// (`// TransformContext: …`)或写在别处的同名字符串都不会让它通过。
func TestWireRuntime_WiresBrowserShotTransform(t *testing.T) {
t.Parallel()
src := readSource(t, "wire_runtime.go")
const assignment = "TransformContext: a.browserShotTransform(visionResolver),"
if !hasNormalizedCodeLine(src, assignment) {
t.Fatalf("wire_runtime.go 的 loop.Options 里找不到 %q —— B3 截图闭环的生产挂点缺失:"+
"截图照落盘、回合照跑,只是**一张图都不会进上下文**", assignment)
}
// 钩子捕获的就是 `visionResolver`,因此它必须在 loop.New 之前建好;顺序反过来装上的
// 就是一个 nil 解析器(ShotAttacher 为 nil),截图静默不送——同样是**零症状**的坏法。
idxResolver := strings.Index(src, "visionResolver := vision.NewMessageResolver(")
idxLoop := strings.Index(src, "a.loop = loop.New(loop.Options{")
if idxResolver < 0 || idxLoop < 0 || idxResolver > idxLoop {
t.Fatalf("visionResolver 与 loop.New 的先后不对(偏移 %d vs %d):钩子必须在解析器**之后**装配",
idxResolver, idxLoop)
}
}
// TestWireFrontend_WiresBrowserPanel 盯住 B4 的生产挂点:`wirePanelHub` 必须在
// `declareBuiltinPanels` 之后调用 `wireBrowserPanel`。
//
// 与上面那条同族——删掉之后**没有任何编译期或运行期症状**:只是面板目录里没有 browser 页、
// 六个动作全回 501(`GET /api/v1/admin/panels` 少一条、点击全部 501),而整树测试照绿。
// 断言同样锚定**一整行调用**,注释掉那一行不会通过。
func TestWireFrontend_WiresBrowserPanel(t *testing.T) {
t.Parallel()
src := readSource(t, "wire_frontend.go")
const call = "wireBrowserPanel(hub, a, a.log)"
if !hasNormalizedCodeLine(src, call) {
t.Fatalf("wire_frontend.go 的 wirePanelHub 里找不到 %q —— B4 的 browser 面板声明"+
"与六个动作都进不了目录(面板无 browser 页、动作全 501)", call)
}
// 位置:声明在前、登记在后是 PLG-2 的硬要求(登记期会核对有没有对应声明)。这里钉住
// 它**在 wirePanelHub 内**且**在 declareBuiltinPanels 之后**,防止被挪到别处或提前。
idxDeclare := strings.Index(src, "declareBuiltinPanels(hub, log)")
idxBrowser := strings.Index(src, call)
if idxDeclare < 0 || idxBrowser < idxDeclare {
t.Fatalf("wireBrowserPanel 的调用位置不对(declareBuiltinPanels 偏移 %d,调用偏移 %d):"+
"它必须在声明之后(wirePanelHub 内部)", idxDeclare, idxBrowser)
}
}
// TestWirePanelHub_DeclaresBrowserPanel 是上面那条源码级断言的**行为侧配偶**:真的走一遍
// `wirePanelHub` 装配,再从面板中枢里读回目录与动作白名单 —— 声明在、六个动作也登记上,
// 才算 B4 的接线成立。
//
// 两条合起来才够:源码级那条拦「那一行被删掉」,这条拦「那一行还在、但调用链断了」
// (wireBrowserPanel 被改成早返回、hub 传错、或声明/登记顺序被调反 ⇒ 只会得到一串 WARN)。
// 这台机器上**没有**浏览器也没关系:不可用时面板仍要如实声明(`available=false`),
// 这正是 design/09 §5.2.1 的「该面板不注册 + 显式报错」之外的正常档。
func TestWirePanelHub_DeclaresBrowserPanel(t *testing.T) {
t.Parallel()
mgr := pluginmgr.NewManager(registry.New(), filepath.Join(t.TempDir(), "plugins.json"), testLog())
defer mgr.Close(context.Background())
b := bus.New()
defer b.Close()
wirePanelHub(&app{cfg: &config.Config{DataDir: t.TempDir()}, plugins: mgr, bus: b, log: testLog()})
hub := mgr.Panels()
if hub == nil {
t.Fatal("wirePanelHub 未把面板中枢接到插件管理器上")
}
entries, err := hub.List(auth.RoleOwner)
if err != nil {
t.Fatalf("List: %v", err)
}
found := false
for _, e := range entries {
if e.PluginID == browsertools.PanelPluginID && e.Panel.ID == browsertools.PanelIDSettings {
found = true
}
}
if !found {
t.Fatalf("面板目录里没有 browser/settings(目录=%+v):B4 的挂点没生效", entries)
}
for _, action := range []string{
browsertools.ActionState, browsertools.ActionFingerprint, browsertools.ActionShots,
browsertools.ActionShoot, browsertools.ActionCheck, browsertools.ActionSaveConfig,
} {
if _, ok := hub.Action(browsertools.PanelPluginID, action); !ok {
t.Errorf("动作 %q 没进白名单(声明有、没人执行 ⇒ 面板点下去只会回 501)", action)
}
}
}
/* ---------------------------------------------------------------------------
* 断言助手
* ------------------------------------------------------------------------- */
// hasNormalizedCodeLine 报告源码里是否存在这样**一行代码**:去掉行首空白、把连续的空白
// 折叠成一个空格之后,与 want 逐字相等。被 `//` 注释掉的行一律不算。
//
// 为什么要折叠空白:字段赋值在 gofmt 里按块对齐(`TransformContext:` 与
// `DocumentMaxInjectChars:` 同一组时中间是 7 个空格,格式而非语义),逐字节比对整行会
// 在**一次无关的字段增删触发重新对齐**时误红;而折叠后逐行相等仍然能挡住
// 「注释掉那一行」与「别处的同名字符串」这两种假绿。
func hasNormalizedCodeLine(src, want string) bool {
for _, line := range strings.Split(src, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "//") {
continue
}
if strings.Join(strings.Fields(line), " ") == want {
return true
}
}
return false
}
// findImagePart 返回请求里的第一个 image 部件。
func findImagePart(msgs []model.Message) (model.Content, bool) {
for _, m := range msgs {
for _, part := range m.Content {
if part.Type == model.ContentTypeImage {
return part, true
}
}
}
return model.Content{}, false
}
// imagePartIsOnUserMessage 报告 image 部件是否挂在 user 消息上(协议硬要求)。
func imagePartIsOnUserMessage(msgs []model.Message) bool {
for _, m := range msgs {
for _, part := range m.Content {
if part.Type == model.ContentTypeImage {
return m.Role == model.RoleUser
}
}
}
return false
}
// allText 拼出请求里全部文本(断言工具形态的文字是否进了上下文)。
func allText(msgs []model.Message) string {
var b strings.Builder
for _, m := range msgs {
b.WriteString(loopText(m))
b.WriteString("\n")
}
return b.String()
}
// lastUserText 取最后一条 user 消息的文本(附件说明挂在它上面)。
func lastUserText(msgs []model.Message) string {
for i := len(msgs) - 1; i >= 0; i-- {
if msgs[i].Role == model.RoleUser {
return loopText(msgs[i])
}
}
return ""
}
+147
View File
@@ -0,0 +1,147 @@
package main
// 本文件是 `browser` 接线的**真机端到端**(默认不跑):从浏览器配置 → 组合根接线 →
// 注册面 → 模型会调用的那把工具 → 真 Chrome → 截图落盘,全程不假任何一层。
//
// 用法(Windows / Git Bash):
//
// CYRENE_BROWSER_E2E=1 go test ./cmd/cyrene/ -run TestBrowserTool_RealHostEndToEnd -v -timeout 5m
//
// 为什么默认 Skip:它会**真的起一个浏览器**、并且要打一次外网(本机没有 Chrome 时
// 自动 Skip)。这类验证属于「人工按需复跑」,不该混进普通 `go test`(AGENTS §10
// 的压测纪律:不在验收里无界占机)。
//
// 为什么用**无头**:这是自动化验证,不该在主人的桌面上弹一个窗口;有头路径的
// 真实验证走 `internal/capability/browser/compare_test.go` 的对照脚本(它默认无头/有头各跑一遍)。
import (
"bytes"
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
browsercap "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// browserE2EEnabled 报告是否显式开启了真机 e2e。
func browserE2EEnabled() bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv("CYRENE_BROWSER_E2E"))) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
// TestBrowserTool_RealHostEndToEnd 走完整条链并断言**页面自报**的事实。
func TestBrowserTool_RealHostEndToEnd(t *testing.T) {
if !browserE2EEnabled() {
t.Skip("未设 CYRENE_BROWSER_E2E=1:真机端到端默认不跑(用法见本文件顶部)")
}
exe, ok := browsercfg.NewProber().FindLocalBrowser()
if !ok {
t.Skip("本机没有 Chrome/Chromium,真机 e2e 无法进行")
}
cfg := wsLoadConfig(t)
raw, _ := json.Marshal(map[string]any{
"executable": exe,
"headless": true,
"timeoutSeconds": 60,
})
writeBrowserConfig(t, cfg, string(raw))
var out bytes.Buffer
a := browserTestApp(t, cfg, &out)
a.wireBrowser()
cap, ok := browserEntry(a)
if !ok {
t.Fatalf("可用时 browser 应进注册面(日志:%s)", out.String())
}
tool, ok := cap.Value.(sdk.Tool)
if !ok {
t.Fatalf("注册的值不是 sdk.Tool: %T", cap.Value)
}
// 收尾必须真的把浏览器关掉:用例结束会删临时目录(含 profile),Chrome 还在跑就会被
// 占用而删不掉。这条 defer 同时是**生产缺口**的现场证明(Tool.Close 目前无人调用)。
if closer, ok := tool.(interface{ Close() error }); ok {
defer func() {
if cerr := closer.Close(); cerr != nil {
t.Errorf("Close: %v", cerr)
}
}()
} else {
t.Error("工具没实现 Close:组合根将没有关闭浏览器的挂点")
}
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
defer cancel()
// ① 指纹自检:证明「这次真的是没在自曝」——navigator.webdriver 必须是 false。
info, err := tool.Execute(ctx, map[string]any{"action": "info"})
if err != nil {
t.Fatalf("action=info: %v", err)
}
fp, ok := info.(browsercap.Fingerprint)
if !ok {
t.Fatalf("info 结果类型 = %T", info)
}
if fp.Webdriver {
t.Errorf("navigator.webdriver = true:自动化痕迹没关掉(%+v)", fp)
}
t.Logf("指纹自检:headless=%v webdriver=%v ua=%s tz=%s lang=%s screen=%dx%d outer=%dx%d",
fp.Headless, fp.Webdriver, fp.UserAgent, fp.Timezone, fp.Language,
fp.ScreenWidth, fp.ScreenHeight, fp.OuterWidth, fp.OuterHeight)
if strings.Contains(strings.ToLower(fp.UserAgent), "headlesschrome") {
t.Logf("提示:无头档的 UA 带 HeadlessChrome —— 这是无头与有头唯一必然不同的一位")
}
// ② 导航:读回页面**自报**的标题(不是我们以为的)。
opened, err := tool.Execute(ctx, map[string]any{"action": "open", "url": "https://example.com/"})
if err != nil {
t.Fatalf("action=open: %v", err)
}
page, ok := opened.(browsercap.PageResult)
if !ok {
t.Fatalf("open 结果类型 = %T", opened)
}
if !strings.Contains(page.Title, "Example") {
t.Errorf("标题 = %q,期望页面自报的 Example Domain", page.Title)
}
if page.ReadyState != "complete" {
t.Errorf("readyState = %q", page.ReadyState)
}
t.Logf("导航:title=%q url=%q readyState=%q source=%q", page.Title, page.URL, page.ReadyState, page.Source)
// ③ 截图落盘:路径存在、非空、尺寸来自字节本身。
shotRaw, err := tool.Execute(ctx, map[string]any{"action": "screenshot"})
if err != nil {
t.Fatalf("action=screenshot: %v", err)
}
shot := shotRaw.(map[string]any)
path, _ := shot["path"].(string)
if path == "" {
t.Fatalf("截图没有路径: %#v", shot)
}
fi, err := os.Stat(path)
if err != nil {
t.Fatalf("截图没落盘: %v", err)
}
if fi.Size() == 0 {
t.Fatal("截图是 0 字节")
}
if !strings.HasPrefix(path, filepath.Join(cfg.DataDir, "browser", "shots")) {
t.Errorf("截图没落在 $DATA_DIR/browser/shots 下: %s", path)
}
width, _ := shot["width"].(int)
height, _ := shot["height"].(int)
if width <= 0 || height <= 0 {
t.Errorf("尺寸没解出来: %#v", shot)
}
t.Logf("截图:path=%s bytes=%v %dx%d sha256=%v", path, shot["bytes"], width, height, shot["sha256"])
}
+188
View File
@@ -0,0 +1,188 @@
package main
// 本文件钉住 `browser` 工具的**生命周期两端**(切片 X3 的收口):
//
// - **装配端**:接线挂点在 `wireRuntime`(与 wireVision / wireTranscode 并列),
// **不再**借 `ioToolOptions()` 转交 —— 挂点是源码级事实,只有读源码才钉得住,
// 因此这里照 wire_channels_test.go 的既有做法断言那一行。
// - **关闭端**:`browser` 工具持有**进程外**资源(我们起的真 Chromium 子进程 + CDP
// 连接 + profile 锁),进程退出不会带走它(Windows 上父进程死不会杀子进程)。
// 常驻用例证明「关闭链真的走到了那一把工具背后的管理器」;`CYRENE_BROWSER_E2E=1`
// 的真机用例进一步证明「真 Chrome 的调试端点真的不再应答」。
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
browsercap "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// TestWireRuntime_CallsWireBrowser 钉住**挂点归位**(切片 X3):browser 的接线由
// `wireRuntime` 调用,位置与 wireVision / wireTranscode 并列。
//
// 为什么必须钉:这条调用是「浏览器工具在不在注册面里」的唯一开关,而它删掉之后
// **没有任何运行期症状** —— 编译照过、启动照起、日志照打,只有模型那边少了一个工具
// (本机有 Chrome 也不会注册)。这与 wire_channels_test.go 钉 VoiceTranscoder 注入
// 是同一类判据:能力静默消失只能靠测试拦。
func TestWireRuntime_CallsWireBrowser(t *testing.T) {
t.Parallel()
src := readSource(t, "wire_runtime.go")
if !strings.Contains(src, "\ta.wireBrowser()") {
t.Fatal("wire_runtime.go 里找不到 `a.wireBrowser()` 这一行 —— browser 工具不会被装配," +
"模型面里静默少一个工具(挂点应与之并列 wireVision / wireTranscode)")
}
}
// TestAppClose_ClosesBrowserSession 钉住**关闭链真的关掉了浏览器管理器**。
//
// 判据不是「Close 被调过」(那只能证明调用点存在),而是**关闭之后的第一个动作**:
// 管理器已进入 closed 态 ⇒ 如实报 ErrNoSession,而不是偷偷再拉起一个浏览器
// (拉起会得到 ErrLaunch,两者一眼可分)。
func TestAppClose_ClosesBrowserSession(t *testing.T) {
t.Parallel()
cfg := wsLoadConfig(t)
// 可执行文件只需**存在**:本用例不启动它,只验证关闭链把管理器关停了。
exe := filepath.Join(t.TempDir(), "fake-browser.exe")
if err := os.WriteFile(exe, []byte("x"), 0o700); err != nil {
t.Fatalf("造假可执行文件: %v", err)
}
raw, _ := json.Marshal(map[string]any{"executable": exe})
writeBrowserConfig(t, cfg, string(raw))
var out bytes.Buffer
a := browserTestApp(t, cfg, &out)
a.wireBrowser()
cap, ok := browserEntry(a)
if !ok {
t.Fatalf("可用时 browser 应进注册面(日志:%s)", out.String())
}
tool, ok := cap.Value.(sdk.Tool)
if !ok {
t.Fatalf("注册的值不是 sdk.Tool: %T", cap.Value)
}
a.close(context.Background())
if !strings.Contains(out.String(), "browser tool closed") {
t.Errorf("关闭链没有留下收尾记录(日志:%s)", out.String())
}
if _, err := tool.Execute(context.Background(), map[string]any{"action": "info"}); !errors.Is(err, browsercap.ErrNoSession) {
t.Fatalf("关闭链没有关掉浏览器管理器:err = %v,want ErrNoSession(ErrLaunch 说明它又拉起了一个)", err)
}
}
// TestAppClose_RealChromeStopsOnShutdown 是真机端到端(默认 Skip):走**组合根的真实
// 关闭链**关掉一个**真的在跑**的 Chrome,并验证它的调试端点不再应答。
//
// 用法(Windows / Git Bash):
//
// CYRENE_BROWSER_E2E=1 go test ./cmd/cyrene/ -run TestAppClose_RealChromeStopsOnShutdown -v -timeout 5m
//
// 为什么默认 Skip:它会真的起一个浏览器(headless,不弹窗),属于「人工按需复跑」,
// 不该混进普通 `go test`(AGENTS §10 的压测纪律:验收里不无界占机)。
func TestAppClose_RealChromeStopsOnShutdown(t *testing.T) {
if !browserE2EEnabled() {
t.Skip("未设 CYRENE_BROWSER_E2E=1:真机端到端默认不跑(用法见本文件顶部)")
}
exe, ok := browsercfg.NewProber().FindLocalBrowser()
if !ok {
t.Skip("本机没有 Chrome/Chromium,真机 e2e 无法进行")
}
cfg := wsLoadConfig(t)
raw, _ := json.Marshal(map[string]any{
"executable": exe,
"headless": true,
"timeoutSeconds": 60,
"idleTimeoutSeconds": 600,
})
writeBrowserConfig(t, cfg, string(raw))
var out bytes.Buffer
a := browserTestApp(t, cfg, &out)
a.wireBrowser()
cap, ok := browserEntry(a)
if !ok {
t.Fatalf("可用时 browser 应进注册面(日志:%s)", out.String())
}
tool, ok := cap.Value.(sdk.Tool)
if !ok {
t.Fatalf("注册的值不是 sdk.Tool: %T", cap.Value)
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
// ① 起一个真浏览器(指纹自检走 CDP,无需外网),并取回它的调试端点。
if _, err := tool.Execute(ctx, map[string]any{"action": "info"}); err != nil {
t.Fatalf("action=info(起真浏览器): %v", err)
}
endpoint, ok := browsercap.DebugEndpoint(browsercfg.DefaultProfileDir(cfg.DataDir))
if !ok {
t.Fatalf("会话已建立,但 profile %s 下没有端口记录(显式端口由我们自己记,见 portrecord.go)",
browsercfg.DefaultProfileDir(cfg.DataDir))
}
if !endpointAlive(endpoint) {
t.Fatalf("会话已建立但调试端点 %s 不应答:本用例的前提不成立", endpoint)
}
t.Logf("真机前置:浏览器在跑,endpoint=%s", endpoint)
// ② 走生产路径关闭:组合根的 a.close(不是直接 tool.Close)。
shutCtx, shutCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer shutCancel()
a.close(shutCtx)
// ③ 进程外的那个浏览器必须真的没了:调试端点不再应答(轮询给一点收尾时间)。
if !waitEndpointGone(endpoint, 15*time.Second) {
t.Fatalf("a.close 之后 %s 仍在应答:浏览器没被关掉(孤儿 Chrome 会占着 profile 锁)", endpoint)
}
// ④ 管理器进入 closed 态:再调动作如实报「已关闭」,不是偷偷重起一个。
if _, err := tool.Execute(context.Background(), map[string]any{"action": "info"}); !errors.Is(err, browsercap.ErrNoSession) {
t.Errorf("关闭后 err = %v, want ErrNoSession", err)
}
t.Logf("真机关闭链:endpoint=%s 已停,管理器态=closed(日志尾部:%s)",
endpoint, tailOfLog(out.String()))
}
// endpointAlive 报告调试端点是否在应答(一次 /json/version)。
func endpointAlive(endpoint string) bool {
client := &http.Client{Timeout: 2 * time.Second}
resp, err := client.Get(endpoint + "/json/version")
if err != nil {
return false
}
defer func() { _ = resp.Body.Close() }()
return resp.StatusCode == http.StatusOK
}
// waitEndpointGone 等端点停止应答,最多等 budget。
func waitEndpointGone(endpoint string, budget time.Duration) bool {
deadline := time.Now().Add(budget)
for {
if !endpointAlive(endpoint) {
return true
}
if time.Now().After(deadline) {
return false
}
time.Sleep(200 * time.Millisecond)
}
}
// tailOfLog 取日志末尾若干字符,供真机用例把现场证据打进 t.Log。
func tailOfLog(s string) string {
const keep = 400
if len(s) <= keep {
return s
}
return s[len(s)-keep:]
}
+366
View File
@@ -0,0 +1,366 @@
package main
// 本文件是 design/28 §8 **B4(M3 面板入口)的组合根接线**:把 `browser` 插件声明的
// 面板页与六个动作登记到插件管理器上(design/09 §5.2.1 的「插件管理器 = 唯一中枢」)。
//
// 五条口径:
//
// 1. **不开任何路由**:动作走宿主的通用动作端点
// `POST /api/v1/admin/plugins/{id}/actions/{action}`(PLG-2),插件只给动作名;
// 2. **面板声明与动作处理器同源**:动作名常量都在 `plugins/tools/browser/panel.go`,
// 本文件只做「名字 → 处理器」的映射,不另抄一份名字;
// 3. **读面不起浏览器**:`plugin.getBrowserState` / `plugin.listShots` /
// `plugin.getFingerprint` 只读内存事实(`state.go` 口径 1)。真正会起浏览器的只有两个
// **写**动作(`plugin.shootPage` / `plugin.checkFingerprint`),它们按 §5.4.1 只对
// owner/admin 可见可调;
// 4. **保存即热重载**:`plugin.saveBrowserConfig` 落 `$DATA_DIR/config/plugins/browser.json`
// 后**重建**工具并替换注册表条目(`registry.ReplaceTool`),所以面板文案说的
// 「保存后立即生效」是真话(design/09 §5.5 的同一条口径);
// 5. **失败要能分类**:一律经 `pluginmgr.ActionError` 交出「错误码 + 文案 + HTTP 状态」,
// 绝不回「已保存」而实际没生效(§5.4.6)。
//
// ⚠️ **接线状态(如实)**:本文件提供 `wireBrowserPanel(hub, a, log)`,生产接线需要在
// `wire_frontend.go` 的 `wirePanelHub` 里加一行(与 search 的声明并列):
//
// wireBrowserPanel(hub, a, log)
//
// 本片(Y4)的文件面**不含** `wire_frontend.go`,因此那一行由主会话落地;落地前本文件
// 只在测试里被使用(`wire_browser_panel_test.go` 用真实 hub + 假浏览器验证「声明过校验、
// 六个动作真能分发」)。
import (
"context"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
browsercap "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg"
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
browsertools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/browser"
)
// browserPanelLogSource 是本文件日志的来源标签(AGENTS §10:禁止无来源裸日志)。
const browserPanelLogSource = "cmd/cyrene/wire_browser_panel"
// 动作失败码(宿主级,只在 browser 面板这组文件里使用)。领域自己的码来自
// `browsercap` 的分类原因码。
const (
// browserCodeConfigUnreadable 报告面板配置存在但读不出来(损坏 / 权限)。
browserCodeConfigUnreadable = "config_unreadable"
// browserCodeConfigRejected 报告这次保存的配置**非法**(越界 / 端点非法):没落盘。
browserCodeConfigRejected = "config_rejected"
// browserCodeConfigWriteFailed 报告配置写不进去(磁盘 / 权限)。
browserCodeConfigWriteFailed = "config_write_failed"
// browserCodeUnavailable 报告本机现在没有可用浏览器来源(工具没进能力面)。
browserCodeUnavailable = "browser_unavailable"
// browserCodeNoSession 报告「当前没有打开的页面」:拍一张没有目标(面板不替模型开页)。
browserCodeNoSession = "browser_session_not_open"
// browserCodeActionFailed 报告浏览器动作执行失败(分类原因见日志)。
browserCodeActionFailed = "browser_action_failed"
)
// wireBrowserPanel 声明 browser 的面板页并把六个动作登记到面板中枢。
//
// 声明被拒只告警、不阻断启动(与 search 的处置一致:§5.2.1 的口径是「该面板不注册 +
// 显式报错」,不是「宿主陪葬」)。登记失败同理(面板会以 501 如实作答)。
func wireBrowserPanel(hub *pluginmgr.Panels, a *app, log *slog.Logger) {
if hub == nil || a == nil {
return
}
env := browserPanelEnv{
app: a,
hub: hub,
log: browserPanelLogger(log),
path: browsercfg.DefaultConfigPath(resolveDataDir(a.cfg)),
}
env.declare()
if err := hub.RegisterActions(browsertools.PanelPluginID, env.handlers()); err != nil {
env.log.Error("browser panel actions not registered; panel actions will answer 501",
"pluginId", browsertools.PanelPluginID, "error", err)
return
}
env.warnImpliedButMissing()
}
// browserPanelLogger 给日志补上本文件的来源标签,并兜住 nil(`logger.WithSource(nil, …)`
// 返回 nil,而本文件多处直接 `env.log.Info/Warn` —— 对 nil 的 *slog.Logger 调用会 panic)。
// 与 `plugins/tools/browser` 的 discardLog 同一手法:nil 的语义是「不输出」,不是「不标注」。
func browserPanelLogger(base *slog.Logger) *slog.Logger {
if base == nil {
return logger.WithSource(slog.New(slog.NewTextHandler(io.Discard, nil)), browserPanelLogSource)
}
return logger.WithSource(base, browserPanelLogSource)
}
// browserPanelEnv 是六个动作共享的接线事实(一次装配、多动作复用)。
//
// 刻意只装**在多个动作间共享**的东西(日志出口、配置文件路径、面板中枢),每次调用各自
// 读文件/读注册面:动作之间不共享「当前配置」这份可变状态,就不会出现「A 动作读到的是
// B 动作刚改之前的那份」。
type browserPanelEnv struct {
app *app
hub *pluginmgr.Panels
log *slog.Logger
path string
}
// handlers 返回本插件声明的**六个动作**的处理器(键 = 声明里的动作名,同源引用常量)。
func (e browserPanelEnv) handlers() map[string]pluginmgr.ActionHandler {
return map[string]pluginmgr.ActionHandler{
browsertools.ActionState: e.state,
browsertools.ActionShots: e.shots,
browsertools.ActionFingerprint: e.fingerprint,
browsertools.ActionShoot: e.shoot,
browsertools.ActionCheck: e.check,
browsertools.ActionSaveConfig: e.saveConfig,
}
}
// declare 用**当前盘上的配置**重新声明面板(表单默认值 = 现状)。保存后也会再调一次,
// 使面板上的值刷新成刚落盘的那份(前端提交成功后会重取快照)。
func (e browserPanelEnv) declare() {
cfg, err := e.currentConfig()
if err != nil {
e.log.Warn("browser panel declared with default config values",
"path", e.path, "error", err)
cfg = browsercfg.Defaults(resolveDataDir(e.app.cfg))
}
available := e.tool() != nil
accepted, derr := e.hub.Declare(browsertools.PanelPluginID, browsertools.AdminPanels(browsertools.PanelInput{
Config: cfg,
Available: available,
Source: e.source(),
}))
if derr != nil {
e.log.Warn("browser panel declaration rejected",
"pluginId", browsertools.PanelPluginID, "accepted", accepted, "error", derr)
return
}
e.log.Info("browser panel declared",
"pluginId", browsertools.PanelPluginID, "panels", accepted, "available", available)
}
// warnImpliedButMissing 把「声明里有、登记里没有」的动作如实报出来(端点对它的结算
// 就是 501)。它是 §5.2.1「表里写了一个没实现的动作,review 时一眼可见」的运行期版本。
func (e browserPanelEnv) warnImpliedButMissing() {
registered := make(map[string]bool)
for _, name := range e.hub.ActionHandlerNames(browsertools.PanelPluginID) {
registered[name] = true
}
for _, name := range e.hub.DeclaredActions(browsertools.PanelPluginID) {
if !registered[name] {
e.log.Warn("declared browser panel action has no handler; it will answer 501",
"pluginId", browsertools.PanelPluginID, "action", name)
}
}
}
// currentConfig 读盘上的 browser 配置(缺失 ⇒ 默认),已 Normalize。
func (e browserPanelEnv) currentConfig() (browsercfg.Config, error) {
dataDir := resolveDataDir(e.app.cfg)
fileCfg, err := browsercfg.Load(e.path, dataDir)
if err != nil {
return browsercfg.Config{}, err
}
out := browsercfg.Defaults(dataDir)
if fileCfg != nil {
out = *fileCfg
}
return out, nil
}
// tool 从能力面取回 browser 工具(没注册 ⇒ nil:面板要如实说明「这台机器上没有浏览器」,
// 而不是报一个空表把「没接线」伪装成「没有数据」)。
func (e browserPanelEnv) tool() *browsertools.Tool {
if e.app == nil || e.app.caps == nil {
return nil
}
entry, err := e.app.caps.Get(browsertools.ToolName)
if err != nil {
return nil
}
tool, _ := entry.Value.(*browsertools.Tool)
return tool
}
// source 返回来源描述(只进面板描述文案)。
func (e browserPanelEnv) source() string {
if tool := e.tool(); tool != nil {
return tool.State().Source
}
return ""
}
// state 是 `plugin.getBrowserState`:运行状态只读事实面的一行。**不启动浏览器**。
func (e browserPanelEnv) state(_ context.Context, _ pluginmgr.ActionRequest) (any, error) {
cfg, err := e.currentConfig()
if err != nil {
cfg = browsercfg.Defaults(resolveDataDir(e.app.cfg))
}
snap := browsercapState(e.tool(), cfg)
return map[string]any{"rows": browsertools.StateRows(snap)}, nil
}
// fingerprint 是 `plugin.getFingerprint`:**上一次**自检的读数(未自检过 ⇒ 如实说「未自检」)。
func (e browserPanelEnv) fingerprint(_ context.Context, _ pluginmgr.ActionRequest) (any, error) {
snap := browsercapState(e.tool(), browsercfg.Config{})
return map[string]any{"rows": browsertools.FingerprintRows(snap.Fingerprint, snap.FingerprintAt)}, nil
}
// shots 是 `plugin.listShots`:最近截图统计(**不含路径与内容**)。
func (e browserPanelEnv) shots(_ context.Context, _ pluginmgr.ActionRequest) (any, error) {
snap := browsercapState(e.tool(), browsercfg.Config{})
return map[string]any{"rows": browsertools.ShotRows(snap.Shots)}, nil
}
// shoot 是 `plugin.shootPage`(写动作,owner-only):拍一张**当前页面**。
//
// 为什么需要会话已在跑:面板**不替模型开页**(否则它就成了「任意 URL 的截图器」,
// design/28 §1 M3 明确否掉的那个面)。主人先在对话里让它打开某页(或人工在有头窗口里
// 接管),再从面板拍一张 —— 真实排障流程就是这样。
func (e browserPanelEnv) shoot(ctx context.Context, _ pluginmgr.ActionRequest) (any, error) {
tool := e.tool()
if tool == nil {
return nil, pluginmgr.NewActionError(browserCodeUnavailable,
"本机没有可用的浏览器来源(未探测到 Chrome/Chromium,也没有在跑的 CDP 端点)",
http.StatusConflict, nil)
}
if !tool.State().SessionLive {
return nil, pluginmgr.NewActionError(browserCodeNoSession,
"浏览器当前没有打开的页面:先让她打开一个页面(或在有头窗口里人工接管),再回面板拍一张",
http.StatusConflict, nil)
}
res, err := tool.Execute(ctx, map[string]any{"action": "screenshot"})
if err != nil {
e.log.Warn("panel screenshot failed", "error", err)
return nil, pluginmgr.NewActionError(browserCodeActionFailed,
"截图失败:"+err.Error(), http.StatusBadGateway, err)
}
return map[string]any{"shot": res}, nil
}
// check 是 `plugin.checkFingerprint`(写动作,owner-only):真起一次浏览器读指纹。
// 它是本页唯一「更新只读事实面」的写动作 —— 读面本身绝不启动浏览器(口径 1)。
func (e browserPanelEnv) check(ctx context.Context, _ pluginmgr.ActionRequest) (any, error) {
tool := e.tool()
if tool == nil {
return nil, pluginmgr.NewActionError(browserCodeUnavailable,
"本机没有可用的浏览器来源,无法自检", http.StatusConflict, nil)
}
if _, err := tool.Execute(ctx, map[string]any{"action": "info"}); err != nil {
e.log.Warn("panel fingerprint check failed", "error", err)
return nil, pluginmgr.NewActionError(browserCodeActionFailed,
"指纹自检失败:"+err.Error(), http.StatusBadGateway, err)
}
snap := tool.State()
return map[string]any{
"rows": browsertools.FingerprintRows(snap.Fingerprint, snap.FingerprintAt),
}, nil
}
// saveConfig 是 `plugin.saveBrowserConfig`(写动作,owner-only):落盘 + 热重载。
//
// 事务形状与 web-search 的热重载同族(`websearch_reload.go`):
//
// ① 快照文件 → ② 校验并落盘 → ③ 按落盘后的配置重建工具并替换注册表条目;
// ④ **保存前本来有可用工具**而 ③ 失败 ⇒ 回滚文件 + 409:绝不让一次保存把「盘上的配置」
// 与「跑着的那个浏览器」静默拆开(那正是 design/09 §5.4.6 点名要防的「报成功但没生效」)。
// 保存前本来**没有**可用工具(例如第一次配一个还没起来的容器)⇒ 文件留下、如实回
// `applied=false` + 原因码:那种情形没有能力可丢,报错反而会让主人**没法先配好**。
func (e browserPanelEnv) saveConfig(ctx context.Context, req pluginmgr.ActionRequest) (any, error) {
patch, err := browserConfigPatch(req.Payload)
if err != nil {
return nil, err
}
cur, err := e.currentConfig()
if err != nil {
return nil, pluginmgr.NewActionError(browserCodeConfigUnreadable,
"面板配置读不出来,本次**没有**改动它;详见服务端日志", http.StatusInternalServerError, err)
}
next, err := applyBrowserConfigPatch(cur, patch)
if err != nil {
return nil, err
}
hadTool := e.tool() != nil
snap, snapExisted, serr := readConfigSnapshot(e.path)
if serr != nil {
return nil, pluginmgr.NewActionError(browserCodeConfigUnreadable,
"面板配置读不出来,本次**没有**改动它", http.StatusInternalServerError, serr)
}
if err := browsercfg.Save(e.path, resolveDataDir(e.app.cfg), next); err != nil {
return nil, pluginmgr.NewActionError(browserCodeConfigWriteFailed,
"配置写不进去(磁盘或权限)", http.StatusInternalServerError, err)
}
applied, registered, reason := e.app.replaceBrowserTool(e.log)
if !applied && hadTool {
if rerr := restoreConfigSnapshot(e.path, snap, snapExisted); rerr != nil {
e.log.Error("browser config rollback failed after a save that did not apply",
"path", e.path, "reason", reason, "rollbackError", rerr)
return nil, pluginmgr.NewActionError(browserCodeConfigWriteFailed,
"这份配置没能生效,且回滚失败:盘上的文件可能已经变了(详见服务端日志)",
http.StatusInternalServerError, rerr)
}
return nil, pluginmgr.NewActionError(browserCodeConfigRejected,
"这份配置没能生效(已回滚到保存前那一份):"+reason, http.StatusConflict, nil)
}
e.declare()
return map[string]any{
"saved": true,
"applied": applied,
"registered": registered,
"reason": reason,
}, nil
}
// browsercapState 取只读事实快照:没有工具时用**盘上的配置**兜底(面板仍要显示现状),
// 而不是回一个空对象让页面显示一片「—」。
func browsercapState(tool *browsertools.Tool, cfg browsercfg.Config) browsercap.StateSnapshot {
if tool != nil {
return tool.State()
}
return browsercap.StateSnapshot{
Available: false,
Headless: cfg.Headless,
ViewportWidth: cfg.ViewportWidth,
ViewportHeight: cfg.ViewportHeight,
ProfileDir: cfg.ProfileDir,
Concurrency: cfg.Concurrency,
IdleTimeoutSeconds: cfg.IdleTimeoutSeconds,
ScreenshotMaxBytes: cfg.ScreenshotMaxBytes,
}
}
// decodeUserValues 把动作载荷里的 `values` 取出来(前端表单的提交形状是 `{values:{…}}`)。
// 形状不对时返回结构化错误,绝不「当成空补丁」静默保存一份什么都没改的配置。
func decodeUserValues(payload any) (map[string]any, error) {
m, ok := payload.(map[string]any)
if !ok {
return nil, pluginmgr.NewActionError(pluginmgr.ActionCodeInvalidPayload,
"请求体不是对象:表单提交的形状是 {\"values\":{…}}", http.StatusBadRequest, nil)
}
raw, exists := m["values"]
if !exists || raw == nil {
return nil, pluginmgr.NewActionError(pluginmgr.ActionCodeInvalidPayload,
"请求体缺少 values(没有要保存的内容)", http.StatusBadRequest, nil)
}
values, ok := raw.(map[string]any)
if !ok {
return nil, pluginmgr.NewActionError(pluginmgr.ActionCodeInvalidPayload,
"values 不是对象", http.StatusBadRequest, nil)
}
return values, nil
}
// requireString 取一个字符串补丁值(空串是合法值:清空 profile/端点即「回落自动探测」)。
func requireString(key string, raw any) (string, error) {
s, ok := raw.(string)
if !ok {
return "", pluginmgr.NewActionError(pluginmgr.ActionCodeInvalidPayload,
fmt.Sprintf("字段 %s 需要一个字符串", key), http.StatusBadRequest, nil)
}
return strings.TrimSpace(s), nil
}
+246
View File
@@ -0,0 +1,246 @@
package main
// 本文件是 browser 面板的**数据面**(切片 Y4 / B4):把动作载荷翻成配置补丁、把配置
// 写盘/回滚,以及保存之后的**热重载**(重建工具 + 换注册表条目)。
//
// 拆出来的理由同 web-search 那一组:`wire_browser_panel.go` 只留「名字 → 处理器」的接线,
// 载荷解析与文件事务各自成文件,函数才守得住 ≤80 行。
//
// 两条口径:
//
// 1. **补丁是补丁**:前端提交的是 `{values:{…}}`,而 `planSubmit` 只发**改动过的**字段
// (见 `frontend/web/src/admin/schema/values.ts`)⇒ 没出现的键一律**保持原值**,
// 绝不把「没提到」当成「清空」(那是静默清空的经典事故,design/09 §5.4.2);
// 2. **回滚是凭据**:保存前先把盘上那一份读进内存,热重载失败就写回去 —— 绝不把后端
// 留在「盘上这份配置让它下次起不来」的状态。
import (
"fmt"
"log/slog"
"net/http"
"os"
"strings"
browsercap "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg"
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
browsertools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/browser"
)
// browserConfigRollbackMode 是回滚写回时的文件权限:与 `browsercfg.Save` 的 0600 同口径
// (那份文件带显式 CDP 端点与 profile 位置)。硬编码 0600 而不是引用对方的常量,是因为
// 变化方向是「收紧」,不存在比 0600 更松的风险。
const browserConfigRollbackMode os.FileMode = 0o600
// browserConfigPatch 解析表单提交的载荷(`{values:{…}}`)并做**形状**校验:
// 只接受本面板声明过的键,未知键一律拒(**绝不静默忽略** —— 静默忽略会让「我改了但它没生效」
// 变成查不出的谜,design/09 §5.2.1 的同一条口径)。
func browserConfigPatch(payload any) (map[string]any, error) {
values, err := decodeUserValues(payload)
if err != nil {
return nil, err
}
known := map[string]bool{
browsertools.FieldHeadless: true,
browsertools.FieldExecutable: true,
browsertools.FieldCDPEndpoint: true,
browsertools.FieldProfileDir: true,
browsertools.FieldViewportWidth: true,
browsertools.FieldViewportHeight: true,
browsertools.FieldIdleTimeout: true,
browsertools.FieldConcurrency: true,
}
out := make(map[string]any, len(values))
for key, raw := range values {
if !known[key] {
return nil, pluginmgr.NewActionError(pluginmgr.ActionCodeInvalidPayload,
fmt.Sprintf("未知字段 %q:本面板只接受 %d 个已声明字段", key, len(known)),
http.StatusBadRequest, nil)
}
out[key] = raw
}
return out, nil
}
// applyBrowserConfigPatch 把补丁合进当前配置。未出现的键保持原值(见文件头口径 1)。
//
// 类型不匹配一律报 400(`values` 已由前端按声明类型还原:number → 数字、switch → 布尔);
// 越界值交给 `browsercfg.Validate` 判(它只判**原始值**,不 Normalize —— 悄悄改小会让
// 面板显示的与生效的不是一回事)。
func applyBrowserConfigPatch(cur browsercfg.Config, patch map[string]any) (browsercfg.Config, error) {
out := cur
for key, raw := range patch {
var err error
switch key {
case browsertools.FieldHeadless:
out.Headless, err = patchBool(key, raw)
case browsertools.FieldExecutable:
out.Executable, err = requireString(key, raw)
case browsertools.FieldCDPEndpoint:
out.CDPEndpoint, err = requireString(key, raw)
case browsertools.FieldProfileDir:
out.ProfileDir, err = requireString(key, raw)
case browsertools.FieldViewportWidth:
out.ViewportWidth, err = patchInt(key, raw)
case browsertools.FieldViewportHeight:
out.ViewportHeight, err = patchInt(key, raw)
case browsertools.FieldIdleTimeout:
out.IdleTimeoutSeconds, err = patchInt(key, raw)
case browsertools.FieldConcurrency:
out.Concurrency, err = patchInt(key, raw)
default:
err = fmt.Errorf("未知字段 %q", key)
}
if err != nil {
return browsercfg.Config{}, pluginmgr.NewActionError(pluginmgr.ActionCodeInvalidPayload,
fmt.Sprintf("字段 %s 的取值不合法:%v", key, err), http.StatusBadRequest, err)
}
}
if verr := out.Validate(); verr != nil {
return browsercfg.Config{}, pluginmgr.NewActionError(browserCodeConfigRejected,
"这份配置超出硬上限,**没有**落盘:"+verr.Error(), http.StatusConflict, verr)
}
return out, nil
}
// patchBool 取一个布尔补丁值。
func patchBool(key string, raw any) (bool, error) {
v, ok := raw.(bool)
if !ok {
return false, fmt.Errorf("需要布尔值")
}
return v, nil
}
// patchInt 取一个数值补丁值(JSON 数字解出来是 float64;整数字符串也认,因为
// 「留空/清空」在某些控件上会以空串抵达)。
func patchInt(key string, raw any) (int, error) {
switch v := raw.(type) {
case float64:
if v != float64(int(v)) {
return 0, fmt.Errorf("需要整数")
}
return int(v), nil
case int:
return v, nil
case string:
trimmed := strings.TrimSpace(v)
if trimmed == "" {
// 清空 = 回落默认(Normalize 的口径:≤0 ⇒ 用默认值)。
return 0, nil
}
var n int
if _, err := fmt.Sscanf(trimmed, "%d", &n); err != nil {
return 0, fmt.Errorf("需要整数")
}
return n, nil
default:
return 0, fmt.Errorf("需要整数")
}
}
// readConfigSnapshot 读一份文件快照(回滚凭据)。文件不存在时返回 (nil, false, nil) ——
// 那是「保存前没有配置文件」这种合法状态,回滚时要把它恢复成「没有文件」。
func readConfigSnapshot(path string) ([]byte, bool, error) {
raw, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, false, nil
}
return nil, false, fmt.Errorf("读配置快照 %s: %w", path, err)
}
return raw, true, nil
}
// restoreConfigSnapshot 把文件恢复到快照那一份;快照本来不存在 ⇒ 删掉它。
func restoreConfigSnapshot(path string, raw []byte, existed bool) error {
if !existed {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("删除配置 %s: %w", path, err)
}
return nil
}
if err := os.WriteFile(path, raw, browserConfigRollbackMode); err != nil {
return fmt.Errorf("回写配置 %s: %w", path, err)
}
return nil
}
// replaceBrowserTool 按**落盘后的配置**重建 browser 工具并替换能力面里的条目
// (design/09 §5.5 的口径:面板保存后下一轮即用新接线,不必重启)。
//
// 返回 (applied, registered, reason):
// - applied=true 报告工具真的被换掉/加上(reason 为空);
// - applied=false 时 reason 是分类原因码(`browsercfg.Reason*` 或本文件的
// "replace-failed"/"register-failed"/"no-registry"),调用方据此决定回滚与文案。
//
// 两种「不生效」都**不改动既有的工具**:没有可用来源时工具不该存在(判据 1 的诚实口径),
// 但也没理由因为一次保存把已经在跑的那一把弄没(那是「保存一次就少一个能力」的事故形状)。
func (a *app) replaceBrowserTool(log *slog.Logger) (bool, bool, string) {
if a == nil || a.caps == nil {
return false, false, "no-registry"
}
existing, gerr := a.caps.Get(browsertools.ToolName)
haveExisting := gerr == nil
carry := shotOutboxOf(existing.Value)
build := a.buildBrowserTool(carry)
if !build.available {
// 新配置下没有可用来源:工具**不注册**(design/28 §7 判据 1 的诚实口径)。
log.Warn("browser tool not rebuilt after config save",
"reason", build.reason, "detail", build.detail,
"configPath", build.configPath, "effect", browserNotRegisteredEffect)
return false, haveExisting, build.reason
}
if haveExisting {
if err := a.caps.ReplaceTool(build.tool); err != nil {
closeBrowserManager(log, build.mgr)
log.Warn("browser tool replace failed", "error", err)
return false, true, "replace-failed"
}
closeOldBrowserTool(log, existing.Value)
return true, true, ""
}
if err := a.caps.RegisterTool(build.tool); err != nil {
closeBrowserManager(log, build.mgr)
log.Warn("browser tool register failed after config save", "error", err)
return false, false, "register-failed"
}
log.Info("browser tool registered after config save",
"source", build.source, "headless", build.headless, "configSource", build.configSource)
return true, true, ""
}
// shotOutboxOf 取旧条目上的附件箱(重建时沿用:模型刚拍完还没送出去的那一张不能丢)。
// 用结构断言而不是具体类型:换工具实现只要还暴露 ShotOutbox 就接得上。
func shotOutboxOf(value any) *browsertools.ShotOutbox {
holder, ok := value.(interface {
ShotOutbox() *browsertools.ShotOutbox
})
if !ok {
return nil
}
return holder.ShotOutbox()
}
// closeOldBrowserTool 关掉被换下来的工具(它持有进程外资源:我们起的真 Chromium、CDP 连接、
// profile 锁)。不关就是每次保存泄漏一个浏览器进程。
func closeOldBrowserTool(log *slog.Logger, value any) {
closer, ok := value.(interface{ Close() error })
if !ok {
return
}
if err := closer.Close(); err != nil {
log.Warn("old browser tool close failed", "error", err)
}
}
// closeBrowserManager 关掉刚建好但没能接上的管理器(注册被拒 / 替换失败时的收尾)。
func closeBrowserManager(log *slog.Logger, mgr *browsercap.Manager) {
if mgr == nil {
return
}
if err := mgr.Close(); err != nil {
log.Warn("browser manager close failed", "error", err)
}
}
+415
View File
@@ -0,0 +1,415 @@
package main
// 本文件是 design/28 §8 **B4(M3 面板入口)的接线验证**(切片 Y4):
// 声明真的进了面板目录、六个动作真的能分发、读写面分类正确、保存真的热重载。
//
// 为什么这些断言必须走**真实面板中枢**(`pluginmgr.NewPanels`)而不是只测函数:
//
// - 「动作可不可调」由声明推出(`pluginmgr.actionIndex`),viewer 的写面在**快照裁剪**
// 里被摘掉(`paneldecl.filterPanel`)—— 两件事都不是本包能自证的;
// - 保存的语义是「盘上改了 + 运行里的工具也换了」,只有从**能力注册表**读回工具才能验。
//
// 真浏览器不在这里:`buildBrowserTool` 只按配置探测**可执行文件是否存在**,因此本文件用
// 一个假的可执行文件就能走完装配(真机 e2e 见 `wire_browser_e2e_test.go`,默认 Skip)。
import (
"bytes"
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"os"
"path/filepath"
"runtime"
"strconv"
"testing"
auth "git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg"
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
browsertools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/browser"
)
// browserPanelFixture 是 B4 的夹具:真 hub + 已注册的 browser 工具(假可执行文件)。
type browserPanelFixture struct {
app *app
hub *pluginmgr.Panels
log *slog.Logger
out *bytes.Buffer
path string
}
// newBrowserPanelFixture 造夹具并完成接线。
func newBrowserPanelFixture(t *testing.T) *browserPanelFixture {
t.Helper()
cfg := wsLoadConfig(t)
exe := filepath.Join(t.TempDir(), "fake-browser.exe")
if err := os.WriteFile(exe, []byte("x"), 0o700); err != nil {
t.Fatalf("造假可执行文件: %v", err)
}
writeBrowserConfig(t, cfg, `{"executable":`+strconv.Quote(exe)+`}`)
var out bytes.Buffer
a := &app{
cfg: cfg,
log: logger.New(logger.Options{Out: &out, Level: "debug"}),
caps: registry.New(),
}
a.wireBrowser()
if _, ok := browserEntry(a); !ok {
t.Fatalf("夹具前提不成立:明明可用却没注册(日志:%s)", out.String())
}
hub, err := pluginmgr.NewPanels(logger.WithSource(a.log, "test/panel"))
if err != nil {
t.Fatalf("NewPanels: %v", err)
}
log := logger.WithSource(a.log, "test/panel")
wireBrowserPanel(hub, a, log)
fx := &browserPanelFixture{
app: a, hub: hub, log: log, out: &out,
path: browsercfg.DefaultConfigPath(cfg.DataDir),
}
t.Cleanup(func() {
if entry, err := a.caps.Get(browsertools.ToolName); err == nil {
if closer, ok := entry.Value.(interface{ Close() error }); ok {
_ = closer.Close()
}
}
})
return fx
}
// dispatch 调一次动作(等价于宿主动作端点的分发路径:声明 → 启用 → 处理器)。
func (fx *browserPanelFixture) dispatch(t *testing.T, action string, payload any) (any, error) {
t.Helper()
return fx.hub.Dispatch(context.Background(), pluginmgr.ActionRequest{
PluginID: browsertools.PanelPluginID, Action: action, Payload: payload,
})
}
// TestWireBrowserPanel_DeclaresPanelAndActionKinds 钉住声明进了目录,且六个动作的读写面
// 分类正确(读面 viewer 可调、写面只 owner/admin —— 这是 §5.4.1 的门)。
func TestWireBrowserPanel_DeclaresPanelAndActionKinds(t *testing.T) {
t.Parallel()
fx := newBrowserPanelFixture(t)
groups, err := fx.hub.Groups(auth.RoleOwner)
if err != nil {
t.Fatalf("Groups(owner): %v", err)
}
if len(groups) != 1 || len(groups[0].Panels) != 1 {
t.Fatalf("面板快照形状 = %+v", groups)
}
item := groups[0].Panels[0]
if item.PluginID != browsertools.PanelPluginID || item.Panel.ID != browsertools.PanelIDSettings {
t.Fatalf("面板标识 = %s/%s", item.PluginID, item.Panel.ID)
}
if len(item.Panel.Sections) != 4 {
t.Fatalf("区块数 = %d, want 4(配置 / 状态 / 指纹 / 截图)", len(item.Panel.Sections))
}
wantKinds := map[string]pluginmgr.ActionKind{
browsertools.ActionState: pluginmgr.ActionRead,
browsertools.ActionShots: pluginmgr.ActionRead,
browsertools.ActionFingerprint: pluginmgr.ActionRead,
browsertools.ActionSaveConfig: pluginmgr.ActionWrite,
browsertools.ActionShoot: pluginmgr.ActionWrite,
browsertools.ActionCheck: pluginmgr.ActionWrite,
}
for name, want := range wantKinds {
got, ok := fx.hub.Action(browsertools.PanelPluginID, name)
if !ok {
t.Errorf("动作 %s 没进声明索引(面板会被宿主拒掉或点了没反应)", name)
continue
}
if got != want {
t.Errorf("动作 %s 的读写面 = %q, want %q", name, got, want)
}
}
}
// TestWireBrowserPanel_ViewerSeesReadOnlyProjection 钉住角色裁剪:viewer 看到的分组里
// **没有**表单(写面)与行内动作(写动作),只留只读表格 —— 这是「只读监控投影」的定义。
func TestWireBrowserPanel_ViewerSeesReadOnlyProjection(t *testing.T) {
t.Parallel()
fx := newBrowserPanelFixture(t)
groups, err := fx.hub.Groups(auth.RoleViewer)
if err != nil {
t.Fatalf("Groups(viewer): %v", err)
}
if len(groups) != 1 || len(groups[0].Panels) != 1 {
t.Fatalf("viewer 的面板快照形状 = %+v", groups)
}
for _, s := range groups[0].Panels[0].Panel.Sections {
if s.Type == "form" {
t.Errorf("viewer 拿到了写表单(%s)", s.ID)
}
if len(s.RowActions) > 0 {
t.Errorf("viewer 拿到了行内写动作(%s:%+v)", s.ID, s.RowActions)
}
}
// 对照:owner 看得到表单与行内动作(否则上面的断言在「什么都没渲染」时也会过)。
ownerGroups, err := fx.hub.Groups(auth.RoleOwner)
if err != nil {
t.Fatalf("Groups(owner): %v", err)
}
forms, rowActions := 0, 0
for _, s := range ownerGroups[0].Panels[0].Panel.Sections {
if s.Type == "form" {
forms++
}
rowActions += len(s.RowActions)
}
if forms != 1 || rowActions != 2 {
t.Fatalf("owner 看到的表单/行内动作 = %d/%d, want 1/2", forms, rowActions)
}
}
// TestWireBrowserPanel_ReadActionsAnswerWithoutLaunchingBrowser 钉住三件事:
// 读动作真的能分发、回的是 `{rows:[…]}`、且**没有**启动浏览器会话(面板取数无副作用)。
func TestWireBrowserPanel_ReadActionsAnswerWithoutLaunchingBrowser(t *testing.T) {
t.Parallel()
fx := newBrowserPanelFixture(t)
for _, action := range []string{
browsertools.ActionState, browsertools.ActionShots, browsertools.ActionFingerprint,
} {
res, err := fx.dispatch(t, action, map[string]any{})
if err != nil {
t.Fatalf("%s: %v", action, err)
}
m, ok := res.(map[string]any)
if !ok {
t.Fatalf("%s 结果类型 = %T", action, res)
}
rows, ok := m["rows"].([]map[string]any)
if !ok || rows == nil {
t.Fatalf("%s 的 rows = %#v(必须是数组,哪怕是空的)", action, m["rows"])
}
// 状态与指纹**恒有一行**(事实面),最近截图在没有截图时如实为空表。
if action != browsertools.ActionShots && len(rows) == 0 {
t.Fatalf("%s 应当至少一行事实:%#v", action, m["rows"])
}
}
tool := browserToolValue(t, fx)
if snap := tool.State(); snap.SessionLive {
t.Error("读动作启动了浏览器会话:面板取数必须无进程外副作用")
}
}
// TestWireBrowserPanel_SaveConfigHotReloads 钉住「保存即生效」:落盘 + 换掉运行中的工具
// (面板的意义就是不改 `.env`、不重启)。
func TestWireBrowserPanel_SaveConfigHotReloads(t *testing.T) {
t.Parallel()
fx := newBrowserPanelFixture(t)
res, err := fx.dispatch(t, browsertools.ActionSaveConfig, map[string]any{
"values": map[string]any{
browsertools.FieldViewportWidth: float64(1600),
browsertools.FieldViewportHeight: float64(900),
browsertools.FieldIdleTimeout: float64(120),
browsertools.FieldHeadless: true,
},
})
if err != nil {
t.Fatalf("saveBrowserConfig: %v", err)
}
m, _ := res.(map[string]any)
if m["applied"] != true || m["registered"] != true {
t.Fatalf("保存结论 = %#v(要求盘上与运行中都生效)", m)
}
snap := browserToolValue(t, fx).State()
if snap.ViewportWidth != 1600 || snap.ViewportHeight != 900 || snap.IdleTimeoutSeconds != 120 || !snap.Headless {
t.Fatalf("热重载后的生效配置 = %+v,与保存内容不一致", snap)
}
raw, err := os.ReadFile(fx.path)
if err != nil {
t.Fatalf("读回配置: %v", err)
}
var onDisk browsercfg.Config
if err := json.Unmarshal(raw, &onDisk); err != nil {
t.Fatalf("配置不是合法 JSON: %v", err)
}
if onDisk.ViewportWidth != 1600 || !onDisk.Headless {
t.Fatalf("盘上的配置 = %+v", onDisk)
}
// 面板默认值跟着刷新(前端提交成功后会重取快照,表单因此显示「刚落盘的那份」)。
groups, _ := fx.hub.Groups(auth.RoleOwner)
for _, s := range groups[0].Panels[0].Panel.Sections {
if s.Type != "form" {
continue
}
for _, f := range s.Fields {
if f.Key == browsertools.FieldViewportWidth && f.Default != 1600 {
t.Errorf("面板默认值没刷新:viewportWidth=%v", f.Default)
}
}
}
}
// TestWireBrowserPanel_SaveConfigRejectsUnknownField 钉住「未知字段不许静默忽略」:
// 静默忽略会让「我改了但它没生效」变成查不出的谜,而且不该在盘上留下半份改动。
func TestWireBrowserPanel_SaveConfigRejectsUnknownField(t *testing.T) {
t.Parallel()
fx := newBrowserPanelFixture(t)
before, _ := os.ReadFile(fx.path)
_, err := fx.dispatch(t, browsertools.ActionSaveConfig, map[string]any{
"values": map[string]any{"nope": "x"},
})
ae := requireActionError(t, err)
if ae.Code != pluginmgr.ActionCodeInvalidPayload || ae.Status != http.StatusBadRequest {
t.Fatalf("失败形状 = %+v, want invalid_payload/400", ae)
}
after, _ := os.ReadFile(fx.path)
if !bytes.Equal(before, after) {
t.Error("被拒的保存改动了盘上的配置文件")
}
}
// TestWireBrowserPanel_SaveConfigRollsBackWhenNotUsable 钉住事务:保存前有可用工具而新配置
// 让它不可用(路径写错)⇒ **回滚**且如实报冲突,绝不让「盘上的」与「跑着的」静默拆开。
func TestWireBrowserPanel_SaveConfigRollsBackWhenNotUsable(t *testing.T) {
t.Parallel()
fx := newBrowserPanelFixture(t)
before, _ := os.ReadFile(fx.path)
_, err := fx.dispatch(t, browsertools.ActionSaveConfig, map[string]any{
"values": map[string]any{
browsertools.FieldExecutable: filepath.Join(t.TempDir(), "no-such-browser.exe"),
},
})
ae := requireActionError(t, err)
if ae.Code != browserCodeConfigRejected || ae.Status != http.StatusConflict {
t.Fatalf("失败形状 = %+v, want %s/409", ae, browserCodeConfigRejected)
}
after, _ := os.ReadFile(fx.path)
if !bytes.Equal(before, after) {
t.Error("没能生效的保存没有回滚:盘上留下了会让工具消失的配置")
}
if _, ok := browserEntry(fx.app); !ok {
t.Error("回滚之后原来的工具也不见了(应当原样保留)")
}
}
// TestWireBrowserPanel_WriteActionsAreHonestWithoutTarget 钉住两个写动作的诚实下限:
// 没有打开的页面时「拍一张」如实报 browser_session_not_open,绝不偷偷替模型开一页。
func TestWireBrowserPanel_WriteActionsAreHonestWithoutTarget(t *testing.T) {
t.Parallel()
fx := newBrowserPanelFixture(t)
_, err := fx.dispatch(t, browsertools.ActionShoot, map[string]any{
"row": map[string]any{"source": "本机 Chrome"},
})
ae := requireActionError(t, err)
if ae.Code != browserCodeNoSession || ae.Status != http.StatusConflict {
t.Fatalf("拍一张的失败形状 = %+v, want %s/409", ae, browserCodeNoSession)
}
}
// TestWireBrowserPanel_WithoutToolIsHonest 钉住「没有浏览器来源」时的两个写动作:如实报
// browser_unavailable(而不是 500 / 假成功),且声明仍然在(面板可以配,配好保存即生效)。
func TestWireBrowserPanel_WithoutToolIsHonest(t *testing.T) {
t.Parallel()
cfg := wsLoadConfig(t)
var out bytes.Buffer
a := &app{cfg: cfg, log: logger.New(logger.Options{Out: &out, Level: "debug"}), caps: registry.New()}
hub, err := pluginmgr.NewPanels(logger.WithSource(a.log, "test/panel"))
if err != nil {
t.Fatalf("NewPanels: %v", err)
}
wireBrowserPanel(hub, a, logger.WithSource(a.log, "test/panel"))
groups, gerr := hub.Groups(auth.RoleOwner)
if gerr != nil || len(groups) != 1 || len(groups[0].Panels) != 1 {
t.Fatalf("没有工具时面板也该声明(可以在面板里配):%+v, %v", groups, gerr)
}
for _, action := range []string{browsertools.ActionShoot, browsertools.ActionCheck} {
_, derr := hub.Dispatch(context.Background(), pluginmgr.ActionRequest{
PluginID: browsertools.PanelPluginID, Action: action,
})
ae := requireActionError(t, derr)
if ae.Code != browserCodeUnavailable {
t.Errorf("%s 的失败形状 = %+v, want %s", action, ae, browserCodeUnavailable)
}
}
}
/* ---------------------------------------------------------------------------
* 助手
* ------------------------------------------------------------------------- */
// browserToolValue 从能力面取回 browser 工具。
func browserToolValue(t *testing.T, fx *browserPanelFixture) *browsertools.Tool {
t.Helper()
entry, err := fx.app.caps.Get(browsertools.ToolName)
if err != nil {
t.Fatalf("能力面里没有 browser 工具: %v", err)
}
tool, ok := entry.Value.(*browsertools.Tool)
if !ok {
t.Fatalf("能力面里的值不是 browser 工具: %T", entry.Value)
}
return tool
}
// requireActionError 断言错误是一条**结构化动作失败**(错误码 + 文案 + 状态)。
func requireActionError(t *testing.T, err error) *pluginmgr.ActionError {
t.Helper()
if err == nil {
t.Fatal("期望一个结构化失败,得到 nil")
}
ae, ok := pluginmgr.AsActionError(err)
if !ok {
t.Fatalf("err = %v(%T),不是 structured ActionError", err, err)
}
if ae.Message == "" {
t.Error("结构化失败没有文案(§5.4.6 要求错误码与文案分离且都在)")
}
return ae
}
// TestWireBrowserPanel_ConfigFileIsPrivate 钉住落盘权限:browser.json 可能带显式 CDP 端点
// 与 profile 位置,按 0600 落盘(`browsercfg.Save` 的口径,这里从**面板保存**这条路上验)。
func TestWireBrowserPanel_ConfigFileIsPrivate(t *testing.T) {
t.Parallel()
fx := newBrowserPanelFixture(t)
if _, err := fx.dispatch(t, browsertools.ActionSaveConfig, map[string]any{
"values": map[string]any{browsertools.FieldIdleTimeout: float64(90)},
}); err != nil {
t.Fatalf("saveBrowserConfig: %v", err)
}
if runtime.GOOS == "windows" {
// Windows 不体现 POSIX 权限位(仓库既有的 store_test.go 同口径跳过)。
t.Skip("Windows 不体现 POSIX 权限位;0600 的判定在 store_test.go 覆盖")
}
info, err := os.Stat(fx.path)
if err != nil {
t.Fatalf("stat 配置: %v", err)
}
if perm := info.Mode().Perm(); perm != 0o600 {
t.Errorf("配置权限 = %o, want 600", perm)
}
}
// TestBrowserPanelEnv_CurrentConfigSurfacesCorruptFile 钉住「坏配置不静默顶替」:
// 文件非法时 `currentConfig` 返回错误(面板据此如实告知),不悄悄回落到默认值。
func TestBrowserPanelEnv_CurrentConfigSurfacesCorruptFile(t *testing.T) {
t.Parallel()
fx := newBrowserPanelFixture(t)
if err := os.WriteFile(fx.path, []byte("{ not json"), 0o600); err != nil {
t.Fatalf("写坏配置: %v", err)
}
if _, err := fx.dispatch(t, browsertools.ActionState, map[string]any{}); err != nil {
t.Fatalf("读动作不该因为配置坏而失败(它用默认值兜底显示现状): %v", err)
}
_, err := fx.dispatch(t, browsertools.ActionSaveConfig, map[string]any{
"values": map[string]any{browsertools.FieldIdleTimeout: float64(90)},
})
ae := requireActionError(t, err)
if ae.Code != browserCodeConfigUnreadable {
t.Fatalf("坏配置下保存的失败形状 = %+v, want %s", ae, browserCodeConfigUnreadable)
}
raw, _ := os.ReadFile(fx.path)
if string(raw) != "{ not json" {
t.Error("坏配置在保存失败后被改写了(不该动它)")
}
if !errors.Is(err, browsercfg.ErrInvalidConfig) {
t.Logf("note: 错误链未含 ErrInvalidConfig(%v)", err)
}
}
+251
View File
@@ -0,0 +1,251 @@
package main
// 本文件钉住切片 V5 的组合根接线:`browser` 工具**只在有可用来源时**进注册面,
// 每一个「不注册」的原因都**分类**写进日志(照 web-search `off` 的诚实口径),
// 而 profile 目录的凭据守卫(不得进 git 工作树)在注册之前生效。
//
// 断言落在**注册表**(模型真正看到的那一面)与**日志字段**上,而不是源码字符串。
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
browsertools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/browser"
)
// browserTestApp 造一个「有注册面 + 有日志出口」的 app:browser 的接线只依赖这三件。
func browserTestApp(t *testing.T, cfg *config.Config, out *bytes.Buffer) *app {
t.Helper()
return &app{
cfg: cfg,
log: logger.New(logger.Options{Out: out, Level: "debug"}),
caps: registry.New(),
}
}
// writeBrowserConfig 把一份 browser.json 写进 $DATA_DIR/config/plugins/(原样字节)。
func writeBrowserConfig(t *testing.T, cfg *config.Config, raw string) string {
t.Helper()
path := browsercfg.DefaultConfigPath(cfg.DataDir)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("建配置目录: %v", err)
}
if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
t.Fatalf("写 %s: %v", path, err)
}
return path
}
// browserEntry 报告注册面里有没有 browser 工具。
func browserEntry(a *app) (registry.Capability, bool) {
cap, err := a.caps.Get(browsertools.ToolName)
return cap, err == nil
}
// TestWireBrowser_InvalidConfigDoesNotRegister 钉住「配置非法 ⇒ 不注册 + 分类原因」:
// 静默拿默认值顶替会让面板里那份坏配置永远看不见。
func TestWireBrowser_InvalidConfigDoesNotRegister(t *testing.T) {
t.Parallel()
cfg := wsLoadConfig(t)
writeBrowserConfig(t, cfg, "{ this is not json")
var out bytes.Buffer
a := browserTestApp(t, cfg, &out)
a.wireBrowser()
if _, ok := browserEntry(a); ok {
t.Fatal("配置非法时 browser 不该进注册面")
}
if got := wsLogField(t, out.String(), "reason"); got != browserReasonInvalidConfig {
t.Errorf("reason = %q, want %q", got, browserReasonInvalidConfig)
}
}
// TestWireBrowser_MissingExecutableDoesNotRegister 钉住「面板把路径写错 ⇒ 不注册」,
// 且原因来自 browsercfg 的分类码(不偷偷回退到自动探测)。
func TestWireBrowser_MissingExecutableDoesNotRegister(t *testing.T) {
t.Parallel()
cfg := wsLoadConfig(t)
raw, _ := json.Marshal(map[string]any{"executable": filepath.Join(t.TempDir(), "no-such-browser.exe")})
writeBrowserConfig(t, cfg, string(raw))
var out bytes.Buffer
a := browserTestApp(t, cfg, &out)
a.wireBrowser()
if _, ok := browserEntry(a); ok {
t.Fatal("可执行文件不存在时 browser 不该进注册面")
}
if got := wsLogField(t, out.String(), "reason"); got != string(browsercfg.ReasonExecutableMissing) {
t.Errorf("reason = %q, want %q", got, browsercfg.ReasonExecutableMissing)
}
}
// TestWireBrowser_UnreachableEndpointDoesNotRegister 钉住 CDP 端点连不上 ⇒ 不注册,
// 原因分类为 cdp_unreachable(配置显式给了端点却不可达,就是「我配的容器没生效」,
// 必须让人看见)。
func TestWireBrowser_UnreachableEndpointDoesNotRegister(t *testing.T) {
t.Parallel()
cfg := wsLoadConfig(t)
// 127.0.0.1 上一个确定没人听的端口:连不上会立刻返回(不是超时)。
raw, _ := json.Marshal(map[string]any{"cdpEndpoint": "http://127.0.0.1:1"})
writeBrowserConfig(t, cfg, string(raw))
var out bytes.Buffer
a := browserTestApp(t, cfg, &out)
a.wireBrowser()
if _, ok := browserEntry(a); ok {
t.Fatal("CDP 端点不可达时 browser 不该进注册面")
}
if got := wsLogField(t, out.String(), "reason"); got != string(browsercfg.ReasonCDPUnreachable) {
t.Errorf("reason = %q, want %q", got, browsercfg.ReasonCDPUnreachable)
}
}
// TestWireBrowser_UsableExecutableRegisters 钉住正向路径:配置给出的可执行文件存在
// ⇒ 工具进注册面,且 profile 目录被按 0700 建好(含凭据的目录不能是 0755)。
func TestWireBrowser_UsableExecutableRegisters(t *testing.T) {
t.Parallel()
cfg := wsLoadConfig(t)
exe := filepath.Join(t.TempDir(), "fake-browser.exe")
if err := os.WriteFile(exe, []byte("#!/bin/sh\n"), 0o700); err != nil {
t.Fatalf("造假可执行文件: %v", err)
}
profileDir := filepath.Join(cfg.DataDir, "browser", "profile-x")
raw, _ := json.Marshal(map[string]any{"executable": exe, "profileDir": profileDir})
writeBrowserConfig(t, cfg, string(raw))
var out bytes.Buffer
a := browserTestApp(t, cfg, &out)
a.wireBrowser()
cap, ok := browserEntry(a)
if !ok {
t.Fatalf("可用时 browser 应进注册面(日志:%s)", out.String())
}
if cap.Kind != registry.KindTool {
t.Errorf("Kind = %q, want tool", cap.Kind)
}
// 默认权限口径:owner-only(两布尔皆 false)。
if cap.Policy.AllowInGroup || cap.Policy.AllowInStranger {
t.Errorf("Policy = %+v,默认应是 owner-only(design/28 §4 第 2 条)", cap.Policy)
}
if _, err := os.Stat(profileDir); err != nil {
t.Errorf("profile 目录应先被建好: %v", err)
}
// 工具名/动作面:注册进去的那把工具就是薄层那一个。
if tool, ok := cap.Value.(interface{ Name() string }); !ok || tool.Name() != browsertools.ToolName {
t.Errorf("注册的值不是 browser 工具: %#v", cap.Value)
}
}
// TestWireBrowser_ProfileInGitTreeIsRejected 钉住凭据守卫:profile 落在 git 工作树里
// ⇒ **不注册**(被 `git add` 收走就是把登录态提交出去,design/28 §4)。
func TestWireBrowser_ProfileInGitTreeIsRejected(t *testing.T) {
t.Parallel()
cfg := wsLoadConfig(t)
exe := filepath.Join(t.TempDir(), "fake-browser.exe")
if err := os.WriteFile(exe, []byte("x"), 0o700); err != nil {
t.Fatalf("造假可执行文件: %v", err)
}
// 造一个「像仓库」的目录:里面有 `.git`,profile 落在它下面、且在 dataDir 之外。
repo := t.TempDir()
if err := os.MkdirAll(filepath.Join(repo, ".git"), 0o755); err != nil {
t.Fatalf("造 .git: %v", err)
}
profileDir := filepath.Join(repo, "browser-profile")
raw, _ := json.Marshal(map[string]any{"executable": exe, "profileDir": profileDir})
writeBrowserConfig(t, cfg, string(raw))
var out bytes.Buffer
a := browserTestApp(t, cfg, &out)
a.wireBrowser()
if _, ok := browserEntry(a); ok {
t.Fatal("profile 位于 git 工作树内时 browser 不该进注册面")
}
if got := wsLogField(t, out.String(), "reason"); got != browserReasonProfileRejected {
t.Errorf("reason = %q, want %q", got, browserReasonProfileRejected)
}
}
// TestIOToolOptions_DoesNotWireBrowser 钉住**临时挂点已删**(切片 X3 把挂点归位到
// `wireRuntime`):browser 的接线原先借 `ioToolOptions()` 转交(V5 改不了
// wire_runtime.go 时的权宜),代价是「取一份 io 选项」这件只读的事会顺带探测并注册
// 浏览器工具 —— 所有只为要选项而调本函数的路径(web-search 的接线测试就是)都会连带
// 起一次浏览器探测。归位后的挂点在 wire_runtime.go,由
// TestWireRuntime_CallsWireBrowser 钉住;这里钉住 io 侧**不再有第二个挂点** ——
// 两处并存不会报错(函数幂等),只会静默多一次探测,因此只有测试能拦住它回流。
func TestIOToolOptions_DoesNotWireBrowser(t *testing.T) {
t.Parallel()
cfg := wsLoadConfig(t)
exe := filepath.Join(t.TempDir(), "fake-browser.exe")
if err := os.WriteFile(exe, []byte("x"), 0o700); err != nil {
t.Fatalf("造假可执行文件: %v", err)
}
raw, _ := json.Marshal(map[string]any{"executable": exe})
writeBrowserConfig(t, cfg, string(raw))
var out bytes.Buffer
a := browserTestApp(t, cfg, &out)
a.ioToolOptions()
if _, ok := browserEntry(a); ok {
t.Fatalf("ioToolOptions 不该再挂 browser(挂点已归位 wire_runtime.go;日志:%s)", out.String())
}
}
// TestWireBrowser_Idempotent 钉住**幂等**:同一个 app 上重复装配只发现工具已在注册表里
// 并跳过,不重复注册、不重复探测。挂点归位后幂等仍是唯一的保险(两条路径万一偶遇)。
func TestWireBrowser_Idempotent(t *testing.T) {
t.Parallel()
cfg := wsLoadConfig(t)
exe := filepath.Join(t.TempDir(), "fake-browser.exe")
if err := os.WriteFile(exe, []byte("x"), 0o700); err != nil {
t.Fatalf("造假可执行文件: %v", err)
}
raw, _ := json.Marshal(map[string]any{"executable": exe})
writeBrowserConfig(t, cfg, string(raw))
var out bytes.Buffer
a := browserTestApp(t, cfg, &out)
a.wireBrowser()
first, ok := browserEntry(a)
if !ok {
t.Fatalf("可用时 browser 应进注册面(日志:%s)", out.String())
}
a.wireBrowser()
second, ok := browserEntry(a)
if !ok {
t.Fatal("第二次调用后 browser 不该消失")
}
if first.Name != second.Name || first.Kind != second.Kind {
t.Errorf("重复装配改写了条目:%+v -> %+v", first, second)
}
// 注册面里只该有一个 browser(registry 重名会被拒;这里确认没被静默换掉)。
count := 0
for _, c := range a.caps.List(registry.KindTool) {
if c.Name == browsertools.ToolName {
count++
}
}
if count != 1 {
t.Errorf("注册面里 browser 条目数 = %d, want 1", count)
}
}
// TestWireBrowser_NilRegistryIsSkipped 钉住夹具友好性:没有注册面时**不 panic**
// (部分既有测试夹具只填了 app 的几个字段)。
func TestWireBrowser_NilRegistryIsSkipped(t *testing.T) {
t.Parallel()
a := &app{}
a.wireBrowser()
}
+183
View File
@@ -0,0 +1,183 @@
package main
import (
"context"
"fmt"
"log/slog"
"sort"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/iot"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// 本文件是 X1(思考轮工具面补齐 / 主人裁决 15)的组合根接线:让**外围子系统**真的
// 出现在能力面里,并给她一把只读的自查工具。它只装配,不新造注册机制、不开端口、
// 不跨进程(单二进制红线)。
// capabilitySurfaceLogSource 是本步日志的来源标记。
const capabilitySurfaceLogSource = "cmd/cyrene"
// iotCapabilityName 是 IoT 子系统在能力注册表里的条目名。
const iotCapabilityName = "iot"
// IoT 只读状态摘要的边界(编译期常量):台数、每台的关键状态键数、单个键值长度与
// 整段摘要字符数。整段上限必须**小于** registry 侧 statusCharLimit,否则真正生效的
// 是那边的截断,而这里以为没截断。
const (
iotStatusMaxDevices = 20
iotStatusMaxKeys = 6
iotStatusMaxValueLen = 40
iotStatusMaxChars = 380
)
// wireCapabilitySurface 把「她能看见自己有什么、外围在什么状态」这条面接进组合根
// (X1 / 裁决 15;design/12 §1「允许调用工具(查设备/心率/日历/天气等)」、
// design/20 §2「能调用工具:与主人会话同等的工具集」):
//
// 1. **外围子系统进能力面**:IoT 以它既有的 `KindIoT` 身份注册进能力注册表 ——
// 此前全仓**无人注册 KindIoT**,于是外围子系统在能力面里根本不存在(面板与
// 清单都列不出);
// 2. **只读自查工具**:`capability_list`(internal/capability/registry 的
// ListTool)注册进同一张注册表,并注入 IoT 的只读状态源,使她无论走 chat 还是
// 主动思考轮,都能列出「有哪些能力/插件 + 外围状态」。
//
// 「chat 与 backthink 同一套工具面」不靠本文件成立,而靠**同一个注册表 / 分发器**:
// backthink 的 LoopThinker 包的就是 `a.loop`(wire_services.go),而 `a.loop` 的工具面
// 就是 `a.dispatcher`(wire_runtime.go),它建立在 `a.caps` 之上。因此这里绝不再造
// 第二套注册机制 —— 把工具塞进 `a.caps` 就同时被两条车道看见。
//
// 失败只告警、绝不阻塞启动:本步只影响「清单可见性」,其余子系统不受影响。
func (a *app) wireCapabilitySurface() {
log := logger.WithSource(a.log, capabilitySurfaceLogSource)
if a.caps == nil {
log.Warn("capability surface skipped: no capability registry")
return
}
a.registerIoTCapability(log)
opts := make([]registry.ListOption, 0, 2)
if a.iot != nil {
opts = append(opts, registry.WithStatusSource(iotStatusSource{mgr: a.iot}))
}
opts = append(opts, registry.WithListLogger(a.log))
listTool, err := registry.NewListTool(a.caps, opts...)
if err != nil {
log.Warn("capability list tool build skipped", "error", err)
return
}
if a.dispatcher == nil {
log.Warn("capability list tool register skipped: no dispatcher")
return
}
if err := a.dispatcher.Register(listTool); err != nil {
log.Warn("capability list tool register skipped", "error", err)
return
}
log.Info("capability list tool registered", "tool", registry.ToolName, "iot_status", a.iot != nil)
}
// registerIoTCapability 把 IoT 门面以 KindIoT 注册进能力注册表。已注册(重名)时
// 只告警:能力面里已经有一条,不是启动失败的理由。
func (a *app) registerIoTCapability(log *slog.Logger) {
if a.iot == nil {
return
}
err := a.caps.Register(registry.Capability{
Name: iotCapabilityName,
Kind: registry.KindIoT,
Description: "IoT peripherals (device list, status, control)",
Value: a.iot,
})
if err != nil {
log.Warn("iot capability register skipped", "error", err)
}
}
// iotStatusSource 把 IoT 子系统接成清单工具的**只读**状态源:它只调 Manager.List
// (纯读),按固定上限渲染设备清单,绝不 Control、绝不改任何设备状态。
type iotStatusSource struct {
mgr *iot.Manager
}
// Name 返回状态分组名。
func (s iotStatusSource) Name() string { return iotCapabilityName }
// Status 渲染一份有界的只读摘要:设备/在线台数,然后是每台设备一行
// (名称/类型/在线/关键状态键值)。超过上限的台数与字符都被截断并显式标注。
func (s iotStatusSource) Status(ctx context.Context) (string, error) {
if s.mgr == nil {
return "", fmt.Errorf("capability surface: iot status: %w", iot.ErrProviderNotRegistered)
}
devices, err := s.mgr.List(ctx)
if err != nil {
return "", fmt.Errorf("capability surface: iot status: %w", err)
}
var b strings.Builder
fmt.Fprintf(&b, "devices=%d online=%d", len(devices), onlineCount(devices))
rendered := 0
for _, d := range devices {
if rendered >= iotStatusMaxDevices {
break
}
line := "\n" + deviceLine(d)
if b.Len()+len(line) > iotStatusMaxChars {
break
}
b.WriteString(line)
rendered++
}
if rendered < len(devices) {
fmt.Fprintf(&b, "\n…(另有 %d 台未列出)", len(devices)-rendered)
}
return b.String(), nil
}
// onlineCount 统计在线设备台数。
func onlineCount(devices []iot.Device) int {
n := 0
for _, d := range devices {
if d.Online {
n++
}
}
return n
}
// deviceLine 把一台设备渲染成单行只读摘要:名称(类型,在线/离线):键=值,…
// 状态键按字典序取前 iotStatusMaxKeys 个,保证同一台设备每次渲染一致。
func deviceLine(d iot.Device) string {
status := "离线"
if d.Online {
status = "在线"
}
keys := make([]string, 0, len(d.State))
for k := range d.State {
keys = append(keys, k)
}
sort.Strings(keys)
pairs := make([]string, 0, len(keys))
for _, k := range keys {
if len(pairs) >= iotStatusMaxKeys {
break
}
pairs = append(pairs, k+"="+truncateValue(d.State[k]))
}
line := fmt.Sprintf("%s(%s,%s)", d.Name, d.Type, status)
if len(pairs) > 0 {
line += ":" + strings.Join(pairs, ",")
}
return line
}
// truncateValue 把设备状态值渲染成有界文本:非文本值走 %v,超长即截断。
func truncateValue(v any) string {
s := fmt.Sprintf("%v", v)
if len([]rune(s)) <= iotStatusMaxValueLen {
return s
}
return string([]rune(s)[:iotStatusMaxValueLen]) + "…"
}
+192
View File
@@ -0,0 +1,192 @@
package main
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/iot"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
)
// surfaceApp 组装一个只含能力面所需的 app:注册表 + 分发表 + mock IoT 子系统。
// 它走的是与生产相同的两个入口(wireTranscode / wireCapabilitySurface),
// 因此断言的是真实接线,而不是测试自造的替身。
func surfaceApp(t *testing.T) *app {
t.Helper()
caps := registry.New()
d, err := tool.New(caps)
if err != nil {
t.Fatalf("tool.New: %v", err)
}
mgr, err := iot.New(config.IoTConfig{Provider: "mock"}, testLog())
if err != nil {
t.Fatalf("iot.New: %v", err)
}
a := &app{log: testLog(), caps: caps, dispatcher: d, iot: mgr}
a.cfg = &config.Config{}
a.cfg.Capacity.Enabled = true
a.wireTranscode()
a.wireCapabilitySurface()
return a
}
// toolNames 返回注册表里当前所有**可调用工具**的名字(模型真正会看到的那一面)。
func toolNames(a *app) []string {
caps := a.caps.List(registry.KindTool)
out := make([]string, 0, len(caps))
for _, c := range caps {
out = append(out, c.Name)
}
return out
}
// TestWireCapabilitySurface_RegistersIoTCapabilityAndListTool 断言 (a)+(c) 的组合根
// 落地:IoT 子系统以 KindIoT 进能力面(此前全仓无人注册该 kind)、只读的
// capability_list 进注册表并被广告给模型,且它读出的外围状态经真实分发器可用。
func TestWireCapabilitySurface_RegistersIoTCapabilityAndListTool(t *testing.T) {
t.Parallel()
a := surfaceApp(t)
iotCap, err := a.caps.Get(iotCapabilityName)
if err != nil {
t.Fatalf("IoT 子系统没进能力注册表: %v", err)
}
if iotCap.Kind != registry.KindIoT {
t.Fatalf("IoT 能力类型 = %q, want %q", iotCap.Kind, registry.KindIoT)
}
if _, ok := iotCap.Value.(*iot.Manager); !ok {
t.Fatalf("IoT 能力值类型 = %T, want *iot.Manager", iotCap.Value)
}
listCap, err := a.caps.Get(registry.ToolName)
if err != nil {
t.Fatalf("capability_list 没进能力注册表: %v", err)
}
if listCap.Kind != registry.KindTool {
t.Fatalf("清单能力类型 = %q, want %q", listCap.Kind, registry.KindTool)
}
if listCap.Policy.AllowInGroup || listCap.Policy.AllowInStranger {
t.Fatalf("清单工具的策略必须保持 fail-closed(owner-only),got %+v", listCap.Policy)
}
advertised := false
for _, s := range a.dispatcher.ToolSchemas() {
if s.Name == registry.ToolName {
advertised = true
}
}
if !advertised {
t.Fatalf("capability_list 未被广告给模型:%v", toolNames(a))
}
// 走真实分发器(含 JSON-Schema 校验与工具执行),断言外围只读状态真的读得出来。
devices, err := a.iot.List(context.Background())
if err != nil {
t.Fatalf("iot.List: %v", err)
}
val, err := a.dispatcher.Invoke(context.Background(), registry.ToolName, map[string]any{})
if err != nil {
t.Fatalf("Invoke(%s): %v", registry.ToolName, err)
}
raw, err := json.Marshal(val)
if err != nil {
t.Fatalf("marshal: %v", err)
}
text := string(raw)
if !strings.Contains(text, fmt.Sprintf(`"devices=%d`, len(devices))) {
t.Fatalf("清单没报出外围设备台数(want devices=%d):%s", len(devices), text)
}
if !strings.Contains(text, `"name":"iot"`) || !strings.Contains(text, `"kind":"iot"`) {
t.Fatalf("清单没列出 IoT 子系统条目:%s", text)
}
if !strings.Contains(text, `"name":"transcode"`) {
t.Fatalf("清单没列出已注册的 transcode 工具:%s", text)
}
// 只读:清单调用(含外围状态读取)不得改动任何设备。
after, err := a.iot.List(context.Background())
if err != nil {
t.Fatalf("iot.List (after): %v", err)
}
if got, want := deviceSnapshot(after), deviceSnapshot(devices); got != want {
t.Fatalf("清单调用改动了设备状态(只读红线):\nwant=%s\ngot=%s", want, got)
}
}
// deviceSnapshot 把设备清单渲染成可比较的快照(按 id 排序,状态键排序)。
func deviceSnapshot(devices []iot.Device) string {
lines := make([]string, 0, len(devices))
for _, d := range devices {
keys := make([]string, 0, len(d.State))
for k := range d.State {
keys = append(keys, k)
}
sort.Strings(keys)
pairs := make([]string, 0, len(keys))
for _, k := range keys {
pairs = append(pairs, fmt.Sprintf("%s=%v", k, d.State[k]))
}
lines = append(lines, fmt.Sprintf("%s|%s|%s|%t|%s", d.ID, d.Name, d.Type, d.Online, strings.Join(pairs, ",")))
}
sort.Strings(lines)
return strings.Join(lines, "\n")
}
// TestWireCapabilitySurface_NewToolsReachableUnderCapacity 断言 (b):CAPACITY_ENABLED=true
// 时**新工具**(transcode / capability_list)在静息与唤醒两个内置档位集的允许集内。
//
// 判据用**注册表里的真实工具名**驱动判定器:若 signals.go 的档位集字面量与注册名
// 漂移(例如把 capability_list 写成 capability-list),该名字就不在 AllToolNames 里、
// 交集后消失 ⇒ 本用例变红。
func TestWireCapabilitySurface_NewToolsReachableUnderCapacity(t *testing.T) {
t.Parallel()
a := surfaceApp(t)
dec := a.buildCapacity()
if dec == nil || !dec.Enabled() {
t.Fatal("CAPACITY_ENABLED=true 时判定器必须启用")
}
names := toolNames(a)
cases := []struct {
name string
msg string
want capacity.State
}{
{"静息", "你好", capacity.StateQuiet},
{"唤醒", "帮我查一下资料", capacity.StateAwake},
}
for _, c := range cases {
dec := dec.Decide(context.Background(), capacity.Input{
Messages: []string{c.msg},
CurrentState: capacity.StateQuiet,
AllToolNames: names,
})
if dec.State != c.want {
t.Fatalf("输入 %q 判定态 = %s, want %s", c.msg, dec.State, c.want)
}
for _, want := range []string{"transcode", registry.ToolName} {
if !containsTool(dec.AllowedTools, want) {
t.Fatalf("%s档位允许集缺 %q:%v(新工具被挡住 = 裁决 15 在安静态失效)",
c.name, want, dec.AllowedTools)
}
}
}
}
// containsTool 报告名字是否在允许集里。
func containsTool(names []string, want string) bool {
for _, n := range names {
if n == want {
return true
}
}
return false
}
+194
View File
@@ -0,0 +1,194 @@
package main
import (
"encoding/json"
"fmt"
"log/slog"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity"
)
// 本文件拥有唤醒式能力(capacity)决策层的装配:判定器构造、工具集 JSON 解析、
// L3 缓存 TTL 解析与 capacity_awaken 事件出口。它从 wire_runtime.go 拆出
// (AGENTS §10 文件 ≤500 行;wire.go 拆分建议同样适用于本文件)。
// buildCapacity 在启用时构造唤醒式能力判定器;未启用则返回 nil(loop 保持默认的
// 「全工具」行为)。它由 CAPACITY_ENABLED 显式开启;允许集映射取自
// CAPACITY_TOOL_SETS,解析失败时回落到内置默认值。
//
// 模型辅助的 L3 层(裁决 14.4.4)是第二个、彼此独立的开关
// (CAPACITY_L3_ENABLED,默认 false):即使能力层已开,L3 仍保持静默,除非被
// 显式要求;其判定结果的缓存 TTL(CAPACITY_L3_CACHE_TTL,默认 5m)在此解析,
// 因为配置加载器只承载标量。
//
// 人格级唤醒覆盖(裁决 14.4.3)分两处进入决策层。本函数填**全局**兜底
// (Config.Awakening),取自当前主角色 YAML 里的块(见 defaultAwakening):
// 未声明该块的人格继承的就是它。每个 turn 的取值由 awakeningFor 解析,loop 经
// loop.Options.PersonaAwakening 对该 turn 自己的角色调用它——没有它,非默认
// 角色的 `awakening` 块会被解析、校验,然后被静默忽略(design/01 §2.1 禁止)。
func (a *app) buildCapacity() *capacity.Decider {
if !a.cfg.Capacity.Enabled {
return nil
}
sets, err := parseCapacityToolSets(a.cfg.Capacity.ToolSetsJSON)
if err != nil {
a.log.Warn("capacity tool sets parse failed, using built-in defaults", "error", err)
sets = nil
}
awakening := a.defaultAwakening()
if !awakening.IsZero() {
logger.WithSource(a.log, "cmd/cyrene").Info("capacity awakening override applied",
"default_state", awakening.DefaultState, "min_confidence", awakening.MinConfidence,
"force_state", awakening.ForceState, "persona", a.defaultPersonaID())
}
cfg := capacity.Config{
Enabled: true,
MinConfidence: a.cfg.Capacity.MinConfidence,
ToolSets: sets,
Awakening: awakening,
}
if a.cfg.Capacity.L3Enabled {
cfg.L3Enabled = true
cfg.L3Classifier = capacity.NewLLMClassifier(a.modelAdapter, a.log)
cfg.L3CacheTTL = parseL3CacheTTL(a.cfg.Capacity.L3CacheTTL, a.log)
}
return capacity.New(cfg, capacity.WithLogger(a.log))
}
// parseL3CacheTTL 解析 CAPACITY_L3_CACHE_TTL 这个时长字符串。空值选代码默认
// (5m,裁决 14.4.4 的落地建议);无法解析的值会告警并回落到同一默认,而不是
// 静默关掉缓存。
func parseL3CacheTTL(raw string, log *slog.Logger) time.Duration {
raw = strings.TrimSpace(raw)
if raw == "" {
return capacity.DefaultL3CacheTTL
}
d, err := time.ParseDuration(raw)
if err != nil {
logger.WithSource(log, "cmd/cyrene").Warn("capacity L3 cache ttl unparsable, using the default",
"value", raw, "default", capacity.DefaultL3CacheTTL.String(), "error", err)
return capacity.DefaultL3CacheTTL
}
return d
}
// parseCapacityToolSets 解码 CAPACITY_TOOL_SETS 的 JSON 映射:态 → 允许的工具名
// 列表。空白串产出 nil(用内置默认);某态取 null 表示不受限(全部工具)。
func parseCapacityToolSets(raw string) (map[capacity.State][]string, error) {
if strings.TrimSpace(raw) == "" {
return nil, nil
}
var m map[string][]string
if err := json.Unmarshal([]byte(raw), &m); err != nil {
return nil, fmt.Errorf("capacity tool sets: %w", err)
}
out := make(map[capacity.State][]string, len(m))
for k, v := range m {
out[capacity.State(k)] = v
}
return out, nil
}
// defaultAwakening 取当前主角色(`configs/users/owner.yaml` 的 `defaultPersona`,
// 未配置时回落 `cyrene`)档案里的 `awakening` 块。它填的是决策层的**全局兜底**
// (capacity.Config.Awakening);每个 turn 真正生效的档位由 awakeningFor 按该
// turn 的角色解析(裁决 14.4.3 的后半)。两者共用同一处映射,因此「全局兜底」
// 与「按角色取值」的口径绝不会分叉。
//
// 未装配人格注册表(降级启动 / 单测构造的 app)时返回零值 —— 注册表缺席时连
// 默认角色都问不出来,此时不补任何档位。
func (a *app) defaultAwakening() capacity.Awakening {
if a.personas == nil {
return capacity.Awakening{}
}
return a.awakeningFor(a.defaultPersonaID())
}
// awakeningFor 取**该角色**档案里的 `awakening` 块,映射为决策层的人格档位覆盖
// (design/14 §3,裁决 14.4.3)。它是 loop.Options.PersonaAwakening 的实现:循环
// 每个 turn 按该 turn 的角色 id 调它,因此绑定非默认角色的会话用**自己**的档位
// 判定能力面,而不是只吃默认角色的全局兜底。
//
// 下列情形一律返回零值 = **无覆盖**,由决策层走全局默认(Config.Awakening +
// 全局阈值),绝不在此处凭空补档位:
// - 尚未装配人格注册表(降级启动 / 单测构造的 app);
// - 角色 id 为空或该角色不在注册表里(未知/已删除人格);
// - 该角色档案里没写 `awakening` 块(缺席就是没有)。
//
// 档位是倾向/呈现,不是能力边界——它只影响本轮暴露的工具面与唤醒阈值。
func (a *app) awakeningFor(personaID string) capacity.Awakening {
if a.personas == nil || personaID == "" {
return capacity.Awakening{}
}
p, ok := a.personas.Persona(personaID)
if !ok || p == nil {
return capacity.Awakening{}
}
return a.awakeningOf(p)
}
// awakeningOf 把一份人格档案的 `awakening` 块映射为决策层的档位覆盖。映射不了
// 的档位名按「无该档位」处理(awakeningStateOf 会告警),因此非法值绝不会被塞进
// 决策层(未知态会落进「不受限工具集」分支)。
func (a *app) awakeningOf(p *loader.Persona) capacity.Awakening {
if p == nil || p.Awakening == nil {
return capacity.Awakening{}
}
aw := capacity.Awakening{
DefaultState: a.awakeningStateOf(p.Awakening.DefaultState, p.ID, "default_state"),
}
if ov := p.Awakening.Overrides; ov != nil {
aw.MinConfidence = ov.MinConfidence
aw.ForceState = a.awakeningStateOf(ov.ForceState, p.ID, "overrides.force_state")
}
return aw
}
// awakeningStateOf 映射单个档位字面量;映射不了就**告警**再按「无该档位」处理。
// 经 loader.Load 进来的人格不可能走到告警分支(载入期已归一化并拒绝非法值),
// 因此它是给「代码构造的人格」留的响铃:宁可吵一声,也不让配置静默失效
// (design/01 §2.1 禁止静默无效的配置)。
func (a *app) awakeningStateOf(raw, personaID, field string) capacity.State {
if raw == "" {
return ""
}
state := awakeningState(raw)
if state == "" {
logger.WithSource(a.log, "cmd/cyrene").Warn("capacity awakening state unrecognized, treated as no override",
"value", raw, "field", field, "persona", personaID)
}
return state
}
// awakeningState 把 loader 归一化后的档位名映射到决策层的态。空串(未声明、或未知
// 值)返回零值态——映射层不做默认填充,缺失的档位就是缺失。
func awakeningState(raw string) capacity.State {
switch raw {
case loader.AwakeningStateQuiet:
return capacity.StateQuiet
case loader.AwakeningStateAwake:
return capacity.StateAwake
case loader.AwakeningStateFull:
return capacity.StateFull
default:
return ""
}
}
// onCapacity 是 loop 的 OnCapacity 出口:它观测一次 capacity_awaken 跃迁,并以
// `capacity_awaken` 帧推给 Web UI(design/14 §5)。loop 只在真实发生状态跃迁时
// 才发(绝非每轮都发);事件携带发起会话 id,因此路由到那个会话而非全局广播。
// 载荷是前端的 AwakenPayload {fromState,toState,tool}。
func (a *app) onCapacity(ev capacity.Event) {
logger.WithSource(a.log, "cmd/cyrene").Debug("capacity event",
"type", ev.Type, "from", ev.FromState, "to", ev.ToState,
"tool", ev.Tool, "at", ev.At, "session", ev.SessionID)
if a.capacitySink == nil {
return
}
a.capacitySink.BroadcastCapacityAwaken(ev.SessionID, string(ev.FromState), string(ev.ToState), ev.Tool)
}
+196
View File
@@ -0,0 +1,196 @@
package main
import (
"context"
"os"
"path/filepath"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity"
)
// 端到端覆盖裁决 14.4.3 的接线:人格 YAML 的 `awakening` 块 → loader 解析 →
// 注册表 → 组合根 buildCapacity → capacity.Config.Awakening → 真实判定结果。
// 之所以断言「判定结果」而不是只断言配置字段,是因为这个档位曾经只「写了没人读」
// ——只看字段填没填,抓不住「读侧被摘掉」的回归。
const awakeningPersonaBody = `name: 测试角色
characterRef: test-role
identity:
role: r
systemPrompt: s
`
// appWithPersonaYAML 造一个最小 app:注册表里装一个人格文件,并把它设为默认角色
// (等价于 owner.yaml 的 defaultPersona 指向它)。
func appWithPersonaYAML(t *testing.T, body string) *app {
t.Helper()
path := filepath.Join(t.TempDir(), "test-role.yaml")
if err := os.WriteFile(path, []byte("id: test-role\n"+body), 0o600); err != nil {
t.Fatalf("write persona yaml: %v", err)
}
return appWithPersonaFile(t, path)
}
// appWithPersonaFile 同上,但从给定的档案路径载入(用于校验随仓库发布的主角色档案)。
func appWithPersonaFile(t *testing.T, path string) *app {
t.Helper()
p, err := loader.Load(path)
if err != nil {
t.Fatalf("loader.Load(%s): %v", path, err)
}
reg := personareg.New()
if err := reg.RegisterPersona(p); err != nil {
t.Fatalf("RegisterPersona: %v", err)
}
if err := reg.RegisterCharacter(&personareg.Character{
ID: p.ID, Name: p.Name, Default: true, PersonaID: p.ID,
}); err != nil {
t.Fatalf("RegisterCharacter: %v", err)
}
a := &app{log: testLog(), personas: reg}
a.cfg = &config.Config{}
a.cfg.Capacity.Enabled = true
return a
}
// decide 跑一次真实判定(无覆盖的基线判定器与接线后的判定器都用它)。
func decide(dec *capacity.Decider, messages ...string) capacity.State {
return dec.Decide(context.Background(), capacity.Input{Messages: messages}).State
}
// baselineDecider 是「没有接线 awakening」的对照判定器:同一个全局阈值,零覆盖。
func baselineDecider() *capacity.Decider {
return capacity.New(capacity.Config{Enabled: true}, capacity.WithLogger(testLog()))
}
// TestCapacityAwakening_AwakePersonaWakesAmbiguousTurn 端到端正向:档案里声明
// `default_state: 唤醒` 后,一个意图各层都拿不准的回合真的落到唤醒态——而同一
// 回合在没有该档位时是静息。这就是「档位真的参与判定」的证据。
func TestCapacityAwakening_AwakePersonaWakesAmbiguousTurn(t *testing.T) {
t.Parallel()
a := appWithPersonaYAML(t, awakeningPersonaBody+"awakening:\n default_state: 唤醒\n")
dec := a.buildCapacity()
if got := decide(dec, "这个"); got != capacity.StateAwake {
t.Fatalf("declared default_state=唤醒 → state %s, want awake", got)
}
if got := decide(baselineDecider(), "这个"); got != capacity.StateQuiet {
t.Fatalf("baseline (no awakening) → state %s, want quiet", got)
}
}
// TestCapacityAwakening_AbsentIsNoOverride 端到端反向:档案里没写该块时,解析
// 结果为空、不报错,组合根传出零值覆盖,判定结果与「完全没接线」的基线逐字一致。
func TestCapacityAwakening_AbsentIsNoOverride(t *testing.T) {
t.Parallel()
a := appWithPersonaYAML(t, awakeningPersonaBody)
if aw := a.defaultAwakening(); !aw.IsZero() {
t.Fatalf("defaultAwakening() = %+v, want zero(缺席就是没有)", aw)
}
yamlPersona, ok := a.defaultPersona()
if !ok {
t.Fatal("default persona not resolvable")
}
if yamlPersona.Awakening != nil {
t.Fatalf("Awakening = %+v, want nil", yamlPersona.Awakening)
}
dec := a.buildCapacity()
for _, tc := range [][]string{{"这个"}, {"帮我查一下资料", "继续"}} {
if got, want := decide(dec, tc...), decide(baselineDecider(), tc...); got != want {
t.Fatalf("决定 %v = %s, baseline = %s(无档位必须与基线一致)", tc, got, want)
}
}
}
// TestCapacityAwakening_RealDefaultPersonaIsWired 锁定主人点名的那件事:随仓库
// 发布的**当前主角色**档案(configs/personas/cyrene.yaml)真的声明了该档位,且
// 它确实改变判定——「温柔陪伴」倾向把靠上下文趋势猜出的任务(L2,置信 0.66)
// 压回静息,而明确的办事指令(L1,置信 0.9)照旧唤醒。
func TestCapacityAwakening_RealDefaultPersonaIsWired(t *testing.T) {
t.Parallel()
a := appWithPersonaFile(t, filepath.Join("..", "..", "configs", "personas", "cyrene.yaml"))
p, ok := a.defaultPersona()
if !ok || p.Awakening == nil {
t.Fatalf("cyrene.yaml 缺少 awakening 块(裁决 14.4.3 要求主角色填上);persona=%+v", p)
}
if p.Awakening.DefaultState != loader.AwakeningStateQuiet {
t.Fatalf("cyrene default_state = %q, want %q(温柔陪伴倾向)",
p.Awakening.DefaultState, loader.AwakeningStateQuiet)
}
if ov := p.Awakening.Overrides; ov == nil || ov.MinConfidence <= capacity.DefaultMinConfidence {
t.Fatalf("cyrene overrides = %+v, want a threshold above the global %.2f(更不易被打扰)",
p.Awakening.Overrides, capacity.DefaultMinConfidence)
}
dec := a.buildCapacity()
trend := []string{"帮我查一下资料", "继续"}
if got := decide(dec, trend...); got != capacity.StateQuiet {
t.Fatalf("紧跟任务的话题延续 → %s, want quiet(人格级阈值压回静息)", got)
}
if got := decide(baselineDecider(), trend...); got != capacity.StateAwake {
t.Fatalf("同一回合在无档位基线 → %s, want awake(证明差异来自该档位)", got)
}
if got := decide(dec, "帮我查一下资料"); got != capacity.StateAwake {
t.Fatalf("明确的办事指令 → %s, want awake(抬高阈值不得把真任务也压掉)", got)
}
}
// TestCapacityAwakening_NilRegistryAndUnknownState 守住两个降级口:人格注册表还
// 没装配时不 panic(返回无覆盖),未知档位名不落进决策层(宁可无覆盖并告警,
// 也不让决策层收到非法态——非法态会命中「不受限工具集」分支)。
func TestCapacityAwakening_NilRegistryAndUnknownState(t *testing.T) {
t.Parallel()
var noReg *app = &app{log: testLog()}
if aw := noReg.defaultAwakening(); !aw.IsZero() {
t.Fatalf("no registry → %+v, want zero", aw)
}
// loader 会把中文字面量归一化成规范态名,所以组合根这一层只认规范名;
// 未声明(空串)与任何未知值都映射为「无该档位」。
cases := map[string]capacity.State{
"": "",
"quiet": capacity.StateQuiet,
"awake": capacity.StateAwake,
"full": capacity.StateFull,
"静息": "",
"AWAKE": "",
"nonsense": "",
}
for raw, want := range cases {
if got := awakeningState(raw); got != want {
t.Errorf("awakeningState(%q) = %q, want %q", raw, got, want)
}
}
// 一个「代码构造」的人格(绕过 loader):非法档位名不得静默生效。
reg := personareg.New()
bad := &loader.Persona{
ID: "bad", Name: "bad", CharacterRef: "bad",
Identity: loader.Identity{Role: "r", SystemPrompt: "s"},
Awakening: &loader.AwakeningParams{DefaultState: "睡觉"},
}
if err := reg.RegisterPersona(bad); err != nil {
t.Fatalf("RegisterPersona: %v", err)
}
if err := reg.RegisterCharacter(&personareg.Character{ID: "bad", Name: "bad", Default: true, PersonaID: "bad"}); err != nil {
t.Fatalf("RegisterCharacter: %v", err)
}
a := &app{log: testLog(), personas: reg}
if aw := a.defaultAwakening(); !aw.IsZero() {
t.Fatalf("unrecognized state → %+v, want zero(按无档位处理)", aw)
}
}
+245
View File
@@ -0,0 +1,245 @@
package main
import (
"context"
"os"
"path/filepath"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
)
// 本文件端到端锁定裁决 14.4.3 的后半:**每个 turn 用该 turn 所属角色的唤醒档位**。
// 只用默认角色的档位填 Config.Awakening(全局兜底)是不够的 —— 绑定非默认角色的
// 会话会「档位被解析、被校验、却静默不生效」(design/01 §2.1 禁止静默无效配置)。
// 因此这里的断言落在**模型实际看到的工具面**上:链路是 persona YAML → loader →
// 注册表 → app.awakeningFor → loop.Options.PersonaAwakening → capacity.Input.Awakening
// → Decide → 广告给模型的 schema。
// 加在两个角色身上的工具名取自 capacity 的内建档位集:terminal 在静息态就可见,
// web-search 只在唤醒/全能态出现,因此「被广告的工具集」直接暴露判定结果。
const (
quietToolName = "terminal"
awakeToolName = "web-search"
)
// roleProbeTool 是只为广告工具名存在的 sdk.Tool 替身:本文件断言的是
// 「哪些工具被广告给模型」,工具本身从不执行。
type roleProbeTool string
func (t roleProbeTool) Name() string { return string(t) }
func (t roleProbeTool) Description() string { return "role probe tool " + string(t) }
func (t roleProbeTool) Parameters() (map[string]any, error) {
return nil, nil
}
func (t roleProbeTool) Execute(context.Context, any) (any, error) { return nil, nil }
// roleProbeAdapter 每次都记下流式请求,使测试能读回该 turn 广告给模型的工具面。
type roleProbeAdapter struct{ streamReqs []model.Request }
func (a *roleProbeAdapter) Name() string { return "role-probe" }
func (a *roleProbeAdapter) Chat(context.Context, model.Request, model.Options) (model.Response, error) {
return model.Response{Content: "好的"}, nil
}
func (a *roleProbeAdapter) ChatStream(_ context.Context, req model.Request, on func(model.StreamEvent)) (model.Response, error) {
a.streamReqs = append(a.streamReqs, req)
if on != nil {
on(model.StreamEvent{Kind: model.StreamKindTextDelta, Delta: "好的"})
}
return model.Response{Content: "好的"}, nil
}
// twoRoleApp 注册两个角色,两者都来自真实 YAML(走与生产相同的载入期解析与校验):
// `cyrene` 是默认角色且档案里**没有** `awakening` 块,`tasky` 带调用方给定的块。
// 默认角色不声明档位,正是「不声明者继承全局兜底(零覆盖)」的现场。
func twoRoleApp(t *testing.T, taskyAwakening string) *app {
t.Helper()
dir := t.TempDir()
reg := personareg.New()
for _, spec := range []struct {
name string
body string
isDefault bool
}{
{"cyrene", awakeningPersonaBody, true},
{"tasky", awakeningPersonaBody + taskyAwakening, false},
} {
path := filepath.Join(dir, spec.name+".yaml")
if err := os.WriteFile(path, []byte("id: "+spec.name+"\n"+spec.body), 0o600); err != nil {
t.Fatalf("write %s yaml: %v", spec.name, err)
}
p, err := loader.Load(path)
if err != nil {
t.Fatalf("loader.Load(%s): %v", spec.name, err)
}
if err := reg.RegisterPersona(p); err != nil {
t.Fatalf("RegisterPersona(%s): %v", spec.name, err)
}
if err := reg.RegisterCharacter(&personareg.Character{
ID: p.ID, Name: p.Name, Default: spec.isDefault, PersonaID: p.ID,
}); err != nil {
t.Fatalf("RegisterCharacter(%s): %v", spec.name, err)
}
}
a := &app{log: testLog(), personas: reg}
a.cfg = &config.Config{}
a.cfg.Capacity.Enabled = true
return a
}
// roleProbeLoop 按生产接线的形状组装带 capacity 的循环:逐 turn 的档位经
// `resolve` 注入(生产传的正是 `a.awakeningFor`),`resolve` 为 nil 时是
// 「接线前」的对照循环。
func roleProbeLoop(t *testing.T, dec *capacity.Decider, ad model.Adapter, resolve func(string) capacity.Awakening) *loop.Loop {
t.Helper()
caps := registry.New()
d, err := tool.New(caps)
if err != nil {
t.Fatalf("tool.New: %v", err)
}
for _, name := range []string{quietToolName, awakeToolName} {
if err := d.Register(roleProbeTool(name)); err != nil {
t.Fatalf("register %s: %v", name, err)
}
}
pool := make(map[string]*agent.Agent, 2)
for _, id := range []string{"cyrene", "tasky"} {
ag, err := agent.New("你是"+id+"。", d, nil, agent.WithModel(ad, nil))
if err != nil {
t.Fatalf("agent.New(%s): %v", id, err)
}
pool[id] = ag
}
return loop.New(loop.Options{
Agent: pool["cyrene"],
Agents: pool,
Tools: d,
Capacity: dec,
PersonaID: "cyrene",
PersonaAwakening: resolve,
OnStream: func(model.StreamEvent) {},
})
}
// roleTurnTools 以 personaID 的会话身份驱动一个 turn,并返回模型实际看到的工具名。
// 多个 texts 按时间顺序入列,最后一个即本轮的用户消息(与判定器看到的窗口一致)。
func roleTurnTools(t *testing.T, l *loop.Loop, ad *roleProbeAdapter, personaID string, texts ...string) map[string]bool {
t.Helper()
msgs := make([]model.Message, 0, len(texts))
for _, txt := range texts {
msgs = append(msgs, model.TextMessage(model.RoleUser, txt))
}
env := loop.SessionEnv{ActorEnv: restrict.ActorOwner, MemWritable: true, PersonaID: personaID}
ctx := loop.WithSessionEnv(context.Background(), env)
if _, err := l.Run(ctx, "web:"+personaID, msgs); err != nil {
t.Fatalf("Run(%s): %v", personaID, err)
}
if len(ad.streamReqs) == 0 {
t.Fatalf("no streaming request captured for %s", personaID)
}
req := ad.streamReqs[len(ad.streamReqs)-1]
out := make(map[string]bool, len(req.Tools))
for _, s := range req.Tools {
out[s.Name] = true
}
return out
}
// TestCapacityAwakening_PerRoleAppliesToItsOwnTurns 是裁决 14.4.3 后半的端到端断言:
// 同一个**意图各层都拿不准**的输入("这个")下,声明了档位的角色与没声明的角色
// 得到不同的工具面,而没声明的角色与「完全没接线」的对照循环逐字一致。
func TestCapacityAwakening_PerRoleAppliesToItsOwnTurns(t *testing.T) {
t.Parallel()
a := twoRoleApp(t, "awakening:\n overrides:\n force_state: 唤醒\n")
// 档位确实按角色取出:非默认角色读自己的块,默认角色缺席即没有。
if aw := a.awakeningFor("tasky"); aw.ForceState != capacity.StateAwake {
t.Fatalf("awakeningFor(tasky) = %+v, want force_state awake(取自己的档案)", aw)
}
if aw := a.awakeningFor("cyrene"); !aw.IsZero() {
t.Fatalf("awakeningFor(cyrene) = %+v, want zero(档案里没写该块)", aw)
}
dec := a.buildCapacity()
const ambiguous = "这个"
wired := &roleProbeAdapter{}
withResolver := roleProbeLoop(t, dec, wired, a.awakeningFor)
baseline := &roleProbeAdapter{}
// 对照循环就是接线前的形状:判定器照旧,只是没有人按 turn 传档位。
before := roleProbeLoop(t, dec, baseline, nil)
declaring := roleTurnTools(t, withResolver, wired, "tasky", ambiguous)
undeclaring := roleTurnTools(t, withResolver, wired, "cyrene", ambiguous)
// 对照循环跑**同一个**角色与同一个输入。
undeclaringBefore := roleTurnTools(t, before, baseline, "cyrene", ambiguous)
if !declaring[awakeToolName] || !declaring[quietToolName] {
t.Fatalf("声明档位的角色工具面 = %v, want terminal+web-search(force_state 唤醒)", declaring)
}
if undeclaring[awakeToolName] {
t.Fatalf("未声明档位的角色工具面 = %v, want 静息(web-search 不得被广告)", undeclaring)
}
if len(declaring) == len(undeclaring) {
t.Fatalf("同一模糊输入下两个角色的工具面相同(%v)—— 档位没有按 turn 生效", declaring)
}
if !sameToolSet(undeclaring, undeclaringBefore) {
t.Fatalf("未声明档位者接线上后行为变了:%v vs 接线前 %v", undeclaring, undeclaringBefore)
}
}
// TestCapacityAwakening_ThresholdRoleDiffersFromGlobal 同一落点的第二个判据:档位
// 也可以只抬高阈值(min_confidence)。默认角色(零覆盖)在话题延续的模糊回合会因
// L2 趋势猜到「办事」而唤醒,非默认角色把自己的阈值抬到 0.95 后同一回合回到静息。
func TestCapacityAwakening_ThresholdRoleDiffersFromGlobal(t *testing.T) {
t.Parallel()
a := twoRoleApp(t, "awakening:\n overrides:\n min_confidence: 0.95\n")
dec := a.buildCapacity()
// L2 趋势(置信 0.66 量级):默认角色零覆盖 → 唤醒;抬高阈值者 → 静息。
trend := []string{"帮我查一下资料", "继续"}
wired := &roleProbeAdapter{}
withResolver := roleProbeLoop(t, dec, wired, a.awakeningFor)
// 对照:完全没接线的循环在同一序列上唤醒(证明差异来自该角色的档位)。
baseline := &roleProbeAdapter{}
before := roleProbeLoop(t, dec, baseline, nil)
loose := roleTurnTools(t, before, baseline, "cyrene", trend...)
strict := roleTurnTools(t, withResolver, wired, "tasky", trend...)
if !loose[awakeToolName] {
t.Fatalf("对照循环工具面 = %v, want 唤醒(web-search 被广告)", loose)
}
if strict[awakeToolName] {
t.Fatalf("抬高阈值的角色工具面 = %v, want 静息(0.95 阈值压回)", strict)
}
}
// sameToolSet 比较两个工具名集合是否逐项相同(忽略注册顺序)。
func sameToolSet(a, b map[string]bool) bool {
if len(a) != len(b) {
return false
}
for name := range a {
if !b[name] {
return false
}
}
return true
}
+189
View File
@@ -0,0 +1,189 @@
package main
// 本文件把**渠道事件留存**接进组合根(design/26 §4.3/§4.4;切片 E3+E4):按配置构造
// 事件存储、交给桥接层注入(wire_channels.go 的 channelbridge.Options.EventStore),
// 拉起常驻的「保留期清扫 + 内存计数落盘」循环,并在退出前最后一次落盘。
//
// 三条取舍逐字对齐参照实现 `wire_document_gc.go`(design/26 §4.4 明写「不另造」):
//
// 1. **启动期清扫不阻塞启动**:它是常驻 goroutine 的第一件事(紧随子系统启动之后
// 开始,网关监听之前就绪),而不是在 newApp/run 里同步等它跑完。
// 2. **失败不退出进程**:清扫 / 落盘失败只记一条 WARN 并等下一轮;一轮失败只是
// 「这一轮没扫干净」,绝不让进程退出。
// 3. **关闭 = 显式配 ≤0**:`CHANNEL_EVENT_RETENTION=0`(或负值)或
// `CHANNEL_EVENT_GC_INTERVAL=0` 都不开启清扫,NewStore 会把关闭原因记进日志
// (静默的「配了却不生效」不存在)。
//
// ⚠️ 与参照实现的一处**已知差异**(如实登记,不假装对齐):docGC 的 `stop()` 由
// `wire_services.go` 的 close 调用,而该文件由别的切片在改(本片不许动)。因此这里的
// 退出路径做成**循环自己的 ctx.Done 分支**:ctx 取消即用独立超时的 context 落最后一次盘。
// 语义与「退出前最后一次 Flush」相同,区别只在**不阻塞** close —— 计数本就是尽力而为的
// 近似值(§4.3),并与关库并发(真撞上只记 WARN,不丢留存面本身)。
//
// 三个键的读取走 `config.LoadDurationEnv`(进程环境 > `.env` > E1 导出的默认常量):
// `.env` 不写回进程环境,直接 os.Getenv 会让只写在 `.env` 里的声明静默失效。
import (
"context"
"log/slog"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/events"
)
// 三个 env 键名:必须与 `.env.example` **逐字一致**(AGENTS §10 的 E6 条目)。
const (
channelEventRetentionEnv = "CHANNEL_EVENT_RETENTION"
channelEventGCIntervalEnv = "CHANNEL_EVENT_GC_INTERVAL"
channelEventCounterFlushEnv = "CHANNEL_EVENT_COUNTER_FLUSH"
)
// channelEventFlushTimeout 是**退出前最后一次 Flush** 的时长上界。它必须配一个独立的
// context:ctx 在这一刻已被取消,拿它去写库只会立刻拿到 context.Canceled —— 那正是
// 「退出时最后一次 flush」最容易悄悄失效的地方。
const channelEventFlushTimeout = 5 * time.Second
// wireChannelEvents 构造事件留存面并挂到 app 上(随后由 wireChannels 注入桥接层)。
// 它**不**启动任何 goroutine:常驻循环由 main 的 startChannelEvents 在子系统启动之后
// 拉起(与文档 GC 的分工逐字相同)。
func (a *app) wireChannelEvents() {
a.channelEvents = a.newChannelEventStore(envPath)
}
// newChannelEventStore 按配置构造事件存储。库未接(`a.db == nil`,只可能出现在测试
// 夹具里)时返回 nil:桥接层随即走「未接线」的 fail-visible 降级路径(内存计数 +
// 首次一条 DEBUG),入站流水线照常,绝不因为没有存储而失败或丢帧。
//
// 默认值一律取 E1(`internal/runtime/events`)导出的常量 —— **不在这里另写一份字面量**:
// 两处各写一份 720h/1h/30s,改一处就会让「文档承诺的默认值」与「代码里的默认值」漂移。
// `MaxPendingKeys` 没有 env 键(design/26 §4.4 只给了三个旋钮),同样取 E1 的默认常量。
func (a *app) newChannelEventStore(envFilePath string) *events.SQLStore {
if a.db == nil {
a.log.Warn("channel event store skipped: no database connection")
return nil
}
store, err := events.NewStore(events.Config{
DB: a.db,
Log: a.log,
FlushInterval: durationEnv(a.log, envFilePath, channelEventCounterFlushEnv, events.DefaultFlushInterval),
MaxPendingKeys: events.DefaultMaxPendingKeys,
GCInterval: durationEnv(a.log, envFilePath, channelEventGCIntervalEnv, events.DefaultGCInterval),
Retention: durationEnv(a.log, envFilePath, channelEventRetentionEnv, events.DefaultRetention),
})
if err != nil {
// NewStore 唯一的失败是 DB 为 nil(上面已挡掉):仍如实处理,绝不吞错。
a.log.Warn("channel event store unavailable", "err", err)
return nil
}
return store
}
// durationEnv 读一个 duration 型 env 键(进程环境 > `.env` > def)。配了但读不懂时记
// 一条带键名与原始取值的 WARN 并回落 def —— 错的是那一个键,不该让整条留存面失效
// (AGENTS §10:非法配置不许静默失效,但也不许一票否决)。消息刻意保持中立(不带
// 具体功能名),使渠道事件与链路图两处共用同一份读取口径(切片 E4 / F2)。
func durationEnv(log *slog.Logger, envFilePath, key string, def time.Duration) time.Duration {
value, err := config.LoadDurationEnv(envFilePath, key, def)
if err != nil {
log.Warn("duration env ignored, falling back to default",
"env", key, "error", err, "fallback", def.String())
}
return value
}
// channelEventStore 返回注入桥接层的留存面:未接线时返回**真正的 nil 接口**,而不是
// 「装着 nil 指针的接口」—— 后者在 Go 里不等于 nil,会让桥接层的「未接线 ⇒ 内存计数」
// 判据失效(症状是每帧都撞一次 nil 指针 panic)。
func (a *app) channelEventStore() events.Store {
if a.channelEvents == nil {
return nil
}
return a.channelEvents
}
// startChannelEvents 拉起留存维护循环(未接线时是空操作)。
func (a *app) startChannelEvents(ctx context.Context) {
if a.channelEvents == nil {
a.log.Info("channel event upkeep disabled: store not wired",
"effect", "events are counted in memory only (bridged to the api layer counters)")
return
}
keeper := &channelEventKeeper{
store: a.channelEvents,
log: logger.WithSource(a.log, "cmd/cyrene/wire_channel_events"),
done: make(chan struct{}),
}
a.channelEventKeeper = keeper
go keeper.loop(ctx)
}
// channelEventKeeper 是渠道事件留存的常驻维护任务:保留期清扫 + 内存计数落盘 +
// 退出前最后一次落盘。它持有**存储句柄**(不是 app),因此生命周期与用法一目了然。
type channelEventKeeper struct {
store *events.SQLStore
log *slog.Logger
// done 在循环退出时关闭。它让「退出前最后一次 Flush」**可被观测**:测试据此确认
// 内存里的计数真的落了盘,而不是随 goroutine 一起消失。
done chan struct{}
}
// loop 是维护循环:启动期先清扫一次、落一次盘,此后按各自间隔定时跑,直到 ctx 被取消
// (取消时落最后一次盘再退出)。
func (k *channelEventKeeper) loop(ctx context.Context) {
defer close(k.done)
k.sweep(ctx)
k.flush(ctx)
gcTicks, stopGC := k.gcTicker()
defer stopGC()
flushTicks := time.NewTicker(k.store.FlushInterval())
defer flushTicks.Stop()
for {
select {
case <-ctx.Done():
k.flushOnShutdown()
return
case <-gcTicks:
k.sweep(ctx)
case <-flushTicks.C:
k.flush(ctx)
}
}
}
// gcTicker 给出清扫的定时通道;GC 关闭时返回 **nil 通道**(select 上永不触发)。
// `time.NewTicker(0)` 会 panic,所以「配了 ≤0 = 关闭」必须在**这里**就分岔,而不是
// 建一个零间隔 ticker 再指望它不跑。
func (k *channelEventKeeper) gcTicker() (<-chan time.Time, func()) {
if !k.store.Enabled() {
return nil, func() {}
}
ticker := time.NewTicker(k.store.Interval())
return ticker.C, ticker.Stop
}
// sweep 跑一轮保留期清扫。每轮的**成功**汇总由 store 自己记(E1 的 Sweep 已记一条,
// 含删除行数与批次),这里只负责把「这一轮整个没跑成」记成 WARN 并让循环继续。
func (k *channelEventKeeper) sweep(ctx context.Context) {
if _, err := k.store.Sweep(ctx); err != nil {
k.log.Warn("channel event sweep failed", "err", err)
}
}
// flush 把内存里待落盘的计数写进库;失败只 WARN(计数是尽力而为的近似值,§4.3)。
func (k *channelEventKeeper) flush(ctx context.Context) {
if err := k.store.Flush(ctx); err != nil {
k.log.Warn("channel event counter flush failed", "err", err)
}
}
// flushOnShutdown 是退出前的最后一次落盘:用独立超时的 context(此刻调用方的 ctx
// 已取消),失败只 WARN —— 退出路径绝不因为一次写库失败而 panic 或阻塞。
func (k *channelEventKeeper) flushOnShutdown() {
ctx, cancel := context.WithTimeout(context.Background(), channelEventFlushTimeout)
defer cancel()
k.flush(ctx)
}
+341
View File
@@ -0,0 +1,341 @@
package main
// 本文件是**渠道事件留存组合根接线**的验收(design/26 §4.3/§4.4;切片 E3+E4)。
//
// 两道并行的取证:
//
// 1. **接线存在性**(与 TestWireDocumentGC_IsWired 同一手法):组合根本身无法在单元
// 测试里实例化(开库、迁移、绑端口,而后端还在运行),因此「存储真的被构造」「真的
// 注入桥接层」「常驻循环真的被拉起」「退出前真的落最后一次盘」只能在源码层钉住 ——
// 少任何一处,留存都会**静默不工作**(事件照丢、日志里一句都没有),而这不
// 会让任何既有用例变红。最后一段还核对 `.env.example` 的三个键(AGENTS §10)。
// 2. **行为**:三个 env 键的默认值必须来自 E1 导出的常量(**不另写一份字面量**)、
// 显式 ≤0 = 关闭清扫、只写在 `.env` 里也真实生效、非法取值回落默认 + WARN;
// 以及「退出前最后一次落盘」真的把内存里的计数写进库。
import (
"bytes"
"context"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/events"
)
// TestWireChannelEvents_IsWired 钉住组合根的四个接缝 + `.env.example` 同步。
func TestWireChannelEvents_IsWired(t *testing.T) {
t.Parallel()
seams := map[string][]string{
"wire_channel_events.go": {
// 存储真的被构造,且默认值取 E1 导出的常量(不另写字面量)。
"events.NewStore(events.Config{",
"MaxPendingKeys: events.DefaultMaxPendingKeys",
"durationEnv(a.log, envFilePath, channelEventCounterFlushEnv, events.DefaultFlushInterval)",
"durationEnv(a.log, envFilePath, channelEventGCIntervalEnv, events.DefaultGCInterval)",
"durationEnv(a.log, envFilePath, channelEventRetentionEnv, events.DefaultRetention)",
// 未接线时交出**真 nil 接口**(装 nil 指针的接口不等于 nil,会让桥接层的
// 「未接线 ⇒ 内存计数」降级判据失效)。
"func (a *app) channelEventStore() events.Store {",
// GC 关闭时不许建零间隔 ticker(time.NewTicker(0) 会 panic)。
"if !k.store.Enabled() {",
// 退出前最后一次落盘,用独立 ctx(此刻调用方的 ctx 已取消)。
"k.flushOnShutdown()",
},
"wire_channels.go": {
"EventStore: a.channelEventStore(),",
},
"wire.go": {
// 必须在 wireChannels 之前构造:桥接层在构造时就注入它。
"a.wireChannelEvents()",
},
"main.go": {
// 常驻循环:没有这一行,留存面只存在于代码里而从不维护(清扫与计数落盘都不跑)。
"a.startChannelEvents(ctx)",
},
}
for file, wants := range seams {
src, err := os.ReadFile(file)
if err != nil {
t.Fatalf("read %s: %v", file, err)
}
for _, want := range wants {
if !strings.Contains(string(src), want) {
t.Fatalf("%s 里找不到 %q —— 渠道事件留存没有被接上组合根", file, want)
}
}
}
// 三个新键必须与代码里的键名**逐字一致**(AGENTS §10 的 E6 条目)。
src, err := os.ReadFile("wire_channel_events.go")
if err != nil {
t.Fatalf("read wire_channel_events.go: %v", err)
}
raw, err := os.ReadFile(filepath.Join("..", "..", ".env.example"))
if err != nil {
t.Fatalf("read .env.example: %v", err)
}
for _, key := range []string{channelEventRetentionEnv, channelEventGCIntervalEnv, channelEventCounterFlushEnv} {
if !strings.Contains(string(src), `"`+key+`"`) {
t.Fatalf("wire_channel_events.go 里没有键名常量 %q", key)
}
if !strings.Contains(string(raw), key+"=") {
t.Fatalf(".env.example 里找不到 %q(新 env 键必须同步模板,AGENTS §10)", key+"=")
}
}
}
// channelEventTestApp 构造一个只带留存面所需依赖的 app(库 + 静默 logger)。
func channelEventTestApp(t *testing.T, log *slog.Logger) *app {
t.Helper()
if log == nil {
log = testLog()
}
return &app{db: openMigratedTestDB(t, filepath.Join(t.TempDir(), "cyrene.db")), log: log}
}
// unsetChannelEventEnv 把三个键从**进程环境**里拿掉并在用例结束后复原。
//
// ⚠️ 不能用 `t.Setenv(key, "")` 代替:空值在优先级链里仍然「存在」,会**盖住** `.env`
// 里的同名声明(lookupRaw 先看 os.LookupEnv),而「只写在 .env 里也生效」正是要验的东西。
func unsetChannelEventEnv(t *testing.T) {
t.Helper()
for _, key := range []string{channelEventRetentionEnv, channelEventGCIntervalEnv, channelEventCounterFlushEnv} {
key := key
prev, had := os.LookupEnv(key)
if had {
t.Cleanup(func() { _ = os.Setenv(key, prev) })
} else {
t.Cleanup(func() { _ = os.Unsetenv(key) })
}
if err := os.Unsetenv(key); err != nil {
t.Fatalf("unset %s: %v", key, err)
}
}
}
// TestNewChannelEventStore_默认值取E1导出常量_正常:未配置时三个旋钮必须**就是**
// E1 导出的默认值(720h / 1h / 30s),并且清扫是开启的 —— 若这里另写了一份字面量,
// 「文档承诺的默认值」与「代码里的默认值」迟早漂移。
func TestNewChannelEventStore_默认值取E1导出常量_正常(t *testing.T) {
unsetChannelEventEnv(t)
store := channelEventTestApp(t, nil).newChannelEventStore("")
if store == nil {
t.Fatal("有库时必须构造出事件存储")
}
if store.Retention() != events.DefaultRetention {
t.Fatalf("Retention = %v, want %v(E1 导出的默认常量)", store.Retention(), events.DefaultRetention)
}
if store.Interval() != events.DefaultGCInterval {
t.Fatalf("GC 间隔 = %v, want %v", store.Interval(), events.DefaultGCInterval)
}
if store.FlushInterval() != events.DefaultFlushInterval {
t.Fatalf("计数落盘间隔 = %v, want %v", store.FlushInterval(), events.DefaultFlushInterval)
}
if !store.Enabled() {
t.Fatal("未配置时清扫必须开启(漏配 != 关闭)")
}
}
// TestNewChannelEventStore_显式零关闭清扫_边界:`≤0 = 关闭`(design/26 §4.4)——
// 0 刻意**不**解释成「立即清扫」,那会让一次笔误抹掉唯一的取证面。
func TestNewChannelEventStore_显式零关闭清扫_边界(t *testing.T) {
cases := []struct {
name string
key string
value string
}{
{"保留期为 0", channelEventRetentionEnv, "0"},
{"保留期为负", channelEventRetentionEnv, "-1h"},
{"清扫间隔为 0", channelEventGCIntervalEnv, "0"},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
unsetChannelEventEnv(t)
t.Setenv(tc.key, tc.value)
store := channelEventTestApp(t, nil).newChannelEventStore("")
if store == nil {
t.Fatal("配置非法不该让存储整个消失")
}
if store.Enabled() {
t.Fatalf("%s=%s 时清扫仍开启,want 关闭(显式 ≤0 = 关闭)", tc.key, tc.value)
}
})
}
}
// TestNewChannelEventStore_只写在env文件里也生效_正常:`.env` 不写回进程环境,直接
// os.Getenv 会**静默忽略**它(= 配了却不生效)。本用例把三个键只写进临时 `.env`,
// 取值必须原样生效。
func TestNewChannelEventStore_只写在env文件里也生效_正常(t *testing.T) {
unsetChannelEventEnv(t)
path := filepath.Join(t.TempDir(), ".env")
body := strings.Join([]string{
channelEventRetentionEnv + "=24h",
channelEventGCIntervalEnv + "=30m",
channelEventCounterFlushEnv + "=5s",
}, "\n") + "\n"
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("写临时 .env: %v", err)
}
store := channelEventTestApp(t, nil).newChannelEventStore(path)
if store == nil {
t.Fatal("有库时必须构造出事件存储")
}
if store.Retention() != 24*time.Hour || store.Interval() != 30*time.Minute || store.FlushInterval() != 5*time.Second {
t.Fatalf("取值 = (%v, %v, %v), want (24h, 30m, 5s)",
store.Retention(), store.Interval(), store.FlushInterval())
}
}
// TestNewChannelEventStore_非法取值回落默认并告警_异常:配了但读不懂 ⇒ 回落 E1 默认值
// **且**记一条带键名与原始取值的 WARN(AGENTS §10:非法配置不许静默失效,也不许一票
// 否决整条留存面)。
func TestNewChannelEventStore_非法取值回落默认并告警_异常(t *testing.T) {
unsetChannelEventEnv(t)
path := filepath.Join(t.TempDir(), ".env")
if err := os.WriteFile(path, []byte(channelEventRetentionEnv+"=三十天\n"), 0o600); err != nil {
t.Fatalf("写临时 .env: %v", err)
}
var buf bytes.Buffer
log := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
store := channelEventTestApp(t, log).newChannelEventStore(path)
if store == nil {
t.Fatal("单个键非法不该让存储整个消失")
}
if store.Retention() != events.DefaultRetention {
t.Fatalf("非法取值时的保留期 = %v, want %v", store.Retention(), events.DefaultRetention)
}
logged := buf.String()
if !strings.Contains(logged, "level=WARN") || !strings.Contains(logged, channelEventRetentionEnv) {
t.Fatalf("非法取值没有留下带键名的 WARN:%q", logged)
}
}
// TestChannelEventStore_未接线时交出真nil接口_边界:未接数据库(或构造失败)时
// `channelEventStore()` 必须返回**真 nil 接口** —— 返回「装着 nil 指针的接口」会让
// 桥接层的 nil 判据失效,症状是每帧撞一次 nil 指针 panic,而单测全绿。
func TestChannelEventStore_未接线时交出真nil接口_边界(t *testing.T) {
blank := &app{log: testLog()}
if store := blank.newChannelEventStore(""); store != nil {
t.Fatalf("库未接时不该构造存储:%+v", store)
}
var asInterface events.Store = blank.channelEventStore()
if asInterface != nil {
t.Fatalf("未接线时必须交出真 nil 接口,拿到的是 %#v", asInterface)
}
}
// TestChannelEventKeeper_退出前最后一次落盘_正常:ctx 取消时循环必须用**独立**的
// context 把内存里的计数写进库。这里把已取消的 ctx 直接交给 loop(同步跑完)——
// 若 `flushOnShutdown` 用调用方那个已取消的 ctx 落盘,它会立刻拿到 context.Canceled,
// 待落盘的计数就会留在内存里(用例据此变红)。
func TestChannelEventKeeper_退出前最后一次落盘_正常(t *testing.T) {
unsetChannelEventEnv(t)
a := channelEventTestApp(t, nil)
store := a.newChannelEventStore("")
if store == nil {
t.Fatal("有库时必须构造出事件存储")
}
ctx := context.Background()
// 一条只计数的 T1 事件:它在内存里待落盘(逐条落行的那类会立刻写库,掩盖问题)。
if err := store.Record(ctx, events.Event{
Platform: events.PlatformQQ,
Tier: events.TierT1,
Category: events.CategoryHeartbeat,
PlatformType: "heartbeat",
}); err != nil {
t.Fatalf("Record: %v", err)
}
if store.PendingKeys() == 0 {
t.Fatal("计数没有留在内存里,用例前提不成立")
}
cancelled, cancel := context.WithCancel(ctx)
cancel()
keeper := &channelEventKeeper{store: store, log: testLog(), done: make(chan struct{})}
keeper.loop(cancelled)
if store.PendingKeys() != 0 {
t.Fatalf("退出时仍有 %d 个键待落盘:最后一次 Flush 没跑", store.PendingKeys())
}
counters, err := store.Counters(ctx, events.PlatformQQ)
if err != nil {
t.Fatalf("Counters: %v", err)
}
if len(counters) != 1 || counters[0].Count != 1 {
t.Fatalf("退出后库里的计数 = %+v, want 一条 count=1 的心跳", counters)
}
select {
case <-keeper.done:
default:
t.Fatal("loop 退出时没有关闭 done(退出路径不可观测)")
}
}
// TestChannelEventKeeper_GC关闭时不建零间隔ticker_边界:`time.NewTicker(0)` 会 panic,
// 所以「配了 ≤0 = 关闭清扫」必须在建 ticker 之前分岔(关闭 ⇒ nil 通道,select 上永不触发)。
func TestChannelEventKeeper_GC关闭时不建零间隔ticker_边界(t *testing.T) {
t.Run("关闭时不建 ticker", func(t *testing.T) {
unsetChannelEventEnv(t)
t.Setenv(channelEventGCIntervalEnv, "0")
store := channelEventTestApp(t, nil).newChannelEventStore("")
if store == nil || store.Enabled() {
t.Fatalf("GC 应关闭:store=%v enabled=%v", store, store != nil && store.Enabled())
}
ticks, stop := (&channelEventKeeper{store: store, log: testLog()}).gcTicker()
if ticks != nil {
t.Fatal("GC 关闭时不该建 ticker(零间隔 ticker 会 panic)")
}
stop()
})
t.Run("开启时给出定时通道", func(t *testing.T) {
unsetChannelEventEnv(t)
store := channelEventTestApp(t, nil).newChannelEventStore("")
if store == nil || !store.Enabled() {
t.Fatal("默认配置下 GC 应开启")
}
ticks, stop := (&channelEventKeeper{store: store, log: testLog()}).gcTicker()
if ticks == nil {
t.Fatal("GC 开启时必须给出定时通道")
}
stop()
})
}
// TestChannelEventEnv_模板取值是合法duration_正常 是 E6(协同清单)自查的机械化版本:
// 三个键名必须能在 `.env.example` 里逐字找到,且模板值本身就是合法 duration(占位符形态,
// 从不放凭据)—— 否则照模板部署的新实例会得到一条 WARN 并**静默**回落到默认值。
func TestChannelEventEnv_模板取值是合法duration_正常(t *testing.T) {
t.Parallel()
raw, err := os.ReadFile(filepath.Join("..", "..", ".env.example"))
if err != nil {
t.Fatalf("read .env.example: %v", err)
}
lines := strings.Split(string(raw), "\n")
for _, key := range []string{channelEventRetentionEnv, channelEventGCIntervalEnv, channelEventCounterFlushEnv} {
found := false
for _, line := range lines {
value, ok := strings.CutPrefix(line, key+"=")
if !ok {
continue
}
found = true
if _, err := time.ParseDuration(value); err != nil {
t.Fatalf("%s 的模板值 %q 不是合法 duration", key, value)
}
}
if !found {
t.Fatalf(".env.example 缺少 %s(AGENTS §10:键名必须与代码逐字一致)", key)
}
}
}
+272
View File
@@ -0,0 +1,272 @@
package main
import (
"context"
"fmt"
"log/slog"
"path/filepath"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/channelbridge"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// ownerRegistrar 实现 gateway.OwnerRegistrar:它把一个渠道账号 id 持久化进主人
// 白名单(configs/users/owner.yaml),**并**热更新渠道 bridge 持有的活主人档案,
// 因此微信扫码登录的确认会被立即认成主人——无需重启,也无需手工改配置。path 与
// 活档案都在接线时捕获:启动加载的 *user.OwnerProfile 就是 bridge 判定 actor 时
// 依据的同一个指针,所以在它上面调 RegisterAccount 对 ResolveActor 立即可见,
// 无需触碰 bridge。
type ownerRegistrar struct {
path string
owner *user.OwnerProfile
log *slog.Logger
}
func (r *ownerRegistrar) RegisterChannelOwner(_ context.Context, platform, accountID string) error {
if strings.TrimSpace(accountID) == "" {
return nil
}
if err := user.RegisterOwnerAccount(r.path, platform, accountID); err != nil {
return fmt.Errorf("register owner %s:%s: %w", platform, accountID, err)
}
// 热生效:把这次登记扩到 bridge 已经指向的那份活档案上。若档案在启动时加载
// 失败(r.owner 为 nil),上面的落盘依然写进磁盘、重启后即被识别;此处打日志
// 是为了让运维知道热路径当时不可用。
if r.owner != nil {
r.owner.RegisterAccount(platform, accountID)
} else {
r.log.Warn("owner registrar: no live profile, whitelist persisted to disk only (restart to take effect)",
"platform", platform, "accountId", accountID)
}
r.log.Info("owner whitelist registered", "platform", platform, "accountId", accountID)
return nil
}
// LiveOwnerProfile 是 ownerRegistrar 对 gateway.liveOwnerProfile 可选扩展的实现(裁决
// 10.4.5):管理面板 PUT /admin/owner-profile 保存后,gateway 需要拿到**同一个**活档案指针
// 做热替换——渠道 bridge 判定 actor 用的就是这个指针,所以热替换无需重启即生效。启动时档案
// 加载失败则为 nil,gateway 会退回「真实落盘 + 提示需重启」,绝不假装热生效。
func (r *ownerRegistrar) LiveOwnerProfile() *user.OwnerProfile { return r.owner }
// channelCaps 把能力注册表适配成 channelbridge.ChannelRegistry(热重载同步注册表的
// 窄端口)。替换时写入的形状与 wireChannels 启动注册**逐字相同**
// (Name/Kind/Description/Value):Description 取适配器名、Value 取适配器本身 ——
// adminListChannels 把 Value 断言成 sdk.Channel、面板按注册名精确匹配,
// 形状一旦不一致,渠道就会从面板上消失。
type channelCaps struct{ caps *registry.Registry }
func (c channelCaps) ReplaceChannel(ch sdk.Channel) error {
if ch == nil {
return fmt.Errorf("wire channel registry: %w", registry.ErrInvalidArgument)
}
if err := c.caps.ReplaceChannel(ch); err != nil {
return fmt.Errorf("wire channel registry %q: %w", ch.Name(), err)
}
return nil
}
func (c channelCaps) RemoveChannel(name string) error {
if err := c.caps.Remove(name); err != nil {
return fmt.Errorf("wire channel registry %q: %w", name, err)
}
return nil
}
// wireChannels 依环境配置构造四个 IM 渠道适配器,把它们注册进能力注册表(管理
// API 据此枚举),并接进渠道 bridge——让已配置的适配器连上,把入站消息按
// session→actor→loop→reply 路由。缺凭据的适配器保持未连接(只记日志,绝不作
// 致命错误):拿空配置去连要么失败,要么(weixin-ilink)触发阻塞式扫码登录。
func (a *app) wireChannels() error {
// 渠道配置可在运行时编辑:channels.json 存在且有效时以它为准,否则回落到 env
// 里的渠道块(design/04 §3.1)。工厂与配置读取读的是同一个文件,因此面板保存 +
// 热重载能让启动口径与运行口径保持一致。
channelsPath := runtimeconfig.DefaultChannelsPath(resolveDataDir(a.cfg))
factory := &channelFactory{path: channelsPath, fallback: channelAccountsFromEnv(), log: a.log}
adapters := buildChannelAdapters(factory)
for _, ch := range adapters {
capp := registry.Capability{
Name: ch.Name(),
Kind: registry.KindChannel,
Description: ch.Name(),
Value: ch,
}
if err := a.caps.Register(capp); err != nil {
a.log.Warn("channel register skipped", "channel", ch.Name(), "error", err)
}
}
// 入站附件的目录口径与文档摄取层(wire_runtime.go 的 docResolver)同源,
// 两处必须指向同一棵目录树,否则附件会落在一个地方、抽取去另一个地方找。
docDataDir := resolveDataDir(a.cfg)
bridge, berr := channelbridge.New(channelbridge.Options{
Log: logger.WithSource(a.log, "services/channelbridge"),
Loop: a.loop,
Sessions: a.sessions,
Owner: a.owner,
History: a.history,
QuietStartHour: a.cfg.Background.QuietStartHour,
QuietEndHour: a.cfg.Background.QuietEndHour,
// 入站附件摄取(design/24 §3;切片 M1):IM 收到的受支持格式文件落进与
// Web 上传**同一个**存储核心(目录口径与 wire_runtime.go 的文档摄取层
// 同源:`<DataDir>/uploads/documents`),元数据也写进同一张 `documents`
// 表——`documents` 表的写入路径因此全仓只有一条(S7 的引用计数与 GC 有
// 单一事实来源)。未配置数据目录时写入面为 nil,桥接器走「未接线」的
// 诚实路径(模型知道有附件读不到),绝不假装读到了。
DocumentStore: documents.StoreConfig{
DataDir: docDataDir,
UploadsDir: filepath.Join(docDataDir, "uploads", "documents"),
Meta: documents.MetadataWriterFor(a.dbPath, a.log),
Log: a.log,
},
// EventStore 是渠道事件留存面(design/26 §4/§5;切片 E3+E4):非消息事件
// (含未知类型与解析失败)由桥接层**无条件**写进它 —— T2/T3/unknown 逐条落行、
// T1 只累加计数;「进不进上下文」另由传入前审查决定,两者互不决定。
// 通道由 channelEventStore() 归一:未接线时交出**真 nil 接口**,桥接层据此走
// 内存计数降级(绝不因为缺存储而中断入站)。
EventStore: a.channelEventStore(),
// 可选的复审模型在接近单适配器上限时剔除低价值排队消息
// (design/12 §6 策略四)。适配器为 nil 时,闸门回落到 FIFO 驱逐。
Review: reviewFor(a.modelAdapter, logger.WithSource(a.log, "services/channelbridge/review")),
// Light 是 design/18 §3 的参与判定器:对触发命中的群聊/陌生人消息做一次
// 成本受控的轻量 LLM 决策(角色名提及 / 概率 / 活跃窗口)。只有真正触发
// 才把消息喂进群聊上下文。
Light: engageLightFor(a.modelAdapter, logger.WithSource(a.log, "services/channelbridge/engage")),
TriggerWords: a.defaultTriggerWords(),
PersonaPrompt: a.defaultInstructions(),
Engage: channelbridge.DefaultEngageConfig(),
// EngageFor 留空(P2a);按群覆盖随 P2c 到来。
// Factory 让面板保存时热替换某平台的适配器而无需重启(design/04 §3.1)。
Factory: factory,
// Channels 让热重载把新适配器写回能力注册表:管理面板(渠道卡片、账号在线
// 状态、open/close 的目标解析)读的就是注册表,不同步它就只能看到旧实例,
// 于是卡片永远「未连接」、点「连接」驱动的是旧 URL 的那把。
Channels: channelCaps{caps: a.caps},
// ChannelPersona 从工厂构建所用的同一份配置里解析账号的渠道级角色
// (design/22 §2 方案 b);bridge 按已连接的适配器缓存它,并在每次重载时刷新。
ChannelPersona: factory.Persona,
// VoiceSTT 把进程内语音链注入入站流水线(切片 IM-ASR;design/06 §1.1):
// IM 语音在没有平台自带转写时由它在**流水线最前端**归一成文本(写回
// msg.Content),因此丢空判据 / 最小打扰判定 / 落库 / 模型可见消息看到的是同
// 一份文本。它在「静默档位」处**不会被当成成功**——语音链末端总是成功,所以
// bridge 用的是 `STTOutcome`(带档位与被掩盖的真实原因),并据此做临时熔断。
// a.voice 同时服务 Web 侧 WS 的 voice_input(同一条降级链,不另建一条)。
VoiceSTT: a.voice,
VoiceSTTTimeout: time.Duration(a.cfg.Voice.VoiceSTTTimeoutSec) * time.Second,
// VoiceTranscoder 把本地转码引擎交给入站语音副本链(design/25 §7.1 第 ④ 步;
// 裁决 11):**不可播格式(微信/QQ 的 AMR)必须先转成可播副本再落盘**,否则
// 那条语音只能不落副本(`category=transcode_not_wired` 的诚实 WARN),客户端
// 便拿不到可回放的字节。注入的是 `wireTranscode` 装配的**同一个**引擎实例
// (与工具面的 `transcode` 工具同源),不是第二条降级链。
VoiceTranscoder: a.transcoder,
})
if berr != nil {
return fmt.Errorf("channel bridge: %w", berr)
}
for _, ch := range adapters {
bridge.Add(ch)
}
bridge.Start()
a.channelBridge = bridge
// 保留具体类型:gateway 只看到 ChannelConfigStore 接口,而启动播种管理员白名单
// 需要 concrete runtimeChannels 的方法。
rc := &runtimeChannels{
path: channelsPath,
fallback: factory.fallback,
bridge: bridge,
owner: a.owner,
log: a.log,
}
// 启动即灌一次每渠道管理员白名单(裁决 06.4.2):不必等面板打开或热重载,
// channels.json 里声明的 adminIds 从第一帧入站消息起就参与判主。
rc.applyAdminIDs(rc.loadAccounts())
a.channels = rc
a.log.Info("channel adapters registered", "count", len(adapters))
return nil
}
// runtimeChannels 是「可运行时编辑的渠道配置」面向 gateway 的视图。Load 重读
// channels.json(文件优先 → env 兜底),Reload 委托给 bridge 的热替换,因此面板的
// 保存/重载路径驱动的正是 bridge 启动时的同一份来源。
type runtimeChannels struct {
path string
fallback map[string][]config.ChannelAccount
bridge *channelbridge.Bridge
// owner 是渠道 bridge 判 actor 用的**同一个**活档案指针:读配置时把每渠道管理员
// 白名单灌进它的 adminIDs 层(裁决 06.4.2),面板保存后无需重启即生效。
owner *user.OwnerProfile
log *slog.Logger
}
func (r *runtimeChannels) Path() string { return r.path }
func (r *runtimeChannels) Load() map[string][]config.ChannelAccount {
accounts := r.loadAccounts()
r.applyAdminIDs(accounts)
return accounts
}
// loadAccounts 只负责「读配置」:文件优先、env 兜底(design/04 §3.1)。
func (r *runtimeChannels) loadAccounts() map[string][]config.ChannelAccount {
accounts, warn := runtimeconfig.LoadChannels(r.path, r.fallback)
if warn != nil {
r.log.Warn("channels.json load fell back to env", "error", warn)
}
return accounts
}
// applyAdminIDs 把各平台声明的 adminIds 整体替换进活主人档案(裁决 06.4.2)。整体替换
// 语义保证热重载后已删除的管理员 id 立即失效;owner.yaml 的账号白名单不受影响。
func (r *runtimeChannels) applyAdminIDs(accounts map[string][]config.ChannelAccount) {
if r.owner == nil {
return
}
for platform, ids := range runtimeconfig.AdminIDs(accounts) {
r.owner.SetPlatformAdmins(platform, ids)
}
}
func (r *runtimeChannels) Reload(platform string) error {
// 先灌管理员白名单再重载适配器:面板一次保存(adminIds + 凭据)两条路径同时生效。
r.applyAdminIDs(r.loadAccounts())
return r.bridge.Reload(platform)
}
// reviewFor 构建渠道 bridge 的复审模型(design/12 §6 策略四);无模型适配器可用时
// 返回 nil,让闸门回落到 FIFO 驱逐——绝不在适配器为 nil 时调用复审器(那会在
// .Chat 上 panic)。
func reviewFor(llm model.Adapter, log *slog.Logger) adapter.Reviewer {
if llm == nil {
return nil
}
return channelbridge.NewLLMReviewer(llm, log)
}
// engageLightFor 构建 design/18 §3 的参与判定器;无模型适配器可用时返回 nil,让
// bridge 的参与引擎朝「沉默」方向 fail-closed(触发命中但无判定器的消息绝不作答)。
func engageLightFor(llm model.Adapter, log *slog.Logger) channelbridge.LightJudge {
if llm == nil {
return nil
}
return channelbridge.NewLLMLightJudge(llm, log)
}
// defaultTriggerWords 返回默认人格的角色名提醒词(design/18 §2),让渠道 bridge
// 知道哪些群聊消息会引发 PROBING。
func (a *app) defaultTriggerWords() []string {
if c, ok := a.personas.DefaultCharacter(); ok {
if p, ok := a.personas.Persona(c.PersonaID); ok {
return append([]string(nil), p.TriggerWords...)
}
}
return nil
}
+118
View File
@@ -0,0 +1,118 @@
package main
import (
"context"
"os"
"path/filepath"
"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/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/channelbridge"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
)
// wireTestChatter / wireTestSessions 是构造真 *channelbridge.Bridge 用的最小桩:
// 本文件的用例只关心「白名单播种的顺序」,不跑消息流水线,所以两个接口各给一个空实现。
type wireTestChatter struct{}
func (wireTestChatter) Run(context.Context, string, []model.Message) (*loop.Result, error) {
return &loop.Result{}, nil
}
type wireTestSessions struct{}
func (wireTestSessions) GetOrCreate(context.Context, adapter.UnifiedMessage, *user.OwnerProfile) (session.Session, error) {
return session.Session{}, nil
}
// newTestRuntimeChannels 造一个指向临时 channels.json 的运行时渠道视图,外加一个 Accounts
// 白名单为空的活主人档案——这样断言命中的就只可能是「每渠道管理员层」这一条来源。
func newTestRuntimeChannels(t *testing.T, bridge *channelbridge.Bridge) (*runtimeChannels, *user.OwnerProfile, string) {
t.Helper()
path := filepath.Join(t.TempDir(), "channels.json")
profile := &user.OwnerProfile{ID: "owner"}
rc := &runtimeChannels{path: path, bridge: bridge, owner: profile, log: testLog()}
return rc, profile, path
}
func writeTestChannels(t *testing.T, path, body string) {
t.Helper()
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("写 channels.json 失败: %v", err)
}
}
// 裁决 06.4.2:文件里声明的 adminIds 必须经 Load 灌进活主人档案并成为判主依据。
func TestRuntimeChannelsLoadSeedsPlatformAdmins(t *testing.T) {
rc, profile, path := newTestRuntimeChannels(t, nil)
writeTestChannels(t, path, `{"kook":[{"id":"conf0","config":{"bot_token":"kook-secret"},"adminIds":["admin-1"," admin-1 ",""]}]}`)
rc.Load()
if !profile.IsOwner("kook", "admin-1") {
t.Fatal("Load 后 kook:admin-1 应被判为主人(adminIds 未生效)")
}
if profile.IsOwner("kook", "someone-else") {
t.Fatal("未声明的账号不应被判为主人")
}
if profile.IsOwner("qq_obv11", "admin-1") {
t.Fatal("管理员层必须按平台隔离:kook 声明的 id 不应在别的平台生效")
}
}
// 裁决 06.4.2:整体替换语义——热重载后文件里删掉的管理员 id 必须立即失效,不残留悬挂管理员。
func TestRuntimeChannelsLoadReplacesRemovedAdmin(t *testing.T) {
rc, profile, path := newTestRuntimeChannels(t, nil)
writeTestChannels(t, path, `{"kook":[{"id":"conf0","config":{"bot_token":"kook-secret"},"adminIds":["admin-1","admin-2"]}]}`)
rc.Load()
if !profile.IsOwner("kook", "admin-2") {
t.Fatal("首次 Load 后 kook:admin-2 应被判为主人")
}
writeTestChannels(t, path, `{"kook":[{"id":"conf0","config":{"bot_token":"kook-secret"}}]}`)
rc.Load()
if profile.IsOwner("kook", "admin-2") || profile.IsOwner("kook", "admin-1") {
t.Fatal("重载后已从配置移除的管理员 id 仍然是主人(整体替换语义失效)")
}
}
// 裁决 06.4.2:Reload 必须先把白名单灌进活档案、再委托 bridge 重载适配器——面板一次保存
// (adminIds + 凭据)两条路径同时生效;这里用未注入 factory 的 bridge 证明「顺序」而非「重载成功」。
func TestRuntimeChannelsReloadSeedsAdminsBeforeDelegating(t *testing.T) {
bridge, err := channelbridge.New(channelbridge.Options{
Log: testLog(),
Loop: wireTestChatter{},
Sessions: wireTestSessions{},
})
if err != nil {
t.Fatalf("channelbridge.New: %v", err)
}
rc, profile, path := newTestRuntimeChannels(t, bridge)
writeTestChannels(t, path, `{"kook":[{"id":"conf0","config":{"bot_token":"kook-secret"},"adminIds":["admin-9"]}]}`)
if err := rc.Reload("kook"); err != channelbridge.ErrNilFactory {
t.Fatalf("Reload err = %v, 期望 ErrNilFactory(测试前提:bridge 无 factory)", err)
}
if !profile.IsOwner("kook", "admin-9") {
t.Fatal("Reload 应先把 adminIds 灌进活档案再委托 bridge,实际被跳过")
}
}
// 裁决 10.4.5:组合根必须交出渠道 bridge 判主用的**同一个**活档案指针,热替换才真的生效;
// 档案未加载时返回 nil,让 gateway 退回「真实落盘 + 提示重启」而不是假装成功。
func TestOwnerRegistrarLiveOwnerProfile(t *testing.T) {
profile := &user.OwnerProfile{ID: "owner"}
r := &ownerRegistrar{path: filepath.Join(t.TempDir(), "owner.yaml"), owner: profile, log: testLog()}
if got := r.LiveOwnerProfile(); got != profile {
t.Fatalf("LiveOwnerProfile 必须返回同一个指针: got %p want %p", got, profile)
}
var orphan ownerRegistrar
if orphan.LiveOwnerProfile() != nil {
t.Fatal("档案未加载时必须返回 nil")
}
}
@@ -0,0 +1,47 @@
package main
import (
"os"
"strings"
"testing"
)
// TestWireChannels_WiresInboundDocumentStore 是一道「接线存在性」防回归检查
// (切片 M1;与 `TestWireRuntime_UsesTheCompositionRootSeams` 同一手法)。
//
// 组合根本身无法在单元测试里实例化(开库、迁移、绑端口,而后端还在运行),
// 因此「IM 入站附件确实被接上了共享文档存储」只能在源码层钉住:列出的字符串
// 少任何一个,桥接器都会静默退回「未接线」的诚实路径 —— 附件不再落库、S7 的 GC
// 也看不到 IM 来源的文件,而这一切**不会**让任何既有用例变红。所以必须显式钉住。
//
// 最后一对断言是**成对契约**:入站附件落盘的目录与文档摄取层读原文件的目录
// 必须逐字同源(`<DataDir>/uploads/documents`),否则附件会落在一个地方、
// 装配期去另一个地方找 —— 症状是「附件全都读不到」,且两边各自都自洽。
func TestWireChannels_WiresInboundDocumentStore(t *testing.T) {
t.Parallel()
const dirSeam = `UploadsDir: filepath.Join(docDataDir, "uploads", "documents")`
src, err := os.ReadFile("wire_channels.go")
if err != nil {
t.Fatalf("read wire_channels.go: %v", err)
}
for _, want := range []string{
"DocumentStore: documents.StoreConfig{",
dirSeam,
"Meta: documents.MetadataWriterFor(a.dbPath, a.log)",
"docDataDir := resolveDataDir(a.cfg)",
} {
if !strings.Contains(string(src), want) {
t.Fatalf("wire_channels.go 里找不到 %q —— IM 入站附件没有被接上共享文档存储", want)
}
}
reader, err := os.ReadFile("wire_runtime.go")
if err != nil {
t.Fatalf("read wire_runtime.go: %v", err)
}
if !strings.Contains(string(reader), dirSeam) {
t.Fatalf("文档摄取层的目录与 IM 入站附件的目录不同源(wire_runtime.go 里找不到 %q)—— "+
"附件会落在一个地方、装配期去另一个地方找", dirSeam)
}
}
+68
View File
@@ -0,0 +1,68 @@
package main
import (
"os"
"reflect"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/transcode"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/channelbridge"
)
// TestWireChannels_InjectsVoiceTranscoder 是一道「接线存在性」防回归检查
// (裁决 11;与同目录 `TestWireChannels_WiresInboundDocumentStore` 同一手法)。
//
// 钉住的事实:组合根把 `wireTranscode` 装配的本地转码引擎交给渠道 bridge,于是**不可播
// 格式的入站语音(微信 / QQ 的 AMR)在落副本之前先被转成可播副本**
// (design/25 §7.1 第 ④ 步)。少了这一行,bridge 按 nil 走它的诚实路径 —— 那句
// `category=transcode_not_wired` 的 WARN 照记、消息一个字不丢,**但那条语音不落副本**
// ⇒ 客户端拿不到可回放的字节,而**没有任何既有用例会变红**。所以必须显式钉住。
//
// 为什么是源码级检查而不是端到端:组合根本身无法在单元测试里实例化(开库、迁移、绑端口,
// 而后端还在运行);退化的行为侧(「转码不可用 ⇒ 不落副本」)已由
// `internal/services/channelbridge/inbound_voice_copy_test.go` 覆盖,这里要钉的只是
// **那一行注入**(以及它的**时机**,见下)。
func TestWireChannels_InjectsVoiceTranscoder(t *testing.T) {
t.Parallel()
src := readSource(t, "wire_channels.go")
// 断言的是**一整行字段赋值**(行首到行尾),因此被注释掉的那一行
// (`\t\t// VoiceTranscoder: …`)或写在别处的同名字符串都不会让它通过。
const assignment = "\n\t\tVoiceTranscoder: a.transcoder,"
if !strings.Contains(src, assignment) {
t.Fatalf("wire_channels.go 里找不到 %q —— 入站语音的 AMR 不会被转成可播副本,"+
"微信语音因此不落副本(design/25 §7.1 第 ④ 步 / 裁决 11)", strings.TrimSpace(assignment))
}
// 注入面必须真装得下我们交进去的那个值:字段类型与 `a.transcoder` 的静态类型同源,
// 否则「接线成功」这句话就只是字面巧合(例如字段被改成一条自定义窄接口)。
field, ok := reflect.TypeOf(channelbridge.Options{}).FieldByName("VoiceTranscoder")
if !ok {
t.Fatal("channelbridge.Options 没有 VoiceTranscoder 字段:注入面无处可放(设计改了?)")
}
if want := reflect.TypeOf((*transcode.Transcoder)(nil)).Elem(); field.Type != want {
t.Fatalf("channelbridge.Options.VoiceTranscoder 的静态类型 = %v,want %v", field.Type, want)
}
// 注入的**时机**同样要钉住:`a.transcoder` 由 `wireTranscode()`(在 `wireRuntime` 里)
// 赋值,所以 `wireRuntime` 必须先于 `wireChannels` 执行。反过来的话上面那行注入的是
// **nil** —— 一切照旧、一条日志都不差,只有「AMR 语音落副本」这条能力静默消失。
app := readSource(t, "wire.go")
idxRuntime, idxChannels := strings.Index(app, "a.wireRuntime()"), strings.Index(app, "a.wireChannels()")
if idxRuntime < 0 || idxChannels < 0 || idxRuntime > idxChannels {
t.Fatalf("wire.go 里 wireRuntime / wireChannels 的先后不对(偏移 %d vs %d):转码引擎必须"+
"在渠道桥被构造**之前**装配好,否则 VoiceTranscoder 注入的是 nil", idxRuntime, idxChannels)
}
}
// readSource 读一个组合根源文件并把行尾归一成 LF(断言里的 `\n` 因此与平台无关)。
func readSource(t *testing.T, name string) string {
t.Helper()
raw, err := os.ReadFile(name)
if err != nil {
t.Fatalf("read %s: %v", name, err)
}
return strings.ReplaceAll(string(raw), "\r\n", "\n")
}
+83
View File
@@ -0,0 +1,83 @@
package main
import (
"log/slog"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
)
// 本文件是**上下文预算的唯一决策点**(组合根):把「配置 + 生效模型组」摊平成
// config.ContextBudgetInput,喂给纯函数 config.DeriveContextBudget,再把结论交给
// loop.Options.MaxContextTokens 与 loop.Options.DocumentMaxInjectChars。
//
// 为什么推导放在这里而不是配置层:窗口与 maxTokens 都在 models.json 的**生效组**
// 上(面板可编辑、热重载),而 CONTEXT_MAX_TOKENS 在 .env。两个来源的会合点只有
// 组合根,推导函数本身因此保持纯粹、可表驱动测试。
//
// 语义(design/04 §1.3 + 附录 A.3):
// - CONTEXT_MAX_TOKENS 未设置 ⇒ 按窗口推导 budget = min(窗口 × 比例, 窗口 − 输出保留);
// - > 0 ⇒ 显式覆盖;0 / 负数 ⇒ 关闭压缩(与接线前一致);
// - 窗口未声明 ⇒ 回落 config.DefaultModelContextWindow 并记**恰好一条** WARN;
// - 比例非法(CONTEXT_BUDGET_RATIO)⇒ 记一条带原始取值的 WARN 并回落 2/3。
// ctxBudget 是组合根推导出的两项预算:循环的溢出压缩预算与单份附件注入预算。
type ctxBudget struct {
// Budget 是压缩预算的完整结论(含推导来源,供日志与断言)。
Budget config.ContextBudget
// DocumentInjectChars 是单份附件注入上下文的字符上限(T1 §7.2)。
DocumentInjectChars int
}
// contextBudget 计算本次运行生效的上下文预算。envPath 是启动时合并的 .env 路径
// (比例键的读取与其余配置同一套优先级;空串表示不看 .env)。
//
// 模型组取**生效组**(models.json 下标 0,或 .env 合成的组):压缩预算与注入预算
// 都是循环级的单一值,而生效组才是新请求默认用的那个。
func (a *app) contextBudget(envPath string) ctxBudget {
in := config.ContextBudgetInput{}
docOverride := 0
if a.cfg != nil {
in.Override = a.cfg.Context.MaxTokens
in.OverrideConfigured = a.cfg.Context.MaxTokensSet
docOverride = a.cfg.Context.DocumentMaxInjectChars
}
if a.llm != nil {
g := a.llm.Group()
in.Window = g.ContextWindow
in.GroupMaxTokens = g.MaxTokens
}
return deriveContextBudget(sourcedWiringLog(a.log), envPath, in, docOverride)
}
// deriveContextBudget 是 contextBudget 的可测内核:取值来源收成显式参数,
// 日志与「恰好一条 WARN」也在这里(测试用捕获型 handler 断言条数)。
func deriveContextBudget(log *slog.Logger, envPath string, in config.ContextBudgetInput, docOverride int) ctxBudget {
ratio, err := config.LoadContextBudgetRatio(envPath)
if err != nil {
// 非法比例:记一条带原始取值的 WARN 并回落默认(AGENTS §10:不许静默失效)。
log.Warn("context budget ratio invalid, falling back",
"env", config.ContextBudgetRatioEnv, "error", err,
"fallback_ratio", config.DefaultContextBudgetRatio)
ratio = config.DefaultContextBudgetRatio
}
in.Ratio = ratio
budget := config.DeriveContextBudget(in)
// 单份附件注入预算:显式覆盖优先,否则随推导出的预算走。
docChars := docOverride
if docChars <= 0 {
docChars = loop.DocumentInjectCharsFor(budget.Tokens)
}
if budget.Mode == config.ContextBudgetDerived && !budget.WindowDeclared {
// 窗口未声明:**恰好一条** WARN(每次启动只推导一次)。
log.Warn("model context window not declared, falling back",
"env", "LLM_CONTEXT_WINDOW", "json_key", "contextWindow",
"window", budget.Window, "hint", "声明窗口才能吃满模型的真实窗口")
}
log.Info("context budget",
"mode", string(budget.Mode), "tokens", budget.Tokens,
"window", budget.Window, "window_declared", budget.WindowDeclared,
"ratio", budget.Ratio, "output_reserve", budget.OutputReserve,
"document_inject_chars", docChars)
return ctxBudget{Budget: budget, DocumentInjectChars: docChars}
}
+426
View File
@@ -0,0 +1,426 @@
package main
import (
"bytes"
"context"
"encoding/json"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件锁定上下文预算的取值链(design/04 §1.3 + 附录 A.3):
//
// - **窗口声明**(models.json 组的 contextWindow / LLM_CONTEXT_WINDOW)与推导
// budget = min(窗口 × 比例, 窗口 − 输出保留);
// - CONTEXT_MAX_TOKENS 的三态(未设置 ⇒ 推导 / >0 ⇒ 覆盖 / 0 或负 ⇒ 关闭);
// - 窗口未声明时**恰好一条** WARN;
// - 单份附件的注入预算随推导预算走,且真的让「清单游标 → document-read」这条
// 续读链路跑通(S4 实测的断裂,切片 T1 §7.2)。
// loopCompactSummaryMarker 是 loop 压缩摘要消息的开头(internal/runtime/loop/
// compactor.go 的 summaryMarker)。测试按这个可观测契约判定「摘要进了 prompt」。
const loopCompactSummaryMarker = "[前文摘要 · history summary]"
// compactProbeAdapter 记下每次流式请求,使测试能读回模型实际看到的上下文。
type compactProbeAdapter struct{ reqs []model.Request }
func (a *compactProbeAdapter) Name() string { return "compact-probe" }
func (a *compactProbeAdapter) Chat(context.Context, model.Request, model.Options) (model.Response, error) {
return model.Response{Content: "好"}, nil
}
func (a *compactProbeAdapter) ChatStream(_ context.Context, req model.Request, on func(model.StreamEvent)) (model.Response, error) {
a.reqs = append(a.reqs, req)
if on != nil {
on(model.StreamEvent{Kind: model.StreamKindTextDelta, Delta: "好"})
}
return model.Response{Content: "好"}, nil
}
// longConversation 造一段「比小预算长得多、单看每条都很正常」的对话:
// 12 轮问答、每条约 220 个汉字(离线估算约 114 token/条,共约 2700 token)。
func longConversation() []model.Message {
nums := []string{"一", "二", "三", "四", "五", "六", "七", "八", "九", "十", "十一", "十二"}
filler := strings.Repeat("今天也要好好吃饭早点休息哦", 17)
msgs := make([]model.Message, 0, 2*len(nums))
for _, n := range nums {
msgs = append(msgs,
model.TextMessage(model.RoleUser, "第"+n+"件事:"+filler),
model.TextMessage(model.RoleAssistant, "好呀,人家记下了♪"+filler),
)
}
return msgs
}
// runWithBudget 用给定预算驱动一次真实循环(装配形状与组合根一致:
// MaxContextTokens 取自推导结论,Compactor 不注入 —— 由 loop 选用内置的结构化
// compactor),返回模型实际看到的请求。
func runWithBudget(t *testing.T, budget int, msgs []model.Message) model.Request {
t.Helper()
adapter := &compactProbeAdapter{}
ag, err := agent.New("你是昔涟。", nil, nil, agent.WithModel(adapter, nil))
if err != nil {
t.Fatalf("agent.New: %v", err)
}
l := loop.New(loop.Options{
Agent: ag,
MaxContextTokens: budget,
OnStream: func(model.StreamEvent) {},
})
env := loop.SessionEnv{ActorEnv: restrict.ActorOwner, MemWritable: true, PersonaID: "cyrene"}
ctx := loop.WithSessionEnv(context.Background(), env)
if _, err := l.Run(ctx, "web", msgs); err != nil {
t.Fatalf("Run: %v", err)
}
if len(adapter.reqs) == 0 {
t.Fatal("没有捕获到任何模型请求")
}
return adapter.reqs[len(adapter.reqs)-1]
}
// summaryMessages 返回请求里所有压缩摘要消息的下标。
func summaryMessages(req model.Request) []int {
var idx []int
for i, m := range req.Messages {
if strings.HasPrefix(loopText(m), loopCompactSummaryMarker) {
idx = append(idx, i)
}
}
return idx
}
// loopText 取一条消息的文本部件拼接(与 loop 的 messageText 同义)。
func loopText(m model.Message) string {
var b strings.Builder
for _, c := range m.Content {
if c.Type == model.ContentTypeText {
b.WriteString(c.Text)
}
}
return b.String()
}
// capturingLog 返回输出 JSON 记录的 logger 与缓冲:这里要按**条数**断言 WARN
// (「窗口未声明恰好一条」),文本子串数不出来。
func capturingLog() (*slog.Logger, *bytes.Buffer) {
buf := &bytes.Buffer{}
return slog.New(slog.NewJSONHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug})), buf
}
// logRecords 把缓冲里的 JSON 记录逐条解出来。
func logRecords(t *testing.T, buf *bytes.Buffer) []map[string]any {
t.Helper()
out := []map[string]any{}
for _, line := range strings.Split(strings.TrimSpace(buf.String()), "\n") {
if strings.TrimSpace(line) == "" {
continue
}
rec := map[string]any{}
if err := json.Unmarshal([]byte(line), &rec); err != nil {
t.Fatalf("日志行不是合法 JSON: %v(%q)", err, line)
}
out = append(out, rec)
}
return out
}
// countRecords 数出消息里包含 substr 的记录条数(只看 msg 与 level)。
func countRecords(records []map[string]any, level, substr string) int {
n := 0
for _, rec := range records {
msg, _ := rec["msg"].(string)
if level != "" && rec["level"] != level {
continue
}
if strings.Contains(msg, substr) {
n++
}
}
return n
}
// envWindowStack 造一个「生效组声明了窗口」的模型栈:models.json 不存在时
// LoadModels 会回落到 .env 合成的组,而 LLM_CONTEXT_WINDOW 正是该组的窗口来源。
func envWindowStack(t *testing.T, window, maxTokens int) *model.RuntimeLLM {
t.Helper()
return model.NewRuntimeLLM(filepath.Join(t.TempDir(), "models.json"), config.LLMConfig{
Provider: "openai",
BaseURL: "https://api.example/v1",
Model: "test-model",
ContextWindow: window,
}, testLog())
}
// modelsWindowStack 造一个「生效组是 models.json 里那一组」的模型栈:组级
// contextWindow / maxTokens 只在 models.json 里有出处,因此这里真写一份文件。
func modelsWindowStack(t *testing.T, window, maxTokens int) *model.RuntimeLLM {
t.Helper()
path := filepath.Join(t.TempDir(), "models.json")
raw, err := json.Marshal([]map[string]any{{
"id": "deepseek", "provider": "openai", "baseUrl": "https://api.example/v1",
"apiKey": "sk-test", "model": "test-model",
"contextWindow": window, "maxTokens": maxTokens,
}})
if err != nil {
t.Fatalf("marshal models.json: %v", err)
}
if err := os.WriteFile(path, raw, 0o600); err != nil {
t.Fatalf("write models.json: %v", err)
}
return model.NewRuntimeLLM(path, config.LLMConfig{}, testLog())
}
// TestContextBudget_DerivedFromTheDeclaredWindow 钉住推导链的接线:窗口取自
// **生效模型组**,预算按附录 A.3 的公式算,比例来自 CONTEXT_BUDGET_RATIO。
func TestContextBudget_DerivedFromTheDeclaredWindow(t *testing.T) {
t.Parallel()
envPath := filepath.Join(t.TempDir(), "absent.env")
log, buf := capturingLog()
a := &app{log: log, cfg: &config.Config{}, llm: envWindowStack(t, 1048576, 0)}
got := a.contextBudget(envPath)
if got.Budget.Tokens != 699050 || got.Budget.Mode != config.ContextBudgetDerived {
t.Fatalf("1M 窗口的预算 = %+v, want 699050/derived", got.Budget)
}
// 单份附件注入预算随推导预算走:1M 窗口下远大于旧的 64K 卡死值。
if got.DocumentInjectChars <= 64*1024 {
t.Fatalf("1M 窗口下的注入预算 = %d, want > 64K", got.DocumentInjectChars)
}
if n := countRecords(logRecords(t, buf), "WARN", "not declared"); n != 0 {
t.Fatalf("窗口已声明却记了 %d 条「未声明」WARN", n)
}
// 组级 maxTokens=384K(思考模式上限)⇒ 输出保留吃满 ⇒ min(699050, 1M−384K)。
a.llm = modelsWindowStack(t, 1048576, 384*1024)
got = a.contextBudget(envPath)
if got.Budget.Tokens != 655360 || got.Budget.OutputReserve != 384*1024 {
t.Fatalf("带输出保留的预算 = %+v, want 655360 / reserve 393216", got.Budget)
}
}
// TestContextBudget_UndeclaredWindowWarnsExactlyOnce 钉住回落与「恰好一条 WARN」:
// 没声明窗口的部署照常工作,但必须被告知「要吃到 1M 得声明窗口」,且只被告知一次。
func TestContextBudget_UndeclaredWindowWarnsExactlyOnce(t *testing.T) {
t.Parallel()
envPath := filepath.Join(t.TempDir(), "absent.env")
log, buf := capturingLog()
a := &app{log: log, cfg: &config.Config{}, llm: envWindowStack(t, 0, 0)}
got := a.contextBudget(envPath)
if got.Budget.Window != config.DefaultModelContextWindow || got.Budget.WindowDeclared {
t.Fatalf("未声明窗口 = %+v, want 回落 %d 且 WindowDeclared=false",
got.Budget, config.DefaultModelContextWindow)
}
if got.Budget.Tokens != 87381 {
t.Fatalf("回落窗口下的预算 = %d, want 87381(131072 × 2/3 截尾)", got.Budget.Tokens)
}
records := logRecords(t, buf)
if n := countRecords(records, "WARN", "not declared"); n != 1 {
t.Fatalf("「窗口未声明」WARN 条数 = %d, want 恰好 1(记录:%v)", n, records)
}
// WARN 必须点出两个声明入口,否则主人不知道该改哪儿。
warn := ""
for _, rec := range records {
if msg, _ := rec["msg"].(string); strings.Contains(msg, "not declared") {
warn = msg
if rec["env"] != "LLM_CONTEXT_WINDOW" || rec["json_key"] != "contextWindow" {
t.Fatalf("WARN 没点出声明入口: %v", rec)
}
}
}
if warn == "" {
t.Fatal("没有找到窗口未声明的 WARN")
}
}
// TestContextBudget_OverrideAndOff 钉住 CONTEXT_MAX_TOKENS 的三态:>0 覆盖、
// 显式 0 关闭、负值关闭 —— 且关闭时**不推导**(窗口未声明也不该冒出 WARN)。
func TestContextBudget_OverrideAndOff(t *testing.T) {
t.Parallel()
envPath := filepath.Join(t.TempDir(), "absent.env")
log, buf := capturingLog()
a := &app{log: log, cfg: &config.Config{}, llm: envWindowStack(t, 1048576, 0)}
a.cfg.Context.MaxTokens = 600
if got := a.contextBudget(envPath); got.Budget.Tokens != 600 || got.Budget.Mode != config.ContextBudgetOverride {
t.Fatalf("显式覆盖 = %+v, want 600/override", got.Budget)
}
if n := countRecords(logRecords(t, buf), "WARN", "not declared"); n != 0 {
t.Fatalf("显式覆盖与窗口无关,却记了 %d 条「未声明」WARN", n)
}
for _, override := range []int{0, -1} {
a.cfg.Context.MaxTokens = override
a.cfg.Context.MaxTokensSet = true // 显式 0 = 关闭(0 与「未设置」必须分开)
got := a.contextBudget(envPath)
if got.Budget.Tokens != override || got.Budget.Mode != config.ContextBudgetOff {
t.Fatalf("override=%d ⇒ %+v, want off", override, got.Budget)
}
}
}
// TestContextBudget_InvalidRatioWarnsAndFallsBack 钉住非法比例的处理:记一条带
// 原始取值的 WARN 并回落 2/3,绝不静默生效(AGENTS §10)。
func TestContextBudget_InvalidRatioWarnsAndFallsBack(t *testing.T) {
dir := t.TempDir()
envPath := filepath.Join(dir, "test.env")
if err := os.WriteFile(envPath, []byte("CONTEXT_BUDGET_RATIO=四分之三\n"), 0o600); err != nil {
t.Fatalf("写 .env: %v", err)
}
log, buf := capturingLog()
a := &app{log: log, cfg: &config.Config{}, llm: envWindowStack(t, 1048576, 0)}
got := a.contextBudget(envPath)
if got.Budget.Ratio != config.DefaultContextBudgetRatio || got.Budget.Tokens != 699050 {
t.Fatalf("非法比例下的预算 = %+v, want 回落 2/3 ⇒ 699050", got.Budget)
}
records := logRecords(t, buf)
if n := countRecords(records, "WARN", "ratio invalid"); n != 1 {
t.Fatalf("非法比例的 WARN 条数 = %d, want 1(记录:%v)", n, records)
}
// 合法比例真的生效:1/2 × 1M = 524288。
if err := os.WriteFile(envPath, []byte("CONTEXT_BUDGET_RATIO=1/2\n"), 0o600); err != nil {
t.Fatalf("写 .env: %v", err)
}
if got := a.contextBudget(envPath); got.Budget.Tokens != 524288 {
t.Fatalf("1/2 比例下的预算 = %d, want 524288", got.Budget.Tokens)
}
}
// TestContextBudget_DocumentInjectCharsOverride 钉住注入预算的显式覆盖优先:
// DOCUMENT_MAX_INJECT_CHARS 是「至少可配」的落点(任务书 §2 第 7 条)。
func TestContextBudget_DocumentInjectCharsOverride(t *testing.T) {
t.Parallel()
a := &app{log: testLog(), cfg: &config.Config{}, llm: envWindowStack(t, 1048576, 0)}
a.cfg.Context.DocumentMaxInjectChars = 4096
got := a.contextBudget(filepath.Join(t.TempDir(), "absent.env"))
if got.DocumentInjectChars != 4096 {
t.Fatalf("显式注入预算 = %d, want 4096", got.DocumentInjectChars)
}
if got.Budget.Tokens != 699050 {
t.Fatalf("注入预算的覆盖不该影响压缩预算:%d", got.Budget.Tokens)
}
}
// TestContextBudget_DefaultsToNoiseFreeLoopDefault 锁定取值链的兜底:显式覆盖与
// 推导都不适用时(这里用显式覆盖举最小的例子),组合根读的就是配置值本身。
func TestContextBudget_DefaultsToNoiseFreeLoopDefault(t *testing.T) {
t.Parallel()
log, buf := capturingLog()
a := &app{log: log, cfg: &config.Config{}}
a.cfg.Context.MaxTokens = config.MinContextOutputReserve
a.cfg.Context.MaxTokensSet = true
if got := a.contextBudget(filepath.Join(t.TempDir(), "absent.env")); got.Budget.Tokens != config.MinContextOutputReserve {
t.Fatalf("contextBudget = %+v, want %d", got.Budget, config.MinContextOutputReserve)
}
if n := countRecords(logRecords(t, buf), "WARN", "not declared"); n != 0 {
t.Fatalf("覆盖态下不该有窗口 WARN(记了 %d 条)", n)
}
}
// TestContextCompaction_OnLongConversationGetsSummarizedIntoThePrompt 是真断言①:
// 预算为正且对话超出预算时,会话**真的被压缩**,且摘要消息进了模型请求。
func TestContextCompaction_OnLongConversationGetsSummarizedIntoThePrompt(t *testing.T) {
t.Parallel()
a := &app{log: testLog(), cfg: &config.Config{}}
a.cfg.Context.MaxTokens = 600 // 远小于 longConversation 的体量
msgs := longConversation()
req := runWithBudget(t, a.contextBudget(filepath.Join(t.TempDir(), "absent.env")).Budget.Tokens, msgs)
summaries := summaryMessages(req)
if len(summaries) != 1 {
t.Fatalf("请求里的摘要消息数 = %d(共 %d 条消息),want 1", len(summaries), len(req.Messages))
}
if summaries[0] != 1 {
t.Fatalf("摘要消息的位置 = %d,want 1(紧跟指令消息的近期窗口之前)", summaries[0])
}
if len(req.Messages) >= len(msgs)+1 {
t.Fatalf("请求消息数 = %d(喂入 %d 条),压缩没有生效", len(req.Messages), len(msgs))
}
// 近期尾部保留(压缩不是把历史全丢掉):最后一条用户消息仍在。
all := ""
for _, m := range req.Messages {
all += loopText(m)
}
if !strings.Contains(all, "第十二件事") {
t.Fatalf("最近一轮已被压掉:请求里找不到最后一轮用户消息")
}
}
// TestContextCompaction_ZeroBudgetKeepsTodaysBehavior 是真断言②:显式 0
// (关闭)时,行为与接线前完全一致 —— 喂进去的消息一条不少地进了请求,
// 且没有任何摘要消息。
func TestContextCompaction_ZeroBudgetKeepsTodaysBehavior(t *testing.T) {
t.Parallel()
a := &app{log: testLog(), cfg: &config.Config{}}
a.cfg.Context.MaxTokens = 0
a.cfg.Context.MaxTokensSet = true // 显式 0 = 关闭(未设置会走推导)
msgs := longConversation()
req := runWithBudget(t, a.contextBudget(filepath.Join(t.TempDir(), "absent.env")).Budget.Tokens, msgs)
if len(req.Messages) != len(msgs)+1 {
t.Fatalf("请求消息数 = %d,want %d(指令 + 全部喂入消息)", len(req.Messages), len(msgs)+1)
}
if summaries := summaryMessages(req); len(summaries) != 0 {
t.Fatalf("关闭压缩却出现了摘要消息:%v", summaries)
}
}
// TestWireRuntime_UsesTheCompositionRootSeams 是一道「接线存在性」防回归检查。
//
// 组合根本身(wireRuntime → newApp)无法在单元测试里实例化:它要开库、迁移、
// 建网关并绑定端口,在主人后端正在运行时尤其不能碰。因此「组合根确实走了这些缝」
// 只能在源码层钉住:wire_runtime.go 必须把预算取自 a.contextBudget(envPath),
// 在这份预算上建护栏管线(buildGuardrailPipeline(ctxBudget)),并把两项预算都交给 loop。
func TestWireRuntime_UsesTheCompositionRootSeams(t *testing.T) {
t.Parallel()
src, err := os.ReadFile("wire_runtime.go")
if err != nil {
t.Fatalf("read wire_runtime.go: %v", err)
}
for _, want := range []string{
"a.buildGuardrailPipeline(ctxBudget)",
"a.contextBudget(envPath)",
"MaxContextTokens: ctxBudget.Budget.Tokens",
"DocumentMaxInjectChars: ctxBudget.DocumentInjectChars",
} {
if !strings.Contains(string(src), want) {
t.Fatalf("wire_runtime.go 里找不到 %q —— 组合根没有走这条缝", want)
}
}
}
// TestContextCompaction_HugeBudgetIsAlsoInert 是变异反证:同一段对话只把预算
// 放大到不可能超出的值,压缩同样不发生 —— 证明上一条的压缩确实由「预算」驱动,
// 而不是别的什么副作用。
func TestContextCompaction_HugeBudgetIsAlsoInert(t *testing.T) {
t.Parallel()
msgs := longConversation()
req := runWithBudget(t, 1<<30, msgs)
if len(req.Messages) != len(msgs)+1 {
t.Fatalf("巨大预算下请求消息数 = %d,want %d", len(req.Messages), len(msgs)+1)
}
if summaries := summaryMessages(req); len(summaries) != 0 {
t.Fatalf("巨大预算下不该出现摘要消息:%v", summaries)
}
}
+198
View File
@@ -0,0 +1,198 @@
package main
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io"
)
// 本文件是切片 T1 §7.2 的**端到端**成对契约(S4 实测断裂的正面用例):
// 装配期注入预算与文档读侧共用同一套字符坐标系,因此清单行给的续读游标真的能
// 被 document-read 用起来。预算推导本身的用例在 wire_context_test.go。
// docE2EName 是合法存储名形态(32 位十六进制 + .txt)。
const docE2EName = "0123456789abcdef0123456789abcdef.txt"
// docBudgetFixture 是一份端到端夹具:隔离数据目录 + 已落盘的正文 + 生产解析器 +
// 生产 document-read 工具 + 一个能捕获模型请求的循环。
type docBudgetFixture struct {
cfg *config.Config
body string
ad *compactProbeAdapter
read sdk.Tool
cursor int
}
// newDocBudgetFixture 造夹具并跑**一次真实的回合**,返回模型看到的清单游标。
func newDocBudgetFixture(t *testing.T, injectBudget int) *docBudgetFixture {
t.Helper()
dataDir := t.TempDir()
uploads := filepath.Join(dataDir, "uploads", "documents")
if err := os.MkdirAll(uploads, 0o700); err != nil {
t.Fatalf("建上传目录: %v", err)
}
fx := &docBudgetFixture{cfg: &config.Config{DataDir: dataDir}, body: strings.Repeat("abcdefghij", 20000)}
if err := os.WriteFile(filepath.Join(uploads, docE2EName), []byte(fx.body), 0o600); err != nil {
t.Fatalf("写上传件: %v", err)
}
adapter := &compactProbeAdapter{}
ag, err := agent.New("你是昔涟。", nil, nil, agent.WithModel(adapter, nil))
if err != nil {
t.Fatalf("agent.New: %v", err)
}
l := loop.New(loop.Options{
Agent: ag,
DocumentResolver: documents.NewResolver(documents.Config{DataDir: dataDir, UploadsDir: uploads}),
DocumentMaxInjectChars: injectBudget,
OnStream: func(model.StreamEvent) {},
})
msg := model.DocumentMessage(model.RoleUser, "看看这个附件",
model.Document{Name: docE2EName, MIME: "text/plain", Size: int64(len(fx.body))})
if _, err := l.Run(context.Background(), "sess-doc", []model.Message{msg}); err != nil {
t.Fatalf("Run: %v", err)
}
if len(adapter.reqs) == 0 {
t.Fatal("没有捕获到模型请求")
}
fx.ad = adapter
tools, err := iotools.New(fx.cfg)
if err != nil {
t.Fatalf("iotools.New: %v", err)
}
fx.read = documentReadToolByName(t, tools)
req := adapter.reqs[len(adapter.reqs)-1]
prompt := loopText(req.Messages[len(req.Messages)-1])
fx.cursor, _ = cursorFromManifest(t, prompt)
return fx
}
// TestDocumentCursor_ContinuationReadsBeyondTheInjectionBudget 是切片 T1 §7.2 的
// **端到端**成对契约(S4 实测断裂的正面用例):正文远大于注入预算时
//
// ①清单行带 truncated + 续读游标(游标 = 实际注入的字符数);
// ②把清单行里那个游标原样交给 `document-read`,拿到的是注入预算**之外**的正文。
//
// 三个角色都是生产实现:装配期是真实的 loop.resolveDocuments,sidecar 由真实的
// documents.Resolver 写出,读取走真实的 document-read 工具。唯一被单独钉住的是
// 「注入预算 = 1000 字符」,那正是本切片新增的可配置项。
func TestDocumentCursor_ContinuationReadsBeyondTheInjectionBudget(t *testing.T) {
t.Parallel()
const injectBudget = 1000
fx := newDocBudgetFixture(t, injectBudget)
if fx.cursor != injectBudget {
t.Fatalf("清单游标 = %d, want %d(= 实际注入的字符数)", fx.cursor, injectBudget)
}
prompt := loopText(fx.ad.reqs[len(fx.ad.reqs)-1].Messages[len(fx.ad.reqs[len(fx.ad.reqs)-1].Messages)-1])
_, injected := cursorFromManifest(t, prompt)
if injected != fx.body[:injectBudget] {
t.Fatalf("注入正文 = %d 字符,want 正文的前 %d 字符", len(injected), injectBudget)
}
// ② 照游标续读:拿到的是注入预算之外的正文(旧实现这里恒为空 —— 断裂所在)。
first := callDocumentRead(t, fx.read, map[string]any{"name": docE2EName, "offset": fx.cursor})
if first["offset"] != float64(fx.cursor) {
t.Fatalf("续读 offset = %v, want %d", first["offset"], fx.cursor)
}
text, _ := first["text"].(string)
if text == "" {
t.Fatal("按清单游标续读读到了空正文 —— 清单给的承诺是假的")
}
if want := fx.body[fx.cursor : fx.cursor+len(text)]; text != want {
t.Fatalf("续读正文不是注入预算之外的那一段:\n got %q\nwant %q", text, want)
}
// 一路翻页到底:拼起来必须正好是注入预算之外的全文(两边同一坐标系)。
joined := text
offset := fx.cursor + len(text)
for i := 0; i < 100 && first["truncated"] == true; i++ {
first = callDocumentRead(t, fx.read, map[string]any{"name": docE2EName, "offset": offset})
page, _ := first["text"].(string)
joined += page
offset += len(page)
}
if joined != fx.body[fx.cursor:] {
t.Fatalf("分页续读拼出 %d 字符,want %d(注入预算之外的全文)", len(joined), len(fx.body)-fx.cursor)
}
if total, _ := first["totalChars"].(float64); int(total) != len(fx.body) {
t.Fatalf("totalChars = %v, want 全文 %d 字符", first["totalChars"], len(fx.body))
}
}
// cursorFromManifest 从模型看到的上下文里取出清单行与续读游标(offset=N)。它刻意
// **解析文本**而不是读内部字段:模型能看到的就只有这一行,游标能不能被模型用起来,
// 正是这条契约要证明的事。返回游标与注入块(清单行 + 换行 + 注入正文)。
func cursorFromManifest(t *testing.T, prompt string) (int, string) {
t.Helper()
start := strings.Index(prompt, "[附件 ")
if start < 0 {
t.Fatalf("上下文里没有清单行:%q", prompt)
}
block := prompt[start:]
line, injected, ok := strings.Cut(block, "]\n")
if !ok {
t.Fatalf("清单行没有闭合或没有正文行:%q", block)
}
if !strings.Contains(line, "已截断") {
t.Fatalf("清单行没有如实标注截断:%q", line)
}
i := strings.Index(line, "offset=")
if i < 0 {
t.Fatalf("清单行没有续读游标:%q", line)
}
digits := line[i+len("offset="):]
end := 0
for end < len(digits) && digits[end] >= '0' && digits[end] <= '9' {
end++
}
n, err := strconv.Atoi(digits[:end])
if err != nil {
t.Fatalf("续读游标不是整数:%q", digits[:end])
}
return n, injected
}
// documentReadToolByName 在真实工具集里找出 document-read 工具。
func documentReadToolByName(t *testing.T, tools []sdk.Tool) sdk.Tool {
t.Helper()
for _, tl := range tools {
if tl.Name() == "document-read" {
return tl
}
}
t.Fatal("工具集里没有 document-read")
return nil
}
// callDocumentRead 执行一次 document-read 并把返回的 JSON 文本解回 map。
func callDocumentRead(t *testing.T, tl sdk.Tool, args map[string]any) map[string]any {
t.Helper()
raw, err := tl.Execute(context.Background(), args)
if err != nil {
t.Fatalf("document-read: %v", err)
}
text, ok := raw.(string)
if !ok {
t.Fatalf("返回值类型 = %T, want string(JSON 文本)", raw)
}
out := map[string]any{}
if err := json.Unmarshal([]byte(text), &out); err != nil {
t.Fatalf("返回值不是合法 JSON: %v(%q)", err, text)
}
return out
}
+99
View File
@@ -0,0 +1,99 @@
package main
// 本文件把**文档回收(GC)**接进组合根(design/24 §3.2 的配额与清理,切片 S7):
// 按配置构造回收器、在启动期清扫一次、此后按间隔定时清扫,退出时干净停下。
//
// 三条取舍写在这里(实现细节见 `internal/runtime/documents/gc.go`):
//
// 1. **启动期清扫不阻塞启动**:它是常驻 goroutine 的第一件事(紧随子系统启动之后
// 开始,网关监听之前就绪),而不是在 newApp/run 里同步等它跑完 —— 数据目录在
// 慢盘/网络盘上时,一次清扫不该把「昔涟能不能起来」绑在磁盘上。
// 2. **失败不退出进程**:查询失败(库不可用、迁移没跑)只记一条 WARN 并等下一轮;
// 单件文件删除失败由 gc.go 记 WARN 后继续,整轮照常跑完。
// 3. **关闭 = 显式配 0**:`DOCUMENT_GC_INTERVAL=0` 或 `DOCUMENT_RETENTION=0`
// 都不构造回收器,NewGC 会把关闭原因记进日志(静默的「配了却不生效」不存在)。
import (
"context"
"log/slog"
"path/filepath"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
)
// documentGC 是常驻的文档回收任务:一个回收器 + 一个可停的循环。
type documentGC struct {
gc *documents.GC
log *slog.Logger
cancel context.CancelFunc
done chan struct{}
}
// newDocumentGC 按当前配置构造回收器;GC 关闭时返回 nil(NewGC 已记下关闭原因)。
// 目录口径与网关的上传目录、摄取层的读取目录**逐字同源**
// (`<DataDir>/uploads/documents`,sidecar 在 `<DataDir>/extracted`)。
func (a *app) newDocumentGC() *documentGC {
docDataDir := resolveDataDir(a.cfg)
gc := documents.NewGC(documents.GCConfig{
DB: a.db,
DataDir: docDataDir,
UploadsDir: filepath.Join(docDataDir, "uploads", "documents"),
Interval: a.cfg.Server.DocumentGCInterval,
Retention: a.cfg.Server.DocumentRetention,
Log: a.log,
})
if !gc.Enabled() {
return nil
}
return &documentGC{gc: gc, log: logger.WithSource(a.log, "cmd/cyrene")}
}
// startDocumentGC 拉起常驻回收(无操作 = GC 关闭):立刻清扫一次,然后按间隔定时
// 清扫,直到 ctx 被取消或 stop 被调用。
func (a *app) startDocumentGC(ctx context.Context) {
runner := a.newDocumentGC()
if runner == nil {
return
}
runCtx, cancel := context.WithCancel(ctx)
runner.cancel = cancel
runner.done = make(chan struct{})
a.docGC = runner
go runner.loop(runCtx)
}
// loop 是回收循环:启动期清扫一次 → 定时清扫。
func (d *documentGC) loop(ctx context.Context) {
defer close(d.done)
d.sweep(ctx)
ticker := time.NewTicker(d.gc.Interval())
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
d.sweep(ctx)
}
}
}
// sweep 跑一轮回收。每轮的成功汇总由 gc.Sweep 自己记(删了几件/释放多少字节,
// **绝不记正文**);这里只负责把「这一轮整个没跑成」记成 WARN 并让循环继续。
func (d *documentGC) sweep(ctx context.Context) {
if _, err := d.gc.Sweep(ctx); err != nil {
d.log.Warn("document gc sweep failed", "err", err)
}
}
// stop 停掉回收循环并等它退出;未启动时是空操作。它必须在关库**之前**调用
// (清扫要用那条连接池)。
func (d *documentGC) stop() {
if d == nil || d.cancel == nil {
return
}
d.cancel()
<-d.done
}
+64
View File
@@ -0,0 +1,64 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
// TestWireDocumentGC_IsWired 是一道「接线存在性」防回归检查(切片 S7;与
// TestWireChannels_WiresInboundDocumentStore 同一手法)。
//
// 组合根本身无法在单元测试里实例化(开库、迁移、绑端口,而后端还在运行),因此
// 「引用计数确实被接上」「启动期/定时清扫确实被拉起」「关闭时确实被停掉」只能在
// 源码层钉住:少任何一处,GC 都会**静默不工作**(消息照写、文件照留、日志里一句
// 都没有),而这不会让任何既有用例变红。
//
// 最后一段是 `.env.example` 的同步检查(AGENTS §10 铁律):新键必须与代码里的
// env 标签**逐字一致**,否则照模板部署的新实例会漏配且静默失效。
func TestWireDocumentGC_IsWired(t *testing.T) {
t.Parallel()
seams := map[string][]string{
"wire_services.go": {
// 引用计数写入面注入 history(消息落库/编辑/删除都在那里增减)。
"history.WithDocumentRefs(documents.NewRefCounter(a.log))",
// 回收循环必须在关库之前停下(清扫用那条连接池)。
"a.docGC.stop()",
},
"wire_document_gc.go": {
"documents.NewGC(documents.GCConfig{",
// 目录口径与网关上传、摄取层读取逐字同源(`<DataDir>/uploads/documents`)。
`UploadsDir: filepath.Join(docDataDir, "uploads", "documents")`,
"Interval: a.cfg.Server.DocumentGCInterval",
"Retention: a.cfg.Server.DocumentRetention",
},
"main.go": {
// 启动期清扫 + 定时:没有这一行,GC 只存在于代码里而从不运行。
"a.startDocumentGC(ctx)",
},
}
for file, wants := range seams {
src, err := os.ReadFile(file)
if err != nil {
t.Fatalf("read %s: %v", file, err)
}
for _, want := range wants {
if !strings.Contains(string(src), want) {
t.Fatalf("%s 里找不到 %q —— 文档引用计数/回收没有被接上组合根", file, want)
}
}
}
// .env.example 的两个新键必须与代码里的 env 标签逐字一致。
raw, err := os.ReadFile(filepath.Join("..", "..", ".env.example"))
if err != nil {
t.Fatalf("read .env.example: %v", err)
}
for _, key := range []string{"DOCUMENT_GC_INTERVAL=", "DOCUMENT_RETENTION="} {
if !strings.Contains(string(raw), key) {
t.Fatalf(".env.example 里找不到 %q(新 env 键必须同步模板,AGENTS §10)", key)
}
}
}
+156
View File
@@ -0,0 +1,156 @@
package main
// 本文件把**链路图落盘**接进组合根(design/19 §4 P3;切片 F2):按配置构造链路图
// 存储,交给发布器当「每轮收尾落一条」的出口,并拉起常驻的保留期清扫循环。网关的
// 只读出口(契约 v0.73 的 `?turnId=` 与 `turns[]`)在 wire_frontend.go 里注入**同一个**
// 存储句柄,因此「写进去的」与「查得到的」永远是一份状态。
//
// 三条取舍逐字对齐参照实现 `wire_channel_events.go` / `wire_document_gc.go`
// (design 明写「不另造」):
//
// 1. **启动期清扫不阻塞启动**:它是常驻 goroutine 的第一件事,而不是在 newApp/run 里
// 同步等它跑完。
// 2. **失败不退出进程**:清扫失败只记一条 WARN 并等下一轮;一轮失败只是「这一轮没
// 扫干净」,绝不让进程退出。
// 3. **关闭 = 显式配 ≤0**:`FLOW_GRAPH_RETENTION=0`(或负值)或
// `FLOW_GRAPH_GC_INTERVAL=0` 都不开启清扫,NewStore 会把关闭原因记进日志(静默的
// 「配了却不生效」不存在)。
//
// ⚠️ 与 channelEventKeeper 的一处刻意留白:本 keeper 只在 ctx 取消时退出循环(不额外
// 挂退出钩子)—— 与 `wire_channel_events.go` 的已知差异一致,因为 `wire_services.go`
// 由别的切片在改(本片不许动)。清扫本就是幂等且尽力而为的。
//
// 两个键的读取走 `config.LoadDurationEnv`(进程环境 > `.env` > 本包导出的默认常量):
// `.env` 不写回进程环境,直接 os.Getenv 会让只写在 `.env` 里的声明静默失效。
import (
"context"
"log/slog"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/flowgraph"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway"
)
// 两个 env 键名:必须与 `.env.example` **逐字一致**(AGENTS §10 的 E6 条目)。
const (
flowGraphRetentionEnv = "FLOW_GRAPH_RETENTION"
flowGraphGCIntervalEnv = "FLOW_GRAPH_GC_INTERVAL"
)
// wireFlowGraphs 构造链路图存储并挂到 app 上(随后 wireRuntime 把它交给发布器作落盘
// 出口,wireFrontend 把它交给网关作只读查询出口)。它**不**启动任何 goroutine:常驻
// 循环由 main 的 startFlowGraphs 在子系统启动之后拉起(与渠道事件留存的分工逐字相同)。
func (a *app) wireFlowGraphs() {
a.flowStore = a.newFlowGraphStore(envPath)
}
// newFlowGraphStore 按配置构造链路图存储。库未接(`a.db == nil`,只可能出现在测试
// 夹具里)时返回 nil:发布器随即走「不落盘」的纯内存路径(F1 之前的行为),会话收尾
// 照常,绝不因为没有存储而失败或改变实时帧。
//
// 默认值一律取 `internal/runtime/flowgraph` 导出的常量 —— **不在这里另写一份字面量**:
// 两处各写一份 168h/1h,改一处就会让「文档承诺的默认值」与「代码里的默认值」漂移。
func (a *app) newFlowGraphStore(envFilePath string) *flowgraph.SQLStore {
if a.db == nil {
a.log.Warn("flow graph store skipped: no database connection")
return nil
}
store, err := flowgraph.NewStore(flowgraph.Config{
DB: a.db,
Log: a.log,
GCInterval: durationEnv(a.log, envFilePath, flowGraphGCIntervalEnv, flowgraph.DefaultGCInterval),
Retention: durationEnv(a.log, envFilePath, flowGraphRetentionEnv, flowgraph.DefaultRetention),
})
if err != nil {
// NewStore 唯一的失败是 DB 为 nil(上面已挡掉):仍如实处理,绝不吞错。
a.log.Warn("flow graph store unavailable", "err", err)
return nil
}
return store
}
// flowRoundRecorder 返回注入发布器的落盘出口:未接线时返回**真正的 nil 接口**,而不是
// 「装着 nil 指针的接口」—— 后者在 Go 里不等于 nil,会让发布器的 `p.record != nil`
// 判据失效(症状是每一轮收尾都撞一次 nil 接收者并记一条 WARN)。
func (a *app) flowRoundRecorder() loop.RoundRecorder {
if a.flowStore == nil {
return nil
}
return a.flowStore
}
// flowGraphStore 返回注入网关的只读出口:未接线时同样交出**真 nil 接口**(与
// channelEventStore 同一取舍)。发布器与网关拿到的是**同一个**存储句柄。
func (a *app) flowGraphStore() gateway.FlowGraphStore {
if a.flowStore == nil {
return nil
}
return a.flowStore
}
// startFlowGraphs 拉起链路图留存的维护循环(未接线时是空操作)。
func (a *app) startFlowGraphs(ctx context.Context) {
if a.flowStore == nil {
a.log.Info("flow graph upkeep disabled: store not wired",
"effect", "rounds are kept in memory only (no local history)")
return
}
keeper := &flowGraphKeeper{
store: a.flowStore,
log: logger.WithSource(a.log, "cmd/cyrene/wire_flow_graphs"),
done: make(chan struct{}),
}
a.flowGraphKeeper = keeper
go keeper.loop(ctx)
}
// flowGraphKeeper 是链路图留存的常驻维护任务(保留期清扫)。它持有**存储句柄**
// (不是 app),因此生命周期与用法一目了然。
type flowGraphKeeper struct {
store *flowgraph.SQLStore
log *slog.Logger
// done 在循环退出时关闭(让退出路径可观测)。
done chan struct{}
}
// loop 是维护循环:启动期先清扫一次,此后按间隔定时清扫,直到 ctx 被取消。
// GC 关闭时启动期那次是空操作,循环随即返回(不留空转 goroutine)。
func (k *flowGraphKeeper) loop(ctx context.Context) {
defer close(k.done)
k.sweep(ctx)
ticks, stop := k.gcTicker()
defer stop()
if ticks == nil {
return
}
for {
select {
case <-ctx.Done():
return
case <-ticks:
k.sweep(ctx)
}
}
}
// gcTicker 给出清扫的定时通道;GC 关闭时返回 **nil 通道**(select 上永不触发)。
// `time.NewTicker(0)` 会 panic,所以「配了 ≤0 = 关闭」必须**在这里**就分岔,而不是
// 建一个零间隔 ticker 再指望它不跑。
func (k *flowGraphKeeper) gcTicker() (<-chan time.Time, func()) {
if !k.store.Enabled() {
return nil, func() {}
}
ticker := time.NewTicker(k.store.Interval())
return ticker.C, ticker.Stop
}
// sweep 跑一轮保留期清扫。每轮的**成功**汇总由 store 自己记(含删除行数与批次),
// 这里只负责把「这一轮整个没跑成」记成 WARN 并让循环继续。
func (k *flowGraphKeeper) sweep(ctx context.Context) {
if _, err := k.store.Sweep(ctx); err != nil {
k.log.Warn("flow graph sweep failed", "err", err)
}
}
+290
View File
@@ -0,0 +1,290 @@
package main
// 本文件是**链路图落盘组合根接线**的验收(切片 F2;design/19 §4 P3)。
//
// 两道并行的取证(与 `wire_channel_events_test.go` 同一手法):
//
// 1. **接线存在性**:组合根本身无法在单元测试里实例化(开库、迁移、绑端口,而后端还在
// 运行),因此「存储真的被构造」「真的交给发布器作落盘出口」「真的注入网关作查询
// 出口」「常驻循环真的被拉起」只能在源码层钉住 —— 少任何一处,落盘会**静默不工作**
// (图照旧只在内存、日志里一句都没有),而这不红任何既有用例。最后一段还核对
// `.env.example` 的两个键(AGENTS §10 的 E6 条目)。
// 2. **行为**:两个 env 键的默认值必须来自 `flowgraph` 导出的常量(**不另写一份字面量**)、
// 显式 ≤0 = 关闭清扫、只写在 `.env` 里也真实生效、非法取值回落默认 + WARN;以及
// GC 关闭时不建零间隔 ticker(`time.NewTicker(0)` 会 panic)。
import (
"bytes"
"context"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/flowgraph"
)
// TestWireFlowGraphs_IsWired 钉住组合根的接缝 + `.env.example` 同步。
func TestWireFlowGraphs_IsWired(t *testing.T) {
t.Parallel()
seams := map[string][]string{
"wire_flow_graphs.go": {
// 存储真的被构造,且默认值取 flowgraph 导出的常量(不另写字面量)。
"flowgraph.NewStore(flowgraph.Config{",
"durationEnv(a.log, envFilePath, flowGraphGCIntervalEnv, flowgraph.DefaultGCInterval)",
"durationEnv(a.log, envFilePath, flowGraphRetentionEnv, flowgraph.DefaultRetention)",
// 未接线时交出**真 nil 接口**(装 nil 指针的接口不等于 nil,会让发布器/网关的
// 未接线判据失效)。
"func (a *app) flowRoundRecorder() loop.RoundRecorder {",
"func (a *app) flowGraphStore() gateway.FlowGraphStore {",
// GC 关闭时不许建零间隔 ticker(time.NewTicker(0) 会 panic)。
"if !k.store.Enabled() {",
},
"wire_runtime.go": {
// 先建存储,再把它交给发布器作每轮落盘出口。
"a.wireFlowGraphs()",
"loop.WithRoundRecorder(a.flowRoundRecorder())",
},
"wire_frontend.go": {
// 网关只读出口注入**同一个**存储句柄。
"FlowGraphs: a.flowGraphStore(),",
},
"main.go": {
// 常驻循环:没有这一行,落盘的轮次图从不被清理(保留期形同虚设、无限增长)。
"a.startFlowGraphs(ctx)",
},
}
for file, wants := range seams {
src, err := os.ReadFile(file)
if err != nil {
t.Fatalf("read %s: %v", file, err)
}
for _, want := range wants {
if !strings.Contains(string(src), want) {
t.Fatalf("%s 里找不到 %q —— 链路图落盘没有被接上组合根", file, want)
}
}
}
// 两个新键必须与代码里的键名**逐字一致**(AGENTS §10 的 E6 条目)。
src, err := os.ReadFile("wire_flow_graphs.go")
if err != nil {
t.Fatalf("read wire_flow_graphs.go: %v", err)
}
raw, err := os.ReadFile(filepath.Join("..", "..", ".env.example"))
if err != nil {
t.Fatalf("read .env.example: %v", err)
}
for _, key := range []string{flowGraphRetentionEnv, flowGraphGCIntervalEnv} {
if !strings.Contains(string(src), `"`+key+`"`) {
t.Fatalf("wire_flow_graphs.go 里没有键名常量 %q", key)
}
if !strings.Contains(string(raw), key+"=") {
t.Fatalf(".env.example 里找不到 %q(新 env 键必须同步模板,AGENTS §10)", key+"=")
}
}
}
// flowGraphTestApp 构造一个只带落盘面所需依赖的 app(库 + 静默 logger)。
func flowGraphTestApp(t *testing.T, log *slog.Logger) *app {
t.Helper()
if log == nil {
log = testLog()
}
return &app{db: openMigratedTestDB(t, filepath.Join(t.TempDir(), "cyrene.db")), log: log}
}
// unsetFlowGraphEnv 把两个键从**进程环境**里拿掉并在用例结束后复原。
// ⚠️ 不能用 `t.Setenv(key, "")` 代替:空值在优先级链里仍然「存在」,会盖住 `.env`
// 里的同名声明,而「只写在 .env 里也生效」正是要验的东西(与渠道事件同一取舍)。
func unsetFlowGraphEnv(t *testing.T) {
t.Helper()
for _, key := range []string{flowGraphRetentionEnv, flowGraphGCIntervalEnv} {
key := key
prev, had := os.LookupEnv(key)
if had {
t.Cleanup(func() { _ = os.Setenv(key, prev) })
} else {
t.Cleanup(func() { _ = os.Unsetenv(key) })
}
if err := os.Unsetenv(key); err != nil {
t.Fatalf("unset %s: %v", key, err)
}
}
}
// TestNewFlowGraphStore_默认值取导出常量_正常:未配置时两个旋钮必须**就是** flowgraph
// 导出的默认值(168h / 1h),且清扫开启 —— 另写字面量迟早与文档承诺漂移。
func TestNewFlowGraphStore_默认值取导出常量_正常(t *testing.T) {
unsetFlowGraphEnv(t)
store := flowGraphTestApp(t, nil).newFlowGraphStore("")
if store == nil {
t.Fatal("有库时必须构造出链路图存储")
}
if store.Retention() != flowgraph.DefaultRetention {
t.Fatalf("Retention = %v, want %v(导出的默认常量)", store.Retention(), flowgraph.DefaultRetention)
}
if store.Interval() != flowgraph.DefaultGCInterval {
t.Fatalf("GC 间隔 = %v, want %v", store.Interval(), flowgraph.DefaultGCInterval)
}
if !store.Enabled() {
t.Fatal("未配置时清扫必须开启(漏配 != 关闭)")
}
}
// TestNewFlowGraphStore_显式零关闭清扫_边界:`≤0 = 关闭`—— 0 刻意不解释成「立即清空」,
// 那会让一次笔误抹掉刚落盘的历史轮。
func TestNewFlowGraphStore_显式零关闭清扫_边界(t *testing.T) {
cases := []struct {
name string
key string
value string
}{
{"保留期为 0", flowGraphRetentionEnv, "0"},
{"保留期为负", flowGraphRetentionEnv, "-1h"},
{"清扫间隔为 0", flowGraphGCIntervalEnv, "0"},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
unsetFlowGraphEnv(t)
t.Setenv(tc.key, tc.value)
store := flowGraphTestApp(t, nil).newFlowGraphStore("")
if store == nil {
t.Fatal("配置非法不该让存储整个消失")
}
if store.Enabled() {
t.Fatalf("%s=%s 时清扫仍开启,want 关闭(显式 ≤0 = 关闭)", tc.key, tc.value)
}
})
}
}
// TestNewFlowGraphStore_只写在env文件里也生效_正常:`.env` 不写回进程环境,直接
// os.Getenv 会**静默忽略**它。本用例把两个键只写进临时 `.env`,取值必须原样生效。
func TestNewFlowGraphStore_只写在env文件里也生效_正常(t *testing.T) {
unsetFlowGraphEnv(t)
path := filepath.Join(t.TempDir(), ".env")
body := strings.Join([]string{
flowGraphRetentionEnv + "=24h",
flowGraphGCIntervalEnv + "=30m",
}, "\n") + "\n"
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("写临时 .env: %v", err)
}
store := flowGraphTestApp(t, nil).newFlowGraphStore(path)
if store == nil {
t.Fatal("有库时必须构造出链路图存储")
}
if store.Retention() != 24*time.Hour || store.Interval() != 30*time.Minute {
t.Fatalf("取值 = (%v, %v), want (24h, 30m)", store.Retention(), store.Interval())
}
}
// TestNewFlowGraphStore_非法取值回落默认并告警_异常:配了但读不懂 ⇒ 回落默认值**且**记
// 一条带键名的 WARN(非法配置不许静默失效,也不许一票否决整条落盘面)。
func TestNewFlowGraphStore_非法取值回落默认并告警_异常(t *testing.T) {
unsetFlowGraphEnv(t)
path := filepath.Join(t.TempDir(), ".env")
if err := os.WriteFile(path, []byte(flowGraphRetentionEnv+"=七天\n"), 0o600); err != nil {
t.Fatalf("写临时 .env: %v", err)
}
var buf bytes.Buffer
log := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
store := flowGraphTestApp(t, log).newFlowGraphStore(path)
if store == nil {
t.Fatal("单个键非法不该让存储整个消失")
}
if store.Retention() != flowgraph.DefaultRetention {
t.Fatalf("非法取值时的保留期 = %v, want %v", store.Retention(), flowgraph.DefaultRetention)
}
logged := buf.String()
if !strings.Contains(logged, "level=WARN") || !strings.Contains(logged, flowGraphRetentionEnv) {
t.Fatalf("非法取值没有留下带键名的 WARN:%q", logged)
}
}
// TestFlowGraphStore_未接线时交出真nil接口_边界:未接数据库时两个出口都必须返回**真
// nil 接口** —— 返回「装着 nil 指针的接口」会让发布器/网关的 nil 判据失效。
func TestFlowGraphStore_未接线时交出真nil接口_边界(t *testing.T) {
blank := &app{log: testLog()}
if store := blank.newFlowGraphStore(""); store != nil {
t.Fatalf("库未接时不该构造存储:%+v", store)
}
if rec := blank.flowRoundRecorder(); rec != nil {
t.Fatalf("未接线时落盘出口必须是真 nil 接口,拿到的是 %#v", rec)
}
if got := blank.flowGraphStore(); got != nil {
t.Fatalf("未接线时网关只读出口必须是真 nil 接口,拿到的是 %#v", got)
}
}
// TestFlowGraphKeeper_GC关闭时不建零间隔ticker_边界:`time.NewTicker(0)` 会 panic,所以
// 「配了 ≤0 = 关闭清扫」必须在建 ticker 之前分岔,且 loop 立即返回(不留空转 goroutine)。
func TestFlowGraphKeeper_GC关闭时不建零间隔ticker_边界(t *testing.T) {
unsetFlowGraphEnv(t)
t.Setenv(flowGraphGCIntervalEnv, "0")
store := flowGraphTestApp(t, nil).newFlowGraphStore("")
if store == nil || store.Enabled() {
t.Fatalf("GC 应关闭:store=%v enabled=%v", store, store != nil && store.Enabled())
}
keeper := &flowGraphKeeper{store: store, log: testLog(), done: make(chan struct{})}
ticks, stop := keeper.gcTicker()
if ticks != nil {
t.Fatal("GC 关闭时不该建 ticker(零间隔 ticker 会 panic)")
}
stop()
// loop 在 GC 关闭时必须立刻返回并关闭 done(否则常驻 goroutine 泄漏)。
keeper.loop(context.Background())
select {
case <-keeper.done:
default:
t.Fatal("GC 关闭时 loop 应立即返回并关闭 done")
}
}
// TestFlowGraphKeeper_开启时给出定时通道_正常:默认配置下 GC 开启,必须给出非 nil 通道。
func TestFlowGraphKeeper_开启时给出定时通道_正常(t *testing.T) {
unsetFlowGraphEnv(t)
store := flowGraphTestApp(t, nil).newFlowGraphStore("")
if store == nil || !store.Enabled() {
t.Fatal("默认配置下 GC 应开启")
}
ticks, stop := (&flowGraphKeeper{store: store, log: testLog()}).gcTicker()
if ticks == nil {
t.Fatal("GC 开启时必须给出定时通道")
}
stop()
}
// TestFlowGraphEnv_模板取值是合法duration_正常 是 E6 自查的机械化版本:两个键名必须能
// 在 `.env.example` 里逐字找到,且模板值是合法 duration(占位符形态,从不放凭据)。
func TestFlowGraphEnv_模板取值是合法duration_正常(t *testing.T) {
t.Parallel()
raw, err := os.ReadFile(filepath.Join("..", "..", ".env.example"))
if err != nil {
t.Fatalf("read .env.example: %v", err)
}
lines := strings.Split(string(raw), "\n")
for _, key := range []string{flowGraphRetentionEnv, flowGraphGCIntervalEnv} {
found := false
for _, line := range lines {
value, ok := strings.CutPrefix(line, key+"=")
if !ok {
continue
}
found = true
if _, err := time.ParseDuration(value); err != nil {
t.Fatalf("%s 的模板值 %q 不是合法 duration", key, value)
}
}
if !found {
t.Fatalf(".env.example 缺少 %s(AGENTS §10:键名必须与代码逐字一致)", key)
}
}
}
+291
View File
@@ -0,0 +1,291 @@
package main
import (
"context"
"fmt"
"log/slog"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/knowledge"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/vector"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
)
// capacityEmitter 是面向网关的 capacity_awaken 广播出口。
// *gateway.Server 实现它;测试用假实现替换以捕获入参。
type capacityEmitter interface {
BroadcastCapacityAwaken(sessionID, fromState, toState, tool string)
}
// wireFrontend 用内嵌/外部 SPA 文件系统与 JWT/RBAC 认证器构建网关。
// Session/Persona/Memories 接到具体的管理器上,管理端/用户端的列表接口才会
// 返回真实数据。
func (a *app) wireFrontend() error {
jwt := auth.New(a.cfg.Auth)
apiKeys := a.wireAPIKeys()
// 插件管理面板声明中枢(design/09 §5.2.1;切片 PLG-2)必须挂在**同一个**插件管理器上
// (两处各存一份生命周期事实必然分叉),且在 gateway.New 之前接好:网关的两条面板端点
// 都经 s.plugins.Panels() 取它。见 wirePanelHub。
wirePanelHub(a)
ragStore := knowledge.New(knowledge.Deps{
Projects: a.projStore,
Retriever: a.retriever,
DefaultPersona: a.defaultPersonaID(),
VectorOn: a.vindex != nil,
Log: a.log,
})
auditStore, aerr := gateway.NewSQLiteAuditStore(context.Background(), a.db, a.log)
if aerr != nil {
a.log.Warn("audit store unavailable, falling back to in-memory", "error", aerr)
auditStore = nil
}
// 不透明刷新令牌(B2)与主 SQLite 句柄共用同一连接。这里失败要优雅降级:
// 登录只是不下发 refreshToken,/auth/refresh 报「未配置」,而不是让整个
// 启动失败。store 以接口形式持有,构造失败就保持 nil(若存成带类型的 nil
// 指针,接口值非 nil,首次调用会 panic)。
var refreshStore gateway.RefreshGrantStore
if store, rerr := auth.NewRefreshStore(context.Background(), a.db, a.log); rerr != nil {
a.log.Warn("refresh token store unavailable", "error", rerr)
} else {
refreshStore = store
}
// a.history 在 wireAuxiliary 里构建,让渠道桥(wireChannels)与网关共用同一个
// 持久化存储。IM 通路之前 History 是个空转依赖,因此把它的创建顺序提前不会
// 影响任何下游。
//
// 记忆整理「试算」出口(契约 v0.75;切片 MT1):注入的正是低峰心跳跑的那个
// *memory.TidyService(同一套规则、同一份配置)。**必须**在它为 nil 时留成
// **真 nil 接口** —— 带类型的 nil 指针会让网关的 `== nil` 判断失效,端点就会
// 去调一个空接收者。
var tidyDryRunner gateway.MemoryTidyDryRunner
if a.memoryTidy != nil {
tidyDryRunner = a.memoryTidy
}
gw, err := gateway.New(a.cfg, gateway.Deps{
Log: logger.WithSource(a.log, "gateway"),
Auth: jwt,
Bus: a.bus,
Registry: a.caps,
Loop: a.loop,
Session: a.sessions,
Personas: a.personas,
Memories: a.memStore,
History: a.history,
IoT: a.iot,
Scheduler: a.scheduler,
Tasks: a.tasks,
Automations: a.automation,
Users: apiKeys,
Refresh: refreshStore,
Backthink: a.backthink,
RAG: ragStore,
LLM: a.llm,
// EnvPath 与 main.go 启动时 config.Load(envPath) 用同一条路径:面板
// 「重置为 env 默认」(切片 R1)按既有解析入口重读同一份 `.env` 声明,
// 网关自己不 fopen `.env`。
EnvPath: envPath,
Channels: a.channels,
Plugins: a.plugins,
OwnerRegistrar: &ownerRegistrar{path: defaultOwnerPath, owner: a.owner, log: a.log},
AuditStore: auditStore,
Streaming: true,
Frontend: resolveFrontendFS(a.log),
Webhooks: a.channelBridge.WebhookHandlers(),
DatabasePath: a.dbPath,
DataDir: resolveDataDir(a.cfg),
Database: a.db,
Vector: a.vectorState(),
// Flow 是「最近一轮链路图」的只读出口(契约 v0.71):注入的正是上面
// loop/orchestration 用的**同一个** *loop.FlowPublisher,因此
// GET /api/v1/admin/flow/graph 读到的就是实时发布的那张图。
Flow: a.flow,
// FlowGraphs 是**已落盘轮次图**的只读出口(契约 v0.73;切片 F2):注入的正是
// 发布器每轮落盘用的**同一个**存储句柄,因此 `?turnId=` 与 `turns[]` 读到的就是
// 收尾时写进去的历史轮。未接线时为真 nil(端点回退到只有内存的行为)。
FlowGraphs: a.flowGraphStore(),
// Usage 是 `llm_usage` 表的只读汇总出口(切片 U1):网关复用组合根已打开的
// 连接池,自己不开池、不读环境变量。库未接时为 nil(端点回 500
// usage_unavailable,不回假汇总)。
Usage: model.NewUsageReader(a.db),
// EventStore 是渠道事件留存的只读查询出口(design/26 §4.5;切片 E6):
// 复用组合根已按 CHANNEL_EVENT_* 建好的那份 store。未接线时为**真 nil**
// (`channelEventStore()` 的取舍),端点据此回 500 channel_events_unavailable,
// 绝不回空 200 假装「没有事件」。
EventStore: a.channelEventStore(),
// MemoryTidy 是记忆整理「试算」的只读出口(契约 v0.75;切片 MT1):上面按
// **真 nil** 取舍过的接口值。
MemoryTidy: tidyDryRunner,
})
if err != nil {
return fmt.Errorf("gateway: %w", err)
}
gw.SetVoice(a.voice)
a.gw = gw // 必须在下面接线 hook 之前赋值——那些 hook 捕获了 a.gw / gw(否则会 nil 接收者 panic)
// 可观测性 trace 出口(design/19 §2):网关已存在,其环形缓冲必须由循环产出的
// span 喂养,否则管理端 trace/metrics 接口会一直是空的。
wireGatewayTraceSink(a, gw)
if a.channelBridge != nil {
// 把渠道桥中「主人私聊」持久化的消息接入编排器所用的同一个
// `message_persisted` 广播,让 Web 客户端用同一套统一帧来镜像 IM 消息
// (design/16 §4.4)。
a.channelBridge.SetOnPersisted(a.gw.BroadcastPersisted)
// IM 路由的**主动推送**另走一条通知通道(切片 P0b):它落的也是主会话,但帧顶
// 带结构化判据 `proactive:true`,客户端据此在非前台补一发浏览器通知。此前 IM
// 路由只有上面那条普通帧 ⇒ 走 IM 的主动消息一条都不弹(而 `auto` 提示在主
// 人有已连接 IM 渠道时优先走 IM,恰恰是最常见的路径)。
a.channelBridge.SetOnProactivePersisted(a.gw.BroadcastProactivePersisted)
// design/16 §4.6 §6:被平台白/黑名单(allow/deny)丢弃的消息会记入网关审计
// 轨迹,这样即使它从未到达循环,这次拒绝也「audit 可查」。
a.channelBridge.SetOnRejected(func(msg adapter.UnifiedMessage, reason string) {
a.gw.AuditEvent("bridge:"+msg.Platform, "channel.reject", reason)
})
}
a.capacitySink = gw
a.moodSink = gw
// 网关 hub(活跃客户端通路)与渠道桥(IM 回落)都已在位,现在接线 backthink 的
// 主动推送出口。backthink.New 执行时它们都还不存在(hub 需要循环,桥需要循环与
// 主人档案,这些都更晚才组装),若不在此处补接线,推送器会解析出 RouteNone。
// 每个 setter 都在服务的生命周期锁下重建推送器。
if a.backthink != nil {
a.backthink.SetActiveSink(gw)
if a.channelBridge != nil {
a.backthink.SetIMSink(a.channelBridge)
}
// design/12 §N C.5:自排的四个动作(queued/coalesced/capped/denied)走与渠道
// 黑名单拒绝同一条审计链(谁/什么/何时/结果),因此它们**面板可查**,不需要
// 新端点(GET /api/v1/admin/observability/audit 已存在)。同样是晚绑定:
// backthink.New 时网关还没组装。
a.backthink.SetAuditSink(gw)
}
return nil
}
// wirePanelHub 给已构造的插件管理器接上面板声明中枢,并把它接到既有进程内 bus 的
// `ws.panels_changed` 上(design/09 §5.2.1;切片 PLG-2)。之后再把内置插件的**动作
// 处理器**登记到管理器上(切片 L1/PLG-2 的执行面接线):没有这一步,面板的每个动作都
// 只能诚实回 501(声明在、没人执行)。
//
// 三条约束都在这里落地:
// - **不新开端口**:热更新信号只走进程内 bus(hub 订阅 ws.> ⇒ 帧类型 panels_changed),
// 与既有实时帧同一根管子;
// - **帧不带内容**:载荷只有 reason / pluginId,前端按自己的角色重拉一次
// GET /api/v1/admin/panels,避免在帧里再实现一遍角色过滤(也就不会漏过门);
// - **接线失败不阻断启动**:中枢构造失败只告警(网关随后对两条面板端点诚实回 501),
// 与 refresh store / key store 的降级口径一致。
func wirePanelHub(a *app) {
if a.plugins == nil {
return
}
log := logger.WithSource(a.log, "capability/plugin/panels")
hub, err := pluginmgr.NewPanels(log)
if err != nil {
a.log.Warn("panel hub unavailable, plugin panel endpoints will answer 501", "error", err)
return
}
hub.SetPublisher(func(ch pluginmgr.PanelChange) {
if a.bus == nil {
return
}
if err := a.bus.Publish(context.Background(), "ws.panels_changed", map[string]any{
"reason": ch.Reason,
"pluginId": ch.PluginID,
}); err != nil {
a.log.Warn("panels_changed publish failed", "reason", ch.Reason, "pluginId", ch.PluginID, "error", err)
}
})
a.plugins.AttachPanels(hub)
declareBuiltinPanels(hub, log)
// browser 插件(design/28 §8 B4)的面板声明 + 六个动作:它自己就把「先声明、后登记
// 动作」这一步走完(wire_browser_panel.go 一处),因此与 search 的纯声明并列。
//
// 为什么要挡一下 `cfg == nil`:浏览器面板的配置路径与表单默认值**全部**由
// `$DATA_DIR` 推出(wire_browser_panel.go 里 `resolveDataDir(a.cfg)`),而部分只读
// 夹具(wire_panels_test.go / wire_plugin_actions_test.go 只填了 plugins/bus/log)
// 没有配置 ⇒ 直调会解空指针。生产路径上 wireFrontend 跑在 config.Load 之后,
// `a.cfg` 恒非 nil,因此这一支只为「不完整夹具」存在(同 wireBrowser 的取舍)。
if a.cfg != nil {
wireBrowserPanel(hub, a, a.log)
}
// 登记动作处理器必须在**声明之后**:登记期会核对「登记的处理器有没有对应声明」,
// 顺序反过来只会得到一串「声明还没到」的 WARN。
// 日志来源另标一处(动作接线 ≠ 面板中枢):`source=cmd/cyrene/wire_plugin_actions`
// 让「谁登记了哪些动作 / 哪些声明没人实现」能在日志里独立定位。
wireBuiltinPluginActions(a.plugins, hub, a, logger.WithSource(a.log, actionWireLogSource))
}
// declareBuiltinPanels 把**编译进二进制的内置插件**的面板声明交给中枢
// (design/09 §5.5 的 dogfood:「网络搜索 → 搜索提供商」页)。
//
// 为什么在组合根做这一步:内置插件的声明与它自己的能力同源(`plugins/tools/io/search`
// 里就摆着 `AdminPanels()`),没有卸载/安装路径 ⇒ 只有「装配期登记一次」这一种生命周期。
// 热装 MCP 插件的声明走另一条路(manifest 读取 + 生命周期重算,后续切片)。
//
// 声明被拒只告警、不阻断启动:一个坏声明不该让整个后端起不来(§5.2.1 的口径是
// 「该面板不注册 + 显式报错」,不是「宿主陪葬」)。
func declareBuiltinPanels(hub *pluginmgr.Panels, log *slog.Logger) {
declarations := []struct {
pluginID string
panels []sdk.Panel
}{
{search.PanelPluginID, search.AdminPanels()},
}
for _, d := range declarations {
accepted, err := hub.Declare(d.pluginID, d.panels)
if err != nil {
log.Warn("builtin panel declaration rejected",
"pluginId", d.pluginID, "accepted", accepted, "error", err)
}
}
}
// wireAPIKeys 构造 API key 管理器,并把落盘载体接到它上面(K1:修「每次重启 key
// 全失效」——此前 key 表只在内存里,主人刚签的 key 被一次重启全部打掉,签发端点
// 长凭据的语义与「重启即作废」自相矛盾)。
//
// 降级口径与上面的 refresh store 一致:构造或载入失败**不阻断启动**,key 仍可签发
// 与校验,只是不跨重启存活;后果写进日志,不静默。注意 AttachKeyStore 在 Load 失败
// 时仍会把 store 接上(见其注释)——那样之后的签发会响亮地失败,而不是又一次
// 「面板报成功、重启全 401」。
func (a *app) wireAPIKeys() *auth.APIKey {
keys := auth.NewAPIKey(a.cfg.Auth)
if a.db == nil {
return keys
}
store, err := auth.NewSQLiteKeyStore(a.db, a.log)
if err != nil {
a.log.Warn("api key store unavailable, issued keys will not survive restart", "error", err)
return keys
}
if err := keys.AttachKeyStore(context.Background(), store); err != nil {
a.log.Warn("api key store load failed, keys issued earlier may be rejected", "error", err)
}
return keys
}
// vectorState 把「PG 增强到底有没有生效」的**运行时真相**报给网关,供管理侧
// 「存储状态」只读端点作答(design/03 §3;面板首页的存储卡)。
//
// 真源就在本组合根,且只有两项事实,因此这里**复用**运行时状态而不新造探测:
//
// - Configured = `DB_PG_URL` 是否配置(非空即算)—— 开关在不在;
// - Live = 活着的向量索引确实落在 pgvector 后端上 —— 真的在不在跑。
//
// 两者会不一致:配置了 PG 但连接/schema 准备失败时,wireMemory 把 vindex 留成
// nil(语义检索降级),此时 Configured=true、Live=false,面板据此显示「已配置但
// 未就绪」,而不是把「配了」当成「生效了」。这里绝不新开 PG 连接去探测:那既多
// 一次网络往返,也可能与运行中的索引给出不同答案。
func (a *app) vectorState() gateway.VectorState {
_, live := a.vindex.(*vector.PGVectorIndex)
return gateway.VectorState{
Configured: strings.TrimSpace(a.cfg.DB.PGURL) != "",
Live: live,
}
}
+71
View File
@@ -0,0 +1,71 @@
package main
import (
"context"
"database/sql"
"path/filepath"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
)
// 本文件锁定组合根接线(K1):wireFrontend 调用的 wireAPIKeys 必须真的把 SQLite
// 载体接到 key 管理器,否则签发的 key 又回到「重启即失效」。
//
// 为什么在组合根测这件事:newApp 无法在单元测试里实例化(要开库、迁移、拉起全部
// 子系统,见 wire_context_test.go 的说明),所以这里直接对 wireAPIKeys 这一步
// 接线取证 —— 它与 newApp 里调用的是同一个函数。
// openMigratedTestDB 打开一个临时文件库并跑完全部迁移。用文件而非 :memory:,
// 因为「重启仍在」必须跨连接读同一份数据。
func openMigratedTestDB(t *testing.T, path string) *sql.DB {
t.Helper()
ctx := context.Background()
conn, err := db.Open(ctx, path, testLog())
if err != nil {
t.Fatalf("db.Open(%s): %v", path, err)
}
if err := db.MigrateUp(ctx, conn, testLog()); err != nil {
_ = conn.Close()
t.Fatalf("db.MigrateUp: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
return conn
}
// TestWireAPIKeys_KeySurvivesRestart 断言组合根接的线真的落盘:签发 → 关连接
// (进程退出)→ 用同一个库重新接线 → 同一把 key 仍能认证。
func TestWireAPIKeys_KeySurvivesRestart(t *testing.T) {
path := filepath.Join(t.TempDir(), "cyrene.db")
first := &app{db: openMigratedTestDB(t, path), cfg: &config.Config{}, log: testLog()}
keys := first.wireAPIKeys()
key, err := keys.Generate()
if err != nil {
t.Fatalf("Generate: %v", err)
}
if err := first.db.Close(); err != nil {
t.Fatalf("close first connection: %v", err)
}
second := &app{db: openMigratedTestDB(t, path), cfg: &config.Config{}, log: testLog()}
restarted := second.wireAPIKeys()
if !restarted.Verify(key) {
t.Fatal("重启后 key 无法认证:组合根没有把落盘载体接上")
}
}
// TestWireAPIKeys_NilDBFallsBackToMemory 断言没有数据库句柄时(纯内存测试/
// 构建路径)接线是空操作且不 panic:key 照旧可签发可校验,只是不跨重启。
func TestWireAPIKeys_NilDBFallsBackToMemory(t *testing.T) {
a := &app{db: nil, cfg: &config.Config{}, log: testLog()}
keys := a.wireAPIKeys() // 必须不 panic
key, err := keys.Generate()
if err != nil {
t.Fatalf("Generate: %v", err)
}
if !keys.Verify(key) {
t.Fatal("未接库路径的 key 无法认证")
}
}
+142
View File
@@ -0,0 +1,142 @@
package main
import (
"log/slog"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/guardrail"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
)
// 本文件是组合根的默认护栏策略(design/04 §5)。三条默认策略与它们的依据:
//
// 1. 敏感信息 = **阻断**档。API key / bearer token / 密码 / 私钥这类东西
// 一旦进了请求或回复就是既成事实的泄露,标记档(放行 + 记一笔)救不回来,
// 因此默认按阻断处理。
// 2. 长度上限 = 只拦病态载荷,不误伤正常对话(两个上限的取值依据见下方常量)。
// 3. 关键词表 = **只在配置提供时**挂。项目不内置任何词表:凭空猜的词只会
// 误伤正常对话,且没有部署方能为它的命中负责(见 config.GuardrailConfig)。
const (
// guardrailMinInputChars 是请求侧字符上限的**下限**(256 Ki 字符)。这个检查
// 跑在**组装完成的请求**上(系统提示词 + 记忆块 + 整段对话 + 工具结果),
// 不是单条用户消息,因此上限按「整个上下文」定。
//
// 为什么是「下限」而不是「定值」(切片 G2 修的真 bug):单份附件的注入预算随
// 推导出的上下文预算走,1M 窗口下已是 349525 字符(T1)。上限若还是硬编码的
// 262144,任何正文超过它的附件都会让**整个**回合以 guardrail_input 结束
// (端到端实测:`POST /chat` 带一个 744 KB 的 txt ⇒
// `{"reply":"input_length_exceeded","stopReason":"guardrail_input"}`),
// 也就是「护栏成了上下文预算的瓶颈」,把「分享大文件」这条主线直接打死。
// 因此生效值由 guardrailInputLimit 派生:max(本下限, 推导预算 token 数 × CharsPerToken)。
// 预算关闭(0)或预算小到乘完仍低于本下限时,上限就是本下限。
guardrailMinInputChars = 262144
// guardrailMaxOutputChars 是响应侧的字符上限。一条回复正常在几十到几百字,
// 长代码/长文也就几千字,65536(64 Ki)字符比正常回复高 2~3 个数量级,
// 因此不会截断任何正常输出,只拦失控的生成(例如无限复读)。这一点在主
// 流式路径上尤其重要:那里输出侧检查是异步旁路(只会审计,不挡已产出的
// 回复),但在静音/非流式路径(backthink、子代理)上否判决会替换答案,
// 所以上限必须留足余量。
//
// **它不随上下文预算派生**:输出失控与「窗口有多大」无关,本切片的输入侧
// 解耦不该顺手把它也放宽。
guardrailMaxOutputChars = 65536
)
// maxChatDocumentsPerTurn 是**单回合**附件数上限(design/24 §6.1、§9.4-3 的定稿值 5)。
// 它是关系不变量「N 份」的那一端:输入上限必须容得下「一整个回合的附件都撞满单份
// 注入预算」的装配结果,否则满载附件的回合会在装配完之后被整回合拦死(切片 G2 只
// 对了 **1 份**,本切片补 **N 份**)。
//
// 出处:真源是 documents.MaxPerMessage(网关侧以
// `maxChatDocuments = documents.MaxPerMessage` 引用它)。本切片**不跨包引用**它 ——
// documents 正被并行切片占用,且这里只需要一个数、不需要它的任何行为;因此取同值,
// 并用测试里的来源说明 + 不变量断言钉住。待并行切片落定后统一成一个共享常量。
const maxChatDocumentsPerTurn = 5
// buildGuardrailPipeline 组装循环与各 agent 共用的护栏管线(design/04 §5)。
// 它是组合根里唯一一处决定「护栏里到底有什么」的地方,因此测试可以直接
// 断言这个函数的产物,而不必启动整个运行时。
//
// 取 budget 参数而不是自己去推导:输入上限是**上下文预算的函数**(关系不变量
// 「单回合全部附件的注入预算合计 ≤ 输入上限」的两端都在这份预算里),而预算的
// 推导只该在 wire_context.go 发生一次 —— 各自推导会把「窗口未声明」那条 WARN
// 记两遍。调用顺序因此固定为「先算预算,再建管线」(wire_runtime.go)。
//
// 选项顺序即检查顺序(guardrail.New 按序应用),且先跑开销低、语义硬的
// 敏感信息检查:命中即返回该判决,长度与关键词检查不会再跑。
func (a *app) buildGuardrailPipeline(budget ctxBudget) *guardrail.Pipeline {
opts := []guardrail.Option{
// ① 敏感信息外泄必须拦:阻断档。
guardrail.WithSensitiveInfo(guardrail.SensitiveBlock),
// ② 长度上限:0 会关掉那一侧,因此两侧都给上保守上限。
guardrail.WithLengthLimits(a.guardrailInputLimit(budget), guardrailMaxOutputChars),
}
// ③ 关键词表仅在配置提供时挂:没有配置就不挂(KeywordGuard 空表本也
// 全放行,这里显式分支是为了让「没配置」在管线上就是没有这个检查)。
if keywords := a.guardrailKeywords(); len(keywords) > 0 {
opts = append(opts, guardrail.WithKeywords(keywords...))
}
return guardrail.New(opts...)
}
// guardrailInputLimit 返回本次运行生效的请求侧字符上限:
// 配置显式覆盖优先,否则由推导出的上下文预算派生(见 deriveGuardrailInputLimit)。
func (a *app) guardrailInputLimit(budget ctxBudget) int {
override := 0
if a.cfg != nil {
override = a.cfg.Guardrail.MaxInputChars
}
return deriveGuardrailInputLimit(
sourcedWiringLog(a.log), override,
budget.Budget.Tokens, budget.DocumentInjectChars, maxChatDocumentsPerTurn,
)
}
// deriveGuardrailInputLimit 是 guardrailInputLimit 的可测内核:取值来源收成显式
// 参数,两条 WARN(非法覆盖 / 关系不变量被破)都在这里,测试可按条数断言。
//
// 派生公式:输入上限 = max(guardrailMinInputChars, 推导预算 token 数 × CharsPerToken)。
// 换算必须与**附件注入预算**同源(G2 的残留缺口正在这里):G2 把预算 token 数按
// 1 字符 ≈ 1 token 兑成字符,而注入预算按 4 字符 ≈ 1 token 兑(loop.CharsPerToken),
// 两边两套换算 ⇒ 上限 699050 字符只够 **2 份**各自撞满注入预算的附件
// (2 × 349525 = 699050),5 份必被拦。改用注入的同一常量后,1M 窗口的上限是
// 2796200 字符,5 份满额注入 1747625 字符仍有明确余量;上限仍然只拦「几百 KB
// 整块文本直接灌进来」这种病态载荷(真正的上下文控制由压缩预算负责)。
func deriveGuardrailInputLimit(log *slog.Logger, override, budgetTokens, docInjectChars, maxDocs int) int {
derived := guardrailMinInputChars
if scaled := budgetTokens * loop.CharsPerToken; scaled > derived {
derived = scaled
}
limit := derived
switch {
case override < 0:
// 非法覆盖:记一条带原始取值的 WARN 并回落派生值(AGENTS §10:不许静默)。
log.Warn("guardrail input limit invalid, falling back to derived",
"env", config.GuardrailMaxInputCharsEnv, "value", override, "fallback", derived)
case override > 0:
limit = override
}
if injected := docInjectChars * maxDocs; injected > limit {
// 关系不变量被破(**多附件**口径):一个回合最多 maxDocs 份附件,每份可注入
// docInjectChars 字符,装配后的请求至少要容得下「全部附件都撞满注入预算」。
// 破了就说明满载附件的回合会在装配完之后被整回合拦下,现象是用户看到一个
// 没有上下文的 input_length_exceeded。这里明说一句,不静默。
log.Warn("document inject budget x max attachments exceeds guardrail input limit; multi-attachment turns may be blocked",
"env", config.GuardrailMaxInputCharsEnv,
"guardrail_max_input_chars", limit,
"max_documents", maxDocs,
"document_inject_chars", docInjectChars,
"document_inject_total_chars", injected,
"hint", "调大本键或调小 DOCUMENT_MAX_INJECT_CHARS,使「单份注入 × 单回合附件数」不超过输入上限")
}
return limit
}
// guardrailKeywords 返回部署方声明的内容策略词表(GUARDRAIL_KEYWORDS);
// 未配置时返回 nil,管线里就不含关键词检查。
func (a *app) guardrailKeywords() []string {
if a.cfg == nil {
return nil
}
return a.cfg.Guardrail.Keywords
}
+355
View File
@@ -0,0 +1,355 @@
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件锁定切片 G2 / G2b 的输入侧解耦(端到端实测抓到的真 bug:大附件被输入护栏拦死):
//
// - 输入上限 = max(256 Ki, 推导预算 token 数 × CharsPerToken)(与附件注入预算
// **同一套换算**;G2 按 1 字符 ≈ 1 token 兑,于是 2 份满额注入的附件就顶到上限,
// G2b 统一到注入侧那个常量);
// - GUARDRAIL_MAX_INPUT_CHARS 的三态(未设/0 = 派生、>0 = 覆盖、<0 = WARN 回落);
// - 关系不变量「单回合全部附件的注入预算合计 ≤ 输入护栏上限」,人为破坏它必须**有 WARN**;
// - 回归:注入正文比旧上限(262144 字符)还大时,回合**不得**以 guardrail_input 结束;
// - 反证:显式把上限收紧后,同一个大附件回合**真的**被拦(护栏没被削弱)。
//
// 多附件的回归与「N 份」不变量的用例在 wire_guardrail_multidoc_test.go。
//
// 输出上限(guardrailMaxOutputChars)不属本切片:它防的是模型输出失控,与上下文
// 预算无关,因此保持 65536 不动(回归见 TestGuardrailPipeline_LengthCapsOnlyCatchPathologicalPayloads)。
// largeDocChars 是回归夹具的附件正文长度:既大于旧上限 262144(否则复现不到 bug),
// 又大于 1M 窗口下的注入预算 349525(因此走「已截断 + 续读游标」那条路径,
// 与主人实测时历史 DTO 里 `"chars":349525,"truncated":true` 的形状一致)。
const largeDocChars = 400000
// TestGuardrailInputLimit_DerivedFromBudget 钉住派生公式(表驱动):
// 输入上限 = max(下限, 推导预算 token 数 × CharsPerToken) —— 小预算落回下限,
// 大窗口随预算抬高(换算与附件注入预算同一个常量,否则多附件回合会被拦死)。
func TestGuardrailInputLimit_DerivedFromBudget(t *testing.T) {
t.Parallel()
cases := []struct {
name string
tokens int
want int
}{
{"1M 窗口推导出的预算(699050 token)⇒ 按 CharsPerToken 上抬", 699050, 2796200},
{"预算正好等于下限 ÷ CharsPerToken ⇒ 下限", guardrailMinInputChars / loop.CharsPerToken, guardrailMinInputChars},
{"小预算(600 token)⇒ 回落下限", 600, guardrailMinInputChars},
{"关闭压缩(0)⇒ 下限", 0, guardrailMinInputChars},
{"负预算 ⇒ 下限", -1, guardrailMinInputChars},
}
for _, c := range cases {
c := c
t.Run(c.name, func(t *testing.T) {
t.Parallel()
log, _ := capturingLog()
if got := deriveGuardrailInputLimit(log, 0, c.tokens, 0, maxChatDocumentsPerTurn); got != c.want {
t.Fatalf("deriveGuardrailInputLimit(budget=%d) = %d, want %d", c.tokens, got, c.want)
}
})
}
}
// TestGuardrailInputLimit_OverrideAndIllegalValue 钉住 env 三态:>0 覆盖、<0 记
// **恰好一条** WARN 后回落派生值(带原始取值,绝不静默)。
func TestGuardrailInputLimit_OverrideAndIllegalValue(t *testing.T) {
t.Parallel()
log, buf := capturingLog()
if got := deriveGuardrailInputLimit(log, 1000, 699050, 0, maxChatDocumentsPerTurn); got != 1000 {
t.Fatalf("显式覆盖 = %d, want 1000(覆盖优先于派生)", got)
}
if n := countRecords(logRecords(t, buf), "WARN", "guardrail input limit invalid"); n != 0 {
t.Fatalf("合法覆盖却记了 %d 条 WARN", n)
}
log, buf = capturingLog()
if got := deriveGuardrailInputLimit(log, -1, 699050, 0, maxChatDocumentsPerTurn); got != 2796200 {
t.Fatalf("非法覆盖后的回落 = %d, want 派生值 2796200", got)
}
records := logRecords(t, buf)
if n := countRecords(records, "WARN", "guardrail input limit invalid"); n != 1 {
t.Fatalf("非法覆盖的 WARN 条数 = %d, want 恰好 1(记录:%v)", n, records)
}
// WARN 必须点出「改哪个键」与原始取值,否则部署方不知道该去改哪儿。
found := false
for _, rec := range records {
if msg, _ := rec["msg"].(string); strings.Contains(msg, "guardrail input limit invalid") {
found = true
if rec["env"] != config.GuardrailMaxInputCharsEnv {
t.Fatalf("WARN 没点出配置键: %v", rec)
}
if rec["value"] != float64(-1) || rec["fallback"] != float64(2796200) {
t.Fatalf("WARN 没带原始取值与回落值: %v", rec)
}
}
}
if !found {
t.Fatal("没有找到非法覆盖的 WARN")
}
}
// TestGuardrailInputLimit_RelationInvariantWithInjectBudget 钉住关系不变量
// 「单回合全部附件的注入预算合计 ≤ 输入护栏上限」:对 1M 窗口的推导值成立
// (maxDocuments 份 × 单份注入),且人为把上限压到之下 ⇒ **记一条 WARN**
// (消息与字段都要让人一眼看出是**多附件**问题),不静默。
func TestGuardrailInputLimit_RelationInvariantWithInjectBudget(t *testing.T) {
t.Parallel()
// 1M 窗口的推导:预算 699050 token ⇒ 单份附件注入预算 349525 字符。
a := &app{log: testLog(), cfg: &config.Config{}, llm: envWindowStack(t, 1048576, 0)}
budget := a.contextBudget(filepath.Join(t.TempDir(), "absent.env"))
if budget.DocumentInjectChars != 349525 {
t.Fatalf("1M 窗口下的注入预算 = %d, want 349525(推导链变了,本用例的前提需要复核)",
budget.DocumentInjectChars)
}
log, buf := capturingLog()
limit := deriveGuardrailInputLimit(log, 0, budget.Budget.Tokens, budget.DocumentInjectChars, maxChatDocumentsPerTurn)
if total := budget.DocumentInjectChars * maxChatDocumentsPerTurn; total > limit {
t.Fatalf("关系不变量被破:%d 份注入合计 %d > 输入上限 %d",
maxChatDocumentsPerTurn, total, limit)
}
if limit != 2796200 {
t.Fatalf("1M 窗口下的输入上限 = %d, want 2796200(= 推导预算 token 数 × %d)",
limit, loop.CharsPerToken)
}
if n := countRecords(logRecords(t, buf), "WARN", "exceeds guardrail input limit"); n != 0 {
t.Fatalf("不变量成立却记了 %d 条 WARN", n)
}
// 人为破坏:把上限压到「全部附件注入合计」之下 ⇒ 必须有一条 WARN 明说后果,
// 且带上 N 份 / 单份 / 合计三项取值(否则没人看得出这是多附件问题)。
log, buf = capturingLog()
if got := deriveGuardrailInputLimit(log, 4096, budget.Budget.Tokens, budget.DocumentInjectChars, maxChatDocumentsPerTurn); got != 4096 {
t.Fatalf("收紧后的上限 = %d, want 4096", got)
}
records := logRecords(t, buf)
if n := countRecords(records, "WARN", "exceeds guardrail input limit"); n != 1 {
t.Fatalf("不变量被破的 WARN 条数 = %d, want 恰好 1(记录:%v)", n, records)
}
found := false
for _, rec := range records {
if msg, _ := rec["msg"].(string); strings.Contains(msg, "exceeds guardrail input limit") {
found = true
if !strings.Contains(msg, "multi-attachment") {
t.Fatalf("WARN 文案看不出是多附件问题: %q", msg)
}
if rec["document_inject_chars"] != float64(budget.DocumentInjectChars) ||
rec["guardrail_max_input_chars"] != float64(4096) ||
rec["max_documents"] != float64(maxChatDocumentsPerTurn) ||
rec["document_inject_total_chars"] != float64(budget.DocumentInjectChars*maxChatDocumentsPerTurn) {
t.Fatalf("WARN 没写清 N 份与两端取值: %v", rec)
}
if hint, _ := rec["hint"].(string); hint == "" {
t.Fatalf("WARN 没给出处置提示: %v", rec)
}
}
}
if !found {
t.Fatal("没有找到关系不变量被破的 WARN")
}
}
// largeAttachmentTurn 是「一个回合 + 一份大附件」的观测结果。
type attachmentTurn struct {
// result 是该回合的循环结论(StopReason / Text / Documents 都在这里)。
result *loop.Result
// modelCalls 是模型被调用的次数(= 捕获到的请求数)。
modelCalls int
// assembledChars 是模型实际看到的文本总量(装配后、注入正文已在内)。
assembledChars int
// perDocInject 是本回合如实记录的**每份**附件注入字符数
// (Result.Documents[i].Chars;夹具里各份等长)。
perDocInject int
// documents 是 Result.Documents 的条数(本回合真的注入了几份)。
documents int
// g2Limit 是 G2 时代的上限(推导预算 token 数按 1 字符 ≈ 1 token 兑成字符)。
// 它让回归用例能自证「装配结果确实落在 G2/G2b 的缺口上」:装配总量超过了它。
g2Limit int
// inputLimit 是本回合实际生效的输入上限(a.guardrailInputLimit 的产物),
// 供用例实证「装配总量落在上限之内」。
inputLimit int
}
// runAttachmentTurn 跑一个回合:1M 窗口的推导预算(单份注入 349525 字符)+ docs 份
// largeDocChars 字符的 txt 附件 + 真实的护栏管线。inputLimitOverride 非 0 时
// 模拟 GUARDRAIL_MAX_INPUT_CHARS 的覆盖态。
//
// 三个角色都是生产实现:预算取自 a.contextBudget、注入走真实的
// loop.resolveDocuments + documents.Resolver、护栏是 a.buildGuardrailPipeline 的产物。
// 唯一被单独钉住的是「窗口 = 1M」。
func runAttachmentTurn(t *testing.T, docs, inputLimitOverride int) attachmentTurn {
t.Helper()
if docs < 1 {
t.Fatalf("docs = %d, want ≥ 1(夹具至少要一份附件)", docs)
}
dataDir := t.TempDir()
uploads := filepath.Join(dataDir, "uploads", "documents")
if err := os.MkdirAll(uploads, 0o700); err != nil {
t.Fatalf("建上传目录: %v", err)
}
body := strings.Repeat("abcdefghij", largeDocChars/10)
refs := make([]model.Document, 0, docs)
for i := 0; i < docs; i++ {
name := attachmentName(i)
if err := os.WriteFile(filepath.Join(uploads, name), []byte(body), 0o600); err != nil {
t.Fatalf("写上传件: %v", err)
}
refs = append(refs, model.Document{Name: name, MIME: "text/plain", Size: int64(len(body))})
}
cfg := &config.Config{DataDir: dataDir}
cfg.Guardrail.MaxInputChars = inputLimitOverride
a := &app{log: testLog(), cfg: cfg, llm: envWindowStack(t, 1048576, 0)}
budget := a.contextBudget(filepath.Join(t.TempDir(), "absent.env"))
guards := a.buildGuardrailPipeline(budget)
adapter := &compactProbeAdapter{}
ag, err := agent.New("你是昔涟。", nil, guards, agent.WithModel(adapter, nil))
if err != nil {
t.Fatalf("agent.New: %v", err)
}
l := loop.New(loop.Options{
Agent: ag,
Guards: guards,
DocumentResolver: documents.NewResolver(documents.Config{DataDir: dataDir, UploadsDir: uploads}),
DocumentMaxInjectChars: budget.DocumentInjectChars,
OnStream: func(model.StreamEvent) {},
})
res, err := l.Run(context.Background(), "sess-large-doc",
[]model.Message{documentTurnMessage("看看这些附件", refs)})
if err != nil {
t.Fatalf("Run: %v", err)
}
turn := attachmentTurn{
result: res, modelCalls: len(adapter.reqs),
documents: len(res.Documents), g2Limit: budget.Budget.Tokens,
inputLimit: a.guardrailInputLimit(budget),
}
if len(res.Documents) > 0 {
turn.perDocInject = res.Documents[0].Chars
}
if len(adapter.reqs) > 0 {
req := adapter.reqs[len(adapter.reqs)-1]
for _, m := range req.Messages {
turn.assembledChars += len(loopText(m))
}
}
return turn
}
// attachmentName 造第 i 份附件的服务端存储名(32 位十六进制 + .txt,与
// wire_document_budget_test.go 的 docE2EName 同一形态)。解析器按名取件,
// 因此多份附件必须各有一个名字。
func attachmentName(i int) string {
return fmt.Sprintf("%032x.txt", i+1)
}
// documentTurnMessage 造一条携带 N 份文档引用的用户消息:model 包只提供单份的
// 构造器(DocumentMessage),多份按同一形状续部件 —— 这正是「一个回合最多
// maxChatDocumentsPerTurn 份附件」的落库形状。
func documentTurnMessage(text string, refs []model.Document) model.Message {
msg := model.DocumentMessage(model.RoleUser, text, refs[0])
for _, ref := range refs[1:] {
msg.Content = append(msg.Content, model.Content{
Type: model.ContentTypeDocument,
DocumentName: ref.Name,
DocumentMIME: ref.MIME,
DocumentURL: ref.URL,
DocumentSize: ref.Size,
})
}
return msg
}
// TestGuardrailWiring_LargeAttachmentPassesInputGuard 是**回归**,对准主人端到端
// 实测抓到的那个真 bug:`POST /api/v1/chat` 带一个 744 KB 的 txt 附件 ⇒
// `{"reply":"input_length_exceeded","stopReason":"guardrail_input"}` —— 历史 DTO 里
// 已如实写下 `"chars":349525,"truncated":true`,工具 document-read 也正常,
// 是回合在**输入护栏**这一层就没被放进去,「分享大文件」这条主线因此被打死。
//
// 这里在进程内复现同一形状,断言它不再发生:注入正文比旧上限还大,但回合照常
// 抵达模型(adapter 被调用一次)并以正常回答结束。
func TestGuardrailWiring_LargeAttachmentPassesInputGuard(t *testing.T) {
t.Parallel()
turn := runAttachmentTurn(t, 1, 0)
// ① bug 的症状先说清楚:回合不得以 guardrail_input 结束,也不得被拦在模型之前。
if turn.result.StopReason == agent.StopReasonGuardrailInput {
t.Fatalf("大附件回合仍以 guardrail_input 结束(这正是被修的 bug):%q", turn.result.Text)
}
if turn.modelCalls != 1 {
t.Fatalf("大附件回合的模型调用次数 = %d, want 1(护栏把回合拦在模型之前了)", turn.modelCalls)
}
// ② 前提自证:这一回合真的注入了 349525 字符(= 主人实测 DTO 里那个数),
// 比旧上限 262144 大,所以上面那条断言不是碰巧成立。
if turn.perDocInject != 349525 {
t.Fatalf("注入字符数 = %d, want 349525(1M 窗口的注入预算;形状变了要复核本用例)",
turn.perDocInject)
}
if turn.assembledChars <= guardrailMinInputChars {
t.Fatalf("装配后的请求只有 %d 字符(≤ 旧上限 %d),复现不到「大附件被拦死」",
turn.assembledChars, guardrailMinInputChars)
}
if !turn.result.Documents[0].Truncated {
t.Fatal("清单没有如实标注注入被截断(design/24 §4.3)")
}
}
// TestGuardrailWiring_TightInputLimitStillBlocks 是**护栏没被削弱**的自证:
// 同一个大附件回合,只要显式把输入上限压到 1000 字符(GUARDRAIL_MAX_INPUT_CHARS
// 的覆盖态),回合必须真的被拦成 guardrail_input,且**在模型调用之前**拦下。
//
// 没有这条,上面那条回归有可能是「把输入侧长度检查放空了」才通过的。
func TestGuardrailWiring_TightInputLimitStillBlocks(t *testing.T) {
t.Parallel()
turn := runAttachmentTurn(t, 1, 1000)
if turn.result.StopReason != agent.StopReasonGuardrailInput {
t.Fatalf("收紧上限后 StopReason = %q, want %q", turn.result.StopReason, agent.StopReasonGuardrailInput)
}
if !strings.Contains(turn.result.Text, "input_length_exceeded") {
t.Fatalf("收紧上限后的回答 = %q, want 含 input_length_exceeded", turn.result.Text)
}
if turn.modelCalls != 0 {
t.Fatalf("被拦的回合仍调用了模型 %d 次 —— 输入侧不是前置门", turn.modelCalls)
}
// 注入本身照旧发生(记录如实),被拦是**上限**那一侧的判决。
if turn.perDocInject != 349525 {
t.Fatalf("注入字符数 = %d, want 349525(注入事实不因上限收紧而改写)", turn.perDocInject)
}
}
// TestGuardrailInputLimit_NotAFunctionOfInjectBudget 是变异反证:注入预算单独变化
// **不影响**输入上限(上限只由上下文预算与显式覆盖决定),因此「N 份注入 ≤ 上限」
// 不可能靠两边同源凑出来 —— 两端确实各自有出处。
func TestGuardrailInputLimit_NotAFunctionOfInjectBudget(t *testing.T) {
t.Parallel()
log, _ := capturingLog()
if got := deriveGuardrailInputLimit(log, 0, 699050, 0, maxChatDocumentsPerTurn); got != 2796200 {
t.Fatalf("注入预算为 0 时的输入上限 = %d, want 2796200", got)
}
if got := deriveGuardrailInputLimit(log, 0, 699050, 349525, maxChatDocumentsPerTurn); got != 2796200 {
t.Fatalf("注入预算 349525 时的输入上限 = %d, want 2796200(不受注入预算影响)", got)
}
}
+184
View File
@@ -0,0 +1,184 @@
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
)
// 本文件锁定切片 G2b:把输入护栏上限从「1 份附件」对齐到「**N 份**附件」。
//
// G2(51c1672)修的是「单份大附件被拦死」,残留缺口是**多附件**:
//
// - 护栏量的是整段装配请求(字符数),而注入预算是**单份**的:
// 1M 窗口下每份注入 349525 字符、上限 699050 字符 ⇒ **2 份**满额注入就顶到上限,
// 再加系统提示词/历史即被拦成 guardrail_input,5 份(单回合上限)必被拦;
// - 根因是两套换算:上限按 1 char ≈ 1 token 兑,注入按 loop.CharsPerToken(4)兑。
//
// 修法:上限 = max(guardrailMinInputChars, 推导预算 token 数 × loop.CharsPerToken),
// 关系不变量升级为「maxChatDocumentsPerTurn 份 × 单份注入 ≤ 输入上限」,破了就 WARN。
// TestGuardrailWiring_TwoFullAttachmentsPassInputGuard 是切片 G2b 对准缺口的回归:
// **2 份**各自撞满注入预算的附件(+ 系统提示词/历史)**不得**再以 guardrail_input
// 结束。G2 之后的上限是 699050 字符(预算 token 数按 1:1 兑),而 2 份满额注入恰好
// 就是 699050 字符 ⇒ 加上装配的固定开销必然超限,这正是主人实测的「多附件回合被拦死」。
func TestGuardrailWiring_TwoFullAttachmentsPassInputGuard(t *testing.T) {
t.Parallel()
turn := runAttachmentTurn(t, 2, 0)
// ① 前提自证:这一回合真的带了 2 份附件,且每份都撞满注入预算(349525 字符)。
if turn.documents != 2 {
t.Fatalf("本回合注入的附件份数 = %d, want 2", turn.documents)
}
if turn.perDocInject != 349525 {
t.Fatalf("每份注入字符数 = %d, want 349525(1M 窗口的注入预算;形状变了要复核本用例)",
turn.perDocInject)
}
// ② bug 的症状先说清楚:回合不得以 guardrail_input 结束,也不得被拦在模型之前。
if turn.result.StopReason == agent.StopReasonGuardrailInput {
t.Fatalf("2 份大附件回合仍以 guardrail_input 结束(G2b 要修的就是它):%q", turn.result.Text)
}
if turn.modelCalls != 1 {
t.Fatalf("2 份大附件回合的模型调用次数 = %d, want 1(护栏把回合拦在模型之前了)", turn.modelCalls)
}
// ③ 缺口自证:装配后的请求**超过**了 G2 时代的上限(= 推导预算 token 数),
// 也就是「2 份满额注入就顶到上限」—— 上面那条断言因此不是碰巧成立。
if turn.assembledChars <= turn.g2Limit {
t.Fatalf("装配后的请求只有 %d 字符(≤ G2 时代的上限 %d),复现不到多附件缺口",
turn.assembledChars, turn.g2Limit)
}
// ④ 每份的注入事实如实回传(正文都比预算长 ⇒ 都该标 truncated + 续读游标)。
for i, ref := range turn.result.Documents {
if ref.Chars != 349525 || !ref.Truncated {
t.Fatalf("第 %d 份附件的注入记录 = %+v,want 349525 字符且 truncated", i+1, ref)
}
}
}
// TestGuardrailWiring_FiveFullAttachmentsPassInputGuard 是同一缺口的**上界**回归:
// 5 份(单回合上限)满额注入的回合同样要抵达模型。只说「2 份能过」不够 ——
// 本切片的不变量是「N 份全满也能过」,这里就按 N 跑一次真回合。
func TestGuardrailWiring_FiveFullAttachmentsPassInputGuard(t *testing.T) {
t.Parallel()
turn := runAttachmentTurn(t, maxChatDocumentsPerTurn, 0)
if turn.documents != maxChatDocumentsPerTurn {
t.Fatalf("本回合注入的附件份数 = %d, want %d", turn.documents, maxChatDocumentsPerTurn)
}
if turn.result.StopReason == agent.StopReasonGuardrailInput {
t.Fatalf("%d 份大附件回合仍以 guardrail_input 结束:%q",
maxChatDocumentsPerTurn, turn.result.Text)
}
if turn.modelCalls != 1 {
t.Fatalf("%d 份大附件回合的模型调用次数 = %d, want 1(护栏把回合拦在模型之前了)",
maxChatDocumentsPerTurn, turn.modelCalls)
}
// 装配总量必须落在生效上限之内 —— 这条既是「没被拦」的解释,也是不变量的实证。
if turn.assembledChars > turn.inputLimit {
t.Fatalf("装配后的请求 %d 字符 > 生效输入上限 %d", turn.assembledChars, turn.inputLimit)
}
}
// TestGuardrailInputLimit_CoversAllAttachmentsOfATurn 钉住**N 份**不变量本身:
// 派生态下「单回合全部附件的注入预算合计 ≤ 输入上限」必须成立(且不记 WARN),
// 而把上限压到合计之下必须有一条看得懂是**多附件**问题的 WARN。
//
// maxChatDocumentsPerTurn = 5 的出处:单回合附件数上限的**真源**是
// documents.MaxPerMessage(网关侧以 `maxChatDocuments = documents.MaxPerMessage`
// 引用它),design/24 §6.1、§9.4-3 的定稿值。本切片**不跨包引用** —— documents 正
// 被并行切片占用,而这里只需要一个数;因此取同值并在下面断言它仍是 5,值一旦漂移
// 本用例即红。待并行切片落定后统一为一个共享常量(届时这两处都改为引用它)。
func TestGuardrailInputLimit_CoversAllAttachmentsOfATurn(t *testing.T) {
t.Parallel()
if maxChatDocumentsPerTurn != 5 {
t.Fatalf("单回合附件数上限 = %d, want 5(documents.MaxPerMessage 的定稿值)",
maxChatDocumentsPerTurn)
}
cases := []struct {
name string
window int
wantDoc int
wantCap int
}{
// 1M 窗口(声明了窗口的生产部署):预算 699050 token ⇒ 单份 349525、上限 2796200。
{name: "1M 窗口", window: 1048576, wantDoc: 349525, wantCap: 2796200},
// 未声明窗口的回落部署(131072):预算 87381 token ⇒ 单份落回 64K、上限 349524
// (= 87381 × 4 > 256 Ki)。旧的 1:1 兑法在这里只给 262144,5 份 64K 合计 327680
// 就会被拦 —— 缺口在回落部署上同样存在,本切片一并修掉。
{name: "未声明窗口的回落部署", window: 0, wantDoc: 64 * 1024, wantCap: 87381 * loop.CharsPerToken},
}
for _, c := range cases {
c := c
t.Run(c.name, func(t *testing.T) {
t.Parallel()
a := &app{log: testLog(), cfg: &config.Config{}, llm: envWindowStack(t, c.window, 0)}
budget := a.contextBudget(filepath.Join(t.TempDir(), "absent.env"))
if budget.DocumentInjectChars != c.wantDoc {
t.Fatalf("单份注入预算 = %d, want %d(推导链变了,本用例的前提需复核)",
budget.DocumentInjectChars, c.wantDoc)
}
log, buf := capturingLog()
limit := deriveGuardrailInputLimit(log, 0, budget.Budget.Tokens, budget.DocumentInjectChars, maxChatDocumentsPerTurn)
if limit != c.wantCap {
t.Fatalf("输入上限 = %d, want %d(= 推导预算 token 数 × %d)",
limit, c.wantCap, loop.CharsPerToken)
}
if total := budget.DocumentInjectChars * maxChatDocumentsPerTurn; total > limit {
t.Fatalf("N 份不变量被破:%d 份 × %d = %d > 上限 %d",
maxChatDocumentsPerTurn, budget.DocumentInjectChars, total, limit)
}
if n := countRecords(logRecords(t, buf), "WARN", "exceeds guardrail input limit"); n != 0 {
t.Fatalf("不变量成立却记了 %d 条 WARN", n)
}
})
}
}
// TestEnvExample_GuardrailInputFormulaMatchesCode 钉住模板与实现同步(AGENTS §10 铁律):
// 该键在 .env.example 里必须**逐字一致**,且注释里的算例必须与代码里的常量现算结果
// 相同 —— 常量或公式漂移时本用例红,照模板部署的新实例才不会漏配/误解口径。
func TestEnvExample_GuardrailInputFormulaMatchesCode(t *testing.T) {
t.Parallel()
raw, err := os.ReadFile(filepath.Join("..", "..", ".env.example"))
if err != nil {
t.Fatalf("读 .env.example: %v", err)
}
template := string(raw)
// 键名逐字一致(从常量拼出来,重命名时本用例即红)。
if key := "# " + config.GuardrailMaxInputCharsEnv + "="; !strings.Contains(template, key) {
t.Fatalf(".env.example 里找不到 %q(键名必须与代码逐字一致)", key)
}
// 注释里的两个算例都由代码现算,模板与实现不许各说各话。
// 1M 窗口 ⇒ 上限 = 699050 × CharsPerToken;5 份满额注入合计 = 单份注入 × 5。
oneMegTokens := 699050
needles := []string{
fmt.Sprintf("%d", oneMegTokens*loop.CharsPerToken),
fmt.Sprintf("%d", loop.DocumentInjectCharsFor(oneMegTokens)*maxChatDocumentsPerTurn),
fmt.Sprintf("%d", guardrailMinInputChars),
}
for _, needle := range needles {
if !strings.Contains(template, needle) {
t.Fatalf(".env.example 的说明里缺少现算值 %q(模板没跟上代码口径)", needle)
}
}
// 口径本身要写清是**多附件**问题,否则部署方照旧按「单份」理解。
for _, needle := range []string{"多附件", "CharsPerToken"} {
if !strings.Contains(template, needle) {
t.Fatalf(".env.example 的说明里缺少 %q(没写清多附件口径)", needle)
}
}
}
+315
View File
@@ -0,0 +1,315 @@
package main
import (
"context"
"path/filepath"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/guardrail"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件锁定 P0-5:组合根的护栏管线**不是空壳**。断言分两层 ——
// 管线本身(正常对话过 / 敏感与超长被拦 / 词表只在配置提供时挂),
// 以及端到端(同一个循环:正常消息真被模型回答,敏感消息在模型调用**之前**
// 就被拦下,适配器一次都没被调用)。
// guardProbeAdapter 只回答固定文本并数调用次数,使「护栏是否在模型调用前拦下」
// 变成可断言的事实。
type guardProbeAdapter struct {
reply string
calls int
}
func (a *guardProbeAdapter) Name() string { return "guard-probe" }
func (a *guardProbeAdapter) Chat(context.Context, model.Request, model.Options) (model.Response, error) {
a.calls++
return model.Response{Content: a.reply}, nil
}
func (a *guardProbeAdapter) ChatStream(ctx context.Context, req model.Request, on func(model.StreamEvent)) (model.Response, error) {
if on != nil {
on(model.StreamEvent{Kind: model.StreamKindTextDelta, Delta: a.reply})
}
return a.Chat(ctx, req, model.Options{})
}
// noWindowBudget 是「没声明窗口的小部署」的预算形状:压缩预算与注入预算两项
// 推导值都是 0,于是派生的输入上限落回下限 guardrailMinInputChars —— 也就是
// 接线前那个硬编码值的等价场景。只关心「护栏里有什么」的用例传它即可。
func noWindowBudget() ctxBudget { return ctxBudget{} }
// guardrailApp 返回带给定关键词表的组合根切片;cfg 为 nil 表示「一点配置都没有」。
func guardrailApp(t *testing.T, keywords []string) *app {
t.Helper()
if keywords == nil {
return &app{log: testLog()}
}
cfg := &config.Config{}
cfg.Guardrail.Keywords = keywords
return &app{log: testLog(), cfg: cfg}
}
// checkInput / checkOutput 是测试侧的薄封装:把一段文本包成请求或响应再判一次。
func checkInput(t *testing.T, p *guardrail.Pipeline, text string) *guardrail.Verdict {
t.Helper()
req := model.Request{Messages: []model.Message{model.TextMessage(model.RoleUser, text)}}
v, err := p.CheckInput(context.Background(), &req)
if err != nil {
t.Fatalf("CheckInput(%q): %v", truncate(text, 40), err)
}
return v
}
func checkOutput(t *testing.T, p *guardrail.Pipeline, text string) *guardrail.Verdict {
t.Helper()
resp := model.Response{Content: text}
v, err := p.CheckOutput(context.Background(), &resp)
if err != nil {
t.Fatalf("CheckOutput: %v", err)
}
return v
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}
// TestGuardrailPipeline_NormalConversationPasses 是真断言①:正常对话照常通过。
// 系统提示词 + 日常用户消息 + 一段几千字的正常长回复都必须放行 —— 护栏上线
// 不能以「误伤日常聊天」为代价。
func TestGuardrailPipeline_NormalConversationPasses(t *testing.T) {
t.Parallel()
p := guardrailApp(t, nil).buildGuardrailPipeline(noWindowBudget())
req := model.Request{Messages: []model.Message{
model.TextMessage(model.RoleSystem, "你是昔涟,温柔俏皮的小女友。"),
model.TextMessage(model.RoleUser, "今天有点累,陪我聊会儿天好不好?"),
model.TextMessage(model.RoleAssistant, "好呀,人家陪着你♪ 今天遇到什么啦?"),
model.TextMessage(model.RoleUser, "就是工作有点多。"),
}}
if v, _ := p.CheckInput(context.Background(), &req); !v.Allow {
t.Fatalf("正常对话被输入护栏拦下:%+v", v)
}
// 一段普通的长回复(约 3000 字)也不该被长度上限碰到。
long := strings.Repeat("今天也要好好吃饭、早点睡觉哦,人家会一直陪着你的♪", 100)
if v := checkOutput(t, p, long); !v.Allow {
t.Fatalf("正常长回复被输出护栏拦下:%+v", v)
}
}
// TestGuardrailPipeline_BlocksSensitiveInfo 是真断言②:敏感信息真被拦(阻断档,
// 不是标记档),且判决里不回流被命中的秘密本身(guardrail 包的既有契约)。
func TestGuardrailPipeline_BlocksSensitiveInfo(t *testing.T) {
t.Parallel()
p := guardrailApp(t, nil).buildGuardrailPipeline(noWindowBudget())
secret := "sk-abcdefghijklmnopqrstuvwxyz0123456789"
cases := []struct {
name string
text string
secret string
}{
{name: "api key 参数", text: "api_key = " + secret, secret: secret},
{name: "私钥头", text: "-----BEGIN RSA PRIVATE KEY-----\nMIIEow...", secret: "MIIEow"},
{name: "bearer token", text: "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9", secret: "eyJhbGciOiJIUzI1NiJ9"},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
v := checkOutput(t, p, tc.text)
if v.Allow {
t.Fatalf("敏感内容未被拦下:%q → %+v", tc.text, v)
}
if !strings.Contains(v.Reason, "sensitive_information_blocked") {
t.Fatalf("拦截原因 = %q,want 含 sensitive_information_blocked", v.Reason)
}
if strings.Contains(v.Reason, tc.secret) {
t.Fatalf("判决里回流了被命中的秘密:%q", v.Reason)
}
})
}
// 请求侧同样拦(用户把密钥贴进来,不该先消耗一次模型调用)。
if v := checkInput(t, p, "帮我看看 password: hunter2 对不对"); v.Allow {
t.Fatalf("请求侧敏感内容未被拦下:%+v", v)
}
}
// TestGuardrailPipeline_LengthCapsOnlyCatchPathologicalPayloads 锁定长度上限的
// 边界:恰好等于上限放行、超一个字符就被拦;并且「很长但正常」的上下文不被误伤。
//
// 这里用的是 noWindowBudget(小窗口部署):派生的输入上限落回下限
// guardrailMinInputChars,也就是接线前那个 262144 的行为。
func TestGuardrailPipeline_LengthCapsOnlyCatchPathologicalPayloads(t *testing.T) {
t.Parallel()
p := guardrailApp(t, nil).buildGuardrailPipeline(noWindowBudget())
if limit := guardrailApp(t, nil).guardrailInputLimit(noWindowBudget()); limit != guardrailMinInputChars {
t.Fatalf("小窗口部署的输入上限 = %d, want 下限 %d", limit, guardrailMinInputChars)
}
// 输出上限是**另一个**数:它防的是模型输出失控,与上下文预算无关,
// 因此本切片只动输入侧,输出侧不得顺手放宽(回归钉住取值本身)。
if guardrailMaxOutputChars != 65536 {
t.Fatalf("输出上限 = %d, want 65536(本切片只动输入侧)", guardrailMaxOutputChars)
}
if v := checkOutput(t, p, strings.Repeat("a", guardrailMaxOutputChars)); !v.Allow {
t.Fatalf("恰好到上限的输出被拦:%+v", v)
}
if v := checkOutput(t, p, strings.Repeat("a", guardrailMaxOutputChars+1)); v.Allow {
t.Fatal("超出输出上限一个字符却放行")
} else if !strings.Contains(v.Reason, "output_length_exceeded") {
t.Fatalf("超长判决 = %q,want output_length_exceeded", v.Reason)
}
// 输入侧检查跑在**组装完成的请求**上,因此上限按整个上下文定:
// 10 万字符(约 5 万 token 的上下文)仍属正常,必须放行。
req := model.Request{Messages: []model.Message{
model.TextMessage(model.RoleSystem, strings.Repeat("设定。", 1000)),
model.TextMessage(model.RoleUser, strings.Repeat("闲聊内容。", 10000)),
}}
if v, _ := p.CheckInput(context.Background(), &req); !v.Allow {
t.Fatalf("10 万字符的正常上下文被输入上限拦下:%+v", v)
}
// 超过上限则被拦。
over := model.Request{Messages: []model.Message{
model.TextMessage(model.RoleUser, strings.Repeat("a", guardrailMinInputChars+1)),
}}
if v, _ := p.CheckInput(context.Background(), &over); v.Allow {
t.Fatal("超出输入上限的载荷却放行")
} else if !strings.Contains(v.Reason, "input_length_exceeded") {
t.Fatalf("超长输入判决 = %q,want input_length_exceeded", v.Reason)
}
}
// TestGuardrailPipeline_KeywordsOnlyWhenConfigured 锁定策略③:项目不硬编词表。
// 同一段文本,没配置时放行,配置了该词才被拦。
func TestGuardrailPipeline_KeywordsOnlyWhenConfigured(t *testing.T) {
t.Parallel()
text := "这段文本里出现了违禁词。"
if v := checkInput(t, guardrailApp(t, nil).buildGuardrailPipeline(noWindowBudget()), text); !v.Allow {
t.Fatalf("未配置词表却拦下了文本:%+v", v)
}
configured := guardrailApp(t, []string{"违禁词"}).buildGuardrailPipeline(noWindowBudget())
v := checkInput(t, configured, text)
if v.Allow {
t.Fatal("配置了词表却没拦下命中文本")
}
if !strings.Contains(v.Reason, "content_policy_blocked") {
t.Fatalf("关键词判决 = %q,want content_policy_blocked", v.Reason)
}
// 词表只按声明的词命中,不牵连别的正常文本。
if v := checkInput(t, configured, "这句很正常。"); !v.Allow {
t.Fatalf("词表误伤了正常文本:%+v", v)
}
}
// TestGuardrailPipeline_EmptyPipelineIsTheBugThisFixes 是反证(变异):空管线
// (P0-5 的原始形态 `guardrail.New()`)对同一段敏感文本一律放行 —— 证明上面
// 的断言不是恒真的,敏感拦截确实来自组合根挂上的检查。
func TestGuardrailPipeline_EmptyPipelineIsTheBugThisFixes(t *testing.T) {
t.Parallel()
empty := guardrail.New()
if v := checkOutput(t, empty, "api_key = sk-abcdefghijklmnopqrstuvwxyz0123456789"); !v.Allow {
t.Fatalf("空管线本应全部放行(这正是 P0-5 的空壳),却拦下了:%+v", v)
}
if v := checkOutput(t, empty, strings.Repeat("a", guardrailMaxOutputChars+1)); !v.Allow {
t.Fatalf("空管线本应不做长度检查,却拦下了:%+v", v)
}
}
// TestGuardrailPipeline_RealPromptPasses 是一道防误伤的现实检查:生产里真正被
// 检查的输入侧载荷是**组装完成的请求**(真实人格提示词 + 主人身份说明书 + 用户
// 消息)。若这几段里恰好命中某条敏感模式,每一轮对话都会被永久拦死 ——
// 因此这里拿仓库里真实的两份文件走一遍。
func TestGuardrailPipeline_RealPromptPasses(t *testing.T) {
t.Parallel()
a, p := identityApp(t, filepath.Join("..", "..", "configs", "users", "owner_identity.md"))
// 身份说明书自 P0-10 收窄后不再进静态指令,而是按会话注入系统上下文
// (persona_identity.go 的 personaKnowledgeFor)。护栏看到的真实载荷仍是
// 「人格提示词 + 身份说明书 + 用户消息」,故这里把它拼回同一段文本。
instructions := a.personaInstructions(p) +
"\n\n" + identityKnowledgeBlock(a.ownerIdentityKnowledge())
if !strings.Contains(instructions, "你叫他「亲爱的」") {
t.Fatalf("测试前提不成立:真实身份说明书没被读进来:%q", instructions)
}
pipeline := a.buildGuardrailPipeline(noWindowBudget())
req := model.Request{Messages: []model.Message{
model.TextMessage(model.RoleSystem, instructions),
model.TextMessage(model.RoleUser, "亲爱的,今天想我了没?"),
}}
v, err := pipeline.CheckInput(context.Background(), &req)
if err != nil {
t.Fatalf("CheckInput: %v", err)
}
if !v.Allow {
t.Fatalf("真实人格提示词 + 身份说明书被护栏拦下(会造成每轮都被拦死):%+v", v)
}
}
// TestGuardrailWiring_EndToEnd 是端到端真断言:同一个循环里,
// - 正常消息被模型正常回答(适配器被调用一次);
// - 敏感消息在模型调用**之前**就被拦下(适配器一次都没被调用),
// 且该回合的 StopReason 是 guardrail_input。
func TestGuardrailWiring_EndToEnd(t *testing.T) {
t.Parallel()
guards := guardrailApp(t, nil).buildGuardrailPipeline(noWindowBudget())
adapter := &guardProbeAdapter{reply: "我在呢♪"}
ag, err := agent.New("你是昔涟。", nil, guards, agent.WithModel(adapter, nil))
if err != nil {
t.Fatalf("agent.New: %v", err)
}
l := loop.New(loop.Options{
Agent: ag,
Guards: guards,
OnStream: func(model.StreamEvent) {},
})
normal, err := l.Run(context.Background(), "web", []model.Message{
model.TextMessage(model.RoleUser, "晚上好呀"),
})
if err != nil {
t.Fatalf("Run(正常消息): %v", err)
}
if normal.Text != "我在呢♪" {
t.Fatalf("正常消息的回答 = %q,want 模型回复(护栏不该改动它)", normal.Text)
}
if adapter.calls != 1 {
t.Fatalf("正常消息的模型调用次数 = %d,want 1", adapter.calls)
}
blocked, err := l.Run(context.Background(), "web", []model.Message{
model.TextMessage(model.RoleUser, "我的 api_key = sk-abcdefghijklmnopqrstuvwxyz0123456789"),
})
if err != nil {
t.Fatalf("Run(敏感消息): %v", err)
}
if adapter.calls != 1 {
t.Fatalf("敏感消息触发了模型调用(calls=%d)—— 输入护栏没有前置拦下", adapter.calls)
}
if !strings.Contains(blocked.Text, "sensitive_information_blocked") {
t.Fatalf("敏感消息的回答 = %q,want 含拦截原因", blocked.Text)
}
if blocked.StopReason != agent.StopReasonGuardrailInput {
t.Fatalf("敏感消息的 StopReason = %q,want %q", blocked.StopReason, agent.StopReasonGuardrailInput)
}
}
+342
View File
@@ -0,0 +1,342 @@
package main
// 本文件装配 io 工具集(plugins/tools/io)的选项:知识检索器、日志、
// **web-search 的后端与超时**(切片 WS1-A 接线 + WS1-B provider 层 + PLG-4 可变源),
// 以及 design/28 §8 B5 的**浏览器降级档挂载**(`bodyFallbackOption()`,实现与口径在
// `wire_body_fallback.go` —— 挂载条件 = 能力面里确实有可用浏览器,详见那个文件)。
//
// 为什么单独成文件而不是塞在 wire_runtime.go 里:那条装配线本就接近文件行数上限,
// 而这里新增的裁决(端点/provider 关闭 ⇒ 工具不进注册面;选定 provider 未登记/未配置
// ⇒ 启动报错;面板配置优先、缺失由 `.env` 播种)需要一个能单测的落点。
//
// 六条口径:
//
// 1. **后端由配置选定**:主 provider 选谁(`WEB_SEARCH_PROVIDER` / 面板文件),降级链
// 怎么走,各家的 key 与基地址从哪来(design/27 §3)。
// 2. **配置有可变源**(design/09 §5.7 E):先读 `$DATA_DIR/config/plugins/web-search.json`
// —— 面板可改、0600 + temp+rename;文件**缺失/为空**时由 `.env` 播种(`.env` 从此只是
// 「最小化功能可跑」的引导面,本片**不新增任何 env 键**);文件**非法**时记 WARN 回落
// `.env`,且**不覆盖**原文件(损坏的配置可以人工救回)。
// 3. **拿不到可用后端就不注册工具**(主人 2026-10-05 报障的核心诉求):端点显式关闭
// (空串 / off / none)或不是 http(s) 绝对地址时,web-search **不进注册面** ——
// 模型根本看不见它,而不是看得见、一调就报错。
// 4. **各家的名字表来自插件,不来自 `.env`**:文件存在时它是**唯一权威**(文件里没有的家
// 就是没有),逐家的名字 / 是否需要 key / 默认基地址全取 `plugins/tools/io/search` 的
// 接入位表 ⇒ **加一家 provider 不必加 env 键**(`.env` 冻结红线)。实现在
// `websearch_providers.go`。
// 5. **未配置的家不进可用清单**:`search.Configured` 判定(云家看 key、searxng 看端点),
// false ⇒ 该家不出现在注册表里(fail-closed)。选定 provider 未配置时由 io 工具集在
// **启动期报错**,绝不静默回落到兜底。
// 6. **超时不另造默认值**:`WEB_SEARCH_TIMEOUT` / 面板的 `timeout` 未声明时沿用 io 包既有
// 超时;负值是笔误,按未配置处理并记一条带键名的 WARN(不静默)。
//
// 同一套解析也服务**热重载**(切片 W3,裁决 2026-10-06 #1):面板保存后由
// `buildWebSearchTool` 按**当前落盘配置**重建这一把工具并替换注册表条目,因此
// 「启动期的接线」与「保存后的接线」恒为同一条路径(不是两份语义)。两份调用的差别
// 只有日志里的 cause 字段(startup / panel-save),便于排障时区分。
import (
"log/slog"
"strings"
"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/logger"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
)
// web-search 的 env 键名:与 `.env.example` 逐字一致(AGENTS §10 的 E6 条目),
// 由 wire_web_search_test.go 逐字核对。它们仍然是**引导面**的键(.env 冻结后不再新增)。
const (
webSearchProviderEnv = "WEB_SEARCH_PROVIDER"
webSearchEndpointEnv = "WEB_SEARCH_ENDPOINT"
webSearchTimeoutEnv = "WEB_SEARCH_TIMEOUT"
webSearchMaxResultsEnv = "WEB_SEARCH_MAX_RESULTS"
webSearchFallbackEnv = "WEB_SEARCH_FALLBACK"
)
// ioLogSource 是本文件日志的来源标签(项目规范 §10:禁止无来源裸日志)。
const ioLogSource = "cmd/cyrene/wire_io_tools"
// 生效配置的来源标签(写进启动日志:一眼看出「这次的 provider 是 .env 给的还是面板给的」)。
const (
// webSearchSourceEnv 表示本次的配置来自 `.env`(含「文件缺失、刚由 .env 播种」这一步)。
webSearchSourceEnv = "env"
// webSearchSourcePanel 表示本次的配置来自面板可变源(面板是唯一来源)。
webSearchSourcePanel = "panel"
)
// 解析一次 web-search 配置的**触发场景**(只进日志的 cause 字段)。
const (
// webSearchCauseStartup 是启动期装配(`ioToolOptions`)。
webSearchCauseStartup = "startup"
// webSearchCausePanelSave 是面板保存后的热重载(`buildWebSearchTool`,见 websearch_reload.go)。
webSearchCausePanelSave = "panel-save"
)
// webSearchToolName 是 web-search 工具在能力注册表里的名字。
//
// 它与 io 包 `func (s *webSearchTool) Name()` 里的字面量同源,而那个名字没有导出常量
// (`plugins/tools/io` 不在本片文件面内,不去改它):因此这里用字面量,并由
// websearch_reload_test.go 钉住 —— 名字一旦漂移,热重载会立刻找不到工具(测试红)。
const webSearchToolName = "web-search"
// ioToolOptions 组装 io 工具集的全部选项。wire_runtime 把它原样交给 iotools.New,
// 因此「注册面里有哪些 io 工具」只由这一处决定。
//
// 它**只做 io 选项**(切片 X3 的收口):browser 的接线挂点原先是借这里转交的
// (V5 当时改不了 wire_runtime.go),现已归位到 `wireRuntime` 里与 `wireVision` /
// `wireTranscode` 并列的那一行(见 wire_runtime.go)。转交留下的第二个代价是副作用:
// 凡是只为了要一份 iotools.Option 而调用本函数的路径(例如 web-search 的接线测试)
// 都会顺带探测并注册浏览器工具 —— 那不该是「取选项」这件事的含义。
func (a *app) ioToolOptions() []iotools.Option {
opts := []iotools.Option{
iotools.WithKnowledgeRetriever(a.retriever),
iotools.WithLogger(a.log),
// B5 的挂载点:有可用浏览器就挂上浏览器渲染取正文,没有就不挂(口径与实现
// 都在 wire_body_fallback.go)。挂载条件必须在**浏览器装配之后**求值 ——
// 这就是 wireRuntime 里 `a.wireBrowser()` 排在 io 工具集之前的原因。
a.bodyFallbackOption(),
}
return append(opts, a.webSearchOptions(webSearchCauseStartup)...)
}
// buildWebSearchTool 按**当前生效配置**构造这一把 web-search 工具(面板保存后的热重载用它
// 重建工具,见 websearch_reload.go)。
//
// 三种返回值,调用方必须分开处置:
// - (tool, nil) ⇒ 正常,工具该在注册面里;
// - (nil, nil) ⇒ 当前配置下它**不该在注册面里**(端点/主 provider 显式关闭,或选定
// provider 没配齐被 WithoutSearch 摘掉)—— 调用方据此把旧条目摘掉;
// - (nil, err) ⇒ 这份配置会让后端**下次启动直接失败**(主 provider 未登记/未配置,
// design/27 §3 的 fail-fast)—— 调用方必须如实报错,绝不留下一个起不来的文件。
//
// 为什么要构造整份 io 工具集再挑出 web-search:io 包的公开构造入口只有
// `iotools.New(cfg, opts...)`(整份集合),没有「只造 web-search」的入口,而
// `plugins/tools/io` 不在本片文件面内。代价是一次 New(几个工具对象 + 一个 http.Client
// 壳;Transport 是共享的 http.DefaultTransport,不新开连接池)—— 面板保存是低频动作,
// 付得起,换来的是**重载后的工具与启动期构造的逐字段同源**(不是第二份接线逻辑)。
func (a *app) buildWebSearchTool(cause string) (sdk.Tool, error) {
set, err := iotools.New(a.cfg, a.webSearchOptions(cause)...)
if err != nil {
return nil, err
}
for _, t := range set {
if t.Name() == webSearchToolName {
return t, nil
}
}
return nil, nil
}
// webSearchOptions 把生效配置翻译成 io 选项。cause 说明**为什么**解析这一次(启动装配 /
// 面板保存后的热重载),只进日志:同一个函数被两处调用,日志里必须能分清是哪一次。
// 端点/后端「关闭 / 非法 / 可用」三种去处都在这里定死(ResolveProvider/ResolveEndpoint)。
func (a *app) webSearchOptions(cause string) []iotools.Option {
log := logger.WithSource(a.log, ioLogSource)
eff, source := a.effectiveWebSearch(log)
ws := eff.Scalar
// ① 后端显式关闭(主 provider = 空 / off / none)⇒ 整件工具不注册。
if decision := ws.ResolveProvider(); decision.Disabled {
// 配置值**绝不写进日志**(只记键名与原因)。
log.Warn("web-search tool not registered",
"selectorKey", webSearchProviderEnv, "reason", config.WebSearchReasonOff, "configSource", source,
"cause", cause,
"effect", "the model will not see a web-search tool at all")
return []iotools.Option{iotools.WithoutSearch()}
}
specs := searchProviderSpecs(ws, eff.Providers)
// ② searxng 的可用性 = 端点裁决(WS1-A 的既有语义):端点显式关闭 / 不是 http(s)
// 绝对地址时**整件工具不注册** —— 那两种取值是「关掉搜索」的显式选择,不是配置
// 错误,因此不 fatal,只留一条带键名的 WARN。
if endpoint := ws.ResolveEndpoint(); !endpoint.Enabled && selectsSearXNG(ws) {
log.Warn("web-search tool not registered",
"endpointKey", webSearchEndpointEnv, "reason", endpoint.Reason, "configSource", source,
"cause", cause,
"effect", "the model will not see a web-search tool at all")
return []iotools.Option{iotools.WithoutSearch()}
}
// primary 是**生效值**(可能来自面板文件、也可能来自 `.env` 播种)⇒ 它旁边的键名只是
// 「这个键在哪份配置里」,而 source 才是「本次的值从哪来」。两者都打出来,不混为一谈。
log.Info("web-search providers configured", "cause", cause,
"selectorKey", webSearchProviderEnv, "primary", strings.ToLower(strings.TrimSpace(ws.Provider)),
"fallbackKey", webSearchFallbackEnv, "fallback", ws.Fallback, "configSource", source,
"available", availableProviderNames(specs))
opts := []iotools.Option{
iotools.WithSearchProviders(specs...),
// 选定主 provider + 降级链:未登记(如本期未做的 tavily)或未配置(缺 key)
// ⇒ io 工具集在启动期报错(不许静默回落)。
iotools.WithSearchSelection(ws.Provider, ws.Fallback),
iotools.WithSearchMaxResults(ws.MaxResults),
}
switch {
case ws.Timeout > 0:
opts = append(opts, iotools.WithSearchTimeout(ws.Timeout))
case ws.Timeout < 0:
log.Warn("web-search timeout is negative, treated as unset",
"key", webSearchTimeoutEnv, "configSource", source, "cause", cause,
"effect", "io package default timeout applies")
}
return opts
}
// effectiveWebSearch 解析 web-search 的**生效配置**,并报告它是从哪儿来的:
//
// - 面板文件存在且合法 ⇒ 它是唯一来源(design/09 §5.7 E 的「先读可变源」);
// - 文件缺失/为空 ⇒ 用 `.env` 播种落盘,本次行为与 `.env` 完全一致(「缺失回落 `.env`
// 播种」,照 models.json materialize 范式);
// - 文件存在但非法 ⇒ 记 WARN、本次回落 `.env`,**不覆盖**原文件。
//
// 返回值里的第二个值是来源标签,只用于日志(`.env` 还是面板),不参与任何裁决。
func (a *app) effectiveWebSearch(log *slog.Logger) (effectiveWebSearchConfig, string) {
env := a.cfg.WebSearch
path := runtimeconfig.WebSearchConfigPath(resolveDataDir(a.cfg))
f, found, err := runtimeconfig.LoadWebSearch(path)
switch {
case err != nil:
log.Warn("web-search 面板配置非法,本次按 .env 运行(不覆盖原文件)",
"path", path, "error", err)
return envEffectiveWebSearch(env), webSearchSourceEnv
case !found:
return envEffectiveWebSearch(a.seedWebSearchConfig(log, path, env)), webSearchSourceEnv
}
warnUnknownSearchProviders(log, path, f)
eff, convErr := effectiveSearchConfig(env, f)
if convErr != nil {
log.Warn("web-search 面板配置无法生效,本次按 .env 运行",
"path", path, "error", convErr)
return envEffectiveWebSearch(env), webSearchSourceEnv
}
log.Info("web-search 面板配置生效", "path", path, "providers", len(f.Providers))
return eff, webSearchSourcePanel
}
// envEffectiveWebSearch 是「**没有可用文件**」时的生效配置:逐字沿用 `.env` 播种层的语义
// (= `seedWebSearchFile` 会落盘的那份形状)。
//
// 它是 `.env` 唯一还能决定各家接线的时刻:文件一旦存在,权威就在文件那边(design/09 §5.7 E
// 的「先读可变源、缺失回落 `.env` 播种」)。
func envEffectiveWebSearch(env config.WebSearchConfig) effectiveWebSearchConfig {
return effectiveWebSearchConfig{Scalar: env, Providers: searchProviderVector(env)}
}
// seedWebSearchConfig 把 `.env` 播种成面板文件(`.env` 只作引导面)。
//
// 播种失败**不阻断启动**:本次按 `.env` 运行,只记一条 WARN(与 models.json 的处置一致)。
func (a *app) seedWebSearchConfig(log *slog.Logger, path string, env config.WebSearchConfig) config.WebSearchConfig {
seed := seedWebSearchFile(env)
if err := runtimeconfig.SaveWebSearch(path, seed); err != nil {
log.Warn("web-search 面板配置播种失败(本次按 .env 运行)", "path", path, "error", err)
return env
}
log.Info("web-search 面板配置已按 .env 播种(此后面板是唯一来源)",
"path", path, "providers", len(seed.Providers))
return env
}
// seedWebSearchFile 把 `.env` 的 web-search 段投影成可变源的初始形状。
//
// **本期内置的家全列**(含还没配 key 的):面板的用途就是「一次把各家配齐」,
// 让用户先看到完整清单、再逐条填,比只列已配置的家少一轮「名字得自己知道」。
//
// 「全列」的名单来自 `.env` 的字段表(`envSearchProviders`)—— 那是**播种层**的输入,
// 因此这里的条数与被播种的键集逐字保持现状(`.env` 冻结:不加、不改、不删)。
func seedWebSearchFile(ws config.WebSearchConfig) runtimeconfig.WebSearchFile {
enabled := runtimeconfig.EnableFlag(true)
providers := envSearchProviders(ws)
entries := make([]runtimeconfig.ProviderEntry, 0, len(providers))
for _, p := range providers {
entries = append(entries, runtimeconfig.ProviderEntry{
Provider: p.Name, APIKey: p.APIKey, BaseURL: p.BaseURL, Enabled: enabled,
})
}
return runtimeconfig.WebSearchFile{
Version: runtimeconfig.WebSearchVersion,
Provider: ws.Provider,
Fallback: ws.Fallback,
MaxResults: ws.MaxResults,
Timeout: seedTimeout(ws.Timeout),
Providers: entries,
}
}
// effectiveSearchConfig 把面板文件翻译成工具要的生效配置。
//
// **权威边界**(写清楚,免得「谁覆盖谁」靠猜):
//
// - **标量以文件为准**:主 provider / 降级链来自文件(文件里写空 / off / none = 显式关闭,
// 与 `.env` 同一套哨兵语义);maxResults / timeout 在「没写」时沿用 `.env`(那两个标量
// 在「没写」与「写成空」之间无法区分,而它们的默认值本来就是引导面的一部分)。
// - **各家以文件为准**:文件里没有的家就是没有(绝不从 `.env` 复活,否则「我在面板里
// 删了它」会变成一句假话),凭据为空 = 这家没配。逐家的名字表**不再由 `.env` 的字段表
// 决定**,而是来自接入位表 —— 见 effectiveSearchProviders / resolveSearchProviders。
// - **searxng 的端点是开关**:文件里那条的 baseUrl 为空 = 关掉这家,**不回落** `.env`
// (回落会让「清空地址」变成假话)。
func effectiveSearchConfig(env config.WebSearchConfig, f runtimeconfig.WebSearchFile) (effectiveWebSearchConfig, error) {
out := env
out.Provider = f.Provider
out.Fallback = append([]string(nil), f.Fallback...)
if f.MaxResults > 0 {
out.MaxResults = f.MaxResults
}
d, err := f.TimeoutDuration()
if err != nil {
return effectiveWebSearchConfig{}, err
}
if d > 0 {
out.Timeout = d
}
// 逐家字段一律清零:它们承载的是 `.env` 的**播种**形状,而生效值现在由 Providers 承载
// (见 effectiveWebSearchConfig 的注释)。清零也顺带保证了任何误用都不会把 `.env` 的
// 旧凭据当成「生效凭据」。
out.ZhipuAPIKey, out.ZhipuBaseURL = "", ""
out.AliyunIQSAPIKey, out.AliyunIQSBaseURL = "", ""
out.BochaAPIKey, out.BochaBaseURL = "", ""
out.VolcengineAPIKey, out.VolcengineBaseURL = "", ""
out.BaiduQianfanAPIKey, out.BaiduQianfanBaseURL = "", ""
out.Endpoint = ""
if e, ok := findProviderEntry(f, search.NameSearXNG); ok && e.IsEnabled() {
out.Endpoint = strings.TrimSpace(e.BaseURL)
}
return effectiveWebSearchConfig{Scalar: out, Providers: effectiveSearchProviders(f)}, nil
}
// warnUnknownSearchProviders 对文件里本期没登记的家记一条 WARN。
//
// 它们不可能被选中(选中的家由 io 工具集在启动期校验),但「配了却不生效」必须说出来 ——
// 静默忽略正是「我明明配了 Tavily 却搜不出」那类报障的来源。
func warnUnknownSearchProviders(log *slog.Logger, path string, f runtimeconfig.WebSearchFile) {
for _, e := range f.Providers {
name := strings.ToLower(strings.TrimSpace(e.Provider))
if name == "" || search.Known(name) {
continue
}
log.Warn("web-search 面板配置里有未登记的提供商,已忽略",
"path", path, "provider", name, "known", search.KnownNames())
}
}
// selectsSearXNG 报告当前配置选的是不是 searxng(大小写与空白不敏感)。
func selectsSearXNG(ws config.WebSearchConfig) bool {
return strings.EqualFold(strings.TrimSpace(ws.Provider), search.NameSearXNG)
}
// 各家的配置投影(searchProviderWire / searchProviderVector / searchProviderSpecs /
// availableProviderNames)在 `websearch_providers.go` —— 那一片是本片「去 env 化」的本体
// (按**接入位表**逐家投影,而不是按 `.env` 的字段表)。
// seedTimeout 把超时播成 duration 字符串:未声明 / 笔误(≤0)落成空 = 未声明。
func seedTimeout(d time.Duration) string {
if d <= 0 {
return ""
}
return d.String()
}
+226
View File
@@ -0,0 +1,226 @@
package main
import (
"context"
"errors"
"fmt"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// errT1ModelNotReady 在 T1 需要模型而模型栈尚未就绪时返回。
//
// 它是 **fail-safe** 信号,不是故障:审查据此回落 `create` + WARN,绝不阻塞、
// 绝不丢记忆(design/23 §15 D3-5)。之所以可能存在这一刻:本服务在
// `wireMemory` 阶段装配,模型栈在更晚的 `wireRuntime` 阶段才建好。
var errT1ModelNotReady = errors.New("memory review: t1 judge: model adapter not ready")
// 本文件把 D3「入库前审查」(DevDocs/design/23-记忆整理与去重 §15.1 D3)接进
// 组合根。三件事只在这里定:
//
// - **形态**:记忆管理会话(复用既有的 sess-memorytidy 装配,见
// memory.TidySessionID)的有界后台队列,不是会话内派 subagent。它与整理
// 心跳并列启动、在 close 里先于关库停止;
// - **依赖面**:只给它记忆存储、embedder,以及 **T1 的一次性模型调用口**
// (memoryT1Judge,§15.1 D3-2 的灰区判定)。服务侧拿不到任何会话/历史读数,
// 因此「独立会话、零上下文、不碰主会话上下文」(§15 D3)是结构性成立的,
// 而不是靠自觉;
// - **审计**:接到网关的 AuditEvent(口径 memory.review.ingest)。出口在调用
// 时才解析 a.gw —— 网关比本服务晚组装(wireFrontend),与 memoryTidy 同
// 一手法。未就绪时只记日志,绝不假装写过审计。
//
// 关闭(MEMORY_REVIEW_ENABLED=false)时**不建、不接**:store 的写入路径因此
// 逐字回到本次改动之前(纯增量、无破坏性变更)。
//
// 🔴 两个开关都不在 env 里(`.env` 已冻结,design/13 §2.1),它们读
// `$DATA_DIR/config/memory-tidy.json`,与整理自动档同一个文件、**零新 env 键**:
// - `{"ingestGate": false}` ⇒ 一键回到「只插不审」(同步入库闸);
// - `{"t1Judge": false}` ⇒ 一键回到纯规则(T1 灰区判定,**零模型调用**);
// - `{"t1PerWindow": 120, "t1WindowSec": 3600}` ⇒ T1 的**全局限速**额度
// (design/23 §15.4);缺位 = 代码默认 120 次/60 分钟;
// - `{"tempTtlSec": 604800}` ⇒ 第五态 `defer` 的**临时记忆存活秒数**
// (design/03 §9.6);缺位/坏值 = 代码默认 7 天。**它没有 `.env` 档**
// (`.env` 已冻结,本键在那里从来没有出处)⇒ 实际是「面板 > 代码默认」。
func (a *app) wireMemoryReview() error {
cfg, err := config.LoadMemoryReviewConfig(envPath)
if err != nil {
return fmt.Errorf("memory review config: %w", err)
}
if !cfg.Enabled {
logger.WithSource(a.log, "memory/review").Info(
"memory ingest review disabled by configuration", "env", "MEMORY_REVIEW_ENABLED")
return nil
}
gateOff := a.memoryIngestGateOff()
// T1 灰区模型判定(design/23 §15.1 D3-2):代码默认**开**,`{"t1Judge": false}`
// 一键回到纯规则(零模型调用)。与入库闸同一个文件、同一套「没配 = 保持默认」
// 口径,**零新 env 键**。
t1Off := a.memoryT1JudgeOff()
// T1 的全局限速额度(design/23 §15.4):同样读那个文件,缺位/坏文件一律
// 交零值给 normalizeReviewConfig 回落**代码默认值**(120 次/60 分钟)——
// 坏配置绝不能让一处限速静默变成「无上限」。
t1PerWindow, t1WindowSec := a.memoryT1RateLimit()
// 临时记忆 TTL(design/03 §9.6):面板 `tempTtlSec` 优先,缺位/坏值回落代码
// 默认 **7 天**(经 normalizeReviewConfig 落地)。**中间那一档(`.env`)在本键
// 上结构性为空** —— `.env` 已冻结(design/13 §2.1),这个键在那里从来没有出处。
tempTTL := a.memoryTempTTL()
reviewer, err := memory.NewPreInsertReviewer(memory.ReviewConfig{
Enabled: cfg.Enabled,
QueueSize: cfg.QueueSize,
ScanLimit: cfg.ScanLimit,
Timeout: time.Duration(cfg.TimeoutSec) * time.Second,
// 召回门复用记忆整理的既有阈值(design/23 §13 开放点 2 = 0.80):
// 入库前审查与夜间整理必须用同一把尺子,否则同一对条目会有两个结论。
Threshold: a.cfg.MemoryTidy.RecallThreshold,
// 同步入库闸:`$DATA_DIR/config/memory-tidy.json` 的 `ingestGate: false`
// 一键回到「只插不审」(= 本次改动之前的异步行为)。预算与扫描上限走
// 代码默认值(闸的总墙钟预算必须有界,但它是**实现参数**不是运维旋钮)。
DisableIngestGate: gateOff,
// T1 的时长走代码默认值(8s,且被单条审查的 Timeout 收紧取小):
// 它是**实现参数**不是运维旋钮 —— 要让 T1 完全不花钱,就关掉它。
DisableT1: t1Off,
// T1 的**全局限速**额度(design/23 §15.4):数值来自
// `$DATA_DIR/config/memory-tidy.json` 的 `t1PerWindow` / `t1WindowSec`,
// 缺位或坏配置时是零值 ⇒ 由 normalizeReviewConfig 回落代码默认
// (120 次/60 分钟)。**没有「无上限」这个状态**:要零花费请关 t1Judge。
T1PerWindow: t1PerWindow,
T1Window: time.Duration(t1WindowSec) * time.Second,
// 第五态 `defer` 的落点(design/23 §18.1):`expires_at` = 判定时刻 + 它。
// 零值 = 没配 ⇒ 由 normalizeReviewConfig 回落代码默认 7 天(tempTtlSec)。
TempTTL: tempTTL,
}, memory.ReviewDeps{
Store: a.memStore,
Embedder: a.embedder,
Audit: a.auditMemoryReview,
Log: a.log,
Judge: a.memoryT1Judge(),
})
if err != nil {
return fmt.Errorf("memory ingest review: %w", err)
}
a.memStore.AttachIngestReviewer(reviewer)
a.memoryReview = reviewer
return nil
}
// memoryIngestGateOff 报告同步入库闸是否被配置显式关掉。读不到/解不开
// `memory-tidy.json` 时返回 false(= 保持代码默认的「开」)并记 WARN ——
// 坏配置绝不能让一处防护静默失效,也绝不能让主人以为它关了却其实开着。
func (a *app) memoryIngestGateOff() bool {
tuning, path, err := a.readMemoryTidyTuning()
if err != nil {
logger.WithSource(a.log, "memory/review").Warn(
"memory ingest gate tuning unreadable, falling back to code default (enabled)",
"path", path, "error", err)
return false
}
return tuning.IngestGateOff()
}
// memoryT1JudgeOff 报告 **T1 灰区模型判定**是否被配置显式关掉。口径与
// memoryIngestGateOff 逐字相同:读不到/解不开 `memory-tidy.json` 时返回 false
// (= 保持代码默认的「开」)并记 WARN —— 坏配置绝不能让一处防护静默失效,
// 也绝不能让主人以为它关了却其实开着。
func (a *app) memoryT1JudgeOff() bool {
tuning, path, err := a.readMemoryTidyTuning()
if err != nil {
logger.WithSource(a.log, "memory/review").Warn(
"memory t1 judge tuning unreadable, falling back to code default (enabled)",
"path", path, "error", err)
return false
}
return tuning.T1JudgeOff()
}
// memoryT1RateLimit 读 **T1 全局限速**的两项旋钮:窗口额度(次)与窗口长度(秒)。
//
// 口径与 memoryIngestGateOff / memoryT1JudgeOff 逐字相同:读不到/解不开
// `memory-tidy.json` 时返回零值(= 不覆盖)并记 WARN —— 零值经
// normalizeReviewConfig 回落**代码默认值**(120 次/60 分钟),因此坏配置既不会
// 让限速静默变成「无上限」,也不会让 T1 静默失效。
//
// 只有**两项都写了且为正**才报一条 Info(自解释的配置回执);只写了一项时静默
// 回落缺的那一项,不报「已配置」以免报出一个与生效值不符的印象。
func (a *app) memoryT1RateLimit() (perWindow int, windowSec int) {
tuning, path, err := a.readMemoryTidyTuning()
if err != nil {
logger.WithSource(a.log, "memory/review").Warn(
"memory t1 rate limit tuning unreadable, falling back to code defaults",
"path", path, "error", err)
return 0, 0
}
if tuning.T1PerWindow > 0 && tuning.T1WindowSec > 0 {
logger.WithSource(a.log, "memory/review").Info(
"memory t1 rate limit loaded",
"path", path, "t1_per_window", tuning.T1PerWindow, "t1_window_sec", tuning.T1WindowSec)
}
return tuning.T1PerWindow, tuning.T1WindowSec
}
// memoryTempTTL 返回**临时记忆 TTL**(design/03 §9.6 的 `tempTtlSec`):面板
// `$DATA_DIR/config/memory-tidy.json` 的值优先,缺位 / 坏值一律回落**代码默认
// 7 天**(返回零值即「不覆盖」,由 NewPreInsertReviewer 的 normalizeReviewConfig
// 落地 —— 与 memoryT1RateLimit 返回零值的口径逐字相同)。
//
// 解析与坏值 WARN 都交给 `memory.ApplyReviewTuning`(与 tidy_tuning.go 对
// TidyConfig 是同一手法):runtimeconfig 只管**文件形状**,而「缺位 / 坏值怎么
// 回落」属于 ReviewConfig 的归一化语义 —— 那里是**唯一**实现点,组合根不再抄一遍
// (抄一遍就意味着两条路径各自决定「什么是坏值」)。
//
// 读不到 / 解不开那个文件时返回零值 + WARN:坏配置绝不能让 TTL 静默变成
// 「不过期」,也不能让每条临时记忆**落库即过期**(一个 ≤0 的 TTL 正是那个形态)。
//
// 🔴 中间那一档(`.env`)在本键上**结构性为空**:`.env` 已冻结
// (design/13 §2.1「不加、不改、不删」),而这个键在那里**从来没有出处**。
// 因此本键的实际生效链是「**面板 > 代码默认**」——不是漏配,设计原文
// (design/03 §9.6)写的就是这一档。
func (a *app) memoryTempTTL() time.Duration {
tuning, path, err := a.readMemoryTidyTuning()
log := logger.WithSource(a.log, "memory/review")
if err != nil {
log.Warn("memory temp ttl tuning unreadable, falling back to code default (7 days)",
"path", path, "error", err)
}
return memory.ApplyReviewTuning(memory.ReviewConfig{}, tuning, log).TempTTL
}
// memoryT1Judge 把「调用时才解析的模型适配器」包成 T1 的一次性调用口。
//
// 为什么是闭包而不是直接塞 `a.modelAdapter`:组合根的顺序是 **wireMemory(建
// 记忆栈,含本服务)→ wireRuntime(建模型栈)**,装配这一刻 `a.modelAdapter`
// 还是 nil。闭包把解析推迟到调用时,于是既保住了「记忆模块只拿到一个调用口、
// 拿不到模型栈」这条依赖面(§15 D3:独立会话、零上下文),又不必改动既有装配
// 顺序 —— 与 a.gw 的晚组装是同一手法(见本文件顶部)。
//
// 用的是**既有**的模型栈本身(含按角色路由与主→备→本地降级),不新造 LLM
// 客户端;`PersonaID` 留空即「无角色绑定」,由生效组的默认链服务它。模型栈仍
// 未就绪时返回 errT1ModelNotReady:那是 fail-safe(回落 create + WARN)。
//
// 读 `a.modelAdapter` 无锁是安全的:它在 wireRuntime 里被**写一次**,而任何
// 读到它的路径(审查工作协程)都在 main 里 `memoryReview.Start` 之后才开始,
// 写入严格先于读取。
func (a *app) memoryT1Judge() memory.T1Judge {
return func(ctx context.Context, req model.Request, opts model.Options) (model.Response, error) {
adapter := a.modelAdapter
if adapter == nil {
return model.Response{}, errT1ModelNotReady
}
return adapter.Chat(ctx, req, opts)
}
}
// auditMemoryReview 把一条审查结论写进网关审计链。它**只带决定、依据与 id**,
// 绝不带任何记忆正文(design §15 D3-8「记忆正文不进日志」)。
func (a *app) auditMemoryReview(who, what, result string) {
if a.gw != nil {
a.gw.AuditEvent(who, what, result)
return
}
logger.WithSource(a.log, "memory/review").Info(
"memory ingest review audit dropped: gateway not ready",
"who", who, "what", what, "result", result)
}
+402
View File
@@ -0,0 +1,402 @@
package main
import (
"bytes"
"context"
"errors"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件锁定 D3「入库前审查」(design/23 §15.1 D3)在**组合根**上的三件事:
// 1. 跑在与整理同一个专属隔离会话(sess-memorytidy),不是主人的对话车道;
// 2. 接线存在(服务被装配、被接到 store 的写入路径、被启动、被停止),
// 少一处就会**静默不工作**(不报错、不审查、日志里一句都没有);
// 3. 「独立会话、不碰主会话上下文」是**可验证**的:审查跑完后主会话的消息条数、
// 最后一条消息与已读游标逐字不变,且 chat_messages 里没有审查会话的行。
// reviewEmbedder 是组合根测试用的确定性 embedder:所有文本同向(余弦 1),
// 因此「同内容的两条记忆」一定被召回,审查的判定路径真的会跑到。
type reviewEmbedder struct{}
func (reviewEmbedder) Embed(_ context.Context, texts []string) ([]embedding.Vector, error) {
out := make([]embedding.Vector, 0, len(texts))
for range texts {
out = append(out, embedding.Vector{1, 0})
}
return out, nil
}
func (reviewEmbedder) Dim() int { return 2 }
// TestMemoryReviewIsWired 是接线存在性防回归:组合根少任何一处,入库前审查
// 都会静默不工作,而这不会让别的用例变红。
//
// 刻意**不**在这里断言 `.env.example`:该文件由并行切片(MT-A)持有,
// 新增的 MEMORY_REVIEW_* 键已登记在 MTB-report.md 里,由主会话统一补模板。
func TestMemoryReviewIsWired(t *testing.T) {
t.Parallel()
seams := map[string][]string{
"wire_runtime.go": {
// 记忆栈建好后就装配审查(它只需要 store + embedder)。
"a.wireMemoryReview()",
},
"wire_memory_review.go": {
"config.LoadMemoryReviewConfig(envPath)",
"memory.NewPreInsertReviewer(memory.ReviewConfig{",
// 依赖面里**没有**任何会话/历史读数 —— 这是「独立会话、零上下文」的
// 结构性保证,而不是靠自觉。
"Audit: a.auditMemoryReview",
// 接到 store 的唯一写入路径上。
"a.memStore.AttachIngestReviewer(reviewer)",
// 同步入库闸(2026-10-07)的开关来自 `$DATA_DIR/config/memory-tidy.json`
// 的 `{"ingestGate": false}`(**零新 env 键**,`.env` 已冻结)。
"a.memoryIngestGateOff()",
"DisableIngestGate: gateOff",
// T1 灰区模型判定(design/23 §15.1 D3-2):开关同样来自那个文件
// (`{"t1Judge": false}`),模型出口**在调用时才解析**(本服务早于
// 模型栈装配,与 a.gw 的晚组装同一手法)。
"a.memoryT1JudgeOff()",
"DisableT1: t1Off",
"Judge: a.memoryT1Judge()",
// T1 的**全局限速**额度(design/23 §15.4)同样来自那个文件,
// 缺位/坏文件一律回落代码默认(120 次/60 分钟)。
"a.memoryT1RateLimit()",
"T1PerWindow: t1PerWindow",
"T1Window: time.Duration(t1WindowSec) * time.Second",
},
"main.go": {
"a.memoryReview.Start(ctx)",
},
"wire_services.go": {
// 关库之前先停审查(它要写 memory_items)。
"a.memoryReview.Stop(ctx)",
},
}
for file, wants := range seams {
src, err := os.ReadFile(file)
if err != nil {
t.Fatalf("read %s: %v", file, err)
}
for _, want := range wants {
if !strings.Contains(string(src), want) {
t.Fatalf("%s 里找不到 %q —— 入库前审查没有被接上组合根", file, want)
}
}
}
// store 侧的唯一写入路径必须投递审查(否则新记忆永远不进队列),
// 且必须**先过同步入库闸**(2026-10-07:闸就摆在写路径上,重复当场落 merged)。
storeSrc, err := os.ReadFile("../../internal/runtime/memory/store.go")
if err != nil {
t.Fatalf("read memory/store.go: %v", err)
}
for _, want := range []string{"s.enqueueReview(item)", "s.planIngest(ctx, item)", "s.finishIngest(ctx, item, plan)"} {
if !strings.Contains(string(storeSrc), want) {
t.Fatalf("memory/store.go 的写入路径上找不到 %q(唯一写路径上的闸/投递断了)", want)
}
}
}
// TestMemoryIngestGate_ReadsDataDirConfig 钉住**同步入库闸**的开关确实来自
// `$DATA_DIR/config/memory-tidy.json`(它没有 env 键:`.env` 已冻结)——
// 若组合根没读那个文件,「一键回到只插不审」就是一句空话。
//
// 口径与自动档同一条:没配 = 开;显式 false = 关;坏文件 = 回落代码默认(开)
// 并记 WARN,**绝不静默把一处防护关掉**。
func TestMemoryIngestGate_ReadsDataDirConfig(t *testing.T) {
t.Parallel()
empty := &app{cfg: &config.Config{DataDir: t.TempDir()}, log: tidyTestLog()}
if empty.memoryIngestGateOff() {
t.Fatal("没配过时入库闸应当保持代码默认(开)")
}
dataDir := t.TempDir()
dir := filepath.Join(dataDir, "config")
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatalf("mkdir: %v", err)
}
path := filepath.Join(dir, "memory-tidy.json")
if err := os.WriteFile(path, []byte(`{"ingestGate": false}`), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
off := &app{cfg: &config.Config{DataDir: dataDir}, log: tidyTestLog()}
if !off.memoryIngestGateOff() {
t.Fatal("ingestGate: false 没被读到(闸没有一键关掉的路径)")
}
if err := os.WriteFile(path, []byte(`{"ingestGate": "nope"}`), 0o600); err != nil {
t.Fatalf("write broken: %v", err)
}
broken := &app{cfg: &config.Config{DataDir: dataDir}, log: tidyTestLog()}
if broken.memoryIngestGateOff() {
t.Fatal("坏配置把入库闸静默关掉了:必须回落代码默认(开)")
}
}
// TestMemoryT1Judge_ReadsDataDirConfig 钉住 **T1 灰区模型判定**的开关确实来自
// `$DATA_DIR/config/memory-tidy.json` 的 `t1Judge`(它没有 env 键:`.env` 已冻结)
// —— T1 是一处**会花掉模型额度**的动作,「一行配置关掉」必须真的成立。
//
// 口径与入库闸同一条:没配 = 开(主人 2026-10-07 要的就是「真的走记忆管理会话」);
// 显式 false = 关(回落纯规则、零模型调用);坏文件 = 回落代码默认(开)并记
// WARN,**绝不静默把一处防护关掉**。
func TestMemoryT1Judge_ReadsDataDirConfig(t *testing.T) {
t.Parallel()
empty := &app{cfg: &config.Config{DataDir: t.TempDir()}, log: tidyTestLog()}
if empty.memoryT1JudgeOff() {
t.Fatal("没配过时 T1 应当保持代码默认(开)")
}
dataDir := t.TempDir()
dir := filepath.Join(dataDir, "config")
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatalf("mkdir: %v", err)
}
path := filepath.Join(dir, "memory-tidy.json")
// 只写一个上限键:它不许把 T1 静默关掉(指针字段的存在理由)。
if err := os.WriteFile(path, []byte(`{"autoApplyPerRound": 5}`), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
partial := &app{cfg: &config.Config{DataDir: dataDir}, log: tidyTestLog()}
if partial.memoryT1JudgeOff() {
t.Fatal("只写了别的键时 T1 被静默关掉了(必须保持代码默认的「开」)")
}
if err := os.WriteFile(path, []byte(`{"t1Judge": false}`), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
off := &app{cfg: &config.Config{DataDir: dataDir}, log: tidyTestLog()}
if !off.memoryT1JudgeOff() {
t.Fatal("t1Judge: false 没被读到(T1 没有一键关掉的路径)")
}
if err := os.WriteFile(path, []byte(`{"t1Judge": "nope"}`), 0o600); err != nil {
t.Fatalf("write broken: %v", err)
}
broken := &app{cfg: &config.Config{DataDir: dataDir}, log: tidyTestLog()}
if broken.memoryT1JudgeOff() {
t.Fatal("坏配置把 T1 静默关掉了:必须回落代码默认(开)")
}
}
// TestMemoryT1RateLimit_ReadsDataDirConfig 钉住 **T1 全局限速**的额度确实来自
// `$DATA_DIR/config/memory-tidy.json` 的 `t1PerWindow` / `t1WindowSec`
// (design/23 §15.4):T1 默认开,每次有灰区候选的审查就发一次调用 ⇒ 额度必须能
// 按现网节奏调,而 `.env` 已冻结、不许为它开 env 键。
//
// 口径与入库闸/T1 开关同一条:没配 = 返回零值(交给 normalizeReviewConfig 回落
// 代码默认 120 次/60 分钟);坏文件 = 同样回落默认额 **并记 WARN** ——
// 坏配置绝不能让限速静默变成「无上限」。
func TestMemoryT1RateLimit_ReadsDataDirConfig(t *testing.T) {
t.Parallel()
empty := &app{cfg: &config.Config{DataDir: t.TempDir()}, log: tidyTestLog()}
if perWindow, windowSec := empty.memoryT1RateLimit(); perWindow != 0 || windowSec != 0 {
t.Fatalf("没配过时应当返回零值(= 回落代码默认):%d/%d", perWindow, windowSec)
}
dataDir := t.TempDir()
dir := filepath.Join(dataDir, "config")
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatalf("mkdir: %v", err)
}
path := filepath.Join(dir, "memory-tidy.json")
if err := os.WriteFile(path, []byte(`{"t1PerWindow": 240, "t1WindowSec": 1800}`), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
set := &app{cfg: &config.Config{DataDir: dataDir}, log: tidyTestLog()}
if perWindow, windowSec := set.memoryT1RateLimit(); perWindow != 240 || windowSec != 1800 {
t.Fatalf("额度没被读到:%d/%d, want 240/1800", perWindow, windowSec)
}
// 坏配置:回落默认额(零值)+ 一条 WARN,绝不静默不限流。
if err := os.WriteFile(path, []byte(`{"t1PerWindow": "many"}`), 0o600); err != nil {
t.Fatalf("write broken: %v", err)
}
var buf bytes.Buffer
broken := &app{cfg: &config.Config{DataDir: dataDir},
log: slog.New(slog.NewTextHandler(&buf, nil))}
if perWindow, windowSec := broken.memoryT1RateLimit(); perWindow != 0 || windowSec != 0 {
t.Fatalf("坏配置下应当回落零值:%d/%d", perWindow, windowSec)
}
if logged := buf.String(); !strings.Contains(logged, "t1 rate limit tuning unreadable") ||
!strings.Contains(logged, "level=WARN") {
t.Fatalf("坏配置没有留下 WARN:%q", logged)
}
}
// TestMemoryT1Judge_UsesTheWiredModelStack 钉住 T1 的模型出口**复用既有模型栈**
// (不新造 LLM 客户端),且返回一个闭包而不是当场取值 —— 本服务在 wireMemory
// 阶段装配,而 a.modelAdapter 要等更晚的 wireRuntime 才建好。
func TestMemoryT1Judge_UsesTheWiredModelStack(t *testing.T) {
t.Parallel()
a := &app{log: tidyTestLog()}
judge := a.memoryT1Judge()
if judge == nil {
t.Fatal("memoryT1Judge 返回了 nil")
}
// 模型栈还没建好:必须 fail-safe(返回错误),绝不 panic、绝不假装成功。
if _, err := judge(context.Background(), model.Request{}, model.Options{}); !errors.Is(err, errT1ModelNotReady) {
t.Fatalf("模型栈未就绪时 err = %v, want %v", err, errT1ModelNotReady)
}
// 接上模型栈后,同一个闭包就能发请求(晚解析的证据)。
stub := &reviewAdapterStub{reply: "{}"}
a.modelAdapter = stub
if _, err := judge(context.Background(), model.Request{Purpose: "memory_t1_judge"}, model.Options{}); err != nil {
t.Fatalf("接上模型栈后调用失败:%v", err)
}
if stub.calls != 1 {
t.Fatalf("模型栈被调用 %d 次, want 1", stub.calls)
}
}
// reviewAdapterStub 是最小的 model.Adapter 桩(只为验「闭包解析到既有的那个
// 模型栈」这件事,不参与判定正确性)。
type reviewAdapterStub struct {
calls int
reply string
}
func (s *reviewAdapterStub) Name() string { return "stub" }
func (s *reviewAdapterStub) Chat(context.Context, model.Request, model.Options) (model.Response, error) {
s.calls++
return model.Response{Content: s.reply}, nil
}
func (s *reviewAdapterStub) ChatStream(
ctx context.Context, req model.Request, onEvent func(model.StreamEvent),
) (model.Response, error) {
return s.Chat(ctx, req, model.Options{})
}
// TestMemoryReviewSessionIsNotConversationLane 与整理同一条口径:审查会话不落在
// 主人的对话车道("web" / "web:<uuid>")上,因此不改她的心情、不进主会话泳道。
func TestMemoryReviewSessionIsNotConversationLane(t *testing.T) {
t.Parallel()
if isConversationLane(memory.TidySessionID) {
t.Fatalf("审查会话 %q 被判成主人对话回合", memory.TidySessionID)
}
if !isConversationLane(history.MainSessionID) {
t.Fatal("主人车道判定被改坏了")
}
}
// TestMemoryReviewLeavesChatHistoryUntouched 是 design/23 §15 D3-7 的可验证版本:
// 审查真的干活(把重复行折回既有行)之后,主会话的消息条数、最后一条消息与
// read_at 游标逐字不变,且 chat_messages 里没有任何属于审查会话的行。
func TestMemoryReviewLeavesChatHistoryUntouched(t *testing.T) {
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)
}
hist, err := history.NewStore(ctx, conn, tidyTestLog())
if err != nil {
t.Fatalf("history store: %v", err)
}
if _, err := hist.AppendFull(ctx, history.Message{
SessionID: history.MainSessionID, Role: history.RoleUser,
Content: "今晚又要加班到很晚", CreatedAt: 1_700_000_000_000,
}); err != nil {
t.Fatalf("append user message: %v", err)
}
last, err := hist.AppendFull(ctx, history.Message{
SessionID: history.MainSessionID, Role: history.RoleAssistant,
Content: "别熬太晚,我陪你", CreatedAt: 1_700_000_001_000,
})
if err != nil {
t.Fatalf("append assistant message: %v", err)
}
if _, err := hist.MarkSessionRead(ctx, history.MainSessionID, time.UnixMilli(1_700_000_000_500)); err != nil {
t.Fatalf("mark read: %v", err)
}
before := readConversationFingerprint(t, ctx, conn, history.MainSessionID)
store, err := memory.NewStore(ctx, conn, tidyTestLog())
if err != nil {
t.Fatalf("memory store: %v", err)
}
content := "主人常在深夜与助手聊天,有熬夜习惯"
seed := func(at time.Time) memory.MemoryItem {
got, err := store.Add(ctx, memory.MemoryItem{
PersonaID: "persona.a", Scope: memory.ScopePersona, Kind: memory.KindRelationship,
Content: content, Confidence: 0.9, CreatedAt: at,
SourceConversation: memory.FormatSourceConversation(history.MainSessionID, "turn-1"),
})
if err != nil {
t.Fatalf("seed memory: %v", err)
}
return got
}
seed(time.UnixMilli(1_700_000_000_000))
reviewer, err := memory.NewPreInsertReviewer(memory.ReviewConfig{
Enabled: true, QueueSize: 8, ScanLimit: 20, Timeout: 5 * time.Second, Threshold: 0.8,
}, memory.ReviewDeps{Store: store, Embedder: reviewEmbedder{}, Log: tidyTestLog()})
if err != nil {
t.Fatalf("NewPreInsertReviewer: %v", err)
}
store.AttachIngestReviewer(reviewer)
reviewer.Start(ctx)
defer func() {
stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = reviewer.Stop(stopCtx)
}()
dup := seed(time.UnixMilli(1_700_000_060_000))
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
var status string
if err := conn.QueryRowContext(ctx,
"SELECT status FROM memory_items WHERE id = ?", dup.ID).Scan(&status); err != nil {
t.Fatalf("read dup status: %v", err)
}
if status == "merged" {
break
}
time.Sleep(5 * time.Millisecond)
}
after := readConversationFingerprint(t, ctx, conn, history.MainSessionID)
if before != after {
t.Fatalf("审查改动了主会话上下文:\n before=%+v\n after=%+v", before, after)
}
if after.lastContent != last.Content || after.lastID != last.ID {
t.Fatalf("主会话最后一条消息变了:%+v", after)
}
var reviewMsgs int
if err := conn.QueryRowContext(ctx,
"SELECT count(*) FROM chat_messages WHERE session_id = ?", memory.TidySessionID).Scan(&reviewMsgs); err != nil {
t.Fatalf("count review messages: %v", err)
}
if reviewMsgs != 0 {
t.Fatalf("审查向 chat_messages 写入了 %d 行(§15 D3-7 要求零写入)", reviewMsgs)
}
reviews, err := store.ListIngestReviews(ctx, 10)
if err != nil {
t.Fatalf("ListIngestReviews: %v", err)
}
if len(reviews) != 1 || reviews[0].Decision != memory.IngestSkip {
t.Fatalf("审查留痕 = %+v, want 恰为一条 skip", reviews)
}
}
+139
View File
@@ -0,0 +1,139 @@
package main
import (
"fmt"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
)
// 本文件把记忆整理(DevDocs/design/23-记忆整理与去重 §4)接进组合根。
//
// 四件事只在这里定:
// - **形态**:定期后台任务(安静时段降频)的心跳(memory.TidyService),不是
// 会话内派 subagent。它在 main.go 与 backthink 并列启动、在 close 里先于关库停止;
// - **依赖面**:只给它记忆存储与 embedder —— 服务侧拿不到任何会话/历史读数,
// 因此 design §4.1「绝不碰主会话上下文」是结构性成立的,而不是靠自觉;
// - **审计**:接到网关的 AuditEvent(design §8 的 memory.tidy.*),出口在调用
// 时才解析 a.gw —— 网关比本服务晚组装(wireFrontend),与 onToolAudit
// 同一手法。未就绪时只记日志,绝不假装写过审计;
// - **运行期调优**(2026-10-07 新增):从 `$DATA_DIR/config/memory-tidy.json`
// 读「自动落地开关 + 每轮上限」,以及(同日追加)**整理频率**四项
// (intervalSec / quietIntervalSec / quietStartHour / quietEndHour)。它是
// `.env` 冻结(design/13 §2.1)之后新旋钮的唯一去处,**零新 env 键**;面板优先、
// `.env` 兜底,键缺位或值不可用一律回落下一档并记 WARN(坏配置绝不能让自动档
// 变成「无限制」,也不能让整理频率变成忙轮询);
// - **启动播种**(2026-10-07 追加,主人「后端**自动创建**独立的面板配置文件**要做**」):
// 那个文件不存在时由后端自己写一份「当前生效值」进去(seedMemoryTidyPanel),
// 使「面板优先」从第一次启动起就成立,而不是靠 `.env` 一直兜着。
//
// 刻意**不做**的一件事(design §4.1 F):
// - 不把 sess-memorytidy 加进 flowPub 的图源白名单(默认关:整理是系统任务,
// 不是主人的会话)。
func (a *app) wireMemoryTidy() error {
cfg := memory.TidyConfigFromConfig(a.cfg.MemoryTidy)
a.applyMemoryTidyTuning(&cfg)
// 启动播种(主人 2026-10-07:「后端**自动创建**独立的面板配置文件**要做**」):
// 文件不存在/空白 ⇒ 把**此刻的生效值**写成面板的一等来源,此后 `.env` 只兜底。
// 放在 applyMemoryTidyTuning **之后**是刻意的:那一刻的 cfg 就是
// 「面板 > `.env` > 代码默认」的结果(面板缺失时即 `.env` + 默认),播它 ⇒ 行为
// 零变化。文件已存在(含损坏)⇒ 一个字节都不动(幂等,见 runtimeconfig.SeedMemoryTidy)。
a.seedMemoryTidyPanel(cfg)
svc, err := memory.NewTidyService(cfg, memory.TidyDeps{
Store: a.memStore,
Embedder: a.embedder,
Audit: a.auditMemoryTidy,
Log: logger.WithSource(a.log, "memory/tidy"),
})
if err != nil {
return fmt.Errorf("memory tidy: %w", err)
}
a.memoryTidy = svc
return nil
}
// seedMemoryTidyPanel 在 `$DATA_DIR/config/memory-tidy.json` 缺失/空白时播种一份
// 「当前生效值」快照(`memory.TidyTuningSnapshot`),使面板配置从第一次启动起就自足,
// 而不是每次都靠 `.env` 兜。失败只记日志、**绝不阻断启动**:运行期仍按 `.env` +
// 代码默认跑,行为与播种前逐字相同(与 materializeModels 同一口径)。
//
// 闸与 T1 的四键(ingestGate / t1Judge / t1PerWindow / t1WindowSec)在 `.env` 里
// **没有出处**(`.env` 已冻结,它们只由这个面板文件与代码默认决定,见
// wireMemoryReview 的三个 helper),而播种只发生在**面板文件不存在**时 —— 那正是
// 「零值 + normalizeReviewConfig」这一条路径,因此这里给零值 ReviewConfig 就是
// **当时**的生效值,不是猜的。审查开关本身(MEMORY_REVIEW_ENABLED)不在这十键里,
// 因此不必为它去读一次 env。
func (a *app) seedMemoryTidyPanel(tidy memory.TidyConfig) {
path := runtimeconfig.DefaultMemoryTidyPath(resolveDataDir(a.cfg))
log := logger.WithSource(a.log, "memory/tidy")
// 播种函数自己会把「跳过 / 无法解析」记成带路径的日志(含 WARN),这里只报
// 「该写却没写成」那一种 —— 不重复记同一条 WARN。
if _, err := runtimeconfig.SeedMemoryTidy(path, memory.TidyTuningSnapshot(tidy, memory.ReviewConfig{}), log); err != nil {
log.Warn("memory-tidy.json 播种失败(面板配置仍缺位,.env 继续兜底)", "path", path, "error", err)
}
}
// applyMemoryTidyTuning 把 `$DATA_DIR/config/memory-tidy.json` 叠到 env 侧的
// TidyConfig 上(见 runtimeconfig.MemoryTidyTuning 的零值口径):
// - 文件里**没写** autoApply ⇒ 保持代码默认(开);
// - 写 `false` ⇒ DisableAutoApply = true(一键回到「只产建议」,不必改代码);
// - autoApplyPerRound ≤ 0 ⇒ 不覆盖,交给 normalizeTidyConfig 填默认上限;
// - **调度四键**(intervalSec / quietIntervalSec / quietStartHour /
// quietEndHour)⇒ 交 memory.ApplyTidyScheduleTuning 逐键解析(面板 >
// `.env` > 代码默认),坏值回落 + WARN。
//
// 读不到/解不开时只记 WARN 并保持 env 侧的值:那正是「回落下一档」——坏文件时
// tuning 是零值(四个调度键都是 nil),因此 ApplyTidyScheduleTuning 一个键都不覆盖,
// env 值原样留着(`.env` 也缺位时由 normalizeTidyConfig 落到代码默认)。
func (a *app) applyMemoryTidyTuning(cfg *memory.TidyConfig) {
tuning, path, err := a.readMemoryTidyTuning()
log := logger.WithSource(a.log, "memory/tidy")
if err != nil {
log.Warn(
"memory tidy tuning unreadable, falling back to the env layer (.env values kept)",
"path", path, "error", err)
} else {
cfg.DisableAutoApply = tuning.AutoApplyOff()
if tuning.AutoApplyPerRound > 0 {
cfg.AutoApplyPerRound = tuning.AutoApplyPerRound
}
}
// 调度四键在两种情形下都要过一遍:成功时按面板值覆盖,失败时 tuning 是零值
// ⇒ 原样返回(= 保持 env 值),因此不必为它再分一条路径。
*cfg = memory.ApplyTidyScheduleTuning(*cfg, tuning, log)
// 这里打的是**归一化之前**的值(quiet_interval_sec 可能还是 0 = 「缺省,等于
// intervalSec」):真正生效的两档周期由 TidyService.Start 的启动日志报。
// panel_decoded = 「文件缺失/空白/解析成功」三者之一(LoadMemoryTidyTuning 把
// 前两者都当作「还没配过」而不是错误),为 false 即坏文件 ⇒ 四个键都回落 `.env`。
log.Info(
"memory tidy tuning applied (panel > .env > code defaults)",
"path", path, "panel_decoded", err == nil, "auto_apply", !cfg.DisableAutoApply,
"auto_apply_per_round", cfg.AutoApplyPerRound,
"interval_sec", cfg.IntervalSec, "quiet_interval_sec", cfg.QuietIntervalSec,
"quiet_hours", fmt.Sprintf("%02d:00-%02d:00", cfg.QuietStartHour, cfg.QuietEndHour))
}
// readMemoryTidyTuning 读一次 `$DATA_DIR/config/memory-tidy.json`(**只读**)。
// 它把「落点 + 读取 + 路径回报」收在一处,供整理自动档与**同步入库闸**两个
// 消费者共用 —— 后者(wireMemoryReview)不属于整理服务,但它的开关就在同一个
// 文件里(零新 env 键,design/13 §2.1)。错误由调用方记 WARN 并回落代码默认值。
func (a *app) readMemoryTidyTuning() (runtimeconfig.MemoryTidyTuning, string, error) {
path := runtimeconfig.DefaultMemoryTidyPath(resolveDataDir(a.cfg))
tuning, err := runtimeconfig.LoadMemoryTidyTuning(path)
if err != nil {
return runtimeconfig.MemoryTidyTuning{}, path, err
}
return tuning, path, nil
}
// auditMemoryTidy 把一轮整理的计数写进网关审计链。它**只带计数**,绝不带
// 任何记忆正文(design §8 的 who/what/when/result 口径)。
func (a *app) auditMemoryTidy(who, what, result string) {
if a.gw != nil {
a.gw.AuditEvent(who, what, result)
return
}
logger.WithSource(a.log, "memory/tidy").Info(
"memory tidy audit dropped: gateway not ready", "who", who, "what", what, "result", result)
}
+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)
}
}
+327
View File
@@ -0,0 +1,327 @@
package main
import (
"context"
"database/sql"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
)
// 本文件锁定记忆整理(design/23 §4.1)在**组合根**上的三件事:
// 1. 跑在专属隔离会话,绝不是主人的对话车道(isConversationLane 对它恒 false);
// 2. 接线存在(服务被装配、被启动、被停止,env 键同步),少一处就会静默不工作;
// 3. 「绝不碰主会话上下文」是**可验证**的:跑完一轮整理后,主会话的消息条数、
// 最后一条消息、已读游标逐字不变,且对 chat_messages 零写入。
// tidyTestLog 返回不写任何地方的 logger,保持测试输出干净。
func tidyTestLog() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// TestMemoryTidySessionIsNotConversationLane 是 design/23 §4.1 B 的对外断言:
// 整理会话不落在主人的对话车道("web" / "web:<uuid>")上。
func TestMemoryTidySessionIsNotConversationLane(t *testing.T) {
t.Parallel()
if isConversationLane(memory.TidySessionID) {
t.Fatalf("整理会话 %q 被判成主人对话回合(会改写她的心情、进主会话泳道)", memory.TidySessionID)
}
// 对照组:主人车道本身必须仍然为 true(避免把断言写成「函数恒 false」)。
if !isConversationLane(history.MainSessionID) || !isConversationLane(history.MainSessionID+":sub-1") {
t.Fatal("主人车道判定被改坏了")
}
}
// TestMemoryTidyIsWired 是接线存在性防回归:组合根少任何一处,整理都会**静默
// 不工作**(不报错、不产候选、日志里一句都没有),而这不会让别的用例变红。
func TestMemoryTidyIsWired(t *testing.T) {
t.Parallel()
seams := map[string][]string{
"wire_runtime.go": {
// 记忆栈建好后就装配整理(它只需要 store + embedder)。
"a.wireMemoryTidy()",
"a.embedder = embedder",
},
"wire_memory_tidy.go": {
"memory.NewTidyService(cfg, memory.TidyDeps{",
// 调优(自动落地开关 + 每轮上限)从 $DATA_DIR/config/memory-tidy.json
// 来,**零新 env 键**(`.env` 已冻结,design/13 §2.1)。少了这一句,
// 那个文件就成了「写了没人读」的摆设,而自动档仍然开着 —— 静默。
"runtimeconfig.LoadMemoryTidyTuning",
// 启动播种(2026-10-07 追加,主人「后端**自动创建**独立的面板配置文件
// **要做**」):少了这两句,那个文件在生产里**根本不存在** ⇒「面板优先」
// 永远是空话,生效的仍是 `.env` 兜底那一档 —— 而不会有任何用例变红。
"a.seedMemoryTidyPanel(cfg)",
"runtimeconfig.SeedMemoryTidy(",
// 依赖面里**没有**任何会话/历史读数 —— 这是「绝不碰主会话上下文」
// 的结构性保证,而不是靠自觉。
"Audit: a.auditMemoryTidy",
},
"main.go": {
"a.memoryTidy.Start(ctx)",
},
"wire_services.go": {
// 关库之前先停整理(它每轮要用连接池)。
"a.memoryTidy.Stop(ctx)",
},
}
for file, wants := range seams {
src, err := os.ReadFile(file)
if err != nil {
t.Fatalf("read %s: %v", file, err)
}
for _, want := range wants {
if !strings.Contains(string(src), want) {
t.Fatalf("%s 里找不到 %q —— 记忆整理没有被接上组合根", file, want)
}
}
}
raw, err := os.ReadFile(filepath.Join("..", "..", ".env.example"))
if err != nil {
t.Fatalf("read .env.example: %v", err)
}
for _, key := range []string{
"MEMORY_TIDY_ENABLED=", "MEMORY_TIDY_INTERVAL_SEC=", "MEMORY_TIDY_QUIET_START_HOUR=",
"MEMORY_TIDY_QUIET_END_HOUR=", "MEMORY_TIDY_RECALL_THRESHOLD=",
"MEMORY_TIDY_SCAN_LIMIT=", "MEMORY_TIDY_CANDIDATE_LIMIT=",
} {
if !strings.Contains(string(raw), key) {
t.Fatalf(".env.example 里找不到 %q(新 env 键必须同步模板,AGENTS §10 协同 E6)", key)
}
}
}
// TestMemoryTidyRunLeavesChatHistoryUntouched 是 design/23 §4.1 E 的可验证版本:
// 整理跑完一轮后,主会话的消息条数、最后一条消息、read_at 游标**逐字不变**,
// 且 chat_messages 里没有任何属于整理会话的行。
//
// 用例让整理真的干活(有可合并的一对),否则断言会是空的。
func TestMemoryTidyRunLeavesChatHistoryUntouched(t *testing.T) {
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)
}
hist, err := history.NewStore(ctx, conn, tidyTestLog())
if err != nil {
t.Fatalf("history store: %v", err)
}
if _, err := hist.AppendFull(ctx, history.Message{
SessionID: history.MainSessionID, Role: history.RoleUser, Content: "今晚又要加班到很晚", CreatedAt: 1_700_000_000_000,
}); err != nil {
t.Fatalf("append user message: %v", err)
}
last, err := hist.AppendFull(ctx, history.Message{
SessionID: history.MainSessionID, Role: history.RoleAssistant, Content: "别熬太晚,我陪你", CreatedAt: 1_700_000_001_000,
})
if err != nil {
t.Fatalf("append assistant message: %v", err)
}
if _, err := hist.MarkSessionRead(ctx, history.MainSessionID, time.UnixMilli(1_700_000_000_500)); err != nil {
t.Fatalf("mark read: %v", err)
}
before := readConversationFingerprint(t, ctx, conn, history.MainSessionID)
store, err := memory.NewStore(ctx, conn, tidyTestLog())
if err != nil {
t.Fatalf("memory store: %v", err)
}
seed := []string{"主人常在深夜与助手聊天,有熬夜习惯", "主人常在深夜与助手聊天,有熬夜习惯,作息偏晚"}
for i, content := range seed {
if _, err := store.Add(ctx, memory.MemoryItem{
PersonaID: "persona.a", Scope: memory.ScopePersona, Kind: memory.KindRelationship,
Content: content, Confidence: 0.9,
CreatedAt: time.UnixMilli(1_700_000_000_000 + int64(i)*60_000),
}); err != nil {
t.Fatalf("seed memory %d: %v", i, err)
}
}
svc, err := memory.NewTidyService(memory.TidyConfigFromConfig(config.MemoryTidyConfig{
Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7,
RecallThreshold: 0.80, ScanLimit: 100, CandidateLimit: 20,
}), memory.TidyDeps{Store: store, Log: tidyTestLog()})
if err != nil {
t.Fatalf("tidy service: %v", err)
}
stats, err := svc.RunOnce(ctx)
if err != nil {
t.Fatalf("tidy RunOnce: %v", err)
}
if stats.Scanned != 2 || stats.Candidates == 0 {
t.Fatalf("整理没真干活,断言会落空:%+v", stats)
}
after := readConversationFingerprint(t, ctx, conn, history.MainSessionID)
if before != after {
t.Fatalf("整理改动了主会话上下文:\n before=%+v\n after=%+v", before, after)
}
if after.lastContent != last.Content || after.lastID != last.ID {
t.Fatalf("主会话最后一条消息变了:%+v", after)
}
var tidyMsgs int
if err := conn.QueryRowContext(ctx,
"SELECT count(*) FROM chat_messages WHERE session_id = ?", memory.TidySessionID).Scan(&tidyMsgs); err != nil {
t.Fatalf("count tidy messages: %v", err)
}
if tidyMsgs != 0 {
t.Fatalf("整理向 chat_messages 写入了 %d 行(design §4.1 D 要求零写入)", tidyMsgs)
}
}
// conversationFingerprint 是主会话的「逐字不变」指纹:消息条数、最后一条消息
// 的 id 与正文、以及会话已读游标。
type conversationFingerprint struct {
messages int
lastID string
lastContent string
readAt int64
}
// readConversationFingerprint 直接读 chat_messages / chat_sessions(主会话只由
// 消息写入路径触碰,因此这是「整理有没有碰会话」最直接的证据)。
func readConversationFingerprint(t *testing.T, ctx context.Context, conn *sql.DB, sessionID string) conversationFingerprint {
t.Helper()
var fp conversationFingerprint
if err := conn.QueryRowContext(ctx,
"SELECT count(*) FROM chat_messages WHERE session_id = ?", sessionID).Scan(&fp.messages); err != nil {
t.Fatalf("count messages: %v", err)
}
if err := conn.QueryRowContext(ctx,
"SELECT id, content FROM chat_messages WHERE session_id = ? ORDER BY created_at DESC, rowid DESC LIMIT 1",
sessionID).Scan(&fp.lastID, &fp.lastContent); err != nil {
t.Fatalf("read last message: %v", err)
}
if err := conn.QueryRowContext(ctx,
"SELECT read_at FROM chat_sessions WHERE id = ?", sessionID).Scan(&fp.readAt); err != nil {
t.Fatalf("read cursor: %v", err)
}
return fp
}
// TestApplyMemoryTidyTuning_ReadsDataDirConfig 钉住「自动档开关确实来自
// $DATA_DIR/config/memory-tidy.json」这条接线:它没有 env 键(`.env` 已冻结),
// 因此若组合根没读那个文件,「一键回到只产建议」就是一句空话 —— 文件写了没人看。
func TestApplyMemoryTidyTuning_ReadsDataDirConfig(t *testing.T) {
t.Parallel()
// ① 文件不存在 ⇒ 保持代码默认(自动档开着)。
empty := &app{cfg: &config.Config{DataDir: t.TempDir()}, log: tidyTestLog()}
cfg := memory.TidyConfigFromConfig(config.MemoryTidyConfig{Enabled: true})
empty.applyMemoryTidyTuning(&cfg)
if cfg.DisableAutoApply || cfg.AutoApplyPerRound != 0 {
t.Fatalf("没配过时 cfg = %+v, want 保持零值(由 normalize 填代码默认)", cfg)
}
// ② 写 {"autoApply": false, "autoApplyPerRound": 5} ⇒ 关掉 + 抬高上限。
dataDir := t.TempDir()
dir := filepath.Join(dataDir, "config")
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatalf("mkdir: %v", err)
}
path := filepath.Join(dir, "memory-tidy.json")
if err := os.WriteFile(path, []byte(`{"autoApply": false, "autoApplyPerRound": 5}`), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
off := &app{cfg: &config.Config{DataDir: dataDir}, log: tidyTestLog()}
cfg = memory.TidyConfigFromConfig(config.MemoryTidyConfig{Enabled: true})
off.applyMemoryTidyTuning(&cfg)
if !cfg.DisableAutoApply || cfg.AutoApplyPerRound != 5 {
t.Fatalf("cfg = %+v, want DisableAutoApply=true 且 AutoApplyPerRound=5", cfg)
}
// ③ 坏文件 ⇒ 记 WARN 并回落代码默认(绝不把自动档读成「无限制」或「关掉」)。
if err := os.WriteFile(path, []byte(`{"autoApply": "nope"}`), 0o600); err != nil {
t.Fatalf("write broken: %v", err)
}
broken := &app{cfg: &config.Config{DataDir: dataDir}, log: tidyTestLog()}
cfg = memory.TidyConfigFromConfig(config.MemoryTidyConfig{Enabled: true})
broken.applyMemoryTidyTuning(&cfg)
if cfg.DisableAutoApply || cfg.AutoApplyPerRound != 0 {
t.Fatalf("坏配置时 cfg = %+v, want 回落零值(默认值由 normalize 填)", cfg)
}
}
// TestApplyMemoryTidyTuning_ScheduleKnobs 钉住**整理频率**四键在组合根上的接线:
// 面板($DATA_DIR/config/memory-tidy.json)优先、`.env` 兜底 —— 主人 2026-10-07
// 口径「.env 只做兜底用 …… 这些键优先从面板配置上取」。
//
// 这里只断言「叠完之后 cfg 里是什么」,不重复 memory 包的优先级链用例:组合根这一层
// 要证明的是**那个文件真的被读了、真的落到了 cfg 上**(少这一句,面板改了频率也无效,
// 而不会有任何用例变红)。
func TestApplyMemoryTidyTuning_ScheduleKnobs(t *testing.T) {
t.Parallel()
// env 层:与面板值故意对立。
env := config.MemoryTidyConfig{Enabled: true, IntervalSec: 7200, QuietStartHour: 23, QuietEndHour: 7}
newApp := func(dataDir string) *app {
return &app{cfg: &config.Config{DataDir: dataDir}, log: tidyTestLog()}
}
apply := func(dataDir string) memory.TidyConfig {
cfg := memory.TidyConfigFromConfig(env)
newApp(dataDir).applyMemoryTidyTuning(&cfg)
return cfg
}
// ① 面板四键都写了 ⇒ 四个都用面板值(覆盖 `.env`)。
dataDir := t.TempDir()
dir := filepath.Join(dataDir, "config")
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatalf("mkdir: %v", err)
}
path := filepath.Join(dir, "memory-tidy.json")
body := `{"intervalSec": 3600, "quietIntervalSec": 10800, "quietStartHour": 1, "quietEndHour": 5}`
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
cfg := apply(dataDir)
if cfg.IntervalSec != 3600 {
t.Fatalf("intervalSec = %d, want 3600(面板优先)", cfg.IntervalSec)
}
if cfg.QuietIntervalSec != 10800 {
t.Fatalf("quietIntervalSec = %d, want 10800(面板优先)", cfg.QuietIntervalSec)
}
if cfg.QuietStartHour != 1 || cfg.QuietEndHour != 5 {
t.Fatalf("安静时段 = %02d:00-%02d:00, want 01:00-05:00(面板优先)",
cfg.QuietStartHour, cfg.QuietEndHour)
}
// ② 文件不存在 ⇒ 四键保持 `.env` 值(面板只是「能覆盖」,不是「必须有」)。
empty := t.TempDir()
cfg = apply(empty)
if cfg.IntervalSec != 7200 || cfg.QuietStartHour != 23 || cfg.QuietEndHour != 7 {
t.Fatalf("没配过时应保持 env 值,得到 %+v", cfg)
}
// ③ 坏文件(类型写错)⇒ 整份回落 `.env`,且**不启动失败**:组合根照旧拿到
// 可用的 cfg(一个坏键不得让整理停摆)。
if err := os.WriteFile(path, []byte(`{"intervalSec": "3600"}`), 0o600); err != nil {
t.Fatalf("write broken: %v", err)
}
cfg = apply(dataDir)
if cfg.IntervalSec != 7200 || cfg.QuietStartHour != 23 || cfg.QuietEndHour != 7 {
t.Fatalf("坏文件时应回落 env 值,得到 %+v", cfg)
}
if cfg.QuietIntervalSec != 0 {
t.Fatalf("坏文件时 quietIntervalSec 应当是零值(由 normalize 取 = intervalSec),得到 %d",
cfg.QuietIntervalSec)
}
}
+139
View File
@@ -0,0 +1,139 @@
package main
import (
"context"
"path/filepath"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
)
// 本文件锁定组合根的面板接线(切片 PLG-2):wirePanelHub 必须把插件管理器的面板
// 中枢接到**既有进程内 bus** 的 `ws.panels_changed` 上 —— 前端热更新靠这条帧,
// 且不新开任何端口(单端口红线)。
//
// 为什么在组合根测:帧的主题名与载荷形状是跨片契约(前端片按它消费),
// 只有对 wirePanelHub 取证才能证明「组合根真的发的是这一条」。
// TestWirePanelHubPublishesPanelsChanged 断言生命周期变化真的经 bus 推出
// `ws.panels_changed`,且载荷带 reason / pluginId(不含任何面板内容)。
func TestWirePanelHubPublishesPanelsChanged(t *testing.T) {
b := bus.New()
defer b.Close()
mgr := pluginmgr.NewManager(registry.New(), filepath.Join(t.TempDir(), "plugins.json"), testLog())
defer mgr.Close(context.Background())
wirePanelHub(&app{plugins: mgr, bus: b, log: testLog()})
hub := mgr.Panels()
if hub == nil {
t.Fatal("wirePanelHub 未把面板中枢接到插件管理器上")
}
events := make(chan bus.Event, 8)
if _, err := b.Subscribe(context.Background(), "ws.panels_changed", func(_ context.Context, e bus.Event) error {
events <- e
return nil
}); err != nil {
t.Fatalf("subscribe: %v", err)
}
enabled := true
panels := []sdk.Panel{{
ID: "providers", Title: "搜索提供商", Order: 1,
When: &sdk.When{Enabled: &enabled},
Sections: []sdk.Section{{
Type: sdk.SectionStatus, ID: "chain", Title: "降级链",
Fields: []sdk.Field{{Key: "active", Type: sdk.FieldText}},
}},
}}
if _, err := hub.Declare("web-search", panels); err != nil {
t.Fatalf("Declare: %v", err)
}
hub.SetEnabled("web-search", false)
for _, want := range []string{"declare", "disable"} {
select {
case e := <-events:
if e.Subject != "ws.panels_changed" {
t.Fatalf("subject = %q", e.Subject)
}
payload, ok := e.Payload.(map[string]any)
if !ok {
t.Fatalf("载荷类型 = %T,期望 map[string]any", e.Payload)
}
if payload["reason"] != want {
t.Fatalf("reason = %v,期望 %q", payload["reason"], want)
}
if payload["pluginId"] != "web-search" {
t.Fatalf("pluginId = %v,期望 web-search", payload["pluginId"])
}
// 帧刻意不带内容:前端按自己的角色重拉快照,避免在这里再实现一遍角色过滤。
if _, leaked := payload["panels"]; leaked {
t.Fatalf("panels_changed 帧不得携带面板内容:%v", payload)
}
case <-time.After(2 * time.Second):
t.Fatalf("未收到 %q 的 panels_changed 帧", want)
}
}
}
// TestWirePanelHubDeclaresBuiltinSearchPanel 断言内置插件(web-search)的面板声明
// 真的进了目录 —— 没有这一步,契约面在运行期就是一份空快照(design/09 §5.5 的 dogfood
// 也就无从验起)。顺带锁定「声明通过校验」:search.AdminPanels() 若被改坏,这里会红。
func TestWirePanelHubDeclaresBuiltinSearchPanel(t *testing.T) {
b := bus.New()
defer b.Close()
mgr := pluginmgr.NewManager(registry.New(), filepath.Join(t.TempDir(), "plugins.json"), testLog())
defer mgr.Close(context.Background())
wirePanelHub(&app{plugins: mgr, bus: b, log: testLog()})
hub := mgr.Panels()
entries, err := hub.List(auth.RoleOwner)
if err != nil {
t.Fatalf("List: %v", err)
}
if len(entries) != 1 || entries[0].PluginID != search.PanelPluginID ||
entries[0].Panel.ID != search.PanelIDProviders {
t.Fatalf("内置面板 = %+v,期望 web-search/providers 一条", entries)
}
for _, name := range search.ActionNames() {
if _, ok := hub.Action(search.PanelPluginID, name); !ok {
t.Fatalf("内置插件声明的动作 %q 未进白名单", name)
}
}
// viewer 拿到的必须是裁掉 form 的只读投影。
viewer, err := hub.List(auth.RoleViewer)
if err != nil {
t.Fatalf("List(viewer): %v", err)
}
if len(viewer) != 1 || len(viewer[0].Panel.Sections) != 1 {
t.Fatalf("viewer 的只读投影 = %+v,期望只剩表格区块", viewer)
}
}
// TestWirePanelHubNilPluginsIsNoop 断言未构造插件管理器时接线是空操作(不 panic)。
func TestWirePanelHubNilPluginsIsNoop(t *testing.T) {
wirePanelHub(&app{bus: bus.New(), log: testLog()})
}
// TestWirePanelHubWithoutBusIsNoop 断言没有 bus 时也只告警、不 panic
// (发布器内部判空,与 refresh store / key store 的降级口径一致)。
func TestWirePanelHubWithoutBusIsNoop(t *testing.T) {
mgr := pluginmgr.NewManager(registry.New(), filepath.Join(t.TempDir(), "plugins.json"), testLog())
defer mgr.Close(context.Background())
wirePanelHub(&app{plugins: mgr, log: testLog()})
hub := mgr.Panels()
if hub == nil {
t.Fatal("没有 bus 时仍应接上面板中枢(只是不发帧)")
}
if _, err := hub.Declare("web-search", nil); err != nil {
t.Fatalf("Declare: %v", err)
}
}
+191
View File
@@ -0,0 +1,191 @@
package main
// 本文件是**内置插件动作面的登记入口**(design/09 §5.2.1「动作分发」;切片 L1/PLG-2 的
// 执行面接线):把编译进二进制的插件声明的动作处理器登记到插件管理器上。首个使用者是
// web-search 的「搜索提供商」页(§5.5 dogfood):四个动作
// plugin.listProviders / plugin.saveProvider / plugin.testProvider / plugin.reauthProvider,
// 外加下拉候选来源 `plugin:providers`(前端按 `optionsFrom` 推出这个名字)。
//
// 三个文件的分工(都在本包):
// - 本文件:登记入口、处理器环境、动作名映射、「声明了却没实现」的装配期核对;
// - `websearch_action_data.go`:动作本体的业务数据面(可变源的读写与翻译、载荷解析);
// - `websearch_probe.go`:探针的传输层绑定(HTTP 客户端 + 同主机守卫 + 状态映射);
// - `websearch_reload.go`:保存后的**热重载**事务(重建工具 + 换注册表条目 + 失败回滚);
// - `websearch_selection.go`:表单里文件级字段(主 provider / 降级链 / 条数上限)的读写。
//
// 三条边界:
//
// 1. **数据面只读写可变源**:`$DATA_DIR/config/plugins/web-search.json`(design/13 §2.1),
// 经 runtimeconfig 的读写入口(0600 + temp+rename),宿主自己不开新路径、不猜形状;
// 2. **探针真发一次请求**:走 `search.Probe`(同一家 provider 构造、同一份归一化),
// 不靠「配置看起来齐了」假装连通(§5.5 的「测试连通」);
// 3. **凭据不出后端**:读侧只回掩码(`MaskedWebSearchFile`),日志只记 provider 名与
// 结果码,绝不记 key / 地址。
//
// 热重载(切片 W3;裁决 2026-10-06 #1):保存动作被 `webSearchActionHandlersWithHotReload`
// 包上事务 —— 落盘成功后重建 web-search 工具并替换注册表条目,**下一轮对话即生效**
// (design/09 §5.5 的端到端判据「面板加一家 ⇒ 不改 `.env`、不重启 ⇒ 下一次搜索真的走它」)。
import (
"log/slog"
"sort"
"strings"
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
)
// actionWireLogSource 是本文件日志的来源标签(项目规范 §10:禁止无来源裸日志)。
const actionWireLogSource = "cmd/cyrene/wire_plugin_actions"
// 动作失败码(宿主级,且只在动作面这组文件里使用)。领域自己的码来自 search(探针码)
// 与 pluginmgr.ActionCodeInvalidPayload。
const (
// actionCodeUnknownProvider 报告请求点名了一家本期没登记的 provider。
actionCodeUnknownProvider = "unknown_provider"
// actionCodeConfigUnreadable 报告面板配置存在但读不出来(损坏/权限)。
actionCodeConfigUnreadable = "config_unreadable"
// actionCodeConfigWriteFailed 报告面板配置写不进去(磁盘/权限)。
actionCodeConfigWriteFailed = "config_write_failed"
)
// wireBuiltinPluginActions 把内置插件的动作处理器登记到插件管理器上(§5.2.1:
// 插件在组合根注册自己的动作处理器)。
//
// 登记之后旧账要结清:**声明里写了但没人实现**的动作会如实记一条 WARN(端点对它的
// 结算就是 501)。这正是 §5.2.1「表里写了一个没实现的动作,review 时一眼可见」的
// 运行期版本 —— 装配期就把差异说出来,而不是等用户点出一个 501。
//
// 登记失败(命名空间非法 / 中枢未接线)只记 ERROR、不阻断启动:一个坏的动作面不该
// 让整个后端起不来(与面板声明的处置一致:该面板不注册 + 显式报错)。
func wireBuiltinPluginActions(mgr *pluginmgr.Manager, hub *pluginmgr.Panels, a *app, log *slog.Logger) {
if mgr == nil || hub == nil {
return
}
env, ok := a.webSearchActionEnv(log)
if !ok {
log.Warn("builtin plugin actions not registered: no config/data dir",
"pluginId", search.PanelPluginID, "effect", "panel actions will answer 501")
return
}
handlers := webSearchActionHandlersWithHotReload(env, a, log)
for optionsFrom, handler := range webSearchOptionsHandlers(env) {
name := pluginmgr.OptionsActionName(optionsFrom)
if name == "" {
log.Error("optionsFrom 无法映射成动作名,候选来源未接线",
"pluginId", search.PanelPluginID, "optionsFrom", optionsFrom)
continue
}
handlers[name] = handler
}
if err := mgr.RegisterActions(search.PanelPluginID, handlers); err != nil {
log.Error("builtin plugin actions not registered; panel actions will answer 501",
"pluginId", search.PanelPluginID, "error", err)
return
}
log.Info("builtin plugin actions registered",
"pluginId", search.PanelPluginID, "actions", registeredActionNames(hub))
warnImpliedButMissing(hub, log)
}
// warnImpliedButMissing 把「声明里有、登记里没有」的动作与候选来源如实报出来
// (前者的结算就是 501 action_not_implemented;后者会让前端静默降级成文本框)。
func warnImpliedButMissing(hub *pluginmgr.Panels, log *slog.Logger) {
registered := make(map[string]bool)
for _, name := range hub.ActionHandlerNames(search.PanelPluginID) {
registered[name] = true
}
for _, name := range hub.DeclaredActions(search.PanelPluginID) {
if !registered[name] {
log.Warn("declared plugin action has no handler; it will answer 501",
"pluginId", search.PanelPluginID, "action", name)
}
}
for _, src := range declaredOptionsSources(search.AdminPanels()) {
name := pluginmgr.OptionsActionName(src)
if name == "" || !registered[name] {
log.Warn("declared optionsFrom has no handler; the control degrades to a text input",
"pluginId", search.PanelPluginID, "optionsFrom", src, "action", name)
}
}
}
// registeredActionNames 返回已登记的处理器的动作名(诊断日志用,已排序)。
func registeredActionNames(hub *pluginmgr.Panels) []string {
names := hub.ActionHandlerNames(search.PanelPluginID)
sort.Strings(names)
return names
}
// declaredOptionsSources 从声明里收集全部 `optionsFrom` 取值(去重、排序)。
//
// 用途是**反向核对**:声明里出现了一个没有实现的候选来源时,装配期就要说出来 ——
// 否则前端只会静默降级成文本框,没人知道是「声明多写了一个」。
func declaredOptionsSources(panels []sdk.Panel) []string {
seen := make(map[string]bool)
for _, panel := range panels {
for _, s := range panel.Sections {
for _, f := range s.Fields {
if key := strings.TrimSpace(f.OptionsFrom); key != "" {
seen[key] = true
}
}
}
}
out := make([]string, 0, len(seen))
for key := range seen {
out = append(out, key)
}
sort.Strings(out)
return out
}
// webSearchActionEnv 是 web-search 动作处理器共享的接线事实(一次装配、多动作复用)。
//
// 刻意只装**在多个动作间共享**的东西(日志出口、可变源路径、`.env` 解析后的配置),
// 每次调用各自读文件:动作之间不共享「当前配置」这份可变状态,就不会出现
// 「A 动作读到的是 B 动作刚改之前的那份」。
type webSearchActionEnv struct {
log *slog.Logger
// path 是可变源落点 `$DATA_DIR/config/plugins/web-search.json`。
path string
// envCfg 是 `.env` 解析结果(**只用来取标量默认值**:探针超时;各家的默认基地址改由
// 接入位表给出,见 resolveBaseURL)。
envCfg config.WebSearchConfig
}
// webSearchActionEnv 构造处理器环境。ok=false 表示没有配置/数据目录
// (未装配完成或测试夹具),此时不登记动作,由调用方如实告警。
func (a *app) webSearchActionEnv(log *slog.Logger) (webSearchActionEnv, bool) {
if a.cfg == nil {
return webSearchActionEnv{}, false
}
return webSearchActionEnv{
log: log,
path: runtimeconfig.WebSearchConfigPath(resolveDataDir(a.cfg)),
envCfg: a.cfg.WebSearch,
}, true
}
// webSearchActionHandlers 返回 web-search 插件声明的**四个动作**的处理器。
func webSearchActionHandlers(env webSearchActionEnv) map[string]pluginmgr.ActionHandler {
return map[string]pluginmgr.ActionHandler{
search.ActionListProviders: env.listProviders,
search.ActionSaveProvider: env.saveProvider,
search.ActionTestProvider: env.testProvider,
search.ActionReauthProvider: env.reauthProvider,
}
}
// webSearchOptionsHandlers 返回「下拉候选来源」的处理器(键 = 声明里的 `optionsFrom` 取值)。
//
// 映射成什么动作名由 `pluginmgr.OptionsActionName` 一处决定(与前端
// `resolveOptionsAction` 同源):两处若分叉,声明的下拉就会去调一个宿主不认识的名字。
func webSearchOptionsHandlers(env webSearchActionEnv) map[string]pluginmgr.ActionHandler {
return map[string]pluginmgr.ActionHandler{
"plugin:providers": env.listProviderOptions,
}
}
+466
View File
@@ -0,0 +1,466 @@
package main
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"path/filepath"
"sort"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
)
// 本文件钉住切片 L1/PLG-2 的**执行面接线**:内置插件 web-search 的四个动作 +
// 下拉候选来源真的被登记到插件管理器上,并且真的能读写可变源、真发一次探针请求。
//
// 为什么在组合根测:处理器与可变源的**接线事实**(哪个文件、哪份默认值、哪个客户端)
// 只在这里成立;插件管理器的单测只能证明「登记面会路由」,证明不了「搜索页点得动」。
// actionTestEnv 是一个接好面板中枢 + 动作处理器的组合根夹具。
type actionTestEnv struct {
app *app
mgr *pluginmgr.Manager
hub *pluginmgr.Panels
// path 是可变源落点($DATA_DIR/config/plugins/web-search.json)。
path string
}
// newActionTestEnv 按组合根的**同一条路**接线:wirePanelHub(声明 + 动作处理器登记)。
// extra 是额外的 .env 行,用来控制代码默认值(如 WEB_SEARCH_PROVIDER)。
func newActionTestEnv(t *testing.T, extra ...string) *actionTestEnv {
t.Helper()
cfg := wsLoadConfig(t, extra...)
b := bus.New()
t.Cleanup(func() { _ = b.Close() })
mgr := pluginmgr.NewManager(registry.New(), filepath.Join(cfg.DataDir, "plugins.json"), testLog())
t.Cleanup(func() { mgr.Close(context.Background()) })
a := &app{cfg: cfg, log: testLog(), bus: b, plugins: mgr}
wirePanelHub(a)
hub := mgr.Panels()
if hub == nil {
t.Fatal("wirePanelHub 未把面板中枢接到插件管理器上")
}
return &actionTestEnv{app: a, mgr: mgr, hub: hub, path: runtimeconfig.WebSearchConfigPath(cfg.DataDir)}
}
// dispatch 调一次动作(走生产同一条 Dispatch:声明 → 启用 → 处理器)。
func (e *actionTestEnv) dispatch(t *testing.T, action string, payload any) (any, error) {
t.Helper()
return e.hub.Dispatch(context.Background(), pluginmgr.ActionRequest{
PluginID: search.PanelPluginID,
Action: action,
Payload: payload,
})
}
// mustDispatch 调一次动作并要求成功。
func (e *actionTestEnv) mustDispatch(t *testing.T, action string, payload any) any {
t.Helper()
out, err := e.dispatch(t, action, payload)
if err != nil {
t.Fatalf("dispatch %s: %v", action, err)
}
return out
}
// saveProvider 走保存动作(表单形状 `{values:{...}}`)。
func (e *actionTestEnv) saveProvider(t *testing.T, values map[string]any) any {
t.Helper()
return e.mustDispatch(t, search.ActionSaveProvider, map[string]any{"values": values})
}
// rowsOf 把列表动作的结果解成面板行。
func (e *actionTestEnv) rowsOf(t *testing.T) []search.ProviderRow {
t.Helper()
out := e.mustDispatch(t, search.ActionListProviders, map[string]any{})
m, ok := out.(map[string]any)
if !ok {
t.Fatalf("列表结果类型 = %T", out)
}
rows, ok := m["rows"].([]search.ProviderRow)
if !ok {
t.Fatalf("列表结果缺 rows: %#v", m)
}
return rows
}
// rowOf 取某一家在表格里的那一行(找不到则失败)。
func (e *actionTestEnv) rowOf(t *testing.T, provider string) search.ProviderRow {
t.Helper()
for _, r := range e.rowsOf(t) {
if r.Provider == provider {
return r
}
}
t.Fatalf("表格里没有 %q 这一行(rows = %+v)", provider, e.rowsOf(t))
return search.ProviderRow{}
}
// loadFile 读盘上的可变源(断言「真的落盘了」时用)。
func (e *actionTestEnv) loadFile(t *testing.T) runtimeconfig.WebSearchFile {
t.Helper()
f, found, err := runtimeconfig.LoadWebSearch(e.path)
if err != nil {
t.Fatalf("LoadWebSearch(%s): %v", e.path, err)
}
if !found {
t.Fatalf("可变源不在盘上: %s", e.path)
}
return f
}
// entryOf 取盘上某家的条目。
func (e *actionTestEnv) entryOf(t *testing.T, provider string) runtimeconfig.ProviderEntry {
t.Helper()
for _, p := range e.loadFile(t).Providers {
if p.Provider == provider {
return p
}
}
t.Fatalf("盘上没有 %q 条目", provider)
return runtimeconfig.ProviderEntry{}
}
// TestWirePanelHubRegistersBuiltinActions 是接线的主判据:内置插件的四个动作与
// 下拉候选来源都进了登记面,且每一个都能真的被 Dispatch 执行(不再回 501)。
func TestWirePanelHubRegistersBuiltinActions(t *testing.T) {
env := newActionTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
want := append([]string(nil), search.ActionNames()...)
want = append(want, pluginmgr.OptionsActionName("plugin:providers"))
registered := env.hub.ActionHandlerNames(search.PanelPluginID)
sort.Strings(want)
if strings.Join(registered, ",") != strings.Join(want, ",") {
t.Fatalf("已登记的处理器 = %v, want %v", registered, want)
}
// 每个已登记的动作都必须**已声明**(否则登记它等于登记一个永远调不到的名字)。
for _, name := range registered {
if _, ok := env.hub.Action(search.PanelPluginID, name); !ok {
t.Errorf("动作 %q 已登记但没声明(永远不可达)", name)
}
}
// 声明里的每一个动作都必须有处理器 —— 否则面板上会有一个点了回 501 的按钮。
for _, name := range env.hub.DeclaredActions(search.PanelPluginID) {
if _, err := env.dispatch(t, name, nil); errors.Is(err, pluginmgr.ErrActionUnavailable) {
t.Errorf("声明过的动作 %q 没有处理器(会回 501)", name)
}
}
// 列表动作也必须真的执行(此时可变源还没配过 ⇒ 空表不是错误)。
if rows := env.rowsOf(t); len(rows) != 0 {
t.Fatalf("没配过任何一家时列表应为空,实际 %+v", rows)
}
}
// TestWebSearchListProvidersShowsStoredEntries 锁定列表动作读的是**可变源**
// (而不是种子/默认值):写进盘里的条目与状态都如实出现,且不含任何凭据。
func TestWebSearchListProvidersShowsStoredEntries(t *testing.T) {
env := newActionTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
env.saveProvider(t, map[string]any{
"provider": search.NameBocha, "apiKey": "sk-bocha-secret",
"baseUrl": "https://api.bocha.cn", "enabled": true,
})
rows := env.rowsOf(t)
byName := make(map[string]search.ProviderRow, len(rows))
for _, r := range rows {
byName[r.Provider] = r
}
if got := byName[search.NameBocha]; got.State != search.StateOK || !got.Enabled {
t.Fatalf("bocha 行 = %+v, want state=ok 且启用", got)
}
raw, err := json.Marshal(rows)
if err != nil {
t.Fatalf("marshal rows: %v", err)
}
if strings.Contains(string(raw), "sk-bocha-secret") {
t.Fatalf("列表动作泄漏了凭据: %s", raw)
}
}
// TestWebSearchSaveProviderHonoursSecretSentinel 锁定写侧的三态哨兵语义
// (§5.4.2:空字符串 ≠ 不改动):
// - apiKey 缺位 ⇒ 原值保留(面板只持有掩码,留空必须真的不改);
// - apiKey 显式空串 ⇒ 清空;
// - enabled / baseUrl 缺位 ⇒ 保留。
func TestWebSearchSaveProviderHonoursSecretSentinel(t *testing.T) {
env := newActionTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
env.saveProvider(t, map[string]any{
"provider": search.NameBocha, "apiKey": "sk-first",
"baseUrl": "https://api.bocha.cn", "enabled": true,
})
// 只改地址(apiKey / enabled 缺位)⇒ 凭据与启用态一个都不许动。
env.saveProvider(t, map[string]any{"provider": search.NameBocha, "baseUrl": "https://mirror.bocha.cn"})
got := env.entryOf(t, search.NameBocha)
if got.APIKey != "sk-first" {
t.Fatalf("apiKey 缺位时被改动了: %q(哨兵语义失效 = 一次保存静默清空凭据)", got.APIKey)
}
if got.BaseURL != "https://mirror.bocha.cn" || !got.IsEnabled() {
t.Fatalf("条目 = %+v, want 新地址 + 仍启用", got)
}
// 显式停用 + 显式清空凭据(前端只有勾了清除才会送出空串)。
env.saveProvider(t, map[string]any{"provider": search.NameBocha, "apiKey": "", "enabled": false})
got = env.entryOf(t, search.NameBocha)
if got.APIKey != "" {
t.Fatalf("显式空串必须清空凭据,得到 %q", got.APIKey)
}
if got.IsEnabled() {
t.Fatal("显式 enabled=false 必须落盘(停用态)")
}
if state := env.rowOf(t, search.NameBocha).State; state != search.StateDisabled {
t.Fatalf("停用后 state = %q, want %q", state, search.StateDisabled)
}
}
// TestWebSearchReauthProviderReturnsMaskedValues 锁定 reauth「把该条带回表单」:
// 返回显式 values(前端 formValuesOfResult 只认 values/formValues),
// 且 apiKey 只给**掩码** —— 读侧永不回明文(§5.4.2)。
func TestWebSearchReauthProviderReturnsMaskedValues(t *testing.T) {
env := newActionTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
env.saveProvider(t, map[string]any{
"provider": search.NameBocha, "apiKey": "sk-abcdefgh",
"baseUrl": "https://api.bocha.cn", "enabled": true,
})
out := env.mustDispatch(t, search.ActionReauthProvider, map[string]any{
"row": map[string]any{"provider": search.NameBocha, "state": search.StateOK},
})
m, ok := out.(map[string]any)
if !ok {
t.Fatalf("reauth 结果类型 = %T", out)
}
values, ok := m["values"].(map[string]any)
if !ok {
t.Fatalf("reauth 结果缺 values: %#v", m)
}
if values["provider"] != search.NameBocha || values["baseUrl"] != "https://api.bocha.cn" {
t.Fatalf("回填的字段 = %#v", values)
}
if values["enabled"] != true {
t.Fatalf("enabled 回填 = %#v, want true", values["enabled"])
}
masked, _ := values["apiKey"].(string)
if masked == "" || masked == "sk-abcdefgh" {
t.Fatalf("apiKey 回填 = %q,必须是掩码(读侧无明文)", masked)
}
if !strings.HasSuffix(masked, "efgh") {
t.Fatalf("掩码口径应与宿主的 maskToken 同源(尾 4 位),得到 %q", masked)
}
}
// TestWebSearchTestProviderHitsRealHTTP 是探针的判据:**真发一次请求**到盘上那条
// 配置指的地址,用真应答区分「通了」与「没通」。
func TestWebSearchTestProviderHitsRealHTTP(t *testing.T) {
hits := make(chan *http.Request, 1)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits <- r
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"results":[{"title":"A","url":"http://a.example","content":"alpha"}]}`))
}))
defer srv.Close()
env := newActionTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
env.saveProvider(t, map[string]any{
"provider": search.NameSearXNG, "baseUrl": srv.URL + "/search", "enabled": true,
})
out := env.mustDispatch(t, search.ActionTestProvider, map[string]any{
"row": map[string]any{"provider": search.NameSearXNG},
})
res, ok := out.(search.ProbeResult)
if !ok {
t.Fatalf("探针结果类型 = %T", out)
}
if !res.OK || res.Count != 1 {
t.Fatalf("探针结论 = %+v, want ok 且 1 条", res)
}
select {
case r := <-hits:
if r.URL.Path != "/search" || !strings.Contains(r.URL.RawQuery, "q=cyrene") {
t.Fatalf("上游收到的请求 = %s?%s(探针必须真的搜一次)", r.URL.Path, r.URL.RawQuery)
}
default:
t.Fatal("上游一个请求都没收到 —— 探针没真发请求")
}
}
// TestWebSearchTestProviderReportsFailureHonestly 锁定失败口径:上游拒了凭据 ⇒
// 结构化非 2xx(码 = 探针码,文案固定不含上游原文),绝不回一句假的「已完成」。
func TestWebSearchTestProviderReportsFailureHonestly(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"bad key sk-should-not-leak"}`))
}))
defer srv.Close()
env := newActionTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
env.saveProvider(t, map[string]any{
"provider": search.NameSearXNG, "baseUrl": srv.URL, "enabled": true,
})
_, err := env.dispatch(t, search.ActionTestProvider, map[string]any{
"row": map[string]any{"provider": search.NameSearXNG},
})
ae, ok := pluginmgr.AsActionError(err)
if !ok {
t.Fatalf("探针失败 err = %v,期望结构化 ActionError", err)
}
if ae.Code != search.ProbeUnauthorized {
t.Fatalf("失败码 = %q, want %q", ae.Code, search.ProbeUnauthorized)
}
if ae.Status != http.StatusBadGateway {
t.Fatalf("失败状态 = %d, want 502(上游失败)", ae.Status)
}
if strings.Contains(ae.Message, "sk-should-not-leak") || ae.Message == "" {
t.Fatalf("失败文案 = %q(不得含上游原文,也不得为空)", ae.Message)
}
}
// TestWebSearchTestProviderNotConfiguredIsClientError 锁定「没配齐」归 400:
// 这是调用方/配置的问题,不是上游不可用(前端据此区分该改配置还是该重试)。
func TestWebSearchTestProviderNotConfiguredIsClientError(t *testing.T) {
env := newActionTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
// 存一条**没配齐**的(显式清空 key):探针必须在发请求**之前**就报 not_configured,
// 绝不拿一个没有凭据的配置去戳上游。
env.saveProvider(t, map[string]any{"provider": search.NameBocha, "apiKey": ""})
_, err := env.dispatch(t, search.ActionTestProvider, map[string]any{
"row": map[string]any{"provider": search.NameBocha},
})
ae, ok := pluginmgr.AsActionError(err)
if !ok || ae.Code != search.ProbeNotConfigured || ae.Status != http.StatusBadRequest {
t.Fatalf("err = %#v, want 400 not_configured", err)
}
}
// TestWebSearchActionsRejectUnknownProviderAndBadPayload 锁定动作的两类调用方错误
// 都被**显式拒绝**(不静默存一条永远选不中的配置,也不把类型错当成空值)。
func TestWebSearchActionsRejectUnknownProviderAndBadPayload(t *testing.T) {
env := newActionTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
cases := []struct {
name string
action string
payload any
wantCode string
}{
{"保存未登记的家", search.ActionSaveProvider,
map[string]any{"values": map[string]any{"provider": "tavily", "apiKey": "sk-x"}}, actionCodeUnknownProvider},
{"保存缺 provider", search.ActionSaveProvider,
map[string]any{"values": map[string]any{"apiKey": "sk-x"}}, pluginmgr.ActionCodeInvalidPayload},
{"保存缺 values 对象", search.ActionSaveProvider,
map[string]any{"apiKey": "sk-x"}, pluginmgr.ActionCodeInvalidPayload},
{"保存 enabled 类型错", search.ActionSaveProvider,
map[string]any{"values": map[string]any{"provider": search.NameBocha, "enabled": "yes"}}, pluginmgr.ActionCodeInvalidPayload},
{"探针未登记的家", search.ActionTestProvider,
map[string]any{"row": map[string]any{"provider": "tavily"}}, actionCodeUnknownProvider},
{"探针缺 provider", search.ActionTestProvider, map[string]any{}, pluginmgr.ActionCodeInvalidPayload},
{"reauth 未登记的家", search.ActionReauthProvider,
map[string]any{"row": map[string]any{"provider": "tavily"}}, actionCodeUnknownProvider},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
_, err := env.dispatch(t, tc.action, tc.payload)
ae, ok := pluginmgr.AsActionError(err)
if !ok {
t.Fatalf("err = %v,期望结构化 ActionError", err)
}
if ae.Code != tc.wantCode || ae.Status != http.StatusBadRequest {
t.Fatalf("err = %+v, want code=%q status=400", ae, tc.wantCode)
}
})
}
// 被拒的保存不得在盘上留下痕迹(没写成功时文件可能根本不存在 —— 那也是「没留下」)。
if f, found, err := runtimeconfig.LoadWebSearch(env.path); err == nil && found {
for _, p := range f.Providers {
if p.Provider == "tavily" {
t.Fatal("未登记的家被写进了可变源")
}
}
}
}
// TestWebSearchOptionsActionFeedsDeclaredOptionsFrom 锁死「声明的下拉一定拉得到候选」:
// 声明里每个 optionsFrom 映射出的动作名都**已登记且已声明**(否则前端只能降级成文本框)。
//
// 候选的形状是 `{value,label}`(切片 LBL-1):这里连**显示名非空**一起钉住 —— 只有值的
// 候选会让面板下拉与表格里全是英文 id(主人 2026-10-06 报障的那个形状)。
func TestWebSearchOptionsActionFeedsDeclaredOptionsFrom(t *testing.T) {
env := newActionTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
sources := declaredOptionsSources(search.AdminPanels())
if len(sources) == 0 {
t.Fatal("声明里一个 optionsFrom 都没有:这条判据失去了意义")
}
for _, src := range sources {
name := pluginmgr.OptionsActionName(src)
if name == "" {
t.Fatalf("optionsFrom %q 映射不出动作名", src)
}
if _, ok := env.hub.Action(search.PanelPluginID, name); !ok {
t.Errorf("候选来源 %q(动作 %s)没进声明索引", src, name)
}
out, err := env.dispatch(t, name, map[string]any{})
if err != nil {
t.Errorf("候选来源 %q 调不动: %v", src, err)
continue
}
options, _ := out.(map[string]any)["options"].([]search.ProviderOption)
if len(options) == 0 {
t.Errorf("候选来源 %q 回了空候选(表单下拉会空着)", src)
}
got, want := optionValues(options), optionValues(search.ProviderOptions())
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Errorf("候选值 = %v, want %v(value 逐字等于 KnownNames())", got, want)
}
for _, o := range options {
if strings.TrimSpace(o.Label) == "" {
t.Errorf("候选来源 %q 的 %q 没有显示名(下拉里会是空白项)", src, o.Value)
}
}
}
}
// optionValues 取出结构化候选的**值**(落盘真源),丢掉显示名。
func optionValues(opts []search.ProviderOption) []string {
out := make([]string, 0, len(opts))
for _, o := range opts {
out = append(out, o.Value)
}
return out
}
// TestWebSearchActionsUnwiredAnswers501 锁定降级口径:**没有配置**(测试夹具/装配未完成)
// 时不登记任何处理器 ⇒ 已声明的动作诚实回 501(ErrActionUnavailable),
// 绝不假装成功地回一份空列表。
func TestWebSearchActionsUnwiredAnswers501(t *testing.T) {
cfg := wsLoadConfig(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
b := bus.New()
defer b.Close()
mgr := pluginmgr.NewManager(registry.New(), filepath.Join(cfg.DataDir, "plugins.json"), testLog())
defer mgr.Close(context.Background())
// cfg 为 nil ⇒ 组合根拿不到数据目录,动作登记被跳过(只告警)。
wirePanelHub(&app{log: testLog(), bus: b, plugins: mgr})
hub := mgr.Panels()
if got := hub.ActionHandlerNames(search.PanelPluginID); len(got) != 0 {
t.Fatalf("没有配置时不该登记任何处理器,实际 %v", got)
}
_, err := hub.Dispatch(context.Background(), pluginmgr.ActionRequest{
PluginID: search.PanelPluginID, Action: search.ActionListProviders,
})
if !errors.Is(err, pluginmgr.ErrActionUnavailable) {
t.Fatalf("err = %v, want ErrActionUnavailable(未登记处理器必须诚实回 501)", err)
}
}
Loaded 100 of 1908 files, more files were not shown because too many files have changed in this diff. Show more