"use client"; import type { MessageState, ThreadAssistantMessagePart, ToolCallMessagePart, ToolCallMessagePartStatus, } from "@assistant-ui/react"; import { useAuiState } from "@assistant-ui/react"; import { BrainIcon, CheckCircle2Icon, ChevronDownIcon, ClockIcon, CloudUploadIcon, DownloadIcon, FileTextIcon, Loader2Icon, MessageSquareTextIcon, PanelRightCloseIcon, WrenchIcon, XCircleIcon, } from "lucide-react"; import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react"; import { type ClawRunStatus, fetchLatestRunStatus, } from "@/components/assistant-ui/thread-list"; import { Button } from "@/components/ui/button"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"; import { ACTIVE_SESSION_CHANGED_EVENT, readActiveSessionId, writeActiveSessionId, } from "@/lib/claw-active-session"; import { cn } from "@/lib/utils"; type ActivityContextValue = { open: boolean; mode: "activity" | "files"; selectedId: string | null; sessionId: string | null; filesRefreshToken: number; openActivity: () => void; 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 openActivity = useCallback(() => { setMode("activity"); setSelectedId(null); setOpen(true); }, []); const openItem = useCallback((id: string) => { setMode("activity"); setSelectedId(id); setOpen(true); }, []); const openFiles = useCallback((nextSessionId?: string | null) => { setMode("files"); setSessionId(nextSessionId ?? readActiveSessionId()); setFilesRefreshToken((value) => value + 1); setOpen(true); }, []); const close = useCallback(() => setOpen(false), []); const value = useMemo( () => ({ open, mode, selectedId, sessionId, filesRefreshToken, openActivity, openItem, openFiles, close, }), [ open, mode, selectedId, sessionId, filesRefreshToken, openActivity, 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[]; }; type LiveRunEvent = NonNullable[number]; export function ActivityPanel({ asDrawer = false }: { asDrawer?: boolean } = {}) { const { open, mode, selectedId, sessionId, filesRefreshToken, openActivity, openItem, close, } = useActivityPanel(); const messages = useAuiState((s) => s.thread.messages); const [activeSessionId, setActiveSessionId] = useState(() => readActiveSessionId(), ); const liveRunStatus = useLiveRunStatus(activeSessionId, mode); const messageItems = useMemo( () => collectActivityItems(messages), [messages], ); const liveItems = useMemo( () => collectLiveRunItems(liveRunStatus), [liveRunStatus], ); const selectedMessageId = selectedId ? activityMessageId(selectedId) : null; const liveRunActive = hasActiveRun(liveRunStatus) && liveItems.length > 0; const selectedMessageItems = selectedMessageId ? messageItems.filter( (item) => activityMessageId(item.id) === selectedMessageId, ) : []; const shouldUseLiveItems = Boolean(activeSessionId && liveRunActive); const items = shouldUseLiveItems ? liveItems : messageItems; const selectedItems = selectedMessageId ? items.filter((item) => activityMessageId(item.id) === selectedMessageId) : []; const visibleItems = shouldUseLiveItems ? liveItems : selectedMessageId && selectedMessageItems.length > 0 ? selectedMessageItems : selectedMessageId && selectedItems.length > 0 ? selectedItems : items; const selectedActivityMissing = Boolean( selectedMessageId && !shouldUseLiveItems && messageItems.length > 0 && selectedMessageItems.length === 0 && selectedItems.length === 0, ); const prevLatestIdRef = useRef(null); useEffect(() => { const update = () => setActiveSessionId(readActiveSessionId()); const handleChanged = (event: Event) => { const detail = (event as CustomEvent<{ sessionId?: string | null }>) .detail; setActiveSessionId(detail?.sessionId ?? readActiveSessionId()); }; window.addEventListener(ACTIVE_SESSION_CHANGED_EVENT, handleChanged); window.addEventListener("focus", update); update(); return () => { window.removeEventListener(ACTIVE_SESSION_CHANGED_EVENT, handleChanged); window.removeEventListener("focus", update); }; }, []); useEffect(() => { const latestId = items.at(-1)?.id ?? null; if ( mode === "activity" && !selectedId && latestId && latestId !== prevLatestIdRef.current ) { openActivity(); } prevLatestIdRef.current = latestId; }, [items, mode, openActivity, selectedId]); if (!asDrawer && !open) return null; const isFilesMode = mode === "files"; const activityBody = ( <>

活动

{visibleItems.length > 0 ? `${visibleItems.length} 个步骤` : "暂无链路"}

{!asDrawer ? ( ) : null}
{visibleItems.length === 0 ? (

当前这轮对话还没有工具链路。

) : (
{selectedActivityMissing ? (

刷新后未能精确定位到这条摘要,已显示当前会话可恢复的活动链路。

) : null} {visibleItems.map((item) => ( { if (nextOpen) openItem(item.id); }} /> ))}
)}
); if (asDrawer) { return ( { if (!next) close(); }} > {isFilesMode ? "聊天中的文件" : "活动"} {isFilesMode ? ( ) : ( activityBody )} ); } if (isFilesMode) { return ( ); } return ( ); } type SessionFile = { name: string; path: string; kind: "input" | "output"; source?: "local" | "jupyter"; size: number; modified_at: string; download_url: string; online_doc_url?: string | null; online_doc_title?: string | null; online_doc_kind?: string | null; online_doc_updated_at?: number | null; }; type SessionFilesPayload = { session_id?: string | null; input?: SessionFile[]; output?: SessionFile[]; error?: string; }; function SessionFilesPanel({ sessionId, onClose, embedded = false, }: { sessionId: string | null; onClose: () => void; embedded?: boolean; }) { const [payload, setPayload] = useState(null); const [status, setStatus] = useState("正在读取聊天中的文件..."); useEffect(() => { let cancelled = false; async function loadFiles() { const activeSessionId = normalizeSessionId( sessionId ?? readActiveSessionId(), ); 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) { writeActiveSessionId(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; const body = ( <>

聊天中的文件

{status || `${total} 个文件`}

{!embedded ? ( ) : null}
{!status && total === 0 ? (

当前聊天还没有上传文件或输出文件。

) : null}
); if (embedded) { return
{body}
; } 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 }) { const [onlineDoc, setOnlineDoc] = useState<{ status: "idle" | "loading" | "auth" | "done" | "error"; message?: string; url?: string | null; kind?: string | null; }>({ status: file.online_doc_url ? "done" : "idle", message: file.online_doc_url ? `已转${onlineFileKindLabel(file.online_doc_kind, file.name)}` : undefined, url: file.online_doc_url ?? null, kind: file.online_doc_kind ?? null, }); const canCreateOnlineDoc = isOnlineDocSupported(file.name); const onlineDocUrl = onlineDoc.url ?? file.online_doc_url ?? null; async function createOnlineDoc() { if (!canCreateOnlineDoc || onlineDoc.status === "loading") return; let pendingWindow: Window | null = null; try { pendingWindow = window.open("about:blank", "_blank"); if (pendingWindow) { pendingWindow.document.write("正在创建飞书在线文件..."); } } catch { pendingWindow = null; } setOnlineDoc({ status: "loading", message: "正在转换..." }); try { const response = await fetch("/api/claw/files/online-doc", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ path: file.path, title: file.name }), }); const payload = (await response.json().catch(() => ({}))) as Record< string, unknown >; if (response.status === 409) { const loginResponse = await fetch("/api/claw/integrations/feishu", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ action: "login" }), }); const loginPayload = (await loginResponse .json() .catch(() => ({}))) as FeishuLoginPayload; if (!loginResponse.ok) { pendingWindow?.close(); setOnlineDoc({ status: "error", message: extractErrorMessage( loginPayload as unknown as Record, ) ?? "飞书授权启动失败", }); return; } const loginUrl = loginPayload.login?.login_url ?? null; if (loginUrl) { if (pendingWindow) { try { pendingWindow.opener = null; } catch { // 有些浏览器不允许改 opener,不影响后续跳转。 } pendingWindow.location.href = loginUrl; } else { window.open(loginUrl, "_blank", "noopener,noreferrer"); } } else if (pendingWindow) { pendingWindow.close(); } setOnlineDoc({ status: "auth", message: loginUrl ? "完成授权后再点一次" : "需要先完成飞书授权", }); return; } if (!response.ok) { pendingWindow?.close(); setOnlineDoc({ status: "error", message: extractErrorMessage(payload) ?? "转换失败", }); return; } const url = typeof payload.url === "string" ? payload.url : null; const kind = typeof payload.kind === "string" ? payload.kind : null; if (url) { if (pendingWindow) { try { pendingWindow.opener = null; } catch { // 有些浏览器不允许改 opener,不影响后续跳转。 } pendingWindow.location.href = url; } else { window.open(url, "_blank", "noopener,noreferrer"); } } else { pendingWindow?.close(); } setOnlineDoc({ status: "done", message: url ? `已生成${onlineFileKindLabel(kind, file.name)}` : "已生成", url, kind, }); window.dispatchEvent(new CustomEvent("claw-session-files-changed")); } catch (error) { pendingWindow?.close(); setOnlineDoc({ status: "error", message: error instanceof Error ? error.message : "转换失败", }); } } return (
{onlineDocUrl ? ( {file.name} ) : (
{file.name}
)}
{file.source === "jupyter" ? "JUPYTER · " : ""} {fileExtension(file.name).toUpperCase() || "FILE"} ·{" "} {formatBytes(file.size)}
{onlineDoc.message ? (
{onlineDoc.message}
) : null}
{canCreateOnlineDoc ? ( ) : null}
); } type FeishuLoginPayload = { login?: { login_url?: string | null; user_code?: string | null; }; }; function isOnlineDocSupported(fileName: string) { return ["csv", "docx", "md", "txt", "xlsx"].includes( fileExtension(fileName).toLowerCase(), ); } function onlineFileKindLabel( kind: string | null | undefined, fileName: string, ) { const normalized = kind?.toLowerCase(); if (normalized === "sheet") return "在线表格"; if (["csv", "xlsx"].includes(fileExtension(fileName).toLowerCase())) { return "在线表格"; } return "在线文档"; } function extractErrorMessage(payload: Record) { const detail = payload.detail; if (typeof detail === "string") return detail; if (detail && typeof detail === "object") { const message = (detail as { message?: unknown }).message; if (typeof message === "string") return message; } const error = payload.error; return typeof error === "string" ? error : null; } 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 useLiveRunStatus( sessionId: string | null, mode: ActivityContextValue["mode"], ) { const [status, setStatus] = useState(null); useEffect(() => { if (!sessionId || mode !== "activity") { setStatus(null); return; } const safeSessionId = sessionId; let cancelled = false; async function refresh() { const nextStatus = await fetchLatestRunStatus(safeSessionId); if (cancelled) return; setStatus(hasActiveRun(nextStatus) ? nextStatus : null); } refresh(); const interval = window.setInterval(refresh, 1000); return () => { cancelled = true; window.clearInterval(interval); }; }, [sessionId, mode]); return status; } function hasActiveRun( status?: ClawRunStatus | null, ): status is ClawRunStatus & { status: "queued" | "running" } { return status?.status === "queued" || status?.status === "running"; } 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 collectLiveRunItems(runStatus: ClawRunStatus | null) { if (!hasActiveRun(runStatus)) return []; const items: ActivityItem[] = []; const events = runStatus.events ?? []; const summaryLines = summarizeLiveRunEvents(runStatus, events); items.push({ id: `live:${runStatus.run_id ?? runStatus.session_id ?? "active"}:reasoning`, kind: "reasoning", title: runStatus.status === "queued" ? "排队中" : "思考中", summary: summaryLines.join("\n"), status: "running", }); const resultById = new Map(); for (const event of events) { if (event.type === "tool_result" && event.tool_call_id) { resultById.set(event.tool_call_id, event); } } const seenToolCalls = new Set(); for (const event of events) { if ( event.type !== "tool_start" || !event.tool_call_id || !event.tool_name || seenToolCalls.has(event.tool_call_id) ) { continue; } seenToolCalls.add(event.tool_call_id); const result = resultById.get(event.tool_call_id); const stageNote = typeof event.assistant_content === "string" ? event.assistant_content.trim() : ""; if (stageNote) { items.push({ id: `live:${runStatus.run_id ?? runStatus.session_id ?? "active"}:${event.tool_call_id}:stage`, kind: "reasoning", title: "阶段说明", summary: stageNote, status: result ? result.ok === false ? "incomplete" : "complete" : "running", }); } const resultSummary = typeof result?.metadata?.output_preview === "string" ? result.metadata.output_preview : result ? result.ok === false ? "工具执行失败,等待最终结果汇总。" : "工具调用完成,等待最终结果汇总。" : undefined; const input = stripClawToolMetadata( attachStageNote(event.arguments ?? {}, event.assistant_content), ); const argsText = formatToolArgs(input); items.push({ id: `live:${runStatus.run_id ?? runStatus.session_id ?? "active"}:${event.tool_call_id}`, kind: "tool", title: event.tool_name, summary: result ? result.ok === false ? "工具调用失败" : "工具调用完成" : "工具调用中", status: result ? result.ok === false ? "incomplete" : "complete" : "running", argsText, resultSummary, rawResult: resultSummary, fileLinks: extractLocalPaths( [argsText, resultSummary].filter(Boolean) as string[], ), }); } return items; } function summarizeLiveRunEvents( runStatus: ClawRunStatus, events: readonly LiveRunEvent[], ) { const lines: string[] = []; for (const event of events) { if (event.type === "run_queued") { lines.push("当前会话已有任务在执行,本轮正在排队。"); } if (event.type === "run_started") { lines.push("后端已开始执行本轮任务。"); } if (event.type === "tool_start") { const stageNote = typeof event.assistant_content === "string" ? event.assistant_content.trim() : ""; lines.push( stageNote || (event.tool_name ? `调用工具 ${event.tool_name}` : "正在调用工具"), ); } if (event.type === "tool_result") { lines.push( event.tool_name ? `工具完成 ${event.tool_name}` : "工具调用完成", ); } if (event.type === "tool_delta") { lines.push(event.tool_name ? `${event.tool_name} 输出中` : "工具输出中"); } if (event.type === "final_text_start") { lines.push("正在整理回复"); } } if (runStatus.current_stage && lines.at(-1) !== runStatus.current_stage) { lines.push(runStatus.current_stage); } if (!lines.length) { lines.push( runStatus.status === "queued" ? "当前会话已有任务在执行,本轮正在排队。" : "后端正在执行这个会话。", ); } return lines.slice(-40); } 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 attachStageNote(input: unknown, stageNote?: unknown) { const note = typeof stageNote === "string" ? stageNote.trim() : ""; if (!note) return input; if (input && typeof input === "object" && !Array.isArray(input)) { return { ...(input as Record), __claw_stage_note: note }; } return { value: input, __claw_stage_note: note }; } 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; }