dcfede7e69
Paket B des Plans. AnythingLLM war nur ein Fallback-Chat zu Hermes; ersetzt durch das echte interaktive Agent-Terminal (`hermes chat`, mit Tools/PC) als eingebettetes Web-Terminal. Box: ttyd (apt) wrappt `hermes chat`; systemd-User-Unit deploy/hermes-terminal.service (LAN-Bind eno1:7681, apt-Default-ttyd-Dienst deaktiviert). In deploy.sh verankert. Backend: config HERMES_TERMINAL_URL statt ANYTHINGLLM_URL/_REPO; agent_status liefert terminal_url/terminal_reachable; maintenance ohne _anythingllm_update; system.py Dienst-Liste zeigt "Hermes-Terminal". Frontend: neue Terminal-Seite (iframe auf ttyd) + Nav-Tab; AgentView/AgentStatusCard/nav/api auf Terminal umgestellt; SystemDrawer toter hermes-dashboard raus, hermes-webui -> hermes-terminal; Guide-Texte aktualisiert. Cleanup: deploy/hermes-webui.service + deploy/lobechat/ entfernt (LobeChat-Migration hinfaellig), HERMES_WEBUI_URL-Env raus. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
250 lines
10 KiB
Python
250 lines
10 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_URL, 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 hermes_brain_info() -> dict:
|
|
"""Aktuelles Agent-Hirn (hermes-Rolle) + bestes verfügbares NousResearch-Hermes-Modell,
|
|
das auf diese Hardware passt. Für den Modell-Manager: Brain sichtbar + updatebar,
|
|
sobald NousResearch eine neuere Hermes-Generation veröffentlicht."""
|
|
from services import discover, llamaswap
|
|
from services.fit import evaluate_fit, extract_params_b
|
|
|
|
models = llamaswap.list_models()
|
|
cur = next((m for m in models if m.get("role") == "hermes"), None)
|
|
cur_ver = _hermes_version(cur["name"]) if cur else None
|
|
cur_params = (cur.get("capabilities") or {}).get("params_b") if cur else None
|
|
current = None
|
|
if cur:
|
|
current = {"name": cur["name"], "filename": cur.get("filename"),
|
|
"params_b": cur_params, "quant": cur.get("quant"),
|
|
"size_bytes": cur.get("size_bytes"), "version": cur_ver,
|
|
"gguf_path": cur.get("gguf_path"), "incomplete": cur.get("incomplete")}
|
|
|
|
ram = psutil.virtual_memory().total / (1024 ** 3)
|
|
best = None
|
|
try:
|
|
cands = []
|
|
for r in discover._fetch_author_models("NousResearch"):
|
|
rid = r.get("id", "")
|
|
if "hermes" not in rid.lower():
|
|
continue
|
|
pb = extract_params_b(rid)
|
|
fit = evaluate_fit(pb, "Q4_K_M", 8192, ram, name=rid)
|
|
if fit["level"] == "too_tight":
|
|
continue
|
|
cands.append({"repo": rid, "name": rid.split("/")[-1],
|
|
"version": _hermes_version(rid) or 0.0, "params_b": pb,
|
|
"downloads": int(r.get("downloads") or 0), "fit": fit})
|
|
# neueste Hermes-Version zuerst, dann größer/fähiger, dann beliebter
|
|
cands.sort(key=lambda c: (c["version"], c["params_b"], c["downloads"]), reverse=True)
|
|
best = cands[0] if cands else None
|
|
except Exception:
|
|
log.debug("hermes_brain_info: HF-Abfrage fehlgeschlagen", exc_info=True)
|
|
|
|
update = False
|
|
if best is not None:
|
|
if cur_ver is None:
|
|
update = True
|
|
elif best["version"] > cur_ver:
|
|
update = True
|
|
elif best["version"] == cur_ver and best["params_b"] > (cur_params or 0) * 1.05:
|
|
update = True
|
|
# gleiche Datei schon installiert? dann kein Update
|
|
if current and best["repo"].split("/")[-1].lower() in (current["name"] or "").lower():
|
|
update = False
|
|
|
|
# Fit-Check: passt das EMPFOHLENE Brain als Always-On noch ins Budget, sodass das
|
|
# größte on-demand-Modell daneben lädt? (Brain muss immer resident sein.)
|
|
budget = None
|
|
try:
|
|
from services.budget import footprint_gb, gtt_budget_gb
|
|
from services.fit import estimate_memory_gb
|
|
groups = llamaswap.list_groups()
|
|
persist = set()
|
|
for g in groups.values():
|
|
if isinstance(g, dict) and g.get("persist"):
|
|
persist.update(g.get("members") or [])
|
|
|
|
cur_name = cur["name"] if cur else None
|
|
brain_ctx = int((cur.get("ctx") if cur else None) or 32768)
|
|
if best:
|
|
brain_gb = estimate_memory_gb(float(best["params_b"]), "Q4_K_M", brain_ctx)
|
|
elif cur:
|
|
brain_gb = footprint_gb(cur)
|
|
else:
|
|
brain_gb = 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()
|
|
# Brain muss immer resident sein → passt Brain + größtes on-demand zusammen?
|
|
# (fast/vision dürfen beim Laden eines großen Modells verdrängt werden.)
|
|
budget = {
|
|
"gtt_gb": gtt,
|
|
"brain_gb": round(brain_gb, 1),
|
|
"warm_projected_gb": round(warm, 1),
|
|
"largest_ondemand_gb": round(largest_od, 1),
|
|
"fits": (brain_gb + largest_od) <= gtt,
|
|
"free_after_gb": round(gtt - brain_gb - largest_od, 1),
|
|
}
|
|
except Exception:
|
|
log.debug("hermes_brain_info: Budget-Berechnung fehlgeschlagen", exc_info=True)
|
|
|
|
return {"current": current, "recommended": best, "update_available": update, "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):
|
|
brain_model = cfg.get("model", {}).get("model", "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`), eingebettet in MC2.
|
|
"terminal_url": HERMES_TERMINAL_URL,
|
|
"gateway_reachable": _reach(HERMES_API_URL, "/health"),
|
|
"terminal_reachable": _reach(HERMES_TERMINAL_URL, "/"),
|
|
"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:
|
|
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
|
|
except PermissionError as exc:
|
|
return {"ok": False, "reason": str(exc)}
|
|
update_brain_model("hermes") # 3) Config + Restart
|
|
return {"ok": True, "old": old, "new": model_id}
|
|
|
|
|
|
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"] = {}
|
|
|
|
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
|