"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}
);