Add assistant-ui data agent frontend
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user