Files
Cyrene_v2/Docs/api-client.md
T
AskaEth c1fa1e9b9a
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(admin): 面板「重置为 env 默认」动作(materialize 的反方向)+ 契约 v0.62
- 语义:只把 .env **确实声明过**的键覆盖回 env 值;env 未声明的键(fallback/multimodal/maxTokens/reasoning/personaModels)原样保留;**组集合不增减**(不复活被面板删的组、不删面板新增的组);无键可改时不写盘。成功即热重载 LLM 栈。返回 {reset:[{group,keys}], skipped:[{group,reason}], path},**只回键名、不回显凭据值**。

- 护栏:必须 confirm:true(缺失/false/非法 body ⇒ 400,且**先于任何读写**);**owner-only**(与同页 POST /admin/llm、/admin/llm/test 同门 —— 这是破坏性写,门不比同族更宽);审计动作 llm.resetFromEnv(camelCase,全仓多词动作的既有约定)。失败路径明确报错(env_defaults_unreadable / models_unreadable / llm_unavailable),不假成功。

- 组合根注入 Deps.EnvPath(网关不 fopen .env):请求时按既有解析入口重读,语义 =「退回 .env 现在声明的默认值」;.env 层里的 provider 是同义反复,不计入「env 为该组声明了什么」的判定(有专门用例)。

- 前端:LLM 页「重置为 env 默认」卡片(中文二次确认 → 提交 → 按 reset/skipped 渲染 → 成功后重拉配置),标签 owner 专属(与 System.tsx 同款)。

