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

` 拆气泡)。`clientMsgId`(v0.56,G4)为**客户端自带的幂等键**(可选):同一会话内同一个非空值只承认第一条,重复到达回 `200 {data:{duplicate:true,messageId,sessionId}}` 且**不落库、不起回合、不发新内容帧**;省略 = 与加入该字段前逐字相同(详见 §3.13)。**会话编排(design/15)**:非流式且该会话「正忙」时**不阻塞**,返回 `202` `{data:{queued:true,queueDepth}}`(稍后按上下文续接);待处理队列满时返回 `409` `{error:{code:"chat_busy"}}`。**`stream:true` 响应面(v0.56,G2,行为变更)**:受理成功后才回——空闲已起回合 = `200 {data:{stream:true}}`、正忙已续接排队 = `202 {data:{stream:true,queued:true,queueDepth}}`、队列满 = `409 {error:{code:"chat_busy"}}`、幂等键重复 = `200 {data:{duplicate:true,messageId,sessionId}}`;**未接 loop 改为 `500 {error:{code:"chat_unavailable"}}`**(旧文写 `200 {stream:true}`,现与同步路径同口径);仅当**未接编排器**才回退总线派发,其失败回 `503 {error:{code:"chat_dispatch_failed"}}`——**不再有「先回 200 再看有没有落地」**,故 `202`/`409` 从此**真的会出现**(修复前因提前 return 永不出现) | | 4 | `/api/v1/sessions` | GET | JWT | ✅ | `{data:[session...]}`。**会话持久化(design/16)**:注入 HistoryStore 后返回持久化会话元数据 `{data:[{id,type("main"|"sub"),title?,createdAt,pinned,unreadCount,lastMessage?,personaId?}]}`(**排序(D5):以代码为准 = `pinned DESC, (type='main') DESC, created_at ASC`**——`pinned=true` 最前,其次是未置顶的主会话,最后是未置顶子会话(按 `createdAt` 升序);⚠️ **置顶的次级会话会排在主会话之前**,客户端定位主会话请按 `id=="web"` / `type=="main"`,**不要假定 index 0 就是主会话**;次级 `web:`;`pinned` 恒返回 bool;**`unreadCount`(D4)恒返回整数**——该会话中 `createdAt >` 已读游标的消息条数(0 = 无未读),与 `lastMessage` **同一条 SQL 派生**、无 N+1;`lastMessage` 为**最后一条消息预览** `{content,role(user|assistant),createdAt}`,content 折叠空白截前 60 rune,空会话省略;**`personaId`(v0.42,可选)**=该会话绑定的角色 id(design/22 §2/§4.5),**空/缺省 = 继承全局默认角色**(`owner.yaml:defaultPersona`),客户端据此渲染激活会话的角色名/头像);未注入时退化回渠道会话列表 | | 5 | `/api/v1/sessions` | POST | JWT(owner/admin) | ✅ | **新建次级会话** → `201 {data:{sessionId,personaId?}}`(`sessionId="web:"`)。body **可选**(可完全省略或空 body):`{personaId?}`(v0.42)在创建时就把新会话绑到该角色(design/22 §2:渠道级只作"新会话的初始角色提供者";**会话角色只在创建时写入、之后不可变**,见 §1 行 8 / design/22 §8-1);`personaId` 为**未知角色**回 `400 bad_request`;省略/空 = 不绑(`persona_id=''`,继承全局默认);**`personaId` 非空时响应回显它**(省略时响应形状与 v0.41 完全一致);未注入 HistoryStore 回 `501 not_implemented`;注入了**不支持角色绑定**的 HistoryStore 且显式传 `personaId` 时也回 `501`(不假装写入) | | 6 | `/api/v1/sessions/{id}/messages` | GET | JWT(owner/admin) | ✅ | **热分页拉历史**:`?limit=&before=&platform=&after=`(A5)默认 `limit=50`(上限 200)。**`before`**=上一页最旧 `createdAt`(毫秒),按时间**倒序**;**`after`**=<`createdAt` 毫秒>,按时间**升序**返回 `created_at > after` 的消息(**正向增量**,客户端把上次看到的 `createdAt` 当游标补断线期间漏的,解决重连只回放 50 条的缺口);**`after` 与 `before` 互斥**,同传回 `400`;负数/非数字回 `400`。`platform` 为**可选**来源平台过滤(design/16 §4.4)——Web 端不传(=全量共享主会话),IM 适配器传**本平台**(如 `telegram`)只拉本渠道消息;`deviceId` 为**客户端具体端标识**;`senderName` 为**第三方发送者昵称**;**`personaId`(v0.42,可选)**=**该消息说话时的角色 id**(design/22 §7-5:逐条记录"谁说的"——会话角色在创建时固定,§8-1;assistant 消息 = 生成它的角色,user 消息 = 当回合角色;**空 = 全局默认角色**,即 v0.42 之前的全部历史消息),客户端据此按角色渲染名字/头像;**`clientMsgId`(v0.56,可选,G4)**=该消息的**客户端幂等键**(带 `clientMsgId` 发的 user 消息会回填它;空则省略),客户端据此把本地乐观气泡对齐到服务端真相;**`q`**(D2)=**会话内内容搜索**(大小写不敏感 ASCII LIKE,`%`/`_`/`\` 已转义为字面量),仍按时间**倒序**、`before`/`limit` 同分页语义;`q` 与 `after` **互斥**(同传 `400`),空 `q` 视为未传(走普通分页),搜索**严格限定在 `{id}` 会话内**(绝不跨会话,群聊/陌生人隔离会话不会外泄);非法 `limit`/`before`/`after` 回 `400`;未注入 HistoryStore 回 `501` | | 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:}}`;不存在回 `404 not_found`;未注入 HistoryStore 回 `501`。**成功后广播下行 `message_deleted`**(§3.8,账号其它端按 `id` 移除) | | 7 | `/api/v1/sessions/{id}` | DELETE | JWT(owner/admin) | ✅ | **删除次级会话及其消息** → `{data:{deleted:}}`;主会话不可删回 `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:"",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/`,非 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` 为**同源访问地址**(`/api/v1/files/`,可直接作 `chat.image`)。校验:`MaxBytesReader` 防超大 → `DetectContentType` 嗅探(**不信客户端 Content-Type**)→ 白名单 png/jpeg/gif/webp/bmp(**拒 SVG/HTML**);文件名用 **crypto/rand 128-bit 随机名**(原始文件名丢弃),存 `/uploads/`(0700)。错误码:缺文件 `400 bad_request`、超限 `413 payload_too_large`、非白名单 `415 unsupported_media_type`、未授权 `401/403` | | 14 | `/api/v1/files/{name}` | GET | 无需(能力URL) | ✅ | **上传文件访问**(B1):从 `/uploads/` 读取,同名端口服务。`name` 仅接受「32 位小写 hex + 白名单扩展名」;**路径穿越全形态拒绝**(`..`/`%2f`/`%2e%2e` 等)。返回 `Content-Type`/`Content-Length`/`Cache-Control: private,max-age=86400,immutable`/`X-Content-Type-Options: nosniff`(支持 Range)。**设计取舍**:GET 不鉴权——该 URL 是**128-bit 随机能力地址**(`chat.image`/`` 无法带 Bearer),POST 仍 owner/admin+审计;任何持有该 URL 者都能取文件,勿外泄到他人 | | 15 | `/api/v1/meta/enums` | GET | JWT(任意角色) | ✅ | **共享枚举权威表**(B4):`{data:{platforms,moods,modes,unknown}}`,每项 `{id,label}`——`platform`/`mood`/`mode` 的**唯一事实来源**,客户端**不必硬编码**即可拿到稳定映射(来源角标 / 情绪标签不再漂移)。只读、无敏感信息,与 `GET /sessions` 同级(owner/admin/viewer 均可读;无 token 回 `401`)。详见 §1.1 | > **语音 REST 源(`/api/v1/audio/*`,均 owner-only + 审计)**:原生/移动端用它上传/获取音频,**绕开 WS 4KB 帧限**。 > - `POST /api/v1/audio/transcribe`:body `{audio:"", format?:"wav", deviceId?}` → `200 {data:{text}}`。`audio` 为 base64(或 data: URI);`format` 为编码(`wav`/`mp3`/`ogg`,缺省 `wav`);空 audio 回 `400`;未注入语音服务 `500 voice_unavailable`、识别失败 `500 stt_failed`。 > - `POST /api/v1/audio/speech`:body `{text, voice?, format?, instruction?}` → `200` 原始音频字节(`Content-Type: audio/`),**非 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 `,**任意已认证角色可读**(`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,客户端拿到的表不会滞后于代码。 > **契约版本 `v0.58`**(**新增 `.env` 键 `LLM_MULTIMODAL`(`bool`,默认 `false`):让 env-only 部署能声明「主模型自身能否收图」**,依据裁决 14.4.1 / design/06 §0(commit `4adfc9a`),2026-10-04 落实):**A. 新键语义(见 §2「模型配置契约」表)**——**`LLM_MULTIMODAL` 是 `models.json` 组 `multimodal` 的 env 等价物**,**只作用于 `.env` 合成组**(**无有效 `models.json` 时才生效**;有组文件时以该组的 `multimodal` 为准,与 `LLM_REASONING_KIND` 同口径):`true` = 图片**直进主模型上下文**(省掉工具那一轮模型调用、也不做有损文本摘要),**留空 / `false`(默认)= 把图交给 vision/OCR 工具换成文本描述**;**绝不凭 base URL / 模型名猜测**端点是否收图(声称端点不具备的能力,比把图交给工具更糟——工具至少回一段文本,design/06 §0 首条)。**B. 与 `LLM_VISION_MODEL` / `LLM_OCR_MODEL` 的关系**——后两者配的是**视觉/OCR 工具调用的模型**,**不代表主模型能收图**,与本键互不代表(见 §1 行 3)。**C. 兼容性(默认值即旧行为)**——默认 `false` ⇒ **老部署行为逐字不变**(引入本键之前,env-only 部署没有任何键能声明该能力,图片恒走 vision 工具);**非 bool 值(如 `maybe`)在配置解码阶段直接报错、启动失败**(fail-fast,**不静默回退成 `false`**——静默的「配了当没配」正是本键要消灭的)。**`LLM_MULTIMODAL=true` 后图片走主模型直收,属部署者显式选择**(形状由部署者声明,后端不替它猜)。**D. 实现落点**——`internal/config/config.go`(`LLMConfig.Multimodal`,默认 `false`)+ `internal/config/runtimeconfig/models.go`(`GroupFromEnv` 把两 provider 分叉都透传进合成组)+ `internal/config/runtimeconfig/multimodal_env_test.go`(默认 false / 显式 true 抵达合成组 / 进程 env 端到端 / 组文件优先 四组测试)。**无端点、无帧、无字段形状变更**(纯新增 env 键)。**上一版 `v0.57`**:(**13.4.5 目标态 steer 落地登记:新下行帧 `steer_ack`**,依据 design/15 §2.0/§2.1/§2.4/§3.x/§5(裁决 13.4.5 目标态,commit `3d8822c`),2026-10-04 落实:**A. 新下行帧 `steer_ack`(见 §3.14)**——`{type:"steer_ack", sessionId, data:{messageId, atStep}}`:忙时会话里新到的用户消息**被在飞回合吃进同一回合**时下发(`messageId` = 那条用户消息的**权威 id**,`atStep` = 吃进它的**模型步序号,1 基**);语义=「**该消息已被本回合吃进(已从 pending 移除)、尚未被正面回答**」⇒ 客户端应把对应气泡渲染为「已读、未答」的**灰态**,直到该回合的 `message.*` / `turn.end` 到达(**不需要第二个确认帧**)。**角色门**:它**携带具体消息身份**(客户端能定位到某一条气泡,而不只是会话忙闲),因此**不登记**进 `frameReachesAll` 的 7 个状态机信号白名单,落 fail-closed 默认档——**只发 `owner` + `admin`**(`viewer` 收不到);经编排器状态通道**由 hub 直接广播、不经 bus**,帧壳带 `sessionId`、**不带** `turnId`/`seq`。**触发时机**:只在**回合内边界**(loop 每一步开头:上一次模型步与它那一批工具调用**都已结束**、下一次模型调用**尚未开始**,此刻没有工具在飞)产生,且**只有该边界真的匹配到 pending 里、且已落库的消息时才发**——**不是每个 step 都发**(没可注入消息的边界一帧都不发,这是常态)。**B. 转向的三条不变量(design/15 §2.0)**——**I1(同会话在途回合数恒为 1)**:转向只注入、不取消,**绝不新开第二个回合**(loop 侧结构性地没有任何「起新回合」的分支,注入只是把消息并进**当前回合**下一次模型调用的视图);**I2(消息恰好被处理一次)**:注入=已消费——被注入的消息立刻从该会话 pending **移除**(按 pending 序号认领),**绝不被第二个回合再答一遍**,去重键=后端 `message.id`,且候选必须**同时**满足「在该会话转录里(最近 20 条窗口内)」与「在本编排器的 pending 里」;**I3(转向不取消已发起的工具)**:注入点只在回合内边界,此刻没有任何工具在飞,正在执行的工具**永远跑完**。**C. 注入的范围(口径)**:注入**只影响本轮视图**(每一步边界重新并入),**不写回运行窗口** ⇒ 既不进该回合的 **checkpoint 快照**、也不进**后台记忆提取的输入面**;被注入的消息**本身早在到达时已落库**(pending 路径即时持久化),注入**不新增、也不改写任何行**。**D. 失败一律让路**:未接转录存储 / 读转录失败 / 该消息尚未落库 / 本回合世代已被打断或被更新的回合取代(认领 `pending` 失败)时,**不发 `steer_ack` 也不注入**——该消息**留在 pending**,照旧作为自己的回合被回答,**绝不静默消失**。**E.** 另含一处**链路图纯增量键**(注入时该轮图的 input 节点带 `props.steered:true`,图仍只有一条主干——不是被画成第二轮),以及**纯新增帧**的兼容性口径(老客户端按未知帧忽略即可)。**上一版 `v0.56`**:(**协同项 A2 / G1 / G2 / G3 / G4 / F3 落地登记:`interrupt` 支持 `scope` + REST `stream:true` 不再静默失败 + `chat.image` 上限 + `clientMsgId` 幂等 + 视觉链路 URL 取回与类型裁决 + 既有错误陈述更正**,依据 `Cyrene-For-Android/devdocs/02-前后端协同事项.md` 的 A2 / G1 / G2 / G3 / G4 / F3(commit `7512d59` 等,2026-10-04 落实):**A. 上行 `interrupt` 新增可选 `scope`(A2)**——`{sessionId}` → `{sessionId, scope?}`,取值域 `{"", "all", "current"}`(**省略 / 空串 / `"all"` 三者同义**):`all` = 取消当前任务 + 清空待处理队列(**逐字不变的现状语义**);**`"current"` = 只取消当前任务、保留待处理队列**(队列消息按编排器正常续跑规则继续执行、带上会话尾部上下文);**其余取值 → 只回发送者** `{type:"error",data:{code:"bad_request"}}`(**定向**:不广播、不打断任何会话,绝不静默按默认执行);命中空闲会话为 no-op。下行 `session_interrupted` 帧形状**零变化**(`{sessionId, mode, dropped}`),但 `dropped` 恒等于**真的被丢弃**的待处理条数——`scope:"current"` 时为 `0`,且 `mode` 是该会话当时的**忙碌通道**(`thinking|executing|subagent`,**不是 `idle`**);`scope:"all"` 时 `mode` 为 `idle`、`dropped` 为清空的 pending 条数。**B. REST `stream:true` 不再静默失败(G2,行为变更)**——该分支改为与 WS `chat` 帧**同一条直达编排器的路径**(design/15 §2.1「会话消息不得被 drop」),**响应面**:受理成功后才回 `200 {data:{stream:true}}` / 正忙已续接排队 `202 {data:{stream:true,queued:true,queueDepth:n}}` / 队列满 `409 {error:{code:"chat_busy"}}` / 幂等键重复 `200 {data:{duplicate:true,messageId,sessionId}}`;**未接 loop → `500 {error:{code:"chat_unavailable"}}`**(旧文写 `200 {stream:true}`,现与同步路径同口径);仅**未接编排器**才回退总线派发,其失败回 `503 {error:{code:"chat_dispatch_failed"}}`(此前 `Publish` 的错误被吞掉,且传的是**请求作用域** ctx)。**`202`/`409` 从此真的会出现**(修复前因提前 `return 200` 永不出现)。派发用的回合上下文与请求作用域脱钩(`context.WithoutCancel` + 30 分钟上限),回合不会在客户端刚收到 200 时被连带取消。**向后兼容**:`stream:false` 与既有同步响应形状一字未动;依赖「任何情况都拿到 200」的客户端必须按上表分支,别再假设 `stream:true` 恒 `200`。**C. `chat.image` 上限(G3)**——`POST /api/v1/chat` 的 `image` 新增硬上限:**解码后 5 MiB**(与 `POST /api/v1/upload` 同口径;base64 字符串上限 = `ceil(n/3)*4 + 64` 字符,覆盖 data: URI 前缀),超限 → `400 {error:{code:"image_too_large", message}}`(文案指路 `POST /api/v1/upload`),在**任何解码 / 落库 / 入模型之前**拦下(超大 base64 因此不进历史、也不会被重连回放重传)。**事实(勿误读为「WS 也有这条 400」)**:WS `chat` 帧**今天已被传输层关死**——WS 读上限 `wsReadLimit = 4096` 字节/帧,超限直接断连,**到不了**这条判定 ⇒ **WS 上发真实图片本就必须「上传 + URL」**。**D. `clientMsgId` 幂等(G4)**——上行**新增可选** `clientMsgId`(REST `chat` 体与 WS `chat` 帧都支持;**省略 = 老行为逐字不变,老客户端零影响**):同一会话内同一个非空值只承认第一条。**重复到达**:REST → `200 {data:{duplicate:true,messageId,sessionId}}`;WS → 新下行帧 **`chat_duplicate`**(`{type:"chat_duplicate", sessionId, data:{sessionId, clientMsgId, messageId}}`,见 §3.13);重复**不落库、不起回合、不发任何内容 / 流式新帧**。**判重由 SQLite 部分唯一索引原子裁决**(`(session_id, client_msg_id) WHERE client_msg_id != ''`),并发的两次重发只有一个赢家,不存在「先查再插」的竞态窗口。`clientMsgId` 另**回填进消息 DTO** ⇒ `message_persisted` / `channel_persisted` / 握手 `history` 回放与 `GET /sessions/{id}/messages` 等读接口可见(空则省略),客户端据此把本地乐观气泡对齐到服务端真相(替代「靠文本相等」的脆弱匹配)。**迁移**:新增 `0013_chat_client_msg_id`(up:加列 + 部分唯一索引 `idx_chat_messages_client_msg_id`;down:先删索引再删列;全新库由 `history.NewStore` 幂等前滚)。**E. 视觉链路事实修正(F3)**——`chat.image` 的 http(s) URL(含 `/api/v1/files/`)现在由**服务端自己取回并内联为 `data:` URI** 再交 provider,**不再原样转给上游**;同源 upload 产物优先走**本地文件读取**。**类型裁决**:**字节嗅探是唯一裁决权**(声明的 Content-Type / 扩展名只作参考且可被否决),嗅探为文本(HTML / PROXY 错误页 / SVG)或判不定且来源未声明 `image/*` → **明确报错**(`vision.ErrNotImage`),**绝不静默当 PNG**。**对客户端无端点 / 帧 / 字段变更**。**F. 修正既有错误陈述(G1)**——`Docs/api-reference.md` §6.2 帧壳表与 §6.6 曾声称结构化流帧只带 `seq`、没有轮次标识,**是错的**:**v0.36 起**结构化流帧(`message.*` / `thinking.*` / `tool_call.*`)就带 `sessionId` + `turnId`(`broadcastStream` 从 `model.StreamEvent` 取,loop 在回合边界注入),v0.50 起另带 `streamId`;已按实现更正(与本文档 v0.36 条目一致)。**G. 环境变量登记**——`.env.example` 新增 **`OWNER_IDENTITY_INJECTION`**(`owner_only` 默认 / `always` / `off`;**非法值回退 `owner_only` 并记 WARN**),并登记 **`CONTEXT_MAX_TOKENS`**(默认 `32768`;`0` / 负 = 关闭压缩)与 **`GUARDRAIL_KEYWORDS`**(逗号分隔;留空 = 不挂关键词护栏),三键见 §2「安全 / 上下文环境变量(v0.56)」。**无新端点、无帧形状破坏**(唯一新增帧 `chat_duplicate` 为**纯新增**;REST `stream:true` 的响应面属**行为变更**,兼容点已在 B 写明)。**上一版 `v0.55`**:(**阶段 7 落地项登记:路径式安全入口 + 数据目录 `platform` + OneBot v11 四端 + 可观测实时推送**,依据裁决 1.6 / 16.4.7 / 12.4.2(commit `a398dc9`)、1.4 / 12.4.1(commit `9253fef`)、05.4.3 + 16.4.3-WS(commit `0e1f163`),以及 13.4.5 面内三条(commit `74b12d8`)、MCP 官方 SDK(commit `a1083a3`),2026-10-04 落实):**A. 安全入口 `ADMIN_PATH`(裁决 1.6 / 16.4.7,前端路由面)**——① env 新增 **`ADMIN_PATH`**(默认 `admin`):面板**前端**路由前缀 = `/` + 值(`admin` → `/admin`,`xxx/admin` → `/xxx/admin`;**非法值回退 `/admin`**——容忍首尾斜杠与空白,拒绝 `.`/`..` 段与 `?`/`#`)。② HTTP 行为:`/` 与其深链返回**注入了 `` 与 `window.__CYRENE_ADMIN_ENTRY__={"prefix":"..."}` 的 index.html**(`Cache-Control: no-store`);**形如面板入口(路径里出现 `admin` 段)但前缀不符的路径 → `404`**(不回落用户端聊天壳、不泄露正确前缀);`/` 若恰好命中 `dist` 真实文件则仍走文件服务器。③ **API 不藏**:`/api/v1/*`、`/api/v1/admin/*`、`/ws/chat`、webhook 一律原样,**无新端点、无帧变更**。④ **口径**:用户端 HTML **永不携带该前缀串**(后端只在命中前缀时注入)⇒ 用户端既无管理入口链接、也解析不出面板路由(design/07 §0.2;前端路由改**路径式**:`frontend/web/src/admin/router.ts` 只读注入、无硬编兜底、移除 hash 入口)。**B. 数据目录(同 commit,裁决 12.4.2)**——`DATA_DIR` 新增可接受值 **`platform`**(大小写不敏感)→ 平台标准目录;默认 `./data` 与显式路径行为不变;**显式空值行为不变**(仍走原兜底)。**C. OneBot v11 四端(`9253fef`,裁决 1.4 / 12.4.1)**——① 配置面(channels.json 账号 `config` + 适配器 `Config()` 快照)新增 `transport`(`ws`|`http`,默认 `ws`,未知值回落 `ws`)/ `api_base_url` / `report_path`(默认 `/`);快照 camelCase 为 `transport`/`apiBaseURL`/`reportPath`;`remoteURL` 快照值 = `remote_url`、为空时回落 `api_base_url`(语义即「出站远端地址」),**既有键与值不变**。② env 兜底 `CHANNEL_QQ_OBV11_TRANSPORT`(默认 `ws`)/ `CHANNEL_QQ_OBV11_API_BASE_URL` / `CHANNEL_QQ_OBV11_REPORT_PATH`。③ 四端语义:`ws+client` Cyrene 拨号、收发同一 WS;`ws+server` OneBot 反连、收发同一 WS;`http+client` Cyrene→OneBot(`POST {api_base_url}/{action}`,纯出站);`http+server` OneBot→Cyrene(POST 到 `listen_addr`+`report_path`,纯入站,fail-closed 鉴权、10MiB 体上限)。④ 取舍:`mode` 决定**哪一半必需**(server 必需 `listen_addr`、client 必需 `api_base_url`),另一半给齐字段就一并启用 ⇒ 单个适配器即可「收上报 + 回消息」。⑤ 端口例外:`http+server` 用 `listen_addr`,属 `AGENTS.md` §9 已登记的**同一豁免**(design/13 §0 原文即写「WS server / HTTP server」);`listen_addr` 为空则报错、**绝不开端口**。**D. 可观测实时推送(`0e1f163`,裁决 05.4.3 + 16.4.3-WS)——新增一节 §3.12**:三个**下行新帧** `observability_log`(`{entries:[{time,level,source,msg}…]}`,最新在前,上限 200 条)/ `observability_trace`(`{spans:[…]}`,上限 100 条)/ `observability_metrics`(`AdminMetrics` 本体、扁平),均**纯新增**(既有帧名/形状零改动)、帧壳**不带** `sessionId`/`turnId`/`seq`;三者**不在** `frameReachesAll` 白名单 ⇒ fail-closed **只发 owner+admin**(viewer 继续走 REST 只读档);订阅 = owner/admin 凭据**读取** `GET /admin/observability/{logs,metrics,trace}` 任一个(该次读取照常返回完整引导数据),该凭据 WS 连接全断即退订并停循环(**不是轮询、无常驻 goroutine**);**REST 端点完全不变**,`?level=` 分级过滤改由**客户端本地**做(帧广播给所有 `owner`/`admin` 连接,订阅只决定循环跑不跑)。**E. 其余(各一句)**:① **13.4.5 面内三条**(`74b12d8`)——续接窗口 `Recent` 在同毫秒并列时改为取**最新** n 条且按插入序(**无 API 变化**,但客户端可观察到的「握手回放 / 续接上下文」顺序更正确);上行 `chat` 不再经**可丢弃**的 bus(**帧序列不变**);被**取消**回合的 checkpoint 不再顶掉下一条消息,新增内部常量 `loop.StopReasonCanceled`(`checkpoints.state.stop_reason` 可能出现该值,**不经 REST/WS 暴露**)。② **MCP**(`a1083a3`)——协议层换成**官方 Model Context Protocol Go SDK v1.8.0**,**REST/WS/env/数据目录零变更**(`MCP_SERVERS_JSON`、`MCP_*`、`transport` 枚举 `stdio|http|streamable|sse` 全不变)。**未做(诚实边界)**:DashScope 形状的 `auto` 推断、Qwen 的 `/think` 前导消息软开关、`get_group_list` 枚举、上游显式退订帧(当前退订只发生在订阅凭据的连接全断时)、审计 tab 仍 5s REST 轮询(未做 `observability_audit` 帧)。**上一版 `v0.54`**:(**渠道配置写路径改 patch 语义(修 P0 级凭据静默丢失) + 阶段 6 前端批的纯前端行为登记**,依据 design/04 §3.1、design/22 §2 方案 b(与 `personaId` 同构的裁决延续,commit `4ae7148`)与裁决 16.4.1 / 16.4.2 / 16.4.3 / 16.4.4 / 16.4.5 / 16.4.6(commit `f454273` / `b480b04` / `c1a98d7` / `8d70f6d`)):① **`PUT /api/v1/admin/channels/{platform}/config` 的 `config` 由「整条替换」改为「按 id 打补丁」**(`runtimeconfig.PatchChannel`,**与 `personaId` 同构的三态**)——**键省略(该键不在 `config` 里,或 `config` 整体缺席 / 为 `null`)= 保留该账号已存值**、**给出非空值 = 覆盖该键**、**显式 `null` 或 `""` = 清除该键**(`0` / `false` 是真实取值,**不算空**);顶层便捷字段(`token` / `baseURL` / …)是普通 `string`,**只在非空时**并入补丁 ⇒ **要清除 `bot_token` 必须走 `config` 的显式 `null` / `""`**;`allowGroups` / `denyGroups` / `allowUsers` / `denyUsers` 与三个限流键**仍是整值替换**(省略 = 清空)。② **破坏性提示**:按旧文实现、依赖「省略某 `config` 键 = 删掉它」的客户端,必须改为**显式传 `null` / `""`** 才能清除该键。③ **修掉的数据安全问题**:面板只发送用户本次碰过的平台字段,后端却整条替换 ⇒ 保存**微信(iLink)**账号卡会抹掉 `bot_token` / `bot_id` / `owner_id` / `get_updates_buf`(**iLink token 是长期凭据**)、**Telegram** 只填 `webhook_url` 而不重填 token 会让 `cleanAccounts` **丢掉整个账号**。④ **未改**:token 要求型平台**显式**清 token 仍按 `cleanAccounts` 丢账号;「本次一个 `config` 键都没给出」在**非 `weixin-ilink`** 平台仍回 `400`。⑤ **无新端点 / 新字段 / 响应形状变更**。**同版另含阶段 6 前端批(纯前端行为,无 API 变化)**:① **16.4.1 静默/活动态与预约切换**——唯一入口 `frontend/web/src/components/modeSwitch.ts`:她活动时用户切换只登记「已预约」、静默时落地;② **16.4.5 会话级消息缓存**——帧按自己的 `sessionId` 落进该会话的消息桶(切会话不清空),**只有单槽忙态**仍按激活会话判定;`graph_update` 按 `data.sessionId` 入**图桶**,管理端 Flow 页取激活会话、Backthink 页取 `sess-backthink`(裁决 16.4.2);③ **16.4.3 / 16.4.6 虚拟滚动与专属页只读卡**——长列表超阈值窗口化、专属页只读展示该平台完整字段;④ **16.4.4 双档 UI 模式**——性能档 = 现状(默认)、华丽档落 `:root.ui-mode-fancy`,持久化键 `cyrene.ui-mode`(纯前端,对应 `design/07 §0.1`,不改后端契约)。**上一版 `v0.53`**:(**阶段 5 落地:模型目录端点 + per-persona 路由 / 本地三级 / capacity L3 / 视觉·OCR 独立模型 / `image-crop` 工具 / 按 provider 形状下发推理开关**,依据 design/04 §3/§3.1/§3.2(裁决 03.4.1 / 03.4.2 / 03.4.3 / 08.4.1)、design/06 §0/§3(裁决 14.4.1 / 14.4.5)、design/14 §2.2/§3/§4(裁决 14.4.2 / 14.4.3 / 14.4.4),2026-10-04 落实):① **新增管理只读端点 `GET /api/v1/admin/llm/catalog`**(**owner|admin** 读,`?refresh=1` 强制在线刷新)→ `200 {data:{source:"cache"|"models.dev", fetchedAt?, stale?, warning?, providers:[{id,name,api?,doc?,env?,npm?,models:[{id,name,attachment,reasoning,toolCall,contextLimit?,outputLimit?}]}]}}`;**既无本地快照又抓不到** → `503 {error:{code:"catalog_unavailable", message}}`;客户端带 `Accept-Encoding: gzip` 时响应带 `Content-Encoding: gzip`(**信封形状不变**)。**副作用只有一处**:落盘快照 `/llm_catalog.json`(**不含任何凭据**、`data/` 已 gitignore、TTL 24h),**不改 `models.json`**;**抓取只发生在本端点内,不进运行时热路径**(模型请求链路从不读外网目录)。详见 §2「模型配置契约(v0.53)」。② **`models.json` 组级新增键**(载体是组,**可热更新**,未声明时该键不出现):`reasoning: {kind: "auto"|"deepseek"|"openai"|"vllm"|"dashscope"}` 决定该组用哪种**提供商开关形状**下发推理指令(**非法值被 `Group.Validate` 直接拒绝**;anthropic 组上声明 openai 系形状同样拒绝)、`personaModels: {personaId: modelId}` 与 `personaProviders: {personaId: "openai"|"anthropic"}` 为**角色级模型/提供商路由**(**非法 provider 值同样拒绝**;未绑定/未知角色回落该组默认)。③ **语义修正(重要)**:**`multimodal` = 主模型自身能否收图**;`LLM_VISION_MODEL` / `LLM_OCR_MODEL` **只配视觉/OCR 工具用的模型**——两者互不代表(见 §1 行 3)。④ **`.env` 新增 9 键**(见 §2「模型配置契约(v0.53)」表与 `.env.example`):`LLM_REASONING_KIND`(默认空 = `auto`)、`LLM_OCR_MODEL`、`LLM_LOCAL_ENABLED`(默认 `false`)/`LLM_LOCAL_PROVIDER`/`LLM_LOCAL_BASE_URL`/`LLM_LOCAL_API_KEY`/`LLM_LOCAL_MODEL`、`CAPACITY_L3_ENABLED`(默认 `false`)/`CAPACITY_L3_CACHE_TTL`(默认 `5m`)。⑤ **内置工具新增 `image-crop`**(按区域裁切图片:`x`/`y`/`width`/`height` → PNG data URI + 裁切尺寸;供大图分区精读;**策略默认 owner-only**)。⑥ **推理开关按形状下发(旧陈述修正)**:v0.41 的「Qwen 一类只 WARN、请求一字不改」**已作废**——形状由 `reasoning.kind` 声明后**真下发**(→ 字段名映射表见 changelog v0.53 条),**只有形状未知/有歧义时才 WARN 且一个字段都不发**;`reasoning_effort` 的非法值由「降级到最近一档」改为**丢弃并记 WARN(不降档)**,`effort:none` 一律翻成**显式关思考**。**未做**:DashScope 形状的 `auto` 推断(只能在显式声明 `reasoning.kind=dashscope` 时下发,绝不从模型名/BaseURL 猜)、Qwen 的 `/think` 前导消息软开关(与 design 口径冲突,未实现)。**上一版 `v0.52`**:(**工具策略落盘 + 主代理代审小字 + 流程图节点参数摘要**,依据 design/11 §3/§4/§8(裁决 1.3 / 10.4.2 / 13.4.3)与 design/19 §1/§1.1(裁决 11.4.2 / 11.4.3 / 13.4.1),2026-10-04 落实):① **工具策略(`allowInGroup`/`allowInStranger`)落盘 SQLite `tool_policies`(迁移 `0012_tool_policy`),重启保留**——`registry.SetPolicy` **先落库后改内存**,落盘失败则内存保持旧值并返回错误(见 §2 行 5);② 新增下行帧 **`approval_notice`**(§3.11):敏感操作经主代理代审后推一行小字(`explain` 由**后端产出**、客户端原样渲染),审计口径 `who`=角色名 / `what`=`approval.<工具名>` / `result`=`granted|denied`,**仅 `owner` + `admin`**(fail-closed 默认);③ `graph_update` 节点 `props` 新增四类**纯增量可选**键(tool `args_keys`/`args_summary`、guardrail `action`/`allow`/`elapsed_ms`、memory `action`/`scope`/`hits`/`bytes`/`count`/`failed`、orchestration `subagent`/`persona`),`memory`/`guardrail`/`orchestration` 旁路节点 span **首次真进图**,值在发布前已脱敏——**帧名、事件名、顶层字段集一字未动**(见 §3.5)。**上一版 `v0.51`**:(**后台子代理结果登 WS 帧 + 回填父会话**,依据 design/05 §9「子代理完成 → 结果回填主会话并提示用户」,裁决 02.4.3,2026-10-04 落实):新增下行帧 **`subagent_result`**(`{type, sessionId, data:{sessionId, status:"done", summary, agents:[{id, goal, role?, mode?, status, summary?, text?, steps?}]}}`,见 §3.10)——`subagent_spawn` 的批次转后台后结算时,编排层发布在 bus 主题 `subagent.result` 上的合并结果,此前**没有任何消费者**(中继既非 `ws.*` 也不认该 4 段以外的 subject)⇒ 结果上不了前台、只剩进度帧;现在该 subject 被登记为中继并成形为这一帧,**帧壳 `sessionId` = 派生该批次的父会话**,结果因此落回**父会话的实时流**。**角色门**:新帧属内容帧 → **只发 `owner` + `admin`**(fail-closed 默认,未扩 7 个状态机信号白名单)。**向后兼容**:纯新增帧,老客户端按未知帧忽略即可,既有帧/端点/鉴权一律不变。**诚实边界**:结果**不落库**(父会话转录里没有新消息、重连回放不补发)、**未**经 design/15 会话消息编排器续接、**前端尚无该帧 handler**(今天的效果是「帧到达 owner/admin 连接」)。**同版另含管理面(D 组,2026-10-04 落实)**:① **`X-API-Key` 真正接线**(裁决 15.4.2):管理/用户侧已认证端点同时接受该头,命中签发表即通过且**一律 `owner` 级**(裁决 15.4.3 取消 role 分级),带该头时只走 key 分支、未知/已吊销/空值回 `401` 且不回落 Bearer(fail-closed),WS 握手仍只认 Bearer;② **新增主人白名单档案读写** `GET`/`PUT /api/v1/admin/owner-profile`(裁决 10.4.5,**owner-only**,写带审计 `owner.profile.update`)——面板的「保存」从此是**真保存**(落盘 `configs/users/owner.yaml` 0600 原子写 + 热替换活档案,渠道 actor 判定立即生效),不再是只改本地 state 的假成功;③ **persona `identity.systemPrompt` 改必填**(裁决 08.4.2):缺失/空白回 `400 bad_request`,**不再派生默认说明串**,写出的 YAML 必能被载入期同一规则重新载入。见下方「管理面板写操作契约」。**上一版 `v0.50`**:(**事件批:流式帧的结构化补齐**,依据 design/04 §1.1,裁决 01.4.1 / 01.4.2 / 01.4.3 / 03.4.4,2026-10-04 落实):① **`tool_execution` 拆三事件**——旧单帧 `tool_execution.update {id,index,delta}`(phase 与工具名拼在 `delta` 字符串里)被 **`tool_execution.start`**(进入执行,`phase:"running"`)/ **`tool_execution.update`**(运行中进度/局部输出,可多帧)/ **`tool_execution.end`**(完成或失败,`phase:"done"|"error"`)替换,载荷改为结构化 `{tool, phase, id?}`(`tool` = 工具名;`phase` = 细粒度执行阶段,与 §3.5 流程图节点 status 同一套 `running/done/error` 枚举;`id` = provider 工具调用 id,可与 `tool_call.*` 帧关联)——**phase 不再塞进 `delta`**;loop 现已真实发出 start/end(update 的 wire 映射已实现并验证,但工具分发器尚无中途进度上报源,**暂无生产者**,见 §3.1)。② **`streamId`(模型流帧 `data` 内新增可选字段)**:同一次模型流(一次 ChatStream 调用)的多帧共享同一 `streamId`,多步工具轮的第二次模型调用取新 id;与帧壳的 `turnId`(整个回合恒定)分工——`turnId` 标记「这轮回答」,`streamId` 标记「这轮里的哪一条流式消息」。③ **`turn.start` / `turn.end`(新帧)**:loop 在回合边界发出、经 ws_bridge 下行;**无 `data` 载荷**(帧壳的 `sessionId`/`turnId`/`seq` 即边界信号;回合终态信息仍由既有 `stream_end` 携带);`turn.end` 覆盖回合的所有出口(最终答案 / 工具循环安全网终止 / 预算耗尽 / 步骤出错),被静音的内部流程(backthink / 子代理)不发出。④ **向后兼容**:`turn.start/end` 与 `streamId` 为纯新增,老客户端可安全忽略;**破坏性**在旧 `tool_execution.update` 单帧——loop 不再产生它,按旧形状(解析 `delta` 里的 `phase:工具名` 拼串)消费的客户端必须迁移到三事件;现有 Web 前端未消费这些帧,行为不变。⑤ 本批新帧全部落在 v0.48 下行帧门的「仅 `owner` + `admin`」档(fail-closed 默认,无需扩白名单)。⑥ **`graph_update` 会话范围口径修正**(裁决 01.4.5,随实现事实补记):`web` / `web:` / `sess-backthink` 三范围(见 §3.5)。见 §3.1 与 v0.50 变更记录。**上一版 `v0.49`**:(**WS 握手回放与上行帧的角色门(design/11 §7.2)**:补上 §7.1 下行门之外的**同一类缺口的另两处**,对 `viewer` **继续收紧**,`owner` / `admin` 行为不变,**无端点、无字段形状变更**——① **握手 `history` 回放改为仅 `owner` + `admin`**:此前任意角色一连上 `/ws/chat` 就会收到主会话最近 50 条消息正文(`thinking` 已剥离,但**正文本身**就是主人的对话),`viewer` 从此**不收**;② **上行 JSON 帧按角色授权(fail-closed)**:非 `owner`/`admin` 连接发来的**任何**上行 JSON 帧一律拒绝,**定向**(只回发送者)回 `{type:"error",data:{code:"forbidden"}}`,**不广播、不驱动模型、不落库、不返回任何内容**——被拒帧含 `chat`(此前 `viewer` 可**真的驱动模型并落库**)、`interrupt`、`subagent_ask` / `task_output_query`(其应答是**定向**帧、能绕过 §7.1 下行门把子代理上下文/任务输出直接回给 `viewer`)、`voice_input`、`mode_switch`,且**未登记的新上行帧默认拒**;**协议级帧(ping/pong、close)不受影响**。③ **合口径**:`viewer` = **可连接、可读 12 条只读监控 REST(v0.40)、可收 7 个状态机信号帧(v0.48)**;**除此以外 WS 上拿不到任何内容、也发不出任何指令**。见 §3 开头的「上行帧与握手回放的角色门」与 v0.49 变更记录。**上一版 `v0.48`**:(**WS 下行帧的角色门(design/11 §7.1)**:hub 的下行现在是**按帧类型分档**的——只有 7 个**状态机信号**(`session_busy` / `pending_ack` / `pending_drain` / `session_idle` / `session_interrupted` / `pending_full` / `session_read`,**均不含任何内容**)继续发给**所有已认证连接**(含 `viewer`),**其余全部下行帧**(流式内容 `message.*` / `thinking.*` / `tool_call.*` / `tool_execution.update` / `stream_chunk` / `stream_end` / `thinking`;持久化 `message_persisted` / `message_edited` / `message_deleted` / `channel_persisted`;系统内部 `graph_update` / `subagent_event` / `task_output` / `task_done` / `tool_used` / `capacity_awaken` / `mode_switch`;私有状态 `mood` / `device_update` / `reminder`)**改为仅发 `owner` + `admin`**)。**对 `viewer` 是收紧**:此前它连上 `/ws/chat` 就能收到角色回复正文与推理内容,现在收不到;**`owner` / `admin` 行为不变**;**无端点、无字段形状变更**——`viewer` 的 12 条只读监控 REST 端点(`viewerAuth`,v0.40)与 `/ws/chat` 的连接鉴权都**照旧**;**新帧类型默认只发 owner/admin**(fail-closed)。见 §3 开头的「下行帧的角色门」与 v0.48 变更记录。**上一版 `v0.47`**:(**`graph_update` 子代理泳道根节点新增可选 `props.persona`**:派发子代理时若指定了目标角色,该分支根节点的 `props` 就多一个 `persona` 键,标注它**正在扮演的角色**——用途是让流程图能显示「这个子代理是以谁的身份在替班」,而不只是子代理 id;**纯增量**:不带角色派发时该键不出现,老客户端忽略未知键即可,见 §3.5 与 v0.47 变更记录)。**上一版 `v0.46`**:(**`PATCH /api/v1/sessions/{id}` 的 `personaId` 由「可重新绑定该会话的角色」改为「出现即 `400`」**:**会话角色在创建时确定、之后不可变**,要换角色请新开会话(design/22 §8-1)——**明确拒绝**而非静默忽略/200 空操作;**键出现**(任何取值:`"yuki"` / `""` / `null` / 未知 id / 空白串)一律 `400 bad_request` 且消息点名 `personaId`,该检查**先于**会话存在性检查(未知会话 + `personaId` 也回 `400` 而不是 `404`)。**破坏性**:按 v0.42–v0.45 旧文实现、依赖「PATCH 换角色 / 显式 `""` 解绑」的客户端必须改为**新建会话**选角色;`title`/`pinned` 行为与 `POST /sessions {personaId}`(创建时绑定)**完全不变**,不再有 `501/200 {personaId}` 的 PATCH 路径)。上一版 `v0.45`:`PUT /admin/channels/{platform}/config` 的 `personaId` 由「省略/`""` 都 = 解绑」修订为三态(**省略(或 `null`)= 保留已存绑定**、**显式 `""` = 解绑**、**非空 id = 绑定**,未知角色仍 `400`)。 ## 2. REST 端点(管理,`/api/v1/admin/*`) > 仅供管理面板与授权客户端;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,全部 **59** 条管理路由无一例外 —— 12 条 viewer 档 + 10 条 owner/admin 档 + 37 条 owner-only 档)。 - **角色分档**(`✅` 放行,`❌` 回 `403 forbidden`;owner>admin>viewer 递增): | 端点组 | owner | admin | viewer | |------|:---:|:---:|:---:| | **只读监控档**(12 条 GET,见下①) | ✅ | ✅ | ✅ | | **owner/admin 只读档**(10 条,见下②) | ✅ | ✅ | ❌ | | **owner-only 写/凭据档**(37 条,见下③) | ✅ | ❌ | ❌ | ① **`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` 的 37 条**(`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/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)」 | | 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 内容)到 `/backup-.db` → `{path,size}` | | 23 | `/api/v1/admin/system/restore` | POST | ✅(写) | 恢复数据(owner-only,审计);body `{path}` → 校验路径在 dataDir 内→**先备份当前**→替换 DB 文件→`{restored:true,backup,note:"需重启后生效"}`(运行进程持有 DB 句柄,热替换需重启可见) | > **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`。 - **副作用(只有这一处)**:落盘快照 `/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`)。 | **管理面板写操作契约**(`POST/PUT/DELETE /api/v1/admin/*`,均 **owner-only**,读为 owner/admin;写操作经 audit 落 `audit_logs`;成功 `{data}` / 失败 `{error:{code,message}}`): - `POST /admin/personas` body `{id,name,role,systemPrompt,characterRef?,modes?/mode?,memoryNamespace?,thinking?}` → `201 {data:PersonaSummary}`(**v0.43 起与读端点同形状:含 `mood` + `thinking?`**;注册进 persona registry + 持久化到 `configs/personas/.yaml`);id 已存在回 `409 duplicate`。**`systemPrompt` 自 v0.51 起必填**(落的 YAML 键是 `identity.systemPrompt`):**缺失/空白 → `400 bad_request`**(消息 `"systemPrompt is required (identity.systemPrompt must not be empty)"`),**不再派生默认说明串**回落——载入期(`loader.Load`)执行同一规则,因此写出的 YAML 必能被重新载入。 - `PUT /admin/personas/{id}` body 同上(不含 id)→ `200 {data:PersonaSummary}`(**v0.43 起与读端点同形状:含 `mood` + `thinking?`**,面板可直接用它回填);不存在回 `404`。**`systemPrompt` 与创建同一把门**:v0.51 起同样必填,缺失/空白回 `400 bad_request`(同一条消息)——**不派生默认串、也不静默沿用旧值**,请求被拒即不落盘。 - `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?,reasoning?,personaModels?,personaProviders?}` → `201 {data:{provider:{id,name,provider,baseUrl,model,multimodal,enabled,fallback?,maxTokens?,reasoning,personaModels?,personaProviders?}, persisted:true, path}}`。**真实持久化**:读-改-写 `models.json`(本地、gitignore),保存组提升为**生效组**(index 0),并**热更新**运行中的 LLM provider(`path` 为落盘文件路径);API key 写入文件但**永不回显/不落日志**。`provider` 为空默认 `openai`;缺 `baseUrl`/`model` 回 `400`;`maxTokens` 为负回 `400`;未注入 LLM stack 回 `500 llm_unavailable`、写盘失败 `500 llm_persist_failed`、热重载失败 `500 llm_reload_failed`。`maxTokens` 缺省/`0` = 未设(回落 `LLM_FALLBACK_MAX_TOKENS` / 适配器默认)。`.env` 为兜底。**v0.53 新增三键**(`reasoning.kind` / `personaModels` / `personaProviders`,语义与校验见上方「模型配置契约(v0.53)」):三者**缺省 = 保留已存值**,给出则 `reasoning` 整块替换、两张 persona 表**整表替换**;非法 `kind` / 非法协议名 / 未知 anthropic 形状 → `400 bad_request`。 - `POST /admin/llm/test` body `{provider?,baseUrl,model,apiKey?,prompt?}` → `200 {data:{ok,provider,model,reply,tokens}}`(**真实模型调用**);无效/失败回 `400 invalid_provider` / `500 llm_test_failed`。API key 仅本次请求内使用,不落库、不进 wire。 - `POST /admin/channels/open`、`POST /admin/channels/close` body `{name}` → `200 {data:{name,connected}}`(真实 Connect/Disconnect);`404`(未注册渠道)/`409 session_rejected`(微信 iLink 侧拒绝/作废了该会话,重连无法修复,须先「扫码登录」重新绑定,v0.39)/`409 needs_login`(微信 iLink 尚无 token)/`500`(其它凭据缺失等连接失败)。 - `POST /admin/channels/test` body `{platform?,accountId,isGroup}` → `200 {data:{role,owner,isGroup,env,verdict}}`。**结构性判定**:channel 层 actor 元数据 × `configs/users/owner.yaml` 白名单,绝不靠提示词。 - **微信 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 内容)快照到 `/backup-.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`。 **知识库 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` = `:<原生 id>`(`audit:` / `backthink:`),**跨页稳定、可去重**;`source` ∈ `audit|backthink`;`createdAt` 为 epoch 毫秒(无法解析的审计时间戳记 `0`,不伪造时间)。`title`/`body` 由各自源派生:审计项 `title = what`(如 `channel.open`)、`body = " · "`;backthink 项 `title = "主动推送( · 已送达|未送达)"`、`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://:8080/ws/chat?token=` 或 `Authorization: Bearer`;未认证在握手前回 `401` JSON。 - **下行(服务端→客户端)**: 经 bus 广播 `{type:"", data:..., turnId?, seq?, sessionId?}`(`turnId`/`seq` 为流式关联字段,见 §3.1;`sessionId` 为**会话持久化(design/16)新增**,能确定会话的事件带上,供客户端把该帧路由到**它自己那个会话**的消息桶(**v0.54 起:非激活会话的帧不再被丢弃**);**v0.36 起结构化流帧 `message.*` / `thinking.*` / `tool_call.*` / `tool_execution.*` 也带 `sessionId` + `turnId`**——多会话并发流式时客户端据此把 delta 落到各帧自身 `sessionId` 的消息桶,见 §3.1)——`message.start/delta/end` / `thinking.start/delta/end` / `tool_call.start/delta/end`(以上模型流帧的 `data` 另带 `streamId`,v0.50)/ `tool_execution.start/update/end`(工具执行三事件,v0.50)/ `turn.start` / `turn.end`(回合边界帧,v0.50)(结构化流,design/04 §1.1),以及向后兼容的 `stream_chunk` / `stream_end` / `thinking`(思维链) / `mood`(情绪变化,见 §3.7) / `reminder` / `device_update` / `tool_used`(工具回显) / `subagent_event`(逐代理进度) / **`subagent_result`**(后台批次的最终结果,见 §3.10) / **`approval_notice`**(主代理代审小字,见 §3.11) / `graph_update` / **`message_persisted`**(会话持久化增量,见 §3.3)/ **`message_edited`**、**`message_deleted`**(消息编辑/删除增量,见 §3.8)/ **`session_read`**(已读游标推进,见 §3.9)/ **`observability_log`**、**`observability_trace`**、**`observability_metrics`**(可观测实时推送,见 §3.12,v0.55)。**会话编排状态(design/15,均为广播,`data.sessionId` 区分会话)**:`session_busy` / `pending_ack` / `pending_drain` / `session_idle` / `session_interrupted` / `pending_full`(见 §3.2)、**`chat_duplicate`**(客户端幂等键重复到达的明确回执,v0.56 新增,见 §3.13);另有 **`steer_ack`**(忙时会话里排队的那条用户消息**已被在飞回合吃进同一回合**的回执,v0.57 新增,见 §3.14)——它经**同一状态通道**,但由 **hub 直接广播、不经 bus**,帧壳带 `sessionId`(`data` 内不重复);因它**携带具体消息身份**,**不属**上面这组「状态机信号」。语音回复走 `voice_output`(定向回请求连接,非广播)。 - **下行帧的角色门(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?}`。`audio` 为 base64(或 data: URI)音频,`format` 为其编码(`wav`/`mp3`/`ogg`);`text` 为已转写文本时跳过 STT;`deviceId` 为**客户端具体端标识**(design/16 §4.5,如 `voiceassist:myapp:v1`,可选),随该语音轮次落库、聊天气泡可标注来源。服务端走 STT→agent 聊天→TTS(design/06 §1),回复经 `voice_output` 定向回该连接。**真 STT/TTS 已接通阿里百炼**(`DASHSCOPE_API_KEY` 必填:STT=`qwen-audio-3.0-asr-flash`、TTS=`cosyvoice-v3-flash`+`longxiaochun_v3`);无凭据时后端走降级链(cloud → edge-tts/espeak(未实现)→ `silent` 空音频)。无注入语音服务时回 `error` 帧(`voice unavailable`)。 - **`voice_output`**(下行,服务端→客户端):`{audio, format, text, reply, provider?}`。`audio` 为 base64 合成音频,`format` 为其编码,`text` 为识别文本,`reply` 为助手回复文本;**当合成音频为空(silent 降级)时带 `provider`**(如 `"silent"`,非空音频省略)——客户端据此提示「没生成声音」,但该帧**仍照常发出、不算业务错误**。 - **向后兼容**:未映射/无法确定的事件仍走 `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)。 - **上下文续接**:忙完重开时新回合**带上一轮上下文**(`{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:` 前缀 → **`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:`(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 `),回退是**个推 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` 由聊天气泡显示(`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` = 固定文案 `「 想要干什么,我审查后批准了」` / `「 想要干什么,我审查后拒绝了」`(**由后端产出**,客户端**原样渲染**,不要自拼)。 - `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` 收灰)。 ## 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 `;WS 握手用 `ws://:/ws/chat?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:` 由 `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=&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:}}`。不存在回 `404`。**重发**用「编辑后内容再 `POST /chat`」即可(无独立重发端点)。两者成功后都会广播(`message_edited` / `message_deleted`,§3.8),同账号其它端**免刷新**同步。 - **实时增量**:连接 WS 后,消息落库会广播 `message_persisted`(§3.3)——**用其中的 `id` 去重**,按 `sessionId` 写进该会话自己的桶(**v0.54 起非激活会话的帧不再丢弃**);新开/重连连接首帧即 `history` 回放(主会话最近 50 条)。**消息被编辑/删除**则分别广播 `message_edited` / `message_deleted`(§3.8)——同一 `sessionId` 路由规则,前者按 `id` 原位改 `content`、后者按 `id` 移除,都不需要重拉整页。 - **发消息**:`POST /api/v1/chat` `{text, deviceId?, image?, mode?, clientMsgId?, stream?}`。`stream:true` **直达会话编排器**(v0.56/G2):受理成功后才回 `200 {stream:true}`;会话正忙已续接排队 `202 {stream:true,queued:true,queueDepth}`、队列满 `409 chat_busy`、**未接 loop `500 chat_unavailable`**、仅未接编排器时的兜底派发失败 `503 chat_dispatch_failed`。非流式返回完整 `reply`(会话正忙时 `202 {queued:true,queueDepth}`、排队满 `409 chat_busy`,见 §3.2)。`image` 解码后上限 **5 MiB**(超限 `400 image_too_large`,文案指路 `POST /api/v1/upload`);**WS `chat` 帧受 4096 字节读上限约束**(超限断连),故 WS 上发大图必须走「上传 → URL」。 - **幂等发送(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::<随机短id>`(如 `web:chrome:a1b2c3`);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` 排队满。 - **红线**:身份判定永远来自后端 channel 层 actor 元数据 + 白名单,**客户端不要用提示词/文本判身份**;`thinking`(思维链)在公开消息/回放中**已剥离**,客户端不应依赖它。 ### 4.5 离线 / 缓存建议(原生端) - PWA / 原生端缓存 `sessions`/`personas` 列表 + 最近一屏消息;重连先本地渲染再拉增量。 - 消息**真源在后端**,本地只做渲染缓存;用 `message_persisted.id` 去重,不要用本地自增 id。 - 语音:上行 `voice_input`(base64 音频或已转写 text),下行 `voice_output`(合成音频 base64 + text)。 ## 5. 变更记录 - `v0.58`(**纯新增 env 键:`LLM_MULTIMODAL`——让 env-only 部署也能声明「主模型自身能收图」**,依据裁决 14.4.1 / design/06 §0(commit `4adfc9a`),2026-10-04 落实)。**新增 1 个 `.env` 键**:`LLM_MULTIMODAL`(`bool`,**默认 `false`**)= `models.json` 组 `multimodal` 的 **env 等价物**,**只在没有有效 `models.json`(走 `.env` 合成组)时生效**(有组文件时以该组的 `multimodal` 为准,与 `LLM_REASONING_KIND` 同口径);`true` = 图片**直进主模型上下文**;**留空 / `false` = 把图交给 vision/OCR 工具换成文本描述**;**非 bool 值(如 `maybe`)启动即报错**(fail-fast,不静默回退)。**兼容性**:默认 `false` ⇒ **老部署行为逐字不变**(引入本键之前,env-only 部署没有任何键能声明该能力,图片恒走 vision 工具);**`LLM_MULTIMODAL=true` 后图片走主模型直收,属部署者显式选择**。**无端点 / 帧 / 字段形状变更**(纯新增 env 键,见 §2「模型配置契约」表与 §1 行 3)。 - `v0.57`(**13.4.5 目标态 steer 落地登记:新下行帧 `steer_ack`(回合内转向的消费回执)**,依据 design/15 §2.0 / §2.1 / §2.4 / §3.x / §5(**13.4.5 目标态规格**,commit `3d8822c`),2026-10-04 落实;**本版是已实现并已推库行为的登记,只有一处纯新增帧 + 一处链路图纯增量键,无其它契约变更**)。**A. 新下行帧 `steer_ack`(见 §3.14)**——`{type:"steer_ack", sessionId, data:{messageId, atStep}}`:忙时会话里新到的用户消息**被在飞回合吃进同一回合**时下发,`messageId` 是那条用户消息的**权威 id**(与 `message_persisted` 的 `data.message.id` 同源)、`atStep` 是吃进它的**模型步序号(1 基)**;同一批次吃进 N 条就发 N 帧、**不合并**。语义=「**已被本回合吃进(已从 pending 移除)、尚未被正面回答**」⇒ 客户端应把对应气泡渲染为「**已读、未答**」的**灰态**,直到该回合的 `message.*` / `turn.end` 到达,**不需要第二个确认帧**(与「忙『稍后回』」的 pending 气泡不是同一个态)。**角色门**:它**携带具体消息身份**,因此**不登记**进 `frameReachesAll` 的 7 个状态机信号白名单,落 fail-closed 默认档——**只发 `owner` + `admin`**(`viewer` 收不到);经编排器状态通道**由 hub 直接广播、不经 bus**;帧壳带 `sessionId`、**不带** `turnId`/`seq`。**触发时机**:只在**回合内边界**(loop 每一步开头:上一次模型步与它那一批工具调用都已结束、下一次模型调用尚未开始,此刻没有工具在飞)产生,且**只有该边界真的匹配到 pending 里、且已落库的消息时才发**——**不是每个 step 都发**(无可注入消息的边界一帧都不发)。**B. 转向的三条不变量(design/15 §2.0)**——**I1(同会话在途回合数恒为 1)**:转向只注入、不取消,**绝不新开第二个回合**(loop 侧结构性地没有任何「起新回合」的分支,注入只是把消息并进**当前回合**下一次模型调用的视图);**I2(消息恰好被处理一次)**:注入=**已消费**——被注入的消息立刻从该会话 pending **移除**(按 pending 序号认领),绝不被第二个回合再答一遍;去重键=后端 `message.id`,候选必须**同时**满足「在该会话转录里」与「在本编排器的 pending 里」(**不用时间戳**:Windows `time.Now()` 步长约 0.44ms,连发常同毫秒并列,任何毫秒基线都会漏注入或重复注入);**I3(转向不取消已发起的工具)**:注入点只在回合内边界,此刻没有任何工具在飞,正在执行的工具**永远跑完**(与 Cline PR #13958「只打断在飞的模型请求、工具与整轮继续」同口径)。**C. 注入的范围(重要口径)**——注入**只影响本轮视图**(每一步边界重新并入),**不写回运行窗口** ⇒ 既不进该回合的 **checkpoint 快照**、也不进**后台记忆提取的输入面**;被注入的消息**本身早在到达时已落库**(pending 路径即时持久化),注入**不新增、也不改写任何行**(它与 `message_persisted` 是两回事:后者在该消息落库时发,前者在该消息被吃进时发)。最新性=**每个边界重读该会话转录**(`history.Recent`),不靠回合开始那一次播种(回合开始前已有的消息由 `startTurnMessages` 播种,**绝不重复注入**)。**D. 失败一律让路(诚实边界)**——未接转录存储 / 读转录失败 / 该消息尚未落库 / 本回合世代已被打断或被更新的回合取代(认领 `pending` 失败)时,**不发 `steer_ack` 也不注入**:该消息**留在 pending**、照旧作为自己的回合被回答,**绝不静默消失**(转向是增益,任何一处失败都不该让已经在跑的回合失败)。**未做**:**没有「取回 / 撤回 pending 消息」的上行帧**(design/15 §5 提到的队列取回暂无协议面),也没有「已撤回」类下行帧;`steer_ack` 只报「**吃进**」,**不报「回答」**(回答由既有的 `message.*` / `turn.end` 表达)。**E. 一处随实现落地的链路图纯增量键**——注入时给该轮图的 **input 节点**打 `props.steered:true`(`loop.FlowPublisher.MarkInputSteered`),让流程图如实表达「这一轮的输入在回合中途被追加过」;图仍**只有一条主干**(**不是**被画成第二轮,与 I1 一致),标记只并入既有 input 节点、**不新建节点、不改标签**。**F. 实现落点**——loop 侧 `internal/runtime/loop/steer.go`(`SteerRequest` / `SteerSource` / `WithSteerSource` / `steerAtBoundary`)+ `run.go` 的边界调用点 + `flow.go` 的 `MarkInputSteered`;网关侧 `internal/services/gateway/session_steer.go`(每回合一份的 `steerTurn` 游标 / `match` / `consumePending`)+ `session_orchestrator.go`(回合启动与每次续跑各挂一份转向源)+ `ws_bridge.go`(`wsSteerAck` 帧名与其语义注释);**组合根零改动**(per-turn 状态用 ctx 承载)。**G. 兼容性**——**纯新增帧**,老客户端按未知帧忽略即可;**无 REST / `.env` / 既有帧 / 鉴权变更**,`session_busy` / `pending_ack` / `pending_drain` / `session_idle` / `session_interrupted` / `pending_full`(§3.2)与 `chat_duplicate`(§3.13)的形状与语义**一字未动**。**验证**:网关侧**真路径** e2e(真 WS `chat` 帧 → 真编排器 pending → 真 loop → 真 SQLite 转录 → 真 `/ws/chat` 连接 × 真 JWT)断言「忙时新消息在**下一个回合内边界**被注入**同一回合**、模型第 2 步窗口里有它、整轮**只有一次**助手回答、pending 被清空、`steer_ack` 恰好 1 帧且 `owner`/`admin` 都收到而 `viewer` 收不到(以白名单哨兵到达为前提)」+「消息尚未落库时留在 pending、绝不静默消失」;loop 侧断言注入边界序列、同流去重、恰好一次、以及未接线转向源时窗口逐字不变。**变异反证**(均已还原复绿):删 `consumePending` → 模型步数 4≠3;注入写回 `msgs` → checkpoint 快照被污染;批次中途注入 → 边界序列异常;把 `steer_ack` 加进白名单 → `viewer` 收到帧。 - `v0.56`(**契约债一次补齐(协同项 A2 / G1 / G2 / G3 / G4 / F3):`interrupt` 支持 `scope` + REST `stream:true` 不再静默失败 + `chat.image` 上限 + `clientMsgId` 幂等 + 视觉链路 URL 取回事实 + 既有错误陈述修正**,依据 `Cyrene-For-Android/devdocs/02-前后端协同事项.md` 的 A2 / G1 / G2 / G3 / G4 / F3(commit `7512d59` 等),2026-10-04 落实)。**A. `interrupt` 支持 `scope`(A2,纯新增可选字段)**——上行 `interrupt` 由 `{sessionId}` 扩为 `{sessionId, scope?}`,取值域 `{"", "all", "current"}`(省略 / 空串 / `"all"` **三者同义**,= 现状语义:取消在跑回合 + **清空**待处理队列;老客户端只发 `{sessionId}` 的行为**逐字不变**)。**新增 `"current"` = 只取消在跑回合、保留待处理队列**,队列消息按编排器正常续跑规则继续执行(带上会话尾部上下文)——修的是「客户端『打断』与『追加队列』两种模式被后端当成一种」:打断一律连队列清空时,用户选「打断」后连发的多条消息会被**静默丢弃**,只剩一个 `dropped` 数字。**其余取值 → 定向回** `{type:"error",data:{code:"bad_request"}}`(只回发送者,**不广播、不打断任何会话**),显式报错而绝不静默按默认执行。**下行 `session_interrupted` 帧形状零变化**(`{sessionId, mode, dropped}`),语义改为「如实上报」:`dropped` 恒等于**真的被丢弃**的待处理条数(`scope:"current"` 时为 `0`),且该档 `mode` 为会话当时的**忙碌通道**(`thinking|executing|subagent`,**不是 `idle`**)。实现落点 `internal/services/gateway/session_interrupt.go`(`InterruptScope` / `InterruptScope()`,从编排器主文件拆出以守住 ≤500 行)与 `ws_hub_turn.go` 的 `interruptRequest.Scope` + `parseInterruptScope`(**大小写敏感**、只容忍首尾空白)。**B. REST `stream:true` 不再静默失败(G2,行为变更,客户端必读)**——见 §1 行 3 与 §4.2:该分支改为与 WS `chat` 帧**同一条直达编排器的路径**(design/15 §2.1「会话消息不得被 drop」)。**响应面**:受理成功后才回 `200 {data:{stream:true}}`;正忙已续接 `202 {data:{stream:true,queued:true,queueDepth}}`;队列满 `409 {error:{code:"chat_busy"}}`;幂等键重复 `200 {data:{duplicate:true,messageId,sessionId}}`;**未接 loop 由 `200 {stream:true}` 改为 `500 {error:{code:"chat_unavailable"}}`**(与同步路径同口径);仅**未接编排器**时回退总线派发,其失败回 `503 {error:{code:"chat_dispatch_failed"}}`(修复前 `Publish` 的错误被吞掉、且传的是**请求作用域** ctx)。**`202` / `409` 从此真的会出现**(修复前因「先 return 200」永不出现)。派发用的回合上下文与请求作用域脱钩(`context.WithoutCancel` + 30 分钟上限),回合不会在客户端刚收到 200 时被连带取消。**兼容性**:`stream:false` 与既有响应形状**一字未动**;**依赖「任何情况都拿到 200」的客户端必须按上表分支**。**C. `chat.image` 上限(G3,纯新增校验)**——`POST /api/v1/chat` 的 `image` 新增硬上限:**解码后 5 MiB**(与 `POST /api/v1/upload` 同口径;base64 字符串上限 = `ceil(n/3)*4 + 64` 字符,覆盖 data: URI 前缀余量),超限 → `400 {error:{code:"image_too_large", message}}`(文案指路 `POST /api/v1/upload`),在**任何解码 / 落库 / 入模型之前**拦下(超大 base64 因此不进历史、也不会被重连回放重传;校验按**字符串长度**做——入口层唯一零分配的一步,先拦再接)。**事实(诚实边界)**:**WS `chat` 帧到不了这条 400**——WS 读上限 `wsReadLimit = 4096` 字节/帧,超限由传输层直接断连 ⇒ **WS 上发真实图片本就必须「上传 + URL」**(图片有「上传」与「内联」两条入口,两边给出同一个「一张图能有多大」的答案)。**D. `clientMsgId` 幂等(G4,纯新增可选字段 + 纯新增帧)**——见 §3.13:上行新增**可选** `clientMsgId`(REST `chat` 体与 WS `chat` 帧都支持),同一会话内同一非空值只承认第一条;重复到达 REST 回 `200 {data:{duplicate:true,messageId,sessionId}}`、WS 回**新下行帧 `chat_duplicate`**(`{type:"chat_duplicate", sessionId, data:{sessionId, clientMsgId, messageId}}`,**未进 7 个状态机信号白名单 ⇒ fail-closed 只发 `owner` + `admin`**),且**不落库、不起回合、不发任何内容 / 流式帧**。判重由 `(session_id, client_msg_id)` 的**部分唯一索引**(`WHERE client_msg_id != ''`,空键刻意排除在外——否则同一会话第二条空键消息就会撞唯一约束)**原子裁决**,并发重发只有一个赢家;非空键的写入走 `ON CONFLICT DO NOTHING`。`clientMsgId` 另**回填进消息 DTO** ⇒ `message_persisted` / `channel_persisted` / 握手 `history` 回放 / `GET /sessions/{id}/messages` 可见(空则省略)。**迁移 `0013_chat_client_msg_id`**(up:加列 + 部分唯一索引 `idx_chat_messages_client_msg_id`;down:**先删索引再删列**;全新库由 `history.NewStore` 幂等前滚同一列与索引)。**向后兼容**:省略 `clientMsgId` 时行为逐字不变(老客户端零影响,历史行该键为空)。**E. 视觉链路事实修正(F3,无契约变更)**——`chat.image` 的 http(s) URL(含 `/api/v1/files/`)由**服务端自己取回并内联为 `data:` URI** 再交 provider(**不再原样转给上游**;把同源 / 局域网 URL 透传给云 provider,对方根本取不到,自部署形态下视觉链路会整条不通);同源 upload 产物优先走**本地文件读取**(`vision.WithUploadsDir`)。**类型裁决**:**字节嗅探是唯一裁决权**——按真实字节判白名单光栅图(png/jpeg/gif/webp/bmp),声明的 Content-Type / 扩展名只作参考且可被否决;嗅探为文本(HTML / PROXY 错误页 / SVG)或判不定且来源未声明 `image/*` → **明确报错**(`vision.ErrNotImage`),**绝不静默回退成 `image/png`**(否则模型会对着乱码编描述)。**F. 修正既有错误陈述(G1,`Docs/api-reference.md`)**——§6.2 帧壳表与 §6.6 曾声称结构化流帧只带 `seq`、没有轮次标识,**是错的**:**v0.36 起**结构化流帧(`message.*` / `thinking.*` / `tool_call.*`)就带 `sessionId` + `turnId`(`broadcastStream` 从 `model.StreamEvent` 取,loop 在回合边界注入),v0.50 起 `data` 内另带 `streamId`;两处均已按实现更正(与本文档 v0.36 条目一致)。**G. 环境变量登记**——`.env.example` 新增 **`OWNER_IDENTITY_INJECTION`**(`owner_only` 默认 / `always` / `off`;**非法值回退 `owner_only` 并记 WARN**),并登记 **`CONTEXT_MAX_TOKENS`**(默认 `32768`;`0` / 负 = 关闭压缩)与 **`GUARDRAIL_KEYWORDS`**(逗号分隔;留空 = 不挂关键词护栏),三键见 §2「安全 / 上下文环境变量(v0.56)」。**验证**:A2 四组断言(`scope` 取值域表 / `"current"` 保留队列 / 省略与 `"all"` 清空 / 非法值定向 `bad_request` 且不打断任何会话,含真实 WS 路由 e2e);G2 / G4 端到端断言(同一 `clientMsgId` 发两次只落**一条**用户消息、只起**一个**回合、第二次响应明确报 `duplicate`;`image` 恰等于上限通过、多一字符 `400 image_too_large`;未接 loop 的 `stream:true` 回 `500` 而非 `200`)。**未做(诚实边界)**:`interrupt` 仍**只有 WS 上行**入口(无 REST 版);`clientMsgId` **无服务端过期清理**(键随消息长期保留,判重窗口即该会话历史)。 - `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 行为**:`/` 与其深链(`/<前缀>/

`)返回**注入了 `` 与 `window.__CYRENE_ADMIN_ENTRY__={"prefix":"..."}` 的 index.html**(`Cache-Control: no-store`;`` 必在资源引用之前,否则多段前缀/深链下 Vite 的 `./assets/...` 会解析到错误目录);**形如面板入口但前缀不符的路径 → `404`**(判定 = 路径里出现 `admin` 段;既不回落用户端聊天壳、也不回显正确前缀);`/` 若**恰好命中 `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`(信封形状不变)。**副作用只有一处**:落盘快照 `/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` 是**后端产出的固定文案**「 想要干什么,我审查后批准了」/「…我审查后拒绝了」(客户端**原样渲染**,不要自拼),`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:` / `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 历史消息按其说话角色出具 `[发言归属] 以下发言来自「<名字>」(),不是你说的。` 独立 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/.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`** 查询参数(``,升序返回 `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`/`` 无法带 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` 解析 `

` 拆气泡**,其它模式原样整条)。 - `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/`,走 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` 快照到 `/backup-.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 不打印不落日志,子项失败记入 `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::<随机短id>`,发送时自动带上,聊天气泡默认小字标注来源(仅当 `deviceId` 存在才显示);**IM 端**按 `:<本地配置唯一id>:<用户平台唯一uid>` 构造(当前 `UnifiedMessage` 尚无 deviceId 概念,channelbridge 以 `::` 占位,`<本地配置唯一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/.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`(契约先行):骨架 + 端点登记表。