Files
Cyrene_v2/internal/config/runtimeconfig/reset.go
T
AskaEth c1fa1e9b9a
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(admin): 面板「重置为 env 默认」动作(materialize 的反方向)+ 契约 v0.62
- 语义:只把 .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 生效。
2026-10-04 21:33:11 +08:00

167 lines
7.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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))
}