"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; filesRefreshToken: number; openItem: (id: string) => void; openFiles: (sessionId?: string | null) => void; close: () => void; }; const ActivityContext = createContext(null); export function ActivityProvider({ children }: { children: ReactNode }) { const [open, setOpen] = useState(false); const [mode, setMode] = useState<"activity" | "files">("activity"); const [selectedId, setSelectedId] = useState(null); const [sessionId, setSessionId] = useState(null); const [filesRefreshToken, setFilesRefreshToken] = useState(0); 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), ); setFilesRefreshToken((value) => value + 1); setOpen(true); }, []); const close = useCallback(() => setOpen(false), []); const value = useMemo( () => ({ open, mode, selectedId, sessionId, filesRefreshToken, openItem, openFiles, close, }), [ open, mode, selectedId, sessionId, filesRefreshToken, openItem, openFiles, 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, mode, selectedId, sessionId, filesRefreshToken, 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 prevLatestIdRef = useRef(null); useEffect(() => { const latestId = items.at(-1)?.id ?? null; if ( mode === "activity" && latestId && latestId !== prevLatestIdRef.current ) { openItem(latestId); } prevLatestIdRef.current = latestId; }, [items, mode, openItem]); if (!open) return null; if (mode === "files") { return ( ); } return ( ); } type SessionFile = { name: string; path: string; kind: "input" | "output"; size: number; modified_at: string; download_url: string; }; type SessionFilesPayload = { session_id?: string | null; input?: SessionFile[]; output?: SessionFile[]; error?: string; }; function SessionFilesPanel({ sessionId, onClose, }: { sessionId: string | null; onClose: () => void; }) { const [payload, setPayload] = useState(null); const [status, setStatus] = useState("正在读取聊天中的文件..."); useEffect(() => { let cancelled = false; async function loadFiles() { const activeSessionId = normalizeSessionId( sessionId ?? window.localStorage.getItem("claw.activeSessionId"), ); setStatus("正在读取聊天中的文件..."); try { let { response, payload: nextPayload } = await fetchSessionFiles(activeSessionId); if ( activeSessionId && response.ok && sessionFilesCount(nextPayload) === 0 ) { const latest = await fetchSessionFiles(null); if ( latest.response.ok && latest.payload.session_id && latest.payload.session_id !== activeSessionId ) { response = latest.response; nextPayload = latest.payload; } } if (cancelled) return; if (!response.ok) { setPayload({ input: [], output: [], error: nextPayload.error }); setStatus(nextPayload.error ?? "读取文件失败"); return; } if (nextPayload.session_id) { window.localStorage.setItem( "claw.activeSessionId", nextPayload.session_id, ); } setPayload(nextPayload); setStatus(""); } catch (err) { if (!cancelled) { setPayload({ input: [], output: [] }); setStatus(err instanceof Error ? err.message : "读取文件失败"); } } } loadFiles(); const handleRefresh = () => { loadFiles(); }; window.addEventListener("claw-sessions-changed", handleRefresh); window.addEventListener("claw-session-files-changed", handleRefresh); window.addEventListener("focus", handleRefresh); return () => { cancelled = true; window.removeEventListener("claw-sessions-changed", handleRefresh); window.removeEventListener("claw-session-files-changed", handleRefresh); window.removeEventListener("focus", handleRefresh); }; }, [sessionId]); const inputFiles = payload?.input ?? []; const outputFiles = payload?.output ?? []; const total = inputFiles.length + outputFiles.length; return ( ); } async function fetchSessionFiles(sessionId: string | null) { const url = new URL("/api/claw/files", window.location.origin); if (sessionId) url.searchParams.set("session_id", sessionId); const response = await fetch(url, { cache: "no-store" }); const payload = (await response.json()) as SessionFilesPayload; return { response, payload }; } function sessionFilesCount(payload: SessionFilesPayload) { return (payload.input?.length ?? 0) + (payload.output?.length ?? 0); } function normalizeSessionId(value: unknown) { if (typeof value !== "string") return null; const trimmed = value.trim(); return trimmed || null; } function FileSection({ title, files, }: { title: string; files: SessionFile[]; }) { return (

{title}

{files.length}
{files.length ? (
{files.map((file) => ( ))}
) : (

暂无{title}文件

)}
); } function SessionFileRow({ file }: { file: SessionFile }) { return (
{file.name}
{fileExtension(file.name).toUpperCase() || "FILE"} ·{" "} {formatBytes(file.size)}
); } 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 (
{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); 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 = stripClawToolMetadata(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).__claw_stage_note; return typeof note === "string" ? note.trim() : ""; } function stripClawToolMetadata(value: unknown): unknown { if (!value || typeof value !== "object" || Array.isArray(value)) return value; const { __claw_stage_note: _stageNote, __claw_elapsed_ms: _elapsedMs, ...rest } = value as Record; 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 (
相关文件
{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; }