"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(null); export function ActivityProvider({ children }: { children: ReactNode }) { const [open, setOpen] = useState(false); const [selectedId, setSelectedId] = useState(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 ( {children} ); } 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 ( ); } 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 (
{item.title}

{item.summary}

{item.argsText ? ( ) : null} {item.resultSummary ? ( ) : null} {item.fileLinks?.length ? ( ) : null} {item.rawResult ? ( 原始结果 ) : null} {item.kind === "reasoning" ? (

这里展示的是思考状态摘要,不展示模型完整隐藏思考过程。

) : null}
); } function ActivityStatusIcon({ status }: { status: ActivityItem["status"] }) { if (status === "running") { return ; } if (status === "incomplete") { return ; } return ( ); } function ActivityPre({ title, value, className, }: { title: string; value: string; className?: string; }) { return (
{title ? (

{title}

) : null}
				{value}
			
); } function ActivityResultSummary({ value }: { value: string }) { return (
结果摘要

{value}

); } 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 (
相关文件
{paths.map((filePath) => ( {basename(filePath)} ))}
); } function getPartStatus( message: Extract, 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, 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; 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(); 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; }