import { useState } from "react"; import type { ReactNode } from "react"; import { ArrowClockwise, CaretRight, CheckCircle, Clock, CloudArrowDown, Gauge, HardDrives, SpinnerGap, Trash, WarningCircle, } from "@phosphor-icons/react"; import { useBenchmarkState, getAvgTokPerSec, type ModelMetrics } from "../hooks/useBenchmark"; import { getModelDescriptors } from "../runtime/registry"; import { formatBytes, quantStatus, type QuantStatus } from "../lib/modelCacheRecord"; import { en } from "../i18n/en"; import type { ModelCategory, ModelDescriptor, ModelId } from "../types"; import type { ModelCacheState } from "../hooks/useModelCache"; /** The sections the status bar groups its model cards under. */ const CATEGORIES: { id: ModelCategory; label: string }[] = [ { id: "language", label: en.categoryLanguage }, { id: "embedder", label: en.categoryEmbedder }, { id: "tts", label: en.categoryTts }, ]; export function BenchmarkPanel({ model, cache, onSelect, settingsPanel, onDownload, loadingModel = null, disabled = false, }: { model: ModelId; cache: ModelCacheState; onSelect(id: ModelId): void; /** Rendered at the very top of the bar: the Settings panel and its button. */ settingsPanel?: ReactNode; /** Download (pre-fetch the weights of) a quant into the browser cache. */ onDownload?(id: ModelId): void; /** The quant whose load is currently in flight, if any. */ loadingModel?: ModelId | null; disabled?: boolean; }) { const { metrics } = useBenchmarkState(); const models = getModelDescriptors(); // First step: only the language models exist yet, and only that section // starts expanded - the other two are reserved for models to come. const [openCats, setOpenCats] = useState>({ language: true, embedder: false, tts: false, }); return ( ); } function ModelCard({ m, md, avgTok, cache, model, onSelect, onDownload, loadingModel, disabled, }: { m: ModelDescriptor; md: ModelMetrics | undefined; avgTok: number; cache: ModelCacheState; model: ModelId; onSelect(id: ModelId): void; onDownload?(id: ModelId): void; loadingModel: ModelId | null; disabled: boolean; }) { const status = quantStatus(cache.cached[m.id], cache.loadedHere[m.id]); const clearing = cache.clearing === m.id; const isSelected = model === m.id; return (
button:first-child:active]:scale-[0.99]", // Keyboard-only focus ring, offset so it never fuses with the // border (and never with the selected border either). "has-[>button:first-child:focus-visible]:ring-2 has-[>button:first-child:focus-visible]:ring-emerald-600", "ring-offset-2 ring-offset-zinc-50 dark:ring-offset-zinc-950", isSelected ? "border-emerald-600/50 bg-emerald-600/5 dark:border-emerald-500/50 dark:bg-emerald-500/10" : disabled ? "border-zinc-200 bg-white opacity-60 dark:border-zinc-800 dark:bg-zinc-900" : "border-zinc-200 bg-white hover:border-zinc-300 dark:border-zinc-800 dark:bg-zinc-900 dark:hover:border-zinc-600", ].join(" ")} > {/* The card body is the select control: one real button, so it is clickable, keyboard-reachable, and screen-readable as a single action. The clear-cache row below is its sibling - a button can never contain another button. */} {/* The "In use" tag rides the card's top-right corner, outside the content flow. It must not be the section's first child: the press/focus selectors target `>button:first-child`, which would silently stop matching otherwise. */} {isSelected && ( {en.inUseLabel} )} {status !== "not-downloaded" && status !== "unknown" && ( )} {(status === "not-downloaded" || status === "evicted") && ( )}
); } const CHIP: Record = { ready: { icon: , label: en.downloadedLabel, tone: "text-emerald-700 dark:text-emerald-400", }, "not-downloaded": { icon: , label: en.notDownloadedLabel, tone: "text-zinc-500 dark:text-zinc-400", }, evicted: { icon: , label: en.evictedLabel, tone: "text-amber-600 dark:text-amber-400", }, unknown: { icon: , label: "…", tone: "text-zinc-500 dark:text-zinc-400", }, }; function StatusChip({ status }: { status: QuantStatus }) { const chip = CHIP[status]; return ( {chip.icon} {chip.label} ); } /** * One figure with its label. The label is deliberately visible text rather than * a tooltip: these numbers are meaningless on their own, and an icon alone does * not say whether "6.89 GB" is what is being used or what is available. * * `row` puts the label and value on one line (for the storage card, where the * labels are long); the default stacks them, which is what fits two per row in * the per-quant grid. */ function Metric({ icon, label, value, accent, layout = "stack", }: { icon: ReactNode; label: string; value: string; accent?: boolean; layout?: "stack" | "row"; }) { const tone = accent ? "text-emerald-700 dark:text-emerald-400" : "text-zinc-900 dark:text-zinc-100"; const caption = "flex min-w-0 items-center gap-1 text-[10px] font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"; if (layout === "row") { return (
{icon} {label} {value}
); } return (
{icon} {label} {value}
); }