docs(api): 契约升 v0.67(契约先行登记:media 播放端点 + voice DTO + 渠道事件查询面 + accountOnline;三批未实现已如实标注)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

This commit is contained in:
AskaEth committed 2026-10-05 12:53:35 +08:00
1 parent b82870f41e
commit be6e5f5780
1 file changed
+134 -12
+134 -12
View File
@@ -1,11 +1,12 @@
# Cyrene v2 客户端 API 契约
> **契约版本:`v0.66`**(上一版 `v0.65`;该基线已归档、只读:`Docs/archive/api-client.v0.65.md`)。
> **契约版本:`v0.67`**(上一版 `v0.66`,其条目见 §6;`v0.65` 及更早的基线已归档、只读:`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`)**而非代码核对**;已在 §7.2 逐条如实标注。**本文档其余内容仍以代码为真源**。
---
@@ -38,6 +39,7 @@
- 成功:`{"data": <载荷>}`(个别端点直接回二进制,见 §4.5 语音)。
- 失败:`{"error":{"code":"<机器可读码>","message":"<人读文案>"}}`。
- 状态码全集(代码实际使用):`200 201 202 400 401 403 404 409 413 415 429 500 501 503`。
- ⚠️ 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` 文本**(文案会随修改变动)。
### 0.4 三档标注惯例
@@ -56,6 +58,7 @@
|---|---|
| `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 登记,尚未实现**;绝不回全 0 假汇总,见 §2.4(a)) |
| LLM usage 出口 | `GET /admin/llm/usage` → `500 usage_unavailable`(绝不回全 0 假汇总) |
| 活跃 SQLite 连接 | `GET /admin/system/storage` → `500 storage_unavailable` |
| 可观测日志缓冲 | `GET /admin/observability/logs` → `501`(组合根注入前) |
@@ -97,8 +100,9 @@
| 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:<id>}}`。成功广播 `message_deleted`。 |
| U19 | GET | `/api/v1/media/{name}` | **owner/admin(必须 Bearer)** | 200 / 206 / 304 / 401 / 403 / 404 / 416 | **语音副本播放 / 取字节**(**v0.67 登记,尚未实现**,见 §7.2)。`Content-Type: audio/*`;`Content-Disposition: inline; filename="<name>"`;`X-Content-Type-Options: nosniff`;`Accept-Ranges: bytes`;支持 `Range` 与 `HEAD`;名字非法 / 穿越 / 不存在统一 `404 media_not_found`。⚠️ **`416` 不是 JSON 信封**(标准库直写)。详见 §4.5 语音。 |
> **`GET /api/v1/files/{name}` 无鉴权**(能力 URL);其余全部用户端点至少要求已认证。
> **`GET /api/v1/files/{name}` 无鉴权**(能力 URL);**`GET /api/v1/media/{name}` 与 `GET /api/v1/documents/{name}` 必须 Bearer owner/admin**(与图片读侧「128-bit 名即凭据」的取向**刻意相反**);其余全部用户端点至少要求已认证。
---
@@ -130,7 +134,7 @@
| 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):**该账号自己的**适配器是否已连接;**键缺席 = 状态未知**(该账号没有活适配器),**不等于**「未连接」—— 面板据此决定是否给「断开」按钮。**不含任何凭据** |
| A5 | GET | `/api/v1/admin/channels` | 渠道清单。`accounts[]` 各元素**可能**带可选 `connected:bool`(v0.66):**该账号自己的**适配器是否已连接;**键缺席 = 状态未知**(该账号没有活适配器),**不等于**「未连接」—— 面板据此决定是否给「断开」按钮。**v0.67 登记(尚未实现)**:各元素另**可能**带可选 `accountOnline:bool`(**账号在平台上是否在线**,与 `connected` 是**两件事**;**键缺席 = 状态未知 ≠ 离线**)—— 见 §2.4(b)。**不含任何凭据** |
| A6 | GET | `/api/v1/admin/iot` | IoT 设备清单 |
| A7 | GET | `/api/v1/admin/automations` | 自动化与提醒清单 |
| A8 | GET | `/api/v1/admin/rag` | 知识库文档清单(名字/描述 + 切分/嵌入状态);`?query=` 搜索亦只读 |
@@ -214,6 +218,33 @@
> - `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 登记、**尚未实现**的管理面(渠道事件查询 + 账号在线状态)
> ⚠️ **本节全部内容尚未进 `routes.go`**(截至契约 v0.67 冻结):形状取自设计文档(`design/26 §4.5`、`§2.6`),**不是**代码核对所得。**§2.0 的计数(12 + 11 + 40 = 63)不含本节端点**;实现落地后再回填 §2.1/§2.2 并更新计数。客户端**不要**现在就按本节编程到「已可用」——以 §7.2 的状态为准。
#### (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:{counters:[{platform,accountId,tier,category,platformType,count,firstSeenAt,lastSeenAt}]}}`,并带 **`approx:true`** 如实标注「计数为聚合近似值」(计数是**内存聚合 + 周期 flush**,非逐条实时)。⚠️ `approx` 的**嵌套层级**设计未明说,见 §7.4 |
- **RBAC 档位未定**:设计原文写「owner/admin;`viewer` 只读亦可,按既有 RBAC 惯例」—— 两种解释都成立,本契约**不替它二选一**(见 §7.4-12)。落地前**不要**假设 viewer 能读。
- **数据源两张表**(`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` 行的投影。设计只给到**落库列**:`id` / `platform` / `account_id` / `tier` / `category` / `platform_type` / `channel_id` / `channel_type` / `group_id` / `actor_id` / `actor_role`(`owner`/`member`/`system`/`unknown`,结构化解析所得)/ `target_id` / `self_related` / `message_id` / `summary` / `created_at` / `updated_at`。`actor_id`/`target_id` 是 **id 不是昵称**;`summary` 是**固定文案**(如「群成员加入」,**不含任何平台原文**)。⚠️ **元素的 JSON 键名设计未逐字给出**,见 §7.4-9 —— 客户端**现在不要硬编码**。
- **未接线存储时**:两个端点都回 **`500 channel_events_unavailable`**(对齐 `usage_unavailable` / `storage_unavailable` 的诚实边界口径,**绝不回全 0 假汇总**)。
- 三个新 env 键(`design/26 §4.4`,已登记进 §5):`CHANNEL_EVENT_RETENTION`(默认 `720h`)、`CHANNEL_EVENT_GC_INTERVAL`(默认 `1h`)、`CHANNEL_EVENT_COUNTER_FLUSH`(默认 `30s`)。
- ⚠️ **微信渠道不提供非消息事件**:查询面 / 面板对该渠道**如实显示空 + 说明文字**,绝不伪造、绝不用别的渠道事件顶替。
#### (b) `GET /api/v1/admin/channels` 的账号项:`accountOnline`
- `accounts[]` 元素**可能**带可选 `accountOnline:bool`(**v0.67 登记、尚未实现**):该账号**在平台侧是否在线**。
- **与 `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` 的日志。
- ⚠️ **字段名(`accountOnline`)是本契约所拟**:设计只规定「独立状态面 + `AccountStatus()` + `known`」,**未给管理面字段名**(见 §7.4-13)。实现若改名,须回改本行与 §6 的 v0.67 条目。
---
## 3. WebSocket `/ws/chat`
@@ -235,7 +266,7 @@
{"type":"history","sessionId":"web","data":{"messages":[ <historyMessage> ... ]}}
```
- 内容 = 主会话最近 **50** 条(`wsReplayLimit`),`thinking` 恒缺席(见 §3.4.4 口径更正);每条**可能**带可选 `turnId`(v0.66,空则键省略)。
- 内容 = 主会话最近 **50** 条(`wsReplayLimit`),`thinking` 恒缺席(见 §3.4.4 口径更正);每条**可能**带可选 `turnId`(v0.66,空则键省略)与可选 `voice`(**v0.67 登记、尚未实现**;形状见 §4.4)。
- **viewer 连接完成握手时完全不做回放**(其只读窗口是 REST 监控面)。
- 回放是尽力而为:存储未接或读出错静默跳过。
@@ -329,6 +360,7 @@
> **思维链的唯一出口是实时帧**:§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 登记,尚未实现)**:消息 DTO 新增的可选 `voice` 对象一次覆盖 `message_persisted` / `channel_persisted` / `message_edited` 与握手 `history` 回放(形状与语义见 §4.4)。**WS 协议本身零变化**:无新帧、无新上行字段;`voice` 只是既有 DTO 的新可选键。
#### 3.4.5 子代理 / 任务 / 系统帧(owner+admin)
@@ -376,16 +408,18 @@
| 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` | 资源缺失;消息列表的会话不存在;文档缺失(含名字非法/穿越);渠道账号 id 不存在(C19/C20) |
| 404 | `not_found`、`session_not_found`、`document_not_found`、`account_not_found`、`media_not_found`(**v0.67 登记、尚未实现**) | 资源缺失;消息列表的会话不存在;文档缺失(含名字非法/穿越);渠道账号 id 不存在(C19/C20);**语音副本缺失**(含名字非法/穿越,三者同一响应,见 §4.5) |
| 409 | `chat_busy`、`account_not_registered` | 待处理队列满;渠道账号已配置但没有已注册适配器(C19/C20,v0.66) |
| 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`、`refresh_unavailable`、`sessions_failed`、`messages_failed`、`session_failed`、`message_failed`、`document_upload_failed`、`login_failed` | 内部错误或依赖未接线(**绝不伪装成功**) |
| 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 登记、尚未实现**)、`refresh_unavailable`、`sessions_failed`、`messages_failed`、`session_failed`、`message_failed`、`document_upload_failed`、`login_failed` | 内部错误或依赖未接线(**绝不伪装成功**) |
| 501 | `not_implemented` | HistoryStore 等未注入 |
| 503 | `timeout`、`chat_dispatch_failed`、`catalog_unavailable` | 派发/上游不可用 |
> **未确证**:`conflict`(409) 的通用码存在(框架级 `ErrConflict`),但除了 `chat_busy` 之外,本文档未逐一确证还有哪些端点会带它。
> **⚠️ `416` 不是错误信封(v0.67 登记,尚未实现)**:`GET /api/v1/media/{name}` 的 `Range` 语法非法或范围不可满足时,**由 Go 标准库直写** `416`(`text/plain` + `Content-Range: bytes */<size>`),**不套** `{"error":{…}}` ⇒ 客户端**不要**拿 `error.code` 去解析它(见 §4.5)。这是字节流端点的既有事实(`/audio/speech` 的 `200`、`/documents/{name}` 的 `200` 同样不是 JSON 信封),**不是**本版新引入的怪癖。
> **`400` 的渠道事件用法(v0.67 登记,尚未实现)**:`GET /admin/channels/events` 对 `platform` 未知、`since`/`until` 跨度 > 90 天都回 `400`(**具体 `code` 名设计未给出**,见 §7.4-11)。
### 4.2 分页与游标
@@ -432,11 +466,20 @@
"personaId": "...", // 空 = 全局默认角色
"clientMsgId": "...", // 空则省略
"turnId": "turn-…", // v0.66;空则省略(老消息/未接管路径)
"voice": { // v0.67 登记(尚未实现);非语音消息键缺席;🎤 判据 = 本键存在
"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` 均为 `omitempty`(空时键缺席)。
字段省略规则:`mode`/`mood`/`image`/`thinking`/`platform`/`deviceId`/`senderName`/`adapterId`/`personaId`/`clientMsgId`/`turnId`/`voice` 均为 `omitempty`(空时键缺席;`voice` 是**指针**,非语音消息整键缺席,**不是** `{}` 也不是 `null`)。
**`turnId` 语义(v0.66,可选)**:
@@ -445,6 +488,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 登记,尚未实现)**:
- 形状:`{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/<name>`(唯一派生字段,由服务端一处拼出)⇒ **客户端绝不自己拼路径**。
- 落库形状 = DTO 形状**减去** `url`(列 `chat_messages.voice TEXT NOT NULL DEFAULT ''`,JSON **单对象**;**用空串不用 NULL**,与 `turn_id` 同一口径)。音频原件**复用文档那一套存储 / 元数据表 / 引用计数 / GC**(同一 `<DataDir>/uploads/documents` 目录),但**不进文档的抽取 / 注入链路**:不被抽取、不进 `documents` DTO 数组、不占注入预算、不出现在附件清单行里。
- **一条消息只落一份音频副本**(= 被转写的那一条语音);同一条消息里的第 2..N 条语音既不转写也不落盘,DTO 里也没有它们(**刻意的边界**,不是遗漏)。
- ⚠️ **微信(`weixin-ilink`)语音没有音频副本、不可回放**:按既有裁决「平台自带转写就不白下载那份音频」⇒ `source:"platform"` 且**无 `name`/`mime`/`size`/`url`**,只保留 🎤 标记。
**`documents` 数组语义(核心)**:
- 形状:`{name,mime,size,chars,truncated,status,reason?}`,**≤5 条/回合**,顺序 = 客户端发送顺序。
@@ -497,6 +553,34 @@
- WS 语音回合(`voice_input`):**与文本回合走同一条路**(进编排器 → 落库 → 流式 → 跨端同步 → 忙碌/排队语义一致)。转写文本落库为用户消息、助手回复落库;`deviceId`/`clientMsgId` 与 `chat` 帧同形。
- `voice_output` 在**该回合结算时**才定向回发起连接(可能晚于若干状态帧,甚至由续跑回合或被 steer 注入的那一回合产生)。空正文 / TTS 失败/降级都走 `provider` 降级信号(`silent` 等),**不把 `stopReason` 当语音内容**;连接断开不补投,客户端带回同一 `clientMsgId` 可安全重试。
**入站语音的音频副本与播放端点(v0.67 登记,尚未实现)**:
- **音频副本何时产生**:渠道适配器入站拿到语音字节后,转写在**流水线最前端**归一(见上),而**落盘在闸门之后** —— 被会话解析 / 黑白名单 / 最小打扰拦下的语音**盘上不留任何文件**(与 IM 附件同一纪律)。落盘 = 内容寻址(`sha256(bytes)[:32] + ext`,同字节同扩展名天然去重),产出该消息的 `voice` 引用(DTO 见 §4.4)。
- **播放端点 `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="<name>"` —— 与文档读侧的 `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 */<size>`,**不套** `{"error":{…}}`。唯一例外:**空文件** + 范围不可满足 ⇒ 忽略 `Range` 回 `200`(标准库的特别处理) |
| 日志 | 404 **不记**错误日志(可预期的输入问题);不记任何字节内容 |
- **三条读侧路的边界(别混)**:
| 端点 | 鉴权 | Disposition | 名字形态 | 语义 |
|---|---|---|---|---|
| `GET /api/v1/files/{name}` | **无**(能力 URL) | `inline`(按图 MIME) | 128-bit 随机名 + 图片扩展名 | 图片,给 `<img>` / provider 直接吃 |
| `GET /api/v1/documents/{name}` | **Bearer owner/admin** | `attachment` | 32 hex + **文档白名单**(23 项) | 文档,**永不内联渲染** |
| `GET /api/v1/media/{name}`(v0.67 登记) | **Bearer owner/admin** | `inline; filename=…` | 32 hex + **音频白名单**(10 项) | 语音副本,供播放器消费 |
- ⚠️ **音频 URL 不能直接贴进 `<audio src>`**:读侧要 Bearer(与图片读侧「128-bit 名即凭据」**刻意相反** —— 语音是私密内容,不是「谁拿到 URL 谁就能听」)⇒ 客户端须**带 token 取字节**(Web:`fetch` → `blob:` → 播放;原生:认证 HTTP 客户端)。这是**有意**的设计,不是缺陷。
- ⚠️ **可播放性未逐一实测**:`amr` / `flac` / `opus` 在部分浏览器内核上播不了 ⇒ 客户端**以实测为准**,播不了就照实提示(不假装;**绝不给一个播不了的 `url`**)。「能播」必须是**格式级**结论,不得靠「扩展名看起来像音频」就宣称可播。
- ⚠️ **没有「删除一条语音副本」的 API**:保留期 GC 是唯一出口(`DOCUMENT_RETENTION`,默认 168h)。
### 4.6 `chat` 请求与响应面
**请求体**(`POST /api/v1/chat` 与 WS `chat` 帧 `data` **同形**):
@@ -620,6 +704,9 @@
| `SUBAGENT_TIMEOUT_SEC` | `600` | 单个子代理整次运行时限 |
| `MCP_SERVERS_JSON` | 空 | MCP server 注册表(JSON 数组)。⚠️ 扁平 `MCP_NAME`/`MCP_COMMAND`/… 为**未接线残留键,配了不生效** |
| `CHANNEL_*` / `CHANNELS_JSON` | 见模板 | 渠道接入(`.env` 为兜底,面板 `channels.json` 优先) |
| `CHANNEL_EVENT_RETENTION` | `720h` | **v0.67 登记(尚未实现)**:`channel_events` 保留期 —— 比文档的 `168h` 长,因为事件表的核心用途是**事后定案**(一周太短);`≤0` = 关闭 GC |
| `CHANNEL_EVENT_GC_INTERVAL` | `1h` | **v0.67 登记(尚未实现)**:渠道事件 GC 扫描间隔(与 `DOCUMENT_GC_INTERVAL` 同范式) |
| `CHANNEL_EVENT_COUNTER_FLUSH` | `30s` | **v0.67 登记(尚未实现)**:计数内存聚合的 flush 间隔。计数是**尽力而为的近似值**(进程崩丢 ≤30s),查询面以 `approx:true` 如实标注(见 §2.4(a)) |
**与契约相关但「配了不生效」的键**:
@@ -634,7 +721,28 @@
> 只保留最近若干版(**v0.60 起**);更早的全部历史见归档 `Docs/archive/api-client.v0.65.md` §5(v0.28 → v0.59)。
> 每版一句话 + 三档标注。**三档定义见 [§0.4](#04-三档标注惯例)。**
### v0.66(当前契约版本)
### v0.67(当前契约版本)
**三批已在设计文档定稿、但尚未实现的新增面一次性登记**:语音消息(播放端点 + 消息 DTO `voice` + 音频副本口径)、非消息事件查询面、账号在线状态(依据 `design/24 §10.11`、`design/26 §4.5` / `§2.6`)。
> ⚠️ **本版是「契约先行」登记**:三批**都还没实现**(逐条见 §7.2),形状**取自设计文档而非代码核对**。客户端**先按形状对齐、不要按「已可用」编程**;实现落地后本版会补记 commit 并把 §7.2 相应行改为上线。
- **纯增量**:
① 新端点 `GET /api/v1/media/{name}`(用户侧,**owner/admin 必须 Bearer**):`200` 字节流 / `206 Partial Content` / `304 Not Modified`;`Content-Type: audio/*`;`Content-Disposition: inline; filename="<name>"`;`nosniff`;`Accept-Ranges: bytes`;`Cache-Control: private, max-age=86400, immutable`;支持 `Range` 与 `HEAD`;`404 media_not_found`;`401`/`403` 由中间件给出。⚠️ **`416` 是标准库直写的非 JSON 响应**(见 §0.3、§4.1、§4.5)。
② 消息 DTO 增**可选** `voice` 对象(`omitempty`;**非语音消息键缺席**):`{source,status,name?,mime?,size?,durationMs?,url?}`;`source` ∈ `asr`/`platform`,`status` ∈ `transcribed`/`empty`/`failed`,`name`/`mime`/`size`/`url` **四者同生共死**(有副本时才在);`url` 是服务端相对路径 `/api/v1/media/<name>`。**🎤 判据 = `voice` 键存在**。一次覆盖全部读取面:`GET /sessions/{id}/messages`、`PATCH /messages/{id}` 响应、`GET /admin/channels[/{platform}]/messages`、`message_persisted` / `channel_persisted` / `message_edited`、握手 `history` 回放(§3.2、§3.4.4、§4.4)。
③ 新错误码 **`media_not_found`(404)** → 加进 §4.1 的 404 行(与 `document_not_found` 并列;**刻意不复用**它,否则客户端分不清缺的是文档还是语音)。
④ 新错误形态 **`416`**(**非信封**,标准库直写)→ §0.3 / §4.1 / §4.5。
⑤ 新 admin 端点 **`GET /api/v1/admin/channels/events`**(非消息事件列表,倒序;`?platform=&tier=&category=&channelId=&since=&until=&limit=&offset=`;响应 `{data:{events:[…],total}}`;`since`/`until` 按 UTC ISO-8601 定宽字符串**字典序**比较、**跨度上限 90 天**;不含正文、不含媒体 URL、不含任何凭据 / `flag`)—— §2.4(a)。
⑥ 新 admin 端点 **`GET /api/v1/admin/channels/events/counters`**(逐类计数汇总 `{data:{counters:[{platform,accountId,tier,category,platformType,count,firstSeenAt,lastSeenAt}]}}`,并带 **`approx:true`** 如实标注「计数为聚合近似值」)—— §2.4(a);未接线存储 ⇒ **`500 channel_events_unavailable`**(新增错误码,**绝不回全 0 假汇总**)。
⑦ `GET /api/v1/admin/channels` 的 `accounts[]` 增**可选** `accountOnline`(**账号在平台侧是否在线**,与 `connected`(适配器/WS 是否连着)**是两件事**;**键缺席 = 状态未知 ≠ 离线**)—— §2.4(b)。
⑧ 新 env 键 3 个:`CHANNEL_EVENT_RETENTION`(`720h`)/ `CHANNEL_EVENT_GC_INTERVAL`(`1h`)/ `CHANNEL_EVENT_COUNTER_FLUSH`(`30s`)—— 已在 §5 登记(本切片**不新增**语音相关 env 键;语音上限/保留期复用既有常量与 `DOCUMENT_*`)。
⑨ **WS 协议零变化**:`media` 是 REST 端点;无新帧、无新上行字段。
- **行为变更**:
⑩ **QQ 语音消息的 `content` 不再含 `[语音]` 占位标记**(旧行为 → 移除):今天从正文里认 `[语音]` 的客户端**必须**改为看结构化字段(**`voice` 键存在 = 语音消息**)。⚠️ **这是本版唯一会打到老客户端的地方**(见 §7.2)。
⑪ `GET /api/v1/admin/system/storage` 的 `documents` 段(件数 / 字节)**自此包含语音副本**(音频副本与文档**同表**:`SELECT COUNT(*), SUM(size) FROM documents`)—— **形状不变**,变的只是**口径**(面板文案宜标注「含语音副本」)。
- **破坏性**:无(新端点、新可选字段、新错误码都是增量;`documents` / `/upload` / `/files` / `/audio/*` 的既有端点**一字未改**;WS 零变化)。
### v0.66(上一版)
**并行帧层上线 + 消息回合标识落库 + 渠道按账号操作;并修正 v0.65 的两处过期/写错的陈述**(依据 design/15 §2.5、design/08 §4;提交 `c0dc435` / `d64b26d` + `d804c4e` / `42279b7` / `a918819` / `679cee0`)。
@@ -658,7 +766,7 @@
- **v0.65 §7 的「未实现」清单**:`turn_begin` / `steer_ack.turnId` / `interrupt.turnId` / `chat_messages.turn_id` / 写类工具文件级互斥 —— **现已全部实现并上线**(`c0dc435`、`d64b26d`+`d804c4e`、`354b08a`),§7 已相应改写。
- ⚠️ **v0.66 补充说明(描述性更正,不动形状/阈值,不升版本号)**:**`chat.image` 的体积判据是「字符串长度」而不是「解码后字节」** —— 旧措辞「内联上限 = 解码后 5 MiB」只对 **base64 / `data:` 形态**成立,且它本身是**换算口径**(`len(image) <= 6990572` 字符,见 §4.5)。**URL 形态不触发 `image_too_large`**(URL 串约 60 字符,必然通过):其天花板在视觉取回层的 **50 MiB**(`io.LimitReader`,**超出静默截断**、不报错);**`POST /api/v1/upload` 自身是 5 MiB 字节硬限**(`413 payload_too_large`)。四处已按实现写清(§4.5 图片、§4.6 响应面、§5 环境变量表、U8 行 + 错误码表)。**无端点 / 帧 / 字段形状变更,无默认值变更**。
### v0.65(上一版)
### v0.65
**忙时并行回合基础管线(I1′ 有条件放宽)+ 六条登记**(依据 design/15 §2.5,主人 2026-10-05 裁决 B)。
@@ -716,7 +824,7 @@
### 7.1 在飞切片
**当前没有已登记的在飞契约项**:本版之前登记的在飞 / 未实现项(`turn_begin`、`steer_ack.turnId`、`interrupt.turnId`、消息 DTO `turnId`、写类工具文件级互斥)**已全部提交并随 v0.66 冻结**,客户端可直接依赖:
**v0.66 及更早登记的在飞项已全部冻结、客户端可直接依赖**(下表)。⚠️ **v0.67 新登记的三批(语音消息 / 非消息事件查询面 / 账号在线状态)尚未实现**,**不在本表** —— 逐条见 §7.2。
| 项 | 状态 | 冻结位置 |
|---|---|---|
@@ -733,6 +841,9 @@
| 项 | 现状 |
|---|---|
| **🔴 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 /admin/channels/events`、`GET /admin/channels/events/counters`) | **未实现**(**契约已先行登记**):形状与错误码见 §2.4(a);权威设计 `design/26 §4.5`。未接线存储 ⇒ `500 channel_events_unavailable`;⚠️ 两个端点的 **RBAC 档位设计未定**(见 §7.4-12) |
| **🔴 v0.67 登记的「账号在线状态」**(`GET /admin/channels` 的 `accounts[].accountOnline`) | **未实现**(**契约已先行登记**):形状见 §2.4(b)。**状态源已落地**(`AccountStatus()`,`plugins/channels/qq_obv11/probe.go`,`a79fe96`),但**管理面还没有任何字段读它**(全仓 grep 无 `accountOnline`)⇒ 该键**当前一定会缺席** |
| **`graph_update` 按路分桶** | **未实现**:`graph_update` 只带 `data.sessionId`(会话级),**无 `turnId`** |
| **第二路进行态 system 摘要**(`WithParallelNotice`) | **未实现**:全仓无该符号;`session_parallel.go` 明示留待后续切片 |
| **env `CHAT_MAX_PARALLEL_TURNS`** | **未接线**:`Deps.MaxParallelTurns` 是构造期旋钮,但组合根未从任何 env 读它 ⇒ **生产恒为默认 2** |
@@ -749,6 +860,9 @@
- 可观测三帧 `observability_*`:`?level=` 分级过滤**由客户端本地做**(帧广播给所有 owner/admin 连接,订阅只决定循环跑不跑)。审计 tab 仍 5s REST 轮询(**未做** `observability_audit` 帧)。
- **`thinking` 恒缺席(v0.66 口径更正)**:消息 DTO 的 `thinking` 在**所有**读取面都不出现(`history.Message.Thinking` 全仓无写入者)。思维链只有实时 `thinking.*` 帧(owner/admin)。**旧文档称「历史思维链可由 `GET /sessions/{id}/messages` 返回」是错的**,见 §3.4.4。
- **消息 DTO 的 `turnId` 只在 Web/网关回合存在**:IM 渠道桥**不经过**网关编排器 ⇒ 其落库行的 `turn_id` 为空、DTO 里 `turnId` 键缺席(预期行为)。`turn_begin` / `steer_ack` / `interrupt.turnId` 同理只存在于网关回合。
- **`GET /api/v1/media/{name}` 读侧要 Bearer owner/admin(v0.67 登记)**:音频 URL **不能**直接贴进 `<audio src>`(浏览器不带 token)⇒ 客户端须带 token 取字节(Web:`fetch` → `blob:`)。这是**有意**的(语音是私密内容,与图片读侧「128-bit 名即凭据」的取向刻意相反)。
- **微信(`weixin-ilink`)入站语音没有音频副本、不可回放(v0.67 登记)**:按既有裁决「平台自带转写就不白下载那份音频」⇒ `voice.source:"platform"` 且**无 `name`/`url`**,只保留 🎤 标记。
- **`416` 不是 JSON 信封(v0.67 登记)**:`GET /api/v1/media/{name}` 的 `Range` 错误由 Go 标准库直写 `text/plain`,**不套** `{"error":{…}}` ⇒ 客户端别拿 `error.code` 解析它(§0.3 / §4.1 / §4.5)。
### 7.4 「未确证」清单(客户端请以实测/代码为准)
@@ -759,8 +873,16 @@
5. **`subagent_event` 在 `subagent_ask` 应答路径上的精确字段**(`orchestration.AuditResult` 形状):未展开。
6. **`account_not_registered`(409) 的适用面**:本文档只确证了 C19/C20(open/close)会带它;`POST /admin/channels/test`、`/{platform}/message/test` 等其它渠道端点是否也有等价的账号级错误码 —— **未逐一确证**。
7. **`turn_begin.data.leg` 在长会话中的取值范围**:代码里是会话内单调递增序号(非「1/2 路」固定枚举),但本文档**未确证**客户端应如何展示 >2 的取值 —— 建议只当诊断信息用。
8. **`GET /admin/channels` 的 `accounts[]` 除 `connected` 外的字段集**:本文档未逐字段展开(该结构在 v0.66 只是**新增**一个可选键)。
8. **`GET /admin/channels` 的 `accounts[]` 除 `connected` 外的字段集**:本文档未逐字段展开(该结构在 v0.66 只是**新增**一个可选键;v0.67 另登记了一个可选 `accountOnline`,见 §2.4(b))。
9. **(v0.67 登记)`GET /admin/channels/events` 的 `events[]` 元素 JSON 键名**:设计只给到 `channel_events` 的**落库列**(snake_case 列名)与 `{events,total}` 外框,**未逐字给出**元素的 JSON 键名(camelCase 还是 snake_case 未定)⇒ 客户端**现在不要硬编码**;`counters[]` 的元素名设计已逐字给出且为 camelCase(大概率同风格,但**未确证**)。
10. **(v0.67 登记)`events.total` 的语义**:是**筛选条件下的总条数**(不受 `limit`/`offset` 影响)还是**本次返回条数** —— 设计未说明。
11. **(v0.67 登记)渠道事件查询的参数默认值与错误码名**:`limit`/`offset` 的默认值、以及 `platform` 未知 / 跨度超 90 天时的**具体 `400` `code` 名**(设计只写「→ `400`」)—— 均未给出。
12. **(v0.67 登记)两个渠道事件端点的 RBAC 档位**:设计原文「owner/admin;`viewer` 只读亦可,按既有 RBAC 惯例」**自相含混**(按既有分档,渠道消息日志面 B2/B3 是 owner/admin 档)⇒ 落地前**不要**假设 viewer 能读。
13. **(v0.67 登记)`accounts[].accountOnline` 的字段名与是否另有 `known` 同级键**:字段名 `accountOnline` 是**本契约所拟**(设计只规定「独立状态面 / `AccountStatus()` / `known` 三态」,未给管理面字段名);哪些平台会带该键、以及实现是否另发一个 `known` 布尔 —— 未确证。
14. **(v0.67 登记)音频各容器在各客户端的可播放性**:`amr` / `flac` / `opus` 在部分浏览器内核上播不了(design/24 §10.6 明确「未逐一实测」)⇒ 客户端**以实测为准**,播不了就照实提示。
15. **(v0.67 登记)`GET /admin/system/storage` 的 `documents` 段是否拆出语音副本**:设计**本期不拆**(不新增 `kind` 列 / 不按 ext 聚合)⇒ 该段**只增口径、不变形状**,客户端**不要**指望能分开统计。
16. **(v0.67 登记 · 前向提示)`voice.url` 指向的可能不是原件,而是转码后的副本**:`design/24 §10.14` 已把「源格式不可播 ⇒ 转码成可播副本(唯一必需的路 = `amr → wav`)」立为口径,转码能力规格见 `design/25-转码能力.md`(**该能力尚未接线**)⇒ 客户端**不要假定 `url` 一定是某一种音频格式**,一律**按响应的 `Content-Type` / `mime` 如实处理**,播不了就照实提示(**绝不给一个播不了的 `url` 假装能播**)。⚠️ 本条**不在 `design/24 §10.11` 的差异清单内**(§10.11 与 §10.13 均无「零转码」绑定表述),登记它是因为「`url` 的字节格式」会直接影响播放端;**最终口径以 `design/25` 为准**。
---
*本文件由「以代码为真源」维护(当前 `v0.66`;`v0.65` 及更早见 `Docs/archive/`)。范围:只改 `Docs/**`,未改动任何代码。*
*本文件由「以代码为真源」维护(当前 `v0.67`;`v0.66` 见本文件 §6,`v0.65` 及更早见 `Docs/archive/`)。范围:只改 `Docs/**`,未改动任何代码。*