import { useEffect, useState } from "react" import { Download, Search, AlertTriangle, ChevronDown, ChevronUp, Heart, Check, Flame } from "lucide-react" import { api, type FitResp } from "@/lib/api" import { useModels } from "@/lib/queries" import { cn } from "@/lib/utils" type Hit = { repo: string; downloads: number; likes: number } const CHIPS: { key: string; label: string; q: string }[] = [ { key: "popular", label: "Beliebt", q: "" }, { key: "coder", label: "Coder", q: "coder" }, { key: "vision", label: "Vision", q: "vision" }, { key: "reasoning", label: "Reasoning", q: "reasoning" }, { key: "small", label: "Klein (≤4B)", q: "3B" }, ] const ROLE_OPTS = ["fast", "heavy", "coder", "vision", "scout"] function fmtN(n: number): string { if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M` if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k` return String(n) } export function ModelBrowse() { const { data: modelsData } = useModels() const installed = modelsData?.models ?? [] const [q, setQ] = useState("") const [results, setResults] = useState([]) const [loading, setLoading] = useState(false) const [msg, setMsg] = useState("") const [chip, setChip] = useState("popular") const [direct, setDirect] = useState("") // Pro aufgeklapptem Treffer: Quant/Rolle/Fit/Install-Zustand. const [open, setOpen] = useState(null) const [quants, setQuants] = useState([]) const [quant, setQuant] = useState("Q4_K_M") const [role, setRole] = useState("") const [fit, setFit] = useState(null) const [oomArmed, setOomArmed] = useState(false) const [installing, setInstalling] = useState>({}) async function runSearch(query: string) { setLoading(true) setMsg("") try { const d = await api<{ results: Hit[] }>(`/api/hf/search?q=${encodeURIComponent(query)}`) setResults(d.results) if (!d.results.length) setMsg("Keine Ergebnisse.") } catch (e) { setMsg(`Suche fehlgeschlagen: ${e}`) } finally { setLoading(false) } } // Beim Öffnen: Trending laden, damit man sofort stöbern kann (ohne zu tippen). useEffect(() => { runSearch("") }, []) function pickChip(c: typeof CHIPS[number]) { setChip(c.key) setQ("") runSearch(c.q) } function submitSearch() { setChip("") runSearch(q) } async function refreshFit(repo: string, qq: string, rl: string) { setOomArmed(false) try { const d = await api( `/api/fit?params_b=0&quant=${encodeURIComponent(qq)}&ctx=8192&name=${encodeURIComponent(repo)}&role=${encodeURIComponent(rl)}` ) setFit(d) } catch { setFit(null) } } async function toggleExpand(repo: string) { if (open === repo) { setOpen(null); return } setOpen(repo) setQuants([]); setFit(null); setRole(""); setOomArmed(false) setMsg("Analysiere Repository…") try { const d = await api<{ repo: string; quants: string[] }>(`/api/hf/quants?repo=${encodeURIComponent(repo)}`) setQuants(d.quants) const pick = d.quants.includes("Q4_K_M") ? "Q4_K_M" : (d.quants[0] || "Q4_K_M") setQuant(pick) setMsg(d.quants.length ? "" : "Keine GGUF-Dateien in diesem Repo gefunden.") if (d.quants.length) refreshFit(d.repo, pick, "") } catch (e) { setMsg(`Fehler: ${e}`) } } function loadDirect() { const r = direct.trim() if (!r) return // Treffer-Liste mit dem Direkt-Repo vorn ergänzen + aufklappen. setResults((rs) => rs.some((x) => x.repo === r) ? rs : [{ repo: r, downloads: 0, likes: 0 }, ...rs]) setDirect("") toggleExpand(r) } // Welches Modell hält die gewählte Rolle? (exklusiv → würde übernommen) const roleHolder = role ? installed.find((m) => (m.role || "").toLowerCase() === role) : undefined async function install(repo: string) { if (fit?.fit.level === "too_tight" && !oomArmed) { setOomArmed(true); return } setInstalling((s) => ({ ...s, [repo]: "Starte…" })) try { await api("/api/models/install", { method: "POST", body: JSON.stringify({ repo, quant, role: role || undefined, jinja: true }), }) setInstalling((s) => ({ ...s, [repo]: "Download läuft" })) setOomArmed(false) } catch (e) { setInstalling((s) => ({ ...s, [repo]: "Fehler" })) } } const isInstalled = (repo: string) => { const tail = repo.split("/").pop()?.toLowerCase().replace(/-gguf$/i, "") || "" return tail.length > 3 && installed.some((m) => m.name.toLowerCase().includes(tail)) } const fitTone = (lvl?: string) => lvl === "perfect" ? "text-emerald-400 border-emerald-500/40 bg-emerald-500/10" : lvl === "marginal" ? "text-amber-400 border-amber-500/40 bg-amber-500/10" : "text-red-400 border-red-500/40 bg-red-500/10" return (
{/* Suchleiste — immer sichtbar */}
setQ(e.target.value)} onKeyDown={(e) => e.key === "Enter" && submitSearch()} placeholder="HuggingFace durchsuchen (z.B. Qwen Coder, Llama-3.1, gemma)…" className="w-full h-10 pl-9 pr-3 rounded-xl border border-border/60 bg-background/40 text-xs outline-none focus:ring-1 focus:ring-primary/50 text-foreground" />
{/* Filter-Chips zum Stöbern */}
{CHIPS.map((c) => ( ))}
{/* Treffer */} {loading ? (
Lade GGUF-Modelle von HuggingFace…
) : (
{results.map((r) => { const isOpen = open === r.repo const inst = isInstalled(r.repo) const author = r.repo.includes("/") ? r.repo.split("/")[0] : "—" const name = r.repo.split("/").pop() return (
{/* Kopf-Zeile (klickbar) */} {/* Aufgeklappt: Quant + Rolle + Fit + Install */} {isOpen && (
{quants.length === 0 ? (
{msg || "Lade Quants…"}
) : ( <>
{/* Fit-Ampel */} {fit && (
{fit.fit.text} ~{fit.params_b}B · ~{fit.fit.req_gb} GB / {fit.sys_ram_gb} GB · ~{fit.fit.tps} t/s {fit.fit.level !== "too_tight" ? ctx → {(fit.assigned_ctx / 1024).toFixed(0)}k : — passt nicht, würde beim Laden abstürzen (OOM).}
)} {/* Rollen-Übernahme-Warnung */} {roleHolder && (
Rolle „{role}" hält aktuell {roleHolder.name.split("/").pop()} — wird beim Download übernommen.
)} )}
)}
) })} {!results.length && !loading &&
{msg || "Keine Ergebnisse."}
}
)} {/* Direkt-Eingabe (Power-User: exaktes Repo/URL) */}
Direkt: setDirect(e.target.value)} onKeyDown={(e) => e.key === "Enter" && loadDirect()} placeholder="org/repo oder HF-URL (z.B. unsloth/Qwen2.5-Coder-7B-Instruct-GGUF)" className="flex-1 h-9 rounded-lg border border-border/60 bg-background/40 px-3 text-xs outline-none focus:ring-1 focus:ring-primary/50 text-foreground font-mono" />
) }