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:
@@ -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)}
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+328
-318
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -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>
|
||||
|
||||
@@ -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 & 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user