Files
Cyrene_v2/frontend/web/src/admin/modules/Memories.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

205 lines
8.5 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 { 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>
);
}