Files
zk-data-agent/frontend/app/components/assistant-ui/thread-list.tsx
T

828 lines
24 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 { 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;
events?: ClawRunEvent[];
};
type ClawActiveRunStatus = ClawRunStatus & {
status: "queued" | "running";
};
type ClawRunEvent = {
type?: string;
tool_name?: string;
tool_call_id?: string;
arguments?: unknown;
assistant_content?: 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 flex-col gap-1">
<ThreadListNew />
<ClawSessionList />
</ThreadListPrimitive.Root>
);
};
const ThreadListNew: FC = () => {
return (
<div
onClickCapture={() => {
window.localStorage.removeItem("claw.activeSessionId");
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(window.localStorage.getItem("claw.activeSessionId"));
const refreshSessions = () => {
fetch("/api/claw/sessions")
.then((res) => (res.ok ? res.json() : []))
.then((payload) => {
if (Array.isArray(payload))
setSessions(dedupeSessions(payload).slice(0, 8));
})
.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="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);
window.localStorage.setItem(
"claw.activeSessionId",
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) {
window.localStorage.removeItem("claw.activeSessionId");
setActiveSessionId(null);
window.dispatchEvent(
new Event("claw-active-session-cleared"),
);
}
} finally {
setDeletingSessionId(null);
}
}}
>
<Trash2Icon className="size-4" />
</button>
</div>
) : null}
</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 ?? "");
if (!content) continue;
if (content.trimStart().startsWith("<system-reminder>")) continue;
const id = `${fallbackSessionId}-${index}`;
const toolParts =
message.role === "assistant"
? toToolParts(message.tool_calls ?? [], toolResults, content)
: [];
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
: [{ type: "text", text: content }]
) as UIMessage["parts"];
const uiMessage: UIMessage = {
id,
role: message.role,
parts,
...(message.role === "assistant"
? {
metadata: {
sessionId: session.session_id ?? fallbackSessionId,
elapsedMs,
},
}
: {}),
} as UIMessage;
const threadMessage = {
id,
role: message.role,
createdAt: new Date(),
content: toThreadMessageContent(parts, 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 id = `${fallbackSessionId}-run-${runStatus.run_id ?? "active"}`;
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: normalizeRunTimestampMs(runStatus.started_at),
},
} 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: normalizeRunTimestampMs(runStatus.started_at),
},
},
} 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 = normalizeRunTimestampMs(runStatus.started_at);
return [
{ type: "reasoning", text: reasoningText, runStartedAtMs },
...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") {
lines.push(
event.tool_name ? `调用工具 ${event.tool_name}` : "正在调用工具",
);
}
if (event.type === "tool_result") {
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];
if (isActiveRunStatus(runStatus)) return visibleMessages;
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 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 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 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(part.input ?? {}),
...elapsedPayload,
...(part.state === "output-available" ? { result: part.output } : {}),
};
}
return part;
});
}
function parseToolInput(value: unknown) {
if (typeof value !== "string") return value ?? {};
try {
return JSON.parse(value);
} catch {
return value;
}
}