Feat: "Modelle finden" mit gleichwertigem Stöbern-&-Suchen-Modus

Profi-Suche raus aus dem versteckten Akkordeon → eigener Modus-Umschalter
(Empfohlen | Stöbern & Suchen) im "Modelle finden"-Tab.

- Stöbern & Suchen: immer sichtbare Suchleiste, Filter-Chips (Beliebt/Coder/
  Vision/Reasoning/Klein), reiche Treffer (Autor · Downloads · Likes · installiert-
  Badge), aufklappbar → Quant + Rolle + Fit-Ampel + Download (OOM-Gate), plus
  Direkt-Eingabe für exaktes Repo/URL.
- Trending ohne Query: hf.search('') liefert jetzt Top-GGUF nach Downloads;
  Route /api/hf/search?q wird optional.
- Empfohlen = unveränderte Rollen-Sockets (Default). AddModel.tsx in ModelBrowse
  aufgegangen → gelöscht.

Verifiziert: npm run build (tsc strict) clean; Backend-Smoke (empty-q → Top-GGUF);
live gegen die Box (Toggle, Chips, Suche, installiert-Erkennung, Fit-Ampel).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-29 19:51:16 +02:00
parent a894a4e952
commit 8108d3fd28
9 changed files with 676 additions and 592 deletions
+1 -1
View File
@@ -82,7 +82,7 @@ class InstallReq(BaseModel):
@router.get("/hf/search")
def hf_search(q: str) -> dict:
def hf_search(q: str = "") -> dict:
return {"results": hf.search(q)}
+6 -4
View File
@@ -31,10 +31,12 @@ def list_quants(repo: str) -> list[str]:
return sorted(quants, key=lambda q: (order.get(q, 99), q))
def search(q: str, limit: int = 20) -> list[dict]:
"""Freie HF-Suche nach GGUF-Repos."""
url = (f"https://huggingface.co/api/models?search={q}"
f"&filter=gguf&sort=downloads&direction=-1&limit={limit}")
def search(q: str = "", limit: int = 24) -> list[dict]:
"""Freie HF-Suche nach GGUF-Repos. Ohne q → Top-GGUF nach Downloads (Stöbern)."""
url = (f"https://huggingface.co/api/models?filter=gguf"
f"&sort=downloads&direction=-1&limit={limit}")
if q and q.strip():
url += f"&search={q.strip()}"
try:
with httpx.Client(timeout=12.0) as c:
data = c.get(url).json()
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -7,8 +7,8 @@
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Mission Control 2.0</title>
<script type="module" crossorigin src="/assets/index-PXdvVWCT.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B_KoUJpZ.css">
<script type="module" crossorigin src="/assets/index-BLpIFZ-c.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-MrCHFz7_.css">
</head>
<body>
<div id="root"></div>
-234
View File
@@ -1,234 +0,0 @@
import { useState } from "react"
import { Download, Search, AlertTriangle } from "lucide-react"
import { api, type FitResp } from "@/lib/api"
import { useModels } from "@/lib/queries"
export function AddModel() {
const [repo, setRepo] = useState("")
const [quants, setQuants] = useState<string[]>([])
const [quant, setQuant] = useState("Q4_K_M")
const [msg, setMsg] = useState("")
const [role, setRole] = useState("")
const [q, setQ] = useState("")
const [results, setResults] = useState<{ repo: string; downloads: number }[]>([])
const [fit, setFit] = useState<FitResp | null>(null)
const [oomArmed, setOomArmed] = useState(false)
const ROLE_OPTS = ["fast", "heavy", "coder", "vision", "scout"]
const { data: modelsData } = useModels()
// Welches Modell hält aktuell die gewählte Rolle? (Rolle ist exklusiv → würde übernommen.)
const roleHolder = role
? modelsData?.models.find((m) => (m.role || "").toLowerCase() === role)
: undefined
async function refreshFit(r: string, qq: string, rl: string) {
setOomArmed(false)
if (!r.trim()) { setFit(null); return }
try {
const d = await api<FitResp>(
`/api/fit?params_b=0&quant=${encodeURIComponent(qq)}&ctx=8192` +
`&name=${encodeURIComponent(r)}&role=${encodeURIComponent(rl)}`
)
setFit(d)
} catch {
setFit(null)
}
}
async function loadQuants(r?: string) {
const rr = r ?? repo
if (!rr.trim()) return
setMsg("Analysiere HuggingFace Repository...")
setFit(null)
try {
const d = await api<{ repo: string; quants: string[] }>(`/api/hf/quants?repo=${encodeURIComponent(rr)}`)
setRepo(d.repo)
setQuants(d.quants)
const pick = d.quants.length ? (d.quants.includes("Q4_K_M") ? "Q4_K_M" : d.quants[0]) : quant
if (d.quants.length) setQuant(pick)
setMsg(d.quants.length ? "" : "Keine GGUF-Dateien in diesem Repository gefunden.")
if (d.quants.length) refreshFit(d.repo, pick, role)
} catch (e) {
setMsg(`Fehler: ${e}`)
}
}
function onQuantChange(qq: string) {
setQuant(qq)
refreshFit(repo, qq, role)
}
function onRoleChange(rl: string) {
setRole(rl)
if (quants.length) refreshFit(repo, quant, rl)
}
async function search() {
if (!q.trim()) return
setMsg("Durchsuche HuggingFace...")
try {
const d = await api<{ results: { repo: string; downloads: number }[] }>(`/api/hf/search?q=${encodeURIComponent(q)}`)
setResults(d.results)
setMsg(d.results.length ? "" : "Keine Ergebnisse gefunden.")
} catch (e) {
setMsg(`Suche fehlgeschlagen: ${e}`)
}
}
async function install() {
if (!repo.trim()) return
// OOM-Gate: zu großes Modell würde erst beim Laden abstürzen → erst bestätigen.
if (fit?.fit.level === "too_tight" && !oomArmed) {
setOomArmed(true)
return
}
setMsg("Download-Job wird initiiert...")
try {
await api("/api/models/install", {
method: "POST",
body: JSON.stringify({ repo, quant, role: role || undefined, jinja: true }),
})
setOomArmed(false)
setMsg(
`Download gestartet: ${repo} (${quant})${role ? `, Rolle: ${role}` : ""}. ` +
`Fortschritt oben.` +
(roleHolder ? `${role}" wurde von ${roleHolder.name} übernommen.` : "") +
(role === "fast" || role === "coder"
? " Speculative Draft (Vocab-geprüft) kannst du nach dem Download an der Modellkarte (»Spec«) setzen."
: "")
)
} catch (e) {
setMsg(`Download-Fehler: ${e}`)
}
}
const fitTone =
fit?.fit.level === "perfect" ? "text-emerald-400 border-emerald-500/40 bg-emerald-500/10"
: fit?.fit.level === "marginal" ? "text-amber-400 border-amber-500/40 bg-amber-500/10"
: "text-red-400 border-red-500/40 bg-red-500/10"
return (
<div className="space-y-4 rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10">
<div className="text-xs font-bold uppercase tracking-wider text-muted-foreground">HF Download &amp; Suche</div>
<div className="flex flex-col sm:flex-row gap-2">
<input
value={repo}
onChange={(e) => { setRepo(e.target.value); setFit(null); setOomArmed(false) }}
placeholder="HF-URL oder org/repo (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"
/>
<div className="flex gap-2">
<button
onClick={() => loadQuants()}
className="h-9 px-4 rounded-lg border border-border/60 bg-background/20 text-xs font-semibold hover:border-primary/50 hover:bg-background/40 transition-all cursor-pointer whitespace-nowrap"
>
Quants laden
</button>
{quants.length > 0 && (
<>
<select
value={quant}
onChange={(e) => onQuantChange(e.target.value)}
className="h-9 rounded-lg border border-border/60 bg-background/40 px-3 text-xs outline-none text-foreground font-semibold"
>
{quants.map((qq) => <option key={qq} value={qq} className="bg-popover text-foreground">{qq}</option>)}
</select>
<select
value={role}
onChange={(e) => onRoleChange(e.target.value)}
title="Rolle (optional) — bestimmt Auto-Konfiguration + setup-bewussten Kontext"
className="h-9 rounded-lg border border-border/60 bg-background/40 px-3 text-xs outline-none text-foreground font-semibold"
>
<option value="" className="bg-popover text-foreground">Rolle</option>
{ROLE_OPTS.map((r) => <option key={r} value={r} className="bg-popover text-foreground">{r}</option>)}
</select>
<button
onClick={install}
className={`h-9 px-4 rounded-lg text-xs font-semibold transition-all cursor-pointer flex items-center gap-1.5 ${
oomArmed
? "bg-red-500 text-white hover:opacity-90"
: "bg-primary text-primary-foreground hover:opacity-90"
}`}
>
{oomArmed
? <><AlertTriangle className="h-3.5 w-3.5" /> OOM-Risiko trotzdem laden</>
: <><Download className="h-3.5 w-3.5" /> Herunterladen</>}
</button>
</>
)}
</div>
</div>
{/* Hardware-Fit-Vorschau (Ampel) — vor dem Download, idiotensicher gegen OOM */}
{fit && (
<div className={`flex flex-wrap items-center gap-x-3 gap-y-1 rounded-lg border px-3 py-2 text-[11px] font-medium ${fitTone}`}>
<span className="font-bold uppercase tracking-wide">{fit.fit.text}</span>
<span className="font-mono opacity-90">
~{fit.params_b}B · ~{fit.fit.req_gb} GB / {fit.sys_ram_gb} GB RAM · ~{fit.fit.tps} t/s
</span>
{fit.fit.level !== "too_tight" && (
<span
className="font-mono opacity-80"
title={`Setup-bewusst: GTT ${fit.budget.gtt_gb} GB reserviert ${fit.budget.reserved_gb} GB (${fit.budget.mode}) → ${fit.budget.budget_gb} GB frei`}
>
ctx {(fit.assigned_ctx / 1024).toFixed(0)}k
</span>
)}
{fit.fit.level === "too_tight" && (
<span className="opacity-90"> passt nicht in den Speicher, würde beim Laden abstürzen (OOM).</span>
)}
</div>
)}
{/* Rollen-Übernahme-Warnung — Rolle ist exklusiv */}
{roleHolder && (
<div className="flex items-start gap-2 rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-[11px] font-medium text-amber-400">
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
<span>
Rolle <strong>{role}"</strong> ist aktuell <strong>{roleHolder.name}</strong> zugewiesen.
Beim Download übernimmt das neue Modell diese Rolle — {roleHolder.name} bleibt installiert, verliert sie aber.
</span>
</div>
)}
<div className="flex gap-2 border-t border-border/20 pt-4">
<div className="relative flex-1">
<input
value={q}
onChange={(e) => setQ(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && search()}
placeholder="HuggingFace durchsuchen (z.B. Llama-3.1)..."
className="w-full h-9 pl-9 pr-3 rounded-lg border border-border/60 bg-background/40 text-xs outline-none focus:ring-1 focus:ring-primary/50 text-foreground"
/>
<Search className="absolute left-3 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
</div>
<button
onClick={search}
className="h-9 px-4 rounded-lg border border-border/60 bg-background/20 text-xs font-semibold hover:border-primary/50 hover:bg-background/40 transition-all cursor-pointer"
>
Suchen
</button>
</div>
{results.length > 0 && (
<div className="max-h-48 space-y-1.5 overflow-y-auto border border-border/40 bg-background/20 p-2 rounded-xl scrollbar-thin">
{results.map((r) => (
<button
key={r.repo}
onClick={() => { setRepo(r.repo); setResults([]); setQ(""); loadQuants(r.repo) }}
className="flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-xs bg-background/10 border border-border/10 hover:border-primary/30 hover:bg-background/30 transition-all"
>
<span className="font-semibold truncate">{r.repo}</span>
<span className="text-[10px] font-mono text-muted-foreground flex items-center gap-1 shrink-0">
<Download className="h-3 w-3" /> {r.downloads.toLocaleString()}
</span>
</button>
))}
</div>
)}
{msg && <div className="text-[10px] font-medium text-primary font-mono">{msg}</div>}
</div>
)
}
+26 -26
View File
@@ -1,11 +1,11 @@
import { useState } from "react"
import { Download, Search, Star, Layers, Check, Zap, Eye, Code, Brain, Compass, ChevronDown, ChevronUp } from "lucide-react"
import { Download, Star, Layers, Check, Zap, Eye, Code, Brain, Compass, ChevronDown, ChevronUp } from "lucide-react"
import { api } from "@/lib/api"
import { useModels, useUpdates, useDiscover } from "@/lib/queries"
import { cn } from "@/lib/utils"
import { fmtBytes } from "@/lib/format"
import { FitBadge } from "@/components/models/ModelBadges"
import { AddModel } from "./AddModel"
import { ModelBrowse } from "./ModelBrowse"
// Die 5 kanonischen Rollen (identisch zu sources.py / ModelBadges.ROLES).
const ROLE_METADATA: Record<string, { title: string; desc: string; icon: any }> = {
@@ -44,7 +44,7 @@ export function Discover() {
const error = loadErr ? String(loadErr) : ""
const [installing, setInstalling] = useState<Record<string, string>>({})
const [expandedAlternatives, setExpandedAlternatives] = useState<Record<string, boolean>>({})
const [showExpert, setShowExpert] = useState(false)
const [mode, setMode] = useState<"recommended" | "browse">("recommended")
async function install(repo: string, role: string, quant: string, toolCapable: boolean) {
setInstalling((s) => ({ ...s, [repo]: "Starte..." }))
@@ -59,15 +59,33 @@ export function Discover() {
}
}
if (loading) return <div className="text-xs text-muted-foreground py-12 text-center">Analysiere Hardware und suche passende GGUF-Empfehlungen</div>
if (error || !data)
return (
<div className="space-y-6">
{/* Modus-Umschalter: geführt (Empfohlen) vs. Stöbern & Suchen */}
<div className="flex rounded-xl border border-border/60 bg-card/45 backdrop-blur-md p-1 w-fit">
{([["recommended", "Empfohlen"], ["browse", "Stöbern & Suchen"]] as const).map(([m, label]) => (
<button
key={m}
onClick={() => setMode(m)}
className={cn(
"rounded-lg px-4 py-1.5 text-xs font-semibold tracking-wide transition-all cursor-pointer",
mode === m ? "bg-primary text-primary-foreground shadow-md shadow-primary/10" : "text-muted-foreground hover:text-foreground",
)}
>
{label}
</button>
))}
</div>
{mode === "browse" ? (
<ModelBrowse />
) : loading ? (
<div className="text-xs text-muted-foreground py-12 text-center">Analysiere Hardware und suche passende GGUF-Empfehlungen</div>
) : (error || !data) ? (
<div className="rounded-xl border border-red-500/20 bg-red-500/5 p-4 text-xs text-red-400">
Empfehlungsdienst temporär nicht erreichbar ({error}).
</div>
)
return (
) : (
<div className="space-y-8">
{/* Informational Header */}
<div className="text-[10px] font-mono text-muted-foreground bg-background/25 border border-border/40 p-4 rounded-xl flex flex-col sm:flex-row sm:items-center justify-between gap-3 shadow-sm">
@@ -261,26 +279,8 @@ export function Discover() {
})}
</div>
{/* Collapsible Custom Hugging Face Downloader */}
<div className="border border-border/50 bg-card/20 rounded-2xl overflow-hidden shadow-md mt-4">
<button
onClick={() => setShowExpert(!showExpert)}
className="w-full px-5 py-4 flex items-center justify-between text-xs font-bold uppercase tracking-wider text-muted-foreground hover:bg-card/30 transition-colors cursor-pointer"
>
<div className="flex items-center gap-2">
<Search className="h-4 w-4 text-primary" />
<span>Eigenes Modell von Hugging Face laden (Erweiterte Ansicht)</span>
</div>
<span className="text-[10px] text-primary hover:underline">
{showExpert ? "Ausblenden ▲" : "Anzeigen ▼"}
</span>
</button>
{showExpert && (
<div className="p-5 border-t border-border/20 bg-card/10">
<AddModel />
</div>
)}
</div>
</div>
)
}
+306
View File
@@ -0,0 +1,306 @@
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<Hit[]>([])
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<string | null>(null)
const [quants, setQuants] = useState<string[]>([])
const [quant, setQuant] = useState("Q4_K_M")
const [role, setRole] = useState("")
const [fit, setFit] = useState<FitResp | null>(null)
const [oomArmed, setOomArmed] = useState(false)
const [installing, setInstalling] = useState<Record<string, string>>({})
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<FitResp>(
`/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 (
<div className="space-y-5">
{/* Suchleiste — immer sichtbar */}
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
<input
value={q}
onChange={(e) => 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"
/>
</div>
<button
onClick={submitSearch}
className="h-10 px-5 rounded-xl bg-primary text-primary-foreground text-xs font-semibold hover:opacity-90 transition-all cursor-pointer"
>
Suchen
</button>
</div>
{/* Filter-Chips zum Stöbern */}
<div className="flex items-center gap-2 flex-wrap">
{CHIPS.map((c) => (
<button
key={c.key}
onClick={() => pickChip(c)}
className={cn(
"flex items-center gap-1.5 rounded-full px-3 py-1.5 text-[11px] font-semibold transition-all cursor-pointer border",
chip === c.key
? "bg-primary/15 text-primary border-primary/30"
: "border-border/50 text-muted-foreground hover:text-foreground hover:border-border",
)}
>
{c.key === "popular" && <Flame className="h-3 w-3" />}
{c.label}
</button>
))}
</div>
{/* Treffer */}
{loading ? (
<div className="text-xs text-muted-foreground py-10 text-center">Lade GGUF-Modelle von HuggingFace</div>
) : (
<div className="space-y-2">
{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 (
<div
key={r.repo}
className={cn(
"rounded-xl border bg-card/45 backdrop-blur-md transition-all",
isOpen ? "border-primary/40 shadow-lg shadow-primary/5" : "border-border/50 hover:border-primary/30",
)}
>
{/* Kopf-Zeile (klickbar) */}
<button
onClick={() => toggleExpand(r.repo)}
className="w-full flex items-center gap-3 p-3.5 text-left cursor-pointer"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-bold font-mono text-foreground truncate max-w-[280px]" title={r.repo}>{name}</span>
{inst && (
<span className="flex items-center gap-1 text-[8px] font-bold text-emerald-400 uppercase tracking-wider bg-emerald-500/10 border border-emerald-500/20 px-1.5 py-0.5 rounded">
<Check className="h-2.5 w-2.5" /> installiert
</span>
)}
</div>
<div className="text-[10px] text-muted-foreground/80 flex items-center gap-3 mt-0.5 font-mono">
<span>{author}</span>
<span className="flex items-center gap-1"><Download className="h-3 w-3" /> {fmtN(r.downloads)}</span>
{r.likes > 0 && <span className="flex items-center gap-1"><Heart className="h-3 w-3" /> {fmtN(r.likes)}</span>}
</div>
</div>
{isOpen ? <ChevronUp className="h-4 w-4 text-muted-foreground shrink-0" /> : <ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />}
</button>
{/* Aufgeklappt: Quant + Rolle + Fit + Install */}
{isOpen && (
<div className="border-t border-border/30 p-3.5 space-y-3">
{quants.length === 0 ? (
<div className="text-[11px] text-muted-foreground font-mono">{msg || "Lade Quants…"}</div>
) : (
<>
<div className="flex flex-wrap items-center gap-2">
<label className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">Quant</label>
<select
value={quant}
onChange={(e) => { setQuant(e.target.value); refreshFit(r.repo, e.target.value, role) }}
className="h-8 rounded-lg border border-border/60 bg-background/40 px-2 text-xs text-foreground font-semibold outline-none"
>
{quants.map((qq) => <option key={qq} value={qq} className="bg-popover text-foreground">{qq}</option>)}
</select>
<label className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground ml-1">Rolle</label>
<select
value={role}
onChange={(e) => { setRole(e.target.value); refreshFit(r.repo, quant, e.target.value) }}
title="Optional — bestimmt Auto-Konfiguration + setup-bewussten Kontext"
className="h-8 rounded-lg border border-border/60 bg-background/40 px-2 text-xs text-foreground font-semibold outline-none"
>
<option value="" className="bg-popover text-foreground">keine</option>
{ROLE_OPTS.map((rl) => <option key={rl} value={rl} className="bg-popover text-foreground">{rl}</option>)}
</select>
<button
onClick={() => install(r.repo)}
disabled={!!installing[r.repo] && installing[r.repo] === "Download läuft"}
className={cn(
"h-8 px-3.5 ml-auto rounded-lg text-xs font-semibold transition-all cursor-pointer flex items-center gap-1.5",
oomArmed ? "bg-red-500 text-white hover:opacity-90" : "bg-primary text-primary-foreground hover:opacity-90",
)}
>
{installing[r.repo] ? installing[r.repo]
: oomArmed ? <><AlertTriangle className="h-3.5 w-3.5" /> OOM trotzdem laden</>
: <><Download className="h-3.5 w-3.5" /> Herunterladen</>}
</button>
</div>
{/* Fit-Ampel */}
{fit && (
<div className={cn("flex flex-wrap items-center gap-x-3 gap-y-1 rounded-lg border px-3 py-2 text-[11px] font-medium", fitTone(fit.fit.level))}>
<span className="font-bold uppercase tracking-wide">{fit.fit.text}</span>
<span className="font-mono opacity-90">~{fit.params_b}B · ~{fit.fit.req_gb} GB / {fit.sys_ram_gb} GB · ~{fit.fit.tps} t/s</span>
{fit.fit.level !== "too_tight"
? <span className="font-mono opacity-80">ctx {(fit.assigned_ctx / 1024).toFixed(0)}k</span>
: <span className="opacity-90"> passt nicht, würde beim Laden abstürzen (OOM).</span>}
</div>
)}
{/* Rollen-Übernahme-Warnung */}
{roleHolder && (
<div className="flex items-start gap-2 rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-[11px] text-amber-400">
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
<span>Rolle <strong>{role}"</strong> hält aktuell <strong>{roleHolder.name.split("/").pop()}</strong> — wird beim Download übernommen.</span>
</div>
)}
</>
)}
</div>
)}
</div>
)
})}
{!results.length && !loading && <div className="text-xs text-muted-foreground py-10 text-center">{msg || "Keine Ergebnisse."}</div>}
</div>
)}
{/* Direkt-Eingabe (Power-User: exaktes Repo/URL) */}
<div className="border-t border-border/30 pt-4 flex flex-col sm:flex-row gap-2 items-stretch sm:items-center">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground shrink-0">Direkt:</span>
<input
value={direct}
onChange={(e) => 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"
/>
<button
onClick={loadDirect}
className="h-9 px-4 rounded-lg border border-border/60 bg-background/20 text-xs font-semibold hover:border-primary/50 hover:bg-background/40 transition-all cursor-pointer whitespace-nowrap"
>
Laden
</button>
</div>
</div>
)
}