Add personal memory and admin dashboard

This commit is contained in:
wuyang6
2026-05-13 14:04:42 +08:00
parent e5095268d8
commit a325098d12
8 changed files with 2081 additions and 0 deletions
+583
View File
@@ -0,0 +1,583 @@
"use client";
import {
ActivityIcon,
DatabaseIcon,
RefreshCwIcon,
SaveIcon,
Trash2Icon,
UserPlusIcon,
UsersIcon,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
type AdminSummary = {
totals?: {
accounts: number;
sessions: number;
tool_calls: number;
tokens: number;
};
memory_queue?: MemoryQueue;
accounts?: AccountRow[];
};
type AccountRow = {
account_id: string;
registered: boolean;
session_count: number;
tool_calls: number;
input_tokens: number;
output_tokens: number;
total_tokens: number;
latest_session_at: number;
models: Record<string, number>;
user_memory_lines: number;
skill_memory_count: number;
};
type MemoryQueue = {
totals: {
events: number;
pending: number;
processing: number;
done: number;
failed: number;
};
accounts: Array<{
account_id: string;
events: number;
pending: number;
processing: number;
done: number;
failed: number;
}>;
};
type MemoryEvent = {
id: string;
session_id: string;
skills: string[];
signals: string[];
priority: number;
status: string;
error?: string | null;
created_at: string;
updated_at: string;
};
type UserMemory = {
content: string;
line_count: number;
updated_at?: string | null;
};
type SkillMemory = {
skill: string;
content: string;
line_count: number;
updated_at?: string | null;
};
const TOKEN_KEY = "zk-admin-token";
export default function AdminPage() {
const [token, setToken] = useState("");
const [username, setUsername] = useState("admin");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
setToken(localStorage.getItem(TOKEN_KEY) ?? "");
}, []);
async function login() {
setError("");
setIsLoading(true);
try {
const response = await fetch("/api/admin/login", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ username, password }),
});
const payload = (await response.json()) as {
token?: string;
detail?: string;
};
if (!response.ok || !payload.token) {
throw new Error(payload.detail ?? "登录失败");
}
localStorage.setItem(TOKEN_KEY, payload.token);
setToken(payload.token);
} catch (err) {
setError(err instanceof Error ? err.message : "登录失败");
} finally {
setIsLoading(false);
}
}
if (!token) {
return (
<main className="flex min-h-dvh items-center justify-center bg-background px-4">
<div className="w-full max-w-sm rounded-lg border bg-card p-6 shadow-sm">
<div className="mb-5">
<h1 className="font-semibold text-xl"></h1>
<p className="mt-1 text-muted-foreground text-sm">
</p>
</div>
<div className="grid gap-3">
<Input
value={username}
onChange={(event) => setUsername(event.target.value)}
placeholder="管理员账号"
/>
<Input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="管理员密码"
onKeyDown={(event) => {
if (event.key === "Enter") login();
}}
/>
{error ? <p className="text-destructive text-sm">{error}</p> : null}
<Button onClick={login} disabled={isLoading}>
{isLoading ? "登录中..." : "登录"}
</Button>
</div>
</div>
</main>
);
}
return <AdminDashboard token={token} onLogout={() => setToken("")} />;
}
function AdminDashboard({
token,
onLogout,
}: {
token: string;
onLogout: () => void;
}) {
const [summary, setSummary] = useState<AdminSummary | null>(null);
const [accounts, setAccounts] = useState<AccountRow[]>([]);
const [selectedAccountId, setSelectedAccountId] = useState("");
const [events, setEvents] = useState<MemoryEvent[]>([]);
const [userMemory, setUserMemory] = useState<UserMemory | null>(null);
const [skillMemories, setSkillMemories] = useState<SkillMemory[]>([]);
const [selectedSkill, setSelectedSkill] = useState("");
const [memoryDraft, setMemoryDraft] = useState("");
const [newAccountId, setNewAccountId] = useState("");
const [notice, setNotice] = useState("");
const selectedAccount = useMemo(
() => accounts.find((item) => item.account_id === selectedAccountId),
[accounts, selectedAccountId],
);
const selectedSkillMemory = useMemo(
() => skillMemories.find((item) => item.skill === selectedSkill),
[skillMemories, selectedSkill],
);
// biome-ignore lint/correctness/useExhaustiveDependencies: 首次进入后台时加载一次即可,后续由刷新按钮触发。
useEffect(() => {
refresh();
}, []);
// biome-ignore lint/correctness/useExhaustiveDependencies: 账号切换时加载对应记忆;函数体依赖当前 token。
useEffect(() => {
if (!selectedAccountId) return;
loadAccountMemory(selectedAccountId);
}, [selectedAccountId]);
useEffect(() => {
if (selectedSkillMemory) {
setMemoryDraft(selectedSkillMemory.content);
} else if (userMemory) {
setMemoryDraft(userMemory.content);
}
}, [selectedSkillMemory, userMemory]);
async function refresh() {
const [summaryPayload, accountPayload] = await Promise.all([
adminFetch<AdminSummary>(token, "/summary"),
adminFetch<AccountRow[]>(token, "/accounts"),
]);
setSummary(summaryPayload);
setAccounts(accountPayload);
const firstAccount =
selectedAccountId || accountPayload[0]?.account_id || "";
setSelectedAccountId(firstAccount);
if (firstAccount) await loadAccountMemory(firstAccount);
}
async function loadAccountMemory(accountId: string) {
const [memoryPayload, userPayload, skillPayload] = await Promise.all([
adminFetch<{ events: MemoryEvent[] }>(
token,
`/memory?account_id=${encodeURIComponent(accountId)}`,
),
adminFetch<UserMemory>(
token,
`/memory/user?account_id=${encodeURIComponent(accountId)}`,
),
adminFetch<SkillMemory[]>(
token,
`/memory/skills?account_id=${encodeURIComponent(accountId)}`,
),
]);
setEvents(memoryPayload.events ?? []);
setUserMemory(userPayload);
setSkillMemories(skillPayload);
setSelectedSkill("");
setMemoryDraft(userPayload.content);
}
async function createAccount() {
if (!newAccountId.trim()) return;
const payload = await adminFetch<{
account_id: string;
initial_password: string;
}>(token, "/accounts", {
method: "POST",
body: JSON.stringify({ account_id: newAccountId.trim() }),
});
setNotice(
`已创建 ${payload.account_id},初始密码 ${payload.initial_password}`,
);
setNewAccountId("");
await refresh();
}
async function deleteAccount(accountId: string) {
if (!confirm(`确定删除账号 ${accountId} 及其本地数据吗?`)) return;
await adminFetch(token, `/accounts/${encodeURIComponent(accountId)}`, {
method: "DELETE",
});
setNotice(`已删除 ${accountId}`);
setSelectedAccountId("");
await refresh();
}
async function saveMemory() {
if (!selectedAccountId) return;
if (selectedSkill) {
await adminFetch(
token,
`/memory/skills/${encodeURIComponent(selectedSkill)}`,
{
method: "PUT",
body: JSON.stringify({
account_id: selectedAccountId,
content: memoryDraft,
}),
},
);
setNotice(`已保存 ${selectedSkill} 记忆`);
} else {
await adminFetch(token, "/memory/user", {
method: "PUT",
body: JSON.stringify({
account_id: selectedAccountId,
content: memoryDraft,
}),
});
setNotice("已保存用户记忆");
}
await loadAccountMemory(selectedAccountId);
}
function logout() {
localStorage.removeItem(TOKEN_KEY);
onLogout();
}
return (
<main className="min-h-dvh bg-background text-foreground">
<header className="sticky top-0 z-10 border-b bg-background/90 backdrop-blur">
<div className="mx-auto flex max-w-7xl items-center gap-3 px-5 py-3">
<div className="flex size-9 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<DatabaseIcon className="size-4" />
</div>
<div className="min-w-0 flex-1">
<h1 className="font-semibold text-lg">ZK Data Agent </h1>
<p className="text-muted-foreground text-xs">
</p>
</div>
<Button variant="outline" size="sm" onClick={refresh}>
<RefreshCwIcon className="size-4" />
</Button>
<Button variant="ghost" size="sm" onClick={logout}>
退
</Button>
</div>
</header>
<div className="mx-auto grid max-w-7xl gap-4 px-5 py-5">
{notice ? (
<div className="rounded-md border bg-muted/40 px-3 py-2 text-sm">
{notice}
</div>
) : null}
<div className="grid gap-3 md:grid-cols-4">
<Metric title="用户" value={summary?.totals?.accounts ?? 0} />
<Metric title="会话" value={summary?.totals?.sessions ?? 0} />
<Metric title="工具调用" value={summary?.totals?.tool_calls ?? 0} />
<Metric
title="Token"
value={formatNumber(summary?.totals?.tokens ?? 0)}
/>
</div>
<div className="grid min-h-[640px] gap-4 lg:grid-cols-[320px_1fr]">
<section className="rounded-lg border bg-card">
<div className="border-b p-3">
<div className="mb-3 flex items-center gap-2 font-medium">
<UsersIcon className="size-4" />
</div>
<div className="flex gap-2">
<Input
value={newAccountId}
placeholder="新账号"
onChange={(event) => setNewAccountId(event.target.value)}
/>
<Button size="icon" variant="outline" onClick={createAccount}>
<UserPlusIcon className="size-4" />
</Button>
</div>
</div>
<div className="max-h-[560px] overflow-y-auto p-2">
{accounts.map((account) => (
<button
key={account.account_id}
type="button"
className={`mb-1 flex w-full flex-col rounded-md px-3 py-2 text-left transition-colors hover:bg-muted ${
selectedAccountId === account.account_id ? "bg-muted" : ""
}`}
onClick={() => setSelectedAccountId(account.account_id)}
>
<span className="flex items-center justify-between gap-2">
<span className="truncate font-medium text-sm">
{account.account_id}
</span>
<span className="text-muted-foreground text-xs">
{account.session_count}
</span>
</span>
<span className="mt-1 text-muted-foreground text-xs">
{account.tool_calls} ·{" "}
{formatNumber(account.total_tokens)} tokens
</span>
</button>
))}
</div>
</section>
<section className="grid gap-4">
<div className="grid gap-3 md:grid-cols-4">
<Metric title="账号" value={selectedAccount?.account_id ?? "-"} />
<Metric
title="用户记忆行"
value={selectedAccount?.user_memory_lines ?? 0}
/>
<Metric
title="Skill 记忆"
value={selectedAccount?.skill_memory_count ?? 0}
/>
<Metric
title="队列 pending"
value={
summary?.memory_queue?.accounts.find(
(item) => item.account_id === selectedAccountId,
)?.pending ?? 0
}
/>
</div>
<div className="grid gap-4 xl:grid-cols-[1fr_360px]">
<div className="rounded-lg border bg-card">
<div className="flex items-center justify-between border-b p-3">
<div>
<div className="font-medium"></div>
<div className="text-muted-foreground text-xs">
Skill Skill
</div>
</div>
<Button size="sm" onClick={saveMemory}>
<SaveIcon className="size-4" />
</Button>
</div>
<div className="flex gap-2 border-b p-3">
<Button
variant={selectedSkill ? "outline" : "secondary"}
size="sm"
onClick={() => {
setSelectedSkill("");
setMemoryDraft(userMemory?.content ?? "");
}}
>
</Button>
<div className="flex min-w-0 flex-1 gap-2 overflow-x-auto">
{skillMemories.map((item) => (
<Button
key={item.skill}
variant={
selectedSkill === item.skill ? "secondary" : "outline"
}
size="sm"
onClick={() => setSelectedSkill(item.skill)}
>
{item.skill}
</Button>
))}
</div>
</div>
<textarea
className="min-h-[420px] w-full resize-y bg-transparent p-4 font-mono text-sm outline-none"
value={memoryDraft}
onChange={(event) => setMemoryDraft(event.target.value)}
/>
</div>
<div className="grid gap-4">
<div className="rounded-lg border bg-card">
<div className="flex items-center gap-2 border-b p-3 font-medium">
<ActivityIcon className="size-4" />
</div>
<div className="grid grid-cols-2 gap-2 p-3 text-sm">
<SmallStat
label="全部"
value={summary?.memory_queue?.totals.events ?? 0}
/>
<SmallStat
label="待处理"
value={summary?.memory_queue?.totals.pending ?? 0}
/>
<SmallStat
label="处理中"
value={summary?.memory_queue?.totals.processing ?? 0}
/>
<SmallStat
label="失败"
value={summary?.memory_queue?.totals.failed ?? 0}
/>
</div>
</div>
<div className="rounded-lg border bg-card">
<div className="border-b p-3 font-medium"></div>
<div className="max-h-[360px] overflow-y-auto p-2">
{events.length ? (
events.map((event) => (
<div
key={event.id}
className="mb-2 rounded-md border bg-muted/20 p-2 text-xs"
>
<div className="flex items-center justify-between gap-2">
<span className="font-medium">{event.status}</span>
<span className="text-muted-foreground">
P{event.priority}
</span>
</div>
<div className="mt-1 truncate text-muted-foreground">
{event.session_id}
</div>
<div className="mt-1">
{event.signals.join(" / ") || "无信号"}
</div>
{event.error ? (
<div className="mt-1 text-destructive">
{event.error}
</div>
) : null}
</div>
))
) : (
<p className="p-4 text-center text-muted-foreground text-sm">
</p>
)}
</div>
</div>
{selectedAccountId ? (
<Button
variant="destructive"
onClick={() => deleteAccount(selectedAccountId)}
>
<Trash2Icon className="size-4" />
</Button>
) : null}
</div>
</div>
</section>
</div>
</div>
</main>
);
}
function Metric({ title, value }: { title: string; value: string | number }) {
return (
<div className="rounded-lg border bg-card p-3">
<div className="text-muted-foreground text-xs">{title}</div>
<div className="mt-1 truncate font-semibold text-xl">{value}</div>
</div>
);
}
function SmallStat({
label,
value,
}: {
label: string;
value: string | number;
}) {
return (
<div className="rounded-md border bg-muted/20 p-2">
<div className="font-medium">{value}</div>
<div className="text-muted-foreground text-xs">{label}</div>
</div>
);
}
async function adminFetch<T>(
token: string,
path: string,
init?: RequestInit,
): Promise<T> {
const separator = path.includes("?") ? "&" : "?";
const response = await fetch(`/api/admin${path}${separator}token=${token}`, {
...init,
headers: {
"content-type": "application/json",
...(init?.headers ?? {}),
},
cache: "no-store",
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload.detail ?? payload.error ?? "请求失败");
}
return payload as T;
}
function formatNumber(value: number) {
return new Intl.NumberFormat("zh-CN", {
notation: value > 10000 ? "compact" : "standard",
maximumFractionDigits: 1,
}).format(value);
}