Files
zk-data-agent/frontend/app/components/assistant-ui/thread-list.tsx
T
2026-05-14 15:02:11 +08:00

1014 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
bindExternalStoreMessage,
type ExportedMessageRepository,
type ThreadMessage,
} from "@assistant-ui/core";
import { ThreadListPrimitive } from "@assistant-ui/react";
import type { UIMessage } from "ai";
import {
MoreHorizontalIcon,
PencilIcon,
PlusIcon,
Trash2Icon,
} from "lucide-react";
import {
type FC,
type KeyboardEvent,
useEffect,
useRef,
useState,
} from "react";
import { Button } from "@/components/ui/button";
import {
clearActiveSessionId,
readActiveSessionId,
writeActiveSessionId,
} from "@/lib/claw-active-session";
import { useClawSessionReplay } from "@/lib/claw-session-replay";
type ClawSession = {
session_id: string;
turns: number;
tool_calls: number;
preview: string;
modified_at: number;
};
type ClawStoredMessage = {
role?: string;
content?: string;
name?: string;
tool_call_id?: string;
tool_calls?: ClawStoredToolCall[];
state?: string;
stop_reason?: string;
metadata?: {
elapsed_ms?: unknown;
kind?: unknown;
placeholder?: unknown;
phase?: unknown;
status?: unknown;
};
};
type ClawStoredSession = {
session_id: string;
messages?: ClawStoredMessage[];
};
export type ClawRunStatus = {
run_id?: string;
session_id?: string;
status?:
| "idle"
| "queued"
| "running"
| "completed"
| "failed"
| "cancelled"
| "interrupted";
current_stage?: string;
started_at?: number;
updated_at?: number;
finished_at?: number;
elapsed_ms?: number;
cancellable?: boolean;
pending_prompt?: string;
events?: ClawRunEvent[];
};
type ClawActiveRunStatus = ClawRunStatus & {
status: "queued" | "running";
};
type ClawRunEvent = {
type?: string;
started_at?: unknown;
recorded_at?: unknown;
elapsed_ms?: unknown;
tool_name?: string;
tool_call_id?: string;
arguments?: unknown;
assistant_content?: string;
delta?: string;
ok?: boolean;
metadata?: {
output_preview?: unknown;
};
};
type ClawStoredToolCall = {
id?: string;
name?: string;
arguments?: unknown;
function?: {
name?: string;
arguments?: unknown;
};
};
export const ThreadList: FC = () => {
return (
<ThreadListPrimitive.Root className="aui-root aui-thread-list-root flex min-h-0 flex-1 flex-col gap-1">
<ThreadListNew />
<ClawSessionList />
</ThreadListPrimitive.Root>
);
};
const ThreadListNew: FC = () => {
return (
<div
onClickCapture={() => {
clearActiveSessionId();
window.dispatchEvent(new Event("claw-active-session-cleared"));
}}
>
<ThreadListPrimitive.New asChild>
<Button
variant="outline"
className="aui-thread-list-new h-9 w-full justify-start gap-2 rounded-lg px-3 text-sm hover:bg-muted data-active:bg-muted"
>
<PlusIcon className="size-4" />
New Task
</Button>
</ThreadListPrimitive.New>
</div>
);
};
const ClawSessionList: FC = () => {
const { replaySession } = useClawSessionReplay();
const [sessions, setSessions] = useState<ClawSession[]>([]);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
const [deletingSessionId, setDeletingSessionId] = useState<string | null>(
null,
);
const [renamingSessionId, setRenamingSessionId] = useState<string | null>(
null,
);
const [draftTitle, setDraftTitle] = useState("");
const [openMenuSessionId, setOpenMenuSessionId] = useState<string | null>(
null,
);
const renameInputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
setActiveSessionId(readActiveSessionId());
const refreshSessions = () => {
fetch("/api/claw/sessions")
.then((res) => (res.ok ? res.json() : []))
.then((payload) => {
if (Array.isArray(payload)) setSessions(dedupeSessions(payload));
})
.catch(() => setSessions([]));
};
const clearActiveSession = () => {
setActiveSessionId(null);
refreshSessions();
};
window.addEventListener("claw-active-session-cleared", clearActiveSession);
window.addEventListener("claw-sessions-changed", refreshSessions);
window.addEventListener("focus", refreshSessions);
refreshSessions();
return () => {
window.removeEventListener(
"claw-active-session-cleared",
clearActiveSession,
);
window.removeEventListener("claw-sessions-changed", refreshSessions);
window.removeEventListener("focus", refreshSessions);
};
}, []);
useEffect(() => {
if (!renamingSessionId) return;
window.requestAnimationFrame(() => {
renameInputRef.current?.focus();
renameInputRef.current?.select();
});
}, [renamingSessionId]);
if (!sessions.length) return null;
const saveTitle = async (sessionId: string, fallbackTitle: string) => {
const normalized = draftTitle.trim();
if (!normalized || normalized === fallbackTitle) {
setRenamingSessionId(null);
setDraftTitle("");
return;
}
const response = await fetch(`/api/claw/sessions/${sessionId}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ title: normalized }),
});
if (!response.ok) return;
const payload = (await response.json()) as { title?: string };
const nextTitle = payload.title || normalized;
setSessions((current) =>
current.map((item) =>
item.session_id === sessionId ? { ...item, preview: nextTitle } : item,
),
);
setRenamingSessionId(null);
setDraftTitle("");
};
return (
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
<div className="flex flex-col gap-1">
{sessions.map((session) => {
const active = activeSessionId === session.session_id;
const loading = loadingSessionId === session.session_id;
return (
<div
key={`claw-session-${session.session_id}-${session.modified_at}`}
className="aui-thread-list-item group relative flex h-9 items-center gap-2 rounded-lg transition-colors hover:bg-muted data-[active=true]:bg-muted"
data-active={active}
>
{renamingSessionId === session.session_id ? (
<div className="aui-thread-list-item-trigger flex h-full min-w-0 flex-1 items-center px-3">
<input
ref={renameInputRef}
type="text"
className="h-7 min-w-0 flex-1 rounded-md border bg-background px-2 text-sm outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={draftTitle}
onChange={(event) => setDraftTitle(event.target.value)}
onBlur={() => {
saveTitle(
session.session_id,
session.preview || session.session_id,
);
}}
onKeyDown={(event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
event.preventDefault();
event.currentTarget.blur();
}
if (event.key === "Escape") {
event.preventDefault();
setRenamingSessionId(null);
setDraftTitle("");
}
}}
/>
</div>
) : (
<button
type="button"
className="aui-thread-list-item-trigger flex h-full min-w-0 flex-1 items-center truncate px-3 text-start text-sm"
onClick={async () => {
setOpenMenuSessionId(null);
writeActiveSessionId(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">
{loading
? "回放中..."
: session.preview || session.session_id}
</span>
</button>
)}
<button
type="button"
className="aui-thread-list-item-more mr-2 flex size-7 shrink-0 items-center justify-center rounded-md p-0 text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover:opacity-100 data-[busy=true]:opacity-100 data-[open=true]:bg-accent data-[open=true]:opacity-100"
data-busy={deletingSessionId === session.session_id}
data-open={openMenuSessionId === session.session_id}
aria-expanded={openMenuSessionId === session.session_id}
aria-label="打开历史会话菜单"
title="更多选项"
onClick={() => {
setOpenMenuSessionId((current) =>
current === session.session_id ? null : session.session_id,
);
}}
>
<MoreHorizontalIcon className="size-4" />
</button>
{openMenuSessionId === session.session_id ? (
<div className="aui-thread-list-item-more-content absolute top-8 right-1 z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md">
<button
type="button"
className="aui-thread-list-item-more-item flex w-full cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm outline-none hover:bg-accent focus:bg-accent"
disabled={renamingSessionId === session.session_id}
onClick={() => {
setRenamingSessionId(session.session_id);
setDraftTitle(session.preview || session.session_id);
setOpenMenuSessionId(null);
}}
>
<PencilIcon className="size-4" />
</button>
<button
type="button"
className="aui-thread-list-item-more-item flex w-full cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm text-destructive outline-none hover:bg-accent focus:bg-accent"
disabled={deletingSessionId === session.session_id}
onClick={async () => {
setDeletingSessionId(session.session_id);
try {
const response = await fetch(
`/api/claw/sessions/${session.session_id}`,
{ method: "DELETE" },
);
if (!response.ok) return;
setSessions((current) =>
current.filter(
(item) => item.session_id !== session.session_id,
),
);
setOpenMenuSessionId(null);
if (activeSessionId === session.session_id) {
clearActiveSessionId();
setActiveSessionId(null);
window.dispatchEvent(
new Event("claw-active-session-cleared"),
);
}
} finally {
setDeletingSessionId(null);
}
}}
>
<Trash2Icon className="size-4" />
</button>
</div>
) : null}
</div>
);
})}
</div>
</div>
);
};
function dedupeSessions(payload: unknown[]) {
const byId = new Map<string, ClawSession>();
for (const item of payload) {
if (!isClawSession(item)) continue;
const previous = byId.get(item.session_id);
if (!previous || item.modified_at >= previous.modified_at) {
byId.set(item.session_id, item);
}
}
return [...byId.values()].sort((a, b) => b.modified_at - a.modified_at);
}
export async function fetchLatestRunStatus(sessionId: string) {
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" });
if (!response.ok) return null;
return (await response.json()) as ClawRunStatus;
} catch {
return null;
}
}
function isClawSession(value: unknown): value is ClawSession {
if (!value || typeof value !== "object") return false;
const item = value as Partial<ClawSession>;
return (
typeof item.session_id === "string" && typeof item.modified_at === "number"
);
}
export function toReplayRepository(
session: ClawStoredSession,
fallbackSessionId: string,
runStatus?: ClawRunStatus | null,
): ExportedMessageRepository {
const messages: ExportedMessageRepository["messages"] = [];
const replayMessages = getReplayMessages(session.messages ?? [], runStatus);
const toolResults = collectToolResults(replayMessages);
const lastAssistantContentIndex =
findLastAssistantContentIndex(replayMessages);
let previousId: string | null = null;
for (const [index, message] of replayMessages.entries()) {
if (message.role === "tool") continue;
if (message.role !== "user" && message.role !== "assistant") continue;
const content = cleanStoredContent(message.content ?? "");
const toolParts =
message.role === "assistant"
? toToolParts(message.tool_calls ?? [], toolResults, content)
: [];
if (!content && !toolParts.length) continue;
if (content.trimStart().startsWith("<system-reminder>")) continue;
const id = `${fallbackSessionId}-${index}`;
const shouldShowAssistantText =
message.role !== "assistant" ||
!toolParts.length ||
index === lastAssistantContentIndex;
const elapsedMs =
message.role === "assistant"
? resolveReplayElapsedMs(replayMessages, index)
: undefined;
const parts = (
message.role === "assistant" && toolParts.length
? shouldShowAssistantText
? [...toolParts, { type: "text", text: content }]
: toolParts
: message.role === "assistant" && elapsedMs !== undefined
? [
{ type: "reasoning", text: "思考并执行完成。", elapsedMs },
{ type: "text", text: content },
]
: [{ type: "text", text: content }]
) as UIMessage["parts"];
const replayParts = withReplayElapsedMs(parts, elapsedMs);
const uiMessage: UIMessage = {
id,
role: message.role,
parts: replayParts,
...(message.role === "assistant"
? {
metadata: {
sessionId: session.session_id ?? fallbackSessionId,
elapsedMs,
},
}
: {}),
} as UIMessage;
const threadMessage = {
id,
role: message.role,
createdAt: new Date(),
content: toThreadMessageContent(replayParts, elapsedMs),
...(message.role === "assistant"
? {
status: { type: "complete", reason: "stop" },
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {
sessionId: session.session_id ?? fallbackSessionId,
elapsedMs,
},
},
}
: { metadata: { custom: {} } }),
} as ThreadMessage;
bindExternalStoreMessage(threadMessage, uiMessage);
messages.push({ message: threadMessage, parentId: previousId });
previousId = id;
}
if (shouldShowTerminalPendingPrompt(replayMessages, runStatus)) {
const pendingPrompt = resolvePendingPrompt(replayMessages, runStatus);
if (pendingPrompt) {
const id = `${fallbackSessionId}-pending-${runStatus?.run_id ?? "terminal"}`;
const parts = [
{ type: "text", text: pendingPrompt },
] as UIMessage["parts"];
const uiMessage: UIMessage = {
id,
role: "user",
parts,
metadata: { sessionId: session.session_id ?? fallbackSessionId },
} as UIMessage;
const threadMessage = {
id,
role: "user",
createdAt: new Date(),
content: toThreadMessageContent(parts),
attachments: [],
metadata: {
custom: { sessionId: session.session_id ?? fallbackSessionId },
},
} as ThreadMessage;
bindExternalStoreMessage(threadMessage, uiMessage);
messages.push({ message: threadMessage, parentId: previousId });
previousId = id;
}
}
if (shouldShowInterruptedNotice(session.messages ?? [], runStatus)) {
const id = `${fallbackSessionId}-interrupted`;
const text =
runStatus?.status === "cancelled"
? "上一次任务已取消,后台没有正在执行的进程。你可以继续回复,或重新发起任务。"
: "上一次任务已中断,后台没有正在执行的进程。你可以继续回复,或重新发起任务。";
const parts = [{ type: "text", text }] as UIMessage["parts"];
const uiMessage: UIMessage = {
id,
role: "assistant",
parts,
metadata: { sessionId: session.session_id ?? fallbackSessionId },
} as UIMessage;
const threadMessage = {
id,
role: "assistant",
createdAt: new Date(),
content: toThreadMessageContent(parts),
status: { type: "complete", reason: "stop" },
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: { sessionId: session.session_id ?? fallbackSessionId },
},
} as ThreadMessage;
bindExternalStoreMessage(threadMessage, uiMessage);
messages.push({ message: threadMessage, parentId: previousId });
previousId = id;
}
if (isActiveRunStatus(runStatus)) {
const pendingPrompt = resolvePendingPrompt(replayMessages, runStatus);
if (pendingPrompt) {
const id = `${fallbackSessionId}-pending-${runStatus.run_id ?? "active"}`;
const parts = [
{ type: "text", text: pendingPrompt },
] as UIMessage["parts"];
const uiMessage: UIMessage = {
id,
role: "user",
parts,
metadata: { sessionId: session.session_id ?? fallbackSessionId },
} as UIMessage;
const threadMessage = {
id,
role: "user",
createdAt: new Date(),
content: toThreadMessageContent(parts),
attachments: [],
metadata: {
custom: { sessionId: session.session_id ?? fallbackSessionId },
},
} as ThreadMessage;
bindExternalStoreMessage(threadMessage, uiMessage);
messages.push({ message: threadMessage, parentId: previousId });
previousId = id;
}
const id = `${fallbackSessionId}-run-${runStatus.run_id ?? "active"}`;
const runStartedAtMs = resolveRunStartedAtMs(runStatus);
const parts = buildActiveRunParts(runStatus) as UIMessage["parts"];
const uiMessage: UIMessage = {
id,
role: "assistant",
parts,
metadata: {
sessionId: session.session_id ?? fallbackSessionId,
runId: runStatus.run_id,
runStartedAtMs,
},
} as UIMessage;
const threadMessage = {
id,
role: "assistant",
createdAt: new Date(),
content: toThreadMessageContent(parts),
status: { type: "running" },
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {
sessionId: session.session_id ?? fallbackSessionId,
runId: runStatus.run_id,
runStartedAtMs,
},
},
} as ThreadMessage;
bindExternalStoreMessage(threadMessage, uiMessage);
messages.push({ message: threadMessage, parentId: previousId });
previousId = id;
}
return {
headId: previousId,
messages,
};
}
function isActiveRunStatus(
runStatus?: ClawRunStatus | null,
): runStatus is ClawActiveRunStatus {
return runStatus?.status === "queued" || runStatus?.status === "running";
}
function shouldShowTerminalPendingPrompt(
messages: readonly ClawStoredMessage[],
runStatus?: ClawRunStatus | null,
) {
return (
!isActiveRunStatus(runStatus) &&
Boolean(resolvePendingPrompt(messages, runStatus)) &&
(runStatus?.status === "interrupted" ||
runStatus?.status === "cancelled" ||
runStatus?.status === "failed")
);
}
function buildActiveRunParts(runStatus: ClawActiveRunStatus) {
const eventLines = summarizeRunEvents(runStatus);
const fallback =
runStatus.status === "queued"
? "当前会话已有任务在执行,本轮正在排队。"
: runStatus.current_stage || "后端正在执行这个会话。";
const reasoningText = eventLines.length ? eventLines.join("\n") : fallback;
const runStartedAtMs = resolveRunStartedAtMs(runStatus);
const elapsedMs = normalizeElapsedMs(runStatus.elapsed_ms);
return [
{ type: "reasoning", text: reasoningText, runStartedAtMs, elapsedMs },
...buildRunToolParts(runStatus.events ?? []),
];
}
function summarizeRunEvents(runStatus: ClawActiveRunStatus) {
const lines: string[] = [];
for (const event of runStatus.events ?? []) {
if (event.type === "run_queued") {
lines.push("当前会话已有任务在执行,本轮正在排队。");
}
if (event.type === "run_started") {
lines.push("后端已开始执行本轮任务。");
}
if (event.type === "tool_start") {
const stageNote =
typeof event.assistant_content === "string"
? event.assistant_content.trim()
: "";
lines.push(
stageNote ||
(event.tool_name ? `调用工具 ${event.tool_name}` : "正在调用工具"),
);
}
if (event.type === "tool_result") {
lines.push(
event.tool_name ? `工具完成 ${event.tool_name}` : "工具调用完成",
);
}
if (event.type === "tool_delta") {
lines.push(event.tool_name ? `${event.tool_name} 输出中` : "工具输出中");
}
if (event.type === "final_text_start") {
lines.push("正在整理回复");
}
if (event.type === "user_review_required") {
lines.push("等待用户 review");
}
}
if (runStatus.current_stage && lines.at(-1) !== runStatus.current_stage) {
lines.push(runStatus.current_stage);
}
return lines.slice(-40);
}
function isNoisyLiveDelta(value: string) {
return value === "。" || value === "" || value === "," || value.length <= 1;
}
function buildRunToolParts(events: readonly ClawRunEvent[]) {
const resultById = new Map<string, ClawRunEvent>();
for (const event of events) {
if (event.type === "tool_result" && event.tool_call_id) {
resultById.set(event.tool_call_id, event);
}
}
const seen = new Set<string>();
return events.flatMap((event) => {
if (
event.type !== "tool_start" ||
!event.tool_call_id ||
!event.tool_name ||
seen.has(event.tool_call_id)
) {
return [];
}
seen.add(event.tool_call_id);
const result = resultById.get(event.tool_call_id);
const outputPreview = result?.metadata?.output_preview;
const output =
typeof outputPreview === "string"
? outputPreview
: result
? result.ok === false
? "工具执行失败,等待最终结果汇总。"
: "工具调用完成,等待最终结果汇总。"
: undefined;
return [
{
type: "dynamic-tool",
toolName: event.tool_name,
toolCallId: event.tool_call_id,
state: output === undefined ? "input-available" : "output-available",
input: attachStageNote(event.arguments ?? {}, event.assistant_content),
...(output === undefined ? {} : { output }),
},
];
});
}
function getReplayMessages(
messages: readonly ClawStoredMessage[],
runStatus?: ClawRunStatus | null,
) {
const visibleMessages = isActiveRunStatus(runStatus)
? messages.filter((message) => !isRunStatusMessage(message))
: [...messages];
return trimIncompleteReplayTail(visibleMessages);
}
function shouldShowInterruptedNotice(
messages: readonly ClawStoredMessage[],
runStatus?: ClawRunStatus | null,
) {
if (isActiveRunStatus(runStatus)) return false;
if (hasRunStatusMessage(messages, runStatus?.status)) return false;
return (
runStatus?.status === "interrupted" ||
runStatus?.status === "cancelled" ||
trimIncompleteReplayTail(messages).length !== messages.length
);
}
function trimIncompleteReplayTail(messages: readonly ClawStoredMessage[]) {
const trimmed = [...messages];
while (trimmed.length && isIncompleteReplayMessage(trimmed.at(-1))) {
trimmed.pop();
}
while (trimmed.length && hasTrailingAssistantToolCall(trimmed.at(-1))) {
trimmed.pop();
}
return trimmed;
}
function isIncompleteReplayMessage(message?: ClawStoredMessage) {
if (!message) return false;
if (message.state && message.state !== "final") return true;
if (
message.role === "tool" &&
(message.metadata?.phase === "starting" ||
message.metadata?.phase === "running")
) {
return true;
}
if (
message.role !== "user" &&
message.metadata?.placeholder === true &&
message.metadata?.status === "running"
) {
return true;
}
return false;
}
function hasTrailingAssistantToolCall(message?: ClawStoredMessage) {
return message?.role === "assistant" && Boolean(message.tool_calls?.length);
}
function hasRunStatusMessage(
messages: readonly ClawStoredMessage[],
status?: string,
) {
return messages.some(
(message) =>
isRunStatusMessage(message) &&
(!status || message.metadata?.status === status),
);
}
function isRunStatusMessage(message?: ClawStoredMessage) {
return message?.metadata?.kind === "run_status";
}
function resolvePendingPrompt(
messages: readonly ClawStoredMessage[],
runStatus?: ClawRunStatus | null,
) {
const pendingPrompt =
typeof runStatus?.pending_prompt === "string"
? runStatus.pending_prompt.trim()
: "";
if (!pendingPrompt) return null;
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (!message || message.role === "system" || message.role === "tool")
continue;
const content = cleanStoredContent(message.content ?? "").trim();
if (!content || content.trimStart().startsWith("<system-reminder>"))
continue;
return message.role === "user" && content === pendingPrompt
? null
: pendingPrompt;
}
return pendingPrompt;
}
function normalizeRunTimestampMs(value: unknown) {
if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
return value < 10_000_000_000 ? Math.round(value * 1000) : Math.round(value);
}
function normalizeElapsedMs(value: unknown) {
if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
return Math.max(0, Math.round(value));
}
function resolveRunStartedAtMs(runStatus: ClawRunStatus) {
const direct = normalizeRunTimestampMs(runStatus.started_at);
if (direct !== undefined) return direct;
const updatedAt = normalizeRunTimestampMs(runStatus.updated_at);
const elapsedMs = normalizeElapsedMs(runStatus.elapsed_ms);
if (updatedAt !== undefined && elapsedMs !== undefined) {
return Math.max(0, updatedAt - elapsedMs);
}
for (const event of runStatus.events ?? []) {
if (event.type !== "run_started") continue;
const eventStarted = normalizeRunTimestampMs(event.started_at);
if (eventStarted !== undefined) return eventStarted;
const recordedAt = normalizeRunTimestampMs(event.recorded_at);
if (recordedAt !== undefined) return recordedAt;
}
for (const event of runStatus.events ?? []) {
const recordedAt = normalizeRunTimestampMs(event.recorded_at);
const elapsedMs = normalizeElapsedMs(event.elapsed_ms);
if (recordedAt !== undefined && elapsedMs !== undefined) {
return Math.max(0, recordedAt - elapsedMs);
}
}
for (const event of runStatus.events ?? []) {
const recordedAt = normalizeRunTimestampMs(event.recorded_at);
if (recordedAt !== undefined) return recordedAt;
}
return undefined;
}
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];
if (message?.role !== "assistant") continue;
const content = cleanStoredContent(message.content ?? "");
if (!content) continue;
return index;
}
return -1;
}
function cleanStoredContent(content: string) {
const markers = ["\n\n[当前会话目录]", "\n[当前会话目录]"];
for (const marker of markers) {
if (content.includes(marker)) return content.split(marker, 1)[0].trim();
}
return content.trim();
}
function collectToolResults(messages: readonly ClawStoredMessage[]) {
const results = new Map<string, string>();
for (const message of messages) {
if (
message.role === "tool" &&
message.tool_call_id &&
!isIncompleteReplayMessage(message)
) {
results.set(message.tool_call_id, message.content ?? "");
}
}
return results;
}
function toToolParts(
toolCalls: readonly ClawStoredToolCall[],
toolResults: Map<string, string>,
stageNote?: string,
) {
return toolCalls.flatMap((call) => {
const toolCallId = call.id;
const toolName = call.function?.name ?? call.name;
if (!toolCallId || !toolName) return [];
const input = attachStageNote(
parseToolInput(call.function?.arguments ?? call.arguments),
stageNote,
);
const output = toolResults.get(toolCallId);
return [
{
type: "dynamic-tool",
toolName,
toolCallId,
state: output === undefined ? "input-available" : "output-available",
input,
...(output === undefined ? {} : { output }),
},
];
});
}
function attachStageNote(input: unknown, stageNote?: string) {
const note = stageNote?.trim();
if (!note) return input;
if (input && typeof input === "object" && !Array.isArray(input)) {
return { ...input, __claw_stage_note: note };
}
return { value: input, __claw_stage_note: note };
}
function withReplayElapsedMs(parts: UIMessage["parts"], elapsedMs?: number) {
if (elapsedMs === undefined) return parts;
return parts.map((part) => {
if (part.type === "reasoning") return { ...part, elapsedMs };
if (part.type === "dynamic-tool") {
return {
...part,
elapsedMs,
input: attachReplayElapsed(part.input ?? {}, elapsedMs),
};
}
return part;
}) as UIMessage["parts"];
}
function attachReplayElapsed(input: unknown, elapsedMs: number) {
if (input && typeof input === "object" && !Array.isArray(input)) {
return { ...input, __claw_elapsed_ms: elapsedMs };
}
return { value: input, __claw_elapsed_ms: elapsedMs };
}
function toThreadMessageContent(parts: UIMessage["parts"], elapsedMs?: number) {
return parts.map((part) => {
const elapsedPayload = elapsedMs === undefined ? {} : { elapsedMs };
if (part.type === "text") return part;
if (part.type === "reasoning") return { ...part, ...elapsedPayload };
if (part.type === "dynamic-tool") {
return {
type: "tool-call",
toolName: part.toolName,
toolCallId: part.toolCallId,
args: part.input ?? {},
argsText: JSON.stringify(stripReplayElapsed(part.input ?? {})),
...elapsedPayload,
...(part.state === "output-available" ? { result: part.output } : {}),
};
}
return part;
});
}
function stripReplayElapsed(value: unknown): unknown {
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
const { __claw_elapsed_ms: _elapsedMs, ...rest } = value as Record<
string,
unknown
>;
return rest;
}
function parseToolInput(value: unknown) {
if (typeof value !== "string") return value ?? {};
try {
return JSON.parse(value);
} catch {
return value;
}
}