"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">) => (
);
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")}
}
>
{t("responseError.retry")}
);
};
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 (
} fullWidth onClick={onSelect}>
{t("newConversation")}
{t("conversations")}
);
};
const ConversationHistory = () => {
const t = useScopedI18n("common.assistant");
const [opened, setOpened] = useState(false);
return (
}
classNames={{ section: classes.historyButtonSection, label: classes.historyButtonLabel }}
onClick={() => setOpened((current) => !current)}
aria-label={opened ? t("closeHistory") : t("openHistory")}
>
{t("conversations")}
setOpened(false)} />
);
};
const EmptyThread = () => {
const t = useScopedI18n("common.assistant");
return (
{t("emptyTitle")}
{t("emptyDescription")}
}
>
{t("suggestions.health.label")}
}>
{t("suggestions.explore.label")}
}>
{t("suggestions.media.label")}
}>
{t("suggestions.command.label")}
);
};
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 (
);
};
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 && (
)}
>
);
};