Add assistant-ui data agent frontend

This commit is contained in:
武阳
2026-05-06 16:18:32 +08:00
parent d6a2359dc1
commit 7d4ae3e7ba
119 changed files with 14330 additions and 14420 deletions
@@ -0,0 +1,415 @@
"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 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">
{items.length > 0 ? `${items.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">
{items.length === 0 ? (
<p className="text-muted-foreground text-sm">
</p>
) : (
<div className="flex flex-col gap-2">
{items.map((item) => (
<ActivityPanelItem
key={item.id}
item={item}
open={selectedId === item.id}
onOpenChange={(nextOpen) => {
if (nextOpen) openItem(item.id);
}}
/>
))}
</div>
)}
</div>
</aside>
);
}
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;
}