docs(api): api-client → v0.56(协同 A2/G2/G3/G4 契约 + 三个环境变量 + 修正两处错误陈述)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- A2:interrupt 新增 scope({"","all","current"}),三态语义 + 非法值定向 bad_request;
  session_interrupted 形状零变化但 dropped 恒为真丢弃数
- G2:stream:true 直达编排器;响应面补 202 {queued} / 409 chat_busy / 503 chat_dispatch_failed;
  未接 loop 由 200 改 500 chat_unavailable(行为变更已写明兼容性)
- G3:image 解码后 5 MiB 上限 → 400 image_too_large(指路上传);**如实写明 WS 侧到不了这条判定**
  (wsReadLimit=4096 传输层直接断连,发真实图片必须「上传 + URL」)
- G4:新增可选 clientMsgId(两端)⇒ 幂等;REST 200 {duplicate:true,messageId}、WS 新帧 chat_duplicate
  (owner+admin,fail-closed);键回填进 message_persisted/channel_persisted/读接口;迁移 0013
- .env.example + api-client 新增三个环境变量:OWNER_IDENTITY_INJECTION(owner_only 默认) /
  CONTEXT_MAX_TOKENS(32768;0/负 = 关) / GUARDRAIL_KEYWORDS(留空 = 不挂)
- 修正两处错误陈述:api-reference §6.2 与 §6.6 的「OnStream 只填 seq、不带 turnId」(v0.36 起即带
  sessionId+turnId,v0.50 起带 streamId);上传上限 50MB → 5 MiB
This commit is contained in:
AskaEth committed 2026-10-04 14:42:46 +08:00
1 parent 7512d593c2
commit 8c6c796a29
3 files changed
+70 -25

No files matched your search

