diff --git a/backend/api/server.py b/backend/api/server.py index 5413d96..39fb5bf 100644 --- a/backend/api/server.py +++ b/backend/api/server.py @@ -276,8 +276,7 @@ class RunManager: def snapshot_latest(self, account_key: str, session_id: str) -> dict[str, Any] | None: with self._lock: - run_id = self._latest_by_session.get((account_key, session_id)) - record = self._runs.get(run_id or '') + record = self._select_session_record(account_key, session_id) if record is None: return None return { @@ -290,6 +289,33 @@ class RunManager: 'error': record.error, } + def _select_session_record( + self, + account_key: str, + session_id: str, + ) -> RunRecord | None: + records = [ + record + for record in self._runs.values() + if record.account_key == account_key and record.session_id == session_id + ] + running = [ + record + for record in records + if record.status == 'running' + ] + if running: + return max(running, key=lambda item: item.updated_at) + queued = [ + record + for record in records + if record.status == 'queued' + ] + if queued: + return max(queued, key=lambda item: item.started_at) + run_id = self._latest_by_session.get((account_key, session_id)) + return self._runs.get(run_id or '') + class AgentState: """Holds account-scoped agent instances, config, and execution locks.""" diff --git a/frontend/app/app/api/claw/runs/cancel/route.ts b/frontend/app/app/api/claw/runs/cancel/route.ts new file mode 100644 index 0000000..cc40beb --- /dev/null +++ b/frontend/app/app/api/claw/runs/cancel/route.ts @@ -0,0 +1,37 @@ +import { getCurrentAccount } from "@/lib/claw-auth"; + +const CLAW_API_URL = process.env.CLAW_API_URL ?? "http://127.0.0.1:8765"; + +export async function POST(req: Request) { + const account = await getCurrentAccount(); + if (!account) + return Response.json({ error: "请先登录账号" }, { status: 401 }); + + const body = (await req.json().catch(() => ({}))) as { + session_id?: unknown; + run_id?: unknown; + }; + const sessionId = + typeof body.session_id === "string" ? body.session_id.trim() : ""; + if (!sessionId) + return Response.json({ error: "Missing session_id" }, { status: 400 }); + + const runId = typeof body.run_id === "string" ? body.run_id.trim() : ""; + const response = await fetch(`${CLAW_API_URL}/api/runs/cancel`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + account_id: account.id, + session_id: sessionId, + ...(runId ? { run_id: runId } : {}), + }), + }); + 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 98e4136..8e23a81 100644 --- a/frontend/app/components/assistant-ui/thread-list.tsx +++ b/frontend/app/components/assistant-ui/thread-list.tsx @@ -50,6 +50,7 @@ export type ClawRunStatus = { session_id?: string; status?: "idle" | "queued" | "running" | "completed" | "failed" | "cancelled"; current_stage?: string; + started_at?: number; }; type ClawStoredToolCall = { @@ -431,7 +432,11 @@ export function toReplayRepository( id, role: "assistant", parts, - metadata: { sessionId: session.session_id ?? fallbackSessionId }, + metadata: { + sessionId: session.session_id ?? fallbackSessionId, + runId: runStatus.run_id, + runStartedAtMs: normalizeRunTimestampMs(runStatus.started_at), + }, } as UIMessage; const threadMessage = { id, @@ -444,7 +449,11 @@ export function toReplayRepository( unstable_annotations: [], unstable_data: [], steps: [], - custom: { sessionId: session.session_id ?? fallbackSessionId }, + custom: { + sessionId: session.session_id ?? fallbackSessionId, + runId: runStatus.run_id, + runStartedAtMs: normalizeRunTimestampMs(runStatus.started_at), + }, }, } as ThreadMessage; bindExternalStoreMessage(threadMessage, uiMessage); @@ -463,6 +472,11 @@ function isActiveRunStatus( return runStatus?.status === "queued" || runStatus?.status === "running"; } +function normalizeRunTimestampMs(value: unknown) { + if (typeof value !== "number" || !Number.isFinite(value)) return undefined; + return value < 10_000_000_000 ? Math.round(value * 1000) : Math.round(value); +} + 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 13a967e..a6c30e0 100644 --- a/frontend/app/components/assistant-ui/thread.tsx +++ b/frontend/app/components/assistant-ui/thread.tsx @@ -157,21 +157,11 @@ 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"), - ); + const replayedRun = useReplayedRunState(); useEffect(() => { - if (!isRunning || !hasReplayedRunMessage) return; - const sessionId = normalizeSessionId( - latestSessionId ?? window.localStorage.getItem("claw.activeSessionId"), - ); - if (!sessionId) return; - const activeSessionId = sessionId; + if (!replayedRun.active || !replayedRun.sessionId) return; + const activeSessionId = replayedRun.sessionId; let cancelled = false; async function refreshIfFinished() { @@ -198,7 +188,41 @@ function useRefreshReplayedRun() { cancelled = true; window.clearInterval(interval); }; - }, [hasReplayedRunMessage, isRunning, latestSessionId, replaySession]); + }, [replayedRun.active, replayedRun.sessionId, replaySession]); +} + +function useReplayedRunState() { + return useAuiState((s) => { + const replayRunMessage = s.thread.messages.find((message) => + isReplayRunMessageId(message.id), + ); + const sessionId = normalizeSessionId( + readMetadataValue(replayRunMessage?.metadata, "sessionId") ?? + findLatestMessageMetadataValue(s.thread.messages, "sessionId") ?? + (typeof window !== "undefined" + ? window.localStorage.getItem("claw.activeSessionId") + : null), + ); + const runId = normalizeSessionId( + readMetadataValue(replayRunMessage?.metadata, "runId"), + ); + return { + active: Boolean(replayRunMessage), + sessionId, + runId, + }; + }); +} + +async function cancelLatestRun(sessionId: string, runId?: string | null) { + await fetch("/api/claw/runs/cancel", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + session_id: sessionId, + ...(runId ? { run_id: runId } : {}), + }), + }); } const ChatTopActions: FC = () => { @@ -315,6 +339,7 @@ const ThreadSuggestionItem: FC = () => { }; const Composer: FC = () => { + const replayedRun = useReplayedRunState(); return ( @@ -329,6 +354,7 @@ const Composer: FC = () => { rows={1} autoFocus aria-label="Message input" + disabled={replayedRun.active} /> @@ -338,6 +364,8 @@ const Composer: FC = () => { }; const ComposerAction: FC = () => { + const runtimeRunning = useAuiState((s) => s.thread.isRunning); + const replayedRun = useReplayedRunState(); return (
@@ -345,7 +373,7 @@ const ComposerAction: FC = () => {
- !s.thread.isRunning}> + {!runtimeRunning && !replayedRun.active ? ( { - - s.thread.isRunning}> + ) : null} + {runtimeRunning ? ( - + ) : null} + {!runtimeRunning && replayedRun.active ? ( + + ) : null}
); }; +const ReplayedRunCancelButton: FC<{ + sessionId: string | null; + runId: string | null; +}> = ({ sessionId, runId }) => { + const { replaySession } = useClawSessionReplay(); + const [cancelling, setCancelling] = useState(false); + return ( + + ); +}; + const ComposerContextStatus: FC = () => { const status = useComposerContextStatus(); const totalTokens = status.contextBudget?.projected_input_tokens ?? 0; @@ -1130,27 +1205,34 @@ const ActivityChainSummary: FC<{ const { openItem } = useActivityPanel(); const startedAtRef = useRef(Date.now()); const [elapsedMs, setElapsedMs] = useState(0); + const runStartedAtMs = useAuiState((s) => { + const value = readMetadataValue(s.message.metadata, "runStartedAtMs"); + return typeof value === "number" && Number.isFinite(value) ? value : null; + }); const label = useAuiState((s) => { const message = s.message; - return summarizeActivityChainLabel( - message.content, - message.status?.type, - elapsedMs, - ); + const status = + message.status?.type === "running" || isReplayRunMessageId(messageId) + ? "running" + : message.status?.type; + return summarizeActivityChainLabel(message.content, status, elapsedMs); }); const active = useAuiState((s) => { const message = s.message; - return message.status?.type === "running"; + return ( + message.status?.type === "running" || isReplayRunMessageId(messageId) + ); }); useEffect(() => { if (!active) return; - setElapsedMs(Date.now() - startedAtRef.current); + const startedAt = runStartedAtMs ?? startedAtRef.current; + setElapsedMs(Math.max(0, Date.now() - startedAt)); const timer = window.setInterval(() => { - setElapsedMs(Date.now() - startedAtRef.current); + setElapsedMs(Math.max(0, Date.now() - startedAt)); }, 1000); return () => window.clearInterval(timer); - }, [active]); + }, [active, runStartedAtMs]); return (