Restore active run state on session replay

This commit is contained in:
武阳
2026-05-07 16:37:14 +08:00
parent 9d7d496443
commit 2ba67ead00
4 changed files with 147 additions and 2 deletions
@@ -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",
},
});
}
@@ -45,6 +45,13 @@ type ClawStoredSession = {
messages?: ClawStoredMessage[]; messages?: ClawStoredMessage[];
}; };
export type ClawRunStatus = {
run_id?: string;
session_id?: string;
status?: "idle" | "queued" | "running" | "completed" | "failed" | "cancelled";
current_stage?: string;
};
type ClawStoredToolCall = { type ClawStoredToolCall = {
id?: string; id?: string;
name?: string; name?: string;
@@ -222,9 +229,16 @@ const ClawSessionList: FC = () => {
if (!response.ok) return; if (!response.ok) return;
const payload = const payload =
(await response.json()) as ClawStoredSession; (await response.json()) as ClawStoredSession;
const runStatus = await fetchLatestRunStatus(
session.session_id,
);
replaySession( replaySession(
session.session_id, session.session_id,
toReplayRepository(payload, session.session_id), toReplayRepository(
payload,
session.session_id,
runStatus,
),
); );
} finally { } finally {
setLoadingSessionId(null); setLoadingSessionId(null);
@@ -323,6 +337,18 @@ function dedupeSessions(payload: unknown[]) {
return [...byId.values()].sort((a, b) => b.modified_at - a.modified_at); 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 { function isClawSession(value: unknown): value is ClawSession {
if (!value || typeof value !== "object") return false; if (!value || typeof value !== "object") return false;
const item = value as Partial<ClawSession>; const item = value as Partial<ClawSession>;
@@ -334,6 +360,7 @@ function isClawSession(value: unknown): value is ClawSession {
export function toReplayRepository( export function toReplayRepository(
session: ClawStoredSession, session: ClawStoredSession,
fallbackSessionId: string, fallbackSessionId: string,
runStatus?: ClawRunStatus | null,
): ExportedMessageRepository { ): ExportedMessageRepository {
const messages: ExportedMessageRepository["messages"] = []; const messages: ExportedMessageRepository["messages"] = [];
const toolResults = collectToolResults(session.messages ?? []); const toolResults = collectToolResults(session.messages ?? []);
@@ -393,12 +420,49 @@ export function toReplayRepository(
messages.push({ message: threadMessage, parentId: previousId }); messages.push({ message: threadMessage, parentId: previousId });
previousId = id; 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 { return {
headId: previousId, headId: previousId,
messages, messages,
}; };
} }
function isActiveRunStatus(
runStatus?: ClawRunStatus | null,
): runStatus is ClawRunStatus {
return runStatus?.status === "queued" || runStatus?.status === "running";
}
function findLastAssistantContentIndex(messages: readonly ClawStoredMessage[]) { function findLastAssistantContentIndex(messages: readonly ClawStoredMessage[]) {
for (let index = messages.length - 1; index >= 0; index -= 1) { for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index]; const message = messages[index];
@@ -42,6 +42,10 @@ import {
} from "@/components/assistant-ui/attachment"; } from "@/components/assistant-ui/attachment";
import { MarkdownText } from "@/components/assistant-ui/markdown-text"; import { MarkdownText } from "@/components/assistant-ui/markdown-text";
import { Reasoning } from "@/components/assistant-ui/reasoning"; 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 { ToolFallback } from "@/components/assistant-ui/tool-fallback";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -58,6 +62,7 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { useClawSessionReplay } from "@/lib/claw-session-replay";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type ComposerInsertEvent = CustomEvent<{ text: string }>; type ComposerInsertEvent = CustomEvent<{ text: string }>;
@@ -110,6 +115,7 @@ type ContextBudget = {
}; };
export const Thread: FC = () => { export const Thread: FC = () => {
useRefreshReplayedRun();
return ( return (
<ThreadPrimitive.Root <ThreadPrimitive.Root
className="aui-root aui-thread-root @container flex h-full flex-col bg-background" className="aui-root aui-thread-root @container flex h-full flex-col bg-background"
@@ -149,6 +155,52 @@ export const Thread: FC = () => {
); );
}; };
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 ChatTopActions: FC = () => {
const { openFiles } = useActivityPanel(); const { openFiles } = useActivityPanel();
const latestSessionId = useAuiState((s) => const latestSessionId = useAuiState((s) =>
@@ -18,6 +18,7 @@ import type { ClawAccount } from "@/app/claw-account-gate";
import { ClawLlmSettings } from "@/app/claw-llm-settings"; import { ClawLlmSettings } from "@/app/claw-llm-settings";
import { ClawThemeSwitcher } from "@/app/claw-theme-switcher"; import { ClawThemeSwitcher } from "@/app/claw-theme-switcher";
import { import {
fetchLatestRunStatus,
ThreadList, ThreadList,
toReplayRepository, toReplayRepository,
} from "@/components/assistant-ui/thread-list"; } from "@/components/assistant-ui/thread-list";
@@ -367,9 +368,10 @@ function useSidebarSessions(open: boolean) {
}); });
if (!response.ok) return; if (!response.ok) return;
const payload = (await response.json()) as ReplaySessionPayload; const payload = (await response.json()) as ReplaySessionPayload;
const runStatus = await fetchLatestRunStatus(cleanSessionId);
replaySession( replaySession(
cleanSessionId, cleanSessionId,
toReplayRepository(payload, cleanSessionId), toReplayRepository(payload, cleanSessionId, runStatus),
); );
} finally { } finally {
setLoadingSessionId(null); setLoadingSessionId(null);