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() {
-
Eigenes Setup erstellen
+
Eigenes Setup erstellen
Stell dir aus beliebigen Modellen ein eigenes Setup zusammen — es erscheint danach mit Hardware-Ampel hier im Cookbook.
@@ -270,8 +270,11 @@ function renderRecipes() {
const best = r.id === RECOMMENDED;
const tag = best ? `
★ Beste Wahl für dein System
`
: r.user ? `
Dein Setup
` : "";
- const del = r.user ? `
×` : "";
+ const del = r.user ? `
+
✎
+
×` : "";
return `
`;
}
async function renderNews() {
diff --git a/static/js/panels/server.js b/static/js/panels/server.js
index 0cabf6b..8cc819c 100644
--- a/static/js/panels/server.js
+++ b/static/js/panels/server.js
@@ -218,6 +218,7 @@ function onJobs(jobs) {
}
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); }
}