import { bindExternalStoreMessage, type ExportedMessageRepository, type ThreadMessage, } from "@assistant-ui/core"; import { AuiIf, ThreadListItemMorePrimitive, ThreadListItemPrimitive, ThreadListPrimitive, } from "@assistant-ui/react"; import type { UIMessage } from "ai"; import { ArchiveIcon, HistoryIcon, MoreHorizontalIcon, PlusIcon, } from "lucide-react"; import { type FC, useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; 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 ( threads.isLoading}> !threads.isLoading}> {() => } ); }; const ThreadListNew: FC = () => { return (
{ window.localStorage.removeItem("claw.activeSessionId"); window.dispatchEvent(new Event("claw-active-session-cleared")); }} >
); }; const ThreadListSkeleton: FC = () => { const skeletonKeys = ["one", "two", "three", "four", "five"]; return (
{skeletonKeys.map((key) => (
))}
); }; const ThreadListItem: FC = () => { return ( ); }; const ThreadListItemMore: FC = () => { return ( Archive ); }; const ClawSessionList: FC = () => { const { replaySession } = useClawSessionReplay(); const [sessions, setSessions] = useState([]); const [activeSessionId, setActiveSessionId] = useState(null); const [loadingSessionId, setLoadingSessionId] = useState(null); useEffect(() => { setActiveSessionId(window.localStorage.getItem("claw.activeSessionId")); const clearActiveSession = () => setActiveSessionId(null); window.addEventListener("claw-active-session-cleared", clearActiveSession); fetch("/api/claw/sessions") .then((res) => (res.ok ? res.json() : [])) .then((payload) => { if (Array.isArray(payload)) setSessions(dedupeSessions(payload).slice(0, 8)); }) .catch(() => setSessions([])); return () => { window.removeEventListener( "claw-active-session-cleared", clearActiveSession, ); }; }, []); if (!sessions.length) return null; return (
Claw 历史会话
{activeSessionId ? ( ) : null}
{sessions.map((session) => { const active = activeSessionId === session.session_id; return ( ); })}
); }; function dedupeSessions(payload: unknown[]) { const byId = new Map(); for (const item of payload) { if (!isClawSession(item)) continue; const previous = byId.get(item.session_id); if (!previous || item.modified_at >= previous.modified_at) { byId.set(item.session_id, item); } } return [...byId.values()].sort((a, b) => b.modified_at - a.modified_at); } function isClawSession(value: unknown): value is ClawSession { if (!value || typeof value !== "object") return false; const item = value as Partial; return ( typeof item.session_id === "string" && typeof item.modified_at === "number" ); } 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("")) 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(); 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, ) { 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; } }