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

1394 lines
40 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,
SquareIcon,
WrenchIcon,
} from "lucide-react";
import { Popover as PopoverPrimitive } from "radix-ui";
import type { ComponentProps, FC, KeyboardEvent, ReactNode } from "react";
import { useCallback, useEffect, 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 { 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,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
type ComposerInsertEvent = CustomEvent<{ text: string }>;
type SkillSummary = {
name: string;
description?: string;
when_to_use?: 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;
};
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;
};
export const Thread: FC = () => {
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>
);
};
const ChatTopActions: FC = () => {
const { openFiles } = useActivityPanel();
const latestSessionId = useAuiState((s) =>
findLatestMessageMetadataValue(s.thread.messages, "sessionId"),
);
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(
typeof latestSessionId === "string" ? latestSessionId : null,
);
}}
>
<FileTextIcon className="size-4 text-muted-foreground" />
</button>
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
</PopoverPrimitive.Root>
</div>
);
};
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 = () => {
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 />
<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"
/>
<ComposerAction />
</div>
</ComposerPrimitive.AttachmentDropzone>
</ComposerPrimitive.Root>
);
};
const ComposerAction: FC = () => {
return (
<div className="aui-composer-action-wrapper relative flex items-center justify-between">
<div className="flex items-center gap-1">
<ComposerAddAttachment />
<ComposerAssistButtons />
</div>
<ComposerContextStatus />
<AuiIf condition={(s) => !s.thread.isRunning}>
<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>
</AuiIf>
<AuiIf condition={(s) => s.thread.isRunning}>
<ComposerPrimitive.Cancel asChild>
<Button
type="button"
variant="default"
size="icon"
className="aui-composer-cancel size-8 rounded-full"
aria-label="Stop generating"
>
<SquareIcon className="aui-composer-cancel-icon size-3 fill-current" />
</Button>
</ComposerPrimitive.Cancel>
</AuiIf>
</div>
);
};
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>;
};
}) {
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-72 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>
{status.models.length ? (
status.models.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">
{modelProvider(model.id)}
</span>
{model.id === status.model ? (
<CheckIcon className="size-3.5 shrink-0" />
) : null}
</button>
))
) : (
<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(() => {
if (typeof latestSessionId === "string" && latestSessionId) {
window.localStorage.setItem("claw.activeSessionId", latestSessionId);
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)
: {};
const sessionId =
(typeof latestSessionId === "string" && latestSessionId) ||
window.localStorage.getItem("claw.activeSessionId") ||
statePayload.active_session_id ||
null;
let sessionPayload: ClawStoredSession | null = null;
let contextBudget: ContextBudget | undefined;
if (sessionId) {
const sessionResponse = await fetch(
`/api/claw/sessions/${sessionId}`,
{
cache: "no-store",
},
);
sessionPayload = sessionResponse.ok
? ((await sessionResponse.json()) as ClawStoredSession)
: 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:
contextBudget?.model ?? sessionPayload?.model ?? statePayload.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 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="常用工具"
description="这里用于调试和明确意图,最终是否调用仍由 Agent 决策。"
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,
description,
triggerIcon,
triggerLabel,
items,
}: {
title: string;
description: 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>
<DialogDescription>{description}</DialogDescription>
</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>
<DialogDescription>
`/` WebUI
</DialogDescription>
</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 列表。");
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 失败");
}
}
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>
<DialogHeader>
<DialogTitle>Skills</DialogTitle>
<DialogDescription>
Skill 便
</DialogDescription>
</DialogHeader>
{status ? (
<p className="text-muted-foreground text-sm">{status}</p>
) : null}
<div className="grid max-h-96 gap-2 overflow-y-auto">
{skills.map((skill) => (
<button
key={skill.name}
type="button"
className="rounded-lg border px-3 py-2 text-left transition-colors hover:bg-muted"
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 text-muted-foreground" />
{skill.name}
</div>
<div className="mt-1 line-clamp-2 text-muted-foreground text-xs">
{skill.description || skill.when_to_use || "暂无说明"}
</div>
</button>
))}
</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 label = useAuiState((s) => {
const message = s.message;
return summarizeActivityChainLabel(message.content, message.status?.type);
});
const active = useAuiState((s) => {
const message = s.message;
return message.status?.type === "running";
});
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 summarizeActivityChainLabel(
content: readonly { type: string; text?: string; result?: unknown }[],
status: string | undefined,
) {
const toolCount = content.filter((part) => part.type === "tool-call").length;
const runningToolCount = content.filter(
(part) => part.type === "tool-call" && part.result === undefined,
).length;
const reasoningText =
content.find((part) => part.type === "reasoning")?.text ?? "";
const lastReasoningLine = reasoningText
.trim()
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.at(-1);
if (status === "running") {
return runningToolCount > 0 ? "调用工具中" : "思考中";
}
const label = compactActivityLabel(lastReasoningLine || "思考完成");
return toolCount > 0 ? `${label} · 调用了 ${toolCount} 个工具` : label;
}
function compactActivityLabel(label: string) {
const normalized = label.replace(/\s+/g, " ").trim();
if (normalized.length <= 36) return normalized;
return `${normalized.slice(0, 35)}`;
}
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 isDisabled = runtimeDisabled || disabled;
useEffect(() => {
if (!isComposingRef.current) {
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]);
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();
setLocalText("");
};
return (
<TextareaAutosize
{...props}
ref={textareaRef}
value={localText}
disabled={isDisabled}
onChange={(event) => {
onChange?.(event);
const nextText = event.currentTarget.value;
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>
);
};