docs(contract): v0.78 登记 16 路已落地的六批形状(含 5 处如实的覆盖率/未实现标注)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

六批:① 消息读取面 toolCalls[] 已实现(迁移 0023 按路键;**覆盖率只到真并行路**)
② 管理面 panels + 动作端点 + panels_changed 帧(**动作执行器无生产实现 ⇒ 现全回 501**)
③ 搜索 provider 可变源(config/plugins/web-search.json;不新增 env 键)
④ 账号上下线落事件 + channel_event 帧(**只覆盖账号状态这一路**)
⑤ iLink 账号三态(-14=被拒非过期;**accounts[] 每账号三态未做**)
⑥ 语音:**只登记落库面已实现**,用户可见面(DTO voice + /media/{name})仍标未实现

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

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

未确证(文档内已标):六批都没在运行中的 :10942 上验过(本批禁换产物/重启);
lane_turn_id == chat_messages.turn_id 仍是代码级推论。
This commit is contained in:
AskaEth committed 2026-10-06 09:11:48 +08:00
1 parent 9e63af9063
commit a150211468
1 file changed
+197 -25
+197 -25
View File
@@ -1,12 +1,12 @@
# Cyrene v2 客户端 API 契约
> **契约版本:`v0.77`**(上一版 `v0.76`,其条目见 §6;`v0.75` / `v0.74` / `v0.73` / `v0.72` / `v0.71` 及更早见本文件 §6,`v0.67` 及更早的基线已归档、只读:`Docs/archive/api-client.v0.65.md`)。
> **契约版本:`v0.78`**(上一版 `v0.77`,其条目见 §6;`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;**语音消息尚未实现**。已在 §7.2 逐条如实标注。**本文档其余内容仍以代码为真源**。
> ⚠️ **v0.67 是「契约先行」登记版**:本版新增的三批 —— **语音消息**(播放端点 + 消息 DTO `voice`)、**非消息事件查询面**、**账号在线状态** —— 登记时**都还没有实现**,形状**取自设计文档**(`design/24 §10.11`、`design/26 §4.5` / `§2.6`)**而非代码核对**。**已落地**:账号在线状态(`52dac45`)、非消息事件查询面(切片 E6)—— 见 §2.4;**语音消息的「用户可见面」截至目前(v0.78)仍未实现**(v0.78 只落地了落库列 `0020` 与入站归一链,见 §4.5 / §7.2)。已在 §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` 语义都未改)。
@@ -18,10 +18,12 @@
> **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 是三面增量版(⚠️ **逐条状态不同**:**① 未实现且当前无实现路径**;**②③ 已实现**)**:① **消息读取面新增 `toolCalls[]`(🔴 本版**未实现** —— 客户端**不要**按它编程)** —— 原定:`GET /api/v1/sessions/{id}/messages` 的消息对象加可选 `toolCalls`(`omitempty`),每条含工具名 / 状态(`ok`/`error`/`unknown`)/ 耗时毫秒 / **已脱敏**的入参与结果摘要;数据源是**已落盘的轮次链路图**(`flow_graphs`,迁移 `0022`,保留期 168h),**不从正文反解**。🔴 **缺席语义**:图取不到(超保留期 / 该轮未落盘 / 隔离渠道会话从不入库)⇒ 字段**整体缺席**,**绝不回空数组**冒充「这一轮没调过工具」。**未实现的原因(实测确认,非推断)**:`chat_messages.turn_id` 与 `flow_graphs.turn_id` **不同源** —— 前者是编排器生成的随机路标识(`turn-` + 16 位十六进制),后者是 `trace.FlowGraph.ID` = `<session>-<轮次序号>`(发布器**进程内**递增计数);两者**无法按消息字面 join**,映射只能靠猜时间窗 ⇒ 按项目口径**不做**。补齐需要先让链路图带上**回合标识**,**不在本版**。⚠️ **v0.77 校正**:链路图的**回合标识已加上**(`graph.turnId`,纯增量、**未用新迁移**)+ 每路一行落盘 ⇒ 该**前提已具备**;但 `toolCalls[]` **本身仍未实现**(还缺一个「按路查图」的读侧入口)—— 见 **§7.2**、§7.4-23。② **未读面(✅ 已实现)** —— `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.76 是三面增量版(⚠️ **逐条状态不同**:**① 已在 v0.78 实现(覆盖率受限)**;**②③ 已实现**)**:① **消息读取面新增 `toolCalls[]`(工具回看面)—— ✅ 已实现(v0.78,切片 CODE-TC),但覆盖率受限**:`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[]` 已实现;但🔴 **只有真并行路能回看**(普通单路回合的图当前不带权威 `turnId`)—— 见 **§4.4、§7.2、§7.4-23 与 §6 的 v0.78 ①**。② **未读面(✅ 已实现)** —— `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.78 是六面增量版(⚠️ **①~⑤ 已实现;⑥ 只到落库面**(用户可见面仍未实现);以纯增量为主,无破坏性变更)**:① **消息读取面 `toolCalls[]` 落地**(切片 CODE-TC)—— v0.76 ① 从「未实现」改为**已实现**(数据源 `flow_graphs` + 迁移 `0023` 的**按路查询键** `lane_turn_id`);🔴 **覆盖率受限(如实)**:只有**真并行路**的图带权威 `turnId`,普通单路回合**查不到图 ⇒ 字段缺席**(契约允许,但不是想要的覆盖率)。详见 **§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**)。⚠️ **动作执行器无生产实现 ⇒ 所有已声明动作当前回 `501 action_not_implemented`**(见 §7.2)。详见 **§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}`)仍未实现**(生产接线未补,见 §7.2)。详见 **§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**。
---
@@ -54,7 +56,8 @@
- 成功:`{"data": <载荷>}`(个别端点直接回二进制,见 §4.5 语音)。
- 失败:`{"error":{"code":"<机器可读码>","message":"<人读文案>"}}`。
- 状态码全集(代码实际使用):`200 201 202 400 401 403 404 409 413 415 429 500 501 503`。
- 状态码全集(代码实际使用):`200 201 202 400 401 403 404 409 413 415 429 500 501 502 503`。
- ⚠️ **`502` 是 v0.78 引入**(插件动作执行失败 ⇒ `POST /admin/plugins/{id}/actions/{action}` 的 `action_failed`,见 §4.14(b));其余状态码集合未变。
- ⚠️ v0.67 登记的 `GET /api/v1/media/{name}`(**尚未实现**,见 §7.2)会引入 **`206` / `304`**,以及一条**非信封**的 **`416`**(`Range` 错误由 Go 标准库直写 `text/plain` + `Content-Range: bytes */<size>`,**不套** `{"error":…}`)。这三者**不在**上面那份「代码实际使用」集合内,实现后才加入;客户端**不要**拿 `error.code` 去解析 `416`。
- **客户端按 `error.code` 分支,不要按 `message` 文本**(文案会随修改变动)。
@@ -134,17 +137,19 @@
| 档位 | 中间件 | 条数 | 说明 |
|---|:--:|:--:|---|
| **只读监控档** | `viewerAuth`(owner/admin/viewer) | **12** | 非敏感投影:无凭据、无他人账号数据、无消息正文 |
| **只读监控档** | `viewerAuth`(owner/admin/viewer) | **14** | 非敏感投影:无凭据、无他人账号数据、无消息正文。**v0.78 新增两条**:面板接入点快照(A13)与插件动作分发(A14,**写门落在 handler 内**,见 A14 行注) |
| **owner/admin 读档** | `writeAuth`(owner/admin) | **14** | viewer 不得看到的一切:记忆、LLM 栈、账号/key、后台思考、审计链、渠道消息日志、完整会话导出、配置事实面(v0.68)、最近一轮链路图快照(v0.71)、记忆整理试算(v0.75) |
| **owner-only 档** | `ownerAuth`(owner) | **40** | 带审计的策略写、每个获取凭据的渠道端点、其余全部变更 |
**管理路由总数 = 12 + 14 + 40 = 66 条。**
**管理路由总数 = 14 + 14 + 40 = 68 条。**
> **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.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))**未计入本表**——这是历史口径,本版未改,也**未**把它算进上面的 14。逐条以代码为准。
> ⚠️ **与归档版不一致(已更正)**:`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 可读(12 条,`viewerAuth`)
### 2.1 viewer 可读(14 条,`viewerAuth`)
| # | 方法 | 路径 | 要点 |
|---|---|---|---|
@@ -160,6 +165,10 @@
| 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) |
| A14 | POST | `/api/v1/admin/plugins/{id}/actions/{action}` | **插件动作分发(v0.78 新增,切片 PLG-2)**。⚠️ **读写面不同档**:路由走 `viewerAuth`,但**写动作在 handler 内按 owner/admin 收紧**(`form.submit` / `table.rowActions` 是写;`table`/`log` 的 `source` 是读,对 viewer 开放)。**只接受该插件声明过的动作名**(未声明 ⇒ `404 action_not_declared`,**不是**「没权限」)。请求体可选 JSON(空体 ⇒ `payload=null`);**宿主不接受任何来自声明的请求地址**(不给插件开 SSRF 面)。⚠️ **当前无生产执行器 ⇒ 所有已声明动作回 `501 action_not_implemented`**(见 §7.2)。错误码与响应形状见 §4.14(b) |
> **A13 / A14 的角色门依据**(`routes.go` 紧邻注释):快照端点对 viewer 开放是因为它回的**本来就是被裁掉写面的只读投影**;动作端点的读来源动作对 viewer 可见,**写门落在 handler 内**(路由中间件只能有一道门)。`panels_changed` 帧(§3.4.5)**不进** `frameReachesAll` 白名单 ⇒ **viewer 收不到该帧**,需自行刷新/轮询重拉 A13。
### 2.2 owner/admin 读(14 条,`writeAuth`)
@@ -241,7 +250,7 @@
> - `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) 已实现**
### 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 冻结版对照**。
@@ -271,6 +280,19 @@
- **已实现口径**(本次落地):管理列表按**可选接口断言**读它 —— 适配器实现了 `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`
@@ -415,9 +437,13 @@
| `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` 会写明**未落库**(不会再说成「已推送」)。
@@ -446,14 +472,15 @@
|---|---|---|
| 400 | `bad_request`、`image_too_large`、`too_many_documents` | 请求体/字段非法、图片超限(`image` **字符串长度**,**仅 base64 / `data:` 形态会触发**,见 §4.5)、附件超条数 |
| 401 | `unauthorized`、`invalid_grant` | 无/坏凭据;refresh token 非法 |
| 403 | `forbidden` | 角色不足;主会话不可删 |
| 404 | `not_found`、`session_not_found`、`document_not_found`、`account_not_found`、`media_not_found`(**v0.67 登记、尚未实现**)、`tidy_run_not_found`(**v0.77 登记、已实现**) | 资源缺失;消息列表的会话不存在;文档缺失(含名字非法/穿越);渠道账号 id 不存在(C19/C20);**语音副本缺失**(含名字非法/穿越,三者同一响应,见 §4.5);**异步试算的 `runId` 未知 / 超 TTL / 进程重启后查不到**(三种原因刻意不区分,见 §4.12.1) |
| 409 | `chat_busy`、`account_not_registered` | 待处理队列满;渠道账号已配置但没有已注册适配器(C19/C20,v0.66) |
| 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 登记、尚未实现**)、`tidy_run_not_found`(**v0.77 登记、已实现**)、`action_not_declared`(**v0.78 登记、已实现**) | 资源缺失;消息列表的会话不存在;文档缺失(含名字非法/穿越);渠道账号 id 不存在(C19/C20);**语音副本缺失**(含名字非法/穿越,三者同一响应,见 §4.5);**异步试算的 `runId` 未知 / 超 TTL / 进程重启后查不到**(三种原因刻意不区分,见 §4.12.1);**插件动作名未在该插件声明里出现过**(**不是**「没权限」,见 §4.14(b)) |
| 409 | `chat_busy`、`account_not_registered`、`plugin_disabled`(**v0.78**) | 待处理队列满;渠道账号已配置但没有已注册适配器(C19/C20,v0.66);**插件已被显式停用,其声明还在但入口与动作都下线**(v0.78,见 §4.14(b)) |
| 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` | 内部错误或依赖未接线(**绝不伪装成功**) |
| 501 | `not_implemented` | HistoryStore 等未注入 |
| 501 | `not_implemented`、`panels_unavailable`(**v0.78**)、`action_not_implemented`(**v0.78**) | HistoryStore 等未注入;**插件面板中枢未接线**(见 §4.14(a));**动作已声明、已启用,但宿主没接线执行器** —— ⚠️ **当前生产路径上所有已声明动作都是这一档**(见 §4.14(b) / §7.2) |
| 502 | `action_failed`(**v0.78**) | **插件动作执行器返回错误**(结构化错误码 + 文案,详细原因只进后端日志),见 §4.14(b) |
| 503 | `timeout`、`chat_dispatch_failed`、`catalog_unavailable` | 派发/上游不可用 |
> **未确证**:`conflict`(409) 的通用码存在(框架级 `ErrConflict`),但除了 `chat_busy` 之外,本文档未逐一确证还有哪些端点会带它。
@@ -505,7 +532,11 @@
"personaId": "...", // 空 = 全局默认角色
"clientMsgId": "...", // 空则省略
"turnId": "turn-…", // v0.66;空则省略(老消息/未接管路径)
"voice": { // v0.67 登记(尚未实现);非语音消息键缺席;🎤 判据 = 本键存在
"toolCalls": [ // v0.78;🔴 只在 GET /sessions/{id}/messages 这一个出口填充(见下)
{ "name": "web_search", "status": "ok", "durationMs": 1200,
"argsSummary": "{\"query\":\"明天天气\"}", "resultSummary": "晴,18~26℃" }
],
"voice": { // v0.67 登记,**尚未实现**(用户可见面;见 §7.2);非语音消息键缺席;🎤 判据 = 本键存在
"source": "asr", // "asr"(服务端识别,含云端)| "platform"(渠道平台自带转写)
"status": "transcribed", // "transcribed" | "empty" | "failed"(闭集三态)
"name": "a1b2c3….ogg", // 音频副本存储名;无副本 ⇒ 键省略
@@ -518,7 +549,9 @@
}
```
字段省略规则:`mode`/`mood`/`image`/`thinking`/`platform`/`deviceId`/`senderName`/`adapterId`/`personaId`/`clientMsgId`/`turnId`/`voice` 均为 `omitempty`(空时键缺席;`voice` 是**指针**,非语音消息整键缺席,**不是** `{}` 也不是 `null`)。
字段省略规则:`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,可选)**:
@@ -527,7 +560,19 @@
- 退化为空(**键省略**)的三类情形:① 加入该列(迁移 `0018`)之前的历史行;② 未经编排器的写入路径(**IM 渠道桥等**,其行 `turn_id` 留空是预期行为);③ 忙时入队 / 被转向吃进的消息 —— 它们**落库那一刻**还没有自己的路,那一帧 `message_persisted` 因此**不带** `turnId`,客户端从随后的 `turn_begin`(帧壳 `turnId` + `data.messageIds`)得知归属;行本身由**实际处理它的那一路**随后补写(先到先得)。
- 列口径:`chat_messages.turn_id TEXT NOT NULL DEFAULT ''`(**不用 NULL**,于是「旧行」与「没写」是同一个值)。
**`voice` 语义(v0.67 登记,尚未实现)**:
**`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.78)**:只有**真并行路**(上行 `chat.parallel` 且照准)的图带权威 `turnId` ⇒ **只有这些回复能回看到工具**;**普通单路回合**(Web 单路聊天、队首续跑)的图**当前不带** `turnId`,落盘后 `flow_graphs.lane_turn_id` 为空 ⇒ 查不到图 ⇒ `toolCalls` **缺席**。这是契约允许的「图取不到」,但不是需求本意;补齐需落在 `internal/runtime/loop/**`(写侧),**不在本版**(见 §7.2、§7.4-23)。
- 新增的读侧依赖:`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 登记;🔴 **用户可见面尚未实现** —— 见 §7.2)**:
- 形状:`{source,status,name?,mime?,size?,durationMs?,url?}`,`omitempty`(**非语音消息键缺席**,与引入语音前逐字相同)。
- **🎤 判据 = `voice` 键存在**(这条消息来自一条语音消息)。客户端**绝不**从 `content` 正文里认媒体类型:QQ 语音正文里那个 `[语音]` 占位标记自 v0.67 起**不再下发**(见 §6 的 v0.67 行为变更),正文里也可能出现用户自己打出的 `[语音]` 字样。**只认 `voice` 键**。
@@ -592,9 +637,11 @@
- WS 语音回合(`voice_input`):**与文本回合走同一条路**(进编排器 → 落库 → 流式 → 跨端同步 → 忙碌/排队语义一致)。转写文本落库为用户消息、助手回复落库;`deviceId`/`clientMsgId` 与 `chat` 帧同形。
- `voice_output` 在**该回合结算时**才定向回发起连接(可能晚于若干状态帧,甚至由续跑回合或被 steer 注入的那一回合产生)。空正文 / TTS 失败/降级都走 `provider` 降级信号(`silent` 等),**不把 `stopReason` 当语音内容**;连接断开不补投,客户端带回同一 `clientMsgId` 可安全重试。
**入站语音的音频副本与播放端点(v0.67 登记,尚未实现)**:
**入站语音的音频副本与播放端点(v0.67 登记;🔴 **用户可见面尚未实现**;v0.78 只落地了「落库列 + 入站归一链」)**:
- **音频副本何时产生**:渠道适配器入站拿到语音字节后,转写在**流水线最前端**归一(见上),而**落盘在闸门之后** —— 被会话解析 / 黑白名单 / 最小打扰拦下的语音**盘上不留任何文件**(与 IM 附件同一纪律)。落盘 = 内容寻址(`sha256(bytes)[:32] + ext`,同字节同扩展名天然去重),产出该消息的 `voice` 引用(DTO 见 §4.4)。
> ⚠️ **v0.78 精确进展(切片 CODE-VOICE,如实登记)**:**已实现**——① 迁移 `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,但**不进文档抽取/注入链路**)。**尚未实现/未接线**——`documents.SaveAudio`(音频存储面)、`history` 的 `voice` 列写视图、组合根注入转码器 `Options.VoiceTranscoder` **三者都还缺** ⇒ 生产上**副本落不进盘、`voice` 列写不进库**(都走**如实的 WARN**,不是假成功);**网关侧的 DTO `voice`(含 `url`)与 `GET /api/v1/media/{name}` 端点仍未实现**。⇒ **客户端一律按「未实现」对待**,不要提前按可用编程(见 §7.2)。唯一已生效的**客户端可见**变化是: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`)。
- **播放端点 `GET /api/v1/media/{name}`**(**owner/admin,必须 Bearer**,与 `GET /api/v1/documents/{name}` 同一套门):
| 项 | 口径 |
@@ -909,6 +956,91 @@
**服务端实现位置**(供对照,非契约):`internal/services/gateway/broadcast.go`(帧构造,Web 路径与 IM 路径**共用同一个函数**)、`internal/services/gateway/session_read.go`(`unreadCountOf` + 可选窄接口 `sessionUnreadCounter`)、`internal/runtime/history/session_read.go`(`UnreadCount`)。
### 4.14 插件管理面板接入点与动作分发(v0.78 新增,切片 PLG-2 / PLG-4)
#### (a) `GET /api/v1/admin/panels` —— 面板接入点快照(**v0.78**)
**门**:`viewerAuth`(owner / admin / viewer 皆可,见 §2.1 A13)。**响应按调用者角色过滤**:viewer 只拿到只读区块(`status` / `log` / `table`,且 table 的**行内动作被摘空**、`form` **整体不出现**、区块全空的面板**整条不出现**)。**未接线面板中枢时回 `501 panels_unavailable`**(**绝不回空列表**把「没接线」伪装成「没有页面」)。
响应 `data`:
```jsonc
{
"role": "owner", // 本次快照按哪个角色裁剪(回显,便于客户端自检)
"groups": [ // §5.2.1 的 nav.group 一级
{
"id": "plugins", // 分组 id(本期闭集只有 plugins)
"title": "插件", // 分组显示名 = 宿主文案(分组本身没有声明)
"order": 20, // 分组排序 = 组内最小 order
"items": [ // nav.item
{
"pluginId": "web-search",
"id": "providers",
"title": "搜索提供商",
"description": "…", // 可选,缺省省略
"icon": "search", // 可选(宿主内置图标名),缺省省略
"order": 20,
"route": "plugin/web-search/providers", // **相对**管理面板前缀
"sections": [ /* design/09 §5.2 的 section 声明**原样**下发(字段名逐字同形) */ ]
}
]
}
],
"pluginIds": ["web-search"] // **声明过面板**的插件 id(已排序);≠ 已安装列表
}
```
- `sections[]` 就是 `pkg/plugins/sdk` 的 `Section` 形状(`type` / `id` / `title` / `fields` / `submit.action` / `source.action` / `columns` / `rowActions` / `limit`),**宿主不翻译**;插件**不注入任何 JS/CSS/HTML**。`type ∈ {form,status,table,log}`(`form` 是可写区块,其余只读)。
- `route` 是**相对片段**(`plugin/<pluginId>/<panelId>`,**不含** `ADMIN_PATH` 前缀)⇒ 前端拼成 `<prefix>/plugin/<pluginId>/<panelId>`。
- `pluginIds` 是给「插件与能力」页做**对照**用的:拿它与 `GET /api/v1/admin/plugins/installed` 的 `name`/`id` 求**差集**即可得到「**已装但未声明任何页面**」的插件,客户端应把这类插件**如实显示成「未声明管理页面」**,**不得**藏掉。
**错误**:`501 {error:{code:"panels_unavailable"}}`(未接线);`403 unknown_role`(角色无法识别,理论不可达,fail-closed)。
> ⚠️ **当前运行期快照不是空的**:内置插件 `web-search`(`plugins/tools/io/search` 的 `AdminPanels()`)在**组合根装配期**声明给中枢 ⇒ 正常部署下 `pluginIds == ["web-search"]`。热装插件(MCP 等)**目前恒为「未声明管理页面」**(声明来源 `manifest.json` 的读取未实现,见 §7.2)。
#### (b) `POST /api/v1/admin/plugins/{id}/actions/{action}` —— 插件动作分发(**v0.78**)
**路径**:`{id}` = 插件 id(面板目录键,本期即插件名,如 `web-search`);`{action}` = 插件**声明过的**动作名(如 `plugin.listProviders`)。
**门**:路由中间件 `viewerAuth`,**写动作在 handler 内再按 owner/admin 收紧**(见 §2.1 A14)。
| 情形 | 状态 | `error.code` | 说明 |
|---|---|---|---|
| 面板中枢未接线 | 501 | `panels_unavailable` | 未接线就说未接线 |
| 动作名未在声明里出现过 | 404 | `action_not_declared` | §5.2.1「只接受该插件声明过的动作名」;**未声明不是「没权限」** |
| 插件已被显式停用 | 409 | `plugin_disabled` | 声明还在,入口与动作都下线 |
| `kind=write` 且调用者不是 owner/admin | 403 | `forbidden` | 写面 = `form.submit` + `table.rowActions`(§5.4.1) |
| 已声明、已启用,但宿主没接线执行器 | 501 | `action_not_implemented` | ⚠️ **当前生产路径就是这一档**(见下) |
| 请求体不是合法 JSON,或超过 1 MiB | 400 | `bad_request` | 绝不把坏体当空体继续跑 |
| 执行器返回错误 | 502 | `action_failed` | 结构化错误码 + 文案(§5.4.6),详细原因只进后端日志 |
**请求体**:可选 JSON。**空体 ⇒ `payload=null`**(读来源动作通常无参)。**原样交给执行器**,宿主不解释插件的数据形状、**不认识任何 URL**(**不给插件开 SSRF 面**)。
**响应 `data`**:`{pluginId,action,kind,result}` —— `kind ∈ {read,write}`(`read` = `table`/`log` 的 `source`;`write` = `form.submit` / `table.rowActions`;同名动作同时出现在读面与写面时**一律按 write**);`result` = 执行器返回值**原样透传**(未接线时该键不存在)。
**审计**:**每一次结算都落一行**,`what = "plugin.action"`,`result ∈ {ok,error,denied,not_declared,unimplemented,unavailable,bad_request}`。⚠️ 持久化审计记录仍只有 `who/what/when/result` 四字段 ⇒ **未含 pluginId / action**(它们只在**结构化日志行**里,见 §7.2)。
> 🔴 **当前生产路径的硬事实(v0.78,不美化)**:**没有任何生产实现的动作执行器**(`Panels.SetExecutor` 只在测试里被调用)⇒ **所有已声明动作都回 `501 action_not_implemented`** —— 搜索提供商页的**表格拉不到数据**(`plugin.listProviders` 是读来源动作)、表单保存不了。**客户端不得把「端点返回某个状态码」当成「数据可用」**:只有拿到 `result` 才算动作真的执行了。见 §7.2。
#### (c) `web-search` 插件的动作面(v0.78,切片 PLG-4;供上面 (b) 的分发面使用)
插件 id `web-search`;可变源落点 `$DATA_DIR/config/plugins/web-search.json`(**0600**、temp+rename)—— ⚠️ **不进客户端契约**(客户端一律走动作端点,不直连文件)。组合根「**先读可变源、缺失回落 `.env` 播种**」:文件一旦落盘,`.env` 的 `WEB_SEARCH_*` **只在文件缺失时**起作用(⚠️ **运维视角的行为变更**,见 §5)。🔴 **不新增任何 env 键**(`.env` 已冻结为引导面;本切片未改 `.env` / `.env.example`)。
`plugin.listProviders` 返回的表格行(`rows[]` 元素):`{ "provider": "bocha", "state": "ok", "enabled": true }`(**凭据与地址不回**)。`state` 取值(闭集)与判定顺序(根因在前):`unknown`(名字本期没登记)→ `disabled`(显式停用)→ `not_configured`(名字已登记但没配齐:云家缺 key / searxng 缺地址)→ `ok`(其余)。⚠️「凭据失效」**不是** `state` 取值(它只能由一次真实调用得知,属 `plugin.testProvider` 的结论)。
四个动作(**本插件声明的全部动作**):
| 动作名 | 语义 | 请求体 | 响应体 |
|---|---|---|---|
| `plugin.listProviders` | 列出各条 provider 配置(**读**来源) | `{}` | `{ "rows": [ {provider,state,enabled} ] }`(凭据/地址不回) |
| `plugin.saveProvider` | 新增/修改一条(按 `provider` **upsert**;**写**) | `{ "provider":"bocha","apiKey":"…","baseUrl":"https://…","enabled":true }` | `{ "saved":true,"provider":"bocha" }` |
| `plugin.testProvider` | 测试连通(**真发一次最小检索**;**写**面 rowAction) | `{ "provider":"bocha" }` | `ProbeResult`,见下 |
| `plugin.reauthProvider` | 重配凭据:把该条带回表单(**只读回显掩码值**;**写**面 rowAction) | `{ "provider":"bocha" }` | `{ "provider":…,"apiKey":"****1234","baseUrl":…,"enabled":true }` |
`plugin.testProvider` 响应体:`{"ok":true,"provider":"bocha","count":1,"latencyMs":412}`;失败时如 `{"ok":false,…,"errorCode":"unauthorized","error":"上游拒绝了凭据(401/403):请核对 API Key 是否有效"}`。`errorCode` 闭集:`unknown_provider` / `not_configured` / `unauthorized` / `rate_limited` / `quota_exceeded` / `network` / `upstream` / `bad_response` / `no_results` / `unknown`。🔴 **`errorCode=no_results` 时 `ok=false` 但「连通是好的」**(空结果 ≠ 不通)⇒ 前端文案**必须**与真正的失败区分开,别把它显示成「该家不可用」。🔴 响应体**绝不**含上游 URL 与凭据。
`optionsFrom: plugin:providers` 的选项值 = `search.ProviderOptions()` = 本期**全部已登记**的 provider 名(含尚未配置的家,稳定排序)。
---
## 5. env 与模型配置(与契约相关)
@@ -966,6 +1098,9 @@
| `CHANNEL_EVENT_COUNTER_FLUSH` | `30s` | **v0.67 登记,已接线**(E4 `afe9afb`):计数内存聚合的 flush 间隔。计数是**尽力而为的近似值**(进程崩丢 ≤30s),查询面以 `approx:true` 如实标注(见 §2.4(a)) |
| `FLOW_GRAPH_RETENTION` | `168h` | **v0.73 新增,已接线**(切片 F2):`flow_graphs` 轮次图保留期;`≤0` = 关闭 GC(显式选择,「配了却不生效」不存在)。过期轮次从 `turns[]` 消失(见 §4.11(b)-5 / §7.4-20) |
| `FLOW_GRAPH_GC_INTERVAL` | `1h` | **v0.73 新增,已接线**(切片 F2):链路图保留期清扫间隔(与 `CHANNEL_EVENT_GC_INTERVAL` / `DOCUMENT_GC_INTERVAL` 同范式;启动期扫一次 + 定时 + 失败不退出) |
| `WEB_SEARCH_*`(`WEB_SEARCH_PROVIDER` / `_FALLBACK` / `_MAX_RESULTS` / `_ENDPOINT` / `_TIMEOUT` / 各家 `_API_KEY` / `_BASE_URL`) | 见模板 | **v0.78 起语义收窄(切片 PLG-4,运维视角的行为变更)**:web-search 的**可变源**改为 `$DATA_DIR/config/plugins/web-search.json`(0600、文件名 = 插件 id)。组合根**先读可变源、文件缺失才回落 `.env` 播种**(即 `.env` 只作**引导面**):**文件一旦落盘,改 `.env` 不再影响运行中的部署**。🔴 **不新增任何 env 键**(`.env` 已冻结);客户端一律走插件动作端点(§4.14(c)),**不直连该文件** |
> ⚠️ **v0.78 的「`.env` 只播种」口径**(切片 PLG-4):这与 §5 开头「**只要文件存在且有效,运行时优先用它**,`.env` 只作兜底/播种层」是**同一条原则**在搜索 provider 上的落地 —— 此前 web-search 是纯 `.env` 面,v0.78 起它也有了面板/文件配置面。**无 env 键新增/删除**;模板里既有 `WEB_SEARCH_*` 键**保持现状**。
**与契约相关但「配了不生效」的键**:
@@ -981,7 +1116,21 @@
> 只保留最近若干版(**v0.60 起**);更早的全部历史见归档 `Docs/archive/api-client.v0.65.md` §5(v0.28 → v0.59)。
> 每版一句话 + 三档标注。**三档定义见 [§0.4](#04-三档标注惯例)。**
### v0.77(当前契约版本)
### v0.78(当前契约版本)
**六面增量 —— ① 消息读取面 `toolCalls[]` 落地(覆盖率受限)|② 插件管理面板接入点(`GET /admin/panels` + 插件动作分发端点 + `panels_changed` 帧)|③ 搜索 provider 可变源(`web-search` 插件)|④ 账号上下线落事件 + 渠道事件实时帧 `channel_event`|⑤ 微信 iLink 凭据三态(`authState`/`authDetail`)|⑥ 语音消息落库面(迁移 `0020`)**(依据:安卓侧要「回看这条回复当时调了什么工具」;design/09 §5.2.1 的插件面板接入点 + §5.4 的安全与权限;design/27 的网络搜索提供商 + design/13 §2.1 的插件配置落点;design/26 §2.6/§4.5 的账号状态事件与实时面;`errcode=-14` 被读成「过期」的误判风险;design/24 §10 的语音落库面。)
- **① 消息读取面 `toolCalls[]` 落地(切片 CODE-TC)**:`GET /api/v1/sessions/{id}/messages` 的消息对象新增**可选** `toolCalls`(`omitempty`),每条 `{name,status,durationMs?,argsSummary?,resultSummary?}`(`name`/`status` 恒在,`status ∈ {ok,error,unknown}` 闭集三态;后三键图上给不出时**缺席**)。数据源 = **已落盘的轮次链路图** `flow_graphs`;🔴 **缺席语义**:图取不到 ⇒ 字段**整体缺席**,**绝不回 `[]`**(客户端只做正向断言)。**为打通读侧补齐**:迁移 `0023_flow_graphs_lane_turn_id` 给 `flow_graphs` 加**按路查询键** `lane_turn_id`(等值索引 `idx_flow_graphs_lane_turn_id (session_id, lane_turn_id)`)+ 回填;`FlowGraphStore` 增只读方法 `GraphsByTurnID`(一页一次 `IN` 批量查,禁 N+1);`history.go` 的 `handleListMessages` 在编码前调 `attachToolCalls`。**只在该出口填充**(其余 DTO 出口逐字不变,有单测钉住)。⚠️ **覆盖率受限(如实)**:只有**真并行路**的图带权威 `turnId` ⇒ **普通单路回合的回复查不到图、`toolCalls` 缺席**(补齐在 `internal/runtime/loop/**` 写侧,**不在本版**)。**§4.4 / §7.2 / §7.4-23 / §2.2 B13**。
- **② 插件管理面板接入点(切片 PLG-2,纯增量为主)**:新增 1 条 viewer 读端点 **`GET /api/v1/admin/panels`**(**按调用者角色过滤**后的接入点快照 `{role,groups[],pluginIds[]}`,viewer 只拿只读区块;未接线 ⇒ `501 panels_unavailable`)与 1 条动作分发端点 **`POST /api/v1/admin/plugins/{id}/actions/{action}`**(**只接受该插件声明过的动作名**,未声明 ⇒ `404 action_not_declared`;写动作 owner/admin 收紧 ⇒ 未授权 `403 forbidden`;停用插件 ⇒ `409 plugin_disabled`;**每次结算都落审计** `what="plugin.action"`;**插件只给动作名、不给 URL** ⇒ 不开 SSRF 面);WS 下行帧 **`panels_changed`**(`{reason,pluginId}`,`reason ∈ {declare,install,uninstall,enable,disable}`,**只发 owner/admin**,**不带内容**⇒ 客户端按角色重拉快照)。权限矩阵:`viewerAuth` 12 → **14**、总数 66 → **68**。🔴 **当前生产路径硬事实**:**无任何生产动作执行器** ⇒ **所有已声明动作都回 `501 action_not_implemented`**(见 §7.2)。**§2.1 A13/A14 / §3.4.5 / §4.14(a)(b)**。
- **③ 搜索 provider 可变源(切片 PLG-4)**:插件 `web-search` 新增可变源 `$DATA_DIR/config/plugins/web-search.json`(**0600**、temp+rename、文件名 = 插件 id),组合根「**先可变源、缺失回落 `.env` 播种**」;面板数据面 = 四个动作 `plugin.listProviders` / `plugin.saveProvider` / `plugin.testProvider` / `plugin.reauthProvider`(表格行 `{provider,state,enabled}`,`state ∈ {unknown,disabled,not_configured,ok}`;`testProvider` 的 `errorCode` 闭集十项,⚠️ **`no_results` 时「空结果 ≠ 不通」**)。🔴 **不新增任何 env 键**(`.env` 已冻结);**`.env` 的 `WEB_SEARCH_*` 自此只在文件缺失时生效**(运维视角的行为变更,如实登记)。**§4.14(c) / §5**。
- **④ 账号上下线落事件 + 渠道事件实时帧(切片 E7)**:`internal/runtime/events` 增「账号状态迁移」这一路 —— 三态归一(**未知也照常落行**,不整类丢弃)→ 落一行 `tier=t2` / `category=lifecycle`(**复用迁移 `0021`**)→ 经**既有进程内 bus** 的主题 `channel.event` 推成 WS 下行帧 **`channel_event`**(`data.event` 与 REST `events[]` **同一个对象**;⚠️ **实时帧的 `id` 键可能整缺**;**只发 owner/admin**,**viewer 走 REST 只读档**)。采样常驻(约 60s 一拍,**不看面板也在跑**)。⚠️ **当前只覆盖账号状态迁移这一路**(其余渠道事件的实时装饰器**未接线**,见 §7.2);采样通道目前唯一来源是 `qq-obv11` 的探活。**§3.4.5**。
- **⑤ 微信 iLink 凭据三态(切片 CODE-F3)**:`GET /api/v1/admin/channels/{platform}/login-status` 新增**可选** `authState` / `authDetail`(`"active"` 有效 / `"rejected"` **被拒且需重扫** / `"unknown"` 未知;**闭集**)。🔴 **文案口径写死**:**`errcode=-14` 是「被拒」不是「过期」**、**未知原因不许写成「过期」/「未连接」**(响应体全文由测试钉住)。**键缺席 = 该账号没有这条状态面**,≠ `unknown`。**无新端点、无新路由**(沿用 v0.39 起的端点)。⚠️ `GET /admin/channels` 的 `accounts[]` **每账号**三态**未实现**(见 §7.2)。**§2.4(c)**。
- **⑥ 语音消息落库面(切片 CODE-VOICE)**:迁移 `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、**不进文档抽取/注入链路**)。🔴 **用户可见面(DTO `voice` + `GET /api/v1/media/{name}`)仍未实现**(缺 `documents.SaveAudio` / `history` 写视图 / 组合根注入转码器三件接线)⇒ **客户端按「未实现」对待**。唯一已生效的**客户端可见**变化:IM 语音在「格式不在可转写白名单内」时正文诚实提示改口径(**形状不变**,客户端本就不该按正文文案分支)。**§4.5 / §7.2**。
- **行为变更**:① 无(新可选键 + 新迁移列/索引);② 无(新端点 / 新帧 / 新可选键;老客户端零影响)—— ⚠️ 但**新增了 `502` 状态码**(仅插件动作执行器失败路径)与一批插件动作错误码;③ **有**(运维视角:`.env` 的 `WEB_SEARCH_*` 仅在可变源文件缺失时生效);④ 无(新帧 + 新事件行;REST 形状未变);⑤ 无(两个可选键;`status`/`botId`/`botToken`/`err` 与 v0.39 起逐字一致);⑥ **有(轻微)**:IM 语音「不可转写格式」的正文诚实提示**改口径**(**形状不变**)。
- **破坏性**:**无**。
- ⚠️ **诚实边界**:① `toolCalls[]` **覆盖率只到真并行路**(普通单路回合查不到图 ⇒ 缺席),且**未做真机端到端**(真并行路的 `lane_turn_id` 与 `chat_messages.turn_id` 逐字相等仍是**代码级**推论);② 插件动作**生产无执行器**(全回 501)、热装插件的声明来源缺、`panels_changed` 对 viewer 不可见、启用态**不跨重启持久化**、审计持久化记录**未含 pluginId/action**(都在 §7.2);③ `web-search` 声明已就绪但**「面板加一家 provider」未做真机端到端**;④ `channel_event` **只覆盖账号状态迁移这一路**(组合根装饰器未接线),且**未做真机端到端**;⑤ iLink `rejected` 分支**只有单测/httptest 证据**(真实 `errcode=-14` 至今 0 次观测);⑥ 语音**生产端到端未跑**(`voice` 列写不进库、副本落不进盘、模型仍不知道文字来自语音)。
### v0.77(上一版)
**链路图按路分桶(FV2)+ 记忆整理护栏抬高 / 诚实截断 / 试算异步落地(MT-A)+ 「入库前审查」env 键登记(MT-B)—— 三批都是纯增量**(依据:主人 2026-10-05 报「并行两路时流程图互相顶掉」、以及「工具回看面要先让链路图带上按路的回合标识」;「护栏被打满 ⇒ 候选数由上限而不是规则决定」要治;`design/23 §15.1 D3` 的入库前审查上线)。
@@ -992,11 +1141,11 @@
- **破坏性**:无。
- **⚠️ 诚实边界**:① 真并行**未做真机端到端**(线上产物是旧构建;真并行是在单测层用「真 `Loop.Run` × 2 + 网关照同的 ctx 通道」复现的)—— 见 §7.4-24;② 上面 ②「抬高后真机 `compared == scanned`」**尚未被真机证实**(只有单测与量级推算支持);③ MT-B 的入库前审查**未在生产库上跑过**。
### v0.76(上一版 · ⚠️ **逐条状态不同 —— ①未实现(阻塞);②③已实现**)
### v0.76(上一版 · ⚠️ **逐条状态不同 —— ①已由 v0.78 实现(覆盖率受限);②③已实现**)
**三面增量 —— ① 消息读取面 `toolCalls[]`(工具回看 · **未实现**)|② 未读面 `unreadCount`(多端共享单游标 · **已实现**)|③ 记忆整理试算的加性异步(**已由 v0.77 落地校正**)**(依据:安卓侧 `Cyrene-For-Android/devdocs/02` 明确「工具回看面比未读面更重要、且要带工具输出/摘要」,以及主人 2026-10-05 裁定的「抬高 + 治入库太宽」导致试算更慢)。
**三面增量 —— ① 消息读取面 `toolCalls[]`(工具回看 · **✅ 已由 v0.78 落地**)|② 未读面 `unreadCount`(多端共享单游标 · **已实现**)|③ 记忆整理试算的加性异步(**已由 v0.77 落地校正**)**(依据:安卓侧 `Cyrene-For-Android/devdocs/02` 明确「工具回看面比未读面更重要、且要带工具输出/摘要」,以及主人 2026-10-05 裁定的「抬高 + 治入库太宽」导致试算更慢)。
- **① 工具回看面(🔴 未实现 —— 客户端不要按它编程)**:原定 `GET /api/v1/sessions/{id}/messages` 的消息对象加可选 `toolCalls`(`omitempty`)—— 每条含 `name` / `status`(`ok`|`error`|`unknown`)/ `durationMs` / `argsSummary` / `resultSummary`(后两者是**图里已有的、已脱敏**内容并按上限截断)。原定数据源 = **已落盘的轮次链路图**(`flow_graphs`,迁移 `0022`,保留期 168h),**不从正文反解、不从内存态猜**。🔴 **缺席语义**:图取不到 ⇒ 字段**整体缺席**(**绝不回空数组**冒充「没调过工具」)。修的是安卓侧「回看这条回复时看不到它当时调了什么」。**⚠️ 实测阻塞(非推断)**:`chat_messages.turn_id` 与 `flow_graphs.turn_id` **不同源**(前者 = 编排器生成的随机路标识 `turn-<16 hex>`,后者 = `trace.FlowGraph.ID`,进程内递增的建图序号)⇒ **无法按消息 join 到它那一轮的图**,而按时间窗硬凑映射属于「猜」,项目口径**不做**。补齐需先给链路图加**回合标识**,**不在本版**。⚠️ **v0.77 校正**:链路图的**回合标识已加上**(`graph.turnId`,纯增量、**未用新迁移**)+ 每路一行落盘 ⇒ 这个**前提已具备**;但 `toolCalls[]` **本身仍未实现**(还缺一个「按 `graph.turnId` 查图」的读侧入口),见 **§7.2**、§7.4-23。
- **① 工具回看面(✅ 已在 v0.78 实现 —— 见 §6 v0.78 ① 与 §4.4;**覆盖率受限**)**:`GET /api/v1/sessions/{id}/messages` 的消息对象加可选 `toolCalls`(`omitempty`)—— 每条含 `name` / `status`(`ok`|`error`|`unknown`)/ `durationMs` / `argsSummary` / `resultSummary`(后两者是**图里已有的、已脱敏**内容并按上限截断)。数据源 = **已落盘的轮次链路图**(`flow_graphs`,迁移 `0022`;**v0.78 补迁移 `0023` 的按路查询键**,保留期 168h),**不从正文反解、不从内存态猜**。🔴 **缺席语义**:图取不到 ⇒ 字段**整体缺席**(**绝不回空数组**冒充「没调过工具」)。修的是安卓侧「回看这条回复时看不到它当时调了什么」。**⚠️ 历史说明(v0.76 登记时未实现的原因,实测确认非推断)**:`chat_messages.turn_id` 与 `flow_graphs.turn_id` **不同源**(前者 = 编排器生成的随机路标识 `turn-<16 hex>`,后者 = `trace.FlowGraph.ID`,进程内递增的建图序号)⇒ **无法按消息 join 到它那一轮的图**。⚠️ **v0.78 校正(切片 CODE-TC)**:v0.77 已给链路图加**按路的回合标识**,v0.78 又补上**按路查图的读侧入口**(迁移 `0023` 的 `lane_turn_id` + `GraphsByTurnID`)⇒ `toolCalls[]` **已实现**;但🔴 **只有真并行路能回看**(普通单路回合的图当前不带权威 `turnId`),补齐需落在 `internal/runtime/loop/**` 写侧,**不在本版**(见 **§4.4**、§7.2、§7.4-23)。
- **② 未读面(纯增量 · ✅ 已实现)**:`message_persisted` 的 `data` 加可选 `unreadCount`(`omitempty`,**只在 >0 且确实算得出来时**出现;**缺席 = 老行为**),`GET /api/v1/sessions` 的列表项同键(**该键自 D4 起就已在**,本版未改它)。口径 = **绝对未读 + 主人单一已读游标(`chat_sessions.read_at`,迁移 `0010`)⇒ 多端共享**:Web 读了,移动端的红点也清 —— 这是与安卓侧**已对齐**的取舍(他们明确赞成),**不是缺陷**,不许改成「每端独立未读」而不经裁定。形状、缺席语义与三种缺席情形见 **§4.13**;帧表已同步(**§3.4.4**)。
- **③ 记忆整理试算加性异步**:`POST …/memories/tidy/dry-run?async=1` ⇒ `202` + `runId`(新增 B15 轮询端点)。**不带 `async` 仍是同步** ⇒ 老客户端(含现有面板)逐字不变。结果活在**进程内存**(TTL 30 min、并发 1、重启即失效 ⇒ 如实 `404`)。⚠️ **v0.77 落地校正**:本项登记的时点标注「契约先行」,**切片 MT-A 已实现**,实现口径见 **§4.12.1**(并发=**复用**既有 `runId`、与同步**同一个 3 分钟**墙钟、`result` 与同步 `data` **逐字节同形**、新错误码 **`tidy_run_not_found`**)。
- **行为变更**:无(②只在既有帧里加可选键;③只在显式带参时改变路径;①未落地 ⇒ 无变更)。
@@ -1225,11 +1374,11 @@
| 项 | 现状 |
|---|---|
| **🔴 v0.67 登记的「语音消息面」**(`GET /api/v1/media/{name}` + 消息 DTO `voice` + 入站语音音频副本落盘) | **未实现**(**契约已先行登记**):端点 / 字段 / 副本口径见 §1.2 U19、§4.4、§4.5;权威设计 `design/24 §10`。⚠️ 同批的**行为变更**(QQ 语音 `content` 不再含 `[语音]`)与**口径变更**(`/admin/system/storage` 的 `documents` 段含语音副本)也**随该切片一起生效**,现在**尚未生效** |
| **🔴 v0.67 登记的「语音消息面」**(`GET /api/v1/media/{name}` + 消息 DTO `voice` + 入站语音音频副本落盘) | **用户可见面仍未实现**(契约已先行登记;v0.78 只落地「落库面」):端点 / 字段 / 副本口径见 §1.2 U19、§4.4、§4.5。**v0.78(切片 CODE-VOICE)已实现**:迁移 `0020` 的 `chat_messages.voice` 列、`internal/runtime/voice/**`(`Source`/`Status` 闭集 + `Ref` 编解码 + 格式级可播表 + `amr→wav` 归一)、`channelbridge` 入站语音「归一 → **闸门之后**落副本 → 落库后写 `voice` 列」全链。**仍未实现 / 未接线**:`documents.SaveAudio`(音频存储面)、`history` 的 `voice` 列写视图、组合根注入 `Options.VoiceTranscoder` **三件都缺** ⇒ 生产上**副本落不进盘、`voice` 列写不进库**(都走**如实的 WARN**,**不是假成功**);**网关 DTO `voice`(含 `url`)与 `GET /api/v1/media/{name}` 端点仍未实现**。⇒ **客户端一律按「未实现」对待**。权威设计 `design/24 §10`。⚠️ 同批的**行为变更**(QQ 语音 `content` 不再含 `[语音]`)与**口径变更**(`/admin/system/storage` 的 `documents` 段含语音副本)仍**尚未生效**;**已生效**的只有 IM 语音「不可转写格式」正文提示改口径(**形状不变**,见 §4.5) |
| **✅ v0.67 登记的「非消息事件查询面」**(`GET /admin/channels/events`、`GET /admin/channels/events/counters`) | **已实现**(v0.67 登记项本切片落地):形状与错误码见 §2.4(a)(已按代码核对:元素键名 camelCase、`approx` 与 `counters` 同级、`400 bad_request`、`owner\|admin` 档);权威设计 `design/26 §4.5`。未接线存储 ⇒ `500 channel_events_unavailable`。⚠️ 本行留在此表仅为与上一版对照,下版改文档时应移入 §7.1 在飞切片表并回填 §2.1/§2.2 的端点计数 |
| **✅ v0.67 登记的「账号在线状态」**(`GET /admin/channels` 的 `accounts[].accountOnline`) | **已实现**(v0.67 登记项本次落地;落地前基线 `f91a83f`,本切片 commit 由主会话补记):形状见 §2.4(b)。管理面按**可选接口断言**读 `AccountStatus()`(`plugins/channels/qq_obv11/probe.go`,`a79fe96`);适配器**没有**该状态面、或实现了但 `known=false` ⇒ **键缺席**(未知 ≠ 离线,绝不用 `false` 冒充)。当前只有 `qq-obv11` 会带该键。⚠️ 本行留在此表仅为与上一版对照,下版改文档时应移入 §7.1 在飞切片表 |
| **✅ P1-T:`directMsgPerMin` 的 gateway 写入路径**(`GET /admin/channels` 的 `accounts[].directMsgPerMin` + `PUT .../config` 的顶层 `directMsgPerMin`) | **已实现**(v0.69):缺口 = gateway 面没让该字段通过 ⇒ **面板保存一次渠道配置就把主人手配的「关闭流控(-1)」静默重置回 `0` = 保护性默认 6 条/分**。现 5 处透传(`AdminChannelAccount` DTO / `channelConfigRequest` / `config.ChannelAccount` 拷贝 / `adminChannelAccountOf` / 微信扫码重建账号)。形状见 §2.2 A5 与 §2.3 C28 注 |
| **🔴 v0.76 登记的「工具回看面」**(消息 DTO `toolCalls[]`) | **未实现(本版阻塞,客户端别按它编程)**:形状见 §6 v0.76 ①、§4.4。原定数据源是**已落盘的链路图**,但两者的轮次键**不同源** —— `chat_messages.turn_id` 是编排器生成的随机路标识(`turn-` + 16 位十六进制),`flow_graphs.turn_id` 是 `trace.FlowGraph.ID`(发布器**进程内**递增的建图序号)⇒ **无法按消息字面 join 到它那一轮的图**;按时间窗硬凑映射属于「猜」,项目口径**不做**。⚠️ **v0.77 校正**:链路图已带上**每路的回合标识**(`graph.turnId`,纯增量、未用新迁移)且**每路一行落盘** ⇒ 「按消息回看那一轮的工具」的**前提已具备**;但 `toolCalls[]` **仍未实现** —— 还缺一个「按路查图」的读侧入口(把 `graph.turnId` 接到消息面上),且 `graph.id` **仍不等于**消息的 `turn_id`。见 §7.4-23 |
| **✅ v0.78 落地的「工具回看面」**(消息 DTO `toolCalls[]`) | **已实现(覆盖率受限)**(v0.78,切片 CODE-TC):形状、缺席语义与**覆盖率边界**见 **§4.4**;读侧依赖 = 迁移 `0023` 的 `flow_graphs.lane_turn_id`(等值索引)+ `FlowGraphStore.GraphsByTurnID`(一页一次 `IN` 批量查,禁 N+1)。⚠️ **只有真并行路能回看** —— 普通单路回合(Web 单路聊天 / 队首续跑)的图**当前不带权威 `turnId`** ⇒ 查不到图 ⇒ 字段**缺席**(补齐需落在 `internal/runtime/loop/**` 写侧,**不在本版**)。⚠️ **未做真机端到端**(见 §7.4-23 / §7.4-25) |
| **✅ v0.76 登记的「未读面」**(`message_persisted.data.unreadCount`) | **已实现**(本切片):形状、缺席语义与三条缺席路径见 **§4.13**,帧表见 §3.4.4。⚠️ `GET /api/v1/sessions` 的同名键**自 D4 起就已在**(`historySessionSummary.UnreadCount`),本片**未改**它 —— 所以「加同键」这句对列表面其实是**既有事实**,不是本版新增 |
| **`graph_update` 按路分桶** | **✅ 已实现(v0.77,切片 FV2)**:帧 `data` 新增每路的 `turnId` 与 `round`,图按 `{会话}#{路}` 分桶 ⇒ **每一路各发自己的帧、各落一行**(不再互相顶掉);管理面 `GET /admin/flow/graph` 新增 `lanes[]`。形状与缺席语义见 §3.4.5 / §4.11(a)(d),登记见 §7.4-19。⚠️ 真并行**未做真机端到端**(见 §7.4-24) |
| **第二路进行态 system 摘要**(`WithParallelNotice`) | **未实现**:全仓无该符号;`session_parallel.go` 明示留待后续切片 |
@@ -1239,6 +1388,15 @@
| **记忆整理候选的 apply / reject / revert + 候选列表 + 运行历史**(`design/23 §11`) | **未实现**(v0.75 **只**加了「试算」B14):试算只「算给我看」、**不改任何记忆**。`GET /admin/memories/candidates`、`POST .../candidates/{id}/{apply,reject,revert}`、`GET /admin/memories/tidy/runs`、`POST /admin/memories/tidy/run` **均尚未实现**。客户端**不要**假定它们存在(`design/23` 的第一版口径本就是「只产建议,apply 才写记忆」,apply 未做的前置原因见该节开放点) |
| **`GET /api/v1/admin/memories` 的整理相关字段**(`kind` / `tags` / `confidence` / `createdAt` / `updatedAt` / `sourceConversation` / `status` / `mergedInto` / `mergeCount`) | **未实现**:该端点当前仍只回 `{id,scope,text}`(`design/23 §11` 登记的读侧缺口)。v0.75 **未**改它的形状 ⇒ 面板暂时无法在这一页按 kind/status 过滤(试算端点 B14 的候选自带这些分区键与 kind) |
| **🔴 记忆「入库前审查」的面板面**(`GET /api/v1/admin/memories/reviews`、`POST /api/v1/admin/memories/{id}/revert`) | **未实现 —— 目前没有任何 HTTP 入口**(v0.77 登记 · 切片 MT-B):审查结论已逐条落表 `memory_ingest_reviews`(读侧能力 `ListIngestReviews` 已就绪),`Store.RevertMerged` 的回滚能力也在,但 `internal/services/gateway/routes.go` 里**没有**对应路由 ⇒ **客户端不得假定这两个端点存在**(请求只会拿到 404)。同样**未登记**为可用的是 MT-B 报告建议的审计动词 `memory.review.ingest`。⚠️ 本行是「未实现」清单,**不是**可用端点清单 |
| **🔴 v0.78 插件动作的「执行器」**(`POST /admin/plugins/{id}/actions/{action}` 的动作本体) | **无生产实现 —— 所有已声明动作当前都回 `501 action_not_implemented`**(切片 PLG-2):端点是**真的**(鉴权 / 白名单 / 审计 / 错误码都实现),但 `Panels.SetExecutor` **只在测试里被调用**(`grep` 全仓无生产调用点)⇒ **`plugin.listProviders` 拉不到数据、`plugin.saveProvider` / `plugin.testProvider` / `plugin.reauthProvider` 都执行不了**。**客户端不得把「端点存在」当成「数据可用」**:只有拿到 `result` 才算执行。见 §4.14(b) |
| **🔴 v0.78 热装插件的面板声明来源**(`plugins/<id>/manifest.json` 读取) | **未实现**(切片 PLG-2 / PLG-1):目录能键热装插件、生命周期已接,但**没人喂声明** ⇒ 热装插件在 `GET /admin/panels` 的 `pluginIds` / `groups` 里**恒为「未声明管理页面」**。当前唯一有声明的是内置 `web-search`(组合根装配期 `Declare`) |
| **`PUT/POST /api/v1/admin/plugins/{id}/enable\|disable`** | **未实现**(切片 PLG-2):`Manager.SetEnabled` 已实现并有测试,但**没有 HTTP 面** ⇒ 客户端**不得假定**能经 REST 启停插件(`panels_changed` 的 `enable`/`disable` 两种 `reason` 因此当前**不会**在运行期出现)。⚠️ 且启用态**不跨重启持久化**(`PluginConfig` 无 `enabled` 字段,`SetEnabled(false)` 只在进程内生效,重启按「已安装」恢复为启用) |
| **`panels_changed` 帧对 `viewer` 可见** | **未实现(刻意)**:该帧**未**登记进 `frameReachesAll` 白名单 ⇒ 按 fail-closed 默认档**只发 owner/admin**,**viewer 收不到**(须自行刷新/轮询重拉 A13)。放开需先改 `ws_frame_scope.go` + 在 `design/11 §7.1` 登记理由(**不在本版**) |
| **插件动作审计的持久化粒度** | **未含 pluginId / action**(切片 PLG-2):持久化审计记录沿用既有 `AuditStore.Append(ctx, who, what, result, ts)` 四字段 ⇒ 只落 `what="plugin.action"`;插件 id 与动作名只在**结构化日志行**里。要进持久化记录需改 `AuditStore` 签名(**不在本版**) |
| **🔴 v0.78 渠道事件实时面的「其余全部事件」** | **未接线**(切片 E7):覆盖桥接层写入的**全部**渠道事件的 `events.PublishingStore` 装饰器**已实现但组合根未接**(`grep` 全仓无 `NewPublishingStore` 的生产调用点)⇒ 目前**只有账号状态迁移这一路**会产生 `channel_event` 帧;其余渠道事件仍**只能靠 REST 轮询**。接线只需组合根两处(`events.NewPublishingStore(...)` + 把 `wire_channels.go` 的 `EventStore` 换成它),**⚠️ 只装饰这一处**(网关侧 `Deps.EventStore` **不要**也换,否则账号状态行会被推两次) |
| **🔴 v0.78 `GET /admin/channels` 的 `accounts[]` 每账号凭据三态** | **未实现**(切片 CODE-F3):`authState`/`authDetail` **只**落在 `GET /admin/channels/{platform}/login-status`(§2.4(c));**每账号**版本需要给 `AdminChannelAccount` 加两字段 + 一个合并器 + 一次取数(收集器 `liveAccountAuthStates()` 已实现并有单测,**无调用方**)⇒ **客户端不得假定 A5 的 `accounts[]` 会带这两个键** |
> ⚠️ **本表(§7.2)的两条口径**:① 标 **✅** 的行(如 v0.67 的两项、`directMsgPerMin`、`graph_update` 按路分桶、`toolCalls[]`、`unreadCount`)**留在表里仅为与上一版对照**,它们**已经是可用契约面**,见各行指向的章节;② 标 **🔴** 的行**不要当已有**。
### 7.3 诚实边界(既有事实)
@@ -1257,6 +1415,10 @@
- **`416` 不是 JSON 信封(v0.67 登记)**:`GET /api/v1/media/{name}` 的 `Range` 错误由 Go 标准库直写 `text/plain`,**不套** `{"error":{…}}` ⇒ 客户端别拿 `error.code` 解析它(§0.3 / §4.1 / §4.5)。
- **账号级限流字段是「值语义」,不是三态(v0.69 明确)**:`groupMsgPerMin`/`msgPerMin`/`maxMessages`/`directMsgPerMin` 在请求体里**省略即 `0`(未设置)**—— 一次「只改 token」的 PUT 若不带这四个键,会把已设限额(含 `directMsgPerMin:-1`)重置为 `0`。要保住就必须回送(面板已如此做)。这是 v0.68 起既有的顶层字段口径,v0.69 **未改**。
- **后台思考行的实时帧只带记录里的字段(v0.70 边界)**:`urgency` 在实时帧上一定是**精确档位**(记录落库值),`urgencyLowerBound` / `quietHoursBypassed` **不在记录里**、因此实时帧不带 —— 它们由网关读历史时按安静时段窗口推导(见 §4.10(b))。⇒ 实时行的「穿透安静时段」标记要等重开页面/历史拉取才出现,**这不等于**「这一轮没有穿透」。
- **`toolCalls` 只在 `GET /sessions/{id}/messages` 填充(v0.78 边界)**:共享 DTO 上的新键,但**只有该 handler** 会填它;其余出口(`PATCH /messages/{id}` 响应、`GET /admin/channels[/{platform}]/messages`、`message_persisted` / `channel_persisted` / `message_edited`、握手 `history` 回放)**逐字不变**(有单测钉住)。且**覆盖率只到真并行路**(见 §4.4 / §7.2)。
- **`web-search` 的「`.env` 只播种」(v0.78 行为变更)**:可变源文件 `$DATA_DIR/config/plugins/web-search.json` 一旦落盘,`.env` 的 `WEB_SEARCH_*` **只在文件缺失时**起作用 ⇒ **改 `.env` 不再影响运行中的部署**(这是 design/09 §5.7 E 要的「`.env` 只播种、面板是配置面」,但运维视角上是行为变更)。🔴 **不是** env 键新增/删除。
- **`channel_event` 是 best-effort 增量提示、且当前只覆盖一路(v0.78 边界)**:帧可能在 hub 发送缓冲满时**丢掉该连接的那一帧** ⇒ 客户端**必须**保留 REST 轮询兜底(以轮询结果为准重排)。且目前**只有账号状态迁移**会产帧(其余渠道事件的实时装饰器**未接线**,见 §7.2);帧里那行的 `id` **可能整缺**(刚落库、id 尚未回读)。
- **`panels_changed` 只发 owner/admin(v0.78 边界)**:该帧**不在** `frameReachesAll` 白名单 ⇒ **viewer 收不到**「插件面板变了」的通知,须自行刷新/轮询重拉 `GET /admin/panels`。且当前运行期只可能在**装配期**推一次(内置声明 `declare`),`enable`/`disable`/`install`/`uninstall` 四种 `reason` 需要**没有 HTTP 面的** `SetEnabled` / 插件安装端点才会出现(见 §7.2)。
### 7.4 「未确证」清单(客户端请以实测/代码为准)
@@ -1293,10 +1455,20 @@
- **消息侧**:`chat_messages.turn_id` = 编排器发的那一路的**随机路标识**(`turn-` + 16 位十六进制;同路的 user 行与 assistant 行同值)。
- **图侧两个键**:① **`graph.turnId`(v0.77 新增)=该图所属**路**的权威回合标识,与 `turn_begin` / `steer_ack` / 流帧帧壳的 `turnId` **同源同值** ⇒ 客户端**可以**用它把图与消息行**按路配对**;② `graph.id` / `flow_graphs.turn_id` 仍是**图侧轮次键**(形状 `{会话}-{序号}[#{路}]`,**不透明**;序号是发布器**进程内**递增的建图序号、重启即从头开始),也就是 `GET /admin/flow/graph?turnId=` 的参数值。
- **客户端三条硬约束**:① 要路标识就**读 `graph.turnId`**,**不得**解析 `graph.id` 去取它;② **仍不得**拿消息的 `turnId` 当 `?turnId=` 用(两者不同源);③ 也**不得**把图上的序号当成会话里的回合序号(重启后序号会重复)。
- **`toolCalls[]` 的前提已具备、但它本身仍未实现**:v0.77 起每路各落一行、图带 `graph.turnId` ⇒ 「按消息回看那一轮调了什么工具」在**数据形状上**已经可行;但还缺一个**按路查图的读侧入口**(把 `graph.turnId` 接到消息面上),因此 `toolCalls[]` 仍是**未实现**(见 §7.2、§6 v0.76 ①)。⚠️ 别把这句读成「已实现」。
- **`toolCalls[]` 已由 v0.78 实现(覆盖率受限)**:v0.77 起每路各落一行、图带 `graph.turnId`;v0.78 又补上**按路查图的读侧入口**(迁移 `0023` 的 `flow_graphs.lane_turn_id` + `FlowGraphStore.GraphsByTurnID`)⇒ 「按消息回看那一轮调了什么工具」**已可用**。⚠️ **但仍只覆盖真并行路的回复** —— 普通单路回合的图**不带**权威 `turnId` ⇒ 查不到图 ⇒ `toolCalls` 缺席;且 `graph.id` **仍不等于**消息的 `turn_id`(两者不同源,不能用 `graph.id` 当 `chat_messages.turn_id`)。见 §4.4、§7.2。
24. **(v0.77 登记)「按路分桶」尚未做真机端到端**:真并行(上行 `chat.parallel`)的修复是在**单测层**验证的(真 `Loop.Run` × 2 + 网关照同的 ctx 通道;网关侧走真 HTTP 处理器 + 真发布器),**没有**对运行中的后端发过一次真实 `chat.parallel` —— 因为线上产物是旧构建、且换二进制 / 重启有专门流程。因此**未确证**:真 WS 客户端在真并行下看到的两路帧**时序**(形状与分桶已由单测钉死,时序上等价但无实测)。另有一条**已知的窄竞态**(未观测、也未加锁消除):若一路恰好在另一路起跑的瞬间收尾,第二条路可能被算作**新的一轮**(此时两路仍各有各的图与落盘行,只是面板不把它们并排展示)。
25. **(v0.78 登记)`toolCalls[]` 的覆盖率与端到端未确证**:① **覆盖率只到真并行路** —— 普通单路回合(Web 单路聊天 / 队首续跑)的图当前**不带权威 `turnId`**(`loop.flowOwner` 取 `ParallelLegFrom(ctx)`,而 `WithParallelLeg` **只在真并行路挂**),落盘后 `flow_graphs.lane_turn_id` 为空 ⇒ 查不到图 ⇒ 字段缺席。这是**代码级确证**(切片 CODE-TC 有一条**钉住现状**的用例 `TestSingleLegRoundLacksLaneTurnID_现状缺口`,当前通过)。② **「存库里的 `lane_turn_id` 与 `chat_messages.turn_id` 逐字相等」仍是代码级推论**(两处都取 `turnLeg.turnID` / `ParallelLeg`),**没有**对运行中的后端发过一次真并行消息来实测 `GET /sessions/web/messages` 拿到 `toolCalls`。③ **老行**(v0.77 之前的落盘)该列回填后仍为空,**不可能**补齐(那时的图里没有该字段)。④ 生产库 `data/cyrene.db` 现存行的回填命中率**未统计**(未碰生产库)。
26. **(v0.78 登记)插件管理面板接入点的真机面未确证**:`GET /api/v1/admin/panels` 与 `POST /admin/plugins/{id}/actions/{action}` 的**真实运行期响应未在 `:10942` 上验过**(换产物 / 重启有专门流程,本切片禁止)。所有证据来自 `httptest` + 进程内 bus。⇒ **未确证**:真机上 `data.pluginIds == ["web-search"]`、`groups[0].items[0].sections` 的两段声明、`route` 的前缀拼接姿势(相对片段,前端拼 `<prefix>/plugin/<pluginId>/<panelId>`)。另:`panels_changed` 的分组文案「插件」是**宿主文案**(design 未规定文案来源),改口径只改一个函数。⚠️ 且**未换产物前**,旧二进制里**没有**这两条路由(会 404),属预期。
27. **(v0.78 登记)`web-search` 可变源的运行期口径未确证**:组合根「先可变源、缺失回落 `.env` 播种」已有单测钉住(含「凭据不复活」「损坏文件不改写」「停用主 provider ⇒ 启动 fail-fast」等 7 条),并有一条**真机 SearXNG e2e**(`provider=searxng` 真返回结果)证明既有链路未被打断;但**「在面板上新增一家 provider」的端到端未跑**(面板入口是动作端点,而**动作执行器无生产实现**,见 §7.2)。⇒ 未确证:`plugin.saveProvider` 的真实落盘 + 生效链路在真机上走通。
28. **(v0.78 登记)渠道事件实时面未做真机端到端**:`channel_event` 的「采样 → 落行 → 推帧 → 只投 owner/admin」由**真 SQLite + 真 bus + 真 WS 连接**的单测/端到端用例覆盖,但**运行中的 `cyrene.exe` 未重启**(红线:主会话换产物)⇒ **没有真机日志证据**。且当前 QQ 已停连 ⇒ 若真机启动,`qq-obv11` 账号为 `known=false`,首拍会落一行 `账号状态未知(尚未探到)`。另:**心跳通道(`meta_event.heartbeat`/`lifecycle`)驱动的迁移仍未接**(需适配器把状态投影进状态面,属 `plugins/**`),当前**唯一**迁移来源是探活(`qq_obv11/probe.go`)。
29. **(v0.78 登记)语音落库面的端到端未确证**:`internal/runtime/voice/**` 与 `channelbridge` 入站副本链有**真 `transcode.Engine`**(真 AMR 夹具 ⇒ RIFF/WAVE + 时长可确证)的走通用例,但**生产端到端未跑**(缺 §4.5 所述三件接线,且禁止重启后端)。⇒ **「Web 上能点开听 AMR 语音 / `voice` 列有值 / 副本在盘上」这三条**目前**都不成立**(走如实的 WARN)。`amr` / `flac` / `opus` 在部分浏览器内核上**可播放性未逐一实测**(判据是设计登记的 MDN 规范级结论);`weixin-ilink` 语音**无副本、不可回放**是**刻意**的(平台自带转写就不白下载音频)。
---
*本文件由「以代码为真源」维护(当前 `v0.77`;`v0.76` / `v0.75` / `v0.74` / `v0.73` / `v0.72` / `v0.71` / `v0.70` / `v0.69` / `v0.68` 见本文件 §6,`v0.67` 及更早见本文件 §6 与 `Docs/archive/`)。范围:`v0.77`(链路图按路分桶 FV2 + 记忆整理护栏/异步 MT-A + 入库前审查 env 键 MT-B)同步了 `internal/runtime/trace/graph.go`、`internal/runtime/loop/**`(`flow.go` / `flow_branch.go` / `run.go` / `steer.go`)、`internal/runtime/orchestration/**`、`internal/runtime/flowgraph/**`、`internal/runtime/memory/**`(`tidy*.go` / `preinsert*.go`)、`internal/config/**`(`config.go` / `memory_review.go`)、`internal/services/gateway/**`(`admin_flow.go` / `contracts.go` / `admin_config_facts.go` / `admin_memory_tidy*.go` / `routes.go`)、`frontend/web/src/store/**`、`frontend/web/src/admin/modules/flow/**` 与本文件(§2.2 B12/B13/B15 / §3.4.5 / §4.1 / §4.10(a) / §4.11 / §4.12 / §4.12.1 / §5 / §6 / §7.2 / §7.4);`v0.76` 的**未读面(②)**同步了 `internal/services/gateway/**`(`broadcast.go` / `session_read.go`)、`internal/runtime/history/session_read.go` 与本文件(§3.4.4 / §4.13 / §6 / §7.2 / §7.4),并**按代码 + 库中真实数据实测确证**了 `chat_messages.turn_id` 与 `flow_graphs.turn_id` **不同源** —— 据此把 ①`toolCalls[]` 明确标为**未实现**(原「契约先行」口径作废;v0.77 起链路图已带 `graph.turnId`,该前提已具备而 `toolCalls[]` 仍未实现);v0.76 的 ③ 试算加性异步**已由 v0.77(MT-A)落地校正**;`v0.75` 同步了 `internal/services/gateway/**`(`admin_memory_tidy.go` / `routes.go` / `deps.go` / `server.go`)、`internal/runtime/memory/**`(`tidy_dryrun.go` / `tidy_recall.go` / `tidy.go`)、`cmd/cyrene/wire_frontend.go`、`frontend/web/src/**` 与本文件;`v0.74` 同步了 `internal/services/gateway/**`(`proactive.go` / `broadcast.go`)、`internal/services/channelbridge/**`(`proactive.go` / `persist.go` / `proactive_notify.go`)、`cmd/cyrene/wire_frontend.go`、`frontend/web/src/lib/ws/handlers/**` 与本文件;`v0.73` 同步了 `internal/services/gateway/**`、`internal/runtime/loop/flow.go`、`internal/runtime/flowgraph/**`、`internal/db/migrations/0022_flow_graphs.*`、`frontend/web/src/**` 与本文件;`v0.72` 同步了 `internal/services/gateway/**`(`proactive*`)、`internal/services/backthink/**`、`frontend/web/src/**` 与本文件;`v0.71` 同步了 `internal/services/gateway/**`、`internal/runtime/loop/flow.go`、`frontend/web/src/**` 与本文件。*
*本文件由「以代码为真源」维护(当前 `v0.78`;`v0.77` / `v0.76` / `v0.75` / `v0.74` / `v0.73` / `v0.72` / `v0.71` / `v0.70` / `v0.69` / `v0.68` 见本文件 §6,`v0.67` 及更早见本文件 §6 与 `Docs/archive/`)。范围:`v0.78`(六面 —— CODE-TC `toolCalls[]` / PLG-2 插件面板接入点 / PLG-4 搜索 provider 可变源 / E7 账号状态事件与 `channel_event` / CODE-F3 iLink 凭据三态 / CODE-VOICE 语音落库面)同步了 `internal/db/migrations/0023_flow_graphs_lane_turn_id.*`、`internal/db/migrations/0020_chat_message_voice.*`、`internal/runtime/flowgraph/**`、`internal/services/gateway/**`(`history.go` / `history_toolcalls.go` / `contracts.go` / `admin_panels.go` / `admin_channels_weixin.go` / `admin_channel_status.go` / `ws_channel_events.go` / `ws_bridge.go` / `ws_hub.go` / `routes.go`)、`internal/capability/plugin/**`(`panels.go` / `manager.go`)、`internal/capability/paneldecl/**`、`internal/config/runtimeconfig/websearch.go`、`internal/config/websearch.go`、`plugins/tools/io/search/**`(`panel.go` / `paneldata.go` / `probe.go`)、`plugins/channels/weixin_ilink/**`(`session_status.go` / `poll.go` / `send.go`)、`internal/runtime/events/**`、`internal/runtime/voice/**`、`internal/services/channelbridge/**`(`inbound_voice*.go`)、`pkg/plugins/sdk/panel.go`、`cmd/cyrene/**`(`wire_frontend.go` / `wire_io_tools.go`)与本文件(§0.3 / §1.2 U19 / §2.0 / §2.1 A13-A14 / §2.4(c) / §3.2 / §3.4.4 / §3.4.5 / §4.1 / §4.4 / §4.5 / §4.14 / §5 / §6 / §7.2 / §7.3 / §7.4);`v0.77`(链路图按路分桶 FV2 + 记忆整理护栏/异步 MT-A + 入库前审查 env 键 MT-B)同步了 `internal/runtime/trace/graph.go`、`internal/runtime/loop/**`(`flow.go` / `flow_branch.go` / `run.go` / `steer.go`)、`internal/runtime/orchestration/**`、`internal/runtime/flowgraph/**`、`internal/runtime/memory/**`(`tidy*.go` / `preinsert*.go`)、`internal/config/**`(`config.go` / `memory_review.go`)、`internal/services/gateway/**`(`admin_flow.go` / `contracts.go` / `admin_config_facts.go` / `admin_memory_tidy*.go` / `routes.go`)、`frontend/web/src/store/**`、`frontend/web/src/admin/modules/flow/**` 与本文件(§2.2 B12/B13/B15 / §3.4.5 / §4.1 / §4.10(a) / §4.11 / §4.12 / §4.12.1 / §5 / §6 / §7.2 / §7.4);`v0.76` 的**未读面(②)**同步了 `internal/services/gateway/**`(`broadcast.go` / `session_read.go`)、`internal/runtime/history/session_read.go` 与本文件(§3.4.4 / §4.13 / §6 / §7.2 / §7.4),并**按代码 + 库中真实数据实测确证**了 `chat_messages.turn_id` 与 `flow_graphs.turn_id` **不同源** —— 该阻塞**已由 v0.78(CODE-TC 的迁移 `0023` + `GraphsByTurnID`)解除**,`toolCalls[]` 已实现(覆盖率受限);v0.76 的 ③ 试算加性异步**已由 v0.77(MT-A)落地校正**;`v0.75` 同步了 `internal/services/gateway/**`(`admin_memory_tidy.go` / `routes.go` / `deps.go` / `server.go`)、`internal/runtime/memory/**`(`tidy_dryrun.go` / `tidy_recall.go` / `tidy.go`)、`cmd/cyrene/wire_frontend.go`、`frontend/web/src/**` 与本文件;`v0.74` 同步了 `internal/services/gateway/**`(`proactive.go` / `broadcast.go`)、`internal/services/channelbridge/**`(`proactive.go` / `persist.go` / `proactive_notify.go`)、`cmd/cyrene/wire_frontend.go`、`frontend/web/src/lib/ws/handlers/**` 与本文件;`v0.73` 同步了 `internal/services/gateway/**`、`internal/runtime/loop/flow.go`、`internal/runtime/flowgraph/**`、`internal/db/migrations/0022_flow_graphs.*`、`frontend/web/src/**` 与本文件;`v0.72` 同步了 `internal/services/gateway/**`(`proactive*`)、`internal/services/backthink/**`、`frontend/web/src/**` 与本文件;`v0.71` 同步了 `internal/services/gateway/**`、`internal/runtime/loop/flow.go`、`frontend/web/src/**` 与本文件。*