Network activity
Browse blocks
Device
Three tools for checking what your device can run, all with nothing to download. See your hardware and browser features, get model recommendations for your device, and browse or inspect GGUF models from HuggingFace.
Device Report
See what your device and browser can do for on-device AI. It checks your hardware, browser features, and free storage, then tells you whether a small AI model will run here. Nothing is downloaded.
Install this block
npx shadcn@latest add @localmode/ui/blocks/device/device-reportReadiness check: 1B-parameter Q4_K_M GGUF LLM
'use client';/** * @file device-report.tsx * @description Self-sufficient zero-download device capability report — hardware, six feature flags, storage, browser/OS, a reference-model readiness verdict, and adaptive batch sizing, all from browser APIs. * @constraint Renders entirely from browser APIs — zero network, zero model bytes on mount. */import { useEffect, useState } from 'react';import { AlertTriangle, RefreshCw } from 'lucide-react';import { useAdaptiveBatchSize, useCapabilities, useStorageQuota } from '@localmode/react';import { formatBytes } from '@localmode/core';import { DeviceBadge } from '@/components/device-badge';import { DeviceCapabilityGrid } from '@/components/device-capability-grid';import { BrowserCompatCard } from '@/components/browser-compat-card';import { StorageMeter } from '@/components/storage-meter';import { AdaptiveBatchCard } from '@/components/adaptive-batch-card';const REFERENCE_MODEL_LABEL = 'Readiness check: 1B-parameter Q4_K_M GGUF LLM';const REFERENCE_MODEL_SHORT = '1B Q4_K_M GGUF LLM';const REFERENCE_REQUIRED_GB = 1.2;const ASSUMED_MEMORY_GB = 4;function sanitizeGpuLabel(raw: string): string { let out = raw.trim(); while ((out.match(/\(/g) ?? []).length > (out.match(/\)/g) ?? []).length) { const cut = out.replace(/\s*\([^()]*$/, '').trim(); if (cut === out) break; out = cut; } return out;}function simplifyGpuName(raw: string): string { const renderer = raw.match(/Renderer:\s*([^,()]+)/i); if (renderer?.[1]) return renderer[1].trim(); if (/SwiftShader/i.test(raw)) return 'SwiftShader'; if (/llvmpipe/i.test(raw)) return 'llvmpipe'; const angle = raw.match(/^ANGLE\s*\((.*)\)\s*$/i); if (angle?.[1]) { const parts = angle[1] .split(',') .map((p) => p.trim()) .filter(Boolean); const label = (parts[1] ?? parts[0] ?? '').replace(/\bDirect3D.*$/i, '').trim(); if (label) return sanitizeGpuLabel(label); } return sanitizeGpuLabel(raw);}export function DeviceReportBlock() { const { capabilities, isDetecting, error: capabilitiesError, refresh } = useCapabilities(); const { quota, error: quotaError, refresh: refreshQuota } = useStorageQuota(); const batch = useAdaptiveBatchSize({ taskType: 'embedding', modelDimensions: 384 }); const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); const error = capabilitiesError ?? quotaError; const status = error ? 'error' : isDetecting || !capabilities ? 'detecting' : 'ready'; const memoryGB = capabilities?.hardware.memory ?? null; const gpuName = capabilities?.hardware.gpu ?? null; const memoryUnknown = capabilities != null && memoryGB == null; const browserLine = capabilities ? [ `${capabilities.browser.name} v${capabilities.browser.version}`, `${capabilities.device.os}${capabilities.device.osVersion ? ` ${capabilities.device.osVersion}` : ''}`, capabilities.device.type, ].join(' · ') : null; const estimatedSpeed = capabilities ? capabilities.features.webgpu ? 'Fast (WebGPU available)' : capabilities.features.simd ? 'Moderate (WASM SIMD)' : 'Slow (plain WASM)' : undefined; const readinessWarnings = memoryUnknown ? [ `Device memory is unknown (navigator.deviceMemory unavailable in this browser) - assuming ${ASSUMED_MEMORY_GB} GB, so this verdict may be conservative or optimistic.`, ] : []; return ( <div className="flex flex-col gap-4 p-4"> {} <span data-state={status} className="sr-only"> {status} </span> {} <span aria-label="Device report status" className="sr-only"> {status} </span> {capabilities && ( <span aria-label="Detected capabilities" className="sr-only"> {JSON.stringify({ cores: capabilities.hardware.cores, memoryGB, gpu: gpuName, webgpu: capabilities.features.webgpu, wasm: capabilities.features.wasm, simd: capabilities.features.simd, threads: capabilities.features.threads, indexeddb: capabilities.features.indexeddb, webworkers: capabilities.features.webworkers, })} </span> )} {} <div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center gap-2"> <DeviceBadge capability="webgpu" /> <DeviceBadge capability="wasm" /> <DeviceBadge capability="storage" /> </div> {browserLine && ( <p className="text-xs text-muted-foreground"> {browserLine} </p> )} </div> {error && ( <div className="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive" > <AlertTriangle className="size-4 shrink-0" aria-hidden="true" /> <span className="min-w-0 flex-1">{error.message}</span> <button type="button" onClick={() => { void refresh(); void refreshQuota(); }} className="inline-flex shrink-0 items-center gap-1 rounded-md border border-border bg-background px-2 py-1 font-medium text-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > <RefreshCw className="size-3" aria-hidden="true" /> Retry </button> </div> )} <div className="grid items-start gap-4 md:grid-cols-2"> {} <div className="flex flex-col gap-2"> <div> {} <DeviceCapabilityGrid className="max-w-none [&_p]:hidden" /> </div> {capabilities && ( <p className="px-1 text-xs text-muted-foreground"> GPU:{' '} <span className="font-medium text-foreground"> {gpuName ? simplifyGpuName(gpuName) : 'None'} </span> </p> )} {memoryUnknown && ( <p className="flex items-start gap-1.5 px-1 text-xs text-amber-600 dark:text-amber-400" > <AlertTriangle className="mt-0.5 size-3.5 shrink-0" aria-hidden="true" /> <span> navigator.deviceMemory is unavailable in this browser - device memory is unknown; the readiness check assumes {ASSUMED_MEMORY_GB} GB. </span> </p> )} </div> {} <div className="flex flex-col gap-4"> <div className="flex flex-col gap-2"> <div> <StorageMeter className="max-w-none" quota={ quota ? { usedBytes: quota.usedBytes, quotaBytes: quota.quotaBytes } : undefined } /> </div> {quota && ( <p aria-label="Storage availability" data-available-bytes={quota.availableBytes} data-quota-bytes={quota.quotaBytes} className="px-1 text-xs text-muted-foreground" > <span className="font-medium text-foreground"> {formatBytes(quota.availableBytes)} </span>{' '} available of {formatBytes(quota.quotaBytes)} quota {quota.isPersisted ? ' · persisted' : ''} </p> )} </div> <div className="flex flex-col gap-2"> {} <p className="px-1 text-sm font-medium text-foreground">{REFERENCE_MODEL_LABEL}</p> {capabilities ? ( <BrowserCompatCard className="max-w-none" modelName={REFERENCE_MODEL_SHORT} requiredGB={REFERENCE_REQUIRED_GB} deviceGB={memoryGB ?? ASSUMED_MEMORY_GB} availableStorageGB={ quota ? Math.round((quota.availableBytes / 1024 ** 3) * 10) / 10 : undefined } crossOriginIsolated={capabilities.features.crossOriginisolated} estimatedSpeed={estimatedSpeed} warnings={readinessWarnings} /> ) : ( <div role="status" className="rounded-xl border border-border bg-card p-5 text-sm text-muted-foreground" > Checking device readiness… </div> )} </div> <div> {mounted ? ( <> {} <AdaptiveBatchCard className="max-w-none [&_.grid-cols-3]:hidden" result={batch} /> <span aria-label="Adaptive batch size" data-batch-size={batch.batchSize} className="sr-only" > {batch.batchSize} </span> <span aria-label="Batch sizing reasoning" className="sr-only"> {batch.reasoning} </span> </> ) : ( <div role="status" className="rounded-xl border border-border bg-card p-5 text-sm text-muted-foreground" > Computing optimal batch size… </div> )} </div> </div> </div> </div> );}Model Advisor
Find the best on-device model for a task on your hardware. Pick a task to get a ranked list of models that fit your device, compare any two side by side, and add your own model to the list. Nothing is downloaded.
Install this block
npx shadcn@latest add @localmode/ui/blocks/device/model-advisor0 of 5 registry models fit this device for Embedding
'use client';/** * @file model-advisor.tsx * @description Self-sufficient block: computes its own device context via useCapabilities and ranks useModelRecommendations over the in-memory registry, with two-model comparison and custom model registration — zero network on mount. */import { useState, type FormEvent, type ReactNode } from 'react';import { AlertTriangle, Plus, RefreshCw, X } from 'lucide-react';import { getModelRegistry, registerModel, type ModelRegistryEntry, type TaskCategory,} from '@localmode/core';import { useCapabilities, useModelRecommendations } from '@localmode/react';import { ModelRecommendationCard } from '@/components/model-recommendation-card';import { ModelComparisonPanel, type ComparisonEntry,} from '@/components/model-comparison-panel';import { cn } from '@/lib/utils';const DEFAULT_TASK: TaskCategory = 'embedding';const RECOMMENDATION_LIMIT = 24;const TASK_LABELS: Record<TaskCategory, string> = { 'embedding': 'Embedding', 'classification': 'Classification', 'zero-shot': 'Zero-Shot Classification', 'ner': 'Named Entity Recognition', 'reranking': 'Reranking', 'generation': 'Text Generation', 'translation': 'Translation', 'summarization': 'Summarization', 'fill-mask': 'Fill-Mask', 'question-answering': 'Question Answering', 'speech-to-text': 'Speech to Text', 'text-to-speech': 'Text to Speech', 'image-classification': 'Image Classification', 'image-captioning': 'Image Captioning', 'object-detection': 'Object Detection', 'segmentation': 'Segmentation', 'ocr': 'OCR', 'document-qa': 'Document QA', 'image-features': 'Image Features', 'image-to-image': 'Image to Image', 'multimodal-embedding': 'Multimodal Embedding',};const TASK_GROUPS: ReadonlyArray<{ label: string; tasks: readonly TaskCategory[] }> = [ { label: 'Text', tasks: [ 'embedding', 'classification', 'zero-shot', 'ner', 'reranking', 'fill-mask', 'question-answering', ], }, { label: 'Generation', tasks: ['generation'] }, { label: 'Translation & Summarization', tasks: ['translation', 'summarization'] }, { label: 'Vision', tasks: [ 'image-classification', 'image-captioning', 'object-detection', 'segmentation', 'ocr', 'document-qa', 'image-features', 'image-to-image', 'multimodal-embedding', ], }, { label: 'Audio', tasks: ['speech-to-text', 'text-to-speech'] },];type Recommendation = ReturnType<typeof useModelRecommendations>['recommendations'][number];function formatSize(sizeMB: number): string { if (sizeMB >= 1024) return `${(sizeMB / 1024).toFixed(1)} GB`; return `${sizeMB} MB`;}function sanitizeGpuLabel(raw: string): string { let out = raw.trim(); while ((out.match(/\(/g) ?? []).length > (out.match(/\)/g) ?? []).length) { const cut = out.replace(/\s*\([^()]*$/, '').trim(); if (cut === out) break; out = cut; } return out;}function simplifyGpuName(raw: string): string { const renderer = raw.match(/Renderer:\s*([^,()]+)/i); if (renderer?.[1]) return renderer[1].trim(); if (/SwiftShader/i.test(raw)) return 'SwiftShader'; if (/llvmpipe/i.test(raw)) return 'llvmpipe'; const angle = raw.match(/^ANGLE\s*\((.*)\)\s*$/i); if (angle?.[1]) { const parts = angle[1] .split(',') .map((p) => p.trim()) .filter(Boolean); const label = (parts[1] ?? parts[0] ?? '').replace(/\bDirect3D.*$/i, '').trim(); if (label) return sanitizeGpuLabel(label); } return sanitizeGpuLabel(raw);}function humanMB(mb: number): string { if (!Number.isFinite(mb)) return `${mb} MB`; if (mb >= 1024 * 1024) return `${(mb / (1024 * 1024)).toFixed(1)} TB`; if (mb >= 1024) return `${(mb / 1024).toFixed(mb >= 10240 ? 0 : 1)} GB`; return `${Math.round(mb)} MB`;}function humanizeReason(reason: string): string { return reason.replace( /(\d+(?:\.\d+)?)\s*MB\s+of\s+(\d+(?:\.\d+)?)\s*MB/gi, (_m, a: string, b: string) => `${humanMB(Number(a))} of ${humanMB(Number(b))}`, );}function toComparisonEntry(rec: Recommendation): ComparisonEntry { return { modelId: rec.entry.modelId, name: rec.entry.name, score: rec.score, sizeMB: rec.entry.sizeMB, size: formatSize(rec.entry.sizeMB), ...(rec.entry.speedTier ? { speedTier: rec.entry.speedTier } : {}), ...(rec.entry.qualityTier ? { qualityTier: rec.entry.qualityTier } : {}), ...(rec.entry.recommendedDevice ? { device: rec.entry.recommendedDevice } : {}), ...(rec.entry.dimensions != null ? { dimensions: rec.entry.dimensions } : {}), };}interface RegistrationDraft { modelId: string; name: string; provider: string; task: TaskCategory; sizeMB: string; minMemoryMB: string; dimensions: string; recommendedDevice: 'webgpu' | 'wasm' | 'cpu'; speedTier: 'fast' | 'medium' | 'slow'; qualityTier: 'low' | 'medium' | 'high'; description: string;}const EMPTY_DRAFT: RegistrationDraft = { modelId: '', name: '', provider: '', task: DEFAULT_TASK, sizeMB: '', minMemoryMB: '', dimensions: '', recommendedDevice: 'wasm', speedTier: 'medium', qualityTier: 'medium', description: '',};function validateDraft(draft: RegistrationDraft): Partial<Record<keyof RegistrationDraft, string>> { const errors: Partial<Record<keyof RegistrationDraft, string>> = {}; if (!draft.modelId.trim()) errors.modelId = 'Model id is required.'; if (!draft.name.trim()) errors.name = 'Name is required.'; if (!draft.provider.trim()) errors.provider = 'Provider is required.'; if (!draft.sizeMB.trim()) { errors.sizeMB = 'Size is required.'; } else { const size = Number(draft.sizeMB); if (!Number.isFinite(size) || size < 1) errors.sizeMB = 'Size must be a number of at least 1 MB.'; } if (draft.minMemoryMB.trim()) { const mem = Number(draft.minMemoryMB); if (!Number.isFinite(mem) || mem <= 0) errors.minMemoryMB = 'Min memory must be a positive number of MB.'; } if (draft.dimensions.trim()) { const dims = Number(draft.dimensions); if (!Number.isInteger(dims) || dims <= 0) errors.dimensions = 'Dimensions must be a positive integer.'; } return errors;}const INPUT_CLASS = 'h-8 w-full rounded-md border border-input bg-background px-2 text-sm font-normal ' + 'focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50';function Field({ label, name, required = false, error, className, children,}: { label: string; name: keyof RegistrationDraft; required?: boolean; error?: string; className?: string; children: ReactNode;}) { return ( <label className={cn('flex min-w-0 flex-col gap-1 text-xs font-medium', className)}> <span> {label} {required && <span className="text-destructive"> *</span>} </span> {children} {error && ( <span className="font-normal text-destructive" > {error} </span> )} </label> );}export function ModelAdvisorBlock() { const [selectedTask, setSelectedTask] = useState<TaskCategory>(DEFAULT_TASK); const [compare, setCompare] = useState<ComparisonEntry[]>([]); const [registerOpen, setRegisterOpen] = useState(false); const [draft, setDraft] = useState<RegistrationDraft>(EMPTY_DRAFT); const [fieldErrors, setFieldErrors] = useState<Partial<Record<keyof RegistrationDraft, string>>>({}); const [formError, setFormError] = useState<string | null>(null); const [registerSuccess, setRegisterSuccess] = useState<string | null>(null); const { recommendations, isLoading, error, refresh } = useModelRecommendations({ task: selectedTask, limit: RECOMMENDATION_LIMIT, }); const { capabilities: device, isDetecting, refresh: refreshDevice } = useCapabilities(); const registryTotal = getModelRegistry().filter((e) => e.task === selectedTask).length; const deviceSummary = device ? [ `${device.hardware.cores} cores`, device.hardware.memory != null ? `${device.hardware.memory} GB RAM` : 'RAM unknown', device.hardware.gpu ? `GPU: ${simplifyGpuName(device.hardware.gpu)}` : null, device.features.webgpu ? 'WebGPU available' : 'WebGPU unavailable (WASM fallback)', ] .filter(Boolean) .join(' · ') : null; const onTaskChange = (task: TaskCategory) => { setSelectedTask(task); setCompare([]); }; const toggleCompare = (modelId: string) => { setCompare((prev) => { if (prev.some((entry) => entry.modelId === modelId)) { return prev.filter((entry) => entry.modelId !== modelId); } const rec = recommendations.find((r) => r.entry.modelId === modelId); if (!rec) return prev; const entry = toComparisonEntry(rec); if (prev.length < 2) return [...prev, entry]; const last = prev[prev.length - 1]; return last ? [last, entry] : [entry]; }); }; const clearCompare = () => setCompare([]); const openRegisterForm = () => { if (registerOpen) { setRegisterOpen(false); return; } setRegisterSuccess(null); setFormError(null); setFieldErrors({}); setDraft((d) => ({ ...d, task: selectedTask })); setRegisterOpen(true); }; const setDraftField = <K extends keyof RegistrationDraft>(key: K, value: RegistrationDraft[K]) => { setDraft((d) => ({ ...d, [key]: value })); }; const submitRegistration = (event: FormEvent<HTMLFormElement>) => { event.preventDefault(); const errors = validateDraft(draft); setFieldErrors(errors); setFormError(null); if (Object.keys(errors).length > 0) return; const entry: ModelRegistryEntry = { modelId: draft.modelId.trim(), provider: draft.provider.trim(), task: draft.task, name: draft.name.trim(), sizeMB: Number(draft.sizeMB), recommendedDevice: draft.recommendedDevice, speedTier: draft.speedTier, qualityTier: draft.qualityTier, ...(draft.minMemoryMB.trim() ? { minMemoryMB: Number(draft.minMemoryMB) } : {}), ...(draft.dimensions.trim() ? { dimensions: Number(draft.dimensions) } : {}), ...(draft.description.trim() ? { description: draft.description.trim() } : {}), }; try { registerModel(entry); refresh(); setRegisterSuccess( `Registered "${entry.name}" (${entry.modelId}) for ${TASK_LABELS[entry.task]} - recommendations refreshed.`, ); setDraft({ ...EMPTY_DRAFT, task: selectedTask }); setFieldErrors({}); setRegisterOpen(false); } catch (err) { setFormError(err instanceof Error ? err.message : String(err)); } }; const taskSelectOptions = () => TASK_GROUPS.map((group) => ( <optgroup key={group.label} label={group.label}> {group.tasks.map((task) => ( <option key={task} value={task}> {TASK_LABELS[task]} </option> ))} </optgroup> )); const [compareA, compareB] = compare; return ( <section className="flex flex-col gap-4 p-4"> {} <span data-state={isDetecting ? 'loading' : 'ready'} className="sr-only" /> {} <div className="flex flex-col gap-2"> <div className="flex flex-wrap items-end gap-2"> <label className="flex flex-col gap-1 text-xs font-medium"> <span>Task</span> <select aria-label="Task category" value={selectedTask} onChange={(e) => onTaskChange(e.target.value as TaskCategory)} className={cn(INPUT_CLASS, 'w-auto min-w-56')} > {taskSelectOptions()} </select> </label> <button type="button" aria-expanded={registerOpen} onClick={openRegisterForm} className="inline-flex h-8 items-center gap-1 rounded-md border border-border bg-background px-3 text-sm font-medium transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > {registerOpen ? ( <X className="size-3.5" aria-hidden="true" /> ) : ( <Plus className="size-3.5" aria-hidden="true" /> )} {registerOpen ? 'Close' : 'Register custom model'} </button> </div> {deviceSummary && ( <p className="text-xs text-muted-foreground"> Ranked for your device - {deviceSummary} </p> )} {registerSuccess && ( <p role="status" aria-live="polite" className="text-xs text-emerald-600 dark:text-emerald-400" > {registerSuccess} </p> )} </div> {} {error && ( <div role="alert" className="flex flex-wrap items-center gap-2 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive" > <AlertTriangle className="size-4 shrink-0" aria-hidden="true" /> <span className="min-w-0 flex-1">Could not compute recommendations: {error.message}</span> <button type="button" onClick={() => { refresh(); void refreshDevice(); }} className="inline-flex h-7 items-center gap-1 rounded-md border border-destructive/40 bg-background px-2 text-xs font-medium text-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > <RefreshCw className="size-3" aria-hidden="true" /> Retry </button> </div> )} {} {registerOpen && ( <form aria-label="Register a custom model" noValidate onSubmit={submitRegistration} className="flex flex-col gap-3 rounded-xl border border-border bg-card p-4 text-card-foreground shadow-sm" > <div className="flex items-center justify-between gap-2"> <h2 className="text-sm font-semibold">Register a custom model</h2> </div> <p className="rounded-md border border-amber-500/30 bg-amber-500/10 px-2 py-1.5 text-xs text-amber-700 dark:text-amber-300" > Custom models are registered in-memory only - they are lost on page refresh. </p> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> <Field label="Model id" name="modelId" required error={fieldErrors.modelId}> <input value={draft.modelId} onChange={(e) => setDraftField('modelId', e.target.value)} placeholder="org/my-custom-model" className={INPUT_CLASS} /> </Field> <Field label="Name" name="name" required error={fieldErrors.name}> <input value={draft.name} onChange={(e) => setDraftField('name', e.target.value)} placeholder="My Custom Model" className={INPUT_CLASS} /> </Field> <Field label="Provider" name="provider" required error={fieldErrors.provider}> <input value={draft.provider} onChange={(e) => setDraftField('provider', e.target.value)} placeholder="transformers, wllama, custom…" className={INPUT_CLASS} /> </Field> <Field label="Task" name="task" required error={fieldErrors.task}> <select value={draft.task} onChange={(e) => setDraftField('task', e.target.value as TaskCategory)} className={INPUT_CLASS} > {taskSelectOptions()} </select> </Field> <Field label="Size (MB)" name="sizeMB" required error={fieldErrors.sizeMB}> <input type="number" min={1} value={draft.sizeMB} onChange={(e) => setDraftField('sizeMB', e.target.value)} placeholder="50" className={INPUT_CLASS} /> </Field> <Field label="Min memory (MB, optional)" name="minMemoryMB" error={fieldErrors.minMemoryMB}> <input type="number" min={1} value={draft.minMemoryMB} onChange={(e) => setDraftField('minMemoryMB', e.target.value)} placeholder="512" className={INPUT_CLASS} /> </Field> <Field label="Dimensions (optional)" name="dimensions" error={fieldErrors.dimensions}> <input type="number" min={1} value={draft.dimensions} onChange={(e) => setDraftField('dimensions', e.target.value)} placeholder="384" className={INPUT_CLASS} /> </Field> <Field label="Recommended device" name="recommendedDevice" required error={fieldErrors.recommendedDevice}> <select value={draft.recommendedDevice} onChange={(e) => setDraftField('recommendedDevice', e.target.value as RegistrationDraft['recommendedDevice']) } className={INPUT_CLASS} > <option value="webgpu">webgpu</option> <option value="wasm">wasm</option> <option value="cpu">cpu</option> </select> </Field> <Field label="Speed tier" name="speedTier" required error={fieldErrors.speedTier}> <select value={draft.speedTier} onChange={(e) => setDraftField('speedTier', e.target.value as RegistrationDraft['speedTier'])} className={INPUT_CLASS} > <option value="fast">fast</option> <option value="medium">medium</option> <option value="slow">slow</option> </select> </Field> <Field label="Quality tier" name="qualityTier" required error={fieldErrors.qualityTier}> <select value={draft.qualityTier} onChange={(e) => setDraftField('qualityTier', e.target.value as RegistrationDraft['qualityTier'])} className={INPUT_CLASS} > <option value="low">low</option> <option value="medium">medium</option> <option value="high">high</option> </select> </Field> <Field label="Description (optional)" name="description" error={fieldErrors.description} className="sm:col-span-2 lg:col-span-3" > <input value={draft.description} onChange={(e) => setDraftField('description', e.target.value)} placeholder="One-line description shown on the recommendation card" className={INPUT_CLASS} /> </Field> </div> {formError && ( <p role="alert" className="text-xs text-destructive"> Registration failed: {formError} </p> )} <div className="flex items-center gap-2"> <button type="submit" className="inline-flex h-8 items-center rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > Register model </button> <button type="button" onClick={() => setRegisterOpen(false)} className="inline-flex h-8 items-center rounded-md border border-border bg-background px-3 text-sm transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > Cancel </button> </div> </form> )} {} {compare.length > 0 && ( <div className="flex flex-col gap-2"> <div className="flex items-center justify-between gap-2"> <h2 className="text-sm font-semibold">Compare models ({compare.length}/2)</h2> <button type="button" onClick={clearCompare} className="inline-flex h-7 items-center rounded-md border border-border bg-background px-2 text-xs font-medium transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > Clear comparison </button> </div> {compareA && compareB ? ( <div role="group" aria-label="Model comparison"> <ModelComparisonPanel entries={[compareA, compareB]} onClear={clearCompare} /> </div> ) : ( <p className="text-xs text-muted-foreground"> Select one more model with a card's Compare toggle to see the side-by-side comparison. </p> )} </div> )} {} <div className="flex flex-col gap-2"> {isLoading ? ( <p role="status" className="text-xs text-muted-foreground"> Detecting device capabilities… </p> ) : ( !error && ( <p className="text-sm"> <span aria-label="Recommendation count" className="font-semibold tabular-nums"> {recommendations.length} </span>{' '} of {registryTotal} registry {registryTotal === 1 ? 'model' : 'models'} fit this device for{' '} {TASK_LABELS[selectedTask]} </p> ) )} {} <ul aria-label="Ranked model recommendations" className="grid min-w-0 list-none items-start gap-4 p-0 lg:grid-cols-2" > {recommendations.map((rec) => { const comparing = compare.some((entry) => entry.modelId === rec.entry.modelId); return ( <li key={rec.entry.modelId} aria-label={rec.entry.name} data-model-id={rec.entry.modelId} data-score={String(rec.score)} className="min-w-0" > <ModelRecommendationCard className="max-w-none" comparing={comparing} onToggleCompare={toggleCompare} recommendation={{ modelId: rec.entry.modelId, name: rec.entry.name, provider: rec.entry.provider, size: rec.entry.dimensions != null ? `${formatSize(rec.entry.sizeMB)} · ${rec.entry.dimensions} dims` : formatSize(rec.entry.sizeMB), score: rec.score, recommendedDevice: rec.entry.recommendedDevice, speedTier: rec.entry.speedTier, qualityTier: rec.entry.qualityTier, ...(rec.entry.description ? { description: rec.entry.description } : {}), reasons: rec.reasons.map(humanizeReason), }} /> </li> ); })} </ul> {!isLoading && !error && recommendations.length === 0 && ( <div className="rounded-xl border border-dashed border-border bg-card px-4 py-6 text-center text-sm text-muted-foreground" > {registryTotal === 0 ? `The registry has no models for ${TASK_LABELS[selectedTask]} yet - register a custom model to see it ranked here, or try a different task.` : `No registry models fit ${TASK_LABELS[selectedTask]} on this device - try a different task or register a custom model.`} </div> )} </div> </section> );}GGUF Explorer
Search over 160,000 GGUF models on HuggingFace and peek inside any file to see its size, details, and whether it will run in your browser. Send a model straight to the chat block when you find one you like. No model download required.
Install this block
npx shadcn@latest add @localmode/ui/blocks/device/gguf-explorerInspect any GGUF
Accepts full URLs or HuggingFace shorthand - e.g. bartowski/Llama-3.2-1B-Instruct-GGUF:Llama-3.2-1B-Instruct-Q4_K_M.gguf. Inspection reads ~4 KB of the file header, never the model.
Curated catalog
30 models verified for browser inferenceTiny - under 500 MB
Small - 500 MB to 1 GB
Medium - 1 to 2 GB
Large - 2 GB and up
HuggingFace GGUF catalog
160,000+ GGUF modelsNothing is fetched from huggingface.co until you browse, type, or sort.
Inspector
Pick a curated model, select a HuggingFace file, or paste a URL to inspect its GGUF header and check whether it can run on this device.
'use client';/** * @file gguf-explorer.tsx * @description Self-sufficient block: HF GGUF search + curated wllama browse + ~4KB Range-read metadata inspection + browser-compat verdict + chat handoff, consuming the promoted `@localmode/wllama` discovery utils (searchGGUFModels/listGGUFFiles). * @constraint GGUF inspection is a user-initiated ~4KB HTTP Range metadata read — never a model download. */import { useEffect, useRef, useState } from 'react';import { checkGGUFBrowserCompatFromURL, deleteModelCache, getModelCategory, HFApiError, isModelCached, listGGUFFiles, preloadModel, searchGGUFModels, WLLAMA_MODELS, type GGUFBrowserCompat, type GGUFMetadata, type HFModelFile, type HFModelSearchResult, type HFSort, type WllamaModelEntry, type WllamaModelId,} from '@localmode/wllama';import { AlertTriangle, CloudDownload, Globe, Loader2, MessageSquare, RotateCw, X,} from 'lucide-react';import { ModelSearchBrowser, type ModelRepoFile, type ModelSearchResult, type ModelSearchSort,} from '@/components/model-search-browser';import { ModelCatalogCard, type CatalogEntry } from '@/components/model-catalog-card';import { GGUFMetadataCard } from '@/components/model-metadata-card';import { BrowserCompatCard } from '@/components/browser-compat-card';import { CapabilityGate } from '@/components/capability-gate';import { ModelDownloader } from '@/components/model-downloader';function buildChatHandoffUrl(model: string, opts?: { mmproj?: string }): string { let url = `/blocks/chat?model=${encodeURIComponent(model)}`; if (opts?.mmproj) { url += `&mmproj=${encodeURIComponent(opts.mmproj)}`; } return url;}const GIB = 1024 * 1024 * 1024;const SHORTHAND_EXAMPLE = 'bartowski/Llama-3.2-1B-Instruct-GGUF:Llama-3.2-1B-Instruct-Q4_K_M.gguf';interface CuratedModel { id: WllamaModelId; entry: WllamaModelEntry;}const ALL_CURATED: CuratedModel[] = Object.entries(WLLAMA_MODELS).map(([id, entry]) => ({ id: id as WllamaModelId, entry: entry as WllamaModelEntry,}));const GROUP_META: { category: ReturnType<typeof getModelCategory>; title: string }[] = [ { category: 'tiny', title: 'Tiny - under 500 MB' }, { category: 'small', title: 'Small - 500 MB to 1 GB' }, { category: 'medium', title: 'Medium - 1 to 2 GB' }, { category: 'large', title: 'Large - 2 GB and up' },];const CURATED_GROUPS = GROUP_META.map((group) => ({ ...group, models: ALL_CURATED.filter((m) => getModelCategory(m.entry.sizeBytes) === group.category),})).filter((group) => group.models.length > 0);function formatParams(count: number): string { if (count >= 1_000_000_000) return `${(count / 1_000_000_000).toFixed(2)}B`; if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(0)}M`; if (count >= 1_000) return `${(count / 1_000).toFixed(0)}K`; return String(count);}function formatBytes(bytes: number): string { if (bytes >= GIB) return `${(bytes / GIB).toFixed(2)} GB`; const mib = 1024 * 1024; if (bytes >= mib) return `${(bytes / mib).toFixed(0)} MB`; return `${(bytes / 1024).toFixed(0)} KB`;}function toGB(bytes: number): number { return Math.round((bytes / GIB) * 100) / 100;}function hfFileUrl(repoId: string, filename: string): string { return `https://huggingface.co/${repoId}/resolve/main/${filename}`;}function isAbortError(err: unknown): boolean { return ( (err instanceof DOMException && err.name === 'AbortError') || (err instanceof Error && err.name === 'AbortError') );}interface SearchErrorInfo { kind: 'rate-limit' | 'network' | 'not-found' | 'unknown'; message: string;}function describeHFError(err: unknown): SearchErrorInfo { if (err instanceof HFApiError) { if (err.kind === 'rate-limit') { return { kind: 'rate-limit', message: `HuggingFace rate limit reached - wait a moment and retry. (${err.message})`, }; } if (err.kind === 'not-found') { return { kind: 'not-found', message: `Not found on HuggingFace. (${err.message})` }; } return { kind: 'network', message: `Network error reaching HuggingFace - check your connection and retry. (${err.message})`, }; } return { kind: 'unknown', message: err instanceof Error ? err.message : String(err) };}function toSearchRow(r: HFModelSearchResult): ModelSearchResult { return { repoId: r.repoId, author: r.author, downloads: r.downloads, likes: r.likes, lastModified: r.lastModified, tags: r.tags, };}function toRepoFile(f: HFModelFile): ModelRepoFile { return { filename: f.filename, sizeBytes: f.sizeBytes, quantLabel: f.quantLabel };}function toCatalogEntry(id: WllamaModelId, entry: WllamaModelEntry): CatalogEntry { return { id, name: entry.name, size: entry.size, description: entry.description, architecture: entry.architecture, parameters: formatParams(entry.parameterCount), quantization: entry.quantization, contextLength: entry.contextLength, tools: entry.supportsToolCalling, vision: entry.vision, embedding: entry.isEmbeddingModel, reranking: entry.isRerankerModel, reasoning: entry.supportsReasoning, };}interface InspectTarget { input: string; label: string; source: 'curated' | 'custom' | 'huggingface'; entry?: WllamaModelEntry;}type Inspection = | { status: 'idle' } | { status: 'inspecting' } | { status: 'done'; result: GGUFBrowserCompat & { metadata: GGUFMetadata } } | { status: 'error'; message: string };type Prefetch = | { status: 'idle' } | { status: 'downloading'; phase: 'model' | 'mmproj'; loaded: number; total: number } | { status: 'done' } | { status: 'error'; message: string };export function GgufExplorerBlock() { const [hfActivated, setHfActivated] = useState(false); const [query, setQuery] = useState(''); const [sort, setSort] = useState<HFSort>('downloads'); const [results, setResults] = useState<ModelSearchResult[]>([]); const [nextCursor, setNextCursor] = useState<string | null>(null); const [isSearching, setIsSearching] = useState(false); const [searchError, setSearchError] = useState<SearchErrorInfo | null>(null); const [expandedRepoId, setExpandedRepoId] = useState<string | undefined>(undefined); const [files, setFiles] = useState<ModelRepoFile[] | undefined>(undefined); const [filesLoading, setFilesLoading] = useState(false); const [filesError, setFilesError] = useState<string | undefined>(undefined); const searchAbort = useRef<AbortController | null>(null); const searchSeq = useRef(0); const filesAbort = useRef<AbortController | null>(null); const [customInput, setCustomInput] = useState(''); const [target, setTarget] = useState<InspectTarget | null>(null); const [inspection, setInspection] = useState<Inspection>({ status: 'idle' }); const inspectAbort = useRef<AbortController | null>(null); const [tryAnyway, setTryAnyway] = useState(false); const [cached, setCached] = useState<boolean | null>(null); const [prefetch, setPrefetch] = useState<Prefetch>({ status: 'idle' }); const prefetchSeq = useRef(0); const executeSearch = async (opts: { append?: boolean; cursor?: string } = {}) => { searchAbort.current?.abort(); const controller = new AbortController(); searchAbort.current = controller; const seq = ++searchSeq.current; setIsSearching(true); setSearchError(null); if (!opts.append) { setExpandedRepoId(undefined); setFiles(undefined); setFilesError(undefined); } try { const page = await searchGGUFModels({ query: query.trim(), sort, ...(opts.cursor ? { cursor: opts.cursor } : {}), abortSignal: controller.signal, }); if (seq !== searchSeq.current || controller.signal.aborted) return; setResults((prev) => opts.append ? [...prev, ...page.results.map(toSearchRow)] : page.results.map(toSearchRow), ); setNextCursor(page.nextCursor); setIsSearching(false); } catch (err) { if (seq !== searchSeq.current || isAbortError(err)) return; setIsSearching(false); setSearchError(describeHFError(err)); } }; useEffect(() => { if (!hfActivated) return; const timer = setTimeout(() => { void executeSearch(); }, 300); return () => clearTimeout(timer); }, [query, sort, hfActivated]); useEffect(() => { return () => { searchAbort.current?.abort(); filesAbort.current?.abort(); inspectAbort.current?.abort(); prefetchSeq.current += 1; }; }, []); const handleQueryChange = (q: string) => { setQuery(q); setHfActivated(true); }; const handleSortChange = (s: ModelSearchSort) => { setSort(s); setHfActivated(true); }; const handleSelectRepo = (repoId: string | null) => { if (repoId === null || expandedRepoId === repoId) { filesAbort.current?.abort(); setExpandedRepoId(undefined); setFiles(undefined); setFilesError(undefined); setFilesLoading(false); return; } filesAbort.current?.abort(); const controller = new AbortController(); filesAbort.current = controller; setExpandedRepoId(repoId); setFiles(undefined); setFilesError(undefined); setFilesLoading(true); listGGUFFiles(repoId, { abortSignal: controller.signal }) .then((fs) => { if (controller.signal.aborted) return; setFiles(fs.map(toRepoFile)); setFilesLoading(false); }) .catch((err: unknown) => { if (controller.signal.aborted || isAbortError(err)) return; setFilesLoading(false); setFilesError(describeHFError(err).message); }); }; const handleSelectFile = (repoId: string, file: ModelRepoFile) => { inspect({ input: hfFileUrl(repoId, file.filename), label: `${repoId} · ${file.filename}`, source: 'huggingface', }); }; const inspect = (nextTarget: InspectTarget) => { inspectAbort.current?.abort(); const controller = new AbortController(); inspectAbort.current = controller; prefetchSeq.current += 1; setPrefetch({ status: 'idle' }); setTryAnyway(false); setCached(null); setTarget(nextTarget); setInspection({ status: 'inspecting' }); void (async () => { try { const result = await checkGGUFBrowserCompatFromURL(nextTarget.input, { abortSignal: controller.signal, }); if (controller.signal.aborted) return; setInspection({ status: 'done', result }); const alreadyCached = await isModelCached(nextTarget.input).catch(() => false); if (!controller.signal.aborted) setCached(alreadyCached); } catch (err) { if (controller.signal.aborted || isAbortError(err)) return; setInspection({ status: 'error', message: err instanceof Error ? err.message : String(err), }); } })(); }; const handleCuratedClick = (modelId: string) => { const curated = ALL_CURATED.find((m) => m.id === modelId); if (!curated) return; inspect({ input: curated.entry.url, label: curated.entry.name, source: 'curated', entry: curated.entry, }); }; const handleCustomInspect = () => { const trimmed = customInput.trim(); if (!trimmed) return; inspect({ input: trimmed, label: trimmed, source: 'custom' }); }; const startPrefetch = () => { if (!target || inspection.status !== 'done' || prefetch.status === 'downloading') return; const model = target; const seq = ++prefetchSeq.current; setPrefetch({ status: 'downloading', phase: 'model', loaded: 0, total: inspection.result.metadata.fileSize, }); void (async () => { try { await preloadModel(model.input, { onProgress: (p) => { if (seq !== prefetchSeq.current) return; setPrefetch({ status: 'downloading', phase: 'model', loaded: p.loaded ?? 0, total: p.total ?? 0, }); }, }); if (seq !== prefetchSeq.current) { await deleteModelCache(model.input).catch(() => {}); return; } const mmproj = model.entry?.mmprojUrl; if (mmproj) { setPrefetch({ status: 'downloading', phase: 'mmproj', loaded: 0, total: 0 }); await preloadModel(mmproj, { onProgress: (p) => { if (seq !== prefetchSeq.current) return; setPrefetch({ status: 'downloading', phase: 'mmproj', loaded: p.loaded ?? 0, total: p.total ?? 0, }); }, }); if (seq !== prefetchSeq.current) { await deleteModelCache(mmproj).catch(() => {}); return; } } setPrefetch({ status: 'done' }); setCached(true); } catch (err) { if (seq !== prefetchSeq.current) return; setPrefetch({ status: 'error', message: err instanceof Error ? err.message : String(err), }); } })(); }; const cancelPrefetch = () => { prefetchSeq.current += 1; setPrefetch({ status: 'idle' }); }; const compat = inspection.status === 'done' ? inspection.result : null; const meta = compat?.metadata ?? null; const handoffHref = target ? buildChatHandoffUrl( target.input, target.entry?.mmprojUrl ? { mmproj: target.entry.mmprojUrl } : undefined, ) : null; return ( <div className="flex flex-col gap-10 p-4"> {} <span data-state="ready" className="sr-only" /> {} <section aria-label="Inspect a custom GGUF" className="flex flex-col gap-2"> <h2 className="text-sm font-semibold">Inspect any GGUF</h2> <div className="flex flex-wrap items-center gap-2"> <input aria-label="GGUF URL or HuggingFace shorthand" value={customInput} onChange={(e) => setCustomInput(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') handleCustomInspect(); }} placeholder="https://huggingface.co/…/resolve/main/model.gguf or repo/name:file.gguf" className="h-9 min-w-72 flex-1 rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" /> <button type="button" onClick={handleCustomInspect} disabled={!customInput.trim()} className="inline-flex h-9 items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground disabled:opacity-50 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > Inspect </button> </div> <p className="text-xs text-muted-foreground"> Accepts full URLs or HuggingFace shorthand - e.g.{' '} <code className="break-all rounded bg-muted px-1 py-0.5 text-[11px] [overflow-wrap:anywhere]"> {SHORTHAND_EXAMPLE} </code> . Inspection reads ~4 KB of the file header, never the model. </p> </section> {} <section aria-label="Curated wllama model catalog" className="flex flex-col gap-4" > <div className="flex items-baseline justify-between gap-2"> <h2 className="text-sm font-semibold">Curated catalog</h2> <span className="text-xs text-muted-foreground"> {ALL_CURATED.length} models verified for browser inference </span> </div> {CURATED_GROUPS.map((group) => ( <div key={group.category} className="flex flex-col gap-2"> <h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground"> {group.title} </h3> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> {group.models.map(({ id, entry }) => ( <div key={id} data-model-id={id}> <ModelCatalogCard entry={toCatalogEntry(id, entry)} selected={target?.input === entry.url} onClick={handleCuratedClick} className="h-full max-w-none" /> </div> ))} </div> </div> ))} </section> {} <section aria-label="Browse HuggingFace GGUF models" className="flex flex-col gap-3"> <div className="flex items-baseline justify-between gap-2"> <h2 className="text-sm font-semibold">HuggingFace GGUF catalog</h2> <span className="text-xs text-muted-foreground">160,000+ GGUF models</span> </div> {!hfActivated && ( <div className="flex flex-wrap items-center gap-3"> <button type="button" onClick={() => setHfActivated(true)} className="inline-flex h-9 items-center gap-1.5 rounded-md border border-border bg-background px-4 text-sm font-medium hover:bg-accent focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > <Globe className="size-4" aria-hidden="true" /> Browse HuggingFace </button> <p className="text-xs text-muted-foreground"> Nothing is fetched from huggingface.co until you browse, type, or sort. </p> </div> )} {searchError && ( <div role="alert" data-kind={searchError.kind} className="flex flex-wrap items-center gap-2 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs" > <AlertTriangle className="size-3.5 shrink-0 text-destructive" aria-hidden="true" /> <span className="min-w-fit whitespace-nowrap rounded-full border border-destructive/40 px-1.5 py-0.5 font-medium uppercase tracking-wide text-destructive"> {searchError.kind} </span> <span className="min-w-0 flex-1 text-destructive">{searchError.message}</span> <button type="button" onClick={() => void executeSearch()} className="inline-flex h-7 items-center gap-1 rounded-md border border-border bg-background px-2 font-medium hover:bg-accent focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > <RotateCw className="size-3" aria-hidden="true" /> Retry </button> </div> )} {} {hfActivated && ( <div className="w-full"> <ModelSearchBrowser className="max-w-none" query={query} onQueryChange={handleQueryChange} sort={sort} onSortChange={handleSortChange} results={results} isLoading={isSearching} expandedRepoId={expandedRepoId} onSelectRepo={handleSelectRepo} files={files} filesLoading={filesLoading} filesError={filesError} onSelectFile={handleSelectFile} /> </div> )} {hfActivated && nextCursor && ( <button type="button" onClick={() => void executeSearch({ append: true, cursor: nextCursor })} disabled={isSearching} className="inline-flex h-8 items-center self-start rounded-md border border-border px-3 text-sm hover:bg-accent disabled:opacity-50 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > {isSearching ? 'Loading…' : 'Load more'} </button> )} </section> {} <section aria-label="GGUF inspector" className="flex flex-col gap-4"> <h2 className="text-sm font-semibold">Inspector</h2> {target === null && ( <p className="text-sm text-muted-foreground"> Pick a curated model, select a HuggingFace file, or paste a URL to inspect its GGUF header and check whether it can run on this device. </p> )} {target && ( <p className="break-all text-xs text-muted-foreground [overflow-wrap:anywhere]"> Inspecting: <span className="font-medium text-foreground">{target.label}</span> </p> )} {inspection.status === 'inspecting' && ( <p role="status" className="flex items-center gap-2 text-sm text-muted-foreground" > <Loader2 className="size-4 animate-spin" aria-hidden="true" /> Reading GGUF header (~4 KB HTTP Range request - the model file is not downloaded)… </p> )} {inspection.status === 'error' && ( <div role="alert" className="flex flex-col gap-2 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2" > {} <p className="text-xs text-destructive">{inspection.message}</p> <button type="button" onClick={() => target && inspect(target)} className="inline-flex h-7 items-center gap-1 self-start rounded-md border border-border bg-background px-2 text-xs font-medium hover:bg-accent focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > <RotateCw className="size-3" aria-hidden="true" /> Retry </button> </div> )} {compat && meta && target && ( <div className="flex flex-col gap-4"> <div className="grid items-start gap-4 lg:grid-cols-2"> {} <div role="group" aria-label="Model metadata" className="flex flex-col gap-3" > <GGUFMetadataCard className="max-w-none" metadata={{ architecture: meta.architecture, parameters: formatParams(meta.parameterCount), quantization: meta.quantization, contextLength: meta.contextLength, embeddingDimension: meta.embeddingLength, vocabSize: meta.vocabSize, headCount: meta.headCount, layerCount: meta.layerCount, fileSizeBytes: meta.fileSize, author: meta.author, license: meta.license, }} /> {} {(meta.modelName || meta.description || target.entry?.dimensions != null || target.entry?.nGpuLayers != null || target.entry?.mmprojUrl) && ( <div className="rounded-xl border border-border bg-card p-4 text-card-foreground shadow-sm"> <p className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground"> Additional details </p> <dl className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1.5 text-xs"> {meta.modelName && ( <> <dt className="text-muted-foreground">Name</dt> <dd className="break-words [overflow-wrap:anywhere]"> {meta.modelName} </dd> </> )} {meta.description && ( <> <dt className="text-muted-foreground">Description</dt> <dd className="break-words [overflow-wrap:anywhere]"> {meta.description} </dd> </> )} {target.entry?.dimensions != null && ( <> <dt className="text-muted-foreground">Embedding dims</dt> <dd>{target.entry.dimensions}</dd> </> )} {target.entry?.nGpuLayers != null && ( <> <dt className="text-muted-foreground">GPU layers</dt> <dd>{target.entry.nGpuLayers}</dd> </> )} {target.entry?.mmprojUrl && ( <> <dt className="text-muted-foreground">Vision projector</dt> <dd>mmproj available - included in the chat handoff</dd> </> )} </dl> </div> )} </div> {} <div role="group" aria-label="Device compatibility" data-can-run={String(compat.canRun)} className="flex flex-col gap-3" > <BrowserCompatCard className="max-w-none" modelName={meta.modelName ?? target.label} requiredGB={toGB(compat.estimatedRAM)} deviceGB={toGB(compat.deviceRAM ?? 4 * GIB)} availableStorageGB={ compat.availableStorage != null ? toGB(compat.availableStorage) : undefined } crossOriginIsolated={compat.hasCORS} estimatedSpeed={compat.estimatedSpeed} warnings={compat.warnings} canRun={compat.canRun} /> {} {compat.recommendations.length > 0 && ( <div className="rounded-xl border border-border bg-card p-4 text-card-foreground shadow-sm"> <p className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground"> Recommendations </p> <ul className="flex list-disc flex-col gap-1 pl-4 text-xs text-muted-foreground" > {compat.recommendations.map((r, i) => ( <li key={i}>{r}</li> ))} </ul> </div> )} </div> </div> {} <div className="flex flex-col gap-3 rounded-xl border border-border bg-card p-4 text-card-foreground shadow-sm"> <p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> Use this model </p> <CapabilityGate requires="wasm" fallback={ <p className="text-xs text-muted-foreground"> Running GGUF models requires WebAssembly, which this browser does not support - the chat handoff is unavailable. </p> } > {compat.canRun || tryAnyway ? ( <div className="flex flex-wrap items-center gap-3"> <a href={handoffHref ?? '#'} className="inline-flex h-9 items-center gap-1.5 rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > <MessageSquare className="size-4" aria-hidden="true" /> Chat with this model </a> {!compat.canRun && tryAnyway && ( <span className="text-xs text-amber-600 dark:text-amber-400"> Compatibility warning overridden - inference may fail or be slow. </span> )} </div> ) : ( <div className="flex flex-col gap-2 rounded-md border border-amber-500/40 bg-amber-500/5 px-3 py-2"> <p className="flex items-start gap-1.5 text-xs text-amber-600 dark:text-amber-400"> <AlertTriangle className="mt-0.5 size-3.5 shrink-0" aria-hidden="true" /> This model is not recommended on this device - see the warnings above. You can still try it. </p> <button type="button" onClick={() => setTryAnyway(true)} className="inline-flex h-8 items-center self-start rounded-md border border-border bg-background px-3 text-xs font-medium hover:bg-accent focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > Try anyway </button> </div> )} </CapabilityGate> <div className="flex flex-col gap-2 border-t border-border pt-3"> <div className="flex flex-wrap items-center gap-3"> {(prefetch.status === 'idle' || prefetch.status === 'error') && ( <button type="button" onClick={startPrefetch} className="inline-flex h-8 items-center gap-1.5 rounded-md border border-border bg-background px-3 text-xs font-medium hover:bg-accent focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > <CloudDownload className="size-3.5" aria-hidden="true" /> Download to cache </button> )} {cached === true && prefetch.status !== 'downloading' && ( <span className="text-xs text-emerald-600 dark:text-emerald-400"> Already in the wllama cache - chat opens instantly. </span> )} {prefetch.status === 'downloading' && ( <button type="button" onClick={cancelPrefetch} className="inline-flex h-8 items-center gap-1 rounded-md border border-border bg-background px-3 text-xs font-medium hover:bg-accent focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > <X className="size-3.5" aria-hidden="true" /> Cancel </button> )} </div> {prefetch.status === 'error' && ( <p className="text-xs text-destructive">{prefetch.message}</p> )} {(prefetch.status === 'downloading' || prefetch.status === 'done') && ( <div> <ModelDownloader className="max-w-none" name={ prefetch.status === 'downloading' && prefetch.phase === 'mmproj' ? `${target.label} - vision projector (mmproj)` : target.label } size={target.entry?.size ?? formatBytes(meta.fileSize)} contextLength={meta.contextLength || undefined} category={target.entry?.vision ? 'Vision' : meta.architecture} progress={ prefetch.status === 'done' ? 1 : { loaded: prefetch.loaded, total: prefetch.total } } ready={prefetch.status === 'done'} /> </div> )} <p className="text-xs text-muted-foreground"> Optional: downloads the model into wllama's browser cache so chat starts instantly. Never automatic. </p> </div> </div> </div> )} </section> </div> );}