diff --git a/frontend/app/components/assistant-ui/activity-panel.tsx b/frontend/app/components/assistant-ui/activity-panel.tsx index 2da6c7d..e3339db 100644 --- a/frontend/app/components/assistant-ui/activity-panel.tsx +++ b/frontend/app/components/assistant-ui/activity-panel.tsx @@ -31,6 +31,10 @@ import { useRef, useState, } from "react"; +import { + type ClawRunStatus, + fetchLatestRunStatus, +} from "@/components/assistant-ui/thread-list"; import { Button } from "@/components/ui/button"; import { Collapsible, @@ -38,6 +42,7 @@ import { CollapsibleTrigger, } from "@/components/ui/collapsible"; import { + ACTIVE_SESSION_CHANGED_EVENT, readActiveSessionId, writeActiveSessionId, } from "@/lib/claw-active-session"; @@ -133,6 +138,8 @@ type ActivityItem = { fileLinks?: string[]; }; +type LiveRunEvent = NonNullable[number]; + export function ActivityPanel() { const { open, @@ -145,13 +152,49 @@ export function ActivityPanel() { close, } = useActivityPanel(); const messages = useAuiState((s) => s.thread.messages); - const items = useMemo(() => collectActivityItems(messages), [messages]); + const [activeSessionId, setActiveSessionId] = useState(() => + readActiveSessionId(), + ); + const liveRunStatus = useLiveRunStatus(activeSessionId, mode); + const replayedSessionId = useMemo( + () => findLatestSessionIdInMessages(messages), + [messages], + ); + const messageItems = useMemo( + () => collectActivityItems(messages), + [messages], + ); + const liveItems = useMemo( + () => collectLiveRunItems(liveRunStatus), + [liveRunStatus], + ); + const shouldUseLiveItems = + liveItems.length > 0 && + activeSessionId !== null && + (activeSessionId !== replayedSessionId || hasActiveRun(liveRunStatus)); + const items = shouldUseLiveItems ? liveItems : messageItems; const selectedMessageId = selectedId ? activityMessageId(selectedId) : null; const visibleItems = selectedMessageId ? items.filter((item) => activityMessageId(item.id) === selectedMessageId) : items; 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 ( @@ -657,6 +700,60 @@ 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 findLatestSessionIdInMessages(messages: readonly MessageState[]) { + for (const message of [...messages].reverse()) { + const metadata = message.metadata; + const custom = + metadata && typeof metadata === "object" && "custom" in metadata + ? (metadata as { custom?: unknown }).custom + : null; + if (custom && typeof custom === "object" && "sessionId" in custom) { + const sessionId = (custom as { sessionId?: unknown }).sessionId; + if (typeof sessionId === "string" && sessionId.trim()) { + return sessionId.trim(); + } + } + } + return null; +} + function ActivityPanelItem({ item, open, @@ -770,6 +867,118 @@ function ActivityResultSummary({ value }: { value: string }) { ); } +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 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 === "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[] = []; @@ -841,6 +1050,15 @@ function getStageNote(value: unknown) { 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 { diff --git a/frontend/app/lib/claw-active-session.ts b/frontend/app/lib/claw-active-session.ts index 0995a71..5227b98 100644 --- a/frontend/app/lib/claw-active-session.ts +++ b/frontend/app/lib/claw-active-session.ts @@ -1,6 +1,7 @@ "use client"; const ACTIVE_SESSION_ID_KEY = "claw.activeSessionId"; +export const ACTIVE_SESSION_CHANGED_EVENT = "claw-active-session-changed"; export function readActiveSessionId() { if (typeof window === "undefined") return null; @@ -19,12 +20,22 @@ export function writeActiveSessionId(sessionId: string | null | undefined) { } window.sessionStorage.setItem(ACTIVE_SESSION_ID_KEY, normalized); window.localStorage.setItem(ACTIVE_SESSION_ID_KEY, normalized); + window.dispatchEvent( + new CustomEvent(ACTIVE_SESSION_CHANGED_EVENT, { + detail: { sessionId: normalized }, + }), + ); } export function clearActiveSessionId() { if (typeof window === "undefined") return; window.sessionStorage.removeItem(ACTIVE_SESSION_ID_KEY); window.localStorage.removeItem(ACTIVE_SESSION_ID_KEY); + window.dispatchEvent( + new CustomEvent(ACTIVE_SESSION_CHANGED_EVENT, { + detail: { sessionId: null }, + }), + ); } function normalizeSessionId(value: unknown) {