Files
Cyrene_v2/frontend/web/src/admin/modules/MemoryTidyDryRunCard.tsx
T
AskaEth eddf921229 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 通过。
2026-10-13 21:59:18 +08:00

294 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from "react";
import { ApiError, adminMemoryTidyDryRun } from "../../lib/api";
import type { AdminTidyDryRunCandidate, AdminTidyDryRunResult } from "../../lib/types";
import { Button, Surface, Tag } from "../../components/primitives";
import { Notice } from "../components";
import { useAuditStore } from "../audit";
/**
* 记忆整理「试算」卡(契约 v0.75 §4.12;切片 MT1)。
*
* 它回答的是「**现在就想看到整理会建议合并哪些**」,而不必等到低峰时段那一轮:
* 点击才跑(**不轮询** —— 试算是一次真实规则计算,不是看板指标),跑完把候选摊在
* 这里。后端保证**零副作用**(候选直接回在响应里,一行都不写),因此卡片的文案把
* 这件事明确写出来(`仅试算,不会改动任何记忆`)。
*
* 三个态的呈现**刻意分开**,因为它们的产品含义不同:
* - 有候选:逐条展示「哪些条 → 建议怎么处理 + 理由 + 分数区间 + diff 预览」;
* - 空:**算完了但没有建议**(要说明这不是「没有重复」,而是被召回门/包含判定/
* 规则否决挡下了);
* - 错误:**显示错误,绝不显示 0** —— 把失败画成「0 条候选」等于把「没算出来」
* 冒充成「算过了,没有」,正是本项目面板明令禁止的那一类。
*
* 展示与取数分层:`MemoryTidyDryRunBody` 只吃状态(可用真实渲染测试直接喂
* fixture),卡组件负责取数 + 局部状态。
*/
/** 诚实文案:整张卡的核心承诺(测试钉住它必须出现)。 */
export const TIDY_DRY_RUN_DISCLAIMER = "仅试算,不会改动任何记忆";
/** 错误态的说明:区分「没算出来」与「算过了,没有」。 */
export const TIDY_DRY_RUN_ERROR_HINT = "这是「没算出来」,不是「算过了、没有建议」";
/** 尝试按钮文案。 */
const RUN_LABEL = "试算整理建议";
/** 面板状态机(idle / running / done / error)。 */
export type TidyDryRunState =
| { kind: "idle" }
| { kind: "running" }
| { kind: "done"; result: AdminTidyDryRunResult }
| { 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: "并入",
link: "只建链接",
archive: "归档建议",
reclassify: "改分类建议",
};
const actionTone: Record<string, "ok" | "warn" | "accent" | "neutral"> = {
merge: "warn",
link: "accent",
archive: "neutral",
reclassify: "neutral",
};
/** ActionTag 渲染候选动作标签;未知动作原样显示(不吞信息)。 */
function ActionTag({ action }: { action: string }): React.ReactNode {
return <Tag tone={actionTone[action] ?? "neutral"}>{actionLabel[action] ?? action}</Tag>;
}
/** VoteReasonLine 渲染规则否决分布(它是「没有建议」时最有用的解释)。 */
function VetoReasonLine({ reasons }: { reasons: Record<string, number> }): React.ReactNode {
const entries = Object.entries(reasons).sort((a, b) => (a[0] < b[0] ? -1 : 1));
if (entries.length === 0) return null;
return (
<span className="text-[11px] text-[var(--mu-text-mid)]">
否决分布(规则把哪些相似对挡在候选之外):
{entries.map(([reason, count]) => (
<span key={reason} className="ml-2 font-mono">
{reason}={count}
</span>
))}
</span>
);
}
/** scoreLabel 渲染分数区间(当前实现一对一个分数,因此多数时候两端相等)。 */
function scoreLabel(candidate: AdminTidyDryRunCandidate): string {
if (candidate.scoreMin === candidate.scoreMax) return candidate.scoreMin.toFixed(3);
return `${candidate.scoreMin.toFixed(3)}–${candidate.scoreMax.toFixed(3)}`;
}
/** CandidateRow 渲染一条候选:动作 + 理由 + 分数 + id 去向 + diff 预览。 */
function CandidateRow({ candidate }: { candidate: AdminTidyDryRunCandidate }): React.ReactNode {
const target = candidate.targetId ? ` → 保留 ${candidate.targetId}` : "";
return (
<div className="space-y-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2">
<div className="flex flex-wrap items-center gap-2 text-xs">
<ActionTag action={candidate.action} />
<span className="font-mono text-[var(--mu-text-lo)]">{scoreLabel(candidate)}</span>
<span className="font-mono text-[11px] text-[var(--mu-text-lo)]">{candidate.reason}</span>
<Tag tone="neutral">{candidate.scope}</Tag>
</div>
<div className="font-mono text-[11px] text-[var(--mu-text-mid)]">
{candidate.sourceIds.join(", ")}
{target}
</div>
{candidate.proposedContent ? (
<div className="text-xs text-[var(--mu-text-mid)]">
<span className="text-[var(--mu-text-lo)]">被并入:</span>
{candidate.proposedContent}
</div>
) : null}
{candidate.mergedContent ? (
<div className="text-xs text-[var(--mu-text-mid)]">
<span className="text-[var(--mu-text-lo)]">存活条目:</span>
{candidate.mergedContent}
</div>
) : null}
</div>
);
}
/** 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 ?? {};
return (
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-3">
<Tag tone="ok">算完了,一行都没写</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}
</span>
</div>
{result.truncated && result.truncated.length > 0 ? (
<Notice tone="warn">
本轮被配额截断:{result.truncated.join("、")} —— 这一轮看到的比库里少,调大
<span className="font-mono"> MEMORY_TIDY_SCAN_LIMIT</span> /{" "}
<span className="font-mono">MEMORY_TIDY_CANDIDATE_LIMIT</span> 后重试。
</Notice>
) : null}
<VetoReasonLine reasons={vetoReasons} />
{result.candidates.length === 0 ? (
<div className="space-y-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2">
<span className="text-xs text-[var(--mu-text-hi)]">这一轮没有产生任何候选。</span>
<p className="text-[11px] text-[var(--mu-text-lo)]">
这意味着「跑完了但没建议」,不等于「库里没有重复」:候选必须先过召回门(相似度只用来召回),
再通过包含判定(A 的事实集合 ⊆ B 才并入),最后还要逐条过规则否决(跨作用域 / 跨角色 / 跨项目 /
群聊来源 / 时效污染 / 数值不一致 / 否定冲突 / 低置信)。上面的否决分布就是「哪些相似对被挡下」。
</p>
</div>
) : (
<div className="space-y-1.5">
<span className="text-xs text-[var(--mu-text-mid)]">建议 {result.candidates.length} 条(均未应用):</span>
{result.candidates.map((candidate, index) => (
<CandidateRow
key={`${candidate.action}-${candidate.sourceIds.join(",")}-${index}`}
candidate={candidate}
/>
))}
</div>
)}
</div>
);
}
/** StateBody 渲染「未跑 / 在跑 / 失败 / 完成」四态中的非按钮部分。 */
function StateBody({ state }: { state: TidyDryRunState }): React.ReactNode {
switch (state.kind) {
case "idle":
return (
<span className="text-xs text-[var(--mu-text-lo)]">
点一下就跑一轮「同样的整理规则」,把「它会建议合并 / 归档 / 建链接哪些」摊开给你看。整理器只在低峰
时段(默认 23:00–07:00)真跑,平时库里的候选队列是空的 —— 这是一次按需试算,不是看板,所以不会自动
刷新。
</span>
);
case "running":
return <span className="text-xs text-[var(--mu-text-lo)]">试算中…(只读计算,不会改动任何记忆)</span>;
case "error":
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>
</div>
);
case "done":
return <ResultBody result={state.result} />;
}
}
/** MemoryTidyDryRunBody 是试算卡的纯展示体(测试直接喂状态)。 */
export function MemoryTidyDryRunBody({
state,
onRun,
}: {
state: TidyDryRunState;
onRun: () => void;
}): React.ReactNode {
return (
<Surface className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold">记忆整理试算</h3>
<Tag tone="accent">{TIDY_DRY_RUN_DISCLAIMER}</Tag>
<span className="text-xs text-[var(--mu-text-lo)]">POST /api/v1/admin/memories/tidy/dry-run</span>
<span className="ml-auto">
<Button variant="primary" disabled={state.kind === "running"} onClick={onRun}>
{state.kind === "running" ? "试算中…" : RUN_LABEL}
</Button>
</span>
</div>
<StateBody state={state} />
</Surface>
);
}
/** MemoryTidyDryRunCard 是记忆管理页挂的试算卡(局部状态 + 点击才跑)。 */
export function MemoryTidyDryRunCard(): React.ReactNode {
const [state, setState] = useState<TidyDryRunState>({ kind: "idle" });
const log = useAuditStore((s) => s.log);
const run = (): void => {
setState({ kind: "running" });
adminMemoryTidyDryRun()
.then((result) => {
log("memory.tidy.dryRun", "ok");
setState({ kind: "done", result });
})
.catch((e: unknown) => {
log("memory.tidy.dryRun", "error");
setState({
kind: "error",
message: e instanceof Error ? e.message : "试算失败",
code: e instanceof ApiError ? e.code : undefined,
});
});
};
return <MemoryTidyDryRunBody state={state} onRun={run} />;
}