主人 2026-10-07 两条口径:「.env 只做兜底用,后端自动创建独立的面板配置文件
之后就走面板配置文件,这些键优先从面板配置上取」+「频率必须调,可以调到 1 小时
或者三小时,或者动态分配(白天短、静默期长)」。
此前 $DATA_DIR/config/memory-tidy.json 只有读路径(装配期读一次 + 启动播种):
主人要改频率只能改文件 + 重启后端。本切片补上「读回来 → 改 → 落盘 → 当场生效」。
后端(读写端点,契约待塔登记 v0.92):
- GET /api/v1/admin/memories/tidy/tuning 读盘上 7 键值 + 取值规则 specs +
缺位时 .env 档的进程生效值(与 /admin/config-facts 同源)
- PUT 同路径:三态补丁(键缺位 = 不改动 / 合法值 = 采用 / 非法值 = 400 且盘上一
个字节都不动)+ null 清除哨兵(删键 ⇒ 回到下一档)+ writeAuth + 审计
memory.tidy.tuning;值一个都没变时不落盘,文件解不开时拒绝覆盖(409)
- 落盘走 runtimeconfig.PatchMemoryTidyTuning(原子写 temp+rename,与
SeedMemoryTidy/SaveWebSearch 同族同手法);补丁语义保留下受管的其它键
(t1Judge/t1PerWindow/autoApplyPerRound 原样保留)
- 热生效:保存后按「面板 > .env > 代码默认」重算并叠到运行期对象;整理档会
重排心跳(否则新周期要等旧周期那一觉睡完),审查档当场改 ingestGate/tempTtlSec。
未接线/失败时如实回 hot:false + hotError,绝不把「落盘了」说成「生效了」
前端(Miuix):
- 记忆管理页新增「记忆整理调优」卡:7 键表单(中文标签、单位、留空⇒跟随谁、
env 档当前值、坏值就地报错并拦住保存、保存后回显从磁盘读回的真值)
- 试算卡的 recallMode 按契约 v0.91 如实渲染:embedding / lexical(已降级)/
mixed(部分分区降级)/ ""(无候选可比)——空串不再渲染成空标签;并把
error.code=tidy_recall_unavailable 单列一句(「尝试过但向量源不可用」,与
「没有候选可比」显式区分)
验证:go test ./internal/{services/gateway,runtime/memory,config/runtimeconfig}
全绿;三态/清除/坏文件拒写/同值不重写/热生效接线各有用例,并逐条做过变异自证;
tsc --noEmit + vite build 通过;两个 esbuild 前端测试 39 + 34 + 41 checks 通过。
151 lines
7.0 KiB
Go
151 lines
7.0 KiB
Go
// memorytidy_save.go 是 `$DATA_DIR/config/memory-tidy.json` 的**写路径**(面板调优)。
|
||
//
|
||
// 为什么需要它(主人 2026-10-07 的两条口径,逐字):「`.env` 只做兜底用 在除了第1次
|
||
// 部署之后 后端自动创建 独立的面板配置文件之后就走面板配置文件。这些键优先从面板
|
||
// 配置上取」+「频率必须调 可以调到 1 小时或者三小时 或者动态分配,比如白天是间隔短
|
||
// 一点 晚上到了静默期稍微长一点」。在这条写路径出现之前,那个文件**只有读路径**
|
||
// (LoadMemoryTidyTuning + 启动播种 SeedMemoryTidy):主人要改频率只能改文件 + 重启。
|
||
//
|
||
// 它与读取侧(memorytidy.go)是**同一份文件形状的唯一两个门**:decodeMemoryTidy
|
||
// 仍是判「这份文件能不能被读走」的唯一那一行代码,写路径不另立一套判定。
|
||
//
|
||
// 四条口径(与既有的 SaveChannels / SaveWebSearch 同族同手法):
|
||
//
|
||
// 1. **补丁语义**:调用方给的是「要设的键(原样 JSON 值)+ 要删的键」,不是整份
|
||
// 文档。盘上其它键(`t1Judge` / `autoApplyPerRound` …以及将来新增的键)**原样
|
||
// 保留** —— 一次面板保存绝不把主人手写进文件的其它旋钮抹掉。
|
||
// 2. **值不变就不落盘**:合并后的文档与盘上那份**语义相同**(同一个 MarshalIndent
|
||
// 口径比较,而不是比原始字节)时**一个字节都不写**、连 mtime 都不动 —— 主人的
|
||
// 手写排版不该因为一次空操作被重排。
|
||
// 3. **读不走的文件拒绝覆盖**:文件存在但不是 JSON 对象 / 结构解不开 ⇒ 返回
|
||
// `ErrInvalidMemoryTidy`,调用方应**原样报错并拒绝写**(盘上此刻运行期正整体
|
||
// 回落 `.env`/代码默认,盖掉它只会掩盖问题)。
|
||
// 4. **原子写**:temp + rename(文件 0600 / 目录 0755,与 SeedMemoryTidy、
|
||
// SaveWebSearch 一致):并发读者要么看到旧文件、要么看到新文件,不会读到半个
|
||
// JSON;且**先写好再换名**,任何一步失败都不会留下半份配置。
|
||
//
|
||
// 格式上的代价(如实登记):补丁经 `map[string]json.RawMessage` 落盘 ⇒ 键序变成
|
||
// 字典序、缩进统一为 2 空格。每个键的值一个不差,但人工排过版的文件在**真正发生
|
||
// 改动时**会被重排一次(口径 2 保证「没改动就不重排」)。
|
||
package runtimeconfig
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
)
|
||
|
||
const (
|
||
// memoryTidySaveFileMode 是落盘文件的权限(只给跑服务的账号读)。
|
||
memoryTidySaveFileMode = 0o600
|
||
// memoryTidySaveDirMode 是 `$DATA_DIR/config/` 的建目录权限。
|
||
memoryTidySaveDirMode = 0o755
|
||
)
|
||
|
||
// PatchMemoryTidyTuning 把一组**已经过调用方校验**的键值并进
|
||
// `memory-tidy.json` 并原子写回,返回落盘后**重新读回**的调优结构。
|
||
//
|
||
// set 的键是「要写的键 → 原样 JSON 值」(调用方负责值的类型/取值域校验:那是 API
|
||
// 契约的一部分,属于网关侧;本函数只管**文件形状与落盘**)。clear 是要从文件里删掉
|
||
// 的键(= 回到下一档兜底)。两者都为空时**不落盘**。
|
||
//
|
||
// 返回 (tuning, wrote, err):
|
||
// - tuning 是**重新读盘**的结果(真值回显,不是「我以为写成了什么」);
|
||
// - wrote 报告这次是否真的写了字节(内容语义未变时为 false);
|
||
// - err 非 nil 时**盘上一个字节都没动**;错误链里带 ErrInvalidMemoryTidy 表示
|
||
// 「盘上那份读不走」⇒ 调用方应拒绝覆盖并如实报错。
|
||
func PatchMemoryTidyTuning(path string, set map[string]json.RawMessage, clear []string) (MemoryTidyTuning, bool, error) {
|
||
doc, err := readMemoryTidyDocForPatch(path)
|
||
if err != nil {
|
||
return MemoryTidyTuning{}, false, err
|
||
}
|
||
before := canonicalMemoryTidyJSON(doc)
|
||
merged := make(map[string]json.RawMessage, len(doc)+len(set))
|
||
for key, value := range doc {
|
||
merged[key] = value
|
||
}
|
||
for key, value := range set {
|
||
merged[key] = value
|
||
}
|
||
for _, key := range clear {
|
||
delete(merged, key)
|
||
}
|
||
raw, err := json.MarshalIndent(merged, "", " ")
|
||
if err != nil {
|
||
return MemoryTidyTuning{}, false, fmt.Errorf("runtimeconfig: marshal memory tidy: %w", err)
|
||
}
|
||
wrote := false
|
||
if !bytes.Equal(raw, before) {
|
||
if err := writeMemoryTidyTuningFile(path, raw); err != nil {
|
||
return MemoryTidyTuning{}, false, err
|
||
}
|
||
wrote = true
|
||
}
|
||
tuning, err := LoadMemoryTidyTuning(path)
|
||
if err != nil {
|
||
// 刚落盘就读不回来:这是本函数自己的错误(写出去的内容必须能被自己的读取
|
||
// 路径读走),如实报错,绝不回一份「成功」。
|
||
return MemoryTidyTuning{}, wrote, fmt.Errorf("runtimeconfig: reread %s after patch: %w", path, err)
|
||
}
|
||
return tuning, wrote, nil
|
||
}
|
||
|
||
// readMemoryTidyDocForPatch 读盘上的文档(键 → 原样 JSON 值)并确认**运行期读得走**。
|
||
//
|
||
// 缺失/全空白 ⇒ 空文档(= 还没配过,与 LoadMemoryTidyTuning 同义,不是错误);
|
||
// 存在但不是 JSON 对象、或整体结构解不开(类型写错)⇒ ErrInvalidMemoryTidy
|
||
// (口径 3:拒绝覆盖)。
|
||
func readMemoryTidyDocForPatch(path string) (map[string]json.RawMessage, error) {
|
||
raw, err := os.ReadFile(path)
|
||
if err != nil {
|
||
if os.IsNotExist(err) {
|
||
return map[string]json.RawMessage{}, nil
|
||
}
|
||
return nil, fmt.Errorf("runtimeconfig: read %s: %w", path, err)
|
||
}
|
||
if strings.TrimSpace(string(raw)) == "" {
|
||
return map[string]json.RawMessage{}, nil
|
||
}
|
||
// 与读取路径**同一行代码**判「这份文件能不能被读走」:一处说坏了、另一处说没事
|
||
// 就是自相矛盾(写入方会据此把一份坏配置盖掉)。
|
||
if _, err := decodeMemoryTidy(raw, path); err != nil {
|
||
return nil, err
|
||
}
|
||
doc := map[string]json.RawMessage{}
|
||
if err := json.Unmarshal(raw, &doc); err != nil {
|
||
return nil, fmt.Errorf("%w: decode %s as object: %w", ErrInvalidMemoryTidy, path, err)
|
||
}
|
||
return doc, nil
|
||
}
|
||
|
||
// canonicalMemoryTidyJSON 返回文档的规范化字节(与落盘同一个 MarshalIndent 口径),
|
||
// 供「值有没有变」的语义比较用。序列化失败时返回 nil —— 那只可能让比较不相等
|
||
// (= 走正常落盘路径),不会误判成「没变」。
|
||
func canonicalMemoryTidyJSON(doc map[string]json.RawMessage) []byte {
|
||
raw, err := json.MarshalIndent(doc, "", " ")
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
return raw
|
||
}
|
||
|
||
// writeMemoryTidyTuningFile 原子写出文件(口径 4):建目录 → 写临时文件 → rename。
|
||
func writeMemoryTidyTuningFile(path string, raw []byte) error {
|
||
if dir := filepath.Dir(path); dir != "" && dir != "." {
|
||
if err := os.MkdirAll(dir, memoryTidySaveDirMode); err != nil {
|
||
return fmt.Errorf("runtimeconfig: mkdir %s: %w", dir, err)
|
||
}
|
||
}
|
||
tmp := path + ".tmp"
|
||
if err := os.WriteFile(tmp, raw, memoryTidySaveFileMode); err != nil {
|
||
return fmt.Errorf("runtimeconfig: write %s: %w", tmp, err)
|
||
}
|
||
if err := os.Rename(tmp, path); err != nil {
|
||
return fmt.Errorf("runtimeconfig: rename %s: %w", path, err)
|
||
}
|
||
return nil
|
||
}
|