Files
homarr/packages/widgets/src/llama-cpp/component.tsx
T

572 lines
20 KiB
TypeScript

"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 (
<LlamacppContent
integrationId={integrationId}
options={options}
width={width}
height={height}
displayMode={displayMode}
/>
);
}
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 (
<Center h="100%" w="100%" p="sm">
<Group gap="xs" wrap="nowrap" miw={0}>
<IconCircleX size={20} color="red" />
<Text size="sm" c="dimmed" lineClamp={1}>
{t("error.unreachable")}
</Text>
</Group>
</Center>
);
}
if (!data) {
return <WidgetEmptyState />;
}
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 = (
<Tooltip label={speedTooltip} withArrow position="bottom">
<RingProgress
size={ringSize}
thickness={Math.max(6, Math.round(ringSize / 10))}
roundCaps
sections={[
{ value: contextPercent ?? 0, color: contextPercent !== null ? getContextColor(contextPercent) : "gray" },
]}
label={
<Center>
<Stack gap={0} align="center" style={{ lineHeight: 1.15 }}>
<Text size={ringSize >= 92 ? "lg" : "sm"} fw={800} lineClamp={1}>
{speedValue}
</Text>
<Text size="xs" c="dimmed" lineClamp={1}>
{t("unit.speedShort")}
</Text>
</Stack>
</Center>
}
/>
</Tooltip>
);
const modelLine = model && (
<Group gap={6} wrap="nowrap" miw={0}>
<IconBrain size={14} color="dimmed" style={{ flexShrink: 0 }} />
<Tooltip label={model.id} withArrow>
<Text size="xs" lineClamp={1} style={{ maxWidth: "100%" }}>
{model.name}
</Text>
</Tooltip>
{model.quantization && (
<Text size="xs" c="dimmed" fw={600} lineClamp={1} style={{ flexShrink: 0 }}>
{model.quantization}
</Text>
)}
</Group>
);
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 (
<Box h="100%" w="100%">
<ScrollArea h="100%">
<Stack gap="md" p="md">
<Group gap="xs" wrap="nowrap" miw={0}>
<StatusDot color={statusColor} label={statusLabel} />
<Text size="md" fw={700} lineClamp={1}>
{t("title")}
</Text>
{options.showRequests && busyLabel && (
<Text size="xs" c="dimmed" lineClamp={1}>
{busyLabel}
</Text>
)}
</Group>
<Group gap="lg" wrap="nowrap" miw={0}>
{ring}
<Stack gap="sm" style={{ flex: 1, minWidth: 0 }}>
{options.showSpeedStats && (
<AdvancedRow
icon={<IconBolt size={14} color="dimmed" />}
label={t("advanced.promptSpeed")}
value={
promptSpeed !== null && promptSpeed > 0
? t("unit.speed", { value: formatNumber(promptSpeed, 1) })
: t("unit.none")
}
/>
)}
{options.showSpeedStats && (
<AdvancedRow
icon={<IconBolt size={14} color="dimmed" />}
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 && (
<AdvancedRow
icon={<IconGauge size={14} color="dimmed" />}
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 && (
<AdvancedRow
icon={<IconDatabase size={14} color="dimmed" />}
label={t("stats.contextUsage")}
value={contextLabel ?? "—"}
/>
)}
{options.showRequests && requestsProcessing !== null && (
<AdvancedRow
icon={<IconServer size={14} color="dimmed" />}
label={t("advanced.requests")}
value={formatNumber(requestsProcessing, 0)}
/>
)}
{options.showRequests && requestsDeferred !== null && (
<AdvancedRow
icon={<IconLayersIntersect size={14} color="dimmed" />}
label={t("advanced.deferred")}
value={formatNumber(requestsDeferred, 0)}
/>
)}
</Stack>
</Group>
{(options.showRequests || (options.showSpeculative && slots?.speculative)) && slots && (
<Group gap="sm" wrap="nowrap" miw={0}>
{options.showRequests && (
<Text size="xs" c="dimmed" lineClamp={1}>
{t("advanced.slots", { processing: slots.processing, total: slots.total })}
</Text>
)}
{options.showSpeculative && slots.speculative && (
<Text size="xs" c="dimmed" lineClamp={1} fw={600}>
{t("advanced.speculative")}
</Text>
)}
</Group>
)}
{options.showModelInfo && model && (
<Stack gap="xs">
<AdvancedRow
icon={<IconBrain size={14} color="dimmed" />}
label={t("modelInfo.model")}
value={model.name}
valueTooltip={model.id}
/>
{model.quantization && (
<AdvancedRow
icon={<IconFileText size={14} color="dimmed" />}
label={t("modelInfo.quantization")}
value={model.quantization}
/>
)}
{model.contextSize !== null && (
<AdvancedRow
icon={<IconDatabase size={14} color="dimmed" />}
label={t("modelInfo.context")}
value={formatNumber(model.contextSize, 0)}
/>
)}
{model.fileSizeBytes !== null && (
<AdvancedRow
icon={<IconFileText size={14} color="dimmed" />}
label={t("modelInfo.size")}
value={formatBytes(model.fileSizeBytes)}
/>
)}
{model.parameterCount !== null && (
<AdvancedRow
icon={<IconBrain size={14} color="dimmed" />}
label={t("modelInfo.parameters")}
value={t("unit.parameters", { value: formatNumber(model.parameterCount / 1e9, 2) })}
/>
)}
</Stack>
)}
{options.showTokenStats && (tokensProcessed !== null || tokensGenerated !== null) && (
<Group gap="md" wrap="nowrap" miw={0}>
{tokensProcessed !== null && (
<AdvancedRow
icon={<IconFileText size={14} color="dimmed" />}
label={t("stats.tokensProcessed")}
value={formatNumber(tokensProcessed, 0)}
/>
)}
{tokensGenerated !== null && (
<AdvancedRow
icon={<IconBolt size={14} color="dimmed" />}
label={t("stats.tokensGenerated")}
value={formatNumber(tokensGenerated, 0)}
/>
)}
</Group>
)}
{options.showSpeculative && hasSpecStats && (
<Stack gap="xs">
{specDrafts !== null && (
<AdvancedRow
icon={<IconLayersIntersect size={14} color="dimmed" />}
label={t("advanced.specDrafts")}
value={formatNumber(specDrafts, 0)}
/>
)}
{specDraftTokens !== null && (
<AdvancedRow
icon={<IconFileText size={14} color="dimmed" />}
label={t("advanced.specDraftTokens")}
value={formatNumber(specDraftTokens, 0)}
/>
)}
{specAcceptedTokens !== null && (
<AdvancedRow
icon={<IconBolt size={14} color="dimmed" />}
label={t("advanced.specAccepted")}
value={formatNumber(specAcceptedTokens, 0)}
/>
)}
</Stack>
)}
{options.showCacheHitRate && cacheHitRate !== null && (
<AdvancedRow
icon={<IconDatabase size={14} color="dimmed" />}
label={t("stats.cacheHitRate")}
value={`${cacheHitRate}%`}
progress={{ value: cacheHitRate, color: cacheHitRate > 75 ? "green" : "blue" }}
/>
)}
</Stack>
</ScrollArea>
</Box>
);
}
// Compact (default) mode — density adapts to the widget's grid footprint.
if (isNarrowWidth(width)) {
return (
<Center h="100%" w="100%">
<Stack gap={isShortHeight(height) ? 0 : 4} align="center" p="xs" style={{ position: "relative" }}>
<Center pos="relative">
{ring}
<Box pos="absolute" top={0} right={0} style={{ zIndex: 1 }}>
<StatusDot color={statusColor} label={statusLabel} />
</Box>
</Center>
{!isShortHeight(height) && options.showModelInfo && modelLine}
</Stack>
</Center>
);
}
const rightColumn = (
<Stack gap="xs" style={{ flex: 1, minWidth: 0 }}>
{options.showModelInfo && modelLine}
{options.showRequests && busyLabel && (
<Text size="xs" c="dimmed" lineClamp={1}>
{busyLabel}
</Text>
)}
{options.showContextUsage && contextUsage && (
<Group gap={6} wrap="nowrap" miw={0} w="100%" align="center">
<Progress
value={contextUsage.percent}
size="xs"
radius="xs"
color={getContextColor(contextUsage.percent)}
style={{ flex: 1, minWidth: 0 }}
/>
<Text size="xs" c="dimmed" lineClamp={1} style={{ flexShrink: 0 }}>
{contextUsage.percent}%
</Text>
</Group>
)}
{isWideWidth(width) && (
<Stack gap="xs">
{options.showSpeedStats && promptSpeed !== null && promptSpeed > 0 && (
<InfoRow
label={t("advanced.promptSpeed")}
value={t("unit.speed", { value: formatNumber(promptSpeed, 1) })}
/>
)}
{options.showCacheHitRate && cacheHitRate !== null && (
<InfoRow label={t("stats.cacheHitRate")} value={`${cacheHitRate}%`} />
)}
{options.showTokenStats && (tokensProcessed !== null || tokensGenerated !== null) && (
<InfoRow
label={t("stats.tokensProcessed")}
value={
tokensProcessed !== null && tokensGenerated !== null
? formatNumber(tokensProcessed + tokensGenerated, 0)
: tokensProcessed !== null
? formatNumber(tokensProcessed, 0)
: "—"
}
/>
)}
</Stack>
)}
</Stack>
);
if (isShortHeight(height)) {
return (
<Center h="100%" w="100%">
<Group gap="md" w="100%" wrap="nowrap" miw={0} justify="center" p="xs">
{ring}
{!isNarrowWidth(width) && (
<Stack gap={2} style={{ minWidth: 0 }}>
{options.showModelInfo && modelLine}
{options.showRequests && busyLabel && (
<Text size="xs" c="dimmed" lineClamp={1}>
{busyLabel}
</Text>
)}
</Stack>
)}
</Group>
</Center>
);
}
return (
<Box h="100%" p="xs" pos="relative">
<Group pos="absolute" top={6} right={10} gap={0} style={{ zIndex: 2 }}>
<StatusDot color={statusColor} label={statusLabel} />
</Group>
<Center h="100%">
<Group gap="md" w="100%" wrap="nowrap" miw={0}>
{ring}
{rightColumn}
</Group>
</Center>
</Box>
);
}
const StatusDot = ({ color, label }: { color: string; label?: string }) => {
const dot = (
<Box
aria-label={label}
style={{
width: 9,
height: 9,
borderRadius: "50%",
backgroundColor: `var(--mantine-color-${color}-6)`,
flexShrink: 0,
cursor: "default",
}}
/>
);
if (!label) return dot;
return (
<Tooltip label={label} withArrow position="left">
{dot}
</Tooltip>
);
};
interface AdvancedRowProps {
icon: ReactNode;
label: string;
value: string;
valueTooltip?: string;
progress?: { value: number; color: string };
}
const AdvancedRow = ({ icon, label, value, valueTooltip, progress }: AdvancedRowProps) => (
<Stack gap={3} style={{ minWidth: 150 }}>
<Group gap={6} wrap="nowrap" miw={0} justify="space-between" w="100%">
<Group gap={6} wrap="nowrap" miw={0}>
{icon}
<Text size="xs" c="dimmed" lineClamp={1}>
{label}
</Text>
</Group>
{valueTooltip ? (
<Tooltip label={valueTooltip} withArrow>
<Text size="xs" fw={600} lineClamp={1} style={{ maxWidth: 240 }}>
{value}
</Text>
</Tooltip>
) : (
<Text size="xs" fw={600} lineClamp={1} style={{ maxWidth: 240 }}>
{value}
</Text>
)}
</Group>
{progress && <Progress value={progress.value} size="xs" radius="xs" color={progress.color} />}
</Stack>
);
const InfoRow = ({ label, value }: { label: string; value: string }) => (
<Group justify="space-between" wrap="nowrap" miw={0}>
<Text size="xs" c="dimmed" lineClamp={1}>
{label}
</Text>
<Text size="xs" fw={600} lineClamp={1}>
{value}
</Text>
</Group>
);