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:
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("、");
|
||||
}
|
||||
@@ -155,3 +155,12 @@ export {
|
||||
adminTidyCandidatesApplyBatch,
|
||||
adminTidyRun,
|
||||
} from "./api/adminMemoryTidyApply";
|
||||
export {
|
||||
adminMemoryTidyTuning,
|
||||
adminMemoryTidyTuningSave,
|
||||
} from "./api/adminMemories";
|
||||
export type {
|
||||
AdminMemoryTidyTuningPatch,
|
||||
AdminMemoryTidyTuningSpec,
|
||||
AdminMemoryTidyTuningView,
|
||||
} from "./api/adminMemories";
|
||||
@@ -0,0 +1,75 @@
|
||||
import { request } from "./core";
|
||||
|
||||
/* ----------------------------------------------------------------------------
|
||||
* 记忆整理**面板调优**(切片 MEMPANEL / M4)。真源:
|
||||
* internal/services/gateway/admin_memory_tuning.go(形状与错误码)
|
||||
* internal/services/gateway/admin_memory_tuning_file.go(取值规则 specs)
|
||||
*
|
||||
* GET /admin/memories/tidy/tuning 读:盘上 7 键值 + 取值规则
|
||||
* PUT /admin/memories/tidy/tuning 写:三态补丁(键缺位 = 不改动 / 合法值 = 采用 /
|
||||
* 非法值 = 400 且盘上不动;`null` = 清掉该键)
|
||||
*
|
||||
* 两条都是 writeAuth(owner|admin)——与同族的 `/admin/memories` 同档:它是私有记忆面
|
||||
* 的运行期旋钮,不是给 viewer 的监控投影。
|
||||
*
|
||||
* ⚠️ 失败走 `error.code`,**绝不**回一份「已保存」的假成功:
|
||||
* - `invalid_payload` 400:值非法 / 未知键(**盘上一个字节都没动**)
|
||||
* - `tuning_corrupt` 409:盘上那份配置文件读不走 ⇒ 拒绝覆盖(要人工修/删)
|
||||
* - `tuning_unavailable` 500:数据目录未接线
|
||||
* - `tuning_write_failed` 500:落盘失败(盘上内容未改动)
|
||||
*
|
||||
* 响应里的 `hot` 是**如实**的:true = 保存后当场生效;false = 已落盘,但运行期没接到
|
||||
* 可热生效的实现(`hotError` 说明原因),此时界面必须写「重启后端后生效」。
|
||||
* -------------------------------------------------------------------------- */
|
||||
|
||||
/** 调的落点(面板配置文件的**相对**路径,形如 `config/memory-tidy.json`)。 */
|
||||
const TUNING_PATH = "/admin/memories/tidy/tuning";
|
||||
|
||||
/** 一个可写键的取值规则(后端下发,面板不另抄一份枚举/上下界)。 */
|
||||
export interface AdminMemoryTidyTuningSpec {
|
||||
/** 键名,如 `intervalSec` / `ingestGate`。 */
|
||||
key: string;
|
||||
/** `int` | `bool`(面板据此选控件类型)。 */
|
||||
kind: string;
|
||||
/** `sec` | `hour`(仅 int 键;面板据此写单位)。 */
|
||||
unit?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
/** 缺位时生效值从哪来:`env:MEMORY_TIDY_*` / `panel:<key>` / `code-default`。 */
|
||||
fallback: string;
|
||||
}
|
||||
|
||||
/** GET / PUT 的 data(PUT 的响应是**重新读盘**的结果,即真值回显)。 */
|
||||
export interface AdminMemoryTidyTuningView {
|
||||
/** 面板配置文件相对 `$DATA_DIR` 的路径。 */
|
||||
path: string;
|
||||
/** 文件是否已存在且有内容(false = 还没播种,后端下次启动会自动创建)。 */
|
||||
present: boolean;
|
||||
/** 本次结果是否**当场生效**。false = 已落盘,要等后端重启(见 hotError)。 */
|
||||
hot: boolean;
|
||||
/** `hot:false` 时的原因。 */
|
||||
hotError?: string;
|
||||
specs: AdminMemoryTidyTuningSpec[];
|
||||
/** **盘上**的值:键缺位 ⇒ 该键不出现(= 跟随兜底),不是 0。 */
|
||||
values: Record<string, number | boolean>;
|
||||
/** 缺位时**下一档(`.env`)**的进程生效值(与 /admin/config-facts 同源)。 */
|
||||
envValues?: Record<string, number>;
|
||||
}
|
||||
|
||||
/** 写补丁:只放**真的要改**的键;`null` = 清掉该键(回到下一档兜底)。 */
|
||||
export type AdminMemoryTidyTuningPatch = Record<string, number | boolean | null>;
|
||||
|
||||
/** GET /api/v1/admin/memories/tidy/tuning —— 读盘上的调优值 + 取值规则。 */
|
||||
export function adminMemoryTidyTuning(): Promise<AdminMemoryTidyTuningView> {
|
||||
return request<AdminMemoryTidyTuningView>(TUNING_PATH);
|
||||
}
|
||||
|
||||
/** PUT /api/v1/admin/memories/tidy/tuning —— 三态补丁写盘(并尽量热生效)。 */
|
||||
export function adminMemoryTidyTuningSave(
|
||||
patch: AdminMemoryTidyTuningPatch
|
||||
): Promise<AdminMemoryTidyTuningView> {
|
||||
return request<AdminMemoryTidyTuningView>(TUNING_PATH, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user