- 契约 v0.62:api-client(权限矩阵改回 12+10+38=60、端点表 9b、重置契约小节、写操作契约、changelog、banner)与 api-reference(端点表 #70、§5.13a 详情含 curl、§8.2/§8.3/§8.4)同步;字段名三方逐字对齐。

- 主会话独立验收:隔离 worktree(R1 的 19 文件)build/vet 绿、gateway+config 三包绿、R1 用例 10 PASS、reset_from_env 残留 0;**变异亲自复现**(去掉 confirm 校验 ⇒ RequiresConfirm 红:status = 200, want 400,且响应体显示面板配置真的被改)。

- 子代理自报并已处理:测试文件触 500 行上限 ⇒ 拆成语义侧 + 护栏侧两份;门改回 owner-only 后前端标签会误导 admin ⇒ 改「owner 专属」并重建 bundle。任务书本身仍是旧口径(owner|admin / snake_case)—— 已由主会话登记修正说明,避免压缩后有人按任务书回退。

- 未做(诚实边界):不支持按组重置;不纳入 channels.json/plugins.json;不自动重启(contextWindow 的预算推导仍只在启动时算一次)。新端点需下一次 build+restart 才在 :10942 生效。
2026-10-04 21:33:11 +08:00

800 lines
355 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Cyrene v2 客户端 API 文档(用户侧)
> **完整版见 `Docs/api-reference.md`**(后端全部 API(REST + WebSocket)超详细调用文档,字段/鉴权/状态码以代码为准)。本文档为**摘要**,契约如有出入以 `api-reference.md` 为准。
> **面向**: Web 前端 / 移动端原生客户端 / IM 桥接等**跨平台客户端开发者**。
> **维护铁律**: 每实现/变更一个 API(REST 端点、WS 事件、admin API)**立即更新本文件**;契约变更必须版本化,不破坏既有客户端。
> **状态**: gateway 已在 M8 落地核心端点;M9 前端按此开发。**`✅` = 已实现**,`🕐` = 规划。
---
## 0. 基础约定
- **Base URL**: `http://<host>:8080`(网关唯一对外端口;生产一般经反代/HTTPS)。
- **版本**: REST 全部挂在 `/api/v1/{resource}`;未来破坏性变更升 v2。
- **认证**: `Authorization: Bearer <JWT>`(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": <string>, "message": <string>}}`;状态码 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 行为:`/<ADMIN_PATH>` 与其深链返回**注入了 `<base href="/">` 与 `window.__CYRENE_ADMIN_ENTRY__={"prefix":"..."}` 的 index.html**(`Cache-Control: no-store`);**形如面板入口(路径里出现 `admin` 段)但前缀不符的路径 → `404`**(**不回落用户端聊天壳**);`/<ADMIN_PATH>` 若恰好命中 `dist` 真实文件则仍走文件服务器。**API 不藏**:`/api/v1/*`、`/api/v1/admin/*`、`/ws/chat`、webhook 一律原样,**无新端点、无帧变更**(安全靠 JWT + 角色门,design/07 §0.1)。**口径**:用户端 HTML **永不携带该前缀串**(后端只在命中前缀时注入)⇒ 用户端既无管理入口链接、也解析不出面板路由(design/07 §0.2)。
---
## 1. REST 端点(公开)
| # | 端点 | 方法 | 认证 | 状态 | 说明 |
|---|------|------|------|:---:|------|
| 1 | `/api/v1/auth/login` | POST | 否 | ✅ | `{username,password}` → `{data:{token,expiresAt,role,refreshToken?}}`(access 24h;**新增 `refreshToken`**——不透明随机串,仅哈希落库,`AUTH_REFRESH_TTL` 默认 720h,用于静默续期;role=owner) |
| 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:<bool>}}`;带 `Authorization: Bearer <jwt>` 且无 body → 撤**该账号全部** grant(全设备登出)→ `{data:{revoked:<bool>,revokedCount:<n>}}`。**两种响应形状不同**——单撤**没有** `revokedCount`;两者都不给回 `400 bad_request`。refresh 相关操作成功才审计 |
| 2 | `/api/v1/health` | GET | 否 | ✅ | 健康检查 `{data:{status:"ok"}}` |
| 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` 才解析 `<p>` 拆气泡)。`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:<uuid>`;`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:<uuid>"`)。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` |
| 6a | `/api/v1/messages/{id}` | PATCH | JWT(owner/admin) | ✅ | **编辑单条消息**(D3):body `{content}`(非空、≤4000 rune,空/纯空白/超长回 `400`)→ **`{data:<消息DTO>}`**(`data` **直接就是**更新后的完整消息 DTO,含 `id/sessionId/role/content/.../createdAt`,**无 `message` 包一层**;**只改 `content`**,role/时间戳/平台保持不变);消息不存在回 `404 not_found`;未注入 HistoryStore 回 `501`。**成功后广播下行 `message_edited`**(§3.8,账号其它端免刷新同步)。**重发不单独设端点**——客户端用「编辑后的内容再 `POST /chat`」即可(避免重复轮次语义) |
| 6b | `/api/v1/messages/{id}` | DELETE | JWT(owner/admin) | ✅ | **删除单条消息**(D3)→ `{data:{deleted:<id>}}`;不存在回 `404 not_found`;未注入 HistoryStore 回 `501`。**成功后广播下行 `message_deleted`**(§3.8,账号其它端按 `id` 移除) |
| 7 | `/api/v1/sessions/{id}` | DELETE | JWT(owner/admin) | ✅ | **删除次级会话及其消息** → `{data:{deleted:<id>}}`;主会话不可删回 `403 forbidden`;未注入 HistoryStore 回 `501` |
| 8 | `/api/v1/sessions/{id}` | PATCH | JWT(owner/admin) | ✅ | **会话元数据更新**(A4 改名 + D5 置顶):body `{title?,pinned?}`(**至少一个字段**;都缺回 `400 {"code":"bad_request","message":"at least one of title or pinned is required"}`——说明「缺字段」)。`title` trim、≤80 rune:**存在但为空/仅空白回 `400 "title must not be blank"`**(**不是** `title is required`——字段在、只是值非法)、超长回 `400 "title is too long (max 80 characters)"`;**空串不能清空标题**(不回落自动命名,故客户端不提供「恢复自动命名」入口);`pinned` 为 bool(置顶/取消,主会话与次级会话均可);**`personaId` 出现即 `400`**(v0.46,design/22 §8-1:**会话角色在创建时确定、之后不可变,要换角色请新开会话**)——**不是静默忽略、也不是 200 空操作**:**键出现**(任何取值:`"yuki"` / `""` / `null` / 未知 id / 空白串)一律 `400 bad_request`,错误消息点名 `personaId`,且该检查**先于**会话存在性检查(**未知会话 + `personaId` 也回 `400`**,不是 `404`;连 `title` 同传也整体 `400`、不改任何字段)。响应只回显**本次变更**的字段:`{data:{id,title?}}` / `{data:{id,pinned?}}` / 两项同改则都有(**title-only 保持 A4 原 `{id,title}` 形状**);不存在回 `404`;主会话(`id="web"`)可改名/置顶但不可删;未注入 HistoryStore 回 `501`。另:会话**首条用户消息到达时自动命名** title(取前 20 rune 折叠空白;纯空白回落 `主会话`/`次级会话`),列表不再空名 |
| 8a | `/api/v1/sessions/{id}/read` | POST | JWT(owner/admin) | ✅ | **推进已读游标**(D4 / Android 协同 B3):body `{before?}`——`before` 为**可选**的毫秒 Unix 时间戳,**缺省(或空 body)= 现在**,即「我看到的都算已读」。→ `200 {data:{sessionId,readAt}}`(`readAt` 为推进后的游标毫秒)。**判定**:`createdAt <= readAt` 的消息算已读(列表 `unreadCount` 只数 `createdAt > readAt` 的);**语义**:**会话级单一游标、单 owner 账号**——读一处 = **该账号所有端已读**(不做 per-device 游标,与「同一账号所有浏览器都属于同一 owner」一致);**只前进不后退**(SQL `read_at = MAX(read_at, before)`),所以乱序/并发/重试请求幂等安全、绝不会把已读变回未读。`before` 为 `0`/负数/非数字、或 body 不是 JSON 对象 → `400`;未知会话(含隔离 `sess-xxx`,不在 `chat_sessions`)→ `404`;未注入 HistoryStore → `501 not_implemented`。**成功后广播下行 `session_read`**(§3.9)让同账号其它端清红点;**失败路径不广播**。**升级回填**:迁移 `0010` 把既有会话游标回填为「该会话现有最新消息时间」,升级当天不误报未读 |
| 9 | `/api/v1/personas` | GET | JWT | ✅ | **角色目录**(任意已认证角色可读)→ `{data:[PersonaSummary...]}`,每项 `{id,name,character,isDefault,mode,memoryNamespace,avatar?,chatBackground?,mood}`。**`mood`(v0.43)** = 该角色**当前情绪状态**(角色级,取值见 `GET /meta/enums` 的 `moods`;`""` = 该角色暂无被跟踪的情绪,**不伪造默认值**)。**语义:情绪跟随角色,与会话无关**——同一角色的所有会话看到同一 `mood`;客户端登录/加载角色目录时用它**播种心情徽标**,避免刷新后短暂空白(随后以 WS `mood` 帧为准)。**不含 admin 专属的 `thinking` 旋钮**(用户侧不暴露模型调用参数)。 |
| 10 | `/api/v1/audio/transcribe` | POST | JWT(owner) | ✅ | **语音→文本**:body `{audio:"<base64>",format?,deviceId?}` → `{data:{text, provider?}}`(走后端 STT,百炼 `qwen-audio-3.0-asr-flash`;**当结果为空文本/silent 降级时响应体带 `provider`**(如 `"silent"`,非空文本省略),客户端据此提示「没识别到声音」) |
| 11 | `/api/v1/audio/speech` | POST | JWT(owner) | ✅ | **文本→音频**:body `{text,voice?,format?,instruction?}` → `200` 音频字节(`Content-Type: audio/<format>`,非 JSON envelope,走后端 TTS,百炼 `cosyvoice-v3-flash`,复刻音色可带 `instruction` 情绪指令;**静默降级空音频时仍 `500 tts_empty`,并附 `X-Voice-Provider: silent` 响应头**,客户端据此提示「没生成声音」) |
| 12 | `/api/v1/audio/voice-clone` | POST | JWT(owner) | ✅ | **声音复刻建音色**:body `{url:"<公网参考音频URL>",prefix?}` → `{data:{voiceId}}`(走百炼 `voice-enrollment` create_voice) |
| 13 | `/api/v1/upload` | POST | JWT(owner/admin) | ✅ | **图片上传**(B1):multipart/form-data,字段 `file`(raster image,5MB 上限)→ `201 {data:{url,size,mime,name}}`;`url` 为**同源访问地址**(`<host>/api/v1/files/<name>`,可直接作 `chat.image`)。校验:`MaxBytesReader` 防超大 → `DetectContentType` 嗅探(**不信客户端 Content-Type**)→ 白名单 png/jpeg/gif/webp/bmp(**拒 SVG/HTML**);文件名用 **crypto/rand 128-bit 随机名**(原始文件名丢弃),存 `<dataDir>/uploads/`(0700)。错误码:缺文件 `400 bad_request`、超限 `413 payload_too_large`、非白名单 `415 unsupported_media_type`、未授权 `401/403` |
| 14 | `/api/v1/files/{name}` | GET | 无需(能力URL) | ✅ | **上传文件访问**(B1):从 `<dataDir>/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`/`<img>` 无法带 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**;**v0.61 纳入 `.pdf`**):multipart/form-data,字段 `file` → `201 {data:{name,url,mime,size,sha256,deduped:false}}`;**同字节同扩展名重复上传 → `200` + 同一 `name` + `deduped:true`**(可当成功处理)。单文件上限 **`CHAT_DOCUMENT_MAX_BYTES`(默认 20 MiB,`≤0` 视为用默认)**。类型裁决 = **字节嗅探(唯一裁决)∧ 扩展名白名单(23 项,含 `.pdf`)**,自 **v0.61** 起为**成对裁决**(`.pdf` 只收嗅探为 `application/pdf` 的字节,PDF 字节也只收在 `.pdf` 名下;HTML/纯文本改名成 `.pdf` ⇒ `415`,PDF 改名成 `.txt` ⇒ `415`);`mime` 为**嗅探基类型**(`data.json` → `text/plain`,真 PDF → `application/pdf`);`url` 为**相对路径** `/api/v1/documents/<32hex>.<ext>`。错误码:`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**;**v0.61 增 PDF MIME**):**必须带 Bearer**——与图片读侧「128-bit 随机名即凭据、免鉴权」**刻意相反**;`name` 仅接受「32 位小写 hex + 白名单扩展名」,**路径穿越全形态拒绝**;响应带 `Content-Disposition: attachment` + `X-Content-Type-Options: nosniff` + `Cache-Control: private,max-age=86400,immutable`;嗅探不出放行类型时下发 `application/octet-stream`(真 PDF 按成对裁决如实下发 `application/pdf`,仍因 `attachment` + `nosniff` 永不可内联);名字非法 / 逃出目录 / 文件不存在统一回 `404 document_not_found`(不泄露差异)。详见 §1.2 |
> **语音 REST 源(`/api/v1/audio/*`,均 owner-only + 审计)**:原生/移动端用它上传/获取音频,**绕开 WS 4KB 帧限**。
> - `POST /api/v1/audio/transcribe`:body `{audio:"<base64>", format?:"wav", deviceId?}` → `200 {data:{text}}`。`audio` 为 base64(或 data: URI);`format` 为编码(`wav`/`mp3`/`ogg`,缺省 `wav`);空 audio 回 `400`;未注入语音服务 `500 voice_unavailable`、识别失败 `500 stt_failed`。
> - `POST /api/v1/audio/speech`:body `{text, voice?, format?, instruction?}` → `200` 原始音频字节(`Content-Type: audio/<format>`),**非 JSON envelope**。`voice`/`format` 目前遵循服务端 `VOICE_TTS_VOICE_ID`/`VOICE_TTS_FORMAT` 配置(后续支持按请求覆盖);`instruction` 为**可选** per-request 情绪/语气指令(如 `"语气开朗俏皮,像女朋友撒娇"`),只对**复刻/设计音色**生效——**系统音色 `longxiaochun_v3` 被服务端忽略,永不发送自由指令**(CosyVoice 系统音色需固定格式);长度 ≤100 字符(汉字记 2);未传时回退到服务端 `VOICE_TTS_INSTRUCTION`。空 text 回 `400`;未注入语音服务 `500 voice_unavailable`、合成失败 `500 tts_failed`、静默空音频 `500 tts_empty`。
> - `POST /api/v1/audio/voice-clone`:body `{url:"<公网参考音频URL>", prefix?:"cyrene"}` → `200 {data:{voiceId}}`。`url` 为**公网可访问的参考音频 URL**(create_voice **只收 `input.url`,不收 base64**),音频要求 10~20s 连续清晰朗读、≤10MB、≥16kHz;`prefix` 为音色名前缀(**≤10 字母/数字**,缺省 `cyrene`)。返回的 `voiceId` 填到 `VOICE_TTS_VOICE_ID` 即可用该复刻音色合成(`target_model` 固定为 `VOICE_TTS_MODEL`,须与合成模型一致)。**URL 不写审计/日志(红线,绝不打日志)**。空 url 回 `400`;未注入语音服务 `500 voice_unavailable`;建音色失败 `500 voice_clone_failed`。**⚠️ 真建音色需主人提供公网参考音频 URL(当前无,断言待提供);且需北京地域 API Key(CosyVoice 声音复刻仅华北2北京地域)。**
### 1.1 枚举权威表(`GET /api/v1/meta/enums`,B4)
> **背景**:`platform` / `mood` / `mode` 此前只在文档里给示例(`web`/`telegram`/…;`calm`;`normal`),客户端只能自建映射表——来源角标与情绪标签因此会**随客户端漂移**。本端点发布**权威枚举表 + 未知值回落约定**:Web / Android / IM bridge **共用同一份**,客户端不必硬编码。
- **鉴权**:`Authorization: Bearer <JWT>`,**任意已认证角色可读**(`owner`/`admin`/`viewer`),与 `GET /api/v1/sessions` 同级——只读、无敏感信息。无 token / 令牌无效回 `401`。无其它错误码(纯内存投影,无存储、无 501)。
- **响应**:`200`
```json
{"data":{
"platforms":[{"id":"web","label":"网页"}, ...],
"moods":[{"id":"calm","label":"平静"}, ...],
"modes":[{"id":"chat","label":"对话"}, ...],
"unknown":"display-raw"
}}
```
每项固定为 `{"id":"<枚举 id>","label":"<默认中文标签>"}`。`label` 是**默认值**,自带 i18n 的客户端可覆盖显示;`id` 才是必须比对的值。
- **`unknown`(未知值回落约定,机器可读)**:恒为 `"display-raw"`。语义见下。
**platform(消息来源平台,`{id:label}` 逐值)**
| id | label | 说明 |
|----|-------|------|
| `web` | 网页 | Web 主会话来源(`chat_messages.platform` 默认值) |
| `kook` | KOOK | KOOK 适配器 |
| `qq-obv11` | QQ | OneBot v11(QQ)适配器 |
| `weixin-ilink` | 微信 | 微信 iLink 适配器 |
| `telegram` | Telegram | Telegram Bot API 适配器 |
| `webhook` | Webhook | 通用 Webhook 适配器 |
**mood(情绪,`{id:label}` 逐值)**
| id | label |
|----|-------|
| `calm` | 平静 |
| `happy` | 开心 |
| `excited` | 兴奋 |
| `sad` | 低落 |
| `angry` | 生气 |
> ⚠️ 情绪状态机**可扩展**(persona 可注册更多状态),因此上表**不是封闭集合**——未知 mood 必须按回落约定原样显示。
**mode(行为/呈现模式,`{id:label}` 逐值)**
| id | label |
|----|-------|
| `chat` | 对话 |
| `code` | 代码 |
| `home` | 家居 |
| `work` | 工作 |
| `docs` | 文档 |
| `voice` | 语音 |
| `vision` | 视觉 |
| `agents` | 智能体 |
| `settings` | 设置 |
**未知值回落约定(红线)**
- 客户端遇到**不在表中**的取值(`platform` / `mood` / `mode` 任一):**原样显示该原始字符串**——**不丢弃、不报错、不替换占位符、不猜测**。
- **服务端新增枚举取值是非破坏性变更**(forward-compatible):表中新增一项不改变既有字段/端点形状,旧客户端无需升级;**不要**因为读到未知值而清空角标、抛异常或中断渲染。
- 判定**只比对 `id`**(大小写敏感,契约 id 为小写);`label` 仅作显示默认值。
- Web 端镜像实现在 `frontend/web/src/lib/enums.ts`(`labelFor(kind, value)` 对未知值返回原值),权威仍以本端点为准。
- **反漂移保证(后端)**:后端有测试断言「每个内置适配器的 `Name()` 都在 `platforms` 表内」「`moods` 与 persona 情绪状态机的内置状态一致」「`modes` 与前端 `MODES` 九模式一致」——漏登记即 CI FAIL,客户端拿到的表不会滞后于代码。
### 1.2 文档存储与摄取契约(v0.61,design/24 §3/§4/§6 + §7 S6 第一段)
> **一句话**:文档 = 「内容寻址的存储 + 服务端抽取 + 清单/正文注入」三段;**读侧要 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>.<ext>`),**不是**图片侧那种绝对同源 URL:读侧要 Bearer,贴进 `<img>` 也取不到;客户端真正要用的是 **`name`**(`chat.documents` 只吃它)。
- **类型裁决(唯一裁决权 = 字节嗅探;v0.61 起为成对裁决)**:
- **扩展名白名单(deny-by-default,23 个)**:`.txt .md .markdown .csv .tsv .json .jsonl .yaml .yml .log .ini .cfg .toml .go .ts .tsx .java .c .h .cpp .rs .sql`,**外加 `.pdf`(v0.61 新增,第 23 项)**;
- **字节嗅探(`http.DetectContentType`)与扩展名成对判定**(代码里就一个裁决函数 `documents.ContentAllowed`):**纯文本类**(上列除 `.pdf` 外的全部文本类扩展名)嗅探基类型必须恰为 **`text/plain`**;**`.pdf`** 嗅探基类型必须恰为 **`application/pdf`**(PDF 除外说明:它是白名单里**唯一**签名与纯文本不同的格式);
- 两者是**合取**:`.js` / `.py` 即便嗅探成文本也拒(不在白名单);改扩展名也换不了类型(**嗅探说了算**),且自 v0.61 起**双向**——**HTML/纯文本改名成 `.pdf` ⇒ `415`**(嗅探不是 PDF),**PDF 改名成 `.txt` ⇒ `415`**(PDF 字节也只收在 `.pdf` 名下);
- **明令拒绝**:`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`;真 PDF 的 `mime` 是 `application/pdf` —— 客户端按 `name` 的扩展名判语义,别拿 `mime` 当类型。
- 错误码:`415 unsupported_media_type`(**扩展名白名单(23 项)与字节嗅探的成对裁决**任一不过——如 HTML 改名成 `.pdf`、或 PDF 改名成 `.txt`)/ `413 document_too_large`(文件或 multipart 体超限)/ `400 bad_request`(缺 `file` 部件、空文件、非 multipart)。**审计**:成功 `document.upload`+`ok`、失败 +`error`(成对发生,失败分支不遗漏)。
**② 读取 `GET /api/v1/documents/{name}`(owner|admin,无审计)**
- **必须带 `Authorization: Bearer <JWT>`**:无 token → `401`、角色不足 → `403`。这是与 `GET /api/v1/files/{name}`(免鉴权能力 URL)的**关键差异**——文档摄取在服务端完成,不需要被 `<img>` / provider 直接消费。
- `name` 形态 = `32 位小写十六进制 + '.' + 白名单扩展名`;**路径穿越全形态拒绝**(`..` / `%2e%2e` / `%2f` 等)。
- 响应头:`Content-Type` = **读时重新嗅探**的基类型(**同一对裁决**:纯文本类 → `text/plain`;**真 PDF 如实下发 `application/pdf`**;嗅不出放行类型 → `application/octet-stream`)、`Content-Disposition: attachment; filename="<name>"`、`X-Content-Type-Options: nosniff`、`Cache-Control: private, max-age=86400, immutable`(内容寻址 ⇒ 名字变则内容变);支持 Range。⚠️ **PDF 也永不内联**:`attachment` + `nosniff` 一字未变,浏览器不会把 `.pdf` 当插件文档渲染。
- 错误:`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`(读不到正文)。**PDF(v0.61)**:**文本层 PDF ⇒ `extracted`**;**扫描件/图片型 PDF(无文本层)⇒ 诚实拒绝 `unreadable`**,原因文案含「这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)」——**绝不假装成功、也绝不返回空正文**;**加密 PDF** 与**损坏/截断 PDF** 是**可区分**的两类失败(加密的提示解除密码后重试)。
- ⚠️ **`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 安全上限」**——此后「正文到此为止」≠「原文件到此为止」(它是与「本回合注入被截断」**完全不同的两件事**)。
- **PDF 文本层(v0.61,切片 B1)**:**文本层 PDF 抽正文**(页内容流里的 `Tj`/`TJ`/`'`/`"` 文本算子,页与页之间以换行相接),与纯文本类走**同一条** sidecar / 注入路径;**扫描件/图片型 PDF ⇒ `unreadable`**(见上 ④,需要 OCR,当前未做);**加密**与**损坏/截断**各自成类、可区分。**安全阀**:单份最多抽 **5000 页**、sidecar 正文上限仍是 **8 MiB**(撞上即 `truncated:true`)。**⚠️ 客户端可见的一次性重算**:抽取器版本内部升到 **`documents/v3`** ⇒ **旧 sidecar 一次性失效重抽**,同一份**旧附件**的 `chars`/`truncated` 结论会在**某次回合之后**重新计算(原先被诚实拒为 `unreadable` 的旧 PDF,若是文本层 PDF 就会变成 `extracted`)——这不是附件被改写,是结论被重算。
- **只读 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` |
> **发现的不一致(如实登记,2026-10-04 复核)**:design/24 §9.2 与 §9.13 写「白名单 **22** 项——原文的 23 项是笔误,以代码为准」,但 **B1(commit `f82c76d`)纳入 `.pdf` 后代码已是 23 项**(`internal/runtime/documents/store.go` 的 `ExtAllowed` = 22 个文本类 + `.pdf`;`internal/services/gateway/documents.go` 的名字形态校验经它判定)——**以代码的 23 项为准**;design/24 §9.2/§9.13/§9.4 第 1 条(「不引解析库」)**均已滞后于 B1 实现**(PDF 文本层已用纯 Go 解析库落地;扫描件与 Office 仍诚实拒绝),本次只改 Docs、**未改 DevDocs**。另:design/24 §6.2 的 `document_unreadable`(400) **在代码里没有对应 REST 错误码**,它只以 `document-read` 的工具错误形态存在(见本节 ⑤),客户端可在 DTO 里看到该文档的 `documents[].status = "unreadable"`。
> **契约版本 `v0.62`**(**面板「重置为 env 默认」动作落地:materialize 的反方向显式动作 + 一个新的 owner-only 管理写端点**,依据任务书 `DevDocs/_dispatch/R1-面板重置为env默认.md`、design/04 §3.1(`.env` 为默认/播种层、面板文件为一等来源)与 `DevDocs/开发时间线.md` 的欠账项(「Materialize 已导出、未接端点」),2026-10-04 落实)。**一句话**:materialize 是「面板缺该键才用 `.env` 补」,本版新增的 `POST /api/v1/admin/llm/reset-from-env` 是**反方向的显式动作**——部署者要求「把面板里 `.env` 声明过的键退回 env 值」。**A. 语义(与补齐表同源)**:只覆盖 `.env` 声明了非空值的键(`provider`/`baseUrl`/`apiKey`/`model`/`contextWindow`)、**env 未声明的键一律保留面板值**(`fallback`/`multimodal`/`maxTokens`/`reasoning`/`personaModels` 永不被改)、**组集合不增减**(不复活被删的组、不删新增的组,顺序也不动)、面板值已与 env 一致的键不算「改回」、**无键可改时不写盘**(幂等);真的改了盘就**热重载**运行中的 LLM 栈(`contextWindow` 的预算推导仍只在启动时算一次)。**B. 护栏 / 响应 / 审计**:**owner-only**(与同页两条 LLM 写端点同门;破坏性写不比同族更宽),body 必须 `{"confirm":true}`(缺失 / `false` / 非法 body ⇒ `400 bad_request` 且**一个字节都不动**)→ `200 {data:{reset:[{group,keys:[…]}], skipped:[{group,reason}], path}}`;**只回键名与中文原因,凭据值(尤其 `apiKey`)绝不回显、绝不进日志**;审计动作 `llm.resetFromEnv`(`ok`/`error`)。**C. 基准与失败**:对照基准 = 请求时刻按**既有解析入口**读到的 `.env` 默认层(新增 `config.LoadLLMDefaults`,路径经 `Deps.EnvPath` 注入,**网关绝不 fopen `.env`**);`.env` 源不可读 → `500 env_defaults_unreadable`、面板配置不存在/不可解析 → `500 models_unreadable`、写盘失败 → `500 llm_persist_failed`、已写盘但热重载失败 → `500 llm_reload_failed`、LLM 栈未注入 → `500 llm_unavailable`——**都不假装成功**。**D. 面板**:LLM 页新增「重置为 env 默认」卡片(`admin/modules/llm/LlmResetFromEnv.tsx`,行上标 `owner 专属`,**中文二次确认**后才提交)。**E. 兼容性**:**纯增量**(新端点 + 新前端卡片 + 新审计动作 + 2 个新错误码 `env_defaults_unreadable` / `models_unreadable`,另复用既有 `llm_persist_failed` / `llm_reload_failed` / `llm_unavailable`),既有端点 / WS 帧 / 字段形状**一字未动**;管理侧权限矩阵因此为 12(viewer 只读)+ 10(owner/admin 读)+ **38**(owner-only)= **60** 条路由。详见 §2「重置为 env 默认契约(v0.62)」。**上一版 `v0.61`**(**PDF 文本层抽取落地:白名单第 23 项 `.pdf` + 类型裁决由「嗅探只放行 `text/plain`」改为 `(扩展名, 嗅探)` 成对裁决**,依据 design/24 §7 的 **S6 第一段**(PDF/Office 的裁决:PDF 文本层引库,扫描件与 Office 仍诚实拒绝)与 commit `f82c76d`,2026-10-04 落实)。**三档标注**:**行为变更** = ① **`.pdf` 由「一律 `415`」变为「可上传 + 可读取 + 文本层可抽取进上下文」**(把「`.pdf` 必被拒」写死的客户端必须改);② **类型裁决收紧为成对判定**(`documents.ContentAllowed`)——**方向是收紧/细化,不是放宽**:HTML/纯文本改名成 `.pdf` ⇒ `415`(嗅探不是 PDF),**PDF 字节改名成 `.txt` ⇒ `415`**(PDF 字节也只收在 `.pdf` 名下);**纯增量** = 扩展名白名单 **22 → 23 项**(唯一新增 `.pdf`),其余 22 个文本类的判定与既有响应**一字未变**。**A. 上传(`POST /api/v1/documents`,owner|admin)**——白名单加 `.pdf`;裁决 = **字节嗅探是唯一裁决权 ∧ 扩展名白名单**,自本版起**按对判定**(纯文本类必须 `text/plain`、`.pdf` 必须 `application/pdf`;落点 `internal/runtime/documents/store.go` 的 `ExtAllowed`(23 项)/ `ContentAllowed`);`mime` 仍是**嗅探基类型**(真 PDF → `application/pdf`);`415` 文案同时点明两条口径。**B. 读取(`GET /api/v1/documents/{name}`,必带 Bearer owner|admin)**——**真 PDF 如实下发 `Content-Type: application/pdf`**(读时按同一对裁决重嗅探,判不定者仍退 `application/octet-stream`),`Content-Disposition: attachment` + `X-Content-Type-Options: nosniff` **一字未变** ⇒ **PDF 永不被内联渲染**(与图片读侧刻意相反)。**C. 抽取语义(DTO `status`)**——**文本层 PDF 抽正文**(`Tj`/`TJ`/`'`/`"` 文本算子,页间以换行相接)⇒ `status:"extracted"`,与纯文本类走同一条 sidecar/注入路径;**扫描件/图片型 PDF ⇒ 诚实拒绝 `status:"unreadable"`**,原因含「这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)」,**绝不假装成功、也不返回空正文**;**加密**与**损坏/截断**是**可区分**的两类失败;安全阀 = 单份最多 5000 页 + sidecar 正文 ≤8 MiB。**D. 客户端可见的一次性重算**——抽取器版本内部升到 **`documents/v3`** ⇒ **旧 sidecar 一次性失效重抽**:同一份旧附件的 `chars`/`truncated` 结论会在**某次回合之后**重新计算(旧 PDF 若是文本层 PDF,将从 `unreadable` 变为 `extracted`)。**E. 未做(诚实边界)**——**不做 OCR / 扫描件栅格化**(纯 Go 无可靠实现,会撞「单二进制 + 交叉编译」「零跨进程 IPC」两条红线)、**不做 PDF 写/生成**(只读)、**不做 `.docx`/`.xlsx` 等 Office**(仍诚实拒绝);**无新端点、无新 WS 帧、无鉴权变更、无 `.env` 新增键**。**实现落点**:`internal/runtime/documents/{extract_pdf.go,extract.go,store.go,sidecar.go}` + 依赖 `github.com/ledongthuc/pdf`(纯 Go、无 cgo)。**上一版 `v0.60`**(**管理侧只读「存储状态」端点 + 面板存储卡接真数据**,依据主人 2026-10-04 实测反馈(面板首页「存储」卡一直是占位「SQLite」)与 design/10 §3 模块 14(系统设置)/ design/03 §3(pgvector 可选增强),2026-10-04 落实):**A. 新增 `GET /api/v1/admin/system/storage`**(**owner-only**、只读、**无审计动作**)→ `200 {data:{main,documents,vector}}`——① `main` = `{kind:"sqlite",path,fileBytes,walBytes,shmBytes,pageSize,pageCount,freelistPages,freeBytes}`(`path` 与备份/恢复端点同源;`walBytes`/`shmBytes` 边车不存在时如实为 `0`;`freeBytes = pageSize × freelistPages`);② `documents` = `{count,bytes}`(读 `documents` 表,**不 walk 目录**;空表 `bytes` 为 `0` 而非 `null`);③ `vector` = `{kind:"pgvector"|"none",enabled,ready}`——`kind` 说「配没配 `DB_PG_URL`」,`ready` 说「现在真的在跑什么」(配了但连接/schema 准备失败 ⇒ `enabled:true` + `ready:false`,客户端显示「已配置但未就绪(检索已降级)」,**别把 `enabled` 当生效**)。**B. 两条纪律**:① **不含凭据**——DSN / 用户名 / 密码 / `postgres://` 全文**一个字都不回**(`vector` 段只有三个键;有用例断言响应体里搜不到这些串);② **只读**——只有 `SELECT`/`PRAGMA` + `os.Stat`,复用组合根注入的**活连接**(不新开连接池、不写、不迁移、不备份,调用前后 `documents` 行数与数据目录文件集合均不变)。**C. `vector` 的真源**:组合根 `cmd/cyrene/wire_frontend.go` 的 `app.vectorState()` —— `Configured` 取 `DB_PG_URL` 非空、`Live` 取活索引的具体类型(`*vector.PGVectorIndex`);网关**只读这份注入值**,不读环境变量、不新开 PG 连接、不自行探测(避免面板与运行时给出两个答案)。**D. 失败不伪装**:未接活连接 / 路径未配置 / 读不到页级统计或 `documents` 表(例如库没跑迁移)⇒ `500 {error:{code:"storage_unavailable",message}}`,**绝不回一个全 0 的假快照**。**E. 面板**:`frontend/web` 的存储卡改为真数据(`value` = `SQLite 12.3 MiB`;`hint` = WAL / 页数 / 空闲页;详情两行 = `documents N 件 · …` 与 `PG 增强 …`),取数走与其它已接线卡片**同一条**加载/错误态(数据没到就显示「端点报错:…」,不回退成 `0`)。**F. 兼容性**:**纯新增端点 + 纯新增前端行为**,既有端点 / WS 帧 / 字段形状**一字未动**(前端 `MiniMetricSpec` 新增的 `extra?: string[]` 为可选补充详情行,不传时渲染与旧版逐字等价)。**上一版 `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)**刻意相反**(文档摄取在服务端完成,不需要被 `<img>`/provider 直接消费),响应带 `Content-Disposition: attachment` + `X-Content-Type-Options: nosniff`(浏览器永不内联渲染)。③ **类型裁决(重要)**:**字节嗅探是唯一裁决者**(`http.DetectContentType`;**v0.59 时**放行的嗅探基类型只有 `text/plain`,**v0.61 起改为 `(扩展名, 嗅探)` 成对裁决**、`.pdf` 走 `application/pdf`),**客户端声明的 `Content-Type` 与原始文件名只作参考、可被嗅探否决**;**扩展名白名单 deny-by-default,实际 **22** 个(⚠️ 历史值:**v0.61 起为 23 项**,纳入 `.pdf`)**:`.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 为准**、并已在 v0.61 变为 23 项,见 §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>.<ext>`;图片侧是绝对 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>.<ext>"}]`**(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/<name>`)现在由**服务端自己取回并内联为 `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 行为:`/<ADMIN_PATH>` 与其深链返回**注入了 `<base href="/">` 与 `window.__CYRENE_ADMIN_ENTRY__={"prefix":"..."}` 的 index.html**(`Cache-Control: no-store`);**形如面板入口(路径里出现 `admin` 段)但前缀不符的路径 → `404`**(不回落用户端聊天壳、不泄露正确前缀);`/<ADMIN_PATH>` 若恰好命中 `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`(**信封形状不变**)。**副作用只有一处**:落盘快照 `<DATA_DIR>/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:<uuid>` / `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/*`)
> 仅供管理面板与授权客户端;RBAC:`owner`/`admin`/`viewer`;**写操作全程审计**。角色分档见下方「管理侧权限矩阵」(v0.40 起 `viewer` 真正可用:只读监控档,其余一律 `403`)。
### 2.0 管理侧权限矩阵(v0.40 起,与 `internal/services/gateway/routes.go` 逐条一致)
- **匿名(无 token / token 无效)**:任意 `/api/v1/admin/*` → `401 {error:{code:"unauthorized"}}`(fail-closed,全部 **60** 条管理路由无一例外 —— 12 条 viewer 档 + 10 条 owner/admin 档 + 38 条 owner-only 档)。
- **角色分档**(`✅` 放行,`❌` 回 `403 forbidden`;owner>admin>viewer 递增):
| 端点组 | owner | admin | viewer |
|------|:---:|:---:|:---:|
| **只读监控档**(12 条 GET,见下①) | ✅ | ✅ | ✅ |
| **owner/admin 只读档**(10 条,见下②) | ✅ | ✅ | ❌ |
| **owner-only 写/凭据档**(38 条,见下③) | ✅ | ❌ | ❌ |
① **`viewer` 可读的 12 条只读监控档**(`viewerAuth` = owner/admin/viewer)——模块清单 + 运行时指标/日志/通知,全部是**非敏感投影**(无凭据、无他人账号数据、无消息正文):
`GET /admin/personas`、`GET /admin/sessions`、`GET /admin/plugins`、`GET /admin/plugins/installed`、`GET /admin/channels`、`GET /admin/iot`、`GET /admin/automations`、`GET /admin/rag`、`GET /admin/observability/metrics`、`GET /admin/observability/trace`、`GET /admin/observability/logs`、`GET /admin/notifications`。
② **仅 `owner`/`admin` 的 10 条**(`viewer` → `403`;理由:含**他人数据 / 隐私正文 / 完整导出 / 审计流水 / 全局凭据面**):
`GET /admin/memories`(私人记忆正文)、`GET /admin/channels/messages`、`GET /admin/channels/{platform}/messages`(完整 IM 消息流/转录)、`GET /admin/llm`(provider/模型配置面)、`GET /admin/llm/catalog`(模型目录候选面,**v0.53 新增**)、`GET /admin/users`(账号与 API key 列表)、`GET /admin/backthink`(后台思考记录)、`GET /admin/observability/audit`(审计流水)、`GET /admin/sessions/{id}/export`(会话导出)、`POST /admin/rag/query`(知识库检索,语义上是读,但按「全局知识面」归此档)。
③ **仅 `owner` 的 38 条**(`admin` 与 `viewer` 均 `403`)——全部写操作,外加**凭据获取路径**(`login-qr`/`login-status`,`view`/`friends`,渠道连接/测试/配置)与**主人身份档案**(决定谁能进 owner 车道,连读都不给 admin):
`GET /admin/channels/{platform}/login-qr`、`login-status`、`view`、`friends`;`POST /admin/channels/{platform}/friend/handle`、`message/test`;`PUT|DELETE /admin/channels/{platform}/config`;`POST /admin/channels/open|close|test`;`POST /admin/plugins/policy|install|uninstall`;`POST /admin/personas`、`PUT /admin/personas/{id}`;`GET|PUT /admin/owner-profile`(v0.51);`POST /admin/users`、`POST /admin/users/apikey`、`DELETE /admin/users/apikey/{id}`;`POST /admin/iot/{id}/control`、`POST /admin/iot/scenarios`、`POST /admin/iot/rules`;`POST /admin/automations`、`POST /admin/automations/{id}/run`、`DELETE /admin/automations/{id}`;`POST /admin/llm`、`POST /admin/llm/test`、**`POST /admin/llm/reset-from-env`(v0.62 新增,破坏性写——覆盖面板对若干键的修改,因此门与同页两条 LLM 写端点一致,**不比同族更宽**)**;`POST /admin/memories`、`POST /admin/memories/search`;`POST /admin/rag`、`POST /admin/rag/delete`;`POST /admin/system/revoke-keys|rotate-jwt|backup|restore`。
> **用户侧同级只读**(非 admin 前缀,同样任意已认证角色可读):`GET /api/v1/sessions`、`GET /api/v1/personas`、`GET /api/v1/meta/enums`(§1 行 4/9/15)。
> **已知遗留**:前端管理面板**尚未**按角色隐藏/禁用分区——`viewer` 打开②③档页面会拿到 `403`,客户端需按本矩阵自行降级(后端契约已定,前端适配后置)。
| # | 端点 | 方法 | 状态 | 说明 |
|---|------|------|:---:|------|
| 1 | `/api/v1/admin/personas` | GET | ✅ | 人格列表(`viewer` 可读)→ `{data:[PersonaSummary...]}`,每项含用户侧全部字段,**另加 admin 专属 `thinking?`(v0.43)**:`{budgetTokens,effort,showToUser,mode}`——见下方「人格 `thinking` 读侧契约」 |
| 2 | `/api/v1/admin/sessions` | GET | ✅ | 持久化会话列表(主会话 + 次级会话,design/16 §2);未注入 HistoryStore 时回退渠道会话视图。**v0.42 起每项含可选 `personaId`**(与会话级选角同源,见 §1 行 4) |
| 3 | `/api/v1/admin/memories` | GET | ✅ | 记忆列表(手动添加/检索已实现;编辑/删除/衰减随 M10 扩展) |
| 4 | `/api/v1/admin/plugins` | GET | ✅ | 插件/能力列表(含 MCP/渠道) |
| 5 | `/api/v1/admin/plugins/policy` | POST | ✅ | 写工具策略(owner-only,审计);**策略落盘 SQLite `tool_policies`,重启保留**(迁移 `0012_tool_policy`)——`registry.SetPolicy` **先落库后改内存**,落盘失败则内存保持旧值并返回错误 |
| 5a | `/api/v1/admin/plugins/installed` | GET | ✅ | **已装运行时插件列表**(owner/admin 读):`{data:[{id,name,kind,transport,endpoint?,command?,args?,apiKey?,env?,createdAt?,live,toolCount}]}`,`apiKey`/敏感 `env` 值**掩码**(如 `****1234`),`live` = 是否已连接注册,`toolCount` = 该 server 注册工具数 |
| 6 | `/api/v1/admin/channels` | GET | ✅ | 渠道列表/健康 + 每平台已配账号(脱敏 token 掩码,design/04 §3.1);已连接渠道的账号附 **bot 身份**(`botId`/`botUsername`/`botNickname`,如 KOOK `GET /api/v3/user/me` 自动获取);适配器要求重新授权时附 **`needsRescan`** + **`lastError`**(v0.39,仅微信 iLink 返回 `errcode=-14`、会话被 iLink 侧拒绝这一种);**每账号附可选 `personaId`(v0.44)**=该账号的**渠道级角色绑定**(design/22 §2 方案 b;缺省 = 未绑定,非敏感、不掩码) |
| 7 | `/api/v1/admin/iot` | GET | ✅ | IoT 设备列表 |
| 8 | `/api/v1/admin/users` | GET/POST | ✅(GET) | 用户/角色/API key(GET 已实现;**key 的签发/吊销已实现**——`POST`/`DELETE /admin/users/apikey`,见下方写操作契约;`POST` 创建**用户**仍 `501`) |
| 9 | `/api/v1/admin/llm` | GET/POST | ✅ | LLM 多厂商多模型(models.json):GET 列表(APIKey 脱敏,含 `provider` 协议与 `maxTokens` 预算);POST 保存组→落盘 `models.json`→**热更新**运行中的 LLM stack(design/04 §3.1,无需重启)。**v0.53**:组级新增 `reasoning` / `personaModels` / `personaProviders` 三键(见下方「模型配置契约(v0.53)」) |
| 9a | `/api/v1/admin/llm/catalog` | GET | ✅ | **模型目录候选**(owner/admin 读,v0.53;`?refresh=1` 强制在线刷新)——面板「添加提供商 / 添加模型」的 baseURL / 模型清单 / 能力位来源,服务端快照 + 离线兜底;失败 `503 catalog_unavailable`。见下方「模型配置契约(v0.53)」 |
| 9b | `/api/v1/admin/llm/reset-from-env` | POST | ✅ | **重置为 env 默认**(**owner-only 写**,v0.62,审计 `llm.resetFromEnv`):body `{"confirm":true}`(缺 = `400`)→ `200 {data:{reset:[{group,keys}], skipped:[{group,reason}], path}}`——**只覆盖 `.env` 声明过的键**、env 未声明的键原样保留、**组集合不增减**,无键可改时**不写盘**;**只回键名、绝不回显凭据值**。见下方「重置为 env 默认契约(v0.62)」 |
| 10 | `/api/v1/admin/automations` | GET/POST | ✅(GET) | 自动化与提醒(GET 已实现;POST 创建 M10) |
| 11 | `/api/v1/admin/backthink` | GET | ✅ | 后台思考记录 |
| 12 | `/api/v1/admin/rag` | GET | ✅ | 知识库文档列表(分块/嵌入状态);带 `?query=` 时并入检索结果 |
| 13 | `/api/v1/admin/rag/query` | POST | ✅ | 知识库检索(真实检索;配置向量增强走语义路径) |
| 14 | `/api/v1/admin/rag/delete` | POST | ✅(写) | 删除知识库文档(**真实删除**:项目索引条目 + 级联章节);owner-only,`{id}` → `{id,deleted:true}`,不存在 `404` |
| 15 | `/api/v1/admin/observability/metrics` | GET | ✅ | 网关请求指标(总数/QPS/延迟/方法·路径·状态计数/LLM·子代理计数) |
| 16 | `/api/v1/admin/observability/trace` | GET | ✅ | 最近 trace span(环形缓冲,默认 512 条;**loop/工具/编排的真实 span**) |
| 17 | `/api/v1/admin/observability/audit` | GET | ✅ | 审计记录(who/what/when/result,SQLite 持久化) |
| 17b | `/api/v1/admin/observability/logs` | GET | ✅ | **后端日志 tail**(logger 环形缓冲,默认 2000 条;`?level=`/`?limit=`,见下方「可观测性契约」) |
| 17a | `/api/v1/admin/notifications` | GET | ✅ | **统一通知 feed**(审计 + 主动推送聚合为只读 feed,design/10 §3 #16;见下方「通知聚合契约」) |
| 18 | `/api/v1/admin/memories` | POST | ✅(写) | 手动添加记忆(owner-only,审计);body `{personaId?,content,scope?}`,无 store 回 `501` |
| 19 | `/api/v1/admin/rag` | POST | ✅(写) | 导入知识文档(owner-only,审计);body `{name,desc?,content}`,分块后落项目索引可检索;无 store 回 `501` |
| 20 | `/api/v1/admin/system/revoke-keys` | POST | ✅(写) | 吊销**全部** API key(owner-only,审计);返回 `{revoked:n}`——key 表清空,先前签发的所有 key 立即失效 |
| 21 | `/api/v1/admin/system/rotate-jwt` | POST | ✅(写) | 轮换 JWT 密钥(owner-only,审计);body `{newSecret}`(≥32 位,不强校验熵)→ `{rotated:true,note,refreshTokensRevoked}`——**现有 access token 立即失效需重新登录,且**所有 refresh token 一并作废(`refreshTokensRevoked` 为删除条数);新密钥**不回显、不落日志** |
| 22 | `/api/v1/admin/system/backup` | POST | ✅(写) | 备份数据(owner-only,审计);用 SQLite `VACUUM INTO` 快照(含已提交 WAL 内容)到 `<dataDir>/backup-<ts>.db` → `{path,size}` |
| 23 | `/api/v1/admin/system/restore` | POST | ✅(写) | 恢复数据(owner-only,审计);body `{path}` → 校验路径在 dataDir 内→**先备份当前**→替换 DB 文件→`{restored:true,backup,note:"需重启后生效"}`(运行进程持有 DB 句柄,热替换需重启可见) |
| 24 | `/api/v1/admin/system/storage` | GET | ✅ | **存储状态(只读,v0.60)**:owner-only、**无审计动作**;`{data:{main:{kind:"sqlite",path,fileBytes,walBytes,shmBytes,pageSize,pageCount,freelistPages,freeBytes},documents:{count,bytes},vector:{kind:"pgvector"|"none",enabled,ready}}}`——**绝不含凭据**(DSN/密码/`postgres://` 全文一个字都不回);未接活连接 / 路径未配置 / `documents` 表读不到 → `500 storage_unavailable`(**不回全 0 假快照**)。详见下方「系统设置契约」 |
> **M10 前端契约补充(管理面板)**:`GET /api/v1/admin/users` · `GET /api/v1/admin/llm` · `GET /api/v1/admin/automations` · `GET /api/v1/admin/backthink` · `GET /api/v1/admin/rag` · `POST /api/v1/admin/rag/query` · `GET /api/v1/admin/observability/metrics` · `GET /api/v1/admin/observability/trace` · `GET /api/v1/admin/observability/audit` · `GET /api/v1/admin/observability/logs` · `GET /api/v1/admin/notifications` 已实现,返回真实数据(**日志 tail 见下方「可观测性契约」**;未注册 `GET /api/v1/admin/logs`)。**写操作已补齐**(见下方「管理面板写操作契约」):人格/API key/设备控制/自动化/LLM 测试/渠道连接与识别测试/记忆检索/会话导出/插件卸载**真实持久化或真实生效**;**系统设置安全与存储类**(吊销 API key / 轮换 JWT 密钥 / 备份 / 恢复)也已**真实实现**(见下方「系统设置契约」);**主人档案保存(v0.51)同样已实现**——`GET`/`PUT /api/v1/admin/owner-profile` 真实落盘 + 热替换活档案,面板的「保存」不再是假成功;仍为后置 M10:**新增用户**、**IoT 场景与规则**、**编辑环境变量/重启服务/检查更新**——后端如实返回 `501 not_implemented`,不伪造成功。(**知识库删除已实现**:`POST /admin/rag/delete` 真实删除,见下方写操作契约;**插件安装已实现**:v1 为 MCP server 热安装。)
**模型配置契约(v0.53)**(依据 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);判定与校验以 `internal/config/runtimeconfig` + `internal/services/gateway/admin_llm*.go` 为准):
- **`GET /api/v1/admin/llm/catalog`(owner|admin 读,v0.53)** → `200 {data:{...}}`:
- **请求**:`?refresh=1` = 强制在线刷新;不带时,本地快照仍在 **24h TTL** 内就直接用它(不每次打开面板都打外网)。
- **响应 `data`**:`{source:"cache"|"models.dev", fetchedAt?, stale?, warning?, providers:[{id,name,api?,doc?,env?,npm?,models:[{id,name,attachment,reasoning,toolCall,contextLimit?,outputLimit?}]}]}`
- `source` 说明这份候选从哪来(`models.dev` = 本次在线抓取,`cache` = 本地快照);`fetchedAt` 为快照抓取时间(RFC3339,无快照时省略);**`stale:true` + `warning` 只在「在线刷新失败、返回上次快照」时出现**(离线兜底,**绝不编造候选**)。
- `api` = 该供应商 baseURL(目录未标注时省略,面板提示需手填);`doc` = 文档地址、`env` = 目录标注的官方 key 环境变量名(仅提示,面板不写 `.env`)、`npm` = SDK 包名。
- 每个模型:`attachment`(接受图片/文件输入 → 面板预填 `multimodal`)、`reasoning`、`toolCall`、`contextLimit?`/`outputLimit?`(目录未标注时为 `0` 而省略;`outputLimit` 直接作 `maxTokens` 建议值)。
- 供应商与模型都**按 id 排序**(输出确定、便于前端 diff);**没有模型清单的供应商不进列表**(避免噪声行)。
- **失败**:**既无本地快照又抓不到** → `503 {error:{code:"catalog_unavailable", message}}`(message 给出「可改用粘贴导入」的指引);**有快照但刷新失败** → **仍 `200`**(`source:"cache"` + `stale:true` + `warning`);快照文件损坏/为空只记 WARN 并按「无快照」继续在线抓取,**不影响本次回答**。
- **压缩**:客户端带 `Accept-Encoding: gzip` 时响应带 **`Content-Encoding: gzip`**(目录约 1MB 量级);**信封 `{data}` 形状不变**,浏览器与 Go 客户端透明解压;不带该头时返回明文并带 `Content-Length`。
- **副作用(只有这一处)**:落盘快照 `<DATA_DIR>/llm_catalog.json`(与 `models.json` 同目录、`data/` 已 gitignore、**不含任何凭据**、TTL 24h);**不修改 `models.json`**。LLM stack 未注入时快照落到系统临时目录,**绝不往工作树写文件**。
- **抓取只发生在本端点内**(数据源 `https://models.dev/api.json`,单次超时 20s、体积上限 64MiB):**不进运行时热路径**——模型请求链路从不读这份外网目录。
- **审计**:成功记 `llm.catalog` / `ok`,返回过期快照记 `stale`,失败记 `error`(可用 `GET /admin/observability/audit` 查)。
- **`models.json` 组级新键(v0.53,可热更新;`GET`/`POST /api/v1/admin/llm` 已同步这三个键的读与写)**:
- **`reasoning: {kind}`**:`kind` ∈ `"auto"`(= 缺省)/`"deepseek"`/`"openai"`/`"vllm"`/`"dashscope"`,决定该组用哪种**提供商开关形状**下发推理指令(vLLM → `chat_template_kwargs.enable_thinking`;DashScope → 顶层 `enable_thinking`;DeepSeek → 顶层 `thinking:{type}` + `reasoning_effort`;OpenAI 系 → 只有 `reasoning_effort`;`auto` = 按模型名/baseURL 推断厂商,**形状有歧义就不下发任何字段**)。
- **校验**:不在上列取值 → 组校验失败(`POST /admin/llm` 回 `400 bad_request`);**anthropic 组上声明 `deepseek`/`openai`/`vllm`/`dashscope` 同样拒绝**(Anthropic 用自己的 `thinking` 形状)。
- **请求语义**:`reasoning` 键**缺失 = 保留已存形状**;给出(哪怕 `{}`)= 整块替换(`kind` 缺失/空串 = 回到「未声明」)。
- **响应语义**:每组的 `reasoning` **恒下发**(`kind:""` = 未声明形状,不是「后端没报告」);顶层另下发 **`reasoningKinds`**(合法形状取值域,**不含空串**——`""` 是「不声明形状」而非一个形状名,面板另行给出该选项)与 **`personaProviderOptions`**(合法协议名域,即 `openai`/`anthropic`)供面板渲染下拉框——取值域由后端常量直接生成,**面板无需硬编码**。
- **`personaModels: {personaId: modelId}`** = **角色级模型路由**(「角色」= 该回合答话的 persona,**不是**消息作者的 role);**`personaProviders: {personaId: "openai"|"anthropic"}`** = **角色级协议路由**(跨协议换 adapter,配合 `personaModels` 使用)。
- **校验**:persona id / 值不得为空,`personaProviders` 的值必须是 `openai` 或 `anthropic`,否则组校验失败(`400`);键值首尾空白会被规整,**规整后撞键直接报错**(不静默丢一条)。
- **回落**:未绑定 / 绑定到未知 persona → 该组默认模型;`personaProviders` 声明的协议在 `models.json` 里**没有对应组** → 记 WARN 并**跳过**(该角色仍走默认链,**不半建一条只会失败的链**)。
- **请求语义**:两个键**缺失 = 保留已存表**;给出(含 `{}`)= **整表替换**(用整表替换而非合并,面板才能删除一条已存路由);未声明时响应中该键不出现(`omitempty`)。
- **语义修正(重要)**:`multimodal` = **主模型自身能否收图**(**不是**「本部署有没有视觉能力」);`LLM_VISION_MODEL` / `LLM_OCR_MODEL` **只配视觉/OCR 工具用的模型**,与 `multimodal` 互不代表(裁决 14.4.1,见 §1 行 3)。
- **`.env` 新增 10 键(v0.53 首登 9 键,v0.58 追加 `LLM_MULTIMODAL`;`.env` 是 `models.json` 的兜底,对齐 `.env.example`)**:
| 键 | 默认 | 语义 |
|---|---|---|
| `LLM_REASONING_KIND` | 空(= `auto`) | **仅供 `.env` 合成组**的推理开关形状声明(**无有效 `models.json` 时**才生效,取值同 `reasoning.kind`;有 `models.json` 时以该组的 `reasoning.kind` 为准) |
| `LLM_OCR_MODEL` | 空 | **OCR 工具**用的模型;空 = 复用 `LLM_VISION_MODEL`,两者都空 = 复用主模型(`LLM_MODEL`) |
| `LLM_MULTIMODAL` | `false` | **声明「主模型自身能否收图」**(`bool`,v0.58 新增)——即 `models.json` 组 `multimodal` 的 **env 等价物**。**仅供 `.env` 合成的模型组**(**无有效 `models.json` 时才生效**;有组文件时以该组的 `multimodal` 为准,与 `LLM_REASONING_KIND` 同口径)。`true` = 图片**直进主模型上下文**;**留空 / `false`(默认)= 把图交给 vision/OCR 工具,换成文本描述**(图像不占上下文,只有工具结果占)。**绝不凭 base URL / 模型名猜测**端点是否收图;**非 bool 值(如 `maybe`)启动即报错**(fail-fast,不静默回退成 `false`)。`LLM_VISION_MODEL` / `LLM_OCR_MODEL` 配的是**视觉/OCR 工具调用的模型**,与本键互不代表 |
| `LLM_LOCAL_ENABLED` | `false` | 降级链**第三级(本地模型)**总开关;默认关,链保持「主 → 备」两级 |
| `LLM_LOCAL_PROVIDER` | `openai` | 本地端点协议(`openai`/`anthropic`) |
| `LLM_LOCAL_BASE_URL` | 空 | 本地端点 base URL(如 `http://127.0.0.1:8080/v1`) |
| `LLM_LOCAL_API_KEY` | 空 | 本地端点 key(本地服务通常为空) |
| `LLM_LOCAL_MODEL` | 空 | 本地模型名 |
| `CAPACITY_L3_ENABLED` | `false` | capacity 意图判定的 **L3(模型辅助分类)**开关;默认关,只走 L1/L2 启发式 |
| `CAPACITY_L3_CACHE_TTL` | `5m` | L3 意图判定结果缓存 TTL |
- `LLM_LOCAL_*` **三个条件缺一不可**:`LLM_LOCAL_ENABLED=true` **且** `LLM_LOCAL_BASE_URL` 非空 **且** `LLM_LOCAL_MODEL` 非空;只开开关而不配端点/模型时**记 WARN 并跳过该级**(绝不半配一个只会失败的链尾步)。
- **内置工具新增 `image-crop`(v0.53)**:按区域裁切图片(参数 `image` + `x`/`y`/`width`/`height`,**0 基像素、区域须完整落在图内**)→ 返回 `{image:"data:image/png;base64,…", format:"image/png", width, height, region:{x,y,width,height}, source:{width,height}}`;用途是**上游可能压缩分辨率、大图细节会丢**,让模型自行裁出区域再交给 vision/OCR 工具精读(design/06 §3,裁决 14.4.5)。上限:源图 ≤50MiB、区域 ≤16MP、返回载荷 ≤8MiB(超出**报错**而非截断/降采样);源可以是本地路径(走 file 工具同一沙箱)/ http(s) URL(同 SSRF 守卫)/ base64 data URI。**策略与其它工具一致**:`tool_policies` 无行时按最严默认 **owner-only**(群聊/陌生人的调用被策略层拒,见 §2 行 5)。
**安全 / 上下文环境变量(v0.56)**(`.env` / 环境变量;与 `.env.example` 逐键对齐):
| 键 | 默认 | 语义 |
|---|---|---|
| `OWNER_IDENTITY_INJECTION` | `owner_only` | **主人身份说明书(`owner.yaml:identityFile`)注入范围**:`owner_only`(默认,**仅主人会话**,按**渠道层强类型 actor 元数据**判定)/ `always`(**所有会话**,含群聊与陌生人私聊,**仅本地自测/排障**——它显式开启红线例外)/ `off`(完全关闭,连主人会话也不注入)。取值**大小写不敏感、容忍首尾空白**;**非法值回退 `owner_only` 并记 WARN**(绝不静默放行、也绝不静默失效)。它只决定「那段知识进不进该会话的上下文」,**不参与任何身份/权限判定**(design/11 §0):文件内容既不参与 `IsOwner`,也不改变授权。 |
| `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 **只在启动时**跑;面板保存走「整组写入 + 热重载」,**那里不跑补齐**——否则面板刚显式清空的键会被立刻补回。
**「重置为 env 默认」契约(v0.62,`POST /api/v1/admin/llm/reset-from-env`)**(任务书 R1;判定以 `internal/config/runtimeconfig/reset.go` + `internal/services/gateway/admin_write_llm_reset.go` 为准):
- **它是什么**:materialize(上一节)的方向是「面板缺该键 ⇒ 用 `.env` 补」;本动作是它的**反方向显式动作**——部署者明确要求「**把面板里这些键退回 `.env` 声明的默认值**」(例如面板把 `apiKey`/`baseUrl` 改坏了,一键回到 env 基线)。两者**共用同一张键表**(`runtimeconfig` 的 `groupFillSpecs`),因此「本动作能改回的键集合」与「materialize 会补齐的键集合」结构性一致。
- **鉴权 / 审计**:**`owner` 专属**(与同页 `POST /admin/llm`、`POST /admin/llm/test` **同门**;`admin` 与 `viewer` 均 `403`)——它是破坏性写(覆盖面板对若干键的修改),**门不比同族写端点更宽**;带审计动作 **`llm.resetFromEnv`**(成功 `ok`、被拒/失败 `error`;多词动作沿用本仓既有 camelCase 约定,与 `llm.addProvider` / `persona.update` 同风格)。
- **请求**:body **必须** `{"confirm":true}`。**缺失 / `false` / 非法 body 一律 `400 {error:{code:"bad_request"}}`**,且**一个字节都不动**——本动作用 env 值覆盖面板修改,属破坏性操作,缺护栏的默认必须是「不执行」。
- **语义(逐条)**:
- **只动 `.env` 声明了非空值的键**(`provider` / `baseUrl` / `apiKey` / `model` / `contextWindow`——与 materialize 的补齐表同集;`contextWindow` 只认正数,`fallback` / `multimodal` / `maxTokens` / `reasoning` / `personaModels` 在 `.env` 里没有出处,**永不被本动作改动**)。缺省协议族按**组自己的 `provider`** 选(`.env` 同时配了扁平块与 `ANTHROPIC_*` 时,两族各拿自己的值)。
- **env 未声明的键一律保留面板值**(含面板显式清空/`false`/`null` 的键)。
- **组集合不增减**:不复活被面板删掉的组、不删面板新增的组,**顺序也不动**(本动作只改键值)。
- **面板值已与 env 一致的键不算「改回」**,因此不出现在 `reset` 里(`provider` 恒属这种情形:env 默认层总为两族各声明一次协议名,而面板组本来就与之相同)。
- **没有任何键需要改时一个字节都不写**(幂等;重复调用第二次会返回 `reset:[]` + 每组一条「已一致」的 `skipped`)。
- **真的改了盘就热重载**运行中的 LLM 供应商栈(design/04 §3.1,无需重启)——但注意 `contextWindow` 的**预算推导**仍只在进程启动时算一次(与 `POST /admin/llm` 同一口径,面板文案已写明「重启后生效」)。
- **响应**:`200 {data:{reset:[{group,keys:[…]}], skipped:[{group,reason}], path}}`
- `reset`:**真的被改回**的分组,`group` = 组 id、`keys` = `models.json` 的 JSON 键名数组(顺序 = 补齐表顺序)。**只回键名,值(尤其 `apiKey`)绝不回显。**
- `skipped`:未被改动的分组 + 中文原因(`"面板值已与 `.env` 默认一致,无需重置"` / `` "`.env` 未声明该组的默认键(面板值原样保留)" ``)。两组键**恒为数组**(无记录时是 `[]`,不是 `null`)。
- `path`:被改写(或本应改写)的 `models.json` 路径,与 `POST /admin/llm` 同源。
- **对照基准**:**请求时刻按既有 config 解析入口读到的 `.env` 默认层**(`config.LoadLLMDefaults`;优先级与启动时 `config.Load` 逐字一致:进程环境 > `.env` > 标签默认)。⇒ 部署形态(systemd/env_file,无 `.env` 文件)下读到的是进程环境里那一份;`.env` 改了而没重启时,本动作按**文件当前内容**回退,而重启后 materialize 也用同一份——两者不冲突。
- **错误(都绝不改文件,且都带审计 `error`)**:`.env` 源不可读 → `500 {error:{code:"env_defaults_unreadable"}}`;面板配置不存在/不可解析 → `500 {error:{code:"models_unreadable"}}`;写盘失败 → `500 {error:{code:"llm_persist_failed"}}`;已写盘但热重载失败 → `500 {error:{code:"llm_reload_failed"}}`(说明文件已改、未生效);LLM 栈未注入 → `500 {error:{code:"llm_unavailable"}}`。
- **面板**:LLM 页的「**重置为 env 默认**」卡片(`frontend/web/src/admin/modules/llm/LlmResetFromEnv.tsx`,行上标 `owner 专属`)——点按钮先展开中文**二次确认**文案,确认后才带 `confirm:true` 提交;结果按 `reset`/`skipped` 渲染成可复核的组名 + 键名摘要。
**管理面板写操作契约**(`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/<id>.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`(同一条消息)——**不派生默认串、也不静默沿用旧值**,请求被拒即不落盘。
- `thinking`(可选,design/01 §2.1)`{budgetTokens?,effort?,showToUser?,mode?}`:写入 YAML 的 `thinking` 节,驱动该人格的思考预算/开关/呈现。`mode` ∈ `default|on|off`(缺省 `default`),非法 `mode` 或负 `budgetTokens` 回 **`400 bad_request`**(`invalid thinking config: …`)。**`showToUser` 是 bool 且可缺省**:**缺省 = 保持现行为(thinking 帧照常推客户端)**;显式 `false` = 只落库不回显。**`PUT` 省略整个 `thinking` 对象时保留已存的 `thinking` 节**(不因客户端不认识该字段而抹掉);显式传对象则整体替换(字段全缺省即清空)。该字段**不构成能力边界**。
- **人格 `thinking` 读侧契约(v0.43,回读形状)**:`GET /admin/personas` 每项按**与写侧同构**的形状回读 `thinking`,供面板回填:
- **未配置的人格:`thinking` 键完全不出现**(不是全零对象)——与写侧「省略 `thinking` 对象 = 保留已存」互为镜像,客户端可有样学样地「原样回填 / 原样省略」。
- 已配置时 **四个键都出现**:`budgetTokens`(number,未设为 `0`)、`effort`(string,未设为 `""`)、`showToUser`、`mode`(string,未设为 `""`)。
- **`showToUser` 是三态、且读侧如实三态**:**`null` = 未配置(保持现行为:thinking 帧照常推客户端)**;`true`/`false` = 显式选择(`false` = 只落库不回显)。**服务端不会把 `null` 拍平成 `false`**(写侧用的是 `*bool`,读侧同样保留指针语义),否则「没配过」会被面板误显示成「关」。
- **能力提示由服务端负责,客户端不要假装**:`effort` 的**有效白名单随当前生效 provider** 而定(OpenAI 兼容系 `none/minimal/low/medium/high/xhigh/max`;DeepSeek 仅 `none/low/high/max`,别名 `minimal→low`/`medium→high`/`xhigh→high`/`ultra→max`;未知 provider 不支持)。**服务端在模型层按 provider / 形状校验,非法或该形状不支持的字段一律丢弃并记 WARN(不降档、也不静默无效)**(design/04 §3.2 / v0.53);读端点只回读**已存配置**,**不承诺**该值在上游真的生效。面板可给出提示与前端枚举校验,但**不得**把「配了 effort」呈现为「上游已启用」。
- `POST /admin/users` → `501 not_implemented`(**无用户表**:登录仅用 `.env` 单 admin `ADMIN_USERNAME`/`ADMIN_PASSWORD`,角色为 `owner>admin>viewer` 常量;多用户存储 + 登录接入需先设计——design/11 单用户为主。不做「建了却登不进」的假用户)。
- `POST /admin/users/apikey`(**无请求体**,v0.51 取消 role 分级)→ `201 {data:{id:<掩码>, key:<明文·仅此一次>, role:"owner"}}`;`key` **仅此响应出现一次**,任何有效 key 鉴权后即 `owner`(裁决 15.4.3:key 不做角色分档;旧的 `{"role":…}` 请求体被忽略,表里也没有角色维度)。
- `DELETE /admin/users/apikey/{id}`(`id`=列表掩码)→ `200 {data:{id,revoked:true}}`;未命中 `404`。
- `GET /api/v1/admin/owner-profile`(**owner-only**,**读不审计**)→ `200 {data:{id,displayName,defaultPersona,identityFile,notes,accounts:[{platform,accountId}]}}`(后三个可选字段为 `omitempty`——为空时**键不出现**,客户端按缺省处理即可);**文件不存在(全新安装)→ `200` + 空白档案**(`accounts:[]`,**非 `null`**,面板据此渲染空表单让主人首次填写);**档案损坏/读不出 → `500 {error:{code:"owner_profile_unreadable"}}`**(如实报错,不返回一份看似正常的空档案——否则「白名单是空的」与「档案读不出来」长得一模一样)。
- `PUT /api/v1/admin/owner-profile`(**owner-only** + 审计 `owner.profile.update`)body 同形状(`accounts` 可空/省略;各字段**首尾空白会被去掉**——面板里手打的平台名/账号 id 常带空格,带进白名单就永远匹配不上)→ `200 {data:<落盘后档案>}`;`id`/`displayName` 缺失或 `accounts` 项**半空**(只给 platform 或只给 accountId)→ `400 {error:{code:"bad_request", message:"invalid owner profile: …"}}` 且**不落盘**(整条全空的占位项被跳过,不算错);写盘失败 → `500 {error:{code:"owner_profile_persist_failed"}}`。落盘 `configs/users/owner.yaml`(**0600 原子写**:临时文件 + rename)+ **热替换活档案**,新白名单**立即**参与渠道层 actor 判定、无需重启;组合根未暴露活档案时只记一条 WARN(已落盘、需重启生效),**不假装热重载成功**。它**不是**提示词/LLM 判定的输入——谁能当主人始终由这份档案的 `accounts` 白名单 + 每渠道 `adminIds` 决定(design/11 §0)。
- `POST /admin/iot/{id}/control` body `{action,params?}` → `200 {data:{id,name,type,state,online}}`(真实下发到 IoT provider);`404` 设备不存在 / `400` 动作不支持或参数错 / `409` 设备离线。
- `POST /admin/iot/scenarios`、`POST /admin/iot/rules` → `501 not_implemented`(**IoT 无场景/规则模型与存储**:`iot.Provider` 只有设备列表/控制/状态;通用 `automations` 只跑 cron/事件规则,不含设备状态条件 + 设备动作执行器。场景/规则 CRUD 需先新增模型 + 执行器)。
- `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?,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/llm/reset-from-env`(**v0.62,owner-only**,审计 `llm.resetFromEnv`)body `{confirm:true}`(缺 = `400 bad_request`)→ `200 {data:{reset:[{group,keys}], skipped:[{group,reason}], path}}`:把面板里 `.env` 声明过的键退回 env 值,**组集合不增减**、env 未声明的键原样保留、无键可改时不写盘,**只回键名不回显凭据值**。失败:`500 env_defaults_unreadable` / `models_unreadable` / `llm_persist_failed` / `llm_reload_failed` / `llm_unavailable`。完整契约见上方「重置为 env 默认契约(v0.62)」。
- `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` 白名单,绝不靠提示词。
- **微信 iLink「会话被拒」契约**(v0.39,适配器级,非端点):iLink 返回 `errcode=-14` 时适配器进入**显式被拒态**——`IsConnected()` 恒 `false`;`Connect()` **不再返回成功**,而是返回「需要重新扫码」类错误(`POST /admin/channels/open` 对应 `409 session_rejected`);`HealthCheck` 同样把「被拒」与「未连接」区分开。`GET /admin/channels` 该平台行带 **`needsRescan:true`** + **`lastError`**(人类可读原因,**绝不含 token**)。**清除条件**:扫码登录拿到新 token(`login-status` 报 `confirmed`)或配置里换了新 token(保存后热重载)。被拒期间:`login-status` **不**假报 `confirmed` 而是 `failed` + 被拒原因(避免面板把已作废的凭据当成功重新落盘);`login-qr` 仍可正常出码(**不**回 `already_logged_in` 挡住重新扫码);`get_bot_qrcode` 不再携带已被拒的 token(否则服务端会以「已绑定」应答而不发新凭据)。未实现该能力的平台:`needsRescan`/`lastError` **不出现**(`omitempty`),客户端按缺省处理。
- **事实说明(勿误读为「过期」)**:iLink 的 bot token 是**长期凭据**(实测一个月前扫码得到的 token 仍可用),`errcode=-14` 表示 **iLink 侧拒绝/作废了该会话凭据**(解绑、在别处重新登录、协议层拒绝等),**不是时间性过期**;恢复方式为**重新扫码绑定**(或配置换新 token)。当前尚未在真实日志中观测到 `-14`,此处按「被拒」语义处理以防静默变哑。
- **KOOK 适配器面板端点**(design/17 §2,**owner-only + 审计**,仅 `kook` 平台实现;其它平台回 `501 not_implemented`,账号未找到回 `404`):这些端点均带可选 `?accountId=` 定位到具体 channels.json 账号(不传=该平台首个账号)。**token/密钥不打印、不落日志**;子项拉取失败不伪造、记入 `errors`。
- `GET /admin/channels/{platform}/view?accountId=` → `200 {data:{platform,accountId?,friends?,userChats?,guilds?,channels?,members?,errors?}}`。聚合该账号真实数据(好友/申请/屏蔽、服务器、首个服务器频道与成员、私信会话);`errors` 为 `{子项:原因}` 记录失败的子项。
- `GET /admin/channels/{platform}/friends?type=&accountId=` → `200 {data:{friends?,requests?,blocked?}}`。`type` ∈ `friend|request|block`(空=全部),调用 KOOK `GET /friend?type=`。
- `POST /admin/channels/{platform}/friend/handle?accountId=` body `{id,accept}` → `200 {data:{handled:true,id,accept}}`。同意/拒绝好友申请(KOOK `POST /friend/handle-request`)。
- `POST /admin/channels/{platform}/message/test?accountId=` body `{targetId,content}` → `200 {data:{msg_id,msg_timestamp}}`。真实向 `targetId`(频道 id)发一条 kmarkdown 测试消息(KOOK `POST /message/create`),验证 bot 可达性。
- `GET /admin/channels` 每条账号附 **`capabilities`**(可选 `[]string`,如 kook 的 `["friends","user_chats","channels","guilds","message_test"]`),供面板按平台渲染专属功能卡片。
- **KOOK「token 即用、其余自动获取」**(适配器契约,非端点):填 KOOK 渠道时**只必填 `token`**(bot_token)。`base_url` 留空即用官方默认 `https://www.kookapp.cn/api/v3`;`receive_mode` 空默认 `ws`(WS 模式地址由 `GET /api/v3/gateway/index?compress=0` 自动获取,已接入);`webhook_path` 空默认 `/webhook/kook`。高级值(改用 webhook、`webhook_verify_token`、`webhook_encrypt_key`,仅 webhook 模式需要)经 PUT 的 `config` JSON 传入(前端走「额外 config」,如 `{"receive_mode":"webhook","webhook_verify_token":"..."}`)。**Connect 拿到 token 后自动调用 `GET /api/v3/user/me`**(Bearer 鉴权)解析 bot 账号(id/username/nickname),存到 adapter 并随 `GET /admin/channels` 账号的 `botId`/`botUsername`/`botNickname` 显示;user/me 失败仅记日志、bot 身份为空,**不中断**连接/收发。token 只在 header / 写文件,**不打印、不进日志**。
- **OneBot v11 四端契约(v0.55,适配器契约 + 配置面;design/08 §4 / design/13 §0,裁决 1.4 / 12.4.1)**:`qq-obv11` 适配器按 `transport`(ws|http) × `mode`(client|server) 提供**四个端**,谁向谁发数据各不相同:
- **`ws + client`**:Cyrene 主动拨号 OneBot 的 WS 端点,收发同一个长连接;**`ws + server`**:OneBot 反连 Cyrene 的 `listen_addr`,收发同一个长连接(两端均沿用改造前语义)。
- **`http + client`**:Cyrene 作为 HTTP 客户端调 OneBot 的 HTTP API——`POST {api_base_url}/{action}`、等响应、按 `retcode` 判成败(**纯出站**,无长连接、无监听端口);token 只走 `Authorization: Bearer`。
- **`http + server`**:OneBot 主动 `POST` 到 Cyrene 的 `listen_addr` + `report_path`(**纯入站**);**fail-closed 鉴权**(配了 `access_token` 时 `Authorization: Bearer` 或 `?access_token=` 必须匹配,否则 `403`)、**10MiB 请求体上限**(超出 `413`)、方法非 `POST` 回 `405`,成功回 `204`。
- **配置面**(channels.json 账号 `config` + 适配器 `Config()` 快照)新增:`transport`(`ws`|`http`,默认 `ws`,**未知值一律回落 `ws`**)、`api_base_url`、`report_path`(默认 `/`,无前导 `/` 时补上)。快照里 camelCase 为 `transport`/`apiBaseURL`/`reportPath`;`remoteURL` 快照值 = `remote_url`,**为空时回落 `api_base_url`**(语义即「出站远端地址」)。**既有键与值不变**(不给出任何新配置时行为与改造前一致:`transport=ws`、`mode=server`)。
- **env 兜底**:`CHANNEL_QQ_OBV11_TRANSPORT`(默认 `ws`)/ `CHANNEL_QQ_OBV11_API_BASE_URL` / `CHANNEL_QQ_OBV11_REPORT_PATH`。
- **关键取舍**:`mode` 决定**哪一半必需**(`server` 必需 `listen_addr`、`client` 必需 `api_base_url`),另一半只要字段给齐就**一并启用** ⇒ 单个适配器即可完成「收上报 + 回消息」闭环;四个端仍各自可独立选择与测试。任一端配置有误即 `Connect` 失败,**绝不留下半开的监听端口**。
- **端口例外**:`http + server` 会用 `listen_addr`,属 `AGENTS.md` §9 已登记的**同一豁免**(design/13 §0 原文即写「WS server / HTTP server」);`listen_addr` 为空则报错、**绝不开端口**、**无默认值**。
- **未接**:`get_group_list` 枚举(后续);`access_token` 为空时上报路径**放行**(与既有 `ws + server` 同口径,尚无 fail-closed 开关)。
- `PUT /admin/channels/{platform}/config`(owner-only,审计)body `{id?,token?,baseURL?,remoteURL?,listenAddr?,mode?,accessToken?,selfID?,config?,allowGroups?,denyGroups?,allowUsers?,denyUsers?,groupMsgPerMin?,msgPerMin?,maxMessages?,personaId?}` → `201 {data:{persisted:true,path,platform,accountId}}`。**真实持久化 + 热更新**(design/04 §3.1,无需重启):读当前有效配置(channels.json→env 兜底)→ **按 id 打补丁**(`PatchChannel`)→ 落盘 `channels.json`(本地、gitignore,0600)→ `bridge.Reload(platform)` 热替换该平台 adapter(进行中消息用旧、新消息用新);token 写入文件但**永不在响应/日志回显**(GET 列表为掩码)。`platform` 必填;空配置回 `400`;未注入渠道配置服务回 `500 channels_unavailable`、写盘失败 `500 channel_persist_failed`、热重载失败 `500 channel_reload_failed`。`.env`(`CHANNELS_JSON` / `CHANNEL_*_TOKEN`)为兜底。
- **`config`(v0.54 起为 patch 语义,与 `personaId` 同构)**:`config` 是**只含本次请求显式给出的键**的补丁,**不是**一整套完整配置。**取值三态**:**键省略(该键不在 `config` 里,或 `config` 整体缺席 / 为 `null`)= 保留该账号已存值**(未给出的键**绝不被抹掉**);**给出非空值 = 覆盖该键**;**显式 `null` 或 `""` = 清除该键**(适配器随后回落到自己的零值;`0` / `false` 是真实取值,**不算空**)。顶层便捷字段(`token` / `baseURL` / `remoteURL` / `listenAddr` / `mode` / `accessToken` / `selfID`)是普通 `string`,**只在非空时**并入补丁(普通 string 分不出「缺席」与「显式空串」)⇒ **要清除 `bot_token` 之类的键,必须走 `config` 里的显式 `null` / `""`**。**为什么**:面板只发送用户本次**碰过**的平台字段——若把「键不存在」当成清除,主人打开一张账号卡直接保存就会**静默抹掉**面板不渲染的凭据(**微信(iLink)**的 `bot_token`/`bot_id`/`owner_id`/`get_updates_buf`,iLink token 是长期凭据;**Telegram** 只填 `webhook_url` 会连 token 一起丢、账号被 `cleanAccounts` 整个删掉)。**仍是整值替换(不走三态)**:`allowGroups` / `denyGroups` / `allowUsers` / `denyUsers` 与 `groupMsgPerMin` / `msgPerMin` / `maxMessages`(线上是值语义)——**省略即清空**,客户端须回传**完整列表 / 非零值**。**未改**:token 要求型平台(kook / telegram)上**显式**清 `bot_token` 仍按 `cleanAccounts` 丢掉该账号(这是明确要求清除凭据,不是误删);「本次请求一个 `config` 键都没给出」在**非 `weixin-ilink`** 平台仍回 `400`(`weixin-ilink` 豁免,以便先建账号再扫码)。
- **`personaId`(v0.44 新增,v0.45 起为三态)**:=该账号**答话时的角色 id**(design/22 §2 方案 b,取值来自 `GET /personas`)。**取值三态**:**键省略(或 `null`)= 保留该账号已存绑定**(不动);**显式 `""` = 解绑**(写盘时该账号不带 `personaId` 键);**非空 id = 绑定该角色**。**未知角色 id 回 `400 bad_request`**(不绑一个答不了话的角色;校验只针对**本次真的传了值**的情形,省略继承的已存绑定在写入时已校验过)。**为什么是三态**:面板只在主人**动过**角色下拉时才发这个键——若把「键不存在」也当成解绑,主人只改 token 就会**静默清掉**账号的角色绑定(两端语义相反即丢数据)。**写盘即生效**:保存后 `Reload(platform)` 会重建该平台 adapter,新角色在下一批入站消息上生效(无需重启)。**它不是身份/权限输入**——谁能做什么仍由 channel 层 actor 元数据 + 白名单决定(design/11 §0)。**优先级见 §4.2「角色解析优先级」**:渠道绑定**只**作用于该账号的**主人私聊**,且仅当主会话自身未绑角色;群聊/陌生人会话**强制默认角色**。
- `DELETE /admin/channels/{platform}/config?id={accountId}`(owner-only,审计)→ `200 {data:{persisted:true,path,platform,accountId}}`。删除该平台指定账号 → 落盘 `channels.json` → `bridge.Reload(platform)` 热更新(该账号 adapter 优雅关停)。`id` 必填;未注入服务回 `500 channels_unavailable`。
- `GET /admin/channels/{platform}/messages?limit=&before=`(owner/admin 读)→ `200 {data:[{id,sessionId,role,content,mode?,mood?,image?,platform?,deviceId?,senderName?,createdAt}]}`。**IM 适配器日志流**(design/16 §4.6 §4):该平台**所有非主人私聊/群聊消息**(隔离会话 `sess-xxx`,非主会话 `web`)以消息流显示,`senderName` 标发送者昵称;未注入 `HistoryStore` 回 `501`。
- `POST /admin/plugins/install`(**owner-only + 审计 `plugin.install`**)body `{kind:"mcp",name,config:{transport:"stdio|http|sse",endpoint?|command?,args?,apiKey?,env?}}` → `200 {data:{installed:true,name,kind:"mcp",transport,toolCount}}`。**v1 只装 MCP server**(单二进制无法热装 Go 插件):真实 connect+discover,把发现的工具**热注册**进 capability registry(下次模型请求即注入,无需重启)→ 落盘 `plugins.json`(本地、gitignore、0600,`apiKey` 写入但**永不回显/不落日志**)→ 持有 live server 供后续刷新/卸载。**同名重复安装 = 刷新**(先卸旧再装新,失败回滚旧)。校验:`name` 必填、`kind` 非 `mcp` 回 `400`、transport 非法/`stdio` 缺 `command`/`http|sse` 缺 `endpoint` 回 `400`;**连接/发现/注册失败回 `502 plugin_install_failed` 且不落盘**(诚实,不静默装一半)。未注入 plugin manager 回 `501 not_implemented`。
- `POST /admin/plugins/uninstall`(**owner-only + 审计 `plugin.uninstall`**)body `{name}` → `200 {data:{name,uninstalled:true,kind:"mcp"}}`(已装 MCP:`Unregister` 移除其工具 + close + 删 `plugins.json` 条目;**非 MCP/普通能力**走原 `registry.Remove`,返回 `{data:{name,uninstalled:true}}`);未命中回 `404`。
- `POST /admin/memories` body `{personaId?,content,scope?}` → `201 {data:{id,scope,text}}`。**手动添加记忆**(owner 私聊直写);`scope` ∈ `user|persona|project`,缺省 `persona`(与「记住…」同路径);无效非空 `scope` 回 `400`;未注入 `memory.Store` 回 `501`。
- `POST /admin/memories/search` body `{query,personaId?,topK?}` → `200 {data:{query,personaId,semantic,hits:[{id,scope,text,score}]}}`。真实检索(keyword×衰减);**语义/向量路径**需主会话注入 vector index(`semantic` 当前恒 `false`,诚实反映未接向量器)。**`personaId` 省略时(v0.42 起)回落到库里注册的默认角色**(`default` 标记的那个 character → 其 persona),**不再写死 `"cyrene"`**;若既未传 `personaId` 又没有任何默认角色 → `400 bad_request`(诚实报错,而不是拿一个不可能命中的 id 去查)。
- `GET /admin/sessions/{id}/export` → `200 {data:{session:{platform,channelId,sessionId,actorRole,owner,isGroup,memWritable}}}`;`404`。当前导出会话身份元数据(完整消息转录由 M10 提供)。
**系统设置契约**(`POST /api/v1/admin/system/*`,均 **owner-only**,审计;成功 `{data}` / 失败 `{error:{code,message}}`):
- `POST /system/revoke-keys`(无 body)→ `200 {data:{revoked:n}}`。**吊销全部 API key**:清空 key 表(先前签发的所有 key 立即失效),`revoked` 为吊销数量;未注入 API key 服务回 `500 users_unavailable`。
- `POST /system/rotate-jwt` body `{newSecret}` → `200 {data:{rotated:true,note:"现有 token 已全部失效,需重新登录"}}`。**轮换 JWT 密钥**:`newSecret` ≥32 位,替换当前签名密钥,**先前 token 全部失效**;`newSecret` 缺失/空白回 `400`;长度 <32 回 `400`;认证服务未配置回 `500 auth_unavailable`。**新密钥永不回显、不落日志**。
- `POST /system/backup`(无 body)→ `200 {data:{path,size}}`。**备份数据**:经 SQLite `VACUUM INTO`(含已提交 WAL 内容)快照到 `<dataDir>/backup-<ts>.db`,`path` 为备份文件绝对路径、`size` 为字节数;数据库路径未配置回 `500 backup_unavailable`、快照失败回 `500 backup_failed`。
- `POST /system/restore` body `{path}` → `200 {data:{restored:true,backup,note:"已安排恢复,需重启服务后生效"}}`。**恢复数据**:`path` 须为 **dataDir 内**的备份文件(**路径穿越防护**——`filepath.Rel` 校验,`..` 逃逸回 `400`);文件不存在回 `404`;**先自动备份当前数据库**(`backup` 为快照路径)再替换 DB 文件;运行进程持有 DB 句柄,**替换后需重启服务生效**;数据库路径未配置回 `500 backup_unavailable`、替换失败回 `500 restore_failed`。
**存储状态契约(`GET /api/v1/admin/system/storage`,v0.60)** —— 上面四条是**动作**(写 + 审计),这一条是**只读查询**:owner-only、**不审计**、**只读**(只有 `SELECT`/`PRAGMA` + `os.Stat`,复用组合根注入的活连接——不新开连接池、不写、不迁移、不备份)。它回答「现在这台机器上到底存了什么」,是面板首页「存储」卡的数据源。
- **请求**:无 body、无 query。`Authorization: Bearer <JWT>`(role=owner)或 `X-API-Key`;**无凭据 / 非 owner → `401`**。
- **响应 `200 {data:…}`**(三块,字段名逐字对齐 `internal/services/gateway/admin_system_storage.go`):
```json
{"data":{
"main":{"kind":"sqlite","path":"/srv/cyrene/data/cyrene.db","fileBytes":12894208,
"walBytes":107872,"shmBytes":32768,"pageSize":4096,"pageCount":3148,
"freelistPages":12,"freeBytes":49152},
"documents":{"count":37,"bytes":2143567},
"vector":{"kind":"none","enabled":false,"ready":false}
}}
```
- `main.kind` 恒为 `"sqlite"`(当前唯一的主库引擎);`main.path` = 组合根实际打开的库文件路径(与备份/恢复端点同一个来源)。
- `main.fileBytes` = 主库文件字节数(`os.Stat`);`walBytes`/`shmBytes` = `<db>-wal` / `<db>-shm` 边车文件字节数,**合法地不存在时如实为 `0`**(已 checkpoint 或库未开 WAL)。
- `main.pageSize`/`pageCount`/`freelistPages` 来自 `PRAGMA page_size` / `page_count` / `freelist_count`(一条 SELECT 取完);`freeBytes = pageSize × freelistPages`,即「已分配但空闲、VACUUM 可回收」的量级。
- `documents` = `documents` 表的**行数**与 `SUM(size)`(读表,**不 walk 目录**);空表时 `bytes` 为 `0`(`COALESCE`),**不是** `null`。
- `vector` = 可选 pgvector 增强的**运行时真相**(主人 2.4 的困惑点:面板只写「SQLite」会让人以为 PG 没生效):
- `kind`:`"pgvector"` = 已配置 `DB_PG_URL`;`"none"` = 未配置(前端显示「未启用(未配置 `DB_PG_URL`)」)。
- `enabled`:开关在不在(等价于 `kind != "none"`)。
- `ready`:**活着的**向量索引确实是 PostgreSQL+pgvector 后端。配了 PG 但连接/schema 准备失败 ⇒ `enabled:true` + `ready:false`(检索已降级),客户端应显示「已配置但未就绪」,**不要**把 `enabled` 当成「生效了」。
- 真源 = 组合根的运行时状态(`DB_PG_URL` 非空 + 活索引的具体类型),网关**不自行探测**。
- **不含凭据(红线)**:DSN / 用户名 / 密码 / `postgres://` 全文**一个字都不回**,`vector` 段只有 `kind`/`enabled`/`ready` 三个键(有专门用例断言响应体里搜不到这些串)。
- **错误**:
- `401`(无凭据 / 非 owner / 凭据无效——与其它 `ownerAuth` 端点同档);
- `500 {error:{code:"storage_unavailable",message}}`:数据库**路径**未配置、活**连接**未注入、或页级统计 / `documents` 表读不到(例如库没跑迁移)。**诚实边界**:此时**不返回**任何全 0 的假快照,客户端应显示错误而不是 `0`。
- **只读保证**:调用前后 `documents` 行数与数据目录内的文件集合均不变(有用例钉住:既没有写、也没有顺手备份)。
- **前端用法**:`adminSystemStorage()`(`frontend/web/src/lib/api/adminSystem.ts`)→ 卡片 `value` = `SQLite 12.3 MiB`、`hint` = WAL/页数/空闲页、详情行 = `documents N 件` 与 `PG 增强 …`;取数走与其它已接线卡片同一条加载/错误态。
**知识库 RAG 契约**:
- `GET /admin/rag` → `{"data":{"documents":[{id,name,desc?,chunks,embedded}],"hits"?:[{docId,docName,chapter?,text,score,semantic}]}}`。`hits` 仅在携带 `?query=` 时出现;`embedded` 反映部署是否配置向量增强(未配置为待嵌入)。
- `POST /admin/rag` body `{"name","desc?","content"}` → `201 {"data":{"name"}}`。**导入文档**:后端分块(空行分段,超长段按固定长度切)并落项目索引(项目+章节),立即可被关键词/行上下文检索;未注入 `RagStore` 回 `501`。语义/向量嵌入为优雅降级(知识库 Store 不直属向量索引,导入文档走 keyword/line 路径;语义检索仍对既有 project-linked 记忆生效)。
- `POST /admin/rag/query` body `{"query":"..."}` → `{"data":{"query":"...","hits":[{docId,docName,chapter?,text,score,semantic}]}}`。检索走项目索引关键词+行上下文;配置 pgvector/向量增强时并入语义/向量命中(`semantic:true`)。
- `POST /admin/rag/delete` body `{"id":"..."}` → `200 {"data":{"id":"...","deleted":true}}`。**真实删除**:从项目索引删除该文档条目并**级联删除其全部章节**(单事务),删除后 `GET /admin/rag` 不再出现、检索不再命中。`id` 缺失/空白回 `400`;文档不存在回 `404 not_found`;未注入 `RagStore` 回 `501`(**owner-only + 审计 `rag.delete`**)。
**可观测性契约**(`GET /api/v1/admin/observability/*`,均需 owner/admin;`metrics`/`trace`/`audit` 的 `?limit=N` 缺省 50、上限 500):
- `GET /metrics` → `{"data":{"totalRequests","qps","avgLatencyMs","maxLatencyMs","perMethod{}","perPath{}","statusCounts{}","llmCalls","subagentEvents"}}`。`qps` 为近 1 秒窗口计数;`llmCalls`/`subagentEvents` 由实际观测到的 trace span 按 `kind` 累计(Tracer 未注入时为 0)。**Token/成本后端不统计、不返回**(不伪造指标)。
- `GET /trace?limit=100` → `{"data":[{id,parentId?,name,kind,status,startedAt,endedAt?,durationNs,props?,events?]}`。`kind` ∈ `llm_call|tool_call|orchestration|state_transition|subagent|memory|guardrail`;`durationNs` 为纳秒;`events` 为 `[{time,type,props?}]`。
- `GET /audit?limit=100` → `{"data":[{id,who,what,when,result}]}`。`id` 为整数自增;`when` 为 RFC3339;来源为 SQLite `audit_logs` 表(未注入 AuditStore 时退化为进程内存)。
- `GET /logs?level=&limit=` → `{"data":[{time,level,source,msg}]}`(**后端日志 tail**,v0.35)。
- **来源**:logger 进程内**有界环形缓冲**(容量 2000,满则丢弃最旧),只存 logger 已产生的记录,**不额外打印、不重渲染、不落库**;控制台/文件输出完全不受影响。`source` 取自 `logger.WithSource` 的 `source=` 属性(`logger.Scoped` 的记录回退为 `module/sub`),无来源记 `""`。
- **排序**:`time` **倒序**(最新在前)。`time` 为 **RFC3339(UTC,秒精度)**,与 `trace.startedAt` / `audit.when` 一致;`level` ∈ `debug|info|warn|error`;`msg` 为记录消息。
- **查询参数**:`level` ∈ `debug|info|warn|error`(大小写不敏感)为**最低等级过滤**(如 `level=warn` 返回 warn + error),**空/缺省 = 全部**,未知值回 `400 invalid_level`;`limit` 缺省 `200`、上限 `1000`(小于等于 0 或非法值回落缺省)。日志量超过 `limit` 时按时间倒序截断(先按 level 过滤再取 `limit`,缓冲全量 2000 条内扫描)。
- **诚实边界**:组合根未注入日志环形缓冲(非二进制主链路启动)时回 **`501 not_implemented`**,不返回伪造/空的成功 tail。日志内容为 logger 已产出内容,**敏感信息按既定规范本就不落日志**(本端点不新增任何打印)。
**通知聚合契约**(`GET /api/v1/admin/notifications`,**owner/admin 读**;design/10 §3 #16):
- `GET /admin/notifications?type=&before=&limit=` → `200 {"data":[{id,type,title,body?,source,createdAt}]}`,按 `createdAt` **倒序**(同毫秒按 `id` 倒序稳定)。
- **查询参数**:`type` ∈ `system|channel|backthink|audit`(**空/缺省 = 全部**,未知值回 `400 invalid_type`);`limit` 缺省 `50`、上限 `200`;`before` 为 `createdAt`(epoch 毫秒)游标,**含游标本身**(`createdAt <= before`——两源时间戳精度不同(审计秒/纳秒、backthink 毫秒),页边界可能落在同一毫秒,**客户端按 `id` 去重**)。缺省/非法 `before` = 从最新开始。
- **字段**:`id` = `<source>:<原生 id>`(`audit:<audit_logs.id>` / `backthink:<backthink_records.id>`),**跨页稳定、可去重**;`source` ∈ `audit|backthink`;`createdAt` 为 epoch 毫秒(无法解析的审计时间戳记 `0`,不伪造时间)。`title`/`body` 由各自源派生:审计项 `title = what`(如 `channel.open`)、`body = "<who> · <result>"`;backthink 项 `title = "主动推送(<trigger> · 已送达|未送达)"`、`body =` 结论摘要(≤200 rune,超出加 `…`)。
- **类型归类**(审计源按操作前缀派生):`channel.*` → `channel`;`system.*` / `plugin.*` / `policy.*` → `system`;其余审计操作(persona/user/memory/rag/session/upload/audio/…)→ `audit`;backthink 主动推送 → `backthink`。
- **数据源与诚实边界**:**只聚合现有源**(审计 `audit_logs` + backthink 记录),**不新建 notifications 表**。backthink 仅 `shouldPush=true` 的记录算通知(纯思考记录不推给主人);**未注入的源 → 该类型为空,不伪造**(无 backthink 服务即无 `backthink` 项);源报错回 `500 notifications_failed`(**不静默少给**)。本版为**只读 feed**:未读/已读游标后置(不建重表)。
## 3. WebSocket `/ws/chat`(实时)
- **握手**: `ws://<host>:8080/ws/chat?token=<JWT>` 或 `Authorization: Bearer`;未认证在握手前回 `401` JSON。
- **下行(服务端→客户端)**: 经 bus 广播 `{type:"<event>", 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 照常(否则连接会掉),被拒的上行帧也不会关掉连接。
> **这是「谁被主动推送」的门,不是数据可见性**:`viewer` 的 12 条只读监控端点(`viewerAuth`,v0.40)**照旧**(含 `GET /personas` 的角色级 `mood`);`viewer` **仍可连** `/ws/chat`(连接鉴权不变),只是收不到第 ② 类帧。定向回请求连接的帧(`voice_output` 等)不受影响。**上一条的门只管「推给谁」;「谁能发、谁能拿回放」见上面 v0.49 那条**——`viewer` 连上后**发不出任何指令**、也**收不到 `history` 回放**。
> **`history` 回放事件(design/16,新增)**:WS 握手升级成功后、该连接下一帧即收到 `{type:"history", sessionId:"web", data:{messages:[{id,sessionId,role,content,mode?,mood?,image?,platform?,deviceId?,personaId?,clientMsgId?,createdAt}]}}`——回放主会话最新 50 条(`thinking` 已剥离;默认主会话,次级会话由客户端按激活会话走 `GET /sessions/{id}/messages` 拉取)。客户端据此在重连/新开连接后补全上下文。消息体含 `senderName?`(第三方昵称,design/16 §4.6,主人私聊为空)与 `personaId?`(v0.42,说话时的角色,design/22 §7-5,空 = 默认角色)。**角色门(v0.49,design/11 §7.2 A)**:这条回放**只发 `owner` + `admin` 连接**;`viewer` 连接建立后**不会**收到 `history` 帧(它若需要只读数据,走 §1 的只读 REST 端点)。
### 3.1 结构化流事件(`message.*` / `thinking.*` / `tool_call.*` / `tool_execution.*` / `turn.start/end`)
- **事件壳**:`{type, sessionId?, turnId?, seq?, data}`, `data` 为**类型化结构**(非 `any`)。`seq` 为 hub 级单调递增序号,供客户端将一次突发的流式事件排序关联。**`sessionId` 为事件所属会话、`turnId` 为该轮助手回合 id(均 v0.36 起带上)**:`sessionId` 由 loop 在回合边界注入(模型适配器在 loop 之下、不感知会话,恒留零值;loop 外无会话上下文的事件该字段为空),`turnId` 由网关每个回合新生成并挂进运行上下文——同一会话同时只有一个 in-flight 回合,故**同轮内所有帧的 `turnId` 一致**。客户端据此把 `message.delta` / `thinking.delta` / `tool_call.*` / `tool_execution.*` **路由到该帧自己 `sessionId` 的会话消息桶**(**v0.54 起:非激活会话的帧不再被丢弃**——后台会话的正文继续累积、切过去即可看到完整这一轮);**只有「单槽忙态」**(`session_busy` / `session_idle` / `session_interrupted` 与 `stream_end` 的 `sessionBusy` 收口,见 §3.2)**仍按 `sessionId === activeSessionId` 判定**,以免别的会话的忙/闲帧串到屏幕上的会话。
- **`streamId`(v0.50,模型流帧 `data` 内可选字段)**:`message.*` / `thinking.*` / `tool_call.*` 帧的 `data` 带 `streamId`(如 `{"delta":"…","index":0,"streamId":"stream-a1b2…"}`)——**同一次模型流的多帧共享同一 id**,loop 在每次模型流(一次 ChatStream 调用)开始时生成;多步工具轮的第二次模型调用取**新** id。`turnId` 标记「这轮回答」(整轮恒定),`streamId` 标记「这轮里的哪一条流式消息」;一个回合含工具调用时会有多个 `streamId`。`tool_execution.*` 与 `turn.*` 帧不属于任何模型流,**不带** `streamId`。
- **`message.start`**(`{index, streamId?}`) / **`message.delta`**(`{delta, index, streamId?}`) / **`message.end`**(`{index, streamId?}`):正文段。`delta` 为正文 token 增量;客户端对 `message.delta` 累加到当前消息正文;`message.start/end` 划定正文段边界,**不**结束整轮(结束由 `stream_end` 负责)。
- **`thinking.start`**(`{index, streamId?}`) / **`thinking.delta`**(`{delta, index, streamId?}`) / **`thinking.end`**(`{index, streamId?}`):思维链段(前端折叠块)。`thinking.delta` 累加增量。
- **`tool_call.start`**(`{id, index, streamId?}`) / **`tool_call.delta`**(`{id, index, delta, streamId?}`) / **`tool_call.end`**(`{id, index, streamId?}`):工具调用中间态;`tool_call.delta` 携带参数增量(如 Anthropic `input_json_delta`)。
- **`tool_execution.start`**(`{tool, phase:"running", id?}`):工具**进入执行**(v0.50)。`tool` 为工具名;`phase` 为细粒度执行阶段(枚举与 §3.5 流程图节点 status 同一套 `running/done/error`);`id` 为 provider 工具调用 id(可关联同轮的 `tool_call.*` 帧,provider 未给 id 时省略)。
- **`tool_execution.update`**(`{tool, phase:"running", delta?, id?}`):工具**运行中**的进度/局部输出,一次执行可多帧(v0.50)。**现状**:wire 映射已实现并验证,但工具分发器尚无中途进度上报源,loop 目前不产出该帧(start/end 有真实生产者)——出现生产者后无需再改契约。
- **`tool_execution.end`**(`{tool, phase:"done"|"error", id?}`):工具执行**完成或失败**(v0.50);`phase:"error"` 即失败(design/19 流程图的 tool 节点据此翻 `error` 态;错误详情不进该帧,走流程图节点日志的脱敏摘要)。
- **`turn.start` / `turn.end`**(无 `data` 载荷):回合边界帧(v0.50),loop 在 Run 的起止发出、经 ws_bridge 下行;帧壳的 `sessionId`/`turnId`/`seq` 即边界信号,客户端用它括起「一轮回答」的完整区间(含其全部模型流与工具执行)。`turn.end` 覆盖回合所有出口(最终答案 / 工具循环安全网终止 / 预算耗尽 / 步骤出错);**回合终态信息(`stopReason`/`steps`)仍由既有 `stream_end` 携带**,两帧分工不重叠。内部自主流程(backthink / 子代理)被静音,不发出这两帧。
- **向后兼容(v0.50 对 `tool_execution` 的替换说明)**:v0.49 及之前的单帧 `tool_execution.update {id,index,delta}`(`delta` 形如 `"start:工具名"` / `"end:error:工具名"` 的拼串)**不再由 loop 产生**——按旧拼串解析的客户端必须迁移到三事件(`tool_call.*` 帧形状不变)。`turn.start/end` 与各帧 `data` 里的 `streamId` 为纯新增键,老客户端按未知帧/未知键忽略即可。现有 Web 前端未消费这些新帧,行为不变。
- **向后兼容**:未映射/无法确定的事件仍走 `stream_chunk` + `stream_end`(非流式回退路径)。
- **上行(客户端→服务端)**: `mode_switch` / `chat`(跑 loop 并广播流式) / `voice_input` / `subagent_ask` / `task_output_query` / `interrupt`。
- **`interrupt`**(上行,客户端→服务端):`{sessionId, scope?}`(**`scope` 为 v0.56/A2 新增**)→ 会话编排器中断(design/15 §3)——取消该会话当前在跑的任务(context cancel)并回 `session_interrupted`;用户说「停下/算了/换个话题」时发。**`scope` 取值域 `{"", "all", "current"}`**(**大小写敏感,只容忍首尾空白**):
- **省略 / 空串 / `"all"`**(三者同义)= 取消当前任务 **+ 清空待处理队列**,会话立即回到空闲(**逐字不变的现状语义**,老客户端只发 `{sessionId}` 的行为完全不变);`session_interrupted.dropped` = 被清空的 pending 条数、`mode` = `idle`。
- **`"current"`** = **只取消当前任务、保留待处理队列**:队列里的消息按编排器正常续跑规则继续执行(带上会话尾部上下文),一条都不丢;它**不递增代次**,因此被取消回合的结算仍属当代。此时 `dropped` 恒为 **`0`**,`mode` 如实报告该会话当时的**忙碌通道**(`thinking`/`executing`/`subagent`,**不是 `idle`**)。用途:客户端「打断」与「追加队列」是两种模式,若打断一律连队列清空,用户选「打断」时连发的多条消息会被静默丢弃、只剩一个 `dropped` 数字。
- **其它取值** → **只回发送者** `{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?, 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`(非流式回退路径)。
---
### 3.2 会话编排状态事件(design/15)
> **角色门(v0.48,design/11 §7.1)**:本节的 6 个状态信号**都在白名单内**——它们只报「会话忙/闲/排队」,**不含任何内容**,所以**所有已认证连接**(含 `viewer`)都收得到;真正的正文/推理内容走 §3.1 的流帧,那些**只发 `owner` + `admin`**。
- **触发**:同一会话内,`chat` 到达时若「她忙」(正在跑 loop/工具循环/子代理/长回复)→ 新消息**不阻塞**、入该会话 pending;这一层的状态经以下事件推给前端,供「正在做 xx… 有新消息稍后回」轻状态展示。
- **`session_busy`**:会话进入忙碌态(`{sessionId, mode}`,`mode` ∈ `idle|thinking|executing|subagent`)。
- **`pending_ack`**:忙时新消息已入队(`{sessionId, mode, queueDepth}`)——「收到,稍后回」。
- **`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)。**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` 已登记的**单槽债**(多会话并发时忙态徽标只反映屏幕上的会话)。
### 3.3 消息持久化增量事件(`message_persisted`,design/16)
- **触发**:每一条 user / assistant 消息**落库后**由编排层广播——Web 主会话路径走 `persistUserMessages`/`persistAssistantReply`,**IM 渠道主人私聊**(design/16 §4.4)由 channelbridge 把消息**落主会话 `web`**(带 `platform` 标记)后复用**同一** `message_persisted` 广播(同一 hub 的 `BroadcastPersisted`),保证 Web 端实时镜像 IM 消息。
- **载荷**:`{type:"message_persisted", sessionId, data:{message:{id, sessionId, role, content, mode?, mood?, image?, platform?, deviceId?, senderName?, personaId?, createdAt}}}`——`role` ∈ `user|assistant`;`id` 为**后端生成的消息 id**(客户端据此去重);`platform` 为**来源平台**(`web` 或渠道如 `telegram`/`weixin-ilink`/`qq-obv11`);`deviceId` 为**具体端/客户端标识**(design/16 §4.5,如 `web:chrome:a1b2c3` / `weixin-ilink:conf0:wx_uid`);`senderName` 为**第三方发送者昵称**(design/16 §4.6,群聊/陌生人消息带,主人私聊为空,`omitempty`);**`personaId`(v0.42,`omitempty`)=该消息说话时的角色 id**(design/22 §7-5;空 = 全局默认角色,即升级前的全部历史),客户端据此按角色渲染名字/头像;**`clientMsgId`(v0.56,`omitempty`,G4)= 客户端自带的幂等键**(空 = 该消息没有幂等键:老客户端、IM 镜像、历史行),客户端据此把本地乐观气泡对齐到服务端真相;`thinking` 已剥离(design/16 §6)。
- **注:群聊/陌生人(非主人)消息**只在**该 IM 适配器日志界面**以消息流显示,**不**经 `message_persisted` 进 Web 主会话(design/16 §4.6 §3-4);该日志流由 `GET /api/v1/admin/channels/{platform}/messages` 拉取,落库后另经 `channel_persisted`(§3.4)实时推送。
- **客户端处理**:把帧按 `data.message.sessionId`(缺省时用帧壳 `sessionId`)写进**该会话自己的消息桶**(**v0.54 起:非激活会话的消息不再被丢弃**,切过去即可见,见 §3.1);按 `id` upsert——本端已预建的**同 role 乐观气泡**(本地 `uid` 无后端 `id`)被**归一**(`uid` → 后端 `id`),异端无本地气泡则直接追加,两者最终收敛为**同一条 `id` 相同的消息**,「A 发消息、A 与 B 都无需刷新且各出现一次」。
### 3.4 适配器消息实时事件(`channel_persisted`,design/16)
- **触发**:每一条**群聊/陌生人(非主人 / 隔离 sess-xxx)消息**落库后由 channelbridge `persistIsolated` 广播;该消息**只写自己的隔离会话**,**绝不**写主会话 `web`,`channel_persisted` 仅是事件通知,不是把内容塞进主会话/Web 上下文(design/16 §4.6 §3-4)。
- **载荷**:`{type:"channel_persisted", sessionId, data:{message:{id, sessionId, role, content, mode?, platform?, deviceId?, adapterId?, senderName?, createdAt}}}`——与 `historyMessage` wire 一致;`adapterId` 为**产生该消息的适配器注册名**(如 `kook:3bef...`,`adapter != ''`);`sessionId` 为隔离会话 id(`sess-xxx`,**非** `web`);`role` ∈ `user|assistant`;`senderName` 为第三方昵称;**`clientMsgId`(v0.56,`omitempty`,G4)= 客户端自带的幂等键**(空 = 无幂等键,如渠道入站消息)。
- **事件类型分流**(`BroadcastPersisted` 按 `sessionId` 分流):`sessionId == "web"` 或其 `web:<uuid>` 前缀 → **`message_persisted`**(Web 聊天实时同步);**其它**(隔离 `sess-xxx`)→ **`channel_persisted`**(适配器日志专用)。
- **客户端处理**:Web 聊天**无** `channel_persisted` handler,天然忽略、不进会话;**IM 适配器日志页**订阅该事件,按 `adapterId === 当前适配器`(或总览 `adapterId != ''`)过滤后**仅追加**(按 `id` 去重、不整页重载),首次拉取仍由 `GET /api/v1/admin/channels/messages` + 轮询兜底。
### 3.5 实时全链路流程事件(`graph_update`,design/19)
> **角色门(v0.48,design/11 §7.1)**:`graph_update` 属「系统内部」帧 → **仅 `owner` + `admin`**(`viewer` 收不到,与本节「仅主人 Web 会话发布」及 design/19 §1「flow 可视化仅 owner/admin」一致)。REST 侧可见性不变。
- **触发**:每次会话轮次中,loop 的每个 trace span **开始/结束时**由 `loop.FlowPublisher` 经 bus `ws.graph_update` 广播增量 **FlowGraph**。**发布范围为三类会话(v0.50 口径,裁决 01.4.5)**:`web`(Web 主会话) / `web:<uuid>`(Web 子会话,含 IM 主人私聊镜像落主会话的形态) / `sess-backthink`(后台思考的固定内部会话,design/20 §2 的「思考链路图」由此取数)——**其余隔离会话(IM 群聊/陌生人的 `sess-xxx`)与子代理轮次不发布**,防敏感信息泄向面板。管理员/主人 Web 端订阅后实时渲染「输入 → 思考(LLM) → 工具 → 输出」主干图。**tool 节点的 `status` 由细粒度 phase 驱动**(v0.50,design/04 §1.1 + design/19 §1):工具执行期间为 `running`,执行结束按成败翻 `done` / `error`——与 §3.1 `tool_execution.*` 帧的 `phase` 同一套枚举、同一处决策。
- **载荷**:`{type:"graph_update", data:{id, nodes:[{id,label,kind?,status?,startedAt?,endedAt?,props?,log?}], edges:[{from,to}], sessionId, startedAt, endedAt}}`。
- **子代理分支节点的 `props.persona`(v0.47,design/22 §8-2)**:`subagent_spawn` 指定了目标角色派发时,该子代理在父会话图里的**分支根节点** `props` 会多一个 `persona` 键(值 = 目标角色 id),标注它**正在扮演的角色**;**不指定角色派发时该键不出现**(与旧版逐字一致),客户端按未知键忽略即可。
- `id` 为 `sessionId-round`(每轮一图);`sessionId` 为会话 id;`startedAt/endedAt` 为 RFC3339。
- `kind` ∈ `input|llm|tool|subagent|memory|guardrail|orchestration|output`。
- `status` ∈ `idle|running|done|error`(`running` = span 在跑 / `done` = 成功 / `error` = 失败)。
- `props` 仅含白名单摘要(如 `stop_reason` / `error_kind` / `result_summary`),**工具原始参数与密钥绝不前送**;长文本截断到 ~80 字。
- **节点 `props` 的可选键(v0.52,裁决 11.4.2 / 11.4.3)**——旁路节点(`memory` / `guardrail` / `orchestration`)的 span 现已**真实产生**,其 `props` 新增下列键。**全部为纯增量、可选**;值在**发布前已脱敏**(凭据形状 → `[redacted]`、超长截断);**帧名、事件名、顶层字段集一字未动**:
- **tool 节点**:`args_keys: string[]`(该次调用的**全部参数名**,字典序,**值一律不出现**)、`args_summary: {[key]: string}`(**仅放行表内、且非密钥类**的键才给值——密钥类键由 `trace.IsSecretKey` 判定(凭据形状 / `*_key` / `*token*` 等),**连值都不给**;单值 ≤**80 rune**、最多 **24** 条,表外键与超出条数的键连条目都没有,客户端按 `args_keys` 逐键列表、未放行的显示「值未展示」)。
- **guardrail 节点**:`action`(`guardrail.input` | `guardrail.output`)、`allow: bool`、`elapsed_ms`;检查本身报错时另带 `error`。
- **memory 节点**:`action`(`recall` | `write`)、`scope`(`owner` | `restricted`)、`hits` / `bytes` / `count` / `failed`、`elapsed_ms`——**只记元数据,不含任何记忆正文**。
- **orchestration 节点**:`subagent`(被派发的子代理 id)、`persona`(指定目标角色时才有)、`elapsed_ms`。
- **input 节点(v0.57,裁决 13.4.5 目标态)**:`steered: bool`——本回合在**回合内边界**吃过新到达的用户消息(steer)时出现;**纯增量、可选**,老客户端忽略即可(标记只并入既有 input 节点,不新建节点、不改标签,图仍只有一条主干)。
- `log` 为该节点累积的 span 事件 `[{time,type,props?}]`(设计 §2「节点实时日志」)。
- **客户端处理**:`graph_update` → `setFlowGraph(parseGraph(data))`(`src/lib/wsParse.ts`)——**图按 `data.sessionId` 落进该会话的「图桶」**(v0.54,裁决 16.4.2;前端 `frontend/web/src/store/sessionCache.ts` 的 `putGraph` / `currentGraph`,桶上限 12 张、超出时丢最久未更新的一张)。管理端「全链路流程」页(`admin/modules/Flow.tsx`)与 Agents 页取**激活会话**的图(该会话还没有图时回落主会话最近一张);**Backthink 页**(`admin/modules/Backthink.tsx`)取 `sess-backthink` 的图——后台思考的图**不再顶掉**管理员看的「当前会话」图。节点可点开看参数/结果/实时日志;无图时诚实显示「暂无流程」。
### 3.6 主动推送事件(`proactive_message`,design/12)
- **触发**:后台思考(backthink)自主决定「此刻主动发一条」时,经 `SendProactive` 投递给**当前所有在线 owner/admin 连接**(viewer / 未登录连接**永不**投递)。`SendProactive` 返回实际送达的连接数——**0 时调用方回退 IM 桥**(`im_bridge`),把该消息经渠道主动推送发给你(KOOK/微信等已接)。
- **载荷**:`{type:"proactive_message", data:{platform?, channelId?, channelType?, content, contentType?, sender?, timestamp?, messageId?}}`(由 `adapter.UnifiedMessage` 脱敏投影:**不含 RawData / 媒体字节 / token**,绝不让凭据漏到前端)。
- **客户端处理**:仅 owner/admin 收到;渲染成一条「她从某平台主动发来」的气泡(内容 + platform + sender + timestamp)。**与 `message_persisted` 的区别**:`message_persisted` 是你与她的对话回显;`proactive_message` 是**她未经你输入**主动推的消息(如 IoT 完成事件、后台思考结论),两路不同、不要混用去重。
- **红线**:owner-only;payload 已脱敏;backthink 每次主动推送**只发一个决策平台**(决策渠道→最近活跃→旋转回退),Web 端与 IM 桥为两条投递路,客户端不要据此断言「所有平台都发了」。
- **推送出口(D1,对客户端无新端点)**:在 WS/IM 投递之外,后端会把主动消息**异步镜像**到「推送出口」——主通道是**主人自建统一推送服务**(`PUSH_EXPORTER_URL` webhook,HTTP POST JSON `{source,title,content,platform,timestamp}`,可带 `Authorization: Bearer <PUSH_EXPORTER_TOKEN>`),回退是**个推 Getui**(字段预留、SDK 未接入)。该出口只影响**出站投递**,不改变 WS 事件契约;未配/不可达时只记日志(注明「个推回退待接入」),**不影响**上面的 WS/IM 投递。移动端接入自建推送服务即可收到主人主动消息。
### 3.7 情绪事件(`mood`,design/01 §2 情绪状态机 + 好感度)
> **角色门(v0.48,design/11 §7.1)**:`mood` 属「私有状态」帧 → **仅 `owner` + `admin`**,`viewer` 不再收到它;但角色当前情绪**仍可读**——`viewer` 照旧调 `GET /personas` 拿到该角色的 `mood` 字段(REST 只读档不变,见 §1)。
- **触发**:每轮对话结束(loop 回合边界,每轮一次、**非**每步)把该轮用户消息的情绪信号应用到**该角色自己的**情绪状态机(按 persona 隔离),且**状态真的变化时**才广播一次 `mood`;状态不变(含「一直 calm」)**不发**,避免每轮噪声。
- **载荷**:`{type:"mood", data:{mood, personaId, affection}}`——`mood` ∈ `calm|happy|excited|sad|angry`(情绪状态机 state id,与消息体 `mood` 同源同值);`personaId` 为该角色 id(情绪/好感度跟随角色,不跟随行为模式);`affection` 为该角色当前好感度(受角色配置 `affection.min/max` 约束)。角色级数值,**不带** `sessionId`。
- **与消息体 `mood` 的关系**:assistant 消息体的 `mood` 是该轮结束后的**当前情绪**(每轮都写,见 §3.3),而 `mood` 事件只在**变化时**发——前者答「这条回复时她什么心情」,后者答「她的心情刚刚变了」。
- **群聊/陌生人(`MemWritable=false`)**:情绪**只读不写**——第三方消息**不**改变角色情绪与好感度、**不**发 `mood` 事件、该轮 assistant 消息 `mood` 为空(与记忆只读同一隔离边界,design/02 §3 / design/11 §2)。
- **状态转移与衰减**:按角色配置的转移表(缺省内置表)与 `emotion.decayRate`——有情绪信号的轮次**沿合法转移走一步**指向对应状态;无信号轮次**衰减一步**趋向 `calm`;`decayRate ≤ 0` 关闭衰减。好感度按角色 `affection.delta` 随情绪轮次增减(warm `+delta` / cold `−delta`,clamped 到 `[min,max]`)。
- **客户端处理**:`mood` → `setMood(data.mood)`(`src/lib/ws.ts`),顶栏/侧栏显示「心情 · <mood>」;消息体内 `mood` 由聊天气泡显示(`chat.tsx`)。
- **状态持久性**:情绪与好感度为**进程内、按角色**状态,**重启回到角色配置的 `initialState`**(design/01 未要求落库;如需跨重启保留,待主人确认后加表)。
### 3.8 消息编辑 / 删除实时事件(`message_edited` / `message_deleted`,D3 实时同步)
- **触发**:`PATCH /api/v1/messages/{id}`(改 content)或 `DELETE /api/v1/messages/{id}` **成功写库后**由网关广播(与 `message_persisted` 同一条 hub 广播路径,**全连接广播 + 客户端按会话过滤**——hub 无按会话订阅;消息体的唯一真源始终在后端,这两个事件只驱动渲染同步,不携带新真源)。
- **`message_edited`**:`{type:"message_edited", sessionId, data:{id, content, mode?, mood?, createdAt?, ...}}`——`data` 为**编辑后的完整消息 DTO**(与 §3.3 的 `message.message` 同形,至少含 `id`/`content`/`sessionId`/`createdAt`,另带 `role`/`mode?`/`mood?`/`image?`/`platform?`/`deviceId?`);`thinking` 已剥离(与 `message_persisted` 一致)。**只改 `content`**,`role`/时间戳/平台等保持原值。
- **`message_deleted`**:`{type:"message_deleted", sessionId, data:{id}}`——只带被删除消息的 `id`(`sessionId` 为该消息所属会话,由后端在删除前读取后填入;客户端按它过滤,**不**据此删除别的会话的行)。
- **客户端处理**:与 `message_persisted` **同一路由规则**——按帧的 `sessionId` 写进**该会话自己的消息桶**(**v0.54 起:不再按激活会话丢弃**)(`message_deleted` 的 `sessionId` 缺失时按 `id` 兜底:消息 `id` 全局唯一,逐桶搜索、只会命中它自己那条)。`message_edited` → **按 `id` 原位更新该条的 `content`**(不新增行:找不到该 `id` 即忽略,避免把别的会话的消息注入当前列表);`message_deleted` → **按 `id` 从列表移除**。两个操作都**不触发整页重拉**——这正是它相对「重拉绕行」的收益。
- **失败不广播**:`404`(消息不存在)/ `400`(内容非法)/ `501`(未注入 HistoryStore)**不**产生任何事件,客户端不会因一次失败请求而丢掉本地消息。
### 3.9 已读游标事件(`session_read`,D4)
- **触发**:`POST /api/v1/sessions/{id}/read` **成功推进游标后**由网关广播(与 `message_persisted` 同一条 hub 广播路径:**全连接广播 + 客户端按 `sessionId` 过滤**,hub 无按会话订阅)。用途是**同账号多端清红点**:一端读了,其它端不用重拉列表就把该会话的未读清零。
- **载荷**:`{type:"session_read", sessionId, data:{readAt}}`——`readAt` 为该会话推进后的**已读游标**(毫秒 Unix 时间戳,与该会话消息的 `createdAt` 同单位)。
- **客户端处理**:仅当 `sessionId === activeSessionId`(或该会话在自己维护的会话列表里)时处理 → 把该会话 `unreadCount` 置 0(更精确的做法是按 `readAt` 重算本地未读;本地没有更完整的数据时直接清零也不会错,因为 `readAt` 只前进)。**它只驱动渲染**:唯一真源仍是后端 `chat_sessions.read_at`,漏收该帧的端在下一次 `GET /api/v1/sessions` 自然收敛(不需要补发、不需要重连)。
- **失败不广播**:`400`(`before` 非法)/ `404`(会话不存在)/ `501`(未注入 HistoryStore)**不**产生事件,其它端不会误清红点。
### 3.10 后台子代理结果事件(`subagent_result`,design/05 §9)
> **角色门(v0.48 下行门的默认档,design/11 §7.1)**:`subagent_result` 属**内容帧**(含子代理的结论正文)→ **仅 `owner` + `admin`**,`viewer` 收不到;它**不在**那 7 个状态机信号白名单里(要进白名单须先在 design/11 §7.1 登记「不含内容」的理由)。
- **触发**:`subagent_spawn` 派发的批次**转后台后**(前台只等 2 秒,design/04 §8.5)批次结算时,编排层把合并结果发布到 bus 主题 `subagent.result`,经 `ws_bridge` 中继成这一帧。**在 2 秒窗口内跑完的批次不发此帧**——它的结果已作为工具结果回给了模型(与本节同一份投影)。
- **载荷**:`{type:"subagent_result", sessionId, data:{sessionId, status:"done", summary, agents:[{id, goal, role?, mode?, status, summary?, text?, steps?}]}}`
- **帧壳 `sessionId` = 派生该批次的父会话**(`data.sessionId` 同值)——客户端据此把结果归到那个会话的消息流,多会话并发时不串话。派发回合没有会话归属时(如循环外派发)两处都省略(该帧即成无归属帧)。
- `data.status` 恒为 `"done"`(帧只在批次结算后发出,无论批次里有没有失败/中止的子代理——逐代理结论看 `agents[].status`);`summary` 是「子代理汇总: 共 N 个, M 成功…」的一行汇总。
- `agents[]` 与派发工具返回给模型的结果**同形同值**:`id`(同时也是 `subagent_event` 帧与流程图泳道节点的 id)、`goal`、`role` / `mode`(绑定的角色/行为模式,未指定时省略)、`status` ∈ `done|failed|aborted`、`summary`(该子代理的执行总结)、`text`(结论正文,**截断到 4000 字**)、`steps`(循环步数)。
- **与 `subagent_event` 的分工**:`subagent_event` 是**逐代理进度**(派发/开始/完成/失败/中止,`{id,name,status,role,mode}`,**不含结论**);`subagent_result` 是**整批的最终结果**(含每个子代理的总结与结论文本),一个批次只发一次。
- **客户端处理(现状)**:后端已按上述形状推送;**现有 Web 前端尚未消费该帧**(无 handler,按未知帧忽略),因此这一版的可见效果是「帧到达 `owner`/`admin` 连接」,而**不是**「聊天气泡里出现一条后台结果」。要渲染成「后台任务完成」提示,按 `sessionId` 过滤后追加即可(读 `data.summary` 与 `data.agents[].summary`/`text`);**不要**把它写进本地消息列表当持久消息——该结果**不落库**,后端转录里没有对应行,重连回放不会补发。
- **边界(诚实说明,v0.51)**:本帧只做「**会话作用域的实时推送**」——**不**在父会话的持久化转录中新增消息,也**未**经 design/15 的会话消息编排器排队/续接;所以 design/05 §9 的「结果回填主会话」目前停在**实时可见**这一层(此前该结果在 `ws_bridge` 处被静默丢弃,前台只剩进度帧)。
### 3.11 主代理代审小字事件(`approval_notice`,design/11 §4)
> **角色门**:内容帧(含角色名/动作/文案)→ 仅 `owner` + `admin`;未进 7 个状态机信号白名单(fail-closed 默认)。
- **触发**:loop 的工具准入门判定某次调用属敏感操作(design/11 §4 清单,当前实现的三个操作类别:执行命令 / 修改文件 / 网络写方法——`GET`/`HEAD`/`OPTIONS` 与读文件、检索属非敏感)并经主代理代审后广播。非敏感调用**不**产生该帧。
- **载荷**:`{type:"approval_notice", sessionId, data:{actor, tool, op, detail, explain, allowed, at}}`
- `sessionId` = 该次 Run 所属会话(子代理的敏感调用归属到**派生它的父会话**,与 §3.10 同口径)。
- `explain` = 固定文案 `「<actor> 想要干什么,我审查后批准了」` / `「<actor> 想要干什么,我审查后拒绝了」`(**由后端产出**,客户端**原样渲染**,不要自拼)。
- `detail` = 动作摘要(工具 + 关键参数,**目标部分截断到 80 rune**);`allowed=false` 表示该敏感调用**被拦截未执行**(无代审者/代审报错也走 fail-closed 拦截,小字同样是「拒绝了」)。
- `op` ∈ `执行命令|修改文件|网络访问`;`actor` = 想动手的角色名;`at` = RFC3339Nano。
- **审计**:每次代审在 `audit_logs` 落一行:`who`=角色名、`what`=`approval.<工具名>`、`result`=`granted|denied`;查询走既有 `GET /api/v1/admin/observability/audit` 与 `/admin/notifications`。
- **客户端处理**:与流式帧同一会话过滤规则(`sessionId === activeSessionId`),把 `explain` 作为聊天流里的一行小字渲染、`detail` 作副文本(现 Web 前端已按此实现,见 `frontend/web/src/components/status.tsx` 的 `ApprovalNoticeHint`)。
- **约束**:不新增端点、不改既有帧;老客户端按未知帧忽略即可。
### 3.12 可观测实时推送(`observability_log` / `observability_trace` / `observability_metrics`,v0.55)
> **角色门**:三个帧**都不在** `frameReachesAll` 白名单 ⇒ fail-closed **只发 `owner` + `admin`**;`viewer` 收不到,继续走既有 12 条只读监控 REST 档(v0.40)。未进 7 个状态机信号白名单(要进须先在 design/11 §7.1 登记「不含内容」的理由——它们是**运行时数据面**,不属于状态信号)。
- **取代关系**:可观测面(日志 / 指标 / trace)从「面板 1s 轮询 REST」改为 **WS 帧推送**(裁决 05.4.3 + 16.4.3-WS,design/10 §5)。**既有 REST 端点完全不变**(路径 / RBAC / `level` / `limit` 语义 / 响应形状一字未动),仍然可用,并在被读取时兼任「订阅握手」。
- **订阅(推送开启条件)**:owner/admin 凭据**读取** `GET /api/v1/admin/observability/{logs,metrics,trace}` **任一个**即建立订阅(绑定**该次登录凭据**,键 = sub+role+iat)。该凭据的 WS 连接**全部断开**即退订;订阅表空后推送循环自行退出(**无常驻 goroutine、无周期性开销**)。**这不是轮询**:那次读取本身照常返回完整引导数据,此后吃推送。
- **节拍**:≤1s 一拍,**无新数据不发帧**(空转时一帧都不发)。
- **三个下行新帧**(**纯新增**,既有帧名/形状零改动;帧壳 `{type,data}`,**不带** `sessionId` / `turnId` / `seq`——它们不属任何会话):
| 帧名 | 载荷 `data` | 上限 | 触发 |
|---|---|---|---|
| `observability_log` | `{entries:[{time,level,source,msg}…]}`(**最新在前**,与 `GET /admin/observability/logs` 同形) | 200 条 | 订阅后**新增**日志;一拍最多一帧,无新条目不发 |
| `observability_trace` | `{spans:[…]}`(**最新在前**,与 `GET /admin/observability/trace` 同形) | 100 条 | 订阅后**新完成**的 span |
| `observability_metrics` | `AdminMetrics` **本体(扁平)**,与 REST 同形:`totalRequests,qps,avgLatencyMs,maxLatencyMs,perMethod,perPath,statusCounts,llmCalls,subagentEvents` | — | 与**上次发出的**不同才发(序列化指纹比较,键排序确定) |
- **初始锚点**:订阅建立时以「当前最新」为锚(日志取最新条目时间、trace 取当前写入序号、指标取当前快照指纹),因此循环**不会**把环形缓冲里的历史整段灌成巨型帧——历史由面板那次引导读取提供。
- **单帧上限的语义**:一拍的突发超过上限时只发**最新**的那些(更旧的条目本就落在面板保留窗口之外,与 REST 尾部 `limit` 上限同口径),不丢「看得见」的数据。
- **分级过滤改在客户端本地做**:这三个帧经 bus 广播给**所有 `owner`/`admin` 连接**(hub 无按连接订阅、也无按连接分级——订阅只决定「推送循环跑不跑」),因此 `?level=` 那种服务端过滤不适用于推送;面板按本地等级选择过滤即可。
- **前端落点**:`frontend/web/src/lib/ws.ts` 的 `subscribeObservabilityFrames()`(帧**不进应用 store**,只有打开着面板的视图在听,其余时候安静丢弃);面板挂载时各读一次对应 REST 作为引导(logs 用 `limit=200`),推送帧**按业务键去重合并、倒序、窗口 200**(`frontend/web/src/admin/modules/observabilityStream.ts`)。
- **向后兼容**:纯新增帧,老客户端按未知帧忽略即可;既有 REST 与其它帧/端点/鉴权一律不变。
### 3.13 客户端幂等回执(`chat_duplicate`,v0.56 / 协同 G4)
> **角色门**:`chat_duplicate` **不在** `frameReachesAll` 的 7 个状态机信号白名单里 ⇒ fail-closed **只发 `owner` + `admin`**(`viewer` 收不到)。它由编排器的状态通道(与 `session_busy` 等同一出口)下发,因此**帧壳带 `sessionId`,没有 `turnId` / `seq`**。
- **触发**:`chat` 上行(REST `POST /api/v1/chat` 或 WS `chat` 帧)带了**非空** `clientMsgId`,而同一会话内**已经认领过同一个值**——即弱网 / 切后台下的客户端超时重发。判重由 `(session_id, client_msg_id)` 的**部分唯一索引**原子裁决(只覆盖非空键),因此并发重发只有一个赢家,不存在「先查再插」的竞态窗口。
- **载荷**:`{type:"chat_duplicate", sessionId, data:{sessionId, clientMsgId, messageId}}`——`messageId` = **既有那条用户消息的 id**(客户端据此把本地乐观气泡对齐到服务端真相,替代「靠文本相等」的脆弱匹配)。它是**广播给所有 `owner` / `admin` 连接**的状态信号(hub 无按会话订阅),没发过该 `clientMsgId` 的客户端按值自行忽略即可。
- **不做什么(红线)**:重复到达**不落库、不起回合、不发任何内容 / 流式新帧**——第二次绝不产生第二条用户消息、第二个模型调用。
- **REST 侧同义**:`POST /api/v1/chat` 的**同步**与 **`stream:true`** 两条路径重复到达都回 `200 {data:{duplicate:true,messageId,sessionId}}`(见 §1 行 3)。
- **回填与可见性**:`clientMsgId` 随消息落库并**回填进消息 DTO** ⇒ `message_persisted`(§3.3)/ `channel_persisted`(§3.4)/ 握手 `history` 回放 / `GET /sessions/{id}/messages`(§1 行 6)都能看到(**空则省略**),客户端可在重连后从读接口收敛本地乐观消息。
- **向后兼容**:上行 `clientMsgId` 是**可选**字段——**省略时行为与加入该字段之前逐字相同**(老客户端零影响,历史行该键为空);`chat_duplicate` 为**纯新增帧**,老客户端按未知帧忽略即可。
- **迁移**:新增 `0013_chat_client_msg_id`(up:`ALTER TABLE chat_messages ADD COLUMN client_msg_id TEXT NOT NULL DEFAULT ''` + 部分唯一索引 `idx_chat_messages_client_msg_id ON chat_messages (session_id, client_msg_id) WHERE client_msg_id != ''`;down:**先删索引再删列**——SQLite 的 `DROP COLUMN` 在列仍被索引引用时会失败;全新库由 `history.NewStore` 幂等前滚同一列与索引)。
### 3.14 回合内 steer 的消费回执(`steer_ack`,v0.57 / 裁决 13.4.5 目标态)
> **角色门**:`steer_ack` **不在** `frameReachesAll` 的 7 个状态机信号白名单里 ⇒ fail-closed **只发 `owner` + `admin`**(`viewer` 收不到)——它**携带具体消息身份**(客户端据此定位到某一条气泡),而不只是会话忙闲。它由编排器的状态通道(与 `session_busy` 等同一出口)**经 hub 直接广播,不经 bus** 下发,因此**帧壳带 `sessionId`,没有 `turnId` / `seq`**。
- **触发**:一个**忙时会话**收到了新用户消息(已入该会话 pending、且**已落库**),在飞回合在**回合内边界**把这条消息**吃进同一回合**的那一刻。**没有可注入消息的边界一帧都不发**。
- **载荷**:`{type:"steer_ack", sessionId, data:{messageId, atStep}}`——帧壳带 `sessionId`(`data` 内**不重复**该键)。
- `messageId` = **那条用户消息的权威 id**(后端生成,与 `message_persisted` 的 `data.message.id` 同源),客户端据此把本地乐观气泡对齐到服务端真相,**不要靠文本相等**这类脆弱匹配。
- `atStep` = **吃进它的模型步序号,从 `1` 开始(1 基)**:被注入的消息出现在**这一模型步**的可见视图里。
- **一批多帧**:同一批次吃进 N 条消息就发 N 帧(每帧一条消息的 `messageId`),**不合并**——FIFO 保持(design/15 §2.1)。
- **语义(客户端怎么渲染)**:该消息**已被在飞回合吃进**(已从待处理队列移除),**但尚未被正面回答** ⇒ 客户端应把对应气泡渲染为「**已读、未答**」的**灰态**,直到该回合的 `message.*` / `turn.end`(§3.1)到达;**不需要第二个确认帧**。它与「忙『稍后回』」的 pending 气泡**不是同一个态**:灰态表示**这条已经在被处理的那一轮里了**。
- **触发时机**:只在**回合内边界**产生——loop **每一步开头**(上一次模型步与它那一批工具调用**都已结束**、下一次模型调用**尚未开始**,因此此刻没有工具在飞,注入**不可能取消**任何已发起的工具)回调转向源;**只有该边界真的匹配到 pending 里、且已落库的用户消息时才发帧**(无新消息则一帧不发,这是常态),**不是每个 step 都发**。
- **与 `pending_ack` / `session_busy` 的关系**:后两者描述**队列深度与忙闲**(「收到,稍后回」「她正忙」);`steer_ack` 描述**队列里哪一条已被本回合吃进**(精确到 `messageId`)。客户端可用「pending 条数 − 已收 `steer_ack` 条数」表达 design/15 §5 的「队列里有 N 条、其中 M 条已被本回合吃进」(忙态本身仍是**单槽**,见 §3.2)。
- **不发的四种情形(失败一律让路)**:未接转录存储 / 读该会话转录失败 / 该消息**尚未落库** / 本回合世代已被打断或被更新的回合取代(认领 `pending` 失败)——一律**不发帧、也不注入**,该消息**留在 pending**,照旧作为自己的回合被回答,**绝不静默消失**。**未接转向源的运行**(子代理、后台思考、未接网关的循环)**不产生该帧**。
- **只吃本编排器排队过的消息**:候选必须在**该会话转录的最近 20 条窗口内**(每次边界重读转录取权威 `id`),且**必须在本编排器的 pending 里**——由别的写入方(如 IM 适配器自有路径)落库、本编排器从未排队的消息**保持不注入**(那条路径正在自己作答它,注入会让同一条被答两次);超出窗口的 pending 消息同样留队、由各自的回合作答(**不丢**)。
- **兼容性**:**纯新增帧**,老客户端按未知帧忽略即可;既有帧 / 端点 / 鉴权 / 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 一次性接入流程(三步走)
1. **拿令牌**:`POST /api/v1/auth/login` `{username,password}` → `{data:{token,expiresAt,role,refreshToken}}`(JWT,默认 24h,`role` ∈ `owner|admin|viewer`;`refreshToken` 为 B2 不透明长期凭据,用于静默续期,未接线 refresh 存储时**省略该键**)。
2. **声明版本(可选/advisory)**:可带 `X-API-Version: 1` + `Authorization: Bearer <token>`;WS 握手用 `ws://<host>:<port>/ws/chat?token=<token>`(或 `Authorization: Bearer`)。**`X-API-Version` 是**advisory**惯例头——服务端当前不校验、不据此协商版本,带不带行为不变**;`viewer` 只能读会话/人格元数据;读写会话消息、管理写操作需 `owner`/`admin`(**WS 上 `viewer` 也只收 7 个状态机信号帧:既收不到 `history` 回放,也发不出任何上行指令**,见 §3 顶部两把角色门,v0.48 + v0.49)。
3. **带端标识**(可选但推荐):每条消息/请求可带 `deviceId`(见 §4.3),让她知道你从哪个端来。
### 4.2 会话与消息用法(聊天)
- **列出会话**:`GET /api/v1/sessions` → `[{id,type:"main"|"sub",title?,createdAt,pinned,unreadCount,lastMessage?,personaId?}]`。**排序:置顶会话(`pinned=true`)最前,其次是未置顶的主会话 `id="web"`**(她与你的主线,最大化继承上下文),再按创建时间升序;⚠️ **置顶的次级会话可以排在主会话之前**,别把列表第一项当主会话(按 `id=="web"` / `type=="main"` 定位);次级 `web:<uuid>` 由 `POST /api/v1/sessions` 新建(临时小事/专业问题,独立上下文回溯)。**`personaId`(v0.42)=该会话绑定的角色**;空/缺省 = **继承全局默认角色**,客户端渲染激活会话的名字/头像用它(后端唯一真源是 `chat_sessions.persona_id`,localStorage 之类只作缓存)。
- **新建时选角(v0.42,v0.46 收口)**:`POST /api/v1/sessions` body `{personaId?}` 在**新建时**就绑角色(省略/空 = 跟随全局默认角色)。**会话角色在创建时确定、之后不可变**(design/22 §8-1):**没有"会话内换角色"这回事**——`PATCH /api/v1/sessions/{id}` 一旦收到 `personaId` 键就回 `400`(见 §1 行 8),**要换角色请新开会话**。**记忆按角色隔离**:不同角色的会话各记各的,同一角色的多个会话互相可见(design/22 §3 口径 1);群聊/陌生人会话仍**只读**、且强制默认角色。
- **角色解析优先级(v0.44,design/22 §2 裁决规则 + §7-3/§7-4)**:一个回合到底以哪个角色答话,**自上而下**取第一个命中的:
1. **请求级覆盖** —— 仍**未实现**(design/22 §2 裁决规则 1,留待后续批次);
2. **会话绑定** `chat_sessions.persona_id`(`POST /sessions {personaId}` 在**创建**时写入;之后不可变,design/22 §8-1)—— 非空即赢;
3. **渠道绑定** —— 该 IM 账号的 `personaId`(`PUT /admin/channels/{platform}/config`,v0.44)。**仅**作用于该账号的**主人私聊**,且仅当该会话(=镜像目标主会话 `web`)**自身未绑角色**时;
4. **全局默认角色** `owner.yaml:defaultPersona` —— 兜底(`''` 始终 = 当前默认角色)。
**群聊 / 陌生人隔离会话强制默认角色**(§7-4,更保守):这两类会话**跳过** 2/3 两条渠道与主会话绑定,一律用默认角色,避免把第三方会话挪进某个角色的记忆域;记忆仍是**只读**。**Web 主会话与 IM 主人私聊共享同一角色**(§7-3):主会话绑 Y 时,同一主会话里 IM 镜像进来的主人私聊回合也以 Y 答话。
- **模型上下文里的「谁说的」(v0.44,design/22 §7-5)**:一回合的上下文由该会话**最近 20 条**持久化消息(`chat_messages`,含 `persona_id`)+ 本回合新消息组成(此前只带新消息本身,模型看不到会话前文);**每条 assistant 历史消息按其说话角色标注**,模型收到形如 `[发言归属] 以下发言来自「昔涟」(xilian),不是你说的。` 的独立 system 标注,以及一条声明当前角色的 `[会话角色] …` 段。**正文永不被改写**:标注只走 system 消息,用户正文与 assistant 正文一字不改(design/11 §10:不给用户正文冒充系统指令的机会)。**对客户端透明**(无新字段、无新端点)。
- **改名 / 置顶**:`PATCH /api/v1/sessions/{id}` body `{title?}`(≤80 rune,空白标题回 `400 "title must not be blank"`——**空串不能清空标题**/恢复自动命名)和/或 `{pinned?:bool}`(至少一个字段;都缺回 `400`)→ `{data:{id,title?,pinned?}}`(只回显变更字段)。**body 里出现 `personaId` 键一律 `400`**(会话角色不可变,v0.46 / design/22 §8-1)。
- **未读 / 已读游标(D4)**:列表项的 **`unreadCount`** 直接用于会话列表红点(恒返回整数,0 = 无未读)。客户端进入会话、或滚到底看到最新消息后调用 **`POST /api/v1/sessions/{id}/read`**:body `{"before":<毫秒>}`(如「本次已渲染的最新一条的 `createdAt`」),或**省略 body** = 标记到**现在**。后端**只前进**地推进该会话**唯一**游标(单 owner 账号:读一处 = 所有端已读),并广播 `session_read`(§3.9)——同账号其它端**免刷新**清红点。列表 → 读 → 再看列表即可自洽(漏收广播的端下次拉列表自然收敛)。
- **拉取激活会话历史**:`GET /api/v1/sessions/{id}/messages?limit=50&before=<ms>&platform=&q=`。
- **热分页**:`limit` 默认 50(上限 200),`before` = 当前最旧一条的 `createdAt`(毫秒),向上翻页。`platform` 可选(IM 适配器传本平台只拉本渠道;Web/通用客户端不传 = 全量共享主会话)。
- **搜索**:`q` 非空 = **会话内**内容搜索(倒序,仍支持 `before`/`limit` 分页);空 `q` = 普通分页;`q` 与 `after` 互斥。**不跨会话**。
- 返回**按时间倒序**,需 `reverse()` 成旧→新用于渲染。
- **编辑 / 删除单条消息**:`PATCH /api/v1/messages/{id}` `{content}`(≤4000 rune)→ `{data:<消息DTO>}`(裸 DTO,无 `message` 包裹);`DELETE /api/v1/messages/{id}` → `{data:{deleted:<id>}}`。不存在回 `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?, 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)
- **`platform`**(来源平台):`web` / 渠道如 `telegram`、`weixin-ilink`、`qq-obv11`。Web 端恒 `web`;IM 渠道消息带各自平台。**同一平台多个端 → 相同 platform、不同 deviceId。**
- **`deviceId`**(具体端,客户端自己生成):Web 每个浏览器 `localStorage` 持久化 `web:<browser>:<随机短id>`(如 `web:chrome:a1b2c3`);IM 按 `<im平台名>:<本地配置唯一id>:<用户平台唯一uid>`;某 APP 可自定可读前缀。发送时带上,服务端落库消息,聊天气泡可标注来源。
- **`senderName`**(第三方昵称):只有群聊/陌生人(非主人)消息带,主人私聊恒空(`omitempty`)。**IM 适配器_log**:该平台非主人私聊在 `GET /api/v1/admin/channels/{platform}/messages` 以消息流显示。
### 4.4 常见错误码 / 边界
- `401` 未认证/令牌失效 → 重新登录。`403` 权限不足(viewer 读管理写/主会话删除)。
- `404` 资源不存在(消息/会话 id 未找到)或端点未实现;`501 not_implemented` 后端明确未提供(如知识库删除、新增用户)。
- `400` 请求体/参数非法(`limit`/`before` 越界、空 content)。`409 chat_busy` 排队满(**REST 响应体与 WS 定向 `error` 帧同码**,见 §3.2)。
- **文档相关(v0.59 起;v0.61 更新白名单与裁决口径)**:`415 unsupported_media_type`(**扩展名白名单(23 项,含 `.pdf`)与字节嗅探的成对裁决**任一不过——HTML/纯文本改名成 `.pdf`、或 PDF 改名成 `.txt` 都走它)、`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"`,**扫描件/图片型 PDF 就是它**,v0.61)。
- **红线**:身份判定永远来自后端 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,**v0.59 起可带 `clientMsgId` 幂等**),下行 `voice_output`(合成音频 base64 + text + **`sessionId`/`deviceId?`**,**定向回发起那条连接、回合结算时才到**,空正文/TTS 失败带 `provider` 降级信号);**语音回合与文本回合走同一条路**(落库 / 跨端 / 忙时排队),见 §3.15。
## 5. 变更记录
- `v0.62`(**面板「重置为 env 默认」动作落地:materialize 的反方向显式动作 + 一个新的 owner-only 管理写端点**,依据任务书 `DevDocs/_dispatch/R1-面板重置为env默认.md` 与 design/04 §3.1(`.env` 为默认/播种层、面板文件为一等来源)和 `DevDocs/开发时间线.md` 的欠账项(「Materialize 已导出、未接端点」),2026-10-04 落实)。**三档标注**:**纯增量** = 新端点 `POST /api/v1/admin/llm/reset-from-env`、面板 LLM 页的新卡片、审计动作 `llm.resetFromEnv`、2 个新错误码(`env_defaults_unreadable` / `models_unreadable`,另复用既有 `llm_persist_failed` / `llm_reload_failed` / `llm_unavailable`);**行为变更** = 无(既有端点 / 帧 / 字段一字未动);**破坏性** = 无(新端点的新用途;它本身的破坏性由请求体 `confirm:true` 强制)。**A. 新端点**(**owner-only**——与同页 `POST /admin/llm`、`POST /admin/llm/test` **同门**,破坏性写不比同族更宽;带审计 `llm.resetFromEnv`)body **必须** `{"confirm":true}`(缺失 / `false` / 非法 body ⇒ `400 bad_request` 且**一个字节都不动**),→ `200 {data:{reset:[{group,keys:[…]}], skipped:[{group,reason}], path}}`。**B. 语义(与 materialize 共用同一张键表 `runtimeconfig.groupFillSpecs`,方向相反)**:① **只覆盖 `.env` 声明过非空值的键**(`provider` / `baseUrl` / `apiKey` / `model` / `contextWindow`;协议族按**组自己的 `provider`** 选,`.env` 同时配扁平块与 `ANTHROPIC_*` 时两族各取自己的值);② **env 未声明的键一律保留面板值**(`fallback` / `multimodal` / `maxTokens` / `reasoning` / `personaModels` 在 `.env` 里没有出处 ⇒ 永不被本动作改动);③ **组集合不增减**(不复活被面板删掉的组、不删面板新增的组,**顺序也不动**);④ **面板值已与 env 一致的键不算「改回」**,不出现在 `reset` 里(`provider` 恒属此种情形);⑤ **无键可改时不写盘**(幂等,重复调用第二次回 `reset:[]` + 每组一条「已一致」的 `skipped`);⑥ **真的改了盘就热重载**运行中的 LLM 栈(无需重启;`contextWindow` 的**预算推导**仍只在启动时算一次,与 `POST /admin/llm` 同口径)。**C. 响应与凭据纪律**:`reset[].keys` 只有 **`models.json` 的 JSON 键名**、`skipped[].reason` 是中文原因,**凭据值(尤其 `apiKey`)绝不回显、绝不进日志**(`reset` / `skipped` 恒为数组,无记录时是 `[]` 而非 `null`;`path` 与 `POST /admin/llm` 同源)。**D. 对照基准**:**请求时刻按既有解析入口读到的 `.env` 默认层**(新增 `config.LoadLLMDefaults`,复用 `parseDotEnv` + 同一份 `env:"…"` 解码器 ⇒ 优先级与启动期 `config.Load` 逐字一致:进程环境 > `.env` > 标签默认);路径由组合根经 `Deps.EnvPath` 注入(与 `main.go` 的 `config.MustLoad(envPath)` 同一条),**网关自己绝不 fopen `.env`**。**E. 失败不伪装**(都**不改文件**且带审计 `error`):`.env` 源不可读 → `500 env_defaults_unreadable`;面板配置不存在/不可解析 → `500 models_unreadable`;写盘失败 → `500 llm_persist_failed`;已写盘但热重载失败 → `500 llm_reload_failed`(说明文件已改、未生效);LLM 栈未注入 → `500 llm_unavailable`。**F. 面板**:`frontend/web` 的 LLM 页新增「重置为 env 默认」卡片(`admin/modules/llm/LlmResetFromEnv.tsx`,标 `owner 专属`)——点按钮先展开**中文二次确认**文案,确认后才带 `confirm:true` 提交;结果按 `reset`/`skipped` 渲染成可复核的组名 + 键名摘要,失败原样显示后端错误文案。**G. 实现落点**:`internal/config/llm_defaults.go`(env 默认层读取)+ `internal/config/runtimeconfig/reset.go`(`ResetFromEnv` 语义,新文件)+ `internal/services/gateway/admin_write_llm_reset.go`(端点,新文件)+ `routes.go`(注册为 `ownerAuth`)+ `deps.go`/`server.go`(`Deps.EnvPath`)+ `cmd/cyrene/wire_frontend.go`(组合根接线)+ 前端 `lib/types/adminWrite.ts` / `lib/api/admin.ts` / `admin/modules/llm/LlmResetFromEnv.tsx` / `admin/modules/Llm.tsx`。**H. 未做 / 诚实边界**:**不支持「只重置某一组」**(一次调用对全部组生效;`skipped` 会如实报出没被改动的组与原因);**不做「重置 channels.json / plugins.json」**(materialize 本身也只覆盖 `models.json` 一族);**不做「重置后自动重启」**(provider 栈本身热更新,窗口推导需重启由面板文案写明)。
- `v0.61`(**PDF 文本层抽取落地:白名单第 23 项 `.pdf` + 类型裁决由「嗅探只放行 `text/plain`」改为 `(扩展名, 嗅探)` 成对裁决**,依据 design/24 §7 的 **S6 第一段**(PDF/Office 的裁决:PDF 文本层引库,扫描件与 Office 仍诚实拒绝)与 commit `f82c76d`,2026-10-04 落实)。**三档标注**:**行为变更** = ① **`.pdf` 由「一律 `415`」变为「可上传 + 可读取 + 文本层可抽取进上下文」**(把「`.pdf` 必被拒」写死的客户端必须改);② **类型裁决收紧为成对判定**(`documents.ContentAllowed`)——**方向是收紧/细化,不是放宽**:HTML/纯文本改名成 `.pdf` ⇒ `415`(嗅探不是 PDF),**PDF 字节改名成 `.txt` ⇒ `415`**(PDF 字节也只收在 `.pdf` 名下);**纯增量** = 扩展名白名单 **22 → 23 项**(唯一新增 `.pdf`),其余 22 个文本类的判定与既有响应**一字未变**。**A. 上传(`POST /api/v1/documents`,owner|admin)**——白名单加 `.pdf`;裁决 = **字节嗅探是唯一裁决权 ∧ 扩展名白名单**,自本版起**按对判定**(纯文本类必须 `text/plain`、`.pdf` 必须 `application/pdf`;落点 `internal/runtime/documents/store.go` 的 `ExtAllowed`(23 项)/ `ContentAllowed`,网关名字形态校验经它判定);`mime` 仍是**嗅探基类型**(真 PDF → `application/pdf`);`415` 文案同时点明两条口径(「只接受白名单内的文档(如 .txt/.md/.csv/.json,或内容确为 PDF 的 `.pdf`),且内容必须与扩展名相符」)。**B. 读取(`GET /api/v1/documents/{name}`,必带 Bearer owner|admin)**——**真 PDF 如实下发 `Content-Type: application/pdf`**(读时按同一对裁决重嗅探,判不定者仍退 `application/octet-stream`),`Content-Disposition: attachment` + `X-Content-Type-Options: nosniff` **一字未变** ⇒ **PDF 永不被内联渲染**(与图片读侧刻意相反);`Cache-Control: private, max-age=86400, immutable` 与 Range 支持不变。**C. 抽取语义(DTO `status`)**——**文本层 PDF 抽正文**(页内容流里的 `Tj`/`TJ`/`'`/`"` 文本算子,页间以换行相接)⇒ `status:"extracted"`,与纯文本类走**同一条** sidecar / 注入路径;**扫描件/图片型 PDF(无文本层)⇒ 诚实拒绝 `status:"unreadable"`**,原因文案含「这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)」,**绝不假装成功、也绝不返回空正文**(`ErrNoTextLayer`);**加密 PDF** 与**损坏/截断 PDF** 是**可区分**的两类失败(`ErrPDFEncrypted` / `ErrPDFDamaged`,加密的提示解除密码后重试);安全阀 = 单份最多 **5000 页** + sidecar 正文 **≤8 MiB**(撞上即 `truncated`)。**D. 客户端可见的一次性重算**——抽取器版本内部升到 **`documents/v3`**(`internal/runtime/documents/extract.go`)⇒ **旧 sidecar 一次性失效重抽**:同一份**旧附件**的 `chars`/`truncated` 结论会在**某次回合之后**重新计算(旧 PDF 若是文本层 PDF,将从 `unreadable` 变为 `extracted`)——不是附件被改写,是结论被重算。**E. 未做(诚实边界)**——**不做 OCR / 扫描件栅格化**(纯 Go 无可靠实现,会撞「单二进制 + 交叉编译」「零跨进程 IPC」两条红线)、**不做 PDF 写/生成**(只读)、**不做 `.docx`/`.xlsx` 等 Office**(仍诚实拒绝);**无新端点、无新 WS 帧、无鉴权变更、无 `.env` 新增键**。**实现落点**:`internal/runtime/documents/extract_pdf.go`(文本层抽取 + 加密/损坏分类)+ `internal/runtime/documents/{extract.go,store.go,sidecar.go}`(版本号 / 白名单与成对裁决 / unreadable 原因文案)+ 依赖 `github.com/ledongthuc/pdf`(纯 Go、无 cgo)。
- `v0.60`(**管理侧只读「存储状态」端点 + 面板存储卡接真数据**,依据主人 2026-10-04 实测反馈(面板首页「存储」卡一直是占位「SQLite」,看不出 PG 增强有没有生效)与 design/10 §3 模块 14(系统设置)/ design/03 §3(pgvector 可选增强),2026-10-04 落实)。**一句话**:**纯新增一个只读端点**,既有端点 / WS 帧 / 字段形状**一字未动**;面板那张假卡换成真数据。**A. 新端点** `GET /api/v1/admin/system/storage`(**owner-only**、**只读**、**无审计动作**,与四条 `POST /system/*` 动作端点同门不同档)→ `200 {data:{main,documents,vector}}`:`main` = `{kind:"sqlite",path,fileBytes,walBytes,shmBytes,pageSize,pageCount,freelistPages,freeBytes}`(`path` 与备份/恢复端点同源;WAL/SHM 边车**合法地不存在**时如实为 `0`;`freeBytes = pageSize × freelistPages`)、`documents` = `{count,bytes}`(读 `documents` 表,**不 walk 目录**,空表 `bytes` 为 `0` 而非 `null`)、`vector` = `{kind:"pgvector"|"none",enabled,ready}`。**B. `vector` 的语义(主人 2.4 的困惑点)**:`kind` 说「配没配 `DB_PG_URL`」,`enabled` 说「开关在不在」(等价于 `kind != "none"`),`ready` 说「**活着的**向量索引真的是 pgvector 后端吗」—— 配了但连接/schema 准备失败 ⇒ `enabled:true` + `ready:false`,客户端必须显示「已配置但未就绪(检索已降级)」,**不要把 `enabled` 当成生效**。客户端显示建议:`none` → 「未启用(未配置 `DB_PG_URL`)」、`enabled && ready` → 「启用(`pgvector` · ready)」、`enabled && !ready` → 未就绪。**C. 两条纪律**:① **不含凭据**——DSN / 用户名 / 密码 / `postgres://` 全文**一个字都不回**(`vector` 段只有 `kind`/`enabled`/`ready` 三个键;有用例断言响应体里搜不到这些串);② **只读**——只有 `SELECT`/`PRAGMA` + `os.Stat`,且复用组合根注入的**活连接**(不新开连接池、不写、不迁移、不备份;调用前后 `documents` 行数与数据目录内文件集合均不变,有用例钉住)。**D. 真源(`vector`)**:组合根 `cmd/cyrene/wire_frontend.go` 的 `app.vectorState()` —— `Configured` 取 `DB_PG_URL` 非空、`Live` 取活索引的具体类型(`*vector.PGVectorIndex`);网关**只读这份注入值**,不读环境变量、不新开 PG 连接、不自行探测(否则面板与运行时会给出两个答案)。**E. 失败不伪装**:未接活连接 / 数据库路径未配置 / 读不到页级统计或 `documents` 表(例如库没跑迁移)⇒ `500 {error:{code:"storage_unavailable",message}}`,**绝不回一个全 0 的假快照**;无凭据 → `401`。**F. 面板**:`frontend/web` 的存储卡(`admin/modules/dashboard/overviewMetrics.ts`)改为真数据 —— `value` = `SQLite 12.3 MiB`(`main.fileBytes` 的人类可读量级)、`hint` = `WAL … · N 页(空闲 M 页 / …)`、新增两行详情 = `documents N 件 · …` 与 `PG 增强 …`;取数走与其它已接线卡片**同一条** `useAsyncData` 加载/错误态(数据没到就显示「端点报错:…」,**不回退成 0、不假装成功**)。**G. 实现落点**:`internal/services/gateway/admin_system_storage.go`(新文件)+ `routes.go`(注册)+ `deps.go`/`server.go`(注入 `Deps.Database` / `Deps.Vector`)+ `cmd/cyrene/wire_frontend.go`(组合根真源)+ 前端 `lib/types/adminWrite.ts` / `lib/api/adminSystem.ts` / `admin/modules/dashboard/{useDashboardExtras,overviewMetrics,MiniMetric}`。**H. 兼容性**:**纯新增**——新端点为纯增量;前端 `MiniMetricSpec` 新增的 `extra?: string[]` 是**可选**补充详情行,不传时卡片渲染与旧版**逐字等价**(其它卡片一字未改)。**未做 / 诚实边界**:`main` 只报 SQLite(当前唯一主库引擎);`vector` 只报 `kind`/`enabled`/`ready`(**刻意不回 host**,把泄漏面压到最小);不报表级明细、不报备份文件列表(`documents` 之外的占用仍要下钻到备份/文件系统看)。
- `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`;**v0.61 起**改为 `(扩展名, 嗅探)` 成对裁决、`.pdf` 走 `application/pdf`),**扩展名白名单 22 项**(⚠️ 历史值;**v0.61 起为 23 项**,纳入 `.pdf`)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** 项(⚠️ 历史值;**v0.61 起为 23 项**,纳入 `.pdf`;当时 design/24 §9.2 写 23,以代码为准);文档的配额/引用计数与 GC(design/24 S7)、~~PDF/Office 解析(S6)~~(⚠️ **PDF 文本层已由 v0.61 落地**;**Office 与扫描件仍诚实拒绝**)与 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/<name>`)由**服务端自己取回并内联为 `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` **无服务端过期清理**(键随消息长期保留,判重窗口即该会话历史)。
- `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,**前端路由面——不是 REST 契约**)**——① **新增 env `ADMIN_PATH`**(默认 `admin`):面板前端路由前缀 = `/` + 值(`admin` → `/admin`;`xxx/admin` → `/xxx/admin`);**非法值回退 `/admin`**——容忍首尾斜杠与空白,拒绝空段、`.`/`..` 段与含 `?`/`#` 的段(宁可挂回默认,也不挂到不可预测的路径上)。② **HTTP 行为**:`/<ADMIN_PATH>` 与其深链(`/<前缀>/<section>`)返回**注入了 `<base href="/">` 与 `window.__CYRENE_ADMIN_ENTRY__={"prefix":"..."}` 的 index.html**(`Cache-Control: no-store`;`<base href="/">` 必在资源引用之前,否则多段前缀/深链下 Vite 的 `./assets/...` 会解析到错误目录);**形如面板入口但前缀不符的路径 → `404`**(判定 = 路径里出现 `admin` 段;既不回落用户端聊天壳、也不回显正确前缀);`/<ADMIN_PATH>` 若**恰好命中 `dist` 真实文件**则仍走文件服务器(面板入口页绝不顶掉 SPA 资源)。③ **API 不藏**:`/api/v1/*`、`/api/v1/admin/*`、`/ws/chat`、webhook 一律原样——**无新端点、无帧变更**,安全靠 JWT + 角色门(design/07 §0.1)。④ **口径**:**用户端 HTML 永不携带该前缀串**(后端只在命中前缀时注入)⇒ 用户端既无管理入口链接、也解析不出面板路由(design/07 §0.2)。前端路由随之改**路径式**(`frontend/web/src/admin/router.ts` 只读注入、**无硬编兜底**、移除 hash 入口;模块切换走 history)。**B. 数据目录(同 commit,裁决 12.4.2)**——`DATA_DIR` 新增可接受值 **`platform`**(大小写不敏感)→ 平台标准数据目录(Windows `%APPDATA%\Cyrene`、macOS `~/Library/Application Support/Cyrene`、Linux `~/.local/share/cyrene`);**默认 `./data` 与显式路径行为不变**;**显式空值行为不变**(仍走组合根原兜底,避免正在运行的实例突然换库)。**C. OneBot v11 四端(`9253fef`,裁决 1.4 / 12.4.1)**——① **配置面**(channels.json 账号 `config` + 适配器 `Config()` 快照)新增:`transport`(`ws`|`http`,默认 `ws`;**未知值一律回落 `ws`**,绝不静默改走 HTTP)、`api_base_url`(OneBot HTTP API 基址,必须显式给出——**不从 `remoteURL` 推导**,v1 那种「剥掉最后一段路径」的推导是有损的)、`report_path`(默认 `/`,无前导 `/` 时补齐);快照里 camelCase 为 `transport` / `apiBaseURL` / `reportPath`;`remoteURL` 快照值 = `remote_url`、为空时回落 `api_base_url`(语义即「**出站远端地址**」)。**既有键与值不变**——不给出任何新配置时行为与改造前逐字节一致(`transport=ws`、`mode=server`)。② **env 兜底**:`CHANNEL_QQ_OBV11_TRANSPORT`(默认 `ws`)/ `CHANNEL_QQ_OBV11_API_BASE_URL` / `CHANNEL_QQ_OBV11_REPORT_PATH`。③ **四端语义(谁向谁发数据)**:`ws+client` Cyrene 拨号 OneBot、收发同一 WS;`ws+server` OneBot 反连 Cyrene、收发同一 WS;`http+client` Cyrene→OneBot(`POST {api_base_url}/{action}`、等响应、按 `retcode` 判成败,**纯出站**、无长连接、token 只走 `Authorization: Bearer`);`http+server` OneBot→Cyrene(POST 到 `listen_addr` + `report_path`,**纯入站**;**fail-closed 鉴权**——配了 `access_token` 时 `Authorization: Bearer` 或 `?access_token=` 不匹配回 `403`,`access_token` 为空则放行,与既有 `ws+server` 同口径)、**10MiB 请求体上限**(超出 `413`)、方法非 `POST` 回 `405`、成功回 `204`。④ **关键取舍**:`mode` 决定**哪一半必需**(server 必需 `listen_addr`、client 必需 `api_base_url`),另一半只要给齐字段就**一并启用** ⇒ **单个适配器即可完成「收上报 + 回消息」闭环**,四端仍各自可独立选择与测试;任一端配置有误即 `Connect` 失败(先做纯配置校验并构造出站客户端,再绑监听端口),**绝不留下半开的端口**。⑤ **端口例外**:`http+server` 会用 `listen_addr`,属 `AGENTS.md` §9 已登记的**同一豁免**(design/13 §0 原文即写「WS server / HTTP server」);`listen_addr` 为空则报错、**绝不开端口**(无默认值)。⑥ **未接**:`get_group_list` 枚举(后续)。**D. 可观测实时推送(`0e1f163`,裁决 05.4.3 + 16.4.3-WS)——新增 §3.12**:日志 / 指标 / trace 从「面板 1s 轮询 REST」改为 **WS 帧推送**,**三个下行新帧**(**纯新增**,既有帧名/形状零改动):`observability_log`(`{entries:[{time,level,source,msg}…]}`,**最新在前**、与 `GET /admin/observability/logs` 同形,单帧上限 **200 条**,触发 = 订阅后**新增**日志)、`observability_trace`(`{spans:[…]}`,与 `GET /admin/observability/trace` 同形,上限 **100 条**,触发 = 订阅后**新完成**的 span)、`observability_metrics`(`AdminMetrics` **本体、扁平**,与 REST 同形:`totalRequests,qps,avgLatencyMs,maxLatencyMs,perMethod,perPath,statusCounts,llmCalls,subagentEvents`,**与上次发出的不同才发**)。**帧壳 `{type,data}` 不带** `sessionId`/`turnId`/`seq`(不属任何会话)。**角色门**:三者**都不在** `frameReachesAll` 白名单 ⇒ fail-closed **只发 `owner` + `admin`**(`viewer` 收不到、继续走既有 12 条只读监控 REST 档)。**订阅(推送开启条件)**:owner/admin 凭据**读取** `GET /admin/observability/{logs,metrics,trace}` **任一个**即建立订阅(绑定**该次登录凭据**,键 = sub+role+iat,不把 token 串留在内存);该凭据的 WS 连接**全部断开**即退订,订阅表空后推送循环自行退出(**无常驻 goroutine**)。**这不是轮询**:读取本身照常返回完整引导数据,此后吃推送;循环启动时以「当前最新」为锚(日志最新条目时间 / trace 写入序号 / 指标快照指纹),因此**不会**把环形缓冲里的历史整段灌成巨型帧。≤1s 一拍、**无新数据不发帧**(空转一帧都不发)。**既有 REST 端点完全不变**(路径 / RBAC / `level` / `limit` 语义 / 响应形状一字未动);`?level=` 那种服务端分级过滤不适用于推送(帧广播给所有 `owner`/`admin` 连接——订阅只决定推送循环跑不跑,不决定谁收帧),改由**客户端本地**做。**前端落点**:`frontend/web/src/lib/ws.ts` 的 `subscribeObservabilityFrames()`(帧不进应用 store,只有打开着面板的视图在听);面板挂载时各读一次对应 REST 作引导(logs `limit=200`),推送帧按业务键**去重合并、倒序、窗口 200**(`admin/modules/observabilityStream.ts` + `observabilityViews.tsx`)。**E. 其余**——① **13.4.5 面内三条**(`74b12d8`):(a) 续接窗口 `Recent` 在同毫秒并列时改为取**最新** n 条且按插入序(Windows `time.Now()` 步长约 0.44ms,连发必然同毫秒并列;修复前并列取**更早**的 ⇒ 丢掉刚发出的最新 n 条且同毫秒内反插入序)——**无 API 变化**,但客户端可观察到的「握手回放 / 续接上下文」顺序更正确;(b) 上行 `chat` 不再经**可丢弃**的 bus,改为直达会话编排器的非阻塞 `Submit`(design/15 §2.1「会话消息不能被 drop」+ §4「不阻塞是硬约束」;编排器未注入时回退 bus)——**帧序列不变**;(c) 被**取消**(中断)的回合把该会话 checkpoint 落定为不可恢复(`context.WithoutCancel` 写,快照保留可查询),**不再顶掉下一条消息的上下文**,新增内部常量 **`loop.StopReasonCanceled`**(`checkpoints.state.stop_reason` 可能出现该值,**不经 REST/WS 暴露**)。② **MCP**(`a1083a3`):协议层换成**官方 Model Context Protocol Go SDK v1.8.0**(+ `jsonschema-go`),删自研 rpc/stdio/http/sse,改用 SDK 的传输 / 协议版本协商与 `-32022` 回退 / keepalive / `tools/list_changed` 订阅 / 能力协商——**REST/WS/env/数据目录零变更**(`MCP_SERVERS_JSON`、`MCP_*`、`transport` 枚举 `stdio|http|streamable|sse` 全不变;`POST /admin/plugins/install` 请求/响应形状不变)。**实现落点**:A = `internal/config/config.go`(`AdminPathPrefix` + `resolveConfiguredDataDir`)+ `internal/services/gateway/routes.go`(`adminEntryHatch`/`adminEntryHTML`)+ `frontend/web/src/admin/router.ts`;B = `internal/config/config.go` + `internal/platform`;C = `plugins/channels/qq_obv11/{adapter,http_client,http_server,session,ws_client,ws_server}.go` + `cmd/cyrene/channels.go`;D = `internal/services/gateway/observability_push.go` + `ws_bridge.go` + `frontend/web/src/admin/modules/observabilityStream.ts`。**未做(诚实边界)**:DashScope 形状的 `auto` 推断(须显式声明 `reasoning.kind=dashscope`)、Qwen 的 `/think` 前导消息软开关、`get_group_list` 枚举、**上游显式退订帧**(当前退订只发生在订阅凭据的连接全断时;WS 上行暂无 unsubscribe 帧)、**审计 tab 仍 5s REST 轮询**(未做 `observability_audit` 帧)。**向后兼容**:A/B 是配置面新增(默认值即现状)、C 全是**纯新增配置键**(默认 `ws+server` 行为不变)、D 是**纯新增帧**——老客户端按未知帧/未知键忽略即可;既有端点 / 帧 / 字段形状**无破坏性变更**。
- `v0.54`(**契约债一次补齐:渠道配置写路径改 patch 语义(修 P0 级凭据静默丢失) + 阶段 6 前端批登记 + 三处过时陈述修正**,依据 design/04 §3.1、design/22 §2 方案 b 与裁决 16.4.1 / 16.4.2 / 16.4.3 / 16.4.4 / 16.4.5 / 16.4.6;commit `4ae7148` / `f454273` / `b480b04` / `c1a98d7` / `8d70f6d`,2026-10-04 落实)。**A. 渠道配置写路径由「整条替换」改为「按 id 打补丁」(P0 级数据安全,破坏性语义变更)**——① **语义变更**:`PUT /api/v1/admin/channels/{platform}/config` 的 `config` 从「整条替换该账号配置」改为「**按 id 打补丁**」(`runtimeconfig.PatchChannel`,**与 `personaId` 同构的三态**):**键省略(该键不在 `config` 里,或 `config` 整体缺席 / 为 `null`)= 保留该账号已存值**、**给出非空值 = 覆盖该键**、**显式 `null` 或 `""` = 清除该键**(适配器回落零值;`0` / `false` 是真实取值,**不算空**)。**破坏性提示**:按旧文实现、依赖「省略某 `config` 键 = 删掉它」的客户端,**必须改为显式传 `null` / `""`** 才能清除该键。② **修掉的数据安全问题**:面板只发送用户本次**碰过**的平台字段,后端却整条替换 ⇒ ⓐ 保存**微信(iLink)**账号卡会抹掉 `bot_token` / `bot_id` / `owner_id` / `get_updates_buf`(**iLink token 是长期凭据**,被抹掉即掉线且不可恢复);ⓑ **Telegram** 只填 `webhook_url` 而不重填 token ⇒ `bot_token` 变空 ⇒ `cleanAccounts` **丢掉整个账号**。两条在修复前都是「面板保存成功、盘上凭据没了」的静默丢失。③ **保留清除能力**:显式 `null` / `""` 仍能真正删除该键,未触碰的兄弟键不受影响。④ **未改**:token 要求型平台(kook / telegram)上**显式**清 `bot_token` 仍按 `cleanAccounts` 丢掉该账号(明确要求清除凭据,不是误删);「本次请求一个 `config` 键都没给出」在**非 `weixin-ilink`** 平台仍回 `400`(`weixin-ilink` 豁免以便先建账号再扫码)。⑤ **无新端点 / 新字段 / 响应形状变更**:请求体与 `201 {data:{persisted,path,platform,accountId}}` 一字未动,只有 `config` 的解释规则变了;`allowGroups` / `denyGroups` / `allowUsers` / `denyUsers` 与 `groupMsgPerMin` / `msgPerMin` / `maxMessages` **仍是整值替换**(省略 = 清空,客户端须回传完整列表 / 非零值)。⑥ **实现落点**:`internal/config/runtimeconfig/channels.go` 新增 `PatchChannel` / `mergeChannelConfig` / `isEmptyConfigValue`;`internal/services/gateway/admin_write_channel_config.go` 的 `adminSaveChannelConfig` 改调 `PatchChannel`(`channelConfigFromRequest` 返回**补丁**而非完整配置)。**验证**:`go test ./internal/services/gateway/ -run TestAdminSaveChannelConfigKeeps -v`(微信 iLink 凭据保留 / 空补丁三形态 / Telegram 账号存活 / 显式清除 / 无基线新建)+ `go test ./internal/config/... -run TestPatchChannel -v`;**变异验证 3 条**——把写路径改回**整条替换**(旧 upsert 语义)后,「省略键保留」「Telegram 账号存活」「空补丁不改凭据」三类断言**精确复现** `bot_token` / `get_updates_buf` 被抹掉与账号被 `cleanAccounts` 删掉的旧缺陷 ⇒ 变异必红。**B. 阶段 6 前端批(纯前端行为,无 API 变化)**——① **16.4.1 静默 / 活动态与预约切换**(`f454273`,帧缺口补 `b480b04`):输入模式切换的唯一入口 `frontend/web/src/components/modeSwitch.ts`——**她活动**(`sessionBusy || 屏上 streaming || sending`)时用户切换只登记「**已预约**」(不改当前模式、不上行、不清除既有预约),**她静默**时立即生效并上行 `mode_switch`;提前切 → **下次静默态落地**(徽章「已预约 · X(她静默后生效)」);预约状态**纯前端**、不进后端契约。② **16.4.5 会话级消息缓存**(`b480b04`):**帧按自己的 `sessionId` 落进该会话的消息桶**(新增 `frontend/web/src/store/sessionCache.ts` 纯函数 + `useAppStore.sessionMessages`),**切会话「存回 / 取出」不再清空**、非激活会话的帧**不再被丢弃**;**只有「单槽忙态」**(`session_busy` / `session_idle` / `session_interrupted` / `stream_end` 的 `sessionBusy` 收口)仍按激活会话判定(见 §3.1 / §3.2)。③ **16.4.2 导图口径**(`b480b04`):`graph_update` 按 `data.sessionId` 入**图桶**(上限 12 张)——管理端 Flow / Agents 页取**激活会话**的图、**Backthink 页取 `sess-backthink`** 的图,后台思考图不再顶掉「当前会话」图(见 §3.5)。④ **16.4.3 / 16.4.6 虚拟滚动与专属页只读卡**(`c1a98d7`):管理端长列表超过阈值走**窗口化**(`AdminTable` / 新增可复用 `VirtualList` / 适配器消息日志,DOM 行数与数据量解耦);专属页**只读展示该平台完整配置字段**(`AdapterConfigSummary`,此前专属页一个配置字段都不显示)。纯管理端 UI。⑤ **16.4.4 双档 UI 模式**(`8d70f6d`):**性能档 = 现状**(默认,Smooth Corner 走 `border-radius` 近似 + 单向高光)、**华丽档**对齐 Miuix 六大核心(连续曲率 k=0.55 + 完整 Liquid Glass)——**华丽档落 `:root.ui-mode-fancy`**(性能档 `ui-mode-performance`,另有 `data-ui-mode` 属性),档位**持久化键 `cyrene.ui-mode`**(localStorage + 跨标签页同步),`main.tsx` 在 `createRoot` 前 `initUiMode()` 保证不闪一帧。**纯前端行为、无 API 变化**(裁决 Q12),对应 **`design/07 §0.1`「双档呈现(裁决 16.4.4)」**。**C. 过时陈述修正(随实现事实改文)**——① §3.1 / §3.3 / §3.8 / §4.2 删去「**非激活会话的帧被忽略 / 仅当会话为激活态才处理或渲染**」的旧口径,改为「帧按其 `sessionId` 路由到该会话的消息桶」(16.4.5 的实现事实,旧文与实现相反);② §3.1 / §3.2 补「**单槽忙态**仍按激活会话判定」;③ §3.5 补 `graph_update` 的**按会话入桶 + 管理端按页取图**口径(16.4.2);`Docs/api-reference.md` 的对应处(§5.24 / §6.2 帧壳)同步。
- `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)**——面板「添加提供商 / 添加模型」的候选目录(baseURL / 模型 id / 能力位 / 输出上限),数据源 `https://models.dev/api.json`;`?refresh=1` 强制在线刷新,否则 24h TTL 内的本地快照直接复用。`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"}}`;**有快照但刷新失败** → 仍 `200` + `stale:true` + `warning`(离线兜底,不编造候选);带 `Accept-Encoding: gzip` 时响应带 `Content-Encoding: gzip`(信封形状不变)。**副作用只有一处**:落盘快照 `<DATA_DIR>/llm_catalog.json`(**无凭据**、`data/` 已 gitignore、TTL 24h),**不改 `models.json`**;**抓取只发生在本端点内,不进运行时热路径**。审计动作 `llm.catalog`(`ok`/`stale`/`error`)。详见 §2「模型配置契约(v0.53)」。② **`models.json` 组级新增三键**(可热更新,`GET`/`POST /api/v1/admin/llm` 已同步读写;**未声明时该键不出现**):`reasoning: {kind}` 声明该组的推理开关**形状**;`personaModels: {personaId: modelId}` 与 `personaProviders: {personaId: "openai"|"anthropic"}` 是**角色级模型/协议路由**(「角色」= 该回合答话的 persona,不是消息作者 role)。**校验在 `Group.Validate`**:非法 `kind`(只认 `auto`/`deepseek`/`openai`/`vllm`/`dashscope`)、anthropic 组上声明 openai 系形状、`personaProviders` 的非法协议名、空 persona id/空值 → **写端点回 `400 bad_request` 且不落盘**;persona 表键值会 trim,**trim 后撞键直接报错**(不静默丢条目)。**请求语义**:三键缺省 = 保留已存值(`reasoning` 整块给出即整块替换,persona 表给出即**整表替换**——否则面板无法删除一条已存路由);**响应语义**:每组的 `reasoning` **恒下发**(`kind:""` = 未声明形状,不是「后端没报告」),`personaModels`/`personaProviders` 为空则省略;顶层新增 **`reasoningKinds`** 与 **`personaProviderOptions`** 两个取值域数组(由后端常量直接生成,面板无需硬编码枚举)。**回落**:未绑定/未知 persona → 该组默认模型;`personaProviders` 声明的协议在 `models.json` 里没有对应组 → 记 WARN 并跳过(该角色仍走默认链,**不半建一条只会失败的链**)。③ **语义修正(重要,此前文档写反了)**:**`multimodal` = 主模型自身能否收图**(不是「本部署有没有视觉能力」);`LLM_VISION_MODEL` / `LLM_OCR_MODEL` **只配视觉/OCR 工具用的模型**(OCR 空则复用视觉模型,两者都空则复用主模型),与 `multimodal` **互不代表**(裁决 14.4.1)。④ **`.env` 新增 9 键**:`LLM_REASONING_KIND`(默认空 = `auto`;**只在无有效 `models.json`(走 env 合成组)时生效**,有 `models.json` 时以该组 `reasoning.kind` 为准)、`LLM_OCR_MODEL`、`LLM_LOCAL_ENABLED`(默认 `false`)/`LLM_LOCAL_PROVIDER`(默认 `openai`)/`LLM_LOCAL_BASE_URL`/`LLM_LOCAL_API_KEY`/`LLM_LOCAL_MODEL`(本地第三级:`Enabled` + `BaseURL` + `Model` 三者齐备才成为链尾一步,只开开关则 WARN 并跳过)、`CAPACITY_L3_ENABLED`(默认 `false`)/`CAPACITY_L3_CACHE_TTL`(默认 `5m`)。⑤ **内置工具新增 `image-crop`**:按区域裁切图片(`image` + `x`/`y`/`width`/`height`,0 基像素、区域须完整落在图内)→ PNG data URI + 裁切尺寸 + 源图尺寸;供**大图被上游压缩后分区精读**(design/06 §3)。上限:源图 ≤50MiB、区域 ≤16MP、载荷 ≤8MiB(超出**报错**,不截断);源可为本地路径(file 工具同一沙箱)/ http(s) URL(同 SSRF 守卫)/ base64 data URI。**策略默认 owner-only**(`tool_policies` 无行时 fail-closed)。⑥ **推理开关按 provider 形状下发(替换 v0.41 的「只 WARN 不发」)**——形状由 `reasoning.kind` 声明(未声明 = `auto`:按模型名/baseURL 推断厂商,**形状有歧义就不下发**),字段名映射表:
| 形状 `kind` | 线上字段 | 说明 |
|---|---|---|
| `deepseek` | 顶层 `thinking:{type:"enabled"\|"disabled"}` + `reasoning_effort` | 思考**默认开**,「不传」≠「关」:要关必须显式 `disabled` 或 `effort:"none"`;`none` 胜过 `enable:true`(关思考优先);`budget_tokens` 在该协议上无对应字段,记 WARN 丢弃 |
| `openai` | 仅 `reasoning_effort` | 该系没有布尔思考开关;`thinking.enable` 记 WARN 丢弃(官方关推理路径 = `effort:"none"`) |
| `vllm` | 顶层 `chat_template_kwargs.enable_thinking` | 只有布尔开关,无 `reasoning_effort`;**`effort:"none"` 一律翻译成显式关思考**(不得降成任何强度档) |
| `dashscope` | 顶层 `enable_thinking` | 同上;**只在显式声明该形状时下发**,绝不从模型名/BaseURL 推断 |
| `auto`/未声明 | 无 | `deepseek`/`openai` 可推断时按其形状下发;**形状有歧义(如 Qwen 可能由 vLLM 或 DashScope 承载)或厂商未知 → 一个字段都不发**,逐项 WARN 并给出配置指引 |
- `reasoning_effort` 校验:按**厂商白名单**(OpenAI 系 `none/minimal/low/medium/high/xhigh/max`;DeepSeek `none/low/high/max`)——非法值**丢弃并记 WARN,不再「降级到最近一档」**(降档会反转调用方意图);DeepSeek 的别名 `minimal→low`/`medium→high`/`xhigh→high`/`ultra→max` 归一化后发送并记一条 WARN。Anthropic 组继续走自己的 `thinking:{type,budget_tokens}`(`reasoning.kind` 在其上被拒)。
- 同批另含两条**对客户端无端点/帧/字段变更**的实现:**persona `awakening` 档位真生效**(`configs/personas/*.yaml` 的 `awakening` 块归一化为 `quiet|awake|full` 并进 capacity 决策;非法档位/越界阈值载入期报错、块缺席 = 无覆盖继续走全局默认,design/14 §3)、**工具越出本轮允许集的软判并回**(越界调用**仍然执行**——能力≠边界;结果里带 `not_allowed` 提示回灌模型,并记审计 `tool.reject.not_allowed`,design/14 §4)。`capacity_awaken` / `approval_notice` 等既有帧形状**一字未动**。
⑦ **向后兼容**:新端点、新组键、新 `.env` 键、新工具都是**纯增量**;既有端点 / WS 帧 / 字段形状**无破坏性变更**(`GET /admin/llm` 只多出 `reasoning`/`personaModels`/`personaProviders`/`reasoningKinds`/`personaProviderOptions` 这些新键,消费旧键的客户端不受影响)。**未做(诚实边界)**:DashScope 形状的 `auto` 推断(需显式声明 `reasoning.kind=dashscope`)、Qwen 的 `/think` 前导消息软开关(与 design 口径冲突,本批未实现)。⑧ **实现落点**:目录 `internal/services/gateway/admin_llm_catalog{,_cache,_source}.go` + 路由 `routes.go`;组键与读侧 `internal/config/runtimeconfig/models.go`(`ReasoningOptions`/`PersonaModels`/`PersonaProviders`/`Validate`)、写读端点 `internal/services/gateway/admin_llm_provider.go` + `admin_write_llm_channels.go` + `admin.go`/`admin_query.go`;形状下发 `internal/runtime/model/reasoning_shape.go`(+ `reasoning.go` 的厂商白名单/别名、`openai_encode.go` 的线上字段);per-persona 路由 `internal/runtime/model/{router,persona_stack,runtime}.go`;本地第三级 `internal/runtime/model/factory.go`(`localChainStep`)+ `internal/config/config.go`;capacity L3 `internal/runtime/capacity/{capacity,classifier,l3}.go`;awakening `internal/persona/loader/awakening.go`;软判 `internal/runtime/tool/allowset.go` + `internal/runtime/loop/tool_policy.go`;工具 `plugins/tools/io/crop.go`。对应提交:`63e5b70`(目录端点)、`fa33c79`(per-persona 路由 / 本地三级 / L3 / OCR 独立模型 / image-crop)、`38b4527`(工具软判)、`71fefe5`(persona awakening)、`8890c30`(按形状下发推理开关 + per-persona 提供商链)。
- `v0.52`(**阶段 4 三组落地:工具策略落盘 + 主代理代审小字 + 流程图节点参数摘要**,依据 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`,唯一索引 `idx_tool_policies_name` 承载 upsert 冲突目标),`POST /api/v1/admin/plugins/policy` 从此**重启保留**——不再是「面板保存成功、一重启就复位」的假持久化(见 §2 行 5)。**写入路径先落库、后改内存**(`registry.SetPolicy` → 落盘成功才改内存并记入快照):**落盘失败则内存保持旧值并返回错误**,不会出现「面板说成功、内存已改、库里没有」的分叉;启动时 `AttachPolicyStore` 立即 Load 全量策略套到**已注册**能力上,此后 `Register` 进来的能力(MCP 热装、插件刷新)同样套上落盘值;**没有行的能力按默认(两布尔均 false = owner-only)处理**(fail-closed)。策略存储不可用/加载失败时**保持最严默认并记 WARN**,不静默降级成内存态、也不假装读出了策略。**对客户端无端点、无字段形状变更**。② **新增下行帧 `approval_notice`**(§3.11,design/11 §4):loop 的工具准入门(`ToolLoopHooks.CallAdmit`)判定某次调用属敏感操作(执行命令 / 修改文件 / 网络**写**方法)→ 经主代理代审后广播 `{type:"approval_notice", sessionId, data:{actor, tool, op, detail, explain, allowed, at}}`;`explain` 是**后端产出的固定文案**「<actor> 想要干什么,我审查后批准了」/「…我审查后拒绝了」(客户端**原样渲染**,不要自拼),`detail` 为动作摘要(工具 + 关键参数,目标部分截断到 80 rune),`allowed=false` 表示该敏感调用**被拦截未执行**(无代审者 / 代审报错同样 fail-closed 拦截,小字同为「拒绝了」)。**非敏感调用不产生该帧**;**子代理的敏感调用归属到派生它的父会话**(与 §3.10 同口径),小字因此落在主人正在看的那个会话里,而不是一个没人看的子代理 id 下。**审计**:每次代审在 `audit_logs` 落一行(批准与拒绝**各一行**)——`who`=角色名、`what`=`approval.<工具名>`、`result`=`granted|denied`,查询走既有 `GET /api/v1/admin/observability/audit` 与 `GET /api/v1/admin/notifications`。**角色门**:内容帧 → **仅 `owner` + `admin`**(fail-closed 默认,未扩那 7 个状态机信号白名单)。**前端已渲染**(`frontend/web/src/lib/ws/handlers/approval.ts` + `frontend/web/src/components/status.tsx` 的 `ApprovalNoticeHint`):按同一会话过滤规则把 `explain` 作为聊天流里的一行小字、`detail` 作副文本。**向后兼容**:纯新增帧,不改既有帧 / 端点 / 鉴权,老客户端按未知帧忽略即可。③ **`graph_update` 节点 `props` 新增四类可选键**(裁决 11.4.2 / 11.4.3,见 §3.5):**旁路节点的 span 首次真进图**——`memory.recall` / `memory.write` / `guardrail.input` / `guardrail.output` / `orchestration.spawn` 现在都由**真实生产者**产生,`kind` 因此会实际出现 `memory` / `guardrail` / `orchestration`;tool 节点另带 `args_keys`(该次调用的**全部参数名**)与 `args_summary`(**仅放行表内、且非密钥类**的键才给值,**密钥类键连值都不给**,单值 ≤**80 rune**、最多 **24** 条);旁路节点只带元数据(`action` / `scope` / `hits` / `bytes` / `count` / `failed` / `allow` / `subagent` / `elapsed_ms`),**不含记忆正文、不含提示词、不含密钥**。值在**发布前已脱敏**(凭据形状 → `[redacted]`、长文本截断),前端对未放行的键显示「值未展示」。**帧名、事件名、顶层字段集一字未动**——以上只是节点 `props` 内的**纯增量可选键**。④ **本版未做 / 诚实边界**:**代审是「记录并批准」**——「敏感与否」与「是否放行」仍由上层策略层判定,`orchestration/audit.Approver` 只做校验、记录、叙述并返回结果模型,**不构成新的权限边界**(身份/权限判定始终来自 channel 层 `actor` 元数据 + 白名单 + 策略引擎);**`agent.RunStep`(静音 / 非流式内部流程,如 backthink)的输出侧护栏仍同步内联**(其裁决是 `StepResult` 的一部分、不落在主会话关键路径上),只有**主会话流式关键路径**的输出侧护栏改为**后台异步**(`loop.checkOutputAsync`,裁决 13.4.1:真并发、不阻塞回合,拦截仍写审计留痕;**输入侧前置门依旧同步拦下**)。⑤ **实现落点**:策略落盘 `internal/capability/registry/policy_store.go` + `registry.go` 的 `SetPolicy`、迁移 `internal/db/migrations/0012_tool_policy.{up,down}.sql`;代审 `internal/capability/restrict/{sensitive,approve}.go` + `internal/runtime/orchestration/audit/approval.go` + `internal/services/gateway/admin_approval.go` + 组合根 `cmd/cyrene/wire_runtime.go` 的 `toolAdmissionHooks`;流程图 `internal/runtime/loop/{spanlog,memory,stream}.go` + `internal/runtime/agent/step.go` + `internal/runtime/orchestration/scheduler_spans.go` + `internal/runtime/trace/{graph_props,graph_build,redact}.go`。
- `v0.51`(**后台子代理结果登 WS 帧 + 回填父会话**:新增下行帧 `subagent_result`;依据 design/05 §9「子代理完成 → 结果回填主会话并提示用户」,裁决 02.4.3,2026-10-04 落实):① **问题**:`subagent_spawn` 的批次前台只等 2 秒(design/04 §8.5),转后台后结算时编排层已把合并结果发布到 bus 主题 `subagent.result`,但 gateway 的中继**不认这个 subject**(既非 `ws.*`,也不是已登记的 4 段广播形态 `cyrene.agent.*.event`)⇒ 结果**永远到不了前台**,前端唯一可见的是逐代理进度帧 `subagent_event`(「跑起来了/跑完了」,但**拿不到结论**)。② **新增帧 `subagent_result`**:`{type:"subagent_result", sessionId, data:{sessionId, status:"done", summary, agents:[{id, goal, role?, mode?, status, summary?, text?, steps?}]}}`——`data` 与派发工具返回给模型的结果**同形同值**(同一处投影、同一处 4000 字截断,`text` 即子代理的结论正文);**帧壳 `sessionId` = 派生该批次的父会话**,客户端据此把结果归到**那个会话**的实时流(多会话并发不串话),而不是当成一张全局帧。详见 §3.10。③ **回填边界(诚实说明)**:本版把「结果可见」做到了**会话作用域的实时帧**——结果现在真的到达父会话的 WS 流(此前被中继静默丢弃)。**未做**:不落库(父会话的持久化转录里**没有**新增一条消息,重连/回放不会补发它),也**未**经 design/15 的会话消息编排器排队续接;**现有 Web 前端也没有该帧的 handler**(按未知帧忽略,故今天的可见效果是「帧到达 owner/admin 连接」而非「聊天气泡里出现结果」)。REST/历史接口**逐条不变**:`GET /sessions/{id}/messages` 不会多出这条结果。④ **角色门(fail-closed)**:新帧属**内容帧** → **只发 `owner` + `admin`**,`viewer` 收不到;**未**扩那 7 个状态机信号白名单(design/11 §7.1,白名单要进须先在设计登记「不含内容」的理由)。⑤ **向后兼容**:**纯新增帧**——老客户端按未知帧忽略即可;不改任何既有帧的类型/字段、不改端点、不改鉴权;`owner`/`admin` 只是多收一帧。⑥ **实现落点**:生产者 `internal/runtime/orchestration/spawntool.go`(`SubagentResultEvent` 载荷带上派发回合的父会话);中继 `internal/services/gateway/ws_bridge.go`(`bridgePatterns` 订阅该 subject + `normalizeEvent` 专用成形:帧壳 `sessionId` 取自载荷父会话;载荷形状不符预期时退回通用映射,两路都只发 `owner`+`admin`)。⑦ **验证**:进程内两端各一条——编排侧断言后台批次发布的载荷**类型/键集/父会话/逐代理结果**;gateway 侧**真 bus Publish → 真 `/ws/chat` 连接**(真 JWT × `owner`/`admin`/`viewer`)断言 `owner` 与 `admin` 各收到**恰好 1 帧** `subagent_result`(帧壳 `sessionId` = 父会话,`data` 的汇总与逐代理字段逐项对得上)+ 1 帧哨兵状态信号,而 `viewer` **只**收到哨兵(收不到结果帧)。**变异验证**:① 把 `orchestration.SubagentResultSubject` 从 `bridgePatterns` 去掉 → 「owner 收到 `subagent_result`」必须 FAIL;② 把 `subagent_result` 加进 `frameReachesAll` → 「`viewer` 收不到」必须 FAIL(两条均已跑,还原后复绿)。⑧ **同版另含管理面(D 组,2026-10-04 落实,与上列同属 v0.51 一版)**:**① `X-API-Key` 真正接线**(裁决 15.4.2)——`Authorize` 之上叠加 key 分支,管理/用户侧已认证端点同时接受该头,签发出去的 key **真的能用**;**且 key 一律超管、取消 role 分级**(裁决 15.4.3,主人原话「key 始终给超级管理员权限」):签发端点**不再读 `role` 请求体**、响应恒 `role:"owner"`、任何有效 key 鉴权后即 `owner`。**fail-closed**:请求带该头时**只走 key 分支**,未知/已吊销/空值(或 key 表未注入)一律 `401`,**不回落 Bearer**;不带该头时 Bearer 路径**一字不变**;**WS 握手仍只认 Bearer**。**破坏性边界**:此前文档写的 `{"role":"admin|viewer"}` 请求体自本版起**被忽略**——若曾有客户端指望签一把只读 key,它拿到的其实是 owner key(这正是裁决要的效果:取消分级,不给「以为只读、实则超管」的错觉)。**② 新增主人档案读写 `GET`/`PUT /api/v1/admin/owner-profile`**(裁决 10.4.5,**owner-only**——这份档案决定谁能进 owner 车道,故连读都不给 `admin`;写带审计 `owner.profile.update`,读不审计;全新安装 `GET` 回 `200` + 空白档案而非 `404`):消灭前端**假保存**——面板的「保存」从此真的落盘 `configs/users/owner.yaml`(0600 原子写)并**热替换活档案**,渠道层 actor 判定立即生效、无需重启;校验失败(`id`/`displayName` 缺失或 accounts 项半空)**不落盘**并回 `400 bad_request`,写盘失败回 `500 owner_profile_persist_failed`,档案损坏/读不出回 `500 owner_profile_unreadable`(不返回看似正常的空档案)。**③ persona `identity.systemPrompt` 改必填**(裁决 08.4.2):`POST`/`PUT /admin/personas` 缺失或空白一律 `400 bad_request`(`"systemPrompt is required (identity.systemPrompt must not be empty)"`),**不再派生默认说明串**回落——载入期执行同一规则,写出的 YAML 必能被重新载入,不会出现「一条没有身份的人格被静默降级成拼凑串」。**文档同步**:§0 认证、§1 行 8、§2.0 权限矩阵(owner-only 档 35 → 37 条)与本「管理面板写操作契约」已按实现改写。
- `v0.50`(**事件批:流式帧的结构化补齐**——`tool_execution` 三事件 + `streamId` + `turn.start/end` + `graph_update` 三范围口径;依据 design/04 §1.1(裁决 01.4.1 / 01.4.2 / 01.4.3 / 03.4.4)与裁决 01.4.5(design/20 §2),2026-10-04 落实):① **`tool_execution` 拆三事件(破坏性替换)**:旧单帧 `tool_execution.update {id,index,delta}`(`delta` 为 `"start:工具名"` / `"end:error:工具名"` 拼串)**不再由 loop 产生**,替换为 `tool_execution.start`(`{tool, phase:"running", id?}`,进入执行)/ `tool_execution.update`(`{tool, phase:"running", delta?, id?}`,运行中进度,可多帧)/ `tool_execution.end`(`{tool, phase:"done"|"error", id?}`,完成或失败)——**phase 与工具名是结构化字段,不再塞进 `delta`**;`id` 复用 provider 工具调用 id(可关联 `tool_call.*` 帧,无 id 时省略该键)。**迁移**:按旧 `delta` 拼串解析的客户端必须改读 `phase`/`tool` 字段;`tool_call.*` 帧形状不变。**诚实边界**:start/end 由 loop 在每次工具执行的首尾真实发出;`update` 的 wire 映射已实现并有单测锁定,但工具分发器尚无中途进度上报源,**loop 暂不产出 update 帧**(未来接入进度上报无需再改契约)。② **`streamId`(新增可选字段,位于模型流帧的 `data` 内)**:`message.*` / `thinking.*` / `tool_call.*` 帧的 `data` 带 `streamId`——loop 在**每次模型流**(一次 ChatStream 调用)开始时生成,同流多帧共享,多步工具轮的下一个模型调用取新 id;与帧壳 `turnId`(整轮恒定)分工:`turnId` = 哪轮回答,`streamId` = 这轮里的哪条流式消息。`tool_execution.*` / `turn.*` 帧不带。③ **`turn.start` / `turn.end`(新帧,无 `data` 载荷)**:loop 在 Run 起止发出、经 ws_bridge 下行(`model.StreamEvent` 新 kind → hub `broadcastStream` 映射);帧壳 `sessionId`/`turnId`/`seq` 即边界信号;`turn.end` 覆盖所有出口(最终答案 / 工具循环安全网终止 / 预算耗尽 / 步骤出错);回合终态仍由 `stream_end` 携带(分工不重叠);被静音的内部流程(backthink / 子代理)不发出。④ **`graph_update` 三范围口径**(01.4.5,随实现事实补记):发布范围为 `web` / `web:<uuid>` / `sess-backthink` 三类(此前文档只写「web 及其子会话」,漏了 backthink 思考链路图这一实现事实;实现侧 `cmd/cyrene/wire_runtime.go` 的 allow 谓词本就如此,本批零代码变更);并补记 **tool 节点 `status` 由 phase 驱动**(与 §3.1 `tool_execution.*` 的 `phase` 同枚举同决策:`trace.FlowStatusForPhase` 消费 tool span 上的 phase,缺省回落 span 终态)。⑤ **角色门**:本批新帧(`tool_execution.start/end`、`turn.start/end`)全部落 v0.48 下行门的「仅 `owner` + `admin`」档(fail-closed 默认,白名单未扩);`viewer` 行为不变。⑥ **向后兼容总结**:新增帧与新增键(`streamId`、`data.tool`、`data.phase`)可被老客户端安全忽略;唯一破坏点是旧 `tool_execution.update` 单帧消失(见 ①);**现有 Web 前端未消费 `tool_execution.*` / `turn.*` / `streamId`,本批对它零影响**。⑦ **验证**:loop 包断言三事件顺序/phase/工具名/id/Delta 为空、同流共享 streamId 跨流相异、turn 边界成对且覆盖错误出口、静音 run 零事件、flow 图 tool 节点 running→done/error 与 phase 一致;gateway 包真 WS 帧端到端(真 loop + 真 dispatcher + bus → relay → broadcastStream):按 seq 排序断言完整帧序 `turn.start → message.delta → tool_execution.start → tool_execution.end → message.delta → turn.end`(+`stream_end`)、`streamId` 每流独立、`tool_execution` 帧无 `delta`/`streamId` 键、turn 帧无载荷;帧门矩阵补 5 行(三事件 + turn 两帧 × 三角色)。**变异验证**:去掉 loop 的 `ev.StreamID = streamID` 透传 → 「streamId 每流独立/前缀」断言 FAIL(已跑,还原后复绿)。
- `v0.49`(**WS 握手回放 + 上行帧的角色门**:把 design/11 §7.1 下行门落地时查出的**同一类缺口的另两处**一并收口——**对 `viewer` 继续收紧**,**`owner` / `admin` 行为不变**,**无端点、无字段形状变更**;依据 design/11 §7.2,2026-10-03 落实):① **问题**:本质与 §7.1 同一个——`viewer` 是**只读监控档**,却能通过 WS 拿到内容、甚至发起写入。(a) 握手 `history` 回放**对任意角色照发**(回放主会话最近 50 条**消息正文**,`thinking` 已剥离但正文本身即主人的对话),`viewer` 一连上就把对话拿走了;(b) `route` 的**上行分发完全没有角色检查**,`viewer` 发 `chat` 能**真的驱动模型并把消息落库**(只读档拿到写权限),`subagent_ask` / `task_output_query` 的应答是**定向帧**(`enqueueJSON`)因此**绕过 §7.1 的下行门**,把子代理上下文 / 任务输出直接回给 `viewer`。② **规则(fail-closed,默认拒)**:**A** 握手 `history` 回放**仅 `owner` + `admin`**(`ownerAdminClaims`,与主动推送同一个判据);**B** 非 `owner`/`admin` 连接发来的**任何**上行 JSON 帧一律拒绝——**定向**(只回发送者)回 `{type:"error",data:{code:"forbidden"}}`,**不广播、不驱动模型、不落库、不返回任何内容**;被拒帧:`chat` / `interrupt` / `subagent_ask` / `task_output_query` / `voice_input` / `mode_switch`(**默认拒**,未登记的一律拒)。**协议级帧不受影响**:WS ping/pong 与正常 close 照常(否则连接会掉),被拒帧也不关连接。③ **实现**:A 收口在**唯一**发出回放的 `hub.replayHistory` 顶部(**不放调用点**,没有第二个调用者能绕过);B 收口在 `wsConn.route` 的**第一条语句**,且写成「**默认拒**」而**不是被拒类型黑名单**——新加的上行帧类型自动被拒,漏一处也不会放行。④ **合口径(与 §7.1 一起读)**:`viewer` = **可以连接、可以读 12 条只读监控 REST(v0.40)、可以收 7 个状态机信号帧(v0.48)**;**除此以外 WS 上拿不到任何内容,也发不出任何指令**。⑤ **未来放宽的要求**:将来若真需要 `viewer` 上行(如手机端以 viewer 凭据做只读订阅之外的交互),**必须先在 design/11 §7.2 给它开口并登记白名单**;代码里那把「默认拒」必须**改造成登记表**,**不得直接删掉**。⑥ **测试与变异验证**:真 WS 连接 + 真 JWT(复用 v0.48 的测试装置)。A:`viewer` 连接**收不到** `history` 回放,`owner` / `admin` **收得到**(且回放里 `thinking` 仍为空);B:`viewer` 依次发 `chat` / `interrupt` / `subagent_ask` / `task_output_query` / `voice_input` / `mode_switch`,**每次都收到定向 `forbidden` error**,且其后**无任何内容帧**(对 `subagent_ask` / `task_output_query` 尤其断言「不得收到定向的 `subagent_event` / `task_output`」),全程**计数 runner = 0 且落库 = 0**;另一条 `viewer` 连接作为观察者**不收**这些 error(证明是**定向**而非广播);回归:`owner` 发同一个 `chat` 仍然**真的驱动 runner(= 1)**并把 user + assistant 两条**落库**;协议级:被拒后 ping/pong 往返与正常 close(服务端注销连接)**均正常**。**变异验证**:① 去掉 A 的门 → `viewer` 立刻收到 `history`(A 断言 FAIL,已跑);② 去掉 B 的门 → `viewer` 的 `chat` 驱动 runner **1 次**并**落库 2 条**、`subagent_ask` 直接回 `subagent_event`(B 断言 FAIL,已跑)。⑦ **影响面**:`owner` / `admin` **逐字不变**;`/ws/chat` **连接鉴权不变**(`viewer` 仍可连);无新端点、无字段形状变更;今天**没有 `viewer` 客户端在跑**(`/auth/login` 仍写死签发 `RoleOwner`,除测试外无 viewer 凭据),故线上实际影响为 0。
- `v0.48`(**WS 下行帧的角色门**:`/ws/chat` 的下行帧改为**按帧类型分档**——**对 `viewer` 是收紧**(此前它连上就能收到角色回复正文与推理内容,现在收不到),**`owner` / `admin` 行为不变**,**无端点、无字段形状变更**;依据 design/11 §7.1,主人 2026-10-03 拍板「按建议收紧」):① **问题**:hub 的下行是**全连接广播**,而 `/ws/chat` 只验 JWT(任意角色可连),于是 `viewer` 连接会收到 `message.*` / `thinking.*` / `tool_call.*`(**含角色回复正文与推理内容**)、`message_persisted` / `message_edited` / `message_deleted`、`graph_update`、`mood`、`subagent_event` / `task_output` / `device_update` / `reminder` 等——与 design/19 §1/§3「flow 可视化仅 owner/admin」直接冲突,也与 `GET /sessions/{id}/messages` 的 owner/admin 档位不一致(**同一批内容从 WS 旁路漏出**)。② **规则(fail-closed,默认拒)**:**只有白名单里的 7 个「状态机信号」**(`session_busy` / `pending_ack` / `pending_drain` / `session_idle` / `session_interrupted` / `pending_full` / `session_read`,**均不含任何内容**)发给**所有已认证连接**(含 `viewer`);**其余全部**(`stream_chunk` / `stream_end` / `thinking`、`message_persisted` / `message_edited` / `message_deleted` / `channel_persisted`、`graph_update` / `subagent_event` / `subagent.result` / `task_output` / `task_done` / `tool_used` / `capacity_awaken` / `mode_switch`、`mood` / `device_update` / `reminder`)**只发 `owner` + `admin`**;**未登记的新帧类型默认只发 owner/admin**(要进白名单须先在 design/11 §7.1 登记理由「不含内容」)。③ **实现**:收口为**唯一一处**「按帧类型分类后投递」的入口(`internal/services/gateway/ws_frame_scope.go` + `hub.broadcast`),**不给每个调用点加开关**(那正是「漏一处即泄漏」的写法)——流式 `message.*` / `thinking.*` / `tool_call.*`(`broadcastStream`)、`stream_chunk` / `stream_end`(`runTurn` / `streamChat`)、`message_persisted` / `message_edited` / `message_deleted`、`mood` / `capacity_awaken`、编排状态信号(`StateSink`)、`graph_update` / `subagent_event` / `device_update` / `reminder`(`relay` 归一化)**全部经同一入口**,不存在第二条投递路径。④ **语义澄清(不是数据从此不可见)**:这条门管「**谁该被主动推送**」——`viewer` 的 **12 条只读监控端点**(v0.40 `viewerAuth`,含 `GET /personas` 的角色级 `mood`)**逐条照旧**;`/ws/chat` 的**连接鉴权不变**(`viewer` 仍可连,只是收不到上列帧);**定向回请求连接的帧**(`voice_output` / `error` / `subagent_ask`、`task_output_query` 的应答)不进这道门、行为不变。⑤ **测试与变异验证**:真 WS 连接 + 真角色 token 的**逐帧表驱动**断言(design/11 §7.1 表里**每一个**帧类型 × 三种角色:`owner` / `admin` 两类都收得到,`viewer` 只收白名单)+ 帧类型 × 角色的直接矩阵 + 「白名单精确等于 7 个状态信号」与「未登记类型默认拒」的锁定;变异验证:把 `graph_update` 改回全广播 → 「viewer 收不到 `graph_update`」必须 FAIL(已跑,见提交说明),把 `session_idle` 改走受限路径 → 「viewer 收得到 `session_idle`」必须 FAIL(已跑)。⑥ **已知残留(本次未动,留待主人裁决)**:`/ws/chat` 的**上行**仍未按角色授权(`viewer` 仍可发 `chat` / `interrupt` / `subagent_ask` / `task_output_query` / `voice_input`,其应答是**定向**帧、不经过本门),且握手时的 `history` 回放仍对任意角色照发(`thinking` 已剥离)——二者都在 design/11 §7.1 本次门的作用域之外。**(本残留已由 `v0.49` 收口:上行帧默认拒、握手回放仅 `owner`/`admin`,见上一条变更记录与 §3 顶部第二把角色门。)**
- `v0.47`(**`graph_update` 子代理泳道根节点新增可选 `props.persona`**,对客户端**纯增量**;实现依据 design/22 §8-2「跨角色派发 = 把其他角色以子代理形式派出去」):① **`subagent_spawn` 指定目标角色派发时**,该子代理在父会话图里的**分支根节点** `props` 多一个 `persona` 键(值 = 目标角色 id),供流程图显示"这个子代理是以谁的身份在替班",而不只是子代理 id。② **不指定角色派发时该键不出现**:帧形状与 v0.46 逐字一致,老客户端忽略未知键即可。③ **无新端点、无字段改名、无鉴权变更**;`subagent_event` 帧**未新增字段**(其 `role` 会等于目标角色——既有字段的取值语义变化,非形状变更)。④ **`subagent_spawn` 的 `persona` 参数不在本文档契约内**:那是给**模型**用的工具 schema(工具层以「进程能否按该角色运行」为判据,未知角色整批拒绝且不产生任何子代理),不是给客户端的 HTTP/WS 契约。⑤ **验证**:进程内 e2e 断言「子代理实际收到的 system prompt = 目标角色 instructions」+「其记忆写入落在**目标角色**域」+「未知角色整批拒绝」+「**省略 `persona` 时结果 JSON 键集与旧版完全相同**」;变异验证(把目标角色换回父/默认角色、关掉角色校验)对应断言均 FAIL。
- `v0.46`(**会话角色不可变:`PATCH /sessions/{id}` 拒绝 `personaId`**,对客户端**是破坏性变更**——按 v0.42–v0.45 旧文实现、依赖「PATCH 换角色」的客户端必须改为**新建会话**选角;design/22 §8-1 主人拍板定稿):① **`PATCH /api/v1/sessions/{id}` 的 `personaId` 改为「**出现即 `400`**」**:只要请求体里**有 `personaId` 键**(任何取值:`"yuki"` / `""` / `null` / 未知 id / 空白串)就回 `400 bad_request`,**错误消息点名 `personaId`** 并说明「会话角色在创建时确定、之后不可变,要换角色请新开会话」。**实现取「明确拒绝」而非静默忽略或 200 空操作**:静默忽略会让客户端以为绑定已改。② **顺序保证**:该检查在解析完 body 后**立即**执行、**先于**会话存在性检查 —— **未知会话 + `personaId` 回 `400`(不是 `404`)**;`title` + `personaId` 同传也整体 `400`,**其余字段一个都不落库**。③ **不变的部分**:`title` / `pinned` 的语义、边界(≤80 rune 等)与响应形状**逐字不变**;`POST /api/v1/sessions {personaId}`(**创建时**绑定、未知角色 `400`、非空回显)**完全不变**;`GET /sessions` 与消息 DTO 的只读 `personaId` 不变。④ **移除的路径**:PATCH 上再也没有 `{personaId}` 成功响应、也没有「不支持角色绑定的 HistoryStore → `501`」这条路(网关的 `Server.setSessionPersona` 及其中间层随之删除;`history.Store.SetSessionPersona` 作为存储能力保留)。⑤ **前端同步**:用户端**移除**「会话行 → 切换角色」菜单与面板「挂角色」下拉,**保留**「新建会话时选角色」;管理面板「角色」列改为**只读**展示创建时绑定的角色。⑥ **测试与变异验证**:新增断言覆盖「键出现即 400(六种取值)+ 绑定未变 + 未知会话仍 400 + `title`/`pinned` 不变」,并把「静默忽略」与「改回写绑定」两种反向实现各跑一次(两处断言均必须 FAIL,见提交说明)。
- `v0.45`(**渠道 `personaId` 三态修订:修「省略 = 解绑」会静默清空绑定**,对客户端**无字段/端点形状变更**,但**语义收紧**——按 v0.44 旧文实现的客户端若靠「省略 `personaId`」来解绑,必须改为**显式传 `""`**;design/22 §2 方案 b):① **`PUT /api/v1/admin/channels/{platform}/config` 的 `personaId` 改为三态**:**键省略(或 JSON `null`)= 保留该账号已存绑定**(不动);**显式 `""` = 解绑**(写盘时该账号不带 `personaId` 键);**非空 id = 绑定该角色**;**未知角色 id 仍 `400 bad_request`**(且**只校验本次真的传了值**的情形——省略时继承的已存绑定在当初写入时已校验)。② **修的问题(数据安全)**:管理面板只在主人**动过**角色下拉时才发这个键(前端 `channelPersona.ts` / `channelPersonaRequest`),而 v0.44 后端把「键不存在」也当成解绑 ——**主人打开一个已绑角色的账号、不动下拉、只改 token 并保存 → 该账号的角色绑定被静默清空**(无提示、无审计可见的理由),两端语义相反。现在后端的「省略」与前端一致 = 保留。③ **实现**:请求 DTO 的该字段由 `string` 变 `*string`(指针区分「省略」与「显式空串」),应用逻辑在既有「读当前配置(channels.json)→ 写入该平台账号 → 落盘 → `bridge.Reload(platform)`」路径内按三态取值(**写入路径自 v0.54 起改为按 id 打补丁 `PatchChannel`**)(省略时从**同平台同 id** 的已存账号继承,故保存一个账号不会动到同平台其它账号的绑定);**无新端点、无新字段、无响应形状变更**。④ **`GET /api/v1/admin/channels` 每账号的 `personaId` 回显不变**(v0.44 新增,非敏感、不掩码),面板据此回填角色下拉。⑤ **测试**:省略 → 绑定保留、显式 `""` → 清除、非空 id → 绑定、未知 id → `400`(含「省略时改其它字段后读回确认」与「同平台多账号互不影响」),并把「省略 = 解绑」作为变异反向验证(该断言必须失败)。
- `v0.44`(**渠道级选角 + 「谁说的」进模型上下文**,对客户端**新增两个可选字段**,向后兼容;design/22 批次 2 后端 = §2 裁决规则 + §4.4 + §7-3/§7-4/§7-5):① **`PUT /api/v1/admin/channels/{platform}/config` body 新增可选 `personaId`**(渠道级绑定,design/22 §2 方案 b):=该账号答话时的角色 id;**未知角色 `400 bad_request`**,~~**省略/`""` = 解绑**(写盘时该账号不带 `personaId` 键)~~(**本条的「省略」一项已由 v0.45 修订为「保留已存绑定」;只有显式 `""` 才解绑**,见上);沿用既有「读当前配置 → 写入该平台账号 → 落盘 `channels.json`(0600) → `bridge.Reload(platform)` 热更新」路径(**写入路径自 v0.54 起改为按 id 打补丁 `PatchChannel`**),保存即生效(下一批入站消息),**无新端点**。② **`GET /api/v1/admin/channels` 每账号新增可选 `personaId`**(非敏感,不掩码),供面板回填。③ **角色解析优先级明确化(对客户端语义澄清,无形状变更)**:**会话绑定 > 渠道绑定 > 全局默认角色**;渠道绑定**只**作用于该账号的**主人私聊**且仅当主会话自身未绑角色(§7-3:IM 主人私聊镜像随主会话角色);**群聊/陌生人隔离会话强制默认角色**(§7-4,更保守,仍只读)。④ **模型上下文新增角色归属标注(§7-5 的剩下一半)**:一回合上下文改为该会话**最近 20 条**持久化消息 + 本回合新消息(此前只带新消息本身),每条 assistant 历史消息按其说话角色出具 `[发言归属] 以下发言来自「<名字>」(<id>),不是你说的。` 独立 system 标注 + 一条 `[会话角色] …` 当前角色声明;**消息正文一字不改**(标注只走 system 消息,design/11 §10),**对客户端透明**(无新字段)。前端无需改动即可继续工作;面板可选用 ① 让每个 IM 账号挂角色。⑤ **向后兼容**:纯新增可选字段;未配置 `personaId` 的账号与不读新字段的客户端行为不变(渠道绑定缺省 = 全局默认角色,与 v0.43 完全一致)。
- `v0.43`(**人格读侧合并批:admin 回读 `thinking` + 用户侧角色级 `mood`**,对客户端**新增两个可选字段**,向后兼容;design/01 §2/§2.1、design/04 §3.2):① **`GET /api/v1/admin/personas` 每项新增可选 `thinking`**(`{budgetTokens,effort,showToUser,mode}`),与写侧同构、供面板回填编辑。**未配置的人格不带 `thinking` 键**(不是全零对象),与 `PUT` 的「省略 `thinking` = 保留已存」互为镜像。② **`showToUser` 三态原样回读**:**`null` = 未配置 → 保持现行为(thinking 帧照常推客户端)**,`true`/`false` = 显式开关;**服务端不把 `null` 拍平成 `false`**(否则面板会把「没配过」误显示为「关」,v0.41 的语义会被读侧丢掉)。③ **`GET /api/v1/personas` 每项新增 `mood`(角色级)**:= 该角色**当前情绪状态**(取值同 `GET /meta/enums` 的 `moods`;`""` = 暂无被跟踪情绪,不伪造)。**情绪跟随角色、与会话无关**——同一角色的所有会话看到同一 `mood`;客户端登录/加载角色目录时用它**播种心情徽标**(按角色 id 分桶),消除「刷新后徽标短暂为空」,随后仍以 WS `mood` 帧为准。**用户侧不暴露 `thinking`**(模型调用旋钮属管理侧)。④ **`effort` 的能力提示归服务端**:读端点只回读**已存配置**,**不承诺**该值在当前 provider 上生效——白名单校验/降级/WARN 仍在模型层(design/04 §3.2),客户端不得把「配了」呈现为「已启用」。⑤ **向后兼容**:纯新增字段;不读新字段的客户端行为逐字节不变(旧面板仍可编辑并保留已存 `thinking`)。
- `v0.42`(**多角色并存 + 按会话选角**,对客户端**新增可选字段/可选请求字段**,向后兼容;design/22 批次 1 = 后端会话级绑定):① **`chat_sessions` 新增 `persona_id TEXT NOT NULL DEFAULT ''`、`chat_messages` 新增 `persona_id TEXT NOT NULL DEFAULT ''`**(同一迁移 **0011**,down 可逆,**不回填**)。`''` **= 继承全局默认角色**(`owner.yaml:defaultPersona`),所以**既有一切行升级后行为逐字节不变**,且默认角色改名后这些行跟着变(design/22 §4.7/§7-6)。② **`GET /api/v1/sessions` 每项新增可选 `personaId`**(=会话绑定角色);**消息 DTO(REST `GET /sessions/{id}/messages`、WS `history` 回放、`message_persisted`/`message_edited` 广播)新增可选 `personaId`**(=**说话时的角色**,design/22 §7-5)。③ **`POST /api/v1/sessions` body 新增可选 `{personaId}`**(创建即绑定;未知角色 `400`;响应仅在非空时回显 `personaId`,故**省略时响应形状与 v0.41 完全一致**);~~**`PATCH /api/v1/sessions/{id}` body 新增可选 `{personaId}`**(重新绑定,**显式 `""` 解绑**,未知角色 `400`;只回显变更字段)~~(**本条的「PATCH 重新绑定/解绑」已由 v0.46 作废**:会话角色在创建时确定、之后不可变,PATCH 一旦收到 `personaId` 即回 `400`,见 §1 行 8 / §8-1)。④ **回合语义**:角色决定**系统提示词 / 记忆命名空间 / 情绪与好感度 / 思考预算**四者**同源**解析(design/22 §4.2);~~换角色**保留上下文**、**不动已存消息**,**下一回合**起生效~~(**已由 v0.46 作废**:会话内不再换角色——角色在创建时定死,`chat_messages.persona_id` 仍逐条记录"谁说的");**记忆口径 = 保持 persona 隔离**(design/22 §3):不同角色的会话互不浮现,同角色的多个会话互相可见。⑤ **请求级覆盖(每次发送临时指定角色)本批未实现**(design/22 §2 裁决规则 1 仍待批次 2/后续);**渠道级绑定(IM 账号/群默认角色)本批未实现**(design/22 §4.4 批次 2),故 IM 路径仍按默认角色解析。⑥ **契约内新增内部读接口**:网关经窄接口类型断言读取 `history.Store.SessionPersona/SetSessionPersona/NewSubSessionFor`(**v0.46 起**请求路径不再调用 `SetSessionPersona`——PATCH 的 persona 写路径已整体移除;「注入的 HistoryStore **不支持**角色绑定」只在 `POST /sessions` **显式传** `personaId` 时回 `501`,而非假成功)。
- `v0.41`(**人格 `thinking` 配置 + 推理旋钮按 provider 映射**,对客户端**新增一个可选请求字段**,向后兼容):① `POST /admin/personas` / `PUT /admin/personas/{id}` body 新增可选 **`thinking`** 对象 `{budgetTokens?,effort?,showToUser?,mode?}`(design/01 §2.1),原样写入并持久化到 `configs/personas/<id>.yaml` 的 `thinking` 节;非法 `mode`/负预算回 **`400 bad_request`**;**`PUT` 省略该对象时保留已存 `thinking`**(旧面板/脚本不会抹掉它);该字段只描述**呈现/资源**,不构成能力边界。② **`showToUser` 语义**:**缺省 = 照常把 `thinking.*` 流帧推给客户端**(现行为),显式 `false` = 仅落库/剥离语义不变、**不推帧**(展示与落库解耦)。③ **服务端推理映射**(对客户端透明,仅影响上游请求):Anthropic 系在 persona 配置 `budgetTokens`/`mode: on` 时发 `thinking:{type:"enabled",budget_tokens:N}`,并**保证 `budget_tokens + 正文留量 ≤ max_tokens`**(不足则抬高 `max_tokens` 并记 WARN,绝不把正文吃空);OpenAI 兼容系按**识别到的厂商白名单**校验 `reasoning_effort`(OpenAI 系 `none…max`、DeepSeek 仅 `low/high/max`),非法值~~**降级到最近一档并记 WARN**~~(**已由 v0.53 修订为「丢弃并记 WARN、不降档」**——降档会反转调用方意图,`none` = 关思考,绝不翻成任何强度档);~~**未知 provider/未识别模型、以及 Qwen 的 `enable_thinking` 硬开关(协议无统一 wire 形状)** 一律记 **WARN(含 provider/字段/取值)并保持请求不变**,绝不静默无效~~(**本条已由 v0.53 作废**:开关形状改由组键 `reasoning.kind` 显式声明并**真下发**,**只有形状未知/有歧义时才 WARN 且一个字段都不发**,见 v0.53 变更记录)。详见 design/04 §3.2。
- `v0.40`(**`viewer` 角色真正接线:只读监控档**,对客户端**收紧/放宽若干 admin 读的鉴权**,无端点/字段形状变更):此前 `viewer` 只是常量(`internal/auth` 的 `RoleViewer` 注释写着「只读 dashboard/logs/monitoring」)而**从未接线**——管理侧 24 条 GET 全走 owner/admin,`viewer` 一条都读不到(**全 403**)。本版新增第四道门 `viewerAuth = owner+admin+viewer`,并把 **12 条只读监控 GET** 从 owner/admin 放宽到 owner/admin/**viewer**:`GET /admin/personas`、`/admin/sessions`、`/admin/plugins`、`/admin/plugins/installed`、`/admin/channels`、`/admin/iot`、`/admin/automations`、`/admin/rag`、`/admin/observability/metrics`、`/admin/observability/trace`、`/admin/observability/logs`、`/admin/notifications`。**明确不放给 viewer**(含凭据获取路径、他人数据、完整消息/导出明文、审计流水,或 `admin` 也不给):`GET /admin/memories`、`/admin/llm`、`/admin/users`、`/admin/backthink`、`/admin/observability/audit`、`/admin/channels/messages`、`/admin/channels/{platform}/messages`、`/admin/sessions/{id}/export`、`POST /admin/rag/query`(这 9 条 owner/admin)以及其余 **35 条 owner-only**(全部写操作 + `login-qr`/`login-status`/`view`/`friends`/渠道连接与配置/`plugins/*`/`users/*`/`system/*`)。**匿名**:任意 admin 路由仍 `401`(fail-closed)。**向后兼容**:`owner`/`admin` 语义不变(放行集合是超集),仅 `viewer` 的可见面从「全 403」变为 12 条只读监控;不依赖 `viewer` 的客户端行为不变。完整分档见 §2.0「管理侧权限矩阵」。
- `v0.39`(**微信 iLink 会话被拒可见化 + 不再假 OK**,对客户端**新增两个可选字段、一处错误码**,向后兼容):① **`GET /api/v1/admin/channels` 每平台行新增可选 `needsRescan`(bool,`omitempty`)+ `lastError`(string,`omitempty`)**——仅当适配器**真的**要求重新授权时出现(今天只有微信 iLink 返回 `errcode=-14` 这一种),取值来自适配器新增的**可选能力接口** `RescanRequired() (bool, string)`,网关**经类型断言**读取(主 `PlatformAdapter` 契约**不变**;未实现该能力的平台字段**缺省不出现**)。用途:把「需要重新扫码」与「主人自己关掉的未连接」区分开——后者仅 `connected:false`。② **`POST /admin/channels/open`** 对「iLink 已拒绝该会话」回 **`409 session_rejected`**(message 明确要求用「扫码登录」重新绑定),**不再**吞掉错误回 200;`409 needs_login`(尚无 token,v0.8 起)不变。③ **适配器行为**:iLink `errcode=-14` 进入**显式被拒态**——`IsConnected()` 恒 `false`;`Connect()` 返回被拒错误(**不再**对被拒会话报成功);`HealthCheck` 区分「会话被拒」与「未连接」;并以 **`ERROR`** 级日志写明「本次会话已被 iLink 拒绝,需要重新扫码绑定」(只带 `bot_id` 等非敏感标识,**token 永不入日志**)。被拒态在读循环与发送两条路径**都会**置位,并仅在**扫码登录拿到新 token** 或**配置换了新 token**(保存 + 热重载)时清除。④ 扫码登录侧:被拒期间 `login-status` 报 **`failed` + 被拒原因**(**不再**假报 `confirmed`,否则面板会把已作废凭据当成功重新落盘);`login-qr` 仍可正常出码(**不**回 `already_logged_in`,否则重新扫码这条路被堵死);`get_bot_qrcode` 不再携带已被拒的 token(否则服务端以「已绑定」应答、**不**发新凭据)。**向后兼容**:只新增可选字段与一处更诚实的错误码;不读新字段、不处理 409 的客户端行为不变(此时面板显示「未连接」,仍可用「扫码登录」恢复)。**语义澄清(本次仅措辞,不改契约形状、不另起版本号——v0.39 尚未被任何客户端采用)**:iLink 的 bot token 是**长期凭据**(实测一个月前扫码得到的 token 仍可用),`errcode=-14` 表示 **iLink 侧拒绝/作废了该会话凭据**,**不是时间性过期**。详见 §2 行 6 与「微信 iLink『会话被拒』契约」。
- `v0.38`(**B4 客户端共享枚举权威表 + 未知值回落约定**,对客户端**新增一个只读 REST 端点**):新增 **`GET /api/v1/meta/enums`**(**任意已认证角色可读**,与 `GET /sessions` 同级;无 token `401`)→ `200 {data:{platforms,moods,modes,unknown}}`,每项 `{id,label}`:① **`platforms`**(6)= `web`/`kook`/`qq-obv11`/`weixin-ilink`/`telegram`/`webhook`——即各内置适配器 `Name()` + Web 主会话来源 `web`;② **`moods`**(5)= `calm`/`happy`/`excited`/`sad`/`angry`,与 persona 情绪状态机内置状态一致(该状态机**可扩展**,故非封闭集合);③ **`modes`**(9)= `chat`/`code`/`home`/`work`/`docs`/`voice`/`vision`/`agents`/`settings`(design/07 §1);④ **`unknown`** 恒 `"display-raw"` = **未知值回落约定机器可读**:客户端遇不在表中的取值**原样显示原始字符串**(**不丢弃、不报错、不占位符**),**服务端新增取值视为非破坏性变更**(forward-compatible)。**单一事实来源在 Go 侧**(`internal/contract/enums`),并有**反漂移测试**断言适配器 `Name()` / 情绪内置状态 / 前端九模式与三张表一致,漏登记即 CI FAIL。Web 侧镜像 `frontend/web/src/lib/enums.ts`(`labelFor` 未知值返回原值),顶栏/侧栏/消息气泡的「心情」标签已改用它渲染。**向后兼容**:仅新增端点,既有契约形状不变。详见 §1 行 15 与 §1.1。
- `v0.37`(**D4 未读状态 / 已读游标**,对客户端**新增一个语义字段、一个 REST 端点、一个下行事件**):① `chat_sessions` 新增 **`read_at INTEGER NOT NULL DEFAULT 0`**(迁移 **0010**,down 可逆;**毫秒 Unix 时间戳**,与 `chat_messages.created_at` **同单位**)。② **语义**:**会话级单一已读游标 + 单 owner 账号**——同一账号所有端共用一个游标,**读一处 = 全端已读**(不做 per-device/per-user 游标,与「同一账号所有浏览器都属于同一 owner」一致);`createdAt <= readAt` 的消息算已读,未读数 = `createdAt > readAt` 的条数。③ **`GET /api/v1/sessions` 每项新增恒返回的 `unreadCount`**(整数,0 = 无未读):与 `lastMessage` **同一条 SQL 派生**(关联子查询 `COUNT(*) WHERE session_id = s.id AND created_at > s.read_at`,走已有 `idx_chat_msgs_session_created`,**无 N+1、不新增索引**)。④ **新增 `POST /api/v1/sessions/{id}/read`**(owner/admin,与 `PATCH /sessions/{id}` 同鉴权):body `{before?}`(可选毫秒;缺省= 现在)→ `200 {data:{sessionId,readAt}}`;`before` 为 0/负数/非数字/body 非对象 → `400`,未知会话(含隔离 `sess-xxx`)→ `404`,未注入 HistoryStore → `501`。**游标只前进不后退**(`read_at = MAX(read_at, before)`),乱序/并发/重试请求幂等安全。⑤ **新增下行事件 `session_read`**:`{type:"session_read", sessionId, data:{readAt}}`,全连接广播 + 客户端按 `sessionId` 过滤,用于同账号其它端清红点;失败路径不广播(§3.9)。⑥ **升级回填**:0010 把既有会话游标回填为「该会话现有最新消息时间」,**升级当天不误报未读**(既有历史全部视为已读)。⑦ `HistoryStore`(网关消费侧契约)新增 `MarkSessionRead(ctx, sessionID, before)`;`SessionMeta` 新增 `UnreadCount`。**向后兼容**:仅新增字段/端点/事件,既有契约形状不变;不消费 `unreadCount`/`session_read` 的客户端行为不变。详见 §1 行 4/8a 与 §3.9。
- `v0.36`(**A1 流式帧附加会话 / 回合标识**,对客户端**新增两个可选字段、无端点变更**):结构化流帧 `message.start/delta/end`、`thinking.start/delta/end`、`tool_call.start/delta/end`、`tool_execution.update` 现在带 `sessionId` + `turnId`(**向后兼容**:只新增帧字段,`data` 结构与既有事件形状不变)。`sessionId` 由 loop 在**回合边界**从运行上下文注入(模型适配器在 loop 之下、天然不感知会话,保持零值;loop 外无会话上下文时该字段为空);`turnId` 由网关**每回合**新生成(`newTurnID`)并经 `loop.WithTurnID` 挂进运行上下文,`runTurn` 是编排层与 legacy 直连路径**唯一**的回合收口点,两路都覆盖——同一会话同时只有一个 in-flight 回合,故同轮内所有帧的 `turnId` 一致。此前这几帧属于 design/16 §4.2 标注的「若知道就带上」缺口(v0.10 起记录):**多会话并发流式时前端会把别的会话的 delta 接进当前会话气泡**,现在客户端按 `sessionId === activeSessionId` 过滤即可路由到激活会话。`stream_chunk` / `stream_end` 的 `sessionId`/`turnId` 行为不变。详见 §3.1。
- `v0.35`(**可观测性收尾:后端日志 tail 端点**,对客户端**新增一个 admin 只读端点**):`GET /api/v1/admin/observability/logs?level=&limit=`(owner/admin)→ `200 {data:[{time,level,source,msg}]}`,**最新在前**。数据源为 logger 新增的**有界环形缓冲**(`internal/logger`,容量 2000,满则丢最旧、并发安全、不阻塞、不改动既有控制台/文件输出),组合根在构建 logger 时挂上、在网关就绪后经 `SetLogBuffer` 注入;缓冲未注入回 `501 not_implemented`(不伪造空 tail)。`time` 为 **RFC3339 UTC(秒精度)**,与 `trace.startedAt`/`audit.when` 一致;`level ∈ debug|info|warn|error`(大小写不敏感)为**最低等级过滤**,空 = 全部,未知值 `400 invalid_level`;`limit` 缺省 `200`、上限 `1000`。`source` 取 `logger.WithSource` 的 `source=`(`Scoped` 记录回落 `module/sub`),无来源为空串。详见 §2「可观测性契约」。
- `v0.34`(**D3 收尾:消息编辑 / 删除的 WS 实时广播**,对客户端**新增两个下行事件、无 REST 契约变更**):`PATCH /api/v1/messages/{id}` 成功后广播 **`message_edited`**(`{type, sessionId, data:<更新后的完整消息DTO>}`,`thinking` 已剥离)、`DELETE /api/v1/messages/{id}` 成功后广播 **`message_deleted`**(`{type, sessionId, data:{id}}`);两者复用 `message_persisted` 的同一 hub 广播路径(**全连接广播 + 客户端按 `sessionId` 过滤**,hub 无按会话订阅),客户端**按 `id` 原位改 `content` / 按 `id` 移除**,不再需要「改/删后重拉」的绕行。`message_deleted` 的 `sessionId` 由后端在删除前读取消息所属会话填入(`HistoryStore` 新增 `GetMessage`;读不到时客户端按全局唯一的 `id` 兜底)。`404`/`400`/`501` 等失败路径**不广播**。Web 端已消费(`lib/ws.ts` + `store/useAppStore` 新增 `updateMessageById`/`removeMessageById`)。详见 §3.8。
- `v0.33`(**A2 记忆异步提取 / A4 可观测性 Tracing 接线 / A6 通知页后端**):① **A6 新增 `GET /api/v1/admin/notifications`**(owner/admin 读,**全新端点**):把**现有源**(审计 `audit_logs` + backthink 主动推送记录)聚合为**统一只读通知 feed**,`type`/`before`/`limit` 过滤与游标见 §2「通知聚合契约」;**不新建表**,未注入的源该类型为空、不伪造;未读游标后置。② **A4 trace 端点由恒空变真实数据**(**无契约变更**,`GET /admin/observability/trace` 与 `metrics` 的 `llmCalls`/`subagentEvents` 从此反映 loop/工具/编排的真实 span):此前 `TraceSink` 从未接线、环形缓冲无人喂;现在组合根把 loop 产生的 span 扇出到网关观测缓冲。响应字段/形状不变。③ **A2 LLM 异步记忆提取接线**(**对客户端无变更**):loop 回合结束后改由 LLM 提取器**后台异步**抽取记忆(不阻塞回复、失败仅记日志),**群聊/陌生人仍只读不写**;无可用模型凭据时回落确定性 `ManualExtractor`(`记住 X` 路径不变)。
- `v0.32`(**A1 情绪系统接线:情绪/好感度真跑起来**):后端把既有的 `persona/emotion` 状态机接进 loop 回合边界——每轮结束按该轮用户消息的情绪信号移动**该角色自己**的情绪状态机(沿配置转移表走一步)并调整好感度(按角色 `affection.delta`);无信号轮次衰减一步趋向 `calm`。① **消息 `mood` 不再恒空**:assistant 消息落库带该轮情绪,`message_persisted` / `history` 回放 / `GET /sessions/{id}/messages` 的 `mood` 为真实值(user 消息不带)。② **新增下行 `mood` 事件**(`{data:{mood,personaId,affection}}`,**仅情绪变化时**广播,见 §3.7),前端顶栏「心情 · x」与消息气泡接真数据。③ **隔离**:群聊/陌生人(`MemWritable=false`)情绪只读不写——不改情绪/好感度、不发事件、该轮 `mood` 空。④ **持久性**:情绪/好感度为进程内按角色状态,重启回 `initialState`(未落库;design/01 未要求)。**触发词表与「每轮一步」为 design/01 未规定处的最小实现,待主人确认**。**契约新增(向后兼容):`mood` 事件 + 消息 `mood` 变真实值。**
- `v0.31`(**Android 协同批次 ③b:B5 剩余 501 + D1 推送出口钩子**):① **B5-知识库删除由 501 改为真实实现**:`POST /api/v1/admin/rag/delete` body `{"id"}` → `200 {data:{id,deleted:true}}`——从项目索引删除该文档条目并**级联删除全部章节**(单事务);不存在回 `404 not_found`,`id` 空回 `400`,未注入 store 回 `501`;路由由 admin 收紧为 **owner-only**(与其它 admin 写一致)+ 审计 `rag.delete`。② **B5-新增用户 / IoT 场景 / IoT 规则仍诚实 501**:无用户表(登录仅 `.env` 单 admin)、IoT 无场景/规则模型与存储(`iot.Provider` 只有设备列表/控制/状态),错误消息明确缺什么,**不伪造 store/假成功**。③ **D1 推送出口钩子**:主动消息在 WS/IM 投递之外异步镜像到外部推送出口,**自建统一推送服务优先 → 个推(Getui)兜底**(个推 SDK 未接入,仅预留 `GETUI_*` 字段,不可达时日志注明待接入);`.env` 新增 `PUSH_EXPORTER_ENABLED/URL/TOKEN/TIMEOUT_SEC`,token 只走 `Authorization` 头、不落日志;**对客户端无新端点**(详见 §3.6)。
- `v0.30`(**Android 协同批次 ③a:D2 消息搜索 / D3 消息编辑删除 / D5 会话置顶**):① **D2**:`GET /api/v1/sessions/{id}/messages` 新增可选 **`q`**——**会话内**内容搜索(大小写不敏感 ASCII LIKE,`%`/`_`/`\` 转义为字面量),仍倒序 + `before`/`limit` 游标;`q` 与 `after` 互斥(同传 `400`),空 `q` 走普通分页;**严格限定单会话,不新增跨会话搜索**(避免群聊/陌生人隔离会话外泄,红线)。`HistoryStore` 新增 `Search`。② **D3**:新增 `PATCH /api/v1/messages/{id}`(body `{content}`,非空 ≤4000 rune → `{data:{message}}`,只改 content)与 `DELETE /api/v1/messages/{id}`(→ `{data:{deleted:id}}`);不存在回 `404 not_found`;`HistoryStore` 新增 `UpdateMessage`/`DeleteMessage` + `ErrMessageNotFound`。**不新增重发端点**(客户端用编辑后的 content 再发)。③ **D5**:`chat_sessions` 新增 `pinned INTEGER NOT NULL DEFAULT 0`(迁移 **0009**,down 可逆);`GET /sessions` 排序改为 **`pinned DESC` → 主会话优先 → `createdAt ASC`**,会话 DTO 新增恒返回的 **`pinned`** bool;`PATCH /sessions/{id}` 扩展为 body `{title?,pinned?}`(至少一个字段,只回显变更字段,title-only 保持 A4 形状);`HistoryStore` 新增 `SetPinned`。全部为 owner/admin 写、读消息 owner/admin;未注入 HistoryStore 回 `501`。
- `v0.29`(**/admin/llm 读-改-写合并**):`POST /api/v1/admin/llm` 由「全量替换」改为**部分编辑合并**——请求体字段用指针区分「未给」与「显式零值」;`maxTokens` / `apiKey` / `provider` / `baseUrl` / `model` / `fallback` / `multimodal` **省略即保留原值**(不再把省略的 `maxTokens` 重置为 0),新组仍要求 `baseUrl`+`model`、非法 provider / 负 maxTokens 仍 `400`。面板 LLM 页新增每组 `maxTokens` 展示/编辑 + 新增/编辑表单;面板插件页新增「已装插件列表」+「安装 MCP 插件」表单(kind/name/transport/endpoint|command+args/apiKey/env),api.ts 请求体带 `config`(修掉旧 body 400)。`runtimeconfig/models.go` 新增 `Find`。
- `v0.28`(**MCP 插件热安装/卸载**):`POST /api/v1/admin/plugins/install`(**由 501 改为真实实现**,owner-only + 审计)body `{kind:"mcp",name,config:{transport,endpoint?|command?,args?,apiKey?,env?}}` → `200 {data:{installed,name,kind,transport,toolCount}}`——真实连接 MCP server、发现并**热注册工具**进 capability registry(下一轮模型请求即注入),配置落盘 `plugins.json`(gitignore、0600);连接失败回 `502` 且**不落盘**;同名重复 = 刷新。`POST /api/v1/admin/plugins/uninstall` 扩展:MCP 插件走 `Unregister`(移除工具 + close + 删配置),普通能力保持原 `registry.Remove`。新增 `GET /api/v1/admin/plugins/installed`(owner/admin 读)列出已装插件(`apiKey`/敏感 `env` 掩码 + `live`/`toolCount`)。`mcp.MCPConfig` 新增 `APIKey`(http/sse 以 `Authorization: Bearer` 发送)与 `Env`(stdio 子进程环境);`plugins.json` 于启动时 `Restore` 重新安装。**契约版本 `v0.28`**。
- `v0.27`(**LLM token 预算可配置化**):`models.json` 模型组新增可选 **`maxTokens`**(`int`,缺省/`0` = 未设),作为该组主/备链的 token 预算;`GET /api/v1/admin/llm` 的 `AdminLlmProvider` 与 `POST /api/v1/admin/llm` body/回显同步该字段(`omitempty`,未设为 0 时不出现)。`.env` 新增 **`LLM_FALLBACK_MAX_TOKENS`**(`int`,默认 `8192`,`0` = 关闭兜底),只作用于**确有回退模型**时链末端的回退步。**优先级**:显式 `Request.MaxTokens`(审查/判定类小预算)> 生效组 `maxTokens` > `LLM_FALLBACK_MAX_TOKENS`(仅链末端、且组未设时)> 适配器默认(anthropic 8192)。单步链(无 fallback)不套用 env 兜底。`maxTokens` 为负回 `400 bad_request`。**契约版本 `v0.27`**。
- `v0.26`(**A5 消息正向增量**):`GET /api/v1/sessions/{id}/messages` 新增 **`after`** 查询参数(`<createdAt 毫秒>`,升序返回 `created_at > after` 的消息,默认 `limit=50`/上限 200)——解决「WS 重连只回放主会话最近 50 条、断线超 50 条永久丢」的缺口;`before` 现行为不变;**`after` 与 `before` 互斥**(同传 `400`);负数/非数字 `400`。`HistoryStore` 新增 `ListAfter`(走 `idx_chat_msgs_session_created`,`rowid` 兜底同毫秒并列)。**游标契约**:客户端用「本页最后一条 `createdAt`」作下一页 `after`;同毫秒消息 > limit 时纯 `createdAt` 游标会跨页重读(超罕见,客户端按 `id` 去重兜底,与现有绕行一致)。
- `v0.25`(**B2 refresh token**):`POST /api/v1/auth/refresh`(**新增**,`{refreshToken}` → 新 access + 新 refresh,默认单次使用旋转,并发重放 fail-closed `invalid_grant`)+ `POST /api/v1/auth/logout`(**新增**,撤单个或全设备)。`POST /api/v1/auth/login` 在 `{token,expiresAt,role}` 之外**新增 `refreshToken`**(不透明 256-bit 随机串,**仅 SHA-256 哈希落库**,`AUTH_REFRESH_TTL` 默认 `720h`,`AUTH_REFRESH_ROTATE_DISABLED` 关旋转)。新表 `refresh_tokens`(迁移 0008,id/user_id/role/token_hash UNIQUE/expires_at/revoked_at;含过期/轮换原子事务);`rotate-jwt` 成功即删除全部 refresh(返回 `refreshTokensRevoked`),`revoke-keys` 不动 refresh。**密码/secret 记录见 `internal/auth`;refresh 全文绝不落日志/审计。**
- `v0.24`(**B1 图片上传端点**):`POST /api/v1/upload`(**新增**,multipart `file`,owner/admin+审计 → `201 {data:{url,size,mime,name}}`)+ `GET /api/v1/files/{name}`(**新增**,同端口静态服务)。安全:`MaxBytesReader`(5MB)、`DetectContentType` 嗅探(不信客户端 Content-Type)、白名单 raster image(拒 SVG/HTML)、`crypto/rand` 128-bit 随机名(丢弃原始文件名)、非可执行上传目录(0700)、`nosniff`;路径穿越全形态拒绝(含编码 `%2f`/`%2e%2e`)。错误码 `400/413/415`(`internal/errors` 增 `ErrPayloadTooLarge`/`ErrUnsupportedMediaType`)。**设计取舍**:GET 不鉴权(能力 URL,`chat.image`/`<img>` 无法带 Bearer),POST 仍 owner/admin。`url` 为绝对地址(`X-Forwarded-Proto`/TLS + `r.Host`,因 `ParseImageRef` 只认 `http(s)://`)。`Deps.DataDir` 由 wire 注入(`resolveDataDir`)。
- `v0.23`(**C2 语音 silent 降级信号**):三处统一用 **`provider`** 只在「结果为空」时出现(非空/成功时才省略)——`POST /api/v1/audio/transcribe` 空文本响应体带 `{text:"", provider:"silent"}`;WS `voice_output` 空音频 payload 带 `provider`;`POST /api/v1/audio/speech` 空音频仍 `500 tts_empty` 并加 `X-Voice-Provider: silent` 响应头(CORS `Access-Control-Expose-Headers` 已放开)。`voice.Chain/Service` 新增 `STTWithProvider/TTSWithProvider`(返回真实档位 cloud/local/edge-tts/espeak/silent),网关用可选接口 `voiceProviderReporter` 透传,自定义实现回退 `silent`。**含 `X-Voice-Provider`(`apperr` 包装、`providerSignal`);`DASHSCOPE_API_KEY` 无/降级时信号为 `silent`。**(`go test -race` 在本机受内核 TSAN VMA 限制失败,非本代码问题。)
- `v0.22`(**B3 会话列表 lastMessage 预览**):`GET /api/v1/sessions` 与 `/api/v1/admin/sessions` 每个会话新增可选 **`lastMessage`** `{content,role,createdAt}`(content 折叠空白截前 60 rune + `…`;空会话省略),**单条 SQL**(LEFT JOIN 取每会话 `ORDER BY created_at DESC,rowid DESC LIMIT 1`,无 N+1,走 `idx_chat_msgs_session_created` 覆盖索引)。`unreadCount` 需已读游标设计,后置。
- `v0.21`(**A4 会话 title**):`PATCH /api/v1/sessions/{id}`(**新增**,body `{title}` ≤80 rune → `{data:{id,title}}`;空/超长 400、不存在 404、主会话可改名);会话**首条用户消息到达时自动命名**(`history.append` 前 `ensureTitle`:折叠空白取前 20 rune,纯空白回落 `主会话`/`次级会话`),仅首次命名、不覆盖。`HistoryStore` 新增 `RenameSession`;隔离会话(`sess-xxx`)不在 `chat_sessions` 有行、不自动命名(其本不在 `/sessions` 列表)。**边界**:前端 `createSubSession(title?)` 的 POST body 含 `title` 但后端暂忽略(会被首条消息自动命名覆盖),未处理。
- `v0.20`(**A3 消息 mode 落库**):`POST /api/v1/chat` 与 `ws.chat` 上行新增**可选 `mode`**(行为模式,design/01 §3 / design/16 §5.1)。后端把每条持久化消息的 `Mode` **真实写入**(IM 路径恒 `chat`;Web 路径经 ctx 透传本轮 `mode`,缺省回落 `chat`;恢复/续接路径沿用发起轮次的 `mode`),消息 DTO、`message_persisted`、`channel_persisted` 的 `mode` 不再恒空,供前端按模式渲染气泡(**仅 `chat` 解析 `<p>` 拆气泡**,其它模式原样整条)。
- `v0.19`(**百炼声音复刻 create_voice + TTS instruction 情绪驱动,主 TTS 用昔涟复刻音色**):`POST /api/v1/audio/voice-clone`(**新增**,owner-only + 审计,body `{url:"<公网参考音频URL>", prefix?}` → `{data:{voiceId}}`)——走后端 `voice.DashscopeCloner`(百炼 `voice-enrollment` create_voice,`POST {base}/services/audio/tts/customization`,`input{action:create_voice, target_model:VOICE_TTS_MODEL, prefix, url}`,**只收公网 URL 不收 base64**,返回 `output.voice_id`;**URL 不打日志不写审计**)。`POST /api/v1/audio/speech` 新增**可选 `instruction`**(per-request 情绪/语气指令,如 `"语气开朗俏皮,像女朋友撒娇"`),经 ctx 透传给 TTS adapter;**只对复刻/设计音色生效**(`cosyvoice-v3-flash` 复刻音色可任意指令;系统音色 `longxiaochun_v3` 需固定格式,服务端 `canInstruct` 判定后**永不发送**)。config 新增 `VOICE_TTS_INSTRUCTION`(默认 `""`)、`VOICE_CLONE_PREFIX`(默认 `cyrene`,≤10 字母/数字);`voice.Chain`/`voice.Service` 新增 `CloneVoice`,`VoiceCloner` 接口(cloud rung 提供;未配 cloud 回 `voice_unavailable`)。**⚠️ 真建昔涟音色仍待主人提供公网参考音频 URL(当前无)**,且 CosyVoice 声音复刻仅华北2北京地域(确认 key 地域);TTS instruction 用现有复刻音色或先仅单测请求体,避免改系统音色行为。
- `v0.18`(**后端语音接通阿里百炼 STT + TTS 主链路**):`POST /api/v1/audio/transcribe`(**新增**,owner + 审计,body `{audio,format?,deviceId?}` → `{data:{text}}`,走 STT);`POST /api/v1/audio/speech`(**新增**,owner + 审计,body `{text,voice?,format?}` → 音频字节 `audio/<format>`,走 TTS)——**REST 语音源**,原生/移动端经它绕开 WS 4KB 帧限上传/获取音频;WS `voice_input` 上行新增 **`deviceId`**(随语音轮次落库,design/16 §4.5)。后端 `internal/runtime/multimodal/voice` 的 cloud rung 由空 endpoint stub 改为**真实百炼 adapter**:STT `qwen-audio-3.0-asr-flash`(base64 原始字节→文本)、TTS `cosyvoice-v3-flash`(按 `voice_id` 合成,取非流式返回的 24h 音频 URL 下载字节)。**实测**(`DASHSCOPE_API_KEY` 实跑):TTS「你好,我是昔涟」→ 106124 字节 wav;把该 wav 喂给 STT → 文本「你好,我是西联。」(TTS 发音把「昔涟」读得近似「西联」,STT 识别正确);语音链 (cloud→…→silent) 返回真实字节/文本。**已实测修正**:`cosyvoice-v3.5-plus`/`longxiaochun`(v2 音色)走 HTTP `SpeechSynthesizer` 会回 `418`(引擎拒绝),改用 **`cosyvoice-v3-flash` + `longxiaochun_v3`**(v3 音色,各模型音色不可混用)。**声音复刻(响昔涟音色)仍待**:需参考音频(公网 URL)在百炼建自定义音色后把 `voice_id` 填入 `VOICE_TTS_VOICE_ID`(本项目不做 create_voice)。config 新增 `DASHSCOPE_BASE_URL`/`VOICE_STT_\*`/`VOICE_TTS_\*`(`VOICE_TTS_SAMPLE_RATE` 默认 24000)。
- `v0.17`(系统设置安全与存储:吊销全部 API key / 轮换 JWT 密钥 / 备份 / 恢复,design/11 §4 + design/10 §3 模块14):新增 `POST /api/v1/admin/system/{revoke-keys,rotate-jwt,backup,restore}`(均 **owner-only** + 审计)。`revoke-keys` 走 `auth.APIKey.RevokeAll()`(清空 key 表,返回 `{revoked:n}`);`rotate-jwt` 走 `auth.JWTService.RotateSecret`(≥32 位,**新密钥不回显/不落日志**,现有 token 立即失效);`backup` 用 SQLite `VACUUM INTO` 快照到 `<dataDir>/backup-<ts>.db`(含已提交 WAL 内容)返回 `{path,size}`;`restore` 先校验路径在 dataDir 内(**`filepath.Rel` 防路径穿越**,逃逸回 `400`)→ **先自动备份当前 DB** → 替换 DB 文件 → 返回 `{restored:true,backup,note:"需重启后生效"}`(运行进程持有 DB 句柄,热替换需重启可见)。gateway 新增 `Deps.DatabasePath`(composition root 传入实际打开的 SQLite 路径)。`编辑环境变量 / 重启服务 / 检查更新` 仍为后置,未实现、不伪造。
- `v0.17`(KOOK 适配器面板 + 每适配器专属页,design/17 §2):`GET /api/v1/admin/channels/{platform}/view`、`GET /api/v1/admin/channels/{platform}/friends?type=`、`POST /api/v1/admin/channels/{platform}/friend/handle`、`POST /api/v1/admin/channels/{platform}/message/test`(**新增**,均 owner-only + 审计,带可选 `?accountId=`,仅 `kook` 实现,其它平台 `501`);`GET /api/v1/admin/channels` 每条账号附 `capabilities`(面板按平台渲染专属卡片)。KOOK 面板:好友/申请、服务器/频道/成员、私信会话、发消息测试——经适配器 REST(`Authorization: Bot <token>`)拉真实数据,token 不打印不落日志,子项失败记入 `errors` 不伪造。
- `v0.16`(运行时配置体系:渠道适配器配置落盘 + 热更新,design/04 §3.1):`PUT /api/v1/admin/channels/{platform}/config`(**新增**,owner-only + 审计)body `{id?,token?,baseURL?,remoteURL?,listenAddr?,mode?,accessToken?,selfID?,config?,allowGroups?,denyGroups?,allowUsers?,denyUsers?,groupMsgPerMin?,msgPerMin?,maxMessages?}` → `201 {persisted:true,path,platform,accountId}`——读当前有效配置 → 写入该平台账号(**自 v0.54 起改为按 id 打补丁 `PatchChannel`**)→ 落盘 `channels.json`(本地、gitignore、0600)→ `bridge.Reload(platform)` **热替换运行中 adapter**(进行中消息用旧、新消息用新),无需重启;token 写入文件但**不回显/不落日志**。`DELETE /api/v1/admin/channels/{platform}/config?id=`(**新增**)删除账号并热更新。`GET /api/v1/admin/channels` 每条渠道附 `accounts`(每平台已配账号,token 脱敏掩码)。`.env`(`CHANNELS_JSON`/`CHANNEL_*_TOKEN`)退为兜底。
- `v0.15`(运行时配置体系:LLM 多厂商多模型独立组落盘 + 热更新,design/04 §3.1):`GET /api/v1/admin/llm` 返回 **models.json 的全部组**(`provider` 协议字段新增,index 0 = 生效组;APIKey 仍脱敏不回显);`POST /api/v1/admin/llm` 由「临时记录」改为**真实持久化 + 热更新**(body `{id?,provider,baseUrl,apiKey?,model,multimodal?,fallback?}` → `201 {provider,persisted:true,path}`,读-改-写 `models.json`、保存组提升为生效组并热重载运行中的 LLM stack,无需重启)。`.env` 退为兜底(无 `models.json` 时按 `LLM_PROVIDER`/`LLM_*`/`ANTHROPIC_*` 合成单组)。
- `v0.14`(IM 多会话隔离 + 第三方身份 + 黑白名单,design/16 §4.6):**`chat_messages` 增 `sender_name` 列**(迁移 0006,默认 `''`);消息 DTO(REST 消息、WS `history` 回放、WS `message_persisted`)均带 **`senderName`**(第三方发送者昵称,主人私聊为空 `omitempty`)。**第三方身份边界**:IM 群聊/陌生人会话在上下文构建时对第三方消息注入 `[第三方 · <昵称>]` 前缀(**不改源消息正文**,落库保留原文)+ 前置系统提示词"每个 IM 的非管理员账户 = 第三方人员,和主人无关"——身份判定基于 channel 层 actor 元数据 + 白名单(`MemWritable=false` 即群聊/陌生人),**绝不靠 LLM 猜**。**会话隔离**:群聊/陌生人消息**落独立隔离会话**(`sess-xxx`,带 `platform`+`senderName`,**不**并入主会话 `web`、**不**走 `message_persisted` 广播),供 **`GET /api/v1/admin/channels/{platform}/messages`**(owner/admin,management 面板渠道日志区)以消息流显示;**前端主会话**只显示各平台管理员(owner)私聊;群聊/陌生人记忆只读不写主会话(`memory_gating` 已按 `MemWritable` 门控)。**多账号**:`CHANNELS_JSON` 支持同一平台多个接入配置(每个 account 独立 session/账号,见 §2 渠道契约)。**黑白名单**:每平台每账号可配 `allowGroups/denyGroups/allowUsers/denyUsers`,channelbridge **入站即拦截**(黑名单/不在白名单 → 丢弃,`audit` 可查,**不进 LLM**);白名单按 `platform+account`(群 id / 用户 uid)。
- `v0.13`(每条消息带 deviceID/client 标识,design/16 §4.5):**`chat_messages` 增 `device_id` 列**(迁移 0005,默认 `''`);`POST /api/v1/chat` 请求体新增**可选 `deviceId`**(客户端生成的端标识,如 `web:chrome:a1b2c3`);消息 DTO(REST 消息、WS `history` 回放、WS `message_persisted`)均带 **`deviceId`**。`platform`(平台)与 `deviceId`(具体端)互补:同一平台多个端 → 相同 `platform`、不同 `deviceId`。**Web 端**由 `lib/client.ts` 用 `localStorage` 持久化每个浏览器一个的 `web:<browser>:<随机短id>`,发送时自动带上,聊天气泡默认小字标注来源(仅当 `deviceId` 存在才显示);**IM 端**按 `<im平台名>:<本地配置唯一id>:<用户平台唯一uid>` 构造(当前 `UnifiedMessage` 尚无 deviceId 概念,channelbridge 以 `<platform>:<channelID>:<senderID>` 占位,`<本地配置唯一id>` 待适配器提供后替换);Web 端聊天不回流 IM。
- `v0.12`(IM 渠道并入主会话 + 平台标记,design/16 §4.4):**`chat_messages` 增 `platform` 列**(迁移 0004,默认 `'web'`);`GET /api/v1/sessions/{id}/messages` 支持可选 **`?platform=`** 过滤(Web 端不传=全量共享主会话;IM 适配器传本平台只拉本渠道);消息 DTO(REST 与 WS `message_persisted`)均带 `platform`。**IM 渠道主人私聊**(`!IsGroup && Owner`)经 channelbridge 落**主会话 `web`**(`platform=<渠道>`)并广播 `message_persisted` → Web 端免刷新镜像;**群聊/陌生人**保持独立隔离上下文、不落主会话。Web 端聊天(`platform=web`)不回流 IM。
- `v0.11`(跨端实时同步,design/16 §4.2):WS 下行新增 **`message_persisted`** 事件——每条 user/assistant 消息落库后广播 `{type, sessionId, data:{message:{id,sessionId,role,content,mode?,mood?,image?,createdAt}}}`(`id` 为后端生成,`thinking` 已剥离)。客户端按 `sessionId === activeSessionId` 过滤 + 按 `id` upsert(含本端乐观气泡 `uid`→后端 `id` 归一)去重,实现「A 发消息、A 与 B 都无需刷新且各出现一次」。后端 `history.Store` 新增 `AppendFull` 供编排层取含 `id` 的落库消息。
- `v0.10`(会话消息持久化 — WS 契约,design/16,后端-2):WS 下行帧新增可选 `sessionId` 字段(能确定会话的事件带上);新增 **`history` 回放事件**——WS 握手成功即回放主会话最新 50 条(`{type:"history", sessionId:"web", data:{messages:[...]}}`,`thinking` 已剥离)。`stream_end`/`stream_chunk`/会话编排状态帧(`session_busy` 等)在能确定 session 时带 `sessionId`;`message.delta` 等结构化流帧当时因 `model.StreamEvent` 无 session 上下文**不带**(即"若知道就带上"边界)——**该缺口已在 v0.36 补齐**(结构化流帧现在带 `sessionId` + `turnId`,见 §3.1)。
- `v0.9`(会话消息持久化 + 主/次级会话,design/16,后端-1):新增 `POST /api/v1/sessions`(新建次级会话)、`GET /api/v1/sessions/{id}/messages?limit=&before=`(游标热分页拉历史,时间倒序)、`DELETE /api/v1/sessions/{id}`(删次级会话及其消息);`GET /api/v1/sessions` 在注入 `HistoryStore` 后改返回持久化会话元数据数组(主 + 次级,`type` 区分),未注入时保持原渠道会话列表向后兼容。读消息/写会话为 owner/admin(用户侧私有历史,thinking 不达 viewer);会话元数据列表保持任意认证角色可读。
- `v0.8`(管理面板写操作 CRUD):补齐管理面板**写端点**(见 §2「管理面板写操作契约」,均 owner-only + 审计)——`POST/PUT /admin/personas`(真实注册 + 持久化 `configs/personas/<id>.yaml`)、`POST /admin/users/apikey` + `DELETE /admin/users/apikey/{id}`(真实签发/撤销)、`POST /admin/iot/{id}/control`(真实设备控制)、`POST /admin/automations` + `/run` + `DELETE`(真实注册/触发/移除规则)、`POST /admin/llm`(持久化 `models.json` + 热更新,v0.15 起)+ `/admin/llm/test`(真实模型调用)、`POST /admin/channels/{open,close,test}`(真实连接/断开 + 结构性身份识别)、`POST /admin/plugins/uninstall`(真实 registry 移除)、`POST /admin/memories/search`(真实检索)、`GET /admin/sessions/{id}/export`。无存储的写(新增用户 / IoT 场景·规则 / 插件安装)如实返回 `501 not_implemented`。
- `v0.7`(会话消息编排 design/15):`POST /api/v1/chat` 非流式且会话「正忙」时改回 `202 {data:{queued:true,queueDepth}}`(原同步 `{data:{reply}}` 仍为空闲时返回);pending 满回 `409 chat_busy`。WS 上行新增 `interrupt`(`{sessionId}`,中断当前任务+清 pending);WS 下行新增会话编排状态事件 `session_busy` / `pending_ack` / `pending_drain` / `session_idle` / `session_interrupted` / `pending_full`(见 §3.2)。既有 `stream_chunk`/`stream_end` 与结构化流事件不变,契约向后兼容。
- `v0.6`(可观测性):新增 `GET /api/v1/admin/observability/{metrics,trace,audit}`(owner/admin)——metrics 返回网关实时请求计数/QPS/延迟/方法·路径·状态分布 + LLM/子代理 span 计数;trace 返回 gateway 环形缓冲里的最近 span(经 TraceSink 对接 runtime tracer,映射为 `AdminTraceSpan`);audit 返回持久化审计(SQLite `audit_logs`,未注入时退化为内存)。`llmCalls`/`subagentEvents` 由实际观测 span 累计;Token/成本后端不统计、不返回(不伪造指标)。
- `v0.5`(批次B·视觉真推理 + 语音端):`POST /api/v1/chat` 增 `image` 字段(base64 data: URI 或 URL),入 loop 视觉路由(vision/OCR 工具或主模型多模态,design/06 §0「先看图再对话」);WS 上行 `voice_input` 接 STT→聊天→TTS 管道,下行新增 `voice_output`(定向回请求连接,含合成音频 + 识别文本 + 回复文本)。
- `v0.4`(结构化流):WS 下行新增 `message.*` / `thinking.*` / `tool_call.*` / `tool_execution.update` 结构化流事件(event shell 补 `turnId`/`seq`,`data` 类型化),对齐 OpenClaw AssistantMessageEvent;保留 `stream_chunk`/`stream_end` 向后兼容。
- `v0.3`(M10):管理面板 `/admin` 落地——按 §2 契约封装 admin 只读端点(personas/sessions/memories/plugins/channels/iot)+ owner-only `plugins/policy` 写;其余 M10 端点以契约路径封装、后端未实现时前端显式"待实现"壳。(**注**:面板**前端**入口自 v0.55 起为**路径式安全入口**,前缀由 `ADMIN_PATH` 决定、默认即 `/admin`;本节其余关于面板的叙述都不含前端路由前缀语义。)
- `v0.2`(M8):gateway 落地——登录/health/chat/sessions/personas + admin 只读端点 + `plugins/policy` 写 + WS `/ws/chat`(JWT + bus 广播)。
- `v0.1`(契约先行):骨架 + 端点登记表。