Restore active run state on session replay
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user