"use client"; import { useEffect, useMemo, useState } from "react"; import { api, ApiError } from "@/lib/api"; import { useLive } from "@/lib/events"; import { fmtBytes, fmtCtx, fmtDate, fmtDuration, fmtGB, fmtParams, fmtSpeed } from "@/lib/format"; import type { Job } from "@/lib/types"; import { CompatPill, KV, PageHeader, Pill, Progress, SizePill, Spinner, useToast } from "@/components/ui"; interface Inspect { repository: string; runtime: string; format: string; files: { path: string; size: number }[]; all_files: { path: string; size: number }[]; download_bytes: number; weights_bytes: number; quantization: string | null; parameter_count: number | null; model_type: string | null; pipeline_tag: string | null; vision: boolean; embedding: boolean; reranker: boolean; max_context: number | null; compatibility: { status: string; reason: string; estimated_ram_gb: number | null; recommended_context: number | null }; size_class: string; disk: { free_gb: number; free_after_gb: number; min_free_gb: number; ok: boolean }; target_dir: string; already_installed: string | null; downloads: number | null; likes: number | null; gated: boolean; } export default function DownloadsPage() { const live = useLive(); const toast = useToast(); const [repo, setRepo] = useState(""); const [quant, setQuant] = useState(""); const [insp, setInsp] = useState(null); const [busy, setBusy] = useState(false); const [history, setHistory] = useState([]); useEffect(() => { api.get<{ downloads: Job[] }>("/api/downloads").then((r) => setHistory(r.downloads)).catch(() => {}); }, [live.version]); const jobs = useMemo(() => { const map = new Map(); for (const j of history) map.set(j.id, j); for (const j of Object.values(live.jobs)) if (j.kind === "download") map.set(j.id, j); return [...map.values()].sort((a, b) => b.created_at - a.created_at); }, [history, live.jobs]); const active = jobs.filter((j) => j.status === "running" || j.status === "queued"); const done = jobs.filter((j) => j.status !== "running" && j.status !== "queued"); const inspect = async () => { if (!repo.trim()) return; setBusy(true); setInsp(null); try { setInsp(await api.post("/api/models/inspect", { repository: repo.trim(), quant: quant || null })); } catch (e) { toast.push(e instanceof ApiError ? e.message : "Inspect failed", "bad"); } finally { setBusy(false); } }; const download = async (force = false) => { if (!insp) return; setBusy(true); try { await api.post("/api/models/download", { repository: insp.repository, quant: quant || null, force }); toast.push(`Download started: ${insp.repository}`, "good"); setInsp(null); setRepo(""); } catch (e) { toast.push(e instanceof ApiError ? e.message : "Download failed", "bad"); } finally { setBusy(false); } }; const ggufQuants = insp ? [...new Set(insp.all_files.filter((f) => f.path.toLowerCase().endsWith(".gguf") && !/mmproj/i.test(f.path)).map((f) => f.path.replace(/-\d{5}-of-\d{5}/, "").replace(/\.gguf$/i, "").split(/[-_.]/).slice(-2).join("_")))] : []; return (
{toast.view}
{ e.preventDefault(); inspect(); }}> setRepo(e.target.value)} /> setQuant(e.target.value)} title="Preferred GGUF quantization, e.g. Q4_K_M" />
{insp && (
{insp.repository} {insp.runtime === "mlx" ? "MLX" : "GGUF · llama.cpp"} {insp.gated && gated} {insp.already_installed && installed as {insp.already_installed}}
{fmtGB(insp.compatibility.estimated_ram_gb)} at {fmtCtx(insp.compatibility.recommended_context)} context} /> {insp.disk.free_after_gb} GB (reserve {insp.disk.min_free_gb} GB)} /> {insp.downloads?.toLocaleString() ?? "—"} downloads · {insp.likes ?? "—"} likes} />
{insp.compatibility.reason}
{ggufQuants.length > 1 &&
Available GGUF quantizations: {ggufQuants.join(", ")} — set one in the quant field and inspect again.
}
{(insp.compatibility.status === "incompatible" || insp.already_installed) && }
Files to download ({insp.files.length})
{insp.files.map((f) => )}
{f.path}{fmtBytes(f.size)}
)}

Queue

{active.map((j) => api.post(`/api/jobs/${j.id}/cancel`)} />)} {!active.length &&
No active downloads.
}

History

{done.slice(0, 30).map((j) => (
{j.status}
{String(j.payload.repository)}
{fmtDate(j.created_at)} · {fmtBytes(Number(j.payload.download_bytes))}{j.error ? ` · ${j.error}` : ""}
{j.status === "failed" && }
))} {!done.length &&
Nothing yet.
}
); } export function JobRow({ j, onCancel }: { j: Job; onCancel?: () => void }) { const d = j.detail as { downloaded?: number; total?: number; speed_bps?: number; eta_seconds?: number; current_file?: string; stage?: string; file_index?: number; file_count?: number }; return (
{j.title}
{d.stage || d.current_file || j.status}{d.file_index ? ` · file ${d.file_index}/${d.file_count}` : ""}
{d.downloaded != null ? `${fmtBytes(d.downloaded)} / ${fmtBytes(d.total)}` : `${Math.round(j.progress * 100)}%`}
{d.speed_bps ? fmtSpeed(d.speed_bps) : ""}{d.eta_seconds ? ` · ETA ${fmtDuration(d.eta_seconds)}` : ""}
{onCancel && }
); }