"use client";
import type { ReactNode } from "react";
import { useMemo, useState } from "react";
import {
Badge,
Box,
Button,
Combobox,
Divider,
Group,
Loader,
Popover,
Progress,
SegmentedControl,
Stack,
Text,
ThemeIcon,
Tooltip,
UnstyledButton,
useCombobox,
} from "@mantine/core";
import {
IconBattery1,
IconBattery3,
IconBatteryFilled,
IconBatteryOff,
IconCheck,
IconPhoto,
} from "@tabler/icons-react";
import { useTimeAgo } from "@homarr/common";
import { assistantReasoningModes } from "@homarr/definitions";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import classes from "./assistant-panel.module.css";
import type { AssistantConversationControls } from "./assistant-conversation-controls";
import { useAssistantPreferences } from "./assistant-context";
import type { AssistantPendingAction } from "./assistant-pending-action";
import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assistant-preferences";
import { resolveAssistantReasoningDisplayMode } from "./assistant-preferences";
import { getAssistantProviderQuotaLevel } from "./assistant-provider-quota";
type ComposerProps = AssistantConversationControls & {
pendingAction: AssistantPendingAction | undefined;
compact?: boolean;
};
const getModelProviderLabel = (modelId: string, fallback: string) => {
const separator = modelId.indexOf("/");
if (separator <= 0) return fallback;
const provider = modelId
.slice(0, separator)
.replace(/^[^\p{L}\p{N}]+/u, "")
.replaceAll(/[-_]/gu, " ");
if (!provider) return fallback;
return `${provider.charAt(0).toLocaleUpperCase()}${provider.slice(1)}`;
};
const formatCompactModelNumber = (value: number) =>
new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(value);
export const RuntimeControls = ({
compact = false,
modelId,
models,
modelOptionsLoading,
reasoning,
onModelChange,
onReasoningChange,
}: ComposerProps) => {
const t = useI18n("assistant");
const { provider: assistantProvider } = useAssistantPreferences();
const [modelSearch, setModelSearch] = useState("");
const selectedModel = models.find((model) => model.id === modelId);
const displayedReasoning = resolveAssistantReasoningDisplayMode({ provider: assistantProvider, modelId, reasoning });
const reasoningIsForced = displayedReasoning === "max";
let reasoningLabel = "Max";
if (!reasoningIsForced) reasoningLabel = t(`runtime.reasoning.${displayedReasoning}`);
let reasoningControl: ReactNode;
if (reasoningIsForced) {
reasoningControl = (
Max
);
} else {
reasoningControl = (
{
if (assistantReasoningModes.includes(value as AssistantReasoningMode)) {
onReasoningChange(value as AssistantReasoningMode);
}
}}
size="xs"
fullWidth
data={assistantReasoningModes.map((mode) => ({
value: mode,
label: t(`runtime.reasoning.${mode}`),
}))}
/>
);
}
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 groupedModels = visibleModels.reduce((groups, model) => {
const provider = getModelProviderLabel(model.id, t("runtime.otherModels"));
const providerModels = groups.get(provider);
if (providerModels) providerModels.push(model);
else groups.set(provider, [model]);
return groups;
}, new Map());
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 (
{compact ? (
) : (
modelCombobox.toggleDropdown()}
aria-label={`${t("runtime.model")}: ${selectedModel?.name ?? t("runtime.noModels")}. ${t("runtime.thinking")}: ${reasoningLabel}`}
aria-expanded={modelCombobox.dropdownOpened}
>
{modelOptionsLoading && }
{selectedModel?.name ?? t("runtime.model")}
{reasoningLabel}
)}
{
setModelSearch(event.currentTarget.value);
modelCombobox.updateSelectedOptionIndex();
}}
placeholder={t("runtime.searchModels")}
aria-label={t("runtime.searchModels")}
size="xs"
/>
{[...groupedModels.entries()].map(([provider, providerModels]) => (
{providerModels.map((model) => (
{model.name}
{model.description?.trim() || model.id}
{model.inputModalities.includes("image") && (
)}
{model.contextLength && (
{formatCompactModelNumber(model.contextLength)}
)}
{model.id === modelId && }
))}
))}
{visibleModels.length === 0 && {t("runtime.noModels")}}
{t("runtime.thinking")}
{reasoningControl}
);
};
const providerQuotaIcons = {
ok: IconBatteryFilled,
warning: IconBattery3,
bad: IconBattery1,
dead: IconBatteryOff,
};
const providerQuotaColors = {
ok: "green",
warning: "yellow",
bad: "orange",
dead: "red",
} as const;
export const HomarrProviderQuota = () => {
const t = useI18n("assistant");
const locale = useCurrentIntlLocale();
const preferences = useAssistantPreferences();
const quota = preferences.quota;
const resetAt = useMemo(() => new Date(quota?.resetsAt ?? Date.now()), [quota?.resetsAt]);
const resetRelative = useTimeAgo(resetAt, 30_000);
if (preferences.provider !== "homarr") return null;
const level = preferences.quota ? getAssistantProviderQuotaLevel(preferences.quota) : "dead";
const Icon = providerQuotaIcons[level];
const color = providerQuotaColors[level];
const percentage = preferences.quota
? Math.min(100, Math.max(0, (preferences.quota.remaining / Math.max(preferences.quota.limit, 1)) * 100))
: 0;
let label = t("providerQuota.signInRequired");
if (preferences.providerUser && !quota) label = t("providerQuota.loading");
if (preferences.providerUser && quota) {
label = t("providerQuota.remainingLabel", {
remaining: quota.remaining,
limit: quota.limit,
});
}
const resetTime = quota ? (
) : null;
let quotaContent: ReactNode;
if (!preferences.providerUser) {
quotaContent = (
<>
{t("providerQuota.signInDescription")}
>
);
} else if (quota) {
quotaContent = (
<>
{t("providerQuota.dailyAllowance")}
{quota.remaining} / {quota.limit}
{t.rich("providerQuota.reset", {
relative: resetRelative,
time: () => resetTime,
})}
{t("providerQuota.toolCalls")}
>
);
} else {
quotaContent = (
);
}
return (
{preferences.quotaLoading && !preferences.quota ? : }
{preferences.quota?.remaining ?? "–"}
{t("providerQuota.title")}
{preferences.providerUser?.name || t("providerQuota.communityWorkshop")}
{preferences.quota && (
{t(`providerQuota.level.${level}`)}
)}
{quotaContent}
{preferences.quotaError && (
{preferences.quotaError}
)}
);
};