"use client"; import { useMemo, useRef } from "react"; import type { ReactNode } from "react"; import { Box, Center, Group, Progress, RingProgress, ScrollArea, Stack, Text, Tooltip } from "@mantine/core"; import { IconBrain, IconBolt, IconCircleX, IconDatabase, IconFileText, IconGauge, IconLayersIntersect, IconServer, } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; import { formatBytes, formatNumber } from "@homarr/common"; import { useI18n } from "@homarr/translation/client"; import { WidgetEmptyState } from "../common/empty-state"; import type { WidgetComponentProps } from "../definition"; import { NoIntegrationSelectedError } from "../errors/no-integration-selected"; // Board grid: a widget N cells wide is N*200 + (N-1)*12 px (1→200, 2→412, 3→624, 4→836). // Width drives how much info fits side-by-side; height only matters for the // single-row (short) case where the widget must drop down to essentials. const isNarrowWidth = (width: number) => width < 300; // 1 cell (200px) const isWideWidth = (width: number) => width >= 550; // 3+ cells (624px+) const isShortHeight = (height: number) => height < 300; // 1 row (200px) const ringSizeByScale = [ { minWidth: 320, size: 108 }, { minWidth: 220, size: 92 }, { minWidth: 0, size: 68 }, ] as const; const getContextColor = (percent: number) => (percent > 90 ? "red" : percent > 75 ? "yellow" : "blue"); export default function LlamacppWidget({ integrationIds, options, width, height, displayMode = "compact", }: WidgetComponentProps<"llamacpp">) { const integrationId = integrationIds[0]; if (!integrationId) { throw new NoIntegrationSelectedError(); } return ( ); } interface LlamacppContentProps { integrationId: string; options: WidgetComponentProps<"llamacpp">["options"]; width: number; height: number; displayMode: "compact" | "advanced"; } function LlamacppContent({ integrationId, options, width, height, displayMode }: LlamacppContentProps) { const t = useI18n("widget.llamacpp"); const { data, isError } = clientApi.widget.llamacpp.getStats.useQuery({ integrationId }); // Tracks the in-flight request across polls so the widget can show the average // speed since that request started: /slots reports the request id (taskId) and how // many tokens it has generated so far (requestDecodedTokens). The server resets that // counter to 0 when a new request starts, so a fresh taskId means a fresh request. const requestRef = useRef<{ taskId: number; baseTokens: number; baseTimeMs: number } | null>(null); const perRequestSpeedTps = useMemo(() => { const taskId = data?.stats.metrics.taskId ?? null; const decoded = data?.stats.metrics.requestDecodedTokens ?? null; if (taskId === null || decoded === null) { requestRef.current = null; return null; } const nowMs = Date.now(); const current = requestRef.current; if (current === null || current.taskId !== taskId) { requestRef.current = { taskId, baseTokens: decoded, baseTimeMs: nowMs }; return null; } const elapsedSeconds = (nowMs - current.baseTimeMs) / 1000; const deltaTokens = decoded - current.baseTokens; if (elapsedSeconds <= 0 || deltaTokens < 0) { return null; } const speed = deltaTokens / elapsedSeconds; return Number.isFinite(speed) && speed > 0 ? speed : null; }, [data]); if (isError) { return (
{t("error.unreachable")}
); } if (!data) { return ; } const stats = data.stats; const isHealthy = stats.health === "ok"; const model = stats.model; const contextUsage = stats.contextUsage; const slots = stats.slots; const requestsProcessing = stats.metrics.requestsProcessing; const isBusy = requestsProcessing !== null && requestsProcessing > 0; let displayedSpeed: number | null; let speedMode: "request" | "live" | "average"; if (isBusy && perRequestSpeedTps !== null) { displayedSpeed = perRequestSpeedTps; speedMode = "request"; } else if (isBusy && (stats.metrics.generationSpeedTps ?? 0) > 0) { displayedSpeed = stats.metrics.generationSpeedTps; speedMode = "live"; } else { displayedSpeed = stats.metrics.avgGenerationSpeedTps; speedMode = "average"; } const speedTooltip = speedMode === "request" ? t("speedTooltip.request") : speedMode === "live" ? t("speedTooltip.current") : t("speedTooltip.average"); const speedValue = displayedSpeed !== null && displayedSpeed > 0 ? formatNumber(displayedSpeed, 1) : t("unit.none"); const statusColor = isHealthy ? "green" : "red"; const statusLabel = isHealthy ? undefined : t("status.unhealthy", { status: stats.health }); const contextPercent = options.showContextUsage && contextUsage ? contextUsage.percent : null; const contextLabel = contextUsage ? `${formatNumber(contextUsage.usedTokens, 0)} / ${formatNumber(contextUsage.contextSize, 0)} (${contextUsage.percent}%)` : null; const ringSize = ringSizeByScale.find(({ minWidth }) => Math.min(width, height) >= minWidth)?.size ?? 68; const ring = ( = 92 ? "lg" : "sm"} fw={800} lineClamp={1}> {speedValue} {t("unit.speedShort")} } /> ); const modelLine = model && ( {model.name} {model.quantization && ( {model.quantization} )} ); const busyLabel = requestsProcessing !== null ? (isBusy ? t("busy", { count: requestsProcessing }) : t("idle")) : null; const promptSpeed = stats.metrics.promptSpeedTps; const avgPromptSpeed = stats.metrics.avgPromptSpeedTps; const cacheHitRate = stats.metrics.promptCacheHitRate; const tokensProcessed = stats.metrics.tokensProcessed; const tokensGenerated = stats.metrics.tokensGenerated; const requestsDeferred = stats.metrics.requestsDeferred; const specDraftTokens = stats.metrics.specDraftTokens; const specAcceptedTokens = stats.metrics.specAcceptedTokens; const specDrafts = stats.metrics.specDrafts; const hasSpecStats = specDraftTokens !== null || specAcceptedTokens !== null || specDrafts !== null; if (displayMode === "advanced") { return ( {t("title")} {options.showRequests && busyLabel && ( {busyLabel} )} {ring} {options.showSpeedStats && ( } label={t("advanced.promptSpeed")} value={ promptSpeed !== null && promptSpeed > 0 ? t("unit.speed", { value: formatNumber(promptSpeed, 1) }) : t("unit.none") } /> )} {options.showSpeedStats && ( } label={t("advanced.avgPrompt")} value={ avgPromptSpeed !== null && avgPromptSpeed > 0 ? t("unit.speed", { value: formatNumber(avgPromptSpeed, 1) }) : t("unit.none") } /> )} {options.showSpeedStats && stats.metrics.avgGenerationSpeedTps !== null && ( } label={t("advanced.avgSpeed")} value={ (stats.metrics.avgGenerationSpeedTps ?? 0) > 0 ? t("unit.speed", { value: formatNumber(stats.metrics.avgGenerationSpeedTps, 1) }) : t("unit.none") } /> )} {options.showContextUsage && contextUsage && ( } label={t("stats.contextUsage")} value={contextLabel ?? "—"} /> )} {options.showRequests && requestsProcessing !== null && ( } label={t("advanced.requests")} value={formatNumber(requestsProcessing, 0)} /> )} {options.showRequests && requestsDeferred !== null && ( } label={t("advanced.deferred")} value={formatNumber(requestsDeferred, 0)} /> )} {(options.showRequests || (options.showSpeculative && slots?.speculative)) && slots && ( {options.showRequests && ( {t("advanced.slots", { processing: slots.processing, total: slots.total })} )} {options.showSpeculative && slots.speculative && ( {t("advanced.speculative")} )} )} {options.showModelInfo && model && ( } label={t("modelInfo.model")} value={model.name} valueTooltip={model.id} /> {model.quantization && ( } label={t("modelInfo.quantization")} value={model.quantization} /> )} {model.contextSize !== null && ( } label={t("modelInfo.context")} value={formatNumber(model.contextSize, 0)} /> )} {model.fileSizeBytes !== null && ( } label={t("modelInfo.size")} value={formatBytes(model.fileSizeBytes)} /> )} {model.parameterCount !== null && ( } label={t("modelInfo.parameters")} value={t("unit.parameters", { value: formatNumber(model.parameterCount / 1e9, 2) })} /> )} )} {options.showTokenStats && (tokensProcessed !== null || tokensGenerated !== null) && ( {tokensProcessed !== null && ( } label={t("stats.tokensProcessed")} value={formatNumber(tokensProcessed, 0)} /> )} {tokensGenerated !== null && ( } label={t("stats.tokensGenerated")} value={formatNumber(tokensGenerated, 0)} /> )} )} {options.showSpeculative && hasSpecStats && ( {specDrafts !== null && ( } label={t("advanced.specDrafts")} value={formatNumber(specDrafts, 0)} /> )} {specDraftTokens !== null && ( } label={t("advanced.specDraftTokens")} value={formatNumber(specDraftTokens, 0)} /> )} {specAcceptedTokens !== null && ( } label={t("advanced.specAccepted")} value={formatNumber(specAcceptedTokens, 0)} /> )} )} {options.showCacheHitRate && cacheHitRate !== null && ( } label={t("stats.cacheHitRate")} value={`${cacheHitRate}%`} progress={{ value: cacheHitRate, color: cacheHitRate > 75 ? "green" : "blue" }} /> )} ); } // Compact (default) mode — density adapts to the widget's grid footprint. if (isNarrowWidth(width)) { return (
{ring}
{!isShortHeight(height) && options.showModelInfo && modelLine}
); } const rightColumn = ( {options.showModelInfo && modelLine} {options.showRequests && busyLabel && ( {busyLabel} )} {options.showContextUsage && contextUsage && ( {contextUsage.percent}% )} {isWideWidth(width) && ( {options.showSpeedStats && promptSpeed !== null && promptSpeed > 0 && ( )} {options.showCacheHitRate && cacheHitRate !== null && ( )} {options.showTokenStats && (tokensProcessed !== null || tokensGenerated !== null) && ( )} )} ); if (isShortHeight(height)) { return (
{ring} {!isNarrowWidth(width) && ( {options.showModelInfo && modelLine} {options.showRequests && busyLabel && ( {busyLabel} )} )}
); } return (
{ring} {rightColumn}
); } const StatusDot = ({ color, label }: { color: string; label?: string }) => { const dot = ( ); if (!label) return dot; return ( {dot} ); }; interface AdvancedRowProps { icon: ReactNode; label: string; value: string; valueTooltip?: string; progress?: { value: number; color: string }; } const AdvancedRow = ({ icon, label, value, valueTooltip, progress }: AdvancedRowProps) => ( {icon} {label} {valueTooltip ? ( {value} ) : ( {value} )} {progress && } ); const InfoRow = ({ label, value }: { label: string; value: string }) => ( {label} {value} );