diff --git a/Docs/api-client.md b/Docs/api-client.md index 67f0a7a..826c4d6 100644 --- a/Docs/api-client.md +++ b/Docs/api-client.md @@ -1,1904 +1,1950 @@ -# Cyrene v2 客户端 API 契约 - -> **契约版本:`v0.84`**(上一版 `v0.83`,其条目见 §6;`v0.82` / `v0.81` / `v0.80` / `v0.79` / `v0.78` / `v0.77` / `v0.76` / `v0.75` / `v0.74` / `v0.73` / `v0.72` / `v0.71` 及更早见本文件 §6,`v0.67` 及更早的基线已归档、只读:`Docs/archive/api-client.v0.65.md`)。 -> **读者**:Web 前端 / 移动端原生客户端(含 Android)/ IM 桥接等**跨平台客户端开发者**。 -> **唯一真源 = 当前代码**(`internal/services/gateway/*` 等)。本文档由代码逐条核对得出;与代码不符时**以代码为准**,并回改本文档。 -> **旧的超详细版已归档、只读**:`Docs/archive/api-client.v0.65.md`(412 KB,含 v0.28→v0.65 全部历史条目)与 `Docs/archive/api-reference.v0.65.md`(170 KB,后端全量字段级文档)。归档的原因是**结构性臃肿 + 历史条目并列 + 长期漂移**;本文档**不复制**那段历史,需要考古时去归档。 -> **维护铁律**:每实现/变更一个 REST 端点、WS 事件、admin API **立即改本文档**;契约变更必须版本化,不破坏既有客户端。 -> **准确性 > 详尽度**:宁少写也别写错;**写不了确证的写「未确证」**,见 [§7](#7-在飞切片与未实现未确证)。 -> ⚠️ **v0.67 是「契约先行」登记版**:本版新增的三批 —— **语音消息**(播放端点 + 消息 DTO `voice`)、**非消息事件查询面**、**账号在线状态** —— 登记时**都还没有实现**,形状**取自设计文档**(`design/24 §10.11`、`design/26 §4.5` / `§2.6`)**而非代码核对**。**已落地**:账号在线状态(`52dac45`)、非消息事件查询面(切片 E6)—— 见 §2.4;**语音消息的「用户可见面」已由 v0.79 落地**(切片 L45:DTO `voice` + `GET /api/v1/media/{name}` + 副本落盘;残留缺口——WS 实时帧/握手回放不带 `voice`、AMR 不落副本(**后者已由 v0.80 修复**)——见 §4.5 / §7.2 / §7.4-29)。已在 §7.2 逐条如实标注。**本文档其余内容仍以代码为真源**。 -> **v0.68 是纯增量版(已实现,非契约先行)**:新增 1 条管理只读端点 `GET /api/v1/admin/config-facts`(配置事实面,见 §2.2 B12),并给 `GET /api/v1/admin/backthink` 的条目加 2 个可选字段(`urgency` / `quietHoursBypassed`,见 §2.2 B8)。**无破坏性变更**,老客户端零影响。 -> **v0.69 是缺口修复版(已实现)**:`PUT /api/v1/admin/channels/{platform}/config` 现在**接收并原样落盘**账号顶层 `directMsgPerMin`,`GET /api/v1/admin/channels` 的账号元素也**如实回显**它(§2.2 A5 / §2.3 C28 注)。修的是「面板保存一次渠道配置,就把主人手配的**关闭流控(-1)**静默重置回 `0` = 保护性默认 6 条/分」。**无破坏性变更**(老客户端不带该键,行为与 v0.68 相同)。 -> **v0.70 是精确化版(已实现,纯增量)**:`GET /api/v1/admin/backthink` 的 `urgency` **默认就是该轮实际生效的精确档位**(随记录落库),另加可选 `urgencyLowerBound:true` 专门标记**旧行的下限推导值**;实时 `backthink` 帧自本版起也带记录里的 `urgency`(§2.2 B8 / §4.10(b),并更正 §4.10(b) 原「实时帧不带这两键」的表述)。**无破坏性变更**(新键可选,老客户端不读即零影响;`urgency` 的既有取值域与 `quietHoursBypassed` 语义都未改)。 -> **v0.71 是链路图补版(已实现,纯增量)**:新增 1 条管理只读端点 **`GET /api/v1/admin/flow/graph`**,把 `ws.graph_update` 的**最近一轮快照**从「只能靠实时帧拿」变成「打开页面即可只读拉取」(§2.2 B13 / §4.11)。修的是「页面在轮次结束后才打开(或刷新过)⇒ `/#/admin/flow` 与后台思考行的『查看完整链路导图』永远空着」—— `graph_update` **只走实时帧**,前端图桶(`flowGraphs`)**不持久化**,此前**没有任何**补拉通路。**无破坏性变更**(新端点;`graph_update` 帧形状、角色门、发布会话范围**一字未改**)。 - -> **v0.72 是主动推送可见性版(已实现,含一处行为变更)**:`proactive_message` 那条消息现在**落进主会话历史**(`web` / `assistant` / `mode:"chat"`,先落库再发帧),帧新增可选 `id`/`createdAt`(主会话历史行的权威 id 与时间戳)供客户端把实时气泡对齐到历史行(§3.4.5 与随后的「落库口径」说明)。修的是主人 2026-10-05 报的「后台思考的主动推送一个都没推出来」—— 活跃客户端路由此前**只广播帧、一行都不写**,刷新即丢且前端零消费点。**两条路由互斥 ⇒ 主会话里恰好一条(不双份)**;`proactive_message` 帧**逐字不变的部分**是既有全部键,客户端不读新键即行为与 v0.71 相同(只会多看到那条历史行)。 - -> **v0.73 是链路图本地落盘版(已实现,纯增量)**:链路图从「只活在进程内存」变成**每轮一行落进本地库**(新表 `flow_graphs`,迁移 `0022`),`GET /api/v1/admin/flow/graph` 在 v0.71 之上**增量**:新增可选查询参数 `turnId`(省略 = 最近一轮;**先查内存 `LastGraph`,miss 才回退落盘**)、响应新增 `turnId` / `source` / `turns[]` 三键(**多轮历史回放**,`design/19 §4` 的 P3)。修的是主人 2026-10-05 追加要求的那句「链路流程应该有本地数据,不然每次重载之后页面就没数据了」—— v0.71 只覆盖「后端进程活着时的最近一轮」,后端一重启、或翻更早的轮次,仍然什么都查不到。**无破坏性变更**(旧键 `sessionId`/`graph` 形状语义不变,`graph_update` 帧、角色门、发布会话范围一字未改;老客户端不读新键即行为与 v0.71 相同)。落盘范围**只有**发布会话白名单内的会话(`flowSessionAllowed`),隔离渠道会话绝不入库。详见 **§2.2 B13 / §4.11**。 - -> **v0.74 是 IM 路由主动消息的提醒版(已实现,纯增量)**:`message_persisted` 的 `data` 新增**可选**结构化判据 `proactive:true` —— IM 路由(`im_bridge`)的主动推送在落库之后**追加**一条带该键的同类帧,客户端据此在**非前台**补一发浏览器通知(此前通知只挂在 `proactive_message` 上,而 `auto` 提示在主人有已连接 IM 渠道时**优先走 IM** ⇒ 实际最可能发生的路径恰恰不弹)。判据是**结构化字段**(不是正文/提示词):普通消息(Web 回合 / IM 回复 / IM 镜像)的帧**永不**带该键 ⇒ 不可能被误判成主动消息。同一行仍照旧发一条**不带**该键的普通镜像帧(v0.72 起的行为逐字不变,两帧同 id ⇒ 客户端按 id 去重后仍是一个气泡、仍只提醒一次)。详见 **§3.4.4 后的「`message_persisted` 的 `proactive` 判据」**。 - -> **v0.76 是三面增量版(⚠️ **逐条状态不同**:**① 已在 v0.78 实现、覆盖率又由 v0.79 补齐**;**②③ 已实现**)**:① **消息读取面新增 `toolCalls[]`(工具回看面)—— ✅ 已实现(v0.78,切片 CODE-TC),覆盖率已于 v0.79 补齐至全部编排回合**:`GET /api/v1/sessions/{id}/messages` 的消息对象加可选 `toolCalls`(`omitempty`),每条含工具名 / 状态(`ok`/`error`/`unknown`)/ 耗时毫秒 / **已脱敏**的入参与结果摘要;数据源是**已落盘的轮次链路图**(`flow_graphs`,迁移 `0022`;**v0.78 加迁移 `0023` 的按路查询键**,保留期 168h),**不从正文反解**。🔴 **缺席语义**:图取不到(超保留期 / 该轮未落盘 / 隔离渠道会话从不入库 / 该路标识查不到图)⇒ 字段**整体缺席**,**绝不回空数组**冒充「这一轮没调过工具」。⚠️ **历史说明(v0.76 登记时未实现的原因,实测确认非推断)**:`chat_messages.turn_id` 与 `flow_graphs.turn_id` **不同源** ⇒ 两者无法按消息字面 join,按时间窗猜映射项目口径不做;补齐需先让链路图带上**按路的回合标识**、并补一个**按路查图**的读侧入口。**v0.78 已把这两件都补上** ⇒ `toolCalls[]` 已实现;🔴 **v0.78 时只有真并行路能回看**(普通单路回合的图当时不带权威 `turnId`)—— **已由 v0.79(切片 CODE-TC-2)补齐**(见 **§4.4、§7.2、§7.4-23 与 §6 的 v0.78 ① / v0.79 ④**)。② **未读面(✅ 已实现)** —— `message_persisted` 的 `data` 加**可选** `unreadCount`(`omitempty`:只在 **>0 且确实算得出来**时出现;**缺席 = 老行为**),`GET /api/v1/sessions` 列表项同键(该键**自 D4 起就已在**,本版未改它)。口径 = **绝对未读 + 主人单一已读游标(`chat_sessions.read_at`)⇒ 多端共享**(Web 读了移动端也清),这是与安卓侧已对齐的**取舍**,不是缺陷。详见 **§3.4.4、§4.13**。③ **记忆整理试算加性异步** —— `POST /api/v1/admin/memories/tidy/dry-run?async=1` ⇒ `202` + `runId`;新增 `GET /api/v1/admin/memories/tidy/dry-run/{runId}` 轮询;**不带 `async` 仍是原同步行为**。**无破坏性变更**(新键 / 新可选参数 / 新端点;老客户端零影响)。详见 §1(会话列表与消息读取)、§2.2 B15 / §4.12.1、§3.4.4。 - -> **v0.77 是链路图按路分桶 + 记忆整理护栏/异步落地版(已实现;纯增量)**:① **`graph_update` 按路分桶**(切片 FV2)—— 帧 `data` 新增可选 `turnId`(该图的**路**的权威回合标识)与 `round`(会话轮次号);真并行(`parallel:true`)时同一会话**每一路各发自己的帧**(`data.id` / `data.turnId` 各不相同),两路不再互相顶掉;`GET /api/v1/admin/flow/graph` 响应新增 `lanes[]`(**最近一轮每一路**的图,恒为非 null 数组),`graph` 语义不变(最新一路 / 指定轮 / `null` 诚实空态)⇒ **老的读取方零改动**。**无破坏性变更**(两个新键可选;单路时 `turnId` 缺席 ⇒ 客户端行为与 v0.75 逐字相同)。详见 **§2.2 B13 / §3.4.5 / §4.11 / §7.4-19**。② **记忆整理护栏抬高 + 诚实截断 + 试算异步落地**(切片 MT-A)—— 每分区候选上限 `MEMORY_TIDY_CANDIDATE_LIMIT` 默认值 `20 → 200`、两两比对上限改为 env 键新键 `MEMORY_TIDY_COMPARE_LIMIT`(默认 `2000`,原先**写死** `400`);配置事实面 **12 → 13 条**;`truncated` 新增第 4 种标签 **`(budget)`**(墙钟时间预算截断);**v0.76 ③ 的加性异步已落地**(`?async=1` + B15 轮询可用;新错误码 **`tidy_run_not_found`**)。详见 **§2.2 B14/B15 / §4.10(a) / §4.12 / §4.12.1 / §5**。③ **「入库前审查」的 4 个 env 键登记**(切片 MT-B,纯增量)—— `MEMORY_REVIEW_ENABLED`(`true`)/ `MEMORY_REVIEW_QUEUE_SIZE`(`256`)/ `MEMORY_REVIEW_SCAN_LIMIT`(`200`)/ `MEMORY_REVIEW_TIMEOUT_SEC`(`20`);⚠️ 该切片的**面板读侧 / 回滚端点目前没有 HTTP 入口**(见 §7.2)。详见 **§5**。 - -> **v0.84 是「显示名 + 段落标签硬约束」版(已实现并推库:`c46053e` 显示名 / `8051906` 段落修复 / `bc1dee3` provider 配置面去 env 化;**一处形状变更(插件动作响应)、一处新增硬约束;无破坏性变更**)**:① **`plugin.providers` 候选响应形状:裸字符串数组 → 对象数组 `{"options":[{"value":,"label":<中文显示名>},…]}`**(§4.14(c))。修的是主人报障「中文搜索接口在管理后台下拉菜单里全给的英文名,你甚至不愿意带个括号把中文名写上去」—— 根因不是漏写文案,而是**整条链路没有「显示名」这个位置**(字段声明只有 `optionsFrom`,动作回的就是纯 id 数组)。🔴 **两条硬口径**:**(i) `label` 仅供显示,`value` 才是落盘值**(把 `label` 提交进 `provider` 会写坏配置文件);**(ii) 前端解码器兼容三种形状**(裸字符串 / `{value,label}` / `{id}`)⇒ 仍返回裸字符串的其他插件与老声明**零影响**。表格单元格按 `value→label` 索引显示、查不到回落原值;多选(降级链)改为「候选开关(显示 label、写 value)+ 落盘值输入框」。② **产出侧新硬约束(§4.16)**:助手文本(落库 / 对外 / IM 镜像)**不得出现未配对的段落标签**(落单 `

` 或 `

`)—— `一个

= 一段`,**段落不嵌套**。修的是安卓端登记的 `

正文

` 畸形(他们按非贪婪契约必然把 `

` 显示给用户);**实测两半都是产出侧造成**:规整器**原样放过**该形状,且会在「末段漏 `

`」时**亲手套出**它(并顺带修掉一个幂等漏洞:落单 `

