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 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") @router.post("/models/install")
def install(req: InstallReq) -> dict: def install(req: InstallReq) -> dict:
"""Lädt ein Modell von HuggingFace (Hintergrund-Job) UND trägt es sofort in """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 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 die Datei da ist. Split-GGUFs werden komplett geladen, registriert wird der
erste Teil (-00001-of-…).""" erste Teil (-00001-of-…). Akzeptiert volle HF-URL ODER org/repo."""
info = hf.resolve_gguf(req.repo, req.quant) repo = hf.normalize_repo(req.repo)
info = hf.resolve_gguf(repo, req.quant)
if not info["first"]: 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 = MODELS_DIR / subdir
target.mkdir(parents=True, exist_ok=True) target.mkdir(parents=True, exist_ok=True)
model_path = str(target / info["first"]) model_path = str(target / info["first"])
@@ -89,7 +101,7 @@ def install(req: InstallReq) -> dict:
ctx = req.ctx ctx = req.ctx
if ctx is None: if ctx is None:
ram = _ram_gb() 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. # Sofort registrieren (Datei kommt gleich) — robust gegen -watch-config.
try: try:
@@ -99,7 +111,7 @@ def install(req: InstallReq) -> dict:
raise HTTPException(500, str(exc)) raise HTTPException(500, str(exc))
# Download-Job: alle GGUF-Teile (+ mmproj) per --include holen. # 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"]: for f in info["files"]:
args.append(f) args.append(f)
if info["mmproj"]: if info["mmproj"]:
@@ -124,6 +136,28 @@ def cancel(job_id: str) -> dict:
return {"ok": jobengine.cancel_job(job_id)} 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}") @router.delete("/models/{model_id}")
def delete(model_id: str) -> dict: def delete(model_id: str) -> dict:
if not llamaswap.delete_model(model_id): 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 os
import re
import sys import sys
import httpx 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: def hf_bin() -> str:
"""Pfad zur `hf`-CLI (bevorzugt neben dem laufenden Python im venv).""" """Pfad zur `hf`-CLI (bevorzugt neben dem laufenden Python im venv)."""
cand = os.path.join(os.path.dirname(sys.executable), "hf") 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 {} 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: def delete_model(model_id: str) -> bool:
"""Entfernt einen Modell-Eintrag aus der config.yaml (und aus allen Gruppen).""" """Entfernt einen Modell-Eintrag aus der config.yaml (und aus allen Gruppen)."""
cfg = read_config() cfg = read_config()
+10 -9
View File
@@ -12,15 +12,16 @@
16 Einträge) — eine geteilte „Verfassung" für Cockpit, Hermes, IDEs. 16 Einträge) — eine geteilte „Verfassung" für Cockpit, Hermes, IDEs.
## Bekannte Tuning-Punkte (vor „rundem" Cutover sinnvoll, kein Blocker) ## Bekannte Tuning-Punkte (vor „rundem" Cutover sinnvoll, kein Blocker)
1. **Hermes thrasht / Kontext-Bloat (NICHT brain-abhängig):** Auf simple Prompts feuert der Agent 1. **Hermes-Thrash → BEHOBEN (W1).** Ursache war eine **vergiftete Dauer-Session** (mein Test hatte sie
Fehl-Tools (`vision_analyze` auf Text → „Bild nicht gefunden", `search_files`-Schleife → Eigen-Guard mit einem Vision-Fehl-Lauf verseucht; Hermes fütterte sie jeden Zug erneut → ~249k Tokens, Vision-auf-
blockt) und injiziert ~124249k Prompt-Tokens (→ 12 min/Antwort). Getestet: mit `auto`→Qwen3.6 UND Text, Such-Schleifen). **Verifiziert:** frische Session = sauber & kohärent, **34k statt 249k**,
mit `coder` (Qwen3-Coder-30B) — **gleiches Verhalten**, also kein Modell-, sondern ein Hermes- keine Fehl-Tools. Maßnahmen: `code_execution.max_tool_calls` 50→20 (Schleifen-Bremse); **History
**Kontext/Session/Tool-Problem** (vorbestehend = das „dumm/loop" aus v1). **Hands-on-Debug nötig unangetastet** (64 Sessions/1048 Msgs bleiben — Hermes' Gedächtnis). Sessions reseten ohnehin täglich
(mit dir):** (a) Session zurücksetzen/frische Session-ID (akkumulierte History?), (b) unnötige (4 Uhr) / nach 24 h idle; das WebUI nutzt pro Chat eine eigene Session. **Speed:** Gateway schaltet
Toolsets/Skills abschalten (`hermes` CLI / config `toolsets`/`platform_toolsets`), v.a. Vision-Tools auf der `fast`-Spur **Thinking aus** (Qwen3.6) → bare Gateway-Antwort ~10 s, direkt.
für reine Text-Aufgaben, (c) Kontext-/History-Limits setzen, (d) Thinking für den Agenten aus. **Rest-Tuning (mit dir, optional):** Hermes' erste Nachricht dauert noch ~6090 s wegen **34k Basis-
Das Brain steht auf `auto` (deine Vorgabe); das ist nicht die Ursache. 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 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. `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`. 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" /> <meta name="theme-color" content="#0d1117" />
<link rel="manifest" href="/manifest.webmanifest" /> <link rel="manifest" href="/manifest.webmanifest" />
<title>Mission Control 2.0</title> <title>Mission Control 2.0</title>
<script type="module" crossorigin src="/assets/index-Ch2rl17k.js"></script> <script type="module" crossorigin src="/assets/index-EHQnFJCd.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C-XbOjOw.css"> <link rel="stylesheet" crossorigin href="/assets/index-IpcnHTkp.css">
</head> </head>
<body> <body>
<div id="root"></div> <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() { function Installed() {
const [models, setModels] = useState<ModelInfo[]>([]) const [models, setModels] = useState<ModelInfo[]>([])
const [error, setError] = useState("") const [error, setError] = useState("")
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
useEffect(() => { function load() {
api<{ models: ModelInfo[] }>("/api/models") api<{ models: ModelInfo[] }>("/api/models")
.then((d) => setModels(d.models)) .then((d) => setModels(d.models))
.catch((e) => setError(String(e))) .catch((e) => setError(String(e)))
.finally(() => setLoading(false)) .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 (loading) return <div className="text-sm text-muted-foreground">Lade</div>
if (error) 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">Fähigkeiten</th>
<th className="px-4 py-2 font-medium">Kontext</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">Größe</th>
<th className="px-4 py-2 font-medium">Aktionen</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{models.length === 0 && ( {models.length === 0 && (
<tr> <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. Keine Modelle konfiguriert.
</td> </td>
</tr> </tr>
@@ -118,17 +142,31 @@ function Installed() {
<tr key={m.name} className="border-b border-border/50 last:border-0"> <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 font-medium">{m.name}</td>
<td className="px-4 py-2.5"> <td className="px-4 py-2.5">
{m.role ? ( <select
<span className="rounded-md bg-primary/15 px-2 py-0.5 text-xs text-primary">{m.role}</span> value={m.role || ""}
) : ( onChange={(e) => setRole(m.name, e.target.value)}
<span className="text-muted-foreground"></span> 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>
<td className="px-4 py-2.5"> <td className="px-4 py-2.5">
<CapsChips caps={m.capabilities} /> <CapsChips caps={m.capabilities} />
</td> </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 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> </tr>
))} ))}
</tbody> </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() { function Discover() {
const [data, setData] = useState<DiscoverResp | null>(null) const [data, setData] = useState<DiscoverResp | null>(null)
const [error, setError] = useState("") const [error, setError] = useState("")
@@ -173,6 +305,7 @@ function Discover() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<AddModel />
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
Live von HuggingFace · Hardware-Fit für ~{data.sys_ram_gb} GB · = beste Wahl je Kategorie Live von HuggingFace · Hardware-Fit für ~{data.sys_ram_gb} GB · = beste Wahl je Kategorie
</div> </div>