Files
homarr/apps/nextjs/src/components/assistant/assistant-panel.tsx
T

1542 lines
53 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.
"use client";
import type { ComponentPropsWithoutRef } from "react";
import { createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
import type {
FileMessagePartProps,
ImageMessagePartProps,
ReasoningMessagePartProps,
SourceMessagePartProps,
ToolCallMessagePartProps,
Unstable_TriggerItem,
} from "@assistant-ui/react";
import type { MessageStatus } from "@assistant-ui/react";
import {
ActionBarPrimitive,
AttachmentPrimitive,
BranchPickerPrimitive,
ComposerPrimitive,
ErrorPrimitive,
MessagePartPrimitive,
MessagePrimitive,
SelectionToolbarPrimitive,
ThreadListItemPrimitive,
ThreadListPrimitive,
ThreadPrimitive,
useAui,
useAuiState,
unstable_useMentionAdapter,
unstable_useSlashCommandAdapter,
} from "@assistant-ui/react";
import { MarkdownTextPrimitive } from "@assistant-ui/react-markdown";
import {
ActionIcon,
Anchor,
Badge,
Box,
Button,
Collapse,
Divider,
FocusTrap,
Group,
Loader,
Popover,
RingProgress,
ScrollArea,
Select,
Stack,
Text,
ThemeIcon,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import { useWindowEvent } from "@mantine/hooks";
import {
IconActivityHeartbeat,
IconAlertTriangle,
IconApps,
IconArrowUp,
IconAt,
IconBrain,
IconCheck,
IconChevronDown,
IconChevronLeft,
IconChevronRight,
IconChevronUp,
IconCopy,
IconDownload,
IconCommand,
IconFile,
IconFileExport,
IconLink,
IconMessage,
IconMinus,
IconPaperclip,
IconPencil,
IconHistory,
IconPlayerStop,
IconPlus,
IconQuote,
IconRefresh,
IconRobot,
IconSearch,
IconSparkles,
IconThumbDown,
IconThumbUp,
IconTool,
IconTrash,
IconX,
} from "@tabler/icons-react";
import remarkBreaks from "remark-breaks";
import remarkGfm from "remark-gfm";
import { clientApi } from "@homarr/api/client";
import { assistantReasoningModes } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import classes from "./assistant-panel.module.css";
import { normalizeAssistantMarkdown } from "./assistant-markdown";
import { getAssistantTelemetry, getAssistantUsage } from "./assistant-message-metadata";
import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assistant-preferences";
import { getToolResultPresentation } from "./assistant-tool-result";
interface AssistantPanelProps {
opened: boolean;
onOpen: () => void;
onClose: () => void;
onDismissActivity: () => void;
activityDismissed: boolean;
isRunning: boolean;
unreadCount: number;
latestAssistantText: string;
latestUserText: string;
latestStatus: MessageStatus | undefined;
modelId: string | null;
models: AssistantRuntimeModelOption[];
modelOptionsLoading: boolean;
reasoning: AssistantReasoningMode;
onModelChange: (modelId: string) => void;
onReasoningChange: (reasoning: AssistantReasoningMode) => void;
}
const markdownRemarkPlugins = [remarkGfm, remarkBreaks];
const MarkdownLink = ({ href, children, ...props }: ComponentPropsWithoutRef<"a">) => {
const external = href !== undefined && /^https?:\/\//iu.test(href);
return (
<a href={href} target={external ? "_blank" : undefined} rel={external ? "noreferrer" : undefined} {...props}>
{children}
</a>
);
};
const MarkdownTable = ({ children, ...props }: ComponentPropsWithoutRef<"table">) => (
<div className={classes.markdownTableWrap}>
<table {...props}>{children}</table>
</div>
);
const AssistantTextPart = () => (
<MarkdownTextPrimitive
className={classes.messageMarkdown}
preprocess={normalizeAssistantMarkdown}
remarkPlugins={markdownRemarkPlugins}
components={{ a: MarkdownLink, table: MarkdownTable }}
defer
/>
);
const UserTextPart = () => <MessagePartPrimitive.Text className={classes.messageText} />;
const Attachment = ({ removable = false }: { removable?: boolean }) => {
const t = useScopedI18n("common.assistant");
return (
<AttachmentPrimitive.Root className={classes.attachment}>
<AttachmentPrimitive.unstable_Thumb className={classes.attachmentThumb} />
<Text size="xs" lineClamp={1} className={classes.attachmentName}>
<AttachmentPrimitive.Name />
</Text>
{removable && (
<AttachmentPrimitive.Remove asChild>
<ActionIcon variant="subtle" color="gray" size="xs" aria-label={t("removeAttachment")}>
<IconX size={12} />
</ActionIcon>
</AttachmentPrimitive.Remove>
)}
</AttachmentPrimitive.Root>
);
};
const SentAttachment = () => <Attachment />;
const ReasoningPart = ({ text, status }: ReasoningMessagePartProps) => {
const t = useScopedI18n("common.assistant");
return (
<details className={classes.reasoning} open={status.type === "running"}>
<summary>
<Group gap="xs" wrap="nowrap">
<Loader type="bars" size="xs" color="gray" style={{ opacity: status.type === "running" ? 1 : 0 }} />
<Text size="xs" fw={600} c="dimmed">
{t("reasoning")}
</Text>
</Group>
</summary>
<Text size="sm" c="dimmed" className={classes.reasoningText}>
{text}
</Text>
</details>
);
};
const SourcePart = (source: SourceMessagePartProps) => (
<Anchor
className={classes.source}
href={source.url}
target={source.url ? "_blank" : undefined}
rel={source.url ? "noreferrer" : undefined}
size="xs"
>
<IconLink size={13} />
{source.title}
</Anchor>
);
const FilePart = ({ data, filename, mimeType }: FileMessagePartProps) => {
const t = useScopedI18n("common.assistant");
const downloadable = data.startsWith("data:");
const displayName = filename ?? mimeType;
return (
<Group className={classes.messageFile} gap="xs" wrap="nowrap">
<IconFile size={16} />
<Text size="xs" lineClamp={1} flex={1}>
{displayName}
</Text>
{downloadable && (
<ActionIcon
component="a"
href={data}
download={filename ?? "assistant-file"}
variant="subtle"
color="gray"
size="xs"
aria-label={t("downloadFile", { name: displayName })}
>
<IconDownload size={13} />
</ActionIcon>
)}
</Group>
);
};
const ImagePart = ({ image, filename }: ImageMessagePartProps) => {
const t = useScopedI18n("common.assistant");
const source = (() => {
if (/^data:image\/(?:gif|jpeg|png|webp);base64,/u.test(image)) return image;
try {
const url = new URL(image, window.location.origin);
return url.origin === window.location.origin ? url.href : null;
} catch {
return null;
}
})();
if (!source) {
return (
<Group className={classes.messageFile} gap="xs" wrap="nowrap">
<IconAlertTriangle size={16} />
<Text size="xs">{t("externalImageBlocked")}</Text>
</Group>
);
}
return <Box component="img" src={source} alt={filename ?? t("attachedImage")} className={classes.messageImage} />;
};
const formatToolResultValue = (value: string | number | boolean) =>
typeof value === "number" ? value.toLocaleString() : String(value);
const ToolResultPreview = ({ result }: { result: unknown }) => {
const presentation = getToolResultPresentation(result);
if (!presentation) return null;
if (presentation.type === "text") {
return (
<Text size="sm" className={classes.toolResultText}>
{presentation.text}
</Text>
);
}
if (presentation.type === "properties") {
return (
<Box className={classes.toolResultProperties}>
{presentation.fields.map((field) => (
<Box key={field.label} className={classes.toolResultProperty}>
<Text size="xs" c="dimmed" lineClamp={1}>
{field.label}
</Text>
<Text size="sm" fw={600} lineClamp={2}>
{formatToolResultValue(field.value)}
</Text>
</Box>
))}
</Box>
);
}
const hiddenCount = Math.max(0, presentation.totalCount - presentation.items.length);
return (
<Box className={classes.toolResultCollection}>
{presentation.items.map((item, index) => (
<Box key={`${item.title}-${index}`} className={classes.toolResultItem}>
<Group gap="xs" justify="space-between" wrap="nowrap">
<Box className={classes.toolResultIdentity}>
<Text size="sm" fw={650} lineClamp={1}>
{item.title}
</Text>
{item.description && (
<Text size="xs" c="dimmed" lineClamp={1}>
{item.description}
</Text>
)}
</Box>
{item.badges.length > 0 && (
<Group gap={4} wrap="nowrap">
{item.badges.map((badge) => (
<Badge key={badge} size="xs" variant="light" color="gray">
{badge}
</Badge>
))}
</Group>
)}
</Group>
{item.fields.length > 0 && (
<Group gap="xs" mt={6} wrap="wrap">
{item.fields.map((field) => (
<Text key={field.label} size="xs" c="dimmed">
{field.label}:{" "}
<Text component="span" inherit c="var(--mantine-color-text)" fw={600}>
{formatToolResultValue(field.value)}
</Text>
</Text>
))}
</Group>
)}
</Box>
))}
{hiddenCount > 0 && (
<Badge className={classes.toolResultMore} size="sm" variant="outline" color="gray">
+{hiddenCount}
</Badge>
)}
</Box>
);
};
const ToolPart = ({
toolName,
args,
result,
isError,
status,
approval,
respondToApproval,
timing,
}: ToolCallMessagePartProps) => {
const t = useScopedI18n("common.assistant");
const [opened, setOpened] = useState(false);
const completed = status?.type === "complete";
const awaitingApproval = approval !== undefined && approval.approved === undefined && !approval.resolution;
const denied = approval?.approved === false;
const failed =
!denied &&
(isError === true ||
status?.type === "incomplete" ||
(typeof result === "object" && result !== null && "error" in result));
const successful = completed && !denied && !failed;
const duration = timing?.completedAt !== undefined ? Math.max(0, timing.completedAt - timing.startedAt) : undefined;
return (
<Box className={classes.tool}>
<UnstyledButton
className={classes.toolHeader}
onClick={() => setOpened((current) => !current)}
aria-expanded={opened || awaitingApproval}
>
<Group justify="space-between" wrap="nowrap" gap="xs">
<Group gap="xs" wrap="nowrap">
<ThemeIcon
size="sm"
radius="xl"
variant="light"
color={denied || failed ? "red" : successful ? "green" : "gray"}
>
{denied || failed ? <IconX size={13} /> : successful ? <IconCheck size={13} /> : <IconRobot size={13} />}
</ThemeIcon>
<Text size="sm" fw={600}>
{toolName.replaceAll("_", " ")}
</Text>
</Group>
<Group gap={6} wrap="nowrap">
{duration !== undefined && (
<Text size="xs" c="dimmed">
{duration < 1000 ? `${duration} ms` : `${(duration / 1000).toFixed(1)} s`}
</Text>
)}
<Text size="xs" c="dimmed">
{awaitingApproval
? t("approvalRequired")
: denied
? t("denied")
: failed
? t("failed")
: successful
? t("complete")
: t("working")}
</Text>
<IconChevronDown
size={14}
className={classes.disclosureIcon}
data-opened={opened || awaitingApproval || undefined}
/>
</Group>
</Group>
</UnstyledButton>
{awaitingApproval && (
<Stack gap="xs" mt="sm">
<Text size="sm">{t("approvalDescription")}</Text>
<Group gap="xs">
<Button size="compact-sm" onClick={() => respondToApproval({ approved: true })}>
{t("allowOnce")}
</Button>
<Button size="compact-sm" variant="default" onClick={() => respondToApproval({ approved: false })}>
{t("deny")}
</Button>
</Group>
</Stack>
)}
{successful && result !== undefined && <ToolResultPreview result={result} />}
<Collapse expanded={opened || awaitingApproval}>
<Stack gap="xs" mt="sm">
<Box>
<Text size="xs" fw={600} c="dimmed">
{t("toolInput")}
</Text>
<Text className={classes.codeBlock} size="xs" component="pre">
{JSON.stringify(args, null, 2)}
</Text>
</Box>
{!denied && result !== undefined && (
<Box>
<Text size="xs" fw={600} c="dimmed">
{t("toolOutput")}
</Text>
<Text className={classes.codeBlock} size="xs" component="pre">
{typeof result === "string" ? result : JSON.stringify(result, null, 2)}
</Text>
</Box>
)}
</Stack>
</Collapse>
</Box>
);
};
const BranchPicker = () => {
const t = useScopedI18n("common.assistant");
return (
<BranchPickerPrimitive.Root hideWhenSingleBranch className={classes.branchPicker}>
<BranchPickerPrimitive.Previous asChild>
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("previousResponse")}>
<IconChevronLeft size={14} />
</ActionIcon>
</BranchPickerPrimitive.Previous>
<Text size="xs" c="dimmed">
<BranchPickerPrimitive.Number /> / <BranchPickerPrimitive.Count />
</Text>
<BranchPickerPrimitive.Next asChild>
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("nextResponse")}>
<IconChevronRight size={14} />
</ActionIcon>
</BranchPickerPrimitive.Next>
</BranchPickerPrimitive.Root>
);
};
const AssistantMessageActions = () => {
const t = useScopedI18n("common.assistant");
return (
<Group className={classes.messageActions} gap="xs" justify="space-between">
<BranchPicker />
<ActionBarPrimitive.Root hideWhenRunning>
<Group gap={2}>
<Tooltip label={t("copy")}>
<ActionBarPrimitive.Copy asChild>
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("copy")}>
<IconCopy size={14} />
</ActionIcon>
</ActionBarPrimitive.Copy>
</Tooltip>
<Tooltip label={t("helpful")}>
<ActionBarPrimitive.FeedbackPositive asChild>
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("helpful")}>
<IconThumbUp size={14} />
</ActionIcon>
</ActionBarPrimitive.FeedbackPositive>
</Tooltip>
<Tooltip label={t("notHelpful")}>
<ActionBarPrimitive.FeedbackNegative asChild>
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("notHelpful")}>
<IconThumbDown size={14} />
</ActionIcon>
</ActionBarPrimitive.FeedbackNegative>
</Tooltip>
<Tooltip label={t("exportMarkdown")}>
<ActionBarPrimitive.ExportMarkdown asChild>
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("exportMarkdown")}>
<IconFileExport size={14} />
</ActionIcon>
</ActionBarPrimitive.ExportMarkdown>
</Tooltip>
<Tooltip label={t("regenerate")}>
<ActionBarPrimitive.Reload asChild>
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("regenerate")}>
<IconRefresh size={14} />
</ActionIcon>
</ActionBarPrimitive.Reload>
</Tooltip>
</Group>
</ActionBarPrimitive.Root>
</Group>
);
};
const UserMessageActions = () => {
const t = useScopedI18n("common.assistant");
return (
<ActionBarPrimitive.Root hideWhenRunning className={classes.userActions}>
<Group gap={2}>
<Tooltip label={t("copyMessage")}>
<ActionBarPrimitive.Copy asChild>
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("copyMessage")}>
<IconCopy size={14} />
</ActionIcon>
</ActionBarPrimitive.Copy>
</Tooltip>
<Tooltip label={t("editMessage")}>
<ActionBarPrimitive.Edit asChild>
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("editMessage")}>
<IconPencil size={14} />
</ActionIcon>
</ActionBarPrimitive.Edit>
</Tooltip>
</Group>
</ActionBarPrimitive.Root>
);
};
const EditComposer = () => {
const t = useScopedI18n("common.assistant");
return (
<ComposerPrimitive.Root className={classes.editComposer}>
<ComposerPrimitive.Input className={classes.editComposerInput} rows={2} />
<Group gap="xs" justify="flex-end">
<ComposerPrimitive.Cancel asChild>
<Button size="compact-sm" variant="default">
{t("cancelEdit")}
</Button>
</ComposerPrimitive.Cancel>
<ComposerPrimitive.Send asChild>
<Button size="compact-sm">{t("updateMessage")}</Button>
</ComposerPrimitive.Send>
</Group>
</ComposerPrimitive.Root>
);
};
const UserMessage = () => {
const isEditing = useAuiState((state) => state.composer.isEditing);
if (isEditing) return <EditComposer />;
return (
<MessagePrimitive.Root className={`${classes.message} ${classes.userMessageWrap}`}>
<Box className={classes.userMessage}>
<MessagePrimitive.Quote>
{(quote) => (
<Text component="blockquote" className={classes.messageQuote} size="sm">
{quote.text}
</Text>
)}
</MessagePrimitive.Quote>
<MessagePrimitive.Attachments components={{ Attachment: SentAttachment }} />
<MessagePrimitive.Parts components={{ Text: UserTextPart, File: FilePart, Image: ImagePart }} />
</Box>
<Group justify="flex-end" gap="xs">
<BranchPicker />
<UserMessageActions />
</Group>
</MessagePrimitive.Root>
);
};
const formatDuration = (milliseconds: number) =>
milliseconds < 1000 ? `${Math.round(milliseconds)} ms` : `${(milliseconds / 1000).toFixed(1)} s`;
const formatCost = (cost: number) => {
if (cost === 0) return "$0";
if (cost < 0.0001) return `<$0.0001`;
return `$${cost.toFixed(cost < 0.01 ? 4 : 3)}`;
};
const getContextColor = (percentage: number) => {
if (percentage >= 90) return "red";
if (percentage >= 75) return "orange";
return "blue";
};
const RequestTelemetry = () => {
const t = useScopedI18n("common.assistant");
const [opened, setOpened] = useState(false);
const metadata = useAuiState((state) => state.message.metadata);
const telemetry = getAssistantTelemetry(metadata);
const usage = getAssistantUsage(metadata);
if (!telemetry) return null;
const contextLength = telemetry.contextLength ?? 0;
const contextUsed = telemetry.contextUsed ?? 0;
const hasContextWindow = telemetry.contextLength !== undefined && telemetry.contextUsed !== undefined;
const contextPercentage = hasContextWindow
? Math.min(
100,
Math.max(0, Math.round((telemetry.contextUtilization ?? contextUsed / Math.max(contextLength, 1)) * 100)),
)
: 0;
const contextRemaining = hasContextWindow ? Math.max(contextLength - contextUsed, 0) : undefined;
const contextLabel = hasContextWindow
? `${t("usage.contextWindow")}: ${contextUsed.toLocaleString()} / ${contextLength.toLocaleString()} (${contextPercentage}%)`
: `${t("usage.contextWindow")}: ${t("usage.notReported")}`;
return (
<Box className={classes.telemetry}>
<Group justify="space-between" align="center" gap="xs" wrap="nowrap">
<Group className={classes.telemetryFacts} gap="xs" wrap="wrap">
<Badge className={classes.modelBadge} size="xs" variant="light" color="gray">
{telemetry.modelId}
</Badge>
<Badge size="xs" variant="outline" color="gray">
{telemetry.provider}
</Badge>
{telemetry.durationMs !== undefined && (
<Text size="xs" c="dimmed">
{formatDuration(telemetry.durationMs)}
</Text>
)}
{telemetry.outputTokensPerSecond !== undefined && (
<Text size="xs" c="dimmed">
{telemetry.outputTokensPerSecond.toFixed(1)} tok/s
</Text>
)}
{telemetry.cost !== undefined && (
<Text size="xs" c="dimmed">
{formatCost(telemetry.cost)}
</Text>
)}
</Group>
<Popover
opened={opened}
onChange={setOpened}
width="min(20rem, calc(100vw - 1.5rem))"
position="bottom-end"
shadow="md"
withArrow
trapFocus
returnFocus
>
<Popover.Target>
<UnstyledButton
className={classes.contextMeter}
aria-label={contextLabel}
aria-expanded={opened}
aria-haspopup="dialog"
title={contextLabel}
onClick={() => setOpened((value) => !value)}
>
<RingProgress
size={40}
thickness={4}
roundCaps
sections={
hasContextWindow ? [{ value: contextPercentage, color: getContextColor(contextPercentage) }] : []
}
label={
<Text className={classes.contextMeterLabel} ta="center" fw={700}>
{hasContextWindow ? `${contextPercentage}%` : "–"}
</Text>
}
/>
</UnstyledButton>
</Popover.Target>
<Popover.Dropdown className={classes.telemetryPopover}>
<Stack gap="sm">
<Group justify="space-between" gap="xs">
<div>
<Text size="sm" fw={700}>
{t("usage.contextWindow")}
</Text>
<Text size="xs" c="dimmed">
{t("usage.requestDetails")}
</Text>
</div>
<Badge size="sm" variant="light" color={getContextColor(contextPercentage)}>
{hasContextWindow ? `${contextPercentage}%` : t("usage.notReported")}
</Badge>
</Group>
<Box className={classes.contextStats}>
<div>
<Text size="xs" c="dimmed">
{t("usage.used")}
</Text>
<Text size="sm" fw={600}>
{telemetry.contextUsed?.toLocaleString() ?? t("usage.notReported")}
</Text>
</div>
<div>
<Text size="xs" c="dimmed">
{t("usage.remaining")}
</Text>
<Text size="sm" fw={600}>
{contextRemaining?.toLocaleString() ?? t("usage.notReported")}
</Text>
</div>
<div>
<Text size="xs" c="dimmed">
{t("usage.capacity")}
</Text>
<Text size="sm" fw={600}>
{telemetry.contextLength?.toLocaleString() ?? t("usage.notReported")}
</Text>
</div>
</Box>
<Divider />
<Box className={classes.usageGrid}>
<div>
<Text size="xs" c="dimmed">
{t("usage.input")}
</Text>
<Text size="sm" fw={600}>
{usage?.inputTokens?.toLocaleString() ?? t("usage.notReported")}
</Text>
</div>
<div>
<Text size="xs" c="dimmed">
{t("usage.output")}
</Text>
<Text size="sm" fw={600}>
{usage?.outputTokens?.toLocaleString() ?? t("usage.notReported")}
</Text>
</div>
<div>
<Text size="xs" c="dimmed">
{t("usage.cached")}
</Text>
<Text size="sm" fw={600}>
{usage?.cachedInputTokens?.toLocaleString() ?? t("usage.notReported")}
</Text>
</div>
<div>
<Text size="xs" c="dimmed">
{t("usage.reasoning")}
</Text>
<Text size="sm" fw={600}>
{usage?.reasoningTokens?.toLocaleString() ?? t("usage.notReported")}
</Text>
</div>
<div>
<Text size="xs" c="dimmed">
{t("usage.cacheWrite")}
</Text>
<Text size="sm" fw={600}>
{usage?.cacheWriteTokens?.toLocaleString() ?? t("usage.notReported")}
</Text>
</div>
<div>
<Text size="xs" c="dimmed">
{t("usage.tokens")}
</Text>
<Text size="sm" fw={600}>
{usage?.totalTokens?.toLocaleString() ?? t("usage.notReported")}
</Text>
</div>
</Box>
<Divider />
<Group gap="lg" align="flex-start">
<div>
<Text size="xs" c="dimmed">
{t("usage.firstOutput")}
</Text>
<Text size="sm" fw={600}>
{telemetry.timeToFirstOutputMs !== undefined
? formatDuration(telemetry.timeToFirstOutputMs)
: t("usage.notReported")}
</Text>
</div>
<div>
<Text size="xs" c="dimmed">
{t("usage.cost")}
</Text>
<Text size="sm" fw={600}>
{telemetry.cost !== undefined ? formatCost(telemetry.cost) : t("usage.notReported")}
</Text>
{telemetry.costType && (
<Text size="xs" c="dimmed">
{t(`usage.${telemetry.costType}`)}
</Text>
)}
</div>
{telemetry.upstreamCost !== undefined && (
<div>
<Text size="xs" c="dimmed">
{t("usage.upstreamCost")}
</Text>
<Text size="sm" fw={600}>
{formatCost(telemetry.upstreamCost)}
</Text>
</div>
)}
{telemetry.finishReason && (
<div>
<Text size="xs" c="dimmed">
{t("usage.finishReason")}
</Text>
<Text size="sm" fw={600}>
{telemetry.finishReason}
</Text>
</div>
)}
</Group>
{telemetry.steps.length > 0 && (
<Stack gap={4}>
<Text size="xs" fw={600}>
{t("usage.agentSteps")}
</Text>
{telemetry.steps.map((step) => (
<Box key={step.index} className={classes.stepRow}>
<Group justify="space-between" gap="xs">
<Text size="xs">
{t("usage.step", { number: step.index })}
{step.routedProvider ? ` · ${step.routedProvider}` : ""}
</Text>
<Text size="xs" c="dimmed">
{formatDuration(step.durationMs)}
{step.outputTokensPerSecond !== undefined
? ` · ${step.outputTokensPerSecond.toFixed(1)} tok/s`
: ""}
{step.cost !== undefined ? ` · ${formatCost(step.cost)}` : ""}
</Text>
</Group>
{step.generationId && (
<Text size="xs" c="dimmed" className={classes.generationId} title={step.generationId}>
{t("usage.generation")}: {step.generationId}
</Text>
)}
</Box>
))}
</Stack>
)}
</Stack>
</Popover.Dropdown>
</Popover>
</Group>
</Box>
);
};
const RuntimeError = () => {
const t = useScopedI18n("common.assistant");
return (
<MessagePrimitive.Error>
<ErrorPrimitive.Root className={classes.messageError}>
<Group align="flex-start" wrap="nowrap">
<ThemeIcon color="red" variant="light" radius="xl" size="md">
<IconAlertTriangle size={17} />
</ThemeIcon>
<Stack gap="xs" flex={1}>
<Text size="sm" fw={700}>
{t("responseError.title")}
</Text>
<ErrorPrimitive.Message className={classes.messageErrorText} />
<ActionBarPrimitive.Reload asChild>
<Button
variant="light"
color="red"
size="compact-sm"
w="fit-content"
leftSection={<IconRefresh size={14} />}
>
{t("responseError.retry")}
</Button>
</ActionBarPrimitive.Reload>
</Stack>
</Group>
</ErrorPrimitive.Root>
</MessagePrimitive.Error>
);
};
const AssistantMessage = () => (
<MessagePrimitive.Root className={`${classes.message} ${classes.assistantMessage}`}>
<MessagePrimitive.Parts
components={{
Text: AssistantTextPart,
Reasoning: ReasoningPart,
Source: SourcePart,
File: FilePart,
Image: ImagePart,
tools: { Fallback: ToolPart },
}}
/>
<RuntimeError />
<RequestTelemetry />
<AssistantMessageActions />
</MessagePrimitive.Root>
);
const HistorySelectContext = createContext<() => void>(() => undefined);
const ThreadListItem = () => {
const t = useScopedI18n("common.assistant");
const onSelect = useContext(HistorySelectContext);
return (
<ThreadListItemPrimitive.Root className={classes.historyItem}>
<Group gap={4} wrap="nowrap">
<ThreadListItemPrimitive.Trigger asChild>
<Button
variant="subtle"
color="gray"
size="compact-sm"
justify="flex-start"
flex={1}
style={{ minWidth: 0 }}
onClick={onSelect}
>
<ThreadListItemPrimitive.Title fallback={t("newConversation")} />
</Button>
</ThreadListItemPrimitive.Trigger>
<ThreadListItemPrimitive.Delete asChild>
<ActionIcon variant="subtle" color="red" size="sm" aria-label={t("delete")}>
<IconTrash size={14} />
</ActionIcon>
</ThreadListItemPrimitive.Delete>
</Group>
</ThreadListItemPrimitive.Root>
);
};
const ThreadHistory = ({ onSelect }: { onSelect: () => void }) => {
const t = useScopedI18n("common.assistant");
return (
<HistorySelectContext.Provider value={onSelect}>
<Stack className={classes.historyMenu} gap="xs" p="xs">
<Group gap="xs" wrap="nowrap" px={4} pt={4}>
<ThreadListPrimitive.New asChild>
<Button variant="light" leftSection={<IconPlus size={16} />} fullWidth onClick={onSelect}>
{t("newConversation")}
</Button>
</ThreadListPrimitive.New>
</Group>
<Group gap="xs" px="xs" mt="xs">
<IconHistory size={14} />
<Text size="sm" fw={600} c="dimmed">
{t("conversations")}
</Text>
</Group>
<ScrollArea h="min(24rem, 55dvh)" type="auto" offsetScrollbars>
<Stack gap={3}>
<ThreadListPrimitive.Items components={{ ThreadListItem }} />
</Stack>
</ScrollArea>
</Stack>
</HistorySelectContext.Provider>
);
};
const ConversationHistory = () => {
const t = useScopedI18n("common.assistant");
const [opened, setOpened] = useState(false);
return (
<Popover
opened={opened}
onChange={setOpened}
position="bottom-end"
width="min(22rem, calc(100vw - 1rem))"
shadow="md"
withinPortal
>
<Popover.Target>
<Button
className={classes.historyButton}
variant="subtle"
color="gray"
size="compact-sm"
leftSection={<IconHistory size={16} />}
classNames={{ section: classes.historyButtonSection, label: classes.historyButtonLabel }}
onClick={() => setOpened((current) => !current)}
aria-label={opened ? t("closeHistory") : t("openHistory")}
>
{t("conversations")}
</Button>
</Popover.Target>
<Popover.Dropdown p={0}>
<ThreadHistory onSelect={() => setOpened(false)} />
</Popover.Dropdown>
</Popover>
);
};
const EmptyThread = () => {
const t = useScopedI18n("common.assistant");
return (
<ThreadPrimitive.Empty>
<Box className={classes.empty}>
<Stack align="center" gap="lg" maw={560} w="100%">
<Stack align="center" gap="xs" maw={430}>
<ThemeIcon size={52} radius="xl" variant="light" color="red">
<IconRobot size={27} />
</ThemeIcon>
<Text size="xl" fw={700}>
{t("emptyTitle")}
</Text>
<Text size="sm" c="dimmed">
{t("emptyDescription")}
</Text>
</Stack>
<Box className={classes.suggestions}>
<ThreadPrimitive.Suggestion prompt={t("suggestions.health.prompt")} send asChild>
<Button
variant="default"
className={classes.suggestion}
leftSection={<IconActivityHeartbeat size={18} />}
>
{t("suggestions.health.label")}
</Button>
</ThreadPrimitive.Suggestion>
<ThreadPrimitive.Suggestion prompt={t("suggestions.explore.prompt")} send asChild>
<Button variant="default" className={classes.suggestion} leftSection={<IconApps size={18} />}>
{t("suggestions.explore.label")}
</Button>
</ThreadPrimitive.Suggestion>
<ThreadPrimitive.Suggestion prompt={t("suggestions.media.prompt")} send asChild>
<Button variant="default" className={classes.suggestion} leftSection={<IconSearch size={18} />}>
{t("suggestions.media.label")}
</Button>
</ThreadPrimitive.Suggestion>
<ThreadPrimitive.Suggestion prompt={t("suggestions.command.prompt")} send asChild>
<Button variant="default" className={classes.suggestion} leftSection={<IconCommand size={18} />}>
{t("suggestions.command.label")}
</Button>
</ThreadPrimitive.Suggestion>
</Box>
</Stack>
</Box>
</ThreadPrimitive.Empty>
);
};
const contextIcons = {
app: IconApps,
integration: IconLink,
board: IconMessage,
widget: IconTool,
tools: IconTool,
};
const TriggerItem = ({ item, index }: { item: Unstable_TriggerItem; index: number }) => {
const Icon =
typeof item.metadata?.icon === "string" && item.metadata.icon in contextIcons
? contextIcons[item.metadata.icon as keyof typeof contextIcons]
: IconAt;
return (
<ComposerPrimitive.Unstable_TriggerPopoverItem className={classes.triggerItem} item={item} index={index}>
<ThemeIcon size="sm" variant="light" color="gray">
<Icon size={13} />
</ThemeIcon>
<div className={classes.triggerItemText}>
<Text size="sm" fw={600} lineClamp={1}>
{item.label}
</Text>
{item.description && (
<Text size="xs" c="dimmed" lineClamp={1}>
{item.description}
</Text>
)}
</div>
</ComposerPrimitive.Unstable_TriggerPopoverItem>
);
};
const ComposerTriggers = () => {
const t = useScopedI18n("common.assistant");
const aui = useAui();
const { data: entities = [], isLoading } = clientApi.assistant.getContextEntities.useQuery(undefined, {
staleTime: 60_000,
});
const categories = useMemo(
() =>
(["app", "integration", "board", "widget"] as const).map((type) => ({
id: type,
label: t(`mentions.${type}`),
items: entities
.filter((entity) => entity.type === type)
.map((entity) => ({
id: entity.id,
type: entity.type,
label: entity.label,
description: entity.description,
icon: entity.type,
})),
})),
[entities, t],
);
const mention = unstable_useMentionAdapter({
categories,
includeModelContextTools: {
category: { id: "tools", label: t("mentions.tools") },
formatLabel: (name) => name.replaceAll("_", " "),
icon: "tools",
},
iconMap: contextIcons,
fallbackIcon: IconAt,
});
const slash = unstable_useSlashCommandAdapter({
removeOnExecute: true,
commands: [
{
id: "health",
label: "/health",
description: t("commands.health"),
execute: () => aui.composer().setText(t("suggestions.health.prompt")),
},
{
id: "explore",
label: "/explore",
description: t("commands.explore"),
execute: () => aui.composer().setText(t("suggestions.explore.prompt")),
},
{
id: "media",
label: "/media",
description: t("commands.media"),
execute: () => aui.composer().setText(t("suggestions.media.prompt")),
},
],
});
return (
<>
<ComposerPrimitive.Unstable_TriggerPopover
className={classes.triggerPopover}
char="@"
adapter={mention.adapter}
isLoading={isLoading}
aria-label={t("mentions.menu")}
>
<ComposerPrimitive.Unstable_TriggerPopover.Directive {...mention.directive} />
<ComposerPrimitive.Unstable_TriggerPopoverCategories className={classes.triggerList}>
{(items) =>
items.map((category) => {
const Icon = contextIcons[category.id as keyof typeof contextIcons] ?? IconAt;
return (
<ComposerPrimitive.Unstable_TriggerPopoverCategoryItem
key={category.id}
categoryId={category.id}
className={classes.triggerItem}
>
<ThemeIcon size="sm" variant="light" color="gray">
<Icon size={13} />
</ThemeIcon>
<Text size="sm" fw={600} flex={1}>
{category.label}
</Text>
<IconChevronRight size={14} />
</ComposerPrimitive.Unstable_TriggerPopoverCategoryItem>
);
})
}
</ComposerPrimitive.Unstable_TriggerPopoverCategories>
<ComposerPrimitive.Unstable_TriggerPopoverItems className={classes.triggerList}>
{(items) =>
items.map((item, index) => <TriggerItem key={`${item.type}:${item.id}`} item={item} index={index} />)
}
</ComposerPrimitive.Unstable_TriggerPopoverItems>
</ComposerPrimitive.Unstable_TriggerPopover>
<ComposerPrimitive.Unstable_TriggerPopover
className={classes.triggerPopover}
char="/"
adapter={slash.adapter}
aria-label={t("commands.menu")}
>
<ComposerPrimitive.Unstable_TriggerPopover.Action {...slash.action} />
<ComposerPrimitive.Unstable_TriggerPopoverItems className={classes.triggerList}>
{(items) => items.map((item, index) => <TriggerItem key={item.id} item={item} index={index} />)}
</ComposerPrimitive.Unstable_TriggerPopoverItems>
</ComposerPrimitive.Unstable_TriggerPopover>
</>
);
};
type ComposerProps = Pick<
AssistantPanelProps,
"modelId" | "models" | "modelOptionsLoading" | "reasoning" | "onModelChange" | "onReasoningChange"
>;
const RuntimeControls = ({
modelId,
models,
modelOptionsLoading,
reasoning,
onModelChange,
onReasoningChange,
}: ComposerProps) => {
const t = useScopedI18n("common.assistant");
return (
<Group className={classes.runtimeControls} gap={6} wrap="nowrap">
<Select
className={classes.modelSelect}
classNames={{ input: classes.runtimeSelectInput }}
size="xs"
value={modelId}
onChange={(value) => {
if (value) onModelChange(value);
}}
data={models.map((model) => ({ value: model.id, label: model.name }))}
searchable
allowDeselect={false}
disabled={modelOptionsLoading || models.length === 0}
leftSection={modelOptionsLoading ? <Loader size={13} /> : <IconSparkles size={14} />}
aria-label={t("runtime.model")}
placeholder={t("runtime.model")}
nothingFoundMessage={t("runtime.noModels")}
comboboxProps={{ withinPortal: true, position: "top-start" }}
/>
<Select
className={classes.reasoningSelect}
classNames={{ input: classes.runtimeSelectInput }}
size="xs"
value={reasoning}
onChange={(value) => {
if (value && assistantReasoningModes.includes(value as AssistantReasoningMode)) {
onReasoningChange(value as AssistantReasoningMode);
}
}}
data={assistantReasoningModes.map((mode) => ({
value: mode,
label: t(`runtime.reasoning.${mode}`),
}))}
allowDeselect={false}
leftSection={<IconBrain size={14} />}
aria-label={t("runtime.thinking")}
comboboxProps={{ withinPortal: true, position: "top-end" }}
/>
</Group>
);
};
const Composer = (props: ComposerProps) => {
const t = useScopedI18n("common.assistant");
const running = useAuiState((state) => state.thread.isRunning);
return (
<Box className={classes.composerWrap}>
<ComposerPrimitive.Unstable_TriggerPopoverRoot>
<ComposerPrimitive.AttachmentDropzone className={classes.composerDropzone}>
<ComposerPrimitive.Root className={classes.composer}>
<ComposerTriggers />
<ComposerPrimitive.Quote className={classes.composerQuote}>
<IconQuote size={15} />
<ComposerPrimitive.QuoteText className={classes.composerQuoteText} />
<ComposerPrimitive.QuoteDismiss asChild>
<ActionIcon variant="subtle" color="gray" size="xs" aria-label={t("dismissQuote")}>
<IconX size={12} />
</ActionIcon>
</ComposerPrimitive.QuoteDismiss>
</ComposerPrimitive.Quote>
<ComposerPrimitive.Attachments>{() => <Attachment removable />}</ComposerPrimitive.Attachments>
<Group className={classes.composerRow} gap="xs" wrap="nowrap" align="flex-end">
<Group gap={2} wrap="nowrap">
<Tooltip label={t("attachments.add")}>
<ComposerPrimitive.AddAttachment asChild>
<ActionIcon variant="subtle" color="gray" size="lg" aria-label={t("attachments.add")}>
<IconPaperclip size={17} />
</ActionIcon>
</ComposerPrimitive.AddAttachment>
</Tooltip>
</Group>
<ComposerPrimitive.Input
className={classes.composerInput}
placeholder={t("composerPlaceholder")}
rows={1}
data-autofocus
/>
{running ? (
<ComposerPrimitive.Cancel asChild>
<ActionIcon color="red" variant="light" size="lg" aria-label={t("stop")}>
<IconPlayerStop size={18} />
</ActionIcon>
</ComposerPrimitive.Cancel>
) : (
<ComposerPrimitive.Send asChild>
<ActionIcon color="red" variant="filled" size="lg" aria-label={t("send")}>
<IconArrowUp size={18} />
</ActionIcon>
</ComposerPrimitive.Send>
)}
</Group>
<Group className={classes.composerFooter} justify="space-between" gap="xs" wrap="nowrap">
<RuntimeControls {...props} />
<Group className={classes.composerHints} gap="xs" wrap="nowrap">
<Text size="xs" c="dimmed">
{t("mentions.hint")}
</Text>
<Text size="xs" c="dimmed">
{t("commands.hint")}
</Text>
</Group>
</Group>
</ComposerPrimitive.Root>
</ComposerPrimitive.AttachmentDropzone>
</ComposerPrimitive.Unstable_TriggerPopoverRoot>
</Box>
);
};
const AssistantActivityBar = ({
isRunning,
unreadCount,
latestAssistantText,
latestUserText,
latestStatus,
onOpen,
onDismissActivity,
activityDismissed,
}: Pick<
AssistantPanelProps,
| "isRunning"
| "unreadCount"
| "latestAssistantText"
| "latestUserText"
| "latestStatus"
| "onOpen"
| "onDismissActivity"
| "activityDismissed"
>) => {
const t = useScopedI18n("common.assistant");
const needsApproval = latestStatus?.type === "requires-action";
const failed = latestStatus?.type === "incomplete" && latestStatus.reason !== "cancelled";
const visible =
!activityDismissed &&
(isRunning || unreadCount > 0 || needsApproval || latestStatus !== undefined || latestAssistantText.length > 0);
if (!visible) return null;
const title = isRunning
? t("activity.thinking")
: needsApproval
? t("activity.approval")
: failed
? t("activity.failed")
: t("activity.ready");
const detail = isRunning
? latestUserText || t("activity.working")
: latestAssistantText || (failed ? t("responseError.description") : t("activity.completed"));
return (
<Box component="output" className={classes.activityBar} aria-live="polite">
<UnstyledButton className={classes.activityTrigger} onClick={onOpen} aria-label={t("activity.expand")}>
<Group gap="sm" wrap="nowrap">
{isRunning ? (
<ThemeIcon variant="light" color="red" radius="xl">
<Loader type="bars" size="sm" color="red" />
</ThemeIcon>
) : (
<ThemeIcon variant="light" color={failed ? "red" : needsApproval ? "yellow" : "green"} radius="xl">
{failed ? (
<IconAlertTriangle size={17} />
) : needsApproval ? (
<IconRobot size={17} />
) : (
<IconCheck size={17} />
)}
</ThemeIcon>
)}
<Stack gap={0} className={classes.activityCopy}>
<Group gap="xs" wrap="nowrap">
<Text size="sm" fw={700}>
{title}
</Text>
{unreadCount > 0 && (
<Badge size="xs" variant="filled" color="red" circle>
{unreadCount > 99 ? "99+" : unreadCount}
</Badge>
)}
</Group>
<Text size="xs" c="dimmed" lineClamp={1}>
{detail}
</Text>
</Stack>
<IconChevronUp size={17} className={classes.activityExpandIcon} />
</Group>
</UnstyledButton>
{!isRunning && !needsApproval && (
<ActionIcon
className={classes.activityDismiss}
variant="subtle"
color="gray"
onClick={onDismissActivity}
aria-label={t("activity.dismiss")}
>
<IconX size={15} />
</ActionIcon>
)}
</Box>
);
};
export const AssistantPanel = ({
opened,
onOpen,
onClose,
onDismissActivity,
activityDismissed,
isRunning,
unreadCount,
latestAssistantText,
latestUserText,
latestStatus,
modelId,
models,
modelOptionsLoading,
reasoning,
onModelChange,
onReasoningChange,
}: AssistantPanelProps) => {
const t = useScopedI18n("common.assistant");
const previousFocusRef = useRef<HTMLElement | null>(null);
useWindowEvent("keydown", (event) => {
if (opened && event.key === "Escape") onClose();
});
useEffect(() => {
if (!opened) {
const rememberOutsideFocus = () => {
if (document.activeElement instanceof HTMLElement) {
previousFocusRef.current = document.activeElement;
}
};
rememberOutsideFocus();
document.addEventListener("focusin", rememberOutsideFocus);
return () => document.removeEventListener("focusin", rememberOutsideFocus);
}
const restoreTarget = previousFocusRef.current;
return () => {
restoreTarget?.focus();
previousFocusRef.current = null;
};
}, [opened]);
return (
<>
{!opened && (
<AssistantActivityBar
onOpen={onOpen}
onDismissActivity={onDismissActivity}
activityDismissed={activityDismissed}
isRunning={isRunning}
unreadCount={unreadCount}
latestAssistantText={latestAssistantText}
latestUserText={latestUserText}
latestStatus={latestStatus}
/>
)}
{opened && (
<FocusTrap active>
<dialog className={classes.floatingPanel} aria-label={t("title")} open>
<Group className={classes.panelHeader} justify="space-between" wrap="nowrap" gap="xs">
<Group className={classes.panelIdentity} gap="xs" wrap="nowrap">
<ThemeIcon variant="light" color="red" radius="xl">
<IconRobot size={18} />
</ThemeIcon>
<div className={classes.panelIdentityText}>
<Text fw={700} lh={1.1} lineClamp={1}>
{t("title")}
</Text>
<Text size="xs" c="dimmed" lineClamp={1}>
{isRunning ? t("activity.thinking") : t("subtitle")}
</Text>
</div>
</Group>
<Group className={classes.panelActions} gap={2} wrap="nowrap">
<ConversationHistory />
<Tooltip label={t("newConversation")}>
<ThreadListPrimitive.New asChild>
<ActionIcon
className={classes.panelAction}
variant="subtle"
color="gray"
aria-label={t("newConversation")}
>
<IconPlus size={17} />
</ActionIcon>
</ThreadListPrimitive.New>
</Tooltip>
<Tooltip label={t("minimize")}>
<ActionIcon
className={classes.panelAction}
variant="subtle"
color="gray"
onClick={onClose}
aria-label={t("minimize")}
>
<IconMinus size={18} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
<ThreadPrimitive.Root className={classes.thread}>
<ThreadPrimitive.Viewport className={classes.viewport}>
<Box className={classes.messages}>
<EmptyThread />
<ThreadPrimitive.Messages components={{ UserMessage, AssistantMessage }} />
</Box>
<SelectionToolbarPrimitive.Root className={classes.selectionToolbar}>
<SelectionToolbarPrimitive.Quote asChild>
<Button variant="filled" color="dark" size="compact-sm" leftSection={<IconQuote size={14} />}>
{t("quoteSelection")}
</Button>
</SelectionToolbarPrimitive.Quote>
</SelectionToolbarPrimitive.Root>
<ThreadPrimitive.ScrollToBottom asChild>
<ActionIcon
className={classes.scrollToBottom}
variant="default"
radius="xl"
aria-label={t("scrollToLatest")}
>
<IconArrowUp size={16} style={{ transform: "rotate(180deg)" }} />
</ActionIcon>
</ThreadPrimitive.ScrollToBottom>
</ThreadPrimitive.Viewport>
<Composer
modelId={modelId}
models={models}
modelOptionsLoading={modelOptionsLoading}
reasoning={reasoning}
onModelChange={onModelChange}
onReasoningChange={onReasoningChange}
/>
</ThreadPrimitive.Root>
</dialog>
</FocusTrap>
)}
</>
);
};