diff --git a/frontend/app/app/assistant.tsx b/frontend/app/app/assistant.tsx index 633f5af..3d20005 100644 --- a/frontend/app/app/assistant.tsx +++ b/frontend/app/app/assistant.tsx @@ -16,7 +16,7 @@ import { } from "@/components/assistant-ui/activity-panel"; import { Thread } from "@/components/assistant-ui/thread"; import { - fetchLatestRunStatus, + type fetchLatestRunStatus, toReplayRepository, } from "@/components/assistant-ui/thread-list"; import { ThreadListSidebar } from "@/components/assistant-ui/threadlist-sidebar"; @@ -35,6 +35,10 @@ import { readPendingWorkspaceSessionId, writeActiveSessionId, } from "@/lib/claw-active-session"; +import { + fetchSessionReplaySnapshot, + readCachedSessionReplay, +} from "@/lib/claw-session-cache"; import { ClawSessionReplayProvider } from "@/lib/claw-session-replay"; import { pushSessionUrl, readSessionIdFromUrl } from "@/lib/claw-session-url"; import { @@ -112,23 +116,46 @@ export const Assistant = ({ initialSessionId }: AssistantProps = {}) => { loadedInitialSessionRef.current = sessionId; const targetSessionId = sessionId; let cancelled = false; + const abortController = new AbortController(); async function loadSession() { try { writeActiveSessionId(targetSessionId); - const response = await fetch(`/api/claw/sessions/${targetSessionId}`, { - cache: "no-store", - }); - if (!response.ok || cancelled) return; - const payload = await response.json(); - const runStatus = await fetchLatestRunStatus(targetSessionId); + const cached = readCachedSessionReplay< + Parameters[0], + Awaited> + >(targetSessionId); + if (cached && !cancelled) { + replaySession( + targetSessionId, + toReplayRepository( + cached.session, + targetSessionId, + cached.runStatus, + ), + ); + } + const snapshot = await fetchSessionReplaySnapshot< + Parameters[0], + Awaited> + >(targetSessionId, { signal: abortController.signal }); if (cancelled) return; - replaySession( - targetSessionId, - toReplayRepository(payload, targetSessionId, runStatus), - ); + if (!snapshot) return; + if (!cached || snapshot.signature !== cached.signature) { + replaySession( + targetSessionId, + toReplayRepository( + snapshot.session, + targetSessionId, + snapshot.runStatus, + ), + ); + } pushSessionUrl(targetSessionId); - } catch { + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") { + return; + } if (!cancelled) { loadedInitialSessionRef.current = null; } @@ -138,6 +165,7 @@ export const Assistant = ({ initialSessionId }: AssistantProps = {}) => { void loadSession(); return () => { cancelled = true; + abortController.abort(); }; }, [account, initialSessionId, replaySession]); @@ -252,8 +280,7 @@ function AssistantWorkspace() { const handleChanged = (event: Event) => { const customEvent = event as CustomEvent<{ sessionId?: string | null }>; const hasExplicitSession = - customEvent.detail && - Object.prototype.hasOwnProperty.call(customEvent.detail, "sessionId"); + customEvent.detail && Object.hasOwn(customEvent.detail, "sessionId"); const next = hasExplicitSession ? (customEvent.detail.sessionId ?? null) : (readSessionIdFromUrl() ?? readActiveSessionId()); diff --git a/frontend/app/components/assistant-ui/thread-list.tsx b/frontend/app/components/assistant-ui/thread-list.tsx index 73e63ea..0e33ae7 100644 --- a/frontend/app/components/assistant-ui/thread-list.tsx +++ b/frontend/app/components/assistant-ui/thread-list.tsx @@ -29,6 +29,12 @@ import { writeActiveSessionId, writePendingWorkspaceSessionId, } from "@/lib/claw-active-session"; +import { + fetchSessionReplaySnapshot, + invalidateCachedSessionReplay, + readCachedSessionReplay, + type SessionReplaySnapshot, +} from "@/lib/claw-session-cache"; import { useClawSessionReplay } from "@/lib/claw-session-replay"; import { pushHomeUrl, pushSessionUrl } from "@/lib/claw-session-url"; @@ -113,6 +119,27 @@ type ClawStoredToolCall = { }; }; +type ReplaySessionFn = ( + sessionId: string, + repository: ExportedMessageRepository, +) => void; + +type ClawReplaySnapshot = SessionReplaySnapshot< + ClawStoredSession, + ClawRunStatus +>; + +function replaySessionSnapshot( + replaySession: ReplaySessionFn, + sessionId: string, + snapshot: ClawReplaySnapshot, +) { + replaySession( + sessionId, + toReplayRepository(snapshot.session, sessionId, snapshot.runStatus), + ); +} + export const ThreadList: FC = () => { return ( @@ -168,7 +195,7 @@ function ensureSidebarWorkspaceSessionId(): { } function workspaceDisplayLabel(entry: JupyterWorkspaceEntry): string { - if (entry.label && entry.label.trim()) return entry.label.trim(); + if (entry.label?.trim()) return entry.label.trim(); if (entry.base_url) { try { return new URL(entry.base_url).host; @@ -243,16 +270,15 @@ const JupyterWorkspaceList: FC = () => { window.dispatchEvent(new Event("claw-sessions-changed")); window.dispatchEvent(new Event("claw-workspaces-changed")); } catch (err) { - setError( - err instanceof Error ? err.message : "切换工作区时网络异常", - ); + setError(err instanceof Error ? err.message : "切换工作区时网络异常"); } finally { setBindingId(null); } }; const remove = async (entry: JupyterWorkspaceEntry) => { - if (!window.confirm(`移除工作区「${workspaceDisplayLabel(entry)}」?`)) return; + if (!window.confirm(`移除工作区「${workspaceDisplayLabel(entry)}」?`)) + return; try { const response = await fetch( `/api/claw/jupyter/workspaces/${encodeURIComponent(entry.id)}`, @@ -331,6 +357,8 @@ const ClawSessionList: FC = () => { null, ); const renameInputRef = useRef(null); + const openRequestRef = useRef(0); + const openAbortRef = useRef(null); useEffect(() => { setActiveSessionId(readActiveSessionId()); @@ -394,6 +422,51 @@ const ClawSessionList: FC = () => { setDraftTitle(""); }; + const openSession = async (sessionId: string) => { + const requestId = openRequestRef.current + 1; + openRequestRef.current = requestId; + openAbortRef.current?.abort(); + const abortController = new AbortController(); + openAbortRef.current = abortController; + const cached = readCachedSessionReplay( + sessionId, + ); + + writeActiveSessionId(sessionId); + pushSessionUrl(sessionId); + setActiveSessionId(sessionId); + if (cached) { + replaySessionSnapshot(replaySession, sessionId, cached); + setLoadingSessionId(null); + } else { + setLoadingSessionId(sessionId); + } + + try { + const snapshot = await fetchSessionReplaySnapshot< + ClawStoredSession, + ClawRunStatus + >(sessionId, { signal: abortController.signal }); + if (requestId !== openRequestRef.current) return; + if (readActiveSessionId() !== sessionId) return; + if (!snapshot) return; + if (!cached || snapshot.signature !== cached.signature) { + replaySessionSnapshot(replaySession, sessionId, snapshot); + } + } catch (error) { + if (!(error instanceof DOMException && error.name === "AbortError")) { + console.warn("[session-list] failed to open session", error); + } + } finally { + if (requestId === openRequestRef.current) { + setLoadingSessionId(null); + } + if (openAbortRef.current === abortController) { + openAbortRef.current = null; + } + } + }; + return (
@@ -443,31 +516,7 @@ const ClawSessionList: FC = () => { preview: session.preview, }); setOpenMenuSessionId(null); - writeActiveSessionId(session.session_id); - pushSessionUrl(session.session_id); - setActiveSessionId(session.session_id); - setLoadingSessionId(session.session_id); - try { - const response = await fetch( - `/api/claw/sessions/${session.session_id}`, - ); - 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, - runStatus, - ), - ); - } finally { - setLoadingSessionId(null); - } + await openSession(session.session_id); }} > @@ -525,6 +574,7 @@ const ClawSessionList: FC = () => { (item) => item.session_id !== session.session_id, ), ); + invalidateCachedSessionReplay(session.session_id); setOpenMenuSessionId(null); if (activeSessionId === session.session_id) { clearActiveSessionId(); diff --git a/frontend/app/components/assistant-ui/threadlist-sidebar.tsx b/frontend/app/components/assistant-ui/threadlist-sidebar.tsx index 91998b0..3e3cf7c 100644 --- a/frontend/app/components/assistant-ui/threadlist-sidebar.tsx +++ b/frontend/app/components/assistant-ui/threadlist-sidebar.tsx @@ -14,12 +14,12 @@ import { } from "lucide-react"; import { Popover as PopoverPrimitive } from "radix-ui"; import type * as React from "react"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import type { ClawAccount } from "@/app/claw-account-gate"; import { ClawLlmSettings } from "@/app/claw-llm-settings"; import { ClawThemeSwitcher } from "@/app/claw-theme-switcher"; import { - fetchLatestRunStatus, + type ClawRunStatus, ThreadList, toReplayRepository, } from "@/components/assistant-ui/thread-list"; @@ -49,6 +49,10 @@ import { readActiveSessionId, writeActiveSessionId, } from "@/lib/claw-active-session"; +import { + fetchSessionReplaySnapshot, + readCachedSessionReplay, +} from "@/lib/claw-session-cache"; import { useClawSessionReplay } from "@/lib/claw-session-replay"; import { pushHomeUrl, pushSessionUrl } from "@/lib/claw-session-url"; @@ -384,6 +388,8 @@ function SessionResultButton({ function useSidebarSessions(open: boolean) { const [sessions, setSessions] = useState([]); const [loadingSessionId, setLoadingSessionId] = useState(null); + const openRequestRef = useRef(0); + const openAbortRef = useRef(null); useEffect(() => { if (!open) return; @@ -417,22 +423,54 @@ function useSidebarSessions(open: boolean) { ) { const cleanSessionId = normalizeSessionId(sessionId); if (!cleanSessionId) return; + const requestId = openRequestRef.current + 1; + openRequestRef.current = requestId; + openAbortRef.current?.abort(); + const abortController = new AbortController(); + openAbortRef.current = abortController; + const cached = readCachedSessionReplay( + cleanSessionId, + ); writeActiveSessionId(cleanSessionId); pushSessionUrl(cleanSessionId); - setLoadingSessionId(sessionId); - try { - const response = await fetch(`/api/claw/sessions/${cleanSessionId}`, { - cache: "no-store", - }); - if (!response.ok) return; - const payload = (await response.json()) as ReplaySessionPayload; - const runStatus = await fetchLatestRunStatus(cleanSessionId); + if (cached) { replaySession( cleanSessionId, - toReplayRepository(payload, cleanSessionId, runStatus), + toReplayRepository(cached.session, cleanSessionId, cached.runStatus), ); - } finally { setLoadingSessionId(null); + } else { + setLoadingSessionId(sessionId); + } + try { + const snapshot = await fetchSessionReplaySnapshot< + ReplaySessionPayload, + ClawRunStatus + >(cleanSessionId, { signal: abortController.signal }); + if (requestId !== openRequestRef.current) return; + if (readActiveSessionId() !== cleanSessionId) return; + if (!snapshot) return; + if (!cached || snapshot.signature !== cached.signature) { + replaySession( + cleanSessionId, + toReplayRepository( + snapshot.session, + cleanSessionId, + snapshot.runStatus, + ), + ); + } + } catch (error) { + if (!(error instanceof DOMException && error.name === "AbortError")) { + console.warn("[sidebar] failed to open session", error); + } + } finally { + if (requestId === openRequestRef.current) { + setLoadingSessionId(null); + } + if (openAbortRef.current === abortController) { + openAbortRef.current = null; + } } } diff --git a/frontend/app/lib/claw-session-cache.ts b/frontend/app/lib/claw-session-cache.ts new file mode 100644 index 0000000..e431d79 --- /dev/null +++ b/frontend/app/lib/claw-session-cache.ts @@ -0,0 +1,115 @@ +"use client"; + +export type SessionReplaySnapshot = { + sessionId: string; + session: TSession; + runStatus: TRun | null; + signature: string; + fetchedAt: number; +}; + +const MAX_REPLAY_CACHE_SIZE = 50; +const replayCache = new Map(); + +export function readCachedSessionReplay( + sessionId: string, +): SessionReplaySnapshot | null { + const cached = replayCache.get(sessionId); + if (!cached) return null; + // Refresh recency for LRU behavior. + replayCache.delete(sessionId); + replayCache.set(sessionId, cached); + return cached as SessionReplaySnapshot; +} + +export function invalidateCachedSessionReplay(sessionId: string) { + replayCache.delete(sessionId); +} + +export async function fetchSessionReplaySnapshot< + TSession = unknown, + TRun = unknown, +>( + sessionId: string, + options: { signal?: AbortSignal } = {}, +): Promise | null> { + const [sessionResponse, runStatus] = await Promise.all([ + fetch(`/api/claw/sessions/${encodeURIComponent(sessionId)}`, { + cache: "no-store", + signal: options.signal, + }), + fetchLatestRunStatusForCache(sessionId, options.signal), + ]); + if (!sessionResponse.ok) return null; + const session = (await sessionResponse.json()) as TSession; + return writeCachedSessionReplay(sessionId, session, runStatus); +} + +function writeCachedSessionReplay( + sessionId: string, + session: TSession, + runStatus: TRun | null, +): SessionReplaySnapshot { + const snapshot: SessionReplaySnapshot = { + sessionId, + session, + runStatus, + signature: sessionReplaySignature(session, runStatus), + fetchedAt: Date.now(), + }; + replayCache.set(sessionId, snapshot as SessionReplaySnapshot); + while (replayCache.size > MAX_REPLAY_CACHE_SIZE) { + const oldest = replayCache.keys().next().value; + if (!oldest) break; + replayCache.delete(oldest); + } + return snapshot; +} + +async function fetchLatestRunStatusForCache( + sessionId: string, + signal?: AbortSignal, +): Promise { + 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", signal }); + if (!response.ok) return null; + return (await response.json()) as TRun; + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") { + throw error; + } + return null; + } +} + +function sessionReplaySignature(session: unknown, runStatus: unknown) { + const sessionObject = isObject(session) ? session : {}; + const runObject = isObject(runStatus) ? runStatus : {}; + const messages = Array.isArray(sessionObject.messages) + ? sessionObject.messages + : []; + const lastMessage = messages.at(-1); + const lastObject = isObject(lastMessage) ? lastMessage : {}; + const metadata = isObject(lastObject.metadata) ? lastObject.metadata : {}; + return [ + sessionObject.session_id, + sessionObject.turns, + sessionObject.tool_calls, + messages.length, + lastObject.role, + lastObject.message_id, + typeof lastObject.content === "string" ? lastObject.content.length : "", + metadata.elapsed_ms, + runObject.run_id, + runObject.status, + runObject.updated_at, + runObject.finished_at, + runObject.elapsed_ms, + ].join("|"); +} + +function isObject(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +}