fix(phase-a): Kontext-Cap, Download-State, Recipe-Edit, OS-Badge, Swap-Flash

A1: 32768-Kontext-Cap entfernt (install-recipe, install-model, register) →
    max_ctx_for() liefert nun bis zu 128k auf Strix Halo; behebt "context
    size exceeded" bei externen Tools.
A2: Download-State jetzt im Status-Endpoint sichtbar: Modelle zeigen
    "↓ Download X%" statt "bereit" während Job läuft (Backend + Frontend).
A3: PUT /api/cookbook/user-recipe/{id} + Edit-Button (✎) für eigene Setups.
    Download-Modal setzt Kontext-Input automatisch auf optimal.
A4: /api/updates liefert apt_cache_age_h; Badge zeigt Tooltip + ⚠ wenn >24h.
A5: Swap-Flash: Topbar-Text pulst kurz teal wenn Modell den State wechselt.
A6: LLM-Engine-Update fragt jetzt per confirmModal nach (Konsistenz).
A7: Event-Delegation statt per-render addEventListener in models.js.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-22 21:33:03 +02:00
parent 022c42dccb
commit 82d15d82db
10 changed files with 214 additions and 45 deletions
+32 -3
View File
@@ -216,6 +216,35 @@ def create_user_recipe(req: UserRecipeReq):
return {"ok": True, "id": rid} return {"ok": True, "id": rid}
@router.put("/user-recipe/{recipe_id}")
def update_user_recipe(recipe_id: str, req: UserRecipeReq):
"""Bestehendes eigenes Setup aktualisieren (Titel, Beschreibung, Modelle)."""
items = load_user_recipes()
idx = next((i for i, r in enumerate(items) if r.get("id") == recipe_id), None)
if idx is None:
raise HTTPException(404, "Eigenes Setup nicht gefunden.")
if not req.title.strip():
raise HTTPException(400, "Bitte einen Titel angeben.")
models = []
for m in req.models:
if not m.repo.strip():
continue
models.append({
"role": (m.role or "modell").strip(),
"name": (m.name or m.repo.split("/")[-1]).strip(),
"repo": m.repo.strip(),
"params_b": extract_params_b(m.repo),
"quant": (m.quant or "Q4_K_M").strip(),
"why": (m.why or "").strip(),
})
if not models:
raise HTTPException(400, "Mindestens ein Modell (Repo) angeben.")
items[idx] = {**items[idx], "title": req.title.strip(), "icon": (req.icon or "box").strip(),
"desc": req.desc.strip(), "models": models}
save_user_recipes(items)
return {"ok": True, "id": recipe_id}
@router.delete("/user-recipe/{recipe_id}") @router.delete("/user-recipe/{recipe_id}")
def delete_user_recipe(recipe_id: str): def delete_user_recipe(recipe_id: str):
items = load_user_recipes() items = load_user_recipes()
@@ -251,8 +280,8 @@ def install_recipe(req: InstallRecipeReq):
JOBS[jid]["result_path"] = str(target / file) JOBS[jid]["result_path"] = str(target / file)
attach_download_progress(jid, str(target), hf_file_size(m["repo"], file)) attach_download_progress(jid, str(target), hf_file_size(m["repo"], file))
job_ids.append(jid) job_ids.append(jid)
# Eintrag jetzt schon schreiben — optimaler Kontext, aber gedeckelt fuer schnellen Erststart. # Eintrag jetzt schon schreiben — optimaler Kontext für die Hardware.
ctx = min(max_ctx_for(m["params_b"], m["quant"], ram_gb), 32768) ctx = max_ctx_for(m["params_b"], m["quant"], ram_gb)
path = str(target / file) path = str(target / file)
cmd = CMD_TEMPLATE.replace("{model}", path).replace("{ctx}", str(ctx)) cmd = CMD_TEMPLATE.replace("{model}", path).replace("{ctx}", str(ctx))
# Schluessel = sprechender Modellname; Rolle (aus dem Rezept) als Alias. # Schluessel = sprechender Modellname; Rolle (aus dem Rezept) als Alias.
@@ -490,7 +519,7 @@ def install_model(req: InstallModelReq):
JOBS[jid]["result_path"] = str(target / file) JOBS[jid]["result_path"] = str(target / file)
attach_download_progress(jid, str(target), hf_file_size(req.repo, file)) attach_download_progress(jid, str(target), hf_file_size(req.repo, file))
cfg = read_config() cfg = read_config()
ctx = min(max_ctx_for(req.params_b, req.quant, ram_gb), 32768) ctx = max_ctx_for(req.params_b, req.quant, ram_gb)
path = str(target / file) path = str(target / file)
cmd = CMD_TEMPLATE.replace("{model}", path).replace("{ctx}", str(ctx)) cmd = CMD_TEMPLATE.replace("{model}", path).replace("{ctx}", str(ctx))
mid = model_id_from_path(path) mid = model_id_from_path(path)
+8 -1
View File
@@ -114,7 +114,14 @@ def updates():
models = len(compute_upgrades(psutil.virtual_memory().total / (1024 ** 3))) models = len(compute_upgrades(psutil.virtual_memory().total / (1024 ** 3)))
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
models = 0 models = 0
return {"os": os_count, "models": models, "engine": 1 if _engine_update_available() else 0} apt_cache_age_h = None
try:
import time as _time
apt_cache_age_h = round((_time.time() - os.path.getmtime("/var/lib/apt/lists")) / 3600, 1)
except Exception: # noqa: BLE001
pass
return {"os": os_count, "models": models, "engine": 1 if _engine_update_available() else 0,
"apt_cache_age_h": apt_cache_age_h}
@router.websocket("/logs/{service}") @router.websocket("/logs/{service}")
async def stream_logs(websocket: WebSocket, service: str): async def stream_logs(websocket: WebSocket, service: str):
+21 -2
View File
@@ -42,7 +42,7 @@ class RegisterReq(BaseModel):
alias: str = "" # rueckwaertskompatibel: wird als Rolle interpretiert, wenn 'role' fehlt alias: str = "" # rueckwaertskompatibel: wird als Rolle interpretiert, wenn 'role' fehlt
role: str | None = None # Rollen-Tag (vision/coder/scout/reviewer/manager o.ae.) role: str | None = None # Rollen-Tag (vision/coder/scout/reviewer/manager o.ae.)
model_path: str model_path: str
ctx: int = 8192 ctx: int | None = None # None → optimal fuer die Hardware (max_ctx_for)
ttl: int | None = None ttl: int | None = None
@@ -138,6 +138,20 @@ def status():
"peak_ram_optimal_gb": (round(estimate_memory_gb(_pb, quant or "Q4_K_M", _oc), 1) if _oc else None), "peak_ram_optimal_gb": (round(estimate_memory_gb(_pb, quant or "Q4_K_M", _oc), 1) if _oc else None),
} }
} }
# Laufende Download-Jobs erkennnen: Modell bekommt state "downloading" + Fortschritt.
for j in JOBS.values():
if j.get("state") not in ("running", "queued"):
continue
rp = j.get("result_path", "")
if not rp:
continue
for mconf in configured.values():
m_p = re.search(r'-(?:m|-model)\s+(\S+)', mconf.get("cmd", ""))
if m_p and m_p.group(1).strip("'\"") == rp:
mconf["state"] = "downloading"
mconf["download_progress"] = j.get("progress")
break
swap_ok = True swap_ok = True
try: try:
running = _swap_get("/running") running = _swap_get("/running")
@@ -200,7 +214,12 @@ def register(req: RegisterReq):
# Bewusst KEIN exists()-Check: beim frischen Download läuft der hf-Job noch, die Datei kommt # Bewusst KEIN exists()-Check: beim frischen Download läuft der hf-Job noch, die Datei kommt
# erst gleich. Eintrag jetzt schon schreiben → llama-swap (-watch-config) lädt, sobald sie da ist. # erst gleich. Eintrag jetzt schon schreiben → llama-swap (-watch-config) lädt, sobald sie da ist.
cfg = read_config() cfg = read_config()
cmd = CMD_TEMPLATE.replace("{model}", req.model_path).replace("{ctx}", str(req.ctx)) ctx = req.ctx
if ctx is None:
params_b = extract_params_b(req.model_path)
ram_gb = psutil.virtual_memory().total / (1024 ** 3)
ctx = max_ctx_for(params_b, "Q4_K_M", ram_gb)
cmd = CMD_TEMPLATE.replace("{model}", req.model_path).replace("{ctx}", str(ctx))
cmd = _augment_vision(cmd, req.model_path) cmd = _augment_vision(cmd, req.model_path)
# Neues Schema: Schluessel = sprechender Modellname (steht so in der Modell-Liste und ist # Neues Schema: Schluessel = sprechender Modellname (steht so in der Modell-Liste und ist
# der API-Name), die Rolle kommt als llama-swap-Alias obendrauf (beide Namen funktionieren). # der API-Name), die Rolle kommt als llama-swap-Alias obendrauf (beide Namen funktionieren).
+8
View File
@@ -4,6 +4,14 @@
Legacy-Klassen bleiben erhalten, bis alle Panels migriert sind. Legacy-Klassen bleiben erhalten, bis alle Panels migriert sind.
========================================================================= */ ========================================================================= */
/* ---- Live-Swap-Flash (Topbar-Highlight wenn Modell wechselt) ---- */
@keyframes swap-flash {
0% { color:var(--tx); }
25% { color:var(--accent); }
100% { color:var(--tx); }
}
.swap-flash { animation: swap-flash 0.9s ease-out forwards; }
/* ---- Karte (Grundbaustein) ---- */ /* ---- Karte (Grundbaustein) ---- */
.card{ .card{
background:var(--panel);border:1px solid var(--line);border-radius:var(--radius); background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);
+6 -2
View File
@@ -19,9 +19,13 @@ export function toast(msg, err = false) {
} }
// Modell-Status -> Badge-HTML // Modell-Status -> Badge-HTML
export function badge(state) { export function badge(state, progress) {
if (state === "running" || state === "ready") return '<span class="badge b-run">geladen</span>'; if (state === "running" || state === "ready") return '<span class="badge b-run">geladen</span>';
if (state === "loading" || state === "starting") return '<span class="badge b-load">lädt…</span>'; if (state === "loading" || state === "starting") return '<span class="badge b-load">lädt…</span>';
if (state === "downloading") {
const pct = progress != null ? ` ${Math.round(progress)}%` : "";
return `<span class="badge b-load">↓ Download${pct}</span>`;
}
return '<span class="badge b-run">bereit</span>'; return '<span class="badge b-run">bereit</span>';
} }
+35 -4
View File
@@ -18,6 +18,7 @@ const panels = [overview, models, server, jobs, cookbook, connect, news, guides]
let lastJobs = []; let lastJobs = [];
let lastSystem = null; let lastSystem = null;
let prevModelStates = {};
// ---- Topbar / Alert aus dem Status ableiten ---- // ---- Topbar / Alert aus dem Status ableiten ----
function applyStatus(s) { function applyStatus(s) {
@@ -76,26 +77,56 @@ function hideAlert() { $("#alert").style.display = "none"; }
// ---- Toolbar: ausstehende Updates anzeigen ---- // ---- Toolbar: ausstehende Updates anzeigen ----
const goView = v => document.querySelector(`.nav-item[data-view="${v}"]`)?.click(); const goView = v => document.querySelector(`.nav-item[data-view="${v}"]`)?.click();
function badge(text, cls, view) { function badge(text, cls, view, title) {
const s = document.createElement("span"); const s = document.createElement("span");
s.className = "upd-badge" + (cls ? " " + cls : ""); s.className = "upd-badge" + (cls ? " " + cls : "");
s.textContent = text; s.onclick = () => goView(view); s.textContent = text;
if (title) s.title = title;
s.onclick = () => goView(view);
return s; return s;
} }
async function pollUpdates() { async function pollUpdates() {
try { try {
const u = await api("/api/updates"); const u = await api("/api/updates");
const el = $("#update-badges"); if (!el) return; 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.innerHTML = "";
el.appendChild(badge(u.os > 0 ? `OS: ${u.os} Updates` : "OS: aktuell", u.os > 0 ? "warn" : "ok", "server")); 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.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")); el.appendChild(badge(u.models > 0 ? `Modelle: ${u.models}` : "Modelle: aktuell", u.models > 0 ? "warn" : "ok", "news"));
} catch { /* still */ } } 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 ---- // ---- Polling ----
async function pollStatus() { async function pollStatus() {
try { applyStatus(await api("/api/status")); } 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); } catch { applyStatus(null); }
} }
async function pollJobs() { async function pollJobs() {
+48 -7
View File
@@ -118,7 +118,7 @@ function mount() {
<div id="cb-new-modal" class="modal-overlay" style="display:none"> <div id="cb-new-modal" class="modal-overlay" style="display:none">
<div class="modal-card" style="max-width:640px"> <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> <button id="cb-new-close" class="ghost" style="position:absolute;top:14px;right:14px">Schließen</button>
<h3>Eigenes Setup erstellen</h3> <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> <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> <label>Titel</label>
<input id="cb-new-title" placeholder="z.B. Mein Schreib-Stack"> <input id="cb-new-title" placeholder="z.B. Mein Schreib-Stack">
@@ -270,8 +270,11 @@ function renderRecipes() {
const best = r.id === RECOMMENDED; const best = r.id === RECOMMENDED;
const tag = best ? `<div class="cb-best-tag">★ Beste Wahl für dein System</div>` 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>` : ""; : r.user ? `<div class="cb-best-tag" style="background:var(--act);color:#fff">Dein Setup</div>` : "";
const del = r.user ? `<span class="cb-delr" data-delr="${esc(r.id)}" title="Setup löschen" const del = r.user ? `
style="position:absolute;top:9px;right:12px;color:var(--err);cursor:pointer;font-size:18px;line-height:1">×</span>` : ""; <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"> return `<button class="card-btn${best ? " cb-best" : ""}" data-recipe="${esc(r.id)}" style="position:relative">
${del}${tag} ${del}${tag}
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
@@ -287,6 +290,8 @@ function renderRecipes() {
b.addEventListener("click", () => openRecipe(b.getAttribute("data-recipe")))); b.addEventListener("click", () => openRecipe(b.getAttribute("data-recipe"))));
$("#cb-recipes").querySelectorAll("[data-delr]").forEach(x => $("#cb-recipes").querySelectorAll("[data-delr]").forEach(x =>
x.addEventListener("click", e => { e.stopPropagation(); deleteRecipe(x.getAttribute("data-delr")); })); 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 ---- // ---- Eigenes Setup erstellen/löschen ----
@@ -301,10 +306,35 @@ function newModelRow() {
</div>`; </div>`;
} }
let editRecipeId = null;
function openNewRecipe() { function openNewRecipe() {
editRecipeId = null;
$("#cb-new-title").value = ""; $("#cb-new-title").value = "";
$("#cb-new-desc").value = ""; $("#cb-new-desc").value = "";
$("#cb-new-models").innerHTML = newModelRow(); $("#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"; $("#cb-new-modal").style.display = "flex";
} }
@@ -319,13 +349,19 @@ async function saveNewRecipe() {
if (!models.length) return toast("Mindestens ein Modell (Repo) angeben.", true); if (!models.length) return toast("Mindestens ein Modell (Repo) angeben.", true);
const btn = $("#cb-new-save"); btn.disabled = true; btn.textContent = "Speichere…"; const btn = $("#cb-new-save"); btn.disabled = true; btn.textContent = "Speichere…";
try { try {
await api("/api/cookbook/user-recipe", { method: "POST", const body = JSON.stringify({ title, desc: $("#cb-new-desc").value.trim(), models });
body: JSON.stringify({ title, desc: $("#cb-new-desc").value.trim(), models }) }); if (editRecipeId) {
toast("Setup gespeichert."); 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"; $("#cb-new-modal").style.display = "none";
loadRecipes(); loadRecipes();
} catch (e) { toast("Fehler: " + e.message, true); } } catch (e) { toast("Fehler: " + e.message, true); }
btn.disabled = false; btn.textContent = "Setup speichern"; btn.disabled = false; btn.textContent = editRecipeId ? "Änderungen speichern" : "Setup speichern";
} }
async function deleteRecipe(id) { async function deleteRecipe(id) {
@@ -482,6 +518,11 @@ function showFit() {
$("#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-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>`; $("#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"); 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 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>` : ""; ? `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"); const btn = $("#cb-m-download");
+45 -23
View File
@@ -3,6 +3,8 @@
import { api } from "../core/api.js"; import { api } from "../core/api.js";
import { $, badge, esc, toast, confirmModal, infoDot } from "../core/ui.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 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."; 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.";
@@ -18,6 +20,11 @@ const ROLES = [
let ALL = []; let ALL = [];
function refreshSoon() { document.dispatchEvent(new Event("mc:refresh")); } 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) { 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>` : ""; 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>` : "";
} }
@@ -26,7 +33,7 @@ function apiIdLine(m) {
const ids = (m.api_ids && m.api_ids.length ? m.api_ids : [m.name]); 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"> 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> <span class="text-mut">API-Name:</span>
${ids.map(id => `<code style="cursor:pointer" data-copy="${esc(id)}" title="Klicken zum Kopieren">${esc(id)}</code>`).join('<span class="text-mut">·</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>`; </div>`;
} }
@@ -113,6 +120,23 @@ function mount() {
$("#role-modal").addEventListener("click", e => { if (e.target.id === "role-modal") $("#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-save").addEventListener("click", () => saveRole($("#role-custom").value.trim().toLowerCase()));
$("#role-clear").addEventListener("click", () => saveRole("")); $("#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; let roleTarget = null;
@@ -137,8 +161,7 @@ async function saveRole(role) {
} catch (e) { toast(e.message, true); } } catch (e) { toast(e.message, true); }
} }
function onStatus(s) { function renderTable() {
ALL = s?.models || [];
const tb = $("#models"); if (!tb) return; const tb = $("#models"); if (!tb) return;
$("#models-empty").style.display = ALL.length ? "none" : "flex"; $("#models-empty").style.display = ALL.length ? "none" : "flex";
$("#m-count").textContent = ALL.length ? ALL.length + " konfiguriert" : ""; $("#m-count").textContent = ALL.length ? ALL.length + " konfiguriert" : "";
@@ -146,7 +169,6 @@ function onStatus(s) {
const sel = $("#chat-model"), cur = sel.value; sel.innerHTML = ""; const sel = $("#chat-model"), cur = sel.value; sel.innerHTML = "";
tb.innerHTML = ALL.map(m => { tb.innerHTML = ALL.map(m => {
if (m.incomplete) { if (m.incomplete) {
// Leere Rolle (Platzhalter ohne Modell-Datei) — ehrlich kennzeichnen statt „bereit".
return `<tr style="opacity:.9"> 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 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>—</td>
@@ -154,8 +176,8 @@ function onStatus(s) {
<td><span class="badge">leer</span></td> <td><span class="badge">leer</span></td>
<td class="port">—</td> <td class="port">—</td>
<td style="text-align:right;white-space:nowrap"> <td style="text-align:right;white-space:nowrap">
<button class="ghost" data-assign="${esc(m.name)}">Modell zuweisen</button> <button class="ghost" data-action="assign" data-name="${esc(m.name)}">Modell zuweisen</button>
<button class="ghost del" data-del="${esc(m.name)}">Löschen</button> <button class="ghost del" data-action="del" data-name="${esc(m.name)}">Löschen</button>
</td></tr>`; </td></tr>`;
} }
const fn = m.meta?.filename ? `<div class="li-sub mono-sm">${esc(m.meta.filename)}</div>` : ""; const fn = m.meta?.filename ? `<div class="li-sub mono-sm">${esc(m.meta.filename)}</div>` : "";
@@ -165,29 +187,29 @@ function onStatus(s) {
${fn}${apiIdLine(m)}</td> ${fn}${apiIdLine(m)}</td>
<td>${capTags(m.meta?.caps)}</td> <td>${capTags(m.meta?.caps)}</td>
<td>${details(m.meta)}</td> <td>${details(m.meta)}</td>
<td>${badge(m.state)}</td> <td>${stateBadge(m)}</td>
<td class="port">${m.port ?? "auto"}</td> <td class="port">${m.port ?? "auto"}</td>
<td style="text-align:right;white-space:nowrap"> <td style="text-align:right;white-space:nowrap">
<button class="ghost" data-role="${esc(m.name)}">Rolle</button> <button class="ghost" data-action="role" data-name="${esc(m.name)}">Rolle</button>
<button class="ghost" data-cfg="${esc(m.name)}">Konfigurieren</button> <button class="ghost" data-action="cfg" data-name="${esc(m.name)}">Konfigurieren</button>
<button class="ghost" data-unload="${esc(m.name)}">Entladen</button> <button class="ghost" data-action="unload" data-name="${esc(m.name)}">Entladen</button>
<button class="ghost del" data-del="${esc(m.name)}">Löschen</button> <button class="ghost del" data-action="del" data-name="${esc(m.name)}">Löschen</button>
</td></tr>`; </td></tr>`;
}).join(""); }).join("");
for (const m of ALL) if (!m.incomplete) sel.insertAdjacentHTML("beforeend", `<option>${esc(m.name)}</option>`); for (const m of ALL) if (!m.incomplete) sel.insertAdjacentHTML("beforeend", `<option>${esc(m.name)}</option>`);
if (cur) sel.value = cur; if (cur) sel.value = cur;
}
tb.querySelectorAll("[data-unload]").forEach(b => b.addEventListener("click", () => unloadOne(b.getAttribute("data-unload")))); function onStatus(s) {
tb.querySelectorAll("[data-cfg]").forEach(b => b.addEventListener("click", () => openConfig(b.getAttribute("data-cfg")))); ALL = s?.models || [];
tb.querySelectorAll("[data-del]").forEach(b => b.addEventListener("click", () => deleteModel(b.getAttribute("data-del")))); renderTable();
tb.querySelectorAll("[data-role]").forEach(b => b.addEventListener("click", () => openRole(b.getAttribute("data-role")))); }
tb.querySelectorAll("[data-copy]").forEach(c => c.addEventListener("click", () => {
navigator.clipboard?.writeText(c.getAttribute("data-copy")); toast("Kopiert: " + c.getAttribute("data-copy")); function onJobs(jobs) {
})); JOBS_CACHE = jobs || [];
tb.querySelectorAll("[data-assign]").forEach(b => b.addEventListener("click", () => { // Nur neu rendern wenn sich der Download-Zustand geändert hat (minimiert DOM-Flicker)
toast("Lade im Cookbook ein Modell und setze den Alias auf: " + b.getAttribute("data-assign")); const hasDownload = JOBS_CACHE.some(j => (j.state === "running" || j.state === "queued") && /^download\b/i.test(j.label));
document.querySelector(".nav-item[data-view='cookbook']")?.click(); if (hasDownload) renderTable();
}));
} }
async function deleteModel(m) { async function deleteModel(m) {
@@ -243,4 +265,4 @@ async function sendChat() {
btn.disabled = false; btn.textContent = "Senden"; refreshSoon(); btn.disabled = false; btn.textContent = "Senden"; refreshSoon();
} }
export default { id: "models", mount, onStatus }; export default { id: "models", mount, onStatus, onJobs };
+10 -3
View File
@@ -116,14 +116,21 @@ function capTag(caps) {
return `<span class="tag text">Text</span>`; return `<span class="tag text">Text</span>`;
} }
function stackRow(m) { function stackRow(m) {
const downloading = m.state === "downloading";
const on = RUNNING.has(m.state); const on = RUNNING.has(m.state);
const dot = m.state === "loading" || m.state === "starting" ? "load" : on ? "on" : ""; const dot = (m.state === "loading" || m.state === "starting" || downloading) ? "load" : on ? "on" : "";
const status = on ? (m.state === "loading" ? "lädt…" : "geladen") : "bereit"; 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>` : ""; 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> return `<div class="li"><span class="li-dot ${dot}"></span>
<div class="li-main"> <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> <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">${status}</span></div>`; <span class="li-meta" style="white-space:nowrap">${esc(status)}</span></div>`;
} }
async function renderNews() { async function renderNews() {
+1
View File
@@ -218,6 +218,7 @@ function onJobs(jobs) {
} }
async function update() { 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"); } try { const r = await api("/api/update", { method: "POST" }); toast("LLM-Engine-Update läuft."); track(r.job_id); watchJob(r.job_id, "LLM-Engine-Update"); }
catch (e) { toast(e.message, true); } catch (e) { toast(e.message, true); }
} }