主人 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 通过。
125 lines
6.7 KiB
TypeScript
125 lines
6.7 KiB
TypeScript
// 记忆整理「调优」卡的**真实渲染**测试(切片 MEMPANEL / M4)。
|
||
//
|
||
// 它钉住三件**文案与呈现**的事(逻辑那一半见 memoryTidyTuning.test.ts):
|
||
// ① 7 个键都有中文标签 + 各自的兜底说明(「留空 ⇒ 跟随谁」);
|
||
// ② 热生效与否**如实**:`hot:false` 必须写「重启后端后生效」,绝不写成「已生效」;
|
||
// ③ 坏值**就地报错**并拦住保存(后端那道 400 仍在,但不让主人白跑)。
|
||
//
|
||
// 跑法(与 MemoryTidyDryRunCard.render.test.ts 同):
|
||
//
|
||
// cd frontend/web \
|
||
// && ./node_modules/.bin/esbuild src/admin/modules/MemoryTidyTuningCard.render.test.ts \
|
||
// --bundle --platform=node --format=cjs \
|
||
// --outfile=/tmp/cyrene-memory-tidy-tuning.render.test.cjs \
|
||
// && node /tmp/cyrene-memory-tidy-tuning.render.test.cjs
|
||
//
|
||
// `tsc --noEmit` 会对本文件做类型检查,因此它只从项目内导入、不 import node:*。
|
||
|
||
import { createElement } from "react";
|
||
import { renderToStaticMarkup } from "react-dom/server";
|
||
import type { AdminMemoryTidyTuningView } from "../../lib/api/adminMemories";
|
||
import {
|
||
MemoryTidyTuningBody,
|
||
TIDY_TUNING_COLD_LABEL,
|
||
TIDY_TUNING_DISCLAIMER,
|
||
TIDY_TUNING_HOT_LABEL,
|
||
} from "./MemoryTidyTuningCard";
|
||
import { draftFromView, TUNING_LABELS } from "./memoryTidyTuning";
|
||
|
||
let checks = 0;
|
||
|
||
function ok(cond: boolean, what: string): void {
|
||
checks += 1;
|
||
if (!cond) throw new Error(`FAILED: ${what}`);
|
||
}
|
||
|
||
function has(haystack: string, needle: string): boolean {
|
||
return haystack.includes(needle);
|
||
}
|
||
|
||
/** fixtureView 造一份 GET 响应形状的数据(含全部 7 个键的规格)。 */
|
||
function fixtureView(overrides: Partial<AdminMemoryTidyTuningView> = {}): AdminMemoryTidyTuningView {
|
||
return {
|
||
path: "config/memory-tidy.json",
|
||
present: true,
|
||
hot: false,
|
||
hotError: "运行期没有接线可热生效的实现(deps.MemoryTidy / deps.Memories);新值已落盘,重启后端后生效",
|
||
specs: [
|
||
{ key: "intervalSec", kind: "int", unit: "sec", min: 1, fallback: "env:MEMORY_TIDY_INTERVAL_SEC" },
|
||
{ key: "quietIntervalSec", kind: "int", unit: "sec", min: 1, fallback: "panel:intervalSec" },
|
||
{ key: "quietStartHour", kind: "int", unit: "hour", min: 0, max: 23, fallback: "env:MEMORY_TIDY_QUIET_START_HOUR" },
|
||
{ key: "quietEndHour", kind: "int", unit: "hour", min: 0, max: 23, fallback: "env:MEMORY_TIDY_QUIET_END_HOUR" },
|
||
{ key: "ingestGate", kind: "bool", fallback: "code-default" },
|
||
{ key: "autoApply", kind: "bool", fallback: "code-default" },
|
||
{ key: "tempTtlSec", kind: "int", unit: "sec", min: 1, fallback: "code-default" },
|
||
],
|
||
values: { intervalSec: 10800, quietStartHour: 23, quietEndHour: 7, ingestGate: true, autoApply: true },
|
||
envValues: { intervalSec: 86400, quietStartHour: 23, quietEndHour: 7 },
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
function renderBody(view: AdminMemoryTidyTuningView, draft: Record<string, string>, errors: Record<string, string> = {}, extra: Record<string, unknown> = {}): string {
|
||
return renderToStaticMarkup(
|
||
createElement(MemoryTidyTuningBody, {
|
||
view,
|
||
draft,
|
||
errors,
|
||
busy: false,
|
||
receipt: null,
|
||
savedView: null,
|
||
onDraftChange: () => {},
|
||
onSave: () => {},
|
||
...extra,
|
||
} as never)
|
||
);
|
||
}
|
||
|
||
const view = fixtureView();
|
||
const html = renderBody(view, draftFromView(view), {}, { receipt: "已保存:平时整理间隔。", savedView: { ...view, hot: true } });
|
||
|
||
// ── ① 7 个键:中文标签 + 键名 + 兜底说明 ───────────────────────────────────
|
||
for (const key of Object.keys(TUNING_LABELS)) {
|
||
ok(has(html, TUNING_LABELS[key]), `必须渲染 ${key} 的中文标签`);
|
||
ok(has(html, key), `必须显示键名 ${key}(后端契约的键,面板不改名)`);
|
||
}
|
||
ok(has(html, TIDY_TUNING_DISCLAIMER), "恒显示「面板配置文件优先,.env 只做兜底」");
|
||
ok(has(html, "config/memory-tidy.json"), "必须显示面板配置文件的落点");
|
||
ok(has(html, "跟随 .env 的 MEMORY_TIDY_INTERVAL_SEC"), "缺位的键要写清「留空 ⇒ 跟随谁」(env 档)");
|
||
ok(has(html, "当前 86400"), "env 档的当前生效值要如实带出来");
|
||
ok(has(html, "跟随面板里的 intervalSec"), "quietIntervalSec 的下一档是面板内的 intervalSec");
|
||
ok(has(html, "跟随代码默认"), "ingestGate/autoApply/tempTtlSec 的下一档是代码默认");
|
||
ok(has(html, "≈ 3 小时"), "秒数要给可读提示(10800 ⇒ 3 小时)");
|
||
|
||
// ── ② 热生效与否:两种口径必须分得清 ───────────────────────────────────────
|
||
ok(has(html, TIDY_TUNING_HOT_LABEL), "hot:true 时必须说「已热生效(不用重启后端)」");
|
||
ok(has(html, "保存后回显"), "保存后必须回显**从磁盘重新读回**的真值");
|
||
ok(has(html, "intervalSec = 10800"), "回显要逐键给出真值");
|
||
|
||
const coldHtml = renderBody(view, draftFromView(view), {}, {
|
||
receipt: "已保存:平时整理间隔。",
|
||
savedView: view, // hot: false
|
||
});
|
||
ok(has(coldHtml, TIDY_TUNING_COLD_LABEL), "hot:false 时必须说「已保存到面板配置文件,重启后端后生效」");
|
||
ok(!has(coldHtml, TIDY_TUNING_HOT_LABEL), "hot:false 时绝不许出现「已热生效」");
|
||
ok(has(coldHtml, "运行期没有接线可热生效的实现"), "hot:false 要带出后端给的 hotError 原因");
|
||
|
||
// ── ③ 坏值就地报错并拦住保存 ───────────────────────────────────────────────
|
||
const badHtml = renderBody(view, { ...draftFromView(view), intervalSec: "0" }, {
|
||
intervalSec: "平时整理间隔 必须 ≥ 1 秒",
|
||
});
|
||
ok(has(badHtml, "必须 ≥ 1 秒"), "坏值要就地显示错误文案");
|
||
ok(has(badHtml, "有坏值,未提交"), "有坏值时必须明说「未提交」(盘上不会被动)");
|
||
ok(has(badHtml, "disabled"), "有坏值时保存按钮必须禁用");
|
||
ok(has(badHtml, "清空一个键 = 从面板文件里删掉它"), "要解释「清空 ≠ 写 0」这条三态口径");
|
||
ok(has(badHtml, "T1 判定"), "要说明同文件其它旋钮原样保留(这张卡只写 7 个键)");
|
||
|
||
// 缺位下的盘上值:没配 ⇒ 面板显示「未写 ⇒ 跟随兜底」,不编一个 0。
|
||
const missing = fixtureView({ values: {} });
|
||
const missingHtml = renderBody(missing, draftFromView(missing));
|
||
ok(has(missingHtml, "留空 ⇒ 跟随 .env 的 MEMORY_TIDY_INTERVAL_SEC"), "没配的键要显示「留空 ⇒ 跟随下一档」");
|
||
ok(!has(missingHtml, "intervalSec = 0"), "没配的键绝不能显示成 0(那是「写了 0」的坏值形态)");
|
||
|
||
// eslint-disable-next-line no-console
|
||
console.log(`MemoryTidyTuningCard.render: ${checks} checks passed`);
|