3679465956
Lokaler KI-Assistent auf dem Bosgame: Text + Sprache, Tool Calling, WebSocket-Streaming. Steuerzentrale des Agentic OS. - hermes_agent.py: ReAct-Agent mit Tool-Set (read_file, list_dir, run_command, system_status, memory r/w, web_search) - routers/hermes.py: WS /chat, POST /transcribe (Whisper), POST /tts (Piper), GET /status, GET /pubkey - HermesPanel.svelte: Chat-UI mit Token-Streaming, Tool-Anzeige, Mikrofon-Button (MediaRecorder), Setup-Wizard (Windows SSH) - Modell-Routing: scout fuer einfache Tasks, coder fuer komplexe - HERMES_* Env-Vars in config.py Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
223 lines
7.2 KiB
Python
223 lines
7.2 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
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
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_SSH_KEY,
|
|
)
|
|
from hermes_agent import run_agent
|
|
|
|
router = APIRouter(prefix="/api")
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Whisper (lazy-loaded, einmalig in RAM)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_whisper_model = None
|
|
_whisper_loading = False
|
|
|
|
|
|
def _get_whisper():
|
|
global _whisper_model, _whisper_loading
|
|
if _whisper_model is not None:
|
|
return _whisper_model
|
|
if _whisper_loading:
|
|
return None
|
|
_whisper_loading = True
|
|
try:
|
|
from faster_whisper import WhisperModel
|
|
_whisper_model = WhisperModel(WHISPER_MODEL_SIZE, device="cpu", compute_type="int8")
|
|
except Exception:
|
|
_whisper_model = None
|
|
finally:
|
|
_whisper_loading = False
|
|
return _whisper_model
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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:
|
|
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 mit dem Hermes Agent via WebSocket."""
|
|
# 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
|
|
|
|
async def send(obj: dict) -> None:
|
|
await websocket.send_json(obj)
|
|
|
|
await run_agent(message, send)
|
|
|
|
except WebSocketDisconnect:
|
|
pass
|
|
except Exception as exc:
|
|
try:
|
|
await websocket.send_json({"type": "error", "content": str(exc)})
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
@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_event_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_event_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=__import__("config").HERMES_WINDOWS_USER,
|
|
key_filename=str(HERMES_SSH_KEY),
|
|
timeout=3,
|
|
)
|
|
c.close()
|
|
ssh_ok = True
|
|
except Exception:
|
|
ssh_ok = False
|
|
|
|
return {
|
|
"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()}
|