From 879669cc594361773011ac85e56099d7e23c4dea Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 5 Oct 2026 09:23:46 +0800 Subject: [PATCH] =?UTF-8?q?docs(api):=20=E5=A5=91=E7=BA=A6=20v0.65?= =?UTF-8?q?=EF=BC=88=E5=BF=99=E6=97=B6=E5=B9=B6=E8=A1=8C=E5=9F=BA=E7=A1=80?= =?UTF-8?q?=E7=AE=A1=E7=BA=BF=20+=20=E5=85=AD=E6=9D=A1=E7=99=BB=E8=AE=B0?= =?UTF-8?q?=EF=BC=89+=20=E4=BF=AE=E4=B8=89=E6=9D=A1=E8=BF=87=E6=9C=9F?= =?UTF-8?q?=E9=99=88=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 升 v0.65(依据 design/15 §2.5 与 commit 89e13cf),一并登记 679cee0(IM 入站附件引用落库) / 59a1ac4(llm_usage.session_id 接线)/ a918819(IM 各渠道语音可被读到), cb564fe / 15181e7 / 7d2ff74 为纯内部修(无契约变化)。 三档:纯增量 = chat.parallel / activeTurns / cancelledTurns / pending_ack.reason / 两条 REST 应答形状 / VOICE_STT_TIMEOUT_SEC;行为变更 = IM 附件引用落库(无新字段)/ IM 语音进上下文 / session_idle 时点收紧(仅并行时);破坏性 = 无。 修正(客户端抓到的三条,属实):① session_id 「恒为 NULL」→ 已接线(端点仍不按会话过滤/分组) ② 「契约版本」标题并列三处 → v0.65 唯一,其余降为「上一版」 ③ 按提交逐个核对补齐(mtime 落后问题)。 未实现项写明:turn_begin / steer_ack 帧壳 turnId / interrupt.turnId / chat_messages.turn_id / graph_update 按路分桶 / 写类工具文件级互斥(tool/legfile.go 仅定死形状,无实现无接线)/ 第二路 system 摘要。 env CHAT_MAX_PARALLEL_TURNS 如实标注「未接线,生产恒为默认 2」。 --- Docs/api-client.md | 39 +++++++++++++++++++++++---------------- Docs/api-reference.md | 31 ++++++++++++++++++------------- 2 files changed, 41 insertions(+), 29 deletions(-) diff --git a/Docs/api-client.md b/Docs/api-client.md index 019c108..96d9ba6 100644 --- a/Docs/api-client.md +++ b/Docs/api-client.md @@ -27,7 +27,7 @@ | 1a | `/api/v1/auth/refresh` | POST | refreshToken | ✅ | **静默续期**(B2):body `{refreshToken}` → `{data:{token,expiresAt,role,refreshToken?}}`(新 access + 新 refresh;**`role` 恒返回** = 该 refresh grant 记录的角色 `owner|admin|viewer`),**默认单次使用旋转**——旧 refresh 立即作废,并发重放 fail-closed `401 invalid_grant`)。`AUTH_REFRESH_ROTATE_DISABLED=true` 关闭轮换时旧 token 保持有效、不返回新 refresh。过期/已撤销/乱串统一 `401 {error:{code:"invalid_grant"}}`;成功才审计 | | 1b | `/api/v1/auth/logout` | POST | refreshToken 或 JWT | ✅ | **登出**(B2):body `{refreshToken}` 撤**单个** grant(幂等)→ `{data:{revoked:}}`;带 `Authorization: Bearer ` 且无 body → 撤**该账号全部** grant(全设备登出)→ `{data:{revoked:,revokedCount:}}`。**两种响应形状不同**——单撤**没有** `revokedCount`;两者都不给回 `400 bad_request`。refresh 相关操作成功才审计 | | 2 | `/api/v1/health` | GET | 否 | ✅ | 健康检查 `{data:{status:"ok"}}` | -| 3 | `/api/v1/chat` | POST | JWT | ✅ | `{text,image?,deviceId?,mode?,clientMsgId?,documents?,stream?}` → `{data:{reply}}`;**`documents?`(v0.59)**=本回合文档附件引用数组(**≤5 条**、**只收服务端存储名**、与 REST `stream:true` / WS `chat` 同形),单回合超限 `400 too_many_documents`(先于任何文件系统访问)、名非法/穿越/不存在 `404 document_not_found`,详见 §1.2;`stream:true` 时**直达会话编排器**(v0.56/G2:不再经可丢弃的 bus,成功受理后才回,见本行末尾「`stream:true` 响应面」)。`image` 为 base64 data: URI 或 http(s) URL,入视觉路由(design/06 §0;**v0.53 语义修正**)——**主模型能不能收图看生效组的 `multimodal`**(`models.json` 组键,面板可保存、热更新);`LLM_VISION_MODEL`/`LLM_OCR_MODEL` **只配视觉/OCR 工具用的模型**,**与主模型能否收图无关**(裁决 14.4.1)——**走 `.env` 合成组(无有效 `models.json`)时,「主模型能否收图」该由 `LLM_MULTIMODAL` 声明(默认 `false`)**(**v0.58**,见 §2「模型配置契约」表);主模型不收图时由视觉·OCR 工具的**文本描述**替代(图像不占上下文,只有工具结果占);**`image` 上限(v0.56,G3)**:解码后 **5 MiB**(与 `POST /api/v1/upload` 同口径;base64 字符串上限 = `ceil(n/3)*4 + 64` 字符,已含 data: URI 前缀余量),超限一律 `400 {error:{code:"image_too_large", message}}`(文案指路 `POST /api/v1/upload`);**WS `chat` 帧到不了这条判定**——WS 读上限 `wsReadLimit = 4096` 字节/帧,超限由传输层直接断连,故 WS 上发真实图片本就必须「上传 + URL」;`deviceId` 为**客户端生成的具体端标识**(design/16 §4.5,如 `web:chrome:a1b2c3`,可选),落库到消息、便于主人知道从哪个端发来;`mode` 为**行为模式**(design/01 §3 / design/16 §5.1,如 `chat`/`code`/`home`/`docs`/…,可选),随该轮次落库——IM 恒 `chat`、缺省回落 `chat`,供前端按模式渲染气泡(仅 `chat` 才解析 `

` 拆气泡)。`clientMsgId`(v0.56,G4)为**客户端自带的幂等键**(可选):同一会话内同一个非空值只承认第一条,重复到达回 `200 {data:{duplicate:true,messageId,sessionId}}` 且**不落库、不起回合、不发新内容帧**;省略 = 与加入该字段前逐字相同(详见 §3.13)。**会话编排(design/15)**:非流式且该会话「正忙」时**不阻塞**,返回 `202` `{data:{queued:true,queueDepth}}`(稍后按上下文续接);待处理队列满时返回 `409` `{error:{code:"chat_busy"}}`。**`stream:true` 响应面(v0.56,G2,行为变更)**:受理成功后才回——空闲已起回合 = `200 {data:{stream:true}}`、正忙已续接排队 = `202 {data:{stream:true,queued:true,queueDepth}}`、队列满 = `409 {error:{code:"chat_busy"}}`、幂等键重复 = `200 {data:{duplicate:true,messageId,sessionId}}`;**未接 loop 改为 `500 {error:{code:"chat_unavailable"}}`**(旧文写 `200 {stream:true}`,现与同步路径同口径);仅当**未接编排器**才回退总线派发,其失败回 `503 {error:{code:"chat_dispatch_failed"}}`——**不再有「先回 200 再看有没有落地」**,故 `202`/`409` 从此**真的会出现**(修复前因提前 return 永不出现) | +| 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` 才解析 `

