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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user