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:
@@ -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