1"use client";23import { useEffect, useMemo, useState } from "react";4import { api, ApiError } from "@/lib/api";5import { useLive } from "@/lib/events";6import { fmtBytes, fmtCtx, fmtDate, fmtDuration, fmtGB, fmtParams, fmtSpeed } from "@/lib/format";7import type { Job } from "@/lib/types";8import { CompatPill, KV, PageHeader, Pill, Progress, SizePill, Spinner, useToast } from "@/components/ui";910interface Inspect {11 repository: string; runtime: string; format: string; files: { path: string; size: number }[]; all_files: { path: string; size: number }[];12 download_bytes: number; weights_bytes: number; quantization: string | null; parameter_count: number | null; model_type: string | null;13 pipeline_tag: string | null; vision: boolean; embedding: boolean; reranker: boolean; max_context: number | null;14 compatibility: { status: string; reason: string; estimated_ram_gb: number | null; recommended_context: number | null }; size_class: string;15 disk: { free_gb: number; free_after_gb: number; min_free_gb: number; ok: boolean }; target_dir: string; already_installed: string | null;16 downloads: number | null; likes: number | null; gated: boolean;17}1819export default function DownloadsPage() {20 const live = useLive();21 const toast = useToast();22 const [repo, setRepo] = useState("");23 const [quant, setQuant] = useState("");24 const [insp, setInsp] = useState<Inspect | null>(null);25 const [busy, setBusy] = useState(false);26 const [history, setHistory] = useState<Job[]>([]);2728 useEffect(() => { api.get<{ downloads: Job[] }>("/api/downloads").then((r) => setHistory(r.downloads)).catch(() => {}); }, [live.version]);2930 const jobs = useMemo(() => {31 const map = new Map<string, Job>();32 for (const j of history) map.set(j.id, j);33 for (const j of Object.values(live.jobs)) if (j.kind === "download") map.set(j.id, j);34 return [...map.values()].sort((a, b) => b.created_at - a.created_at);35 }, [history, live.jobs]);36 const active = jobs.filter((j) => j.status === "running" || j.status === "queued");37 const done = jobs.filter((j) => j.status !== "running" && j.status !== "queued");3839 const inspect = async () => {40 if (!repo.trim()) return;41 setBusy(true);42 setInsp(null);43 try {44 setInsp(await api.post<Inspect>("/api/models/inspect", { repository: repo.trim(), quant: quant || null }));45 } catch (e) {46 toast.push(e instanceof ApiError ? e.message : "Inspect failed", "bad");47 } finally {48 setBusy(false);49 }50 };51 const download = async (force = false) => {52 if (!insp) return;53 setBusy(true);54 try {55 await api.post("/api/models/download", { repository: insp.repository, quant: quant || null, force });56 toast.push(`Download started: ${insp.repository}`, "good");57 setInsp(null);58 setRepo("");59 } catch (e) {60 toast.push(e instanceof ApiError ? e.message : "Download failed", "bad");61 } finally {62 setBusy(false);63 }64 };65 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("_")))] : [];6667 return (68 <div>69 {toast.view}70 <PageHeader title="Downloads" sub="Add models from Hugging Face. Every download is inspected for size, RAM and compatibility first." />71 <div className="card p-4 mb-5">72 <form className="flex flex-col sm:flex-row gap-2" onSubmit={(e) => { e.preventDefault(); inspect(); }}>73 <input className="input flex-1" placeholder="mlx-community/Qwen3-4B-Instruct-2507-4bit or unsloth/Qwen3.6-35B-A3B-GGUF or a huggingface.co URL" value={repo} onChange={(e) => setRepo(e.target.value)} />74 <input className="input sm:w-36" placeholder="quant (GGUF)" value={quant} onChange={(e) => setQuant(e.target.value)} title="Preferred GGUF quantization, e.g. Q4_K_M" />75 <button className="btn btn-primary" disabled={busy || !repo.trim()}>{busy && !insp ? <Spinner /> : "Inspect"}</button>76 </form>77 {insp && (78 <div className="mt-4 grid md:grid-cols-2 gap-4">79 <div>80 <div className="flex items-center gap-2 flex-wrap mb-2">81 <span className="font-medium">{insp.repository}</span>82 <Pill tone="accent">{insp.runtime === "mlx" ? "MLX" : "GGUF · llama.cpp"}</Pill>83 <CompatPill status={insp.compatibility.status} reason={insp.compatibility.reason} />84 <SizePill cls={insp.size_class} />85 {insp.gated && <Pill tone="warn">gated</Pill>}86 {insp.already_installed && <Pill tone="good">installed as {insp.already_installed}</Pill>}87 </div>88 <KV k="Download size" v={fmtBytes(insp.download_bytes)} />89 <KV k="Expected RAM" v={<>{fmtGB(insp.compatibility.estimated_ram_gb)} at {fmtCtx(insp.compatibility.recommended_context)} context</>} />90 <KV k="Parameters" v={fmtParams(insp.parameter_count)} />91 <KV k="Quantization" v={insp.quantization} />92 <KV k="Architecture" v={insp.model_type} />93 <KV k="Type" v={insp.embedding ? "embedding" : insp.reranker ? "reranker" : insp.vision ? "vision-language" : "text generation"} />94 <KV k="Max context" v={fmtCtx(insp.max_context)} />95 <KV k="Free disk after" v={<span className={insp.disk.ok ? "" : "text-bad"}>{insp.disk.free_after_gb} GB (reserve {insp.disk.min_free_gb} GB)</span>} />96 <KV k="Target" v={insp.target_dir} mono />97 <KV k="Popularity" v={<>{insp.downloads?.toLocaleString() ?? "—"} downloads · {insp.likes ?? "—"} likes</>} />98 <div className="text-xs text-ink-3 mt-2">{insp.compatibility.reason}</div>99 {ggufQuants.length > 1 && <div className="text-xs text-ink-3 mt-1">Available GGUF quantizations: {ggufQuants.join(", ")} — set one in the quant field and inspect again.</div>}100 <div className="flex gap-2 mt-4">101 <button className="btn btn-primary" disabled={busy || !insp.disk.ok || insp.compatibility.status === "incompatible" || !!insp.already_installed} onClick={() => download(false)}>{busy ? <Spinner /> : `Download ${fmtBytes(insp.download_bytes)}`}</button>102 {(insp.compatibility.status === "incompatible" || insp.already_installed) && <button className="btn" disabled={busy || !insp.disk.ok} onClick={() => download(true)}>Force</button>}103 </div>104 </div>105 <div className="card bg-bg p-3 max-h-72 overflow-auto">106 <div className="label mb-2">Files to download ({insp.files.length})</div>107 <table className="tbl text-xs"><tbody>{insp.files.map((f) => <tr key={f.path}><td className="mono">{f.path}</td><td className="text-right num">{fmtBytes(f.size)}</td></tr>)}</tbody></table>108 </div>109 </div>110 )}111 </div>112113 <h2 className="text-sm font-medium mb-2">Queue</h2>114 <div className="flex flex-col gap-2 mb-6">115 {active.map((j) => <JobRow key={j.id} j={j} onCancel={() => api.post(`/api/jobs/${j.id}/cancel`)} />)}116 {!active.length && <div className="card p-5 text-sm text-ink-3 text-center">No active downloads.</div>}117 </div>118 <h2 className="text-sm font-medium mb-2">History</h2>119 <div className="card divide-y divide-border">120 {done.slice(0, 30).map((j) => (121 <div key={j.id} className="px-4 py-2.5 flex items-center gap-3 text-sm">122 <Pill tone={j.status === "completed" ? "good" : j.status === "failed" ? "bad" : "neutral"}>{j.status}</Pill>123 <div className="min-w-0 flex-1"><div className="truncate">{String(j.payload.repository)}</div><div className="text-xs text-ink-3">{fmtDate(j.created_at)} · {fmtBytes(Number(j.payload.download_bytes))}{j.error ? ` · ${j.error}` : ""}</div></div>124 {j.status === "failed" && <button className="btn btn-sm" onClick={() => api.post(`/api/downloads/${j.id}/retry`).then(() => toast.push("Retrying", "good")).catch((e) => toast.push(e.message, "bad"))}>Retry</button>}125 </div>126 ))}127 {!done.length && <div className="p-5 text-sm text-ink-3 text-center">Nothing yet.</div>}128 </div>129 </div>130 );131}132133export function JobRow({ j, onCancel }: { j: Job; onCancel?: () => void }) {134 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 };135 return (136 <div className="card p-4">137 <div className="flex items-center justify-between gap-3 mb-2">138 <div className="min-w-0"><div className="font-medium truncate">{j.title}</div>139 <div className="text-xs text-ink-3 truncate">{d.stage || d.current_file || j.status}{d.file_index ? ` · file ${d.file_index}/${d.file_count}` : ""}</div></div>140 <div className="text-right text-xs num shrink-0">141 <div>{d.downloaded != null ? `${fmtBytes(d.downloaded)} / ${fmtBytes(d.total)}` : `${Math.round(j.progress * 100)}%`}</div>142 <div className="text-ink-3">{d.speed_bps ? fmtSpeed(d.speed_bps) : ""}{d.eta_seconds ? ` · ETA ${fmtDuration(d.eta_seconds)}` : ""}</div>143 </div>144 {onCancel && <button className="btn btn-sm" onClick={onCancel}>Cancel</button>}145 </div>146 <Progress value={j.progress} />147 </div>148 );149}150