8e7ce1b1d3
Voll-Duplex Sprach-Interaktion vom lokalen PC mit dem vollen Hermes-Agenten (api_server :8642, OpenAI-kompatibel → gleiche Tools + geteiltes Mem0 wie CLI/Telegram). - Voice-Sidecar (voice_service/, eigenes Py3.12-venv ~/.voice, :8650): STT faster-whisper (medium, de) + gestuftes TTS — Piper (schnell, Default) + Chatterbox (premium, Voice-Cloning, lazy-load, CPU-Start). Analog mem0_service. - Backend: routers/voice.py (Proxy /api/voice/stt|tts|voices + /chat-SSE an Hermes mit Bearer API_SERVER_KEY + X-Hermes-Session-Id für server-seitigen Verlauf). config.py: VOICE_SERVICE_URL + HERMES_API_KEY (Fallback aus ~/.hermes/.env). System-Dienstliste + Wartung (Restart/Logs) um voice-service ergänzt. - Frontend: Sprechen-Tab mit 3D-Avatar (VRM via three-vrm) — Lippensync (Web-Audio-Pegel), Blinzeln, Sentiment-Mimik, Ruhepose. Avatar-Picker (CORS-freie Galerie + .vrm-Upload + URL + VRoid-Hub-Link) + Stimm-Auswahl. Push-to-talk (Knopf/Leertaste). Deps: three, r3f, drei. - Deploy: deploy/voice-service.service + deploy.sh (idempotenter Sidecar-Install, enable, restart). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
129 lines
4.5 KiB
Python
129 lines
4.5 KiB
Python
"""System-Endpoints: Live-Status + Wartung (Restart/Self-Update — auf der Box).
|
|
|
|
Wartung läuft als systemd-USER-Dienst → KEIN sudo/Passwort (Nordstern).
|
|
Lokal (Windows) schlagen die Shell-Befehle harmlos fehl und werden als Fehler
|
|
zurückgegeben statt zu crashen.
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
import subprocess
|
|
|
|
from fastapi import APIRouter, HTTPException
|
|
from pydantic import BaseModel
|
|
|
|
import httpx
|
|
|
|
from config import GATEWAY_URL, HERMES_API_URL, LLAMA_SWAP_URL, MEM0_SERVICE_URL, VOICE_SERVICE_URL
|
|
from services import backup as backup_svc
|
|
from services.agent import agent_status
|
|
from services.gateway import gateway_reachable
|
|
from services.llamaswap import engine_reachable, list_models
|
|
from services.pricing import compute_savings
|
|
from services.system import system_status
|
|
from services.token_stats import get_stats
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/api")
|
|
|
|
# Nur diese User-Dienste dürfen neugestartet werden.
|
|
ALLOWED_SERVICES = {"mission-control-2", "hermes-gateway", "hermes-webui", "mem0-service", "voice-service"}
|
|
# 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()
|
|
|
|
|
|
def _mem0_reachable() -> bool:
|
|
try:
|
|
return httpx.get(f"{MEM0_SERVICE_URL}/health", timeout=2).status_code == 200
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _voice_reachable() -> bool:
|
|
try:
|
|
return httpx.get(f"{VOICE_SERVICE_URL}/health", timeout=2).status_code == 200
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
@router.get("/system/services")
|
|
def services() -> dict:
|
|
"""Aggregierte Erreichbarkeit aller Stack-Dienste (für die Health-Anzeige)."""
|
|
a = agent_status()
|
|
gw_url = f"{GATEWAY_URL}/v1"
|
|
return {
|
|
"services": [
|
|
{"name": "Engine (llama-swap)", "url": LLAMA_SWAP_URL, "ok": engine_reachable()},
|
|
{"name": "Gateway (integriert)", "url": gw_url, "ok": gateway_reachable()},
|
|
{"name": "Hermes-Gateway", "url": HERMES_API_URL, "ok": a["gateway_reachable"]},
|
|
{"name": "Hermes-Terminal", "url": a["terminal_url"], "ok": a["terminal_reachable"]},
|
|
{"name": "Mem0 (Gedächtnis)", "url": MEM0_SERVICE_URL, "ok": _mem0_reachable()},
|
|
{"name": "Voice (STT/TTS)", "url": VOICE_SERVICE_URL, "ok": _voice_reachable()},
|
|
],
|
|
"links": {
|
|
"engine_ui": f"{LLAMA_SWAP_URL}/ui",
|
|
"gateway": gw_url,
|
|
"hermes_terminal": a["terminal_url"],
|
|
},
|
|
}
|
|
|
|
|
|
@router.post("/system/backup")
|
|
def backup() -> dict:
|
|
return backup_svc.backup_now()
|
|
|
|
|
|
@router.get("/system/backups")
|
|
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: # noqa: BLE001
|
|
return {"ok": False, "code": -1, "out": "", "err": str(exc)}
|
|
|
|
|
|
class RestartReq(BaseModel):
|
|
service: str
|
|
|
|
|
|
@router.post("/system/restart")
|
|
def restart(req: RestartReq) -> dict:
|
|
if req.service not in ALLOWED_SERVICES:
|
|
raise HTTPException(400, f"Dienst '{req.service}' nicht erlaubt.")
|
|
return _run(["systemctl", "--user", "restart", 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)."""
|
|
# Rolle je Modell/Alias (lowercase) für die Tarif-Auflösung auflösen.
|
|
role_map: dict[str, str | None] = {}
|
|
try:
|
|
for m in list_models():
|
|
role_map[m["name"].lower()] = m.get("role")
|
|
for alias in m.get("aliases", []):
|
|
role_map[alias.lower()] = m.get("role")
|
|
except Exception:
|
|
log.warning("token_stats: list_models fehlgeschlagen, Tarife per Name", exc_info=True)
|
|
return compute_savings(get_stats(), role_map)
|