+26 -14
View File
@@ -1,6 +1,6 @@
# Cyrene v2 后端 API 参考(api-reference.md)
> **权威归属(先读这条)**:**契约以 `Docs/api-client.md` 为准**——那是版本化的跨平台客户端契约,每个端点的形状 / 语义 / 版本变更都记在那里(当前 `v0.55`)。**本文件是内部与运维参考**:端点全集索引 + 示例 + 部署细节。**凡是与 api-client 重复的契约性描述,一律以 api-client 为准(本文件不再维护第二份契约);发现两者不一致时,改本文件而不是改 api-client。**
> **权威归属(先读这条)**:**契约以 `Docs/api-client.md` 为准**——那是版本化的跨平台客户端契约,每个端点的形状 / 语义 / 版本变更都记在那里(当前 `v0.56`)。**本文件是内部与运维参考**:端点全集索引 + 示例 + 部署细节。**凡是与 api-client 重复的契约性描述,一律以 api-client 为准(本文件不再维护第二份契约);发现两者不一致时,改本文件而不是改 api-client。**
> **本文件的正确用法**:查「有哪些端点、大致形状、错误码、部署注意事项」可以;**写客户端代码时请以 `Docs/api-client.md` 为准**,本文件的字段表 / 示例可能滞后。
> **历史包袱(如实)**:本文件长期自称「全部后端 API 的唯一详细参考 / 超详细主文档 / 每变更必同步」,但实际**系统性滞后于实现与 api-client**(2026-10-03 的 16 路 design-vs-code 审计结论)。本次已修正与实现**直接矛盾**的陈述、并把若干复述型段落改为引用 api-client;**仍未登记的端点 / WS 事件清单见 `DevDocs/audit/00-总览.md` §4④**,属后续「文档补全」批次,本文件暂不补全。
> **其余仍成立**:所有字段名、取值、鉴权规则、状态码最终**以代码为准**(`internal/services/gateway/*.go`);契约变更版本化(REST 走 `/api/v1`,破坏性变更升 v2),不得破坏既有客户端(版本化承诺本身记在 api-client §5)。
@@ -14,7 +14,7 @@
- **响应统一信封**:
- 成功:`{"data": <payload>}`(HTTP 200 / 201 / 202 / 204)。
- 失败:`{"error": {"code": <string>, "message": <string>}}`(HTTP 400/401/403/404/409/500/501 等)。
- **错误码**:业务错误带稳定字符串 `code`(如 `unauthorized` / `not_found` / `bad_request` / `chat_busy` / `not_implemented`),便于客户端分支;`message` 面向人读。
- **错误码**:业务错误带稳定字符串 `code`(如 `unauthorized` / `not_found` / `bad_request` / `chat_busy` / `not_implemented`,以及 **v0.56** 新增的 `image_too_large`(`chat.image` 超 5 MiB)与 `chat_dispatch_failed`(流式兜底派发失败)),便于客户端分支;`message` 面向人读。
- **CORS**:`Access-Control-Allow-Origin` 为配置的 `ALLOWED_ORIGINS` 白名单(`*` 时放开);`Allow-Methods: GET, POST, PUT, DELETE, OPTIONS`;`Allow-Headers: Authorization, Content-Type, X-API-Version`;`Access-Control-Expose-Headers: X-Voice-Provider`(跨域浏览器可读语音降级信号头);`Max-Age: 600`。`OPTIONS` 预检直接回 `204`。同源/无 Origin 请求放行。**注意:`X-API-Version` 仅作**advisory**惯例头(见 §0.2)——CORS 已放行但它**不参与服务端版本协商**,服务端当前不校验、不据此改变行为。**
- **请求日志**:每个请求由 `logging` 中间件记录一行(method/path/status/duration_ms),并计入可观测性指标。panic 由 `recover` 中间件兜底为 `500 {error:{code:internal,...}}`,不崩溃进程。
- **超时**:WS 连接 ping 周期 54s、pong 等待 60s、写超时 10s;读上限 4096 字节/帧。
@@ -45,7 +45,7 @@
### 0.3 上传 / 媒体(重要确认)
- **`POST /api/v1/upload`**(B1)上传图片 → 返回**同源 URL**(`/api/v1/files/<name>`),可直接作 `chat.image`;也可继续用 **base64 data: URI** 或 http(s) URL 走 `POST /api/v1/chat` / WS `chat` 的 `image` 字段(入视觉路由,见 §6 流式与直连)。**语义(v0.53 修正,见 api-client §1 行 3)**:**主模型能否收图 = 生效 `models.json` 组的 `multimodal`**(不是「有没有配独立视觉模型」);`LLM_VISION_MODEL` / `LLM_OCR_MODEL` **只配视觉/OCR 工具用的模型**;主模型不收图时由 vision/OCR 工具返回**文本描述**(另有 `image-crop` 工具可裁区域后重读,便于大图分区精读)。
- **`POST /api/v1/upload`**(B1)上传图片 → 返回**同源 URL**(`/api/v1/files/<name>`),可直接作 `chat.image`;也可继续用 **base64 data: URI** 或 http(s) URL 走 `POST /api/v1/chat` / WS `chat` 的 `image` 字段(入视觉路由,见 §6 流式与直连)。**语义(v0.53 修正,见 api-client §1 行 3)**:**主模型能否收图 = 生效 `models.json` 组的 `multimodal`**(不是「有没有配独立视觉模型」);`LLM_VISION_MODEL` / `LLM_OCR_MODEL` **只配视觉/OCR 工具用的模型**;主模型不收图时由 vision/OCR 工具返回**文本描述**(另有 `image-crop` 工具可裁区域后重读,便于大图分区精读)。**内联上限(v0.56)**:`chat.image` 解码后 **5 MiB**(字符串上限 `ceil(n/3)*4 + 64`),超限 `400 {"error":{"code":"image_too_large",...}}`;WS `chat` 帧受 4096 字节读上限约束(超限断连),故 WS 发大图必须走「上传 → URL」。**视觉链路的 URL 取回(v0.56,实现事实)**:`chat.image` 的 http(s) URL(含 `/api/v1/files/<name>`)由**服务端自己取回并内联为 `data:` URI** 再交 provider,**不再把 URL 原样转给上游**(同源 upload 产物优先走**本地文件读取**,`vision.WithUploadsDir`;取回沿用 http/https 白名单 + 50MiB 上限 + 取回超时)。**类型裁决(同一批实现事实)**:**字节嗅探是唯一裁决权**——按真实字节判白名单光栅图(png/jpeg/gif/webp/bmp),声明的 Content-Type / 扩展名只作参考且可被否决;嗅探为文本(HTML/PROXY 错误页、SVG)或判不定且来源没声明 `image/*` → **明确报错**(`vision.ErrNotImage`),**绝不静默回退成 `image/png`**(旧文若写「非图片响应会被当 PNG」即按此更正)。
- **REST 语音端点已存在**:`POST /api/v1/audio/transcribe`(STT)、`POST /api/v1/audio/speech`(TTS)、`POST /api/v1/audio/voice-clone`(声音复刻),均 owner-only + 审计,绕开 WS 4KB 帧限。语音也可走 WS `voice_input`(上行)/`voice_output`(下行)。
- 管理端导入知识文档(RAG)走 JSON body 文本(`POST /api/v1/admin/rag`),非文件上传。
@@ -66,7 +66,7 @@
| # | 方法 | 路径 | 鉴权 | 状态 | 说明 | 里程碑 |
|---|------|------|------|:---:|------|:---:|
| 3 | POST | `/api/v1/chat` | owner\|admin | 200/202/409 | 发消息(同步回复或触发 WS 流式) | M8/M15 |
| 3 | POST | `/api/v1/chat` | owner\|admin | 200/202/400/409/500/503 | 发消息(同步回复或触发 WS 流式);**v0.56**:`stream:true` 受理成功才回(`202` 续接排队 / `409` 队列满 / `500 chat_unavailable` 未接 loop / `503 chat_dispatch_failed` 兜底派发失败)、`image` 超 5 MiB → `400 image_too_large`、`clientMsgId` 重复 → `200 {duplicate:true}` | M8/M15 |
| 4 | GET | `/api/v1/sessions` | JWT | 200 | 会话列表(持久化会话 or 渠道会话回退) | M16 |
| 5 | GET | `/api/v1/personas` | JWT | 200 | 人格列表 | M8 |
| 6 | GET | `/api/v1/sessions/{id}/messages` | owner\|admin | 200 | 会话消息热分页 | M16 |
@@ -217,7 +217,8 @@ curl -s -X POST http://<host>:8080/api/v1/auth/login \
| `image` | string | 否 | 视觉输入:base64 **data: URI** 或 http(s) URL;入 vision/OCR 工具或主模型多模态(design/06)。图文并存则为带图 + 文本的 turn。**语义(v0.53)**:主模型能否收图看生效组的 `multimodal`;`LLM_VISION_MODEL`/`LLM_OCR_MODEL` 只配视觉/OCR **工具**的模型(见 api-client §1 行 3) |
| `deviceId` | string | 否 | 客户端生成的端标识(design/16 §4.5,如 `web:chrome:a1b2c3`),落库到消息,可选 |
| `mode` | string | 否 | 行为模式(design/01 §3、design/16 §5.1,如 `chat`/`code`/…);随该轮落库供气泡渲染;**IM 恒 `chat`、缺省回落 `chat`**(`chatRequest.Mode`) |
| `stream` | bool | 否 | `true` = 经 WS 触发流式(`publishChatStream` 发 `ws.chat`),**立即返回**;缺省 / `false` = 同步返回完整回复(Go bool 零值即 false,仍**无「必填」约束**) |
| `clientMsgId` | string | 否 | **客户端幂等键(v0.56,G4)**:同一会话内同一个非空值只承认第一条,重复到达回 `200 {"data":{"duplicate":true,"messageId":...,"sessionId":...}}` 且**不落库、不起回合**;省略 = 与加入该字段前逐字相同(老客户端零影响)。原生端用它把本地乐观气泡对齐到服务端真相 |
| `stream` | bool | 否 | `true` = **直达会话编排器**的流式提交(v0.56/G2:接了编排器就直接 `Submit`,**不再经可丢弃的 bus**;未接编排器才回退 `publishChatStream` 发 `ws.chat`),**受理成功后才回响应**;缺省 / `false` = 同步返回完整回复(Go bool 零值即 false,仍**无「必填」约束**) |
```json
{"sessionId":"web","text":"你好","deviceId":"web:chrome:a1b2c3","stream":false}
@@ -226,13 +227,17 @@ curl -s -X POST http://<host>:8080/api/v1/auth/login \
- **响应**:
- 同步(`stream=false`,会话空闲)`200` `{"data":{"reply":"<助手文本>","steps":<int>,"stopReason":"<string>"}}`(`chatResponse`)。`steps` 为工具循环步数,`stopReason` 为停止原因(如 `stop`/`idle`/`error`)。
- 同步但会话**正忙**(`SubmitSync` 返回 `Pending`)`202` `{"data":{"queued":true,"queueDepth":<int>}}`(非阻塞 ack,稍后按上下文续接)。
- 流式(`stream=true`)`200` `{"data":{"stream":true}}` —— 实际文本经 WS 推送(见 §6)。
- 同步且 **`clientMsgId` 重复到达**(`SubmitSync` 返回 `Duplicate`)`200` `{"data":{"duplicate":true,"messageId":<id>,"sessionId":<id>}}`(**v0.56/G4**:不落库、不起回合,`messageId` 是既有那条的 id)。
- 流式(`stream=true`,**v0.56 行为变更**):**受理成功后才回** —— 空闲已起回合 `200` `{"data":{"stream":true}}`;正忙已续接排队 `202` `{"data":{"stream":true,"queued":true,"queueDepth":<int>}}`;待处理满 `409` `{"error":{"code":"chat_busy",...}}`;幂等键重复 `200` `{"data":{"duplicate":true,"messageId":<id>,"sessionId":<id>}}`(**不落库、不起回合**)。实际文本经 WS 推送(见 §6)。**修复前**该分支在问编排器之前就回 `200 {"data":{"stream":true}}`,故 `202`/`409` 永不出现;**未接 loop 时由 `200 {stream:true}` 改为 `500 chat_unavailable`**(与同步路径同口径)。
- **错误**:
- `400` `{"error":{"code":"bad_request","message":"text or image is required"}}`(空消息)。
- `400` `{"error":{"code":"image_too_large",...}}`(**v0.56/G3**:`image` 解码后超 **5 MiB**;base64 字符串上限 `ceil(n/3)*4+64`,与 `POST /api/v1/upload` 同口径,文案指路该端点)。
- `409` `{"error":{"code":"chat_busy","message":"会话正忙,待处理消息已满"}}`(pending 队列满,`PendingFull`)。
- `500` `{"error":{"code":"chat_unavailable",...}}`(`runner` 未注入)/ `{"error":{"code":"chat_failed",...}}`(回合运行出错)。
- `503` `{"error":{"code":"chat_dispatch_failed",...}}`(**v0.56/G2**:仅**未接编排器**时的总线兜底派发失败——本轮**未提交**,不再是静默 200)。
- **备注**:
- **会话编排(design/15)**:同会话正忙时新消息**不阻塞**、入 pending(默认上限 5);满则 `409 chat_busy`。状态经 WS 状态事件通知(§6.4)。
- **幂等(v0.56/G4)**:带 `clientMsgId` 的用户轮次由 `(session_id, client_msg_id)` 的**部分唯一索引**原子判重(写在 `internal/runtime/history/store.go` 的 `AppendFull` + `client_msg_id.go`),重复到达**不落库、不起回合**;该键随后出现在消息 DTO(含 `GET /sessions/{id}/messages`)。
- 主会话 `web` 绑定**全量访问 env**(`loop.OwnerEnv()`:记忆读写 + 行为不变)。
- 敏感字段(`image` 可能为 base64)不走日志明文。
- curl:
@@ -1080,7 +1085,7 @@ curl -s -X POST http://<host>:8080/api/v1/admin/system/restore \
| 字段 | 类型 | 必填 | 说明 |
|------|------|:---:|------|
| `type` | string | 是 | 事件类型(见 §6.3 表) |
| `turnId` | string | 否 | 一轮交换的关联 id(流式;`turn-<hex>`);OnStream 结构化流路径**不带** |
| `turnId` | string | 否 | 一轮交换的关联 id(流式;`turn-<hex>`)。**v0.36 起结构化流帧(`message.*`/`thinking.*`/`tool_call.*`)同样带 `turnId`**(`broadcastStream` 从 `model.StreamEvent` 取,loop 在回合边界注入);v0.50 起这些帧的 `data` 内另带 `streamId`(见 §6.6)。**旧文曾说这条路径只有 `seq`、没有轮次标识,那是错的,已更正** |
| `seq` | int | 否 | hub 级单调递增序号,供客户端排序关联流式突发 |
| `sessionId` | string | 否 | 能确定会话的事件带上,客户端按它把帧路由到该会话的消息桶(v0.54 起非激活会话的帧不再丢弃;只有单槽忙态仍按激活会话判定,见 `api-client.md` §3.1/§3.2/§3.3);可观测推送三帧(`observability_log`/`observability_trace`/`observability_metrics`,v0.55)**不带**该字段,帧壳只有 `{type,data}` |
| `data` | any | 否 | 类型化负载(每个事件见 §6.3) |
@@ -1099,23 +1104,23 @@ curl -s -X POST http://<host>:8080/api/v1/admin/system/restore \
| type | 数据负载 | 服务端行为 | 结果 |
|------|----------|-----------|------|
| `mode_switch` | 任意(如 `{"mode":"chat"}`) | 发布 `ws.mode_switch` → hub 规范化广播 | 下行 `mode_switch`(广播) |
| `chat` | `{sessionId?, text, image?, deviceId?, mode?}` | **直达会话编排器的非阻塞 `Submit`(v0.55 起:上行 chat 不再经可丢弃的 bus——会话消息不得被 drop;编排器未注入时回退发布 `ws.chat`)** → `streamChat`(跑 loop 并流式) | 下行 `stream_chunk`/`stream_end` 或 `message.*/thinking.*/tool_call.*`(广播) |
| `interrupt` | `{sessionId}` | 会话编排器中断:取消在跑 turn + 清空 pending | 下行 `session_interrupted` |
| `chat` | `{sessionId?, text, image?, deviceId?, mode?, clientMsgId?}` | **直达会话编排器的非阻塞 `Submit`(v0.55 起:上行 chat 不再经可丢弃的 bus——会话消息不得被 drop;编排器未注入时回退发布 `ws.chat`)** → `streamChat`(跑 loop 并流式)。`clientMsgId`(**v0.56/G4**)非空且重复到达 → **不落库、不起回合**,只回下行 `chat_duplicate` | 下行 `stream_chunk`/`stream_end` 或 `message.*/thinking.*/tool_call.*`(广播);重复时下行 `chat_duplicate` |
| `interrupt` | `{sessionId, scope?}` | 按 `scope` 中断(**v0.56/A2**):省略 / `""` / `"all"` = 取消在跑 turn + **清空 pending**(现状语义);`"current"` = **只取消在跑 turn、保留 pending**(队列按编排器续跑规则继续执行);**其它取值 → 定向回 `error{bad_request}`,不打断任何会话** | 下行 `session_interrupted`(`{sessionId,mode,dropped}`;`dropped` = **真的被丢弃**的 pending 条数,`scope:"current"` 时为 `0` 且 `mode` 为当时的忙碌通道) |
| `voice_input` | `{audio?, format?, text?, sessionId?}` | STT→chat→TTS(缺省 sessionId=`web`) | 下行 `voice_output`(**定向回请求连接**) |
| `subagent_ask` | `{id, mode?(""/full/tail/keyword), n, query?}` | 审计子代理上下文(缺省 `mode`=full) | 下行 `subagent_event`(或 `error`) |
| `task_output_query` | `{taskId}` | 查询后台任务输出 | 下行 `task_output`(或 `error`) |
**上行各负载字段**
- **`chat`**(`chatStreamRequest`):`{sessionId`(缺省`"web"`), `text`, `image?`(base64 data: URI 或 URL), `deviceId?`, `mode?`(行为模式,缺省回落 `chat`)}。text+image 至少一项;`runner` 未注入或两者皆空 → 直接广播 `stream_end{stopReason:"idle"}`。主会话 `web` 绑定全量 env(`loop.OwnerEnv()`)。`chat` 进入时若该会话正忙 → 由编排器入队(见 §6.4),非阻塞。**仅 owner/admin 连接可发**(见本节角色门)。
- **`interrupt`**:`{sessionId}`。命中空闲会话为 no-op。
- **`chat`**(`chatStreamRequest`):`{sessionId`(缺省`"web"`), `text`, `image?`(base64 data: URI 或 URL), `deviceId?`, `mode?`(行为模式,缺省回落 `chat`), `clientMsgId?`(客户端幂等键,**v0.56/G4**:同一会话内同一非空值只承认第一条,重复到达回下行 `chat_duplicate` `{sessionId,clientMsgId,messageId}`,且**不落库、不起回合**;省略 = 老行为逐字不变)}。text+image 至少一项;`runner` 未注入或两者皆空 → 直接广播 `stream_end{stopReason:"idle"}`。主会话 `web` 绑定全量 env(`loop.OwnerEnv()`)。`chat` 进入时若该会话正忙 → 由编排器入队(见 §6.4),非阻塞。**仅 owner/admin 连接可发**(见本节角色门)。
- **`interrupt`**(**v0.56/A2**):`{sessionId, scope?}`。`scope` 省略 / `""` / `"all"` = 取消当前任务 + 清空待处理队列(现状语义);`"current"` = **只取消当前任务、保留待处理队列**(队列按编排器续跑规则继续执行);**其它取值 → 定向回** `{type:"error",data:{code:"bad_request"}}`,**不广播、不打断任何会话**。命中空闲会话为 no-op(两种 scope 都是)。
- **`voice_input`**:`{audio`(base64 或 data: URI), `format`(`wav|mp3|ogg|webm|mpeg`,空由 mime 猜,默认 `wav`), `text`(已转写则跳过 STT), `sessionId?`}。无 `voice` 服务 → `{type:"error",data:{message:"voice unavailable"}}`。
- **`subagent_ask`**:`{id`(必填), `mode`(空/full/tail/keyword), `n`(tail 查询窗长), `query`(keyword 查询词)}。无 auditor → `error`;id 缺失 → `error`。
- **`task_output_query`**:`{taskId}`。无 task store → `error`;运行中任务返回截至目前输出;输出拼接 `stdout + "\n" + stderr`。
### 6.4 下行事件全表
> **角色门(design/11 §7.1,fail-closed)**:下行帧按类型分档,收口在 `internal/services/gateway/ws_frame_scope.go` 的 `frameReachesAll`(**唯一一处判定**,各调用点不自行决定可见性)。**只有 7 个不含内容的状态机信号**发给**所有已认证连接**(含 `viewer`):`session_busy` / `pending_ack` / `pending_drain` / `session_idle` / `session_interrupted` / `pending_full` / **`session_read`**。**其余全部只发 `owner`+`admin`**:流式内容(`message.*` / `thinking.*` / `tool_call.*` / `tool_execution.update` / `stream_chunk` / `stream_end`)、持久化(`message_persisted` / `message_edited` / `message_deleted` / `channel_persisted`)、系统内部(`graph_update` / `subagent_event` / `task_output` / `task_done` / `tool_used` / `capacity_awaken` / `mode_switch`)、私有状态(`mood` / `device_update` / `reminder` / `proactive_message`)、**可观测推送(`observability_log` / `observability_trace` / `observability_metrics`,v0.55)**;**未登记的新帧类型默认只发 owner/admin**。**本表并非全表**:`session_read` / `message_edited` / `message_deleted` 等已实现事件尚未逐行登记于此;**权威清单与逐帧口径见 `Docs/api-client.md` §3**。
> **角色门(design/11 §7.1,fail-closed)**:下行帧按类型分档,收口在 `internal/services/gateway/ws_frame_scope.go` 的 `frameReachesAll`(**唯一一处判定**,各调用点不自行决定可见性)。**只有 7 个不含内容的状态机信号**发给**所有已认证连接**(含 `viewer`):`session_busy` / `pending_ack` / `pending_drain` / `session_idle` / `session_interrupted` / `pending_full` / **`session_read`**。**其余全部只发 `owner`+`admin`**:流式内容(`message.*` / `thinking.*` / `tool_call.*` / `tool_execution.update` / `stream_chunk` / `stream_end`)、持久化(`message_persisted` / `message_edited` / `message_deleted` / `channel_persisted`)、系统内部(`graph_update` / `subagent_event` / `task_output` / `task_done` / `tool_used` / `capacity_awaken` / `mode_switch`)、私有状态(`mood` / `device_update` / `reminder` / `proactive_message`)、**可观测推送(`observability_log` / `observability_trace` / `observability_metrics`,v0.55)**、**`chat_duplicate`(v0.56/G4:虽由编排器状态通道下发,但未进白名单)**;**未登记的新帧类型默认只发 owner/admin**。**本表并非全表**:`session_read` / `message_edited` / `message_deleted` 等已实现事件尚未逐行登记于此;**权威清单与逐帧口径见 `Docs/api-client.md` §3**。
>
> **来源标记**:`[direct]` 网关直接 emit;`[relay]` hub 从 bus `ws.>` 规范化(任意 `ws.X` 主题 → 类型 `X`);`[bridge]` 桥接 producer 主题;`[target]` 仅定向特定连接。
@@ -1138,8 +1143,9 @@ curl -s -X POST http://<host>:8080/api/v1/admin/system/restore \
| `pending_ack` | direct(编排) | `{sessionId, mode, queueDepth}` | 忙时新消息已入队「收到,稍后回」 |
| `pending_drain` | direct(编排) | `{sessionId, mode, queueDepth}` | 忙完开始消费下一待处理消息 |
| `session_idle` | direct(编排) | `{sessionId, mode}` | 会话回到空闲、队列清空 |
| `session_interrupted` | direct(编排) | `{sessionId, mode, dropped}` | 用户 interrupt,当前任务取消、pending 清空 |
| `session_interrupted` | direct(编排) | `{sessionId, mode, dropped}` | 用户 `interrupt`。**v0.56/A2**:帧形状零变化——`scope:"all"`(省略 / 空串同义)= 任务取消 + pending 清空(`mode=idle`、`dropped`=清空条数);`scope:"current"` = **只取消任务、保留 pending**(`dropped` 恒 `0`、`mode` 为当时忙碌通道) |
| `pending_full` | direct(编排) | `{sessionId, mode, max}` | 单会话 pending 达上限(默认 5)拒绝入队 |
| `chat_duplicate` | direct(编排) | `{sessionId, clientMsgId, messageId}` | **v0.56/G4(新增帧)**:同一会话内**重复到达**的 `clientMsgId` 的明确回执——**不落库、不起回合、不发任何内容帧**。帧壳带 `sessionId`;**未进 7 个状态机信号白名单 ⇒ fail-closed 只发 `owner`+`admin`**(发过该 `clientMsgId` 的客户端用它把本地乐观气泡对齐到既有 `messageId`,其它客户端按值自行忽略) |
| `message_persisted` | direct | `{message:historyMessage}`(sessionId 设) | 每条 user/assistant 消息落库后广播(主会话 `web`/`web:<uuid>`),thinking 剥离 |
| `channel_persisted` | direct | `{message:historyMessage}`(sessionId=`sess-xxx`) | 群聊/陌生人(非主人)消息落库后广播,供 IM 适配器日志页 |
| `voice_output` | direct/target | `{audio, format, text, reply}` | 语音合成回复(定向回请求连接) |
@@ -1176,6 +1182,7 @@ curl -s -X POST http://<host>:8080/api/v1/admin/system/restore \
| `deviceId` | string | `omitempty` |
| `senderName` | string | `omitempty`(第三方昵称,主人私聊为空) |
| `adapterId` | string | `omitempty`(仅 `channel_persisted` 常见,如 `kook:3bef...`) |
| `clientMsgId` | string | `omitempty`(**v0.56/G4**:客户端自带的幂等键——由 `chat` 上行带 `clientMsgId` 的 user 消息写入,之后在 `message_persisted` / `channel_persisted` / `history` 回放 / `GET /sessions/{id}/messages` 中原样回填;空 = 无幂等键,即老客户端 / IM 镜像 / 历史行) |
| `createdAt` | int64 | Unix 毫秒 |
**事件类型分流**(`BroadcastPersisted`):`sessionId == "web"` 或以 `web:` 前缀 → **`message_persisted`**(Web 聊天实时同步);其它(隔离 `sess-xxx`)→ **`channel_persisted`**(IM 适配器日志专用)。Web 聊天无 `channel_persisted` handler,天然忽略。
@@ -1189,7 +1196,7 @@ curl -s -X POST http://<host>:8080/api/v1/admin/system/restore \
### 6.6 流式关联(`turnId` / `seq`)
- `turnId`:`runTurn` 每次生成 `turn-<hex>`(crypto/rand),用于关联一轮流式突发;`stream_end`/`stream_chunk`(回退路径)带 `turnId`。**OnStream 结构化流路径(`message.*` 等)由 `broadcastStream` 只填 `seq`、不带 `turnId`**(OnStream bus 负载未携带 turn 身份)。
- `turnId`:`runTurn` 每次生成 `turn-<hex>`(crypto/rand),用于关联一轮流式突发;`stream_end`/`stream_chunk`(回退路径)带 `turnId`。**结构化流路径(`message.*` / `thinking.*` / `tool_call.*`)自 v0.36 起同样带 `sessionId` + `turnId`**——`broadcastStream` 直接把 `model.StreamEvent` 的 `SessionID`/`TurnID` 填进帧壳,loop 在回合边界把 `turnId` 挂进运行上下文(同轮所有帧一致)。**旧文曾说这条路径只有 `seq`、缺轮次标识,是错的,本版按实现更正**(与 api-client 的 v0.36 条目一致)。v0.50 起这些帧的 `data` 内另带 `streamId`(同一次模型流的多帧共享,多步工具轮的第二次模型调用取新 id);`tool_execution.*` 与 `turn.start/end` 帧**不带** `streamId`。
- `seq`:hub 级单调递增 `atomic.Uint64`,保证一次突发有序,即便 loop 多 goroutine 触发。
### 6.7 WS 示例
@@ -1214,6 +1221,11 @@ TOKEN=$(curl -s -X POST http://<host>:8080/api/v1/auth/login \
# 4) 上行 interrupt
# {"type":"interrupt","data":{"sessionId":"web"}}
# 下行:{"type":"session_interrupted","data":{"sessionId":"web","mode":"idle","dropped":2}}
# v0.56/A2:只打断当前那一轮、保留待处理队列 → scope:"current"(此时 dropped 恒 0、mode 为当时忙碌通道,如 thinking)
# {"type":"interrupt","data":{"sessionId":"web","scope":"current"}}
# 下行:{"type":"session_interrupted","data":{"sessionId":"web","mode":"thinking","dropped":0}}
# 非法 scope:{"type":"interrupt","data":{"sessionId":"web","scope":"cancel"}}
# 定向回发送者:{"type":"error","data":{"code":"bad_request"}}(不广播、不打断任何会话)
# 5) 上行 voice_input
# {"type":"voice_input","data":{"audio":"<base64>","format":"wav","sessionId":"web"}}