1491 lines
43 KiB
TypeScript
1491 lines
43 KiB
TypeScript
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(() => {
|
||
const cleanLatestSessionId = normalizeSessionId(latestSessionId);
|
||
if (cleanLatestSessionId) {
|
||
window.localStorage.setItem("claw.activeSessionId", 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)
|
||
: {};
|
||
const sessionId = normalizeSessionId(
|
||
normalizeSessionId(latestSessionId) ||
|
||
normalizeSessionId(
|
||
window.localStorage.getItem("claw.activeSessionId"),
|
||
) ||
|
||
normalizeSessionId(statePayload.active_session_id),
|
||
);
|
||
if (sessionId)
|
||
window.localStorage.setItem("claw.activeSessionId", sessionId);
|
||
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 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="常用工具"
|
||
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 startedAtRef = useRef(Date.now());
|
||
const [elapsedMs, setElapsedMs] = useState(0);
|
||
const label = useAuiState((s) => {
|
||
const message = s.message;
|
||
return summarizeActivityChainLabel(
|
||
message.content,
|
||
message.status?.type,
|
||
elapsedMs,
|
||
);
|
||
});
|
||
const active = useAuiState((s) => {
|
||
const message = s.message;
|
||
return message.status?.type === "running";
|
||
});
|
||
|
||
useEffect(() => {
|
||
if (!active) return;
|
||
setElapsedMs(Date.now() - startedAtRef.current);
|
||
const timer = window.setInterval(() => {
|
||
setElapsedMs(Date.now() - startedAtRef.current);
|
||
}, 1000);
|
||
return () => window.clearInterval(timer);
|
||
}, [active]);
|
||
|
||
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>
|
||
);
|
||
};
|
||
|
||
type ActivitySummaryPart = {
|
||
type: string;
|
||
text?: string;
|
||
result?: unknown;
|
||
args?: unknown;
|
||
toolName?: string;
|
||
};
|
||
|
||
function summarizeActivityChainLabel(
|
||
content: readonly ActivitySummaryPart[],
|
||
status: string | undefined,
|
||
elapsedMs = 0,
|
||
) {
|
||
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 = `用时 ${formatActivityDuration(elapsedMs)}`;
|
||
if (activeDetail) {
|
||
return `${compactActivityLabel(activeDetail, 15)},${elapsed}`;
|
||
}
|
||
return `思考中,${elapsed}`;
|
||
}
|
||
|
||
const label = compactActivityLabel(
|
||
activeDetail || lastReasoningLine || "思考完成",
|
||
15,
|
||
);
|
||
const finalDuration = extractDurationLabel(lastReasoningLine);
|
||
const elapsed = finalDuration || formatActivityDuration(elapsedMs);
|
||
return `${label},用时 ${elapsed}`;
|
||
}
|
||
|
||
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();
|
||
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("");
|
||
};
|
||
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>
|
||
);
|
||
};
|