SPB Git forge

spb/llm-api

Public
0commits 0branches 0releases
0 Bsize
maindefault branch
—last push
9.3 KB · 150 lines tsx
Raw Blame History
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