"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 { getAssistantProviderQuotaLevel } from "./assistant-provider-quota"; type ComposerProps = AssistantConversationControls & { pendingAction: AssistantPendingAction | undefined }; 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 = ({ modelId, models, modelOptionsLoading, reasoning, onModelChange, onReasoningChange, }: ComposerProps) => { const t = useI18n("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 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 ( modelCombobox.toggleDropdown()} aria-label={`${t("runtime.model")}: ${selectedModel?.name ?? t("runtime.noModels")}. ${t("runtime.thinking")}: ${t(`runtime.reasoning.${reasoning}`)}`} aria-expanded={modelCombobox.dropdownOpened} > {modelOptionsLoading && } {selectedModel?.name ?? t("runtime.model")} {t(`runtime.reasoning.${reasoning}`)} { 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")} { 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 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} )} ); };