Files
zk-data-agent/frontend/app/components/assistant-ui/thread.tsx
T
2026-05-06 16:18:32 +08:00

1205 lines
35 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,
ListIcon,
MoreHorizontalIcon,
PencilIcon,
RefreshCwIcon,
SlashIcon,
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 UsageSummary = {
input_tokens?: number;
output_tokens?: number;
total_tokens?: number;
reasoning_tokens?: number;
};
type ClawState = {
model?: string;
active_session_id?: string | null;
};
type ClawStoredSession = {
session_id?: string;
model?: string;
usage?: UsageSummary;
};
type ModelOption = {
id: string;
};
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-scroll scroll-smooth"
>
<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 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 justify-center px-4">
<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">
Hello there!
</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">
How can I help you today?
</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.usage?.total_tokens ?? 0;
const limit = inferContextLimit(status.model);
const percent = Math.min(100, Math.round((totalTokens / limit) * 100));
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}
/>
<ModelPickerButton status={status} />
</div>
);
};
function ContextWindowButton({
percent,
totalTokens,
limit,
}: {
percent: number;
totalTokens: number;
limit: number;
}) {
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 font-semibold text-sm">
Codex
</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 latestUsage = useAuiState((s) =>
findLatestMessageMetadataValue(s.thread.messages, "usage"),
) as UsageSummary | undefined;
const [status, setStatus] = useState<{
model?: string;
usage?: UsageSummary;
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);
}
}, [latestSessionId]);
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;
if (sessionId) {
const sessionResponse = await fetch(
`/api/claw/sessions/${sessionId}`,
{
cache: "no-store",
},
);
sessionPayload = sessionResponse.ok
? ((await sessionResponse.json()) as ClawStoredSession)
: null;
}
if (cancelled) return;
setStatus((current) => ({
model: sessionPayload?.model ?? statePayload.model,
usage: latestUsage ?? sessionPayload?.usage,
sessionId,
models: current.models,
setModel: current.setModel,
}));
} catch {
if (!cancelled) {
setStatus((current) => ({
...current,
usage: latestUsage ?? current.usage,
}));
}
}
}
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, latestUsage]);
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,
}));
}, []);
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 inferContextLimit(model?: string) {
const lower = (model ?? "").toLowerCase();
if (lower.includes("qwen3-32b")) return 128_000;
if (lower.includes("deepseek")) return 128_000;
if (lower.includes("mimo")) return 128_000;
return 128_000;
}
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">
<PromptInsertDialog
title="快捷入口"
description="选择一个数据开发入口,会把可编辑提示词插入输入框。"
triggerIcon={<SlashIcon className="size-4" />}
triggerLabel="/"
items={QUICK_PROMPTS}
/>
<SkillInsertDialog />
<PromptInsertDialog
title="常用工具"
description="这里用于调试和明确意图,最终是否调用仍由 Agent 决策。"
triggerIcon={<WrenchIcon className="size-4" />}
triggerLabel="Tools"
items={TOOL_PROMPTS}
/>
</div>
);
};
const QUICK_PROMPTS = [
{
title: "产品定义生成数据",
description: "从产品定义、例子 query 或手写边界整理数据目标。",
prompt:
"请根据我提供的产品定义或手写规则,先总结 query 语义边界、目标标签和排除范围,给我 review 数据生成目标,确认后再生成 canonical records。",
},
{
title: "线上数据挖掘",
description: "构造关键词/正则/domain 条件,采样候选并转换线上样本。",
prompt:
"请进入线上数据挖掘流程:先理解我的挖掘需求,设计筛选策略并采样候选,待我 review 后,把选中的线上数据直接转换为 canonical records,不要走生成数据分支。",
},
{
title: "校验 records",
description: "检查 canonical records 的字段、标签和多轮上下文。",
prompt:
"请校验我提供的 canonical records,重点检查 query、target、prev_session、timestamp 和标签格式,并输出需要修复的问题。",
},
];
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}
<span className="hidden text-xs md:inline">{triggerLabel}</span>
</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 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" />
<span className="hidden text-xs md:inline">Skills</span>
</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 />;
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 = lastReasoningLine || "思考完成";
return toolCount > 0 ? `${label} · 调用了 ${toolCount} 个工具` : label;
}
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>
);
};