` 会越跑越多)。🔴 **历史行不回填**,消费方**仍应保留**剥落单标签的容错。③ **模型面登记(非客户端契约面,供跨端对照)**:`web-fetch` 的**浏览器渲染降级档已接线**(`design/28 §1 M5 / §8 B5`),其返回体用 **`fetchedBy`**(`direct` / `browser`)**如实标注正文实际来自哪条路**,两条档的失败各自留痕(`browserError` / `directError`),**绝不静默替换**;被 SSRF 拒绝的目标**不换路**。④ **计数**:本版**无新端点** ⇒ §2.0 权限矩阵维持 **14 + 24 + 40 = 78**。⑤ ⚠️ **诚实边界**:**`web-search` 的真机工具路径在登记时仍有一处未部署的修复**(智谱对「热搜/资讯」类查询返回的条目 `link` 整批为空,旧判据把「无链接」当「不是结果」整批丢光 ⇒ 工具报 `no results` 而面板探针报 `ok` —— 因为探针**不过 `Finalize`**,对这一类缺陷**结构性失明**)。修复已推库(`2c56bc9`:无链接条目照原样保留、只有 link/title/content/snippet 全空的空壳行才不算结果),**要等下次 `build` + `restart` 才真机生效**。**教训登记**:**别拿面板探针通不通来推断工具路径通不通**。 - -> **v0.83 是记忆整理「应用面」版(已实现并推库:切片 TIDY-1 `454ddb0` + 读侧 status 闸 `9126d65` + 网关端到端回归 `f847769`;**纯增量、无破坏性变更**)**:① **新增 5 条 `writeAuth` 端点**,补上「整理只产建议、永远不落地」这个两天没闭环的缺口 —— **B20** `GET /api/v1/admin/memories/candidates`(候选列表)、**B21** `POST …/candidates/{id}/apply`(单条应用)、**B22** `POST …/candidates/apply?runId=&limit=`(批量应用;`runId` 省略 = **全部待定**)、**B23** `POST …/candidates/{id}/revert`(单条回滚)、**B24** `POST …/memories/tidy/run`(手动**真跑**一轮:写候选 + 留痕,**不自动应用**)。语义与形状见 **§4.15**。🔴 **三条硬口径(客户端按此编程)**:**(i) 幂等不是错误** —— 已决定过的候选回 `200` + `status:"skipped"`,界面**不得**画成失败;**(ii) 超限必须如实** —— 批量回执的 `total` 是**真正命中筛选的待定候选数**,而 `applied+skipped+failed` 只覆盖 `limit` 之内,两者不等时 `truncated:true`(不许把「本轮 100 / 共 342」画成「全部完成」);**(iii) 永不物理删除** —— merge / archive 都是**软动作**(只改 `status`,正文一个字不改、行一行不删),因此 `revert` 无损可逆。② **读侧 status 闸(行为修复,形状零变化)**:记忆的**四条列表读路径**(`ListByPersona` / `List` / `ListScope` / `ListProject`)现在只回 **`status='active'`** 的行 ⇒ 被软合并(`'merged'`)与被归档(`'archived'`)的记忆**不再被召回、不再进上下文、也不再出现在面板 `GET /admin/memories`**(此前它们照样出现 ⇒ 等于「清了也白清」)。🔴 **刻意不过闸的两处**:按 id 的 `Get`(撤销软合并必须先读得到被吸收的行)与 **`GET /admin/memories/reviews`**(审查留痕**本身没有 status 概念**,且它是软删行的查看与撤销入口 —— 加闸等于审计自我抹除)。③ **调度不再饿死(内部行为,对客户端零形状影响)**:整理从「ticker 从进程启动算起、重启十几次就永远走不完 24h」改成**到点即跑 + catch-up**,以 `memory_tidy_runs` 的留痕行为**持久事实**判「上次到底跑过没有」,并把手动真跑端点(B24)做成 panic button。④ **计数**:`writeAuth` 段 19 → **24**、管理路由总数 73 → **78**(`14 + 24 + 40`;按 `routes.go` 逐条核对:经 `writeAuth` 挂载的 **admin** 路由实有 **26** 条,按历史口径减去 §2.4(a) 的 `channels/events` 与 `.../counters` ⇒ **24**)。⚠️ **诚实边界(别当已验证)**:**生产库全程零动作** —— 1191 行记忆与 77 条待定候选**都没有被应用或清理**(等主人拍板,见 §4.15(d));**面板上的真机端到端未跑**(前端操作面在本版登记时尚未落地,并行切片在做);旧候选的 `run_id` 是空串 ⇒ 「**按 run 批量**」找不到它们,要清它们必须走**全部待定**那一档;候选表的 `run_id` 列目前**只在存储包前滚**(`memory.NewStore`),**未**同步进迁移 `0019`。 -> -> **v0.82 是三面增量版(① 已实现(切片 Y1 `215eedf`);② 已实现(切片 Y3 `4213b26`);③ 形状已定、代码在树(切片 Y4 `e0a6bca` + 组合根挂点);**纯增量、无破坏性变更**)**:① **`tool_execution.end` 的 `data` 新增可选 `resultSummary`(string)** —— 工具卡从此能在**实时**显示「这次调用拿到了什么」,不必等事后翻回看面。它修的是「实时只看到工具名与状态、结果要事后才知道」。🔴 **缺席语义(安卓端按此编程)**:**键在场 ⇒ 渲染结果区;键缺席 ⇒ 只显示工具名与状态** —— **内容与回看面 `toolCalls[].resultSummary` 同源同口径**(同一份**已脱敏**文本 `trace.RedactText`:凭据形状清洗 + 截断)、**同一个上限 `≤500` rune**(超长按 rune 截断、末位 `…`);**空值 ⇒ 整键缺席,绝不用空串顶位**(空串会让客户端渲染出一个空的结果区)。**只有「真的没有结果」才缺席**:调用成功但结果为空(nil / 空串)、或错误文本本身为空;`tool_execution.start` / `.update` **不带**该键。⚠️ **失败也带**(本版口径):调用失败 / 取消 / 超时的 `.end` 帧带的是**该次错误文本的脱敏渲染** —— 与回看面(那里失败调用回的是图上的 `error` 摘要)**给同一份说法**,客户端不必为「实时」与「回看」写两套判据。详见 **§3.4.2**。② **自排的成本可见面**(切片 Y3,design/12 §N C.5/C.6)—— `GET /api/v1/admin/backthink` 新增**可选** `?trigger=` **服务端筛选**(取值域 = **闭集**:三个内建标签 + 本服务订阅的事件主题,真源 `backthink.TriggerFilterValues()`;值**未知或为空** ⇒ **`400 invalid_trigger`**,**绝不静默忽略** —— 静默忽略会让面板在显示全部的同时声称「正在筛选」;合法值**下推 SQL**,因此 `limit` 数的是**匹配**条数);新增 1 条 `writeAuth` 只读端点 **`GET /api/v1/admin/backthink/self-wake`**(§2.2 B19 / §4.10(e),**三态** `available`/`running`/`reason` + `gate` 快照 + `configFile`)。⚠️ **`trigger` 的「显示口径」仍是开放的**(客户端按字面值显示、不做闭集校验)—— **筛选白名单**与**显示取值域**是两件事,别混(见 §4.10(d))。③ **`browser` 插件管理面板(B4)**(切片 Y4,design/28 §8)—— `GET /api/v1/admin/panels` 的面板目录新增 `browser` / `settings`(「浏览器与截图」,4 个区块:配置表单 + 状态 / 指纹 / 截图三张只读表),该插件声明**六个动作**(3 读 3 写,见 §4.14(d));**不开任何新路由**(走既有 A14 通用动作分发),因此 §2.0 计数**只**因 ② 的 self-wake 端点 +1。**模型面**(非客户端契约面)另有一条:`browser` 工具的 `screenshot` 返回值新增 **`turnAttachment`**(闭集 `queued` / `no-session` / `outbox-full` / `disabled`)。**④ 本版把 §4.10(d) 一句已成假话的口径改正**:`request_think` **已接线**(`cmd/cyrene/wire_services.go` 的 `wireSelfWakeTool`;启动日志 `self wake tool registered tool=request_think`)⇒ `trigger="self_wake"` 与 4 条 `backthink.self_wake.*` 审计动词**在生产里会真的出现**。**⑤ 计数**:`writeAuth` 段 18 → **19**、管理路由总数 72 → **73**(`14 + 19 + 40`)。⚠️ **登记时的工作树状态(如实)**:③ 的**组合根两个挂点**(`cmd/cyrene/wire_frontend.go` 的 `wireBrowserPanel(hub, a, a.log)`、`cmd/cyrene/wire_runtime.go` 的 `TransformContext: a.browserShotTransform(visionResolver),`)与 ① 的**失败也带摘要**(`internal/runtime/loop/tool_frame_summary.go`)在本版登记时**仍在工作树、未提交** ⇒ 提交前,跑着的后端里**没有**这些行为;提交后请按 `git show` 逐字复核。 - -> **v0.81 是「她自排轮的用户可见面 + 时间轴保留期信号 + `round` 跨重启单调修复」版(切片 C1-1 `94da6bc` / C1-2 `ed50053`(自排)+ 切片 X1(时间轴两处真缺陷);**无新端点、无破坏性变更**)**:① **`GET /api/v1/admin/backthink` 的行 `trigger` 新增取值 `"self_wake"`**(= 这一轮是**她自己排起**的思考,`design/12 §N C`)—— `trigger` 的取值域**不封闭**(`heartbeat` 周期心跳 / `startup` 启动轮 / 事件轮 = 事件主题 / `self_wake` 自排),客户端**按字面值显示、不要当闭集校验**(见 §4.10(d))。⚠️ **可达性**:她的入口工具 `request_think` **尚未接线** ⇒ 该取值与 ② 的动词在生产里**目前不会出现**(形状先登记)。—— ✅ **本条已过期,已由 v0.82 ④ 改正**:`request_think` 已接线(`cmd/cyrene/wire_services.go` 的 `wireSelfWakeTool`,切片 X2 `2d211c2`),生产里**会真的出现**。② **新增 4 条审计动词** `backthink.self_wake.{queued,coalesced,capped,denied}`,经既有 `GET /api/v1/admin/observability/audit`(B9)可查,`who` 恒为 `backthink`(加性,见 §4.10(d))。③ **时间轴新增 `retentionBoundaryReached`(bool)并与 `truncated` 分意**:`truncated:true` **只**表示「本次不是全部」(分页未到底 **或** 落盘源未接线),**不能**据此断言「更早的轮次已被保留期清扫」——那件事由 **`retentionBoundaryReached:true`** 单独回答(判据三条缺一不可 + **三个盲区如实登记**,见 §4.11.1(a)(b)-3)。④ **`round` 跨重启不再重复**(发布器先从落盘播种计数器):修的是「重启后轮次号从 1 重来 ⇒ 时间轴把重启前后两轮并成一块」的真机缺陷,契约里「`round` 每会话单调」这句**从此真的成立**(见 §4.11.1(e))。⑤ **计数**:本版**无新端点** ⇒ §2.0 权限矩阵维持 **14 + 18 + 40 = 72**(§2.2 段内 B1–B18 共 **18** 条)。⚠️ **同批对客户端零形状影响(如实说明)**:技能渐进披露接线(L2 `6475eb8`)、示例技能与端到端验收(V3 `4a04ad4`)、浏览器 B1 接线 + B2 薄层工具面(V5 `f1fbc24`)**都不改变任何 REST / WS / admin 形状**。⚠️ 括注原文写「浏览器是**模型面工具**,不进客户端契约面」—— **对 B1/B2 成立,但已部分过期**:v0.82 的 **B4**(浏览器管理面板,切片 Y4)**进**客户端契约面(`GET /admin/panels` 多一条目录项 + 六个动作走既有 A14),见 **§4.14(d)**。⚠️ **③④ 的形状取自切片 X1 的工作树实现,登记时尚未提交** ⇒ 提交后请按 `git show` 复核键名与判据。 - -> **v0.80 是三批增量版(均**已实现**,**纯增量、无破坏性变更**)**:① **链路图时间轴出口**(切片 W1,`design/19 §7`)—— 新增 1 条管理只读端点 **`GET /api/v1/admin/flow/timeline`**(§2.2 B18 / §4.11.1),把「链路图」从「**最近一轮** + 已落盘轮次的**键列表**」扩到**按时间正序的多轮时间轴**(每轮含该轮**每一路**),支持 `limit`(缺省 **50** / 硬上限 **200**)与 `before` 游标向**更早**翻页;响应另给 `retentionHours` / `retentionEnabled` / `truncated`,供面板**如实**标注「更早的轮次已过保留期」。修的是主人 2026-10-06 的原话「web 后台的全链路流程图我要的是**所有消息按时间顺序串起来**的流程图,而不是只有最新一个来回」。老端点 `GET /api/v1/admin/flow/graph`(B13)**逐字未改**(老客户端零改动)。② **搜索 provider 热重载 + 主 provider / 降级链**(切片 W3;裁决 2026-10-06 #1 / #2)—— `plugin.saveProvider` 成功落盘后,组合根**按落盘后的配置重建 web-search 工具并原子替换能力注册表里的条目**(`registry.ReplaceTool`,保留运行期策略)⇒ **下一轮对话即用新配置、不需要重启**;若这次改动会让 web-search **没有可用后端**(下次启动会 fail-fast)⇒ **文件按字节回滚** + **`409 config_unstartable`**。同一次提交可带两个**文件级**字段 `primary` / `fallback[]`(缺位 = 不改动);响应新增机器可读事实 **`hotReload:{applied,registered}`**;新增错误码 **`primary_not_configured`(400)** / **`config_unstartable`(409)**。🔴 **本版把三处已成假话的旧口径改正**(§4.14(b)(c) / §7.2)—— 此前写的「面板保存后**不**热替换运行中的工具、需重启」与「面板**无**主 provider / 降级链字段」**都不再成立**。⚠️ **未确证(如实)**:真机面板上「新增/改一家 provider → 下一次搜索真的走它」的端到端、以及**真机探针打真实上游**,都没跑。③ **入站语音 AMR 也可回放**(切片 W4;裁决 #11)—— 组合根补齐 `channelbridge.Options.VoiceTranscoder` 注入(`cmd/cyrene/wire_channels.go`)⇒ **不可播格式的入站语音在落盘之前先转成 `.wav` 再落副本**(`mime: audio/wav`;本期唯一必需的转码路 = QQ OneBot v11 的 `amr → wav`,见 `internal/runtime/voice/playable.go`),客户端从此能拿到可回放的字节;此前走 `category=transcode_not_wired` 的诚实 WARN、**不落副本**。**形状零变化**(DTO `voice` 的键集合与语义一字未改;可播格式仍**原样落、一次转码都不发生**)。🔴 **同批改正 §4.5 / §7.1 / §7.2 / §7.4 里已成假话的「组合根未注入 `VoiceTranscoder` ⇒ AMR 不落副本」**(`transcode_not_wired` 从此只剩「引擎未装配 / 构造失败」一档;旧版本横幅保留当时状态,另加「已由 v0.80 修复」注)。⚠️ **未确证(如实)**:③ 的**生产端到端未跑**(红线:不重启后端)⇒ 本期证据是 `channelbridge` 的行为单测(真 AMR 夹具 → 真引擎 → RIFF/WAVE)与 `cmd/cyrene` 的**源码级接线用例**。详见 **§6**。 - -> **v0.79 是「面板动作真可用 + 记忆审查/语音用户可见面 + 链路图回合标识全覆盖」版(四批,均**已实现**;**唯一一处形状变化**)**:① **插件面板动作真可用**(切片 L1/PLG-2 的执行面接线)—— `POST /api/v1/admin/plugins/{id}/actions/{action}` **不再一律 501**:web-search 的四个动作 + 下拉候选来源(`plugin.providers`)都已登记**生产处理器**,**真读写** `$DATA_DIR/config/plugins/web-search.json`、探针**真发一次 HTTP**;失败回**结构化非 2xx**(错误码/文案/状态由插件侧给出,宿主只透传),新增 `invalid_payload` / `unknown_provider` / `not_configured` / `config_unreadable` / `config_write_failed` 与一批探针码(502);**只有「已声明但宿主未登记处理器」才回 `501 action_not_implemented`**(不再是兜底档)。审计新增附加字段 `code`(**不进 DB 记录体、不含请求体**)。详见 **§2.1 A14 / §4.1 / §4.14 / §7.2**。② **「入库前审查」(D3)的管理面落地**(切片 L45)—— 新增 2 条 `writeAuth` 端点 **`GET /api/v1/admin/memories/reviews?limit=`**(留痕列表,**不含记忆正文**;`limit` 缺省 100 / `≤0` 同缺省 / >500 钳位 / 非整数 `400`)与 **`POST /api/v1/admin/memories/{id}/revert`**(撤销一次软合并,**无损可逆**,落审计 `memory.review.revert`);未接线 ⇒ `500 memory_review_unavailable`(**绝不回空数组**假装没有留痕)。详见 **§2.2 B16/B17 / §4.1**。③ **语音消息的用户可见面落地**(切片 L45)—— 消息 DTO 的 **`voice`** 现在真的填充(REST 读取面 + `message_edited`),新增 **`GET /api/v1/media/{name}`**(200/206/304/416/`HEAD`;`audio/*` 内联 + `nosniff` + `Accept-Ranges`;**无 `ETag`**;名字非法 / 穿越 / 缺失统一 `404 media_not_found`),副本真落 `$DATA_DIR`(`documents.SaveAudio`,内容寻址、0600)。⚠️ **残留缺口(如实)**:`message_persisted` / `channel_persisted` 帧与握手 `history` 回放**仍不带 `voice`**;组合根**未注入 `VoiceTranscoder`** ⇒ **AMR 不落副本**(可播格式照落)—— ✅ **AMR 这一项已由 v0.80(切片 W4)修复**。详见 **§1.2 U19 / §3.4.4 / §4.1 / §4.4 / §4.5 / §7.2**。④ 🔴 **链路图的权威回合标识覆盖到全部编排回合**(切片 CODE-TC-2)—— 编排器驱动的**每一路**(含**普通单路回合**与队首续跑路)的图现在都带权威 `turnId`,取值 = 该轮消息行的 `chat_messages.turn_id` ⇒ `GET /sessions/{id}/messages` 的 **`toolCalls[]` 覆盖率从「只有真并行路」扩大到「全部编排回合」**(普通单路聊天与队首续跑回复现在也能回看工具)。🔴 **唯一形状变化**:`graph.id` 的 `#{回合标识}` 后缀现在**单路也追加**(`web-7` → `web-7#turn-…`)⇒ 客户端**一律使用响应里给出的 `graph.id` / `turns[].turnId`**,**不要自己拼** `{会话}-{序号}`。**缺席语义一字未改**:拿不到权威标识的写入路径(未接编排器的调用方、**后台思考** `sess-backthink`、旧后端)`turnId` 仍缺席、`toolCalls` 仍整体缺席;**v0.79 之前的单路历史行不回填**。详见 **§4.4 / §4.11 / §7.2 / §7.4-23**。**无破坏性变更**(新端点 / 新可选键 / 新错误码;老客户端若不自己拼 `graph.id` 则零改动)。 - -> **v0.78 是六面增量版(⚠️ **①~⑤ 已实现;⑥ 只到落库面**(用户可见面**已由 v0.79 补齐**);以纯增量为主,无破坏性变更)**:① **消息读取面 `toolCalls[]` 落地**(切片 CODE-TC)—— v0.76 ① 从「未实现」改为**已实现**(数据源 `flow_graphs` + 迁移 `0023` 的**按路查询键** `lane_turn_id`);🔴 **v0.78 时覆盖率受限(如实)**:只有**真并行路**的图带权威 `turnId`,普通单路回合**查不到图 ⇒ 字段缺席**(契约允许,但不是想要的覆盖率)—— **已由 v0.79 补齐**(见 §6 v0.79 ④)。详见 **§4.4 / §7.2 / §7.4-23**。② **插件管理面板接入点(管理面新增两条路由 + 一条 WS 帧)**(切片 PLG-2)—— 新增 `GET /api/v1/admin/panels`(**按调用者角色过滤**后的接入点快照,viewer 可读)与 `POST /api/v1/admin/plugins/{id}/actions/{action}`(**只接受该插件声明过的动作名**,未声明 ⇒ `404 action_not_declared`;写动作 `writeAuth` + 审计);WS 下行帧 **`panels_changed`**(插件生命周期变化后推,**只发 owner/admin**)。⚠️ **动作执行器在 v0.78 无生产实现 ⇒ 当时所有已声明动作都回 `501 action_not_implemented`** —— **已由 v0.79 接线**(见 §6 v0.79 ① 与 §4.14)。详见 **§2.1 A13/A14 / §3.4.5 / §4.14**。③ **搜索 provider 可变源**(切片 PLG-4)—— 插件 `web-search` 的配置从 `.env` 迁到 `$DATA_DIR/config/plugins/web-search.json`(0600,文件名 = 插件 id),组合根「**先可变源、缺失回落 `.env` 播种**」;面板数据面 = `plugin.listProviders` / `plugin.saveProvider` / `plugin.testProvider` / `plugin.reauthProvider` 四个动作。🔴 **不新增任何 env 键**(`.env` 已冻结为引导面)。详见 **§4.14(c)**。④ **账号上下线落事件 + 渠道事件实时帧**(切片 E7)—— 账号 online/offline 迁移落进 `internal/runtime/events`(复用迁移 `0021`,`tier=t2` / `category=lifecycle`),并经**既有进程内 bus** 推成 WS 下行帧 **`channel_event`**(**只发 owner/admin**,viewer 走 REST 只读档)。⚠️ **当前只覆盖账号状态迁移这一路**;覆盖其余全部渠道事件的装饰器**未接线**(见 §7.2)。详见 **§3.4.5**。⑤ **微信 iLink 账号状态三态**(切片 CODE-F3)—— `GET /api/v1/admin/channels/{platform}/login-status` 新增可选 `authState` / `authDetail`(**有效 / 被拒 / 未知**);🔴 **`errcode=-14` 是「被拒」不是「过期」**,**未知同样不许写成「过期」**。⚠️ `GET /admin/channels` 的 `accounts[]` **每账号**三态**未实现**(见 §7.2)。详见 **§2.4(c)**。⑥ **语音消息落库面**(切片 CODE-VOICE)—— 迁移 `0020` 给 `chat_messages` 加 `voice` 列(`TEXT NOT NULL DEFAULT ''`);**用户可见面(DTO `voice` + `GET /api/v1/media/{name}`)在 v0.78 时未实现,已由 v0.79 落地**(切片 L45,见 §6 v0.79 ③);残留缺口(WS 实时帧 / 握手回放不带 `voice`;AMR 那一项**已由 v0.80 修复**)见 **§4.5 / §7.2**。**无破坏性变更**(新端点 / 新可选键 / 新帧;老客户端零影响)。 - -> **v0.75 是记忆整理试算版(已实现,纯增量)**:新增 1 条管理端点 **`POST /api/v1/admin/memories/tidy/dry-run`**(**只读语义的 POST**,`writeAuth` = owner/admin)—— 跑**同一套**记忆整理规则(召回门 + 包含判定 + 规则否决,与低峰定时轮共用代码路径),把候选**直接回在响应里**,**一行都不写**(不写 `memory_tidy_candidates` / `memory_tidy_runs`,绝不改 `memory_items`)。修的是「整理器只在 23:00–07:00 跑(design/23 §4),在它第一次开跑前面板只能空着」——主人要「现在就想看到在 736 条上它会建议合并哪些」。**零副作用是硬约束**(后端有逐行快照单测钉死);失败**一律如实作答**(`tidy_unavailable` / `tidy_disabled` / `tidy_recall_unavailable` / `tidy_timeout`),**绝不回空候选数组假装「没有建议」**。**无破坏性变更**(新端点;既有端点/帧一字未改)。详见 **§2.2 B14 / §4.12**。 - ---- - -## 0. 基础约定 - -### 0.1 基址与版本 - -- **Base URL**:`http://:`。`PORT` 默认 **8080**(`env:"PORT" default:"8080"`);生产一般经反代/HTTPS,运行实例可能自定义端口。 -- **唯一对外端口**:单二进制、单端口(运行实例实测 `:10942`)。REST、WS、管理 API、静态资源**全在同一端口**;不另有监听端口(唯一登记例外:`qq-obv11` 渠道 OneBot 反连 server 模式,见 design/08 §4,与客户端契约无关)。 -- **版本前缀**: - - 用户侧 REST:`/api/v1/{resource}` - - 管理侧 REST:`/api/v1/admin/*` - - 实时:`/ws/chat`(WebSocket) -- 破坏性变更升 v2;本文档内的纯增量变更不升。 - -### 0.2 认证与授权 - -| 凭据 | 用法 | 作用面 | 备注 | -|---|---|---|---| -| **Bearer JWT** | `Authorization: Bearer ` | 用户侧 + 管理侧**全部已认证端点** | RBAC:`owner` > `admin` > `viewer`(递增)。access token TTL **24h**(`tokenTTL`)。 | -| **`X-API-Key`** | 请求头 `X-API-Key: ` | 用户侧 + 管理侧全部已认证端点 | **命中签发表即 `owner` 级**(不做角色分档,裁决 15.4.3)。**带该头时只走 key 分支**:未知/已吊销/空值 → `401`,**绝不回落 Bearer**(fail-closed)。key 表未注入 = 空表(带头一律 401)。 | -| **WS 握手** | `?token=` 查询参数**或** `Authorization: Bearer ` | 仅 `/ws/chat` | **只认 JWT**(`auth.Verify`)。**不接受 `X-API-Key`**。失败在升级前回 `401 {"error":{"code":"unauthorized",...}}`。 | - -- JWT claims 的 `role` 决定授权分档;`sub` 是账号标识。 -- `X-API-Key` 命中后 `sub` 记作 `apikey:<脱敏 id>`(审计可定位哪把 key,但绝不含密钥材料)。 -- **身份/权限绝不来自提示词或 LLM 判断**(项目红线):一律由 channel 层强类型 `actor` 元数据 + 白名单 + 策略引擎决定。 - -### 0.3 响应信封 - -- 成功:`{"data": <载荷>}`(个别端点直接回二进制,见 §4.5 语音)。 -- 失败:`{"error":{"code":"<机器可读码>","message":"<人读文案>"}}`。 -- 状态码全集(代码实际使用):`200 201 202 206 304 400 401 403 404 409 413 415 416 429 500 501 502 503`。 - - ⚠️ **`502` 是 v0.78 引入**(插件动作执行失败 ⇒ `POST /admin/plugins/{id}/actions/{action}` 的 `action_failed`,见 §4.14(b));其余状态码集合未变。 - - ⚠️ **`206` / `304` / `416` 是 v0.79 起「实际使用」**(`GET /api/v1/media/{name}` 落地,见 §1.2 U19 / §4.5):`206`(`Range` 部分内容)/ `304`(条件请求)由 Go 标准库给出;`416`(`Range` 语法非法或范围不可满足)是**非信封**的 —— 标准库直写 `text/plain` + `Content-Range: bytes */`,**不套** `{"error":…}` ⇒ 客户端**不要**拿 `error.code` 去解析 `416`。 -- **客户端按 `error.code` 分支,不要按 `message` 文本**(文案会随修改变动)。 - -### 0.4 三档标注惯例 - -本文档与「变更记录」用三档描述每次变更对客户端的影响: - -- **纯增量**:只新增端点 / 帧 / 可选字段 / 取值。老客户端不读新键时**行为逐字不变**。 -- **行为变更**:既有端点/帧的语义或响应面**变了**,客户端需改处理(如新状态码、新时点)。 -- **破坏性**:既有字段/形状被移除或改义,老客户端会坏。 - -### 0.5 未接依赖时的诚实边界(fail-visible) - -后端某些端点依赖注入的存储/服务;未接线时**明确报错或退化为诚实空结果**,绝不返回假数据: - -| 依赖 | 未接线时的行为 | -|---|---| -| `HistoryStore` | `GET /sessions` 回退到渠道会话视图;`POST /sessions`、`GET /sessions/{id}/messages`、`PATCH/DELETE /sessions/{id}`、`POST /sessions/{id}/read`、`PATCH/DELETE /messages/{id}` → `501 not_implemented` | -| 语音服务 | `POST /audio/*` → `500 voice_unavailable`;WS `voice_input` → `error` 帧 `{"message":"voice unavailable"}` | -| 渠道事件存储(`internal/runtime/events`) | `GET /admin/channels/events` 与 `.../events/counters` → `500 channel_events_unavailable`(**v0.67 已实现**,见 §2.4(a);绝不回全 0 假汇总) | -| LLM usage 出口 | `GET /admin/llm/usage` → `500 usage_unavailable`(绝不回全 0 假汇总) | -| 活跃 SQLite 连接 | `GET /admin/system/storage` → `500 storage_unavailable` | -| 可观测日志缓冲 | `GET /admin/observability/logs` → `501`(组合根注入前) | - ---- - -## 1. 用户侧 REST 端点 - -### 1.1 公开端点(无需认证) - -| # | 方法 | 路径 | 状态码 | 要点 | -|---|---|---|---|---| -| P1 | GET | `/api/v1/health` | 200 | 存活探针 → `{data:{status:"ok"}}` | -| P2 | POST | `/api/v1/auth/login` | 200 / 400 / 401 / 500 | body `{username,password}` → `{data:{token,expiresAt,role,refreshToken?}}`。access 24h;`role` 恒 `owner`;`refreshToken` 仅在接了 refresh 存储时出现(`AUTH_REFRESH_TTL` 默认 720h)。凭据错 → `401 unauthorized`。 | -| P3 | POST | `/api/v1/auth/refresh` | 200 / 400 / 401 / 500 | body `{refreshToken}` → 新 access + 新 refresh(默认**单次使用轮换**)。未知/已吊销/过期 → `401 invalid_grant`。`AUTH_REFRESH_ROTATE_DISABLED=true` 时旧 token 保持有效、不返回新 refresh。未接存储 → `500 refresh_unavailable`。 | -| P4 | POST | `/api/v1/auth/logout` | 200 / 400 / 401 / 500 | 带 `{refreshToken}` → 撤单份 `{data:{revoked:bool}}`(幂等,未知也 200);带 Bearer 且无 body → 撤该账号全部 `{data:{revoked,revokedCount}}`(**两种响应形状不同**)。两者都无 → `400`。 | - -> 登录/刷新/登出**公开**是刻意的:非透明 refresh token 本身就是凭据,故过期 access 的客户端仍能轮换/吊销它。它们对非法 token **fail-closed**。 - -### 1.2 用户端点 - -| # | 方法 | 路径 | 鉴权 | 状态码 | 要点 | -|---|---|---|---|---|---| -| U1 | POST | `/api/v1/chat` | owner/admin | 200 / 202 / 400 / 409 / 500 / 503 | 主对话入口,见 [§4.6](#46-chat-请求与响应面)。body `{sessionId?,text,image?,deviceId?,mode?,clientMsgId?,documents?,parallel?,stream?}`。 | -| U2 | GET | `/api/v1/sessions` | 任意已认证 | 200 / 500 | 会话列表 `{data:[{id,type,title?,createdAt,pinned,unreadCount,lastMessage?,personaId?}]}`。**排序:`pinned DESC, (type='main') DESC, created_at ASC`** —— 置顶的次级会话会排在主会话之前,**勿假定 index 0 是主会话**。主会话 `id="web"`、`type="main"`;子会话 `web:`、`type="sub"`。history 未注入时回退渠道会话视图。 | -| U3 | GET | `/api/v1/personas` | 任意已认证 | 200 | 角色目录 `{data:[{id,name,character,isDefault,mode,memoryNamespace,avatar?,chatBackground?,mood}]}`。`mood` 为空 = 该角色暂无被跟踪情绪(不伪造)。**不含** admin 专属 `thinking` 旋钮。 | -| U4 | GET | `/api/v1/meta/enums` | 任意已认证 | 200 | 共享枚举权威表 `{data:{platforms,moods,modes,unknown}}`;每项 `{id,label}`。`unknown` 恒 `"display-raw"`。**未知取值原样显示,不要丢弃/报错**(服务端新增取值是非破坏性变更)。详见 [§4.7](#47-枚举权威表)。 | -| U5 | POST | `/api/v1/audio/transcribe` | **owner** | 200 / 400 / 500 | STT。body `{audio,format?,deviceId?}` → `{data:{text, provider?}}`。`provider` 只在结果为空/降级时出现(如 `"silent"`)。空 audio → `400`;未接语音 → `500 voice_unavailable`;识别失败 → `500 stt_failed`。**owner-only + 审计**。 | -| U6 | POST | `/api/v1/audio/speech` | **owner** | 200 / 400 / 500 | TTS。body `{text,voice?,format?,instruction?}` → **`200` 原始音频字节**(`Content-Type: audio/`,**非 JSON 信封**)。空 text → `400`;未接语音 → `500 voice_unavailable`;失败 → `500 tts_failed`;静默空音频 → `500 tts_empty` 且响应头 `X-Voice-Provider: silent`。**owner-only + 审计**。 | -| U7 | POST | `/api/v1/audio/voice-clone` | **owner** | 200 / 400 / 500 | 声音复刻建音色。body `{url,prefix?}` → `{data:{voiceId}}`。参考 URL **绝不进日志/审计**。空 url → `400`;失败 → `500 voice_clone_failed`。 | -| U8 | POST | `/api/v1/upload` | owner/admin | 201 / 400 / 413 / 415 / 500 | **图片上传**,`multipart/form-data` 字段 `file`(光栅图,**单文件字节 ≤ 5 MiB**,即 `maxUploadBytes = 5 << 20`)→ `{data:{url,size,mime,name}}`。`url` 是**绝对同源 URL**,可直接作 `chat.image`(该路径的 5 MiB 在**上传时**定死,与 `chat.image` 的字符长度判据无关,见 [§4.5](#45-图片--文档--语音链路))。嗅探裁决 png/jpeg/gif/webp/bmp(拒 SVG/HTML);原始文件名丢弃,用 128-bit 随机名。缺文件/空文件 `400`、超限 `413 payload_too_large`、非白名单 `415 unsupported_media_type`。**带审计**。 | -| U9 | GET | `/api/v1/files/{name}` | **无需**(能力 URL) | 200 / 404 | 上传文件读取。`name` 仅接受「32 位小写 hex + 白名单扩展名」;路径穿越全形态拒绝。**GET 不鉴权**是该 URL 为 128-bit 随机能力地址(``/provider 无法带 token);任何持有 URL 者可取,勿外泄。 | -| U10 | POST | `/api/v1/documents` | owner/admin | 201 / 200 / 400 / 413 / 415 / 500 | **文档上传**,`multipart/form-data` 字段 `file`。首次 `201 {data:{name,url,mime,size,sha256,deduped:false}}`;**同字节同扩展名重复上传 → `200` + 同 `name` + `deduped:true`**(可当成功)。单文件上限 `CHAT_DOCUMENT_MAX_BYTES`(默认 20 MiB,`≤0` 用默认)。见 [§4.5](#45-图片--文档--语音链路)。 | -| U11 | GET | `/api/v1/documents/{name}` | **owner/admin(必须 Bearer)** | 200 / 404 | 文档读取。与图片读侧**刻意相反**(必须鉴权)。响应 `Content-Disposition: attachment` + `X-Content-Type-Options: nosniff`,**永不内联渲染**。名字非法/穿越/不存在统一 `404 document_not_found`。 | -| U12 | GET | `/api/v1/sessions/{id}/messages` | owner/admin | 200 / 400 / 404 / 500 / 501 | 热分页历史。query `?limit=&before=&after=&platform=&q=`,见 [§4.2](#42-分页与游标)。**会话不存在/已删除 → `404 session_not_found`**;**存在但无消息 → `200 {"data":[]}`**。 | -| U13 | POST | `/api/v1/sessions` | owner/admin | 201 / 400 / 501 | 新建子会话。body 可选 `{personaId?}` → `{data:{sessionId,personaId?}}`(`sessionId="web:"`)。未知 personaId → `400`。**会话角色创建时固定、之后不可变**(见 U15)。 | -| U14 | DELETE | `/api/v1/sessions/{id}` | owner/admin | 200 / 403 / 500 / 501 | 删除子会话及其消息 → `{data:{deleted:}}`。主会话不可删 → `403 forbidden`。 | -| U15 | PATCH | `/api/v1/sessions/{id}` | owner/admin | 200 / 400 / 404 / 500 / 501 | 会话元数据。body `{title?,pinned?}`(**至少一个**)。`title` trim、≤80 rune、空/纯空白 → `400 "title must not be blank"`(**空串不能清空标题**)。**`personaId` 键出现即 `400`**(任何取值,含 `null`/`""`),且**先于**会话存在性检查(未知会话 + personaId 也回 `400`)。响应只回显本次变更字段 `{data:{id,title?}}` / `{id,pinned?}`。 | -| U16 | POST | `/api/v1/sessions/{id}/read` | owner/admin | 200 / 400 / 404 / 500 / 501 | 推进已读游标。body `{before?}`(毫秒;缺省=现在)→ `{data:{sessionId,readAt}}`。游标**会话级单所有者、只进不退**(读一处 = 该账号所有端已读)。成功广播 `session_read` 帧;失败不广播。 | -| U17 | PATCH | `/api/v1/messages/{id}` | owner/admin | 200 / 400 / 404 / 500 / 501 | 编辑单条消息。body `{content}`(非空、≤4000 rune)→ **`{data:<消息 DTO>}`**(`data` 直接就是完整消息,**不再包一层 `message`**)。只改 `content`。成功广播 `message_edited`。 | -| U18 | DELETE | `/api/v1/messages/{id}` | owner/admin | 200 / 404 / 500 / 501 | 删除单条消息 → `{data:{deleted:}}`。成功广播 `message_deleted`。 | -| U19 | GET | `/api/v1/media/{name}` | **owner/admin(必须 Bearer)** | 200 / 206 / 304 / 401 / 403 / 404 / 416 | **语音副本播放 / 取字节**(**v0.67 登记,v0.79 已实现**,切片 L45)。`Content-Type: audio/*`(按**存储名扩展名**映射,不查库、不嗅探);`Content-Disposition: inline; filename=""`;`X-Content-Type-Options: nosniff`;`Accept-Ranges: bytes`;`Cache-Control: private, max-age=86400, immutable`;**无 `ETag`**(条件请求走 `Last-Modified`);支持 `Range` 与 `HEAD`;名字非法 / 穿越 / 不存在统一 `404 media_not_found`。⚠️ **`416` 不是 JSON 信封**(标准库直写 `text/plain` + `Content-Range: bytes */`)。详见 §4.5 语音。 | - -> **`GET /api/v1/files/{name}` 无鉴权**(能力 URL);**`GET /api/v1/media/{name}` 与 `GET /api/v1/documents/{name}` 必须 Bearer owner/admin**(与图片读侧「128-bit 名即凭据」的取向**刻意相反**);其余全部用户端点至少要求已认证。 - ---- - -## 2. 管理侧端点与权限矩阵 - -> 仅管理面板与授权客户端使用。RBAC `owner`/`admin`/`viewer`;**写操作全程审计**。 -> 本节与 `internal/services/gateway/routes.go` **逐条一致**。 - -### 2.0 权限矩阵(口径与计数) - -- **匿名(无 token / token 无效)**:任意 `/api/v1/admin/*` → `401 unauthorized`(fail-closed,全部管理路由无一例外)。 -- **角色分档**(✅ 放行,❌ `403 forbidden`): - -| 档位 | 中间件 | 条数 | 说明 | -|---|:--:|:--:|---| -| **只读监控档** | `viewerAuth`(owner/admin/viewer) | **14** | 非敏感投影:无凭据、无他人账号数据、无消息正文。**v0.78 新增两条**:面板接入点快照(A13)与插件动作分发(A14,**写门落在 handler 内**,见 A14 行注) | -| **owner/admin 读档** | `writeAuth`(owner/admin) | **24** | viewer 不得看到的一切:记忆、LLM 栈、账号/key、后台思考、审计链、渠道消息日志、完整会话导出、配置事实面(v0.68)、最近一轮链路图快照(v0.71)、记忆整理试算(v0.75)、入库前审查留痕与回滚(**v0.79**,B16/B17)、链路图时间轴(**v0.80**,B18)、自排闸只读快照(**v0.82**,B19)、**记忆整理应用面五条(v0.83,B20–B24)** | -| **owner-only 档** | `ownerAuth`(owner) | **40** | 带审计的策略写、每个获取凭据的渠道端点、其余全部变更 | - -**管理路由总数 = 14 + 24 + 40 = 78 条。** - -> **v0.78 计数变更**:`viewerAuth` 段新增两条(A13 `GET /admin/panels`、A14 `POST /admin/plugins/{id}/actions/{action}`)⇒ 12 → **14**、总数 66 → **68**。A14 走 `viewerAuth` 中间件,但**写动作在 handler 内按 owner/admin 收紧**(路由中间件只能有一道门)—— 因此它**是**本表的一行,读写面却不同档,详见 §2.1 A14 与 §4.14。 - -> **v0.79 计数变更**:`writeAuth` 段新增两条(B16 `GET /admin/memories/reviews`、B17 `POST /admin/memories/{id}/revert`,切片 L45)⇒ 14 → **16**、总数 68 → **70**。⚠️ 同批落地的 `GET /api/v1/media/{name}`(§1.2 U19)是**用户侧**端点,**不**进入本矩阵。 - -> **v0.80 计数变更**:`writeAuth` 段新增一条(B18 `GET /admin/flow/timeline`,切片 W1)⇒ 段内**实有 18 条**、**管理路由总数实有 72 条**(`14 + 18 + 40`)。**本版一并回补了自 v0.76 起累积的 −1 记账滞后**(见下一段),故上表「条数」记 **17 → 18**、总数记 **71 → 72**。它只读、不审计,与同族的 B13(`GET /admin/flow/graph`)**同门同档**。 - -> ✅ **记账滞后已修正(2026-10-06,本版回补)**:按上一段「计数口径」那句(= 三张清单**列出的行数**)核,**§2.2 实有 18 行**(B1–B18)、**§2.1 实有 14 行**(A1–A14)、**§2.3 实有 40 行** ⇒ 逐行相加 **14 + 18 + 40 = 72**,与上表「条数」序列(`writeAuth` **18** / 总数 **72**)**已一致**(`routes.go` 交叉核对:admin 侧经 `writeAuth` 挂载 20 条,减去 §2.4(a) 按历史口径不计入本表的 `channels/events` 与 `.../counters` ⇒ **18**)。**根因**(S6 报告 §6.1 发现):v0.76 新增 B15 时**漏把条数 +1**,此后 v0.78 / v0.79 / v0.80 各版沿旧序列 +1 而未回补 ⇒ 那个 **−1 滞后从 v0.76 一路带到 v0.80 的登记值**。 -> ⚠️ **上面 v0.78 / v0.79 两个计数段的绝对值仍沿用当时未回补的写法**(其**增量正确** —— `viewerAuth` 12 → 14、`writeAuth` 各版 +1 / +2 —— 但**绝对值比真实行数少 1**);**§6 里各版条目(v0.76 ~ v0.80)的同类计数同理**。按本文件「历史条目保留当时状态」的惯例**不逐版改数**,**以本表 + 三张清单的行数为准**。 - -> **v0.82 计数变更(实数,已按 `routes.go` 核对)**:`writeAuth` 段新增一条(**B19 `GET /admin/backthink/self-wake`**,切片 Y3)⇒ 段内 **18 → 19**、总数 **72 → 73**。三张清单的行数同步为 **§2.1 14 行**(A1–A14)/ **§2.2 19 行**(B1–B19)/ **§2.3 40 行**(C1–C40)。**核对方式(可复现)**:`internal/services/gateway/routes.go` 里经 `writeAuth` 挂载的 **admin** 路由**实有 21 条**,按历史口径减去 §2.4(a) 的 `channels/events` 与 `.../counters` ⇒ **19**,与 §2.2 的行数一致;`viewerAuth` 挂载 **14**、`ownerAuth` 挂载 **40**(逐条列举见 `grep -c 'mux.Handle("[A-Z]* /api/v1/admin.*'`)。⚠️ **本版另一个「看似该 +1、其实不动计数」的面**:`browser` 插件的六个动作(§4.14(d))**不开任何新路由** —— 它走**既有** A14 `POST /admin/plugins/{id}/actions/{action}`,因此 §2.1 的 14 与总数都**不变**(A14 那一行本就写明「写门落在 handler 内」,动作名由**声明**白名单决定,声明多一个插件不新增路由)。 - -> **v0.83 计数变更(实数,已按 `routes.go` 逐条核对)**:`writeAuth` 段新增**五**条(**B20–B24**:候选列表 / 单条应用 / 批量应用 / 单条回滚 / 手动真跑,切片 TIDY-1)⇒ 段内 **19 → 24**、总数 **73 → 78**。三张清单行数同步为 **§2.1 14 行** / **§2.2 24 行**(B1–B24)/ **§2.3 40 行**。**核对方式(可复现)**:`grep -c 'mux.Handle("[A-Z]* /api/v1/admin.*writeAuth' internal/services/gateway/routes.go` = **26**,按历史口径减去 §2.4(a) 的 `channels/events` 与 `.../counters` ⇒ **24**(`viewerAuth` **14** / `ownerAuth` **40**,与上表一致)。 - -> **计数口径(v0.71 明确)**:总数是 §2.1 / §2.2 / §2.3 三张清单里**列出的行数**(与 v0.68 起的口径一致)。⚠️ `internal/services/gateway/routes.go` 里实际经 `writeAuth` 挂载的 **admin** 路由比 §2.2 多:v0.67 登记的 `GET /admin/channels/events` 与 `.../counters`(见 §2.4(a))**未计入本表**——这是历史口径,本版未改,也**未**把它算进上表的 **writeAuth 条数**(v0.83 为 **24**)。逐条以代码为准。 - -> ⚠️ **与归档版不一致(已更正)**:`Docs/archive/api-client.v0.65.md` §2.0 写「12 + 11 + 38 = 61」(其 v0.62 条目又写「12 + 10 + 38 = 60」,自相矛盾)。**以代码为准 = 12 + 11 + 40 = 63**。 - -### 2.1 viewer 可读(14 条,`viewerAuth`) - -| # | 方法 | 路径 | 要点 | -|---|---|---|---| -| A1 | GET | `/api/v1/admin/personas` | 人格清单(含 admin 专属 `thinking` 块) | -| A2 | GET | `/api/v1/admin/sessions` | 会话清单 | -| A3 | GET | `/api/v1/admin/plugins` | 能力/插件清单 | -| A4 | GET | `/api/v1/admin/plugins/installed` | 已安装插件清单 | -| A5 | GET | `/api/v1/admin/channels` | 渠道清单。`accounts[]` 各元素**可能**带可选 `connected:bool`(v0.66):**该账号自己的**适配器是否已连接;**键缺席 = 状态未知**(该账号没有活适配器),**不等于**「未连接」—— 面板据此决定是否给「断开」按钮。**v0.67 已实现**(本次落地;落地前基线 `f91a83f`):各元素另**可能**带可选 `accountOnline:bool`(**账号在平台上是否在线**,与 `connected` 是**两件事**;**键缺席 = 状态未知 ≠ 离线**)—— 见 §2.4(b);各元素**可能**带可选 `directMsgPerMin:number`(**v0.69 新增,已实现**):该账号**非主人私聊(陌生人)**每分钟每发送者上限,是账号**顶层**字段(与 `groupMsgPerMin` 同一条写路径,**不在 `config` 里**)。语义:**键缺席或 `0` ⇒ 保护性默认 6 条/分**(陌生人必须有流控,缺省绝不解释成「关闭」)、**正值 ⇒ 该值**、**负值 ⇒ 关闭该限制**(主人的显式选择)。⚠️ 因 `omitempty`,**`0` 与未设置线上同形**(既有约定,未改)**不含任何凭据** | -| A6 | GET | `/api/v1/admin/iot` | IoT 设备清单 | -| A7 | GET | `/api/v1/admin/automations` | 自动化与提醒清单 | -| A8 | GET | `/api/v1/admin/rag` | 知识库文档清单(名字/描述 + 切分/嵌入状态);`?query=` 搜索亦只读 | -| A9 | GET | `/api/v1/admin/observability/metrics` | 运行时指标快照 | -| A10 | GET | `/api/v1/admin/observability/trace` | 运行时 span | -| A11 | GET | `/api/v1/admin/observability/logs` | 后端日志尾部(最新优先,`?level=&limit=`;缓冲未注入时 501) | -| A12 | GET | `/api/v1/admin/notifications` | 统一通知 feed(审计链 + 后台思考推送的只读投影) | -| A13 | GET | `/api/v1/admin/panels` | **插件管理面板接入点快照(v0.78 新增,切片 PLG-2)**:**按调用者角色过滤**后的导航条目集合 —— viewer 只拿到只读区块(`status`/`log`/`table`,且 table 的行内动作被摘空、`form` 整体不出现、区块全空的面板整条不出现)。响应 `{data:{role,groups[],pluginIds[]}}`。未接面板中枢 → **`501 panels_unavailable`**(**绝不回空列表**)。形状与两条给客户端的口径见 §4.14(a)。**v0.82 起目录里多一条内置页**:`browser` / `settings`(「浏览器与截图」,切片 Y4,见 §4.14(d))⇒ 声明接线后 `pluginIds` 为 `["browser","web-search"]`(**已排序**)—— ⚠️ **登记时该挂点仍在工作树、未提交**,因此**在跑的那份产物里没有它**(真机已核:`:10942` 上 `GET /admin/panels` 需 owner 凭据,但**启动日志全篇 0 条 `browser panel declared`** ⇒ 该面板未接线)| -| A14 | POST | `/api/v1/admin/plugins/{id}/actions/{action}` | **插件动作分发(v0.78 新增,切片 PLG-2)**。⚠️ **读写面不同档**:路由走 `viewerAuth`,但**写动作在 handler 内按 owner/admin 收紧**(`form.submit` / `table.rowActions` 是写;`table`/`log` 的 `source` 与下拉 `optionsFrom` 是读,对 viewer 开放)。**只接受该插件声明过的动作名**(未声明 ⇒ `404 action_not_declared`,**不是**「没权限」)。请求体可选 JSON(空体 ⇒ `payload=null`);**宿主不接受任何来自声明的请求地址**(不给插件开 SSRF 面)。✅ **v0.79:生产执行器已接线**(切片 L1/PLG-2)—— 内置 `web-search` 的四个动作 + 候选来源 `plugin.providers` 都已登记生产处理器,**真可用**(真读写可变源、探针真发一次 HTTP);**只有「已声明但宿主未登记处理器」才回 `501 action_not_implemented`**(不再是兜底档)。错误码与响应形状见 §4.14(b)(c) | - -> **A13 / A14 的角色门依据**(`routes.go` 紧邻注释):快照端点对 viewer 开放是因为它回的**本来就是被裁掉写面的只读投影**;动作端点的读来源动作对 viewer 可见,**写门落在 handler 内**(路由中间件只能有一道门)。`panels_changed` 帧(§3.4.5)**不进** `frameReachesAll` 白名单 ⇒ **viewer 收不到该帧**,需自行刷新/轮询重拉 A13。 - -### 2.2 owner/admin 读(19 条,`writeAuth`) - -| # | 方法 | 路径 | 要点 | -|---|---|---|---| -| B1 | GET | `/api/v1/admin/memories` | 记忆条目(含他人账号数据) | -| B2 | GET | `/api/v1/admin/channels/messages` | 适配器消息日志,`?adapter=&platform=` 三档粒度(`before`/`limit`) | -| B3 | GET | `/api/v1/admin/channels/{platform}/messages` | 单平台跨隔离会话消息(最新优先) | -| B4 | GET | `/api/v1/admin/llm` | LLM provider 栈(组 DTO,含 `contextWindow` 等) | -| B5 | GET | `/api/v1/admin/llm/usage` | LLM 用量汇总(切片 U1,v0.64)。`?since=&until=&purpose=`;缺省近 24h、跨度上限 90 天(超 → `400`);`hitRate = cacheHit/(cacheHit+cacheMiss)`,分母 0 ⇒ `0`。**不含凭据与消息正文**。未接线 → `500 usage_unavailable` | -| B6 | GET | `/api/v1/admin/llm/catalog` | 模型目录候选(`?refresh=1` 强制在线刷新)。既无快照又抓不到 → `503 catalog_unavailable` | -| B7 | GET | `/api/v1/admin/users` | 账号/API key 清单 | -| B8 | GET | `/api/v1/admin/backthink` | 后台思考记录。每条**可能**带可选 `urgency`(`"routine"`/`"warning"`/`"critical"`,`internal/urgency` 词汇;**默认是精确档位**,带 `urgencyLowerBound:true` 时才是下限,见 §4.10(b))与可选 `quietHoursBypassed:true`(该轮**穿透了安静时段**)—— **v0.68 新增;档位的精确语义与 `urgencyLowerBound` 为 v0.70**。⚠️ 每条的 `trigger` 是**开放的**取值域(`heartbeat` 周期心跳 / `startup` 启动轮 / 事件轮的**事件主题** / **`self_wake`(v0.81 登记)** = 她自己排起的一轮思考),客户端**按字面值显示**、不做闭集校验,见 §4.10(d)。🆕 **v0.82 新增可选 `?trigger=` 服务端筛选**(切片 Y3):取值域 = **闭集**(= 上面那些取值,真源 `backthink.TriggerFilterValues()`);**未知值或空值 ⇒ `400 invalid_trigger`**(**绝不静默忽略**);合法值**下推 SQL**,`limit` 数的是**匹配**条数。⚠️ **「筛选白名单是闭集」与「显示取值域是开放集」不矛盾**,见 §4.10(d)(e) | -| B9 | GET | `/api/v1/admin/observability/audit` | 审计链 | -| B10 | GET | `/api/v1/admin/sessions/{id}/export` | 完整会话导出 | -| B11 | POST | `/api/v1/admin/rag/query` | 知识库检索(带审计的读型 POST) | -| B12 | GET | `/api/v1/admin/config-facts` | **配置事实面(v0.68 新增,只读;v0.75 追加 3 键;v0.77 追加 1 键)**:记忆整理 **8** 键 + 后台思考心跳 3 键(`ENABLE_BACKGROUND_THINKING`/`THINK_INTERVAL_SEC`/`THINK_QUIET_INTERVAL_SEC`)+ 安静时段 `QUIET_START_HOUR`/`QUIET_END_HOUR` 的**进程生效值**,逐条带 `default`/`isDefault`。**不含任何密钥/令牌值**(连「是否已配置」都不回凭据类的)。形状见 §4.10 | -| B13 | GET | `/api/v1/admin/flow/graph` | **链路图快照 / 多轮历史回放(v0.71 新增;v0.73 增量;v0.77 增量)**,只读、不审计。`?sessionId=`(省略 = 主会话 `web`)+ **v0.73 新增 `?turnId=`**(省略 = 最近一轮,**先内存后落盘**)。响应 `{data:{sessionId,turnId,source,graph,lanes[],turns[]}}`:`graph` 是**一份 §3.4.5 `graph_update` 同形快照**,没有该轮时**如实为 `null`**(**绝不画假图**);`source` ∈ `live`(内存最近一轮)/`persisted`(落盘的历史轮)/`none`;**v0.77 新增 `lanes[]`**(**最近一轮每一路**的图,每项 `{turnId,graph}`,按路的开始时间升序,恒为非 null 数组 —— 真并行时这才是全貌,`graph` 只给最新一路),`graph` 的既有语义**一字未改** ⇒ 老读取方零改动;`turns[]`(`{turnId,createdAt}`,最新在前)供面板选历史轮(**v0.73**)。未接线图源 → `500 flow_unavailable`。语义见 §4.11 | -| B14 | POST | `/api/v1/admin/memories/tidy/dry-run` | **记忆整理「试算」(v0.75 新增,只读语义的 POST)**:跑同一套整理规则、把候选**直接回在响应里**,**零副作用**(不写任何表)。请求体可为空。响应 `{data:{dryRun:true,recallMode,durationMs,ruleVersion,partitions,scanned,compared,pairs,vetoed,vetoReasons,truncated?,candidates[]}}`;`candidates` 恒为非 null 数组(空数组 = 「算过了,没有建议」,与「没算出来」的错误响应**必须能区分**)。失败:未接线 → `500 tidy_unavailable`、配置关闭 → `409 tidy_disabled`、语义召回不可用 → `500 tidy_recall_unavailable`、超时 → `503 tidy_timeout`。**不审计**(只读计算)。语义与形状见 §4.12 | -| B15 | GET | `/api/v1/admin/memories/tidy/dry-run/{runId}` | **异步试算结果查询(v0.76 新增 · ✅ 已实现(v0.77 校正))**:配合 `POST …/memories/tidy/dry-run?async=1`(后者仍属 B14,`async=1` 时回 `202` + `{data:{runId,status:"running",startedAt}}`)轮询取回结果。响应 `{data:{status:"running"|"done"|"failed", result?, error?}}`:**`running` 时 `result`/`error` 两键都缺席**(绝不回一个空 `result` 冒充「跑完了但没有建议」);`done` 时 `result` 与 §4.12 同步响应的 `data` **逐字节同形**(**同一套 JSON,不是第二份**);`failed` 时 `error` 为 `{code,message}`(`code` 沿用 §4.12 的失败码)。⚠️ **结果只活在进程内存**(单二进制、零跨进程):TTL **30 分钟**(从**完成时刻**起算)、同时只允许 **1** 个在跑(**第二个请求复用既有 `runId`**,不引入新错误码);进程重启后旧 `runId` **如实失效** ⇒ `404 tidy_run_not_found`,**绝不假装还在跑**。鉴权与 B14 同档(`writeAuth` = owner/admin)、同样**不审计**。语义见 §4.12.1 | -| B16 | GET | `/api/v1/admin/memories/reviews` | **入库前审查(D3)留痕列表(v0.79 新增,切片 L45;✅ 已实现)**。`?limit=`:缺省 **100**、`≤0` 同缺省、> **500** 钳到 500、非整数 → `400 bad_request`(钳位不报错,与渠道事件查询面同一约定)。响应 `{data:[…]}`,元素键 camelCase:`id` / `itemId` / `personaId` / `scope` / `projectId`(可选)/ `decision` ∈ `skip\|merge\|modify\|create` / `reason`(可选,规则依据)/ `veto`(可选,命中的否决规则)/ `targetId`(可选,命中的既有条目)/ `mergedIds`(**恒为数组**)/ `score` / `recallMode`(可选)/ `degraded` / `ruleVersion` / `sessionId`(可选)/ `createdAt`(**UTC RFC3339**)。🔴 **不含记忆正文**(命中的 id 由客户端回表取原文)。未接线 → `500 memory_review_unavailable`(**绝不回空数组**假装没有留痕);读取失败 → `500 reviews_failed`。只读、不审计 | -| B17 | POST | `/api/v1/admin/memories/{id}/revert` | **撤销一次软合并(v0.79 新增,切片 L45;✅ 已实现;design/23 §8「回滚」)**。**无请求体**。200 `{data:{reverted:""}}`;该行不在 `merged` 状态(id 拼错 / 已回滚过)→ `404 not_found`;未接线 → `500 memory_review_unavailable`;存储失败 → `500 revert_failed`。**无损可逆**:被吸收行复原为 `active` + `merged_into=''`,并从存活行 `merged_from` 摘除、`merge_count` 减 1(下限 1);**行永不物理删除**。**落审计** `memory.review.revert`(who / result ∈ `ok\|error`;**只记 item id,不记正文**)。⚠️ 门是 `writeAuth`(owner\|admin);`design/23 §11` 表格对同族 `candidates/{id}/revert` 标的是 owner-only —— **口径差异待主会话裁决**(若以设计为准,路由换成 `ownerAuth` 即可) | -| B18 | GET | `/api/v1/admin/flow/timeline` | **链路图时间轴 / 多轮串联(v0.80 新增,切片 W1;v0.81 增量)**,只读、不审计。`?sessionId=`(省略 = 主会话 `web`)+ `?limit=`(缺省 **50**、> **200** 钳到 200、非整数 `400 bad_request`、`≤0` 同缺省)+ `?before=`(**游标**:取开始时刻**严格早于**它的轮次;省略 = 最新那一页;取值 = 响应里的 `nextBefore`,也接受 RFC3339 或 Unix 毫秒,非法 → `400 bad_request`)。响应 `{data:{sessionId,source,rounds[],nextBefore,hasMore,truncated,retentionBoundaryReached,retentionHours,retentionEnabled}}`:`rounds` **按时间正序**(最早在前),每项 `{turnId,round,startedAt,endedAt,lanes[]}`(`lanes[]` 每项 `{turnId,graph}`,`graph` 与 `graph_update` 同形);`round` 为 `null` = **未知**(老行),`endedAt` 为空串 = **尚未收尾**(在飞轮);`source` ∈ `persisted`(本页至少一轮来自落盘)/`live`(本页全部来自内存)/`none`。🆕 **v0.81(切片 X1)**:① 新增 **`retentionBoundaryReached`**(bool)= 「该会话确有更早的轮次已被保留期清扫」——**与 `truncated` 分意**,只有它 `true` 时面板才可说「更早的轮次已过保留期」(判据/盲区见 **§4.11.1(a)**);② **`round` 跨重启不再重复**(发布器先从落盘播种计数器,见 **§4.11.1(e)**)⇒ 按 `round` 分组不再把重启前后两轮并成一块。未接线(内存 + 落盘都没接)→ `500 flow_unavailable`。语义与空态见 **§4.11.1** | -| B19 | GET | `/api/v1/admin/backthink/self-wake` | **自排闸只读快照(v0.82 新增,切片 Y3;design/12 §N C.5 的读侧)**,只读、不审计、不写库。**无参数**。响应 `{data:{available,running,reason?,configFile?,gate?}}` —— **三态必须可区分**(面板**不得**把「看不到」画成「0 次」):① `available:false` ⇒ 本进程给不出快照(后台思考未接线 / 实现不提供读口 / 闸不在),此时 **`gate` 缺席**、`reason` 说明原因(**HTTP 仍是 `200`**,不假装有一个 0/0 的闸);② `available:true, running:false` ⇒ 已接线但不在运行,`gate` **仍带**配置面数字(上限/窗口/代码默认值),`reason` 说明「此刻她排不出新的一轮」;③ `available:true, running:true` ⇒ 正常态,`reason` 缺席。`gate` 快照 = `{pending,used,limit,windowSec,defaultLimit,defaultWindowSec,limitIsDefault,windowIsDefault}`(`used` 与工具收据 `SelfWakeReceipt.Used` **同一口径**;`limit`/`windowSec` 是**进程生效**值,已 normalize)。🔴 **`limitIsDefault` / `windowIsDefault` 的语义**:报告生效值**恰好等于**代码默认值 —— **不是**「配置文件里没写这个键」的断言(网关**不读** `backthink.json`,与 config-facts 的 `isDefault` 同一条诚实口径:无从区分「配了 4」与「没配」)。`configFile` = **相对落点**(`config/backthink.json`,相对 `$DATA_DIR`)。⚠️ **它不含敏感信息、也不是写口**:改上限/窗口要改配置文件 + 重启(面板写路径**未做**)。语义与三态口径见 **§4.10(e)**。✅ **真机已核(路由存在性,无需凭据)**:`:10942` 上 `POST /api/v1/admin/backthink/self-wake` → **`405` + `Allow: GET, HEAD`**、`GET` 同路径无 token → **`401`**(负对照:同法打一个不存在的 admin 路径 → `404`)⇒ 这条路由**确在**运行中的产物里(⚠️ **不代表**已用真凭据拿到过 `data`;后者**未验**) | - -| B20 | GET | `/api/v1/admin/memories/candidates` | **整理候选列表(v0.83 新增,切片 TIDY-1)**,只读、不审计。`?status=`(**省略 ⇒ 默认只回 `pending`**;落库取值是**闭集** `pending` / `applied` —— `revert` 会把候选退回 `pending`;⚠️ **`rejected` 尚未实现**,没有 reject 端点)+ `?limit=`(缺省 **100**、`>500` 钳到 500、非整数 → `400 bad_request`)。响应 `{data:{candidates:[…],count:N}}`,`candidates` **恒为非 null 数组**(空数组 = 「真没有候选」,与「没接线」的错误响应必须能区分)。每条:`id` / `action` ∈ `merge\|link\|archive\|reclassify` / `status` / `sourceIds[]`(恒为数组)/ `targetId?` / `personaId` / `scope` / `projectId?` / `kind?` / `proposedContent?` / `mergedContent?` / `reason` / `ruleVersion` / `runId?` / `scoreMin` / `scoreMax` / `createdAt`(**UTC RFC3339**)/ `evidence`。失败:未接线 → `500 tidy_unavailable`(**绝不回空数组**假装没有候选)、读取失败 → `500 tidy_candidates_failed`。语义见 **§4.15(a)** | -| B21 | POST | `/api/v1/admin/memories/candidates/{id}/apply` | **应用一条候选(v0.83 新增,切片 TIDY-1;design/23 §7/§8)** —— 唯二真正改记忆的粒度之一。**无请求体**。`200 {data:{candidateId,action,status,reason?,targetId?,affectedIds[]}}`:`status` ∈ `applied`(真改了)/ `skipped`(**幂等**:这条已被决定过 ⇒ 不是错误,界面不得报红)。🔴 **别把这个 `status` 与 B20 候选行上的 `status` 混读**:这里是**本次操作的结果**(`memory.TidyApplyStatus`,另有 `failed` 一档),B20 那个是**候选的落库状态**(闭集 `pending`/`applied`)。`affectedIds` = 这次真的被改动的 `memory_items` id(**恒为数组**)。失败:候选不存在 → `404 not_found`;未接线 → `500 tidy_unavailable`;写失败 → `500 tidy_apply_failed`。**落审计** `memory.tidy.apply`(`result` 以 `ok` 开头 + 动作/结果/受影响行数;**绝不带正文**)。🔴 **软动作**:merge = 被吸收行 `status='merged'` + `merged_into=<存活 id>`,存活行 `merged_from` 追加 + `merge_count`+1 + `confidence` 取较大者 + `updated_at` 刷新;archive = `status='archived'`;**正文一个字不改、行一行不删** | -| B22 | POST | `/api/v1/admin/memories/candidates/apply?runId=&limit=` | **批量应用候选(v0.83 新增,切片 TIDY-1)**。`runId` 给出 ⇒ 只应用**该轮产出的**候选;**省略 ⇒ 全部待定**(面板的「清全部」档)。`limit` 省略/空 ⇒ 领域侧默认 **100**;`>500` **钳到 500**;非整数或负数 → `400 bad_request`。`200 {data:{runId?,limit,total,truncated,applied,skipped,failed,merged,archived,reclassified,results[]}}`。🔴 **两条读数铁律**:① `total` = **真正命中筛选的待定候选数**,而 `applied+skipped+failed` 只覆盖 `limit` 之内 ⇒ 两者不等时 **`truncated:true`**(客户端**必须**如实显示「本轮处理 N / 共 M」);② `results[]` 逐条给 `status`(**失败隔离**:一条失败不拖累其余)。失败:`runId` 查不到(**留痕行**不存在)→ `404 tidy_run_not_found`(与「这一轮没有待定候选」**不是一回事**);未接线 → `500 tidy_unavailable`;写失败 → `500 tidy_apply_failed`。**落审计** `memory.tidy.apply`(带 run_id / limit / total / truncated / applied / skipped / failed)。⚠️ **旧候选(TIDY-1 之前产出的 77 条)的 `run_id` 是空串** ⇒ 「按 run 批量」找不到它们,要清它们只能走**全部待定**这一档 | -| B23 | POST | `/api/v1/admin/memories/candidates/{id}/revert` | **回滚一条已应用的候选(v0.83 新增,切片 TIDY-1;design/23 §8「回滚」)**。**无请求体**。响应形状与 B21 **逐字同形**(`{candidateId,action,status,reason?,targetId?,affectedIds[]}`)。**无损可逆**:被吸收行复原为 `active` + `merged_into=''`、从存活行 `merged_from` 摘除、`merge_count` 减 1(下限 1)、候选退回 `pending`;**行永不物理删除**。失败:候选不存在 → `404 not_found`;未接线 → `500 tidy_unavailable`;写失败 → `500 tidy_revert_failed`。**落审计** `memory.tidy.revert`(与 B17 的 `memory.review.revert` **不同**:B17 按**记忆 id** 撤销 D3 的软合并,本条按**候选 id**,故不复用同一动词) | -| B24 | POST | `/api/v1/admin/memories/tidy/run` | **手动真跑一轮整理(v0.83 新增,切片 TIDY-1)** —— 不等 23:00 的 panic button:按**同一套规则与配置**立刻跑一轮,**写候选 + 写运行留痕**,**不自动应用**(人工在环,能落地的只有 B21/B22)。**无请求体**。`200 {data:{runId,trigger:"manual",ruleVersion,durationMs,partitions,scanned,compared,pairs,vetoed,errors,truncated[],vetoReasons{},candidatesCreated,candidatesDuplicate,candidatesByAction{}}}`。🔴 **如实计数**:`candidatesCreated` = 本次**新建**候选数、`candidatesDuplicate` = 被**幂等键**挡下的重复数(同一批数据重复跑 ⇒ 全进 duplicate,**不是失败**);`errors` 非 0 面板必须显示;`truncated[]` 是**被护栏截断**的分区标签(扫描/比对/候选上限,或墙钟预算 `[budget]`)。失败:配置关闭 → `409 tidy_disabled`;超时 → `503 tidy_timeout`(**已跑部分仍写入了留痕**);只接线了试算(没接真跑)→ `500 tidy_run_unavailable`;未接线 → `500 tidy_unavailable`;失败 → `500 tidy_run_failed`。**审计不在这里写** —— 由领域侧记一条 `memory.tidy.run`(`trigger=manual` + run_id + 计数),**一次点击一条**。语义见 **§4.15(b)** | - -> **读取 `GET /admin/observability/{logs,metrics,trace}` 兼任可观测推送的订阅握手**(该凭据的连接全部断开即退订)。REST 响应形状不变。 - -### 2.3 owner-only(40 条,`ownerAuth`) - -| # | 方法 | 路径 | 要点 | -|---|---|---|---| -| C1 | POST | `/api/v1/admin/plugins/policy` | 设置能力可达性标记(工具策略,落盘 `tool_policies`) | -| C2 | POST | `/api/v1/admin/personas` | 创建人格 | -| C3 | PUT | `/api/v1/admin/personas/{id}` | 更新人格(`identity.systemPrompt` 必填,缺失/空白 → `400`) | -| C4 | GET | `/api/v1/admin/owner-profile` | 主人档案读取 | -| C5 | PUT | `/api/v1/admin/owner-profile` | 主人档案写(落盘 `configs/users/owner.yaml` + 热更新活档案;审计 `owner.profile.update`) | -| C6 | POST | `/api/v1/admin/users` | 创建账号 | -| C7 | POST | `/api/v1/admin/users/apikey` | 签发 API key(明文仅返回一次) | -| C8 | DELETE | `/api/v1/admin/users/apikey/{id}` | 吊销 API key | -| C9 | POST | `/api/v1/admin/iot/{id}/control` | 控制设备 | -| C10 | POST | `/api/v1/admin/iot/scenarios` | IoT 场景写 | -| C11 | POST | `/api/v1/admin/iot/rules` | IoT 规则写 | -| C12 | POST | `/api/v1/admin/automations` | 创建自动化 | -| C13 | POST | `/api/v1/admin/automations/reminders` | 创建定时提醒(到点发 `reminder.trigger`) | -| C14 | POST | `/api/v1/admin/automations/{id}/run` | 手动运行自动化 | -| C15 | DELETE | `/api/v1/admin/automations/{id}` | 删除自动化 | -| C16 | POST | `/api/v1/admin/llm` | 添加/更新 LLM provider(写 `models.json` + 热重载) | -| C17 | POST | `/api/v1/admin/llm/test` | 连通性测试 | -| C18 | POST | `/api/v1/admin/llm/reset-from-env` | 重置为 env 默认(切片 R1,v0.62;body 必须 `{"confirm":true}`,否则 `400` 且一个字节都不动;审计 `llm.resetFromEnv`) | -| C19 | POST | `/api/v1/admin/channels/open` | 打开渠道(v0.66 增 `accountId`,见下注) | -| C20 | POST | `/api/v1/admin/channels/close` | 关闭渠道(v0.66 增 `accountId`,见下注) | -| C21 | POST | `/api/v1/admin/channels/test` | 渠道连通测试 | -| C22 | GET | `/api/v1/admin/channels/{platform}/login-qr` | 取登录二维码(获取凭据,故 owner-only) | -| C23 | GET | `/api/v1/admin/channels/{platform}/login-status` | 查扫码登录状态 | -| C24 | GET | `/api/v1/admin/channels/{platform}/view` | 平台专属只读视图 | -| C25 | GET | `/api/v1/admin/channels/{platform}/friends` | 好友/联系人清单 | -| C26 | POST | `/api/v1/admin/channels/{platform}/friend/handle` | 好友申请处理 | -| C27 | POST | `/api/v1/admin/channels/{platform}/message/test` | 发测试消息 | -| C28 | PUT | `/api/v1/admin/channels/{platform}/config` | 保存渠道账号配置(**按 id patch** 语义,见 C29 注) | -| C29 | DELETE | `/api/v1/admin/channels/{platform}/config` | 删除渠道账号。**必须显式寻址**:`?id=<账号 id>` 或 `?empty=1`(v0.66),见下注 | -| C30 | POST | `/api/v1/admin/plugins/install` | 安装插件 | -| C31 | POST | `/api/v1/admin/plugins/uninstall` | 卸载插件 | -| C32 | POST | `/api/v1/admin/memories` | 写记忆 | -| C33 | POST | `/api/v1/admin/memories/search` | 搜记忆 | -| C34 | POST | `/api/v1/admin/rag` | 索引文档(切分 + 嵌入) | -| C35 | POST | `/api/v1/admin/rag/delete` | 删文档(项目条目 + 级联章节) | -| C36 | POST | `/api/v1/admin/system/revoke-keys` | 全部吊销 API key | -| C37 | POST | `/api/v1/admin/system/rotate-jwt` | 轮换 JWT 密钥 | -| C38 | POST | `/api/v1/admin/system/backup` | 备份 | -| C39 | POST | `/api/v1/admin/system/restore` | 恢复 | -| C40 | GET | `/api/v1/admin/system/storage` | 存储状态**只读**(v0.60)。`{data:{main,documents,vector}}`;**不含凭据**;未接活连接 → `500 storage_unavailable` | - -> **C28 的 patch 语义(v0.54 起)**:`config` 内**键省略 = 保留已存值**;给出非空值 = 覆盖;**显式 `null`/`""` = 清除该键**(`0`/`false` 是真实取值,不算空)。顶层便捷字段(`token`/`baseURL` 等)只在非空时并入补丁,**要清 `bot_token` 必须走 `config` 的显式 `null`/`""`**。 -> **C28 的账号级限流字段(v0.69 修正)**:`groupMsgPerMin` / `msgPerMin` / `maxMessages` / **`directMsgPerMin`** 都是账号**顶层**字段(**不在 `config` 里**,C28 的 patch 三态**不适用**于它们)。它们是**值语义**:请求体省略 ⇒ `0` = 未设置(对 `directMsgPerMin` 即保护性默认 6 条/分)。**要保住已设的值必须把它回送**;面板先由 `GET /admin/channels` 回显、再原样写回(`frontend/web/src/admin/modules/channels/channelUtils.ts` 的 `limitInitialOf` / `limitRequestBody`)。⚠️ v0.68 及更早的后端**完全忽略** `directMsgPerMin`:老客户端不受影响,但**要表达「关闭流控(-1)」必须对 v0.69+ 后端发**。 -> **C18 破坏性**:只覆盖 `.env` 声明过非空值的键(`provider`/`baseUrl`/`apiKey`/`model`/`contextWindow`),`fallback`/`multimodal`/`maxTokens`/`reasoning`/`personaModels` 永不被改;组集合不增减。 -> **C19/C20 的 `accountId`(v0.66,多账号平台必需)**:请求体 = `{name, accountId?}`(`name` 必填:平台名或精确适配器名)。 -> - 给出 `accountId` ⇒ **只**驱动该账号的适配器(账号 id 非敏感;`"default"` 映射到该平台的裸适配器名,即无 id 账号的注册口径)。账号**完全不存在** ⇒ `404 account_not_found`(文案附该平台已知账号 id 列表);账号**已配置但当前没有活适配器** ⇒ `409 account_not_registered`(常见原因:缺该账号必需凭据、或保存后尚未重载)。两种都**零连接**,**绝不静默回落到第一条**(「点了连接却连上别的账号」正是此改动要修的缺口)。 -> - 缺省 `accountId` ⇒ 平台级旧语义(老客户端零影响):精确同名适配器 → 该平台 `channels.json` **有效配置顺序第一条** → 裸平台名兜底;仍找不到 ⇒ `404 not_found`。⚠️ 第 2 步在 v0.66 起是**确定性的**(此前走注册表 map 顺序,多账号平台上每次可能落到不同账号)。 -> - 响应(open/close 同形):`{data:{name, resolved, connected, accountId?}}` —— `name` 是请求里的名字(旧契约不变),`resolved` 是**实际被驱动**的适配器注册名(多账号平台上与平台名不同),`accountId` 仅在请求指定时回显,`connected` 为操作后的状态。**凭据永不出现**。审计动作名不变(`channel.open` / `channel.close`)。 -> **C29 的两种寻址(v0.66,都必须显式给出)**: -> - `?id=<账号 id>` ⇒ 删该 id 那一条。⚠️ **`id` 不存在时仍回 `200`**(`removed:1`),与「刚删过」不可区分 —— 客户端按幂等处理。 -> - `?empty=1` ⇒ 删该平台上**全部无 id 的历史条目**(这类条目改不动:带空 id 保存会被当成新账号另开一条)。**无可删 ⇒ `404 not_found`**,不写盘、不重载、不假装删过。 -> - `id` 与 `empty=1` 同时给出 ⇒ `400 bad_request`;**两者都缺 ⇒ `400 bad_request`**(绝不把「id 缺失」默认解释成删无 id 条目 —— 那是一次没有字面证据的批量删除)。`?empty=` / `?empty=0` / `?empty=false` 一律是「否」。 -> - 响应:`{data:{persisted, path, platform, accountId, empty, removed}}`(`empty`/`removed` 是 v0.66 新增);`removed` = 实际删除的条目数。删空后平台键整个消失(与按 id 删除的收尾口径一致)。 - -### 2.4 v0.67 登记的管理面(渠道事件查询 + 账号在线状态)—— **(a) 已实现 / (b) 已实现**;v0.78 追加 **(c) iLink 凭据三态** - -> ⚠️ **本节两项都已落地**(账号在线状态 `52dac45`;渠道事件查询面本切片 E6):形状取自设计文档(`design/26 §4.5`、`§2.6`),落地时已按代码核对。**§2.0 的计数(12 + 11 + 40 = 63)原本不含本节端点**;两个渠道事件端点已登记进 `routes.go`(`owner|admin` 档),下版改文档时应把它们并入 §2.1/§2.2 并更新计数。**本行留在此处仅为与 v0.67 冻结版对照**。 - -#### (a) 渠道事件(非消息事件)只读查询 —— **已实现** - -| # | 方法 | 路径 | 要点 | -|---|---|---|---| -| D1 | GET | `/api/v1/admin/channels/events` | 非消息事件列表(**倒序**)。query:`platform` / `tier` / `category` / `channelId` / `since` / `until` / `limit` / `offset`。响应 `{data:{events:[…], total}}`。`since` / `until` 按 **UTC ISO-8601 定宽字符串**做**字典序**比较(与 `GET /admin/llm/usage` 同口径);**跨度上限 90 天**(超 → `400`);`platform` 未知 → `400`。**不含正文、不含任何媒体 URL、不含任何凭据 / `flag`** | -| D2 | GET | `/api/v1/admin/channels/events/counters` | 逐类计数汇总(**近似值**)。query:`platform`。响应 `{data:{approx:true, counters:[{platform,accountId,tier,category,platformType,count,firstSeenAt,lastSeenAt}]}}`,`approx:true` 如实标注「计数为聚合近似值」(计数是**内存聚合 + 周期 flush**,非逐条实时)。**落地口径**:`approx` 与 `counters` **同级**(都在 `data` 内,见 §7.4-11 的落地记录) | - -- **RBAC 档位已定(落地口径)**:两个端点走 **`owner|admin`**(`writeAuth`),与同族的渠道消息日志面(§2.1 B2/B3、`/admin/channels/messages`)同档 —— 事件行带群 id / 成员 id / 关联消息 id,属私有内容面,不是给 viewer 的监控投影。**viewer 为 `403`**(已进 `rbac_viewer_test.go` 的 `ownerAdminReadRoutes` 覆盖)。 -- **参数缺省与错误码(落地口径,见 §7.4-11)**:`limit` ≤0 或未给 ⇒ 用存储层缺省 `100`;超硬上限 ⇒ **钳到 `500`**(不报错,`total` 仍如实给出筛选条件下的总条数);非法整数(如 `limit=abc`)⇒ `400 bad_request`;`since`/`until` 非法 ⇒ `400 bad_request`;`platform` 未知 ⇒ `400 bad_request`;`tier` 非 `t1/t2/t3` ⇒ `400 bad_request`。⚠️ **`category` 不做白名单校验**:类别轴的权威在适配器层且会持续增加,未知类别是一次**精确匹配**(返回空),不是 400。 -- **时间窗缺省(落地口径)**:**没给 `since`/`until` 就不注入时间条件**(不是「近 24h」)—— 端点绝不替客户端造一个下界(那正是「静默裁剪」)。只给 `since` 时按「现在」判跨度(超 90 天 ⇒ 400),但**不**把「现在」写进过滤条件。 -- **数据源两张表**(`design/26 §4.1`,表名**复数**):`channel_events`(`tier` 为 `t2`/`t3` 的事件与**所有** `unknown` 逐条留存,**参与保留期 GC**)与 `channel_event_counters`(`t1` 与**所有**帧的计数,按 `(platform, account_id, platform_type)` upsert,**不参与 GC**)。 -- `tier` 取值域:`t1`(只计数)/ `t2` / `t3`(逐条留存;T3 是「谁对谁做了什么」的定向交互记录)。`category` 取值域(**归一化类别**,设计原文给出 `heartbeat` / `lifecycle` / `join` / `leave` / `recall` / `ban` / `admin` / `friend_request` / `group_request` / `poke` / `reaction` / `button` / `vote` / `item` / `link` / `unknown`,并在末位带 `…` ⇒ **非穷举**,客户端**未知取值原样展示、不报错**)。 -- `events[]` 元素 = 一条 `channel_events` 行的投影。**落地口径(见 §7.4-9):键名一律 camelCase,逐字如下** —— `id` / `platform` / `accountId` / `tier` / `category` / `platformType` / `channelId` / `channelType` / `groupId` / `actorId` / `actorRole`(`owner`/`member`/`system`/`unknown`,结构化解析所得)/ `targetId` / `selfRelated`(bool)/ `messageId` / `summary` / `createdAt` / `updatedAt`(后两者是 **UTC RFC3339**,与 `llm/usage` 的时间回显同口径)。`actorId`/`targetId` 是 **id 不是昵称**;`summary` 是**固定文案**(如「群成员加入」,**不含任何平台原文**)。无数据时 `events` 是 `[]`(不是 `null`)。 -- **未接线存储时**:两个端点都回 **`500 channel_events_unavailable`**(对齐 `usage_unavailable` / `storage_unavailable` 的诚实边界口径,**绝不回全 0 假汇总**)。读库失败同样是 `500 channel_events_unavailable`(错误文案不同)。 -- 三个新 env 键(`design/26 §4.4`,已登记进 §5):`CHANNEL_EVENT_RETENTION`(默认 `720h`)、`CHANNEL_EVENT_GC_INTERVAL`(默认 `1h`)、`CHANNEL_EVENT_COUNTER_FLUSH`(默认 `30s`)。 -- ⚠️ **微信渠道不提供非消息事件**:查询面 / 面板对该渠道**如实显示空 + 说明文字**,绝不伪造、绝不用别的渠道事件顶替(落地时已有测试钉住:`platform=weixin-ilink` 的响应里绝不出现别的平台的事件)。 - -#### (b) `GET /api/v1/admin/channels` 的账号项:`accountOnline` - -- `accounts[]` 元素**可能**带可选 `accountOnline:bool`(**v0.67 登记,已实现** —— 管理面已落地,见 §7.2):该账号**在平台侧是否在线**。 -- **与 `connected` 是两件事**(这是本案的全部理由):`connected` = **本项目的适配器是否连着**(WS 会话连通);`accountOnline` = **账号本身在平台上是否在线**。OneBot 实现(NapCat 等)的进程与正向 WS 可以一直连着,而背后的 QQ 账号已经掉线 —— 此时 `connected:true` 而 `accountOnline:false`,消息**既收不到也发不出**。 -- **三态**(`known` 口径):`accountOnline:true` = **确知在线**;`accountOnline:false` = **确知离线**;**键缺席 = 状态未知**(尚未探到:探活未接线 / 刚启动 / 一直失败 / 该平台没有独立账号状态面)。**绝不可把「未知」画成「离线」**,也不可画成「在线」—— 那正是 2026-10-05 事故里「面板仍显示已连接、日志一片空白」的同一类坑。 -- 状态源:`AccountStatus()`(`plugins/channels/qq_obv11/probe.go`),其 `known` 区分「确知离线」与「尚未探到」;本字段的**缺席**即承载 `known=false`。当前**只有 `qq-obv11`** 有这条独立状态面:周期约 **60s** 主动探活 OneBot `get_status`(读 `{online, good}`),与上游可配的 `meta_event` 心跳**并列**(心跳优先、探活兜底),**只在状态变化时**记一条带 `source` 的日志。 -- **已实现口径**(本次落地):管理列表按**可选接口断言**读它 —— 适配器实现了 `AccountStatus()` 才读,否则**键缺席**;`known=false` 时同样**键缺席**(绝不用 `false` 冒充离线)。**不另发** `known` 同级键:缺席本身即承载 `known=false`。当前**只有 `qq-obv11`** 会带该键(telegram / webhook 等没有独立状态面)。 -- ⚠️ **字段名(`accountOnline`)是本契约所拟**:设计只规定「独立状态面 + `AccountStatus()` + `known`」,**未给管理面字段名**(见 §7.4-13)。实现沿用了本契约所拟的名字(未改名)。 - -#### (c) `GET /api/v1/admin/channels/{platform}/login-status` 的账号项:`authState` / `authDetail`(**v0.78 新增,切片 CODE-F3,已实现**) - -- 该端点响应新增**可选** `authState:string` + `authDetail:string`(`omitempty`):该账号**会话凭据**的三态与**如实的**中文说明。**无新端点、无新路由**(沿用既有扫码登录状态端点,`owner-only`,v0.39 起就有)。 -- **取值是闭集**:`"active"`(**有效**:iLink 接受过当前凭据)|`"rejected"`(**被拒**:iLink 以 `errcode=-14` 拒绝/作废了这份会话凭据,**需要重新扫码绑定**)|`"unknown"`(**未知**:没有凭据,或有凭据但还没有一次被 iLink 接受的调用可以作证)。 -- **与 `status` 是两件事**(本字段的全部理由):`status` 是**扫码登录流程**的状态机(`idle`/`waiting`/`confirmed`/`failed`/`need_verifycode`);`authState` 是**凭据在 iLink 侧是否有效**的结论。一次 `confirmed` 的扫码只说明「拿到了凭据」—— 它是否被接受要等一次成功的调用(那时 `authState` 才从 `unknown` 变 `active`)。因此**允许**出现 `status:"confirmed"` 且 `authState:"unknown"`:**这是事实,不是矛盾**。 -- 🔴 **`errcode=-14` 是「被拒」不是「过期」**:iLink 的 bot token 是**长期凭据**(实测一个月前扫码得到的 token 仍可用);`-14` 表示 iLink 不再接受这个会话(已解绑 / 在别处重新登录 / 协议层拒绝),**不是时间性过期**。`authState:"rejected"` 的说明只说「已被 iLink 拒绝,需要重新扫码绑定」。**未知同样绝不许写成「过期」**(也不许写成「未连接」)—— 服务端文案已由测试钉死(响应体全文不得出现「过期」)。 -- **键缺席 ≠ `unknown`**:适配器**没有这条状态面**时两个键**缺席**(缺席 = 该账号没有这个状态面);`"unknown"` 是**值**,表示**有状态面但尚未确认** —— 面板必须能区分这两件事。 -- 数据源 = 适配器的**可选能力接口** `SessionStatus() (state, detail string)`(`plugins/channels/weixin_ilink/session_status.go`),网关经类型断言读取(`internal/services/gateway/admin_channel_status.go` 的 `sessionStatusAware`,穿 `namedAdapter` 包装)。**当前只有 `weixin-ilink` 会带这两个键**;其它平台字段缺席,行为一字不变。 -- `authDetail` **绝不含任何凭据**。 -- **行为变更 / 破坏性**:无(两个键可选、纯增量;`status` / `botId` / `botToken` / `err` 与 v0.39 起逐字一致)。 -- 🔴 **同域但未实现的面**:`GET /api/v1/admin/channels` 的 `accounts[]` 元素**尚未**带**每账号** `authState`/`authDetail`(缺 `AdminChannelAccount` 两字段与合并器的接线)—— 见 §7.2。 -- ⚠️ **未确证(真机)**:真实 `errcode=-14` 至今**从未在日志里观测到** ⇒ `rejected` 分支只有单测 / httptest 证据,**没有真机证据**(见 §7.4-26)。 - ---- - -## 3. WebSocket `/ws/chat` - -### 3.1 握手 - -- `GET /ws/chat`,凭据取自 `?token=` 或 `Authorization: Bearer `(**只认 JWT**)。 -- 校验失败:升级前回 `401 {"error":{"code":"unauthorized",...}}`。 -- 方法非 GET → `400 bad_request`。 -- 帧大小:**读上限 4096 字节/帧**(`wsReadLimit`)。超限由传输层直接断连 —— 因此**真实音频/图片二进制必须走 REST**,WS 只发小载荷。 -- keepalive:服务端周期 `Ping`(54s),客户端应回 `Pong`;读超时 60s。 -- 非 JSON 帧 → 定向 `{"type":"error","data":{"message":"invalid json"}}`(连接保留)。 - -### 3.2 握手 `history` 回放(角色门:只发 owner+admin) - -- 连接注册后,**仅当该连接是 `owner`/`admin`** 时,服务端**定向**发送一帧: - - ```json - {"type":"history","sessionId":"web","data":{"messages":[ ... ]}} - ``` - -- 内容 = 主会话最近 **50** 条(`wsReplayLimit`),`thinking` 恒缺席(见 §3.4.4 口径更正);每条**可能**带可选 `turnId`(v0.66,空则键省略)。🔴 **`voice` 不在握手回放里**(v0.67 登记时曾计划覆盖):v0.79 的 `voice` 只填在 4 个 REST 读取面 + `message_edited`,握手 `history` 回放**不带该键**(见 §6 v0.79 ③ / §4.4 / §7.2)。 -- **viewer 连接完成握手时完全不做回放**(其只读窗口是 REST 监控面)。 -- 回放是尽力而为:存储未接或读出错静默跳过。 - -### 3.3 上行帧(客户端 → 服务端) - -> **角色门(fail-closed)**:**只有 `owner`/`admin` 连接可以发送任何应用内上行帧**。非 owner/admin 发任何上行 JSON 帧 → **定向**回 `{"type":"error","data":{"code":"forbidden"}}`(只到发送者,不广播、不驱动模型、不落库)。该门是 `route()` 的第一条语句且**默认拒**:未登记的新帧类型在显式登记前一律被拒。协议级帧(ping/pong/close)由 gorilla 内部处理,不受影响。 - -帧壳:`{"type":"","reqId?":"","data":{...}}`。`reqId` 保留未用。 - -| type | data | 语义 | -|---|---|---| -| `chat` | `{sessionId?,text,image?,deviceId?,mode?,clientMsgId?,documents?,parallel?}` | 会话消息。**与 REST `POST /chat` 同形**。见 [§4.6](#46-chat-请求与响应面)。 | -| `interrupt` | `{sessionId,scope?,turnId?}` | 打断。见 [§4.8](#48-interrupt-的-scope-与-turnid)。 | -| `voice_input` | `{audio,format?,text?,sessionId?,deviceId?,clientMsgId?}` | 语音回合:STT → 编排器 → 结算时 TTS 定向回本连接。见 [§4.5](#45-图片--文档--语音链路)。 | -| `mode_switch` | 任意(透传) | 行为模式广播;服务端以 `mode_switch` 下行帧回放给(owner/admin)连接。 | -| `subagent_ask` | `{id,mode?,n?,query?}` | 审计某子代理上下文。`mode` ∈ `""/full/tail/keyword`。定向回 `subagent_event`;未接 auditor → `error {"message":"subagent audit unavailable"}`。 | -| `task_output_query` | `{taskId}` | 取任务输出。定向回 `task_output`;未接 task store → `error {"message":"task output unavailable"}`。 | - -上行错误应答(均**定向**,`{"type":"error","data":{...}}`): - -| 情形 | data | -|---|---| -| 非 owner/admin 发送任意上行帧 | `{"code":"forbidden"}` | -| 未知 type | `{"message":"unknown event type"}` | -| 非 JSON | `{"message":"invalid json"}` | -| `interrupt` scope 非法 | `{"code":"bad_request"}` | -| `interrupt` 指定 `turnId` 不存在(仅 scope:"current") | `{"code":"bad_request"}` | -| `chat` 待处理队列满 | `{"code":"chat_busy","message":...,"sessionId":...,"clientMsgId?":...,"messageId?":...}`(**只发发送者**;同时广播 `pending_full` 状态帧) | -| `chat` 携带的文档不合法 | `{"code":""}` | -| `voice_input` 无 audio 也无 text | `{"message":"voice audio or text required"}` | -| `voice_input` 未接语音服务 | `{"message":"voice unavailable"}` | - -### 3.4 下行帧(服务端 → 客户端)与角色门 - -> **唯一投递入口 `hub.broadcast` 即角色门**:它按帧类型查 `frameReachesAll` 表决定受众。 -> - **全员白名单(7 个「状态机信号」,owner/admin/viewer 都收)**:`session_busy`、`pending_ack`、`pending_drain`、`session_idle`、`session_interrupted`、`pending_full`、`session_read`。它们**只报告会话忙闲/游标,不含任何内容**。 -> - **其余全部下行帧 = fail-closed 默认档:只发 owner + admin**。新增帧类型在有人显式登记前一律落此档(漏分类不泄露内容)。 -> - 分类表在 `internal/services/gateway/ws_frame_scope.go`,扩白名单属契约变更。 - -帧壳:`{"type":"","turnId?":"...","seq?":,"sessionId?":"...","data":{...}}`(`turnId`/`seq` 为空则键省略)。 - -#### 3.4.1 全员白名单(7 帧) - -| type | 帧壳 | data | 说明 | -|---|---|---|---| -| `session_busy` | sessionId | `{sessionId,mode,activeTurns}` | 会话转忙。`mode` ∈ `thinking/executing/subagent` | -| `pending_ack` | sessionId | `{sessionId,mode,queueDepth,reason?}` | 忙时消息入队。`reason` **仅在显式声明 `parallel` 却落回排队时**出现,取值 `"parallel_limit"` | -| `pending_drain` | sessionId | `{sessionId,mode:"thinking",queueDepth,activeTurns}` | 续跑弹出队首 | -| `session_idle` | sessionId | `{sessionId,mode:"idle",activeTurns:0}` | 转空闲。**时点收紧(v0.65)**:所有在途路收尾**且** pending 空才发(单路时与旧行为逐字相同)。见 [§4.6](#46-chat-请求与响应面) | -| `session_interrupted` | sessionId | `{sessionId,mode,dropped,cancelledTurns,activeTurns}` | 打断结果。`scope:"all"` ⇒ `mode:"idle"`、`dropped`=清空的 pending 数、`activeTurns:0`;`scope:"current"` ⇒ `mode` 是当时忙碌通道(非 idle)、`dropped:0` | -| `pending_full` | sessionId | `{sessionId,mode,max,clientMsgId?,messageId?}` | 队列满(`max` 为上限)。`clientMsgId` = 被拒消息的幂等键(未携带则键缺席);`messageId` **仅当该消息此前已落库/已认领时**出现(服务端绝不在此处新铸 id) | -| `session_read` | sessionId | `{readAt}` | 已读游标推进(毫秒)。同账号所有端据此清角标 | - -#### 3.4.2 结构化流帧(owner+admin;帧壳带 sessionId + turnId + seq) - -| type | data | -|---|---| -| `message.start` / `message.delta` / `message.end` | `{index, delta?, streamId?}` | -| `thinking.start` / `thinking.delta` / `thinking.end` | `{index, delta?, streamId?}` | -| `tool_call.start` / `tool_call.delta` / `tool_call.end` | `{id, index, delta?, streamId?}` | -| `tool_execution.start` / `tool_execution.update` / `tool_execution.end` | `{tool, phase, id?, delta?, resultSummary?}`(`phase` ∈ `running/done/error`;**phase 绝不塞进 delta**;**`resultSummary` 只在 `end` 帧、且只在「真的有结论」时在场**(v0.82)—— **成功 ⇒ 结果摘要;失败/取消/超时 ⇒ 该次错误文本的摘要**(两边都走同一份脱敏路径);**只有「真的没有结果」才整键缺席**(成功但结果为空、或错误文本为空),**绝不用空串顶位**。上限 **≤500** rune(超长按 rune 截断、末位 `…`),与回看面 `toolCalls[].resultSummary` 同源同口径,读侧不做二次脱敏也不放宽) | -| `turn.start` / `turn.end` | 无 data(帧壳即边界信号) | -| `stream_chunk` | `{delta}`(非流式路径的单段文本) | -| `stream_end` | `{stopReason, steps?, error?}` | - -> `streamId`(可选)= 同一次模型流(一次 ChatStream 调用)的多帧关联键;`turnId` = 整个回合恒定。两者分工不同。 -> `tool_execution.update`:wire 映射已实现,但**工具分发器目前没有中途进度上报源**,故实际**暂无生产者**(`start`/`end` 真实发出)。 -> -> **`resultSummary` 的缺席语义(v0.82,安卓端按此编程)**:它是 `tool_execution.end` 的**可选**键 —— **键在场 ⇔ 这次调用有了结论**;客户端**只做正向断言**,人缺席时显示「工具名 + 状态」即可。映射表(**客户端不要按 `phase` 反推键在不在**,只按「键在不在」分支): - -| 情形 | `.end` 帧上的 `data.resultSummary` | -|---|---| -| 调用成功且结果非空 | **在场**(已脱敏 + 已截断;图侧同源文本通常 ≤80 rune,网关再按 ≤500 兜底) | -| 调用失败 / 取消 / 超时(`Invoke` 返回 error) | **在场** —— **该次错误文本的脱敏渲染**(与回看面「失败调用给图上的 `error` 摘要」同一说法) | -| 调用成功但结果为空(nil / 空串) | **缺席**(整键不出现) | -| 错误文本本身为空 | **缺席** | -| `tool_execution.start` / `.update`(即使有人误挂摘要) | **缺席**(契约只在 `end` 帧给) | -| 被静音的内部流程(backthink / 子代理) | **整帧不发**(既有行为,一字未改) | - -> 「绝不回空串顶位」有两道保险:loop 侧留空 + 网关侧 `s != ""` 才写键(切片 Y1 的变异 A 证明这道守卫真的在起作用)。⚠️ **与回看面的差异(v0.82 起已收窄)**:v0.82 之前本帧**失败时整键缺席**、而回看面对失败调用给的是 `error` 摘要 ⇒ 同一次调用「实时看不到原因、事后看得到」。v0.82 把两侧统一为「失败也带该次错误文本的摘要」,**客户端从此只需一套判据**。⚠️ **未确证**:本键的**真机**(`:10942` 发布产物)端到端**未跑**,见 §7.4-30。 - -#### 3.4.3 编排帧(owner+admin) - -| type | 帧壳 | data | 说明 | -|---|---|---|---| -| `turn_begin` | sessionId + **turnId** | `{kind,messageIds,leg,activeTurns}` | 每起一路(含续跑路)**恰好发一帧**(v0.66 落地)。`kind` ∈ `"turn"`(新回合/**续跑**)/`"parallel"`(显式并行且照准,**仅供展示**);`messageIds` = 该路已落库用户消息的权威 id 数组(可为空数组,**恒为数组不为 null**);`leg` = 会话内路序号(1 基,会话内单调递增);`activeTurns` 同忙态帧。客户端据此把气泡置「已受理·回答中」并按 `sessionId + turnId` **两级分桶**;服务端口径是「按 `messageIds` 是否含本气泡的 `messageId`」判定归属,`kind` **不是**判据。 | -| `steer_ack` | sessionId + **turnId** | `{messageId,atStep}` | 忙时新消息被在飞回合吃进同一回合:`messageId` 是权威 id,`atStep` 是模型步序号(1 基)。语义 = 「已读、未答」⇒ 客户端把该气泡渲染灰态,直到该路 `message.*`/`turn.end` 到达(**不需要第二个确认帧**)。帧壳 `turnId` 与吃进它的那一路 `turn_begin` 同值(v0.66 落地)—— 并行下没有它配不上正确的 `turn.end`。 | -| `chat_duplicate` | sessionId | `{sessionId,clientMsgId,messageId}` | `clientMsgId` 判重命中:**没有新回合、没有新内容帧**,客户端把本地乐观气泡对齐到既有 `messageId` | - -> **`turn_begin` 的帧序(v0.66,按实现)**:`session_busy`(新回合)或 `pending_drain`(续跑)→ `message_persisted`(user) → `turn_begin`。 -> 顺序是刻意的、不可调换:`turn_begin.data.messageIds` 必须是**已落库的权威 id**,因此它只能在用户消息落库之后发;客户端因此总能先看到 `message_persisted`,再收到把该气泡钉到某一路的 `turn_begin`。 -> 起第 1 路与起并行路**共用同一条启动路径**,帧序完全同形 —— 客户端只需实现一遍「`turn_begin` → 灰态」。 - -#### 3.4.4 持久化 / 历史帧(owner+admin) - -| type | 帧壳 | data | 说明 | -|---|---|---|---| -| `message_persisted` | sessionId | `{message:,unreadCount?:,proactive?:true}` | Web 主会话及 `web:*` 子会话的消息落库实时同步。**`thinking` 恒缺席**;**可能**带可选 `turnId`(v0.66,空则键省略);**可能**带可选 `unreadCount`(v0.76,该会话此刻的**绝对未读数**,**只在 >0 且算得出来时**出现 —— 见 §4.13);**可能**带可选结构化判据 `proactive:true`(v0.74,仅 IM 路由的主动推送 —— 见下方说明,普通消息永不带它) | -| `channel_persisted` | sessionId | `{message:}` | 隔离渠道会话(`sess-*`)的消息落库,供 IM 日志页。**`thinking` 恒缺席**;**可能**带可选 `turnId`(v0.66) | -| `message_edited` | sessionId | ``(**data 直接就是 DTO**) | 编辑单条消息。**`thinking` 恒缺席**;**可能**带可选 `turnId`(v0.66) | -| `message_deleted` | sessionId | `{id}` | 删除单条消息 | - -> **`thinking` 可达性口径(v0.66 口径更正)**:消息 DTO 的 `thinking` 在**全部读取面**(`GET /sessions/{id}/messages`、`GET /admin/channels[/{platform}]/messages`、握手 `history` 回放、`message_persisted` / `channel_persisted` / `message_edited`)**恒为缺席**。原因不是「有意清空」,而是 `history.Message.Thinking` **全仓没有任何写入者**(`loop.Result` 没有该字段、Anthropic 流式收尾直接丢弃思考文本)⇒ 该键在线上**永远不会出现**。 - -> **`message_persisted` 的 `proactive` 判据(v0.74,纯增量)**:帧 `data` 里**可选**的布尔键 `proactive:true` 是「这条是她**主动**说的话」在线上**唯一**的结构化判据,由后端的主动推送通道打上(`gateway.BroadcastProactivePersisted`)。客户端读到它就应走与 `proactive_message` **同一套**提醒逻辑(页面不在前台时补一发浏览器通知、同一条只打扰一次),因此「她该不该提醒我」**与后端走了哪条路由无关**。 -> - **为什么需要它**:`auto` 提示在主人有已连接 IM 渠道时**优先走 IM**,而 IM 路由过去只发普通 `message_persisted` —— 浏览器通知挂在 `proactive_message` 上 ⇒ **实际最可能发生的路径恰恰不弹**。修的是这条边界(主人 2026-10-05 裁定「她说话该不该提醒我,与走哪条路由无关」)。 -> - **判据为什么不会误伤普通消息**:只有主动推送那条通道会调用带判据的广播入口;普通消息(Web 回合 / IM 回复 / IM 镜像)走的仍是原入口,那条路径**永不**往帧顶放该键。客户端只应做 `data.proactive === true` 的**严格相等**判断 —— 缺失、`false`、非布尔值都按「不是主动消息」处理。 -> - **去重键 = `message.id`**:主动推送镜像回主会话时,**同一条**消息会先发一条**不带**判据的普通帧(v0.72 起的行为,逐字不变,任何客户端都能渲染),随后追加一条带判据的帧。两帧同 `message.id` ⇒ 合并成一个气泡(消息 store 按 id 去重),提醒也只发一次(提醒去重表按同一个 id 记)。**`proactive_message` 与带判据的 `message_persisted` 同时到达时同样只提醒一次**。 -> - **落库失败时不发判据帧**:没有落库行就没有权威 `id`,而 `message_persisted` 的载荷要求非空 `id`(缺了客户端会整帧丢弃)⇒ 那种情况下只记 WARN、不弹通知(绝不发一条会被丢掉的帧假装提醒过)。 -> - **⚠️ 判据不落库**:它只活在**实时帧**上(写入那一刻才知道这条是不是主动推送),库里的行没有对应列(不改 schema)。因此刷新后从 `GET /sessions/web/messages` / 握手 `history` 回放读到的那一行**不带**该信息 —— 这是预期的:通知只对「实时到达」有意义,翻历史不该弹提醒。 -> **思维链的唯一出口是实时帧**:§3.4.2 的 `thinking.start` / `thinking.delta` / `thinking.end`(owner/admin 档)。客户端**不要**为「从历史里读回思维链」写任何代码。 -> ⚠️ 本行**更正**本文档 v0.65 及更早的写法(旧版称「历史思维链只有 `GET /sessions/{id}/messages`(owner/admin)返回」)—— **那是文档写错了**,与实现不符。见 §7.3。 -> 另注:管理面板人格清单里的 `thinking` 是**人格旋钮**(`GET /admin/personas` 的 `thinking` 块),与消息 DTO 的 `thinking` 不是同一个字段。 -> **`voice` 覆盖同一批读取面(v0.67 登记;🔴 **v0.79 只落地了其中一部分**)**:消息 DTO 的可选 `voice` 对象**设计上**一次覆盖 `message_persisted` / `channel_persisted` / `message_edited` 与握手 `history` 回放(形状与语义见 §4.4)。🔴 **v0.79 实测覆盖**:只有 `message_edited`(与 §4.4 列出的 4 个 REST 读取面)会带它;**`message_persisted` / `channel_persisted` 与握手 `history` 回放仍不带 `voice`**(补齐需给 `history.Message` 加字段,**未做**)⇒ 客户端**不要在实时帧 / 握手回放上等 `voice`**。**WS 协议本身零变化**:无新帧、无新上行字段;`voice` 只是既有 DTO 的新可选键。 - -#### 3.4.5 子代理 / 任务 / 系统帧(owner+admin) - -| type | 帧壳 | data(要点) | -|---|---|---| -| `subagent_event` | — | `{id,name,status,role,mode}`(进度:dispatched/started/done/failed/aborted);`subagent_ask` 的应答另有 `AuditResult` 形状 | -| `subagent_result` | **sessionId = 父会话** | 批次结果 `{sessionId,status,summary,agents:[{id,goal,role?,mode?,status,summary?,text?,steps?}]}`。**不落库**(父会话转录无新消息、重连回放不补发) | -| `task_output` | — | `{taskId,output,status}`(`task_output_query` 应答);亦桥接自 `task.output` 主题 | -| `task_done` | — | 桥接自 `task.done` | -| `device_update` | — | 桥接自 `iot.device_update` | -| `automation_result` | — | 桥接自 `automation.result` | -| `reminder` | — | 桥接自 `reminder.trigger` | -| `graph_update` | — | 实时 flow 图快照(`data.sessionId` 标会话;**v0.77 起 `data` 另可带 `turnId`(该图的**路**的权威回合标识)与 `round`(会话轮次号)** ⇒ 真并行时同一会话**每一路各发自己的帧**,两路不再互相顶掉)。⚠️ **纯实时、不重放**:断开/刷新期间的那一轮不会补发 ⇒ 打开页面时用 `GET /api/v1/admin/flow/graph`(§2.2 B13 / §4.11)拉**最近一轮快照**(该端点 v0.77 起另有 `lanes[]` 给出每一路)。缺席语义见 §4.11(d) | -| `capacity_awaken` | sessionId | `{fromState,toState,tool}` | -| `mood` | (无 sessionId) | `{mood,personaId,affection}`;角色级情绪,仅变化时发 | -| `proactive_message` | — | `{platform,channelId,channelType,content,contentType,sender,timestamp,messageId,id?,createdAt?}`(已消毒,绝不含凭据/媒体字节)。**v0.72**:`id`/`createdAt` 是这条消息在**主会话历史**里的权威行 id 与落库时间戳(同现/同省),落库失败时键缺席 —— 客户端据此把实时气泡对齐到历史行(按 id 去重、刷新后仍在) | -| `approval_notice` | sessionId | `{actor,tool,op,detail,explain,allowed,at}`(主代理代审小字;`explain` 由后端产出、客户端原样渲染) | -| `mode_switch` | — | 上行 `mode_switch` 的回放(透传 `data`) | -| `observability_log` | — | `{entries:[{time,level,source,msg}...]}`,上限 200,最新在前 | -| `observability_trace` | — | `{spans:[...]}`,上限 100,最新在前 | -| `observability_metrics` | — | 扁平 `AdminMetrics` 快照 | -| `panels_changed` | (无 sessionId) | **插件面板集合变更信号(v0.78 新增,切片 PLG-2)**:`{reason,pluginId}`;`reason ∈ {declare,install,uninstall,enable,disable}`,`pluginId` **可为空串**(无法归因到单个插件时)。🔴 **帧刻意不带任何面板内容** ⇒ 客户端据此**按自己的角色重拉一次** `GET /api/v1/admin/panels`(A13)。**只发 owner/admin**(未进 `frameReachesAll` 白名单 ⇒ **viewer 收不到**,须自行刷新/轮询)。传输 = 进程内 bus 主题 `ws.panels_changed`(**零新端口**) | -| `channel_event` | (无 sessionId) | **渠道事件实时帧(v0.78 新增,切片 E7)**:`{event:{…}}`,`data.event` 与 `GET /admin/channels/events` 的 `events[]` 元素**同一个对象**(v0.67 登记的 `ChannelEventJSON`,键名 camelCase)。⚠️ **`id` 键可能整个缺席**(实时帧对应**刚落库、id 尚未回读**的那一行;列表端点读已落盘行,`id` 恒有值)。**只发 owner/admin**(未进 `frameReachesAll` 白名单;viewer 走 REST 只读档)。🔴 **增量提示、不是权威列表**:帧是 best-effort(hub 发送缓冲满即丢该连接的那一帧)⇒ 客户端**必须**保留 REST 轮询兜底;去重建议按(`platform`,`accountId`,`platformType`,`createdAt`)或以轮询结果为准重排。⚠️ **当前只覆盖账号状态迁移这一路**(见 §7.2) | - -> **observability_* 的订阅**:owner/admin 凭据**读取** `GET /admin/observability/{logs,metrics,trace}` 任一个即订阅;该凭据的 WS 连接全断即退订(无轮询、无常驻 goroutine)。 - -> **`channel_event` 当前实际会产生的事件(v0.78,切片 E7)**:只有**账号状态迁移** —— 即「**她自己的账号**上线 / 离线 / 未知」这一路。落库行为 `platform`(如 `qq-obv11`)/ `accountId` / `tier="t2"`(高价值 ⇒ **逐条落行**)/ `category="lifecycle"` / `platformType ∈ {get_status, meta_event.heartbeat, meta_event.lifecycle, account_status}` / `selfRelated:true` / `actorId:""` / `actorRole:"system"` / `channelId`·`channelType`·`groupId`·`messageId` 均空 / `summary` = **九句固定文案之一**(`账号在线(首次确知)`、`账号离线(首次确知)`、`账号状态未知(尚未探到)`、`账号离线(在线→离线)`、`账号上线(离线→在线)`、`账号上线(未知→在线)`、`账号离线(未知→离线)`、`账号状态未知(在线→未知)`、`账号状态未知(离线→未知)`,**不含任何平台原文**)。采样由网关**常驻**(约 60s 一拍,**不看面板也在跑**),**未知(`known=false`)也照常落行**(「尚未探到」≠ 离线)。当前**唯一**有账号状态面的平台是 `qq-obv11`;将来若把 `meta_event` 心跳也接进同一状态面,`platformType` 会跟着变。 - -> **`proactive_message` 的落库口径(v0.72,行为变更)**:后台思考决定「此刻主动开口」时,这条消息现在是**主会话(`web`)历史里的一条真实 `assistant` 消息** —— 走活跃客户端路由(在线 owner/admin 连接)的那一路此前**只广播帧、一行都不写** ⇒ 刷新/重载后什么都补不回来(既不显示气泡,也补不出历史)。本版 `SendProactive` **先落库、再发帧**: -> - 落库行:`sessionId="web"`、`role="assistant"`、`content` = 帧里的 `content`(后端已净化的同一份文本,系统元数据绝不进正文)、`platform` = 帧里的 `platform`、`mode="chat"`、`turn_id` **留空**(主动推送不属于任何一路回合)。因此 `GET /sessions/web/messages`、握手 `history` 回放、`message_persisted` 都能读到它。 -> - **`id`/`createdAt` 随帧下发**,客户端应据此把实时气泡对齐到历史行(按 id 去重;刷新后由历史行接续,不是第二个气泡)。**落库失败时这两个键缺席**,但同时:帧照发(实时气泡不丢),后端记 WARN 并返回错误 ⇒ 后台思考记录里的 `route/detail` 会写明**未落库**(不会再说成「已推送」)。 -> - **无在线 owner/admin 连接时也会落库**:推送器如实报告「没有活跃客户端」(`pushed=false`),这条话仍作为一条真实记录留在主会话里,下次打开就能看到。 -> - **IM 路由(v0.74 起也提醒)**:`im_bridge` 路由本就把消息镜像回主会话并广播 `message_persisted`(逐条一条,**不双份**),v0.74 起在落库之后**追加**一条帧顶带 `proactive:true` 的同类帧 ⇒ 客户端走与 `proactive_message` 同一套提醒逻辑(见 §3.4.4 后的「`message_persisted` 的 `proactive` 判据」)。两条路由在一次推送里**互斥**(`web` 提示只走活跃客户端,其余先 IM、IM 投递失败才降级活跃客户端),因此同一条主动消息在主会话里**恰好一条**。 - -#### 3.4.6 定向帧(不经 `hub.broadcast`,只到指定连接) - -| type | 说明 | -|---|---| -| `history` | 握手回放(§3.2,仅 owner/admin) | -| `voice_output` | 语音回合结算后**定向回发起 `voice_input` 的那条连接**。帧壳**无 sessionId**;data `{audio(base64),format,text,reply,sessionId,deviceId?,provider?}`。定向目标是连接本身,**服务端不按 deviceId 路由**;连接已断不补投 | -| `error` | 上行拒绝 / 处理函数级失败(形状见 §3.3 与 §4.1) | - ---- - -## 4. 契约细节 - -### 4.1 错误响应与错误码 - -- 形状:`{"error":{"code":"","message":""}}`。 -- 中间件级(Bearer/角色门)固定:`401 unauthorized` / `403 forbidden`。 -- 常用业务码(按状态分组): - -| 状态 | code(示例) | 场景 | -|---|---|---| -| 400 | `bad_request`、`image_too_large`、`too_many_documents`、`invalid_payload`(**v0.79**)、`unknown_provider`(**v0.79**)、`not_configured`(**v0.79**)、`primary_not_configured`(**v0.80**)、`invalid_trigger`(**v0.82**) | 请求体/字段非法、图片超限(`image` **字符串长度**,**仅 base64 / `data:` 形态会触发**,见 §4.5)、附件超条数;**插件动作的载荷形状错 / provider 名未登记 / 该家没配齐**(这三个码自 v0.79 起由动作处理器给出,见 §4.14(b)(c));**面板保存的 `primary` 指名一家「已登记但未配齐」的家**(**v0.80**,写盘**之前**就拒 —— 那份配置会让后端下次起不来,见 §4.14(c));**`GET /admin/backthink?trigger=` 的值未知或为空**(**v0.82**,见 §4.10(e-1);`browser` 面板的**未知字段**也走 `invalid_payload`,见 §4.14(d)) | -| 401 | `unauthorized`、`invalid_grant` | 无/坏凭据;refresh token 非法 | -| 403 | `forbidden`、`unknown_role`(**v0.78**,面板快照的角色无法识别,理论不可达、fail-closed) | 角色不足;主会话不可删;**插件写动作的调用者不是 owner/admin**(v0.78,见 §4.14(b)) | -| 404 | `not_found`、`session_not_found`、`document_not_found`、`account_not_found`、`media_not_found`(**v0.67 登记、v0.79 已实现**)、`tidy_run_not_found`(**v0.77 登记、已实现**)、`action_not_declared`(**v0.78 登记、已实现**) | 资源缺失;消息列表的会话不存在;文档缺失(含名字非法/穿越);渠道账号 id 不存在(C19/C20);**语音副本缺失**(含名字非法/穿越,三者同一响应,见 §4.5);**异步试算的 `runId` 未知 / 超 TTL / 进程重启后查不到**(三种原因刻意不区分,见 §4.12.1);**插件动作名未在该插件声明里出现过**(**不是**「没权限」,见 §4.14(b));**回滚的记忆条目不在被合并状态**(`not_found`,v0.79,见 §2.2 B17);**候选 id 不存在**与**「按 run 批量应用」的留痕行不存在**(**v0.83**,B21/B23 与 B22 —— 后者刻意与「这一轮没有待定候选」区分开) | -| 409 | `chat_busy`、`account_not_registered`、`plugin_disabled`(**v0.78**)、`config_unstartable`(**v0.80**)、`browser_unavailable` / `browser_session_not_open` / `config_rejected`(**v0.82**) | 待处理队列满;渠道账号已配置但没有已注册适配器(C19/C20,v0.66);**插件已被显式停用,其声明还在但入口与动作都下线**(v0.78,见 §4.14(b));**记忆整理被配置关闭**(`tidy_disabled`,v0.75 试算既有口径、**v0.83 的手动真跑 B24 复用同码同义** —— `MEMORY_TIDY_ENABLED=false`);**这次面板保存的结果会让 web-search 起不来**(主 provider 未配齐 / 未登记 ⇒ 下次启动 fail-fast)⇒ 已**按字节回滚**、运行中的工具保持原样(**v0.80**,见 §4.14(c));**`browser` 面板(v0.82,见 §4.14(d))**:本机没有可用浏览器来源 / 当前没有打开的页面(面板**不替模型开页**)/ 这次保存没能生效**已按字节回滚** | -| 413 | `payload_too_large`、`document_too_large` | 上传/文档超限 | -| 415 | `unsupported_media_type` | 图片/文档类型拒绝 | -| 429 | `rate_limited` | 限流(框架级) | -| 500 | `internal`、`chat_unavailable`、`chat_failed`、`voice_unavailable`、`stt_failed`、`tts_failed`、`tts_empty`、`voice_clone_failed`、`usage_unavailable`、`storage_unavailable`、`channel_events_unavailable`(**v0.67 已实现**,见 §2.4(a))、`refresh_unavailable`、`sessions_failed`、`messages_failed`、`session_failed`、`message_failed`、`document_upload_failed`、`login_failed`、`memory_review_unavailable`(**v0.79 新增**,切片 L45:入库前审查留痕存储未接线 —— 列表与回滚**共用**)、`reviews_failed`(**v0.79**:读留痕失败)、`revert_failed`(**v0.79**:回滚写失败)、`config_unreadable`(**v0.79**:面板可变源存在但读不出来,**不覆盖**原文件)、`config_write_failed`(**v0.79**:可变源写不进去,本次改动**没有**生效;⚠️ **回滚本身失败**时也用这个码并明说「盘上可能已经变了」—— **v0.82 的 `browser` 面板复用这两个码**,见 §4.14(d))、**记忆整理应用面(v0.83,切片 TIDY-1)**:`tidy_unavailable`(整理写面未接线;**候选列表 / 应用 / 回滚共用**,与只读试算组同名同义)、`tidy_candidates_failed`(读候选失败)、`tidy_apply_failed`(单条或批量应用失败)、`tidy_revert_failed`(回滚写失败)、`tidy_run_unavailable`(**只接线了只读试算、真跑入口没接** —— 「没跑」与「跑了没产出」必须能分辨)、`tidy_run_failed`(真跑失败) | 内部错误或依赖未接线(**绝不伪装成功**) | -| 501 | `not_implemented`、`panels_unavailable`(**v0.78**)、`action_not_implemented`(**v0.78**) | HistoryStore 等未注入;**插件面板中枢未接线**(见 §4.14(a));**动作已声明,但宿主没有登记它的处理器** —— 🔴 **v0.79 起不再是兜底档**:内置 `web-search` 的动作已全部登记(见 §4.14(b)(c)),这条只在「声明写了却没实现」时出现 | -| 502 | `action_failed`(**v0.78**)、探针码 `unauthorized` / `rate_limited` / `quota_exceeded` / `network` / `upstream` / `bad_response` / `no_results` / `unknown`(**v0.79**)、`browser_action_failed`(**v0.82**) | **插件动作执行失败**(结构化错误码 + 文案,详细原因只进后端日志);中间一组是 `plugin.testProvider` 探针的**上游结论**(其中 **`no_results` = 连通正常但没查到结果**);末一个 = `browser` 面板的截图 / 指纹自检**执行失败**(**v0.82**,见 §4.14(b)(d)) | -| 503 | `timeout`、`chat_dispatch_failed`、`catalog_unavailable`、`tidy_timeout`(**v0.83** 手动真跑超时:**本轮未完整跑完,已跑部分仍写入了留痕** —— 不假装跑全了) | 派发/上游不可用 | - -> **未确证**:`conflict`(409) 的通用码存在(框架级 `ErrConflict`),但除了 `chat_busy` 之外,本文档未逐一确证还有哪些端点会带它。 -> **⚠️ `416` 不是错误信封(v0.67 登记,**v0.79 已实现**)**:`GET /api/v1/media/{name}` 的 `Range` 语法非法或范围不可满足时,**由 Go 标准库直写** `416`(`text/plain` + `Content-Range: bytes */`),**不套** `{"error":{…}}` ⇒ 客户端**不要**拿 `error.code` 去解析它(见 §4.5)。这是字节流端点的既有事实(`/audio/speech` 的 `200`、`/documents/{name}` 的 `200` 同样不是 JSON 信封),**不是**本版新引入的怪癖。 -> **`400` 的渠道事件用法(v0.67 已实现,见 §2.4(a))**:`GET /admin/channels/events` 对 `platform` 未知、`tier` 非法、`since`/`until` 跨度 > 90 天、`since`/`until` 时间点非法、`limit`/`offset` 非整数都回 `400`(**落地用的 `code` 是通用的 `bad_request`**,见 §7.4-11)。 - -### 4.2 分页与游标 - -`GET /api/v1/sessions/{id}/messages`: - -- `limit`:默认 **50**,生效范围 **[1,200]**(大于 200 **钳到 200**,不报错);非数字或 `<1` → `400`。 -- `before`(毫秒 `createdAt`):返回**严格早于** before 的消息,**倒序**(默认方向,向后翻历史)。 -- `after`(毫秒 `createdAt`):返回**严格新于** after 的消息,**正序**(重连后的前向增量追赶)。 -- `before` 与 `after` **互斥**(同传且都 >0 → `400`);`after` 非数字/负 → `400`。 -- `q`:会话内内容搜索(大小写不敏感 ASCII LIKE,`%`/`_`/`\` 已转义),**倒序**、仍遵守 `before`/`limit`;**`q` 与 `after` 互斥**;空 `q` = 未传。 -- `platform`:可选来源平台过滤(Web 端不传 = 全量;IM 适配器传本平台)。 -- **形状校验(`limit`/`before`/`after` 非法、互斥冲突)先于 404 判定**:形状错就是 `400`,与目标会话存在性无关。 - -### 4.3 幂等(`clientMsgId`) - -- 上行可选字段(REST `chat` body 与 WS `chat` 帧**同形**;`voice_input` 亦然)。 -- **同一会话内同一个非空 `clientMsgId` 只承认第一条**。重复到达: - - REST → `200 {"data":{"duplicate":true,"messageId":...,"sessionId":...}}` - - WS → 下行帧 `chat_duplicate`(§3.4.3) - - **不落库、不起回合、不发任何内容/流式新帧**。 -- 判重由 SQLite 部分唯一索引 `(session_id, client_msg_id) WHERE client_msg_id != ''` 原子裁决(并发重发只有一个赢家)。 -- `clientMsgId` **回填进消息 DTO**(空则省略),读接口与 `message_persisted` 给出同一答案 —— 客户端据此对齐本地乐观气泡(替代「靠文本相等」的脆弱匹配)。 -- 省略它时行为逐字不变(老客户端零影响)。 - -### 4.4 消息 DTO(`historyMessage`) - -`GET /sessions/{id}/messages` 的数组元素、`PATCH /messages/{id}` 的响应 `data`、`GET /admin/channels[/{platform}]/messages` 的元素、`message_persisted` / `channel_persisted` 的 `data.message`、`message_edited` 的 `data`、握手 `history` 回放的 `messages[]` —— **同一个结构体**: - -```json -{ - "id": "<消息 id>", - "sessionId": "web", - "role": "user|assistant", - "content": "...", - "mode": "chat", - "mood": "", - "image": "", - "documents": [ {"name":"...","mime":"...","size":123,"chars":8000,"truncated":false,"status":"extracted"} ], - "thinking": "...", // 恒缺席(全仓无写入者;见 §3.4.4 口径更正) - "platform": "web", - "deviceId": "web:chrome:a1b2c3", - "senderName": "...", // 第三方发送者显示名;空 = 主人 - "adapterId": "kook:3bef...", // IM 来源适配器;Web 来源为空 - "personaId": "...", // 空 = 全局默认角色 - "clientMsgId": "...", // 空则省略 - "turnId": "turn-…", // v0.66;空则省略(老消息/未接管路径) - "toolCalls": [ // v0.78;🔴 只在 GET /sessions/{id}/messages 这一个出口填充(见下) - { "name": "web_search", "status": "ok", "durationMs": 1200, - "argsSummary": "{\"query\":\"明天天气\"}", "resultSummary": "晴,18~26℃" } - ], - "voice": { // v0.67 登记,v0.79 已实现(切片 L45);非语音消息键缺席;🎤 判据 = 本键存在 - "source": "asr", // "asr"(服务端识别,含云端)| "platform"(渠道平台自带转写) - "status": "transcribed", // "transcribed" | "empty" | "failed"(闭集三态) - "name": "a1b2c3….ogg", // 音频副本存储名;无副本 ⇒ 键省略 - "mime": "audio/ogg", - "size": 12345, - "durationMs": 3200, // 可选:渠道如实给出时才有(绝不估算) - "url": "/api/v1/media/a1b2c3….ogg" // 与 name 同生共死(服务端相对路径) - }, - "createdAt": 1700000000000 -} -``` - -字段省略规则:`mode`/`mood`/`image`/`thinking`/`platform`/`deviceId`/`senderName`/`adapterId`/`personaId`/`clientMsgId`/`turnId`/`toolCalls`/`voice` 均为 `omitempty`(空时键缺席;`voice` 是**指针**,非语音消息整键缺席,**不是** `{}` 也不是 `null`;`toolCalls` 见下)。 - -> ⚠️ **`toolCalls` 只在一个出口填充(v0.78,切片 CODE-TC)**:字段加在共享 DTO 上,但**只有** `GET /api/v1/sessions/{id}/messages` 的 handler 会填它 —— 其余出口(`PATCH /messages/{id}` 响应、`GET /admin/channels[/{platform}]/messages`、`message_persisted` / `channel_persisted` / `message_edited`、握手 `history` 回放)**逐字不变**(有单测钉住它们不产出该键)。客户端不要在那些面上等 `toolCalls`。 - -**`turnId` 语义(v0.66,可选)**: - -- 形状:字符串,与**该路**的实时流帧帧壳 `turnId`、以及 `turn_begin` 帧壳 `turnId` **同值**(编排器生成,见 §3.4.3 / §4.8)。 -- **同一路的 user 行与 assistant 行同值** ⇒ 客户端在**重连 / 回放**时可用它精确配对「回复 ↔ 提问」,并恢复回复侧的分组小标(真并行下两路回答交错,只按顺序渲染会配错)。 -- 退化为空(**键省略**)的三类情形:① 加入该列(迁移 `0018`)之前的历史行;② 未经编排器的写入路径(**IM 渠道桥等**,其行 `turn_id` 留空是预期行为);③ 忙时入队 / 被转向吃进的消息 —— 它们**落库那一刻**还没有自己的路,那一帧 `message_persisted` 因此**不带** `turnId`,客户端从随后的 `turn_begin`(帧壳 `turnId` + `data.messageIds`)得知归属;行本身由**实际处理它的那一路**随后补写(先到先得)。 -- 列口径:`chat_messages.turn_id TEXT NOT NULL DEFAULT ''`(**不用 NULL**,于是「旧行」与「没写」是同一个值)。 - -**`toolCalls` 语义(v0.78 落地,切片 CODE-TC;可选,仅 `GET /sessions/{id}/messages`)**: - -- 形状:数组,每条 `{name,status,durationMs?,argsSummary?,resultSummary?}`。`name` / `status` **恒在**;`status ∈ {ok,error,unknown}`(**闭集三态**);后三个键 `omitempty`(图上给不出该项时**键缺席**)。 -- `name` = 图的工具节点里 `props.tool`(回落节点 `label`)。`status` 映射:图节点 `done → ok`、`error → error`、其余(`running`/`idle`/空)→ `unknown` —— **未知一律落 `unknown`**,绝不把没结论的调用说成成功。 -- `durationMs` = 图上量到的起止差(毫秒);**缺任一时间戳、或不足 1ms ⇒ 键缺席,绝不估算、绝不回 `0` 冒充**。 -- `argsSummary` / `resultSummary` 逐字来自**已落盘的轮次链路图**(`flow_graphs`)里**出图时已脱敏**的 props(`args_summary` / `result_summary`);**失败的调用在 `resultSummary` 里给的是图上的 `error` 摘要**(与面板同一份已脱敏文本)。**读侧不做二次脱敏、也不放宽**。两者各自 ≤ **500** 字符(超长按 rune 截断,末位 `…`)。 -- 单条消息最多 **20** 条(超出按**图序**取前 20 条)。 -- 🔴 **缺席语义(契约核心)**:`toolCalls` 出现 ⇔ 这条消息所属的那一轮**确实调用过工具**;图取不到(超保留期 / 该轮未落盘 / 隔离渠道会话从不入库 / **该路标识查不到图** / 图源未接线)**以及**这一轮没调过工具,**都是键缺席**,**绝不回 `[]` 冒充「没调过工具」** ⇒ 客户端只做**正向断言**(有键 ⇒ 调过工具),**不得**把「没有这个键」读成「没调过工具」。 -- 数据源接线:`Deps.FlowGraphs`(`*flowgraph.SQLStore`)为 nil 时**只是没有这一面** —— 端点仍 `200`、正文一字不变(**不**像管理端点那样回 `500 flow_unavailable`)。查图失败只记一条带来源的 WARN 并让字段缺席(**不让可视化存档的故障把整页历史变成 500**)。 -- ✅ **覆盖率已补齐(v0.79,切片 CODE-TC-2)**:编排器驱动的**每一路**(含**普通单路回合**与**队首续跑路**)的图现在都带权威 `turnId`(= 该轮消息行的 `chat_messages.turn_id`)⇒ **这些回复都能回看到工具**(此前只有真并行路可以)。仍**缺席**的只剩两类:① **拿不到权威标识的写入路径** —— 未接编排器的调用方、**后台思考**(`sess-backthink` 在发布会话白名单内,但它的 ctx 上没有权威标识)、旧后端;② **v0.79 之前落盘的历史单路行**(`lane_turn_id` 为空且**无可回填来源** —— 那时的图里根本没存这个标识,用会话 id / 时间戳 / 消息表 join 去补都是**编造身份**)。见 §7.2、§7.4-23、§7.4-25。 -- 🔴 **一处形状变化(v0.79)**:`graph.id` 的 `#{回合标识}` 后缀现在**单路也追加**(`web-7` → `web-7#turn-…`)—— 客户端若要给 `GET /admin/flow/graph?turnId=` 取值,**一律用响应里的 `graph.id` / `turns[].turnId`**,**不要自己拼** `{会话}-{序号}`(老客户端自拼的 `web-7` **查不到新行**)。见 §4.11(a)(d)。 -- 新增的读侧依赖:`FlowGraphStore` 增只读方法 `GraphsByTurnID(ctx, sessionID, laneTurnIDs)`(一页一次 `IN` 批量查,禁 N+1);新表列 `flow_graphs.lane_turn_id`(迁移 `0023`,等值索引 `idx_flow_graphs_lane_turn_id (session_id, lane_turn_id)`)。**老行**(v0.77 之前的落盘)该列回填后仍为空 ⇒ 如实查不到。 - -**`voice` 语义(v0.67 登记;✅ **v0.79 已实现**,切片 L45 —— 覆盖率见本节末)**: - -- 形状:`{source,status,name?,mime?,size?,durationMs?,url?}`,`omitempty`(**非语音消息键缺席**,与引入语音前逐字相同)。 -- **🎤 判据 = `voice` 键存在**(这条消息来自一条语音消息)。客户端**绝不**从 `content` 正文里认媒体类型:QQ 语音正文里那个 `[语音]` 占位标记自 v0.67 起**不再下发**(见 §6 的 v0.67 行为变更),正文里也可能出现用户自己打出的 `[语音]` 字样。**只认 `voice` 键**。 -- `source` ∈ `"asr"`(服务端语音识别所得,含云端)/ `"platform"`(渠道平台自带转写,微信 iLink 的 `voice_item.text`)。 -- `status` ∈ `"transcribed"`(正文即转写文本)/ `"empty"`(转写成功但没识别出文字)/ `"failed"`(没拿到转写文本 —— 正文是那行中文诚实提示)。**客户端按 `status` 分支,绝不按正文文案分支**(§0.3 的同一纪律)。设计还**刻意不下发** `failed` 的机器可读细分(未配置 / 鉴权 / 欠费 / 限流 / 超时 / 格式不支持 / 无字节 ⇒ 分类只进服务端日志,正文给的是人读的原因方向)。 -- `name` / `mime` / `size` / `url` **四者同生共死**:有音频副本时全在,**无副本时全缺席**(**不会**出现 `url` 指着一个不存在的 `name` 这种形状)。 -- `durationMs` **只有渠道如实给出时才有**;读不出来就**省略键,绝不估算**(当前未确证各渠道是否都给得出,见 design/24 §10.12-8)。 -- `url` 是**服务端相对路径** `/api/v1/media/`(唯一派生字段,由服务端一处拼出)⇒ **客户端绝不自己拼路径**。 -- 落库形状 = DTO 形状**减去** `url`(列 `chat_messages.voice TEXT NOT NULL DEFAULT ''`,JSON **单对象**;**用空串不用 NULL**,与 `turn_id` 同一口径)。音频原件**复用文档那一套存储 / 元数据表 / 引用计数 / GC**(同一 `/uploads/documents` 目录),但**不进文档的抽取 / 注入链路**:不被抽取、不进 `documents` DTO 数组、不占注入预算、不出现在附件清单行里。 -- **一条消息只落一份音频副本**(= 被转写的那一条语音);同一条消息里的第 2..N 条语音既不转写也不落盘,DTO 里也没有它们(**刻意的边界**,不是遗漏)。 -- ⚠️ **微信(`weixin-ilink`)语音没有音频副本、不可回放**:按既有裁决「平台自带转写就不白下载那份音频」⇒ `source:"platform"` 且**无 `name`/`mime`/`size`/`url`**,只保留 🎤 标记。 -- ✅ **覆盖率(v0.79,如实)**:`voice` 已在**这 4 个读取面**填充 —— `GET /sessions/{id}/messages`、`GET /admin/channels/messages`、`GET /admin/channels/{platform}/messages`、`PATCH /messages/{id}` 的**响应与 `message_edited` 广播帧**。🔴 **尚未填充**:`message_persisted` / `channel_persisted`(`broadcast.go`)与握手 `history` 回放(`ws_history.go`)—— 需要给 `history.Message` 加 `voice` 字段(**未做**);且入站语音的 `voice` 列是**消息落库之后**回填的,`message_persisted` 那一帧在**落库那一刻**发出,即使加了字段也拿不到值。⇒ **客户端不要在实时帧上等 `voice`**(见 §7.2、§7.4-29)。 -- ⚠️ **列被外部改坏时可见地缺席**:非法 JSON / 闭集破坏时**不静默当「不是语音」** —— 记一条带来源与消息 id(**不含正文**)的 WARN,并让该字段缺席。契约的 `voice` 没有「读不出」这一态,因此缺席是唯一诚实的表达(与 `documents` 回一条 `unreadable` 不同)。 - -**`documents` 数组语义(核心)**: - -- 形状:`{name,mime,size,chars,truncated,status,reason?}`,**≤5 条/回合**,顺序 = 客户端发送顺序。 -- **0 个附件时该键省略**(不是 `[]`,与引入文档前逐字相同)。 -- `status` ∈ `extracted`(正文已进上下文)/ `listed`(只给了模型附件清单,正文没进上下文)/ `unreadable`(读不到正文)。 -- **`chars` / `truncated` 是「本回合注入侧」语义**:`chars` = **本回合注入**模型上下文的字符数(截断时为截断后值),**不是文件字数**;`truncated` = **注入**是否被预算截断,**不是文件是否被截断**。全文长度只有 `document-read` 工具的 `totalChars` 能看到。 -- `reason`(可选,`omitempty`):**只在 `status:"unreadable"` 时下发**(`extracted`/`listed` 时键缺席)。逐字就是服务端注入给模型的那份原因(同一字符串),客户端**不必自己拼**。当前文案集合(服务端常量,逐字): - - `无法抽取正文:这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)` - - `无法抽取正文:PDF 已加密(无法用空口令打开),请解除密码后重新上传` - - `无法抽取正文:PDF 损坏或无法解析` - - `无法抽取正文:本期不支持 Word/Excel/PPT 解析(无文本层)` - - `无法抽取正文:内容不在可抽取的纯文本范围内` - - `无法抽取正文:文档文件不存在或已被清理` / `…:读取文档失败` / `…:文档引用形态非法` / `…:文档上传目录未配置` - - `无法读取或解析失败`(解析器整条消息失败时的诚实兜底) - - ⚠️ **只放文案**:不含落盘路径、不含凭据;原始错误只进服务端日志。 -- **不含正文**(结构上保证:正文若从历史回传,每翻一页都要背着它)。 -- 时点:`status`/`chars`/`truncated`/`reason` 由**回合结束后**的回写写进 `document` 列 ⇒ 只在**读取面**(`GET /sessions/{id}/messages`、握手回放)可见;`message_persisted` 在用户消息落库时就发出(那时还没有结论)。 -- 列被写坏时(外部改动/格式变更)**绝不静默当无附件**:回一条 `status:"unreadable"` 的最小引用。 - -### 4.5 图片 / 文档 / 语音链路 - -**图片**: - -- 两条入口:`POST /api/v1/upload`(multipart,**单文件字节 ≤ 5 MiB**)取**绝对同源 URL**;或 `chat.image` 直接内联 base64 `data:` URI / http(s) URL。 -- ⚠️ **按形态写清(v0.66 补充说明,更正旧措辞「解码后 5 MiB」)**:`chat.image` 的**唯一入口判据是 `image` 字符串长度,不是「解码后字节数」** —— `POST /api/v1/chat` 的 `handleChat` 在**解码 / 落库 / 入模型之前**调用 `checkChatImageSize(image, limit)`(`internal/services/gateway/rest.go`),判定就是 `len(image) <= limit`;`limit` = `CHAT_IMAGE_MAX_BYTES`(默认 `5242880`)按 base64 膨胀 4/3 换算出的**字符**上限 = `ceil(n/3)*4 + 64`,默认 **6990572 字符**(那 64 字符是 `data:` 前缀余量)。**超一字符**即 `400 {"error":{"code":"image_too_large"}}`(`message` 带上限与本次长度,文案指路先上传)。 - - **base64 / `data:` URI 形态**:受上述 **6990572 字符**约束,**等价于解码后约 5 MiB**。`data:` 前缀越长,留给载荷的字符越少;**裸 base64**(无前缀)在恰界(正好 6990572 字符)时解码 ≈ `5242929` 字节 = **5 MiB + 49 字节** ⇒ **「解码后 5 MiB」是换算口径**,逐字节的硬判据在代码里**不存在**(视觉解码层的兜底预检 `vision/limits.go` 同样是**按字符串长度**判 `MaxInlineImageChars`)。要与「一张图 5 MiB」严格对齐,客户端按 `ceil(n/3)*4 + 64` 自查即可,不要按解码字节数试探。 - - **http(s) URL 形态**(含 `/api/v1/upload` 返回的 `/api/v1/files/<32hex>.`):判据与形态无关(任何**短于 6990572 字符**的串都通过),而正常 URL 只有约 60 字符 ⇒ **实践中 `image_too_large` 不会因 URL 形态触发**。URL 形态真正的天花板在**视觉取回层**:`internal/runtime/multimodal/vision/image.go` 的 `maxFetchedBody = maxImageBytes = 50 << 20`(**50 MiB**;其**视觉/OCR 工具路径**下同源 upload 产物优先走本地直读 `readFile`,用同一常量,而**主模型直收路径**仍走一次 HTTP 自取),另有 **30 s** 取回超时。⚠️ 该 50 MiB 是 `io.LimitReader` 的**截断**上限 —— **超出部分被静默丢弃、不返回错误** ⇒ 客户端**不要**把「服务端会拒绝超大 URL 图」当契约,也不要把 50 MiB 当成能用满的额度。 - - **upload 形态**:`POST /api/v1/upload` 自带 **5 MiB** 单文件硬上限(`maxUploadBytes = 5 << 20`;超限 `413 {"error":{"code":"payload_too_large"}}`)⇒ 经 upload 拿到的同源 URL 图,有效上限仍是 **5 MiB**,在**上传那一刻**定死,与本接口的字符串长度判据无关。 -- 上限来源 `CHAT_IMAGE_MAX_BYTES`:`≤0` = 用默认(**不存在「禁用上限」的取值**);**只能收紧、不能放宽**(钳到 vision 解码层硬顶 `vision.MaxInlineImageBytes` = 5 MiB)。 -- `data:` 前缀与类型嗅探**都不参与**这个体积判据:前缀只通过那 64 字符余量影响能塞多少;类型裁决(**字节嗅探**为唯一裁决权;HTML/SVG/判不定 → 明确报错,绝不静默当 PNG)发生在**视觉层**,属回合内错误,**不是**入口 `400`。 -- **WS `chat` 帧到不了这条判定**:WS 上行(`streamChat`)**没有任何图片体积校验**,读上限 4096 字节/帧由传输层直接断连 ⇒ **WS 上发真实图片必须「上传 + URL」**。 -- 视觉 URL 由服务端取回内联为 `data:` URI 再交 provider。**主模型能否收图**看生效组的 `multimodal`(见 §5),与 `LLM_VISION_MODEL`/`LLM_OCR_MODEL` 无关。 - -**文档**: - -- 上传 `POST /api/v1/documents`(owner/admin,字段 `file`,上限 `CHAT_DOCUMENT_MAX_BYTES` 默认 20 MiB)。 -- **类型裁决 = 字节嗅探(唯一裁决)∧ 扩展名白名单(23 项,含 `.pdf`)**,**成对判定**(`.pdf` 必须嗅探为 `application/pdf`,纯文本类必须 `text/plain`)。HTML 改名 `.pdf` ⇒ `415`;PDF 改名 `.txt` ⇒ `415`。`mime` 是**嗅探基类型**(`data.json` → `text/plain`;真 PDF → `application/pdf`)。 -- 落盘名 = `sha256(bytes)[:32] + ext`(内容寻址;原始文件名永不参与路径 ⇒ 同字节同扩展名天然去重)。 -- 读取 `GET /api/v1/documents/{name}` **必须 Bearer owner/admin**,一律 `attachment` + `nosniff`(永不内联)。 -- 对话携带:`chat.documents = [{"name":"<服务端存储名>"}]`(**只给 name**,绝不接受 URL;多带的 `mime`/`url`/`size` 一律被服务端覆盖)。**≤5 条**,超出 `400 too_many_documents`(先于任何文件系统访问);任一条非法/不存在 → **整个请求被拒**(`404 document_not_found`),绝不静默丢附件。附件部件挂在**同一条 user 消息**上;**纯附件回合**(无文本、无图片)同样成立。 -- 内置工具 `document-read`(模型侧,客户端一般只需知道它存在):注入被截断时清单行给续读游标 `offset`。策略默认 owner-only(群聊/陌生人天然读不到)。 -- **PDF(v0.61)**:文本层 PDF ⇒ `extracted`;扫描件/图片型 PDF(无文本层)⇒ 诚实拒绝 `unreadable`;加密与损坏可区分。 - -**语音**: - -- REST 源(`/api/v1/audio/*`,均 **owner-only + 审计**,绕开 WS 4KB 帧限):见 U5–U7。 - - `/transcribe`:`{audio,format?,deviceId?}` → `{data:{text,provider?}}`。 - - `/speech`:`{text,voice?,format?,instruction?}` → **原始音频字节**(`Content-Type: audio/`)。`instruction`(≤100 字符,汉字记 2)只对复刻/设计音色生效,系统音色 `longxiaochun_v3` 被忽略。 - - `/voice-clone`:`{url,prefix?}` → `{data:{voiceId}}`。⚠️ 真建音色需公网参考音频 URL(当前无,待主人提供)+ 北京地域 API Key。 -- WS 语音回合(`voice_input`):**与文本回合走同一条路**(进编排器 → 落库 → 流式 → 跨端同步 → 忙碌/排队语义一致)。转写文本落库为用户消息、助手回复落库;`deviceId`/`clientMsgId` 与 `chat` 帧同形。 -- `voice_output` 在**该回合结算时**才定向回发起连接(可能晚于若干状态帧,甚至由续跑回合或被 steer 注入的那一回合产生)。空正文 / TTS 失败/降级都走 `provider` 降级信号(`silent` 等),**不把 `stopReason` 当语音内容**;连接断开不补投,客户端带回同一 `clientMsgId` 可安全重试。 - -**入站语音的音频副本与播放端点(v0.67 登记;✅ **v0.79 已实现**,切片 L45;残留缺口见下)**: - -> ✅ **v0.79 落地进展(切片 L45,如实登记)**:**已实现** —— ① `documents.Store.SaveAudio`(音频白名单 / 内容寻址 / 同字节去重 / 目录 0700、**文件 0600** / 元数据写同一张 `documents` 表、引用计数并入同一套 `documents.ref_count`);② `history.Store.UpdateMessageVoice` + `VoiceColumns`(`chat_messages.voice` 列的**读写两条路**)⇒ channelbridge 的入站副本链**端到端可落地**;③ **网关 DTO `voice`(含唯一派生字段 `url`)** 与 **`GET /api/v1/media/{name}`**(§1.2 U19 / 下表)。🔴 **残留缺口(如实,不美化)**:① ✅ **已修(v0.80,切片 W4 / 裁决 #11)**:组合根**已注入** `VoiceTranscoder`(`cmd/cyrene/wire_channels.go` 的 `channelbridge.Options`)⇒ **不可播格式(`amr`)也落副本**(落盘前先转 `.wav`,`mime: audio/wav`);`transcode_not_wired` 只剩「引擎未装配 / 构造失败」一档。⚠️ **生产端到端仍未跑**;**可播格式(ogg/wav/mp3/m4a/aac/flac/webm/oga/opus)仍原样落、不转码**;② **WS 实时帧 / 握手回放不带 `voice`**(见 §3.4.4 与 §4.4 覆盖率);③ **消息 / 会话被删除时音频副本的引用计数不被释放**(`refs.go` 的增减点只认 `document` 列)⇒ 副本 `ref_count` **偏高**(后果是**晚回收 / 不回收**,绝不早删);④ **生产端到端未跑**(禁止重启后端)⇒ 「真机 IM 语音 → 盘上 0600 副本 → 客户端取到字节」只有替身 + 临时库/临时目录级证据。 - -> **v0.78 已落地的基础件(切片 CODE-VOICE;v0.79 已在其上补齐用户可见面)**:① 迁移 `0020_chat_message_voice` 给 `chat_messages` 加 `voice TEXT NOT NULL DEFAULT ''` 列(JSON 单对象,形状 = 下面的 DTO `voice` **减去 `url`**);② `internal/runtime/voice/**` 新包(`Source`/`Status` 闭集、`Ref` 编解码、格式级可播表 + `amr→wav` 转码归一);③ `channelbridge` 入站语音「归一(最前端)→ 闸门**之后**落副本 → 落库后写 `voice` 列」全链打通(落盘内容寻址、复用文档存储与 GC,但**不进文档抽取/注入链路**)。唯一已生效且**客户端可见**的既有变化:IM 语音在「音频格式不在可转写白名单内」时正文诚实提示由「…(需要先转码,当前未做)」改为「…(服务端只把白名单内的音频送语音识别),这条语音没有转写。」—— **形状不变**(正文仍是转写文本或一行诚实提示;客户端本就**不该**按正文文案分支)。 - -- **音频副本何时产生**:渠道适配器入站拿到语音字节后,转写在**流水线最前端**归一(见上),而**落盘在闸门之后** —— 被会话解析 / 黑白名单 / 最小打扰拦下的语音**盘上不留任何文件**(与 IM 附件同一纪律)。落盘 = 内容寻址(`sha256(bytes)[:32] + ext`,同字节同扩展名天然去重),产出该消息的 `voice` 引用(DTO 见 §4.4)。**不可播格式(`amr`)在落盘之前先转成可播副本(`amr→wav`)**;转码不可用时**不落副本、不打假 url、消息一个字不丢**(带稳定 `category` 的 WARN;10 类:`no_bytes`/`not_playable`/`transcode_not_wired`/`unsupported_format`/`input_too_large`/`output_too_large`/`decode_failed`/`encode_failed`/`timeout`/`busy`)。⚠️ **现状(v0.80 / 切片 W4 起)**:组合根**已注入 `VoiceTranscoder`** ⇒ `amr` 先转 `.wav` 再落副本(`mime: audio/wav`);可播格式仍原样落、**不转码**。⚠️ `transcode_not_wired` 从此只剩「引擎未装配 / 构造失败」一档(**生产装配下不再出现**)。 -- **播放端点 `GET /api/v1/media/{name}`**(**owner/admin,必须 Bearer**,与 `GET /api/v1/documents/{name}` 同一套门): - -| 项 | 口径 | -|---|---| -| 名字形态 | 32 位小写十六进制主名 + **音频白名单**扩展名(**10 项**:`.wav` `.mp3` `.aac` `.ogg` `.oga` `.opus` `.m4a` `.webm` `.flac` `.amr`);`..` 等路径穿越全形态拒绝 | -| `Content-Type` | 按**存储名的扩展名**映射,**恒为 `audio/*`**(`.wav→audio/wav`、`.mp3→audio/mpeg`、`.aac→audio/aac`、`.ogg`/`.oga`/`.opus→audio/ogg`、`.m4a→audio/mp4`、`.webm→audio/webm`、`.flac→audio/flac`、`.amr→audio/amr`)。**不查库、不重新嗅探**(扩展名与字节在**保存时**已成对裁决) | -| `Content-Disposition` | `inline; filename=""` —— 与文档读侧的 `attachment` **刻意相反**(音频要被播放器消费;因下发类型恒 `audio/*` + `nosniff`,内联不构成文档侧那类 XSS 面) | -| 其他响应头 | `X-Content-Type-Options: nosniff`;`Accept-Ranges: bytes`;`Cache-Control: private, max-age=86400, immutable`;**不设 `ETag`**(不自造语义,条件请求走 `Last-Modified`) | -| 状态码 | `200` 全量 / `206 Partial Content`(`Range: bytes=0-`、`bytes=N-M`、`bytes=-N`,**由 Go 标准库 `http.ServeContent` 处理 —— 客户端不要自研 range 解析**)/ `304 Not Modified`(条件请求)/ `404 media_not_found`(**名字形态非法 / 路径穿越 / 文件不存在三者同一响应**,不泄露「存在与否」以外的信息)/ `401` / `403`(中间件) | -| `HEAD` | 支持(`GET` 模式同时匹配 `HEAD`)⇒ 客户端可先探长度再决定要不要整段拉 | -| ⚠️ `416` | **不是 JSON 信封**:`Range` 语法非法或范围不可满足 ⇒ 标准库直写 `text/plain` + `Content-Range: bytes */`,**不套** `{"error":{…}}`。唯一例外:**空文件** + 范围不可满足 ⇒ 忽略 `Range` 回 `200`(标准库的特别处理) | -| 日志 | 404 **不记**错误日志(可预期的输入问题);不记任何字节内容 | - -- **三条读侧路的边界(别混)**: - -| 端点 | 鉴权 | Disposition | 名字形态 | 语义 | -|---|---|---|---|---| -| `GET /api/v1/files/{name}` | **无**(能力 URL) | `inline`(按图 MIME) | 128-bit 随机名 + 图片扩展名 | 图片,给 `` / provider 直接吃 | -| `GET /api/v1/documents/{name}` | **Bearer owner/admin** | `attachment` | 32 hex + **文档白名单**(23 项) | 文档,**永不内联渲染** | -| `GET /api/v1/media/{name}`(v0.67 登记 · **v0.79 已实现**) | **Bearer owner/admin** | `inline; filename=…` | 32 hex + **音频白名单**(10 项) | 语音副本,供播放器消费 | - -- ⚠️ **音频 URL 不能直接贴进 `