From 2ba67ead00382f9f095edbc09c91aeb855931251 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=AD=A6=E9=98=B3?= Date: Thu, 7 May 2026 16:37:14 +0800 Subject: [PATCH] Restore active run state on session replay --- .../app/app/api/claw/runs/latest/route.ts | 27 ++++++++ .../components/assistant-ui/thread-list.tsx | 66 ++++++++++++++++++- .../app/components/assistant-ui/thread.tsx | 52 +++++++++++++++ .../assistant-ui/threadlist-sidebar.tsx | 4 +- 4 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 frontend/app/app/api/claw/runs/latest/route.ts diff --git a/frontend/app/app/api/claw/runs/latest/route.ts b/frontend/app/app/api/claw/runs/latest/route.ts new file mode 100644 index 0000000..2ed3287 --- /dev/null +++ b/frontend/app/app/api/claw/runs/latest/route.ts @@ -0,0 +1,27 @@ +import { getCurrentAccount } from "@/lib/claw-auth"; + +const CLAW_API_URL = process.env.CLAW_API_URL ?? "http://127.0.0.1:8765"; + +export async function GET(req: Request) { + const account = await getCurrentAccount(); + if (!account) + return Response.json({ error: "请先登录账号" }, { status: 401 }); + + const incoming = new URL(req.url); + const sessionId = incoming.searchParams.get("session_id")?.trim(); + if (!sessionId) + return Response.json({ error: "Missing session_id" }, { status: 400 }); + + const url = new URL(`${CLAW_API_URL}/api/runs/latest`); + url.searchParams.set("account_id", account.id); + url.searchParams.set("session_id", sessionId); + const response = await fetch(url, { cache: "no-store" }); + const payload = await response.text(); + return new Response(payload, { + status: response.status, + headers: { + "content-type": + response.headers.get("content-type") ?? "application/json", + }, + }); +} diff --git a/frontend/app/components/assistant-ui/thread-list.tsx b/frontend/app/components/assistant-ui/thread-list.tsx index 3ca03d2..98e4136 100644 --- a/frontend/app/components/assistant-ui/thread-list.tsx +++ b/frontend/app/components/assistant-ui/thread-list.tsx @@ -45,6 +45,13 @@ type ClawStoredSession = { messages?: ClawStoredMessage[]; }; +export type ClawRunStatus = { + run_id?: string; + session_id?: string; + status?: "idle" | "queued" | "running" | "completed" | "failed" | "cancelled"; + current_stage?: string; +}; + type ClawStoredToolCall = { id?: string; name?: string; @@ -222,9 +229,16 @@ const ClawSessionList: FC = () => { if (!response.ok) return; const payload = (await response.json()) as ClawStoredSession; + const runStatus = await fetchLatestRunStatus( + session.session_id, + ); replaySession( session.session_id, - toReplayRepository(payload, session.session_id), + toReplayRepository( + payload, + session.session_id, + runStatus, + ), ); } finally { setLoadingSessionId(null); @@ -323,6 +337,18 @@ function dedupeSessions(payload: unknown[]) { return [...byId.values()].sort((a, b) => b.modified_at - a.modified_at); } +export async function fetchLatestRunStatus(sessionId: string) { + try { + const url = new URL("/api/claw/runs/latest", window.location.origin); + url.searchParams.set("session_id", sessionId); + const response = await fetch(url, { cache: "no-store" }); + if (!response.ok) return null; + return (await response.json()) as ClawRunStatus; + } catch { + return null; + } +} + function isClawSession(value: unknown): value is ClawSession { if (!value || typeof value !== "object") return false; const item = value as Partial; @@ -334,6 +360,7 @@ function isClawSession(value: unknown): value is ClawSession { export function toReplayRepository( session: ClawStoredSession, fallbackSessionId: string, + runStatus?: ClawRunStatus | null, ): ExportedMessageRepository { const messages: ExportedMessageRepository["messages"] = []; const toolResults = collectToolResults(session.messages ?? []); @@ -393,12 +420,49 @@ export function toReplayRepository( messages.push({ message: threadMessage, parentId: previousId }); previousId = id; } + if (isActiveRunStatus(runStatus)) { + const id = `${fallbackSessionId}-run-${runStatus.run_id ?? "active"}`; + const text = + runStatus.status === "queued" + ? "当前会话已有任务在执行,本轮正在排队。" + : runStatus.current_stage || "后端正在执行这个会话。"; + const parts = [{ type: "reasoning", text }] as UIMessage["parts"]; + const uiMessage: UIMessage = { + id, + role: "assistant", + parts, + metadata: { sessionId: session.session_id ?? fallbackSessionId }, + } as UIMessage; + const threadMessage = { + id, + role: "assistant", + createdAt: new Date(), + content: toThreadMessageContent(parts), + status: { type: "running" }, + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: { sessionId: session.session_id ?? fallbackSessionId }, + }, + } as ThreadMessage; + bindExternalStoreMessage(threadMessage, uiMessage); + messages.push({ message: threadMessage, parentId: previousId }); + previousId = id; + } return { headId: previousId, messages, }; } +function isActiveRunStatus( + runStatus?: ClawRunStatus | null, +): runStatus is ClawRunStatus { + return runStatus?.status === "queued" || runStatus?.status === "running"; +} + function findLastAssistantContentIndex(messages: readonly ClawStoredMessage[]) { for (let index = messages.length - 1; index >= 0; index -= 1) { const message = messages[index]; diff --git a/frontend/app/components/assistant-ui/thread.tsx b/frontend/app/components/assistant-ui/thread.tsx index 143f9e7..13a967e 100644 --- a/frontend/app/components/assistant-ui/thread.tsx +++ b/frontend/app/components/assistant-ui/thread.tsx @@ -42,6 +42,10 @@ import { } from "@/components/assistant-ui/attachment"; import { MarkdownText } from "@/components/assistant-ui/markdown-text"; import { Reasoning } from "@/components/assistant-ui/reasoning"; +import { + fetchLatestRunStatus, + toReplayRepository, +} from "@/components/assistant-ui/thread-list"; import { ToolFallback } from "@/components/assistant-ui/tool-fallback"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { Button } from "@/components/ui/button"; @@ -58,6 +62,7 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { useClawSessionReplay } from "@/lib/claw-session-replay"; import { cn } from "@/lib/utils"; type ComposerInsertEvent = CustomEvent<{ text: string }>; @@ -110,6 +115,7 @@ type ContextBudget = { }; export const Thread: FC = () => { + useRefreshReplayedRun(); return ( { ); }; +function useRefreshReplayedRun() { + const { replaySession } = useClawSessionReplay(); + const isRunning = useAuiState((s) => s.thread.isRunning); + const hasReplayedRunMessage = useAuiState((s) => + s.thread.messages.some((message) => message.id.includes("-run-")), + ); + const latestSessionId = useAuiState((s) => + findLatestMessageMetadataValue(s.thread.messages, "sessionId"), + ); + + useEffect(() => { + if (!isRunning || !hasReplayedRunMessage) return; + const sessionId = normalizeSessionId( + latestSessionId ?? window.localStorage.getItem("claw.activeSessionId"), + ); + if (!sessionId) return; + const activeSessionId = sessionId; + let cancelled = false; + + async function refreshIfFinished() { + const runStatus = await fetchLatestRunStatus(activeSessionId); + if (cancelled) return; + if (runStatus?.status === "queued" || runStatus?.status === "running") { + return; + } + const response = await fetch(`/api/claw/sessions/${activeSessionId}`, { + cache: "no-store", + }); + if (!response.ok || cancelled) return; + const payload = await response.json(); + replaySession( + activeSessionId, + toReplayRepository(payload, activeSessionId, runStatus), + ); + window.dispatchEvent(new Event("claw-sessions-changed")); + } + + refreshIfFinished(); + const interval = window.setInterval(refreshIfFinished, 3000); + return () => { + cancelled = true; + window.clearInterval(interval); + }; + }, [hasReplayedRunMessage, isRunning, latestSessionId, replaySession]); +} + const ChatTopActions: FC = () => { const { openFiles } = useActivityPanel(); const latestSessionId = useAuiState((s) => diff --git a/frontend/app/components/assistant-ui/threadlist-sidebar.tsx b/frontend/app/components/assistant-ui/threadlist-sidebar.tsx index 6bf3065..df956fa 100644 --- a/frontend/app/components/assistant-ui/threadlist-sidebar.tsx +++ b/frontend/app/components/assistant-ui/threadlist-sidebar.tsx @@ -18,6 +18,7 @@ import type { ClawAccount } from "@/app/claw-account-gate"; import { ClawLlmSettings } from "@/app/claw-llm-settings"; import { ClawThemeSwitcher } from "@/app/claw-theme-switcher"; import { + fetchLatestRunStatus, ThreadList, toReplayRepository, } from "@/components/assistant-ui/thread-list"; @@ -367,9 +368,10 @@ function useSidebarSessions(open: boolean) { }); if (!response.ok) return; const payload = (await response.json()) as ReplaySessionPayload; + const runStatus = await fetchLatestRunStatus(cleanSessionId); replaySession( cleanSessionId, - toReplayRepository(payload, cleanSessionId), + toReplayRepository(payload, cleanSessionId, runStatus), ); } finally { setLoadingSessionId(null);