- 新增 V1 记忆系统(memory.json)支持:提取结果强制中文、历史字段修复为增量累积日志 - 用户设置页面:V1 模式下新增 agent 选择器,可查看各 agent 的私有记忆 - 管理员记忆页面:区分 V1/V2 渲染,V1 支持完整 CRUD(用户画像/历史摘要/事实记录) - 后端新增 V1 记忆 CRUD API(sections/facts),前端同步添加对应 hooks 和 API 调用 - 新建专家页面:标记 bootstrap 线程类型,跳过记忆保存,侧边栏过滤该类线程 - 输入框工具栏新增「记忆」开关:关闭时 V1/V2 均不注入,开启时按 config.yaml 版本正常注入 - 修复 agent 目录不存在时的 FileNotFoundError,回退至默认配置 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
717 lines
27 KiB
TypeScript
717 lines
27 KiB
TypeScript
"use client";
|
||
|
||
import { useState } from "react";
|
||
import { Check, Loader2, Pencil, Plus, Trash2, X } from "lucide-react";
|
||
import { toast } from "sonner";
|
||
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import {
|
||
useAdminAddMemoryEntry,
|
||
useAdminAddV1Fact,
|
||
useAdminDeleteMemoryEntry,
|
||
useAdminDeleteV1Fact,
|
||
useAdminReplaceMemoryEntry,
|
||
useAdminUpdateV1Fact,
|
||
useAdminUpdateV1Section,
|
||
useUserMemory,
|
||
} from "@/core/admin/hooks";
|
||
import { useAllUsers } from "@/core/auth/hooks";
|
||
|
||
function getEntries(data: Record<string, unknown>): string[] {
|
||
const raw = (data as { entries?: unknown }).entries;
|
||
if (!Array.isArray(raw)) return [];
|
||
return raw.filter((e): e is string => typeof e === "string" && e.trim().length > 0);
|
||
}
|
||
|
||
// ── V1 只读视图 ────────────────────────────────────────────────────────────
|
||
|
||
const CTX_LABELS: Record<string, string> = {
|
||
workContext: "工作背景",
|
||
personalContext: "个人信息",
|
||
topOfMind: "当前重点",
|
||
};
|
||
const HISTORY_LABELS: Record<string, string> = {
|
||
recentMonths: "近期",
|
||
earlierContext: "早期背景",
|
||
longTermBackground: "长期背景",
|
||
};
|
||
const CATEGORY_COLORS: Record<string, string> = {
|
||
preference: "bg-blue-100 text-blue-700",
|
||
knowledge: "bg-green-100 text-green-700",
|
||
context: "bg-yellow-100 text-yellow-700",
|
||
behavior: "bg-purple-100 text-purple-700",
|
||
goal: "bg-orange-100 text-orange-700",
|
||
correction: "bg-red-100 text-red-700",
|
||
};
|
||
|
||
const V1_CATEGORIES = ["preference", "knowledge", "context", "behavior", "goal", "correction"];
|
||
|
||
function extractSummary(val: unknown): string {
|
||
if (typeof val === "string") return val;
|
||
if (val && typeof val === "object" && "summary" in val)
|
||
return String((val as Record<string, unknown>).summary ?? "");
|
||
return "";
|
||
}
|
||
|
||
// 单个摘要字段的行内编辑
|
||
function V1SectionField({
|
||
label,
|
||
value,
|
||
onSave,
|
||
}: {
|
||
label: string;
|
||
value: string;
|
||
onSave: (v: string) => Promise<void>;
|
||
}) {
|
||
const [editing, setEditing] = useState(false);
|
||
const [draft, setDraft] = useState(value);
|
||
const [saving, setSaving] = useState(false);
|
||
|
||
async function handleSave() {
|
||
if (draft.trim() === value.trim()) { setEditing(false); return; }
|
||
setSaving(true);
|
||
try { await onSave(draft.trim()); setEditing(false); }
|
||
catch (err) { toast.error(err instanceof Error ? err.message : "保存失败"); }
|
||
finally { setSaving(false); }
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-1">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-xs font-medium text-muted-foreground">{label}</span>
|
||
{!editing && (
|
||
<Button size="icon" variant="ghost" className="size-6" onClick={() => { setDraft(value); setEditing(true); }}>
|
||
<Pencil className="size-3" />
|
||
</Button>
|
||
)}
|
||
</div>
|
||
{editing ? (
|
||
<div className="space-y-1.5">
|
||
<Textarea value={draft} onChange={(e) => setDraft(e.target.value)} className="min-h-[72px] text-sm" autoFocus />
|
||
<div className="flex gap-1.5">
|
||
<Button size="sm" onClick={handleSave} disabled={saving}>
|
||
{saving ? <Loader2 className="size-3 animate-spin" /> : <Check className="size-3" />}保存
|
||
</Button>
|
||
<Button size="sm" variant="ghost" onClick={() => setEditing(false)}><X className="size-3" />取消</Button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<p className="text-sm whitespace-pre-wrap text-foreground/80 min-h-[1.5rem]">{value || <span className="text-muted-foreground italic">(空)</span>}</p>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 单条事实的行内编辑
|
||
function V1FactItem({
|
||
fact,
|
||
agent,
|
||
userId,
|
||
}: {
|
||
fact: Record<string, unknown>;
|
||
agent: string | null;
|
||
userId: string;
|
||
}) {
|
||
const [editing, setEditing] = useState(false);
|
||
const [draftContent, setDraftContent] = useState(String(fact.content ?? ""));
|
||
const [draftCategory, setDraftCategory] = useState(String(fact.category ?? "context"));
|
||
const [draftConfidence, setDraftConfidence] = useState(
|
||
typeof fact.confidence === "number" ? Math.round(fact.confidence * 100) : 80,
|
||
);
|
||
const updateFact = useAdminUpdateV1Fact(userId);
|
||
const deleteFact = useAdminDeleteV1Fact(userId);
|
||
|
||
const factId = String(fact.id ?? "");
|
||
const confidence = typeof fact.confidence === "number" ? fact.confidence : 0;
|
||
const category = String(fact.category ?? "context");
|
||
|
||
async function handleSave() {
|
||
try {
|
||
await updateFact.mutateAsync({
|
||
factId,
|
||
updates: { content: draftContent.trim(), category: draftCategory, confidence: draftConfidence / 100 },
|
||
agent,
|
||
});
|
||
setEditing(false);
|
||
} catch (err) { toast.error(err instanceof Error ? err.message : "更新失败"); }
|
||
}
|
||
|
||
async function handleDelete() {
|
||
try { await deleteFact.mutateAsync({ factId, agent }); }
|
||
catch (err) { toast.error(err instanceof Error ? err.message : "删除失败"); }
|
||
}
|
||
|
||
return (
|
||
<div className="rounded border p-2 space-y-1 group">
|
||
{editing ? (
|
||
<div className="space-y-2">
|
||
<Textarea value={draftContent} onChange={(e) => setDraftContent(e.target.value)} className="min-h-[64px] text-sm" autoFocus />
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<Select value={draftCategory} onValueChange={setDraftCategory}>
|
||
<SelectTrigger className="h-7 w-36 text-xs"><SelectValue /></SelectTrigger>
|
||
<SelectContent>{V1_CATEGORIES.map((c) => <SelectItem key={c} value={c} className="text-xs">{c}</SelectItem>)}</SelectContent>
|
||
</Select>
|
||
<div className="flex items-center gap-1">
|
||
<span className="text-xs text-muted-foreground">置信度</span>
|
||
<input
|
||
type="number" min={0} max={100} value={draftConfidence}
|
||
onChange={(e) => setDraftConfidence(Number(e.target.value))}
|
||
className="w-16 h-7 rounded border px-2 text-xs"
|
||
/>
|
||
<span className="text-xs text-muted-foreground">%</span>
|
||
</div>
|
||
</div>
|
||
<div className="flex gap-1.5">
|
||
<Button size="sm" onClick={handleSave} disabled={updateFact.isPending}>
|
||
{updateFact.isPending ? <Loader2 className="size-3 animate-spin" /> : <Check className="size-3" />}保存
|
||
</Button>
|
||
<Button size="sm" variant="ghost" onClick={() => setEditing(false)}><X className="size-3" />取消</Button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div className="flex items-center justify-between gap-2">
|
||
<div className="flex items-center gap-2">
|
||
<span className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${CATEGORY_COLORS[category] ?? "bg-gray-100 text-gray-700"}`}>{category}</span>
|
||
<span className="text-xs text-muted-foreground">{Math.round(confidence * 100)}%</span>
|
||
</div>
|
||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||
<Button size="icon" variant="ghost" className="size-6" onClick={() => { setDraftContent(String(fact.content ?? "")); setDraftCategory(category); setDraftConfidence(Math.round(confidence * 100)); setEditing(true); }}>
|
||
<Pencil className="size-3" />
|
||
</Button>
|
||
<Button size="icon" variant="ghost" className="size-6 hover:text-destructive" onClick={handleDelete} disabled={deleteFact.isPending}>
|
||
{deleteFact.isPending ? <Loader2 className="size-3 animate-spin" /> : <Trash2 className="size-3" />}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<p className="text-sm whitespace-pre-wrap">{String(fact.content ?? "")}</p>
|
||
{fact.sourceError ? <p className="text-xs text-red-600 bg-red-50 rounded px-2 py-1">避免:{String(fact.sourceError)}</p> : null}
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 新增事实表单
|
||
function V1AddFactForm({ agent, userId }: { agent: string | null; userId: string }) {
|
||
const [open, setOpen] = useState(false);
|
||
const [content, setContent] = useState("");
|
||
const [category, setCategory] = useState("context");
|
||
const [confidence, setConfidence] = useState(80);
|
||
const addFact = useAdminAddV1Fact(userId);
|
||
|
||
async function handleAdd() {
|
||
if (!content.trim()) return;
|
||
try {
|
||
await addFact.mutateAsync({ content: content.trim(), category, confidence: confidence / 100, agent });
|
||
setContent(""); setCategory("context"); setConfidence(80); setOpen(false);
|
||
} catch (err) { toast.error(err instanceof Error ? err.message : "新增失败"); }
|
||
}
|
||
|
||
if (!open) {
|
||
return (
|
||
<Button variant="outline" size="sm" className="mt-1 w-full" onClick={() => setOpen(true)}>
|
||
<Plus className="size-3" />新增事实
|
||
</Button>
|
||
);
|
||
}
|
||
return (
|
||
<div className="rounded-md border p-3 space-y-2">
|
||
<Textarea placeholder="事实内容…" value={content} onChange={(e) => setContent(e.target.value)} className="min-h-[64px] text-sm" autoFocus />
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<Select value={category} onValueChange={setCategory}>
|
||
<SelectTrigger className="h-7 w-36 text-xs"><SelectValue /></SelectTrigger>
|
||
<SelectContent>{V1_CATEGORIES.map((c) => <SelectItem key={c} value={c} className="text-xs">{c}</SelectItem>)}</SelectContent>
|
||
</Select>
|
||
<div className="flex items-center gap-1">
|
||
<span className="text-xs text-muted-foreground">置信度</span>
|
||
<input type="number" min={0} max={100} value={confidence} onChange={(e) => setConfidence(Number(e.target.value))} className="w-16 h-7 rounded border px-2 text-xs" />
|
||
<span className="text-xs text-muted-foreground">%</span>
|
||
</div>
|
||
</div>
|
||
<div className="flex gap-1.5">
|
||
<Button size="sm" onClick={handleAdd} disabled={addFact.isPending || !content.trim()}>
|
||
{addFact.isPending ? <Loader2 className="size-3 animate-spin" /> : <Check className="size-3" />}确认
|
||
</Button>
|
||
<Button size="sm" variant="ghost" onClick={() => setOpen(false)}><X className="size-3" />取消</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function V1BucketView({
|
||
data,
|
||
agent,
|
||
userId,
|
||
}: {
|
||
data: Record<string, unknown>;
|
||
agent: string | null;
|
||
userId: string;
|
||
}) {
|
||
const updateSection = useAdminUpdateV1Section(userId);
|
||
|
||
const ctxRaw = (data.user ?? data.userContext ?? {}) as Record<string, unknown>;
|
||
const historyRaw = (data.history ?? {}) as Record<string, unknown>;
|
||
const factsRaw = Array.isArray(data.facts) ? (data.facts as Record<string, unknown>[]) : [];
|
||
|
||
const allCtxFields = ["workContext", "personalContext", "topOfMind"];
|
||
const allHistoryFields = ["recentMonths", "earlierContext", "longTermBackground"];
|
||
|
||
if (!Object.keys(ctxRaw).length && !Object.keys(historyRaw).length && !factsRaw.length) {
|
||
return <p className="text-muted-foreground py-6 text-center text-sm">暂无 V1 记忆</p>;
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
{/* 用户画像字段 */}
|
||
<div className="rounded-md border p-3 space-y-3">
|
||
<h4 className="text-xs font-semibold text-muted-foreground">用户画像</h4>
|
||
{allCtxFields.map((field) => (
|
||
<V1SectionField
|
||
key={field}
|
||
label={CTX_LABELS[field] ?? field}
|
||
value={extractSummary(ctxRaw[field])}
|
||
onSave={(summary) =>
|
||
updateSection.mutateAsync({ sectionType: "user", field, summary, agent }).then(() => undefined)
|
||
}
|
||
/>
|
||
))}
|
||
</div>
|
||
|
||
{/* 历史摘要字段 */}
|
||
<div className="rounded-md border p-3 space-y-3">
|
||
<h4 className="text-xs font-semibold text-muted-foreground">历史摘要</h4>
|
||
{allHistoryFields.map((field) => (
|
||
<V1SectionField
|
||
key={field}
|
||
label={HISTORY_LABELS[field] ?? field}
|
||
value={extractSummary(historyRaw[field])}
|
||
onSave={(summary) =>
|
||
updateSection.mutateAsync({ sectionType: "history", field, summary, agent }).then(() => undefined)
|
||
}
|
||
/>
|
||
))}
|
||
</div>
|
||
|
||
{/* 事实记录 */}
|
||
<div className="rounded-md border p-3 space-y-2">
|
||
<h4 className="text-xs font-semibold text-muted-foreground">事实记录({factsRaw.length} 条)</h4>
|
||
{factsRaw.map((fact, i) => (
|
||
<V1FactItem key={String(fact.id ?? i)} fact={fact} agent={agent} userId={userId} />
|
||
))}
|
||
<V1AddFactForm agent={agent} userId={userId} />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Memory entry list with inline editing ──────────────────────────────────
|
||
|
||
function MemoryEntryList({
|
||
entries,
|
||
userId,
|
||
agent,
|
||
}: {
|
||
entries: string[];
|
||
userId: string;
|
||
agent: string | null;
|
||
}) {
|
||
const [editIndex, setEditIndex] = useState<number | null>(null);
|
||
const [editDraft, setEditDraft] = useState("");
|
||
const [addDraft, setAddDraft] = useState("");
|
||
const [isAdding, setIsAdding] = useState(false);
|
||
|
||
const addMutation = useAdminAddMemoryEntry(userId);
|
||
const replaceMutation = useAdminReplaceMemoryEntry(userId);
|
||
const deleteMutation = useAdminDeleteMemoryEntry(userId);
|
||
|
||
const isPending =
|
||
addMutation.isPending || replaceMutation.isPending || deleteMutation.isPending;
|
||
|
||
function startEdit(index: number) {
|
||
setEditIndex(index);
|
||
setEditDraft(entries[index] ?? "");
|
||
}
|
||
|
||
function cancelEdit() {
|
||
setEditIndex(null);
|
||
setEditDraft("");
|
||
}
|
||
|
||
async function confirmEdit(index: number) {
|
||
const oldText = entries[index];
|
||
if (!oldText || editDraft.trim() === oldText.trim()) {
|
||
cancelEdit();
|
||
return;
|
||
}
|
||
try {
|
||
await replaceMutation.mutateAsync({ oldText, content: editDraft.trim(), agent });
|
||
cancelEdit();
|
||
} catch (err) {
|
||
toast.error(err instanceof Error ? err.message : "修改失败");
|
||
}
|
||
}
|
||
|
||
async function handleDelete(entry: string) {
|
||
try {
|
||
await deleteMutation.mutateAsync({ oldText: entry, agent });
|
||
} catch (err) {
|
||
toast.error(err instanceof Error ? err.message : "删除失败");
|
||
}
|
||
}
|
||
|
||
async function handleAdd() {
|
||
const content = addDraft.trim();
|
||
if (!content) return;
|
||
try {
|
||
await addMutation.mutateAsync({ content, agent });
|
||
setAddDraft("");
|
||
setIsAdding(false);
|
||
} catch (err) {
|
||
toast.error(err instanceof Error ? err.message : "新增失败");
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col gap-2">
|
||
{entries.length === 0 && !isAdding && (
|
||
<p className="text-muted-foreground py-6 text-center text-sm">暂无记忆条目</p>
|
||
)}
|
||
|
||
{entries.map((entry, i) => (
|
||
<div key={i} className="group rounded-md border p-3">
|
||
{editIndex === i ? (
|
||
<div className="flex flex-col gap-2">
|
||
<Textarea
|
||
value={editDraft}
|
||
onChange={(e) => setEditDraft(e.target.value)}
|
||
className="min-h-[80px] text-sm"
|
||
autoFocus
|
||
/>
|
||
<div className="flex gap-2">
|
||
<Button
|
||
size="sm"
|
||
onClick={() => confirmEdit(i)}
|
||
disabled={replaceMutation.isPending}
|
||
>
|
||
{replaceMutation.isPending ? (
|
||
<Loader2 className="size-3 animate-spin" />
|
||
) : (
|
||
<Check className="size-3" />
|
||
)}
|
||
保存
|
||
</Button>
|
||
<Button size="sm" variant="ghost" onClick={cancelEdit}>
|
||
<X className="size-3" />
|
||
取消
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="flex items-start gap-2">
|
||
<span className="text-muted-foreground mt-0.5 shrink-0 text-sm">·</span>
|
||
<span className="flex-1 whitespace-pre-wrap text-sm">{entry}</span>
|
||
<div className="flex shrink-0 gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
className="size-7"
|
||
onClick={() => startEdit(i)}
|
||
disabled={isPending}
|
||
>
|
||
<Pencil className="size-3" />
|
||
</Button>
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
className="size-7 hover:text-destructive"
|
||
onClick={() => handleDelete(entry)}
|
||
disabled={isPending}
|
||
>
|
||
{deleteMutation.isPending ? (
|
||
<Loader2 className="size-3 animate-spin" />
|
||
) : (
|
||
<Trash2 className="size-3" />
|
||
)}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
|
||
{isAdding ? (
|
||
<div className="flex flex-col gap-2 rounded-md border p-3">
|
||
<Textarea
|
||
placeholder="输入新记忆条目内容…"
|
||
value={addDraft}
|
||
onChange={(e) => setAddDraft(e.target.value)}
|
||
className="min-h-[80px] text-sm"
|
||
autoFocus
|
||
/>
|
||
<div className="flex gap-2">
|
||
<Button size="sm" onClick={handleAdd} disabled={addMutation.isPending || !addDraft.trim()}>
|
||
{addMutation.isPending ? <Loader2 className="size-3 animate-spin" /> : <Check className="size-3" />}
|
||
确认
|
||
</Button>
|
||
<Button size="sm" variant="ghost" onClick={() => { setIsAdding(false); setAddDraft(""); }}>
|
||
<X className="size-3" />
|
||
取消
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="mt-1 w-full"
|
||
onClick={() => setIsAdding(true)}
|
||
disabled={isPending}
|
||
>
|
||
<Plus className="size-3" />
|
||
新增条目
|
||
</Button>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Right panel: memory tabs for a selected user ───────────────────────────
|
||
|
||
function UserMemoryPanel({ userId }: { userId: string }) {
|
||
const { data: memory, isLoading } = useUserMemory(userId);
|
||
const [selectedAgent, setSelectedAgent] = useState<string | null>(null);
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<div className="text-muted-foreground flex flex-1 items-center justify-center gap-2 text-sm">
|
||
<Loader2 className="size-4 animate-spin" />
|
||
加载中…
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const isV1 = memory?.version === "v1";
|
||
const globalBucket = memory?.buckets.find((b) => b.name === "global");
|
||
const agentBuckets = (memory?.buckets ?? []).filter((b) => b.name !== "global");
|
||
|
||
// ── V1 只读视图 ──────────────────────────────────────────────────────────
|
||
if (isV1) {
|
||
const currentAgent = selectedAgent ?? agentBuckets[0]?.name ?? null;
|
||
const currentBucket = currentAgent
|
||
? agentBuckets.find((b) => b.name === currentAgent)
|
||
: globalBucket;
|
||
|
||
return (
|
||
<Tabs defaultValue="global" className="flex flex-1 flex-col overflow-hidden">
|
||
<div className="border-b px-4 pt-4">
|
||
<div className="mb-2 rounded-md bg-amber-50 border border-amber-200 px-3 py-1.5 text-xs text-amber-700">
|
||
V1 模式:记忆由系统自动提取,不支持手动管理
|
||
</div>
|
||
<TabsList variant="line">
|
||
<TabsTrigger value="global">用户记忆</TabsTrigger>
|
||
{agentBuckets.length > 0 && (
|
||
<TabsTrigger value="agent">
|
||
Agent 记忆
|
||
<Badge variant="secondary" className="ml-1.5 text-xs">
|
||
{agentBuckets.length}
|
||
</Badge>
|
||
</TabsTrigger>
|
||
)}
|
||
</TabsList>
|
||
</div>
|
||
|
||
<TabsContent value="global" className="flex-1 overflow-hidden p-0">
|
||
<ScrollArea className="h-full">
|
||
<div className="p-4">
|
||
{globalBucket ? <V1BucketView data={globalBucket.data} agent={null} userId={userId} /> : (
|
||
<p className="text-muted-foreground py-6 text-center text-sm">暂无数据</p>
|
||
)}
|
||
</div>
|
||
</ScrollArea>
|
||
</TabsContent>
|
||
|
||
{agentBuckets.length > 0 && (
|
||
<TabsContent value="agent" className="flex-1 overflow-hidden p-0">
|
||
<ScrollArea className="h-full">
|
||
<div className="p-4">
|
||
<div className="mb-3 flex items-center gap-2">
|
||
<span className="text-muted-foreground text-xs">选择 Agent:</span>
|
||
<Select value={currentAgent ?? ""} onValueChange={(v) => setSelectedAgent(v)}>
|
||
<SelectTrigger className="h-7 w-48 text-xs">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{agentBuckets.map((b) => (
|
||
<SelectItem key={b.name} value={b.name} className="text-xs">
|
||
{b.display_name || b.name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
{currentBucket && <V1BucketView data={currentBucket.data} agent={currentAgent} userId={userId} />}
|
||
</div>
|
||
</ScrollArea>
|
||
</TabsContent>
|
||
)}
|
||
</Tabs>
|
||
);
|
||
}
|
||
|
||
// ── V2 可编辑视图 ────────────────────────────────────────────────────────
|
||
const userEntries = globalBucket ? getEntries(globalBucket.data) : [];
|
||
const currentAgent = selectedAgent ?? agentBuckets[0]?.name ?? null;
|
||
const agentBucket = agentBuckets.find((b) => b.name === currentAgent);
|
||
const agentEntries = agentBucket ? getEntries(agentBucket.data) : [];
|
||
|
||
return (
|
||
<Tabs defaultValue="user" className="flex flex-1 flex-col overflow-hidden">
|
||
<div className="border-b px-4 pt-4">
|
||
<TabsList variant="line">
|
||
<TabsTrigger value="user">
|
||
用户画像
|
||
{userEntries.length > 0 && (
|
||
<Badge variant="secondary" className="ml-1.5 text-xs">
|
||
{userEntries.length}
|
||
</Badge>
|
||
)}
|
||
</TabsTrigger>
|
||
<TabsTrigger value="agent">
|
||
Agent 记忆
|
||
{agentBuckets.length > 0 && (
|
||
<Badge variant="secondary" className="ml-1.5 text-xs">
|
||
{agentBuckets.length}
|
||
</Badge>
|
||
)}
|
||
</TabsTrigger>
|
||
</TabsList>
|
||
</div>
|
||
|
||
<TabsContent value="user" className="flex-1 overflow-hidden p-0">
|
||
<ScrollArea className="h-full">
|
||
<div className="p-4">
|
||
<p className="text-muted-foreground mb-3 text-xs">
|
||
USER.md — 跨 Agent 共享的用户画像
|
||
</p>
|
||
<MemoryEntryList entries={userEntries} userId={userId} agent={null} />
|
||
</div>
|
||
</ScrollArea>
|
||
</TabsContent>
|
||
|
||
<TabsContent value="agent" className="flex-1 overflow-hidden p-0">
|
||
<ScrollArea className="h-full">
|
||
<div className="p-4">
|
||
{agentBuckets.length === 0 ? (
|
||
<p className="text-muted-foreground py-6 text-center text-sm">该用户暂无 Agent 记忆</p>
|
||
) : (
|
||
<>
|
||
<div className="mb-3 flex items-center gap-2">
|
||
<span className="text-muted-foreground text-xs">选择 Agent:</span>
|
||
<Select
|
||
value={currentAgent ?? ""}
|
||
onValueChange={(v) => setSelectedAgent(v)}
|
||
>
|
||
<SelectTrigger className="h-7 w-48 text-xs">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{agentBuckets.map((b) => (
|
||
<SelectItem key={b.name} value={b.name} className="text-xs">
|
||
{b.display_name || b.name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<span className="text-muted-foreground text-xs">— MEMORY.md</span>
|
||
</div>
|
||
{currentAgent && (
|
||
<MemoryEntryList entries={agentEntries} userId={userId} agent={currentAgent} />
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
</ScrollArea>
|
||
</TabsContent>
|
||
</Tabs>
|
||
);
|
||
}
|
||
|
||
// ── Main page ──────────────────────────────────────────────────────────────
|
||
|
||
export default function AdminMemoryPage() {
|
||
const { data: users, isLoading: usersLoading } = useAllUsers(true);
|
||
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
||
const [userQuery, setUserQuery] = useState("");
|
||
|
||
const filteredUsers = (users ?? []).filter((u) =>
|
||
u.email.toLowerCase().includes(userQuery.toLowerCase()),
|
||
);
|
||
|
||
return (
|
||
<div className="flex h-full">
|
||
{/* Left: user list */}
|
||
<div className="flex w-64 shrink-0 flex-col border-r">
|
||
<div className="border-b p-3">
|
||
<Input
|
||
placeholder="搜索用户…"
|
||
value={userQuery}
|
||
onChange={(e) => setUserQuery(e.target.value)}
|
||
className="h-8 text-sm"
|
||
/>
|
||
</div>
|
||
<ScrollArea className="flex-1">
|
||
{usersLoading && (
|
||
<div className="text-muted-foreground flex items-center justify-center gap-2 py-10 text-sm">
|
||
<Loader2 className="size-4 animate-spin" />
|
||
加载中…
|
||
</div>
|
||
)}
|
||
{!usersLoading && filteredUsers.length === 0 && (
|
||
<p className="text-muted-foreground py-8 text-center text-sm">暂无用户</p>
|
||
)}
|
||
{filteredUsers.map((user) => (
|
||
<button
|
||
key={user.id}
|
||
className={`flex w-full flex-col items-start gap-0.5 px-3 py-2.5 text-left text-sm transition-colors hover:bg-accent ${
|
||
selectedUserId === user.id ? "bg-accent font-medium" : ""
|
||
}`}
|
||
onClick={() => setSelectedUserId(user.id)}
|
||
>
|
||
<span className="max-w-full truncate">{user.email}</span>
|
||
{user.system_role === "admin" && (
|
||
<Badge variant="default" className="h-4 px-1 text-[10px]">
|
||
Admin
|
||
</Badge>
|
||
)}
|
||
</button>
|
||
))}
|
||
</ScrollArea>
|
||
</div>
|
||
|
||
{/* Right: memory tabs */}
|
||
<div className="flex flex-1 flex-col overflow-hidden">
|
||
{selectedUserId ? (
|
||
<UserMemoryPanel key={selectedUserId} userId={selectedUserId} />
|
||
) : (
|
||
<div className="text-muted-foreground flex flex-1 items-center justify-center text-sm">
|
||
请在左侧选择用户
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|