diff --git a/Docs/api-client.md b/Docs/api-client.md index 2502494..43ccaca 100644 --- a/Docs/api-client.md +++ b/Docs/api-client.md @@ -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":[ ... ]}} ``` -- 内容 = 主会话最近 **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:}` | Web 主会话及 `web:*` 子会话的消息落库实时同步。**`thinking` 恒为空串** | -| `channel_persisted` | sessionId | `{message:}` | 隔离渠道会话(`sess-*`)的消息落库,供 IM 日志页。**`thinking` 恒为空** | -| `message_edited` | sessionId | ``(**data 直接就是 DTO**) | 编辑单条消息。**`thinking` 恒为空** | +| `message_persisted` | sessionId | `{message:}` | Web 主会话及 `web:*` 子会话的消息落库实时同步。**`thinking` 恒缺席**;**可能**带可选 `turnId`(v0.66,空则键省略) | +| `channel_persisted` | sessionId | `{message:}` | 隔离渠道会话(`sess-*`)的消息落库,供 IM 日志页。**`thinking` 恒缺席**;**可能**带可选 `turnId`(v0.66) | +| `message_edited` | sessionId | ``(**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 曾把这四项列为「未实现」——那是**归档时的代码状态**,**已过期**。 +> ⚠️ **工作树另有未提交改动(未冻结、未登记为契约项)**:当前工作树存在与本文档无关的未提交改动(例如助手回复的 `

` 段落包裹规整 `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/**`,未改动任何代码。*