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."""
+117
View File
@@ -0,0 +1,117 @@
"""
Hermes-UI Reverse-Proxy — bettet das Hermes-Web-Dashboard in Mission Control ein.
Das Hermes-Agent-Framework bringt eine fertige Web-UI mit (Chat mit Live-Tool-
Aktivitaet, Approval-Prompts, Settings, Sessions). Sie laeuft als eigener Dienst
lokal auf der Box (`hermes dashboard`, Port 9119, an 127.0.0.1 gebunden). MC
proxyt sie unter `/hermes-ui/` und bettet sie per iframe ein:
- Das Dashboard ist explizit fuer Prefix-Reverse-Proxy gebaut: wir setzen
`X-Forwarded-Prefix: /hermes-ui`, dann rewritet es index.html/CSS/Asset-URLs
und seinen SPA-Base-Path selbst.
- Auf Loopback injiziert das Dashboard seinen eigenen Session-Token in die SPA
-> kein zweiter Login. MC bleibt das eine Gateway (LAN-only).
Damit faellt der fruehere Eigenbau-Chat (Proxy auf :8642) weg.
"""
import asyncio
import httpx
import websockets
from fastapi import APIRouter, Request, WebSocket
from fastapi.responses import RedirectResponse, Response
from starlette.websockets import WebSocketState
from config import HERMES_DASHBOARD_URL
router = APIRouter()
PREFIX = "/hermes-ui"
_WS_BASE = HERMES_DASHBOARD_URL.replace("http://", "ws://").replace("https://", "wss://")
# Hop-by-hop-Header, die ein Proxy nicht weiterreichen darf (RFC 7230) plus solche,
# die httpx/Starlette selbst neu berechnen (Laenge/Encoding).
_HOP = {
"host", "content-length", "connection", "keep-alive", "transfer-encoding",
"content-encoding", "upgrade", "proxy-authenticate", "proxy-authorization",
"te", "trailers",
}
_METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"]
@router.get(PREFIX)
def _hermes_ui_root_redirect():
"""Bare /hermes-ui -> /hermes-ui/ (sonst greift das Asset-Prefix-Rewriting nicht)."""
return RedirectResponse(url=PREFIX + "/")
@router.api_route(PREFIX + "/{path:path}", methods=_METHODS)
async def hermes_ui_proxy(request: Request, path: str):
"""HTTP-Reverse-Proxy auf das Hermes-Dashboard mit Prefix-Header."""
url = f"{HERMES_DASHBOARD_URL}/{path}"
headers = {k: v for k, v in request.headers.items() if k.lower() not in _HOP}
headers["X-Forwarded-Prefix"] = PREFIX
body = await request.body()
timeout = httpx.Timeout(60.0, connect=10.0)
async with httpx.AsyncClient(timeout=timeout, follow_redirects=False) as client:
r = await client.request(
request.method, url,
params=request.query_params, headers=headers, content=body,
)
resp_headers = {k: v for k, v in r.headers.items() if k.lower() not in _HOP}
return Response(
content=r.content, status_code=r.status_code,
headers=resp_headers, media_type=r.headers.get("content-type"),
)
@router.websocket(PREFIX + "/api/{name}")
async def hermes_ui_ws(ws: WebSocket, name: str):
"""WebSocket-Bruecke fuer die Chat-/Event-WS des Dashboards (pty/ws/pub/events)."""
await ws.accept()
qs = ws.url.query
target = f"{_WS_BASE}/api/{name}" + (f"?{qs}" if qs else "")
try:
async with websockets.connect(
target,
additional_headers={"X-Forwarded-Prefix": PREFIX},
max_size=None, open_timeout=10, ping_interval=None,
) as up:
async def client_to_upstream():
try:
while True:
msg = await ws.receive()
if msg.get("type") == "websocket.disconnect":
break
if msg.get("text") is not None:
await up.send(msg["text"])
elif msg.get("bytes") is not None:
await up.send(msg["bytes"])
except Exception:
pass
finally:
await up.close()
async def upstream_to_client():
try:
async for m in up:
if isinstance(m, (bytes, bytearray)):
await ws.send_bytes(m)
else:
await ws.send_text(m)
except Exception:
pass
finally:
if ws.application_state != WebSocketState.DISCONNECTED:
await ws.close()
await asyncio.gather(client_to_upstream(), upstream_to_client())
except Exception:
if ws.application_state != WebSocketState.DISCONNECTED:
try:
await ws.close()
except Exception:
pass