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绿
193 lines
8.1 KiB
TypeScript
193 lines
8.1 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";
|
||
|
||
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>
|
||
);
|
||
}
|