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