Cache session replay on client

This commit is contained in:
wuyang6
2026-06-12 17:28:15 +08:00
parent ddf06c727d
commit 00a83ba4c3
4 changed files with 286 additions and 56 deletions
+41 -14
View File
@@ -16,7 +16,7 @@ import {
} from "@/components/assistant-ui/activity-panel"; } from "@/components/assistant-ui/activity-panel";
import { Thread } from "@/components/assistant-ui/thread"; import { Thread } from "@/components/assistant-ui/thread";
import { import {
fetchLatestRunStatus, type fetchLatestRunStatus,
toReplayRepository, toReplayRepository,
} from "@/components/assistant-ui/thread-list"; } from "@/components/assistant-ui/thread-list";
import { ThreadListSidebar } from "@/components/assistant-ui/threadlist-sidebar"; import { ThreadListSidebar } from "@/components/assistant-ui/threadlist-sidebar";
@@ -35,6 +35,10 @@ import {
readPendingWorkspaceSessionId, readPendingWorkspaceSessionId,
writeActiveSessionId, writeActiveSessionId,
} from "@/lib/claw-active-session"; } from "@/lib/claw-active-session";
import {
fetchSessionReplaySnapshot,
readCachedSessionReplay,
} from "@/lib/claw-session-cache";
import { ClawSessionReplayProvider } from "@/lib/claw-session-replay"; import { ClawSessionReplayProvider } from "@/lib/claw-session-replay";
import { pushSessionUrl, readSessionIdFromUrl } from "@/lib/claw-session-url"; import { pushSessionUrl, readSessionIdFromUrl } from "@/lib/claw-session-url";
import { import {
@@ -112,23 +116,46 @@ export const Assistant = ({ initialSessionId }: AssistantProps = {}) => {
loadedInitialSessionRef.current = sessionId; loadedInitialSessionRef.current = sessionId;
const targetSessionId = sessionId; const targetSessionId = sessionId;
let cancelled = false; let cancelled = false;
const abortController = new AbortController();
async function loadSession() { async function loadSession() {
try { try {
writeActiveSessionId(targetSessionId); writeActiveSessionId(targetSessionId);
const response = await fetch(`/api/claw/sessions/${targetSessionId}`, { const cached = readCachedSessionReplay<
cache: "no-store", Parameters<typeof toReplayRepository>[0],
}); Awaited<ReturnType<typeof fetchLatestRunStatus>>
if (!response.ok || cancelled) return; >(targetSessionId);
const payload = await response.json(); if (cached && !cancelled) {
const runStatus = await fetchLatestRunStatus(targetSessionId); replaySession(
targetSessionId,
toReplayRepository(
cached.session,
targetSessionId,
cached.runStatus,
),
);
}
const snapshot = await fetchSessionReplaySnapshot<
Parameters<typeof toReplayRepository>[0],
Awaited<ReturnType<typeof fetchLatestRunStatus>>
>(targetSessionId, { signal: abortController.signal });
if (cancelled) return; if (cancelled) return;
replaySession( if (!snapshot) return;
targetSessionId, if (!cached || snapshot.signature !== cached.signature) {
toReplayRepository(payload, targetSessionId, runStatus), replaySession(
); targetSessionId,
toReplayRepository(
snapshot.session,
targetSessionId,
snapshot.runStatus,
),
);
}
pushSessionUrl(targetSessionId); pushSessionUrl(targetSessionId);
} catch { } catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
return;
}
if (!cancelled) { if (!cancelled) {
loadedInitialSessionRef.current = null; loadedInitialSessionRef.current = null;
} }
@@ -138,6 +165,7 @@ export const Assistant = ({ initialSessionId }: AssistantProps = {}) => {
void loadSession(); void loadSession();
return () => { return () => {
cancelled = true; cancelled = true;
abortController.abort();
}; };
}, [account, initialSessionId, replaySession]); }, [account, initialSessionId, replaySession]);
@@ -252,8 +280,7 @@ function AssistantWorkspace() {
const handleChanged = (event: Event) => { const handleChanged = (event: Event) => {
const customEvent = event as CustomEvent<{ sessionId?: string | null }>; const customEvent = event as CustomEvent<{ sessionId?: string | null }>;
const hasExplicitSession = const hasExplicitSession =
customEvent.detail && customEvent.detail && Object.hasOwn(customEvent.detail, "sessionId");
Object.prototype.hasOwnProperty.call(customEvent.detail, "sessionId");
const next = hasExplicitSession const next = hasExplicitSession
? (customEvent.detail.sessionId ?? null) ? (customEvent.detail.sessionId ?? null)
: (readSessionIdFromUrl() ?? readActiveSessionId()); : (readSessionIdFromUrl() ?? readActiveSessionId());
@@ -29,6 +29,12 @@ import {
writeActiveSessionId, writeActiveSessionId,
writePendingWorkspaceSessionId, writePendingWorkspaceSessionId,
} from "@/lib/claw-active-session"; } from "@/lib/claw-active-session";
import {
fetchSessionReplaySnapshot,
invalidateCachedSessionReplay,
readCachedSessionReplay,
type SessionReplaySnapshot,
} from "@/lib/claw-session-cache";
import { useClawSessionReplay } from "@/lib/claw-session-replay"; import { useClawSessionReplay } from "@/lib/claw-session-replay";
import { pushHomeUrl, pushSessionUrl } from "@/lib/claw-session-url"; 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 = () => { export const ThreadList: FC = () => {
return ( return (
<ThreadListPrimitive.Root className="aui-root aui-thread-list-root flex min-h-0 flex-1 flex-col gap-1"> <ThreadListPrimitive.Root className="aui-root aui-thread-list-root flex min-h-0 flex-1 flex-col gap-1">
@@ -168,7 +195,7 @@ function ensureSidebarWorkspaceSessionId(): {
} }
function workspaceDisplayLabel(entry: JupyterWorkspaceEntry): string { 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) { if (entry.base_url) {
try { try {
return new URL(entry.base_url).host; 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-sessions-changed"));
window.dispatchEvent(new Event("claw-workspaces-changed")); window.dispatchEvent(new Event("claw-workspaces-changed"));
} catch (err) { } catch (err) {
setError( setError(err instanceof Error ? err.message : "切换工作区时网络异常");
err instanceof Error ? err.message : "切换工作区时网络异常",
);
} finally { } finally {
setBindingId(null); setBindingId(null);
} }
}; };
const remove = async (entry: JupyterWorkspaceEntry) => { const remove = async (entry: JupyterWorkspaceEntry) => {
if (!window.confirm(`移除工作区「${workspaceDisplayLabel(entry)}」?`)) return; if (!window.confirm(`移除工作区「${workspaceDisplayLabel(entry)}」?`))
return;
try { try {
const response = await fetch( const response = await fetch(
`/api/claw/jupyter/workspaces/${encodeURIComponent(entry.id)}`, `/api/claw/jupyter/workspaces/${encodeURIComponent(entry.id)}`,
@@ -331,6 +357,8 @@ const ClawSessionList: FC = () => {
null, null,
); );
const renameInputRef = useRef<HTMLInputElement | null>(null); const renameInputRef = useRef<HTMLInputElement | null>(null);
const openRequestRef = useRef(0);
const openAbortRef = useRef<AbortController | null>(null);
useEffect(() => { useEffect(() => {
setActiveSessionId(readActiveSessionId()); setActiveSessionId(readActiveSessionId());
@@ -394,6 +422,51 @@ const ClawSessionList: FC = () => {
setDraftTitle(""); 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<ClawStoredSession, ClawRunStatus>(
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 ( return (
<div className="min-h-0 flex-1 overflow-y-auto pr-1"> <div className="min-h-0 flex-1 overflow-y-auto pr-1">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
@@ -443,31 +516,7 @@ const ClawSessionList: FC = () => {
preview: session.preview, preview: session.preview,
}); });
setOpenMenuSessionId(null); setOpenMenuSessionId(null);
writeActiveSessionId(session.session_id); await openSession(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);
}
}} }}
> >
<span className="truncate"> <span className="truncate">
@@ -525,6 +574,7 @@ const ClawSessionList: FC = () => {
(item) => item.session_id !== session.session_id, (item) => item.session_id !== session.session_id,
), ),
); );
invalidateCachedSessionReplay(session.session_id);
setOpenMenuSessionId(null); setOpenMenuSessionId(null);
if (activeSessionId === session.session_id) { if (activeSessionId === session.session_id) {
clearActiveSessionId(); clearActiveSessionId();
@@ -14,12 +14,12 @@ import {
} from "lucide-react"; } from "lucide-react";
import { Popover as PopoverPrimitive } from "radix-ui"; import { Popover as PopoverPrimitive } from "radix-ui";
import type * as React from "react"; 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 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, type ClawRunStatus,
ThreadList, ThreadList,
toReplayRepository, toReplayRepository,
} from "@/components/assistant-ui/thread-list"; } from "@/components/assistant-ui/thread-list";
@@ -49,6 +49,10 @@ import {
readActiveSessionId, readActiveSessionId,
writeActiveSessionId, writeActiveSessionId,
} from "@/lib/claw-active-session"; } from "@/lib/claw-active-session";
import {
fetchSessionReplaySnapshot,
readCachedSessionReplay,
} from "@/lib/claw-session-cache";
import { useClawSessionReplay } from "@/lib/claw-session-replay"; import { useClawSessionReplay } from "@/lib/claw-session-replay";
import { pushHomeUrl, pushSessionUrl } from "@/lib/claw-session-url"; import { pushHomeUrl, pushSessionUrl } from "@/lib/claw-session-url";
@@ -384,6 +388,8 @@ function SessionResultButton({
function useSidebarSessions(open: boolean) { function useSidebarSessions(open: boolean) {
const [sessions, setSessions] = useState<SidebarSession[]>([]); const [sessions, setSessions] = useState<SidebarSession[]>([]);
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null); const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
const openRequestRef = useRef(0);
const openAbortRef = useRef<AbortController | null>(null);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@@ -417,22 +423,54 @@ function useSidebarSessions(open: boolean) {
) { ) {
const cleanSessionId = normalizeSessionId(sessionId); const cleanSessionId = normalizeSessionId(sessionId);
if (!cleanSessionId) return; if (!cleanSessionId) return;
const requestId = openRequestRef.current + 1;
openRequestRef.current = requestId;
openAbortRef.current?.abort();
const abortController = new AbortController();
openAbortRef.current = abortController;
const cached = readCachedSessionReplay<ReplaySessionPayload, ClawRunStatus>(
cleanSessionId,
);
writeActiveSessionId(cleanSessionId); writeActiveSessionId(cleanSessionId);
pushSessionUrl(cleanSessionId); pushSessionUrl(cleanSessionId);
setLoadingSessionId(sessionId); if (cached) {
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);
replaySession( replaySession(
cleanSessionId, cleanSessionId,
toReplayRepository(payload, cleanSessionId, runStatus), toReplayRepository(cached.session, cleanSessionId, cached.runStatus),
); );
} finally {
setLoadingSessionId(null); 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;
}
} }
} }
+115
View File
@@ -0,0 +1,115 @@
"use client";
export type SessionReplaySnapshot<TSession = unknown, TRun = unknown> = {
sessionId: string;
session: TSession;
runStatus: TRun | null;
signature: string;
fetchedAt: number;
};
const MAX_REPLAY_CACHE_SIZE = 50;
const replayCache = new Map<string, SessionReplaySnapshot>();
export function readCachedSessionReplay<TSession = unknown, TRun = unknown>(
sessionId: string,
): SessionReplaySnapshot<TSession, TRun> | 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<TSession, TRun>;
}
export function invalidateCachedSessionReplay(sessionId: string) {
replayCache.delete(sessionId);
}
export async function fetchSessionReplaySnapshot<
TSession = unknown,
TRun = unknown,
>(
sessionId: string,
options: { signal?: AbortSignal } = {},
): Promise<SessionReplaySnapshot<TSession, TRun> | null> {
const [sessionResponse, runStatus] = await Promise.all([
fetch(`/api/claw/sessions/${encodeURIComponent(sessionId)}`, {
cache: "no-store",
signal: options.signal,
}),
fetchLatestRunStatusForCache<TRun>(sessionId, options.signal),
]);
if (!sessionResponse.ok) return null;
const session = (await sessionResponse.json()) as TSession;
return writeCachedSessionReplay(sessionId, session, runStatus);
}
function writeCachedSessionReplay<TSession, TRun>(
sessionId: string,
session: TSession,
runStatus: TRun | null,
): SessionReplaySnapshot<TSession, TRun> {
const snapshot: SessionReplaySnapshot<TSession, TRun> = {
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<TRun>(
sessionId: string,
signal?: AbortSignal,
): Promise<TRun | null> {
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<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}