diff --git a/config.py b/config.py index 5cbecd6..9c8db37 100644 --- a/config.py +++ b/config.py @@ -6,6 +6,7 @@ damit es genau eine Quelle der Wahrheit fuer Pfade, URLs und Defaults gibt. """ import os +import sys from pathlib import Path 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")) 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). yaml = YAML() yaml.preserve_quotes = True diff --git a/routers/cookbook.py b/routers/cookbook.py index 6f26e54..b23edac 100644 --- a/routers/cookbook.py +++ b/routers/cookbook.py @@ -12,7 +12,7 @@ from ruamel.yaml.scalarstring import LiteralScalarString from auth import auth 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 jobengine import start_job, JOBS from recipes import RECIPES, UPGRADES @@ -149,7 +149,7 @@ def install_recipe(req: InstallRecipeReq): if not recipe: raise HTTPException(404, "Setup nicht gefunden.") ram_gb = psutil.virtual_memory().total / (1024 ** 3) - env = {"HF_XET_HIGH_PERFORMANCE": "1"} + env = dict(HF_DOWNLOAD_ENV) if req.hf_token: env["HF_TOKEN"] = req.hf_token cfg = read_config() @@ -160,7 +160,7 @@ def install_recipe(req: InstallRecipeReq): continue # kein GGUF im Repo gefunden -> Modell ueberspringen (Rest installiert trotzdem) target = MODELS_DIR / m["repo"].split("/")[-1] 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) JOBS[jid]["result_path"] = str(target / file) job_ids.append(jid) @@ -226,12 +226,12 @@ def install_model(req: InstallModelReq): if not file: raise HTTPException(404, "Keine GGUF-Datei im Repo gefunden.") ram_gb = psutil.virtual_memory().total / (1024 ** 3) - env = {"HF_XET_HIGH_PERFORMANCE": "1"} + env = dict(HF_DOWNLOAD_ENV) if req.hf_token: env["HF_TOKEN"] = req.hf_token target = MODELS_DIR / req.repo.split("/")[-1] 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) JOBS[jid]["result_path"] = str(target / file) cfg = read_config() diff --git a/routers/models.py b/routers/models.py index c581cd5..daac96a 100644 --- a/routers/models.py +++ b/routers/models.py @@ -13,26 +13,18 @@ from pydantic import BaseModel from ruamel.yaml.scalarstring import LiteralScalarString 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 llamaswap import _swap_get, read_config, write_config from hw_math import extract_params_b, max_ctx_for, estimate_memory_gb import re import os -import sys import psutil 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 # --------------------------------------------------------------------------- @@ -148,9 +140,8 @@ def download(req: DownloadReq): sub = req.subdir or req.repo.split("/")[-1] target = MODELS_DIR / sub target.mkdir(parents=True, exist_ok=True) - 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 = {"HF_HUB_DISABLE_XET": "1"} + args = [hf_bin(), "download", req.repo, req.file, "--local-dir", str(target)] + env = dict(HF_DOWNLOAD_ENV) if req.hf_token: env["HF_TOKEN"] = req.hf_token job_id = start_job(args, f"download {req.repo}/{req.file}", env=env) diff --git a/static/css/components.css b/static/css/components.css index 9b32edf..b4292a7 100644 --- a/static/css/components.css +++ b/static/css/components.css @@ -240,6 +240,7 @@ button:disabled{opacity:.5;cursor:not-allowed} color:var(--tx);font-family:var(--sans)} .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.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} .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)} diff --git a/static/js/panels/connect.js b/static/js/panels/connect.js index ced405e..8ce67d7 100644 --- a/static/js/panels/connect.js +++ b/static/js/panels/connect.js @@ -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 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) { return `
@@ -76,12 +98,17 @@ function render() {
- OpenCode (Windows) — Config + OpenCode — Config-Datei (JSON)
-

Lege in OpenCode einen OpenAI-kompatiblen Provider an:

- ${field("Basis-URL", url)} - ${field("Modell-ID", firstModel)} - ${field("API-Key", "(dein Token oder leer lassen)")} +

OpenCode richtest du nicht über Felder ein, sondern über eine Config-Datei. Lege sie hier an:

+

Windows: %USERPROFILE%\\.config\\opencode\\opencode.json
+ (oder im Projektordner als opencode.json)

+
+ +
${esc(openCodeConfig(url, ids))}
+
+

Danach in OpenCode /models tippen und llama-swap/${esc(firstModel)} wählen. Das Auto-Swapping lädt das passende Modell von selbst.

+
Fehler „Connect to API could not be established"? Dann zeigt die baseURL ins Leere. Sie muss auf die LAN-IP des Bosgame zeigen (oben: ${esc(url)}) — niemals localhost oder 127.0.0.1, denn das wäre dein eigener PC, nicht der Server.
diff --git a/static/js/panels/cookbook.js b/static/js/panels/cookbook.js index 08a84a7..ae331cd 100644 --- a/static/js/panels/cookbook.js +++ b/static/js/panels/cookbook.js @@ -18,6 +18,8 @@ let currentAnalysis = null; let activeFilter = ""; let RECIPES = []; 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 fitWord = lvl => lvl === "perfect" ? "Passt locker" : lvl === "marginal" ? "Wird knapp" : "Zu groß"; @@ -211,6 +213,7 @@ async function doSearch() { function renderResults(results) { const grid = $("#cb-grid"); + cardFits = []; if (!results?.length) { grid.innerHTML = `
Keine GGUF-Modelle gefunden.
`; return; } grid.innerHTML = results.map((m, 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 b = $("#cb-b-" + i), mt = $("#cb-m-" + i); 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]; b.className = "fit-badge " + fitCls(best.fit.level); b.textContent = best.fit.text; 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", `
★ Beste Wahl für dein System
`); } } function showFit() { diff --git a/static/js/panels/server.js b/static/js/panels/server.js index 02e10cb..53ba87c 100644 --- a/static/js/panels/server.js +++ b/static/js/panels/server.js @@ -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.")}
+ +

Live-Konsole