Files
Cyrene_v2/frontend/web/src/admin/modules/Memories.tsx
T
Aska 9c2a37c4ac
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat: 管理面板补齐(RAG导入/记忆添加/会话接history/诚实表述) + design/16 §4.6 IM多会话隔离+第三方身份+黑白名单
agent-98 面板批次:
- 会话管理面板改接 history.ListSessions(主web+次级) + 新建次级会话按钮
- 记忆面板手动添加入口 POST /admin/memories(owner)
- 知识库真正实现 IndexDocument(分块+落projectindex) + POST /admin/rag 导入端点
- 三面板空态诚实表述(去除'后端M10+提供')
- RAG嵌入降级到keyword/line(无直属embedder,不伪造语义,符合红线)

agent-99 design/16 §4.6:
- model.Message.SenderName + 迁移0006 sender_name 列
- 第三方身份边界: session.ResolveActor判定(非LLM猜) + [第三方·昵称]前缀(不改源content)
- 群聊/陌生人落独立sess-xxx(persistIsolated),不写主会话,不广播
- 黑/白名单(allowGroups/denyGroups/allowUsers/denyUsers)最前拦截,不进LLM
- 多账号 CHANNELS_JSON 配置 + 每平台多实例
- 管理只读 GET /admin/channels/{platform}/messages(该平台非主人消息流)

实机验证(JWT): 会话管理返回主会话web / 记忆添加成功 / RAG导入documents非空+Cyrene命中; 后端build/vet/test全绿 + 前端build绿
2026-09-07 05:13:36 +08:00

193 lines
8.1 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";
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>
<Notice>
编辑 / 删除 / 衰减 / 溯源为后端 M10+ 提供,当前只读 + 手动添加 + 检索测试。群聊 / 陌生人会话查记忆时只读不写、不入主会话上下文、不泄露主人敏感信息。
</Notice>
</div>
);
}