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:
@@ -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"
|
||||
|
||||
|
||||
@@ -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")))
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
Vendored
+1
-1
@@ -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)}
|
||||
|
||||
Vendored
+39
-50
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user