docs(api): 契约升 v0.66(帧层 + turn_id + 渠道配置 + 修正未实现清单 + 思考流口径更正)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 登记 turn_begin / steer_ack 帧壳 turnId / interrupt.turnId(仅 scope=current)/ 帧序按实现
  (session_busy → message_persisted(user) → turn_begin)/ activeTurns / cancelledTurns /
  pending_ack.reason=parallel_limit / session_idle 并行时收紧
- chat_messages.turn_id:历史 DTO、PATCH 响应、admin 渠道日志、message_persisted /
  channel_persisted / message_edited、握手回放均增可选 turnId(空则键省略)
- 渠道配置:open|close 可选 accountId + resolved/accountId;DELETE config 显式 empty=1 + removed/empty;
  GET /admin/channels 的 accounts[].connected
- 语音:VOICE_STT_TIMEOUT_SEC;IM 入站附件引用落库(行为变更)
- 修正 v0.65 的「未实现」清单(那 5 项都已实现并上线)
- 思考流口径更正:历史 thinking 恒缺席(全仓无写入者),思维链唯一出口是实时 thinking.* 帧
This commit is contained in:
AskaEth committed 2026-10-05 11:15:25 +08:00
1 parent eaada3af5d
commit 0cbc9ff886
1 file changed
+93 -36
+93 -36
View File
@@ -1,6 +1,6 @@
# Cyrene v2 客户端 API 契约
> **契约版本:`v0.65`**(与 `Docs/archive/api-client.v0.65.md` 同一基线)。
> **契约版本:`v0.66`**(上一版 `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,后端全量字段级文档)。归档的原因是**结构性臃肿 + 历史条目并列 + 长期漂移**;本文档**不复制**那段历史,需要考古时去归档。
@@ -130,7 +130,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` | 渠道清单 |
| A5 | GET | `/api/v1/admin/channels` | 渠道清单。`accounts[]` 各元素**可能**带可选 `connected:bool`(v0.66):**该账号自己的**适配器是否已连接;**键缺席 = 状态未知**(该账号没有活适配器),**不等于**「未连接」—— 面板据此决定是否给「断开」按钮。**不含任何凭据** |
| A6 | GET | `/api/v1/admin/iot` | IoT 设备清单 |
| A7 | GET | `/api/v1/admin/automations` | 自动化与提醒清单 |
| A8 | GET | `/api/v1/admin/rag` | 知识库文档清单(名字/描述 + 切分/嵌入状态);`?query=` 搜索亦只读 |
@@ -179,8 +179,8 @@
| 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` | 打开渠道 |
| C20 | POST | `/api/v1/admin/channels/close` | 关闭渠道 |
| 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` | 查扫码登录状态 |
@@ -189,7 +189,7 @@
| 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` | 删除渠道账号 |
| 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` | 写记忆 |
@@ -204,6 +204,15 @@
> **C28 的 patch 语义(v0.54 起)**:`config` 内**键省略 = 保留已存值**;给出非空值 = 覆盖;**显式 `null`/`""` = 清除该键**(`0`/`false` 是真实取值,不算空)。顶层便捷字段(`token`/`baseURL` 等)只在非空时并入补丁,**要清 `bot_token` 必须走 `config` 的显式 `null`/`""`**。
> **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 删除的收尾口径一致)。
---
@@ -226,7 +235,7 @@
{"type":"history","sessionId":"web","data":{"messages":[ <historyMessage> ... ]}}
```
- 内容 = 主会话最近 **50** 条(`wsReplayLimit`),**已剥离 `thinking`**(恒为空串)。
- 内容 = 主会话最近 **50** 条(`wsReplayLimit`),`thinking` 恒缺席(见 §3.4.4 口径更正);每条**可能**带可选 `turnId`(v0.66,空则键省略)。
- **viewer 连接完成握手时完全不做回放**(其只读窗口是 REST 监控面)。
- 回放是尽力而为:存储未接或读出错静默跳过。
@@ -299,20 +308,27 @@
| type | 帧壳 | data | 说明 |
|---|---|---|---|
| `turn_begin` | sessionId + **turnId** | `{kind,messageIds,leg,activeTurns}` | 每起一路(含续跑路)恰好发一帧。`kind` ∈ `"turn"`(新回合/续跑)/`"parallel"`(显式并行且照准,**仅供展示**);`messageIds` = 该路已落库用户消息的权威 id 数组(可为空数组,**恒为数组不为 null**);`leg` = 会话内路序号(1 基);`activeTurns` 同忙态帧。客户端据此把气泡置「已受理·回答中」并按 `sessionId + turnId` **两级分桶**。⚠️ **在飞、未提交**见 [§7](#7-在飞切片与未实现未确证) |
| `steer_ack` | sessionId + **turnId** | `{messageId,atStep}` | 忙时新消息被在飞回合吃进同一回合:`messageId` 是权威 id,`atStep` 是模型步序号(1 基)。语义 = 「已读、未答」⇒ 客户端把该气泡渲染灰态,直到该路 `message.*`/`turn.end` 到达(**不需要第二个确认帧**)。⚠️ 在飞、未提交 |
| `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:<historyMessage>}` | Web 主会话及 `web:*` 子会话的消息落库实时同步。**`thinking` 恒为空串** |
| `channel_persisted` | sessionId | `{message:<historyMessage>}` | 隔离渠道会话(`sess-*`)的消息落库,供 IM 日志页。**`thinking` 恒为空** |
| `message_edited` | sessionId | `<historyMessage>`(**data 直接就是 DTO**) | 编辑单条消息。**`thinking` 恒为空** |
| `message_persisted` | sessionId | `{message:<historyMessage>}` | Web 主会话及 `web:*` 子会话的消息落库实时同步。**`thinking` 恒缺席**;**可能**带可选 `turnId`(v0.66,空则键省略) |
| `channel_persisted` | sessionId | `{message:<historyMessage>}` | 隔离渠道会话(`sess-*`)的消息落库,供 IM 日志页。**`thinking` 恒缺席**;**可能**带可选 `turnId`(v0.66) |
| `message_edited` | sessionId | `<historyMessage>`(**data 直接就是 DTO**) | 编辑单条消息。**`thinking` 恒缺席**;**可能**带可选 `turnId`(v0.66) |
| `message_deleted` | sessionId | `{id}` | 删除单条消息 |
> **`thinking` 可达性口径**:实时帧(`message_persisted` / `channel_persisted` / `message_edited`)与握手回放中 `thinking` **一律为空**(有意默认);**实时思维链**只有 §3.4.2 的 `thinking.*` 流帧按 owner/admin 档下发;**历史思维链**只有 `GET /sessions/{id}/messages`(owner/admin)返回。
> **`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 流式收尾直接丢弃思考文本)⇒ 该键在线上**永远不会出现**。
> **思维链的唯一出口是实时帧**:§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` 不是同一个字段。
#### 3.4.5 子代理 / 任务 / 系统帧(owner+admin)
@@ -360,8 +376,8 @@
| 400 | `bad_request`、`image_too_large`、`too_many_documents` | 请求体/字段非法、图片超限、附件超条数 |
| 401 | `unauthorized`、`invalid_grant` | 无/坏凭据;refresh token 非法 |
| 403 | `forbidden` | 角色不足;主会话不可删 |
| 404 | `not_found`、`session_not_found`、`document_not_found` | 资源缺失;消息列表的会话不存在;文档缺失(含名字非法/穿越) |
| 409 | `chat_busy` | 待处理队列满 |
| 404 | `not_found`、`session_not_found`、`document_not_found`、`account_not_found` | 资源缺失;消息列表的会话不存在;文档缺失(含名字非法/穿越);渠道账号 id 不存在(C19/C20) |
| 409 | `chat_busy`、`account_not_registered` | 待处理队列满;渠道账号已配置但没有已注册适配器(C19/C20,v0.66) |
| 413 | `payload_too_large`、`document_too_large` | 上传/文档超限 |
| 415 | `unsupported_media_type` | 图片/文档类型拒绝 |
| 429 | `rate_limited` | 限流(框架级) |
@@ -396,7 +412,7 @@
### 4.4 消息 DTO(`historyMessage`)
`GET /sessions/{id}/messages` 的数组元素、`message_persisted` / `channel_persisted` 的 `data.message`、`message_edited` 的 `data`、握手 `history` 回放的 `messages[]` —— **同一个结构体**:
`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
{
@@ -408,18 +424,26 @@
"mood": "",
"image": "",
"documents": [ {"name":"...","mime":"...","size":123,"chars":8000,"truncated":false,"status":"extracted"} ],
"thinking": "...", // 仅 GET /sessions/{id}/messages(owner/admin)返回;实时帧恒为空
"thinking": "...", // 恒缺席(全仓无写入者;见 §3.4.4 口径更正)
"platform": "web",
"deviceId": "web:chrome:a1b2c3",
"senderName": "...", // 第三方发送者显示名;空 = 主人
"adapterId": "kook:3bef...", // IM 来源适配器;Web 来源为空
"personaId": "...", // 空 = 全局默认角色
"clientMsgId": "...", // 空则省略
"turnId": "turn-…", // v0.66;空则省略(老消息/未接管路径)
"createdAt": 1700000000000
}
```
字段省略规则:`mode`/`mood`/`image`/`thinking`/`platform`/`deviceId`/`senderName`/`adapterId`/`personaId`/`clientMsgId` 均为 `omitempty`(空时键缺席)。
字段省略规则:`mode`/`mood`/`image`/`thinking`/`platform`/`deviceId`/`senderName`/`adapterId`/`personaId`/`clientMsgId`/`turnId` 均为 `omitempty`(空时键缺席)。
**`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**,于是「旧行」与「没写」是同一个值)。
**`documents` 数组语义(核心)**:
@@ -509,13 +533,14 @@
> **不再有「先回 200 再看有没有落地」**;`202`/`409` 真的会出现。派发用的回合上下文与请求作用域脱钩(`WithoutCancel` + 30 分钟上限),回合不会在客户端刚收到 200 时被连带取消。
**忙时并行(v0.65,`parallel:true`,仅显式声明时生效)**:
**忙时并行(v0.65 起,`parallel:true`,仅显式声明时生效)**:
- **触发权在客户端**:服务端**不做任何启发式自动升级**。空闲时 `parallel` 无效果(照常起第 1 路)。
- 会话**忙**且单会话并发额度未满(默认 **2**,硬上限 **3**)⇒ 本条**升为一条独立并行路立即起**(见上表 `{parallel:true,turnId}`)。
- 会话**忙**且单会话并发额度未满(默认 **2**,硬上限 **3**)⇒ 本条**升为一条独立并行路立即起**(见上表 `{parallel:true,turnId}`;该 `turnId` 随后出现在这一路的 `turn_begin` 帧壳与全部流帧帧壳上)。
- 额度已满、**或真并行被 `<2` 关掉** ⇒ 落回排队语义(`202 {queued:true,queueDepth}`)**并在 `pending_ack` 带 `reason:"parallel_limit"`**。
- 在途路数经 `activeTurns` 出现在忙态帧(`session_busy`/`session_idle`/`pending_drain`/`session_interrupted`)中。
- `session_idle` 时点收紧(**仅并行时与旧行为不同**):所有在途路收尾**且** pending 空才报空闲。
- 在途路数经 `activeTurns` 出现在忙态帧(`session_busy`/`session_idle`/`pending_drain`/`session_interrupted`)与 `turn_begin.data` 中。
- `session_idle` 时点收紧(**仅并行时与旧行为不同**):所有在途路收尾**且** pending 空才报空闲(单路时逐字不变)。
- **每一路起跑都发一帧 `turn_begin`**(v0.66 落地,见 §3.4.3)——客户端据此把用户气泡钉到路、并按 `sessionId + turnId` 两级分桶。
- **省略 `parallel` 的客户端行为逐字不变**。
- ⚠️ **env `CHAT_MAX_PARALLEL_TURNS` 未接线**(见 §5、§7):生产**恒为默认 2**。
@@ -537,8 +562,9 @@
- 省略 / `""` / `"all"`:取消**全部在途路**并**清空待处理队列**(历史语义)。
- `"current"`:只取消在途路(**多路时为每一路**),**保留**待处理队列(队列消息按正常续跑规则继续执行)。
- 其它取值 → **定向**回发送者 `{"type":"error","data":{"code":"bad_request"}}`(不广播、不打断任何会话,绝不静默按默认执行)。
- **`turnId`(在飞、未提交,见 §7)**:**只在 `scope:"current"` 时生效**,只取消该 `turnId` 那一**路**(其余在途路与待处理队列不受影响)。省略 scope 或 `scope:"all"` 时按契约**忽略** `turnId`。
- 目标不存在(已收尾/已取消/id 写错)⇒ 定向回 `{"code":"bad_request"}`,**绝不**静默降级成「停全部」。
- **`turnId`(v0.66 落地)**:**只在 `scope:"current"` 时生效**,只取消该 `turnId` 那一**路**(其余在途路与待处理队列不受影响)。省略 scope 或 `scope:"all"` 时按契约**忽略** `turnId`(服务端只留一条 debug 日志,响应面不变)。
- 目标不存在(已收尾/已取消/id 写错)⇒ 定向回 `{"code":"bad_request"}`(只到发送者,**不广播、不动任何会话**),**绝不**静默降级成「停全部」。
- 省略 `turnId` 的 `scope:"current"` = 作用于**每一路**(v0.65 原口径,逐字不变)。
- **`scope:"all"` 不发 `session_idle`**:客户端靠 `session_interrupted` 收口。
- 命中空闲会话为 no-op。
@@ -601,13 +627,37 @@
> 只保留最近若干版(**v0.60 起**);更早的全部历史见归档 `Docs/archive/api-client.v0.65.md` §5(v0.28 → v0.59)。
> 每版一句话 + 三档标注。**三档定义见 [§0.4](#04-三档标注惯例)。**
### v0.65(当前基线)
### v0.66(当前契约版本)
**并行帧层上线 + 消息回合标识落库 + 渠道按账号操作;并修正 v0.65 的两处过期/写错的陈述**(依据 design/15 §2.5、design/08 §4;提交 `c0dc435` / `d64b26d` + `d804c4e` / `42279b7` / `a918819` / `679cee0`)。
- **纯增量**:
① 新下行帧 **`turn_begin`**(owner+admin,**不进** `frameReachesAll` 白名单):帧壳 `{type,sessionId,turnId}`、data `{kind,messageIds,leg,activeTurns}`,**每起一路(含续跑路)恰好一帧**(`c0dc435`)。
② **`steer_ack` 增帧壳 `turnId`**(data `{messageId,atStep}` 不变)(`c0dc435`)。
③ **上行 `interrupt.turnId`**:仅 `scope:"current"` 生效(其它 scope 按契约忽略);目标路不存在 ⇒ **定向** `{type:"error",data:{code:"bad_request"}}`(复用既有 code),不广播、不动会话(`c0dc435`)。
④ 消息 DTO 增**可选** `turnId`(`omitempty`,空则键省略;同一路的 user 与 assistant 同值)—— 一次覆盖 `GET /sessions/{id}/messages`、`PATCH /messages/{id}` 响应、`GET /admin/channels[/{platform}]/messages`、WS `message_persisted` / `channel_persisted` / `message_edited`、握手 `history` 回放(`d64b26d` + `d804c4e`;迁移 `0018`)。
⑤ `POST /admin/channels/open|close` 请求体增**可选** `accountId`;未知 id ⇒ `404 account_not_found`,已配置未注册 ⇒ `409 account_not_registered`;响应增 `resolved` / `accountId`(`42279b7`)。
⑥ `DELETE /admin/channels/{platform}/config` 增**显式** `?empty=1`(与 `?id=` 互斥 ⇒ `400`;无可删 ⇒ `404`);响应增 `empty` / `removed`(`42279b7`)。
⑦ `GET /admin/channels` 的 `accounts[]` 增**可选** `connected`(键缺席 = 状态未知 ≠ 未连接)(`42279b7`)。
⑧ 新 env 键 `VOICE_STT_TIMEOUT_SEC`(默认 `20`;**只约束 IM 入站语音**,Web `voice_input` 不受其约束)(`a918819`)—— 已在 §5 登记。
- **行为变更**:
⑨ **IM 用户消息现在带 `documents`**(同一 DTO、无新字段;0 附件仍**省略**该键)(`679cee0`)—— v0.65 曾预告,本次随该提交真正上线。
⑩ `turn_begin` 的**帧序**:`session_busy`/`pending_drain` → `message_persisted`(user) → `turn_begin`(`messageIds` 必须是已落库的权威 id,故只能在落库之后发)(`c0dc435`)。
⑪ **不带 `accountId` 的 open/close 目标现在是确定性的**:按 `channels.json` 有效配置顺序第一条(此前走注册表 map 顺序,多账号平台上每次可能落到不同账号)(`42279b7`)。
⑫ `DELETE .../config?empty=1` 由「被当成 id 缺失 ⇒ `400`」变为**真的删除无 id 条目**(显式化;`?id=` / `?empty=` 语义边界见 §2.3 注)(`42279b7`)。
- **破坏性**:无(新帧对新客户端可见、老客户端忽略即可;所有新增字段皆可选;无既有字段被移除或改义)。
- ⚠️ **本版更正 v0.65 的两处陈述(文档写错,不是代码变更)**:
- **`thinking` 口径**:本文档曾写「历史思维链只有 `GET /sessions/{id}/messages`(owner/admin)返回」—— **与实现不符**。`history.Message.Thinking` **全仓没有任何写入者**(`loop.Result` 无该字段、`finalizeAnthropicStream` 直接丢弃思考文本)⇒ 该键在**所有**读取面**恒缺席**;思维链的**唯一出口**是实时 `thinking.*` 帧(owner/admin)。已在 §3.4.4、§4.4 按实测改写。
- **v0.65 §7 的「未实现」清单**:`turn_begin` / `steer_ack.turnId` / `interrupt.turnId` / `chat_messages.turn_id` / 写类工具文件级互斥 —— **现已全部实现并上线**(`c0dc435`、`d64b26d`+`d804c4e`、`354b08a`),§7 已相应改写。
### v0.65(上一版)
**忙时并行回合基础管线(I1′ 有条件放宽)+ 六条登记**(依据 design/15 §2.5,主人 2026-10-05 裁决 B)。
- **纯增量**:① 上行 `chat.parallel?:bool`(WS `chat` 帧 + REST 三入口同形);② `session_busy`/`session_idle`/`pending_drain`/`session_interrupted` 的 `data` 增 `activeTurns:int`;③ `session_interrupted.data` 增 `cancelledTurns:int`;④ `pending_ack.data` 增**可选** `reason:"parallel_limit"`;⑤ REST 两条新应答形状(同步 `202 {parallel:true,turnId}` / `stream:true` 的 `200 {stream:true,parallel:true,turnId}`);⑥ 新 env 键 `VOICE_STT_TIMEOUT_SEC`(默认 20s)。
- **行为变更**:⑦ IM 入站附件引用**真的写进落库的用户消息**(`documents` 列,同一 DTO、无新字段);⑧ IM 各渠道语音进上下文(云端 ASR 转写为文本,失败落一行中文诚实提示);⑨ `session_idle` 时点收紧为「所有在途路收尾且 pending 空」(**仅并行时生效**)。
- **破坏性**:无(省略 `parallel` / 不读新键的客户端行为逐字不变)。
- ⚠️ **回填**:上条 ⑦(IM 附件引用落库)与 ⑥(`VOICE_STT_TIMEOUT_SEC`)在 v0.65 冻结时**尚未提交**,已分别在 `679cee0` / `a918819` 落地,见 v0.66。
### v0.64
@@ -656,28 +706,30 @@
> 本节是**诚实边界**:让客户端**不要以为已有**、也不要**误以为没有**。
### 7.1 在飞切片(工作树已实现,**尚未提交为契约版本**)
### 7.1 在飞切片
下列四项**在当前工作树代码里已实现且有通过的单测**,但**尚未 commit/push、未冻结为契约版本**(相对已提交的 HEAD `2ba1baf` 属**工作树未提交改动**)。客户端**可先按此实现**,但**以提交冻结后为准**:
**当前没有已登记的在飞契约项**:本版之前登记的在飞 / 未实现项(`turn_begin`、`steer_ack.turnId`、`interrupt.turnId`、消息 DTO `turnId`、写类工具文件级互斥)**已全部提交并随 v0.66 冻结**,客户端可直接依赖:
| 项 | 状态 | 落点 |
| 项 | 状态 | 冻结位置 |
|---|---|---|
| 新下行帧 **`turn_begin`**(`{kind,messageIds,leg,activeTurns}`,帧壳带 `turnId`;单路/并行/续跑均发) | **已实现(在飞)** | `session_events.go`、`session_parallel.go` |
| **`steer_ack` 的帧壳 `turnId`**(客户端把灰态配到正确那一路的 `turn.end`) | **已实现(在飞)** | `session_steer.go`(`emitTurnState`) |
| **`interrupt.turnId`**(`scope:"current"` 时精确停某一路;不存在回 `bad_request`) | **已实现(在飞)** | `ws_hub_turn.go`、`session_interrupt.go` |
| **写类工具文件级互斥**(读共享 / 读写与写写互斥,被占住回 `ErrFileBusy` 文案给模型) | **已实现(在飞)** | `internal/runtime/tool/{fileclaims.go,legfile.go,tool.go}`、`loop/tools.go` |
| 新下行帧 **`turn_begin`** | **已上线**(`c0dc435`) | §3.4.3;`session_events.go`、`session_parallel.go` |
| **`steer_ack` 的帧壳 `turnId`** | **已上线**(`c0dc435`) | §3.4.3;`session_steer.go`(`emitTurnState`) |
| **`interrupt.turnId`** | **已上线**(`c0dc435`) | §4.8;`ws_hub_turn.go`、`session_interrupt.go` |
| **消息 DTO `turnId`**(含 `chat_messages.turn_id` 迁移 `0018`) | **已上线**(`d64b26d` + `d804c4e`) | §4.4;`history.go`、`session_turn_id.go` |
| **写类工具文件级互斥**(读共享 / 读写与写写互斥,被占住回 `ErrFileBusy` 文案给模型) | **已上线**(`354b08a`、`ef3dcb6`)—— **纯服务端行为,无客户端可见契约面** | `internal/runtime/tool/{fileclaims.go,legfile.go,tool.go}`、`loop/tools.go` |
> ⚠️ 归档版 v0.65 §J 曾把这四项列为「未实现」——那是**归档时的代码状态**,**已过期**。
> ⚠️ **工作树另有未提交改动(未冻结、未登记为契约项)**:当前工作树存在与本文档无关的未提交改动(例如助手回复的 `<p>` 段落包裹规整 `internal/runtime/turnreply/`、前端 `turn` 分桶接线等)。它们**尚未 commit、未经契约评审**,本文档**不逐项登记**其形状;客户端**不要**按当前工作树形态编程 —— 等它们冻结成下一版契约条目后再对齐(本表只登记已冻结的项)。
> 归档版 v0.65 §J 曾把上表前四项列为「未实现」——那是**归档时的代码状态**,**已过期**。
### 7.2 确认为「未实现」(别当已有)
| 项 | 现状 |
|---|---|
| **`chat_messages.turn_id` 列与 DTO 键** | **未实现**:`history.Message` 与消息 DTO 都**没有** `turnId` 字段;回合↔消息绑定靠实时 `turn_begin.messageIds` **实时**提供,**历史读取面不补** |
| **`graph_update` 按路分桶** | **未实现**:`graph_update` 只带 `data.sessionId`(会话级),**无 `turnId`** |
| **第二路进行态 system 摘要**(`WithParallelNotice`) | **未实现**:`session_parallel.go` 明示留待后续切片 |
| **第二路进行态 system 摘要**(`WithParallelNotice`) | **未实现**:全仓无该符号;`session_parallel.go` 明示留待后续切片 |
| **env `CHAT_MAX_PARALLEL_TURNS`** | **未接线**:`Deps.MaxParallelTurns` 是构造期旋钮,但组合根未从任何 env 读它 ⇒ **生产恒为默认 2** |
| **`tool_execution.update` 的生产者** | wire 映射已实现,但工具分发器暂无中途进度上报源 ⇒ 实际**暂无生产者**(`start`/`end` 真实发出) |
| **`tool_execution.update` 的生产者** | wire 映射已实现,但工具分发器暂无中途进度上报源(全仓无 `StreamKindToolExecutionUpdate` 的生产者)⇒ 实际**暂无生产者**(`start`/`end` 真实发出) |
| **`chat_messages.turn_id` 上的 IM 行** | IM 渠道桥**不走**网关编排器 ⇒ 其行 `turn_id` 留空属**预期**(契约里该键缺席),不是缺陷 |
### 7.3 诚实边界(既有事实)
@@ -687,6 +739,8 @@
- `POST /audio/speech` 的请求体 `voice`/`format` **当前不生效**(合成遵循服务端 `VOICE_TTS_VOICE_ID`/`VOICE_TTS_FORMAT`)。
- `/audio/voice-clone` **真建音色**需公网参考音频 URL(当前无,待提供)+ 北京地域 API Key。
- 可观测三帧 `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` 同理只存在于网关回合。
### 7.4 「未确证」清单(客户端请以实测/代码为准)
@@ -695,7 +749,10 @@
3. **各管理写端点的精确请求体字段与状态码**:本文档 §2.3 只给「方法/路径/鉴权/一句话要点」,**未逐字段展开**(归档 `Docs/archive/api-reference.v0.65.md` 有历史详细版,但可能漂移)。客户端实现管理写操作前请回代码或参考该归档并**以代码为准**。
4. **`observability_metrics` 的完整 `AdminMetrics` 字段集**:本文档只说明它是扁平快照,未展开字段。
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 只是**新增**一个可选键)。
---
*本文件由「以代码为真源」重写(v0.65 基线)。范围:只写 `Docs/**`,未改动任何代码。*
*本文件由「以代码为真源」维护(当前 `v0.66`;`v0.65` 及更早见 `Docs/archive/`)。范围:只改 `Docs/**`,未改动任何代码。*