From b5a4c208d50d5b9e61909ee4d58128e1a9daaa90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=AD=A6=E9=98=B3?= Date: Fri, 8 May 2026 17:45:32 +0800 Subject: [PATCH] Fix session file and elapsed replay state --- frontend/app/app/api/claw/files/route.ts | 44 +++++++++++++++++- .../assistant-ui/activity-panel.tsx | 46 +++++++++++++++---- .../components/assistant-ui/thread-list.tsx | 24 +++++++++- .../app/components/assistant-ui/thread.tsx | 14 +++--- 4 files changed, 108 insertions(+), 20 deletions(-) diff --git a/frontend/app/app/api/claw/files/route.ts b/frontend/app/app/api/claw/files/route.ts index 06cc1fd..998e056 100644 --- a/frontend/app/app/api/claw/files/route.ts +++ b/frontend/app/app/api/claw/files/route.ts @@ -16,15 +16,24 @@ export async function GET(req: Request) { const url = new URL(req.url); const requestedPath = url.searchParams.get("path"); - const sessionId = url.searchParams.get("session_id"); + const sessionId = normalizeSessionId(url.searchParams.get("session_id")); if (!requestedPath && sessionId) { return Response.json({ + session_id: sessionId, input: await listSessionFiles(account.id, sessionId, "input"), output: await listSessionFiles(account.id, sessionId, "output"), }); } if (!requestedPath) { - return Response.json({ error: "缺少文件路径或会话 ID" }, { status: 400 }); + const latestSessionId = await findLatestSessionId(account.id); + if (!latestSessionId) { + return Response.json({ session_id: null, input: [], output: [] }); + } + return Response.json({ + session_id: latestSessionId, + input: await listSessionFiles(account.id, latestSessionId, "input"), + output: await listSessionFiles(account.id, latestSessionId, "output"), + }); } const accountRoot = path.resolve(accountBaseRoot(account.id)); @@ -87,6 +96,37 @@ async function listSessionFiles( } } +function normalizeSessionId(value: string | null) { + const trimmed = value?.trim(); + return trimmed || null; +} + +async function findLatestSessionId(accountId: string) { + const sessionsRoot = path.join(accountBaseRoot(accountId), "sessions"); + try { + const entries = await readdir(sessionsRoot, { withFileTypes: true }); + const candidates = await Promise.all( + entries + .filter((entry) => entry.isDirectory()) + .map(async (entry) => { + const sessionDir = path.join(sessionsRoot, entry.name); + const sessionFile = path.join(sessionDir, "session.json"); + try { + const sessionStat = await stat(sessionFile); + return { id: entry.name, modifiedAt: sessionStat.mtimeMs }; + } catch { + const dirStat = await stat(sessionDir); + return { id: entry.name, modifiedAt: dirStat.mtimeMs }; + } + }), + ); + candidates.sort((a, b) => b.modifiedAt - a.modifiedAt); + return candidates.at(0)?.id ?? null; + } catch { + return null; + } +} + function contentTypeFor(filePath: string) { const ext = path.extname(filePath).toLowerCase(); if (ext === ".json") return "application/json; charset=utf-8"; diff --git a/frontend/app/components/assistant-ui/activity-panel.tsx b/frontend/app/components/assistant-ui/activity-panel.tsx index 8c506e1..8e77680 100644 --- a/frontend/app/components/assistant-ui/activity-panel.tsx +++ b/frontend/app/components/assistant-ui/activity-panel.tsx @@ -222,6 +222,7 @@ type SessionFile = { }; type SessionFilesPayload = { + session_id?: string | null; input?: SessionFile[]; output?: SessionFile[]; error?: string; @@ -243,24 +244,37 @@ function SessionFilesPanel({ const activeSessionId = normalizeSessionId( sessionId ?? window.localStorage.getItem("claw.activeSessionId"), ); - if (!activeSessionId) { - setPayload({ input: [], output: [] }); - setStatus("当前还没有可用会话。"); - return; - } - window.localStorage.setItem("claw.activeSessionId", activeSessionId); setStatus("正在读取聊天中的文件..."); try { - const url = new URL("/api/claw/files", window.location.origin); - url.searchParams.set("session_id", activeSessionId); - const response = await fetch(url, { cache: "no-store" }); - const nextPayload = (await response.json()) as SessionFilesPayload; + let { response, payload: nextPayload } = + await fetchSessionFiles(activeSessionId); + if ( + activeSessionId && + response.ok && + sessionFilesCount(nextPayload) === 0 + ) { + const latest = await fetchSessionFiles(null); + if ( + latest.response.ok && + latest.payload.session_id && + latest.payload.session_id !== activeSessionId + ) { + response = latest.response; + nextPayload = latest.payload; + } + } if (cancelled) return; if (!response.ok) { setPayload({ input: [], output: [], error: nextPayload.error }); setStatus(nextPayload.error ?? "读取文件失败"); return; } + if (nextPayload.session_id) { + window.localStorage.setItem( + "claw.activeSessionId", + nextPayload.session_id, + ); + } setPayload(nextPayload); setStatus(""); } catch (err) { @@ -322,6 +336,18 @@ function SessionFilesPanel({ ); } +async function fetchSessionFiles(sessionId: string | null) { + const url = new URL("/api/claw/files", window.location.origin); + if (sessionId) url.searchParams.set("session_id", sessionId); + const response = await fetch(url, { cache: "no-store" }); + const payload = (await response.json()) as SessionFilesPayload; + return { response, payload }; +} + +function sessionFilesCount(payload: SessionFilesPayload) { + return (payload.input?.length ?? 0) + (payload.output?.length ?? 0); +} + function normalizeSessionId(value: unknown) { if (typeof value !== "string") return null; const trimmed = value.trim(); diff --git a/frontend/app/components/assistant-ui/thread-list.tsx b/frontend/app/components/assistant-ui/thread-list.tsx index 6b38d26..b88a1cf 100644 --- a/frontend/app/components/assistant-ui/thread-list.tsx +++ b/frontend/app/components/assistant-ui/thread-list.tsx @@ -414,6 +414,10 @@ export function toReplayRepository( message.role !== "assistant" || !toolParts.length || index === lastAssistantContentIndex; + const elapsedMs = + message.role === "assistant" + ? resolveReplayElapsedMs(replayMessages, index) + : undefined; const parts = ( message.role === "assistant" && toolParts.length ? shouldShowAssistantText @@ -429,7 +433,7 @@ export function toReplayRepository( ? { metadata: { sessionId: session.session_id ?? fallbackSessionId, - elapsedMs: normalizeElapsedMs(message.metadata?.elapsed_ms), + elapsedMs, }, } : {}), @@ -449,7 +453,7 @@ export function toReplayRepository( steps: [], custom: { sessionId: session.session_id ?? fallbackSessionId, - elapsedMs: normalizeElapsedMs(message.metadata?.elapsed_ms), + elapsedMs, }, }, } @@ -708,6 +712,22 @@ function normalizeElapsedMs(value: unknown) { return Math.max(0, Math.round(value)); } +function resolveReplayElapsedMs( + messages: readonly ClawStoredMessage[], + index: number, +) { + const current = normalizeElapsedMs(messages[index]?.metadata?.elapsed_ms); + if (current !== undefined) return current; + for (let i = index + 1; i < messages.length; i += 1) { + const message = messages[i]; + if (message?.role === "user") return undefined; + if (message?.role !== "assistant") continue; + const elapsedMs = normalizeElapsedMs(message.metadata?.elapsed_ms); + if (elapsedMs !== undefined) return elapsedMs; + } + return undefined; +} + 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 3dac8ae..20fa79d 100644 --- a/frontend/app/components/assistant-ui/thread.tsx +++ b/frontend/app/components/assistant-ui/thread.tsx @@ -1320,7 +1320,7 @@ const ActivityChainSummary: FC<{ ? Math.max(0, Math.round(value)) : null; }); - const [elapsedMs, setElapsedMs] = useState(storedElapsedMs ?? 0); + const [elapsedMs, setElapsedMs] = useState(storedElapsedMs); const label = useAuiState((s) => { const message = s.message; const status = @@ -1338,7 +1338,7 @@ const ActivityChainSummary: FC<{ useEffect(() => { if (!active) { - if (storedElapsedMs !== null) setElapsedMs(storedElapsedMs); + setElapsedMs(storedElapsedMs); return; } const startedAt = @@ -1392,7 +1392,7 @@ function findNumberInActivityParts( function summarizeActivityChainLabel( content: readonly ActivitySummaryPart[], status: string | undefined, - elapsedMs = 0, + elapsedMs: number | null = null, ) { const toolParts = content.filter((part) => part.type === "tool-call"); const runningTool = toolParts.findLast((part) => part.result === undefined); @@ -1412,7 +1412,7 @@ function summarizeActivityChainLabel( (isGenericActivityLine(lastReasoningLine) ? "" : lastReasoningLine); if (status === "running") { - const elapsed = `用时 ${formatActivityDuration(elapsedMs)}`; + const elapsed = `用时 ${formatActivityDuration(elapsedMs ?? 0)}`; if (activeDetail) { return `${compactActivityLabel(activeDetail, 15)},${elapsed}`; } @@ -1424,8 +1424,10 @@ function summarizeActivityChainLabel( 15, ); const finalDuration = extractDurationLabel(lastReasoningLine); - const elapsed = finalDuration || formatActivityDuration(elapsedMs); - return `${label},用时 ${elapsed}`; + const elapsed = + finalDuration || + (elapsedMs === null ? "" : formatActivityDuration(elapsedMs)); + return elapsed ? `${label},用时 ${elapsed}` : label; } function getToolStageNote(value: unknown) {