1202 lines
36 KiB
TypeScript
1202 lines
36 KiB
TypeScript
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 (
|
|
<ThreadListPrimitive.Root className="aui-root aui-thread-list-root flex min-h-0 flex-1 flex-col gap-1">
|
|
<ThreadListNew />
|
|
<JupyterWorkspaceList />
|
|
<ClawSessionList />
|
|
</ThreadListPrimitive.Root>
|
|
);
|
|
};
|
|
|
|
const ThreadListNew: FC = () => {
|
|
const { clearSession } = useClawSessionReplay();
|
|
return (
|
|
<Button
|
|
type="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"
|
|
onClick={() => {
|
|
pushHomeUrl();
|
|
clearActiveSessionId();
|
|
clearSession();
|
|
window.dispatchEvent(new Event("claw-active-session-cleared"));
|
|
}}
|
|
>
|
|
<PlusIcon className="size-4" />
|
|
New Task
|
|
</Button>
|
|
);
|
|
};
|
|
|
|
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<JupyterWorkspaceEntry[]>([]);
|
|
const [loaded, setLoaded] = useState(false);
|
|
const [bindingId, setBindingId] = useState<string | null>(null);
|
|
const [error, setError] = useState<string | null>(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 (
|
|
<div className="mt-1 flex flex-col gap-1 border-y border-dashed py-2">
|
|
<div className="flex items-center gap-1.5 px-2 text-muted-foreground text-xs uppercase tracking-wide">
|
|
<ServerIcon className="size-3" />
|
|
<span>工作区</span>
|
|
</div>
|
|
<div className="flex flex-col gap-0.5">
|
|
{entries.map((entry) => {
|
|
const label = workspaceDisplayLabel(entry);
|
|
const subtitle = entry.workspace_root ?? "";
|
|
const isBinding = bindingId === entry.id;
|
|
return (
|
|
<div
|
|
key={entry.id}
|
|
className="group relative flex h-9 items-center gap-1 rounded-lg transition-colors hover:bg-muted"
|
|
>
|
|
<button
|
|
type="button"
|
|
disabled={isBinding}
|
|
onClick={() => void bind(entry)}
|
|
className="flex h-full min-w-0 flex-1 items-center gap-2 rounded-lg px-3 text-left text-sm disabled:cursor-wait"
|
|
title={`${entry.base_url ?? ""}${subtitle ? ` · ${subtitle}` : ""}`}
|
|
>
|
|
{isBinding ? (
|
|
<Loader2Icon className="size-3.5 shrink-0 animate-spin" />
|
|
) : (
|
|
<ServerIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
|
)}
|
|
<span className="min-w-0 flex-1 truncate">{label}</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
aria-label="移除工作区"
|
|
title="移除工作区"
|
|
onClick={() => void remove(entry)}
|
|
className="mr-1 hidden size-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-background hover:text-foreground group-hover:flex"
|
|
>
|
|
<XIcon className="size-3.5" />
|
|
</button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
{error ? (
|
|
<div className="px-2 text-destructive text-xs">{error}</div>
|
|
) : null}
|
|
</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 () => {
|
|
console.log("[session-list] click", {
|
|
sessionId: session.session_id,
|
|
preview: session.preview,
|
|
});
|
|
setOpenMenuSessionId(null);
|
|
writeActiveSessionId(session.session_id);
|
|
pushSessionUrl(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 runCreatedAt = new Date(runStartedAtMs ?? Date.now());
|
|
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,
|
|
custom: {
|
|
sessionId: session.session_id ?? fallbackSessionId,
|
|
runId: runStatus.run_id,
|
|
runStartedAtMs,
|
|
},
|
|
},
|
|
} as UIMessage;
|
|
const threadMessage = {
|
|
id,
|
|
role: "assistant",
|
|
createdAt: runCreatedAt,
|
|
content: toThreadMessageContent(parts),
|
|
status: { type: "running" },
|
|
metadata: {
|
|
unstable_state: null,
|
|
unstable_annotations: [],
|
|
unstable_data: [],
|
|
steps: [],
|
|
sessionId: session.session_id ?? fallbackSessionId,
|
|
runId: runStatus.run_id,
|
|
runStartedAtMs,
|
|
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<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;
|
|
}
|
|
}
|