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:
@@ -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),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
-1
File diff suppressed because one or more lines are too long
+397
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
-397
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="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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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 (
|
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 & Suche</div>
|
<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">
|
<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"
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => loadQuants()}
|
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"
|
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
|
Quants laden
|
||||||
@@ -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>)}
|
||||||
@@ -95,17 +137,47 @@ export function AddModel() {
|
|||||||
<option value="" className="bg-popover text-foreground">Rolle…</option>
|
<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>)}
|
{ROLE_OPTS.map((r) => <option key={r} value={r} className="bg-popover text-foreground">{r}</option>)}
|
||||||
</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
|
||||||
@@ -117,8 +189,8 @@ export function AddModel() {
|
|||||||
/>
|
/>
|
||||||
<Search className="absolute left-3 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
|
<Search className="absolute left-3 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={search}
|
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"
|
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
|
Suchen
|
||||||
|
|||||||
Reference in New Issue
Block a user