import {
bindExternalStoreMessage,
type ExportedMessageRepository,
type ThreadMessage,
} from "@assistant-ui/core";
import { ThreadListPrimitive } from "@assistant-ui/react";
import type { UIMessage } from "ai";
import {
Loader2Icon,
MoreHorizontalIcon,
PencilIcon,
PlusIcon,
ServerIcon,
Trash2Icon,
XIcon,
} from "lucide-react";
import {
type FC,
type KeyboardEvent,
useEffect,
useRef,
useState,
} from "react";
import { Button } from "@/components/ui/button";
import {
clearActiveSessionId,
markFreshLocalId,
readActiveSessionId,
writeActiveSessionId,
writePendingWorkspaceSessionId,
} from "@/lib/claw-active-session";
import { useClawSessionReplay } from "@/lib/claw-session-replay";
import { pushHomeUrl, pushSessionUrl } from "@/lib/claw-session-url";
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 (
);
};
const ThreadListNew: FC = () => {
return (
{
clearActiveSessionId();
pushHomeUrl();
window.dispatchEvent(new Event("claw-active-session-cleared"));
}}
>
);
};
type JupyterWorkspaceEntry = {
id: string;
label?: string;
base_url?: string;
workspace_root?: string;
created_at?: number;
last_used_at?: number;
};
function ensureSidebarWorkspaceSessionId(): {
sessionId: string;
created: boolean;
} {
const existing = readActiveSessionId();
if (existing) return { sessionId: existing, created: false };
const generated =
typeof crypto !== "undefined" && "randomUUID" in crypto
? `__LOCALID_${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}`
: `__LOCALID_${Math.random().toString(36).slice(2, 10)}`;
markFreshLocalId(generated);
writeActiveSessionId(generated);
return { sessionId: generated, created: true };
}
function workspaceDisplayLabel(entry: JupyterWorkspaceEntry): string {
if (entry.label && entry.label.trim()) return entry.label.trim();
if (entry.base_url) {
try {
return new URL(entry.base_url).host;
} catch {
return entry.base_url;
}
}
return entry.id;
}
const JupyterWorkspaceList: FC = () => {
const [entries, setEntries] = useState([]);
const [loaded, setLoaded] = useState(false);
const [bindingId, setBindingId] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
const refresh = () => {
fetch("/api/claw/jupyter/workspaces", { cache: "no-store" })
.then((res) => (res.ok ? res.json() : []))
.then((payload) => {
if (cancelled) return;
if (Array.isArray(payload)) {
setEntries(payload as JupyterWorkspaceEntry[]);
} else {
setEntries([]);
}
setLoaded(true);
})
.catch(() => {
if (cancelled) return;
setEntries([]);
setLoaded(true);
});
};
refresh();
const onChanged = () => refresh();
window.addEventListener("claw-workspaces-changed", onChanged);
window.addEventListener("focus", refresh);
return () => {
cancelled = true;
window.removeEventListener("claw-workspaces-changed", onChanged);
window.removeEventListener("focus", refresh);
};
}, []);
if (!loaded || entries.length === 0) return null;
const bind = async (entry: JupyterWorkspaceEntry) => {
setError(null);
setBindingId(entry.id);
const { sessionId, created } = ensureSidebarWorkspaceSessionId();
try {
const response = await fetch(
`/api/claw/jupyter/workspaces/${encodeURIComponent(entry.id)}/bind`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ session_id: sessionId }),
},
);
if (!response.ok) {
const payload = (await response.json().catch(() => ({}))) as {
detail?: string;
error?: string;
};
setError(payload.detail ?? payload.error ?? "切换工作区失败");
return;
}
if (created) writePendingWorkspaceSessionId(sessionId);
window.dispatchEvent(new Event("claw-sessions-changed"));
window.dispatchEvent(new Event("claw-workspaces-changed"));
} catch (err) {
setError(
err instanceof Error ? err.message : "切换工作区时网络异常",
);
} finally {
setBindingId(null);
}
};
const remove = async (entry: JupyterWorkspaceEntry) => {
if (!window.confirm(`移除工作区「${workspaceDisplayLabel(entry)}」?`)) return;
try {
const response = await fetch(
`/api/claw/jupyter/workspaces/${encodeURIComponent(entry.id)}`,
{ method: "DELETE" },
);
if (!response.ok) return;
setEntries((current) => current.filter((e) => e.id !== entry.id));
} catch {
// best effort; next refresh will reconcile
}
};
return (
工作区
{entries.map((entry) => {
const label = workspaceDisplayLabel(entry);
const subtitle = entry.workspace_root ?? "";
const isBinding = bindingId === entry.id;
return (
);
})}
{error ? (
{error}
) : null}
);
};
const ClawSessionList: FC = () => {
const { replaySession } = useClawSessionReplay();
const [sessions, setSessions] = useState([]);
const [activeSessionId, setActiveSessionId] = useState(null);
const [loadingSessionId, setLoadingSessionId] = useState(null);
const [deletingSessionId, setDeletingSessionId] = useState(
null,
);
const [renamingSessionId, setRenamingSessionId] = useState(
null,
);
const [draftTitle, setDraftTitle] = useState("");
const [openMenuSessionId, setOpenMenuSessionId] = useState(
null,
);
const renameInputRef = useRef(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 (
{sessions.map((session) => {
const active = activeSessionId === session.session_id;
const loading = loadingSessionId === session.session_id;
return (
{renamingSessionId === session.session_id ? (
setDraftTitle(event.target.value)}
onBlur={() => {
saveTitle(
session.session_id,
session.preview || session.session_id,
);
}}
onKeyDown={(event: KeyboardEvent) => {
if (event.key === "Enter") {
event.preventDefault();
event.currentTarget.blur();
}
if (event.key === "Escape") {
event.preventDefault();
setRenamingSessionId(null);
setDraftTitle("");
}
}}
/>
) : (
)}
{openMenuSessionId === session.session_id ? (
) : null}
);
})}
);
};
function dedupeSessions(payload: unknown[]) {
const byId = new Map();
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;
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("")) 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 buildRunToolParts(events: readonly ClawRunEvent[]) {
const resultById = new Map();
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();
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(""))
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();
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,
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;
}
}