"use client"; import type { ComponentPropsWithoutRef, RefObject } from "react"; import { createContext, useContext, useEffect, useLayoutEffect, 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, unstable_useTriggerPopoverScopeContext, } from "@assistant-ui/react"; import { MarkdownTextPrimitive } from "@assistant-ui/react-markdown"; import { ActionIcon, Anchor, Badge, Box, Button, Collapse, Combobox, Divider, FocusTrap, Group, Loader, Menu, Popover, RingProgress, ScrollArea, Stack, Text, ThemeIcon, Tooltip, UnstyledButton, useCombobox, } from "@mantine/core"; import { useWindowEvent } from "@mantine/hooks"; import { IconActivityHeartbeat, IconAlertTriangle, IconApps, IconArrowUp, IconArrowsMaximize, IconAt, IconBrain, IconCheck, IconChevronDown, IconChevronLeft, IconChevronRight, IconChevronUp, IconCopy, IconDownload, IconFile, IconFileExport, IconLink, IconMessage, IconMinus, IconPaperclip, IconPalette, 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 { AssistantPendingAction } from "./assistant-pending-action"; import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assistant-preferences"; import { getNearestTriggerScrollTop } from "./assistant-trigger-scroll"; import { getToolResultPresentation } from "./assistant-tool-result"; export interface AssistantConversationControls { modelId: string | null; models: AssistantRuntimeModelOption[]; modelOptionsLoading: boolean; reasoning: AssistantReasoningMode; onModelChange: (modelId: string) => void; onReasoningChange: (reasoning: AssistantReasoningMode) => void; } interface AssistantPanelProps extends AssistantConversationControls { opened: boolean; onOpen: () => void; onClose: () => void; onDismissActivity: () => void; activityDismissed: boolean; isRunning: boolean; unreadCount: number; latestAssistantText: string; latestUserText: string; latestStatus: MessageStatus | undefined; pendingAction: AssistantPendingAction | undefined; } 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 [approvalResponse, setApprovalResponse] = useState<"approve" | "deny" | null>(null); 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} > {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)} position="bottom-end" width="min(22rem, calc(100vw - 1rem))" shadow="md" withinPortal > 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 TriggerPopoverAutoScroll = ({ viewportRef }: { viewportRef: RefObject }) => { const { activeCategoryId, categories, highlightedIndex, isSearchMode, items, open, query } = unstable_useTriggerPopoverScopeContext(); useLayoutEffect(() => { if (!open) return; const viewport = viewportRef.current; const highlightedItem = viewport?.querySelector("[data-highlighted]"); if (!viewport || !highlightedItem) return; const viewportRect = viewport.getBoundingClientRect(); const itemRect = highlightedItem.getBoundingClientRect(); const viewportTop = viewportRect.top + viewport.clientTop; const viewportBottom = viewportTop + viewport.clientHeight; viewport.scrollTop = getNearestTriggerScrollTop({ scrollTop: viewport.scrollTop, viewportTop, viewportBottom, itemTop: itemRect.top, itemBottom: itemRect.bottom, }); }, [activeCategoryId, categories, highlightedIndex, isSearchMode, items, open, query, viewportRef]); return null; }; 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 mentionViewportRef = useRef(null); const slashViewportRef = useRef(null); 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")), }, { id: "style", label: "/style", description: t("commands.style"), execute: () => aui.composer().setText(t("suggestions.style.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 = AssistantConversationControls & { pendingAction: AssistantPendingAction | undefined }; const RuntimeControls = ({ modelId, models, modelOptionsLoading, reasoning, onModelChange, onReasoningChange, }: ComposerProps) => { const t = useScopedI18n("common.assistant"); const [modelSearch, setModelSearch] = useState(""); const selectedModel = models.find((model) => model.id === modelId); const normalizedModelSearch = modelSearch.trim().toLocaleLowerCase(); const visibleModels = normalizedModelSearch.length === 0 ? models : models.filter( (model) => model.name.toLocaleLowerCase().includes(normalizedModelSearch) || model.id.toLocaleLowerCase().includes(normalizedModelSearch), ); const modelCombobox = useCombobox({ onDropdownOpen: () => { modelCombobox.selectActiveOption(); requestAnimationFrame(() => modelCombobox.focusSearchInput()); }, onDropdownClose: () => { modelCombobox.resetSelectedOption(); setModelSearch(""); }, }); const selectModel = (value: string) => { if (!models.some((model) => model.id === value)) return; onModelChange(value); modelCombobox.closeDropdown(); }; return ( { setModelSearch(event.currentTarget.value); modelCombobox.updateSelectedOptionIndex(); }} placeholder={t("runtime.model")} aria-label={t("runtime.model")} /> {visibleModels.map((model) => ( {model.name} {model.id} {model.id === modelId && } ))} {visibleModels.length === 0 && {t("runtime.noModels")}} {t("runtime.thinking")} {assistantReasoningModes.map((mode) => ( : null} onClick={() => onReasoningChange(mode)} > {t(`runtime.reasoning.${mode}`)} ))} ); }; const Composer = (props: ComposerProps) => { const t = useScopedI18n("common.assistant"); const running = useAuiState((state) => state.thread.isRunning); const hasPendingAction = props.pendingAction !== undefined; return ( { if (hasPendingAction) event.preventDefault(); }} > {() => } {running ? ( ) : ( )} {t("mentions.hint")} {t("commands.hint")} ); }; const usePendingActionCopy = (action: AssistantPendingAction | undefined) => { const t = useScopedI18n("common.assistant"); if (!action) return undefined; if (action.kind === "question") { return { title: t("pendingAction.answerTitle"), detail: action.detail ?? t("pendingAction.answerFallback") }; } if (action.kind === "form") { const isBoardSettings = action.toolName === "configure_board_settings"; return { title: isBoardSettings ? t("pendingAction.boardFormTitle") : t("pendingAction.formTitle"), detail: action.detail ?? (isBoardSettings ? t("configureBoardSettings.title") : t("configureApp.title")), }; } const tool = action.toolName.replaceAll("_", " "); return { title: t("activity.approval"), detail: action.detail ? `${tool} · ${action.detail}` : tool }; }; const PendingActionBanner = ({ pendingAction }: { pendingAction: AssistantPendingAction | undefined }) => { const t = useScopedI18n("common.assistant"); const copy = usePendingActionCopy(pendingAction); if (!copy) return null; return ( {copy.title} {copy.detail} ); }; const AssistantActivityBar = ({ isRunning, unreadCount, latestAssistantText, latestUserText, latestStatus, pendingAction, onOpen, onDismissActivity, activityDismissed, }: Pick< AssistantPanelProps, | "isRunning" | "unreadCount" | "latestAssistantText" | "latestUserText" | "latestStatus" | "pendingAction" | "onOpen" | "onDismissActivity" | "activityDismissed" >) => { const t = useScopedI18n("common.assistant"); const pendingCopy = usePendingActionCopy(pendingAction); const needsApproval = pendingAction !== undefined || 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") : pendingCopy ? pendingCopy.title : needsApproval ? t("activity.approval") : failed ? t("activity.failed") : t("activity.ready"); const detail = isRunning ? latestUserText || t("activity.working") : pendingCopy?.detail || latestAssistantText || (failed ? t("responseError.description") : t("activity.completed")); return ( {isRunning ? ( ) : ( {failed ? ( ) : needsApproval ? ( ) : ( )} )} {title} {unreadCount > 0 && ( {unreadCount > 99 ? "99+" : unreadCount} )} {detail} {needsApproval ? ( {t("pendingAction.review")} ) : ( )} {!isRunning && !needsApproval && ( )} ); }; interface AssistantConversationSurfaceProps extends AssistantConversationControls { isRunning: boolean; pendingAction: AssistantPendingAction | undefined; onExpand?: () => void; onMinimize?: () => void; } export const AssistantConversationSurface = ({ isRunning, pendingAction, modelId, models, modelOptionsLoading, reasoning, onModelChange, onReasoningChange, onExpand, onMinimize, }: AssistantConversationSurfaceProps) => { const t = useScopedI18n("common.assistant"); return ( <>
{t("title")} {isRunning ? t("activity.thinking") : t("subtitle")}
{onExpand && ( )} {onMinimize && ( )}
); }; export const AssistantPanel = ({ opened, onOpen, onClose, onDismissActivity, activityDismissed, isRunning, unreadCount, latestAssistantText, latestUserText, latestStatus, pendingAction, 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 && ( )} ); };