feat: Cookbook-Download-Fix, Live-Update-Konsole, Profi-Highlight, OpenCode-Guide

Backend:
- hf_bin() + HF_DOWNLOAD_ENV zentral in config.py; install-recipe und
  install-model in cookbook.py nutzten weiter das fehlende `hf` + falsches
  XET-Env -> Haupt-Installationsweg war ebenso kaputt, jetzt konsistent gefixt

Frontend:
- Server: laufende Updates (LLM-Engine/OS) live auf der Server-Seite mitlesbar,
  inkl. klarer Fertig-/Fehlgeschlagen-Meldung (onJobs + Aktueller-Vorgang-Karte)
- Cookbook/Profi-Modus: meistgeladenes, passendes Modell wird als
  "★ Beste Wahl für dein System" hervorgehoben (analog zu den Templates)
- Verbinden: OpenCode korrekt per opencode.json (@ai-sdk/openai-compatible)
  statt irrefuehrender Felder; erklaert "Connect to API"-Fehler (LAN-IP statt
  localhost)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-21 18:03:12 +02:00
parent f07a8440b2
commit 720b47a6e1
7 changed files with 125 additions and 28 deletions
+14
View File
@@ -6,6 +6,7 @@ damit es genau eine Quelle der Wahrheit fuer Pfade, URLs und Defaults gibt.
""" """
import os import os
import sys
from pathlib import Path from pathlib import Path
from ruamel.yaml import YAML from ruamel.yaml import YAML
@@ -33,6 +34,19 @@ TOKEN = os.environ.get("MC_TOKEN", "") # leer = keine Auth (nur LAN!)
SOURCE_DIR = os.path.expanduser(os.environ.get("MC_SOURCE_DIR", "~/mission-control")) SOURCE_DIR = os.path.expanduser(os.environ.get("MC_SOURCE_DIR", "~/mission-control"))
PROD_DIR = str(Path(__file__).resolve().parent) PROD_DIR = str(Path(__file__).resolve().parent)
# Env fuer alle HuggingFace-Downloads: XET deaktivieren (mit aktivem XET haengt der
# Download reproduzierbar bei ~6 MB, siehe CLAUDE.md). Token wird je Job ergaenzt.
HF_DOWNLOAD_ENV = {"HF_HUB_DISABLE_XET": "1"}
def hf_bin() -> str:
"""Pfad zur `hf`-CLI fuer Modell-Downloads. Bevorzugt die im venv installierte
(neben dem laufenden Python), da der Dienst-PATH das venv/bin meist nicht
enthaelt. Faellt sonst auf ein global installiertes `hf` zurueck."""
cand = os.path.join(os.path.dirname(sys.executable), "hf")
return cand if os.path.exists(cand) else "hf"
# Gemeinsame YAML-Instanz (preserve_quotes haelt Kommentare/Quotes in config.yaml). # Gemeinsame YAML-Instanz (preserve_quotes haelt Kommentare/Quotes in config.yaml).
yaml = YAML() yaml = YAML()
yaml.preserve_quotes = True yaml.preserve_quotes = True
+5 -5
View File
@@ -12,7 +12,7 @@ from ruamel.yaml.scalarstring import LiteralScalarString
from auth import auth from auth import auth
from hw_math import evaluate_fit, max_ctx_for from hw_math import evaluate_fit, max_ctx_for
from config import MODELS_DIR, CMD_TEMPLATE, DEFAULT_TTL from config import MODELS_DIR, CMD_TEMPLATE, DEFAULT_TTL, HF_DOWNLOAD_ENV, hf_bin
from llamaswap import read_config, write_config from llamaswap import read_config, write_config
from jobengine import start_job, JOBS from jobengine import start_job, JOBS
from recipes import RECIPES, UPGRADES from recipes import RECIPES, UPGRADES
@@ -149,7 +149,7 @@ def install_recipe(req: InstallRecipeReq):
if not recipe: if not recipe:
raise HTTPException(404, "Setup nicht gefunden.") raise HTTPException(404, "Setup nicht gefunden.")
ram_gb = psutil.virtual_memory().total / (1024 ** 3) ram_gb = psutil.virtual_memory().total / (1024 ** 3)
env = {"HF_XET_HIGH_PERFORMANCE": "1"} env = dict(HF_DOWNLOAD_ENV)
if req.hf_token: if req.hf_token:
env["HF_TOKEN"] = req.hf_token env["HF_TOKEN"] = req.hf_token
cfg = read_config() cfg = read_config()
@@ -160,7 +160,7 @@ def install_recipe(req: InstallRecipeReq):
continue # kein GGUF im Repo gefunden -> Modell ueberspringen (Rest installiert trotzdem) continue # kein GGUF im Repo gefunden -> Modell ueberspringen (Rest installiert trotzdem)
target = MODELS_DIR / m["repo"].split("/")[-1] target = MODELS_DIR / m["repo"].split("/")[-1]
target.mkdir(parents=True, exist_ok=True) target.mkdir(parents=True, exist_ok=True)
args = ["hf", "download", m["repo"], file, "--local-dir", str(target)] args = [hf_bin(), "download", m["repo"], file, "--local-dir", str(target)]
jid = start_job(args, f"download {m['name']}", env=env) jid = start_job(args, f"download {m['name']}", env=env)
JOBS[jid]["result_path"] = str(target / file) JOBS[jid]["result_path"] = str(target / file)
job_ids.append(jid) job_ids.append(jid)
@@ -226,12 +226,12 @@ def install_model(req: InstallModelReq):
if not file: if not file:
raise HTTPException(404, "Keine GGUF-Datei im Repo gefunden.") raise HTTPException(404, "Keine GGUF-Datei im Repo gefunden.")
ram_gb = psutil.virtual_memory().total / (1024 ** 3) ram_gb = psutil.virtual_memory().total / (1024 ** 3)
env = {"HF_XET_HIGH_PERFORMANCE": "1"} env = dict(HF_DOWNLOAD_ENV)
if req.hf_token: if req.hf_token:
env["HF_TOKEN"] = req.hf_token env["HF_TOKEN"] = req.hf_token
target = MODELS_DIR / req.repo.split("/")[-1] target = MODELS_DIR / req.repo.split("/")[-1]
target.mkdir(parents=True, exist_ok=True) target.mkdir(parents=True, exist_ok=True)
jid = start_job(["hf", "download", req.repo, file, "--local-dir", str(target)], jid = start_job([hf_bin(), "download", req.repo, file, "--local-dir", str(target)],
f"download {req.repo.split('/')[-1]}", env=env) f"download {req.repo.split('/')[-1]}", env=env)
JOBS[jid]["result_path"] = str(target / file) JOBS[jid]["result_path"] = str(target / file)
cfg = read_config() cfg = read_config()
+4 -13
View File
@@ -13,26 +13,18 @@ from pydantic import BaseModel
from ruamel.yaml.scalarstring import LiteralScalarString from ruamel.yaml.scalarstring import LiteralScalarString
from auth import auth from auth import auth
from config import CMD_TEMPLATE, CONFIG_PATH, DEFAULT_TTL, LLAMA_SWAP_URL, MODELS_DIR, TOKEN from config import (CMD_TEMPLATE, CONFIG_PATH, DEFAULT_TTL, HF_DOWNLOAD_ENV, LLAMA_SWAP_URL,
MODELS_DIR, TOKEN, hf_bin)
from jobengine import JOBS, start_job from jobengine import JOBS, start_job
from llamaswap import _swap_get, read_config, write_config from llamaswap import _swap_get, read_config, write_config
from hw_math import extract_params_b, max_ctx_for, estimate_memory_gb from hw_math import extract_params_b, max_ctx_for, estimate_memory_gb
import re import re
import os import os
import sys
import psutil import psutil
router = APIRouter(prefix="/api", dependencies=[Depends(auth)]) router = APIRouter(prefix="/api", dependencies=[Depends(auth)])
def _hf_bin() -> str:
"""Pfad zur `hf`-CLI. Bevorzugt die im venv installierte (neben dem laufenden
Python), da der Dienst-PATH das venv/bin meist nicht enthaelt. Faellt sonst auf
ein global installiertes `hf` zurueck."""
cand = os.path.join(os.path.dirname(sys.executable), "hf")
return cand if os.path.exists(cand) else "hf"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Request-Modelle # Request-Modelle
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -148,9 +140,8 @@ def download(req: DownloadReq):
sub = req.subdir or req.repo.split("/")[-1] sub = req.subdir or req.repo.split("/")[-1]
target = MODELS_DIR / sub target = MODELS_DIR / sub
target.mkdir(parents=True, exist_ok=True) target.mkdir(parents=True, exist_ok=True)
args = [_hf_bin(), "download", req.repo, req.file, "--local-dir", str(target)] args = [hf_bin(), "download", req.repo, req.file, "--local-dir", str(target)]
# XET deaktivieren: mit aktivem XET haengt der Download reproduzierbar bei ~6 MB (siehe CLAUDE.md). env = dict(HF_DOWNLOAD_ENV)
env = {"HF_HUB_DISABLE_XET": "1"}
if req.hf_token: if req.hf_token:
env["HF_TOKEN"] = req.hf_token env["HF_TOKEN"] = req.hf_token
job_id = start_job(args, f"download {req.repo}/{req.file}", env=env) job_id = start_job(args, f"download {req.repo}/{req.file}", env=env)
+1
View File
@@ -240,6 +240,7 @@ button:disabled{opacity:.5;cursor:not-allowed}
color:var(--tx);font-family:var(--sans)} color:var(--tx);font-family:var(--sans)}
.card-btn:hover,.card-btn:focus{border-color:var(--accent);background:var(--bg2);outline:none} .card-btn:hover,.card-btn:focus{border-color:var(--accent);background:var(--bg2);outline:none}
.card-btn.cb-best{border:2px solid var(--accent);background:rgba(45,212,191,.05)} .card-btn.cb-best{border:2px solid var(--accent);background:rgba(45,212,191,.05)}
.card.res-best{border:2px solid var(--accent);background:rgba(45,212,191,.05)}
.cb-best-tag{display:inline-block;font-size:11px;color:var(--accent-ink);background:var(--accent);border-radius:6px;padding:2px 9px;margin-bottom:8px;font-weight:500} .cb-best-tag{display:inline-block;font-size:11px;color:var(--accent-ink);background:var(--accent);border-radius:6px;padding:2px 9px;margin-bottom:8px;font-weight:500}
.card-btn h3{margin:0;font-size:var(--text-lg);font-weight:500} .card-btn h3{margin:0;font-size:var(--text-lg);font-weight:500}
.card-btn p{margin:var(--sp-2) 0 0;font-size:var(--text-sm);color:var(--mut)} .card-btn p{margin:var(--sp-2) 0 0;font-size:var(--text-sm);color:var(--mut)}
+32 -5
View File
@@ -21,6 +21,28 @@ window.cnGo = v => document.querySelector(`.nav-item[data-view="${v}"]`)?.click(
function baseUrl() { return `${location.protocol}//${location.hostname}:8080/v1`; } function baseUrl() { return `${location.protocol}//${location.hostname}:8080/v1`; }
function models() { return S?.models || []; } function models() { return S?.models || []; }
// Fertige opencode.json fuer einen OpenAI-kompatiblen Provider auf llama-swap.
function openCodeConfig(url, ids) {
const list = ids.length ? ids : ["coder"];
const modelMap = list.map(id => ` "${id}": { "name": "${id}" }`).join(",\n");
return `{
"$schema": "https://opencode.ai/config.json",
"provider": {
"llama-swap": {
"npm": "@ai-sdk/openai-compatible",
"name": "Bosgame (llama-swap)",
"options": {
"baseURL": "${url}",
"apiKey": "local"
},
"models": {
${modelMap}
}
}
}
}`;
}
function field(label, value) { function field(label, value) {
return `<label>${label}</label> return `<label>${label}</label>
<div class="flex gap-2" style="align-items:stretch;margin-bottom:12px"> <div class="flex gap-2" style="align-items:stretch;margin-bottom:12px">
@@ -76,12 +98,17 @@ function render() {
<div class="card" style="padding:0;overflow:hidden"> <div class="card" style="padding:0;overflow:hidden">
<details class="guide-acc" open> <details class="guide-acc" open>
<summary>OpenCode (Windows) — Config</summary> <summary>OpenCode — Config-Datei (JSON)</summary>
<div class="acc-body"> <div class="acc-body">
<p>Lege in OpenCode einen OpenAI-kompatiblen Provider an:</p> <p>OpenCode richtest du nicht über Felder ein, sondern über eine <b>Config-Datei</b>. Lege sie hier an:</p>
${field("Basis-URL", url)} <p class="mono-sm" style="line-height:1.6">Windows: <code>%USERPROFILE%\\.config\\opencode\\opencode.json</code><br>
${field("Modell-ID", firstModel)} (oder im Projektordner als <code>opencode.json</code>)</p>
${field("API-Key", "(dein Token oder leer lassen)")} <div style="position:relative;margin-top:12px">
<button class="ghost" style="position:absolute;top:8px;right:8px;z-index:1" onclick="window.mcCopyCode(this)">Kopieren</button>
<div class="log" style="max-height:none"><code>${esc(openCodeConfig(url, ids))}</code></div>
</div>
<p style="margin-top:12px">Danach in OpenCode <code>/models</code> tippen und <b>llama-swap/${esc(firstModel)}</b> wählen. Das Auto-Swapping lädt das passende Modell von selbst.</p>
<div class="hint" style="margin-top:8px"><b>Fehler „Connect to API could not be established"?</b> Dann zeigt die <code>baseURL</code> ins Leere. Sie muss auf die <b>LAN-IP des Bosgame</b> zeigen (oben: <code>${esc(url)}</code>) — niemals <code>localhost</code> oder <code>127.0.0.1</code>, denn das wäre dein eigener PC, nicht der Server.</div>
</div> </div>
</details> </details>
<details class="guide-acc"> <details class="guide-acc">
+22 -2
View File
@@ -18,6 +18,8 @@ let currentAnalysis = null;
let activeFilter = ""; let activeFilter = "";
let RECIPES = []; let RECIPES = [];
let RECOMMENDED = null; let RECOMMENDED = null;
let cardFits = []; // Fit-Level je Such-Karte (fuer „Beste Wahl"-Markierung)
const FIT_RANK = { perfect: 0, marginal: 1, too_tight: 2 };
const fitCls = lvl => lvl === "perfect" ? "ok" : lvl === "marginal" ? "warn" : "bad"; const fitCls = lvl => lvl === "perfect" ? "ok" : lvl === "marginal" ? "warn" : "bad";
const fitWord = lvl => lvl === "perfect" ? "Passt locker" : lvl === "marginal" ? "Wird knapp" : "Zu groß"; const fitWord = lvl => lvl === "perfect" ? "Passt locker" : lvl === "marginal" ? "Wird knapp" : "Zu groß";
@@ -211,6 +213,7 @@ async function doSearch() {
function renderResults(results) { function renderResults(results) {
const grid = $("#cb-grid"); const grid = $("#cb-grid");
cardFits = [];
if (!results?.length) { grid.innerHTML = `<div class="empty" style="grid-column:1/-1;text-align:center">Keine GGUF-Modelle gefunden.</div>`; return; } if (!results?.length) { grid.innerHTML = `<div class="empty" style="grid-column:1/-1;text-align:center">Keine GGUF-Modelle gefunden.</div>`; return; }
grid.innerHTML = results.map((m, i) => ` grid.innerHTML = results.map((m, i) => `
<div class="card" style="display:flex;flex-direction:column;cursor:pointer" data-res="${i}"> <div class="card" style="display:flex;flex-direction:column;cursor:pointer" data-res="${i}">
@@ -233,11 +236,28 @@ async function fetchFitForCard(i, repo_id) {
const res = await api("/api/cookbook/analyze", { method: "POST", body: JSON.stringify({ repo_id, ctx: 8192 }) }); const res = await api("/api/cookbook/analyze", { method: "POST", body: JSON.stringify({ repo_id, ctx: 8192 }) });
const b = $("#cb-b-" + i), mt = $("#cb-m-" + i); const b = $("#cb-b-" + i), mt = $("#cb-m-" + i);
if (!b || !mt) return; if (!b || !mt) return;
if (!res.files?.length) { b.className = "fit-badge"; b.style.cssText = "background:rgba(139,151,165,.14);color:var(--mut)"; b.title = GGUF_HELP; b.textContent = "kein GGUF"; mt.textContent = "—"; return; } if (!res.files?.length) { b.className = "fit-badge"; b.style.cssText = "background:rgba(139,151,165,.14);color:var(--mut)"; b.title = GGUF_HELP; b.textContent = "kein GGUF"; mt.textContent = "—"; cardFits[i] = null; markBestResult(); return; }
let best = res.files.find(f => f.quant?.includes("Q4_K_M")) || res.files[0]; let best = res.files.find(f => f.quant?.includes("Q4_K_M")) || res.files[0];
b.className = "fit-badge " + fitCls(best.fit.level); b.textContent = best.fit.text; b.className = "fit-badge " + fitCls(best.fit.level); b.textContent = best.fit.text;
mt.textContent = metricLine(best.fit) + " · " + (best.quant || "GGUF"); mt.textContent = metricLine(best.fit) + " · " + (best.quant || "GGUF");
} catch { const b = $("#cb-b-" + i); if (b) { b.className = "fit-badge bad"; b.textContent = "Fehler"; } } cardFits[i] = best.fit.level; markBestResult();
} catch { const b = $("#cb-b-" + i); if (b) { b.className = "fit-badge bad"; b.textContent = "Fehler"; } cardFits[i] = null; markBestResult(); }
}
// Markiert die beste System-Wahl in den Suchergebnissen: das meistgeladene Modell
// (Liste ist nach Downloads sortiert), das noch sauber auf die Hardware passt.
function markBestResult() {
const grid = $("#cb-grid"); if (!grid) return;
let bestI = -1, bestRank = 99;
cardFits.forEach((lvl, i) => {
if (lvl == null) return;
const rank = FIT_RANK[lvl] ?? 99;
if (rank < bestRank) { bestRank = rank; bestI = i; }
});
grid.querySelectorAll("[data-res]").forEach(el => { el.classList.remove("res-best"); el.querySelector(".cb-best-tag")?.remove(); });
if (bestI < 0 || bestRank > 1) return; // nur hervorheben, wenn etwas Lauffähiges (perfect/marginal) dabei ist
const el = grid.querySelector(`[data-res="${bestI}"]`);
if (el) { el.classList.add("res-best"); el.insertAdjacentHTML("afterbegin", `<div class="cb-best-tag">★ Beste Wahl für dein System</div>`); }
} }
function showFit() { function showFit() {
+47 -3
View File
@@ -38,6 +38,12 @@ function mount() {
${row("w-reboot", "bolt", "red", "Server neustarten (Reboot)", "Bootet den ganzen Bosgame neu. Alles ist für ~1 Minute offline, auch dieses Dashboard.")} ${row("w-reboot", "bolt", "red", "Server neustarten (Reboot)", "Bootet den ganzen Bosgame neu. Alles ist für ~1 Minute offline, auch dieses Dashboard.")}
</div> </div>
<div class="card" id="w-update-card" style="display:none">
<div class="card-h"><h3>Aktueller Vorgang</h3><span id="w-update-badge" style="margin-left:auto"></span></div>
<div class="card-sub" id="w-update-label">—</div>
<div id="w-update-log" class="log" style="max-height:280px"></div>
</div>
<div class="card"> <div class="card">
<div class="card-h"><h3>Live-Konsole</h3> <div class="card-h"><h3>Live-Konsole</h3>
<select id="w-console-sel" style="margin:0 0 0 auto;width:240px"> <select id="w-console-sel" style="margin:0 0 0 auto;width:240px">
@@ -93,8 +99,46 @@ async function selfUpdate() {
catch (e) { toast(e.message, true); } catch (e) { toast(e.message, true); }
} }
// ---- Live-Verlauf laufender Vorgaenge (Updates) direkt auf der Server-Seite ----
let activeJob = null; // { id, label }
const notified = new Set(); // Job-IDs, fuer die schon eine Abschluss-Meldung kam
function watchJob(id, label) {
activeJob = { id, label };
const card = $("#w-update-card");
if (!card) return;
card.style.display = "";
$("#w-update-label").textContent = label + " läuft…";
$("#w-update-badge").innerHTML = '<span class="badge b-load">läuft…</span>';
$("#w-update-log").textContent = "Starte…";
card.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
function onJobs(jobs) {
if (!activeJob) return;
const j = (jobs || []).find(x => x.id === activeJob.id);
const card = $("#w-update-card");
if (!j || !card) return;
card.style.display = "";
const logEl = $("#w-update-log");
if (logEl) { logEl.textContent = (j.log || []).join("\n"); logEl.scrollTop = logEl.scrollHeight; }
const badge = $("#w-update-badge");
const done = j.state === "done", failed = j.state === "failed";
if (badge) badge.innerHTML = done ? '<span class="badge b-run">✓ fertig</span>'
: failed ? '<span class="badge b-err">✗ fehlgeschlagen</span>'
: '<span class="badge b-load">läuft…</span>';
if (done || failed) {
$("#w-update-label").textContent = activeJob.label + (done ? " — erfolgreich abgeschlossen." : " — fehlgeschlagen (siehe Protokoll).");
if (!notified.has(j.id)) {
notified.add(j.id);
toast(done ? "✓ " + activeJob.label + " abgeschlossen." : "✗ " + activeJob.label + " fehlgeschlagen.", failed);
if (done) setTimeout(refreshSoon, 800);
}
}
}
async function update() { async function update() {
try { const r = await api("/api/update", { method: "POST" }); toast("llama.cpp-Update läuft — siehe Aktivität."); track(r.job_id); } try { const r = await api("/api/update", { method: "POST" }); toast("LLM-Engine-Update läuft."); track(r.job_id); watchJob(r.job_id, "LLM-Engine-Update"); }
catch (e) { toast(e.message, true); } catch (e) { toast(e.message, true); }
} }
@@ -102,7 +146,7 @@ async function osUpdate() {
if (!await confirmModal({ title: "OS-Updates installieren?", body: "Führt <code>apt update &amp; upgrade</code> aus. Das kann ein paar Minuten dauern; der Fortschritt erscheint in der Aktivität." })) return; if (!await confirmModal({ title: "OS-Updates installieren?", body: "Führt <code>apt update &amp; upgrade</code> aus. Das kann ein paar Minuten dauern; der Fortschritt erscheint in der Aktivität." })) return;
const pwd = await promptModal({ title: "sudo-Passwort", body: "Für die System-Updates wird einmalig dein sudo-Passwort gebraucht.", placeholder: "sudo-Passwort", password: true, confirmLabel: "Installieren" }); const pwd = await promptModal({ title: "sudo-Passwort", body: "Für die System-Updates wird einmalig dein sudo-Passwort gebraucht.", placeholder: "sudo-Passwort", password: true, confirmLabel: "Installieren" });
if (!pwd) return; if (!pwd) return;
try { const r = await api("/api/os-update", { method: "POST", body: JSON.stringify({ password: pwd }) }); toast("OS-Update gestartet."); track(r.job_id); } try { const r = await api("/api/os-update", { method: "POST", body: JSON.stringify({ password: pwd }) }); toast("OS-Update gestartet."); track(r.job_id); watchJob(r.job_id, "OS-Update"); }
catch (e) { toast(e.message, true); } catch (e) { toast(e.message, true); }
} }
@@ -114,4 +158,4 @@ async function rebootServer() {
catch (e) { toast(e.message, true); } catch (e) { toast(e.message, true); }
} }
export default { id: "server", mount }; export default { id: "server", mount, onJobs };