From c1fa1e9b9adfb55e409148c84a2a34d4d48065c1 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 4 Oct 2026 21:33:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E9=9D=A2=E6=9D=BF=E3=80=8C?= =?UTF-8?q?=E9=87=8D=E7=BD=AE=E4=B8=BA=20env=20=E9=BB=98=E8=AE=A4=E3=80=8D?= =?UTF-8?q?=E5=8A=A8=E4=BD=9C=EF=BC=88materialize=20=E7=9A=84=E5=8F=8D?= =?UTF-8?q?=E6=96=B9=E5=90=91=EF=BC=89+=20=E5=A5=91=E7=BA=A6=20v0.62?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 语义:只把 .env **确实声明过**的键覆盖回 env 值;env 未声明的键(fallback/multimodal/maxTokens/reasoning/personaModels)原样保留;**组集合不增减**(不复活被面板删的组、不删面板新增的组);无键可改时不写盘。成功即热重载 LLM 栈。返回 {reset:[{group,keys}], skipped:[{group,reason}], path},**只回键名、不回显凭据值**。 - 护栏:必须 confirm:true(缺失/false/非法 body ⇒ 400,且**先于任何读写**);**owner-only**(与同页 POST /admin/llm、/admin/llm/test 同门 —— 这是破坏性写,门不比同族更宽);审计动作 llm.resetFromEnv(camelCase,全仓多词动作的既有约定)。失败路径明确报错(env_defaults_unreadable / models_unreadable / llm_unavailable),不假成功。 - 组合根注入 Deps.EnvPath(网关不 fopen .env):请求时按既有解析入口重读,语义 =「退回 .env 现在声明的默认值」;.env 层里的 provider 是同义反复,不计入「env 为该组声明了什么」的判定(有专门用例)。 - 前端:LLM 页「重置为 env 默认」卡片(中文二次确认 → 提交 → 按 reset/skipped 渲染 → 成功后重拉配置),标签 owner 专属(与 System.tsx 同款)。 - 契约 v0.62:api-client(权限矩阵改回 12+10+38=60、端点表 9b、重置契约小节、写操作契约、changelog、banner)与 api-reference(端点表 #70、§5.13a 详情含 curl、§8.2/§8.3/§8.4)同步;字段名三方逐字对齐。 - 主会话独立验收:隔离 worktree(R1 的 19 文件)build/vet 绿、gateway+config 三包绿、R1 用例 10 PASS、reset_from_env 残留 0;**变异亲自复现**(去掉 confirm 校验 ⇒ RequiresConfirm 红:status = 200, want 400,且响应体显示面板配置真的被改)。 - 子代理自报并已处理:测试文件触 500 行上限 ⇒ 拆成语义侧 + 护栏侧两份;门改回 owner-only 后前端标签会误导 admin ⇒ 改「owner 专属」并重建 bundle。任务书本身仍是旧口径(owner|admin / snake_case)—— 已由主会话登记修正说明,避免压缩后有人按任务书回退。 - 未做(诚实边界):不支持按组重置;不纳入 channels.json/plugins.json;不自动重启(contextWindow 的预算推导仍只在启动时算一次)。新端点需下一次 build+restart 才在 :10942 生效。 --- Docs/api-client.md | 33 +- Docs/api-reference.md | 34 +- cmd/cyrene/wire_frontend.go | 4 + frontend/web/src/admin/modules/Llm.tsx | 5 + .../src/admin/modules/llm/LlmResetFromEnv.tsx | 103 ++++++ frontend/web/src/lib/api.ts | 1 + frontend/web/src/lib/api/admin.ts | 10 + frontend/web/src/lib/types/adminWrite.ts | 20 ++ internal/config/llm_defaults.go | 37 ++ internal/config/llm_defaults_test.go | 81 +++++ internal/config/runtimeconfig/reset.go | 166 +++++++++ internal/config/runtimeconfig/reset_test.go | 280 +++++++++++++++ .../services/gateway/admin_write_llm_reset.go | 122 +++++++ .../admin_write_llm_reset_guard_test.go | 206 +++++++++++ .../gateway/admin_write_llm_reset_test.go | 326 ++++++++++++++++++ internal/services/gateway/deps.go | 5 + internal/services/gateway/rbac_viewer_test.go | 1 + internal/services/gateway/routes.go | 5 + internal/services/gateway/server.go | 6 +- 19 files changed, 1434 insertions(+), 11 deletions(-) create mode 100644 frontend/web/src/admin/modules/llm/LlmResetFromEnv.tsx create mode 100644 internal/config/llm_defaults.go create mode 100644 internal/config/llm_defaults_test.go create mode 100644 internal/config/runtimeconfig/reset.go create mode 100644 internal/config/runtimeconfig/reset_test.go create mode 100644 internal/services/gateway/admin_write_llm_reset.go create mode 100644 internal/services/gateway/admin_write_llm_reset_guard_test.go create mode 100644 internal/services/gateway/admin_write_llm_reset_test.go diff --git a/Docs/api-client.md b/Docs/api-client.md index 744f484..a144a7b 100644 --- a/Docs/api-client.md +++ b/Docs/api-client.md @@ -196,7 +196,7 @@ > **发现的不一致(如实登记,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.61`**(**PDF 文本层抽取落地:白名单第 23 项 `.pdf` + 类型裁决由「嗅探只放行 `text/plain`」改为 `(扩展名, 嗅探)` 成对裁决**,依据 design/24 §7 的 **S6 第一段**(PDF/Office 的裁决:PDF 文本层引库,扫描件与 Office 仍诚实拒绝)与 commit `f82c76d`,2026-10-04 落实)。**三档标注**:**行为变更** = ① **`.pdf` 由「一律 `415`」变为「可上传 + 可读取 + 文本层可抽取进上下文」**(把「`.pdf` 必被拒」写死的客户端必须改);② **类型裁决收紧为成对判定**(`documents.ContentAllowed`)——**方向是收紧/细化,不是放宽**:HTML/纯文本改名成 `.pdf` ⇒ `415`(嗅探不是 PDF),**PDF 字节改名成 `.txt` ⇒ `415`**(PDF 字节也只收在 `.pdf` 名下);**纯增量** = 扩展名白名单 **22 → 23 项**(唯一新增 `.pdf`),其余 22 个文本类的判定与既有响应**一字未变**。**A. 上传(`POST /api/v1/documents`,owner|admin)**——白名单加 `.pdf`;裁决 = **字节嗅探是唯一裁决权 ∧ 扩展名白名单**,自本版起**按对判定**(纯文本类必须 `text/plain`、`.pdf` 必须 `application/pdf`;落点 `internal/runtime/documents/store.go` 的 `ExtAllowed`(23 项)/ `ContentAllowed`);`mime` 仍是**嗅探基类型**(真 PDF → `application/pdf`);`415` 文案同时点明两条口径。**B. 读取(`GET /api/v1/documents/{name}`,必带 Bearer owner|admin)**——**真 PDF 如实下发 `Content-Type: application/pdf`**(读时按同一对裁决重嗅探,判不定者仍退 `application/octet-stream`),`Content-Disposition: attachment` + `X-Content-Type-Options: nosniff` **一字未变** ⇒ **PDF 永不被内联渲染**(与图片读侧刻意相反)。**C. 抽取语义(DTO `status`)**——**文本层 PDF 抽正文**(`Tj`/`TJ`/`'`/`"` 文本算子,页间以换行相接)⇒ `status:"extracted"`,与纯文本类走同一条 sidecar/注入路径;**扫描件/图片型 PDF ⇒ 诚实拒绝 `status:"unreadable"`**,原因含「这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)」,**绝不假装成功、也不返回空正文**;**加密**与**损坏/截断**是**可区分**的两类失败;安全阀 = 单份最多 5000 页 + sidecar 正文 ≤8 MiB。**D. 客户端可见的一次性重算**——抽取器版本内部升到 **`documents/v3`** ⇒ **旧 sidecar 一次性失效重抽**:同一份旧附件的 `chars`/`truncated` 结论会在**某次回合之后**重新计算(旧 PDF 若是文本层 PDF,将从 `unreadable` 变为 `extracted`)。**E. 未做(诚实边界)**——**不做 OCR / 扫描件栅格化**(纯 Go 无可靠实现,会撞「单二进制 + 交叉编译」「零跨进程 IPC」两条红线)、**不做 PDF 写/生成**(只读)、**不做 `.docx`/`.xlsx` 等 Office**(仍诚实拒绝);**无新端点、无新 WS 帧、无鉴权变更、无 `.env` 新增键**。**实现落点**:`internal/runtime/documents/{extract_pdf.go,extract.go,store.go,sidecar.go}` + 依赖 `github.com/ledongthuc/pdf`(纯 Go、无 cgo)。**上一版 `v0.60`**(**管理侧只读「存储状态」端点 + 面板存储卡接真数据**,依据主人 2026-10-04 实测反馈(面板首页「存储」卡一直是占位「SQLite」)与 design/10 §3 模块 14(系统设置)/ design/03 §3(pgvector 可选增强),2026-10-04 落实):**A. 新增 `GET /api/v1/admin/system/storage`**(**owner-only**、只读、**无审计动作**)→ `200 {data:{main,documents,vector}}`——① `main` = `{kind:"sqlite",path,fileBytes,walBytes,shmBytes,pageSize,pageCount,freelistPages,freeBytes}`(`path` 与备份/恢复端点同源;`walBytes`/`shmBytes` 边车不存在时如实为 `0`;`freeBytes = pageSize × freelistPages`);② `documents` = `{count,bytes}`(读 `documents` 表,**不 walk 目录**;空表 `bytes` 为 `0` 而非 `null`);③ `vector` = `{kind:"pgvector"|"none",enabled,ready}`——`kind` 说「配没配 `DB_PG_URL`」,`ready` 说「现在真的在跑什么」(配了但连接/schema 准备失败 ⇒ `enabled:true` + `ready:false`,客户端显示「已配置但未就绪(检索已降级)」,**别把 `enabled` 当生效**)。**B. 两条纪律**:① **不含凭据**——DSN / 用户名 / 密码 / `postgres://` 全文**一个字都不回**(`vector` 段只有三个键;有用例断言响应体里搜不到这些串);② **只读**——只有 `SELECT`/`PRAGMA` + `os.Stat`,复用组合根注入的**活连接**(不新开连接池、不写、不迁移、不备份,调用前后 `documents` 行数与数据目录文件集合均不变)。**C. `vector` 的真源**:组合根 `cmd/cyrene/wire_frontend.go` 的 `app.vectorState()` —— `Configured` 取 `DB_PG_URL` 非空、`Live` 取活索引的具体类型(`*vector.PGVectorIndex`);网关**只读这份注入值**,不读环境变量、不新开 PG 连接、不自行探测(避免面板与运行时给出两个答案)。**D. 失败不伪装**:未接活连接 / 路径未配置 / 读不到页级统计或 `documents` 表(例如库没跑迁移)⇒ `500 {error:{code:"storage_unavailable",message}}`,**绝不回一个全 0 的假快照**。**E. 面板**:`frontend/web` 的存储卡改为真数据(`value` = `SQLite 12.3 MiB`;`hint` = WAL / 页数 / 空闲页;详情两行 = `documents N 件 · …` 与 `PG 增强 …`),取数走与其它已接线卡片**同一条**加载/错误态(数据没到就显示「端点报错:…」,不回退成 `0`)。**F. 兼容性**:**纯新增端点 + 纯新增前端行为**,既有端点 / WS 帧 / 字段形状**一字未动**(前端 `MiniMetricSpec` 新增的 `extra?: string[]` 为可选补充详情行,不传时渲染与旧版逐字等价)。**上一版 `v0.59`**:(**文档进对话(`F1/F2`)+ 上下文预算按窗口推导(`T1`)+ 语音回合进编排(`G5/C1/C3`)+ `thinking` 可达性口径**,依据 design/24 §3/§4/§6(切片 S1–S5)、design/04 §1.3 + 附录 A.3(主人裁决 2026-10-04)与 `Cyrene-For-Android/devdocs/02-前后端协同事项.md` 的 G5/C1/C3,commit `a8851dc` / `a4789ec` / `20c1edb` / `5b2b385` / `72b95df` / `3a01b4e`,2026-10-04 落实):**A. 文档存储与摄取(两个新端点 + 一个可选请求字段 = 纯增量;DTO 形状变更是破坏性,但单数形状从未发布,故无兼容期)**——① **新增 `POST /api/v1/documents`**(owner|admin,`multipart/form-data`,字段 `file`)→ 首次 `201 {data:{name,url,mime,size,sha256,deduped:false}}`;**同字节同扩展名重复上传 → `200` + 同一 `name` + `deduped:true`**(客户端可当成功处理);单文件上限 **`CHAT_DOCUMENT_MAX_BYTES`(默认 20 MiB = 20971520)**,`≤0` 视为用默认;它与图片上限 `CHAT_IMAGE_MAX_BYTES` **刻意解耦**(改文档上限不会静默改掉图片行为)。② **新增 `GET /api/v1/documents/{name}`**:**必须带 Bearer(owner|admin)**——与图片读侧(免鉴权的能力 URL)**刻意相反**(文档摄取在服务端完成,不需要被 ``/provider 直接消费),响应带 `Content-Disposition: attachment` + `X-Content-Type-Options: nosniff`(浏览器永不内联渲染)。③ **类型裁决(重要)**:**字节嗅探是唯一裁决者**(`http.DetectContentType`;**v0.59 时**放行的嗅探基类型只有 `text/plain`,**v0.61 起改为 `(扩展名, 嗅探)` 成对裁决**、`.pdf` 走 `application/pdf`),**客户端声明的 `Content-Type` 与原始文件名只作参考、可被嗅探否决**;**扩展名白名单 deny-by-default,实际 **22** 个(⚠️ 历史值:**v0.61 起为 23 项**,纳入 `.pdf`)**:`.txt .md .markdown .csv .tsv .json .jsonl .yaml .yml .log .ini .cfg .toml .go .ts .tsx .java .c .h .cpp .rs .sql`(⚠️ design/24 §9.2 写「23 项」但**当时**所列清单与代码均为 **22** 项,**以代码 22 为准**、并已在 v0.61 变为 23 项,见 §1.2「发现的不一致」);**明令拒绝** `text/html`、SVG/XML、zip 及其封装(docx/xlsx)、可执行/脚本(exe/dll/bat/cmd/ps1/js/mjs/sh/py)——即便 `.js`/`.py` 嗅探成 `text/plain` 也拒(判定 = 扩展名 ∧ 嗅探的**合取**)。④ **边界(勿与图片混用)**:`url` 是**相对路径**(`/api/v1/documents/<32hex>.`;图片侧是绝对 URL);`mime` 是**嗅探所得基类型**(故 `data.json` 返回 `text/plain`,不是 `application/json`);落盘名 = `sha256(bytes)[:32] + ext`(**内容寻址**,原始文件名永不参与路径 ⇒ 同字节同扩展名天然同名去重)。⑤ **4 个错误码**:`unsupported_media_type`(415) / `document_too_large`(413) / `document_not_found`(404,含名字形态非法与路径穿越) / `too_many_documents`(400)。⑥ **`chat` 请求新增可选 `"documents":[{"name":"<32hex>."}]`**(REST 同步 / REST `stream:true` / WS `chat` **三处同形**):**≤5 条**(超出 `too_many_documents` 400,且**先于任何文件系统访问**);**只收服务端存储名、绝不收 URL**(不重开 F3 那种「服务端代取 URL」的 SSRF 面);客户端多带的 `mime/url/size` **一律被服务端覆盖**(MIME/大小取自落盘字节);名非法/穿越/不存在 → `document_not_found`(404);**任一条不合规都让整个请求被拒**,绝不「丢掉这条附件但照样起回合」(WS 侧经 `error` 帧带 `data.code` 下发并中止本回合,REST 侧回对应状态码)。⑦ **响应 / 历史 DTO 由 `document`(单对象)改为 `documents`(数组,≤5)**——元素形状 `{"name","mime","size","chars","truncated","status"}`,`status ∈ extracted|listed|unreadable`;**0 个附件时该键省略**;同一 DTO 供 `GET /sessions/{id}/messages`、`message_persisted`、`message_edited`、WS 握手回放 **四处同时生效**(同一个 `historyMessage` 结构)。⚠️ **原单数形状从未发布过**(不在任何已发布版本号里)⇒ **无兼容期**,直接换数组(design/24 §6.1 那句 `"document":{…}` 已随之作废)。⚠️ **`chars`/`truncated` 改为「本回合注入侧」语义**:`chars` = **本回合注入**模型上下文的字符数、`truncated` = **注入**是否被截断(**不是**「原文件是否被截断」);**全文长度**只有 `document-read` 的 `totalChars` 能看到。⑧ **内置工具 `document-read`**(模型侧工具,客户端一般只需知道它存在):`{name, offset?, limit?}` → `{name,status,offset,limit,totalChars,truncated,nextOffset,text,extractTruncated?}`;`limit` 默认 **8000** 字符、上限 **64K**;`offset` 单位是**字符(rune)**、0 基、起点 = sidecar 正文(含 CSV/TSV 摘要行)第 0 个字符;`offset<0` 或 `>totalChars` → 错误(`==totalChars` 合法,读到末尾空片段);`extractTruncated=true` **专指**「撞上 8 MiB sidecar 安全上限」(≠ 本回合注入被截断);策略默认 **owner-only**(群聊/陌生人天然读不到主人的文件)。**B. 配置面(纯增量 + 一处语义变更)**——① **`models.json` 组级新增 `contextWindow`**(正整数 = 该组模型的上下文窗口;`0`/省略 = 未声明 ⇒ 后端按 **131072** 保守估算并记**恰好一条** WARN;**负数 = 组校验失败**,写端点回 `400`)。 ② **`.env` 新增/变更 4 键**:`LLM_CONTEXT_WINDOW`(**per-group 播种**,等价于 `models.json` 的 `contextWindow`)、`CONTEXT_BUDGET_RATIO`(默认 **2/3**,接受 `2/3` 或 `0.6667`,非法值记 WARN 后回落)、`DOCUMENT_MAX_INJECT_CHARS`(单份附件注入上下文的字符上限;未设 = 按推导出的预算自动算)、**`CONTEXT_MAX_TOKENS` 语义变更(关键)**:**未设置 ⇒ 按窗口推导**(`budget = min(窗口 × 比例, 窗口 − 输出保留)`,输出保留 = `max(32768, 该组 maxTokens)` 且**封顶 `窗口/2`**;**1M 窗口 ⇒ 699050 tokens**);`>0` = 显式覆盖(原样使用);`0`/负 = **关闭压缩**。⇒ **`.env` 里若仍写着 `CONTEXT_MAX_TOKENS=32768`,它就是显式覆盖、推导不生效**(这是设计语义,**不是 bug**;本次已把 `.env.example` 里该行改成注释形式)。③ **`.env` → 面板配置的 materialize 语义(欠账,本次补登记)**:进程**每次启动**跑一次;**面板配置非空 ⇒ 组集合完全由面板决定**(env 不新增、不复活被面板删掉的组);**键级只在面板缺该键时用 env 补**,面板已有的键(含**显式空值 / 显式 `false` / 显式 `null`**)**绝不覆盖**;面板配置缺失/为空时才由 env 播种出合成组;**「面板改一次、重启被打回」是硬约束**(有专门用例钉住);每次启动记「补了哪些 / 跳了哪些」,**凭据绝不进日志**;范围目前**仅 `models.json` 一族**(`channels.json` / `plugins.json` 未纳入)。④ **面板读写面**:`GET /api/v1/admin/llm` 的组 DTO **含 `contextWindow`**(`omitempty`,与 `maxTokens` 同形:未声明时该键省略),`POST /api/v1/admin/llm` **接受可选 `contextWindow`**——**缺失 = 保留已存窗口**、**显式 `0` 与缺失同义**(都 = 未声明)、**负值 `400 bad_request` 且不落盘**;面板 LLM 页据此显示/编辑该键。**C. 语音(有行为变更,见 §3.15)**——语音轮次**现在走与文本同一条路径**:会**落库**(用户消息 = 转写文本,助手消息 = 回复)、会发 `message_persisted` 与流式帧、**跨端同步**、忙时按编排器既有规则**排队**(`pending_ack`/`pending_drain`/`session_busy` 语义零变化,只是多了一个消费者);`deviceId` 照旧落库、`platform` 走存储默认 `web`;`voice_input` 上行增可选 `clientMsgId`(与 `chat` 帧同形 ⇒ 语音也有幂等;重复到达回既有 `chat_duplicate`,**不产生新回合、不发音频**);`voice_output`(定向)载荷增 `sessionId`(恒有)与 `deviceId`(非空才有);**空正文**与 **TTS 失败/降级**都走既有 `provider` 降级信号(客户端据此提示「没生成声音」;**不再**把 `stopReason` 当语音内容);**时点**:`voice_output` 在**该回合结算时**才到(可能晚于若干状态帧,甚至由续跑回合或被 steer 注入的那一回合产生);定向目标是**发起 `voice_input` 的那条连接本身**(服务端**不按 deviceId 路由**);连接断开后**不补投**,客户端重连后带上同一 `clientMsgId` 可安全重试。**D. `G5`(纯增量:广播帧 +2 个可选键;新增一条定向错误帧,见 §3.2)**——① **`pending_full` 广播载荷**增 `clientMsgId?` / `messageId?`:前者 = **被拒那条消息**的幂等键(未携带则**整个键缺席**);后者**仅当该消息此前已落库/已认领时**才回填——**服务端绝不在此处新铸 id**(队列满的拒绝发生在幂等键认领之前,代铸会让客户端重试被判成「重复到达」而永远进不来)。② **新增定向帧(此前无文档登记)**:`{type:"error", data:{code:"chat_busy", message, sessionId, clientMsgId?, messageId?}}`,**只发发送者那条连接**、不广播;`code` 与 REST `409 chat_busy` **同码**(客户端一个 code 覆盖两条入口)。**E. `E3`:`thinking` 可达性口径(不改行为,只是把话说清)**——消息体的 **`thinking` 字段**在**实时帧**(`message_persisted` / `channel_persisted` / `message_edited`)与**握手回放**中**一律为空**(隐私与带宽的**有意**默认;实时思维链仍由 §3.1 的 `thinking.*` 流帧按 owner/admin 档下发);**历史消息的思维链**只有 `GET /sessions/{id}/messages`(owner|admin)返回。**上一版 `v0.58`**:(**新增 `.env` 键 `LLM_MULTIMODAL`(`bool`,默认 `false`):让 env-only 部署能声明「主模型自身能否收图」**,依据裁决 14.4.1 / design/06 §0(commit `4adfc9a`),2026-10-04 落实):**A. 新键语义(见 §2「模型配置契约」表)**——**`LLM_MULTIMODAL` 是 `models.json` 组 `multimodal` 的 env 等价物**,**只作用于 `.env` 合成组**(**无有效 `models.json` 时才生效**;有组文件时以该组的 `multimodal` 为准,与 `LLM_REASONING_KIND` 同口径):`true` = 图片**直进主模型上下文**(省掉工具那一轮模型调用、也不做有损文本摘要),**留空 / `false`(默认)= 把图交给 vision/OCR 工具换成文本描述**;**绝不凭 base URL / 模型名猜测**端点是否收图(声称端点不具备的能力,比把图交给工具更糟——工具至少回一段文本,design/06 §0 首条)。**B. 与 `LLM_VISION_MODEL` / `LLM_OCR_MODEL` 的关系**——后两者配的是**视觉/OCR 工具调用的模型**,**不代表主模型能收图**,与本键互不代表(见 §1 行 3)。**C. 兼容性(默认值即旧行为)**——默认 `false` ⇒ **老部署行为逐字不变**(引入本键之前,env-only 部署没有任何键能声明该能力,图片恒走 vision 工具);**非 bool 值(如 `maybe`)在配置解码阶段直接报错、启动失败**(fail-fast,**不静默回退成 `false`**——静默的「配了当没配」正是本键要消灭的)。**`LLM_MULTIMODAL=true` 后图片走主模型直收,属部署者显式选择**(形状由部署者声明,后端不替它猜)。**D. 实现落点**——`internal/config/config.go`(`LLMConfig.Multimodal`,默认 `false`)+ `internal/config/runtimeconfig/models.go`(`GroupFromEnv` 把两 provider 分叉都透传进合成组)+ `internal/config/runtimeconfig/multimodal_env_test.go`(默认 false / 显式 true 抵达合成组 / 进程 env 端到端 / 组文件优先 四组测试)。**无端点、无帧、无字段形状变更**(纯新增 env 键)。**上一版 `v0.57`**:(**13.4.5 目标态 steer 落地登记:新下行帧 `steer_ack`**,依据 design/15 §2.0/§2.1/§2.4/§3.x/§5(裁决 13.4.5 目标态,commit `3d8822c`),2026-10-04 落实:**A. 新下行帧 `steer_ack`(见 §3.14)**——`{type:"steer_ack", sessionId, data:{messageId, atStep}}`:忙时会话里新到的用户消息**被在飞回合吃进同一回合**时下发(`messageId` = 那条用户消息的**权威 id**,`atStep` = 吃进它的**模型步序号,1 基**);语义=「**该消息已被本回合吃进(已从 pending 移除)、尚未被正面回答**」⇒ 客户端应把对应气泡渲染为「已读、未答」的**灰态**,直到该回合的 `message.*` / `turn.end` 到达(**不需要第二个确认帧**)。**角色门**:它**携带具体消息身份**(客户端能定位到某一条气泡,而不只是会话忙闲),因此**不登记**进 `frameReachesAll` 的 7 个状态机信号白名单,落 fail-closed 默认档——**只发 `owner` + `admin`**(`viewer` 收不到);经编排器状态通道**由 hub 直接广播、不经 bus**,帧壳带 `sessionId`、**不带** `turnId`/`seq`。**触发时机**:只在**回合内边界**(loop 每一步开头:上一次模型步与它那一批工具调用**都已结束**、下一次模型调用**尚未开始**,此刻没有工具在飞)产生,且**只有该边界真的匹配到 pending 里、且已落库的消息时才发**——**不是每个 step 都发**(没可注入消息的边界一帧都不发,这是常态)。**B. 转向的三条不变量(design/15 §2.0)**——**I1(同会话在途回合数恒为 1)**:转向只注入、不取消,**绝不新开第二个回合**(loop 侧结构性地没有任何「起新回合」的分支,注入只是把消息并进**当前回合**下一次模型调用的视图);**I2(消息恰好被处理一次)**:注入=已消费——被注入的消息立刻从该会话 pending **移除**(按 pending 序号认领),**绝不被第二个回合再答一遍**,去重键=后端 `message.id`,且候选必须**同时**满足「在该会话转录里(最近 20 条窗口内)」与「在本编排器的 pending 里」;**I3(转向不取消已发起的工具)**:注入点只在回合内边界,此刻没有任何工具在飞,正在执行的工具**永远跑完**。**C. 注入的范围(口径)**:注入**只影响本轮视图**(每一步边界重新并入),**不写回运行窗口** ⇒ 既不进该回合的 **checkpoint 快照**、也不进**后台记忆提取的输入面**;被注入的消息**本身早在到达时已落库**(pending 路径即时持久化),注入**不新增、也不改写任何行**。**D. 失败一律让路**:未接转录存储 / 读转录失败 / 该消息尚未落库 / 本回合世代已被打断或被更新的回合取代(认领 `pending` 失败)时,**不发 `steer_ack` 也不注入**——该消息**留在 pending**,照旧作为自己的回合被回答,**绝不静默消失**。**E.** 另含一处**链路图纯增量键**(注入时该轮图的 input 节点带 `props.steered:true`,图仍只有一条主干——不是被画成第二轮),以及**纯新增帧**的兼容性口径(老客户端按未知帧忽略即可)。**上一版 `v0.56`**:(**协同项 A2 / G1 / G2 / G3 / G4 / F3 落地登记:`interrupt` 支持 `scope` + REST `stream:true` 不再静默失败 + `chat.image` 上限 + `clientMsgId` 幂等 + 视觉链路 URL 取回与类型裁决 + 既有错误陈述更正**,依据 `Cyrene-For-Android/devdocs/02-前后端协同事项.md` 的 A2 / G1 / G2 / G3 / G4 / F3(commit `7512d59` 等,2026-10-04 落实):**A. 上行 `interrupt` 新增可选 `scope`(A2)**——`{sessionId}` → `{sessionId, scope?}`,取值域 `{"", "all", "current"}`(**省略 / 空串 / `"all"` 三者同义**):`all` = 取消当前任务 + 清空待处理队列(**逐字不变的现状语义**);**`"current"` = 只取消当前任务、保留待处理队列**(队列消息按编排器正常续跑规则继续执行、带上会话尾部上下文);**其余取值 → 只回发送者** `{type:"error",data:{code:"bad_request"}}`(**定向**:不广播、不打断任何会话,绝不静默按默认执行);命中空闲会话为 no-op。下行 `session_interrupted` 帧形状**零变化**(`{sessionId, mode, dropped}`),但 `dropped` 恒等于**真的被丢弃**的待处理条数——`scope:"current"` 时为 `0`,且 `mode` 是该会话当时的**忙碌通道**(`thinking|executing|subagent`,**不是 `idle`**);`scope:"all"` 时 `mode` 为 `idle`、`dropped` 为清空的 pending 条数。**B. REST `stream:true` 不再静默失败(G2,行为变更)**——该分支改为与 WS `chat` 帧**同一条直达编排器的路径**(design/15 §2.1「会话消息不得被 drop」),**响应面**:受理成功后才回 `200 {data:{stream:true}}` / 正忙已续接排队 `202 {data:{stream:true,queued:true,queueDepth:n}}` / 队列满 `409 {error:{code:"chat_busy"}}` / 幂等键重复 `200 {data:{duplicate:true,messageId,sessionId}}`;**未接 loop → `500 {error:{code:"chat_unavailable"}}`**(旧文写 `200 {stream:true}`,现与同步路径同口径);仅**未接编排器**才回退总线派发,其失败回 `503 {error:{code:"chat_dispatch_failed"}}`(此前 `Publish` 的错误被吞掉,且传的是**请求作用域** ctx)。**`202`/`409` 从此真的会出现**(修复前因提前 `return 200` 永不出现)。派发用的回合上下文与请求作用域脱钩(`context.WithoutCancel` + 30 分钟上限),回合不会在客户端刚收到 200 时被连带取消。**向后兼容**:`stream:false` 与既有同步响应形状一字未动;依赖「任何情况都拿到 200」的客户端必须按上表分支,别再假设 `stream:true` 恒 `200`。**C. `chat.image` 上限(G3)**——`POST /api/v1/chat` 的 `image` 新增硬上限:**解码后 5 MiB**(与 `POST /api/v1/upload` 同口径;base64 字符串上限 = `ceil(n/3)*4 + 64` 字符,覆盖 data: URI 前缀),超限 → `400 {error:{code:"image_too_large", message}}`(文案指路 `POST /api/v1/upload`),在**任何解码 / 落库 / 入模型之前**拦下(超大 base64 因此不进历史、也不会被重连回放重传)。**事实(勿误读为「WS 也有这条 400」)**:WS `chat` 帧**今天已被传输层关死**——WS 读上限 `wsReadLimit = 4096` 字节/帧,超限直接断连,**到不了**这条判定 ⇒ **WS 上发真实图片本就必须「上传 + URL」**。**D. `clientMsgId` 幂等(G4)**——上行**新增可选** `clientMsgId`(REST `chat` 体与 WS `chat` 帧都支持;**省略 = 老行为逐字不变,老客户端零影响**):同一会话内同一个非空值只承认第一条。**重复到达**:REST → `200 {data:{duplicate:true,messageId,sessionId}}`;WS → 新下行帧 **`chat_duplicate`**(`{type:"chat_duplicate", sessionId, data:{sessionId, clientMsgId, messageId}}`,见 §3.13);重复**不落库、不起回合、不发任何内容 / 流式新帧**。**判重由 SQLite 部分唯一索引原子裁决**(`(session_id, client_msg_id) WHERE client_msg_id != ''`),并发的两次重发只有一个赢家,不存在「先查再插」的竞态窗口。`clientMsgId` 另**回填进消息 DTO** ⇒ `message_persisted` / `channel_persisted` / 握手 `history` 回放与 `GET /sessions/{id}/messages` 等读接口可见(空则省略),客户端据此把本地乐观气泡对齐到服务端真相(替代「靠文本相等」的脆弱匹配)。**迁移**:新增 `0013_chat_client_msg_id`(up:加列 + 部分唯一索引 `idx_chat_messages_client_msg_id`;down:先删索引再删列;全新库由 `history.NewStore` 幂等前滚)。**E. 视觉链路事实修正(F3)**——`chat.image` 的 http(s) URL(含 `/api/v1/files/`)现在由**服务端自己取回并内联为 `data:` URI** 再交 provider,**不再原样转给上游**;同源 upload 产物优先走**本地文件读取**。**类型裁决**:**字节嗅探是唯一裁决权**(声明的 Content-Type / 扩展名只作参考且可被否决),嗅探为文本(HTML / PROXY 错误页 / SVG)或判不定且来源未声明 `image/*` → **明确报错**(`vision.ErrNotImage`),**绝不静默当 PNG**。**对客户端无端点 / 帧 / 字段变更**。**F. 修正既有错误陈述(G1)**——`Docs/api-reference.md` §6.2 帧壳表与 §6.6 曾声称结构化流帧只带 `seq`、没有轮次标识,**是错的**:**v0.36 起**结构化流帧(`message.*` / `thinking.*` / `tool_call.*`)就带 `sessionId` + `turnId`(`broadcastStream` 从 `model.StreamEvent` 取,loop 在回合边界注入),v0.50 起另带 `streamId`;已按实现更正(与本文档 v0.36 条目一致)。**G. 环境变量登记**——`.env.example` 新增 **`OWNER_IDENTITY_INJECTION`**(`owner_only` 默认 / `always` / `off`;**非法值回退 `owner_only` 并记 WARN**),并登记 **`CONTEXT_MAX_TOKENS`**(默认 `32768`;`0` / 负 = 关闭压缩)与 **`GUARDRAIL_KEYWORDS`**(逗号分隔;留空 = 不挂关键词护栏),三键见 §2「安全 / 上下文环境变量(v0.56)」。**无新端点、无帧形状破坏**(唯一新增帧 `chat_duplicate` 为**纯新增**;REST `stream:true` 的响应面属**行为变更**,兼容点已在 B 写明)。**上一版 `v0.55`**:(**阶段 7 落地项登记:路径式安全入口 + 数据目录 `platform` + OneBot v11 四端 + 可观测实时推送**,依据裁决 1.6 / 16.4.7 / 12.4.2(commit `a398dc9`)、1.4 / 12.4.1(commit `9253fef`)、05.4.3 + 16.4.3-WS(commit `0e1f163`),以及 13.4.5 面内三条(commit `74b12d8`)、MCP 官方 SDK(commit `a1083a3`),2026-10-04 落实):**A. 安全入口 `ADMIN_PATH`(裁决 1.6 / 16.4.7,前端路由面)**——① env 新增 **`ADMIN_PATH`**(默认 `admin`):面板**前端**路由前缀 = `/` + 值(`admin` → `/admin`,`xxx/admin` → `/xxx/admin`;**非法值回退 `/admin`**——容忍首尾斜杠与空白,拒绝 `.`/`..` 段与 `?`/`#`)。② HTTP 行为:`/` 与其深链返回**注入了 `` 与 `window.__CYRENE_ADMIN_ENTRY__={"prefix":"..."}` 的 index.html**(`Cache-Control: no-store`);**形如面板入口(路径里出现 `admin` 段)但前缀不符的路径 → `404`**(不回落用户端聊天壳、不泄露正确前缀);`/` 若恰好命中 `dist` 真实文件则仍走文件服务器。③ **API 不藏**:`/api/v1/*`、`/api/v1/admin/*`、`/ws/chat`、webhook 一律原样,**无新端点、无帧变更**。④ **口径**:用户端 HTML **永不携带该前缀串**(后端只在命中前缀时注入)⇒ 用户端既无管理入口链接、也解析不出面板路由(design/07 §0.2;前端路由改**路径式**:`frontend/web/src/admin/router.ts` 只读注入、无硬编兜底、移除 hash 入口)。**B. 数据目录(同 commit,裁决 12.4.2)**——`DATA_DIR` 新增可接受值 **`platform`**(大小写不敏感)→ 平台标准目录;默认 `./data` 与显式路径行为不变;**显式空值行为不变**(仍走原兜底)。**C. OneBot v11 四端(`9253fef`,裁决 1.4 / 12.4.1)**——① 配置面(channels.json 账号 `config` + 适配器 `Config()` 快照)新增 `transport`(`ws`|`http`,默认 `ws`,未知值回落 `ws`)/ `api_base_url` / `report_path`(默认 `/`);快照 camelCase 为 `transport`/`apiBaseURL`/`reportPath`;`remoteURL` 快照值 = `remote_url`、为空时回落 `api_base_url`(语义即「出站远端地址」),**既有键与值不变**。② env 兜底 `CHANNEL_QQ_OBV11_TRANSPORT`(默认 `ws`)/ `CHANNEL_QQ_OBV11_API_BASE_URL` / `CHANNEL_QQ_OBV11_REPORT_PATH`。③ 四端语义:`ws+client` Cyrene 拨号、收发同一 WS;`ws+server` OneBot 反连、收发同一 WS;`http+client` Cyrene→OneBot(`POST {api_base_url}/{action}`,纯出站);`http+server` OneBot→Cyrene(POST 到 `listen_addr`+`report_path`,纯入站,fail-closed 鉴权、10MiB 体上限)。④ 取舍:`mode` 决定**哪一半必需**(server 必需 `listen_addr`、client 必需 `api_base_url`),另一半给齐字段就一并启用 ⇒ 单个适配器即可「收上报 + 回消息」。⑤ 端口例外:`http+server` 用 `listen_addr`,属 `AGENTS.md` §9 已登记的**同一豁免**(design/13 §0 原文即写「WS server / HTTP server」);`listen_addr` 为空则报错、**绝不开端口**。**D. 可观测实时推送(`0e1f163`,裁决 05.4.3 + 16.4.3-WS)——新增一节 §3.12**:三个**下行新帧** `observability_log`(`{entries:[{time,level,source,msg}…]}`,最新在前,上限 200 条)/ `observability_trace`(`{spans:[…]}`,上限 100 条)/ `observability_metrics`(`AdminMetrics` 本体、扁平),均**纯新增**(既有帧名/形状零改动)、帧壳**不带** `sessionId`/`turnId`/`seq`;三者**不在** `frameReachesAll` 白名单 ⇒ fail-closed **只发 owner+admin**(viewer 继续走 REST 只读档);订阅 = owner/admin 凭据**读取** `GET /admin/observability/{logs,metrics,trace}` 任一个(该次读取照常返回完整引导数据),该凭据 WS 连接全断即退订并停循环(**不是轮询、无常驻 goroutine**);**REST 端点完全不变**,`?level=` 分级过滤改由**客户端本地**做(帧广播给所有 `owner`/`admin` 连接,订阅只决定循环跑不跑)。**E. 其余(各一句)**:① **13.4.5 面内三条**(`74b12d8`)——续接窗口 `Recent` 在同毫秒并列时改为取**最新** n 条且按插入序(**无 API 变化**,但客户端可观察到的「握手回放 / 续接上下文」顺序更正确);上行 `chat` 不再经**可丢弃**的 bus(**帧序列不变**);被**取消**回合的 checkpoint 不再顶掉下一条消息,新增内部常量 `loop.StopReasonCanceled`(`checkpoints.state.stop_reason` 可能出现该值,**不经 REST/WS 暴露**)。② **MCP**(`a1083a3`)——协议层换成**官方 Model Context Protocol Go SDK v1.8.0**,**REST/WS/env/数据目录零变更**(`MCP_SERVERS_JSON`、`MCP_*`、`transport` 枚举 `stdio|http|streamable|sse` 全不变)。**未做(诚实边界)**:DashScope 形状的 `auto` 推断、Qwen 的 `/think` 前导消息软开关、`get_group_list` 枚举、上游显式退订帧(当前退订只发生在订阅凭据的连接全断时)、审计 tab 仍 5s REST 轮询(未做 `observability_audit` 帧)。**上一版 `v0.54`**:(**渠道配置写路径改 patch 语义(修 P0 级凭据静默丢失) + 阶段 6 前端批的纯前端行为登记**,依据 design/04 §3.1、design/22 §2 方案 b(与 `personaId` 同构的裁决延续,commit `4ae7148`)与裁决 16.4.1 / 16.4.2 / 16.4.3 / 16.4.4 / 16.4.5 / 16.4.6(commit `f454273` / `b480b04` / `c1a98d7` / `8d70f6d`)):① **`PUT /api/v1/admin/channels/{platform}/config` 的 `config` 由「整条替换」改为「按 id 打补丁」**(`runtimeconfig.PatchChannel`,**与 `personaId` 同构的三态**)——**键省略(该键不在 `config` 里,或 `config` 整体缺席 / 为 `null`)= 保留该账号已存值**、**给出非空值 = 覆盖该键**、**显式 `null` 或 `""` = 清除该键**(`0` / `false` 是真实取值,**不算空**);顶层便捷字段(`token` / `baseURL` / …)是普通 `string`,**只在非空时**并入补丁 ⇒ **要清除 `bot_token` 必须走 `config` 的显式 `null` / `""`**;`allowGroups` / `denyGroups` / `allowUsers` / `denyUsers` 与三个限流键**仍是整值替换**(省略 = 清空)。② **破坏性提示**:按旧文实现、依赖「省略某 `config` 键 = 删掉它」的客户端,必须改为**显式传 `null` / `""`** 才能清除该键。③ **修掉的数据安全问题**:面板只发送用户本次碰过的平台字段,后端却整条替换 ⇒ 保存**微信(iLink)**账号卡会抹掉 `bot_token` / `bot_id` / `owner_id` / `get_updates_buf`(**iLink token 是长期凭据**)、**Telegram** 只填 `webhook_url` 而不重填 token 会让 `cleanAccounts` **丢掉整个账号**。④ **未改**:token 要求型平台**显式**清 token 仍按 `cleanAccounts` 丢账号;「本次一个 `config` 键都没给出」在**非 `weixin-ilink`** 平台仍回 `400`。⑤ **无新端点 / 新字段 / 响应形状变更**。**同版另含阶段 6 前端批(纯前端行为,无 API 变化)**:① **16.4.1 静默/活动态与预约切换**——唯一入口 `frontend/web/src/components/modeSwitch.ts`:她活动时用户切换只登记「已预约」、静默时落地;② **16.4.5 会话级消息缓存**——帧按自己的 `sessionId` 落进该会话的消息桶(切会话不清空),**只有单槽忙态**仍按激活会话判定;`graph_update` 按 `data.sessionId` 入**图桶**,管理端 Flow 页取激活会话、Backthink 页取 `sess-backthink`(裁决 16.4.2);③ **16.4.3 / 16.4.6 虚拟滚动与专属页只读卡**——长列表超阈值窗口化、专属页只读展示该平台完整字段;④ **16.4.4 双档 UI 模式**——性能档 = 现状(默认)、华丽档落 `:root.ui-mode-fancy`,持久化键 `cyrene.ui-mode`(纯前端,对应 `design/07 §0.1`,不改后端契约)。**上一版 `v0.53`**:(**阶段 5 落地:模型目录端点 + per-persona 路由 / 本地三级 / capacity L3 / 视觉·OCR 独立模型 / `image-crop` 工具 / 按 provider 形状下发推理开关**,依据 design/04 §3/§3.1/§3.2(裁决 03.4.1 / 03.4.2 / 03.4.3 / 08.4.1)、design/06 §0/§3(裁决 14.4.1 / 14.4.5)、design/14 §2.2/§3/§4(裁决 14.4.2 / 14.4.3 / 14.4.4),2026-10-04 落实):① **新增管理只读端点 `GET /api/v1/admin/llm/catalog`**(**owner|admin** 读,`?refresh=1` 强制在线刷新)→ `200 {data:{source:"cache"|"models.dev", fetchedAt?, stale?, warning?, providers:[{id,name,api?,doc?,env?,npm?,models:[{id,name,attachment,reasoning,toolCall,contextLimit?,outputLimit?}]}]}}`;**既无本地快照又抓不到** → `503 {error:{code:"catalog_unavailable", message}}`;客户端带 `Accept-Encoding: gzip` 时响应带 `Content-Encoding: gzip`(**信封形状不变**)。**副作用只有一处**:落盘快照 `/llm_catalog.json`(**不含任何凭据**、`data/` 已 gitignore、TTL 24h),**不改 `models.json`**;**抓取只发生在本端点内,不进运行时热路径**(模型请求链路从不读外网目录)。详见 §2「模型配置契约(v0.53)」。② **`models.json` 组级新增键**(载体是组,**可热更新**,未声明时该键不出现):`reasoning: {kind: "auto"|"deepseek"|"openai"|"vllm"|"dashscope"}` 决定该组用哪种**提供商开关形状**下发推理指令(**非法值被 `Group.Validate` 直接拒绝**;anthropic 组上声明 openai 系形状同样拒绝)、`personaModels: {personaId: modelId}` 与 `personaProviders: {personaId: "openai"|"anthropic"}` 为**角色级模型/提供商路由**(**非法 provider 值同样拒绝**;未绑定/未知角色回落该组默认)。③ **语义修正(重要)**:**`multimodal` = 主模型自身能否收图**;`LLM_VISION_MODEL` / `LLM_OCR_MODEL` **只配视觉/OCR 工具用的模型**——两者互不代表(见 §1 行 3)。④ **`.env` 新增 9 键**(见 §2「模型配置契约(v0.53)」表与 `.env.example`):`LLM_REASONING_KIND`(默认空 = `auto`)、`LLM_OCR_MODEL`、`LLM_LOCAL_ENABLED`(默认 `false`)/`LLM_LOCAL_PROVIDER`/`LLM_LOCAL_BASE_URL`/`LLM_LOCAL_API_KEY`/`LLM_LOCAL_MODEL`、`CAPACITY_L3_ENABLED`(默认 `false`)/`CAPACITY_L3_CACHE_TTL`(默认 `5m`)。⑤ **内置工具新增 `image-crop`**(按区域裁切图片:`x`/`y`/`width`/`height` → PNG data URI + 裁切尺寸;供大图分区精读;**策略默认 owner-only**)。⑥ **推理开关按形状下发(旧陈述修正)**:v0.41 的「Qwen 一类只 WARN、请求一字不改」**已作废**——形状由 `reasoning.kind` 声明后**真下发**(→ 字段名映射表见 changelog v0.53 条),**只有形状未知/有歧义时才 WARN 且一个字段都不发**;`reasoning_effort` 的非法值由「降级到最近一档」改为**丢弃并记 WARN(不降档)**,`effort:none` 一律翻成**显式关思考**。**未做**:DashScope 形状的 `auto` 推断(只能在显式声明 `reasoning.kind=dashscope` 时下发,绝不从模型名/BaseURL 猜)、Qwen 的 `/think` 前导消息软开关(与 design 口径冲突,未实现)。**上一版 `v0.52`**:(**工具策略落盘 + 主代理代审小字 + 流程图节点参数摘要**,依据 design/11 §3/§4/§8(裁决 1.3 / 10.4.2 / 13.4.3)与 design/19 §1/§1.1(裁决 11.4.2 / 11.4.3 / 13.4.1),2026-10-04 落实):① **工具策略(`allowInGroup`/`allowInStranger`)落盘 SQLite `tool_policies`(迁移 `0012_tool_policy`),重启保留**——`registry.SetPolicy` **先落库后改内存**,落盘失败则内存保持旧值并返回错误(见 §2 行 5);② 新增下行帧 **`approval_notice`**(§3.11):敏感操作经主代理代审后推一行小字(`explain` 由**后端产出**、客户端原样渲染),审计口径 `who`=角色名 / `what`=`approval.<工具名>` / `result`=`granted|denied`,**仅 `owner` + `admin`**(fail-closed 默认);③ `graph_update` 节点 `props` 新增四类**纯增量可选**键(tool `args_keys`/`args_summary`、guardrail `action`/`allow`/`elapsed_ms`、memory `action`/`scope`/`hits`/`bytes`/`count`/`failed`、orchestration `subagent`/`persona`),`memory`/`guardrail`/`orchestration` 旁路节点 span **首次真进图**,值在发布前已脱敏——**帧名、事件名、顶层字段集一字未动**(见 §3.5)。**上一版 `v0.51`**:(**后台子代理结果登 WS 帧 + 回填父会话**,依据 design/05 §9「子代理完成 → 结果回填主会话并提示用户」,裁决 02.4.3,2026-10-04 落实):新增下行帧 **`subagent_result`**(`{type, sessionId, data:{sessionId, status:"done", summary, agents:[{id, goal, role?, mode?, status, summary?, text?, steps?}]}}`,见 §3.10)——`subagent_spawn` 的批次转后台后结算时,编排层发布在 bus 主题 `subagent.result` 上的合并结果,此前**没有任何消费者**(中继既非 `ws.*` 也不认该 4 段以外的 subject)⇒ 结果上不了前台、只剩进度帧;现在该 subject 被登记为中继并成形为这一帧,**帧壳 `sessionId` = 派生该批次的父会话**,结果因此落回**父会话的实时流**。**角色门**:新帧属内容帧 → **只发 `owner` + `admin`**(fail-closed 默认,未扩 7 个状态机信号白名单)。**向后兼容**:纯新增帧,老客户端按未知帧忽略即可,既有帧/端点/鉴权一律不变。**诚实边界**:结果**不落库**(父会话转录里没有新消息、重连回放不补发)、**未**经 design/15 会话消息编排器续接、**前端尚无该帧 handler**(今天的效果是「帧到达 owner/admin 连接」)。**同版另含管理面(D 组,2026-10-04 落实)**:① **`X-API-Key` 真正接线**(裁决 15.4.2):管理/用户侧已认证端点同时接受该头,命中签发表即通过且**一律 `owner` 级**(裁决 15.4.3 取消 role 分级),带该头时只走 key 分支、未知/已吊销/空值回 `401` 且不回落 Bearer(fail-closed),WS 握手仍只认 Bearer;② **新增主人白名单档案读写** `GET`/`PUT /api/v1/admin/owner-profile`(裁决 10.4.5,**owner-only**,写带审计 `owner.profile.update`)——面板的「保存」从此是**真保存**(落盘 `configs/users/owner.yaml` 0600 原子写 + 热替换活档案,渠道 actor 判定立即生效),不再是只改本地 state 的假成功;③ **persona `identity.systemPrompt` 改必填**(裁决 08.4.2):缺失/空白回 `400 bad_request`,**不再派生默认说明串**,写出的 YAML 必能被载入期同一规则重新载入。见下方「管理面板写操作契约」。**上一版 `v0.50`**:(**事件批:流式帧的结构化补齐**,依据 design/04 §1.1,裁决 01.4.1 / 01.4.2 / 01.4.3 / 03.4.4,2026-10-04 落实):① **`tool_execution` 拆三事件**——旧单帧 `tool_execution.update {id,index,delta}`(phase 与工具名拼在 `delta` 字符串里)被 **`tool_execution.start`**(进入执行,`phase:"running"`)/ **`tool_execution.update`**(运行中进度/局部输出,可多帧)/ **`tool_execution.end`**(完成或失败,`phase:"done"|"error"`)替换,载荷改为结构化 `{tool, phase, id?}`(`tool` = 工具名;`phase` = 细粒度执行阶段,与 §3.5 流程图节点 status 同一套 `running/done/error` 枚举;`id` = provider 工具调用 id,可与 `tool_call.*` 帧关联)——**phase 不再塞进 `delta`**;loop 现已真实发出 start/end(update 的 wire 映射已实现并验证,但工具分发器尚无中途进度上报源,**暂无生产者**,见 §3.1)。② **`streamId`(模型流帧 `data` 内新增可选字段)**:同一次模型流(一次 ChatStream 调用)的多帧共享同一 `streamId`,多步工具轮的第二次模型调用取新 id;与帧壳的 `turnId`(整个回合恒定)分工——`turnId` 标记「这轮回答」,`streamId` 标记「这轮里的哪一条流式消息」。③ **`turn.start` / `turn.end`(新帧)**:loop 在回合边界发出、经 ws_bridge 下行;**无 `data` 载荷**(帧壳的 `sessionId`/`turnId`/`seq` 即边界信号;回合终态信息仍由既有 `stream_end` 携带);`turn.end` 覆盖回合的所有出口(最终答案 / 工具循环安全网终止 / 预算耗尽 / 步骤出错),被静音的内部流程(backthink / 子代理)不发出。④ **向后兼容**:`turn.start/end` 与 `streamId` 为纯新增,老客户端可安全忽略;**破坏性**在旧 `tool_execution.update` 单帧——loop 不再产生它,按旧形状(解析 `delta` 里的 `phase:工具名` 拼串)消费的客户端必须迁移到三事件;现有 Web 前端未消费这些帧,行为不变。⑤ 本批新帧全部落在 v0.48 下行帧门的「仅 `owner` + `admin`」档(fail-closed 默认,无需扩白名单)。⑥ **`graph_update` 会话范围口径修正**(裁决 01.4.5,随实现事实补记):`web` / `web:` / `sess-backthink` 三范围(见 §3.5)。见 §3.1 与 v0.50 变更记录。**上一版 `v0.49`**:(**WS 握手回放与上行帧的角色门(design/11 §7.2)**:补上 §7.1 下行门之外的**同一类缺口的另两处**,对 `viewer` **继续收紧**,`owner` / `admin` 行为不变,**无端点、无字段形状变更**——① **握手 `history` 回放改为仅 `owner` + `admin`**:此前任意角色一连上 `/ws/chat` 就会收到主会话最近 50 条消息正文(`thinking` 已剥离,但**正文本身**就是主人的对话),`viewer` 从此**不收**;② **上行 JSON 帧按角色授权(fail-closed)**:非 `owner`/`admin` 连接发来的**任何**上行 JSON 帧一律拒绝,**定向**(只回发送者)回 `{type:"error",data:{code:"forbidden"}}`,**不广播、不驱动模型、不落库、不返回任何内容**——被拒帧含 `chat`(此前 `viewer` 可**真的驱动模型并落库**)、`interrupt`、`subagent_ask` / `task_output_query`(其应答是**定向**帧、能绕过 §7.1 下行门把子代理上下文/任务输出直接回给 `viewer`)、`voice_input`、`mode_switch`,且**未登记的新上行帧默认拒**;**协议级帧(ping/pong、close)不受影响**。③ **合口径**:`viewer` = **可连接、可读 12 条只读监控 REST(v0.40)、可收 7 个状态机信号帧(v0.48)**;**除此以外 WS 上拿不到任何内容、也发不出任何指令**。见 §3 开头的「上行帧与握手回放的角色门」与 v0.49 变更记录。**上一版 `v0.48`**:(**WS 下行帧的角色门(design/11 §7.1)**:hub 的下行现在是**按帧类型分档**的——只有 7 个**状态机信号**(`session_busy` / `pending_ack` / `pending_drain` / `session_idle` / `session_interrupted` / `pending_full` / `session_read`,**均不含任何内容**)继续发给**所有已认证连接**(含 `viewer`),**其余全部下行帧**(流式内容 `message.*` / `thinking.*` / `tool_call.*` / `tool_execution.update` / `stream_chunk` / `stream_end` / `thinking`;持久化 `message_persisted` / `message_edited` / `message_deleted` / `channel_persisted`;系统内部 `graph_update` / `subagent_event` / `task_output` / `task_done` / `tool_used` / `capacity_awaken` / `mode_switch`;私有状态 `mood` / `device_update` / `reminder`)**改为仅发 `owner` + `admin`**)。**对 `viewer` 是收紧**:此前它连上 `/ws/chat` 就能收到角色回复正文与推理内容,现在收不到;**`owner` / `admin` 行为不变**;**无端点、无字段形状变更**——`viewer` 的 12 条只读监控 REST 端点(`viewerAuth`,v0.40)与 `/ws/chat` 的连接鉴权都**照旧**;**新帧类型默认只发 owner/admin**(fail-closed)。见 §3 开头的「下行帧的角色门」与 v0.48 变更记录。**上一版 `v0.47`**:(**`graph_update` 子代理泳道根节点新增可选 `props.persona`**:派发子代理时若指定了目标角色,该分支根节点的 `props` 就多一个 `persona` 键,标注它**正在扮演的角色**——用途是让流程图能显示「这个子代理是以谁的身份在替班」,而不只是子代理 id;**纯增量**:不带角色派发时该键不出现,老客户端忽略未知键即可,见 §3.5 与 v0.47 变更记录)。**上一版 `v0.46`**:(**`PATCH /api/v1/sessions/{id}` 的 `personaId` 由「可重新绑定该会话的角色」改为「出现即 `400`」**:**会话角色在创建时确定、之后不可变**,要换角色请新开会话(design/22 §8-1)——**明确拒绝**而非静默忽略/200 空操作;**键出现**(任何取值:`"yuki"` / `""` / `null` / 未知 id / 空白串)一律 `400 bad_request` 且消息点名 `personaId`,该检查**先于**会话存在性检查(未知会话 + `personaId` 也回 `400` 而不是 `404`)。**破坏性**:按 v0.42–v0.45 旧文实现、依赖「PATCH 换角色 / 显式 `""` 解绑」的客户端必须改为**新建会话**选角色;`title`/`pinned` 行为与 `POST /sessions {personaId}`(创建时绑定)**完全不变**,不再有 `501/200 {personaId}` 的 PATCH 路径)。上一版 `v0.45`:`PUT /admin/channels/{platform}/config` 的 `personaId` 由「省略/`""` 都 = 解绑」修订为三态(**省略(或 `null`)= 保留已存绑定**、**显式 `""` = 解绑**、**非空 id = 绑定**,未知角色仍 `400`)。 +> **契约版本 `v0.62`**(**面板「重置为 env 默认」动作落地:materialize 的反方向显式动作 + 一个新的 owner-only 管理写端点**,依据任务书 `DevDocs/_dispatch/R1-面板重置为env默认.md`、design/04 §3.1(`.env` 为默认/播种层、面板文件为一等来源)与 `DevDocs/开发时间线.md` 的欠账项(「Materialize 已导出、未接端点」),2026-10-04 落实)。**一句话**:materialize 是「面板缺该键才用 `.env` 补」,本版新增的 `POST /api/v1/admin/llm/reset-from-env` 是**反方向的显式动作**——部署者要求「把面板里 `.env` 声明过的键退回 env 值」。**A. 语义(与补齐表同源)**:只覆盖 `.env` 声明了非空值的键(`provider`/`baseUrl`/`apiKey`/`model`/`contextWindow`)、**env 未声明的键一律保留面板值**(`fallback`/`multimodal`/`maxTokens`/`reasoning`/`personaModels` 永不被改)、**组集合不增减**(不复活被删的组、不删新增的组,顺序也不动)、面板值已与 env 一致的键不算「改回」、**无键可改时不写盘**(幂等);真的改了盘就**热重载**运行中的 LLM 栈(`contextWindow` 的预算推导仍只在启动时算一次)。**B. 护栏 / 响应 / 审计**:**owner-only**(与同页两条 LLM 写端点同门;破坏性写不比同族更宽),body 必须 `{"confirm":true}`(缺失 / `false` / 非法 body ⇒ `400 bad_request` 且**一个字节都不动**)→ `200 {data:{reset:[{group,keys:[…]}], skipped:[{group,reason}], path}}`;**只回键名与中文原因,凭据值(尤其 `apiKey`)绝不回显、绝不进日志**;审计动作 `llm.resetFromEnv`(`ok`/`error`)。**C. 基准与失败**:对照基准 = 请求时刻按**既有解析入口**读到的 `.env` 默认层(新增 `config.LoadLLMDefaults`,路径经 `Deps.EnvPath` 注入,**网关绝不 fopen `.env`**);`.env` 源不可读 → `500 env_defaults_unreadable`、面板配置不存在/不可解析 → `500 models_unreadable`、写盘失败 → `500 llm_persist_failed`、已写盘但热重载失败 → `500 llm_reload_failed`、LLM 栈未注入 → `500 llm_unavailable`——**都不假装成功**。**D. 面板**:LLM 页新增「重置为 env 默认」卡片(`admin/modules/llm/LlmResetFromEnv.tsx`,行上标 `owner 专属`,**中文二次确认**后才提交)。**E. 兼容性**:**纯增量**(新端点 + 新前端卡片 + 新审计动作 + 2 个新错误码 `env_defaults_unreadable` / `models_unreadable`,另复用既有 `llm_persist_failed` / `llm_reload_failed` / `llm_unavailable`),既有端点 / WS 帧 / 字段形状**一字未动**;管理侧权限矩阵因此为 12(viewer 只读)+ 10(owner/admin 读)+ **38**(owner-only)= **60** 条路由。详见 §2「重置为 env 默认契约(v0.62)」。**上一版 `v0.61`**(**PDF 文本层抽取落地:白名单第 23 项 `.pdf` + 类型裁决由「嗅探只放行 `text/plain`」改为 `(扩展名, 嗅探)` 成对裁决**,依据 design/24 §7 的 **S6 第一段**(PDF/Office 的裁决:PDF 文本层引库,扫描件与 Office 仍诚实拒绝)与 commit `f82c76d`,2026-10-04 落实)。**三档标注**:**行为变更** = ① **`.pdf` 由「一律 `415`」变为「可上传 + 可读取 + 文本层可抽取进上下文」**(把「`.pdf` 必被拒」写死的客户端必须改);② **类型裁决收紧为成对判定**(`documents.ContentAllowed`)——**方向是收紧/细化,不是放宽**:HTML/纯文本改名成 `.pdf` ⇒ `415`(嗅探不是 PDF),**PDF 字节改名成 `.txt` ⇒ `415`**(PDF 字节也只收在 `.pdf` 名下);**纯增量** = 扩展名白名单 **22 → 23 项**(唯一新增 `.pdf`),其余 22 个文本类的判定与既有响应**一字未变**。**A. 上传(`POST /api/v1/documents`,owner|admin)**——白名单加 `.pdf`;裁决 = **字节嗅探是唯一裁决权 ∧ 扩展名白名单**,自本版起**按对判定**(纯文本类必须 `text/plain`、`.pdf` 必须 `application/pdf`;落点 `internal/runtime/documents/store.go` 的 `ExtAllowed`(23 项)/ `ContentAllowed`);`mime` 仍是**嗅探基类型**(真 PDF → `application/pdf`);`415` 文案同时点明两条口径。**B. 读取(`GET /api/v1/documents/{name}`,必带 Bearer owner|admin)**——**真 PDF 如实下发 `Content-Type: application/pdf`**(读时按同一对裁决重嗅探,判不定者仍退 `application/octet-stream`),`Content-Disposition: attachment` + `X-Content-Type-Options: nosniff` **一字未变** ⇒ **PDF 永不被内联渲染**(与图片读侧刻意相反)。**C. 抽取语义(DTO `status`)**——**文本层 PDF 抽正文**(`Tj`/`TJ`/`'`/`"` 文本算子,页间以换行相接)⇒ `status:"extracted"`,与纯文本类走同一条 sidecar/注入路径;**扫描件/图片型 PDF ⇒ 诚实拒绝 `status:"unreadable"`**,原因含「这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)」,**绝不假装成功、也不返回空正文**;**加密**与**损坏/截断**是**可区分**的两类失败;安全阀 = 单份最多 5000 页 + sidecar 正文 ≤8 MiB。**D. 客户端可见的一次性重算**——抽取器版本内部升到 **`documents/v3`** ⇒ **旧 sidecar 一次性失效重抽**:同一份旧附件的 `chars`/`truncated` 结论会在**某次回合之后**重新计算(旧 PDF 若是文本层 PDF,将从 `unreadable` 变为 `extracted`)。**E. 未做(诚实边界)**——**不做 OCR / 扫描件栅格化**(纯 Go 无可靠实现,会撞「单二进制 + 交叉编译」「零跨进程 IPC」两条红线)、**不做 PDF 写/生成**(只读)、**不做 `.docx`/`.xlsx` 等 Office**(仍诚实拒绝);**无新端点、无新 WS 帧、无鉴权变更、无 `.env` 新增键**。**实现落点**:`internal/runtime/documents/{extract_pdf.go,extract.go,store.go,sidecar.go}` + 依赖 `github.com/ledongthuc/pdf`(纯 Go、无 cgo)。**上一版 `v0.60`**(**管理侧只读「存储状态」端点 + 面板存储卡接真数据**,依据主人 2026-10-04 实测反馈(面板首页「存储」卡一直是占位「SQLite」)与 design/10 §3 模块 14(系统设置)/ design/03 §3(pgvector 可选增强),2026-10-04 落实):**A. 新增 `GET /api/v1/admin/system/storage`**(**owner-only**、只读、**无审计动作**)→ `200 {data:{main,documents,vector}}`——① `main` = `{kind:"sqlite",path,fileBytes,walBytes,shmBytes,pageSize,pageCount,freelistPages,freeBytes}`(`path` 与备份/恢复端点同源;`walBytes`/`shmBytes` 边车不存在时如实为 `0`;`freeBytes = pageSize × freelistPages`);② `documents` = `{count,bytes}`(读 `documents` 表,**不 walk 目录**;空表 `bytes` 为 `0` 而非 `null`);③ `vector` = `{kind:"pgvector"|"none",enabled,ready}`——`kind` 说「配没配 `DB_PG_URL`」,`ready` 说「现在真的在跑什么」(配了但连接/schema 准备失败 ⇒ `enabled:true` + `ready:false`,客户端显示「已配置但未就绪(检索已降级)」,**别把 `enabled` 当生效**)。**B. 两条纪律**:① **不含凭据**——DSN / 用户名 / 密码 / `postgres://` 全文**一个字都不回**(`vector` 段只有三个键;有用例断言响应体里搜不到这些串);② **只读**——只有 `SELECT`/`PRAGMA` + `os.Stat`,复用组合根注入的**活连接**(不新开连接池、不写、不迁移、不备份,调用前后 `documents` 行数与数据目录文件集合均不变)。**C. `vector` 的真源**:组合根 `cmd/cyrene/wire_frontend.go` 的 `app.vectorState()` —— `Configured` 取 `DB_PG_URL` 非空、`Live` 取活索引的具体类型(`*vector.PGVectorIndex`);网关**只读这份注入值**,不读环境变量、不新开 PG 连接、不自行探测(避免面板与运行时给出两个答案)。**D. 失败不伪装**:未接活连接 / 路径未配置 / 读不到页级统计或 `documents` 表(例如库没跑迁移)⇒ `500 {error:{code:"storage_unavailable",message}}`,**绝不回一个全 0 的假快照**。**E. 面板**:`frontend/web` 的存储卡改为真数据(`value` = `SQLite 12.3 MiB`;`hint` = WAL / 页数 / 空闲页;详情两行 = `documents N 件 · …` 与 `PG 增强 …`),取数走与其它已接线卡片**同一条**加载/错误态(数据没到就显示「端点报错:…」,不回退成 `0`)。**F. 兼容性**:**纯新增端点 + 纯新增前端行为**,既有端点 / WS 帧 / 字段形状**一字未动**(前端 `MiniMetricSpec` 新增的 `extra?: string[]` 为可选补充详情行,不传时渲染与旧版逐字等价)。**上一版 `v0.59`**:(**文档进对话(`F1/F2`)+ 上下文预算按窗口推导(`T1`)+ 语音回合进编排(`G5/C1/C3`)+ `thinking` 可达性口径**,依据 design/24 §3/§4/§6(切片 S1–S5)、design/04 §1.3 + 附录 A.3(主人裁决 2026-10-04)与 `Cyrene-For-Android/devdocs/02-前后端协同事项.md` 的 G5/C1/C3,commit `a8851dc` / `a4789ec` / `20c1edb` / `5b2b385` / `72b95df` / `3a01b4e`,2026-10-04 落实):**A. 文档存储与摄取(两个新端点 + 一个可选请求字段 = 纯增量;DTO 形状变更是破坏性,但单数形状从未发布,故无兼容期)**——① **新增 `POST /api/v1/documents`**(owner|admin,`multipart/form-data`,字段 `file`)→ 首次 `201 {data:{name,url,mime,size,sha256,deduped:false}}`;**同字节同扩展名重复上传 → `200` + 同一 `name` + `deduped:true`**(客户端可当成功处理);单文件上限 **`CHAT_DOCUMENT_MAX_BYTES`(默认 20 MiB = 20971520)**,`≤0` 视为用默认;它与图片上限 `CHAT_IMAGE_MAX_BYTES` **刻意解耦**(改文档上限不会静默改掉图片行为)。② **新增 `GET /api/v1/documents/{name}`**:**必须带 Bearer(owner|admin)**——与图片读侧(免鉴权的能力 URL)**刻意相反**(文档摄取在服务端完成,不需要被 ``/provider 直接消费),响应带 `Content-Disposition: attachment` + `X-Content-Type-Options: nosniff`(浏览器永不内联渲染)。③ **类型裁决(重要)**:**字节嗅探是唯一裁决者**(`http.DetectContentType`;**v0.59 时**放行的嗅探基类型只有 `text/plain`,**v0.61 起改为 `(扩展名, 嗅探)` 成对裁决**、`.pdf` 走 `application/pdf`),**客户端声明的 `Content-Type` 与原始文件名只作参考、可被嗅探否决**;**扩展名白名单 deny-by-default,实际 **22** 个(⚠️ 历史值:**v0.61 起为 23 项**,纳入 `.pdf`)**:`.txt .md .markdown .csv .tsv .json .jsonl .yaml .yml .log .ini .cfg .toml .go .ts .tsx .java .c .h .cpp .rs .sql`(⚠️ design/24 §9.2 写「23 项」但**当时**所列清单与代码均为 **22** 项,**以代码 22 为准**、并已在 v0.61 变为 23 项,见 §1.2「发现的不一致」);**明令拒绝** `text/html`、SVG/XML、zip 及其封装(docx/xlsx)、可执行/脚本(exe/dll/bat/cmd/ps1/js/mjs/sh/py)——即便 `.js`/`.py` 嗅探成 `text/plain` 也拒(判定 = 扩展名 ∧ 嗅探的**合取**)。④ **边界(勿与图片混用)**:`url` 是**相对路径**(`/api/v1/documents/<32hex>.`;图片侧是绝对 URL);`mime` 是**嗅探所得基类型**(故 `data.json` 返回 `text/plain`,不是 `application/json`);落盘名 = `sha256(bytes)[:32] + ext`(**内容寻址**,原始文件名永不参与路径 ⇒ 同字节同扩展名天然同名去重)。⑤ **4 个错误码**:`unsupported_media_type`(415) / `document_too_large`(413) / `document_not_found`(404,含名字形态非法与路径穿越) / `too_many_documents`(400)。⑥ **`chat` 请求新增可选 `"documents":[{"name":"<32hex>."}]`**(REST 同步 / REST `stream:true` / WS `chat` **三处同形**):**≤5 条**(超出 `too_many_documents` 400,且**先于任何文件系统访问**);**只收服务端存储名、绝不收 URL**(不重开 F3 那种「服务端代取 URL」的 SSRF 面);客户端多带的 `mime/url/size` **一律被服务端覆盖**(MIME/大小取自落盘字节);名非法/穿越/不存在 → `document_not_found`(404);**任一条不合规都让整个请求被拒**,绝不「丢掉这条附件但照样起回合」(WS 侧经 `error` 帧带 `data.code` 下发并中止本回合,REST 侧回对应状态码)。⑦ **响应 / 历史 DTO 由 `document`(单对象)改为 `documents`(数组,≤5)**——元素形状 `{"name","mime","size","chars","truncated","status"}`,`status ∈ extracted|listed|unreadable`;**0 个附件时该键省略**;同一 DTO 供 `GET /sessions/{id}/messages`、`message_persisted`、`message_edited`、WS 握手回放 **四处同时生效**(同一个 `historyMessage` 结构)。⚠️ **原单数形状从未发布过**(不在任何已发布版本号里)⇒ **无兼容期**,直接换数组(design/24 §6.1 那句 `"document":{…}` 已随之作废)。⚠️ **`chars`/`truncated` 改为「本回合注入侧」语义**:`chars` = **本回合注入**模型上下文的字符数、`truncated` = **注入**是否被截断(**不是**「原文件是否被截断」);**全文长度**只有 `document-read` 的 `totalChars` 能看到。⑧ **内置工具 `document-read`**(模型侧工具,客户端一般只需知道它存在):`{name, offset?, limit?}` → `{name,status,offset,limit,totalChars,truncated,nextOffset,text,extractTruncated?}`;`limit` 默认 **8000** 字符、上限 **64K**;`offset` 单位是**字符(rune)**、0 基、起点 = sidecar 正文(含 CSV/TSV 摘要行)第 0 个字符;`offset<0` 或 `>totalChars` → 错误(`==totalChars` 合法,读到末尾空片段);`extractTruncated=true` **专指**「撞上 8 MiB sidecar 安全上限」(≠ 本回合注入被截断);策略默认 **owner-only**(群聊/陌生人天然读不到主人的文件)。**B. 配置面(纯增量 + 一处语义变更)**——① **`models.json` 组级新增 `contextWindow`**(正整数 = 该组模型的上下文窗口;`0`/省略 = 未声明 ⇒ 后端按 **131072** 保守估算并记**恰好一条** WARN;**负数 = 组校验失败**,写端点回 `400`)。 ② **`.env` 新增/变更 4 键**:`LLM_CONTEXT_WINDOW`(**per-group 播种**,等价于 `models.json` 的 `contextWindow`)、`CONTEXT_BUDGET_RATIO`(默认 **2/3**,接受 `2/3` 或 `0.6667`,非法值记 WARN 后回落)、`DOCUMENT_MAX_INJECT_CHARS`(单份附件注入上下文的字符上限;未设 = 按推导出的预算自动算)、**`CONTEXT_MAX_TOKENS` 语义变更(关键)**:**未设置 ⇒ 按窗口推导**(`budget = min(窗口 × 比例, 窗口 − 输出保留)`,输出保留 = `max(32768, 该组 maxTokens)` 且**封顶 `窗口/2`**;**1M 窗口 ⇒ 699050 tokens**);`>0` = 显式覆盖(原样使用);`0`/负 = **关闭压缩**。⇒ **`.env` 里若仍写着 `CONTEXT_MAX_TOKENS=32768`,它就是显式覆盖、推导不生效**(这是设计语义,**不是 bug**;本次已把 `.env.example` 里该行改成注释形式)。③ **`.env` → 面板配置的 materialize 语义(欠账,本次补登记)**:进程**每次启动**跑一次;**面板配置非空 ⇒ 组集合完全由面板决定**(env 不新增、不复活被面板删掉的组);**键级只在面板缺该键时用 env 补**,面板已有的键(含**显式空值 / 显式 `false` / 显式 `null`**)**绝不覆盖**;面板配置缺失/为空时才由 env 播种出合成组;**「面板改一次、重启被打回」是硬约束**(有专门用例钉住);每次启动记「补了哪些 / 跳了哪些」,**凭据绝不进日志**;范围目前**仅 `models.json` 一族**(`channels.json` / `plugins.json` 未纳入)。④ **面板读写面**:`GET /api/v1/admin/llm` 的组 DTO **含 `contextWindow`**(`omitempty`,与 `maxTokens` 同形:未声明时该键省略),`POST /api/v1/admin/llm` **接受可选 `contextWindow`**——**缺失 = 保留已存窗口**、**显式 `0` 与缺失同义**(都 = 未声明)、**负值 `400 bad_request` 且不落盘**;面板 LLM 页据此显示/编辑该键。**C. 语音(有行为变更,见 §3.15)**——语音轮次**现在走与文本同一条路径**:会**落库**(用户消息 = 转写文本,助手消息 = 回复)、会发 `message_persisted` 与流式帧、**跨端同步**、忙时按编排器既有规则**排队**(`pending_ack`/`pending_drain`/`session_busy` 语义零变化,只是多了一个消费者);`deviceId` 照旧落库、`platform` 走存储默认 `web`;`voice_input` 上行增可选 `clientMsgId`(与 `chat` 帧同形 ⇒ 语音也有幂等;重复到达回既有 `chat_duplicate`,**不产生新回合、不发音频**);`voice_output`(定向)载荷增 `sessionId`(恒有)与 `deviceId`(非空才有);**空正文**与 **TTS 失败/降级**都走既有 `provider` 降级信号(客户端据此提示「没生成声音」;**不再**把 `stopReason` 当语音内容);**时点**:`voice_output` 在**该回合结算时**才到(可能晚于若干状态帧,甚至由续跑回合或被 steer 注入的那一回合产生);定向目标是**发起 `voice_input` 的那条连接本身**(服务端**不按 deviceId 路由**);连接断开后**不补投**,客户端重连后带上同一 `clientMsgId` 可安全重试。**D. `G5`(纯增量:广播帧 +2 个可选键;新增一条定向错误帧,见 §3.2)**——① **`pending_full` 广播载荷**增 `clientMsgId?` / `messageId?`:前者 = **被拒那条消息**的幂等键(未携带则**整个键缺席**);后者**仅当该消息此前已落库/已认领时**才回填——**服务端绝不在此处新铸 id**(队列满的拒绝发生在幂等键认领之前,代铸会让客户端重试被判成「重复到达」而永远进不来)。② **新增定向帧(此前无文档登记)**:`{type:"error", data:{code:"chat_busy", message, sessionId, clientMsgId?, messageId?}}`,**只发发送者那条连接**、不广播;`code` 与 REST `409 chat_busy` **同码**(客户端一个 code 覆盖两条入口)。**E. `E3`:`thinking` 可达性口径(不改行为,只是把话说清)**——消息体的 **`thinking` 字段**在**实时帧**(`message_persisted` / `channel_persisted` / `message_edited`)与**握手回放**中**一律为空**(隐私与带宽的**有意**默认;实时思维链仍由 §3.1 的 `thinking.*` 流帧按 owner/admin 档下发);**历史消息的思维链**只有 `GET /sessions/{id}/messages`(owner|admin)返回。**上一版 `v0.58`**:(**新增 `.env` 键 `LLM_MULTIMODAL`(`bool`,默认 `false`):让 env-only 部署能声明「主模型自身能否收图」**,依据裁决 14.4.1 / design/06 §0(commit `4adfc9a`),2026-10-04 落实):**A. 新键语义(见 §2「模型配置契约」表)**——**`LLM_MULTIMODAL` 是 `models.json` 组 `multimodal` 的 env 等价物**,**只作用于 `.env` 合成组**(**无有效 `models.json` 时才生效**;有组文件时以该组的 `multimodal` 为准,与 `LLM_REASONING_KIND` 同口径):`true` = 图片**直进主模型上下文**(省掉工具那一轮模型调用、也不做有损文本摘要),**留空 / `false`(默认)= 把图交给 vision/OCR 工具换成文本描述**;**绝不凭 base URL / 模型名猜测**端点是否收图(声称端点不具备的能力,比把图交给工具更糟——工具至少回一段文本,design/06 §0 首条)。**B. 与 `LLM_VISION_MODEL` / `LLM_OCR_MODEL` 的关系**——后两者配的是**视觉/OCR 工具调用的模型**,**不代表主模型能收图**,与本键互不代表(见 §1 行 3)。**C. 兼容性(默认值即旧行为)**——默认 `false` ⇒ **老部署行为逐字不变**(引入本键之前,env-only 部署没有任何键能声明该能力,图片恒走 vision 工具);**非 bool 值(如 `maybe`)在配置解码阶段直接报错、启动失败**(fail-fast,**不静默回退成 `false`**——静默的「配了当没配」正是本键要消灭的)。**`LLM_MULTIMODAL=true` 后图片走主模型直收,属部署者显式选择**(形状由部署者声明,后端不替它猜)。**D. 实现落点**——`internal/config/config.go`(`LLMConfig.Multimodal`,默认 `false`)+ `internal/config/runtimeconfig/models.go`(`GroupFromEnv` 把两 provider 分叉都透传进合成组)+ `internal/config/runtimeconfig/multimodal_env_test.go`(默认 false / 显式 true 抵达合成组 / 进程 env 端到端 / 组文件优先 四组测试)。**无端点、无帧、无字段形状变更**(纯新增 env 键)。**上一版 `v0.57`**:(**13.4.5 目标态 steer 落地登记:新下行帧 `steer_ack`**,依据 design/15 §2.0/§2.1/§2.4/§3.x/§5(裁决 13.4.5 目标态,commit `3d8822c`),2026-10-04 落实:**A. 新下行帧 `steer_ack`(见 §3.14)**——`{type:"steer_ack", sessionId, data:{messageId, atStep}}`:忙时会话里新到的用户消息**被在飞回合吃进同一回合**时下发(`messageId` = 那条用户消息的**权威 id**,`atStep` = 吃进它的**模型步序号,1 基**);语义=「**该消息已被本回合吃进(已从 pending 移除)、尚未被正面回答**」⇒ 客户端应把对应气泡渲染为「已读、未答」的**灰态**,直到该回合的 `message.*` / `turn.end` 到达(**不需要第二个确认帧**)。**角色门**:它**携带具体消息身份**(客户端能定位到某一条气泡,而不只是会话忙闲),因此**不登记**进 `frameReachesAll` 的 7 个状态机信号白名单,落 fail-closed 默认档——**只发 `owner` + `admin`**(`viewer` 收不到);经编排器状态通道**由 hub 直接广播、不经 bus**,帧壳带 `sessionId`、**不带** `turnId`/`seq`。**触发时机**:只在**回合内边界**(loop 每一步开头:上一次模型步与它那一批工具调用**都已结束**、下一次模型调用**尚未开始**,此刻没有工具在飞)产生,且**只有该边界真的匹配到 pending 里、且已落库的消息时才发**——**不是每个 step 都发**(没可注入消息的边界一帧都不发,这是常态)。**B. 转向的三条不变量(design/15 §2.0)**——**I1(同会话在途回合数恒为 1)**:转向只注入、不取消,**绝不新开第二个回合**(loop 侧结构性地没有任何「起新回合」的分支,注入只是把消息并进**当前回合**下一次模型调用的视图);**I2(消息恰好被处理一次)**:注入=已消费——被注入的消息立刻从该会话 pending **移除**(按 pending 序号认领),**绝不被第二个回合再答一遍**,去重键=后端 `message.id`,且候选必须**同时**满足「在该会话转录里(最近 20 条窗口内)」与「在本编排器的 pending 里」;**I3(转向不取消已发起的工具)**:注入点只在回合内边界,此刻没有任何工具在飞,正在执行的工具**永远跑完**。**C. 注入的范围(口径)**:注入**只影响本轮视图**(每一步边界重新并入),**不写回运行窗口** ⇒ 既不进该回合的 **checkpoint 快照**、也不进**后台记忆提取的输入面**;被注入的消息**本身早在到达时已落库**(pending 路径即时持久化),注入**不新增、也不改写任何行**。**D. 失败一律让路**:未接转录存储 / 读转录失败 / 该消息尚未落库 / 本回合世代已被打断或被更新的回合取代(认领 `pending` 失败)时,**不发 `steer_ack` 也不注入**——该消息**留在 pending**,照旧作为自己的回合被回答,**绝不静默消失**。**E.** 另含一处**链路图纯增量键**(注入时该轮图的 input 节点带 `props.steered:true`,图仍只有一条主干——不是被画成第二轮),以及**纯新增帧**的兼容性口径(老客户端按未知帧忽略即可)。**上一版 `v0.56`**:(**协同项 A2 / G1 / G2 / G3 / G4 / F3 落地登记:`interrupt` 支持 `scope` + REST `stream:true` 不再静默失败 + `chat.image` 上限 + `clientMsgId` 幂等 + 视觉链路 URL 取回与类型裁决 + 既有错误陈述更正**,依据 `Cyrene-For-Android/devdocs/02-前后端协同事项.md` 的 A2 / G1 / G2 / G3 / G4 / F3(commit `7512d59` 等,2026-10-04 落实):**A. 上行 `interrupt` 新增可选 `scope`(A2)**——`{sessionId}` → `{sessionId, scope?}`,取值域 `{"", "all", "current"}`(**省略 / 空串 / `"all"` 三者同义**):`all` = 取消当前任务 + 清空待处理队列(**逐字不变的现状语义**);**`"current"` = 只取消当前任务、保留待处理队列**(队列消息按编排器正常续跑规则继续执行、带上会话尾部上下文);**其余取值 → 只回发送者** `{type:"error",data:{code:"bad_request"}}`(**定向**:不广播、不打断任何会话,绝不静默按默认执行);命中空闲会话为 no-op。下行 `session_interrupted` 帧形状**零变化**(`{sessionId, mode, dropped}`),但 `dropped` 恒等于**真的被丢弃**的待处理条数——`scope:"current"` 时为 `0`,且 `mode` 是该会话当时的**忙碌通道**(`thinking|executing|subagent`,**不是 `idle`**);`scope:"all"` 时 `mode` 为 `idle`、`dropped` 为清空的 pending 条数。**B. REST `stream:true` 不再静默失败(G2,行为变更)**——该分支改为与 WS `chat` 帧**同一条直达编排器的路径**(design/15 §2.1「会话消息不得被 drop」),**响应面**:受理成功后才回 `200 {data:{stream:true}}` / 正忙已续接排队 `202 {data:{stream:true,queued:true,queueDepth:n}}` / 队列满 `409 {error:{code:"chat_busy"}}` / 幂等键重复 `200 {data:{duplicate:true,messageId,sessionId}}`;**未接 loop → `500 {error:{code:"chat_unavailable"}}`**(旧文写 `200 {stream:true}`,现与同步路径同口径);仅**未接编排器**才回退总线派发,其失败回 `503 {error:{code:"chat_dispatch_failed"}}`(此前 `Publish` 的错误被吞掉,且传的是**请求作用域** ctx)。**`202`/`409` 从此真的会出现**(修复前因提前 `return 200` 永不出现)。派发用的回合上下文与请求作用域脱钩(`context.WithoutCancel` + 30 分钟上限),回合不会在客户端刚收到 200 时被连带取消。**向后兼容**:`stream:false` 与既有同步响应形状一字未动;依赖「任何情况都拿到 200」的客户端必须按上表分支,别再假设 `stream:true` 恒 `200`。**C. `chat.image` 上限(G3)**——`POST /api/v1/chat` 的 `image` 新增硬上限:**解码后 5 MiB**(与 `POST /api/v1/upload` 同口径;base64 字符串上限 = `ceil(n/3)*4 + 64` 字符,覆盖 data: URI 前缀),超限 → `400 {error:{code:"image_too_large", message}}`(文案指路 `POST /api/v1/upload`),在**任何解码 / 落库 / 入模型之前**拦下(超大 base64 因此不进历史、也不会被重连回放重传)。**事实(勿误读为「WS 也有这条 400」)**:WS `chat` 帧**今天已被传输层关死**——WS 读上限 `wsReadLimit = 4096` 字节/帧,超限直接断连,**到不了**这条判定 ⇒ **WS 上发真实图片本就必须「上传 + URL」**。**D. `clientMsgId` 幂等(G4)**——上行**新增可选** `clientMsgId`(REST `chat` 体与 WS `chat` 帧都支持;**省略 = 老行为逐字不变,老客户端零影响**):同一会话内同一个非空值只承认第一条。**重复到达**:REST → `200 {data:{duplicate:true,messageId,sessionId}}`;WS → 新下行帧 **`chat_duplicate`**(`{type:"chat_duplicate", sessionId, data:{sessionId, clientMsgId, messageId}}`,见 §3.13);重复**不落库、不起回合、不发任何内容 / 流式新帧**。**判重由 SQLite 部分唯一索引原子裁决**(`(session_id, client_msg_id) WHERE client_msg_id != ''`),并发的两次重发只有一个赢家,不存在「先查再插」的竞态窗口。`clientMsgId` 另**回填进消息 DTO** ⇒ `message_persisted` / `channel_persisted` / 握手 `history` 回放与 `GET /sessions/{id}/messages` 等读接口可见(空则省略),客户端据此把本地乐观气泡对齐到服务端真相(替代「靠文本相等」的脆弱匹配)。**迁移**:新增 `0013_chat_client_msg_id`(up:加列 + 部分唯一索引 `idx_chat_messages_client_msg_id`;down:先删索引再删列;全新库由 `history.NewStore` 幂等前滚)。**E. 视觉链路事实修正(F3)**——`chat.image` 的 http(s) URL(含 `/api/v1/files/`)现在由**服务端自己取回并内联为 `data:` URI** 再交 provider,**不再原样转给上游**;同源 upload 产物优先走**本地文件读取**。**类型裁决**:**字节嗅探是唯一裁决权**(声明的 Content-Type / 扩展名只作参考且可被否决),嗅探为文本(HTML / PROXY 错误页 / SVG)或判不定且来源未声明 `image/*` → **明确报错**(`vision.ErrNotImage`),**绝不静默当 PNG**。**对客户端无端点 / 帧 / 字段变更**。**F. 修正既有错误陈述(G1)**——`Docs/api-reference.md` §6.2 帧壳表与 §6.6 曾声称结构化流帧只带 `seq`、没有轮次标识,**是错的**:**v0.36 起**结构化流帧(`message.*` / `thinking.*` / `tool_call.*`)就带 `sessionId` + `turnId`(`broadcastStream` 从 `model.StreamEvent` 取,loop 在回合边界注入),v0.50 起另带 `streamId`;已按实现更正(与本文档 v0.36 条目一致)。**G. 环境变量登记**——`.env.example` 新增 **`OWNER_IDENTITY_INJECTION`**(`owner_only` 默认 / `always` / `off`;**非法值回退 `owner_only` 并记 WARN**),并登记 **`CONTEXT_MAX_TOKENS`**(默认 `32768`;`0` / 负 = 关闭压缩)与 **`GUARDRAIL_KEYWORDS`**(逗号分隔;留空 = 不挂关键词护栏),三键见 §2「安全 / 上下文环境变量(v0.56)」。**无新端点、无帧形状破坏**(唯一新增帧 `chat_duplicate` 为**纯新增**;REST `stream:true` 的响应面属**行为变更**,兼容点已在 B 写明)。**上一版 `v0.55`**:(**阶段 7 落地项登记:路径式安全入口 + 数据目录 `platform` + OneBot v11 四端 + 可观测实时推送**,依据裁决 1.6 / 16.4.7 / 12.4.2(commit `a398dc9`)、1.4 / 12.4.1(commit `9253fef`)、05.4.3 + 16.4.3-WS(commit `0e1f163`),以及 13.4.5 面内三条(commit `74b12d8`)、MCP 官方 SDK(commit `a1083a3`),2026-10-04 落实):**A. 安全入口 `ADMIN_PATH`(裁决 1.6 / 16.4.7,前端路由面)**——① env 新增 **`ADMIN_PATH`**(默认 `admin`):面板**前端**路由前缀 = `/` + 值(`admin` → `/admin`,`xxx/admin` → `/xxx/admin`;**非法值回退 `/admin`**——容忍首尾斜杠与空白,拒绝 `.`/`..` 段与 `?`/`#`)。② HTTP 行为:`/` 与其深链返回**注入了 `` 与 `window.__CYRENE_ADMIN_ENTRY__={"prefix":"..."}` 的 index.html**(`Cache-Control: no-store`);**形如面板入口(路径里出现 `admin` 段)但前缀不符的路径 → `404`**(不回落用户端聊天壳、不泄露正确前缀);`/` 若恰好命中 `dist` 真实文件则仍走文件服务器。③ **API 不藏**:`/api/v1/*`、`/api/v1/admin/*`、`/ws/chat`、webhook 一律原样,**无新端点、无帧变更**。④ **口径**:用户端 HTML **永不携带该前缀串**(后端只在命中前缀时注入)⇒ 用户端既无管理入口链接、也解析不出面板路由(design/07 §0.2;前端路由改**路径式**:`frontend/web/src/admin/router.ts` 只读注入、无硬编兜底、移除 hash 入口)。**B. 数据目录(同 commit,裁决 12.4.2)**——`DATA_DIR` 新增可接受值 **`platform`**(大小写不敏感)→ 平台标准目录;默认 `./data` 与显式路径行为不变;**显式空值行为不变**(仍走原兜底)。**C. OneBot v11 四端(`9253fef`,裁决 1.4 / 12.4.1)**——① 配置面(channels.json 账号 `config` + 适配器 `Config()` 快照)新增 `transport`(`ws`|`http`,默认 `ws`,未知值回落 `ws`)/ `api_base_url` / `report_path`(默认 `/`);快照 camelCase 为 `transport`/`apiBaseURL`/`reportPath`;`remoteURL` 快照值 = `remote_url`、为空时回落 `api_base_url`(语义即「出站远端地址」),**既有键与值不变**。② env 兜底 `CHANNEL_QQ_OBV11_TRANSPORT`(默认 `ws`)/ `CHANNEL_QQ_OBV11_API_BASE_URL` / `CHANNEL_QQ_OBV11_REPORT_PATH`。③ 四端语义:`ws+client` Cyrene 拨号、收发同一 WS;`ws+server` OneBot 反连、收发同一 WS;`http+client` Cyrene→OneBot(`POST {api_base_url}/{action}`,纯出站);`http+server` OneBot→Cyrene(POST 到 `listen_addr`+`report_path`,纯入站,fail-closed 鉴权、10MiB 体上限)。④ 取舍:`mode` 决定**哪一半必需**(server 必需 `listen_addr`、client 必需 `api_base_url`),另一半给齐字段就一并启用 ⇒ 单个适配器即可「收上报 + 回消息」。⑤ 端口例外:`http+server` 用 `listen_addr`,属 `AGENTS.md` §9 已登记的**同一豁免**(design/13 §0 原文即写「WS server / HTTP server」);`listen_addr` 为空则报错、**绝不开端口**。**D. 可观测实时推送(`0e1f163`,裁决 05.4.3 + 16.4.3-WS)——新增一节 §3.12**:三个**下行新帧** `observability_log`(`{entries:[{time,level,source,msg}…]}`,最新在前,上限 200 条)/ `observability_trace`(`{spans:[…]}`,上限 100 条)/ `observability_metrics`(`AdminMetrics` 本体、扁平),均**纯新增**(既有帧名/形状零改动)、帧壳**不带** `sessionId`/`turnId`/`seq`;三者**不在** `frameReachesAll` 白名单 ⇒ fail-closed **只发 owner+admin**(viewer 继续走 REST 只读档);订阅 = owner/admin 凭据**读取** `GET /admin/observability/{logs,metrics,trace}` 任一个(该次读取照常返回完整引导数据),该凭据 WS 连接全断即退订并停循环(**不是轮询、无常驻 goroutine**);**REST 端点完全不变**,`?level=` 分级过滤改由**客户端本地**做(帧广播给所有 `owner`/`admin` 连接,订阅只决定循环跑不跑)。**E. 其余(各一句)**:① **13.4.5 面内三条**(`74b12d8`)——续接窗口 `Recent` 在同毫秒并列时改为取**最新** n 条且按插入序(**无 API 变化**,但客户端可观察到的「握手回放 / 续接上下文」顺序更正确);上行 `chat` 不再经**可丢弃**的 bus(**帧序列不变**);被**取消**回合的 checkpoint 不再顶掉下一条消息,新增内部常量 `loop.StopReasonCanceled`(`checkpoints.state.stop_reason` 可能出现该值,**不经 REST/WS 暴露**)。② **MCP**(`a1083a3`)——协议层换成**官方 Model Context Protocol Go SDK v1.8.0**,**REST/WS/env/数据目录零变更**(`MCP_SERVERS_JSON`、`MCP_*`、`transport` 枚举 `stdio|http|streamable|sse` 全不变)。**未做(诚实边界)**:DashScope 形状的 `auto` 推断、Qwen 的 `/think` 前导消息软开关、`get_group_list` 枚举、上游显式退订帧(当前退订只发生在订阅凭据的连接全断时)、审计 tab 仍 5s REST 轮询(未做 `observability_audit` 帧)。**上一版 `v0.54`**:(**渠道配置写路径改 patch 语义(修 P0 级凭据静默丢失) + 阶段 6 前端批的纯前端行为登记**,依据 design/04 §3.1、design/22 §2 方案 b(与 `personaId` 同构的裁决延续,commit `4ae7148`)与裁决 16.4.1 / 16.4.2 / 16.4.3 / 16.4.4 / 16.4.5 / 16.4.6(commit `f454273` / `b480b04` / `c1a98d7` / `8d70f6d`)):① **`PUT /api/v1/admin/channels/{platform}/config` 的 `config` 由「整条替换」改为「按 id 打补丁」**(`runtimeconfig.PatchChannel`,**与 `personaId` 同构的三态**)——**键省略(该键不在 `config` 里,或 `config` 整体缺席 / 为 `null`)= 保留该账号已存值**、**给出非空值 = 覆盖该键**、**显式 `null` 或 `""` = 清除该键**(`0` / `false` 是真实取值,**不算空**);顶层便捷字段(`token` / `baseURL` / …)是普通 `string`,**只在非空时**并入补丁 ⇒ **要清除 `bot_token` 必须走 `config` 的显式 `null` / `""`**;`allowGroups` / `denyGroups` / `allowUsers` / `denyUsers` 与三个限流键**仍是整值替换**(省略 = 清空)。② **破坏性提示**:按旧文实现、依赖「省略某 `config` 键 = 删掉它」的客户端,必须改为**显式传 `null` / `""`** 才能清除该键。③ **修掉的数据安全问题**:面板只发送用户本次碰过的平台字段,后端却整条替换 ⇒ 保存**微信(iLink)**账号卡会抹掉 `bot_token` / `bot_id` / `owner_id` / `get_updates_buf`(**iLink token 是长期凭据**)、**Telegram** 只填 `webhook_url` 而不重填 token 会让 `cleanAccounts` **丢掉整个账号**。④ **未改**:token 要求型平台**显式**清 token 仍按 `cleanAccounts` 丢账号;「本次一个 `config` 键都没给出」在**非 `weixin-ilink`** 平台仍回 `400`。⑤ **无新端点 / 新字段 / 响应形状变更**。**同版另含阶段 6 前端批(纯前端行为,无 API 变化)**:① **16.4.1 静默/活动态与预约切换**——唯一入口 `frontend/web/src/components/modeSwitch.ts`:她活动时用户切换只登记「已预约」、静默时落地;② **16.4.5 会话级消息缓存**——帧按自己的 `sessionId` 落进该会话的消息桶(切会话不清空),**只有单槽忙态**仍按激活会话判定;`graph_update` 按 `data.sessionId` 入**图桶**,管理端 Flow 页取激活会话、Backthink 页取 `sess-backthink`(裁决 16.4.2);③ **16.4.3 / 16.4.6 虚拟滚动与专属页只读卡**——长列表超阈值窗口化、专属页只读展示该平台完整字段;④ **16.4.4 双档 UI 模式**——性能档 = 现状(默认)、华丽档落 `:root.ui-mode-fancy`,持久化键 `cyrene.ui-mode`(纯前端,对应 `design/07 §0.1`,不改后端契约)。**上一版 `v0.53`**:(**阶段 5 落地:模型目录端点 + per-persona 路由 / 本地三级 / capacity L3 / 视觉·OCR 独立模型 / `image-crop` 工具 / 按 provider 形状下发推理开关**,依据 design/04 §3/§3.1/§3.2(裁决 03.4.1 / 03.4.2 / 03.4.3 / 08.4.1)、design/06 §0/§3(裁决 14.4.1 / 14.4.5)、design/14 §2.2/§3/§4(裁决 14.4.2 / 14.4.3 / 14.4.4),2026-10-04 落实):① **新增管理只读端点 `GET /api/v1/admin/llm/catalog`**(**owner|admin** 读,`?refresh=1` 强制在线刷新)→ `200 {data:{source:"cache"|"models.dev", fetchedAt?, stale?, warning?, providers:[{id,name,api?,doc?,env?,npm?,models:[{id,name,attachment,reasoning,toolCall,contextLimit?,outputLimit?}]}]}}`;**既无本地快照又抓不到** → `503 {error:{code:"catalog_unavailable", message}}`;客户端带 `Accept-Encoding: gzip` 时响应带 `Content-Encoding: gzip`(**信封形状不变**)。**副作用只有一处**:落盘快照 `/llm_catalog.json`(**不含任何凭据**、`data/` 已 gitignore、TTL 24h),**不改 `models.json`**;**抓取只发生在本端点内,不进运行时热路径**(模型请求链路从不读外网目录)。详见 §2「模型配置契约(v0.53)」。② **`models.json` 组级新增键**(载体是组,**可热更新**,未声明时该键不出现):`reasoning: {kind: "auto"|"deepseek"|"openai"|"vllm"|"dashscope"}` 决定该组用哪种**提供商开关形状**下发推理指令(**非法值被 `Group.Validate` 直接拒绝**;anthropic 组上声明 openai 系形状同样拒绝)、`personaModels: {personaId: modelId}` 与 `personaProviders: {personaId: "openai"|"anthropic"}` 为**角色级模型/提供商路由**(**非法 provider 值同样拒绝**;未绑定/未知角色回落该组默认)。③ **语义修正(重要)**:**`multimodal` = 主模型自身能否收图**;`LLM_VISION_MODEL` / `LLM_OCR_MODEL` **只配视觉/OCR 工具用的模型**——两者互不代表(见 §1 行 3)。④ **`.env` 新增 9 键**(见 §2「模型配置契约(v0.53)」表与 `.env.example`):`LLM_REASONING_KIND`(默认空 = `auto`)、`LLM_OCR_MODEL`、`LLM_LOCAL_ENABLED`(默认 `false`)/`LLM_LOCAL_PROVIDER`/`LLM_LOCAL_BASE_URL`/`LLM_LOCAL_API_KEY`/`LLM_LOCAL_MODEL`、`CAPACITY_L3_ENABLED`(默认 `false`)/`CAPACITY_L3_CACHE_TTL`(默认 `5m`)。⑤ **内置工具新增 `image-crop`**(按区域裁切图片:`x`/`y`/`width`/`height` → PNG data URI + 裁切尺寸;供大图分区精读;**策略默认 owner-only**)。⑥ **推理开关按形状下发(旧陈述修正)**:v0.41 的「Qwen 一类只 WARN、请求一字不改」**已作废**——形状由 `reasoning.kind` 声明后**真下发**(→ 字段名映射表见 changelog v0.53 条),**只有形状未知/有歧义时才 WARN 且一个字段都不发**;`reasoning_effort` 的非法值由「降级到最近一档」改为**丢弃并记 WARN(不降档)**,`effort:none` 一律翻成**显式关思考**。**未做**:DashScope 形状的 `auto` 推断(只能在显式声明 `reasoning.kind=dashscope` 时下发,绝不从模型名/BaseURL 猜)、Qwen 的 `/think` 前导消息软开关(与 design 口径冲突,未实现)。**上一版 `v0.52`**:(**工具策略落盘 + 主代理代审小字 + 流程图节点参数摘要**,依据 design/11 §3/§4/§8(裁决 1.3 / 10.4.2 / 13.4.3)与 design/19 §1/§1.1(裁决 11.4.2 / 11.4.3 / 13.4.1),2026-10-04 落实):① **工具策略(`allowInGroup`/`allowInStranger`)落盘 SQLite `tool_policies`(迁移 `0012_tool_policy`),重启保留**——`registry.SetPolicy` **先落库后改内存**,落盘失败则内存保持旧值并返回错误(见 §2 行 5);② 新增下行帧 **`approval_notice`**(§3.11):敏感操作经主代理代审后推一行小字(`explain` 由**后端产出**、客户端原样渲染),审计口径 `who`=角色名 / `what`=`approval.<工具名>` / `result`=`granted|denied`,**仅 `owner` + `admin`**(fail-closed 默认);③ `graph_update` 节点 `props` 新增四类**纯增量可选**键(tool `args_keys`/`args_summary`、guardrail `action`/`allow`/`elapsed_ms`、memory `action`/`scope`/`hits`/`bytes`/`count`/`failed`、orchestration `subagent`/`persona`),`memory`/`guardrail`/`orchestration` 旁路节点 span **首次真进图**,值在发布前已脱敏——**帧名、事件名、顶层字段集一字未动**(见 §3.5)。**上一版 `v0.51`**:(**后台子代理结果登 WS 帧 + 回填父会话**,依据 design/05 §9「子代理完成 → 结果回填主会话并提示用户」,裁决 02.4.3,2026-10-04 落实):新增下行帧 **`subagent_result`**(`{type, sessionId, data:{sessionId, status:"done", summary, agents:[{id, goal, role?, mode?, status, summary?, text?, steps?}]}}`,见 §3.10)——`subagent_spawn` 的批次转后台后结算时,编排层发布在 bus 主题 `subagent.result` 上的合并结果,此前**没有任何消费者**(中继既非 `ws.*` 也不认该 4 段以外的 subject)⇒ 结果上不了前台、只剩进度帧;现在该 subject 被登记为中继并成形为这一帧,**帧壳 `sessionId` = 派生该批次的父会话**,结果因此落回**父会话的实时流**。**角色门**:新帧属内容帧 → **只发 `owner` + `admin`**(fail-closed 默认,未扩 7 个状态机信号白名单)。**向后兼容**:纯新增帧,老客户端按未知帧忽略即可,既有帧/端点/鉴权一律不变。**诚实边界**:结果**不落库**(父会话转录里没有新消息、重连回放不补发)、**未**经 design/15 会话消息编排器续接、**前端尚无该帧 handler**(今天的效果是「帧到达 owner/admin 连接」)。**同版另含管理面(D 组,2026-10-04 落实)**:① **`X-API-Key` 真正接线**(裁决 15.4.2):管理/用户侧已认证端点同时接受该头,命中签发表即通过且**一律 `owner` 级**(裁决 15.4.3 取消 role 分级),带该头时只走 key 分支、未知/已吊销/空值回 `401` 且不回落 Bearer(fail-closed),WS 握手仍只认 Bearer;② **新增主人白名单档案读写** `GET`/`PUT /api/v1/admin/owner-profile`(裁决 10.4.5,**owner-only**,写带审计 `owner.profile.update`)——面板的「保存」从此是**真保存**(落盘 `configs/users/owner.yaml` 0600 原子写 + 热替换活档案,渠道 actor 判定立即生效),不再是只改本地 state 的假成功;③ **persona `identity.systemPrompt` 改必填**(裁决 08.4.2):缺失/空白回 `400 bad_request`,**不再派生默认说明串**,写出的 YAML 必能被载入期同一规则重新载入。见下方「管理面板写操作契约」。**上一版 `v0.50`**:(**事件批:流式帧的结构化补齐**,依据 design/04 §1.1,裁决 01.4.1 / 01.4.2 / 01.4.3 / 03.4.4,2026-10-04 落实):① **`tool_execution` 拆三事件**——旧单帧 `tool_execution.update {id,index,delta}`(phase 与工具名拼在 `delta` 字符串里)被 **`tool_execution.start`**(进入执行,`phase:"running"`)/ **`tool_execution.update`**(运行中进度/局部输出,可多帧)/ **`tool_execution.end`**(完成或失败,`phase:"done"|"error"`)替换,载荷改为结构化 `{tool, phase, id?}`(`tool` = 工具名;`phase` = 细粒度执行阶段,与 §3.5 流程图节点 status 同一套 `running/done/error` 枚举;`id` = provider 工具调用 id,可与 `tool_call.*` 帧关联)——**phase 不再塞进 `delta`**;loop 现已真实发出 start/end(update 的 wire 映射已实现并验证,但工具分发器尚无中途进度上报源,**暂无生产者**,见 §3.1)。② **`streamId`(模型流帧 `data` 内新增可选字段)**:同一次模型流(一次 ChatStream 调用)的多帧共享同一 `streamId`,多步工具轮的第二次模型调用取新 id;与帧壳的 `turnId`(整个回合恒定)分工——`turnId` 标记「这轮回答」,`streamId` 标记「这轮里的哪一条流式消息」。③ **`turn.start` / `turn.end`(新帧)**:loop 在回合边界发出、经 ws_bridge 下行;**无 `data` 载荷**(帧壳的 `sessionId`/`turnId`/`seq` 即边界信号;回合终态信息仍由既有 `stream_end` 携带);`turn.end` 覆盖回合的所有出口(最终答案 / 工具循环安全网终止 / 预算耗尽 / 步骤出错),被静音的内部流程(backthink / 子代理)不发出。④ **向后兼容**:`turn.start/end` 与 `streamId` 为纯新增,老客户端可安全忽略;**破坏性**在旧 `tool_execution.update` 单帧——loop 不再产生它,按旧形状(解析 `delta` 里的 `phase:工具名` 拼串)消费的客户端必须迁移到三事件;现有 Web 前端未消费这些帧,行为不变。⑤ 本批新帧全部落在 v0.48 下行帧门的「仅 `owner` + `admin`」档(fail-closed 默认,无需扩白名单)。⑥ **`graph_update` 会话范围口径修正**(裁决 01.4.5,随实现事实补记):`web` / `web:` / `sess-backthink` 三范围(见 §3.5)。见 §3.1 与 v0.50 变更记录。**上一版 `v0.49`**:(**WS 握手回放与上行帧的角色门(design/11 §7.2)**:补上 §7.1 下行门之外的**同一类缺口的另两处**,对 `viewer` **继续收紧**,`owner` / `admin` 行为不变,**无端点、无字段形状变更**——① **握手 `history` 回放改为仅 `owner` + `admin`**:此前任意角色一连上 `/ws/chat` 就会收到主会话最近 50 条消息正文(`thinking` 已剥离,但**正文本身**就是主人的对话),`viewer` 从此**不收**;② **上行 JSON 帧按角色授权(fail-closed)**:非 `owner`/`admin` 连接发来的**任何**上行 JSON 帧一律拒绝,**定向**(只回发送者)回 `{type:"error",data:{code:"forbidden"}}`,**不广播、不驱动模型、不落库、不返回任何内容**——被拒帧含 `chat`(此前 `viewer` 可**真的驱动模型并落库**)、`interrupt`、`subagent_ask` / `task_output_query`(其应答是**定向**帧、能绕过 §7.1 下行门把子代理上下文/任务输出直接回给 `viewer`)、`voice_input`、`mode_switch`,且**未登记的新上行帧默认拒**;**协议级帧(ping/pong、close)不受影响**。③ **合口径**:`viewer` = **可连接、可读 12 条只读监控 REST(v0.40)、可收 7 个状态机信号帧(v0.48)**;**除此以外 WS 上拿不到任何内容、也发不出任何指令**。见 §3 开头的「上行帧与握手回放的角色门」与 v0.49 变更记录。**上一版 `v0.48`**:(**WS 下行帧的角色门(design/11 §7.1)**:hub 的下行现在是**按帧类型分档**的——只有 7 个**状态机信号**(`session_busy` / `pending_ack` / `pending_drain` / `session_idle` / `session_interrupted` / `pending_full` / `session_read`,**均不含任何内容**)继续发给**所有已认证连接**(含 `viewer`),**其余全部下行帧**(流式内容 `message.*` / `thinking.*` / `tool_call.*` / `tool_execution.update` / `stream_chunk` / `stream_end` / `thinking`;持久化 `message_persisted` / `message_edited` / `message_deleted` / `channel_persisted`;系统内部 `graph_update` / `subagent_event` / `task_output` / `task_done` / `tool_used` / `capacity_awaken` / `mode_switch`;私有状态 `mood` / `device_update` / `reminder`)**改为仅发 `owner` + `admin`**)。**对 `viewer` 是收紧**:此前它连上 `/ws/chat` 就能收到角色回复正文与推理内容,现在收不到;**`owner` / `admin` 行为不变**;**无端点、无字段形状变更**——`viewer` 的 12 条只读监控 REST 端点(`viewerAuth`,v0.40)与 `/ws/chat` 的连接鉴权都**照旧**;**新帧类型默认只发 owner/admin**(fail-closed)。见 §3 开头的「下行帧的角色门」与 v0.48 变更记录。**上一版 `v0.47`**:(**`graph_update` 子代理泳道根节点新增可选 `props.persona`**:派发子代理时若指定了目标角色,该分支根节点的 `props` 就多一个 `persona` 键,标注它**正在扮演的角色**——用途是让流程图能显示「这个子代理是以谁的身份在替班」,而不只是子代理 id;**纯增量**:不带角色派发时该键不出现,老客户端忽略未知键即可,见 §3.5 与 v0.47 变更记录)。**上一版 `v0.46`**:(**`PATCH /api/v1/sessions/{id}` 的 `personaId` 由「可重新绑定该会话的角色」改为「出现即 `400`」**:**会话角色在创建时确定、之后不可变**,要换角色请新开会话(design/22 §8-1)——**明确拒绝**而非静默忽略/200 空操作;**键出现**(任何取值:`"yuki"` / `""` / `null` / 未知 id / 空白串)一律 `400 bad_request` 且消息点名 `personaId`,该检查**先于**会话存在性检查(未知会话 + `personaId` 也回 `400` 而不是 `404`)。**破坏性**:按 v0.42–v0.45 旧文实现、依赖「PATCH 换角色 / 显式 `""` 解绑」的客户端必须改为**新建会话**选角色;`title`/`pinned` 行为与 `POST /sessions {personaId}`(创建时绑定)**完全不变**,不再有 `501/200 {personaId}` 的 PATCH 路径)。上一版 `v0.45`:`PUT /admin/channels/{platform}/config` 的 `personaId` 由「省略/`""` 都 = 解绑」修订为三态(**省略(或 `null`)= 保留已存绑定**、**显式 `""` = 解绑**、**非空 id = 绑定**,未知角色仍 `400`)。 ## 2. REST 端点(管理,`/api/v1/admin/*`) @@ -204,14 +204,14 @@ ### 2.0 管理侧权限矩阵(v0.40 起,与 `internal/services/gateway/routes.go` 逐条一致) -- **匿名(无 token / token 无效)**:任意 `/api/v1/admin/*` → `401 {error:{code:"unauthorized"}}`(fail-closed,全部 **59** 条管理路由无一例外 —— 12 条 viewer 档 + 10 条 owner/admin 档 + 37 条 owner-only 档)。 +- **匿名(无 token / token 无效)**:任意 `/api/v1/admin/*` → `401 {error:{code:"unauthorized"}}`(fail-closed,全部 **60** 条管理路由无一例外 —— 12 条 viewer 档 + 10 条 owner/admin 档 + 38 条 owner-only 档)。 - **角色分档**(`✅` 放行,`❌` 回 `403 forbidden`;owner>admin>viewer 递增): | 端点组 | owner | admin | viewer | |------|:---:|:---:|:---:| | **只读监控档**(12 条 GET,见下①) | ✅ | ✅ | ✅ | | **owner/admin 只读档**(10 条,见下②) | ✅ | ✅ | ❌ | -| **owner-only 写/凭据档**(37 条,见下③) | ✅ | ❌ | ❌ | +| **owner-only 写/凭据档**(38 条,见下③) | ✅ | ❌ | ❌ | ① **`viewer` 可读的 12 条只读监控档**(`viewerAuth` = owner/admin/viewer)——模块清单 + 运行时指标/日志/通知,全部是**非敏感投影**(无凭据、无他人账号数据、无消息正文): `GET /admin/personas`、`GET /admin/sessions`、`GET /admin/plugins`、`GET /admin/plugins/installed`、`GET /admin/channels`、`GET /admin/iot`、`GET /admin/automations`、`GET /admin/rag`、`GET /admin/observability/metrics`、`GET /admin/observability/trace`、`GET /admin/observability/logs`、`GET /admin/notifications`。 @@ -219,8 +219,8 @@ ② **仅 `owner`/`admin` 的 10 条**(`viewer` → `403`;理由:含**他人数据 / 隐私正文 / 完整导出 / 审计流水 / 全局凭据面**): `GET /admin/memories`(私人记忆正文)、`GET /admin/channels/messages`、`GET /admin/channels/{platform}/messages`(完整 IM 消息流/转录)、`GET /admin/llm`(provider/模型配置面)、`GET /admin/llm/catalog`(模型目录候选面,**v0.53 新增**)、`GET /admin/users`(账号与 API key 列表)、`GET /admin/backthink`(后台思考记录)、`GET /admin/observability/audit`(审计流水)、`GET /admin/sessions/{id}/export`(会话导出)、`POST /admin/rag/query`(知识库检索,语义上是读,但按「全局知识面」归此档)。 -③ **仅 `owner` 的 37 条**(`admin` 与 `viewer` 均 `403`)——全部写操作,外加**凭据获取路径**(`login-qr`/`login-status`,`view`/`friends`,渠道连接/测试/配置)与**主人身份档案**(决定谁能进 owner 车道,连读都不给 admin): -`GET /admin/channels/{platform}/login-qr`、`login-status`、`view`、`friends`;`POST /admin/channels/{platform}/friend/handle`、`message/test`;`PUT|DELETE /admin/channels/{platform}/config`;`POST /admin/channels/open|close|test`;`POST /admin/plugins/policy|install|uninstall`;`POST /admin/personas`、`PUT /admin/personas/{id}`;`GET|PUT /admin/owner-profile`(v0.51);`POST /admin/users`、`POST /admin/users/apikey`、`DELETE /admin/users/apikey/{id}`;`POST /admin/iot/{id}/control`、`POST /admin/iot/scenarios`、`POST /admin/iot/rules`;`POST /admin/automations`、`POST /admin/automations/{id}/run`、`DELETE /admin/automations/{id}`;`POST /admin/llm`、`POST /admin/llm/test`;`POST /admin/memories`、`POST /admin/memories/search`;`POST /admin/rag`、`POST /admin/rag/delete`;`POST /admin/system/revoke-keys|rotate-jwt|backup|restore`。 +③ **仅 `owner` 的 38 条**(`admin` 与 `viewer` 均 `403`)——全部写操作,外加**凭据获取路径**(`login-qr`/`login-status`,`view`/`friends`,渠道连接/测试/配置)与**主人身份档案**(决定谁能进 owner 车道,连读都不给 admin): +`GET /admin/channels/{platform}/login-qr`、`login-status`、`view`、`friends`;`POST /admin/channels/{platform}/friend/handle`、`message/test`;`PUT|DELETE /admin/channels/{platform}/config`;`POST /admin/channels/open|close|test`;`POST /admin/plugins/policy|install|uninstall`;`POST /admin/personas`、`PUT /admin/personas/{id}`;`GET|PUT /admin/owner-profile`(v0.51);`POST /admin/users`、`POST /admin/users/apikey`、`DELETE /admin/users/apikey/{id}`;`POST /admin/iot/{id}/control`、`POST /admin/iot/scenarios`、`POST /admin/iot/rules`;`POST /admin/automations`、`POST /admin/automations/{id}/run`、`DELETE /admin/automations/{id}`;`POST /admin/llm`、`POST /admin/llm/test`、**`POST /admin/llm/reset-from-env`(v0.62 新增,破坏性写——覆盖面板对若干键的修改,因此门与同页两条 LLM 写端点一致,**不比同族更宽**)**;`POST /admin/memories`、`POST /admin/memories/search`;`POST /admin/rag`、`POST /admin/rag/delete`;`POST /admin/system/revoke-keys|rotate-jwt|backup|restore`。 > **用户侧同级只读**(非 admin 前缀,同样任意已认证角色可读):`GET /api/v1/sessions`、`GET /api/v1/personas`、`GET /api/v1/meta/enums`(§1 行 4/9/15)。 > **已知遗留**:前端管理面板**尚未**按角色隐藏/禁用分区——`viewer` 打开②③档页面会拿到 `403`,客户端需按本矩阵自行降级(后端契约已定,前端适配后置)。 @@ -238,6 +238,7 @@ | 8 | `/api/v1/admin/users` | GET/POST | ✅(GET) | 用户/角色/API key(GET 已实现;**key 的签发/吊销已实现**——`POST`/`DELETE /admin/users/apikey`,见下方写操作契约;`POST` 创建**用户**仍 `501`) | | 9 | `/api/v1/admin/llm` | GET/POST | ✅ | LLM 多厂商多模型(models.json):GET 列表(APIKey 脱敏,含 `provider` 协议与 `maxTokens` 预算);POST 保存组→落盘 `models.json`→**热更新**运行中的 LLM stack(design/04 §3.1,无需重启)。**v0.53**:组级新增 `reasoning` / `personaModels` / `personaProviders` 三键(见下方「模型配置契约(v0.53)」) | | 9a | `/api/v1/admin/llm/catalog` | GET | ✅ | **模型目录候选**(owner/admin 读,v0.53;`?refresh=1` 强制在线刷新)——面板「添加提供商 / 添加模型」的 baseURL / 模型清单 / 能力位来源,服务端快照 + 离线兜底;失败 `503 catalog_unavailable`。见下方「模型配置契约(v0.53)」 | +| 9b | `/api/v1/admin/llm/reset-from-env` | POST | ✅ | **重置为 env 默认**(**owner-only 写**,v0.62,审计 `llm.resetFromEnv`):body `{"confirm":true}`(缺 = `400`)→ `200 {data:{reset:[{group,keys}], skipped:[{group,reason}], path}}`——**只覆盖 `.env` 声明过的键**、env 未声明的键原样保留、**组集合不增减**,无键可改时**不写盘**;**只回键名、绝不回显凭据值**。见下方「重置为 env 默认契约(v0.62)」 | | 10 | `/api/v1/admin/automations` | GET/POST | ✅(GET) | 自动化与提醒(GET 已实现;POST 创建 M10) | | 11 | `/api/v1/admin/backthink` | GET | ✅ | 后台思考记录 | | 12 | `/api/v1/admin/rag` | GET | ✅ | 知识库文档列表(分块/嵌入状态);带 `?query=` 时并入检索结果 | @@ -331,6 +332,26 @@ - **范围**:目前**仅 `models.json` 一族**(`channels.json` / `plugins.json` **未纳入**)。 - **与面板保存路径的分工**:materialize **只在启动时**跑;面板保存走「整组写入 + 热重载」,**那里不跑补齐**——否则面板刚显式清空的键会被立刻补回。 +**「重置为 env 默认」契约(v0.62,`POST /api/v1/admin/llm/reset-from-env`)**(任务书 R1;判定以 `internal/config/runtimeconfig/reset.go` + `internal/services/gateway/admin_write_llm_reset.go` 为准): + +- **它是什么**:materialize(上一节)的方向是「面板缺该键 ⇒ 用 `.env` 补」;本动作是它的**反方向显式动作**——部署者明确要求「**把面板里这些键退回 `.env` 声明的默认值**」(例如面板把 `apiKey`/`baseUrl` 改坏了,一键回到 env 基线)。两者**共用同一张键表**(`runtimeconfig` 的 `groupFillSpecs`),因此「本动作能改回的键集合」与「materialize 会补齐的键集合」结构性一致。 +- **鉴权 / 审计**:**`owner` 专属**(与同页 `POST /admin/llm`、`POST /admin/llm/test` **同门**;`admin` 与 `viewer` 均 `403`)——它是破坏性写(覆盖面板对若干键的修改),**门不比同族写端点更宽**;带审计动作 **`llm.resetFromEnv`**(成功 `ok`、被拒/失败 `error`;多词动作沿用本仓既有 camelCase 约定,与 `llm.addProvider` / `persona.update` 同风格)。 +- **请求**:body **必须** `{"confirm":true}`。**缺失 / `false` / 非法 body 一律 `400 {error:{code:"bad_request"}}`**,且**一个字节都不动**——本动作用 env 值覆盖面板修改,属破坏性操作,缺护栏的默认必须是「不执行」。 +- **语义(逐条)**: + - **只动 `.env` 声明了非空值的键**(`provider` / `baseUrl` / `apiKey` / `model` / `contextWindow`——与 materialize 的补齐表同集;`contextWindow` 只认正数,`fallback` / `multimodal` / `maxTokens` / `reasoning` / `personaModels` 在 `.env` 里没有出处,**永不被本动作改动**)。缺省协议族按**组自己的 `provider`** 选(`.env` 同时配了扁平块与 `ANTHROPIC_*` 时,两族各拿自己的值)。 + - **env 未声明的键一律保留面板值**(含面板显式清空/`false`/`null` 的键)。 + - **组集合不增减**:不复活被面板删掉的组、不删面板新增的组,**顺序也不动**(本动作只改键值)。 + - **面板值已与 env 一致的键不算「改回」**,因此不出现在 `reset` 里(`provider` 恒属这种情形:env 默认层总为两族各声明一次协议名,而面板组本来就与之相同)。 + - **没有任何键需要改时一个字节都不写**(幂等;重复调用第二次会返回 `reset:[]` + 每组一条「已一致」的 `skipped`)。 + - **真的改了盘就热重载**运行中的 LLM 供应商栈(design/04 §3.1,无需重启)——但注意 `contextWindow` 的**预算推导**仍只在进程启动时算一次(与 `POST /admin/llm` 同一口径,面板文案已写明「重启后生效」)。 +- **响应**:`200 {data:{reset:[{group,keys:[…]}], skipped:[{group,reason}], path}}` + - `reset`:**真的被改回**的分组,`group` = 组 id、`keys` = `models.json` 的 JSON 键名数组(顺序 = 补齐表顺序)。**只回键名,值(尤其 `apiKey`)绝不回显。** + - `skipped`:未被改动的分组 + 中文原因(`"面板值已与 `.env` 默认一致,无需重置"` / `` "`.env` 未声明该组的默认键(面板值原样保留)" ``)。两组键**恒为数组**(无记录时是 `[]`,不是 `null`)。 + - `path`:被改写(或本应改写)的 `models.json` 路径,与 `POST /admin/llm` 同源。 +- **对照基准**:**请求时刻按既有 config 解析入口读到的 `.env` 默认层**(`config.LoadLLMDefaults`;优先级与启动时 `config.Load` 逐字一致:进程环境 > `.env` > 标签默认)。⇒ 部署形态(systemd/env_file,无 `.env` 文件)下读到的是进程环境里那一份;`.env` 改了而没重启时,本动作按**文件当前内容**回退,而重启后 materialize 也用同一份——两者不冲突。 +- **错误(都绝不改文件,且都带审计 `error`)**:`.env` 源不可读 → `500 {error:{code:"env_defaults_unreadable"}}`;面板配置不存在/不可解析 → `500 {error:{code:"models_unreadable"}}`;写盘失败 → `500 {error:{code:"llm_persist_failed"}}`;已写盘但热重载失败 → `500 {error:{code:"llm_reload_failed"}}`(说明文件已改、未生效);LLM 栈未注入 → `500 {error:{code:"llm_unavailable"}}`。 +- **面板**:LLM 页的「**重置为 env 默认**」卡片(`frontend/web/src/admin/modules/llm/LlmResetFromEnv.tsx`,行上标 `owner 专属`)——点按钮先展开中文**二次确认**文案,确认后才带 `confirm:true` 提交;结果按 `reset`/`skipped` 渲染成可复核的组名 + 键名摘要。 + **管理面板写操作契约**(`POST/PUT/DELETE /api/v1/admin/*`,均 **owner-only**,读为 owner/admin;写操作经 audit 落 `audit_logs`;成功 `{data}` / 失败 `{error:{code,message}}`): - `POST /admin/personas` body `{id,name,role,systemPrompt,characterRef?,modes?/mode?,memoryNamespace?,thinking?}` → `201 {data:PersonaSummary}`(**v0.43 起与读端点同形状:含 `mood` + `thinking?`**;注册进 persona registry + 持久化到 `configs/personas/.yaml`);id 已存在回 `409 duplicate`。**`systemPrompt` 自 v0.51 起必填**(落的 YAML 键是 `identity.systemPrompt`):**缺失/空白 → `400 bad_request`**(消息 `"systemPrompt is required (identity.systemPrompt must not be empty)"`),**不再派生默认说明串**回落——载入期(`loader.Load`)执行同一规则,因此写出的 YAML 必能被重新载入。 - `PUT /admin/personas/{id}` body 同上(不含 id)→ `200 {data:PersonaSummary}`(**v0.43 起与读端点同形状:含 `mood` + `thinking?`**,面板可直接用它回填);不存在回 `404`。**`systemPrompt` 与创建同一把门**:v0.51 起同样必填,缺失/空白回 `400 bad_request`(同一条消息)——**不派生默认串、也不静默沿用旧值**,请求被拒即不落盘。 @@ -352,6 +373,7 @@ - `DELETE /admin/automations/{id}` → `200 {data:{id,deleted:true}}`;`404`。 - `POST /admin/llm` body `{id?,provider?,baseUrl,apiKey?,model,multimodal?,fallback?,maxTokens?,contextWindow?,reasoning?,personaModels?,personaProviders?}` → `201 {data:{provider:{id,name,provider,baseUrl,model,multimodal,enabled,fallback?,maxTokens?,contextWindow?,reasoning,personaModels?,personaProviders?}, persisted:true, path}}`。**真实持久化**:读-改-写 `models.json`(本地、gitignore),保存组提升为**生效组**(index 0),并**热更新**运行中的 LLM provider(`path` 为落盘文件路径);API key 写入文件但**永不回显/不落日志**。`provider` 为空默认 `openai`;缺 `baseUrl`/`model` 回 `400`;`maxTokens` / `contextWindow` 为负回 `400`;未注入 LLM stack 回 `500 llm_unavailable`、写盘失败 `500 llm_persist_failed`、热重载失败 `500 llm_reload_failed`。`maxTokens` 缺省/`0` = 未设(回落 `LLM_FALLBACK_MAX_TOKENS` / 适配器默认);`contextWindow` 缺省/`0` = 未声明(按回落窗口 131072 推导)。⚠️ **时机**:预算推导**只在进程启动时算一次**(`cmd/cyrene/wire_context.go` 组合期)⇒ 面板改窗口后**需重启**才重算(provider 栈本身是热更新的,两者口径不同,面板文案已写明「重启后生效」)。`.env` 为兜底。**v0.53 新增三键**(`reasoning.kind` / `personaModels` / `personaProviders`,语义与校验见上方「模型配置契约(v0.53)」):三者**缺省 = 保留已存值**,给出则 `reasoning` 整块替换、两张 persona 表**整表替换**;非法 `kind` / 非法协议名 / 未知 anthropic 形状 → `400 bad_request`。**v0.59 新增 `contextWindow`**(组级模型窗口,语义见上方「上下文窗口与预算契约(v0.59)」):**缺省 = 保留已存窗口**、显式 `0` 与缺省同义、负值 `400`。 - `POST /admin/llm/test` body `{provider?,baseUrl,model,apiKey?,prompt?}` → `200 {data:{ok,provider,model,reply,tokens}}`(**真实模型调用**);无效/失败回 `400 invalid_provider` / `500 llm_test_failed`。API key 仅本次请求内使用,不落库、不进 wire。 +- `POST /admin/llm/reset-from-env`(**v0.62,owner-only**,审计 `llm.resetFromEnv`)body `{confirm:true}`(缺 = `400 bad_request`)→ `200 {data:{reset:[{group,keys}], skipped:[{group,reason}], path}}`:把面板里 `.env` 声明过的键退回 env 值,**组集合不增减**、env 未声明的键原样保留、无键可改时不写盘,**只回键名不回显凭据值**。失败:`500 env_defaults_unreadable` / `models_unreadable` / `llm_persist_failed` / `llm_reload_failed` / `llm_unavailable`。完整契约见上方「重置为 env 默认契约(v0.62)」。 - `POST /admin/channels/open`、`POST /admin/channels/close` body `{name}` → `200 {data:{name,connected}}`(真实 Connect/Disconnect);`404`(未注册渠道)/`409 session_rejected`(微信 iLink 侧拒绝/作废了该会话,重连无法修复,须先「扫码登录」重新绑定,v0.39)/`409 needs_login`(微信 iLink 尚无 token)/`500`(其它凭据缺失等连接失败)。 - `POST /admin/channels/test` body `{platform?,accountId,isGroup}` → `200 {data:{role,owner,isGroup,env,verdict}}`。**结构性判定**:channel 层 actor 元数据 × `configs/users/owner.yaml` 白名单,绝不靠提示词。 - **微信 iLink「会话被拒」契约**(v0.39,适配器级,非端点):iLink 返回 `errcode=-14` 时适配器进入**显式被拒态**——`IsConnected()` 恒 `false`;`Connect()` **不再返回成功**,而是返回「需要重新扫码」类错误(`POST /admin/channels/open` 对应 `409 session_rejected`);`HealthCheck` 同样把「被拒」与「未连接」区分开。`GET /admin/channels` 该平台行带 **`needsRescan:true`** + **`lastError`**(人类可读原因,**绝不含 token**)。**清除条件**:扫码登录拿到新 token(`login-status` 报 `confirmed`)或配置里换了新 token(保存后热重载)。被拒期间:`login-status` **不**假报 `confirmed` 而是 `failed` + 被拒原因(避免面板把已作废的凭据当成功重新落盘);`login-qr` 仍可正常出码(**不**回 `already_logged_in` 挡住重新扫码);`get_bot_qrcode` 不再携带已被拒的 token(否则服务端会以「已绑定」应答而不发新凭据)。未实现该能力的平台:`needsRescan`/`lastError` **不出现**(`omitempty`),客户端按缺省处理。 @@ -699,6 +721,7 @@ - 语音:上行 `voice_input`(base64 音频或已转写 text,**v0.59 起可带 `clientMsgId` 幂等**),下行 `voice_output`(合成音频 base64 + text + **`sessionId`/`deviceId?`**,**定向回发起那条连接、回合结算时才到**,空正文/TTS 失败带 `provider` 降级信号);**语音回合与文本回合走同一条路**(落库 / 跨端 / 忙时排队),见 §3.15。 ## 5. 变更记录 +- `v0.62`(**面板「重置为 env 默认」动作落地:materialize 的反方向显式动作 + 一个新的 owner-only 管理写端点**,依据任务书 `DevDocs/_dispatch/R1-面板重置为env默认.md` 与 design/04 §3.1(`.env` 为默认/播种层、面板文件为一等来源)和 `DevDocs/开发时间线.md` 的欠账项(「Materialize 已导出、未接端点」),2026-10-04 落实)。**三档标注**:**纯增量** = 新端点 `POST /api/v1/admin/llm/reset-from-env`、面板 LLM 页的新卡片、审计动作 `llm.resetFromEnv`、2 个新错误码(`env_defaults_unreadable` / `models_unreadable`,另复用既有 `llm_persist_failed` / `llm_reload_failed` / `llm_unavailable`);**行为变更** = 无(既有端点 / 帧 / 字段一字未动);**破坏性** = 无(新端点的新用途;它本身的破坏性由请求体 `confirm:true` 强制)。**A. 新端点**(**owner-only**——与同页 `POST /admin/llm`、`POST /admin/llm/test` **同门**,破坏性写不比同族更宽;带审计 `llm.resetFromEnv`)body **必须** `{"confirm":true}`(缺失 / `false` / 非法 body ⇒ `400 bad_request` 且**一个字节都不动**),→ `200 {data:{reset:[{group,keys:[…]}], skipped:[{group,reason}], path}}`。**B. 语义(与 materialize 共用同一张键表 `runtimeconfig.groupFillSpecs`,方向相反)**:① **只覆盖 `.env` 声明过非空值的键**(`provider` / `baseUrl` / `apiKey` / `model` / `contextWindow`;协议族按**组自己的 `provider`** 选,`.env` 同时配扁平块与 `ANTHROPIC_*` 时两族各取自己的值);② **env 未声明的键一律保留面板值**(`fallback` / `multimodal` / `maxTokens` / `reasoning` / `personaModels` 在 `.env` 里没有出处 ⇒ 永不被本动作改动);③ **组集合不增减**(不复活被面板删掉的组、不删面板新增的组,**顺序也不动**);④ **面板值已与 env 一致的键不算「改回」**,不出现在 `reset` 里(`provider` 恒属此种情形);⑤ **无键可改时不写盘**(幂等,重复调用第二次回 `reset:[]` + 每组一条「已一致」的 `skipped`);⑥ **真的改了盘就热重载**运行中的 LLM 栈(无需重启;`contextWindow` 的**预算推导**仍只在启动时算一次,与 `POST /admin/llm` 同口径)。**C. 响应与凭据纪律**:`reset[].keys` 只有 **`models.json` 的 JSON 键名**、`skipped[].reason` 是中文原因,**凭据值(尤其 `apiKey`)绝不回显、绝不进日志**(`reset` / `skipped` 恒为数组,无记录时是 `[]` 而非 `null`;`path` 与 `POST /admin/llm` 同源)。**D. 对照基准**:**请求时刻按既有解析入口读到的 `.env` 默认层**(新增 `config.LoadLLMDefaults`,复用 `parseDotEnv` + 同一份 `env:"…"` 解码器 ⇒ 优先级与启动期 `config.Load` 逐字一致:进程环境 > `.env` > 标签默认);路径由组合根经 `Deps.EnvPath` 注入(与 `main.go` 的 `config.MustLoad(envPath)` 同一条),**网关自己绝不 fopen `.env`**。**E. 失败不伪装**(都**不改文件**且带审计 `error`):`.env` 源不可读 → `500 env_defaults_unreadable`;面板配置不存在/不可解析 → `500 models_unreadable`;写盘失败 → `500 llm_persist_failed`;已写盘但热重载失败 → `500 llm_reload_failed`(说明文件已改、未生效);LLM 栈未注入 → `500 llm_unavailable`。**F. 面板**:`frontend/web` 的 LLM 页新增「重置为 env 默认」卡片(`admin/modules/llm/LlmResetFromEnv.tsx`,标 `owner 专属`)——点按钮先展开**中文二次确认**文案,确认后才带 `confirm:true` 提交;结果按 `reset`/`skipped` 渲染成可复核的组名 + 键名摘要,失败原样显示后端错误文案。**G. 实现落点**:`internal/config/llm_defaults.go`(env 默认层读取)+ `internal/config/runtimeconfig/reset.go`(`ResetFromEnv` 语义,新文件)+ `internal/services/gateway/admin_write_llm_reset.go`(端点,新文件)+ `routes.go`(注册为 `ownerAuth`)+ `deps.go`/`server.go`(`Deps.EnvPath`)+ `cmd/cyrene/wire_frontend.go`(组合根接线)+ 前端 `lib/types/adminWrite.ts` / `lib/api/admin.ts` / `admin/modules/llm/LlmResetFromEnv.tsx` / `admin/modules/Llm.tsx`。**H. 未做 / 诚实边界**:**不支持「只重置某一组」**(一次调用对全部组生效;`skipped` 会如实报出没被改动的组与原因);**不做「重置 channels.json / plugins.json」**(materialize 本身也只覆盖 `models.json` 一族);**不做「重置后自动重启」**(provider 栈本身热更新,窗口推导需重启由面板文案写明)。 - `v0.61`(**PDF 文本层抽取落地:白名单第 23 项 `.pdf` + 类型裁决由「嗅探只放行 `text/plain`」改为 `(扩展名, 嗅探)` 成对裁决**,依据 design/24 §7 的 **S6 第一段**(PDF/Office 的裁决:PDF 文本层引库,扫描件与 Office 仍诚实拒绝)与 commit `f82c76d`,2026-10-04 落实)。**三档标注**:**行为变更** = ① **`.pdf` 由「一律 `415`」变为「可上传 + 可读取 + 文本层可抽取进上下文」**(把「`.pdf` 必被拒」写死的客户端必须改);② **类型裁决收紧为成对判定**(`documents.ContentAllowed`)——**方向是收紧/细化,不是放宽**:HTML/纯文本改名成 `.pdf` ⇒ `415`(嗅探不是 PDF),**PDF 字节改名成 `.txt` ⇒ `415`**(PDF 字节也只收在 `.pdf` 名下);**纯增量** = 扩展名白名单 **22 → 23 项**(唯一新增 `.pdf`),其余 22 个文本类的判定与既有响应**一字未变**。**A. 上传(`POST /api/v1/documents`,owner|admin)**——白名单加 `.pdf`;裁决 = **字节嗅探是唯一裁决权 ∧ 扩展名白名单**,自本版起**按对判定**(纯文本类必须 `text/plain`、`.pdf` 必须 `application/pdf`;落点 `internal/runtime/documents/store.go` 的 `ExtAllowed`(23 项)/ `ContentAllowed`,网关名字形态校验经它判定);`mime` 仍是**嗅探基类型**(真 PDF → `application/pdf`);`415` 文案同时点明两条口径(「只接受白名单内的文档(如 .txt/.md/.csv/.json,或内容确为 PDF 的 `.pdf`),且内容必须与扩展名相符」)。**B. 读取(`GET /api/v1/documents/{name}`,必带 Bearer owner|admin)**——**真 PDF 如实下发 `Content-Type: application/pdf`**(读时按同一对裁决重嗅探,判不定者仍退 `application/octet-stream`),`Content-Disposition: attachment` + `X-Content-Type-Options: nosniff` **一字未变** ⇒ **PDF 永不被内联渲染**(与图片读侧刻意相反);`Cache-Control: private, max-age=86400, immutable` 与 Range 支持不变。**C. 抽取语义(DTO `status`)**——**文本层 PDF 抽正文**(页内容流里的 `Tj`/`TJ`/`'`/`"` 文本算子,页间以换行相接)⇒ `status:"extracted"`,与纯文本类走**同一条** sidecar / 注入路径;**扫描件/图片型 PDF(无文本层)⇒ 诚实拒绝 `status:"unreadable"`**,原因文案含「这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)」,**绝不假装成功、也绝不返回空正文**(`ErrNoTextLayer`);**加密 PDF** 与**损坏/截断 PDF** 是**可区分**的两类失败(`ErrPDFEncrypted` / `ErrPDFDamaged`,加密的提示解除密码后重试);安全阀 = 单份最多 **5000 页** + sidecar 正文 **≤8 MiB**(撞上即 `truncated`)。**D. 客户端可见的一次性重算**——抽取器版本内部升到 **`documents/v3`**(`internal/runtime/documents/extract.go`)⇒ **旧 sidecar 一次性失效重抽**:同一份**旧附件**的 `chars`/`truncated` 结论会在**某次回合之后**重新计算(旧 PDF 若是文本层 PDF,将从 `unreadable` 变为 `extracted`)——不是附件被改写,是结论被重算。**E. 未做(诚实边界)**——**不做 OCR / 扫描件栅格化**(纯 Go 无可靠实现,会撞「单二进制 + 交叉编译」「零跨进程 IPC」两条红线)、**不做 PDF 写/生成**(只读)、**不做 `.docx`/`.xlsx` 等 Office**(仍诚实拒绝);**无新端点、无新 WS 帧、无鉴权变更、无 `.env` 新增键**。**实现落点**:`internal/runtime/documents/extract_pdf.go`(文本层抽取 + 加密/损坏分类)+ `internal/runtime/documents/{extract.go,store.go,sidecar.go}`(版本号 / 白名单与成对裁决 / unreadable 原因文案)+ 依赖 `github.com/ledongthuc/pdf`(纯 Go、无 cgo)。 - `v0.60`(**管理侧只读「存储状态」端点 + 面板存储卡接真数据**,依据主人 2026-10-04 实测反馈(面板首页「存储」卡一直是占位「SQLite」,看不出 PG 增强有没有生效)与 design/10 §3 模块 14(系统设置)/ design/03 §3(pgvector 可选增强),2026-10-04 落实)。**一句话**:**纯新增一个只读端点**,既有端点 / WS 帧 / 字段形状**一字未动**;面板那张假卡换成真数据。**A. 新端点** `GET /api/v1/admin/system/storage`(**owner-only**、**只读**、**无审计动作**,与四条 `POST /system/*` 动作端点同门不同档)→ `200 {data:{main,documents,vector}}`:`main` = `{kind:"sqlite",path,fileBytes,walBytes,shmBytes,pageSize,pageCount,freelistPages,freeBytes}`(`path` 与备份/恢复端点同源;WAL/SHM 边车**合法地不存在**时如实为 `0`;`freeBytes = pageSize × freelistPages`)、`documents` = `{count,bytes}`(读 `documents` 表,**不 walk 目录**,空表 `bytes` 为 `0` 而非 `null`)、`vector` = `{kind:"pgvector"|"none",enabled,ready}`。**B. `vector` 的语义(主人 2.4 的困惑点)**:`kind` 说「配没配 `DB_PG_URL`」,`enabled` 说「开关在不在」(等价于 `kind != "none"`),`ready` 说「**活着的**向量索引真的是 pgvector 后端吗」—— 配了但连接/schema 准备失败 ⇒ `enabled:true` + `ready:false`,客户端必须显示「已配置但未就绪(检索已降级)」,**不要把 `enabled` 当成生效**。客户端显示建议:`none` → 「未启用(未配置 `DB_PG_URL`)」、`enabled && ready` → 「启用(`pgvector` · ready)」、`enabled && !ready` → 未就绪。**C. 两条纪律**:① **不含凭据**——DSN / 用户名 / 密码 / `postgres://` 全文**一个字都不回**(`vector` 段只有 `kind`/`enabled`/`ready` 三个键;有用例断言响应体里搜不到这些串);② **只读**——只有 `SELECT`/`PRAGMA` + `os.Stat`,且复用组合根注入的**活连接**(不新开连接池、不写、不迁移、不备份;调用前后 `documents` 行数与数据目录内文件集合均不变,有用例钉住)。**D. 真源(`vector`)**:组合根 `cmd/cyrene/wire_frontend.go` 的 `app.vectorState()` —— `Configured` 取 `DB_PG_URL` 非空、`Live` 取活索引的具体类型(`*vector.PGVectorIndex`);网关**只读这份注入值**,不读环境变量、不新开 PG 连接、不自行探测(否则面板与运行时会给出两个答案)。**E. 失败不伪装**:未接活连接 / 数据库路径未配置 / 读不到页级统计或 `documents` 表(例如库没跑迁移)⇒ `500 {error:{code:"storage_unavailable",message}}`,**绝不回一个全 0 的假快照**;无凭据 → `401`。**F. 面板**:`frontend/web` 的存储卡(`admin/modules/dashboard/overviewMetrics.ts`)改为真数据 —— `value` = `SQLite 12.3 MiB`(`main.fileBytes` 的人类可读量级)、`hint` = `WAL … · N 页(空闲 M 页 / …)`、新增两行详情 = `documents N 件 · …` 与 `PG 增强 …`;取数走与其它已接线卡片**同一条** `useAsyncData` 加载/错误态(数据没到就显示「端点报错:…」,**不回退成 0、不假装成功**)。**G. 实现落点**:`internal/services/gateway/admin_system_storage.go`(新文件)+ `routes.go`(注册)+ `deps.go`/`server.go`(注入 `Deps.Database` / `Deps.Vector`)+ `cmd/cyrene/wire_frontend.go`(组合根真源)+ 前端 `lib/types/adminWrite.ts` / `lib/api/adminSystem.ts` / `admin/modules/dashboard/{useDashboardExtras,overviewMetrics,MiniMetric}`。**H. 兼容性**:**纯新增**——新端点为纯增量;前端 `MiniMetricSpec` 新增的 `extra?: string[]` 是**可选**补充详情行,不传时卡片渲染与旧版**逐字等价**(其它卡片一字未改)。**未做 / 诚实边界**:`main` 只报 SQLite(当前唯一主库引擎);`vector` 只报 `kind`/`enabled`/`ready`(**刻意不回 host**,把泄漏面压到最小);不报表级明细、不报备份文件列表(`documents` 之外的占用仍要下钻到备份/文件系统看)。 - `v0.59`(**文档进对话 + 上下文预算按窗口推导 + 语音回合进编排 + `thinking` 可达性口径**,依据 design/24 §3/§4/§6(切片 S1–S5)、design/04 §1.3 + 附录 A.3(主人裁决 2026-10-04)与 `Cyrene-For-Android/devdocs/02-前后端协同事项.md` 的 G5 / C1 / C3;commit `a8851dc` / `a4789ec` / `20c1edb` / `5b2b385` / `72b95df` / `3a01b4e`,2026-10-04 落实)。**三档标注**:**纯增量** = 两个 documents 端点、`chat.documents` 可选字段、4 个文档错误码、`models.json` 组键 `contextWindow`、4 个 `.env` 键、`document-read` 工具、`pending_full` 的 `clientMsgId?`/`messageId?`、定向 `error{code:"chat_busy"}` 帧、`voice_input.clientMsgId`、`voice_output.sessionId`/`deviceId`;**行为变更** = ① 语音回合改走编排器(落库 / 流式 / 跨端 / 忙时排队)② `CONTEXT_MAX_TOKENS` **未设置时改为按窗口推导**(`.env` 里写着 `32768` 仍是显式覆盖)③ 队列满时新增**定向回执**(此前 WS 无等价物);**破坏性** = ① `historyMessage` 的 `document`(单对象)→ **`documents`(数组,≤5)** ——**但单数形状从未随任何已发布版本号发布过**,故**无兼容期** ② `chars` / `truncated` 的语义由「抽取侧」改为 **「本回合注入侧」**(全文长度只有 `document-read` 的 `totalChars` 能看到)。**A. 文档(新增 §1.2 + §1 行 16/17)**——`POST /api/v1/documents`(multipart `file`,上限 `CHAT_DOCUMENT_MAX_BYTES` 默认 20 MiB)→ `201 {name,url,mime,size,sha256,deduped:false}`;**同字节同扩展名重复上传 → `200` + 同一 `name` + `deduped:true`**;`GET /api/v1/documents/{name}` **必须带 Bearer(owner|admin)**(与图片读侧的免鉴权能力 URL 刻意相反)+ `Content-Disposition: attachment` + `nosniff`;**字节嗅探是唯一类型裁决**(**当时**放行基类型只有 `text/plain`;**v0.61 起**改为 `(扩展名, 嗅探)` 成对裁决、`.pdf` 走 `application/pdf`),**扩展名白名单 22 项**(⚠️ 历史值;**v0.61 起为 23 项**,纳入 `.pdf`)deny-by-default,`mime` 是嗅探基类型(`data.json` → `text/plain`)、`url` 是**相对路径**;错误码 `unsupported_media_type`(415) / `document_too_large`(413) / `document_not_found`(404) / `too_many_documents`(400);`chat` 请求(REST 同步 / REST `stream:true` / WS **三处同形**)增可选 `documents:[{name}]`(**≤5、只收服务端存储名、不收 URL**,先于任何 FS 访问判条数,客户端多带的 `mime/url/size` 被服务端覆盖);DTO 四处同源(`GET /sessions/{id}/messages` / `message_persisted` / `message_edited` / 握手回放)改 `documents` 数组,元素 `{name,mime,size,chars,truncated,status}`、`status ∈ extracted|listed|unreadable`、无附件时省略该键;内置工具 `document-read`(`{name,offset?,limit?}` → `totalChars`/`truncated`/`nextOffset`/`text`/`extractTruncated?`,`limit` 默认 8000、上限 64K、`offset` 为 0 基 rune,策略默认 owner-only)。**B. 配置(§2「上下文窗口与预算契约(v0.59)」)**——`models.json` 组级新增 `contextWindow`(0/省略 = 未声明 ⇒ 131072 保守估算 + **恰好一条 WARN**;负数 = 校验失败);`.env` 新增 `LLM_CONTEXT_WINDOW` / `CONTEXT_BUDGET_RATIO`(默认 `2/3`)/ `DOCUMENT_MAX_INJECT_CHARS`,并**变更 `CONTEXT_MAX_TOKENS` 语义**(未设置 ⇒ 按窗口推导;`>0` 覆盖;`0`/负 = 关闭压缩)——推导 `budget = min(窗口 × 比例, 窗口 − 输出保留)`,输出保留 = `max(32768, 该组 maxTokens)` 封顶 `窗口/2`,**1M 窗口 ⇒ 699050 tokens**;另**补登记 `.env` → 面板的 materialize 语义**(每次启动一次;面板非空 ⇒ 组集合由面板决定、env 不复活被删的组;键级只在面板缺该键时补,面板的**显式空值/`false`/`null` 绝不覆盖**;「面板改一次、重启被打回」为硬约束;凭据不进日志;范围仅 `models.json` 一族)。**C. 语音(新增 §3.15)**——语音轮次**现在走与文本同一条路径**(落库 = 转写文本 + 助手回复、发 `message_persisted` 与流式帧、跨端同步、忙时按既有规则排队,`pending_ack`/`pending_drain`/`session_busy` 语义零变化);`deviceId` 照旧落库、`platform` 走存储默认 `web`;`voice_input` 增可选 `clientMsgId`(重复到达回 `chat_duplicate`,不产生新回合、不发音频);`voice_output` 增 `sessionId`(恒有)/ `deviceId`(非空才有),**空正文与 TTS 失败/降级都走既有 `provider` 降级信号**(不再把 `stopReason` 当语音内容);**时点** = 回合结算时才到(可能晚于若干状态帧、也可能由续跑或被 steer 注入的那一回合产生);**定向目标 = 发起 `voice_input` 的那条连接本身**(不按 `deviceId` 路由)、**断线不补投**(带来源 WARN),重连带同一 `clientMsgId` 重试安全。**D. `G5`(§3.2)**——`pending_full` **广播**载荷增 `clientMsgId?` / `messageId?`(前者 = 被拒那条消息的幂等键,未携带则整个键缺席;后者**仅当该消息此前已落库/已认领时**才回填——**绝不在此处新铸 id**,否则客户端重试会被判成「重复到达」而永远进不来);**新增定向帧** `{type:"error", data:{code:"chat_busy", message, sessionId, clientMsgId?, messageId?}}`,**只发发送者那条连接**、不广播,`code` 与 REST `409 chat_busy` **同码**。**E. `thinking` 口径(§4.4,不改行为)**——消息体 `thinking` 字段在**实时帧**(`message_persisted` / `channel_persisted` / `message_edited`)与**握手回放**中**一律为空**(隐私与带宽的**有意**默认);实时思维链仍走 §3.1 的 `thinking.*` 流帧(owner/admin);**历史消息的思维链**只有 `GET /sessions/{id}/messages`(owner|admin)返回。**实现落点**:文档 `internal/services/gateway/documents.go` + `chat_documents.go` + `session_transcript.go` + `internal/runtime/history/document.go`(DTO)+ `internal/runtime/loop/document.go`(装配期注入与结论回写)+ `plugins/tools/io/document_read.go`;预算 `internal/config/context_budget.go` + `context.go` + `llm.go` + `internal/config/runtimeconfig/models.go`(`contextWindow`)+ `materialize.go` + `cmd/cyrene/wire_context.go`;语音/G5 `internal/services/gateway/voice.go` + `session_voice.go` + `session_reject.go` + `ws_route.go`(`wsChatBusyCode`)+ `session_orchestrator.go`。**未做 / 诚实边界**:`document_unreadable`(400) 目前只有工具错误形态(无 REST 错误码);文档白名单**当时**为 **22** 项(⚠️ 历史值;**v0.61 起为 23 项**,纳入 `.pdf`;当时 design/24 §9.2 写 23,以代码为准);文档的配额/引用计数与 GC(design/24 S7)、~~PDF/Office 解析(S6)~~(⚠️ **PDF 文本层已由 v0.61 落地**;**Office 与扫描件仍诚实拒绝**)与 IM 侧 `msg.Media` 摄取**均未做**。 diff --git a/Docs/api-reference.md b/Docs/api-reference.md index f579494..d42a6ba 100644 --- a/Docs/api-reference.md +++ b/Docs/api-reference.md @@ -134,6 +134,7 @@ | 39 | DELETE | `/api/v1/admin/automations/{id}` | **owner** | 200 | 删除规则 | M8 | | 40 | POST | `/api/v1/admin/llm` | **owner** | 201 | 保存 LLM provider 组(落盘 + 热更新) | M8/M15 | | 41 | POST | `/api/v1/admin/llm/test` | **owner** | 200 | 真实模型调用测试 | M8/M15 | +| 70 | POST | `/api/v1/admin/llm/reset-from-env` | **owner** | 200/400/500 | **重置为 env 默认(v0.62)**:把面板里 `.env` 声明过的键覆盖回 env 值(组集合不增减、env 未声明的键原样保留、无键可改时不写盘),body `{confirm:true}` 缺则 `400`,只回键名不回显凭据;失败 `env_defaults_unreadable`/`models_unreadable`/`llm_persist_failed`/`llm_reload_failed`/`llm_unavailable`(见 §5.13a) | 后 M12 | | 42 | POST | `/api/v1/admin/channels/open` | **owner** | 200 | 连接渠道 | M8 | | 43 | POST | `/api/v1/admin/channels/close` | **owner** | 200 | 断开渠道 | M8 | | 44 | POST | `/api/v1/admin/channels/test` | **owner** | 200 | 结构性身份识别测试 | M8/M16 | @@ -158,7 +159,7 @@ **另**:外部 webhook 回调(如 `webhook` 渠道的 HMAC 回调)由 composition root 通过 `Deps.Webhooks` 动态挂载,**不走网关鉴权**(handler 自校验签名/授权);路径由调用方决定,不在上表枚举。 -**补登记说明(2026-10-04)**:上表中 `#62`–`#68` 是本次按 `routes.go` 逐条核对后新增的行(`plugins/installed` / `observability/logs` / `notifications` / `llm/catalog` / `owner-profile` GET+PUT / `automations/reminders`),插在各自的鉴权分档里、**既有编号一律未动**。§1.1 的 `#3`/`#4`(`auth/refresh`、`auth/logout`)与 §1.2 的 `#14`–`#18`(`upload`、`files/{name}`、三支 `audio/*`)同理。**v0.59 再补两行**:§1.2 的 `#19`/`#20`(`POST /api/v1/documents`、`GET /api/v1/documents/{name}`,均按 `routes.go` 的 `writeAuth` 注册核对)——同样取本表下一个可用号、不动既有编号。**v0.60 再补一行**:`#69` GET `/api/v1/admin/system/storage`(owner-only 只读,按 `routes.go` 的 `ownerAuth` 注册核对;**无审计动作**——它是查询不是动作),同样取下一个可用号、既有编号未动。**仍未登记的是 WS 事件帧**(见 §6.4 与本文档开头的历史包袱说明)。 +**补登记说明(2026-10-04)**:上表中 `#62`–`#68` 是本次按 `routes.go` 逐条核对后新增的行(`plugins/installed` / `observability/logs` / `notifications` / `llm/catalog` / `owner-profile` GET+PUT / `automations/reminders`),插在各自的鉴权分档里、**既有编号一律未动**。§1.1 的 `#3`/`#4`(`auth/refresh`、`auth/logout`)与 §1.2 的 `#14`–`#18`(`upload`、`files/{name}`、三支 `audio/*`)同理。**v0.59 再补两行**:§1.2 的 `#19`/`#20`(`POST /api/v1/documents`、`GET /api/v1/documents/{name}`,均按 `routes.go` 的 `writeAuth` 注册核对)——同样取本表下一个可用号、不动既有编号。**v0.60 再补一行**:`#69` GET `/api/v1/admin/system/storage`(owner-only 只读,按 `routes.go` 的 `ownerAuth` 注册核对;**无审计动作**——它是查询不是动作),同样取下一个可用号、既有编号未动。**v0.62 再补一行**:`#70` POST `/api/v1/admin/llm/reset-from-env`(**owner-only 写**,按 `routes.go` 的 `ownerAuth` 注册核对,见 §5.13a),同样取下一个可用号、既有编号未动。**仍未登记的是 WS 事件帧**(见 §6.4 与本文档开头的历史包袱说明)。 **WS**:`GET /ws/chat`(握手,JWT,见 §6)。 @@ -750,7 +751,7 @@ curl -s http://:8080/api/v1/admin/sessions/web:0123.../export -H "Authoriz ## 5. 管理后台 · 写端点(`owner`,全程审计) -> 以下端点**鉴权 = `Authorize(owner)`**(仅主人)。每个写操作都会调用 `auditClaims(r, "", "ok"|"error")` 记录 who/what/when/result(落 `audit_logs` + 网关日志)。**敏感值(token/密钥/新 JWT 密钥/DB 路径)不在响应/日志回显**。 +> 以下端点**鉴权 = `Authorize(owner)`**(仅主人)。每个写操作都会调用 `auditClaims(r, "", "ok"|"error")` 记录 who/what/when/result(落 `audit_logs` + 网关日志)。**敏感值(token/密钥/新 JWT 密钥/DB 路径)不在响应/日志回显**。**动作名约定**:多词动作用 camelCase(`llm.addProvider` / `llm.resetFromEnv` / `persona.update` / `memory.search` …),与 §8.3 的清单逐字一致。 ### 5.1 POST /api/v1/admin/plugins/policy - **Body**(`policyRequest`):`{"name":"<能力名>","allowInGroup":bool,"allowInStranger":bool}`。 @@ -890,6 +891,28 @@ curl -s -X POST http://:8080/api/v1/admin/llm \ -d '{"provider":"openai","baseUrl":"https://api.openai.com/v1","apiKey":"sk-...","model":"gpt-4o","multimodal":true}' ``` +### 5.13a POST /api/v1/admin/llm/reset-from-env(v0.62,「重置为 env 默认」) +- **鉴权**:**`owner` 专属**(与同组 `POST /admin/llm`、`POST /admin/llm/test` 同门;`admin` 与 `viewer` 均 `403`)——它是破坏性写(覆盖面板对若干键的修改),**门不比同族写端点更宽**。**审计**:`llm.resetFromEnv`(成功 `ok`、被拒/失败 `error`)。 +- **Body**(`llmResetRequest`):`{"confirm":true}` —— **必须显式为 true**。缺失 / `false` / 非法 body 一律 `400 {"error":{"code":"bad_request"}}`,且**一个字节都不动**(本动作用 env 值覆盖面板修改,是破坏性操作,缺护栏的默认必须是「不执行」)。 +- **语义**:把面板(`models.json`)里 **`.env` 默认层确实声明过(非空值)** 的键覆盖回 env 值。它与启动期 materialize 的补齐**方向相反、键表同源**(`runtimeconfig` 的 `groupFillSpecs` / `envLayer`),因此逐条是: + - **只动** `provider` / `baseUrl` / `apiKey` / `model` / `contextWindow`(缺省协议族按**组自己的 `provider`** 选:`.env` 同时配了扁平块与 `ANTHROPIC_*` 时两族各取自己的值;`contextWindow` 只认正数)。 + - **env 未声明的键一律保留面板值**(`fallback` / `multimodal` / `maxTokens` / `reasoning` / `personaModels` 在 `.env` 里没有出处 ⇒ 永不被本动作改动)。 + - **组集合不增减**(不复活被面板删掉的组、不删面板新增的组,**顺序也不动**)。 + - **面板值已与 env 一致的键不算「改回」**(`provider` 恒属这种情形),不出现在 `reset` 里。 + - **无键可改时一个字节都不写**(幂等;重复调用第二次会返回 `reset:[]` + 每组一条「已一致」的 `skipped`)。 + - 真的改了盘就**热重载**运行中的 LLM 栈(无需重启);但 `contextWindow` 的**上下文预算推导**仍只在进程启动时算一次(与 `POST /admin/llm` 同口径,重启后生效)。 +- **对照基准**:**请求时刻按既有 config 解析入口读到的 `.env` 默认层**(`config.LoadLLMDefaults`,优先级与启动期 `config.Load` 逐字一致:进程环境 > `.env` > 标签默认);`.env` 路径由组合根经 `Deps.EnvPath` 注入(与 `main.go` 的 `config.MustLoad(envPath)` 同一条)——**网关自己绝不 fopen `.env`**。 +- **响应**:`200 {"data":{"reset":[{"group":"<组 id>","keys":["baseUrl","apiKey",…]}],"skipped":[{"group":"<组 id>","reason":"<中文原因>"}],"path":""}}`。`keys` **只有键名**(顺序 = 补齐表顺序),**凭据值绝不回显**;`reset` / `skipped` 恒为数组(无记录时是 `[]` 而非 `null`);`reason` 是两种之一:`` "面板值已与 `.env` 默认一致,无需重置" `` / `` "`.env` 未声明该组的默认键(面板值原样保留)" ``。 +- **错误**(都**不改文件**且带审计 `error`):`.env` 源不可读 → `500 env_defaults_unreadable`;面板配置不存在/不可解析 → `500 models_unreadable`;写盘失败 → `500 llm_persist_failed`;已写盘但热重载失败 → `500 llm_reload_failed`(说明文件已改、未生效);LLM 栈未注入 → `500 llm_unavailable`。 +- **面板**:LLM 页的「重置为 env 默认」卡片(`frontend/web/src/admin/modules/llm/LlmResetFromEnv.tsx`)——先展开中文二次确认文案,确认后才带 `confirm:true` 提交。完整契约见 `api-client.md` §2「重置为 env 默认契约(v0.62)」。curl: + +```bash +curl -s -X POST http://:8080/api/v1/admin/llm/reset-from-env \ + -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -H 'X-API-Version: 1' \ + -d '{"confirm":true}' +# => {"data":{"reset":[{"group":"deepseek","keys":["baseUrl","apiKey","model"]}],"skipped":[],"path":".../models.json"}} +``` + ### 5.14 POST /api/v1/admin/llm/test - **Body**(`llmTestRequest`):`{"provider?","baseUrl","model","apiKey?","prompt?"}`。`model`+`baseUrl` 必填;`provider` 空默认 `openai`;`prompt` 空用探针 `"Reply with the single word: OK"`。 - **响应**:`200 {"data":{"ok":true,"provider":"openai","model":"gpt-4o","reply":"<模型回复>","tokens":}}`——**真实模型调用**(超时 30s)。 @@ -1413,7 +1436,8 @@ TOKEN=$(curl -s -X POST http://:8080/api/v1/auth/login \ - `RAG` 未注入:`GET /admin/rag` 返回空文档列表;`POST /admin/rag`、`/admin/rag/delete` 回 `501`。 - `Runner(Loop)` 未注入:`/chat` 回 `500 chat_unavailable`;WS `chat` 直接广播 `stream_end{stopReason:"idle"}`。 - `Channels` 未注入:`/admin/channels/config` 回 `500 channels_unavailable`。 -- `LLM` 未注入:`/admin/llm` 按 `.env` 合成单组;`POST /admin/llm` 回 `500 llm_unavailable`。 +- `LLM` 未注入:`/admin/llm` 按 `.env` 合成单组;`POST /admin/llm` 回 `500 llm_unavailable`;`POST /admin/llm/reset-from-env`(v0.62)同样回 `500 llm_unavailable`(不假装重置成功)。 +- `Deps.EnvPath`(v0.62):组合根注入的启动用 `.env` 路径,**只**供 `POST /admin/llm/reset-from-env` 按既有解析入口重读 env 默认层(网关不 fopen `.env`);为空 = 只看进程环境变量(生产 systemd/env_file 形态)。`.env` 读不出(例如路径是个目录)→ `500 env_defaults_unreadable`,**不改任何文件**。 - `Voice` 未注入:WS `voice_input` 回 `error {message:"voice unavailable"}`。 - `Scheduler(auditor)` 未注入:WS `subagent_ask` 回 `error {message:"subagent audit unavailable"}`。 - `Tasks` 未注入:WS `task_output_query` 回 `error {message:"task output unavailable"}`。 @@ -1422,12 +1446,12 @@ TOKEN=$(curl -s -X POST http://:8080/api/v1/auth/login \ ### 8.3 审计动作清单(`what` 字段,落 `audit_logs`) -`policy.set` / `persona.create` / `persona.update` / `user.create` / `apikey.issue` / `apikey.revoke` / `iot.control` / `iot.scenario` / `iot.rule` / `automation.create` / `automation.run` / `automation.delete` / `llm.addProvider` / `llm.test` / `channel.open` / `channel.close` / `channel.identity-test` / `channel.weixin.login.qr` / `channel.weixin.login.status` / `channel.weixin.login.persist` / `channel.platform.view` / `channel.friends` / `channel.friend.handle` / `channel.message.test` / `channel.save` / `channel.delete` / `plugin.install` / `plugin.uninstall` / `memory.add` / `memory.search` / `rag.index` / `rag.query` / `rag.delete` / `session.export` / `system.revoke_keys` / `system.rotate_jwt` / `system.backup` / `system.restore`。 +`policy.set` / `persona.create` / `persona.update` / `user.create` / `apikey.issue` / `apikey.revoke` / `iot.control` / `iot.scenario` / `iot.rule` / `automation.create` / `automation.run` / `automation.delete` / `llm.addProvider` / `llm.test` / `llm.resetFromEnv`(v0.62)/ `channel.open` / `channel.close` / `channel.identity-test` / `channel.weixin.login.qr` / `channel.weixin.login.status` / `channel.weixin.login.persist` / `channel.platform.view` / `channel.friends` / `channel.friend.handle` / `channel.message.test` / `channel.save` / `channel.delete` / `plugin.install` / `plugin.uninstall` / `memory.add` / `memory.search` / `rag.index` / `rag.query` / `rag.delete` / `session.export` / `system.revoke_keys` / `system.rotate_jwt` / `system.backup` / `system.restore`。 (`what` 后缀 `_failed/_persist_failed/_reload_failed` 等计入 `error` 分支。) ### 8.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 项)。 +- **已真实实现**:登录/健康检查/聊天(同步+流式)/会话(列表/新建/删除/消息分页)/人格/**文档存储与摄取(v0.59:`POST`/`GET /api/v1/documents` + `chat.documents` + `documents` DTO + `document-read` 工具;**v0.61:PDF 文本层抽取**,白名单 23 项含 `.pdf`、成对裁决、扫描件诚实拒绝)**/**语音回合进编排(v0.59:落库 / 流式 / 跨端 / 忙时排队,`voice_output` 归属键)**/管理读(personas/sessions/memories/plugins/channels/iot/llm/users/automations/backthink/rag/observability×4:metrics/trace/audit/logs)/管理写(人格/API key/设备控制/自动化/LLM 保存·测试·**重置为 env 默认(v0.62,owner-only)**/渠道连接·断开·身份测试/渠道配置·删除/KOOK 面板/微信扫码/记忆添加·检索/插件安装·卸载/RAG 导入·删除/主人档案读写(`GET`/`PUT /api/v1/admin/owner-profile`,v0.51)/系统设置 4 项)。 - **`thinking` 可达性(v0.59 口径,不改行为)**:消息体 `thinking` 字段在**实时帧**(`message_persisted`/`channel_persisted`/`message_edited`)与**握手回放**中**一律剥离**(有意默认);**实时思维链**走 `thinking.*` 流帧(owner/admin);**历史思维链**只有 `GET /sessions/{id}/messages`(owner/admin)返回。 - **仍为后置 M10(返回 `501 not_implemented`,不伪造)**:新增用户(无用户表/登录)、IoT 场景与规则(无模型/存储)、编辑环境变量/重启服务/检查更新、完整会话转录导出(当前导出身份元数据)。**文档侧后置**:配额与引用计数/GC(design/24 S7)、**Office 解析(`.doc`/`.docx`/`.xls`/`.xlsx`/`.ppt`/`.pptx`/`.odt`/`.ods`/`.odp`/`.rtf`,仍诚实拒绝)**、**扫描件/图片型 PDF 的 OCR**(v0.61 已做文本层 PDF,无文本层者如实报 `unreadable`)、IM 侧 `msg.Media` 摄取(`channelbridge` 只取 `msg.Content`)。**已知缺口**:design/24 §6.2 的 `document_unreadable`(400) 无 REST 落点(只有 `document-read` 工具错误形态;客户端在 DTO 里看到的是 `documents[].status = "unreadable"`)。 - **语义/向量增强**:`memory.search` 的 `semantic` 当前恒为 `false`——**向量索引已由组合根注入**(`cmd/cyrene/wire.go` 的 `memory.WithVectorIndex`),但**索引内当前无向量**(生产无写入),故语义打分返回空,属诚实反映而非伪造;RAG `embedded` 反映部署是否配置向量增强;**配置了增强必须真实走语义/向量路径(红线,非空壳)**。 diff --git a/cmd/cyrene/wire_frontend.go b/cmd/cyrene/wire_frontend.go index 054615c..5f81022 100644 --- a/cmd/cyrene/wire_frontend.go +++ b/cmd/cyrene/wire_frontend.go @@ -69,6 +69,10 @@ func (a *app) wireFrontend() error { Backthink: a.backthink, RAG: ragStore, LLM: a.llm, + // EnvPath 与 main.go 启动时 config.Load(envPath) 用同一条路径:面板 + // 「重置为 env 默认」(切片 R1)按既有解析入口重读同一份 `.env` 声明, + // 网关自己不 fopen `.env`。 + EnvPath: envPath, Channels: a.channels, Plugins: a.plugins, OwnerRegistrar: &ownerRegistrar{path: defaultOwnerPath, owner: a.owner, log: a.log}, diff --git a/frontend/web/src/admin/modules/Llm.tsx b/frontend/web/src/admin/modules/Llm.tsx index 2534743..18f76c0 100644 --- a/frontend/web/src/admin/modules/Llm.tsx +++ b/frontend/web/src/admin/modules/Llm.tsx @@ -8,6 +8,7 @@ import { Button, EmptyState, Surface, Tag } from "../../components/primitives"; import { Icon } from "../../components/Icon"; import { CatalogPanel } from "./llm/LlmCatalogPanel"; import { LlmProviderForm } from "./llm/LlmProviderForm"; +import { LlmResetFromEnv } from "./llm/LlmResetFromEnv"; import type { CatalogHint } from "./llm/LlmProviderRouting"; /** 一个供应商分组行:身份 + token 预算 + 上下文窗口 + 推理形状 + 路由 + 测试/编辑动作。 */ @@ -236,6 +237,10 @@ export function LlmModule(): React.ReactNode { /> + {/* 「重置为 env 默认」(v0.62):面板把键改坏时的一键回基线入口。成功后重新 + 拉取配置,让上面的列表立刻反映退回后的真值。 */} + + { log("llm.catalog.fill", "ok"); diff --git a/frontend/web/src/admin/modules/llm/LlmResetFromEnv.tsx b/frontend/web/src/admin/modules/llm/LlmResetFromEnv.tsx new file mode 100644 index 0000000..ed33666 --- /dev/null +++ b/frontend/web/src/admin/modules/llm/LlmResetFromEnv.tsx @@ -0,0 +1,103 @@ +import { useState } from "react"; +import { adminResetLlmFromEnv } from "../../../lib/api"; +import type { AdminLlmResetResult } from "../../../lib/types"; +import { useAuditStore } from "../../audit"; +import { Notice } from "../../components"; +import { Button, Surface, Tag } from "../../../components/primitives"; + +/** 「重置为 env 默认」把面板值退回 `.env` 声明的默认层(v0.62)。 + * + * 它是**破坏性**动作(覆盖面板对这些键的修改),因此按钮本身不直接发请求:先展开 + * 一段中文二次确认文案,用户点「确认重置」才带上 `confirm:true` 提交。响应只回 + * **键名**(绝不回显凭据值),因此这里只渲染组名 + 键名 + 后端给的跳过原因。 */ + +/** 把一次重置的响应渲染成可复核的中文摘要(只有键名与原因,不含任何值)。 */ +function describeResult(res: AdminLlmResetResult): string { + if (res.reset.length === 0) { + const why = res.skipped.map((s) => `${s.group}(${s.reason})`).join(";"); + return why + ? `没有任何键被改回:${why}。面板值保持原样。` + : "没有任何键被改回:面板配置里没有 `.env` 声明过的键,面板值保持原样。"; + } + const head = `已把 ${res.reset.length} 个分组的键退回 .env 默认值:`; + const body = res.reset.map((g) => `${g.group}(${g.keys.join("、")})`).join(";"); + const skipped = res.skipped.length + ? `;另 ${res.skipped.length} 个分组未改动:${res.skipped.map((s) => `${s.group}(${s.reason})`).join(";")}` + : ""; + return `${head}${body}${skipped}。凭据值不会回显。`; +} + +/** 一次重置往返:二次确认、busy、结果与失败四态都由本组件持有;成功时收起确认并让 + * 父组件重新拉取配置(失败保持展开,便于用户修正后一键重试)。 */ +function useResetAction(onReset: () => void) { + const log = useAuditStore((s) => s.log); + const [confirming, setConfirming] = useState(false); + const [busy, setBusy] = useState(false); + const [result, setResult] = useState(null); + const [fail, setFail] = useState(null); + + const run = () => { + setBusy(true); + setFail(null); + setResult(null); + adminResetLlmFromEnv() + .then((res) => { + log("llm.resetFromEnv", "ok"); + setResult(`${describeResult(res)}(${res.path})`); + setConfirming(false); + onReset(); + }) + .catch((e: unknown) => { + log("llm.resetFromEnv", "error"); + setFail(e instanceof Error ? e.message : "重置为 env 默认失败"); + }) + .finally(() => setBusy(false)); + }; + + return { confirming, setConfirming, busy, result, fail, run }; +} + +export function LlmResetFromEnv({ onReset }: { onReset: () => void }): React.ReactNode { + const { confirming, setConfirming, busy, result, fail, run } = useResetAction(onReset); + + return ( + +
+

重置为 env 默认

+ 危险 + owner 专属 + 写操作 · 已审计 +
+

+ 把面板里 .env(默认层)声明过的键覆盖回 env 值 —— 例如面板把 baseUrl / apiKey + 改坏了,一键回到部署基线。只动 env 声明过的键:env 未声明的键原样保留, + 组集合不增减(不复活被面板删掉的组、不删面板新增的组)。真的改了配置就热更新运行中的 + LLM 供应商栈(无需重启),并且只回键名、绝不回显凭据值。 +

+ + {!confirming ? ( + + ) : ( +
+

+ 这会用 .env 声明的值覆盖面板里这些键的修改(面板值一旦被覆盖就找不回来, + 需要重新填写或保存)。组集合与 env 未声明的键都不受影响。确定继续? +

+
+ + +
+
+ )} + + {fail ? {fail} : null} + {result ? {result} : null} +
+ ); +} diff --git a/frontend/web/src/lib/api.ts b/frontend/web/src/lib/api.ts index 4fa7767..ce41d7e 100644 --- a/frontend/web/src/lib/api.ts +++ b/frontend/web/src/lib/api.ts @@ -79,6 +79,7 @@ export { adminPluginInstall, adminPluginUninstall, adminRevokeApiKey, + adminResetLlmFromEnv, adminRunAutomation, adminSaveOwnerProfile, adminSearchRag, diff --git a/frontend/web/src/lib/api/admin.ts b/frontend/web/src/lib/api/admin.ts index c4a1bc5..6f17371 100644 --- a/frontend/web/src/lib/api/admin.ts +++ b/frontend/web/src/lib/api/admin.ts @@ -5,6 +5,7 @@ import type { AdminLlmCatalog, AdminLlmConfig, AdminLlmProvider, + AdminLlmResetResult, AdminPluginRecord, AdminRagHit, AdminRagResponse, @@ -236,6 +237,15 @@ export function adminAddLlmProvider(req: LlmProviderRequest): Promise<{ export function adminTestLlm(req: LlmTestRequest): Promise { return request("/admin/llm/test", { method: "POST", body: JSON.stringify(req) }); } +/** POST /api/v1/admin/llm/reset-from-env — 把面板里 `.env`(默认层)**声明过**的键 + * 覆盖回 env 值(v0.62):组集合不增减、env 未声明的键原样保留。破坏性,因此请求体 + * 必须带 `confirm:true`(客户端只在用户二次确认后才发)。响应只回键名,绝无凭据值。 */ +export function adminResetLlmFromEnv(): Promise { + return request("/admin/llm/reset-from-env", { + method: "POST", + body: JSON.stringify({ confirm: true }), + }); +} /** POST /api/v1/admin/plugins/install — installs (or refreshes) an MCP server * plugin (v0.28): connects, discovers its tools and hot-registers them. The * backend answers 502 and persists nothing when connect/discover fails. */ diff --git a/frontend/web/src/lib/types/adminWrite.ts b/frontend/web/src/lib/types/adminWrite.ts index 28a0c9d..315a719 100644 --- a/frontend/web/src/lib/types/adminWrite.ts +++ b/frontend/web/src/lib/types/adminWrite.ts @@ -72,6 +72,26 @@ export interface LlmTestResult { tokens: number; } +/** 一次「重置为 env 默认」里**真的被改回**的分组:**只有键名,绝无凭据值**。 */ +export interface AdminLlmResetGroup { + group: string; + keys: string[]; +} + +/** 一个未被改动的分组与原因(中文文案由后端给出,客户端原样渲染)。 */ +export interface AdminLlmResetSkip { + group: string; + reason: string; +} + +/** POST /api/v1/admin/llm/reset-from-env result(v0.62)。`reset` / `skipped` 恒为 + * 数组(没有对应记录时是 `[]`,不是 `null`)。 */ +export interface AdminLlmResetResult { + reset: AdminLlmResetGroup[]; + skipped: AdminLlmResetSkip[]; + path: string; +} + /** MCP server connection block for POST /api/v1/admin/plugins/install (v0.28). * `endpoint` is for http/sse, `command` (+ `args`) for stdio. apiKey is sent * once and persisted but never echoed back; env is the stdio child environment. */ diff --git a/internal/config/llm_defaults.go b/internal/config/llm_defaults.go new file mode 100644 index 0000000..8065136 --- /dev/null +++ b/internal/config/llm_defaults.go @@ -0,0 +1,37 @@ +package config + +import ( + "fmt" + "reflect" +) + +// LoadLLMDefaults 按既有解析入口读取 `.env` 默认层的 LLM 块。它是面板 +// 「重置为 env 默认」动作(切片 R1,见 runtimeconfig.ResetFromEnv)的**对照基准**: +// 那个动作要知道「`.env` 到底声明了哪些键」,而网关自己绝不 fopen `.env`。 +// +// 它复用 parseDotEnv 与同一份 `env:"…"` 标签解码器,因此优先级与 Load 逐字一致: +// 进程环境变量 > `.env` > 标签默认值(生产走 systemd/env_file 时不配 `.env`, +// 结果就是进程环境里那一份)。envPath 为空表示不看 `.env`(与 Load 的约定一致)。 +// +// 为什么只解 LLM 子树、而不是整份 Load:整份 Load 会在缺 JWT_SECRET 这类必填项时 +// 失败,而本读取只需要 LLM 块的声明;窗口负数在 Load 阶段就会报错(窗口没有负数 +// 语义),这里保持同一口径,不另立一套判定。 +func LoadLLMDefaults(envPath string) (LLMConfig, error) { + fromEnv := map[string]string{} + if envPath != "" { + m, err := parseDotEnv(envPath) + if err != nil { + return LLMConfig{}, fmt.Errorf("config: read %s: %w", envPath, err) + } + fromEnv = m + } + var llm LLMConfig + if err := decodeValue(reflect.ValueOf(&llm), fromEnv); err != nil { + return LLMConfig{}, err + } + if llm.ContextWindow < 0 { + return LLMConfig{}, fmt.Errorf("config: LLM_CONTEXT_WINDOW 不能为负(%d):0 = 未声明(按回落窗口推导)", + llm.ContextWindow) + } + return llm, nil +} diff --git a/internal/config/llm_defaults_test.go b/internal/config/llm_defaults_test.go new file mode 100644 index 0000000..d6d7c3d --- /dev/null +++ b/internal/config/llm_defaults_test.go @@ -0,0 +1,81 @@ +package config + +import ( + "path/filepath" + "strings" + "testing" +) + +// 本文件钉住「重置为 env 默认」动作(切片 R1)的**对照基准读取**:`.env` 默认层必须 +// 按既有解析入口读(同一份 parseDotEnv + 同一份 `env:"…"` 标签解码器),优先级与 +// Load 逐字一致(进程环境 > `.env` > 标签默认),且读不出时明确报错——绝不回一份 +// 空快照冒充「env 什么都没声明」。 +// +// 它刻意只解 LLM 子树:整份 Load 会在缺 JWT_SECRET 这类必填项时失败,而本读取不需要 +// 它们(第一个用例就把这条钉住)。 + +func TestLoadLLMDefaultsReadsEnvFileWithoutRequiredConfig(t *testing.T) { + // 这份 `.env` 故意不含 JWT_SECRET 等必填项:本读取只解 LLM 子树,不该因此失败。 + path := writeEnvFile(t, strings.Join([]string{ + "# 注释与空行照旧被忽略", + "LLM_BASE_URL=https://env.example/v1", + "LLM_API_KEY=sk-env-plaintext", + "LLM_MODEL=env-model", + "LLM_CONTEXT_WINDOW=131072", + "ANTHROPIC_MODEL=env-claude", + "", + }, "\n")) + + llm, err := LoadLLMDefaults(path) + if err != nil { + t.Fatalf("LoadLLMDefaults: %v", err) + } + if llm.Provider != "openai" || llm.BaseURL != "https://env.example/v1" || + llm.APIKey != "sk-env-plaintext" || llm.Model != "env-model" || llm.ContextWindow != 131072 { + t.Fatalf("llm = %+v, want `.env` 里逐字声明的扁平块(LLM_PROVIDER 未声明 ⇒ 标签默认 openai)", llm) + } + if llm.AnthropicModel != "env-claude" { + t.Fatalf("AnthropicModel = %q, want env-claude(同一份解码器覆盖全部 LLM_*/ANTHROPIC_* 标签)", llm.AnthropicModel) + } +} + +func TestLoadLLMDefaultsProcessEnvWinsOverEnvFile(t *testing.T) { + path := writeEnvFile(t, "LLM_MODEL=from-file\n") + t.Setenv("LLM_MODEL", "from-process") + // 该键没被声明:必须仍是零值(空),不能被 `.env` 的其它行牵连。 + t.Setenv("LLM_BASE_URL", "") + + llm, err := LoadLLMDefaults(path) + if err != nil { + t.Fatalf("LoadLLMDefaults: %v", err) + } + if llm.Model != "from-process" { + t.Fatalf("Model = %q, want from-process(优先级必须与 Load 一致:进程环境 > .env)", llm.Model) + } +} + +func TestLoadLLMDefaultsUnreadableSourceFails(t *testing.T) { + // 目录不是可读的 `.env`:读取必须明确报错,而不是静默回一份空声明。 + dir := t.TempDir() + if _, err := LoadLLMDefaults(dir); err == nil { + t.Fatal("对目录调用 LoadLLMDefaults 应报错, want 明确错误") + } + + // 不存在的文件按 `.env` 的开发期约定容忍(parseDotEnv 的既有语义):结果是 + // 「只按进程环境与标签默认」的一份快照,而不是错误。 + missing := filepath.Join(t.TempDir(), "absent.env") + llm, err := LoadLLMDefaults(missing) + if err != nil { + t.Fatalf("文件缺失应容忍(.env 只是开发期便利): %v", err) + } + if llm.Provider != "openai" || llm.BaseURL != "" { + t.Fatalf("llm = %+v, want 标签默认 + 空串(没有 `.env` 可读)", llm) + } +} + +func TestLoadLLMDefaultsRejectsNegativeContextWindow(t *testing.T) { + path := writeEnvFile(t, "LLM_CONTEXT_WINDOW=-1\n") + if _, err := LoadLLMDefaults(path); err == nil { + t.Fatal("负数窗口应报错(与 Load 同一口径:窗口没有负数语义)") + } +} diff --git a/internal/config/runtimeconfig/reset.go b/internal/config/runtimeconfig/reset.go new file mode 100644 index 0000000..d52223e --- /dev/null +++ b/internal/config/runtimeconfig/reset.go @@ -0,0 +1,166 @@ +package runtimeconfig + +import ( + "errors" + "fmt" + "log/slog" + "os" + "strings" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/config" + "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" +) + +// 本文件落地「重置为 env 默认」这个**显式**动作(切片 R1)。它与 materialize.go 的 +// 补齐方向相反,却共用**同一份** env 默认层投影(groupFillSpecs + envLayer): +// +// - Materialize(进程启动):面板缺该键 ⇒ 用 env 补上(面板是一等来源); +// - ResetFromEnv(部署者显式要求):`.env` 声明过的键 ⇒ 覆盖回 env 值(env 是基线)。 +// +// 共用同一张键表,因此「本动作能改回的键集合」与「Materialize 会补齐的键集合」 +// 结构性一致,不可能各说各话。组集合完全由面板决定:本动作不新增、不复活、不删除 +// 任何组(顺序也不动),只改键值。 + +// ErrPanelUnreadable 表示面板配置文件读不到或解析不了。它专供调用方把 +// 「配置不可读」与「写盘失败」分成两种处置(两种都不会改动文件)。 +var ErrPanelUnreadable = errors.New("runtimeconfig: panel config unreadable") + +// 跳过原因:两种「这一组一个键都没改」的情形。它们是可直接给面板与审计看的 +// 中文文案,因此以常量承载,避免代码、测试与文档各写一份。 +const ( + // SkipReasonNoDefaults 是该组的 provider 族在 env 默认层里没有任何**有信息量**的 + // 键(见 isTautologicalEnvKey)——`.env` 没有为这一族声明默认值。 + SkipReasonNoDefaults = "`.env` 未声明该组的默认键(面板值原样保留)" + // SkipReasonAlreadyDefault 是 env 声明过该组的键,但面板值已与 env 值一致。 + SkipReasonAlreadyDefault = "面板值已与 `.env` 默认一致,无需重置" +) + +// ResetGroup 是一次重置里**真的被改回**的分组:Keys 是 models.json 的 JSON 键名 +// (与结构体 tag 逐字一致),**不含任何值**——凭据类键的值绝不回显、绝不进日志。 +type ResetGroup struct { + GroupID string + Keys []string +} + +// ResetSkip 是一个未被改动的分组与原因(顺序即面板里的组顺序)。 +type ResetSkip struct { + GroupID string + Reason string +} + +// ResetResult 是一次 ResetFromEnv 的可复核产物。 +type ResetResult struct { + // Reset 是被改回的分组,只含**真的变了**的键(面板值已与 env 一致的不算改回)。 + Reset []ResetGroup + // Skipped 是未被改动的分组与原因。 + Skipped []ResetSkip + // Written 为 true 表示本次真的改动了面板文件;无改动时一个字节都不动(幂等)。 + Written bool +} + +// ResetFromEnv 把面板组里「`.env` 默认层确实声明过」的键覆盖回 env 值。 +// +// 语义(与 Materialize 的补齐语义互为对照): +// - 只动 `.env` 声明了非空值的键;env 未声明的键**一律保留面板值**; +// - 组集合**不增减**(不复活被删的组、不删新增的组,顺序也不动); +// - 面板值已与 env 值一致的键不算「改回」,不出现在 Reset 里; +// - 没有任何键需要改时**不写盘**(幂等)。 +// +// 失败一律返回错误且**一个字节都不落盘**:面板文件不可读/不可解析(ErrPanelUnreadable) +// 或写盘失败(SaveModels 自身的错误),调用方据此回显式错误,绝不假装成功。 +// log 可为 nil(slog 对 nil logger 安全)。 +func ResetFromEnv(path string, env config.LLMConfig, log *slog.Logger) (ResetResult, error) { + log = logger.WithSource(log, "config/runtimeconfig") + raw, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return ResetResult{}, fmt.Errorf("%w: 面板配置文件不存在(%s),没有可重置的面板值: %w", + ErrPanelUnreadable, path, err) + } + return ResetResult{}, fmt.Errorf("%w: 读取 %s: %w", ErrPanelUnreadable, path, err) + } + panel, err := decodePanel(raw) + if err != nil { + return ResetResult{}, fmt.Errorf("%w: 解析 %s: %w", ErrPanelUnreadable, path, err) + } + layer := newEnvLayer(env) + groups := make([]Group, len(panel.groups)) + var res ResetResult + for i, g := range panel.groups { + groups[i] = g + changed, declared := resetGroup(&groups[i], layer) + if len(changed) == 0 { + res.Skipped = append(res.Skipped, ResetSkip{GroupID: reportGroupID(g.ID), Reason: skipReason(declared)}) + continue + } + res.Reset = append(res.Reset, ResetGroup{GroupID: reportGroupID(g.ID), Keys: changed}) + } + if len(res.Reset) == 0 { + log.Debug("面板配置无需重置:没有与 `.env` 默认不同的键", "path", path, "source", envLayerSource) + return res, nil + } + // SaveModels 先逐组校验再原子写(临时文件 + rename),因此校验或写盘失败时 + // 原文件保持原样——「重置失败」绝不留下半写状态。 + if err := SaveModels(path, groups); err != nil { + return res, fmt.Errorf("runtimeconfig: 重置 %s: %w", path, err) + } + res.Written = true + logReset(log, path, res) + return res, nil +} + +// resetGroup 把 env 为该组声明的键覆盖回 env 值,返回**真的被改回**的键与 +// 「env 为该组声明了几个**有信息量**的键」(见 isTautologicalEnvKey)。键顺序即 +// groupFillSpecs 的顺序(与被补的键同序)。 +func resetGroup(g *Group, layer envLayer) (changed []string, declared int) { + defaults := layer.defaultsFor(*g) + for _, spec := range groupFillSpecs { + value := defaults[spec.key] + if value == "" { + continue + } + if !isTautologicalEnvKey(spec.key) { + declared++ + } + if spec.get(*g) == value { + continue + } + spec.set(g, value) + changed = append(changed, spec.key) + } + return changed, declared +} + +// isTautologicalEnvKey 报告该键在 env 默认层里是不是**同义反复**:`provider` 的值由 +// 「按组自己的 provider 选族」合成,因此恒等于组里已有的值,**不可能产生一次「改回」**。 +// 它仍留在比较表内(与 materialize 的补齐集合逐字一致,`provider` 确实可能被补),但 +// **不计入**「`.env` 到底为该组声明了什么」的判定——否则「`.env` 完全没声明这一族」会被 +// 它伪装成「面板值已一致」,面板上那条跳过原因就会骗人。 +func isTautologicalEnvKey(key string) bool { return key == "provider" } + +// skipReason 把「env 为这一组声明了几个键」翻成给面板看的原因。 +func skipReason(declared int) string { + if declared == 0 { + return SkipReasonNoDefaults + } + return SkipReasonAlreadyDefault +} + +// reportGroupID 是报告用的组标签:组 id 为空(手写配置,本就不合法)时用占位符, +// 与 fill 日志的 tagKeys 同一约定,避免出现空字符串组名。 +func reportGroupID(id string) string { + if strings.TrimSpace(id) == "" { + return "(no-id)" + } + return id +} + +// logReset 汇报一次重置:只打组 id 与键名,**绝不打任何值**(apiKey 等凭据不进日志)。 +func logReset(log *slog.Logger, path string, res ResetResult) { + groups := make([]string, 0, len(res.Reset)) + for _, g := range res.Reset { + groups = append(groups, g.GroupID+"."+strings.Join(g.Keys, ",")) + } + log.Info("面板配置已按 .env 默认层重置(只改键值,组集合不变)", "path", path, "source", envLayerSource, + "reset", groups, "resetCount", len(res.Reset), "skippedCount", len(res.Skipped)) +} diff --git a/internal/config/runtimeconfig/reset_test.go b/internal/config/runtimeconfig/reset_test.go new file mode 100644 index 0000000..174f2be --- /dev/null +++ b/internal/config/runtimeconfig/reset_test.go @@ -0,0 +1,280 @@ +package runtimeconfig + +import ( + "bytes" + "errors" + "log/slog" + "os" + "path/filepath" + "strings" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/config" +) + +// 本文件钉住「重置为 env 默认」这个**显式**动作的语义(切片 R1,实现见 reset.go): +// 只改 `.env` 声明过的键、env 未声明的键原样保留、组集合与顺序不增减、面板值已与 +// env 一致时不写盘(幂等),且失败一个字节都不落盘 —— 与 materialize 的补齐方向相反、 +// 键表同源。 + +// resetPanelFile 写一份含三组的面板配置:openai 组(面板值全都与 env 不同)、 +// anthropic 组(env 侧另有一块 anthropic 声明)、以及 env 里完全没有对应声明的 +// 第三组(它的面板值只可能被保留)。返回文件路径。 +func resetPanelFile(t *testing.T) string { + t.Helper() + return writeModelsFile(t, `[ + {"id":"panel-openai","provider":"openai","baseUrl":"https://panel.example/v1","apiKey":"sk-panel-key","model":"panel-model", + "fallback":"panel-fallback","multimodal":true,"maxTokens":4096,"contextWindow":4096, + "reasoning":{"kind":"vllm"},"personaModels":{"cyrene":"panel-model"}}, + {"id":"panel-anthropic","provider":"anthropic","baseUrl":"https://panel-anthropic.example","apiKey":"sk-panel-ant","model":"panel-claude"}, + {"id":"panel-extra","provider":"openai","baseUrl":"https://panel-extra.example/v1","apiKey":"sk-panel-extra","model":"panel-extra-model"} + ]`) +} + +// TestResetFromEnvOnlyTouchesEnvDeclaredKeys 是核心语义:`.env` 声明过的键被改回 +// env 值,env 未声明的键(fallback / multimodal / maxTokens / reasoning / +// personaModels)**原样保留**;`provider` 虽在 env 默认层里被声明,但面板值已与 +// env 一致,因此不算「被改回」。 +func TestResetFromEnvOnlyTouchesEnvDeclaredKeys(t *testing.T) { + t.Parallel() + env := materializeEnv() + env.ContextWindow = 131072 + path := resetPanelFile(t) + + res, err := ResetFromEnv(path, env, discardLog()) + if err != nil { + t.Fatalf("ResetFromEnv: %v", err) + } + if !res.Written { + t.Fatal("Written = false, want true(有键被改回就该落盘)") + } + // 三组都在:openai 组被改回,anthropic 组被改回,extra 组也一样(同属 openai 族)。 + got := map[string][]string{} + for _, g := range res.Reset { + got[g.GroupID] = g.Keys + } + wantOpenAI := []string{"baseUrl", "apiKey", "model", "contextWindow"} + if !equalStrings(got["panel-openai"], wantOpenAI) { + t.Fatalf("panel-openai 改回的键 = %v, want %v(provider 已一致,不算改回)", got["panel-openai"], wantOpenAI) + } + wantAnthropic := []string{"baseUrl", "apiKey", "model", "contextWindow"} + if !equalStrings(got["panel-anthropic"], wantAnthropic) { + t.Fatalf("panel-anthropic 改回的键 = %v, want %v(anthropic 族按组自己的 provider 选族;LLM_CONTEXT_WINDOW 对两族都播种)", + got["panel-anthropic"], wantAnthropic) + } + if len(res.Skipped) != 0 { + t.Fatalf("Skipped = %+v, want 空(每一组都有键被改回)", res.Skipped) + } + + // 磁盘上的真值:env 值覆盖,未声明的键原样保留。 + groups, warn := LoadModels(path, env) + if warn != nil { + t.Fatalf("LoadModels: warn = %v", warn) + } + g := mustFindGroup(t, groups, "panel-openai") + if g.BaseURL != env.BaseURL || g.APIKey != env.APIKey || g.Model != env.Model || g.ContextWindow != env.ContextWindow { + t.Fatalf("panel-openai = %+v, want env 的 baseUrl/apiKey/model/contextWindow", g) + } + if g.Fallback != "panel-fallback" || !g.Multimodal || g.MaxTokens != 4096 || + g.Reasoning.Kind != "vllm" || g.PersonaModels["cyrene"] != "panel-model" { + t.Fatalf("panel-openai = %+v, want env 未声明的键原样保留", g) + } + ant := mustFindGroup(t, groups, "panel-anthropic") + if ant.BaseURL != env.AnthropicBaseURL || ant.APIKey != env.AnthropicAPIKey || ant.Model != env.AnthropicModel { + t.Fatalf("panel-anthropic = %+v, want anthropic 族的 env 值", ant) + } + // 组集合与顺序不变(panel-extra 是面板新增的组,env 里没有它的 id)。 + if len(groups) != 3 || groups[0].ID != "panel-openai" || groups[1].ID != "panel-anthropic" || groups[2].ID != "panel-extra" { + t.Fatalf("组集合/顺序 = %+v, want 原样的三组", groups) + } +} + +// mustFindGroup 按 id 取组,取不到直接失败。 +func mustFindGroup(t *testing.T, groups []Group, id string) Group { + t.Helper() + g, ok := Find(groups, id) + if !ok { + t.Fatalf("组 %q 不在磁盘上: %+v", id, groups) + } + return g +} + +// TestResetFromEnvKeepsGroupsEnvWantsBack 钉住「组集合不因 env 而增减」:env 里 +// 有 anthropic 块,但面板把 anthropic 组删了 —— 重置**不复活**它;面板新增的组 +// 也**不被删除**(组数与顺序都不动)。 +func TestResetFromEnvKeepsGroupsEnvWantsBack(t *testing.T) { + t.Parallel() + env := materializeEnv() + path := writeModelsFile(t, `[ + {"id":"panel-extra","provider":"openai","baseUrl":"https://panel-extra.example/v1","apiKey":"sk-panel-extra","model":"panel-extra-model"} + ]`) + before := readRaw(t, path) + + res, err := ResetFromEnv(path, env, discardLog()) + if err != nil { + t.Fatalf("ResetFromEnv: %v", err) + } + if !res.Written { + t.Fatal("Written = false, want true(openai 组确实被改回)") + } + groups, warn := LoadModels(path, env) + if warn != nil { + t.Fatalf("LoadModels: warn = %v", warn) + } + if len(groups) != 1 || groups[0].ID != "panel-extra" { + t.Fatalf("组集合 = %+v, want 只有面板那一组(envelope 不复活被删的组)", groups) + } + if _, ok := Find(groups, ProviderAnthropic); ok { + t.Fatal("env 声明的 anthropic 组被复活了, want 不新增组") + } + if readRaw(t, path) == before { + t.Fatal("openai 组的键应被改回(文件该变)") + } +} + +// TestResetFromEnvIdempotentNoWrite 钉住幂等:第二次调用时面板值已与 env 一致, +// 因此**一个字节都不动**,并且报出「已一致」的跳过原因。 +func TestResetFromEnvIdempotentNoWrite(t *testing.T) { + t.Parallel() + env := materializeEnv() + path := resetPanelFile(t) + if _, err := ResetFromEnv(path, env, discardLog()); err != nil { + t.Fatalf("第一次 ResetFromEnv: %v", err) + } + before := readRaw(t, path) + + res, err := ResetFromEnv(path, env, discardLog()) + if err != nil { + t.Fatalf("第二次 ResetFromEnv: %v", err) + } + if res.Written { + t.Fatal("Written = true, want false(无键可改时不写盘)") + } + if len(res.Reset) != 0 { + t.Fatalf("Reset = %+v, want 空", res.Reset) + } + if len(res.Skipped) != 3 { + t.Fatalf("Skipped = %+v, want 三组都报「已一致」", res.Skipped) + } + for _, s := range res.Skipped { + if s.Reason != SkipReasonAlreadyDefault { + t.Fatalf("跳过原因 = %q, want %q", s.Reason, SkipReasonAlreadyDefault) + } + } + if readRaw(t, path) != before { + t.Fatal("无键可改时文件被改动了, want 逐字节不变") + } +} + +// TestResetFromEnvUnreadablePanelFails 钉住失败路径:面板文件不存在/不可解析时 +// 明确报错(ErrPanelUnreadable),绝不假装成功、也绝不创建文件。 +func TestResetFromEnvUnreadablePanelFails(t *testing.T) { + t.Parallel() + env := materializeEnv() + + t.Run("文件不存在", func(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "models.json") + if _, err := ResetFromEnv(path, env, discardLog()); !errors.Is(err, ErrPanelUnreadable) { + t.Fatalf("err = %v, want ErrPanelUnreadable", err) + } + if _, statErr := os.Stat(path); !os.IsNotExist(statErr) { + t.Fatal("重置失败却创建了文件, want 什么都不做") + } + }) + + t.Run("内容不可解析", func(t *testing.T) { + t.Parallel() + path := writeModelsFile(t, `{"not":"an array"}`) + before := readRaw(t, path) + if _, err := ResetFromEnv(path, env, discardLog()); !errors.Is(err, ErrPanelUnreadable) { + t.Fatalf("err = %v, want ErrPanelUnreadable", err) + } + if readRaw(t, path) != before { + t.Fatal("解析失败的文件被改写了, want 一个字节都不动") + } + }) +} + +// TestResetFromEnvEnvDeclaresNothing 钉住「env 什么都没声明」这一侧:组的 +// baseUrl/model 等一个都不动,且**每一组**都报「env 未声明该组的默认键」——这一条 +// 之所以能成立,靠的是把 `provider`(同义反复,恒等于组里已有的值)排除在 +// 「env 声明了什么」的判定之外,否则面板上会显示成误导性的「面板值已一致」。 +func TestResetFromEnvEnvDeclaresNothing(t *testing.T) { + t.Parallel() + env := config.LLMConfig{Provider: ProviderOpenAI} + path := resetPanelFile(t) + before := readRaw(t, path) + + res, err := ResetFromEnv(path, env, discardLog()) + if err != nil { + t.Fatalf("ResetFromEnv: %v", err) + } + if res.Written || len(res.Reset) != 0 { + t.Fatalf("res = %+v, want 无改动(env 未声明任何键)", res) + } + if len(res.Skipped) != 3 { + t.Fatalf("Skipped = %+v, want 三组都被跳过", res.Skipped) + } + for _, s := range res.Skipped { + if s.Reason != SkipReasonNoDefaults { + t.Fatalf("组 %s 的跳过原因 = %q, want %q", s.GroupID, s.Reason, SkipReasonNoDefaults) + } + } + if readRaw(t, path) != before { + t.Fatal("env 未声明任何键时文件被改动了, want 逐字节不变") + } +} + +// TestResetFromEnvSkipsFamilyWithoutDeclaration 钉住「按组自己的 provider 选族」的 +// 另一面:`.env` 只配了扁平 openai 块时,anthropic 组没有任何可改回的键,且原因必须 +// 是「`.env` 未声明」而不是「已一致」。 +func TestResetFromEnvSkipsFamilyWithoutDeclaration(t *testing.T) { + t.Parallel() + env := materializeEnv() // 含 anthropic 块 + env.AnthropicBaseURL, env.AnthropicAPIKey, env.AnthropicModel, env.AnthropicFallback = "", "", "", "" + path := resetPanelFile(t) + + res, err := ResetFromEnv(path, env, discardLog()) + if err != nil { + t.Fatalf("ResetFromEnv: %v", err) + } + var anthropic *ResetSkip + for i := range res.Skipped { + if res.Skipped[i].GroupID == "panel-anthropic" { + anthropic = &res.Skipped[i] + } + } + if anthropic == nil { + t.Fatalf("anthropic 组不在 skipped 里: %+v", res.Skipped) + } + if anthropic.Reason != SkipReasonNoDefaults { + t.Fatalf("anthropic 组跳过原因 = %q, want %q", anthropic.Reason, SkipReasonNoDefaults) + } +} + +// TestResetFromEnvNeverLogsCredentialValues 钉住凭据纪律:日志里只有组 id 与 +// 键名,env 与面板的 apiKey 值都不出现。 +func TestResetFromEnvNeverLogsCredentialValues(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + log := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) + env := materializeEnv() + path := resetPanelFile(t) + + if _, err := ResetFromEnv(path, env, log); err != nil { + t.Fatalf("ResetFromEnv: %v", err) + } + out := buf.String() + for _, secret := range []string{env.APIKey, env.AnthropicAPIKey, "sk-panel-key", "sk-panel-ant"} { + if strings.Contains(out, secret) { + t.Fatalf("日志里出现了凭据 %q: %s", secret, out) + } + } + if !strings.Contains(out, `"source":"config/runtimeconfig"`) { + t.Fatalf("日志缺来源标注: %s", out) + } + if !strings.Contains(out, "panel-openai.baseUrl") { + t.Fatalf("日志未报告被改回的键(可审计): %s", out) + } +} diff --git a/internal/services/gateway/admin_write_llm_reset.go b/internal/services/gateway/admin_write_llm_reset.go new file mode 100644 index 0000000..95e437f --- /dev/null +++ b/internal/services/gateway/admin_write_llm_reset.go @@ -0,0 +1,122 @@ +package gateway + +import ( + "encoding/json" + "errors" + "net/http" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/config" + "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" + apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors" +) + +// 「重置为 env 默认」动作(切片 R1):把面板里**`.env` 确实声明过**的键覆盖回 env 值。 +// 它与启动期的 materialize 补齐**方向相反、键表同源**(runtimeconfig 的 +// groupFillSpecs / envLayer):materialize 是「面板缺该键才用 env 补」,本动作是 +// 部署者**显式**要求「env 是基线,把这些键的面板值改回来」——例如面板把 +// apiKey/baseUrl 改坏了、要一键回到 env 基线。 + +// llmResetFromEnvAction 是审计动作名(落 audit_logs 的 what 字段)。多词动作名沿用 +// 本仓既有约定 camelCase(与 llm.addProvider / llm.test / persona.update 同风格)。 +const llmResetFromEnvAction = "llm.resetFromEnv" + +// llmResetRequest 是 POST /api/v1/admin/llm/reset-from-env 的请求体。Confirm 必须 +// **显式为 true**:本动作用 env 值覆盖面板对这些键的修改,属破坏性操作,因此缺护栏的 +// 默认必须是「不执行」(键缺失与 false 都回 400,绝不静默照做)。 +type llmResetRequest struct { + Confirm bool `json:"confirm"` +} + +// llmResetGroup 是响应里被改回的一组:**只回键名,绝不回值**。 +type llmResetGroup struct { + Group string `json:"group"` + Keys []string `json:"keys"` +} + +// llmResetSkip 是响应里未被改动的组与原因(中文文案,可直接给面板显示)。 +type llmResetSkip struct { + Group string `json:"group"` + Reason string `json:"reason"` +} + +// adminResetLlmFromEnv 把面板配置里 `.env` 声明过的键退回 env 值:组集合不增减、 +// env 未声明的键原样保留、面板值已与 env 一致的键不动(没有任何键要改时**不写盘**)。 +// 真的改了盘就热重载运行中的 LLM 栈,因此不需要重启(design/04 §3.1)。 +// +// 对照基准是**按既有 config 解析入口读到的 `.env` 默认层**(Deps.EnvPath),网关 +// 自己绝不 fopen `.env`;读不到就以明确错误失败,一个字节都不改。 +func (s *Server) adminResetLlmFromEnv(w http.ResponseWriter, r *http.Request) { + var req llmResetRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body")) + return + } + if !req.Confirm { + s.auditClaims(r, llmResetFromEnvAction, "error") + writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", + "重置为 env 默认会用 `.env` 声明的值覆盖面板对这些键的修改,请带 confirm:true 明确确认")) + return + } + if s.llm == nil { + s.auditClaims(r, llmResetFromEnvAction, "error") + writeError(w, apperr.Wrap(apperr.ErrInternal, "llm_unavailable", "LLM 配置服务未注入")) + return + } + env, err := config.LoadLLMDefaults(s.envPath) + if err != nil { + s.auditClaims(r, llmResetFromEnvAction, "error") + s.log.Error("llm reset: read env defaults", "envPath", s.envPath, "error", err) + writeError(w, apperr.Wrap(apperr.ErrInternal, "env_defaults_unreadable", + "读取 `.env` 默认层失败,未改动面板配置:%s", err.Error())) + return + } + res, err := runtimeconfig.ResetFromEnv(s.llm.Path(), env, s.log) + if err != nil { + s.auditClaims(r, llmResetFromEnvAction, "error") + s.log.Error("llm reset from env", "path", s.llm.Path(), "error", err) + if errors.Is(err, runtimeconfig.ErrPanelUnreadable) { + writeError(w, apperr.Wrap(apperr.ErrInternal, "models_unreadable", + "面板配置不可读,未改动任何文件:%s", err.Error())) + return + } + writeError(w, apperr.Wrap(apperr.ErrInternal, "llm_persist_failed", + "写回 models.json 失败,未生效:%s", err.Error())) + return + } + if res.Written { + if err := s.llm.Reload(); err != nil { + s.auditClaims(r, llmResetFromEnvAction, "error") + s.log.Error("llm reload", "error", err) + writeError(w, apperr.Wrap(apperr.ErrInternal, "llm_reload_failed", + "已写回 models.json,但热重载失败")) + return + } + } + s.auditClaims(r, llmResetFromEnvAction, "ok") + writeJSON(w, http.StatusOK, map[string]any{ + "reset": resetGroupsDTO(res.Reset), + "skipped": resetSkipsDTO(res.Skipped), + "path": s.llm.Path(), + }) +} + +// resetGroupsDTO 把域结果映射成 wire 形状:数组恒存在(没改回任何键时是 `[]`, +// 不是 `null`),键名原样透传、**值一律不出现在这条路径上**。 +func resetGroupsDTO(groups []runtimeconfig.ResetGroup) []llmResetGroup { + out := make([]llmResetGroup, 0, len(groups)) + for _, g := range groups { + keys := make([]string, 0, len(g.Keys)) + keys = append(keys, g.Keys...) + out = append(out, llmResetGroup{Group: g.GroupID, Keys: keys}) + } + return out +} + +// resetSkipsDTO 同上:未被改动的组(含跳过原因)恒以数组形态下发。 +func resetSkipsDTO(skips []runtimeconfig.ResetSkip) []llmResetSkip { + out := make([]llmResetSkip, 0, len(skips)) + for _, s := range skips { + out = append(out, llmResetSkip{Group: s.GroupID, Reason: s.Reason}) + } + return out +} diff --git a/internal/services/gateway/admin_write_llm_reset_guard_test.go b/internal/services/gateway/admin_write_llm_reset_guard_test.go new file mode 100644 index 0000000..176973b --- /dev/null +++ b/internal/services/gateway/admin_write_llm_reset_guard_test.go @@ -0,0 +1,206 @@ +package gateway + +import ( + "context" + "database/sql" + "encoding/json" + "io" + "log/slog" + "net/http" + "os" + "path/filepath" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" + "git.yeij.top/AskaEth/Cyrene_v2/internal/db" +) + +// 本文件是「重置为 env 默认」(v0.62,实现见 admin_write_llm_reset.go)的**护栏与 +// 失败路径**侧:破坏性 `confirm` 门、RBAC 门(owner-only)、审计落库,以及三条 +// 「明确报错、绝不假成功」的失败路径。语义侧(只改 env 声明的键 / 组集合不变 / 幂等) +// 在 admin_write_llm_reset_test.go。 +// +// 用例同样只走临时目录 + 临时 `.env` + 临时 models.json,绝不碰仓库根 `.env` 与 `data/`。 + +// TestAdminResetLlmFromEnvRequiresConfirm 钉住破坏性护栏:缺 `confirm`(整个 body 省略、 +// 空对象、显式 false)都回 400,且**文件一个字节都不动**。 +func TestAdminResetLlmFromEnvRequiresConfirm(t *testing.T) { + t.Parallel() + f := newLlmResetFixture(t, "", resetPanelJSON, Deps{}) + tok := f.ownerToken(t) + + for _, tc := range []struct { + name string + body map[string]any + }{ + {"整个 body 省略", nil}, + {"空对象", map[string]any{}}, + {"显式 false", map[string]any{"confirm": false}}, + } { + tc := tc + t.Run(tc.name, func(t *testing.T) { + resp, body := f.post(t, tok, tc.body) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body) + } + wantErrorShape(t, body) + if readResetPanel(t, f.modelsPath) != f.before { + t.Fatal("缺 confirm 的请求改动了面板文件, want 逐字节不变") + } + }) + } +} + +// TestAdminResetLlmFromEnvRBAC 钉住角色门:它是**破坏性写**,因此与同页的 +// `POST /admin/llm`、`/admin/llm/test` 同门 —— **owner-only**:无凭据 401、viewer 403、 +// **admin 403**(admin 绝不越入 owner),且被拒的请求一个字节都不动。 +func TestAdminResetLlmFromEnvRBAC(t *testing.T) { + t.Parallel() + f := newLlmResetFixture(t, "", resetPanelJSON, Deps{}) + + if resp, body := f.post(t, "", map[string]any{"confirm": true}); resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("匿名 status = %d, want 401 (body=%s)", resp.StatusCode, body) + } + for _, role := range []auth.Role{auth.RoleViewer, auth.RoleAdmin} { + role := role + tok := signAny(t, f.jwt, "acct", role) + if resp, body := f.post(t, tok, map[string]any{"confirm": true}); resp.StatusCode != http.StatusForbidden { + t.Fatalf("%s status = %d, want 403 (body=%s)", role, resp.StatusCode, body) + } + } + if readResetPanel(t, f.modelsPath) != f.before { + t.Fatal("被拒的请求改动了面板文件, want 逐字节不变") + } + // owner 仍可执行(门只收紧到 owner,不是关掉能力)。 + resp, body := f.post(t, f.ownerToken(t), map[string]any{"confirm": true}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("owner status = %d, want 200 (body=%s)", resp.StatusCode, body) + } +} + +// TestAdminResetLlmFromEnvAudits 钉住审计:成功落一行 `llm.resetFromEnv` / ok, +// 缺 confirm 的尝试落一行 error —— 都进真实 audit_logs(SQLite)。动作名沿用本仓 +// 既有多词动作的 camelCase 约定(与 llm.addProvider / persona.update 同风格)。 +func TestAdminResetLlmFromEnvAudits(t *testing.T) { + t.Parallel() + ctx := context.Background() + conn := openAuditConn(t) + auditStore, err := NewSQLiteAuditStore(ctx, conn, nil) + if err != nil { + t.Fatalf("audit store: %v", err) + } + f := newLlmResetFixture(t, "", resetPanelJSON, Deps{AuditStore: auditStore}) + tok := f.ownerToken(t) + + if resp, body := f.post(t, tok, nil); resp.StatusCode != http.StatusBadRequest { + t.Fatalf("缺 confirm status = %d, want 400 (body=%s)", resp.StatusCode, body) + } + if resp, body := f.post(t, tok, map[string]any{"confirm": true}); resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body) + } + + rows := readAuditRows(t, ctx, conn) + want := []auditRow{ + {who: "acct:owner", what: "llm.resetFromEnv", result: "error"}, + {who: "acct:owner", what: "llm.resetFromEnv", result: "ok"}, + } + if len(rows) != len(want) { + t.Fatalf("audit_logs 行数 = %d, want %d (%+v)", len(rows), len(want), rows) + } + for i := range want { + if rows[i] != want[i] { + t.Fatalf("audit_logs[%d] = %+v, want %+v", i, rows[i], want[i]) + } + } +} + +// TestAdminResetLlmFromEnvUnreadableEnvSource 钉住失败路径之一:`.env` 源不可读 ⇒ +// 明确 500 `env_defaults_unreadable`,面板文件一个字节都不动(绝不假成功)。 +func TestAdminResetLlmFromEnvUnreadableEnvSource(t *testing.T) { + t.Parallel() + // 目录不是可读的 `.env`:读取必然失败。 + f := newLlmResetFixture(t, t.TempDir(), resetPanelJSON, Deps{}) + tok := f.ownerToken(t) + + resp, body := f.post(t, tok, map[string]any{"confirm": true}) + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500 (body=%s)", resp.StatusCode, body) + } + if code := errorCode(t, body); code != "env_defaults_unreadable" { + t.Fatalf("error code = %q, want env_defaults_unreadable (body=%s)", code, body) + } + if readResetPanel(t, f.modelsPath) != f.before { + t.Fatal("env 源不可读时改动了面板文件, want 逐字节不变") + } + if f.runtime.Model() != "panel-model" { + t.Fatalf("运行中的栈被改动了(model=%q), want 原样", f.runtime.Model()) + } +} + +// TestAdminResetLlmFromEnvMissingPanelFile 钉住失败路径之二:面板配置不存在 ⇒ 明确 +// 错误(models_unreadable),且**不创建文件**、不假装重置成功。 +func TestAdminResetLlmFromEnvMissingPanelFile(t *testing.T) { + t.Parallel() + modelsPath := filepath.Join(t.TempDir(), "models.json") + runtime := newLlmRuntimeForGateway(t, modelsPath, llmEnvForTest()) + _, ts, jwt := buildEnv(t, cfgDefault(), Deps{LLM: runtime, EnvPath: writeResetEnvFile(t)}) + tok := signAny(t, jwt, "acct", auth.RoleOwner) + + resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/llm/reset-from-env", + map[string]any{"confirm": true}, tok) + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500 (body=%s)", resp.StatusCode, body) + } + if code := errorCode(t, body); code != "models_unreadable" { + t.Fatalf("error code = %q, want models_unreadable (body=%s)", code, body) + } + if _, err := os.Stat(modelsPath); !os.IsNotExist(err) { + t.Fatal("面板配置不存在时创建了文件, want 什么都不做") + } +} + +// TestAdminResetLlmFromEnvUnwired 钉住未接线:LLM 栈没注入时明确 500 `llm_unavailable`。 +func TestAdminResetLlmFromEnvUnwired(t *testing.T) { + t.Parallel() + _, ts, jwt := buildEnv(t, cfgDefault(), Deps{EnvPath: writeResetEnvFile(t)}) + tok := signAny(t, jwt, "acct", auth.RoleOwner) + + resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/llm/reset-from-env", + map[string]any{"confirm": true}, tok) + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500 (body=%s)", resp.StatusCode, body) + } + if code := errorCode(t, body); code != "llm_unavailable" { + t.Fatalf("error code = %q, want llm_unavailable (body=%s)", code, body) + } +} + +// openAuditConn 在临时目录开一个真实 SQLite 连接(审计落库断言用)。绝不碰 data/。 +func openAuditConn(t *testing.T) *sql.DB { + t.Helper() + conn, err := db.Open(context.Background(), filepath.Join(t.TempDir(), "cyrene.db"), + slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + t.Cleanup(func() { conn.Close() }) + return conn +} + +// errorCode 取错误信封里的 code 字段(并顺带断言 message 非空)。 +func errorCode(t *testing.T, body []byte) string { + t.Helper() + var env struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(body, &env); err != nil { + t.Fatalf("decode error envelope: %v (body=%s)", err, body) + } + if env.Error.Message == "" { + t.Fatalf("错误信封缺 message: %s", body) + } + return env.Error.Code +} diff --git a/internal/services/gateway/admin_write_llm_reset_test.go b/internal/services/gateway/admin_write_llm_reset_test.go new file mode 100644 index 0000000..71224c6 --- /dev/null +++ b/internal/services/gateway/admin_write_llm_reset_test.go @@ -0,0 +1,326 @@ +package gateway + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" + "git.yeij.top/AskaEth/Cyrene_v2/internal/config" + "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" +) + +// 本文件钉住「重置为 env 默认」动作(切片 R1,实现见 admin_write_llm_reset.go)的 +// **语义**侧:只覆盖 `.env` 声明过的键、env 未声明的键原样保留、组集合不增减、幂等。 +// 护栏(confirm / RBAC / 审计)与失败路径在 admin_write_llm_reset_guard_test.go。 +// +// 用例全部走**临时目录 + 临时 `.env` 文件 + 临时 models.json**:仓库根的 `.env` 与 +// `data/` 一个字节都不碰。 + +const ( + // resetEnvKey / resetPanelKey 是两个必须**永不回显**的凭据明文。 + resetEnvKey = "sk-env-reset-plaintext" + resetPanelKey = "sk-panel-reset-plaintext" +) + +// resetPanelJSON 是面板(models.json)的初始状态:openai 组与 anthropic 组的面板值 +// 全都与临时 `.env` 的声明不同,且带几把 env 里**没有出处**的键(fallback / +// multimodal / maxTokens / reasoning / personaModels)——它们必须原样活下来。 +const resetPanelJSON = `[ + {"id":"panel-openai","provider":"openai","baseUrl":"https://panel.example/v1","apiKey":"` + resetPanelKey + `","model":"panel-model", + "fallback":"panel-fallback","multimodal":true,"maxTokens":4096, + "reasoning":{"kind":"vllm"},"personaModels":{"cyrene":"panel-model"}}, + {"id":"panel-anthropic","provider":"anthropic","baseUrl":"https://panel-anthropic.example","apiKey":"sk-panel-ant", + "model":"panel-claude","fallback":"panel-ant-fallback"} +]` + +// llmResetFixture 是一次重置用例的运行环境。 +type llmResetFixture struct { + modelsPath string + envPath string + runtime *model.RuntimeLLM + ts *httptest.Server + jwt *auth.JWTService + before string +} + +// newLlmResetFixture 写面板文件 + 临时 `.env`,接上可热重载的 LLM 栈并起测试服务。 +// envPath 留空 = 用临时 `.env`;「env 源不可读」的用例自己传一个目录路径进来。 +func newLlmResetFixture(t *testing.T, envPath, panel string, deps Deps) *llmResetFixture { + t.Helper() + modelsPath, before := writeResetPanelFile(t, panel) + if envPath == "" { + envPath = writeResetEnvFile(t) + } + runtime := newLlmRuntimeForGateway(t, modelsPath, llmEnvForTest()) + deps.LLM = runtime + deps.EnvPath = envPath + _, ts, jwt := buildEnv(t, cfgDefault(), deps) + return &llmResetFixture{modelsPath: modelsPath, envPath: envPath, runtime: runtime, ts: ts, jwt: jwt, before: before} +} + +// post 发一次重置请求(token 为空 = 匿名)。 +func (f *llmResetFixture) post(t *testing.T, tok string, body map[string]any) (*http.Response, []byte) { + t.Helper() + return doReq(t, http.MethodPost, f.ts.URL+"/api/v1/admin/llm/reset-from-env", body, tok) +} + +// ownerToken 签发 owner token(每个用例自带一个,避免共享状态)。 +func (f *llmResetFixture) ownerToken(t *testing.T) string { + t.Helper() + return signAny(t, f.jwt, "acct", auth.RoleOwner) +} + +// writeResetEnvFile 写一份临时 `.env` 默认层并返回其路径(`LLM_PROVIDER` 缺省按 +// 标签默认 openai;两族都声明,好让用例分辨「按组自己的 provider 选族」)。 +func writeResetEnvFile(t *testing.T) string { + t.Helper() + content := strings.Join([]string{ + "# 重置用例的临时 env 默认层(绝不写到仓库根 .env)", + "LLM_BASE_URL=https://env.example/v1", + "LLM_API_KEY=" + resetEnvKey, + "LLM_MODEL=env-model", + "LLM_CONTEXT_WINDOW=131072", + "ANTHROPIC_BASE_URL=https://env-anthropic.example", + "ANTHROPIC_API_KEY=sk-env-anthropic-plaintext", + "ANTHROPIC_MODEL=env-claude", + "", + }, "\n") + path := filepath.Join(t.TempDir(), "reset.env") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("写临时 .env: %v", err) + } + return path +} + +// writeResetPanelFile 写一份面板 models.json,返回路径与文件原文。 +func writeResetPanelFile(t *testing.T, panel string) (string, string) { + t.Helper() + path := filepath.Join(t.TempDir(), "models.json") + if err := os.WriteFile(path, []byte(panel), 0o600); err != nil { + t.Fatalf("写面板 models.json: %v", err) + } + return path, readResetPanel(t, path) +} + +// readResetPanel 读回面板文件原文(幂等/不动的断言必须比字节,不能只看解出来的组)。 +func readResetPanel(t *testing.T, path string) string { + t.Helper() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("读回 %s: %v", path, err) + } + return string(raw) +} + +// llmResetResponse 是 POST /api/v1/admin/llm/reset-from-env 的响应体:字段名逐字 +// 对齐网关 DTO(llmResetGroup / llmResetSkip)。 +type llmResetResponse struct { + Reset []struct { + Group string `json:"group"` + Keys []string `json:"keys"` + } `json:"reset"` + Skipped []struct { + Group string `json:"group"` + Reason string `json:"reason"` + } `json:"skipped"` + Path string `json:"path"` +} + +// decodeResetResponse 解响应体的 data 段。 +func decodeResetResponse(t *testing.T, body []byte) llmResetResponse { + t.Helper() + var res llmResetResponse + if err := json.Unmarshal(decodeBody(t, body)["data"], &res); err != nil { + t.Fatalf("decode reset response: %v (body=%s)", err, body) + } + return res +} + +// resetKeys 取某个组被改回的键名。 +func resetKeys(t *testing.T, res llmResetResponse, group string) []string { + t.Helper() + for _, g := range res.Reset { + if g.Group == group { + return g.Keys + } + } + t.Fatalf("组 %q 不在 reset 列表里: %+v", group, res.Reset) + return nil +} + +// diskGroups 从磁盘读回组(真实落盘断言)。 +func diskGroups(t *testing.T, path string) []runtimeconfig.Group { + t.Helper() + groups, warn := runtimeconfig.LoadModels(path, config.LLMConfig{}) + if warn != nil { + t.Fatalf("LoadModels: warn = %v", warn) + } + return groups +} + +func TestAdminResetLlmFromEnvOnlyResetsEnvDeclaredKeys(t *testing.T) { + t.Parallel() + f := newLlmResetFixture(t, "", resetPanelJSON, Deps{}) + tok := f.ownerToken(t) + + resp, body := f.post(t, tok, map[string]any{"confirm": true}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body) + } + res := decodeResetResponse(t, body) + if res.Path != f.modelsPath { + t.Fatalf("path = %q, want %q", res.Path, f.modelsPath) + } + // 只回**真的被改回**的键(provider 在 env 默认层里也有声明,但面板值已一致 ⇒ 不算改回)。 + wantKeys := []string{"baseUrl", "apiKey", "model", "contextWindow"} + if got := resetKeys(t, res, "panel-openai"); !equalStringSlices(got, wantKeys) { + t.Fatalf("panel-openai 改回的键 = %v, want %v", got, wantKeys) + } + if got := resetKeys(t, res, "panel-anthropic"); !equalStringSlices(got, wantKeys) { + t.Fatalf("panel-anthropic 改回的键 = %v, want %v(anthropic 族按组自己的 provider 选族)", got, wantKeys) + } + if len(res.Skipped) != 0 { + t.Fatalf("skipped = %+v, want 空(两组都有键被改回)", res.Skipped) + } + + // 磁盘真值:env 值覆盖,env 未声明的键原样保留。 + groups := diskGroups(t, f.modelsPath) + if len(groups) != 2 || groups[0].ID != "panel-openai" || groups[1].ID != "panel-anthropic" { + t.Fatalf("组集合/顺序 = %+v, want 原样的两组", groups) + } + g := groups[0] + if g.BaseURL != "https://env.example/v1" || g.APIKey != resetEnvKey || g.Model != "env-model" || g.ContextWindow != 131072 { + t.Fatalf("panel-openai = %+v, want env 的 baseUrl/apiKey/model/contextWindow", g) + } + if g.Fallback != "panel-fallback" || !g.Multimodal || g.MaxTokens != 4096 || + g.Reasoning.Kind != "vllm" || g.PersonaModels["cyrene"] != "panel-model" { + t.Fatalf("env 未声明的键被动了: %+v", g) + } + ant := groups[1] + if ant.BaseURL != "https://env-anthropic.example" || ant.APIKey != "sk-env-anthropic-plaintext" || + ant.Model != "env-claude" || ant.Fallback != "panel-ant-fallback" { + t.Fatalf("panel-anthropic = %+v, want anthropic 族的 env 值 + 保留 fallback", ant) + } + + // 运行中的栈真的换了配置(热重载,无需重启):生效组(下标 0)已退回 env 基线。 + if f.runtime.Group().ID != "panel-openai" || f.runtime.Model() != "env-model" { + t.Fatalf("runtime group=%q model=%q, want 热重载到 env 基线", f.runtime.Group().ID, f.runtime.Model()) + } + + // 凭据纪律:两个 key 的明文都不在响应体里(只回键名)。 + for _, secret := range []string{resetEnvKey, resetPanelKey, "sk-env-anthropic-plaintext", "sk-panel-ant"} { + if strings.Contains(string(body), secret) { + t.Fatalf("响应体回显了凭据 %q: %s", secret, body) + } + } +} + +// equalStringSlices 比对两份字符串切片(顺序敏感:键顺序即 groupFillSpecs 顺序)。 +func equalStringSlices(got, want []string) bool { + if len(got) != len(want) { + return false + } + for i := range got { + if got[i] != want[i] { + return false + } + } + return true +} + +// TestAdminResetLlmFromEnvKeepsGroupSet 钉住「组集合不增减」:面板删掉的 anthropic 组 +// 不因 `.env` 仍声明它而复活,面板新增的组也不被删除(顺序一并保持)。 +func TestAdminResetLlmFromEnvKeepsGroupSet(t *testing.T) { + t.Parallel() + panel := `[ + {"id":"panel-extra","provider":"openai","baseUrl":"https://panel-extra.example/v1","apiKey":"` + resetPanelKey + `","model":"panel-extra-model"} + ]` + f := newLlmResetFixture(t, "", panel, Deps{}) + tok := f.ownerToken(t) + + resp, body := f.post(t, tok, map[string]any{"confirm": true}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body) + } + if got := resetKeys(t, decodeResetResponse(t, body), "panel-extra"); !equalStringSlices(got, []string{"baseUrl", "apiKey", "model", "contextWindow"}) { + t.Fatalf("panel-extra 改回的键 = %v, want [baseUrl apiKey model contextWindow]", got) + } + groups := diskGroups(t, f.modelsPath) + if len(groups) != 1 || groups[0].ID != "panel-extra" { + t.Fatalf("组集合 = %+v, want 只有面板那一组(不复活被删的 anthropic 组)", groups) + } + if _, ok := runtimeconfig.Find(groups, runtimeconfig.ProviderAnthropic); ok { + t.Fatal("`.env` 声明的 anthropic 组被复活了, want 不新增组") + } +} + +// TestAdminResetLlmFromEnvIdempotent 钉住幂等:第二次调用没有任何键要改,于是不写盘、 +// 两组都报「已与 env 一致」。 +func TestAdminResetLlmFromEnvIdempotent(t *testing.T) { + t.Parallel() + f := newLlmResetFixture(t, "", resetPanelJSON, Deps{}) + tok := f.ownerToken(t) + + if resp, body := f.post(t, tok, map[string]any{"confirm": true}); resp.StatusCode != http.StatusOK { + t.Fatalf("第一次 status = %d, want 200 (body=%s)", resp.StatusCode, body) + } + afterFirst := readResetPanel(t, f.modelsPath) + + resp, body := f.post(t, tok, map[string]any{"confirm": true}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("第二次 status = %d, want 200 (body=%s)", resp.StatusCode, body) + } + res := decodeResetResponse(t, body) + if len(res.Reset) != 0 { + t.Fatalf("reset = %+v, want 空(面板值已与 env 一致)", res.Reset) + } + if len(res.Skipped) != 2 { + t.Fatalf("skipped = %+v, want 两组都报「已一致」", res.Skipped) + } + for _, s := range res.Skipped { + if s.Reason != runtimeconfig.SkipReasonAlreadyDefault { + t.Fatalf("跳过原因 = %q, want %q", s.Reason, runtimeconfig.SkipReasonAlreadyDefault) + } + } + if readResetPanel(t, f.modelsPath) != afterFirst { + t.Fatal("无键可改时文件被改动了, want 逐字节不变") + } +} + +// TestAdminResetLlmFromEnvSkipsFamilyEnvDidNotDeclare 钉住面板上那条跳过原因的诚实性: +// `.env` 只配了扁平 openai 块时,anthropic 族没有可改回的键,跳过原因必须是 +// 「`.env` 未声明该组的默认键」而不是误导性的「面板值已一致」。 +func TestAdminResetLlmFromEnvSkipsFamilyEnvDidNotDeclare(t *testing.T) { + t.Parallel() + envPath := filepath.Join(t.TempDir(), "openai-only.env") + content := "LLM_BASE_URL=https://env.example/v1\nLLM_API_KEY=" + resetEnvKey + "\nLLM_MODEL=env-model\n" + if err := os.WriteFile(envPath, []byte(content), 0o600); err != nil { + t.Fatalf("写临时 .env: %v", err) + } + f := newLlmResetFixture(t, envPath, resetPanelJSON, Deps{}) + tok := f.ownerToken(t) + + resp, body := f.post(t, tok, map[string]any{"confirm": true}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body) + } + res := decodeResetResponse(t, body) + if got := resetKeys(t, res, "panel-openai"); !equalStringSlices(got, []string{"baseUrl", "apiKey", "model"}) { + t.Fatalf("panel-openai 改回的键 = %v, want [baseUrl apiKey model](扁平块声明的就是这三个)", got) + } + reason := "" + for _, s := range res.Skipped { + if s.Group == "panel-anthropic" { + reason = s.Reason + } + } + if reason != runtimeconfig.SkipReasonNoDefaults { + t.Fatalf("panel-anthropic 的跳过原因 = %q, want %q", reason, runtimeconfig.SkipReasonNoDefaults) + } +} diff --git a/internal/services/gateway/deps.go b/internal/services/gateway/deps.go index 7516b7d..8ebc9c3 100644 --- a/internal/services/gateway/deps.go +++ b/internal/services/gateway/deps.go @@ -79,6 +79,11 @@ type Deps struct { // 可选:为 nil 时管理 /llm 端点回退到环境配置,使 // 既有的网关测试继续可用。 LLM *model.RuntimeLLM + // EnvPath 是进程启动时解析的 `.env` 文件路径(组合根传给 config.Load 的同一个)。 + // 「重置为 env 默认」动作(切片 R1)按**既有 config 解析入口**重读 env 默认层, + // 网关自己绝不 fopen `.env`。空表示只看进程环境变量(与 config.Load("") 的 + // 约定一致,生产走 systemd/env_file 时就是这种形态)。可选。 + EnvPath string // Channels 是运行时可编辑的渠道适配器配置(channels.json)。 // 可选:为 nil 时管理 /channels/config 端点返回明确的 // not-configured 错误,与 LLM 栈未设置时的行为一致。 diff --git a/internal/services/gateway/rbac_viewer_test.go b/internal/services/gateway/rbac_viewer_test.go index 9d9e0b7..4fe8882 100644 --- a/internal/services/gateway/rbac_viewer_test.go +++ b/internal/services/gateway/rbac_viewer_test.go @@ -85,6 +85,7 @@ var ownerOnlyRoutes = []rbacRoute{ {"automations/{id} DELETE", http.MethodDelete, "/api/v1/admin/automations/a1"}, {"llm POST", http.MethodPost, "/api/v1/admin/llm"}, {"llm/test", http.MethodPost, "/api/v1/admin/llm/test"}, + {"llm/reset-from-env", http.MethodPost, "/api/v1/admin/llm/reset-from-env"}, {"memories POST", http.MethodPost, "/api/v1/admin/memories"}, {"memories/search", http.MethodPost, "/api/v1/admin/memories/search"}, {"rag POST", http.MethodPost, "/api/v1/admin/rag"}, diff --git a/internal/services/gateway/routes.go b/internal/services/gateway/routes.go index 8e4781f..468e86f 100644 --- a/internal/services/gateway/routes.go +++ b/internal/services/gateway/routes.go @@ -206,6 +206,11 @@ func (s *Server) registerAdminWriteRoutes(mux *http.ServeMux, ownerAuth func(htt mux.Handle("DELETE /api/v1/admin/automations/{id}", ownerAuth(http.HandlerFunc(s.adminDeleteAutomation))) mux.Handle("POST /api/v1/admin/llm", ownerAuth(http.HandlerFunc(s.adminAddLlmProvider))) mux.Handle("POST /api/v1/admin/llm/test", ownerAuth(http.HandlerFunc(s.adminTestLlm))) + // 「重置为 env 默认」(切片 R1,v0.62):把面板里 `.env` 声明过的键覆盖回 env 值, + // 组集合不增减。**仅 owner**——它是破坏性写(覆盖面板对这些键的修改),门与同页的 + // `POST /admin/llm`、`/admin/llm/test` 一致,绝不比同族写端点更宽。写操作,带审计 + // 动作 `llm.resetFromEnv`;破坏性另由请求体的 `confirm:true` 兜住。 + mux.Handle("POST /api/v1/admin/llm/reset-from-env", ownerAuth(http.HandlerFunc(s.adminResetLlmFromEnv))) mux.Handle("POST /api/v1/admin/channels/open", ownerAuth(http.HandlerFunc(s.adminChannelOpen))) mux.Handle("POST /api/v1/admin/channels/close", ownerAuth(http.HandlerFunc(s.adminChannelClose))) mux.Handle("POST /api/v1/admin/channels/test", ownerAuth(http.HandlerFunc(s.adminChannelTest))) diff --git a/internal/services/gateway/server.go b/internal/services/gateway/server.go index e777df5..9e7933b 100644 --- a/internal/services/gateway/server.go +++ b/internal/services/gateway/server.go @@ -71,7 +71,10 @@ type Server struct { dataDir string // llm 是可热切换的提供方栈(models.json)。为 nil 时 admin 的 /llm // 端点回退到环境变量配置。它是可选的,以便未接它的测试仍能工作。 - llm *model.RuntimeLLM + llm *model.RuntimeLLM + // envPath 是启动时解析的 `.env` 路径(见 Deps.EnvPath):只供 + // 「重置为 env 默认」动作按既有解析入口重读 env 默认层,网关不 fopen `.env`。 + envPath string channels ChannelConfigStore // plugins 是可在运行时安装的外部工具提供方管理器。见 // Deps.Plugins。 @@ -126,6 +129,7 @@ func New(cfg *config.Config, deps Deps) (*Server, error) { streaming: deps.Streaming, frontend: deps.Frontend, llm: deps.LLM, + envPath: deps.EnvPath, channels: deps.Channels, plugins: deps.Plugins, ownerRegistrar: deps.OwnerRegistrar,