主人 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 通过。
153 lines
6.5 KiB
TypeScript
153 lines
6.5 KiB
TypeScript
import type {
|
||
AdminMemoryTidyTuningPatch,
|
||
AdminMemoryTidyTuningSpec,
|
||
AdminMemoryTidyTuningView,
|
||
} from "../../lib/api/adminMemories";
|
||
|
||
/* ----------------------------------------------------------------------------
|
||
* 记忆整理「面板调优」卡的**纯逻辑**(切片 MEMPANEL / M4):表单草稿 ↔ 三态补丁。
|
||
*
|
||
* 与呈现分开(与 memoryTidyApplyState.ts 同一手法):这一半不依赖 React,可以直接
|
||
* 用 node 跑断言(见 memoryTidyTuning.test.ts)。
|
||
*
|
||
* 三条不可走样的口径:
|
||
* 1. **取值规则来自后端的 specs**(类型/单位/上下界/兜底来源):面板不硬编码第二套
|
||
* 枚举或上下界 —— 否则就会出现「面板允许、后端 400」的漂移。本文件只负责把
|
||
* spec 翻成**草稿的校验规则**与中文文案。
|
||
* 2. **键缺位 ≠ 清空**:草稿里没动过的键**不进补丁**(= 后端不动它);把一个原本
|
||
* 有值的键清空 ⇒ 发 `null`(= 后端删掉该键,回到下一档兜底)。两者在盘上的结果
|
||
* 完全不同,因此这里必须分开。
|
||
* 3. **坏值就地报错**:非法草稿**不产生补丁**(界面据此拦下保存),而不是发出去等
|
||
* 后端 400 —— 后端那道门仍在(它是权威),这只是不让主人白跑一趟。
|
||
* -------------------------------------------------------------------------- */
|
||
|
||
/** `true` / `false` / 三个中文标签:调优键的中文名(面板文案)。 */
|
||
export const TUNING_LABELS: Record<string, string> = {
|
||
intervalSec: "平时整理间隔",
|
||
quietIntervalSec: "静默时段整理间隔",
|
||
quietStartHour: "静默时段开始(本地钟点)",
|
||
quietEndHour: "静默时段结束(本地钟点)",
|
||
ingestGate: "同步入库闸(写入时当场审查)",
|
||
autoApply: "轮末自动落地(高置信候选直接改记忆)",
|
||
tempTtlSec: "临时记忆存活时长",
|
||
};
|
||
|
||
/** 兜底来源的中文说明(后端 specs 的 `fallback` 只有三族)。 */
|
||
export function fallbackLabel(spec: AdminMemoryTidyTuningSpec): string {
|
||
if (spec.fallback.startsWith("env:")) {
|
||
return `跟随 .env 的 ${spec.fallback.slice("env:".length)}`;
|
||
}
|
||
if (spec.fallback.startsWith("panel:")) {
|
||
return `跟随面板里的 ${spec.fallback.slice("panel:".length)}`;
|
||
}
|
||
return "跟随代码默认";
|
||
}
|
||
|
||
/** 单位的中文说明。 */
|
||
export function unitLabel(spec: AdminMemoryTidyTuningSpec): string {
|
||
if (spec.unit === "sec") return "秒";
|
||
if (spec.unit === "hour") return "本地钟点(0-23)";
|
||
return "";
|
||
}
|
||
|
||
/** 秒数的可读提示(面板不改变单位,只帮主人把 10800 读成 3 小时)。 */
|
||
export function secondsHint(seconds: number): string {
|
||
if (!Number.isFinite(seconds) || seconds <= 0) return "";
|
||
if (seconds % 3600 === 0) return `≈ ${seconds / 3600} 小时`;
|
||
if (seconds % 60 === 0) return `≈ ${seconds / 60} 分钟`;
|
||
return `≈ ${seconds} 秒`;
|
||
}
|
||
|
||
/** 表单草稿:每个键一个字符串(bool 键用 "true"/"false",空串 = 跟随兜底/清空)。 */
|
||
export type TuningDraft = Record<string, string>;
|
||
|
||
/** 草稿的校验结果。 */
|
||
export interface TuningDraftCheck {
|
||
/** 键 → 就地错误文案(空对象 = 可以保存)。 */
|
||
errors: Record<string, string>;
|
||
/** 真正要提交的三态补丁(只含改动过的键)。 */
|
||
patch: AdminMemoryTidyTuningPatch;
|
||
}
|
||
|
||
/** draftFromView 把盘上的值铺成表单草稿(键缺位 ⇒ 空串 = 跟随兜底)。 */
|
||
export function draftFromView(view: AdminMemoryTidyTuningView): TuningDraft {
|
||
const draft: TuningDraft = {};
|
||
for (const spec of view.specs) {
|
||
const value = view.values[spec.key];
|
||
if (value === undefined || value === null) {
|
||
draft[spec.key] = "";
|
||
continue;
|
||
}
|
||
draft[spec.key] = typeof value === "boolean" ? (value ? "true" : "false") : String(value);
|
||
}
|
||
return draft;
|
||
}
|
||
|
||
/** fieldError 校验**单个**键的草稿值;返回空串表示合法(空 = 沿用兜底,也合法)。 */
|
||
export function fieldError(spec: AdminMemoryTidyTuningSpec, raw: string): string {
|
||
const text = raw.trim();
|
||
if (text === "") return "";
|
||
if (spec.kind === "bool") {
|
||
return text === "true" || text === "false" ? "" : `${TUNING_LABELS[spec.key] ?? spec.key} 只能选「开 / 关 / 跟随兜底」`;
|
||
}
|
||
if (!/^-?\d+$/.test(text)) {
|
||
return `${TUNING_LABELS[spec.key] ?? spec.key} 必须是整数(秒),收到「${text}」`;
|
||
}
|
||
const n = Number(text);
|
||
if (spec.min !== undefined && n < spec.min) {
|
||
return `${TUNING_LABELS[spec.key] ?? spec.key} 必须 ≥ ${spec.min}${unitLabel(spec)}`;
|
||
}
|
||
if (spec.max !== undefined && n > spec.max) {
|
||
return `${TUNING_LABELS[spec.key] ?? spec.key} 必须 ≤ ${spec.max}${unitLabel(spec)}`;
|
||
}
|
||
return "";
|
||
}
|
||
|
||
/** buildTuningPatch 是「草稿 → 三态补丁」的唯一实现点(口径 2、3)。 */
|
||
export function buildTuningPatch(
|
||
view: AdminMemoryTidyTuningView,
|
||
draft: TuningDraft
|
||
): TuningDraftCheck {
|
||
const errors: Record<string, string> = {};
|
||
const patch: AdminMemoryTidyTuningPatch = {};
|
||
for (const spec of view.specs) {
|
||
const raw = draft[spec.key] ?? "";
|
||
const err = fieldError(spec, raw);
|
||
if (err) {
|
||
errors[spec.key] = err;
|
||
continue;
|
||
}
|
||
const current = view.values[spec.key];
|
||
const text = raw.trim();
|
||
if (text === "") {
|
||
// 原本就没有值 ⇒ 一个字节都不必动(后端同样把「键缺位」当「不改动」)。
|
||
if (current === undefined || current === null) continue;
|
||
// 原本有值、现在清空 ⇒ **清除哨兵**(删掉该键,回到下一档兜底)。
|
||
patch[spec.key] = null;
|
||
continue;
|
||
}
|
||
if (spec.kind === "bool") {
|
||
const next = text === "true";
|
||
if (typeof current === "boolean" && current === next) continue;
|
||
patch[spec.key] = next;
|
||
continue;
|
||
}
|
||
const next = Number(text);
|
||
if (typeof current === "number" && current === next) continue;
|
||
patch[spec.key] = next;
|
||
}
|
||
return { errors, patch };
|
||
}
|
||
|
||
/** patchKeys 返回补丁里出现的键(供回执文案与日志/审计展示,顺序稳定)。 */
|
||
export function patchKeys(patch: AdminMemoryTidyTuningPatch): string[] {
|
||
return Object.keys(patch).sort();
|
||
}
|
||
|
||
/** patchSummary 把补丁说成人话(保存后回执用,例如「平时整理间隔、同步入库闸」)。 */
|
||
export function patchSummary(patch: AdminMemoryTidyTuningPatch): string {
|
||
const keys = patchKeys(patch);
|
||
if (keys.length === 0) return "没有需要改的键";
|
||
return keys.map((k) => TUNING_LABELS[k] ?? k).join("、");
|
||
}
|