Feat: idiotensichere Erweiterte Ansicht - Fit-Ampel (OOM-Gate) + Rollen-Uebernahme-Warnung

Manueller HF-Install hatte zwei scharfe Kanten:
- kein Fit/OOM-Schutz: zu grosses Modell stuerzte erst beim Laden ab
- stiller Rollen-Diebstahl: exklusiver Alias wanderte kommentarlos weg

Backend: /api/fit leitet params_b jetzt aus KATALOG (echte Metadaten, MoE-bewusst)
oder Namens-Schaetzung ab (params_b<=0) -> Fit-Vorschau fuer beliebige HF-Repos.

Frontend (AddModel): Hardware-Fit-Ampel (perfect/marginal/OOM) mit ~params/req_gb/tps
nach 'Quants laden'; OOM-Gate (zweiter, roter Klick noetig); Warnung welches Modell
die gewaehlte Rolle aktuell haelt und sie verliert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-27 13:51:29 +02:00
parent c074d977ce
commit 14325e7690
8 changed files with 505 additions and 418 deletions
+86 -14
View File
@@ -1,6 +1,7 @@
import { useState } from "react"
import { Download, Search } from "lucide-react"
import { api } from "@/lib/api"
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("")
@@ -10,23 +11,52 @@ export function AddModel() {
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) {
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)}`
)
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)
if (d.quants.length) setQuant(d.quants.includes("Q4_K_M") ? "Q4_K_M" : d.quants[0])
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)
} catch (e) {
setMsg(`Fehler: ${e}`)
}
}
function onQuantChange(qq: string) {
setQuant(qq)
refreshFit(repo, qq)
}
async function search() {
if (!q.trim()) return
setMsg("Durchsuche HuggingFace...")
@@ -41,15 +71,22 @@ export function AddModel() {
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."
: "")
@@ -59,20 +96,25 @@ export function AddModel() {
}
}
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)}
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()}
<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
@@ -81,7 +123,7 @@ export function AddModel() {
<>
<select
value={quant}
onChange={(e) => setQuant(e.target.value)}
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>)}
@@ -95,17 +137,47 @@ export function AddModel() {
<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 bg-primary text-primary-foreground text-xs font-semibold hover:opacity-90 transition-all cursor-pointer flex items-center gap-1.5"
<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"
}`}
>
<Download className="h-3.5 w-3.5" /> Herunterladen
{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="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
@@ -117,8 +189,8 @@ export function AddModel() {
/>
<Search className="absolute left-3 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
</div>
<button
onClick={search}
<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