""" Hermes Router — Chat-WebSocket, Voice-Endpoints, Setup-Status. Endpunkte: WS /api/hermes/chat — Streaming Chat mit dem Hermes Agent POST /api/hermes/transcribe — Audio → Text (Whisper) POST /api/hermes/tts — Text → Audio WAV (Piper) GET /api/hermes/status — Komponentenstatus (Whisper, Piper, SSH) GET /api/hermes/pubkey — Bosgame SSH-Public-Key fuer Windows-Setup """ import asyncio import hashlib import json import subprocess import tempfile import threading from pathlib import Path from typing import Optional import httpx from fastapi import APIRouter, Depends, UploadFile, File, WebSocket, WebSocketDisconnect from fastapi.responses import Response, JSONResponse from auth import auth from config import ( PIPER_BIN, PIPER_VOICE, WHISPER_MODEL_SIZE, HERMES_WINDOWS_HOST, HERMES_WINDOWS_USER, HERMES_SSH_KEY, HERMES_API_URL, HERMES_API_KEY, ) router = APIRouter(prefix="/api") # --------------------------------------------------------------------------- # Whisper (lazy-loaded, einmalig in RAM) # --------------------------------------------------------------------------- _whisper_model = None _whisper_loading = False _whisper_lock = threading.Lock() def _get_whisper(): global _whisper_model, _whisper_loading with _whisper_lock: if _whisper_model is not None: return _whisper_model if _whisper_loading: return None _whisper_loading = True try: from faster_whisper import WhisperModel model = WhisperModel(WHISPER_MODEL_SIZE, device="cpu", compute_type="int8") with _whisper_lock: _whisper_model = model return model except Exception: return None finally: with _whisper_lock: _whisper_loading = False # --------------------------------------------------------------------------- # TTS-Cache (in-memory, max 64 Eintraege) # --------------------------------------------------------------------------- _tts_cache: dict[str, bytes] = {} def _tts(text: str) -> Optional[bytes]: key = hashlib.md5(text.encode()).hexdigest() if key in _tts_cache: return _tts_cache[key] if not PIPER_BIN.exists() or not PIPER_VOICE.exists(): return None with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: wav_path = f.name try: result = subprocess.run( [str(PIPER_BIN), "--model", str(PIPER_VOICE), "--output_file", wav_path], input=text, capture_output=True, text=True, timeout=30 ) if result.returncode != 0: print(f"[Piper] Fehler: {result.stderr or 'exit '+str(result.returncode)}") return None audio = Path(wav_path).read_bytes() if len(_tts_cache) >= 64: _tts_cache.pop(next(iter(_tts_cache))) _tts_cache[key] = audio return audio except Exception: return None finally: Path(wav_path).unlink(missing_ok=True) # --------------------------------------------------------------------------- # Endpoints # --------------------------------------------------------------------------- @router.websocket("/hermes/chat") async def hermes_chat(websocket: WebSocket): """Streaming-Chat: proxyt zum Hermes-Agent-Server (:8642, OpenAI-kompatibel). Behaelt den WS-Contract des Frontends bei (thinking/token/done/error), damit die HermesPanel unveraendert bleibt. Modell-Wahl, Tools, Gedaechtnis und Mehr-Schritt-Logik macht der Hermes-Agent selbst. """ # Manuelle Token-Auth (WS kann keine HTTP-Header senden) from auth import TOKEN token_param = websocket.query_params.get("token", "") if TOKEN and token_param != TOKEN: await websocket.close(code=4001) return await websocket.accept() try: while True: raw = await websocket.receive_text() try: payload = json.loads(raw) message = payload.get("message", "").strip() except Exception: message = raw.strip() if not message: continue await websocket.send_json({"type": "thinking"}) try: await _proxy_chat(message, websocket) except Exception as exc: await websocket.send_json( {"type": "error", "content": f"Hermes nicht erreichbar: {exc}"} ) await websocket.send_json({"type": "done"}) except WebSocketDisconnect: pass except Exception: pass async def _proxy_chat(message: str, websocket: WebSocket) -> None: """Eine Antwort vom Hermes-Agent-Server holen und Tokens ans WS relayen. Unterstuetzt sowohl SSE-Streaming als auch eine nicht-streamende JSON-Antwort — in beiden Faellen genau EIN Agent-Lauf (kein Doppel-Call). """ headers = {"Content-Type": "application/json"} if HERMES_API_KEY: headers["Authorization"] = f"Bearer {HERMES_API_KEY}" body = { "model": "hermes-agent", "messages": [{"role": "user", "content": message}], "stream": True, } timeout = httpx.Timeout(300.0, connect=10.0) async with httpx.AsyncClient(timeout=timeout) as client: async with client.stream( "POST", f"{HERMES_API_URL}/chat/completions", headers=headers, json=body ) as resp: if resp.status_code != 200: detail = (await resp.aread()).decode(errors="replace")[:300] await websocket.send_json( {"type": "error", "content": f"HTTP {resp.status_code}: {detail}"} ) return if "text/event-stream" in resp.headers.get("content-type", ""): async for line in resp.aiter_lines(): if not line.startswith("data:"): continue data = line[5:].strip() if data == "[DONE]": break try: delta = json.loads(data)["choices"][0].get("delta", {}) piece = delta.get("content") or "" except Exception: piece = "" if piece: await websocket.send_json({"type": "token", "content": piece}) else: # Server streamt nicht → ganze JSON-Antwort als ein Token senden raw = await resp.aread() try: d = json.loads(raw) content = d["choices"][0]["message"].get("content", "") or "" except Exception: content = raw.decode(errors="replace") if content: await websocket.send_json({"type": "token", "content": content}) @router.post("/hermes/transcribe", dependencies=[Depends(auth)]) async def transcribe_audio(audio: UploadFile = File(...)): """Audio-Datei (webm/ogg/wav) per Whisper transkribieren.""" model = _get_whisper() if model is None: return JSONResponse( status_code=503, content={"error": "Whisper nicht verfuegbar. Bitte faster-whisper installieren."} ) with tempfile.NamedTemporaryFile(suffix=".webm", delete=False) as f: tmp = Path(f.name) tmp.write_bytes(await audio.read()) try: segments, _ = await asyncio.get_running_loop().run_in_executor( None, lambda: model.transcribe(str(tmp), language="de") ) text = " ".join(s.text.strip() for s in segments).strip() return {"text": text} except Exception as exc: return JSONResponse(status_code=500, content={"error": str(exc)}) finally: tmp.unlink(missing_ok=True) @router.post("/hermes/tts", dependencies=[Depends(auth)]) async def text_to_speech(body: dict): """Text zu WAV-Audio per Piper TTS konvertieren.""" text = (body.get("text") or "").strip() if not text: return JSONResponse(status_code=400, content={"error": "Kein Text angegeben."}) audio = await asyncio.get_running_loop().run_in_executor(None, lambda: _tts(text)) if audio is None: return JSONResponse( status_code=503, content={"error": "Piper TTS nicht verfuegbar. Bitte Piper Binary + Stimme installieren."} ) return Response(content=audio, media_type="audio/wav") @router.get("/hermes/status", dependencies=[Depends(auth)]) def hermes_status(): """Status aller Hermes-Komponenten.""" # Whisper try: from faster_whisper import WhisperModel # noqa whisper = "ready" if _whisper_model else "installed" except ImportError: whisper = "not_installed" # Piper piper = "ready" if (PIPER_BIN.exists() and PIPER_VOICE.exists()) else "not_installed" # SSH ssh_configured = bool(HERMES_WINDOWS_HOST) and HERMES_SSH_KEY.exists() ssh_ok = False if ssh_configured: try: import paramiko c = paramiko.SSHClient() c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) c.connect( HERMES_WINDOWS_HOST, username=HERMES_WINDOWS_USER, key_filename=str(HERMES_SSH_KEY), timeout=3, ) c.close() ssh_ok = True except Exception: ssh_ok = False # Hermes-Agent-Server (:8642) — erreichbar? try: httpx.get(HERMES_API_URL.rsplit("/v1", 1)[0] + "/", timeout=2) hermes = "ready" except Exception: hermes = "offline" return { "hermes": hermes, "whisper": whisper, "piper": piper, "ssh_configured": ssh_configured, "ssh_ok": ssh_ok, "windows_host": HERMES_WINDOWS_HOST or None, } @router.get("/hermes/pubkey", dependencies=[Depends(auth)]) def hermes_pubkey(): """Gibt den SSH-Public-Key des Hermes Agent zurueck (fuer Windows authorized_keys).""" pub = Path(str(HERMES_SSH_KEY) + ".pub") if not pub.exists(): return JSONResponse( status_code=404, content={"error": "Kein SSH-Key gefunden. Bitte auf dem Bosgame generieren: " "ssh-keygen -t ed25519 -C 'hermes-agent@bosgame' " f"-f {HERMES_SSH_KEY} -N ''"} ) return {"pubkey": pub.read_text().strip()}