Files
zk-data-agent/frontend/app/components/assistant-ui/threadlist-sidebar.tsx
T
2026-06-12 17:28:15 +08:00

1055 lines
31 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 type { ExportedMessageRepository } from "@assistant-ui/core";
import {
BarChart3Icon,
BotIcon,
BrainIcon,
ChevronDownIcon,
Clock3Icon,
LogOutIcon,
PanelLeftOpenIcon,
SaveIcon,
SearchIcon,
SquarePenIcon,
UserIcon,
} from "lucide-react";
import { Popover as PopoverPrimitive } from "radix-ui";
import type * as React from "react";
import { useEffect, useRef, useState } from "react";
import type { ClawAccount } from "@/app/claw-account-gate";
import { ClawLlmSettings } from "@/app/claw-llm-settings";
import { ClawThemeSwitcher } from "@/app/claw-theme-switcher";
import {
type ClawRunStatus,
ThreadList,
toReplayRepository,
} from "@/components/assistant-ui/thread-list";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
SidebarTrigger,
useSidebar,
} from "@/components/ui/sidebar";
import {
clearActiveSessionId,
readActiveSessionId,
writeActiveSessionId,
} from "@/lib/claw-active-session";
import {
fetchSessionReplaySnapshot,
readCachedSessionReplay,
} from "@/lib/claw-session-cache";
import { useClawSessionReplay } from "@/lib/claw-session-replay";
import { pushHomeUrl, pushSessionUrl } from "@/lib/claw-session-url";
type ThreadListSidebarProps = React.ComponentProps<typeof Sidebar> & {
account: ClawAccount;
onLogout: () => void;
};
type ClawState = {
model?: string;
session_directory?: string;
active_session_id?: string | null;
};
type ClawSessionSummary = {
session_id: string;
turns: number;
tool_calls: number;
modified_at: number;
preview?: string;
model?: string;
usage?: UsageSummary;
is_training?: boolean;
};
type UsageSummary = {
input_tokens?: number;
output_tokens?: number;
total_tokens?: number;
};
type ClawStoredSession = {
session_id: string;
messages?: unknown[];
usage?: UsageSummary;
tool_calls?: number;
turns?: number;
model?: string;
};
type UserMemoryPayload = {
content: string;
line_count?: number;
updated_at?: string | null;
};
type SkillMemoryPayload = {
skill: string;
content: string;
line_count?: number;
updated_at?: string | null;
};
type MemoryQueuePayload = {
queue?: {
totals?: {
events?: number;
pending?: number;
processing?: number;
done?: number;
failed?: number;
};
};
events?: MemoryEventPayload[];
};
type MemoryEventPayload = {
id: string;
session_id: string;
status: string;
priority: number;
signals?: string[];
error?: string | null;
updated_at?: string;
};
type ReplaySessionPayload = Parameters<typeof toReplayRepository>[0];
type SidebarSession = ClawSessionSummary & {
preview: string;
};
export function ThreadListSidebar({
account,
onLogout,
...props
}: ThreadListSidebarProps) {
return (
<Sidebar collapsible="icon" {...props}>
<div className="flex h-full flex-col group-data-[collapsible=icon]:hidden">
<SidebarHeader className="aui-sidebar-header mb-2 border-b">
<div className="aui-sidebar-header-content flex items-center justify-between gap-2">
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg">
<div className="aui-sidebar-header-icon-wrapper flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
<BotIcon className="aui-sidebar-header-icon size-4" />
</div>
<div className="aui-sidebar-header-heading mr-6 flex flex-col gap-0.5 leading-none">
<span className="aui-sidebar-header-title font-semibold">
ZK Data Agent
</span>
<span className="text-muted-foreground text-xs">
</span>
</div>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
<SidebarTrigger className="size-8 shrink-0" />
</div>
</SidebarHeader>
<SidebarContent className="aui-sidebar-content overflow-hidden px-2">
<ThreadList />
</SidebarContent>
<SidebarRail />
<SidebarFooter className="aui-sidebar-footer border-t">
<AccountMenu account={account} onLogout={onLogout} />
</SidebarFooter>
</div>
<div className="hidden h-full group-data-[collapsible=icon]:flex">
<CollapsedSidebarRail account={account} onLogout={onLogout} />
</div>
</Sidebar>
);
}
function CollapsedSidebarRail({
account,
onLogout,
}: {
account: ClawAccount;
onLogout: () => void;
}) {
const { setOpen } = useSidebar();
const { clearSession } = useClawSessionReplay();
return (
<div className="flex h-full w-full flex-col items-center border-r bg-sidebar py-3 text-sidebar-foreground">
<button
type="button"
className="group mb-5 flex size-9 items-center justify-center rounded-lg text-sidebar-foreground transition-colors hover:bg-sidebar-accent"
aria-label="展开侧栏"
title="展开侧栏"
onClick={() => setOpen(true)}
>
<BotIcon className="size-5 group-hover:hidden" />
<PanelLeftOpenIcon className="hidden size-5 group-hover:block" />
</button>
<div className="flex flex-col items-center gap-3">
<CollapsedIconButton
label="新任务"
onClick={() => {
pushHomeUrl();
clearActiveSessionId();
clearSession();
window.dispatchEvent(new Event("claw-active-session-cleared"));
}}
>
<SquarePenIcon className="size-4" />
</CollapsedIconButton>
<CollapsedSessionSearch />
<CollapsedRecentSessions />
</div>
<div className="mt-auto">
<AccountMenu account={account} onLogout={onLogout} compact />
</div>
</div>
);
}
function CollapsedIconButton({
label,
children,
onClick,
}: {
label: string;
children: React.ReactNode;
onClick?: () => void;
}) {
return (
<button
type="button"
className="flex size-8 items-center justify-center rounded-lg text-sidebar-foreground transition-colors hover:bg-sidebar-accent"
aria-label={label}
title={label}
onClick={onClick}
>
{children}
</button>
);
}
function CollapsedSessionSearch() {
const { replaySession } = useClawSessionReplay();
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const { sessions, loadingSessionId, openSession } = useSidebarSessions(open);
const filteredSessions = sessions.filter((session) => {
const keyword = query.trim().toLowerCase();
if (!keyword) return true;
return (
session.preview.toLowerCase().includes(keyword) ||
session.session_id.toLowerCase().includes(keyword)
);
});
return (
<>
<CollapsedIconButton label="搜索任务" onClick={() => setOpen(true)}>
<SearchIcon className="size-4" />
</CollapsedIconButton>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="top-[35%] max-w-lg translate-y-[-35%] gap-3 p-4">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
ID
</DialogDescription>
</DialogHeader>
<Input
autoFocus
value={query}
placeholder="输入关键词..."
onChange={(event) => setQuery(event.target.value)}
/>
<div className="max-h-80 overflow-y-auto">
{filteredSessions.length ? (
<div className="flex flex-col gap-1">
{filteredSessions.map((session) => (
<SessionResultButton
key={session.session_id}
session={session}
loading={loadingSessionId === session.session_id}
onClick={async () => {
await openSession(session.session_id, replaySession);
setOpen(false);
}}
/>
))}
</div>
) : (
<p className="py-8 text-center text-muted-foreground text-sm">
</p>
)}
</div>
</DialogContent>
</Dialog>
</>
);
}
function CollapsedRecentSessions() {
const { replaySession } = useClawSessionReplay();
const [open, setOpen] = useState(false);
const { sessions, loadingSessionId, openSession } = useSidebarSessions(open);
return (
<PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
<PopoverPrimitive.Trigger asChild>
<CollapsedIconButton label="最近任务">
<Clock3Icon className="size-4" />
</CollapsedIconButton>
</PopoverPrimitive.Trigger>
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
side="right"
align="start"
sideOffset={10}
className="z-50 w-72 rounded-xl border bg-popover p-2 text-popover-foreground shadow-lg outline-none"
>
<div className="px-2 py-1.5 font-medium text-sm"></div>
{sessions.length ? (
<div className="flex max-h-96 flex-col gap-1 overflow-y-auto">
{sessions.map((session) => (
<SessionResultButton
key={session.session_id}
session={session}
loading={loadingSessionId === session.session_id}
onClick={async () => {
await openSession(session.session_id, replaySession);
setOpen(false);
}}
/>
))}
</div>
) : (
<p className="px-2 py-6 text-center text-muted-foreground text-sm">
</p>
)}
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
</PopoverPrimitive.Root>
);
}
function SessionResultButton({
session,
loading,
onClick,
}: {
session: SidebarSession;
loading: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
className="flex w-full flex-col rounded-lg px-2 py-2 text-left transition-colors hover:bg-muted"
onClick={onClick}
>
<span className="flex items-center gap-1.5">
<span className="truncate font-medium text-sm">
{loading ? "回放中..." : session.preview || session.session_id}
</span>
{session.is_training ? (
<span className="shrink-0 rounded bg-amber-100 px-1.5 text-[10px] text-amber-800">
</span>
) : null}
</span>
<span className="mt-0.5 text-muted-foreground text-xs">
{session.turns ?? 0} · {session.tool_calls ?? 0}
</span>
</button>
);
}
function useSidebarSessions(open: boolean) {
const [sessions, setSessions] = useState<SidebarSession[]>([]);
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
const openRequestRef = useRef(0);
const openAbortRef = useRef<AbortController | null>(null);
useEffect(() => {
if (!open) return;
let cancelled = false;
const reload = () => {
fetch("/api/claw/sessions", { cache: "no-store" })
.then((res) => (res.ok ? res.json() : []))
.then((payload) => {
if (cancelled || !Array.isArray(payload)) return;
setSessions(dedupeSidebarSessions(payload));
})
.catch(() => {
if (!cancelled) setSessions([]);
});
};
reload();
const handleUpdate = () => reload();
window.addEventListener("claw-session-updated", handleUpdate);
return () => {
cancelled = true;
window.removeEventListener("claw-session-updated", handleUpdate);
};
}, [open]);
async function openSession(
sessionId: string,
replaySession: (
sessionId: string,
repository: ExportedMessageRepository,
) => void,
) {
const cleanSessionId = normalizeSessionId(sessionId);
if (!cleanSessionId) return;
const requestId = openRequestRef.current + 1;
openRequestRef.current = requestId;
openAbortRef.current?.abort();
const abortController = new AbortController();
openAbortRef.current = abortController;
const cached = readCachedSessionReplay<ReplaySessionPayload, ClawRunStatus>(
cleanSessionId,
);
writeActiveSessionId(cleanSessionId);
pushSessionUrl(cleanSessionId);
if (cached) {
replaySession(
cleanSessionId,
toReplayRepository(cached.session, cleanSessionId, cached.runStatus),
);
setLoadingSessionId(null);
} else {
setLoadingSessionId(sessionId);
}
try {
const snapshot = await fetchSessionReplaySnapshot<
ReplaySessionPayload,
ClawRunStatus
>(cleanSessionId, { signal: abortController.signal });
if (requestId !== openRequestRef.current) return;
if (readActiveSessionId() !== cleanSessionId) return;
if (!snapshot) return;
if (!cached || snapshot.signature !== cached.signature) {
replaySession(
cleanSessionId,
toReplayRepository(
snapshot.session,
cleanSessionId,
snapshot.runStatus,
),
);
}
} catch (error) {
if (!(error instanceof DOMException && error.name === "AbortError")) {
console.warn("[sidebar] failed to open session", error);
}
} finally {
if (requestId === openRequestRef.current) {
setLoadingSessionId(null);
}
if (openAbortRef.current === abortController) {
openAbortRef.current = null;
}
}
}
return { sessions, loadingSessionId, openSession };
}
function dedupeSidebarSessions(payload: unknown[]) {
const byId = new Map<string, SidebarSession>();
for (const item of payload) {
if (!isSidebarSession(item)) continue;
const previous = byId.get(item.session_id);
if (!previous || item.modified_at >= previous.modified_at) {
byId.set(item.session_id, item);
}
}
return [...byId.values()].sort((a, b) => b.modified_at - a.modified_at);
}
function isSidebarSession(value: unknown): value is SidebarSession {
if (!value || typeof value !== "object") return false;
const item = value as Partial<SidebarSession>;
return (
typeof item.session_id === "string" &&
typeof item.modified_at === "number" &&
typeof item.preview === "string"
);
}
function AccountMenu({
account,
onLogout,
compact = false,
}: {
account: ClawAccount;
onLogout: () => void;
compact?: boolean;
}) {
const [open, setOpen] = useState(false);
const [state, setState] = useState<ClawState | null>(null);
const [sessions, setSessions] = useState<ClawSessionSummary[]>([]);
const [activeSession, setActiveSession] = useState<ClawStoredSession | null>(
null,
);
const [modelUsageOpen, setModelUsageOpen] = useState(false);
const [memoryDialogOpen, setMemoryDialogOpen] = useState(false);
const [userMemory, setUserMemory] = useState<UserMemoryPayload | null>(null);
const [skillMemories, setSkillMemories] = useState<SkillMemoryPayload[]>([]);
const [memoryQueue, setMemoryQueue] = useState<MemoryQueuePayload | null>(
null,
);
const [memoryDraft, setMemoryDraft] = useState("");
const [selectedMemorySkill, setSelectedMemorySkill] = useState("");
const [memorySaving, setMemorySaving] = useState(false);
useEffect(() => {
if (!open) return;
Promise.all([
fetch("/api/claw/state", { cache: "no-store" }).then((res) =>
res.ok ? res.json() : null,
),
fetch("/api/claw/sessions", { cache: "no-store" }).then((res) =>
res.ok ? res.json() : [],
),
])
.then(async ([statePayload, sessionsPayload]) => {
const nextState = statePayload as ClawState | null;
const nextSessions = Array.isArray(sessionsPayload)
? (sessionsPayload as ClawSessionSummary[])
: [];
setState(nextState);
setSessions(nextSessions);
const activeSessionId = normalizeSessionId(readActiveSessionId());
if (!activeSessionId) {
setActiveSession(null);
return;
}
writeActiveSessionId(activeSessionId);
const response = await fetch(`/api/claw/sessions/${activeSessionId}`, {
cache: "no-store",
});
setActiveSession(response.ok ? await response.json() : null);
})
.catch(() => {
setState(null);
setSessions([]);
setActiveSession(null);
});
}, [open]);
useEffect(() => {
if (!open && !memoryDialogOpen) return;
Promise.all([
fetch("/api/claw/memory/user", { cache: "no-store" }).then((res) =>
res.ok ? res.json() : null,
),
fetch("/api/claw/memory/skills", { cache: "no-store" }).then((res) =>
res.ok ? res.json() : [],
),
fetch("/api/claw/memory/events", { cache: "no-store" }).then((res) =>
res.ok ? res.json() : null,
),
]).then(([userPayload, skillPayload, queuePayload]) => {
const nextUser = userPayload as UserMemoryPayload | null;
setUserMemory(nextUser);
setSkillMemories(
Array.isArray(skillPayload)
? (skillPayload as SkillMemoryPayload[])
: [],
);
setMemoryQueue(queuePayload as MemoryQueuePayload | null);
if (memoryDialogOpen && !selectedMemorySkill) {
setMemoryDraft(nextUser?.content ?? "");
}
});
}, [open, memoryDialogOpen, selectedMemorySkill]);
async function logout() {
await fetch("/api/claw/auth/logout", { method: "POST" });
clearActiveSessionId();
onLogout();
}
async function saveUserMemory() {
setMemorySaving(true);
try {
const response = await fetch(
selectedMemorySkill
? `/api/claw/memory/skills/${encodeURIComponent(selectedMemorySkill)}`
: "/api/claw/memory/user",
{
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ content: memoryDraft }),
},
);
if (response.ok) {
if (selectedMemorySkill) {
const payload = (await response.json()) as SkillMemoryPayload;
setSkillMemories((current) =>
current.some((item) => item.skill === payload.skill)
? current.map((item) =>
item.skill === payload.skill ? payload : item,
)
: [...current, payload],
);
setMemoryDraft(payload.content);
} else {
const payload = (await response.json()) as UserMemoryPayload;
setUserMemory(payload);
setMemoryDraft(payload.content);
}
}
} finally {
setMemorySaving(false);
}
}
const totalToolCalls = sessions.reduce(
(sum, session) => sum + (session.tool_calls ?? 0),
0,
);
const modelUsage = aggregateUsageByModel(sessions);
const modelUsageTotalTokens = modelUsage.reduce(
(sum, item) => sum + item.totalTokens,
0,
);
const selectedSkillMemory = selectedMemorySkill
? skillMemories.find((item) => item.skill === selectedMemorySkill)
: null;
return (
<PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
<PopoverPrimitive.Trigger asChild>
{compact ? (
<button
type="button"
className="flex size-8 items-center justify-center rounded-full bg-sidebar-primary text-sidebar-primary-foreground transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
aria-label="账号与统计"
title={account.username}
>
<span className="font-medium text-xs">
{account.username.slice(0, 2).toUpperCase()}
</span>
</button>
) : (
<button
type="button"
className="flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left transition-colors hover:bg-sidebar-accent"
>
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
<UserIcon className="size-4" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-sm">
{account.username}
</div>
<div className="text-muted-foreground text-xs"></div>
</div>
</button>
)}
</PopoverPrimitive.Trigger>
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
side="top"
align="start"
sideOffset={8}
className="z-50 w-80 rounded-lg border bg-popover p-3 text-popover-foreground shadow-md outline-none"
>
<div className="mb-3 flex items-center gap-2">
<div className="flex size-9 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<UserIcon className="size-4" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{account.username}</div>
<div className="truncate text-muted-foreground text-xs">
{state?.model ?? "模型配置读取中"}
</div>
</div>
<Button
variant="ghost"
size="sm"
className="h-8 shrink-0 gap-1.5 px-2 text-muted-foreground"
onClick={logout}
>
<LogOutIcon className="size-3.5" />
退
</Button>
</div>
<div className="grid grid-cols-3 gap-2">
<AccountStat label="会话" value={sessions.length} />
<AccountStat label="工具" value={totalToolCalls} />
<AccountStat
label="Token"
value={activeSession?.usage?.total_tokens ?? "暂无"}
/>
</div>
<div className="mt-3 rounded-md border bg-muted/30 p-2 text-xs">
<div className="mb-1 flex items-center gap-1.5 font-medium">
<BarChart3Icon className="size-3.5" />
</div>
<div className="grid grid-cols-2 gap-x-3 gap-y-1 text-muted-foreground">
<span> token</span>
<span className="text-right">
{activeSession?.usage?.input_tokens ?? "暂无"}
</span>
<span> token</span>
<span className="text-right">
{activeSession?.usage?.output_tokens ?? "暂无"}
</span>
<span></span>
<span className="text-right">
{activeSession?.tool_calls ?? "暂无"}
</span>
</div>
</div>
<div className="mt-3 rounded-md border bg-muted/30 p-2 text-xs">
<button
type="button"
className="flex w-full items-center justify-between gap-2 text-left"
onClick={() => setModelUsageOpen((value) => !value)}
>
<span className="font-medium"></span>
<span className="flex items-center gap-1 text-muted-foreground">
{modelUsage.length
? `${modelUsage.length} 个模型 · ${formatCompactNumber(modelUsageTotalTokens)} tokens`
: "暂无"}
<ChevronDownIcon
className={`size-3.5 transition-transform ${
modelUsageOpen ? "rotate-180" : ""
}`}
/>
</span>
</button>
{modelUsageOpen && modelUsage.length ? (
<div className="mt-2 flex max-h-48 flex-col gap-2 overflow-y-auto pr-1">
{modelUsage.map((item) => (
<div
key={item.model}
className="grid grid-cols-[1fr_auto] gap-2"
>
<span className="truncate text-muted-foreground">
{item.model}
</span>
<span>{formatCompactNumber(item.totalTokens)} tokens</span>
<span className="text-muted-foreground">
{item.sessions} · {item.toolCalls}
</span>
<span className="text-right text-muted-foreground">
in {formatCompactNumber(item.inputTokens)} / out{" "}
{formatCompactNumber(item.outputTokens)}
</span>
</div>
))}
</div>
) : null}
{modelUsageOpen && !modelUsage.length ? (
<div className="text-muted-foreground"></div>
) : null}
</div>
<div className="mt-3 rounded-md border bg-muted/30 p-2 text-xs">
<div className="flex items-center justify-between gap-2">
<span className="flex items-center gap-1.5 font-medium">
<BrainIcon className="size-3.5" />
</span>
<Button
size="xs"
variant="outline"
onClick={() => {
setSelectedMemorySkill("");
setMemoryDraft(userMemory?.content ?? "");
setOpen(false);
setMemoryDialogOpen(true);
}}
>
</Button>
</div>
<div className="mt-2 grid grid-cols-3 gap-2">
<AccountStat label="用户行" value={userMemory?.line_count ?? 0} />
<AccountStat label="Skill" value={skillMemories.length} />
<AccountStat
label="待处理"
value={memoryQueue?.queue?.totals?.pending ?? 0}
/>
</div>
</div>
<div className="mt-3 flex items-center justify-between gap-2 border-t pt-3">
<span className="text-muted-foreground text-xs">
</span>
<div className="flex items-center gap-1">
<ClawThemeSwitcher />
<ClawLlmSettings />
</div>
</div>
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
<Dialog open={memoryDialogOpen} onOpenChange={setMemoryDialogOpen}>
<DialogContent className="max-h-[88vh] overflow-hidden sm:max-w-5xl">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
Skill Skill
Skill
</DialogDescription>
</DialogHeader>
<div className="grid min-h-0 gap-4 md:grid-cols-[220px_1fr_240px]">
<div className="min-h-0 rounded-md border bg-muted/20">
<div className="border-b px-3 py-2 font-medium text-xs">
</div>
<div className="max-h-[520px] overflow-y-auto p-2">
<Button
className="mb-2 h-auto w-full justify-start px-2 py-2 text-left"
size="sm"
variant={selectedMemorySkill ? "outline" : "secondary"}
onClick={() => {
setSelectedMemorySkill("");
setMemoryDraft(userMemory?.content ?? "");
}}
>
<div className="min-w-0">
<div className="truncate font-medium"></div>
<div className="text-muted-foreground text-xs">
{userMemory?.line_count ?? 0}
</div>
</div>
</Button>
<div className="mb-1 px-1 text-muted-foreground text-xs">
Skill
</div>
{skillMemories.length ? (
<div className="grid gap-1">
{skillMemories.map((item) => (
<Button
key={item.skill}
className="h-auto w-full justify-start px-2 py-2 text-left"
size="sm"
variant={
selectedMemorySkill === item.skill
? "secondary"
: "ghost"
}
onClick={() => {
setSelectedMemorySkill(item.skill);
setMemoryDraft(item.content);
}}
>
<div className="min-w-0">
<div className="truncate font-medium">
{item.skill}
</div>
<div className="text-muted-foreground text-xs">
{item.line_count ?? 0}
{item.updated_at
? ` · ${formatShortTime(item.updated_at)}`
: ""}
</div>
</div>
</Button>
))}
</div>
) : (
<div className="rounded-md border border-dashed p-3 text-muted-foreground text-xs">
Skill 使 Skill
Skill
</div>
)}
</div>
</div>
<div className="grid min-h-0 content-start gap-2">
<div className="rounded-md border bg-muted/20 px-3 py-2">
<div className="font-medium text-sm">
{selectedMemorySkill
? `${selectedMemorySkill} 使用记忆`
: "用户记忆"}
</div>
<div className="mt-1 text-muted-foreground text-xs">
{selectedMemorySkill
? "仅在该 Skill 启用并进入模型可见列表时注入。"
: "作为当前账号的长期偏好和通用经验注入。"}
</div>
</div>
<textarea
className="min-h-[420px] resize-none rounded-md border bg-background p-3 font-mono text-xs outline-none"
value={memoryDraft}
onChange={(event) => setMemoryDraft(event.target.value)}
/>
<div className="flex items-center justify-between gap-2">
<span className="truncate text-muted-foreground text-xs">
{selectedMemorySkill && selectedSkillMemory?.updated_at
? `最近更新 ${formatShortTime(selectedSkillMemory.updated_at)}`
: userMemory?.updated_at
? `最近更新 ${formatShortTime(userMemory.updated_at)}`
: "暂无更新时间"}
</span>
<Button
size="sm"
onClick={saveUserMemory}
disabled={memorySaving}
>
<SaveIcon className="size-3.5" />
{memorySaving ? "保存中..." : "保存"}
</Button>
</div>
</div>
<div className="grid content-start gap-3">
<div className="grid grid-cols-2 gap-2">
<AccountStat
label="用户记忆行"
value={userMemory?.line_count ?? 0}
/>
<AccountStat label="Skill 记忆" value={skillMemories.length} />
<AccountStat
label="待处理"
value={memoryQueue?.queue?.totals?.pending ?? 0}
/>
<AccountStat
label="失败"
value={memoryQueue?.queue?.totals?.failed ?? 0}
/>
</div>
<div className="rounded-md border bg-muted/30">
<div className="border-b px-2 py-2 font-medium text-xs">
</div>
<div className="max-h-[320px] overflow-y-auto p-2">
{memoryQueue?.events?.length ? (
memoryQueue.events.map((event) => (
<div
key={event.id}
className="mb-2 rounded-md border bg-background 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 text-muted-foreground">
{event.signals?.join(" / ") || "无信号"}
</div>
{event.error ? (
<div className="mt-1 text-destructive">
{event.error}
</div>
) : null}
</div>
))
) : (
<div className="py-6 text-center text-muted-foreground text-xs">
</div>
)}
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>
</PopoverPrimitive.Root>
);
}
function normalizeSessionId(value: unknown) {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed || null;
}
function aggregateUsageByModel(sessions: ClawSessionSummary[]) {
const byModel = new Map<
string,
{
model: string;
sessions: number;
toolCalls: number;
inputTokens: number;
outputTokens: number;
totalTokens: number;
}
>();
for (const session of sessions) {
const model = session.model || "unknown";
const current = byModel.get(model) ?? {
model,
sessions: 0,
toolCalls: 0,
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
};
current.sessions += 1;
current.toolCalls += session.tool_calls ?? 0;
current.inputTokens += session.usage?.input_tokens ?? 0;
current.outputTokens += session.usage?.output_tokens ?? 0;
current.totalTokens +=
session.usage?.total_tokens ??
(session.usage?.input_tokens ?? 0) + (session.usage?.output_tokens ?? 0);
byModel.set(model, current);
}
return [...byModel.values()].sort((a, b) => b.totalTokens - a.totalTokens);
}
function formatCompactNumber(value: number) {
return new Intl.NumberFormat("en", {
notation: "compact",
maximumFractionDigits: 1,
}).format(value);
}
function formatShortTime(value: string) {
const timestamp = Date.parse(value);
if (Number.isNaN(timestamp)) return value;
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(timestamp));
}
function AccountStat({
label,
value,
}: {
label: string;
value: string | number;
}) {
return (
<div className="rounded-md border bg-muted/30 px-2 py-1.5">
<div className="font-medium text-sm">{value}</div>
<div className="text-muted-foreground text-xs">{label}</div>
</div>
);
}