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
+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>