Files
zk-data-agent/frontend/app/components/assistant-ui/threadlist-sidebar.tsx
T
2026-05-11 20:53:08 +08:00

683 lines
20 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 { ThreadListPrimitive } from "@assistant-ui/react";
import {
BarChart3Icon,
BotIcon,
ChevronDownIcon,
Clock3Icon,
LogOutIcon,
PanelLeftOpenIcon,
SearchIcon,
SquarePenIcon,
UserIcon,
} from "lucide-react";
import { Popover as PopoverPrimitive } from "radix-ui";
import type * as React from "react";
import { useEffect, 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 {
fetchLatestRunStatus,
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 { useClawSessionReplay } from "@/lib/claw-session-replay";
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;
};
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 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();
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">
<div
onClickCapture={() => {
window.localStorage.removeItem("claw.activeSessionId");
window.dispatchEvent(new Event("claw-active-session-cleared"));
}}
>
<ThreadListPrimitive.New asChild>
<CollapsedIconButton label="新任务">
<SquarePenIcon className="size-4" />
</CollapsedIconButton>
</ThreadListPrimitive.New>
</div>
<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="truncate font-medium text-sm">
{loading ? "回放中..." : session.preview || session.session_id}
</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);
useEffect(() => {
if (!open) return;
let cancelled = false;
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([]);
});
return () => {
cancelled = true;
};
}, [open]);
async function openSession(
sessionId: string,
replaySession: (
sessionId: string,
repository: ExportedMessageRepository,
) => void,
) {
const cleanSessionId = normalizeSessionId(sessionId);
if (!cleanSessionId) return;
window.localStorage.setItem("claw.activeSessionId", cleanSessionId);
setLoadingSessionId(sessionId);
try {
const response = await fetch(`/api/claw/sessions/${cleanSessionId}`, {
cache: "no-store",
});
if (!response.ok) return;
const payload = (await response.json()) as ReplaySessionPayload;
const runStatus = await fetchLatestRunStatus(cleanSessionId);
replaySession(
cleanSessionId,
toReplayRepository(payload, cleanSessionId, runStatus),
);
} finally {
setLoadingSessionId(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);
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(
window.localStorage.getItem("claw.activeSessionId") ??
nextState?.active_session_id,
);
if (!activeSessionId) {
setActiveSession(null);
return;
}
window.localStorage.setItem("claw.activeSessionId", 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]);
async function logout() {
await fetch("/api/claw/auth/logout", { method: "POST" });
window.localStorage.removeItem("claw.activeSessionId");
onLogout();
}
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,
);
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 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>
</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 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>
);
}