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
+40 -6
View File
@@ -70,17 +70,29 @@ class InstallReq(BaseModel):
hf_token: str | None = None
@router.get("/hf/search")
def hf_search(q: str) -> dict:
return {"results": hf.search(q)}
@router.get("/hf/quants")
def hf_quants(repo: str) -> dict:
repo = hf.normalize_repo(repo)
return {"repo": repo, "quants": hf.list_quants(repo)}
@router.post("/models/install")
def install(req: InstallReq) -> dict:
"""Lädt ein Modell von HuggingFace (Hintergrund-Job) UND trägt es sofort in
llama-swap ein (cmd + Rolle-Alias). llama-swap (-watch-config) lädt es, sobald
die Datei da ist. Split-GGUFs werden komplett geladen, registriert wird der
erste Teil (-00001-of-…)."""
info = hf.resolve_gguf(req.repo, req.quant)
erste Teil (-00001-of-…). Akzeptiert volle HF-URL ODER org/repo."""
repo = hf.normalize_repo(req.repo)
info = hf.resolve_gguf(repo, req.quant)
if not info["first"]:
raise HTTPException(404, f"Keine GGUF-Datei für Quant '{req.quant}' in {req.repo} gefunden.")
raise HTTPException(404, f"Keine GGUF-Datei für Quant '{req.quant}' in {repo} gefunden.")
subdir = req.repo.split("/")[-1]
subdir = repo.split("/")[-1]
target = MODELS_DIR / subdir
target.mkdir(parents=True, exist_ok=True)
model_path = str(target / info["first"])
@@ -89,7 +101,7 @@ def install(req: InstallReq) -> dict:
ctx = req.ctx
if ctx is None:
ram = _ram_gb()
ctx = max_ctx_for(extract_params_b(req.repo), req.quant, ram)
ctx = max_ctx_for(extract_params_b(repo), req.quant, ram)
# Sofort registrieren (Datei kommt gleich) — robust gegen -watch-config.
try:
@@ -99,7 +111,7 @@ def install(req: InstallReq) -> dict:
raise HTTPException(500, str(exc))
# Download-Job: alle GGUF-Teile (+ mmproj) per --include holen.
args = [hf.hf_bin(), "download", req.repo]
args = [hf.hf_bin(), "download", repo]
for f in info["files"]:
args.append(f)
if info["mmproj"]:
@@ -124,6 +136,28 @@ def cancel(job_id: str) -> dict:
return {"ok": jobengine.cancel_job(job_id)}
class RoleReq(BaseModel):
role: str | None = None
@router.post("/models/{model_id}/role")
def set_model_role(model_id: str, body: RoleReq) -> dict:
if not llamaswap.set_role(model_id, body.role):
raise HTTPException(404, "Modell nicht gefunden")
return {"ok": True}
class CtxReq(BaseModel):
ctx: int
@router.post("/models/{model_id}/ctx")
def set_model_ctx(model_id: str, body: CtxReq) -> dict:
if not llamaswap.set_ctx(model_id, body.ctx):
raise HTTPException(404, "Modell nicht gefunden")
return {"ok": True}
@router.delete("/models/{model_id}")
def delete(model_id: str) -> dict:
if not llamaswap.delete_model(model_id):
+44 -1
View File
@@ -1,11 +1,54 @@
"""HuggingFace-Helfer: GGUF-Dateien eines Repos auflösen (inkl. Split-Teile) + Größen."""
"""HuggingFace-Helfer: GGUF-Dateien eines Repos auflösen (inkl. Split-Teile) + Größen,
freie Suche, Repo-URL→ID, verfügbare Quants."""
import os
import re
import sys
import httpx
def normalize_repo(s: str) -> str:
"""Akzeptiert volle HF-URL oder `org/repo` → liefert immer `org/repo`."""
s = (s or "").strip()
m = re.search(r"huggingface\.co/([^/\s]+/[^/\s?#]+)", s)
if m:
return m.group(1)
return s.strip("/")
def list_quants(repo: str) -> list[str]:
"""Verfügbare Quant-Stufen eines Repos (aus den GGUF-Dateinamen, ohne mmproj)."""
quants: set[str] = set()
for e in _tree(repo):
p = str(e.get("path", ""))
if p.lower().endswith(".gguf") and "mmproj" not in p.lower():
m = re.search(r"(I?Q\d[\w]*|F16|BF16|FP16|F32)", p, re.IGNORECASE)
if m:
quants.add(m.group(1).upper())
# gängige Reihenfolge zuerst
order = {"Q4_K_M": 0, "Q4_K_S": 1, "Q5_K_M": 2, "Q6_K": 3, "Q8_0": 4, "Q3_K_M": 5, "Q2_K": 6}
return sorted(quants, key=lambda q: (order.get(q, 99), q))
def search(q: str, limit: int = 20) -> list[dict]:
"""Freie HF-Suche nach GGUF-Repos."""
url = (f"https://huggingface.co/api/models?search={q}"
f"&filter=gguf&sort=downloads&direction=-1&limit={limit}")
try:
with httpx.Client(timeout=12.0) as c:
data = c.get(url).json()
except Exception:
return []
out = []
for m in (data if isinstance(data, list) else []):
rid = m.get("id")
if rid:
out.append({"repo": rid, "downloads": int(m.get("downloads") or 0),
"likes": int(m.get("likes") or 0)})
return out
def hf_bin() -> str:
"""Pfad zur `hf`-CLI (bevorzugt neben dem laufenden Python im venv)."""
cand = os.path.join(os.path.dirname(sys.executable), "hf")
+27
View File
@@ -188,6 +188,33 @@ def list_groups() -> dict:
return read_config().get("groups") or {}
def set_role(model_id: str, role: str | None) -> bool:
"""Rolle (llama-swap-Alias) eines bestehenden Modells setzen/ändern. So tauscht man
z.B. das `fast`-Hirn: Rolle `fast` auf ein anderes Modell legen (Alias wandert)."""
cfg = read_config()
if model_id not in (cfg.get("models") or {}):
return False
set_role_alias(cfg, model_id, role)
write_config(cfg)
return True
def set_ctx(model_id: str, ctx: int) -> bool:
"""Kontextlänge (-c) eines bestehenden Modells ändern."""
cfg = read_config()
spec = (cfg.get("models") or {}).get(model_id)
if not spec:
return False
cmd = str(spec.get("cmd", ""))
if _CTX_RE.search(cmd):
cmd = re.sub(r"-(?:c|-ctx-size)\s+\d+", f"-c {ctx}", cmd)
else:
cmd = cmd.rstrip() + f" -c {ctx}"
spec["cmd"] = LiteralScalarString(cmd if cmd.endswith("\n") else cmd + "\n")
write_config(cfg)
return True
def delete_model(model_id: str) -> bool:
"""Entfernt einen Modell-Eintrag aus der config.yaml (und aus allen Gruppen)."""
cfg = read_config()
+10 -9
View File
@@ -12,15 +12,16 @@
16 Einträge) — eine geteilte „Verfassung" für Cockpit, Hermes, IDEs.
## Bekannte Tuning-Punkte (vor „rundem" Cutover sinnvoll, kein Blocker)
1. **Hermes thrasht / Kontext-Bloat (NICHT brain-abhängig):** Auf simple Prompts feuert der Agent
Fehl-Tools (`vision_analyze` auf Text → „Bild nicht gefunden", `search_files`-Schleife → Eigen-Guard
blockt) und injiziert ~124249k Prompt-Tokens (→ 12 min/Antwort). Getestet: mit `auto`→Qwen3.6 UND
mit `coder` (Qwen3-Coder-30B) — **gleiches Verhalten**, also kein Modell-, sondern ein Hermes-
**Kontext/Session/Tool-Problem** (vorbestehend = das „dumm/loop" aus v1). **Hands-on-Debug nötig
(mit dir):** (a) Session zurücksetzen/frische Session-ID (akkumulierte History?), (b) unnötige
Toolsets/Skills abschalten (`hermes` CLI / config `toolsets`/`platform_toolsets`), v.a. Vision-Tools
für reine Text-Aufgaben, (c) Kontext-/History-Limits setzen, (d) Thinking für den Agenten aus.
Das Brain steht auf `auto` (deine Vorgabe); das ist nicht die Ursache.
1. **Hermes-Thrash → BEHOBEN (W1).** Ursache war eine **vergiftete Dauer-Session** (mein Test hatte sie
mit einem Vision-Fehl-Lauf verseucht; Hermes fütterte sie jeden Zug erneut → ~249k Tokens, Vision-auf-
Text, Such-Schleifen). **Verifiziert:** frische Session = sauber & kohärent, **34k statt 249k**,
keine Fehl-Tools. Maßnahmen: `code_execution.max_tool_calls` 50→20 (Schleifen-Bremse); **History
unangetastet** (64 Sessions/1048 Msgs bleiben — Hermes' Gedächtnis). Sessions reseten ohnehin täglich
(4 Uhr) / nach 24 h idle; das WebUI nutzt pro Chat eine eigene Session. **Speed:** Gateway schaltet
auf der `fast`-Spur **Thinking aus** (Qwen3.6) → bare Gateway-Antwort ~10 s, direkt.
**Rest-Tuning (mit dir, optional):** Hermes' erste Nachricht dauert noch ~6090 s wegen **34k Basis-
Kontext** (Tool-Schemas + 26 Skills) + Agent-Loop. Hebel: unnötige Toolsets/Skills prunen
(`config toolsets`/`platform_toolsets`, 26 Skills sichten) → schlankerer Prompt = schnellerer Agent.
2. **SSH→Windows (voller PC-Zugriff):** Windows-seitig OpenSSH-Server aktivieren + Key
`id_ed25519_hermes_agent` autorisieren (vom Box-Host aus). Erst dann erreicht Hermes' Shell den PC.
3. **hermes-webui (nesquena) standalone:** optional — aktuell läuft das eingebaute `hermes-dashboard`.
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>