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

@@ -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,
});
});
};