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:
+8
-87
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user