SPB Git forge

spb/llm-api

Public
0commits 0branches 0releases
0 Bsize
maindefault branch
—last push
11.0 KB · 160 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 { fmtAgo, fmtBytes, fmtGB, fmtParams } from "@/lib/format";7import type { Candidate, Job } from "@/lib/types";8import { CompatPill, Modal, PageHeader, Pill, Progress, SizePill, Spinner, useToast } from "@/components/ui";910const TASKS = ["", "general", "coding", "reasoning", "vision", "embedding", "reranker"];11const SIZES = ["", "TINY", "SMALL", "MEDIUM", "LARGE", "XL"];1213export default function HarvesterPage() {14  const live = useLive();15  const toast = useToast();16  const [rows, setRows] = useState<Candidate[]>([]);17  const [starter, setStarter] = useState<{ slot: string; candidate: Candidate | null }[]>([]);18  const [lastScan, setLastScan] = useState<number | null>(null);19  const [task, setTask] = useState("");20  const [rt, setRt] = useState("");21  const [size, setSize] = useState("");22  const [q, setQ] = useState("");23  const [dups, setDups] = useState(false);24  const [opts, setOpts] = useState(false);25  const [scanOpts, setScanOpts] = useState({ runtimes: ["mlx", "gguf"], min_downloads: 500, limit_per_author: 150, max_ram_gb: "", search: "" });26  const [busy, setBusy] = useState(false);2728  const scanJob = Object.values(live.jobs).find((j) => j.kind === "harvest" && (j.status === "running" || j.status === "queued"));2930  const load = () => {31    const p = new URLSearchParams();32    if (task) p.set("task", task);33    if (rt) p.set("runtime", rt);34    if (size) p.set("size_class", size);35    if (q) p.set("q", q);36    if (dups) p.set("include_duplicates", "true");37    api.get<{ candidates: Candidate[]; last_scan: number | null; starter: { slot: string; candidate: Candidate | null }[] }>(`/api/harvest/candidates?${p}`)38      .then((r) => { setRows(r.candidates); setLastScan(r.last_scan); setStarter(r.starter); }).catch(() => {});39  };40  useEffect(() => { load(); }, [task, rt, size, q, dups, live.version]); // eslint-disable-line react-hooks/exhaustive-deps4142  const scan = async () => {43    setBusy(true);44    try {45      await api.post("/api/harvest/scan", { ...scanOpts, max_ram_gb: scanOpts.max_ram_gb ? Number(scanOpts.max_ram_gb) : null, search: scanOpts.search || null });46      toast.push("Harvest started — this takes a minute or two", "good");47      setOpts(false);48    } catch (e) {49      toast.push(e instanceof ApiError ? e.message : "Failed", "bad");50    } finally {51      setBusy(false);52    }53  };54  const toggle = async (c: Candidate) => {55    await api.post("/api/harvest/select", { repo_id: c.repo_id, selected: !c.selected });56    load();57  };58  const dismiss = async (c: Candidate) => { await api.post("/api/harvest/dismiss", { repo_id: c.repo_id }); load(); };59  const queue = async () => {60    setBusy(true);61    try {62      const r = await api.post<{ queued: { repo: string; job?: string; error?: string }[] }>("/api/harvest/queue");63      const ok = r.queued.filter((x) => x.job).length;64      const bad = r.queued.filter((x) => x.error);65      toast.push(`${ok} download${ok === 1 ? "" : "s"} queued${bad.length ? ` · ${bad.length} failed: ${bad[0].error}` : ""}`, bad.length ? "bad" : "good");66      load();67    } finally {68      setBusy(false);69    }70  };71  const selected = rows.filter((r) => r.selected);72  const selectedBytes = selected.reduce((a, r) => a + (r.download_bytes || 0), 0);73  const families = useMemo(() => [...new Set(rows.map((r) => r.family))].sort(), [rows]);7475  return (76    <div>77      {toast.view}78      <PageHeader title="Model Harvester" sub={<>Explores Hugging Face (mlx-community, unsloth, bartowski, ggml-org…), keeps only models that fit this Mac, removes duplicate quantizations and proposes a download queue. {lastScan ? <>Last scan {fmtAgo(lastScan)}.</> : "No scan yet."}</>}79        actions={<>80          <button className="btn" onClick={() => setOpts(true)}>Options</button>81          <button className="btn btn-primary" onClick={scan} disabled={busy || !!scanJob}>{scanJob ? <><Spinner /> Scanning…</> : "Scan Hugging Face"}</button>82        </>} />8384      {scanJob && <div className="card p-4 mb-4"><div className="text-sm mb-2">{String((scanJob.detail as { stage?: string }).stage || "starting")}</div><Progress value={scanJob.progress} /></div>}8586      {starter.some((s) => s.candidate) && (87        <div className="card p-4 mb-4">88          <div className="flex items-center justify-between mb-2"><div className="font-medium text-sm">Suggested starter library</div><span className="text-xs text-ink-3">best candidate per slot — tick the ones you want</span></div>89          <div className="grid sm:grid-cols-2 lg:grid-cols-5 gap-2">90            {starter.map((s) => (91              <div key={s.slot} className={`rounded-lg border p-2.5 text-xs ${s.candidate ? "border-border bg-surface-2" : "border-dashed border-border text-ink-3"}`}>92                <div className="label mb-1">{s.slot}</div>93                {s.candidate ? (94                  <label className="flex items-start gap-2 cursor-pointer">95                    <input type="checkbox" className="mt-0.5" checked={!!s.candidate.selected} onChange={() => toggle(s.candidate!)} disabled={!!s.candidate.installed} />96                    <span className="min-w-0"><span className="block truncate font-medium text-ink" title={s.candidate.repo_id}>{s.candidate.name}</span><span className="text-ink-3 num">{fmtGB(s.candidate.estimated_ram_gb)} · {s.candidate.quantization}{s.candidate.installed ? " · installed" : ""}</span></span>97                  </label>98                ) : "no candidate"}99              </div>100            ))}101          </div>102        </div>103      )}104105      <div className="flex flex-wrap gap-2 items-center mb-3">106        <input className="input max-w-xs" placeholder="Search…" value={q} onChange={(e) => setQ(e.target.value)} />107        <select className="input w-auto" value={task} onChange={(e) => setTask(e.target.value)}>{TASKS.map((t) => <option key={t} value={t}>{t || "All tasks"}</option>)}</select>108        <select className="input w-auto" value={rt} onChange={(e) => setRt(e.target.value)}><option value="">MLX + GGUF</option><option value="mlx">MLX</option><option value="llamacpp">GGUF</option></select>109        <select className="input w-auto" value={size} onChange={(e) => setSize(e.target.value)}>{SIZES.map((s) => <option key={s} value={s}>{s || "All sizes"}</option>)}</select>110        <label className="text-xs text-ink-2 flex items-center gap-1.5"><input type="checkbox" checked={dups} onChange={(e) => setDups(e.target.checked)} /> show duplicate quantizations</label>111        <div className="ml-auto flex items-center gap-2 text-xs text-ink-2 num">112          {selected.length > 0 && <span>{selected.length} selected · {fmtBytes(selectedBytes)}</span>}113          <button className="btn btn-primary btn-sm" disabled={!selected.length || busy} onClick={queue}>Queue downloads</button>114        </div>115      </div>116117      <div className="card overflow-x-auto">118        <table className="tbl">119          <thead><tr><th></th><th>Model</th><th>Task</th><th>Runtime</th><th>Quant</th><th className="text-right">Params</th><th className="text-right">Download</th><th className="text-right">Est. RAM</th><th>Compat</th><th className="text-right">Downloads</th><th className="text-right">Score</th><th></th></tr></thead>120          <tbody>121            {rows.map((c) => (122              <tr key={c.repo_id} className={c.installed ? "opacity-60" : ""}>123                <td><input type="checkbox" checked={!!c.selected} disabled={!!c.installed} onChange={() => toggle(c)} /></td>124                <td><a className="font-medium hover:underline" href={`https://huggingface.co/${c.repo_id}`} target="_blank" rel="noreferrer">{c.name}</a><div className="text-xs text-ink-3 truncate max-w-[300px]">{c.repo_id}{c.duplicate_of ? ` · duplicate of ${c.duplicate_of.split("/")[1]}` : ""}{c.installed ? " · installed" : ""}</div></td>125                <td><Pill tone={c.task === "coding" ? "accent" : c.task === "reasoning" ? "violet" : c.task === "vision" ? "warn" : "neutral"}>{c.task}</Pill></td>126                <td className="text-ink-2">{c.runtime === "mlx" ? "MLX" : "GGUF"}</td>127                <td className="mono text-xs">{c.quantization || "—"}</td>128                <td className="text-right num">{fmtParams(c.parameter_count)}</td>129                <td className="text-right num">{fmtBytes(c.download_bytes)}</td>130                <td className="text-right num">{fmtGB(c.estimated_ram_gb)} <SizePill cls={c.size_class} /></td>131                <td><CompatPill status={c.compatibility_status} reason={c.compatibility_reason} /></td>132                <td className="text-right num text-ink-2">{c.downloads.toLocaleString()}</td>133                <td className="text-right num">{c.score.toFixed(0)}</td>134                <td><button className="btn btn-ghost btn-sm" title="Hide" onClick={() => dismiss(c)}>✕</button></td>135              </tr>136            ))}137            {!rows.length && <tr><td colSpan={12} className="text-center text-ink-3 py-10">{lastScan ? "No candidates match." : "Run a scan to discover compatible models."}</td></tr>}138          </tbody>139        </table>140      </div>141      {families.length > 0 && <div className="text-xs text-ink-3 mt-2">Families: {families.join(", ")}</div>}142143      <Modal open={opts} onClose={() => setOpts(false)} title="Harvest options">144        <div className="flex flex-col gap-3 text-sm">145          <div className="flex gap-4">146            {["mlx", "gguf"].map((r) => <label key={r} className="flex items-center gap-2"><input type="checkbox" checked={scanOpts.runtimes.includes(r)} onChange={(e) => setScanOpts({ ...scanOpts, runtimes: e.target.checked ? [...scanOpts.runtimes, r] : scanOpts.runtimes.filter((x) => x !== r) })} /> {r.toUpperCase()}</label>)}147          </div>148          <label className="flex flex-col gap-1"><span className="label">Minimum downloads</span><input className="input" type="number" value={scanOpts.min_downloads} onChange={(e) => setScanOpts({ ...scanOpts, min_downloads: Number(e.target.value) })} /></label>149          <label className="flex flex-col gap-1"><span className="label">Models listed per author</span><input className="input" type="number" value={scanOpts.limit_per_author} onChange={(e) => setScanOpts({ ...scanOpts, limit_per_author: Number(e.target.value) })} /></label>150          <label className="flex flex-col gap-1"><span className="label">Max RAM (GB, blank = safe budget)</span><input className="input" value={scanOpts.max_ram_gb} onChange={(e) => setScanOpts({ ...scanOpts, max_ram_gb: e.target.value })} /></label>151          <label className="flex flex-col gap-1"><span className="label">Search term (optional)</span><input className="input" value={scanOpts.search} onChange={(e) => setScanOpts({ ...scanOpts, search: e.target.value })} placeholder="qwen, gemma, coder…" /></label>152          <div className="flex justify-end gap-2 mt-2"><button className="btn" onClick={() => setOpts(false)}>Cancel</button><button className="btn btn-primary" onClick={scan} disabled={busy}>Scan</button></div>153        </div>154      </Modal>155    </div>156  );157}158159export type { Job };160