Hermes-GUI: eingebaute hermes serve-Weboberflaeche same-origin in MC2 einbetten (ersetzt CLI-Terminal-Tab)

Statt des ttyd-CLI-Terminals zeigt der Tab jetzt die reiche eingebaute Hermes-Web-GUI
(hermes serve/dashboard: Threads, sichtbare Tool-Calls, Sessions, Config) - same-origin,
ohne offenen Port, ohne Login (Loopback :9119 = LAN-Trust wie Terminal/Konsole).

- backend/routers/hermes_ui.py: generischer Reverse-Proxy /hermes-ui/* (alle Methoden + beliebige
  WebSockets) -> 127.0.0.1:9119; injiziert window.__HERMES_BASE_PATH__=/hermes-ui in die index.html,
  damit die SPA ihre API/WS-Calls unter dem Praefix absetzt (sonst Kollision mit MC2s /api).
- deploy/hermes-builtin-ui.service: hermes serve --skip-build auf Loopback :9119.
- deploy/deploy.sh: baut das GUI-Bundle mit Vite-base=/hermes-ui/ (Temp->Swap, ueberlebt Hermes-
  Updates) + installiert/startet den Dienst.
- backend/app.py + config.py + services/agent.py: Router registriert, Upstream/Pfad + Status-Feld.
- frontend: Terminal-Tab -> "Hermes GUI" (TerminalView iframe /hermes-ui/, nav-Label, vite-Dev-Proxy).

E2E im Browser verifiziert: GUI rendert eingebettet, /hermes-ui/api/* liefern echte Daten
(289 Sessions, verbundene Plattformen), Assets + Plugins laden, Basis-Pfad korrekt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-06 14:48:12 +02:00
parent 001ebb7742
commit 42e45b4b50
13 changed files with 320 additions and 122 deletions
+135
View File
@@ -0,0 +1,135 @@
"""
Same-origin Reverse-Proxy für die EINGEBAUTE Hermes-Web-GUI (`hermes serve`/`dashboard`).
Anders als das ttyd-Terminal (reiner Byte-Durchreicher, siehe routers/console.py) ist die
Hermes-GUI eine volle Single-Page-App: HTML + gehashte Assets + JSON-API + MEHRERE WebSockets
(/api/ws, /api/console, /api/pty, /api/events). Deshalb hier ein GENERISCHER Reverse-Proxy für
alles unter `/hermes-ui/*` (alle HTTP-Methoden + beliebige WebSockets).
Der Backend-Server läuft NUR auf Loopback (127.0.0.1:9119, `hermes serve --skip-build`, kein
Login — gleiches LAN-Trust-Modell wie Konsole/Terminal) und wird über den ohnehin offenen
MC2-Port (9001) same-origin durchgereicht → keine eigene Firewall-Freigabe, kein CORS.
Damit die absoluten Pfade der SPA (`/assets`, `/api`, `/api/ws`) nicht mit MC2s eigenen (`/assets`,
`/api`) kollidieren, wird das Hermes-Bundle mit Vite-`base=/hermes-ui/` gebaut (deploy.sh) — dann
liegen ALLE seine Pfade unter `/hermes-ui/`. Dieser Proxy streift das Präfix ab und leitet an
`:9119/...` weiter.
"""
import asyncio
import logging
import re
import httpx
import websockets
from fastapi import APIRouter, Request, WebSocket
from starlette.responses import RedirectResponse, Response
from config import HERMES_BUILTIN_UI_UPSTREAM
log = logging.getLogger(__name__)
router = APIRouter()
_BASE = "hermes-ui"
# Hop-by-hop- + Kodierungs-Header, die ein Proxy nicht 1:1 weiterreichen darf (Länge/Encoding
# rechnet Starlette neu; httpx liefert bereits dekodierten Body).
_DROP = {
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailers",
"transfer-encoding", "upgrade", "content-encoding", "content-length", "host",
}
@router.websocket(f"/{_BASE}/{{path:path}}")
async def _proxy_ws(ws: WebSocket, path: str) -> None:
"""Beliebige WebSocket unter /hermes-ui/* (z. B. /hermes-ui/api/ws) an :9119 durchreichen."""
ws_upstream = HERMES_BUILTIN_UI_UPSTREAM.replace("http://", "ws://").replace("https://", "wss://")
query = ws.url.query
target = f"{ws_upstream}/{path}" + (f"?{query}" if query else "")
await ws.accept()
try:
async with websockets.connect(
target, open_timeout=10, max_size=None, ping_interval=None,
) as up:
async def c2u() -> None:
while True:
msg = await ws.receive()
if msg.get("type") == "websocket.disconnect":
return
if (t := msg.get("text")) is not None:
await up.send(t)
elif (b := msg.get("bytes")) is not None:
await up.send(b)
async def u2c() -> None:
async for m in up:
if isinstance(m, (bytes, bytearray)):
await ws.send_bytes(bytes(m))
else:
await ws.send_text(m)
_done, pending = await asyncio.wait(
[asyncio.create_task(c2u()), asyncio.create_task(u2c())],
return_when=asyncio.FIRST_COMPLETED,
)
for task in pending:
task.cancel()
except Exception:
log.debug("hermes-ui ws-proxy (%s) beendet/fehlgeschlagen", path, exc_info=True)
finally:
try:
await ws.close()
except Exception:
pass
# Die SPA liest ihren API/WS-Basis-Pfad zur Laufzeit aus `window.__HERMES_BASE_PATH__` (nf() im
# Bundle). `hermes serve` injiziert diese Zuweisung selbst ans Ende von <head> (Wert leer, weil es
# auf Root läuft) — dessen Wert überschreiben wir auf /hermes-ui, sonst setzt die GUI ihre /api-Calls
# auf MC2s eigenen /api-Namensraum (SPA-Fallback = HTML) ab. Die Assets liegen bereits per
# Vite-base=/hermes-ui/ richtig. MC2 macht die Ersetzung → überlebt Hermes-Updates, ohne die gebaute
# index.html patchen zu müssen. Fällt die Server-Injektion je weg, setzen wir sie ersatzweise selbst.
_BASE_PATH_RE = re.compile(rb'window\.__HERMES_BASE_PATH__\s*=\s*"[^"]*"')
_BASE_PATH_SET = b'window.__HERMES_BASE_PATH__="/' + _BASE.encode() + b'"'
_HEAD_INJECT = b"<head><script>" + _BASE_PATH_SET + b";</script>"
async def _proxy_http(request: Request, path: str = "") -> Response:
upstream = f"{HERMES_BUILTIN_UI_UPSTREAM}/{path}"
fwd_headers = {k: v for k, v in request.headers.items() if k.lower() not in _DROP}
# Upstream unkomprimiert anfordern: sonst greift die HTML-Injektion nicht und die
# content-encoding-Header würden nicht zum (bereits dekodierten) Body passen.
fwd_headers["accept-encoding"] = "identity"
try:
async with httpx.AsyncClient(timeout=30.0, follow_redirects=False) as c:
r = await c.request(
request.method, upstream,
headers=fwd_headers, params=request.query_params, content=await request.body(),
)
except httpx.HTTPError as exc:
log.debug("hermes-ui http-proxy (%s) nicht erreichbar: %s", path, exc)
return Response(
content=b"Hermes-WebUI (hermes serve) nicht erreichbar.",
status_code=502, media_type="text/plain",
)
body = r.content
if "text/html" in r.headers.get("content-type", "").lower():
if _BASE_PATH_RE.search(body):
body = _BASE_PATH_RE.sub(_BASE_PATH_SET, body)
elif b"<head>" in body:
body = body.replace(b"<head>", _HEAD_INJECT, 1)
resp_headers = {k: v for k, v in r.headers.items() if k.lower() not in _DROP}
return Response(content=body, status_code=r.status_code, headers=resp_headers)
@router.get(f"/{_BASE}")
async def _root_redirect() -> RedirectResponse:
# Ohne Schrägstrich → auf /hermes-ui/ umleiten (die SPA erwartet den Präfix mit Slash).
return RedirectResponse(url=f"/{_BASE}/")
router.add_api_route(
f"/{_BASE}/{{path:path}}", _proxy_http,
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
name="hermes_ui_proxy",
)