Fix session file and elapsed replay state

This commit is contained in:
武阳
2026-05-08 17:45:32 +08:00
parent 1af0a6f0ea
commit b5a4c208d5
4 changed files with 108 additions and 20 deletions
+42 -2
View File
@@ -16,15 +16,24 @@ export async function GET(req: Request) {
const url = new URL(req.url); const url = new URL(req.url);
const requestedPath = url.searchParams.get("path"); const requestedPath = url.searchParams.get("path");
const sessionId = url.searchParams.get("session_id"); const sessionId = normalizeSessionId(url.searchParams.get("session_id"));
if (!requestedPath && sessionId) { if (!requestedPath && sessionId) {
return Response.json({ return Response.json({
session_id: sessionId,
input: await listSessionFiles(account.id, sessionId, "input"), input: await listSessionFiles(account.id, sessionId, "input"),
output: await listSessionFiles(account.id, sessionId, "output"), output: await listSessionFiles(account.id, sessionId, "output"),
}); });
} }
if (!requestedPath) { 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)); 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) { function contentTypeFor(filePath: string) {
const ext = path.extname(filePath).toLowerCase(); const ext = path.extname(filePath).toLowerCase();
if (ext === ".json") return "application/json; charset=utf-8"; if (ext === ".json") return "application/json; charset=utf-8";
@@ -222,6 +222,7 @@ type SessionFile = {
}; };
type SessionFilesPayload = { type SessionFilesPayload = {
session_id?: string | null;
input?: SessionFile[]; input?: SessionFile[];
output?: SessionFile[]; output?: SessionFile[];
error?: string; error?: string;
@@ -243,24 +244,37 @@ function SessionFilesPanel({
const activeSessionId = normalizeSessionId( const activeSessionId = normalizeSessionId(
sessionId ?? window.localStorage.getItem("claw.activeSessionId"), sessionId ?? window.localStorage.getItem("claw.activeSessionId"),
); );
if (!activeSessionId) {
setPayload({ input: [], output: [] });
setStatus("当前还没有可用会话。");
return;
}
window.localStorage.setItem("claw.activeSessionId", activeSessionId);
setStatus("正在读取聊天中的文件..."); setStatus("正在读取聊天中的文件...");
try { try {
const url = new URL("/api/claw/files", window.location.origin); let { response, payload: nextPayload } =
url.searchParams.set("session_id", activeSessionId); await fetchSessionFiles(activeSessionId);
const response = await fetch(url, { cache: "no-store" }); if (
const nextPayload = (await response.json()) as SessionFilesPayload; 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 (cancelled) return;
if (!response.ok) { if (!response.ok) {
setPayload({ input: [], output: [], error: nextPayload.error }); setPayload({ input: [], output: [], error: nextPayload.error });
setStatus(nextPayload.error ?? "读取文件失败"); setStatus(nextPayload.error ?? "读取文件失败");
return; return;
} }
if (nextPayload.session_id) {
window.localStorage.setItem(
"claw.activeSessionId",
nextPayload.session_id,
);
}
setPayload(nextPayload); setPayload(nextPayload);
setStatus(""); setStatus("");
} catch (err) { } 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) { function normalizeSessionId(value: unknown) {
if (typeof value !== "string") return null; if (typeof value !== "string") return null;
const trimmed = value.trim(); const trimmed = value.trim();
@@ -414,6 +414,10 @@ export function toReplayRepository(
message.role !== "assistant" || message.role !== "assistant" ||
!toolParts.length || !toolParts.length ||
index === lastAssistantContentIndex; index === lastAssistantContentIndex;
const elapsedMs =
message.role === "assistant"
? resolveReplayElapsedMs(replayMessages, index)
: undefined;
const parts = ( const parts = (
message.role === "assistant" && toolParts.length message.role === "assistant" && toolParts.length
? shouldShowAssistantText ? shouldShowAssistantText
@@ -429,7 +433,7 @@ export function toReplayRepository(
? { ? {
metadata: { metadata: {
sessionId: session.session_id ?? fallbackSessionId, sessionId: session.session_id ?? fallbackSessionId,
elapsedMs: normalizeElapsedMs(message.metadata?.elapsed_ms), elapsedMs,
}, },
} }
: {}), : {}),
@@ -449,7 +453,7 @@ export function toReplayRepository(
steps: [], steps: [],
custom: { custom: {
sessionId: session.session_id ?? fallbackSessionId, 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)); 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[]) { function findLastAssistantContentIndex(messages: readonly ClawStoredMessage[]) {
for (let index = messages.length - 1; index >= 0; index -= 1) { for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index]; const message = messages[index];
@@ -1320,7 +1320,7 @@ const ActivityChainSummary: FC<{
? Math.max(0, Math.round(value)) ? Math.max(0, Math.round(value))
: null; : null;
}); });
const [elapsedMs, setElapsedMs] = useState(storedElapsedMs ?? 0); const [elapsedMs, setElapsedMs] = useState<number | null>(storedElapsedMs);
const label = useAuiState((s) => { const label = useAuiState((s) => {
const message = s.message; const message = s.message;
const status = const status =
@@ -1338,7 +1338,7 @@ const ActivityChainSummary: FC<{
useEffect(() => { useEffect(() => {
if (!active) { if (!active) {
if (storedElapsedMs !== null) setElapsedMs(storedElapsedMs); setElapsedMs(storedElapsedMs);
return; return;
} }
const startedAt = const startedAt =
@@ -1392,7 +1392,7 @@ function findNumberInActivityParts(
function summarizeActivityChainLabel( function summarizeActivityChainLabel(
content: readonly ActivitySummaryPart[], content: readonly ActivitySummaryPart[],
status: string | undefined, status: string | undefined,
elapsedMs = 0, elapsedMs: number | null = null,
) { ) {
const toolParts = content.filter((part) => part.type === "tool-call"); const toolParts = content.filter((part) => part.type === "tool-call");
const runningTool = toolParts.findLast((part) => part.result === undefined); const runningTool = toolParts.findLast((part) => part.result === undefined);
@@ -1412,7 +1412,7 @@ function summarizeActivityChainLabel(
(isGenericActivityLine(lastReasoningLine) ? "" : lastReasoningLine); (isGenericActivityLine(lastReasoningLine) ? "" : lastReasoningLine);
if (status === "running") { if (status === "running") {
const elapsed = `用时 ${formatActivityDuration(elapsedMs)}`; const elapsed = `用时 ${formatActivityDuration(elapsedMs ?? 0)}`;
if (activeDetail) { if (activeDetail) {
return `${compactActivityLabel(activeDetail, 15)}${elapsed}`; return `${compactActivityLabel(activeDetail, 15)}${elapsed}`;
} }
@@ -1424,8 +1424,10 @@ function summarizeActivityChainLabel(
15, 15,
); );
const finalDuration = extractDurationLabel(lastReasoningLine); const finalDuration = extractDurationLabel(lastReasoningLine);
const elapsed = finalDuration || formatActivityDuration(elapsedMs); const elapsed =
return `${label},用时 ${elapsed}`; finalDuration ||
(elapsedMs === null ? "" : formatActivityDuration(elapsedMs));
return elapsed ? `${label},用时 ${elapsed}` : label;
} }
function getToolStageNote(value: unknown) { function getToolStageNote(value: unknown) {