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:
@@ -73,6 +73,13 @@ export interface Fit {
|
||||
tps: number
|
||||
}
|
||||
|
||||
export interface FitResp {
|
||||
params_b: number
|
||||
fit: Fit
|
||||
optimal_ctx: number
|
||||
sys_ram_gb: number
|
||||
}
|
||||
|
||||
export interface ModelInfo {
|
||||
name: string
|
||||
role: string | null
|
||||
|
||||
@@ -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 & 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
|
||||
|
||||
Reference in New Issue
Block a user