Cache session replay on client
This commit is contained in:
@@ -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<typeof toReplayRepository>[0],
|
||||
Awaited<ReturnType<typeof fetchLatestRunStatus>>
|
||||
>(targetSessionId);
|
||||
if (cached && !cancelled) {
|
||||
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;
|
||||
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());
|
||||
|
||||
@@ -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 (
|
||||
<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 {
|
||||
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<HTMLInputElement | null>(null);
|
||||
const openRequestRef = useRef(0);
|
||||
const openAbortRef = useRef<AbortController | null>(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<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 (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -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);
|
||||
}}
|
||||
>
|
||||
<span className="truncate">
|
||||
@@ -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();
|
||||
|
||||
@@ -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<SidebarSession[]>([]);
|
||||
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
|
||||
const openRequestRef = useRef(0);
|
||||
const openAbortRef = useRef<AbortController | null>(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<ReplaySessionPayload, ClawRunStatus>(
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user