This repository has been archived on 2026-07-22. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
mission-control/routers/hermes.py
T
Hitonabi 565994977f fix(v9): Hermes-Proxy non-streaming (Tool-Calls wurden im Stream geleakt)
Der Hermes-API-Server fuehrt im Streaming-Modus Tool-Calls nicht aus und
streamt rohe <function=…></tool_call>-Tokens als Text. Non-streaming
durchlaeuft den vollen Agent-Loop (Tools, Gedaechtnis). Proxy holt die
fertige Antwort und streamt sie wortweise selbst ans Frontend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 19:27:23 +02:00

283 lines
9.6 KiB
Python

"""
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.
WICHTIG: bewusst **non-streaming**. Im Streaming-Modus (`stream:true`) gibt
der Hermes-API-Server rohe Modell-Tokens aus und fuehrt Tool-Calls NICHT aus
(`<function=…></tool_call>` leakt in den Text). Non-streaming durchlaeuft den
vollen Agent-Loop (Tools, Gedaechtnis, Mehr-Schritt) und liefert die fertige
Antwort. Fuer fluessige Optik streamen wir sie wortweise selbst ans Frontend.
"""
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}],
}
timeout = httpx.Timeout(300.0, connect=10.0)
async with httpx.AsyncClient(timeout=timeout) as client:
r = await client.post(
f"{HERMES_API_URL}/chat/completions", headers=headers, json=body
)
if r.status_code != 200:
await websocket.send_json(
{"type": "error", "content": f"HTTP {r.status_code}: {r.text[:300]}"}
)
return
try:
content = r.json()["choices"][0]["message"].get("content", "") or ""
except Exception:
content = r.text
words = content.split(" ")
for i, word in enumerate(words):
token = word + (" " if i < len(words) - 1 else "")
await websocket.send_json({"type": "token", "content": token})
await asyncio.sleep(0.01)
@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()}