M3b:世谱激活接入组装管线(关键词命中/常驻/预算/符文展开)与前端记忆治理页(候选箱/重忆确认/正影/删除/实录溯源)

This commit is contained in:
chenxin committed 2026-08-27 09:22:20 +08:00
1 parent eb0027757d
commit e914aec9c4
8 files changed
+569 -1

No files matched your search

+58
View File
@@ -0,0 +1,58 @@
import { PageContainer } from "@/components/layout/PageContainer";
import { ErrorState } from "@/components/shared/StateViews";
import { MemoryGovernance } from "@/components/memories/MemoryGovernance";
import { serverApi } from "@/lib/api/server-client";
import { describeError } from "@/lib/utils/error";
import type { Character } from "@/types/api";
import type { FactualRecordView, XirangMemory } from "@/lib/api/memories";
export const dynamic = "force-dynamic";
export default async function MemoriesPage() {
let error: string | null = null;
let characterId: string | undefined;
try {
const active = await serverApi
.get<Character>("/characters/active")
.catch(() => null);
characterId = active?.id;
} catch (e) {
error = describeError(e);
}
if (error || !characterId) {
return (
<PageContainer
title="记忆"
description="三级记忆治理:确认、正影与删除。"
>
<ErrorState
message={error ?? "尚未激活人物——启镜后这里会生长出记忆。"}
/>
</PageContainer>
);
}
const [memories, records] = await Promise.all([
serverApi
.get<XirangMemory[]>(`/characters/${characterId}/memories`)
.catch(() => []),
serverApi
.get<FactualRecordView[]>(`/characters/${characterId}/factual-records`)
.catch(() => []),
]);
return (
<PageContainer
title="记忆"
description="候选箱审阅 · 重忆确认 · 正影与删除 · 实录溯源。删除权高于召回便利。"
>
<MemoryGovernance
characterId={characterId}
initialMemories={memories}
initialRecords={records}
/>
</PageContainer>
);
}
+5 -1
View File
@@ -7,6 +7,7 @@ import { LogoButton } from "@/components/easter-egg/LogoButton";
import {
AdminIcon,
ArchiveIcon,
MemoryIcon,
ChatIcon,
CharacterIcon,
CosmosIcon,
@@ -84,7 +85,10 @@ const NAV_GROUPS: NavGroup[] = [
},
{
title: "藏识",
items: [{ href: "/archive", label: "归档", icon: <ArchiveIcon /> }],
items: [
{ href: "/memory", label: "记忆", icon: <MemoryIcon /> },
{ href: "/archive", label: "归档", icon: <ArchiveIcon /> },
],
},
{
title: "百工",
@@ -0,0 +1,293 @@
"use client";
/**
* 记忆治理:候选箱审阅、重忆确认、正影、删除、来源展开、实录查看。
* 治理红线:删除权与来源可见性高于召回便利;墓碑与高敏不展示原文。
*/
import { useCallback, useState } from "react";
import { Button } from "@/components/ui/Button";
import { Modal } from "@/components/ui/Modal";
import * as memoriesApi from "@/lib/api/memories";
import { cn } from "@/lib/utils/cn";
import type { FactualRecordView, XirangMemory } from "@/lib/api/memories";
interface MemoryGovernanceProps {
characterId: string;
initialMemories: XirangMemory[];
initialRecords: FactualRecordView[];
}
const TIER_LABEL: Record<string, string> = {
trace: "微痕",
explicit: "明忆",
sensitive: "重忆",
};
const TYPE_LABEL: Record<string, string> = {
persona: "画像",
episodic: "事件",
instruction: "指令",
relationship_view: "心痕",
fact_candidate: "手记",
};
const STATUS_LABEL: Record<string, string> = {
candidate: "候选箱",
pending: "待确认",
active: "已生效",
superseded: "已正影",
tombstoned: "已删除",
};
type Tab = "pending" | "candidate" | "active" | "archive" | "records";
export function MemoryGovernance({
characterId,
initialMemories,
initialRecords,
}: MemoryGovernanceProps) {
const [tab, setTab] = useState<Tab>("pending");
const [items, setItems] = useState(initialMemories);
const [records] = useState(initialRecords);
const [busy, setBusy] = useState(false);
const [correctTarget, setCorrectTarget] = useState<XirangMemory | null>(null);
const [correctValue, setCorrectValue] = useState("");
const reload = useCallback(async () => {
setItems(await memoriesApi.listMemories(characterId));
}, [characterId]);
const run = async (action: () => Promise<unknown>) => {
setBusy(true);
try {
await action();
await reload();
} finally {
setBusy(false);
}
};
const byTab = (status: XirangMemory["status"]) =>
items.filter((m) => m.status === status);
const visible: XirangMemory[] =
tab === "pending"
? byTab("pending")
: tab === "candidate"
? byTab("candidate")
: tab === "active"
? byTab("active")
: tab === "archive"
? items.filter(
(m) => m.status === "superseded" || m.status === "tombstoned",
)
: [];
const tabs: { key: Tab; label: string; count: number }[] = [
{ key: "pending", label: "待确认", count: byTab("pending").length },
{ key: "candidate", label: "候选箱", count: byTab("candidate").length },
{ key: "active", label: "已生效", count: byTab("active").length },
{
key: "archive",
label: "归档",
count: items.filter(
(m) => m.status === "superseded" || m.status === "tombstoned",
).length,
},
{ key: "records", label: "实录", count: records.length },
];
return (
<div className="space-y-4">
<div
role="tablist"
aria-label="记忆分区"
className="flex flex-wrap gap-2"
>
{tabs.map((t) => (
<button
key={t.key}
type="button"
role="tab"
aria-selected={tab === t.key}
onClick={() => setTab(t.key)}
className={cn(
"rounded-full border px-3 py-1 text-sm transition-colors",
tab === t.key
? "border-patina bg-patina/15 text-patina"
: "border-rule text-muted hover:bg-overlay-hover",
)}
>
{t.label}
<span className="ml-1.5 text-xs opacity-70">{t.count}</span>
</button>
))}
</div>
{tab === "records" ? (
<ul className="space-y-2">
{records.length === 0 && (
<li className="rounded-xl border border-dashed border-rule p-5 text-center text-sm text-muted">
尚无实录——镜树归真后自动生成。
</li>
)}
{records.map((r) => (
<li
key={r.id}
className="rounded-xl border border-rule bg-surface p-4"
>
<p className="text-sm">{r.summary}</p>
<p className="mt-1 text-xs text-muted">
{new Date(r.occurred_at).toLocaleString("zh-CN")} · 证据{" "}
{r.evidence.path_nodes?.length ?? 0} 节点
</p>
</li>
))}
</ul>
) : (
<ul className="space-y-2">
{visible.length === 0 && (
<li className="rounded-xl border border-dashed border-rule p-5 text-center text-sm text-muted">
此分区暂无记忆。
</li>
)}
{visible.map((m) => (
<li
key={m.id}
className="rounded-xl border border-rule bg-surface p-4"
>
<div className="flex flex-wrap items-center gap-2 text-xs">
<span
className={cn(
"rounded px-1.5 py-0.5",
m.tier === "sensitive" &&
"bg-amber-500/15 text-amber-600 dark:text-amber-400",
m.tier !== "sensitive" && "bg-muted text-muted",
)}
>
{TIER_LABEL[m.tier] ?? m.tier}
</span>
<span className="rounded bg-muted px-1.5 py-0.5 text-muted">
{TYPE_LABEL[m.type] ?? m.type}
</span>
<span className="rounded bg-muted px-1.5 py-0.5 text-muted">
{STATUS_LABEL[m.status] ?? m.status}
</span>
{m.recall_count > 0 && (
<span className="text-muted">召回 {m.recall_count} 次</span>
)}
<span className="ml-auto text-muted">
{new Date(m.created_at).toLocaleString("zh-CN")}
</span>
</div>
<p className="mt-2 text-sm leading-relaxed">
{m.content || "(原文已随删除移除)"}
</p>
{m.source_refs.length > 0 && (
<p className="mt-1 text-xs text-muted">
来源:{m.source_refs.join(" · ")}
</p>
)}
{(m.status === "pending" ||
m.status === "candidate" ||
m.status === "active") && (
<div className="mt-3 flex flex-wrap gap-2">
{(m.status === "pending" || m.status === "candidate") && (
<Button
variant="primary"
size="sm"
disabled={busy}
onClick={() =>
void run(() => memoriesApi.confirmMemory(m.id))
}
>
确认生效
</Button>
)}
<Button
variant="ghost"
size="sm"
disabled={busy}
onClick={() => {
setCorrectTarget(m);
setCorrectValue(m.content);
}}
>
正影
</Button>
<Button
variant="ghost"
size="sm"
disabled={busy}
onClick={() => void run(() => memoriesApi.denyMemory(m.id))}
>
拒绝
</Button>
<Button
variant="danger"
size="sm"
disabled={busy}
onClick={() => {
if (
window.confirm("删除后全链路停用且不可恢复原文,确认?")
) {
void run(() => memoriesApi.deleteMemory(m.id));
}
}}
>
删除
</Button>
</div>
)}
</li>
))}
</ul>
)}
<Modal
open={Boolean(correctTarget)}
onClose={() => setCorrectTarget(null)}
title="正影(形成新版本)"
size="md"
>
<div className="space-y-3 text-sm">
<p className="text-muted">
正影不改写历史:新版本成为召回当前版,旧版保留来源与纠错轨迹、停止普通召回。
</p>
<textarea
value={correctValue}
onChange={(e) => setCorrectValue(e.target.value)}
rows={5}
className="w-full resize-none rounded-lg border border-rule bg-transparent p-2 text-sm outline-none focus:border-patina"
/>
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setCorrectTarget(null)}
>
取消
</Button>
<Button
variant="primary"
size="sm"
disabled={busy || !correctValue.trim() || !correctTarget}
onClick={() => {
if (correctTarget) {
void run(async () => {
await memoriesApi.correctMemory(
correctTarget.id,
correctValue.trim(),
);
setCorrectTarget(null);
});
}
}}
>
创建新版本
</Button>
</div>
</div>
</Modal>
</div>
);
}
+1
View File
@@ -20,6 +20,7 @@ export * as settingsApi from "./settings";
export * as importsApi from "./imports";
export * as extractionApi from "./extraction";
export * as worldApi from "./world";
export * as memoriesApi from "./memories";
export * as lorebooksApi from "./lorebooks";
export * as skillsApi from "./skills";
export * as mcpApi from "./mcp";
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("./client", () => ({
apiGet: vi.fn(),
apiPost: vi.fn(),
apiDelete: vi.fn(),
}));
import { apiGet, apiPost } from "./client";
import { confirmMemory, correctMemory, listMemories } from "./memories";
describe("memories api", () => {
it("listMemories GET /characters/:id/memories 带筛选", async () => {
vi.mocked(apiGet).mockResolvedValue([]);
await listMemories("character_1", { status: "pending" });
expect(apiGet).toHaveBeenCalledWith("/characters/character_1/memories", {
status: "pending",
});
});
it("confirmMemory POST /memories/:id/confirm", async () => {
vi.mocked(apiPost).mockResolvedValue({ id: "memory_1", status: "active" });
await confirmMemory("memory_1");
expect(apiPost).toHaveBeenCalledWith("/memories/memory_1/confirm");
});
it("correctMemory POST /memories/:id/correct", async () => {
vi.mocked(apiPost).mockResolvedValue({ id: "memory_2" });
await correctMemory("memory_1", "新版本");
expect(apiPost).toHaveBeenCalledWith("/memories/memory_1/correct", {
new_content: "新版本",
});
});
});
+64
View File
@@ -0,0 +1,64 @@
/** 息壤记忆 API Client(api-contracts §3.5)。 */
import { apiDelete, apiGet, apiPost } from "./client";
export interface XirangMemory {
id: string;
character_id: string;
tier: "trace" | "explicit" | "sensitive";
type: string;
status: "candidate" | "pending" | "active" | "superseded" | "tombstoned";
source: string;
content: string;
source_refs: string[];
sensitivity: string;
priority: number;
confidence: number;
vitality: number;
realm: string;
prev_version_id: string | null;
recall_count: number;
last_recalled_at: string | null;
created_at: string;
}
export interface FactualRecordView {
id: string;
canonization_id: string;
summary: string;
evidence: { path_nodes?: string[] };
occurred_at: string;
}
export function listMemories(
characterId: string,
filters?: { tier?: string; status?: string; type?: string },
) {
return apiGet<XirangMemory[]>(`/characters/${characterId}/memories`, filters);
}
export function confirmMemory(memoryId: string) {
return apiPost<XirangMemory>(`/memories/${memoryId}/confirm`);
}
export function denyMemory(memoryId: string) {
return apiPost<{ memory_id: string; exited_retrieval: boolean }>(
`/memories/${memoryId}/deny`,
);
}
export function correctMemory(memoryId: string, newContent: string) {
return apiPost<XirangMemory>(`/memories/${memoryId}/correct`, {
new_content: newContent,
});
}
export function deleteMemory(memoryId: string) {
return apiDelete<void>(`/memories/${memoryId}`);
}
export function listFactualRecords(characterId: string) {
return apiGet<FactualRecordView[]>(
`/characters/${characterId}/factual-records`,
);
}