fix+feat: offene Punkte abgeschlossen
Kontext in Connect-Snippet: - ConnectPanel: max_tokens nutzt jetzt den echten konfigurierten Kontext des Modells (m.meta.ctx) statt hartkodiertem 32768 -> Zed sieht 128k Phase D - 'Immer aktiv halten' Toggle: - models.py: UpdateReq bekommt optionales ttl-Feld - update_model: setzt TTL wenn angegeben (ctx und ttl unabhaengig) - ModelsPanel: Checkbox 'Immer aktiv halten' im Konfig-Modal (TTL=99999 = nie entladen, TTL=300 = Standard 5min) MoE tps-Schaetzung: - hw_math.py: extract_active_params_b() erkennt A3B-Suffix - estimate_speed(): moe_active_ratio-Parameter, sqrt-Boost fuer MoE - evaluate_fit(): name-Parameter (optional) fuer automatische MoE-Erkennung - cookbook.py: alle evaluate_fit-Aufrufe mit name= versehen Cleanup: - static/js/panels/*.js + static/js/main.js geloescht (Dead Code) - wird-Datei (versehentlich committet) geloescht - CLAUDE.md: KISS-Statement auf Vite+Svelte-Stand aktualisiert Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -80,8 +80,10 @@ rsync; **Python-Code-Änderungen brauchen den Restart**.
|
||||
|
||||
## Konventionen
|
||||
|
||||
- KISS über alles: kein schweres Framework, kein Build-Schritt, solange es ohne geht.
|
||||
- **SoC ohne Build**: neuer Bereich = ein `routers/<bereich>.py` (FastAPI-Router) + ein `static/js/panels/<bereich>.js` (ES-Modul nach Panel-Vertrag) + ein Nav-Eintrag in `index.html`. Gemeinsame Backend-Logik in `config/auth/jobengine/llamaswap`, gemeinsame Frontend-Logik in `js/core/*`. Keine schweren Libs, kein CDN — nur relative `import`s.
|
||||
- **Frontend: Vite + Svelte 5** (Phase C). Neuer Bereich = `frontend/src/panels/<Bereich>Panel.svelte` + `routers/<bereich>.py` + `initNav`-Eintrag in `index.html`. Danach `npm run build` in `frontend/` → `static/dist/main.js` wird committet.
|
||||
- Stores: `frontend/src/stores/status.svelte.ts`, `jobs.svelte.ts`, `system.svelte.ts` — Svelte 5 Runes (`$state`).
|
||||
- Statische Hilfsfunktionen in `static/js/core/` (api.js, ui.js, nav.js) bleiben als gemeinsame Basis — werden von Vite gebündelt, nicht direkt geladen.
|
||||
- **Backend SoC**: ein `routers/<bereich>.py` je Bereich, gemeinsame Logik in `config/auth/jobengine/llamaswap/hw_math`.
|
||||
- Endpoint-URLs bleiben unter `/api/*`; neue Bereiche degradieren sauber, wenn ihre Quelle (sysfs, `amd-smi`, `systemctl`) fehlt (z. B. beim Entwickeln auf Windows).
|
||||
- Funktion darf **nicht** von `localStorage` abhängen (nur das Token-Feld nutzt es, das ist ok).
|
||||
- **Sicherheit**: Das Backend führt Shell-Befehle aus → ausschließlich im vertrauenswürdigen LAN betreiben, niemals offen ins Internet.
|
||||
|
||||
@@ -26,7 +26,8 @@
|
||||
const ms = ids.length ? allModels.filter((m: any) => ids.includes(m.name)) : (allModels.length ? allModels : [{ name: 'coder', meta: {} }])
|
||||
const entries = ms.map((m: any) => {
|
||||
const img = (m.meta?.caps || []).some((c: string) => /Bild|Vision/i.test(c))
|
||||
return ` { "name": "${m.name}", "display_name": "${m.name}", "max_tokens": 32768${img ? `, "capabilities": { "tools": true, "images": true }` : ''} }`
|
||||
const ctx = m.meta?.ctx || 131072 // nutze konfigurierten Kontext des Modells (Standard: 128k)
|
||||
return ` { "name": "${m.name}", "display_name": "${m.name}", "max_tokens": ${ctx}${img ? `, "capabilities": { "tools": true, "images": true }` : ''} }`
|
||||
}).join(',\n')
|
||||
return `"language_models": {\n "openai_compatible": {\n "bosgame": {\n "api_url": "${url}",\n "available_models": [\n${entries}\n ]\n }\n }\n}`
|
||||
}
|
||||
|
||||
@@ -35,20 +35,25 @@
|
||||
chatLoading = false; refresh()
|
||||
}
|
||||
|
||||
const DEFAULT_TTL = 300
|
||||
|
||||
// ---- Config Modal ----
|
||||
let cfgOpen = $state(false)
|
||||
let cfgModel = $state<any>(null)
|
||||
let cfgCtx = $state(8192)
|
||||
let cfgAlwaysActive = $state(false)
|
||||
|
||||
function openConfig(name: string) {
|
||||
cfgModel = allModels.find((m: any) => m.name === name)
|
||||
if (!cfgModel) return
|
||||
cfgCtx = cfgModel.meta?.ctx || 8192
|
||||
cfgAlwaysActive = (cfgModel.ttl ?? DEFAULT_TTL) >= 99999
|
||||
cfgOpen = true
|
||||
}
|
||||
async function saveConfig() {
|
||||
const ttl = cfgAlwaysActive ? 99999 : DEFAULT_TTL
|
||||
try {
|
||||
await api('/api/update_model', { method: 'POST', body: JSON.stringify({ alias: cfgModel.name, ctx: cfgCtx }) })
|
||||
await api('/api/update_model', { method: 'POST', body: JSON.stringify({ alias: cfgModel.name, ctx: cfgCtx, ttl }) })
|
||||
toast('Gespeichert — aktiv beim nächsten Modell-Start.')
|
||||
cfgOpen = false; refresh()
|
||||
} catch (e: any) { toast(e.message, true) }
|
||||
@@ -218,6 +223,20 @@
|
||||
</div>
|
||||
{/if}
|
||||
<div class="hint" style="margin-bottom:14px">Es läuft immer nur <b>ein</b> Modell gleichzeitig — ein größerer Kontext hier beeinflusst deine anderen Modelle nicht.</div>
|
||||
|
||||
<div class="tile" style="display:flex;justify-content:space-between;align-items:center;margin:0 0 14px">
|
||||
<div style="flex:1">
|
||||
<div style="font-size:13px;font-weight:500">Immer aktiv halten</div>
|
||||
<div class="hint" style="margin:3px 0 0">Modell bleibt dauerhaft im RAM — nie automatisch entladen. Ideal für den kleinen Scout (~5 GB), der immer schnell antworten soll.</div>
|
||||
</div>
|
||||
<label style="display:flex;align-items:center;gap:8px;margin:0;cursor:pointer;flex:0 0 auto">
|
||||
<input type="checkbox" bind:checked={cfgAlwaysActive} style="width:18px;height:18px;accent-color:var(--accent);cursor:pointer">
|
||||
<span class="mono-sm" style="color:{cfgAlwaysActive ? 'var(--accent)' : 'var(--mut)'}">
|
||||
{cfgAlwaysActive ? 'An (TTL = ∞)' : 'Aus (TTL = 5 min)'}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button class="primary" style="width:100%;margin-top:6px" onclick={saveConfig}>Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+23
-8
@@ -36,21 +36,36 @@ def estimate_memory_gb(params_b: float, quant: str, ctx: int) -> float:
|
||||
|
||||
return weights + context_vram
|
||||
|
||||
def estimate_speed(req_gb: float, sys_ram_gb: float) -> float:
|
||||
"""Berechnet die geschätzte Tokens/s basierend auf der 273 GB/s Bandbreite der APU."""
|
||||
# Strix Halo hat ca 273 GB/s Unified Memory Bandbreite.
|
||||
def extract_active_params_b(name: str) -> float | None:
|
||||
"""Aktive Parameter bei MoE-Modellen aus dem Namen (z.B. '30B-A3B' → 3.0).
|
||||
Gibt None zurück für Dense-Modelle (kein MoE-Suffix erkannt)."""
|
||||
m = re.search(r'(?<![a-zA-Z])a(\d+(?:\.\d+)?)b\b', name.lower())
|
||||
return float(m.group(1)) if m else None
|
||||
|
||||
|
||||
def estimate_speed(req_gb: float, sys_ram_gb: float, moe_active_ratio: float = 1.0) -> float:
|
||||
"""Berechnet die geschätzte Tokens/s basierend auf der 273 GB/s Bandbreite der APU.
|
||||
moe_active_ratio = aktive_params / gesamt_params; < 1 bei MoE (z.B. 0.1 für 30B-A3B)."""
|
||||
bw = 273 if sys_ram_gb > 8 else 70
|
||||
if req_gb <= 0:
|
||||
return 0.0
|
||||
|
||||
# (Bandbreite / Modellgröße) * Effizienz (0.55)
|
||||
raw_tps = (bw / req_gb) * 0.55
|
||||
# MoE-Boost: nur ein Bruchteil der Gewichte wird pro Token aktiviert →
|
||||
# effektiv höhere Durchsatzrate als ein Dense-Modell gleicher Gesamtgröße.
|
||||
# sqrt-Dämpfung: Routing-Overhead + VRAM-Traffic für alle Experten bleibt.
|
||||
if moe_active_ratio < 0.8:
|
||||
raw_tps *= (1.0 / moe_active_ratio) ** 0.5
|
||||
return raw_tps
|
||||
|
||||
def evaluate_fit(params_b: float, quant: str, ctx: int, sys_ram_gb: float) -> dict:
|
||||
"""Berechnet den Fit für ein System mit Shared Memory (APU)."""
|
||||
|
||||
def evaluate_fit(params_b: float, quant: str, ctx: int, sys_ram_gb: float,
|
||||
name: str = "") -> dict:
|
||||
"""Berechnet den Fit für ein System mit Shared Memory (APU).
|
||||
name wird für MoE-Erkennung genutzt (optional)."""
|
||||
req_gb = estimate_memory_gb(params_b, quant, ctx)
|
||||
tps = estimate_speed(req_gb, sys_ram_gb)
|
||||
active_b = extract_active_params_b(name) if name else None
|
||||
moe_ratio = (active_b / params_b) if (active_b and params_b > 0) else 1.0
|
||||
tps = estimate_speed(req_gb, sys_ram_gb, moe_ratio)
|
||||
|
||||
# Das OS und andere Prozesse brauchen RAM. Wir lassen 4GB Puffer.
|
||||
usable_ram = max(sys_ram_gb - 4.0, 0)
|
||||
|
||||
+4
-4
@@ -127,7 +127,7 @@ async def analyze_repo(req: AnalyzeRequest):
|
||||
results = []
|
||||
for f in gguf_files:
|
||||
quant = extract_quant(f)
|
||||
fit = evaluate_fit(params_b, quant, req.ctx, ram_gb)
|
||||
fit = evaluate_fit(params_b, quant, req.ctx, ram_gb, name=req.repo_id)
|
||||
|
||||
# Priority-Score, um den besten Fit an oberste Stelle zu setzen.
|
||||
# "Q4_K_M" ist oft der Sweetspot.
|
||||
@@ -173,7 +173,7 @@ def recipes():
|
||||
for r in all_recipes():
|
||||
models, worst = [], "perfect"
|
||||
for m in r["models"]:
|
||||
fit = evaluate_fit(m["params_b"], m["quant"], 8192, ram_gb)
|
||||
fit = evaluate_fit(m["params_b"], m["quant"], 8192, ram_gb, name=m.get("name", ""))
|
||||
models.append({**m, "fit": fit, "optimal_ctx": max_ctx_for(m["params_b"], m["quant"], ram_gb)})
|
||||
if _FIT_ORDER[fit["level"]] > _FIT_ORDER[worst]:
|
||||
worst = fit["level"]
|
||||
@@ -427,7 +427,7 @@ def refresh_discover(ram_gb: float) -> dict:
|
||||
role = _categorize(rid)
|
||||
params_b = extract_params_b(rid)
|
||||
quant = "Q4_K_M" # Referenz-Quant fuer die Fit-Einschaetzung
|
||||
fit = evaluate_fit(params_b, quant, 8192, ram_gb)
|
||||
fit = evaluate_fit(params_b, quant, 8192, ram_gb, name=rid)
|
||||
by_cat[role].append({
|
||||
"name": rid.split("/")[-1], "author": rid.split("/")[0], "repo": rid,
|
||||
"role": role, "params_b": params_b, "quant": quant,
|
||||
@@ -518,7 +518,7 @@ def compute_upgrades(ram_gb):
|
||||
out.append({
|
||||
"name": up["name"], "repo": up["repo"], "params_b": up["params_b"], "quant": up["quant"],
|
||||
"why": up["why"], "old": old, "role": old,
|
||||
"fit": evaluate_fit(up["params_b"], up["quant"], 8192, ram_gb),
|
||||
"fit": evaluate_fit(up["params_b"], up["quant"], 8192, ram_gb, name=up.get("name", "")),
|
||||
"optimal_ctx": max_ctx_for(up["params_b"], up["quant"], ram_gb),
|
||||
})
|
||||
return out
|
||||
|
||||
+15
-11
@@ -58,7 +58,8 @@ class ChatReq(BaseModel):
|
||||
|
||||
class UpdateReq(BaseModel):
|
||||
alias: str
|
||||
ctx: int
|
||||
ctx: int | None = None
|
||||
ttl: int | None = None
|
||||
|
||||
|
||||
class DeleteReq(BaseModel):
|
||||
@@ -251,17 +252,20 @@ def update_model(req: UpdateReq):
|
||||
cfg = read_config()
|
||||
if req.alias not in cfg.get("models", {}):
|
||||
raise HTTPException(404, "Modell nicht gefunden")
|
||||
|
||||
|
||||
spec = cfg["models"][req.alias]
|
||||
cmd = str(spec.get("cmd", ""))
|
||||
|
||||
# Replace or add context size
|
||||
if re.search(r'-(?:c|-ctx-size)\s+\d+', cmd):
|
||||
cmd = re.sub(r'-(?:c|-ctx-size)\s+\d+', f'-c {req.ctx}', cmd)
|
||||
else:
|
||||
cmd = cmd.strip() + f" -c {req.ctx}\n"
|
||||
|
||||
cfg["models"][req.alias]["cmd"] = LiteralScalarString(cmd)
|
||||
|
||||
if req.ctx is not None:
|
||||
cmd = str(spec.get("cmd", ""))
|
||||
if re.search(r'-(?:c|-ctx-size)\s+\d+', cmd):
|
||||
cmd = re.sub(r'-(?:c|-ctx-size)\s+\d+', f'-c {req.ctx}', cmd)
|
||||
else:
|
||||
cmd = cmd.strip() + f" -c {req.ctx}\n"
|
||||
cfg["models"][req.alias]["cmd"] = LiteralScalarString(cmd)
|
||||
|
||||
if req.ttl is not None:
|
||||
cfg["models"][req.alias]["ttl"] = req.ttl
|
||||
|
||||
write_config(cfg)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
Vendored
+23
-23
File diff suppressed because one or more lines are too long
@@ -1,195 +0,0 @@
|
||||
// main.js — App-Boot: Panels mounten, Nav starten, Topbar/Alert pflegen, Polling fahren.
|
||||
// Panel-Vertrag: { id, mount?(), onStatus?(s), onJobs?(jobs) }.
|
||||
|
||||
import { api, getToken, setToken, getHfToken, setHfToken } from "./core/api.js";
|
||||
import { $ } from "./core/ui.js";
|
||||
import { initNav } from "./core/nav.js";
|
||||
|
||||
import overview from "./panels/overview.js";
|
||||
import models from "./panels/models.js";
|
||||
import server from "./panels/server.js";
|
||||
import jobs from "./panels/jobs.js";
|
||||
import cookbook from "./panels/cookbook.js";
|
||||
import connect from "./panels/connect.js";
|
||||
import news from "./panels/news.js";
|
||||
import guides from "./panels/guides.js";
|
||||
|
||||
const panels = [overview, models, server, jobs, cookbook, connect, news, guides];
|
||||
|
||||
let lastJobs = [];
|
||||
let lastSystem = null;
|
||||
let prevModelStates = {};
|
||||
|
||||
// ---- Topbar / Alert aus dem Status ableiten ----
|
||||
function applyStatus(s) {
|
||||
const dot = $("#swdot"), label = $("#swlabel"), alert = $("#alert");
|
||||
|
||||
if (!s) {
|
||||
dot.className = "dot off";
|
||||
label.textContent = "Backend nicht erreichbar";
|
||||
$("#top-active-text").textContent = "Backend offline";
|
||||
showAlert("Backend nicht erreichbar – läuft uvicorn?", false);
|
||||
} else {
|
||||
const host = s.swap_url.replace(/^https?:\/\//, "");
|
||||
dot.className = "dot " + (s.swap_ok ? "on" : "off");
|
||||
label.textContent = (s.swap_ok ? "LLM-Engine: Online – " : "LLM-Engine: Offline – ") + host;
|
||||
|
||||
const active = (s.models || []).find(m => m.state === "running");
|
||||
if (active) {
|
||||
$("#top-active-text").innerHTML = `Geladen: <b style="color:var(--teal)">${active.name}</b>`;
|
||||
} else {
|
||||
$("#top-active-text").innerHTML = "Kein Modell im VRAM";
|
||||
}
|
||||
|
||||
if (s.swap_ok) hideAlert();
|
||||
else showAlert(`LLM-Engine nicht erreichbar unter <b>${host}</b> – läuft der llama-swap Dienst?`, true);
|
||||
}
|
||||
setSecChip(s);
|
||||
for (const p of panels) p.onStatus?.(s);
|
||||
}
|
||||
|
||||
// Security-Chip: ehrlich anzeigen, ob ein Zugangs-Token aktiv ist.
|
||||
function setSecChip(s) {
|
||||
const chip = $("#sec-chip"), tx = $("#sec-chip-tx");
|
||||
if (!chip || !tx) return;
|
||||
const secured = !!s?.secured;
|
||||
chip.classList.toggle("ok", secured);
|
||||
tx.textContent = secured ? "Geschützt · Heimnetz" : "Nur im Heimnetz (offen)";
|
||||
}
|
||||
|
||||
function applyJobs(jobs) {
|
||||
lastJobs = jobs || [];
|
||||
for (const p of panels) p.onJobs?.(lastJobs);
|
||||
}
|
||||
|
||||
function applySystem(sys) {
|
||||
lastSystem = sys;
|
||||
for (const p of panels) p.onSystem?.(sys);
|
||||
}
|
||||
|
||||
function showAlert(html, warn) {
|
||||
const a = $("#alert");
|
||||
a.className = "alert" + (warn ? " warn" : "");
|
||||
a.innerHTML = `<span class="a-dot"></span><span>${html}</span>`;
|
||||
a.style.display = "flex";
|
||||
}
|
||||
function hideAlert() { $("#alert").style.display = "none"; }
|
||||
|
||||
// ---- Toolbar: ausstehende Updates anzeigen ----
|
||||
const goView = v => document.querySelector(`.nav-item[data-view="${v}"]`)?.click();
|
||||
function badge(text, cls, view, title) {
|
||||
const s = document.createElement("span");
|
||||
s.className = "upd-badge" + (cls ? " " + cls : "");
|
||||
s.textContent = text;
|
||||
if (title) s.title = title;
|
||||
s.onclick = () => goView(view);
|
||||
return s;
|
||||
}
|
||||
async function pollUpdates() {
|
||||
try {
|
||||
const u = await api("/api/updates");
|
||||
const el = $("#update-badges"); if (!el) return;
|
||||
const age = u.apt_cache_age_h;
|
||||
const stale = age != null && age > 24;
|
||||
const ageNote = age != null ? ` · apt-Cache: vor ${age}h` : "";
|
||||
el.innerHTML = "";
|
||||
el.appendChild(badge(
|
||||
u.os > 0 ? `OS: ${u.os} Updates` : (stale ? "OS: ?" : "OS: aktuell"),
|
||||
u.os > 0 ? "warn" : stale ? "warn" : "ok",
|
||||
"server",
|
||||
u.os > 0 ? `${u.os} ausstehende Pakete${ageNote}` : (stale ? `apt-Cache veraltet (${age}h) — OS-Update prüfen` : `Keine ausstehenden Pakete${ageNote}`)
|
||||
));
|
||||
el.appendChild(badge(u.engine > 0 ? "Engine: Update" : "Engine: aktuell", u.engine > 0 ? "warn" : "ok", "server"));
|
||||
el.appendChild(badge(u.models > 0 ? `Modelle: ${u.models}` : "Modelle: aktuell", u.models > 0 ? "warn" : "ok", "news"));
|
||||
} catch { /* still */ }
|
||||
}
|
||||
|
||||
// ---- Swap-Erkennung: kurze visuelle Hervorhebung wenn Modell wechselt ----
|
||||
function flashSwap() {
|
||||
const el = $("#top-active-text");
|
||||
if (el) { el.classList.add("swap-flash"); setTimeout(() => el.classList.remove("swap-flash"), 900); }
|
||||
}
|
||||
|
||||
// ---- Polling ----
|
||||
async function pollStatus() {
|
||||
try {
|
||||
const s = await api("/api/status");
|
||||
// Zustandswechsel erkennen → Swap-Flash auslösen
|
||||
const newStates = {};
|
||||
for (const m of (s?.models || [])) newStates[m.name] = m.state;
|
||||
for (const [name, state] of Object.entries(newStates)) {
|
||||
const prev = prevModelStates[name];
|
||||
if (prev && prev !== state && (state === "running" || state === "loading")) {
|
||||
flashSwap();
|
||||
break;
|
||||
}
|
||||
}
|
||||
prevModelStates = newStates;
|
||||
applyStatus(s);
|
||||
}
|
||||
catch { applyStatus(null); }
|
||||
}
|
||||
async function pollJobs() {
|
||||
try { applyJobs(await api("/api/jobs")); }
|
||||
catch { /* still */ }
|
||||
}
|
||||
|
||||
function connectSystemStream() {
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const wsUrl = `${protocol}//${window.location.host}/api/system/stream`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
applySystem(data);
|
||||
} catch (err) {}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
// Bei Verbindungsabbruch nach 3 Sekunden erneut versuchen
|
||||
setTimeout(connectSystemStream, 3000);
|
||||
};
|
||||
|
||||
ws.onerror = () => ws.close();
|
||||
}
|
||||
|
||||
// ---- Boot ----
|
||||
function bootToken() {
|
||||
const i = $("#token");
|
||||
if (i) {
|
||||
i.value = getToken();
|
||||
i.addEventListener("change", e => { setToken(e.target.value); pollStatus(); });
|
||||
}
|
||||
|
||||
const hf = $("#hf-token");
|
||||
if (hf) { hf.value = getHfToken(); hf.addEventListener("change", e => setHfToken(e.target.value)); }
|
||||
|
||||
const sbtn = $("#nav-settings");
|
||||
const smod = $("#settings-modal");
|
||||
const scls = $("#sm-close");
|
||||
if (sbtn && smod && scls) {
|
||||
sbtn.addEventListener("click", () => smod.style.display = "flex");
|
||||
scls.addEventListener("click", () => smod.style.display = "none");
|
||||
}
|
||||
}
|
||||
function tickClock() {
|
||||
const c = $("#clock");
|
||||
if (c) c.textContent = new Date().toTimeString().slice(0, 5);
|
||||
}
|
||||
|
||||
for (const p of panels) p.mount?.();
|
||||
initNav("overview");
|
||||
bootToken();
|
||||
tickClock();
|
||||
document.addEventListener("mc:refresh", pollStatus);
|
||||
|
||||
pollStatus();
|
||||
pollJobs();
|
||||
connectSystemStream();
|
||||
|
||||
setInterval(tickClock, 1000);
|
||||
setInterval(pollStatus, 3000);
|
||||
setInterval(pollJobs, 1500);
|
||||
pollUpdates();
|
||||
setInterval(pollUpdates, 60000);
|
||||
@@ -1,308 +0,0 @@
|
||||
// connect.js — „Verbinden" (v5): Wizard-Style, Merge-sichere Configs, Auto-Swap erklärt.
|
||||
|
||||
import { api, getToken } from "../core/api.js";
|
||||
import { $, esc, icon, toast } from "../core/ui.js";
|
||||
|
||||
let S = null;
|
||||
let lastSig = "";
|
||||
let selectedTool = "zed";
|
||||
let selectedModelIds = [];
|
||||
|
||||
window.mcCopyCode = btn => {
|
||||
const code = btn.parentElement.querySelector("code,pre");
|
||||
navigator.clipboard?.writeText(code?.textContent || "");
|
||||
const o = btn.textContent; btn.textContent = "Kopiert!"; setTimeout(() => (btn.textContent = o), 1400);
|
||||
};
|
||||
window.mcCopyVal = btn => {
|
||||
const i = btn.previousElementSibling;
|
||||
navigator.clipboard?.writeText(i.value);
|
||||
const o = btn.textContent; btn.textContent = "Kopiert!"; setTimeout(() => (btn.textContent = o), 1400);
|
||||
};
|
||||
window.cnGo = v => document.querySelector(`.nav-item[data-view="${v}"]`)?.click();
|
||||
window.cnSelectTool = t => { selectedTool = t; renderGuide(); };
|
||||
window.cnToggleModel = id => {
|
||||
if (selectedModelIds.includes(id)) selectedModelIds = selectedModelIds.filter(x => x !== id);
|
||||
else selectedModelIds.push(id);
|
||||
renderGuide();
|
||||
};
|
||||
|
||||
function baseUrl() { return `http://${location.hostname}:8080/v1`; }
|
||||
function isLanIp(h) { return /^\d{1,3}(\.\d{1,3}){3}$/.test(h); }
|
||||
function behindProxy() { return location.protocol === "https:" || !isLanIp(location.hostname); }
|
||||
function models() { return (S?.models || []).filter(m => !m.incomplete); }
|
||||
function activeIds() {
|
||||
return selectedModelIds.length ? selectedModelIds : models().map(m => m.name);
|
||||
}
|
||||
|
||||
function field(label, value) {
|
||||
return `<label>${label}</label>
|
||||
<div class="flex gap-2" style="align-items:stretch;margin-bottom:12px">
|
||||
<input class="mono-sm" readonly value="${esc(value)}" style="flex:1;margin:0">
|
||||
<button class="ghost" onclick="window.mcCopyVal(this)">Kopieren</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const okChip = txt =>
|
||||
`<span class="chip" style="background:rgba(63,185,80,.12);border-color:rgba(63,185,80,.25);color:#7ee29a">${icon("check")}${esc(txt)}</span>`;
|
||||
|
||||
// ---- Merge-sichere Config-Snippets ----
|
||||
|
||||
function zedMergeBlock(url, ids) {
|
||||
const ms = ids.length
|
||||
? models().filter(m => ids.includes(m.name))
|
||||
: (models().length ? models() : [{ name: "coder", meta: {} }]);
|
||||
const entries = ms.map(m => {
|
||||
const img = (m.meta?.caps || []).some(c => /Bild|Vision/i.test(c));
|
||||
const caps = img ? `, "capabilities": { "tools": true, "images": true }` : ``;
|
||||
return ` { "name": "${m.name}", "display_name": "${m.name}", "max_tokens": 32768${caps} }`;
|
||||
}).join(",\n");
|
||||
return `"language_models": {
|
||||
"openai_compatible": {
|
||||
"bosgame": {
|
||||
"api_url": "${url}",
|
||||
"available_models": [
|
||||
${entries}
|
||||
]
|
||||
}
|
||||
}
|
||||
}`;
|
||||
}
|
||||
|
||||
function openCodeMergeBlock(url, ids) {
|
||||
const list = ids.length ? ids : (models().length ? models().map(m => m.name) : ["coder"]);
|
||||
const modelMap = list.map(id => ` "${id}": { "name": "${id}" }`).join(",\n");
|
||||
return `"provider": {
|
||||
"llama-swap": {
|
||||
"npm": "@ai-sdk/openai-compatible",
|
||||
"name": "Bosgame (llama-swap)",
|
||||
"options": {
|
||||
"baseURL": "${url}",
|
||||
"apiKey": "local"
|
||||
},
|
||||
"models": {
|
||||
${modelMap}
|
||||
}
|
||||
}
|
||||
}`;
|
||||
}
|
||||
|
||||
// ---- Render-Abschnitte ----
|
||||
|
||||
function renderConnectionCard(url) {
|
||||
return `<div class="card" id="cn-conn-card">
|
||||
<div class="card-h"><h3>1 · Verbindung prüfen</h3></div>
|
||||
<div class="card-sub">Stelle sicher, dass die LLM-Engine erreichbar ist, bevor du Tools konfigurierst.</div>
|
||||
${field("Engine-Adresse (für alle Tools)", url)}
|
||||
${behindProxy() ? `<div class="alert warn" style="margin:0 0 12px"><span class="a-dot"></span><span>
|
||||
Du öffnest Mission Control über einen <b>Proxy/Namen</b> (HTTPS o.ä.). Die Engine läuft auf <b>Port 8080 ohne HTTPS</b> direkt am Bosgame.
|
||||
Trag in deinen Tools die <b>LAN-IP</b> ein: <code>http://192.168.178.151:8080/v1</code> — nicht den Proxy-Namen.</span></div>` : ""}
|
||||
<div class="btn-row" style="display:flex;align-items:center;gap:10px">
|
||||
<button class="primary" id="cn-test">Verbindung testen</button>
|
||||
<span id="cn-test-result" class="mono-sm"></span>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderAutoSwapCard() {
|
||||
return `<div class="card">
|
||||
<div class="card-h"><h3>Wie funktioniert das Auto-Swap?</h3></div>
|
||||
<div style="display:flex;flex-direction:column;gap:10px">
|
||||
<div class="tile" style="display:flex;gap:12px;align-items:flex-start">
|
||||
<span style="font-size:20px;line-height:1;flex:0 0 auto">⚡</span>
|
||||
<div>
|
||||
<div style="font-weight:500;font-size:13.5px">Kein manuelles Starten nötig</div>
|
||||
<div class="card-sub" style="margin:4px 0 0">Wenn du in Zed oder OpenCode <b>„coder"</b> auswählst und eine Anfrage sendest,
|
||||
lädt llama-swap das Modell automatisch — in ca. 2–5 Sekunden. Du siehst es hier im Dashboard unter „Aktives Modell".</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tile" style="display:flex;gap:12px;align-items:flex-start">
|
||||
<span style="font-size:20px;line-height:1;flex:0 0 auto">🔄</span>
|
||||
<div>
|
||||
<div style="font-weight:500;font-size:13.5px">Modelle wechseln sich automatisch ab</div>
|
||||
<div class="card-sub" style="margin:4px 0 0">Rufst du ein anderes Modell auf, entlädt llama-swap das aktuelle (nach TTL-Ablauf)
|
||||
und lädt das neue. Es läuft immer nur <b>eines gleichzeitig</b> — llama-swap verwaltet das komplett selbst.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tile" style="display:flex;gap:12px;align-items:flex-start">
|
||||
<span style="font-size:20px;line-height:1;flex:0 0 auto">🧠</span>
|
||||
<div>
|
||||
<div style="font-weight:500;font-size:13.5px">Zwei Modelle gleichzeitig aktiv halten</div>
|
||||
<div class="card-sub" style="margin:4px 0 0">Du hast 124 GB RAM — genug für z.B. Scout 8B (~5 GB) <b>dauerhaft geladen</b> + ein
|
||||
größeres Modell das swappt. Dafür beim Scout im <b>Modelle-Tab → Konfigurieren</b> die TTL auf <code>99999</code> setzen
|
||||
(= nie automatisch entladen). Das kleine Modell beantwortet schnelle Fragen sofort, ohne jede Wartezeit.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderModelSelector() {
|
||||
const ms = models();
|
||||
if (!ms.length) return "";
|
||||
const chips = ms.map(m =>
|
||||
`<span class="chip" style="cursor:pointer;${selectedModelIds.includes(m.name) ? "background:rgba(45,212,191,.2);border-color:var(--accent);color:var(--accent)" : ""}"
|
||||
onclick="window.cnToggleModel('${esc(m.name)}')">${esc(m.name)}</span>`
|
||||
).join(" ");
|
||||
const note = selectedModelIds.length
|
||||
? `${selectedModelIds.length} von ${ms.length} ausgewählt`
|
||||
: "alle werden eingeschlossen";
|
||||
return `<div class="card">
|
||||
<div class="card-h"><h3>2 · Modelle auswählen</h3><span class="meta">${note}</span></div>
|
||||
<div class="card-sub">Wähle, welche Modelle im Config-Snippet erscheinen sollen. Standardmäßig alle.</div>
|
||||
<div class="flex gap-2" style="flex-wrap:wrap">${chips}</div>
|
||||
${selectedModelIds.length ? `<div style="margin-top:10px"><a href="#" onclick="event.preventDefault();window.cnClearModels()" style="color:var(--mut);font-size:12.5px">Auswahl aufheben (alle)</a></div>` : ""}
|
||||
</div>`;
|
||||
}
|
||||
window.cnClearModels = () => { selectedModelIds = []; renderGuide(); };
|
||||
|
||||
function toolBtn(id, label, desc) {
|
||||
const active = selectedTool === id;
|
||||
return `<button class="card-btn${active ? " cb-best" : ""}" style="text-align:left;padding:12px 16px"
|
||||
onclick="window.cnSelectTool('${id}')">
|
||||
<div style="font-weight:500;font-size:13.5px">${esc(label)}</div>
|
||||
<div class="card-sub" style="margin:3px 0 0">${esc(desc)}</div>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
function renderToolPicker() {
|
||||
return `<div class="card">
|
||||
<div class="card-h"><h3>3 · Tool wählen</h3></div>
|
||||
<div class="card-sub">Für welches Werkzeug brauchst du die Konfiguration?</div>
|
||||
<div class="grid grid-4" style="gap:8px">
|
||||
${toolBtn("zed", "Zed", "Schneller nativer Editor — Empfohlen")}
|
||||
${toolBtn("opencode", "OpenCode", "Open-Source Coding-Agent (Terminal/Desktop)")}
|
||||
${toolBtn("cline", "Cline / Cursor", "VS Code Extension, Bring-Your-Own-Key")}
|
||||
${toolBtn("openwebui", "OpenWebUI", "Browser-Chat-Interface")}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderConfigCard(url) {
|
||||
const ids = activeIds();
|
||||
const firstModel = ids[0] || "coder";
|
||||
let content = "";
|
||||
|
||||
if (selectedTool === "zed") {
|
||||
const block = zedMergeBlock(url, ids);
|
||||
content = `
|
||||
<p>Öffne die Zed-Settings mit <code>Cmd/Strg + ,</code> und <b>füge diesen Block</b> in deine bestehende
|
||||
<code>settings.json</code> ein. Falls bereits ein <code>"bosgame"</code>-Eintrag existiert: vorher löschen.</p>
|
||||
<div class="alert warn" style="margin-bottom:12px;padding:10px 14px">
|
||||
<span class="a-dot"></span><span>Nur diesen Block einfügen, <b>nicht die ganze Datei ersetzen</b> — sonst gehen andere Einstellungen verloren.</span>
|
||||
</div>
|
||||
<div style="position:relative;margin-top:4px">
|
||||
<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(block)}</code></div>
|
||||
</div>
|
||||
<p style="margin-top:12px"><b>API-Key:</b> Öffne den Agent-Panel → Anbieter <b>bosgame</b> → trag irgendeinen Wert ein (z.B. <code>local</code>).
|
||||
Alternativ <code>BOSGAME_API_KEY=local</code> als Umgebungsvariable.</p>
|
||||
<div class="hint">Danach im Agent-Panel das Modell <b>bosgame / ${esc(firstModel)}</b> auswählen. Das Auto-Swap lädt es beim ersten Request.</div>`;
|
||||
} else if (selectedTool === "opencode") {
|
||||
const block = openCodeMergeBlock(url, ids);
|
||||
content = `
|
||||
<p>Datei anlegen oder öffnen:</p>
|
||||
<p class="mono-sm" style="line-height:1.6">Windows: <code>%USERPROFILE%\\.config\\opencode\\opencode.json</code><br>
|
||||
Linux/Mac: <code>~/.config/opencode/opencode.json</code><br>
|
||||
Oder im Projektordner: <code>opencode.json</code></p>
|
||||
<div class="alert warn" style="margin:8px 0 12px;padding:10px 14px">
|
||||
<span class="a-dot"></span><span>Nur den <code>"provider"</code>-Block einfügen oder mergen.
|
||||
Falls bereits ein <code>"llama-swap"</code>-Eintrag vorhanden ist: <b>vorher löschen</b>, sonst erscheinen Modelle doppelt.</span>
|
||||
</div>
|
||||
<div style="position:relative">
|
||||
<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(block)}</code></div>
|
||||
</div>
|
||||
<p style="margin-top:12px">Danach in OpenCode <code>/models</code> → <b>llama-swap/${esc(firstModel)}</b> wählen.</p>
|
||||
<div class="hint">Statt alles zu ersetzen: nur den <code>provider</code>-Block in deine bestehende Config einfügen — so bleiben andere Anbieter erhalten.</div>`;
|
||||
} else if (selectedTool === "cline") {
|
||||
content = `
|
||||
<p>In Cline/Cursor den Provider <b>„OpenAI Compatible"</b> wählen und folgende Felder eintragen:</p>
|
||||
${field("Basis-URL (OpenAI Compatible)", url)}
|
||||
${field("Modell-ID", firstModel)}
|
||||
<div class="hint">API-Key: ein beliebiger Wert (z.B. <code>local</code>) — llama-swap prüft ihn nicht.</div>`;
|
||||
} else if (selectedTool === "openwebui") {
|
||||
content = `
|
||||
<p>Settings → Admin Panel → Connections → neue OpenAI-Verbindung:</p>
|
||||
${field("Basis-URL", url)}
|
||||
${field("API-Key", "dummy-key")}
|
||||
<div class="hint">Nach dem Speichern erscheinen die Modelle automatisch in der Modell-Auswahl.</div>`;
|
||||
}
|
||||
|
||||
return `<div class="card">
|
||||
<div class="card-h"><h3>4 · Config-Snippet</h3></div>
|
||||
${content}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderVisionCard(url) {
|
||||
const visionModel = models().find(m => m.meta?.caps?.includes("Bild"))?.name;
|
||||
if (!visionModel) return "";
|
||||
const snippet = `curl ${url}/chat/completions \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"model": "${visionModel}",
|
||||
"messages": [{"role":"user","content":[
|
||||
{"type":"text","text":"Was ist auf dem Bild falsch?"},
|
||||
{"type":"image_url","image_url":{"url":"data:image/png;base64,<DEIN_BILD>"}}
|
||||
]}]
|
||||
}'`;
|
||||
return `<div class="card">
|
||||
<div class="card-h"><h3>Bilder einbinden (Vision)</h3>${okChip(`„${visionModel}" bereit`)}</div>
|
||||
<div class="card-sub">Mit Vision-Modellen kannst du Screenshots oder Fehlerbilder direkt mitschicken.</div>
|
||||
<div style="position:relative;margin-top:8px">
|
||||
<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(snippet)}</code></div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function render() {
|
||||
const c = $(".view[data-view='connect']"); if (!c) return;
|
||||
const url = baseUrl();
|
||||
|
||||
c.innerHTML = `
|
||||
<div class="pagehead"><div>
|
||||
<h1>Verbinden</h1>
|
||||
<div class="sub">Schritt für Schritt: KI-Modelle in deine Tools einbinden — getestet, erklärt, mit Copy-Paste-Config.</div>
|
||||
</div></div>
|
||||
|
||||
${renderConnectionCard(url)}
|
||||
${renderAutoSwapCard()}
|
||||
${renderModelSelector()}
|
||||
${renderToolPicker()}
|
||||
${renderConfigCard(url)}
|
||||
${renderVisionCard(url)}`;
|
||||
|
||||
$("#cn-test")?.addEventListener("click", testConnection);
|
||||
}
|
||||
|
||||
function renderGuide() {
|
||||
const url = baseUrl();
|
||||
const sel = $(".view[data-view='connect'] .card:nth-of-type(3)");
|
||||
if (!sel) { render(); return; }
|
||||
// Nur die unteren 3 Karten neu rendern (Modell-Auswahl, Tool-Picker, Config)
|
||||
const c = $(".view[data-view='connect']");
|
||||
const cards = c?.querySelectorAll(".card");
|
||||
if (!cards || cards.length < 3) { render(); return; }
|
||||
// Einfachster Weg: komplett neu rendern (Scroll-Position bleibt wegen Hash-Routing ok)
|
||||
render();
|
||||
}
|
||||
|
||||
async function testConnection() {
|
||||
const res = $("#cn-test-result"); res.innerHTML = `<span class="text-mut">Teste…</span>`;
|
||||
try {
|
||||
const r = await api("/api/integration/test");
|
||||
res.innerHTML = r.ok
|
||||
? okChip(`Verbunden — ${r.models.length} Modell(e): ${r.models.join(", ")}`)
|
||||
: `<span style="color:var(--err)">✗ Keine Verbindung: ${esc(r.error)}</span>`;
|
||||
} catch (e) { res.innerHTML = `<span style="color:var(--err)">✗ ${esc(e.message)}</span>`; }
|
||||
}
|
||||
|
||||
function mount() { render(); }
|
||||
function onStatus(s) {
|
||||
S = s;
|
||||
const sig = (s?.models || []).map(m => m.name).join(",") + "|" + !!s;
|
||||
if (sig !== lastSig) { lastSig = sig; render(); }
|
||||
}
|
||||
|
||||
export default { id: "connect", mount, onStatus };
|
||||
@@ -1,585 +0,0 @@
|
||||
// cookbook.js — Cookbook 2.0 (v4): Use-Case-Setups („Wofür?") + Profi-Suche.
|
||||
// Backend: /api/cookbook/{recipes,install-recipe,analyze,evaluate} (hw_math).
|
||||
|
||||
import { api, getHfToken } from "../core/api.js";
|
||||
import { $, esc, icon, toast, infoDot, confirmModal } from "../core/ui.js";
|
||||
|
||||
const CTX_HELP = "Kontext = das Kurzzeitgedächtnis des Modells. Größer = merkt sich mehr, braucht aber mehr Speicher und wird etwas langsamer.";
|
||||
const GGUF_HELP = "GGUF ist das lokal lauffähige Dateiformat. Unsere Engine lädt nur GGUF — Repos ohne GGUF-Datei kann sie nicht nutzen.";
|
||||
const MOE_HELP = "MoE (Mixture of Experts): viele Parameter fürs Wissen, aber pro Wort ist nur ein kleiner Teil aktiv → fast große-Modell-Qualität bei hohem Tempo. Ideal für deine APU.";
|
||||
|
||||
// Was kann das Modell? (Fähigkeit aus dem Namen / der Backend-Rolle ableiten.)
|
||||
const ROLE_LABEL = { vision: "Bilder", coder: "Coden", reasoning: "Logik", agent: "Agenten & Tools",
|
||||
scout: "Allrounder", reviewer: "Review", manager: "Manager" };
|
||||
const CAP_KW = [
|
||||
{ label: "Bilder", kw: ["-vl-", "-vl", "vision", "llava", "multimodal", "-mm-", "pixtral"] },
|
||||
{ label: "Coden", kw: ["coder", "-code-", "code-", "codestral", "starcoder"] },
|
||||
{ label: "Logik", kw: ["-r1", "deepseek-r1", "reasoning", "qwq", "magistral", "-think", "thinking", "-o1"] },
|
||||
{ label: "Agenten & Tools", kw: ["hermes", "-tool", "command-r", "watt", "-fc-", "function"] },
|
||||
];
|
||||
function capLabel(name) {
|
||||
const l = (name || "").toLowerCase();
|
||||
for (const c of CAP_KW) if (c.kw.some(k => l.includes(k))) return c.label;
|
||||
return "Allrounder";
|
||||
}
|
||||
// MoE-Erkennung über strukturelle Marker im Namen (zuverlässig, keine Falschaussagen):
|
||||
// 8x7B-Stil, „…-A3B" (aktive Parameter), oder explizit „mixtral"/„moe".
|
||||
function isMoe(s = "") {
|
||||
const l = s.toLowerCase();
|
||||
return /\d+x\d+(\.\d+)?b/.test(l) || /(?<![a-z])a\d+(\.\d+)?b\b/.test(l) || l.includes("mixtral") || /\bmoe\b/.test(l);
|
||||
}
|
||||
function moeActive(s = "") {
|
||||
const m = s.toLowerCase().match(/(?<![a-z])a(\d+(?:\.\d+)?)b\b/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
function moeChip(name) {
|
||||
if (!isMoe(name)) return "";
|
||||
const a = moeActive(name);
|
||||
return `<span class="chip" title="${esc(MOE_HELP)}">MoE${a ? ` · ~${a}B aktiv` : ""}</span>`;
|
||||
}
|
||||
function capChip(label) { return label ? `<span class="chip">${esc(label)}</span>` : ""; }
|
||||
|
||||
const FILTERS = [
|
||||
{ id: "", label: "Alle" }, { id: "coder", label: "Coder" }, { id: "scout", label: "Scout" },
|
||||
{ id: "vision", label: "Vision" }, { id: "manager", label: "Manager" }, { id: "reviewer", label: "Reviewer" },
|
||||
];
|
||||
|
||||
let lastSys = null;
|
||||
let currentResults = [];
|
||||
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ß";
|
||||
|
||||
function mount() {
|
||||
const c = $(".view[data-view='cookbook']");
|
||||
c.innerHTML = `
|
||||
<div class="pagehead"><div>
|
||||
<h1>Modell-Cookbook</h1>
|
||||
<div class="sub">Wofür willst du es nutzen? Wir empfehlen das passende Setup für deine Hardware.</div>
|
||||
</div></div>
|
||||
|
||||
<div class="card-h" style="align-items:center"><h3>Wofür möchtest du es nutzen?</h3>
|
||||
<span class="chip" id="cb-hw" style="margin-left:auto">deine Hardware</span>
|
||||
<button class="ghost" id="cb-new-open" style="margin-left:10px">+ Eigenes Setup</button></div>
|
||||
<div class="grid grid-3" id="cb-recipes">
|
||||
<div class="empty" style="grid-column:1/-1;text-align:center">Lade Setups…</div>
|
||||
</div>
|
||||
|
||||
<div class="card-h" style="align-items:center;margin-top:20px"><h3>Aktuell beste Modelle für dein System</h3>
|
||||
<span class="chip" id="cb-disc-when" style="margin-left:auto"></span>
|
||||
<button class="ghost" id="cb-disc-refresh" style="margin-left:10px">Aktualisieren</button></div>
|
||||
<div class="card-sub" style="margin:-6px 0 10px">Automatisch aus vertrauenswürdigen Quellen (HuggingFace) — laufend aktuell, gefiltert auf das, was auf deine Hardware passt.</div>
|
||||
<div id="cb-discover"><div class="empty" style="text-align:center">Lade aktuelle Empfehlungen…</div></div>
|
||||
|
||||
<details class="guide-acc card" style="padding:0;overflow:hidden;margin-top:18px">
|
||||
<summary>Profi-Modus: HuggingFace direkt durchsuchen</summary>
|
||||
<div class="acc-body">
|
||||
<div class="flex gap-3" style="margin-bottom:8px">
|
||||
<input id="cb-search" placeholder="Suchen oder HuggingFace-Link/Repo einfügen (z.B. unsloth/MiniMax-M3-GGUF)…" style="margin:0;flex:1">
|
||||
<button class="primary" id="cb-btn-search" style="white-space:nowrap">Suchen</button>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center" style="flex-wrap:wrap">
|
||||
<div id="cb-filters" class="flex gap-2" style="flex-wrap:wrap;flex:1"></div>
|
||||
<select id="cb-sort" style="width:auto;margin:0;padding:7px 10px;font-family:var(--sans)">
|
||||
<option value="downloads">Beliebteste Downloads</option>
|
||||
<option value="likes">Am beliebtesten (Likes)</option>
|
||||
<option value="lastModified">Zuletzt aktualisiert</option>
|
||||
<option value="trendingScore">Im Trend</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="legend" style="margin:14px 0">
|
||||
<span><i style="background:var(--on)"></i>passt locker</span>
|
||||
<span><i style="background:var(--warn)"></i>läuft, aber knapp</span>
|
||||
<span><i style="background:var(--err)"></i>zu groß für deinen Speicher</span>
|
||||
</div>
|
||||
<div class="hint" style="margin:-6px 0 14px">Es werden nur Modelle im GGUF-Format ${infoDot(GGUF_HELP)} gezeigt — nur die laufen lokal.</div>
|
||||
<div class="grid grid-3" id="cb-grid"></div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div id="cb-recipe-modal" class="modal-overlay" style="display:none">
|
||||
<div class="modal-card" style="max-width:580px">
|
||||
<button id="cb-r-close" class="ghost" style="position:absolute;top:14px;right:14px">Schließen</button>
|
||||
<h3 id="cb-r-title">Setup</h3>
|
||||
<p class="text-mut text-sm" id="cb-r-desc" style="margin:-4px 0 16px"></p>
|
||||
<div id="cb-r-models"></div>
|
||||
<div class="hint" id="cb-r-note" style="margin-top:12px"></div>
|
||||
<button class="primary" id="cb-r-install" style="width:100%;margin-top:8px">Komplettes Setup installieren</button>
|
||||
<div class="hint" style="margin-top:8px">Lädt alle Modelle herunter und pflegt sie mit optimalem Kontext ein. Fortschritt in der Aktivität.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="cb-new-modal" class="modal-overlay" style="display:none">
|
||||
<div class="modal-card" style="max-width:640px">
|
||||
<button id="cb-new-close" class="ghost" style="position:absolute;top:14px;right:14px">Schließen</button>
|
||||
<h3 id="cb-new-modal-title">Eigenes Setup erstellen</h3>
|
||||
<p class="text-mut text-sm" style="margin:-4px 0 16px">Stell dir aus beliebigen Modellen ein eigenes Setup zusammen — es erscheint danach mit Hardware-Ampel hier im Cookbook.</p>
|
||||
<label>Titel</label>
|
||||
<input id="cb-new-title" placeholder="z.B. Mein Schreib-Stack">
|
||||
<label>Kurzbeschreibung (optional)</label>
|
||||
<input id="cb-new-desc" placeholder="Wofür ist dieses Setup gut?">
|
||||
<label style="margin-top:10px">Modelle</label>
|
||||
<div id="cb-new-models"></div>
|
||||
<button class="ghost" id="cb-new-add" style="margin-top:8px">+ Modell hinzufügen</button>
|
||||
<div class="hint" style="margin-top:10px">Die <b>Repo-ID</b> findest du über die <b>Profi-Suche</b> weiter unten (z.B. <code>unsloth/Qwen3-8B-GGUF</code>). Die Modellgröße erkennen wir automatisch aus dem Namen.</div>
|
||||
<button class="primary" id="cb-new-save" style="width:100%;margin-top:14px">Setup speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="cb-modal" class="modal-overlay" style="display:none">
|
||||
<div class="modal-card" style="max-width:560px">
|
||||
<button id="cb-modal-close" class="ghost" style="position:absolute;top:14px;right:14px">Schließen</button>
|
||||
<h3 id="cb-m-title">Modell</h3>
|
||||
<p class="mono-sm" id="cb-m-repo" style="margin:-4px 0 16px">repo/name</p>
|
||||
<label>Quantisierung (GGUF-Datei) wählen</label>
|
||||
<select id="cb-m-files"></select>
|
||||
<div class="hint" id="cb-m-loading" style="display:none">Lade Dateien von HuggingFace…</div>
|
||||
<div class="row" style="margin-top:4px">
|
||||
<div><label>Rolle (optional)</label><input id="cb-m-alias" placeholder="z.B. coder, vision, scout"></div>
|
||||
<div><label>Kontext-Größe ${infoDot(CTX_HELP)}</label><input id="cb-m-ctx" type="number" value="8192"></div>
|
||||
</div>
|
||||
<div class="hint" id="cb-m-ctx-rec" style="margin:-6px 0 14px"></div>
|
||||
<div id="cb-m-fit" class="tile" style="display:flex;justify-content:space-between;align-items:center;margin:8px 0 18px">
|
||||
<div><div style="font-size:13px">Ressourcen-Check</div>
|
||||
<div class="hint" id="cb-m-fit-text" style="margin:4px 0 0">Berechne…</div></div>
|
||||
<span id="cb-m-fit-badge"></span>
|
||||
</div>
|
||||
<button class="primary" id="cb-m-download" style="width:100%">Herunterladen & Einpflegen</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
renderFilters();
|
||||
$("#cb-btn-search").addEventListener("click", doSearch);
|
||||
$("#cb-search").addEventListener("keydown", e => { if (e.key === "Enter") doSearch(); });
|
||||
$("#cb-sort").addEventListener("change", () => { if ($("#cb-search").value.trim() || activeFilter) doSearch(); });
|
||||
$("#cb-modal-close").addEventListener("click", () => $("#cb-modal").style.display = "none");
|
||||
$("#cb-modal").addEventListener("click", e => { if (e.target.id === "cb-modal") $("#cb-modal").style.display = "none"; });
|
||||
$("#cb-m-download").addEventListener("click", doDownload);
|
||||
$("#cb-m-files").addEventListener("change", updateLiveFit);
|
||||
$("#cb-m-ctx").addEventListener("change", reanalyzeCtx);
|
||||
window.cbSetCtx = v => { $("#cb-m-ctx").value = v; reanalyzeCtx(); };
|
||||
$("#cb-r-close").addEventListener("click", () => $("#cb-recipe-modal").style.display = "none");
|
||||
$("#cb-recipe-modal").addEventListener("click", e => { if (e.target.id === "cb-recipe-modal") $("#cb-recipe-modal").style.display = "none"; });
|
||||
$("#cb-new-open").addEventListener("click", openNewRecipe);
|
||||
$("#cb-new-close").addEventListener("click", () => $("#cb-new-modal").style.display = "none");
|
||||
$("#cb-new-modal").addEventListener("click", e => { if (e.target.id === "cb-new-modal") $("#cb-new-modal").style.display = "none"; });
|
||||
$("#cb-new-add").addEventListener("click", () => $("#cb-new-models").insertAdjacentHTML("beforeend", newModelRow()));
|
||||
$("#cb-new-models").addEventListener("click", e => { const d = e.target.closest(".cb-nm-del"); if (d) d.closest(".cb-nm-row").remove(); });
|
||||
$("#cb-new-save").addEventListener("click", saveNewRecipe);
|
||||
|
||||
$("#cb-disc-refresh").addEventListener("click", () => loadDiscover(true));
|
||||
$("#cb-discover").addEventListener("click", e => {
|
||||
const b = e.target.closest("[data-inst]"); if (!b) return;
|
||||
installDiscovered(b.getAttribute("data-inst"), b.getAttribute("data-role"), b.getAttribute("data-pb"), b);
|
||||
});
|
||||
|
||||
renderHwChip();
|
||||
loadRecipes();
|
||||
loadDiscover();
|
||||
}
|
||||
|
||||
// ---- Automatische Modell-Entdeckung („aktuell beste Modelle") ----
|
||||
function fmtAgo(ts) {
|
||||
const s = Date.now() / 1000 - ts;
|
||||
if (s < 90) return "gerade eben";
|
||||
if (s < 3600) return `vor ${Math.round(s / 60)} min`;
|
||||
if (s < 86400) return `vor ${Math.round(s / 3600)} h`;
|
||||
return `vor ${Math.round(s / 86400)} Tg.`;
|
||||
}
|
||||
|
||||
async function loadDiscover(force = false) {
|
||||
const box = $("#cb-discover"), when = $("#cb-disc-when"), btn = $("#cb-disc-refresh");
|
||||
if (force) { btn.disabled = true; btn.textContent = "Suche…"; box.innerHTML = `<div class="empty" style="text-align:center">Frage Quellen ab…</div>`; }
|
||||
try {
|
||||
const d = await api("/api/cookbook/discover" + (force ? "?force=true" : ""));
|
||||
if (when) when.textContent = d.updated ? `aktualisiert ${fmtAgo(d.updated)}` : "";
|
||||
renderDiscover(d);
|
||||
} catch (e) {
|
||||
box.innerHTML = `<div class="alert warn" style="margin:0"><span class="a-dot"></span><span>Empfehlungen nicht ladbar: ${esc(e.message)}</span></div>`;
|
||||
}
|
||||
btn.disabled = false; btn.textContent = "Aktualisieren";
|
||||
}
|
||||
|
||||
function renderDiscover(d) {
|
||||
const box = $("#cb-discover");
|
||||
const cats = d.categories || [];
|
||||
if (!cats.length) { box.innerHTML = `<div class="empty" style="text-align:center">Keine passenden Modelle gefunden.</div>`; return; }
|
||||
box.innerHTML = cats.map(c => `
|
||||
<div style="margin-bottom:8px">
|
||||
<div class="flex items-center gap-2" style="margin:6px 0 8px"><span class="text-accent">${icon(c.icon)}</span>
|
||||
<h4 style="margin:0;font-size:13.5px;font-weight:600">${esc(c.title)}</h4></div>
|
||||
<div class="grid grid-3">${c.models.map(m => discCard(m, c.recommended)).join("")}</div>
|
||||
</div>`).join("");
|
||||
}
|
||||
|
||||
function discCard(m, recommended) {
|
||||
const reqs = (m.requirements || []).map(r =>
|
||||
`<div class="li-sub" style="color:var(--warn);margin-top:3px">⚠ ${esc(r)}</div>`).join("");
|
||||
const best = recommended && m.repo === recommended;
|
||||
const tag = best ? `<div class="cb-best-tag">★ Beste Wahl für dein System</div>` : "";
|
||||
const chips = `<div class="flex gap-2" style="flex-wrap:wrap;margin-top:7px">${capChip(ROLE_LABEL[m.role] || capLabel(m.repo))}${moeChip(m.repo)}</div>`;
|
||||
const action = m.installed
|
||||
? `<span class="fit-badge ok" title="bereits eingerichtet als ${esc(m.installed)}">✓ installiert</span>`
|
||||
: `<button class="primary" data-inst="${esc(m.repo)}" data-role="${esc(m.role)}" data-pb="${m.params_b}"
|
||||
style="padding:6px 12px;font-size:12.5px">Installieren</button>`;
|
||||
return `<div class="card${best ? " res-best" : ""}" style="display:flex;flex-direction:column;position:relative">
|
||||
${tag}
|
||||
<div class="flex justify-between" style="align-items:flex-start;gap:8px">
|
||||
<div style="min-width:0"><h3 style="margin:0;font-size:14px;font-weight:500;word-break:break-word">${esc(m.name)}</h3>
|
||||
<div class="text-xs text-mut" style="margin-top:3px">${esc(m.author)} · ${m.params_b}B</div></div>
|
||||
<span class="fit-badge ${fitCls(m.fit.level)}">${esc(m.fit.text)}</span>
|
||||
</div>
|
||||
${chips}
|
||||
<div class="mono-sm text-mut" style="margin-top:8px;font-size:11.5px">${metricLine(m.fit)} · ⬇ ${(m.downloads || 0).toLocaleString()}</div>
|
||||
${reqs}
|
||||
<div style="flex:1;min-height:8px"></div>
|
||||
<div class="flex justify-end" style="margin-top:10px">${action}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function installDiscovered(repo, role, params_b, btn) {
|
||||
btn.disabled = true; btn.textContent = "Starte…";
|
||||
try {
|
||||
await api("/api/cookbook/install-model", { method: "POST",
|
||||
body: JSON.stringify({ repo, role, params_b: parseFloat(params_b) || 7, quant: "Q4_K_M", hf_token: getHfToken() }) });
|
||||
toast("Download gestartet — siehe Aktivität.");
|
||||
document.querySelector(".nav-item[data-view='activity']")?.click();
|
||||
} catch (e) { toast("Fehler: " + e.message, true); btn.disabled = false; btn.textContent = "Installieren"; }
|
||||
}
|
||||
|
||||
// ---- Use-Case-Setups ----
|
||||
async function loadRecipes() {
|
||||
try {
|
||||
const d = await api("/api/cookbook/recipes");
|
||||
RECIPES = d.recipes || [];
|
||||
RECOMMENDED = d.recommended_id || null;
|
||||
renderRecipes();
|
||||
} catch (e) {
|
||||
$("#cb-recipes").innerHTML = `<div class="alert err" style="grid-column:1/-1">Setups nicht ladbar: ${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderRecipes() {
|
||||
$("#cb-recipes").innerHTML = RECIPES.map(r => {
|
||||
const best = r.id === RECOMMENDED;
|
||||
const tag = best ? `<div class="cb-best-tag">★ Beste Wahl für dein System</div>`
|
||||
: r.user ? `<div class="cb-best-tag" style="background:var(--act);color:#fff">Dein Setup</div>` : "";
|
||||
const del = r.user ? `
|
||||
<span class="cb-editr" data-editr="${esc(r.id)}" title="Setup bearbeiten"
|
||||
style="position:absolute;top:10px;right:36px;color:var(--accent);cursor:pointer;font-size:14px;line-height:1">✎</span>
|
||||
<span class="cb-delr" data-delr="${esc(r.id)}" title="Setup löschen"
|
||||
style="position:absolute;top:9px;right:12px;color:var(--err);cursor:pointer;font-size:18px;line-height:1">×</span>` : "";
|
||||
return `<button class="card-btn${best ? " cb-best" : ""}" data-recipe="${esc(r.id)}" style="position:relative">
|
||||
${del}${tag}
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="flex items-center gap-2"><span class="text-accent">${icon(r.icon)}</span>
|
||||
<h3 style="margin:0;font-size:15px">${esc(r.title)}</h3></span>
|
||||
<span class="fit-badge ${fitCls(r.fit_level)}">${fitWord(r.fit_level)}</span>
|
||||
</div>
|
||||
<p>${esc(r.desc)}</p>
|
||||
<div class="text-xs text-mut">${r.models.length} Modell${r.models.length > 1 ? "e" : ""} im Setup</div>
|
||||
</button>`;
|
||||
}).join("");
|
||||
$("#cb-recipes").querySelectorAll("[data-recipe]").forEach(b =>
|
||||
b.addEventListener("click", () => openRecipe(b.getAttribute("data-recipe"))));
|
||||
$("#cb-recipes").querySelectorAll("[data-delr]").forEach(x =>
|
||||
x.addEventListener("click", e => { e.stopPropagation(); deleteRecipe(x.getAttribute("data-delr")); }));
|
||||
$("#cb-recipes").querySelectorAll("[data-editr]").forEach(x =>
|
||||
x.addEventListener("click", e => { e.stopPropagation(); openEditRecipe(x.getAttribute("data-editr")); }));
|
||||
}
|
||||
|
||||
// ---- Eigenes Setup erstellen/löschen ----
|
||||
function newModelRow() {
|
||||
return `<div class="cb-nm-row flex gap-2" style="margin-bottom:8px;align-items:center">
|
||||
<input class="cb-nm-repo" placeholder="Repo, z.B. unsloth/Qwen3-8B-GGUF" style="flex:2;margin:0">
|
||||
<input class="cb-nm-role" placeholder="Rolle (z.B. coder)" style="flex:1;margin:0">
|
||||
<select class="cb-nm-quant" style="width:auto;margin:0;font-family:var(--sans)">
|
||||
<option>Q4_K_M</option><option>Q5_K_M</option><option>Q6_K</option><option>Q8_0</option><option>Q3_K_M</option>
|
||||
</select>
|
||||
<button class="ghost del cb-nm-del" title="Zeile entfernen" style="margin:0;padding:6px 10px">×</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
let editRecipeId = null;
|
||||
|
||||
function openNewRecipe() {
|
||||
editRecipeId = null;
|
||||
$("#cb-new-title").value = "";
|
||||
$("#cb-new-desc").value = "";
|
||||
$("#cb-new-models").innerHTML = newModelRow();
|
||||
$("#cb-new-modal-title").textContent = "Eigenes Setup erstellen";
|
||||
$("#cb-new-save").textContent = "Setup speichern";
|
||||
$("#cb-new-modal").style.display = "flex";
|
||||
}
|
||||
|
||||
function openEditRecipe(id) {
|
||||
const r = RECIPES.find(x => x.id === id); if (!r) return;
|
||||
editRecipeId = id;
|
||||
$("#cb-new-title").value = r.title;
|
||||
$("#cb-new-desc").value = r.desc || "";
|
||||
$("#cb-new-models").innerHTML = r.models.map(m => {
|
||||
const row = document.createElement("div");
|
||||
row.innerHTML = newModelRow();
|
||||
const el = row.firstElementChild;
|
||||
el.querySelector(".cb-nm-repo").value = m.repo;
|
||||
el.querySelector(".cb-nm-role").value = m.role;
|
||||
const quant = el.querySelector(".cb-nm-quant");
|
||||
if (quant) [...quant.options].forEach(o => { if (o.value === m.quant) o.selected = true; });
|
||||
return el.outerHTML;
|
||||
}).join("");
|
||||
$("#cb-new-modal-title").textContent = "Setup bearbeiten";
|
||||
$("#cb-new-save").textContent = "Änderungen speichern";
|
||||
$("#cb-new-modal").style.display = "flex";
|
||||
}
|
||||
|
||||
async function saveNewRecipe() {
|
||||
const title = $("#cb-new-title").value.trim();
|
||||
if (!title) return toast("Bitte einen Titel angeben.", true);
|
||||
const models = [...$("#cb-new-models").querySelectorAll(".cb-nm-row")].map(row => ({
|
||||
repo: row.querySelector(".cb-nm-repo").value.trim(),
|
||||
role: row.querySelector(".cb-nm-role").value.trim() || "modell",
|
||||
quant: row.querySelector(".cb-nm-quant").value,
|
||||
})).filter(m => m.repo);
|
||||
if (!models.length) return toast("Mindestens ein Modell (Repo) angeben.", true);
|
||||
const btn = $("#cb-new-save"); btn.disabled = true; btn.textContent = "Speichere…";
|
||||
try {
|
||||
const body = JSON.stringify({ title, desc: $("#cb-new-desc").value.trim(), models });
|
||||
if (editRecipeId) {
|
||||
await api("/api/cookbook/user-recipe/" + encodeURIComponent(editRecipeId), { method: "PUT", body });
|
||||
toast("Setup aktualisiert.");
|
||||
} else {
|
||||
await api("/api/cookbook/user-recipe", { method: "POST", body });
|
||||
toast("Setup gespeichert.");
|
||||
}
|
||||
editRecipeId = null;
|
||||
$("#cb-new-modal").style.display = "none";
|
||||
loadRecipes();
|
||||
} catch (e) { toast("Fehler: " + e.message, true); }
|
||||
btn.disabled = false; btn.textContent = editRecipeId ? "Änderungen speichern" : "Setup speichern";
|
||||
}
|
||||
|
||||
async function deleteRecipe(id) {
|
||||
const r = RECIPES.find(x => x.id === id);
|
||||
if (!await confirmModal({ title: `„${r ? r.title : id}" löschen?`,
|
||||
body: "Dein eigenes Setup wird aus dem Cookbook entfernt. Bereits installierte Modelle bleiben unangetastet.",
|
||||
confirmLabel: "Löschen", danger: true })) return;
|
||||
try { await api("/api/cookbook/user-recipe/" + encodeURIComponent(id), { method: "DELETE" }); toast("Setup gelöscht."); loadRecipes(); }
|
||||
catch (e) { toast(e.message, true); }
|
||||
}
|
||||
|
||||
function openRecipe(id) {
|
||||
const r = RECIPES.find(x => x.id === id); if (!r) return;
|
||||
$("#cb-r-title").textContent = r.title;
|
||||
$("#cb-r-desc").textContent = r.desc;
|
||||
$("#cb-r-models").innerHTML = `<div class="list">${r.models.map(m => `
|
||||
<div class="li" style="align-items:flex-start">
|
||||
<div class="li-main">
|
||||
<div class="flex items-center gap-2" style="flex-wrap:wrap">
|
||||
<span class="li-id">${esc(m.name)}</span><span class="tag text">${esc(m.role)}</span>${moeChip(m.repo)}</div>
|
||||
<div class="li-sub">${esc(m.why)}</div>
|
||||
<div class="li-sub mono-sm">~${m.fit.req_gb.toFixed(1)} GB · ~${Math.round(m.fit.tps)} Tok/s · optimal ~${Math.round(m.optimal_ctx / 1024)}k Kontext</div>
|
||||
</div>
|
||||
<span class="fit-badge ${fitCls(m.fit.level)}">${esc(m.fit.text)}</span>
|
||||
</div>`).join("")}</div>`;
|
||||
const maxRam = Math.max(...r.models.map(m => m.fit.req_gb));
|
||||
$("#cb-r-note").innerHTML = `Größter Spitzenbedarf: <b>~${maxRam.toFixed(1)} GB</b>. ` +
|
||||
`Es läuft immer nur <b>ein</b> Modell gleichzeitig — das größte bestimmt, ob das Setup passt (kein Summieren).`;
|
||||
const btn = $("#cb-r-install");
|
||||
btn.className = r.fit_level === "too_tight" ? "primary warn" : "primary";
|
||||
btn.textContent = r.fit_level === "too_tight" ? "Trotzdem installieren (zu groß)" : "Komplettes Setup installieren";
|
||||
btn.onclick = () => installRecipe(r.id);
|
||||
$("#cb-recipe-modal").style.display = "flex";
|
||||
}
|
||||
|
||||
async function installRecipe(id) {
|
||||
const btn = $("#cb-r-install"); btn.disabled = true; btn.textContent = "Starte…";
|
||||
try {
|
||||
const r = await api("/api/cookbook/install-recipe", { method: "POST", body: JSON.stringify({ recipe_id: id, hf_token: getHfToken() }) });
|
||||
toast(`Setup wird installiert (${r.count} Modelle) — siehe Aktivität.`);
|
||||
$("#cb-recipe-modal").style.display = "none";
|
||||
document.querySelector(".nav-item[data-view='activity']")?.click();
|
||||
} catch (e) { toast("Fehler: " + e.message, true); }
|
||||
btn.disabled = false; btn.textContent = "Komplettes Setup installieren";
|
||||
}
|
||||
|
||||
// ---- Hardware-Chip / Filter ----
|
||||
function renderFilters() {
|
||||
$("#cb-filters").innerHTML = FILTERS.map(f =>
|
||||
`<button class="${activeFilter === f.id ? "primary" : "ghost"}" data-f="${f.id}" style="border-radius:999px">${f.label}</button>`).join("");
|
||||
$("#cb-filters").querySelectorAll("[data-f]").forEach(b =>
|
||||
b.addEventListener("click", () => { activeFilter = b.getAttribute("data-f"); renderFilters(); doSearch(); }));
|
||||
}
|
||||
function renderHwChip() {
|
||||
const el = $("#cb-hw"); if (!el) return;
|
||||
if (lastSys?.ram?.total) el.innerHTML = `${icon("cpu")}${Math.round(lastSys.ram.total / 1024 ** 3)} GB Speicher`;
|
||||
}
|
||||
|
||||
// ---- Profi-Suche (HuggingFace) ----
|
||||
function metricLine(fit) { return `~${fit.req_gb.toFixed(1)} GB · ~${Math.round(fit.tps)} Tok/s`; }
|
||||
|
||||
// Erkennt eine direkte Modell-Angabe: ganze HuggingFace-URL ODER „owner/repo".
|
||||
// So kann man Links wie https://huggingface.co/unsloth/MiniMax-M3-GGUF einfach
|
||||
// reinkopieren und landet exakt auf diesem Modell (statt einer unscharfen Suche).
|
||||
function parseHfRepo(s) {
|
||||
s = s.trim();
|
||||
const u = s.match(/huggingface\.co\/([^\s/]+\/[^\s/?#]+)/i);
|
||||
if (u) return u[1];
|
||||
if (/^[\w.-]+\/[\w.-]+$/.test(s)) return s;
|
||||
return null;
|
||||
}
|
||||
|
||||
async function doSearch() {
|
||||
const raw = $("#cb-search").value.trim();
|
||||
const repo = parseHfRepo(raw);
|
||||
let q = raw;
|
||||
if (activeFilter && !repo) q = q ? q + " " + activeFilter : activeFilter;
|
||||
if (!q && !repo) { $("#cb-grid").innerHTML = ""; return; }
|
||||
const btn = $("#cb-btn-search"); btn.disabled = true; btn.textContent = "Lade…";
|
||||
$("#cb-grid").innerHTML = `<div class="empty" style="grid-column:1/-1;text-align:center">Suche auf HuggingFace…</div>`;
|
||||
try {
|
||||
if (repo) {
|
||||
// Direkter Treffer per Link/Repo — keine Stichwortsuche, sondern genau dieses Modell.
|
||||
currentResults = [{ id: repo, author: repo.split("/")[0], downloads: 0 }];
|
||||
renderResults(currentResults);
|
||||
} else {
|
||||
const sort = $("#cb-sort")?.value || "downloads";
|
||||
// limit=40: auch große/seltenere Modelle (z.B. Llama-4-Scout 109B) erscheinen,
|
||||
// statt von den Top-12-Trends verdrängt zu werden. Fit-Ampel sagt ehrlich, was passt.
|
||||
const url = `https://huggingface.co/api/models?search=${encodeURIComponent(q)}&filter=gguf&sort=${encodeURIComponent(sort)}&direction=-1&limit=40`;
|
||||
const r = await fetch(url);
|
||||
currentResults = await r.json();
|
||||
renderResults(currentResults);
|
||||
}
|
||||
} catch (e) { $("#cb-grid").innerHTML = `<div class="alert err" style="grid-column:1/-1">${esc(e.message)}</div>`; }
|
||||
btn.disabled = false; btn.textContent = "Suchen";
|
||||
}
|
||||
|
||||
function renderResults(results) {
|
||||
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; }
|
||||
grid.innerHTML = results.map((m, i) => `
|
||||
<div class="card" style="display:flex;flex-direction:column;cursor:pointer" data-res="${i}">
|
||||
<div class="flex justify-between" style="align-items:flex-start;gap:8px">
|
||||
<div style="min-width:0"><h3 style="margin:0;font-size:14.5px;font-weight:500;word-break:break-word">${esc(m.id.split("/").pop())}</h3>
|
||||
<div class="text-xs text-mut" style="margin-top:3px">${esc(m.author || "")}</div></div>
|
||||
<span class="fit-badge warn" id="cb-b-${i}">prüfe…</span>
|
||||
</div>
|
||||
<div class="flex gap-2" style="flex-wrap:wrap;margin-top:7px">${capChip(capLabel(m.id))}${moeChip(m.id)}</div>
|
||||
<div style="flex:1;margin-top:12px"></div>
|
||||
<div class="flex justify-between items-center text-xs text-mut" style="border-top:1px solid var(--line);padding-top:11px">
|
||||
<span class="mono-sm" id="cb-m-${i}">Hardware-Fit…</span>
|
||||
<span class="mono-sm">⬇ ${(m.downloads || 0).toLocaleString()}</span></div>
|
||||
</div>`).join("");
|
||||
grid.querySelectorAll("[data-res]").forEach(el => el.addEventListener("click", () => openResult(+el.getAttribute("data-res"))));
|
||||
results.forEach((m, i) => fetchFitForCard(i, m.id));
|
||||
}
|
||||
|
||||
async function fetchFitForCard(i, repo_id) {
|
||||
try {
|
||||
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 = "—"; 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");
|
||||
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() {
|
||||
const file = $("#cb-m-files").value;
|
||||
const f = currentAnalysis?.files.find(x => x.filename === file);
|
||||
if (!f) { $("#cb-m-fit").style.display = "none"; return; }
|
||||
$("#cb-m-fit").style.display = "flex";
|
||||
$("#cb-m-fit-text").innerHTML = `Bedarf: <b>~${f.fit.req_gb.toFixed(1)} GB</b> · ${currentAnalysis.params_b}B · ${esc(f.quant)} · ~${Math.round(f.fit.tps)} Tok/s`;
|
||||
$("#cb-m-fit-badge").innerHTML = `<span class="fit-badge ${fitCls(f.fit.level)}">${esc(f.fit.text)}</span>`;
|
||||
const opt = f.optimal_ctx, recEl = $("#cb-m-ctx-rec");
|
||||
if (opt) {
|
||||
// Kontext-Input auto auf optimal setzen, wenn noch auf Default (8192)
|
||||
const ctxInput = $("#cb-m-ctx");
|
||||
if (ctxInput && (ctxInput.value === "8192" || ctxInput.value === "")) ctxInput.value = opt;
|
||||
}
|
||||
if (recEl) recEl.innerHTML = opt
|
||||
? `Empfohlener Kontext für deine Hardware: <b>~${Math.round(opt / 1024)}k</b> — <a href="#" onclick="event.preventDefault();window.cbSetCtx(${opt})">übernehmen</a>` : "";
|
||||
const btn = $("#cb-m-download");
|
||||
if (f.fit.level === "too_tight") { btn.className = "primary warn"; btn.textContent = "Trotzdem holen (zu groß)"; }
|
||||
else { btn.className = "primary"; btn.textContent = "Herunterladen & Einpflegen"; }
|
||||
}
|
||||
function updateLiveFit() { showFit(); }
|
||||
|
||||
async function reanalyzeCtx() {
|
||||
if (!currentAnalysis) return;
|
||||
const ctx = parseInt($("#cb-m-ctx").value) || 8192;
|
||||
const file = $("#cb-m-files").value;
|
||||
try {
|
||||
currentAnalysis = await api("/api/cookbook/analyze", { method: "POST", body: JSON.stringify({ repo_id: currentAnalysis.repo, ctx }) });
|
||||
$("#cb-m-files").value = file; showFit();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function openModalBase(title, repo) {
|
||||
$("#cb-modal").style.display = "flex";
|
||||
$("#cb-m-title").textContent = title;
|
||||
$("#cb-m-repo").textContent = repo;
|
||||
}
|
||||
|
||||
async function openResult(i) {
|
||||
const m = currentResults[i]; if (!m) return;
|
||||
openModalBase(m.id.split("/").pop(), m.id);
|
||||
$("#cb-m-alias").value = "";
|
||||
$("#cb-m-files").style.display = "none"; $("#cb-m-loading").style.display = "block"; $("#cb-m-download").disabled = true;
|
||||
try {
|
||||
const ctx = parseInt($("#cb-m-ctx").value) || 8192;
|
||||
currentAnalysis = await api("/api/cookbook/analyze", { method: "POST", body: JSON.stringify({ repo_id: m.id, ctx }) });
|
||||
$("#cb-m-loading").style.display = "none"; $("#cb-m-files").style.display = "block";
|
||||
if (!currentAnalysis.files?.length) { $("#cb-m-files").innerHTML = "<option>Keine GGUF-Dateien gefunden</option>"; $("#cb-m-fit").style.display = "none"; return; }
|
||||
$("#cb-m-files").innerHTML = currentAnalysis.files.map(f => {
|
||||
const mark = f.fit.level === "perfect" ? "●" : f.fit.level === "marginal" ? "◐" : "○";
|
||||
return `<option value="${esc(f.filename)}">${mark} ${esc(f.filename)}</option>`;
|
||||
}).join("");
|
||||
$("#cb-m-download").disabled = false; showFit();
|
||||
} catch (e) { $("#cb-m-loading").textContent = "Fehler: " + e.message; }
|
||||
}
|
||||
|
||||
async function doDownload() {
|
||||
const repo = $("#cb-m-repo").textContent, file = $("#cb-m-files").value;
|
||||
const role = $("#cb-m-alias").value.trim().toLowerCase(), ctx = parseInt($("#cb-m-ctx").value) || 8192;
|
||||
if (!repo || !file) return toast("Bitte eine GGUF-Datei wählen.", true);
|
||||
const btn = $("#cb-m-download"); btn.disabled = true; btn.textContent = "Starte…";
|
||||
try {
|
||||
const res = await api("/api/download", { method: "POST", body: JSON.stringify({ repo, file, hf_token: getHfToken() }) });
|
||||
await api("/api/register", { method: "POST", body: JSON.stringify({ role, model_path: res.expected_path, ctx }) });
|
||||
toast("Download gestartet — siehe Aktivität.");
|
||||
$("#cb-modal").style.display = "none";
|
||||
document.querySelector(".nav-item[data-view='activity']")?.click();
|
||||
} catch (e) { toast("Fehler: " + e.message, true); }
|
||||
btn.disabled = false; btn.textContent = "Herunterladen & Einpflegen";
|
||||
}
|
||||
|
||||
function onSystem(sys) { lastSys = sys; renderHwChip(); }
|
||||
|
||||
export default { id: "cookbook", mount, onSystem };
|
||||
@@ -1,145 +0,0 @@
|
||||
// guides.js — Anfänger-Guide (v6): praktische Tutorials/Workflows + kompakte Konzepte.
|
||||
|
||||
import { $ } from "../core/ui.js";
|
||||
|
||||
const TUTORIALS = [
|
||||
["Dein erster Workflow — in 3 Schritten", `
|
||||
<ol class="tut-steps">
|
||||
<li><b>Modell holen:</b> Tab <b>Cookbook</b> → wähle deinen Anwendungsfall (z.B. „Coden") →
|
||||
<b>Komplettes Setup installieren</b>. Der Download läuft im Tab <b>Aktivität</b>.</li>
|
||||
<li><b>Verbinden:</b> Tab <b>Verbinden</b> → Basis-URL kopieren → in deinem Tool (z.B. OpenCode)
|
||||
als „OpenAI-kompatibel" einfügen. <b>Verbindung testen</b> bestätigt, dass es läuft.</li>
|
||||
<li><b>Arbeiten:</b> Im Tool das Modell (z.B. <code>coder</code>) wählen und loslegen. Das passende
|
||||
Modell wird <b>automatisch geladen</b> — du musst nichts starten.</li>
|
||||
</ol>`],
|
||||
["Wie leere ich den Kontext? Machen die Tools das selbst?", `
|
||||
<p><b>Kurz: meistens ja, automatisch.</b> Der Kontext (das „Gedächtnis") gilt pro Unterhaltung.
|
||||
Startest du im Tool einen <b>neuen Chat / eine neue Session</b>, ist der Kontext wieder leer —
|
||||
das Tool verwaltet das Fenster für dich.</p>
|
||||
<p>Du musst nur selten eingreifen: wird ein Gespräch sehr lang, fang einen neuen an. <b>Wichtig:</b>
|
||||
„Grafikspeicher leeren" (Tab Server) ist etwas anderes — das wirft das <i>Modell</i> aus dem Speicher,
|
||||
nicht den Gesprächsverlauf.</p>`],
|
||||
["Was passiert, wenn der Kontext volläuft?", `
|
||||
<p>Ist das Kontextfenster voll, passiert je nach Tool eins von dreien: das Modell <b>vergisst den
|
||||
Anfang</b> (älteste Nachrichten fallen raus), das Tool <b>kürzt automatisch</b>, oder du bekommst
|
||||
einen Fehler wie „context length exceeded".</p>
|
||||
<p><b>So gehst du vor:</b></p>
|
||||
<ol class="tut-steps">
|
||||
<li><b>Neuen Chat starten</b> — der einfachste Reset, Kontext ist wieder leer.</li>
|
||||
<li><b>Zusammenfassen lassen:</b> „Fasse unseren Stand kurz zusammen", dann im neuen Chat einfügen.</li>
|
||||
<li><b>Kontext vergrößern:</b> Tab <b>Modelle</b> → <b>Konfigurieren</b> → wir zeigen das Optimum
|
||||
für deine Hardware (mehr Kontext = mehr Speicherbedarf).</li>
|
||||
</ol>`],
|
||||
["Was ist ein Agent — und wann brauche ich einen?", `
|
||||
<p>Ein nacktes Modell <b>schreibt nur Text zurück</b>. Ein <b>Agent</b> ist ein Programm drumherum,
|
||||
das das Modell <b>handeln</b> lässt: Dateien lesen und ändern, Befehle ausführen, im Web suchen —
|
||||
Schritt für Schritt in einer Schleife, bis die Aufgabe erledigt ist.</p>
|
||||
<p><b>Bild dazu:</b> Reiner Chat = du fragst einen Experten und tippst seine Antwort selbst ab.
|
||||
Agent = der Experte sitzt an deinem PC und <i>macht es selbst</i> — du schaust zu und bestätigst.</p>
|
||||
<p><b>Wann lohnt sich was?</b></p>
|
||||
<ul class="tut-steps" style="list-style:disc">
|
||||
<li><b>Fragen, Texte, Erklärungen, Brainstorming</b> → <u>kein</u> Agent nötig, eine Chat-Oberfläche
|
||||
wie <b>OpenWebUI</b> reicht völlig.</li>
|
||||
<li><b>Coden über mehrere Dateien, Befehle ausführen, recherchieren + umsetzen, automatisieren</b>
|
||||
→ <b>Agent</b> (OpenCode, Cline, Continue).</li>
|
||||
</ul>
|
||||
<p><b>So arbeitest du damit:</b> Du beschreibst ein <b>Ziel</b> in Worten („baue Feature X", „finde den
|
||||
Bug in dieser Datei"). Der Agent macht sich einen Plan und arbeitet ihn ab; heikle Schritte bestätigst
|
||||
du. Lass ihn am besten in einem <b>Projektordner mit Git</b> arbeiten — dann ist jede Änderung
|
||||
nachvollziehbar und rückgängig zu machen.</p>
|
||||
<p><b>Gut zu wissen:</b> Diese Agenten <b>laufen lokal auf deinem PC</b> und reden mit deinem lokalen
|
||||
Modell. Du brauchst <b>keinen</b> Cloud-Dienst — die „empfohlenen Tools" im Tab <b>Verbinden</b> SIND
|
||||
die Agenten.</p>`],
|
||||
["Was sind Skills — und wie installiere ich sie?", `
|
||||
<p>Ein <b>Skill</b> ist <b>verpacktes Können</b>: eine kurze Anleitung (oft + ein paar Hilfsskripte),
|
||||
die dem Agenten beibringt, eine <b>wiederkehrende Aufgabe</b> jedes Mal so zu erledigen, wie <i>du</i>
|
||||
es willst. Stell es dir wie ein Rezept oder eine Arbeitsanweisung vor, die der Agent liest, sobald sie passt.</p>
|
||||
<p><b>Skill vs. MCP — nicht verwechseln:</b> <b>MCP</b> gibt dem Agenten <b>neue Werkzeuge</b> (Aktionen,
|
||||
z.B. „auf eine Datenbank zugreifen"). <b>Skills</b> geben ihm <b>Know-how</b> (wie er etwas <i>gut</i> macht)
|
||||
mit den Werkzeugen, die er schon hat. Kurz: <b>Werkzeuge = MCP, Anleitungen = Skills.</b></p>
|
||||
<p><b>Wofür gut?</b></p>
|
||||
<ul class="tut-steps" style="list-style:disc">
|
||||
<li><b>Einheitlichkeit:</b> „Commit-Nachrichten immer in diesem Format", „unsere Komponenten sehen so aus".</li>
|
||||
<li><b>Wiederholung sparen:</b> du musst denselben Kontext nicht jedes Mal neu erklären.</li>
|
||||
<li><b>Wissen festhalten:</b> „so testen wir", „so wird ein Changelog geschrieben", Fach-Workflows.</li>
|
||||
</ul>
|
||||
<p><b>In deinen Tools heißt das konkret:</b></p>
|
||||
<ul class="tut-steps" style="list-style:disc">
|
||||
<li><b>OpenCode:</b> Projektregeln in einer <code>AGENTS.md</code> + eigene Befehle (Commands).</li>
|
||||
<li><b>Cline:</b> Regeln als <code>.clinerules</code> + „Workflows".</li>
|
||||
<li><b>Continue:</b> „Rules" + „Prompts".</li>
|
||||
<li><b>Offener Standard „Agent Skills":</b> ein Ordner mit einer <code>SKILL.md</code> (Beschreibung +
|
||||
Anleitung, optional Skripte) — zunehmend von mehreren Tools unterstützt.</li>
|
||||
</ul>
|
||||
<p><b>Installieren = meist nur eine Datei/einen Ordner ablegen</b> (kein Build, kein Neustart-Zauber):</p>
|
||||
<ol class="tut-steps">
|
||||
<li><b>Pro Projekt:</b> die Datei in den Projektordner legen (z.B. <code>AGENTS.md</code>,
|
||||
<code>.clinerules/</code>) — gilt nur für dieses Projekt.</li>
|
||||
<li><b>Global:</b> in den Konfig-Ordner des Tools — gilt überall. Das Tool liest es beim nächsten Start.</li>
|
||||
</ol>
|
||||
<p><b>Beste Quellen:</b> zuerst die <b>Doku deines Tools</b> (OpenCode/Cline/Continue) — am verlässlichsten.
|
||||
Dann <b>„awesome"-Listen auf GitHub</b> (z.B. <i>awesome-claude-skills</i>, <i>awesome-mcp-servers</i>) und
|
||||
die <b>quelloffenen Anthropic-Skills</b> als Vorlagen, um das Format zu lernen.</p>
|
||||
<p class="hint" style="margin-top:10px"><b>Für lokale Modelle wichtig:</b> Skills sind nur Text-Anleitungen
|
||||
und funktionieren mit <i>jedem</i> Modell — aber kleinere lokale Modelle befolgen komplexe Skills weniger
|
||||
zuverlässig als große. Halte sie <b>kurz, klar und konkret</b>.</p>`],
|
||||
["MCP — deinem Agenten echte Werkzeuge geben", `
|
||||
<p><b>MCP</b> (Model Context Protocol) ist ein einheitlicher „Stecker", über den ein Agent zusätzliche
|
||||
<b>Werkzeuge</b> bekommt: das Web durchsuchen, Dateien lesen, eine Datenbank abfragen, GitHub bedienen …
|
||||
Ohne MCP kann das Modell nur reden; mit MCP kann es <b>etwas tun</b>.</p>
|
||||
<p><b>So nutzt du es:</b> Du fügst in deinem Agenten einen „MCP-Server" hinzu (eine kleine Zeile in der
|
||||
Konfiguration je Werkzeug). Danach entscheidet der Agent selbst, wann er das Werkzeug einsetzt.</p>
|
||||
<ul class="tut-steps" style="list-style:disc">
|
||||
<li><b>Unterstützt:</b> Cline und Continue haben das beste MCP-Ökosystem; viele fertige Server gibt es
|
||||
auf GitHub (Suchbegriff <i>awesome-mcp-servers</i>).</li>
|
||||
<li><b>Faustregel:</b> Fehlt dem Agenten eine <b>Fähigkeit</b> (z.B. Web-Zugriff) → MCP. Soll er etwas
|
||||
nur <b>besser/einheitlicher machen</b> → Skill.</li>
|
||||
</ul>`],
|
||||
["Auto-Swap — wie das Laden funktioniert", `
|
||||
<p>llama-swap lädt automatisch das Modell, das gerade angefragt wird, und entlädt es nach einer
|
||||
Leerlaufzeit wieder (oder wenn du ein anderes anfragst). <b>Es läuft immer nur eines gleichzeitig.</b></p>
|
||||
<p>Heißt für dich: Du richtest beliebig viele Modelle ein und wählst im Tool einfach das passende —
|
||||
das Laden/Entladen passiert im Hintergrund, ohne dass du etwas tun musst.</p>`],
|
||||
["Die empfohlenen Tools optimal nutzen", `
|
||||
<ul class="tut-steps" style="list-style:disc">
|
||||
<li><b>OpenCode:</b> Autonomes Coden im Terminal/Editor — ideal für „bau mir Feature X".</li>
|
||||
<li><b>Cline:</b> Dasselbe als VS-Code-Erweiterung, mit Bestätigung vor jeder Aktion.</li>
|
||||
<li><b>OpenWebUI:</b> Klassische Chat-Oberfläche für Fragen/Texte.</li>
|
||||
</ul>
|
||||
<p><b>Tipp:</b> starkes Modell (<code>coder</code>) für schwere Aufgaben, schnelles
|
||||
(<code>coder-fast</code>) für simple — du wählst es einfach im Tool aus.</p>`],
|
||||
];
|
||||
|
||||
const CONCEPTS = [
|
||||
["LLM / Modell", "Die KI selbst — versteht und schreibt Text (wie ChatGPT, nur lokal auf deinem PC). Ein Modell ist eine konkrete Datei davon, oft auf etwas spezialisiert (Code, Bilder, Chat)."],
|
||||
["GGUF", "Das lokal lauffähige Dateiformat. Unsere Engine lädt nur GGUF — Repos ohne GGUF kann sie nicht nutzen."],
|
||||
["Quantisierung (Q4_K_M)", "Verkleinert ein Modell (16-Bit → 4-Bit), damit es in den Speicher passt — bei kaum Qualitätsverlust. Q4_K_M ist der beliebte Sweetspot."],
|
||||
["Kontextfenster", "Das Kurzzeitgedächtnis: wie viel Text sich das Modell gleichzeitig merkt. Größer = mehr Überblick, mehr Speicher, etwas langsamer."],
|
||||
["Auto-Swap", "llama-swap lädt/entlädt Modelle automatisch — immer nur eins gleichzeitig. Du managst nichts."],
|
||||
["Agent", "Ein Programm (OpenCode, Cline, Continue), das das Modell handeln lässt — Dateien lesen/ändern, Befehle ausführen, planen, im Loop bis die Aufgabe erledigt ist. Reiner Chat schreibt nur Text zurück."],
|
||||
["MCP", "Model Context Protocol — der Anschluss, über den ein Agent zusätzliche Werkzeuge bekommt (Web, Dateien, Datenbank). MCP = Werkzeuge."],
|
||||
["Skills", "Verpackte Anleitung für wiederkehrende Aufgaben — der Agent liest sie und macht es jedes Mal wie du willst. Skills = Anleitungen (das Gegenstück zu MCP-Werkzeugen)."],
|
||||
["Rules / Commands", "Die konkrete Form von Skills je Tool: AGENTS.md (OpenCode), .clinerules (Cline), Rules/Prompts (Continue) — eine Datei im Projekt- oder Konfig-Ordner."],
|
||||
];
|
||||
|
||||
function mount() {
|
||||
const c = $(".view[data-view='guides']"); if (!c) return;
|
||||
c.innerHTML = `
|
||||
<div class="pagehead"><div>
|
||||
<h1>Guide für Einsteiger</h1>
|
||||
<div class="sub">Praktische Workflows + die Local-LLM-Welt in Klartext — ohne Vorwissen.</div></div></div>
|
||||
|
||||
<div class="card-h"><h3>Tutorials & Workflows</h3></div>
|
||||
<div class="card" style="padding:0;overflow:hidden">
|
||||
${TUTORIALS.map(([t, b], i) => `
|
||||
<details class="guide-acc"${i === 0 ? " open" : ""}><summary>${t}</summary>
|
||||
<div class="acc-body">${b}</div></details>`).join("")}
|
||||
</div>
|
||||
|
||||
<div class="card-h" style="margin-top:8px"><h3>Konzepte kurz erklärt</h3></div>
|
||||
<div class="concept-grid">
|
||||
${CONCEPTS.map(([term, text]) => `<div class="concept"><h4>${term}</h4><p>${text}</p></div>`).join("")}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export default { id: "guides", mount };
|
||||
@@ -1,155 +0,0 @@
|
||||
// jobs.js — Aktivität (v3): Live-System-Metriken + Hintergrund-Jobs mit Log.
|
||||
// Exportiert track(id), damit andere Panels einen Job auto-aufklappen.
|
||||
|
||||
import { api } from "../core/api.js";
|
||||
import { $, esc, fmtBytes, toast } from "../core/ui.js";
|
||||
|
||||
const tracked = new Set();
|
||||
let JOBS = [];
|
||||
let SYS = null;
|
||||
|
||||
export function track(id) { tracked.add(id); renderJobs(); }
|
||||
|
||||
const hist = { cpu: [], ram: [], gpu: [] };
|
||||
const MAX_HIST = 60;
|
||||
|
||||
function statusBadge(s) { return s === "done" ? '<span class="badge b-run">fertig</span>' : s === "failed" ? '<span class="badge b-err">fehler</span>' : s === "canceled" ? '<span class="badge">abgebrochen</span>' : '<span class="badge b-load">läuft…</span>'; }
|
||||
function dotClass(s) { return s === "done" ? "on" : (s === "failed" || s === "canceled") ? "" : "load"; }
|
||||
|
||||
// Fortschritt in %: bevorzugt der exakte Wert vom Backend (Download-Datei-Polling),
|
||||
// sonst Fallback auf eine %-Angabe in der letzten Log-Zeile.
|
||||
function jobPct(j) {
|
||||
if (j.state !== "running") return null;
|
||||
if (typeof j.progress === "number") return Math.min(100, j.progress);
|
||||
const last = (j.log || [])[j.log.length - 1] || "";
|
||||
const m = last.match(/(\d+(?:\.\d+)?)\s*%/);
|
||||
return m ? Math.min(100, parseFloat(m[1])) : null;
|
||||
}
|
||||
|
||||
// Restzeit menschlich: "noch ~2 min" / "noch ~45 s".
|
||||
function fmtEta(s) {
|
||||
if (s == null || s < 0) return "";
|
||||
if (s >= 90) return `noch ~${Math.round(s / 60)} min`;
|
||||
return `noch ~${Math.max(1, Math.round(s))} s`;
|
||||
}
|
||||
|
||||
async function cancelJob(id) {
|
||||
try { await api(`/api/jobs/${id}/cancel`, { method: "POST" }); toast("Abbruch angefordert…"); renderJobs(); }
|
||||
catch (e) { toast(e.message, true); }
|
||||
}
|
||||
|
||||
async function deleteJob(id) {
|
||||
try { await api(`/api/jobs/${id}`, { method: "DELETE" }); renderJobs(); }
|
||||
catch (e) { toast(e.message, true); }
|
||||
}
|
||||
|
||||
async function clearJobs() {
|
||||
try { const r = await api("/api/jobs/clear", { method: "POST" }); toast(`${r.removed} Einträge entfernt.`); renderJobs(); }
|
||||
catch (e) { toast(e.message, true); }
|
||||
}
|
||||
|
||||
function tile(label, value, sub) {
|
||||
return `<div class="tile"><div class="t-l">${label}</div><div class="t-v">${value}</div><div class="t-s">${sub}</div></div>`;
|
||||
}
|
||||
function meter(label, pct) {
|
||||
const p = Math.max(0, Math.min(100, pct || 0));
|
||||
const cls = p >= 90 ? "bad" : p >= 75 ? "warn" : "";
|
||||
return `<div class="meter"><div class="meter-h"><span class="mk">${label}</span><span class="mv">${Math.round(p)} %</span></div><div class="bar ${cls}"><i style="width:${Math.max(2, p)}%"></i></div></div>`;
|
||||
}
|
||||
function spark(arr, varName) {
|
||||
return '<div style="display:flex;align-items:flex-end;gap:2px;height:38px;margin-top:10px">' +
|
||||
arr.map(v => `<div style="width:4px;background:var(${varName});opacity:.55;height:${Math.max(2, v)}%;border-radius:2px"></div>`).join("") + "</div>";
|
||||
}
|
||||
|
||||
function gpuPct() {
|
||||
const g = SYS?.gpu;
|
||||
if (g && (g.vram.total + g.gtt.total) > 0) return ((g.vram.used + g.gtt.used) / (g.vram.total + g.gtt.total)) * 100;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function renderSys() {
|
||||
if (!SYS) return;
|
||||
hist.cpu.push(SYS.cpu.percent); hist.ram.push(SYS.ram.percent); hist.gpu.push(gpuPct());
|
||||
for (const k of ["cpu", "ram", "gpu"]) if (hist[k].length > MAX_HIST) hist[k].shift();
|
||||
|
||||
const k = $("#act-kpis");
|
||||
if (k) k.innerHTML =
|
||||
tile("Prozessor (CPU)", `${Math.round(SYS.cpu.percent)}<small> %</small>`, SYS.cpu.temp != null ? `${Math.round(SYS.cpu.temp)}° CPU-Temp` : "Auslastung") +
|
||||
tile("Arbeitsspeicher", `${Math.round(SYS.ram.percent)}<small> %</small>`, `${fmtBytes(SYS.ram.used)} / ${fmtBytes(SYS.ram.total)}`) +
|
||||
tile("Grafikspeicher", `${Math.round(gpuPct())}<small> %</small>`, SYS.gpu_temp != null ? `${Math.round(SYS.gpu_temp)}° GPU-Temp` : "VRAM + GTT");
|
||||
|
||||
const g = SYS.gpu;
|
||||
const gpuStr = g && (g.vram.total + g.gtt.total) > 0 ? `${fmtBytes(g.vram.used + g.gtt.used)} / ${fmtBytes(g.vram.total + g.gtt.total)}` : "–";
|
||||
const s = $("#act-sys");
|
||||
if (s) s.innerHTML = `
|
||||
<div class="card-h"><h3>System-Metriken (Bosgame)</h3></div>
|
||||
<div class="card-sub">Live-Auslastung deines Mini-PCs, alle 0,5 Sekunden.</div>
|
||||
<div class="kv" style="margin-bottom:6px">
|
||||
<div class="kv-row"><span class="kv-k">Arbeitsspeicher (RAM)</span><span class="kv-v">${fmtBytes(SYS.ram.used)} / ${fmtBytes(SYS.ram.total)}</span></div>
|
||||
<div class="kv-row"><span class="kv-k">Grafikspeicher (VRAM + GTT)</span><span class="kv-v">${gpuStr}</span></div>
|
||||
<div class="kv-row"><span class="kv-k">Speicherplatz (Disk)</span><span class="kv-v">${Math.round(SYS.disk.percent)} % belegt</span></div>
|
||||
<div class="kv-row"><span class="kv-k">Temperatur (GPU / CPU)</span><span class="kv-v">${SYS.gpu_temp != null ? Math.round(SYS.gpu_temp) + "°" : "–"} / ${SYS.cpu.temp != null ? Math.round(SYS.cpu.temp) + "°" : "–"}</span></div>
|
||||
</div>
|
||||
<div class="grid grid-3" style="margin-top:14px">
|
||||
<div><div class="meta text-xs">CPU-Verlauf</div>${spark(hist.cpu, "--accent")}</div>
|
||||
<div><div class="meta text-xs">RAM-Verlauf</div>${spark(hist.ram, "--purple")}</div>
|
||||
<div><div class="meta text-xs">VRAM-Verlauf</div>${spark(hist.gpu, "--on")}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function mount() {
|
||||
$("#act-head").innerHTML = `<div class="pagehead"><div>
|
||||
<h1>Aktivität</h1>
|
||||
<div class="sub">Live-Auslastung und laufende Aufgaben (Downloads, Updates) mit Protokoll.</div></div></div>`;
|
||||
|
||||
$("#v-activity").innerHTML = `
|
||||
<div class="card-h"><h3>Hintergrund-Aufgaben</h3><span class="meta" id="job-count"></span>
|
||||
<button class="ghost" id="jobs-clear" style="margin-left:auto;display:none;padding:4px 11px;font-size:12px">Verlauf leeren</button></div>
|
||||
<div class="card-sub">Downloads & Updates erscheinen hier mit Live-Protokoll — zum Aufklappen klicken.</div>
|
||||
<div id="jobs"></div>
|
||||
<div id="jobs-empty" class="empty-c"><div class="e-t">Gerade nichts los.</div><div class="e-s">Alles ruhig — keine laufenden Aufgaben.</div></div>`;
|
||||
|
||||
$("#v-activity").addEventListener("click", e => {
|
||||
if (e.target.closest("#jobs-clear")) { clearJobs(); return; }
|
||||
const cancel = e.target.closest("[data-cancel]");
|
||||
if (cancel) { e.stopPropagation(); cancelJob(cancel.getAttribute("data-cancel")); return; }
|
||||
const del = e.target.closest("[data-del]");
|
||||
if (del) { e.stopPropagation(); deleteJob(del.getAttribute("data-del")); return; }
|
||||
const h = e.target.closest(".job-h"); if (!h) return;
|
||||
const id = h.getAttribute("data-id");
|
||||
tracked.has(id) ? tracked.delete(id) : tracked.add(id);
|
||||
renderJobs();
|
||||
});
|
||||
if (SYS) renderSys();
|
||||
}
|
||||
|
||||
function renderJobs() {
|
||||
const c = $("#jobs"); if (!c) return;
|
||||
$("#jobs-empty").style.display = JOBS.length ? "none" : "flex";
|
||||
const failed = JOBS.filter(j => j.state === "failed").length;
|
||||
const finished = JOBS.filter(j => j.state !== "running" && j.state !== "queued").length;
|
||||
$("#job-count").textContent = JOBS.length ? (failed ? failed + " Fehler" : JOBS.length + " gesamt") : "";
|
||||
const clearBtn = $("#jobs-clear"); if (clearBtn) clearBtn.style.display = finished ? "" : "none";
|
||||
c.innerHTML = JOBS.map(j => {
|
||||
const log = tracked.has(j.id) ? `<div class="log">${esc((j.log || []).join("\n"))}</div>` : "";
|
||||
const p = jobPct(j);
|
||||
const prog = p != null ? `<div class="bar" style="margin-top:8px"><i style="width:${p}%"></i></div>` : "";
|
||||
// bei laufenden Downloads: Prozent + Restzeit + Rate
|
||||
const eta = j.state === "running" ? fmtEta(j.eta_s) : "";
|
||||
const rate = j.state === "running" && j.rate_bps ? fmtBytes(j.rate_bps) + "/s" : "";
|
||||
const extra = [eta, rate].filter(Boolean).join(" · ");
|
||||
const pctTxt = p != null
|
||||
? `<span class="mono-sm" style="margin-left:auto">${Math.round(p)} %${extra ? ` · ${extra}` : ""}</span>`
|
||||
: "";
|
||||
const cancelBtn = j.state === "running"
|
||||
? `<button class="ghost" data-cancel="${esc(j.id)}" style="${p != null ? "" : "margin-left:auto;"}margin-right:2px;padding:2px 9px;font-size:12px">Abbrechen</button>`
|
||||
: `<button class="ghost" data-del="${esc(j.id)}" title="Aus Verlauf entfernen" style="margin-left:auto;margin-right:2px;padding:2px 9px;font-size:14px;line-height:1">×</button>`;
|
||||
return `<div class="job"><div class="job-h" data-id="${esc(j.id)}">
|
||||
<span class="li-dot ${dotClass(j.state)}"></span><span class="mid">${esc(j.label)}</span>${pctTxt}${cancelBtn}${statusBadge(j.state)}</div>${prog}${log}</div>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function onJobs(jobs) { JOBS = jobs || []; renderJobs(); }
|
||||
function onSystem(sys) { SYS = sys; if ($("#act-sys")) renderSys(); }
|
||||
|
||||
export default { id: "jobs", mount, onJobs, onSystem };
|
||||
@@ -1,268 +0,0 @@
|
||||
// models.js — "Modelle"-Ansicht (v3): Schnelltest-Chat, Modell-Tabelle, Kontext-Konfiguration.
|
||||
|
||||
import { api } from "../core/api.js";
|
||||
import { $, badge, esc, toast, confirmModal, infoDot } from "../core/ui.js";
|
||||
|
||||
let JOBS_CACHE = [];
|
||||
|
||||
const CTX_HELP = "Kontext = das Kurzzeitgedächtnis des Modells. Größer = merkt sich mehr (längere Dateien/Chats), braucht aber mehr Speicher und wird etwas langsamer.";
|
||||
const ROLE_HELP = "Die Rolle ist ein zusätzlicher, sprechender Name (z.B. coder). Du kannst in deinen Tools entweder den echten Modellnamen ODER die Rolle angeben — beides führt zum selben Modell.";
|
||||
|
||||
// Kanonische Rollen für die Schnellwahl (frei tippbar bleibt möglich).
|
||||
const ROLES = [
|
||||
{ id: "coder", label: "Coder", desc: "Programmieren & Code" },
|
||||
{ id: "vision", label: "Vision", desc: "Bilder verstehen" },
|
||||
{ id: "scout", label: "Scout", desc: "Schneller Allrounder" },
|
||||
{ id: "reviewer", label: "Reviewer", desc: "Code/Texte prüfen" },
|
||||
{ id: "manager", label: "Manager", desc: "Planen & koordinieren" },
|
||||
];
|
||||
|
||||
let ALL = [];
|
||||
function refreshSoon() { document.dispatchEvent(new Event("mc:refresh")); }
|
||||
|
||||
// Badge mit Download-Fortschritt aus Backend-State (download_progress vom status endpoint)
|
||||
function stateBadge(m) {
|
||||
return badge(m.state, m.download_progress);
|
||||
}
|
||||
|
||||
function roleTag(role) {
|
||||
return role ? `<span class="tag" style="background:rgba(45,212,191,.14);color:var(--accent);border-color:rgba(45,212,191,.3)">${esc(role)}</span>` : "";
|
||||
}
|
||||
// Zeigt die API-Namen (echter Name + Rollen-Alias) mit Kopier-Knopf — das, was man im Tool angibt.
|
||||
function apiIdLine(m) {
|
||||
const ids = (m.api_ids && m.api_ids.length ? m.api_ids : [m.name]);
|
||||
return `<div class="li-sub mono-sm" style="margin-top:3px;display:flex;align-items:center;gap:6px;flex-wrap:wrap">
|
||||
<span class="text-mut">API-Name:</span>
|
||||
${ids.map(id => `<code style="cursor:pointer" data-action="copy" data-name="${esc(id)}" title="Klicken zum Kopieren">${esc(id)}</code>`).join('<span class="text-mut">·</span>')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function capTags(caps) {
|
||||
if (!caps?.length) return "–";
|
||||
return caps.map(c =>
|
||||
c === "Code" ? `<span class="tag code">Code</span>`
|
||||
: c === "Bild" ? `<span class="tag img">Bild</span>`
|
||||
: `<span class="tag text">Text</span>`).join(" ");
|
||||
}
|
||||
function details(meta) {
|
||||
if (!meta) return "–";
|
||||
const q = meta.quant || "?";
|
||||
const c = meta.ctx ? Math.round(meta.ctx / 1024) + "K" : "?";
|
||||
const s = meta.size_bytes ? (meta.size_bytes / 1024 ** 3).toFixed(1) + " GB" : "?";
|
||||
return `<span class="mono-sm">${esc(q)} · ${c} · ${s}</span>`;
|
||||
}
|
||||
|
||||
function mount() {
|
||||
$("#m-head").innerHTML = `<div class="pagehead"><div>
|
||||
<h1>Modelle</h1>
|
||||
<div class="sub">Deine konfigurierten Modelle — testen, Kontext anpassen oder aus dem Speicher werfen.</div></div></div>`;
|
||||
|
||||
$("#m-chat").innerHTML = `
|
||||
<div class="card-h"><h3>Schnelltest</h3></div>
|
||||
<div class="card-sub">Schreib eine Nachricht — das gewählte Modell wird automatisch geweckt.</div>
|
||||
<label>Modell</label>
|
||||
<select id="chat-model"></select>
|
||||
<label>Nachricht</label>
|
||||
<textarea id="chat-msg" placeholder="z.B. „Erklär mir kurz, was du kannst."></textarea>
|
||||
<button class="primary" id="chat-btn">Senden</button>
|
||||
<div id="chat-reply" class="reply" style="display:none"></div>`;
|
||||
|
||||
$("#m-table").innerHTML = `
|
||||
<div class="card-h"><h3>Modelle & Ports</h3><span class="meta" id="m-count"></span></div>
|
||||
<div class="card-sub">Modelle laden automatisch, sobald eine Anfrage kommt — du musst nichts manuell starten.</div>
|
||||
<table>
|
||||
<thead><tr><th>Modell</th><th>Kann</th><th>Details</th><th>Status</th><th>Port</th><th style="text-align:right">Aktionen</th></tr></thead>
|
||||
<tbody id="models"></tbody>
|
||||
</table>
|
||||
<div id="models-empty" class="empty-c" style="display:none">
|
||||
<div class="e-t">Noch keine Modelle konfiguriert</div>
|
||||
<div class="e-s">Hol dir unter „Cookbook" ein passendes Modell.</div>
|
||||
</div>
|
||||
|
||||
<div id="cfg-modal" class="modal-overlay" style="display:none">
|
||||
<div class="modal-card">
|
||||
<button id="cfg-close" class="ghost" style="position:absolute;top:14px;right:14px">Schließen</button>
|
||||
<h3>Modell konfigurieren</h3>
|
||||
<p class="mono-sm" id="cfg-model-name" style="margin:-4px 0 16px"></p>
|
||||
<label>Kontext-Größe (Tokens) ${infoDot(CTX_HELP)}</label>
|
||||
<input id="cfg-ctx" type="number" value="8192">
|
||||
<div class="tile" id="cfg-rec" style="display:none;margin:8px 0 10px;justify-content:space-between;align-items:center" >
|
||||
<div style="flex:1"><div style="font-size:13px">Empfohlen für deine Hardware</div>
|
||||
<div class="hint" id="cfg-rec-note" style="margin:4px 0 0"></div></div>
|
||||
<button class="ghost" id="cfg-rec-apply">Optimal übernehmen</button>
|
||||
</div>
|
||||
<div class="hint" style="margin-bottom:14px">Es läuft immer nur <b>ein</b> Modell gleichzeitig — ein größerer Kontext hier beeinflusst deine anderen Modelle nicht.</div>
|
||||
<button class="primary" id="cfg-save" style="width:100%;margin-top:6px">Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="role-modal" class="modal-overlay" style="display:none">
|
||||
<div class="modal-card">
|
||||
<button id="role-close" class="ghost" style="position:absolute;top:14px;right:14px">Schließen</button>
|
||||
<h3>Rolle festlegen ${infoDot(ROLE_HELP)}</h3>
|
||||
<p class="mono-sm" id="role-model-name" style="margin:-4px 0 16px"></p>
|
||||
<div class="hint" style="margin-bottom:10px">Wähle eine Rolle als zweiten, sprechenden Namen. In deinen Tools funktionieren danach <b>beide</b>: der echte Modellname und die Rolle.</div>
|
||||
<div id="role-chips" class="flex gap-2" style="flex-wrap:wrap;margin-bottom:12px"></div>
|
||||
<label>Eigene Rolle (optional)</label>
|
||||
<input id="role-custom" placeholder="z.B. uebersetzer">
|
||||
<div class="btn-row" style="margin-top:14px;display:flex;gap:8px">
|
||||
<button class="ghost" id="role-clear" style="flex:1">Rolle entfernen</button>
|
||||
<button class="primary" id="role-save" style="flex:2">Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
$("#chat-btn").addEventListener("click", sendChat);
|
||||
$("#cfg-close").addEventListener("click", () => $("#cfg-modal").style.display = "none");
|
||||
$("#cfg-modal").addEventListener("click", e => { if (e.target.id === "cfg-modal") $("#cfg-modal").style.display = "none"; });
|
||||
$("#cfg-save").addEventListener("click", saveConfig);
|
||||
$("#role-close").addEventListener("click", () => $("#role-modal").style.display = "none");
|
||||
$("#role-modal").addEventListener("click", e => { if (e.target.id === "role-modal") $("#role-modal").style.display = "none"; });
|
||||
$("#role-save").addEventListener("click", () => saveRole($("#role-custom").value.trim().toLowerCase()));
|
||||
$("#role-clear").addEventListener("click", () => saveRole(""));
|
||||
|
||||
// Event-Delegation: ein einziger Listener für alle Tabellen-Aktionen
|
||||
$("#m-table").addEventListener("click", e => {
|
||||
const btn = e.target.closest("[data-action]");
|
||||
if (!btn) return;
|
||||
const name = btn.getAttribute("data-name"), action = btn.getAttribute("data-action");
|
||||
if (!name) return;
|
||||
if (action === "cfg") openConfig(name);
|
||||
else if (action === "role") openRole(name);
|
||||
else if (action === "unload") unloadOne(name);
|
||||
else if (action === "del") deleteModel(name);
|
||||
else if (action === "copy") { navigator.clipboard?.writeText(name); toast("Kopiert: " + name); }
|
||||
else if (action === "assign") {
|
||||
toast("Lade im Cookbook ein Modell und setze den Alias auf: " + name);
|
||||
document.querySelector(".nav-item[data-view='cookbook']")?.click();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let roleTarget = null;
|
||||
function openRole(name) {
|
||||
roleTarget = name;
|
||||
const m = ALL.find(x => x.name === name);
|
||||
$("#role-model-name").textContent = name;
|
||||
$("#role-custom").value = "";
|
||||
$("#role-chips").innerHTML = ROLES.map(r =>
|
||||
`<button class="${m?.role === r.id ? "primary" : "ghost"}" data-rolepick="${r.id}" title="${esc(r.desc)}" style="border-radius:999px">${esc(r.label)}</button>`).join("");
|
||||
$("#role-chips").querySelectorAll("[data-rolepick]").forEach(b =>
|
||||
b.addEventListener("click", () => saveRole(b.getAttribute("data-rolepick"))));
|
||||
$("#role-modal").style.display = "flex";
|
||||
}
|
||||
async function saveRole(role) {
|
||||
if (!roleTarget) return;
|
||||
try {
|
||||
await api("/api/set_role", { method: "POST", body: JSON.stringify({ alias: roleTarget, role }) });
|
||||
toast(role ? `Rolle gesetzt: ${role}` : "Rolle entfernt.");
|
||||
$("#role-modal").style.display = "none";
|
||||
refreshSoon();
|
||||
} catch (e) { toast(e.message, true); }
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
const tb = $("#models"); if (!tb) return;
|
||||
$("#models-empty").style.display = ALL.length ? "none" : "flex";
|
||||
$("#m-count").textContent = ALL.length ? ALL.length + " konfiguriert" : "";
|
||||
|
||||
const sel = $("#chat-model"), cur = sel.value; sel.innerHTML = "";
|
||||
tb.innerHTML = ALL.map(m => {
|
||||
if (m.incomplete) {
|
||||
return `<tr style="opacity:.9">
|
||||
<td class="mid" style="font-weight:500">${esc(m.name)}<div class="li-sub mono-sm" style="color:var(--warn)">kein Modell hinterlegt</div></td>
|
||||
<td>—</td>
|
||||
<td><span class="mono-sm text-mut">leere Rolle</span></td>
|
||||
<td><span class="badge">leer</span></td>
|
||||
<td class="port">—</td>
|
||||
<td style="text-align:right;white-space:nowrap">
|
||||
<button class="ghost" data-action="assign" data-name="${esc(m.name)}">Modell zuweisen</button>
|
||||
<button class="ghost del" data-action="del" data-name="${esc(m.name)}">Löschen</button>
|
||||
</td></tr>`;
|
||||
}
|
||||
const fn = m.meta?.filename ? `<div class="li-sub mono-sm">${esc(m.meta.filename)}</div>` : "";
|
||||
return `<tr>
|
||||
<td class="mid">
|
||||
<div style="font-weight:500;display:flex;align-items:center;gap:7px;flex-wrap:wrap">${esc(m.name)}${roleTag(m.role)}</div>
|
||||
${fn}${apiIdLine(m)}</td>
|
||||
<td>${capTags(m.meta?.caps)}</td>
|
||||
<td>${details(m.meta)}</td>
|
||||
<td>${stateBadge(m)}</td>
|
||||
<td class="port">${m.port ?? "auto"}</td>
|
||||
<td style="text-align:right;white-space:nowrap">
|
||||
<button class="ghost" data-action="role" data-name="${esc(m.name)}">Rolle</button>
|
||||
<button class="ghost" data-action="cfg" data-name="${esc(m.name)}">Konfigurieren</button>
|
||||
<button class="ghost" data-action="unload" data-name="${esc(m.name)}">Entladen</button>
|
||||
<button class="ghost del" data-action="del" data-name="${esc(m.name)}">Löschen</button>
|
||||
</td></tr>`;
|
||||
}).join("");
|
||||
for (const m of ALL) if (!m.incomplete) sel.insertAdjacentHTML("beforeend", `<option>${esc(m.name)}</option>`);
|
||||
if (cur) sel.value = cur;
|
||||
}
|
||||
|
||||
function onStatus(s) {
|
||||
ALL = s?.models || [];
|
||||
renderTable();
|
||||
}
|
||||
|
||||
function onJobs(jobs) {
|
||||
JOBS_CACHE = jobs || [];
|
||||
// Nur neu rendern wenn sich der Download-Zustand geändert hat (minimiert DOM-Flicker)
|
||||
const hasDownload = JOBS_CACHE.some(j => (j.state === "running" || j.state === "queued") && /^download\b/i.test(j.label));
|
||||
if (hasDownload) renderTable();
|
||||
}
|
||||
|
||||
async function deleteModel(m) {
|
||||
const ok = await confirmModal({
|
||||
title: `„${m}" wirklich löschen?`,
|
||||
body: "Das Modell wird aus der Konfiguration ausgetragen und seine Dateien werden von der Festplatte gelöscht, um Speicher freizugeben. <b>Das lässt sich nicht rückgängig machen</b> — später brauchst du dafür einen neuen Download.",
|
||||
confirmLabel: "Endgültig löschen", danger: true });
|
||||
if (!ok) return;
|
||||
try {
|
||||
const r = await api("/api/delete_model", { method: "POST", body: JSON.stringify({ alias: m }) });
|
||||
toast(r.note || ("Gelöscht: " + m));
|
||||
setTimeout(refreshSoon, 600);
|
||||
} catch (e) { toast(e.message, true); }
|
||||
}
|
||||
|
||||
function openConfig(alias) {
|
||||
const m = ALL.find(x => x.name === alias); if (!m) return;
|
||||
$("#cfg-model-name").textContent = m.name;
|
||||
$("#cfg-ctx").value = m.meta?.ctx || 8192;
|
||||
const opt = m.meta?.optimal_ctx, rec = $("#cfg-rec");
|
||||
if (opt) {
|
||||
const cur = m.meta?.ctx || 0, peak = m.meta?.peak_ram_gb, peakOpt = m.meta?.peak_ram_optimal_gb;
|
||||
$("#cfg-rec-note").innerHTML =
|
||||
`Optimal ~${Math.round(opt / 1024)}k → ~${peakOpt} GB Spitzenbedarf.<br>` +
|
||||
`Aktuell ~${Math.round(cur / 1024)}k → ~${peak} GB.`;
|
||||
$("#cfg-rec-apply").onclick = () => { $("#cfg-ctx").value = opt; };
|
||||
rec.style.display = "flex";
|
||||
} else { rec.style.display = "none"; }
|
||||
$("#cfg-modal").style.display = "flex";
|
||||
}
|
||||
async function saveConfig() {
|
||||
const alias = $("#cfg-model-name").textContent, ctx = parseInt($("#cfg-ctx").value) || 8192;
|
||||
$("#cfg-save").disabled = true;
|
||||
try { await api("/api/update_model", { method: "POST", body: JSON.stringify({ alias, ctx }) }); toast("Gespeichert — aktiv beim nächsten Modell-Start."); $("#cfg-modal").style.display = "none"; refreshSoon(); }
|
||||
catch (e) { toast(e.message, true); }
|
||||
$("#cfg-save").disabled = false;
|
||||
}
|
||||
|
||||
async function unloadOne(m) {
|
||||
if (!await confirmModal({ title: `„${m}" entladen?`, body: "Das Modell wird aus dem Grafikspeicher geworfen und lädt beim nächsten Aufruf automatisch neu.", confirmLabel: "Entladen" })) return;
|
||||
try { await api("/api/unload?model=" + encodeURIComponent(m), { method: "POST" }); toast("Entladen: " + m); setTimeout(refreshSoon, 600); }
|
||||
catch (e) { toast(e.message, true); }
|
||||
}
|
||||
|
||||
async function sendChat() {
|
||||
const model = $("#chat-model").value, message = $("#chat-msg").value.trim();
|
||||
if (!model) return toast("Kein Modell vorhanden.", true);
|
||||
if (!message) return;
|
||||
const btn = $("#chat-btn"); btn.disabled = true; btn.textContent = "…";
|
||||
const box = $("#chat-reply"); box.style.display = "block"; box.textContent = "(wecke Modell, kann beim Laden kurz dauern…)";
|
||||
try { const r = await api("/api/chat", { method: "POST", body: JSON.stringify({ model, message }) }); box.textContent = r.reply; }
|
||||
catch (e) { box.textContent = "Fehler: " + e.message; }
|
||||
btn.disabled = false; btn.textContent = "Senden"; refreshSoon();
|
||||
}
|
||||
|
||||
export default { id: "models", mount, onStatus, onJobs };
|
||||
@@ -1,79 +0,0 @@
|
||||
// news.js — News-Board (v6): Magazin-Layout + „Für dich: Upgrades".
|
||||
|
||||
import { api, getHfToken } from "../core/api.js";
|
||||
import { $, esc, ago, toast } from "../core/ui.js";
|
||||
|
||||
let UPS = [];
|
||||
const fitCls = l => l === "perfect" ? "ok" : l === "marginal" ? "warn" : "bad";
|
||||
|
||||
function mount() {
|
||||
$(".view[data-view='news']").innerHTML = `
|
||||
<div class="pagehead">
|
||||
<div><h1>News</h1>
|
||||
<div class="sub">Aktuelles aus der Local-LLM-Welt — aus kuratierten Quellen (HuggingFace, r/LocalLLaMA, Latent Space, Ahead of AI …).</div></div>
|
||||
<button class="ghost" id="news-refresh">Aktualisieren</button>
|
||||
</div>
|
||||
<div id="upgrades"></div>
|
||||
<div id="news-list"><div class="empty" style="text-align:center;padding:40px">Lade News…</div></div>`;
|
||||
$("#news-refresh").addEventListener("click", () => { load(); loadUpgrades(); });
|
||||
load();
|
||||
loadUpgrades();
|
||||
}
|
||||
|
||||
// ---- Upgrade-Vorschläge ----
|
||||
function upRow(u, i) {
|
||||
return `<div class="li" style="align-items:flex-start">
|
||||
<div class="li-main">
|
||||
<div class="flex items-center gap-2" style="flex-wrap:wrap">
|
||||
<span class="li-id" style="font-family:var(--sans);font-weight:500">${esc(u.name)}</span>
|
||||
<span class="fit-badge ${fitCls(u.fit.level)}">${esc(u.fit.text)}</span></div>
|
||||
<div class="li-sub">Ersetzt dein „${esc(u.old)}". ${esc(u.why)}</div>
|
||||
</div>
|
||||
<button class="primary" data-up="${i}">Installieren</button>
|
||||
</div>`;
|
||||
}
|
||||
async function loadUpgrades() {
|
||||
const el = $("#upgrades"); if (!el) return;
|
||||
try {
|
||||
UPS = (await api("/api/cookbook/upgrades")).upgrades || [];
|
||||
if (!UPS.length) { el.innerHTML = ""; return; }
|
||||
el.innerHTML = `<div class="card" style="margin-bottom:18px;border-color:rgba(45,212,191,.25)">
|
||||
<div class="card-h"><h3>Für dich: Upgrades</h3><span class="meta">${UPS.length}</span></div>
|
||||
<div class="card-sub">Neuere Modelle, die ein installiertes ersetzen können — mit Begründung.</div>
|
||||
${UPS.map(upRow).join("")}</div>`;
|
||||
el.querySelectorAll("[data-up]").forEach(b => b.addEventListener("click", () => installUpgrade(+b.getAttribute("data-up"), b)));
|
||||
} catch { el.innerHTML = ""; }
|
||||
}
|
||||
async function installUpgrade(i, btn) {
|
||||
const u = UPS[i]; if (!u) return;
|
||||
btn.disabled = true; btn.textContent = "Starte…";
|
||||
try {
|
||||
await api("/api/cookbook/install-model", { method: "POST", body: JSON.stringify({
|
||||
repo: u.repo, role: u.role, params_b: u.params_b, quant: u.quant, hf_token: getHfToken() }) });
|
||||
toast(`„${u.name}" wird installiert — ersetzt „${u.old}". Siehe Aktivität.`);
|
||||
document.querySelector(".nav-item[data-view='activity']")?.click();
|
||||
} catch (e) { toast("Fehler: " + e.message, true); btn.disabled = false; btn.textContent = "Installieren"; }
|
||||
}
|
||||
|
||||
// ---- News (Magazin-Layout) ----
|
||||
function newsCard(n, featured) {
|
||||
const date = n.ts ? ago(n.ts) : "";
|
||||
const rel = n.relevant ? `<span class="news-rel">für dich</span>` : "";
|
||||
return `<a href="${esc(n.link)}" target="_blank" rel="noopener" class="news-card${featured ? " featured" : ""}">
|
||||
<div class="news-meta"><span class="news-src">${esc(n.source)}</span>${rel}<span class="news-date">${esc(date)}</span></div>
|
||||
<div class="news-title">${esc(n.title)}</div>
|
||||
</a>`;
|
||||
}
|
||||
async function load() {
|
||||
const el = $("#news-list"); if (!el) return;
|
||||
el.innerHTML = `<div class="empty" style="text-align:center;padding:40px">Lade News… (erstes Laden kann kurz dauern)</div>`;
|
||||
try {
|
||||
const items = (await api("/api/news")).items || [];
|
||||
el.innerHTML = items.length
|
||||
? `<div class="news-grid">${items.map((n, i) => newsCard(n, i === 0)).join("")}</div>`
|
||||
: `<div class="empty-c"><div class="e-t">Keine News geladen</div>
|
||||
<div class="e-s">Die Quellen sind gerade nicht erreichbar — später nochmal probieren.</div></div>`;
|
||||
} catch (e) { el.innerHTML = `<div class="alert err" style="margin:0">${esc(e.message)}</div>`; }
|
||||
}
|
||||
|
||||
export default { id: "news", mount };
|
||||
@@ -1,165 +0,0 @@
|
||||
// overview.js — Dashboard (v3): Klartext-Urteil, Metrik-Kacheln, System-Gesundheit,
|
||||
// geführter Schnellstart, "Dein Stack". Speist sich aus /api/status + /api/system/stream.
|
||||
|
||||
import { api } from "../core/api.js";
|
||||
import { $, esc, icon, toast, fmtBytes, confirmModal } from "../core/ui.js";
|
||||
|
||||
let S = null; // letzter Status
|
||||
let SYS = null; // letzte System-Metriken
|
||||
|
||||
const RUNNING = new Set(["running", "ready", "loading", "starting"]);
|
||||
|
||||
function models() { return S?.models || []; }
|
||||
function activeModel() { return models().find(m => RUNNING.has(m.state)); }
|
||||
|
||||
// ---- Aktionen (geführt) ----
|
||||
function go(view) { document.querySelector(`.nav-item[data-view="${view}"]`)?.click(); }
|
||||
async function freeMemory() {
|
||||
const ok = await confirmModal({
|
||||
title: "Speicher freigeben?",
|
||||
body: "Alle aktuell geladenen Modelle werden aus dem Grafikspeicher geworfen. " +
|
||||
"Beim nächsten Aufruf lädt das jeweilige Modell einfach neu — es geht nichts verloren.",
|
||||
confirmLabel: "Speicher freigeben",
|
||||
});
|
||||
if (!ok) return;
|
||||
try { await api("/api/unload", { method: "POST" }); toast("Speicher freigegeben."); setTimeout(() => document.dispatchEvent(new Event("mc:refresh")), 600); }
|
||||
catch (e) { toast(e.message, true); }
|
||||
}
|
||||
window.mcOv = { go, freeMemory };
|
||||
|
||||
// ---- Hero (menschliches Urteil) ----
|
||||
function renderHero() {
|
||||
let title = "Verbinde…", sub = "";
|
||||
if (S) {
|
||||
const total = models().length, act = activeModel();
|
||||
if (!S.swap_ok) { title = "LLM-Engine offline"; sub = "Der llama-swap-Dienst antwortet gerade nicht."; }
|
||||
else if (SYS && SYS.ram.percent >= 90) { title = "Achtung: Speicher wird knapp."; sub = `Arbeitsspeicher bei ${Math.round(SYS.ram.percent)} % — eventuell Speicher freigeben.`; }
|
||||
else {
|
||||
title = "Alles läuft rund.";
|
||||
sub = `${total} ${total === 1 ? "Modell" : "Modelle"} bereit · ${act ? `„${act.name}" geladen` : "keins geladen"} · keine Warnungen.`;
|
||||
}
|
||||
}
|
||||
$("#ov-hero").innerHTML = `<div class="pagehead"><div>
|
||||
<h1>${esc(title)}</h1><div class="sub">${esc(sub)}</div></div></div>`;
|
||||
}
|
||||
|
||||
// ---- Metrik-Kacheln ----
|
||||
function tile(label, valueHtml, sub, vCls = "", sCls = "") {
|
||||
return `<div class="tile"><div class="t-l">${label}</div>
|
||||
<div class="t-v ${vCls}">${valueHtml}</div><div class="t-s ${sCls}">${sub}</div></div>`;
|
||||
}
|
||||
function tempWord(t) { return t == null ? "" : t < 55 ? "kühl & gesund" : t < 72 ? "normal" : "läuft heiß"; }
|
||||
function renderTiles() {
|
||||
const act = activeModel();
|
||||
const swap = S?.swap_ok;
|
||||
let engine = tile("LLM-Engine", swap ? "Online" : "Offline", "llama-swap", swap ? "ok" : "bad");
|
||||
let model = tile("Aktives Modell", act ? esc(act.name) : "Keins",
|
||||
act ? "im Grafikspeicher" : "nichts geladen");
|
||||
let temp, mem;
|
||||
if (SYS) {
|
||||
const t = SYS.gpu_temp ?? SYS.cpu.temp;
|
||||
temp = tile("GPU-Temperatur", t != null ? `${Math.round(t)}°` : "–", tempWord(t),
|
||||
"", t != null && t < 72 ? "ok" : "");
|
||||
const free = SYS.ram.total - SYS.ram.used;
|
||||
mem = tile("Freier Speicher", `${(free / 1024 ** 3).toFixed(0)}<small> GB</small>`,
|
||||
`von ${(SYS.ram.total / 1024 ** 3).toFixed(0)} GB`);
|
||||
} else {
|
||||
temp = tile("GPU-Temperatur", "–", "messe…");
|
||||
mem = tile("Freier Speicher", "–", "messe…");
|
||||
}
|
||||
$("#ov-tiles").innerHTML = engine + model + temp + mem;
|
||||
}
|
||||
|
||||
// ---- System-Gesundheit ----
|
||||
function meter(label, pct) {
|
||||
const p = Math.max(0, Math.min(100, pct || 0));
|
||||
const cls = p >= 90 ? "bad" : p >= 75 ? "warn" : "";
|
||||
return `<div class="meter"><div class="meter-h"><span class="mk">${label}</span>
|
||||
<span class="mv">${Math.round(p)} %</span></div>
|
||||
<div class="bar ${cls}"><i style="width:${Math.max(2, p)}%"></i></div></div>`;
|
||||
}
|
||||
function renderHealth() {
|
||||
const head = `<div class="card-h"><h3>System-Gesundheit</h3></div>
|
||||
<div class="card-sub">So ausgelastet ist dein Mini-PC gerade.</div>`;
|
||||
if (!SYS) { $("#ov-health").innerHTML = head + `<div class="empty">Warte auf Messwerte…</div>`; return; }
|
||||
let gpuPct = 0;
|
||||
const g = SYS.gpu;
|
||||
if (g && (g.vram.total + g.gtt.total) > 0)
|
||||
gpuPct = ((g.vram.used + g.gtt.used) / (g.vram.total + g.gtt.total)) * 100;
|
||||
$("#ov-health").innerHTML = head +
|
||||
meter("Prozessor (CPU)", SYS.cpu.percent) +
|
||||
meter("Arbeitsspeicher (RAM)", SYS.ram.percent) +
|
||||
meter("Grafikspeicher (VRAM)", gpuPct);
|
||||
}
|
||||
|
||||
// ---- Schnellstart ----
|
||||
function qa(ic, tone, title, sub, onclick) {
|
||||
return `<button class="qa" onclick="${onclick}">
|
||||
<span class="qa-ic ${tone}">${icon(ic)}</span>
|
||||
<span class="qa-main"><span class="qa-t">${title}</span><span class="qa-s">${sub}</span></span>
|
||||
<span class="qa-arrow">${icon("chevron")}</span></button>`;
|
||||
}
|
||||
function renderQuickstart() {
|
||||
$("#ov-quickstart").innerHTML =
|
||||
`<div class="card-h"><h3>Schnellstart</h3></div>
|
||||
<div class="card-sub">Die häufigsten Aufgaben — ein Klick.</div>` +
|
||||
qa("search", "teal", "Modell finden", "Passend zu deiner Hardware", "window.mcOv.go('cookbook')") +
|
||||
qa("refresh", "amber", "Speicher freigeben", "Modelle entladen · fragt vorher nach", "window.mcOv.freeMemory()") +
|
||||
qa("file", "blue", "Logs ansehen", "Live mitlesen, was läuft", "window.mcOv.go('server')");
|
||||
}
|
||||
|
||||
// ---- Dein Stack ----
|
||||
function capTag(caps) {
|
||||
if (!caps) return "";
|
||||
if (caps.includes("Bild")) return `<span class="tag img">Bild</span>`;
|
||||
if (caps.includes("Code")) return `<span class="tag code">Code</span>`;
|
||||
return `<span class="tag text">Text</span>`;
|
||||
}
|
||||
function stackRow(m) {
|
||||
const downloading = m.state === "downloading";
|
||||
const on = RUNNING.has(m.state);
|
||||
const dot = (m.state === "loading" || m.state === "starting" || downloading) ? "load" : on ? "on" : "";
|
||||
let status;
|
||||
if (downloading) {
|
||||
const pct = m.download_progress != null ? ` ${Math.round(m.download_progress)}%` : "";
|
||||
status = `↓ Download${pct}`;
|
||||
} else {
|
||||
status = on ? (m.state === "loading" ? "lädt…" : "geladen") : "bereit";
|
||||
}
|
||||
const file = m.meta?.filename ? `<div class="li-sub mono-sm">${esc(m.meta.filename)}</div>` : "";
|
||||
return `<div class="li"><span class="li-dot ${dot}"></span>
|
||||
<div class="li-main">
|
||||
<div class="flex items-center gap-2"><span class="li-id">${esc(m.name)}</span>${capTag(m.meta?.caps)}</div>${file}</div>
|
||||
<span class="li-meta" style="white-space:nowrap">${esc(status)}</span></div>`;
|
||||
}
|
||||
|
||||
async function renderNews() {
|
||||
const el = $("#ov-news"); if (!el) return;
|
||||
const head = `<div class="card-h"><h3>Top-News</h3><span class="meta" style="cursor:pointer" onclick="window.mcOv.go('news')">Alle →</span></div>`;
|
||||
el.innerHTML = head + `<div class="empty" style="text-align:center;padding:20px">Lade…</div>`;
|
||||
try {
|
||||
const items = ((await api("/api/news")).items || []).slice(0, 4);
|
||||
el.innerHTML = head + (items.length
|
||||
? `<div class="list">${items.map(n => `<a class="li" href="${esc(n.link)}" target="_blank" rel="noopener" style="text-decoration:none;color:inherit">
|
||||
<div class="li-main"><div style="font-size:13px;line-height:1.4">${esc(n.title)}</div>
|
||||
<div class="li-sub">${esc(n.source)}</div></div></a>`).join("")}</div>`
|
||||
: `<div class="empty">Keine News.</div>`);
|
||||
} catch { el.innerHTML = head + `<div class="empty">News gerade nicht ladbar.</div>`; }
|
||||
}
|
||||
function renderStack() {
|
||||
const ms = models();
|
||||
$("#ov-stack").innerHTML =
|
||||
`<div class="card-h"><h3>Dein Stack</h3><span class="meta">${ms.length ? ms.length + " konfiguriert" : ""}</span></div>` +
|
||||
(ms.length
|
||||
? `<div class="list">${ms.map(stackRow).join("")}</div>`
|
||||
: `<div class="empty-c"><div class="e-t">Noch keine Modelle</div>
|
||||
<div class="e-s">Hol dir unter „Cookbook" ein Modell, das auf deine Hardware passt.</div></div>`);
|
||||
}
|
||||
|
||||
function renderAll() { renderHero(); renderTiles(); renderHealth(); renderQuickstart(); renderStack(); }
|
||||
|
||||
function mount() { renderQuickstart(); renderAll(); renderNews(); }
|
||||
function onStatus(s) { S = s; renderHero(); renderTiles(); renderStack(); }
|
||||
function onSystem(sys) { SYS = sys; renderHero(); renderTiles(); renderHealth(); }
|
||||
|
||||
export default { id: "overview", mount, onStatus, onSystem };
|
||||
@@ -1,242 +0,0 @@
|
||||
// server.js — Server & Wartung (v3): Dienste, OS-Updates, Reboot, Live-Konsole.
|
||||
// Selbsterklärende Aktionszeilen + heikle Aktionen mit Klartext-Bestätigung.
|
||||
|
||||
import { api, getToken } from "../core/api.js";
|
||||
import { $, esc, toast, icon, confirmModal, promptModal } from "../core/ui.js";
|
||||
import { track } from "./jobs.js";
|
||||
|
||||
function refreshSoon() { document.dispatchEvent(new Event("mc:refresh")); }
|
||||
|
||||
function row(id, ic, tone, title, desc) {
|
||||
return `<button class="qa${tone === "red" ? " qa-danger" : ""}" id="${id}">
|
||||
<span class="qa-ic ${tone}">${icon(ic)}</span>
|
||||
<span class="qa-main"><span class="qa-t">${title}</span><span class="qa-s">${desc}</span></span>
|
||||
<span class="qa-arrow">${icon("chevron")}</span></button>`;
|
||||
}
|
||||
|
||||
function mount() {
|
||||
$("#wartung").outerHTML = `<div id="wartung" style="display:flex;flex-direction:column;gap:var(--sp-4)">
|
||||
<div class="pagehead"><div>
|
||||
<h1>Server & Wartung</h1>
|
||||
<div class="sub">Dienste steuern, Updates einspielen und live mitlesen — ganz ohne SSH oder Terminal.</div>
|
||||
</div></div>
|
||||
|
||||
<div class="card" id="w-swap-card">
|
||||
<div class="card-h"><h3>LLM-Engine (llama-swap)</h3>
|
||||
<span class="chip" id="w-swap-state" style="margin-left:auto">prüfe…</span></div>
|
||||
<div class="card-sub">Das Herzstück: llama-swap lädt bei jeder Anfrage automatisch das passende Modell und entlädt die anderen — es läuft immer nur <b>eines</b> gleichzeitig.</div>
|
||||
<div class="kv" style="margin-bottom:6px">
|
||||
<div class="kv-row"><span class="kv-k">Adresse (für deine Tools)</span><span class="kv-v mono-sm" id="w-swap-url">—</span></div>
|
||||
<div class="kv-row"><span class="kv-k">Aktuell geladen</span><span class="kv-v" id="w-swap-loaded">—</span></div>
|
||||
<div class="kv-row"><span class="kv-k">Konfigurierte Modelle</span><span class="kv-v" id="w-swap-count">—</span></div>
|
||||
</div>
|
||||
<div class="hint" style="margin:8px 0 12px"><b>Wie ansteuern?</b> In deinem Tool (Zed/OpenCode) die obige Adresse als OpenAI-Basis-URL eintragen und einen <b>Modellnamen oder eine Rolle</b> angeben (siehe „Verbinden"). Eine einzige Anfrage genügt — das Modell wird automatisch geweckt.</div>
|
||||
<div class="btn-row" style="display:flex;align-items:center;gap:10px"><button class="ghost" id="w-swap-check">Verbindung prüfen</button><span id="w-swap-check-res" class="mono-sm"></span></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-h"><h3>Dienste & Applikation</h3></div>
|
||||
<div class="card-sub">Diese Aktionen sind harmlos und passwortlos — es geht nichts dabei verloren.</div>
|
||||
${row("w-restart-swap", "refresh", "teal", "LLM-Engine neustarten", "Startet llama-swap neu. Geladene Modelle werden kurz entladen (~5 Sek), laden danach automatisch wieder.")}
|
||||
${row("w-restart-mc", "refresh", "blue", "Dashboard neustarten", "Startet diese Oberfläche neu. Sie trennt kurz und verbindet automatisch wieder.")}
|
||||
${row("w-self-update", "download", "teal", "Mission Control aktualisieren", "Holt die neueste Dashboard-Version aus Gitea und startet automatisch neu (~10 Sek).")}
|
||||
${row("w-update", "bolt", "blue", "LLM-Engine aktualisieren", "Lädt die neueste llama.cpp-Version (ROCm) für deine GPU und installiert sie.")}
|
||||
${row("w-unload", "database", "amber", "Grafikspeicher leeren", "Wirft alle aktuell geladenen Modelle aus dem VRAM. Fragt vorher nach.")}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-h"><h3>Betriebssystem (Bosgame)</h3></div>
|
||||
<div class="card-sub">Tiefe Eingriffe — das Dashboard fragt einmalig nach deinem sudo-Passwort.</div>
|
||||
${row("w-os-update", "refresh", "amber", "OS-Updates installieren", "Führt apt update & upgrade aus. Kann ein paar Minuten dauern — Fortschritt in der Aktivität.")}
|
||||
${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 class="card" id="w-update-card" style="display:none">
|
||||
<div class="card-h"><h3>Aktueller Vorgang</h3>
|
||||
<button class="ghost" id="w-update-cancel" style="margin-left:auto;display:none;padding:3px 12px">Abbrechen</button>
|
||||
<span id="w-update-badge" style="margin-left:10px"></span></div>
|
||||
<div class="card-sub" id="w-update-label">—</div>
|
||||
<div id="w-update-bar" class="bar" style="display:none;margin-bottom:10px"><i style="width:0%"></i></div>
|
||||
<div id="w-update-log" class="log" style="max-height:280px"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-h"><h3>Live-Konsole</h3>
|
||||
<select id="w-console-sel" style="margin:0 0 0 auto;width:240px">
|
||||
<option value="llama-swap">LLM-Engine (llama-swap)</option>
|
||||
<option value="mission-control">Dashboard (mission-control)</option>
|
||||
<option value="system">System (ganzer Server)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="card-sub">Live mitlesen, was der Dienst gerade tut.</div>
|
||||
<div id="w-console" class="console">Verbinde…</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
$("#w-restart-swap").addEventListener("click", () => restartService("llama-swap",
|
||||
"LLM-Engine neustarten?", "Die Engine startet neu. Geladene Modelle werden kurz entladen (~5 Sekunden), laden danach automatisch wieder."));
|
||||
$("#w-restart-mc").addEventListener("click", () => restartService("mission-control",
|
||||
"Dashboard neustarten?", "Diese Oberfläche trennt sich kurz und verbindet automatisch wieder. Laufende Downloads laufen weiter."));
|
||||
$("#w-self-update").addEventListener("click", selfUpdate);
|
||||
$("#w-update").addEventListener("click", update);
|
||||
$("#w-unload").addEventListener("click", unloadAll);
|
||||
$("#w-os-update").addEventListener("click", osUpdate);
|
||||
$("#w-reboot").addEventListener("click", rebootServer);
|
||||
$("#w-console-sel").addEventListener("change", connectConsole);
|
||||
$("#w-update-cancel").addEventListener("click", cancelActive);
|
||||
$("#w-swap-check").addEventListener("click", checkSwap);
|
||||
if (lastStatus) renderSwap(lastStatus);
|
||||
connectConsole();
|
||||
}
|
||||
|
||||
// ---- llama-swap-Transparenz ----
|
||||
let lastStatus = null;
|
||||
function renderSwap(s) {
|
||||
const chip = $("#w-swap-state"); if (!chip) return;
|
||||
const ok = !!s?.swap_ok;
|
||||
chip.textContent = ok ? "● erreichbar" : "● offline";
|
||||
chip.style.cssText = "margin-left:auto;" + (ok
|
||||
? "background:rgba(63,185,80,.12);border-color:rgba(63,185,80,.25);color:#7ee29a"
|
||||
: "background:rgba(248,81,73,.12);border-color:rgba(248,81,73,.3);color:#ff9b95");
|
||||
$("#w-swap-url").textContent = (s?.swap_url || "—") + "/v1";
|
||||
const models = (s?.models || []);
|
||||
const loaded = models.find(m => m.state === "running" || m.state === "loading");
|
||||
$("#w-swap-loaded").innerHTML = loaded
|
||||
? `<b>${esc(loaded.name)}</b> <span class="text-mut">(${esc(loaded.state)})</span>`
|
||||
: `<span class="text-mut">nichts geladen — lädt bei der nächsten Anfrage</span>`;
|
||||
const real = models.filter(m => !m.incomplete).length;
|
||||
$("#w-swap-count").textContent = real ? `${real} bereit` : "keine";
|
||||
}
|
||||
|
||||
async function checkSwap() {
|
||||
const res = $("#w-swap-check-res"); res.textContent = "prüfe…"; res.style.color = "var(--mut)";
|
||||
try {
|
||||
const r = await api("/api/integration/test");
|
||||
if (r.ok) { res.style.color = "#7ee29a"; res.textContent = `✓ Engine antwortet — ${r.models.length} Modell(e): ${r.models.join(", ")}`; }
|
||||
else { res.style.color = "#ff9b95"; res.textContent = "✗ keine Verbindung: " + r.error; }
|
||||
} catch (e) { res.style.color = "#ff9b95"; res.textContent = "✗ " + e.message; }
|
||||
}
|
||||
|
||||
function onStatus(s) { lastStatus = s; renderSwap(s); }
|
||||
|
||||
async function cancelActive() {
|
||||
if (!activeJob) return;
|
||||
if (!await confirmModal({ title: activeJob.label + " abbrechen?", body: "Der laufende Vorgang wird gestoppt. Bei einem Download bleibt eine unvollständige Datei zurück (kannst du später neu starten).", confirmLabel: "Abbrechen", danger: true })) return;
|
||||
try { await api(`/api/jobs/${activeJob.id}/cancel`, { method: "POST" }); toast("Abbruch angefordert…"); }
|
||||
catch (e) { toast(e.message, true); }
|
||||
}
|
||||
|
||||
let ws = null;
|
||||
function connectConsole() {
|
||||
if (ws) { ws.close(); ws = null; }
|
||||
const svc = $("#w-console-sel").value, out = $("#w-console");
|
||||
out.textContent = "Verbinde mit " + svc + "…\n";
|
||||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
ws = new WebSocket(`${proto}//${location.host}/api/logs/${svc}?token=${encodeURIComponent(getToken())}`);
|
||||
ws.onmessage = e => { out.textContent += e.data; out.scrollTop = out.scrollHeight; };
|
||||
ws.onclose = () => { out.textContent += "\n— Verbindung getrennt —"; };
|
||||
}
|
||||
|
||||
async function restartService(name, title, body) {
|
||||
if (!await confirmModal({ title, body, confirmLabel: "Neustarten" })) return;
|
||||
try { await api(`/api/service/${name}/restart`, { method: "POST" }); toast("Neustart ausgelöst: " + name); setTimeout(refreshSoon, 2000); }
|
||||
catch (e) { toast(e.message, true); }
|
||||
}
|
||||
|
||||
async function unloadAll() {
|
||||
if (!await confirmModal({ title: "Grafikspeicher leeren?", body: "Alle geladenen Modelle werden entladen. Sie laden beim nächsten Aufruf automatisch neu — es geht nichts verloren.", confirmLabel: "Leeren" })) return;
|
||||
try { await api("/api/unload", { method: "POST" }); toast("Grafikspeicher geleert."); setTimeout(refreshSoon, 600); }
|
||||
catch (e) { toast(e.message, true); }
|
||||
}
|
||||
|
||||
async function selfUpdate() {
|
||||
if (!await confirmModal({ title: "Mission Control aktualisieren?", body: "Holt die neueste Dashboard-Version aus Gitea und startet automatisch neu. Die Seite trennt kurz und verbindet von selbst wieder (~10 Sek)." })) return;
|
||||
try { const r = await api("/api/self-update", { method: "POST" }); toast("Update läuft — Dashboard startet gleich neu."); track(r.job_id); }
|
||||
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 done = j.state === "done", failed = j.state === "failed", canceled = j.state === "canceled";
|
||||
const terminal = done || failed || canceled;
|
||||
const badge = $("#w-update-badge");
|
||||
if (badge) badge.innerHTML = done ? '<span class="badge b-run">✓ fertig</span>'
|
||||
: failed ? '<span class="badge b-err">✗ fehlgeschlagen</span>'
|
||||
: canceled ? '<span class="badge">abgebrochen</span>'
|
||||
: '<span class="badge b-load">läuft…</span>';
|
||||
|
||||
// Fortschrittsbalken: exakter Backend-Wert (Download) bevorzugt, sonst %-Angabe im Log.
|
||||
let pval = null;
|
||||
if (!terminal) {
|
||||
if (typeof j.progress === "number") {
|
||||
pval = j.progress;
|
||||
} else {
|
||||
const last = (j.log || [])[j.log.length - 1] || "";
|
||||
const pm = last.match(/(\d+(?:\.\d+)?)\s*%/);
|
||||
if (pm) pval = parseFloat(pm[1]);
|
||||
}
|
||||
}
|
||||
const bar = $("#w-update-bar");
|
||||
if (bar) {
|
||||
if (pval != null) { bar.style.display = ""; bar.querySelector("i").style.width = Math.min(100, pval) + "%"; }
|
||||
else bar.style.display = "none";
|
||||
}
|
||||
$("#w-update-cancel").style.display = terminal ? "none" : "";
|
||||
|
||||
if (terminal) {
|
||||
$("#w-update-label").textContent = activeJob.label +
|
||||
(done ? " — erfolgreich abgeschlossen." : canceled ? " — abgebrochen." : " — fehlgeschlagen (siehe Protokoll).");
|
||||
if (!notified.has(j.id)) {
|
||||
notified.add(j.id);
|
||||
toast(done ? "✓ " + activeJob.label + " abgeschlossen."
|
||||
: canceled ? activeJob.label + " abgebrochen."
|
||||
: "✗ " + activeJob.label + " fehlgeschlagen.", failed);
|
||||
if (done) setTimeout(refreshSoon, 800);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function update() {
|
||||
if (!await confirmModal({ title: "LLM-Engine aktualisieren?", body: "Lädt die neueste llama.cpp-ROCm-Version und installiert sie. Die Engine ist während des Updates kurz nicht erreichbar." })) return;
|
||||
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); }
|
||||
}
|
||||
|
||||
async function osUpdate() {
|
||||
if (!await confirmModal({ title: "OS-Updates installieren?", body: "Führt <code>apt update & 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" });
|
||||
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); watchJob(r.job_id, "OS-Update"); }
|
||||
catch (e) { toast(e.message, true); }
|
||||
}
|
||||
|
||||
async function rebootServer() {
|
||||
if (!await confirmModal({ title: "Server wirklich neu starten?", body: "Der ganze Bosgame startet physisch neu. Alles ist für ~1 Minute offline — auch dieses Dashboard.", confirmLabel: "Reboot", danger: true })) return;
|
||||
const pwd = await promptModal({ title: "sudo-Passwort", body: "Für den Reboot wird einmalig dein sudo-Passwort gebraucht.", placeholder: "sudo-Passwort", password: true, confirmLabel: "Jetzt neustarten", danger: true });
|
||||
if (!pwd) return;
|
||||
try { await api("/api/reboot", { method: "POST", body: JSON.stringify({ password: pwd }) }); toast("Reboot ausgelöst — bis gleich."); }
|
||||
catch (e) { toast(e.message, true); }
|
||||
}
|
||||
|
||||
export default { id: "server", mount, onJobs, onStatus };
|
||||
Reference in New Issue
Block a user