feat(memory): 记忆整理调优面板读写(三态 + 热生效 + 面板表单)

主人 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 通过。
This commit is contained in:
AskaEth committed 2026-10-13 21:59:18 +08:00
1 parent 17c41265e8
commit eddf921229
18 files changed
+2604 -5

No files matched your search

@@ -9,6 +9,7 @@ import { Button, Surface, Tag } from "../../components/primitives";
import { MemoryTidyFactsCard } from "./ConfigFactsCards";
import { MemoryTidyDryRunCard } from "./MemoryTidyDryRunCard";
import { MemoryTidyApplyCard } from "./MemoryTidyApplyCard";
import { MemoryTidyTuningCard } from "./MemoryTidyTuningCard";
const scopeTone: Record<string, "ok" | "warn" | "accent" | "neutral"> = {
user: "accent",
@@ -191,6 +192,8 @@ export function MemoriesModule(): React.ReactNode {
<MemoryTidyApplyCard />
<MemoryTidyTuningCard />
<MemoryTidyFactsCard />
<Notice>
@@ -25,6 +25,8 @@ import {
MemoryTidyDryRunBody,
TIDY_DRY_RUN_DISCLAIMER,
TIDY_DRY_RUN_ERROR_HINT,
TIDY_RECALL_UNAVAILABLE,
TIDY_RECALL_UNAVAILABLE_HINT,
type TidyDryRunState,
} from "./MemoryTidyDryRunCard";
@@ -96,7 +98,7 @@ ok(has(doneHtml, "0.950"), "必须显示分数(区间)");
ok(has(doneHtml, "mem-short") && has(doneHtml, "保留 mem-long"), "必须显示「哪条 → 保留哪条」");
ok(has(doneHtml, "作息偏晚"), "必须显示合并后的存活条目原文(diff 预览)");
ok(has(doneHtml, "扫描 736") && has(doneHtml, "用时 42 ms"), "必须显示扫描口径与耗时");
ok(has(doneHtml, "召回来源:embedding"), "必须显示召回来源(lexical 是降级信号)");
ok(has(doneHtml, "召回来源:语义向量(embedding)"), "必须显示召回来源(lexical 是降级信号)");
ok(has(doneHtml, "scope=1"), "必须显示否决分布(它解释「为什么没有更多建议」)");
// ── 截断:必须说明「这一轮看到的比库里少 + 该调哪个键」 ─────────────────────
@@ -124,6 +126,42 @@ ok(!has(errorHtml, "扫描 "), "错误态绝不得渲染观测口径(扫描/
ok(!has(errorHtml, "这一轮没有产生任何候选"), "错误态不得复用空态文案(两者含义不同)");
ok(has(errorHtml, "把失败画成「0 条」"), "错误态反而要解释「为什么不显示 0 条」");
// ── ④ 召回来源(契约 v0.91):四种取值**各有各的话**,空串绝不是空标签 ────────
// 起因是 M6 的诊断(DevDocs/audit/去重失效诊断-2026-10-13.md):旧实现把「全量
// 降级」静默报成 embedding,面板据此判读会把「向量不可用」读成「向量正常」。
// 因此这个字段的**全部价值**就是让人看得出这一轮到底走了哪条路。
const recallCases: Array<[string, string]> = [
["embedding", "语义向量"],
["lexical", "已降级"],
["mixed", "部分分区降级"],
["", "无候选可比"],
["unregistered_mode", "未登记取值"],
];
for (const [mode, want] of recallCases) {
const html = render({ kind: "done", result: { ...RESULT, recallMode: mode } });
ok(has(html, want), `recallMode=${JSON.stringify(mode)} 必须渲染成可读中文(含「${want}」)`);
}
// 空串绝不许渲染成「召回来源:」后面空白(那是本次要修的那个缺陷)。
const noCandidateHtml = render({ kind: "done", result: { ...RESULT, recallMode: "", candidates: [], pairs: 0 } });
ok(has(noCandidateHtml, "无候选可比"), "空串 = 本轮没做召回,必须说出来");
ok(!has(noCandidateHtml, "召回来源:<"), "空串绝不能渲染成空标签(后面什么都没有)");
ok(!has(noCandidateHtml, "召回来源:</"), "空串绝不能渲染成空标签(连标签体都空)");
ok(has(noCandidateHtml, "未做召回"), "空串要解释「这一轮压根没算过相似度」,不能含糊成「—」");
// ── ⑤ 「尝试过召回但向量源不可用」:与上面两种都不同,走**错误**路径 ─────────
// 试算的严格口径下这种失败不回响应体(前端只能从 error.code 认出来),因此这里
// 必须单独给一句说明 —— 否则主人对着一句「试算失败」分不清是没候选还是向量挂了。
const recallDownHtml = render({
kind: "error",
message: "语义召回(embedding)不可用,试算未产出候选(试算不做静默降级)",
code: TIDY_RECALL_UNAVAILABLE,
});
ok(has(recallDownHtml, TIDY_RECALL_UNAVAILABLE_HINT), "向量源不可用时要给专门的说明");
ok(has(recallDownHtml, "不等于"), "必须与「没有候选可比」显式区分");
ok(has(recallDownHtml, "没有降级到词项"), "必须说清它也没有降级到词项余弦");
ok(!has(errorHtml, TIDY_RECALL_UNAVAILABLE_HINT), "别的错误不该套用这条说明(不许乱归因)");
// ── 未跑 / 在跑 ────────────────────────────────────────────────────────────
const idleHtml = render({ kind: "idle" });
ok(has(idleHtml, "试算整理建议"), "未跑时必须给出按钮文案");
@@ -1,5 +1,5 @@
import { useState } from "react";
import { adminMemoryTidyDryRun } from "../../lib/api";
import { ApiError, adminMemoryTidyDryRun } from "../../lib/api";
import type { AdminTidyDryRunCandidate, AdminTidyDryRunResult } from "../../lib/types";
import { Button, Surface, Tag } from "../../components/primitives";
import { Notice } from "../components";
@@ -38,7 +38,18 @@ export type TidyDryRunState =
| { kind: "idle" }
| { kind: "running" }
| { kind: "done"; result: AdminTidyDryRunResult }
| { kind: "error"; message: string };
| { kind: "error"; message: string; code?: string };
/** TIDY_RECALL_UNAVAILABLE 是「这一轮**尝试过**向量召回但向量源不可用」的错误码。
*
* 它与「没有候选可比」(成功响应 + `recallMode:""`)和「降级到词项」(`lexical`)
* 是三件不同的事,必须各有各的话。试算的严格口径下这种失败**不回响应体**,因此它
* 只走错误路径 —— 面板在这里把它说出来,而不是让主人对着一句「试算失败」猜。 */
export const TIDY_RECALL_UNAVAILABLE = "tidy_recall_unavailable";
/** TIDY_RECALL_UNAVAILABLE_HINT 是上面那个错误码的补充说明。 */
export const TIDY_RECALL_UNAVAILABLE_HINT =
"这一轮尝试过向量召回、但向量源不可用(试算不做静默降级):它既不等于「没有候选可比」,也没有降级到词项余弦。请检查 embedding 服务后重试。";
const actionLabel: Record<string, string> = {
merge: "并入",
@@ -112,6 +123,50 @@ function CandidateRow({ candidate }: { candidate: AdminTidyDryRunCandidate }): R
);
}
/** RecallSourceTag 渲染「这一轮到底走的是向量还是词项」(契约 v0.91 的 `recallMode`)。
*
* 它是 M6 诊断(`DevDocs/audit/去重失效诊断-2026-10-13.md`)的落点:旧实现按「有没
* 有配 embedder」写这个字段,于是**全量降级被静默报成 `embedding`**,面板据此判读会
* 把「向量不可用」读成「向量正常工作」。取值域与读法(三种态**必须分得清**):
*
* - `embedding` —— 语义向量召回真的用上了(好态);
* - `lexical` —— 全部走零依赖词项余弦(进程没接 embedder ⇒ 降级,命中会更少);
* - `mixed` —— **部分分区降级**、其余走向量(v0.91 新增);
* - `""` —— **没有候选可比**:这一轮压根没算过相似度(不是降级,也不该显示成
* 一个空标签 —— 那会让人以为字段坏了)。
*
* ⚠️ 「尝试过召回但向量源不可用」**不在这里**:试算的严格口径下那种情况直接报错
* (`error.code = tidy_recall_unavailable`),响应体根本不会出现;线上也没有
* `recall_unavailable_partitions` 这个键(它只存在于后端日志行)。因此本组件不许
* 用「—」把空串含糊掉 —— 每个取值都有一句可读的中文。 */
export function recallSourceLabel(mode: string): string {
switch (mode) {
case "embedding":
return "召回来源:语义向量(embedding)";
case "lexical":
return "召回来源:词项余弦(未接向量,已降级)";
case "mixed":
return "召回来源:部分分区降级(其余走向量)";
case "":
return "召回来源:无候选可比(本轮未做召回)";
default:
return `召回来源:${mode}(未登记取值)`;
}
}
/** recallSourceTone 给上面那句话配色调:好态 accent、降级 warn、无候选 neutral。 */
export function recallSourceTone(mode: string): "accent" | "warn" | "neutral" {
switch (mode) {
case "embedding":
return "accent";
case "lexical":
case "mixed":
return "warn";
default:
return "neutral";
}
}
/** ResultBody 渲染一次试算的完整结果(有候选 / 空 / 观测口径)。 */
function ResultBody({ result }: { result: AdminTidyDryRunResult }): React.ReactNode {
const vetoReasons = result.vetoReasons ?? {};
@@ -119,7 +174,7 @@ function ResultBody({ result }: { result: AdminTidyDryRunResult }): React.ReactN
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-3">
<Tag tone="ok">算完了,一行都没写</Tag>
<Tag tone={result.recallMode === "embedding" ? "accent" : "warn"}>召回来源:{result.recallMode}</Tag>
<Tag tone={recallSourceTone(result.recallMode)}>{recallSourceLabel(result.recallMode)}</Tag>
<span className="text-[11px] text-[var(--mu-text-lo)]">
扫描 {result.scanned} / 比对 {result.compared} / 召回 {result.pairs} 对 / 否决 {result.vetoed} 对 · 用时{" "}
{result.durationMs} ms · 规则 {result.ruleVersion} · 分区 {result.partitions}
@@ -174,6 +229,9 @@ function StateBody({ state }: { state: TidyDryRunState }): React.ReactNode {
return (
<div className="space-y-1">
<Notice tone="err">试算失败:{state.message}</Notice>
{state.code === TIDY_RECALL_UNAVAILABLE ? (
<span className="text-[11px] text-[var(--mu-text-mid)]">{TIDY_RECALL_UNAVAILABLE_HINT}</span>
) : null}
<span className="text-[11px] text-[var(--mu-text-lo)]">
{TIDY_DRY_RUN_ERROR_HINT}。这里刻意不显示候选数 —— 把失败画成「0 条」就是把「没算出来」冒充成结论。
</span>
@@ -223,7 +281,11 @@ export function MemoryTidyDryRunCard(): React.ReactNode {
})
.catch((e: unknown) => {
log("memory.tidy.dryRun", "error");
setState({ kind: "error", message: e instanceof Error ? e.message : "试算失败" });
setState({
kind: "error",
message: e instanceof Error ? e.message : "试算失败",
code: e instanceof ApiError ? e.code : undefined,
});
});
};
@@ -0,0 +1,124 @@
// 记忆整理「调优」卡的**真实渲染**测试(切片 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`);
@@ -0,0 +1,273 @@
import { useCallback, useEffect, useState } from "react";
import {
adminMemoryTidyTuning,
adminMemoryTidyTuningSave,
type AdminMemoryTidyTuningView,
} from "../../lib/api/adminMemories";
import { Button, Surface, Tag } from "../../components/primitives";
import { Notice } from "../components";
import { useAuditStore } from "../audit";
import type { TuningDraft } from "./memoryTidyTuning";
import {
TUNING_LABELS,
buildTuningPatch,
draftFromView,
fallbackLabel,
patchSummary,
secondsHint,
unitLabel,
} from "./memoryTidyTuning";
/**
* 记忆整理「调优」卡(切片 MEMPANEL / M4)。真源:
* internal/services/gateway/admin_memory_tuning.go。
*
* 它回答的是主人的两句原话:「`.env` 只做兜底用 …… 后端自动创建独立的面板配置文件
* 之后就走面板配置文件,这些键优先从面板配置上取」与「频率必须调,可以调到 1 小时
* 或者三小时,或者动态分配,比如白天间隔短一点、晚上静默期稍微长一点」。
*
* 三条口径落在文案里,因为它们是这张卡的产品承诺:
* 1. **面板优先、`.env` 兜底**:留空 = 不写这个键 ⇒ 回落 `.env` → 代码默认。
* 每个键都写清「跟随谁」,不做含糊的「未设置」;
* 2. **坏值就地报错**:校验规则来自后端的 specs(类型/单位/上下界),非法草稿
* **不发请求**(后端那道 400 仍在,只是不让主人白跑);
* 3. **热生效与否如实说**:后端回 `hot:false` 时写「重启后端后生效」,绝不写成
* 「已生效」。
*
* 呈现与取数分层:`MemoryTidyTuningBody` 只吃状态(渲染测试直接喂 fixture),卡组件
* 负责取数 + 表单草稿 + 保存。
*/
/** 这张卡的核心承诺(渲染测试钉住它必须出现)。 */
export const TIDY_TUNING_DISCLAIMER = "面板配置文件优先,.env 只做兜底";
/** 热生效与「要重启」两种文案(测试分别钉住)。 */
export const TIDY_TUNING_HOT_LABEL = "已热生效(不用重启后端)";
export const TIDY_TUNING_COLD_LABEL = "已保存到面板配置文件,重启后端后生效";
/** BodyProps 是展示半边的输入(全部状态由外面给,便于直接渲染断言)。 */
export interface TuningBodyProps {
view: AdminMemoryTidyTuningView;
draft: TuningDraft;
errors: Record<string, string>;
busy: boolean;
/** 保存成功的回执文案(含热生效与否);null = 还没保存过。 */
receipt: string | null;
/** 保存后回显的真值(后端**重新读盘**的结果)。 */
savedView: AdminMemoryTidyTuningView | null;
onDraftChange: (key: string, value: string) => void;
onSave: () => void;
}
/** hotLabel 返回「这次到底生没生效」的诚实文案。 */
export function hotLabel(view: AdminMemoryTidyTuningView): string {
return view.hot ? TIDY_TUNING_HOT_LABEL : TIDY_TUNING_COLD_LABEL;
}
/** fallbackHint 返回「留空时跟随谁 + 兜底那一档当前是多少」(有就报数)。 */
function fallbackHint(view: AdminMemoryTidyTuningView, key: string, fallback: string): string {
const envValue = view.envValues?.[key];
const base = fallbackLabel({ key, kind: "int", fallback });
return envValue === undefined ? base : `${base}(当前 ${envValue})`;
}
/** FieldRow 渲染一个键:中文标签 + 控件 + 兜底说明 + 就地错误。 */
function FieldRow({
spec,
value,
error,
fallback,
valueHint,
onChange,
}: {
spec: AdminMemoryTidyTuningView["specs"][number];
value: string;
error: string;
fallback: string;
valueHint: string;
onChange: (next: string) => void;
}): React.ReactNode {
const label = TUNING_LABELS[spec.key] ?? spec.key;
const inputCls =
"w-40 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]";
return (
<div className="space-y-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)]/60 px-3 py-2">
<div className="flex flex-wrap items-center gap-2">
<span className="min-w-56 text-xs text-[var(--mu-text-hi)]">{label}</span>
{spec.kind === "bool" ? (
<select
value={value}
className="h-9 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
onChange={(e) => onChange(e.target.value)}
>
<option value="">跟随兜底</option>
<option value="true">开</option>
<option value="false">关</option>
</select>
) : (
<input
value={value}
inputMode="numeric"
placeholder="留空 = 跟随兜底"
className={inputCls}
onChange={(e) => onChange(e.target.value)}
/>
)}
{valueHint ? <span className="text-[11px] text-[var(--mu-text-lo)]">{valueHint}</span> : null}
<span className="font-mono text-[11px] text-[var(--mu-text-lo)]">{spec.key}</span>
</div>
<div className="text-[11px] text-[var(--mu-text-lo)]">
{value.trim() === "" ? `留空 ⇒ ${fallback}` : `写进面板配置文件(覆盖 ${fallback})`}
{spec.kind === "int" && unitLabel(spec) ? ` · 单位:${unitLabel(spec)}` : ""}
</div>
{error ? <div className="text-[11px] text-[var(--mu-err)]">⚠ {error}</div> : null}
</div>
);
}
/** MemoryTidyTuningBody 是展示半边:吃状态、不取数(渲染测试直接喂它)。 */
export function MemoryTidyTuningBody({
view,
draft,
errors,
busy,
receipt,
savedView,
onDraftChange,
onSave,
}: TuningBodyProps): React.ReactNode {
const hasErrors = Object.keys(errors).length > 0;
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<Tag tone="accent">{TIDY_TUNING_DISCLAIMER}</Tag>
<span className="font-mono text-[11px] text-[var(--mu-text-lo)]">{view.path}</span>
<span className="text-[11px] text-[var(--mu-text-lo)]">
{view.present ? "面板配置已存在" : "面板配置还没创建(后端下次启动会自动创建)"}
</span>
</div>
{view.specs.map((spec) => (
<FieldRow
key={spec.key}
spec={spec}
value={draft[spec.key] ?? ""}
error={errors[spec.key] ?? ""}
fallback={fallbackHint(view, spec.key, spec.fallback)}
valueHint={spec.unit === "sec" && /^\d+$/.test((draft[spec.key] ?? "").trim())
? secondsHint(Number((draft[spec.key] ?? "").trim()))
: ""}
onChange={(next) => onDraftChange(spec.key, next)}
/>
))}
<div className="flex flex-wrap items-center gap-2">
<Button variant="primary" disabled={busy || hasErrors} onClick={onSave}>
{busy ? "保存中…" : "保存调优"}
</Button>
{hasErrors ? (
<span className="text-[11px] text-[var(--mu-text-lo)]">有坏值,未提交(后端一个字都不会改)</span>
) : null}
</div>
{receipt ? <Notice>{receipt}</Notice> : null}
{savedView ? (
<div className="space-y-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-[11px]">
<span className="text-[var(--mu-text-mid)]">保存后回显(从磁盘重新读回的真值):</span>
{savedView.specs.map((spec) => {
const v = savedView.values[spec.key];
return (
<div key={spec.key} className="font-mono text-[var(--mu-text-lo)]">
{spec.key} = {v === undefined ? "(未写 ⇒ 跟随兜底)" : String(v)}
</div>
);
})}
<div className="text-[var(--mu-text-mid)]">{hotLabel(savedView)}</div>
{savedView.hot ? null : (
<div className="text-[var(--mu-text-lo)]">{savedView.hotError ?? "运行期未接线可热生效的实现"}</div>
)}
</div>
) : null}
<p className="text-[11px] text-[var(--mu-text-lo)]">
只写这张卡的 7 个键;文件里其它旋钮(T1 判定 / T1 限速 / 每轮自动落地上限)原样保留。
清空一个键 = 从面板文件里删掉它(回到下一档兜底),不是写 0。
</p>
</div>
);
}
/** MemoryTidyTuningCard 负责取数、表单草稿与保存。 */
export function MemoryTidyTuningCard(): React.ReactNode {
const [view, setView] = useState<AdminMemoryTidyTuningView | null>(null);
const [draft, setDraft] = useState<TuningDraft>({});
const [errors, setErrors] = useState<Record<string, string>>({});
const [busy, setBusy] = useState(false);
const [fail, setFail] = useState<string | null>(null);
const [receipt, setReceipt] = useState<string | null>(null);
const [savedView, setSavedView] = useState<AdminMemoryTidyTuningView | null>(null);
const log = useAuditStore((s) => s.log);
const reload = useCallback(() => {
setFail(null);
return adminMemoryTidyTuning()
.then((next) => {
setView(next);
setDraft(draftFromView(next));
})
.catch((e: unknown) => setFail(e instanceof Error ? e.message : "读取面板配置失败"));
}, []);
useEffect(() => {
void reload();
}, [reload]);
const onDraftChange = useCallback((key: string, value: string) => {
setDraft((prev) => ({ ...prev, [key]: value }));
setReceipt(null);
}, []);
const onSave = useCallback(() => {
if (!view) return;
const check = buildTuningPatch(view, draft);
setErrors(check.errors);
if (Object.keys(check.errors).length > 0) return;
setBusy(true);
setFail(null);
adminMemoryTidyTuningSave(check.patch)
.then((next) => {
// 响应里的值是后端**重新读盘**的结果:据此重铺草稿(回显真值)。
setView(next);
setDraft(draftFromView(next));
setSavedView(next);
setErrors({});
setReceipt(`已保存:${patchSummary(check.patch)}。${hotLabel(next)}`);
log("memory.tidy.tuning", "ok");
})
.catch((e: unknown) => {
setFail(e instanceof Error ? e.message : "保存面板配置失败");
log("memory.tidy.tuning", "error");
})
.finally(() => setBusy(false));
}, [view, draft, log]);
return (
<Surface className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold">记忆整理调优(面板配置)</h3>
<Tag tone="neutral">GET / PUT /api/v1/admin/memories/tidy/tuning</Tag>
</div>
{fail ? <Notice tone="err">{fail}</Notice> : null}
{view === null ? (
<span className="text-xs text-[var(--mu-text-lo)]">{fail ? "读取失败" : "读取中…"}</span>
) : (
<MemoryTidyTuningBody
view={view}
draft={draft}
errors={errors}
busy={busy}
receipt={receipt}
savedView={savedView}
onDraftChange={onDraftChange}
onSave={onSave}
/>
)}
</Surface>
);
}
@@ -0,0 +1,167 @@
// 记忆整理「调优」卡**纯逻辑**的测试(切片 MEMPANEL / M4)。
//
// 为什么需要它:这张卡最容易写错的地方不是样式,而是**三态**(键缺位 / 合法值 /
// 非法值)与「清空 ≠ 不改」这两条 —— 错一处就会写出「主人以为改了、盘上没动」或者
// 「一个坏值把整份配置写成 0」这类事故。这些判据全在 memoryTidyTuning.ts 的纯函数里,
// 因此不需要渲染就能钉住。
//
// 跑法(与同目录 *.render.test.ts 同一手法,本文件不依赖 React):
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/admin/modules/memoryTidyTuning.test.ts \
// --bundle --platform=node --format=cjs \
// --outfile=/tmp/cyrene-memory-tidy-tuning.test.cjs \
// && node /tmp/cyrene-memory-tidy-tuning.test.cjs
//
// `tsc --noEmit` 会对本文件做类型检查,因此它只从项目内导入、不 import node:*。
import type {
AdminMemoryTidyTuningSpec,
AdminMemoryTidyTuningView,
} from "../../lib/api/adminMemories";
import {
buildTuningPatch,
draftFromView,
fallbackLabel,
fieldError,
patchSummary,
secondsHint,
} from "./memoryTidyTuning";
let checks = 0;
function ok(cond: boolean, what: string): void {
checks += 1;
if (!cond) throw new Error(`FAILED: ${what}`);
}
function eq<T>(got: T, want: T, what: string): void {
checks += 1;
if (got !== want) throw new Error(`FAILED: ${what}:got=${JSON.stringify(got)} want=${JSON.stringify(want)}`);
}
function deepEq(got: unknown, want: unknown, what: string): void {
const g = JSON.stringify(got);
const w = JSON.stringify(want);
checks += 1;
if (g !== w) throw new Error(`FAILED: ${what}:got=${g} want=${w}`);
}
/** spec 是后端 specs 的替身(取值规则来自后端,前端只消费)。 */
function spec(key: string, kind: string, extra: Partial<AdminMemoryTidyTuningSpec> = {}): AdminMemoryTidyTuningSpec {
return { key, kind, fallback: "code-default", ...extra } as AdminMemoryTidyTuningSpec;
}
/** view 造一份 GET 响应形状的 fixture。 */
function view(values: Record<string, number | boolean>, envValues?: Record<string, number>): AdminMemoryTidyTuningView {
return {
path: "config/memory-tidy.json",
present: true,
hot: false,
hotError: "运行期没有接线可热生效的实现",
specs: [
spec("intervalSec", "int", { unit: "sec", min: 1, fallback: "env:MEMORY_TIDY_INTERVAL_SEC" }),
spec("quietIntervalSec", "int", { unit: "sec", min: 1, fallback: "panel:intervalSec" }),
spec("quietStartHour", "int", { unit: "hour", min: 0, max: 23, fallback: "env:MEMORY_TIDY_QUIET_START_HOUR" }),
spec("quietEndHour", "int", { unit: "hour", min: 0, max: 23, fallback: "env:MEMORY_TIDY_QUIET_END_HOUR" }),
spec("ingestGate", "bool", { fallback: "code-default" }),
spec("autoApply", "bool", { fallback: "code-default" }),
spec("tempTtlSec", "int", { unit: "sec", min: 1, fallback: "code-default" }),
],
values,
envValues,
};
}
// ① 草稿:键缺位 ⇒ 空串(= 跟随兜底),而不是 0 —— 「没配」与「配成 0」必须分得开。
{
const draft = draftFromView(view({ intervalSec: 10800, ingestGate: false }));
eq(draft.intervalSec, "10800", "草稿回填盘上值");
eq(draft.ingestGate, "false", "布尔键回填为字符串");
eq(draft.quietIntervalSec, "", "盘上没这个键 ⇒ 空串(跟随兜底)");
eq(draft.tempTtlSec, "", "盘上没这个键 ⇒ 空串(跟随兜底)");
}
// ② 三个态:没动过的键**不进补丁**(= 不改动);改过的进补丁;合法值按类型提交。
{
const base = view({ intervalSec: 86400, quietStartHour: 23, quietEndHour: 7, ingestGate: false });
const draft = draftFromView(base);
const untouched = buildTuningPatch(base, draft);
deepEq(untouched.patch, {}, "没动过任何键 ⇒ 空补丁(盘上逐字节不动)");
deepEq(untouched.errors, {}, "没动过任何键 ⇒ 无错误");
const changed = buildTuningPatch(base, { ...draft, intervalSec: "10800", ingestGate: "true" });
deepEq(changed.patch, { intervalSec: 10800, ingestGate: true }, "合法值 ⇒ 采用(且只含改动过的键)");
deepEq(changed.errors, {}, "合法值 ⇒ 无错误");
}
// ③ 清空哨兵:原本有值、现在清空 ⇒ `null`(删掉该键,回到下一档兜底);
// 原本就没值、现在还是空 ⇒ 什么都不发(不改动)。
{
const base = view({ intervalSec: 3600, tempTtlSec: 604800 });
const draft = draftFromView(base);
const cleared = buildTuningPatch(base, { ...draft, intervalSec: "" });
deepEq(cleared.patch, { intervalSec: null }, "清空一个有值的键 ⇒ null(回到兜底)");
const alreadyEmpty = view({ ingestGate: true });
const mix = view({ ingestGate: true });
const r2 = buildTuningPatch(mix, { ...draftFromView(alreadyEmpty), tempTtlSec: "" });
deepEq(r2.patch, {}, "原本就没值的键保持空 ⇒ 不改动(不是清除)");
}
// ④ 非法值:**就地报错且不进补丁**(界面据此拦下保存),后端的 400 仍是权威那道门。
{
const base = view({ intervalSec: 3600, quietStartHour: 23, quietEndHour: 7 });
const draft = draftFromView(base);
const cases: Array<[string, string, string]> = [
["intervalSec", "abc", "非整数"],
["intervalSec", "0", "低于下界(0 会让整理空转)"],
["intervalSec", "-60", "负数"],
["quietStartHour", "24", "钟点越上界"],
["quietEndHour", "-1", "钟点越下界"],
["tempTtlSec", "1.5", "小数不是整数"],
];
for (const [key, raw, why] of cases) {
const res = buildTuningPatch(base, { ...draft, [key]: raw });
ok(res.errors[key] !== undefined, `非法值必须就地报错(${why})`);
ok(res.patch[key] === undefined, `非法值绝不能进补丁(${why})`);
}
// 混着来:一个坏值不影响对合法键的校验,但界面整体不许保存(errors 非空)。
const mixed = buildTuningPatch(base, { ...draft, intervalSec: "0", quietStartHour: "22" });
ok(Object.keys(mixed.errors).length === 1, "只有一个坏值时只报一处");
eq(mixed.patch.quietStartHour, 22, "合法键仍照常进补丁(后端一次事务性写盘)");
// 布尔键只认三个取值。
eq(fieldError(spec("ingestGate", "bool", { fallback: "code-default" }), "yes").length > 0, true, "布尔键的坏值要报错");
eq(fieldError(spec("ingestGate", "bool", { fallback: "code-default" }), ""), "", "空 = 跟随兜底,合法");
}
// ⑤ 边界值合法(下界/上界本身可用,不是「越界」)。
{
eq(fieldError(spec("intervalSec", "int", { unit: "sec", min: 1, fallback: "x" }), "1"), "", "下界本身合法");
eq(fieldError(spec("quietStartHour", "int", { unit: "hour", min: 0, max: 23, fallback: "x" }), "0"), "", "钟点 0 合法");
eq(fieldError(spec("quietEndHour", "int", { unit: "hour", min: 0, max: 23, fallback: "x" }), "23"), "", "钟点 23 合法");
}
// ⑥ 文案:兜底来源与单位要写成人话(面板的「留空 ⇒ 跟随谁」全靠它)。
{
eq(fallbackLabel(spec("intervalSec", "int", { fallback: "env:MEMORY_TIDY_INTERVAL_SEC" })),
"跟随 .env 的 MEMORY_TIDY_INTERVAL_SEC", "env 兜底文案");
eq(fallbackLabel(spec("quietIntervalSec", "int", { fallback: "panel:intervalSec" })),
"跟随面板里的 intervalSec", "面板内兜底文案");
eq(fallbackLabel(spec("tempTtlSec", "int", { fallback: "code-default" })), "跟随代码默认", "代码默认文案");
eq(secondsHint(10800), "≈ 3 小时", "秒 → 小时提示");
eq(secondsHint(1800), "≈ 30 分钟", "秒 → 分钟提示");
eq(secondsHint(0), "", "非正数不提示");
eq(patchSummary({ intervalSec: 3600, ingestGate: false }), "同步入库闸(写入时当场审查)、平时整理间隔", "回执文案");
eq(patchSummary({}), "没有需要改的键", "空补丁回执");
}
// ⑦ 字符串数字不做隐式转换(" 12 " 这种带空白的要能通过,但 "12 3" 不行)。
{
const base = view({ intervalSec: 3600 });
const padded = buildTuningPatch(base, { ...draftFromView(base), intervalSec: " 7200 " });
deepEq(padded.patch, { intervalSec: 7200 }, "两侧空白应当被容忍");
const bad = buildTuningPatch(base, { ...draftFromView(base), intervalSec: "12 3" });
ok(bad.errors.intervalSec !== undefined, "内部空格不是合法整数");
}
console.log(`memoryTidyTuning.test.ts: ${checks} checks passed`);
@@ -0,0 +1,152 @@
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("、");
}