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.responses import FileResponse, JSONResponse, Response
from fastapi.staticfiles import StaticFiles 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") app = FastAPI(title="Mission Control")
@@ -45,6 +45,7 @@ app.include_router(integration.router)
app.include_router(news.router) app.include_router(news.router)
app.include_router(memory.router) app.include_router(memory.router)
app.include_router(hermes.router) app.include_router(hermes.router)
app.include_router(hermes_ui.router)
_STATIC = Path(__file__).parent / "static" _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. # MC laeuft als hitonabi auf der Box -> direkter Lese-/Exec-Zugriff.
HERMES_HOME = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes"))) 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_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_HOST = os.environ.get("HERMES_WINDOWS_HOST", "")
HERMES_WINDOWS_USER = os.environ.get("HERMES_WINDOWS_USER", "TobisPC") 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"))) 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 { api } from '@core/api.js'
import { toast } from '@core/ui.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 ---- // ---- 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 status = $state<any>(null)
let setupOpen = $state(false) let setupOpen = $state(false)
let cockpitOpen = $state(false) let cockpitOpen = $state(false)
@@ -25,167 +11,6 @@
let cronJobs = $state<any[]>([]) let cronJobs = $state<any[]>([])
let skills = $state<any[]>([]) let skills = $state<any[]>([])
let pubkey = $state('') 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 ---- // ---- Status laden ----
async function loadStatus() { async function loadStatus() {
@@ -223,20 +48,6 @@
) )
let skillsEnabled = $derived(skills.filter(s => (s.status || '').includes('enabled')).length) 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() { function copyKey() {
navigator.clipboard?.writeText(pubkey) navigator.clipboard?.writeText(pubkey)
toast('SSH-Key kopiert') toast('SSH-Key kopiert')
@@ -244,7 +55,6 @@
// ---- Boot ---- // ---- Boot ----
$effect(() => { $effect(() => {
connect()
loadStatus() loadStatus()
}) })
</script> </script>
@@ -256,23 +66,12 @@
<div class="sub">Dein lokaler KI-Assistent — kennt den Bosgame, lernt mit.</div> <div class="sub">Dein lokaler KI-Assistent — kennt den Bosgame, lernt mit.</div>
</div> </div>
<div class="flex gap-2" style="flex-shrink:0;margin-top:6px;flex-wrap:wrap;align-items:center"> <div class="flex gap-2" style="flex-shrink:0;margin-top:6px;flex-wrap:wrap;align-items:center">
<!-- Whisper-Status --> <!-- Hermes-Status -->
<span class="chip" style="font-size:11px;{status?.whisper === 'ready' || status?.whisper === 'installed' <span class="chip" style="font-size:11px;{status?.hermes === 'ready'
? 'color:var(--accent);border-color:var(--accent)' ? 'color:#7ee29a;border-color:rgba(126,226,154,.4)'
: 'color:var(--mut)'}"> : 'color:#ff9b95;border-color:rgba(255,155,149,.4)'}">
🎙 Whisper: {status?.whisper ?? '…'} ● Agent: {status?.hermes === 'ready' ? 'online' : 'offline'}
</span> </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) --> <!-- Cockpit (Phase 4) -->
<button class="ghost" style="padding:4px 10px;font-size:12px;{cockpitOpen ? 'border-color:var(--accent);color:var(--accent)' : ''}" <button class="ghost" style="padding:4px 10px;font-size:12px;{cockpitOpen ? 'border-color:var(--accent);color:var(--accent)' : ''}"
onclick={() => { cockpitOpen = !cockpitOpen; if (cockpitOpen) loadCockpit() }}> onclick={() => { cockpitOpen = !cockpitOpen; if (cockpitOpen) loadCockpit() }}>
@@ -485,139 +284,19 @@
</div> </div>
{/if} {/if}
<!-- ==================== CHAT ==================== --> <!-- ==================== CHAT (eingebettetes Hermes-Web-Dashboard) ==================== -->
<div class="card" style="padding:0;display:flex;flex-direction:column;min-height:420px"> <div class="card" style="padding:0;overflow:hidden">
{#if status?.hermes === 'ready'}
<!-- Nachrichten-Liste --> <iframe
<div style="flex:1;overflow-y:auto;padding:16px;display:flex;flex-direction:column;gap:10px" title="Hermes Chat"
id="hermes-msgs"> src="/hermes-ui/"
{#if messages.length === 0} style="width:100%;height:calc(100vh - 210px);min-height:540px;border:0;display:block;background:#0f1720"
<div class="empty-c" style="margin:auto"> ></iframe>
<div class="e-t">Hermes ist bereit</div> {:else}
<div class="e-s">Stelle eine Frage oder gib einen Auftrag — per Text oder Sprache.</div> <div class="empty-c" style="padding:48px 24px">
<div style="margin-top:16px;display:flex;flex-direction:column;gap:6px"> <div class="e-t">Hermes-Agent offline</div>
{#each [ <div class="e-s">Der Hermes-Gateway (:8642) bzw. das Dashboard (:9119) ist nicht erreichbar.
'Was läuft gerade auf dem System?', Status im Cockpit prüfen oder den Dienst neu starten.</div>
'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>
</div> </div>
{/if} {/if}
</div> </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 mcp>=1.0 # MCP Python SDK fuer mcp_memory.py stdio-Server
faster-whisper>=1.0 # Lokale Sprach-zu-Text Transkription (Hermes Voice) faster-whisper>=1.0 # Lokale Sprach-zu-Text Transkription (Hermes Voice)
paramiko>=3.0 # SSH-Client fuer Hermes → Windows-PC Zugriff 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: Endpunkte:
WS /api/hermes/chat — Streaming Chat mit dem Hermes Agent
POST /api/hermes/transcribe — Audio → Text (Whisper) POST /api/hermes/transcribe — Audio → Text (Whisper)
POST /api/hermes/tts — Text → Audio WAV (Piper) 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 GET /api/hermes/pubkey — Bosgame SSH-Public-Key fuer Windows-Setup
""" """
import asyncio import asyncio
import hashlib import hashlib
import json
import subprocess import subprocess
import tempfile import tempfile
import threading import threading
@@ -19,14 +21,14 @@ from pathlib import Path
from typing import Optional from typing import Optional
import httpx 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 fastapi.responses import Response, JSONResponse
from auth import auth from auth import auth
from config import ( from config import (
PIPER_BIN, PIPER_VOICE, WHISPER_MODEL_SIZE, PIPER_BIN, PIPER_VOICE, WHISPER_MODEL_SIZE,
HERMES_WINDOWS_HOST, HERMES_WINDOWS_USER, HERMES_SSH_KEY, HERMES_WINDOWS_HOST, HERMES_WINDOWS_USER, HERMES_SSH_KEY,
HERMES_API_URL, HERMES_API_KEY, HERMES_API_URL,
) )
router = APIRouter(prefix="/api") router = APIRouter(prefix="/api")
@@ -99,87 +101,6 @@ def _tts(text: str) -> Optional[bytes]:
# Endpoints # 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)]) @router.post("/hermes/transcribe", dependencies=[Depends(auth)])
async def transcribe_audio(audio: UploadFile = File(...)): async def transcribe_audio(audio: UploadFile = File(...)):
"""Audio-Datei (webm/ogg/wav) per Whisper transkribieren.""" """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