diff --git a/routers/cookbook.py b/routers/cookbook.py index 72f6662..e02b963 100644 --- a/routers/cookbook.py +++ b/routers/cookbook.py @@ -216,6 +216,35 @@ def create_user_recipe(req: UserRecipeReq): 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}") def delete_user_recipe(recipe_id: str): items = load_user_recipes() @@ -251,8 +280,8 @@ def install_recipe(req: InstallRecipeReq): JOBS[jid]["result_path"] = str(target / file) attach_download_progress(jid, str(target), hf_file_size(m["repo"], file)) job_ids.append(jid) - # Eintrag jetzt schon schreiben — optimaler Kontext, aber gedeckelt fuer schnellen Erststart. - ctx = min(max_ctx_for(m["params_b"], m["quant"], ram_gb), 32768) + # Eintrag jetzt schon schreiben — optimaler Kontext für die Hardware. + ctx = max_ctx_for(m["params_b"], m["quant"], ram_gb) path = str(target / file) cmd = CMD_TEMPLATE.replace("{model}", path).replace("{ctx}", str(ctx)) # 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) attach_download_progress(jid, str(target), hf_file_size(req.repo, file)) 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) cmd = CMD_TEMPLATE.replace("{model}", path).replace("{ctx}", str(ctx)) mid = model_id_from_path(path) diff --git a/routers/maintenance.py b/routers/maintenance.py index e0a622e..f8b37e0 100644 --- a/routers/maintenance.py +++ b/routers/maintenance.py @@ -114,7 +114,14 @@ def updates(): models = len(compute_upgrades(psutil.virtual_memory().total / (1024 ** 3))) except Exception: # noqa: BLE001 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}") async def stream_logs(websocket: WebSocket, service: str): diff --git a/routers/models.py b/routers/models.py index 068b61c..06e6293 100644 --- a/routers/models.py +++ b/routers/models.py @@ -42,7 +42,7 @@ class RegisterReq(BaseModel): alias: str = "" # rueckwaertskompatibel: wird als Rolle interpretiert, wenn 'role' fehlt role: str | None = None # Rollen-Tag (vision/coder/scout/reviewer/manager o.ae.) model_path: str - ctx: int = 8192 + ctx: int | None = None # None → optimal fuer die Hardware (max_ctx_for) 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), } } + # 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 try: 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 # erst gleich. Eintrag jetzt schon schreiben → llama-swap (-watch-config) lädt, sobald sie da ist. 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) # 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). diff --git a/static/css/components.css b/static/css/components.css index f3c90a7..a12876d 100644 --- a/static/css/components.css +++ b/static/css/components.css @@ -4,6 +4,14 @@ 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) ---- */ .card{ background:var(--panel);border:1px solid var(--line);border-radius:var(--radius); diff --git a/static/js/core/ui.js b/static/js/core/ui.js index e1d30bd..396a7ba 100644 --- a/static/js/core/ui.js +++ b/static/js/core/ui.js @@ -19,9 +19,13 @@ export function toast(msg, err = false) { } // Modell-Status -> Badge-HTML -export function badge(state) { - if (state === "running" || state === "ready") return 'geladen'; +export function badge(state, progress) { + if (state === "running" || state === "ready") return 'geladen'; if (state === "loading" || state === "starting") return 'lädt…'; + if (state === "downloading") { + const pct = progress != null ? ` ${Math.round(progress)}%` : ""; + return `↓ Download${pct}`; + } return 'bereit'; } diff --git a/static/js/main.js b/static/js/main.js index 9602565..a6c9f2f 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -18,6 +18,7 @@ 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) { @@ -76,26 +77,56 @@ 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) { +function badge(text, cls, view, title) { const s = document.createElement("span"); 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; } 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` : "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.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 { 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); } } async function pollJobs() { diff --git a/static/js/panels/cookbook.js b/static/js/panels/cookbook.js index e72bd96..1e38b4a 100644 --- a/static/js/panels/cookbook.js +++ b/static/js/panels/cookbook.js @@ -118,7 +118,7 @@ function mount() {