- 语义:只把 .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 生效。
167 lines
7.2 KiB
Go
167 lines
7.2 KiB
Go
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))
|
||
}
|