3a6e3c5432
Statt des ttyd-CLI-Terminals zeigt der Tab jetzt die reiche eingebaute Hermes-Web-GUI (hermes serve/dashboard: Threads, sichtbare Tool-Calls, Sessions, Config) - same-origin, ohne offenen Port, ohne Login (Loopback :9119 = LAN-Trust wie Terminal/Konsole). - backend/routers/hermes_ui.py: generischer Reverse-Proxy /hermes-ui/* (alle Methoden + beliebige WebSockets) -> 127.0.0.1:9119; injiziert window.__HERMES_BASE_PATH__=/hermes-ui in die index.html, damit die SPA ihre API/WS-Calls unter dem Praefix absetzt (sonst Kollision mit MC2s /api). - deploy/hermes-builtin-ui.service: hermes serve --skip-build auf Loopback :9119. - deploy/deploy.sh: baut das GUI-Bundle mit Vite-base=/hermes-ui/ (Temp->Swap, ueberlebt Hermes- Updates) + installiert/startet den Dienst. - backend/app.py + config.py + services/agent.py: Router registriert, Upstream/Pfad + Status-Feld. - frontend: Terminal-Tab -> "Hermes GUI" (TerminalView iframe /hermes-ui/, nav-Label, vite-Dev-Proxy). E2E im Browser verifiziert: GUI rendert eingebettet, /hermes-ui/api/* liefern echte Daten (289 Sessions, verbundene Plattformen), Assets + Plugins laden, Basis-Pfad korrekt. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
265 lines
12 KiB
Python
265 lines
12 KiB
Python
"""
|
|
Hermes-Agent-Status (Control-Plane-Read). MC betreibt Hermes NICHT — es zeigt nur
|
|
Status + verlinkt das standalone hermes-webui. Voller Zugriff + Tools/MCP werden in
|
|
Hermes' eigener Config verdrahtet (siehe docs/HERMES_SETUP.md).
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
import re
|
|
|
|
import httpx
|
|
import psutil
|
|
|
|
from config import (HERMES_TERMINAL_UPSTREAM, HERMES_TERMINAL_PATH, BOX_CONSOLE_UPSTREAM,
|
|
BOX_CONSOLE_PATH, HERMES_BUILTIN_UI_UPSTREAM, HERMES_BUILTIN_UI_PATH,
|
|
HERMES_API_URL, 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:
|
|
from ruamel.yaml import YAML
|
|
p = HERMES_HOME / "config.yaml"
|
|
if p.exists():
|
|
with p.open(encoding="utf-8") as f:
|
|
cfg = YAML().load(f) or {}
|
|
m = (cfg.get("model") or {}) if isinstance(cfg, dict) else {}
|
|
return str(m.get("default") or m.get("model") or "auto")
|
|
except Exception:
|
|
log.debug("_active_brain_name: Lesefehler", exc_info=True)
|
|
return "auto"
|
|
|
|
|
|
def hermes_brain_info() -> dict:
|
|
"""Aktuelles Agent-Hirn = Modell/Alias, das Hermes laut Config nutzt (model.default),
|
|
plus Budget-Check. Zeigt das REAL genutzte Hirn — unabhängig von einer 'hermes'-Rolle."""
|
|
from services import llamaswap
|
|
|
|
models = llamaswap.list_models()
|
|
brain = _active_brain_name() # z.B. "fast" (Alias) oder ein Modellname
|
|
bl = brain.lower()
|
|
cur = next((m for m in models if (m.get("role") or "").lower() == bl), None) \
|
|
or next((m for m in models if bl in (m["name"] or "").lower()), None)
|
|
cur_params = (cur.get("capabilities") or {}).get("params_b") if cur else None
|
|
current = None
|
|
if cur:
|
|
current = {"name": cur["name"], "alias": brain, "filename": cur.get("filename"),
|
|
"params_b": cur_params, "quant": cur.get("quant"),
|
|
"size_bytes": cur.get("size_bytes"),
|
|
"gguf_path": cur.get("gguf_path"), "incomplete": cur.get("incomplete")}
|
|
|
|
# Fit-Check: passt das (immer warme) Hirn + das größte on-demand-Modell zusammen ins Budget?
|
|
budget = None
|
|
try:
|
|
from services.budget import footprint_gb, gtt_budget_gb
|
|
groups = llamaswap.list_groups()
|
|
persist = set()
|
|
for g in groups.values():
|
|
if isinstance(g, dict) and (g.get("persist") or g.get("persistent")):
|
|
persist.update(g.get("members") or [])
|
|
|
|
cur_name = cur["name"] if cur else None
|
|
brain_gb = footprint_gb(cur) if cur else 0.0
|
|
# voller Always-Warm-Footprint (alle persist, Brain=Empfehlung) — nur Info
|
|
warm = brain_gb + sum(footprint_gb(m) for m in models
|
|
if m["name"] in persist and m["name"] != cur_name)
|
|
largest_od = max((footprint_gb(m) for m in models if m["name"] not in persist), default=0.0)
|
|
gtt = gtt_budget_gb()
|
|
# Seit dem `persistent`-Fix (03.07.) bleibt das GANZE Warmset (Hirn+embed+vision)
|
|
# resident, wenn ein on-demand-Modell DANEBEN lädt → der reale Peak ist Warmset +
|
|
# größtes on-demand, nicht nur Hirn + größtes. Genau daran wird `fits` gemessen.
|
|
budget = {
|
|
"gtt_gb": gtt,
|
|
"brain_gb": round(brain_gb, 1),
|
|
"warm_projected_gb": round(warm, 1),
|
|
"largest_ondemand_gb": round(largest_od, 1),
|
|
"fits": (warm + largest_od) <= gtt,
|
|
"free_after_gb": round(gtt - warm - largest_od, 1),
|
|
}
|
|
except Exception:
|
|
log.debug("hermes_brain_info: Budget-Berechnung fehlgeschlagen", exc_info=True)
|
|
|
|
return {"current": current, "recommended": None, "update_available": False, "budget": budget}
|
|
|
|
|
|
def _reach(url: str, path: str = "") -> bool:
|
|
try:
|
|
with httpx.Client(timeout=3.0) as c:
|
|
return c.get(f"{url}{path}").status_code < 500
|
|
except httpx.HTTPError:
|
|
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)
|
|
|
|
|
|
return {
|
|
"gateway_url": HERMES_API_URL,
|
|
# Interaktives Web-Terminal (ttyd → `hermes chat`): same-origin über MC2 geproxyt.
|
|
"terminal_url": HERMES_TERMINAL_PATH,
|
|
# 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/).
|
|
"hermes_ui_url": HERMES_BUILTIN_UI_PATH,
|
|
"gateway_reachable": _reach(HERMES_API_URL, "/health"),
|
|
# Erreichbarkeit der lokalen ttyd-Upstreams (Loopback, je base-path).
|
|
"terminal_reachable": _reach(HERMES_TERMINAL_UPSTREAM, "/hermes-terminal/"),
|
|
"box_console_reachable": _reach(BOX_CONSOLE_UPSTREAM, "/console/"),
|
|
# Erreichbarkeit der eingebauten Hermes-GUI (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"),
|
|
}
|
|
|
|
|
|
def set_agent_brain(model_id: str) -> dict:
|
|
"""Setzt ein (bereits installiertes) Modell als Agent-Hirn — WARM-bewusst:
|
|
1) vergibt den 'hermes'-Alias (das Agent-Hirn-Slot),
|
|
2) tauscht es in die residente brains-Gruppe (altes Hirn raus, fast/vision bleiben),
|
|
3) zeigt die Hermes-Config auf den 'hermes'-Alias + Gateway-Restart.
|
|
So bleibt das neue Hirn warm und der Agent nutzt es sofort."""
|
|
from services import llamaswap
|
|
models = {m["name"]: m for m in llamaswap.list_models()}
|
|
if model_id not in models:
|
|
return {"ok": False, "reason": "Modell nicht installiert — erst über Modelle-finden laden."}
|
|
old = next((m["name"] for m in models.values() if m.get("role") == "hermes"), None)
|
|
if model_id == old:
|
|
# Idempotent härten: auch wenn schon Hirn, warm (brains) + ttl 0 sicherstellen.
|
|
try:
|
|
from services.llamaswap import set_ttl
|
|
brains = (llamaswap.list_groups().get("brains") or {}).get("members") or []
|
|
if model_id not in brains:
|
|
llamaswap.set_group("brains", brains + [model_id], swap=False, persist=True)
|
|
set_ttl(model_id, 0)
|
|
except PermissionError as exc:
|
|
return {"ok": False, "reason": str(exc)}
|
|
return {"ok": True, "old": old, "new": model_id, "note": "ist bereits das Agent-Hirn"}
|
|
try:
|
|
llamaswap.set_role(model_id, "hermes") # 1) Alias
|
|
brains = (llamaswap.list_groups().get("brains") or {}).get("members") or []
|
|
new_members = [x for x in brains if x not in (old, model_id)] + [model_id]
|
|
llamaswap.set_group("brains", new_members, swap=False, persist=True) # 2) warm
|
|
# 2b) TTL härten: neues Hirn nie auto-entladen; altes Hirn auf Default entspannen.
|
|
from services.llamaswap import set_ttl, DEFAULT_TTL
|
|
set_ttl(model_id, 0)
|
|
if old:
|
|
set_ttl(old, DEFAULT_TTL)
|
|
except PermissionError as exc:
|
|
return {"ok": False, "reason": str(exc)}
|
|
update_brain_model("hermes") # 3) Config + Restart
|
|
# Weiche Budget-Warnung (kein Hard-Block): passt Hirn + größtes on-demand zusammen ins GTT?
|
|
warning = None
|
|
try:
|
|
b = hermes_brain_info().get("budget") or {}
|
|
if b and not b.get("fits", True):
|
|
warning = (f"Speicher-Warnung: Hirn (~{b.get('brain_gb')} GB) + größtes on-demand-"
|
|
f"Modell (~{b.get('largest_ondemand_gb')} GB) übersteigen das GTT-Budget "
|
|
f"(~{b.get('gtt_gb')} GB) — das Hirn ist persistent, heavy/coder laden "
|
|
f"DANEBEN: Überlauf droht (Lade-Crash/Swapping statt Verdrängung).")
|
|
except Exception:
|
|
log.debug("set_agent_brain: Budget-Check fehlgeschlagen", exc_info=True)
|
|
return {"ok": True, "old": old, "new": model_id, "warning": warning}
|
|
|
|
|
|
def update_brain_model(new_model: str) -> bool:
|
|
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 = {}
|
|
|
|
if not isinstance(cfg, dict):
|
|
cfg = {}
|
|
|
|
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).
|
|
cfg["model"]["default"] = new_model
|
|
cfg["model"]["model"] = new_model
|
|
|
|
try:
|
|
from ruamel.yaml import YAML
|
|
r_yaml = YAML()
|
|
with config_path.open("w", encoding="utf-8") as f:
|
|
r_yaml.dump(cfg, f)
|
|
|
|
# Restart the user-space service to apply changes
|
|
try:
|
|
import services.maintenance as maintenance
|
|
maintenance.restart_service("hermes-gateway")
|
|
except Exception:
|
|
log.warning("update_brain_model: hermes-gateway-Restart fehlgeschlagen", exc_info=True)
|
|
|
|
return True
|
|
except Exception:
|
|
log.warning("update_brain_model: Schreiben der config.yaml fehlgeschlagen", exc_info=True)
|
|
return False
|