From 902b6eecfdef9924481c771d65701f85adc06ff1 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 4 Oct 2026 21:50:08 +0800 Subject: [PATCH] =?UTF-8?q?feat(api):=20documents[].reason=20+=20messages?= =?UTF-8?q?=20=E5=AF=B9=E4=B8=8D=E5=AD=98=E5=9C=A8=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E5=9B=9E=20404=EF=BC=88=E5=AE=89=E5=8D=93=E5=9B=9E=E6=89=A7?= =?UTF-8?q?=E4=B9=9D=E4=B8=A4=E6=9D=A1=EF=BC=9B=E5=A5=91=E7=BA=A6=20v0.63?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ① documents[] 元素增可选 reason(omitempty):装配期在给出 status 的**同一处**填充,取的**就是注入模型清单里那份原因文案**(同一字符串两处用,服务端不为客户端另编一句),只在 unreadable 时下发;随 S3.5 的回合后回写落进 document 列 ⇒ 历史接口 / 握手回放都有。文案只含原因、不含落盘路径/存储名以外内容/凭据(有独特目录名反证断言)。 - ② GET /sessions/{id}/messages:不存在/已删除 ⇒ 404 session_not_found;**存在但无消息仍 200 {data:[]}**(空会话绝不能被答成 404);参数形状校验仍先于 404;存在性判定复用既有窄视图(history.Store.SessionPersona,未知 id 报 ErrSessionNotFound),**未新增 SQL/存储方法**;判不出来(未接该视图)保持旧语义(判不出 ≠ 不存在),判定本身失败回 500 不静默。 - 契约 v0.63(三档:reason 纯增量 / messages 404 行为变更 / 无破坏性):api-client §1 行 6 + §1.2④ 的 10 条逐字文案 + §4.4 错误码 + changelog + banner;api-reference §1.2 行 6 + §3.4 + §3.8 DTO + §8.1 错误码表。 - 主会话独立核对的副作用(隔离会话 sess-* 现在也 404):查活库 chat_sessions 里**根本没有 sess-* 行**(只有 web 与 web:),sess-backthink 消息数 0,前端唯一提到 sess-backthink 的是 graph_update 的内存桶测试 ⇒ **没有客户端按 id 读隔离会话的 messages**;且这与 POST /sessions/{id}/read(v0.37 起即 404)一致 ⇒ 接受不加 sess- 例外,并在回执里问安卓确认。 - 主会话独立验收:隔离 worktree(S9 的 13 文件)build/vet 绿、整树连跑 2 次 59 ok / 0 FAIL、reason/404 用例 8 PASS、双平台交叉编译绿;**变异亲自复现**(在调用点短路 404 守卫 ⇒ UnknownSessionIs404 与 DeletedSessionIs404 双红:status = 200, want 404)。 - 如实登记:验收过程中出现过**一次不可复现的子集 FAIL**(restore 之后那次 Session|Reason 子集跑;未记到用例名)—— 随后同一子集、那三条用例 ×5、以及整树 ×2 全绿,判为瞬时;已记入账本,后续留意。 --- Docs/api-client.md | 25 ++- Docs/api-reference.md | 14 +- internal/runtime/documents/reason_test.go | 108 +++++++++ internal/runtime/history/document.go | 9 + internal/runtime/history/document_test.go | 7 + internal/runtime/loop/document.go | 24 +- internal/runtime/loop/document_reason_test.go | 98 ++++++++ internal/services/gateway/history.go | 9 + .../services/gateway/history_session_guard.go | 82 +++++++ .../gateway/history_session_guard_test.go | 173 ++++++++++++++ .../gateway/session_document_reason_test.go | 211 ++++++++++++++++++ .../gateway/session_document_status.go | 18 +- .../gateway/session_document_status_test.go | 22 +- 13 files changed, 779 insertions(+), 21 deletions(-) create mode 100644 internal/runtime/documents/reason_test.go create mode 100644 internal/runtime/loop/document_reason_test.go create mode 100644 internal/services/gateway/history_session_guard.go create mode 100644 internal/services/gateway/history_session_guard_test.go create mode 100644 internal/services/gateway/session_document_reason_test.go diff --git a/Docs/api-client.md b/Docs/api-client.md index a144a7b..65f19e2 100644 --- a/Docs/api-client.md +++ b/Docs/api-client.md @@ -30,7 +30,7 @@ | 3 | `/api/v1/chat` | POST | JWT | ✅ | `{text,image?,deviceId?,mode?,clientMsgId?,documents?,stream?}` → `{data:{reply}}`;**`documents?`(v0.59)**=本回合文档附件引用数组(**≤5 条**、**只收服务端存储名**、与 REST `stream:true` / WS `chat` 同形),单回合超限 `400 too_many_documents`(先于任何文件系统访问)、名非法/穿越/不存在 `404 document_not_found`,详见 §1.2;`stream:true` 时**直达会话编排器**(v0.56/G2:不再经可丢弃的 bus,成功受理后才回,见本行末尾「`stream:true` 响应面」)。`image` 为 base64 data: URI 或 http(s) URL,入视觉路由(design/06 §0;**v0.53 语义修正**)——**主模型能不能收图看生效组的 `multimodal`**(`models.json` 组键,面板可保存、热更新);`LLM_VISION_MODEL`/`LLM_OCR_MODEL` **只配视觉/OCR 工具用的模型**,**与主模型能否收图无关**(裁决 14.4.1)——**走 `.env` 合成组(无有效 `models.json`)时,「主模型能否收图」该由 `LLM_MULTIMODAL` 声明(默认 `false`)**(**v0.58**,见 §2「模型配置契约」表);主模型不收图时由视觉·OCR 工具的**文本描述**替代(图像不占上下文,只有工具结果占);**`image` 上限(v0.56,G3)**:解码后 **5 MiB**(与 `POST /api/v1/upload` 同口径;base64 字符串上限 = `ceil(n/3)*4 + 64` 字符,已含 data: URI 前缀余量),超限一律 `400 {error:{code:"image_too_large", message}}`(文案指路 `POST /api/v1/upload`);**WS `chat` 帧到不了这条判定**——WS 读上限 `wsReadLimit = 4096` 字节/帧,超限由传输层直接断连,故 WS 上发真实图片本就必须「上传 + URL」;`deviceId` 为**客户端生成的具体端标识**(design/16 §4.5,如 `web:chrome:a1b2c3`,可选),落库到消息、便于主人知道从哪个端发来;`mode` 为**行为模式**(design/01 §3 / design/16 §5.1,如 `chat`/`code`/`home`/`docs`/…,可选),随该轮次落库——IM 恒 `chat`、缺省回落 `chat`,供前端按模式渲染气泡(仅 `chat` 才解析 `

` 拆气泡)。`clientMsgId`(v0.56,G4)为**客户端自带的幂等键**(可选):同一会话内同一个非空值只承认第一条,重复到达回 `200 {data:{duplicate:true,messageId,sessionId}}` 且**不落库、不起回合、不发新内容帧**;省略 = 与加入该字段前逐字相同(详见 §3.13)。**会话编排(design/15)**:非流式且该会话「正忙」时**不阻塞**,返回 `202` `{data:{queued:true,queueDepth}}`(稍后按上下文续接);待处理队列满时返回 `409` `{error:{code:"chat_busy"}}`。**`stream:true` 响应面(v0.56,G2,行为变更)**:受理成功后才回——空闲已起回合 = `200 {data:{stream:true}}`、正忙已续接排队 = `202 {data:{stream:true,queued:true,queueDepth}}`、队列满 = `409 {error:{code:"chat_busy"}}`、幂等键重复 = `200 {data:{duplicate:true,messageId,sessionId}}`;**未接 loop 改为 `500 {error:{code:"chat_unavailable"}}`**(旧文写 `200 {stream:true}`,现与同步路径同口径);仅当**未接编排器**才回退总线派发,其失败回 `503 {error:{code:"chat_dispatch_failed"}}`——**不再有「先回 200 再看有没有落地」**,故 `202`/`409` 从此**真的会出现**(修复前因提前 return 永不出现) | | 4 | `/api/v1/sessions` | GET | JWT | ✅ | `{data:[session...]}`。**会话持久化(design/16)**:注入 HistoryStore 后返回持久化会话元数据 `{data:[{id,type("main"|"sub"),title?,createdAt,pinned,unreadCount,lastMessage?,personaId?}]}`(**排序(D5):以代码为准 = `pinned DESC, (type='main') DESC, created_at ASC`**——`pinned=true` 最前,其次是未置顶的主会话,最后是未置顶子会话(按 `createdAt` 升序);⚠️ **置顶的次级会话会排在主会话之前**,客户端定位主会话请按 `id=="web"` / `type=="main"`,**不要假定 index 0 就是主会话**;次级 `web:`;`pinned` 恒返回 bool;**`unreadCount`(D4)恒返回整数**——该会话中 `createdAt >` 已读游标的消息条数(0 = 无未读),与 `lastMessage` **同一条 SQL 派生**、无 N+1;`lastMessage` 为**最后一条消息预览** `{content,role(user|assistant),createdAt}`,content 折叠空白截前 60 rune,空会话省略;**`personaId`(v0.42,可选)**=该会话绑定的角色 id(design/22 §2/§4.5),**空/缺省 = 继承全局默认角色**(`owner.yaml:defaultPersona`),客户端据此渲染激活会话的角色名/头像);未注入时退化回渠道会话列表 | | 5 | `/api/v1/sessions` | POST | JWT(owner/admin) | ✅ | **新建次级会话** → `201 {data:{sessionId,personaId?}}`(`sessionId="web:"`)。body **可选**(可完全省略或空 body):`{personaId?}`(v0.42)在创建时就把新会话绑到该角色(design/22 §2:渠道级只作"新会话的初始角色提供者";**会话角色只在创建时写入、之后不可变**,见 §1 行 8 / design/22 §8-1);`personaId` 为**未知角色**回 `400 bad_request`;省略/空 = 不绑(`persona_id=''`,继承全局默认);**`personaId` 非空时响应回显它**(省略时响应形状与 v0.41 完全一致);未注入 HistoryStore 回 `501 not_implemented`;注入了**不支持角色绑定**的 HistoryStore 且显式传 `personaId` 时也回 `501`(不假装写入) | -| 6 | `/api/v1/sessions/{id}/messages` | GET | JWT(owner/admin) | ✅ | **热分页拉历史**:`?limit=&before=&platform=&after=`(A5)默认 `limit=50`(上限 200)。**`before`**=上一页最旧 `createdAt`(毫秒),按时间**倒序**;**`after`**=<`createdAt` 毫秒>,按时间**升序**返回 `created_at > after` 的消息(**正向增量**,客户端把上次看到的 `createdAt` 当游标补断线期间漏的,解决重连只回放 50 条的缺口);**`after` 与 `before` 互斥**,同传回 `400`;负数/非数字回 `400`。`platform` 为**可选**来源平台过滤(design/16 §4.4)——Web 端不传(=全量共享主会话),IM 适配器传**本平台**(如 `telegram`)只拉本渠道消息;`deviceId` 为**客户端具体端标识**;`senderName` 为**第三方发送者昵称**;**`personaId`(v0.42,可选)**=**该消息说话时的角色 id**(design/22 §7-5:逐条记录"谁说的"——会话角色在创建时固定,§8-1;assistant 消息 = 生成它的角色,user 消息 = 当回合角色;**空 = 全局默认角色**,即 v0.42 之前的全部历史消息),客户端据此按角色渲染名字/头像;**`clientMsgId`(v0.56,可选,G4)**=该消息的**客户端幂等键**(带 `clientMsgId` 发的 user 消息会回填它;空则省略),客户端据此把本地乐观气泡对齐到服务端真相;**`q`**(D2)=**会话内内容搜索**(大小写不敏感 ASCII LIKE,`%`/`_`/`\` 已转义为字面量),仍按时间**倒序**、`before`/`limit` 同分页语义;`q` 与 `after` **互斥**(同传 `400`),空 `q` 视为未传(走普通分页),搜索**严格限定在 `{id}` 会话内**(绝不跨会话,群聊/陌生人隔离会话不会外泄);非法 `limit`/`before`/`after` 回 `400`;未注入 HistoryStore 回 `501` | +| 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`,与目标存不存在无关) | | 6a | `/api/v1/messages/{id}` | PATCH | JWT(owner/admin) | ✅ | **编辑单条消息**(D3):body `{content}`(非空、≤4000 rune,空/纯空白/超长回 `400`)→ **`{data:<消息DTO>}`**(`data` **直接就是**更新后的完整消息 DTO,含 `id/sessionId/role/content/.../createdAt`,**无 `message` 包一层**;**只改 `content`**,role/时间戳/平台保持不变);消息不存在回 `404 not_found`;未注入 HistoryStore 回 `501`。**成功后广播下行 `message_edited`**(§3.8,账号其它端免刷新同步)。**重发不单独设端点**——客户端用「编辑后的内容再 `POST /chat`」即可(避免重复轮次语义) | | 6b | `/api/v1/messages/{id}` | DELETE | JWT(owner/admin) | ✅ | **删除单条消息**(D3)→ `{data:{deleted:}}`;不存在回 `404 not_found`;未注入 HistoryStore 回 `501`。**成功后广播下行 `message_deleted`**(§3.8,账号其它端按 `id` 移除) | | 7 | `/api/v1/sessions/{id}` | DELETE | JWT(owner/admin) | ✅ | **删除次级会话及其消息** → `{data:{deleted:}}`;主会话不可删回 `403 forbidden`;未注入 HistoryStore 回 `501` | @@ -164,8 +164,24 @@ "documents":[{"name":"a1b2….md","mime":"text/plain","size":12345,"chars":8000,"truncated":false,"status":"extracted"}] ``` +读不到时(v0.63)多一个**可选** `reason`: + +```json +"documents":[{"name":"a1b2….pdf","mime":"application/pdf","size":2048,"status":"unreadable","reason":"无法抽取正文:这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)"}] +``` + - **≤5 条**,顺序 = 客户端发送顺序;**0 个附件时该键省略**(不是 `[]`,与引入文档之前的形状一致)。 - `status` ∈ `extracted`(正文已进上下文)/ `listed`(只给了模型一份附件清单,正文没进上下文)/ `unreadable`(读不到正文)。**PDF(v0.61)**:**文本层 PDF ⇒ `extracted`**;**扫描件/图片型 PDF(无文本层)⇒ 诚实拒绝 `unreadable`**,原因文案含「这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)」——**绝不假装成功、也绝不返回空正文**;**加密 PDF** 与**损坏/截断 PDF** 是**可区分**的两类失败(加密的提示解除密码后重试)。 +- **`reason`(v0.63,可选,`omitempty`)**=**读不到的原因文案**,**只在 `status:"unreadable"` 时下发**(`extracted` / `listed` 时该键**缺席**,客户端按「缺失 ⇒ 回落现有通用句」实现即可)。它**逐字就是服务端注入给模型的那份原因**(同一字符串,两处同源),因此客户端**不必自己拼**「图片型 PDF,需要 OCR」这类服务端没说过的话。当前文案集合(服务端常量,逐字): + - `无法抽取正文:这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)`(扫描件/图片型 PDF,`ErrNoTextLayer`) + - `无法抽取正文:PDF 已加密(无法用空口令打开),请解除密码后重新上传` + - `无法抽取正文:PDF 损坏或无法解析` + - `无法抽取正文:本期不支持 Word/Excel/PPT 解析(无文本层)`(Office) + - `无法抽取正文:内容不在可抽取的纯文本范围内`(非文本/二进制) + - `无法抽取正文:文档文件不存在或已被清理` / `无法抽取正文:读取文档失败` / `无法抽取正文:文档引用形态非法` / `无法抽取正文:文档上传目录未配置` + - `无法抽取正文:读取或解析失败`(解析器整条消息失败时的诚实兜底) + ⚠️ **只放文案**:不含落盘路径、不含凭据、不含存储名以外的内容;原始错误(可能带路径)**只进服务端日志**,绝不透出(有端到端用例用「独特临时目录名」做反证断言)。 + ⚠️ **时点**:原因与 `status`/`chars`/`truncated` 同批,由**回合结束后**的回写写进 `document` 列(S3.5)——因此它**只在读取面**(`GET /sessions/{id}/messages`、握手回放)可见;`message_persisted` 帧在用户消息落库时就发出(那时还没有结论,这与 `status` 的既有行为一致)。 - ⚠️ **`chars` / `truncated` 是「本回合注入侧」事实**:`chars` = **本回合注入**模型上下文的字符数、`truncated` = **注入**是否被截断。**它不是「文件有多少字」、也不是「文件是否被截断」**——全文长度只有 `document-read` 的 `totalChars` 能看到;「抽取期撞上 8 MiB 安全上限」只有 `extractTruncated` 能看到。 - **不含正文**(结构上保证:正文若从历史回传,每翻一页都要背着它)。 - ⚠️ **破坏性提示**:旧文档里出现过单数 `document`(对象)的形状,**但那个形状从未随任何已发布契约版本号发布过**(客户端无从实现)⇒ 本版**不留兼容期**——客户端按 `documents`(数组、可缺失)读取即可。 @@ -196,6 +212,8 @@ > **发现的不一致(如实登记,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.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/*`) @@ -518,7 +536,7 @@ ### 3.3 消息持久化增量事件(`message_persisted`,design/16) - **触发**:每一条 user / assistant 消息**落库后**由编排层广播——Web 主会话路径走 `persistUserMessages`/`persistAssistantReply`,**IM 渠道主人私聊**(design/16 §4.4)由 channelbridge 把消息**落主会话 `web`**(带 `platform` 标记)后复用**同一** `message_persisted` 广播(同一 hub 的 `BroadcastPersisted`),保证 Web 端实时镜像 IM 消息。 -- **载荷**:`{type:"message_persisted", sessionId, data:{message:{id, sessionId, role, content, mode?, mood?, image?, platform?, deviceId?, senderName?, personaId?, createdAt}}}`——`role` ∈ `user|assistant`;`id` 为**后端生成的消息 id**(客户端据此去重);`platform` 为**来源平台**(`web` 或渠道如 `telegram`/`weixin-ilink`/`qq-obv11`);`deviceId` 为**具体端/客户端标识**(design/16 §4.5,如 `web:chrome:a1b2c3` / `weixin-ilink:conf0:wx_uid`);`senderName` 为**第三方发送者昵称**(design/16 §4.6,群聊/陌生人消息带,主人私聊为空,`omitempty`);**`personaId`(v0.42,`omitempty`)=该消息说话时的角色 id**(design/22 §7-5;空 = 全局默认角色,即升级前的全部历史),客户端据此按角色渲染名字/头像;**`clientMsgId`(v0.56,`omitempty`,G4)= 客户端自带的幂等键**(空 = 该消息没有幂等键:老客户端、IM 镜像、历史行),客户端据此把本地乐观气泡对齐到服务端真相;`thinking` 已剥离(design/16 §6)。 +- **载荷**:`{type:"message_persisted", sessionId, data:{message:{id, sessionId, role, content, mode?, mood?, image?, platform?, deviceId?, senderName?, personaId?, createdAt}}}`——`role` ∈ `user|assistant`;`id` 为**后端生成的消息 id**(客户端据此去重);`platform` 为**来源平台**(`web` 或渠道如 `telegram`/`weixin-ilink`/`qq-obv11`);`deviceId` 为**具体端/客户端标识**(design/16 §4.5,如 `web:chrome:a1b2c3` / `weixin-ilink:conf0:wx_uid`);`senderName` 为**第三方发送者昵称**(design/16 §4.6,群聊/陌生人消息带,主人私聊为空,`omitempty`);**`personaId`(v0.42,`omitempty`)=该消息说话时的角色 id**(design/22 §7-5;空 = 全局默认角色,即升级前的全部历史),客户端据此按角色渲染名字/头像;**`clientMsgId`(v0.56,`omitempty`,G4)= 客户端自带的幂等键**(空 = 该消息没有幂等键:老客户端、IM 镜像、历史行),客户端据此把本地乐观气泡对齐到服务端真相;`thinking` 已剥离(design/16 §6)。**`documents?`(v0.59 起;v0.63 补 `reason`)**:载荷里的消息与 `historyMessage` **同结构**(同一结构体),因此带附件的回合会带 `documents` 数组(`[{name,mime,size,chars?,truncated?,status?,reason?}]`,0 个附件时省略该键)。⚠️ **时点**:本帧在消息**落库那一刻**发出(用户消息早于该回合的抽取),因此帧里的 `status`/`reason` **可能还没有结论**;抽取结论由回合结束后的回写补进库里,**以读取面(`GET /sessions/{id}/messages`、握手回放)为准**(有附件的回合请按读接口渲染「读到了 / 读不到 + 原因」)。 - **注:群聊/陌生人(非主人)消息**只在**该 IM 适配器日志界面**以消息流显示,**不**经 `message_persisted` 进 Web 主会话(design/16 §4.6 §3-4);该日志流由 `GET /api/v1/admin/channels/{platform}/messages` 拉取,落库后另经 `channel_persisted`(§3.4)实时推送。 - **客户端处理**:把帧按 `data.message.sessionId`(缺省时用帧壳 `sessionId`)写进**该会话自己的消息桶**(**v0.54 起:非激活会话的消息不再被丢弃**,切过去即可见,见 §3.1);按 `id` upsert——本端已预建的**同 role 乐观气泡**(本地 `uid` 无后端 `id`)被**归一**(`uid` → 后端 `id`),异端无本地气泡则直接追加,两者最终收敛为**同一条 `id` 相同的消息**,「A 发消息、A 与 B 都无需刷新且各出现一次」。 @@ -710,7 +728,7 @@ ### 4.4 常见错误码 / 边界 - `401` 未认证/令牌失效 → 重新登录。`403` 权限不足(viewer 读管理写/主会话删除)。 -- `404` 资源不存在(消息/会话 id 未找到)或端点未实现;`501 not_implemented` 后端明确未提供(如知识库删除、新增用户)。 +- `404` 资源不存在(消息/会话 id 未找到)或端点未实现;`501 not_implemented` 后端明确未提供(如知识库删除、新增用户)。**会话相关(v0.63)**:`GET /sessions/{id}/messages` 对**不存在/已删除**的会话回 `404 {"error":{"code":"session_not_found"}}`(此前是 `200 {"data":[]}`,客户端因此分不清「空会话」与「会话已不在」);`PATCH /sessions/{id}` 的未知会话仍是 `404 not_found`(**码不同**:前者点明是会话,后者是通用资源),`POST /sessions/{id}/read` 与 `PATCH /messages/{id}` 沿用各自既有码。 - `400` 请求体/参数非法(`limit`/`before` 越界、空 content)。`409 chat_busy` 排队满(**REST 响应体与 WS 定向 `error` 帧同码**,见 §3.2)。 - **文档相关(v0.59 起;v0.61 更新白名单与裁决口径)**:`415 unsupported_media_type`(**扩展名白名单(23 项,含 `.pdf`)与字节嗅探的成对裁决**任一不过——HTML/纯文本改名成 `.pdf`、或 PDF 改名成 `.txt` 都走它)、`413 document_too_large`(超 `CHAT_DOCUMENT_MAX_BYTES`,默认 20 MiB)、`404 document_not_found`(文档名非法/穿越/不存在;`chat.documents` 里引用不存在的名也是它)、`400 too_many_documents`(单回合 >5 条)。⚠️ **诚实说明**:design/24 §6.2 列的 `document_unreadable`(400) 目前**没有对应的 REST 错误码**——它只以 `document-read` 工具的错误形态存在(模型侧可见;客户端在 DTO 里看到的是该文档的 `documents[].status = "unreadable"`,**扫描件/图片型 PDF 就是它**,v0.61)。 - **红线**:身份判定永远来自后端 channel 层 actor 元数据 + 白名单,**客户端不要用提示词/文本判身份**;消息体的 **`thinking` 字段**在**实时帧**(`message_persisted` / `channel_persisted` / `message_edited`)与**握手回放**中**恒为空**(**有意的隐私与带宽默认**,不是漏发);实时思维链仍由 §3.1 的 `thinking.*` 流帧按 owner/admin 档下发;**历史消息的思维链**只有 `GET /sessions/{id}/messages`(owner/admin)能拿到,客户端不应依赖其它入口。 @@ -721,6 +739,7 @@ - 语音:上行 `voice_input`(base64 音频或已转写 text,**v0.59 起可带 `clientMsgId` 幂等**),下行 `voice_output`(合成音频 base64 + text + **`sessionId`/`deviceId?`**,**定向回发起那条连接、回合结算时才到**,空正文/TTS 失败带 `provider` 降级信号);**语音回合与文本回合走同一条路**(落库 / 跨端 / 忙时排队),见 §3.15。 ## 5. 变更记录 +- `v0.63`(**客户端登记的两条落地:`documents[]` 增可选 `reason`(读不到的原因)+ `GET /sessions/{id}/messages` 对不存在/已删除的会话回 `404 session_not_found`**,依据 `Cyrene-For-Android/devdocs/02-前后端协同事项.md` 的两条登记(2026-10-05 凌晨)与后端「回执九」的接受与选择,2026-10-05 落实)。**三档标注**:**纯增量** = ① `documents[]` 元素增可选 `reason`(`omitempty`,**只在 `status:"unreadable"` 时下发**;不读该键的客户端行为逐字不变);**行为变更** = ② `messages` 对**不存在/已删除**的会话由 `200 {"data":[]}` 改为 **`404 {"error":{"code":"session_not_found"}}`**(**客户端需按 404 处理**:退回主会话 + 一句提示;回执九已提前告知);**破坏性** = 无。**A. `reason`(§1.2 ④)**——数据来源 = 服务端**已有的那份抽取失败原因文案**(就是注入模型清单里那条,`internal/runtime/documents/sidecar.go` 的 `reason*` 常量);装配期在给出 `status` 的同时给出 `Reason`(`internal/runtime/loop/document.go` 的 `documentReason`),**同一个字符串两处用**:既进模型可见上下文,也作为客户端可见的 `reason` —— 服务端因此不必为客户端另编一句,两处口径也不可能分叉。**只在「读不到」时下发**(`extracted`/`listed` 为空 ⇒ 键缺席;客户端按「缺失 ⇒ 回落通用句」实现)。**文案集合(逐字)**:`无法抽取正文:这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)` / `…PDF 已加密(无法用空口令打开),请解除密码后重新上传` / `…PDF 损坏或无法解析` / `…本期不支持 Word/Excel/PPT 解析(无文本层)` / `…内容不在可抽取的纯文本范围内` / `…文档文件不存在或已被清理` / `…读取文档失败` / `…文档引用形态非法` / `…文档上传目录未配置` / 解析器整条消息失败时的诚实兜底 `无法抽取正文:读取或解析失败`。**只放文案**:不含落盘路径、不含凭据、不含存储名以外的内容;原始错误(可能带路径)**只进日志** —— 端到端用例用一个**独特的临时目录名**做反证断言(响应体里搜不到它,且 reason 不含 `uploads`、`\documents` 等路径片段)。**抵达面**:它随 `history.DocumentRef`(**与落库同一个结构体**)由 S3.5 的回合后回写写进 `document` 列(`mergeDocumentStatus` 把 `reason` 与 `status`/`chars`/`truncated` **同批**搬运、并计入「有无变化」判据),因此**所有读取面**(`GET /sessions/{id}/messages`、握手回放)都有它;⚠️ `message_persisted` 帧在用户消息落库时就发出,那时抽取还没发生(**不带** `status`/`reason`,与既有 `status` 行为一致 —— 客户端以读接口为准)。**B. `404 session_not_found`(§1 行 6)**——现象:此前对**已删除/凭空编造**的会话都回 `200 {"data":[]}`,客户端分不清「空会话」与「会话已不在」(主人的真实遭遇:正看着的临时会话被清理,界面上只剩一片空白)。**理由(与 API 自身一致)**:`PATCH /sessions/{id}`、`POST /sessions/{id}/read` 对未知会话本来就是 `404`,让 `messages` 单独例外会让每个新客户端都踩一次。**实现**:判定**复用既有存储面**(`history.Store.SessionPersona` —— 既有存储里唯一一个「按 id 读**单个**会话 + 未知 id 报 `history.ErrSessionNotFound`」的方法;`ListSessions` 是全量列表扫描,在热分页端点上代价不成比例),**不新写 SQL、不新增存储方法**,落点 `internal/services/gateway/history_session_guard.go`(新文件)+ `handleListMessages` 一行收口。**存在但没有消息仍是 `200 {"data":[]}`**(空会话绝不能被答成 404);参数校验(`limit`/`before`/`after`/`q`)**先于**存在性判定(形状错了就是 `400`,与目标存不存在无关);存储**没有该视图**(测试替身 / 未接库的窄存储)视为**无从判定** ⇒ 保持旧的空集合语义(判不出来绝不等于「不存在」)。**C. 会话注册表口径(重要,随本版更正 §3.4 旧文)**:`chat_sessions` 是会话注册表;**隔离的 IM 会话(群聊/陌生人的 `sess-xxx`、内部 `sess-backthink`)不在其中** ⇒ 它们在本端点与 `POST /sessions/{id}/read` 拿到**同一个答案**(`404`),这是「与 API 自身一致」的必然推论;它们的消息仍可由 `GET /api/v1/admin/channels/{platform}/messages` 读出(`Docs/api-reference.md` §3.4 那句「IM 隔离会话 `sess-xxx` 亦可用」**随本版更正**)。**D. 错误码风格**:新码 **`session_not_found`**(点明是**会话**,与 `chat` 的 `document_not_found` 同风格),与 `PATCH /sessions/{id}` 的既有通用码 `not_found` **不同码** —— 本版**不动** PATCH 的既有码(不制造新的破坏性变更),差异已在 §4.4 明写。**E. 未做 / 诚实边界**:`reason` 不做 i18n、不改成码值枚举(就是服务端文案,客户端原样展示);不为「会话不存在」新增 WS 帧;不新增/变更任何 `.env` 键。**实现落点**:`internal/runtime/history/document.go`(DTO 字段)+ `internal/runtime/loop/document.go`(`documentReason`)+ `internal/services/gateway/session_document_status.go`(回写)+ `internal/services/gateway/{history.go,history_session_guard.go}`(404)+ `Docs/{api-client,api-reference}.md`。**验证**:`go build ./... && go vet ./... && go test ./... -count=1` 全绿 + windows/darwin 交叉编译通过;新用例:①扫描件 PDF 经「真实文件→真实 sidecar→真实 loop→真实 HTTP DTO」链路后 `reason` 含「OCR」且 `extracted` 时该键缺席;②reason 逐字等于注入模型那份(含解析器整条失败的兜底);③已删除会话 ⇒ `404 session_not_found`,④存在但无消息 ⇒ `200 {"data":[]}`(回归),⑤`?q=`/`after`/参数校验既有用例全绿;反证:用一个**独特的临时目录名**做路径泄漏断言(响应体里搜不到它)。 - `v0.62`(**面板「重置为 env 默认」动作落地:materialize 的反方向显式动作 + 一个新的 owner-only 管理写端点**,依据任务书 `DevDocs/_dispatch/R1-面板重置为env默认.md` 与 design/04 §3.1(`.env` 为默认/播种层、面板文件为一等来源)和 `DevDocs/开发时间线.md` 的欠账项(「Materialize 已导出、未接端点」),2026-10-04 落实)。**三档标注**:**纯增量** = 新端点 `POST /api/v1/admin/llm/reset-from-env`、面板 LLM 页的新卡片、审计动作 `llm.resetFromEnv`、2 个新错误码(`env_defaults_unreadable` / `models_unreadable`,另复用既有 `llm_persist_failed` / `llm_reload_failed` / `llm_unavailable`);**行为变更** = 无(既有端点 / 帧 / 字段一字未动);**破坏性** = 无(新端点的新用途;它本身的破坏性由请求体 `confirm:true` 强制)。**A. 新端点**(**owner-only**——与同页 `POST /admin/llm`、`POST /admin/llm/test` **同门**,破坏性写不比同族更宽;带审计 `llm.resetFromEnv`)body **必须** `{"confirm":true}`(缺失 / `false` / 非法 body ⇒ `400 bad_request` 且**一个字节都不动**),→ `200 {data:{reset:[{group,keys:[…]}], skipped:[{group,reason}], path}}`。**B. 语义(与 materialize 共用同一张键表 `runtimeconfig.groupFillSpecs`,方向相反)**:① **只覆盖 `.env` 声明过非空值的键**(`provider` / `baseUrl` / `apiKey` / `model` / `contextWindow`;协议族按**组自己的 `provider`** 选,`.env` 同时配扁平块与 `ANTHROPIC_*` 时两族各取自己的值);② **env 未声明的键一律保留面板值**(`fallback` / `multimodal` / `maxTokens` / `reasoning` / `personaModels` 在 `.env` 里没有出处 ⇒ 永不被本动作改动);③ **组集合不增减**(不复活被面板删掉的组、不删面板新增的组,**顺序也不动**);④ **面板值已与 env 一致的键不算「改回」**,不出现在 `reset` 里(`provider` 恒属此种情形);⑤ **无键可改时不写盘**(幂等,重复调用第二次回 `reset:[]` + 每组一条「已一致」的 `skipped`);⑥ **真的改了盘就热重载**运行中的 LLM 栈(无需重启;`contextWindow` 的**预算推导**仍只在启动时算一次,与 `POST /admin/llm` 同口径)。**C. 响应与凭据纪律**:`reset[].keys` 只有 **`models.json` 的 JSON 键名**、`skipped[].reason` 是中文原因,**凭据值(尤其 `apiKey`)绝不回显、绝不进日志**(`reset` / `skipped` 恒为数组,无记录时是 `[]` 而非 `null`;`path` 与 `POST /admin/llm` 同源)。**D. 对照基准**:**请求时刻按既有解析入口读到的 `.env` 默认层**(新增 `config.LoadLLMDefaults`,复用 `parseDotEnv` + 同一份 `env:"…"` 解码器 ⇒ 优先级与启动期 `config.Load` 逐字一致:进程环境 > `.env` > 标签默认);路径由组合根经 `Deps.EnvPath` 注入(与 `main.go` 的 `config.MustLoad(envPath)` 同一条),**网关自己绝不 fopen `.env`**。**E. 失败不伪装**(都**不改文件**且带审计 `error`):`.env` 源不可读 → `500 env_defaults_unreadable`;面板配置不存在/不可解析 → `500 models_unreadable`;写盘失败 → `500 llm_persist_failed`;已写盘但热重载失败 → `500 llm_reload_failed`(说明文件已改、未生效);LLM 栈未注入 → `500 llm_unavailable`。**F. 面板**:`frontend/web` 的 LLM 页新增「重置为 env 默认」卡片(`admin/modules/llm/LlmResetFromEnv.tsx`,标 `owner 专属`)——点按钮先展开**中文二次确认**文案,确认后才带 `confirm:true` 提交;结果按 `reset`/`skipped` 渲染成可复核的组名 + 键名摘要,失败原样显示后端错误文案。**G. 实现落点**:`internal/config/llm_defaults.go`(env 默认层读取)+ `internal/config/runtimeconfig/reset.go`(`ResetFromEnv` 语义,新文件)+ `internal/services/gateway/admin_write_llm_reset.go`(端点,新文件)+ `routes.go`(注册为 `ownerAuth`)+ `deps.go`/`server.go`(`Deps.EnvPath`)+ `cmd/cyrene/wire_frontend.go`(组合根接线)+ 前端 `lib/types/adminWrite.ts` / `lib/api/admin.ts` / `admin/modules/llm/LlmResetFromEnv.tsx` / `admin/modules/Llm.tsx`。**H. 未做 / 诚实边界**:**不支持「只重置某一组」**(一次调用对全部组生效;`skipped` 会如实报出没被改动的组与原因);**不做「重置 channels.json / plugins.json」**(materialize 本身也只覆盖 `models.json` 一族);**不做「重置后自动重启」**(provider 栈本身热更新,窗口推导需重启由面板文案写明)。 - `v0.61`(**PDF 文本层抽取落地:白名单第 23 项 `.pdf` + 类型裁决由「嗅探只放行 `text/plain`」改为 `(扩展名, 嗅探)` 成对裁决**,依据 design/24 §7 的 **S6 第一段**(PDF/Office 的裁决:PDF 文本层引库,扫描件与 Office 仍诚实拒绝)与 commit `f82c76d`,2026-10-04 落实)。**三档标注**:**行为变更** = ① **`.pdf` 由「一律 `415`」变为「可上传 + 可读取 + 文本层可抽取进上下文」**(把「`.pdf` 必被拒」写死的客户端必须改);② **类型裁决收紧为成对判定**(`documents.ContentAllowed`)——**方向是收紧/细化,不是放宽**:HTML/纯文本改名成 `.pdf` ⇒ `415`(嗅探不是 PDF),**PDF 字节改名成 `.txt` ⇒ `415`**(PDF 字节也只收在 `.pdf` 名下);**纯增量** = 扩展名白名单 **22 → 23 项**(唯一新增 `.pdf`),其余 22 个文本类的判定与既有响应**一字未变**。**A. 上传(`POST /api/v1/documents`,owner|admin)**——白名单加 `.pdf`;裁决 = **字节嗅探是唯一裁决权 ∧ 扩展名白名单**,自本版起**按对判定**(纯文本类必须 `text/plain`、`.pdf` 必须 `application/pdf`;落点 `internal/runtime/documents/store.go` 的 `ExtAllowed`(23 项)/ `ContentAllowed`,网关名字形态校验经它判定);`mime` 仍是**嗅探基类型**(真 PDF → `application/pdf`);`415` 文案同时点明两条口径(「只接受白名单内的文档(如 .txt/.md/.csv/.json,或内容确为 PDF 的 `.pdf`),且内容必须与扩展名相符」)。**B. 读取(`GET /api/v1/documents/{name}`,必带 Bearer owner|admin)**——**真 PDF 如实下发 `Content-Type: application/pdf`**(读时按同一对裁决重嗅探,判不定者仍退 `application/octet-stream`),`Content-Disposition: attachment` + `X-Content-Type-Options: nosniff` **一字未变** ⇒ **PDF 永不被内联渲染**(与图片读侧刻意相反);`Cache-Control: private, max-age=86400, immutable` 与 Range 支持不变。**C. 抽取语义(DTO `status`)**——**文本层 PDF 抽正文**(页内容流里的 `Tj`/`TJ`/`'`/`"` 文本算子,页间以换行相接)⇒ `status:"extracted"`,与纯文本类走**同一条** sidecar / 注入路径;**扫描件/图片型 PDF(无文本层)⇒ 诚实拒绝 `status:"unreadable"`**,原因文案含「这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)」,**绝不假装成功、也绝不返回空正文**(`ErrNoTextLayer`);**加密 PDF** 与**损坏/截断 PDF** 是**可区分**的两类失败(`ErrPDFEncrypted` / `ErrPDFDamaged`,加密的提示解除密码后重试);安全阀 = 单份最多 **5000 页** + sidecar 正文 **≤8 MiB**(撞上即 `truncated`)。**D. 客户端可见的一次性重算**——抽取器版本内部升到 **`documents/v3`**(`internal/runtime/documents/extract.go`)⇒ **旧 sidecar 一次性失效重抽**:同一份**旧附件**的 `chars`/`truncated` 结论会在**某次回合之后**重新计算(旧 PDF 若是文本层 PDF,将从 `unreadable` 变为 `extracted`)——不是附件被改写,是结论被重算。**E. 未做(诚实边界)**——**不做 OCR / 扫描件栅格化**(纯 Go 无可靠实现,会撞「单二进制 + 交叉编译」「零跨进程 IPC」两条红线)、**不做 PDF 写/生成**(只读)、**不做 `.docx`/`.xlsx` 等 Office**(仍诚实拒绝);**无新端点、无新 WS 帧、无鉴权变更、无 `.env` 新增键**。**实现落点**:`internal/runtime/documents/extract_pdf.go`(文本层抽取 + 加密/损坏分类)+ `internal/runtime/documents/{extract.go,store.go,sidecar.go}`(版本号 / 白名单与成对裁决 / unreadable 原因文案)+ 依赖 `github.com/ledongthuc/pdf`(纯 Go、无 cgo)。 - `v0.60`(**管理侧只读「存储状态」端点 + 面板存储卡接真数据**,依据主人 2026-10-04 实测反馈(面板首页「存储」卡一直是占位「SQLite」,看不出 PG 增强有没有生效)与 design/10 §3 模块 14(系统设置)/ design/03 §3(pgvector 可选增强),2026-10-04 落实)。**一句话**:**纯新增一个只读端点**,既有端点 / WS 帧 / 字段形状**一字未动**;面板那张假卡换成真数据。**A. 新端点** `GET /api/v1/admin/system/storage`(**owner-only**、**只读**、**无审计动作**,与四条 `POST /system/*` 动作端点同门不同档)→ `200 {data:{main,documents,vector}}`:`main` = `{kind:"sqlite",path,fileBytes,walBytes,shmBytes,pageSize,pageCount,freelistPages,freeBytes}`(`path` 与备份/恢复端点同源;WAL/SHM 边车**合法地不存在**时如实为 `0`;`freeBytes = pageSize × freelistPages`)、`documents` = `{count,bytes}`(读 `documents` 表,**不 walk 目录**,空表 `bytes` 为 `0` 而非 `null`)、`vector` = `{kind:"pgvector"|"none",enabled,ready}`。**B. `vector` 的语义(主人 2.4 的困惑点)**:`kind` 说「配没配 `DB_PG_URL`」,`enabled` 说「开关在不在」(等价于 `kind != "none"`),`ready` 说「**活着的**向量索引真的是 pgvector 后端吗」—— 配了但连接/schema 准备失败 ⇒ `enabled:true` + `ready:false`,客户端必须显示「已配置但未就绪(检索已降级)」,**不要把 `enabled` 当成生效**。客户端显示建议:`none` → 「未启用(未配置 `DB_PG_URL`)」、`enabled && ready` → 「启用(`pgvector` · ready)」、`enabled && !ready` → 未就绪。**C. 两条纪律**:① **不含凭据**——DSN / 用户名 / 密码 / `postgres://` 全文**一个字都不回**(`vector` 段只有 `kind`/`enabled`/`ready` 三个键;有用例断言响应体里搜不到这些串);② **只读**——只有 `SELECT`/`PRAGMA` + `os.Stat`,且复用组合根注入的**活连接**(不新开连接池、不写、不迁移、不备份;调用前后 `documents` 行数与数据目录内文件集合均不变,有用例钉住)。**D. 真源(`vector`)**:组合根 `cmd/cyrene/wire_frontend.go` 的 `app.vectorState()` —— `Configured` 取 `DB_PG_URL` 非空、`Live` 取活索引的具体类型(`*vector.PGVectorIndex`);网关**只读这份注入值**,不读环境变量、不新开 PG 连接、不自行探测(否则面板与运行时会给出两个答案)。**E. 失败不伪装**:未接活连接 / 数据库路径未配置 / 读不到页级统计或 `documents` 表(例如库没跑迁移)⇒ `500 {error:{code:"storage_unavailable",message}}`,**绝不回一个全 0 的假快照**;无凭据 → `401`。**F. 面板**:`frontend/web` 的存储卡(`admin/modules/dashboard/overviewMetrics.ts`)改为真数据 —— `value` = `SQLite 12.3 MiB`(`main.fileBytes` 的人类可读量级)、`hint` = `WAL … · N 页(空闲 M 页 / …)`、新增两行详情 = `documents N 件 · …` 与 `PG 增强 …`;取数走与其它已接线卡片**同一条** `useAsyncData` 加载/错误态(数据没到就显示「端点报错:…」,**不回退成 0、不假装成功**)。**G. 实现落点**:`internal/services/gateway/admin_system_storage.go`(新文件)+ `routes.go`(注册)+ `deps.go`/`server.go`(注入 `Deps.Database` / `Deps.Vector`)+ `cmd/cyrene/wire_frontend.go`(组合根真源)+ 前端 `lib/types/adminWrite.ts` / `lib/api/adminSystem.ts` / `admin/modules/dashboard/{useDashboardExtras,overviewMetrics,MiniMetric}`。**H. 兼容性**:**纯新增**——新端点为纯增量;前端 `MiniMetricSpec` 新增的 `extra?: string[]` 是**可选**补充详情行,不传时卡片渲染与旧版**逐字等价**(其它卡片一字未改)。**未做 / 诚实边界**:`main` 只报 SQLite(当前唯一主库引擎);`vector` 只报 `kind`/`enabled`/`ready`(**刻意不回 host**,把泄漏面压到最小);不报表级明细、不报备份文件列表(`documents` 之外的占用仍要下钻到备份/文件系统看)。 diff --git a/Docs/api-reference.md b/Docs/api-reference.md index d42a6ba..5948157 100644 --- a/Docs/api-reference.md +++ b/Docs/api-reference.md @@ -72,7 +72,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 | | 4 | GET | `/api/v1/sessions` | JWT | 200 | 会话列表(持久化会话 or 渠道会话回退) | M16 | | 5 | GET | `/api/v1/personas` | JWT | 200 | 人格列表 | M8 | -| 6 | GET | `/api/v1/sessions/{id}/messages` | owner\|admin | 200 | 会话消息热分页 | M16 | +| 6 | GET | `/api/v1/sessions/{id}/messages` | owner\|admin | 200/**404** | 会话消息热分页;**v0.63**:会话**不存在/已删除** ⇒ `404 session_not_found`,**存在但没有消息仍 `200 {"data":[]}`**(见 §3.4) | M16 | | 7 | POST | `/api/v1/sessions` | owner\|admin | 201 | 新建次级会话 | M16 | | 8 | DELETE | `/api/v1/sessions/{id}` | owner\|admin | 200 | 删除次级会话 | M16 | | 9 | PATCH | `/api/v1/sessions/{id}` | owner\|admin | 200 | 会话改名(A4) + 置顶(`pinned`, D5) body `{title?,pinned?}`(部分更新,只回显变更字段;见 §3.6a) | M16 | @@ -345,7 +345,7 @@ curl -s http://:8080/api/v1/personas -H "Authorization: Bearer $TOKEN" -H ### 3.4 GET /api/v1/sessions/{id}/messages - **鉴权**:`Authorize(owner, admin)`(会话消息/thinking 属主人私密历史,viewer 不可读)。 -- **Path**:`{id}` = 会话 id(`web` 或 `web:`;IM 隔离会话 `sess-xxx` 亦可用)。 +- **Path**:`{id}` = **会话注册表 `chat_sessions` 里可寻址的**会话 id(`web` 或 `web:`)。**v0.63 起**:不在 `chat_sessions` 里的 id(已删除的次级会话、凭空编造的 id、**以及隔离的 IM 会话 `sess-xxx` 与内部 `sess-backthink`**)⇒ `404 session_not_found` —— 这与 `POST /sessions/{id}/read`(v0.37 起对隔离会话即 404)是同一口径;隔离会话的消息请走 `GET /api/v1/admin/channels/{platform}/messages`(§4.7)。⚠️ 旧文那句「IM 隔离会话 `sess-xxx` 亦可用」**已随 v0.63 作废**。 - **Query**: | 参数 | 类型 | 必填 | 默认 | 说明 | |------|------|:---:|------|------| @@ -366,7 +366,7 @@ curl -s http://:8080/api/v1/personas -H "Authorization: Bearer $TOKEN" -H | `mode` | string | 该条的模式(`omitempty`,供渲染气泡不用猜) | | `mood` | string | 情绪标记(`omitempty`) | | `image` | string | 图像引用(`omitempty`) | -| `documents` | array | **文档附件与抽取统计**(`omitempty`,**v0.59**):`[{name,mime,size,chars,truncated,status}]`,**≤5 条**、顺序 = 客户端发送顺序;**0 个附件时该键省略**;`status ∈ extracted|listed|unreadable`。⚠️ **`chars`/`truncated` 是「本回合注入侧」事实**——`chars` = 本回合**注入**上下文的字符数、`truncated` = **注入**是否被截断(**不是**「文件是否被截断」);**全文长度**只有 `document-read` 的 `totalChars` 能看到。**不含正文**。同一字段也出现在 `message_persisted` / `channel_persisted` / `message_edited` / 握手回放(同一结构体、四处同时生效)。⚠️ 单数 `document`(对象)形状**从未随任何已发布版本号发布**,本版起一律是 `documents` 数组 | +| `documents` | array | **文档附件与抽取统计**(`omitempty`,**v0.59**):`[{name,mime,size,chars,truncated,status,reason?}]`,**≤5 条**、顺序 = 客户端发送顺序;**0 个附件时该键省略**;`status ∈ extracted|listed|unreadable`。⚠️ **`chars`/`truncated` 是「本回合注入侧」事实**——`chars` = 本回合**注入**上下文的字符数、`truncated` = **注入**是否被截断(**不是**「文件是否被截断」);**全文长度**只有 `document-read` 的 `totalChars` 能看到。**不含正文**。**`reason`(v0.63,可选)**=**读不到的原因文案**,**只在 `status:"unreadable"` 时出现**(`extracted`/`listed` 时该键缺席);它**逐字就是注入模型的那份原因**(如 `无法抽取正文:这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)`),**只有文案**:不含路径、不含凭据、不含存储名以外的内容(原始错误只进日志)。同一字段也出现在 `message_persisted` / `channel_persisted` / `message_edited` / 握手回放(同一结构体、四处同时生效);⚠️ 实时帧在用户消息落库时发出,那时抽取未发生 ⇒ **`status`/`reason` 以读接口为准**。⚠️ 单数 `document`(对象)形状**从未随任何已发布版本号发布**,本版起一律是 `documents` 数组 | | `thinking` | string | 思维链(`omitempty`;**本端点 owner/admin-only,故可达**;**公开回放 / 实时持久化帧中已剥离**)。**v0.59 口径(有意默认,非缺陷)**:消息体的 `thinking` 字段在 `message_persisted` / `channel_persisted` / `message_edited` 与握手 `history` 回放中**恒为空**;**历史消息的思维链只有本端点(owner\|admin)返回**。实时思维链另走 `thinking.*` 流帧(§6.4,owner/admin 档) | | `platform` | string | 来源平台(`web` / `telegram` / `weixin-ilink` / `qq-obv11`...,`omitempty`) | | `deviceId` | string | 客户端端标识(`omitempty`,如 `web:chrome:a1b2c3`) | @@ -380,8 +380,8 @@ curl -s http://:8080/api/v1/personas -H "Authorization: Bearer $TOKEN" -H {"data":[{"id":"m1","sessionId":"web","role":"assistant","content":"你好","mode":"normal","mood":"calm","platform":"web","deviceId":"web:chrome:a1b2c3","createdAt":1753...}]} ``` -- **错误**:`400`(`limit`/`before`/`after` 非法、`before`+`after` 同传、`id` 缺失);`501` `{"error":{"code":"not_implemented","message":"会话历史服务未配置(未注入 HistoryStore)"}}`;`500` `{"error":{"code":"messages_failed",...}}`。 -- **备注**:热分页(design/16 §2.2,§5.1)。`thinking` 只在本 owner/admin 端点可见;WS 回放/广播已剥离。curl: +- **错误**:`400`(`limit`/`before`/`after` 非法、`before`+`after` 同传、`q`+`after` 同传、`id` 缺失);**`404` `{"error":{"code":"session_not_found","message":"session \"\" not found"}}`(v0.63,行为变更)——会话不存在 / 已删除**(含不在 `chat_sessions` 的隔离 `sess-xxx`);**存在但确实没有消息仍是 `200 {"data":[]}`**;参数校验**先于**存在性判定;`501` `{"error":{"code":"not_implemented","message":"会话历史服务未配置(未注入 HistoryStore)"}}`;`500` `{"error":{"code":"messages_failed",...}}`(含存在性判定本身读不出来时 —— **不静默退化成空列表**)。 +- **备注**:热分页(design/16 §2.2,§5.1)。`thinking` 只在本 owner/admin 端点可见;WS 回放/广播已剥离。**会话存活判据(v0.63)**:客户端用 **404(`session_not_found`)**判定「当前会话已不在」,据此退回主会话 + 一句提示;**不要**用「返回空数组」推断存活 —— 空数组现在**只**表示「这条会话存在但没有消息」。curl: ```bash curl -s 'http://:8080/api/v1/sessions/web/messages?limit=50&before=1753000000000&platform=web' \ @@ -545,7 +545,7 @@ curl -s -X POST http://:8080/api/v1/sessions/web/read \ - **≤5 条/回合**:超出 → `400 too_many_documents`,**先于任何文件系统访问**判定;名非法 / 穿越 / 不存在 → `404 document_not_found`;**任一条不合规都让整个请求被拒**(绝不静默丢掉附件)。 - 附件部件挂在**同一条 `user` 消息**上;**纯附件回合**(无文本、无图片)成立。WS 上行帧仍有 **4096 字节**硬上限 ⇒ 现实中文档走 REST;两条入口共用同一份解析与校验,不会分叉。 - **WS 侧失败**:经 `error` 帧下发 `data.code`(`too_many_documents` / `document_not_found` / `chat_documents_unavailable`)并**中止本回合**,绝不静默。 -- **历史 / 实时 DTO(四处同源)**:`historyMessage.documents`(见 §3.4 表)——`[{name,mime,size,chars,truncated,status}]`,`status ∈ extracted|listed|unreadable`,**0 个附件时省略该键**;⚠️ `chars` / `truncated` 是**本回合注入侧**事实(全文长度只有 `document-read` 的 `totalChars` 可见)。 +- **历史 / 实时 DTO(四处同源)**:`historyMessage.documents`(见 §3.4 表)——`[{name,mime,size,chars,truncated,status,reason?}]`,`status ∈ extracted|listed|unreadable`,**0 个附件时省略该键**;⚠️ `chars` / `truncated` 是**本回合注入侧**事实(全文长度只有 `document-read` 的 `totalChars` 可见);**`reason`(v0.63)**只在 `unreadable` 时出现 = 读不到的原因文案(**逐字**等于注入模型那份,**只有文案、不含路径/凭据**)。 **D) 内置工具 `document-read`(模型侧)**:`{name, offset?, limit?}` → `{name,status,offset,limit,totalChars,truncated,nextOffset,text,extractTruncated?}`;`limit` 默认 **8000**、上限 **64K** 字符(`≤0` 用缺省、超上限夹取);`offset` 为 **0 基 rune 偏移**、起点 = sidecar 正文(含 CSV/TSV 摘要行);`offset<0` 或 `>totalChars` → 工具错误(`==totalChars` 合法);`extractTruncated=true` 专指「抽取期撞上 **8 MiB** sidecar 安全上限」。只读 sidecar、**绝不重抽**;策略默认 **owner-only**。**诚实边界**:design/24 §6.2 列的 `document_unreadable`(400) **没有对应的 REST 错误码**,它只以本工具的错误形态存在(客户端在 DTO 里看到的是 `status:"unreadable"`)。 @@ -1422,7 +1422,7 @@ TOKEN=$(curl -s -X POST http://:8080/api/v1/auth/login \ | 400 | `bad_request` / `invalid_cron` / `invalid_rule` / `invalid_role` / `unsupported_action` / `invalid_param` / `invalid_provider` / `invalid_scope` / `too_many_documents`(v0.59) | 请求体/参数非法 | | 401 | `unauthorized` | 认证缺失/失效/过期(REST Bearer 与 WS) | | 403 | `forbidden` | 角色不足(viewer 读管理/主会话删除/写操作);主会话不可删 | -| 404 | `not_found` / `document_not_found`(v0.59) | 资源不存在(文档:名字非法 / 路径穿越 / 文件不存在**同一响应**) | +| 404 | `not_found` / `document_not_found`(v0.59) / `session_not_found`(**v0.63**) | 资源不存在(文档:名字非法 / 路径穿越 / 文件不存在**同一响应**;会话:`GET /sessions/{id}/messages` 对**不存在 / 已删除**的会话,**空会话仍是 `200 {"data":[]}`**) | | 409 | `chat_busy` / `duplicate` / `device_offline` / `needs_login` | 冲突:队列满(**REST 响应体与 WS 定向 `error` 帧同码**,v0.59/G5)/ 人格已存在 / 设备离线 / 渠道未登录 | | 413 | `document_too_large`(v0.59) | 文档超 `CHAT_DOCUMENT_MAX_BYTES`(默认 20 MiB),文案带生效上限字节数 | | 415 | `unsupported_media_type`(v0.59;判定口径 v0.61) | 文档类型不在白名单——**扩展名白名单(23 项,含 `.pdf`)与字节嗅探的成对裁决**任一不过(HTML/纯文本改名成 `.pdf`、或 PDF 改名成 `.txt` 都走它) | diff --git a/internal/runtime/documents/reason_test.go b/internal/runtime/documents/reason_test.go new file mode 100644 index 0000000..db51b93 --- /dev/null +++ b/internal/runtime/documents/reason_test.go @@ -0,0 +1,108 @@ +package documents + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" +) + +// 本文件是契约 v0.63 的 `documents[].reason` 在**摄取层侧**的端到端回归(客户端 +// 登记:`status:"unreadable"` 只有状态没有原因,客户端只能显示通用句,不愿自己 +// 编服务端的理由)。它与 sidecar_test.go 的 `TestResolver_EndToEndWithLoop` 同族: +// 真实文件 → 真实 sidecar → 真实 loop 装配期,只是这里断言的是**回给客户端的原因** +// 与**注入模型的原因**是同一个字符串,以及「读不到才给、能读到就没有」这条口径。 + +// TestResolver_EndToEndWithLoop_UnreadableReasonIsVerbatim 钉住契约 v0.63 的 ①: +// **扫描件 PDF(无文本层)** 经真实链路(真文件 → 真 sidecar → 真 loop 装配期) +// 后,`Result.Documents` 上带回**逐字**等于注入模型那份的原因文案(含「OCR」), +// 而**文本层 PDF / 纯文本**(extracted)一个 reason 都没有(该键从 DTO 里省略)。 +// +// 「逐字一致」是本用例的核心断言:客户端展示的理由与模型看到的理由必须是同一个 +// 字符串,服务端不为客户端另编一句。 +func TestResolver_EndToEndWithLoop_UnreadableReasonIsVerbatim(t *testing.T) { + t.Parallel() + layout := newTestLayout(t) + body := "季度报告:营收增长 12%。\n" + textName := layout.writeUpload(t, txtName, []byte(body)) + scanName := layout.writeUpload(t, pdfName, buildPDF(t, pdfFixture{Contents: []string{imageContent}})) + r := NewResolver(Config{DataDir: layout.dataDir, UploadsDir: layout.uploads}) + + ad := &recordingAdapter{} + ag, err := agent.New("你是助手。", nil, nil, agent.WithModel(ad, nil)) + if err != nil { + t.Fatalf("agent.New: %v", err) + } + l := loop.New(loop.Options{Agent: ag, DocumentResolver: r, MaxRounds: 1}) + msg := model.Message{Role: model.RoleUser, Content: []model.Content{ + {Type: model.ContentTypeText, Text: "这两份都看看"}, + {Type: model.ContentTypeDocument, DocumentName: textName, DocumentMIME: "text/plain", DocumentSize: int64(len(body))}, + {Type: model.ContentTypeDocument, DocumentName: scanName, DocumentMIME: PDFMIME, DocumentSize: 2048}, + }} + res, err := l.Run(context.Background(), "sess-doc-reason", []model.Message{msg}) + if err != nil { + t.Fatalf("Run: %v", err) + } + byName := map[string]history.DocumentRef{} + for _, ref := range res.Documents { + byName[ref.Name] = ref + } + if got := byName[textName]; got.Status != history.DocumentStatusExtracted || got.Reason != "" { + t.Fatalf("纯文本结论 = %+v, want extracted 且无 reason", got) + } + scan := byName[scanName] + if scan.Status != history.DocumentStatusUnreadable { + t.Fatalf("扫描件结论 = %+v, want unreadable(这份 PDF 没有文本层)", scan) + } + if scan.Reason != reasonPDFNoTextLayer { + t.Fatalf("扫描件 reason = %q, want %q(必须与注入模型的原因逐字一致)", scan.Reason, reasonPDFNoTextLayer) + } + if !strings.Contains(scan.Reason, "OCR") { + t.Fatalf("扫描件 reason = %q, want 含「OCR」", scan.Reason) + } + + // 模型可见的那一份:同一句话(清单块之后的诚实原因)。 + if len(ad.reqs) == 0 { + t.Fatal("没有记录到模型请求") + } + modelSees := false + for _, m := range ad.reqs[0].Messages { + for _, part := range m.Content { + if part.Type == model.ContentTypeText && strings.Contains(part.Text, reasonPDFNoTextLayer) { + modelSees = true + } + } + } + if !modelSees { + t.Fatal("注入模型的原因文案与回给客户端的 reason 不是同一句") + } + + // 反证:原因里没有路径 —— 盘上路径只可能是 `/<存储名>`,原始错误若被 + // 透出必然带出它;`uploads` 是真实目录段名,存储名本身则是另一路(DTO 的 name)。 + for _, leak := range []string{layout.uploads, layout.dataDir, "uploads", scanName} { + if strings.Contains(scan.Reason, leak) { + t.Fatalf("reason = %q 含路径/引用片段 %q,原因文案里不得有路径", scan.Reason, leak) + } + } + + // DTO 形状:unreadable 带 reason 键、extracted 不带。 + raw, err := json.Marshal(scan) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if !strings.Contains(string(raw), `"reason":`) { + t.Fatalf("扫描件 DTO = %s, want 带 reason 键", raw) + } + textRaw, err := json.Marshal(byName[textName]) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if strings.Contains(string(textRaw), `"reason"`) { + t.Fatalf("extracted 的 DTO = %s, want 不带 reason 键", textRaw) + } +} diff --git a/internal/runtime/history/document.go b/internal/runtime/history/document.go index 3617056..0398107 100644 --- a/internal/runtime/history/document.go +++ b/internal/runtime/history/document.go @@ -72,6 +72,15 @@ type DocumentRef struct { Truncated bool `json:"truncated,omitempty"` // Status 是抽取状态,取值见 DocumentStatus* 常量。 Status string `json:"status,omitempty"` + // Reason 是 Status == unreadable 时**读不到正文的原因**(design/24 §4.4 的 + // 诚实原因文案),供客户端直接把服务端的说法展示给用户 —— 客户端因此不必 + // 自己拼一句服务端没说过的话(契约 v0.63)。它**只在「读不到」时下发** + // (extracted / listed 时为空 ⇒ 该键从 DTO 里消失)。 + // + // 内容纪律:**只有文案**。不含落盘路径、不含任何凭据、不含存储名以外的标识 + // —— 它就是同一份注入给模型的原因短句(装配期与注入共用同一个字符串),原始 + // 错误(可能带路径)只进日志,绝不透出。 + Reason string `json:"reason,omitempty"` } // EncodeDocuments 把一条消息携带的文档引用编码成落库用的 JSON 数组。空列表返回 diff --git a/internal/runtime/history/document_test.go b/internal/runtime/history/document_test.go index 26cfa80..9ad714f 100644 --- a/internal/runtime/history/document_test.go +++ b/internal/runtime/history/document_test.go @@ -31,6 +31,13 @@ func TestDocumentRefRoundTrip(t *testing.T) { Status: DocumentStatusExtracted, }, {Name: "fedcba9876543210fedcba9876543210.md", MIME: "text/markdown", Size: 42, Status: DocumentStatusListed}, + { + Name: "00112233445566778899aabbccddeeff.pdf", + MIME: "application/pdf", + Size: 2048, + Status: DocumentStatusUnreadable, + Reason: "无法抽取正文:这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)", + }, } raw, err := EncodeDocuments(refs) if err != nil { diff --git a/internal/runtime/loop/document.go b/internal/runtime/loop/document.go index f64fe09..47ac2b5 100644 --- a/internal/runtime/loop/document.go +++ b/internal/runtime/loop/document.go @@ -100,7 +100,7 @@ const ( // 形状与 ImageResolver 同族:注入式依赖、装配期按消息调用一次、失败时回合 // 继续。差别只在返回值 —— 图像解析就地改写一条消息,而文档解析把「结论」与 // 「正文」分开交回:清单行、截断标记与「清单恒在」由循环统一渲染(解析器缺席 -// 或失败时也必须成立),status/chars/truncated 则经 Result.Documents 回给 +// 或失败时也必须成立),status/chars/truncated/reason 则经 Result.Documents 回给 // 客户端(§4.4、§6.1)。 // // 两份映射都以**服务端存储名**为键,值分别是该文档的结论(形状即 @@ -108,6 +108,10 @@ const ( // 诚实的原因文案)。缺少条目的文档由循环降级为「仅清单」。用这些中立类型, // 摄取层不必依赖 loop 包,循环也不必依赖摄取层 —— 与 vision.MessageResolver // 满足 ImageResolver 的方式相同。 +// +// 契约(v0.63):status == unreadable 时,**正文就是原因** —— 循环把它原样作为 +// `documents[].reason` 回给客户端。因此它必须是「人能读懂、不含路径与内部细节」 +// 的短句(design/24 §4.4 已是这个口径);原始错误只进日志。 type DocumentResolver interface { // Resolve 解析 msg 里的文档部件。error 表示整条消息都解析不了(例如回合 // 已被取消);**单份文档**的失败不是 error,而是 unreadable 结论。 @@ -209,6 +213,7 @@ func documentResolution(part model.Content, refs map[string]history.DocumentRef, if ref.Status == history.DocumentStatusUnreadable && body == "" { body = documentUnreadableBody } + ref.Reason = documentReason(ref.Status, body) if ref.Status != history.DocumentStatusExtracted { return ref, body } @@ -229,6 +234,23 @@ func documentStatusKnown(status string) bool { } } +// documentReason 给出随结论回给客户端的**原因文案**(契约 v0.63 的 +// `documents[].reason`):只在「读不到」时给,其余状态一律为空(该键因此从 DTO +// 里省略)。 +// +// 它就是注入给模型的那份原因文案 —— 装配期把 unreadable 的正文当作原因用 +// (解析器读不到时给的就是诚实原因,见 DocumentResolver 的契约;解析器整条报错 +// 时是 documentUnreadableBody 这个兜底)。**同一个字符串两处用**是刻意的:客户端 +// 看到的理由与模型看到的理由因此不可能分叉,服务端也从不为客户端另编一句。 +// +// 只有文案:不含路径、不含凭据、不含存储名以外的标识 —— 原始错误只进日志。 +func documentReason(status, body string) string { + if status != history.DocumentStatusUnreadable { + return "" + } + return body +} + // documentBlock 渲染一份文档的注入块:清单行**恒在**(名、MIME、大小、结论), // 其后是可注入的正文,或读不到时的诚实原因(design/24 §4.2-3、§4.4)。 func documentBlock(part model.Content, ref history.DocumentRef, body string) string { diff --git a/internal/runtime/loop/document_reason_test.go b/internal/runtime/loop/document_reason_test.go new file mode 100644 index 0000000..ab02d88 --- /dev/null +++ b/internal/runtime/loop/document_reason_test.go @@ -0,0 +1,98 @@ +package loop + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" +) + +// 本文件钉住契约 v0.63 的 `documents[].reason`(客户端登记:`status:"unreadable"` +// 只有状态没有原因,客户端只能显示通用句、不愿自己编服务端的理由): +// +// - **只在读不到时**下发(`extracted` / `listed` 时该键从 DTO 里消失); +// - 文案**逐字**等于注入给模型的那份原因 —— 两处同源于一个字符串,服务端因此 +// 不为客户端另编一句,两个出口也不可能分叉; +// - 解析器整条消息失败时也有诚实的兜底理由(绝不「读不到但没有理由」)。 +// +// 真实的「扫描件 PDF ⇒ 含 OCR 的原因」链路在 `internal/runtime/documents` 的 +// `TestResolver_EndToEndWithLoop_UnreadableReasonIsVerbatim` 里跑(那里有 PDF 夹具)。 + +// docC 是第三个合法的 S1 存储名形态(32 位十六进制 + 扩展名),供本文件使用。 +const docC = "00112233445566778899aabbccddeeff.csv" + +// TestResolveDocuments_ReasonOnlyWhenUnreadable 钉住 reason 的三个出口:能读到与 +// 只给清单时为空、读不到时逐字等于注入模型的那份原因。 +func TestResolveDocuments_ReasonOnlyWhenUnreadable(t *testing.T) { + t.Parallel() + res := &fakeDocumentResolver{ + refs: map[string]history.DocumentRef{ + docA: {Status: history.DocumentStatusExtracted}, + docB: {Status: history.DocumentStatusUnreadable}, + docC: {Status: history.DocumentStatusListed}, + }, + bodies: map[string]string{ + docA: "抽到的正文", + docB: "无法抽取正文:这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)", + docC: "不该被注入的正文", + }, + } + l := New(Options{DocumentResolver: res}) + in := []model.Message{ + documentMessage(docA, "text/plain", 10), + documentMessage(docB, "application/pdf", 20), + documentMessage(docC, "text/plain", 30), + } + out, refs := l.resolveDocuments(context.Background(), in) + byName := map[string]history.DocumentRef{} + for _, ref := range refs { + byName[ref.Name] = ref + } + if got := byName[docA].Reason; got != "" { + t.Fatalf("extracted 的 reason = %q, want 空(该键必须缺席)", got) + } + if got := byName[docC].Reason; got != "" { + t.Fatalf("listed 的 reason = %q, want 空", got) + } + want := res.bodies[docB] + if got := byName[docB].Reason; got != want { + t.Fatalf("unreadable 的 reason = %q, want %q(必须与注入模型的原因逐字一致)", got, want) + } + // 同一份文案确实进了模型可见上下文 —— 两处同源因此不可能分叉。 + if !strings.Contains(messageTextOf(out[1]), want) { + t.Fatalf("注入块 = %q, want 含同一份原因文案", messageTextOf(out[1])) + } + // DTO 形状:unreadable 带 reason 键,extracted 不带。 + raw, err := json.Marshal(byName[docB]) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if !strings.Contains(string(raw), `"reason":`) { + t.Fatalf("unreadable 的 DTO = %s, want 带 reason 键", raw) + } + extractedRaw, err := json.Marshal(byName[docA]) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if strings.Contains(string(extractedRaw), `"reason"`) { + t.Fatalf("extracted 的 DTO = %s, want 不带 reason 键", extractedRaw) + } +} + +// TestResolveDocuments_ResolverErrorCarriesHonestReason 钉住兜底路径也带原因:解析器 +// 整条消息失败时,客户端拿到的是同一句诚实兜底(`documentUnreadableBody`)—— +// 「读不到但没有理由」正是本字段要消灭的状态。 +func TestResolveDocuments_ResolverErrorCarriesHonestReason(t *testing.T) { + t.Parallel() + l := New(Options{DocumentResolver: &fakeDocumentResolver{err: errDocResolve}}) + _, refs := l.resolveDocuments(context.Background(), []model.Message{documentMessage(docA, "text/plain", 10)}) + if len(refs) != 1 { + t.Fatalf("结论 = %+v, want 一条", refs) + } + if refs[0].Reason != documentUnreadableBody { + t.Fatalf("reason = %q, want %q(诚实兜底,绝不空着)", refs[0].Reason, documentUnreadableBody) + } +} diff --git a/internal/services/gateway/history.go b/internal/services/gateway/history.go index 56a8b65..bad9588 100644 --- a/internal/services/gateway/history.go +++ b/internal/services/gateway/history.go @@ -196,6 +196,9 @@ func (s *Server) handleCreateSubSession(w http.ResponseWriter, r *http.Request) // after 互斥,空 q 被忽略,因此普通分页 // 不变。同时给出 before 与 after(二者都 > 0)返回 400, // after 非数字/为负或 limit/before 非法时亦然。 +// +// **会话不存在 / 已删除 ⇒ `404 session_not_found`**(契约 v0.63,行为变更); +// 存在但确实没有消息仍是 `200 {"data":[]}`(见 history_session_guard.go)。 func (s *Server) handleListMessages(w http.ResponseWriter, r *http.Request) { if s.history == nil { writeError(w, &apperr.AppError{Code: "not_implemented", Message: "会话历史服务未配置(未注入 HistoryStore)", HTTPCode: http.StatusNotImplemented}) @@ -240,6 +243,12 @@ func (s *Server) handleListMessages(w http.ResponseWriter, r *http.Request) { writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "q and after are mutually exclusive")) return } + // 会话不存在 / 已删除 ⇒ 404 session_not_found(契约 v0.63):客户端据此把 + // 「当前会话已不在」与「这是一条空会话」分开处理。**存在但没有消息**仍走 + // 下面的空数组(见 history_session_guard.go)。 + if s.rejectMissingSession(w, r, id) { + return + } var msgs []history.Message var err error diff --git a/internal/services/gateway/history_session_guard.go b/internal/services/gateway/history_session_guard.go new file mode 100644 index 0000000..07ba7f5 --- /dev/null +++ b/internal/services/gateway/history_session_guard.go @@ -0,0 +1,82 @@ +package gateway + +import ( + "context" + "errors" + "fmt" + "net/http" + + apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" +) + +// 本文件归属读路径上**「这条会话到底还在不在」**的唯一判断(契约 v0.63)。 +// +// 背景(客户端登记,2026-10-05):`GET /api/v1/sessions/{id}/messages` 此前对 +// **已删除 / 根本不存在**的会话回 `200 {"data":[]}`,客户端因此分不清「这是一条 +// 空会话」与「这条会话已经不在了」(主人的真实遭遇:正在看的临时会话被清理掉, +// 界面上只剩一片空白)。同一个 API 里 `PATCH /sessions/{id}`、`POST +// /sessions/{id}/read` 对未知会话本来就是 `404` —— 让 messages 单独例外会让每个 +// 新客户端都踩一次。 +// +// 因此自 v0.63 起:不存在 / 已删除 ⇒ `404 {error:{code:"session_not_found"}}`; +// **存在但没有消息**仍是 `200 {"data":[]}`(空会话绝不能被答成 404)。 +// +// 判据**复用既有存储面**,不新写 SQL、也不新增存储方法: +// `history.Store.SessionPersona` 是既有存储里唯一一个「按 id 读**单个**会话 + +// 未知 id 报 `history.ErrSessionNotFound`」的方法(`ListSessions` 是全量列表扫描, +// 在一个热分页端点上代价不成比例)。该口径由 `internal/runtime/history` 的 +// `TestSessionPersonaUnknownSession` 钉住,装配期角色解析(persona_env.go)用的 +// 也正是这个更窄视图。 +// +// 名字口径(与既有 API 一致):`chat_sessions` 是**会话注册表**,隔离的 IM 会话 +// (群聊 / 陌生人的 `sess-xxx`,以及 `sess-backthink`)不在其中 —— 它们与 +// `POST /sessions/{id}/read` 拿到的是同一个答案(404:在那条路径上不可寻址), +// 其消息仍可由 `/api/v1/admin/channels/{platform}/messages` 读出。 +// +// 未实现该视图的存储(测试替身 / 未接库的窄存储)视为**无从判定**:此时保持旧的 +// 「空集合」语义 —— 判不出来**绝不等于**「不存在」,把一次本可成功的读答成 404 +// 比答成空列表更糟。 + +// sessionNotFoundCode 是「会话不存在 / 已删除」的机器可读错误码(契约 v0.63, +// 见 Docs/api-client.md §1 行 6 与 §4.4)。 +const sessionNotFoundCode = "session_not_found" + +// sessionMissing 报告存储是否**明确否认** sessionID 的存在。 +// +// 第三个返回值是读取本身的失败:调用方必须按「不静默」处理(回 500 并留带来源的 +// 日志),绝不把「问不出来」当成「不存在」。 +func (s *Server) sessionMissing(ctx context.Context, sessionID string) (bool, error) { + reader, ok := s.history.(sessionPersonaReader) + if !ok { + return false, nil + } + _, err := reader.SessionPersona(ctx, sessionID) + switch { + case errors.Is(err, history.ErrSessionNotFound): + return true, nil + case err != nil: + return false, fmt.Errorf("gateway: session lookup %s: %w", sessionID, err) + default: + return false, nil + } +} + +// rejectMissingSession 在会话不存在时写出 `404 session_not_found` 并返回 true, +// 使调用方一行收口:`if s.rejectMissingSession(w, r, id) { return }`。 +// +// 判定失败(存储读不出来)回 `500 messages_failed` 并返回 true —— 宁可让这一次读 +// 失败并留下可诊断的日志,也不假装它是空会话。 +func (s *Server) rejectMissingSession(w http.ResponseWriter, r *http.Request, id string) bool { + missing, err := s.sessionMissing(r.Context(), id) + if err != nil { + s.log.Error("history messages session lookup", "err", err, "session", id) + writeError(w, apperr.Wrap(apperr.ErrInternal, "messages_failed", "failed to list messages")) + return true + } + if !missing { + return false + } + writeError(w, apperr.Wrap(apperr.ErrNotFound, sessionNotFoundCode, "session %q not found", id)) + return true +} diff --git a/internal/services/gateway/history_session_guard_test.go b/internal/services/gateway/history_session_guard_test.go new file mode 100644 index 0000000..495547b --- /dev/null +++ b/internal/services/gateway/history_session_guard_test.go @@ -0,0 +1,173 @@ +package gateway + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" + "git.yeij.top/AskaEth/Cyrene_v2/internal/config" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" +) + +// 本文件钉住契约 v0.63 的 `GET /api/v1/sessions/{id}/messages` 语义(客户端登记, +// 2026-10-05): +// +// - **不存在 / 已删除**的会话 ⇒ `404 {error:{code:"session_not_found"}}` +// (与 `PATCH /sessions/{id}` / `POST /sessions/{id}/read` 对未知会话的口径 +// 一致),客户端因此能把「当前会话已经不在了」与「这是一条空会话」分开; +// - **存在但没有消息** ⇒ 仍是 `200 {"data":[]}`(空会话绝不能被答成 404); +// - `?q=` / `after` / 参数校验的既有语义不变(400 仍先于 404 —— 请求形状错了 +// 就是 400,与目标存不存在无关)。 + +// TestListMessagesUnknownSessionIs404 是本契约的主断言:一个从未存在过的会话 id +// 必须得到 404 + `session_not_found`,而不是「看起来像空会话」。 +func TestListMessagesUnknownSessionIs404(t *testing.T) { + t.Parallel() + _, ts, jwt, _ := newHistoryEnv(t) + tok := signAny(t, jwt, "acct", auth.RoleOwner) + + for _, id := range []string{"web:ffffffffffffffffffffffffffffffff", "web:never-existed"} { + resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+id+"/messages", nil, tok) + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("GET %s/messages status = %d, want 404 (body=%s)", id, resp.StatusCode, body) + } + wantErrorMessage(t, body, "session_not_found") + } +} + +// TestListMessagesDeletedSessionIs404 复现客户端的真实遭遇:一条被 DELETE 掉的次级 +// 会话(连同它的消息)此后必须报「不在了」,而不是回一个空数组让客户端以为它只是 +// 空的。 +func TestListMessagesDeletedSessionIs404(t *testing.T) { + t.Parallel() + _, ts, jwt, st := newHistoryEnv(t) + ctx := context.Background() + sub, err := st.NewSubSession(ctx, "owner") + if err != nil { + t.Fatalf("new sub session: %v", err) + } + if err := st.Append(ctx, history.Message{SessionID: sub, Role: history.RoleUser, Content: "临时验收会话", CreatedAt: 1000}); err != nil { + t.Fatalf("append: %v", err) + } + tok := signAny(t, jwt, "acct", auth.RoleOwner) + + // 删除前:200 且带那条消息(契约的另一半 —— 活着的会话照旧)。 + resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+sub+"/messages", nil, tok) + if resp.StatusCode != http.StatusOK { + t.Fatalf("删除前 status = %d, want 200 (body=%s)", resp.StatusCode, body) + } + var before []historyMessage + decodeDataSlice(t, body, &before) + if len(before) != 1 { + t.Fatalf("删除前 messages = %d 条, want 1", len(before)) + } + + if resp, body = doReq(t, http.MethodDelete, ts.URL+"/api/v1/sessions/"+sub, nil, tok); resp.StatusCode != http.StatusOK { + t.Fatalf("delete session status = %d, want 200 (body=%s)", resp.StatusCode, body) + } + + resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+sub+"/messages", nil, tok) + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("删除后 status = %d, want 404 (body=%s)", resp.StatusCode, body) + } + wantErrorMessage(t, body, "session_not_found") +} + +// TestListMessagesEmptySessionIs200Empty 是**回归**:真会话就是没有消息时, +// 必须仍然是 200 + 空数组 —— 契约只把「不存在」变成 404,绝不把「空会话」也卷进去。 +// 主会话(不可删、永远存在)与刚建好的次级会话各测一次。 +func TestListMessagesEmptySessionIs200Empty(t *testing.T) { + t.Parallel() + _, ts, jwt, st := newHistoryEnv(t) + sub, err := st.NewSubSession(context.Background(), "owner") + if err != nil { + t.Fatalf("new sub session: %v", err) + } + tok := signAny(t, jwt, "acct", auth.RoleOwner) + + for _, id := range []string{history.MainSessionID, sub} { + resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+id+"/messages", nil, tok) + if resp.StatusCode != http.StatusOK { + t.Fatalf("空会话 %s status = %d, want 200 (body=%s)", id, resp.StatusCode, body) + } + var page []historyMessage + decodeDataSlice(t, body, &page) + if len(page) != 0 { + t.Fatalf("空会话 %s messages = %+v, want 空数组", id, page) + } + } +} + +// TestListMessagesUnknownSessionKeepsParamChecks 钉住判定顺序:请求形状错了就是 +// 400(与目标会话存不存在无关),形状对了才轮到 404。`?q=` / `after` 的既有语义 +// 因此在未知会话上也不会退化成一个含糊的 404。 +func TestListMessagesUnknownSessionKeepsParamChecks(t *testing.T) { + t.Parallel() + _, ts, jwt, _ := newHistoryEnv(t) + tok := signAny(t, jwt, "acct", auth.RoleOwner) + base := ts.URL + "/api/v1/sessions/web:never-existed/messages" + + for _, tc := range []struct { + name string + query string + want int + }{ + {"非法 limit", "?limit=abc", http.StatusBadRequest}, + {"limit 越界", "?limit=0", http.StatusBadRequest}, + {"非法 before", "?before=abc", http.StatusBadRequest}, + {"before 与 after 互斥", "?before=1000&after=500", http.StatusBadRequest}, + {"q 与 after 互斥", "?q=x&after=500", http.StatusBadRequest}, + {"形状合法 ⇒ 404", "?q=x", http.StatusNotFound}, + {"after 形状合法 ⇒ 404", "?after=500", http.StatusNotFound}, + } { + resp, body := doReq(t, http.MethodGet, base+tc.query, nil, tok) + if resp.StatusCode != tc.want { + t.Fatalf("%s: status = %d, want %d (body=%s)", tc.name, resp.StatusCode, tc.want, body) + } + } +} + +// TestSessionMissingWithoutLookupSurfaceIsNotMissing 钉住「判不出来 ≠ 不存在」: +// 存储没有单会话读取面时(测试替身 / 未接库的窄存储),读路径保持旧的空集合语义 +// —— 把一次本可成功的读答成 404 比答成空列表更糟。 +func TestSessionMissingWithoutLookupSurfaceIsNotMissing(t *testing.T) { + t.Parallel() + hist := &fakeHistory{} + srv, err := New(cfgDefault(), Deps{Auth: auth.New(config.AuthConfig{JWTSecret: testSecret}), History: hist}) + if err != nil { + t.Fatalf("构造网关失败: %v", err) + } + t.Cleanup(srv.Close) + missing, err := srv.sessionMissing(context.Background(), "web:whatever") + if err != nil { + t.Fatalf("sessionMissing: %v", err) + } + if missing { + t.Fatal("没有存在性判据的存储不得被判成「会话不存在」") + } +} + +// wantErrorMessage 断言错误信封的 code 与 message 都成形(客户端按 code 分支)。 +func wantErrorMessage(t *testing.T, body []byte, code string) { + t.Helper() + env := decodeBody(t, body) + raw, ok := env["error"] + if !ok { + t.Fatalf("响应里没有 error 信封 (body=%s)", body) + } + var eobj struct { + Code string `json:"code"` + Message string `json:"message"` + } + if err := json.Unmarshal(raw, &eobj); err != nil { + t.Fatalf("解码 error 信封: %v (body=%s)", err, body) + } + if eobj.Code != code { + t.Fatalf("error.code = %q, want %q", eobj.Code, code) + } + if eobj.Message == "" { + t.Fatalf("error.message 为空 (body=%s)", body) + } +} diff --git a/internal/services/gateway/session_document_reason_test.go b/internal/services/gateway/session_document_reason_test.go new file mode 100644 index 0000000..6d22927 --- /dev/null +++ b/internal/services/gateway/session_document_reason_test.go @@ -0,0 +1,211 @@ +package gateway + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" + "git.yeij.top/AskaEth/Cyrene_v2/internal/config" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" +) + +// 本文件是契约 v0.63 的 **HTTP 层端到端**回归:`documents[].reason`(「读不到」 +// 的原因)从**真实文件**出发,经真实摄取层 → 真实 loop 装配期 → 回合后回写 → +// 历史读接口,一路抵达客户端;且**只在读不到时**出现、**不含任何路径**。 +// +// 与 session_document_e2e_test.go(S3.5 的结论回写)的分工:那边用假 runner 验证 +// 「每个附件都落库 + 结论都回来」,这里用**真实 loop + 真实摄取层**验证新增的 +// reason 字段本身的产生与运送,并额外钉住「extracted 时该键缺席」。 + +// reasonLeakMarker 是反证用的**独特**目录名:它只出现在真实盘上路径里, +// 因此「响应体里搜不到它」就是「原因文案没有把路径透给客户端」的直接证据。 +const reasonLeakMarker = "cyrene-reason-leak-marker" + +// reasonStubAdapter 是最小模型适配器:记下请求、回一句文本,使真实 loop 能走完 +// 一个回合(与 documents 包的 recordingAdapter 同职责,网关侧独立一份)。 +type reasonStubAdapter struct{ reqs []model.Request } + +func (a *reasonStubAdapter) Name() string { return "reason-stub" } + +func (a *reasonStubAdapter) Chat(_ context.Context, req model.Request, _ model.Options) (model.Response, error) { + a.reqs = append(a.reqs, req) + return model.Response{Model: "stub", Content: "看过了"}, nil +} + +func (a *reasonStubAdapter) ChatStream(ctx context.Context, req model.Request, _ func(model.StreamEvent)) (model.Response, error) { + return a.Chat(ctx, req, model.Options{}) +} + +// scanPDFBytes 现场拼出一份**合法但无文本层**的 PDF(一页;内容流只画一个矩形, +// 不含任何文本算子)—— 它就是「图片型/扫描件 PDF」在抽取器眼里的样子。 +// 不下载、不引入二进制资源(与 documents 包的 buildPDF 同一思路,这里只需这一种 +// 形态,故不搬那套完整夹具)。 +func scanPDFBytes(t *testing.T) []byte { + t.Helper() + // 页内容流:q 保存状态 → 设填充色 → 画矩形 → Q 恢复状态。没有任何 Tj/TJ。 + content := "q 0 0 1 rg 100 100 200 200 re f Q" + objs := []string{ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R >>", + fmt.Sprintf("<< /Length %d >>\nstream\n%s\nendstream", len(content), content), + } + var b bytes.Buffer + b.WriteString("%PDF-1.4\n") + offsets := make([]int, len(objs)+1) + for i, body := range objs { + offsets[i+1] = b.Len() + fmt.Fprintf(&b, "%d 0 obj\n%s\nendobj\n", i+1, body) + } + xrefAt := b.Len() + fmt.Fprintf(&b, "xref\n0 %d\n0000000000 65535 f \n", len(objs)+1) + for i := 1; i <= len(objs); i++ { + fmt.Fprintf(&b, "%010d 00000 n \n", offsets[i]) + } + fmt.Fprintf(&b, "trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(objs)+1, xrefAt) + return b.Bytes() +} + +// documentReasonEnv 搭一套**全真**的装配:真数据目录、真摄取层、真 loop、真 +// 历史存储、真 HTTP 服务。返回服务地址、token 与两份文档的存储名。 +func documentReasonEnv(t *testing.T) (baseURL, tok, scanName, textName, dataDir string) { + t.Helper() + dataDir = filepath.Join(t.TempDir(), reasonLeakMarker) + scanName = storeGatewayDocument(t, dataDir, string(scanPDFBytes(t)), ".pdf") + textName = storeGatewayDocument(t, dataDir, "季度报告:营收增长 12%。\n", ".txt") + + resolver := documents.NewResolver(documents.Config{ + DataDir: dataDir, + UploadsDir: filepath.Join(dataDir, "uploads", documentSubDir), + }) + ag, err := agent.New("你是助手。", nil, nil, agent.WithModel(&reasonStubAdapter{}, nil)) + if err != nil { + t.Fatalf("agent.New: %v", err) + } + run := loop.New(loop.Options{Agent: ag, DocumentResolver: resolver, MaxRounds: 1}) + + jwt := auth.New(config.AuthConfig{JWTSecret: testSecret}) + cfg := &config.Config{Auth: config.AuthConfig{AdminUser: "admin", AdminPass: "secret", JWTSecret: testSecret}} + srv, err := New(cfg, Deps{Auth: jwt, DataDir: dataDir, Loop: run, History: newGatewayHistoryStore(t)}) + if err != nil { + t.Fatalf("构造网关失败: %v", err) + } + httpSrv := httptest.NewServer(srv.Handler()) + t.Cleanup(httpSrv.Close) + t.Cleanup(srv.Close) + tok, err = jwt.Sign("acct", auth.RoleOwner, time.Hour) + if err != nil { + t.Fatalf("签发 token 失败: %v", err) + } + return httpSrv.URL, tok, scanName, textName, dataDir +} + +// TestDocumentReasonReachesMessagesWire 是本契约 ① 的端到端断言:扫描件 PDF ⇒ +// `status:"unreadable"` **且**带回原因(文案含「OCR」);纯文本 ⇒ `extracted` 且 +// **没有** `reason` 键;整份响应体里搜不到任何路径(反证见 reasonLeakMarker)。 +func TestDocumentReasonReachesMessagesWire(t *testing.T) { + t.Parallel() + ts, tok, scanName, textName, _ := documentReasonEnv(t) + + resp, body := doReq(t, http.MethodPost, ts+"/api/v1/chat", map[string]any{ + "sessionId": history.MainSessionID, + "text": "这两份都看看", + "documents": []chatDocumentRef{{Name: scanName}, {Name: textName}}, + }, tok) + if resp.StatusCode != http.StatusOK { + t.Fatalf("chat status = %d, want 200 (body=%s)", resp.StatusCode, body) + } + + resp, body = doReq(t, http.MethodGet, ts+"/api/v1/sessions/"+history.MainSessionID+"/messages", nil, tok) + if resp.StatusCode != http.StatusOK { + t.Fatalf("messages status = %d, want 200 (body=%s)", resp.StatusCode, body) + } + docs := userMessageDocuments(t, body, "这两份都看看") + if len(docs) != 2 { + t.Fatalf("历史接口返回的 documents = %+v, want 2 条", docs) + } + + got := map[string]map[string]any{} + for _, d := range docs { + name, _ := d["name"].(string) + got[name] = d + } + scan, ok := got[scanName] + if !ok { + t.Fatalf("扫描件的引用不在文档数组里: %+v", docs) + } + if scan["status"] != history.DocumentStatusUnreadable { + t.Fatalf("扫描件 status = %v, want unreadable(这份 PDF 没有文本层)", scan["status"]) + } + reason, ok := scan["reason"].(string) + if !ok || reason == "" { + t.Fatalf("扫描件的 documents[].reason 缺失或不是字符串: %+v(契约 v0.63 要求读不到时给出原因)", scan) + } + if !strings.Contains(reason, "OCR") { + t.Fatalf("扫描件 reason = %q, want 含「OCR」(需要 OCR 才是这份 PDF 的真实原因)", reason) + } + + text, ok := got[textName] + if !ok { + t.Fatalf("纯文本的引用不在文档数组里: %+v", docs) + } + if text["status"] != history.DocumentStatusExtracted { + t.Fatalf("纯文本 status = %v, want extracted", text["status"]) + } + if _, present := text["reason"]; present { + t.Fatalf("extracted 的文档不得带 reason(该键必须缺席): %+v", text) + } + + // 反证:原因文案(乃至整份响应)里没有任何路径信息。marker 只存在于盘上路径 + // (`/cyrene-reason-leak-marker/uploads/documents/…`),一旦原始错误 + // (「open …\cyrene-reason-leak-marker\uploads\documents\….pdf:」)被透出,这里 + // 必然命中。**刻意不断言「不含 `/`」**:诚实原因文案本身就用它分隔 + // 「图片型/无文本层」,那是文案的一部分,不是路径。 + if strings.Contains(string(body), reasonLeakMarker) { + t.Fatalf("响应体里出现了盘上路径片段: %s", body) + } + for _, leak := range []string{reasonLeakMarker, "uploads", string(filepath.Separator) + "documents"} { + if strings.Contains(reason, leak) { + t.Fatalf("reason = %q 含路径片段 %q,原因文案里不得有路径", reason, leak) + } + } + if strings.Contains(reason, scanName) { + t.Fatalf("reason = %q 含存储名之外的引用信息(存储名本身已在同一元素的 name 键里)", reason) + } +} + +// userMessageDocuments 取出内容为 content 的那条用户消息的 documents 数组(**按 +// 原始 JSON 对象**解,使「键到底在不在」可断言 —— 解进结构体会把「缺席」与 +// 「空串」抹成同一个值)。 +func userMessageDocuments(t *testing.T, body []byte, content string) []map[string]any { + t.Helper() + var envelope struct { + Data []struct { + Role string `json:"role"` + Content string `json:"content"` + Documents []map[string]any `json:"documents"` + } `json:"data"` + } + if err := json.Unmarshal(body, &envelope); err != nil { + t.Fatalf("unmarshal messages: %v (body=%s)", err, body) + } + for _, m := range envelope.Data { + if m.Role == history.RoleUser && m.Content == content { + return m.Documents + } + } + t.Fatalf("历史里没有找到内容为 %q 的用户消息 (body=%s)", content, body) + return nil +} diff --git a/internal/services/gateway/session_document_status.go b/internal/services/gateway/session_document_status.go index d91927b..6900f45 100644 --- a/internal/services/gateway/session_document_status.go +++ b/internal/services/gateway/session_document_status.go @@ -16,7 +16,7 @@ import ( // 列 —— 两段式是刻意的,也是客户端能看到「昔涟读不到这个文件」的唯一路径。 // // 三条纪律: -// - 只补结论(status/chars/truncated),身份与大小逐字保留; +// - 只补结论(status/chars/truncated 与 unreadable 的 reason),身份与大小逐字保留; // - 按**服务端存储名**匹配,匹配不到就原样留着(凭空给它一个结论就是编造); // - 找不到行、存储未实现该视图、写库失败一律带来源 WARN,绝不静默。 // @@ -103,6 +103,9 @@ func (o *Orchestrator) takeTurnDocuments(sessionID string, gen uint64) ([]histor // 消息的 document 列(design/24 §4.4/§6.1)。它由 completeTurn 在助手回复落库 // 之后、状态机迁移之前调用,因此结论与该回合的对话内容一起同批入账。 // +// 回写的是 status/chars/truncated 与 unreadable 的原因文案(reason,契约 +// v0.63)—— 之后所有读取面(历史接口 / 握手回放)拿到的是同一份结论。 +// // 找不到对应行(ErrMessageNotFound)、存储未接该视图、列被写坏、写库失败时都 // **不静默**:打一条带来源的 WARN 说明「状态未回写」。日志只带会话、消息 id、 // 条数与错误,**绝不带正文**,也不带列内容。 @@ -151,10 +154,13 @@ func (o *Orchestrator) persistDocumentStatus(sessionID string, gen uint64, res * } // mergeDocumentStatus 把本回合解析到的结论按存储名并进已落库的引用数组:匹配到 -// 的补上 status/chars/truncated,没匹配到的原样保留(那意味着本回合没有解析到 -// 它,凭空给它一个结论就是编造)。第二个返回值报告是否真的补进了新结论 —— +// 的补上 status/chars/truncated/reason,没匹配到的原样保留(那意味着本回合没有 +// 解析到它,凭空给它一个结论就是编造)。第二个返回值报告是否真的补进了新结论 —— // 没变化就不必写库、也不必刷一条「什么都没变」的日志。 // +// reason 与 status 同批搬运(契约 v0.63):抽取结论的这三项 + 原因文案是同一个 +// 回合的同一份事实,分两次写会让客户端看到「unreadable 但没有理由」的中间态。 +// // 列被写坏时返回包装了 history 哨兵的错误,由调用方按「不静默」处理。 func mergeDocumentStatus(raw string, resolved []history.DocumentRef) (string, bool, error) { refs, err := history.DecodeDocuments(raw) @@ -173,10 +179,12 @@ func mergeDocumentStatus(raw string, resolved []history.DocumentRef) (string, bo if !ok { continue } - if refs[i].Status == got.Status && refs[i].Chars == got.Chars && refs[i].Truncated == got.Truncated { + if refs[i].Status == got.Status && refs[i].Chars == got.Chars && + refs[i].Truncated == got.Truncated && refs[i].Reason == got.Reason { continue } - refs[i].Status, refs[i].Chars, refs[i].Truncated = got.Status, got.Chars, got.Truncated + refs[i].Status, refs[i].Chars, refs[i].Truncated, refs[i].Reason = + got.Status, got.Chars, got.Truncated, got.Reason changed = true } if !changed { diff --git a/internal/services/gateway/session_document_status_test.go b/internal/services/gateway/session_document_status_test.go index b10c450..f905b7b 100644 --- a/internal/services/gateway/session_document_status_test.go +++ b/internal/services/gateway/session_document_status_test.go @@ -114,12 +114,15 @@ func TestMergeDocumentStatus(t *testing.T) { raw, err := history.EncodeDocuments([]history.DocumentRef{ {Name: "aaa.txt", MIME: "text/plain", Size: 1}, {Name: "bbb.pdf", MIME: "application/pdf", Size: 2}, + {Name: "ccc.txt", MIME: "text/plain", Size: 3}, }) if err != nil { t.Fatalf("encode: %v", err) } + const scanReason = "无法抽取正文:这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)" merged, changed, err := mergeDocumentStatus(raw, []history.DocumentRef{ {Name: "aaa.txt", Status: history.DocumentStatusExtracted, Chars: 42, Truncated: true}, + {Name: "bbb.pdf", Status: history.DocumentStatusUnreadable, Reason: scanReason}, }) if err != nil { t.Fatalf("merge: %v", err) @@ -131,17 +134,26 @@ func TestMergeDocumentStatus(t *testing.T) { if err != nil { t.Fatalf("decode merged: %v", err) } - if len(refs) != 2 { - t.Fatalf("refs = %+v, want 2 条(没匹配到的原样保留)", refs) + if len(refs) != 3 { + t.Fatalf("refs = %+v, want 3 条(没匹配到的原样保留)", refs) } if refs[0].Status != history.DocumentStatusExtracted || refs[0].Chars != 42 || !refs[0].Truncated || refs[0].MIME != "text/plain" || refs[0].Size != 1 { t.Fatalf("refs[0] = %+v, want 结论补上且身份不变", refs[0]) } - if refs[1].Status != "" || refs[1].Name != "bbb.pdf" { - t.Fatalf("refs[1] = %+v, want 原样保留(本回合没解析到它)", refs[1]) + // v0.63:reason 与 status 同批回写,客户端因此不会看到「unreadable 但没有理由」。 + if refs[1].Status != history.DocumentStatusUnreadable || refs[1].Reason != scanReason { + t.Fatalf("refs[1] = %+v, want unreadable + 原因文案", refs[1]) + } + if refs[2].Status != "" || refs[2].Reason != "" || refs[2].Name != "ccc.txt" { + t.Fatalf("refs[2] = %+v, want 原样保留(本回合没解析到它)", refs[2]) } - if _, changed, err := mergeDocumentStatus(merged, []history.DocumentRef{{Name: "aaa.txt", Status: history.DocumentStatusExtracted, Chars: 42, Truncated: true}}); err != nil || changed { + // 结论与原因都补齐后,同一批再合一次必须报「无变化」——reason 也是变化判据的 + // 一部分(否则每回合都会因为空 reason 而重写一次)。 + if _, changed, err := mergeDocumentStatus(merged, []history.DocumentRef{ + {Name: "aaa.txt", Status: history.DocumentStatusExtracted, Chars: 42, Truncated: true}, + {Name: "bbb.pdf", Status: history.DocumentStatusUnreadable, Reason: scanReason}, + }); err != nil || changed { t.Fatalf("changed = %v, err = %v, want 无变化且无错(不必写库)", changed, err) } if _, _, err := mergeDocumentStatus("{not json", nil); !errors.Is(err, history.ErrBadDocuments) {