feat(admin): 面板「重置为 env 默认」动作(materialize 的反方向)+ 契约 v0.62
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 语义:只把 .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 生效。
This commit is contained in:
AskaEth committed 2026-10-04 21:33:11 +08:00
1 parent 367abf2c8e
commit c1fa1e9b9a
19 files changed
+1434 -11

No files matched your search

+28 -5
View File
@@ -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`);网关**只读这份注入值**,不读环境Line truncated
> **契约版本 `v0.62`**(**面板「重置为 env 默认」动作落地:materialize 的反方向显式动作 + 一个新的 owner-only 管理写端点**,依据任务书 `DevDocs/_dispatch/R1-面板重置为env默认.md`、design/04 §3.1(`.env` 为默认/播种层、面板文件为一等来源)与 `DevDocs/开发时间线.md` 的欠账项(「Materialize 已导出、未接端点」),2026-10-04 落实)。**一句话**:materialize 是「面板缺该键才用 `.env` 补」,本版新增的 `POST /api/v1/admin/llm/reset-from-env` 是**反方向的显式动作**——部署者要求「把面板里 `.env` 声明过的键退回 env 值」。**A. 语义(与补齐表同源)**:只覆盖 `.env` 声明了非空值的键(`provider`/`baseUrl`/`apiKey`/`model`/`contextWindow`)、**env 未声明的键一律保留面板值**(`fallback`/`multimodal`/`maxTokens`/`reasoning`/`personaModels` 永不被改)、**组集合不增减**(不复活被删的组、不删新增的组,顺序也不动)、面板值已与 env 一致的键不算「改回」、**无键可改时不写盘**(幂等);真的改了盘就**热重载**运行中的 LLM 栈(`contextWindow` 的预算推导仍只在启动时算一次)。**B. 护栏 / 响应 / 审计**:**owner-only**(与同页两条 LLM 写端点同门;破坏性写不比同族更宽),body 必须 `{"confirm":true}`(缺失 / `false` / 非法 body ⇒ `400 bad_request` 且**一个字节都不动**)→ `200 {data:{reset:[{group,keys:[…]}], skipped:[{group,reason}], path}}`;**只回键名与中文原因,凭据值(尤其 `apiKey`)绝不回显、绝不进日志**;审计动作 `llm.resetFromEnv`(`ok`/`error`)。**C. 基准与失败**:对照基准 = 请求时刻按**既有解析入口**读到的 `.env` 默认层(新增 `config.LoadLLMDefaults`,路径经 `Deps.EnvPath` 注入,**网关绝不 fopen `.env`**);`.env` 源不可读 → `500 env_defaults_unreadable`、面板配置不存在/不可解析 → `500 models_unreadable`、写盘失败 → `500 llm_persist_failed`、已写盘但热重载失败 → `500 llm_reload_failed`、LLM 栈未注入 → `500 llm_unavailable`——**都不假装成功**。**D. 面板**:LLM 页新增「重置为 env 默认」卡片(`admin/modules/llm/LlmResetFromEnv.tsx`,行上标 `owner 专属`,**中文二次确认**后才提交)。**E. 兼容性**:**纯增量**(新端点 + 新前端卡片 + 新审计动作 + 2 个新错误码 `env_defaults_unreadable` / `models_unreadable`,另复用既有 `llm_persist_failed` / `llm_reload_failed` / `llm_unavailable`),既有端点 / WS 帧 / 字段形状**一字未动**;管理侧权限矩阵因此为 12(viewer 只读)+ 10(owner/admin 读)+ **38**(owner-only)= **60** 条路由。详见 §2「重置为 env 默认契约(v0.62)」。**上一版 `v0.61`**(**PDF 文本层抽取落地:白名单第 23 项 `.pdf` + 类型裁决由「嗅探只放行 `text/plain`」改为 `(扩展名, 嗅探)` 成对裁决**,依据 design/24 §7 的 **S6 第一段**(PDF/Office 的裁决:PDF 文本层引库,扫描件与 Office 仍诚实拒绝)与 commit `f82c76d`,2026-10-04 落实)。**三档标注**:**行为变更** = ① **`.pdf` 由「一律 `415`」变为「可上传 + 可读取 + 文本层可抽取进上下文」**(把「`.pdf` 必被拒」写死的客户端必须改);② **类型裁决收紧为成对判定**(`documents.ContentAllowed`)——**方向是收紧/细化,不是放宽**:HTML/纯文本改名成 `.pdf` ⇒ `415`(嗅探不是 PDF),**PDF 字节改名成 `.txt` ⇒ `415`**(PDF 字节也只收在 `.pdf` 名下);**纯增量** = 扩展名白名单 **22 → 23 项**(唯一新增 `.pdf`),其余 22 个文本类的判定与既有响应**一字未变**。**A. 上传(`POST /api/v1/documents`,owner|admin)**——白名单加 `.pdf`;裁决 = **字节嗅探是唯一裁决权 ∧ 扩展名白名单**,自本版起**按对判定**(纯文本类必须 `text/plain`、`.pdf` 必须 `application/pdf`;落点 `internal/runtime/documents/store.go` 的 `ExtAllowed`(23 项)/ `ContentAllowed`);`mime` 仍是**嗅探基类型**(真 PDF → `application/pdf`);`415` 文案同时点明两条口径。**B. 读取(`GET /api/v1/documents/{name}`,必带 Bearer owner|admin)**——**真 PDF 如实下发 `Content-Type: application/pdf`**(读时按同一对裁决重嗅探,判不定者仍退 `application/octet-stream`),`Content-Disposition: attachment` + `X-Content-Type-Options: nosniff` **一字未变** ⇒ **PDF 永不被内联渲染**(与图片读侧刻意相反)。**C. 抽取语义(DTO `status`)**——**文本层 PDF 抽正文**(`Tj`/`TJ`/`'`/`"` 文本算子,页间以换行相接)⇒ `status:"extracted"`,与纯文本类走同一条 sidecar/注入路径;**扫描件/图片型 PDF ⇒ 诚实拒绝 `status:"unreadable"`**,原因含「这是图片型/无文本层 PDF,需Line truncated
## 2. REST 端点(管理,`/api/v1/admin/*`)
@@ -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/<id>.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` 重试安全Line truncated
+29 -5
View File
@@ -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://<host>:8080/api/v1/admin/sessions/web:0123.../export -H "Authoriz
## 5. 管理后台 · 写端点(`owner`,全程审计)
> 以下端点**鉴权 = `Authorize(owner)`**(仅主人)。每个写操作都会调用 `auditClaims(r, "<action>", "ok"|"error")` 记录 who/what/when/result(落 `audit_logs` + 网关日志)。**敏感值(token/密钥/新 JWT 密钥/DB 路径)不在响应/日志回显**。
> 以下端点**鉴权 = `Authorize(owner)`**(仅主人)。每个写操作都会调用 `auditClaims(r, "<action>", "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://<host>: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":"<models.json 路径>"}}`。`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://<host>: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":<int>}}`——**真实模型调用**(超时 30s)。
@@ -1413,7 +1436,8 @@ TOKEN=$(curl -s -X POST http://<host>: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://<host>: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` 反映部署是否配置向量增强;**配置了增强必须真实走语义/向量路径(红线,非空壳)**。
+4
View File
@@ -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},
+5
View File
@@ -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 {
/>
</Surface>
{/* 「重置为 env 默认」(v0.62):面板把键改坏时的一键回基线入口。成功后重新
拉取配置,让上面的列表立刻反映退回后的真值。 */}
<LlmResetFromEnv onReset={reload} />
<CatalogPanel
onPick={(draft, picked) => {
log("llm.catalog.fill", "ok");
@@ -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<string | null>(null);
const [fail, setFail] = useState<string | null>(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 (
<Surface className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold">重置为 env 默认</h3>
<Tag tone="err">危险</Tag>
<Tag tone="warn">owner 专属</Tag>
<Tag tone="neutral">写操作 · 已审计</Tag>
</div>
<p className="text-xs text-[var(--mu-text-lo)]">
把面板里 <b>.env(默认层)声明过</b>的键覆盖回 env 值 —— 例如面板把 baseUrl / apiKey
改坏了,一键回到部署基线。只动 env 声明过的键:<b>env 未声明的键原样保留</b>,
组集合<b>不增减</b>(不复活被面板删掉的组、不删面板新增的组)。真的改了配置就热更新运行中的
LLM 供应商栈(无需重启),并且只回<b>键名</b>、绝不回显凭据值。
</p>
{!confirming ? (
<Button size="sm" variant="danger" disabled={busy} onClick={() => setConfirming(true)}>
重置为 env 默认
</Button>
) : (
<div className="space-y-2 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] p-3">
<p className="text-xs text-[var(--mu-text-mid)]">
这会用 <b>.env</b> 声明的值<b>覆盖面板里这些键的修改</b>(面板值一旦被覆盖就找不回来,
需要重新填写或保存)。组集合与 env 未声明的键都不受影响。确定继续?
</p>
<div className="flex items-center gap-2">
<Button size="sm" variant="danger" disabled={busy} onClick={run}>
{busy ? "重置中…" : "确认重置"}
</Button>
<Button size="sm" variant="ghost" disabled={busy} onClick={() => setConfirming(false)}>
取消
</Button>
</div>
</div>
)}
{fail ? <Notice tone="err">{fail}</Notice> : null}
{result ? <Notice tone="ok">{result}</Notice> : null}
</Surface>
);
}
+1
View File
@@ -79,6 +79,7 @@ export {
adminPluginInstall,
adminPluginUninstall,
adminRevokeApiKey,
adminResetLlmFromEnv,
adminRunAutomation,
adminSaveOwnerProfile,
adminSearchRag,
+10
View File
@@ -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<LlmTestResult> {
return request<LlmTestResult>("/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<AdminLlmResetResult> {
return request<AdminLlmResetResult>("/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. */
+20
View File
@@ -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. */
+37
View File
@@ -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
}
+81
View File
@@ -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 同一口径:窗口没有负数语义)")
}
}
+166
View File
@@ -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))
}
+280
View File
@@ -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)
}
}
@@ -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
}
@@ -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
}
@@ -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)
}
}
+5
View File
@@ -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 栈未设置时的行为一致。
@@ -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"},
+5
View File
@@ -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)))
+5 -1
View File
@@ -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,