Files
Cyrene_v2/Docs/archive/api-client.v0.65.md
T
AskaEth 2ba1baf6d0
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(api): 归档 v0.65 旧版 api-client / api-reference(准备以代码为真源重写干净版)
旧版积了 v0.28→v0.65 的历史条目、「契约版本」标题并列、双变更记录、mtime 落后等结构性问题;
归档保留完整历史(含 §5 变更记录),新文件将从零重写,只留最近若干版并指向本归档。
2026-10-05 09:29:33 +08:00

403 KiB
Raw Blame History

Cyrene v2 客户端 API 文档(用户侧)

完整版见 Docs/api-reference.md(后端全部 API(REST + WebSocket)超详细调用文档,字段/鉴权/状态码以代码为准)。本文档为摘要,契约如有出入以 api-reference.md 为准。

面向: Web 前端 / 移动端原生客户端 / IM 桥接等跨平台客户端开发者。 维护铁律: 每实现/变更一个 API(REST 端点、WS 事件、admin API)立即更新本文件;契约变更必须版本化,不破坏既有客户端。 状态: gateway 已在 M8 落地核心端点;M9 前端按此开发。✅ = 已实现,🕐 = 规划。


0. 基础约定

  • Base URL: http://<host>:8080(网关唯一对外端口;生产一般经反代/HTTPS)。
  • 版本: REST 全部挂在 /api/v1/{resource};未来破坏性变更升 v2。
  • 认证: Authorization: Bearer <JWT>(RBAC:owner/admin/viewer,权限矩阵见 §2.0);X-API-Key 已接线(v0.51):所有已认证 API(管理 /api/v1/admin/* 与用户侧 /api/v1/*)同时接受 X-API-Key;命中签发表即通过,且一律 owner 级(不再按角色分档,裁决 15.4.3)。请求带该头时只走 key 分支——未知/已吊销/空值回 401 且不回落 Bearer(fail-closed)。WS 握手仍只认 Bearer。
  • 响应统一: {"data": ...} / {"error": {"code": <string>, "message": <string>}};状态码 200/201/204/400/401/403/404/413/415/429/500(413/415 为 v0.59 文档端点新增)。
  • 错误码:业务错误带 code 字符串(如 unauthorized / not_found),便于客户端分支。
  • 数据目录 DATA_DIR(v0.55):默认 ./data;新增接受保留字 platform(大小写不敏感) → 平台标准数据目录(Windows %APPDATA%\Cyrene、macOS ~/Library/Application Support/Cyrene、Linux ~/.local/share/cyrene)。默认 ./data 与显式路径行为不变;显式空值行为也不变(仍走原兜底,不会因升级突然换库)。
  • 管理面板安全入口 ADMIN_PATH(v0.55,前端路由面,不是 REST 契约):面板前端路由前缀 = / + ADMIN_PATH(默认 admin → /admin;xxx/admin → /xxx/admin;非法值回退 /admin)。HTTP 行为:/<ADMIN_PATH> 与其深链返回注入了 <base href="/"> 与 window.__CYRENE_ADMIN_ENTRY__={"prefix":"..."} 的 index.html(Cache-Control: no-store);形如面板入口(路径里出现 admin 段)但前缀不符的路径 → 404(不回落用户端聊天壳);/<ADMIN_PATH> 若恰好命中 dist 真实文件则仍走文件服务器。API 不藏:/api/v1/*、/api/v1/admin/*、/ws/chat、webhook 一律原样,无新端点、无帧变更(安全靠 JWT + 角色门,design/07 §0.1)。口径:用户端 HTML 永不携带该前缀串(后端只在命中前缀时注入)⇒ 用户端既无管理入口链接、也解析不出面板路由(design/07 §0.2)。

1. REST 端点(公开)

# 端点 方法 认证 状态 说明
1 /api/v1/auth/login POST 否 ✅ {username,password} → {data:{token,expiresAt,role,refreshToken?}}(access 24h;新增 refreshToken——不透明随机串,仅哈希落库,AUTH_REFRESH_TTL 默认 720h,用于静默续期;role=owner)
1a /api/v1/auth/refresh POST refreshToken ✅ 静默续期(B2):body {refreshToken} → {data:{token,expiresAt,role,refreshToken?}}(新 access + 新 refresh;role 恒返回 = 该 refresh grant 记录的角色 `owner
1b /api/v1/auth/logout POST refreshToken 或 JWT ✅ 登出(B2):body {refreshToken} 撤单个 grant(幂等)→ {data:{revoked:<bool>}};带 Authorization: Bearer <jwt> 且无 body → 撤该账号全部 grant(全设备登出)→ {data:{revoked:<bool>,revokedCount:<n>}}。两种响应形状不同——单撤没有 revokedCount;两者都不给回 400 bad_request。refresh 相关操作成功才审计
2 /api/v1/health GET 否 ✅ 健康检查 {data:{status:"ok"}}
3 /api/v1/chat POST JWT ✅ {text,image?,deviceId?,mode?,clientMsgId?,documents?,parallel?,stream?} → {data:{reply}};documents?(v0.59)=本回合文档附件引用数组(≤5 条、只收服务端存储名、与 REST stream:true / WS chat 同形),单回合超限 400 too_many_documents(先于任何文件系统访问)、名非法/穿越/不存在 404 document_not_found,详见 §1.2;stream:true 时直达会话编排器(v0.56/G2:不再经可丢弃的 bus,成功受理后才回,见本行末尾「stream:true 响应面」)。image 为 base64 data: URI 或 http(s) URL,入视觉路由(design/06 §0;v0.53 语义修正)——主模型能不能收图看生效组的 multimodal(models.json 组键,面板可保存、热更新);LLM_VISION_MODEL/LLM_OCR_MODEL 只配视觉/OCR 工具用的模型,与主模型能否收图无关(裁决 14.4.1)——走 .env 合成组(无有效 models.json)时,「主模型能否收图」该由 LLM_MULTIMODAL 声明(默认 false)(v0.58,见 §2「模型配置契约」表);主模型不收图时由视觉·OCR 工具的文本描述替代(图像不占上下文,只有工具结果占);image 上限(v0.56,G3):解码后 5 MiB(与 POST /api/v1/upload 同口径;base64 字符串上限 = ceil(n/3)*4 + 64 字符,已含 data: URI 前缀余量),超限一律 400 {error:{code:"image_too_large", message}}(文案指路 POST /api/v1/upload);WS chat 帧到不了这条判定——WS 读上限 wsReadLimit = 4096 字节/帧,超限由传输层直接断连,故 WS 上发真实图片本就必须「上传 + URL」;deviceId 为客户端生成的具体端标识(design/16 §4.5,如 web:chrome:a1b2c3,可选),落库到消息、便于主人知道从哪个端发来;mode 为行为模式(design/01 §3 / design/16 §5.1,如 chat/code/home/docs/…,可选),随该轮次落库——IM 恒 chat、缺省回落 chat,供前端按模式渲染气泡(仅 chat 才解析 <p> 拆气泡)。clientMsgId(v0.56,G4)为客户端自带的幂等键(可选):同一会话内同一个非空值只承认第一条,重复到达回 200 {data:{duplicate:true,messageId,sessionId}} 且不落库、不起回合、不发新内容帧;省略 = 与加入该字段前逐字相同(详见 §3.13)。会话编排(design/15):非流式且该会话「正忙」时不阻塞,返回 202 {data:{queued:true,queueDepth}}(稍后按上下文续接);待处理队列满时返回 409 {error:{code:"chat_busy"}}。忙时并行(v0.65,parallel:true,仅显式声明时生效):该会话已有在途回合、且单会话并发额度未满(默认 2、硬上限 3)⇒ 本条升为一条独立的并行路立即起,同步入口回 202 {data:{parallel:true,turnId:"turn-…"}}(拿不到同步 reply,文本照旧经 WS 流式到达;turnId 就是那一路的权威回合标识、与该路全部流帧同值);空闲时 parallel 无效果(照常起第 1 路);省略 parallel = 与加入该字段前逐字不变(服务端不做启发式自动升级);额度已满、或真并行被 <2 关掉 ⇒ 落回既有排队语义 202 {queued:true,queueDepth} 并在 pending_ack 带 reason:"parallel_limit"(见 §3.2)。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}}、已升为独立并行路 = 200 {data:{stream:true,parallel:true,turnId:"turn-…"}}(v0.65;仅 parallel:true 且额度未满时出现);未接 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"
5 /api/v1/sessions POST JWT(owner/admin) ✅ 新建次级会话 → 201 {data:{sessionId,personaId?}}(sessionId="web:<uuid>")。body 可选(可完全省略或空 body):{personaId?}(v0.42)在创建时就把新会话绑到该角色(design/22 §2:渠道级只作"新会话的初始角色提供者";会话角色只在创建时写入、之后不可变,见 §1 行 8 / design/22 §8-1);personaId 为未知角色回 400 bad_request;省略/空 = 不绑(persona_id='',继承全局默认);personaId 非空时响应回显它(省略时响应形状与 v0.41 完全一致);未注入 HistoryStore 回 501 not_implemented;注入了不支持角色绑定的 HistoryStore 且显式传 personaId 时也回 501(不假装写入)
6 /api/v1/sessions/{id}/messages GET JWT(owner/admin) ✅ 热分页拉历史:?limit=&before=&platform=&after=(A5)默认 limit=50(上限 200)。before=上一页最旧 createdAt(毫秒),按时间倒序;after=<createdAt 毫秒>,按时间升序返回 created_at > after 的消息(正向增量,客户端把上次看到的 createdAt 当游标补断线期间漏的,解决重连只回放 50 条的缺口);after 与 before 互斥,同传回 400;负数/非数字回 400。platform 为可选来源平台过滤(design/16 §4.4)——Web 端不传(=全量共享主会话),IM 适配器传本平台(如 telegram)只拉本渠道消息;deviceId 为客户端具体端标识;senderName 为第三方发送者昵称;personaId(v0.42,可选)=该消息说话时的角色 id(design/22 §7-5:逐条记录"谁说的"——会话角色在创建时固定,§8-1;assistant 消息 = 生成它的角色,user 消息 = 当回合角色;空 = 全局默认角色,即 v0.42 之前的全部历史消息),客户端据此按角色渲染名字/头像;clientMsgId(v0.56,可选,G4)=该消息的客户端幂等键(带 clientMsgId 发的 user 消息会回填它;空则省略),客户端据此把本地乐观气泡对齐到服务端真相;q(D2)=会话内内容搜索(大小写不敏感 ASCII LIKE,%/_/\ 已转义为字面量),仍按时间倒序、before/limit 同分页语义;q 与 after 互斥(同传 400),空 q 视为未传(走普通分页),搜索严格限定在 {id} 会话内(绝不跨会话,群聊/陌生人隔离会话不会外泄);非法 limit/before/after 回 400;未注入 HistoryStore 回 501。(v0.63,行为变更)会话不存在 / 已删除 ⇒ 404 {"error":{"code":"session_not_found","message":"session \"<id>\" not found"}}——与 PATCH /sessions/{id}、POST /sessions/{id}/read 对未知会话的口径一致,客户端据此把「当前会话已经不在了」(退回主会话 + 一句提示)与「这是一条空会话」分开;存在但确实没有消息仍回 200 {"data":[]}。⚠️ 判据是会话注册表 chat_sessions:隔离的 IM 会话(群聊/陌生人的 sess-xxx、内部 sess-backthink)不在其中,因此与 POST /sessions/{id}/read 拿到同一个答案(404)——它们的消息请走 GET /api/v1/admin/channels/{platform}/messages。⚠️ 请求形状(limit/before/after 非法、before+after 同传、q+after 同传)仍先于 404 判定(形状错了就是 400,与目标存不存在无关)
6a /api/v1/messages/{id} PATCH JWT(owner/admin) ✅ 编辑单条消息(D3):body {content}(非空、≤4000 rune,空/纯空白/超长回 400)→ {data:<消息DTO>}(data 直接就是更新后的完整消息 DTO,含 id/sessionId/role/content/.../createdAt,无 message 包一层;只改 content,role/时间戳/平台保持不变);消息不存在回 404 not_found;未注入 HistoryStore 回 501。成功后广播下行 message_edited(§3.8,账号其它端免刷新同步)。重发不单独设端点——客户端用「编辑后的内容再 POST /chat」即可(避免重复轮次语义)
6b /api/v1/messages/{id} DELETE JWT(owner/admin) ✅ 删除单条消息(D3)→ {data:{deleted:<id>}};不存在回 404 not_found;未注入 HistoryStore 回 501。成功后广播下行 message_deleted(§3.8,账号其它端按 id 移除)
7 /api/v1/sessions/{id} DELETE JWT(owner/admin) ✅ 删除次级会话及其消息 → {data:{deleted:<id>}};主会话不可删回 403 forbidden;未注入 HistoryStore 回 501
8 /api/v1/sessions/{id} PATCH JWT(owner/admin) ✅ 会话元数据更新(A4 改名 + D5 置顶):body {title?,pinned?}(至少一个字段;都缺回 400 {"code":"bad_request","message":"at least one of title or pinned is required"}——说明「缺字段」)。title trim、≤80 rune:存在但为空/仅空白回 400 "title must not be blank"(不是 title is required——字段在、只是值非法)、超长回 400 "title is too long (max 80 characters)";空串不能清空标题(不回落自动命名,故客户端不提供「恢复自动命名」入口);pinned 为 bool(置顶/取消,主会话与次级会话均可);personaId 出现即 400(v0.46,design/22 §8-1:会话角色在创建时确定、之后不可变,要换角色请新开会话)——不是静默忽略、也不是 200 空操作:键出现(任何取值:"yuki" / "" / null / 未知 id / 空白串)一律 400 bad_request,错误消息点名 personaId,且该检查先于会话存在性检查(未知会话 + personaId 也回 400,不是 404;连 title 同传也整体 400、不改任何字段)。响应只回显本次变更的字段:{data:{id,title?}} / {data:{id,pinned?}} / 两项同改则都有(title-only 保持 A4 原 {id,title} 形状);不存在回 404;主会话(id="web")可改名/置顶但不可删;未注入 HistoryStore 回 501。另:会话首条用户消息到达时自动命名 title(取前 20 rune 折叠空白;纯空白回落 主会话/次级会话),列表不再空名
8a /api/v1/sessions/{id}/read POST JWT(owner/admin) ✅ 推进已读游标(D4 / Android 协同 B3):body {before?}——before 为可选的毫秒 Unix 时间戳,缺省(或空 body)= 现在,即「我看到的都算已读」。→ 200 {data:{sessionId,readAt}}(readAt 为推进后的游标毫秒)。判定:createdAt <= readAt 的消息算已读(列表 unreadCount 只数 createdAt > readAt 的);语义:会话级单一游标、单 owner 账号——读一处 = 该账号所有端已读(不做 per-device 游标,与「同一账号所有浏览器都属于同一 owner」一致);只前进不后退(SQL read_at = MAX(read_at, before)),所以乱序/并发/重试请求幂等安全、绝不会把已读变回未读。before 为 0/负数/非数字、或 body 不是 JSON 对象 → 400;未知会话(含隔离 sess-xxx,不在 chat_sessions)→ 404;未注入 HistoryStore → 501 not_implemented。成功后广播下行 session_read(§3.9)让同账号其它端清红点;失败路径不广播。升级回填:迁移 0010 把既有会话游标回填为「该会话现有最新消息时间」,升级当天不误报未读
9 /api/v1/personas GET JWT ✅ 角色目录(任意已认证角色可读)→ {data:[PersonaSummary...]},每项 {id,name,character,isDefault,mode,memoryNamespace,avatar?,chatBackground?,mood}。mood(v0.43) = 该角色当前情绪状态(角色级,取值见 GET /meta/enums 的 moods;"" = 该角色暂无被跟踪的情绪,不伪造默认值)。语义:情绪跟随角色,与会话无关——同一角色的所有会话看到同一 mood;客户端登录/加载角色目录时用它播种心情徽标,避免刷新后短暂空白(随后以 WS mood 帧为准)。不含 admin 专属的 thinking 旋钮(用户侧不暴露模型调用参数)。
10 /api/v1/audio/transcribe POST JWT(owner) ✅ 语音→文本:body {audio:"<base64>",format?,deviceId?} → {data:{text, provider?}}(走后端 STT,百炼 qwen-audio-3.0-asr-flash;当结果为空文本/silent 降级时响应体带 provider(如 "silent",非空文本省略),客户端据此提示「没识别到声音」)
11 /api/v1/audio/speech POST JWT(owner) ✅ 文本→音频:body {text,voice?,format?,instruction?} → 200 音频字节(Content-Type: audio/<format>,非 JSON envelope,走后端 TTS,百炼 cosyvoice-v3-flash,复刻音色可带 instruction 情绪指令;静默降级空音频时仍 500 tts_empty,并附 X-Voice-Provider: silent 响应头,客户端据此提示「没生成声音」)
12 /api/v1/audio/voice-clone POST JWT(owner) ✅ 声音复刻建音色:body {url:"<公网参考音频URL>",prefix?} → {data:{voiceId}}(走百炼 voice-enrollment create_voice)
13 /api/v1/upload POST JWT(owner/admin) ✅ 图片上传(B1):multipart/form-data,字段 file(raster image,5MB 上限)→ 201 {data:{url,size,mime,name}};url 为同源访问地址(<host>/api/v1/files/<name>,可直接作 chat.image)。校验:MaxBytesReader 防超大 → DetectContentType 嗅探(不信客户端 Content-Type)→ 白名单 png/jpeg/gif/webp/bmp(拒 SVG/HTML);文件名用 crypto/rand 128-bit 随机名(原始文件名丢弃),存 <dataDir>/uploads/(0700)。错误码:缺文件 400 bad_request、超限 413 payload_too_large、非白名单 415 unsupported_media_type、未授权 401/403
14 /api/v1/files/{name} GET 无需(能力URL) ✅ 上传文件访问(B1):从 <dataDir>/uploads/ 读取,同名端口服务。name 仅接受「32 位小写 hex + 白名单扩展名」;路径穿越全形态拒绝(../%2f/%2e%2e 等)。返回 Content-Type/Content-Length/Cache-Control: private,max-age=86400,immutable/X-Content-Type-Options: nosniff(支持 Range)。设计取舍:GET 不鉴权——该 URL 是128-bit 随机能力地址(chat.image/<img> 无法带 Bearer),POST 仍 owner/admin+审计;任何持有该 URL 者都能取文件,勿外泄到他人

| 15 | /api/v1/meta/enums | GET | JWT(任意角色) | ✅ | 共享枚举权威表(B4):{data:{platforms,moods,modes,unknown}},每项 {id,label}——platform/mood/mode 的唯一事实来源,客户端不必硬编码即可拿到稳定映射(来源角标 / 情绪标签不再漂移)。只读、无敏感信息,与 GET /sessions 同级(owner/admin/viewer 均可读;无 token 回 401)。详见 §1.1 | | 16 | /api/v1/documents | POST | JWT(owner/admin) | ✅ | 文档上传(design/24 §3,v0.59;v0.61 纳入 .pdf):multipart/form-data,字段 file → 201 {data:{name,url,mime,size,sha256,deduped:false}};同字节同扩展名重复上传 → 200 + 同一 name + deduped:true(可当成功处理)。单文件上限 CHAT_DOCUMENT_MAX_BYTES(默认 20 MiB,≤0 视为用默认)。类型裁决 = 字节嗅探(唯一裁决)∧ 扩展名白名单(23 项,含 .pdf),自 v0.61 起为成对裁决(.pdf 只收嗅探为 application/pdf 的字节,PDF 字节也只收在 .pdf 名下;HTML/纯文本改名成 .pdf ⇒ 415,PDF 改名成 .txt ⇒ 415);mime 为嗅探基类型(data.json → text/plain,真 PDF → application/pdf);url 为相对路径 /api/v1/documents/<32hex>.<ext>。错误码:415 unsupported_media_type / 413 document_too_large / 400 bad_request(缺 file / 空文件)。详见 §1.2 | | 17 | /api/v1/documents/{name} | GET | JWT(owner/admin) | ✅ | 文档读取(v0.59;v0.61 增 PDF MIME):必须带 Bearer——与图片读侧「128-bit 随机名即凭据、免鉴权」刻意相反;name 仅接受「32 位小写 hex + 白名单扩展名」,路径穿越全形态拒绝;响应带 Content-Disposition: attachment + X-Content-Type-Options: nosniff + Cache-Control: private,max-age=86400,immutable;嗅探不出放行类型时下发 application/octet-stream(真 PDF 按成对裁决如实下发 application/pdf,仍因 attachment + nosniff 永不可内联);名字非法 / 逃出目录 / 文件不存在统一回 404 document_not_found(不泄露差异)。详见 §1.2 |

语音 REST 源(/api/v1/audio/*,均 owner-only + 审计):原生/移动端用它上传/获取音频,绕开 WS 4KB 帧限。

  • POST /api/v1/audio/transcribe:body {audio:"<base64>", format?:"wav", deviceId?} → 200 {data:{text}}。audio 为 base64(或 data: URI);format 为编码(wav/mp3/ogg,缺省 wav);空 audio 回 400;未注入语音服务 500 voice_unavailable、识别失败 500 stt_failed。
  • POST /api/v1/audio/speech:body {text, voice?, format?, instruction?} → 200 原始音频字节(Content-Type: audio/<format>),非 JSON envelope。voice/format 目前遵循服务端 VOICE_TTS_VOICE_ID/VOICE_TTS_FORMAT 配置(后续支持按请求覆盖);instruction 为可选 per-request 情绪/语气指令(如 "语气开朗俏皮,像女朋友撒娇"),只对复刻/设计音色生效——系统音色 longxiaochun_v3 被服务端忽略,永不发送自由指令(CosyVoice 系统音色需固定格式);长度 ≤100 字符(汉字记 2);未传时回退到服务端 VOICE_TTS_INSTRUCTION。空 text 回 400;未注入语音服务 500 voice_unavailable、合成失败 500 tts_failed、静默空音频 500 tts_empty。
  • POST /api/v1/audio/voice-clone:body {url:"<公网参考音频URL>", prefix?:"cyrene"} → 200 {data:{voiceId}}。url 为公网可访问的参考音频 URL(create_voice 只收 input.url,不收 base64),音频要求 10~20s 连续清晰朗读、≤10MB、≥16kHz;prefix 为音色名前缀(≤10 字母/数字,缺省 cyrene)。返回的 voiceId 填到 VOICE_TTS_VOICE_ID 即可用该复刻音色合成(target_model 固定为 VOICE_TTS_MODEL,须与合成模型一致)。URL 不写审计/日志(红线,绝不打日志)。空 url 回 400;未注入语音服务 500 voice_unavailable;建音色失败 500 voice_clone_failed。⚠️ 真建音色需主人提供公网参考音频 URL(当前无,断言待提供);且需北京地域 API Key(CosyVoice 声音复刻仅华北2北京地域)。

1.1 枚举权威表(GET /api/v1/meta/enums,B4)

背景:platform / mood / mode 此前只在文档里给示例(web/telegram/…;calm;normal),客户端只能自建映射表——来源角标与情绪标签因此会随客户端漂移。本端点发布权威枚举表 + 未知值回落约定:Web / Android / IM bridge 共用同一份,客户端不必硬编码。

  • 鉴权:Authorization: Bearer <JWT>,任意已认证角色可读(owner/admin/viewer),与 GET /api/v1/sessions 同级——只读、无敏感信息。无 token / 令牌无效回 401。无其它错误码(纯内存投影,无存储、无 501)。
  • 响应:200
    {"data":{
      "platforms":[{"id":"web","label":"网页"}, ...],
      "moods":[{"id":"calm","label":"平静"}, ...],
      "modes":[{"id":"chat","label":"对话"}, ...],
      "unknown":"display-raw"
    }}
    
    每项固定为 {"id":"<枚举 id>","label":"<默认中文标签>"}。label 是默认值,自带 i18n 的客户端可覆盖显示;id 才是必须比对的值。
  • unknown(未知值回落约定,机器可读):恒为 "display-raw"。语义见下。

platform(消息来源平台,{id:label} 逐值)

id label 说明
web 网页 Web 主会话来源(chat_messages.platform 默认值)
kook KOOK KOOK 适配器
qq-obv11 QQ OneBot v11(QQ)适配器
weixin-ilink 微信 微信 iLink 适配器
telegram Telegram Telegram Bot API 适配器
webhook Webhook 通用 Webhook 适配器

mood(情绪,{id:label} 逐值)

id label
calm 平静
happy 开心
excited 兴奋
sad 低落
angry 生气

⚠️ 情绪状态机可扩展(persona 可注册更多状态),因此上表不是封闭集合——未知 mood 必须按回落约定原样显示。

mode(行为/呈现模式,{id:label} 逐值)

id label
chat 对话
code 代码
home 家居
work 工作
docs 文档
voice 语音
vision 视觉
agents 智能体
settings 设置

未知值回落约定(红线)

  • 客户端遇到不在表中的取值(platform / mood / mode 任一):原样显示该原始字符串——不丢弃、不报错、不替换占位符、不猜测。
  • 服务端新增枚举取值是非破坏性变更(forward-compatible):表中新增一项不改变既有字段/端点形状,旧客户端无需升级;不要因为读到未知值而清空角标、抛异常或中断渲染。
  • 判定只比对 id(大小写敏感,契约 id 为小写);label 仅作显示默认值。
  • Web 端镜像实现在 frontend/web/src/lib/enums.ts(labelFor(kind, value) 对未知值返回原值),权威仍以本端点为准。
  • 反漂移保证(后端):后端有测试断言「每个内置适配器的 Name() 都在 platforms 表内」「moods 与 persona 情绪状态机的内置状态一致」「modes 与前端 MODES 九模式一致」——漏登记即 CI FAIL,客户端拿到的表不会滞后于代码。

1.2 文档存储与摄取契约(v0.61,design/24 §3/§4/§6 + §7 S6 第一段)

一句话:文档 = 「内容寻址的存储 + 服务端抽取 + 清单/正文注入」三段;读侧要 Bearer(与图片读侧刻意相反),客户端只需要两个端点 + chat.documents 一个字段,外加一个 documents 数组 DTO。

① 上传 POST /api/v1/documents(owner|admin + 审计 document.upload)

  • 请求:multipart/form-data,字段 file(单个)。上限 CHAT_DOCUMENT_MAX_BYTES(默认 20971520 = 20 MiB;≤0 视为用默认,不存在「禁用上限」的取值);请求体额外留 1 MiB multipart 余量,文件本身按确切上限校验两次(部件大小 + 流式写入时多读 1 字节)。

  • 响应:

    {"data":{"name":"a1b2…(32位hex).md","url":"/api/v1/documents/a1b2….md","mime":"text/plain","size":12345,"sha256":"<64位hex>","deduped":false}}
    

    首次 201 + deduped:false;同字节同扩展名重复上传 → 200 + 同一个 name + deduped:true(落盘名 = sha256(bytes)[:32] + ext,内容寻址;客户端原始文件名只用来取扩展名,永不参与落盘路径)。200 可当成功处理——内容与首次逐字一致。

  • url 是相对路径(/api/v1/documents/<32hex>.<ext>),不是图片侧那种绝对同源 URL:读侧要 Bearer,贴进 <img> 也取不到;客户端真正要用的是 name(chat.documents 只吃它)。

  • 类型裁决(唯一裁决权 = 字节嗅探;v0.61 起为成对裁决):

    • 扩展名白名单(deny-by-default,23 个):.txt .md .markdown .csv .tsv .json .jsonl .yaml .yml .log .ini .cfg .toml .go .ts .tsx .java .c .h .cpp .rs .sql,外加 .pdf(v0.61 新增,第 23 项);
    • 字节嗅探(http.DetectContentType)与扩展名成对判定(代码里就一个裁决函数 documents.ContentAllowed):纯文本类(上列除 .pdf 外的全部文本类扩展名)嗅探基类型必须恰为 text/plain;.pdf 嗅探基类型必须恰为 application/pdf(PDF 除外说明:它是白名单里唯一签名与纯文本不同的格式);
    • 两者是合取:.js / .py 即便嗅探成文本也拒(不在白名单);改扩展名也换不了类型(嗅探说了算),且自 v0.61 起双向——HTML/纯文本改名成 .pdf ⇒ 415(嗅探不是 PDF),PDF 改名成 .txt ⇒ 415(PDF 字节也只收在 .pdf 名下);
    • 明令拒绝:text/html、image/svg+xml 与 XML、application/zip 及其封装(docx/xlsx)、可执行/脚本(exe/dll/bat/cmd/ps1/js/mjs/sh/py)、判不定者;
    • mime 是嗅探所得基类型(去掉 ; charset=…):data.json 的 mime 是 text/plain,不是 application/json;真 PDF 的 mime 是 application/pdf —— 客户端按 name 的扩展名判语义,别拿 mime 当类型。
  • 错误码:415 unsupported_media_type(扩展名白名单(23 项)与字节嗅探的成对裁决任一不过——如 HTML 改名成 .pdf、或 PDF 改名成 .txt)/ 413 document_too_large(文件或 multipart 体超限)/ 400 bad_request(缺 file 部件、空文件、非 multipart)。审计:成功 document.upload+ok、失败 +error(成对发生,失败分支不遗漏)。

② 读取 GET /api/v1/documents/{name}(owner|admin,无审计)

  • 必须带 Authorization: Bearer <JWT>:无 token → 401、角色不足 → 403。这是与 GET /api/v1/files/{name}(免鉴权能力 URL)的关键差异——文档摄取在服务端完成,不需要被 <img> / provider 直接消费。
  • name 形态 = 32 位小写十六进制 + '.' + 白名单扩展名;路径穿越全形态拒绝(.. / %2e%2e / %2f 等)。
  • 响应头:Content-Type = 读时重新嗅探的基类型(同一对裁决:纯文本类 → text/plain;真 PDF 如实下发 application/pdf;嗅不出放行类型 → application/octet-stream)、Content-Disposition: attachment; filename="<name>"、X-Content-Type-Options: nosniff、Cache-Control: private, max-age=86400, immutable(内容寻址 ⇒ 名字变则内容变);支持 Range。⚠️ PDF 也永不内联:attachment + nosniff 一字未变,浏览器不会把 .pdf 当插件文档渲染。
  • 错误:404 document_not_found —— 名字非法 / 路径逃逸 / 文件不存在三种情况同一响应(不泄露「是否存在」以外的信息)。

③ chat.documents(REST 同步 / REST stream:true / WS chat 三处同形)

{"sessionId":"web","text":"帮我看看这份","documents":[{"name":"a1b2….md"},{"name":"c3d4….csv"}]}
  • 只给 name(服务端存储名)。客户端多带的 mime / url / size 一律被服务端覆盖(MIME 与大小取自落盘字节);绝不接受任意 http(s) URL——不重开 F3 那种「服务端代取 URL」的 SSRF 面。
  • ≤5 条/回合:超出 → 400 too_many_documents,且该判定先于任何文件系统访问。
  • 名非法 / 穿越 / 文件不存在 → 404 document_not_found;任一条不合规都让整个请求被拒——绝不「丢掉这条附件但照样起回合」(静默丢附件正是 design/24 §4.4 禁止的「传上去了但双方都不知道」)。WS 侧经 error 帧带 data.code 下发并中止本回合。
  • WS 上行帧仍有 4096 字节硬上限 ⇒ 现实中文档走 REST;两处共用同一份解析与校验,不会分叉。
  • 附件部件挂在同一条 user 消息上;纯附件回合(无文本、无图片)同样成立,不会被判成空回合。

④ DTO:documents 数组(历史与实时同形)

GET /sessions/{id}/messages、message_persisted、channel_persisted、message_edited、握手 history 回放的 historyMessage 上是同一个键(同一结构体):

"documents":[{"name":"a1b2….md","mime":"text/plain","size":12345,"chars":8000,"truncated":false,"status":"extracted"}]

读不到时(v0.63)多一个可选 reason:

"documents":[{"name":"a1b2….pdf","mime":"application/pdf","size":2048,"status":"unreadable","reason":"无法抽取正文:这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)"}]
  • ≤5 条,顺序 = 客户端发送顺序;0 个附件时该键省略(不是 [],与引入文档之前的形状一致)。
  • status ∈ extracted(正文已进上下文)/ listed(只给了模型一份附件清单,正文没进上下文)/ unreadable(读不到正文)。PDF(v0.61):文本层 PDF ⇒ extracted;扫描件/图片型 PDF(无文本层)⇒ 诚实拒绝 unreadable,原因文案含「这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)」——绝不假装成功、也绝不返回空正文;加密 PDF 与损坏/截断 PDF 是可区分的两类失败(加密的提示解除密码后重试)。
  • reason(v0.63,可选,omitempty)=读不到的原因文案,只在 status:"unreadable" 时下发(extracted / listed 时该键缺席,客户端按「缺失 ⇒ 回落现有通用句」实现即可)。它逐字就是服务端注入给模型的那份原因(同一字符串,两处同源),因此客户端不必自己拼「图片型 PDF,需要 OCR」这类服务端没说过的话。当前文案集合(服务端常量,逐字):
    • 无法抽取正文:这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)(扫描件/图片型 PDF,ErrNoTextLayer)
    • 无法抽取正文:PDF 已加密(无法用空口令打开),请解除密码后重新上传
    • 无法抽取正文:PDF 损坏或无法解析
    • 无法抽取正文:本期不支持 Word/Excel/PPT 解析(无文本层)(Office)
    • 无法抽取正文:内容不在可抽取的纯文本范围内(非文本/二进制)
    • 无法抽取正文:文档文件不存在或已被清理 / 无法抽取正文:读取文档失败 / 无法抽取正文:文档引用形态非法 / 无法抽取正文:文档上传目录未配置
    • 无法抽取正文:读取或解析失败(解析器整条消息失败时的诚实兜底) ⚠️ 只放文案:不含落盘路径、不含凭据、不含存储名以外的内容;原始错误(可能带路径)只进服务端日志,绝不透出(有端到端用例用「独特临时目录名」做反证断言)。 ⚠️ 时点:原因与 status/chars/truncated 同批,由回合结束后的回写写进 document 列(S3.5)——因此它只在读取面(GET /sessions/{id}/messages、握手回放)可见;message_persisted 帧在用户消息落库时就发出(那时还没有结论,这与 status 的既有行为一致)。
  • ⚠️ chars / truncated 是「本回合注入侧」事实:chars = 本回合注入模型上下文的字符数、truncated = 注入是否被截断。它不是「文件有多少字」、也不是「文件是否被截断」——全文长度只有 document-read 的 totalChars 能看到;「抽取期撞上 8 MiB 安全上限」只有 extractTruncated 能看到。
  • 不含正文(结构上保证:正文若从历史回传,每翻一页都要背着它)。
  • ⚠️ 破坏性提示:旧文档里出现过单数 document(对象)的形状,但那个形状从未随任何已发布契约版本号发布过(客户端无从实现)⇒ 本版不留兼容期——客户端按 documents(数组、可缺失)读取即可。

⑤ 内置工具 document-read(模型侧工具;客户端一般只需知道它存在)

  • 注入被截断时,模型可见的清单行给出续读游标(offset=N),模型用 {name, offset?, limit?} 继续读;
  • 返回(JSON 文本):{name, status, offset, limit, totalChars, truncated, nextOffset, extractTruncated?, text};
    • limit 默认 8000 字符、上限 64K 字符(≤0 按缺省、超上限夹取);
    • offset 单位是字符(rune)、0 基、起点 = sidecar 正文(含 CSV/TSV 摘要行)第 0 个字符;
    • offset < 0 或 offset > totalChars → 工具错误(offset == totalChars 合法,读到末尾的空片段);truncated=true 时 nextOffset 是续读起点;
    • extractTruncated=true 专指「抽取期撞上 8 MiB sidecar 安全上限」——此后「正文到此为止」≠「原文件到此为止」(它是与「本回合注入被截断」完全不同的两件事)。
  • PDF 文本层(v0.61,切片 B1):文本层 PDF 抽正文(页内容流里的 Tj/TJ/'/" 文本算子,页与页之间以换行相接),与纯文本类走同一条 sidecar / 注入路径;扫描件/图片型 PDF ⇒ unreadable(见上 ④,需要 OCR,当前未做);加密与损坏/截断各自成类、可区分。安全阀:单份最多抽 5000 页、sidecar 正文上限仍是 8 MiB(撞上即 truncated:true)。⚠️ 客户端可见的一次性重算:抽取器版本内部升到 documents/v3 ⇒ 旧 sidecar 一次性失效重抽,同一份旧附件的 chars/truncated 结论会在某次回合之后重新计算(原先被诚实拒为 unreadable 的旧 PDF,若是文本层 PDF 就会变成 extracted)——这不是附件被改写,是结论被重算。
  • 只读 sidecar、绝不重抽:读取不会打乱 status/chars/truncated 的持久统计;原文件在抽取之后被删除也不影响读取。
  • 策略默认 owner-only(tool_policies 无行时 fail-closed):群聊/陌生人根本调不到——是安全增益。
  • 失败必须可见:名非法 / 没有抽取结果 / 抽取结论为 unreadable → 明确报错,绝不返回空正文假装成功。

⑥ 与图片链路的边界(一张表)

面 图片(B1) 文档(v0.59)
上传 POST /api/v1/upload,字段 file,5 MiB POST /api/v1/documents,字段 file,20 MiB(CHAT_DOCUMENT_MAX_BYTES)
读侧鉴权 无(128-bit 随机名即能力 URL) 必须 Bearer(owner|admin)
读侧响应 按扩展名回 image MIME,可内联 Content-Disposition: attachment + nosniff,永不内联
响应 url 绝对同源 URL 相对路径
客户端字段 chat.image(base64 / URL) chat.documents[].name(服务端存储名)
去重 不适用(每次新随机名) 内容寻址:同字节同扩展名 → 同一 name

发现的不一致(如实登记,2026-10-04 复核):design/24 §9.2 与 §9.13 写「白名单 22 项——原文的 23 项是笔误,以代码为准」,但 B1(commit f82c76d)纳入 .pdf 后代码已是 23 项(internal/runtime/documents/store.go 的 ExtAllowed = 22 个文本类 + .pdf;internal/services/gateway/documents.go 的名字形态校验经它判定)——以代码的 23 项为准;design/24 §9.2/§9.13/§9.4 第 1 条(「不引解析库」)均已滞后于 B1 实现(PDF 文本层已用纯 Go 解析库落地;扫描件与 Office 仍诚实拒绝),本次只改 Docs、未改 DevDocs。另:design/24 §6.2 的 document_unreadable(400) 在代码里没有对应 REST 错误码,它只以 document-read 的工具错误形态存在(见本节 ⑤),客户端可在 DTO 里看到该文档的 documents[].status = "unreadable"。

契约版本 v0.65(忙时并行回合基础管线(I1′ 有条件放宽)+ 六条登记,依据 design/15 §2.5(主人 2026-10-05 裁决 B)与 commit 89e13cf,并一并登记 679cee0(IM 入站附件引用落库)/ 59a1ac4(llm_usage.session_id 接线)/ a918819(IM 各渠道语音可被读到)与 cb564fe / 15181e7 / 7d2ff74(纯内部修,无契约变化),2026-10-05 落实)。一句话:会话正忙时,客户端可显式声明把新消息作为独立一路立即起(单会话默认 2 路、硬上限 3、<2 = 关闭真并行),忙态帧因此开始报「在途路数」;同版把三条过期陈述与两条 IM 链路事实拉回与代码同步。三档标注:纯增量 = ① 上行 chat.parallel?:bool(WS chat 帧 + REST 三入口同形)、② session_busy / session_idle / pending_drain / session_interrupted 的 data 增 activeTurns:int、③ session_interrupted.data 增 cancelledTurns:int、④ pending_ack.data 增可选 reason:"parallel_limit"、⑤ REST 两条新应答形状(同步 202 {data:{parallel:true,turnId}} / stream:true 的 200 {data:{stream:true,parallel:true,turnId}})、⑥ 新 env 键 VOICE_STT_TIMEOUT_SEC(默认 20s);行为变更 = ⑦ IM 入站附件引用真的写进落库的用户消息(documents 列,同一 DTO、无新字段)、⑧ IM 各渠道语音进上下文(云端 ASR 转写为文本;不成功时落一行中文诚实提示,绝不静默/空消息)、⑨ session_idle 时点收紧为「所有在途路收尾且 pending 空」(仅并行时生效,单路逐字不变);破坏性 = 无(省略 parallel / 不读新键的客户端行为逐字不变)。A. chat.parallel——POST /api/v1/chat body 与 WS chat 帧 data 同形新增可选 parallel:bool(omitempty),语义 = 「把本条作为独立一路处理」;只在会话已有在途回合、且并发额度未满时照准,空闲时无效果(照常起第 1 路),服务端不做任何启发式自动升级(触发权在客户端,design/15 §2.5.1)。B. activeTurns / cancelledTurns——均纯增量整数、落在既有「状态机信号」白名单帧内(不含内容,所有已认证连接含 viewer 都收得到);activeTurns = 该会话当前在途路数(含正在收尾的路;单路时非 0 即 1),cancelledTurns = 本次真的取消了几路。C. pending_ack.reason——只在「客户端显式声明了并行、本条却落回排队」时出现(额度已满,或真并行被 <2 关掉——客户端问了并行而没拿到),其它情况该键完全缺席(绝不伪造)。D. REST 新应答形状——只在 parallel:true 且被照准时才可能出现;turnId 就是那一路的权威回合标识、与该路全部流帧同值;未照准(未声明 / 空闲 / 额度满)时形状与既有逐字相同。E. interrupt 多路口径——scope:"current" 省略 turnId 时取消的是**「当前全部在途路」(不是只停一路);scope:"all" 取消全部在途路 + 清队列、且不发 session_idle(与今天一致,客户端靠 session_interrupted 收口);interrupt.turnId(精确停某一路)本版未实现。F. turnId 生成方变更——由「网关每回合内部新生成」改为「编排器生成 → loop 复用」;帧形状与取值分布不变(单路仍是每回合全新的 turn-<hex>)⇒ 对外无可见差异,写在此处只为让「谁生成」的口径与实现一致。G. IM 附件引用落库(679cee0)——此前 IM 路径只把引用挂在内存里的模型可见消息上、落库那一步没写 documents 列 ⇒ 客户端读到的那条消息一片空白**(安卓报的 P0);现在复用 history.EncodeDocuments 同一口径写该列,并在回合结束后按 id 回写抽取结论。契约零扩张:无新端点 / 帧 / 字段;0 个附件 ⇒ 键省略;形状即 Web 的同一个结构体。H. IM 语音(a918819)——入站语音在流水线最前端归一为文本:平台自带转写优先(微信 iLink 非空 ⇒ 不调 ASR,不重复付费),否则走云端 ASR;降级绝不静默——静默档位被识破(只认 STTOutcome.Transcribed()),鉴权失败 / 欠费 / 限流 / 超时按原因分类并做临时熔断 + 退避(TTL 恢复),无论是否熔断,消息面都落一行中文诚实提示。新 env 键 VOICE_STT_TIMEOUT_SEC(默认 20 秒;≤0 用默认):只约束 IM 渠道入站语音的这一次转写(流水线要为它一直举着一个入站并发槽),Web 客户端的 voice_input 不受它约束;无新端点 / 新帧。I. llm_usage.session_id 修正(59a1ac4)——v0.64 的「恒为 NULL」已过期:loop 在 Run 顶部把会话 id 播种进 ctx,适配器落库时带上它(空串仍映射 NULL);但 GET /admin/llm/usage 仍不按 session_id 过滤 / 分组,响应形状不变。J. 未实现 / 预留(写明,别当已有)——新帧 turn_begin、steer_ack 的帧壳 turnId、interrupt.turnId、chat_messages.turn_id 列与 DTO 键、graph_update 按路分桶、写类工具文件级互斥(tool/legfile.go 的 FileClaimer / ErrFileBusy / WithLegID 只定死形状、无实现无接线)、第二路进行态 system 摘要(WithParallelNotice)均未实现。K. env CHAT_MAX_PARALLEL_TURNS:未接线(如实)——Deps.MaxParallelTurns 是编排器构造期旋钮,但组合根尚未从任何 env 读它 ⇒ 生产恒为默认 2(.env 里配了不生效),本版不登记为可用旋钮。详见 §5 变更记录与 §3.2。

上一版 v0.64(LLM 用量落库 + 只读汇总端点 + 面板用量小卡,依据主人 2026-10-04 夜裁决「2.4 落」(在「usage 落库」「只看日志」之间选前者)与 T1(commit 72b95df,已在每次模型调用后记结构化用量日志但只落日志)。一句话:把那条日志里的非敏感样本落进新表 llm_usage(迁移 0017),并新增一个只读汇总端点与一张面板小卡 —— 从此看得见趋势与按用途/模型的成本,而写入绝不阻塞模型调用。三档标注:纯增量 = 新端点 GET /api/v1/admin/llm/usage、新迁移 0017_llm_usage、新 env 键 LLM_USAGE_RETENTION(默认 720h,≤0 = 关闭清理)、面板 LLM 页新小卡、前端新 API adminLlmUsage();行为变更 = 无(既有端点 / 帧 / 字段形状一字未动;model 层新增的是可选 sink,不注入时行为逐字不变——既有 model 用例零改动即全绿);破坏性 = 无。A. 写入(关键取舍):model 层不直接开库,只在既有的 logUsage 处多一个可选接口 model.UsageSink(nil = 只记日志,逐字等于引入本版之前);组合根注入 model.UsageWriter —— 有界队列(1024)+ 单写者 + 批量事务(128/批,2s 兜底),队列满丢弃并限流 WARN(30s 一条),绝不阻塞、绝不 panic、绝不无界增长;整批落库失败只记一条带来源的 WARN。sink 挂在 RuntimeLLM 上(而不是每个适配器上),因此面板每次热重载 models.json 重建适配器时,落库不会静默断一次;每一条链(主/备/本地第三级/按角色跨协议)都带上它。B. 表与索引:llm_usage(id/session_id/purpose/adapter/model/prompt_tokens/completion_tokens/cache_hit_tokens/cache_miss_tokens/reasoning_tokens/total_tokens/took_ms/created_at)+ idx_llm_usage_created_at(汇总与清理的依赖)+ idx_llm_usage_session_id。session_id 当前恒为 NULL(模型层无会话概念;要填需 loop 在请求上打标,属本轮并行切片在途文件,故留列后接)。⚠️ 该状态已由 v0.65 更正:loop 已把会话 id 播种进运行 ctx、适配器落库时带上它(见 §1.2 与 §5 v0.65 条目 I)——该列不再恒为 NULL。C. 读取:GET /api/v1/admin/llm/usage?since=&until=&purpose=(owner|admin 读,与同页 /admin/llm 同门;不审计——它是查询不是动作)→ {data:{window:{since,until,purpose},totals:{calls,prompt,completion,cacheHit,cacheMiss,hitRate},byPurpose:[…],byModel:[…]}};缺省窗口 = 近 24h,跨度上限 90 天(超过 → 400,不静默裁剪);since/until 接受 RFC3339 或 Unix 毫秒;hitRate = cacheHit/(cacheHit+cacheMiss),分母 0 ⇒ 0(口径已写进文档与前端展示规则:分母 0 显示 —,不显示 0%);分解按调用次数降序、同次数按键升序;绝不含凭据与消息正文。D. 保留期:LLM_USAGE_RETENTION(默认 720h = 30 天;≤0 = 关闭清理,日志写明)→ 启动期清一次 + 每小时清一次(复用文档 GC 的既有模式;清扫间隔不做成 env 键)。E. 面板:LLM 页新增「用量(近 24h)」小卡(frontend/web/src/admin/modules/llm/LlmUsageCard.tsx)——调用数 / 输入 token / 缓存命中率;取数与其它卡片同一条 useAsyncData 加载/错误态(读不到显示错误,绝不显示 0)。F. 失败不伪装:汇总出口未接线或查库失败 → 500 usage_unavailable,不回全 0 假汇总。详见 §2「LLM 用量汇总契约(v0.64)」。

上一版 v0.63(客户端登记两条落地:documents[] 增可选 reason + GET /sessions/{id}/messages 对不存在的会话回 404 session_not_found,依据 Cyrene-For-Android/devdocs/02-前后端协同事项.md 的「登记(客户端会话,2026-10-05 凌晨 · 跟上 v0.61 的 .pdf 之后的两条)」与「登记(… · 一条 API 语义建议:已删除的会话「看起来像空会话」)」两条,以及后端「回执九」(2026-10-05 凌晨)的选择与承诺,2026-10-05 落实)。三档标注:纯增量 = ① documents[] 元素增可选 reason(omitempty,只在 status:"unreadable" 时下发;不读该键的老客户端行为逐字不变);行为变更 = ② GET /api/v1/sessions/{id}/messages 对不存在 / 已删除的会话由 200 {"data":[]} 改为 404 {"error":{"code":"session_not_found"}}(客户端需处理 404 —— 回执九已告知,客户端此前先用自建兜底);破坏性 = 无(既有字段形状、既有成功响应一字未动)。详见 §1 行 6、§1.2 ④ 与 §5 变更记录。

上一版 v0.62(面板「重置为 env 默认」动作落地:materialize 的反方向显式动作 + 一个新的 owner-only 管理写端点,依据任务书 DevDocs/_dispatch/R1-面板重置为env默认.md、design/04 §3.1(.env 为默认/播种层、面板文件为一等来源)与 DevDocs/开发时间线.md 的欠账项(「Materialize 已导出、未接端点」),2026-10-04 落实)。一句话:materialize 是「面板缺该键才用 .env 补」,本版新增的 POST /api/v1/admin/llm/reset-from-env 是反方向的显式动作——部署者要求「把面板里 .env 声明过的键退回 env 值」。A. 语义(与补齐表同源):只覆盖 .env 声明了非空值的键(provider/baseUrl/apiKey/model/contextWindow)、env 未声明的键一律保留面板值(fallback/multimodal/maxTokens/reasoning/personaModels 永不被改)、组集合不增减(不复活被删的组、不删新增的组,顺序也不动)、面板值已与 env 一致的键不算「改回」、无键可改时不写盘(幂等);真的改了盘就热重载运行中的 LLM 栈(contextWindow 的预算推导仍只在启动时算一次)。B. 护栏 / 响应 / 审计:owner-only(与同页两条 LLM 写端点同门;破坏性写不比同族更宽),body 必须 {"confirm":true}(缺失 / false / 非法 body ⇒ 400 bad_request 且一个字节都不动)→ 200 {data:{reset:[{group,keys:[…]}], skipped:[{group,reason}], path}};只回键名与中文原因,凭据值(尤其 apiKey)绝不回显、绝不进日志;审计动作 llm.resetFromEnv(ok/error)。C. 基准与失败:对照基准 = 请求时刻按既有解析入口读到的 .env 默认层(新增 config.LoadLLMDefaults,路径经 Deps.EnvPath 注入,网关绝不 fopen .env);.env 源不可读 → 500 env_defaults_unreadable、面板配置不存在/不可解析 → 500 models_unreadable、写盘失败 → 500 llm_persist_failed、已写盘但热重载失败 → 500 llm_reload_failed、LLM 栈未注入 → 500 llm_unavailable——都不假装成功。D. 面板:LLM 页新增「重置为 env 默认」卡片(admin/modules/llm/LlmResetFromEnv.tsx,行上标 owner 专属,中文二次确认后才提交)。E. 兼容性:纯增量(新端点 + 新前端卡片 + 新审计动作 + 2 个新错误码 env_defaults_unreadable / models_unreadable,另复用既有 llm_persist_failed / llm_reload_failed / llm_unavailable),既有端点 / WS 帧 / 字段形状一字未动;管理侧权限矩阵因此为 12(viewer 只读)+ 10(owner/admin 读)+ 38(owner-only)= 60 条路由。详见 §2「重置为 env 默认契约(v0.62)」。上一版 v0.61(PDF 文本层抽取落地:白名单第 23 项 .pdf + 类型裁决由「嗅探只放行 text/plain」改为 (扩展名, 嗅探) 成对裁决,依据 design/24 §7 的 S6 第一段(PDF/Office 的裁决:PDF 文本层引库,扫描件与 Office 仍诚实拒绝)与 commit f82c76d,2026-10-04 落实)。三档标注:行为变更 = ① .pdf 由「一律 415」变为「可上传 + 可读取 + 文本层可抽取进上下文」(把「.pdf 必被拒」写死的客户端必须改);② 类型裁决收紧为成对判定(documents.ContentAllowed)——方向是收紧/细化,不是放宽:HTML/纯文本改名成 .pdf ⇒ 415(嗅探不是 PDF),PDF 字节改名成 .txt ⇒ 415(PDF 字节也只收在 .pdf 名下);纯增量 = 扩展名白名单 22 → 23 项(唯一新增 .pdf),其余 22 个文本类的判定与既有响应一字未变。A. 上传(POST /api/v1/documents,owner|admin)——白名单加 .pdf;裁决 = 字节嗅探是唯一裁决权 ∧ 扩展名白名单,自本版起按对判定(纯文本类必须 text/plain、.pdf 必须 application/pdf;落点 internal/runtime/documents/store.go 的 ExtAllowed(23 项)/ ContentAllowed);mime 仍是嗅探基类型(真 PDF → application/pdf);415 文案同时点明两条口径。B. 读取(GET /api/v1/documents/{name},必带 Bearer owner|admin)——真 PDF 如实下发 Content-Type: application/pdf(读时按同一对裁决重嗅探,判不定者仍退 application/octet-stream),Content-Disposition: attachment + X-Content-Type-Options: nosniff 一字未变 ⇒ PDF 永不被内联渲染(与图片读侧刻意相反)。C. 抽取语义(DTO status)——文本层 PDF 抽正文(Tj/TJ/'/" 文本算子,页间以换行相接)⇒ status:"extracted",与纯文本类走同一条 sidecar/注入路径;扫描件/图片型 PDF ⇒ 诚实拒绝 status:"unreadable",原因含「这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)」,绝不假装成功、也不返回空正文;加密与损坏/截断是可区分的两类失败;安全阀 = 单份最多 5000 页 + sidecar 正文 ≤8 MiB。D. 客户端可见的一次性重算——抽取器版本内部升到 documents/v3 ⇒ 旧 sidecar 一次性失效重抽:同一份旧附件的 chars/truncated 结论会在某次回合之后重新计算(旧 PDF 若是文本层 PDF,将从 unreadable 变为 extracted)。E. 未做(诚实边界)——不做 OCR / 扫描件栅格化(纯 Go 无可靠实现,会撞「单二进制 + 交叉编译」「零跨进程 IPC」两条红线)、不做 PDF 写/生成(只读)、不做 .docx/.xlsx 等 Office(仍诚实拒绝);无新端点、无新 WS 帧、无鉴权变更、无 .env 新增键。实现落点:internal/runtime/documents/{extract_pdf.go,extract.go,store.go,sidecar.go} + 依赖 github.com/ledongthuc/pdf(纯 Go、无 cgo)。上一版 v0.60(管理侧只读「存储状态」端点 + 面板存储卡接真数据,依据主人 2026-10-04 实测反馈(面板首页「存储」卡一直是占位「SQLite」)与 design/10 §3 模块 14(系统设置)/ design/03 §3(pgvector 可选增强),2026-10-04 落实):A. 新增 GET /api/v1/admin/system/storage(owner-only、只读、无审计动作)→ 200 {data:{main,documents,vector}}——① main = {kind:"sqlite",path,fileBytes,walBytes,shmBytes,pageSize,pageCount,freelistPages,freeBytes}(path 与备份/恢复端点同源;walBytes/shmBytes 边车不存在时如实为 0;freeBytes = pageSize × freelistPages);② documents = {count,bytes}(读 documents 表,不 walk 目录;空表 bytes 为 0 而非 null);③ vector = {kind:"pgvector"|"none",enabled,ready}——kind 说「配没配 DB_PG_URL」,ready 说「现在真的在跑什么」(配了但连接/schema 准备失败 ⇒ enabled:true + ready:false,客户端显示「已配置但未就绪(检索已降级)」,别把 enabled 当生效)。B. 两条纪律:① 不含凭据——DSN / 用户名 / 密码 / postgres:// 全文一个字都不回(vector 段只有三个键;有用例断言响应体里搜不到这些串);② 只读——只有 SELECT/PRAGMA + os.Stat,复用组合根注入的活连接(不新开连接池、不写、不迁移、不备份,调用前后 documents 行数与数据目录文件集合均不变)。C. vector 的真源:组合根 cmd/cyrene/wire_frontend.go 的 app.vectorState() —— Configured 取 DB_PG_URL 非空、Live 取活索引的具体类型(*vector.PGVectorIndex);网关只读这份注入值,不读环境变量、不新开 PG 连接、不自行探测(避免面板与运行时给出两个答案)。D. 失败不伪装:未接活连接 / 路径未配置 / 读不到页级统计或 documents 表(例如库没跑迁移)⇒ 500 {error:{code:"storage_unavailable",message}},绝不回一个全 0 的假快照。E. 面板:frontend/web 的存储卡改为真数据(value = SQLite 12.3 MiB;hint = WAL / 页数 / 空闲页;详情两行 = documents N 件 · … 与 PG 增强 …),取数走与其它已接线卡片同一条加载/错误态(数据没到就显示「端点报错:…」,不回退成 0)。F. 兼容性:纯新增端点 + 纯新增前端行为,既有端点 / WS 帧 / 字段形状一字未动(前端 MiniMetricSpec 新增的 extra?: string[] 为可选补充详情行,不传时渲染与旧版逐字等价)。上一版 v0.59:(文档进对话(F1/F2)+ 上下文预算按窗口推导(T1)+ 语音回合进编排(G5/C1/C3)+ thinking 可达性口径,依据 design/24 §3/§4/§6(切片 S1–S5)、design/04 §1.3 + 附录 A.3(主人裁决 2026-10-04)与 Cyrene-For-Android/devdocs/02-前后端协同事项.md 的 G5/C1/C3,commit a8851dc / a4789ec / 20c1edb / 5b2b385 / 72b95df / 3a01b4e,2026-10-04 落实):A. 文档存储与摄取(两个新端点 + 一个可选请求字段 = 纯增量;DTO 形状变更是破坏性,但单数形状从未发布,故无兼容期)——① 新增 POST /api/v1/documents(owner|admin,multipart/form-data,字段 file)→ 首次 201 {data:{name,url,mime,size,sha256,deduped:false}};同字节同扩展名重复上传 → 200 + 同一 name + deduped:true(客户端可当成功处理);单文件上限 CHAT_DOCUMENT_MAX_BYTES(默认 20 MiB = 20971520),≤0 视为用默认;它与图片上限 CHAT_IMAGE_MAX_BYTES 刻意解耦(改文档上限不会静默改掉图片行为)。② 新增 GET /api/v1/documents/{name}:必须带 Bearer(owner|admin)——与图片读侧(免鉴权的能力 URL)刻意相反(文档摄取在服务端完成,不需要被 <img>/provider 直接消费),响应带 Content-Disposition: attachment + X-Content-Type-Options: nosniff(浏览器永不内联渲染)。③ 类型裁决(重要):字节嗅探是唯一裁决者(http.DetectContentType;v0.59 时放行的嗅探基类型只有 text/plain,v0.61 起改为 (扩展名, 嗅探) 成对裁决、.pdf 走 application/pdf),客户端声明的 Content-Type 与原始文件名只作参考、可被嗅探否决;扩展名白名单 deny-by-default,实际 22 个(⚠️ 历史值:v0.61 起为 23 项,纳入 .pdf):.txt .md .markdown .csv .tsv .json .jsonl .yaml .yml .log .ini .cfg .toml .go .ts .tsx .java .c .h .cpp .rs .sql(⚠️ design/24 §9.2 写「23 项」但当时所列清单与代码均为 22 项,以代码 22 为准、并已在 v0.61 变为 23 项,见 §1.2「发现的不一致」);明令拒绝 text/html、SVG/XML、zip 及其封装(docx/xlsx)、可执行/脚本(exe/dll/bat/cmd/ps1/js/mjs/sh/py)——即便 .js/.py 嗅探成 text/plain 也拒(判定 = 扩展名 ∧ 嗅探的合取)。④ 边界(勿与图片混用):url 是相对路径(/api/v1/documents/<32hex>.<ext>;图片侧是绝对 URL);mime 是嗅探所得基类型(故 data.json 返回 text/plain,不是 application/json);落盘名 = sha256(bytes)[:32] + ext(内容寻址,原始文件名永不参与路径 ⇒ 同字节同扩展名天然同名去重)。⑤ 4 个错误码:unsupported_media_type(415) / document_too_large(413) / document_not_found(404,含名字形态非法与路径穿越) / too_many_documents(400)。⑥ chat 请求新增可选 "documents":[{"name":"<32hex>.<ext>"}](REST 同步 / REST stream:true / WS chat 三处同形):≤5 条(超出 too_many_documents 400,且先于任何文件系统访问);只收服务端存储名、绝不收 URL(不重开 F3 那种「服务端代取 URL」的 SSRF 面);客户端多带的 mime/url/size 一律被服务端覆盖(MIME/大小取自落盘字节);名非法/穿越/不存在 → document_not_found(404);任一条不合规都让整个请求被拒,绝不「丢掉这条附件但照样起回合」(WS 侧经 error 帧带 data.code 下发并中止本回合,REST 侧回对应状态码)。⑦ 响应 / 历史 DTO 由 document(单对象)改为 documents(数组,≤5)——元素形状 {"name","mime","size","chars","truncated","status"},status ∈ extracted|listed|unreadable;0 个附件时该键省略;同一 DTO 供 GET /sessions/{id}/messages、message_persisted、message_edited、WS 握手回放 四处同时生效(同一个 historyMessage 结构)。⚠️ 原单数形状从未发布过(不在任何已发布版本号里)⇒ 无兼容期,直接换数组(design/24 §6.1 那句 "document":{…} 已随之作废)。⚠️ chars/truncated 改为「本回合注入侧」语义:chars = 本回合注入模型上下文的字符数、truncated = 注入是否被截断(不是「原文件是否被截断」);全文长度只有 document-read 的 totalChars 能看到。⑧ 内置工具 document-read(模型侧工具,客户端一般只需知道它存在):{name, offset?, limit?} → {name,status,offset,limit,totalChars,truncated,nextOffset,text,extractTruncated?};limit 默认 8000 字符、上限 64K;offset 单位是字符(rune)、0 基、起点 = sidecar 正文(含 CSV/TSV 摘要行)第 0 个字符;offset<0 或 >totalChars → 错误(==totalChars 合法,读到末尾空片段);extractTruncated=true 专指「撞上 8 MiB sidecar 安全上限」(≠ 本回合注入被截断);策略默认 owner-only(群聊/陌生人天然读不到主人的文件)。B. 配置面(纯增量 + 一处语义变更)——① models.json 组级新增 contextWindow(正整数 = 该组模型的上下文窗口;0/省略 = 未声明 ⇒ 后端按 131072 保守估算并记恰好一条 WARN;负数 = 组校验失败,写端点回 400)。 ② .env 新增/变更 4 键:LLM_CONTEXT_WINDOW(per-group 播种,等价于 models.json 的 contextWindow)、CONTEXT_BUDGET_RATIO(默认 2/3,接受 2/3 或 0.6667,非法值记 WARN 后回落)、DOCUMENT_MAX_INJECT_CHARS(单份附件注入上下文的字符上限;未设 = 按推导出的预算自动算)、CONTEXT_MAX_TOKENS 语义变更(关键):未设置 ⇒ 按窗口推导(budget = min(窗口 × 比例, 窗口 − 输出保留),输出保留 = max(32768, 该组 maxTokens) 且封顶 窗口/2;1M 窗口 ⇒ 699050 tokens);>0 = 显式覆盖(原样使用);0/负 = 关闭压缩。⇒ .env 里若仍写着 CONTEXT_MAX_TOKENS=32768,它就是显式覆盖、推导不生效(这是设计语义,不是 bug;本次已把 .env.example 里该行改成注释形式)。③ .env → 面板配置的 materialize 语义(欠账,本次补登记):进程每次启动跑一次;面板配置非空 ⇒ 组集合完全由面板决定(env 不新增、不复活被面板删掉的组);键级只在面板缺该键时用 env 补,面板已有的键(含显式空值 / 显式 false / 显式 null)绝不覆盖;面板配置缺失/为空时才由 env 播种出合成组;「面板改一次、重启被打回」是硬约束(有专门用例钉住);每次启动记「补了哪些 / 跳了哪些」,凭据绝不进日志;范围目前仅 models.json 一族(channels.json / plugins.json 未纳入)。④ 面板读写面:GET /api/v1/admin/llm 的组 DTO 含 contextWindow(omitempty,与 maxTokens 同形:未声明时该键省略),POST /api/v1/admin/llm 接受可选 contextWindow——缺失 = 保留已存窗口、显式 0 与缺失同义(都 = 未声明)、负值 400 bad_request 且不落盘;面板 LLM 页据此显示/编辑该键。C. 语音(有行为变更,见 §3.15)——语音轮次现在走与文本同一条路径:会落库(用户消息 = 转写文本,助手消息 = 回复)、会发 message_persisted 与流式帧、跨端同步、忙时按编排器既有规则排队(pending_ack/pending_drain/session_busy 语义零变化,只是多了一个消费者);deviceId 照旧落库、platform 走存储默认 web;voice_input 上行增可选 clientMsgId(与 chat 帧同形 ⇒ 语音也有幂等;重复到达回既有 chat_duplicate,不产生新回合、不发音频);voice_output(定向)载荷增 sessionId(恒有)与 deviceId(非空才有);空正文与 TTS 失败/降级都走既有 provider 降级信号(客户端据此提示「没生成声音」;不再把 stopReason 当语音内容);时点:voice_output 在该回合结算时才到(可能晚于若干状态帧,甚至由续跑回合或被 steer 注入的那一回合产生);定向目标是发起 voice_input 的那条连接本身(服务端不按 deviceId 路由);连接断开后不补投,客户端重连后带上同一 clientMsgId 可安全重试。D. G5(纯增量:广播帧 +2 个可选键;新增一条定向错误帧,见 §3.2)——① pending_full 广播载荷增 clientMsgId? / messageId?:前者 = 被拒那条消息的幂等键(未携带则整个键缺席);后者仅当该消息此前已落库/已认领时才回填——服务端绝不在此处新铸 id(队列满的拒绝发生在幂等键认领之前,代铸会让客户端重试被判成「重复到达」而永远进不来)。② 新增定向帧(此前无文档登记):{type:"error", data:{code:"chat_busy", message, sessionId, clientMsgId?, messageId?}},只发发送者那条连接、不广播;code 与 REST 409 chat_busy 同码(客户端一个 code 覆盖两条入口)。E. E3:thinking 可达性口径(不改行为,只是把话说清)——消息体的 thinking 字段在实时帧(message_persisted / channel_persisted / message_edited)与握手回放中一律为空(隐私与带宽的有意默认;实时思维链仍由 §3.1 的 thinking.* 流帧按 owner/admin 档下发);历史消息的思维链只有 GET /sessions/{id}/messages(owner|admin)返回。上一版 v0.58:(新增 .env 键 LLM_MULTIMODAL(bool,默认 false):让 env-only 部署能声明「主模型自身能否收图」,依据裁决 14.4.1 / design/06 §0(commit 4adfc9a),2026-10-04 落实):A. 新键语义(见 §2「模型配置契约」表)——LLM_MULTIMODAL 是 models.json 组 multimodal 的 env 等价物,只作用于 .env 合成组(无有效 models.json 时才生效;有组文件时以该组的 multimodal 为准,与 LLM_REASONING_KIND 同口径):true = 图片直进主模型上下文(省掉工具那一轮模型调用、也不做有损文本摘要),留空 / false(默认)= 把图交给 vision/OCR 工具换成文本描述;绝不凭 base URL / 模型名猜测端点是否收图(声称端点不具备的能力,比把图交给工具更糟——工具至少回一段文本,design/06 §0 首条)。B. 与 LLM_VISION_MODEL / LLM_OCR_MODEL 的关系——后两者配的是视觉/OCR 工具调用的模型,不代表主模型能收图,与本键互不代表(见 §1 行 3)。C. 兼容性(默认值即旧行为)——默认 false ⇒ 老部署行为逐字不变(引入本键之前,env-only 部署没有任何键能声明该能力,图片恒走 vision 工具);非 bool 值(如 maybe)在配置解码阶段直接报错、启动失败(fail-fast,不静默回退成 false——静默的「配了当没配」正是本键要消灭的)。LLM_MULTIMODAL=true 后图片走主模型直收,属部署者显式选择(形状由部署者声明,后端不替它猜)。D. 实现落点——internal/config/config.go(LLMConfig.Multimodal,默认 false)+ internal/config/runtimeconfig/models.go(GroupFromEnv 把两 provider 分叉都透传进合成组)+ internal/config/runtimeconfig/multimodal_env_test.go(默认 false / 显式 true 抵达合成组 / 进程 env 端到端 / 组文件优先 四组测试)。无端点、无帧、无字段形状变更(纯新增 env 键)。上一版 v0.57:(13.4.5 目标态 steer 落地登记:新下行帧 steer_ack,依据 design/15 §2.0/§2.1/§2.4/§3.x/§5(裁决 13.4.5 目标态,commit 3d8822c),2026-10-04 落实:A. 新下行帧 steer_ack(见 §3.14)——{type:"steer_ack", sessionId, data:{messageId, atStep}}:忙时会话里新到的用户消息被在飞回合吃进同一回合时下发(messageId = 那条用户消息的权威 id,atStep = 吃进它的模型步序号,1 基);语义=「该消息已被本回合吃进(已从 pending 移除)、尚未被正面回答」⇒ 客户端应把对应气泡渲染为「已读、未答」的灰态,直到该回合的 message.* / turn.end 到达(不需要第二个确认帧)。角色门:它携带具体消息身份(客户端能定位到某一条气泡,而不只是会话忙闲),因此不登记进 frameReachesAll 的 7 个状态机信号白名单,落 fail-closed 默认档——只发 owner + admin(viewer 收不到);经编排器状态通道由 hub 直接广播、不经 bus,帧壳带 sessionId、不带 turnId/seq。触发时机:只在回合内边界(loop 每一步开头:上一次模型步与它那一批工具调用都已结束、下一次模型调用尚未开始,此刻没有工具在飞)产生,且只有该边界真的匹配到 pending 里、且已落库的消息时才发——不是每个 step 都发(没可注入消息的边界一帧都不发,这是常态)。B. 转向的三条不变量(design/15 §2.0)——I1(同会话在途回合数恒为 1):转向只注入、不取消,绝不新开第二个回合(loop 侧结构性地没有任何「起新回合」的分支,注入只是把消息并进当前回合下一次模型调用的视图);I2(消息恰好被处理一次):注入=已消费——被注入的消息立刻从该会话 pending 移除(按 pending 序号认领),绝不被第二个回合再答一遍,去重键=后端 message.id,且候选必须同时满足「在该会话转录里(最近 20 条窗口内)」与「在本编排器的 pending 里」;I3(转向不取消已发起的工具):注入点只在回合内边界,此刻没有任何工具在飞,正在执行的工具永远跑完。C. 注入的范围(口径):注入只影响本轮视图(每一步边界重新并入),不写回运行窗口 ⇒ 既不进该回合的 checkpoint 快照、也不进后台记忆提取的输入面;被注入的消息本身早在到达时已落库(pending 路径即时持久化),注入不新增、也不改写任何行。D. 失败一律让路:未接转录存储 / 读转录失败 / 该消息尚未落库 / 本回合世代已被打断或被更新的回合取代(认领 pending 失败)时,不发 steer_ack 也不注入——该消息留在 pending,照旧作为自己的回合被回答,绝不静默消失。E. 另含一处链路图纯增量键(注入时该轮图的 input 节点带 props.steered:true,图仍只有一条主干——不是被画成第二轮),以及纯新增帧的兼容性口径(老客户端按未知帧忽略即可)。上一版 v0.56:(协同项 A2 / G1 / G2 / G3 / G4 / F3 落地登记:interrupt 支持 scope + REST stream:true 不再静默失败 + chat.image 上限 + clientMsgId 幂等 + 视觉链路 URL 取回与类型裁决 + 既有错误陈述更正,依据 Cyrene-For-Android/devdocs/02-前后端协同事项.md 的 A2 / G1 / G2 / G3 / G4 / F3(commit 7512d59 等,2026-10-04 落实):A. 上行 interrupt 新增可选 scope(A2)——{sessionId} → {sessionId, scope?},取值域 {"", "all", "current"}(省略 / 空串 / "all" 三者同义):all = 取消当前任务 + 清空待处理队列(逐字不变的现状语义);"current" = 只取消当前任务、保留待处理队列(队列消息按编排器正常续跑规则继续执行、带上会话尾部上下文);其余取值 → 只回发送者 {type:"error",data:{code:"bad_request"}}(定向:不广播、不打断任何会话,绝不静默按默认执行);命中空闲会话为 no-op。下行 session_interrupted 帧形状零变化({sessionId, mode, dropped}),但 dropped 恒等于真的被丢弃的待处理条数——scope:"current" 时为 0,且 mode 是该会话当时的忙碌通道(thinking|executing|subagent,不是 idle);scope:"all" 时 mode 为 idle、dropped 为清空的 pending 条数。B. REST stream:true 不再静默失败(G2,行为变更)——该分支改为与 WS chat 帧同一条直达编排器的路径(design/15 §2.1「会话消息不得被 drop」),响应面:受理成功后才回 200 {data:{stream:true}} / 正忙已续接排队 202 {data:{stream:true,queued:true,queueDepth:n}} / 队列满 409 {error:{code:"chat_busy"}} / 幂等键重复 200 {data:{duplicate:true,messageId,sessionId}};未接 loop → 500 {error:{code:"chat_unavailable"}}(旧文写 200 {stream:true},现与同步路径同口径);仅未接编排器才回退总线派发,其失败回 503 {error:{code:"chat_dispatch_failed"}}(此前 Publish 的错误被吞掉,且传的是请求作用域 ctx)。202/409 从此真的会出现(修复前因提前 return 200 永不出现)。派发用的回合上下文与请求作用域脱钩(context.WithoutCancel + 30 分钟上限),回合不会在客户端刚收到 200 时被连带取消。向后兼容:stream:false 与既有同步响应形状一字未动;依赖「任何情况都拿到 200」的客户端必须按上表分支,别再假设 stream:true 恒 200。C. chat.image 上限(G3)——POST /api/v1/chat 的 image 新增硬上限:解码后 5 MiB(与 POST /api/v1/upload 同口径;base64 字符串上限 = ceil(n/3)*4 + 64 字符,覆盖 data: URI 前缀),超限 → 400 {error:{code:"image_too_large", message}}(文案指路 POST /api/v1/upload),在任何解码 / 落库 / 入模型之前拦下(超大 base64 因此不进历史、也不会被重连回放重传)。事实(勿误读为「WS 也有这条 400」):WS chat 帧今天已被传输层关死——WS 读上限 wsReadLimit = 4096 字节/帧,超限直接断连,到不了这条判定 ⇒ WS 上发真实图片本就必须「上传 + URL」。D. clientMsgId 幂等(G4)——上行新增可选 clientMsgId(REST chat 体与 WS chat 帧都支持;省略 = 老行为逐字不变,老客户端零影响):同一会话内同一个非空值只承认第一条。重复到达:REST → 200 {data:{duplicate:true,messageId,sessionId}};WS → 新下行帧 chat_duplicate({type:"chat_duplicate", sessionId, data:{sessionId, clientMsgId, messageId}},见 §3.13);重复不落库、不起回合、不发任何内容 / 流式新帧。判重由 SQLite 部分唯一索引原子裁决((session_id, client_msg_id) WHERE client_msg_id != ''),并发的两次重发只有一个赢家,不存在「先查再插」的竞态窗口。clientMsgId 另回填进消息 DTO ⇒ message_persisted / channel_persisted / 握手 history 回放与 GET /sessions/{id}/messages 等读接口可见(空则省略),客户端据此把本地乐观气泡对齐到服务端真相(替代「靠文本相等」的脆弱匹配)。迁移:新增 0013_chat_client_msg_id(up:加列 + 部分唯一索引 idx_chat_messages_client_msg_id;down:先删索引再删列;全新库由 history.NewStore 幂等前滚)。E. 视觉链路事实修正(F3)——chat.image 的 http(s) URL(含 /api/v1/files/<name>)现在由服务端自己取回并内联为 data: URI 再交 provider,不再原样转给上游;同源 upload 产物优先走本地文件读取。类型裁决:字节嗅探是唯一裁决权(声明的 Content-Type / 扩展名只作参考且可被否决),嗅探为文本(HTML / PROXY 错误页 / SVG)或判不定且来源未声明 image/* → 明确报错(vision.ErrNotImage),绝不静默当 PNG。对客户端无端点 / 帧 / 字段变更。F. 修正既有错误陈述(G1)——Docs/api-reference.md §6.2 帧壳表与 §6.6 曾声称结构化流帧只带 seq、没有轮次标识,是错的:v0.36 起结构化流帧(message.* / thinking.* / tool_call.*)就带 sessionId + turnId(broadcastStream 从 model.StreamEvent 取,loop 在回合边界注入),v0.50 起另带 streamId;已按实现更正(与本文档 v0.36 条目一致)。G. 环境变量登记——.env.example 新增 OWNER_IDENTITY_INJECTION(owner_only 默认 / always / off;非法值回退 owner_only 并记 WARN),并登记 CONTEXT_MAX_TOKENS(默认 32768;0 / 负 = 关闭压缩)与 GUARDRAIL_KEYWORDS(逗号分隔;留空 = 不挂关键词护栏),三键见 §2「安全 / 上下文环境变量(v0.56)」。无新端点、无帧形状破坏(唯一新增帧 chat_duplicate 为纯新增;REST stream:true 的响应面属行为变更,兼容点已在 B 写明)。上一版 v0.55:(阶段 7 落地项登记:路径式安全入口 + 数据目录 platform + OneBot v11 四端 + 可观测实时推送,依据裁决 1.6 / 16.4.7 / 12.4.2(commit a398dc9)、1.4 / 12.4.1(commit 9253fef)、05.4.3 + 16.4.3-WS(commit 0e1f163),以及 13.4.5 面内三条(commit 74b12d8)、MCP 官方 SDK(commit a1083a3),2026-10-04 落实):A. 安全入口 ADMIN_PATH(裁决 1.6 / 16.4.7,前端路由面)——① env 新增 ADMIN_PATH(默认 admin):面板前端路由前缀 = / + 值(admin → /admin,xxx/admin → /xxx/admin;非法值回退 /admin——容忍首尾斜杠与空白,拒绝 ./.. 段与 ?/#)。② HTTP 行为:/<ADMIN_PATH> 与其深链返回注入了 <base href="/"> 与 window.__CYRENE_ADMIN_ENTRY__={"prefix":"..."} 的 index.html(Cache-Control: no-store);形如面板入口(路径里出现 admin 段)但前缀不符的路径 → 404(不回落用户端聊天壳、不泄露正确前缀);/<ADMIN_PATH> 若恰好命中 dist 真实文件则仍走文件服务器。③ API 不藏:/api/v1/*、/api/v1/admin/*、/ws/chat、webhook 一律原样,无新端点、无帧变更。④ 口径:用户端 HTML 永不携带该前缀串(后端只在命中前缀时注入)⇒ 用户端既无管理入口链接、也解析不出面板路由(design/07 §0.2;前端路由改路径式:frontend/web/src/admin/router.ts 只读注入、无硬编兜底、移除 hash 入口)。B. 数据目录(同 commit,裁决 12.4.2)——DATA_DIR 新增可接受值 platform(大小写不敏感)→ 平台标准目录;默认 ./data 与显式路径行为不变;显式空值行为不变(仍走原兜底)。C. OneBot v11 四端(9253fef,裁决 1.4 / 12.4.1)——① 配置面(channels.json 账号 config + 适配器 Config() 快照)新增 transport(ws|http,默认 ws,未知值回落 ws)/ api_base_url / report_path(默认 /);快照 camelCase 为 transport/apiBaseURL/reportPath;remoteURL 快照值 = remote_url、为空时回落 api_base_url(语义即「出站远端地址」),既有键与值不变。② env 兜底 CHANNEL_QQ_OBV11_TRANSPORT(默认 ws)/ CHANNEL_QQ_OBV11_API_BASE_URL / CHANNEL_QQ_OBV11_REPORT_PATH。③ 四端语义:ws+client Cyrene 拨号、收发同一 WS;ws+server OneBot 反连、收发同一 WS;http+client Cyrene→OneBot(POST {api_base_url}/{action},纯出站);http+server OneBot→Cyrene(POST 到 listen_addr+report_path,纯入站,fail-closed 鉴权、10MiB 体上限)。④ 取舍:mode 决定哪一半必需(server 必需 listen_addr、client 必需 api_base_url),另一半给齐字段就一并启用 ⇒ 单个适配器即可「收上报 + 回消息」。⑤ 端口例外:http+server 用 listen_addr,属 AGENTS.md §9 已登记的同一豁免(design/13 §0 原文即写「WS server / HTTP server」);listen_addr 为空则报错、绝不开端口。D. 可观测实时推送(0e1f163,裁决 05.4.3 + 16.4.3-WS)——新增一节 §3.12:三个下行新帧 observability_log({entries:[{time,level,source,msg}…]},最新在前,上限 200 条)/ observability_trace({spans:[…]},上限 100 条)/ observability_metrics(AdminMetrics 本体、扁平),均纯新增(既有帧名/形状零改动)、帧壳不带 sessionId/turnId/seq;三者不在 frameReachesAll 白名单 ⇒ fail-closed 只发 owner+admin(viewer 继续走 REST 只读档);订阅 = owner/admin 凭据读取 GET /admin/observability/{logs,metrics,trace} 任一个(该次读取照常返回完整引导数据),该凭据 WS 连接全断即退订并停循环(不是轮询、无常驻 goroutine);REST 端点完全不变,?level= 分级过滤改由客户端本地做(帧广播给所有 owner/admin 连接,订阅只决定循环跑不跑)。E. 其余(各一句):① 13.4.5 面内三条(74b12d8)——续接窗口 Recent 在同毫秒并列时改为取最新 n 条且按插入序(无 API 变化,但客户端可观察到的「握手回放 / 续接上下文」顺序更正确);上行 chat 不再经可丢弃的 bus(帧序列不变);被取消回合的 checkpoint 不再顶掉下一条消息,新增内部常量 loop.StopReasonCanceled(checkpoints.state.stop_reason 可能出现该值,不经 REST/WS 暴露)。② MCP(a1083a3)——协议层换成官方 Model Context Protocol Go SDK v1.8.0,REST/WS/env/数据目录零变更(MCP_SERVERS_JSON、MCP_*、transport 枚举 stdio|http|streamable|sse 全不变)。未做(诚实边界):DashScope 形状的 auto 推断、Qwen 的 /think 前导消息软开关、get_group_list 枚举、上游显式退订帧(当前退订只发生在订阅凭据的连接全断时)、审计 tab 仍 5s REST 轮询(未做 observability_audit 帧)。上一版 v0.54:(渠道配置写路径改 patch 语义(修 P0 级凭据静默丢失) + 阶段 6 前端批的纯前端行为登记,依据 design/04 §3.1、design/22 §2 方案 b(与 personaId 同构的裁决延续,commit 4ae7148)与裁决 16.4.1 / 16.4.2 / 16.4.3 / 16.4.4 / 16.4.5 / 16.4.6(commit f454273 / b480b04 / c1a98d7 / 8d70f6d)):① PUT /api/v1/admin/channels/{platform}/config 的 config 由「整条替换」改为「按 id 打补丁」(runtimeconfig.PatchChannel,与 personaId 同构的三态)——键省略(该键不在 config 里,或 config 整体缺席 / 为 null)= 保留该账号已存值、给出非空值 = 覆盖该键、显式 null 或 "" = 清除该键(0 / false 是真实取值,不算空);顶层便捷字段(token / baseURL / …)是普通 string,只在非空时并入补丁 ⇒ 要清除 bot_token 必须走 config 的显式 null / "";allowGroups / denyGroups / allowUsers / denyUsers 与三个限流键仍是整值替换(省略 = 清空)。② 破坏性提示:按旧文实现、依赖「省略某 config 键 = 删掉它」的客户端,必须改为显式传 null / "" 才能清除该键。③ 修掉的数据安全问题:面板只发送用户本次碰过的平台字段,后端却整条替换 ⇒ 保存微信(iLink)账号卡会抹掉 bot_token / bot_id / owner_id / get_updates_buf(iLink token 是长期凭据)、Telegram 只填 webhook_url 而不重填 token 会让 cleanAccounts 丢掉整个账号。④ 未改:token 要求型平台显式清 token 仍按 cleanAccounts 丢账号;「本次一个 config 键都没给出」在非 weixin-ilink 平台仍回 400。⑤ 无新端点 / 新字段 / 响应形状变更。同版另含阶段 6 前端批(纯前端行为,无 API 变化):① 16.4.1 静默/活动态与预约切换——唯一入口 frontend/web/src/components/modeSwitch.ts:她活动时用户切换只登记「已预约」、静默时落地;② 16.4.5 会话级消息缓存——帧按自己的 sessionId 落进该会话的消息桶(切会话不清空),只有单槽忙态仍按激活会话判定;graph_update 按 data.sessionId 入图桶,管理端 Flow 页取激活会话、Backthink 页取 sess-backthink(裁决 16.4.2);③ 16.4.3 / 16.4.6 虚拟滚动与专属页只读卡——长列表超阈值窗口化、专属页只读展示该平台完整字段;④ 16.4.4 双档 UI 模式——性能档 = 现状(默认)、华丽档落 :root.ui-mode-fancy,持久化键 cyrene.ui-mode(纯前端,对应 design/07 §0.1,不改后端契约)。上一版 v0.53:(阶段 5 落地:模型目录端点 + per-persona 路由 / 本地三级 / capacity L3 / 视觉·OCR 独立模型 / image-crop 工具 / 按 provider 形状下发推理开关,依据 design/04 §3/§3.1/§3.2(裁决 03.4.1 / 03.4.2 / 03.4.3 / 08.4.1)、design/06 §0/§3(裁决 14.4.1 / 14.4.5)、design/14 §2.2/§3/§4(裁决 14.4.2 / 14.4.3 / 14.4.4),2026-10-04 落实):① 新增管理只读端点 GET /api/v1/admin/llm/catalog(owner|admin 读,?refresh=1 强制在线刷新)→ 200 {data:{source:"cache"|"models.dev", fetchedAt?, stale?, warning?, providers:[{id,name,api?,doc?,env?,npm?,models:[{id,name,attachment,reasoning,toolCall,contextLimit?,outputLimit?}]}]}};既无本地快照又抓不到 → 503 {error:{code:"catalog_unavailable", message}};客户端带 Accept-Encoding: gzip 时响应带 Content-Encoding: gzip(信封形状不变)。副作用只有一处:落盘快照 <DATA_DIR>/llm_catalog.json(不含任何凭据、data/ 已 gitignore、TTL 24h),不改 models.json;抓取只发生在本端点内,不进运行时热路径(模型请求链路从不读外网目录)。详见 §2「模型配置契约(v0.53)」。② models.json 组级新增键(载体是组,可热更新,未声明时该键不出现):reasoning: {kind: "auto"|"deepseek"|"openai"|"vllm"|"dashscope"} 决定该组用哪种提供商开关形状下发推理指令(非法值被 Group.Validate 直接拒绝;anthropic 组上声明 openai 系形状同样拒绝)、personaModels: {personaId: modelId} 与 personaProviders: {personaId: "openai"|"anthropic"} 为角色级模型/提供商路由(非法 provider 值同样拒绝;未绑定/未知角色回落该组默认)。③ 语义修正(重要):multimodal = 主模型自身能否收图;LLM_VISION_MODEL / LLM_OCR_MODEL 只配视觉/OCR 工具用的模型——两者互不代表(见 §1 行 3)。④ .env 新增 9 键(见 §2「模型配置契约(v0.53)」表与 .env.example):LLM_REASONING_KIND(默认空 = auto)、LLM_OCR_MODEL、LLM_LOCAL_ENABLED(默认 false)/LLM_LOCAL_PROVIDER/LLM_LOCAL_BASE_URL/LLM_LOCAL_API_KEY/LLM_LOCAL_MODEL、CAPACITY_L3_ENABLED(默认 false)/CAPACITY_L3_CACHE_TTL(默认 5m)。⑤ 内置工具新增 image-crop(按区域裁切图片:x/y/width/height → PNG data URI + 裁切尺寸;供大图分区精读;策略默认 owner-only)。⑥ 推理开关按形状下发(旧陈述修正):v0.41 的「Qwen 一类只 WARN、请求一字不改」已作废——形状由 reasoning.kind 声明后真下发(→ 字段名映射表见 changelog v0.53 条),只有形状未知/有歧义时才 WARN 且一个字段都不发;reasoning_effort 的非法值由「降级到最近一档」改为丢弃并记 WARN(不降档),effort:none 一律翻成显式关思考。未做:DashScope 形状的 auto 推断(只能在显式声明 reasoning.kind=dashscope 时下发,绝不从模型名/BaseURL 猜)、Qwen 的 /think 前导消息软开关(与 design 口径冲突,未实现)。上一版 v0.52:(工具策略落盘 + 主代理代审小字 + 流程图节点参数摘要,依据 design/11 §3/§4/§8(裁决 1.3 / 10.4.2 / 13.4.3)与 design/19 §1/§1.1(裁决 11.4.2 / 11.4.3 / 13.4.1),2026-10-04 落实):① 工具策略(allowInGroup/allowInStranger)落盘 SQLite tool_policies(迁移 0012_tool_policy),重启保留——registry.SetPolicy 先落库后改内存,落盘失败则内存保持旧值并返回错误(见 §2 行 5);② 新增下行帧 approval_notice(§3.11):敏感操作经主代理代审后推一行小字(explain 由后端产出、客户端原样渲染),审计口径 who=角色名 / what=approval.<工具名> / result=granted|denied,仅 owner + admin(fail-closed 默认);③ graph_update 节点 props 新增四类纯增量可选键(tool args_keys/args_summary、guardrail action/allow/elapsed_ms、memory action/scope/hits/bytes/count/failed、orchestration subagent/persona),memory/guardrail/orchestration 旁路节点 span 首次真进图,值在发布前已脱敏——帧名、事件名、顶层字段集一字未动(见 §3.5)。上一版 v0.51:(后台子代理结果登 WS 帧 + 回填父会话,依据 design/05 §9「子代理完成 → 结果回填主会话并提示用户」,裁决 02.4.3,2026-10-04 落实):新增下行帧 subagent_result({type, sessionId, data:{sessionId, status:"done", summary, agents:[{id, goal, role?, mode?, status, summary?, text?, steps?}]}},见 §3.10)——subagent_spawn 的批次转后台后结算时,编排层发布在 bus 主题 subagent.result 上的合并结果,此前没有任何消费者(中继既非 ws.* 也不认该 4 段以外的 subject)⇒ 结果上不了前台、只剩进度帧;现在该 subject 被登记为中继并成形为这一帧,帧壳 sessionId = 派生该批次的父会话,结果因此落回父会话的实时流。角色门:新帧属内容帧 → 只发 owner + admin(fail-closed 默认,未扩 7 个状态机信号白名单)。向后兼容:纯新增帧,老客户端按未知帧忽略即可,既有帧/端点/鉴权一律不变。诚实边界:结果不落库(父会话转录里没有新消息、重连回放不补发)、未经 design/15 会话消息编排器续接、前端尚无该帧 handler(今天的效果是「帧到达 owner/admin 连接」)。同版另含管理面(D 组,2026-10-04 落实):① X-API-Key 真正接线(裁决 15.4.2):管理/用户侧已认证端点同时接受该头,命中签发表即通过且一律 owner 级(裁决 15.4.3 取消 role 分级),带该头时只走 key 分支、未知/已吊销/空值回 401 且不回落 Bearer(fail-closed),WS 握手仍只认 Bearer;② 新增主人白名单档案读写 GET/PUT /api/v1/admin/owner-profile(裁决 10.4.5,owner-only,写带审计 owner.profile.update)——面板的「保存」从此是真保存(落盘 configs/users/owner.yaml 0600 原子写 + 热替换活档案,渠道 actor 判定立即生效),不再是只改本地 state 的假成功;③ persona identity.systemPrompt 改必填(裁决 08.4.2):缺失/空白回 400 bad_request,不再派生默认说明串,写出的 YAML 必能被载入期同一规则重新载入。见下方「管理面板写操作契约」。上一版 v0.50:(事件批:流式帧的结构化补齐,依据 design/04 §1.1,裁决 01.4.1 / 01.4.2 / 01.4.3 / 03.4.4,2026-10-04 落实):① tool_execution 拆三事件——旧单帧 tool_execution.update {id,index,delta}(phase 与工具名拼在 delta 字符串里)被 tool_execution.start(进入执行,phase:"running")/ tool_execution.update(运行中进度/局部输出,可多帧)/ tool_execution.end(完成或失败,phase:"done"|"error")替换,载荷改为结构化 {tool, phase, id?}(tool = 工具名;phase = 细粒度执行阶段,与 §3.5 流程图节点 status 同一套 running/done/error 枚举;id = provider 工具调用 id,可与 tool_call.* 帧关联)——phase 不再塞进 delta;loop 现已真实发出 start/end(update 的 wire 映射已实现并验证,但工具分发器尚无中途进度上报源,暂无生产者,见 §3.1)。② streamId(模型流帧 data 内新增可选字段):同一次模型流(一次 ChatStream 调用)的多帧共享同一 streamId,多步工具轮的第二次模型调用取新 id;与帧壳的 turnId(整个回合恒定)分工——turnId 标记「这轮回答」,streamId 标记「这轮里的哪一条流式消息」。③ turn.start / turn.end(新帧):loop 在回合边界发出、经 ws_bridge 下行;无 data 载荷(帧壳的 sessionId/turnId/seq 即边界信号;回合终态信息仍由既有 stream_end 携带);turn.end 覆盖回合的所有出口(最终答案 / 工具循环安全网终止 / 预算耗尽 / 步骤出错),被静音的内部流程(backthink / 子代理)不发出。④ 向后兼容:turn.start/end 与 streamId 为纯新增,老客户端可安全忽略;破坏性在旧 tool_execution.update 单帧——loop 不再产生它,按旧形状(解析 delta 里的 phase:工具名 拼串)消费的客户端必须迁移到三事件;现有 Web 前端未消费这些帧,行为不变。⑤ 本批新帧全部落在 v0.48 下行帧门的「仅 owner + admin」档(fail-closed 默认,无需扩白名单)。⑥ graph_update 会话范围口径修正(裁决 01.4.5,随实现事实补记):web / web:<uuid> / sess-backthink 三范围(见 §3.5)。见 §3.1 与 v0.50 变更记录。上一版 v0.49:(WS 握手回放与上行帧的角色门(design/11 §7.2):补上 §7.1 下行门之外的同一类缺口的另两处,对 viewer 继续收紧,owner / admin 行为不变,无端点、无字段形状变更——① 握手 history 回放改为仅 owner + admin:此前任意角色一连上 /ws/chat 就会收到主会话最近 50 条消息正文(thinking 已剥离,但正文本身就是主人的对话),viewer 从此不收;② 上行 JSON 帧按角色授权(fail-closed):非 owner/admin 连接发来的任何上行 JSON 帧一律拒绝,定向(只回发送者)回 {type:"error",data:{code:"forbidden"}},不广播、不驱动模型、不落库、不返回任何内容——被拒帧含 chat(此前 viewer 可真的驱动模型并落库)、interrupt、subagent_ask / task_output_query(其应答是定向帧、能绕过 §7.1 下行门把子代理上下文/任务输出直接回给 viewer)、voice_input、mode_switch,且未登记的新上行帧默认拒;协议级帧(ping/pong、close)不受影响。③ 合口径:viewer = 可连接、可读 12 条只读监控 REST(v0.40)、可收 7 个状态机信号帧(v0.48);除此以外 WS 上拿不到任何内容、也发不出任何指令。见 §3 开头的「上行帧与握手回放的角色门」与 v0.49 变更记录。上一版 v0.48:(WS 下行帧的角色门(design/11 §7.1):hub 的下行现在是按帧类型分档的——只有 7 个状态机信号(session_busy / pending_ack / pending_drain / session_idle / session_interrupted / pending_full / session_read,均不含任何内容)继续发给所有已认证连接(含 viewer),其余全部下行帧(流式内容 message.* / thinking.* / tool_call.* / tool_execution.update / stream_chunk / stream_end / thinking;持久化 message_persisted / message_edited / message_deleted / channel_persisted;系统内部 graph_update / subagent_event / task_output / task_done / tool_used / capacity_awaken / mode_switch;私有状态 mood / device_update / reminder)改为仅发 owner + admin)。对 viewer 是收紧:此前它连上 /ws/chat 就能收到角色回复正文与推理内容,现在收不到;owner / admin 行为不变;无端点、无字段形状变更——viewer 的 12 条只读监控 REST 端点(viewerAuth,v0.40)与 /ws/chat 的连接鉴权都照旧;新帧类型默认只发 owner/admin(fail-closed)。见 §3 开头的「下行帧的角色门」与 v0.48 变更记录。上一版 v0.47:(graph_update 子代理泳道根节点新增可选 props.persona:派发子代理时若指定了目标角色,该分支根节点的 props 就多一个 persona 键,标注它正在扮演的角色——用途是让流程图能显示「这个子代理是以谁的身份在替班」,而不只是子代理 id;纯增量:不带角色派发时该键不出现,老客户端忽略未知键即可,见 §3.5 与 v0.47 变更记录)。上一版 v0.46:(PATCH /api/v1/sessions/{id} 的 personaId 由「可重新绑定该会话的角色」改为「出现即 400」:会话角色在创建时确定、之后不可变,要换角色请新开会话(design/22 §8-1)——明确拒绝而非静默忽略/200 空操作;键出现(任何取值:"yuki" / "" / null / 未知 id / 空白串)一律 400 bad_request 且消息点名 personaId,该检查先于会话存在性检查(未知会话 + personaId 也回 400 而不是 404)。破坏性:按 v0.42–v0.45 旧文实现、依赖「PATCH 换角色 / 显式 "" 解绑」的客户端必须改为新建会话选角色;title/pinned 行为与 POST /sessions {personaId}(创建时绑定)完全不变,不再有 501/200 {personaId} 的 PATCH 路径)。上一版 v0.45:PUT /admin/channels/{platform}/config 的 personaId 由「省略/"" 都 = 解绑」修订为三态(省略(或 null)= 保留已存绑定、显式 "" = 解绑、非空 id = 绑定,未知角色仍 400)。

2. REST 端点(管理,/api/v1/admin/*)

仅供管理面板与授权客户端;RBAC:owner/admin/viewer;写操作全程审计。角色分档见下方「管理侧权限矩阵」(v0.40 起 viewer 真正可用:只读监控档,其余一律 403)。

2.0 管理侧权限矩阵(v0.40 起,与 internal/services/gateway/routes.go 逐条一致)

  • 匿名(无 token / token 无效):任意 /api/v1/admin/* → 401 {error:{code:"unauthorized"}}(fail-closed,全部 61 条管理路由无一例外 —— 12 条 viewer 档 + 11 条 owner/admin 档 + 38 条 owner-only 档)。
  • 角色分档(✅ 放行,❌ 回 403 forbidden;owner>admin>viewer 递增):
端点组 owner admin viewer
只读监控档(12 条 GET,见下①) ✅ ✅ ✅
owner/admin 只读档(11 条,见下②) ✅ ✅ ❌
owner-only 写/凭据档(38 条,见下③) ✅ ❌ ❌

① viewer 可读的 12 条只读监控档(viewerAuth = owner/admin/viewer)——模块清单 + 运行时指标/日志/通知,全部是非敏感投影(无凭据、无他人账号数据、无消息正文): GET /admin/personas、GET /admin/sessions、GET /admin/plugins、GET /admin/plugins/installed、GET /admin/channels、GET /admin/iot、GET /admin/automations、GET /admin/rag、GET /admin/observability/metrics、GET /admin/observability/trace、GET /admin/observability/logs、GET /admin/notifications。

② 仅 owner/admin 的 11 条(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/llm/usage(LLM 用量/成本汇总面,v0.64 新增——含 token 成本与模型/用途分解,故与 /admin/llm 同门,不放给 viewer)、GET /admin/users(账号与 API key 列表)、GET /admin/backthink(后台思考记录)、GET /admin/observability/audit(审计流水)、GET /admin/sessions/{id}/export(会话导出)、POST /admin/rag/query(知识库检索,语义上是读,但按「全局知识面」归此档)。

③ 仅 owner 的 38 条(admin 与 viewer 均 403)——全部写操作,外加凭据获取路径(login-qr/login-status,view/friends,渠道连接/测试/配置)与主人身份档案(决定谁能进 owner 车道,连读都不给 admin): GET /admin/channels/{platform}/login-qr、login-status、view、friends;POST /admin/channels/{platform}/friend/handle、message/test;PUT|DELETE /admin/channels/{platform}/config;POST /admin/channels/open|close|test;POST /admin/plugins/policy|install|uninstall;POST /admin/personas、PUT /admin/personas/{id};GET|PUT /admin/owner-profile(v0.51);POST /admin/users、POST /admin/users/apikey、DELETE /admin/users/apikey/{id};POST /admin/iot/{id}/control、POST /admin/iot/scenarios、POST /admin/iot/rules;POST /admin/automations、POST /admin/automations/{id}/run、DELETE /admin/automations/{id};POST /admin/llm、POST /admin/llm/test、POST /admin/llm/reset-from-env(v0.62 新增,破坏性写——覆盖面板对若干键的修改,因此门与同页两条 LLM 写端点一致,不比同族更宽);POST /admin/memories、POST /admin/memories/search;POST /admin/rag、POST /admin/rag/delete;POST /admin/system/revoke-keys|rotate-jwt|backup|restore。

用户侧同级只读(非 admin 前缀,同样任意已认证角色可读):GET /api/v1/sessions、GET /api/v1/personas、GET /api/v1/meta/enums(§1 行 4/9/15)。 已知遗留:前端管理面板尚未按角色隐藏/禁用分区——viewer 打开②③档页面会拿到 403,客户端需按本矩阵自行降级(后端契约已定,前端适配后置)。

# 端点 方法 状态 说明
1 /api/v1/admin/personas GET ✅ 人格列表(viewer 可读)→ {data:[PersonaSummary...]},每项含用户侧全部字段,另加 admin 专属 thinking?(v0.43):{budgetTokens,effort,showToUser,mode}——见下方「人格 thinking 读侧契约」
2 /api/v1/admin/sessions GET ✅ 持久化会话列表(主会话 + 次级会话,design/16 §2);未注入 HistoryStore 时回退渠道会话视图。v0.42 起每项含可选 personaId(与会话级选角同源,见 §1 行 4)
3 /api/v1/admin/memories GET ✅ 记忆列表(手动添加/检索已实现;编辑/删除/衰减随 M10 扩展)
4 /api/v1/admin/plugins GET ✅ 插件/能力列表(含 MCP/渠道)
5 /api/v1/admin/plugins/policy POST ✅ 写工具策略(owner-only,审计);策略落盘 SQLite tool_policies,重启保留(迁移 0012_tool_policy)——registry.SetPolicy 先落库后改内存,落盘失败则内存保持旧值并返回错误
5a /api/v1/admin/plugins/installed GET ✅ 已装运行时插件列表(owner/admin 读):{data:[{id,name,kind,transport,endpoint?,command?,args?,apiKey?,env?,createdAt?,live,toolCount}]},apiKey/敏感 env 值掩码(如 ****1234),live = 是否已连接注册,toolCount = 该 server 注册工具数
6 /api/v1/admin/channels GET ✅ 渠道列表/健康 + 每平台已配账号(脱敏 token 掩码,design/04 §3.1);已连接渠道的账号附 bot 身份(botId/botUsername/botNickname,如 KOOK GET /api/v3/user/me 自动获取);适配器要求重新授权时附 needsRescan + lastError(v0.39,仅微信 iLink 返回 errcode=-14、会话被 iLink 侧拒绝这一种);每账号附可选 personaId(v0.44)=该账号的渠道级角色绑定(design/22 §2 方案 b;缺省 = 未绑定,非敏感、不掩码)
7 /api/v1/admin/iot GET ✅ IoT 设备列表
8 /api/v1/admin/users GET/POST ✅(GET) 用户/角色/API key(GET 已实现;key 的签发/吊销已实现——POST/DELETE /admin/users/apikey,见下方写操作契约;POST 创建用户仍 501)
9 /api/v1/admin/llm GET/POST ✅ LLM 多厂商多模型(models.json):GET 列表(APIKey 脱敏,含 provider 协议与 maxTokens 预算);POST 保存组→落盘 models.json→热更新运行中的 LLM stack(design/04 §3.1,无需重启)。v0.53:组级新增 reasoning / personaModels / personaProviders 三键(见下方「模型配置契约(v0.53)」)
9a /api/v1/admin/llm/catalog GET ✅ 模型目录候选(owner/admin 读,v0.53;?refresh=1 强制在线刷新)——面板「添加提供商 / 添加模型」的 baseURL / 模型清单 / 能力位来源,服务端快照 + 离线兜底;失败 503 catalog_unavailable。见下方「模型配置契约(v0.53)」
9b /api/v1/admin/llm/reset-from-env POST ✅ 重置为 env 默认(owner-only 写,v0.62,审计 llm.resetFromEnv):body {"confirm":true}(缺 = 400)→ 200 {data:{reset:[{group,keys}], skipped:[{group,reason}], path}}——只覆盖 .env 声明过的键、env 未声明的键原样保留、组集合不增减,无键可改时不写盘;只回键名、绝不回显凭据值。见下方「重置为 env 默认契约(v0.62)」
10 /api/v1/admin/automations GET/POST ✅(GET) 自动化与提醒(GET 已实现;POST 创建 M10)
11 /api/v1/admin/backthink GET ✅ 后台思考记录
12 /api/v1/admin/rag GET ✅ 知识库文档列表(分块/嵌入状态);带 ?query= 时并入检索结果
13 /api/v1/admin/rag/query POST ✅ 知识库检索(真实检索;配置向量增强走语义路径)
14 /api/v1/admin/rag/delete POST ✅(写) 删除知识库文档(真实删除:项目索引条目 + 级联章节);owner-only,{id} → {id,deleted:true},不存在 404
15 /api/v1/admin/observability/metrics GET ✅ 网关请求指标(总数/QPS/延迟/方法·路径·状态计数/LLM·子代理计数)
16 /api/v1/admin/observability/trace GET ✅ 最近 trace span(环形缓冲,默认 512 条;loop/工具/编排的真实 span)
17 /api/v1/admin/observability/audit GET ✅ 审计记录(who/what/when/result,SQLite 持久化)
17b /api/v1/admin/observability/logs GET ✅ 后端日志 tail(logger 环形缓冲,默认 2000 条;?level=/?limit=,见下方「可观测性契约」)
17a /api/v1/admin/notifications GET ✅ 统一通知 feed(审计 + 主动推送聚合为只读 feed,design/10 §3 #16;见下方「通知聚合契约」)
18 /api/v1/admin/memories POST ✅(写) 手动添加记忆(owner-only,审计);body {personaId?,content,scope?},无 store 回 501
19 /api/v1/admin/rag POST ✅(写) 导入知识文档(owner-only,审计);body {name,desc?,content},分块后落项目索引可检索;无 store 回 501
20 /api/v1/admin/system/revoke-keys POST ✅(写) 吊销全部 API key(owner-only,审计);返回 {revoked:n}——key 表清空,先前签发的所有 key 立即失效
21 /api/v1/admin/system/rotate-jwt POST ✅(写) 轮换 JWT 密钥(owner-only,审计);body {newSecret}(≥32 位,不强校验熵)→ {rotated:true,note,refreshTokensRevoked}——现有 access token 立即失效需重新登录,且所有 refresh token 一并作废(refreshTokensRevoked 为删除条数);新密钥不回显、不落日志
22 /api/v1/admin/system/backup POST ✅(写) 备份数据(owner-only,审计);用 SQLite VACUUM INTO 快照(含已提交 WAL 内容)到 <dataDir>/backup-<ts>.db → {path,size}
23 /api/v1/admin/system/restore POST ✅(写) 恢复数据(owner-only,审计);body {path} → 校验路径在 dataDir 内→先备份当前→替换 DB 文件→{restored:true,backup,note:"需重启后生效"}(运行进程持有 DB 句柄,热替换需重启可见)
24 /api/v1/admin/system/storage GET ✅ 存储状态(只读,v0.60):owner-only、无审计动作;`{data:{main:{kind:"sqlite",path,fileBytes,walBytes,shmBytes,pageSize,pageCount,freelistPages,freeBytes},documents:{count,bytes},vector:{kind:"pgvector"
24a /api/v1/admin/llm/usage GET ✅ LLM 用量汇总(只读,v0.64,切片 U1):owner|admin 读、无审计动作;?since=&until=&purpose=(时间接受 RFC3339 或 Unix 毫秒;缺省窗口 = 近 24h;跨度上限 90 天,超过 → 400,不静默裁剪)→ {data:{window:{since,until,purpose},totals:{calls,prompt,completion,cacheHit,cacheMiss,hitRate},byPurpose:[…],byModel:[…]}};hitRate = cacheHit/(cacheHit+cacheMiss),分母 0 ⇒ 0;绝不含凭据与消息正文;未接线 / 查库失败 → 500 usage_unavailable(不回全 0 假汇总)。详见下方「LLM 用量汇总契约(v0.64)」

M10 前端契约补充(管理面板):GET /api/v1/admin/users · GET /api/v1/admin/llm · GET /api/v1/admin/automations · GET /api/v1/admin/backthink · GET /api/v1/admin/rag · POST /api/v1/admin/rag/query · GET /api/v1/admin/observability/metrics · GET /api/v1/admin/observability/trace · GET /api/v1/admin/observability/audit · GET /api/v1/admin/observability/logs · GET /api/v1/admin/notifications 已实现,返回真实数据(日志 tail 见下方「可观测性契约」;未注册 GET /api/v1/admin/logs)。写操作已补齐(见下方「管理面板写操作契约」):人格/API key/设备控制/自动化/LLM 测试/渠道连接与识别测试/记忆检索/会话导出/插件卸载真实持久化或真实生效;系统设置安全与存储类(吊销 API key / 轮换 JWT 密钥 / 备份 / 恢复)也已真实实现(见下方「系统设置契约」);主人档案保存(v0.51)同样已实现——GET/PUT /api/v1/admin/owner-profile 真实落盘 + 热替换活档案,面板的「保存」不再是假成功;仍为后置 M10:新增用户、IoT 场景与规则、编辑环境变量/重启服务/检查更新——后端如实返回 501 not_implemented,不伪造成功。(知识库删除已实现:POST /admin/rag/delete 真实删除,见下方写操作契约;插件安装已实现:v1 为 MCP server 热安装。)

模型配置契约(v0.53)(依据 design/04 §3/§3.1/§3.2(裁决 03.4.1 / 03.4.2 / 03.4.3 / 08.4.1)与 design/06 §0/§3(裁决 14.4.1 / 14.4.5);判定与校验以 internal/config/runtimeconfig + internal/services/gateway/admin_llm*.go 为准):

  • GET /api/v1/admin/llm/catalog(owner|admin 读,v0.53) → 200 {data:{...}}:

    • 请求:?refresh=1 = 强制在线刷新;不带时,本地快照仍在 24h TTL 内就直接用它(不每次打开面板都打外网)。
    • 响应 data:{source:"cache"|"models.dev", fetchedAt?, stale?, warning?, providers:[{id,name,api?,doc?,env?,npm?,models:[{id,name,attachment,reasoning,toolCall,contextLimit?,outputLimit?}]}]}
      • source 说明这份候选从哪来(models.dev = 本次在线抓取,cache = 本地快照);fetchedAt 为快照抓取时间(RFC3339,无快照时省略);stale:true + warning 只在「在线刷新失败、返回上次快照」时出现(离线兜底,绝不编造候选)。
      • api = 该供应商 baseURL(目录未标注时省略,面板提示需手填);doc = 文档地址、env = 目录标注的官方 key 环境变量名(仅提示,面板不写 .env)、npm = SDK 包名。
      • 每个模型:attachment(接受图片/文件输入 → 面板预填 multimodal)、reasoning、toolCall、contextLimit?/outputLimit?(目录未标注时为 0 而省略;outputLimit 直接作 maxTokens 建议值)。
      • 供应商与模型都按 id 排序(输出确定、便于前端 diff);没有模型清单的供应商不进列表(避免噪声行)。
    • 失败:既无本地快照又抓不到 → 503 {error:{code:"catalog_unavailable", message}}(message 给出「可改用粘贴导入」的指引);有快照但刷新失败 → 仍 200(source:"cache" + stale:true + warning);快照文件损坏/为空只记 WARN 并按「无快照」继续在线抓取,不影响本次回答。
    • 压缩:客户端带 Accept-Encoding: gzip 时响应带 Content-Encoding: gzip(目录约 1MB 量级);信封 {data} 形状不变,浏览器与 Go 客户端透明解压;不带该头时返回明文并带 Content-Length。
    • 副作用(只有这一处):落盘快照 <DATA_DIR>/llm_catalog.json(与 models.json 同目录、data/ 已 gitignore、不含任何凭据、TTL 24h);不修改 models.json。LLM stack 未注入时快照落到系统临时目录,绝不往工作树写文件。
    • 抓取只发生在本端点内(数据源 https://models.dev/api.json,单次超时 20s、体积上限 64MiB):不进运行时热路径——模型请求链路从不读这份外网目录。
    • 审计:成功记 llm.catalog / ok,返回过期快照记 stale,失败记 error(可用 GET /admin/observability/audit 查)。
  • models.json 组级新键(v0.53,可热更新;GET/POST /api/v1/admin/llm 已同步这三个键的读与写):

    • reasoning: {kind}:kind ∈ "auto"(= 缺省)/"deepseek"/"openai"/"vllm"/"dashscope",决定该组用哪种提供商开关形状下发推理指令(vLLM → chat_template_kwargs.enable_thinking;DashScope → 顶层 enable_thinking;DeepSeek → 顶层 thinking:{type} + reasoning_effort;OpenAI 系 → 只有 reasoning_effort;auto = 按模型名/baseURL 推断厂商,形状有歧义就不下发任何字段)。
      • 校验:不在上列取值 → 组校验失败(POST /admin/llm 回 400 bad_request);anthropic 组上声明 deepseek/openai/vllm/dashscope 同样拒绝(Anthropic 用自己的 thinking 形状)。
      • 请求语义:reasoning 键缺失 = 保留已存形状;给出(哪怕 {})= 整块替换(kind 缺失/空串 = 回到「未声明」)。
      • 响应语义:每组的 reasoning 恒下发(kind:"" = 未声明形状,不是「后端没报告」);顶层另下发 reasoningKinds(合法形状取值域,不含空串——"" 是「不声明形状」而非一个形状名,面板另行给出该选项)与 personaProviderOptions(合法协议名域,即 openai/anthropic)供面板渲染下拉框——取值域由后端常量直接生成,面板无需硬编码。
    • personaModels: {personaId: modelId} = 角色级模型路由(「角色」= 该回合答话的 persona,不是消息作者的 role);personaProviders: {personaId: "openai"|"anthropic"} = 角色级协议路由(跨协议换 adapter,配合 personaModels 使用)。
      • 校验:persona id / 值不得为空,personaProviders 的值必须是 openai 或 anthropic,否则组校验失败(400);键值首尾空白会被规整,规整后撞键直接报错(不静默丢一条)。
      • 回落:未绑定 / 绑定到未知 persona → 该组默认模型;personaProviders 声明的协议在 models.json 里没有对应组 → 记 WARN 并跳过(该角色仍走默认链,不半建一条只会失败的链)。
      • 请求语义:两个键缺失 = 保留已存表;给出(含 {})= 整表替换(用整表替换而非合并,面板才能删除一条已存路由);未声明时响应中该键不出现(omitempty)。
    • 语义修正(重要):multimodal = 主模型自身能否收图(不是「本部署有没有视觉能力」);LLM_VISION_MODEL / LLM_OCR_MODEL 只配视觉/OCR 工具用的模型,与 multimodal 互不代表(裁决 14.4.1,见 §1 行 3)。
  • .env 新增 10 键(v0.53 首登 9 键,v0.58 追加 LLM_MULTIMODAL;.env 是 models.json 的兜底,对齐 .env.example):

    键 默认 语义
    LLM_REASONING_KIND 空(= auto) 仅供 .env 合成组的推理开关形状声明(无有效 models.json 时才生效,取值同 reasoning.kind;有 models.json 时以该组的 reasoning.kind 为准)
    LLM_OCR_MODEL 空 OCR 工具用的模型;空 = 复用 LLM_VISION_MODEL,两者都空 = 复用主模型(LLM_MODEL)
    LLM_MULTIMODAL false 声明「主模型自身能否收图」(bool,v0.58 新增)——即 models.json 组 multimodal 的 env 等价物。仅供 .env 合成的模型组(无有效 models.json 时才生效;有组文件时以该组的 multimodal 为准,与 LLM_REASONING_KIND 同口径)。true = 图片直进主模型上下文;留空 / false(默认)= 把图交给 vision/OCR 工具,换成文本描述(图像不占上下文,只有工具结果占)。绝不凭 base URL / 模型名猜测端点是否收图;非 bool 值(如 maybe)启动即报错(fail-fast,不静默回退成 false)。LLM_VISION_MODEL / LLM_OCR_MODEL 配的是视觉/OCR 工具调用的模型,与本键互不代表
    LLM_LOCAL_ENABLED false 降级链**第三级(本地模型)**总开关;默认关,链保持「主 → 备」两级
    LLM_LOCAL_PROVIDER openai 本地端点协议(openai/anthropic)
    LLM_LOCAL_BASE_URL 空 本地端点 base URL(如 http://127.0.0.1:8080/v1)
    LLM_LOCAL_API_KEY 空 本地端点 key(本地服务通常为空)
    LLM_LOCAL_MODEL 空 本地模型名
    CAPACITY_L3_ENABLED false capacity 意图判定的 **L3(模型辅助分类)**开关;默认关,只走 L1/L2 启发式
    CAPACITY_L3_CACHE_TTL 5m L3 意图判定结果缓存 TTL
    • LLM_LOCAL_* 三个条件缺一不可:LLM_LOCAL_ENABLED=true 且 LLM_LOCAL_BASE_URL 非空 且 LLM_LOCAL_MODEL 非空;只开开关而不配端点/模型时记 WARN 并跳过该级(绝不半配一个只会失败的链尾步)。
  • 内置工具新增 image-crop(v0.53):按区域裁切图片(参数 image + x/y/width/height,0 基像素、区域须完整落在图内)→ 返回 {image:"data:image/png;base64,…", format:"image/png", width, height, region:{x,y,width,height}, source:{width,height}};用途是上游可能压缩分辨率、大图细节会丢,让模型自行裁出区域再交给 vision/OCR 工具精读(design/06 §3,裁决 14.4.5)。上限:源图 ≤50MiB、区域 ≤16MP、返回载荷 ≤8MiB(超出报错而非截断/降采样);源可以是本地路径(走 file 工具同一沙箱)/ http(s) URL(同 SSRF 守卫)/ base64 data URI。策略与其它工具一致:tool_policies 无行时按最严默认 owner-only(群聊/陌生人的调用被策略层拒,见 §2 行 5)。

安全 / 上下文环境变量(v0.56)(.env / 环境变量;与 .env.example 逐键对齐):

键 默认 语义
OWNER_IDENTITY_INJECTION owner_only 主人身份说明书(owner.yaml:identityFile)注入范围:owner_only(默认,仅主人会话,按渠道层强类型 actor 元数据判定)/ always(所有会话,含群聊与陌生人私聊,仅本地自测/排障——它显式开启红线例外)/ off(完全关闭,连主人会话也不注入)。取值大小写不敏感、容忍首尾空白;非法值回退 owner_only 并记 WARN(绝不静默放行、也绝不静默失效)。它只决定「那段知识进不进该会话的上下文」,不参与任何身份/权限判定(design/11 §0):文件内容既不参与 IsOwner,也不改变授权。
CONTEXT_MAX_TOKENS 32768 上下文溢出压缩预算(token,离线估算;默认值与 loop.DefaultMaxContextTokens 同源,有测试锁死两者不许漂移)。会话估算上下文超过它时,更早的轮次被摘要成一条前导摘要消息,近期尾部(含尾部工具结果)原样保留;0 或负数 = 关闭压缩(行为与接线前一致)。
GUARDRAIL_KEYWORDS 空 内容策略关键词表(逗号分隔,大小写不敏感的子串匹配)。留空 = 不挂关键词护栏(项目不内置任何词表,只有你显式声明过的词才会被拦)。敏感信息(API key / bearer token / 密码 / 私钥)走阻断档、请求与响应各有长度上限——这两项是代码内定的安全默认值,不对外开放配置(cmd/cyrene/wire_guardrail.go)。

上下文窗口与预算契约(v0.59)(design/04 §1.3 + 附录 A.3 的主人裁决,2026-10-04;判定以 internal/config/context_budget.go + cmd/cyrene/wire_context.go 为准):

  • models.json 组级新键 contextWindow:正整数 = 该组模型的上下文窗口(token)。本仓没有任何 provider 侧窗口发现代码,窗口只能由人声明。0(省略该键)= 未声明 ⇒ 后端按 DefaultModelContextWindow = 131072 保守估算推导预算,并记恰好一条 WARN(提示「声明窗口才能吃满模型的真实窗口」);负数 = 组校验失败(写端点回 400,载入期同样拒绝)。
    • 面板读写面:GET /api/v1/admin/llm 的组 DTO 含 contextWindow(omitempty,未声明时该键不出现——与 maxTokens 同形),POST /api/v1/admin/llm 接受可选 contextWindow:缺失 = 保留已存窗口(面板的局部编辑绝不复位它)、显式 0 与缺失同义(都 = 未声明)、负值 400 bad_request 且不落盘。因此面板可以显示并编辑该键,不是「只能手改文件」。
  • .env 4 键(与 .env.example 逐键对齐):
键 默认 语义
LLM_CONTEXT_WINDOW 空(= 未声明) per-group 播种:models.json 组 contextWindow 的 env 等价物(同一个数、同一个含义)。它作用于 .env 合成组与 materialize 的播种层;负数启动报错。
CONTEXT_BUDGET_RATIO 2/3 推导比例,取值域 (0, 1],分数(2/3)与小数(0.6667)两种写法都接受;非法值记一条带原始取值的 WARN 并回落 2/3(绝不「配了当没配」)。
DOCUMENT_MAX_INJECT_CHARS 空(= 自动) 单份附件注入上下文的字符上限。未设 = 按推导出的上下文预算自动算(缺省下限 64K 字符);>0 = 显式覆盖。它与「sidecar 存多少」是两个数:sidecar 存抽取全文(只受 8 MiB 安全上限约束),本键只决定每回合注入多少 ⇒ 截断发生在装配期,清单行的续读游标因此真是「继续读的起点」。
CONTEXT_MAX_TOKENS 未设置(≠ 0) 三态(v0.59 语义变更):未设置 ⇒ 按窗口推导;>0 ⇒ 显式覆盖(原样使用);0 / 负数 ⇒ 关闭压缩。「未设置」与「显式 0」必须区分(按环境变量 / .env 的存在性判定,不靠 0),因为 0 已被「关闭压缩」占用。
  • 推导式:budget = min(窗口 × 比例, 窗口 − 输出保留);输出保留 = max(32768, 该组 maxTokens),并封顶为 窗口/2(组把 maxTokens 声明得比窗口还大时,不让「窗口 − 输出」退化到 0 或负数)。1M 窗口 ⇒ 699050 tokens(1048576 × 2/3 取整)。
  • ⚠️ CONTEXT_MAX_TOKENS=32768 属「显式覆盖」:.env 里若仍写着这个数(旧模板的默认值取自「gpt-4o 的 128K 窗口 ÷ 4」,该前提已随默认模型 1M 窗口失效),推导不生效——这是设计语义,不是 bug。要吃到推导就删掉 / 注释掉该行(本次 .env.example 已把该行改成注释形式,并在旁注写明旧值来历)。本次不改客户端的任何行为:它只是一个 env 键的语义说明。
  • .env → 面板配置的 materialize 语义(v0.59 补登记的欠账,此前未成文):进程每次启动跑一次(runtimeconfig.Materialize,在模型栈构建之前):
    • 组级:面板配置非空 ⇒ 组集合完全由面板决定(env 不新增组、不复活被面板删掉的组,也不删面板的组);面板配置缺失 / 空文件 / 空数组 ⇒ 由 env 播种出合成组并落盘(让面板从此有一份一等来源)。
    • 键级:只在面板缺该键时用 env 补;面板已有该键(含显式空值 / 显式 false / 显式 null)⇒ 面板优先,绝不覆盖(靠「原始键是否出现」区分「从未写过」与「显式清空」——类型化解码做不到这件事)。
    • 硬约束:「面板改一次、重启被打回」绝不成立(有专门用例钉住)。无改动时一个字节都不动(幂等)。
    • 日志:补齐写 Info(补了哪几个键、来源 env),因面板优先而跳过的写 Debug;只打组 id 与键名,apiKey 等凭据绝不进日志。
    • 范围:目前仅 models.json 一族(channels.json / plugins.json 未纳入)。
    • 与面板保存路径的分工:materialize 只在启动时跑;面板保存走「整组写入 + 热重载」,那里不跑补齐——否则面板刚显式清空的键会被立刻补回。

「重置为 env 默认」契约(v0.62,POST /api/v1/admin/llm/reset-from-env)(任务书 R1;判定以 internal/config/runtimeconfig/reset.go + internal/services/gateway/admin_write_llm_reset.go 为准):

  • 它是什么:materialize(上一节)的方向是「面板缺该键 ⇒ 用 .env 补」;本动作是它的反方向显式动作——部署者明确要求「把面板里这些键退回 .env 声明的默认值」(例如面板把 apiKey/baseUrl 改坏了,一键回到 env 基线)。两者共用同一张键表(runtimeconfig 的 groupFillSpecs),因此「本动作能改回的键集合」与「materialize 会补齐的键集合」结构性一致。
  • 鉴权 / 审计:owner 专属(与同页 POST /admin/llm、POST /admin/llm/test 同门;admin 与 viewer 均 403)——它是破坏性写(覆盖面板对若干键的修改),门不比同族写端点更宽;带审计动作 llm.resetFromEnv(成功 ok、被拒/失败 error;多词动作沿用本仓既有 camelCase 约定,与 llm.addProvider / persona.update 同风格)。
  • 请求:body 必须 {"confirm":true}。缺失 / false / 非法 body 一律 400 {error:{code:"bad_request"}},且一个字节都不动——本动作用 env 值覆盖面板修改,属破坏性操作,缺护栏的默认必须是「不执行」。
  • 语义(逐条):
    • 只动 .env 声明了非空值的键(provider / baseUrl / apiKey / model / contextWindow——与 materialize 的补齐表同集;contextWindow 只认正数,fallback / multimodal / maxTokens / reasoning / personaModels 在 .env 里没有出处,永不被本动作改动)。缺省协议族按组自己的 provider 选(.env 同时配了扁平块与 ANTHROPIC_* 时,两族各拿自己的值)。
    • env 未声明的键一律保留面板值(含面板显式清空/false/null 的键)。
    • 组集合不增减:不复活被面板删掉的组、不删面板新增的组,顺序也不动(本动作只改键值)。
    • 面板值已与 env 一致的键不算「改回」,因此不出现在 reset 里(provider 恒属这种情形:env 默认层总为两族各声明一次协议名,而面板组本来就与之相同)。
    • 没有任何键需要改时一个字节都不写(幂等;重复调用第二次会返回 reset:[] + 每组一条「已一致」的 skipped)。
    • 真的改了盘就热重载运行中的 LLM 供应商栈(design/04 §3.1,无需重启)——但注意 contextWindow 的预算推导仍只在进程启动时算一次(与 POST /admin/llm 同一口径,面板文案已写明「重启后生效」)。
  • 响应:200 {data:{reset:[{group,keys:[…]}], skipped:[{group,reason}], path}}
    • reset:真的被改回的分组,group = 组 id、keys = models.json 的 JSON 键名数组(顺序 = 补齐表顺序)。只回键名,值(尤其 apiKey)绝不回显。
    • skipped:未被改动的分组 + 中文原因("面板值已与 .env 默认一致,无需重置" / "`.env` 未声明该组的默认键(面板值原样保留)")。两组键恒为数组(无记录时是 [],不是 null)。
    • path:被改写(或本应改写)的 models.json 路径,与 POST /admin/llm 同源。
  • 对照基准:请求时刻按既有 config 解析入口读到的 .env 默认层(config.LoadLLMDefaults;优先级与启动时 config.Load 逐字一致:进程环境 > .env > 标签默认)。⇒ 部署形态(systemd/env_file,无 .env 文件)下读到的是进程环境里那一份;.env 改了而没重启时,本动作按文件当前内容回退,而重启后 materialize 也用同一份——两者不冲突。
  • 错误(都绝不改文件,且都带审计 error):.env 源不可读 → 500 {error:{code:"env_defaults_unreadable"}};面板配置不存在/不可解析 → 500 {error:{code:"models_unreadable"}};写盘失败 → 500 {error:{code:"llm_persist_failed"}};已写盘但热重载失败 → 500 {error:{code:"llm_reload_failed"}}(说明文件已改、未生效);LLM 栈未注入 → 500 {error:{code:"llm_unavailable"}}。
  • 面板:LLM 页的「重置为 env 默认」卡片(frontend/web/src/admin/modules/llm/LlmResetFromEnv.tsx,行上标 owner 专属)——点按钮先展开中文二次确认文案,确认后才带 confirm:true 提交;结果按 reset/skipped 渲染成可复核的组名 + 键名摘要。

管理面板写操作契约(POST/PUT/DELETE /api/v1/admin/*,均 owner-only,读为 owner/admin;写操作经 audit 落 audit_logs;成功 {data} / 失败 {error:{code,message}}):

  • POST /admin/personas body {id,name,role,systemPrompt,characterRef?,modes?/mode?,memoryNamespace?,thinking?} → 201 {data:PersonaSummary}(v0.43 起与读端点同形状:含 mood + thinking?;注册进 persona registry + 持久化到 configs/personas/<id>.yaml);id 已存在回 409 duplicate。systemPrompt 自 v0.51 起必填(落的 YAML 键是 identity.systemPrompt):缺失/空白 → 400 bad_request(消息 "systemPrompt is required (identity.systemPrompt must not be empty)"),不再派生默认说明串回落——载入期(loader.Load)执行同一规则,因此写出的 YAML 必能被重新载入。
  • PUT /admin/personas/{id} body 同上(不含 id)→ 200 {data:PersonaSummary}(v0.43 起与读端点同形状:含 mood + thinking?,面板可直接用它回填);不存在回 404。systemPrompt 与创建同一把门:v0.51 起同样必填,缺失/空白回 400 bad_request(同一条消息)——不派生默认串、也不静默沿用旧值,请求被拒即不落盘。
  • thinking(可选,design/01 §2.1){budgetTokens?,effort?,showToUser?,mode?}:写入 YAML 的 thinking 节,驱动该人格的思考预算/开关/呈现。mode ∈ default|on|off(缺省 default),非法 mode 或负 budgetTokens 回 400 bad_request(invalid thinking config: …)。showToUser 是 bool 且可缺省:缺省 = 保持现行为(thinking 帧照常推客户端);显式 false = 只落库不回显。PUT 省略整个 thinking 对象时保留已存的 thinking 节(不因客户端不认识该字段而抹掉);显式传对象则整体替换(字段全缺省即清空)。该字段不构成能力边界。
  • 人格 thinking 读侧契约(v0.43,回读形状):GET /admin/personas 每项按与写侧同构的形状回读 thinking,供面板回填:
    • 未配置的人格:thinking 键完全不出现(不是全零对象)——与写侧「省略 thinking 对象 = 保留已存」互为镜像,客户端可有样学样地「原样回填 / 原样省略」。
    • 已配置时 四个键都出现:budgetTokens(number,未设为 0)、effort(string,未设为 "")、showToUser、mode(string,未设为 "")。
    • showToUser 是三态、且读侧如实三态:null = 未配置(保持现行为:thinking 帧照常推客户端);true/false = 显式选择(false = 只落库不回显)。服务端不会把 null 拍平成 false(写侧用的是 *bool,读侧同样保留指针语义),否则「没配过」会被面板误显示成「关」。
    • 能力提示由服务端负责,客户端不要假装:effort 的有效白名单随当前生效 provider 而定(OpenAI 兼容系 none/minimal/low/medium/high/xhigh/max;DeepSeek 仅 none/low/high/max,别名 minimal→low/medium→high/xhigh→high/ultra→max;未知 provider 不支持)。服务端在模型层按 provider / 形状校验,非法或该形状不支持的字段一律丢弃并记 WARN(不降档、也不静默无效)(design/04 §3.2 / v0.53);读端点只回读已存配置,不承诺该值在上游真的生效。面板可给出提示与前端枚举校验,但不得把「配了 effort」呈现为「上游已启用」。
  • POST /admin/users → 501 not_implemented(无用户表:登录仅用 .env 单 admin ADMIN_USERNAME/ADMIN_PASSWORD,角色为 owner>admin>viewer 常量;多用户存储 + 登录接入需先设计——design/11 单用户为主。不做「建了却登不进」的假用户)。
  • POST /admin/users/apikey(无请求体,v0.51 取消 role 分级)→ 201 {data:{id:<掩码>, key:<明文·仅此一次>, role:"owner"}};key 仅此响应出现一次,任何有效 key 鉴权后即 owner(裁决 15.4.3:key 不做角色分档;旧的 {"role":…} 请求体被忽略,表里也没有角色维度)。
  • DELETE /admin/users/apikey/{id}(id=列表掩码)→ 200 {data:{id,revoked:true}};未命中 404。
  • GET /api/v1/admin/owner-profile(owner-only,读不审计)→ 200 {data:{id,displayName,defaultPersona,identityFile,notes,accounts:[{platform,accountId}]}}(后三个可选字段为 omitempty——为空时键不出现,客户端按缺省处理即可);文件不存在(全新安装)→ 200 + 空白档案(accounts:[],非 null,面板据此渲染空表单让主人首次填写);档案损坏/读不出 → 500 {error:{code:"owner_profile_unreadable"}}(如实报错,不返回一份看似正常的空档案——否则「白名单是空的」与「档案读不出来」长得一模一样)。
  • PUT /api/v1/admin/owner-profile(owner-only + 审计 owner.profile.update)body 同形状(accounts 可空/省略;各字段首尾空白会被去掉——面板里手打的平台名/账号 id 常带空格,带进白名单就永远匹配不上)→ 200 {data:<落盘后档案>};id/displayName 缺失或 accounts 项半空(只给 platform 或只给 accountId)→ 400 {error:{code:"bad_request", message:"invalid owner profile: …"}} 且不落盘(整条全空的占位项被跳过,不算错);写盘失败 → 500 {error:{code:"owner_profile_persist_failed"}}。落盘 configs/users/owner.yaml(0600 原子写:临时文件 + rename)+ 热替换活档案,新白名单立即参与渠道层 actor 判定、无需重启;组合根未暴露活档案时只记一条 WARN(已落盘、需重启生效),不假装热重载成功。它不是提示词/LLM 判定的输入——谁能当主人始终由这份档案的 accounts 白名单 + 每渠道 adminIds 决定(design/11 §0)。
  • POST /admin/iot/{id}/control body {action,params?} → 200 {data:{id,name,type,state,online}}(真实下发到 IoT provider);404 设备不存在 / 400 动作不支持或参数错 / 409 设备离线。
  • POST /admin/iot/scenarios、POST /admin/iot/rules → 501 not_implemented(IoT 无场景/规则模型与存储:iot.Provider 只有设备列表/控制/状态;通用 automations 只跑 cron/事件规则,不含设备状态条件 + 设备动作执行器。场景/规则 CRUD 需先新增模型 + 执行器)。
  • POST /admin/automations body {name,kind:"cron|event",expression} → 201 {data:{id,name,kind}}(真实注册规则);无效 cron/规则回 400 invalid_cron|invalid_rule。
  • POST /admin/automations/{id}/run → 200 {data:{id,output}}(手动触发);404。
  • DELETE /admin/automations/{id} → 200 {data:{id,deleted:true}};404。
  • POST /admin/llm body {id?,provider?,baseUrl,apiKey?,model,multimodal?,fallback?,maxTokens?,contextWindow?,reasoning?,personaModels?,personaProviders?} → 201 {data:{provider:{id,name,provider,baseUrl,model,multimodal,enabled,fallback?,maxTokens?,contextWindow?,reasoning,personaModels?,personaProviders?}, persisted:true, path}}。真实持久化:读-改-写 models.json(本地、gitignore),保存组提升为生效组(index 0),并热更新运行中的 LLM provider(path 为落盘文件路径);API key 写入文件但永不回显/不落日志。provider 为空默认 openai;缺 baseUrl/model 回 400;maxTokens / contextWindow 为负回 400;未注入 LLM stack 回 500 llm_unavailable、写盘失败 500 llm_persist_failed、热重载失败 500 llm_reload_failed。maxTokens 缺省/0 = 未设(回落 LLM_FALLBACK_MAX_TOKENS / 适配器默认);contextWindow 缺省/0 = 未声明(按回落窗口 131072 推导)。⚠️ 时机:预算推导只在进程启动时算一次(cmd/cyrene/wire_context.go 组合期)⇒ 面板改窗口后需重启才重算(provider 栈本身是热更新的,两者口径不同,面板文案已写明「重启后生效」)。.env 为兜底。v0.53 新增三键(reasoning.kind / personaModels / personaProviders,语义与校验见上方「模型配置契约(v0.53)」):三者缺省 = 保留已存值,给出则 reasoning 整块替换、两张 persona 表整表替换;非法 kind / 非法协议名 / 未知 anthropic 形状 → 400 bad_request。v0.59 新增 contextWindow(组级模型窗口,语义见上方「上下文窗口与预算契约(v0.59)」):缺省 = 保留已存窗口、显式 0 与缺省同义、负值 400。
  • POST /admin/llm/test body {provider?,baseUrl,model,apiKey?,prompt?} → 200 {data:{ok,provider,model,reply,tokens}}(真实模型调用);无效/失败回 400 invalid_provider / 500 llm_test_failed。API key 仅本次请求内使用,不落库、不进 wire。
  • POST /admin/llm/reset-from-env(v0.62,owner-only,审计 llm.resetFromEnv)body {confirm:true}(缺 = 400 bad_request)→ 200 {data:{reset:[{group,keys}], skipped:[{group,reason}], path}}:把面板里 .env 声明过的键退回 env 值,组集合不增减、env 未声明的键原样保留、无键可改时不写盘,只回键名不回显凭据值。失败:500 env_defaults_unreadable / models_unreadable / llm_persist_failed / llm_reload_failed / llm_unavailable。完整契约见上方「重置为 env 默认契约(v0.62)」。
  • POST /admin/channels/open、POST /admin/channels/close body {name} → 200 {data:{name,connected}}(真实 Connect/Disconnect);404(未注册渠道)/409 session_rejected(微信 iLink 侧拒绝/作废了该会话,重连无法修复,须先「扫码登录」重新绑定,v0.39)/409 needs_login(微信 iLink 尚无 token)/500(其它凭据缺失等连接失败)。
  • POST /admin/channels/test body {platform?,accountId,isGroup} → 200 {data:{role,owner,isGroup,env,verdict}}。结构性判定:channel 层 actor 元数据 × configs/users/owner.yaml 白名单,绝不靠提示词。
  • 微信 iLink「会话被拒」契约(v0.39,适配器级,非端点):iLink 返回 errcode=-14 时适配器进入显式被拒态——IsConnected() 恒 false;Connect() 不再返回成功,而是返回「需要重新扫码」类错误(POST /admin/channels/open 对应 409 session_rejected);HealthCheck 同样把「被拒」与「未连接」区分开。GET /admin/channels 该平台行带 needsRescan:true + lastError(人类可读原因,绝不含 token)。清除条件:扫码登录拿到新 token(login-status 报 confirmed)或配置里换了新 token(保存后热重载)。被拒期间:login-status 不假报 confirmed 而是 failed + 被拒原因(避免面板把已作废的凭据当成功重新落盘);login-qr 仍可正常出码(不回 already_logged_in 挡住重新扫码);get_bot_qrcode 不再携带已被拒的 token(否则服务端会以「已绑定」应答而不发新凭据)。未实现该能力的平台:needsRescan/lastError 不出现(omitempty),客户端按缺省处理。
  • 事实说明(勿误读为「过期」):iLink 的 bot token 是长期凭据(实测一个月前扫码得到的 token 仍可用),errcode=-14 表示 iLink 侧拒绝/作废了该会话凭据(解绑、在别处重新登录、协议层拒绝等),不是时间性过期;恢复方式为重新扫码绑定(或配置换新 token)。当前尚未在真实日志中观测到 -14,此处按「被拒」语义处理以防静默变哑。
  • KOOK 适配器面板端点(design/17 §2,owner-only + 审计,仅 kook 平台实现;其它平台回 501 not_implemented,账号未找到回 404):这些端点均带可选 ?accountId= 定位到具体 channels.json 账号(不传=该平台首个账号)。token/密钥不打印、不落日志;子项拉取失败不伪造、记入 errors。
    • GET /admin/channels/{platform}/view?accountId= → 200 {data:{platform,accountId?,friends?,userChats?,guilds?,channels?,members?,errors?}}。聚合该账号真实数据(好友/申请/屏蔽、服务器、首个服务器频道与成员、私信会话);errors 为 {子项:原因} 记录失败的子项。
    • GET /admin/channels/{platform}/friends?type=&accountId= → 200 {data:{friends?,requests?,blocked?}}。type ∈ friend|request|block(空=全部),调用 KOOK GET /friend?type=。
    • POST /admin/channels/{platform}/friend/handle?accountId= body {id,accept} → 200 {data:{handled:true,id,accept}}。同意/拒绝好友申请(KOOK POST /friend/handle-request)。
    • POST /admin/channels/{platform}/message/test?accountId= body {targetId,content} → 200 {data:{msg_id,msg_timestamp}}。真实向 targetId(频道 id)发一条 kmarkdown 测试消息(KOOK POST /message/create),验证 bot 可达性。
    • GET /admin/channels 每条账号附 capabilities(可选 []string,如 kook 的 ["friends","user_chats","channels","guilds","message_test"]),供面板按平台渲染专属功能卡片。
  • KOOK「token 即用、其余自动获取」(适配器契约,非端点):填 KOOK 渠道时只必填 token(bot_token)。base_url 留空即用官方默认 https://www.kookapp.cn/api/v3;receive_mode 空默认 ws(WS 模式地址由 GET /api/v3/gateway/index?compress=0 自动获取,已接入);webhook_path 空默认 /webhook/kook。高级值(改用 webhook、webhook_verify_token、webhook_encrypt_key,仅 webhook 模式需要)经 PUT 的 config JSON 传入(前端走「额外 config」,如 {"receive_mode":"webhook","webhook_verify_token":"..."})。Connect 拿到 token 后自动调用 GET /api/v3/user/me(Bearer 鉴权)解析 bot 账号(id/username/nickname),存到 adapter 并随 GET /admin/channels 账号的 botId/botUsername/botNickname 显示;user/me 失败仅记日志、bot 身份为空,不中断连接/收发。token 只在 header / 写文件,不打印、不进日志。
  • OneBot v11 四端契约(v0.55,适配器契约 + 配置面;design/08 §4 / design/13 §0,裁决 1.4 / 12.4.1):qq-obv11 适配器按 transport(ws|http) × mode(client|server) 提供四个端,谁向谁发数据各不相同:
    • ws + client:Cyrene 主动拨号 OneBot 的 WS 端点,收发同一个长连接;ws + server:OneBot 反连 Cyrene 的 listen_addr,收发同一个长连接(两端均沿用改造前语义)。
    • http + client:Cyrene 作为 HTTP 客户端调 OneBot 的 HTTP API——POST {api_base_url}/{action}、等响应、按 retcode 判成败(纯出站,无长连接、无监听端口);token 只走 Authorization: Bearer。
    • http + server:OneBot 主动 POST 到 Cyrene 的 listen_addr + report_path(纯入站);fail-closed 鉴权(配了 access_token 时 Authorization: Bearer 或 ?access_token= 必须匹配,否则 403)、10MiB 请求体上限(超出 413)、方法非 POST 回 405,成功回 204。
    • 配置面(channels.json 账号 config + 适配器 Config() 快照)新增:transport(ws|http,默认 ws,未知值一律回落 ws)、api_base_url、report_path(默认 /,无前导 / 时补上)。快照里 camelCase 为 transport/apiBaseURL/reportPath;remoteURL 快照值 = remote_url,为空时回落 api_base_url(语义即「出站远端地址」)。既有键与值不变(不给出任何新配置时行为与改造前一致:transport=ws、mode=server)。
    • env 兜底:CHANNEL_QQ_OBV11_TRANSPORT(默认 ws)/ CHANNEL_QQ_OBV11_API_BASE_URL / CHANNEL_QQ_OBV11_REPORT_PATH。
    • 关键取舍:mode 决定哪一半必需(server 必需 listen_addr、client 必需 api_base_url),另一半只要字段给齐就一并启用 ⇒ 单个适配器即可完成「收上报 + 回消息」闭环;四个端仍各自可独立选择与测试。任一端配置有误即 Connect 失败,绝不留下半开的监听端口。
    • 端口例外:http + server 会用 listen_addr,属 AGENTS.md §9 已登记的同一豁免(design/13 §0 原文即写「WS server / HTTP server」);listen_addr 为空则报错、绝不开端口、无默认值。
    • 未接:get_group_list 枚举(后续);access_token 为空时上报路径放行(与既有 ws + server 同口径,尚无 fail-closed 开关)。
  • PUT /admin/channels/{platform}/config(owner-only,审计)body {id?,token?,baseURL?,remoteURL?,listenAddr?,mode?,accessToken?,selfID?,config?,allowGroups?,denyGroups?,allowUsers?,denyUsers?,groupMsgPerMin?,msgPerMin?,maxMessages?,personaId?} → 201 {data:{persisted:true,path,platform,accountId}}。真实持久化 + 热更新(design/04 §3.1,无需重启):读当前有效配置(channels.json→env 兜底)→ 按 id 打补丁(PatchChannel)→ 落盘 channels.json(本地、gitignore,0600)→ bridge.Reload(platform) 热替换该平台 adapter(进行中消息用旧、新消息用新);token 写入文件但永不在响应/日志回显(GET 列表为掩码)。platform 必填;空配置回 400;未注入渠道配置服务回 500 channels_unavailable、写盘失败 500 channel_persist_failed、热重载失败 500 channel_reload_failed。.env(CHANNELS_JSON / CHANNEL_*_TOKEN)为兜底。
    • config(v0.54 起为 patch 语义,与 personaId 同构):config 是只含本次请求显式给出的键的补丁,不是一整套完整配置。取值三态:键省略(该键不在 config 里,或 config 整体缺席 / 为 null)= 保留该账号已存值(未给出的键绝不被抹掉);给出非空值 = 覆盖该键;显式 null 或 "" = 清除该键(适配器随后回落到自己的零值;0 / false 是真实取值,不算空)。顶层便捷字段(token / baseURL / remoteURL / listenAddr / mode / accessToken / selfID)是普通 string,只在非空时并入补丁(普通 string 分不出「缺席」与「显式空串」)⇒ 要清除 bot_token 之类的键,必须走 config 里的显式 null / ""。为什么:面板只发送用户本次碰过的平台字段——若把「键不存在」当成清除,主人打开一张账号卡直接保存就会静默抹掉面板不渲染的凭据(微信(iLink)的 bot_token/bot_id/owner_id/get_updates_buf,iLink token 是长期凭据;Telegram 只填 webhook_url 会连 token 一起丢、账号被 cleanAccounts 整个删掉)。仍是整值替换(不走三态):allowGroups / denyGroups / allowUsers / denyUsers 与 groupMsgPerMin / msgPerMin / maxMessages(线上是值语义)——省略即清空,客户端须回传完整列表 / 非零值。未改:token 要求型平台(kook / telegram)上显式清 bot_token 仍按 cleanAccounts 丢掉该账号(这是明确要求清除凭据,不是误删);「本次请求一个 config 键都没给出」在非 weixin-ilink 平台仍回 400(weixin-ilink 豁免,以便先建账号再扫码)。
    • personaId(v0.44 新增,v0.45 起为三态):=该账号答话时的角色 id(design/22 §2 方案 b,取值来自 GET /personas)。取值三态:键省略(或 null)= 保留该账号已存绑定(不动);显式 "" = 解绑(写盘时该账号不带 personaId 键);非空 id = 绑定该角色。未知角色 id 回 400 bad_request(不绑一个答不了话的角色;校验只针对本次真的传了值的情形,省略继承的已存绑定在写入时已校验过)。为什么是三态:面板只在主人动过角色下拉时才发这个键——若把「键不存在」也当成解绑,主人只改 token 就会静默清掉账号的角色绑定(两端语义相反即丢数据)。写盘即生效:保存后 Reload(platform) 会重建该平台 adapter,新角色在下一批入站消息上生效(无需重启)。它不是身份/权限输入——谁能做什么仍由 channel 层 actor 元数据 + 白名单决定(design/11 §0)。优先级见 §4.2「角色解析优先级」:渠道绑定只作用于该账号的主人私聊,且仅当主会话自身未绑角色;群聊/陌生人会话强制默认角色。
  • DELETE /admin/channels/{platform}/config?id={accountId}(owner-only,审计)→ 200 {data:{persisted:true,path,platform,accountId}}。删除该平台指定账号 → 落盘 channels.json → bridge.Reload(platform) 热更新(该账号 adapter 优雅关停)。id 必填;未注入服务回 500 channels_unavailable。
  • GET /admin/channels/{platform}/messages?limit=&before=(owner/admin 读)→ 200 {data:[{id,sessionId,role,content,mode?,mood?,image?,platform?,deviceId?,senderName?,createdAt}]}。IM 适配器日志流(design/16 §4.6 §4):该平台所有非主人私聊/群聊消息(隔离会话 sess-xxx,非主会话 web)以消息流显示,senderName 标发送者昵称;未注入 HistoryStore 回 501。
  • POST /admin/plugins/install(owner-only + 审计 plugin.install)body {kind:"mcp",name,config:{transport:"stdio|http|sse",endpoint?|command?,args?,apiKey?,env?}} → 200 {data:{installed:true,name,kind:"mcp",transport,toolCount}}。v1 只装 MCP server(单二进制无法热装 Go 插件):真实 connect+discover,把发现的工具热注册进 capability registry(下次模型请求即注入,无需重启)→ 落盘 plugins.json(本地、gitignore、0600,apiKey 写入但永不回显/不落日志)→ 持有 live server 供后续刷新/卸载。同名重复安装 = 刷新(先卸旧再装新,失败回滚旧)。校验:name 必填、kind 非 mcp 回 400、transport 非法/stdio 缺 command/http|sse 缺 endpoint 回 400;连接/发现/注册失败回 502 plugin_install_failed 且不落盘(诚实,不静默装一半)。未注入 plugin manager 回 501 not_implemented。
  • POST /admin/plugins/uninstall(owner-only + 审计 plugin.uninstall)body {name} → 200 {data:{name,uninstalled:true,kind:"mcp"}}(已装 MCP:Unregister 移除其工具 + close + 删 plugins.json 条目;非 MCP/普通能力走原 registry.Remove,返回 {data:{name,uninstalled:true}});未命中回 404。
  • POST /admin/memories body {personaId?,content,scope?} → 201 {data:{id,scope,text}}。手动添加记忆(owner 私聊直写);scope ∈ user|persona|project,缺省 persona(与「记住…」同路径);无效非空 scope 回 400;未注入 memory.Store 回 501。
  • POST /admin/memories/search body {query,personaId?,topK?} → 200 {data:{query,personaId,semantic,hits:[{id,scope,text,score}]}}。真实检索(keyword×衰减);语义/向量路径需主会话注入 vector index(semantic 当前恒 false,诚实反映未接向量器)。personaId 省略时(v0.42 起)回落到库里注册的默认角色(default 标记的那个 character → 其 persona),不再写死 "cyrene";若既未传 personaId 又没有任何默认角色 → 400 bad_request(诚实报错,而不是拿一个不可能命中的 id 去查)。
  • GET /admin/sessions/{id}/export → 200 {data:{session:{platform,channelId,sessionId,actorRole,owner,isGroup,memWritable}}};404。当前导出会话身份元数据(完整消息转录由 M10 提供)。

系统设置契约(POST /api/v1/admin/system/*,均 owner-only,审计;成功 {data} / 失败 {error:{code,message}}):

  • POST /system/revoke-keys(无 body)→ 200 {data:{revoked:n}}。吊销全部 API key:清空 key 表(先前签发的所有 key 立即失效),revoked 为吊销数量;未注入 API key 服务回 500 users_unavailable。
  • POST /system/rotate-jwt body {newSecret} → 200 {data:{rotated:true,note:"现有 token 已全部失效,需重新登录"}}。轮换 JWT 密钥:newSecret ≥32 位,替换当前签名密钥,先前 token 全部失效;newSecret 缺失/空白回 400;长度 <32 回 400;认证服务未配置回 500 auth_unavailable。新密钥永不回显、不落日志。
  • POST /system/backup(无 body)→ 200 {data:{path,size}}。备份数据:经 SQLite VACUUM INTO(含已提交 WAL 内容)快照到 <dataDir>/backup-<ts>.db,path 为备份文件绝对路径、size 为字节数;数据库路径未配置回 500 backup_unavailable、快照失败回 500 backup_failed。
  • POST /system/restore body {path} → 200 {data:{restored:true,backup,note:"已安排恢复,需重启服务后生效"}}。恢复数据:path 须为 dataDir 内的备份文件(路径穿越防护——filepath.Rel 校验,.. 逃逸回 400);文件不存在回 404;先自动备份当前数据库(backup 为快照路径)再替换 DB 文件;运行进程持有 DB 句柄,替换后需重启服务生效;数据库路径未配置回 500 backup_unavailable、替换失败回 500 restore_failed。

存储状态契约(GET /api/v1/admin/system/storage,v0.60) —— 上面四条是动作(写 + 审计),这一条是只读查询:owner-only、不审计、只读(只有 SELECT/PRAGMA + os.Stat,复用组合根注入的活连接——不新开连接池、不写、不迁移、不备份)。它回答「现在这台机器上到底存了什么」,是面板首页「存储」卡的数据源。

  • 请求:无 body、无 query。Authorization: Bearer <JWT>(role=owner)或 X-API-Key;无凭据 / 非 owner → 401。
  • 响应 200 {data:…}(三块,字段名逐字对齐 internal/services/gateway/admin_system_storage.go):
{"data":{
  "main":{"kind":"sqlite","path":"/srv/cyrene/data/cyrene.db","fileBytes":12894208,
          "walBytes":107872,"shmBytes":32768,"pageSize":4096,"pageCount":3148,
          "freelistPages":12,"freeBytes":49152},
  "documents":{"count":37,"bytes":2143567},
  "vector":{"kind":"none","enabled":false,"ready":false}
}}
  • main.kind 恒为 "sqlite"(当前唯一的主库引擎);main.path = 组合根实际打开的库文件路径(与备份/恢复端点同一个来源)。
  • main.fileBytes = 主库文件字节数(os.Stat);walBytes/shmBytes = <db>-wal / <db>-shm 边车文件字节数,合法地不存在时如实为 0(已 checkpoint 或库未开 WAL)。
  • main.pageSize/pageCount/freelistPages 来自 PRAGMA page_size / page_count / freelist_count(一条 SELECT 取完);freeBytes = pageSize × freelistPages,即「已分配但空闲、VACUUM 可回收」的量级。
  • documents = documents 表的行数与 SUM(size)(读表,不 walk 目录);空表时 bytes 为 0(COALESCE),不是 null。
  • vector = 可选 pgvector 增强的运行时真相(主人 2.4 的困惑点:面板只写「SQLite」会让人以为 PG 没生效):
    • kind:"pgvector" = 已配置 DB_PG_URL;"none" = 未配置(前端显示「未启用(未配置 DB_PG_URL)」)。
    • enabled:开关在不在(等价于 kind != "none")。
    • ready:活着的向量索引确实是 PostgreSQL+pgvector 后端。配了 PG 但连接/schema 准备失败 ⇒ enabled:true + ready:false(检索已降级),客户端应显示「已配置但未就绪」,不要把 enabled 当成「生效了」。
    • 真源 = 组合根的运行时状态(DB_PG_URL 非空 + 活索引的具体类型),网关不自行探测。
  • 不含凭据(红线):DSN / 用户名 / 密码 / postgres:// 全文一个字都不回,vector 段只有 kind/enabled/ready 三个键(有专门用例断言响应体里搜不到这些串)。
  • 错误:
    • 401(无凭据 / 非 owner / 凭据无效——与其它 ownerAuth 端点同档);
    • 500 {error:{code:"storage_unavailable",message}}:数据库路径未配置、活连接未注入、或页级统计 / documents 表读不到(例如库没跑迁移)。诚实边界:此时不返回任何全 0 的假快照,客户端应显示错误而不是 0。
  • 只读保证:调用前后 documents 行数与数据目录内的文件集合均不变(有用例钉住:既没有写、也没有顺手备份)。
  • 前端用法:adminSystemStorage()(frontend/web/src/lib/api/adminSystem.ts)→ 卡片 value = SQLite 12.3 MiB、hint = WAL/页数/空闲页、详情行 = documents N 件 与 PG 增强 …;取数走与其它已接线卡片同一条加载/错误态。

LLM 用量汇总契约(GET /api/v1/admin/llm/usage,v0.64,切片 U1) —— 只读查询:owner|admin 读(与同页 GET /admin/llm 同门;含成本与模型/用途分解,不是给 viewer 的监控投影)、不审计、只读(只有 SELECT,复用组合根注入的活连接——不新开池、不写、不迁移)。它回答「这些模型调用到底花了多少」,是面板 LLM 页「用量」小卡的数据源。字段名逐字对齐 internal/services/gateway/admin_llm_usage.go。

  • 请求:GET /api/v1/admin/llm/usage?since=&until=&purpose=,Authorization: Bearer <JWT>(role=owner|admin)或 X-API-Key。
    • since/until:接受两种形态 —— RFC3339(如 2026-10-05T00:00:00Z,JS 的 new Date().toISOString() 就是这个)或 Unix 毫秒(纯数字,如 1759622400000;与 /sessions/{id}/messages 的 before/after 游标同一种口径,便于客户端复用)。其它形态 → 400 bad_request(绝不静默当成「现在」)。
    • 缺省窗口 = 近 24 小时(until 缺省 = 现在,since 缺省 = until − 24h);until 必须严格晚于 since,否则 400。
    • 窗口上限 = 90 天:跨度超过上限回 400 {error:{code:"bad_request"}}(文案点名上限)——不静默裁剪,静默裁剪会让客户端把「我没查那么久」误读成「就那么少」。
    • purpose:空格截断后精确匹配用途标签(如 chat / memory_extract / noise_review);缺省/空 = 不过滤。
  • 响应:200
{"data":{
  "window":{"since":"2026-10-04T00:00:00Z","until":"2026-10-05T00:00:00Z","purpose":""},
  "totals":{"calls":128,"prompt":412000,"completion":38000,
            "cacheHit":356000,"cacheMiss":56000,"hitRate":0.8641},
  "byPurpose":[{"purpose":"chat","calls":120,"prompt":410000,"completion":37000,
                "cacheHit":355000,"cacheMiss":55000,"hitRate":0.8659}],
  "byModel":[{"model":"deepseek-chat","calls":128,"prompt":412000,"completion":38000,
              "cacheHit":356000,"cacheMiss":56000,"hitRate":0.8641}]
}}
  • window 回显补齐后的时间窗(缺省值也回显,客户端不必自己猜)与用途过滤("" = 未过滤);时间一律 UTC RFC3339。
  • totals:整窗合计,键恰好六个——calls(成功调用次数)、prompt(输入侧 token)、completion(输出侧 token)、cacheHit/cacheMiss(输入侧两档)、hitRate。
  • hitRate 口径(重要):cacheHit / (cacheHit + cacheMiss),分母为 0 时下发 0(口径与 model.Response.CacheHitRate 同源,绝不下发 NaN/Inf/编造的 100%)。⚠️ 客户端展示时建议把「分母为 0」与「真 0% 命中」分开:前者显示 — + 说明「本窗口没有输入侧缓存明细」,后者才是 0.0%(Web 面板的用量小卡就是这么做的)。
  • byPurpose / byModel:按用途 / 按模型分解,元素键 = 分组键 + 上面六个合计键(分别是 purpose / model);排序 = 调用次数降序、同次数按键升序(输出确定,客户端可安全 diff);无数据时是 [] 而非 null。
  • 不含凭据(红线):响应里既没有 apiKey / token,也没有消息正文 —— 那些东西根本没进 llm_usage 表(写入方的样本结构里就没有这些字段)。
  • 错误:401(无凭据 / 凭据无效);403(viewer;与 /admin/llm 同门);400 bad_request(since/until 形态非法、since 不早于 until、跨度超 90 天);500 {error:{code:"usage_unavailable"}}(汇总出口未接线,或查库失败)——诚实边界:此时不返回全 0 的假汇总,客户端应显示错误而不是 0。
  • 数据来源与写入口径(谁在写这张表):每次成功的模型调用都会把一条非敏感样本(adapter / model / purpose / 六个 token 计数 / tookMs)异步落进本机 llm_usage 表(迁移 0017)。写入是有界队列 + 单写者 + 批量事务:队列满即丢弃并限流 WARN,绝不阻塞模型调用;整批失败只记 WARN,不影响任何一次对话。凭据与消息正文绝不入库、绝不进日志。
    • ✅ session_id 已接线(v0.65 更正):loop 在 Run 顶部把会话 id 播种进运行 ctx(model.WithSessionID),适配器落库时带上它(空串映射为 NULL)——v0.64 写的「当前恒为 NULL」已过期,该列从此真的有值。但本端点仍不按会话过滤 / 分组(只按 purpose / model 分解),响应形状不变;sink 未注入或会话未播种时行为逐字不变。
    • 保留期:LLM_USAGE_RETENTION(默认 720h = 30 天;≤0 = 关闭清理,样本全留,启动日志会写明「llm usage retention disabled」);启动期清一次 + 此后每小时清一次。
  • 鉴权/审计档位说明:它是查询不是动作,因此无审计记录;矩阵归类见 §2.0 的 ②(owner/admin 只读档)。
  • 前端用法:adminLlmUsage()(frontend/web/src/lib/api/admin.ts)→ 卡片「用量(近 24h)」显示调用数 / 输入 token / 缓存命中率(cacheHit + cacheMiss = 0 时命中率显示 —);取数走与其它已接线卡片同一条加载/错误态(读不到就显示错误,不显示 0)。

知识库 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/成本本端点不统计、不返回(不伪造指标)——token 与成本由 v0.64 新增的 GET /admin/llm/usage 单独回答(见 §2「LLM 用量汇总契约(v0.64)」)。
  • GET /trace?limit=100 → {"data":[{id,parentId?,name,kind,status,startedAt,endedAt?,durationNs,props?,events?]}。kind ∈ llm_call|tool_call|orchestration|state_transition|subagent|memory|guardrail;durationNs 为纳秒;events 为 [{time,type,props?}]。
  • GET /audit?limit=100 → {"data":[{id,who,what,when,result}]}。id 为整数自增;when 为 RFC3339;来源为 SQLite audit_logs 表(未注入 AuditStore 时退化为进程内存)。
  • GET /logs?level=&limit= → {"data":[{time,level,source,msg}]}(后端日志 tail,v0.35)。
    • 来源:logger 进程内有界环形缓冲(容量 2000,满则丢弃最旧),只存 logger 已产生的记录,不额外打印、不重渲染、不落库;控制台/文件输出完全不受影响。source 取自 logger.WithSource 的 source= 属性(logger.Scoped 的记录回退为 module/sub),无来源记 ""。
    • 排序:time 倒序(最新在前)。time 为 RFC3339(UTC,秒精度),与 trace.startedAt / audit.when 一致;level ∈ debug|info|warn|error;msg 为记录消息。
    • 查询参数:level ∈ debug|info|warn|error(大小写不敏感)为最低等级过滤(如 level=warn 返回 warn + error),空/缺省 = 全部,未知值回 400 invalid_level;limit 缺省 200、上限 1000(小于等于 0 或非法值回落缺省)。日志量超过 limit 时按时间倒序截断(先按 level 过滤再取 limit,缓冲全量 2000 条内扫描)。
    • 诚实边界:组合根未注入日志环形缓冲(非二进制主链路启动)时回 501 not_implemented,不返回伪造/空的成功 tail。日志内容为 logger 已产出内容,敏感信息按既定规范本就不落日志(本端点不新增任何打印)。

通知聚合契约(GET /api/v1/admin/notifications,owner/admin 读;design/10 §3 #16):

  • GET /admin/notifications?type=&before=&limit= → 200 {"data":[{id,type,title,body?,source,createdAt}]},按 createdAt 倒序(同毫秒按 id 倒序稳定)。
  • 查询参数:type ∈ system|channel|backthink|audit(空/缺省 = 全部,未知值回 400 invalid_type);limit 缺省 50、上限 200;before 为 createdAt(epoch 毫秒)游标,含游标本身(createdAt <= before——两源时间戳精度不同(审计秒/纳秒、backthink 毫秒),页边界可能落在同一毫秒,客户端按 id 去重)。缺省/非法 before = 从最新开始。
  • 字段:id = <source>:<原生 id>(audit:<audit_logs.id> / backthink:<backthink_records.id>),跨页稳定、可去重;source ∈ audit|backthink;createdAt 为 epoch 毫秒(无法解析的审计时间戳记 0,不伪造时间)。title/body 由各自源派生:审计项 title = what(如 channel.open)、body = "<who> · <result>";backthink 项 title = "主动推送(<trigger> · 已送达|未送达)"、body = 结论摘要(≤200 rune,超出加 …)。
  • 类型归类(审计源按操作前缀派生):channel.* → channel;system.* / plugin.* / policy.* → system;其余审计操作(persona/user/memory/rag/session/upload/audio/…)→ audit;backthink 主动推送 → backthink。
  • 数据源与诚实边界:只聚合现有源(审计 audit_logs + backthink 记录),不新建 notifications 表。backthink 仅 shouldPush=true 的记录算通知(纯思考记录不推给主人);未注入的源 → 该类型为空,不伪造(无 backthink 服务即无 backthink 项);源报错回 500 notifications_failed(不静默少给)。本版为只读 feed:未读/已读游标后置(不建重表)。

3. WebSocket /ws/chat(实时)

  • 握手: ws://<host>:8080/ws/chat?token=<JWT> 或 Authorization: Bearer;未认证在握手前回 401 JSON。

  • 下行(服务端→客户端): 经 bus 广播 {type:"<event>", data:..., turnId?, seq?, sessionId?}(turnId/seq 为流式关联字段,见 §3.1;sessionId 为会话持久化(design/16)新增,能确定会话的事件带上,供客户端把该帧路由到它自己那个会话的消息桶(v0.54 起:非激活会话的帧不再被丢弃);v0.36 起结构化流帧 message.* / thinking.* / tool_call.* / tool_execution.* 也带 sessionId + turnId——多会话并发流式时客户端据此把 delta 落到各帧自身 sessionId 的消息桶,见 §3.1)——message.start/delta/end / thinking.start/delta/end / tool_call.start/delta/end(以上模型流帧的 data 另带 streamId,v0.50)/ tool_execution.start/update/end(工具执行三事件,v0.50)/ turn.start / turn.end(回合边界帧,v0.50)(结构化流,design/04 §1.1),以及向后兼容的 stream_chunk / stream_end / thinking(思维链) / mood(情绪变化,见 §3.7) / reminder / device_update / tool_used(工具回显) / subagent_event(逐代理进度) / subagent_result(后台批次的最终结果,见 §3.10) / approval_notice(主代理代审小字,见 §3.11) / graph_update / message_persisted(会话持久化增量,见 §3.3)/ message_edited、message_deleted(消息编辑/删除增量,见 §3.8)/ session_read(已读游标推进,见 §3.9)/ observability_log、observability_trace、observability_metrics(可观测实时推送,见 §3.12,v0.55)。会话编排状态(design/15,均为广播,data.sessionId 区分会话):session_busy / pending_ack / pending_drain / session_idle / session_interrupted / pending_full(见 §3.2)、chat_duplicate(客户端幂等键重复到达的明确回执,v0.56 新增,见 §3.13);另有 steer_ack(忙时会话里排队的那条用户消息已被在飞回合吃进同一回合的回执,v0.57 新增,见 §3.14)——它经同一状态通道,但由 hub 直接广播、不经 bus,帧壳带 sessionId(data 内不重复);因它携带具体消息身份,不属上面这组「状态机信号」。语音回复走 voice_output(定向回请求连接,非广播);队列满的定向拒绝走 {type:"error", data:{code:"chat_busy", …}}(只发发送者,见 §3.2);语音回合本身走与文本相同的编排路径(见 §3.15)。

  • 下行帧的角色门(v0.48,design/11 §7.1): 下行帧按类型分两类,由后端唯一一处收口判定(不在白名单里的帧默认只发 owner + admin,fail-closed):① 状态机信号(白名单,不含任何内容)→ 所有已认证连接(含 viewer):session_busy / pending_ack / pending_drain / session_idle / session_interrupted / pending_full(§3.2)、session_read(§3.9);② 其余全部 → 仅 owner + admin:流式内容 message.* / thinking.* / tool_call.* / tool_execution.start/update/end / turn.start / turn.end(v0.50 新增的这几种同属本档——工具进度与回合边界对 viewer 也是内容相邻信息,fail-closed 默认即只发 owner+admin,未扩白名单)/ stream_chunk / stream_end / thinking(§3.1)、持久化 message_persisted(§3.3)/ channel_persisted(§3.4)/ message_edited / message_deleted(§3.8)、系统内部 graph_update(§3.5)/ subagent_event / subagent_result(§3.10,v0.51 新增的这帧同属本档) / approval_notice(§3.11,v0.52 新增,同属本档) / task_output / task_done / tool_used / capacity_awaken / mode_switch、私有状态 mood(§3.7)/ device_update / reminder、可观测推送 observability_log / observability_trace / observability_metrics(§3.12,v0.55 新增,同属本档——未进 7 个状态机信号白名单,fail-closed 只发 owner+admin)、chat_duplicate(§3.13,v0.56 新增,同属本档——它虽由编排器状态通道下发,但未进那 7 个状态机信号白名单,故 fail-closed 只发 owner+admin)、steer_ack(§3.14,v0.57 新增,同属本档——它携带具体消息 id 与模型步序号,未进那 7 个状态机信号白名单,故 fail-closed 只发 owner+admin;经 hub 直接广播、不经 bus)。

  • 上行帧与握手回放的角色门(v0.49,design/11 §7.2): 与上面那条下行门成对,收的是同一个缺口的两处残余——① 客户端→服务端(上行)JSON 帧:只有 owner / admin 连接可以发;非 owner/admin(即 viewer)连接发来的任何上行 JSON 帧都默认被拒,回复是定向(只回发送者)的 {type:"error", data:{code:"forbidden"}},不广播、不驱动模型、不落库、不返回任何内容。被拒帧:chat / interrupt / subagent_ask / task_output_query / voice_input / mode_switch——未登记的新上行帧同样默认拒,要放行必须先在 design/11 §7.2 登记白名单(实现上那把「默认拒」也必须同步改为登记表,不能直接删)。② 握手 history 回放:仅 owner + admin 连接收到;viewer 连接建立后不收回放。协议级帧不受影响:WS ping/pong 与正常 close 照常(否则连接会掉),被拒的上行帧也不会关掉连接。

    这是「谁被主动推送」的门,不是数据可见性:viewer 的 12 条只读监控端点(viewerAuth,v0.40)照旧(含 GET /personas 的角色级 mood);viewer 仍可连 /ws/chat(连接鉴权不变),只是收不到第 ② 类帧。定向回请求连接的帧(voice_output 等)不受影响。上一条的门只管「推给谁」;「谁能发、谁能拿回放」见上面 v0.49 那条——viewer 连上后发不出任何指令、也收不到 history 回放。

    history 回放事件(design/16,新增):WS 握手升级成功后、该连接下一帧即收到 {type:"history", sessionId:"web", data:{messages:[{id,sessionId,role,content,mode?,mood?,image?,platform?,deviceId?,personaId?,clientMsgId?,createdAt}]}}——回放主会话最新 50 条(thinking 已剥离;默认主会话,次级会话由客户端按激活会话走 GET /sessions/{id}/messages 拉取)。客户端据此在重连/新开连接后补全上下文。消息体含 senderName?(第三方昵称,design/16 §4.6,主人私聊为空)与 personaId?(v0.42,说话时的角色,design/22 §7-5,空 = 默认角色)。角色门(v0.49,design/11 §7.2 A):这条回放只发 owner + admin 连接;viewer 连接建立后不会收到 history 帧(它若需要只读数据,走 §1 的只读 REST 端点)。

3.1 结构化流事件(message.* / thinking.* / tool_call.* / tool_execution.* / turn.start/end)

  • 事件壳:{type, sessionId?, turnId?, seq?, data}, data 为类型化结构(非 any)。seq 为 hub 级单调递增序号,供客户端将一次突发的流式事件排序关联。sessionId 为事件所属会话、turnId 为该轮助手回合 id(均 v0.36 起带上):sessionId 由 loop 在回合边界注入(模型适配器在 loop 之下、不感知会话,恒留零值;loop 外无会话上下文的事件该字段为空),turnId 由网关每个回合新生成并挂进运行上下文——同一会话同时只有一个 in-flight 回合,故同轮内所有帧的 turnId 一致。客户端据此把 message.delta / thinking.delta / tool_call.* / tool_execution.* 路由到该帧自己 sessionId 的会话消息桶(v0.54 起:非激活会话的帧不再被丢弃——后台会话的正文继续累积、切过去即可看到完整这一轮);只有「单槽忙态」(session_busy / session_idle / session_interrupted 与 stream_end 的 sessionBusy 收口,见 §3.2)仍按 sessionId === activeSessionId 判定,以免别的会话的忙/闲帧串到屏幕上的会话。
  • streamId(v0.50,模型流帧 data 内可选字段):message.* / thinking.* / tool_call.* 帧的 data 带 streamId(如 {"delta":"…","index":0,"streamId":"stream-a1b2…"})——同一次模型流的多帧共享同一 id,loop 在每次模型流(一次 ChatStream 调用)开始时生成;多步工具轮的第二次模型调用取新 id。turnId 标记「这轮回答」(整轮恒定),streamId 标记「这轮里的哪一条流式消息」;一个回合含工具调用时会有多个 streamId。tool_execution.* 与 turn.* 帧不属于任何模型流,不带 streamId。
  • message.start({index, streamId?}) / message.delta({delta, index, streamId?}) / message.end({index, streamId?}):正文段。delta 为正文 token 增量;客户端对 message.delta 累加到当前消息正文;message.start/end 划定正文段边界,不结束整轮(结束由 stream_end 负责)。
  • thinking.start({index, streamId?}) / thinking.delta({delta, index, streamId?}) / thinking.end({index, streamId?}):思维链段(前端折叠块)。thinking.delta 累加增量。
  • tool_call.start({id, index, streamId?}) / tool_call.delta({id, index, delta, streamId?}) / tool_call.end({id, index, streamId?}):工具调用中间态;tool_call.delta 携带参数增量(如 Anthropic input_json_delta)。
  • tool_execution.start({tool, phase:"running", id?}):工具进入执行(v0.50)。tool 为工具名;phase 为细粒度执行阶段(枚举与 §3.5 流程图节点 status 同一套 running/done/error);id 为 provider 工具调用 id(可关联同轮的 tool_call.* 帧,provider 未给 id 时省略)。
  • tool_execution.update({tool, phase:"running", delta?, id?}):工具运行中的进度/局部输出,一次执行可多帧(v0.50)。现状:wire 映射已实现并验证,但工具分发器尚无中途进度上报源,loop 目前不产出该帧(start/end 有真实生产者)——出现生产者后无需再改契约。
  • tool_execution.end({tool, phase:"done"|"error", id?}):工具执行完成或失败(v0.50);phase:"error" 即失败(design/19 流程图的 tool 节点据此翻 error 态;错误详情不进该帧,走流程图节点日志的脱敏摘要)。
  • turn.start / turn.end(无 data 载荷):回合边界帧(v0.50),loop 在 Run 的起止发出、经 ws_bridge 下行;帧壳的 sessionId/turnId/seq 即边界信号,客户端用它括起「一轮回答」的完整区间(含其全部模型流与工具执行)。turn.end 覆盖回合所有出口(最终答案 / 工具循环安全网终止 / 预算耗尽 / 步骤出错);回合终态信息(stopReason/steps)仍由既有 stream_end 携带,两帧分工不重叠。内部自主流程(backthink / 子代理)被静音,不发出这两帧。
  • 向后兼容(v0.50 对 tool_execution 的替换说明):v0.49 及之前的单帧 tool_execution.update {id,index,delta}(delta 形如 "start:工具名" / "end:error:工具名" 的拼串)不再由 loop 产生——按旧拼串解析的客户端必须迁移到三事件(tool_call.* 帧形状不变)。turn.start/end 与各帧 data 里的 streamId 为纯新增键,老客户端按未知帧/未知键忽略即可。现有 Web 前端未消费这些新帧,行为不变。
  • 向后兼容:未映射/无法确定的事件仍走 stream_chunk + stream_end(非流式回退路径)。
  • 上行(客户端→服务端): mode_switch / chat(跑 loop 并广播流式) / voice_input / subagent_ask / task_output_query / interrupt。
  • interrupt(上行,客户端→服务端):{sessionId, scope?}(scope 为 v0.56/A2 新增)→ 会话编排器中断(design/15 §3)——取消该会话当前在跑的任务(context cancel)并回 session_interrupted;用户说「停下/算了/换个话题」时发。scope 取值域 {"", "all", "current"}(大小写敏感,只容忍首尾空白):
    • 省略 / 空串 / "all"(三者同义)= 取消当前全部在途路 + 清空待处理队列,会话立即回到空闲(逐字不变的现状语义,老客户端只发 {sessionId} 的行为完全不变);session_interrupted.dropped = 被清空的 pending 条数、mode = idle、activeTurns = 0。
    • "current" = 只取消在途路、保留待处理队列(多路时每一路都取消——省略 turnId 的 scope 就是「作用于每一路」):队列里的消息按编排器正常续跑规则继续执行(带上会话尾部上下文),一条都不丢。此时 dropped 恒为 0,mode 如实报告该会话当时的忙碌通道(thinking/executing/subagent,不是 idle)。用途:客户端「打断」与「追加队列」是两种模式,若打断一律连队列清空,用户选「打断」时连发的多条消息会被静默丢弃、只剩一个 dropped 数字。
    • 计数键(v0.65,纯增量):session_interrupted.data 增 cancelledTurns(本次真的取消了几路)与 activeTurns(清点后仍在途的路;"all" 时恒 0);并不发 session_idle(与今天一致,客户端靠本帧收口)。
    • 未实现(v0.65 诚实边界):精确停某一路的 interrupt.turnId 本版未实现(省略 turnId 即作用于全部在途路,见上);{sessionId, turnId} 里的 turnId 今天会被忽略。
    • 其它取值 → 只回发送者 {type:"error",data:{code:"bad_request"}}(定向:不广播、不打断任何会话);显式报错,绝不静默按默认执行(否则客户端以为队列保住了、实际被清空,正是 A2 要修掉的那类缺陷)。
    • 命中空闲会话为 no-op(两种 scope 都是)。
  • chat 帧的上行字段(v0.56/G4 + v0.59 + v0.65):{sessionId, text, image?, deviceId?, mode?, clientMsgId?, documents?, parallel?}——clientMsgId 为客户端自带的幂等键(可选):同一会话内同一个非空值只承认第一条,重复到达不落库、不起回合,回新下行帧 chat_duplicate(§3.13);省略 = 与加入该字段之前逐字相同。documents(v0.59)为文档附件引用数组(§1 行 16/17 与 §1.2)。parallel(v0.65,可选) = 「把本条作为独立一路处理」的显式声明:只有会话正忙、且单会话并发额度未满(默认 2 / 硬上限 3 / <2 关闭真并行)才照准——照准则立即起第二条路(帧壳 turnId 即那一路),额度满、或真并行被关掉、或未声明则落回既有排队语义并在 pending_ack 带 reason:"parallel_limit"(见 §3.2);空闲时无效果;服务端不做启发式自动升级。REST POST /api/v1/chat 这几个可选项语义完全相同(重复回 200 {data:{duplicate:true,messageId,sessionId}}、照准并行回 202 {data:{parallel:true,turnId}},见 §1 行 3)。
  • voice_input(上行,客户端→服务端):{audio?, format?, text?, sessionId?, deviceId?, clientMsgId?}。audio 为 base64(或 data: URI)音频,format 为其编码(wav/mp3/ogg);text 为已转写文本时跳过 STT;deviceId 为客户端具体端标识(design/16 §4.5,如 voiceassist:myapp:v1,可选),随该语音轮次落库、聊天气泡可标注来源。clientMsgId(v0.59)=客户端自带的幂等键(可选,与 chat 帧同形 ⇒ 语音也有幂等):同一会话内同一个非空值只承认第一条,重复到达不落库、不起回合、不发音频,回既有下行帧 chat_duplicate(§3.13);省略 = 与加入该字段之前逐字相同。语音回合现在走与文本回合同一条路(进编排器 → 落库 → 流式 → 跨端同步 → 忙时排队),详见 §3.15。服务端走 STT→agent 聊天→TTS(design/06 §1),回复经 voice_output 定向回该连接。真 STT/TTS 已接通阿里百炼(DASHSCOPE_API_KEY 必填:STT=qwen-audio-3.0-asr-flash、TTS=cosyvoice-v3-flash+longxiaochun_v3);无凭据时后端走降级链(cloud → edge-tts/espeak(未实现)→ silent 空音频)。无注入语音服务时回 error 帧(voice unavailable)。
  • voice_output(下行,服务端→客户端,定向):{audio, format, text, reply, sessionId, deviceId?, provider?}。audio 为 base64 合成音频,format 为其编码,text 为识别文本,reply 为助手回复文本;v0.59 新增 sessionId(恒有)与 deviceId(非空才有)——多端同时说话时客户端据此区分归属。当合成音频为空(silent 降级)或 TTS 失败时带 provider(如 "silent",非空音频时省略)——客户端据此提示「没生成声音」,但该帧仍照常发出、不算业务错误;空正文一律走同一条降级信号,绝不拿 stopReason 顶替成语音内容。时点与投递口径见 §3.15(回合结算时才到、定向目标 = 发起那条连接、断线不补投)。
  • 向后兼容:未映射/无法确定的事件仍走 stream_chunk + stream_end(非流式回退路径)。

3.2 会话编排状态事件(design/15)

角色门(v0.48,design/11 §7.1):本节的 6 个状态信号都在白名单内——它们只报「会话忙/闲/排队」,不含任何内容,所以所有已认证连接(含 viewer)都收得到;真正的正文/推理内容走 §3.1 的流帧,那些只发 owner + admin。

  • 触发:同一会话内,chat 到达时若「她忙」(正在跑 loop/工具循环/子代理/长回复)→ 新消息不阻塞、入该会话 pending;这一层的状态经以下事件推给前端,供「正在做 xx… 有新消息稍后回」轻状态展示。
  • session_busy:会话进入忙碌态({sessionId, mode, activeTurns},mode ∈ idle|thinking|executing|subagent)。activeTurns(v0.65,纯增量) = 该会话当前在途路数(含正在收尾的路;单路时非 0 即 1)——并行时它才是真正的路数。
  • pending_ack:忙时新消息已入队({sessionId, mode, queueDepth, reason?})——「收到,稍后回」。reason(v0.65,纯增量、可选):只在「客户端显式声明了并行(parallel:true)、本条却落回排队」时出现,取值恒为 "parallel_limit"(额度已满,或真并行被 <2 关掉——客户端问了并行而没拿到);其它情况该键完全缺席,帧与今天逐字相同(绝不伪造)。
  • pending_drain:忙完开始消费下一待处理消息({sessionId, mode, queueDepth, activeTurns})。
  • session_idle:会话回到空闲({sessionId, mode, activeTurns})。⚠️ 时点(v0.65,行为变更,仅并行时生效):从「队列清空」收紧为「所有在途路都已收尾,且 pending 为空」——单路时这个时点与今天逐字相同(此刻在途路数必为 0);真并行下,只要还有任一路在飞(哪怕它正在续跑队列里的下一条)就不报空闲。
  • session_interrupted:用户 interrupt 中断({sessionId, mode, dropped, cancelledTurns, activeTurns})——语义随 scope(v0.56/A2):scope:"all"(也是省略 / 空串)= 取消当前全部在途路 + pending 清空,此时 mode = idle、dropped = 被清空的 pending 条数、activeTurns = 0;scope:"current" = 只取消在途路、pending 保留(多路时每一路都取消,因为省略 turnId 的 scope 就是「作用于每一路」),此时 dropped 恒为 0、mode 为该会话当时的忙碌通道(thinking/executing/subagent,非 idle)。dropped 恒等于真的被丢弃的条数(绝不把「保留」报成丢弃)。两个计数键(v0.65,纯增量):cancelledTurns = 本次真的取消了几路,activeTurns = 清点之后仍在途的路(scope:"all" 恒为 0)。scope:"all" 不发 session_idle(与今天一致,客户端靠本帧收口)。
  • 多路下的未实现项(v0.65,诚实边界):精确停某一路的 interrupt.turnId、编排器发出的 turn_begin 帧、steer_ack 的帧壳 turnId 本版均未实现(见 §5 v0.65 条目 J 段);graph_update 亦未按路分桶(两路会同会话互相顶掉)。客户端不要依赖这些帧/键。
  • pending_full:单会话 pending 达上限(默认 5)拒绝入队({sessionId, mode, max})——防无限积压(design/15 §4)。v0.59(G5)载荷增两个可选键:clientMsgId?(= 被拒那条消息的客户端幂等键;该消息未携带则整个键缺席,绝不伪造)与 messageId?(仅当该消息此前已落库 / 已认领时才回填)。⚠️ 服务端绝不在此处新铸 id:队列满的拒绝发生在幂等键认领之前,代铸一个 id 会让客户端拿同一 clientMsgId 重试时被判成「重复到达」而永远进不来。
  • pending_full 的定向伴生帧(v0.59 / G5,此前无文档登记):队列满时除广播 pending_full 外,服务端还会向发送这条消息的那条连接单独发一条定向错误帧 {type:"error", data:{code:"chat_busy", message, sessionId, clientMsgId?, messageId?}}——只发发送者、不广播(其它端按广播帧里的 clientMsgId 自行对齐即可)。code 与 REST 的 409 chat_busy 同码(客户端一个 code 覆盖两条入口)。用途:WS 路径此前没有与 REST 409 等价的回执,本地乐观气泡只能永远挂在「发送中…」;有了它就能精确回滚那一条。
  • 上下文续接:忙完重开时新回合带上一轮上下文({mode, queueDepth} 之外的续接由服务端完成),非清空重来(design/15 §2.2)。
  • 客户端侧(v0.54):sessionBusy / pendingCount 是单槽全局状态(不按会话分桶)——前端只在帧的 sessionId === activeSessionId 时置忙/置闲(frontend/web/src/lib/ws/handlers/session.ts,置忙与置闲两侧同一把尺子),与「消息按自己的 sessionId 入桶、非激活会话不丢弃」不同(见 §3.1)。这是 design/16 §4.2 已登记的单槽债(多会话并发时忙态徽标只反映屏幕上的会话)。

3.3 消息持久化增量事件(message_persisted,design/16)

  • 触发:每一条 user / assistant 消息落库后由编排层广播——Web 主会话路径走 persistUserMessages/persistAssistantReply,IM 渠道主人私聊(design/16 §4.4)由 channelbridge 把消息落主会话 web(带 platform 标记)后复用同一 message_persisted 广播(同一 hub 的 BroadcastPersisted),保证 Web 端实时镜像 IM 消息。
  • 载荷:{type:"message_persisted", sessionId, data:{message:{id, sessionId, role, content, mode?, mood?, image?, platform?, deviceId?, senderName?, personaId?, createdAt}}}——role ∈ user|assistant;id 为后端生成的消息 id(客户端据此去重);platform 为来源平台(web 或渠道如 telegram/weixin-ilink/qq-obv11);deviceId 为具体端/客户端标识(design/16 §4.5,如 web:chrome:a1b2c3 / weixin-ilink:conf0:wx_uid);senderName 为第三方发送者昵称(design/16 §4.6,群聊/陌生人消息带,主人私聊为空,omitempty);personaId(v0.42,omitempty)=该消息说话时的角色 id(design/22 §7-5;空 = 全局默认角色,即升级前的全部历史),客户端据此按角色渲染名字/头像;clientMsgId(v0.56,omitempty,G4)= 客户端自带的幂等键(空 = 该消息没有幂等键:老客户端、IM 镜像、历史行),客户端据此把本地乐观气泡对齐到服务端真相;thinking 已剥离(design/16 §6)。documents?(v0.59 起;v0.63 补 reason):载荷里的消息与 historyMessage 同结构(同一结构体),因此带附件的回合会带 documents 数组([{name,mime,size,chars?,truncated?,status?,reason?}],0 个附件时省略该键)。⚠️ 时点:本帧在消息落库那一刻发出(用户消息早于该回合的抽取),因此帧里的 status/reason 可能还没有结论;抽取结论由回合结束后的回写补进库里,以读取面(GET /sessions/{id}/messages、握手回放)为准(有附件的回合请按读接口渲染「读到了 / 读不到 + 原因」)。
  • 注:群聊/陌生人(非主人)消息只在该 IM 适配器日志界面以消息流显示,不经 message_persisted 进 Web 主会话(design/16 §4.6 §3-4);该日志流由 GET /api/v1/admin/channels/{platform}/messages 拉取,落库后另经 channel_persisted(§3.4)实时推送。
  • 客户端处理:把帧按 data.message.sessionId(缺省时用帧壳 sessionId)写进该会话自己的消息桶(v0.54 起:非激活会话的消息不再被丢弃,切过去即可见,见 §3.1);按 id upsert——本端已预建的同 role 乐观气泡(本地 uid 无后端 id)被归一(uid → 后端 id),异端无本地气泡则直接追加,两者最终收敛为同一条 id 相同的消息,「A 发消息、A 与 B 都无需刷新且各出现一次」。

3.4 适配器消息实时事件(channel_persisted,design/16)

  • 触发:每一条群聊/陌生人(非主人 / 隔离 sess-xxx)消息落库后由 channelbridge persistIsolated 广播;该消息只写自己的隔离会话,绝不写主会话 web,channel_persisted 仅是事件通知,不是把内容塞进主会话/Web 上下文(design/16 §4.6 §3-4)。
  • 载荷:{type:"channel_persisted", sessionId, data:{message:{id, sessionId, role, content, mode?, platform?, deviceId?, adapterId?, senderName?, createdAt}}}——与 historyMessage wire 一致;adapterId 为产生该消息的适配器注册名(如 kook:3bef...,adapter != '');sessionId 为隔离会话 id(sess-xxx,非 web);role ∈ user|assistant;senderName 为第三方昵称;clientMsgId(v0.56,omitempty,G4)= 客户端自带的幂等键(空 = 无幂等键,如渠道入站消息)。
  • 事件类型分流(BroadcastPersisted 按 sessionId 分流):sessionId == "web" 或其 web:<uuid> 前缀 → message_persisted(Web 聊天实时同步);其它(隔离 sess-xxx)→ channel_persisted(适配器日志专用)。
  • 客户端处理:Web 聊天无 channel_persisted handler,天然忽略、不进会话;IM 适配器日志页订阅该事件,按 adapterId === 当前适配器(或总览 adapterId != '')过滤后仅追加(按 id 去重、不整页重载),首次拉取仍由 GET /api/v1/admin/channels/messages + 轮询兜底。

3.5 实时全链路流程事件(graph_update,design/19)

角色门(v0.48,design/11 §7.1):graph_update 属「系统内部」帧 → 仅 owner + admin(viewer 收不到,与本节「仅主人 Web 会话发布」及 design/19 §1「flow 可视化仅 owner/admin」一致)。REST 侧可见性不变。

  • 触发:每次会话轮次中,loop 的每个 trace span 开始/结束时由 loop.FlowPublisher 经 bus ws.graph_update 广播增量 FlowGraph。发布范围为三类会话(v0.50 口径,裁决 01.4.5):web(Web 主会话) / web:<uuid>(Web 子会话,含 IM 主人私聊镜像落主会话的形态) / sess-backthink(后台思考的固定内部会话,design/20 §2 的「思考链路图」由此取数)——其余隔离会话(IM 群聊/陌生人的 sess-xxx)与子代理轮次不发布,防敏感信息泄向面板。管理员/主人 Web 端订阅后实时渲染「输入 → 思考(LLM) → 工具 → 输出」主干图。tool 节点的 status 由细粒度 phase 驱动(v0.50,design/04 §1.1 + design/19 §1):工具执行期间为 running,执行结束按成败翻 done / error——与 §3.1 tool_execution.* 帧的 phase 同一套枚举、同一处决策。
  • 载荷:{type:"graph_update", data:{id, nodes:[{id,label,kind?,status?,startedAt?,endedAt?,props?,log?}], edges:[{from,to}], sessionId, startedAt, endedAt}}。
  • 子代理分支节点的 props.persona(v0.47,design/22 §8-2):subagent_spawn 指定了目标角色派发时,该子代理在父会话图里的分支根节点 props 会多一个 persona 键(值 = 目标角色 id),标注它正在扮演的角色;不指定角色派发时该键不出现(与旧版逐字一致),客户端按未知键忽略即可。
    • id 为 sessionId-round(每轮一图);sessionId 为会话 id;startedAt/endedAt 为 RFC3339。
    • kind ∈ input|llm|tool|subagent|memory|guardrail|orchestration|output。
    • status ∈ idle|running|done|error(running = span 在跑 / done = 成功 / error = 失败)。
    • props 仅含白名单摘要(如 stop_reason / error_kind / result_summary),工具原始参数与密钥绝不前送;长文本截断到 ~80 字。
    • 节点 props 的可选键(v0.52,裁决 11.4.2 / 11.4.3)——旁路节点(memory / guardrail / orchestration)的 span 现已真实产生,其 props 新增下列键。全部为纯增量、可选;值在发布前已脱敏(凭据形状 → [redacted]、超长截断);帧名、事件名、顶层字段集一字未动:
      • tool 节点:args_keys: string[](该次调用的全部参数名,字典序,值一律不出现)、args_summary: {[key]: string}(仅放行表内、且非密钥类的键才给值——密钥类键由 trace.IsSecretKey 判定(凭据形状 / *_key / *token* 等),连值都不给;单值 ≤80 rune、最多 24 条,表外键与超出条数的键连条目都没有,客户端按 args_keys 逐键列表、未放行的显示「值未展示」)。
      • guardrail 节点:action(guardrail.input | guardrail.output)、allow: bool、elapsed_ms;检查本身报错时另带 error。
      • memory 节点:action(recall | write)、scope(owner | restricted)、hits / bytes / count / failed、elapsed_ms——只记元数据,不含任何记忆正文。
      • orchestration 节点:subagent(被派发的子代理 id)、persona(指定目标角色时才有)、elapsed_ms。
      • input 节点(v0.57,裁决 13.4.5 目标态):steered: bool——本回合在回合内边界吃过新到达的用户消息(steer)时出现;纯增量、可选,老客户端忽略即可(标记只并入既有 input 节点,不新建节点、不改标签,图仍只有一条主干)。
    • log 为该节点累积的 span 事件 [{time,type,props?}](设计 §2「节点实时日志」)。
  • 客户端处理:graph_update → setFlowGraph(parseGraph(data))(src/lib/wsParse.ts)——图按 data.sessionId 落进该会话的「图桶」(v0.54,裁决 16.4.2;前端 frontend/web/src/store/sessionCache.ts 的 putGraph / currentGraph,桶上限 12 张、超出时丢最久未更新的一张)。管理端「全链路流程」页(admin/modules/Flow.tsx)与 Agents 页取激活会话的图(该会话还没有图时回落主会话最近一张);Backthink 页(admin/modules/Backthink.tsx)取 sess-backthink 的图——后台思考的图不再顶掉管理员看的「当前会话」图。节点可点开看参数/结果/实时日志;无图时诚实显示「暂无流程」。

3.6 主动推送事件(proactive_message,design/12)

  • 触发:后台思考(backthink)自主决定「此刻主动发一条」时,经 SendProactive 投递给当前所有在线 owner/admin 连接(viewer / 未登录连接永不投递)。SendProactive 返回实际送达的连接数——0 时调用方回退 IM 桥(im_bridge),把该消息经渠道主动推送发给你(KOOK/微信等已接)。
  • 载荷:{type:"proactive_message", data:{platform?, channelId?, channelType?, content, contentType?, sender?, timestamp?, messageId?}}(由 adapter.UnifiedMessage 脱敏投影:不含 RawData / 媒体字节 / token,绝不让凭据漏到前端)。
  • 客户端处理:仅 owner/admin 收到;渲染成一条「她从某平台主动发来」的气泡(内容 + platform + sender + timestamp)。与 message_persisted 的区别:message_persisted 是你与她的对话回显;proactive_message 是她未经你输入主动推的消息(如 IoT 完成事件、后台思考结论),两路不同、不要混用去重。
  • 红线:owner-only;payload 已脱敏;backthink 每次主动推送只发一个决策平台(决策渠道→最近活跃→旋转回退),Web 端与 IM 桥为两条投递路,客户端不要据此断言「所有平台都发了」。
  • 推送出口(D1,对客户端无新端点):在 WS/IM 投递之外,后端会把主动消息异步镜像到「推送出口」——主通道是主人自建统一推送服务(PUSH_EXPORTER_URL webhook,HTTP POST JSON {source,title,content,platform,timestamp},可带 Authorization: Bearer <PUSH_EXPORTER_TOKEN>),回退是个推 Getui(字段预留、SDK 未接入)。该出口只影响出站投递,不改变 WS 事件契约;未配/不可达时只记日志(注明「个推回退待接入」),不影响上面的 WS/IM 投递。移动端接入自建推送服务即可收到主人主动消息。

3.7 情绪事件(mood,design/01 §2 情绪状态机 + 好感度)

角色门(v0.48,design/11 §7.1):mood 属「私有状态」帧 → 仅 owner + admin,viewer 不再收到它;但角色当前情绪仍可读——viewer 照旧调 GET /personas 拿到该角色的 mood 字段(REST 只读档不变,见 §1)。

  • 触发:每轮对话结束(loop 回合边界,每轮一次、非每步)把该轮用户消息的情绪信号应用到该角色自己的情绪状态机(按 persona 隔离),且状态真的变化时才广播一次 mood;状态不变(含「一直 calm」)不发,避免每轮噪声。
  • 载荷:{type:"mood", data:{mood, personaId, affection}}——mood ∈ calm|happy|excited|sad|angry(情绪状态机 state id,与消息体 mood 同源同值);personaId 为该角色 id(情绪/好感度跟随角色,不跟随行为模式);affection 为该角色当前好感度(受角色配置 affection.min/max 约束)。角色级数值,不带 sessionId。
  • 与消息体 mood 的关系:assistant 消息体的 mood 是该轮结束后的当前情绪(每轮都写,见 §3.3),而 mood 事件只在变化时发——前者答「这条回复时她什么心情」,后者答「她的心情刚刚变了」。
  • 群聊/陌生人(MemWritable=false):情绪只读不写——第三方消息不改变角色情绪与好感度、不发 mood 事件、该轮 assistant 消息 mood 为空(与记忆只读同一隔离边界,design/02 §3 / design/11 §2)。
  • 状态转移与衰减:按角色配置的转移表(缺省内置表)与 emotion.decayRate——有情绪信号的轮次沿合法转移走一步指向对应状态;无信号轮次衰减一步趋向 calm;decayRate ≤ 0 关闭衰减。好感度按角色 affection.delta 随情绪轮次增减(warm +delta / cold −delta,clamped 到 [min,max])。
  • 客户端处理:mood → setMood(data.mood)(src/lib/ws.ts),顶栏/侧栏显示「心情 · 」;消息体内 mood 由聊天气泡显示(chat.tsx)。
  • 状态持久性:情绪与好感度为进程内、按角色状态,重启回到角色配置的 initialState(design/01 未要求落库;如需跨重启保留,待主人确认后加表)。

3.8 消息编辑 / 删除实时事件(message_edited / message_deleted,D3 实时同步)

  • 触发:PATCH /api/v1/messages/{id}(改 content)或 DELETE /api/v1/messages/{id} 成功写库后由网关广播(与 message_persisted 同一条 hub 广播路径,全连接广播 + 客户端按会话过滤——hub 无按会话订阅;消息体的唯一真源始终在后端,这两个事件只驱动渲染同步,不携带新真源)。
  • message_edited:{type:"message_edited", sessionId, data:{id, content, mode?, mood?, createdAt?, ...}}——data 为编辑后的完整消息 DTO(与 §3.3 的 message.message 同形,至少含 id/content/sessionId/createdAt,另带 role/mode?/mood?/image?/platform?/deviceId?);thinking 已剥离(与 message_persisted 一致)。只改 content,role/时间戳/平台等保持原值。
  • message_deleted:{type:"message_deleted", sessionId, data:{id}}——只带被删除消息的 id(sessionId 为该消息所属会话,由后端在删除前读取后填入;客户端按它过滤,不据此删除别的会话的行)。
  • 客户端处理:与 message_persisted 同一路由规则——按帧的 sessionId 写进该会话自己的消息桶(v0.54 起:不再按激活会话丢弃)(message_deleted 的 sessionId 缺失时按 id 兜底:消息 id 全局唯一,逐桶搜索、只会命中它自己那条)。message_edited → 按 id 原位更新该条的 content(不新增行:找不到该 id 即忽略,避免把别的会话的消息注入当前列表);message_deleted → 按 id 从列表移除。两个操作都不触发整页重拉——这正是它相对「重拉绕行」的收益。
  • 失败不广播:404(消息不存在)/ 400(内容非法)/ 501(未注入 HistoryStore)不产生任何事件,客户端不会因一次失败请求而丢掉本地消息。

3.9 已读游标事件(session_read,D4)

  • 触发:POST /api/v1/sessions/{id}/read 成功推进游标后由网关广播(与 message_persisted 同一条 hub 广播路径:全连接广播 + 客户端按 sessionId 过滤,hub 无按会话订阅)。用途是同账号多端清红点:一端读了,其它端不用重拉列表就把该会话的未读清零。
  • 载荷:{type:"session_read", sessionId, data:{readAt}}——readAt 为该会话推进后的已读游标(毫秒 Unix 时间戳,与该会话消息的 createdAt 同单位)。
  • 客户端处理:仅当 sessionId === activeSessionId(或该会话在自己维护的会话列表里)时处理 → 把该会话 unreadCount 置 0(更精确的做法是按 readAt 重算本地未读;本地没有更完整的数据时直接清零也不会错,因为 readAt 只前进)。它只驱动渲染:唯一真源仍是后端 chat_sessions.read_at,漏收该帧的端在下一次 GET /api/v1/sessions 自然收敛(不需要补发、不需要重连)。
  • 失败不广播:400(before 非法)/ 404(会话不存在)/ 501(未注入 HistoryStore)不产生事件,其它端不会误清红点。

3.10 后台子代理结果事件(subagent_result,design/05 §9)

角色门(v0.48 下行门的默认档,design/11 §7.1):subagent_result 属内容帧(含子代理的结论正文)→ 仅 owner + admin,viewer 收不到;它不在那 7 个状态机信号白名单里(要进白名单须先在 design/11 §7.1 登记「不含内容」的理由)。

  • 触发:subagent_spawn 派发的批次转后台后(前台只等 2 秒,design/04 §8.5)批次结算时,编排层把合并结果发布到 bus 主题 subagent.result,经 ws_bridge 中继成这一帧。在 2 秒窗口内跑完的批次不发此帧——它的结果已作为工具结果回给了模型(与本节同一份投影)。
  • 载荷:{type:"subagent_result", sessionId, data:{sessionId, status:"done", summary, agents:[{id, goal, role?, mode?, status, summary?, text?, steps?}]}}
    • 帧壳 sessionId = 派生该批次的父会话(data.sessionId 同值)——客户端据此把结果归到那个会话的消息流,多会话并发时不串话。派发回合没有会话归属时(如循环外派发)两处都省略(该帧即成无归属帧)。
    • data.status 恒为 "done"(帧只在批次结算后发出,无论批次里有没有失败/中止的子代理——逐代理结论看 agents[].status);summary 是「子代理汇总: 共 N 个, M 成功…」的一行汇总。
    • agents[] 与派发工具返回给模型的结果同形同值:id(同时也是 subagent_event 帧与流程图泳道节点的 id)、goal、role / mode(绑定的角色/行为模式,未指定时省略)、status ∈ done|failed|aborted、summary(该子代理的执行总结)、text(结论正文,截断到 4000 字)、steps(循环步数)。
  • 与 subagent_event 的分工:subagent_event 是逐代理进度(派发/开始/完成/失败/中止,{id,name,status,role,mode},不含结论);subagent_result 是整批的最终结果(含每个子代理的总结与结论文本),一个批次只发一次。
  • 客户端处理(现状):后端已按上述形状推送;现有 Web 前端尚未消费该帧(无 handler,按未知帧忽略),因此这一版的可见效果是「帧到达 owner/admin 连接」,而不是「聊天气泡里出现一条后台结果」。要渲染成「后台任务完成」提示,按 sessionId 过滤后追加即可(读 data.summary 与 data.agents[].summary/text);不要把它写进本地消息列表当持久消息——该结果不落库,后端转录里没有对应行,重连回放不会补发。
  • 边界(诚实说明,v0.51):本帧只做「会话作用域的实时推送」——不在父会话的持久化转录中新增消息,也未经 design/15 的会话消息编排器排队/续接;所以 design/05 §9 的「结果回填主会话」目前停在实时可见这一层(此前该结果在 ws_bridge 处被静默丢弃,前台只剩进度帧)。

3.11 主代理代审小字事件(approval_notice,design/11 §4)

角色门:内容帧(含角色名/动作/文案)→ 仅 owner + admin;未进 7 个状态机信号白名单(fail-closed 默认)。

  • 触发:loop 的工具准入门判定某次调用属敏感操作(design/11 §4 清单,当前实现的三个操作类别:执行命令 / 修改文件 / 网络写方法——GET/HEAD/OPTIONS 与读文件、检索属非敏感)并经主代理代审后广播。非敏感调用不产生该帧。
  • 载荷:{type:"approval_notice", sessionId, data:{actor, tool, op, detail, explain, allowed, at}}
    • sessionId = 该次 Run 所属会话(子代理的敏感调用归属到派生它的父会话,与 §3.10 同口径)。
    • explain = 固定文案 「<actor> 想要干什么,我审查后批准了」 / 「<actor> 想要干什么,我审查后拒绝了」(由后端产出,客户端原样渲染,不要自拼)。
    • detail = 动作摘要(工具 + 关键参数,目标部分截断到 80 rune);allowed=false 表示该敏感调用被拦截未执行(无代审者/代审报错也走 fail-closed 拦截,小字同样是「拒绝了」)。
    • op ∈ 执行命令|修改文件|网络访问;actor = 想动手的角色名;at = RFC3339Nano。
  • 审计:每次代审在 audit_logs 落一行:who=角色名、what=approval.<工具名>、result=granted|denied;查询走既有 GET /api/v1/admin/observability/audit 与 /admin/notifications。
  • 客户端处理:与流式帧同一会话过滤规则(sessionId === activeSessionId),把 explain 作为聊天流里的一行小字渲染、detail 作副文本(现 Web 前端已按此实现,见 frontend/web/src/components/status.tsx 的 ApprovalNoticeHint)。
  • 约束:不新增端点、不改既有帧;老客户端按未知帧忽略即可。

3.12 可观测实时推送(observability_log / observability_trace / observability_metrics,v0.55)

角色门:三个帧都不在 frameReachesAll 白名单 ⇒ fail-closed 只发 owner + admin;viewer 收不到,继续走既有 12 条只读监控 REST 档(v0.40)。未进 7 个状态机信号白名单(要进须先在 design/11 §7.1 登记「不含内容」的理由——它们是运行时数据面,不属于状态信号)。

  • 取代关系:可观测面(日志 / 指标 / trace)从「面板 1s 轮询 REST」改为 WS 帧推送(裁决 05.4.3 + 16.4.3-WS,design/10 §5)。既有 REST 端点完全不变(路径 / RBAC / level / limit 语义 / 响应形状一字未动),仍然可用,并在被读取时兼任「订阅握手」。
  • 订阅(推送开启条件):owner/admin 凭据读取 GET /api/v1/admin/observability/{logs,metrics,trace} 任一个即建立订阅(绑定该次登录凭据,键 = sub+role+iat)。该凭据的 WS 连接全部断开即退订;订阅表空后推送循环自行退出(无常驻 goroutine、无周期性开销)。这不是轮询:那次读取本身照常返回完整引导数据,此后吃推送。
  • 节拍:≤1s 一拍,无新数据不发帧(空转时一帧都不发)。
  • 三个下行新帧(纯新增,既有帧名/形状零改动;帧壳 {type,data},不带 sessionId / turnId / seq——它们不属任何会话):
帧名 载荷 data 上限 触发
observability_log {entries:[{time,level,source,msg}…]}(最新在前,与 GET /admin/observability/logs 同形) 200 条 订阅后新增日志;一拍最多一帧,无新条目不发
observability_trace {spans:[…]}(最新在前,与 GET /admin/observability/trace 同形) 100 条 订阅后新完成的 span
observability_metrics AdminMetrics 本体(扁平),与 REST 同形:totalRequests,qps,avgLatencyMs,maxLatencyMs,perMethod,perPath,statusCounts,llmCalls,subagentEvents — 与上次发出的不同才发(序列化指纹比较,键排序确定)
  • 初始锚点:订阅建立时以「当前最新」为锚(日志取最新条目时间、trace 取当前写入序号、指标取当前快照指纹),因此循环不会把环形缓冲里的历史整段灌成巨型帧——历史由面板那次引导读取提供。
  • 单帧上限的语义:一拍的突发超过上限时只发最新的那些(更旧的条目本就落在面板保留窗口之外,与 REST 尾部 limit 上限同口径),不丢「看得见」的数据。
  • 分级过滤改在客户端本地做:这三个帧经 bus 广播给所有 owner/admin 连接(hub 无按连接订阅、也无按连接分级——订阅只决定「推送循环跑不跑」),因此 ?level= 那种服务端过滤不适用于推送;面板按本地等级选择过滤即可。
  • 前端落点:frontend/web/src/lib/ws.ts 的 subscribeObservabilityFrames()(帧不进应用 store,只有打开着面板的视图在听,其余时候安静丢弃);面板挂载时各读一次对应 REST 作为引导(logs 用 limit=200),推送帧按业务键去重合并、倒序、窗口 200(frontend/web/src/admin/modules/observabilityStream.ts)。
  • 向后兼容:纯新增帧,老客户端按未知帧忽略即可;既有 REST 与其它帧/端点/鉴权一律不变。

3.13 客户端幂等回执(chat_duplicate,v0.56 / 协同 G4)

角色门:chat_duplicate 不在 frameReachesAll 的 7 个状态机信号白名单里 ⇒ fail-closed 只发 owner + admin(viewer 收不到)。它由编排器的状态通道(与 session_busy 等同一出口)下发,因此帧壳带 sessionId,没有 turnId / seq。

  • 触发:chat 上行(REST POST /api/v1/chat 或 WS chat 帧)带了非空 clientMsgId,而同一会话内已经认领过同一个值——即弱网 / 切后台下的客户端超时重发。判重由 (session_id, client_msg_id) 的部分唯一索引原子裁决(只覆盖非空键),因此并发重发只有一个赢家,不存在「先查再插」的竞态窗口。
  • 载荷:{type:"chat_duplicate", sessionId, data:{sessionId, clientMsgId, messageId}}——messageId = 既有那条用户消息的 id(客户端据此把本地乐观气泡对齐到服务端真相,替代「靠文本相等」的脆弱匹配)。它是广播给所有 owner / admin 连接的状态信号(hub 无按会话订阅),没发过该 clientMsgId 的客户端按值自行忽略即可。
  • 不做什么(红线):重复到达不落库、不起回合、不发任何内容 / 流式新帧——第二次绝不产生第二条用户消息、第二个模型调用。
  • REST 侧同义:POST /api/v1/chat 的同步与 stream:true 两条路径重复到达都回 200 {data:{duplicate:true,messageId,sessionId}}(见 §1 行 3)。
  • 回填与可见性:clientMsgId 随消息落库并回填进消息 DTO ⇒ message_persisted(§3.3)/ channel_persisted(§3.4)/ 握手 history 回放 / GET /sessions/{id}/messages(§1 行 6)都能看到(空则省略),客户端可在重连后从读接口收敛本地乐观消息。
  • 向后兼容:上行 clientMsgId 是可选字段——省略时行为与加入该字段之前逐字相同(老客户端零影响,历史行该键为空);chat_duplicate 为纯新增帧,老客户端按未知帧忽略即可。
  • 迁移:新增 0013_chat_client_msg_id(up:ALTER TABLE chat_messages ADD COLUMN client_msg_id TEXT NOT NULL DEFAULT '' + 部分唯一索引 idx_chat_messages_client_msg_id ON chat_messages (session_id, client_msg_id) WHERE client_msg_id != '';down:先删索引再删列——SQLite 的 DROP COLUMN 在列仍被索引引用时会失败;全新库由 history.NewStore 幂等前滚同一列与索引)。

3.14 回合内 steer 的消费回执(steer_ack,v0.57 / 裁决 13.4.5 目标态)

角色门:steer_ack 不在 frameReachesAll 的 7 个状态机信号白名单里 ⇒ fail-closed 只发 owner + admin(viewer 收不到)——它携带具体消息身份(客户端据此定位到某一条气泡),而不只是会话忙闲。它由编排器的状态通道(与 session_busy 等同一出口)经 hub 直接广播,不经 bus 下发,因此帧壳带 sessionId,没有 turnId / seq。

  • 触发:一个忙时会话收到了新用户消息(已入该会话 pending、且已落库),在飞回合在回合内边界把这条消息吃进同一回合的那一刻。没有可注入消息的边界一帧都不发。
  • 载荷:{type:"steer_ack", sessionId, data:{messageId, atStep}}——帧壳带 sessionId(data 内不重复该键)。
    • messageId = 那条用户消息的权威 id(后端生成,与 message_persisted 的 data.message.id 同源),客户端据此把本地乐观气泡对齐到服务端真相,不要靠文本相等这类脆弱匹配。
    • atStep = 吃进它的模型步序号,从 1 开始(1 基):被注入的消息出现在这一模型步的可见视图里。
    • 一批多帧:同一批次吃进 N 条消息就发 N 帧(每帧一条消息的 messageId),不合并——FIFO 保持(design/15 §2.1)。
  • 语义(客户端怎么渲染):该消息已被在飞回合吃进(已从待处理队列移除),但尚未被正面回答 ⇒ 客户端应把对应气泡渲染为「已读、未答」的灰态,直到该回合的 message.* / turn.end(§3.1)到达;不需要第二个确认帧。它与「忙『稍后回』」的 pending 气泡不是同一个态:灰态表示这条已经在被处理的那一轮里了。
  • 触发时机:只在回合内边界产生——loop 每一步开头(上一次模型步与它那一批工具调用都已结束、下一次模型调用尚未开始,因此此刻没有工具在飞,注入不可能取消任何已发起的工具)回调转向源;只有该边界真的匹配到 pending 里、且已落库的用户消息时才发帧(无新消息则一帧不发,这是常态),不是每个 step 都发。
  • 与 pending_ack / session_busy 的关系:后两者描述队列深度与忙闲(「收到,稍后回」「她正忙」);steer_ack 描述队列里哪一条已被本回合吃进(精确到 messageId)。客户端可用「pending 条数 − 已收 steer_ack 条数」表达 design/15 §5 的「队列里有 N 条、其中 M 条已被本回合吃进」(忙态本身仍是单槽,见 §3.2)。
  • 不发的四种情形(失败一律让路):未接转录存储 / 读该会话转录失败 / 该消息尚未落库 / 本回合世代已被打断或被更新的回合取代(认领 pending 失败)——一律不发帧、也不注入,该消息留在 pending,照旧作为自己的回合被回答,绝不静默消失。未接转向源的运行(子代理、后台思考、未接网关的循环)不产生该帧。
  • 只吃本编排器排队过的消息:候选必须在该会话转录的最近 20 条窗口内(每次边界重读转录取权威 id),且必须在本编排器的 pending 里——由别的写入方(如 IM 适配器自有路径)落库、本编排器从未排队的消息保持不注入(那条路径正在自己作答它,注入会让同一条被答两次);超出窗口的 pending 消息同样留队、由各自的回合作答(不丢)。
  • 兼容性:纯新增帧,老客户端按未知帧忽略即可;既有帧 / 端点 / 鉴权 / REST / .env 一律不变。多路下的已知缺口(v0.65 诚实边界):本帧的帧壳仍不带 turnId(v0.57 起如此,v0.65 的并行基础管线未接线它)——真并行时同一会话有两条交错的路,客户端无法从本帧判断那条消息被「哪一路」吃进;按 turnId 两级分桶是后续切片的事(见 §5 v0.65 条目 J 段)。
  • 客户端现状(诚实边界):Web 前端目前尚未消费该帧(frontend/web/src 内无 steer_ack handler),故今天的可见效果是「帧到达 owner / admin 连接」;要落成灰态,按本节口径加 handler 即可(messageId 定位气泡、message.* / turn.end 收灰)。

3.15 语音回合(voice_input / voice_output,v0.59:进编排 + 归属 + 降级信号)

行为变更(对语音客户端是实质变化):语音回合不再「转写完直连 loop 就跑完」——它进会话编排器,与文本回合走同一条路。因此语音问过的事进历史、跨端可见、参与忙/排队。帧形状的变化只有两处(§3.1 已改):voice_input 增可选 clientMsgId、voice_output 增 sessionId(恒有)/ deviceId(非空才有)。

  • 同一条路(v0.59):一次 voice_input 在 STT 之后产出的用户消息 = 转写文本,经编排器提交:
    • 落库:用户消息(转写文本)与助手回复都进 chat_messages ⇒ GET /sessions/{id}/messages 拉得到、握手回放补得到;
    • 实时帧:照发 message_persisted、流式 message.* / thinking.* / tool_call.* / turn.start / turn.end、状态帧等(与文本回合零差别);
    • 跨端同步:另一端连着 WS 也能看到这一轮(按各自 sessionId 入桶);
    • 忙时排队:目标会话正忙 ⇒ 该语音消息按编排器既有规则入 pending——pending_ack / pending_drain / pending_full / session_interrupted 的语义与形状零变化,只是多了一个消费者;排队满同样回 §3.2 的广播 pending_full + 定向 error{code:"chat_busy"}(带 clientMsgId?/messageId?)。
    • deviceId 照旧落库(design/16 §4.5);platform 走存储默认 web。
  • 幂等(v0.59):voice_input 增可选 clientMsgId(与 chat 帧同形)。重复到达 → 编排器回既有下行帧 chat_duplicate(§3.13),不产生新回合、不发音频、不落库。客户端重连后带同一个 clientMsgId 重试是安全的。
  • voice_output 的时点(重要,别当成「发出去就立刻有音频回来」):
    • 它在该回合结算时才到——可能晚于若干状态帧(pending_ack → session_busy → … → session_idle),也可能由续跑回合(忙完消费 pending 的那一轮)或被 steer 注入的那一回合(§3.14)产生;
    • 忙时先收 pending_ack;steer_ack 可作「已读未答」的灰态(§3.14);
    • 因此不能用「发完 N 秒没音频」判定失败,也不要用到达顺序推断回合归属——用帧里的 sessionId / deviceId 判归属。
  • 定向目标与断线(诚实边界):
    • voice_output 只发「发起这条 voice_input 的那条 WS 连接本身」——服务端不按 deviceId 路由(deviceId 只是载荷里的归属标注),也不会发给同账号的其它端;
    • 连接断开后不补投:服务端的收尾回调发现该连接已关就记一条带来源的 WARN 并丢弃(音频是「回复已落库」之后的增强,绝不静默假成功);助手回复本身已经落库,所以重连后从历史 / 回放能拿到正文,只是拿不到那段音频;
    • 重连后带同一 clientMsgId 重发不会重复起回合(幂等),但也不会补回那次音频(音频不落库)。
  • 降级信号(与 §3.1 同一口径):provider 只在没有真实音频时出现("silent" 或实际档位),覆盖两种情况——空正文(回合没产出文本,如被取消)与 TTS 失败 / 降级为空音频。客户端据此提示「没生成声音」,但该帧仍照常发出:客户端总能看到这条语音回合的终点,而不是永远等一个不会来的 voice_output。绝不把 stopReason(stop / idle)当语音内容。
  • 未接编排器时(未接线的构建 / 裸 hub 单元测试)保留旧直连路径:STT → 直接跑 loop → 立即合成并回音频。两条路径共用同一个帧构造,voice_output 契约一致。
  • 引擎与降级链:真 STT/TTS 走阿里百炼(STT qwen-audio-3.0-asr-flash、TTS cosyvoice-v3-flash + longxiaochun_v3);无凭据时 cloud → edge-tts/espeak(未实现)→ silent 空音频。
  • IM 渠道入站语音(v0.65,与上面这条 Web 语音回合是两条路径):IM 适配器收到的语音消息在入站流水线最前端被归一为文本(写回 msg.Content),因此丢空判据 / 最小打扰判定 / 落库 / 模型可见消息看到的是同一份文本——语音内容从此进上下文(此前纯语音消息会在丢空判据处被整条丢掉)。三条口径:① 平台自带转写优先(微信 iLink 的 voice_item.text 非空 ⇒ 不调 ASR,不重复付费);② 静默档位必须被识破(语音链末端总是成功,只看文本会把「没有可用的识别后端」当成一次成功的空转写)⇒ 只认 STTOutcome.Transcribed();③ 绝不静默、绝不留空消息——转写不成功时落一行中文诚实提示(讲清没转写成功 + 原因方向:未配置 / 鉴权失败 / 欠费 / 限流 / 超时 / 静默降级 / 静默音频),不含上游错误原文与密钥。鉴权失败 / 欠费 / 限流按原因分类做临时熔断 + 退避(TTL 恢复;熔断打开时不再发起上游调用,提示里带上退避剩余时间)。超时旋钮 VOICE_STT_TIMEOUT_SEC(秒,默认 20;≤0 用默认)只约束这一次 IM 入站转写(要一直举着一个入站并发槽),Web 客户端的 voice_input 不受它约束。无新端点 / 新帧;客户端看到的是普通的用户消息(正文 = 转写文本或那行提示),在 IM 适配器日志 / 主人私聊镜像里与其它消息一致。

4. 客户端接入指引(Web / 移动 / 原生 / IM bridge 通用)

4.1 一次性接入流程(三步走)

  1. 拿令牌:POST /api/v1/auth/login {username,password} → {data:{token,expiresAt,role,refreshToken}}(JWT,默认 24h,role ∈ owner|admin|viewer;refreshToken 为 B2 不透明长期凭据,用于静默续期,未接线 refresh 存储时省略该键)。
  2. 声明版本(可选/advisory):可带 X-API-Version: 1 + Authorization: Bearer <token>;WS 握手用 ws://<host>:<port>/ws/chat?token=<token>(或 Authorization: Bearer)。X-API-Version 是advisory惯例头——服务端当前不校验、不据此协商版本,带不带行为不变;viewer 只能读会话/人格元数据;读写会话消息、管理写操作需 owner/admin(WS 上 viewer 也只收 7 个状态机信号帧:既收不到 history 回放,也发不出任何上行指令,见 §3 顶部两把角色门,v0.48 + v0.49)。
  3. 带端标识(可选但推荐):每条消息/请求可带 deviceId(见 §4.3),让她知道你从哪个端来。

4.2 会话与消息用法(聊天)

  • 列出会话:GET /api/v1/sessions → [{id,type:"main"|"sub",title?,createdAt,pinned,unreadCount,lastMessage?,personaId?}]。排序:置顶会话(pinned=true)最前,其次是未置顶的主会话 id="web"(她与你的主线,最大化继承上下文),再按创建时间升序;⚠️ 置顶的次级会话可以排在主会话之前,别把列表第一项当主会话(按 id=="web" / type=="main" 定位);次级 web:<uuid> 由 POST /api/v1/sessions 新建(临时小事/专业问题,独立上下文回溯)。personaId(v0.42)=该会话绑定的角色;空/缺省 = 继承全局默认角色,客户端渲染激活会话的名字/头像用它(后端唯一真源是 chat_sessions.persona_id,localStorage 之类只作缓存)。
  • 新建时选角(v0.42,v0.46 收口):POST /api/v1/sessions body {personaId?} 在新建时就绑角色(省略/空 = 跟随全局默认角色)。会话角色在创建时确定、之后不可变(design/22 §8-1):没有"会话内换角色"这回事——PATCH /api/v1/sessions/{id} 一旦收到 personaId 键就回 400(见 §1 行 8),要换角色请新开会话。记忆按角色隔离:不同角色的会话各记各的,同一角色的多个会话互相可见(design/22 §3 口径 1);群聊/陌生人会话仍只读、且强制默认角色。
  • 角色解析优先级(v0.44,design/22 §2 裁决规则 + §7-3/§7-4):一个回合到底以哪个角色答话,自上而下取第一个命中的:
    1. 请求级覆盖 —— 仍未实现(design/22 §2 裁决规则 1,留待后续批次);
    2. 会话绑定 chat_sessions.persona_id(POST /sessions {personaId} 在创建时写入;之后不可变,design/22 §8-1)—— 非空即赢;
    3. 渠道绑定 —— 该 IM 账号的 personaId(PUT /admin/channels/{platform}/config,v0.44)。仅作用于该账号的主人私聊,且仅当该会话(=镜像目标主会话 web)自身未绑角色时;
    4. 全局默认角色 owner.yaml:defaultPersona —— 兜底('' 始终 = 当前默认角色)。 群聊 / 陌生人隔离会话强制默认角色(§7-4,更保守):这两类会话跳过 2/3 两条渠道与主会话绑定,一律用默认角色,避免把第三方会话挪进某个角色的记忆域;记忆仍是只读。Web 主会话与 IM 主人私聊共享同一角色(§7-3):主会话绑 Y 时,同一主会话里 IM 镜像进来的主人私聊回合也以 Y 答话。
  • 模型上下文里的「谁说的」(v0.44,design/22 §7-5):一回合的上下文由该会话最近 20 条持久化消息(chat_messages,含 persona_id)+ 本回合新消息组成(此前只带新消息本身,模型看不到会话前文);每条 assistant 历史消息按其说话角色标注,模型收到形如 [发言归属] 以下发言来自「昔涟」(xilian),不是你说的。 的独立 system 标注,以及一条声明当前角色的 [会话角色] … 段。正文永不被改写:标注只走 system 消息,用户正文与 assistant 正文一字不改(design/11 §10:不给用户正文冒充系统指令的机会)。对客户端透明(无新字段、无新端点)。
  • 改名 / 置顶:PATCH /api/v1/sessions/{id} body {title?}(≤80 rune,空白标题回 400 "title must not be blank"——空串不能清空标题/恢复自动命名)和/或 {pinned?:bool}(至少一个字段;都缺回 400)→ {data:{id,title?,pinned?}}(只回显变更字段)。body 里出现 personaId 键一律 400(会话角色不可变,v0.46 / design/22 §8-1)。
  • 未读 / 已读游标(D4):列表项的 unreadCount 直接用于会话列表红点(恒返回整数,0 = 无未读)。客户端进入会话、或滚到底看到最新消息后调用 POST /api/v1/sessions/{id}/read:body {"before":<毫秒>}(如「本次已渲染的最新一条的 createdAt」),或省略 body = 标记到现在。后端只前进地推进该会话唯一游标(单 owner 账号:读一处 = 所有端已读),并广播 session_read(§3.9)——同账号其它端免刷新清红点。列表 → 读 → 再看列表即可自洽(漏收广播的端下次拉列表自然收敛)。
  • 拉取激活会话历史:GET /api/v1/sessions/{id}/messages?limit=50&before=<ms>&platform=&q=。
    • 热分页:limit 默认 50(上限 200),before = 当前最旧一条的 createdAt(毫秒),向上翻页。platform 可选(IM 适配器传本平台只拉本渠道;Web/通用客户端不传 = 全量共享主会话)。
    • 搜索:q 非空 = 会话内内容搜索(倒序,仍支持 before/limit 分页);空 q = 普通分页;q 与 after 互斥。不跨会话。
    • 返回按时间倒序,需 reverse() 成旧→新用于渲染。
  • 编辑 / 删除单条消息:PATCH /api/v1/messages/{id} {content}(≤4000 rune)→ {data:<消息DTO>}(裸 DTO,无 message 包裹);DELETE /api/v1/messages/{id} → {data:{deleted:<id>}}。不存在回 404。重发用「编辑后内容再 POST /chat」即可(无独立重发端点)。两者成功后都会广播(message_edited / message_deleted,§3.8),同账号其它端免刷新同步。
  • 实时增量:连接 WS 后,消息落库会广播 message_persisted(§3.3)——用其中的 id 去重,按 sessionId 写进该会话自己的桶(v0.54 起非激活会话的帧不再丢弃);新开/重连连接首帧即 history 回放(主会话最近 50 条)。消息被编辑/删除则分别广播 message_edited / message_deleted(§3.8)——同一 sessionId 路由规则,前者按 id 原位改 content、后者按 id 移除,都不需要重拉整页。
  • 发消息:POST /api/v1/chat {text, deviceId?, image?, mode?, clientMsgId?, documents?, parallel?, stream?}。stream:true 直达会话编排器(v0.56/G2):受理成功后才回 200 {stream:true}(已升为独立并行路 = 200 {stream:true,parallel:true,turnId},v0.65);会话正忙已续接排队 202 {stream:true,queued:true,queueDepth}、队列满 409 chat_busy、未接 loop 500 chat_unavailable、仅未接编排器时的兜底派发失败 503 chat_dispatch_failed。非流式返回完整 reply(会话正忙时 202 {queued:true,queueDepth}、已升为独立并行路 202 {parallel:true,turnId}(v0.65)、排队满 409 chat_busy,见 §3.2)。忙时并行(v0.65):带 parallel:true 且会话已有在途回合、单会话额度未满 ⇒ 本条独立一路立即起(默认 2 路 / 硬上限 3 / <2 关闭真并行),REST 拿不到同步 reply、文本照旧经 WS 到达;省略 = 逐字不变,服务端不做启发式升级。image 解码后上限 5 MiB(超限 400 image_too_large,文案指路 POST /api/v1/upload);WS chat 帧受 4096 字节读上限约束(超限断连),故 WS 上发大图必须走「上传 → URL」。文档附件(v0.59):先 POST /api/v1/documents(multipart file,上限 20 MiB)拿到 name,再在 documents:[{"name":…}] 里引用(≤5 条、只给存储名;超限 400 too_many_documents、名非法/不存在 404 document_not_found,契约见 §1.2)。text/image/documents 至少一项非空(纯附件回合成立)。
  • 幂等发送(v0.56/G4):给自己生成的每条消息带一个非空 clientMsgId(如本地乐观气泡的本地 id),服务端在同一会话内只承认第一条:重复(超时重发 / 切后台重发)不再落库、不再起回合——WS 回 chat_duplicate(§3.13)、REST 回 200 {data:{duplicate:true,messageId,sessionId}};该键也会回填进消息 DTO,供重连后对齐本地气泡。省略它 = 老行为逐字不变。

4.3 deviceId 与 platform 规范(design/16 §4.4-4.5)

  • platform(来源平台):web / 渠道如 telegram、weixin-ilink、qq-obv11。Web 端恒 web;IM 渠道消息带各自平台。同一平台多个端 → 相同 platform、不同 deviceId。
  • deviceId(具体端,客户端自己生成):Web 每个浏览器 localStorage 持久化 web:<browser>:<随机短id>(如 web:chrome:a1b2c3);IM 按 <im平台名>:<本地配置唯一id>:<用户平台唯一uid>;某 APP 可自定可读前缀。发送时带上,服务端落库消息,聊天气泡可标注来源。
  • senderName(第三方昵称):只有群聊/陌生人(非主人)消息带,主人私聊恒空(omitempty)。IM 适配器_log:该平台非主人私聊在 GET /api/v1/admin/channels/{platform}/messages 以消息流显示。

4.4 常见错误码 / 边界

  • 401 未认证/令牌失效 → 重新登录。403 权限不足(viewer 读管理写/主会话删除)。
  • 404 资源不存在(消息/会话 id 未找到)或端点未实现;501 not_implemented 后端明确未提供(如知识库删除、新增用户)。会话相关(v0.63):GET /sessions/{id}/messages 对不存在/已删除的会话回 404 {"error":{"code":"session_not_found"}}(此前是 200 {"data":[]},客户端因此分不清「空会话」与「会话已不在」);PATCH /sessions/{id} 的未知会话仍是 404 not_found(码不同:前者点明是会话,后者是通用资源),POST /sessions/{id}/read 与 PATCH /messages/{id} 沿用各自既有码。
  • 400 请求体/参数非法(limit/before 越界、空 content)。409 chat_busy 排队满(REST 响应体与 WS 定向 error 帧同码,见 §3.2)。
  • 文档相关(v0.59 起;v0.61 更新白名单与裁决口径):415 unsupported_media_type(扩展名白名单(23 项,含 .pdf)与字节嗅探的成对裁决任一不过——HTML/纯文本改名成 .pdf、或 PDF 改名成 .txt 都走它)、413 document_too_large(超 CHAT_DOCUMENT_MAX_BYTES,默认 20 MiB)、404 document_not_found(文档名非法/穿越/不存在;chat.documents 里引用不存在的名也是它)、400 too_many_documents(单回合 >5 条)。⚠️ 诚实说明:design/24 §6.2 列的 document_unreadable(400) 目前没有对应的 REST 错误码——它只以 document-read 工具的错误形态存在(模型侧可见;客户端在 DTO 里看到的是该文档的 documents[].status = "unreadable",扫描件/图片型 PDF 就是它,v0.61)。
  • 红线:身份判定永远来自后端 channel 层 actor 元数据 + 白名单,客户端不要用提示词/文本判身份;消息体的 thinking 字段在实时帧(message_persisted / channel_persisted / message_edited)与握手回放中恒为空(有意的隐私与带宽默认,不是漏发);实时思维链仍由 §3.1 的 thinking.* 流帧按 owner/admin 档下发;历史消息的思维链只有 GET /sessions/{id}/messages(owner/admin)能拿到,客户端不应依赖其它入口。

4.5 离线 / 缓存建议(原生端)

  • PWA / 原生端缓存 sessions/personas 列表 + 最近一屏消息;重连先本地渲染再拉增量。
  • 消息真源在后端,本地只做渲染缓存;用 message_persisted.id 去重,不要用本地自增 id。
  • 语音:上行 voice_input(base64 音频或已转写 text,v0.59 起可带 clientMsgId 幂等),下行 voice_output(合成音频 base64 + text + sessionId/deviceId?,定向回发起那条连接、回合结算时才到,空正文/TTS 失败带 provider 降级信号);语音回合与文本回合走同一条路(落库 / 跨端 / 忙时排队),见 §3.15。

5. 变更记录

  • v0.65(忙时并行回合基础管线(I1′ 有条件放宽)+ 六条登记,依据 design/15 §2.5(主人 2026-10-05 裁决 B,commit 89e13cf),并一并登记 679cee0(IM 入站附件引用落库,安卓报的 P0)/ 59a1ac4(llm_usage.session_id 真正落库)/ a918819(IM 各渠道语音可被读到)与 cb564fe(memory 空 query 不触发语义回退,纯内部)/ 15181e7(Anthropic 并行工具结果合并为同一条 user 消息,修真机 400,纯内部)/ 7d2ff74(telegram 语音内联回归测试,无契约),2026-10-05 落实)。三档标注:纯增量 = ① 上行 chat.parallel?:bool(WS chat 帧 + REST 三入口同形)、② session_busy / session_idle / pending_drain / session_interrupted 的 data 增 activeTurns:int(均在既有白名单帧内)、③ session_interrupted.data 增 cancelledTurns:int、④ pending_ack.data 增可选 reason:"parallel_limit"、⑤ REST 两条新应答形状(同步 202 {data:{parallel:true,turnId}} / stream:true 的 200 {data:{stream:true,parallel:true,turnId}})、⑥ 新 env 键 VOICE_STT_TIMEOUT_SEC(默认 20s);行为变更 = ⑦ IM 入站附件引用真的写进落库的用户消息(documents 列;同一 DTO、无新字段)、⑧ IM 各渠道语音进上下文(转写不成功落一行中文诚实提示,绝不静默/空消息)、⑨ session_idle 时点收紧为「所有在途路收尾且 pending 空」(仅并行时生效,单路逐字不变);破坏性 = 无(省略 parallel / 不读新键的客户端行为逐字不变;session_id 有值属既有列,端点响应形状不变)。A. chat.parallel(纯增量,省略 = 逐字不变)——POST /api/v1/chat body 与 WS chat 帧 data 同形新增可选 parallel:bool(omitempty),语义 = 「把本条作为独立一路处理」;只在会话已有在途回合、且单会话并发额度未满时照准(额度:默认 2、硬上限 3、<2 = 关闭真并行);空闲时无效果(照常起第 1 路);服务端不做任何启发式自动升级(触发权在客户端,design/15 §2.5.1:启发式会让「同一条消息被如何作答」随负载漂移,破坏「恰好一次」的直觉)。真并行路立即起(不是「排到队首再起」),因此 REST 同步入口拿不到同步 reply,文本照旧经 WS 流式到达。B. 忙态帧的 activeTurns / cancelledTurns(纯增量,均在既有白名单帧内)——session_busy / session_idle / pending_drain / session_interrupted 的 data 各增整数 activeTurns(该会话当前在途路数,含正在收尾的路;单路时非 0 即 1);session_interrupted.data 另增 cancelledTurns(本次真的取消了几路)。四个帧都不含任何内容,因此仍属「状态机信号」白名单、所有已认证连接(含 viewer)都收得到。C. pending_ack.reason(纯增量,可选,绝不伪造)——pending_ack.data 增可选 reason:"parallel_limit",只在「客户端显式声明了并行、本条却落回排队」时出现(额度已满,或真并行被 <2 关掉——客户端问了并行而没拿到);其它情况该键完全缺席,帧与今天逐字相同。D. REST 两条新应答形状(纯增量,只在 parallel:true 且被照准时才可能出现)——① 同步入口(stream:false)照准并行 ⇒ 202 {"data":{"parallel":true,"turnId":"turn-…"}}(不是 queued:它不排队、是立即起第二条路);② stream:true 照准并行 ⇒ 200 {"data":{"stream":true,"parallel":true,"turnId":"turn-…"}}。turnId 就是那一路的权威回合标识、与该路全部流帧的 turnId 同值。未照准(未声明 / 空闲 / 额度满)时形状与既有逐字相同(202 queued / 200 {stream:true})。E. interrupt 的多路口径(口径确认,无新字段)——scope:"current" 省略 turnId 时取消的是**「当前全部在途路」(不是只停一路,因为省略 turnId 的 scope 就是「作用于每一路」);scope:"all" 取消全部在途路 + 清队列、且不发 session_idle(与今天一致,客户端靠 session_interrupted 收口);interrupt.turnId(精确停某一路)本版未实现。F. turnId 的生成方变更(对外无可见差异)——turnId 由「网关每回合内部新生成(runTurn 的 newTurnID())」改为「编排器生成 → loop 复用」(loop.WithTurnID;未挂载时如子代理 / backthink / 未接编排器仍新铸)。帧形状与取值分布不变(单路时仍是每回合全新的 turn-<hex>),因此对客户端无可见差异**;写在此处只为让「谁生成」的口径与实现一致(这也是后续 turn_begin 帧与流帧必须同值的硬前提——否则客户端按 turnId 两级分桶会立即失配)。G. IM 入站附件引用落库(679cee0,行为变更,无新字段)——此前 IM 路径只把附件引用挂在内存里的模型可见消息上、落库那一步没写 documents 列 ⇒ 客户端读到的那条消息一片空白(安卓报的 P0);现在 channelbridge 复用 history.EncodeDocuments 同一口径写该列,并在回合结束后按 id 回写抽取结论(status/chars/truncated/reason,与 Web 侧 gateway/session_document_status.go 逐字同构)。契约零扩张:无新端点 / 帧 / 字段;0 个附件 ⇒ 列空 ⇒ 键省略;形状即 Web 的同一个结构体(history.DocumentRef)。⚠️ 读侧时点不变:message_persisted 在用户消息落库那一刻发出,那时抽取结论还没产生 ⇒ 以读取面(GET /sessions/{id}/messages / 握手回放)为准。H. IM 各渠道语音可被读到(a918819,行为变更 + 一个新 env 键)——入站语音在入站流水线最前端被归一为文本(写回 msg.Content,使丢空判据 / 最小打扰判定 / 落库 / 模型可见消息看到同一份文本):平台自带转写优先(微信 iLink 的 voice_item.text 非空 ⇒ 不调 ASR,不重复付费),否则走云端 ASR(VOICE_STT_MODEL)。降级绝不静默:静默档位被识破(只认 voice.STTOutcome.Transcribed()——只看文本会把「没有可用的识别后端」当成一次成功的空转写),鉴权失败 / 欠费 / 限流 / 超时按原因分类并做临时熔断 + 退避(TTL 恢复,熔断打开时不再发起上游调用);无论是否熔断,消息面都落一行中文诚实提示(讲清没转写成功与原因方向,不含上游错误原文/密钥),绝不留空消息、绝不假装成功。新 env 键 VOICE_STT_TIMEOUT_SEC(秒,默认 20;≤0 用桥接器默认值 20s):只约束 IM 渠道入站语音的这一次转写(流水线要为它一直举着一个入站并发槽,上游卡住不能拖死整条流水线);Web 客户端的 voice_input 不受它约束。无新端点 / 新帧;kook 不再把 CDN 地址写进 Content(地址仍在 Media[].URL)。I. llm_usage.session_id 真正落库(59a1ac4,文档修正)——v0.64 写的「session_id 当前恒为 NULL」已过期:loop 在 Run 顶部把会话 id 播种进 ctx(model.WithSessionID),适配器 record(ctx,…) 带上它,空串映射为 NULL,该列从此真的有值。但 GET /api/v1/admin/llm/usage 仍不按 session_id 过滤 / 分组,响应形状不变(window/totals/byPurpose/byModel 一字未动);sink 未注入或会话未播种时行为逐字不变(既有 model 用例零断言改动即全绿)。J. 未实现 / 预留(写明,别让客户端以为已有)——与 design/15 §2.5 的目标态相比,本版只落了「基础管线」,下列各项均未实现、也未登记为可用:新下行帧 turn_begin(kind:"turn"|"parallel" / leg / 归属消息 id 列表);steer_ack 的帧壳 turnId(今天仍不带 turnId,多路下客户端无法判断「被哪一路吃进」);interrupt.turnId(精确停某一路);chat_messages.turn_id 列与其 DTO 键(历史里「回复 ↔ 提问」的配对仍只能靠顺序/时间戳);graph_update 按路分桶(两路会各开一张图、同会话互相顶掉);写类工具的文件级互斥(internal/runtime/tool/legfile.go 的 FileClaimer / ErrFileBusy / WithLegID 只定死形状、无实现无接线);第二路的进行态 system 摘要(WithParallelNotice 未做)。调试/报表不要依赖这些键或帧。K. env CHAT_MAX_PARALLEL_TURNS:未接线(如实)——design/15 §2.5.7 规划的单会话并发上限 env 键本版未接线:Deps.MaxParallelTurns 是编排器的构造期旋钮,但组合根尚未从任何 env 读它 ⇒ 生产恒为默认 2(.env 里配了不生效)。本版不把它登记为可用旋钮;要改上限须等组合根接线(或后续切片)。实现落点:internal/services/gateway/{session_parallel.go(新),session_state.go(新),session_pending.go(新),session_orchestrator.go,session_interrupt.go,session_steer.go,session_turn_fence.go,session_turn_reply.go,session_voice.go,session_document_status.go,chat_stream_dispatch.go,rest.go,ws_hub_turn.go,server.go,deps.go} + internal/runtime/loop/{session_ctx.go,checkpoint.go} + internal/runtime/tool/legfile.go(预留);IM 侧 internal/channel/mediafetch/inline_voice.go、internal/services/channelbridge/{inbound_voice.go,inbound_documents.go,inbound_documents_persist.go,voice_breaker.go}、internal/runtime/multimodal/voice/{outcome.go,errors.go}、internal/config/config.go(VoiceSTTTimeoutSec)+ cmd/cyrene/wire_channels.go。验证:go build ./... && go vet ./... && go test ./... -count=1 全绿(含 -race -count=3 的并行用例);单路逐字不变、未带新字段的老客户端行为不变、CHAT_MAX_PARALLEL_TURNS<2 时全仓行为=steer-only 现状。两条变异:① turnId 由 loop 各自新铸(不复用编排器那份)⇒ 客户端两级分桶失配断言红;② session_idle 时点不按「全部在途路收尾」⇒ 单路/双路时点断言红(还原后均复绿)。

  • v0.64(LLM 用量落库 + 只读汇总端点 + 面板用量小卡,依据主人 2026-10-04 夜裁决「2.4 落」与 T1(72b95df:每次模型调用已记结构化用量日志、但只落日志),2026-10-04 落实)。三档标注:纯增量 = 新端点 GET /api/v1/admin/llm/usage、新迁移 0017_llm_usage、新 env 键 LLM_USAGE_RETENTION(默认 720h;≤0 = 关闭清理)、面板 LLM 页新小卡、前端新 API adminLlmUsage();行为变更 = 无;破坏性 = 无。A. 为什么落库:日志能看单次、看不了趋势,也做不出按用途/模型/会话的成本视图,面板更无数据源 —— 主人要的是「这些调用到底花了多少」。B. 写入绝不阻塞调用(本版最关键的一处设计):model 层不直接开库,只在既有 logUsage 处增加可选接口 model.UsageSink(nil ⇒ 仅日志、行为逐字不变,因此既有 model 用例零改动即全绿);组合根注入 model.UsageWriter = 有界队列(1024)+ 单写者 + 批量事务(128 行/批、2s 兜底冲刷):队列满 ⇒ 丢弃 + 限流 WARN(30s 一条,带累计丢弃数),绝不阻塞、绝不 panic、绝不无界增长;整批失败只记一条带来源 WARN(不静默);停机时先 drain 写完再退。sink 挂在 RuntimeLLM(热重载时稳定)而非适配器上,因此面板每次保存 models.json 都不会把落库静默断掉;每一条链(主/备/本地第三级/按角色跨协议)都带上它。C. 表:llm_usage(id/session_id/purpose/adapter/model/prompt_tokens/completion_tokens/cache_hit_tokens/cache_miss_tokens/reasoning_tokens/total_tokens/took_ms/created_at)+ idx_llm_usage_created_at + idx_llm_usage_session_id。✅ session_id 已接线(v0.65 更正):模型层「不直接开库」的取舍不变,但 loop 现在在 Run 顶部把会话 id 播种进 ctx(model.WithSessionID),适配器 record(ctx,…) 带上它、空串映射 NULL ⇒ 该列从此真的有值(v0.64 写的「恒为 NULL」已过期)。本端点仍不按 session_id 过滤 / 分组,响应形状不变;sink 未注入或会话未播种时行为逐字不变。D. 读取:GET /api/v1/admin/llm/usage?since=&until=&purpose=(owner|admin 读,与同页 /admin/llm 同门;不审计 —— 查询不是动作)→ {data:{window:{since,until,purpose},totals:{calls,prompt,completion,cacheHit,cacheMiss,hitRate},byPurpose:[…],byModel:[…]}};缺省窗口 = 近 24h;since/until 接受 RFC3339 或 Unix 毫秒;跨度上限 90 天,超过 ⇒ 400(不静默裁剪);hitRate = cacheHit/(cacheHit+cacheMiss),分母为 0 ⇒ 0(口径已写进 §2 契约与前端展示规则:分母 0 显示 —,不显示 0%);分解按调用次数降序、同次数按键升序;绝不含凭据与消息正文(表里根本没有这些列)。E. 保留期:LLM_USAGE_RETENTION 默认 720h(30 天),≤0 = 关闭清理(启动日志写明「llm usage retention disabled」);启动期清一次 + 每小时清一次(复用 cmd/cyrene/wire_document_gc.go 的既有模式;清扫间隔不做成 env 键 —— 保留期才是部署者的旋钮)。F. 面板:LLM 页「用量(近 24h)」小卡 = 调用数 / 输入 token / 缓存命中率,取数走既有 useAsyncData 加载/错误态(读不到显示错误,绝不显示 0)。G. 失败不伪装:汇总出口未接线或查库失败 ⇒ 500 {error:{code:"usage_unavailable"}},不回全 0 假汇总。H. 可观测性端点的口径不变:GET /admin/observability/metrics 仍只报 llmCalls/subagentEvents(它不统计 token/成本),token 与成本由本版端点单独回答。实现落点:internal/runtime/model/{usage.go,usage_store.go,usage_query.go,openai.go,anthropic.go,factory.go,runtime.go}、internal/db/migrations/0017_llm_usage.{up,down}.sql、cmd/cyrene/{wire_usage.go,wire_runtime.go,wire_frontend.go,wire_services.go,main.go}、internal/services/gateway/{admin_llm_usage.go,routes.go,deps.go,contracts.go,server.go}、frontend/web/src/admin/modules/llm/LlmUsageCard.tsx、.env.example。验证:sink 为 nil 逐字不变、队列满不阻塞(有超时判定)、批量写入→读回(含 hitRate 公式)、时间窗/用途过滤与超限 400、保留期清理(过期删 / 未过期留 / ≤0 不清理)、鉴权(无凭据 401 / viewer 403 / admin 放行)、不回显凭据(键集恰好六个);两条变异:① RecordUsage 改阻塞发送 ⇒ 「队列满不阻塞」红;② hitRate 分母写成 cacheMiss ⇒ 命中率断言红(还原后均复绿)。

  • v0.63(客户端登记的两条落地:documents[] 增可选 reason(读不到的原因)+ GET /sessions/{id}/messages 对不存在/已删除的会话回 404 session_not_found,依据 Cyrene-For-Android/devdocs/02-前后端协同事项.md 的两条登记(2026-10-05 凌晨)与后端「回执九」的接受与选择,2026-10-05 落实)。三档标注:纯增量 = ① documents[] 元素增可选 reason(omitempty,只在 status:"unreadable" 时下发;不读该键的客户端行为逐字不变);行为变更 = ② messages 对不存在/已删除的会话由 200 {"data":[]} 改为 404 {"error":{"code":"session_not_found"}}(客户端需按 404 处理:退回主会话 + 一句提示;回执九已提前告知);破坏性 = 无。A. reason(§1.2 ④)——数据来源 = 服务端已有的那份抽取失败原因文案(就是注入模型清单里那条,internal/runtime/documents/sidecar.go 的 reason* 常量);装配期在给出 status 的同时给出 Reason(internal/runtime/loop/document.go 的 documentReason),同一个字符串两处用:既进模型可见上下文,也作为客户端可见的 reason —— 服务端因此不必为客户端另编一句,两处口径也不可能分叉。只在「读不到」时下发(extracted/listed 为空 ⇒ 键缺席;客户端按「缺失 ⇒ 回落通用句」实现)。文案集合(逐字):无法抽取正文:这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做) / …PDF 已加密(无法用空口令打开),请解除密码后重新上传 / …PDF 损坏或无法解析 / …本期不支持 Word/Excel/PPT 解析(无文本层) / …内容不在可抽取的纯文本范围内 / …文档文件不存在或已被清理 / …读取文档失败 / …文档引用形态非法 / …文档上传目录未配置 / 解析器整条消息失败时的诚实兜底 无法抽取正文:读取或解析失败。只放文案:不含落盘路径、不含凭据、不含存储名以外的内容;原始错误(可能带路径)只进日志 —— 端到端用例用一个独特的临时目录名做反证断言(响应体里搜不到它,且 reason 不含 uploads、\documents 等路径片段)。抵达面:它随 history.DocumentRef(与落库同一个结构体)由 S3.5 的回合后回写写进 document 列(mergeDocumentStatus 把 reason 与 status/chars/truncated 同批搬运、并计入「有无变化」判据),因此所有读取面(GET /sessions/{id}/messages、握手回放)都有它;⚠️ message_persisted 帧在用户消息落库时就发出,那时抽取还没发生(不带 status/reason,与既有 status 行为一致 —— 客户端以读接口为准)。B. 404 session_not_found(§1 行 6)——现象:此前对已删除/凭空编造的会话都回 200 {"data":[]},客户端分不清「空会话」与「会话已不在」(主人的真实遭遇:正看着的临时会话被清理,界面上只剩一片空白)。理由(与 API 自身一致):PATCH /sessions/{id}、POST /sessions/{id}/read 对未知会话本来就是 404,让 messages 单独例外会让每个新客户端都踩一次。实现:判定复用既有存储面(history.Store.SessionPersona —— 既有存储里唯一一个「按 id 读单个会话 + 未知 id 报 history.ErrSessionNotFound」的方法;ListSessions 是全量列表扫描,在热分页端点上代价不成比例),不新写 SQL、不新增存储方法,落点 internal/services/gateway/history_session_guard.go(新文件)+ handleListMessages 一行收口。存在但没有消息仍是 200 {"data":[]}(空会话绝不能被答成 404);参数校验(limit/before/after/q)先于存在性判定(形状错了就是 400,与目标存不存在无关);存储没有该视图(测试替身 / 未接库的窄存储)视为无从判定 ⇒ 保持旧的空集合语义(判不出来绝不等于「不存在」)。C. 会话注册表口径(重要,随本版更正 §3.4 旧文):chat_sessions 是会话注册表;隔离的 IM 会话(群聊/陌生人的 sess-xxx、内部 sess-backthink)不在其中 ⇒ 它们在本端点与 POST /sessions/{id}/read 拿到同一个答案(404),这是「与 API 自身一致」的必然推论;它们的消息仍可由 GET /api/v1/admin/channels/{platform}/messages 读出(Docs/api-reference.md §3.4 那句「IM 隔离会话 sess-xxx 亦可用」随本版更正)。D. 错误码风格:新码 session_not_found(点明是会话,与 chat 的 document_not_found 同风格),与 PATCH /sessions/{id} 的既有通用码 not_found 不同码 —— 本版不动 PATCH 的既有码(不制造新的破坏性变更),差异已在 §4.4 明写。E. 未做 / 诚实边界:reason 不做 i18n、不改成码值枚举(就是服务端文案,客户端原样展示);不为「会话不存在」新增 WS 帧;不新增/变更任何 .env 键。实现落点:internal/runtime/history/document.go(DTO 字段)+ internal/runtime/loop/document.go(documentReason)+ internal/services/gateway/session_document_status.go(回写)+ internal/services/gateway/{history.go,history_session_guard.go}(404)+ Docs/{api-client,api-reference}.md。验证:go build ./... && go vet ./... && go test ./... -count=1 全绿 + windows/darwin 交叉编译通过;新用例:①扫描件 PDF 经「真实文件→真实 sidecar→真实 loop→真实 HTTP DTO」链路后 reason 含「OCR」且 extracted 时该键缺席;②reason 逐字等于注入模型那份(含解析器整条失败的兜底);③已删除会话 ⇒ 404 session_not_found,④存在但无消息 ⇒ 200 {"data":[]}(回归),⑤?q=/after/参数校验既有用例全绿;反证:用一个独特的临时目录名做路径泄漏断言(响应体里搜不到它)。

  • v0.62(面板「重置为 env 默认」动作落地:materialize 的反方向显式动作 + 一个新的 owner-only 管理写端点,依据任务书 DevDocs/_dispatch/R1-面板重置为env默认.md 与 design/04 §3.1(.env 为默认/播种层、面板文件为一等来源)和 DevDocs/开发时间线.md 的欠账项(「Materialize 已导出、未接端点」),2026-10-04 落实)。三档标注:纯增量 = 新端点 POST /api/v1/admin/llm/reset-from-env、面板 LLM 页的新卡片、审计动作 llm.resetFromEnv、2 个新错误码(env_defaults_unreadable / models_unreadable,另复用既有 llm_persist_failed / llm_reload_failed / llm_unavailable);行为变更 = 无(既有端点 / 帧 / 字段一字未动);破坏性 = 无(新端点的新用途;它本身的破坏性由请求体 confirm:true 强制)。A. 新端点(owner-only——与同页 POST /admin/llm、POST /admin/llm/test 同门,破坏性写不比同族更宽;带审计 llm.resetFromEnv)body 必须 {"confirm":true}(缺失 / false / 非法 body ⇒ 400 bad_request 且一个字节都不动),→ 200 {data:{reset:[{group,keys:[…]}], skipped:[{group,reason}], path}}。B. 语义(与 materialize 共用同一张键表 runtimeconfig.groupFillSpecs,方向相反):① 只覆盖 .env 声明过非空值的键(provider / baseUrl / apiKey / model / contextWindow;协议族按组自己的 provider 选,.env 同时配扁平块与 ANTHROPIC_* 时两族各取自己的值);② env 未声明的键一律保留面板值(fallback / multimodal / maxTokens / reasoning / personaModels 在 .env 里没有出处 ⇒ 永不被本动作改动);③ 组集合不增减(不复活被面板删掉的组、不删面板新增的组,顺序也不动);④ 面板值已与 env 一致的键不算「改回」,不出现在 reset 里(provider 恒属此种情形);⑤ 无键可改时不写盘(幂等,重复调用第二次回 reset:[] + 每组一条「已一致」的 skipped);⑥ 真的改了盘就热重载运行中的 LLM 栈(无需重启;contextWindow 的预算推导仍只在启动时算一次,与 POST /admin/llm 同口径)。C. 响应与凭据纪律:reset[].keys 只有 models.json 的 JSON 键名、skipped[].reason 是中文原因,凭据值(尤其 apiKey)绝不回显、绝不进日志(reset / skipped 恒为数组,无记录时是 [] 而非 null;path 与 POST /admin/llm 同源)。D. 对照基准:请求时刻按既有解析入口读到的 .env 默认层(新增 config.LoadLLMDefaults,复用 parseDotEnv + 同一份 env:"…" 解码器 ⇒ 优先级与启动期 config.Load 逐字一致:进程环境 > .env > 标签默认);路径由组合根经 Deps.EnvPath 注入(与 main.go 的 config.MustLoad(envPath) 同一条),网关自己绝不 fopen .env。E. 失败不伪装(都不改文件且带审计 error):.env 源不可读 → 500 env_defaults_unreadable;面板配置不存在/不可解析 → 500 models_unreadable;写盘失败 → 500 llm_persist_failed;已写盘但热重载失败 → 500 llm_reload_failed(说明文件已改、未生效);LLM 栈未注入 → 500 llm_unavailable。F. 面板:frontend/web 的 LLM 页新增「重置为 env 默认」卡片(admin/modules/llm/LlmResetFromEnv.tsx,标 owner 专属)——点按钮先展开中文二次确认文案,确认后才带 confirm:true 提交;结果按 reset/skipped 渲染成可复核的组名 + 键名摘要,失败原样显示后端错误文案。G. 实现落点:internal/config/llm_defaults.go(env 默认层读取)+ internal/config/runtimeconfig/reset.go(ResetFromEnv 语义,新文件)+ internal/services/gateway/admin_write_llm_reset.go(端点,新文件)+ routes.go(注册为 ownerAuth)+ deps.go/server.go(Deps.EnvPath)+ cmd/cyrene/wire_frontend.go(组合根接线)+ 前端 lib/types/adminWrite.ts / lib/api/admin.ts / admin/modules/llm/LlmResetFromEnv.tsx / admin/modules/Llm.tsx。H. 未做 / 诚实边界:不支持「只重置某一组」(一次调用对全部组生效;skipped 会如实报出没被改动的组与原因);不做「重置 channels.json / plugins.json」(materialize 本身也只覆盖 models.json 一族);不做「重置后自动重启」(provider 栈本身热更新,窗口推导需重启由面板文案写明)。

  • v0.61(PDF 文本层抽取落地:白名单第 23 项 .pdf + 类型裁决由「嗅探只放行 text/plain」改为 (扩展名, 嗅探) 成对裁决,依据 design/24 §7 的 S6 第一段(PDF/Office 的裁决:PDF 文本层引库,扫描件与 Office 仍诚实拒绝)与 commit f82c76d,2026-10-04 落实)。三档标注:行为变更 = ① .pdf 由「一律 415」变为「可上传 + 可读取 + 文本层可抽取进上下文」(把「.pdf 必被拒」写死的客户端必须改);② 类型裁决收紧为成对判定(documents.ContentAllowed)——方向是收紧/细化,不是放宽:HTML/纯文本改名成 .pdf ⇒ 415(嗅探不是 PDF),PDF 字节改名成 .txt ⇒ 415(PDF 字节也只收在 .pdf 名下);纯增量 = 扩展名白名单 22 → 23 项(唯一新增 .pdf),其余 22 个文本类的判定与既有响应一字未变。A. 上传(POST /api/v1/documents,owner|admin)——白名单加 .pdf;裁决 = 字节嗅探是唯一裁决权 ∧ 扩展名白名单,自本版起按对判定(纯文本类必须 text/plain、.pdf 必须 application/pdf;落点 internal/runtime/documents/store.go 的 ExtAllowed(23 项)/ ContentAllowed,网关名字形态校验经它判定);mime 仍是嗅探基类型(真 PDF → application/pdf);415 文案同时点明两条口径(「只接受白名单内的文档(如 .txt/.md/.csv/.json,或内容确为 PDF 的 .pdf),且内容必须与扩展名相符」)。B. 读取(GET /api/v1/documents/{name},必带 Bearer owner|admin)——真 PDF 如实下发 Content-Type: application/pdf(读时按同一对裁决重嗅探,判不定者仍退 application/octet-stream),Content-Disposition: attachment + X-Content-Type-Options: nosniff 一字未变 ⇒ PDF 永不被内联渲染(与图片读侧刻意相反);Cache-Control: private, max-age=86400, immutable 与 Range 支持不变。C. 抽取语义(DTO status)——文本层 PDF 抽正文(页内容流里的 Tj/TJ/'/" 文本算子,页间以换行相接)⇒ status:"extracted",与纯文本类走同一条 sidecar / 注入路径;扫描件/图片型 PDF(无文本层)⇒ 诚实拒绝 status:"unreadable",原因文案含「这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)」,绝不假装成功、也绝不返回空正文(ErrNoTextLayer);加密 PDF 与损坏/截断 PDF 是可区分的两类失败(ErrPDFEncrypted / ErrPDFDamaged,加密的提示解除密码后重试);安全阀 = 单份最多 5000 页 + sidecar 正文 ≤8 MiB(撞上即 truncated)。D. 客户端可见的一次性重算——抽取器版本内部升到 documents/v3(internal/runtime/documents/extract.go)⇒ 旧 sidecar 一次性失效重抽:同一份旧附件的 chars/truncated 结论会在某次回合之后重新计算(旧 PDF 若是文本层 PDF,将从 unreadable 变为 extracted)——不是附件被改写,是结论被重算。E. 未做(诚实边界)——不做 OCR / 扫描件栅格化(纯 Go 无可靠实现,会撞「单二进制 + 交叉编译」「零跨进程 IPC」两条红线)、不做 PDF 写/生成(只读)、不做 .docx/.xlsx 等 Office(仍诚实拒绝);无新端点、无新 WS 帧、无鉴权变更、无 .env 新增键。实现落点:internal/runtime/documents/extract_pdf.go(文本层抽取 + 加密/损坏分类)+ internal/runtime/documents/{extract.go,store.go,sidecar.go}(版本号 / 白名单与成对裁决 / unreadable 原因文案)+ 依赖 github.com/ledongthuc/pdf(纯 Go、无 cgo)。

  • v0.60(管理侧只读「存储状态」端点 + 面板存储卡接真数据,依据主人 2026-10-04 实测反馈(面板首页「存储」卡一直是占位「SQLite」,看不出 PG 增强有没有生效)与 design/10 §3 模块 14(系统设置)/ design/03 §3(pgvector 可选增强),2026-10-04 落实)。一句话:纯新增一个只读端点,既有端点 / WS 帧 / 字段形状一字未动;面板那张假卡换成真数据。A. 新端点 GET /api/v1/admin/system/storage(owner-only、只读、无审计动作,与四条 POST /system/* 动作端点同门不同档)→ 200 {data:{main,documents,vector}}:main = {kind:"sqlite",path,fileBytes,walBytes,shmBytes,pageSize,pageCount,freelistPages,freeBytes}(path 与备份/恢复端点同源;WAL/SHM 边车合法地不存在时如实为 0;freeBytes = pageSize × freelistPages)、documents = {count,bytes}(读 documents 表,不 walk 目录,空表 bytes 为 0 而非 null)、vector = {kind:"pgvector"|"none",enabled,ready}。B. vector 的语义(主人 2.4 的困惑点):kind 说「配没配 DB_PG_URL」,enabled 说「开关在不在」(等价于 kind != "none"),ready 说「活着的向量索引真的是 pgvector 后端吗」—— 配了但连接/schema 准备失败 ⇒ enabled:true + ready:false,客户端必须显示「已配置但未就绪(检索已降级)」,不要把 enabled 当成生效。客户端显示建议:none → 「未启用(未配置 DB_PG_URL)」、enabled && ready → 「启用(pgvector · ready)」、enabled && !ready → 未就绪。C. 两条纪律:① 不含凭据——DSN / 用户名 / 密码 / postgres:// 全文一个字都不回(vector 段只有 kind/enabled/ready 三个键;有用例断言响应体里搜不到这些串);② 只读——只有 SELECT/PRAGMA + os.Stat,且复用组合根注入的活连接(不新开连接池、不写、不迁移、不备份;调用前后 documents 行数与数据目录内文件集合均不变,有用例钉住)。D. 真源(vector):组合根 cmd/cyrene/wire_frontend.go 的 app.vectorState() —— Configured 取 DB_PG_URL 非空、Live 取活索引的具体类型(*vector.PGVectorIndex);网关只读这份注入值,不读环境变量、不新开 PG 连接、不自行探测(否则面板与运行时会给出两个答案)。E. 失败不伪装:未接活连接 / 数据库路径未配置 / 读不到页级统计或 documents 表(例如库没跑迁移)⇒ 500 {error:{code:"storage_unavailable",message}},绝不回一个全 0 的假快照;无凭据 → 401。F. 面板:frontend/web 的存储卡(admin/modules/dashboard/overviewMetrics.ts)改为真数据 —— value = SQLite 12.3 MiB(main.fileBytes 的人类可读量级)、hint = WAL … · N 页(空闲 M 页 / …)、新增两行详情 = documents N 件 · … 与 PG 增强 …;取数走与其它已接线卡片同一条 useAsyncData 加载/错误态(数据没到就显示「端点报错:…」,不回退成 0、不假装成功)。G. 实现落点:internal/services/gateway/admin_system_storage.go(新文件)+ routes.go(注册)+ deps.go/server.go(注入 Deps.Database / Deps.Vector)+ cmd/cyrene/wire_frontend.go(组合根真源)+ 前端 lib/types/adminWrite.ts / lib/api/adminSystem.ts / admin/modules/dashboard/{useDashboardExtras,overviewMetrics,MiniMetric}。H. 兼容性:纯新增——新端点为纯增量;前端 MiniMetricSpec 新增的 extra?: string[] 是可选补充详情行,不传时卡片渲染与旧版逐字等价(其它卡片一字未改)。未做 / 诚实边界:main 只报 SQLite(当前唯一主库引擎);vector 只报 kind/enabled/ready(刻意不回 host,把泄漏面压到最小);不报表级明细、不报备份文件列表(documents 之外的占用仍要下钻到备份/文件系统看)。

  • v0.59(文档进对话 + 上下文预算按窗口推导 + 语音回合进编排 + thinking 可达性口径,依据 design/24 §3/§4/§6(切片 S1–S5)、design/04 §1.3 + 附录 A.3(主人裁决 2026-10-04)与 Cyrene-For-Android/devdocs/02-前后端协同事项.md 的 G5 / C1 / C3;commit a8851dc / a4789ec / 20c1edb / 5b2b385 / 72b95df / 3a01b4e,2026-10-04 落实)。三档标注:纯增量 = 两个 documents 端点、chat.documents 可选字段、4 个文档错误码、models.json 组键 contextWindow、4 个 .env 键、document-read 工具、pending_full 的 clientMsgId?/messageId?、定向 error{code:"chat_busy"} 帧、voice_input.clientMsgId、voice_output.sessionId/deviceId;行为变更 = ① 语音回合改走编排器(落库 / 流式 / 跨端 / 忙时排队)② CONTEXT_MAX_TOKENS 未设置时改为按窗口推导(.env 里写着 32768 仍是显式覆盖)③ 队列满时新增定向回执(此前 WS 无等价物);破坏性 = ① historyMessage 的 document(单对象)→ documents(数组,≤5) ——但单数形状从未随任何已发布版本号发布过,故无兼容期 ② chars / truncated 的语义由「抽取侧」改为 「本回合注入侧」(全文长度只有 document-read 的 totalChars 能看到)。A. 文档(新增 §1.2 + §1 行 16/17)——POST /api/v1/documents(multipart file,上限 CHAT_DOCUMENT_MAX_BYTES 默认 20 MiB)→ 201 {name,url,mime,size,sha256,deduped:false};同字节同扩展名重复上传 → 200 + 同一 name + deduped:true;GET /api/v1/documents/{name} 必须带 Bearer(owner|admin)(与图片读侧的免鉴权能力 URL 刻意相反)+ Content-Disposition: attachment + nosniff;字节嗅探是唯一类型裁决(当时放行基类型只有 text/plain;v0.61 起改为 (扩展名, 嗅探) 成对裁决、.pdf 走 application/pdf),扩展名白名单 22 项(⚠️ 历史值;v0.61 起为 23 项,纳入 .pdf)deny-by-default,mime 是嗅探基类型(data.json → text/plain)、url 是相对路径;错误码 unsupported_media_type(415) / document_too_large(413) / document_not_found(404) / too_many_documents(400);chat 请求(REST 同步 / REST stream:true / WS 三处同形)增可选 documents:[{name}](≤5、只收服务端存储名、不收 URL,先于任何 FS 访问判条数,客户端多带的 mime/url/size 被服务端覆盖);DTO 四处同源(GET /sessions/{id}/messages / message_persisted / message_edited / 握手回放)改 documents 数组,元素 {name,mime,size,chars,truncated,status}、status ∈ extracted|listed|unreadable、无附件时省略该键;内置工具 document-read({name,offset?,limit?} → totalChars/truncated/nextOffset/text/extractTruncated?,limit 默认 8000、上限 64K、offset 为 0 基 rune,策略默认 owner-only)。B. 配置(§2「上下文窗口与预算契约(v0.59)」)——models.json 组级新增 contextWindow(0/省略 = 未声明 ⇒ 131072 保守估算 + 恰好一条 WARN;负数 = 校验失败);.env 新增 LLM_CONTEXT_WINDOW / CONTEXT_BUDGET_RATIO(默认 2/3)/ DOCUMENT_MAX_INJECT_CHARS,并变更 CONTEXT_MAX_TOKENS 语义(未设置 ⇒ 按窗口推导;>0 覆盖;0/负 = 关闭压缩)——推导 budget = min(窗口 × 比例, 窗口 − 输出保留),输出保留 = max(32768, 该组 maxTokens) 封顶 窗口/2,1M 窗口 ⇒ 699050 tokens;另补登记 .env → 面板的 materialize 语义(每次启动一次;面板非空 ⇒ 组集合由面板决定、env 不复活被删的组;键级只在面板缺该键时补,面板的显式空值/false/null 绝不覆盖;「面板改一次、重启被打回」为硬约束;凭据不进日志;范围仅 models.json 一族)。C. 语音(新增 §3.15)——语音轮次现在走与文本同一条路径(落库 = 转写文本 + 助手回复、发 message_persisted 与流式帧、跨端同步、忙时按既有规则排队,pending_ack/pending_drain/session_busy 语义零变化);deviceId 照旧落库、platform 走存储默认 web;voice_input 增可选 clientMsgId(重复到达回 chat_duplicate,不产生新回合、不发音频);voice_output 增 sessionId(恒有)/ deviceId(非空才有),空正文与 TTS 失败/降级都走既有 provider 降级信号(不再把 stopReason 当语音内容);时点 = 回合结算时才到(可能晚于若干状态帧、也可能由续跑或被 steer 注入的那一回合产生);定向目标 = 发起 voice_input 的那条连接本身(不按 deviceId 路由)、断线不补投(带来源 WARN),重连带同一 clientMsgId 重试安全。D. G5(§3.2)——pending_full 广播载荷增 clientMsgId? / messageId?(前者 = 被拒那条消息的幂等键,未携带则整个键缺席;后者仅当该消息此前已落库/已认领时才回填——绝不在此处新铸 id,否则客户端重试会被判成「重复到达」而永远进不来);新增定向帧 {type:"error", data:{code:"chat_busy", message, sessionId, clientMsgId?, messageId?}},只发发送者那条连接、不广播,code 与 REST 409 chat_busy 同码。E. thinking 口径(§4.4,不改行为)——消息体 thinking 字段在实时帧(message_persisted / channel_persisted / message_edited)与握手回放中一律为空(隐私与带宽的有意默认);实时思维链仍走 §3.1 的 thinking.* 流帧(owner/admin);历史消息的思维链只有 GET /sessions/{id}/messages(owner|admin)返回。实现落点:文档 internal/services/gateway/documents.go + chat_documents.go + session_transcript.go + internal/runtime/history/document.go(DTO)+ internal/runtime/loop/document.go(装配期注入与结论回写)+ plugins/tools/io/document_read.go;预算 internal/config/context_budget.go + context.go + llm.go + internal/config/runtimeconfig/models.go(contextWindow)+ materialize.go + cmd/cyrene/wire_context.go;语音/G5 internal/services/gateway/voice.go + session_voice.go + session_reject.go + ws_route.go(wsChatBusyCode)+ session_orchestrator.go。未做 / 诚实边界:document_unreadable(400) 目前只有工具错误形态(无 REST 错误码);文档白名单当时为 22 项(⚠️ 历史值;v0.61 起为 23 项,纳入 .pdf;当时 design/24 §9.2 写 23,以代码为准);文档的配额/引用计数与 GC(design/24 S7)、PDF/Office 解析(S6)(⚠️ PDF 文本层已由 v0.61 落地;Office 与扫描件仍诚实拒绝)与 IM 侧 msg.Media 摄取均未做。

  • v0.58(纯新增 env 键:LLM_MULTIMODAL——让 env-only 部署也能声明「主模型自身能收图」,依据裁决 14.4.1 / design/06 §0(commit 4adfc9a),2026-10-04 落实)。新增 1 个 .env 键:LLM_MULTIMODAL(bool,默认 false)= models.json 组 multimodal 的 env 等价物,只在没有有效 models.json(走 .env 合成组)时生效(有组文件时以该组的 multimodal 为准,与 LLM_REASONING_KIND 同口径);true = 图片直进主模型上下文;留空 / false = 把图交给 vision/OCR 工具换成文本描述;非 bool 值(如 maybe)启动即报错(fail-fast,不静默回退)。兼容性:默认 false ⇒ 老部署行为逐字不变(引入本键之前,env-only 部署没有任何键能声明该能力,图片恒走 vision 工具);LLM_MULTIMODAL=true 后图片走主模型直收,属部署者显式选择。无端点 / 帧 / 字段形状变更(纯新增 env 键,见 §2「模型配置契约」表与 §1 行 3)。

  • v0.57(13.4.5 目标态 steer 落地登记:新下行帧 steer_ack(回合内转向的消费回执),依据 design/15 §2.0 / §2.1 / §2.4 / §3.x / §5(13.4.5 目标态规格,commit 3d8822c),2026-10-04 落实;本版是已实现并已推库行为的登记,只有一处纯新增帧 + 一处链路图纯增量键,无其它契约变更)。A. 新下行帧 steer_ack(见 §3.14)——{type:"steer_ack", sessionId, data:{messageId, atStep}}:忙时会话里新到的用户消息被在飞回合吃进同一回合时下发,messageId 是那条用户消息的权威 id(与 message_persisted 的 data.message.id 同源)、atStep 是吃进它的模型步序号(1 基);同一批次吃进 N 条就发 N 帧、不合并。语义=「已被本回合吃进(已从 pending 移除)、尚未被正面回答」⇒ 客户端应把对应气泡渲染为「已读、未答」的灰态,直到该回合的 message.* / turn.end 到达,不需要第二个确认帧(与「忙『稍后回』」的 pending 气泡不是同一个态)。角色门:它携带具体消息身份,因此不登记进 frameReachesAll 的 7 个状态机信号白名单,落 fail-closed 默认档——只发 owner + admin(viewer 收不到);经编排器状态通道由 hub 直接广播、不经 bus;帧壳带 sessionId、不带 turnId/seq。触发时机:只在回合内边界(loop 每一步开头:上一次模型步与它那一批工具调用都已结束、下一次模型调用尚未开始,此刻没有工具在飞)产生,且只有该边界真的匹配到 pending 里、且已落库的消息时才发——不是每个 step 都发(无可注入消息的边界一帧都不发)。B. 转向的三条不变量(design/15 §2.0)——I1(同会话在途回合数恒为 1):转向只注入、不取消,绝不新开第二个回合(loop 侧结构性地没有任何「起新回合」的分支,注入只是把消息并进当前回合下一次模型调用的视图);I2(消息恰好被处理一次):注入=已消费——被注入的消息立刻从该会话 pending 移除(按 pending 序号认领),绝不被第二个回合再答一遍;去重键=后端 message.id,候选必须同时满足「在该会话转录里」与「在本编排器的 pending 里」(不用时间戳:Windows time.Now() 步长约 0.44ms,连发常同毫秒并列,任何毫秒基线都会漏注入或重复注入);I3(转向不取消已发起的工具):注入点只在回合内边界,此刻没有任何工具在飞,正在执行的工具永远跑完(与 Cline PR #13958「只打断在飞的模型请求、工具与整轮继续」同口径)。C. 注入的范围(重要口径)——注入只影响本轮视图(每一步边界重新并入),不写回运行窗口 ⇒ 既不进该回合的 checkpoint 快照、也不进后台记忆提取的输入面;被注入的消息本身早在到达时已落库(pending 路径即时持久化),注入不新增、也不改写任何行(它与 message_persisted 是两回事:后者在该消息落库时发,前者在该消息被吃进时发)。最新性=每个边界重读该会话转录(history.Recent),不靠回合开始那一次播种(回合开始前已有的消息由 startTurnMessages 播种,绝不重复注入)。D. 失败一律让路(诚实边界)——未接转录存储 / 读转录失败 / 该消息尚未落库 / 本回合世代已被打断或被更新的回合取代(认领 pending 失败)时,不发 steer_ack 也不注入:该消息留在 pending、照旧作为自己的回合被回答,绝不静默消失(转向是增益,任何一处失败都不该让已经在跑的回合失败)。未做:没有「取回 / 撤回 pending 消息」的上行帧(design/15 §5 提到的队列取回暂无协议面),也没有「已撤回」类下行帧;steer_ack 只报「吃进」,不报「回答」(回答由既有的 message.* / turn.end 表达)。E. 一处随实现落地的链路图纯增量键——注入时给该轮图的 input 节点打 props.steered:true(loop.FlowPublisher.MarkInputSteered),让流程图如实表达「这一轮的输入在回合中途被追加过」;图仍只有一条主干(不是被画成第二轮,与 I1 一致),标记只并入既有 input 节点、不新建节点、不改标签。F. 实现落点——loop 侧 internal/runtime/loop/steer.go(SteerRequest / SteerSource / WithSteerSource / steerAtBoundary)+ run.go 的边界调用点 + flow.go 的 MarkInputSteered;网关侧 internal/services/gateway/session_steer.go(每回合一份的 steerTurn 游标 / match / consumePending)+ session_orchestrator.go(回合启动与每次续跑各挂一份转向源)+ ws_bridge.go(wsSteerAck 帧名与其语义注释);组合根零改动(per-turn 状态用 ctx 承载)。G. 兼容性——纯新增帧,老客户端按未知帧忽略即可;无 REST / .env / 既有帧 / 鉴权变更,session_busy / pending_ack / pending_drain / session_idle / session_interrupted / pending_full(§3.2)与 chat_duplicate(§3.13)的形状与语义一字未动。验证:网关侧真路径 e2e(真 WS chat 帧 → 真编排器 pending → 真 loop → 真 SQLite 转录 → 真 /ws/chat 连接 × 真 JWT)断言「忙时新消息在下一个回合内边界被注入同一回合、模型第 2 步窗口里有它、整轮只有一次助手回答、pending 被清空、steer_ack 恰好 1 帧且 owner/admin 都收到而 viewer 收不到(以白名单哨兵到达为前提)」+「消息尚未落库时留在 pending、绝不静默消失」;loop 侧断言注入边界序列、同流去重、恰好一次、以及未接线转向源时窗口逐字不变。变异反证(均已还原复绿):删 consumePending → 模型步数 4≠3;注入写回 msgs → checkpoint 快照被污染;批次中途注入 → 边界序列异常;把 steer_ack 加进白名单 → viewer 收到帧。

  • v0.56(契约债一次补齐(协同项 A2 / G1 / G2 / G3 / G4 / F3):interrupt 支持 scope + REST stream:true 不再静默失败 + chat.image 上限 + clientMsgId 幂等 + 视觉链路 URL 取回事实 + 既有错误陈述修正,依据 Cyrene-For-Android/devdocs/02-前后端协同事项.md 的 A2 / G1 / G2 / G3 / G4 / F3(commit 7512d59 等),2026-10-04 落实)。A. interrupt 支持 scope(A2,纯新增可选字段)——上行 interrupt 由 {sessionId} 扩为 {sessionId, scope?},取值域 {"", "all", "current"}(省略 / 空串 / "all" 三者同义,= 现状语义:取消在跑回合 + 清空待处理队列;老客户端只发 {sessionId} 的行为逐字不变)。新增 "current" = 只取消在跑回合、保留待处理队列,队列消息按编排器正常续跑规则继续执行(带上会话尾部上下文)——修的是「客户端『打断』与『追加队列』两种模式被后端当成一种」:打断一律连队列清空时,用户选「打断」后连发的多条消息会被静默丢弃,只剩一个 dropped 数字。其余取值 → 定向回 {type:"error",data:{code:"bad_request"}}(只回发送者,不广播、不打断任何会话),显式报错而绝不静默按默认执行。下行 session_interrupted 帧形状零变化({sessionId, mode, dropped}),语义改为「如实上报」:dropped 恒等于真的被丢弃的待处理条数(scope:"current" 时为 0),且该档 mode 为会话当时的忙碌通道(thinking|executing|subagent,不是 idle)。实现落点 internal/services/gateway/session_interrupt.go(InterruptScope / InterruptScope(),从编排器主文件拆出以守住 ≤500 行)与 ws_hub_turn.go 的 interruptRequest.Scope + parseInterruptScope(大小写敏感、只容忍首尾空白)。B. REST stream:true 不再静默失败(G2,行为变更,客户端必读)——见 §1 行 3 与 §4.2:该分支改为与 WS chat 帧同一条直达编排器的路径(design/15 §2.1「会话消息不得被 drop」)。响应面:受理成功后才回 200 {data:{stream:true}};正忙已续接 202 {data:{stream:true,queued:true,queueDepth}};队列满 409 {error:{code:"chat_busy"}};幂等键重复 200 {data:{duplicate:true,messageId,sessionId}};未接 loop 由 200 {stream:true} 改为 500 {error:{code:"chat_unavailable"}}(与同步路径同口径);仅未接编排器时回退总线派发,其失败回 503 {error:{code:"chat_dispatch_failed"}}(修复前 Publish 的错误被吞掉、且传的是请求作用域 ctx)。202 / 409 从此真的会出现(修复前因「先 return 200」永不出现)。派发用的回合上下文与请求作用域脱钩(context.WithoutCancel + 30 分钟上限),回合不会在客户端刚收到 200 时被连带取消。兼容性:stream:false 与既有响应形状一字未动;依赖「任何情况都拿到 200」的客户端必须按上表分支。C. chat.image 上限(G3,纯新增校验)——POST /api/v1/chat 的 image 新增硬上限:解码后 5 MiB(与 POST /api/v1/upload 同口径;base64 字符串上限 = ceil(n/3)*4 + 64 字符,覆盖 data: URI 前缀余量),超限 → 400 {error:{code:"image_too_large", message}}(文案指路 POST /api/v1/upload),在任何解码 / 落库 / 入模型之前拦下(超大 base64 因此不进历史、也不会被重连回放重传;校验按字符串长度做——入口层唯一零分配的一步,先拦再接)。事实(诚实边界):WS chat 帧到不了这条 400——WS 读上限 wsReadLimit = 4096 字节/帧,超限由传输层直接断连 ⇒ WS 上发真实图片本就必须「上传 + URL」(图片有「上传」与「内联」两条入口,两边给出同一个「一张图能有多大」的答案)。D. clientMsgId 幂等(G4,纯新增可选字段 + 纯新增帧)——见 §3.13:上行新增可选 clientMsgId(REST chat 体与 WS chat 帧都支持),同一会话内同一非空值只承认第一条;重复到达 REST 回 200 {data:{duplicate:true,messageId,sessionId}}、WS 回新下行帧 chat_duplicate({type:"chat_duplicate", sessionId, data:{sessionId, clientMsgId, messageId}},未进 7 个状态机信号白名单 ⇒ fail-closed 只发 owner + admin),且不落库、不起回合、不发任何内容 / 流式帧。判重由 (session_id, client_msg_id) 的部分唯一索引(WHERE client_msg_id != '',空键刻意排除在外——否则同一会话第二条空键消息就会撞唯一约束)原子裁决,并发重发只有一个赢家;非空键的写入走 ON CONFLICT DO NOTHING。clientMsgId 另回填进消息 DTO ⇒ message_persisted / channel_persisted / 握手 history 回放 / GET /sessions/{id}/messages 可见(空则省略)。迁移 0013_chat_client_msg_id(up:加列 + 部分唯一索引 idx_chat_messages_client_msg_id;down:先删索引再删列;全新库由 history.NewStore 幂等前滚同一列与索引)。向后兼容:省略 clientMsgId 时行为逐字不变(老客户端零影响,历史行该键为空)。E. 视觉链路事实修正(F3,无契约变更)——chat.image 的 http(s) URL(含 /api/v1/files/<name>)由服务端自己取回并内联为 data: URI 再交 provider(不再原样转给上游;把同源 / 局域网 URL 透传给云 provider,对方根本取不到,自部署形态下视觉链路会整条不通);同源 upload 产物优先走本地文件读取(vision.WithUploadsDir)。类型裁决:字节嗅探是唯一裁决权——按真实字节判白名单光栅图(png/jpeg/gif/webp/bmp),声明的 Content-Type / 扩展名只作参考且可被否决;嗅探为文本(HTML / PROXY 错误页 / SVG)或判不定且来源未声明 image/* → 明确报错(vision.ErrNotImage),绝不静默回退成 image/png(否则模型会对着乱码编描述)。F. 修正既有错误陈述(G1,Docs/api-reference.md)——§6.2 帧壳表与 §6.6 曾声称结构化流帧只带 seq、没有轮次标识,是错的:v0.36 起结构化流帧(message.* / thinking.* / tool_call.*)就带 sessionId + turnId(broadcastStream 从 model.StreamEvent 取,loop 在回合边界注入),v0.50 起 data 内另带 streamId;两处均已按实现更正(与本文档 v0.36 条目一致)。G. 环境变量登记——.env.example 新增 OWNER_IDENTITY_INJECTION(owner_only 默认 / always / off;非法值回退 owner_only 并记 WARN),并登记 CONTEXT_MAX_TOKENS(默认 32768;0 / 负 = 关闭压缩)与 GUARDRAIL_KEYWORDS(逗号分隔;留空 = 不挂关键词护栏),三键见 §2「安全 / 上下文环境变量(v0.56)」。验证:A2 四组断言(scope 取值域表 / "current" 保留队列 / 省略与 "all" 清空 / 非法值定向 bad_request 且不打断任何会话,含真实 WS 路由 e2e);G2 / G4 端到端断言(同一 clientMsgId 发两次只落一条用户消息、只起一个回合、第二次响应明确报 duplicate;image 恰等于上限通过、多一字符 400 image_too_large;未接 loop 的 stream:true 回 500 而非 200)。未做(诚实边界):interrupt 仍只有 WS 上行入口(无 REST 版);clientMsgId 无服务端过期清理(键随消息长期保留,判重窗口即该会话历史)。

  • v0.55(阶段 7 落地项登记:路径式安全入口 + 数据目录 platform + OneBot v11 四端 + 可观测实时推送,依据裁决 1.6 / 16.4.7 / 12.4.2(commit a398dc9)、1.4 / 12.4.1(commit 9253fef)、05.4.3 + 16.4.3-WS(commit 0e1f163),以及 13.4.5 面内三条(commit 74b12d8)、MCP 官方 SDK(commit a1083a3),2026-10-04 落实;本版全部是已实现并已推库行为的登记,无新代码)。A. 安全入口(ADMIN_PATH,裁决 1.6 / 16.4.7,前端路由面——不是 REST 契约)——① 新增 env ADMIN_PATH(默认 admin):面板前端路由前缀 = / + 值(admin → /admin;xxx/admin → /xxx/admin);非法值回退 /admin——容忍首尾斜杠与空白,拒绝空段、./.. 段与含 ?/# 的段(宁可挂回默认,也不挂到不可预测的路径上)。② HTTP 行为:/<ADMIN_PATH> 与其深链(/<前缀>/<section>)返回注入了 <base href="/"> 与 window.__CYRENE_ADMIN_ENTRY__={"prefix":"..."} 的 index.html(Cache-Control: no-store;<base href="/"> 必在资源引用之前,否则多段前缀/深链下 Vite 的 ./assets/... 会解析到错误目录);形如面板入口但前缀不符的路径 → 404(判定 = 路径里出现 admin 段;既不回落用户端聊天壳、也不回显正确前缀);/<ADMIN_PATH> 若恰好命中 dist 真实文件则仍走文件服务器(面板入口页绝不顶掉 SPA 资源)。③ API 不藏:/api/v1/*、/api/v1/admin/*、/ws/chat、webhook 一律原样——无新端点、无帧变更,安全靠 JWT + 角色门(design/07 §0.1)。④ 口径:用户端 HTML 永不携带该前缀串(后端只在命中前缀时注入)⇒ 用户端既无管理入口链接、也解析不出面板路由(design/07 §0.2)。前端路由随之改路径式(frontend/web/src/admin/router.ts 只读注入、无硬编兜底、移除 hash 入口;模块切换走 history)。B. 数据目录(同 commit,裁决 12.4.2)——DATA_DIR 新增可接受值 platform(大小写不敏感)→ 平台标准数据目录(Windows %APPDATA%\Cyrene、macOS ~/Library/Application Support/Cyrene、Linux ~/.local/share/cyrene);默认 ./data 与显式路径行为不变;显式空值行为不变(仍走组合根原兜底,避免正在运行的实例突然换库)。C. OneBot v11 四端(9253fef,裁决 1.4 / 12.4.1)——① 配置面(channels.json 账号 config + 适配器 Config() 快照)新增:transport(ws|http,默认 ws;未知值一律回落 ws,绝不静默改走 HTTP)、api_base_url(OneBot HTTP API 基址,必须显式给出——不从 remoteURL 推导,v1 那种「剥掉最后一段路径」的推导是有损的)、report_path(默认 /,无前导 / 时补齐);快照里 camelCase 为 transport / apiBaseURL / reportPath;remoteURL 快照值 = remote_url、为空时回落 api_base_url(语义即「出站远端地址」)。既有键与值不变——不给出任何新配置时行为与改造前逐字节一致(transport=ws、mode=server)。② env 兜底:CHANNEL_QQ_OBV11_TRANSPORT(默认 ws)/ CHANNEL_QQ_OBV11_API_BASE_URL / CHANNEL_QQ_OBV11_REPORT_PATH。③ 四端语义(谁向谁发数据):ws+client Cyrene 拨号 OneBot、收发同一 WS;ws+server OneBot 反连 Cyrene、收发同一 WS;http+client Cyrene→OneBot(POST {api_base_url}/{action}、等响应、按 retcode 判成败,纯出站、无长连接、token 只走 Authorization: Bearer);http+server OneBot→Cyrene(POST 到 listen_addr + report_path,纯入站;fail-closed 鉴权——配了 access_token 时 Authorization: Bearer 或 ?access_token= 不匹配回 403,access_token 为空则放行,与既有 ws+server 同口径)、10MiB 请求体上限(超出 413)、方法非 POST 回 405、成功回 204。④ 关键取舍:mode 决定哪一半必需(server 必需 listen_addr、client 必需 api_base_url),另一半只要给齐字段就一并启用 ⇒ 单个适配器即可完成「收上报 + 回消息」闭环,四端仍各自可独立选择与测试;任一端配置有误即 Connect 失败(先做纯配置校验并构造出站客户端,再绑监听端口),绝不留下半开的端口。⑤ 端口例外:http+server 会用 listen_addr,属 AGENTS.md §9 已登记的同一豁免(design/13 §0 原文即写「WS server / HTTP server」);listen_addr 为空则报错、绝不开端口(无默认值)。⑥ 未接:get_group_list 枚举(后续)。D. 可观测实时推送(0e1f163,裁决 05.4.3 + 16.4.3-WS)——新增 §3.12:日志 / 指标 / trace 从「面板 1s 轮询 REST」改为 WS 帧推送,三个下行新帧(纯新增,既有帧名/形状零改动):observability_log({entries:[{time,level,source,msg}…]},最新在前、与 GET /admin/observability/logs 同形,单帧上限 200 条,触发 = 订阅后新增日志)、observability_trace({spans:[…]},与 GET /admin/observability/trace 同形,上限 100 条,触发 = 订阅后新完成的 span)、observability_metrics(AdminMetrics 本体、扁平,与 REST 同形:totalRequests,qps,avgLatencyMs,maxLatencyMs,perMethod,perPath,statusCounts,llmCalls,subagentEvents,与上次发出的不同才发)。帧壳 {type,data} 不带 sessionId/turnId/seq(不属任何会话)。角色门:三者都不在 frameReachesAll 白名单 ⇒ fail-closed 只发 owner + admin(viewer 收不到、继续走既有 12 条只读监控 REST 档)。订阅(推送开启条件):owner/admin 凭据读取 GET /admin/observability/{logs,metrics,trace} 任一个即建立订阅(绑定该次登录凭据,键 = sub+role+iat,不把 token 串留在内存);该凭据的 WS 连接全部断开即退订,订阅表空后推送循环自行退出(无常驻 goroutine)。这不是轮询:读取本身照常返回完整引导数据,此后吃推送;循环启动时以「当前最新」为锚(日志最新条目时间 / trace 写入序号 / 指标快照指纹),因此不会把环形缓冲里的历史整段灌成巨型帧。≤1s 一拍、无新数据不发帧(空转一帧都不发)。既有 REST 端点完全不变(路径 / RBAC / level / limit 语义 / 响应形状一字未动);?level= 那种服务端分级过滤不适用于推送(帧广播给所有 owner/admin 连接——订阅只决定推送循环跑不跑,不决定谁收帧),改由客户端本地做。前端落点:frontend/web/src/lib/ws.ts 的 subscribeObservabilityFrames()(帧不进应用 store,只有打开着面板的视图在听);面板挂载时各读一次对应 REST 作引导(logs limit=200),推送帧按业务键去重合并、倒序、窗口 200(admin/modules/observabilityStream.ts + observabilityViews.tsx)。E. 其余——① 13.4.5 面内三条(74b12d8):(a) 续接窗口 Recent 在同毫秒并列时改为取最新 n 条且按插入序(Windows time.Now() 步长约 0.44ms,连发必然同毫秒并列;修复前并列取更早的 ⇒ 丢掉刚发出的最新 n 条且同毫秒内反插入序)——无 API 变化,但客户端可观察到的「握手回放 / 续接上下文」顺序更正确;(b) 上行 chat 不再经可丢弃的 bus,改为直达会话编排器的非阻塞 Submit(design/15 §2.1「会话消息不能被 drop」+ §4「不阻塞是硬约束」;编排器未注入时回退 bus)——帧序列不变;(c) 被取消(中断)的回合把该会话 checkpoint 落定为不可恢复(context.WithoutCancel 写,快照保留可查询),不再顶掉下一条消息的上下文,新增内部常量 loop.StopReasonCanceled(checkpoints.state.stop_reason 可能出现该值,不经 REST/WS 暴露)。② MCP(a1083a3):协议层换成官方 Model Context Protocol Go SDK v1.8.0(+ jsonschema-go),删自研 rpc/stdio/http/sse,改用 SDK 的传输 / 协议版本协商与 -32022 回退 / keepalive / tools/list_changed 订阅 / 能力协商——REST/WS/env/数据目录零变更(MCP_SERVERS_JSON、MCP_*、transport 枚举 stdio|http|streamable|sse 全不变;POST /admin/plugins/install 请求/响应形状不变)。实现落点:A = internal/config/config.go(AdminPathPrefix + resolveConfiguredDataDir)+ internal/services/gateway/routes.go(adminEntryHatch/adminEntryHTML)+ frontend/web/src/admin/router.ts;B = internal/config/config.go + internal/platform;C = plugins/channels/qq_obv11/{adapter,http_client,http_server,session,ws_client,ws_server}.go + cmd/cyrene/channels.go;D = internal/services/gateway/observability_push.go + ws_bridge.go + frontend/web/src/admin/modules/observabilityStream.ts。未做(诚实边界):DashScope 形状的 auto 推断(须显式声明 reasoning.kind=dashscope)、Qwen 的 /think 前导消息软开关、get_group_list 枚举、上游显式退订帧(当前退订只发生在订阅凭据的连接全断时;WS 上行暂无 unsubscribe 帧)、审计 tab 仍 5s REST 轮询(未做 observability_audit 帧)。向后兼容:A/B 是配置面新增(默认值即现状)、C 全是纯新增配置键(默认 ws+server 行为不变)、D 是纯新增帧——老客户端按未知帧/未知键忽略即可;既有端点 / 帧 / 字段形状无破坏性变更。

  • v0.54(契约债一次补齐:渠道配置写路径改 patch 语义(修 P0 级凭据静默丢失) + 阶段 6 前端批登记 + 三处过时陈述修正,依据 design/04 §3.1、design/22 §2 方案 b 与裁决 16.4.1 / 16.4.2 / 16.4.3 / 16.4.4 / 16.4.5 / 16.4.6;commit 4ae7148 / f454273 / b480b04 / c1a98d7 / 8d70f6d,2026-10-04 落实)。A. 渠道配置写路径由「整条替换」改为「按 id 打补丁」(P0 级数据安全,破坏性语义变更)——① 语义变更:PUT /api/v1/admin/channels/{platform}/config 的 config 从「整条替换该账号配置」改为「按 id 打补丁」(runtimeconfig.PatchChannel,与 personaId 同构的三态):键省略(该键不在 config 里,或 config 整体缺席 / 为 null)= 保留该账号已存值、给出非空值 = 覆盖该键、显式 null 或 "" = 清除该键(适配器回落零值;0 / false 是真实取值,不算空)。破坏性提示:按旧文实现、依赖「省略某 config 键 = 删掉它」的客户端,必须改为显式传 null / "" 才能清除该键。② 修掉的数据安全问题:面板只发送用户本次碰过的平台字段,后端却整条替换 ⇒ ⓐ 保存微信(iLink)账号卡会抹掉 bot_token / bot_id / owner_id / get_updates_buf(iLink token 是长期凭据,被抹掉即掉线且不可恢复);ⓑ Telegram 只填 webhook_url 而不重填 token ⇒ bot_token 变空 ⇒ cleanAccounts 丢掉整个账号。两条在修复前都是「面板保存成功、盘上凭据没了」的静默丢失。③ 保留清除能力:显式 null / "" 仍能真正删除该键,未触碰的兄弟键不受影响。④ 未改:token 要求型平台(kook / telegram)上显式清 bot_token 仍按 cleanAccounts 丢掉该账号(明确要求清除凭据,不是误删);「本次请求一个 config 键都没给出」在非 weixin-ilink 平台仍回 400(weixin-ilink 豁免以便先建账号再扫码)。⑤ 无新端点 / 新字段 / 响应形状变更:请求体与 201 {data:{persisted,path,platform,accountId}} 一字未动,只有 config 的解释规则变了;allowGroups / denyGroups / allowUsers / denyUsers 与 groupMsgPerMin / msgPerMin / maxMessages 仍是整值替换(省略 = 清空,客户端须回传完整列表 / 非零值)。⑥ 实现落点:internal/config/runtimeconfig/channels.go 新增 PatchChannel / mergeChannelConfig / isEmptyConfigValue;internal/services/gateway/admin_write_channel_config.go 的 adminSaveChannelConfig 改调 PatchChannel(channelConfigFromRequest 返回补丁而非完整配置)。验证:go test ./internal/services/gateway/ -run TestAdminSaveChannelConfigKeeps -v(微信 iLink 凭据保留 / 空补丁三形态 / Telegram 账号存活 / 显式清除 / 无基线新建)+ go test ./internal/config/... -run TestPatchChannel -v;变异验证 3 条——把写路径改回整条替换(旧 upsert 语义)后,「省略键保留」「Telegram 账号存活」「空补丁不改凭据」三类断言精确复现 bot_token / get_updates_buf 被抹掉与账号被 cleanAccounts 删掉的旧缺陷 ⇒ 变异必红。B. 阶段 6 前端批(纯前端行为,无 API 变化)——① 16.4.1 静默 / 活动态与预约切换(f454273,帧缺口补 b480b04):输入模式切换的唯一入口 frontend/web/src/components/modeSwitch.ts——她活动(sessionBusy || 屏上 streaming || sending)时用户切换只登记「已预约」(不改当前模式、不上行、不清除既有预约),她静默时立即生效并上行 mode_switch;提前切 → 下次静默态落地(徽章「已预约 · X(她静默后生效)」);预约状态纯前端、不进后端契约。② 16.4.5 会话级消息缓存(b480b04):帧按自己的 sessionId 落进该会话的消息桶(新增 frontend/web/src/store/sessionCache.ts 纯函数 + useAppStore.sessionMessages),切会话「存回 / 取出」不再清空、非激活会话的帧不再被丢弃;只有「单槽忙态」(session_busy / session_idle / session_interrupted / stream_end 的 sessionBusy 收口)仍按激活会话判定(见 §3.1 / §3.2)。③ 16.4.2 导图口径(b480b04):graph_update 按 data.sessionId 入图桶(上限 12 张)——管理端 Flow / Agents 页取激活会话的图、Backthink 页取 sess-backthink 的图,后台思考图不再顶掉「当前会话」图(见 §3.5)。④ 16.4.3 / 16.4.6 虚拟滚动与专属页只读卡(c1a98d7):管理端长列表超过阈值走窗口化(AdminTable / 新增可复用 VirtualList / 适配器消息日志,DOM 行数与数据量解耦);专属页只读展示该平台完整配置字段(AdapterConfigSummary,此前专属页一个配置字段都不显示)。纯管理端 UI。⑤ 16.4.4 双档 UI 模式(8d70f6d):性能档 = 现状(默认,Smooth Corner 走 border-radius 近似 + 单向高光)、华丽档对齐 Miuix 六大核心(连续曲率 k=0.55 + 完整 Liquid Glass)——华丽档落 :root.ui-mode-fancy(性能档 ui-mode-performance,另有 data-ui-mode 属性),档位持久化键 cyrene.ui-mode(localStorage + 跨标签页同步),main.tsx 在 createRoot 前 initUiMode() 保证不闪一帧。纯前端行为、无 API 变化(裁决 Q12),对应 design/07 §0.1「双档呈现(裁决 16.4.4)」。C. 过时陈述修正(随实现事实改文)——① §3.1 / §3.3 / §3.8 / §4.2 删去「非激活会话的帧被忽略 / 仅当会话为激活态才处理或渲染」的旧口径,改为「帧按其 sessionId 路由到该会话的消息桶」(16.4.5 的实现事实,旧文与实现相反);② §3.1 / §3.2 补「单槽忙态仍按激活会话判定」;③ §3.5 补 graph_update 的按会话入桶 + 管理端按页取图口径(16.4.2);Docs/api-reference.md 的对应处(§5.24 / §6.2 帧壳)同步。

  • v0.53(阶段 5 落地:模型目录端点 + per-persona 路由 / 本地三级 / capacity L3 / 视觉·OCR 独立模型 / image-crop 工具 / 按 provider 形状下发推理开关,依据 design/04 §3/§3.1/§3.2(裁决 03.4.1 / 03.4.2 / 03.4.3 / 08.4.1)、design/06 §0/§3(裁决 14.4.1 / 14.4.5)、design/14 §2.2/§3/§4(裁决 14.4.2 / 14.4.3 / 14.4.4),2026-10-04 落实):① 新增管理只读端点 GET /api/v1/admin/llm/catalog(owner|admin)——面板「添加提供商 / 添加模型」的候选目录(baseURL / 模型 id / 能力位 / 输出上限),数据源 https://models.dev/api.json;?refresh=1 强制在线刷新,否则 24h TTL 内的本地快照直接复用。200 {data:{source:"cache"|"models.dev", fetchedAt?, stale?, warning?, providers:[{id,name,api?,doc?,env?,npm?,models:[{id,name,attachment,reasoning,toolCall,contextLimit?,outputLimit?}]}]}};既无快照又抓不到 → 503 {error:{code:"catalog_unavailable"}};有快照但刷新失败 → 仍 200 + stale:true + warning(离线兜底,不编造候选);带 Accept-Encoding: gzip 时响应带 Content-Encoding: gzip(信封形状不变)。副作用只有一处:落盘快照 <DATA_DIR>/llm_catalog.json(无凭据、data/ 已 gitignore、TTL 24h),不改 models.json;抓取只发生在本端点内,不进运行时热路径。审计动作 llm.catalog(ok/stale/error)。详见 §2「模型配置契约(v0.53)」。② models.json 组级新增三键(可热更新,GET/POST /api/v1/admin/llm 已同步读写;未声明时该键不出现):reasoning: {kind} 声明该组的推理开关形状;personaModels: {personaId: modelId} 与 personaProviders: {personaId: "openai"|"anthropic"} 是角色级模型/协议路由(「角色」= 该回合答话的 persona,不是消息作者 role)。校验在 Group.Validate:非法 kind(只认 auto/deepseek/openai/vllm/dashscope)、anthropic 组上声明 openai 系形状、personaProviders 的非法协议名、空 persona id/空值 → 写端点回 400 bad_request 且不落盘;persona 表键值会 trim,trim 后撞键直接报错(不静默丢条目)。请求语义:三键缺省 = 保留已存值(reasoning 整块给出即整块替换,persona 表给出即整表替换——否则面板无法删除一条已存路由);响应语义:每组的 reasoning 恒下发(kind:"" = 未声明形状,不是「后端没报告」),personaModels/personaProviders 为空则省略;顶层新增 reasoningKinds 与 personaProviderOptions 两个取值域数组(由后端常量直接生成,面板无需硬编码枚举)。回落:未绑定/未知 persona → 该组默认模型;personaProviders 声明的协议在 models.json 里没有对应组 → 记 WARN 并跳过(该角色仍走默认链,不半建一条只会失败的链)。③ 语义修正(重要,此前文档写反了):multimodal = 主模型自身能否收图(不是「本部署有没有视觉能力」);LLM_VISION_MODEL / LLM_OCR_MODEL 只配视觉/OCR 工具用的模型(OCR 空则复用视觉模型,两者都空则复用主模型),与 multimodal 互不代表(裁决 14.4.1)。④ .env 新增 9 键:LLM_REASONING_KIND(默认空 = auto;只在无有效 models.json(走 env 合成组)时生效,有 models.json 时以该组 reasoning.kind 为准)、LLM_OCR_MODEL、LLM_LOCAL_ENABLED(默认 false)/LLM_LOCAL_PROVIDER(默认 openai)/LLM_LOCAL_BASE_URL/LLM_LOCAL_API_KEY/LLM_LOCAL_MODEL(本地第三级:Enabled + BaseURL + Model 三者齐备才成为链尾一步,只开开关则 WARN 并跳过)、CAPACITY_L3_ENABLED(默认 false)/CAPACITY_L3_CACHE_TTL(默认 5m)。⑤ 内置工具新增 image-crop:按区域裁切图片(image + x/y/width/height,0 基像素、区域须完整落在图内)→ PNG data URI + 裁切尺寸 + 源图尺寸;供大图被上游压缩后分区精读(design/06 §3)。上限:源图 ≤50MiB、区域 ≤16MP、载荷 ≤8MiB(超出报错,不截断);源可为本地路径(file 工具同一沙箱)/ http(s) URL(同 SSRF 守卫)/ base64 data URI。策略默认 owner-only(tool_policies 无行时 fail-closed)。⑥ 推理开关按 provider 形状下发(替换 v0.41 的「只 WARN 不发」)——形状由 reasoning.kind 声明(未声明 = auto:按模型名/baseURL 推断厂商,形状有歧义就不下发),字段名映射表:

    形状 kind 线上字段 说明
    deepseek 顶层 thinking:{type:"enabled"|"disabled"} + reasoning_effort 思考默认开,「不传」≠「关」:要关必须显式 disabled 或 effort:"none";none 胜过 enable:true(关思考优先);budget_tokens 在该协议上无对应字段,记 WARN 丢弃
    openai 仅 reasoning_effort 该系没有布尔思考开关;thinking.enable 记 WARN 丢弃(官方关推理路径 = effort:"none")
    vllm 顶层 chat_template_kwargs.enable_thinking 只有布尔开关,无 reasoning_effort;effort:"none" 一律翻译成显式关思考(不得降成任何强度档)
    dashscope 顶层 enable_thinking 同上;只在显式声明该形状时下发,绝不从模型名/BaseURL 推断
    auto/未声明 无 deepseek/openai 可推断时按其形状下发;形状有歧义(如 Qwen 可能由 vLLM 或 DashScope 承载)或厂商未知 → 一个字段都不发,逐项 WARN 并给出配置指引
    • reasoning_effort 校验:按厂商白名单(OpenAI 系 none/minimal/low/medium/high/xhigh/max;DeepSeek none/low/high/max)——非法值丢弃并记 WARN,不再「降级到最近一档」(降档会反转调用方意图);DeepSeek 的别名 minimal→low/medium→high/xhigh→high/ultra→max 归一化后发送并记一条 WARN。Anthropic 组继续走自己的 thinking:{type,budget_tokens}(reasoning.kind 在其上被拒)。
    • 同批另含两条对客户端无端点/帧/字段变更的实现:persona awakening 档位真生效(configs/personas/*.yaml 的 awakening 块归一化为 quiet|awake|full 并进 capacity 决策;非法档位/越界阈值载入期报错、块缺席 = 无覆盖继续走全局默认,design/14 §3)、工具越出本轮允许集的软判并回(越界调用仍然执行——能力≠边界;结果里带 not_allowed 提示回灌模型,并记审计 tool.reject.not_allowed,design/14 §4)。capacity_awaken / approval_notice 等既有帧形状一字未动。 ⑦ 向后兼容:新端点、新组键、新 .env 键、新工具都是纯增量;既有端点 / WS 帧 / 字段形状无破坏性变更(GET /admin/llm 只多出 reasoning/personaModels/personaProviders/reasoningKinds/personaProviderOptions 这些新键,消费旧键的客户端不受影响)。未做(诚实边界):DashScope 形状的 auto 推断(需显式声明 reasoning.kind=dashscope)、Qwen 的 /think 前导消息软开关(与 design 口径冲突,本批未实现)。⑧ 实现落点:目录 internal/services/gateway/admin_llm_catalog{,_cache,_source}.go + 路由 routes.go;组键与读侧 internal/config/runtimeconfig/models.go(ReasoningOptions/PersonaModels/PersonaProviders/Validate)、写读端点 internal/services/gateway/admin_llm_provider.go + admin_write_llm_channels.go + admin.go/admin_query.go;形状下发 internal/runtime/model/reasoning_shape.go(+ reasoning.go 的厂商白名单/别名、openai_encode.go 的线上字段);per-persona 路由 internal/runtime/model/{router,persona_stack,runtime}.go;本地第三级 internal/runtime/model/factory.go(localChainStep)+ internal/config/config.go;capacity L3 internal/runtime/capacity/{capacity,classifier,l3}.go;awakening internal/persona/loader/awakening.go;软判 internal/runtime/tool/allowset.go + internal/runtime/loop/tool_policy.go;工具 plugins/tools/io/crop.go。对应提交:63e5b70(目录端点)、fa33c79(per-persona 路由 / 本地三级 / L3 / OCR 独立模型 / image-crop)、38b4527(工具软判)、71fefe5(persona awakening)、8890c30(按形状下发推理开关 + per-persona 提供商链)。
  • v0.52(阶段 4 三组落地:工具策略落盘 + 主代理代审小字 + 流程图节点参数摘要,依据 design/11 §3/§4/§8(裁决 1.3 / 10.4.2 / 13.4.3)与 design/19 §1/§1.1(裁决 11.4.2 / 11.4.3 / 13.4.1),2026-10-04 落实):① 工具策略落盘:allowInGroup / allowInStranger 的载体改为 SQLite 表 tool_policies(迁移 0012_tool_policy,唯一索引 idx_tool_policies_name 承载 upsert 冲突目标),POST /api/v1/admin/plugins/policy 从此重启保留——不再是「面板保存成功、一重启就复位」的假持久化(见 §2 行 5)。写入路径先落库、后改内存(registry.SetPolicy → 落盘成功才改内存并记入快照):落盘失败则内存保持旧值并返回错误,不会出现「面板说成功、内存已改、库里没有」的分叉;启动时 AttachPolicyStore 立即 Load 全量策略套到已注册能力上,此后 Register 进来的能力(MCP 热装、插件刷新)同样套上落盘值;没有行的能力按默认(两布尔均 false = owner-only)处理(fail-closed)。策略存储不可用/加载失败时保持最严默认并记 WARN,不静默降级成内存态、也不假装读出了策略。对客户端无端点、无字段形状变更。② 新增下行帧 approval_notice(§3.11,design/11 §4):loop 的工具准入门(ToolLoopHooks.CallAdmit)判定某次调用属敏感操作(执行命令 / 修改文件 / 网络写方法)→ 经主代理代审后广播 {type:"approval_notice", sessionId, data:{actor, tool, op, detail, explain, allowed, at}};explain 是后端产出的固定文案「 想要干什么,我审查后批准了」/「…我审查后拒绝了」(客户端原样渲染,不要自拼),detail 为动作摘要(工具 + 关键参数,目标部分截断到 80 rune),allowed=false 表示该敏感调用被拦截未执行(无代审者 / 代审报错同样 fail-closed 拦截,小字同为「拒绝了」)。非敏感调用不产生该帧;子代理的敏感调用归属到派生它的父会话(与 §3.10 同口径),小字因此落在主人正在看的那个会话里,而不是一个没人看的子代理 id 下。审计:每次代审在 audit_logs 落一行(批准与拒绝各一行)——who=角色名、what=approval.<工具名>、result=granted|denied,查询走既有 GET /api/v1/admin/observability/audit 与 GET /api/v1/admin/notifications。角色门:内容帧 → 仅 owner + admin(fail-closed 默认,未扩那 7 个状态机信号白名单)。前端已渲染(frontend/web/src/lib/ws/handlers/approval.ts + frontend/web/src/components/status.tsx 的 ApprovalNoticeHint):按同一会话过滤规则把 explain 作为聊天流里的一行小字、detail 作副文本。向后兼容:纯新增帧,不改既有帧 / 端点 / 鉴权,老客户端按未知帧忽略即可。③ graph_update 节点 props 新增四类可选键(裁决 11.4.2 / 11.4.3,见 §3.5):旁路节点的 span 首次真进图——memory.recall / memory.write / guardrail.input / guardrail.output / orchestration.spawn 现在都由真实生产者产生,kind 因此会实际出现 memory / guardrail / orchestration;tool 节点另带 args_keys(该次调用的全部参数名)与 args_summary(仅放行表内、且非密钥类的键才给值,密钥类键连值都不给,单值 ≤80 rune、最多 24 条);旁路节点只带元数据(action / scope / hits / bytes / count / failed / allow / subagent / elapsed_ms),不含记忆正文、不含提示词、不含密钥。值在发布前已脱敏(凭据形状 → [redacted]、长文本截断),前端对未放行的键显示「值未展示」。帧名、事件名、顶层字段集一字未动——以上只是节点 props 内的纯增量可选键。④ 本版未做 / 诚实边界:代审是「记录并批准」——「敏感与否」与「是否放行」仍由上层策略层判定,orchestration/audit.Approver 只做校验、记录、叙述并返回结果模型,不构成新的权限边界(身份/权限判定始终来自 channel 层 actor 元数据 + 白名单 + 策略引擎);agent.RunStep(静音 / 非流式内部流程,如 backthink)的输出侧护栏仍同步内联(其裁决是 StepResult 的一部分、不落在主会话关键路径上),只有主会话流式关键路径的输出侧护栏改为后台异步(loop.checkOutputAsync,裁决 13.4.1:真并发、不阻塞回合,拦截仍写审计留痕;输入侧前置门依旧同步拦下)。⑤ 实现落点:策略落盘 internal/capability/registry/policy_store.go + registry.go 的 SetPolicy、迁移 internal/db/migrations/0012_tool_policy.{up,down}.sql;代审 internal/capability/restrict/{sensitive,approve}.go + internal/runtime/orchestration/audit/approval.go + internal/services/gateway/admin_approval.go + 组合根 cmd/cyrene/wire_runtime.go 的 toolAdmissionHooks;流程图 internal/runtime/loop/{spanlog,memory,stream}.go + internal/runtime/agent/step.go + internal/runtime/orchestration/scheduler_spans.go + internal/runtime/trace/{graph_props,graph_build,redact}.go。

  • v0.51(后台子代理结果登 WS 帧 + 回填父会话:新增下行帧 subagent_result;依据 design/05 §9「子代理完成 → 结果回填主会话并提示用户」,裁决 02.4.3,2026-10-04 落实):① 问题:subagent_spawn 的批次前台只等 2 秒(design/04 §8.5),转后台后结算时编排层已把合并结果发布到 bus 主题 subagent.result,但 gateway 的中继不认这个 subject(既非 ws.*,也不是已登记的 4 段广播形态 cyrene.agent.*.event)⇒ 结果永远到不了前台,前端唯一可见的是逐代理进度帧 subagent_event(「跑起来了/跑完了」,但拿不到结论)。② 新增帧 subagent_result:{type:"subagent_result", sessionId, data:{sessionId, status:"done", summary, agents:[{id, goal, role?, mode?, status, summary?, text?, steps?}]}}——data 与派发工具返回给模型的结果同形同值(同一处投影、同一处 4000 字截断,text 即子代理的结论正文);帧壳 sessionId = 派生该批次的父会话,客户端据此把结果归到那个会话的实时流(多会话并发不串话),而不是当成一张全局帧。详见 §3.10。③ 回填边界(诚实说明):本版把「结果可见」做到了会话作用域的实时帧——结果现在真的到达父会话的 WS 流(此前被中继静默丢弃)。未做:不落库(父会话的持久化转录里没有新增一条消息,重连/回放不会补发它),也未经 design/15 的会话消息编排器排队续接;现有 Web 前端也没有该帧的 handler(按未知帧忽略,故今天的可见效果是「帧到达 owner/admin 连接」而非「聊天气泡里出现结果」)。REST/历史接口逐条不变:GET /sessions/{id}/messages 不会多出这条结果。④ 角色门(fail-closed):新帧属内容帧 → 只发 owner + admin,viewer 收不到;未扩那 7 个状态机信号白名单(design/11 §7.1,白名单要进须先在设计登记「不含内容」的理由)。⑤ 向后兼容:纯新增帧——老客户端按未知帧忽略即可;不改任何既有帧的类型/字段、不改端点、不改鉴权;owner/admin 只是多收一帧。⑥ 实现落点:生产者 internal/runtime/orchestration/spawntool.go(SubagentResultEvent 载荷带上派发回合的父会话);中继 internal/services/gateway/ws_bridge.go(bridgePatterns 订阅该 subject + normalizeEvent 专用成形:帧壳 sessionId 取自载荷父会话;载荷形状不符预期时退回通用映射,两路都只发 owner+admin)。⑦ 验证:进程内两端各一条——编排侧断言后台批次发布的载荷类型/键集/父会话/逐代理结果;gateway 侧真 bus Publish → 真 /ws/chat 连接(真 JWT × owner/admin/viewer)断言 owner 与 admin 各收到恰好 1 帧 subagent_result(帧壳 sessionId = 父会话,data 的汇总与逐代理字段逐项对得上)+ 1 帧哨兵状态信号,而 viewer 只收到哨兵(收不到结果帧)。变异验证:① 把 orchestration.SubagentResultSubject 从 bridgePatterns 去掉 → 「owner 收到 subagent_result」必须 FAIL;② 把 subagent_result 加进 frameReachesAll → 「viewer 收不到」必须 FAIL(两条均已跑,还原后复绿)。⑧ 同版另含管理面(D 组,2026-10-04 落实,与上列同属 v0.51 一版):① X-API-Key 真正接线(裁决 15.4.2)——Authorize 之上叠加 key 分支,管理/用户侧已认证端点同时接受该头,签发出去的 key 真的能用;且 key 一律超管、取消 role 分级(裁决 15.4.3,主人原话「key 始终给超级管理员权限」):签发端点不再读 role 请求体、响应恒 role:"owner"、任何有效 key 鉴权后即 owner。fail-closed:请求带该头时只走 key 分支,未知/已吊销/空值(或 key 表未注入)一律 401,不回落 Bearer;不带该头时 Bearer 路径一字不变;WS 握手仍只认 Bearer。破坏性边界:此前文档写的 {"role":"admin|viewer"} 请求体自本版起被忽略——若曾有客户端指望签一把只读 key,它拿到的其实是 owner key(这正是裁决要的效果:取消分级,不给「以为只读、实则超管」的错觉)。② 新增主人档案读写 GET/PUT /api/v1/admin/owner-profile(裁决 10.4.5,owner-only——这份档案决定谁能进 owner 车道,故连读都不给 admin;写带审计 owner.profile.update,读不审计;全新安装 GET 回 200 + 空白档案而非 404):消灭前端假保存——面板的「保存」从此真的落盘 configs/users/owner.yaml(0600 原子写)并热替换活档案,渠道层 actor 判定立即生效、无需重启;校验失败(id/displayName 缺失或 accounts 项半空)不落盘并回 400 bad_request,写盘失败回 500 owner_profile_persist_failed,档案损坏/读不出回 500 owner_profile_unreadable(不返回看似正常的空档案)。③ persona identity.systemPrompt 改必填(裁决 08.4.2):POST/PUT /admin/personas 缺失或空白一律 400 bad_request("systemPrompt is required (identity.systemPrompt must not be empty)"),不再派生默认说明串回落——载入期执行同一规则,写出的 YAML 必能被重新载入,不会出现「一条没有身份的人格被静默降级成拼凑串」。文档同步:§0 认证、§1 行 8、§2.0 权限矩阵(owner-only 档 35 → 37 条)与本「管理面板写操作契约」已按实现改写。

  • v0.50(事件批:流式帧的结构化补齐——tool_execution 三事件 + streamId + turn.start/end + graph_update 三范围口径;依据 design/04 §1.1(裁决 01.4.1 / 01.4.2 / 01.4.3 / 03.4.4)与裁决 01.4.5(design/20 §2),2026-10-04 落实):① tool_execution 拆三事件(破坏性替换):旧单帧 tool_execution.update {id,index,delta}(delta 为 "start:工具名" / "end:error:工具名" 拼串)不再由 loop 产生,替换为 tool_execution.start({tool, phase:"running", id?},进入执行)/ tool_execution.update({tool, phase:"running", delta?, id?},运行中进度,可多帧)/ tool_execution.end({tool, phase:"done"|"error", id?},完成或失败)——phase 与工具名是结构化字段,不再塞进 delta;id 复用 provider 工具调用 id(可关联 tool_call.* 帧,无 id 时省略该键)。迁移:按旧 delta 拼串解析的客户端必须改读 phase/tool 字段;tool_call.* 帧形状不变。诚实边界:start/end 由 loop 在每次工具执行的首尾真实发出;update 的 wire 映射已实现并有单测锁定,但工具分发器尚无中途进度上报源,loop 暂不产出 update 帧(未来接入进度上报无需再改契约)。② streamId(新增可选字段,位于模型流帧的 data 内):message.* / thinking.* / tool_call.* 帧的 data 带 streamId——loop 在每次模型流(一次 ChatStream 调用)开始时生成,同流多帧共享,多步工具轮的下一个模型调用取新 id;与帧壳 turnId(整轮恒定)分工:turnId = 哪轮回答,streamId = 这轮里的哪条流式消息。tool_execution.* / turn.* 帧不带。③ turn.start / turn.end(新帧,无 data 载荷):loop 在 Run 起止发出、经 ws_bridge 下行(model.StreamEvent 新 kind → hub broadcastStream 映射);帧壳 sessionId/turnId/seq 即边界信号;turn.end 覆盖所有出口(最终答案 / 工具循环安全网终止 / 预算耗尽 / 步骤出错);回合终态仍由 stream_end 携带(分工不重叠);被静音的内部流程(backthink / 子代理)不发出。④ graph_update 三范围口径(01.4.5,随实现事实补记):发布范围为 web / web:<uuid> / sess-backthink 三类(此前文档只写「web 及其子会话」,漏了 backthink 思考链路图这一实现事实;实现侧 cmd/cyrene/wire_runtime.go 的 allow 谓词本就如此,本批零代码变更);并补记 tool 节点 status 由 phase 驱动(与 §3.1 tool_execution.* 的 phase 同枚举同决策:trace.FlowStatusForPhase 消费 tool span 上的 phase,缺省回落 span 终态)。⑤ 角色门:本批新帧(tool_execution.start/end、turn.start/end)全部落 v0.48 下行门的「仅 owner + admin」档(fail-closed 默认,白名单未扩);viewer 行为不变。⑥ 向后兼容总结:新增帧与新增键(streamId、data.tool、data.phase)可被老客户端安全忽略;唯一破坏点是旧 tool_execution.update 单帧消失(见 ①);现有 Web 前端未消费 tool_execution.* / turn.* / streamId,本批对它零影响。⑦ 验证:loop 包断言三事件顺序/phase/工具名/id/Delta 为空、同流共享 streamId 跨流相异、turn 边界成对且覆盖错误出口、静音 run 零事件、flow 图 tool 节点 running→done/error 与 phase 一致;gateway 包真 WS 帧端到端(真 loop + 真 dispatcher + bus → relay → broadcastStream):按 seq 排序断言完整帧序 turn.start → message.delta → tool_execution.start → tool_execution.end → message.delta → turn.end(+stream_end)、streamId 每流独立、tool_execution 帧无 delta/streamId 键、turn 帧无载荷;帧门矩阵补 5 行(三事件 + turn 两帧 × 三角色)。变异验证:去掉 loop 的 ev.StreamID = streamID 透传 → 「streamId 每流独立/前缀」断言 FAIL(已跑,还原后复绿)。

  • v0.49(WS 握手回放 + 上行帧的角色门:把 design/11 §7.1 下行门落地时查出的同一类缺口的另两处一并收口——对 viewer 继续收紧,owner / admin 行为不变,无端点、无字段形状变更;依据 design/11 §7.2,2026-10-03 落实):① 问题:本质与 §7.1 同一个——viewer 是只读监控档,却能通过 WS 拿到内容、甚至发起写入。(a) 握手 history 回放对任意角色照发(回放主会话最近 50 条消息正文,thinking 已剥离但正文本身即主人的对话),viewer 一连上就把对话拿走了;(b) route 的上行分发完全没有角色检查,viewer 发 chat 能真的驱动模型并把消息落库(只读档拿到写权限),subagent_ask / task_output_query 的应答是定向帧(enqueueJSON)因此绕过 §7.1 的下行门,把子代理上下文 / 任务输出直接回给 viewer。② 规则(fail-closed,默认拒):A 握手 history 回放仅 owner + admin(ownerAdminClaims,与主动推送同一个判据);B 非 owner/admin 连接发来的任何上行 JSON 帧一律拒绝——定向(只回发送者)回 {type:"error",data:{code:"forbidden"}},不广播、不驱动模型、不落库、不返回任何内容;被拒帧:chat / interrupt / subagent_ask / task_output_query / voice_input / mode_switch(默认拒,未登记的一律拒)。协议级帧不受影响:WS ping/pong 与正常 close 照常(否则连接会掉),被拒帧也不关连接。③ 实现:A 收口在唯一发出回放的 hub.replayHistory 顶部(不放调用点,没有第二个调用者能绕过);B 收口在 wsConn.route 的第一条语句,且写成「默认拒」而不是被拒类型黑名单——新加的上行帧类型自动被拒,漏一处也不会放行。④ 合口径(与 §7.1 一起读):viewer = 可以连接、可以读 12 条只读监控 REST(v0.40)、可以收 7 个状态机信号帧(v0.48);除此以外 WS 上拿不到任何内容,也发不出任何指令。⑤ 未来放宽的要求:将来若真需要 viewer 上行(如手机端以 viewer 凭据做只读订阅之外的交互),必须先在 design/11 §7.2 给它开口并登记白名单;代码里那把「默认拒」必须改造成登记表,不得直接删掉。⑥ 测试与变异验证:真 WS 连接 + 真 JWT(复用 v0.48 的测试装置)。A:viewer 连接收不到 history 回放,owner / admin 收得到(且回放里 thinking 仍为空);B:viewer 依次发 chat / interrupt / subagent_ask / task_output_query / voice_input / mode_switch,每次都收到定向 forbidden error,且其后无任何内容帧(对 subagent_ask / task_output_query 尤其断言「不得收到定向的 subagent_event / task_output」),全程计数 runner = 0 且落库 = 0;另一条 viewer 连接作为观察者不收这些 error(证明是定向而非广播);回归:owner 发同一个 chat 仍然真的驱动 runner(= 1)并把 user + assistant 两条落库;协议级:被拒后 ping/pong 往返与正常 close(服务端注销连接)均正常。变异验证:① 去掉 A 的门 → viewer 立刻收到 history(A 断言 FAIL,已跑);② 去掉 B 的门 → viewer 的 chat 驱动 runner 1 次并落库 2 条、subagent_ask 直接回 subagent_event(B 断言 FAIL,已跑)。⑦ 影响面:owner / admin 逐字不变;/ws/chat 连接鉴权不变(viewer 仍可连);无新端点、无字段形状变更;今天没有 viewer 客户端在跑(/auth/login 仍写死签发 RoleOwner,除测试外无 viewer 凭据),故线上实际影响为 0。

  • v0.48(WS 下行帧的角色门:/ws/chat 的下行帧改为按帧类型分档——对 viewer 是收紧(此前它连上就能收到角色回复正文与推理内容,现在收不到),owner / admin 行为不变,无端点、无字段形状变更;依据 design/11 §7.1,主人 2026-10-03 拍板「按建议收紧」):① 问题:hub 的下行是全连接广播,而 /ws/chat 只验 JWT(任意角色可连),于是 viewer 连接会收到 message.* / thinking.* / tool_call.*(含角色回复正文与推理内容)、message_persisted / message_edited / message_deleted、graph_update、mood、subagent_event / task_output / device_update / reminder 等——与 design/19 §1/§3「flow 可视化仅 owner/admin」直接冲突,也与 GET /sessions/{id}/messages 的 owner/admin 档位不一致(同一批内容从 WS 旁路漏出)。② 规则(fail-closed,默认拒):只有白名单里的 7 个「状态机信号」(session_busy / pending_ack / pending_drain / session_idle / session_interrupted / pending_full / session_read,均不含任何内容)发给所有已认证连接(含 viewer);其余全部(stream_chunk / stream_end / thinking、message_persisted / message_edited / message_deleted / channel_persisted、graph_update / subagent_event / subagent.result / task_output / task_done / tool_used / capacity_awaken / mode_switch、mood / device_update / reminder)只发 owner + admin;未登记的新帧类型默认只发 owner/admin(要进白名单须先在 design/11 §7.1 登记理由「不含内容」)。③ 实现:收口为唯一一处「按帧类型分类后投递」的入口(internal/services/gateway/ws_frame_scope.go + hub.broadcast),不给每个调用点加开关(那正是「漏一处即泄漏」的写法)——流式 message.* / thinking.* / tool_call.*(broadcastStream)、stream_chunk / stream_end(runTurn / streamChat)、message_persisted / message_edited / message_deleted、mood / capacity_awaken、编排状态信号(StateSink)、graph_update / subagent_event / device_update / reminder(relay 归一化)全部经同一入口,不存在第二条投递路径。④ 语义澄清(不是数据从此不可见):这条门管「谁该被主动推送」——viewer 的 12 条只读监控端点(v0.40 viewerAuth,含 GET /personas 的角色级 mood)逐条照旧;/ws/chat 的连接鉴权不变(viewer 仍可连,只是收不到上列帧);定向回请求连接的帧(voice_output / error / subagent_ask、task_output_query 的应答)不进这道门、行为不变。⑤ 测试与变异验证:真 WS 连接 + 真角色 token 的逐帧表驱动断言(design/11 §7.1 表里每一个帧类型 × 三种角色:owner / admin 两类都收得到,viewer 只收白名单)+ 帧类型 × 角色的直接矩阵 + 「白名单精确等于 7 个状态信号」与「未登记类型默认拒」的锁定;变异验证:把 graph_update 改回全广播 → 「viewer 收不到 graph_update」必须 FAIL(已跑,见提交说明),把 session_idle 改走受限路径 → 「viewer 收得到 session_idle」必须 FAIL(已跑)。⑥ 已知残留(本次未动,留待主人裁决):/ws/chat 的上行仍未按角色授权(viewer 仍可发 chat / interrupt / subagent_ask / task_output_query / voice_input,其应答是定向帧、不经过本门),且握手时的 history 回放仍对任意角色照发(thinking 已剥离)——二者都在 design/11 §7.1 本次门的作用域之外。(本残留已由 v0.49 收口:上行帧默认拒、握手回放仅 owner/admin,见上一条变更记录与 §3 顶部第二把角色门。)

  • v0.47(graph_update 子代理泳道根节点新增可选 props.persona,对客户端纯增量;实现依据 design/22 §8-2「跨角色派发 = 把其他角色以子代理形式派出去」):① subagent_spawn 指定目标角色派发时,该子代理在父会话图里的分支根节点 props 多一个 persona 键(值 = 目标角色 id),供流程图显示"这个子代理是以谁的身份在替班",而不只是子代理 id。② 不指定角色派发时该键不出现:帧形状与 v0.46 逐字一致,老客户端忽略未知键即可。③ 无新端点、无字段改名、无鉴权变更;subagent_event 帧未新增字段(其 role 会等于目标角色——既有字段的取值语义变化,非形状变更)。④ subagent_spawn 的 persona 参数不在本文档契约内:那是给模型用的工具 schema(工具层以「进程能否按该角色运行」为判据,未知角色整批拒绝且不产生任何子代理),不是给客户端的 HTTP/WS 契约。⑤ 验证:进程内 e2e 断言「子代理实际收到的 system prompt = 目标角色 instructions」+「其记忆写入落在目标角色域」+「未知角色整批拒绝」+「省略 persona 时结果 JSON 键集与旧版完全相同」;变异验证(把目标角色换回父/默认角色、关掉角色校验)对应断言均 FAIL。

  • v0.46(会话角色不可变:PATCH /sessions/{id} 拒绝 personaId,对客户端是破坏性变更——按 v0.42–v0.45 旧文实现、依赖「PATCH 换角色」的客户端必须改为新建会话选角;design/22 §8-1 主人拍板定稿):① PATCH /api/v1/sessions/{id} 的 personaId 改为「出现即 400」:只要请求体里有 personaId 键(任何取值:"yuki" / "" / null / 未知 id / 空白串)就回 400 bad_request,错误消息点名 personaId 并说明「会话角色在创建时确定、之后不可变,要换角色请新开会话」。实现取「明确拒绝」而非静默忽略或 200 空操作:静默忽略会让客户端以为绑定已改。② 顺序保证:该检查在解析完 body 后立即执行、先于会话存在性检查 —— 未知会话 + personaId 回 400(不是 404);title + personaId 同传也整体 400,其余字段一个都不落库。③ 不变的部分:title / pinned 的语义、边界(≤80 rune 等)与响应形状逐字不变;POST /api/v1/sessions {personaId}(创建时绑定、未知角色 400、非空回显)完全不变;GET /sessions 与消息 DTO 的只读 personaId 不变。④ 移除的路径:PATCH 上再也没有 {personaId} 成功响应、也没有「不支持角色绑定的 HistoryStore → 501」这条路(网关的 Server.setSessionPersona 及其中间层随之删除;history.Store.SetSessionPersona 作为存储能力保留)。⑤ 前端同步:用户端移除「会话行 → 切换角色」菜单与面板「挂角色」下拉,保留「新建会话时选角色」;管理面板「角色」列改为只读展示创建时绑定的角色。⑥ 测试与变异验证:新增断言覆盖「键出现即 400(六种取值)+ 绑定未变 + 未知会话仍 400 + title/pinned 不变」,并把「静默忽略」与「改回写绑定」两种反向实现各跑一次(两处断言均必须 FAIL,见提交说明)。

  • v0.45(渠道 personaId 三态修订:修「省略 = 解绑」会静默清空绑定,对客户端无字段/端点形状变更,但语义收紧——按 v0.44 旧文实现的客户端若靠「省略 personaId」来解绑,必须改为显式传 "";design/22 §2 方案 b):① PUT /api/v1/admin/channels/{platform}/config 的 personaId 改为三态:键省略(或 JSON null)= 保留该账号已存绑定(不动);显式 "" = 解绑(写盘时该账号不带 personaId 键);非空 id = 绑定该角色;未知角色 id 仍 400 bad_request(且只校验本次真的传了值的情形——省略时继承的已存绑定在当初写入时已校验)。② 修的问题(数据安全):管理面板只在主人动过角色下拉时才发这个键(前端 channelPersona.ts / channelPersonaRequest),而 v0.44 后端把「键不存在」也当成解绑 ——主人打开一个已绑角色的账号、不动下拉、只改 token 并保存 → 该账号的角色绑定被静默清空(无提示、无审计可见的理由),两端语义相反。现在后端的「省略」与前端一致 = 保留。③ 实现:请求 DTO 的该字段由 string 变 *string(指针区分「省略」与「显式空串」),应用逻辑在既有「读当前配置(channels.json)→ 写入该平台账号 → 落盘 → bridge.Reload(platform)」路径内按三态取值(写入路径自 v0.54 起改为按 id 打补丁 PatchChannel)(省略时从同平台同 id 的已存账号继承,故保存一个账号不会动到同平台其它账号的绑定);无新端点、无新字段、无响应形状变更。④ GET /api/v1/admin/channels 每账号的 personaId 回显不变(v0.44 新增,非敏感、不掩码),面板据此回填角色下拉。⑤ 测试:省略 → 绑定保留、显式 "" → 清除、非空 id → 绑定、未知 id → 400(含「省略时改其它字段后读回确认」与「同平台多账号互不影响」),并把「省略 = 解绑」作为变异反向验证(该断言必须失败)。

  • v0.44(渠道级选角 + 「谁说的」进模型上下文,对客户端新增两个可选字段,向后兼容;design/22 批次 2 后端 = §2 裁决规则 + §4.4 + §7-3/§7-4/§7-5):① PUT /api/v1/admin/channels/{platform}/config body 新增可选 personaId(渠道级绑定,design/22 §2 方案 b):=该账号答话时的角色 id;未知角色 400 bad_request,省略/"" = 解绑(写盘时该账号不带 personaId 键)(本条的「省略」一项已由 v0.45 修订为「保留已存绑定」;只有显式 "" 才解绑,见上);沿用既有「读当前配置 → 写入该平台账号 → 落盘 channels.json(0600) → bridge.Reload(platform) 热更新」路径(写入路径自 v0.54 起改为按 id 打补丁 PatchChannel),保存即生效(下一批入站消息),无新端点。② GET /api/v1/admin/channels 每账号新增可选 personaId(非敏感,不掩码),供面板回填。③ 角色解析优先级明确化(对客户端语义澄清,无形状变更):会话绑定 > 渠道绑定 > 全局默认角色;渠道绑定只作用于该账号的主人私聊且仅当主会话自身未绑角色(§7-3:IM 主人私聊镜像随主会话角色);群聊/陌生人隔离会话强制默认角色(§7-4,更保守,仍只读)。④ 模型上下文新增角色归属标注(§7-5 的剩下一半):一回合上下文改为该会话最近 20 条持久化消息 + 本回合新消息(此前只带新消息本身),每条 assistant 历史消息按其说话角色出具 [发言归属] 以下发言来自「<名字>」(<id>),不是你说的。 独立 system 标注 + 一条 [会话角色] … 当前角色声明;消息正文一字不改(标注只走 system 消息,design/11 §10),对客户端透明(无新字段)。前端无需改动即可继续工作;面板可选用 ① 让每个 IM 账号挂角色。⑤ 向后兼容:纯新增可选字段;未配置 personaId 的账号与不读新字段的客户端行为不变(渠道绑定缺省 = 全局默认角色,与 v0.43 完全一致)。

  • v0.43(人格读侧合并批:admin 回读 thinking + 用户侧角色级 mood,对客户端新增两个可选字段,向后兼容;design/01 §2/§2.1、design/04 §3.2):① GET /api/v1/admin/personas 每项新增可选 thinking({budgetTokens,effort,showToUser,mode}),与写侧同构、供面板回填编辑。未配置的人格不带 thinking 键(不是全零对象),与 PUT 的「省略 thinking = 保留已存」互为镜像。② showToUser 三态原样回读:null = 未配置 → 保持现行为(thinking 帧照常推客户端),true/false = 显式开关;服务端不把 null 拍平成 false(否则面板会把「没配过」误显示为「关」,v0.41 的语义会被读侧丢掉)。③ GET /api/v1/personas 每项新增 mood(角色级):= 该角色当前情绪状态(取值同 GET /meta/enums 的 moods;"" = 暂无被跟踪情绪,不伪造)。情绪跟随角色、与会话无关——同一角色的所有会话看到同一 mood;客户端登录/加载角色目录时用它播种心情徽标(按角色 id 分桶),消除「刷新后徽标短暂为空」,随后仍以 WS mood 帧为准。用户侧不暴露 thinking(模型调用旋钮属管理侧)。④ effort 的能力提示归服务端:读端点只回读已存配置,不承诺该值在当前 provider 上生效——白名单校验/降级/WARN 仍在模型层(design/04 §3.2),客户端不得把「配了」呈现为「已启用」。⑤ 向后兼容:纯新增字段;不读新字段的客户端行为逐字节不变(旧面板仍可编辑并保留已存 thinking)。

  • v0.42(多角色并存 + 按会话选角,对客户端新增可选字段/可选请求字段,向后兼容;design/22 批次 1 = 后端会话级绑定):① chat_sessions 新增 persona_id TEXT NOT NULL DEFAULT ''、chat_messages 新增 persona_id TEXT NOT NULL DEFAULT ''(同一迁移 0011,down 可逆,不回填)。'' = 继承全局默认角色(owner.yaml:defaultPersona),所以既有一切行升级后行为逐字节不变,且默认角色改名后这些行跟着变(design/22 §4.7/§7-6)。② GET /api/v1/sessions 每项新增可选 personaId(=会话绑定角色);消息 DTO(REST GET /sessions/{id}/messages、WS history 回放、message_persisted/message_edited 广播)新增可选 personaId(=说话时的角色,design/22 §7-5)。③ POST /api/v1/sessions body 新增可选 {personaId}(创建即绑定;未知角色 400;响应仅在非空时回显 personaId,故省略时响应形状与 v0.41 完全一致);PATCH /api/v1/sessions/{id} body 新增可选 {personaId}(重新绑定,显式 "" 解绑,未知角色 400;只回显变更字段)(本条的「PATCH 重新绑定/解绑」已由 v0.46 作废:会话角色在创建时确定、之后不可变,PATCH 一旦收到 personaId 即回 400,见 §1 行 8 / §8-1)。④ 回合语义:角色决定系统提示词 / 记忆命名空间 / 情绪与好感度 / 思考预算四者同源解析(design/22 §4.2);换角色保留上下文、不动已存消息,下一回合起生效(已由 v0.46 作废:会话内不再换角色——角色在创建时定死,chat_messages.persona_id 仍逐条记录"谁说的");记忆口径 = 保持 persona 隔离(design/22 §3):不同角色的会话互不浮现,同角色的多个会话互相可见。⑤ 请求级覆盖(每次发送临时指定角色)本批未实现(design/22 §2 裁决规则 1 仍待批次 2/后续);渠道级绑定(IM 账号/群默认角色)本批未实现(design/22 §4.4 批次 2),故 IM 路径仍按默认角色解析。⑥ 契约内新增内部读接口:网关经窄接口类型断言读取 history.Store.SessionPersona/SetSessionPersona/NewSubSessionFor(v0.46 起请求路径不再调用 SetSessionPersona——PATCH 的 persona 写路径已整体移除;「注入的 HistoryStore 不支持角色绑定」只在 POST /sessions 显式传 personaId 时回 501,而非假成功)。

  • v0.41(人格 thinking 配置 + 推理旋钮按 provider 映射,对客户端新增一个可选请求字段,向后兼容):① POST /admin/personas / PUT /admin/personas/{id} body 新增可选 thinking 对象 {budgetTokens?,effort?,showToUser?,mode?}(design/01 §2.1),原样写入并持久化到 configs/personas/<id>.yaml 的 thinking 节;非法 mode/负预算回 400 bad_request;PUT 省略该对象时保留已存 thinking(旧面板/脚本不会抹掉它);该字段只描述呈现/资源,不构成能力边界。② showToUser 语义:缺省 = 照常把 thinking.* 流帧推给客户端(现行为),显式 false = 仅落库/剥离语义不变、不推帧(展示与落库解耦)。③ 服务端推理映射(对客户端透明,仅影响上游请求):Anthropic 系在 persona 配置 budgetTokens/mode: on 时发 thinking:{type:"enabled",budget_tokens:N},并保证 budget_tokens + 正文留量 ≤ max_tokens(不足则抬高 max_tokens 并记 WARN,绝不把正文吃空);OpenAI 兼容系按识别到的厂商白名单校验 reasoning_effort(OpenAI 系 none…max、DeepSeek 仅 low/high/max),非法值~~降级到最近一档并记 WARN(已由 v0.53 修订为「丢弃并记 WARN、不降档」——降档会反转调用方意图,none = 关思考,绝不翻成任何强度档);未知 provider/未识别模型、以及 Qwen 的 enable_thinking 硬开关(协议无统一 wire 形状) 一律记 WARN(含 provider/字段/取值)并保持请求不变,绝不静默无效~~(本条已由 v0.53 作废:开关形状改由组键 reasoning.kind 显式声明并真下发,只有形状未知/有歧义时才 WARN 且一个字段都不发,见 v0.53 变更记录)。详见 design/04 §3.2。

  • v0.40(viewer 角色真正接线:只读监控档,对客户端收紧/放宽若干 admin 读的鉴权,无端点/字段形状变更):此前 viewer 只是常量(internal/auth 的 RoleViewer 注释写着「只读 dashboard/logs/monitoring」)而从未接线——管理侧 24 条 GET 全走 owner/admin,viewer 一条都读不到(全 403)。本版新增第四道门 viewerAuth = owner+admin+viewer,并把 12 条只读监控 GET 从 owner/admin 放宽到 owner/admin/viewer:GET /admin/personas、/admin/sessions、/admin/plugins、/admin/plugins/installed、/admin/channels、/admin/iot、/admin/automations、/admin/rag、/admin/observability/metrics、/admin/observability/trace、/admin/observability/logs、/admin/notifications。明确不放给 viewer(含凭据获取路径、他人数据、完整消息/导出明文、审计流水,或 admin 也不给):GET /admin/memories、/admin/llm、/admin/users、/admin/backthink、/admin/observability/audit、/admin/channels/messages、/admin/channels/{platform}/messages、/admin/sessions/{id}/export、POST /admin/rag/query(这 9 条 owner/admin)以及其余 35 条 owner-only(全部写操作 + login-qr/login-status/view/friends/渠道连接与配置/plugins/*/users/*/system/*)。匿名:任意 admin 路由仍 401(fail-closed)。向后兼容:owner/admin 语义不变(放行集合是超集),仅 viewer 的可见面从「全 403」变为 12 条只读监控;不依赖 viewer 的客户端行为不变。完整分档见 §2.0「管理侧权限矩阵」。

  • v0.39(微信 iLink 会话被拒可见化 + 不再假 OK,对客户端新增两个可选字段、一处错误码,向后兼容):① GET /api/v1/admin/channels 每平台行新增可选 needsRescan(bool,omitempty)+ lastError(string,omitempty)——仅当适配器真的要求重新授权时出现(今天只有微信 iLink 返回 errcode=-14 这一种),取值来自适配器新增的可选能力接口 RescanRequired() (bool, string),网关经类型断言读取(主 PlatformAdapter 契约不变;未实现该能力的平台字段缺省不出现)。用途:把「需要重新扫码」与「主人自己关掉的未连接」区分开——后者仅 connected:false。② POST /admin/channels/open 对「iLink 已拒绝该会话」回 409 session_rejected(message 明确要求用「扫码登录」重新绑定),不再吞掉错误回 200;409 needs_login(尚无 token,v0.8 起)不变。③ 适配器行为:iLink errcode=-14 进入显式被拒态——IsConnected() 恒 false;Connect() 返回被拒错误(不再对被拒会话报成功);HealthCheck 区分「会话被拒」与「未连接」;并以 ERROR 级日志写明「本次会话已被 iLink 拒绝,需要重新扫码绑定」(只带 bot_id 等非敏感标识,token 永不入日志)。被拒态在读循环与发送两条路径都会置位,并仅在扫码登录拿到新 token 或配置换了新 token(保存 + 热重载)时清除。④ 扫码登录侧:被拒期间 login-status 报 failed + 被拒原因(不再假报 confirmed,否则面板会把已作废凭据当成功重新落盘);login-qr 仍可正常出码(不回 already_logged_in,否则重新扫码这条路被堵死);get_bot_qrcode 不再携带已被拒的 token(否则服务端以「已绑定」应答、不发新凭据)。向后兼容:只新增可选字段与一处更诚实的错误码;不读新字段、不处理 409 的客户端行为不变(此时面板显示「未连接」,仍可用「扫码登录」恢复)。语义澄清(本次仅措辞,不改契约形状、不另起版本号——v0.39 尚未被任何客户端采用):iLink 的 bot token 是长期凭据(实测一个月前扫码得到的 token 仍可用),errcode=-14 表示 iLink 侧拒绝/作废了该会话凭据,不是时间性过期。详见 §2 行 6 与「微信 iLink『会话被拒』契约」。

  • v0.38(B4 客户端共享枚举权威表 + 未知值回落约定,对客户端新增一个只读 REST 端点):新增 GET /api/v1/meta/enums(任意已认证角色可读,与 GET /sessions 同级;无 token 401)→ 200 {data:{platforms,moods,modes,unknown}},每项 {id,label}:① platforms(6)= web/kook/qq-obv11/weixin-ilink/telegram/webhook——即各内置适配器 Name() + Web 主会话来源 web;② moods(5)= calm/happy/excited/sad/angry,与 persona 情绪状态机内置状态一致(该状态机可扩展,故非封闭集合);③ modes(9)= chat/code/home/work/docs/voice/vision/agents/settings(design/07 §1);④ unknown 恒 "display-raw" = 未知值回落约定机器可读:客户端遇不在表中的取值原样显示原始字符串(不丢弃、不报错、不占位符),服务端新增取值视为非破坏性变更(forward-compatible)。单一事实来源在 Go 侧(internal/contract/enums),并有反漂移测试断言适配器 Name() / 情绪内置状态 / 前端九模式与三张表一致,漏登记即 CI FAIL。Web 侧镜像 frontend/web/src/lib/enums.ts(labelFor 未知值返回原值),顶栏/侧栏/消息气泡的「心情」标签已改用它渲染。向后兼容:仅新增端点,既有契约形状不变。详见 §1 行 15 与 §1.1。

  • v0.37(D4 未读状态 / 已读游标,对客户端新增一个语义字段、一个 REST 端点、一个下行事件):① chat_sessions 新增 read_at INTEGER NOT NULL DEFAULT 0(迁移 0010,down 可逆;毫秒 Unix 时间戳,与 chat_messages.created_at 同单位)。② 语义:会话级单一已读游标 + 单 owner 账号——同一账号所有端共用一个游标,读一处 = 全端已读(不做 per-device/per-user 游标,与「同一账号所有浏览器都属于同一 owner」一致);createdAt <= readAt 的消息算已读,未读数 = createdAt > readAt 的条数。③ GET /api/v1/sessions 每项新增恒返回的 unreadCount(整数,0 = 无未读):与 lastMessage 同一条 SQL 派生(关联子查询 COUNT(*) WHERE session_id = s.id AND created_at > s.read_at,走已有 idx_chat_msgs_session_created,无 N+1、不新增索引)。④ 新增 POST /api/v1/sessions/{id}/read(owner/admin,与 PATCH /sessions/{id} 同鉴权):body {before?}(可选毫秒;缺省= 现在)→ 200 {data:{sessionId,readAt}};before 为 0/负数/非数字/body 非对象 → 400,未知会话(含隔离 sess-xxx)→ 404,未注入 HistoryStore → 501。游标只前进不后退(read_at = MAX(read_at, before)),乱序/并发/重试请求幂等安全。⑤ 新增下行事件 session_read:{type:"session_read", sessionId, data:{readAt}},全连接广播 + 客户端按 sessionId 过滤,用于同账号其它端清红点;失败路径不广播(§3.9)。⑥ 升级回填:0010 把既有会话游标回填为「该会话现有最新消息时间」,升级当天不误报未读(既有历史全部视为已读)。⑦ HistoryStore(网关消费侧契约)新增 MarkSessionRead(ctx, sessionID, before);SessionMeta 新增 UnreadCount。向后兼容:仅新增字段/端点/事件,既有契约形状不变;不消费 unreadCount/session_read 的客户端行为不变。详见 §1 行 4/8a 与 §3.9。

  • v0.36(A1 流式帧附加会话 / 回合标识,对客户端新增两个可选字段、无端点变更):结构化流帧 message.start/delta/end、thinking.start/delta/end、tool_call.start/delta/end、tool_execution.update 现在带 sessionId + turnId(向后兼容:只新增帧字段,data 结构与既有事件形状不变)。sessionId 由 loop 在回合边界从运行上下文注入(模型适配器在 loop 之下、天然不感知会话,保持零值;loop 外无会话上下文时该字段为空);turnId 由网关每回合新生成(newTurnID)并经 loop.WithTurnID 挂进运行上下文,runTurn 是编排层与 legacy 直连路径唯一的回合收口点,两路都覆盖——同一会话同时只有一个 in-flight 回合,故同轮内所有帧的 turnId 一致。此前这几帧属于 design/16 §4.2 标注的「若知道就带上」缺口(v0.10 起记录):多会话并发流式时前端会把别的会话的 delta 接进当前会话气泡,现在客户端按 sessionId === activeSessionId 过滤即可路由到激活会话。stream_chunk / stream_end 的 sessionId/turnId 行为不变。详见 §3.1。

  • v0.35(可观测性收尾:后端日志 tail 端点,对客户端新增一个 admin 只读端点):GET /api/v1/admin/observability/logs?level=&limit=(owner/admin)→ 200 {data:[{time,level,source,msg}]},最新在前。数据源为 logger 新增的有界环形缓冲(internal/logger,容量 2000,满则丢最旧、并发安全、不阻塞、不改动既有控制台/文件输出),组合根在构建 logger 时挂上、在网关就绪后经 SetLogBuffer 注入;缓冲未注入回 501 not_implemented(不伪造空 tail)。time 为 RFC3339 UTC(秒精度),与 trace.startedAt/audit.when 一致;level ∈ debug|info|warn|error(大小写不敏感)为最低等级过滤,空 = 全部,未知值 400 invalid_level;limit 缺省 200、上限 1000。source 取 logger.WithSource 的 source=(Scoped 记录回落 module/sub),无来源为空串。详见 §2「可观测性契约」。

  • v0.34(D3 收尾:消息编辑 / 删除的 WS 实时广播,对客户端新增两个下行事件、无 REST 契约变更):PATCH /api/v1/messages/{id} 成功后广播 message_edited({type, sessionId, data:<更新后的完整消息DTO>},thinking 已剥离)、DELETE /api/v1/messages/{id} 成功后广播 message_deleted({type, sessionId, data:{id}});两者复用 message_persisted 的同一 hub 广播路径(全连接广播 + 客户端按 sessionId 过滤,hub 无按会话订阅),客户端按 id 原位改 content / 按 id 移除,不再需要「改/删后重拉」的绕行。message_deleted 的 sessionId 由后端在删除前读取消息所属会话填入(HistoryStore 新增 GetMessage;读不到时客户端按全局唯一的 id 兜底)。404/400/501 等失败路径不广播。Web 端已消费(lib/ws.ts + store/useAppStore 新增 updateMessageById/removeMessageById)。详见 §3.8。

  • v0.33(A2 记忆异步提取 / A4 可观测性 Tracing 接线 / A6 通知页后端):① A6 新增 GET /api/v1/admin/notifications(owner/admin 读,全新端点):把现有源(审计 audit_logs + backthink 主动推送记录)聚合为统一只读通知 feed,type/before/limit 过滤与游标见 §2「通知聚合契约」;不新建表,未注入的源该类型为空、不伪造;未读游标后置。② A4 trace 端点由恒空变真实数据(无契约变更,GET /admin/observability/trace 与 metrics 的 llmCalls/subagentEvents 从此反映 loop/工具/编排的真实 span):此前 TraceSink 从未接线、环形缓冲无人喂;现在组合根把 loop 产生的 span 扇出到网关观测缓冲。响应字段/形状不变。③ A2 LLM 异步记忆提取接线(对客户端无变更):loop 回合结束后改由 LLM 提取器后台异步抽取记忆(不阻塞回复、失败仅记日志),群聊/陌生人仍只读不写;无可用模型凭据时回落确定性 ManualExtractor(记住 X 路径不变)。

  • v0.32(A1 情绪系统接线:情绪/好感度真跑起来):后端把既有的 persona/emotion 状态机接进 loop 回合边界——每轮结束按该轮用户消息的情绪信号移动该角色自己的情绪状态机(沿配置转移表走一步)并调整好感度(按角色 affection.delta);无信号轮次衰减一步趋向 calm。① 消息 mood 不再恒空:assistant 消息落库带该轮情绪,message_persisted / history 回放 / GET /sessions/{id}/messages 的 mood 为真实值(user 消息不带)。② 新增下行 mood 事件({data:{mood,personaId,affection}},仅情绪变化时广播,见 §3.7),前端顶栏「心情 · x」与消息气泡接真数据。③ 隔离:群聊/陌生人(MemWritable=false)情绪只读不写——不改情绪/好感度、不发事件、该轮 mood 空。④ 持久性:情绪/好感度为进程内按角色状态,重启回 initialState(未落库;design/01 未要求)。触发词表与「每轮一步」为 design/01 未规定处的最小实现,待主人确认。契约新增(向后兼容):mood 事件 + 消息 mood 变真实值。

  • v0.31(Android 协同批次 ③b:B5 剩余 501 + D1 推送出口钩子):① B5-知识库删除由 501 改为真实实现:POST /api/v1/admin/rag/delete body {"id"} → 200 {data:{id,deleted:true}}——从项目索引删除该文档条目并级联删除全部章节(单事务);不存在回 404 not_found,id 空回 400,未注入 store 回 501;路由由 admin 收紧为 owner-only(与其它 admin 写一致)+ 审计 rag.delete。② B5-新增用户 / IoT 场景 / IoT 规则仍诚实 501:无用户表(登录仅 .env 单 admin)、IoT 无场景/规则模型与存储(iot.Provider 只有设备列表/控制/状态),错误消息明确缺什么,不伪造 store/假成功。③ D1 推送出口钩子:主动消息在 WS/IM 投递之外异步镜像到外部推送出口,自建统一推送服务优先 → 个推(Getui)兜底(个推 SDK 未接入,仅预留 GETUI_* 字段,不可达时日志注明待接入);.env 新增 PUSH_EXPORTER_ENABLED/URL/TOKEN/TIMEOUT_SEC,token 只走 Authorization 头、不落日志;对客户端无新端点(详见 §3.6)。

  • v0.30(Android 协同批次 ③a:D2 消息搜索 / D3 消息编辑删除 / D5 会话置顶):① D2:GET /api/v1/sessions/{id}/messages 新增可选 q——会话内内容搜索(大小写不敏感 ASCII LIKE,%/_/\ 转义为字面量),仍倒序 + before/limit 游标;q 与 after 互斥(同传 400),空 q 走普通分页;严格限定单会话,不新增跨会话搜索(避免群聊/陌生人隔离会话外泄,红线)。HistoryStore 新增 Search。② D3:新增 PATCH /api/v1/messages/{id}(body {content},非空 ≤4000 rune → {data:{message}},只改 content)与 DELETE /api/v1/messages/{id}(→ {data:{deleted:id}});不存在回 404 not_found;HistoryStore 新增 UpdateMessage/DeleteMessage + ErrMessageNotFound。不新增重发端点(客户端用编辑后的 content 再发)。③ D5:chat_sessions 新增 pinned INTEGER NOT NULL DEFAULT 0(迁移 0009,down 可逆);GET /sessions 排序改为 pinned DESC → 主会话优先 → createdAt ASC,会话 DTO 新增恒返回的 pinned bool;PATCH /sessions/{id} 扩展为 body {title?,pinned?}(至少一个字段,只回显变更字段,title-only 保持 A4 形状);HistoryStore 新增 SetPinned。全部为 owner/admin 写、读消息 owner/admin;未注入 HistoryStore 回 501。

  • v0.29(/admin/llm 读-改-写合并):POST /api/v1/admin/llm 由「全量替换」改为部分编辑合并——请求体字段用指针区分「未给」与「显式零值」;maxTokens / apiKey / provider / baseUrl / model / fallback / multimodal 省略即保留原值(不再把省略的 maxTokens 重置为 0),新组仍要求 baseUrl+model、非法 provider / 负 maxTokens 仍 400。面板 LLM 页新增每组 maxTokens 展示/编辑 + 新增/编辑表单;面板插件页新增「已装插件列表」+「安装 MCP 插件」表单(kind/name/transport/endpoint|command+args/apiKey/env),api.ts 请求体带 config(修掉旧 body 400)。runtimeconfig/models.go 新增 Find。

  • v0.28(MCP 插件热安装/卸载):POST /api/v1/admin/plugins/install(由 501 改为真实实现,owner-only + 审计)body {kind:"mcp",name,config:{transport,endpoint?|command?,args?,apiKey?,env?}} → 200 {data:{installed,name,kind,transport,toolCount}}——真实连接 MCP server、发现并热注册工具进 capability registry(下一轮模型请求即注入),配置落盘 plugins.json(gitignore、0600);连接失败回 502 且不落盘;同名重复 = 刷新。POST /api/v1/admin/plugins/uninstall 扩展:MCP 插件走 Unregister(移除工具 + close + 删配置),普通能力保持原 registry.Remove。新增 GET /api/v1/admin/plugins/installed(owner/admin 读)列出已装插件(apiKey/敏感 env 掩码 + live/toolCount)。mcp.MCPConfig 新增 APIKey(http/sse 以 Authorization: Bearer 发送)与 Env(stdio 子进程环境);plugins.json 于启动时 Restore 重新安装。契约版本 v0.28。

  • v0.27(LLM token 预算可配置化):models.json 模型组新增可选 maxTokens(int,缺省/0 = 未设),作为该组主/备链的 token 预算;GET /api/v1/admin/llm 的 AdminLlmProvider 与 POST /api/v1/admin/llm body/回显同步该字段(omitempty,未设为 0 时不出现)。.env 新增 LLM_FALLBACK_MAX_TOKENS(int,默认 8192,0 = 关闭兜底),只作用于确有回退模型时链末端的回退步。优先级:显式 Request.MaxTokens(审查/判定类小预算)> 生效组 maxTokens > LLM_FALLBACK_MAX_TOKENS(仅链末端、且组未设时)> 适配器默认(anthropic 8192)。单步链(无 fallback)不套用 env 兜底。maxTokens 为负回 400 bad_request。契约版本 v0.27。

  • v0.26(A5 消息正向增量):GET /api/v1/sessions/{id}/messages 新增 after 查询参数(<createdAt 毫秒>,升序返回 created_at > after 的消息,默认 limit=50/上限 200)——解决「WS 重连只回放主会话最近 50 条、断线超 50 条永久丢」的缺口;before 现行为不变;after 与 before 互斥(同传 400);负数/非数字 400。HistoryStore 新增 ListAfter(走 idx_chat_msgs_session_created,rowid 兜底同毫秒并列)。游标契约:客户端用「本页最后一条 createdAt」作下一页 after;同毫秒消息 > limit 时纯 createdAt 游标会跨页重读(超罕见,客户端按 id 去重兜底,与现有绕行一致)。

  • v0.25(B2 refresh token):POST /api/v1/auth/refresh(新增,{refreshToken} → 新 access + 新 refresh,默认单次使用旋转,并发重放 fail-closed invalid_grant)+ POST /api/v1/auth/logout(新增,撤单个或全设备)。POST /api/v1/auth/login 在 {token,expiresAt,role} 之外新增 refreshToken(不透明 256-bit 随机串,仅 SHA-256 哈希落库,AUTH_REFRESH_TTL 默认 720h,AUTH_REFRESH_ROTATE_DISABLED 关旋转)。新表 refresh_tokens(迁移 0008,id/user_id/role/token_hash UNIQUE/expires_at/revoked_at;含过期/轮换原子事务);rotate-jwt 成功即删除全部 refresh(返回 refreshTokensRevoked),revoke-keys 不动 refresh。密码/secret 记录见 internal/auth;refresh 全文绝不落日志/审计。

  • v0.24(B1 图片上传端点):POST /api/v1/upload(新增,multipart file,owner/admin+审计 → 201 {data:{url,size,mime,name}})+ GET /api/v1/files/{name}(新增,同端口静态服务)。安全:MaxBytesReader(5MB)、DetectContentType 嗅探(不信客户端 Content-Type)、白名单 raster image(拒 SVG/HTML)、crypto/rand 128-bit 随机名(丢弃原始文件名)、非可执行上传目录(0700)、nosniff;路径穿越全形态拒绝(含编码 %2f/%2e%2e)。错误码 400/413/415(internal/errors 增 ErrPayloadTooLarge/ErrUnsupportedMediaType)。设计取舍:GET 不鉴权(能力 URL,chat.image/<img> 无法带 Bearer),POST 仍 owner/admin。url 为绝对地址(X-Forwarded-Proto/TLS + r.Host,因 ParseImageRef 只认 http(s)://)。Deps.DataDir 由 wire 注入(resolveDataDir)。

  • v0.23(C2 语音 silent 降级信号):三处统一用 provider 只在「结果为空」时出现(非空/成功时才省略)——POST /api/v1/audio/transcribe 空文本响应体带 {text:"", provider:"silent"};WS voice_output 空音频 payload 带 provider;POST /api/v1/audio/speech 空音频仍 500 tts_empty 并加 X-Voice-Provider: silent 响应头(CORS Access-Control-Expose-Headers 已放开)。voice.Chain/Service 新增 STTWithProvider/TTSWithProvider(返回真实档位 cloud/local/edge-tts/espeak/silent),网关用可选接口 voiceProviderReporter 透传,自定义实现回退 silent。含 X-Voice-Provider(apperr 包装、providerSignal);DASHSCOPE_API_KEY 无/降级时信号为 silent。(go test -race 在本机受内核 TSAN VMA 限制失败,非本代码问题。)

  • v0.22(B3 会话列表 lastMessage 预览):GET /api/v1/sessions 与 /api/v1/admin/sessions 每个会话新增可选 lastMessage {content,role,createdAt}(content 折叠空白截前 60 rune + …;空会话省略),单条 SQL(LEFT JOIN 取每会话 ORDER BY created_at DESC,rowid DESC LIMIT 1,无 N+1,走 idx_chat_msgs_session_created 覆盖索引)。unreadCount 需已读游标设计,后置。

  • v0.21(A4 会话 title):PATCH /api/v1/sessions/{id}(新增,body {title} ≤80 rune → {data:{id,title}};空/超长 400、不存在 404、主会话可改名);会话首条用户消息到达时自动命名(history.append 前 ensureTitle:折叠空白取前 20 rune,纯空白回落 主会话/次级会话),仅首次命名、不覆盖。HistoryStore 新增 RenameSession;隔离会话(sess-xxx)不在 chat_sessions 有行、不自动命名(其本不在 /sessions 列表)。边界:前端 createSubSession(title?) 的 POST body 含 title 但后端暂忽略(会被首条消息自动命名覆盖),未处理。

  • v0.20(A3 消息 mode 落库):POST /api/v1/chat 与 ws.chat 上行新增可选 mode(行为模式,design/01 §3 / design/16 §5.1)。后端把每条持久化消息的 Mode 真实写入(IM 路径恒 chat;Web 路径经 ctx 透传本轮 mode,缺省回落 chat;恢复/续接路径沿用发起轮次的 mode),消息 DTO、message_persisted、channel_persisted 的 mode 不再恒空,供前端按模式渲染气泡(仅 chat 解析 <p> 拆气泡,其它模式原样整条)。

  • v0.19(百炼声音复刻 create_voice + TTS instruction 情绪驱动,主 TTS 用昔涟复刻音色):POST /api/v1/audio/voice-clone(新增,owner-only + 审计,body {url:"<公网参考音频URL>", prefix?} → {data:{voiceId}})——走后端 voice.DashscopeCloner(百炼 voice-enrollment create_voice,POST {base}/services/audio/tts/customization,input{action:create_voice, target_model:VOICE_TTS_MODEL, prefix, url},只收公网 URL 不收 base64,返回 output.voice_id;URL 不打日志不写审计)。POST /api/v1/audio/speech 新增可选 instruction(per-request 情绪/语气指令,如 "语气开朗俏皮,像女朋友撒娇"),经 ctx 透传给 TTS adapter;只对复刻/设计音色生效(cosyvoice-v3-flash 复刻音色可任意指令;系统音色 longxiaochun_v3 需固定格式,服务端 canInstruct 判定后永不发送)。config 新增 VOICE_TTS_INSTRUCTION(默认 "")、VOICE_CLONE_PREFIX(默认 cyrene,≤10 字母/数字);voice.Chain/voice.Service 新增 CloneVoice,VoiceCloner 接口(cloud rung 提供;未配 cloud 回 voice_unavailable)。⚠️ 真建昔涟音色仍待主人提供公网参考音频 URL(当前无),且 CosyVoice 声音复刻仅华北2北京地域(确认 key 地域);TTS instruction 用现有复刻音色或先仅单测请求体,避免改系统音色行为。

  • v0.18(后端语音接通阿里百炼 STT + TTS 主链路):POST /api/v1/audio/transcribe(新增,owner + 审计,body {audio,format?,deviceId?} → {data:{text}},走 STT);POST /api/v1/audio/speech(新增,owner + 审计,body {text,voice?,format?} → 音频字节 audio/<format>,走 TTS)——REST 语音源,原生/移动端经它绕开 WS 4KB 帧限上传/获取音频;WS voice_input 上行新增 deviceId(随语音轮次落库,design/16 §4.5)。后端 internal/runtime/multimodal/voice 的 cloud rung 由空 endpoint stub 改为真实百炼 adapter:STT qwen-audio-3.0-asr-flash(base64 原始字节→文本)、TTS cosyvoice-v3-flash(按 voice_id 合成,取非流式返回的 24h 音频 URL 下载字节)。实测(DASHSCOPE_API_KEY 实跑):TTS「你好,我是昔涟」→ 106124 字节 wav;把该 wav 喂给 STT → 文本「你好,我是西联。」(TTS 发音把「昔涟」读得近似「西联」,STT 识别正确);语音链 (cloud→…→silent) 返回真实字节/文本。已实测修正:cosyvoice-v3.5-plus/longxiaochun(v2 音色)走 HTTP SpeechSynthesizer 会回 418(引擎拒绝),改用 cosyvoice-v3-flash + longxiaochun_v3(v3 音色,各模型音色不可混用)。声音复刻(响昔涟音色)仍待:需参考音频(公网 URL)在百炼建自定义音色后把 voice_id 填入 VOICE_TTS_VOICE_ID(本项目不做 create_voice)。config 新增 DASHSCOPE_BASE_URL/VOICE_STT_\*/VOICE_TTS_\*(VOICE_TTS_SAMPLE_RATE 默认 24000)。

  • v0.17(系统设置安全与存储:吊销全部 API key / 轮换 JWT 密钥 / 备份 / 恢复,design/11 §4 + design/10 §3 模块14):新增 POST /api/v1/admin/system/{revoke-keys,rotate-jwt,backup,restore}(均 owner-only + 审计)。revoke-keys 走 auth.APIKey.RevokeAll()(清空 key 表,返回 {revoked:n});rotate-jwt 走 auth.JWTService.RotateSecret(≥32 位,新密钥不回显/不落日志,现有 token 立即失效);backup 用 SQLite VACUUM INTO 快照到 <dataDir>/backup-<ts>.db(含已提交 WAL 内容)返回 {path,size};restore 先校验路径在 dataDir 内(filepath.Rel 防路径穿越,逃逸回 400)→ 先自动备份当前 DB → 替换 DB 文件 → 返回 {restored:true,backup,note:"需重启后生效"}(运行进程持有 DB 句柄,热替换需重启可见)。gateway 新增 Deps.DatabasePath(composition root 传入实际打开的 SQLite 路径)。编辑环境变量 / 重启服务 / 检查更新 仍为后置,未实现、不伪造。

  • v0.17(KOOK 适配器面板 + 每适配器专属页,design/17 §2):GET /api/v1/admin/channels/{platform}/view、GET /api/v1/admin/channels/{platform}/friends?type=、POST /api/v1/admin/channels/{platform}/friend/handle、POST /api/v1/admin/channels/{platform}/message/test(新增,均 owner-only + 审计,带可选 ?accountId=,仅 kook 实现,其它平台 501);GET /api/v1/admin/channels 每条账号附 capabilities(面板按平台渲染专属卡片)。KOOK 面板:好友/申请、服务器/频道/成员、私信会话、发消息测试——经适配器 REST(Authorization: Bot <token>)拉真实数据,token 不打印不落日志,子项失败记入 errors 不伪造。

  • v0.16(运行时配置体系:渠道适配器配置落盘 + 热更新,design/04 §3.1):PUT /api/v1/admin/channels/{platform}/config(新增,owner-only + 审计)body {id?,token?,baseURL?,remoteURL?,listenAddr?,mode?,accessToken?,selfID?,config?,allowGroups?,denyGroups?,allowUsers?,denyUsers?,groupMsgPerMin?,msgPerMin?,maxMessages?} → 201 {persisted:true,path,platform,accountId}——读当前有效配置 → 写入该平台账号(自 v0.54 起改为按 id 打补丁 PatchChannel)→ 落盘 channels.json(本地、gitignore、0600)→ bridge.Reload(platform) 热替换运行中 adapter(进行中消息用旧、新消息用新),无需重启;token 写入文件但不回显/不落日志。DELETE /api/v1/admin/channels/{platform}/config?id=(新增)删除账号并热更新。GET /api/v1/admin/channels 每条渠道附 accounts(每平台已配账号,token 脱敏掩码)。.env(CHANNELS_JSON/CHANNEL_*_TOKEN)退为兜底。

  • v0.15(运行时配置体系:LLM 多厂商多模型独立组落盘 + 热更新,design/04 §3.1):GET /api/v1/admin/llm 返回 models.json 的全部组(provider 协议字段新增,index 0 = 生效组;APIKey 仍脱敏不回显);POST /api/v1/admin/llm 由「临时记录」改为真实持久化 + 热更新(body {id?,provider,baseUrl,apiKey?,model,multimodal?,fallback?} → 201 {provider,persisted:true,path},读-改-写 models.json、保存组提升为生效组并热重载运行中的 LLM stack,无需重启)。.env 退为兜底(无 models.json 时按 LLM_PROVIDER/LLM_*/ANTHROPIC_* 合成单组)。

  • v0.14(IM 多会话隔离 + 第三方身份 + 黑白名单,design/16 §4.6):chat_messages 增 sender_name 列(迁移 0006,默认 '');消息 DTO(REST 消息、WS history 回放、WS message_persisted)均带 senderName(第三方发送者昵称,主人私聊为空 omitempty)。第三方身份边界:IM 群聊/陌生人会话在上下文构建时对第三方消息注入 [第三方 · <昵称>] 前缀(不改源消息正文,落库保留原文)+ 前置系统提示词"每个 IM 的非管理员账户 = 第三方人员,和主人无关"——身份判定基于 channel 层 actor 元数据 + 白名单(MemWritable=false 即群聊/陌生人),绝不靠 LLM 猜。会话隔离:群聊/陌生人消息落独立隔离会话(sess-xxx,带 platform+senderName,不并入主会话 web、不走 message_persisted 广播),供 GET /api/v1/admin/channels/{platform}/messages(owner/admin,management 面板渠道日志区)以消息流显示;前端主会话只显示各平台管理员(owner)私聊;群聊/陌生人记忆只读不写主会话(memory_gating 已按 MemWritable 门控)。多账号:CHANNELS_JSON 支持同一平台多个接入配置(每个 account 独立 session/账号,见 §2 渠道契约)。黑白名单:每平台每账号可配 allowGroups/denyGroups/allowUsers/denyUsers,channelbridge 入站即拦截(黑名单/不在白名单 → 丢弃,audit 可查,不进 LLM);白名单按 platform+account(群 id / 用户 uid)。

  • v0.13(每条消息带 deviceID/client 标识,design/16 §4.5):chat_messages 增 device_id 列(迁移 0005,默认 '');POST /api/v1/chat 请求体新增可选 deviceId(客户端生成的端标识,如 web:chrome:a1b2c3);消息 DTO(REST 消息、WS history 回放、WS message_persisted)均带 deviceId。platform(平台)与 deviceId(具体端)互补:同一平台多个端 → 相同 platform、不同 deviceId。Web 端由 lib/client.ts 用 localStorage 持久化每个浏览器一个的 web:<browser>:<随机短id>,发送时自动带上,聊天气泡默认小字标注来源(仅当 deviceId 存在才显示);IM 端按 <im平台名>:<本地配置唯一id>:<用户平台唯一uid> 构造(当前 UnifiedMessage 尚无 deviceId 概念,channelbridge 以 <platform>:<channelID>:<senderID> 占位,<本地配置唯一id> 待适配器提供后替换);Web 端聊天不回流 IM。

  • v0.12(IM 渠道并入主会话 + 平台标记,design/16 §4.4):chat_messages 增 platform 列(迁移 0004,默认 'web');GET /api/v1/sessions/{id}/messages 支持可选 ?platform= 过滤(Web 端不传=全量共享主会话;IM 适配器传本平台只拉本渠道);消息 DTO(REST 与 WS message_persisted)均带 platform。IM 渠道主人私聊(!IsGroup && Owner)经 channelbridge 落主会话 web(platform=<渠道>)并广播 message_persisted → Web 端免刷新镜像;群聊/陌生人保持独立隔离上下文、不落主会话。Web 端聊天(platform=web)不回流 IM。

  • v0.11(跨端实时同步,design/16 §4.2):WS 下行新增 message_persisted 事件——每条 user/assistant 消息落库后广播 {type, sessionId, data:{message:{id,sessionId,role,content,mode?,mood?,image?,createdAt}}}(id 为后端生成,thinking 已剥离)。客户端按 sessionId === activeSessionId 过滤 + 按 id upsert(含本端乐观气泡 uid→后端 id 归一)去重,实现「A 发消息、A 与 B 都无需刷新且各出现一次」。后端 history.Store 新增 AppendFull 供编排层取含 id 的落库消息。

  • v0.10(会话消息持久化 — WS 契约,design/16,后端-2):WS 下行帧新增可选 sessionId 字段(能确定会话的事件带上);新增 history 回放事件——WS 握手成功即回放主会话最新 50 条({type:"history", sessionId:"web", data:{messages:[...]}},thinking 已剥离)。stream_end/stream_chunk/会话编排状态帧(session_busy 等)在能确定 session 时带 sessionId;message.delta 等结构化流帧当时因 model.StreamEvent 无 session 上下文不带(即"若知道就带上"边界)——该缺口已在 v0.36 补齐(结构化流帧现在带 sessionId + turnId,见 §3.1)。

  • v0.9(会话消息持久化 + 主/次级会话,design/16,后端-1):新增 POST /api/v1/sessions(新建次级会话)、GET /api/v1/sessions/{id}/messages?limit=&before=(游标热分页拉历史,时间倒序)、DELETE /api/v1/sessions/{id}(删次级会话及其消息);GET /api/v1/sessions 在注入 HistoryStore 后改返回持久化会话元数据数组(主 + 次级,type 区分),未注入时保持原渠道会话列表向后兼容。读消息/写会话为 owner/admin(用户侧私有历史,thinking 不达 viewer);会话元数据列表保持任意认证角色可读。

  • v0.8(管理面板写操作 CRUD):补齐管理面板写端点(见 §2「管理面板写操作契约」,均 owner-only + 审计)——POST/PUT /admin/personas(真实注册 + 持久化 configs/personas/<id>.yaml)、POST /admin/users/apikey + DELETE /admin/users/apikey/{id}(真实签发/撤销)、POST /admin/iot/{id}/control(真实设备控制)、POST /admin/automations + /run + DELETE(真实注册/触发/移除规则)、POST /admin/llm(持久化 models.json + 热更新,v0.15 起)+ /admin/llm/test(真实模型调用)、POST /admin/channels/{open,close,test}(真实连接/断开 + 结构性身份识别)、POST /admin/plugins/uninstall(真实 registry 移除)、POST /admin/memories/search(真实检索)、GET /admin/sessions/{id}/export。无存储的写(新增用户 / IoT 场景·规则 / 插件安装)如实返回 501 not_implemented。

  • v0.7(会话消息编排 design/15):POST /api/v1/chat 非流式且会话「正忙」时改回 202 {data:{queued:true,queueDepth}}(原同步 {data:{reply}} 仍为空闲时返回);pending 满回 409 chat_busy。WS 上行新增 interrupt({sessionId},中断当前任务+清 pending);WS 下行新增会话编排状态事件 session_busy / pending_ack / pending_drain / session_idle / session_interrupted / pending_full(见 §3.2)。既有 stream_chunk/stream_end 与结构化流事件不变,契约向后兼容。

  • v0.6(可观测性):新增 GET /api/v1/admin/observability/{metrics,trace,audit}(owner/admin)——metrics 返回网关实时请求计数/QPS/延迟/方法·路径·状态分布 + LLM/子代理 span 计数;trace 返回 gateway 环形缓冲里的最近 span(经 TraceSink 对接 runtime tracer,映射为 AdminTraceSpan);audit 返回持久化审计(SQLite audit_logs,未注入时退化为内存)。llmCalls/subagentEvents 由实际观测 span 累计;Token/成本后端不统计、不返回(不伪造指标)。

  • v0.5(批次B·视觉真推理 + 语音端):POST /api/v1/chat 增 image 字段(base64 data: URI 或 URL),入 loop 视觉路由(vision/OCR 工具或主模型多模态,design/06 §0「先看图再对话」);WS 上行 voice_input 接 STT→聊天→TTS 管道,下行新增 voice_output(定向回请求连接,含合成音频 + 识别文本 + 回复文本)。

  • v0.4(结构化流):WS 下行新增 message.* / thinking.* / tool_call.* / tool_execution.update 结构化流事件(event shell 补 turnId/seq,data 类型化),对齐 OpenClaw AssistantMessageEvent;保留 stream_chunk/stream_end 向后兼容。

  • v0.3(M10):管理面板 /admin 落地——按 §2 契约封装 admin 只读端点(personas/sessions/memories/plugins/channels/iot)+ owner-only plugins/policy 写;其余 M10 端点以契约路径封装、后端未实现时前端显式"待实现"壳。(注:面板前端入口自 v0.55 起为路径式安全入口,前缀由 ADMIN_PATH 决定、默认即 /admin;本节其余关于面板的叙述都不含前端路由前缀语义。)

  • v0.2(M8):gateway 落地——登录/health/chat/sessions/personas + admin 只读端点 + plugins/policy 写 + WS /ws/chat(JWT + bus 广播)。

  • v0.1(契约先行):骨架 + 端点登记表。