Files
zk-data-agent/frontend/app/components/assistant-ui/thread-list.tsx
T
2026-05-06 17:19:13 +08:00

486 lines
15 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[];
metadata?: {
elapsed_ms?: unknown;
};
};
type ClawStoredSession = {
session_id: string;
messages?: ClawStoredMessage[];
};
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;
replaySession(
session.session_id,
toReplayRepository(payload, session.session_id),
);
} 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);
}
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"
);
}
function toReplayRepository(
session: ClawStoredSession,
fallbackSessionId: string,
): ExportedMessageRepository {
const messages: ExportedMessageRepository["messages"] = [];
const toolResults = collectToolResults(session.messages ?? []);
let previousId: string | null = null;
for (const [index, message] of (session.messages ?? []).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)
: [];
const elapsedMs =
typeof message.metadata?.elapsed_ms === "number"
? message.metadata.elapsed_ms
: undefined;
const reasoningParts =
message.role === "assistant" &&
(toolParts.length > 0 || elapsedMs !== undefined)
? [
{
type: "reasoning",
text: formatHistoricalReasoning(toolParts.length, elapsedMs),
},
]
: [];
const parts = (
message.role === "assistant" &&
(reasoningParts.length || toolParts.length)
? [...reasoningParts, ...toolParts, { type: "text", text: content }]
: [{ type: "text", text: content }]
) as UIMessage["parts"];
const uiMessage: UIMessage = {
id,
role: message.role,
parts,
...(message.role === "assistant"
? { metadata: { sessionId: session.session_id ?? fallbackSessionId } }
: {}),
} as UIMessage;
const threadMessage = {
id,
role: message.role,
createdAt: new Date(),
content: toThreadMessageContent(parts),
...(message.role === "assistant"
? {
status: { type: "complete", reason: "stop" },
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: { sessionId: session.session_id ?? fallbackSessionId },
},
}
: { metadata: { custom: {} } }),
} as ThreadMessage;
bindExternalStoreMessage(threadMessage, uiMessage);
messages.push({ message: threadMessage, parentId: previousId });
previousId = id;
}
return {
headId: previousId,
messages,
};
}
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) {
results.set(message.tool_call_id, message.content ?? "");
}
}
return results;
}
function toToolParts(
toolCalls: readonly ClawStoredToolCall[],
toolResults: Map<string, string>,
) {
return toolCalls.flatMap((call) => {
const toolCallId = call.id;
const toolName = call.function?.name ?? call.name;
if (!toolCallId || !toolName) return [];
const input = parseToolInput(call.function?.arguments ?? call.arguments);
const output = toolResults.get(toolCallId);
return [
{
type: "dynamic-tool",
toolName,
toolCallId,
state: output === undefined ? "input-available" : "output-available",
input,
...(output === undefined ? {} : { output }),
},
];
});
}
function formatHistoricalReasoning(toolCount: number, elapsedMs?: number) {
const duration =
elapsedMs === undefined ? "" : `,用时 ${formatDuration(elapsedMs)}`;
if (toolCount <= 0) return `历史记录:思考完成${duration}`;
return `历史记录:本轮调用了 ${toolCount} 个工具${duration}`;
}
function formatDuration(ms: number) {
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
function toThreadMessageContent(parts: UIMessage["parts"]) {
return parts.map((part) => {
if (part.type === "text" || part.type === "reasoning") return part;
if (part.type === "dynamic-tool") {
return {
type: "tool-call",
toolName: part.toolName,
toolCallId: part.toolCallId,
args: part.input ?? {},
argsText: JSON.stringify(part.input ?? {}),
...(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;
}
}