feat(api): documents[].reason + messages 对不存在会话回 404(安卓回执九两条;契约 v0.63)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- ① 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:<uuid>),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 全绿,判为瞬时;已记入账本,后续留意。
This commit is contained in:
AskaEth committed 2026-10-04 21:50:08 +08:00
1 parent c1fa1e9b9a
commit 902b6eecfd
13 files changed
+779 -21

No files matched your search

+22 -3
View File
@@ -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` 才解析 `<p>` 拆气泡)。`clientMsgId`(v0.56,G4)为**客户端自带的幂等键**(可选):同一会话内同一个非空值只承认第一条,重复到达回 `200 {data:{duplicate:true,messageId,sessionId}}` 且**不落库、不起回合、不发新内容帧**;省略 = 与加入该字段前逐字相同(详见 §3.13)。**会话编排(design/15)**:非流式且该会话「正忙」时**不阻塞**,返回 `202` `{data:{queued:true,queueDepth}}`(稍后按上下文续接);待处理队列满时返回 `409` `{error:{code:"chat_busy"}}`。**`stream:true` 响应面(v0.56,G2,行为变更)**:受理成功后才回——空闲已起回合 = `200 {data:{stream:true}}`、正忙已续接排队 = `202 {data:{stream:true,queued:true,queueDepth}}`、队列满 = `409 {error:{code:"chat_busy"}}`、幂等键重复 = `200 {data:{duplicate:true,messageId,sessionId}}`;**未接 loop 改为 `500 {error:{code:"chat_unavailable"}}`**(旧文写 `200 {stream:true}`,现与同步路径同口径);仅当**未接编排器**才回退总线派发,其失败回 `503 {error:{code:"chat_dispatch_failed"}}`——**不再有「先回 200 再看有没有落地」**,故 `202`/`409` 从此**真的会出现**(修复前因提前 return 永不出现) |
| 4 | `/api/v1/sessions` | GET | JWT | ✅ | `{data:[session...]}`。**会话持久化(design/16)**:注入 HistoryStore 后返回持久化会话元数据 `{data:[{id,type("main"|"sub"),title?,createdAt,pinned,unreadCount,lastMessage?,personaId?}]}`(**排序(D5):以代码为准 = `pinned DESC, (type='main') DESC, created_at ASC`**——`pinned=true` 最前,其次是未置顶的主会话,最后是未置顶子会话(按 `createdAt` 升序);⚠️ **置顶的次级会话会排在主会话之前**,客户端定位主会话请按 `id=="web"` / `type=="main"`,**不要假定 index 0 就是主会话**;次级 `web:<uuid>`;`pinned` 恒返回 bool;**`unreadCount`(D4)恒返回整数**——该会话中 `createdAt >` 已读游标的消息条数(0 = 无未读),与 `lastMessage` **同一条 SQL 派生**、无 N+1;`lastMessage` 为**最后一条消息预览** `{content,role(user|assistant),createdAt}`,content 折叠空白截前 60 rune,空会话省略;**`personaId`(v0.42,可选)**=该会话绑定的角色 id(design/22 §2/§4.5),**空/缺省 = 继承全局默认角色**(`owner.yaml:defaultPersona`),客户端据此渲染激活会话的角色名/头像);未注入时退化回渠道会话列表 |
| 5 | `/api/v1/sessions` | POST | JWT(owner/admin) | ✅ | **新建次级会话** → `201 {data:{sessionId,personaId?}}`(`sessionId="web:<uuid>"`)。body **可选**(可完全省略或空 body):`{personaId?}`(v0.42)在创建时就把新会话绑到该角色(design/22 §2:渠道级只作"新会话的初始角色提供者";**会话角色只在创建时写入、之后不可变**,见 §1 行 8 / design/22 §8-1);`personaId` 为**未知角色**回 `400 bad_request`;省略/空 = 不绑(`persona_id=''`,继承全局默认);**`personaId` 非空时响应回显它**(省略时响应形状与 v0.41 完全一致);未注入 HistoryStore 回 `501 not_implemented`;注入了**不支持角色绑定**的 HistoryStore 且显式传 `personaId` 时也回 `501`(不假装写入) |
| 6 | `/api/v1/sessions/{id}/messages` | GET | JWT(owner/admin) | ✅ | **热分页拉历史**:`?limit=&before=&platform=&after=`(A5)默认 `limit=50`(上限 200)。**`before`**=上一页最旧 `createdAt`(毫秒),按时间**倒序**;**`after`**=<`createdAt` 毫秒>,按时间**升序**返回 `created_at > after` 的消息(**正向增量**,客户端把上次看到的 `createdAt` 当游标补断线期间漏的,解决重连只回放 50 条的缺口);**`after` 与 `before` 互斥**,同传回 `400`;负数/非数字回 `400`。`platform` 为**可选**来源平台过滤(design/16 §4.4)——Web 端不传(=全量共享主会话),IM 适配器传**本平台**(如 `telegram`)只拉本渠道消息;`deviceId` 为**客户端具体端标识**;`senderName` 为**第三方发送者昵称**;**`personaId`(v0.42,可选)**=**该消息说话时的角色 id**(design/22 §7-5:逐条记录"谁说的"——会话角色在创建时固定,§8-1;assistant 消息 = 生成它的角色,user 消息 = 当回合角色;**空 = 全局默认角色**,即 v0.42 之前的全部历史消息),客户端据此按角色渲染名字/头像;**`clientMsgId`(v0.56,可选,G4)**=该消息的**客户端幂等键**(带 `clientMsgId` 发的 user 消息会回填它;空则省略),客户端据此把本地乐观气泡对齐到服务端真相;**`q`**(D2)=**会话内内容搜索**(大小写不敏感 ASCII LIKE,`%`/`_`/`\` 已转义为字面量),仍按时间**倒序**、`before`/`limit` 同分页语义;`q` 与 `after` **互斥**(同传 `400`),空 `q` 视为未传(走普通分页),搜索**严格限定在 `{id}` 会话内**(绝不跨会话,群聊/陌生人隔离会话不会外泄);非法 `limit`/`before`/`after` 回 `400`;未注入 HistoryStore 回 `501` |
| 6 | `/api/v1/sessions/{id}/messages` | GET | JWT(owner/admin) | ✅ | **热分页拉历史**:`?limit=&before=&platform=&after=`(A5)默认 `limit=50`(上限 200)。**`before`**=上一页最旧 `createdAt`(毫秒),按时间**倒序**;**`after`**=<`createdAt` 毫秒>,按时间**升序**返回 `created_at > after` 的消息(**正向增量**,客户端把上次看到的 `createdAt` 当游标补断线期间漏的,解决重连只回放 50 条的缺口);**`after` 与 `before` 互斥**,同传回 `400`;负数/非数字回 `400`。`platform` 为**可选**来源平台过滤(design/16 §4.4)——Web 端不传(=全量共享主会话),IM 适配器传**本平台**(如 `telegram`)只拉本渠道消息;`deviceId` 为**客户端具体端标识**;`senderName` 为**第三方发送者昵称**;**`personaId`(v0.42,可选)**=**该消息说话时的角色 id**(design/22 §7-5:逐条记录"谁说的"——会话角色在创建时固定,§8-1;assistant 消息 = 生成它的角色,user 消息 = 当回合角色;**空 = 全局默认角色**,即 v0.42 之前的全部历史消息),客户端据此按角色渲染名字/头像;**`clientMsgId`(v0.56,可选,G4)**=该消息的**客户端幂等键**(带 `clientMsgId` 发的 user 消息会回填它;空则省略),客户端据此把本地乐观气泡对齐到服务端真相;**`q`**(D2)=**会话内内容搜索**(大小写不敏感 ASCII LIKE,`%`/`_`/`\` 已转义为字面量),仍按时间**倒序**、`before`/`limit` 同分页语义;`q` 与 `after` **互斥**(同传 `400`),空 `q` 视为未传(走普通分页),搜索**严格限定在 `{id}` 会话内**(绝不跨会话,群聊/陌生人隔离会话不会外泄);非法 `limit`/`before`/`after` 回 `400`;未注入 HistoryStore 回 `501`。**(v0.63,行为变更)会话不存在 / 已删除 ⇒ `404 {"error":{"code":"session_not_found","message":"session \"<id>\" not found"}}`**——与 `PATCH /sessions/{id}`、`POST /sessions/{id}/read` 对未知会话的口径一致,客户端据此把「当前会话已经不在了」(退回主会话 + 一句提示)与「这是一条空会话」分开;**存在但确实没有消息仍回 `200 {"data":[]}`**。⚠️ **判据是会话注册表 `chat_sessions`**:隔离的 IM 会话(群聊/陌生人的 `sess-xxx`、内部 `sess-backthink`)**不在其中**,因此与 `POST /sessions/{id}/read` 拿到同一个答案(`404`)——它们的消息请走 `GET /api/v1/admin/channels/{platform}/messages`。⚠️ 请求形状(`limit`/`before`/`after` 非法、`before`+`after` 同传、`q`+`after` 同传)**仍先于** 404 判定(形状错了就是 `400`,与目标存不存在无关) |
| 6a | `/api/v1/messages/{id}` | PATCH | JWT(owner/admin) | ✅ | **编辑单条消息**(D3):body `{content}`(非空、≤4000 rune,空/纯空白/超长回 `400`)→ **`{data:<消息DTO>}`**(`data` **直接就是**更新后的完整消息 DTO,含 `id/sessionId/role/content/.../createdAt`,**无 `message` 包一层**;**只改 `content`**,role/时间戳/平台保持不变);消息不存在回 `404 not_found`;未注入 HistoryStore 回 `501`。**成功后广播下行 `message_edited`**(§3.8,账号其它端免刷新同步)。**重发不单独设端点**——客户端用「编辑后的内容再 `POST /chat`」即可(避免重复轮次语义) |
| 6b | `/api/v1/messages/{id}` | DELETE | JWT(owner/admin) | ✅ | **删除单条消息**(D3)→ `{data:{deleted:<id>}}`;不存在回 `404 not_found`;未注入 HistoryStore 回 `501`。**成功后广播下行 `message_deleted`**(§3.8,账号其它端按 `id` 移除) |
| 7 | `/api/v1/sessions/{id}` | DELETE | JWT(owner/admin) | ✅ | **删除次级会话及其消息** → `{data:{deleted:<id>}}`;主会话不可删回 `403 forbidden`;未注入 HistoryStore 回 `501` |
@@ -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,需Line truncated
## 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 的既有Line truncated
- `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` 之外的占用仍要下钻到备份/文件系统看)。