diff --git a/routers/models.py b/routers/models.py index d4943db..18ef8f9 100644 --- a/routers/models.py +++ b/routers/models.py @@ -21,6 +21,7 @@ from llamaswap import _swap_get, read_config, write_config from hw_math import extract_params_b, max_ctx_for, estimate_memory_gb import re import os +import shutil import psutil router = APIRouter(prefix="/api", dependencies=[Depends(auth)]) @@ -53,6 +54,11 @@ class UpdateReq(BaseModel): ctx: int +class DeleteReq(BaseModel): + alias: str + delete_files: bool = True + + # --------------------------------------------------------------------------- # Endpoints # --------------------------------------------------------------------------- @@ -186,6 +192,78 @@ def update_model(req: UpdateReq): return {"ok": True} +def _dir_size(p: Path) -> int: + if p.is_file(): + try: + return p.stat().st_size + except OSError: + return 0 + total = 0 + for root, _dirs, files in os.walk(p): + for f in files: + try: + total += os.path.getsize(os.path.join(root, f)) + except OSError: + pass + return total + + +def _delete_model_files(model_path: str, cfg: dict) -> tuple[str | None, int]: + """Loescht die Dateien eines Modells — STRENG auf MODELS_DIR begrenzt. Loescht den + ganzen Repo-Unterordner (inkl. .cache), wenn dieser ein direktes Kind von MODELS_DIR + ist und kein anderes (verbleibendes) Modell eine Datei darin nutzt; sonst nur die + GGUF-Datei selbst. Gibt (geloeschter_pfad|None, freigegebene_bytes) zurueck.""" + root = MODELS_DIR.resolve() + p = Path(model_path).resolve() + if root not in p.parents: # Sicherheit: niemals ausserhalb des Modell-Ordners loeschen + return None, 0 + parent = p.parent + others = " ".join(str(s.get("cmd", "")) for s in (cfg.get("models") or {}).values()) + parent_shared = parent != root and str(parent) in others + target = parent if (parent != root and not parent_shared) else p + if not target.exists(): + return None, 0 + freed = _dir_size(target) + if target.is_dir(): + shutil.rmtree(target) + else: + target.unlink() + return str(target), freed + + +@router.post("/delete_model") +def delete_model(req: DeleteReq): + """Modell komplett entfernen: aus der config.yaml austragen (+ entladen) und optional + die GGUF-Dateien loeschen, um Speicher freizugeben. Unwiderruflich.""" + cfg = read_config() + models = cfg.get("models", {}) + if req.alias not in models: + raise HTTPException(404, "Modell nicht gefunden.") + cmd = str(models[req.alias].get("cmd", "")) + m = re.search(r'-(?:m|-model)\s+(\S+)', cmd) + model_path = m.group(1).strip('"\'') if m else None + + # Erst entladen (falls geladen) — Fehler ignorieren, das Modell soll trotzdem weg. + try: + with httpx.Client(timeout=10.0) as c: + c.post(f"{LLAMA_SWAP_URL}/api/models/unload/{req.alias}") + except Exception: # noqa: BLE001 + pass + + del models[req.alias] + write_config(cfg) # cfg enthaelt das Modell jetzt nicht mehr -> _delete prueft die Restmenge + + note, freed = "", 0 + if req.delete_files and model_path: + try: + deleted, freed = _delete_model_files(model_path, cfg) + note = (f"Dateien gelöscht ({round(freed / 1024 ** 3, 1)} GB frei)." + if deleted else "Dateien liegen außerhalb des Modell-Ordners — nur ausgetragen.") + except Exception as exc: # noqa: BLE001 + note = f"Aus der Konfiguration entfernt, aber Dateien konnten nicht gelöscht werden: {exc}" + return {"ok": True, "alias": req.alias, "freed_bytes": freed, "note": note or "Modell entfernt."} + + @router.post("/unload") def unload(model: str | None = None): path = f"/api/models/unload/{model}" if model else "/api/models/unload" diff --git a/static/css/components.css b/static/css/components.css index b4292a7..f3c90a7 100644 --- a/static/css/components.css +++ b/static/css/components.css @@ -165,6 +165,8 @@ button.danger{background:var(--err);border-color:var(--err);color:#fff} button.danger:hover{filter:brightness(1.06)} button.warn{background:var(--warn);border-color:var(--warn);color:#3a2a05} button.ghost{padding:6px 11px;font-size:12.5px;background:none} +button.ghost.del{color:var(--err)} +button.ghost.del:hover{border-color:var(--err);background:var(--red-dim)} button:disabled{opacity:.5;cursor:not-allowed} .btn-row{display:flex;gap:10px;flex-wrap:wrap} diff --git a/static/js/panels/models.js b/static/js/panels/models.js index e5f63dd..fe7543b 100644 --- a/static/js/panels/models.js +++ b/static/js/panels/models.js @@ -91,6 +91,7 @@ function onStatus(s) { + `; }).join(""); for (const m of ALL) sel.insertAdjacentHTML("beforeend", ``); @@ -98,6 +99,20 @@ function onStatus(s) { tb.querySelectorAll("[data-unload]").forEach(b => b.addEventListener("click", () => unloadOne(b.getAttribute("data-unload")))); tb.querySelectorAll("[data-cfg]").forEach(b => b.addEventListener("click", () => openConfig(b.getAttribute("data-cfg")))); + tb.querySelectorAll("[data-del]").forEach(b => b.addEventListener("click", () => deleteModel(b.getAttribute("data-del")))); +} + +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. Das lässt sich nicht rückgängig machen — 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) {