主人 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 通过。
274 lines
11 KiB
TypeScript
274 lines
11 KiB
TypeScript
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>
|
||
);
|
||
}
|