feat: Modelle loeschen (Config austragen + Dateien entfernen)
- POST /api/delete_model: entlaedt das Modell, traegt es aus der config.yaml aus und loescht optional die GGUF-Dateien, um Speicher freizugeben - Sicherheit: Loeschen STRENG auf MODELS_DIR begrenzt (Pfad-Resolve-Check); loescht den ganzen Repo-Unterordner inkl. .cache nur, wenn er ein direktes Kind von MODELS_DIR ist und kein anderes Modell eine Datei darin nutzt, sonst nur die einzelne GGUF-Datei. Meldet freigegebene GB zurueck. - Modelle-Tab: roter "Loeschen"-Button je Zeile mit Klartext-Sicherheitsabfrage (unwiderruflich) via confirmModal(danger) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
from hw_math import extract_params_b, max_ctx_for, estimate_memory_gb
|
||||||
import re
|
import re
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
import psutil
|
import psutil
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", dependencies=[Depends(auth)])
|
router = APIRouter(prefix="/api", dependencies=[Depends(auth)])
|
||||||
@@ -53,6 +54,11 @@ class UpdateReq(BaseModel):
|
|||||||
ctx: int
|
ctx: int
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteReq(BaseModel):
|
||||||
|
alias: str
|
||||||
|
delete_files: bool = True
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Endpoints
|
# Endpoints
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -186,6 +192,78 @@ def update_model(req: UpdateReq):
|
|||||||
return {"ok": True}
|
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")
|
@router.post("/unload")
|
||||||
def unload(model: str | None = None):
|
def unload(model: str | None = None):
|
||||||
path = f"/api/models/unload/{model}" if model else "/api/models/unload"
|
path = f"/api/models/unload/{model}" if model else "/api/models/unload"
|
||||||
|
|||||||
@@ -165,6 +165,8 @@ button.danger{background:var(--err);border-color:var(--err);color:#fff}
|
|||||||
button.danger:hover{filter:brightness(1.06)}
|
button.danger:hover{filter:brightness(1.06)}
|
||||||
button.warn{background:var(--warn);border-color:var(--warn);color:#3a2a05}
|
button.warn{background:var(--warn);border-color:var(--warn);color:#3a2a05}
|
||||||
button.ghost{padding:6px 11px;font-size:12.5px;background:none}
|
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}
|
button:disabled{opacity:.5;cursor:not-allowed}
|
||||||
.btn-row{display:flex;gap:10px;flex-wrap:wrap}
|
.btn-row{display:flex;gap:10px;flex-wrap:wrap}
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ function onStatus(s) {
|
|||||||
<td style="text-align:right;white-space:nowrap">
|
<td style="text-align:right;white-space:nowrap">
|
||||||
<button class="ghost" data-cfg="${esc(m.name)}">Konfigurieren</button>
|
<button class="ghost" data-cfg="${esc(m.name)}">Konfigurieren</button>
|
||||||
<button class="ghost" data-unload="${esc(m.name)}">Entladen</button>
|
<button class="ghost" data-unload="${esc(m.name)}">Entladen</button>
|
||||||
|
<button class="ghost del" data-del="${esc(m.name)}">Löschen</button>
|
||||||
</td></tr>`;
|
</td></tr>`;
|
||||||
}).join("");
|
}).join("");
|
||||||
for (const m of ALL) sel.insertAdjacentHTML("beforeend", `<option>${esc(m.name)}</option>`);
|
for (const m of ALL) sel.insertAdjacentHTML("beforeend", `<option>${esc(m.name)}</option>`);
|
||||||
@@ -98,6 +99,20 @@ function onStatus(s) {
|
|||||||
|
|
||||||
tb.querySelectorAll("[data-unload]").forEach(b => b.addEventListener("click", () => unloadOne(b.getAttribute("data-unload"))));
|
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-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. <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) {
|
function openConfig(alias) {
|
||||||
|
|||||||
Reference in New Issue
Block a user