Ampel / ampel (push) Successful in 26s
Ballast raus: - 35 Routen ohne Nutzer entfernt (agent/*, fit, roles, ctx, drafts, groups, routing/policy, system/history, system/self-update, maintenance/reboot, zeitmaschine/inhalt, zeitplan, voice/health|metrics|trace|voices|reference|tts). Von 95 auf 60. - Tote Module geloescht: agent-Router, roles, agent_aktivitaet, metrics_history (samt 10-s-Sampler), voice_metrics, migrate_config, parse_mc2_timeout, scripts/. - Unbenutzte Funktionen und Konstanten entfernt (Modell-Upgrade-Empfehlung, Draft-/Kontext- Setzer, Konsole, PC-Ausfuehrer-Probe, Routing-Policy-Editor ...). Robuster: - Jobs in eigener Prozessgruppe (Abbrechen beendet wirklich alles), Zeitlimit je Job-Art, start_job_exklusiv: zwei Klicks starten kein doppeltes Update mehr; alte Jobs raeumen sich auf. - Update-Pruefung meldet Fehler (pruef_fehler, Lampe "Pruefung unklar") statt "aktuell". - Nach jedem Update sofort neu pruefen (update_stand) statt 10 Minuten alten Stand zeigen. - llama-swap-Config: Sperre (RLock + flock) fuer UI, Radar, Aufraeumen und Hirn-Umstellung. - Hermes-Config: bei Lesefehler nichts schreiben, atomar, mit Sicherung. - Live-Strom und Gateway-Warnung blockieren den Event-Loop nicht mehr (Lucy, OpenChamber). - Gateway antwortet bei Engine-Ausfall im OpenAI-Fehlerformat (502) statt nacktem 500. - Abgestuerzte Waechter-Pruefung wird ein gelber Hinweis statt still zu verschwinden. - Download laedt nur den gewuenschten Quant (vorher bei Fehlen alle Teile aller Varianten), Download-Jobs in Gruppe "download"; HF-Suche kodiert den Suchbegriff. - Herkunftspruefung: schreibende /api-Aufrufe fremder Webseiten werden abgelehnt (keine Anmeldung, User-Entscheid); Skripte, Desktop-Lucy und /v1 unveraendert. - Modellpfade: Eintragen und Loeschen nur innerhalb von MODELS_DIR. - Dienste-Liste fragt keine abgebauten Dienste mehr ab (PC-Ausfuehrer haette 3 s gekostet). - SSE-Fehlerzeilen von /api/voice/chat als gueltiges JSON. - mission-control-2.service: --timeout-graceful-shutdown 3 (Neustart ohne 10-s-Haenger). Tests: 92 gruen (neu: Herkunft, Quant-Auswahl, abgestuerzte Pruefung). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
147 lines
6.9 KiB
Python
147 lines
6.9 KiB
Python
"""
|
|
Aufräumen der Modell-Platte (Box-Wart, Umbau 09/2026): was belegt Platz, ohne gebraucht zu werden?
|
|
|
|
Zwei Arten von Kandidaten:
|
|
• Einträge ohne Rolle llama-swap-Einträge, die keine der heutigen Rollen tragen (Hirn, Coder, ihre
|
|
Bild-Zwillinge, Einbettung, Reranker) und in keiner immer-warmen Gruppe stehen.
|
|
• Ordner ohne Eintrag Unterordner von MODELS_DIR, auf die kein Eintrag mehr zeigt (alte Originale,
|
|
Reserve-Modelle, Drafts aus früheren Versuchen).
|
|
|
|
Gelöscht wird nur auf Knopfdruck des Nutzers (mit Rückfrage in der Oberfläche), nie automatisch
|
|
(User-Entscheid 23.09.: „nach der Umstellung löschen“ — die Entscheidung je Modell bleibt beim Nutzer).
|
|
Ein Eintrag wird aus der Config genommen, danach verschwinden nur Ordner, auf die NICHTS mehr zeigt:
|
|
Die Bild-Zwillinge teilen sich ihre Gewichte mit Hirn und Coder, geteilte Dateien bleiben immer liegen.
|
|
"""
|
|
|
|
import os
|
|
import shutil
|
|
from pathlib import Path
|
|
|
|
from config import MODELS_DIR
|
|
|
|
from services import llamaswap
|
|
|
|
AKTIVE_ROLLEN = {"hermes", "fast", "coder", "heavy", "vision", "coder-bild", "embed", "reranker"}
|
|
SYSTEM_ORDNER = {"mc2-backups", "radar", "pruefstand-cache", "lost+found"}
|
|
AUF_DER_BOX = os.name == "posix" # auf dem Windows-PC (Entwicklung) wird nie gelöscht
|
|
|
|
# Was der Nutzer über bekannte Ordner wissen sollte, bevor er löscht (Stand 24.09.2026).
|
|
HINWEISE = {
|
|
"Qwen3.6-35B-A3B-MTP-GGUF": "Original des Hirns vor der Uncensored-Variante (17.09.) — der Rückweg, falls das Hirn "
|
|
"einmal Probleme macht.",
|
|
"Qwen3.8-27B-GGUF": "Original des Coders vor der Uncensored-Variante (17.09.) — der Rückweg für den Coder.",
|
|
"Nemotron-3.5-Lightning-30B-A3B-GGUF": "Reserve-Hirn aus dem Prüfstand vom 17.09. (gleich gut bewertet, "
|
|
"nie in Betrieb).",
|
|
"DFlash-drafts": "Draft-Modelle aus früheren Versuchen, heute ungenutzt.",
|
|
"drafts": "Draft-Modelle aus früheren Versuchen, heute ungenutzt.",
|
|
"gpt-oss-120b": "Früheres heavy-Modell, hat seit dem 04.09. keine Rolle mehr.",
|
|
"Qwen3-VL-30B-A3B-Instruct": "Früheres Bild-Modell, seit dem 24.09. durch die Bild-Zwillinge abgelöst.",
|
|
"Muse-Glimmer-30B": "Debugger — die Rolle entfällt (Entscheid 23.09.: höchstens Hirn und Coder).",
|
|
}
|
|
|
|
|
|
def _ordner_von(pfad: str) -> str | None:
|
|
"""Oberster Ordner unter MODELS_DIR, in dem eine Datei liegt (oder None, wenn sie woanders liegt)."""
|
|
if not os.path.isabs(pfad):
|
|
return None
|
|
try:
|
|
rel = Path(pfad).resolve().relative_to(Path(MODELS_DIR).resolve())
|
|
except (ValueError, OSError):
|
|
return None
|
|
return rel.parts[0] if len(rel.parts) > 1 else None
|
|
|
|
|
|
def _genutzte_ordner(models: dict) -> dict[str, set[str]]:
|
|
"""Ordner → Einträge, deren Befehl eine Datei darin nennt (Gewichte, Projektor, Draft)."""
|
|
genutzt: dict[str, set[str]] = {}
|
|
for name, spec in models.items():
|
|
if not isinstance(spec, dict):
|
|
continue
|
|
for wort in str(spec.get("cmd") or "").split(): # jedes Wort, das unter MODELS_DIR liegt
|
|
if (ordner := _ordner_von(wort.strip("'\""))):
|
|
genutzt.setdefault(ordner, set()).add(name)
|
|
return genutzt
|
|
|
|
|
|
def _groesse(pfad: Path) -> int:
|
|
summe = 0
|
|
for wurzel, _, dateien in os.walk(pfad):
|
|
for datei in dateien:
|
|
try:
|
|
summe += os.path.getsize(os.path.join(wurzel, datei))
|
|
except OSError:
|
|
pass
|
|
return summe
|
|
|
|
|
|
def _immer_warm(cfg: dict) -> set[str]:
|
|
return {m for g in (cfg.get("groups") or {}).values()
|
|
if isinstance(g, dict) and (g.get("persistent") or g.get("persist")) for m in g.get("members") or []}
|
|
|
|
|
|
def kandidaten() -> list[dict]:
|
|
"""Alles, was sich löschen ließe — größte zuerst. Löscht nichts."""
|
|
cfg = llamaswap.read_config()
|
|
models = cfg.get("models") or {}
|
|
warm = _immer_warm(cfg)
|
|
genutzt = _genutzte_ordner(models)
|
|
liste: list[dict] = []
|
|
for name, spec in models.items():
|
|
aliase = {str(a).lower() for a in (spec or {}).get("aliases") or []}
|
|
if aliase & AKTIVE_ROLLEN or name in warm:
|
|
continue
|
|
eigene = {o for o, wer in genutzt.items() if wer == {name}} # nur von diesem Eintrag genutzt
|
|
groesse = sum(_groesse(Path(MODELS_DIR) / o) for o in eigene)
|
|
rolle = f"Rolle „{', '.join(sorted(aliase))}“ gibt es nicht mehr." if aliase else "Hat keine Rolle."
|
|
liste.append({"art": "eintrag", "name": name, "groesse_gb": round(groesse / 1e9, 1),
|
|
"hinweis": HINWEISE.get(name, rolle)})
|
|
try:
|
|
ordner = sorted(p for p in Path(MODELS_DIR).iterdir() if p.is_dir())
|
|
except OSError:
|
|
ordner = []
|
|
for pfad in ordner:
|
|
if pfad.name in genutzt or pfad.name in SYSTEM_ORDNER or pfad.name.startswith("."):
|
|
continue
|
|
liste.append({"art": "ordner", "name": pfad.name, "groesse_gb": round(_groesse(pfad) / 1e9, 1),
|
|
"hinweis": HINWEISE.get(pfad.name, "Kein Eintrag nutzt diesen Ordner.")})
|
|
return sorted(liste, key=lambda k: -k["groesse_gb"])
|
|
|
|
|
|
def _ordner_loeschen(name: str) -> int:
|
|
"""Einen Ordner direkt unter MODELS_DIR löschen; gibt die freigewordenen Bytes zurück."""
|
|
wurzel = Path(MODELS_DIR).resolve()
|
|
pfad = (wurzel / name).resolve()
|
|
if pfad.parent != wurzel or not pfad.is_dir() or name in SYSTEM_ORDNER or name.startswith("."):
|
|
raise ValueError(f"{name} ist kein löschbarer Modell-Ordner.")
|
|
groesse = _groesse(pfad)
|
|
shutil.rmtree(pfad)
|
|
return groesse
|
|
|
|
|
|
def loeschen(art: str, name: str) -> dict:
|
|
"""Knopf „Löschen“: nur, was gerade als Kandidat gilt. Gibt {ok, text} oder {ok: False, detail} zurück."""
|
|
if not any(k["art"] == art and k["name"] == name for k in kandidaten()):
|
|
return {"ok": False, "detail": f"{name} ist nicht (mehr) zum Löschen frei."}
|
|
if not AUF_DER_BOX:
|
|
return {"ok": False, "detail": "Löschen geht nur auf der Box."}
|
|
frei = 0
|
|
try:
|
|
if art == "ordner":
|
|
frei = _ordner_loeschen(name)
|
|
else:
|
|
with llamaswap.config_sperre():
|
|
cfg = llamaswap.read_config()
|
|
models = cfg.get("models") or {}
|
|
vorher = _genutzte_ordner(models)
|
|
del models[name]
|
|
for gruppe in (cfg.get("groups") or {}).values():
|
|
if isinstance(gruppe, dict) and name in (gruppe.get("members") or []):
|
|
gruppe["members"] = [m for m in gruppe["members"] if m != name]
|
|
llamaswap.write_config(cfg)
|
|
nachher = _genutzte_ordner(models)
|
|
for ordner in sorted(set(vorher) - set(nachher)): # nur Ordner, auf die jetzt nichts mehr zeigt
|
|
frei += _ordner_loeschen(ordner)
|
|
except (OSError, ValueError) as e:
|
|
return {"ok": False, "detail": f"Löschen ging nicht: {e}"}
|
|
return {"ok": True, "text": f"{name} gelöscht, {frei / 1e9:.1f} GB frei."}
|