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

690 lines
19 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,
DownloadIcon,
FileTextIcon,
MessageSquareTextIcon,
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;
mode: "activity" | "files";
selectedId: string | null;
sessionId: string | null;
openItem: (id: string) => void;
openFiles: (sessionId?: string | null) => void;
close: () => void;
};
const ActivityContext = createContext<ActivityContextValue | null>(null);
export function ActivityProvider({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false);
const [mode, setMode] = useState<"activity" | "files">("activity");
const [selectedId, setSelectedId] = useState<string | null>(null);
const [sessionId, setSessionId] = useState<string | null>(null);
const openItem = useCallback((id: string) => {
setMode("activity");
setSelectedId(id);
setOpen(true);
}, []);
const openFiles = useCallback((nextSessionId?: string | null) => {
setMode("files");
setSessionId(
nextSessionId ??
(typeof window !== "undefined"
? window.localStorage.getItem("claw.activeSessionId")
: null),
);
setOpen(true);
}, []);
const close = useCallback(() => setOpen(false), []);
const value = useMemo(
() => ({
open,
mode,
selectedId,
sessionId,
openItem,
openFiles,
close,
}),
[open, mode, selectedId, sessionId, openItem, openFiles, 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, mode, selectedId, sessionId, 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 (mode === "activity" && items.length > prevCountRef.current) {
openItem(items.at(-1)?.id ?? items[0]?.id ?? "");
}
prevCountRef.current = items.length;
}, [items, mode, openItem]);
if (!open) return null;
if (mode === "files") {
return <SessionFilesPanel sessionId={sessionId} onClose={close} />;
}
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>
);
}
type SessionFile = {
name: string;
path: string;
kind: "input" | "output";
size: number;
modified_at: string;
download_url: string;
};
type SessionFilesPayload = {
input?: SessionFile[];
output?: SessionFile[];
error?: string;
};
function SessionFilesPanel({
sessionId,
onClose,
}: {
sessionId: string | null;
onClose: () => void;
}) {
const [payload, setPayload] = useState<SessionFilesPayload | null>(null);
const [status, setStatus] = useState("正在读取聊天中的文件...");
useEffect(() => {
let cancelled = false;
async function loadFiles() {
const activeSessionId =
sessionId ?? window.localStorage.getItem("claw.activeSessionId");
if (!activeSessionId) {
setPayload({ input: [], output: [] });
setStatus("当前还没有可用会话。");
return;
}
setStatus("正在读取聊天中的文件...");
try {
const url = new URL("/api/claw/files", window.location.origin);
url.searchParams.set("session_id", activeSessionId);
const response = await fetch(url, { cache: "no-store" });
const nextPayload = (await response.json()) as SessionFilesPayload;
if (cancelled) return;
if (!response.ok) {
setPayload({ input: [], output: [], error: nextPayload.error });
setStatus(nextPayload.error ?? "读取文件失败");
return;
}
setPayload(nextPayload);
setStatus("");
} catch (err) {
if (!cancelled) {
setPayload({ input: [], output: [] });
setStatus(err instanceof Error ? err.message : "读取文件失败");
}
}
}
loadFiles();
return () => {
cancelled = true;
};
}, [sessionId]);
const inputFiles = payload?.input ?? [];
const outputFiles = payload?.output ?? [];
const total = inputFiles.length + outputFiles.length;
return (
<aside className="fixed inset-y-0 right-0 z-30 flex w-[min(25rem,calc(100vw-1rem))] flex-col border-l bg-background shadow-xl lg:static lg:z-auto lg:h-full lg:w-96 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">
{status || `${total} 个文件`}
</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8"
onClick={onClose}
aria-label="关闭文件面板"
>
<PanelRightCloseIcon className="size-4" />
</Button>
</header>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
{!status && total === 0 ? (
<p className="text-muted-foreground text-sm">
</p>
) : null}
<FileSection title="输入" files={inputFiles} />
<FileSection title="输出" files={outputFiles} />
</div>
</aside>
);
}
function FileSection({
title,
files,
}: {
title: string;
files: SessionFile[];
}) {
return (
<section className="mb-5">
<div className="mb-2 flex items-center justify-between">
<h3 className="font-medium text-sm">{title}</h3>
<span className="text-muted-foreground text-xs">{files.length}</span>
</div>
{files.length ? (
<div className="flex flex-col gap-2">
{files.map((file) => (
<SessionFileRow key={`${file.kind}:${file.path}`} file={file} />
))}
</div>
) : (
<p className="rounded-lg border border-dashed px-3 py-4 text-center text-muted-foreground text-xs">
{title}
</p>
)}
</section>
);
}
function SessionFileRow({ file }: { file: SessionFile }) {
return (
<div className="flex items-center gap-3 rounded-lg border px-3 py-2">
<div className="flex size-8 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
<FileTextIcon className="size-4" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-sm" title={file.name}>
{file.name}
</div>
<div className="text-muted-foreground text-xs">
{fileExtension(file.name).toUpperCase() || "FILE"} ·{" "}
{formatBytes(file.size)}
</div>
</div>
<a
href={file.download_url}
className="flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label={`下载 ${file.name}`}
title="下载"
>
<DownloadIcon className="size-4" />
</a>
<button
type="button"
className="flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label={`针对 ${file.name} 提问`}
title="针对文件提问"
onClick={() => askAboutFile(file)}
>
<MessageSquareTextIcon className="size-4" />
</button>
</div>
);
}
function askAboutFile(file: SessionFile) {
window.dispatchEvent(
new CustomEvent("claw-composer-insert", {
detail: {
text: [
`请针对这个${file.kind === "input" ? "输入" : "输出"}文件回答我的问题:`,
`- 文件名: ${file.name}`,
`- 本地路径: ${file.path}`,
"请先使用 read_file 工具读取文件内容。",
].join("\n"),
},
}),
);
}
function fileExtension(fileName: string) {
const ext = fileName.split(".").at(-1);
return ext && ext !== fileName ? ext : "";
}
function formatBytes(bytes: number) {
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${bytes} B`;
}
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);
const summary =
part.text.trim() ||
(status === "running"
? "模型正在整理下一步行动。"
: "思考并执行完成。");
items.push({
id,
kind: "reasoning",
title: reasoningTitle(summary, status),
summary,
status,
});
}
if (part.type === "tool-call") {
const input = stripStageNote(part.args);
const stageNote = getStageNote(part.args);
const argsText = formatToolArgs(input);
if (stageNote) {
items.push({
id: `${id}:stage`,
kind: "reasoning",
title: "阶段说明",
summary: stageNote,
status: getPartStatus(message, part),
});
}
items.push({
id,
kind: "tool",
title: part.toolName,
summary: summarizeTool(message, part),
status: getPartStatus(message, part),
argsText,
result: decodeJsonString(part.result),
resultSummary: summarizeToolResult(decodeJsonString(part.result)),
rawResult:
part.result === undefined
? undefined
: formatValue(decodeJsonString(part.result)),
fileLinks: extractLocalPaths(
[
argsText,
part.result === undefined
? undefined
: formatValue(decodeJsonString(part.result)),
].filter(Boolean) as string[],
),
});
}
}
}
return items;
}
function getStageNote(value: unknown) {
if (!value || typeof value !== "object" || Array.isArray(value)) return "";
const note = (value as Record<string, unknown>).__claw_stage_note;
return typeof note === "string" ? note.trim() : "";
}
function stripStageNote(value: unknown): unknown {
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
const { __claw_stage_note: _stageNote, ...rest } = value as Record<
string,
unknown
>;
return rest;
}
function formatToolArgs(value: unknown) {
if (value === undefined) return undefined;
if (typeof value === "string") return value;
return JSON.stringify(value ?? {}, null, 2);
}
function reasoningTitle(
text: string,
status: ToolCallMessagePartStatus["type"],
) {
if (status === "running") return "思考中";
const normalized = text.trim();
if (
normalized.startsWith("思考中") ||
normalized.startsWith("思考并执行完成")
) {
return "思考完成";
}
return "阶段说明";
}
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;
}