feat(2.0): W2+W3 — HF-Link/Suche + Modell-Verwaltungs-UX

W2: install akzeptiert HF-URL ODER org/repo (normalize_repo); GET /api/hf/
search + /api/hf/quants; Frontend AddModel-Panel (URL+Quant-Dropdown+freie
Suche) im Discover-Tab. W3: POST /api/models/{id}/role + /ctx; Installiert-
Tab mit Rollen-Select (fast/heavy/coder/...), ctx-Edit, Loeschen → LLM
tauschen per Klick.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-25 14:33:23 +02:00
parent 501ba36b89
commit c863f01a78
9 changed files with 295 additions and 57 deletions
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
@@ -6,8 +6,8 @@
<meta name="theme-color" content="#0d1117" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>Mission Control 2.0</title>
<script type="module" crossorigin src="/assets/index-Ch2rl17k.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C-XbOjOw.css">
<script type="module" crossorigin src="/assets/index-EHQnFJCd.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-IpcnHTkp.css">
</head>
<body>
<div id="root"></div>
+142 -9
View File
@@ -74,17 +74,40 @@ function FitBadge({ fit }: { fit: Fit }) {
)
}
const ROLES = ["", "fast", "heavy", "coder", "reasoning", "agent", "vision", "scout"]
function Installed() {
const [models, setModels] = useState<ModelInfo[]>([])
const [error, setError] = useState("")
const [loading, setLoading] = useState(true)
useEffect(() => {
function load() {
api<{ models: ModelInfo[] }>("/api/models")
.then((d) => setModels(d.models))
.catch((e) => setError(String(e)))
.finally(() => setLoading(false))
}, [])
}
useEffect(load, [])
async function setRole(name: string, role: string) {
await api(`/api/models/${encodeURIComponent(name)}/role`, {
method: "POST", body: JSON.stringify({ role: role || null }),
})
load()
}
async function setCtx(name: string, cur: number | null) {
const v = prompt("Kontextlänge (Tokens):", String(cur || 32768))
if (!v) return
await api(`/api/models/${encodeURIComponent(name)}/ctx`, {
method: "POST", body: JSON.stringify({ ctx: parseInt(v, 10) }),
})
load()
}
async function del(name: string) {
if (!confirm(`Modell '${name}' aus der Config entfernen? (GGUF-Datei bleibt)`)) return
await api(`/api/models/${encodeURIComponent(name)}`, { method: "DELETE" })
load()
}
if (loading) return <div className="text-sm text-muted-foreground">Lade</div>
if (error)
@@ -104,12 +127,13 @@ function Installed() {
<th className="px-4 py-2 font-medium">Fähigkeiten</th>
<th className="px-4 py-2 font-medium">Kontext</th>
<th className="px-4 py-2 font-medium">Größe</th>
<th className="px-4 py-2 font-medium">Aktionen</th>
</tr>
</thead>
<tbody>
{models.length === 0 && (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-muted-foreground">
<td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">
Keine Modelle konfiguriert.
</td>
</tr>
@@ -118,17 +142,31 @@ function Installed() {
<tr key={m.name} className="border-b border-border/50 last:border-0">
<td className="px-4 py-2.5 font-medium">{m.name}</td>
<td className="px-4 py-2.5">
{m.role ? (
<span className="rounded-md bg-primary/15 px-2 py-0.5 text-xs text-primary">{m.role}</span>
) : (
<span className="text-muted-foreground"></span>
)}
<select
value={m.role || ""}
onChange={(e) => setRole(m.name, e.target.value)}
className="rounded-md border border-border bg-background px-1.5 py-1 text-xs outline-none"
title="Rolle/Alias setzen (so tauschst du z.B. das fast-Hirn)"
>
{ROLES.map((r) => (
<option key={r} value={r}>{r || "—"}</option>
))}
</select>
</td>
<td className="px-4 py-2.5">
<CapsChips caps={m.capabilities} />
</td>
<td className="px-4 py-2.5 text-muted-foreground">{fmtCtx(m.ctx)}</td>
<td className="px-4 py-2.5">
<button onClick={() => setCtx(m.name, m.ctx)} className="text-muted-foreground hover:text-foreground" title="Kontext ändern">
{fmtCtx(m.ctx)}
</button>
</td>
<td className="px-4 py-2.5 text-muted-foreground">{fmtSize(m.size_bytes)}</td>
<td className="px-4 py-2.5">
<button onClick={() => del(m.name)} className="text-muted-foreground hover:text-red-500" title="Aus Config entfernen">
🗑
</button>
</td>
</tr>
))}
</tbody>
@@ -137,6 +175,100 @@ function Installed() {
)
}
function AddModel() {
const [repo, setRepo] = useState("")
const [quants, setQuants] = useState<string[]>([])
const [quant, setQuant] = useState("Q4_K_M")
const [msg, setMsg] = useState("")
const [q, setQ] = useState("")
const [results, setResults] = useState<{ repo: string; downloads: number }[]>([])
async function loadQuants(r?: string) {
const rr = r ?? repo
if (!rr.trim()) return
setMsg("Lade Quants…")
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])
setMsg(d.quants.length ? "" : "Keine GGUF-Quants gefunden")
} catch (e) {
setMsg(`Fehler: ${e}`)
}
}
async function search() {
if (!q.trim()) return
const d = await api<{ results: { repo: string; downloads: number }[] }>(`/api/hf/search?q=${encodeURIComponent(q)}`)
setResults(d.results)
}
async function install() {
if (!repo.trim()) return
setMsg("Installiere…")
try {
await api("/api/models/install", {
method: "POST", body: JSON.stringify({ repo, quant, jinja: true }),
})
setMsg(`Download gestartet: ${repo} (${quant}) — Fortschritt oben.`)
} catch (e) {
setMsg(`Fehler: ${e}`)
}
}
return (
<div className="space-y-3 rounded-xl border border-border bg-card p-4">
<div className="text-sm font-medium">Eigenes Modell laden (HuggingFace)</div>
<div className="flex flex-wrap items-center gap-2">
<input
value={repo}
onChange={(e) => setRepo(e.target.value)}
placeholder="HF-URL oder org/repo (z.B. unsloth/Qwen3.6-35B-A3B-GGUF)"
className="min-w-[280px] flex-1 rounded-md border border-border bg-background px-2 py-1.5 text-sm outline-none focus:ring-2 focus:ring-ring"
/>
<button onClick={() => loadQuants()} className="rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent">
Quants laden
</button>
{quants.length > 0 && (
<>
<select value={quant} onChange={(e) => setQuant(e.target.value)} className="rounded-md border border-border bg-background px-2 py-1.5 text-sm">
{quants.map((qq) => <option key={qq} value={qq}>{qq}</option>)}
</select>
<button onClick={install} className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90">
Installieren
</button>
</>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<input
value={q}
onChange={(e) => setQ(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && search()}
placeholder="HuggingFace durchsuchen (GGUF)…"
className="min-w-[240px] flex-1 rounded-md border border-border bg-background px-2 py-1.5 text-sm outline-none focus:ring-2 focus:ring-ring"
/>
<button onClick={search} className="rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent">Suchen</button>
</div>
{results.length > 0 && (
<div className="max-h-48 space-y-1 overflow-y-auto">
{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-md px-2 py-1 text-left text-xs hover:bg-accent"
>
<span className="truncate">{r.repo}</span>
<span className="text-muted-foreground">{r.downloads.toLocaleString()}</span>
</button>
))}
</div>
)}
{msg && <div className="text-xs text-muted-foreground">{msg}</div>}
</div>
)
}
function Discover() {
const [data, setData] = useState<DiscoverResp | null>(null)
const [error, setError] = useState("")
@@ -173,6 +305,7 @@ function Discover() {
return (
<div className="space-y-6">
<AddModel />
<div className="text-xs text-muted-foreground">
Live von HuggingFace · Hardware-Fit für ~{data.sys_ram_gb} GB · = beste Wahl je Kategorie
</div>