Files
zk-data-agent/frontend/app/components/assistant-ui/thread.tsx
T
2026-05-14 11:47:37 +08:00

2386 lines
70 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
ActionBarMorePrimitive,
ActionBarPrimitive,
AuiIf,
BranchPickerPrimitive,
ComposerPrimitive,
ErrorPrimitive,
MessagePrimitive,
SuggestionPrimitive,
ThreadPrimitive,
useAui,
useAuiState,
} from "@assistant-ui/react";
import {
ArrowDownIcon,
ArrowUpIcon,
BookOpenIcon,
BrainIcon,
CheckIcon,
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
CopyIcon,
DownloadIcon,
FileTextIcon,
ListIcon,
MoreHorizontalIcon,
PencilIcon,
RefreshCwIcon,
SearchIcon,
SquareIcon,
WrenchIcon,
} from "lucide-react";
import { Popover as PopoverPrimitive } from "radix-ui";
import type {
ComponentProps,
Dispatch,
FC,
KeyboardEvent,
ReactNode,
SetStateAction,
} from "react";
import {
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import TextareaAutosize from "react-textarea-autosize";
import { useActivityPanel } from "@/components/assistant-ui/activity-panel";
import {
ComposerAddAttachment,
ComposerAttachments,
UserMessageAttachments,
} from "@/components/assistant-ui/attachment";
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
import { Reasoning } from "@/components/assistant-ui/reasoning";
import {
fetchLatestRunStatus,
toReplayRepository,
} from "@/components/assistant-ui/thread-list";
import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
ACTIVE_SESSION_CHANGED_EVENT,
clearActiveSessionId,
clearPendingWorkspaceSessionId,
PENDING_WORKSPACE_SESSION_CHANGED_EVENT,
readActiveSessionId,
readPendingWorkspaceSessionId,
writeActiveSessionId,
writePendingWorkspaceSessionId,
} from "@/lib/claw-active-session";
import { useClawSessionReplay } from "@/lib/claw-session-replay";
import { cn } from "@/lib/utils";
type ComposerInsertEvent = CustomEvent<{ text: string }>;
type SkillSummary = {
name: string;
description?: string;
when_to_use?: string;
enabled?: boolean;
configurable?: boolean;
};
type SkillSyncResponse = {
skills?: SkillSummary[];
updated?: boolean;
before?: string;
after?: string;
changed_files?: string[];
message?: string;
error?: string;
detail?: string;
};
type SlashCommandSummary = {
names: string[];
primary: string;
description?: string;
webui_supported?: boolean;
};
type ClawState = {
model?: string;
active_session_id?: string | null;
};
type ClawStoredSession = {
session_id?: string;
model?: string;
};
type ModelOption = {
id: string;
provider?: string;
model_type?: string;
};
type ContextBudget = {
model?: string;
context_window_tokens?: number;
projected_input_tokens?: number;
message_tokens?: number;
chat_overhead_tokens?: number;
reserved_output_tokens?: number;
reserved_compaction_buffer_tokens?: number;
reserved_schema_tokens?: number;
hard_input_limit_tokens?: number;
soft_input_limit_tokens?: number;
overflow_tokens?: number;
soft_overflow_tokens?: number;
exceeds_hard_limit?: boolean;
exceeds_soft_limit?: boolean;
token_counter_backend?: string;
token_counter_source?: string;
token_counter_accurate?: boolean;
};
function scheduleSessionListRefresh() {
if (typeof window === "undefined") return;
window.dispatchEvent(new Event("claw-sessions-changed"));
for (const delay of [300, 1000, 2500]) {
window.setTimeout(() => {
window.dispatchEvent(new Event("claw-sessions-changed"));
}, delay);
}
}
export const Thread: FC = () => {
useRefreshReplayedRun();
return (
<ThreadPrimitive.Root
className="aui-root aui-thread-root @container flex h-full flex-col bg-background"
style={{
["--thread-max-width" as string]: "44rem",
["--composer-radius" as string]: "24px",
["--composer-padding" as string]: "10px",
}}
>
<ThreadPrimitive.Viewport
turnAnchor="top"
data-slot="aui_thread-viewport"
className="relative flex flex-1 flex-col overflow-x-auto overflow-y-auto scroll-smooth"
>
<ChatTopActions />
<div className="mx-auto flex w-full max-w-(--thread-max-width) flex-1 flex-col px-4 pt-4">
<AuiIf condition={(s) => s.thread.isEmpty}>
<ThreadWelcome />
</AuiIf>
<div
data-slot="aui_message-group"
className="mb-10 flex flex-col gap-y-8 empty:hidden"
>
<ThreadPrimitive.Messages>
{() => <ThreadMessage />}
</ThreadPrimitive.Messages>
</div>
<ThreadPrimitive.ViewportFooter className="aui-thread-viewport-footer sticky bottom-0 mt-auto flex flex-col gap-4 overflow-visible rounded-t-(--composer-radius) bg-background pb-4 md:pb-6">
<ThreadScrollToBottom />
<Composer />
</ThreadPrimitive.ViewportFooter>
</div>
</ThreadPrimitive.Viewport>
</ThreadPrimitive.Root>
);
};
function useRefreshReplayedRun() {
const { replaySession } = useClawSessionReplay();
const replayedRun = useReplayedRunState();
useEffect(() => {
if (!replayedRun.active || !replayedRun.sessionId) return;
const activeSessionId = replayedRun.sessionId;
let cancelled = false;
async function refreshIfFinished() {
const runStatus = await fetchLatestRunStatus(activeSessionId);
if (cancelled) return;
if (runStatus?.status === "queued" || runStatus?.status === "running") {
const response = await fetch(`/api/claw/sessions/${activeSessionId}`, {
cache: "no-store",
});
if (!response.ok || cancelled) return;
const payload = await response.json();
replaySession(
activeSessionId,
toReplayRepository(payload, activeSessionId, runStatus),
);
return;
}
const response = await fetch(`/api/claw/sessions/${activeSessionId}`, {
cache: "no-store",
});
if (!response.ok || cancelled) return;
const payload = await response.json();
replaySession(
activeSessionId,
toReplayRepository(payload, activeSessionId, runStatus),
);
window.dispatchEvent(new Event("claw-sessions-changed"));
}
refreshIfFinished();
const interval = window.setInterval(refreshIfFinished, 3000);
return () => {
cancelled = true;
window.clearInterval(interval);
};
}, [replayedRun.active, replayedRun.sessionId, replaySession]);
}
function useReplayedRunState() {
const active = useAuiState((s) =>
s.thread.messages.some((message) => isReplayRunMessageId(message.id)),
);
const replaySessionId = useAuiState((s) => {
const replayRunMessage = s.thread.messages.find((message) =>
isReplayRunMessageId(message.id),
);
return normalizeSessionId(
readMetadataValue(replayRunMessage?.metadata, "sessionId"),
);
});
const latestSessionId = useAuiState((s) =>
normalizeSessionId(
findLatestMessageMetadataValue(s.thread.messages, "sessionId"),
),
);
const runId = useAuiState((s) => {
const replayRunMessage = s.thread.messages.find((message) =>
isReplayRunMessageId(message.id),
);
return normalizeSessionId(
readMetadataValue(replayRunMessage?.metadata, "runId"),
);
});
const sessionId = replaySessionId ?? latestSessionId;
return useMemo(
() => ({ active, sessionId, runId }),
[active, sessionId, runId],
);
}
function usePendingWorkspaceSessionId() {
const [sessionId, setSessionId] = useState<string | null>(() =>
typeof window !== "undefined"
? normalizeSessionId(readPendingWorkspaceSessionId())
: null,
);
useEffect(() => {
const refresh = () => {
setSessionId(normalizeSessionId(readPendingWorkspaceSessionId()));
};
window.addEventListener(PENDING_WORKSPACE_SESSION_CHANGED_EVENT, refresh);
window.addEventListener("storage", refresh);
window.addEventListener("focus", refresh);
return () => {
window.removeEventListener(
PENDING_WORKSPACE_SESSION_CHANGED_EVENT,
refresh,
);
window.removeEventListener("storage", refresh);
window.removeEventListener("focus", refresh);
};
}, []);
return sessionId;
}
function useActiveSessionId() {
const [sessionId, setSessionId] = useState<string | null>(() =>
typeof window !== "undefined"
? normalizeSessionId(readActiveSessionId())
: null,
);
useEffect(() => {
const refresh = () => {
setSessionId(normalizeSessionId(readActiveSessionId()));
};
window.addEventListener(ACTIVE_SESSION_CHANGED_EVENT, refresh);
window.addEventListener("storage", refresh);
window.addEventListener("focus", refresh);
return () => {
window.removeEventListener(ACTIVE_SESSION_CHANGED_EVENT, refresh);
window.removeEventListener("storage", refresh);
window.removeEventListener("focus", refresh);
};
}, []);
return sessionId;
}
function useCurrentThreadSessionId({
includePending = false,
includeActive = true,
} = {}) {
const currentRun = useReplayedRunState();
const pendingSessionId = usePendingWorkspaceSessionId();
const activeSessionId = useActiveSessionId();
useEffect(() => {
if (currentRun.sessionId && currentRun.sessionId === pendingSessionId) {
clearPendingWorkspaceSessionId();
}
}, [currentRun.sessionId, pendingSessionId]);
return (
currentRun.sessionId ??
(includePending ? pendingSessionId : null) ??
(includeActive ? activeSessionId : null)
);
}
async function cancelLatestRun(sessionId: string, runId?: string | null) {
await fetch("/api/claw/runs/cancel", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
session_id: sessionId,
...(runId ? { run_id: runId } : {}),
}),
});
}
const ChatTopActions: FC = () => {
const { openFiles } = useActivityPanel();
const [workspaceOpen, setWorkspaceOpen] = useState(false);
const normalizedSessionId = useCurrentThreadSessionId({
includePending: true,
});
const workspaceStatus = useJupyterWorkspaceStatus(normalizedSessionId);
return (
<div className="pointer-events-none sticky top-3 z-20 mr-4 flex justify-end">
<PopoverPrimitive.Root>
<PopoverPrimitive.Trigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="pointer-events-auto size-8 rounded-full bg-background/90 text-muted-foreground shadow-sm backdrop-blur hover:bg-muted hover:text-foreground"
aria-label="聊天选项"
>
<MoreHorizontalIcon className="size-4" />
</Button>
</PopoverPrimitive.Trigger>
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
side="bottom"
align="end"
sideOffset={8}
className="z-50 min-w-40 rounded-xl border bg-popover p-1 text-popover-foreground shadow-lg outline-none"
>
<button
type="button"
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-muted"
onClick={() => {
openFiles(normalizedSessionId);
}}
>
<FileTextIcon className="size-4 text-muted-foreground" />
</button>
<button
type="button"
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-muted"
onClick={() => setWorkspaceOpen(true)}
>
<WrenchIcon className="size-4 text-muted-foreground" />
{workspaceStatus?.connected ? "Jupyter 工作区" : "切换工作区"}
</button>
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
</PopoverPrimitive.Root>
<WorkspaceSwitchDialog
open={workspaceOpen}
onOpenChange={setWorkspaceOpen}
sessionId={normalizedSessionId}
/>
</div>
);
};
type JupyterWorkspaceStatus = {
connected?: boolean;
session_id?: string;
workspace_cwd?: string;
jupyter_tree_url?: string;
detail?: string;
error?: string;
};
type WorkspaceSwitchProgress = {
percent: number;
label: string;
};
const WORKSPACE_SWITCH_STEPS = [
"登录 Jupyter",
"初始化目录",
"创建 Python 环境",
"配置 pip 源",
"同步 Skill",
"链接工作区",
];
function useJupyterWorkspaceStatus(sessionId: string | null) {
const [status, setStatus] = useState<JupyterWorkspaceStatus | null>(null);
useEffect(() => {
if (!sessionId) {
setStatus(null);
return;
}
const sessionIdForRequest = sessionId;
let cancelled = false;
async function refresh() {
try {
const response = await fetch(
`/api/claw/jupyter?session_id=${encodeURIComponent(sessionIdForRequest)}`,
{ cache: "no-store" },
);
const payload = (await response.json().catch(() => ({}))) as
| JupyterWorkspaceStatus
| Record<string, never>;
if (cancelled) return;
if (!response.ok) {
setStatus(null);
return;
}
setStatus(payload as JupyterWorkspaceStatus);
} catch {
if (!cancelled) setStatus(null);
}
}
void refresh();
const onRefresh = () => void refresh();
window.addEventListener("focus", onRefresh);
window.addEventListener("claw-sessions-changed", onRefresh);
const interval = window.setInterval(refresh, 8000);
return () => {
cancelled = true;
window.removeEventListener("focus", onRefresh);
window.removeEventListener("claw-sessions-changed", onRefresh);
window.clearInterval(interval);
};
}, [sessionId]);
return status;
}
function WorkspaceSwitchDialog({
open,
onOpenChange,
sessionId,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
sessionId: string | null;
}) {
const [jupyterUrl, setJupyterUrl] = useState("");
const [password, setPassword] = useState("");
const [workspaceRoot, setWorkspaceRoot] = useState(
"/root/zk_agent_workspaces",
);
const [status, setStatus] = useState<JupyterWorkspaceStatus | null>(null);
const [submitting, setSubmitting] = useState(false);
const [message, setMessage] = useState("");
const [progress, setProgress] = useState<WorkspaceSwitchProgress | null>(
null,
);
const fieldId = useId();
const effectiveSessionId = sessionId;
useEffect(() => {
if (!open || !effectiveSessionId) return;
const sessionIdForRequest = effectiveSessionId;
let cancelled = false;
async function refreshStatus() {
const response = await fetch(
`/api/claw/jupyter?session_id=${encodeURIComponent(sessionIdForRequest)}`,
{ cache: "no-store" },
);
const payload = (await response.json().catch(() => ({}))) as
| JupyterWorkspaceStatus
| Record<string, never>;
if (!cancelled) setStatus(payload as JupyterWorkspaceStatus);
}
void refreshStatus().catch(() => undefined);
return () => {
cancelled = true;
};
}, [open, effectiveSessionId]);
async function submit() {
setMessage("");
const sessionIdForRequest = ensureWorkspaceSessionId(effectiveSessionId);
if (!jupyterUrl.trim() || !password) {
setMessage("请填写 Jupyter 地址和密码。");
return;
}
setSubmitting(true);
const progressTimer = startWorkspaceProgress(setProgress);
try {
const needsFirstMessageSession = !normalizeSessionId(sessionId);
const response = await fetch("/api/claw/jupyter", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
session_id: sessionIdForRequest,
base_url: jupyterUrl.trim(),
password,
workspace_root: workspaceRoot.trim() || "/root/zk_agent_workspaces",
}),
});
const payload = (await response.json().catch(() => ({}))) as
| JupyterWorkspaceStatus
| { detail?: string; error?: string };
if (!response.ok) {
setMessage(payload.detail ?? payload.error ?? "切换工作区失败");
return;
}
writeActiveSessionId(sessionIdForRequest);
if (needsFirstMessageSession) {
writePendingWorkspaceSessionId(sessionIdForRequest);
}
setStatus(payload as JupyterWorkspaceStatus);
setProgress({ percent: 100, label: "切换完成" });
setMessage("工作区切换成功,后续工具会在远端 Jupyter 工作区执行。");
setPassword("");
} finally {
window.clearInterval(progressTimer);
setSubmitting(false);
window.setTimeout(() => setProgress(null), 1200);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="pointer-events-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>
{status?.connected ? "Jupyter 工作区" : "切换工作区"}
</DialogTitle>
<DialogDescription>
{status?.connected
? "当前 session 已固定到远端 Jupyter 工作区。"
: "把当前 session 的工具执行切换到远端 Jupyter 开发机。"}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 text-sm">
{status?.connected ? (
<div className="rounded-md border bg-muted/40 p-3 text-muted-foreground">
<div className="font-medium text-foreground"></div>
<div className="mt-1 break-all">
{status.workspace_cwd}
</div>
{status.jupyter_tree_url ? (
<a
className="mt-1 block break-all text-primary underline-offset-4 hover:underline"
href={status.jupyter_tree_url}
target="_blank"
rel="noreferrer"
>
Jupyter
</a>
) : null}
</div>
) : null}
{!status?.connected ? (
<>
<label className="grid gap-1.5" htmlFor={`${fieldId}-url`}>
<span className="text-muted-foreground">Jupyter </span>
<Input
id={`${fieldId}-url`}
value={jupyterUrl}
onChange={(event) => setJupyterUrl(event.target.value)}
placeholder="https://...-jupyter.../lab"
disabled={submitting}
/>
</label>
<label className="grid gap-1.5" htmlFor={`${fieldId}-password`}>
<span className="text-muted-foreground"></span>
<Input
id={`${fieldId}-password`}
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="Jupyter 登录密码"
disabled={submitting}
/>
</label>
<label className="grid gap-1.5" htmlFor={`${fieldId}-root`}>
<span className="text-muted-foreground"></span>
<Input
id={`${fieldId}-root`}
value={workspaceRoot}
onChange={(event) => setWorkspaceRoot(event.target.value)}
disabled={submitting}
/>
</label>
</>
) : null}
{progress ? (
<div className="rounded-md border bg-muted/30 px-3 py-2">
<div className="mb-2 flex items-center justify-between gap-3 text-xs">
<span className="text-muted-foreground">{progress.label}</span>
<span className="font-medium tabular-nums">
{Math.round(progress.percent)}%
</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-all duration-500"
style={{ width: `${progress.percent}%` }}
/>
</div>
</div>
) : null}
{message ? (
<div className="rounded-md bg-muted px-3 py-2 text-muted-foreground">
{message}
</div>
) : null}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
</Button>
{!status?.connected ? (
<Button type="button" disabled={submitting} onClick={submit}>
{submitting ? "连接中..." : "切换"}
</Button>
) : null}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function ensureWorkspaceSessionId(current: string | null) {
if (current) return current;
const generated =
typeof crypto !== "undefined" && "randomUUID" in crypto
? `__LOCALID_${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}`
: `__LOCALID_${Math.random().toString(36).slice(2, 10)}`;
writeActiveSessionId(generated);
scheduleSessionListRefresh();
return generated;
}
function startWorkspaceProgress(
setProgress: Dispatch<SetStateAction<WorkspaceSwitchProgress | null>>,
) {
setProgress({ percent: 6, label: WORKSPACE_SWITCH_STEPS[0] });
return window.setInterval(() => {
setProgress((current) => {
const previous = current ?? {
percent: 6,
label: WORKSPACE_SWITCH_STEPS[0],
};
const nextPercent = Math.min(
92,
previous.percent + (previous.percent < 55 ? 9 : 4),
);
const stepIndex = Math.min(
WORKSPACE_SWITCH_STEPS.length - 1,
Math.floor((nextPercent / 100) * WORKSPACE_SWITCH_STEPS.length),
);
return {
percent: nextPercent,
label: WORKSPACE_SWITCH_STEPS[stepIndex],
};
});
}, 1200);
}
const ThreadMessage: FC = () => {
const role = useAuiState((s) => s.message.role);
const isEditing = useAuiState((s) => s.message.composer.isEditing);
if (isEditing) return <EditComposer />;
if (role === "user") return <UserMessage />;
return <AssistantMessage />;
};
const ThreadScrollToBottom: FC = () => {
return (
<ThreadPrimitive.ScrollToBottom asChild>
<TooltipIconButton
tooltip="Scroll to bottom"
variant="outline"
className="aui-thread-scroll-to-bottom absolute -top-12 z-10 self-center rounded-full p-4 disabled:invisible dark:border-border dark:bg-background dark:hover:bg-accent"
>
<ArrowDownIcon />
</TooltipIconButton>
</ThreadPrimitive.ScrollToBottom>
);
};
const ThreadWelcome: FC = () => {
return (
<div className="aui-thread-welcome-root my-auto flex grow flex-col">
<div className="aui-thread-welcome-center flex w-full grow flex-col items-center justify-center">
<div className="aui-thread-welcome-message flex size-full flex-col items-center justify-center px-4 text-center">
<h1 className="aui-thread-welcome-message-inner fade-in slide-in-from-bottom-1 animate-in fill-mode-both font-semibold text-2xl duration-200">
ZK Data Agent
</h1>
<p className="aui-thread-welcome-message-inner fade-in slide-in-from-bottom-1 animate-in fill-mode-both text-muted-foreground text-xl delay-75 duration-200">
</p>
</div>
</div>
<ThreadSuggestions />
</div>
);
};
const ThreadSuggestions: FC = () => {
return (
<div className="aui-thread-welcome-suggestions grid w-full @md:grid-cols-2 gap-2 pb-4">
<ThreadPrimitive.Suggestions>
{() => <ThreadSuggestionItem />}
</ThreadPrimitive.Suggestions>
</div>
);
};
const ThreadSuggestionItem: FC = () => {
return (
<div className="aui-thread-welcome-suggestion-display fade-in slide-in-from-bottom-2 @md:nth-[n+3]:block nth-[n+3]:hidden animate-in fill-mode-both duration-200">
<SuggestionPrimitive.Trigger send asChild>
<Button
variant="ghost"
className="aui-thread-welcome-suggestion h-auto w-full @md:flex-col flex-wrap items-start justify-start gap-1 rounded-3xl border bg-background px-4 py-3 text-left text-sm transition-colors hover:bg-muted"
>
<SuggestionPrimitive.Title className="aui-thread-welcome-suggestion-text-1 font-medium" />
<SuggestionPrimitive.Description className="aui-thread-welcome-suggestion-text-2 text-muted-foreground empty:hidden" />
</Button>
</SuggestionPrimitive.Trigger>
</div>
);
};
const Composer: FC = () => {
const replayedRun = useReplayedRunState();
const workspaceSessionId = useCurrentThreadSessionId({
includePending: true,
});
return (
<ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col">
<ComposerPrimitive.AttachmentDropzone asChild>
<div
data-slot="aui_composer-shell"
className="flex w-full flex-col gap-2 rounded-(--composer-radius) border bg-background p-(--composer-padding) transition-shadow focus-within:border-ring/75 focus-within:ring-2 focus-within:ring-ring/20 data-[dragging=true]:border-ring data-[dragging=true]:border-dashed data-[dragging=true]:bg-accent/50"
>
<ComposerAttachments />
<ComposerWorkspaceStatus sessionId={workspaceSessionId} />
<ImeComposerInput
placeholder="Send a message..."
className="aui-composer-input max-h-32 min-h-10 w-full resize-none bg-transparent px-1.75 py-1 text-sm outline-none placeholder:text-muted-foreground/80"
rows={1}
autoFocus
aria-label="Message input"
disabled={replayedRun.active}
/>
<ComposerAction />
</div>
</ComposerPrimitive.AttachmentDropzone>
</ComposerPrimitive.Root>
);
};
const ComposerWorkspaceStatus: FC<{ sessionId: string | null }> = ({
sessionId,
}) => {
const status = useJupyterWorkspaceStatus(sessionId);
if (!status?.connected) return null;
return (
<div className="flex items-center justify-center px-1 pt-0.5">
<div className="flex min-w-0 items-center gap-2 rounded-full border bg-muted/40 px-3 py-1 text-xs text-muted-foreground">
<span className="size-2 shrink-0 rounded-full bg-emerald-500" />
<span className="shrink-0 font-medium text-foreground">
Jupyter
</span>
{status.workspace_cwd ? (
<span className="hidden max-w-56 truncate sm:inline">
{status.workspace_cwd}
</span>
) : null}
{status.jupyter_tree_url ? (
<a
className="shrink-0 text-primary underline-offset-4 hover:underline"
href={status.jupyter_tree_url}
target="_blank"
rel="noreferrer"
>
</a>
) : null}
</div>
</div>
);
};
const ComposerAction: FC = () => {
const runtimeRunning = useAuiState((s) => s.thread.isRunning);
const replayedRun = useReplayedRunState();
const currentSessionId = useCurrentThreadSessionId({
includePending: true,
includeActive: true,
});
const cancelSessionId = replayedRun.sessionId ?? currentSessionId;
return (
<div className="aui-composer-action-wrapper relative flex items-center justify-between">
<div className="flex items-center gap-1">
<ComposerAddAttachment />
<ComposerAssistButtons />
</div>
<ComposerContextStatus />
{!runtimeRunning && !replayedRun.active ? (
<ComposerPrimitive.Send asChild>
<TooltipIconButton
tooltip="Send message"
side="bottom"
type="button"
variant="default"
size="icon"
className="aui-composer-send size-8 rounded-full"
aria-label="Send message"
>
<ArrowUpIcon className="aui-composer-send-icon size-4" />
</TooltipIconButton>
</ComposerPrimitive.Send>
) : null}
{runtimeRunning ? (
<Button
type="button"
variant="default"
size="icon"
className="aui-composer-cancel size-8 rounded-full"
aria-label="Stop generating"
disabled={!cancelSessionId}
onClick={() => {
if (!cancelSessionId) return;
void cancelLatestRun(cancelSessionId, replayedRun.runId).catch(
() => undefined,
);
}}
>
<SquareIcon className="aui-composer-cancel-icon size-3 fill-current" />
</Button>
) : null}
{!runtimeRunning && replayedRun.active ? (
<ReplayedRunCancelButton
sessionId={replayedRun.sessionId}
runId={replayedRun.runId}
/>
) : null}
</div>
);
};
const ReplayedRunCancelButton: FC<{
sessionId: string | null;
runId: string | null;
}> = ({ sessionId, runId }) => {
const { replaySession } = useClawSessionReplay();
const [cancelling, setCancelling] = useState(false);
return (
<Button
type="button"
variant="default"
size="icon"
className="aui-composer-cancel size-8 rounded-full"
aria-label="Stop generating"
disabled={!sessionId || cancelling}
onClick={async () => {
if (!sessionId) return;
setCancelling(true);
try {
await cancelLatestRun(sessionId, runId);
const runStatus = await fetchLatestRunStatus(sessionId);
const response = await fetch(`/api/claw/sessions/${sessionId}`, {
cache: "no-store",
});
if (response.ok) {
const payload = await response.json();
replaySession(
sessionId,
toReplayRepository(payload, sessionId, runStatus),
);
}
window.dispatchEvent(new Event("claw-sessions-changed"));
} finally {
setCancelling(false);
}
}}
>
<SquareIcon className="aui-composer-cancel-icon size-3 fill-current" />
</Button>
);
};
const ComposerContextStatus: FC = () => {
const status = useComposerContextStatus();
const totalTokens = status.contextBudget?.projected_input_tokens ?? 0;
const limit = status.contextBudget?.context_window_tokens ?? 0;
const percent = limit
? Math.min(100, Math.round((totalTokens / limit) * 100))
: 0;
return (
<div className="mx-2 hidden min-w-0 flex-1 items-center justify-end gap-1.5 sm:flex">
<ContextWindowButton
percent={percent}
totalTokens={totalTokens}
limit={limit}
contextBudget={status.contextBudget}
/>
<ModelPickerButton status={status} />
</div>
);
};
function ContextWindowButton({
percent,
totalTokens,
limit,
contextBudget,
}: {
percent: number;
totalTokens: number;
limit: number;
contextBudget?: ContextBudget;
}) {
const ringStyle = {
background: `conic-gradient(var(--primary) ${percent * 3.6}deg, var(--muted) 0deg)`,
};
return (
<PopoverPrimitive.Root>
<Tooltip>
<TooltipTrigger asChild>
<PopoverPrimitive.Trigger asChild>
<button
type="button"
className="flex size-8 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="上下文窗口"
>
<span
className="flex size-4 shrink-0 items-center justify-center rounded-full"
style={ringStyle}
>
<span className="size-2 rounded-full bg-background" />
</span>
</button>
</PopoverPrimitive.Trigger>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-64 text-center text-sm">
<div className="text-muted-foreground"></div>
<div>
{percent}% {Math.max(0, 100 - percent)}%
</div>
<div>
{formatCompactTokenCount(totalTokens)} {" "}
{formatCompactTokenCount(limit)}
</div>
</TooltipContent>
</Tooltip>
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
side="top"
align="end"
sideOffset={10}
className="z-50 w-64 rounded-2xl border bg-popover px-4 py-5 text-center text-popover-foreground shadow-lg outline-none"
>
<div className="text-muted-foreground text-sm"></div>
<div className="mt-1 font-medium text-2xl">
{percent}% {Math.max(0, 100 - percent)}%
</div>
<div className="mt-2 text-lg">
{formatCompactTokenCount(totalTokens)} {" "}
{formatCompactTokenCount(limit)}
</div>
<div className="mt-3 grid gap-1 text-muted-foreground text-xs">
<div>
{formatCompactTokenCount(
contextBudget?.soft_input_limit_tokens ?? 0,
)}
</div>
<div>
{formatCompactTokenCount(
contextBudget?.hard_input_limit_tokens ?? 0,
)}
</div>
<div>
{formatCompactTokenCount(
contextBudget?.reserved_output_tokens ?? 0,
)}
{formatCompactTokenCount(
contextBudget?.reserved_compaction_buffer_tokens ?? 0,
)}
</div>
<div>
{contextBudget?.token_counter_backend ?? "unknown"}
{contextBudget?.token_counter_accurate ? "(精确)" : "(估算)"}
</div>
</div>
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
</PopoverPrimitive.Root>
);
}
function ModelPickerButton({
status,
}: {
status: {
model?: string;
models: ModelOption[];
setModel: (model: string) => Promise<void>;
};
}) {
const [query, setQuery] = useState("");
const normalizedQuery = query.trim().toLowerCase();
const filteredModels = useMemo(() => {
if (!normalizedQuery) return status.models;
return status.models.filter((model) => {
const provider = model.provider ?? modelProvider(model.id);
return (
model.id.toLowerCase().includes(normalizedQuery) ||
provider.toLowerCase().includes(normalizedQuery)
);
});
}, [normalizedQuery, status.models]);
return (
<PopoverPrimitive.Root>
<PopoverPrimitive.Trigger asChild>
<button
type="button"
className="flex min-w-0 max-w-44 items-center gap-1.5 rounded-full bg-muted px-2.5 py-1 text-foreground text-sm transition-colors hover:bg-muted/80"
aria-label="切换模型"
>
<span className="min-w-0 truncate font-medium">
{compactModelLabel(status.model)}
</span>
<ChevronDownIcon className="size-3.5 shrink-0 text-muted-foreground" />
</button>
</PopoverPrimitive.Trigger>
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
side="top"
align="end"
sideOffset={10}
className="z-50 w-[min(36rem,calc(100vw-2rem))] rounded-2xl border bg-popover p-2 text-popover-foreground shadow-lg outline-none"
>
<div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">
</div>
<div className="relative mb-2">
<SearchIcon className="-translate-y-1/2 pointer-events-none absolute top-1/2 left-2.5 size-3.5 text-muted-foreground" />
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索模型或来源"
className="h-8 w-full rounded-lg border bg-background pr-2.5 pl-8 text-sm outline-none transition-colors placeholder:text-muted-foreground focus:border-ring"
/>
</div>
{status.models.length ? (
<div className="h-[min(24rem,calc(100vh-12rem))] overflow-y-auto pr-1">
{filteredModels.length ? (
filteredModels.map((model) => (
<button
key={model.id}
type="button"
className="flex h-8 w-full items-center justify-between gap-2 rounded-lg px-2 text-left text-sm hover:bg-muted"
onClick={() => status.setModel(model.id)}
>
<span className="min-w-0 flex-1 truncate">{model.id}</span>
<span className="shrink-0 text-muted-foreground text-xs">
{model.provider ?? modelProvider(model.id)}
</span>
{model.id === status.model ? (
<CheckIcon className="size-3.5 shrink-0" />
) : null}
</button>
))
) : (
<div className="px-2 py-8 text-center text-muted-foreground text-sm">
</div>
)}
</div>
) : (
<div className="px-2 py-2 text-muted-foreground text-sm">
</div>
)}
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
</PopoverPrimitive.Root>
);
}
function useComposerContextStatus() {
const isRunning = useAuiState((s) => s.thread.isRunning);
const latestSessionId = useAuiState((s) =>
findLatestMessageMetadataValue(s.thread.messages, "sessionId"),
);
const wasRunningRef = useRef(false);
const [status, setStatus] = useState<{
model?: string;
contextBudget?: ContextBudget;
sessionId?: string | null;
models: ModelOption[];
setModel: (model: string) => Promise<void>;
}>({
models: [],
setModel: async () => {},
});
useEffect(() => {
const cleanLatestSessionId = normalizeSessionId(latestSessionId);
if (cleanLatestSessionId) {
writeActiveSessionId(cleanLatestSessionId);
window.setTimeout(() => {
window.dispatchEvent(new Event("claw-sessions-changed"));
}, 0);
}
}, [latestSessionId]);
useEffect(() => {
if (wasRunningRef.current && !isRunning) {
window.dispatchEvent(new Event("claw-sessions-changed"));
window.setTimeout(() => {
window.dispatchEvent(new Event("claw-sessions-changed"));
}, 1500);
}
wasRunningRef.current = isRunning;
}, [isRunning]);
useEffect(() => {
let cancelled = false;
async function refresh() {
try {
const stateResponse = await fetch("/api/claw/state", {
cache: "no-store",
});
const statePayload = stateResponse.ok
? ((await stateResponse.json()) as ClawState)
: {};
let sessionId = normalizeSessionId(
normalizeSessionId(latestSessionId) ||
normalizeSessionId(readActiveSessionId()) ||
normalizeSessionId(statePayload.active_session_id),
);
if (sessionId) writeActiveSessionId(sessionId);
let sessionPayload: ClawStoredSession | null = null;
let contextBudget: ContextBudget | undefined;
if (sessionId) {
const sessionResponse = await fetch(
`/api/claw/sessions/${sessionId}`,
{
cache: "no-store",
},
);
if (sessionResponse.ok) {
sessionPayload =
(await sessionResponse.json()) as ClawStoredSession;
} else if (sessionResponse.status === 404) {
if (
sessionId === normalizeSessionId(readPendingWorkspaceSessionId())
) {
sessionPayload = null;
} else {
clearActiveSessionId();
sessionId = null;
}
}
}
const budgetUrl = new URL(
"/api/claw/context-budget",
window.location.origin,
);
if (sessionId) budgetUrl.searchParams.set("session_id", sessionId);
const budgetResponse = await fetch(budgetUrl, { cache: "no-store" });
contextBudget = budgetResponse.ok
? ((await budgetResponse.json()) as ContextBudget)
: undefined;
if (cancelled) return;
setStatus((current) => ({
model:
statePayload.model ?? contextBudget?.model ?? sessionPayload?.model,
contextBudget,
sessionId,
models: current.models,
setModel: current.setModel,
}));
} catch {
if (!cancelled) {
setStatus((current) => ({
...current,
}));
}
}
}
refresh();
const onRefresh = () => refresh();
window.addEventListener("focus", onRefresh);
window.addEventListener("claw-active-session-cleared", onRefresh);
const interval = window.setInterval(refresh, isRunning ? 2000 : 8000);
return () => {
cancelled = true;
window.removeEventListener("focus", onRefresh);
window.removeEventListener("claw-active-session-cleared", onRefresh);
window.clearInterval(interval);
};
}, [isRunning, latestSessionId]);
useEffect(() => {
let cancelled = false;
async function refreshModels() {
try {
const response = await fetch("/api/claw/models", {
cache: "no-store",
});
const payload = (await response.json()) as { models?: ModelOption[] };
if (!cancelled && response.ok) {
setStatus((current) => ({
...current,
models: Array.isArray(payload.models) ? payload.models : [],
}));
}
} catch {
if (!cancelled) {
setStatus((current) => ({ ...current, models: [] }));
}
}
}
refreshModels();
return () => {
cancelled = true;
};
}, []);
const setModel = useCallback(async (model: string) => {
const response = await fetch("/api/claw/state", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ model }),
});
const payload = (await response.json()) as ClawState;
if (!response.ok) return;
setStatus((current) => ({
...current,
model: payload.model ?? model,
contextBudget: undefined,
}));
}, []);
useEffect(() => {
setStatus((current) => ({ ...current, setModel }));
}, [setModel]);
return status;
}
function findLatestMessageMetadataValue(
messages: readonly unknown[],
key: string,
) {
for (const message of [...messages].reverse()) {
if (!message || typeof message !== "object") continue;
const metadata = (message as { metadata?: unknown }).metadata;
const value = readMetadataValue(metadata, key);
if (value !== undefined) return value;
}
return undefined;
}
function readMetadataValue(metadata: unknown, key: string): unknown {
if (!metadata || typeof metadata !== "object") return undefined;
const record = metadata as Record<string, unknown>;
if (record[key] !== undefined) return record[key];
const custom = record.custom;
if (custom && typeof custom === "object") {
return (custom as Record<string, unknown>)[key];
}
return undefined;
}
function normalizeSessionId(value: unknown) {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed || null;
}
function formatCompactTokenCount(value: number) {
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`;
return `${value}`;
}
function compactModelLabel(model?: string) {
if (!model) return "模型";
const value = model.includes("/") ? model.split("/").at(-1) || model : model;
if (value.toLowerCase().startsWith("mimo-v2.5")) return "MiMo 2.5";
if (value.toLowerCase().startsWith("mimo-v2")) return "MiMo 2";
if (value.toLowerCase().startsWith("deepseek")) return "DeepSeek";
if (value.toLowerCase().startsWith("qwen")) return "Qwen";
if (value.toLowerCase().startsWith("minimax")) return "MiniMax";
return value;
}
function modelProvider(model: string) {
if (model.includes("/")) return model.split("/", 1)[0] || "unknown";
return "default";
}
const ComposerAssistButtons: FC = () => {
return (
<div className="flex items-center gap-1">
<SlashCommandDialog />
<SkillInsertDialog />
<PromptInsertDialog
title="常用工具"
triggerIcon={<WrenchIcon className="size-4" />}
triggerLabel=""
items={TOOL_PROMPTS}
/>
</div>
);
};
const TOOL_PROMPTS = [
{
title: "data_agent_profile_router_sessions",
description: "查看 parquet 线上 session 数据字段、规模和样例。",
prompt:
"请优先使用 data_agent_profile_router_sessions 了解我指定的 router_session_parquet 数据目录,再给出可用字段和初步挖掘建议。",
},
{
title: "data_agent_search_router_sessions",
description: "用关键词、正则、domain、device 等条件筛选候选。",
prompt:
"请优先使用 data_agent_search_router_sessions 按我的条件筛选线上候选,并说明筛选策略、命中数量和样例质量。",
},
{
title: "data_agent_normalize_dataset_draft",
description: "把自然语言 draft 转换为 canonical records。",
prompt:
"请在我确认数据内容后,使用 data_agent_normalize_dataset_draft 把 draft 转换成 canonical records。",
},
{
title: "data_agent_validate_dataset_records",
description: "校验 canonical records 是否符合结构约束。",
prompt:
"请使用 data_agent_validate_dataset_records 校验当前 canonical records,并解释每类错误应该如何修复。",
},
];
function PromptInsertDialog({
title,
triggerIcon,
triggerLabel,
items,
}: {
title: string;
triggerIcon: ReactNode;
triggerLabel: string;
items: { title: string; description: string; prompt: string }[];
}) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 gap-1 rounded-full px-2 text-muted-foreground"
aria-label={title}
>
{triggerIcon}
{triggerLabel ? (
<span className="hidden text-xs md:inline">{triggerLabel}</span>
) : null}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<div className="grid gap-2">
{items.map((item) => (
<button
key={item.title}
type="button"
className="rounded-lg border px-3 py-2 text-left transition-colors hover:bg-muted"
onClick={() => {
insertComposerText(item.prompt);
setOpen(false);
}}
>
<div className="font-medium text-sm">{item.title}</div>
<div className="mt-1 text-muted-foreground text-xs">
{item.description}
</div>
</button>
))}
</div>
</DialogContent>
</Dialog>
);
}
function SlashCommandDialog() {
const [open, setOpen] = useState(false);
const [commands, setCommands] = useState<SlashCommandSummary[]>([]);
const [status, setStatus] = useState(
"点击后读取当前后端 slash command 列表。",
);
async function loadCommands() {
setStatus("正在读取 slash command 列表...");
try {
const response = await fetch("/api/claw/slash-commands", {
cache: "no-store",
});
const payload = (await response.json()) as
| SlashCommandSummary[]
| { error?: string };
if (!response.ok || !Array.isArray(payload)) {
throw new Error(
Array.isArray(payload) ? "读取失败" : (payload.error ?? "读取失败"),
);
}
setCommands(payload);
setStatus(payload.length ? "" : "当前没有可用 slash command。");
} catch (err) {
setStatus(err instanceof Error ? err.message : "读取 slash command 失败");
}
}
return (
<Dialog
open={open}
onOpenChange={(open) => {
setOpen(open);
if (open) loadCommands();
}}
>
<DialogTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 rounded-full px-2 font-mono text-muted-foreground text-sm"
aria-label="Slash Commands"
>
/
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
{status ? (
<p className="text-muted-foreground text-sm">{status}</p>
) : null}
<div className="grid max-h-96 gap-2 overflow-y-auto">
{commands.map((command) => {
const commandText = `/${command.primary}`;
const aliases = command.names
.filter((name) => name !== command.primary)
.map((name) => `/${name}`)
.join(" ");
return (
<button
key={command.primary}
type="button"
className="rounded-lg border px-3 py-2 text-left transition-colors hover:bg-muted"
onClick={() => {
insertComposerText(`${commandText}\n`);
setOpen(false);
}}
>
<div className="font-medium font-mono text-sm">
{commandText}
</div>
<div className="mt-1 line-clamp-2 text-muted-foreground text-xs">
{command.description || "暂无说明"}
</div>
{aliases ? (
<div className="mt-1 text-muted-foreground/80 text-[11px]">
{aliases}
</div>
) : null}
</button>
);
})}
</div>
</DialogContent>
</Dialog>
);
}
function SkillInsertDialog() {
const [open, setOpen] = useState(false);
const [skills, setSkills] = useState<SkillSummary[]>([]);
const [status, setStatus] = useState("点击后读取当前后端 Skill 列表。");
const [updatingSkill, setUpdatingSkill] = useState<string | null>(null);
const [syncingSkills, setSyncingSkills] = useState(false);
const [bulkUpdatingSkills, setBulkUpdatingSkills] = useState(false);
const configurableSkills = skills.filter(
(skill) => skill.configurable !== false,
);
const allSkillsEnabled =
configurableSkills.length > 0 &&
configurableSkills.every((skill) => skill.enabled !== false);
async function loadSkills() {
setStatus("正在读取 Skill 列表...");
try {
const response = await fetch("/api/claw/skills", { cache: "no-store" });
const payload = (await response.json()) as
| SkillSummary[]
| { error?: string };
if (!response.ok || !Array.isArray(payload)) {
throw new Error(
Array.isArray(payload) ? "读取失败" : (payload.error ?? "读取失败"),
);
}
setSkills(payload);
setStatus(payload.length ? "" : "当前没有可用 Skill。");
} catch (err) {
setStatus(err instanceof Error ? err.message : "读取 Skill 失败");
}
}
async function syncSkills() {
setSyncingSkills(true);
setStatus("正在从 git 同步 Skill...");
try {
const response = await fetch("/api/claw/skills", {
method: "POST",
cache: "no-store",
});
const payload = (await response.json()) as SkillSyncResponse;
if (!response.ok || !Array.isArray(payload.skills)) {
throw new Error(payload.detail ?? payload.error ?? "同步 Skill 失败");
}
setSkills(payload.skills);
const changedCount = payload.changed_files?.length ?? 0;
if (payload.updated) {
const shortHash = payload.after
? payload.after.slice(0, 7)
: "最新提交";
setStatus(`Skill 已更新到 ${shortHash},变更 ${changedCount} 个文件。`);
} else {
setStatus("Skill 已是最新。");
}
} catch (err) {
setStatus(err instanceof Error ? err.message : "同步 Skill 失败");
} finally {
setSyncingSkills(false);
}
}
async function setSkillEnabled(skill: SkillSummary, enabled: boolean) {
if (skill.configurable === false) return;
const previous = skills;
setUpdatingSkill(skill.name);
setSkills((items) =>
items.map((item) =>
item.name === skill.name ? { ...item, enabled } : item,
),
);
try {
const response = await fetch("/api/claw/skills", {
method: "PATCH",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({ skill: skill.name, enabled }),
});
const payload = (await response.json()) as
| SkillSummary[]
| { error?: string; detail?: string };
if (!response.ok || !Array.isArray(payload)) {
throw new Error(
Array.isArray(payload)
? "更新失败"
: (payload.detail ?? payload.error ?? "更新失败"),
);
}
setSkills(payload);
setStatus(enabled ? `已启用 ${skill.name}` : `已停用 ${skill.name}`);
} catch (err) {
setSkills(previous);
setStatus(err instanceof Error ? err.message : "更新 Skill 失败");
} finally {
setUpdatingSkill(null);
}
}
async function setAllSkillsEnabled(enabled: boolean) {
if (!configurableSkills.length) return;
const previous = skills;
setBulkUpdatingSkills(true);
setSkills((items) =>
items.map((item) =>
item.configurable === false ? item : { ...item, enabled },
),
);
try {
const response = await fetch("/api/claw/skills", {
method: "PATCH",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({ apply_all: true, enabled }),
});
const payload = (await response.json()) as
| SkillSummary[]
| { error?: string; detail?: string };
if (!response.ok || !Array.isArray(payload)) {
throw new Error(
Array.isArray(payload)
? "更新失败"
: (payload.detail ?? payload.error ?? "更新失败"),
);
}
setSkills(payload);
setStatus(enabled ? "已启用全部 Skill。" : "已停用全部可选 Skill。");
} catch (err) {
setSkills(previous);
setStatus(err instanceof Error ? err.message : "批量更新 Skill 失败");
} finally {
setBulkUpdatingSkills(false);
}
}
return (
<Dialog
open={open}
onOpenChange={(open) => {
setOpen(open);
if (open) loadSkills();
}}
>
<DialogTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 gap-1 rounded-full px-2 text-muted-foreground"
aria-label="Skills"
>
<BookOpenIcon className="size-4" />
</Button>
</DialogTrigger>
<DialogContent className="gap-3 p-5">
<DialogHeader className="gap-1 pr-6">
<DialogTitle>Skills</DialogTitle>
</DialogHeader>
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 px-2.5"
disabled={!configurableSkills.length || bulkUpdatingSkills}
onClick={() => setAllSkillsEnabled(!allSkillsEnabled)}
>
{allSkillsEnabled ? "全取消" : "全选"}
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 gap-2 px-2.5"
disabled={syncingSkills}
onClick={syncSkills}
>
<RefreshCwIcon
className={cn("size-3.5", syncingSkills && "animate-spin")}
/>
Skill
</Button>
</div>
{status ? (
<p className="min-w-0 flex-1 truncate text-muted-foreground text-xs">
{status}
</p>
) : null}
</div>
<div className="grid max-h-96 gap-2 overflow-y-auto pt-1">
{skills.map((skill) => (
<div
key={skill.name}
className="flex items-start gap-3 rounded-lg border px-3 py-2 transition-colors hover:bg-muted"
>
<input
type="checkbox"
checked={skill.enabled !== false}
disabled={
skill.configurable === false || updatingSkill === skill.name
}
aria-label={`启用 ${skill.name}`}
className="mt-1 size-4 accent-primary"
onChange={(event) =>
setSkillEnabled(skill, event.currentTarget.checked)
}
/>
<button
type="button"
className="min-w-0 flex-1 text-left"
onClick={() => {
insertComposerText(`Use the ${skill.name} skill.\n\n`);
setOpen(false);
}}
>
<div className="flex items-center gap-2 font-medium text-sm">
<ListIcon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{skill.name}</span>
</div>
<div className="mt-1 line-clamp-2 text-muted-foreground text-xs">
{skill.description || skill.when_to_use || "暂无说明"}
</div>
</button>
</div>
))}
</div>
</DialogContent>
</Dialog>
);
}
function insertComposerText(text: string) {
window.dispatchEvent(
new CustomEvent("claw-composer-insert", {
detail: { text },
}),
);
}
const MessageError: FC = () => {
return (
<MessagePrimitive.Error>
<ErrorPrimitive.Root className="aui-message-error-root mt-2 rounded-md border border-destructive bg-destructive/10 p-3 text-destructive text-sm dark:bg-destructive/5 dark:text-red-200">
<ErrorPrimitive.Message className="aui-message-error-message line-clamp-2" />
</ErrorPrimitive.Root>
</MessagePrimitive.Error>
);
};
const AssistantMessage: FC = () => {
const ACTION_BAR_PT = "pt-1.5";
const ACTION_BAR_HEIGHT = `-mb-7.5 min-h-7.5 ${ACTION_BAR_PT}`;
const messageId = useAuiState((s) => s.message.id);
return (
<MessagePrimitive.Root
data-slot="aui_assistant-message-root"
data-role="assistant"
className="fade-in slide-in-from-bottom-1 relative animate-in duration-150"
>
<div
data-slot="aui_assistant-message-content"
className="wrap-break-word px-2 text-foreground leading-relaxed"
>
<MessagePrimitive.GroupedParts
groupBy={(part) => {
if (part.type === "reasoning" || part.type === "tool-call")
return ["group-chain"];
return null;
}}
>
{({ part }) => {
switch (part.type) {
case "group-chain":
return (
<ActivityChainSummary
messageId={messageId}
partIndex={part.indices[0]}
/>
);
case "text":
return (
<MarkdownText
text={part.text}
isRunning={part.status?.type === "running"}
/>
);
case "reasoning":
return <Reasoning {...part} />;
case "tool-call":
return part.toolUI ?? <ToolFallback {...part} />;
default:
return null;
}
}}
</MessagePrimitive.GroupedParts>
<MessageError />
</div>
<div
data-slot="aui_assistant-message-footer"
className={cn("ml-2 flex items-center", ACTION_BAR_HEIGHT)}
>
<BranchPicker />
<AssistantActionBar />
</div>
</MessagePrimitive.Root>
);
};
const ActivityChainSummary: FC<{
messageId: string;
partIndex: number | undefined;
}> = ({ messageId, partIndex }) => {
const { openItem } = useActivityPanel();
const metadataRunStartedAtMs = useAuiState((s) => {
const value = readMetadataValue(s.message.metadata, "runStartedAtMs");
return typeof value === "number" && Number.isFinite(value) ? value : null;
});
const contentRunStartedAtMs = useAuiState((s) =>
findNumberInActivityParts(s.message.content, "runStartedAtMs"),
);
const storedElapsedMs = useAuiState((s) => {
const value = readMetadataValue(s.message.metadata, "elapsedMs");
const contentValue = findElapsedMsInActivityParts(s.message.content);
if (typeof value !== "number" || !Number.isFinite(value)) {
return contentValue;
}
return typeof value === "number" && Number.isFinite(value)
? Math.max(0, Math.round(value))
: null;
});
const initialStartedAtMs = metadataRunStartedAtMs ?? contentRunStartedAtMs;
const [elapsedMs, setElapsedMs] = useState<number | null>(() => {
if (storedElapsedMs !== null) return storedElapsedMs;
if (initialStartedAtMs !== null) {
return Math.max(0, Date.now() - initialStartedAtMs);
}
return null;
});
const label = useAuiState((s) => {
const message = s.message;
const status =
message.status?.type === "running" || isReplayRunMessageId(messageId)
? "running"
: message.status?.type;
return summarizeActivityChainLabel(message.content, status, elapsedMs);
});
const active = useAuiState((s) => {
const message = s.message;
return (
message.status?.type === "running" || isReplayRunMessageId(messageId)
);
});
useEffect(() => {
if (!active) {
setElapsedMs(storedElapsedMs);
return;
}
const startedAt =
metadataRunStartedAtMs ??
contentRunStartedAtMs ??
(storedElapsedMs === null ? null : Date.now() - storedElapsedMs);
if (startedAt === null) {
setElapsedMs(storedElapsedMs);
return;
}
setElapsedMs(Math.max(0, Date.now() - startedAt));
const timer = window.setInterval(() => {
setElapsedMs(Math.max(0, Date.now() - startedAt));
}, 1000);
return () => window.clearInterval(timer);
}, [active, metadataRunStartedAtMs, contentRunStartedAtMs, storedElapsedMs]);
return (
<button
type="button"
className="mb-3 flex items-center gap-2 rounded-md px-1 py-1 text-muted-foreground text-sm transition-colors hover:text-foreground"
onClick={() => {
if (partIndex !== undefined) openItem(`${messageId}:${partIndex}`);
}}
>
<BrainIcon className={cn("size-4", active && "animate-pulse")} />
<span>{label}</span>
<span aria-hidden></span>
</button>
);
};
function isReplayRunMessageId(messageId: string) {
return messageId.includes("-run-");
}
type ActivitySummaryPart = {
type: string;
text?: string;
result?: unknown;
args?: unknown;
toolName?: string;
runStartedAtMs?: unknown;
elapsedMs?: unknown;
};
function findNumberInActivityParts(
content: readonly ActivitySummaryPart[],
key: keyof ActivitySummaryPart,
) {
for (const part of content) {
const value = part[key];
if (typeof value === "number" && Number.isFinite(value)) return value;
}
return null;
}
function findElapsedMsInActivityParts(content: readonly ActivitySummaryPart[]) {
for (const part of content) {
if (typeof part.elapsedMs === "number" && Number.isFinite(part.elapsedMs)) {
return Math.max(0, Math.round(part.elapsedMs));
}
const args = part.args;
if (!args || typeof args !== "object" || Array.isArray(args)) continue;
const value = (args as Record<string, unknown>).__claw_elapsed_ms;
if (typeof value === "number" && Number.isFinite(value)) {
return Math.max(0, Math.round(value));
}
}
return null;
}
function summarizeActivityChainLabel(
content: readonly ActivitySummaryPart[],
status: string | undefined,
elapsedMs: number | null = null,
) {
const toolParts = content.filter((part) => part.type === "tool-call");
const runningTool = toolParts.findLast((part) => part.result === undefined);
const activeTool = runningTool ?? toolParts.at(-1);
const reasoningText =
content.find((part) => part.type === "reasoning")?.text ?? "";
const lastReasoningLine = reasoningText
.trim()
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.at(-1);
const stageNote = activeTool ? getToolStageNote(activeTool.args) : "";
const activeDetail =
stageNote ||
(activeTool?.toolName ? `调用 ${activeTool.toolName}` : "") ||
(isGenericActivityLine(lastReasoningLine) ? "" : lastReasoningLine);
if (status === "running") {
const elapsed =
elapsedMs === null ? "" : `,用时 ${formatActivityDuration(elapsedMs)}`;
if (activeDetail) {
return `${compactActivityLabel(activeDetail, 15)}${elapsed}`;
}
return elapsed ? `思考中${elapsed}` : "思考中";
}
const label = compactActivityLabel(
activeDetail || lastReasoningLine || "思考完成",
15,
);
const finalDuration = extractDurationLabel(lastReasoningLine);
const elapsed =
finalDuration ||
(elapsedMs === null ? "" : formatActivityDuration(elapsedMs));
return elapsed ? `${label},用时 ${elapsed}` : label;
}
function getToolStageNote(value: unknown) {
if (!value || typeof value !== "object" || Array.isArray(value)) return "";
const note = (value as Record<string, unknown>).__claw_stage_note;
return typeof note === "string" ? note.trim() : "";
}
function isGenericActivityLine(value?: string) {
if (!value) return true;
return (
value.startsWith("思考中") ||
value.startsWith("思考并执行完成") ||
value.endsWith("输出中...")
);
}
function extractDurationLabel(value?: string) {
if (!value) return "";
const match = value.match(/用时\s*([^。,.\s]+)/);
return match?.[1] ?? "";
}
function formatActivityDuration(ms: number) {
const safeMs = Math.max(0, Math.round(ms));
if (safeMs < 1000) return `${safeMs}ms`;
const totalSeconds = Math.round(safeMs / 1000);
if (totalSeconds < 60) return `${totalSeconds}s`;
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return seconds > 0 ? `${minutes}min${seconds}s` : `${minutes}min`;
}
function compactActivityLabel(label: string, maxLength = 15) {
const normalized = label.replace(/\s+/g, " ").trim();
const progress = normalized.match(
/^进度[:]\s*批\s*(\d+)\s*\/\s*(\d+)(?:[,]\s*已生成\s*(\d+)\s*\/\s*(\d+))?/,
);
if (progress) {
const batch = `${progress[1]}/${progress[2]}`;
const count =
progress[3] && progress[4] ? `${progress[3]}/${progress[4]}` : "";
const compactProgress = `${batch}${count}`;
if (compactProgress.length <= maxLength) return compactProgress;
return batch;
}
if (normalized.length <= maxLength) return normalized;
return `${normalized.slice(0, Math.max(1, maxLength - 1))}`;
}
const AssistantActionBar: FC = () => {
return (
<ActionBarPrimitive.Root
hideWhenRunning
autohide="not-last"
className="aui-assistant-action-bar-root col-start-3 row-start-2 -ml-1 flex gap-1 text-muted-foreground"
>
<ActionBarPrimitive.Copy asChild>
<TooltipIconButton tooltip="Copy">
<AuiIf condition={(s) => s.message.isCopied}>
<CheckIcon />
</AuiIf>
<AuiIf condition={(s) => !s.message.isCopied}>
<CopyIcon />
</AuiIf>
</TooltipIconButton>
</ActionBarPrimitive.Copy>
<ActionBarPrimitive.Reload asChild>
<TooltipIconButton tooltip="Refresh">
<RefreshCwIcon />
</TooltipIconButton>
</ActionBarPrimitive.Reload>
<ActionBarMorePrimitive.Root>
<ActionBarMorePrimitive.Trigger asChild>
<TooltipIconButton
tooltip="More"
className="data-[state=open]:bg-accent"
>
<MoreHorizontalIcon />
</TooltipIconButton>
</ActionBarMorePrimitive.Trigger>
<ActionBarMorePrimitive.Content
side="bottom"
align="start"
className="aui-action-bar-more-content z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md"
>
<ActionBarPrimitive.ExportMarkdown asChild>
<ActionBarMorePrimitive.Item className="aui-action-bar-more-item flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground">
<DownloadIcon className="size-4" />
Export as Markdown
</ActionBarMorePrimitive.Item>
</ActionBarPrimitive.ExportMarkdown>
</ActionBarMorePrimitive.Content>
</ActionBarMorePrimitive.Root>
</ActionBarPrimitive.Root>
);
};
const UserMessage: FC = () => {
return (
<MessagePrimitive.Root
data-slot="aui_user-message-root"
className="fade-in slide-in-from-bottom-1 grid animate-in auto-rows-auto grid-cols-[minmax(72px,1fr)_auto] content-start gap-y-2 px-2 duration-150 [&:where(>*)]:col-start-2"
data-role="user"
>
<UserMessageAttachments />
<div className="aui-user-message-content-wrapper relative col-start-2 min-w-0">
<div className="aui-user-message-content wrap-break-word peer rounded-2xl bg-muted px-4 py-2.5 text-foreground empty:hidden">
<MessagePrimitive.Parts />
</div>
<div className="aui-user-action-bar-wrapper absolute top-1/2 left-0 -translate-x-full -translate-y-1/2 pr-2 peer-empty:hidden">
<UserActionBar />
</div>
</div>
<BranchPicker
data-slot="aui_user-branch-picker"
className="col-span-full col-start-1 row-start-3 -mr-1 justify-end"
/>
</MessagePrimitive.Root>
);
};
const UserActionBar: FC = () => {
return (
<ActionBarPrimitive.Root
hideWhenRunning
autohide="not-last"
className="aui-user-action-bar-root flex flex-col items-end"
>
<ActionBarPrimitive.Edit asChild>
<TooltipIconButton tooltip="Edit" className="aui-user-action-edit p-4">
<PencilIcon />
</TooltipIconButton>
</ActionBarPrimitive.Edit>
</ActionBarPrimitive.Root>
);
};
const EditComposer: FC = () => {
return (
<MessagePrimitive.Root
data-slot="aui_edit-composer-wrapper"
className="flex flex-col px-2"
>
<ComposerPrimitive.Root className="aui-edit-composer-root ml-auto flex w-full max-w-[85%] flex-col rounded-2xl bg-muted">
<ImeComposerInput
className="aui-edit-composer-input min-h-14 w-full resize-none bg-transparent p-4 text-foreground text-sm outline-none"
autoFocus
/>
<div className="aui-edit-composer-footer mx-3 mb-3 flex items-center gap-2 self-end">
<ComposerPrimitive.Cancel asChild>
<Button variant="ghost" size="sm">
Cancel
</Button>
</ComposerPrimitive.Cancel>
<ComposerPrimitive.Send asChild>
<Button size="sm">Update</Button>
</ComposerPrimitive.Send>
</div>
</ComposerPrimitive.Root>
</MessagePrimitive.Root>
);
};
const ImeComposerInput: FC<ComponentProps<typeof TextareaAutosize>> = ({
autoFocus,
onChange,
onCompositionEnd,
onCompositionStart,
onKeyDown,
disabled,
...props
}) => {
const aui = useAui();
const storeText = useAuiState((s) =>
s.composer.isEditing ? s.composer.text : "",
);
const runtimeDisabled = useAuiState(
(s) => s.thread.isDisabled || s.composer.dictation?.inputDisabled,
);
const [localText, setLocalText] = useState(storeText);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const isComposingRef = useRef(false);
const justSubmittedRef = useRef(false);
const isDisabled = runtimeDisabled || disabled;
useEffect(() => {
if (!isComposingRef.current) {
if (justSubmittedRef.current && storeText) return;
if (!storeText) justSubmittedRef.current = false;
setLocalText(storeText);
}
}, [storeText]);
useEffect(() => {
if (
!isComposingRef.current &&
localText !== storeText &&
aui.composer().getState().isEditing
) {
aui.composer().setText(localText);
}
}, [aui, localText, storeText]);
useEffect(() => {
if (!autoFocus || isDisabled) return;
const textarea = textareaRef.current;
if (!textarea) return;
textarea.focus({ preventScroll: true });
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
}, [autoFocus, isDisabled]);
useEffect(() => {
const clearAfterSubmit = () => {
justSubmittedRef.current = true;
setLocalText("");
scheduleSessionListRefresh();
};
const unsubscribeComposer = aui.on("composer.send", clearAfterSubmit);
const unsubscribeRun = aui.on("thread.runStart", clearAfterSubmit);
return () => {
unsubscribeComposer();
unsubscribeRun();
};
}, [aui]);
const syncComposerText = useCallback(
(value: string) => {
if (!aui.composer().getState().isEditing) return;
aui.composer().setText(value);
},
[aui],
);
useEffect(() => {
const textarea = textareaRef.current;
if (!textarea) return;
const handleInsert = (event: Event) => {
const insertText = (event as ComposerInsertEvent).detail?.text;
if (!insertText) return;
const nextText = [localText.trimEnd(), insertText.trim()]
.filter(Boolean)
.join("\n\n");
setLocalText(nextText);
syncComposerText(nextText);
requestAnimationFrame(() => {
textarea.focus({ preventScroll: true });
textarea.setSelectionRange(nextText.length, nextText.length);
});
};
window.addEventListener("claw-composer-insert", handleInsert);
return () =>
window.removeEventListener("claw-composer-insert", handleInsert);
}, [localText, syncComposerText]);
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
onKeyDown?.(event);
if (event.defaultPrevented || isDisabled) return;
if (
event.nativeEvent.isComposing ||
isComposingRef.current ||
event.key !== "Enter" ||
event.shiftKey
) {
return;
}
const threadState = aui.thread().getState();
if (threadState.isRunning && !threadState.capabilities.queue) return;
event.preventDefault();
syncComposerText(localText);
aui.composer().send();
justSubmittedRef.current = true;
setLocalText("");
};
return (
<TextareaAutosize
{...props}
ref={textareaRef}
value={localText}
disabled={isDisabled}
onChange={(event) => {
onChange?.(event);
const nextText = event.currentTarget.value;
justSubmittedRef.current = false;
setLocalText(nextText);
const isNativeComposing =
(event.nativeEvent as { isComposing?: boolean }).isComposing === true;
if (!isNativeComposing && !isComposingRef.current) {
syncComposerText(nextText);
}
}}
onCompositionStart={(event) => {
onCompositionStart?.(event);
isComposingRef.current = true;
}}
onCompositionEnd={(event) => {
onCompositionEnd?.(event);
isComposingRef.current = false;
const target = event.currentTarget;
queueMicrotask(() => {
const nextText = target.value;
setLocalText(nextText);
syncComposerText(nextText);
});
}}
onKeyDown={handleKeyDown}
/>
);
};
const BranchPicker: FC<BranchPickerPrimitive.Root.Props> = ({
className,
...rest
}) => {
return (
<BranchPickerPrimitive.Root
hideWhenSingleBranch
className={cn(
"aui-branch-picker-root mr-2 -ml-2 inline-flex items-center text-muted-foreground text-xs",
className,
)}
{...rest}
>
<BranchPickerPrimitive.Previous asChild>
<TooltipIconButton tooltip="Previous">
<ChevronLeftIcon />
</TooltipIconButton>
</BranchPickerPrimitive.Previous>
<span className="aui-branch-picker-state font-medium">
<BranchPickerPrimitive.Number /> / <BranchPickerPrimitive.Count />
</span>
<BranchPickerPrimitive.Next asChild>
<TooltipIconButton tooltip="Next">
<ChevronRightIcon />
</TooltipIconButton>
</BranchPickerPrimitive.Next>
</BranchPickerPrimitive.Root>
);
};