Files
zk-data-agent/frontend/app/components/assistant-ui/activity-panel.tsx
T
2026-05-06 17:19:13 +08:00

426 lines
12 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.
"use client";
import type {
MessageState,
ThreadAssistantMessagePart,
ToolCallMessagePart,
ToolCallMessagePartStatus,
} from "@assistant-ui/react";
import { useAuiState } from "@assistant-ui/react";
import {
BrainIcon,
CheckCircle2Icon,
ChevronDownIcon,
ClockIcon,
FileTextIcon,
PanelRightCloseIcon,
WrenchIcon,
XCircleIcon,
} from "lucide-react";
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
type ActivityContextValue = {
open: boolean;
selectedId: string | null;
openItem: (id: string) => void;
close: () => void;
};
const ActivityContext = createContext<ActivityContextValue | null>(null);
export function ActivityProvider({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const openItem = useCallback((id: string) => {
setSelectedId(id);
setOpen(true);
}, []);
const close = useCallback(() => setOpen(false), []);
const value = useMemo(
() => ({
open,
selectedId,
openItem,
close,
}),
[open, selectedId, openItem, close],
);
return (
<ActivityContext.Provider value={value}>
{children}
</ActivityContext.Provider>
);
}
export function useActivityPanel() {
const value = useContext(ActivityContext);
if (!value) {
throw new Error("useActivityPanel must be used within ActivityProvider");
}
return value;
}
type ActivityItem = {
id: string;
kind: "reasoning" | "tool";
title: string;
summary: string;
status: ToolCallMessagePartStatus["type"];
argsText?: string;
result?: unknown;
resultSummary?: string;
rawResult?: string;
fileLinks?: string[];
};
export function ActivityPanel() {
const { open, selectedId, openItem, close } = useActivityPanel();
const messages = useAuiState((s) => s.thread.messages);
const items = useMemo(() => collectActivityItems(messages), [messages]);
const selectedMessageId = selectedId ? activityMessageId(selectedId) : null;
const visibleItems = selectedMessageId
? items.filter((item) => activityMessageId(item.id) === selectedMessageId)
: items;
const prevCountRef = useRef(0);
useEffect(() => {
if (items.length > prevCountRef.current) {
openItem(items.at(-1)?.id ?? items[0]?.id ?? "");
}
prevCountRef.current = items.length;
}, [items, openItem]);
if (!open) return null;
return (
<aside className="fixed inset-y-0 right-0 z-30 flex w-[min(23rem,calc(100vw-1rem))] flex-col border-l bg-background shadow-xl lg:static lg:z-auto lg:h-full lg:w-92 lg:shrink-0 lg:shadow-none">
<header className="flex h-16 shrink-0 items-center justify-between border-b px-4">
<div className="min-w-0">
<h2 className="font-semibold text-base"></h2>
<p className="text-muted-foreground text-xs">
{visibleItems.length > 0
? `${visibleItems.length} 个步骤`
: "暂无链路"}
</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8"
onClick={close}
aria-label="关闭活动面板"
>
<PanelRightCloseIcon className="size-4" />
</Button>
</header>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
{visibleItems.length === 0 ? (
<p className="text-muted-foreground text-sm">
</p>
) : (
<div className="flex flex-col gap-2">
{visibleItems.map((item) => (
<ActivityPanelItem
key={item.id}
item={item}
open={selectedId === item.id}
onOpenChange={(nextOpen) => {
if (nextOpen) openItem(item.id);
}}
/>
))}
</div>
)}
</div>
</aside>
);
}
function activityMessageId(activityId: string) {
return activityId.split(":", 1)[0] ?? activityId;
}
function ActivityPanelItem({
item,
open,
onOpenChange,
}: {
item: ActivityItem;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const Icon = item.kind === "tool" ? WrenchIcon : BrainIcon;
return (
<Collapsible
open={open}
onOpenChange={onOpenChange}
className="rounded-lg border bg-card text-card-foreground"
>
<CollapsibleTrigger className="flex w-full items-start gap-3 px-3 py-3 text-left">
<ActivityStatusIcon status={item.status} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="truncate font-medium text-sm">{item.title}</span>
</div>
<p className="mt-1 line-clamp-2 text-muted-foreground text-xs">
{item.summary}
</p>
</div>
<ChevronDownIcon
className={cn(
"mt-0.5 size-4 shrink-0 text-muted-foreground transition-transform",
!open && "-rotate-90",
)}
/>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="flex flex-col gap-3 border-t px-3 py-3 text-xs">
{item.argsText ? (
<ActivityPre title="输入参数" value={item.argsText} />
) : null}
{item.resultSummary ? (
<ActivityResultSummary value={item.resultSummary} />
) : null}
{item.fileLinks?.length ? (
<ActivityFileLinks paths={item.fileLinks} />
) : null}
{item.rawResult ? (
<Collapsible>
<CollapsibleTrigger className="flex items-center gap-1 font-medium text-muted-foreground transition-colors hover:text-foreground">
<ChevronDownIcon className="size-3" />
</CollapsibleTrigger>
<CollapsibleContent>
<ActivityPre title="" value={item.rawResult} className="mt-2" />
</CollapsibleContent>
</Collapsible>
) : null}
{item.kind === "reasoning" ? (
<p className="text-muted-foreground">
</p>
) : null}
</div>
</CollapsibleContent>
</Collapsible>
);
}
function ActivityStatusIcon({ status }: { status: ActivityItem["status"] }) {
if (status === "running") {
return <ClockIcon className="mt-0.5 size-4 shrink-0 animate-pulse" />;
}
if (status === "incomplete") {
return <XCircleIcon className="mt-0.5 size-4 shrink-0 text-destructive" />;
}
return (
<CheckCircle2Icon className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
);
}
function ActivityPre({
title,
value,
className,
}: {
title: string;
value: string;
className?: string;
}) {
return (
<div className={className}>
{title ? (
<p className="mb-1 font-medium text-muted-foreground">{title}</p>
) : null}
<pre className="max-h-80 overflow-auto rounded-md bg-muted px-3 py-2 whitespace-pre-wrap">
{value}
</pre>
</div>
);
}
function ActivityResultSummary({ value }: { value: string }) {
return (
<div className="rounded-md border bg-muted/30 px-3 py-2">
<div className="mb-1 flex items-center gap-1.5 font-medium text-muted-foreground">
<FileTextIcon className="size-3.5" />
</div>
<p className="whitespace-pre-wrap">{value}</p>
</div>
);
}
function collectActivityItems(messages: readonly MessageState[]) {
const items: ActivityItem[] = [];
for (const message of messages) {
if (message.role !== "assistant") continue;
const parts = message.content as readonly ThreadAssistantMessagePart[];
for (const [index, part] of parts.entries()) {
const id = `${message.id}:${index}`;
if (part.type === "reasoning") {
const status = getPartStatus(message, part);
items.push({
id,
kind: "reasoning",
title: status === "running" ? "思考中" : "思考完成",
summary:
part.text.trim() ||
(status === "running"
? "模型正在整理下一步行动。"
: "思考并执行完成。"),
status,
});
}
if (part.type === "tool-call") {
items.push({
id,
kind: "tool",
title: part.toolName,
summary: summarizeTool(message, part),
status: getPartStatus(message, part),
argsText: part.argsText,
result: decodeJsonString(part.result),
resultSummary: summarizeToolResult(decodeJsonString(part.result)),
rawResult:
part.result === undefined
? undefined
: formatValue(decodeJsonString(part.result)),
fileLinks: extractLocalPaths(
[
part.argsText,
part.result === undefined
? undefined
: formatValue(decodeJsonString(part.result)),
].filter(Boolean) as string[],
),
});
}
}
}
return items;
}
function ActivityFileLinks({ paths }: { paths: string[] }) {
return (
<div className="rounded-md border bg-muted/30 px-3 py-2">
<div className="mb-1 flex items-center gap-1.5 font-medium text-muted-foreground">
<FileTextIcon className="size-3.5" />
</div>
<div className="flex flex-col gap-1">
{paths.map((filePath) => (
<a
key={filePath}
href={`/api/claw/files?path=${encodeURIComponent(filePath)}`}
className="truncate text-primary hover:underline"
target="_blank"
rel="noreferrer"
title={filePath}
>
{basename(filePath)}
</a>
))}
</div>
</div>
);
}
function getPartStatus(
message: Extract<MessageState, { role: "assistant" }>,
part: ThreadAssistantMessagePart,
): ToolCallMessagePartStatus["type"] {
if (message.status.type === "incomplete") return "incomplete";
if (part.type === "tool-call" && part.result === undefined) return "running";
if (message.status.type === "running") return "running";
return "complete";
}
function summarizeTool(
message: Extract<MessageState, { role: "assistant" }>,
part: ToolCallMessagePart,
) {
const status = getPartStatus(message, part);
if (status === "running") return "调用工具中";
if (status === "incomplete") return "工具调用未完成";
if (part.result === undefined) return "已提交工具参数";
return "工具调用完成";
}
function decodeJsonString(value: unknown): unknown {
if (typeof value !== "string") return value;
try {
return JSON.parse(value);
} catch {
return value;
}
}
function formatValue(value: unknown) {
const decoded = decodeJsonString(value);
return typeof decoded === "string"
? decoded
: JSON.stringify(decoded, null, 2);
}
function summarizeToolResult(value: unknown) {
const decoded = decodeJsonString(value);
if (decoded === undefined) return undefined;
if (typeof decoded === "string") return trimText(decoded, 600);
if (!decoded || typeof decoded !== "object") return String(decoded);
const record = decoded as Record<string, unknown>;
const parts: string[] = [];
if (typeof record.tool === "string") parts.push(`工具: ${record.tool}`);
if (typeof record.ok === "boolean")
parts.push(record.ok ? "状态: 成功" : "状态: 失败");
if (typeof record.error === "string") parts.push(`错误: ${record.error}`);
if (typeof record.content === "string")
parts.push(trimText(record.content, 600));
if (!parts.length) parts.push(trimText(JSON.stringify(record, null, 2), 600));
return parts.join("\n");
}
function trimText(text: string, maxLength: number) {
const normalized = text.trim();
if (normalized.length <= maxLength) return normalized;
return `${normalized.slice(0, maxLength)}...`;
}
function extractLocalPaths(values: string[]) {
const paths = new Set<string>();
for (const value of values) {
for (const match of value.matchAll(/\/Users\/[^\s"',)]+/g)) {
paths.add(match[0]);
}
}
return [...paths];
}
function basename(filePath: string) {
return filePath.split("/").filter(Boolean).at(-1) ?? filePath;
}