492 lines
15 KiB
TypeScript
492 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"
|
|
);
|
|
}
|
|
|
|
export function toReplayRepository(
|
|
session: ClawStoredSession,
|
|
fallbackSessionId: string,
|
|
): ExportedMessageRepository {
|
|
const messages: ExportedMessageRepository["messages"] = [];
|
|
const toolResults = collectToolResults(session.messages ?? []);
|
|
const lastAssistantContentIndex = findLastAssistantContentIndex(
|
|
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, content)
|
|
: [];
|
|
const shouldShowAssistantText =
|
|
message.role !== "assistant" ||
|
|
!toolParts.length ||
|
|
index === lastAssistantContentIndex;
|
|
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 } }
|
|
: {}),
|
|
} 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 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) {
|
|
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"]) {
|
|
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;
|
|
}
|
|
}
|