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
+12 -4
View File
@@ -5,7 +5,7 @@ from fastapi import APIRouter, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from config import HF_DOWNLOAD_ENV, MODELS_DIR from config import HF_DOWNLOAD_ENV, MODELS_DIR
from services import discover, hf, jobengine, llamaswap from services import catalog, discover, hf, jobengine, llamaswap
from services.fit import evaluate_fit, extract_params_b, max_ctx_for from services.fit import evaluate_fit, extract_params_b, max_ctx_for
router = APIRouter(prefix="/api") router = APIRouter(prefix="/api")
@@ -31,11 +31,19 @@ def discover_models(force: bool = False) -> dict:
@router.get("/fit") @router.get("/fit")
def fit(params_b: float, quant: str = "Q4_K_M", ctx: int = 8192, name: str = "") -> dict: def fit(params_b: float = 0, quant: str = "Q4_K_M", ctx: int = 8192, name: str = "") -> dict:
"""Hardware-Fit-Vorschau. params_b<=0 → aus KATALOG (echte Metadaten, MoE-bewusst)
oder sonst aus dem Namen geschätzt. So liefert die 'Erweiterte Ansicht' eine
Ampel/t-s-Schätzung für beliebige HF-Repos, bevor heruntergeladen wird."""
ram = _ram_gb() ram = _ram_gb()
pb = params_b
if pb <= 0:
meta = catalog.meta_for_name(name) if name else None
pb = float(meta["total_params_b"]) if (meta and meta.get("total_params_b")) else extract_params_b(name)
return { return {
"fit": evaluate_fit(params_b, quant, ctx, ram, name=name), "params_b": round(pb, 1),
"optimal_ctx": max_ctx_for(params_b, quant, ram), "fit": evaluate_fit(pb, quant, ctx, ram, name=name),
"optimal_ctx": max_ctx_for(pb, quant, ram),
"sys_ram_gb": round(ram, 1), "sys_ram_gb": round(ram, 1),
} }
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
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="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Mission Control 2.0</title> <title>Mission Control 2.0</title>
<script type="module" crossorigin src="/assets/index-OBh42VZw.js"></script> <script type="module" crossorigin src="/assets/index-CXYyTK29.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BA8F-FbW.css"> <link rel="stylesheet" crossorigin href="/assets/index-CqYu-pXY.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+7
View File
@@ -73,6 +73,13 @@ export interface Fit {
tps: number tps: number
} }
export interface FitResp {
params_b: number
fit: Fit
optimal_ctx: number
sys_ram_gb: number
}
export interface ModelInfo { export interface ModelInfo {
name: string name: string
role: string | null role: string | null
+79 -7
View File
@@ -1,6 +1,7 @@
import { useState } from "react" import { useState } from "react"
import { Download, Search } from "lucide-react" import { Download, Search, AlertTriangle } from "lucide-react"
import { api } from "@/lib/api" import { api, type FitResp } from "@/lib/api"
import { useModels } from "@/lib/queries"
export function AddModel() { export function AddModel() {
const [repo, setRepo] = useState("") const [repo, setRepo] = useState("")
@@ -10,23 +11,52 @@ export function AddModel() {
const [role, setRole] = useState("") const [role, setRole] = useState("")
const [q, setQ] = useState("") const [q, setQ] = useState("")
const [results, setResults] = useState<{ repo: string; downloads: number }[]>([]) 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 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) { async function loadQuants(r?: string) {
const rr = r ?? repo const rr = r ?? repo
if (!rr.trim()) return if (!rr.trim()) return
setMsg("Analysiere HuggingFace Repository...") setMsg("Analysiere HuggingFace Repository...")
setFit(null)
try { try {
const d = await api<{ repo: string; quants: string[] }>(`/api/hf/quants?repo=${encodeURIComponent(rr)}`) const d = await api<{ repo: string; quants: string[] }>(`/api/hf/quants?repo=${encodeURIComponent(rr)}`)
setRepo(d.repo) setRepo(d.repo)
setQuants(d.quants) 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.") setMsg(d.quants.length ? "" : "Keine GGUF-Dateien in diesem Repository gefunden.")
if (d.quants.length) refreshFit(d.repo, pick)
} catch (e) { } catch (e) {
setMsg(`Fehler: ${e}`) setMsg(`Fehler: ${e}`)
} }
} }
function onQuantChange(qq: string) {
setQuant(qq)
refreshFit(repo, qq)
}
async function search() { async function search() {
if (!q.trim()) return if (!q.trim()) return
setMsg("Durchsuche HuggingFace...") setMsg("Durchsuche HuggingFace...")
@@ -41,15 +71,22 @@ export function AddModel() {
async function install() { async function install() {
if (!repo.trim()) return 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...") setMsg("Download-Job wird initiiert...")
try { try {
await api("/api/models/install", { await api("/api/models/install", {
method: "POST", method: "POST",
body: JSON.stringify({ repo, quant, role: role || undefined, jinja: true }), body: JSON.stringify({ repo, quant, role: role || undefined, jinja: true }),
}) })
setOomArmed(false)
setMsg( setMsg(
`Download gestartet: ${repo} (${quant})${role ? `, Rolle: ${role}` : ""}. ` + `Download gestartet: ${repo} (${quant})${role ? `, Rolle: ${role}` : ""}. ` +
`Fortschritt oben.` + `Fortschritt oben.` +
(roleHolder ? `${role}" wurde von ${roleHolder.name} übernommen.` : "") +
(role === "fast" || role === "coder" (role === "fast" || role === "coder"
? " Speculative Draft (Vocab-geprüft) kannst du nach dem Download an der Modellkarte (»Spec«) setzen." ? " Speculative Draft (Vocab-geprüft) kannst du nach dem Download an der Modellkarte (»Spec«) setzen."
: "") : "")
@@ -59,6 +96,11 @@ 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 ( 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="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="text-xs font-bold uppercase tracking-wider text-muted-foreground">HF Download &amp; Suche</div>
@@ -66,7 +108,7 @@ export function AddModel() {
<div className="flex flex-col sm:flex-row gap-2"> <div className="flex flex-col sm:flex-row gap-2">
<input <input
value={repo} 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)" 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" 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"
/> />
@@ -81,7 +123,7 @@ export function AddModel() {
<> <>
<select <select
value={quant} 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" 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>)} {quants.map((qq) => <option key={qq} value={qq} className="bg-popover text-foreground">{qq}</option>)}
@@ -97,15 +139,45 @@ export function AddModel() {
</select> </select>
<button <button
onClick={install} 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" 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> </button>
</> </>
)} )}
</div> </div>
</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="flex gap-2 border-t border-border/20 pt-4">
<div className="relative flex-1"> <div className="relative flex-1">
<input <input