` 拆气泡)。`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"|"sub"),title?,createdAt,pinned,unreadCount,lastMessage?,personaId?}]}`(**排序(D5):以代码为准 = `pinned DESC, (type='main') DESC, created_at ASC`**——`pinned=true` 最前,其次是未置顶的主会话,最后是未置顶子会话(按 `createdAt` 升序);⚠️ **置顶的次级会话会排在主会话之前**,客户端定位主会话请按 `id=="web"` / `type=="main"`,**不要假定 index 0 就是主会话**;次级 `web:`;`pinned` 恒返回 bool;**`unreadCount`(D4)恒返回整数**——该会话中 `createdAt >` 已读游标的消息条数(0 = 无未读),与 `lastMessage` **同一条 SQL 派生**、无 N+1;`lastMessage` 为**最后一条消息预览** `{content,role(user|assistant),createdAt}`,content 折叠空白截前 60 rune,空会话省略;**`personaId`(v0.42,可选)**=该会话绑定的角色 id(design/22 §2/§4.5),**空/缺省 = 继承全局默认角色**(`owner.yaml:defaultPersona`),客户端据此渲染激活会话的角色名/头像);未注入时退化回渠道会话列表 | | 5 | `/api/v1/sessions` | POST | JWT(owner/admin) | ✅ | **新建次级会话** → `201 {data:{sessionId,personaId?}}`(`sessionId="web:"`)。body **可选**(可完全省略或空 body):`{personaId?}`(v0.42)在创建时就把新会话绑到该角色(design/22 §2:渠道级只作"新会话的初始角色提供者";**会话角色只在创建时写入、之后不可变**,见 §1 行 8 / design/22 §8-1);`personaId` 为**未知角色**回 `400 bad_request`;省略/空 = 不绑(`persona_id=''`,继承全局默认);**`personaId` 非空时响应回显它**(省略时响应形状与 v0.41 完全一致);未注入 HistoryStore 回 `501 not_implemented`;注入了**不支持角色绑定**的 HistoryStore 且显式传 `personaId` 时也回 `501`(不假装写入) | | 6 | `/api/v1/sessions/{id}/messages` | GET | JWT(owner/admin) | ✅ | **热分页拉历史**:`?limit=&before=&platform=&after=`(A5)默认 `limit=50`(上限 200)。**`before`**=上一页最旧 `createdAt`(毫秒),按时间**倒序**;**`after`**=<`createdAt` 毫秒>,按时间**升序**返回 `created_at > after` 的消息(**正向增量**,客户端把上次看到的 `createdAt` 当游标补断线期间漏的,解决重连只回放 50 条的缺口);**`after` 与 `before` 互斥**,同传回 `400`;负数/非数字回 `400`。`platform` 为**可选**来源平台过滤(design/16 §4.4)——Web 端不传(=全量共享主会话),IM 适配器传**本平台**(如 `telegram`)只拉本渠道消息;`deviceId` 为**客户端具体端标识**;`senderName` 为**第三方发送者昵称**;**`personaId`(v0.42,可选)**=**该消息说话时的角色 id**(design/22 §7-5:逐条记录"谁说的"——会话角色在创建时固定,§8-1;assistant 消息 = 生成它的角色,user 消息 = 当回合角色;**空 = 全局默认角色**,即 v0.42 之前的全部历史消息),客户端据此按角色渲染名字/头像;**`clientMsgId`(v0.56,可选,G4)**=该消息的**客户端幂等键**(带 `clientMsgId` 发的 user 消息会回填它;空则省略),客户端据此把本地乐观气泡对齐到服务端真相;**`q`**(D2)=**会话内内容搜索**(大小写不敏感 ASCII LIKE,`%`/`_`/`\` 已转义为字面量),仍按时间**倒序**、`before`/`limit` 同分页语义;`q` 与 `after` **互斥**(同传 `400`),空 `q` 视为未传(走普通分页),搜索**严格限定在 `{id}` 会话内**(绝不跨会话,群聊/陌生人隔离会话不会外泄);非法 `limit`/`before`/`after` 回 `400`;未注入 HistoryStore 回 `501`。**(v0.63,行为变更)会话不存在 / 已删除 ⇒ `404 {"error":{"code":"session_not_found","message":"session \"\" 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`,与目标存不存在无关) | @@ -212,11 +212,13 @@ > **发现的不一致(如实登记,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.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 在请求上打标,属本轮并行切片在途文件,故留列后接)。**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.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-`)⇒ **对外无可见差异**,写在此处只为让「谁生成」的口径与实现一致。**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.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.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.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)**刻意相反**(文档摄取在服务端完成,不需要被 ``/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>.`;图片侧是绝对 URL);`mime` 是**嗅探所得基类型**(故 `data.json` 返回 `text/plain`,不是 `application/json`);落盘名 = `sha256(bytes)[:32] + ext`(**内容寻址**,原始文件名永不参与路径 ⇒ 同字节同扩展名天然同名去重)。⑤ **4 个错误码**:`unsupported_media_type`(415) / `document_too_large`(413) / `document_not_found`(404,含名字形态非法与路径穿越) / `too_many_documents`(400)。⑥ **`chat` 请求新增可选 `"documents":[{"name":"<32hex>."}]`**(REST 同步 / REST `stream:true` / WS `chat` **三处同形**):**≤5 条**(超出 `too_many_documents` 400,且**先于任何文件系统访问**);**只收服务端存储名、绝不收 URL**(不重开 F3 那种「服务端代取 URL」的 SSRF 面);客户端多带的 `mime/url/size` **一律被服务端覆盖**(MIME/大小取自落盘字节);名非法/穿越/不存在 → `document_not_found`(404);**任一条不合规都让整个请求被拒**,绝不「丢掉这条附件但照样起回合」(WS 侧经 `error` 帧带 `data.code` 下发并中止本回合,REST 侧回对应状态码)。⑦ **响应 / 历史 DTO 由 `document`(单对象)改为 `documents`(数组,≤5)**——元素形状 `{"name","mime","size","chars","truncated","status"}`,`status ∈ extracted|listed|unreadable`;**0 个附件时该键省略**;同一 DTO 供 `GET /sessions/{id}/messages`、`message_persisted`、`message_edited`、WS 握手回放 **四处同时生效**(同一个 `historyMessage` 结构)。⚠️ **原单数形状从未发布过**(不在任何已发布版本号里)⇒ **无兼容期**,直接换数组(design/24 §6.1 那句 `"document":{…}` 已随之作废)。⚠️ **`chars`/`truncated` 改为「本回合注入侧」语义**:`chars` = **本回合注入**模型上下文的字符数、`truncated` = **注入**是否被截断(**不是**「原文件是否被截断」);**全文长度**只有 `document-read` 的 `totalChars` 能看到。⑧ **内置工具 `document-read`**(模型侧工具,客户端一般只需知道它存在):`{name, offset?, limit?}` → `{name,status,offset,limit,totalChars,truncated,nextOffset,text,extractTruncated?}`;`limit` 默认 **8000** 字符、上限 **64K**;`offset` 单位是**字符(rune)**、0 基、起点 = sidecar 正文(含 CSV/TSV 摘要行)第 0 个字符;`offset<0` 或 `>totalChars` → 错误(`==totalChars` 合法,读到末尾空片段);`extractTruncated=true` **专指**「撞上 8 MiB sidecar 安全上限」(≠ 本回合注入被截断);策略默认 **owner-only**(群聊/陌生人天然读不到主人的文件)。**B. 配置面(纯增量 + 一处语义变更)**——① **`models.json` 组级新增 `contextWindow`**(正整数 = 该组模型的上下文窗口;`0`/省略 = 未声明 ⇒ 后端按 **131072** 保守估算并记**恰好一条** WARN;**负数 = 组校验失败**,写端点回 `400`)。 ② **`.env` 新增/变更 4 键**:`LLM_CONTEXT_WINDOW`(**per-group 播种**,等价于 `models.json` 的 `contextWindow`)、`CONTEXT_BUDGET_RATIO`(默认 **2/3**,接受 `2/3` 或 `0.6667`,非法值记 WARN 后回落)、`DOCUMENT_MAX_INJECT_CHARS`(单份附件注入上下文的字符上限;未设 = 按推导出的预算自动算)、**`CONTEXT_MAX_TOKENS` 语义变更(关键)**:**未设置 ⇒ 按窗口推导**(`budget = min(窗口 × 比例, 窗口 − 输出保留)`,输出保留 = `max(32768, 该组 maxTokens)` 且**封顶 `窗口/2`**;**1M 窗口 ⇒ 699050 tokens**);`>0` = 显式覆盖(原样使用);`0`/负 = **关闭压缩**。⇒ **`.env` 里若仍写着 `CONTEXT_MAX_TOKENS=32768`,它就是显式覆盖、推导不生效**(这是设计语义,**不是 bug**;本次已把 `.env.example` 里该行改成注释形式)。③ **`.env` → 面板配置的 materialize 语义(欠账,本次补登记)**:进程**每次启动**跑一次;**面板配置非空 ⇒ 组集合完全由面板决定**(env 不新增、不复活被面板删掉的组);**键级只在面板缺该键时用 env 补**,面板已有的键(含**显式空值 / 显式 `false` / 显式 `null`**)**绝不覆盖**;面板配置缺失/为空时才由 env 播种出合成组;**「面板改一次、重启被打回」是硬约束**(有专门用例钉住);每次启动记「补了哪些 / 跳了哪些」,**凭据绝不进日志**;范围目前**仅 `models.json` 一族**(`channels.json` / `plugins.json` 未纳入)。④ **面板读写面**:`GET /api/v1/admin/llm` 的组 DTO **含 `contextWindow`**(`omitempty`,与 `maxTokens` 同形:未声明时该键省略),`POST /api/v1/admin/llm` **接受可选 `contextWindow`**——**缺失 = 保留已存窗口**、**显式 `0` 与缺失同义**(都 = 未声明)、**负值 `400 bad_request` 且不落盘**;面板 LLM 页据此显示/编辑该键。**C. 语音(有行为变更,见 §3.15)**——语音轮次**现在走与文本同一条路径**:会**落库**(用户消息 = 转写文本,助手消息 = 回复)、会发 `message_persisted` 与流式帧、**跨端同步**、忙时按编排器既有规则**排队**(`pending_ack`/`pending_drain`/`session_busy` 语义零变化,只是多了一个消费者);`deviceId` 照旧落库、`platform` 走存储默认 `web`;`voice_input` 上行增可选 `clientMsgId`(与 `chat` 帧同形 ⇒ 语音也有幂等;重复到达回既有 `chat_duplicate`,**不产生新回合、不发音频**);`voice_output`(定向)载荷增 `sessionId`(恒有)与 `deviceId`(非空才有);**空正文**与 **TTS 失败/降级**都走既有 `provider` 降级信号(客户端据此提示「没生成声音」;**不再**把 `stopReason` 当语音内容);**时点**:`voice_output` 在**该回合结算时**才到(可能晚于若干状态帧,甚至由续跑回合或被 steer 注入的那一回合产生);定向目标是**发起 `voice_input` 的那条连接本身**(服务端**不按 deviceId 路由**);连接断开后**不补投**,客户端重连后带上同一 `clientMsgId` 可安全重试。**D. `G5`(纯增量:广播帧 +2 个可选键;新增一条定向错误帧,见 §3.2)**——① **`pending_full` 广播载荷**增 `clientMsgId?` / `messageId?`:前者 = **被拒那条消息**的幂等键(未携带则**整个键缺席**);后者**仅当该消息此前已落库/已认领时**才回填——**服务端绝不在此处新铸 id**(队列满的拒绝发生在幂等键认领之前,代铸会让客户端重试被判成「重复到达」而永远进不来)。② **新增定向帧(此前无文档登记)**:`{type:"error", data:{code:"chat_busy", message, sessionId, clientMsgId?, messageId?}}`,**只发发送者那条连接**、不广播;`code` 与 REST `409 chat_busy` **同码**(客户端一个 code 覆盖两条入口)。**E. `E3`:`thinking` 可达性口径(不改行为,只是把话说清)**——消息体的 **`thinking` 字段**在**实时帧**(`message_persisted` / `channel_persisted` / `message_edited`)与**握手回放**中**一律为空**(隐私与带宽的**有意**默认;实时思维链仍由 §3.1 的 `thinking.*` 流帧按 owner/admin 档下发);**历史消息的思维链**只有 `GET /sessions/{id}/messages`(owner|admin)返回。**上一版 `v0.58`**:(**新增 `.env` 键 `LLM_MULTIMODAL`(`bool`,默认 `false`):让 env-only 部署能声明「主模型自身能否收图」**,依据裁决 14.4.1 / design/06 §0(commit `4adfc9a`),2026-10-04 落实):**A. 新键语义(见 §2「模型配置契约」表)**——**`LLM_MULTIMODAL` 是 `models.json` 组 `multimodal` 的 env 等价物**,**只作用于 `.env` 合成组**(**无有效 `models.json` 时才生效**;有组文件时以该组的 `multimodal` 为准,与 `LLM_REASONING_KIND` 同口径):`true` = 图片**直进主模型上下文**(省掉工具那一轮模型调用、也不做有损文本摘要),**留空 / `false`(默认)= 把图交给 vision/OCR 工具换成文本描述**;**绝不凭 base URL / 模型名猜测**端点是否收图(声称端点不具备的能力,比把图交给工具更糟——工具至少回一段文本,design/06 §0 首条)。**B. 与 `LLM_VISION_MODEL` / `LLM_OCR_MODEL` 的关系**——后两者配的是**视觉/OCR 工具调用的模型**,**不代表主模型能收图**,与本键互不代表(见 §1 行 3)。**C. 兼容性(默认值即旧行为)**——默认 `false` ⇒ **老部署行为逐字不变**(引入本键之前,env-only 部署没有任何键能声明该能力,图片恒走 vision 工具);**非 bool 值(如 `maybe`)在配置解码阶段直接报错、启动失败**(fail-fast,**不静默回退成 `false`**——静默的「配了当没配」正是本键要消灭的)。**`LLM_MULTIMODAL=true` 后图片走主模型直收,属部署者显式选择**(形状由部署者声明,后端不替它猜)。**D. 实现落点**——`internal/config/config.go`(`LLMConfig.Multimodal`,默认 `false`)+ `internal/config/runtimeconfig/models.go`(`GroupFromEnv` 把两 provider 分叉都透传进合成组)+ `internal/config/runtimeconfig/multimodal_env_test.go`(默认 false / 显式 true 抵达合成组 / 进程 env 端到端 / 组文件优先 四组测试)。**无端点、无帧、无字段形状变更**(纯新增 env 键)。**上一版 `v0.57`**:(**13.4.5 目标态 steer 落地登记:新下行帧 `steer_ack`**,依据 design/15 §2.0/§2.1/§2.4/§3.x/§5(裁决 13.4.5 目标态,commit `3d8822c`),2026-10-04 落实:**A. 新下行帧 `steer_ack`(见 §3.14)**——`{type:"steer_ack", sessionId, data:{messageId, atStep}}`:忙时会话里新到的用户消息**被在飞回合吃进同一回合**时下发(`messageId` = 那条用户消息的**权威 id**,`atStep` = 吃进它的**模型步序号,1 基**);语义=「**该消息已被本回合吃进(已从 pending 移除)、尚未被正面回答**」⇒ 客户端应把对应气泡渲染为「已读、未答」的**灰态**,直到该回合的 `message.*` / `turn.end` 到达(**不需要第二个确认帧**)。**角色门**:它**携带具体消息身份**(客户端能定位到某一条气泡,而不只是会话忙闲),因此**不登记**进 `frameReachesAll` 的 7 个状态机信号白名单,落 fail-closed 默认档——**只发 `owner` + `admin`**(`viewer` 收不到);经编排器状态通道**由 hub 直接广播、不经 bus**,帧壳带 `sessionId`、**不带** `turnId`/`seq`。**触发时机**:只在**回合内边界**(loop 每一步开头:上一次模型步与它那一批工具调用**都已结束**、下一次模型调用**尚未开始**,此刻没有工具在飞)产生,且**只有该边界真的匹配到 pending 里、且已落库的消息时才发**——**不是每个 step 都发**(没可注入消息的边界一帧都不发,这是常态)。**B. 转向的三条不变量(design/15 §2.0)**——**I1(同会话在途回合数恒为 1)**:转向只注入、不取消,**绝不新开第二个回合**(loop 侧结构性地没有任何「起新回合」的分支,注入只是把消息并进**当前回合**下一次模型调用的视图);**I2(消息恰好被处理一次)**:注入=已消费——被注入的消息立刻从该会话 pending **移除**(按 pending 序号认领),**绝不被第二个回合再答一遍**,去重键=后端 `message.id`,且候选必须**同时**满足「在该会话转录里(最近 20 条窗口内)」与「在本编排器的 pending 里」;**I3(转向不取消已发起的工具)**:注入点只在回合内边界,此刻没有任何工具在飞,正在执行的工具**永远跑完**。**C. 注入的范围(口径)**:注入**只影响本轮视图**(每一步边界重新并入),**不写回运行窗口** ⇒ 既不进该回合的 **checkpoint 快照**、也不进**后台记忆提取的输入面**;被注入的消息**本身早在到达时已落库**(pending 路径即时持久化),注入**不新增、也不改写任何行**。**D. 失败一律让路**:未接转录存储 / 读转录失败 / 该消息尚未落库 / 本回合世代已被打断或被更新的回合取代(认领 `pending` 失败)时,**不发 `steer_ack` 也不注入**——该消息**留在 pending**,照旧作为自己的回合被回答,**绝不静默消失**。**E.** 另含一处**链路图纯增量键**(注入时该轮图的 input 节点带 `props.steered:true`,图仍只有一条主干——不是被画成第二轮),以及**纯新增帧**的兼容性口径(老客户端按未知帧忽略即可)。**上一版 `v0.56`**:(**协同项 A2 / G1 / G2 / G3 / G4 / F3 落地登记:`interrupt` 支持 `scope` + REST `stream:true` 不再静默失败 + `chat.image` 上限 + `clientMsgId` 幂等 + 视觉链路 URL 取回与类型裁决 + 既有错误陈述更正**,依据 `Cyrene-For-Android/devdocs/02-前后端协同事项.md` 的 A2 / G1 / G2 / G3 / G4 / F3(commit `7512d59` 等,2026-10-04 落实):**A. 上行 `interrupt` 新增可选 `scope`(A2)**——`{sessionId}` → `{sessionId, scope?}`,取值域 `{"", "all", "current"}`(**省略 / 空串 / `"all"` 三者同义**):`all` = 取消当前任务 + 清空待处理队列(**逐字不变的现状语义**);**`"current"` = 只取消当前任务、保留待处理队列**(队列消息按编排器正常续跑规则继续执行、带上会话尾部上下文);**其余取值 → 只回发送者** `{type:"error",data:{code:"bad_request"}}`(**定向**:不广播、不打断任何会话,绝不静默按默认执行);命中空闲会话为 no-op。下行 `session_interrupted` 帧形状**零变化**(`{sessionId, mode, dropped}`),但 `dropped` 恒等于**真的被丢弃**的待处理条数——`scope:"current"` 时为 `0`,且 `mode` 是该会话当时的**忙碌通道**(`thinking|executing|subagent`,**不是 `idle`**);`scope:"all"` 时 `mode` 为 `idle`、`dropped` 为清空的 pending 条数。**B. REST `stream:true` 不再静默失败(G2,行为变更)**——该分支改为与 WS `chat` 帧**同一条直达编排器的路径**(design/15 §2.1「会话消息不得被 drop」),**响应面**:受理成功后才回 `200 {data:{stream:true}}` / 正忙已续接排队 `202 {data:{stream:true,queued:true,queueDepth:n}}` / 队列满 `409 {error:{code:"chat_busy"}}` / 幂等键重复 `200 {data:{duplicate:true,messageId,sessionId}}`;**未接 loop → `500 {error:{code:"chat_unavailable"}}`**(旧文写 `200 {stream:true}`,现与同步路径同口径);仅**未接编排器**才回退总线派发,其失败回 `503 {error:{code:"chat_dispatch_failed"}}`(此前 `Publish` 的错误被吞掉,且传的是**请求作用域** ctx)。**`202`/`409` 从此真的会出现**(修复前因提前 `return 200` 永不出现)。派发用的回合上下文与请求作用域脱钩(`context.WithoutCancel` + 30 分钟上限),回合不会在客户端刚收到 200 时被连带取消。**向后兼容**:`stream:false` 与既有同步响应形状一字未动;依赖「任何情况都拿到 200」的客户端必须按上表分支,别再假设 `stream:true` 恒 `200`。**C. `chat.image` 上限(G3)**——`POST /api/v1/chat` 的 `image` 新增硬上限:**解码后 5 MiB**(与 `POST /api/v1/upload` 同口径;base64 字符串上限 = `ceil(n/3)*4 + 64` 字符,覆盖 data: URI 前缀),超限 → `400 {error:{code:"image_too_large", message}}`(文案指路 `POST /api/v1/upload`),在**任何解码 / 落库 / 入模型之前**拦下(超大 base64 因此不进历史、也不会被重连回放重传)。**事实(勿误读为「WS 也有这条 400」)**:WS `chat` 帧**今天已被传输层关死**——WS 读上限 `wsReadLimit = 4096` 字节/帧,超限直接断连,**到不了**这条判定 ⇒ **WS 上发真实图片本就必须「上传 + URL」**。**D. `clientMsgId` 幂等(G4)**——上行**新增可选** `clientMsgId`(REST `chat` 体与 WS `chat` 帧都支持;**省略 = 老行为逐字不变,老客户端零影响**):同一会话内同一个非空值只承认第一条。**重复到达**:REST → `200 {data:{duplicate:true,messageId,sessionId}}`;WS → 新下行帧 **`chat_duplicate`**(`{type:"chat_duplicate", sessionId, data:{sessionId, clientMsgId, messageId}}`,见 §3.13);重复**不落库、不起回合、不发任何内容 / 流式新帧**。**判重由 SQLite 部分唯一索引原子裁决**(`(session_id, client_msg_id) WHERE client_msg_id != ''`),并发的两次重发只有一个赢家,不存在「先查再插」的竞态窗口。`clientMsgId` 另**回填进消息 DTO** ⇒ `message_persisted` / `channel_persisted` / 握手 `history` 回放与 `GET /sessions/{id}/messages` 等读接口可见(空则省略),客户端据此把本地乐观气泡对齐到服务端真相(替代「靠文本相等」的脆弱匹配)。**迁移**:新增 `0013_chat_client_msg_id`(up:加列 + 部分唯一索引 `idx_chat_messages_client_msg_id`;down:先删索引再删列;全新库由 `history.NewStore` 幂等前滚)。**E. 视觉链路事实修正(F3)**——`chat.image` 的 http(s) URL(含 `/api/v1/files/`)现在由**服务端自己取回并内联为 `data:` URI** 再交 provider,**不再原样转给上游**;同源 upload 产物优先走**本地文件读取**。**类型裁决**:**字节嗅探是唯一裁决权**(声明的 Content-Type / 扩展名只作参考且可被否决),嗅探为文本(HTML / PROXY 错误页 / SVG)或判不定且来源未声明 `image/*` → **明确报错**(`vision.ErrNotImage`),**绝不静默当 PNG**。**对客户端无端点 / 帧 / 字段变更**。**F. 修正既有错误陈述(G1)**——`Docs/api-reference.md` §6.2 帧壳表与 §6.6 曾声称结构化流帧只带 `seq`、没有轮次标识,**是错的**:**v0.36 起**结构化流帧(`message.*` / `thinking.*` / `tool_call.*`)就带 `sessionId` + `turnId`(`broadcastStream` 从 `model.StreamEvent` 取,loop 在回合边界注入),v0.50 起另带 `streamId`;已按实现更正(与本文档 v0.36 条目一致)。**G. 环境变量登记**——`.env.example` 新增 **`OWNER_IDENTITY_INJECTION`**(`owner_only` 默认 / `always` / `off`;**非法值回退 `owner_only` 并记 WARN**),并登记 **`CONTEXT_MAX_TOKENS`**(默认 `32768`;`0` / 负 = 关闭压缩)与 **`GUARDRAIL_KEYWORDS`**(逗号分隔;留空 = 不挂关键词护栏),三键见 §2「安全 / 上下文环境变量(v0.56)」。**无新端点、无帧形状破坏**(唯一新增帧 `chat_duplicate` 为**纯新增**;REST `stream:true` 的响应面属**行为变更**,兼容点已在 B 写明)。**上一版 `v0.55`**:(**阶段 7 落地项登记:路径式安全入口 + 数据目录 `platform` + OneBot v11 四端 + 可观测实时推送**,依据裁决 1.6 / 16.4.7 / 12.4.2(commit `a398dc9`)、1.4 / 12.4.1(commit `9253fef`)、05.4.3 + 16.4.3-WS(commit `0e1f163`),以及 13.4.5 面内三条(commit `74b12d8`)、MCP 官方 SDK(commit `a1083a3`),2026-10-04 落实):**A. 安全入口 `ADMIN_PATH`(裁决 1.6 / 16.4.7,前端路由面)**——① env 新增 **`ADMIN_PATH`**(默认 `admin`):面板**前端**路由前缀 = `/` + 值(`admin` → `/admin`,`xxx/admin` → `/xxx/admin`;**非法值回退 `/admin`**——容忍首尾斜杠与空白,拒绝 `.`/`..` 段与 `?`/`#`)。② HTTP 行为:`/` 与其深链返回**注入了 `` 与 `window.__CYRENE_ADMIN_ENTRY__={"prefix":"..."}` 的 index.html**(`Cache-Control: no-store`);**形如面板入口(路径里出现 `admin` 段)但前缀不符的路径 → `404`**(不回落用户端聊天壳、不泄露正确前缀);`/` 若恰好命中 `dist` 真实文件则仍走文件服务器。③ **API 不藏**:`/api/v1/*`、`/api/v1/admin/*`、`/ws/chat`、webhook 一律原样,**无新端点、无帧变更**。④ **口径**:用户端 HTML **永不携带该前缀串**(后端只在命中前缀时注入)⇒ 用户端既无管理入口链接、也解析不出面板路由(design/07 §0.2;前端路由改**路径式**:`frontend/web/src/admin/router.ts` 只读注入、无硬编兜底、移除 hash 入口)。**B. 数据目录(同 commit,裁决 12.4.2)**——`DATA_DIR` 新增可接受值 **`platform`**(大小写不敏感)→ 平台标准目录;默认 `./data` 与显式路径行为不变;**显式空值行为不变**(仍走原兜底)。**C. OneBot v11 四端(`9253fef`,裁决 1.4 / 12.4.1)**——① 配置面(channels.json 账号 `config` + 适配器 `Config()` 快照)新增 `transport`(`ws`|`http`,默认 `ws`,未知值回落 `ws`)/ `api_base_url` / `report_path`(默认 `/`);快照 camelCase 为 `transport`/`apiBaseURL`/`reportPath`;`remoteURL` 快照值 = `remote_url`、为空时回落 `api_base_url`(语义即「出站远端地址」),**既有键与值不变**。② env 兜底 `CHANNEL_QQ_OBV11_TRANSPORT`(默认 `ws`)/ `CHANNEL_QQ_OBV11_API_BASE_URL` / `CHANNEL_QQ_OBV11_REPORT_PATH`。③ 四端语义:`ws+client` Cyrene 拨号、收发同一 WS;`ws+server` OneBot 反连、收发同一 WS;`http+client` Cyrene→OneBot(`POST {api_base_url}/{action}`,纯出站);`http+server` OneBot→Cyrene(POST 到 `listen_addr`+`report_path`,纯入站,fail-closed 鉴权、10MiB 体上限)。④ 取舍:`mode` 决定**哪一半必需**(server 必需 `listen_addr`、client 必需 `api_base_url`),另一半给齐字段就一并启用 ⇒ 单个适配器即可「收上报 + 回消息」。⑤ 端口例外:`http+server` 用 `listen_addr`,属 `AGENTS.md` §9 已登记的**同一豁免**(design/13 §0 原文即写「WS server / HTTP server」);`listen_addr` 为空则报错、**绝不开端口**。**D. 可观测实时推送(`0e1f163`,裁决 05.4.3 + 16.4.3-WS)——新增一节 §3.12**:三个**下行新帧** `observability_log`(`{entries:[{time,level,source,msg}…]}`,最新在前,上限 200 条)/ `observability_trace`(`{spans:[…]}`,上限 100 条)/ `observability_metrics`(`AdminMetrics` 本体、扁平),均**纯新增**(既有帧名/形状零改动)、帧壳**不带** `sessionId`/`turnId`/`seq`;三者**不在** `frameReachesAll` 白名单 ⇒ fail-closed **只发 owner+admin**(viewer 继续走 REST 只读档);订阅 = owner/admin 凭据**读取** `GET /admin/observability/{logs,metrics,trace}` 任一个(该次读取照常返回完整引导数据),该凭据 WS 连接全断即退订并停循环(**不是轮询、无常驻 goroutine**);**REST 端点完全不变**,`?level=` 分级过滤改由**客户端本地**做(帧广播给所有 `owner`/`admin` 连接,订阅只决定循环跑不跑)。**E. 其余(各一句)**:① **13.4.5 面内三条**(`74b12d8`)——续接窗口 `Recent` 在同毫秒并列时改为取**最新** n 条且按插入序(**无 API 变化**,但客户端可观察到的「握手回放 / 续接上下文」顺序更正确);上行 `chat` 不再经**可丢弃**的 bus(**帧序列不变**);被**取消**回合的 checkpoint 不再顶掉下一条消息,新增内部常量 `loop.StopReasonCanceled`(`checkpoints.state.stop_reason` 可能出现该值,**不经 REST/WS 暴露**)。② **MCP**(`a1083a3`)——协议层换成**官方 Model Context Protocol Go SDK v1.8.0**,**REST/WS/env/数据目录零变更**(`MCP_SERVERS_JSON`、`MCP_*`、`transport` 枚举 `stdio|http|streamable|sse` 全不变)。**未做(诚实边界)**:DashScope 形状的 `auto` 推断、Qwen 的 `/think` 前导消息软开关、`get_group_list` 枚举、上游显式退订帧(当前退订只发生在订阅凭据的连接全断时)、审计 tab 仍 5s REST 轮询(未做 `observability_audit` 帧)。**上一版 `v0.54`**:(**渠道配置写路径改 patch 语义(修 P0 级凭据静默丢失) + 阶段 6 前端批的纯前端行为登记**,依据 design/04 §3.1、design/22 §2 方案 b(与 `personaId` 同构的裁决延续,commit `4ae7148`)与裁决 16.4.1 / 16.4.2 / 16.4.3 / 16.4.4 / 16.4.5 / 16.4.6(commit `f454273` / `b480b04` / `c1a98d7` / `8d70f6d`)):① **`PUT /api/v1/admin/channels/{platform}/config` 的 `config` 由「整条替换」改为「按 id 打补丁」**(`runtimeconfig.PatchChannel`,**与 `personaId` 同构的三态**)——**键省略(该键不在 `config` 里,或 `config` 整体缺席 / 为 `null`)= 保留该账号已存值**、**给出非空值 = 覆盖该键**、**显式 `null` 或 `""` = 清除该键**(`0` / `false` 是真实取值,**不算空**);顶层便捷字段(`token` / `baseURL` / …)是普通 `string`,**只在非空时**并入补丁 ⇒ **要清除 `bot_token` 必须走 `config` 的显式 `null` / `""`**;`allowGroups` / `denyGroups` / `allowUsers` / `denyUsers` 与三个限流键**仍是整值替换**(省略 = 清空)。② **破坏性提示**:按旧文实现、依赖「省略某 `config` 键 = 删掉它」的客户端,必须改为**显式传 `null` / `""`** 才能清除该键。③ **修掉的数据安全问题**:面板只发送用户本次碰过的平台字段,后端却整条替换 ⇒ 保存**微信(iLink)**账号卡会抹掉 `bot_token` / `bot_id` / `owner_id` / `get_updates_buf`(**iLink token 是长期凭据**)、**Telegram** 只填 `webhook_url` 而不重填 token 会让 `cleanAccounts` **丢掉整个账号**。④ **未改**:token 要求型平台**显式**清 token 仍按 `cleanAccounts` 丢账号;「本次一个 `config` 键都没给出」在**非 `weixin-ilink`** 平台仍回 `400`。⑤ **无新端点 / 新字段 / 响应形状变更**。**同版另含阶段 6 前端批(纯前端行为,无 API 变化)**:① **16.4.1 静默/活动态与预约切换**——唯一入口 `frontend/web/src/components/modeSwitch.ts`:她活动时用户切换只登记「已预约」、静默时落地;② **16.4.5 会话级消息缓存**——帧按自己的 `sessionId` 落进该会话的消息桶(切会话不清空),**只有单槽忙态**仍按激活会话判定;`graph_update` 按 `data.sessionId` 入**图桶**,管理端 Flow 页取激活会话、Backthink 页取 `sess-backthink`(裁决 16.4.2);③ **16.4.3 / 16.4.6 虚拟滚动与专属页只读卡**——长列表超阈值窗口化、专属页只读展示该平台完整字段;④ **16.4.4 双档 UI 模式**——性能档 = 现状(默认)、华丽档落 `:root.ui-mode-fancy`,持久化键 `cyrene.ui-mode`(纯前端,对应 `design/07 §0.1`,不改后端契约)。**上一版 `v0.53`**:(**阶段 5 落地:模型目录端点 + per-persona 路由 / 本地三级 / capacity L3 / 视觉·OCR 独立模型 / `image-crop` 工具 / 按 provider 形状下发推理开关**,依据 design/04 §3/§3.1/§3.2(裁决 03.4.1 / 03.4.2 / 03.4.3 / 08.4.1)、design/06 §0/§3(裁决 14.4.1 / 14.4.5)、design/14 §2.2/§3/§4(裁决 14.4.2 / 14.4.3 / 14.4.4),2026-10-04 落实):① **新增管理只读端点 `GET /api/v1/admin/llm/catalog`**(**owner|admin** 读,`?refresh=1` 强制在线刷新)→ `200 {data:{source:"cache"|"models.dev", fetchedAt?, stale?, warning?, providers:[{id,name,api?,doc?,env?,npm?,models:[{id,name,attachment,reasoning,toolCall,contextLimit?,outputLimit?}]}]}}`;**既无本地快照又抓不到** → `503 {error:{code:"catalog_unavailable", message}}`;客户端带 `Accept-Encoding: gzip` 时响应带 `Content-Encoding: gzip`(**信封形状不变**)。**副作用只有一处**:落盘快照 `/llm_catalog.json`(**不含任何凭据**、`data/` 已 gitignore、TTL 24h),**不改 `models.json`**;**抓取只发生在本端点内,不进运行时热路径**(模型请求链路从不读外网目录)。详见 §2「模型配置契约(v0.53)」。② **`models.json` 组级新增键**(载体是组,**可热更新**,未声明时该键不出现):`reasoning: {kind: "auto"|"deepseek"|"openai"|"vllm"|"dashscope"}` 决定该组用哪种**提供商开关形状**下发推理指令(**非法值被 `Group.Validate` 直接拒绝**;anthropic 组上声明 openai 系形状同样拒绝)、`personaModels: {personaId: modelId}` 与 `personaProviders: {personaId: "openai"|"anthropic"}` 为**角色级模型/提供商路由**(**非法 provider 值同样拒绝**;未绑定/未知角色回落该组默认)。③ **语义修正(重要)**:**`multimodal` = 主模型自身能否收图**;`LLM_VISION_MODEL` / `LLM_OCR_MODEL` **只配视觉/OCR 工具用的模型**——两者互不代表(见 §1 行 3)。④ **`.env` 新增 9 键**(见 §2「模型配置契约(v0.53)」表与 `.env.example`):`LLM_REASONING_KIND`(默认空 = `auto`)、`LLM_OCR_MODEL`、`LLM_LOCAL_ENABLED`(默认 `false`)/`LLM_LOCAL_PROVIDER`/`LLM_LOCAL_BASE_URL`/`LLM_LOCAL_API_KEY`/`LLM_LOCAL_MODEL`、`CAPACITY_L3_ENABLED`(默认 `false`)/`CAPACITY_L3_CACHE_TTL`(默认 `5m`)。⑤ **内置工具新增 `image-crop`**(按区域裁切图片:`x`/`y`/`width`/`height` → PNG data URI + 裁切尺寸;供大图分区精读;**策略默认 owner-only**)。⑥ **推理开关按形状下发(旧陈述修正)**:v0.41 的「Qwen 一类只 WARN、请求一字不改」**已作废**——形状由 `reasoning.kind` 声明后**真下发**(→ 字段名映射表见 changelog v0.53 条),**只有形状未知/有歧义时才 WARN 且一个字段都不发**;`reasoning_effort` 的非法值由「降级到最近一档」改为**丢弃并记 WARN(不降档)**,`effort:none` 一律翻成**显式关思考**。**未做**:DashScope 形状的 `auto` 推断(只能在显式声明 `reasoning.kind=dashscope` 时下发,绝不从模型名/BaseURL 猜)、Qwen 的 `/think` 前导消息软开关(与 design 口径冲突,未实现)。**上一版 `v0.52`**:(**工具策略落盘 + 主代理代审小字 + 流程图节点参数摘要**,依据 design/11 §3/§4/§8(裁决 1.3 / 10.4.2 / 13.4.3)与 design/19 §1/§1.1(裁决 11.4.2 / 11.4.3 / 13.4.1),2026-10-04 落实):① **工具策略(`allowInGroup`/`allowInStranger`)落盘 SQLite `tool_policies`(迁移 `0012_tool_policy`),重启保留**——`registry.SetPolicy` **先落库后改内存**,落盘失败则内存保持旧值并返回错误(见 §2 行 5);② 新增下行帧 **`approval_notice`**(§3.11):敏感操作经主代理代审后推一行小字(`explain` 由**后端产出**、客户端原样渲染),审计口径 `who`=角色名 / `what`=`approval.<工具名>` / `result`=`granted|denied`,**仅 `owner` + `admin`**(fail-closed 默认);③ `graph_update` 节点 `props` 新增四类**纯增量可选**键(tool `args_keys`/`args_summary`、guardrail `action`/`allow`/`elapsed_ms`、memory `action`/`scope`/`hits`/`bytes`/`count`/`failed`、orchestration `subagent`/`persona`),`memory`/`guardrail`/`orchestration` 旁路节点 span **首次真进图**,值在发布前已脱敏——**帧名、事件名、顶层字段集一字未动**(见 §3.5)。**上一版 `v0.51`**:(**后台子代理结果登 WS 帧 + 回填父会话**,依据 design/05 §9「子代理完成 → 结果回填主会话并提示用户」,裁决 02.4.3,2026-10-04 落实):新增下行帧 **`subagent_result`**(`{type, sessionId, data:{sessionId, status:"done", summary, agents:[{id, goal, role?, mode?, status, summary?, text?, steps?}]}}`,见 §3.10)——`subagent_spawn` 的批次转后台后结算时,编排层发布在 bus 主题 `subagent.result` 上的合并结果,此前**没有任何消费者**(中继既非 `ws.*` 也不认该 4 段以外的 subject)⇒ 结果上不了前台、只剩进度帧;现在该 subject 被登记为中继并成形为这一帧,**帧壳 `sessionId` = 派生该批次的父会话**,结果因此落回**父会话的实时流**。**角色门**:新帧属内容帧 → **只发 `owner` + `admin`**(fail-closed 默认,未扩 7 个状态机信号白名单)。**向后兼容**:纯新增帧,老客户端按未知帧忽略即可,既有帧/端点/鉴权一律不变。**诚实边界**:结果**不落库**(父会话转录里没有新消息、重连回放不补发)、**未**经 design/15 会话消息编排器续接、**前端尚无该帧 handler**(今天的效果是「帧到达 owner/admin 连接」)。**同版另含管理面(D 组,2026-10-04 落实)**:① **`X-API-Key` 真正接线**(裁决 15.4.2):管理/用户侧已认证端点同时接受该头,命中签发表即通过且**一律 `owner` 级**(裁决 15.4.3 取消 role 分级),带该头时只走 key 分支、未知/已吊销/空值回 `401` 且不回落 Bearer(fail-closed),WS 握手仍只认 Bearer;② **新增主人白名单档案读写** `GET`/`PUT /api/v1/admin/owner-profile`(裁决 10.4.5,**owner-only**,写带审计 `owner.profile.update`)——面板的「保存」从此是**真保存**(落盘 `configs/users/owner.yaml` 0600 原子写 + 热替换活档案,渠道 actor 判定立即生效),不再是只改本地 state 的假成功;③ **persona `identity.systemPrompt` 改必填**(裁决 08.4.2):缺失/空白回 `400 bad_request`,**不再派生默认说明串**,写出的 YAML 必能被载入期同一规则重新载入。见下方「管理面板写操作契约」。**上一版 `v0.50`**:(**事件批:流式帧的结构化补齐**,依据 design/04 §1.1,裁决 01.4.1 / 01.4.2 / 01.4.3 / 03.4.4,2026-10-04 落实):① **`tool_execution` 拆三事件**——旧单帧 `tool_execution.update {id,index,delta}`(phase 与工具名拼在 `delta` 字符串里)被 **`tool_execution.start`**(进入执行,`phase:"running"`)/ **`tool_execution.update`**(运行中进度/局部输出,可多帧)/ **`tool_execution.end`**(完成或失败,`phase:"done"|"error"`)替换,载荷改为结构化 `{tool, phase, id?}`(`tool` = 工具名;`phase` = 细粒度执行阶段,与 §3.5 流程图节点 status 同一套 `running/done/error` 枚举;`id` = provider 工具调用 id,可与 `tool_call.*` 帧关联)——**phase 不再塞进 `delta`**;loop 现已真实发出 start/end(update 的 wire 映射已实现并验证,但工具分发器尚无中途进度上报源,**暂无生产者**,见 §3.1)。② **`streamId`(模型流帧 `data` 内新增可选字段)**:同一次模型流(一次 ChatStream 调用)的多帧共享同一 `streamId`,多步工具轮的第二次模型调用取新 id;与帧壳的 `turnId`(整个回合恒定)分工——`turnId` 标记「这轮回答」,`streamId` 标记「这轮里的哪一条流式消息」。③ **`turn.start` / `turn.end`(新帧)**:loop 在回合边界发出、经 ws_bridge 下行;**无 `data` 载荷**(帧壳的 `sessionId`/`turnId`/`seq` 即边界信号;回合终态信息仍由既有 `stream_end` 携带);`turn.end` 覆盖回合的所有出口(最终答案 / 工具循环安全网终止 / 预算耗尽 / 步骤出错),被静音的内部流程(backthink / 子代理)不发出。④ **向后兼容**:`turn.start/end` 与 `streamId` 为纯新增,老客户端可安全忽略;**破坏性**在旧 `tool_execution.update` 单帧——loop 不再产生它,按旧形状(解析 `delta` 里的 `phase:工具名` 拼串)消费的客户端必须迁移到三事件;现有 Web 前端未消费这些帧,行为不变。⑤ 本批新帧全部落在 v0.48 下行帧门的「仅 `owner` + `admin`」档(fail-closed 默认,无需扩白名单)。⑥ **`graph_update` 会话范围口径修正**(裁决 01.4.5,随实现事实补记):`web` / `web:` / `sess-backthink` 三范围(见 §3.5)。见 §3.1 与 v0.50 变更记录。**上一版 `v0.49`**:(**WS 握手回放与上行帧的角色门(design/11 §7.2)**:补上 §7.1 下行门之外的**同一类缺口的另两处**,对 `viewer` **继续收紧**,`owner` / `admin` 行为不变,**无端点、无字段形状变更**——① **握手 `history` 回放改为仅 `owner` + `admin`**:此前任意角色一连上 `/ws/chat` 就会收到主会话最近 50 条消息正文(`thinking` 已剥离,但**正文本身**就是主人的对话),`viewer` 从此**不收**;② **上行 JSON 帧按角色授权(fail-closed)**:非 `owner`/`admin` 连接发来的**任何**上行 JSON 帧一律拒绝,**定向**(只回发送者)回 `{type:"error",data:{code:"forbidden"}}`,**不广播、不驱动模型、不落库、不返回任何内容**——被拒帧含 `chat`(此前 `viewer` 可**真的驱动模型并落库**)、`interrupt`、`subagent_ask` / `task_output_query`(其应答是**定向**帧、能绕过 §7.1 下行门把子代理上下文/任务输出直接回给 `viewer`)、`voice_input`、`mode_switch`,且**未登记的新上行帧默认拒**;**协议级帧(ping/pong、close)不受影响**。③ **合口径**:`viewer` = **可连接、可读 12 条只读监控 REST(v0.40)、可收 7 个状态机信号帧(v0.48)**;**除此以外 WS 上拿不到任何内容、也发不出任何指令**。见 §3 开头的「上行帧与握手回放的角色门」与 v0.49 变更记录。**上一版 `v0.48`**:(**WS 下行帧的角色门(design/11 §7.1)**:hub 的下行现在是**按帧类型分档**的——只有 7 个**状态机信号**(`session_busy` / `pending_ack` / `pending_drain` / `session_idle` / `session_interrupted` / `pending_full` / `session_read`,**均不含任何内容**)继续发给**所有已认证连接**(含 `viewer`),**其余全部下行帧**(流式内容 `message.*` / `thinking.*` / `tool_call.*` / `tool_execution.update` / `stream_chunk` / `stream_end` / `thinking`;持久化 `message_persisted` / `message_edited` / `message_deleted` / `channel_persisted`;系统内部 `graph_update` / `subagent_event` / `task_output` / `task_done` / `tool_used` / `capacity_awaken` / `mode_switch`;私有状态 `mood` / `device_update` / `reminder`)**改为仅发 `owner` + `admin`**)。**对 `viewer` 是收紧**:此前它连上 `/ws/chat` 就能收到角色回复正文与推理内容,现在收不到;**`owner` / `admin` 行为不变**;**无端点、无字段形状变更**——`viewer` 的 12 条只读监控 REST 端点(`viewerAuth`,v0.40)与 `/ws/chat` 的连接鉴权都**照旧**;**新帧类型默认只发 owner/admin**(fail-closed)。见 §3 开头的「下行帧的角色门」与 v0.48 变更记录。**上一版 `v0.47`**:(**`graph_update` 子代理泳道根节点新增可选 `props.persona`**:派发子代理时若指定了目标角色,该分支根节点的 `props` 就多一个 `persona` 键,标注它**正在扮演的角色**——用途是让流程图能显示「这个子代理是以谁的身份在替班」,而不只是子代理 id;**纯增量**:不带角色派发时该键不出现,老客户端忽略未知键即可,见 §3.5 与 v0.47 变更记录)。**上一版 `v0.46`**:(**`PATCH /api/v1/sessions/{id}` 的 `personaId` 由「可重新绑定该会话的角色」改为「出现即 `400`」**:**会话角色在创建时确定、之后不可变**,要换角色请新开会话(design/22 §8-1)——**明确拒绝**而非静默忽略/200 空操作;**键出现**(任何取值:`"yuki"` / `""` / `null` / 未知 id / 空白串)一律 `400 bad_request` 且消息点名 `personaId`,该检查**先于**会话存在性检查(未知会话 + `personaId` 也回 `400` 而不是 `404`)。**破坏性**:按 v0.42–v0.45 旧文实现、依赖「PATCH 换角色 / 显式 `""` 解绑」的客户端必须改为**新建会话**选角色;`title`/`pinned` 行为与 `POST /sessions {personaId}`(创建时绑定)**完全不变**,不再有 `501/200 {personaId}` 的 PATCH 路径)。上一版 `v0.45`:`PUT /admin/channels/{platform}/config` 的 `personaId` 由「省略/`""` 都 = 解绑」修订为三态(**省略(或 `null`)= 保留已存绑定**、**显式 `""` = 解绑**、**非空 id = 绑定**,未知角色仍 `400`)。 +> **上一版 `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)**刻意相反**(文档摄取在服务端完成,不需要被 ``/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>.`;图片侧是绝对 URL);`mime` 是**嗅探所得基类型**(故 `data.json` 返回 `text/plain`,不是 `application/json`);落盘名 = `sha256(bytes)[:32] + ext`(**内容寻址**,原始文件名永不参与路径 ⇒ 同字节同扩展名天然同名去重)。⑤ **4 个错误码**:`unsupported_media_type`(415) / `document_too_large`(413) / `document_not_found`(404,含名字形态非法与路径穿越) / `too_many_documents`(400)。⑥ **`chat` 请求新增可选 `"documents":[{"name":"<32hex>."}]`**(REST 同步 / REST `stream:true` / WS `chat` **三处同形**):**≤5 条**(超出 `too_many_documents` 400,且**先于任何文件系统访问**);**只收服务端存储名、绝不收 URL**(不重开 F3 那种「服务端代取 URL」的 SSRF 面);客户端多带的 `mime/url/size` **一律被服务端覆盖**(MIME/大小取自落盘字节);名非法/穿越/不存在 → `document_not_found`(404);**任一条不合规都让整个请求被拒**,绝不「丢掉这条附件但照样起回合」(WS 侧经 `error` 帧带 `data.code` 下发并中止本回合,REST 侧回对应状态码)。⑦ **响应 / 历史 DTO 由 `document`(单对象)改为 `documents`(数组,≤5)**——元素形状 `{"name","mime","size","chars","truncated","status"}`,`status ∈ extracted|listed|unreadable`;**0 个附件时该键省略**;同一 DTO 供 `GET /sessions/{id}/messages`、`message_persisted`、`message_edited`、WS 握手回放 **四处同时生效**(同一个 `historyMessage` 结构)。⚠️ **原单数形状从未发布过**(不在任何已发布版本号里)⇒ **无兼容期**,直接换数组(design/24 §6.1 那句 `"document":{…}` 已随之作废)。⚠️ **`chars`/`truncated` 改为「本回合注入侧」语义**:`chars` = **本回合注入**模型上下文的字符数、`truncated` = **注入**是否被截断(**不是**「原文件是否被截断」);**全文长度**只有 `document-read` 的 `totalChars` 能看到。⑧ **内置工具 `document-read`**(模型侧工具,客户端一般只需知道它存在):`{name, offset?, limit?}` → `{name,status,offset,limit,totalChars,truncated,nextOffset,text,extractTruncated?}`;`limit` 默认 **8000** 字符、上限 **64K**;`offset` 单位是**字符(rune)**、0 基、起点 = sidecar 正文(含 CSV/TSV 摘要行)第 0 个字符;`offset<0` 或 `>totalChars` → 错误(`==totalChars` 合法,读到末尾空片段);`extractTruncated=true` **专指**「撞上 8 MiB sidecar 安全上限」(≠ 本回合注入被截断);策略默认 **owner-only**(群聊/陌生人天然读不到主人的文件)。**B. 配置面(纯增量 + 一处语义变更)**——① **`models.json` 组级新增 `contextWindow`**(正整数 = 该组模型的上下文窗口;`0`/省略 = 未声明 ⇒ 后端按 **131072** 保守估算并记**恰好一条** WARN;**负数 = 组校验失败**,写端点回 `400`)。 ② **`.env` 新增/变更 4 键**:`LLM_CONTEXT_WINDOW`(**per-group 播种**,等价于 `models.json` 的 `contextWindow`)、`CONTEXT_BUDGET_RATIO`(默认 **2/3**,接受 `2/3` 或 `0.6667`,非法值记 WARN 后回落)、`DOCUMENT_MAX_INJECT_CHARS`(单份附件注入上下文的字符上限;未设 = 按推导出的预算自动算)、**`CONTEXT_MAX_TOKENS` 语义变更(关键)**:**未设置 ⇒ 按窗口推导**(`budget = min(窗口 × 比例, 窗口 − 输出保留)`,输出保留 = `max(32768, 该组 maxTokens)` 且**封顶 `窗口/2`**;**1M 窗口 ⇒ 699050 tokens**);`>0` = 显式覆盖(原样使用);`0`/负 = **关闭压缩**。⇒ **`.env` 里若仍写着 `CONTEXT_MAX_TOKENS=32768`,它就是显式覆盖、推导不生效**(这是设计语义,**不是 bug**;本次已把 `.env.example` 里该行改成注释形式)。③ **`.env` → 面板配置的 materialize 语义(欠账,本次补登记)**:进程**每次启动**跑一次;**面板配置非空 ⇒ 组集合完全由面板决定**(env 不新增、不复活被面板删掉的组);**键级只在面板缺该键时用 env 补**,面板已有的键(含**显式空值 / 显式 `false` / 显式 `null`**)**绝不覆盖**;面板配置缺失/为空时才由 env 播种出合成组;**「面板改一次、重启被打回」是硬约束**(有专门用例钉住);每次启动记「补了哪些 / 跳了哪些」,**凭据绝不进日志**;范围目前**仅 `models.json` 一族**(`channels.json` / `plugins.json` 未纳入)。④ **面板读写面**:`GET /api/v1/admin/llm` 的组 DTO **含 `contextWindow`**(`omitempty`,与 `maxTokens` 同形:未声明时该键省略),`POST /api/v1/admin/llm` **接受可选 `contextWindow`**——**缺失 = 保留已存窗口**、**显式 `0` 与缺失同义**(都 = 未声明)、**负值 `400 bad_request` 且不落盘**;面板 LLM 页据此显示/编辑该键。**C. 语音(有行为变更,见 §3.15)**——语音轮次**现在走与文本同一条路径**:会**落库**(用户消息 = 转写文本,助手消息 = 回复)、会发 `message_persisted` 与流式帧、**跨端同步**、忙时按编排器既有规则**排队**(`pending_ack`/`pending_drain`/`session_busy` 语义零变化,只是多了一个消费者);`deviceId` 照旧落库、`platform` 走存储默认 `web`;`voice_input` 上行增可选 `clientMsgId`(与 `chat` 帧同形 ⇒ 语音也有幂等;重复到达回既有 `chat_duplicate`,**不产生新回合、不发音频**);`voice_output`(定向)载荷增 `sessionId`(恒有)与 `deviceId`(非空才有);**空正文**与 **TTS 失败/降级**都走既有 `provider` 降级信号(客户端据此提示「没生成声音」;**不再**把 `stopReason` 当语音内容);**时点**:`voice_output` 在**该回合结算时**才到(可能晚于若干状态帧,甚至由续跑回合或被 steer 注入的那一回合产生);定向目标是**发起 `voice_input` 的那条连接本身**(服务端**不按 deviceId 路由**);连接断开后**不补投**,客户端重连后带上同一 `clientMsgId` 可安全重试。**D. `G5`(纯增量:广播帧 +2 个可选键;新增一条定向错误帧,见 §3.2)**——① **`pending_full` 广播载荷**增 `clientMsgId?` / `messageId?`:前者 = **被拒那条消息**的幂等键(未携带则**整个键缺席**);后者**仅当该消息此前已落库/已认领时**才回填——**服务端绝不在此处新铸 id**(队列满的拒绝发生在幂等键认领之前,代铸会让客户端重试被判成「重复到达」而永远进不来)。② **新增定向帧(此前无文档登记)**:`{type:"error", data:{code:"chat_busy", message, sessionId, clientMsgId?, messageId?}}`,**只发发送者那条连接**、不广播;`code` 与 REST `409 chat_busy` **同码**(客户端一个 code 覆盖两条入口)。**E. `E3`:`thinking` 可达性口径(不改行为,只是把话说清)**——消息体的 **`thinking` 字段**在**实时帧**(`message_persisted` / `channel_persisted` / `message_edited`)与**握手回放**中**一律为空**(隐私与带宽的**有意**默认;实时思维链仍由 §3.1 的 `thinking.*` 流帧按 owner/admin 档下发);**历史消息的思维链**只有 `GET /sessions/{id}/messages`(owner|admin)返回。**上一版 `v0.58`**:(**新增 `.env` 键 `LLM_MULTIMODAL`(`bool`,默认 `false`):让 env-only 部署能声明「主模型自身能否收图」**,依据裁决 14.4.1 / design/06 §0(commit `4adfc9a`),2026-10-04 落实):**A. 新键语义(见 §2「模型配置契约」表)**——**`LLM_MULTIMODAL` 是 `models.json` 组 `multimodal` 的 env 等价物**,**只作用于 `.env` 合成组**(**无有效 `models.json` 时才生效**;有组文件时以该组的 `multimodal` 为准,与 `LLM_REASONING_KIND` 同口径):`true` = 图片**直进主模型上下文**(省掉工具那一轮模型调用、也不做有损文本摘要),**留空 / `false`(默认)= 把图交给 vision/OCR 工具换成文本描述**;**绝不凭 base URL / 模型名猜测**端点是否收图(声称端点不具备的能力,比把图交给工具更糟——工具至少回一段文本,design/06 §0 首条)。**B. 与 `LLM_VISION_MODEL` / `LLM_OCR_MODEL` 的关系**——后两者配的是**视觉/OCR 工具调用的模型**,**不代表主模型能收图**,与本键互不代表(见 §1 行 3)。**C. 兼容性(默认值即旧行为)**——默认 `false` ⇒ **老部署行为逐字不变**(引入本键之前,env-only 部署没有任何键能声明该能力,图片恒走 vision 工具);**非 bool 值(如 `maybe`)在配置解码阶段直接报错、启动失败**(fail-fast,**不静默回退成 `false`**——静默的「配了当没配」正是本键要消灭的)。**`LLM_MULTIMODAL=true` 后图片走主模型直收,属部署者显式选择**(形状由部署者声明,后端不替它猜)。**D. 实现落点**——`internal/config/config.go`(`LLMConfig.Multimodal`,默认 `false`)+ `internal/config/runtimeconfig/models.go`(`GroupFromEnv` 把两 provider 分叉都透传进合成组)+ `internal/config/runtimeconfig/multimodal_env_test.go`(默认 false / 显式 true 抵达合成组 / 进程 env 端到端 / 组文件优先 四组测试)。**无端点、无帧、无字段形状变更**(纯新增 env 键)。**上一版 `v0.57`**:(**13.4.5 目标态 steer 落地登记:新下行帧 `steer_ack`**,依据 design/15 §2.0/§2.1/§2.4/§3.x/§5(裁决 13.4.5 目标态,commit `3d8822c`),2026-10-04 落实:**A. 新下行帧 `steer_ack`(见 §3.14)**——`{type:"steer_ack", sessionId, data:{messageId, atStep}}`:忙时会话里新到的用户消息**被在飞回合吃进同一回合**时下发(`messageId` = 那条用户消息的**权威 id**,`atStep` = 吃进它的**模型步序号,1 基**);语义=「**该消息已被本回合吃进(已从 pending 移除)、尚未被正面回答**」⇒ 客户端应把对应气泡渲染为「已读、未答」的**灰态**,直到该回合的 `message.*` / `turn.end` 到达(**不需要第二个确认帧**)。**角色门**:它**携带具体消息身份**(客户端能定位到某一条气泡,而不只是会话忙闲),因此**不登记**进 `frameReachesAll` 的 7 个状态机信号白名单,落 fail-closed 默认档——**只发 `owner` + `admin`**(`viewer` 收不到);经编排器状态通道**由 hub 直接广播、不经 bus**,帧壳带 `sessionId`、**不带** `turnId`/`seq`。**触发时机**:只在**回合内边界**(loop 每一步开头:上一次模型步与它那一批工具调用**都已结束**、下一次模型调用**尚未开始**,此刻没有工具在飞)产生,且**只有该边界真的匹配到 pending 里、且已落库的消息时才发**——**不是每个 step 都发**(没可注入消息的边界一帧都不发,这是常态)。**B. 转向的三条不变量(design/15 §2.0)**——**I1(同会话在途回合数恒为 1)**:转向只注入、不取消,**绝不新开第二个回合**(loop 侧结构性地没有任何「起新回合」的分支,注入只是把消息并进**当前回合**下一次模型调用的视图);**I2(消息恰好被处理一次)**:注入=已消费——被注入的消息立刻从该会话 pending **移除**(按 pending 序号认领),**绝不被第二个回合再答一遍**,去重键=后端 `message.id`,且候选必须**同时**满足「在该会话转录里(最近 20 条窗口内)」与「在本编排器的 pending 里」;**I3(转向不取消已发起的工具)**:注入点只在回合内边界,此刻没有任何工具在飞,正在执行的工具**永远跑完**。**C. 注入的范围(口径)**:注入**只影响本轮视图**(每一步边界重新并入),**不写回运行窗口** ⇒ 既不进该回合的 **checkpoint 快照**、也不进**后台记忆提取的输入面**;被注入的消息**本身早在到达时已落库**(pending 路径即时持久化),注入**不新增、也不改写任何行**。**D. 失败一律让路**:未接转录存储 / 读转录失败 / 该消息尚未落库 / 本回合世代已被打断或被更新的回合取代(认领 `pending` 失败)时,**不发 `steer_ack` 也不注入**——该消息**留在 pending**,照旧作为自己的回合被回答,**绝不静默消失**。**E.** 另含一处**链路图纯增量键**(注入时该轮图的 input 节点带 `props.steered:true`,图仍只有一条主干——不是被画成第二轮),以及**纯新增帧**的兼容性口径(老客户端按未知帧忽略即可)。**上一版 `v0.56`**:(**协同项 A2 / G1 / G2 / G3 / G4 / F3 落地登记:`interrupt` 支持 `scope` + REST `stream:true` 不再静默失败 + `chat.image` 上限 + `clientMsgId` 幂等 + 视觉链路 URL 取回与类型裁决 + 既有错误陈述更正**,依据 `Cyrene-For-Android/devdocs/02-前后端协同事项.md` 的 A2 / G1 / G2 / G3 / G4 / F3(commit `7512d59` 等,2026-10-04 落实):**A. 上行 `interrupt` 新增可选 `scope`(A2)**——`{sessionId}` → `{sessionId, scope?}`,取值域 `{"", "all", "current"}`(**省略 / 空串 / `"all"` 三者同义**):`all` = 取消当前任务 + 清空待处理队列(**逐字不变的现状语义**);**`"current"` = 只取消当前任务、保留待处理队列**(队列消息按编排器正常续跑规则继续执行、带上会话尾部上下文);**其余取值 → 只回发送者** `{type:"error",data:{code:"bad_request"}}`(**定向**:不广播、不打断任何会话,绝不静默按默认执行);命中空闲会话为 no-op。下行 `session_interrupted` 帧形状**零变化**(`{sessionId, mode, dropped}`),但 `dropped` 恒等于**真的被丢弃**的待处理条数——`scope:"current"` 时为 `0`,且 `mode` 是该会话当时的**忙碌通道**(`thinking|executing|subagent`,**不是 `idle`**);`scope:"all"` 时 `mode` 为 `idle`、`dropped` 为清空的 pending 条数。**B. REST `stream:true` 不再静默失败(G2,行为变更)**——该分支改为与 WS `chat` 帧**同一条直达编排器的路径**(design/15 §2.1「会话消息不得被 drop」),**响应面**:受理成功后才回 `200 {data:{stream:true}}` / 正忙已续接排队 `202 {data:{stream:true,queued:true,queueDepth:n}}` / 队列满 `409 {error:{code:"chat_busy"}}` / 幂等键重复 `200 {data:{duplicate:true,messageId,sessionId}}`;**未接 loop → `500 {error:{code:"chat_unavailable"}}`**(旧文写 `200 {stream:true}`,现与同步路径同口径);仅**未接编排器**才回退总线派发,其失败回 `503 {error:{code:"chat_dispatch_failed"}}`(此前 `Publish` 的错误被吞掉,且传的是**请求作用域** ctx)。**`202`/`409` 从此真的会出现**(修复前因提前 `return 200` 永不出现)。派发用的回合上下文与请求作用域脱钩(`context.WithoutCancel` + 30 分钟上限),回合不会在客户端刚收到 200 时被连带取消。**向后兼容**:`stream:false` 与既有同步响应形状一字未动;依赖「任何情况都拿到 200」的客户端必须按上表分支,别再假设 `stream:true` 恒 `200`。**C. `chat.image` 上限(G3)**——`POST /api/v1/chat` 的 `image` 新增硬上限:**解码后 5 MiB**(与 `POST /api/v1/upload` 同口径;base64 字符串上限 = `ceil(n/3)*4 + 64` 字符,覆盖 data: URI 前缀),超限 → `400 {error:{code:"image_too_large", message}}`(文案指路 `POST /api/v1/upload`),在**任何解码 / 落库 / 入模型之前**拦下(超大 base64 因此不进历史、也不会被重连回放重传)。**事实(勿误读为「WS 也有这条 400」)**:WS `chat` 帧**今天已被传输层关死**——WS 读上限 `wsReadLimit = 4096` 字节/帧,超限直接断连,**到不了**这条判定 ⇒ **WS 上发真实图片本就必须「上传 + URL」**。**D. `clientMsgId` 幂等(G4)**——上行**新增可选** `clientMsgId`(REST `chat` 体与 WS `chat` 帧都支持;**省略 = 老行为逐字不变,老客户端零影响**):同一会话内同一个非空值只承认第一条。**重复到达**:REST → `200 {data:{duplicate:true,messageId,sessionId}}`;WS → 新下行帧 **`chat_duplicate`**(`{type:"chat_duplicate", sessionId, data:{sessionId, clientMsgId, messageId}}`,见 §3.13);重复**不落库、不起回合、不发任何内容 / 流式新帧**。**判重由 SQLite 部分唯一索引原子裁决**(`(session_id, client_msg_id) WHERE client_msg_id != ''`),并发的两次重发只有一个赢家,不存在「先查再插」的竞态窗口。`clientMsgId` 另**回填进消息 DTO** ⇒ `message_persisted` / `channel_persisted` / 握手 `history` 回放与 `GET /sessions/{id}/messages` 等读接口可见(空则省略),客户端据此把本地乐观气泡对齐到服务端真相(替代「靠文本相等」的脆弱匹配)。**迁移**:新增 `0013_chat_client_msg_id`(up:加列 + 部分唯一索引 `idx_chat_messages_client_msg_id`;down:先删索引再删列;全新库由 `history.NewStore` 幂等前滚)。**E. 视觉链路事实修正(F3)**——`chat.image` 的 http(s) URL(含 `/api/v1/files/`)现在由**服务端自己取回并内联为 `data:` URI** 再交 provider,**不再原样转给上游**;同源 upload 产物优先走**本地文件读取**。**类型裁决**:**字节嗅探是唯一裁决权**(声明的 Content-Type / 扩展名只作参考且可被否决),嗅探为文本(HTML / PROXY 错误页 / SVG)或判不定且来源未声明 `image/*` → **明确报错**(`vision.ErrNotImage`),**绝不静默当 PNG**。**对客户端无端点 / 帧 / 字段变更**。**F. 修正既有错误陈述(G1)**——`Docs/api-reference.md` §6.2 帧壳表与 §6.6 曾声称结构化流帧只带 `seq`、没有轮次标识,**是错的**:**v0.36 起**结构化流帧(`message.*` / `thinking.*` / `tool_call.*`)就带 `sessionId` + `turnId`(`broadcastStream` 从 `model.StreamEvent` 取,loop 在回合边界注入),v0.50 起另带 `streamId`;已按实现更正(与本文档 v0.36 条目一致)。**G. 环境变量登记**——`.env.example` 新增 **`OWNER_IDENTITY_INJECTION`**(`owner_only` 默认 / `always` / `off`;**非法值回退 `owner_only` 并记 WARN**),并登记 **`CONTEXT_MAX_TOKENS`**(默认 `32768`;`0` / 负 = 关闭压缩)与 **`GUARDRAIL_KEYWORDS`**(逗号分隔;留空 = 不挂关键词护栏),三键见 §2「安全 / 上下文环境变量(v0.56)」。**无新端点、无帧形状破坏**(唯一新增帧 `chat_duplicate` 为**纯新增**;REST `stream:true` 的响应面属**行为变更**,兼容点已在 B 写明)。**上一版 `v0.55`**:(**阶段 7 落地项登记:路径式安全入口 + 数据目录 `platform` + OneBot v11 四端 + 可观测实时推送**,依据裁决 1.6 / 16.4.7 / 12.4.2(commit `a398dc9`)、1.4 / 12.4.1(commit `9253fef`)、05.4.3 + 16.4.3-WS(commit `0e1f163`),以及 13.4.5 面内三条(commit `74b12d8`)、MCP 官方 SDK(commit `a1083a3`),2026-10-04 落实):**A. 安全入口 `ADMIN_PATH`(裁决 1.6 / 16.4.7,前端路由面)**——① env 新增 **`ADMIN_PATH`**(默认 `admin`):面板**前端**路由前缀 = `/` + 值(`admin` → `/admin`,`xxx/admin` → `/xxx/admin`;**非法值回退 `/admin`**——容忍首尾斜杠与空白,拒绝 `.`/`..` 段与 `?`/`#`)。② HTTP 行为:`/` 与其深链返回**注入了 `` 与 `window.__CYRENE_ADMIN_ENTRY__={"prefix":"..."}` 的 index.html**(`Cache-Control: no-store`);**形如面板入口(路径里出现 `admin` 段)但前缀不符的路径 → `404`**(不回落用户端聊天壳、不泄露正确前缀);`/` 若恰好命中 `dist` 真实文件则仍走文件服务器。③ **API 不藏**:`/api/v1/*`、`/api/v1/admin/*`、`/ws/chat`、webhook 一律原样,**无新端点、无帧变更**。④ **口径**:用户端 HTML **永不携带该前缀串**(后端只在命中前缀时注入)⇒ 用户端既无管理入口链接、也解析不出面板路由(design/07 §0.2;前端路由改**路径式**:`frontend/web/src/admin/router.ts` 只读注入、无硬编兜底、移除 hash 入口)。**B. 数据目录(同 commit,裁决 12.4.2)**——`DATA_DIR` 新增可接受值 **`platform`**(大小写不敏感)→ 平台标准目录;默认 `./data` 与显式路径行为不变;**显式空值行为不变**(仍走原兜底)。**C. OneBot v11 四端(`9253fef`,裁决 1.4 / 12.4.1)**——① 配置面(channels.json 账号 `config` + 适配器 `Config()` 快照)新增 `transport`(`ws`|`http`,默认 `ws`,未知值回落 `ws`)/ `api_base_url` / `report_path`(默认 `/`);快照 camelCase 为 `transport`/`apiBaseURL`/`reportPath`;`remoteURL` 快照值 = `remote_url`、为空时回落 `api_base_url`(语义即「出站远端地址」),**既有键与值不变**。② env 兜底 `CHANNEL_QQ_OBV11_TRANSPORT`(默认 `ws`)/ `CHANNEL_QQ_OBV11_API_BASE_URL` / `CHANNEL_QQ_OBV11_REPORT_PATH`。③ 四端语义:`ws+client` Cyrene 拨号、收发同一 WS;`ws+server` OneBot 反连、收发同一 WS;`http+client` Cyrene→OneBot(`POST {api_base_url}/{action}`,纯出站);`http+server` OneBot→Cyrene(POST 到 `listen_addr`+`report_path`,纯入站,fail-closed 鉴权、10MiB 体上限)。④ 取舍:`mode` 决定**哪一半必需**(server 必需 `listen_addr`、client 必需 `api_base_url`),另一半给齐字段就一并启用 ⇒ 单个适配器即可「收上报 + 回消息」。⑤ 端口例外:`http+server` 用 `listen_addr`,属 `AGENTS.md` §9 已登记的**同一豁免**(design/13 §0 原文即写「WS server / HTTP server」);`listen_addr` 为空则报错、**绝不开端口**。**D. 可观测实时推送(`0e1f163`,裁决 05.4.3 + 16.4.3-WS)——新增一节 §3.12**:三个**下行新帧** `observability_log`(`{entries:[{time,level,source,msg}…]}`,最新在前,上限 200 条)/ `observability_trace`(`{spans:[…]}`,上限 100 条)/ `observability_metrics`(`AdminMetrics` 本体、扁平),均**纯新增**(既有帧名/形状零改动)、帧壳**不带** `sessionId`/`turnId`/`seq`;三者**不在** `frameReachesAll` 白名单 ⇒ fail-closed **只发 owner+admin**(viewer 继续走 REST 只读档);订阅 = owner/admin 凭据**读取** `GET /admin/observability/{logs,metrics,trace}` 任一个(该次读取照常返回完整引导数据),该凭据 WS 连接全断即退订并停循环(**不是轮询、无常驻 goroutine**);**REST 端点完全不变**,`?level=` 分级过滤改由**客户端本地**做(帧广播给所有 `owner`/`admin` 连接,订阅只决定循环跑不跑)。**E. 其余(各一句)**:① **13.4.5 面内三条**(`74b12d8`)——续接窗口 `Recent` 在同毫秒并列时改为取**最新** n 条且按插入序(**无 API 变化**,但客户端可观察到的「握手回放 / 续接上下文」顺序更正确);上行 `chat` 不再经**可丢弃**的 bus(**帧序列不变**);被**取消**回合的 checkpoint 不再顶掉下一条消息,新增内部常量 `loop.StopReasonCanceled`(`checkpoints.state.stop_reason` 可能出现该值,**不经 REST/WS 暴露**)。② **MCP**(`a1083a3`)——协议层换成**官方 Model Context Protocol Go SDK v1.8.0**,**REST/WS/env/数据目录零变更**(`MCP_SERVERS_JSON`、`MCP_*`、`transport` 枚举 `stdio|http|streamable|sse` 全不变)。**未做(诚实边界)**:DashScope 形状的 `auto` 推断、Qwen 的 `/think` 前导消息软开关、`get_group_list` 枚举、上游显式退订帧(当前退订只发生在订阅凭据的连接全断时)、审计 tab 仍 5s REST 轮询(未做 `observability_audit` 帧)。**上一版 `v0.54`**:(**渠道配置写路径改 patch 语义(修 P0 级凭据静默丢失) + 阶段 6 前端批的纯前端行为登记**,依据 design/04 §3.1、design/22 §2 方案 b(与 `personaId` 同构的裁决延续,commit `4ae7148`)与裁决 16.4.1 / 16.4.2 / 16.4.3 / 16.4.4 / 16.4.5 / 16.4.6(commit `f454273` / `b480b04` / `c1a98d7` / `8d70f6d`)):① **`PUT /api/v1/admin/channels/{platform}/config` 的 `config` 由「整条替换」改为「按 id 打补丁」**(`runtimeconfig.PatchChannel`,**与 `personaId` 同构的三态**)——**键省略(该键不在 `config` 里,或 `config` 整体缺席 / 为 `null`)= 保留该账号已存值**、**给出非空值 = 覆盖该键**、**显式 `null` 或 `""` = 清除该键**(`0` / `false` 是真实取值,**不算空**);顶层便捷字段(`token` / `baseURL` / …)是普通 `string`,**只在非空时**并入补丁 ⇒ **要清除 `bot_token` 必须走 `config` 的显式 `null` / `""`**;`allowGroups` / `denyGroups` / `allowUsers` / `denyUsers` 与三个限流键**仍是整值替换**(省略 = 清空)。② **破坏性提示**:按旧文实现、依赖「省略某 `config` 键 = 删掉它」的客户端,必须改为**显式传 `null` / `""`** 才能清除该键。③ **修掉的数据安全问题**:面板只发送用户本次碰过的平台字段,后端却整条替换 ⇒ 保存**微信(iLink)**账号卡会抹掉 `bot_token` / `bot_id` / `owner_id` / `get_updates_buf`(**iLink token 是长期凭据**)、**Telegram** 只填 `webhook_url` 而不重填 token 会让 `cleanAccounts` **丢掉整个账号**。④ **未改**:token 要求型平台**显式**清 token 仍按 `cleanAccounts` 丢账号;「本次一个 `config` 键都没给出」在**非 `weixin-ilink`** 平台仍回 `400`。⑤ **无新端点 / 新字段 / 响应形状变更**。**同版另含阶段 6 前端批(纯前端行为,无 API 变化)**:① **16.4.1 静默/活动态与预约切换**——唯一入口 `frontend/web/src/components/modeSwitch.ts`:她活动时用户切换只登记「已预约」、静默时落地;② **16.4.5 会话级消息缓存**——帧按自己的 `sessionId` 落进该会话的消息桶(切会话不清空),**只有单槽忙态**仍按激活会话判定;`graph_update` 按 `data.sessionId` 入**图桶**,管理端 Flow 页取激活会话、Backthink 页取 `sess-backthink`(裁决 16.4.2);③ **16.4.3 / 16.4.6 虚拟滚动与专属页只读卡**——长列表超阈值窗口化、专属页只读展示该平台完整字段;④ **16.4.4 双档 UI 模式**——性能档 = 现状(默认)、华丽档落 `:root.ui-mode-fancy`,持久化键 `cyrene.ui-mode`(纯前端,对应 `design/07 §0.1`,不改后端契约)。**上一版 `v0.53`**:(**阶段 5 落地:模型目录端点 + per-persona 路由 / 本地三级 / capacity L3 / 视觉·OCR 独立模型 / `image-crop` 工具 / 按 provider 形状下发推理开关**,依据 design/04 §3/§3.1/§3.2(裁决 03.4.1 / 03.4.2 / 03.4.3 / 08.4.1)、design/06 §0/§3(裁决 14.4.1 / 14.4.5)、design/14 §2.2/§3/§4(裁决 14.4.2 / 14.4.3 / 14.4.4),2026-10-04 落实):① **新增管理只读端点 `GET /api/v1/admin/llm/catalog`**(**owner|admin** 读,`?refresh=1` 强制在线刷新)→ `200 {data:{source:"cache"|"models.dev", fetchedAt?, stale?, warning?, providers:[{id,name,api?,doc?,env?,npm?,models:[{id,name,attachment,reasoning,toolCall,contextLimit?,outputLimit?}]}]}}`;**既无本地快照又抓不到** → `503 {error:{code:"catalog_unavailable", message}}`;客户端带 `Accept-Encoding: gzip` 时响应带 `Content-Encoding: gzip`(**信封形状不变**)。**副作用只有一处**:落盘快照 `/llm_catalog.json`(**不含任何凭据**、`data/` 已 gitignore、TTL 24h),**不改 `models.json`**;**抓取只发生在本端点内,不进运行时热路径**(模型请求链路从不读外网目录)。详见 §2「模型配置契约(v0.53)」。② **`models.json` 组级新增键**(载体是组,**可热更新**,未声明时该键不出现):`reasoning: {kind: "auto"|"deepseek"|"openai"|"vllm"|"dashscope"}` 决定该组用哪种**提供商开关形状**下发推理指令(**非法值被 `Group.Validate` 直接拒绝**;anthropic 组上声明 openai 系形状同样拒绝)、`personaModels: {personaId: modelId}` 与 `personaProviders: {personaId: "openai"|"anthropic"}` 为**角色级模型/提供商路由**(**非法 provider 值同样拒绝**;未绑定/未知角色回落该组默认)。③ **语义修正(重要)**:**`multimodal` = 主模型自身能否收图**;`LLM_VISION_MODEL` / `LLM_OCR_MODEL` **只配视觉/OCR 工具用的模型**——两者互不代表(见 §1 行 3)。④ **`.env` 新增 9 键**(见 §2「模型配置契约(v0.53)」表与 `.env.example`):`LLM_REASONING_KIND`(默认空 = `auto`)、`LLM_OCR_MODEL`、`LLM_LOCAL_ENABLED`(默认 `false`)/`LLM_LOCAL_PROVIDER`/`LLM_LOCAL_BASE_URL`/`LLM_LOCAL_API_KEY`/`LLM_LOCAL_MODEL`、`CAPACITY_L3_ENABLED`(默认 `false`)/`CAPACITY_L3_CACHE_TTL`(默认 `5m`)。⑤ **内置工具新增 `image-crop`**(按区域裁切图片:`x`/`y`/`width`/`height` → PNG data URI + 裁切尺寸;供大图分区精读;**策略默认 owner-only**)。⑥ **推理开关按形状下发(旧陈述修正)**:v0.41 的「Qwen 一类只 WARN、请求一字不改」**已作废**——形状由 `reasoning.kind` 声明后**真下发**(→ 字段名映射表见 changelog v0.53 条),**只有形状未知/有歧义时才 WARN 且一个字段都不发**;`reasoning_effort` 的非法值由「降级到最近一档」改为**丢弃并记 WARN(不降档)**,`effort:none` 一律翻成**显式关思考**。**未做**:DashScope 形状的 `auto` 推断(只能在显式声明 `reasoning.kind=dashscope` 时下发,绝不从模型名/BaseURL 猜)、Qwen 的 `/think` 前导消息软开关(与 design 口径冲突,未实现)。**上一版 `v0.52`**:(**工具策略落盘 + 主代理代审小字 + 流程图节点参数摘要**,依据 design/11 §3/§4/§8(裁决 1.3 / 10.4.2 / 13.4.3)与 design/19 §1/§1.1(裁决 11.4.2 / 11.4.3 / 13.4.1),2026-10-04 落实):① **工具策略(`allowInGroup`/`allowInStranger`)落盘 SQLite `tool_policies`(迁移 `0012_tool_policy`),重启保留**——`registry.SetPolicy` **先落库后改内存**,落盘失败则内存保持旧值并返回错误(见 §2 行 5);② 新增下行帧 **`approval_notice`**(§3.11):敏感操作经主代理代审后推一行小字(`explain` 由**后端产出**、客户端原样渲染),审计口径 `who`=角色名 / `what`=`approval.<工具名>` / `result`=`granted|denied`,**仅 `owner` + `admin`**(fail-closed 默认);③ `graph_update` 节点 `props` 新增四类**纯增量可选**键(tool `args_keys`/`args_summary`、guardrail `action`/`allow`/`elapsed_ms`、memory `action`/`scope`/`hits`/`bytes`/`count`/`failed`、orchestration `subagent`/`persona`),`memory`/`guardrail`/`orchestration` 旁路节点 span **首次真进图**,值在发布前已脱敏——**帧名、事件名、顶层字段集一字未动**(见 §3.5)。**上一版 `v0.51`**:(**后台子代理结果登 WS 帧 + 回填父会话**,依据 design/05 §9「子代理完成 → 结果回填主会话并提示用户」,裁决 02.4.3,2026-10-04 落实):新增下行帧 **`subagent_result`**(`{type, sessionId, data:{sessionId, status:"done", summary, agents:[{id, goal, role?, mode?, status, summary?, text?, steps?}]}}`,见 §3.10)——`subagent_spawn` 的批次转后台后结算时,编排层发布在 bus 主题 `subagent.result` 上的合并结果,此前**没有任何消费者**(中继既非 `ws.*` 也不认该 4 段以外的 subject)⇒ 结果上不了前台、只剩进度帧;现在该 subject 被登记为中继并成形为这一帧,**帧壳 `sessionId` = 派生该批次的父会话**,结果因此落回**父会话的实时流**。**角色门**:新帧属内容帧 → **只发 `owner` + `admin`**(fail-closed 默认,未扩 7 个状态机信号白名单)。**向后兼容**:纯新增帧,老客户端按未知帧忽略即可,既有帧/端点/鉴权一律不变。**诚实边界**:结果**不落库**(父会话转录里没有新消息、重连回放不补发)、**未**经 design/15 会话消息编排器续接、**前端尚无该帧 handler**(今天的效果是「帧到达 owner/admin 连接」)。**同版另含管理面(D 组,2026-10-04 落实)**:① **`X-API-Key` 真正接线**(裁决 15.4.2):管理/用户侧已认证端点同时接受该头,命中签发表即通过且**一律 `owner` 级**(裁决 15.4.3 取消 role 分级),带该头时只走 key 分支、未知/已吊销/空值回 `401` 且不回落 Bearer(fail-closed),WS 握手仍只认 Bearer;② **新增主人白名单档案读写** `GET`/`PUT /api/v1/admin/owner-profile`(裁决 10.4.5,**owner-only**,写带审计 `owner.profile.update`)——面板的「保存」从此是**真保存**(落盘 `configs/users/owner.yaml` 0600 原子写 + 热替换活档案,渠道 actor 判定立即生效),不再是只改本地 state 的假成功;③ **persona `identity.systemPrompt` 改必填**(裁决 08.4.2):缺失/空白回 `400 bad_request`,**不再派生默认说明串**,写出的 YAML 必能被载入期同一规则重新载入。见下方「管理面板写操作契约」。**上一版 `v0.50`**:(**事件批:流式帧的结构化补齐**,依据 design/04 §1.1,裁决 01.4.1 / 01.4.2 / 01.4.3 / 03.4.4,2026-10-04 落实):① **`tool_execution` 拆三事件**——旧单帧 `tool_execution.update {id,index,delta}`(phase 与工具名拼在 `delta` 字符串里)被 **`tool_execution.start`**(进入执行,`phase:"running"`)/ **`tool_execution.update`**(运行中进度/局部输出,可多帧)/ **`tool_execution.end`**(完成或失败,`phase:"done"|"error"`)替换,载荷改为结构化 `{tool, phase, id?}`(`tool` = 工具名;`phase` = 细粒度执行阶段,与 §3.5 流程图节点 status 同一套 `running/done/error` 枚举;`id` = provider 工具调用 id,可与 `tool_call.*` 帧关联)——**phase 不再塞进 `delta`**;loop 现已真实发出 start/end(update 的 wire 映射已实现并验证,但工具分发器尚无中途进度上报源,**暂无生产者**,见 §3.1)。② **`streamId`(模型流帧 `data` 内新增可选字段)**:同一次模型流(一次 ChatStream 调用)的多帧共享同一 `streamId`,多步工具轮的第二次模型调用取新 id;与帧壳的 `turnId`(整个回合恒定)分工——`turnId` 标记「这轮回答」,`streamId` 标记「这轮里的哪一条流式消息」。③ **`turn.start` / `turn.end`(新帧)**:loop 在回合边界发出、经 ws_bridge 下行;**无 `data` 载荷**(帧壳的 `sessionId`/`turnId`/`seq` 即边界信号;回合终态信息仍由既有 `stream_end` 携带);`turn.end` 覆盖回合的所有出口(最终答案 / 工具循环安全网终止 / 预算耗尽 / 步骤出错),被静音的内部流程(backthink / 子代理)不发出。④ **向后兼容**:`turn.start/end` 与 `streamId` 为纯新增,老客户端可安全忽略;**破坏性**在旧 `tool_execution.update` 单帧——loop 不再产生它,按旧形状(解析 `delta` 里的 `phase:工具名` 拼串)消费的客户端必须迁移到三事件;现有 Web 前端未消费这些帧,行为不变。⑤ 本批新帧全部落在 v0.48 下行帧门的「仅 `owner` + `admin`」档(fail-closed 默认,无需扩白名单)。⑥ **`graph_update` 会话范围口径修正**(裁决 01.4.5,随实现事实补记):`web` / `web:` / `sess-backthink` 三范围(见 §3.5)。见 §3.1 与 v0.50 变更记录。**上一版 `v0.49`**:(**WS 握手回放与上行帧的角色门(design/11 §7.2)**:补上 §7.1 下行门之外的**同一类缺口的另两处**,对 `viewer` **继续收紧**,`owner` / `admin` 行为不变,**无端点、无字段形状变更**——① **握手 `history` 回放改为仅 `owner` + `admin`**:此前任意角色一连上 `/ws/chat` 就会收到主会话最近 50 条消息正文(`thinking` 已剥离,但**正文本身**就是主人的对话),`viewer` 从此**不收**;② **上行 JSON 帧按角色授权(fail-closed)**:非 `owner`/`admin` 连接发来的**任何**上行 JSON 帧一律拒绝,**定向**(只回发送者)回 `{type:"error",data:{code:"forbidden"}}`,**不广播、不驱动模型、不落库、不返回任何内容**——被拒帧含 `chat`(此前 `viewer` 可**真的驱动模型并落库**)、`interrupt`、`subagent_ask` / `task_output_query`(其应答是**定向**帧、能绕过 §7.1 下行门把子代理上下文/任务输出直接回给 `viewer`)、`voice_input`、`mode_switch`,且**未登记的新上行帧默认拒**;**协议级帧(ping/pong、close)不受影响**。③ **合口径**:`viewer` = **可连接、可读 12 条只读监控 REST(v0.40)、可收 7 个状态机信号帧(v0.48)**;**除此以外 WS 上拿不到任何内容、也发不出任何指令**。见 §3 开头的「上行帧与握手回放的角色门」与 v0.49 变更记录。**上一版 `v0.48`**:(**WS 下行帧的角色门(design/11 §7.1)**:hub 的下行现在是**按帧类型分档**的——只有 7 个**状态机信号**(`session_busy` / `pending_ack` / `pending_drain` / `session_idle` / `session_interrupted` / `pending_full` / `session_read`,**均不含任何内容**)继续发给**所有已认证连接**(含 `viewer`),**其余全部下行帧**(流式内容 `message.*` / `thinking.*` / `tool_call.*` / `tool_execution.update` / `stream_chunk` / `stream_end` / `thinking`;持久化 `message_persisted` / `message_edited` / `message_deleted` / `channel_persisted`;系统内部 `graph_update` / `subagent_event` / `task_output` / `task_done` / `tool_used` / `capacity_awaken` / `mode_switch`;私有状态 `mood` / `device_update` / `reminder`)**改为仅发 `owner` + `admin`**)。**对 `viewer` 是收紧**:此前它连上 `/ws/chat` 就能收到角色回复正文与推理内容,现在收不到;**`owner` / `admin` 行为不变**;**无端点、无字段形状变更**——`viewer` 的 12 条只读监控 REST 端点(`viewerAuth`,v0.40)与 `/ws/chat` 的连接鉴权都**照旧**;**新帧类型默认只发 owner/admin**(fail-closed)。见 §3 开头的「下行帧的角色门」与 v0.48 变更记录。**上一版 `v0.47`**:(**`graph_update` 子代理泳道根节点新增可选 `props.persona`**:派发子代理时若指定了目标角色,该分支根节点的 `props` 就多一个 `persona` 键,标注它**正在扮演的角色**——用途是让流程图能显示「这个子代理是以谁的身份在替班」,而不只是子代理 id;**纯增量**:不带角色派发时该键不出现,老客户端忽略未知键即可,见 §3.5 与 v0.47 变更记录)。**上一版 `v0.46`**:(**`PATCH /api/v1/sessions/{id}` 的 `personaId` 由「可重新绑定该会话的角色」改为「出现即 `400`」**:**会话角色在创建时确定、之后不可变**,要换角色请新开会话(design/22 §8-1)——**明确拒绝**而非静默忽略/200 空操作;**键出现**(任何取值:`"yuki"` / `""` / `null` / 未知 id / 空白串)一律 `400 bad_request` 且消息点名 `personaId`,该检查**先于**会话存在性检查(未知会话 + `personaId` 也回 `400` 而不是 `404`)。**破坏性**:按 v0.42–v0.45 旧文实现、依赖「PATCH 换角色 / 显式 `""` 解绑」的客户端必须改为**新建会话**选角色;`title`/`pinned` 行为与 `POST /sessions {personaId}`(创建时绑定)**完全不变**,不再有 `501/200 {personaId}` 的 PATCH 路径)。上一版 `v0.45`:`PUT /admin/channels/{platform}/config` 的 `personaId` 由「省略/`""` 都 = 解绑」修订为三态(**省略(或 `null`)= 保留已存绑定**、**显式 `""` = 解绑**、**非空 id = 绑定**,未知角色仍 `400`)。 ## 2. REST 端点(管理,`/api/v1/admin/*`) @@ -491,7 +493,7 @@ - **不含凭据(红线)**:响应里既没有 `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` 当前恒为 `NULL`**:模型适配器跑在 loop 之下、没有会话概念(`internal/runtime/model/stream.go` 的 `StreamEvent.SessionID` 注释有同样说明),把会话标识带到适配器边界需要 loop 侧在请求上打标 —— 列已建好、索引已建好,接线后即可用;**当前聚合也按会话分组**(本端点只按 `purpose` / `model` 分解)。 + - ✅ **`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**)。 @@ -545,11 +547,13 @@ - **向后兼容**:未映射/无法确定的事件仍走 `stream_chunk` + `stream_end`(非流式回退路径)。 - **上行(客户端→服务端)**: `mode_switch` / `chat`(跑 loop 并广播流式) / `voice_input` / `subagent_ask` / `task_output_query` / `interrupt`。 - **`interrupt`**(上行,客户端→服务端):`{sessionId, scope?}`(**`scope` 为 v0.56/A2 新增**)→ 会话编排器中断(design/15 §3)——取消该会话当前在跑的任务(context cancel)并回 `session_interrupted`;用户说「停下/算了/换个话题」时发。**`scope` 取值域 `{"", "all", "current"}`**(**大小写敏感,只容忍首尾空白**): - - **省略 / 空串 / `"all"`**(三者同义)= 取消当前任务 **+ 清空待处理队列**,会话立即回到空闲(**逐字不变的现状语义**,老客户端只发 `{sessionId}` 的行为完全不变);`session_interrupted.dropped` = 被清空的 pending 条数、`mode` = `idle`。 - - **`"current"`** = **只取消当前任务、保留待处理队列**:队列里的消息按编排器正常续跑规则继续执行(带上会话尾部上下文),一条都不丢;它**不递增代次**,因此被取消回合的结算仍属当代。此时 `dropped` 恒为 **`0`**,`mode` 如实报告该会话当时的**忙碌通道**(`thinking`/`executing`/`subagent`,**不是 `idle`**)。用途:客户端「打断」与「追加队列」是两种模式,若打断一律连队列清空,用户选「打断」时连发的多条消息会被静默丢弃、只剩一个 `dropped` 数字。 + - **省略 / 空串 / `"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` 帧的 `clientMsgId`**(上行,v0.56/G4):`{sessionId, text, image?, deviceId?, mode?, clientMsgId?}`——`clientMsgId` 为**客户端自带的幂等键**(可选):同一会话内同一个非空值只承认第一条,重复到达**不落库、不起回合**,回新下行帧 **`chat_duplicate`**(§3.13);省略 = 与加入该字段之前逐字相同。**REST `POST /api/v1/chat` 同名可选项语义相同**(重复回 `200 {data:{duplicate:true,messageId,sessionId}}`,见 §1 行 3)。 +- **`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`(非流式回退路径)。 @@ -560,11 +564,12 @@ > **角色门(v0.48,design/11 §7.1)**:本节的 6 个状态信号**都在白名单内**——它们只报「会话忙/闲/排队」,**不含任何内容**,所以**所有已认证连接**(含 `viewer`)都收得到;真正的正文/推理内容走 §3.1 的流帧,那些**只发 `owner` + `admin`**。 - **触发**:同一会话内,`chat` 到达时若「她忙」(正在跑 loop/工具循环/子代理/长回复)→ 新消息**不阻塞**、入该会话 pending;这一层的状态经以下事件推给前端,供「正在做 xx… 有新消息稍后回」轻状态展示。 -- **`session_busy`**:会话进入忙碌态(`{sessionId, mode}`,`mode` ∈ `idle|thinking|executing|subagent`)。 -- **`pending_ack`**:忙时新消息已入队(`{sessionId, mode, queueDepth}`)——「收到,稍后回」。 -- **`pending_drain`**:忙完开始消费下一待处理消息(`{sessionId, mode, queueDepth}`)。 -- **`session_idle`**:会话回到空闲、队列清空(`{sessionId, mode}`)。 -- **`session_interrupted`**:用户 `interrupt` 中断(`{sessionId, mode, dropped}`)——**帧形状零变化**,语义随 `scope`(v0.56/A2):`scope:"all"`(省略 / 空串同义)= 当前任务取消、pending 清空,此时 `mode = idle`、`dropped` = 被清空的 pending 条数;**`scope:"current"` = 只取消当前任务、pending 保留**,此时 `dropped` 恒为 **`0`**、`mode` 为该会话当时的**忙碌通道**(`thinking`/`executing`/`subagent`,**非 `idle`**)。`dropped` 恒等于**真的被丢弃**的条数(绝不把「保留」报成丢弃)。 +- **`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)。 @@ -703,7 +708,7 @@ - **与 `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` 一律不变。 +- **兼容性**:**纯新增帧**,老客户端按未知帧忽略即可;既有帧 / 端点 / 鉴权 / 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:进编排 + 归属 + 降级信号) @@ -728,6 +733,7 @@ - **降级信号(与 §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 通用) @@ -754,7 +760,7 @@ - 返回**按时间倒序**,需 `reverse()` 成旧→新用于渲染。 - **编辑 / 删除单条消息**:`PATCH /api/v1/messages/{id}` `{content}`(≤4000 rune)→ `{data:<消息DTO>}`(裸 DTO,无 `message` 包裹);`DELETE /api/v1/messages/{id}` → `{data:{deleted:}}`。不存在回 `404`。**重发**用「编辑后内容再 `POST /chat`」即可(无独立重发端点)。两者成功后都会广播(`message_edited` / `message_deleted`,§3.8),同账号其它端**免刷新**同步。 - **实时增量**:连接 WS 后,消息落库会广播 `message_persisted`(§3.3)——**用其中的 `id` 去重**,按 `sessionId` 写进该会话自己的桶(**v0.54 起非激活会话的帧不再丢弃**);新开/重连连接首帧即 `history` 回放(主会话最近 50 条)。**消息被编辑/删除**则分别广播 `message_edited` / `message_deleted`(§3.8)——同一 `sessionId` 路由规则,前者按 `id` 原位改 `content`、后者按 `id` 移除,都不需要重拉整页。 -- **发消息**:`POST /api/v1/chat` `{text, deviceId?, image?, mode?, clientMsgId?, documents?, stream?}`。`stream:true` **直达会话编排器**(v0.56/G2):受理成功后才回 `200 {stream:true}`;会话正忙已续接排队 `202 {stream:true,queued:true,queueDepth}`、队列满 `409 chat_busy`、**未接 loop `500 chat_unavailable`**、仅未接编排器时的兜底派发失败 `503 chat_dispatch_failed`。非流式返回完整 `reply`(会话正忙时 `202 {queued:true,queueDepth}`、排队满 `409 chat_busy`,见 §3.2)。`image` 解码后上限 **5 MiB**(超限 `400 image_too_large`,文案指路 `POST /api/v1/upload`);**WS `chat` 帧受 4096 字节读上限约束**(超限断连),故 WS 上发大图必须走「上传 → URL」。**文档附件(v0.59)**:先 `POST /api/v1/documents`(multipart `file`,上限 20 MiB)拿到 `name`,再在 `documents:[{"name":…}]` 里引用(**≤5 条、只给存储名**;超限 `400 too_many_documents`、名非法/不存在 `404 document_not_found`,契约见 §1.2)。`text`/`image`/`documents` **至少一项非空**(纯附件回合成立)。 +- **发消息**:`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) @@ -775,7 +781,8 @@ - 语音:上行 `voice_input`(base64 音频或已转写 text,**v0.59 起可带 `clientMsgId` 幂等**),下行 `voice_output`(合成音频 base64 + text + **`sessionId`/`deviceId?`**,**定向回发起那条连接、回合结算时才到**,空正文/TTS 失败带 `provider` 降级信号);**语音回合与文本回合走同一条路**(落库 / 跨端 / 忙时排队),见 §3.15。 ## 5. 变更记录 -- `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` 当前恒为 `NULL`**:模型适配器跑在 loop 之下、没有会话概念(`internal/runtime/model/stream.go` 对 `StreamEvent.SessionID` 有同样说明),填它需要 loop 在请求上打标,而那份文件属本轮并行切片在途,故**先留列与索引、后接线**;本端点也**不按会话分组**。**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.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-`),因此**对客户端无可见差异**;写在此处只为让「谁生成」的口径与实现一致(这也是后续 `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)。 diff --git a/Docs/api-reference.md b/Docs/api-reference.md index 2d2fb98..ea01437 100644 --- a/Docs/api-reference.md +++ b/Docs/api-reference.md @@ -1,6 +1,6 @@ # Cyrene v2 后端 API 参考(api-reference.md) -> **权威归属(先读这条)**:**契约以 `Docs/api-client.md` 为准**——那是版本化的跨平台客户端契约,每个端点的形状 / 语义 / 版本变更都记在那里(当前 `v0.61`)。**本文件是内部与运维参考**:端点全集索引 + 示例 + 部署细节。**凡是与 api-client 重复的契约性描述,一律以 api-client 为准(本文件不再维护第二份契约);发现两者不一致时,改本文件而不是改 api-client。** +> **权威归属(先读这条)**:**契约以 `Docs/api-client.md` 为准**——那是版本化的跨平台客户端契约,每个端点的形状 / 语义 / 版本变更都记在那里(当前 `v0.65`)。**本文件是内部与运维参考**:端点全集索引 + 示例 + 部署细节。**凡是与 api-client 重复的契约性描述,一律以 api-client 为准(本文件不再维护第二份契约);发现两者不一致时,改本文件而不是改 api-client。** > **本文件的正确用法**:查「有哪些端点、大致形状、错误码、部署注意事项」可以;**写客户端代码时请以 `Docs/api-client.md` 为准**,本文件的字段表 / 示例可能滞后。 > **历史包袱(如实)**:本文件长期自称「全部后端 API 的唯一详细参考 / 超详细主文档 / 每变更必同步」,但实际**系统性滞后于实现与 api-client**(2026-10-03 的 16 路 design-vs-code 审计结论)。本次已修正与实现**直接矛盾**的陈述、并把若干复述型段落改为引用 api-client;**§1 的 REST 端点表已于 2026-10-04 按 `internal/services/gateway/routes.go` 逐条核对并补登记**(补上此前缺失的 `POST /auth/refresh`、`POST /auth/logout`、`POST /upload`、`GET /files/{name}`、三支 `/audio/*`、`GET /admin/llm/catalog`、`GET /admin/observability/logs`、`GET /admin/notifications`、`GET /admin/plugins/installed`、`GET|PUT /admin/owner-profile`、`POST /admin/automations/reminders`)。**仍未补全的是 WS 事件帧清单**:§6.4 自述「并非全表」,`DevDocs/audit/00-总览.md` §4④ 列出的 `session_read` / `message_edited` / `message_deleted` 等仍未逐行登记。 > **其余仍成立**:所有字段名、取值、鉴权规则、状态码最终**以代码为准**(`internal/services/gateway/*.go`);契约变更版本化(REST 走 `/api/v1`,破坏性变更升 v2),不得破坏既有客户端(版本化承诺本身记在 api-client §5)。 @@ -69,7 +69,7 @@ | # | 方法 | 路径 | 鉴权 | 状态 | 说明 | 里程碑 | |---|------|------|------|:---:|------|:---:| -| 3 | POST | `/api/v1/chat` | owner\|admin | 200/202/400/404/409/500/503 | 发消息(同步回复或触发 WS 流式);**v0.56**:`stream:true` 受理成功才回(`202` 续接排队 / `409` 队列满 / `500 chat_unavailable` 未接 loop / `503 chat_dispatch_failed` 兜底派发失败)、`image` 超上限 → `400 image_too_large`(上限 = `CHAT_IMAGE_MAX_BYTES`,默认 5 MiB 与 `/upload` 同口径;≤0 视为用默认)、`clientMsgId` 重复 → `200 {duplicate:true}`;**v0.59**:可选 `documents:[{name}]`(≤5 条、只收服务端存储名)→ 超限 `400 too_many_documents`、名非法/不存在 `404 document_not_found`(见 §3.8) | M8/M15 | +| 3 | POST | `/api/v1/chat` | owner\|admin | 200/202/400/404/409/500/503 | 发消息(同步回复或触发 WS 流式);**v0.56**:`stream:true` 受理成功才回(`202` 续接排队 / `409` 队列满 / `500 chat_unavailable` 未接 loop / `503 chat_dispatch_failed` 兜底派发失败)、`image` 超上限 → `400 image_too_large`(上限 = `CHAT_IMAGE_MAX_BYTES`,默认 5 MiB 与 `/upload` 同口径;≤0 视为用默认)、`clientMsgId` 重复 → `200 {duplicate:true}`;**v0.59**:可选 `documents:[{name}]`(≤5 条、只收服务端存储名)→ 超限 `400 too_many_documents`、名非法/不存在 `404 document_not_found`(见 §3.8);**v0.65**:可选 `parallel:true`(忙时显式声明把本条作为**独立一路**;照准则同步入口 `202 {parallel:true,turnId}`、`stream:true` 的 `200 {stream:true,parallel:true,turnId}`;未照准 / 省略时形状不变) | M8/M15 | | 4 | GET | `/api/v1/sessions` | JWT | 200 | 会话列表(持久化会话 or 渠道会话回退) | M16 | | 5 | GET | `/api/v1/personas` | JWT | 200 | 人格列表 | M8 | | 6 | GET | `/api/v1/sessions/{id}/messages` | owner\|admin | 200/**404** | 会话消息热分页;**v0.63**:会话**不存在/已删除** ⇒ `404 session_not_found`,**存在但没有消息仍 `200 {"data":[]}`**(见 §3.4) | M16 | @@ -241,6 +241,7 @@ curl -s -X POST http://:8080/api/v1/auth/login \ | `mode` | string | 否 | 行为模式(design/01 §3、design/16 §5.1,如 `chat`/`code`/…);随该轮落库供气泡渲染;**IM 恒 `chat`、缺省回落 `chat`**(`chatRequest.Mode`) | | `clientMsgId` | string | 否 | **客户端幂等键(v0.56,G4)**:同一会话内同一个非空值只承认第一条,重复到达回 `200 {"data":{"duplicate":true,"messageId":...,"sessionId":...}}` 且**不落库、不起回合**;省略 = 与加入该字段前逐字相同(老客户端零影响)。原生端用它把本地乐观气泡对齐到服务端真相 | | `documents` | array | 否 | **文档附件引用(v0.59)**:`[{"name":"<32位hex>."}]`,**≤5 条**、**只收服务端存储名**(`POST /api/v1/documents` 的响应 `name`),**绝不接受任意 URL**;客户端多带的 `mime`/`url`/`size` 一律被服务端覆盖。超出 → `400 too_many_documents`(**先于任何文件系统访问**);名非法/穿越/不存在 → `404 document_not_found`;`text`/`image`/`documents` **至少一项非空**(纯附件回合成立)。WS `chat` 帧同形(见 §6.3) | +| `parallel` | bool | 否 | **独立一路的显式声明(v0.65)**:`true` 且会话正忙、单会话并发额度未满 ⇒ 本条**升为一条独立的并行路立即起**(不排队);空闲时无效果;**省略 = 与加入该字段前逐字不变**(服务端**不做**启发式自动升级)。额度:单会话默认 2、硬上限 3、`<2` = 关闭真并行(此时仍可传,只是落回排队并回 `pending_ack{reason:"parallel_limit"}`)。WS `chat` 帧同形(见 §6.3) | | `stream` | bool | 否 | `true` = **直达会话编排器**的流式提交(v0.56/G2:接了编排器就直接 `Submit`,**不再经可丢弃的 bus**;未接编排器才回退 `publishChatStream` 发 `ws.chat`),**受理成功后才回响应**;缺省 / `false` = 同步返回完整回复(Go bool 零值即 false,仍**无「必填」约束**) | ```json @@ -250,8 +251,9 @@ curl -s -X POST http://:8080/api/v1/auth/login \ - **响应**: - 同步(`stream=false`,会话空闲)`200` `{"data":{"reply":"<助手文本>","steps":,"stopReason":""}}`(`chatResponse`)。`steps` 为工具循环步数,`stopReason` 为停止原因(如 `stop`/`idle`/`error`)。 - 同步但会话**正忙**(`SubmitSync` 返回 `Pending`)`202` `{"data":{"queued":true,"queueDepth":}}`(非阻塞 ack,稍后按上下文续接)。 + - 同步、**已升为独立并行路**(`SubmitSync` 返回 `Parallel`,**v0.65**,仅在客户端发了 `parallel:true` 且额度未满时出现)`202` `{"data":{"parallel":true,"turnId":"turn-…"}}`(**不是 `queued`**:第二条路立即起、不排队,本次请求拿不到同步 `reply`,文本照旧经 WS 流式到达;`turnId` = 那一路的权威回合标识、与该路全部流帧同值)。 - 同步且 **`clientMsgId` 重复到达**(`SubmitSync` 返回 `Duplicate`)`200` `{"data":{"duplicate":true,"messageId":,"sessionId":}}`(**v0.56/G4**:不落库、不起回合,`messageId` 是既有那条的 id)。 - - 流式(`stream=true`,**v0.56 行为变更**):**受理成功后才回** —— 空闲已起回合 `200` `{"data":{"stream":true}}`;正忙已续接排队 `202` `{"data":{"stream":true,"queued":true,"queueDepth":}}`;待处理满 `409` `{"error":{"code":"chat_busy",...}}`;幂等键重复 `200` `{"data":{"duplicate":true,"messageId":,"sessionId":}}`(**不落库、不起回合**)。实际文本经 WS 推送(见 §6)。**修复前**该分支在问编排器之前就回 `200 {"data":{"stream":true}}`,故 `202`/`409` 永不出现;**未接 loop 时由 `200 {stream:true}` 改为 `500 chat_unavailable`**(与同步路径同口径)。 + - 流式(`stream=true`,**v0.56 行为变更**):**受理成功后才回** —— 空闲已起回合 `200` `{"data":{"stream":true}}`;正忙已续接排队 `202` `{"data":{"stream":true,"queued":true,"queueDepth":}}`;待处理满 `409` `{"error":{"code":"chat_busy",...}}`;幂等键重复 `200` `{"data":{"duplicate":true,"messageId":,"sessionId":}}`(**不落库、不起回合**)。;**v0.65**:已升为独立并行路 ⇒ `200 {"data":{"stream":true,"parallel":true,"turnId":"turn-…"}}`(仅 `parallel:true` 且额度未满时)。实际文本经 WS 推送(见 §6)。**修复前**该分支在问编排器之前就回 `200 {"data":{"stream":true}}`,故 `202`/`409` 永不出现;**未接 loop 时由 `200 {stream:true}` 改为 `500 chat_unavailable`**(与同步路径同口径)。 - **错误**: - `400` `{"error":{"code":"bad_request","message":"text or image is required"}}`(空消息)。 - `400` `{"error":{"code":"image_too_large",...}}`(**v0.56/G3**:`image` 解码后超上限;上限 = `CHAT_IMAGE_MAX_BYTES`(默认 **5 MiB**,`≤0` 用默认),base64 字符串上限 `ceil(n/3)*4+64`,与 `POST /api/v1/upload` 同口径,文案指路该端点)。 @@ -766,7 +768,7 @@ curl -s http://:8080/api/v1/admin/sessions/web:0123.../export -H "Authoriz - **字段**(逐字对齐 `internal/services/gateway/admin_llm_usage.go`):`window` 回显补齐后的 UTC RFC3339 时间窗与 `purpose`(`""` = 未过滤);`totals` 恒为六个键(`calls`/`prompt`/`completion`/`cacheHit`/`cacheMiss`/`hitRate`);`byPurpose[].purpose` 与 `byModel[].model` 是分组键,其余同上;**排序 = 调用次数降序、同次数按键升序**;无数据时两个数组是 `[]` **不是 `null`**。 - **`hitRate` 口径**:`cacheHit / (cacheHit + cacheMiss)`;**分母为 0 ⇒ `0`**(口径与 `model.Response.CacheHitRate` 同源)。客户端应把「分母 0」与「真 0% 命中」分开(前者显示 `—`)。 - **错误**:`400 bad_request`(时间形态非法 / `since` 不早于 `until` / 跨度超 90 天);`401`;`403`;`500 {"error":{"code":"usage_unavailable","message":"读取 LLM 用量汇总失败"}}`(汇总出口未接线或查库失败 —— **绝不回全 0 假汇总**)。 -- **数据来源**:每次**成功**模型调用把一条非敏感样本异步落进 `llm_usage`(迁移 `0017`;写入口径见 §4.19 的契约与 changelog v0.64)——**有界队列 + 单写者 + 批量事务,队列满丢弃并限流 WARN,绝不阻塞模型调用**;**凭据与消息正文绝不入库**。⚠️ `session_id` 当前恒为 `NULL`(模型层无会话概念),本端点也**不按会话分组**。保留期 `LLM_USAGE_RETENTION`(默认 `720h`;`≤0` = 关闭清理)。 +- **数据来源**:每次**成功**模型调用把一条非敏感样本异步落进 `llm_usage`(迁移 `0017`;写入口径见 §4.19 的契约与 changelog v0.64)——**有界队列 + 单写者 + 批量事务,队列满丢弃并限流 WARN,绝不阻塞模型调用**;**凭据与消息正文绝不入库**。✅ **`session_id` 已接线(v0.65 更正)**:loop 在 `Run` 顶部把会话 id 播种进 ctx、适配器落库时带上它(空串映射 `NULL`)——v0.64 写的「恒为 `NULL`」已过期;但本端点**仍不按会话过滤 / 分组**,响应形状不变。保留期 `LLM_USAGE_RETENTION`(默认 `720h`;`≤0` = 关闭清理)。 - **curl**: ```bash @@ -1281,7 +1283,7 @@ curl -s http://:8080/api/v1/admin/system/storage \ | type | 数据负载 | 服务端行为 | 结果 | |------|----------|-----------|------| | `mode_switch` | 任意(如 `{"mode":"chat"}`) | 发布 `ws.mode_switch` → hub 规范化广播 | 下行 `mode_switch`(广播) | -| `chat` | `{sessionId?, text, image?, deviceId?, mode?, clientMsgId?, documents?}` | **直达会话编排器的非阻塞 `Submit`(v0.55 起:上行 chat 不再经可丢弃的 bus——会话消息不得被 drop;编排器未注入时回退发布 `ws.chat`)** → `streamChat`(跑 loop 并流式)。`clientMsgId`(**v0.56/G4**)非空且重复到达 → **不落库、不起回合**,只回下行 `chat_duplicate`。**`documents?`(v0.59)** = 文档附件引用数组(≤5、只收服务端存储名;解析失败回 `error` 帧带 `data.code` 并中止本回合) | 下行 `stream_chunk`/`stream_end` 或 `message.*/thinking.*/tool_call.*`(广播);重复时下行 `chat_duplicate` | +| `chat` | `{sessionId?, text, image?, deviceId?, mode?, clientMsgId?, documents?, parallel?}` | **直达会话编排器的非阻塞 `Submit`(v0.55 起:上行 chat 不再经可丢弃的 bus——会话消息不得被 drop;编排器未注入时回退发布 `ws.chat`)** → `streamChat`(跑 loop 并流式)。`clientMsgId`(**v0.56/G4**)非空且重复到达 → **不落库、不起回合**,只回下行 `chat_duplicate`。**`documents?`(v0.59)** = 文档附件引用数组(≤5、只收服务端存储名;解析失败回 `error` 帧带 `data.code` 并中止本回合)。**`parallel?`(v0.65)** = 显式声明「把本条作为独立一路」:会话正忙且额度未满 ⇒ 立即起第二条路(忙态帧随之带 `activeTurns`);否则落回排队并在 `pending_ack` 带 `reason:"parallel_limit"`;空闲时无效果;**省略 = 逐字不变** | 下行 `stream_chunk`/`stream_end` 或 `message.*/thinking.*/tool_call.*`(广播);重复时下行 `chat_duplicate`;并行路照准时本轮帧的 `turnId` 即该路 | | `interrupt` | `{sessionId, scope?}` | 按 `scope` 中断(**v0.56/A2**):省略 / `""` / `"all"` = 取消在跑 turn + **清空 pending**(现状语义);`"current"` = **只取消在跑 turn、保留 pending**(队列按编排器续跑规则继续执行);**其它取值 → 定向回 `error{bad_request}`,不打断任何会话** | 下行 `session_interrupted`(`{sessionId,mode,dropped}`;`dropped` = **真的被丢弃**的 pending 条数,`scope:"current"` 时为 `0` 且 `mode` 为当时的忙碌通道) | | `voice_input` | `{audio?, format?, text?, sessionId?, deviceId?, clientMsgId?}` | STT→**会话编排器**→结算时 TTS(缺省 sessionId=`web`);`deviceId` 落库、`platform` 走存储默认 `web`;**`clientMsgId?`(v0.59)** 非空且重复到达 → 回下行 `chat_duplicate`,**不产生新回合、不发音频** | 下行 `voice_output`(**定向回请求连接**,回合结算时才到);与文本同一条编排路径(忙时排队 / 跨端同步 / 落库) | | `subagent_ask` | `{id, mode?(""/full/tail/keyword), n, query?}` | 审计子代理上下文(缺省 `mode`=full) | 下行 `subagent_event`(或 `error`) | @@ -1289,7 +1291,7 @@ curl -s http://:8080/api/v1/admin/system/storage \ **上行各负载字段** -- **`chat`**(`chatStreamRequest`):`{sessionId`(缺省`"web"`), `text`, `image?`(base64 data: URI 或 URL), `deviceId?`, `mode?`(行为模式,缺省回落 `chat`), `clientMsgId?`(客户端幂等键,**v0.56/G4**:同一会话内同一非空值只承认第一条,重复到达回下行 `chat_duplicate` `{sessionId,clientMsgId,messageId}`,且**不落库、不起回合**;省略 = 老行为逐字不变), `documents?`(文档附件引用数组,**v0.59**:`[{name}]`,≤5 条、只收服务端存储名;超出 `too_many_documents`、名非法/不存在 `document_not_found`,两处入口共用同一份解析)}。text / image / documents 至少一项非空;`runner` 未注入或三者皆空 → 直接广播 `stream_end{stopReason:"idle"}`。主会话 `web` 绑定全量 env(`loop.OwnerEnv()`)。`chat` 进入时若该会话正忙 → 由编排器入队(见 §6.4),非阻塞。**仅 owner/admin 连接可发**(见本节角色门)。 +- **`chat`**(`chatStreamRequest`):`{sessionId`(缺省`"web"`), `text`, `image?`(base64 data: URI 或 URL), `deviceId?`, `mode?`(行为模式,缺省回落 `chat`), `clientMsgId?`(客户端幂等键,**v0.56/G4**:同一会话内同一非空值只承认第一条,重复到达回下行 `chat_duplicate` `{sessionId,clientMsgId,messageId}`,且**不落库、不起回合**;省略 = 老行为逐字不变), `documents?`(文档附件引用数组,**v0.59**:`[{name}]`,≤5 条、只收服务端存储名;超出 `too_many_documents`、名非法/不存在 `document_not_found`,两处入口共用同一份解析),`parallel?`(独立一路的显式声明,**v0.65**:会话正忙且额度未满 ⇒ 立即起第二条路;省略 = 逐字不变,服务端不做启发式升级)}。text / image / documents 至少一项非空;`runner` 未注入或三者皆空 → 直接广播 `stream_end{stopReason:"idle"}`。主会话 `web` 绑定全量 env(`loop.OwnerEnv()`)。`chat` 进入时若该会话正忙 → 由编排器入队(见 §6.4),非阻塞。**仅 owner/admin 连接可发**(见本节角色门)。 - **`interrupt`**(**v0.56/A2**):`{sessionId, scope?}`。`scope` 省略 / `""` / `"all"` = 取消当前任务 + 清空待处理队列(现状语义);`"current"` = **只取消当前任务、保留待处理队列**(队列按编排器续跑规则继续执行);**其它取值 → 定向回** `{type:"error",data:{code:"bad_request"}}`,**不广播、不打断任何会话**。命中空闲会话为 no-op(两种 scope 都是)。 - **`voice_input`**:`{audio`(base64 或 data: URI), `format`(`wav|mp3|ogg|webm|mpeg`,空由 mime 猜,默认 `wav`), `text`(已转写则跳过 STT), `sessionId?`, `deviceId?`, `clientMsgId?`}。**v0.59**:`deviceId` 随语音轮次落库(`platform` 走存储默认 `web`)、`clientMsgId` 为**可选幂等键**(重复到达回下行 `chat_duplicate`:不产生新回合、不发音频);**语音回合与文本回合走同一条编排路径**(落库 / 流式 / 跨端 / 忙时排队),音频在**回合结算时**定向回发起连接(`{audio,format,text,reply,sessionId,deviceId?,provider?}`)。无 `voice` 服务 → `{type:"error",data:{message:"voice unavailable"}}`。 - **`subagent_ask`**:`{id`(必填), `mode`(空/full/tail/keyword), `n`(tail 查询窗长), `query`(keyword 查询词)}。无 auditor → `error`;id 缺失 → `error`。 @@ -1316,11 +1318,11 @@ curl -s http://:8080/api/v1/admin/system/storage \ | `tool_execution.update` | direct | `{id,index,delta}` | 工具执行进度/局部输出 | | `stream_chunk` | direct | `{delta}` | **向后兼容整段文本**(仅非流式回退路径;流式路径不 emit) | | `stream_end` | direct | `{stopReason, steps?}` / `{stopReason:"idle"}` / `{stopReason:"error", error}` | 一轮结束 | -| `session_busy` | direct(编排) | `{sessionId, mode}` | 会话进入忙碌态(mode∈idle/thinking/executing/subagent) | -| `pending_ack` | direct(编排) | `{sessionId, mode, queueDepth}` | 忙时新消息已入队「收到,稍后回」 | -| `pending_drain` | direct(编排) | `{sessionId, mode, queueDepth}` | 忙完开始消费下一待处理消息 | -| `session_idle` | direct(编排) | `{sessionId, mode}` | 会话回到空闲、队列清空 | -| `session_interrupted` | direct(编排) | `{sessionId, mode, dropped}` | 用户 `interrupt`。**v0.56/A2**:帧形状零变化——`scope:"all"`(省略 / 空串同义)= 任务取消 + pending 清空(`mode=idle`、`dropped`=清空条数);`scope:"current"` = **只取消任务、保留 pending**(`dropped` 恒 `0`、`mode` 为当时忙碌通道) | +| `session_busy` | direct(编排) | `{sessionId, mode, activeTurns}` | 会话进入忙碌态(mode∈idle/thinking/executing/subagent)。**v0.65**:`activeTurns` = 该会话当前**在途路数**(含正在收尾的路;单路时非 0 即 1),纯增量 | +| `pending_ack` | direct(编排) | `{sessionId, mode, queueDepth, reason?}` | 忙时新消息已入队「收到,稍后回」。**v0.65**:可选 `reason:"parallel_limit"`,**只在客户端显式声明了并行(`parallel:true`)却落回排队时出现**(额度已满 / 真并行被 `<2` 关掉),其它情况该键缺席 | +| `pending_drain` | direct(编排) | `{sessionId, mode, queueDepth, activeTurns}` | 忙完开始消费下一待处理消息。**v0.65**:增 `activeTurns` | +| `session_idle` | direct(编排) | `{sessionId, mode, activeTurns}` | 会话回到空闲。**v0.65**:增 `activeTurns`;时点收紧为「**所有在途路收尾且 pending 空**」(**仅并行时生效**,单路逐字不变) | +| `session_interrupted` | direct(编排) | `{sessionId, mode, dropped, cancelledTurns, activeTurns}` | 用户 `interrupt`。**v0.56/A2**:`scope:"all"`(省略 / 空串同义)= 任务取消 + pending 清空(`mode=idle`、`dropped`=清空条数);`scope:"current"` = **取消全部在途路、保留 pending**(`dropped` 恒 `0`、`mode` 为当时忙碌通道)。**v0.65 纯增量两键**:`cancelledTurns` = 本次真的取消了几路;`activeTurns` = 清点后仍在途的路(`scope:"all"` 恒 `0`)。`scope:"all"` **不发 `session_idle`**(客户端靠本帧收口) | | `pending_full` | direct(编排) | `{sessionId, mode, max, clientMsgId?, messageId?}` | 单会话 pending 达上限(默认 5)拒绝入队。**v0.59/G5 纯增量两键**:`clientMsgId` = **被拒那条消息**的幂等键(该消息未携带则整个键缺席);`messageId` = **仅当该消息此前已落库/已认领时**才回填——**绝不在此处新铸 id**(队列满的拒绝发生在幂等键认领之前,代铸会让客户端重试被判成「重复到达」而永远进不来)。**另**:同一时刻向**发送者那条连接**定向回 `{type:"error",data:{code:"chat_busy",message,sessionId,clientMsgId?,messageId?}}`(只发发送者、不广播;`code` 与 REST `409 chat_busy` 同码) | | `chat_duplicate` | direct(编排) | `{sessionId, clientMsgId, messageId}` | **v0.56/G4(新增帧)**:同一会话内**重复到达**的 `clientMsgId` 的明确回执——**不落库、不起回合、不发任何内容帧**。帧壳带 `sessionId`;**未进 7 个状态机信号白名单 ⇒ fail-closed 只发 `owner`+`admin`**(发过该 `clientMsgId` 的客户端用它把本地乐观气泡对齐到既有 `messageId`,其它客户端按值自行忽略) | | `steer_ack` | direct(编排) | `{messageId, atStep}`(帧壳带 `sessionId`,`data` 内不重复该键) | **v0.57(新增帧)**:忙时会话里新到的用户消息**已被在飞回合吃进同一回合**(已从 pending 移除、**尚未被正面回答**)——`messageId` = 那条用户消息的**权威 id**(与 `message_persisted` 的 `data.message.id` 同源)、`atStep` = 吃进它的**模型步序号(1 基)**;一批吃进 N 条发 N 帧(不合并)。客户端把对应气泡渲染为「已读、未答」的**灰态**,直到该回合的 `message.*`/`turn.end` 到达(不需要第二个确认帧)。**只在回合内边界**产生(一次模型步与其工具批次都已结束、下一次模型调用尚未开始;无匹配消息则一帧不发);**不带** `turnId`/`seq`,经 hub 直接广播、**不经 bus**;**未进 7 个状态机信号白名单 ⇒ fail-closed 只发 `owner`+`admin`**(`viewer` 收不到)。详见 api-client §3.14 | @@ -1374,11 +1376,12 @@ curl -s http://:8080/api/v1/admin/system/storage \ - **续接**:忙完重开时新回合**带上上一轮上下文**(`tail` 默认 20 条),非清空重来。 - **mode** 取值:`idle` / `thinking` / `executing` / `subagent`。 - REST 同步路径(§3.1)在忙时返回 `202 queued` / 满 `409 chat_busy`,与这些 WS 状态事件一致。 +- **忙时并行(v0.65,基础管线)**:上行 `chat.parallel:true`(WS 帧 / REST 三入口同形)+ 单会话额度未满(默认 2 / 硬上限 3 / `<2` 关闭)⇒ 本条升为**独立一路立即起**(不排队)。忙态帧随之带 `activeTurns`、`session_interrupted` 带 `cancelledTurns`、`pending_ack` 在「声明了并行却落回排队」时带 `reason:"parallel_limit"`;`session_idle` 时点收紧为「所有在途路收尾且 pending 空」(**仅并行时生效**,单路逐字不变)。**未实现**:`turn_begin` / `steer_ack.turnId` / `interrupt.turnId` / `chat_messages.turn_id` / `graph_update` 按路分桶 / 写类工具文件级互斥 / 第二路进行态 system 摘要;env `CHAT_MAX_PARALLEL_TURNS` **未接线**(生产恒为默认 2)。详见 api-client §5 v0.65。 - **队列满的身份回填与定向回执(v0.59/G5)**:`pending_full` 的**广播**载荷增 `clientMsgId?` / `messageId?`(前者 = 被拒那条消息的幂等键,未携带则整个键缺席;后者**仅当该消息此前已落库/已认领**时回填,**绝不新铸 id**);同时向**发送者那条连接**定向回 `{type:"error",data:{code:"chat_busy",…}}`(`code` 与 REST `409 chat_busy` **同码**,客户端一个 code 覆盖两条入口;只发发送者、不广播)。详见 api-client §3.2。 ### 6.6 流式关联(`turnId` / `seq`) -- `turnId`:`runTurn` 每次生成 `turn-`(crypto/rand),用于关联一轮流式突发;`stream_end`/`stream_chunk`(回退路径)带 `turnId`。**结构化流路径(`message.*` / `thinking.*` / `tool_call.*`)自 v0.36 起同样带 `sessionId` + `turnId`**——`broadcastStream` 直接把 `model.StreamEvent` 的 `SessionID`/`TurnID` 填进帧壳,loop 在回合边界把 `turnId` 挂进运行上下文(同轮所有帧一致)。**旧文曾说这条路径只有 `seq`、缺轮次标识,是错的,本版按实现更正**(与 api-client 的 v0.36 条目一致)。v0.50 起这些帧的 `data` 内另带 `streamId`(同一次模型流的多帧共享,多步工具轮的第二次模型调用取新 id);`tool_execution.*` 与 `turn.start/end` 帧**不带** `streamId`。 +- `turnId`:**v0.65 起由编排器生成、loop 复用**——编排器在启动某一路前生成 `turn-`(crypto/rand)并经 `loop.WithTurnID` 挂进该路运行上下文,`runTurn` **ctx 上已有则复用、没有才新铸**(未接编排器的回退路径 / 子代理 / backthink 仍新铸)。单路时帧上仍是每回合全新的随机 id ⇒ **对客户端无可见差异**。用于关联一轮流式突发;`stream_end`/`stream_chunk`(回退路径)带 `turnId`。**结构化流路径(`message.*` / `thinking.*` / `tool_call.*`)自 v0.36 起同样带 `sessionId` + `turnId`**——`broadcastStream` 直接把 `model.StreamEvent` 的 `SessionID`/`TurnID` 填进帧壳,loop 在回合边界把 `turnId` 挂进运行上下文(同轮所有帧一致)。**旧文曾说这条路径只有 `seq`、缺轮次标识,是错的,本版按实现更正**(与 api-client 的 v0.36 条目一致)。v0.50 起这些帧的 `data` 内另带 `streamId`(同一次模型流的多帧共享,多步工具轮的第二次模型调用取新 id);`tool_execution.*` 与 `turn.start/end` 帧**不带** `streamId`。 - `seq`:hub 级单调递增 `atomic.Uint64`,保证一次突发有序,即便 loop 多 goroutine 触发。 ### 6.7 WS 示例 @@ -1467,6 +1470,7 @@ TOKEN=$(curl -s -X POST http://:8080/api/v1/auth/login \ - `LLM` 未注入:`/admin/llm` 按 `.env` 合成单组;`POST /admin/llm` 回 `500 llm_unavailable`;`POST /admin/llm/reset-from-env`(v0.62)同样回 `500 llm_unavailable`(不假装重置成功)。 - `Deps.EnvPath`(v0.62):组合根注入的启动用 `.env` 路径,**只**供 `POST /admin/llm/reset-from-env` 按既有解析入口重读 env 默认层(网关不 fopen `.env`);为空 = 只看进程环境变量(生产 systemd/env_file 形态)。`.env` 读不出(例如路径是个目录)→ `500 env_defaults_unreadable`,**不改任何文件**。 - `Voice` 未注入:WS `voice_input` 回 `error {message:"voice unavailable"}`。 +- **IM 入站语音(v0.65)**:`channelbridge` 的 `VoiceSTT` 由组合根注入(与 Web 侧 `voice_input` **同一条** `voice.Chain`,不另建一条);超时旋钮 **`VOICE_STT_TIMEOUT_SEC`**(秒,默认 **20**,`≤0` 用默认)**只约束 IM 渠道入站语音的这一次转写**(流水线要为它一直举着一个入站并发槽;Web 客户端的 `voice_input` 不受它约束)。转写不成功(未配置 / 鉴权失败 / 欠费 / 限流 / 超时 / 静默档位)时**消息面落一行中文诚实提示**(按原因分类、含临时熔断退避),**绝不留空消息、绝不假装成功**。 - `Scheduler(auditor)` 未注入:WS `subagent_ask` 回 `error {message:"subagent audit unavailable"}`。 - `Tasks` 未注入:WS `task_output_query` 回 `error {message:"task output unavailable"}`。 - `AuditStore` 未注入:审计退化为进程内存(端点仍返回数据)。 @@ -1481,7 +1485,8 @@ TOKEN=$(curl -s -X POST http://:8080/api/v1/auth/login \ - **已真实实现**:登录/健康检查/聊天(同步+流式)/会话(列表/新建/删除/消息分页)/人格/**文档存储与摄取(v0.59:`POST`/`GET /api/v1/documents` + `chat.documents` + `documents` DTO + `document-read` 工具;**v0.61:PDF 文本层抽取**,白名单 23 项含 `.pdf`、成对裁决、扫描件诚实拒绝)**/**语音回合进编排(v0.59:落库 / 流式 / 跨端 / 忙时排队,`voice_output` 归属键)**/管理读(personas/sessions/memories/plugins/channels/iot/llm/users/automations/backthink/rag/observability×4:metrics/trace/audit/logs)/管理写(人格/API key/设备控制/自动化/LLM 保存·测试·**重置为 env 默认(v0.62,owner-only)**/渠道连接·断开·身份测试/渠道配置·删除/KOOK 面板/微信扫码/记忆添加·检索/插件安装·卸载/RAG 导入·删除/主人档案读写(`GET`/`PUT /api/v1/admin/owner-profile`,v0.51)/系统设置 4 项)。 - **`thinking` 可达性(v0.59 口径,不改行为)**:消息体 `thinking` 字段在**实时帧**(`message_persisted`/`channel_persisted`/`message_edited`)与**握手回放**中**一律剥离**(有意默认);**实时思维链**走 `thinking.*` 流帧(owner/admin);**历史思维链**只有 `GET /sessions/{id}/messages`(owner/admin)返回。 -- **仍为后置 M10(返回 `501 not_implemented`,不伪造)**:新增用户(无用户表/登录)、IoT 场景与规则(无模型/存储)、编辑环境变量/重启服务/检查更新、完整会话转录导出(当前导出身份元数据)。**文档侧后置**:配额与引用计数/GC(design/24 S7)、**Office 解析(`.doc`/`.docx`/`.xls`/`.xlsx`/`.ppt`/`.pptx`/`.odt`/`.ods`/`.odp`/`.rtf`,仍诚实拒绝)**、**扫描件/图片型 PDF 的 OCR**(v0.61 已做文本层 PDF,无文本层者如实报 `unreadable`)、IM 侧 `msg.Media` 摄取(`channelbridge` 只取 `msg.Content`)。**已知缺口**:design/24 §6.2 的 `document_unreadable`(400) 无 REST 落点(只有 `document-read` 工具错误形态;客户端在 DTO 里看到的是 `documents[].status = "unreadable"`)。 +- **仍为后置 M10(返回 `501 not_implemented`,不伪造)**:新增用户(无用户表/登录)、IoT 场景与规则(无模型/存储)、编辑环境变量/重启服务/检查更新、完整会话转录导出(当前导出身份元数据)。**文档侧后置**:配额与引用计数/GC(design/24 S7)、**Office 解析(`.doc`/`.docx`/`.xls`/`.xlsx`/`.ppt`/`.pptx`/`.odt`/`.ods`/`.odp`/`.rtf`,仍诚实拒绝)**、**扫描件/图片型 PDF 的 OCR**(v0.61 已做文本层 PDF,无文本层者如实报 `unreadable`)、IM 侧**富媒体摄取**(v0.65 起 `channelbridge` 已摄取入站**内联附件字节**——图片/文件引用写进用户消息 `documents` 列——与入站**语音**字节(经 ASR 归一为文本,见 §8.2);其余媒体类型仍未做)。**已知缺口**:design/24 §6.2 的 `document_unreadable`(400) 无 REST 落点(只有 `document-read` 工具错误形态;客户端在 DTO 里看到的是 `documents[].status = "unreadable"`)。 +- **忙时并行(v0.65,基础管线)的未实现集合(诚实标注)**:`turn_begin` 帧、`steer_ack` 的帧壳 `turnId`、`interrupt.turnId`、`chat_messages.turn_id` 列与 DTO 键、`graph_update` 按路分桶、**写类工具文件级互斥**(`internal/runtime/tool/legfile.go` 的 `FileClaimer`/`ErrFileBusy`/`WithLegID` 仅**定死形状、无实现无接线**)、第二路进行态 system 摘要(`loop.WithParallelNotice`)**均未实现**;env `CHAT_MAX_PARALLEL_TURNS` **未接线**(`Deps.MaxParallelTurns` 无人从 env 读 ⇒ **生产恒为默认 2**,`.env` 配了不生效)。**已实现的是「基础管线」**:上行 `chat.parallel`(WS/REST 三入口同形)、忙态帧 `activeTurns`、`session_interrupted.cancelledTurns`、`pending_ack.reason:"parallel_limit"`、两条 REST 应答形状(同步 `202 {parallel:true,turnId}` / `stream:true` 的 `200 {stream:true,parallel:true,turnId}`)、`session_idle` 时点收紧(仅并行时生效)。详见 api-client §5 v0.65。 - **语义/向量增强**:`memory.search` 的 `semantic` 当前恒为 `false`——**向量索引已由组合根注入**(`cmd/cyrene/wire.go` 的 `memory.WithVectorIndex`),但**索引内当前无向量**(生产无写入),故语义打分返回空,属诚实反映而非伪造;RAG `embedded` 反映部署是否配置向量增强;**配置了增强必须真实走语义/向量路径(红线,非空壳)**。 - **API key 认证**:`X-API-Key` **已接入鉴权**(v0.51,见 §0.1 注与 api-client.md §认证)——命中签发表即通过、一律 `owner` 级(取消 role 分级),带该头时只走 key 分支(坏 key 回 `401`、不回落 Bearer);签发/吊销/列表同样真实生效。