"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 ( {children} ); }; const MarkdownTable = ({ children, ...props }: ComponentPropsWithoutRef<"table">) => (
{children}
); const AssistantTextPart = () => ( ); const UserTextPart = () => ; const Attachment = ({ removable = false }: { removable?: boolean }) => { const t = useScopedI18n("common.assistant"); return ( {removable && ( )} ); }; const SentAttachment = () => ; const ReasoningPart = ({ text, status }: ReasoningMessagePartProps) => { const t = useScopedI18n("common.assistant"); return (
{t("reasoning")} {text}
); }; const SourcePart = (source: SourceMessagePartProps) => ( {source.title} ); const FilePart = ({ data, filename, mimeType }: FileMessagePartProps) => { const t = useScopedI18n("common.assistant"); const downloadable = data.startsWith("data:"); const displayName = filename ?? mimeType; return ( {displayName} {downloadable && ( )} ); }; 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 ( {t("externalImageBlocked")} ); } return ; }; 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 ( {presentation.text} ); } if (presentation.type === "properties") { return ( {presentation.fields.map((field) => ( {field.label} {formatToolResultValue(field.value)} ))} ); } const hiddenCount = Math.max(0, presentation.totalCount - presentation.items.length); return ( {presentation.items.map((item, index) => ( {item.title} {item.description && ( {item.description} )} {item.badges.length > 0 && ( {item.badges.map((badge) => ( {badge} ))} )} {item.fields.length > 0 && ( {item.fields.map((field) => ( {field.label}:{" "} {formatToolResultValue(field.value)} ))} )} ))} {hiddenCount > 0 && ( +{hiddenCount} )} ); }; 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 ( setOpened((current) => !current)} aria-expanded={opened || awaitingApproval} > {denied || failed ? : successful ? : } {toolName.replaceAll("_", " ")} {duration !== undefined && ( {duration < 1000 ? `${duration} ms` : `${(duration / 1000).toFixed(1)} s`} )} {awaitingApproval ? t("approvalRequired") : denied ? t("denied") : failed ? t("failed") : successful ? t("complete") : t("working")} {awaitingApproval && ( {t("approvalDescription")} )} {successful && result !== undefined && } {t("toolInput")} {JSON.stringify(args, null, 2)} {!denied && result !== undefined && ( {t("toolOutput")} {typeof result === "string" ? result : JSON.stringify(result, null, 2)} )} ); }; const BranchPicker = () => { const t = useScopedI18n("common.assistant"); return ( / ); }; const AssistantMessageActions = () => { const t = useScopedI18n("common.assistant"); return ( ); }; const UserMessageActions = () => { const t = useScopedI18n("common.assistant"); return ( ); }; const EditComposer = () => { const t = useScopedI18n("common.assistant"); return ( ); }; const UserMessage = () => { const isEditing = useAuiState((state) => state.composer.isEditing); if (isEditing) return ; return ( {(quote) => ( {quote.text} )} ); }; 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 ( {telemetry.modelId} {telemetry.provider} {telemetry.durationMs !== undefined && ( {formatDuration(telemetry.durationMs)} )} {telemetry.outputTokensPerSecond !== undefined && ( {telemetry.outputTokensPerSecond.toFixed(1)} tok/s )} {telemetry.cost !== undefined && ( {formatCost(telemetry.cost)} )} setOpened((value) => !value)} > {hasContextWindow ? `${contextPercentage}%` : "–"} } />
{t("usage.contextWindow")} {t("usage.requestDetails")}
{hasContextWindow ? `${contextPercentage}%` : t("usage.notReported")}
{t("usage.used")} {telemetry.contextUsed?.toLocaleString() ?? t("usage.notReported")}
{t("usage.remaining")} {contextRemaining?.toLocaleString() ?? t("usage.notReported")}
{t("usage.capacity")} {telemetry.contextLength?.toLocaleString() ?? t("usage.notReported")}
{t("usage.input")} {usage?.inputTokens?.toLocaleString() ?? t("usage.notReported")}
{t("usage.output")} {usage?.outputTokens?.toLocaleString() ?? t("usage.notReported")}
{t("usage.cached")} {usage?.cachedInputTokens?.toLocaleString() ?? t("usage.notReported")}
{t("usage.reasoning")} {usage?.reasoningTokens?.toLocaleString() ?? t("usage.notReported")}
{t("usage.cacheWrite")} {usage?.cacheWriteTokens?.toLocaleString() ?? t("usage.notReported")}
{t("usage.tokens")} {usage?.totalTokens?.toLocaleString() ?? t("usage.notReported")}
{t("usage.firstOutput")} {telemetry.timeToFirstOutputMs !== undefined ? formatDuration(telemetry.timeToFirstOutputMs) : t("usage.notReported")}
{t("usage.cost")} {telemetry.cost !== undefined ? formatCost(telemetry.cost) : t("usage.notReported")} {telemetry.costType && ( {t(`usage.${telemetry.costType}`)} )}
{telemetry.upstreamCost !== undefined && (
{t("usage.upstreamCost")} {formatCost(telemetry.upstreamCost)}
)} {telemetry.finishReason && (
{t("usage.finishReason")} {telemetry.finishReason}
)}
{telemetry.steps.length > 0 && ( {t("usage.agentSteps")} {telemetry.steps.map((step) => ( {t("usage.step", { number: step.index })} {step.routedProvider ? ` · ${step.routedProvider}` : ""} {formatDuration(step.durationMs)} {step.outputTokensPerSecond !== undefined ? ` · ${step.outputTokensPerSecond.toFixed(1)} tok/s` : ""} {step.cost !== undefined ? ` · ${formatCost(step.cost)}` : ""} {step.generationId && ( {t("usage.generation")}: {step.generationId} )} ))} )}
); }; const RuntimeError = () => { const t = useScopedI18n("common.assistant"); return ( {t("responseError.title")} ); }; const AssistantMessage = () => ( ); const HistorySelectContext = createContext<() => void>(() => undefined); const ThreadListItem = () => { const t = useScopedI18n("common.assistant"); const onSelect = useContext(HistorySelectContext); return ( ); }; const ThreadHistory = ({ onSelect }: { onSelect: () => void }) => { const t = useScopedI18n("common.assistant"); return ( {t("conversations")} ); }; const ConversationHistory = () => { const t = useScopedI18n("common.assistant"); const [opened, setOpened] = useState(false); return ( setOpened(false)} /> ); }; const EmptyThread = () => { const t = useScopedI18n("common.assistant"); return ( {t("emptyTitle")} {t("emptyDescription")} ); }; 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 (
{item.label} {item.description && ( {item.description} )}
); }; 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 ( <> {(items) => items.map((category) => { const Icon = contextIcons[category.id as keyof typeof contextIcons] ?? IconAt; return ( {category.label} ); }) } {(items) => items.map((item, index) => ) } {(items) => items.map((item, index) => )} ); }; 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 ( { 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={} aria-label={t("runtime.thinking")} comboboxProps={{ withinPortal: true, position: "top-end" }} /> ); }; const Composer = (props: ComposerProps) => { const t = useScopedI18n("common.assistant"); const running = useAuiState((state) => state.thread.isRunning); return ( {() => } {running ? ( ) : ( )} {t("mentions.hint")} {t("commands.hint")} ); }; 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 ( {isRunning ? ( ) : ( {failed ? ( ) : needsApproval ? ( ) : ( )} )} {title} {unreadCount > 0 && ( {unreadCount > 99 ? "99+" : unreadCount} )} {detail} {!isRunning && !needsApproval && ( )} ); }; 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(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 && ( )} {opened && (
{t("title")} {isRunning ? t("activity.thinking") : t("subtitle")}
)} ); };