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