phase1b: Backend entruempelt und robuster (35 tote Routen raus, Sperren, ehrliche Update-Pruefung)
Ampel / ampel (push) Successful in 26s
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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c8718fcde0
commit
e9f488b56c
+15
-9
@@ -21,10 +21,9 @@ import httpx
|
||||
from config import FRONTEND_DIST, V1_UPSTREAM, VERSION
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from routers import (
|
||||
agent,
|
||||
boxwart,
|
||||
events,
|
||||
gateway_proxy,
|
||||
@@ -39,7 +38,8 @@ from routers import (
|
||||
zeitmaschine,
|
||||
)
|
||||
from routers import reminders as reminders_router
|
||||
from services import metrics_history, reminders, warmer
|
||||
from services import reminders, warmer
|
||||
from services.herkunft import erlaubt
|
||||
from starlette.requests import Request
|
||||
|
||||
# Zentrales Logging — Level via MC_LOG_LEVEL (INFO default). Eine Konfiguration
|
||||
@@ -63,14 +63,12 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
"Hirn-Re-Warm-Wächter aktiv (Intervall %ss, Hirn dynamisch aus Hermes-Config)",
|
||||
warmer.INTERVAL,
|
||||
)
|
||||
# Probelauf (neue Fassung neben der laufenden, Box-Wart-Umbau 09/2026): Erinnerungen und
|
||||
# Messverlauf gehören dem echten MC2 — zwei Schreiber würden Erinnerungen doppelt feuern.
|
||||
# Probelauf (neue Fassung neben der laufenden, Box-Wart-Umbau 09/2026): Erinnerungen
|
||||
# gehören dem echten MC2 — zwei Schreiber würden Erinnerungen doppelt feuern.
|
||||
if os.environ.get("MC_PROBELAUF", "") != "1":
|
||||
tasks.append(asyncio.create_task(reminders.reminders_loop()))
|
||||
# 24-h-Metrik-Verlauf: 10-s-Sampler, Ringpuffer, persistiert.
|
||||
tasks.append(asyncio.create_task(metrics_history.sampler_loop()))
|
||||
else:
|
||||
log.info("Probelauf: Erinnerungen und Messverlauf bleiben beim echten MC2.")
|
||||
log.info("Probelauf: Erinnerungen bleiben beim echten MC2.")
|
||||
# Geteilter HTTP-Client zur lokalen Engine: Keep-Alive/Connection-Pooling statt neuer Client
|
||||
# pro /v1-Anfrage (spart Sockets/TIME_WAIT unter parallelen Agent-Strömen von Zed/Kilo).
|
||||
app.state.gw_client = httpx.AsyncClient(
|
||||
@@ -96,6 +94,15 @@ app.add_middleware(
|
||||
)
|
||||
|
||||
|
||||
# Herkunftsprüfung (24.09.2026, User-Entscheid: keine Anmeldung): Knöpfe fremder Webseiten werden
|
||||
# abgelehnt, Skripte, Desktop-Lucy und /v1 bleiben unberührt. Regeln in services/herkunft.py.
|
||||
@app.middleware("http")
|
||||
async def herkunft(request: Request, call_next: Callable[[Request], Awaitable[httpx.Response]]):
|
||||
if not erlaubt(request.method, request.url.path, request.headers.get("origin"), request.headers.get("host")):
|
||||
return JSONResponse({"detail": "Diese Aktion geht nur von der Box-Wart-Seite selbst aus."}, status_code=403)
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def no_cache(
|
||||
request: Request, call_next: Callable[[Request], Awaitable[httpx.Response]]
|
||||
@@ -113,7 +120,6 @@ app.include_router(models.router)
|
||||
app.include_router(routing.router)
|
||||
app.include_router(system.router)
|
||||
|
||||
app.include_router(agent.router)
|
||||
app.include_router(
|
||||
voice.router
|
||||
) # Sprache: STT/TTS-Proxy + Hermes-Agent-Chat (Voice-Tab)
|
||||
|
||||
@@ -9,8 +9,6 @@ Dienste (llama-swap, LiteLLM-Gateway, Hermes) steuert, statt sie nachzubauen.
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
# --- Engine (llama-swap) -----------------------------------------------------
|
||||
LLAMA_SWAP_URL = os.environ.get("MC_LLAMA_SWAP_URL", "http://127.0.0.1:8080").rstrip("/")
|
||||
CONFIG_PATH = Path(os.environ.get("MC_CONFIG_PATH", "/etc/llama-swap/config.yaml"))
|
||||
@@ -103,14 +101,6 @@ VOICE_SERVICE_URL = os.environ.get("MC_VOICE_SERVICE_URL", "http://127.0.0.1:865
|
||||
# UND — seit dem Raphael-Umbau der Desktop-Lucy (04.09.2026) — auch den PC: MC2 reicht ihn unter
|
||||
# /api/lucy/stimme/* ins LAN (routers/voice.py). EINE Stimme für alle Türen. :8650 ist NICHT Lucy.
|
||||
LUCY_STIMME_URL = os.environ.get("MC_LUCY_STIMME_URL", "http://127.0.0.1:8021").rstrip("/")
|
||||
# Box-Konsole: ttyd-Web-Terminal (echte Login-Shell). Bindet NUR an Loopback
|
||||
# (127.0.0.1:7682, base-path /console) und wird von MC2 über den ohnehin offenen Port 9001
|
||||
# rückwärts geproxyt (routers/console.py → same-origin /console/). So braucht die Konsole
|
||||
# KEINE eigene Firewall-Freigabe (Port 7682 ist von außen dicht) und keinen sudo-Eingriff.
|
||||
# Direkter, SSH-artiger Zugriff, kein Passwort — gleiches LAN-Trust-Modell wie das Dashboard.
|
||||
BOX_CONSOLE_UPSTREAM = os.environ.get("MC_BOX_CONSOLE_UPSTREAM", "http://127.0.0.1:7682").rstrip("/")
|
||||
# Öffentlicher, gleicher-Ursprung-Pfad, unter dem MC2 die Konsole ausliefert (iframe-Ziel).
|
||||
BOX_CONSOLE_PATH = "/console/"
|
||||
# Eingebaute Hermes-Web-GUI (`hermes serve`/`dashboard` — die richtige Agent-Oberfläche mit
|
||||
# Threads/Tool-Calls, die auch die Electron-Desktop-App umhüllt). ‼️ Sie bindet NICHT auf Loopback:
|
||||
# ein systemd-Drop-in überschreibt `--host 127.0.0.1` mit `0.0.0.0` und setzt dafür ein
|
||||
@@ -123,14 +113,9 @@ BOX_CONSOLE_PATH = "/console/"
|
||||
# liegen; der Proxy streift /hermes-ui ab. Kein eigener Firewall-Port nötig.
|
||||
HERMES_BUILTIN_UI_UPSTREAM = os.environ.get("MC_HERMES_BUILTIN_UI_UPSTREAM", "http://127.0.0.1:9119").rstrip("/")
|
||||
HERMES_BUILTIN_UI_PATH = "/hermes-ui/"
|
||||
# GitHub-Repo für Update-Checks.
|
||||
HERMES_AGENT_REPO = os.environ.get("MC_HERMES_AGENT_REPO", "NousResearch/hermes-agent")
|
||||
HERMES_HOME = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes")))
|
||||
# PC Executor — läuft auf dem Windows-PC, erreichbar über LAN.
|
||||
PC_EXECUTOR_URL = os.environ.get("MC_PC_EXECUTOR_URL", "http://192.168.178.22:7777").rstrip("/") # PC-IP seit 09/2026 .22 (vorher .98)
|
||||
|
||||
# --- Server ------------------------------------------------------------------
|
||||
HOST = os.environ.get("MC_HOST", "0.0.0.0")
|
||||
PORT = int(os.environ.get("MC_PORT", "9000"))
|
||||
# Gebautes React-Frontend (frontend/dist). In Prod liefert FastAPI es statisch aus;
|
||||
# im Dev läuft der Vite-Dev-Server separat und proxyt /api hierher.
|
||||
@@ -138,7 +123,3 @@ FRONTEND_DIST = Path(os.environ.get("MC_FRONTEND_DIST", str(Path(__file__).resol
|
||||
|
||||
# Version (Phase 0 — Greenfield-Skeleton).
|
||||
VERSION = "2.0.0-w8"
|
||||
|
||||
# Gemeinsame YAML-Instanz (preserve_quotes hält Kommentare/Quotes in config.yaml).
|
||||
yaml = YAML()
|
||||
yaml.preserve_quotes = True
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add backend directory to sys.path so we can import services
|
||||
sys.path.append(str(Path(__file__).resolve().parent))
|
||||
|
||||
from config import CONFIG_PATH
|
||||
from services.llamaswap import _PATH_RE, read_config, spec_draft_flags, write_config
|
||||
|
||||
|
||||
def migrate():
|
||||
print(f"Reading config from {CONFIG_PATH}...")
|
||||
if not CONFIG_PATH.exists():
|
||||
print(f"Config path {CONFIG_PATH} does not exist. Skipping.")
|
||||
return
|
||||
|
||||
cfg = read_config()
|
||||
models = cfg.get("models", {})
|
||||
|
||||
for name, spec in models.items():
|
||||
if not isinstance(spec, dict):
|
||||
continue
|
||||
cmd = spec.get("cmd", "")
|
||||
if not cmd:
|
||||
continue
|
||||
|
||||
print(f"Migrating model: {name}")
|
||||
|
||||
# 1. Defektes --prompt-cache/--prompt-cache-all entfernen (llama-CLI-Flags,
|
||||
# die llama-server ablehnt → Start scheitert). Caching macht llama-server
|
||||
# automatisch pro Slot.
|
||||
cmd = cmd.replace(" --prompt-cache-all", "").replace(" --prompt-cache", "")
|
||||
|
||||
# 2. Extract aliases/role
|
||||
aliases = spec.get("aliases", [])
|
||||
role = aliases[0] if aliases else None
|
||||
|
||||
# 3. Add parallel + (nur vocab-kompatibles) Speculative Decoding für fast/coder.
|
||||
# spec_draft_flags() prüft die Vocab-Kompatibilität und hängt --spec-type an;
|
||||
# ein inkompatibler Draft (z.B. qwen2.5 ↔ Qwen3.6) wird NICHT gesetzt.
|
||||
if role in ("fast", "coder"):
|
||||
if "--parallel" not in cmd:
|
||||
cmd = cmd.strip() + " --parallel 2"
|
||||
if "--spec-draft-model" not in cmd:
|
||||
target = mt.group(1) if (mt := _PATH_RE.search(cmd)) else ""
|
||||
cmd = cmd.strip() + spec_draft_flags(target)
|
||||
|
||||
# Update cmd
|
||||
from ruamel.yaml.scalarstring import LiteralScalarString
|
||||
spec["cmd"] = LiteralScalarString(cmd.strip() + "\n")
|
||||
|
||||
print(f"Writing updated config back to {CONFIG_PATH}...")
|
||||
write_config(cfg)
|
||||
print("Migration completed successfully!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
migrate()
|
||||
@@ -1,52 +0,0 @@
|
||||
"""Agent-Endpoint: Hermes-Status + WebUI-Link (MC verlinkt nur, betreibt nicht)."""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from services import agent_aktivitaet
|
||||
from services.agent import agent_status, hermes_brain_info, set_agent_brain, update_brain_model
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
|
||||
class BrainReq(BaseModel):
|
||||
model: str
|
||||
|
||||
|
||||
class SetBrainReq(BaseModel):
|
||||
model_id: str
|
||||
|
||||
|
||||
@router.get("/agent/status")
|
||||
def status() -> dict:
|
||||
return agent_status()
|
||||
|
||||
|
||||
@router.get("/agent/brain")
|
||||
def brain_info() -> dict:
|
||||
"""Aktuelles Agent-Hirn (hermes) + bestes NousResearch-Hermes-Update."""
|
||||
return hermes_brain_info()
|
||||
|
||||
|
||||
@router.post("/agent/brain/set")
|
||||
def set_brain(body: SetBrainReq) -> dict:
|
||||
"""Setzt ein installiertes Modell als Agent-Hirn (Alias + warm-Gruppe + Config)."""
|
||||
res = set_agent_brain(body.model_id)
|
||||
if not res.get("ok"):
|
||||
raise HTTPException(400, res.get("reason", "Fehler beim Setzen des Agent-Hirns"))
|
||||
return res
|
||||
|
||||
|
||||
@router.post("/agent/brain")
|
||||
def set_brain_model(body: BrainReq) -> dict:
|
||||
ok = update_brain_model(body.model)
|
||||
return {"ok": ok}
|
||||
|
||||
|
||||
@router.get("/agent/aktivitaet")
|
||||
def aktivitaet(limit: int = 60) -> dict:
|
||||
"""Werkzeug-Verlauf des Agenten (v3-Umbau P6).
|
||||
|
||||
Gelesen aus Hermes' eigenem Log — MC2 patcht dort nichts, es schaut nur zu. Was
|
||||
NICHT drin steht (Denkstrom, Werkzeug-Argumente), verspricht die Ansicht auch nicht.
|
||||
"""
|
||||
return agent_aktivitaet.uebersicht(max(1, min(limit, 300)))
|
||||
+23
-10
@@ -5,7 +5,6 @@ Box-Wart-Schnittstellen (Umbau 09/2026): alles, was der Cockpit-Startbildschirm
|
||||
GET /api/hinweise Hinweise + Verlauf des Wächters
|
||||
POST /api/hinweise/{id}/aktion/{aktion} Knopf eines Hinweises ausführen
|
||||
GET /api/modelle/nutzung Wer nutzt die Modelle (7 Tage, 24 h je Stunde)
|
||||
GET /api/zeitplan Heute gelaufen / demnächst geplant
|
||||
GET /api/updates/verlauf Was die letzten Update-Läufe wirklich gebracht haben
|
||||
POST /api/updates/festgehalten/{b}/freigeben Festgehaltenen Baustein wieder freigeben
|
||||
GET /api/modelle/aufraeumen Was auf der Modell-Platte ungenutzt Platz belegt
|
||||
@@ -40,11 +39,12 @@ router = APIRouter(prefix="/api", tags=["boxwart"])
|
||||
LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin"))
|
||||
|
||||
_updates_lock = threading.Lock()
|
||||
_updates_cache: dict = {"ts": 0.0, "daten": None, "laeuft": False}
|
||||
_updates_cache: dict = {"ts": 0.0, "daten": None, "laeuft": False, "stand": -1}
|
||||
UPDATES_CACHE_S = 600
|
||||
|
||||
|
||||
def _updates_holen() -> None:
|
||||
stand = maintenance.update_stand
|
||||
try:
|
||||
daten = maintenance.updates()
|
||||
except Exception:
|
||||
@@ -53,6 +53,7 @@ def _updates_holen() -> None:
|
||||
if daten is not None or _updates_cache["daten"] is None:
|
||||
_updates_cache["daten"] = daten or {}
|
||||
_updates_cache["ts"] = time.time()
|
||||
_updates_cache["stand"] = stand
|
||||
_updates_cache["laeuft"] = False
|
||||
|
||||
|
||||
@@ -61,7 +62,10 @@ def _updates_gecacht() -> dict:
|
||||
gern zehn Sekunden und mehr. Der Start wartet darauf nie: Er bekommt den letzten Stand (oder
|
||||
zunächst nichts), frisch geholt wird im Hintergrund."""
|
||||
with _updates_lock:
|
||||
veraltet = _updates_cache["daten"] is None or time.time() - _updates_cache["ts"] > UPDATES_CACHE_S
|
||||
# Nach jedem abgeschlossenen Update zählt maintenance.update_stand hoch — dann sofort neu holen,
|
||||
# statt bis zu 10 Minuten „Aktualisieren“ für schon eingespielte Updates zu zeigen (24.09.2026).
|
||||
veraltet = (_updates_cache["daten"] is None or time.time() - _updates_cache["ts"] > UPDATES_CACHE_S
|
||||
or _updates_cache["stand"] != maintenance.update_stand)
|
||||
if veraltet and not _updates_cache["laeuft"]:
|
||||
_updates_cache["laeuft"] = True
|
||||
threading.Thread(target=_updates_holen, name="updates-holen", daemon=True).start()
|
||||
@@ -83,6 +87,15 @@ def _bausteine(u: dict) -> list[dict]:
|
||||
return liste
|
||||
|
||||
|
||||
NAMEN_UNKLAR = {"os": "Betriebssystem", "engine": "Motor", "swap": "llama-swap", "hermes": "Hermes"}
|
||||
|
||||
|
||||
def _unklar(u: dict) -> list[dict]:
|
||||
"""Bausteine, deren Update-Prüfung scheiterte — ehrlich „unbekannt“ statt „aktuell“."""
|
||||
return [{"id": k, "name": NAMEN_UNKLAR.get(k, k), "grund": grund}
|
||||
for k, grund in (u.get("pruef_fehler") or {}).items() if grund]
|
||||
|
||||
|
||||
def _zeit_kurz(ts: float | None) -> str:
|
||||
if not ts:
|
||||
return "–"
|
||||
@@ -143,12 +156,17 @@ def _lampen(stand: dict, u: dict) -> list[dict]:
|
||||
|
||||
n = len(_bausteine(u))
|
||||
fest = len(update_verlauf.festgehalten())
|
||||
unklar = len(_unklar(u))
|
||||
if fest:
|
||||
lampen.append(lampe("updates", "Updates", "warn", f"{fest} festgehalten"))
|
||||
elif _updates_cache["daten"] is None:
|
||||
lampen.append(lampe("updates", "Updates", "aus", "wird geprüft"))
|
||||
elif n:
|
||||
lampen.append(lampe("updates", "Updates", "info", f"{n} bereit"))
|
||||
elif unklar:
|
||||
lampen.append(lampe("updates", "Updates", "warn", "Prüfung unklar"))
|
||||
else:
|
||||
lampen.append(lampe("updates", "Updates", "info" if n else "ok", f"{n} bereit" if n else "aktuell"))
|
||||
lampen.append(lampe("updates", "Updates", "ok", "aktuell"))
|
||||
return lampen
|
||||
|
||||
|
||||
@@ -183,7 +201,7 @@ def start() -> dict:
|
||||
"verlauf": stand["verlauf"][:20],
|
||||
"lampen": _lampen(stand, u),
|
||||
"box": _box(),
|
||||
"updates": {"bausteine": _bausteine(u), "modelle": u.get("model_list") or [],
|
||||
"updates": {"bausteine": _bausteine(u), "unklar": _unklar(u),
|
||||
"festgehalten": update_verlauf.festgehalten(),
|
||||
"gelesen": _updates_cache["daten"] is not None},
|
||||
"flugplan": zeitplan.flugplan(),
|
||||
@@ -208,11 +226,6 @@ def nutzung() -> dict:
|
||||
return modell_nutzung.nutzung()
|
||||
|
||||
|
||||
@router.get("/zeitplan")
|
||||
def zeitplan_route() -> dict:
|
||||
return zeitplan.flugplan()
|
||||
|
||||
|
||||
@router.get("/updates/verlauf")
|
||||
def updates_verlauf(anzahl: int = 8) -> dict:
|
||||
"""Die letzten Update-Läufe mit dem Ergebnis je Baustein (aus dem Meldeprotokoll der Box)."""
|
||||
|
||||
@@ -28,7 +28,6 @@ import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from config import MODELS_DIR
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
@@ -69,8 +68,6 @@ def _fingerprints() -> dict[str, object]:
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
# Erinnerungen: Datei-mtime (das Auftragsbuch ist seit 04.09.2026 ausgebaut)
|
||||
fp["reminders"] = _mtime(MODELS_DIR / "mc2-reminders.json")
|
||||
return fp
|
||||
|
||||
|
||||
@@ -80,7 +77,9 @@ async def _strom(request: Request, mit_metrik: bool):
|
||||
Die Basislinie entsteht JE VERBINDUNG (der Client hat beim Verbinden frisch geladen) —
|
||||
ein globaler Snapshot würde bei mehreren Clients Events verschlucken.
|
||||
"""
|
||||
alt = _fingerprints()
|
||||
# Die Abdrücke fragen u. a. llama-swap ab (bis 2 s). Im Event-Loop blockierte das jede andere
|
||||
# Anfrage dieses Prozesses, auch Lucys /v1-Weiterleitung — seit 24.09.2026 im Thread.
|
||||
alt = await asyncio.to_thread(_fingerprints)
|
||||
yield ": verbunden\n\n"
|
||||
|
||||
seit_abdruck = 0.0
|
||||
@@ -97,7 +96,8 @@ async def _strom(request: Request, mit_metrik: bool):
|
||||
if mit_metrik:
|
||||
try:
|
||||
from services.system import metrik_punkt
|
||||
yield f"event: metrik\ndata: {json.dumps(metrik_punkt())}\n\n"
|
||||
punkt = await asyncio.to_thread(metrik_punkt)
|
||||
yield f"event: metrik\ndata: {json.dumps(punkt)}\n\n"
|
||||
seit_ping = 0.0
|
||||
except Exception:
|
||||
# Ein kaputter Messpunkt darf den Strom nicht reißen — die Ansicht fällt
|
||||
@@ -106,7 +106,7 @@ async def _strom(request: Request, mit_metrik: bool):
|
||||
|
||||
if seit_abdruck >= ABDRUCK_S:
|
||||
seit_abdruck = 0.0
|
||||
neu = _fingerprints()
|
||||
neu = await asyncio.to_thread(_fingerprints)
|
||||
keys = [k for k, v in neu.items() if k in alt and v != alt[k]]
|
||||
alt.update(neu)
|
||||
if keys:
|
||||
|
||||
@@ -161,10 +161,20 @@ def _inject_language(body: dict, alias: str) -> None:
|
||||
elif isinstance(first_sys.get("content"), list):
|
||||
first_sys["content"].append({"type": "text", "text": _LANG_DIRECTIVE})
|
||||
|
||||
def _upstream_fehler(text: str, status: int = 502, headers: dict | None = None) -> JSONResponse:
|
||||
"""Fehler im OpenAI-Format statt eines nackten 500 (24.09.2026): Hermes, OpenChamber und Lucy
|
||||
können damit umgehen und zeigen den Grund an."""
|
||||
return JSONResponse({"error": {"message": text, "type": "upstream_error"}},
|
||||
status_code=status, headers=headers)
|
||||
|
||||
|
||||
@router.get("/models")
|
||||
async def models(request: Request):
|
||||
client = request.app.state.gw_client # geteilter Keep-Alive-Client (siehe app.py lifespan)
|
||||
r = await client.get(f"{LLAMA_SWAP_URL}/v1/models", timeout=10.0)
|
||||
try:
|
||||
r = await client.get(f"{LLAMA_SWAP_URL}/v1/models", timeout=10.0)
|
||||
except httpx.HTTPError as exc:
|
||||
return _upstream_fehler(f"Engine nicht erreichbar: {exc.__class__.__name__}")
|
||||
try:
|
||||
data = r.json()
|
||||
except ValueError:
|
||||
@@ -275,7 +285,10 @@ async def _proxy(path: str, request: Request):
|
||||
url = f"{LLAMA_SWAP_URL}{path}"
|
||||
if body.get("stream"):
|
||||
req = client.build_request("POST", url, json=body, timeout=None)
|
||||
r = await client.send(req, stream=True)
|
||||
try:
|
||||
r = await client.send(req, stream=True)
|
||||
except httpx.HTTPError as exc:
|
||||
return _upstream_fehler(f"Engine nicht erreichbar: {exc.__class__.__name__}", headers=routed)
|
||||
if r.status_code != 200:
|
||||
await r.aread()
|
||||
try:
|
||||
@@ -293,8 +306,15 @@ async def _proxy(path: str, request: Request):
|
||||
await r.aclose()
|
||||
return StreamingResponse(gen(), media_type="text/event-stream", headers=routed)
|
||||
|
||||
r = await client.post(url, json=body, timeout=600.0)
|
||||
resp_json = r.json()
|
||||
try:
|
||||
r = await client.post(url, json=body, timeout=600.0)
|
||||
except httpx.HTTPError as exc:
|
||||
return _upstream_fehler(f"Engine nicht erreichbar: {exc.__class__.__name__}", headers=routed)
|
||||
try:
|
||||
resp_json = r.json()
|
||||
except ValueError:
|
||||
log.warning("%s: Engine-Antwort ist kein JSON (HTTP %s): %.200s", path, r.status_code, r.text)
|
||||
return _upstream_fehler(f"Engine lieferte keine gültige Antwort (HTTP {r.status_code}).", headers=routed)
|
||||
if isinstance(resp_json, dict):
|
||||
record_usage(resp_json.get("usage"), alias)
|
||||
if any(isinstance(c, dict) and c.get("finish_reason") == "length"
|
||||
|
||||
@@ -78,11 +78,6 @@ def update_all() -> dict:
|
||||
return maintenance.update_all_job()
|
||||
|
||||
|
||||
@router.post("/maintenance/reboot")
|
||||
def reboot() -> dict:
|
||||
return maintenance.reboot()
|
||||
|
||||
|
||||
@router.post("/maintenance/restart")
|
||||
def restart(body: RestartReq) -> dict:
|
||||
return maintenance.restart_service(body.service)
|
||||
|
||||
+10
-102
@@ -1,11 +1,12 @@
|
||||
"""Modelle-Endpoints: Liste (mit Caps), Discover, Fit, Register, Groups."""
|
||||
"""Modelle-Endpoints: Liste, Discover, Suche und Installation bei Hugging Face, Jobs, Rollen,
|
||||
Laden/Entladen/Löschen. Fit-, Kontext-, Draft- und Gruppen-Routen sind am 24.09.2026 entfallen
|
||||
(ohne Nutzer seit dem Box-Wart-Umbau)."""
|
||||
|
||||
import psutil
|
||||
from config import HF_DOWNLOAD_ENV, MODELS_DIR
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from services import budget, discover, geheimnisse, hf, jobengine, llamaswap
|
||||
from services.fit import evaluate_fit, max_ctx_for
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
@@ -29,27 +30,6 @@ def discover_models(force: bool = False) -> dict:
|
||||
return {**data, "sys_ram_gb": round(ram, 1)}
|
||||
|
||||
|
||||
@router.get("/fit")
|
||||
def fit(params_b: float = 0, quant: str = "Q4_K_M", ctx: int = 8192,
|
||||
name: str = "", role: str = "") -> dict:
|
||||
"""Hardware-Fit-Vorschau. params_b<=0 → aus KATALOG (echte Metadaten, MoE-bewusst)
|
||||
oder sonst aus dem Namen geschätzt. assigned_ctx = der ctx, der TATSÄCHLICH vergeben
|
||||
würde: SETUP-BEWUSST (neben Hirn/warmem Set), nicht nur gegen den Gesamt-RAM.
|
||||
So sieht die 'Erweiterte Ansicht' vor dem Download Ampel + echten ctx."""
|
||||
ram = _ram_gb()
|
||||
pb = params_b if params_b > 0 else budget.params_b_for(name)
|
||||
saw = budget.setup_aware_ctx(pb, quant, role=role or None)
|
||||
return {
|
||||
"params_b": round(pb, 1),
|
||||
"fit": evaluate_fit(pb, quant, ctx, ram, name=name),
|
||||
"optimal_ctx": max_ctx_for(pb, quant, ram), # Roh-Obergrenze (Modell allein)
|
||||
"assigned_ctx": saw["ctx"], # setup-bewusst vergeben
|
||||
"budget": {"gtt_gb": saw["gtt_gb"], "reserved_gb": saw["reserved_gb"],
|
||||
"budget_gb": saw["budget_gb"], "mode": saw["mode"]},
|
||||
"sys_ram_gb": round(ram, 1),
|
||||
}
|
||||
|
||||
|
||||
class RegisterReq(BaseModel):
|
||||
model_path: str
|
||||
role: str | None = None
|
||||
@@ -61,6 +41,10 @@ class RegisterReq(BaseModel):
|
||||
|
||||
@router.post("/models/register")
|
||||
def register(req: RegisterReq) -> dict:
|
||||
# Nur Dateien aus dem Modellordner (24.09.2026): der Pfad landet im Startbefehl der Engine.
|
||||
for pfad in (req.model_path, req.mmproj_path):
|
||||
if pfad and not llamaswap.im_modellordner(pfad):
|
||||
raise HTTPException(400, f"Pfad liegt nicht im Modellordner ({MODELS_DIR}): {pfad}")
|
||||
try:
|
||||
model_id = llamaswap.register_model(
|
||||
req.model_path, role=req.role, ctx=req.ctx, ttl=req.ttl,
|
||||
@@ -148,8 +132,9 @@ def install(req: InstallReq) -> dict:
|
||||
env = dict(HF_DOWNLOAD_ENV)
|
||||
if token := geheimnisse.hf_token():
|
||||
env["HF_TOKEN"] = token
|
||||
job_id = jobengine.start_job(args, f"download {req.repo}", env=env,
|
||||
on_done=_apply_role if role else None)
|
||||
# Gruppe „download“: so taucht der Download in der Oberfläche mit Fortschritt und Abbrechen auf.
|
||||
job_id = jobengine.start_job(args, f"Download {repo}", env=env, group="download",
|
||||
on_done=_apply_role if role else None, zeitlimit_s=6 * 3600)
|
||||
jobengine.attach_download_progress(job_id, str(target), info["total_bytes"])
|
||||
return {"ok": True, "job_id": job_id, "model_id": model_id, "model_path": model_path,
|
||||
"total_bytes": info["total_bytes"], "files": len(info["files"])}
|
||||
@@ -169,14 +154,6 @@ class RoleReq(BaseModel):
|
||||
role: str | None = None
|
||||
|
||||
|
||||
@router.get("/roles/{role}/recommend")
|
||||
def recommend_role(role: str) -> dict:
|
||||
"""Welches installierte Modell passt am besten auf diese Rolle? (Capability + setup-
|
||||
bewusster Fit). Basis für 'Empfohlen'-Hinweis + Auto-Pick im Rollen-Zuweisungs-Modal."""
|
||||
from services import roles
|
||||
return roles.recommend_for_role(role)
|
||||
|
||||
|
||||
@router.post("/models/{model_id}/role")
|
||||
def set_model_role(model_id: str, body: RoleReq) -> dict:
|
||||
new_role = (body.role or "").strip().lower()
|
||||
@@ -201,54 +178,6 @@ def set_model_role(model_id: str, body: RoleReq) -> dict:
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
class CtxReq(BaseModel):
|
||||
ctx: int
|
||||
|
||||
|
||||
@router.get("/models/{model_id}/ctx/auto")
|
||||
def auto_ctx(model_id: str) -> dict:
|
||||
"""Setup-bewusster Optimal-ctx für ein bestehendes Modell (Rolle/Params/Quant +
|
||||
aktuelles Setup). Basis für den 'Auto'-Button an der Modellkarte."""
|
||||
m = next((x for x in llamaswap.list_models() if x["name"] == model_id), None)
|
||||
if not m:
|
||||
raise HTTPException(404, "Modell nicht gefunden")
|
||||
saw = budget.setup_aware_ctx_for_model(m)
|
||||
return {"model_id": model_id, "current_ctx": m.get("ctx"),
|
||||
"params_b": round(budget.params_of_model(m), 1), "quant": m.get("quant"),
|
||||
"role": m.get("role"), **saw}
|
||||
|
||||
|
||||
@router.post("/models/{model_id}/ctx")
|
||||
def set_model_ctx(model_id: str, body: CtxReq) -> dict:
|
||||
if not llamaswap.set_ctx(model_id, body.ctx):
|
||||
raise HTTPException(404, "Modell nicht gefunden")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/models/drafts")
|
||||
def list_drafts(target: str = "") -> dict:
|
||||
"""Verfügbare Draft-Modelle + ihre Vocab-Kompatibilität zum Ziel-Modell
|
||||
(target = GGUF-Pfad). Basis für die idiotensichere Spec-Draft-Auswahl im UI."""
|
||||
return llamaswap.drafts_for(target)
|
||||
|
||||
|
||||
class DraftReq(BaseModel):
|
||||
draft_path: str | None = None
|
||||
|
||||
|
||||
@router.post("/models/{model_id}/draft")
|
||||
def set_model_draft(model_id: str, body: DraftReq) -> dict:
|
||||
"""Setzt/entfernt den Speculative-Decoding-Draft eines Modells. Inkompatible
|
||||
(oder nicht prüfbare) Drafts werden serverseitig abgelehnt."""
|
||||
try:
|
||||
res = llamaswap.set_spec_draft(model_id, body.draft_path)
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(500, str(exc))
|
||||
if not res["ok"]:
|
||||
raise HTTPException(400 if "kompatib" in res["reason"].lower() else 404, res["reason"])
|
||||
return res
|
||||
|
||||
|
||||
@router.post("/models/unload")
|
||||
def unload_all_models() -> dict:
|
||||
import httpx
|
||||
@@ -297,24 +226,3 @@ def delete(model_id: str) -> dict:
|
||||
if not llamaswap.delete_model(model_id):
|
||||
raise HTTPException(404, "Modell nicht gefunden")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/groups")
|
||||
def groups() -> dict:
|
||||
return {"groups": llamaswap.list_groups()}
|
||||
|
||||
|
||||
class GroupReq(BaseModel):
|
||||
group: str
|
||||
members: list[str]
|
||||
swap: bool = False
|
||||
persist: bool = False
|
||||
|
||||
|
||||
@router.put("/groups")
|
||||
def set_group(req: GroupReq) -> dict:
|
||||
try:
|
||||
llamaswap.set_group(req.group, req.members, swap=req.swap, persist=req.persist)
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(500, str(exc))
|
||||
return {"ok": True}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"""Routing-Endpoints: Lane-Summary (chat/coding) + UI-editierbare Policy (hot-reload)."""
|
||||
"""Routing-Übersicht (Lanes, Gateway erreichbar). Der Policy-Editor ist seit dem Box-Wart-Umbau
|
||||
aus der Oberfläche raus; die Policy selbst liest der Gateway weiter aus mc2-routing.json."""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from fastapi import APIRouter
|
||||
from services import gateway
|
||||
from services.routing_policy import policy_meta, save_policy
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
@@ -11,32 +10,3 @@ router = APIRouter(prefix="/api")
|
||||
@router.get("/routing")
|
||||
def routing() -> dict:
|
||||
return {**gateway.routing_summary(), "gateway_reachable": gateway.gateway_reachable()}
|
||||
|
||||
|
||||
@router.get("/routing/policy")
|
||||
def get_policy() -> dict:
|
||||
"""Aktuelle Policy + Defaults (für „Zurücksetzen“) + Feld-Spezifikation für den Editor."""
|
||||
return policy_meta()
|
||||
|
||||
|
||||
class PolicyPatch(BaseModel):
|
||||
fast: str | None = None
|
||||
heavy: str | None = None
|
||||
coder: str | None = None
|
||||
coder_lite: str | None = None
|
||||
heavy_chars: int | None = None
|
||||
coding_escalate_chars: int | None = None
|
||||
fast_no_think: bool | None = None
|
||||
|
||||
|
||||
@router.put("/routing/policy")
|
||||
def put_policy(patch: PolicyPatch) -> dict:
|
||||
"""Teil-Update der Routing-Policy. Validiert, persistiert atomar, sofort wirksam (hot-reload)."""
|
||||
fields = {k: v for k, v in patch.model_dump().items() if v is not None}
|
||||
if not fields:
|
||||
raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren.")
|
||||
try:
|
||||
new_policy = save_policy(fields)
|
||||
except (ValueError, TypeError) as e:
|
||||
raise HTTPException(status_code=400, detail=f"Ungültige Policy: {e}")
|
||||
return {"policy": new_policy}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""System-Endpoints: Live-Status + Wartung (Restart/Self-Update — auf der Box).
|
||||
"""System-Endpoints: Live-Status, Dienste-Liste, Sicherung, Neustart, Token-Verbrauch.
|
||||
|
||||
Wartung läuft als systemd-USER-Dienst → KEIN sudo/Passwort (Nordstern).
|
||||
Lokal (Windows) schlagen die Shell-Befehle harmlos fehl und werden als Fehler
|
||||
@@ -26,22 +26,11 @@ log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
# Quelle für Self-Update (auf der Box ~/mission-control-v2).
|
||||
SOURCE_DIR = os.path.expanduser(os.environ.get("MC2_SOURCE_DIR", "~/mission-control-v2"))
|
||||
|
||||
|
||||
@router.get("/system/status")
|
||||
def status() -> dict:
|
||||
return system_status()
|
||||
|
||||
|
||||
@router.get("/system/history")
|
||||
def history(minutes: int = 60) -> dict:
|
||||
"""Metrik-Verlauf (max. 24 h) für die Cockpit-Zeitachse — s. services/metrics_history."""
|
||||
from services import metrics_history
|
||||
return metrics_history.history(minutes)
|
||||
|
||||
|
||||
def _voice_reachable() -> bool:
|
||||
try:
|
||||
return httpx.get(f"{VOICE_SERVICE_URL}/health", timeout=2).status_code == 200
|
||||
@@ -124,15 +113,6 @@ def backups() -> dict:
|
||||
return {"backups": backup_svc.list_backups()}
|
||||
|
||||
|
||||
def _run(cmd: list[str], cwd: str | None = None) -> dict:
|
||||
try:
|
||||
p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=180)
|
||||
return {"ok": p.returncode == 0, "code": p.returncode,
|
||||
"out": (p.stdout or "")[-2000:], "err": (p.stderr or "")[-2000:]}
|
||||
except Exception as exc:
|
||||
return {"ok": False, "code": -1, "out": "", "err": str(exc)}
|
||||
|
||||
|
||||
class RestartReq(BaseModel):
|
||||
service: str
|
||||
|
||||
@@ -147,15 +127,6 @@ def restart(req: RestartReq) -> dict:
|
||||
return maintenance.restart_service(req.service)
|
||||
|
||||
|
||||
@router.post("/system/self-update")
|
||||
def self_update() -> dict:
|
||||
"""git pull (Source) → venv-Deps → Dienst-Restart. Auf der Box; lokal Fehler."""
|
||||
pull = _run(["git", "fetch", "--all"], cwd=SOURCE_DIR)
|
||||
reset = _run(["git", "reset", "--hard", "origin/main"], cwd=SOURCE_DIR)
|
||||
restart_res = _run(["systemctl", "--user", "restart", "mission-control-2"])
|
||||
return {"pull": pull, "reset": reset, "restart": restart_res}
|
||||
|
||||
|
||||
@router.get("/system/token-stats")
|
||||
def token_stats() -> dict:
|
||||
"""Token-Verbrauch + Cloud-Ersparnis. Logik im pricing-Service (SSoT)."""
|
||||
|
||||
+289
-425
@@ -1,425 +1,289 @@
|
||||
"""
|
||||
Voice-Endpoints für „Mit Hermes reden" (Browser-Voice + 3D-Avatar).
|
||||
|
||||
Dünner Layer: STT/TTS werden zum Voice-Sidecar (:8650) geproxyt; der Chat geht an den
|
||||
Hermes-`api_server` (:8642, OpenAI-kompatibel) — denselben vollen Agenten mit Tools +
|
||||
eigenem Gedächtnis wie CLI/Telegram. Mit stabilem `X-Hermes-Session-Id` hält die Plattform den
|
||||
Transcript server-seitig, daher schickt der Client je Turn nur die neue User-Nachricht.
|
||||
|
||||
LAN-only (kein Token in der 2.0-Phase), wie die übrigen MC2-Endpoints.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
# Injection-Schutz (Stufe 0): guard.py liegt im mcp/-Verzeichnis. Per Pfad laden (eigene MC2-Venv).
|
||||
import sys as _sys
|
||||
import time
|
||||
|
||||
import httpx
|
||||
from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL, LLAMA_SWAP_URL, LUCY_STIMME_URL, VOICE_SERVICE_URL
|
||||
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from services import announce
|
||||
from services.voice_metrics import ( # Per-Stage-Latenz + Per-Turn-Trace (intern)
|
||||
Timer,
|
||||
TurnTrace,
|
||||
get_metrics,
|
||||
get_trace,
|
||||
park,
|
||||
record_stage,
|
||||
)
|
||||
|
||||
_GUARD_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "mcp")
|
||||
if _GUARD_DIR not in _sys.path:
|
||||
_sys.path.insert(0, _GUARD_DIR)
|
||||
try:
|
||||
from guard import wrap_untrusted
|
||||
except Exception: # den Voice-Pfad nie wegen des Filters lahmlegen
|
||||
def wrap_untrusted(text: str, label: str = "") -> str:
|
||||
return text
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
# Bildschirm-Sicht: das DEDIZIERTE Vision-Modell (Qwen3-VL-8B) beschreibt das Bild; die Beschreibung
|
||||
# geht als TEXT an Hermes -> Lucy behält ihr volles Hirn/Gedächtnis UND nutzt das bessere VL-Modell
|
||||
# (statt der schwächeren Vision der fast-MoE). Per Env abschaltbar/umstellbar.
|
||||
VISION_MODEL = os.environ.get("MC_VISION_MODEL", "vision")
|
||||
# Knappe Beschreibung = schnellere VL-Generierung UND weniger Hermes-Kontext-Bloat (B2).
|
||||
VISION_MAX_TOKENS = int(os.environ.get("MC_VISION_MAX_TOKENS", "280"))
|
||||
|
||||
|
||||
async def _describe_images(image_urls: list[str], hint: str) -> str:
|
||||
"""Lässt das Vision-Modell die Screenshots (1 je Monitor) knapp beschreiben (Deutsch).
|
||||
Mehrere Bilder gehen in EINER Nachricht ans VL-Modell. Leerer String bei Fehler."""
|
||||
multi = len(image_urls) > 1
|
||||
intro = (f"Hier sind {len(image_urls)} Screenshots (je ein Monitor). Beschreibe auf Deutsch in höchstens "
|
||||
"5 kurzen Sätzen das Wesentliche (pro Monitor: App/Fenster, wichtige Inhalte, sichtbarer Text/Code). "
|
||||
"Keine Einleitung, keine Wiederholung der Frage. "
|
||||
if multi else
|
||||
"Beschreibe auf Deutsch in höchstens 5 kurzen Sätzen das Wesentliche auf diesem Screenshot "
|
||||
"(App/Fenster, wichtige Inhalte, sichtbarer Text/Code). Keine Einleitung. ")
|
||||
content: list = [{"type": "text", "text": intro + "Frage des Nutzers dazu: " + hint}]
|
||||
for u in image_urls:
|
||||
content.append({"type": "image_url", "image_url": {"url": u}})
|
||||
try:
|
||||
# 45 s statt 120 s: Qwen3-VL braucht warm ~5 s; wenn es 45 s nicht schafft, ist etwas
|
||||
# kaputt und Lucy soll lieber ohne Bildschirm-Kontext antworten als ewig hängen.
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(float(os.environ.get("MC_VISION_TIMEOUT", "45")), connect=5.0)) as client:
|
||||
r = await client.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json={
|
||||
"model": VISION_MODEL, "max_tokens": VISION_MAX_TOKENS, "stream": False,
|
||||
"messages": [{"role": "user", "content": content}],
|
||||
})
|
||||
r.raise_for_status()
|
||||
return (r.json().get("choices") or [{}])[0].get("message", {}).get("content", "").strip()
|
||||
except Exception as exc:
|
||||
log.warning("Vision-Beschreibung fehlgeschlagen: %s", exc)
|
||||
return ""
|
||||
|
||||
_TIMEOUT = httpx.Timeout(120.0, connect=5.0) # Chatterbox-TTS auf CPU darf dauern
|
||||
|
||||
|
||||
class TTSIn(BaseModel):
|
||||
text: str
|
||||
engine: str = "piper"
|
||||
voice: str = ""
|
||||
language: str = ""
|
||||
ref_path: str = ""
|
||||
|
||||
|
||||
class ChatIn(BaseModel):
|
||||
text: str # die neue User-Äußerung (STT-Ergebnis)
|
||||
session_id: str # stabiler Voice-Faden → server-seitiger Transcript
|
||||
session_key: str = "" # optional: Langzeit-Memory-Scope
|
||||
system: str = "" # optionaler ephemerer System-Prompt (z.B. „antworte knapp/gesprochen")
|
||||
model: str = ""
|
||||
images: list[str] = [] # optionale Bildschirm-Sicht: ein data:-URL je Monitor (Lucys „Augen")
|
||||
|
||||
|
||||
class AnnounceIn(BaseModel):
|
||||
text: str # die Meldung (wird von Lucy gesprochen)
|
||||
subject: str = "" # kurze Betreffzeile (z.B. "[Update]")
|
||||
source: str = "" # Absender (sentry/notify/cron …) — nur fürs Log/Panel
|
||||
priority: str = "normal" # 'silent' = nur im Verlauf zeigen, nicht sprechen
|
||||
|
||||
|
||||
class AlarmIn(BaseModel):
|
||||
text: str # die Alarm-Meldung
|
||||
subject: str = "[Alarm]" # Betreff (Telegram-Präfix)
|
||||
source: str = "alarm" # Absender fürs Log/Panel (z.B. "lucy-watchdog")
|
||||
|
||||
|
||||
@router.post("/alarm")
|
||||
def alarm(body: AlarmIn) -> dict:
|
||||
"""Lucy-UNABHÄNGIGER Alarm-Weg: schickt direkt auf Telegram (und legt die Meldung in den
|
||||
Briefkasten). Für Absender, die NICHT auf die sprechende Lucy zählen können — allen voran
|
||||
der PC-seitige Lucy-Watchdog, wenn die Desktop-App selbst hängt (dann nützt der Briefkasten
|
||||
nichts, weil niemand ihn vorliest → Telegram ist der einzige verlässliche Kanal). LAN-only
|
||||
wie alle MC2-Endpoints."""
|
||||
text = (body.text or "").strip()
|
||||
if not text:
|
||||
raise HTTPException(400, "Leere Meldung.")
|
||||
subject = (body.subject or "[Alarm]").strip()
|
||||
try:
|
||||
item = announce.add(text, subject, body.source or "alarm", "normal")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc))
|
||||
announce.notify_telegram(subject, text) # best-effort Telegram (posix/bash; Windows = No-op)
|
||||
return {"ok": True, "item": item}
|
||||
|
||||
|
||||
@router.post("/voice/announce")
|
||||
def voice_announce(body: AnnounceIn) -> dict:
|
||||
"""Meldung in den Briefkasten legen (Lucy-Proaktivität). Absender: Health-Wächter,
|
||||
notify.sh (Updates/Radar/Telegram-Spiegel), Hermes-cron. LAN-only wie alle MC2-Endpoints."""
|
||||
try:
|
||||
return {"ok": True, "item": announce.add(body.text, body.subject, body.source, body.priority)}
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc))
|
||||
|
||||
|
||||
@router.get("/voice/announcements")
|
||||
def voice_announcements(after: int | None = None, limit: int = 20) -> dict:
|
||||
"""Neue Meldungen nach Cursor `after` abholen (Lucy pollt). Ohne `after` nur den
|
||||
aktuellen Cursor-Stand (latest) — Erststart plappert so keine alten Meldungen nach."""
|
||||
return announce.list_after(after, limit)
|
||||
|
||||
|
||||
@router.get("/voice/health")
|
||||
def voice_health() -> dict:
|
||||
"""Erreichbarkeit des Voice-Sidecars + ob der Hermes-API-Key gesetzt ist."""
|
||||
out: dict = {"sidecar": False, "hermes_key": bool(HERMES_API_KEY)}
|
||||
try:
|
||||
r = httpx.get(f"{VOICE_SERVICE_URL}/health", timeout=httpx.Timeout(5.0))
|
||||
out["sidecar"] = r.status_code == 200
|
||||
out["detail"] = r.json() if r.status_code == 200 else None
|
||||
except Exception as exc:
|
||||
out["error"] = str(exc)
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/voice/metrics")
|
||||
def voice_metrics() -> dict:
|
||||
"""Rollende Latenz-Stats je Stufe (avg/p50/p95/last, ms). Quelle u.a. für selbstkritik-feed.sh."""
|
||||
return get_metrics()
|
||||
|
||||
|
||||
@router.get("/voice/trace")
|
||||
def voice_trace(limit: int = 20) -> dict:
|
||||
"""Per-Turn-Trace: die letzten `limit` Chat-Turns mit Stufen-Breakdown (STT · Vision · Hirn ·
|
||||
Generierung). Neueste zuerst. Für die Latenz-Ansicht im Cockpit —
|
||||
damit man den EINEN langsamen Turn sieht, den ein Durchschnitt verschluckt."""
|
||||
return {"turns": get_trace(limit)}
|
||||
|
||||
|
||||
@router.get("/voice/voices")
|
||||
def voice_voices() -> dict:
|
||||
try:
|
||||
r = httpx.get(f"{VOICE_SERVICE_URL}/voices", timeout=httpx.Timeout(10.0))
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as exc:
|
||||
raise HTTPException(502, f"Voice-Sidecar nicht erreichbar: {exc}")
|
||||
|
||||
|
||||
@router.post("/voice/stt")
|
||||
async def voice_stt(audio: UploadFile = File(...), language: str = Form(default="")) -> dict:
|
||||
"""Mikro-Audio → Text (Proxy auf Sidecar /stt)."""
|
||||
data = await audio.read()
|
||||
if not data:
|
||||
raise HTTPException(400, "Leeres Audio.")
|
||||
files = {"audio": (audio.filename or "rec.webm", data, audio.content_type or "audio/webm")}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
_t0 = time.perf_counter()
|
||||
r = await client.post(f"{VOICE_SERVICE_URL}/stt", files=files, data={"language": language})
|
||||
_ms = (time.perf_counter() - _t0) * 1000.0
|
||||
record_stage("stt", _ms)
|
||||
park("stt", _ms) # der folgende /voice/chat-Turn sammelt die STT-Dauer für seinen Trace ein
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except httpx.HTTPError as exc:
|
||||
raise HTTPException(502, f"STT fehlgeschlagen: {exc}")
|
||||
|
||||
|
||||
@router.post("/voice/turn")
|
||||
async def voice_turn(audio: UploadFile = File(...)) -> dict:
|
||||
"""Semantische Turn-Detection (Smart Turn v3): war die Äußerung fertig? Proxy → Sidecar."""
|
||||
data = await audio.read()
|
||||
if not data:
|
||||
raise HTTPException(400, "Leeres Audio.")
|
||||
files = {"audio": (audio.filename or "rec.wav", data, audio.content_type or "audio/wav")}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=3.0)) as client:
|
||||
with Timer("turn"):
|
||||
r = await client.post(f"{VOICE_SERVICE_URL}/turn", files=files)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except httpx.HTTPError as exc:
|
||||
# Turn-Check ist eine Optimierung — bei Ausfall lieber sofort antworten als hängen.
|
||||
log.warning("Turn-Check fehlgeschlagen: %s", exc)
|
||||
return {"complete": True, "probability": 1.0, "engine": "fallback"}
|
||||
|
||||
|
||||
@router.post("/voice/reference")
|
||||
async def voice_set_reference(audio: UploadFile = File(...)) -> dict:
|
||||
"""Klon-Referenz (z.B. ElevenLabs-Erzeugnis) hochladen → Chatterbox nutzt sie. Proxy → Sidecar."""
|
||||
data = await audio.read()
|
||||
if not data:
|
||||
raise HTTPException(400, "Leeres Audio.")
|
||||
files = {"audio": (audio.filename or "ref.wav", data, audio.content_type or "audio/mpeg")}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
r = await client.post(f"{VOICE_SERVICE_URL}/reference", files=files)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except httpx.HTTPError as exc:
|
||||
raise HTTPException(502, f"Referenz-Upload fehlgeschlagen: {exc}")
|
||||
|
||||
|
||||
@router.get("/voice/reference")
|
||||
def voice_get_reference() -> dict:
|
||||
try:
|
||||
r = httpx.get(f"{VOICE_SERVICE_URL}/reference", timeout=httpx.Timeout(8.0))
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as exc:
|
||||
return {"active": False, "error": str(exc)}
|
||||
|
||||
|
||||
@router.delete("/voice/reference")
|
||||
def voice_clear_reference() -> dict:
|
||||
try:
|
||||
r = httpx.delete(f"{VOICE_SERVICE_URL}/reference", timeout=httpx.Timeout(8.0))
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except httpx.HTTPError as exc:
|
||||
raise HTTPException(502, f"Löschen fehlgeschlagen: {exc}")
|
||||
|
||||
|
||||
@router.post("/voice/tts")
|
||||
async def voice_tts(body: TTSIn) -> Response:
|
||||
"""Text → Sprache (Proxy auf Sidecar /tts), liefert WAV-Bytes."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
with Timer("tts"):
|
||||
r = await client.post(f"{VOICE_SERVICE_URL}/tts", json=body.model_dump())
|
||||
r.raise_for_status()
|
||||
return Response(content=r.content, media_type=r.headers.get("content-type", "audio/wav"))
|
||||
except httpx.HTTPError as exc:
|
||||
raise HTTPException(502, f"TTS fehlgeschlagen: {exc}")
|
||||
|
||||
|
||||
@router.post("/voice/chat")
|
||||
async def voice_chat(body: ChatIn) -> StreamingResponse:
|
||||
"""Neue User-Äußerung → Hermes-Agent (api_server, streamend). SSE wird 1:1 durchgereicht.
|
||||
|
||||
Mit `X-Hermes-Session-Id` hält die Plattform den Verlauf — wir senden nur die neue Nachricht.
|
||||
Auth per Bearer (API_SERVER_KEY); ohne Key liefert :8642 ein 401."""
|
||||
if not HERMES_API_KEY:
|
||||
raise HTTPException(503, "HERMES_API_KEY/API_SERVER_KEY nicht gesetzt — Agent-Auth fehlt.")
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {HERMES_API_KEY}",
|
||||
"X-Hermes-Session-Id": body.session_id,
|
||||
}
|
||||
if body.session_key:
|
||||
headers["X-Hermes-Session-Key"] = body.session_key
|
||||
|
||||
async def gen():
|
||||
# Per-Turn-Trace: sammelt STT (davor, geparkt) + Vision + Hirn-TTFT + Generierung zu EINEM
|
||||
# Datensatz -> die Latenz-Ansicht zeigt den einzelnen Hänger.
|
||||
trace = TurnTrace(session_id=body.session_id, kind="voice")
|
||||
first = True
|
||||
first_content = True
|
||||
committed = False
|
||||
|
||||
def _commit() -> None:
|
||||
nonlocal committed
|
||||
if not committed:
|
||||
committed = True
|
||||
trace.commit()
|
||||
|
||||
try:
|
||||
# Bildschirm-Sicht INNERHALB des Streams (C2-Fix): so startet die SSE-Antwort sofort und
|
||||
# der Client bekommt ein Progress-Event (-> Lucy kann eine Warte-Ansage sprechen), statt
|
||||
# dass der Request bis zu 120 s "tot" hängt, während das Vision-Modell beschreibt.
|
||||
user_text = body.text
|
||||
imgs = [u for u in (body.images or []) if u]
|
||||
trace.had_images = bool(imgs)
|
||||
if imgs:
|
||||
yield b'event: hermes.vision.progress\ndata: {"note": "Bildschirm wird angeschaut"}\n\n'
|
||||
_tv = time.perf_counter()
|
||||
desc = await _describe_images(imgs, body.text)
|
||||
trace.note_vision((time.perf_counter() - _tv) * 1000.0)
|
||||
if desc:
|
||||
safe_desc = wrap_untrusted(desc, "BILDSCHIRM")
|
||||
user_text = f"[Bildschirm-Sicht — das ist gerade auf dem/den Schirm(en) zu sehen:\n{safe_desc}\n]\n\n{body.text}"
|
||||
messages = []
|
||||
if body.system:
|
||||
messages.append({"role": "system", "content": body.system})
|
||||
messages.append({"role": "user", "content": user_text})
|
||||
trace.mark_brain_start() # ab hier zählt die Hirn-Zeit (Vision ist schon abgeschlossen)
|
||||
payload = {"model": body.model or HERMES_API_MODEL, "messages": messages, "stream": True}
|
||||
# Lucys Hirn (Qwen3.6) ist ein Thinking-Modell -> für die gesprochene Assistentin Thinking AUS,
|
||||
# sonst generiert es tausende Reasoning-Token VOR der kurzen Antwort (gemessen: 11k Token, ~30s TTFB).
|
||||
# Gleiches Muster wie die fast-Spur im Gateway (gateway_proxy.py).
|
||||
if os.environ.get("MC_VOICE_NO_THINK", "1") not in ("0", "false", "False"):
|
||||
payload["chat_template_kwargs"] = {"enable_thinking": False}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(None, connect=5.0)) as client:
|
||||
async with client.stream(
|
||||
"POST", f"{HERMES_API_URL}/v1/chat/completions", json=payload, headers=headers,
|
||||
) as r:
|
||||
if r.status_code != 200:
|
||||
detail = (await r.aread()).decode("utf-8", "replace")[:500]
|
||||
trace.error = f"Hermes {r.status_code}"
|
||||
yield f"data: {{\"error\": \"Hermes {r.status_code}: {detail}\"}}\n\n".encode()
|
||||
return
|
||||
async for chunk in r.aiter_raw():
|
||||
if first: # Time-To-First-Byte des Hermes-Streams (Verbindungs-Overhead)
|
||||
trace.note_ttfb()
|
||||
first = False
|
||||
# Erster CONTENT-Delta = echte Hirn-Latenz (Agent-Overhead + Gedächtnis + LLM-TTFT) —
|
||||
# chat_ttfb misst nur den SSE-Start (~5 ms) und ist dafür blind.
|
||||
if first_content and b'"content"' in chunk:
|
||||
trace.note_first_content()
|
||||
first_content = False
|
||||
yield chunk
|
||||
except httpx.HTTPError as exc:
|
||||
trace.error = "verbindung"
|
||||
yield f"data: {{\"error\": \"Verbindung zu Hermes fehlgeschlagen: {exc}\"}}\n\n".encode()
|
||||
finally:
|
||||
_commit() # Turn immer verbuchen (auch bei Fehler/Abbruch)
|
||||
|
||||
return StreamingResponse(gen(), media_type="text/event-stream")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
# Lucys Stimme ins LAN reichen (Raphael-Umbau 04.09.2026). lucy-stimme.service (:8021, pocket-tts
|
||||
# german_24l) bindet nur Loopback; die Desktop-Lucy am PC spricht seit dem Umbau nicht mehr mit
|
||||
# einem eigenen pocket_server, sondern mit DIESEM — dieselbe Stimme wie die Telegram-Sprachnachrichten.
|
||||
# Dünner Proxy, API 1:1 (pocket_server: /health, /tts -> WAV, /tts/stream -> PCM16 + X-Sample-Rate).
|
||||
# LAN-only wie alle MC2-Endpoints.
|
||||
|
||||
class LucyTtsIn(BaseModel):
|
||||
text: str
|
||||
emo: str | None = None # Stimmungs-Profil (pocket_server EMO_PROFILES); Raphael-Lucy setzt keins
|
||||
|
||||
|
||||
@router.get("/lucy/stimme/health")
|
||||
def lucy_stimme_health() -> dict:
|
||||
"""Bereitschaft von Lucys Stimme (pocket_server /health: status ok|loading)."""
|
||||
try:
|
||||
r = httpx.get(f"{LUCY_STIMME_URL}/health", timeout=httpx.Timeout(5.0))
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as exc:
|
||||
raise HTTPException(502, f"Lucys Stimme (:8021) nicht erreichbar: {exc}")
|
||||
|
||||
|
||||
@router.post("/lucy/stimme/tts")
|
||||
async def lucy_stimme_tts(body: LucyTtsIn) -> Response:
|
||||
"""Text -> WAV (ganzer Text). Warm-up der Desktop-Lucy + Jobs, die eine Datei brauchen."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(600.0, connect=5.0)) as client:
|
||||
with Timer("lucy_tts"):
|
||||
r = await client.post(f"{LUCY_STIMME_URL}/tts", json=body.model_dump(exclude_none=True))
|
||||
r.raise_for_status()
|
||||
return Response(content=r.content, media_type=r.headers.get("content-type", "audio/wav"),
|
||||
headers={k: v for k, v in r.headers.items() if k.lower().startswith("x-")})
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise HTTPException(exc.response.status_code, f"Lucys Stimme: {exc.response.text[:200]}")
|
||||
except httpx.HTTPError as exc:
|
||||
raise HTTPException(502, f"Lucys Stimme nicht erreichbar: {exc}")
|
||||
|
||||
|
||||
@router.post("/lucy/stimme/tts/stream")
|
||||
async def lucy_stimme_tts_stream(body: LucyTtsIn) -> StreamingResponse:
|
||||
"""Text -> rohes PCM16-mono, satzweise gestreamt (Samplerate im Header X-Sample-Rate).
|
||||
Der Live-Pfad der Desktop-Lucy: erstes Audio nach dem ersten Satz. Der Upstream-Stream bleibt
|
||||
offen, solange der Client liest — bricht der Client ab (Barge-in), schließt httpx den Upstream."""
|
||||
client = httpx.AsyncClient(timeout=httpx.Timeout(None, connect=5.0))
|
||||
try:
|
||||
req = client.build_request("POST", f"{LUCY_STIMME_URL}/tts/stream", json=body.model_dump(exclude_none=True))
|
||||
upstream = await client.send(req, stream=True)
|
||||
except httpx.HTTPError as exc:
|
||||
await client.aclose()
|
||||
raise HTTPException(502, f"Lucys Stimme nicht erreichbar: {exc}")
|
||||
if upstream.status_code != 200:
|
||||
detail = (await upstream.aread()).decode("utf-8", "replace")[:200]
|
||||
await upstream.aclose(); await client.aclose()
|
||||
raise HTTPException(upstream.status_code, f"Lucys Stimme: {detail}")
|
||||
|
||||
async def gen():
|
||||
try:
|
||||
async for chunk in upstream.aiter_raw():
|
||||
yield chunk
|
||||
finally:
|
||||
await upstream.aclose()
|
||||
await client.aclose()
|
||||
|
||||
return StreamingResponse(gen(), media_type="application/octet-stream",
|
||||
headers={"X-Sample-Rate": upstream.headers.get("x-sample-rate", "24000")})
|
||||
"""
|
||||
Sprach- und Melde-Endpunkte für Lucy (Desktop-App, Telegram-Spiegel, Wächter).
|
||||
|
||||
Dünner Layer: STT und die Turn-Erkennung gehen an den Voice-Sidecar (:8650, schläft seit
|
||||
24.09.2026 bis zur Android-App), der Chat an den Hermes-`api_server` (:8642, OpenAI-kompatibel) —
|
||||
derselbe volle Agent mit Werkzeugen und Gedächtnis wie Telegram. Mit stabilem
|
||||
`X-Hermes-Session-Id` hält die Plattform den Verlauf, der Client schickt je Turn nur die neue
|
||||
Nachricht. Lucys Stimme (pocket-tts, :8021) wird ins LAN gereicht.
|
||||
|
||||
Abgebaut am 24.09.2026 (ohne Nutzer): Voice-Health, Latenz-Metriken und -Trace, Stimmenliste,
|
||||
Klon-Referenz und das alte Piper/Chatterbox-TTS.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
# Injection-Schutz (Stufe 0): guard.py liegt im mcp/-Verzeichnis. Per Pfad laden (eigene MC2-Venv).
|
||||
import sys as _sys
|
||||
|
||||
import httpx
|
||||
from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL, LLAMA_SWAP_URL, LUCY_STIMME_URL, VOICE_SERVICE_URL
|
||||
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from services import announce
|
||||
|
||||
_GUARD_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "mcp")
|
||||
if _GUARD_DIR not in _sys.path:
|
||||
_sys.path.insert(0, _GUARD_DIR)
|
||||
try:
|
||||
from guard import wrap_untrusted
|
||||
except Exception: # den Voice-Pfad nie wegen des Filters lahmlegen
|
||||
def wrap_untrusted(text: str, label: str = "") -> str:
|
||||
return text
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
# Bildschirm-Sicht: das Bild-Modell beschreibt die Screenshots; die Beschreibung geht als TEXT an
|
||||
# Hermes -> Lucy behält ihr volles Hirn und Gedächtnis. Per Env umstellbar.
|
||||
VISION_MODEL = os.environ.get("MC_VISION_MODEL", "vision")
|
||||
# Knappe Beschreibung = schnellere Generierung UND weniger Kontext für Hermes.
|
||||
VISION_MAX_TOKENS = int(os.environ.get("MC_VISION_MAX_TOKENS", "280"))
|
||||
_STT_TIMEOUT = httpx.Timeout(120.0, connect=5.0)
|
||||
|
||||
|
||||
def _sse_fehler(text: str) -> bytes:
|
||||
"""SSE-Fehlerzeile als gültiges JSON (Anführungszeichen im Text brachen früher den Lucy-Client)."""
|
||||
return f"data: {json.dumps({'error': text}, ensure_ascii=False)}\n\n".encode()
|
||||
|
||||
|
||||
async def _describe_images(image_urls: list[str], hint: str) -> str:
|
||||
"""Lässt das Bild-Modell die Screenshots (1 je Monitor) knapp beschreiben (Deutsch).
|
||||
Mehrere Bilder gehen in EINER Nachricht ans Modell. Leerer String bei Fehler."""
|
||||
multi = len(image_urls) > 1
|
||||
intro = (f"Hier sind {len(image_urls)} Screenshots (je ein Monitor). Beschreibe auf Deutsch in höchstens "
|
||||
"5 kurzen Sätzen das Wesentliche (pro Monitor: App/Fenster, wichtige Inhalte, sichtbarer Text/Code). "
|
||||
"Keine Einleitung, keine Wiederholung der Frage. "
|
||||
if multi else
|
||||
"Beschreibe auf Deutsch in höchstens 5 kurzen Sätzen das Wesentliche auf diesem Screenshot "
|
||||
"(App/Fenster, wichtige Inhalte, sichtbarer Text/Code). Keine Einleitung. ")
|
||||
content: list = [{"type": "text", "text": intro + "Frage des Nutzers dazu: " + hint}]
|
||||
for u in image_urls:
|
||||
content.append({"type": "image_url", "image_url": {"url": u}})
|
||||
try:
|
||||
# 45 s: Wenn das Bild-Modell so lange braucht, ist etwas kaputt, und Lucy soll lieber ohne
|
||||
# Bildschirm-Kontext antworten als ewig hängen.
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(float(os.environ.get("MC_VISION_TIMEOUT", "45")), connect=5.0)) as client:
|
||||
r = await client.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json={
|
||||
"model": VISION_MODEL, "max_tokens": VISION_MAX_TOKENS, "stream": False,
|
||||
"messages": [{"role": "user", "content": content}],
|
||||
})
|
||||
r.raise_for_status()
|
||||
return (r.json().get("choices") or [{}])[0].get("message", {}).get("content", "").strip()
|
||||
except Exception as exc:
|
||||
log.warning("Vision-Beschreibung fehlgeschlagen: %s", exc)
|
||||
return ""
|
||||
|
||||
|
||||
class ChatIn(BaseModel):
|
||||
text: str # die neue User-Äußerung (STT-Ergebnis)
|
||||
session_id: str # stabiler Gesprächsfaden → server-seitiger Verlauf
|
||||
session_key: str = "" # optional an Hermes durchgereicht (X-Hermes-Session-Key)
|
||||
system: str = "" # optionaler ephemerer System-Prompt (z.B. „antworte knapp/gesprochen")
|
||||
model: str = ""
|
||||
images: list[str] = [] # optionale Bildschirm-Sicht: ein data:-URL je Monitor (Lucys „Augen")
|
||||
|
||||
|
||||
class AnnounceIn(BaseModel):
|
||||
text: str # die Meldung (wird von Lucy gesprochen)
|
||||
subject: str = "" # kurze Betreffzeile (z.B. "[Update]")
|
||||
source: str = "" # Absender (sentry/notify/cron …) — nur fürs Log/Panel
|
||||
priority: str = "normal" # 'silent' = nur im Verlauf zeigen, nicht sprechen
|
||||
|
||||
|
||||
class AlarmIn(BaseModel):
|
||||
text: str # die Alarm-Meldung
|
||||
subject: str = "[Alarm]" # Betreff (Telegram-Präfix)
|
||||
source: str = "alarm" # Absender fürs Log/Panel (z.B. "lucy-watchdog")
|
||||
|
||||
|
||||
@router.post("/alarm")
|
||||
def alarm(body: AlarmIn) -> dict:
|
||||
"""Lucy-UNABHÄNGIGER Alarm-Weg: schickt direkt auf Telegram (und legt die Meldung in den
|
||||
Briefkasten). Für Absender, die NICHT auf die sprechende Lucy zählen können — allen voran
|
||||
der PC-seitige Lucy-Watchdog, wenn die Desktop-App selbst hängt. Der Betreff „[Alarm]“ geht
|
||||
auch nachts sofort raus (notify.sh)."""
|
||||
text = (body.text or "").strip()
|
||||
if not text:
|
||||
raise HTTPException(400, "Leere Meldung.")
|
||||
subject = (body.subject or "[Alarm]").strip()
|
||||
try:
|
||||
item = announce.add(text, subject, body.source or "alarm", "normal")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc))
|
||||
announce.notify_telegram(subject, text) # best-effort Telegram (posix/bash; Windows = No-op)
|
||||
return {"ok": True, "item": item}
|
||||
|
||||
|
||||
@router.post("/voice/announce")
|
||||
def voice_announce(body: AnnounceIn) -> dict:
|
||||
"""Meldung in den Briefkasten legen (Lucy-Proaktivität). Absender: Wächter, notify.sh
|
||||
(Updates/Radar/Telegram-Spiegel), Hermes-Cron."""
|
||||
try:
|
||||
return {"ok": True, "item": announce.add(body.text, body.subject, body.source, body.priority)}
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc))
|
||||
|
||||
|
||||
@router.get("/voice/announcements")
|
||||
def voice_announcements(after: int | None = None, limit: int = 20) -> dict:
|
||||
"""Neue Meldungen nach Cursor `after` abholen (Lucy pollt). Ohne `after` nur den
|
||||
aktuellen Cursor-Stand (latest) — Erststart plappert so keine alten Meldungen nach."""
|
||||
return announce.list_after(after, limit)
|
||||
|
||||
|
||||
@router.post("/voice/stt")
|
||||
async def voice_stt(audio: UploadFile = File(...), language: str = Form(default="")) -> dict:
|
||||
"""Mikro-Audio → Text (Proxy auf Sidecar /stt)."""
|
||||
data = await audio.read()
|
||||
if not data:
|
||||
raise HTTPException(400, "Leeres Audio.")
|
||||
files = {"audio": (audio.filename or "rec.webm", data, audio.content_type or "audio/webm")}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_STT_TIMEOUT) as client:
|
||||
r = await client.post(f"{VOICE_SERVICE_URL}/stt", files=files, data={"language": language})
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except httpx.HTTPError as exc:
|
||||
raise HTTPException(502, f"STT fehlgeschlagen: {exc}")
|
||||
|
||||
|
||||
@router.post("/voice/turn")
|
||||
async def voice_turn(audio: UploadFile = File(...)) -> dict:
|
||||
"""Semantische Turn-Detection (Smart Turn v3): war die Äußerung fertig? Proxy → Sidecar."""
|
||||
data = await audio.read()
|
||||
if not data:
|
||||
raise HTTPException(400, "Leeres Audio.")
|
||||
files = {"audio": (audio.filename or "rec.wav", data, audio.content_type or "audio/wav")}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=3.0)) as client:
|
||||
r = await client.post(f"{VOICE_SERVICE_URL}/turn", files=files)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except httpx.HTTPError as exc:
|
||||
# Turn-Check ist eine Optimierung — bei Ausfall lieber sofort antworten als hängen.
|
||||
log.warning("Turn-Check fehlgeschlagen: %s", exc)
|
||||
return {"complete": True, "probability": 1.0, "engine": "fallback"}
|
||||
|
||||
|
||||
@router.post("/voice/chat")
|
||||
async def voice_chat(body: ChatIn) -> StreamingResponse:
|
||||
"""Neue User-Äußerung → Hermes-Agent (api_server, streamend). SSE wird 1:1 durchgereicht.
|
||||
|
||||
Mit `X-Hermes-Session-Id` hält die Plattform den Verlauf — wir senden nur die neue Nachricht.
|
||||
Auth per Bearer (API_SERVER_KEY); ohne Key liefert :8642 ein 401."""
|
||||
if not HERMES_API_KEY:
|
||||
raise HTTPException(503, "HERMES_API_KEY/API_SERVER_KEY nicht gesetzt — Agent-Auth fehlt.")
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {HERMES_API_KEY}",
|
||||
"X-Hermes-Session-Id": body.session_id,
|
||||
}
|
||||
if body.session_key:
|
||||
headers["X-Hermes-Session-Key"] = body.session_key
|
||||
|
||||
async def gen():
|
||||
# Bildschirm-Sicht INNERHALB des Streams: so startet die SSE-Antwort sofort und der Client
|
||||
# bekommt ein Progress-Event (-> Lucy kann eine Warte-Ansage sprechen), statt dass der
|
||||
# Request hängt, während das Bild-Modell beschreibt.
|
||||
user_text = body.text
|
||||
imgs = [u for u in (body.images or []) if u]
|
||||
if imgs:
|
||||
yield b'event: hermes.vision.progress\ndata: {"note": "Bildschirm wird angeschaut"}\n\n'
|
||||
desc = await _describe_images(imgs, body.text)
|
||||
if desc:
|
||||
safe_desc = wrap_untrusted(desc, "BILDSCHIRM")
|
||||
user_text = f"[Bildschirm-Sicht — das ist gerade auf dem/den Schirm(en) zu sehen:\n{safe_desc}\n]\n\n{body.text}"
|
||||
messages = []
|
||||
if body.system:
|
||||
messages.append({"role": "system", "content": body.system})
|
||||
messages.append({"role": "user", "content": user_text})
|
||||
payload = {"model": body.model or HERMES_API_MODEL, "messages": messages, "stream": True}
|
||||
# Lucys Hirn ist ein Thinking-Modell -> für die gesprochene Assistentin Thinking AUS, sonst
|
||||
# generiert es tausende Reasoning-Token VOR der kurzen Antwort (gemessen: 11k Token, ~30 s).
|
||||
if os.environ.get("MC_VOICE_NO_THINK", "1") not in ("0", "false", "False"):
|
||||
payload["chat_template_kwargs"] = {"enable_thinking": False}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(None, connect=5.0)) as client:
|
||||
async with client.stream(
|
||||
"POST", f"{HERMES_API_URL}/v1/chat/completions", json=payload, headers=headers,
|
||||
) as r:
|
||||
if r.status_code != 200:
|
||||
detail = (await r.aread()).decode("utf-8", "replace")[:500]
|
||||
yield _sse_fehler(f"Hermes {r.status_code}: {detail}")
|
||||
return
|
||||
async for chunk in r.aiter_raw():
|
||||
yield chunk
|
||||
except httpx.HTTPError as exc:
|
||||
yield _sse_fehler(f"Verbindung zu Hermes fehlgeschlagen: {exc}")
|
||||
|
||||
return StreamingResponse(gen(), media_type="text/event-stream")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
# Lucys Stimme ins LAN reichen (Raphael-Umbau 04.09.2026). lucy-stimme.service (:8021, pocket-tts
|
||||
# german_24l) bindet nur Loopback; die Desktop-Lucy am PC spricht mit DIESEM — dieselbe Stimme wie
|
||||
# die Telegram-Sprachnachrichten. Dünner Proxy, API 1:1 (pocket_server: /health, /tts -> WAV,
|
||||
# /tts/stream -> PCM16 + X-Sample-Rate).
|
||||
|
||||
class LucyTtsIn(BaseModel):
|
||||
text: str
|
||||
emo: str | None = None # Stimmungs-Profil (pocket_server EMO_PROFILES); Raphael-Lucy setzt keins
|
||||
|
||||
|
||||
@router.get("/lucy/stimme/health")
|
||||
def lucy_stimme_health() -> dict:
|
||||
"""Bereitschaft von Lucys Stimme (pocket_server /health: status ok|loading)."""
|
||||
try:
|
||||
r = httpx.get(f"{LUCY_STIMME_URL}/health", timeout=httpx.Timeout(5.0))
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as exc:
|
||||
raise HTTPException(502, f"Lucys Stimme (:8021) nicht erreichbar: {exc}")
|
||||
|
||||
|
||||
@router.post("/lucy/stimme/tts")
|
||||
async def lucy_stimme_tts(body: LucyTtsIn) -> Response:
|
||||
"""Text -> WAV (ganzer Text). Warm-up der Desktop-Lucy + Jobs, die eine Datei brauchen."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(600.0, connect=5.0)) as client:
|
||||
r = await client.post(f"{LUCY_STIMME_URL}/tts", json=body.model_dump(exclude_none=True))
|
||||
r.raise_for_status()
|
||||
return Response(content=r.content, media_type=r.headers.get("content-type", "audio/wav"),
|
||||
headers={k: v for k, v in r.headers.items() if k.lower().startswith("x-")})
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise HTTPException(exc.response.status_code, f"Lucys Stimme: {exc.response.text[:200]}")
|
||||
except httpx.HTTPError as exc:
|
||||
raise HTTPException(502, f"Lucys Stimme nicht erreichbar: {exc}")
|
||||
|
||||
|
||||
@router.post("/lucy/stimme/tts/stream")
|
||||
async def lucy_stimme_tts_stream(body: LucyTtsIn) -> StreamingResponse:
|
||||
"""Text -> rohes PCM16-mono, satzweise gestreamt (Samplerate im Header X-Sample-Rate).
|
||||
Der Live-Pfad der Desktop-Lucy: erstes Audio nach dem ersten Satz. Der Upstream-Stream bleibt
|
||||
offen, solange der Client liest — bricht der Client ab (Barge-in), schließt httpx den Upstream."""
|
||||
client = httpx.AsyncClient(timeout=httpx.Timeout(None, connect=5.0))
|
||||
try:
|
||||
req = client.build_request("POST", f"{LUCY_STIMME_URL}/tts/stream", json=body.model_dump(exclude_none=True))
|
||||
upstream = await client.send(req, stream=True)
|
||||
except httpx.HTTPError as exc:
|
||||
await client.aclose()
|
||||
raise HTTPException(502, f"Lucys Stimme nicht erreichbar: {exc}")
|
||||
if upstream.status_code != 200:
|
||||
detail = (await upstream.aread()).decode("utf-8", "replace")[:200]
|
||||
await upstream.aclose(); await client.aclose()
|
||||
raise HTTPException(upstream.status_code, f"Lucys Stimme: {detail}")
|
||||
|
||||
async def gen():
|
||||
try:
|
||||
async for chunk in upstream.aiter_raw():
|
||||
yield chunk
|
||||
finally:
|
||||
await upstream.aclose()
|
||||
await client.aclose()
|
||||
|
||||
return StreamingResponse(gen(), media_type="application/octet-stream",
|
||||
headers={"X-Sample-Rate": upstream.headers.get("x-sample-rate", "24000")})
|
||||
|
||||
@@ -31,17 +31,6 @@ def list_snapshots() -> dict:
|
||||
return {"available": os.name == "posix", "backups": backup_svc.list_backups()}
|
||||
|
||||
|
||||
@router.get("/zeitmaschine/inhalt")
|
||||
def snapshot_contents(file: str) -> dict:
|
||||
"""Top-Level-Komponenten eines Snapshots (hermes, llama-swap, MANIFEST …)."""
|
||||
if not _FILE_RX.match(file):
|
||||
raise HTTPException(400, "Ungültiger Snapshot-Name.")
|
||||
p = backup_svc.BACKUP_DIR / file
|
||||
if not p.is_file():
|
||||
raise HTTPException(404, "Snapshot nicht gefunden.")
|
||||
return {"file": file, "components": backup_svc.snapshot_components(p)}
|
||||
|
||||
|
||||
@router.post("/zeitmaschine/restore")
|
||||
def restore(body: RestoreIn) -> dict:
|
||||
"""Wiederherstellung starten (detached). restore.sh macht VORHER selbst ein
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Parse systemd timeout errors for mission-control-2.service from journalctl.
|
||||
|
||||
Captures lines containing "State 'stop-sigterm' timed out" and appends them
|
||||
to ~/logs/mc2-timeout.log with ISO timestamps.
|
||||
|
||||
Permission errors are handled gracefully.
|
||||
|
||||
Uses user journal (systemctl --user) to query logs.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
LOG_DIR = Path.home() / "logs"
|
||||
LOG_FILE = LOG_DIR / "mc2-timeout.log"
|
||||
SERVICE_NAME = "mission-control-2.service"
|
||||
SEARCH_PATTERN = "State 'stop-sigterm' timed out"
|
||||
JOURNALCTL_LIMIT = 1000
|
||||
|
||||
|
||||
def ensure_log_dir() -> None:
|
||||
"""Create log directory if it doesn't exist."""
|
||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def get_timeout_entries() -> list[str]:
|
||||
"""Run journalctl --user and return lines matching the timeout pattern."""
|
||||
cmd = [
|
||||
"journalctl",
|
||||
"--user",
|
||||
"-u", SERVICE_NAME,
|
||||
"--no-pager",
|
||||
"-n", str(JOURNALCTL_LIMIT),
|
||||
]
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
print("journalctl timed out")
|
||||
return []
|
||||
except PermissionError:
|
||||
print("Permission denied: journalctl requires access to user logs")
|
||||
return []
|
||||
|
||||
if result.returncode != 0:
|
||||
if "Permission denied" in result.stderr:
|
||||
print("Permission denied: journalctl requires access to user logs")
|
||||
else:
|
||||
print(f"journalctl failed: {result.stderr.strip()}")
|
||||
return []
|
||||
|
||||
return [
|
||||
line
|
||||
for line in result.stdout.splitlines()
|
||||
if SEARCH_PATTERN in line
|
||||
]
|
||||
|
||||
|
||||
def write_to_log(entries: list[str]) -> int:
|
||||
"""Append entries to log file with ISO timestamps. Returns count written."""
|
||||
ensure_log_dir()
|
||||
|
||||
timestamp = datetime.now(timezone.utc).isoformat()
|
||||
written = 0
|
||||
|
||||
with LOG_FILE.open("a", encoding="utf-8") as f:
|
||||
for entry in entries:
|
||||
f.write(f"[{timestamp}] {entry}\n")
|
||||
written += 1
|
||||
|
||||
return written
|
||||
|
||||
|
||||
def main() -> None:
|
||||
entries = get_timeout_entries()
|
||||
if not entries:
|
||||
print("No timeout entries found.")
|
||||
return
|
||||
|
||||
count = write_to_log(entries)
|
||||
print(f"Appended {count} timeout entry/ies to {LOG_FILE}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+47
-99
@@ -6,31 +6,18 @@ Hermes' eigener Config verdrahtet (siehe docs/HERMES_SETUP.md).
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
import httpx
|
||||
from config import (
|
||||
BOX_CONSOLE_PATH,
|
||||
BOX_CONSOLE_UPSTREAM,
|
||||
HERMES_API_URL,
|
||||
HERMES_BUILTIN_UI_PATH,
|
||||
HERMES_BUILTIN_UI_UPSTREAM,
|
||||
HERMES_HOME,
|
||||
PC_EXECUTOR_URL,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _hermes_version(name: str) -> float | None:
|
||||
"""Versionszahl aus 'Hermes-4.3', 'Hermes-4', 'Nous-Hermes-2' → 4.3/4.0/2.0."""
|
||||
low = (name or "").lower()
|
||||
if "hermes" not in low:
|
||||
return None
|
||||
m = re.search(r"hermes[-_ ]?(\d+(?:\.\d+)?)", low)
|
||||
return float(m.group(1)) if m else None
|
||||
|
||||
|
||||
def _active_brain_name() -> str:
|
||||
"""Aktives Agent-Hirn aus Hermes' Config: model.default (sonst model.model)."""
|
||||
try:
|
||||
@@ -106,67 +93,18 @@ def _reach(url: str, path: str = "") -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _count_enabled_mcp_servers() -> int:
|
||||
config_path = HERMES_HOME / "config.yaml"
|
||||
if not config_path.exists():
|
||||
return 0
|
||||
try:
|
||||
from ruamel.yaml import YAML
|
||||
r_yaml = YAML()
|
||||
with config_path.open("r", encoding="utf-8") as f:
|
||||
cfg = r_yaml.load(f) or {}
|
||||
mcp_servers = cfg.get("mcp_servers", {}) if isinstance(cfg, dict) else {}
|
||||
if not isinstance(mcp_servers, dict):
|
||||
return 0
|
||||
return sum(1 for v in mcp_servers.values() if isinstance(v, dict) and v.get("enabled", True))
|
||||
except Exception:
|
||||
log.debug("_count_enabled_mcp_servers: Fehler", exc_info=True)
|
||||
return 0
|
||||
|
||||
|
||||
def agent_status() -> dict:
|
||||
"""Erreichbarkeit von Gateway (:8642) + WebUI (:8787) + lokale Hinweise."""
|
||||
home = HERMES_HOME
|
||||
brain_model = "auto"
|
||||
config_path = home / "config.yaml"
|
||||
if config_path.exists():
|
||||
try:
|
||||
from ruamel.yaml import YAML
|
||||
r_yaml = YAML()
|
||||
with config_path.open("r", encoding="utf-8") as f:
|
||||
cfg = r_yaml.load(f) or {}
|
||||
if isinstance(cfg, dict):
|
||||
# Hermes nutzt model.default als aktives Modell (model.model = Provider-Param).
|
||||
m = cfg.get("model", {}) or {}
|
||||
brain_model = m.get("default") or m.get("model") or "auto"
|
||||
except Exception:
|
||||
log.debug("agent_status: Hermes-config.yaml nicht lesbar", exc_info=True)
|
||||
|
||||
"""Erreichbarkeit des Hermes-Gateways (:8642) und des Hermes-Dashboards — für die Dienste-Liste.
|
||||
|
||||
Bis 24.09.2026 fragte die Funktion bei jedem Aufruf auch die abgebaute Konsole und den
|
||||
PC-Ausführer ab (3 s Zeitlimit) und las die Hermes-Config, obwohl nur diese Felder gebraucht
|
||||
werden. Seit der PC-Ausführer schläft, hätte das jede Dienste-Abfrage um 3 s verzögert."""
|
||||
return {
|
||||
"gateway_url": HERMES_API_URL,
|
||||
# Box-Konsole (ttyd → Login-Shell): same-origin über MC2 geproxyt (/console/).
|
||||
"box_console_url": BOX_CONSOLE_PATH,
|
||||
# Eingebaute Hermes-Web-GUI (hermes serve): same-origin über MC2 geproxyt (/hermes-ui/).
|
||||
# Seit dem Umzug auf Hermes Desktop ist :9119 zugleich das Desktop-Gateway.
|
||||
"hermes_ui_url": HERMES_BUILTIN_UI_PATH,
|
||||
"gateway_reachable": _reach(HERMES_API_URL, "/health"),
|
||||
# Erreichbarkeit des lokalen ttyd-Upstreams (Loopback, base-path /console).
|
||||
"box_console_reachable": _reach(BOX_CONSOLE_UPSTREAM, "/console/"),
|
||||
# Erreichbarkeit der eingebauten Hermes-GUI / des Desktop-Gateways (Loopback :9119).
|
||||
"hermes_ui_reachable": _reach(HERMES_BUILTIN_UI_UPSTREAM, "/"),
|
||||
"home_exists": home.exists(),
|
||||
"brain_model": brain_model,
|
||||
# Best-effort: welche Verdrahtung lokal sichtbar ist (auf der Box aussagekräftig).
|
||||
"has_config": (home / "config.yaml").exists() or (home / "config.json").exists(),
|
||||
"has_skills": (home / "skills").exists(),
|
||||
"has_memories": (home / "memories").exists(),
|
||||
# Neue Felder: Telegram, MCP-Server-Anzahl, PC-Executor-Erreichbarkeit.
|
||||
"telegram_enabled": bool(os.environ.get("TELEGRAM_BOT_TOKEN", "")),
|
||||
"mcp_server_count": _count_enabled_mcp_servers(),
|
||||
"pc_executor_reachable": _reach(PC_EXECUTOR_URL, "/health"),
|
||||
# Konfigurierte Adresse mitliefern, damit die UI sie ANZEIGT statt hartcodiert (Review 15.07.).
|
||||
"pc_executor_url": PC_EXECUTOR_URL,
|
||||
}
|
||||
|
||||
|
||||
@@ -220,49 +158,59 @@ def set_agent_brain(model_id: str) -> dict:
|
||||
|
||||
|
||||
def update_brain_model(new_model: str) -> bool:
|
||||
"""Setzt Lucys Hirn in Hermes' config.yaml (model.default + model.model) und startet den
|
||||
Hermes-Gateway neu.
|
||||
|
||||
Seit 24.09.2026 vorsichtig: Ist die Datei nicht lesbar (halb geschrieben, Syntaxfehler), wird
|
||||
NICHTS geschrieben — früher entstand dann eine neue Datei nur mit dem model-Block, und der Rest
|
||||
von Hermes' Konfiguration wäre weg gewesen. Geschrieben wird atomar (tmp + os.replace), vorher
|
||||
liegt eine Sicherung config.yaml.bak-hirn-<Zeitstempel> daneben."""
|
||||
import shutil
|
||||
import time as _time
|
||||
|
||||
from config import HERMES_HOME
|
||||
home = HERMES_HOME
|
||||
config_path = home / "config.yaml"
|
||||
|
||||
# Ensure home directory exists
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
cfg = {}
|
||||
if config_path.exists():
|
||||
try:
|
||||
from ruamel.yaml import YAML
|
||||
r_yaml = YAML()
|
||||
with config_path.open("r", encoding="utf-8") as f:
|
||||
cfg = r_yaml.load(f) or {}
|
||||
except Exception:
|
||||
log.debug("update_brain_model: bestehende config.yaml nicht lesbar", exc_info=True)
|
||||
cfg = {}
|
||||
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
config_path = HERMES_HOME / "config.yaml"
|
||||
if not config_path.exists():
|
||||
log.warning("update_brain_model: %s fehlt — Hirn wird nicht umgestellt", config_path)
|
||||
return False
|
||||
r_yaml = YAML()
|
||||
r_yaml.preserve_quotes = True
|
||||
r_yaml.width = 100
|
||||
r_yaml.indent(mapping=2, sequence=4, offset=2)
|
||||
try:
|
||||
with config_path.open("r", encoding="utf-8") as f:
|
||||
cfg = r_yaml.load(f)
|
||||
except Exception:
|
||||
log.warning("update_brain_model: config.yaml nicht lesbar — nichts geschrieben", exc_info=True)
|
||||
return False
|
||||
if not isinstance(cfg, dict):
|
||||
cfg = {}
|
||||
|
||||
log.warning("update_brain_model: config.yaml hat unerwarteten Inhalt — nichts geschrieben")
|
||||
return False
|
||||
|
||||
if "model" not in cfg or not isinstance(cfg["model"], dict):
|
||||
cfg["model"] = {}
|
||||
|
||||
# Hermes liest model.default als aktives Modell; model.model ist der Provider-Param.
|
||||
# Beide setzen, sonst greift die Umschaltung nicht (latenter Bug: nur model.model gesetzt).
|
||||
# Beide setzen, sonst greift die Umschaltung nicht.
|
||||
cfg["model"]["default"] = new_model
|
||||
cfg["model"]["model"] = new_model
|
||||
|
||||
tmp = config_path.with_suffix(".yaml.neu")
|
||||
try:
|
||||
from ruamel.yaml import YAML
|
||||
r_yaml = YAML()
|
||||
with config_path.open("w", encoding="utf-8") as f:
|
||||
shutil.copy2(config_path, config_path.with_name(f"config.yaml.bak-hirn-{_time.strftime('%Y%m%d-%H%M%S')}"))
|
||||
with tmp.open("w", encoding="utf-8") as f:
|
||||
r_yaml.dump(cfg, f)
|
||||
|
||||
# Restart the user-space service to apply changes
|
||||
try:
|
||||
from services import maintenance
|
||||
maintenance.restart_service("hermes-gateway")
|
||||
except Exception:
|
||||
log.warning("update_brain_model: hermes-gateway-Restart fehlgeschlagen", exc_info=True)
|
||||
|
||||
return True
|
||||
YAML(typ="safe").load(tmp.read_text(encoding="utf-8")) # muss wieder lesbar sein
|
||||
os.replace(tmp, config_path)
|
||||
except Exception:
|
||||
log.warning("update_brain_model: Schreiben der config.yaml fehlgeschlagen", exc_info=True)
|
||||
tmp.unlink(missing_ok=True)
|
||||
return False
|
||||
|
||||
try:
|
||||
from services import maintenance
|
||||
maintenance.restart_service("hermes-gateway")
|
||||
except Exception:
|
||||
log.warning("update_brain_model: hermes-gateway-Restart fehlgeschlagen", exc_info=True)
|
||||
return True
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
"""Werkzeug-Verlauf des Agenten (v3-Umbau P6).
|
||||
|
||||
WARUM ES DAS GIBT: Lucys Arbeit war eine Blackbox mit Statuswort. Der Blueprint sah
|
||||
dafür eine „Live Agent Matrix" mit Denkstrom vor (§4.4) — die ist so nicht baubar: Die
|
||||
Denkschritte entstehen im Hermes-Prozess, und `AGENTS.md` verbietet es, dessen Quellcode
|
||||
zu patchen.
|
||||
|
||||
Beim Nachsehen zeigte sich aber, dass die HÄLFTE davon längst offen daliegt: Hermes
|
||||
protokolliert jeden Werkzeug-Ruf nach `~/.hermes/logs/agent.log`. Eine Log-Datei zu lesen
|
||||
ist kein Patchen. Was dadurch sichtbar wird — welches Werkzeug, wie lange, mit welchem
|
||||
Ergebnis, in welchem Lauf — ist für die Frage „was tut sie gerade und wo hängt es" oft
|
||||
nützlicher als der Fließtext ihrer Gedanken.
|
||||
|
||||
WAS NICHT GEHT (und hier auch nicht so tut): der Denkstrom selbst und die Argumente eines
|
||||
Werkzeug-Rufs. Beides steht nicht im Log. Die Ansicht verspricht deshalb nur, was sie
|
||||
halten kann.
|
||||
|
||||
ES HAT SICH SOFORT GELOHNT: Beim ersten Lesen fiel auf, dass `web_extract` seit
|
||||
mindestens dem 25.08. JEDEN Morgen um 07:00 scheitert (der Suchanbieter kann keine
|
||||
Seiten abrufen). Vier Tage lang, ohne dass es irgendwo aufgefallen wäre.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Die Box läuft in Europe/Berlin (AGENTS.md). Hermes' Log trägt Ortszeit ohne Offset.
|
||||
LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin"))
|
||||
|
||||
LOG_PFAD = Path(os.path.expanduser(
|
||||
os.environ.get("MC_HERMES_AGENT_LOG", "~/.hermes/logs/agent.log")))
|
||||
|
||||
# Nur das Ende der Datei lesen. Sie wächst auf mehrere MB und wird rotiert; für einen
|
||||
# Verlauf der letzten Stunden reicht der Schwanz — und er kostet nichts.
|
||||
LESE_BYTES = 512 * 1024
|
||||
|
||||
# Die drei Formen, in denen Hermes einen Werkzeug-Ruf notiert (am Log gemessen, nicht
|
||||
# geraten). Die Lauf-Kennung in eckigen Klammern fehlt bei Nicht-Cron-Läufen.
|
||||
#
|
||||
# INFO [cron_…] agent.tool_executor: tool terminal completed (1.40s, 53 chars)
|
||||
# INFO agent.tool_executor: tool web_search completed (2.61s, 2944 chars)
|
||||
# WARNING [cron_…] agent.tool_executor: Tool web_extract returned error (0.17s): {…}
|
||||
# INFO agent.tool_executor: tool web_extract failed (0.17s): {…}
|
||||
_ZEIT = r"(?P<zeit>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}),\d+"
|
||||
_LAUF = r"(?:\[(?P<lauf>[^\]]+)\] )?"
|
||||
|
||||
_FERTIG = re.compile(
|
||||
_ZEIT + r" \w+ " + _LAUF + r"agent\.tool_executor: [Tt]ool (?P<werkzeug>\S+) completed "
|
||||
r"\((?P<dauer>[\d.]+)s, (?P<zeichen>\d+) chars\)")
|
||||
|
||||
_FEHLER = re.compile(
|
||||
_ZEIT + r" \w+ " + _LAUF + r"agent\.tool_executor: [Tt]ool (?P<werkzeug>\S+) "
|
||||
r"(?:failed|returned error) \((?P<dauer>[\d.]+)s\)(?::\s*(?P<detail>.*))?")
|
||||
|
||||
|
||||
def _schwanz(pfad: Path, n: int) -> list[str]:
|
||||
"""Die letzten n Bytes als Zeilen. Die erste Zeile kann angeschnitten sein und
|
||||
wird verworfen — ein halber Zeitstempel passt auf kein Muster, aber sicher ist besser."""
|
||||
try:
|
||||
groesse = pfad.stat().st_size
|
||||
with pfad.open("rb") as f:
|
||||
f.seek(max(0, groesse - n))
|
||||
roh = f.read()
|
||||
zeilen = roh.decode("utf-8", errors="replace").splitlines()
|
||||
return zeilen[1:] if groesse > n and zeilen else zeilen
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
def _kurz(detail: str | None) -> str | None:
|
||||
"""Fehlertext des Werkzeugs auf einen lesbaren Satz eindampfen. Hermes legt dort ein
|
||||
JSON ab; interessant ist daran nur das `error`-Feld."""
|
||||
if not detail:
|
||||
return None
|
||||
treffer = re.search(r'"error"\s*:\s*"([^"]{1,300})"', detail)
|
||||
text = treffer.group(1) if treffer else detail.strip()
|
||||
return (text[:297] + "…") if len(text) > 300 else text
|
||||
|
||||
|
||||
def rufe(limit: int = 60) -> list[dict]:
|
||||
"""Die jüngsten Werkzeug-Rufe, ältester zuerst."""
|
||||
ergebnis: list[dict] = []
|
||||
for zeile in _schwanz(LOG_PFAD, LESE_BYTES):
|
||||
m = _FERTIG.match(zeile)
|
||||
if m:
|
||||
ergebnis.append({
|
||||
"zeit": _iso(m.group("zeit")),
|
||||
"lauf": m.group("lauf"),
|
||||
"werkzeug": m.group("werkzeug"),
|
||||
"dauer_s": float(m.group("dauer")),
|
||||
"zeichen": int(m.group("zeichen")),
|
||||
"ok": True,
|
||||
"fehler": None,
|
||||
})
|
||||
continue
|
||||
m = _FEHLER.match(zeile)
|
||||
if m:
|
||||
ergebnis.append({
|
||||
"zeit": _iso(m.group("zeit")),
|
||||
"lauf": m.group("lauf"),
|
||||
"werkzeug": m.group("werkzeug"),
|
||||
"dauer_s": float(m.group("dauer")),
|
||||
"zeichen": None,
|
||||
"ok": False,
|
||||
"fehler": _kurz(m.group("detail")),
|
||||
})
|
||||
return ergebnis[-limit:]
|
||||
|
||||
|
||||
def _iso(s: str) -> str:
|
||||
"""`2026-08-28 07:03:18` → ISO MIT Zeitzone.
|
||||
|
||||
Hermes schreibt seine Log-Zeitstempel in Ortszeit ohne Offset. Die naiv zu lassen
|
||||
wäre bequem (der Klient zeigt sie nur an) — aber genau daran hängt eine
|
||||
Projektregel: Naive Zeiten werden über MC_LOCAL_TZ aufgelöst, nie geraten
|
||||
(AGENTS.md; ruff DTZ007 erzwingt es). Sobald jemand später damit rechnet — Dauer
|
||||
über Mitternacht, Vergleich mit einem Cron-Plan — wäre eine offsetlose Zeit eine
|
||||
Falle, die erst zur Zeitumstellung zuschnappt."""
|
||||
try:
|
||||
return datetime.strptime(s, "%Y-%m-%d %H:%M:%S").replace(tzinfo=LOCAL_TZ).isoformat()
|
||||
except ValueError:
|
||||
return s
|
||||
|
||||
|
||||
def uebersicht(limit: int = 60) -> dict:
|
||||
"""Was die Agent-Ansicht braucht: die Rufe selbst, je Werkzeug eine Bilanz und die
|
||||
wiederkehrenden Fehler zusammengefasst.
|
||||
|
||||
Die Bilanz ist der eigentliche Nutzen: Ein Werkzeug, das IMMER scheitert, verschwindet
|
||||
in einer Zeitleiste — in einer Zeile „web_extract · 4 Rufe · 4 Fehler" nicht."""
|
||||
liste = rufe(limit)
|
||||
if not LOG_PFAD.exists():
|
||||
return {"verfuegbar": False, "pfad": str(LOG_PFAD), "rufe": [],
|
||||
"werkzeuge": [], "laeufe": []}
|
||||
|
||||
bilanz: dict[str, dict] = {}
|
||||
for r in liste:
|
||||
b = bilanz.setdefault(r["werkzeug"], {
|
||||
"werkzeug": r["werkzeug"], "rufe": 0, "fehler": 0,
|
||||
"dauer_summe": 0.0, "letzter_fehler": None,
|
||||
})
|
||||
b["rufe"] += 1
|
||||
b["dauer_summe"] += r["dauer_s"]
|
||||
if not r["ok"]:
|
||||
b["fehler"] += 1
|
||||
b["letzter_fehler"] = r["fehler"]
|
||||
|
||||
werkzeuge = sorted(
|
||||
({**b, "dauer_schnitt_s": round(b["dauer_summe"] / max(b["rufe"], 1), 2)}
|
||||
for b in bilanz.values()),
|
||||
key=lambda b: (-b["fehler"], -b["rufe"]),
|
||||
)
|
||||
|
||||
# Läufe in der Reihenfolge ihres ersten Auftretens (nicht sortiert nach Kennung —
|
||||
# die trägt zwar ein Datum, aber darauf sollte sich niemand verlassen).
|
||||
laeufe: list[dict] = []
|
||||
gesehen: dict[str, dict] = {}
|
||||
for r in liste:
|
||||
schluessel = r["lauf"] or "(interaktiv)"
|
||||
if schluessel not in gesehen:
|
||||
gesehen[schluessel] = {"lauf": schluessel, "von": r["zeit"], "bis": r["zeit"],
|
||||
"rufe": 0, "fehler": 0}
|
||||
laeufe.append(gesehen[schluessel])
|
||||
e = gesehen[schluessel]
|
||||
e["bis"] = r["zeit"]
|
||||
e["rufe"] += 1
|
||||
if not r["ok"]:
|
||||
e["fehler"] += 1
|
||||
|
||||
return {"verfuegbar": True, "pfad": str(LOG_PFAD), "rufe": liste,
|
||||
"werkzeuge": werkzeuge, "laeufe": laeufe}
|
||||
@@ -112,14 +112,6 @@ def notify_telegram(subject: str, text: str) -> None:
|
||||
log.warning("notify_telegram: notify.sh fehlgeschlagen", exc_info=True)
|
||||
|
||||
|
||||
def list_recent(limit: int = 150) -> list[dict]:
|
||||
"""Die jüngsten Einträge (neueste zuerst) — Datenquelle der Chronik: alles, was die Box
|
||||
dem Commander je aktiv gemeldet hat (Health-Wächter, Updates, Radar, Träume, Alarme)."""
|
||||
with _lock:
|
||||
state = _load()
|
||||
return list(reversed(state["items"][-max(1, min(limit, MAX_ITEMS)):]))
|
||||
|
||||
|
||||
def list_after(after: int | None, limit: int = 20) -> dict:
|
||||
"""Einträge NACH Cursor `after` (aufsteigend). Ohne Cursor nur den aktuellen
|
||||
Stand liefern (latest) — so initialisiert Lucy ihren Cursor, ohne Altes nachzuplappern."""
|
||||
|
||||
@@ -129,14 +129,15 @@ def loeschen(art: str, name: str) -> dict:
|
||||
if art == "ordner":
|
||||
frei = _ordner_loeschen(name)
|
||||
else:
|
||||
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)
|
||||
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)
|
||||
|
||||
@@ -27,11 +27,6 @@ def _latest() -> Path | None:
|
||||
return snaps[0] if snaps else None
|
||||
|
||||
|
||||
def snapshot_components(tarball: Path) -> list[str]:
|
||||
"""Öffentliche Sicht auf die Snapshot-Komponenten (Zeitmaschine-Detail in der UI)."""
|
||||
return _components(tarball)
|
||||
|
||||
|
||||
def _components(tarball: Path) -> list[str]:
|
||||
"""Top-Level-Einträge im Tarball (zur Anzeige im UI)."""
|
||||
try:
|
||||
|
||||
@@ -169,37 +169,3 @@ def setup_aware_ctx(params_b: float, quant: str, role: str | None = None) -> dic
|
||||
}
|
||||
|
||||
|
||||
def _snap_ctx(raw_ctx: float, cap: int | None = None) -> int:
|
||||
"""Größter 'schöner' Kontext ≤ raw_ctx (und ≤ Trainings-Kontext des Modells, falls bekannt)."""
|
||||
from services.fit import _NICE_CTX
|
||||
if cap:
|
||||
raw_ctx = min(raw_ctx, cap)
|
||||
best = _NICE_CTX[0]
|
||||
for c in _NICE_CTX:
|
||||
if c <= raw_ctx:
|
||||
best = c
|
||||
return best
|
||||
|
||||
|
||||
def setup_aware_ctx_for_model(model: dict) -> dict:
|
||||
"""Setup-bewusster Optimal-ctx für ein INSTALLIERTES Modell. Für den 'Auto'-Button an der
|
||||
Modellkarte. Nutzt die ECHTE KV-Größe des GGUF (gleiche Zahlensprache wie footprint_gb) —
|
||||
Fallback auf die params-Heuristik nur, wenn das GGUF nicht lesbar ist."""
|
||||
from services import gguf_meta
|
||||
quant = model.get("quant") or "Q4_K_M"
|
||||
path = model.get("gguf_path")
|
||||
meta = gguf_meta.arch_meta(path) if path else None
|
||||
if not meta:
|
||||
return setup_aware_ctx(params_of_model(model), quant, role=model.get("role"))
|
||||
|
||||
gtt = gtt_budget_gb()
|
||||
r = reserved_gb(model.get("role"))
|
||||
budget = max(gtt - r["reserved_gb"] - HEADROOM_GB, 0.0)
|
||||
bpp = QUANT_BYTES_PER_PARAM.get(quant.upper(), 0.55)
|
||||
size_gb = (model.get("size_bytes") or 0) / (1024 ** 3)
|
||||
weights = max(params_of_model(model) * bpp, size_gb)
|
||||
ck, cv = _cache_types(model.get("cmd") or "")
|
||||
per_tok = gguf_meta.kv_gb_per_token(meta, ck, cv)
|
||||
ctx = _snap_ctx((budget - weights) / per_tok, cap=meta.get("n_ctx_train")) if per_tok > 0 else 2048
|
||||
return {"ctx": ctx, "gtt_gb": gtt, "reserved_gb": round(r["reserved_gb"], 1),
|
||||
"budget_gb": round(budget, 1), "mode": r["mode"]}
|
||||
|
||||
@@ -25,8 +25,6 @@ from services.sources import CATEGORIES, SKIP_TOKENS, TRUSTED_AUTHORS
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_FIT_ORDER = {"perfect": 0, "marginal": 1, "too_tight": 2}
|
||||
|
||||
|
||||
def _categorize(repo_id: str) -> str:
|
||||
low = repo_id.lower()
|
||||
|
||||
@@ -99,8 +99,3 @@ def max_ctx_for(params_b: float, quant: str, sys_ram_gb: float) -> int:
|
||||
return max_ctx_in_budget(params_b, quant, max(sys_ram_gb - 4.0, 0) * 0.8)
|
||||
|
||||
|
||||
def recommend_ctx(params_b: float, quant: str, sys_ram_gb: float) -> dict:
|
||||
ctx = max_ctx_for(params_b, quant, sys_ram_gb)
|
||||
k = ctx // 1024
|
||||
return {"ctx": ctx, "k": k,
|
||||
"note": f"Bis ~{k}k Kontext passt komfortabel auf deine Hardware ({round(sys_ram_gb)} GB)."}
|
||||
|
||||
@@ -7,6 +7,7 @@ verstreute, still scheiternde String-Suche durch einen testbaren SSE-Zeilenparse
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
|
||||
from services.token_stats import increment_tokens
|
||||
@@ -37,12 +38,17 @@ def warn_truncation(model: str, max_tokens: int | None = None) -> None:
|
||||
return
|
||||
_trunc_last[model] = now
|
||||
log.warning("gateway: finish_reason=length bei %s — Antwort am Kontext-/Token-Limit abgerissen", model)
|
||||
# Im eigenen Thread abliefern: announce.add spricht im Gateway-Prozess per HTTP mit MC2 (bis 5 s).
|
||||
# Synchron hätte das den Event-Loop des Gateways und damit jeden LLM-Strom der Box angehalten.
|
||||
threading.Thread(target=_melde_abriss, args=(model,), daemon=True).start()
|
||||
|
||||
|
||||
def _melde_abriss(model: str) -> None:
|
||||
try:
|
||||
from services import announce
|
||||
announce.add(
|
||||
f"Eine Antwort von „{model}“ ist am Token-/Kontext-Limit abgerissen "
|
||||
f"(finish_reason=length). War das ein Nacht-Worker, ist seine Aufgabe zu groß "
|
||||
f"geschnitten — besser in Etappen teilen (eine Etappe = ein Worker-Lauf).",
|
||||
f"Eine Antwort von „{model}“ ist am Token- oder Kontext-Limit abgerissen "
|
||||
f"(finish_reason=length). Meist war die Aufgabe zu groß für einen Durchgang.",
|
||||
"[Kontext-Limit]", "gateway", "silent")
|
||||
except Exception:
|
||||
log.warning("gateway: Kontext-Limit-Warnung nicht zustellbar", exc_info=True)
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Herkunftsprüfung für Knöpfe (24.09.2026, User-Entscheid: keine Anmeldung).
|
||||
|
||||
Knöpfe schicken POST/PUT/DELETE an /api. Ein Browser setzt dabei den Origin-Header — kommt er von
|
||||
einer fremden Webseite, wird die Anfrage abgelehnt. Das verhindert, dass eine fremde Seite im
|
||||
Browser eines Heimnetz-Geräts heimlich Updates, Neustarts oder Löschen auslöst.
|
||||
|
||||
Unverändert erlaubt: Anfragen ohne Origin (Skripte, curl, Lucy-Watchdog), Origin „null“/„file://“
|
||||
(Desktop-Lucy aus Electron) und alles unter /v1 (OpenChamber, Hermes).
|
||||
"""
|
||||
|
||||
ENTWICKLUNG = {"localhost:5173", "127.0.0.1:5173", "localhost:5180", "localhost:5181"}
|
||||
SCHREIBEND = {"POST", "PUT", "PATCH", "DELETE"}
|
||||
|
||||
|
||||
def erlaubt(methode: str, pfad: str, origin: str | None, host: str | None) -> bool:
|
||||
if methode.upper() not in SCHREIBEND or not pfad.startswith("/api/"):
|
||||
return True
|
||||
if not origin or origin in ("null", "file://"):
|
||||
return True
|
||||
wirt = origin.split("://", 1)[-1].rstrip("/")
|
||||
return wirt == (host or "") or wirt in ENTWICKLUNG
|
||||
+12
-9
@@ -33,13 +33,12 @@ def list_quants(repo: str) -> list[str]:
|
||||
|
||||
def search(q: str = "", limit: int = 24) -> list[dict]:
|
||||
"""Freie HF-Suche nach GGUF-Repos. Ohne q → Top-GGUF nach Downloads (Stöbern)."""
|
||||
url = (f"https://huggingface.co/api/models?filter=gguf"
|
||||
f"&sort=downloads&direction=-1&limit={limit}")
|
||||
params: dict[str, str | int] = {"filter": "gguf", "sort": "downloads", "direction": -1, "limit": limit}
|
||||
if q and q.strip():
|
||||
url += f"&search={q.strip()}"
|
||||
params["search"] = q.strip() # von httpx kodiert (vorher roh an die URL gehängt)
|
||||
try:
|
||||
with httpx.Client(timeout=12.0) as c:
|
||||
data = c.get(url).json()
|
||||
data = c.get("https://huggingface.co/api/models", params=params).json()
|
||||
except Exception:
|
||||
return []
|
||||
out = []
|
||||
@@ -80,15 +79,19 @@ def resolve_gguf(repo: str, quant: str = "Q4_K_M") -> dict:
|
||||
# mmproj separat (Vision-Projektor)
|
||||
mmproj = next((e["path"] for e in ggufs if "mmproj" in e["path"].lower()), None)
|
||||
model = [e for e in ggufs if "mmproj" not in e["path"].lower()]
|
||||
# bevorzugt den gewünschten Quant
|
||||
pref = [e for e in model if q in e["path"].lower()]
|
||||
chosen = pref or model
|
||||
# Nur der gewünschte Quant. Bis 24.09.2026 fiel die Auswahl sonst auf ALLE Modelldateien zurück —
|
||||
# bei aufgeteilten Repos lud der Download dann alle Teile aller Varianten (hunderte GB).
|
||||
chosen = [e for e in model if q in e["path"].lower()]
|
||||
if not chosen:
|
||||
return {"files": [], "first": None, "total_bytes": 0, "mmproj": mmproj, "split": False}
|
||||
# Split? Wenn die gewählten Dateien -of- enthalten → alle Teile dieser Gruppe.
|
||||
# Split? Wenn die gewählten Dateien -of- enthalten → alle Teile EINER Gruppe (gleicher Präfix).
|
||||
split = any("-of-" in e["path"].lower() for e in chosen)
|
||||
if split:
|
||||
parts = sorted([e for e in chosen if "-of-" in e["path"].lower()], key=lambda e: e["path"])
|
||||
def _gruppe(pfad: str) -> str:
|
||||
return re.sub(r"-\d{5}-of-\d{5}\.gguf$", "", pfad, flags=re.IGNORECASE)
|
||||
erste = min(e["path"] for e in chosen if "-of-" in e["path"].lower())
|
||||
parts = sorted([e for e in chosen if "-of-" in e["path"].lower() and _gruppe(e["path"]) == _gruppe(erste)],
|
||||
key=lambda e: e["path"])
|
||||
files = [e["path"] for e in parts]
|
||||
first = files[0]
|
||||
total = sum(_size(e) for e in parts)
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
"""
|
||||
Mini-Job-System: Hintergrund-Prozesse mit Live-Log + Download-Fortschritt.
|
||||
Portiert aus Mission Control v1 (jobengine.py). In-Memory, ein Daemon-Thread je Job.
|
||||
|
||||
Seit 24.09.2026:
|
||||
- Jobs laufen in einer eigenen Prozessgruppe; „Abbrechen“ beendet die ganze Gruppe (vorher nur
|
||||
die bash-Hülle — sudo, apt und hf liefen weiter, während der Job schon „abgebrochen“ hieß).
|
||||
- Jede Job-Art hat ein Zeitlimit; ein hängendes apt hält den Wartungs-Riegel nicht mehr ewig.
|
||||
- `start_job_exklusiv` prüft und trägt unter EINER Sperre ein: zwei Klicks starten nicht mehr
|
||||
zwei Updates derselben Gruppe.
|
||||
- Beendete Jobs werden nach einem Tag bzw. ab 40 Stück aufgeräumt.
|
||||
|
||||
Noch offen (Phase 2): Jobs überleben keinen Neustart von MC2, sie gehören in einen eigenen Worker.
|
||||
"""
|
||||
|
||||
import glob
|
||||
import os
|
||||
import shlex
|
||||
import signal
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
@@ -14,6 +25,11 @@ import uuid
|
||||
JOBS: dict[str, dict] = {}
|
||||
_PROCS: dict[str, subprocess.Popen] = {}
|
||||
_LOG_CAP = 400
|
||||
_LOCK = threading.Lock()
|
||||
BEHALTEN_MAX = 40 # so viele beendete Jobs bleiben sichtbar
|
||||
BEHALTEN_S = 24 * 3600 # beendete Jobs verschwinden nach einem Tag
|
||||
STANDARD_ZEITLIMIT_S = 3 * 3600
|
||||
_ENDE = ("done", "failed", "canceled")
|
||||
|
||||
|
||||
def _append_log(job: dict, line: str) -> None:
|
||||
@@ -57,34 +73,64 @@ def _pump_output(job: dict, stream) -> None:
|
||||
commit()
|
||||
|
||||
|
||||
def _run_job(job_id: str, args: list[str], env: dict | None = None):
|
||||
def _beende_gruppe(proc: subprocess.Popen) -> None:
|
||||
"""Den Job-Prozess samt Kindern beenden (posix: ganze Prozessgruppe, sonst nur den Prozess)."""
|
||||
try:
|
||||
if os.name == "posix":
|
||||
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
||||
else:
|
||||
proc.terminate()
|
||||
except (ProcessLookupError, PermissionError, OSError):
|
||||
pass
|
||||
|
||||
|
||||
def _run_job(job_id: str, args: list[str], env: dict | None = None, zeitlimit_s: int | None = None):
|
||||
"""Job-Prozess starten und mitschreiben.
|
||||
|
||||
v3-Umbau P1 (28.08.2026): Hier wurde frueher ein Sudo-Passwort aus dem Browser an
|
||||
stdin gefuettert (und dafuer `sudo -n` in den Argumenten zu `sudo -S` umgeschrieben).
|
||||
Auf der Box laeuft sudo passwortlos (`NOPASSWD: ALL`), der Pfad war tot. Ohne ihn
|
||||
braucht der Prozess auch keine stdin-Pipe mehr: DEVNULL sorgt dafuer, dass ein Job,
|
||||
der wider Erwarten nach einem Passwort fragt, sofort scheitert statt still zu haengen."""
|
||||
v3-Umbau P1 (28.08.2026): Auf der Box läuft sudo passwortlos (`NOPASSWD: ALL`), der Job
|
||||
braucht keine stdin-Pipe. DEVNULL sorgt dafür, dass ein Job, der wider Erwarten nach einem
|
||||
Passwort fragt, sofort scheitert statt still zu hängen."""
|
||||
job = JOBS[job_id]
|
||||
job["state"] = "running"
|
||||
wecker: threading.Timer | None = None
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
list(args), stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
stdin=subprocess.DEVNULL,
|
||||
bufsize=0,
|
||||
env={**os.environ, **(env or {})},
|
||||
start_new_session=(os.name == "posix"),
|
||||
)
|
||||
_PROCS[job_id] = proc
|
||||
grenze = zeitlimit_s or STANDARD_ZEITLIMIT_S
|
||||
|
||||
def _zu_lang() -> None:
|
||||
job["zeitlimit"] = True
|
||||
_append_log(job, f"[mc] Zeitlimit ({grenze // 60} min) überschritten, Job wird beendet.")
|
||||
_beende_gruppe(proc)
|
||||
|
||||
wecker = threading.Timer(grenze, _zu_lang)
|
||||
wecker.daemon = True
|
||||
wecker.start()
|
||||
|
||||
_pump_output(job, proc.stdout)
|
||||
proc.wait()
|
||||
job["returncode"] = proc.returncode
|
||||
job["state"] = "canceled" if job.get("canceled") else ("done" if proc.returncode == 0 else "failed")
|
||||
if job.get("canceled"):
|
||||
job["state"] = "canceled"
|
||||
elif job.get("zeitlimit"):
|
||||
job["state"] = "failed"
|
||||
job["error"] = "Zeitlimit überschritten"
|
||||
else:
|
||||
job["state"] = "done" if proc.returncode == 0 else "failed"
|
||||
except Exception as exc:
|
||||
_append_log(job, f"[mc] Fehler: {exc}")
|
||||
job["state"] = "failed"
|
||||
job["error"] = str(exc)
|
||||
job["returncode"] = -1
|
||||
finally:
|
||||
if wecker is not None:
|
||||
wecker.cancel()
|
||||
_PROCS.pop(job_id, None)
|
||||
job["finished_at"] = time.time()
|
||||
cb = job.pop("_on_done", None)
|
||||
@@ -110,7 +156,7 @@ def attach_download_progress(job_id: str, local_dir: str, total_bytes: int) -> N
|
||||
rate = 0.0
|
||||
while True:
|
||||
j = JOBS.get(job_id)
|
||||
if not j or j["state"] in ("done", "failed", "canceled"):
|
||||
if not j or j["state"] in _ENDE:
|
||||
break
|
||||
try:
|
||||
inc = glob.glob(pat, recursive=True)
|
||||
@@ -137,25 +183,54 @@ def attach_download_progress(job_id: str, local_dir: str, total_bytes: int) -> N
|
||||
threading.Thread(target=_watch, daemon=True).start()
|
||||
|
||||
|
||||
def start_job(args: list[str], label: str, env: dict | None = None, on_done=None,
|
||||
group: str | None = None) -> str:
|
||||
def _aufraeumen() -> None:
|
||||
"""Alte beendete Jobs entfernen (unter _LOCK aufrufen)."""
|
||||
jetzt = time.time()
|
||||
fertig = sorted((j for j in JOBS.values() if j["state"] in _ENDE),
|
||||
key=lambda j: j.get("finished_at") or 0, reverse=True)
|
||||
for i, j in enumerate(fertig):
|
||||
if i >= BEHALTEN_MAX or jetzt - (j.get("finished_at") or jetzt) > BEHALTEN_S:
|
||||
JOBS.pop(j["id"], None)
|
||||
|
||||
|
||||
def _eintragen(args: list[str], label: str, on_done, group: str | None) -> str:
|
||||
job_id = uuid.uuid4().hex[:12]
|
||||
log_args = list(args)
|
||||
JOBS[job_id] = {
|
||||
"id": job_id, "label": label, "state": "queued", "group": group,
|
||||
"log": ["$ " + " ".join(shlex.quote(a) for a in log_args)],
|
||||
"log": ["$ " + " ".join(shlex.quote(a) for a in args)],
|
||||
"returncode": None, "started_at": time.time(), "finished_at": None,
|
||||
}
|
||||
if on_done:
|
||||
JOBS[job_id]["_on_done"] = on_done
|
||||
threading.Thread(target=_run_job, args=(job_id, args, env), daemon=True).start()
|
||||
return job_id
|
||||
|
||||
|
||||
def start_job(args: list[str], label: str, env: dict | None = None, on_done=None,
|
||||
group: str | None = None, zeitlimit_s: int | None = None) -> str:
|
||||
with _LOCK:
|
||||
_aufraeumen()
|
||||
job_id = _eintragen(list(args), label, on_done, group)
|
||||
threading.Thread(target=_run_job, args=(job_id, list(args), env, zeitlimit_s), daemon=True).start()
|
||||
return job_id
|
||||
|
||||
|
||||
def start_job_exklusiv(group: str, args: list[str], label: str, env: dict | None = None, on_done=None,
|
||||
zeitlimit_s: int | None = None) -> tuple[str | None, dict | None]:
|
||||
"""Wie start_job, aber nur, wenn in der Gruppe nichts läuft — Prüfen und Eintragen unter einer
|
||||
Sperre. Rückgabe: (neue Job-ID, None) oder (None, der schon laufende Job)."""
|
||||
with _LOCK:
|
||||
if (laeuft := active_in_group(group)) is not None:
|
||||
return None, laeuft
|
||||
_aufraeumen()
|
||||
job_id = _eintragen(list(args), label, on_done, group)
|
||||
threading.Thread(target=_run_job, args=(job_id, list(args), env, zeitlimit_s), daemon=True).start()
|
||||
return job_id, None
|
||||
|
||||
|
||||
def active_in_group(group: str) -> dict | None:
|
||||
"""Erster laufender/wartender Job einer Gruppe (z.B. 'maintenance'), sonst None.
|
||||
Basis für den Wartungs-Riegel: nur EIN System-Update gleichzeitig."""
|
||||
for j in JOBS.values():
|
||||
for j in list(JOBS.values()):
|
||||
if j.get("group") == group and j.get("state") in ("running", "queued"):
|
||||
return j
|
||||
return None
|
||||
@@ -163,16 +238,13 @@ def active_in_group(group: str) -> dict | None:
|
||||
|
||||
def cancel_job(job_id: str) -> bool:
|
||||
job = JOBS.get(job_id)
|
||||
if not job or job["state"] in ("done", "failed", "canceled"):
|
||||
if not job or job["state"] in _ENDE:
|
||||
return False
|
||||
job["canceled"] = True
|
||||
_append_log(job, "[mc] Abbruch angefordert…")
|
||||
proc = _PROCS.get(job_id)
|
||||
if proc is not None:
|
||||
try:
|
||||
proc.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
_beende_gruppe(proc)
|
||||
else:
|
||||
job["state"] = "canceled"
|
||||
job["finished_at"] = time.time()
|
||||
@@ -181,4 +253,6 @@ def cancel_job(job_id: str) -> bool:
|
||||
|
||||
def public_jobs() -> list[dict]:
|
||||
"""Jobs ohne interne Felder (_on_done) für die API."""
|
||||
return [{k: v for k, v in j.items() if not k.startswith("_")} for j in JOBS.values()]
|
||||
with _LOCK:
|
||||
_aufraeumen()
|
||||
return [{k: v for k, v in j.items() if not k.startswith("_")} for j in JOBS.values()]
|
||||
|
||||
@@ -6,9 +6,13 @@ NEU in 2.0: `groups` für Ko-Residenz (schnell + schwer gleichzeitig geladen,
|
||||
`swap:false`) → Multi-Model-Delegation ohne Nachlade-Latenz.
|
||||
"""
|
||||
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from config import (
|
||||
@@ -17,12 +21,18 @@ from config import (
|
||||
DEFAULT_TTL,
|
||||
DRAFTS_DIR,
|
||||
LLAMA_SWAP_URL,
|
||||
MODELS_DIR,
|
||||
SPEC_DRAFT_MODEL_PATH,
|
||||
SPEC_DRAFT_N_MAX,
|
||||
SPEC_TYPE,
|
||||
)
|
||||
from ruamel.yaml.scalarstring import LiteralScalarString
|
||||
|
||||
try:
|
||||
import fcntl
|
||||
except ImportError: # Windows (Entwicklung): nur die Prozess-Sperre
|
||||
fcntl = None
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Kanonische Serving-Rollen — EINE Quelle der Wahrheit (identisch zu sources.ROLE_IDS,
|
||||
@@ -53,6 +63,44 @@ def _gguf_total_size(path: str) -> int | None:
|
||||
return None
|
||||
|
||||
|
||||
# --- Sperre ------------------------------------------------------------------
|
||||
# Die llama-swap-Config hat mehrere Schreiber: die Oberfläche, das Radar, das Aufräumen und die
|
||||
# Hirn-Umstellung. Ohne Sperre gingen gleichzeitige Änderungen verloren (lesen, ändern, schreiben
|
||||
# überlappten). Seit 24.09.2026 läuft jedes Lesen-Ändern-Schreiben unter dieser Sperre: im
|
||||
# Prozess per RLock, zwischen Prozessen per flock auf einer Datei neben der Config.
|
||||
_SPERRE = threading.RLock()
|
||||
_SPERR_TIEFE = threading.local()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def config_sperre():
|
||||
with _SPERRE:
|
||||
tiefe = getattr(_SPERR_TIEFE, "n", 0)
|
||||
_SPERR_TIEFE.n = tiefe + 1
|
||||
datei = None
|
||||
try:
|
||||
if tiefe == 0 and fcntl is not None:
|
||||
try:
|
||||
datei = open(CONFIG_PATH.with_name(".mc2-config.lock"), "a+")
|
||||
fcntl.flock(datei, fcntl.LOCK_EX)
|
||||
except OSError:
|
||||
datei = None # ohne Sperrdatei (z. B. fehlende Rechte) bleibt die Prozess-Sperre
|
||||
yield
|
||||
finally:
|
||||
_SPERR_TIEFE.n = tiefe
|
||||
if datei is not None:
|
||||
fcntl.flock(datei, fcntl.LOCK_UN)
|
||||
datei.close()
|
||||
|
||||
|
||||
def _mit_sperre(fn):
|
||||
@functools.wraps(fn)
|
||||
def huelle(*args, **kwargs):
|
||||
with config_sperre():
|
||||
return fn(*args, **kwargs)
|
||||
return huelle
|
||||
|
||||
|
||||
# --- Lesen -------------------------------------------------------------------
|
||||
def read_config() -> dict:
|
||||
if not CONFIG_PATH.exists():
|
||||
@@ -236,7 +284,7 @@ def write_config(cfg: dict) -> None:
|
||||
) from exc
|
||||
|
||||
|
||||
|
||||
@_mit_sperre
|
||||
def register_model(model_path: str, role: str | None = None, ctx: int = 8192,
|
||||
ttl: int | None = None, mmproj_path: str | None = None,
|
||||
jinja: bool = False, set_alias: bool = True) -> str:
|
||||
@@ -368,70 +416,8 @@ def spec_draft_flags(target_path: str) -> str:
|
||||
return _spec_flags_for_draft(d) if d else ""
|
||||
|
||||
|
||||
def drafts_for(target_path: str) -> dict:
|
||||
"""Für die UI: alle Drafts + ihre Kompatibilität zum Ziel-Modell. Schließt MTP-Köpfe
|
||||
NEBEN dem Zielmodell ein (DRAFTS_DIR kennt sie nicht). `mtp:true` markiert MTP-Drafts.
|
||||
compatible=None heißt 'nicht prüfbar' (Ziel- oder Draft-GGUF fehlt)."""
|
||||
from services import gguf_meta
|
||||
exists = bool(target_path and os.path.exists(target_path))
|
||||
drafts = list_drafts()
|
||||
seen = {d["path"] for d in drafts}
|
||||
for p in _sibling_mtp_drafters(target_path):
|
||||
if p not in seen:
|
||||
drafts.append({"path": p, "filename": os.path.basename(p),
|
||||
"size_bytes": os.path.getsize(p) if os.path.exists(p) else None,
|
||||
"vocab": gguf_meta.fingerprint(p)})
|
||||
for d in drafts:
|
||||
d["compatible"] = gguf_meta.compatible(target_path, d["path"]) if exists else None
|
||||
d["mtp"] = _is_mtp_draft(d["path"])
|
||||
return {
|
||||
"target_path": target_path,
|
||||
"target_exists": exists,
|
||||
"target_vocab": gguf_meta.fingerprint(target_path) if exists else None,
|
||||
"drafts": drafts,
|
||||
}
|
||||
|
||||
|
||||
def set_spec_draft(model_id: str, draft_path: str | None) -> dict:
|
||||
"""Setzt (oder entfernt mit draft_path=None) den Spec-Draft eines Modells.
|
||||
Validiert die Vocab-Kompatibilität — ein inkompatibler/unprüfbarer Draft wird
|
||||
abgelehnt (idiotensicher). Returns {ok, reason}."""
|
||||
cfg = read_config()
|
||||
spec = (cfg.get("models") or {}).get(model_id)
|
||||
if not isinstance(spec, dict):
|
||||
return {"ok": False, "reason": "Modell nicht gefunden"}
|
||||
cmd = str(spec.get("cmd", ""))
|
||||
# vorhandene Spec-Flags entfernen (idempotent) — klassisch UND MTP.
|
||||
cmd = re.sub(r"\s+--(?:spec-draft-model|model-draft)\s+\S+", "", cmd)
|
||||
cmd = re.sub(r"\s+-md\s+\S+", "", cmd)
|
||||
cmd = re.sub(r"\s+--spec-type\s+\S+", "", cmd)
|
||||
cmd = re.sub(r"\s+--spec-draft-n-(?:max|min)\s+\S+", "", cmd)
|
||||
|
||||
if draft_path:
|
||||
# relative Angabe (nur Dateiname) gegen DRAFTS_DIR auflösen
|
||||
if not os.path.isabs(draft_path) and "/" not in draft_path:
|
||||
draft_path = str(DRAFTS_DIR / draft_path)
|
||||
if not os.path.exists(draft_path):
|
||||
return {"ok": False, "reason": "Draft-Datei nicht gefunden"}
|
||||
from services import gguf_meta
|
||||
target = ""
|
||||
if (mt := _PATH_RE.search(cmd)):
|
||||
target = mt.group(1).replace("'", "").replace('"', "")
|
||||
comp = gguf_meta.compatible(target, draft_path) if os.path.exists(target) else None
|
||||
if comp is not True:
|
||||
reason = ("Draft ist NICHT vocab-kompatibel zum Modell — Speculative Decoding "
|
||||
"würde beim Laden scheitern."
|
||||
if comp is False else
|
||||
"Kompatibilität nicht prüfbar (Modell-GGUF fehlt) — Draft nicht gesetzt.")
|
||||
return {"ok": False, "reason": reason}
|
||||
cmd = cmd.rstrip() + _spec_flags_for_draft(draft_path)
|
||||
|
||||
spec["cmd"] = LiteralScalarString(cmd.rstrip() + "\n")
|
||||
write_config(cfg)
|
||||
return {"ok": True, "reason": ""}
|
||||
|
||||
|
||||
# --- Groups (Ko-Residenz) ----------------------------------------------------
|
||||
@_mit_sperre
|
||||
def set_group(group: str, members: list[str], swap: bool = False, persist: bool = False) -> None:
|
||||
"""llama-swap-`groups`-Eintrag setzen. swap=False → alle Mitglieder dürfen
|
||||
GLEICHZEITIG laufen (Ko-Residenz, keine Nachlade-Latenz). persist=True →
|
||||
@@ -467,6 +453,7 @@ def list_groups() -> dict:
|
||||
return read_config().get("groups") or {}
|
||||
|
||||
|
||||
@_mit_sperre
|
||||
def set_role(model_id: str, role: str | None) -> bool:
|
||||
"""Rolle (llama-swap-Alias) eines bestehenden Modells setzen/ändern. So tauscht man
|
||||
z.B. das `fast`-Hirn: Rolle `fast` auf ein anderes Modell legen (Alias wandert)."""
|
||||
@@ -478,6 +465,7 @@ def set_role(model_id: str, role: str | None) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
@_mit_sperre
|
||||
def add_role(model_id: str, role: str) -> bool:
|
||||
"""Wie set_role, aber die übrigen Aliase des Ziel-Modells bleiben — für Modelle mit zwei Rollen
|
||||
(Coder: coder UND heavy). set_role behielte nur die geschützten Aliase und würfe coder wieder weg."""
|
||||
@@ -493,22 +481,7 @@ def add_role(model_id: str, role: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def set_ctx(model_id: str, ctx: int) -> bool:
|
||||
"""Kontextlänge (-c) eines bestehenden Modells ändern."""
|
||||
cfg = read_config()
|
||||
spec = (cfg.get("models") or {}).get(model_id)
|
||||
if not spec:
|
||||
return False
|
||||
cmd = str(spec.get("cmd", ""))
|
||||
if _CTX_RE.search(cmd):
|
||||
cmd = re.sub(r"-(?:c|-ctx-size)\s+\d+", f"-c {ctx}", cmd)
|
||||
else:
|
||||
cmd = cmd.rstrip() + f" -c {ctx}"
|
||||
spec["cmd"] = LiteralScalarString(cmd if cmd.endswith("\n") else cmd + "\n")
|
||||
write_config(cfg)
|
||||
return True
|
||||
|
||||
|
||||
@_mit_sperre
|
||||
def set_ttl(model_id: str, ttl: int) -> bool:
|
||||
"""Idle-TTL (Sekunden) eines bestehenden Modells setzen. ttl=0 → nie automatisch
|
||||
entladen (für das Agent-Hirn, das dauerhaft warm bleiben muss)."""
|
||||
@@ -521,6 +494,15 @@ def set_ttl(model_id: str, ttl: int) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
@_mit_sperre
|
||||
def im_modellordner(pfad: str) -> bool:
|
||||
"""Liegt der Pfad (aufgelöst) unter MODELS_DIR? Grenze für Löschen und Eintragen (24.09.2026)."""
|
||||
try:
|
||||
return Path(pfad).resolve().is_relative_to(MODELS_DIR.resolve())
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def delete_model(model_id: str) -> bool:
|
||||
"""Entfernt einen Modell-Eintrag aus der config.yaml, löscht die zugehörigen
|
||||
GGUF-Dateien (auch Splits) vom Datenträger und bereinigt leere Ordner.
|
||||
@@ -540,6 +522,9 @@ def delete_model(model_id: str) -> bool:
|
||||
m = None
|
||||
if m:
|
||||
path = m.group(1).replace("'", "").replace('"', "")
|
||||
if path and not im_modellordner(path):
|
||||
log.warning("delete_model: %s liegt außerhalb von %s — nur der Eintrag wird entfernt", path, MODELS_DIR)
|
||||
path = ""
|
||||
if path:
|
||||
# 1. Haupt-GGUF-Datei löschen
|
||||
if os.path.exists(path):
|
||||
@@ -567,7 +552,7 @@ def delete_model(model_id: str) -> bool:
|
||||
mmproj_match = re.search(r'--mmproj\s+[\'"]?([^\s\'"]+)[\'"]?', cmd)
|
||||
if mmproj_match:
|
||||
m_path = mmproj_match.group(1)
|
||||
if os.path.exists(m_path) and m_path not in andere:
|
||||
if os.path.exists(m_path) and m_path not in andere and im_modellordner(m_path):
|
||||
try:
|
||||
os.remove(m_path)
|
||||
except Exception:
|
||||
@@ -575,7 +560,7 @@ def delete_model(model_id: str) -> bool:
|
||||
|
||||
# 3. Eltern-Ordner löschen, falls er leer ist und nicht der Modelle-Wurzelordner selbst ist
|
||||
try:
|
||||
if not os.listdir(dirname) and os.path.basename(dirname) != "models":
|
||||
if not os.listdir(dirname) and Path(dirname).resolve() != MODELS_DIR.resolve():
|
||||
os.rmdir(dirname)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
+85
-189
@@ -14,9 +14,8 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import psutil
|
||||
|
||||
from services import catalog, discover, jobengine, llamaswap, system
|
||||
from services import jobengine, system
|
||||
|
||||
# System-Dienste (root, via sudo -n NOPASSWD) vs. User-Dienste (systemctl --user).
|
||||
SYSTEM_SERVICES = {"llama-swap"}
|
||||
@@ -24,7 +23,16 @@ SYSTEM_SERVICES = {"llama-swap"}
|
||||
# erneut laufen lassen“ — der Wächter bietet das als Knopf an (services/waechter.py).
|
||||
USER_SERVICES = {"mission-control-2", "mc2-gateway", "mc2-steward", "box-console",
|
||||
"hermes-gateway", "hermes-builtin-ui", "voice-service", "lucy-stimme",
|
||||
"projekte-sync", "mc2-backup", "mc2-radar"}
|
||||
"projekte-sync", "mc2-backup", "mc2-radar", "mc2-morgenmeldung", "mc2-autoupdate"}
|
||||
|
||||
# Warum eine Update-Prüfung nicht klappte (None = geprüft). Bis 24.09.2026 verschluckten die
|
||||
# Prüfungen Netz-, API- und apt-Fehler und meldeten „kein Update“ — das Cockpit zeigte dann
|
||||
# „aktuell“, obwohl gar nicht geprüft werden konnte.
|
||||
PRUEF_FEHLER: dict[str, str | None] = {"os": None, "engine": None, "swap": None, "hermes": None}
|
||||
|
||||
# Zählt abgeschlossene Updates hoch; Zwischenspeicher (z. B. im Box-Wart-Start) vergleichen ihn,
|
||||
# damit „Aktualisieren“ direkt nach einem Update verschwindet statt erst nach 10 Minuten.
|
||||
update_stand = 0
|
||||
|
||||
# Engine-Update: lädt den neuesten Vulkan-Build (deploy/update-engine.sh, läuft als root).
|
||||
_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
@@ -88,10 +96,6 @@ def _installed_engine_build() -> int | None:
|
||||
return None
|
||||
|
||||
|
||||
def _ram_gb() -> float:
|
||||
return psutil.virtual_memory().total / (1024 ** 3)
|
||||
|
||||
|
||||
def _os_upgradable() -> int:
|
||||
try:
|
||||
# LC_ALL=C erzwingt englische apt-Ausgabe ("[upgradable from: ...]") — sonst zählt
|
||||
@@ -99,8 +103,10 @@ def _os_upgradable() -> int:
|
||||
out = subprocess.run(
|
||||
["bash", "-c", "LC_ALL=C apt list --upgradable 2>/dev/null | grep -c upgradable || true"],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
PRUEF_FEHLER["os"] = None
|
||||
return int((out.stdout or "0").strip() or 0)
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
PRUEF_FEHLER["os"] = f"Paketliste nicht lesbar ({exc.__class__.__name__})"
|
||||
return 0
|
||||
|
||||
|
||||
@@ -109,8 +115,12 @@ def _engine_update_available() -> bool:
|
||||
if now - _engine_cache["ts"] < 3600:
|
||||
return _engine_cache["avail"]
|
||||
avail = False
|
||||
fehler = None
|
||||
try:
|
||||
rel = _latest_engine_asset_release() or {}
|
||||
rel = _latest_engine_asset_release()
|
||||
if rel is None:
|
||||
fehler = "Neueste Engine-Version bei GitHub nicht abrufbar"
|
||||
rel = rel or {}
|
||||
tag = str(rel.get("tag_name", ""))
|
||||
latest = int(m.group(1)) if (m := re.search(r"(\d{3,})", tag)) else None
|
||||
installed = _installed_engine_build()
|
||||
@@ -119,8 +129,12 @@ def _engine_update_available() -> bool:
|
||||
elif rel.get("published_at"): # Fallback: Release-Datum vs. Engine-mtime
|
||||
pub = datetime.fromisoformat(rel["published_at"].replace("Z", "+00:00")).timestamp()
|
||||
avail = pub > os.path.getmtime(ENGINE_PATH) + 86400
|
||||
except Exception:
|
||||
elif fehler is None:
|
||||
fehler = "Installierte Engine-Version nicht lesbar"
|
||||
except Exception as exc:
|
||||
avail = False
|
||||
fehler = f"Engine-Prüfung gescheitert ({exc.__class__.__name__})"
|
||||
PRUEF_FEHLER["engine"] = fehler
|
||||
_engine_cache.update(ts=now, avail=avail)
|
||||
return avail
|
||||
|
||||
@@ -144,6 +158,7 @@ def _swap_update_available() -> bool:
|
||||
if now - _swap_cache["ts"] < 3600:
|
||||
return _swap_cache["avail"]
|
||||
avail = False
|
||||
fehler = None
|
||||
try:
|
||||
rel = httpx.get(f"https://api.github.com/repos/{SWAP_REPO}/releases/latest",
|
||||
timeout=6, headers={"User-Agent": "MissionControl2"}).json()
|
||||
@@ -152,8 +167,14 @@ def _swap_update_available() -> bool:
|
||||
installed = _installed_swap_version()
|
||||
if latest is not None and installed is not None:
|
||||
avail = latest > installed
|
||||
except Exception:
|
||||
elif latest is None:
|
||||
fehler = "Neueste llama-swap-Version bei GitHub nicht abrufbar"
|
||||
else:
|
||||
fehler = "Installierte llama-swap-Version nicht lesbar"
|
||||
except Exception as exc:
|
||||
avail = False
|
||||
fehler = f"llama-swap-Prüfung gescheitert ({exc.__class__.__name__})"
|
||||
PRUEF_FEHLER["swap"] = fehler
|
||||
_swap_cache.update(ts=now, avail=avail)
|
||||
return avail
|
||||
|
||||
@@ -198,109 +219,12 @@ def _components_cached() -> list[dict]:
|
||||
if now - _comp_cache["ts"] < 3600 and _comp_cache["data"]:
|
||||
return _comp_cache["data"]
|
||||
data = [_hermes_agent_update()]
|
||||
PRUEF_FEHLER["hermes"] = ("Hermes-Quelle nicht erreichbar (git fetch)"
|
||||
if data[0].get("reachable") is False else None)
|
||||
_comp_cache.update(ts=now, data=data)
|
||||
return data
|
||||
|
||||
|
||||
def _params_of(m: dict) -> float:
|
||||
"""Größen-bewusste Parameterzahl eines installierten Modells: max aus Namens-Schätzung
|
||||
und Dateigröße (fängt namenlose wie 'Qwen3-Coder-Next' UND Split-GGUFs ab)."""
|
||||
from services.fit import QUANT_BYTES_PER_PARAM
|
||||
caps = m.get("capabilities") or {}
|
||||
bpp = QUANT_BYTES_PER_PARAM.get((m.get("quant") or "Q4_K_M").upper(), 0.55)
|
||||
size_gb = (m.get("size_bytes") or 0) / (1024 ** 3)
|
||||
pb_size = (size_gb / bpp) if size_gb > 1.0 else 0.0
|
||||
return max(float(caps.get("params_b") or 0), pb_size, 0.0)
|
||||
|
||||
|
||||
# Familien-Subtyp + Generations-Version aus dem Modellnamen (für „echtes Upgrade?").
|
||||
_FAM_PATS = (("qwen", r"qwen(\d+(?:\.\d+)?)"), ("gemma", r"gemma[-_ ]?(\d+(?:\.\d+)?)"),
|
||||
("llama", r"llama[-_ ]?(\d+(?:\.\d+)?)"), ("phi", r"phi[-_ ]?(\d+(?:\.\d+)?)"),
|
||||
("mistral", r"mistral"), ("hermes", r"hermes[-_ ]?(\d+(?:\.\d+)?)"))
|
||||
|
||||
|
||||
def _gen_key(name: str):
|
||||
"""(Familie+Subtyp, Generations-Version) oder None. Z.B. 'Qwen3-VL-2B' → ('qwen-vl', 3.0),
|
||||
'Qwen2.5-VL-7B' → ('qwen-vl', 2.5). Nur gleiche Familie ist sinnvoll vergleichbar."""
|
||||
low = (name or "").lower()
|
||||
sub = "-vl" if any(k in low for k in ("-vl", "vl-", "vision", "llava", "pixtral")) else \
|
||||
"-coder" if ("coder" in low or "-code" in low) else ""
|
||||
for fam, pat in _FAM_PATS:
|
||||
m = re.search(pat, low)
|
||||
if m:
|
||||
ver = float(m.group(1)) if (m.groups() and m.group(1)) else 0.0
|
||||
return (fam + sub, ver)
|
||||
return None
|
||||
|
||||
|
||||
def _meta(name: str, model_dict: dict | None = None, im: dict | None = None) -> dict:
|
||||
"""Metadaten (family, gen, total, active, moe) — bevorzugt den kuratierten Katalog,
|
||||
sonst die Felder eines Discover-/Modell-Dicts, sonst Namens-/Größen-Heuristik."""
|
||||
cm = catalog.meta_for_name(name)
|
||||
if cm:
|
||||
return {"family": cm.get("family"), "gen": cm.get("generation"),
|
||||
"total": float(cm.get("total_params_b") or 0),
|
||||
"active": cm.get("active_params_b"), "moe": bool(cm.get("moe"))}
|
||||
d = model_dict or {}
|
||||
g = _gen_key(name)
|
||||
total = float(d.get("params_b") or 0) or (_params_of(im) if im else 0.0)
|
||||
return {"family": (d.get("family") or (g[0] if g else None)),
|
||||
"gen": (d.get("generation") if d.get("generation") is not None else (g[1] if g else None)),
|
||||
"total": total, "active": d.get("active_b"), "moe": bool(d.get("moe"))}
|
||||
|
||||
|
||||
def model_upgrades() -> list[dict]:
|
||||
"""Je Rolle ein ECHTES Upgrade — nur wenn die Empfehlung wirklich besser ist:
|
||||
gleiche Familie UND (neuere Generation ODER deutlich größer) UND kein Tempo-Downgrade
|
||||
(MoE-first für die bandbreiten-limitierte Box: dense ersetzt MoE nur bei großem Wissens-
|
||||
Sprung). Metadaten kommen aus dem kuratierten Katalog → keine Namens-Raterei."""
|
||||
disc = discover.safe_discover(_ram_gb())
|
||||
if not disc:
|
||||
return []
|
||||
|
||||
installed = llamaswap.list_models()
|
||||
inst_by_role = {m["role"]: m for m in installed if m.get("role")}
|
||||
cmds = " ".join(str(s.get("cmd", "")).lower()
|
||||
for s in (llamaswap.read_config().get("models") or {}).values())
|
||||
out = []
|
||||
|
||||
for c in disc.get("categories", []):
|
||||
role = c["role"]
|
||||
im = inst_by_role.get(role)
|
||||
if im is None:
|
||||
continue
|
||||
rec = c.get("recommended")
|
||||
if not rec:
|
||||
continue
|
||||
rec_model = next((x for x in c.get("models", []) if x.get("repo") == rec), None)
|
||||
|
||||
i = _meta(im["name"], im=im)
|
||||
r = _meta(rec, model_dict=rec_model)
|
||||
|
||||
if not i["family"] or not r["family"] or i["family"] != r["family"]:
|
||||
continue # andere/unbekannte Familie → kein Upgrade
|
||||
if r["gen"] is not None and i["gen"] is not None and r["gen"] < i["gen"] - 1e-6:
|
||||
continue # ältere Generation → niemals
|
||||
same_gen = (r["gen"] is None or i["gen"] is None or abs(r["gen"] - i["gen"]) < 1e-6)
|
||||
if same_gen:
|
||||
if r["total"] and i["total"] and r["total"] < i["total"] * 1.05:
|
||||
continue # gleiche Gen, nicht größer → kein Upgrade
|
||||
# MoE-first: ein MoE durch dense ersetzen nur bei deutlichem Wissens-Sprung
|
||||
if i["moe"] and not r["moe"] and r["total"] < i["total"] * 1.5:
|
||||
continue
|
||||
# Tempo nicht verschlechtern (aktive Params), außer großer Wissens-Gewinn
|
||||
ia, ra = (i["active"] or i["total"]), (r["active"] or r["total"])
|
||||
if ia and ra > ia * 1.3 and r["total"] < i["total"] * 1.3:
|
||||
continue
|
||||
|
||||
base = rec.split("/")[-1].lower()
|
||||
stem = base.removesuffix("-gguf")
|
||||
if base in cmds or (stem and stem in cmds):
|
||||
continue # schon installiert
|
||||
out.append({"role": role, "title": c["title"], "repo": rec})
|
||||
return out
|
||||
|
||||
|
||||
def _last_apt_update() -> float | None:
|
||||
for path in ["/var/lib/apt/periodic/update-success-stamp", "/var/cache/apt/pkgcache.bin"]:
|
||||
if os.path.exists(path):
|
||||
@@ -312,11 +236,23 @@ def _last_apt_update() -> float | None:
|
||||
|
||||
|
||||
def updates() -> dict:
|
||||
ups = model_upgrades()
|
||||
return {"os": _os_upgradable(), "engine": 1 if _engine_update_available() else 0,
|
||||
"swap": 1 if _swap_update_available() else 0,
|
||||
"models": len(ups), "model_list": ups, "last_check": _last_apt_update(),
|
||||
"components": _components_cached()}
|
||||
"""Offene Updates je Baustein. `pruef_fehler` nennt je Baustein, warum nicht geprüft werden
|
||||
konnte (None = geprüft). Die frühere Modell-Upgrade-Empfehlung ist raus (24.09.2026): das
|
||||
Modell-Radar hat die Aufgabe übernommen, und die Oberfläche zeigte sie nicht mehr."""
|
||||
daten = {"os": _os_upgradable(), "engine": 1 if _engine_update_available() else 0,
|
||||
"swap": 1 if _swap_update_available() else 0,
|
||||
"last_check": _last_apt_update(), "components": _components_cached()}
|
||||
daten["pruef_fehler"] = dict(PRUEF_FEHLER)
|
||||
return daten
|
||||
|
||||
|
||||
def _nach_update() -> None:
|
||||
"""Nach einem abgeschlossenen Update: Zwischenspeicher leeren und den Stand hochzählen."""
|
||||
global update_stand
|
||||
_engine_cache.update(ts=0.0, avail=False)
|
||||
_swap_cache.update(ts=0.0, avail=False)
|
||||
_comp_cache.update(ts=0.0, data=[])
|
||||
update_stand += 1
|
||||
|
||||
|
||||
# ── Update-Details (was genau wird aktualisiert) — on-demand beim Öffnen des Fensters ──
|
||||
@@ -630,30 +566,28 @@ def logs(service: str, lines: int = 200) -> dict:
|
||||
return {"ok": r["ok"], "text": r["out"] or r["err"]}
|
||||
return {"ok": False, "text": "", "err": "Dienst nicht erlaubt."}
|
||||
|
||||
def check_updates_job() -> dict:
|
||||
if err := check_sudo_needs_password():
|
||||
return err
|
||||
|
||||
def on_done():
|
||||
_engine_cache.update(ts=0.0, avail=False)
|
||||
_comp_cache.update(ts=0.0, data=[]) # Hermes-Status ebenfalls neu berechnen lassen
|
||||
|
||||
cmd = "sudo apt-get update"
|
||||
job_id = jobengine.start_job(["bash", "-c", cmd], "Nach Updates suchen", on_done=on_done)
|
||||
def _starten(args: list[str], label: str, on_done=None, zeitlimit_s: int | None = None) -> dict:
|
||||
"""Wartungs-Riegel: nur EIN binär-/dienst-veränderndes Job gleichzeitig. Prüfen und Eintragen
|
||||
passieren unter einer Sperre (jobengine.start_job_exklusiv) — vorher lag zwischen Prüfung und
|
||||
Start ein langsamer Aufruf, und zwei Klicks konnten zwei Updates starten."""
|
||||
job_id, laeuft = jobengine.start_job_exklusiv("maintenance", args, label, on_done=on_done,
|
||||
zeitlimit_s=zeitlimit_s)
|
||||
if job_id is None:
|
||||
return {"ok": False, "status": "busy", "running": (laeuft or {}).get("label"),
|
||||
"detail": f"Es läuft schon: {(laeuft or {}).get('label', 'ein Update')}."}
|
||||
return {"ok": True, "job_id": job_id}
|
||||
|
||||
|
||||
def _maintenance_busy() -> dict | None:
|
||||
"""Wartungs-Riegel: nur EIN binär-/dienst-veränderndes Update gleichzeitig. Verhindert
|
||||
Doppelklick UND parallele Updates aus zwei Tabs/Sessions (racende .bak-Sicherung/Restarts)."""
|
||||
if j := jobengine.active_in_group("maintenance"):
|
||||
return {"ok": False, "status": "busy", "running": j.get("label")}
|
||||
return None
|
||||
def check_updates_job() -> dict:
|
||||
if err := check_sudo_needs_password():
|
||||
return err
|
||||
# apt-get update gehört in den Wartungs-Riegel: parallel zu einem apt upgrade kollidiert es mit
|
||||
# der dpkg-Sperre. In der Gruppe taucht der Job auch in der Oberfläche auf.
|
||||
return _starten(["bash", "-c", "sudo apt-get update"], "Nach Updates suchen",
|
||||
on_done=_nach_update, zeitlimit_s=15 * 60)
|
||||
|
||||
|
||||
def os_update_job() -> dict:
|
||||
if busy := _maintenance_busy():
|
||||
return busy
|
||||
if err := check_sudo_needs_password():
|
||||
return err
|
||||
# Nach dem apt-Upgrade den Stack funktional prüfen (Job wird rot, wenn etwas kaputt ging).
|
||||
@@ -662,49 +596,26 @@ def os_update_job() -> dict:
|
||||
cmd = ("sudo apt-get update && "
|
||||
"sudo bash -c 'DEBIAN_FRONTEND=noninteractive apt-get upgrade -y' "
|
||||
f"&& bash {STACK_POSTCHECK}")
|
||||
job_id = jobengine.start_job(["bash", "-c", cmd], "OS-Update (apt)",
|
||||
group="maintenance")
|
||||
return {"ok": True, "job_id": job_id}
|
||||
return _starten(["bash", "-c", cmd], "OS-Update (apt)", on_done=_nach_update, zeitlimit_s=90 * 60)
|
||||
|
||||
|
||||
def engine_update_job() -> dict | None:
|
||||
if not ENGINE_UPDATE_CMD:
|
||||
return None
|
||||
if busy := _maintenance_busy():
|
||||
return busy
|
||||
# KEIN sudo-Passwort-Gate: update-engine.sh ist per sudoers NOPASSWD freigegeben
|
||||
# (sudoers-mc2-autonomie) und läuft passwortlos. Das frühere `sudo true`-Gate hat das
|
||||
# Update fälschlich blockiert, wenn (noch) kein Box-Passwort hinterlegt war.
|
||||
|
||||
def on_done():
|
||||
_engine_cache.update(ts=0.0, avail=False) # Cache leeren → frischer Build-Vergleich
|
||||
|
||||
# update-engine.sh sichert den alten Build, aktualisiert, startet llama-swap neu, prüft den
|
||||
# Stack (stack-postcheck.sh) und rollt bei Fehler selbst zurück. Exit 0 nur bei verifiziertem
|
||||
# neuen Build → on_done (Cache leeren) läuft nur dann; bei Rollback (Exit 1/2) bleibt das Badge.
|
||||
job_id = jobengine.start_job(["bash", "-c", ENGINE_UPDATE_CMD],
|
||||
"Engine-Update (llama.cpp Vulkan)",
|
||||
group="maintenance", on_done=on_done)
|
||||
return {"ok": True, "job_id": job_id}
|
||||
# neuen Build. KEIN sudo-Passwort-Gate: das Skript ist per sudoers NOPASSWD freigegeben.
|
||||
return _starten(["bash", "-c", ENGINE_UPDATE_CMD], "Engine-Update (llama.cpp Vulkan)",
|
||||
on_done=_nach_update, zeitlimit_s=60 * 60)
|
||||
|
||||
|
||||
def swap_update_job() -> dict | None:
|
||||
if not SWAP_UPDATE_CMD:
|
||||
return None
|
||||
if busy := _maintenance_busy():
|
||||
return busy
|
||||
# KEIN sudo-Passwort-Gate: update-swap.sh ist per sudoers NOPASSWD freigegeben (wie die Engine).
|
||||
|
||||
def on_done():
|
||||
_swap_cache.update(ts=0.0, avail=False) # Cache leeren → frischer Versions-Vergleich
|
||||
|
||||
# update-swap.sh sichert die alte Binary, aktualisiert, startet llama-swap neu, prüft den Stack
|
||||
# (stack-postcheck.sh) und rollt bei Fehler selbst zurück. Exit 0 nur bei verifizierter neuer
|
||||
# Version → on_done (Cache leeren) läuft nur dann; bei Rollback (Exit 1/2) bleibt das Badge.
|
||||
job_id = jobengine.start_job(["bash", "-c", SWAP_UPDATE_CMD],
|
||||
"Router-Update (llama-swap)",
|
||||
group="maintenance", on_done=on_done)
|
||||
return {"ok": True, "job_id": job_id}
|
||||
# und rollt bei Fehler selbst zurück. Exit 0 nur bei verifizierter neuer Version.
|
||||
return _starten(["bash", "-c", SWAP_UPDATE_CMD], "Router-Update (llama-swap)",
|
||||
on_done=_nach_update, zeitlimit_s=30 * 60)
|
||||
|
||||
|
||||
def _hermes_update_cmd() -> str:
|
||||
@@ -753,16 +664,9 @@ def _hermes_update_cmd() -> str:
|
||||
def hermes_update_job() -> dict:
|
||||
"""Hermes-Agent aktualisieren wie die CLI (`hermes update` = git pull + Deps), danach
|
||||
den Gateway neu starten. Davor ein Sicherheits-Backup (unser deploy/backup.sh). Kein sudo
|
||||
(alles im User-Space). Läuft als Hintergrund-Job (kann ~1 Min dauern)."""
|
||||
if busy := _maintenance_busy():
|
||||
return busy
|
||||
|
||||
def on_done():
|
||||
_comp_cache.update(ts=0.0, data=[]) # Update-Status neu berechnen lassen
|
||||
|
||||
job_id = jobengine.start_job(["bash", "-c", _hermes_update_cmd()], "Hermes-Agent-Update",
|
||||
group="maintenance", on_done=on_done)
|
||||
return {"ok": True, "job_id": job_id}
|
||||
(alles im User-Space). Läuft als Hintergrund-Job (kann einige Minuten dauern)."""
|
||||
return _starten(["bash", "-c", _hermes_update_cmd()], "Hermes-Agent-Update",
|
||||
on_done=_nach_update, zeitlimit_s=45 * 60)
|
||||
|
||||
|
||||
def update_all_job() -> dict:
|
||||
@@ -771,8 +675,9 @@ def update_all_job() -> dict:
|
||||
Befehl des Einzel-Updates (mit eigenem Backup/Postcheck/Rollback). `&&`-Kette = bei
|
||||
Fehler stoppt der Rest (das Log zeigt, wo). OS zuletzt, weil apt am breitesten eingreift;
|
||||
es ist das einzige mit sudo — scheitert das, laufen die sudo-freien Teile trotzdem."""
|
||||
if busy := _maintenance_busy():
|
||||
return busy
|
||||
if laeuft := jobengine.active_in_group("maintenance"):
|
||||
return {"ok": False, "status": "busy", "running": laeuft.get("label"),
|
||||
"detail": f"Es läuft schon: {laeuft.get('label', 'ein Update')}."}
|
||||
upd = updates()
|
||||
parts: list[tuple[str, str]] = []
|
||||
if upd.get("engine") and ENGINE_UPDATE_CMD:
|
||||
@@ -804,18 +709,9 @@ def update_all_job() -> dict:
|
||||
if not os_pending:
|
||||
cmd += f" && bash {STACK_POSTCHECK}" # Abschluss-Check, falls apt ihn nicht schon lieferte
|
||||
|
||||
def on_done():
|
||||
_engine_cache.update(ts=0.0, avail=False)
|
||||
_swap_cache.update(ts=0.0, avail=False)
|
||||
_comp_cache.update(ts=0.0, data=[])
|
||||
|
||||
labels = " → ".join(label for label, _ in parts)
|
||||
job_id = jobengine.start_job(["bash", "-c", cmd], f"Alle aktualisieren ({labels})",
|
||||
group="maintenance", on_done=on_done)
|
||||
return {"ok": True, "job_id": job_id, "parts": [label for label, _ in parts]}
|
||||
|
||||
|
||||
def reboot() -> dict:
|
||||
if err := check_sudo_needs_password():
|
||||
return err
|
||||
return _run(["sudo", "reboot"])
|
||||
ergebnis = _starten(["bash", "-c", cmd], f"Alle aktualisieren ({labels})",
|
||||
on_done=_nach_update, zeitlimit_s=3 * 3600)
|
||||
if ergebnis.get("ok"):
|
||||
ergebnis["parts"] = [label for label, _ in parts]
|
||||
return ergebnis
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
"""24-h-Metrik-Verlauf für die Cockpit-Zeitachse (User-Wunsch 16.07.: „WANN war Last?").
|
||||
|
||||
Ein leichter Sammler (Lifespan-Task in app.py) legt alle SAMPLE_S Sekunden einen
|
||||
kompakten Punkt in einen Ringpuffer: CPU/RAM/GPU/Disk in Prozent + die Token-
|
||||
GESAMTZÄHLER (das Frontend rechnet Raten aus den Deltas). Der Puffer hält exakt
|
||||
24 h — Älteres fällt automatisch raus (deque maxlen), nichts wächst unbegrenzt.
|
||||
Periodisch wird auf Platte persistiert (übersteht MC2-Restarts/Deploys); beim
|
||||
Laden fliegt alles raus, was älter als 24 h ist.
|
||||
|
||||
Bewusst NICHT im Steward: die Historie ist reine UI-Ware, und ein paar Sekunden
|
||||
Lücke pro Deploy sind egal.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from collections import deque
|
||||
|
||||
from config import MODELS_DIR
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
SAMPLE_S = 10 # Abtast-Takt
|
||||
RETENTION_S = 24 * 3600 # 24 h — danach löschen und neu bauen (Ringpuffer)
|
||||
MAXLEN = RETENTION_S // SAMPLE_S # 8640 Punkte
|
||||
FLUSH_S = 300 # höchstens alle 5 min auf Platte
|
||||
MAX_RETURN_POINTS = 300 # Endpoint downsampled auf ~diese Punktzahl
|
||||
|
||||
HISTORY_PATH = MODELS_DIR / "mc2-metrics-history.json"
|
||||
|
||||
# Punkt = [t, cpu, ram, gpu, disk, tp, tc] (t = Epoch-Sekunden; tp/tc = Token-Totale)
|
||||
_points: deque = deque(maxlen=MAXLEN)
|
||||
_loaded = False
|
||||
_last_flush = 0.0
|
||||
|
||||
|
||||
def _load() -> None:
|
||||
global _loaded
|
||||
if _loaded:
|
||||
return
|
||||
_loaded = True
|
||||
try:
|
||||
raw = json.loads(HISTORY_PATH.read_text(encoding="utf-8"))
|
||||
cutoff = time.time() - RETENTION_S
|
||||
for p in raw if isinstance(raw, list) else []:
|
||||
if isinstance(p, list) and len(p) == 7 and isinstance(p[0], (int, float)) and p[0] >= cutoff:
|
||||
_points.append(p)
|
||||
if _points:
|
||||
log.info("metrics_history: %d Punkte aus %s geladen", len(_points), HISTORY_PATH)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except Exception:
|
||||
log.warning("metrics_history: %s nicht lesbar — starte leer", HISTORY_PATH, exc_info=True)
|
||||
|
||||
|
||||
def _flush() -> None:
|
||||
global _last_flush
|
||||
_last_flush = time.time()
|
||||
try:
|
||||
tmp = HISTORY_PATH.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(list(_points), separators=(",", ":")), encoding="utf-8")
|
||||
tmp.replace(HISTORY_PATH)
|
||||
except OSError:
|
||||
log.warning("metrics_history: %s nicht schreibbar", HISTORY_PATH, exc_info=True)
|
||||
|
||||
|
||||
def _sample() -> None:
|
||||
import psutil
|
||||
|
||||
from services.system import _gpu_sysfs
|
||||
from services.token_stats import get_stats
|
||||
|
||||
vm = psutil.virtual_memory()
|
||||
disk = None
|
||||
try:
|
||||
du = psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else os.getcwd())
|
||||
disk = du.percent
|
||||
except Exception:
|
||||
pass
|
||||
gpu = None
|
||||
try:
|
||||
g = _gpu_sysfs()
|
||||
gpu = g.get("busy_percent") if g else None
|
||||
except Exception:
|
||||
pass
|
||||
tp = tc = None
|
||||
try:
|
||||
ts = get_stats()
|
||||
tp, tc = ts.get("prompt_tokens"), ts.get("completion_tokens")
|
||||
except Exception:
|
||||
pass
|
||||
# interval=None: nicht-blockierende CPU-Messung seit dem letzten Aufruf (10 s her — ideal).
|
||||
_points.append([int(time.time()), psutil.cpu_percent(interval=None), vm.percent, gpu, disk, tp, tc])
|
||||
|
||||
|
||||
async def sampler_loop() -> None:
|
||||
"""Dauer-Sammler fürs App-Lifespan. Fehler eines Ticks reißen die Schleife nie."""
|
||||
_load()
|
||||
while True:
|
||||
try:
|
||||
await asyncio.to_thread(_sample)
|
||||
except Exception:
|
||||
log.debug("metrics_history: Sample fehlgeschlagen", exc_info=True)
|
||||
if time.time() - _last_flush >= FLUSH_S:
|
||||
try:
|
||||
await asyncio.to_thread(_flush)
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(SAMPLE_S)
|
||||
|
||||
|
||||
def history(minutes: int = 60) -> dict:
|
||||
"""Punkte der letzten N Minuten, auf ≤ MAX_RETURN_POINTS ausgedünnt (Stride)."""
|
||||
_load()
|
||||
minutes = max(1, min(int(minutes), RETENTION_S // 60))
|
||||
cutoff = time.time() - minutes * 60
|
||||
pts = [p for p in _points if p[0] >= cutoff]
|
||||
stride = max(1, len(pts) // MAX_RETURN_POINTS)
|
||||
pts = pts[::stride]
|
||||
return {
|
||||
"sample_s": SAMPLE_S * stride,
|
||||
"points": [
|
||||
{"t": p[0], "cpu": p[1], "ram": p[2], "gpu": p[3], "disk": p[4], "tp": p[5], "tc": p[6]}
|
||||
for p in pts
|
||||
],
|
||||
}
|
||||
+13
-10
@@ -1384,16 +1384,19 @@ def _tausche_ein(k: dict) -> dict:
|
||||
else:
|
||||
ziel = quelle # schon verschoben (früherer, abgebrochener Versuch)
|
||||
pfade = _lokale_pfade(k, ziel)
|
||||
modell_id = llamaswap.register_model(pfade["gguf"], role=ALIAS[rolle], ctx=int(k.get("ctx") or CTX_ROLLE[rolle]),
|
||||
mmproj_path=None, jinja=True, set_alias=False)
|
||||
cfg = llamaswap.read_config()
|
||||
try: # die getesteten Flags statt der Vorlage aus register_model (sonst liefe ein ungetesteter Draft mit)
|
||||
cfg["models"][modell_id]["cmd"] = LiteralScalarString(betriebs_befehl(rolle, pfade, k) + "\n")
|
||||
except (KeyError, TypeError) as e:
|
||||
raise RuntimeError(f"{modell_id} fehlt nach dem Eintragen in der llama-swap-Config.") from e
|
||||
if pfade.get("mmproj"):
|
||||
_zwilling_eintragen(cfg, rolle, modell_id, pfade, k)
|
||||
llamaswap.write_config(cfg)
|
||||
# Eintragen, Befehl ersetzen und Zwilling anlegen unter EINER Config-Sperre (24.09.2026).
|
||||
with llamaswap.config_sperre():
|
||||
modell_id = llamaswap.register_model(pfade["gguf"], role=ALIAS[rolle],
|
||||
ctx=int(k.get("ctx") or CTX_ROLLE[rolle]),
|
||||
mmproj_path=None, jinja=True, set_alias=False)
|
||||
cfg = llamaswap.read_config()
|
||||
try: # die getesteten Flags statt der Vorlage aus register_model (sonst liefe ein ungetesteter Draft mit)
|
||||
cfg["models"][modell_id]["cmd"] = LiteralScalarString(betriebs_befehl(rolle, pfade, k) + "\n")
|
||||
except (KeyError, TypeError) as e:
|
||||
raise RuntimeError(f"{modell_id} fehlt nach dem Eintragen in der llama-swap-Config.") from e
|
||||
if pfade.get("mmproj"):
|
||||
_zwilling_eintragen(cfg, rolle, modell_id, pfade, k)
|
||||
llamaswap.write_config(cfg)
|
||||
hinweis = None
|
||||
if rolle == "hirn":
|
||||
from services.agent import set_agent_brain
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
"""
|
||||
Rollen-Empfehlung: welches INSTALLIERTE Modell passt am besten auf eine Serving-Rolle?
|
||||
Capability-getrieben (Vision/Coder/Tools/MoE aus services.caps) + setup-bewusster Fit
|
||||
(services.budget). Speist den 'Empfohlen'-Hinweis + Auto-Pick im Rollen-Zuweisungs-Modal.
|
||||
|
||||
EINE Quelle der Wahrheit mit der ctx-/Fit-Logik: nutzt budget.setup_aware_ctx_for_model
|
||||
und fit.evaluate_fit — dieselbe Mathematik wie Install-Automatik und Auto-ctx-Button.
|
||||
"""
|
||||
|
||||
import psutil
|
||||
|
||||
from services import budget, catalog, llamaswap
|
||||
from services.fit import evaluate_fit
|
||||
|
||||
|
||||
def _ram_gb() -> float:
|
||||
return psutil.virtual_memory().total / (1024 ** 3)
|
||||
|
||||
|
||||
def _capability_suit(role: str, caps: dict, name: str) -> float:
|
||||
"""0..1 — Capability-Eignung (HARTE Gates). 0 = grundsätzlich falsch für die Rolle.
|
||||
Größe/Tempo bewertet getrennt _pref(), damit z.B. 'fast' nicht das größte Modell zieht."""
|
||||
role = (role or "").lower()
|
||||
low = (name or "").lower()
|
||||
vision = bool(caps.get("vision"))
|
||||
coder = bool(caps.get("coder"))
|
||||
tools = caps.get("tools") != "no"
|
||||
|
||||
if role == "vision":
|
||||
if not vision:
|
||||
return 0.0 # harte Anforderung
|
||||
return 1.0 if ("vl" in low or "llava" in low or "pixtral" in low) else 0.7 # dediziert > omni
|
||||
if role == "coder":
|
||||
return 1.0 if coder else 0.4 # Coder-Modell Pflicht für Empfehlung
|
||||
if role == "hermes":
|
||||
# Agent-Hirn: Hermes-Familie am robustesten; sonst natives Tool-Calling Pflicht.
|
||||
if "hermes" in low:
|
||||
return 1.0
|
||||
return 0.6 if tools else 0.1
|
||||
if role == "fast":
|
||||
# Alltags-Hirn braucht zuverlässige Tools; Größe/Tempo macht _pref.
|
||||
return 1.0 if tools else 0.6
|
||||
if role == "heavy":
|
||||
return 1.0
|
||||
if role == "scout":
|
||||
return 0.9 if vision else 0.7
|
||||
return 0.5
|
||||
|
||||
|
||||
def _pref(role: str, params: float, tps: float) -> float:
|
||||
"""0..1 — rollengerechte GRÖSSEN-/TEMPO-Präferenz. 'fast' belohnt Tempo & Kleinheit,
|
||||
'heavy' Größe (Wissen), 'hermes' moderate Größe (muss warm + ko-resident bleiben)."""
|
||||
role = (role or "").lower()
|
||||
if role == "fast":
|
||||
speed = min(tps / 25.0, 1.0)
|
||||
size_ok = 1.0 if params <= 50 else 50.0 / params
|
||||
return speed * size_ok
|
||||
if role == "heavy":
|
||||
return min(params / 120.0, 1.0)
|
||||
if role == "hermes":
|
||||
return 1.0 if params <= 24 else max(0.15, 24.0 / params) # 7–24B ideal als Hirn
|
||||
if role == "vision":
|
||||
return 1.0 if params <= 12 else 0.7 # klein/günstig bevorzugt
|
||||
if role == "coder":
|
||||
return 0.5 + 0.5 * min(params / 80.0, 1.0)
|
||||
if role == "scout":
|
||||
return 1.0 if params <= 40 else 0.5
|
||||
return 0.5
|
||||
|
||||
|
||||
def _catalog_role_match(role: str, name: str) -> bool:
|
||||
"""Ist dieses Modell im kuratierten Katalog (Cookbook) genau für DIESE Rolle gelistet?
|
||||
Dann ist es der prinzipien-konforme Pick → starker Bonus."""
|
||||
meta = catalog.meta_for_name(name)
|
||||
return bool(meta and (meta.get("role") or "").lower() == (role or "").lower())
|
||||
|
||||
|
||||
def _reason(role: str, caps: dict, name: str, fit: dict, fits: bool,
|
||||
incomplete: bool, cat_match: bool) -> str:
|
||||
if incomplete:
|
||||
return "Download unvollständig"
|
||||
if role == "vision" and not caps.get("vision"):
|
||||
return "keine Vision-Fähigkeit"
|
||||
if role == "coder" and not caps.get("coder"):
|
||||
return "kein Coder-Modell"
|
||||
if role == "hermes" and "hermes" not in (name or "").lower() and caps.get("tools") == "no":
|
||||
return "kein natives Tool-Calling"
|
||||
if not fits:
|
||||
return "passt nicht ins Budget (OOM)"
|
||||
bits = []
|
||||
if cat_match:
|
||||
bits.append("Katalog-Pick ✓")
|
||||
if role == "vision":
|
||||
bits.append("Vision ✓")
|
||||
if role == "coder" and caps.get("coder"):
|
||||
bits.append("Coder ✓")
|
||||
if role == "hermes":
|
||||
bits.append("Hermes" if "hermes" in (name or "").lower()
|
||||
else ("Tools ✓" if caps.get("tools") != "no" else "ohne Tools"))
|
||||
if caps.get("moe"):
|
||||
bits.append("MoE")
|
||||
bits.append(f"{fit['text']}, ~{fit['tps']:.0f} t/s")
|
||||
return " · ".join(bits)
|
||||
|
||||
|
||||
def recommend_for_role(role: str) -> dict:
|
||||
"""Rankt alle installierten Modelle für eine Rolle. Empfohlen = bester geeigneter,
|
||||
passender Eintrag. Liefert pro Modell Fit/Eignung/Begründung fürs UI."""
|
||||
role = (role or "").strip().lower()
|
||||
ram = _ram_gb()
|
||||
out = []
|
||||
for m in llamaswap.list_models():
|
||||
caps = m.get("capabilities") or {}
|
||||
params = budget.params_of_model(m)
|
||||
quant = m.get("quant") or "Q4_K_M"
|
||||
ctx = budget.setup_aware_ctx_for_model(m)["ctx"]
|
||||
fit = evaluate_fit(params, quant, ctx, ram, name=m["name"])
|
||||
incomplete = bool(m.get("incomplete"))
|
||||
fits = (fit["level"] != "too_tight") and not incomplete
|
||||
tps = fit["tps"] or 0
|
||||
suit = _capability_suit(role, caps, m["name"])
|
||||
cat_match = _catalog_role_match(role, m["name"])
|
||||
suitable = suit >= 0.5 and fits
|
||||
|
||||
fit_term = {"perfect": 1.0, "marginal": 0.3}.get(fit["level"], -2.0)
|
||||
# Eignung dominiert (×2), rollengerechte Größe/Tempo (_pref), Katalog-Anker, dann Fit.
|
||||
score = (2.0 * suit) + _pref(role, params, tps) + (0.6 if cat_match else 0.0) + fit_term
|
||||
if not fits:
|
||||
score -= 5.0
|
||||
|
||||
out.append({
|
||||
"name": m["name"], "current_role": m.get("role"),
|
||||
"params_b": round(params, 1), "quant": quant,
|
||||
"fit": fit, "suitable": suitable, "incomplete": incomplete,
|
||||
"score": round(score, 3),
|
||||
"reason": _reason(role, caps, m["name"], fit, fits, incomplete, cat_match),
|
||||
})
|
||||
|
||||
out.sort(key=lambda x: -x["score"])
|
||||
rec = next((o["name"] for o in out if o["suitable"]), None)
|
||||
for o in out:
|
||||
o["recommended"] = (o["name"] == rec)
|
||||
return {"role": role, "recommended": rec, "models": out}
|
||||
@@ -19,8 +19,6 @@ from services.routing_policy import load_policy
|
||||
# (routing_policy.py) und kommen pro Request via load_policy() (hot-reload). Die Env-Vars
|
||||
# sind dort die Defaults. Die Regex-Keyword-Listen unten bleiben bewusst im Code.
|
||||
|
||||
# Virtuelle Lanes, die im Gateway als „Modelle" sichtbar sind.
|
||||
LANES = ["coding", "chat"]
|
||||
LANE_ALIASES = {"auto": "chat"} # Rückwärtskompatibel: model:auto == chat
|
||||
|
||||
_HEAVY_KW = re.compile(
|
||||
@@ -36,18 +34,6 @@ _CODING_HEAVY_KW = re.compile(
|
||||
r"entwirf\s+(eine\s+)?architektur|plane?\s+(die\s+)?architektur)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# Code-Indikatoren — verhindert, dass echte Code-Anfragen als „trivial" auf fast abrutschen.
|
||||
# Bewusst breit (inkl. natürlichsprachiger Coding-Begriffe DE+EN): in der coding-Lane soll im Zweifel
|
||||
# `coder` gewinnen; nur echte Nicht-Code-Kürze ("hallo", "wie spät") rutscht auf fast.
|
||||
_CODE_HINT = re.compile(
|
||||
r"```|\bdef \b|\bclass \b|\bimport \b|\bfunction\b|=>|;\s*$|"
|
||||
r"\.(py|ts|tsx|js|jsx|go|rs|java|cpp|c|rb|php|sql)\b|/src/|traceback|stack\s*trace|"
|
||||
r"\b(funktion|function|bug|fix|fehler|error|exception|implementier\w*|schreib\w*|"
|
||||
r"code\w*|coden|test\w*|klasse|method\w*|methode|refactor\w*|kompil\w*|compile|"
|
||||
r"build|deploy|debug|script|skript|api|endpoint|query|regex|json|yaml|"
|
||||
r"npm|pip|git|docker|terminal|shell|command)\b",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
|
||||
|
||||
# Bild-Weiche (Faden 11): Aliase, die selbst Bilder koennen — die werden NIE umgeroutet.
|
||||
@@ -150,6 +136,3 @@ def choose_for_lane(lane: str, body: dict) -> tuple[str, str]:
|
||||
return _route_chat(text, n) # chat + alles Unbekannte
|
||||
|
||||
|
||||
def choose_model(body: dict) -> tuple[str, str]:
|
||||
"""Rückwärtskompatibel: altes `model:auto` == chat-Lane."""
|
||||
return choose_for_lane("chat", body)
|
||||
|
||||
@@ -115,21 +115,3 @@ def load_policy() -> dict:
|
||||
return dict(_CACHE["policy"])
|
||||
|
||||
|
||||
def save_policy(patch: dict) -> dict:
|
||||
"""Validiert + persistiert atomar. Gibt die neue, vollständige Policy zurück."""
|
||||
clean = _coerce(patch) # wirft bei ungültigem Input
|
||||
with _LOCK:
|
||||
current = {**DEFAULTS, **_coerce_safe(_read_file()), **clean}
|
||||
POLICY_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = POLICY_PATH.with_suffix(".json.tmp")
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(current, f, ensure_ascii=False, indent=2)
|
||||
os.replace(tmp, POLICY_PATH)
|
||||
_CACHE["mtime"] = None # nächster load_policy() lädt frisch
|
||||
_CACHE["policy"] = None
|
||||
return current
|
||||
|
||||
|
||||
def policy_meta() -> dict:
|
||||
"""Für den UI-Editor: aktuelle Werte + Defaults (für „Zurücksetzen“) + Feld-Spezifikation."""
|
||||
return {"policy": load_policy(), "defaults": dict(DEFAULTS), "fields": FIELDS}
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
"""
|
||||
Per-Stage-Latenz-Metriken + Per-Turn-Trace für die Voice/Lucy-Pipeline.
|
||||
|
||||
Zwei Sichten auf dieselben Messungen:
|
||||
|
||||
1. **Rollende Stats** (`record_stage`/`Timer`/`get_metrics`) — avg/p50/p95/last je Stufe über die
|
||||
letzten N Messungen. Gut für Trends („Wie schnell ist STT im Schnitt?").
|
||||
2. **Per-Turn-Trace** (`TurnTrace`/`get_trace`) — jeder einzelne Chat-Turn als eigener Datensatz mit
|
||||
seinem Stufen-Breakdown. Nur so sieht man den EINEN langsamen Turn (der 30-s-Hänger), den ein
|
||||
Durchschnitt verschluckt. Das war der eigentliche Anlass (Telegram-/Voice-Hänger diagnostizieren).
|
||||
|
||||
**Was MC2 messen kann — und was nicht:** MC2 proxyt den Chat nur an Hermes (:8642). Die Stufen STT,
|
||||
Vision, Hirn-TTFT (Zeit bis zum ersten Inhalts-Token) und Generierung sind hier direkt messbar. Der
|
||||
**Gedächtnis-Abruf** dagegen läuft seit der Ablösung des Sidecars (07.08.2026) Hermes-intern — es
|
||||
gibt keinen Rückruf an MC2 mehr, also auch keine Messung; er steckt jetzt in der Hirn-Zeit. Ebenso die
|
||||
**Tool-Runden**: im Hermes-LLM-Loop ohne Callback an MC2 → unsichtbar, im „Generierung"-Bucket.
|
||||
|
||||
STT läuft als eigener HTTP-Request VOR dem Turn, ohne Turn-ID. Auf einem EIN-Nutzer-Gerät genügt eine
|
||||
schlanke Best-Effort-Korrelation: die zuletzt gemessene STT-Dauer wird global „geparkt" und vom
|
||||
nächsten Chat-Turn eingesammelt (mit Frist-Check). Kein Turn-ID-Durchreichen durch den Lucy-Client
|
||||
nötig.
|
||||
|
||||
In-Memory + thread-safe (keine Datei-I/O — Latenz-Telemetrie ist transient, Restart = Reset).
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from collections import deque
|
||||
|
||||
_LOCK = threading.Lock()
|
||||
_MAX = 200
|
||||
_STAGES: dict[str, deque] = {}
|
||||
_TURNS: deque = deque(maxlen=60) # letzte N vollständige Chat-Turns (Per-Turn-Trace)
|
||||
|
||||
# Bekannte Stufen (für stabile UI-Reihenfolge); unbekannte werden trotzdem erfasst.
|
||||
STAGES = ("stt", "vision", "chat_ttfb", "chat_first_content", "tts")
|
||||
|
||||
# Best-effort-Korrelation (Ein-Nutzer-Gerät): zuletzt gemessene STT-Dauer als
|
||||
# (ms, perf_counter-Zeitstempel). Der nächste Chat-Turn sammelt sie ein und leert sie.
|
||||
_PARKED: dict[str, tuple[float, float] | None] = {"stt": None}
|
||||
|
||||
|
||||
def record_stage(stage: str, ms: float) -> None:
|
||||
"""Eine gemessene Stage-Dauer (ms) verbuchen. No-op bei negativen Werten."""
|
||||
if ms is None or ms < 0:
|
||||
return
|
||||
with _LOCK:
|
||||
dq = _STAGES.get(stage)
|
||||
if dq is None:
|
||||
dq = _STAGES[stage] = deque(maxlen=_MAX)
|
||||
dq.append(float(ms))
|
||||
|
||||
|
||||
def park(kind: str, ms: float) -> None:
|
||||
"""Eine Messung, die NICHT im Chat-Request selbst passiert (STT läuft davor), global parken,
|
||||
damit der nächste Chat-Turn sie einsammeln kann."""
|
||||
if ms is None or ms < 0 or kind not in _PARKED:
|
||||
return
|
||||
with _LOCK:
|
||||
_PARKED[kind] = (float(ms), time.perf_counter())
|
||||
|
||||
|
||||
def _take_stt(max_age: float = 20.0) -> float | None:
|
||||
"""Geparkte STT-Dauer einsammeln, wenn frisch (STT liegt VOR dem Turn-Start)."""
|
||||
with _LOCK:
|
||||
v = _PARKED.get("stt")
|
||||
if v and (time.perf_counter() - v[1]) <= max_age:
|
||||
_PARKED["stt"] = None
|
||||
return round(v[0], 1)
|
||||
return None
|
||||
|
||||
|
||||
class Timer:
|
||||
"""Context-Manager: misst die verstrichene Zeit und verbucht sie auf `stage`.
|
||||
Funktioniert um `await`-Aufrufe herum (enter → await → exit)."""
|
||||
|
||||
def __init__(self, stage: str) -> None:
|
||||
self.stage = stage
|
||||
self._t0 = 0.0
|
||||
|
||||
def __enter__(self) -> "Timer":
|
||||
self._t0 = time.perf_counter()
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc) -> None:
|
||||
record_stage(self.stage, (time.perf_counter() - self._t0) * 1000.0)
|
||||
|
||||
|
||||
class TurnTrace:
|
||||
"""Ein Per-Turn-Trace für den Voice/Lucy-Chatpfad. In `voice.py` über die Dauer eines Chat-Turns
|
||||
gehalten; `commit()` schreibt den Datensatz in den Ringpuffer UND speist die rollenden Stats.
|
||||
|
||||
Balken-Stufen (zeitlich DISJUNKT, ergeben zusammen den Turn): stt · vision · hirn · gen."""
|
||||
|
||||
def __init__(self, session_id: str = "", kind: str = "voice") -> None:
|
||||
self.id = uuid.uuid4().hex[:8]
|
||||
self.ts = time.time()
|
||||
self.perf0 = time.perf_counter()
|
||||
self._brain0 = self.perf0 # Referenz für die Hirn-Zeit (nach Vision neu gesetzt)
|
||||
self.session_id = (session_id or "")[:24]
|
||||
self.kind = kind
|
||||
self.vision_ms: float | None = None # Bildschirm-Beschreibung (falls Bilder)
|
||||
self.hirn_ms: float | None = None # Zeit bis zum ersten Inhalts-Token (Agent + Gedächtnis + TTFT)
|
||||
self.had_images = False
|
||||
self.error: str | None = None
|
||||
|
||||
def note_vision(self, ms: float) -> None:
|
||||
self.vision_ms = round(ms, 1)
|
||||
record_stage("vision", ms)
|
||||
|
||||
def mark_brain_start(self) -> None:
|
||||
"""Startpunkt der Hirn-Zeit — direkt VOR dem Hermes-Request, damit die Vision-Zeit NICHT
|
||||
in die Hirn-Zeit gezählt wird (sonst Doppelzählung mit dem Vision-Segment)."""
|
||||
self._brain0 = time.perf_counter()
|
||||
|
||||
def note_ttfb(self) -> None:
|
||||
record_stage("chat_ttfb", (time.perf_counter() - self._brain0) * 1000.0) # SSE-Start (~5 ms), nur rollend
|
||||
|
||||
def note_first_content(self) -> None:
|
||||
"""Erster Inhalts-Delta = echte Hirn-Latenz (Agent-Overhead + Gedächtnis + LLM-TTFT)."""
|
||||
ms = (time.perf_counter() - self._brain0) * 1000.0
|
||||
self.hirn_ms = round(ms, 1)
|
||||
record_stage("chat_first_content", ms)
|
||||
|
||||
def commit(self) -> dict:
|
||||
total = (time.perf_counter() - self.perf0) * 1000.0
|
||||
stt = _take_stt() # rollend bereits in /voice/stt erfasst
|
||||
# Generierung = alles nach dem ersten Inhalts-Token bis Stream-Ende.
|
||||
gen = round(total - self.hirn_ms, 1) if self.hirn_ms is not None else None
|
||||
rec = {
|
||||
"id": self.id,
|
||||
"ts": round(self.ts, 3),
|
||||
"session": self.session_id,
|
||||
"kind": self.kind,
|
||||
"had_images": self.had_images,
|
||||
"stt_ms": stt,
|
||||
"vision_ms": self.vision_ms,
|
||||
"hirn_ms": self.hirn_ms,
|
||||
"gen_ms": gen if (gen is None or gen >= 0) else 0.0,
|
||||
"total_ms": round(total, 1),
|
||||
"error": self.error,
|
||||
}
|
||||
with _LOCK:
|
||||
_TURNS.append(rec)
|
||||
return rec
|
||||
|
||||
|
||||
def _summary(vals: list[float]) -> dict:
|
||||
if not vals:
|
||||
return {"count": 0}
|
||||
s = sorted(vals)
|
||||
n = len(s)
|
||||
return {
|
||||
"count": n,
|
||||
"avg_ms": round(sum(s) / n, 1),
|
||||
"p50_ms": round(s[n // 2], 1),
|
||||
"p95_ms": round(s[min(n - 1, int(n * 0.95))], 1),
|
||||
"last_ms": round(vals[-1], 1),
|
||||
}
|
||||
|
||||
|
||||
def get_metrics() -> dict:
|
||||
"""Rollende Zusammenfassung je Stufe."""
|
||||
with _LOCK:
|
||||
return {stage: _summary(list(dq)) for stage, dq in _STAGES.items()}
|
||||
|
||||
|
||||
def get_trace(limit: int = 20) -> list[dict]:
|
||||
"""Die letzten `limit` Chat-Turns (neueste zuerst) mit Stufen-Breakdown."""
|
||||
limit = max(1, min(int(limit or 20), _TURNS.maxlen or 60))
|
||||
with _LOCK:
|
||||
return list(_TURNS)[-limit:][::-1]
|
||||
@@ -480,8 +480,15 @@ def takt() -> None:
|
||||
continue # während eines Updates sind Neustarts normal
|
||||
try:
|
||||
befunde += pruefung()
|
||||
except Exception:
|
||||
log.debug("waechter: %s fehlgeschlagen", pruefung.__name__, exc_info=True)
|
||||
except Exception as exc:
|
||||
# Bis 24.09.2026 stand das nur im Debug-Log: Die Prüfung war dann still aus, und das
|
||||
# Cockpit blieb grün (z. B. wenn Hermes das Format seiner Job-Liste ändert).
|
||||
log.warning("waechter: %s fehlgeschlagen", pruefung.__name__, exc_info=True)
|
||||
befunde.append(Befund(
|
||||
id=f"pruefung:{pruefung.__name__}", stufe="gelb",
|
||||
titel="Eine Prüfung des Wächters lief nicht",
|
||||
text=f"{pruefung.__name__}: {exc.__class__.__name__}: {exc}"[:240],
|
||||
quelle="mc2-steward"))
|
||||
|
||||
with _lock:
|
||||
hinweise: dict = _stand["hinweise"]
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Herkunftsprüfung für Knöpfe (24.09.2026)."""
|
||||
|
||||
from services.herkunft import erlaubt
|
||||
|
||||
BOX = "192.168.178.151:9001"
|
||||
|
||||
|
||||
def test_eigene_seite_und_skripte_duerfen():
|
||||
assert erlaubt("POST", "/api/maintenance/os-update", "http://192.168.178.151:9001", BOX)
|
||||
assert erlaubt("POST", "/api/alarm", None, BOX) # Lucy-Watchdog, curl
|
||||
assert erlaubt("POST", "/api/voice/chat", "null", BOX) # Desktop-Lucy (Electron)
|
||||
assert erlaubt("POST", "/api/voice/stt", "file://", BOX)
|
||||
assert erlaubt("POST", "/api/system/restart", "http://localhost:5173", BOX) # Vite-Entwicklung
|
||||
|
||||
|
||||
def test_fremde_webseite_darf_keine_knoepfe_druecken():
|
||||
assert not erlaubt("POST", "/api/maintenance/update-all", "https://boese.example", BOX)
|
||||
assert not erlaubt("DELETE", "/api/models/x", "http://192.168.178.99:8080", BOX)
|
||||
|
||||
|
||||
def test_lesen_und_v1_bleiben_offen():
|
||||
assert erlaubt("GET", "/api/start", "https://boese.example", BOX)
|
||||
assert erlaubt("POST", "/v1/chat/completions", "http://localhost:3000", BOX)
|
||||
@@ -756,3 +756,22 @@ def test_immer_warme_gruppe_verdraengt_nichts(monkeypatch):
|
||||
assert cfg["models"]["h"]["ttl"] == 0
|
||||
llamaswap.set_group("andere", ["h"], swap=True, persist=False)
|
||||
assert "exclusive" not in cfg["groups"]["andere"]
|
||||
|
||||
|
||||
def test_hf_download_nimmt_nur_den_gewuenschten_quant(monkeypatch):
|
||||
"""24.09.2026: Fehlt der Quant, lädt der Download NICHTS (vorher: alle Teile aller Varianten)."""
|
||||
from services import hf
|
||||
|
||||
baum = [
|
||||
{"path": "Q8_0/Modell-Q8_0-00001-of-00002.gguf", "size": 50},
|
||||
{"path": "Q8_0/Modell-Q8_0-00002-of-00002.gguf", "size": 50},
|
||||
{"path": "Q4_K_M/Modell-Q4_K_M-00001-of-00002.gguf", "size": 30},
|
||||
{"path": "Q4_K_M/Modell-Q4_K_M-00002-of-00002.gguf", "size": 30},
|
||||
{"path": "mmproj-F16.gguf", "size": 5},
|
||||
]
|
||||
monkeypatch.setattr(hf, "_tree", lambda repo: baum)
|
||||
leer = hf.resolve_gguf("x/y", "IQ3_XXS")
|
||||
assert leer["first"] is None and leer["files"] == []
|
||||
q4 = hf.resolve_gguf("x/y", "Q4_K_M")
|
||||
assert q4["files"] == ["Q4_K_M/Modell-Q4_K_M-00001-of-00002.gguf", "Q4_K_M/Modell-Q4_K_M-00002-of-00002.gguf"]
|
||||
assert q4["total_bytes"] == 65 and q4["mmproj"] == "mmproj-F16.gguf"
|
||||
|
||||
@@ -120,3 +120,22 @@ def test_schlafende_dienste_sind_kein_befund(monkeypatch):
|
||||
assert "dienst:lucy-stimme" in ids
|
||||
assert waechter.schlaeft(zustaende["voice-service"]) is True
|
||||
assert waechter.schlaeft({"ActiveState": "failed", "UnitFileState": "disabled"}) is False
|
||||
|
||||
|
||||
def test_abgestuerzte_pruefung_wird_ein_gelber_befund(monkeypatch, tmp_path):
|
||||
"""24.09.2026: Eine Prüfung, die abstürzt, darf nicht still verschwinden (Cockpit blieb grün)."""
|
||||
def kaputt():
|
||||
raise ValueError("jobs.json hat ein neues Format")
|
||||
|
||||
kaputt.__name__ = "pruefe_hermes_jobs"
|
||||
monkeypatch.setattr(waechter, "PRUEFUNGEN", (kaputt,))
|
||||
monkeypatch.setattr(waechter, "STORE_PATH", tmp_path / "mc2-waechter.json")
|
||||
monkeypatch.setattr(waechter, "_update_laeuft", lambda: False)
|
||||
monkeypatch.setattr(waechter, "_telegram", lambda betreff, text: None)
|
||||
monkeypatch.setattr(waechter, "FAIL_AFTER", 1)
|
||||
monkeypatch.setattr(waechter, "_stand", {"hinweise": {}, "kandidaten": {}, "verlauf": [], "stand": None,
|
||||
"update_laeuft": False})
|
||||
waechter.takt()
|
||||
hinweise = waechter._stand["hinweise"]
|
||||
assert "pruefung:pruefe_hermes_jobs" in hinweise
|
||||
assert "neues Format" in hinweise["pruefung:pruefe_hermes_jobs"]["text"]
|
||||
|
||||
Reference in New Issue
Block a user