977 lines
29 KiB
TypeScript
977 lines
29 KiB
TypeScript
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 (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 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 === "content_delta" && event.delta) {
|
||
const note = event.delta.trim();
|
||
if (note && !isNoisyLiveDelta(note)) lines.push(note);
|
||
}
|
||
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,
|
||
) {
|
||
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;
|
||
}
|
||
}
|