主人 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 通过。
205 lines
8.5 KiB
TypeScript
205 lines
8.5 KiB
TypeScript
import { useState } from "react";
|
||
import { useAsyncData } from "../../lib/useAsyncData";
|
||
import { adminAddMemory, adminListMemories, adminMemorySearch } from "../../lib/api";
|
||
import type { MemoryRecord, MemorySearchHit } from "../../lib/types";
|
||
import { useAutoReload } from "../hooks";
|
||
import { useAuditStore } from "../audit";
|
||
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
|
||
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",
|
||
persona: "ok",
|
||
project: "warn",
|
||
};
|
||
|
||
const SCOPES = ["user", "persona", "project"] as const;
|
||
|
||
function MemoryTable({ rows, loading, error }: { rows: MemoryRecord[] | null; loading: boolean; error: string | null }): React.ReactNode {
|
||
const columns: AdminColumn<MemoryRecord>[] = [
|
||
{ key: "scope", title: "作用域", render: (m) => <Tag tone={scopeTone[m.scope] ?? "neutral"}>{m.scope}</Tag> },
|
||
{ key: "text", title: "内容", render: (m) => <span className="block max-w-md truncate">{m.text}</span> },
|
||
{ key: "id", title: "ID", render: (m) => <span className="font-mono text-xs text-[var(--mu-text-lo)]">{m.id}</span> },
|
||
];
|
||
return (
|
||
<AdminTable columns={columns} rows={rows} loading={loading} error={error} emptyTitle="当前无记忆:说出『记住…』会写入,或点『添加记忆』手动写入" rowKey={(m) => m.id} />
|
||
);
|
||
}
|
||
|
||
export function MemoriesModule(): React.ReactNode {
|
||
const { data, loading, error, reload } = useAsyncData(adminListMemories, []);
|
||
useAutoReload(reload, 15000);
|
||
const [query, setQuery] = useState("");
|
||
const [searchQuery, setSearchQuery] = useState("");
|
||
const [personaId, setPersonaId] = useState("cyrene");
|
||
const [hits, setHits] = useState<MemorySearchHit[] | null>(null);
|
||
const [busy, setBusy] = useState(false);
|
||
const [fail, setFail] = useState<string | null>(null);
|
||
// Manual "添加记忆" form fields.
|
||
const [addPersona, setAddPersona] = useState("cyrene");
|
||
const [addScope, setAddScope] = useState<(typeof SCOPES)[number]>("persona");
|
||
const [addContent, setAddContent] = useState("");
|
||
const log = useAuditStore((s) => s.log);
|
||
|
||
const filtered = (data ?? []).filter((m) => {
|
||
if (!query.trim()) return true;
|
||
const q = query.toLowerCase();
|
||
return m.text.toLowerCase().includes(q) || m.scope.toLowerCase().includes(q) || m.id.toLowerCase().includes(q);
|
||
});
|
||
|
||
const search = () => {
|
||
if (!searchQuery.trim()) {
|
||
setFail("测试查询不能为空");
|
||
return;
|
||
}
|
||
setBusy(true);
|
||
setFail(null);
|
||
adminMemorySearch({ query: searchQuery.trim(), personaId: personaId.trim() || "cyrene" })
|
||
.then((r) => {
|
||
log("memory.vectorTest", "ok");
|
||
setHits(r.hits);
|
||
})
|
||
.catch((e: unknown) => {
|
||
log("memory.vectorTest", "error");
|
||
setFail(e instanceof Error ? e.message : "向量测试失败");
|
||
})
|
||
.finally(() => setBusy(false));
|
||
};
|
||
|
||
const addMemory = () => {
|
||
if (!addContent.trim()) {
|
||
setFail("记忆内容不能为空");
|
||
return;
|
||
}
|
||
setBusy(true);
|
||
setFail(null);
|
||
adminAddMemory({
|
||
personaId: addPersona.trim() || "cyrene",
|
||
scope: addScope,
|
||
content: addContent.trim(),
|
||
})
|
||
.then(() => {
|
||
log("memory.add", "ok");
|
||
setAddContent("");
|
||
// Reflect the new record immediately (also refreshes the table).
|
||
reload();
|
||
})
|
||
.catch((e: unknown) => {
|
||
log("memory.add", "error");
|
||
setFail(e instanceof Error ? e.message : "添加记忆失败");
|
||
})
|
||
.finally(() => setBusy(false));
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-5">
|
||
<PageHeader
|
||
title="记忆管理"
|
||
subtitle="GET /api/v1/admin/memories + POST /memories + POST /memories/search · 用户习惯(全局)/角色日常(只读)/项目(读写) 作用域隔离"
|
||
actions={
|
||
<Button variant="soft" disabled={busy} onClick={search}>
|
||
向量测试
|
||
</Button>
|
||
}
|
||
/>
|
||
|
||
{fail ? <Notice tone="err">{fail}</Notice> : null}
|
||
|
||
<Surface className="space-y-3">
|
||
<h3 className="text-sm font-semibold">添加记忆(写入后端持久化)</h3>
|
||
<div className="flex flex-wrap items-start gap-2">
|
||
<input
|
||
value={addPersona}
|
||
onChange={(e) => setAddPersona(e.target.value)}
|
||
placeholder="personaId"
|
||
className="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)]"
|
||
/>
|
||
<select
|
||
value={addScope}
|
||
onChange={(e) => setAddScope(e.target.value as (typeof SCOPES)[number])}
|
||
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)]"
|
||
>
|
||
{SCOPES.map((s) => (
|
||
<option key={s} value={s}>
|
||
{s}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<input
|
||
value={addContent}
|
||
onChange={(e) => setAddContent(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" && addContent.trim()) void addMemory();
|
||
}}
|
||
placeholder="记忆内容(如:她喜欢喝冰美式)…"
|
||
className="min-w-0 flex-1 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)]"
|
||
/>
|
||
<Button variant="primary" disabled={busy} onClick={addMemory}>
|
||
添加记忆
|
||
</Button>
|
||
</div>
|
||
<span className="text-[11px] text-[var(--mu-text-lo)]">作用域为 persona 时与「记住…」同路径;owner 私聊写入安全(群聊/陌生人隔离在 loop 层)。</span>
|
||
</Surface>
|
||
|
||
<Surface className="space-y-3">
|
||
<h3 className="text-sm font-semibold">语义/关键词检索测试(真实 retrieval)</h3>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<input
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
placeholder="检索查询…"
|
||
className="min-w-0 flex-1 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)]"
|
||
/>
|
||
<input
|
||
value={personaId}
|
||
onChange={(e) => setPersonaId(e.target.value)}
|
||
placeholder="personaId"
|
||
className="w-32 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)]"
|
||
/>
|
||
</div>
|
||
{hits ? (
|
||
<div className="space-y-1">
|
||
{hits.length === 0 ? (
|
||
<span className="text-xs text-[var(--mu-text-lo)]">无命中</span>
|
||
) : (
|
||
hits.map((h) => (
|
||
<div key={h.id} className="rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-xs">
|
||
<Tag tone="neutral">{h.scope}</Tag> <span className="ml-1 font-mono text-[var(--mu-text-lo)]">{h.score.toFixed(3)}</span>
|
||
<div className="mt-1 text-[var(--mu-text-mid)]">{h.text}</div>
|
||
</div>
|
||
))
|
||
)}
|
||
<span className="text-[11px] text-[var(--mu-text-lo)]">当前为 keyword×decay 路径;语义/向量路径需主会话注入 vector index(wire.go 装配)。</span>
|
||
</div>
|
||
) : null}
|
||
</Surface>
|
||
|
||
<Surface className="space-y-3">
|
||
<input
|
||
value={query}
|
||
onChange={(e) => setQuery(e.target.value)}
|
||
placeholder="按内容 / 作用域 / ID 过滤…"
|
||
className="w-full 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)]"
|
||
/>
|
||
<MemoryTable rows={loading ? null : filtered} loading={loading} error={error} />
|
||
</Surface>
|
||
|
||
<MemoryTidyDryRunCard />
|
||
|
||
<MemoryTidyApplyCard />
|
||
|
||
<MemoryTidyTuningCard />
|
||
|
||
<MemoryTidyFactsCard />
|
||
|
||
<Notice>
|
||
编辑 / 删除 / 衰减 / 溯源为后端 M10+ 提供,当前只读 + 手动添加 + 检索测试。群聊 / 陌生人会话查记忆时只读不写、不入主会话上下文、不泄露主人敏感信息。
|
||
</Notice>
|
||
</div>
|
||
);
|
||
}
|