feat(v9): Phase 5 — Hermes-Web-Dashboard eingebettet, Eigenbau-Chat raus

Das Framework bringt eine fertige Web-UI mit (Chat mit Live-Tool-Aktivitaet,
Approval-Prompts, Settings, Sessions). Statt sie nachzubauen, betten wir sie ein:

- routers/hermes_ui.py: HTTP+WS-Reverse-Proxy auf das lokale Dashboard (:9119)
  unter /hermes-ui/ mit X-Forwarded-Prefix -> Dashboard rewritet Assets/Base-Path
  selbst, injiziert seinen Session-Token (kein zweiter Login). WS-Bruecke fuer
  pty/ws/pub/events.
- HermesPanel: Chat -> iframe auf /hermes-ui/; Eigenbau-Chat/Voice/WS entfernt.
  Cockpit + Setup bleiben. Loest damit Kontext-/Lern-/Tool-Sichtbarkeits-Themen,
  da die UI direkt mit dem Agent-Loop spricht (kein Proxy-Bug mehr).
- routers/hermes.py: Chat-WS + _proxy_chat entfernt (Cutover).
- config.py: HERMES_DASHBOARD_URL. requirements: websockets.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-24 16:47:52 +02:00
parent 40d9efee18
commit 09aabbb86e
8 changed files with 190 additions and 478 deletions
+8 -87
View File
@@ -1,17 +1,19 @@
"""
Hermes Router — Chat-WebSocket, Voice-Endpoints, Setup-Status.
Hermes Router — Voice-Endpoints, Status & Cockpit-Reads.
Der Chat selbst ist in das eingebettete Hermes-Web-Dashboard umgezogen
(siehe `routers/hermes_ui.py`); der fruehere Eigenbau-Chat-WS/Proxy entfiel.
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/status — Komponentenstatus (Hermes, Whisper, Piper, SSH)
GET /api/hermes/{agent,cron,skills} — Cockpit-Reads (Phase 4)
GET /api/hermes/pubkey — Bosgame SSH-Public-Key fuer Windows-Setup
"""
import asyncio
import hashlib
import json
import subprocess
import tempfile
import threading
@@ -19,14 +21,14 @@ from pathlib import Path
from typing import Optional
import httpx
from fastapi import APIRouter, Depends, UploadFile, File, WebSocket, WebSocketDisconnect
from fastapi import APIRouter, Depends, UploadFile, File
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,
HERMES_API_URL,
)
router = APIRouter(prefix="/api")
@@ -99,87 +101,6 @@ def _tts(text: str) -> Optional[bytes]:
# 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."""