diff --git a/Docs/api-client.md b/Docs/api-client.md index 522ce4a..a71b0ab 100644 --- a/Docs/api-client.md +++ b/Docs/api-client.md @@ -12,7 +12,7 @@ - **Base URL**: `http://:8080`(网关唯一对外端口;生产一般经反代/HTTPS)。 - **版本**: REST 全部挂在 `/api/v1/{resource}`;未来破坏性变更升 v2。 - **认证**: `Authorization: Bearer `(RBAC:`owner`/`admin`/`viewer`,权限矩阵见 §2.0);**`X-API-Key` 已接线(v0.51)**:所有已认证 API(管理 `/api/v1/admin/*` 与用户侧 `/api/v1/*`)同时接受 `X-API-Key`;命中签发表即通过,且**一律 `owner` 级**(不再按角色分档,裁决 15.4.3)。请求带该头时**只走 key 分支**——未知/已吊销/空值回 `401` 且**不回落 Bearer**(fail-closed)。**WS 握手仍只认 Bearer**。 -- **响应统一**: `{"data": ...}` / `{"error": {"code": , "message": }}`;状态码 200/201/204/400/401/403/404/429/500。 +- **响应统一**: `{"data": ...}` / `{"error": {"code": , "message": }}`;状态码 200/201/204/400/401/403/404/413/415/429/500(**413/415 为 v0.59 文档端点新增**)。 - **错误码**:业务错误带 `code` 字符串(如 `unauthorized` / `not_found`),便于客户端分支。 - **数据目录 `DATA_DIR`(v0.55)**:默认 `./data`;**新增接受保留字 `platform`(大小写不敏感)** → 平台标准数据目录(Windows `%APPDATA%\Cyrene`、macOS `~/Library/Application Support/Cyrene`、Linux `~/.local/share/cyrene`)。**默认 `./data` 与显式路径行为不变**;**显式空值行为也不变**(仍走原兜底,不会因升级突然换库)。 - **管理面板安全入口 `ADMIN_PATH`(v0.55,前端路由面,不是 REST 契约)**:面板**前端**路由前缀 = `/` + `ADMIN_PATH`(默认 `admin` → `/admin`;`xxx/admin` → `/xxx/admin`;**非法值回退 `/admin`**)。HTTP 行为:`/` 与其深链返回**注入了 `` 与 `window.__CYRENE_ADMIN_ENTRY__={"prefix":"..."}` 的 index.html**(`Cache-Control: no-store`);**形如面板入口(路径里出现 `admin` 段)但前缀不符的路径 → `404`**(**不回落用户端聊天壳**);`/` 若恰好命中 `dist` 真实文件则仍走文件服务器。**API 不藏**:`/api/v1/*`、`/api/v1/admin/*`、`/ws/chat`、webhook 一律原样,**无新端点、无帧变更**(安全靠 JWT + 角色门,design/07 §0.1)。**口径**:用户端 HTML **永不携带该前缀串**(后端只在命中前缀时注入)⇒ 用户端既无管理入口链接、也解析不出面板路由(design/07 §0.2)。 @@ -27,7 +27,7 @@ | 1a | `/api/v1/auth/refresh` | POST | refreshToken | ✅ | **静默续期**(B2):body `{refreshToken}` → `{data:{token,expiresAt,role,refreshToken?}}`(新 access + 新 refresh;**`role` 恒返回** = 该 refresh grant 记录的角色 `owner|admin|viewer`),**默认单次使用旋转**——旧 refresh 立即作废,并发重放 fail-closed `401 invalid_grant`)。`AUTH_REFRESH_ROTATE_DISABLED=true` 关闭轮换时旧 token 保持有效、不返回新 refresh。过期/已撤销/乱串统一 `401 {error:{code:"invalid_grant"}}`;成功才审计 | | 1b | `/api/v1/auth/logout` | POST | refreshToken 或 JWT | ✅ | **登出**(B2):body `{refreshToken}` 撤**单个** grant(幂等)→ `{data:{revoked:}}`;带 `Authorization: Bearer ` 且无 body → 撤**该账号全部** grant(全设备登出)→ `{data:{revoked:,revokedCount:}}`。**两种响应形状不同**——单撤**没有** `revokedCount`;两者都不给回 `400 bad_request`。refresh 相关操作成功才审计 | | 2 | `/api/v1/health` | GET | 否 | ✅ | 健康检查 `{data:{status:"ok"}}` | -| 3 | `/api/v1/chat` | POST | JWT | ✅ | `{text,image?,deviceId?,mode?,clientMsgId?,stream?}` → `{data:{reply}}`;`stream:true` 时**直达会话编排器**(v0.56/G2:不再经可丢弃的 bus,成功受理后才回,见本行末尾「`stream:true` 响应面」)。`image` 为 base64 data: URI 或 http(s) URL,入视觉路由(design/06 §0;**v0.53 语义修正**)——**主模型能不能收图看生效组的 `multimodal`**(`models.json` 组键,面板可保存、热更新);`LLM_VISION_MODEL`/`LLM_OCR_MODEL` **只配视觉/OCR 工具用的模型**,**与主模型能否收图无关**(裁决 14.4.1)——**走 `.env` 合成组(无有效 `models.json`)时,「主模型能否收图」该由 `LLM_MULTIMODAL` 声明(默认 `false`)**(**v0.58**,见 §2「模型配置契约」表);主模型不收图时由视觉·OCR 工具的**文本描述**替代(图像不占上下文,只有工具结果占);**`image` 上限(v0.56,G3)**:解码后 **5 MiB**(与 `POST /api/v1/upload` 同口径;base64 字符串上限 = `ceil(n/3)*4 + 64` 字符,已含 data: URI 前缀余量),超限一律 `400 {error:{code:"image_too_large", message}}`(文案指路 `POST /api/v1/upload`);**WS `chat` 帧到不了这条判定**——WS 读上限 `wsReadLimit = 4096` 字节/帧,超限由传输层直接断连,故 WS 上发真实图片本就必须「上传 + URL」;`deviceId` 为**客户端生成的具体端标识**(design/16 §4.5,如 `web:chrome:a1b2c3`,可选),落库到消息、便于主人知道从哪个端发来;`mode` 为**行为模式**(design/01 §3 / design/16 §5.1,如 `chat`/`code`/`home`/`docs`/…,可选),随该轮次落库——IM 恒 `chat`、缺省回落 `chat`,供前端按模式渲染气泡(仅 `chat` 才解析 `

` 拆气泡)。`clientMsgId`(v0.56,G4)为**客户端自带的幂等键**(可选):同一会话内同一个非空值只承认第一条,重复到达回 `200 {data:{duplicate:true,messageId,sessionId}}` 且**不落库、不起回合、不发新内容帧**;省略 = 与加入该字段前逐字相同(详见 §3.13)。**会话编排(design/15)**:非流式且该会话「正忙」时**不阻塞**,返回 `202` `{data:{queued:true,queueDepth}}`(稍后按上下文续接);待处理队列满时返回 `409` `{error:{code:"chat_busy"}}`。**`stream:true` 响应面(v0.56,G2,行为变更)**:受理成功后才回——空闲已起回合 = `200 {data:{stream:true}}`、正忙已续接排队 = `202 {data:{stream:true,queued:true,queueDepth}}`、队列满 = `409 {error:{code:"chat_busy"}}`、幂等键重复 = `200 {data:{duplicate:true,messageId,sessionId}}`;**未接 loop 改为 `500 {error:{code:"chat_unavailable"}}`**(旧文写 `200 {stream:true}`,现与同步路径同口径);仅当**未接编排器**才回退总线派发,其失败回 `503 {error:{code:"chat_dispatch_failed"}}`——**不再有「先回 200 再看有没有落地」**,故 `202`/`409` 从此**真的会出现**(修复前因提前 return 永不出现) | +| 3 | `/api/v1/chat` | POST | JWT | ✅ | `{text,image?,deviceId?,mode?,clientMsgId?,documents?,stream?}` → `{data:{reply}}`;**`documents?`(v0.59)**=本回合文档附件引用数组(**≤5 条**、**只收服务端存储名**、与 REST `stream:true` / WS `chat` 同形),单回合超限 `400 too_many_documents`(先于任何文件系统访问)、名非法/穿越/不存在 `404 document_not_found`,详见 §1.2;`stream:true` 时**直达会话编排器**(v0.56/G2:不再经可丢弃的 bus,成功受理后才回,见本行末尾「`stream:true` 响应面」)。`image` 为 base64 data: URI 或 http(s) URL,入视觉路由(design/06 §0;**v0.53 语义修正**)——**主模型能不能收图看生效组的 `multimodal`**(`models.json` 组键,面板可保存、热更新);`LLM_VISION_MODEL`/`LLM_OCR_MODEL` **只配视觉/OCR 工具用的模型**,**与主模型能否收图无关**(裁决 14.4.1)——**走 `.env` 合成组(无有效 `models.json`)时,「主模型能否收图」该由 `LLM_MULTIMODAL` 声明(默认 `false`)**(**v0.58**,见 §2「模型配置契约」表);主模型不收图时由视觉·OCR 工具的**文本描述**替代(图像不占上下文,只有工具结果占);**`image` 上限(v0.56,G3)**:解码后 **5 MiB**(与 `POST /api/v1/upload` 同口径;base64 字符串上限 = `ceil(n/3)*4 + 64` 字符,已含 data: URI 前缀余量),超限一律 `400 {error:{code:"image_too_large", message}}`(文案指路 `POST /api/v1/upload`);**WS `chat` 帧到不了这条判定**——WS 读上限 `wsReadLimit = 4096` 字节/帧,超限由传输层直接断连,故 WS 上发真实图片本就必须「上传 + URL」;`deviceId` 为**客户端生成的具体端标识**(design/16 §4.5,如 `web:chrome:a1b2c3`,可选),落库到消息、便于主人知道从哪个端发来;`mode` 为**行为模式**(design/01 §3 / design/16 §5.1,如 `chat`/`code`/`home`/`docs`/…,可选),随该轮次落库——IM 恒 `chat`、缺省回落 `chat`,供前端按模式渲染气泡(仅 `chat` 才解析 `

` 拆气泡)。`clientMsgId`(v0.56,G4)为**客户端自带的幂等键**(可选):同一会话内同一个非空值只承认第一条,重复到达回 `200 {data:{duplicate:true,messageId,sessionId}}` 且**不落库、不起回合、不发新内容帧**;省略 = 与加入该字段前逐字相同(详见 §3.13)。**会话编排(design/15)**:非流式且该会话「正忙」时**不阻塞**,返回 `202` `{data:{queued:true,queueDepth}}`(稍后按上下文续接);待处理队列满时返回 `409` `{error:{code:"chat_busy"}}`。**`stream:true` 响应面(v0.56,G2,行为变更)**:受理成功后才回——空闲已起回合 = `200 {data:{stream:true}}`、正忙已续接排队 = `202 {data:{stream:true,queued:true,queueDepth}}`、队列满 = `409 {error:{code:"chat_busy"}}`、幂等键重复 = `200 {data:{duplicate:true,messageId,sessionId}}`;**未接 loop 改为 `500 {error:{code:"chat_unavailable"}}`**(旧文写 `200 {stream:true}`,现与同步路径同口径);仅当**未接编排器**才回退总线派发,其失败回 `503 {error:{code:"chat_dispatch_failed"}}`——**不再有「先回 200 再看有没有落地」**,故 `202`/`409` 从此**真的会出现**(修复前因提前 return 永不出现) | | 4 | `/api/v1/sessions` | GET | JWT | ✅ | `{data:[session...]}`。**会话持久化(design/16)**:注入 HistoryStore 后返回持久化会话元数据 `{data:[{id,type("main"|"sub"),title?,createdAt,pinned,unreadCount,lastMessage?,personaId?}]}`(**排序(D5):以代码为准 = `pinned DESC, (type='main') DESC, created_at ASC`**——`pinned=true` 最前,其次是未置顶的主会话,最后是未置顶子会话(按 `createdAt` 升序);⚠️ **置顶的次级会话会排在主会话之前**,客户端定位主会话请按 `id=="web"` / `type=="main"`,**不要假定 index 0 就是主会话**;次级 `web:`;`pinned` 恒返回 bool;**`unreadCount`(D4)恒返回整数**——该会话中 `createdAt >` 已读游标的消息条数(0 = 无未读),与 `lastMessage` **同一条 SQL 派生**、无 N+1;`lastMessage` 为**最后一条消息预览** `{content,role(user|assistant),createdAt}`,content 折叠空白截前 60 rune,空会话省略;**`personaId`(v0.42,可选)**=该会话绑定的角色 id(design/22 §2/§4.5),**空/缺省 = 继承全局默认角色**(`owner.yaml:defaultPersona`),客户端据此渲染激活会话的角色名/头像);未注入时退化回渠道会话列表 | | 5 | `/api/v1/sessions` | POST | JWT(owner/admin) | ✅ | **新建次级会话** → `201 {data:{sessionId,personaId?}}`(`sessionId="web:"`)。body **可选**(可完全省略或空 body):`{personaId?}`(v0.42)在创建时就把新会话绑到该角色(design/22 §2:渠道级只作"新会话的初始角色提供者";**会话角色只在创建时写入、之后不可变**,见 §1 行 8 / design/22 §8-1);`personaId` 为**未知角色**回 `400 bad_request`;省略/空 = 不绑(`persona_id=''`,继承全局默认);**`personaId` 非空时响应回显它**(省略时响应形状与 v0.41 完全一致);未注入 HistoryStore 回 `501 not_implemented`;注入了**不支持角色绑定**的 HistoryStore 且显式传 `personaId` 时也回 `501`(不假装写入) | | 6 | `/api/v1/sessions/{id}/messages` | GET | JWT(owner/admin) | ✅ | **热分页拉历史**:`?limit=&before=&platform=&after=`(A5)默认 `limit=50`(上限 200)。**`before`**=上一页最旧 `createdAt`(毫秒),按时间**倒序**;**`after`**=<`createdAt` 毫秒>,按时间**升序**返回 `created_at > after` 的消息(**正向增量**,客户端把上次看到的 `createdAt` 当游标补断线期间漏的,解决重连只回放 50 条的缺口);**`after` 与 `before` 互斥**,同传回 `400`;负数/非数字回 `400`。`platform` 为**可选**来源平台过滤(design/16 §4.4)——Web 端不传(=全量共享主会话),IM 适配器传**本平台**(如 `telegram`)只拉本渠道消息;`deviceId` 为**客户端具体端标识**;`senderName` 为**第三方发送者昵称**;**`personaId`(v0.42,可选)**=**该消息说话时的角色 id**(design/22 §7-5:逐条记录"谁说的"——会话角色在创建时固定,§8-1;assistant 消息 = 生成它的角色,user 消息 = 当回合角色;**空 = 全局默认角色**,即 v0.42 之前的全部历史消息),客户端据此按角色渲染名字/头像;**`clientMsgId`(v0.56,可选,G4)**=该消息的**客户端幂等键**(带 `clientMsgId` 发的 user 消息会回填它;空则省略),客户端据此把本地乐观气泡对齐到服务端真相;**`q`**(D2)=**会话内内容搜索**(大小写不敏感 ASCII LIKE,`%`/`_`/`\` 已转义为字面量),仍按时间**倒序**、`before`/`limit` 同分页语义;`q` 与 `after` **互斥**(同传 `400`),空 `q` 视为未传(走普通分页),搜索**严格限定在 `{id}` 会话内**(绝不跨会话,群聊/陌生人隔离会话不会外泄);非法 `limit`/`before`/`after` 回 `400`;未注入 HistoryStore 回 `501` | @@ -44,6 +44,8 @@ | 14 | `/api/v1/files/{name}` | GET | 无需(能力URL) | ✅ | **上传文件访问**(B1):从 `/uploads/` 读取,同名端口服务。`name` 仅接受「32 位小写 hex + 白名单扩展名」;**路径穿越全形态拒绝**(`..`/`%2f`/`%2e%2e` 等)。返回 `Content-Type`/`Content-Length`/`Cache-Control: private,max-age=86400,immutable`/`X-Content-Type-Options: nosniff`(支持 Range)。**设计取舍**:GET 不鉴权——该 URL 是**128-bit 随机能力地址**(`chat.image`/`` 无法带 Bearer),POST 仍 owner/admin+审计;任何持有该 URL 者都能取文件,勿外泄到他人 | | 15 | `/api/v1/meta/enums` | GET | JWT(任意角色) | ✅ | **共享枚举权威表**(B4):`{data:{platforms,moods,modes,unknown}}`,每项 `{id,label}`——`platform`/`mood`/`mode` 的**唯一事实来源**,客户端**不必硬编码**即可拿到稳定映射(来源角标 / 情绪标签不再漂移)。只读、无敏感信息,与 `GET /sessions` 同级(owner/admin/viewer 均可读;无 token 回 `401`)。详见 §1.1 | +| 16 | `/api/v1/documents` | POST | JWT(owner/admin) | ✅ | **文档上传**(design/24 §3,**v0.59**):multipart/form-data,字段 `file` → `201 {data:{name,url,mime,size,sha256,deduped:false}}`;**同字节同扩展名重复上传 → `200` + 同一 `name` + `deduped:true`**(可当成功处理)。单文件上限 **`CHAT_DOCUMENT_MAX_BYTES`(默认 20 MiB,`≤0` 视为用默认)**。类型裁决 = **字节嗅探(唯一裁决)∧ 扩展名白名单(22 项)**;`mime` 为**嗅探基类型**(`data.json` → `text/plain`);`url` 为**相对路径** `/api/v1/documents/<32hex>.`。错误码:`415 unsupported_media_type` / `413 document_too_large` / `400 bad_request`(缺 `file` / 空文件)。详见 §1.2 | +| 17 | `/api/v1/documents/{name}` | GET | **JWT(owner/admin)** | ✅ | **文档读取**(**v0.59**):**必须带 Bearer**——与图片读侧「128-bit 随机名即凭据、免鉴权」**刻意相反**;`name` 仅接受「32 位小写 hex + 白名单扩展名」,**路径穿越全形态拒绝**;响应带 `Content-Disposition: attachment` + `X-Content-Type-Options: nosniff` + `Cache-Control: private,max-age=86400,immutable`;嗅探不出放行类型时下发 `application/octet-stream`;名字非法 / 逃出目录 / 文件不存在统一回 `404 document_not_found`(不泄露差异)。详见 §1.2 | > **语音 REST 源(`/api/v1/audio/*`,均 owner-only + 审计)**:原生/移动端用它上传/获取音频,**绕开 WS 4KB 帧限**。 > - `POST /api/v1/audio/transcribe`:body `{audio:"", format?:"wav", deviceId?}` → `200 {data:{text}}`。`audio` 为 base64(或 data: URI);`format` 为编码(`wav`/`mp3`/`ogg`,缺省 `wav`);空 audio 回 `400`;未注入语音服务 `500 voice_unavailable`、识别失败 `500 stt_failed`。 @@ -112,7 +114,88 @@ - Web 端镜像实现在 `frontend/web/src/lib/enums.ts`(`labelFor(kind, value)` 对未知值返回原值),权威仍以本端点为准。 - **反漂移保证(后端)**:后端有测试断言「每个内置适配器的 `Name()` 都在 `platforms` 表内」「`moods` 与 persona 情绪状态机的内置状态一致」「`modes` 与前端 `MODES` 九模式一致」——漏登记即 CI FAIL,客户端拿到的表不会滞后于代码。 -> **契约版本 `v0.58`**(**新增 `.env` 键 `LLM_MULTIMODAL`(`bool`,默认 `false`):让 env-only 部署能声明「主模型自身能否收图」**,依据裁决 14.4.1 / design/06 §0(commit `4adfc9a`),2026-10-04 落实):**A. 新键语义(见 §2「模型配置契约」表)**——**`LLM_MULTIMODAL` 是 `models.json` 组 `multimodal` 的 env 等价物**,**只作用于 `.env` 合成组**(**无有效 `models.json` 时才生效**;有组文件时以该组的 `multimodal` 为准,与 `LLM_REASONING_KIND` 同口径):`true` = 图片**直进主模型上下文**(省掉工具那一轮模型调用、也不做有损文本摘要),**留空 / `false`(默认)= 把图交给 vision/OCR 工具换成文本描述**;**绝不凭 base URL / 模型名猜测**端点是否收图(声称端点不具备的能力,比把图交给工具更糟——工具至少回一段文本,design/06 §0 首条)。**B. 与 `LLM_VISION_MODEL` / `LLM_OCR_MODEL` 的关系**——后两者配的是**视觉/OCR 工具调用的模型**,**不代表主模型能收图**,与本键互不代表(见 §1 行 3)。**C. 兼容性(默认值即旧行为)**——默认 `false` ⇒ **老部署行为逐字不变**(引入本键之前,env-only 部署没有任何键能声明该能力,图片恒走 vision 工具);**非 bool 值(如 `maybe`)在配置解码阶段直接报错、启动失败**(fail-fast,**不静默回退成 `false`**——静默的「配了当没配」正是本键要消灭的)。**`LLM_MULTIMODAL=true` 后图片走主模型直收,属部署者显式选择**(形状由部署者声明,后端不替它猜)。**D. 实现落点**——`internal/config/config.go`(`LLMConfig.Multimodal`,默认 `false`)+ `internal/config/runtimeconfig/models.go`(`GroupFromEnv` 把两 provider 分叉都透传进合成组)+ `internal/config/runtimeconfig/multimodal_env_test.go`(默认 false / 显式 true 抵达合成组 / 进程 env 端到端 / 组文件优先 四组测试)。**无端点、无帧、无字段形状变更**(纯新增 env 键)。**上一版 `v0.57`**:(**13.4.5 目标态 steer 落地登记:新下行帧 `steer_ack`**,依据 design/15 §2.0/§2.1/§2.4/§3.x/§5(裁决 13.4.5 目标态,commit `3d8822c`),2026-10-04 落实:**A. 新下行帧 `steer_ack`(见 §3.14)**——`{type:"steer_ack", sessionId, data:{messageId, atStep}}`:忙时会话里新到的用户消息**被在飞回合吃进同一回合**时下发(`messageId` = 那条用户消息的**权威 id**,`atStep` = 吃进它的**模型步序号,1 基**);语义=「**该消息已被本回合吃进(已从 pending 移除)、尚未被正面回答**」⇒ 客户端应把对应气泡渲染为「已读、未答」的**灰态**,直到该回合的 `message.*` / `turn.end` 到达(**不需要第二个确认帧**)。**角色门**:它**携带具体消息身份**(客户端能定位到某一条气泡,而不只是会话忙闲),因此**不登记**进 `frameReachesAll` 的 7 个状态机信号白名单,落 fail-closed 默认档——**只发 `owner` + `admin`**(`viewer` 收不到);经编排器状态通道**由 hub 直接广播、不经 bus**,帧壳带 `sessionId`、**不带** `turnId`/`seq`。**触发时机**:只在**回合内边界**(loop 每一步开头:上一次模型步与它那一批工具调用**都已结束**、下一次模型调用**尚未开始**,此刻没有工具在飞)产生,且**只有该边界真的匹配到 pending 里、且已落库的消息时才发**——**不是每个 step 都发**(没可注入消息的边界一帧都不发,这是常态)。**B. 转向的三条不变量(design/15 §2.0)**——**I1(同会话在途回合数恒为 1)**:转向只注入、不取消,**绝不新开第二个回合**(loop 侧结构性地没有任何「起新回合」的分支,注入只是把消息并进**当前回合**下一次模型调用的视图);**I2(消息恰好被处理一次)**:注入=已消费——被注入的消息立刻从该会话 pending **移除**(按 pending 序号认领),**绝不被第二个回合再答一遍**,去重键=后端 `message.id`,且候选必须**同时**满足「在该会话转录里(最近 20 条窗口内)」与「在本编排器的 pending 里」;**I3(转向不取消已发起的工具)**:注入点只在回合内边界,此刻没有任何工具在飞,正在执行的工具**永远跑完**。**C. 注入的范围(口径)**:注入**只影响本轮视图**(每一步边界重新并入),**不写回运行窗口** ⇒ 既不进该回合的 **checkpoint 快照**、也不进**后台记忆提取的输入面**;被注入的消息**本身早在到达时已落库**(pending 路径即时持久化),注入**不新增、也不改写任何行**。**D. 失败一律让路**:未接转录存储 / 读转录失败 / 该消息尚未落库 / 本回合世代已被打断或被更新的回合取代(认领 `pending` 失败)时,**不发 `steer_ack` 也不注入**——该消息**留在 pending**,照旧作为自己的回合被回答,**绝不静默消失**。**E.** 另含一处**链路图纯增量键**(注入时该轮图的 input 节点带 `props.steered:true`,图仍只有一条主干——不是被画成第二轮),以及**纯新增帧**的兼容性口径(老客户端按未知帧忽略即可)。**上一版 `v0.56`**:(**协同项 A2 / G1 / G2 / G3 / G4 / F3 落地登记:`interrupt` 支持 `scope` + REST `stream:true` 不再静默失败 + `chat.image` 上限 + `clientMsgId` 幂等 + 视觉链路 URL 取回与类型裁决 + 既有错误陈述更正**,依据 `Cyrene-For-Android/devdocs/02-前后端协同事项.md` 的 A2 / G1 / G2 / G3 / G4 / F3(commit `7512d59` 等,2026-10-04 落实):**A. 上行 `interrupt` 新增可选 `scope`(A2)**——`{sessionId}` → `{sessionId, scope?}`,取值域 `{"", "all", "current"}`(**省略 / 空串 / `"all"` 三者同义**):`all` = 取消当前任务 + 清空待处理队列(**逐字不变的现状语义**);**`"current"` = 只取消当前任务、保留待处理队列**(队列消息按编排器正常续跑规则继续执行、带上会话尾部上下文);**其余取值 → 只回发送者** `{type:"error",data:{code:"bad_request"}}`(**定向**:不广播、不打断任何会话,绝不静默按默认执行);命中空闲会话为 no-op。下行 `session_interrupted` 帧形状**零变化**(`{sessionId, mode, dropped}`),但 `dropped` 恒等于**真的被丢弃**的待处理条数——`scope:"current"` 时为 `0`,且 `mode` 是该会话当时的**忙碌通道**(`thinking|executing|subagent`,**不是 `idle`**);`scope:"all"` 时 `mode` 为 `idle`、`dropped` 为清空的 pending 条数。**B. REST `stream:true` 不再静默失败(G2,行为变更)**——该分支改为与 WS `chat` 帧**同一条直达编排器的路径**(design/15 §2.1「会话消息不得被 drop」),**响应面**:受理成功后才回 `200 {data:{stream:true}}` / 正忙已续接排队 `202 {data:{stream:true,queued:true,queueDepth:n}}` / 队列满 `409 {error:{code:"chat_busy"}}` / 幂等键重复 `200 {data:{duplicate:true,messageId,sessionId}}`;**未接 loop → `500 {error:{code:"chat_unavailable"}}`**(旧文写 `200 {stream:true}`,现与同步路径同口径);仅**未接编排器**才回退总线派发,其失败回 `503 {error:{code:"chat_dispatch_failed"}}`(此前 `Publish` 的错误被吞掉,且传的是**请求作用域** ctx)。**`202`/`409` 从此真的会出现**(修复前因提前 `return 200` 永不出现)。派发用的回合上下文与请求作用域脱钩(`context.WithoutCancel` + 30 分钟上限),回合不会在客户端刚收到 200 时被连带取消。**向后兼容**:`stream:false` 与既有同步响应形状一字未动;依赖「任何情况都拿到 200」的客户端必须按上表分支,别再假设 `stream:true` 恒 `200`。**C. `chat.image` 上限(G3)**——`POST /api/v1/chat` 的 `image` 新增硬上限:**解码后 5 MiB**(与 `POST /api/v1/upload` 同口径;base64 字符串上限 = `ceil(n/3)*4 + 64` 字符,覆盖 data: URI 前缀),超限 → `400 {error:{code:"image_too_large", message}}`(文案指路 `POST /api/v1/upload`),在**任何解码 / 落库 / 入模型之前**拦下(超大 base64 因此不进历史、也不会被重连回放重传)。**事实(勿误读为「WS 也有这条 400」)**:WS `chat` 帧**今天已被传输层关死**——WS 读上限 `wsReadLimit = 4096` 字节/帧,超限直接断连,**到不了**这条判定 ⇒ **WS 上发真实图片本就必须「上传 + URL」**。**D. `clientMsgId` 幂等(G4)**——上行**新增可选** `clientMsgId`(REST `chat` 体与 WS `chat` 帧都支持;**省略 = 老行为逐字不变,老客户端零影响**):同一会话内同一个非空值只承认第一条。**重复到达**:REST → `200 {data:{duplicate:true,messageId,sessionId}}`;WS → 新下行帧 **`chat_duplicate`**(`{type:"chat_duplicate", sessionId, data:{sessionId, clientMsgId, messageId}}`,见 §3.13);重复**不落库、不起回合、不发任何内容 / 流式新帧**。**判重由 SQLite 部分唯一索引原子裁决**(`(session_id, client_msg_id) WHERE client_msg_id != ''`),并发的两次重发只有一个赢家,不存在「先查再插」的竞态窗口。`clientMsgId` 另**回填进消息 DTO** ⇒ `message_persisted` / `channel_persisted` / 握手 `history` 回放与 `GET /sessions/{id}/messages` 等读接口可见(空则省略),客户端据此把本地乐观气泡对齐到服务端真相(替代「靠文本相等」的脆弱匹配)。**迁移**:新增 `0013_chat_client_msg_id`(up:加列 + 部分唯一索引 `idx_chat_messages_client_msg_id`;down:先删索引再删列;全新库由 `history.NewStore` 幂等前滚)。**E. 视觉链路事实修正(F3)**——`chat.image` 的 http(s) URL(含 `/api/v1/files/`)现在由**服务端自己取回并内联为 `data:` URI** 再交 provider,**不再原样转给上游**;同源 upload 产物优先走**本地文件读取**。**类型裁决**:**字节嗅探是唯一裁决权**(声明的 Content-Type / 扩展名只作参考且可被否决),嗅探为文本(HTML / PROXY 错误页 / SVG)或判不定且来源未声明 `image/*` → **明确报错**(`vision.ErrNotImage`),**绝不静默当 PNG**。**对客户端无端点 / 帧 / 字段变更**。**F. 修正既有错误陈述(G1)**——`Docs/api-reference.md` §6.2 帧壳表与 §6.6 曾声称结构化流帧只带 `seq`、没有轮次标识,**是错的**:**v0.36 起**结构化流帧(`message.*` / `thinking.*` / `tool_call.*`)就带 `sessionId` + `turnId`(`broadcastStream` 从 `model.StreamEvent` 取,loop 在回合边界注入),v0.50 起另带 `streamId`;已按实现更正(与本文档 v0.36 条目一致)。**G. 环境变量登记**——`.env.example` 新增 **`OWNER_IDENTITY_INJECTION`**(`owner_only` 默认 / `always` / `off`;**非法值回退 `owner_only` 并记 WARN**),并登记 **`CONTEXT_MAX_TOKENS`**(默认 `32768`;`0` / 负 = 关闭压缩)与 **`GUARDRAIL_KEYWORDS`**(逗号分隔;留空 = 不挂关键词护栏),三键见 §2「安全 / 上下文环境变量(v0.56)」。**无新端点、无帧形状破坏**(唯一新增帧 `chat_duplicate` 为**纯新增**;REST `stream:true` 的响应面属**行为变更**,兼容点已在 B 写明)。**上一版 `v0.55`**:(**阶段 7 落地项登记:路径式安全入口 + 数据目录 `platform` + OneBot v11 四端 + 可观测实时推送**,依据裁决 1.6 / 16.4.7 / 12.4.2(commit `a398dc9`)、1.4 / 12.4.1(commit `9253fef`)、05.4.3 + 16.4.3-WS(commit `0e1f163`),以及 13.4.5 面内三条(commit `74b12d8`)、MCP 官方 SDK(commit `a1083a3`),2026-10-04 落实):**A. 安全入口 `ADMIN_PATH`(裁决 1.6 / 16.4.7,前端路由面)**——① env 新增 **`ADMIN_PATH`**(默认 `admin`):面板**前端**路由前缀 = `/` + 值(`admin` → `/admin`,`xxx/admin` → `/xxx/admin`;**非法值回退 `/admin`**——容忍首尾斜杠与空白,拒绝 `.`/`..` 段与 `?`/`#`)。② HTTP 行为:`/` 与其深链返回**注入了 `` 与 `window.__CYRENE_ADMIN_ENTRY__={"prefix":"..."}` 的 index.html**(`Cache-Control: no-store`);**形如面板入口(路径里出现 `admin` 段)但前缀不符的路径 → `404`**(不回落用户端聊天壳、不泄露正确前缀);`/` 若恰好命中 `dist` 真实文件则仍走文件服务器。③ **API 不藏**:`/api/v1/*`、`/api/v1/admin/*`、`/ws/chat`、webhook 一律原样,**无新端点、无帧变更**。④ **口径**:用户端 HTML **永不携带该前缀串**(后端只在命中前缀时注入)⇒ 用户端既无管理入口链接、也解析不出面板路由(design/07 §0.2;前端路由改**路径式**:`frontend/web/src/admin/router.ts` 只读注入、无硬编兜底、移除 hash 入口)。**B. 数据目录(同 commit,裁决 12.4.2)**——`DATA_DIR` 新增可接受值 **`platform`**(大小写不敏感)→ 平台标准目录;默认 `./data` 与显式路径行为不变;**显式空值行为不变**(仍走原兜底)。**C. OneBot v11 四端(`9253fef`,裁决 1.4 / 12.4.1)**——① 配置面(channels.json 账号 `config` + 适配器 `Config()` 快照)新增 `transport`(`ws`|`http`,默认 `ws`,未知值回落 `ws`)/ `api_base_url` / `report_path`(默认 `/`);快照 camelCase 为 `transport`/`apiBaseURL`/`reportPath`;`remoteURL` 快照值 = `remote_url`、为空时回落 `api_base_url`(语义即「出站远端地址」),**既有键与值不变**。② env 兜底 `CHANNEL_QQ_OBV11_TRANSPORT`(默认 `ws`)/ `CHANNEL_QQ_OBV11_API_BASE_URL` / `CHANNEL_QQ_OBV11_REPORT_PATH`。③ 四端语义:`ws+client` Cyrene 拨号、收发同一 WS;`ws+server` OneBot 反连、收发同一 WS;`http+client` Cyrene→OneBot(`POST {api_base_url}/{action}`,纯出站);`http+server` OneBot→Cyrene(POST 到 `listen_addr`+`report_path`,纯入站,fail-closed 鉴权、10MiB 体上限)。④ 取舍:`mode` 决定**哪一半必需**(server 必需 `listen_addr`、client 必需 `api_base_url`),另一半给齐字段就一并启用 ⇒ 单个适配器即可「收上报 + 回消息」。⑤ 端口例外:`http+server` 用 `listen_addr`,属 `AGENTS.md` §9 已登记的**同一豁免**(design/13 §0 原文即写「WS server / HTTP server」);`listen_addr` 为空则报错、**绝不开端口**。**D. 可观测实时推送(`0e1f163`,裁决 05.4.3 + 16.4.3-WS)——新增一节 §3.12**:三个**下行新帧** `observability_log`(`{entries:[{time,level,source,msg}…]}`,最新在前,上限 200 条)/ `observability_trace`(`{spans:[…]}`,上限 100 条)/ `observability_metrics`(`AdminMetrics` 本体、扁平),均**纯新增**(既有帧名/形状零改动)、帧壳**不带** `sessionId`/`turnId`/`seq`;三者**不在** `frameReachesAll` 白名单 ⇒ fail-closed **只发 owner+admin**(viewer 继续走 REST 只读档);订阅 = owner/admin 凭据**读取** `GET /admin/observability/{logs,metrics,trace}` 任一个(该次读取照常返回完整引导数据),该凭据 WS 连接全断即退订并停循环(**不是轮询、无常驻 goroutine**);**REST 端点完全不变**,`?level=` 分级过滤改由**客户端本地**做(帧广播给所有 `owner`/`admin` 连接,订阅只决定循环跑不跑)。**E. 其余(各一句)**:① **13.4.5 面内三条**(`74b12d8`)——续接窗口 `Recent` 在同毫秒并列时改为取**最新** n 条且按插入序(**无 API 变化**,但客户端可观察到的「握手回放 / 续接上下文」顺序更正确);上行 `chat` 不再经**可丢弃**的 bus(**帧序列不变**);被**取消**回合的 checkpoint 不再顶掉下一条消息,新增内部常量 `loop.StopReasonCanceled`(`checkpoints.state.stop_reason` 可能出现该值,**不经 REST/WS 暴露**)。② **MCP**(`a1083a3`)——协议层换成**官方 Model Context Protocol Go SDK v1.8.0**,**REST/WS/env/数据目录零变更**(`MCP_SERVERS_JSON`、`MCP_*`、`transport` 枚举 `stdio|http|streamable|sse` 全不变)。**未做(诚实边界)**:DashScope 形状的 `auto` 推断、Qwen 的 `/think` 前导消息软开关、`get_group_list` 枚举、上游显式退订帧(当前退订只发生在订阅凭据的连接全断时)、审计 tab 仍 5s REST 轮询(未做 `observability_audit` 帧)。**上一版 `v0.54`**:(**渠道配置写路径改 patch 语义(修 P0 级凭据静默丢失) + 阶段 6 前端批的纯前端行为登记**,依据 design/04 §3.1、design/22 §2 方案 b(与 `personaId` 同构的裁决延续,commit `4ae7148`)与裁决 16.4.1 / 16.4.2 / 16.4.3 / 16.4.4 / 16.4.5 / 16.4.6(commit `f454273` / `b480b04` / `c1a98d7` / `8d70f6d`)):① **`PUT /api/v1/admin/channels/{platform}/config` 的 `config` 由「整条替换」改为「按 id 打补丁」**(`runtimeconfig.PatchChannel`,**与 `personaId` 同构的三态**)——**键省略(该键不在 `config` 里,或 `config` 整体缺席 / 为 `null`)= 保留该账号已存值**、**给出非空值 = 覆盖该键**、**显式 `null` 或 `""` = 清除该键**(`0` / `false` 是真实取值,**不算空**);顶层便捷字段(`token` / `baseURL` / …)是普通 `string`,**只在非空时**并入补丁 ⇒ **要清除 `bot_token` 必须走 `config` 的显式 `null` / `""`**;`allowGroups` / `denyGroups` / `allowUsers` / `denyUsers` 与三个限流键**仍是整值替换**(省略 = 清空)。② **破坏性提示**:按旧文实现、依赖「省略某 `config` 键 = 删掉它」的客户端,必须改为**显式传 `null` / `""`** 才能清除该键。③ **修掉的数据安全问题**:面板只发送用户本次碰过的平台字段,后端却整条替换 ⇒ 保存**微信(iLink)**账号卡会抹掉 `bot_token` / `bot_id` / `owner_id` / `get_updates_buf`(**iLink token 是长期凭据**)、**Telegram** 只填 `webhook_url` 而不重填 token 会让 `cleanAccounts` **丢掉整个账号**。④ **未改**:token 要求型平台**显式**清 token 仍按 `cleanAccounts` 丢账号;「本次一个 `config` 键都没给出」在**非 `weixin-ilink`** 平台仍回 `400`。⑤ **无新端点 / 新字段 / 响应形状变更**。**同版另含阶段 6 前端批(纯前端行为,无 API 变化)**:① **16.4.1 静默/活动态与预约切换**——唯一入口 `frontend/web/src/components/modeSwitch.ts`:她活动时用户切换只登记「已预约」、静默时落地;② **16.4.5 会话级消息缓存**——帧按自己的 `sessionId` 落进该会话的消息桶(切会话不清空),**只有单槽忙态**仍按激活会话判定;`graph_update` 按 `data.sessionId` 入**图桶**,管理端 Flow 页取激活会话、Backthink 页取 `sess-backthink`(裁决 16.4.2);③ **16.4.3 / 16.4.6 虚拟滚动与专属页只读卡**——长列表超阈值窗口化、专属页只读展示该平台完整字段;④ **16.4.4 双档 UI 模式**——性能档 = 现状(默认)、华丽档落 `:root.ui-mode-fancy`,持久化键 `cyrene.ui-mode`(纯前端,对应 `design/07 §0.1`,不改后端契约)。**上一版 `v0.53`**:(**阶段 5 落地:模型目录端点 + per-persona 路由 / 本地三级 / capacity L3 / 视觉·OCR 独立模型 / `image-crop` 工具 / 按 provider 形状下发推理开关**,依据 design/04 §3/§3.1/§3.2(裁决 03.4.1 / 03.4.2 / 03.4.3 / 08.4.1)、design/06 §0/§3(裁决 14.4.1 / 14.4.5)、design/14 §2.2/§3/§4(裁决 14.4.2 / 14.4.3 / 14.4.4),2026-10-04 落实):① **新增管理只读端点 `GET /api/v1/admin/llm/catalog`**(**owner|admin** 读,`?refresh=1` 强制在线刷新)→ `200 {data:{source:"cache"|"models.dev", fetchedAt?, stale?, warning?, providers:[{id,name,api?,doc?,env?,npm?,models:[{id,name,attachment,reasoning,toolCall,contextLimit?,outputLimit?}]}]}}`;**既无本地快照又抓不到** → `503 {error:{code:"catalog_unavailable", message}}`;客户端带 `Accept-Encoding: gzip` 时响应带 `Content-Encoding: gzip`(**信封形状不变**)。**副作用只有一处**:落盘快照 `/llm_catalog.json`(**不含任何凭据**、`data/` 已 gitignore、TTL 24h),**不改 `models.json`**;**抓取只发生在本端点内,不进运行时热路径**(模型请求链路从不读外网目录)。详见 §2「模型配置契约(v0.53)」。② **`models.json` 组级新增键**(载体是组,**可热更新**,未声明时该键不出现):`reasoning: {kind: "auto"|"deepseek"|"openai"|"vllm"|"dashscope"}` 决定该组用哪种**提供商开关形状**下发推理指令(**非法值被 `Group.Validate` 直接拒绝**;anthropic 组上声明 openai 系形状同样拒绝)、`personaModels: {personaId: modelId}` 与 `personaProviders: {personaId: "openai"|"anthropic"}` 为**角色级模型/提供商路由**(**非法 provider 值同样拒绝**;未绑定/未知角色回落该组默认)。③ **语义修正(重要)**:**`multimodal` = 主模型自身能否收图**;`LLM_VISION_MODEL` / `LLM_OCR_MODEL` **只配视觉/OCR 工具用的模型**——两者互不代表(见 §1 行 3)。④ **`.env` 新增 9 键**(见 §2「模型配置契约(v0.53)」表与 `.env.example`):`LLM_REASONING_KIND`(默认空 = `auto`)、`LLM_OCR_MODEL`、`LLM_LOCAL_ENABLED`(默认 `false`)/`LLM_LOCAL_PROVIDER`/`LLM_LOCAL_BASE_URL`/`LLM_LOCAL_API_KEY`/`LLM_LOCAL_MODEL`、`CAPACITY_L3_ENABLED`(默认 `false`)/`CAPACITY_L3_CACHE_TTL`(默认 `5m`)。⑤ **内置工具新增 `image-crop`**(按区域裁切图片:`x`/`y`/`width`/`height` → PNG data URI + 裁切尺寸;供大图分区精读;**策略默认 owner-only**)。⑥ **推理开关按形状下发(旧陈述修正)**:v0.41 的「Qwen 一类只 WARN、请求一字不改」**已作废**——形状由 `reasoning.kind` 声明后**真下发**(→ 字段名映射表见 changelog v0.53 条),**只有形状未知/有歧义时才 WARN 且一个字段都不发**;`reasoning_effort` 的非法值由「降级到最近一档」改为**丢弃并记 WARN(不降档)**,`effort:none` 一律翻成**显式关思考**。**未做**:DashScope 形状的 `auto` 推断(只能在显式声明 `reasoning.kind=dashscope` 时下发,绝不从模型名/BaseURL 猜)、Qwen 的 `/think` 前导消息软开关(与 design 口径冲突,未实现)。**上一版 `v0.52`**:(**工具策略落盘 + 主代理代审小字 + 流程图节点参数摘要**,依据 design/11 §3/§4/§8(裁决 1.3 / 10.4.2 / 13.4.3)与 design/19 §1/§1.1(裁决 11.4.2 / 11.4.3 / 13.4.1),2026-10-04 落实):① **工具策略(`allowInGroup`/`allowInStranger`)落盘 SQLite `tool_policies`(迁移 `0012_tool_policy`),重启保留**——`registry.SetPolicy` **先落库后改内存**,落盘失败则内存保持旧值并返回错误(见 §2 行 5);② 新增下行帧 **`approval_notice`**(§3.11):敏感操作经主代理代审后推一行小字(`explain` 由**后端产出**、客户端原样渲染),审计口径 `who`=角色名 / `what`=`approval.<工具名>` / `result`=`granted|denied`,**仅 `owner` + `admin`**(fail-closed 默认);③ `graph_update` 节点 `props` 新增四类**纯增量可选**键(tool `args_keys`/`args_summary`、guardrail `action`/`allow`/`elapsed_ms`、memory `action`/`scope`/`hits`/`bytes`/`count`/`failed`、orchestration `subagent`/`persona`),`memory`/`guardrail`/`orchestration` 旁路节点 span **首次真进图**,值在发布前已脱敏——**帧名、事件名、顶层字段集一字未动**(见 §3.5)。**上一版 `v0.51`**:(**后台子代理结果登 WS 帧 + 回填父会话**,依据 design/05 §9「子代理完成 → 结果回填主会话并提示用户」,裁决 02.4.3,2026-10-04 落实):新增下行帧 **`subagent_result`**(`{type, sessionId, data:{sessionId, status:"done", summary, agents:[{id, goal, role?, mode?, status, summary?, text?, steps?}]}}`,见 §3.10)——`subagent_spawn` 的批次转后台后结算时,编排层发布在 bus 主题 `subagent.result` 上的合并结果,此前**没有任何消费者**(中继既非 `ws.*` 也不认该 4 段以外的 subject)⇒ 结果上不了前台、只剩进度帧;现在该 subject 被登记为中继并成形为这一帧,**帧壳 `sessionId` = 派生该批次的父会话**,结果因此落回**父会话的实时流**。**角色门**:新帧属内容帧 → **只发 `owner` + `admin`**(fail-closed 默认,未扩 7 个状态机信号白名单)。**向后兼容**:纯新增帧,老客户端按未知帧忽略即可,既有帧/端点/鉴权一律不变。**诚实边界**:结果**不落库**(父会话转录里没有新消息、重连回放不补发)、**未**经 design/15 会话消息编排器续接、**前端尚无该帧 handler**(今天的效果是「帧到达 owner/admin 连接」)。**同版另含管理面(D 组,2026-10-04 落实)**:① **`X-API-Key` 真正接线**(裁决 15.4.2):管理/用户侧已认证端点同时接受该头,命中签发表即通过且**一律 `owner` 级**(裁决 15.4.3 取消 role 分级),带该头时只走 key 分支、未知/已吊销/空值回 `401` 且不回落 Bearer(fail-closed),WS 握手仍只认 Bearer;② **新增主人白名单档案读写** `GET`/`PUT /api/v1/admin/owner-profile`(裁决 10.4.5,**owner-only**,写带审计 `owner.profile.update`)——面板的「保存」从此是**真保存**(落盘 `configs/users/owner.yaml` 0600 原子写 + 热替换活档案,渠道 actor 判定立即生效),不再是只改本地 state 的假成功;③ **persona `identity.systemPrompt` 改必填**(裁决 08.4.2):缺失/空白回 `400 bad_request`,**不再派生默认说明串**,写出的 YAML 必能被载入期同一规则重新载入。见下方「管理面板写操作契约」。**上一版 `v0.50`**:(**事件批:流式帧的结构化补齐**,依据 design/04 §1.1,裁决 01.4.1 / 01.4.2 / 01.4.3 / 03.4.4,2026-10-04 落实):① **`tool_execution` 拆三事件**——旧单帧 `tool_execution.update {id,index,delta}`(phase 与工具名拼在 `delta` 字符串里)被 **`tool_execution.start`**(进入执行,`phase:"running"`)/ **`tool_execution.update`**(运行中进度/局部输出,可多帧)/ **`tool_execution.end`**(完成或失败,`phase:"done"|"error"`)替换,载荷改为结构化 `{tool, phase, id?}`(`tool` = 工具名;`phase` = 细粒度执行阶段,与 §3.5 流程图节点 status 同一套 `running/done/error` 枚举;`id` = provider 工具调用 id,可与 `tool_call.*` 帧关联)——**phase 不再塞进 `delta`**;loop 现已真实发出 start/end(update 的 wire 映射已实现并验证,但工具分发器尚无中途进度上报源,**暂无生产者**,见 §3.1)。② **`streamId`(模型流帧 `data` 内新增可选字段)**:同一次模型流(一次 ChatStream 调用)的多帧共享同一 `streamId`,多步工具轮的第二次模型调用取新 id;与帧壳的 `turnId`(整个回合恒定)分工——`turnId` 标记「这轮回答」,`streamId` 标记「这轮里的哪一条流式消息」。③ **`turn.start` / `turn.end`(新帧)**:loop 在回合边界发出、经 ws_bridge 下行;**无 `data` 载荷**(帧壳的 `sessionId`/`turnId`/`seq` 即边界信号;回合终态信息仍由既有 `stream_end` 携带);`turn.end` 覆盖回合的所有出口(最终答案 / 工具循环安全网终止 / 预算耗尽 / 步骤出错),被静音的内部流程(backthink / 子代理)不发出。④ **向后兼容**:`turn.start/end` 与 `streamId` 为纯新增,老客户端可安全忽略;**破坏性**在旧 `tool_execution.update` 单帧——loop 不再产生它,按旧形状(解析 `delta` 里的 `phase:工具名` 拼串)消费的客户端必须迁移到三事件;现有 Web 前端未消费这些帧,行为不变。⑤ 本批新帧全部落在 v0.48 下行帧门的「仅 `owner` + `admin`」档(fail-closed 默认,无需扩白名单)。⑥ **`graph_update` 会话范围口径修正**(裁决 01.4.5,随实现事实补记):`web` / `web:` / `sess-backthink` 三范围(见 §3.5)。见 §3.1 与 v0.50 变更记录。**上一版 `v0.49`**:(**WS 握手回放与上行帧的角色门(design/11 §7.2)**:补上 §7.1 下行门之外的**同一类缺口的另两处**,对 `viewer` **继续收紧**,`owner` / `admin` 行为不变,**无端点、无字段形状变更**——① **握手 `history` 回放改为仅 `owner` + `admin`**:此前任意角色一连上 `/ws/chat` 就会收到主会话最近 50 条消息正文(`thinking` 已剥离,但**正文本身**就是主人的对话),`viewer` 从此**不收**;② **上行 JSON 帧按角色授权(fail-closed)**:非 `owner`/`admin` 连接发来的**任何**上行 JSON 帧一律拒绝,**定向**(只回发送者)回 `{type:"error",data:{code:"forbidden"}}`,**不广播、不驱动模型、不落库、不返回任何内容**——被拒帧含 `chat`(此前 `viewer` 可**真的驱动模型并落库**)、`interrupt`、`subagent_ask` / `task_output_query`(其应答是**定向**帧、能绕过 §7.1 下行门把子代理上下文/任务输出直接回给 `viewer`)、`voice_input`、`mode_switch`,且**未登记的新上行帧默认拒**;**协议级帧(ping/pong、close)不受影响**。③ **合口径**:`viewer` = **可连接、可读 12 条只读监控 REST(v0.40)、可收 7 个状态机信号帧(v0.48)**;**除此以外 WS 上拿不到任何内容、也发不出任何指令**。见 §3 开头的「上行帧与握手回放的角色门」与 v0.49 变更记录。**上一版 `v0.48`**:(**WS 下行帧的角色门(design/11 §7.1)**:hub 的下行现在是**按帧类型分档**的——只有 7 个**状态机信号**(`session_busy` / `pending_ack` / `pending_drain` / `session_idle` / `session_interrupted` / `pending_full` / `session_read`,**均不含任何内容**)继续发给**所有已认证连接**(含 `viewer`),**其余全部下行帧**(流式内容 `message.*` / `thinking.*` / `tool_call.*` / `tool_execution.update` / `stream_chunk` / `stream_end` / `thinking`;持久化 `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`)**改为仅发 `owner` + `admin`**)。**对 `viewer` 是收紧**:此前它连上 `/ws/chat` 就能收到角色回复正文与推理内容,现在收不到;**`owner` / `admin` 行为不变**;**无端点、无字段形状变更**——`viewer` 的 12 条只读监控 REST 端点(`viewerAuth`,v0.40)与 `/ws/chat` 的连接鉴权都**照旧**;**新帧类型默认只发 owner/admin**(fail-closed)。见 §3 开头的「下行帧的角色门」与 v0.48 变更记录。**上一版 `v0.47`**:(**`graph_update` 子代理泳道根节点新增可选 `props.persona`**:派发子代理时若指定了目标角色,该分支根节点的 `props` 就多一个 `persona` 键,标注它**正在扮演的角色**——用途是让流程图能显示「这个子代理是以谁的身份在替班」,而不只是子代理 id;**纯增量**:不带角色派发时该键不出现,老客户端忽略未知键即可,见 §3.5 与 v0.47 变更记录)。**上一版 `v0.46`**:(**`PATCH /api/v1/sessions/{id}` 的 `personaId` 由「可重新绑定该会话的角色」改为「出现即 `400`」**:**会话角色在创建时确定、之后不可变**,要换角色请新开会话(design/22 §8-1)——**明确拒绝**而非静默忽略/200 空操作;**键出现**(任何取值:`"yuki"` / `""` / `null` / 未知 id / 空白串)一律 `400 bad_request` 且消息点名 `personaId`,该检查**先于**会话存在性检查(未知会话 + `personaId` 也回 `400` 而不是 `404`)。**破坏性**:按 v0.42–v0.45 旧文实现、依赖「PATCH 换角色 / 显式 `""` 解绑」的客户端必须改为**新建会话**选角色;`title`/`pinned` 行为与 `POST /sessions {personaId}`(创建时绑定)**完全不变**,不再有 `501/200 {personaId}` 的 PATCH 路径)。上一版 `v0.45`:`PUT /admin/channels/{platform}/config` 的 `personaId` 由「省略/`""` 都 = 解绑」修订为三态(**省略(或 `null`)= 保留已存绑定**、**显式 `""` = 解绑**、**非空 id = 绑定**,未知角色仍 `400`)。 +### 1.2 文档存储与摄取契约(v0.59,design/24 §3/§4/§6) + +> **一句话**:文档 = 「内容寻址的存储 + 服务端抽取 + 清单/正文注入」三段;**读侧要 Bearer**(与图片读侧刻意相反),客户端只需要**两个端点 + `chat.documents` 一个字段**,外加一个 `documents` 数组 DTO。 + +**① 上传 `POST /api/v1/documents`(owner|admin + 审计 `document.upload`)** + +- 请求:`multipart/form-data`,字段 `file`(单个)。**上限** `CHAT_DOCUMENT_MAX_BYTES`(默认 **20971520 = 20 MiB**;`≤0` 视为用默认,**不存在「禁用上限」的取值**);请求体额外留 1 MiB multipart 余量,**文件本身按确切上限校验两次**(部件大小 + 流式写入时多读 1 字节)。 +- 响应: + + ```json + {"data":{"name":"a1b2…(32位hex).md","url":"/api/v1/documents/a1b2….md","mime":"text/plain","size":12345,"sha256":"<64位hex>","deduped":false}} + ``` + + 首次 `201` + `deduped:false`;**同字节同扩展名重复上传 → `200` + 同一个 `name` + `deduped:true`**(落盘名 = `sha256(bytes)[:32] + ext`,**内容寻址**;客户端原始文件名只用来取扩展名,**永不参与落盘路径**)。**200 可当成功处理**——内容与首次逐字一致。 +- **`url` 是相对路径**(`/api/v1/documents/<32hex>.`),**不是**图片侧那种绝对同源 URL:读侧要 Bearer,贴进 `` 也取不到;客户端真正要用的是 **`name`**(`chat.documents` 只吃它)。 +- **类型裁决(唯一裁决权 = 字节嗅探)**: + - **扩展名白名单(deny-by-default,22 个)**:`.txt .md .markdown .csv .tsv .json .jsonl .yaml .yml .log .ini .cfg .toml .go .ts .tsx .java .c .h .cpp .rs .sql`; + - **字节嗅探**:`http.DetectContentType` 的**基类型必须恰为 `text/plain`**,否则拒; + - 两者是**合取**:`.js` / `.py` 即便嗅探成文本也拒(不在白名单);改扩展名也换不了类型(**嗅探说了算**); + - **明令拒绝**:`text/html`、`image/svg+xml` 与 XML、`application/zip` 及其封装(docx/xlsx)、可执行/脚本(exe/dll/bat/cmd/ps1/js/mjs/sh/py)、判不定者。 + - **`mime` 是嗅探所得基类型**(去掉 `; charset=…`):`data.json` 的 `mime` 是 `text/plain`,**不是** `application/json` —— 客户端按 `name` 的扩展名判语义,别拿 `mime` 当类型。 +- 错误码:`415 unsupported_media_type`(扩展名或嗅探任一不过)/ `413 document_too_large`(文件或 multipart 体超限)/ `400 bad_request`(缺 `file` 部件、空文件、非 multipart)。**审计**:成功 `document.upload`+`ok`、失败 +`error`(成对发生,失败分支不遗漏)。 + +**② 读取 `GET /api/v1/documents/{name}`(owner|admin,无审计)** + +- **必须带 `Authorization: Bearer `**:无 token → `401`、角色不足 → `403`。这是与 `GET /api/v1/files/{name}`(免鉴权能力 URL)的**关键差异**——文档摄取在服务端完成,不需要被 `` / provider 直接消费。 +- `name` 形态 = `32 位小写十六进制 + '.' + 白名单扩展名`;**路径穿越全形态拒绝**(`..` / `%2e%2e` / `%2f` 等)。 +- 响应头:`Content-Type` = **读时重新嗅探**的基类型(嗅不出放行类型 → `application/octet-stream`)、`Content-Disposition: attachment; filename=""`、`X-Content-Type-Options: nosniff`、`Cache-Control: private, max-age=86400, immutable`(内容寻址 ⇒ 名字变则内容变);支持 Range。 +- 错误:`404 document_not_found` —— **名字非法 / 路径逃逸 / 文件不存在三种情况同一响应**(不泄露「是否存在」以外的信息)。 + +**③ `chat.documents`(REST 同步 / REST `stream:true` / WS `chat` 三处同形)** + +```json +{"sessionId":"web","text":"帮我看看这份","documents":[{"name":"a1b2….md"},{"name":"c3d4….csv"}]} +``` + +- **只给 `name`**(服务端存储名)。客户端多带的 `mime` / `url` / `size` **一律被服务端覆盖**(MIME 与大小取自落盘字节);**绝不接受任意 http(s) URL**——不重开 F3 那种「服务端代取 URL」的 SSRF 面。 +- **≤5 条/回合**:超出 → `400 too_many_documents`,且该判定**先于任何文件系统访问**。 +- 名非法 / 穿越 / 文件不存在 → `404 document_not_found`;**任一条不合规都让整个请求被拒**——绝不「丢掉这条附件但照样起回合」(静默丢附件正是 design/24 §4.4 禁止的「传上去了但双方都不知道」)。WS 侧经 `error` 帧带 `data.code` 下发并中止本回合。 +- WS 上行帧仍有 **4096 字节**硬上限 ⇒ 现实中文档走 REST;两处**共用同一份解析与校验**,不会分叉。 +- 附件部件挂在**同一条 user 消息**上;**纯附件回合**(无文本、无图片)同样成立,不会被判成空回合。 + +**④ DTO:`documents` 数组(历史与实时同形)** + +`GET /sessions/{id}/messages`、`message_persisted`、`channel_persisted`、`message_edited`、握手 `history` 回放的 `historyMessage` 上是**同一个键**(同一结构体): + +```json +"documents":[{"name":"a1b2….md","mime":"text/plain","size":12345,"chars":8000,"truncated":false,"status":"extracted"}] +``` + +- **≤5 条**,顺序 = 客户端发送顺序;**0 个附件时该键省略**(不是 `[]`,与引入文档之前的形状一致)。 +- `status` ∈ `extracted`(正文已进上下文)/ `listed`(只给了模型一份附件清单,正文没进上下文)/ `unreadable`(读不到正文)。 +- ⚠️ **`chars` / `truncated` 是「本回合注入侧」事实**:`chars` = **本回合注入**模型上下文的字符数、`truncated` = **注入**是否被截断。**它不是「文件有多少字」、也不是「文件是否被截断」**——全文长度只有 `document-read` 的 `totalChars` 能看到;「抽取期撞上 8 MiB 安全上限」只有 `extractTruncated` 能看到。 +- **不含正文**(结构上保证:正文若从历史回传,每翻一页都要背着它)。 +- ⚠️ **破坏性提示**:旧文档里出现过单数 `document`(对象)的形状,**但那个形状从未随任何已发布契约版本号发布过**(客户端无从实现)⇒ 本版**不留兼容期**——客户端按 `documents`(数组、可缺失)读取即可。 + +**⑤ 内置工具 `document-read`(模型侧工具;客户端一般只需知道它存在)** + +- 注入被截断时,模型可见的清单行给出**续读游标**(`offset=N`),模型用 `{name, offset?, limit?}` 继续读; +- 返回(JSON 文本):`{name, status, offset, limit, totalChars, truncated, nextOffset, extractTruncated?, text}`; + - `limit` 默认 **8000** 字符、上限 **64K** 字符(`≤0` 按缺省、超上限**夹取**); + - `offset` 单位是**字符(rune)**、**0 基**、起点 = sidecar 正文(**含 CSV/TSV 摘要行**)第 0 个字符; + - `offset < 0` 或 `offset > totalChars` → 工具错误(`offset == totalChars` 合法,读到末尾的空片段);`truncated=true` 时 `nextOffset` 是续读起点; + - **`extractTruncated=true` 专指「抽取期撞上 8 MiB sidecar 安全上限」**——此后「正文到此为止」≠「原文件到此为止」(它是与「本回合注入被截断」**完全不同的两件事**)。 +- **只读 sidecar、绝不重抽**:读取不会打乱 `status`/`chars`/`truncated` 的持久统计;原文件在抽取之后被删除也不影响读取。 +- **策略默认 owner-only**(`tool_policies` 无行时 fail-closed):群聊/陌生人**根本调不到**——是安全增益。 +- **失败必须可见**:名非法 / 没有抽取结果 / 抽取结论为 `unreadable` → 明确报错,绝不返回空正文假装成功。 + +**⑥ 与图片链路的边界(一张表)** + +| 面 | 图片(B1) | 文档(v0.59) | +|---|---|---| +| 上传 | `POST /api/v1/upload`,字段 `file`,5 MiB | `POST /api/v1/documents`,字段 `file`,20 MiB(`CHAT_DOCUMENT_MAX_BYTES`) | +| 读侧鉴权 | **无**(128-bit 随机名即能力 URL) | **必须 Bearer(owner\|admin)** | +| 读侧响应 | 按扩展名回 image MIME,可内联 | `Content-Disposition: attachment` + `nosniff`,**永不内联** | +| 响应 `url` | **绝对**同源 URL | **相对路径** | +| 客户端字段 | `chat.image`(base64 / URL) | `chat.documents[].name`(服务端存储名) | +| 去重 | 不适用(每次新随机名) | **内容寻址**:同字节同扩展名 → 同一 `name` | + +> **发现的不一致(如实登记)**:design/24 §9.2 与本题面任务书写「白名单 **23** 项」,但设计所列清单与代码 `documentExtAllowed` 均为 **22** 项(见上表)——**以代码的 22 项为准**。另:design/24 §6.2 的 `document_unreadable`(400) **在代码里没有对应 REST 错误码**,它只以 `document-read` 的工具错误形态存在(见本节 ⑤)。 + +> **契约版本 `v0.59`**(**文档进对话(`F1/F2`)+ 上下文预算按窗口推导(`T1`)+ 语音回合进编排(`G5/C1/C3`)+ `thinking` 可达性口径**,依据 design/24 §3/§4/§6(切片 S1–S5)、design/04 §1.3 + 附录 A.3(主人裁决 2026-10-04)与 `Cyrene-For-Android/devdocs/02-前后端协同事项.md` 的 G5/C1/C3,commit `a8851dc` / `a4789ec` / `20c1edb` / `5b2b385` / `72b95df` / `3a01b4e`,2026-10-04 落实):**A. 文档存储与摄取(两个新端点 + 一个可选请求字段 = 纯增量;DTO 形状变更是破坏性,但单数形状从未发布,故无兼容期)**——① **新增 `POST /api/v1/documents`**(owner|admin,`multipart/form-data`,字段 `file`)→ 首次 `201 {data:{name,url,mime,size,sha256,deduped:false}}`;**同字节同扩展名重复上传 → `200` + 同一 `name` + `deduped:true`**(客户端可当成功处理);单文件上限 **`CHAT_DOCUMENT_MAX_BYTES`(默认 20 MiB = 20971520)**,`≤0` 视为用默认;它与图片上限 `CHAT_IMAGE_MAX_BYTES` **刻意解耦**(改文档上限不会静默改掉图片行为)。② **新增 `GET /api/v1/documents/{name}`**:**必须带 Bearer(owner|admin)**——与图片读侧(免鉴权的能力 URL)**刻意相反**(文档摄取在服务端完成,不需要被 ``/provider 直接消费),响应带 `Content-Disposition: attachment` + `X-Content-Type-Options: nosniff`(浏览器永不内联渲染)。③ **类型裁决(重要)**:**字节嗅探是唯一裁决者**(`http.DetectContentType`,放行的嗅探基类型只有 `text/plain`),**客户端声明的 `Content-Type` 与原始文件名只作参考、可被嗅探否决**;**扩展名白名单 deny-by-default,实际 **22** 个**:`.txt .md .markdown .csv .tsv .json .jsonl .yaml .yml .log .ini .cfg .toml .go .ts .tsx .java .c .h .cpp .rs .sql`(⚠️ design/24 §9.2 写「23 项」但所列清单与代码均为 **22** 项,**以代码 22 为准**,见 §1.2「发现的不一致」);**明令拒绝** `text/html`、SVG/XML、zip 及其封装(docx/xlsx)、可执行/脚本(exe/dll/bat/cmd/ps1/js/mjs/sh/py)——即便 `.js`/`.py` 嗅探成 `text/plain` 也拒(判定 = 扩展名 ∧ 嗅探的**合取**)。④ **边界(勿与图片混用)**:`url` 是**相对路径**(`/api/v1/documents/<32hex>.`;图片侧是绝对 URL);`mime` 是**嗅探所得基类型**(故 `data.json` 返回 `text/plain`,不是 `application/json`);落盘名 = `sha256(bytes)[:32] + ext`(**内容寻址**,原始文件名永不参与路径 ⇒ 同字节同扩展名天然同名去重)。⑤ **4 个错误码**:`unsupported_media_type`(415) / `document_too_large`(413) / `document_not_found`(404,含名字形态非法与路径穿越) / `too_many_documents`(400)。⑥ **`chat` 请求新增可选 `"documents":[{"name":"<32hex>."}]`**(REST 同步 / REST `stream:true` / WS `chat` **三处同形**):**≤5 条**(超出 `too_many_documents` 400,且**先于任何文件系统访问**);**只收服务端存储名、绝不收 URL**(不重开 F3 那种「服务端代取 URL」的 SSRF 面);客户端多带的 `mime/url/size` **一律被服务端覆盖**(MIME/大小取自落盘字节);名非法/穿越/不存在 → `document_not_found`(404);**任一条不合规都让整个请求被拒**,绝不「丢掉这条附件但照样起回合」(WS 侧经 `error` 帧带 `data.code` 下发并中止本回合,REST 侧回对应状态码)。⑦ **响应 / 历史 DTO 由 `document`(单对象)改为 `documents`(数组,≤5)**——元素形状 `{"name","mime","size","chars","truncated","status"}`,`status ∈ extracted|listed|unreadable`;**0 个附件时该键省略**;同一 DTO 供 `GET /sessions/{id}/messages`、`message_persisted`、`message_edited`、WS 握手回放 **四处同时生效**(同一个 `historyMessage` 结构)。⚠️ **原单数形状从未发布过**(不在任何已发布版本号里)⇒ **无兼容期**,直接换数组(design/24 §6.1 那句 `"document":{…}` 已随之作废)。⚠️ **`chars`/`truncated` 改为「本回合注入侧」语义**:`chars` = **本回合注入**模型上下文的字符数、`truncated` = **注入**是否被截断(**不是**「原文件是否被截断」);**全文长度**只有 `document-read` 的 `totalChars` 能看到。⑧ **内置工具 `document-read`**(模型侧工具,客户端一般只需知道它存在):`{name, offset?, limit?}` → `{name,status,offset,limit,totalChars,truncated,nextOffset,text,extractTruncated?}`;`limit` 默认 **8000** 字符、上限 **64K**;`offset` 单位是**字符(rune)**、0 基、起点 = sidecar 正文(含 CSV/TSV 摘要行)第 0 个字符;`offset<0` 或 `>totalChars` → 错误(`==totalChars` 合法,读到末尾空片段);`extractTruncated=true` **专指**「撞上 8 MiB sidecar 安全上限」(≠ 本回合注入被截断);策略默认 **owner-only**(群聊/陌生人天然读不到主人的文件)。**B. 配置面(纯增量 + 一处语义变更)**——① **`models.json` 组级新增 `contextWindow`**(正整数 = 该组模型的上下文窗口;`0`/省略 = 未声明 ⇒ 后端按 **131072** 保守估算并记**恰好一条** WARN;**负数 = 组校验失败**,写端点回 `400`)。 ② **`.env` 新增/变更 4 键**:`LLM_CONTEXT_WINDOW`(**per-group 播种**,等价于 `models.json` 的 `contextWindow`)、`CONTEXT_BUDGET_RATIO`(默认 **2/3**,接受 `2/3` 或 `0.6667`,非法值记 WARN 后回落)、`DOCUMENT_MAX_INJECT_CHARS`(单份附件注入上下文的字符上限;未设 = 按推导出的预算自动算)、**`CONTEXT_MAX_TOKENS` 语义变更(关键)**:**未设置 ⇒ 按窗口推导**(`budget = min(窗口 × 比例, 窗口 − 输出保留)`,输出保留 = `max(32768, 该组 maxTokens)` 且**封顶 `窗口/2`**;**1M 窗口 ⇒ 699050 tokens**);`>0` = 显式覆盖(原样使用);`0`/负 = **关闭压缩**。⇒ **`.env` 里若仍写着 `CONTEXT_MAX_TOKENS=32768`,它就是显式覆盖、推导不生效**(这是设计语义,**不是 bug**;本次已把 `.env.example` 里该行改成注释形式)。③ **`.env` → 面板配置的 materialize 语义(欠账,本次补登记)**:进程**每次启动**跑一次;**面板配置非空 ⇒ 组集合完全由面板决定**(env 不新增、不复活被面板删掉的组);**键级只在面板缺该键时用 env 补**,面板已有的键(含**显式空值 / 显式 `false` / 显式 `null`**)**绝不覆盖**;面板配置缺失/为空时才由 env 播种出合成组;**「面板改一次、重启被打回」是硬约束**(有专门用例钉住);每次启动记「补了哪些 / 跳了哪些」,**凭据绝不进日志**;范围目前**仅 `models.json` 一族**(`channels.json` / `plugins.json` 未纳入)。④ **面板读写面**:`GET /api/v1/admin/llm` 的组 DTO **含 `contextWindow`**(`omitempty`,与 `maxTokens` 同形:未声明时该键省略),`POST /api/v1/admin/llm` **接受可选 `contextWindow`**——**缺失 = 保留已存窗口**、**显式 `0` 与缺失同义**(都 = 未声明)、**负值 `400 bad_request` 且不落盘**;面板 LLM 页据此显示/编辑该键。**C. 语音(有行为变更,见 §3.15)**——语音轮次**现在走与文本同一条路径**:会**落库**(用户消息 = 转写文本,助手消息 = 回复)、会发 `message_persisted` 与流式帧、**跨端同步**、忙时按编排器既有规则**排队**(`pending_ack`/`pending_drain`/`session_busy` 语义零变化,只是多了一个消费者);`deviceId` 照旧落库、`platform` 走存储默认 `web`;`voice_input` 上行增可选 `clientMsgId`(与 `chat` 帧同形 ⇒ 语音也有幂等;重复到达回既有 `chat_duplicate`,**不产生新回合、不发音频**);`voice_output`(定向)载荷增 `sessionId`(恒有)与 `deviceId`(非空才有);**空正文**与 **TTS 失败/降级**都走既有 `provider` 降级信号(客户端据此提示「没生成声音」;**不再**把 `stopReason` 当语音内容);**时点**:`voice_output` 在**该回合结算时**才到(可能晚于若干状态帧,甚至由续跑回合或被 steer 注入的那一回合产生);定向目标是**发起 `voice_input` 的那条连接本身**(服务端**不按 deviceId 路由**);连接断开后**不补投**,客户端重连后带上同一 `clientMsgId` 可安全重试。**D. `G5`(纯增量:广播帧 +2 个可选键;新增一条定向错误帧,见 §3.2)**——① **`pending_full` 广播载荷**增 `clientMsgId?` / `messageId?`:前者 = **被拒那条消息**的幂等键(未携带则**整个键缺席**);后者**仅当该消息此前已落库/已认领时**才回填——**服务端绝不在此处新铸 id**(队列满的拒绝发生在幂等键认领之前,代铸会让客户端重试被判成「重复到达」而永远进不来)。② **新增定向帧(此前无文档登记)**:`{type:"error", data:{code:"chat_busy", message, sessionId, clientMsgId?, messageId?}}`,**只发发送者那条连接**、不广播;`code` 与 REST `409 chat_busy` **同码**(客户端一个 code 覆盖两条入口)。**E. `E3`:`thinking` 可达性口径(不改行为,只是把话说清)**——消息体的 **`thinking` 字段**在**实时帧**(`message_persisted` / `channel_persisted` / `message_edited`)与**握手回放**中**一律为空**(隐私与带宽的**有意**默认;实时思维链仍由 §3.1 的 `thinking.*` 流帧按 owner/admin 档下发);**历史消息的思维链**只有 `GET /sessions/{id}/messages`(owner|admin)返回。**上一版 `v0.58`**:(**新增 `.env` 键 `LLM_MULTIMODAL`(`bool`,默认 `false`):让 env-only 部署能声明「主模型自身能否收图」**,依据裁决 14.4.1 / design/06 §0(commit `4adfc9a`),2026-10-04 落实):**A. 新键语义(见 §2「模型配置契约」表)**——**`LLM_MULTIMODAL` 是 `models.json` 组 `multimodal` 的 env 等价物**,**只作用于 `.env` 合成组**(**无有效 `models.json` 时才生效**;有组文件时以该组的 `multimodal` 为准,与 `LLM_REASONING_KIND` 同口径):`true` = 图片**直进主模型上下文**(省掉工具那一轮模型调用、也不做有损文本摘要),**留空 / `false`(默认)= 把图交给 vision/OCR 工具换成文本描述**;**绝不凭 base URL / 模型名猜测**端点是否收图(声称端点不具备的能力,比把图交给工具更糟——工具至少回一段文本,design/06 §0 首条)。**B. 与 `LLM_VISION_MODEL` / `LLM_OCR_MODEL` 的关系**——后两者配的是**视觉/OCR 工具调用的模型**,**不代表主模型能收图**,与本键互不代表(见 §1 行 3)。**C. 兼容性(默认值即旧行为)**——默认 `false` ⇒ **老部署行为逐字不变**(引入本键之前,env-only 部署没有任何键能声明该能力,图片恒走 vision 工具);**非 bool 值(如 `maybe`)在配置解码阶段直接报错、启动失败**(fail-fast,**不静默回退成 `false`**——静默的「配了当没配」正是本键要消灭的)。**`LLM_MULTIMODAL=true` 后图片走主模型直收,属部署者显式选择**(形状由部署者声明,后端不替它猜)。**D. 实现落点**——`internal/config/config.go`(`LLMConfig.Multimodal`,默认 `false`)+ `internal/config/runtimeconfig/models.go`(`GroupFromEnv` 把两 provider 分叉都透传进合成组)+ `internal/config/runtimeconfig/multimodal_env_test.go`(默认 false / 显式 true 抵达合成组 / 进程 env 端到端 / 组文件优先 四组测试)。**无端点、无帧、无字段形状变更**(纯新增 env 键)。**上一版 `v0.57`**:(**13.4.5 目标态 steer 落地登记:新下行帧 `steer_ack`**,依据 design/15 §2.0/§2.1/§2.4/§3.x/§5(裁决 13.4.5 目标态,commit `3d8822c`),2026-10-04 落实:**A. 新下行帧 `steer_ack`(见 §3.14)**——`{type:"steer_ack", sessionId, data:{messageId, atStep}}`:忙时会话里新到的用户消息**被在飞回合吃进同一回合**时下发(`messageId` = 那条用户消息的**权威 id**,`atStep` = 吃进它的**模型步序号,1 基**);语义=「**该消息已被本回合吃进(已从 pending 移除)、尚未被正面回答**」⇒ 客户端应把对应气泡渲染为「已读、未答」的**灰态**,直到该回合的 `message.*` / `turn.end` 到达(**不需要第二个确认帧**)。**角色门**:它**携带具体消息身份**(客户端能定位到某一条气泡,而不只是会话忙闲),因此**不登记**进 `frameReachesAll` 的 7 个状态机信号白名单,落 fail-closed 默认档——**只发 `owner` + `admin`**(`viewer` 收不到);经编排器状态通道**由 hub 直接广播、不经 bus**,帧壳带 `sessionId`、**不带** `turnId`/`seq`。**触发时机**:只在**回合内边界**(loop 每一步开头:上一次模型步与它那一批工具调用**都已结束**、下一次模型调用**尚未开始**,此刻没有工具在飞)产生,且**只有该边界真的匹配到 pending 里、且已落库的消息时才发**——**不是每个 step 都发**(没可注入消息的边界一帧都不发,这是常态)。**B. 转向的三条不变量(design/15 §2.0)**——**I1(同会话在途回合数恒为 1)**:转向只注入、不取消,**绝不新开第二个回合**(loop 侧结构性地没有任何「起新回合」的分支,注入只是把消息并进**当前回合**下一次模型调用的视图);**I2(消息恰好被处理一次)**:注入=已消费——被注入的消息立刻从该会话 pending **移除**(按 pending 序号认领),**绝不被第二个回合再答一遍**,去重键=后端 `message.id`,且候选必须**同时**满足「在该会话转录里(最近 20 条窗口内)」与「在本编排器的 pending 里」;**I3(转向不取消已发起的工具)**:注入点只在回合内边界,此刻没有任何工具在飞,正在执行的工具**永远跑完**。**C. 注入的范围(口径)**:注入**只影响本轮视图**(每一步边界重新并入),**不写回运行窗口** ⇒ 既不进该回合的 **checkpoint 快照**、也不进**后台记忆提取的输入面**;被注入的消息**本身早在到达时已落库**(pending 路径即时持久化),注入**不新增、也不改写任何行**。**D. 失败一律让路**:未接转录存储 / 读转录失败 / 该消息尚未落库 / 本回合世代已被打断或被更新的回合取代(认领 `pending` 失败)时,**不发 `steer_ack` 也不注入**——该消息**留在 pending**,照旧作为自己的回合被回答,**绝不静默消失**。**E.** 另含一处**链路图纯增量键**(注入时该轮图的 input 节点带 `props.steered:true`,图仍只有一条主干——不是被画成第二轮),以及**纯新增帧**的兼容性口径(老客户端按未知帧忽略即可)。**上一版 `v0.56`**:(**协同项 A2 / G1 / G2 / G3 / G4 / F3 落地登记:`interrupt` 支持 `scope` + REST `stream:true` 不再静默失败 + `chat.image` 上限 + `clientMsgId` 幂等 + 视觉链路 URL 取回与类型裁决 + 既有错误陈述更正**,依据 `Cyrene-For-Android/devdocs/02-前后端协同事项.md` 的 A2 / G1 / G2 / G3 / G4 / F3(commit `7512d59` 等,2026-10-04 落实):**A. 上行 `interrupt` 新增可选 `scope`(A2)**——`{sessionId}` → `{sessionId, scope?}`,取值域 `{"", "all", "current"}`(**省略 / 空串 / `"all"` 三者同义**):`all` = 取消当前任务 + 清空待处理队列(**逐字不变的现状语义**);**`"current"` = 只取消当前任务、保留待处理队列**(队列消息按编排器正常续跑规则继续执行、带上会话尾部上下文);**其余取值 → 只回发送者** `{type:"error",data:{code:"bad_request"}}`(**定向**:不广播、不打断任何会话,绝不静默按默认执行);命中空闲会话为 no-op。下行 `session_interrupted` 帧形状**零变化**(`{sessionId, mode, dropped}`),但 `dropped` 恒等于**真的被丢弃**的待处理条数——`scope:"current"` 时为 `0`,且 `mode` 是该会话当时的**忙碌通道**(`thinking|executing|subagent`,**不是 `idle`**);`scope:"all"` 时 `mode` 为 `idle`、`dropped` 为清空的 pending 条数。**B. REST `stream:true` 不再静默失败(G2,行为变更)**——该分支改为与 WS `chat` 帧**同一条直达编排器的路径**(design/15 §2.1「会话消息不得被 drop」),**响应面**:受理成功后才回 `200 {data:{stream:true}}` / 正忙已续接排队 `202 {data:{stream:true,queued:true,queueDepth:n}}` / 队列满 `409 {error:{code:"chat_busy"}}` / 幂等键重复 `200 {data:{duplicate:true,messageId,sessionId}}`;**未接 loop → `500 {error:{code:"chat_unavailable"}}`**(旧文写 `200 {stream:true}`,现与同步路径同口径);仅**未接编排器**才回退总线派发,其失败回 `503 {error:{code:"chat_dispatch_failed"}}`(此前 `Publish` 的错误被吞掉,且传的是**请求作用域** ctx)。**`202`/`409` 从此真的会出现**(修复前因提前 `return 200` 永不出现)。派发用的回合上下文与请求作用域脱钩(`context.WithoutCancel` + 30 分钟上限),回合不会在客户端刚收到 200 时被连带取消。**向后兼容**:`stream:false` 与既有同步响应形状一字未动;依赖「任何情况都拿到 200」的客户端必须按上表分支,别再假设 `stream:true` 恒 `200`。**C. `chat.image` 上限(G3)**——`POST /api/v1/chat` 的 `image` 新增硬上限:**解码后 5 MiB**(与 `POST /api/v1/upload` 同口径;base64 字符串上限 = `ceil(n/3)*4 + 64` 字符,覆盖 data: URI 前缀),超限 → `400 {error:{code:"image_too_large", message}}`(文案指路 `POST /api/v1/upload`),在**任何解码 / 落库 / 入模型之前**拦下(超大 base64 因此不进历史、也不会被重连回放重传)。**事实(勿误读为「WS 也有这条 400」)**:WS `chat` 帧**今天已被传输层关死**——WS 读上限 `wsReadLimit = 4096` 字节/帧,超限直接断连,**到不了**这条判定 ⇒ **WS 上发真实图片本就必须「上传 + URL」**。**D. `clientMsgId` 幂等(G4)**——上行**新增可选** `clientMsgId`(REST `chat` 体与 WS `chat` 帧都支持;**省略 = 老行为逐字不变,老客户端零影响**):同一会话内同一个非空值只承认第一条。**重复到达**:REST → `200 {data:{duplicate:true,messageId,sessionId}}`;WS → 新下行帧 **`chat_duplicate`**(`{type:"chat_duplicate", sessionId, data:{sessionId, clientMsgId, messageId}}`,见 §3.13);重复**不落库、不起回合、不发任何内容 / 流式新帧**。**判重由 SQLite 部分唯一索引原子裁决**(`(session_id, client_msg_id) WHERE client_msg_id != ''`),并发的两次重发只有一个赢家,不存在「先查再插」的竞态窗口。`clientMsgId` 另**回填进消息 DTO** ⇒ `message_persisted` / `channel_persisted` / 握手 `history` 回放与 `GET /sessions/{id}/messages` 等读接口可见(空则省略),客户端据此把本地乐观气泡对齐到服务端真相(替代「靠文本相等」的脆弱匹配)。**迁移**:新增 `0013_chat_client_msg_id`(up:加列 + 部分唯一索引 `idx_chat_messages_client_msg_id`;down:先删索引再删列;全新库由 `history.NewStore` 幂等前滚)。**E. 视觉链路事实修正(F3)**——`chat.image` 的 http(s) URL(含 `/api/v1/files/`)现在由**服务端自己取回并内联为 `data:` URI** 再交 provider,**不再原样转给上游**;同源 upload 产物优先走**本地文件读取**。**类型裁决**:**字节嗅探是唯一裁决权**(声明的 Content-Type / 扩展名只作参考且可被否决),嗅探为文本(HTML / PROXY 错误页 / SVG)或判不定且来源未声明 `image/*` → **明确报错**(`vision.ErrNotImage`),**绝不静默当 PNG**。**对客户端无端点 / 帧 / 字段变更**。**F. 修正既有错误陈述(G1)**——`Docs/api-reference.md` §6.2 帧壳表与 §6.6 曾声称结构化流帧只带 `seq`、没有轮次标识,**是错的**:**v0.36 起**结构化流帧(`message.*` / `thinking.*` / `tool_call.*`)就带 `sessionId` + `turnId`(`broadcastStream` 从 `model.StreamEvent` 取,loop 在回合边界注入),v0.50 起另带 `streamId`;已按实现更正(与本文档 v0.36 条目一致)。**G. 环境变量登记**——`.env.example` 新增 **`OWNER_IDENTITY_INJECTION`**(`owner_only` 默认 / `always` / `off`;**非法值回退 `owner_only` 并记 WARN**),并登记 **`CONTEXT_MAX_TOKENS`**(默认 `32768`;`0` / 负 = 关闭压缩)与 **`GUARDRAIL_KEYWORDS`**(逗号分隔;留空 = 不挂关键词护栏),三键见 §2「安全 / 上下文环境变量(v0.56)」。**无新端点、无帧形状破坏**(唯一新增帧 `chat_duplicate` 为**纯新增**;REST `stream:true` 的响应面属**行为变更**,兼容点已在 B 写明)。**上一版 `v0.55`**:(**阶段 7 落地项登记:路径式安全入口 + 数据目录 `platform` + OneBot v11 四端 + 可观测实时推送**,依据裁决 1.6 / 16.4.7 / 12.4.2(commit `a398dc9`)、1.4 / 12.4.1(commit `9253fef`)、05.4.3 + 16.4.3-WS(commit `0e1f163`),以及 13.4.5 面内三条(commit `74b12d8`)、MCP 官方 SDK(commit `a1083a3`),2026-10-04 落实):**A. 安全入口 `ADMIN_PATH`(裁决 1.6 / 16.4.7,前端路由面)**——① env 新增 **`ADMIN_PATH`**(默认 `admin`):面板**前端**路由前缀 = `/` + 值(`admin` → `/admin`,`xxx/admin` → `/xxx/admin`;**非法值回退 `/admin`**——容忍首尾斜杠与空白,拒绝 `.`/`..` 段与 `?`/`#`)。② HTTP 行为:`/` 与其深链返回**注入了 `` 与 `window.__CYRENE_ADMIN_ENTRY__={"prefix":"..."}` 的 index.html**(`Cache-Control: no-store`);**形如面板入口(路径里出现 `admin` 段)但前缀不符的路径 → `404`**(不回落用户端聊天壳、不泄露正确前缀);`/` 若恰好命中 `dist` 真实文件则仍走文件服务器。③ **API 不藏**:`/api/v1/*`、`/api/v1/admin/*`、`/ws/chat`、webhook 一律原样,**无新端点、无帧变更**。④ **口径**:用户端 HTML **永不携带该前缀串**(后端只在命中前缀时注入)⇒ 用户端既无管理入口链接、也解析不出面板路由(design/07 §0.2;前端路由改**路径式**:`frontend/web/src/admin/router.ts` 只读注入、无硬编兜底、移除 hash 入口)。**B. 数据目录(同 commit,裁决 12.4.2)**——`DATA_DIR` 新增可接受值 **`platform`**(大小写不敏感)→ 平台标准目录;默认 `./data` 与显式路径行为不变;**显式空值行为不变**(仍走原兜底)。**C. OneBot v11 四端(`9253fef`,裁决 1.4 / 12.4.1)**——① 配置面(channels.json 账号 `config` + 适配器 `Config()` 快照)新增 `transport`(`ws`|`http`,默认 `ws`,未知值回落 `ws`)/ `api_base_url` / `report_path`(默认 `/`);快照 camelCase 为 `transport`/`apiBaseURL`/`reportPath`;`remoteURL` 快照值 = `remote_url`、为空时回落 `api_base_url`(语义即「出站远端地址」),**既有键与值不变**。② env 兜底 `CHANNEL_QQ_OBV11_TRANSPORT`(默认 `ws`)/ `CHANNEL_QQ_OBV11_API_BASE_URL` / `CHANNEL_QQ_OBV11_REPORT_PATH`。③ 四端语义:`ws+client` Cyrene 拨号、收发同一 WS;`ws+server` OneBot 反连、收发同一 WS;`http+client` Cyrene→OneBot(`POST {api_base_url}/{action}`,纯出站);`http+server` OneBot→Cyrene(POST 到 `listen_addr`+`report_path`,纯入站,fail-closed 鉴权、10MiB 体上限)。④ 取舍:`mode` 决定**哪一半必需**(server 必需 `listen_addr`、client 必需 `api_base_url`),另一半给齐字段就一并启用 ⇒ 单个适配器即可「收上报 + 回消息」。⑤ 端口例外:`http+server` 用 `listen_addr`,属 `AGENTS.md` §9 已登记的**同一豁免**(design/13 §0 原文即写「WS server / HTTP server」);`listen_addr` 为空则报错、**绝不开端口**。**D. 可观测实时推送(`0e1f163`,裁决 05.4.3 + 16.4.3-WS)——新增一节 §3.12**:三个**下行新帧** `observability_log`(`{entries:[{time,level,source,msg}…]}`,最新在前,上限 200 条)/ `observability_trace`(`{spans:[…]}`,上限 100 条)/ `observability_metrics`(`AdminMetrics` 本体、扁平),均**纯新增**(既有帧名/形状零改动)、帧壳**不带** `sessionId`/`turnId`/`seq`;三者**不在** `frameReachesAll` 白名单 ⇒ fail-closed **只发 owner+admin**(viewer 继续走 REST 只读档);订阅 = owner/admin 凭据**读取** `GET /admin/observability/{logs,metrics,trace}` 任一个(该次读取照常返回完整引导数据),该凭据 WS 连接全断即退订并停循环(**不是轮询、无常驻 goroutine**);**REST 端点完全不变**,`?level=` 分级过滤改由**客户端本地**做(帧广播给所有 `owner`/`admin` 连接,订阅只决定循环跑不跑)。**E. 其余(各一句)**:① **13.4.5 面内三条**(`74b12d8`)——续接窗口 `Recent` 在同毫秒并列时改为取**最新** n 条且按插入序(**无 API 变化**,但客户端可观察到的「握手回放 / 续接上下文」顺序更正确);上行 `chat` 不再经**可丢弃**的 bus(**帧序列不变**);被**取消**回合的 checkpoint 不再顶掉下一条消息,新增内部常量 `loop.StopReasonCanceled`(`checkpoints.state.stop_reason` 可能出现该值,**不经 REST/WS 暴露**)。② **MCP**(`a1083a3`)——协议层换成**官方 Model Context Protocol Go SDK v1.8.0**,**REST/WS/env/数据目录零变更**(`MCP_SERVERS_JSON`、`MCP_*`、`transport` 枚举 `stdio|http|streamable|sse` 全不变)。**未做(诚实边界)**:DashScope 形状的 `auto` 推断、Qwen 的 `/think` 前导消息软开关、`get_group_list` 枚举、上游显式退订帧(当前退订只发生在订阅凭据的连接全断时)、审计 tab 仍 5s REST 轮询(未做 `observability_audit` 帧)。**上一版 `v0.54`**:(**渠道配置写路径改 patch 语义(修 P0 级凭据静默丢失) + 阶段 6 前端批的纯前端行为登记**,依据 design/04 §3.1、design/22 §2 方案 b(与 `personaId` 同构的裁决延续,commit `4ae7148`)与裁决 16.4.1 / 16.4.2 / 16.4.3 / 16.4.4 / 16.4.5 / 16.4.6(commit `f454273` / `b480b04` / `c1a98d7` / `8d70f6d`)):① **`PUT /api/v1/admin/channels/{platform}/config` 的 `config` 由「整条替换」改为「按 id 打补丁」**(`runtimeconfig.PatchChannel`,**与 `personaId` 同构的三态**)——**键省略(该键不在 `config` 里,或 `config` 整体缺席 / 为 `null`)= 保留该账号已存值**、**给出非空值 = 覆盖该键**、**显式 `null` 或 `""` = 清除该键**(`0` / `false` 是真实取值,**不算空**);顶层便捷字段(`token` / `baseURL` / …)是普通 `string`,**只在非空时**并入补丁 ⇒ **要清除 `bot_token` 必须走 `config` 的显式 `null` / `""`**;`allowGroups` / `denyGroups` / `allowUsers` / `denyUsers` 与三个限流键**仍是整值替换**(省略 = 清空)。② **破坏性提示**:按旧文实现、依赖「省略某 `config` 键 = 删掉它」的客户端,必须改为**显式传 `null` / `""`** 才能清除该键。③ **修掉的数据安全问题**:面板只发送用户本次碰过的平台字段,后端却整条替换 ⇒ 保存**微信(iLink)**账号卡会抹掉 `bot_token` / `bot_id` / `owner_id` / `get_updates_buf`(**iLink token 是长期凭据**)、**Telegram** 只填 `webhook_url` 而不重填 token 会让 `cleanAccounts` **丢掉整个账号**。④ **未改**:token 要求型平台**显式**清 token 仍按 `cleanAccounts` 丢账号;「本次一个 `config` 键都没给出」在**非 `weixin-ilink`** 平台仍回 `400`。⑤ **无新端点 / 新字段 / 响应形状变更**。**同版另含阶段 6 前端批(纯前端行为,无 API 变化)**:① **16.4.1 静默/活动态与预约切换**——唯一入口 `frontend/web/src/components/modeSwitch.ts`:她活动时用户切换只登记「已预约」、静默时落地;② **16.4.5 会话级消息缓存**——帧按自己的 `sessionId` 落进该会话的消息桶(切会话不清空),**只有单槽忙态**仍按激活会话判定;`graph_update` 按 `data.sessionId` 入**图桶**,管理端 Flow 页取激活会话、Backthink 页取 `sess-backthink`(裁决 16.4.2);③ **16.4.3 / 16.4.6 虚拟滚动与专属页只读卡**——长列表超阈值窗口化、专属页只读展示该平台完整字段;④ **16.4.4 双档 UI 模式**——性能档 = 现状(默认)、华丽档落 `:root.ui-mode-fancy`,持久化键 `cyrene.ui-mode`(纯前端,对应 `design/07 §0.1`,不改后端契约)。**上一版 `v0.53`**:(**阶段 5 落地:模型目录端点 + per-persona 路由 / 本地三级 / capacity L3 / 视觉·OCR 独立模型 / `image-crop` 工具 / 按 provider 形状下发推理开关**,依据 design/04 §3/§3.1/§3.2(裁决 03.4.1 / 03.4.2 / 03.4.3 / 08.4.1)、design/06 §0/§3(裁决 14.4.1 / 14.4.5)、design/14 §2.2/§3/§4(裁决 14.4.2 / 14.4.3 / 14.4.4),2026-10-04 落实):① **新增管理只读端点 `GET /api/v1/admin/llm/catalog`**(**owner|admin** 读,`?refresh=1` 强制在线刷新)→ `200 {data:{source:"cache"|"models.dev", fetchedAt?, stale?, warning?, providers:[{id,name,api?,doc?,env?,npm?,models:[{id,name,attachment,reasoning,toolCall,contextLimit?,outputLimit?}]}]}}`;**既无本地快照又抓不到** → `503 {error:{code:"catalog_unavailable", message}}`;客户端带 `Accept-Encoding: gzip` 时响应带 `Content-Encoding: gzip`(**信封形状不变**)。**副作用只有一处**:落盘快照 `/llm_catalog.json`(**不含任何凭据**、`data/` 已 gitignore、TTL 24h),**不改 `models.json`**;**抓取只发生在本端点内,不进运行时热路径**(模型请求链路从不读外网目录)。详见 §2「模型配置契约(v0.53)」。② **`models.json` 组级新增键**(载体是组,**可热更新**,未声明时该键不出现):`reasoning: {kind: "auto"|"deepseek"|"openai"|"vllm"|"dashscope"}` 决定该组用哪种**提供商开关形状**下发推理指令(**非法值被 `Group.Validate` 直接拒绝**;anthropic 组上声明 openai 系形状同样拒绝)、`personaModels: {personaId: modelId}` 与 `personaProviders: {personaId: "openai"|"anthropic"}` 为**角色级模型/提供商路由**(**非法 provider 值同样拒绝**;未绑定/未知角色回落该组默认)。③ **语义修正(重要)**:**`multimodal` = 主模型自身能否收图**;`LLM_VISION_MODEL` / `LLM_OCR_MODEL` **只配视觉/OCR 工具用的模型**——两者互不代表(见 §1 行 3)。④ **`.env` 新增 9 键**(见 §2「模型配置契约(v0.53)」表与 `.env.example`):`LLM_REASONING_KIND`(默认空 = `auto`)、`LLM_OCR_MODEL`、`LLM_LOCAL_ENABLED`(默认 `false`)/`LLM_LOCAL_PROVIDER`/`LLM_LOCAL_BASE_URL`/`LLM_LOCAL_API_KEY`/`LLM_LOCAL_MODEL`、`CAPACITY_L3_ENABLED`(默认 `false`)/`CAPACITY_L3_CACHE_TTL`(默认 `5m`)。⑤ **内置工具新增 `image-crop`**(按区域裁切图片:`x`/`y`/`width`/`height` → PNG data URI + 裁切尺寸;供大图分区精读;**策略默认 owner-only**)。⑥ **推理开关按形状下发(旧陈述修正)**:v0.41 的「Qwen 一类只 WARN、请求一字不改」**已作废**——形状由 `reasoning.kind` 声明后**真下发**(→ 字段名映射表见 changelog v0.53 条),**只有形状未知/有歧义时才 WARN 且一个字段都不发**;`reasoning_effort` 的非法值由「降级到最近一档」改为**丢弃并记 WARN(不降档)**,`effort:none` 一律翻成**显式关思考**。**未做**:DashScope 形状的 `auto` 推断(只能在显式声明 `reasoning.kind=dashscope` 时下发,绝不从模型名/BaseURL 猜)、Qwen 的 `/think` 前导消息软开关(与 design 口径冲突,未实现)。**上一版 `v0.52`**:(**工具策略落盘 + 主代理代审小字 + 流程图节点参数摘要**,依据 design/11 §3/§4/§8(裁决 1.3 / 10.4.2 / 13.4.3)与 design/19 §1/§1.1(裁决 11.4.2 / 11.4.3 / 13.4.1),2026-10-04 落实):① **工具策略(`allowInGroup`/`allowInStranger`)落盘 SQLite `tool_policies`(迁移 `0012_tool_policy`),重启保留**——`registry.SetPolicy` **先落库后改内存**,落盘失败则内存保持旧值并返回错误(见 §2 行 5);② 新增下行帧 **`approval_notice`**(§3.11):敏感操作经主代理代审后推一行小字(`explain` 由**后端产出**、客户端原样渲染),审计口径 `who`=角色名 / `what`=`approval.<工具名>` / `result`=`granted|denied`,**仅 `owner` + `admin`**(fail-closed 默认);③ `graph_update` 节点 `props` 新增四类**纯增量可选**键(tool `args_keys`/`args_summary`、guardrail `action`/`allow`/`elapsed_ms`、memory `action`/`scope`/`hits`/`bytes`/`count`/`failed`、orchestration `subagent`/`persona`),`memory`/`guardrail`/`orchestration` 旁路节点 span **首次真进图**,值在发布前已脱敏——**帧名、事件名、顶层字段集一字未动**(见 §3.5)。**上一版 `v0.51`**:(**后台子代理结果登 WS 帧 + 回填父会话**,依据 design/05 §9「子代理完成 → 结果回填主会话并提示用户」,裁决 02.4.3,2026-10-04 落实):新增下行帧 **`subagent_result`**(`{type, sessionId, data:{sessionId, status:"done", summary, agents:[{id, goal, role?, mode?, status, summary?, text?, steps?}]}}`,见 §3.10)——`subagent_spawn` 的批次转后台后结算时,编排层发布在 bus 主题 `subagent.result` 上的合并结果,此前**没有任何消费者**(中继既非 `ws.*` 也不认该 4 段以外的 subject)⇒ 结果上不了前台、只剩进度帧;现在该 subject 被登记为中继并成形为这一帧,**帧壳 `sessionId` = 派生该批次的父会话**,结果因此落回**父会话的实时流**。**角色门**:新帧属内容帧 → **只发 `owner` + `admin`**(fail-closed 默认,未扩 7 个状态机信号白名单)。**向后兼容**:纯新增帧,老客户端按未知帧忽略即可,既有帧/端点/鉴权一律不变。**诚实边界**:结果**不落库**(父会话转录里没有新消息、重连回放不补发)、**未**经 design/15 会话消息编排器续接、**前端尚无该帧 handler**(今天的效果是「帧到达 owner/admin 连接」)。**同版另含管理面(D 组,2026-10-04 落实)**:① **`X-API-Key` 真正接线**(裁决 15.4.2):管理/用户侧已认证端点同时接受该头,命中签发表即通过且**一律 `owner` 级**(裁决 15.4.3 取消 role 分级),带该头时只走 key 分支、未知/已吊销/空值回 `401` 且不回落 Bearer(fail-closed),WS 握手仍只认 Bearer;② **新增主人白名单档案读写** `GET`/`PUT /api/v1/admin/owner-profile`(裁决 10.4.5,**owner-only**,写带审计 `owner.profile.update`)——面板的「保存」从此是**真保存**(落盘 `configs/users/owner.yaml` 0600 原子写 + 热替换活档案,渠道 actor 判定立即生效),不再是只改本地 state 的假成功;③ **persona `identity.systemPrompt` 改必填**(裁决 08.4.2):缺失/空白回 `400 bad_request`,**不再派生默认说明串**,写出的 YAML 必能被载入期同一规则重新载入。见下方「管理面板写操作契约」。**上一版 `v0.50`**:(**事件批:流式帧的结构化补齐**,依据 design/04 §1.1,裁决 01.4.1 / 01.4.2 / 01.4.3 / 03.4.4,2026-10-04 落实):① **`tool_execution` 拆三事件**——旧单帧 `tool_execution.update {id,index,delta}`(phase 与工具名拼在 `delta` 字符串里)被 **`tool_execution.start`**(进入执行,`phase:"running"`)/ **`tool_execution.update`**(运行中进度/局部输出,可多帧)/ **`tool_execution.end`**(完成或失败,`phase:"done"|"error"`)替换,载荷改为结构化 `{tool, phase, id?}`(`tool` = 工具名;`phase` = 细粒度执行阶段,与 §3.5 流程图节点 status 同一套 `running/done/error` 枚举;`id` = provider 工具调用 id,可与 `tool_call.*` 帧关联)——**phase 不再塞进 `delta`**;loop 现已真实发出 start/end(update 的 wire 映射已实现并验证,但工具分发器尚无中途进度上报源,**暂无生产者**,见 §3.1)。② **`streamId`(模型流帧 `data` 内新增可选字段)**:同一次模型流(一次 ChatStream 调用)的多帧共享同一 `streamId`,多步工具轮的第二次模型调用取新 id;与帧壳的 `turnId`(整个回合恒定)分工——`turnId` 标记「这轮回答」,`streamId` 标记「这轮里的哪一条流式消息」。③ **`turn.start` / `turn.end`(新帧)**:loop 在回合边界发出、经 ws_bridge 下行;**无 `data` 载荷**(帧壳的 `sessionId`/`turnId`/`seq` 即边界信号;回合终态信息仍由既有 `stream_end` 携带);`turn.end` 覆盖回合的所有出口(最终答案 / 工具循环安全网终止 / 预算耗尽 / 步骤出错),被静音的内部流程(backthink / 子代理)不发出。④ **向后兼容**:`turn.start/end` 与 `streamId` 为纯新增,老客户端可安全忽略;**破坏性**在旧 `tool_execution.update` 单帧——loop 不再产生它,按旧形状(解析 `delta` 里的 `phase:工具名` 拼串)消费的客户端必须迁移到三事件;现有 Web 前端未消费这些帧,行为不变。⑤ 本批新帧全部落在 v0.48 下行帧门的「仅 `owner` + `admin`」档(fail-closed 默认,无需扩白名单)。⑥ **`graph_update` 会话范围口径修正**(裁决 01.4.5,随实现事实补记):`web` / `web:` / `sess-backthink` 三范围(见 §3.5)。见 §3.1 与 v0.50 变更记录。**上一版 `v0.49`**:(**WS 握手回放与上行帧的角色门(design/11 §7.2)**:补上 §7.1 下行门之外的**同一类缺口的另两处**,对 `viewer` **继续收紧**,`owner` / `admin` 行为不变,**无端点、无字段形状变更**——① **握手 `history` 回放改为仅 `owner` + `admin`**:此前任意角色一连上 `/ws/chat` 就会收到主会话最近 50 条消息正文(`thinking` 已剥离,但**正文本身**就是主人的对话),`viewer` 从此**不收**;② **上行 JSON 帧按角色授权(fail-closed)**:非 `owner`/`admin` 连接发来的**任何**上行 JSON 帧一律拒绝,**定向**(只回发送者)回 `{type:"error",data:{code:"forbidden"}}`,**不广播、不驱动模型、不落库、不返回任何内容**——被拒帧含 `chat`(此前 `viewer` 可**真的驱动模型并落库**)、`interrupt`、`subagent_ask` / `task_output_query`(其应答是**定向**帧、能绕过 §7.1 下行门把子代理上下文/任务输出直接回给 `viewer`)、`voice_input`、`mode_switch`,且**未登记的新上行帧默认拒**;**协议级帧(ping/pong、close)不受影响**。③ **合口径**:`viewer` = **可连接、可读 12 条只读监控 REST(v0.40)、可收 7 个状态机信号帧(v0.48)**;**除此以外 WS 上拿不到任何内容、也发不出任何指令**。见 §3 开头的「上行帧与握手回放的角色门」与 v0.49 变更记录。**上一版 `v0.48`**:(**WS 下行帧的角色门(design/11 §7.1)**:hub 的下行现在是**按帧类型分档**的——只有 7 个**状态机信号**(`session_busy` / `pending_ack` / `pending_drain` / `session_idle` / `session_interrupted` / `pending_full` / `session_read`,**均不含任何内容**)继续发给**所有已认证连接**(含 `viewer`),**其余全部下行帧**(流式内容 `message.*` / `thinking.*` / `tool_call.*` / `tool_execution.update` / `stream_chunk` / `stream_end` / `thinking`;持久化 `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`)**改为仅发 `owner` + `admin`**)。**对 `viewer` 是收紧**:此前它连上 `/ws/chat` 就能收到角色回复正文与推理内容,现在收不到;**`owner` / `admin` 行为不变**;**无端点、无字段形状变更**——`viewer` 的 12 条只读监控 REST 端点(`viewerAuth`,v0.40)与 `/ws/chat` 的连接鉴权都**照旧**;**新帧类型默认只发 owner/admin**(fail-closed)。见 §3 开头的「下行帧的角色门」与 v0.48 变更记录。**上一版 `v0.47`**:(**`graph_update` 子代理泳道根节点新增可选 `props.persona`**:派发子代理时若指定了目标角色,该分支根节点的 `props` 就多一个 `persona` 键,标注它**正在扮演的角色**——用途是让流程图能显示「这个子代理是以谁的身份在替班」,而不只是子代理 id;**纯增量**:不带角色派发时该键不出现,老客户端忽略未知键即可,见 §3.5 与 v0.47 变更记录)。**上一版 `v0.46`**:(**`PATCH /api/v1/sessions/{id}` 的 `personaId` 由「可重新绑定该会话的角色」改为「出现即 `400`」**:**会话角色在创建时确定、之后不可变**,要换角色请新开会话(design/22 §8-1)——**明确拒绝**而非静默忽略/200 空操作;**键出现**(任何取值:`"yuki"` / `""` / `null` / 未知 id / 空白串)一律 `400 bad_request` 且消息点名 `personaId`,该检查**先于**会话存在性检查(未知会话 + `personaId` 也回 `400` 而不是 `404`)。**破坏性**:按 v0.42–v0.45 旧文实现、依赖「PATCH 换角色 / 显式 `""` 解绑」的客户端必须改为**新建会话**选角色;`title`/`pinned` 行为与 `POST /sessions {personaId}`(创建时绑定)**完全不变**,不再有 `501/200 {personaId}` 的 PATCH 路径)。上一版 `v0.45`:`PUT /admin/channels/{platform}/config` 的 `personaId` 由「省略/`""` 都 = 解绑」修订为三态(**省略(或 `null`)= 保留已存绑定**、**显式 `""` = 解绑**、**非空 id = 绑定**,未知角色仍 `400`)。 ## 2. REST 端点(管理,`/api/v1/admin/*`) @@ -223,6 +306,29 @@ | `CONTEXT_MAX_TOKENS` | `32768` | **上下文溢出压缩预算**(token,离线估算;默认值与 `loop.DefaultMaxContextTokens` 同源,有测试锁死两者不许漂移)。会话估算上下文超过它时,更早的轮次被摘要成一条前导摘要消息,近期尾部(含尾部工具结果)原样保留;**`0` 或负数 = 关闭压缩**(行为与接线前一致)。 | | `GUARDRAIL_KEYWORDS` | 空 | **内容策略关键词表**(逗号分隔,大小写不敏感的子串匹配)。**留空 = 不挂关键词护栏**(项目不内置任何词表,只有你显式声明过的词才会被拦)。敏感信息(API key / bearer token / 密码 / 私钥)走**阻断档**、请求与响应各有长度上限——这两项是代码内定的安全默认值,**不对外开放配置**(`cmd/cyrene/wire_guardrail.go`)。 | +**上下文窗口与预算契约(v0.59)**(design/04 §1.3 + 附录 A.3 的主人裁决,2026-10-04;判定以 `internal/config/context_budget.go` + `cmd/cyrene/wire_context.go` 为准): + +- **`models.json` 组级新键 `contextWindow`**:正整数 = 该组模型的**上下文窗口**(token)。本仓**没有任何 provider 侧窗口发现代码**,窗口只能由人声明。`0`(省略该键)= **未声明** ⇒ 后端按 **`DefaultModelContextWindow = 131072`** 保守估算推导预算,并记**恰好一条** WARN(提示「声明窗口才能吃满模型的真实窗口」);**负数 = 组校验失败**(写端点回 `400`,载入期同样拒绝)。 + - **面板读写面**:`GET /api/v1/admin/llm` 的组 DTO **含 `contextWindow`**(`omitempty`,未声明时该键不出现——与 `maxTokens` 同形),`POST /api/v1/admin/llm` **接受可选 `contextWindow`**:**缺失 = 保留已存窗口**(面板的局部编辑绝不复位它)、**显式 `0` 与缺失同义**(都 = 未声明)、**负值 `400 bad_request` 且不落盘**。因此面板可以显示并编辑该键,不是「只能手改文件」。 +- **`.env` 4 键**(与 `.env.example` 逐键对齐): + +| 键 | 默认 | 语义 | +|---|---|---| +| `LLM_CONTEXT_WINDOW` | 空(= 未声明) | **per-group 播种**:`models.json` 组 `contextWindow` 的 **env 等价物**(同一个数、同一个含义)。它作用于 `.env` 合成组与 materialize 的播种层;**负数启动报错**。 | +| `CONTEXT_BUDGET_RATIO` | `2/3` | 推导比例,取值域 `(0, 1]`,**分数(`2/3`)与小数(`0.6667`)两种写法都接受**;**非法值记一条带原始取值的 WARN 并回落 `2/3`**(绝不「配了当没配」)。 | +| `DOCUMENT_MAX_INJECT_CHARS` | 空(= 自动) | **单份附件注入上下文**的字符上限。未设 = 按推导出的上下文预算自动算(缺省下限 **64K 字符**);`>0` = 显式覆盖。它与「sidecar 存多少」是**两个数**:sidecar 存抽取全文(只受 **8 MiB** 安全上限约束),本键只决定每回合注入多少 ⇒ **截断发生在装配期**,清单行的续读游标因此真是「继续读的起点」。 | +| `CONTEXT_MAX_TOKENS` | **未设置**(≠ `0`) | **三态(v0.59 语义变更)**:**未设置 ⇒ 按窗口推导**;`>0` ⇒ **显式覆盖**(原样使用);`0` / 负数 ⇒ **关闭压缩**。「未设置」与「显式 `0`」必须区分(按环境变量 / `.env` 的**存在性**判定,不靠 0),因为 `0` 已被「关闭压缩」占用。 | + +- **推导式**:`budget = min(窗口 × 比例, 窗口 − 输出保留)`;**输出保留 = `max(32768, 该组 maxTokens)`,并封顶为 `窗口/2`**(组把 `maxTokens` 声明得比窗口还大时,不让「窗口 − 输出」退化到 0 或负数)。**1M 窗口 ⇒ `699050` tokens**(`1048576 × 2/3` 取整)。 +- ⚠️ **`CONTEXT_MAX_TOKENS=32768` 属「显式覆盖」**:`.env` 里若仍写着这个数(旧模板的默认值取自「gpt-4o 的 128K 窗口 ÷ 4」,该前提已随默认模型 1M 窗口失效),**推导不生效**——这是设计语义,**不是 bug**。要吃到推导就**删掉 / 注释掉该行**(本次 `.env.example` 已把该行改成注释形式,并在旁注写明旧值来历)。**本次不改客户端的任何行为**:它只是一个 env 键的语义说明。 +- **`.env` → 面板配置的 materialize 语义(v0.59 补登记的欠账,此前未成文)**:进程**每次启动**跑一次(`runtimeconfig.Materialize`,在模型栈构建之前): + - **组级**:面板配置**非空** ⇒ **组集合完全由面板决定**(env 不新增组、**不复活**被面板删掉的组,也不删面板的组);面板配置**缺失 / 空文件 / 空数组** ⇒ 由 env 播种出合成组并落盘(让面板从此有一份一等来源)。 + - **键级**:**只在面板缺该键时用 env 补**;面板**已有**该键(含**显式空值 / 显式 `false` / 显式 `null`**)⇒ **面板优先,绝不覆盖**(靠「原始键是否出现」区分「从未写过」与「显式清空」——类型化解码做不到这件事)。 + - **硬约束**:「面板改一次、重启被打回」**绝不成立**(有专门用例钉住)。无改动时**一个字节都不动**(幂等)。 + - **日志**:补齐写 Info(补了哪几个键、来源 env),因面板优先而跳过的写 Debug;**只打组 id 与键名,`apiKey` 等凭据绝不进日志**。 + - **范围**:目前**仅 `models.json` 一族**(`channels.json` / `plugins.json` **未纳入**)。 + - **与面板保存路径的分工**:materialize **只在启动时**跑;面板保存走「整组写入 + 热重载」,**那里不跑补齐**——否则面板刚显式清空的键会被立刻补回。 + **管理面板写操作契约**(`POST/PUT/DELETE /api/v1/admin/*`,均 **owner-only**,读为 owner/admin;写操作经 audit 落 `audit_logs`;成功 `{data}` / 失败 `{error:{code,message}}`): - `POST /admin/personas` body `{id,name,role,systemPrompt,characterRef?,modes?/mode?,memoryNamespace?,thinking?}` → `201 {data:PersonaSummary}`(**v0.43 起与读端点同形状:含 `mood` + `thinking?`**;注册进 persona registry + 持久化到 `configs/personas/.yaml`);id 已存在回 `409 duplicate`。**`systemPrompt` 自 v0.51 起必填**(落的 YAML 键是 `identity.systemPrompt`):**缺失/空白 → `400 bad_request`**(消息 `"systemPrompt is required (identity.systemPrompt must not be empty)"`),**不再派生默认说明串**回落——载入期(`loader.Load`)执行同一规则,因此写出的 YAML 必能被重新载入。 - `PUT /admin/personas/{id}` body 同上(不含 id)→ `200 {data:PersonaSummary}`(**v0.43 起与读端点同形状:含 `mood` + `thinking?`**,面板可直接用它回填);不存在回 `404`。**`systemPrompt` 与创建同一把门**:v0.51 起同样必填,缺失/空白回 `400 bad_request`(同一条消息)——**不派生默认串、也不静默沿用旧值**,请求被拒即不落盘。 @@ -242,7 +348,7 @@ - `POST /admin/automations` body `{name,kind:"cron|event",expression}` → `201 {data:{id,name,kind}}`(真实注册规则);无效 cron/规则回 `400 invalid_cron|invalid_rule`。 - `POST /admin/automations/{id}/run` → `200 {data:{id,output}}`(手动触发);`404`。 - `DELETE /admin/automations/{id}` → `200 {data:{id,deleted:true}}`;`404`。 -- `POST /admin/llm` body `{id?,provider?,baseUrl,apiKey?,model,multimodal?,fallback?,maxTokens?,reasoning?,personaModels?,personaProviders?}` → `201 {data:{provider:{id,name,provider,baseUrl,model,multimodal,enabled,fallback?,maxTokens?,reasoning,personaModels?,personaProviders?}, persisted:true, path}}`。**真实持久化**:读-改-写 `models.json`(本地、gitignore),保存组提升为**生效组**(index 0),并**热更新**运行中的 LLM provider(`path` 为落盘文件路径);API key 写入文件但**永不回显/不落日志**。`provider` 为空默认 `openai`;缺 `baseUrl`/`model` 回 `400`;`maxTokens` 为负回 `400`;未注入 LLM stack 回 `500 llm_unavailable`、写盘失败 `500 llm_persist_failed`、热重载失败 `500 llm_reload_failed`。`maxTokens` 缺省/`0` = 未设(回落 `LLM_FALLBACK_MAX_TOKENS` / 适配器默认)。`.env` 为兜底。**v0.53 新增三键**(`reasoning.kind` / `personaModels` / `personaProviders`,语义与校验见上方「模型配置契约(v0.53)」):三者**缺省 = 保留已存值**,给出则 `reasoning` 整块替换、两张 persona 表**整表替换**;非法 `kind` / 非法协议名 / 未知 anthropic 形状 → `400 bad_request`。 +- `POST /admin/llm` body `{id?,provider?,baseUrl,apiKey?,model,multimodal?,fallback?,maxTokens?,contextWindow?,reasoning?,personaModels?,personaProviders?}` → `201 {data:{provider:{id,name,provider,baseUrl,model,multimodal,enabled,fallback?,maxTokens?,contextWindow?,reasoning,personaModels?,personaProviders?}, persisted:true, path}}`。**真实持久化**:读-改-写 `models.json`(本地、gitignore),保存组提升为**生效组**(index 0),并**热更新**运行中的 LLM provider(`path` 为落盘文件路径);API key 写入文件但**永不回显/不落日志**。`provider` 为空默认 `openai`;缺 `baseUrl`/`model` 回 `400`;`maxTokens` / `contextWindow` 为负回 `400`;未注入 LLM stack 回 `500 llm_unavailable`、写盘失败 `500 llm_persist_failed`、热重载失败 `500 llm_reload_failed`。`maxTokens` 缺省/`0` = 未设(回落 `LLM_FALLBACK_MAX_TOKENS` / 适配器默认);`contextWindow` 缺省/`0` = 未声明(按回落窗口 131072 推导)。⚠️ **时机**:预算推导**只在进程启动时算一次**(`cmd/cyrene/wire_context.go` 组合期)⇒ 面板改窗口后**需重启**才重算(provider 栈本身是热更新的,两者口径不同,面板文案已写明「重启后生效」)。`.env` 为兜底。**v0.53 新增三键**(`reasoning.kind` / `personaModels` / `personaProviders`,语义与校验见上方「模型配置契约(v0.53)」):三者**缺省 = 保留已存值**,给出则 `reasoning` 整块替换、两张 persona 表**整表替换**;非法 `kind` / 非法协议名 / 未知 anthropic 形状 → `400 bad_request`。**v0.59 新增 `contextWindow`**(组级模型窗口,语义见上方「上下文窗口与预算契约(v0.59)」):**缺省 = 保留已存窗口**、显式 `0` 与缺省同义、负值 `400`。 - `POST /admin/llm/test` body `{provider?,baseUrl,model,apiKey?,prompt?}` → `200 {data:{ok,provider,model,reply,tokens}}`(**真实模型调用**);无效/失败回 `400 invalid_provider` / `500 llm_test_failed`。API key 仅本次请求内使用,不落库、不进 wire。 - `POST /admin/channels/open`、`POST /admin/channels/close` body `{name}` → `200 {data:{name,connected}}`(真实 Connect/Disconnect);`404`(未注册渠道)/`409 session_rejected`(微信 iLink 侧拒绝/作废了该会话,重连无法修复,须先「扫码登录」重新绑定,v0.39)/`409 needs_login`(微信 iLink 尚无 token)/`500`(其它凭据缺失等连接失败)。 - `POST /admin/channels/test` body `{platform?,accountId,isGroup}` → `200 {data:{role,owner,isGroup,env,verdict}}`。**结构性判定**:channel 层 actor 元数据 × `configs/users/owner.yaml` 白名单,绝不靠提示词。 @@ -308,7 +414,7 @@ ## 3. WebSocket `/ws/chat`(实时) - **握手**: `ws://:8080/ws/chat?token=` 或 `Authorization: Bearer`;未认证在握手前回 `401` JSON。 -- **下行(服务端→客户端)**: 经 bus 广播 `{type:"", data:..., turnId?, seq?, sessionId?}`(`turnId`/`seq` 为流式关联字段,见 §3.1;`sessionId` 为**会话持久化(design/16)新增**,能确定会话的事件带上,供客户端把该帧路由到**它自己那个会话**的消息桶(**v0.54 起:非激活会话的帧不再被丢弃**);**v0.36 起结构化流帧 `message.*` / `thinking.*` / `tool_call.*` / `tool_execution.*` 也带 `sessionId` + `turnId`**——多会话并发流式时客户端据此把 delta 落到各帧自身 `sessionId` 的消息桶,见 §3.1)——`message.start/delta/end` / `thinking.start/delta/end` / `tool_call.start/delta/end`(以上模型流帧的 `data` 另带 `streamId`,v0.50)/ `tool_execution.start/update/end`(工具执行三事件,v0.50)/ `turn.start` / `turn.end`(回合边界帧,v0.50)(结构化流,design/04 §1.1),以及向后兼容的 `stream_chunk` / `stream_end` / `thinking`(思维链) / `mood`(情绪变化,见 §3.7) / `reminder` / `device_update` / `tool_used`(工具回显) / `subagent_event`(逐代理进度) / **`subagent_result`**(后台批次的最终结果,见 §3.10) / **`approval_notice`**(主代理代审小字,见 §3.11) / `graph_update` / **`message_persisted`**(会话持久化增量,见 §3.3)/ **`message_edited`**、**`message_deleted`**(消息编辑/删除增量,见 §3.8)/ **`session_read`**(已读游标推进,见 §3.9)/ **`observability_log`**、**`observability_trace`**、**`observability_metrics`**(可观测实时推送,见 §3.12,v0.55)。**会话编排状态(design/15,均为广播,`data.sessionId` 区分会话)**:`session_busy` / `pending_ack` / `pending_drain` / `session_idle` / `session_interrupted` / `pending_full`(见 §3.2)、**`chat_duplicate`**(客户端幂等键重复到达的明确回执,v0.56 新增,见 §3.13);另有 **`steer_ack`**(忙时会话里排队的那条用户消息**已被在飞回合吃进同一回合**的回执,v0.57 新增,见 §3.14)——它经**同一状态通道**,但由 **hub 直接广播、不经 bus**,帧壳带 `sessionId`(`data` 内不重复);因它**携带具体消息身份**,**不属**上面这组「状态机信号」。语音回复走 `voice_output`(定向回请求连接,非广播)。 +- **下行(服务端→客户端)**: 经 bus 广播 `{type:"", data:..., turnId?, seq?, sessionId?}`(`turnId`/`seq` 为流式关联字段,见 §3.1;`sessionId` 为**会话持久化(design/16)新增**,能确定会话的事件带上,供客户端把该帧路由到**它自己那个会话**的消息桶(**v0.54 起:非激活会话的帧不再被丢弃**);**v0.36 起结构化流帧 `message.*` / `thinking.*` / `tool_call.*` / `tool_execution.*` 也带 `sessionId` + `turnId`**——多会话并发流式时客户端据此把 delta 落到各帧自身 `sessionId` 的消息桶,见 §3.1)——`message.start/delta/end` / `thinking.start/delta/end` / `tool_call.start/delta/end`(以上模型流帧的 `data` 另带 `streamId`,v0.50)/ `tool_execution.start/update/end`(工具执行三事件,v0.50)/ `turn.start` / `turn.end`(回合边界帧,v0.50)(结构化流,design/04 §1.1),以及向后兼容的 `stream_chunk` / `stream_end` / `thinking`(思维链) / `mood`(情绪变化,见 §3.7) / `reminder` / `device_update` / `tool_used`(工具回显) / `subagent_event`(逐代理进度) / **`subagent_result`**(后台批次的最终结果,见 §3.10) / **`approval_notice`**(主代理代审小字,见 §3.11) / `graph_update` / **`message_persisted`**(会话持久化增量,见 §3.3)/ **`message_edited`**、**`message_deleted`**(消息编辑/删除增量,见 §3.8)/ **`session_read`**(已读游标推进,见 §3.9)/ **`observability_log`**、**`observability_trace`**、**`observability_metrics`**(可观测实时推送,见 §3.12,v0.55)。**会话编排状态(design/15,均为广播,`data.sessionId` 区分会话)**:`session_busy` / `pending_ack` / `pending_drain` / `session_idle` / `session_interrupted` / `pending_full`(见 §3.2)、**`chat_duplicate`**(客户端幂等键重复到达的明确回执,v0.56 新增,见 §3.13);另有 **`steer_ack`**(忙时会话里排队的那条用户消息**已被在飞回合吃进同一回合**的回执,v0.57 新增,见 §3.14)——它经**同一状态通道**,但由 **hub 直接广播、不经 bus**,帧壳带 `sessionId`(`data` 内不重复);因它**携带具体消息身份**,**不属**上面这组「状态机信号」。语音回复走 `voice_output`(定向回请求连接,非广播);**队列满的定向拒绝**走 `{type:"error", data:{code:"chat_busy", …}}`(只发发送者,见 §3.2);语音回合本身走与文本相同的编排路径(见 §3.15)。 - **下行帧的角色门(v0.48,design/11 §7.1)**: 下行帧按**类型**分两类,由后端**唯一一处**收口判定(**不在白名单里的帧默认只发 `owner` + `admin`**,fail-closed):① **状态机信号**(白名单,**不含任何内容**)→ **所有已认证连接**(含 `viewer`):`session_busy` / `pending_ack` / `pending_drain` / `session_idle` / `session_interrupted` / `pending_full`(§3.2)、`session_read`(§3.9);② **其余全部** → **仅 `owner` + `admin`**:流式内容 `message.*` / `thinking.*` / `tool_call.*` / `tool_execution.start/update/end` / `turn.start` / `turn.end`(**v0.50 新增的这几种同属本档**——工具进度与回合边界对 `viewer` 也是内容相邻信息,fail-closed 默认即只发 owner+admin,未扩白名单)/ `stream_chunk` / `stream_end` / `thinking`(§3.1)、持久化 `message_persisted`(§3.3)/ `channel_persisted`(§3.4)/ `message_edited` / `message_deleted`(§3.8)、系统内部 `graph_update`(§3.5)/ `subagent_event` / **`subagent_result`(§3.10,v0.51 新增的这帧同属本档)** / **`approval_notice`(§3.11,v0.52 新增,同属本档)** / `task_output` / `task_done` / `tool_used` / `capacity_awaken` / `mode_switch`、私有状态 `mood`(§3.7)/ `device_update` / `reminder`、**可观测推送 `observability_log` / `observability_trace` / `observability_metrics`(§3.12,v0.55 新增,同属本档——未进 7 个状态机信号白名单,fail-closed 只发 owner+admin)**、**`chat_duplicate`(§3.13,v0.56 新增,同属本档——它虽由编排器状态通道下发,但未进那 7 个状态机信号白名单,故 fail-closed 只发 owner+admin)**、**`steer_ack`(§3.14,v0.57 新增,同属本档——它携带具体消息 id 与模型步序号,未进那 7 个状态机信号白名单,故 fail-closed 只发 owner+admin;经 hub 直接广播、不经 bus)**。 - **上行帧与握手回放的角色门(v0.49,design/11 §7.2)**: 与上面那条下行门**成对**,收的是同一个缺口的两处残余——① **客户端→服务端(上行)JSON 帧**:只有 `owner` / `admin` 连接可以发;非 `owner`/`admin`(即 `viewer`)连接发来的**任何**上行 JSON 帧都**默认被拒**,回复是**定向**(只回发送者)的 `{type:"error", data:{code:"forbidden"}}`,**不广播、不驱动模型、不落库、不返回任何内容**。被拒帧:`chat` / `interrupt` / `subagent_ask` / `task_output_query` / `voice_input` / `mode_switch`——**未登记的新上行帧同样默认拒**,要放行必须先在 design/11 §7.2 登记白名单(实现上那把「默认拒」也必须同步改为登记表,不能直接删)。② **握手 `history` 回放**:仅 `owner` + `admin` 连接收到;`viewer` 连接建立后**不收**回放。**协议级帧不受影响**:WS ping/pong 与正常 close 照常(否则连接会掉),被拒的上行帧也不会关掉连接。 @@ -335,8 +441,8 @@ - **其它取值** → **只回发送者** `{type:"error",data:{code:"bad_request"}}`(**定向**:不广播、**不打断任何会话**);显式报错,绝不静默按默认执行(否则客户端以为队列保住了、实际被清空,正是 A2 要修掉的那类缺陷)。 - 命中空闲会话为 no-op(两种 scope 都是)。 - **`chat` 帧的 `clientMsgId`**(上行,v0.56/G4):`{sessionId, text, image?, deviceId?, mode?, clientMsgId?}`——`clientMsgId` 为**客户端自带的幂等键**(可选):同一会话内同一个非空值只承认第一条,重复到达**不落库、不起回合**,回新下行帧 **`chat_duplicate`**(§3.13);省略 = 与加入该字段之前逐字相同。**REST `POST /api/v1/chat` 同名可选项语义相同**(重复回 `200 {data:{duplicate:true,messageId,sessionId}}`,见 §1 行 3)。 -- **`voice_input`**(上行,客户端→服务端):`_{audio?, format?, text?, sessionId?, deviceId?}`。`audio` 为 base64(或 data: URI)音频,`format` 为其编码(`wav`/`mp3`/`ogg`);`text` 为已转写文本时跳过 STT;`deviceId` 为**客户端具体端标识**(design/16 §4.5,如 `voiceassist:myapp:v1`,可选),随该语音轮次落库、聊天气泡可标注来源。服务端走 STT→agent 聊天→TTS(design/06 §1),回复经 `voice_output` 定向回该连接。**真 STT/TTS 已接通阿里百炼**(`DASHSCOPE_API_KEY` 必填:STT=`qwen-audio-3.0-asr-flash`、TTS=`cosyvoice-v3-flash`+`longxiaochun_v3`);无凭据时后端走降级链(cloud → edge-tts/espeak(未实现)→ `silent` 空音频)。无注入语音服务时回 `error` 帧(`voice unavailable`)。 -- **`voice_output`**(下行,服务端→客户端):`{audio, format, text, reply, provider?}`。`audio` 为 base64 合成音频,`format` 为其编码,`text` 为识别文本,`reply` 为助手回复文本;**当合成音频为空(silent 降级)时带 `provider`**(如 `"silent"`,非空音频省略)——客户端据此提示「没生成声音」,但该帧**仍照常发出、不算业务错误**。 +- **`voice_input`**(上行,客户端→服务端):`{audio?, format?, text?, sessionId?, deviceId?, clientMsgId?}`。`audio` 为 base64(或 data: URI)音频,`format` 为其编码(`wav`/`mp3`/`ogg`);`text` 为已转写文本时跳过 STT;`deviceId` 为**客户端具体端标识**(design/16 §4.5,如 `voiceassist:myapp:v1`,可选),随该语音轮次落库、聊天气泡可标注来源。**`clientMsgId`(v0.59)=客户端自带的幂等键**(可选,**与 `chat` 帧同形** ⇒ 语音也有幂等):同一会话内同一个非空值只承认第一条,重复到达**不落库、不起回合、不发音频**,回既有下行帧 `chat_duplicate`(§3.13);省略 = 与加入该字段之前逐字相同。**语音回合现在走与文本回合同一条路**(进编排器 → 落库 → 流式 → 跨端同步 → 忙时排队),详见 **§3.15**。服务端走 STT→agent 聊天→TTS(design/06 §1),回复经 `voice_output` 定向回该连接。**真 STT/TTS 已接通阿里百炼**(`DASHSCOPE_API_KEY` 必填:STT=`qwen-audio-3.0-asr-flash`、TTS=`cosyvoice-v3-flash`+`longxiaochun_v3`);无凭据时后端走降级链(cloud → edge-tts/espeak(未实现)→ `silent` 空音频)。无注入语音服务时回 `error` 帧(`voice unavailable`)。 +- **`voice_output`**(下行,服务端→客户端,**定向**):`{audio, format, text, reply, sessionId, deviceId?, provider?}`。`audio` 为 base64 合成音频,`format` 为其编码,`text` 为识别文本,`reply` 为助手回复文本;**v0.59 新增 `sessionId`(恒有)与 `deviceId`(非空才有)**——多端同时说话时客户端据此区分归属。**当合成音频为空(silent 降级)或 TTS 失败时带 `provider`**(如 `"silent"`,非空音频时省略)——客户端据此提示「没生成声音」,但该帧**仍照常发出、不算业务错误**;**空正文一律走同一条降级信号,绝不拿 `stopReason` 顶替成语音内容**。**时点与投递口径见 §3.15**(回合结算时才到、定向目标 = 发起那条连接、断线不补投)。 - **向后兼容**:未映射/无法确定的事件仍走 `stream_chunk` + `stream_end`(非流式回退路径)。 --- @@ -350,7 +456,8 @@ - **`pending_drain`**:忙完开始消费下一待处理消息(`{sessionId, mode, queueDepth}`)。 - **`session_idle`**:会话回到空闲、队列清空(`{sessionId, mode}`)。 - **`session_interrupted`**:用户 `interrupt` 中断(`{sessionId, mode, dropped}`)——**帧形状零变化**,语义随 `scope`(v0.56/A2):`scope:"all"`(省略 / 空串同义)= 当前任务取消、pending 清空,此时 `mode = idle`、`dropped` = 被清空的 pending 条数;**`scope:"current"` = 只取消当前任务、pending 保留**,此时 `dropped` 恒为 **`0`**、`mode` 为该会话当时的**忙碌通道**(`thinking`/`executing`/`subagent`,**非 `idle`**)。`dropped` 恒等于**真的被丢弃**的条数(绝不把「保留」报成丢弃)。 -- **`pending_full`**:单会话 pending 达上限(默认 5)拒绝入队(`{sessionId, mode, max}`)——防无限积压(design/15 §4)。 +- **`pending_full`**:单会话 pending 达上限(默认 5)拒绝入队(`{sessionId, mode, max}`)——防无限积压(design/15 §4)。**v0.59(G5)载荷增两个可选键**:`clientMsgId?`(= **被拒那条消息**的客户端幂等键;该消息未携带则**整个键缺席**,绝不伪造)与 `messageId?`(**仅当该消息此前已落库 / 已认领时**才回填)。⚠️ **服务端绝不在此处新铸 id**:队列满的拒绝发生在幂等键**认领之前**,代铸一个 id 会让客户端拿同一 `clientMsgId` 重试时被判成「重复到达」而**永远进不来**。 +- **`pending_full` 的定向伴生帧(v0.59 / G5,此前无文档登记)**:队列满时**除广播 `pending_full` 外**,服务端还会向**发送这条消息的那条连接**单独发一条定向错误帧 `{type:"error", data:{code:"chat_busy", message, sessionId, clientMsgId?, messageId?}}`——**只发发送者、不广播**(其它端按广播帧里的 `clientMsgId` 自行对齐即可)。`code` 与 REST 的 `409 chat_busy` **同码**(客户端**一个 code 覆盖两条入口**)。用途:WS 路径此前没有与 REST `409` 等价的回执,本地乐观气泡只能永远挂在「发送中…」;有了它就能精确回滚那一条。 - **上下文续接**:忙完重开时新回合**带上一轮上下文**(`{mode, queueDepth}` 之外的续接由服务端完成),非清空重来(design/15 §2.2)。 - **客户端侧(v0.54)**:`sessionBusy` / `pendingCount` 是**单槽全局**状态(不按会话分桶)——前端只在帧的 `sessionId === activeSessionId` 时置忙/置闲(`frontend/web/src/lib/ws/handlers/session.ts`,置忙与置闲两侧同一把尺子),与「消息按自己的 `sessionId` 入桶、非激活会话不丢弃」不同(见 §3.1)。这是 `design/16 §4.2` 已登记的**单槽债**(多会话并发时忙态徽标只反映屏幕上的会话)。 @@ -490,6 +597,29 @@ - **兼容性**:**纯新增帧**,老客户端按未知帧忽略即可;既有帧 / 端点 / 鉴权 / REST / `.env` 一律不变。 - **客户端现状(诚实边界)**:**Web 前端目前尚未消费该帧**(`frontend/web/src` 内无 `steer_ack` handler),故今天的可见效果是「帧到达 `owner` / `admin` 连接」;要落成灰态,按本节口径加 handler 即可(`messageId` 定位气泡、`message.*` / `turn.end` 收灰)。 +### 3.15 语音回合(`voice_input` / `voice_output`,v0.59:进编排 + 归属 + 降级信号) + +> **行为变更(对语音客户端是实质变化)**:语音回合**不再「转写完直连 loop 就跑完」**——它进**会话编排器**,与文本回合**走同一条路**。因此语音问过的事**进历史**、**跨端可见**、**参与忙/排队**。帧形状的变化只有两处(§3.1 已改):`voice_input` 增可选 `clientMsgId`、`voice_output` 增 `sessionId`(恒有)/ `deviceId`(非空才有)。 + +- **同一条路(v0.59)**:一次 `voice_input` 在 STT 之后产出的用户消息 = **转写文本**,经编排器提交: + - **落库**:用户消息(转写文本)与助手回复都进 `chat_messages` ⇒ `GET /sessions/{id}/messages` 拉得到、握手回放补得到; + - **实时帧**:照发 `message_persisted`、流式 `message.*` / `thinking.*` / `tool_call.*` / `turn.start` / `turn.end`、状态帧等(与文本回合零差别); + - **跨端同步**:另一端连着 WS 也能看到这一轮(按各自 `sessionId` 入桶); + - **忙时排队**:目标会话正忙 ⇒ 该语音消息**按编排器既有规则入 pending**——`pending_ack` / `pending_drain` / `pending_full` / `session_interrupted` 的**语义与形状零变化**,只是多了一个消费者;**排队满**同样回 §3.2 的广播 `pending_full` + **定向 `error{code:"chat_busy"}`**(带 `clientMsgId?`/`messageId?`)。 + - `deviceId` 照旧落库(design/16 §4.5);`platform` 走存储默认 `web`。 +- **幂等(v0.59)**:`voice_input` 增可选 `clientMsgId`(**与 `chat` 帧同形**)。重复到达 → 编排器回既有下行帧 `chat_duplicate`(§3.13),**不产生新回合、不发音频、不落库**。客户端重连后带**同一个** `clientMsgId` 重试是安全的。 +- **`voice_output` 的时点(重要,别当成「发出去就立刻有音频回来」)**: + - 它在**该回合结算时**才到——可能**晚于若干状态帧**(`pending_ack` → `session_busy` → … → `session_idle`),也可能由**续跑回合**(忙完消费 pending 的那一轮)或**被 steer 注入的那一回合**(§3.14)产生; + - 忙时先收 `pending_ack`;`steer_ack` 可作「已读未答」的灰态(§3.14); + - 因此**不能用「发完 N 秒没音频」判定失败**,也不要用到达顺序推断回合归属——用帧里的 `sessionId` / `deviceId` 判归属。 +- **定向目标与断线(诚实边界)**: + - `voice_output` **只发「发起这条 `voice_input` 的那条 WS 连接本身」**——服务端**不按 `deviceId` 路由**(`deviceId` 只是载荷里的归属标注),也**不会**发给同账号的其它端; + - **连接断开后不补投**:服务端的收尾回调发现该连接已关就**记一条带来源的 WARN 并丢弃**(音频是「回复已落库」之后的增强,绝不静默假成功);**助手回复本身已经落库**,所以重连后从历史 / 回放能拿到正文,只是拿不到那段音频; + - 重连后带同一 `clientMsgId` 重发**不会**重复起回合(幂等),但**也不会**补回那次音频(音频不落库)。 +- **降级信号(与 §3.1 同一口径)**:`provider` 只在**没有真实音频**时出现(`"silent"` 或实际档位),覆盖两种情况——**空正文**(回合没产出文本,如被取消)与 **TTS 失败 / 降级为空音频**。客户端据此提示「没生成声音」,但**该帧仍照常发出**:客户端总能看到这条语音回合的终点,而不是永远等一个不会来的 `voice_output`。**绝不把 `stopReason`(`stop` / `idle`)当语音内容**。 +- **未接编排器时**(未接线的构建 / 裸 hub 单元测试)保留旧直连路径:STT → 直接跑 loop → 立即合成并回音频。两条路径共用同一个帧构造,`voice_output` 契约一致。 +- **引擎与降级链**:真 STT/TTS 走阿里百炼(STT `qwen-audio-3.0-asr-flash`、TTS `cosyvoice-v3-flash` + `longxiaochun_v3`);无凭据时 cloud → edge-tts/espeak(未实现)→ `silent` 空音频。 + ## 4. 客户端接入指引(Web / 移动 / 原生 / IM bridge 通用) ### 4.1 一次性接入流程(三步走) @@ -515,7 +645,7 @@ - 返回**按时间倒序**,需 `reverse()` 成旧→新用于渲染。 - **编辑 / 删除单条消息**:`PATCH /api/v1/messages/{id}` `{content}`(≤4000 rune)→ `{data:<消息DTO>}`(裸 DTO,无 `message` 包裹);`DELETE /api/v1/messages/{id}` → `{data:{deleted:}}`。不存在回 `404`。**重发**用「编辑后内容再 `POST /chat`」即可(无独立重发端点)。两者成功后都会广播(`message_edited` / `message_deleted`,§3.8),同账号其它端**免刷新**同步。 - **实时增量**:连接 WS 后,消息落库会广播 `message_persisted`(§3.3)——**用其中的 `id` 去重**,按 `sessionId` 写进该会话自己的桶(**v0.54 起非激活会话的帧不再丢弃**);新开/重连连接首帧即 `history` 回放(主会话最近 50 条)。**消息被编辑/删除**则分别广播 `message_edited` / `message_deleted`(§3.8)——同一 `sessionId` 路由规则,前者按 `id` 原位改 `content`、后者按 `id` 移除,都不需要重拉整页。 -- **发消息**:`POST /api/v1/chat` `{text, deviceId?, image?, mode?, clientMsgId?, stream?}`。`stream:true` **直达会话编排器**(v0.56/G2):受理成功后才回 `200 {stream:true}`;会话正忙已续接排队 `202 {stream:true,queued:true,queueDepth}`、队列满 `409 chat_busy`、**未接 loop `500 chat_unavailable`**、仅未接编排器时的兜底派发失败 `503 chat_dispatch_failed`。非流式返回完整 `reply`(会话正忙时 `202 {queued:true,queueDepth}`、排队满 `409 chat_busy`,见 §3.2)。`image` 解码后上限 **5 MiB**(超限 `400 image_too_large`,文案指路 `POST /api/v1/upload`);**WS `chat` 帧受 4096 字节读上限约束**(超限断连),故 WS 上发大图必须走「上传 → URL」。 +- **发消息**:`POST /api/v1/chat` `{text, deviceId?, image?, mode?, clientMsgId?, documents?, stream?}`。`stream:true` **直达会话编排器**(v0.56/G2):受理成功后才回 `200 {stream:true}`;会话正忙已续接排队 `202 {stream:true,queued:true,queueDepth}`、队列满 `409 chat_busy`、**未接 loop `500 chat_unavailable`**、仅未接编排器时的兜底派发失败 `503 chat_dispatch_failed`。非流式返回完整 `reply`(会话正忙时 `202 {queued:true,queueDepth}`、排队满 `409 chat_busy`,见 §3.2)。`image` 解码后上限 **5 MiB**(超限 `400 image_too_large`,文案指路 `POST /api/v1/upload`);**WS `chat` 帧受 4096 字节读上限约束**(超限断连),故 WS 上发大图必须走「上传 → URL」。**文档附件(v0.59)**:先 `POST /api/v1/documents`(multipart `file`,上限 20 MiB)拿到 `name`,再在 `documents:[{"name":…}]` 里引用(**≤5 条、只给存储名**;超限 `400 too_many_documents`、名非法/不存在 `404 document_not_found`,契约见 §1.2)。`text`/`image`/`documents` **至少一项非空**(纯附件回合成立)。 - **幂等发送(v0.56/G4)**:给自己生成的每条消息带一个**非空** `clientMsgId`(如本地乐观气泡的本地 id),服务端在同一会话内只承认第一条:重复(超时重发 / 切后台重发)**不再落库、不再起回合**——WS 回 `chat_duplicate`(§3.13)、REST 回 `200 {data:{duplicate:true,messageId,sessionId}}`;该键也会**回填进消息 DTO**,供重连后对齐本地气泡。**省略它 = 老行为逐字不变**。 ### 4.3 deviceId 与 platform 规范(design/16 §4.4-4.5) @@ -526,15 +656,17 @@ ### 4.4 常见错误码 / 边界 - `401` 未认证/令牌失效 → 重新登录。`403` 权限不足(viewer 读管理写/主会话删除)。 - `404` 资源不存在(消息/会话 id 未找到)或端点未实现;`501 not_implemented` 后端明确未提供(如知识库删除、新增用户)。 -- `400` 请求体/参数非法(`limit`/`before` 越界、空 content)。`409 chat_busy` 排队满。 -- **红线**:身份判定永远来自后端 channel 层 actor 元数据 + 白名单,**客户端不要用提示词/文本判身份**;`thinking`(思维链)在公开消息/回放中**已剥离**,客户端不应依赖它。 +- `400` 请求体/参数非法(`limit`/`before` 越界、空 content)。`409 chat_busy` 排队满(**REST 响应体与 WS 定向 `error` 帧同码**,见 §3.2)。 +- **文档相关(v0.59)**:`415 unsupported_media_type`(扩展名白名单或字节嗅探任一不过)、`413 document_too_large`(超 `CHAT_DOCUMENT_MAX_BYTES`,默认 20 MiB)、`404 document_not_found`(文档名非法/穿越/不存在;`chat.documents` 里引用不存在的名也是它)、`400 too_many_documents`(单回合 >5 条)。⚠️ **诚实说明**:design/24 §6.2 列的 `document_unreadable`(400) 目前**没有对应的 REST 错误码**——它只以 `document-read` 工具的错误形态存在(模型侧可见;客户端在 DTO 里看到的是该文档的 `documents[].status = "unreadable"`)。 +- **红线**:身份判定永远来自后端 channel 层 actor 元数据 + 白名单,**客户端不要用提示词/文本判身份**;消息体的 **`thinking` 字段**在**实时帧**(`message_persisted` / `channel_persisted` / `message_edited`)与**握手回放**中**恒为空**(**有意的隐私与带宽默认**,不是漏发);实时思维链仍由 §3.1 的 `thinking.*` 流帧按 owner/admin 档下发;**历史消息的思维链**只有 `GET /sessions/{id}/messages`(owner/admin)能拿到,客户端不应依赖其它入口。 ### 4.5 离线 / 缓存建议(原生端) - PWA / 原生端缓存 `sessions`/`personas` 列表 + 最近一屏消息;重连先本地渲染再拉增量。 - 消息**真源在后端**,本地只做渲染缓存;用 `message_persisted.id` 去重,不要用本地自增 id。 -- 语音:上行 `voice_input`(base64 音频或已转写 text),下行 `voice_output`(合成音频 base64 + text)。 +- 语音:上行 `voice_input`(base64 音频或已转写 text,**v0.59 起可带 `clientMsgId` 幂等**),下行 `voice_output`(合成音频 base64 + text + **`sessionId`/`deviceId?`**,**定向回发起那条连接、回合结算时才到**,空正文/TTS 失败带 `provider` 降级信号);**语音回合与文本回合走同一条路**(落库 / 跨端 / 忙时排队),见 §3.15。 ## 5. 变更记录 +- `v0.59`(**文档进对话 + 上下文预算按窗口推导 + 语音回合进编排 + `thinking` 可达性口径**,依据 design/24 §3/§4/§6(切片 S1–S5)、design/04 §1.3 + 附录 A.3(主人裁决 2026-10-04)与 `Cyrene-For-Android/devdocs/02-前后端协同事项.md` 的 G5 / C1 / C3;commit `a8851dc` / `a4789ec` / `20c1edb` / `5b2b385` / `72b95df` / `3a01b4e`,2026-10-04 落实)。**三档标注**:**纯增量** = 两个 documents 端点、`chat.documents` 可选字段、4 个文档错误码、`models.json` 组键 `contextWindow`、4 个 `.env` 键、`document-read` 工具、`pending_full` 的 `clientMsgId?`/`messageId?`、定向 `error{code:"chat_busy"}` 帧、`voice_input.clientMsgId`、`voice_output.sessionId`/`deviceId`;**行为变更** = ① 语音回合改走编排器(落库 / 流式 / 跨端 / 忙时排队)② `CONTEXT_MAX_TOKENS` **未设置时改为按窗口推导**(`.env` 里写着 `32768` 仍是显式覆盖)③ 队列满时新增**定向回执**(此前 WS 无等价物);**破坏性** = ① `historyMessage` 的 `document`(单对象)→ **`documents`(数组,≤5)** ——**但单数形状从未随任何已发布版本号发布过**,故**无兼容期** ② `chars` / `truncated` 的语义由「抽取侧」改为 **「本回合注入侧」**(全文长度只有 `document-read` 的 `totalChars` 能看到)。**A. 文档(新增 §1.2 + §1 行 16/17)**——`POST /api/v1/documents`(multipart `file`,上限 `CHAT_DOCUMENT_MAX_BYTES` 默认 20 MiB)→ `201 {name,url,mime,size,sha256,deduped:false}`;**同字节同扩展名重复上传 → `200` + 同一 `name` + `deduped:true`**;`GET /api/v1/documents/{name}` **必须带 Bearer(owner|admin)**(与图片读侧的免鉴权能力 URL 刻意相反)+ `Content-Disposition: attachment` + `nosniff`;**字节嗅探是唯一类型裁决**(放行基类型只有 `text/plain`),**扩展名白名单 22 项** deny-by-default,`mime` 是嗅探基类型(`data.json` → `text/plain`)、`url` 是**相对路径**;错误码 `unsupported_media_type`(415) / `document_too_large`(413) / `document_not_found`(404) / `too_many_documents`(400);`chat` 请求(REST 同步 / REST `stream:true` / WS **三处同形**)增可选 `documents:[{name}]`(**≤5、只收服务端存储名、不收 URL**,先于任何 FS 访问判条数,客户端多带的 `mime/url/size` 被服务端覆盖);DTO 四处同源(`GET /sessions/{id}/messages` / `message_persisted` / `message_edited` / 握手回放)改 `documents` 数组,元素 `{name,mime,size,chars,truncated,status}`、`status ∈ extracted|listed|unreadable`、无附件时省略该键;内置工具 `document-read`(`{name,offset?,limit?}` → `totalChars`/`truncated`/`nextOffset`/`text`/`extractTruncated?`,`limit` 默认 8000、上限 64K、`offset` 为 0 基 rune,策略默认 owner-only)。**B. 配置(§2「上下文窗口与预算契约(v0.59)」)**——`models.json` 组级新增 `contextWindow`(0/省略 = 未声明 ⇒ 131072 保守估算 + **恰好一条 WARN**;负数 = 校验失败);`.env` 新增 `LLM_CONTEXT_WINDOW` / `CONTEXT_BUDGET_RATIO`(默认 `2/3`)/ `DOCUMENT_MAX_INJECT_CHARS`,并**变更 `CONTEXT_MAX_TOKENS` 语义**(未设置 ⇒ 按窗口推导;`>0` 覆盖;`0`/负 = 关闭压缩)——推导 `budget = min(窗口 × 比例, 窗口 − 输出保留)`,输出保留 = `max(32768, 该组 maxTokens)` 封顶 `窗口/2`,**1M 窗口 ⇒ 699050 tokens**;另**补登记 `.env` → 面板的 materialize 语义**(每次启动一次;面板非空 ⇒ 组集合由面板决定、env 不复活被删的组;键级只在面板缺该键时补,面板的**显式空值/`false`/`null` 绝不覆盖**;「面板改一次、重启被打回」为硬约束;凭据不进日志;范围仅 `models.json` 一族)。**C. 语音(新增 §3.15)**——语音轮次**现在走与文本同一条路径**(落库 = 转写文本 + 助手回复、发 `message_persisted` 与流式帧、跨端同步、忙时按既有规则排队,`pending_ack`/`pending_drain`/`session_busy` 语义零变化);`deviceId` 照旧落库、`platform` 走存储默认 `web`;`voice_input` 增可选 `clientMsgId`(重复到达回 `chat_duplicate`,不产生新回合、不发音频);`voice_output` 增 `sessionId`(恒有)/ `deviceId`(非空才有),**空正文与 TTS 失败/降级都走既有 `provider` 降级信号**(不再把 `stopReason` 当语音内容);**时点** = 回合结算时才到(可能晚于若干状态帧、也可能由续跑或被 steer 注入的那一回合产生);**定向目标 = 发起 `voice_input` 的那条连接本身**(不按 `deviceId` 路由)、**断线不补投**(带来源 WARN),重连带同一 `clientMsgId` 重试安全。**D. `G5`(§3.2)**——`pending_full` **广播**载荷增 `clientMsgId?` / `messageId?`(前者 = 被拒那条消息的幂等键,未携带则整个键缺席;后者**仅当该消息此前已落库/已认领时**才回填——**绝不在此处新铸 id**,否则客户端重试会被判成「重复到达」而永远进不来);**新增定向帧** `{type:"error", data:{code:"chat_busy", message, sessionId, clientMsgId?, messageId?}}`,**只发发送者那条连接**、不广播,`code` 与 REST `409 chat_busy` **同码**。**E. `thinking` 口径(§4.4,不改行为)**——消息体 `thinking` 字段在**实时帧**(`message_persisted` / `channel_persisted` / `message_edited`)与**握手回放**中**一律为空**(隐私与带宽的**有意**默认);实时思维链仍走 §3.1 的 `thinking.*` 流帧(owner/admin);**历史消息的思维链**只有 `GET /sessions/{id}/messages`(owner|admin)返回。**实现落点**:文档 `internal/services/gateway/documents.go` + `chat_documents.go` + `session_transcript.go` + `internal/runtime/history/document.go`(DTO)+ `internal/runtime/loop/document.go`(装配期注入与结论回写)+ `plugins/tools/io/document_read.go`;预算 `internal/config/context_budget.go` + `context.go` + `llm.go` + `internal/config/runtimeconfig/models.go`(`contextWindow`)+ `materialize.go` + `cmd/cyrene/wire_context.go`;语音/G5 `internal/services/gateway/voice.go` + `session_voice.go` + `session_reject.go` + `ws_route.go`(`wsChatBusyCode`)+ `session_orchestrator.go`。**未做 / 诚实边界**:`document_unreadable`(400) 目前只有工具错误形态(无 REST 错误码);文档白名单实际为 **22** 项(design/24 §9.2 写 23,以代码为准);文档的配额/引用计数与 GC(design/24 S7)、PDF/Office 解析(S6)与 IM 侧 `msg.Media` 摄取**均未做**。 - `v0.58`(**纯新增 env 键:`LLM_MULTIMODAL`——让 env-only 部署也能声明「主模型自身能收图」**,依据裁决 14.4.1 / design/06 §0(commit `4adfc9a`),2026-10-04 落实)。**新增 1 个 `.env` 键**:`LLM_MULTIMODAL`(`bool`,**默认 `false`**)= `models.json` 组 `multimodal` 的 **env 等价物**,**只在没有有效 `models.json`(走 `.env` 合成组)时生效**(有组文件时以该组的 `multimodal` 为准,与 `LLM_REASONING_KIND` 同口径);`true` = 图片**直进主模型上下文**;**留空 / `false` = 把图交给 vision/OCR 工具换成文本描述**;**非 bool 值(如 `maybe`)启动即报错**(fail-fast,不静默回退)。**兼容性**:默认 `false` ⇒ **老部署行为逐字不变**(引入本键之前,env-only 部署没有任何键能声明该能力,图片恒走 vision 工具);**`LLM_MULTIMODAL=true` 后图片走主模型直收,属部署者显式选择**。**无端点 / 帧 / 字段形状变更**(纯新增 env 键,见 §2「模型配置契约」表与 §1 行 3)。 - `v0.57`(**13.4.5 目标态 steer 落地登记:新下行帧 `steer_ack`(回合内转向的消费回执)**,依据 design/15 §2.0 / §2.1 / §2.4 / §3.x / §5(**13.4.5 目标态规格**,commit `3d8822c`),2026-10-04 落实;**本版是已实现并已推库行为的登记,只有一处纯新增帧 + 一处链路图纯增量键,无其它契约变更**)。**A. 新下行帧 `steer_ack`(见 §3.14)**——`{type:"steer_ack", sessionId, data:{messageId, atStep}}`:忙时会话里新到的用户消息**被在飞回合吃进同一回合**时下发,`messageId` 是那条用户消息的**权威 id**(与 `message_persisted` 的 `data.message.id` 同源)、`atStep` 是吃进它的**模型步序号(1 基)**;同一批次吃进 N 条就发 N 帧、**不合并**。语义=「**已被本回合吃进(已从 pending 移除)、尚未被正面回答**」⇒ 客户端应把对应气泡渲染为「**已读、未答**」的**灰态**,直到该回合的 `message.*` / `turn.end` 到达,**不需要第二个确认帧**(与「忙『稍后回』」的 pending 气泡不是同一个态)。**角色门**:它**携带具体消息身份**,因此**不登记**进 `frameReachesAll` 的 7 个状态机信号白名单,落 fail-closed 默认档——**只发 `owner` + `admin`**(`viewer` 收不到);经编排器状态通道**由 hub 直接广播、不经 bus**;帧壳带 `sessionId`、**不带** `turnId`/`seq`。**触发时机**:只在**回合内边界**(loop 每一步开头:上一次模型步与它那一批工具调用都已结束、下一次模型调用尚未开始,此刻没有工具在飞)产生,且**只有该边界真的匹配到 pending 里、且已落库的消息时才发**——**不是每个 step 都发**(无可注入消息的边界一帧都不发)。**B. 转向的三条不变量(design/15 §2.0)**——**I1(同会话在途回合数恒为 1)**:转向只注入、不取消,**绝不新开第二个回合**(loop 侧结构性地没有任何「起新回合」的分支,注入只是把消息并进**当前回合**下一次模型调用的视图);**I2(消息恰好被处理一次)**:注入=**已消费**——被注入的消息立刻从该会话 pending **移除**(按 pending 序号认领),绝不被第二个回合再答一遍;去重键=后端 `message.id`,候选必须**同时**满足「在该会话转录里」与「在本编排器的 pending 里」(**不用时间戳**:Windows `time.Now()` 步长约 0.44ms,连发常同毫秒并列,任何毫秒基线都会漏注入或重复注入);**I3(转向不取消已发起的工具)**:注入点只在回合内边界,此刻没有任何工具在飞,正在执行的工具**永远跑完**(与 Cline PR #13958「只打断在飞的模型请求、工具与整轮继续」同口径)。**C. 注入的范围(重要口径)**——注入**只影响本轮视图**(每一步边界重新并入),**不写回运行窗口** ⇒ 既不进该回合的 **checkpoint 快照**、也不进**后台记忆提取的输入面**;被注入的消息**本身早在到达时已落库**(pending 路径即时持久化),注入**不新增、也不改写任何行**(它与 `message_persisted` 是两回事:后者在该消息落库时发,前者在该消息被吃进时发)。最新性=**每个边界重读该会话转录**(`history.Recent`),不靠回合开始那一次播种(回合开始前已有的消息由 `startTurnMessages` 播种,**绝不重复注入**)。**D. 失败一律让路(诚实边界)**——未接转录存储 / 读转录失败 / 该消息尚未落库 / 本回合世代已被打断或被更新的回合取代(认领 `pending` 失败)时,**不发 `steer_ack` 也不注入**:该消息**留在 pending**、照旧作为自己的回合被回答,**绝不静默消失**(转向是增益,任何一处失败都不该让已经在跑的回合失败)。**未做**:**没有「取回 / 撤回 pending 消息」的上行帧**(design/15 §5 提到的队列取回暂无协议面),也没有「已撤回」类下行帧;`steer_ack` 只报「**吃进**」,**不报「回答」**(回答由既有的 `message.*` / `turn.end` 表达)。**E. 一处随实现落地的链路图纯增量键**——注入时给该轮图的 **input 节点**打 `props.steered:true`(`loop.FlowPublisher.MarkInputSteered`),让流程图如实表达「这一轮的输入在回合中途被追加过」;图仍**只有一条主干**(**不是**被画成第二轮,与 I1 一致),标记只并入既有 input 节点、**不新建节点、不改标签**。**F. 实现落点**——loop 侧 `internal/runtime/loop/steer.go`(`SteerRequest` / `SteerSource` / `WithSteerSource` / `steerAtBoundary`)+ `run.go` 的边界调用点 + `flow.go` 的 `MarkInputSteered`;网关侧 `internal/services/gateway/session_steer.go`(每回合一份的 `steerTurn` 游标 / `match` / `consumePending`)+ `session_orchestrator.go`(回合启动与每次续跑各挂一份转向源)+ `ws_bridge.go`(`wsSteerAck` 帧名与其语义注释);**组合根零改动**(per-turn 状态用 ctx 承载)。**G. 兼容性**——**纯新增帧**,老客户端按未知帧忽略即可;**无 REST / `.env` / 既有帧 / 鉴权变更**,`session_busy` / `pending_ack` / `pending_drain` / `session_idle` / `session_interrupted` / `pending_full`(§3.2)与 `chat_duplicate`(§3.13)的形状与语义**一字未动**。**验证**:网关侧**真路径** e2e(真 WS `chat` 帧 → 真编排器 pending → 真 loop → 真 SQLite 转录 → 真 `/ws/chat` 连接 × 真 JWT)断言「忙时新消息在**下一个回合内边界**被注入**同一回合**、模型第 2 步窗口里有它、整轮**只有一次**助手回答、pending 被清空、`steer_ack` 恰好 1 帧且 `owner`/`admin` 都收到而 `viewer` 收不到(以白名单哨兵到达为前提)」+「消息尚未落库时留在 pending、绝不静默消失」;loop 侧断言注入边界序列、同流去重、恰好一次、以及未接线转向源时窗口逐字不变。**变异反证**(均已还原复绿):删 `consumePending` → 模型步数 4≠3;注入写回 `msgs` → checkpoint 快照被污染;批次中途注入 → 边界序列异常;把 `steer_ack` 加进白名单 → `viewer` 收到帧。 - `v0.56`(**契约债一次补齐(协同项 A2 / G1 / G2 / G3 / G4 / F3):`interrupt` 支持 `scope` + REST `stream:true` 不再静默失败 + `chat.image` 上限 + `clientMsgId` 幂等 + 视觉链路 URL 取回事实 + 既有错误陈述修正**,依据 `Cyrene-For-Android/devdocs/02-前后端协同事项.md` 的 A2 / G1 / G2 / G3 / G4 / F3(commit `7512d59` 等),2026-10-04 落实)。**A. `interrupt` 支持 `scope`(A2,纯新增可选字段)**——上行 `interrupt` 由 `{sessionId}` 扩为 `{sessionId, scope?}`,取值域 `{"", "all", "current"}`(省略 / 空串 / `"all"` **三者同义**,= 现状语义:取消在跑回合 + **清空**待处理队列;老客户端只发 `{sessionId}` 的行为**逐字不变**)。**新增 `"current"` = 只取消在跑回合、保留待处理队列**,队列消息按编排器正常续跑规则继续执行(带上会话尾部上下文)——修的是「客户端『打断』与『追加队列』两种模式被后端当成一种」:打断一律连队列清空时,用户选「打断」后连发的多条消息会被**静默丢弃**,只剩一个 `dropped` 数字。**其余取值 → 定向回** `{type:"error",data:{code:"bad_request"}}`(只回发送者,**不广播、不打断任何会话**),显式报错而绝不静默按默认执行。**下行 `session_interrupted` 帧形状零变化**(`{sessionId, mode, dropped}`),语义改为「如实上报」:`dropped` 恒等于**真的被丢弃**的待处理条数(`scope:"current"` 时为 `0`),且该档 `mode` 为会话当时的**忙碌通道**(`thinking|executing|subagent`,**不是 `idle`**)。实现落点 `internal/services/gateway/session_interrupt.go`(`InterruptScope` / `InterruptScope()`,从编排器主文件拆出以守住 ≤500 行)与 `ws_hub_turn.go` 的 `interruptRequest.Scope` + `parseInterruptScope`(**大小写敏感**、只容忍首尾空白)。**B. REST `stream:true` 不再静默失败(G2,行为变更,客户端必读)**——见 §1 行 3 与 §4.2:该分支改为与 WS `chat` 帧**同一条直达编排器的路径**(design/15 §2.1「会话消息不得被 drop」)。**响应面**:受理成功后才回 `200 {data:{stream:true}}`;正忙已续接 `202 {data:{stream:true,queued:true,queueDepth}}`;队列满 `409 {error:{code:"chat_busy"}}`;幂等键重复 `200 {data:{duplicate:true,messageId,sessionId}}`;**未接 loop 由 `200 {stream:true}` 改为 `500 {error:{code:"chat_unavailable"}}`**(与同步路径同口径);仅**未接编排器**时回退总线派发,其失败回 `503 {error:{code:"chat_dispatch_failed"}}`(修复前 `Publish` 的错误被吞掉、且传的是**请求作用域** ctx)。**`202` / `409` 从此真的会出现**(修复前因「先 return 200」永不出现)。派发用的回合上下文与请求作用域脱钩(`context.WithoutCancel` + 30 分钟上限),回合不会在客户端刚收到 200 时被连带取消。**兼容性**:`stream:false` 与既有响应形状**一字未动**;**依赖「任何情况都拿到 200」的客户端必须按上表分支**。**C. `chat.image` 上限(G3,纯新增校验)**——`POST /api/v1/chat` 的 `image` 新增硬上限:**解码后 5 MiB**(与 `POST /api/v1/upload` 同口径;base64 字符串上限 = `ceil(n/3)*4 + 64` 字符,覆盖 data: URI 前缀余量),超限 → `400 {error:{code:"image_too_large", message}}`(文案指路 `POST /api/v1/upload`),在**任何解码 / 落库 / 入模型之前**拦下(超大 base64 因此不进历史、也不会被重连回放重传;校验按**字符串长度**做——入口层唯一零分配的一步,先拦再接)。**事实(诚实边界)**:**WS `chat` 帧到不了这条 400**——WS 读上限 `wsReadLimit = 4096` 字节/帧,超限由传输层直接断连 ⇒ **WS 上发真实图片本就必须「上传 + URL」**(图片有「上传」与「内联」两条入口,两边给出同一个「一张图能有多大」的答案)。**D. `clientMsgId` 幂等(G4,纯新增可选字段 + 纯新增帧)**——见 §3.13:上行新增**可选** `clientMsgId`(REST `chat` 体与 WS `chat` 帧都支持),同一会话内同一非空值只承认第一条;重复到达 REST 回 `200 {data:{duplicate:true,messageId,sessionId}}`、WS 回**新下行帧 `chat_duplicate`**(`{type:"chat_duplicate", sessionId, data:{sessionId, clientMsgId, messageId}}`,**未进 7 个状态机信号白名单 ⇒ fail-closed 只发 `owner` + `admin`**),且**不落库、不起回合、不发任何内容 / 流式帧**。判重由 `(session_id, client_msg_id)` 的**部分唯一索引**(`WHERE client_msg_id != ''`,空键刻意排除在外——否则同一会话第二条空键消息就会撞唯一约束)**原子裁决**,并发重发只有一个赢家;非空键的写入走 `ON CONFLICT DO NOTHING`。`clientMsgId` 另**回填进消息 DTO** ⇒ `message_persisted` / `channel_persisted` / 握手 `history` 回放 / `GET /sessions/{id}/messages` 可见(空则省略)。**迁移 `0013_chat_client_msg_id`**(up:加列 + 部分唯一索引 `idx_chat_messages_client_msg_id`;down:**先删索引再删列**;全新库由 `history.NewStore` 幂等前滚同一列与索引)。**向后兼容**:省略 `clientMsgId` 时行为逐字不变(老客户端零影响,历史行该键为空)。**E. 视觉链路事实修正(F3,无契约变更)**——`chat.image` 的 http(s) URL(含 `/api/v1/files/`)由**服务端自己取回并内联为 `data:` URI** 再交 provider(**不再原样转给上游**;把同源 / 局域网 URL 透传给云 provider,对方根本取不到,自部署形态下视觉链路会整条不通);同源 upload 产物优先走**本地文件读取**(`vision.WithUploadsDir`)。**类型裁决**:**字节嗅探是唯一裁决权**——按真实字节判白名单光栅图(png/jpeg/gif/webp/bmp),声明的 Content-Type / 扩展名只作参考且可被否决;嗅探为文本(HTML / PROXY 错误页 / SVG)或判不定且来源未声明 `image/*` → **明确报错**(`vision.ErrNotImage`),**绝不静默回退成 `image/png`**(否则模型会对着乱码编描述)。**F. 修正既有错误陈述(G1,`Docs/api-reference.md`)**——§6.2 帧壳表与 §6.6 曾声称结构化流帧只带 `seq`、没有轮次标识,**是错的**:**v0.36 起**结构化流帧(`message.*` / `thinking.*` / `tool_call.*`)就带 `sessionId` + `turnId`(`broadcastStream` 从 `model.StreamEvent` 取,loop 在回合边界注入),v0.50 起 `data` 内另带 `streamId`;两处均已按实现更正(与本文档 v0.36 条目一致)。**G. 环境变量登记**——`.env.example` 新增 **`OWNER_IDENTITY_INJECTION`**(`owner_only` 默认 / `always` / `off`;**非法值回退 `owner_only` 并记 WARN**),并登记 **`CONTEXT_MAX_TOKENS`**(默认 `32768`;`0` / 负 = 关闭压缩)与 **`GUARDRAIL_KEYWORDS`**(逗号分隔;留空 = 不挂关键词护栏),三键见 §2「安全 / 上下文环境变量(v0.56)」。**验证**:A2 四组断言(`scope` 取值域表 / `"current"` 保留队列 / 省略与 `"all"` 清空 / 非法值定向 `bad_request` 且不打断任何会话,含真实 WS 路由 e2e);G2 / G4 端到端断言(同一 `clientMsgId` 发两次只落**一条**用户消息、只起**一个**回合、第二次响应明确报 `duplicate`;`image` 恰等于上限通过、多一字符 `400 image_too_large`;未接 loop 的 `stream:true` 回 `500` 而非 `200`)。**未做(诚实边界)**:`interrupt` 仍**只有 WS 上行**入口(无 REST 版);`clientMsgId` **无服务端过期清理**(键随消息长期保留,判重窗口即该会话历史)。 diff --git a/Docs/api-reference.md b/Docs/api-reference.md index 9805905..04fead6 100644 --- a/Docs/api-reference.md +++ b/Docs/api-reference.md @@ -14,7 +14,7 @@ - **响应统一信封**: - 成功:`{"data": }`(HTTP 200 / 201 / 202 / 204)。 - 失败:`{"error": {"code": , "message": }}`(HTTP 400/401/403/404/409/500/501 等)。 -- **错误码**:业务错误带稳定字符串 `code`(如 `unauthorized` / `not_found` / `bad_request` / `chat_busy` / `not_implemented`,以及 **v0.56** 新增的 `image_too_large`(`chat.image` 超 5 MiB)与 `chat_dispatch_failed`(流式兜底派发失败)),便于客户端分支;`message` 面向人读。 +- **错误码**:业务错误带稳定字符串 `code`(如 `unauthorized` / `not_found` / `bad_request` / `chat_busy` / `not_implemented`,**v0.56** 新增的 `image_too_large`(`chat.image` 超 5 MiB)与 `chat_dispatch_failed`(流式兜底派发失败),**v0.59** 新增的文档四码 `unsupported_media_type`(415) / `document_too_large`(413) / `document_not_found`(404) / `too_many_documents`(400)),便于客户端分支;`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 字节/帧。 @@ -46,7 +46,8 @@ ### 0.3 上传 / 媒体(重要确认) - **`POST /api/v1/upload`**(B1)上传图片 → 返回**同源 URL**(`/api/v1/files/`),可直接作 `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 工具用的模型**;**env-only 部署(无有效 `models.json`,走 `.env` 合成组)用 `LLM_MULTIMODAL`(默认 `false`,v0.58)声明这个能力**——它才是该组 `multimodal` 的 env 等价物,且**非 bool 值启动报错**;主模型不收图时由 vision/OCR 工具返回**文本描述**(另有 `image-crop` 工具可裁区域后重读,便于大图分区精读)。**内联上限(v0.56,2026-10-04 起可配)**:`chat.image` 解码后默认 **5 MiB**(与 `POST /api/v1/upload` 同口径;换算成字符串上限 `ceil(n/3)*4 + 64` 字符,已含 data: 前缀余量),超限 `400 {"error":{"code":"image_too_large",...}}`;字节上限由 `.env` 的 **`CHAT_IMAGE_MAX_BYTES`** 配置(默认 `5242880`;**≤0 视为「用默认」**,不存在「禁用上限」的取值),换配置时错误文案里的 MiB 数字会自动跟随;WS `chat` 帧受 4096 字节读上限约束(超限断连),故 WS 发大图必须走「上传 → URL」。**视觉链路的 URL 取回(v0.56,实现事实)**:`chat.image` 的 http(s) URL(含 `/api/v1/files/`)由**服务端自己取回并内联为 `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`(下行)。 +- **文档存储端点已存在(v0.59)**:`POST /api/v1/documents`(owner/admin + 审计 `document.upload`,multipart 字段 `file`,上限 `CHAT_DOCUMENT_MAX_BYTES` 默认 **20 MiB**)与 `GET /api/v1/documents/{name}`(**必须 Bearer owner/admin**,`attachment` + `nosniff`,与图片读侧刻意相反)。**类型裁决 = 字节嗅探(唯一)∧ 扩展名白名单(22 项)**,`mime` 为嗅探基类型(`data.json` → `text/plain`)、`url` 为**相对路径**;同字节同扩展名重复上传回 `200` + 同一 `name` + `deduped:true`。回合引用走 `chat` 的可选 `documents:[{name}]`(**≤5 条、只收服务端存储名**);历史/实时 DTO 是 **`documents` 数组**(元素 `{name,mime,size,chars,truncated,status}`,无附件时省略该键)。契约见 §3.8 与 api-client §1.2。 +- **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`(下行);**v0.59 起语音回合进会话编排器**(落库 / 流式 / 跨端 / 忙时排队),`voice_output` 增 `sessionId`/`deviceId?`、`voice_input` 增 `clientMsgId?`,见 §6.3/§6.4 与 api-client §3.15。 - 管理端导入知识文档(RAG)走 JSON body 文本(`POST /api/v1/admin/rag`),非文件上传。 --- @@ -68,7 +69,7 @@ | # | 方法 | 路径 | 鉴权 | 状态 | 说明 | 里程碑 | |---|------|------|------|:---:|------|:---:| -| 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` 超上限 → `400 image_too_large`(上限 = `CHAT_IMAGE_MAX_BYTES`,默认 5 MiB 与 `/upload` 同口径;≤0 视为用默认)、`clientMsgId` 重复 → `200 {duplicate:true}` | M8/M15 | +| 3 | POST | `/api/v1/chat` | owner\|admin | 200/202/400/404/409/500/503 | 发消息(同步回复或触发 WS 流式);**v0.56**:`stream:true` 受理成功才回(`202` 续接排队 / `409` 队列满 / `500 chat_unavailable` 未接 loop / `503 chat_dispatch_failed` 兜底派发失败)、`image` 超上限 → `400 image_too_large`(上限 = `CHAT_IMAGE_MAX_BYTES`,默认 5 MiB 与 `/upload` 同口径;≤0 视为用默认)、`clientMsgId` 重复 → `200 {duplicate:true}`;**v0.59**:可选 `documents:[{name}]`(≤5 条、只收服务端存储名)→ 超限 `400 too_many_documents`、名非法/不存在 `404 document_not_found`(见 §3.8) | 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 | @@ -84,6 +85,8 @@ | 16 | POST | `/api/v1/audio/transcribe` | **owner** | 200 | 语音转写(STT,绕开 WS 4KB 帧限);静音/空结果时响应带 `provider` | M17 | | 17 | POST | `/api/v1/audio/speech` | **owner** | 200 | 语音合成(TTS);响应头 `X-Voice-Provider` 报告实际提供方(降级可见) | M17 | | 18 | POST | `/api/v1/audio/voice-clone` | **owner** | 200 | 声音复刻(百炼 create_voice) | M17 | +| 19 | POST | `/api/v1/documents` | owner\|admin | 201/200/400/413/415 | **文档上传(v0.59)**:multipart 字段 `file`(上限 `CHAT_DOCUMENT_MAX_BYTES`,默认 20 MiB)→ `201 {name,url,mime,size,sha256,deduped:false}`;**同字节同扩展名重复上传 → `200` + 同一 `name` + `deduped:true`**;字节嗅探(唯一裁决)∧ 扩展名白名单(22 项),`mime` 为嗅探基类型、`url` 为**相对路径**;错误码 `400 bad_request`/`413 document_too_large`/`415 unsupported_media_type`(见 §3.8) | M-S(design/24 S1) | +| 20 | GET | `/api/v1/documents/{name}` | **owner\|admin** | 200/404 | **文档读取(v0.59)**:**必须带 Bearer**(与图片读侧免鉴权能力 URL 刻意相反);`name` = 32 位小写 hex + 白名单扩展名,路径穿越全形态拒绝;`Content-Disposition: attachment` + `X-Content-Type-Options: nosniff`;嗅探不出放行类型 → `application/octet-stream`;统一 `404 document_not_found`(见 §3.8) | M-S(design/24 S1) | ### 1.3 管理后台(`/api/v1/admin/*`) @@ -154,7 +157,7 @@ **另**:外部 webhook 回调(如 `webhook` 渠道的 HMAC 回调)由 composition root 通过 `Deps.Webhooks` 动态挂载,**不走网关鉴权**(handler 自校验签名/授权);路径由调用方决定,不在上表枚举。 -**补登记说明(2026-10-04)**:上表中 `#62`–`#68` 是本次按 `routes.go` 逐条核对后新增的行(`plugins/installed` / `observability/logs` / `notifications` / `llm/catalog` / `owner-profile` GET+PUT / `automations/reminders`),插在各自的鉴权分档里、**既有编号一律未动**。§1.1 的 `#3`/`#4`(`auth/refresh`、`auth/logout`)与 §1.2 的 `#14`–`#18`(`upload`、`files/{name}`、三支 `audio/*`)同理。**仍未登记的是 WS 事件帧**(见 §6.4 与本文档开头的历史包袱说明)。 +**补登记说明(2026-10-04)**:上表中 `#62`–`#68` 是本次按 `routes.go` 逐条核对后新增的行(`plugins/installed` / `observability/logs` / `notifications` / `llm/catalog` / `owner-profile` GET+PUT / `automations/reminders`),插在各自的鉴权分档里、**既有编号一律未动**。§1.1 的 `#3`/`#4`(`auth/refresh`、`auth/logout`)与 §1.2 的 `#14`–`#18`(`upload`、`files/{name}`、三支 `audio/*`)同理。**v0.59 再补两行**:§1.2 的 `#19`/`#20`(`POST /api/v1/documents`、`GET /api/v1/documents/{name}`,均按 `routes.go` 的 `writeAuth` 注册核对)——同样取本表下一个可用号、不动既有编号。**仍未登记的是 WS 事件帧**(见 §6.4 与本文档开头的历史包袱说明)。 **WS**:`GET /ws/chat`(握手,JWT,见 §6)。 @@ -234,6 +237,7 @@ curl -s -X POST http://:8080/api/v1/auth/login \ | `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`) | | `clientMsgId` | string | 否 | **客户端幂等键(v0.56,G4)**:同一会话内同一个非空值只承认第一条,重复到达回 `200 {"data":{"duplicate":true,"messageId":...,"sessionId":...}}` 且**不落库、不起回合**;省略 = 与加入该字段前逐字相同(老客户端零影响)。原生端用它把本地乐观气泡对齐到服务端真相 | +| `documents` | array | 否 | **文档附件引用(v0.59)**:`[{"name":"<32位hex>."}]`,**≤5 条**、**只收服务端存储名**(`POST /api/v1/documents` 的响应 `name`),**绝不接受任意 URL**;客户端多带的 `mime`/`url`/`size` 一律被服务端覆盖。超出 → `400 too_many_documents`(**先于任何文件系统访问**);名非法/穿越/不存在 → `404 document_not_found`;`text`/`image`/`documents` **至少一项非空**(纯附件回合成立)。WS `chat` 帧同形(见 §6.3) | | `stream` | bool | 否 | `true` = **直达会话编排器**的流式提交(v0.56/G2:接了编排器就直接 `Submit`,**不再经可丢弃的 bus**;未接编排器才回退 `publishChatStream` 发 `ws.chat`),**受理成功后才回响应**;缺省 / `false` = 同步返回完整回复(Go bool 零值即 false,仍**无「必填」约束**) | ```json @@ -248,6 +252,8 @@ curl -s -X POST http://:8080/api/v1/auth/login \ - **错误**: - `400` `{"error":{"code":"bad_request","message":"text or image is required"}}`(空消息)。 - `400` `{"error":{"code":"image_too_large",...}}`(**v0.56/G3**:`image` 解码后超上限;上限 = `CHAT_IMAGE_MAX_BYTES`(默认 **5 MiB**,`≤0` 用默认),base64 字符串上限 `ceil(n/3)*4+64`,与 `POST /api/v1/upload` 同口径,文案指路该端点)。 + - `400` `{"error":{"code":"too_many_documents",...}}`(**v0.59**:`documents` 超过 5 条,先于任何文件系统访问判定)。 + - `404` `{"error":{"code":"document_not_found",...}}`(**v0.59**:`documents[].name` 形态非法 / 路径穿越 / 文件不存在——三种情况同一响应)。 - `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)。 @@ -358,7 +364,8 @@ curl -s http://:8080/api/v1/personas -H "Authorization: Bearer $TOKEN" -H | `mode` | string | 该条的模式(`omitempty`,供渲染气泡不用猜) | | `mood` | string | 情绪标记(`omitempty`) | | `image` | string | 图像引用(`omitempty`) | -| `thinking` | string | 思维链(`omitempty`;**本端点 owner/admin-only,故可达;公开回放/持久化广播中已剥离**) | +| `documents` | array | **文档附件与抽取统计**(`omitempty`,**v0.59**):`[{name,mime,size,chars,truncated,status}]`,**≤5 条**、顺序 = 客户端发送顺序;**0 个附件时该键省略**;`status ∈ extracted|listed|unreadable`。⚠️ **`chars`/`truncated` 是「本回合注入侧」事实**——`chars` = 本回合**注入**上下文的字符数、`truncated` = **注入**是否被截断(**不是**「文件是否被截断」);**全文长度**只有 `document-read` 的 `totalChars` 能看到。**不含正文**。同一字段也出现在 `message_persisted` / `channel_persisted` / `message_edited` / 握手回放(同一结构体、四处同时生效)。⚠️ 单数 `document`(对象)形状**从未随任何已发布版本号发布**,本版起一律是 `documents` 数组 | +| `thinking` | string | 思维链(`omitempty`;**本端点 owner/admin-only,故可达**;**公开回放 / 实时持久化帧中已剥离**)。**v0.59 口径(有意默认,非缺陷)**:消息体的 `thinking` 字段在 `message_persisted` / `channel_persisted` / `message_edited` 与握手 `history` 回放中**恒为空**;**历史消息的思维链只有本端点(owner\|admin)返回**。实时思维链另走 `thinking.*` 流帧(§6.4,owner/admin 档) | | `platform` | string | 来源平台(`web` / `telegram` / `weixin-ilink` / `qq-obv11`...,`omitempty`) | | `deviceId` | string | 客户端端标识(`omitempty`,如 `web:chrome:a1b2c3`) | | `senderName` | string | 第三方发送者昵称(`omitempty`;群聊/陌生人消息带,主人私聊为空) | @@ -482,6 +489,63 @@ curl -s -X POST http://:8080/api/v1/sessions/web/read \ # => {"data":{"platforms":[...],"moods":[...],"modes":[...],"unknown":"display-raw"}} ``` +### 3.8 POST /api/v1/documents · GET /api/v1/documents/{name}(v0.59,design/24 §3/§6) + +> **一句话**:文档 = 内容寻址存储 + 服务端抽取 + 清单/正文注入。**读侧要 Bearer**(与图片读侧「随机名即能力 URL」刻意相反);客户端只要这两个端点 + `chat` 的 `documents` 字段。权威口径与边界表见 api-client §1.2。 + +**A) 上传 `POST /api/v1/documents`(owner\|admin + 审计 `document.upload`)** + +- **请求**:`multipart/form-data`,字段 `file`(单个)。**单文件上限** = `CHAT_DOCUMENT_MAX_BYTES`(默认 **20971520 = 20 MiB**;`≤0` 视为用默认,**不存在「禁用上限」的取值**)。它与图片的 `CHAT_IMAGE_MAX_BYTES` **刻意解耦**(改一边不影响另一边)。请求体额外留 1 MiB multipart 信封余量,**文件本身按确切上限校验两次**(部件大小 + 流式写入时多读 1 字节)。 +- **响应**: + + ```json + {"data":{"name":"a1b2…(32位hex).md","url":"/api/v1/documents/a1b2….md","mime":"text/plain","size":12345,"sha256":"<64位hex>","deduped":false}} + ``` + + 首次 `201`(`deduped:false`);**同字节同扩展名重复上传 → `200` + 同一个 `name` + `deduped:true`**(落盘名 = `sha256(bytes)[:32] + ext`,**内容寻址**,盘上只留一份;元数据行 `ON CONFLICT(name) DO NOTHING` —— 首次的 uploader / 时间戳是权威)。客户端可把 `200` 当成功处理。 +- **落盘位置**:`/uploads/documents/`(目录 0700、文件 0600);客户端原始文件名**只用来取扩展名**,永不参与路径。 +- **类型裁决(唯一裁决权 = 字节嗅探)**: + - **扩展名白名单(deny-by-default,22 个)**:`.txt .md .markdown .csv .tsv .json .jsonl .yaml .yml .log .ini .cfg .toml .go .ts .tsx .java .c .h .cpp .rs .sql`; + - **字节嗅探**(`http.DetectContentType`)的**基类型必须恰为 `text/plain`**; + - 二者是**合取**:`.js` / `.py` 即便嗅探为文本也拒(不在白名单);改扩展名也换不了类型; + - **明令拒绝**:`text/html`、`image/svg+xml` 与 XML、`application/zip` 及封装(docx/xlsx)、可执行/脚本(exe/dll/bat/cmd/ps1/js/mjs/sh/py)、判不定者。 + - 响应里的 **`mime` 是嗅探所得基类型**(去参数),故 `data.json` → `text/plain`(**不是** `application/json`);**`url` 是相对路径**(读侧要 Bearer,绝对化没有意义)。 +- **错误**:`400 bad_request`(缺 `file` 部件 / 空文件 / 非 multipart);`413 document_too_large`(文件或信封超限,文案带生效上限字节数);`415 unsupported_media_type`(扩展名或嗅探任一不过,文案同时点明两条口径);`401` / `403`(未认证 / 角色不足)。**审计**:成功 `document.upload`+`ok`、失败 +`error`(成对,不遗漏)。 + + ```bash + curl -s -X POST http://:8080/api/v1/documents \ + -H "Authorization: Bearer $TOKEN" -F 'file=@notes.md' + # => 201 {"data":{"name":"…(32hex).md","url":"/api/v1/documents/….md","mime":"text/plain","size":1234,"sha256":"…","deduped":false}} + # 再传同一份:=> 200 {"data":{…同一 name…,"deduped":true}} + ``` + +**B) 读取 `GET /api/v1/documents/{name}`(owner\|admin,不审计)** + +- **鉴权**:**必须带 `Authorization: Bearer `**(`owner`/`admin`);无 token → `401`,角色不足 → `403`。这是与 `GET /api/v1/files/{name}` 的**关键差异**——文档摄取在服务端完成,不需要被 `` / provider 直接消费,因此不开「名字即凭据」的免鉴权读侧,也不放开内联服务 HTML/SVG 的口子。 +- **`name` 形态**:`32 位小写十六进制 + '.' + 白名单扩展名`;**路径穿越全形态拒绝**(`..` / `%2e%2e` / `%2f` 等,含 ServeMux 清理前的守卫)。 +- **响应头**:`Content-Type` = **读时重新嗅探**的基类型(嗅不出放行类型 → `application/octet-stream`);`Content-Disposition: attachment; filename=""`;`X-Content-Type-Options: nosniff`;`Cache-Control: private, max-age=86400, immutable`(内容寻址 ⇒ 名字变则内容变);支持 Range。 +- **错误**:`404 document_not_found` —— **名字非法 / 路径逃逸 / 文件不存在三种情况同一响应**(不泄露「是否存在」以外的信息)。 + + ```bash + curl -s -D - -o /tmp/doc.md http://:8080/api/v1/documents/<32hex>.md \ + -H "Authorization: Bearer $TOKEN" + # => 200,头含 Content-Disposition: attachment、X-Content-Type-Options: nosniff + ``` + +**C) `chat.documents`(REST 同步 / REST `stream:true` / WS `chat` 三处同形)** + +```json +{"sessionId":"web","text":"帮我看看这份","documents":[{"name":"a1b2….md"},{"name":"c3d4….csv"}]} +``` + +- **只给 `name`**(服务端存储名);客户端多带的 `mime` / `url` / `size` **一律被服务端覆盖**(MIME 与大小取自落盘字节),**绝不接受任意 http(s) URL**(不重开 SSRF 面)。 +- **≤5 条/回合**:超出 → `400 too_many_documents`,**先于任何文件系统访问**判定;名非法 / 穿越 / 不存在 → `404 document_not_found`;**任一条不合规都让整个请求被拒**(绝不静默丢掉附件)。 +- 附件部件挂在**同一条 `user` 消息**上;**纯附件回合**(无文本、无图片)成立。WS 上行帧仍有 **4096 字节**硬上限 ⇒ 现实中文档走 REST;两条入口共用同一份解析与校验,不会分叉。 +- **WS 侧失败**:经 `error` 帧下发 `data.code`(`too_many_documents` / `document_not_found` / `chat_documents_unavailable`)并**中止本回合**,绝不静默。 +- **历史 / 实时 DTO(四处同源)**:`historyMessage.documents`(见 §3.4 表)——`[{name,mime,size,chars,truncated,status}]`,`status ∈ extracted|listed|unreadable`,**0 个附件时省略该键**;⚠️ `chars` / `truncated` 是**本回合注入侧**事实(全文长度只有 `document-read` 的 `totalChars` 可见)。 + +**D) 内置工具 `document-read`(模型侧)**:`{name, offset?, limit?}` → `{name,status,offset,limit,totalChars,truncated,nextOffset,text,extractTruncated?}`;`limit` 默认 **8000**、上限 **64K** 字符(`≤0` 用缺省、超上限夹取);`offset` 为 **0 基 rune 偏移**、起点 = sidecar 正文(含 CSV/TSV 摘要行);`offset<0` 或 `>totalChars` → 工具错误(`==totalChars` 合法);`extractTruncated=true` 专指「抽取期撞上 **8 MiB** sidecar 安全上限」。只读 sidecar、**绝不重抽**;策略默认 **owner-only**。**诚实边界**:design/24 §6.2 列的 `document_unreadable`(400) **没有对应的 REST 错误码**,它只以本工具的错误形态存在(客户端在 DTO 里看到的是 `status:"unreadable"`)。 + --- ## 4. 管理后台 · 读端点(`owner|admin`) @@ -577,6 +641,7 @@ curl -s http://:8080/api/v1/admin/iot -H "Authorization: Bearer $TOKEN" -H - `personaModels` / `personaProviders`(v0.53,`omitempty`):**角色级模型 / 协议路由表**(`{personaId: modelId}` / `{personaId: "openai"|"anthropic"}`);未配置 = 该键不出现。 - `multimodal`:**主模型自己能否收图**(= 面板的「多模态」勾选);视觉/OCR **工具**的模型是 `.env` 的 `LLM_VISION_MODEL`/`LLM_OCR_MODEL`,与本字段无关(裁决 14.4.1 修语义);**env-only 部署(无有效 `models.json`,走 `.env` 合成组)由 `LLM_MULTIMODAL`(默认 `false`,v0.58)声明本字段**。 - **API key 永不返回**(wire 无 key 字段)。 + - `contextWindow`(v0.59,`omitempty`):该组声明的**模型上下文窗口**(token)。0 / 键缺失 = **未声明** ⇒ 运行时按回落窗口(`DefaultModelContextWindow`,131072)保守推导并记一条 WARN;负数在校验阶段被拒。**它是 `.env` 侧 `LLM_CONTEXT_WINDOW` 的对偶**(per-group 播种)。语义见 api-client §2「上下文窗口与预算契约(v0.59)」。 - 数据源:`models.json` 各组(index 0 = 生效组);无 `models.json` 时按 `.env` 合成单组。curl: ```bash @@ -812,9 +877,9 @@ curl -s -X DELETE http://:8080/api/v1/admin/automations/ -H "Authoriza ``` ### 5.13 POST /api/v1/admin/llm -- **Body**(`llmProviderRequest`):`{"id?","name?","provider?","baseUrl","apiKey?","model","multimodal?","fallback?","maxTokens?","reasoning?","personaModels?","personaProviders?"}`。`baseUrl`+`model` 必填;`provider` 空默认 `openai`(仅允许 `openai|anthropic`);`maxTokens` 缺省/`0` = 未设,负数回 `400`。**v0.53 三键**缺省 = 保留已存值(`reasoning` 整块给出即整块替换;两张 persona 表给出即整表替换);非法 `reasoning.kind`、非法协议名、anthropic 组声明 openai 系形状 → `400 bad_request`(`Group.Validate` 报错,不落盘)。契约见 api-client「模型配置契约(v0.53)」。 +- **Body**(`llmProviderRequest`):`{"id?","name?","provider?","baseUrl","apiKey?","model","multimodal?","fallback?","maxTokens?","contextWindow?","reasoning?","personaModels?","personaProviders?"}`。`baseUrl`+`model` 必填;`provider` 空默认 `openai`(仅允许 `openai|anthropic`);`maxTokens` 缺省/`0` = 未设,负数回 `400`。**v0.59 `contextWindow?`**:该组声明的**模型上下文窗口**(token)——**缺失 = 保留已存窗口**、**显式 `0` 与缺失同义**(都 = 未声明 ⇒ 按回落窗口 131072 推导)、**负数回 `400` 且不落盘**;保存响应的 `` 会把它回显(`omitempty`)。**v0.53 三键**缺省 = 保留已存值(`reasoning` 整块给出即整块替换;两张 persona 表给出即整表替换);非法 `reasoning.kind`、非法协议名、anthropic 组声明 openai 系形状 → `400 bad_request`(`Group.Validate` 报错,不落盘)。契约见 api-client「模型配置契约(v0.53)」与「上下文窗口与预算契约(v0.59)」。 - **响应**:`201 {"data":{"provider":,"persisted":true,"path":""}}`。**真实持久化**:读-改-写 `models.json`,保存组**提升为生效组**(index 0),并**热重载**运行中的 LLM stack(无需重启)。 -- **错误**:`400`(缺 baseUrl/model / invalid provider);`500`(LLM 服务未注入 / 写盘失败 / 热重载失败)。**API key 写入文件但永不回显、不落日志**。 +- **错误**:`400`(缺 baseUrl/model / invalid provider / `contextWindow` 负值);`500`(LLM 服务未注入 / 写盘失败 / 热重载失败)。**API key 写入文件但永不回显、不落日志**。 - **审计**:`llm.addProvider`。curl: ```bash @@ -1122,17 +1187,17 @@ curl -s -X POST http://:8080/api/v1/admin/system/restore \ | type | 数据负载 | 服务端行为 | 结果 | |------|----------|-----------|------| | `mode_switch` | 任意(如 `{"mode":"chat"}`) | 发布 `ws.mode_switch` → hub 规范化广播 | 下行 `mode_switch`(广播) | -| `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` | +| `chat` | `{sessionId?, text, image?, deviceId?, mode?, clientMsgId?, documents?}` | **直达会话编排器的非阻塞 `Submit`(v0.55 起:上行 chat 不再经可丢弃的 bus——会话消息不得被 drop;编排器未注入时回退发布 `ws.chat`)** → `streamChat`(跑 loop 并流式)。`clientMsgId`(**v0.56/G4**)非空且重复到达 → **不落库、不起回合**,只回下行 `chat_duplicate`。**`documents?`(v0.59)** = 文档附件引用数组(≤5、只收服务端存储名;解析失败回 `error` 帧带 `data.code` 并中止本回合) | 下行 `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`(**定向回请求连接**) | +| `voice_input` | `{audio?, format?, text?, sessionId?, deviceId?, clientMsgId?}` | STT→**会话编排器**→结算时 TTS(缺省 sessionId=`web`);`deviceId` 落库、`platform` 走存储默认 `web`;**`clientMsgId?`(v0.59)** 非空且重复到达 → 回下行 `chat_duplicate`,**不产生新回合、不发音频** | 下行 `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`), `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 连接可发**(见本节角色门)。 +- **`chat`**(`chatStreamRequest`):`{sessionId`(缺省`"web"`), `text`, `image?`(base64 data: URI 或 URL), `deviceId?`, `mode?`(行为模式,缺省回落 `chat`), `clientMsgId?`(客户端幂等键,**v0.56/G4**:同一会话内同一非空值只承认第一条,重复到达回下行 `chat_duplicate` `{sessionId,clientMsgId,messageId}`,且**不落库、不起回合**;省略 = 老行为逐字不变), `documents?`(文档附件引用数组,**v0.59**:`[{name}]`,≤5 条、只收服务端存储名;超出 `too_many_documents`、名非法/不存在 `document_not_found`,两处入口共用同一份解析)}。text / image / documents 至少一项非空;`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"}}`。 +- **`voice_input`**:`{audio`(base64 或 data: URI), `format`(`wav|mp3|ogg|webm|mpeg`,空由 mime 猜,默认 `wav`), `text`(已转写则跳过 STT), `sessionId?`, `deviceId?`, `clientMsgId?`}。**v0.59**:`deviceId` 随语音轮次落库(`platform` 走存储默认 `web`)、`clientMsgId` 为**可选幂等键**(重复到达回下行 `chat_duplicate`:不产生新回合、不发音频);**语音回合与文本回合走同一条编排路径**(落库 / 流式 / 跨端 / 忙时排队),音频在**回合结算时**定向回发起连接(`{audio,format,text,reply,sessionId,deviceId?,provider?}`)。无 `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`。 @@ -1162,12 +1227,12 @@ curl -s -X POST http://:8080/api/v1/admin/system/restore \ | `pending_drain` | direct(编排) | `{sessionId, mode, queueDepth}` | 忙完开始消费下一待处理消息 | | `session_idle` | direct(编排) | `{sessionId, mode}` | 会话回到空闲、队列清空 | | `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)拒绝入队 | +| `pending_full` | direct(编排) | `{sessionId, mode, max, clientMsgId?, messageId?}` | 单会话 pending 达上限(默认 5)拒绝入队。**v0.59/G5 纯增量两键**:`clientMsgId` = **被拒那条消息**的幂等键(该消息未携带则整个键缺席);`messageId` = **仅当该消息此前已落库/已认领时**才回填——**绝不在此处新铸 id**(队列满的拒绝发生在幂等键认领之前,代铸会让客户端重试被判成「重复到达」而永远进不来)。**另**:同一时刻向**发送者那条连接**定向回 `{type:"error",data:{code:"chat_busy",message,sessionId,clientMsgId?,messageId?}}`(只发发送者、不广播;`code` 与 REST `409 chat_busy` 同码) | | `chat_duplicate` | direct(编排) | `{sessionId, clientMsgId, messageId}` | **v0.56/G4(新增帧)**:同一会话内**重复到达**的 `clientMsgId` 的明确回执——**不落库、不起回合、不发任何内容帧**。帧壳带 `sessionId`;**未进 7 个状态机信号白名单 ⇒ fail-closed 只发 `owner`+`admin`**(发过该 `clientMsgId` 的客户端用它把本地乐观气泡对齐到既有 `messageId`,其它客户端按值自行忽略) | | `steer_ack` | direct(编排) | `{messageId, atStep}`(帧壳带 `sessionId`,`data` 内不重复该键) | **v0.57(新增帧)**:忙时会话里新到的用户消息**已被在飞回合吃进同一回合**(已从 pending 移除、**尚未被正面回答**)——`messageId` = 那条用户消息的**权威 id**(与 `message_persisted` 的 `data.message.id` 同源)、`atStep` = 吃进它的**模型步序号(1 基)**;一批吃进 N 条发 N 帧(不合并)。客户端把对应气泡渲染为「已读、未答」的**灰态**,直到该回合的 `message.*`/`turn.end` 到达(不需要第二个确认帧)。**只在回合内边界**产生(一次模型步与其工具批次都已结束、下一次模型调用尚未开始;无匹配消息则一帧不发);**不带** `turnId`/`seq`,经 hub 直接广播、**不经 bus**;**未进 7 个状态机信号白名单 ⇒ fail-closed 只发 `owner`+`admin`**(`viewer` 收不到)。详见 api-client §3.14 | | `message_persisted` | direct | `{message:historyMessage}`(sessionId 设) | 每条 user/assistant 消息落库后广播(主会话 `web`/`web:`),thinking 剥离 | | `channel_persisted` | direct | `{message:historyMessage}`(sessionId=`sess-xxx`) | 群聊/陌生人(非主人)消息落库后广播,供 IM 适配器日志页 | -| `voice_output` | direct/target | `{audio, format, text, reply}` | 语音合成回复(定向回请求连接) | +| `voice_output` | direct/target | `{audio, format, text, reply, sessionId, deviceId?, provider?}` | 语音合成回复(**定向回发起 `voice_input` 的那条连接**;**不按 `deviceId` 路由**、断线**不补投**)。**v0.59 纯增量**:`sessionId`(恒有)与 `deviceId`(非空才有)标注归属;`provider` 在**空正文**或 **TTS 失败/降级为空音频**时出现(如 `"silent"`),该帧仍照常发出、不是业务错误。**时点**:该回合**结算时**才到(可能晚于若干状态帧,也可能由续跑或被 steer 注入的那一回合产生) | | `subagent_event` | direct+relay | 审计结果 或 `{id,name,status,role,mode}` | 子代理上下文查询应答 / 子代理进度 | | `task_output` | direct+bridge | `{taskId, output, status}` | 后台任务输出查询应答 / task.output 桥接 | | `capacity_awaken` | direct | `{fromState, toState, tool?}` | 能力状态迁移(design/14 §5,`sessionId` 设) | @@ -1181,7 +1246,7 @@ curl -s -X POST http://:8080/api/v1/admin/system/restore \ | `observability_log` | bridge | `{entries:[{time,level,source,msg}…]}`(最新在前) | **可观测推送(v0.55)**:订阅后新增日志,≤1s 一拍最多一帧、无新条目不发;单帧上限 200 条;**不带 `sessionId`** | | `observability_trace` | bridge | `{spans:[AdminTraceSpan…]}`(最新在前) | **可观测推送(v0.55)**:订阅后新完成的 span;单帧上限 100 条;**不带 `sessionId`** | | `observability_metrics` | bridge | ``(扁平,与 REST 同形) | **可观测推送(v0.55)**:与上次发出的不同才发;**不带 `sessionId`** | -| `error` | direct | `{message}` | 非法 JSON / 未知类型 / 语音·子代理·任务错误等 | +| `error` | direct | `{message}` / `{code, message, sessionId?, clientMsgId?, messageId?}` | 非法 JSON / 未知类型 / 语音·子代理·任务错误等。**v0.59 新增一种定向用法**:队列满时向**发送者**回 `{code:"chat_busy", …}`(G5,见上表 `pending_full` 行);`chat` 帧的文档引用被拒时回 `{code:"too_many_documents"|"document_not_found"|"chat_documents_unavailable", sessionId}` 并中止本回合 | > **`auth`**(旧 design/07 列表):当前**不**由网关直接 emit;若未来有 `ws.auth` 生产者主题,则按 `[relay]` 规则广播。本文档以代码为准。 @@ -1196,7 +1261,8 @@ curl -s -X POST http://:8080/api/v1/admin/system/restore \ | `mode` | string | `omitempty` | | `mood` | string | `omitempty` | | `image` | string | `omitempty` | -| `thinking` | string | **`omitempty`,但在这些实时帧中恒为空**(`BroadcastPersisted`/`replayHistory` 均置空;`/messages` owner/admin 端点才带) | +| `documents` | array | **`omitempty`(v0.59)**:`[{name,mime,size,chars,truncated,status}]`,**≤5 条**、顺序 = 客户端发送顺序;**0 个附件时该键省略**;`status ∈ extracted|listed|unreadable`;**不含正文**。⚠️ `chars` = 本回合**注入**上下文的字符数、`truncated` = **注入**是否被截断(**不是**文件是否被截断;全文长度只有 `document-read` 的 `totalChars` 可见)。同一字段也出现在 `GET /sessions/{id}/messages`(§3.4)与 `message_edited`;单数 `document` 形状**从未随任何已发布版本发布** | +| `thinking` | string | **`omitempty`,但在这些实时帧中恒为空**(`BroadcastPersisted`/`replayHistory` 均置空;`/messages` owner/admin 端点才带)。**v0.59 口径(有意默认)**:实时帧与握手回放的该字段**一律剥离**;实时思维链另走 `thinking.*` 流帧(owner/admin 档),**历史思维链只有 owner/admin 的历史端点返回** | | `platform` | string | `omitempty`(`web`/`telegram`/`weixin-ilink`/`qq-obv11`...) | | `deviceId` | string | `omitempty` | | `senderName` | string | `omitempty`(第三方昵称,主人私聊为空) | @@ -1214,6 +1280,7 @@ curl -s -X POST http://:8080/api/v1/admin/system/restore \ - **续接**:忙完重开时新回合**带上上一轮上下文**(`tail` 默认 20 条),非清空重来。 - **mode** 取值:`idle` / `thinking` / `executing` / `subagent`。 - REST 同步路径(§3.1)在忙时返回 `202 queued` / 满 `409 chat_busy`,与这些 WS 状态事件一致。 +- **队列满的身份回填与定向回执(v0.59/G5)**:`pending_full` 的**广播**载荷增 `clientMsgId?` / `messageId?`(前者 = 被拒那条消息的幂等键,未携带则整个键缺席;后者**仅当该消息此前已落库/已认领**时回填,**绝不新铸 id**);同时向**发送者那条连接**定向回 `{type:"error",data:{code:"chat_busy",…}}`(`code` 与 REST `409 chat_busy` **同码**,客户端一个 code 覆盖两条入口;只发发送者、不广播)。详见 api-client §3.2。 ### 6.6 流式关联(`turnId` / `seq`) @@ -1283,14 +1350,16 @@ TOKEN=$(curl -s -X POST http://:8080/api/v1/auth/login \ | 状态码 | code(示例) | 说明 | |:---:|------|------| | 200 | - | 成功 | -| 201 | - | 创建成功(personas/子会话/apikey/automations/llm/channels config/memories/rag) | +| 201 | - | 创建成功(personas/子会话/apikey/automations/llm/channels config/memories/rag/documents) | | 202 | - | 已接受(chat 排队 `queued:true,queueDepth`) | | 204 | - | CORS 预检 OPTIONS 成功无内容 | -| 400 | `bad_request` / `invalid_cron` / `invalid_rule` / `invalid_role` / `unsupported_action` / `invalid_param` / `invalid_provider` / `invalid_scope` | 请求体/参数非法 | +| 400 | `bad_request` / `invalid_cron` / `invalid_rule` / `invalid_role` / `unsupported_action` / `invalid_param` / `invalid_provider` / `invalid_scope` / `too_many_documents`(v0.59) | 请求体/参数非法 | | 401 | `unauthorized` | 认证缺失/失效/过期(REST Bearer 与 WS) | | 403 | `forbidden` | 角色不足(viewer 读管理/主会话删除/写操作);主会话不可删 | -| 404 | `not_found` | 资源不存在 | -| 409 | `chat_busy` / `duplicate` / `device_offline` / `needs_login` | 冲突:队列满 / 人格已存在 / 设备离线 / 渠道未登录 | +| 404 | `not_found` / `document_not_found`(v0.59) | 资源不存在(文档:名字非法 / 路径穿越 / 文件不存在**同一响应**) | +| 409 | `chat_busy` / `duplicate` / `device_offline` / `needs_login` | 冲突:队列满(**REST 响应体与 WS 定向 `error` 帧同码**,v0.59/G5)/ 人格已存在 / 设备离线 / 渠道未登录 | +| 413 | `document_too_large`(v0.59) | 文档超 `CHAT_DOCUMENT_MAX_BYTES`(默认 20 MiB),文案带生效上限字节数 | +| 415 | `unsupported_media_type`(v0.59) | 文档类型不在白名单——**扩展名白名单(22 项)与字节嗅探的合取**任一不过 | | 500 | `internal` / `chat_failed` / `sessions_failed` / … | 服务端错误(含未注入依赖) | | 501 | `not_implemented` | 后端**确定未实现**(新增用户 / IoT 场景·规则 / 编辑环境变量·重启服务·检查更新 / 完整会话转录导出等)——诚实返回,不伪造成功。**注:插件安装(§5.26)、知识库删除(§1.3 #23)、主人档案保存(`PUT /api/v1/admin/owner-profile`,§1.3 #67)均已实现,不再返回 501** | @@ -1315,8 +1384,9 @@ TOKEN=$(curl -s -X POST http://:8080/api/v1/auth/login \ ### 8.4 里程碑与未实现集合(诚实标注) -- **已真实实现**:登录/健康检查/聊天(同步+流式)/会话(列表/新建/删除/消息分页)/人格/管理读(personas/sessions/memories/plugins/channels/iot/llm/users/automations/backthink/rag/observability×4:metrics/trace/audit/logs)/管理写(人格/API key/设备控制/自动化/LLM 保存·测试/渠道连接·断开·身份测试/渠道配置·删除/KOOK 面板/微信扫码/记忆添加·检索/插件安装·卸载/RAG 导入·删除/主人档案读写(`GET`/`PUT /api/v1/admin/owner-profile`,v0.51)/系统设置 4 项)。 -- **仍为后置 M10(返回 `501 not_implemented`,不伪造)**:新增用户(无用户表/登录)、IoT 场景与规则(无模型/存储)、编辑环境变量/重启服务/检查更新、完整会话转录导出(当前导出身份元数据)。 +- **已真实实现**:登录/健康检查/聊天(同步+流式)/会话(列表/新建/删除/消息分页)/人格/**文档存储与摄取(v0.59:`POST`/`GET /api/v1/documents` + `chat.documents` + `documents` DTO + `document-read` 工具)**/**语音回合进编排(v0.59:落库 / 流式 / 跨端 / 忙时排队,`voice_output` 归属键)**/管理读(personas/sessions/memories/plugins/channels/iot/llm/users/automations/backthink/rag/observability×4:metrics/trace/audit/logs)/管理写(人格/API key/设备控制/自动化/LLM 保存·测试/渠道连接·断开·身份测试/渠道配置·删除/KOOK 面板/微信扫码/记忆添加·检索/插件安装·卸载/RAG 导入·删除/主人档案读写(`GET`/`PUT /api/v1/admin/owner-profile`,v0.51)/系统设置 4 项)。 +- **`thinking` 可达性(v0.59 口径,不改行为)**:消息体 `thinking` 字段在**实时帧**(`message_persisted`/`channel_persisted`/`message_edited`)与**握手回放**中**一律剥离**(有意默认);**实时思维链**走 `thinking.*` 流帧(owner/admin);**历史思维链**只有 `GET /sessions/{id}/messages`(owner/admin)返回。 +- **仍为后置 M10(返回 `501 not_implemented`,不伪造)**:新增用户(无用户表/登录)、IoT 场景与规则(无模型/存储)、编辑环境变量/重启服务/检查更新、完整会话转录导出(当前导出身份元数据)。**文档侧后置**:配额与引用计数/GC(design/24 S7)、PDF/Office 解析(S6,待裁决)、IM 侧 `msg.Media` 摄取(`channelbridge` 只取 `msg.Content`)。**已知缺口**:design/24 §6.2 的 `document_unreadable`(400) 无 REST 落点(只有 `document-read` 工具错误形态)。 - **语义/向量增强**:`memory.search` 的 `semantic` 当前恒为 `false`——**向量索引已由组合根注入**(`cmd/cyrene/wire.go` 的 `memory.WithVectorIndex`),但**索引内当前无向量**(生产无写入),故语义打分返回空,属诚实反映而非伪造;RAG `embedded` 反映部署是否配置向量增强;**配置了增强必须真实走语义/向量路径(红线,非空壳)**。 - **API key 认证**:`X-API-Key` **已接入鉴权**(v0.51,见 §0.1 注与 api-client.md §认证)——命中签发表即通过、一律 `owner` 级(取消 role 分级),带该头时只走 key 分支(坏 key 回 `401`、不回落 Bearer);签发/吊销/列表同样真实生效。