This repository has been archived on 2026-07-22. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Hitonabi 09aabbb86e 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>
2026-06-24 16:47:52 +02:00

118 lines
4.4 KiB
Python

"""
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