565994977f
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>
283 lines
9.6 KiB
Python
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()}
|