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
+2 -1
View File
@@ -20,7 +20,7 @@ from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse, JSONResponse, Response
from fastapi.staticfiles import StaticFiles
from routers import jobs, hermes, maintenance, memory, models, system, cookbook, integration, news
from routers import jobs, hermes, hermes_ui, maintenance, memory, models, system, cookbook, integration, news
app = FastAPI(title="Mission Control")
@@ -45,6 +45,7 @@ app.include_router(integration.router)
app.include_router(news.router)
app.include_router(memory.router)
app.include_router(hermes.router)
app.include_router(hermes_ui.router)
_STATIC = Path(__file__).parent / "static"
+4
View File
@@ -74,6 +74,10 @@ HERMES_API_KEY = _read_hermes_api_key()
# MC laeuft als hitonabi auf der Box -> direkter Lese-/Exec-Zugriff.
HERMES_HOME = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes")))
HERMES_BIN = os.path.expanduser(os.environ.get("HERMES_BIN", "~/.local/bin/hermes"))
# Hermes-Web-Dashboard (eingebaute UI mit Chat/Tool-Aktivitaet, lokal gebunden).
# MC reverse-proxyt es unter /hermes-ui/ (X-Forwarded-Prefix) und bettet es per
# iframe ein -> eine Oberflaeche, kein zweiter Login (Dashboard self-auth auf Loopback).
HERMES_DASHBOARD_URL = os.environ.get("HERMES_DASHBOARD_URL", "http://127.0.0.1:9119").rstrip("/")
HERMES_WINDOWS_HOST = os.environ.get("HERMES_WINDOWS_HOST", "")
HERMES_WINDOWS_USER = os.environ.get("HERMES_WINDOWS_USER", "TobisPC")
HERMES_SSH_KEY = Path(os.path.expanduser(os.environ.get("HERMES_SSH_KEY", "~/.ssh/id_ed25519_hermes_agent")))
+18 -339
View File
@@ -2,21 +2,7 @@
import { api } from '@core/api.js'
import { toast } from '@core/ui.js'
// ---- Typen ----
type MsgRole = 'user' | 'hermes' | 'info' | 'tool' | 'error'
type Msg = { role: MsgRole; content: string; tool?: string; args?: any }
const SESSION_KEY = 'hermes_chat_history'
// ---- State ----
let messages = $state<Msg[]>((() => {
try { return JSON.parse(sessionStorage.getItem(SESSION_KEY) || '[]') } catch { return [] }
})())
let input = $state('')
let thinking = $state(false)
let stopping = $state(false)
let voiceOn = $state(false)
let recording = $state(false)
let status = $state<any>(null)
let setupOpen = $state(false)
let cockpitOpen = $state(false)
@@ -25,167 +11,6 @@
let cronJobs = $state<any[]>([])
let skills = $state<any[]>([])
let pubkey = $state('')
let ws = $state<WebSocket | null>(null)
let currentBot = $state('') // aktuell gestreamter Hermes-Token
// Nachrichten in sessionStorage sichern (lebt nur für aktive Browser-Tab-Session)
$effect(() => {
try { sessionStorage.setItem(SESSION_KEY, JSON.stringify(messages)) } catch {}
})
// ---- WebSocket ----
function connect() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
const token = localStorage.getItem('mc_token') || ''
const u = `${proto}//${location.host}/api/hermes/chat${token ? '?token=' + token : ''}`
const sock = new WebSocket(u)
sock.onmessage = (e) => {
const m = JSON.parse(e.data)
if (m.type === 'thinking') {
thinking = true
} else if (m.type === 'info') {
// kleine Info-Meldung (Modell-Anzeige)
} else if (m.type === 'token') {
thinking = false
currentBot += m.content
// letzter Eintrag aktualisieren oder neu anlegen
const last = messages[messages.length - 1]
if (last?.role === 'hermes') {
messages = [...messages.slice(0, -1), { role: 'hermes', content: currentBot }]
} else {
messages = [...messages, { role: 'hermes', content: currentBot }]
}
} else if (m.type === 'tool_call') {
thinking = false
const args = m.args || {}
const preview = Object.values(args).join(' ').slice(0, 60)
messages = [...messages, { role: 'tool', content: preview, tool: m.name }]
} else if (m.type === 'tool_result') {
// Tool-Result in letzten Tool-Eintrag schreiben
const last = [...messages].reverse().find(x => x.role === 'tool' && x.tool === m.name)
if (last) last.content = m.content
messages = [...messages]
} else if (m.type === 'error') {
thinking = false
messages = [...messages, { role: 'error', content: m.content }]
} else if (m.type === 'done') {
thinking = false
stopping = false
currentBot = ''
if (voiceOn) speakLast()
}
}
sock.onclose = () => {
ws = null
setTimeout(connect, 3000)
}
sock.onerror = () => sock.close()
ws = sock
}
// ---- Stopp ----
function stopGeneration() {
stopping = true
thinking = false
if (currentBot) {
messages = [...messages.slice(0, -1),
{ role: 'hermes', content: currentBot + ' ✋' }]
}
currentBot = ''
ws?.close() // onclose reconnects nach 3s automatisch
}
// ---- Senden ----
async function send() {
const text = input.trim()
if (!text || !ws || ws.readyState !== 1) return
messages = [...messages, { role: 'user', content: text }]
input = ''
currentBot = ''
stopping = false
ws.send(JSON.stringify({ message: text }))
}
// ---- Verlauf löschen ----
function clearHistory() {
messages = []
currentBot = ''
try { sessionStorage.removeItem(SESSION_KEY) } catch {}
}
// ---- Voice: Aufnahme ----
let mediaRecorder: MediaRecorder | null = null
let audioChunks: Blob[] = []
async function startRecording() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
audioChunks = []
mediaRecorder = new MediaRecorder(stream)
mediaRecorder.ondataavailable = (e) => audioChunks.push(e.data)
mediaRecorder.onstop = () => {
stream.getTracks().forEach(t => t.stop())
sendAudio()
}
mediaRecorder.start()
recording = true
} catch (e: any) {
toast('Mikrofon-Zugriff verweigert: ' + e.message, true)
}
}
function stopRecording() {
mediaRecorder?.stop()
recording = false
}
async function sendAudio() {
const blob = new Blob(audioChunks, { type: 'audio/webm' })
const form = new FormData()
form.append('audio', blob, 'voice.webm')
try {
const r = await fetch('/api/hermes/transcribe', {
method: 'POST',
headers: localStorage.getItem('mc_token')
? { 'X-MC-Token': localStorage.getItem('mc_token')! }
: {},
body: form,
})
const data = await r.json()
if (data.text) {
input = data.text
send()
} else {
toast('Konnte Audio nicht verstehen.', true)
}
} catch (e: any) {
toast('Transkription fehlgeschlagen: ' + e.message, true)
}
}
// ---- Voice: Ausgabe ----
async function speakLast() {
const last = [...messages].reverse().find(m => m.role === 'hermes')
if (!last) return
try {
const r = await fetch('/api/hermes/tts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(localStorage.getItem('mc_token') ? { 'X-MC-Token': localStorage.getItem('mc_token')! } : {})
},
body: JSON.stringify({ text: last.content }),
})
if (!r.ok) return
const blob = await r.blob()
const url = URL.createObjectURL(blob)
const audio = new Audio(url)
audio.onended = () => URL.revokeObjectURL(url)
audio.play()
} catch { /* TTS optional */ }
}
// ---- Status laden ----
async function loadStatus() {
@@ -223,20 +48,6 @@
)
let skillsEnabled = $derived(skills.filter(s => (s.status || '').includes('enabled')).length)
// ---- Hilfsfunktionen ----
function toolLabel(name: string) {
const map: Record<string, string> = {
read_file: '📄 Lese Datei',
list_directory: '📁 Liste Verzeichnis',
run_command: '⚡ Befehl',
get_system_status: '📊 System-Status',
get_memories: '🧠 Gedächtnis',
add_memory: '💾 Speichern',
web_search: '🌐 Websuche',
}
return map[name] || name
}
function copyKey() {
navigator.clipboard?.writeText(pubkey)
toast('SSH-Key kopiert')
@@ -244,7 +55,6 @@
// ---- Boot ----
$effect(() => {
connect()
loadStatus()
})
</script>
@@ -256,23 +66,12 @@
<div class="sub">Dein lokaler KI-Assistent — kennt den Bosgame, lernt mit.</div>
</div>
<div class="flex gap-2" style="flex-shrink:0;margin-top:6px;flex-wrap:wrap;align-items:center">
<!-- Whisper-Status -->
<span class="chip" style="font-size:11px;{status?.whisper === 'ready' || status?.whisper === 'installed'
? 'color:var(--accent);border-color:var(--accent)'
: 'color:var(--mut)'}">
🎙 Whisper: {status?.whisper ?? '…'}
<!-- Hermes-Status -->
<span class="chip" style="font-size:11px;{status?.hermes === 'ready'
? 'color:#7ee29a;border-color:rgba(126,226,154,.4)'
: 'color:#ff9b95;border-color:rgba(255,155,149,.4)'}">
● Agent: {status?.hermes === 'ready' ? 'online' : 'offline'}
</span>
<!-- SSH-Status -->
<span class="chip" style="font-size:11px;{status?.ssh_ok
? 'color:var(--accent);border-color:var(--accent)'
: 'color:var(--mut)'}">
🔑 SSH: {status?.ssh_ok ? 'verbunden' : status?.ssh_configured ? 'Fehler' : 'nicht eingerichtet'}
</span>
<!-- Voice Toggle -->
<button class="ghost" style="padding:4px 12px;font-size:12px;{voiceOn ? 'border-color:var(--accent);color:var(--accent)' : ''}"
onclick={() => voiceOn = !voiceOn}>
🔊 Voice {voiceOn ? 'an' : 'aus'}
</button>
<!-- Cockpit (Phase 4) -->
<button class="ghost" style="padding:4px 10px;font-size:12px;{cockpitOpen ? 'border-color:var(--accent);color:var(--accent)' : ''}"
onclick={() => { cockpitOpen = !cockpitOpen; if (cockpitOpen) loadCockpit() }}>
@@ -485,139 +284,19 @@
</div>
{/if}
<!-- ==================== CHAT ==================== -->
<div class="card" style="padding:0;display:flex;flex-direction:column;min-height:420px">
<!-- Nachrichten-Liste -->
<div style="flex:1;overflow-y:auto;padding:16px;display:flex;flex-direction:column;gap:10px"
id="hermes-msgs">
{#if messages.length === 0}
<div class="empty-c" style="margin:auto">
<div class="e-t">Hermes ist bereit</div>
<div class="e-s">Stelle eine Frage oder gib einen Auftrag — per Text oder Sprache.</div>
<div style="margin-top:16px;display:flex;flex-direction:column;gap:6px">
{#each [
'Was läuft gerade auf dem System?',
'Zeig mir den Inhalt von /etc/llama-swap/config.yaml',
'Suche nach den neuesten Qwen3-Modellen',
] as suggestion}
<button class="ghost" style="font-size:12.5px;text-align:left"
onclick={() => { input = suggestion; send() }}>
{suggestion}
</button>
{/each}
</div>
</div>
{/if}
{#each messages as msg (msg)}
{#if msg.role === 'user'}
<div style="display:flex;justify-content:flex-end">
<div style="background:rgba(45,212,191,.15);border:1px solid rgba(45,212,191,.3);
border-radius:12px 12px 2px 12px;padding:10px 14px;max-width:75%;
font-size:13.5px;line-height:1.5;word-break:break-word">
{msg.content}
</div>
</div>
{:else if msg.role === 'hermes'}
<div style="display:flex;gap:8px;align-items:flex-start">
<div style="width:28px;height:28px;border-radius:50%;background:rgba(45,212,191,.2);
border:1px solid rgba(45,212,191,.4);display:flex;align-items:center;
justify-content:center;font-size:14px;flex-shrink:0">H</div>
<div style="background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.08);
border-radius:2px 12px 12px 12px;padding:10px 14px;max-width:80%;
font-size:13.5px;line-height:1.6;word-break:break-word;white-space:pre-wrap">
{msg.content}
</div>
</div>
{:else if msg.role === 'tool'}
<div style="display:flex;align-items:center;gap:8px;color:var(--mut);font-size:11.5px;
padding:4px 8px;background:rgba(255,255,255,.02);border-radius:6px">
<span>{toolLabel(msg.tool || '')}</span>
{#if msg.content}
<span style="font-family:monospace;font-size:11px;opacity:.7;overflow:hidden;
text-overflow:ellipsis;white-space:nowrap;max-width:400px">
{msg.content}
</span>
{/if}
</div>
{:else if msg.role === 'info'}
<div style="text-align:center;color:var(--mut);font-size:11px">{msg.content}</div>
{:else if msg.role === 'error'}
<div style="background:rgba(240,87,63,.1);border:1px solid rgba(240,87,63,.3);
border-radius:8px;padding:8px 12px;color:#ff9b95;font-size:12.5px">
{msg.content}
</div>
{/if}
{/each}
{#if thinking}
<div style="display:flex;gap:8px;align-items:center;color:var(--mut);font-size:12px">
<div style="width:28px;height:28px;border-radius:50%;background:rgba(45,212,191,.1);
border:1px solid rgba(45,212,191,.2);display:flex;align-items:center;
justify-content:center;font-size:14px">H</div>
<span style="animation:pulse 1.2s ease-in-out infinite">denkt nach…</span>
</div>
{/if}
</div>
<!-- Input-Bereich -->
<div style="border-top:1px solid rgba(255,255,255,.07);padding:12px 16px;display:flex;gap:8px;align-items:flex-end">
<textarea
rows="2"
placeholder="Frage stellen oder Aufgabe beschreiben…"
style="flex:1;resize:none;font-size:13.5px;line-height:1.5;min-height:44px;max-height:160px"
bind:value={input}
onkeydown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() } }}
></textarea>
<!-- Mikrofon-Button -->
<button
class="ghost"
style="padding:10px 12px;flex-shrink:0;font-size:18px;line-height:1;
{recording ? 'border-color:var(--err);color:var(--err)' : ''}"
title={recording ? 'Aufnahme stoppen' : 'Spracheingabe'}
onmousedown={startRecording}
onmouseup={stopRecording}
ontouchstart={startRecording}
ontouchend={stopRecording}>
{recording ? '⏹' : '🎤'}
</button>
{#if thinking || stopping}
<!-- Stopp-Button während Hermes antwortet -->
<button class="ghost" style="padding:10px 18px;flex-shrink:0;border-color:var(--err);color:var(--err)"
onclick={stopGeneration}>
⏹ Stopp
</button>
{:else}
<!-- Senden-Button -->
<button
class="primary"
style="padding:10px 18px;flex-shrink:0"
disabled={!input.trim() || !ws || ws.readyState !== 1}
onclick={send}>
Senden
</button>
{/if}
</div>
<!-- Fußzeile: Verlauf löschen -->
{#if messages.length > 0 && !thinking}
<div style="border-top:1px solid rgba(255,255,255,.04);padding:6px 16px;display:flex;justify-content:flex-end">
<button class="ghost" style="font-size:11px;padding:2px 8px;color:var(--mut)"
onclick={clearHistory}>Verlauf löschen</button>
<!-- ==================== CHAT (eingebettetes Hermes-Web-Dashboard) ==================== -->
<div class="card" style="padding:0;overflow:hidden">
{#if status?.hermes === 'ready'}
<iframe
title="Hermes Chat"
src="/hermes-ui/"
style="width:100%;height:calc(100vh - 210px);min-height:540px;border:0;display:block;background:#0f1720"
></iframe>
{:else}
<div class="empty-c" style="padding:48px 24px">
<div class="e-t">Hermes-Agent offline</div>
<div class="e-s">Der Hermes-Gateway (:8642) bzw. das Dashboard (:9119) ist nicht erreichbar.
Status im Cockpit prüfen oder den Dienst neu starten.</div>
</div>
{/if}
</div>
<style>
@keyframes pulse {
0%, 100% { opacity: 1 }
50% { opacity: .4 }
}
</style>
+1
View File
@@ -7,3 +7,4 @@ huggingface_hub>=0.34 # liefert die `hf`-CLI fuer Modell-Downloads
mcp>=1.0 # MCP Python SDK fuer mcp_memory.py stdio-Server
faster-whisper>=1.0 # Lokale Sprach-zu-Text Transkription (Hermes Voice)
paramiko>=3.0 # SSH-Client fuer Hermes → Windows-PC Zugriff
websockets>=12.0 # WebSocket-Client fuer den Hermes-Dashboard-Reverse-Proxy
+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
+1 -1
View File
@@ -1 +1 @@
.guide-tabs.svelte-112sh7i{display:flex;gap:6px;flex-wrap:wrap;margin:-6px 0 2px}.guide-tab.svelte-112sh7i{font-size:12px;padding:5px 13px;border-radius:999px;background:var(--tile);border:1px solid var(--line);color:var(--mut);cursor:pointer;transition:.13s;font-family:var(--sans)}.guide-tab.svelte-112sh7i:hover{color:var(--tx);border-color:var(--line2)}.guide-tab.active.svelte-112sh7i{background:#2dd4bf1f;border-color:#2dd4bf4d;color:var(--accent)}.guide-card.svelte-112sh7i{padding:0;overflow:hidden;margin-bottom:0}.guide-group-hd.svelte-112sh7i{padding:9px 18px 7px;font-size:10.5px;font-weight:700;letter-spacing:.09em;text-transform:uppercase;color:var(--accent);background:#2dd4bf0f;border-bottom:1px solid var(--line)}.tut-table{display:flex;flex-direction:column;border:1px solid var(--line);border-radius:6px;overflow:hidden;margin-top:8px;font-size:12.5px}.tut-row{display:grid;grid-template-columns:1fr 2fr;border-bottom:1px solid var(--line)}.tut-row:last-child{border-bottom:none}.tut-cell-hd{padding:10px 14px;font-weight:500;border-right:1px solid var(--line);background:#ffffff06;line-height:1.5}.tut-cell-hd.bad{color:var(--mut);text-decoration:line-through;font-style:italic}.tut-cell-bd{padding:10px 14px;line-height:1.6;color:var(--fg)}.tut-sources{margin-top:14px;padding-top:10px;border-top:1px solid var(--line);font-size:11.5px;color:var(--mut)}.tut-sources a{color:var(--accent);text-decoration:none}.tut-sources a:hover{text-decoration:underline}.tut-code{display:block;background:var(--bg, #0d0d0d);border:1px solid var(--line);border-radius:6px;padding:12px 14px;font-family:monospace;font-size:11.5px;line-height:1.65;white-space:pre;overflow-x:auto;margin:10px 0;color:var(--fg)}@keyframes svelte-h40163-pulse{0%,to{opacity:1}50%{opacity:.4}}
.guide-tabs.svelte-112sh7i{display:flex;gap:6px;flex-wrap:wrap;margin:-6px 0 2px}.guide-tab.svelte-112sh7i{font-size:12px;padding:5px 13px;border-radius:999px;background:var(--tile);border:1px solid var(--line);color:var(--mut);cursor:pointer;transition:.13s;font-family:var(--sans)}.guide-tab.svelte-112sh7i:hover{color:var(--tx);border-color:var(--line2)}.guide-tab.active.svelte-112sh7i{background:#2dd4bf1f;border-color:#2dd4bf4d;color:var(--accent)}.guide-card.svelte-112sh7i{padding:0;overflow:hidden;margin-bottom:0}.guide-group-hd.svelte-112sh7i{padding:9px 18px 7px;font-size:10.5px;font-weight:700;letter-spacing:.09em;text-transform:uppercase;color:var(--accent);background:#2dd4bf0f;border-bottom:1px solid var(--line)}.tut-table{display:flex;flex-direction:column;border:1px solid var(--line);border-radius:6px;overflow:hidden;margin-top:8px;font-size:12.5px}.tut-row{display:grid;grid-template-columns:1fr 2fr;border-bottom:1px solid var(--line)}.tut-row:last-child{border-bottom:none}.tut-cell-hd{padding:10px 14px;font-weight:500;border-right:1px solid var(--line);background:#ffffff06;line-height:1.5}.tut-cell-hd.bad{color:var(--mut);text-decoration:line-through;font-style:italic}.tut-cell-bd{padding:10px 14px;line-height:1.6;color:var(--fg)}.tut-sources{margin-top:14px;padding-top:10px;border-top:1px solid var(--line);font-size:11.5px;color:var(--mut)}.tut-sources a{color:var(--accent);text-decoration:none}.tut-sources a:hover{text-decoration:underline}.tut-code{display:block;background:var(--bg, #0d0d0d);border:1px solid var(--line);border-radius:6px;padding:12px 14px;font-family:monospace;font-size:11.5px;line-height:1.65;white-space:pre;overflow-x:auto;margin:10px 0;color:var(--fg)}
+39 -50
View File
File diff suppressed because one or more lines are too long