feat(hermes): v8 — Hermes Agent mit Voice-Interface
Lokaler KI-Assistent auf dem Bosgame: Text + Sprache, Tool Calling, WebSocket-Streaming. Steuerzentrale des Agentic OS. - hermes_agent.py: ReAct-Agent mit Tool-Set (read_file, list_dir, run_command, system_status, memory r/w, web_search) - routers/hermes.py: WS /chat, POST /transcribe (Whisper), POST /tts (Piper), GET /status, GET /pubkey - HermesPanel.svelte: Chat-UI mit Token-Streaming, Tool-Anzeige, Mikrofon-Button (MediaRecorder), Setup-Wizard (Windows SSH) - Modell-Routing: scout fuer einfache Tasks, coder fuer komplexe - HERMES_* Env-Vars in config.py Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,7 @@ import ConnectPanel from './panels/ConnectPanel.svelte'
|
||||
import NewsPanel from './panels/NewsPanel.svelte'
|
||||
import GuidesPanel from './panels/GuidesPanel.svelte'
|
||||
import MemoryPanel from './panels/MemoryPanel.svelte'
|
||||
import HermesPanel from './panels/HermesPanel.svelte'
|
||||
|
||||
let prevModelStates: Record<string, string> = {}
|
||||
|
||||
@@ -158,6 +159,7 @@ const panels: [string, any][] = [
|
||||
['news', NewsPanel],
|
||||
['guides', GuidesPanel],
|
||||
['memory', MemoryPanel],
|
||||
['hermes', HermesPanel],
|
||||
]
|
||||
for (const [view, Panel] of panels) {
|
||||
const el = document.querySelector(`.view[data-view="${view}"]`)
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
<script lang="ts">
|
||||
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 }
|
||||
|
||||
// ---- State ----
|
||||
let messages = $state<Msg[]>([])
|
||||
let input = $state('')
|
||||
let thinking = $state(false)
|
||||
let voiceOn = $state(false)
|
||||
let recording = $state(false)
|
||||
let status = $state<any>(null)
|
||||
let setupOpen = $state(false)
|
||||
let pubkey = $state('')
|
||||
let ws = $state<WebSocket | null>(null)
|
||||
let currentBot = $state('') // aktuell gestreamter Hermes-Token
|
||||
|
||||
// ---- 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.findLast(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
|
||||
currentBot = ''
|
||||
if (voiceOn) speakLast()
|
||||
}
|
||||
}
|
||||
|
||||
sock.onclose = () => {
|
||||
ws = null
|
||||
setTimeout(connect, 3000)
|
||||
}
|
||||
sock.onerror = () => sock.close()
|
||||
ws = sock
|
||||
}
|
||||
|
||||
// ---- Senden ----
|
||||
async function send() {
|
||||
const text = input.trim()
|
||||
if (!text || !ws || ws.readyState !== 1) return
|
||||
messages = [...messages, { role: 'user', content: text }]
|
||||
input = ''
|
||||
currentBot = ''
|
||||
ws.send(JSON.stringify({ message: text }))
|
||||
}
|
||||
|
||||
// ---- 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() {
|
||||
try { status = await api('/api/hermes/status') }
|
||||
catch { status = null }
|
||||
}
|
||||
|
||||
async function loadPubkey() {
|
||||
try {
|
||||
const r = await api('/api/hermes/pubkey')
|
||||
pubkey = r.pubkey
|
||||
} catch { pubkey = '' }
|
||||
}
|
||||
|
||||
// ---- 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')
|
||||
}
|
||||
|
||||
// ---- Boot ----
|
||||
$effect(() => {
|
||||
connect()
|
||||
loadStatus()
|
||||
})
|
||||
</script>
|
||||
|
||||
<!-- ==================== HEADER ==================== -->
|
||||
<div class="pagehead" style="display:flex;justify-content:space-between;align-items:flex-start">
|
||||
<div>
|
||||
<h1>Hermes</h1>
|
||||
<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 ?? '…'}
|
||||
</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>
|
||||
<!-- Setup -->
|
||||
<button class="ghost" style="padding:4px 10px;font-size:12px"
|
||||
onclick={() => { setupOpen = !setupOpen; if (setupOpen) { loadStatus(); loadPubkey() } }}>
|
||||
⚙ Setup
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== SETUP-WIZARD ==================== -->
|
||||
{#if setupOpen}
|
||||
<div class="card" style="border-color:rgba(45,212,191,.3)">
|
||||
<div class="card-h"><h3>Hermes Setup-Wizard</h3></div>
|
||||
|
||||
<!-- Whisper -->
|
||||
<div class="tile" style="margin-bottom:10px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<div>
|
||||
<b>Whisper (Sprache → Text)</b>
|
||||
<div class="card-sub" style="font-size:12px">faster-whisper muss auf dem Bosgame installiert sein</div>
|
||||
</div>
|
||||
<span class="chip" style="font-size:11px;{status?.whisper !== 'not_installed' ? 'color:#7ee29a' : 'color:#ff9b95'}">
|
||||
{status?.whisper !== 'not_installed' ? '✓ OK' : '✗ fehlt'}
|
||||
</span>
|
||||
</div>
|
||||
{#if status?.whisper === 'not_installed'}
|
||||
<div class="log" style="margin-top:8px"><code>pip install faster-whisper</code></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Piper -->
|
||||
<div class="tile" style="margin-bottom:10px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<div>
|
||||
<b>Piper TTS (Text → Sprache)</b>
|
||||
<div class="card-sub" style="font-size:12px">Piper-Binary + deutsche Stimme</div>
|
||||
</div>
|
||||
<span class="chip" style="font-size:11px;{status?.piper === 'ready' ? 'color:#7ee29a' : 'color:#ff9b95'}">
|
||||
{status?.piper === 'ready' ? '✓ OK' : '✗ fehlt'}
|
||||
</span>
|
||||
</div>
|
||||
{#if status?.piper !== 'ready'}
|
||||
<div class="log" style="margin-top:8px"><code>{"# Auf dem Bosgame:\nmkdir -p /opt/mission-control/piper/voices\n# Piper Binary + Thorsten-Stimme (Anleitung in Guides)"}</code></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Windows SSH -->
|
||||
<div class="tile">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<div>
|
||||
<b>Windows SSH-Verbindung</b>
|
||||
<div class="card-sub" style="font-size:12px">Damit Hermes Dateien auf deinem Windows-PC ändern kann</div>
|
||||
</div>
|
||||
<span class="chip" style="font-size:11px;{status?.ssh_ok ? 'color:#7ee29a' : 'color:var(--mut)'}">
|
||||
{status?.ssh_ok ? '✓ verbunden' : 'optional'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if !status?.ssh_ok}
|
||||
<div style="margin-top:12px">
|
||||
<div style="font-size:12.5px;font-weight:500;margin-bottom:8px">Einrichtung (einmalig, ~3 Minuten):</div>
|
||||
|
||||
<div style="margin-bottom:8px">
|
||||
<div class="card-sub" style="font-size:11.5px;margin-bottom:4px">1. PowerShell als Admin — OpenSSH Server installieren:</div>
|
||||
<div class="log"><code>Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0</code></div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:8px">
|
||||
<div class="card-sub" style="font-size:11.5px;margin-bottom:4px">2. Dienst starten und dauerhaft aktivieren:</div>
|
||||
<div class="log"><code>{"Start-Service sshd\nSet-Service -Name sshd -StartupType Automatic"}</code></div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:8px">
|
||||
<div class="card-sub" style="font-size:11.5px;margin-bottom:4px">3. SSH-Ordner erstellen:</div>
|
||||
<div class="log"><code>{"New-Item -ItemType Directory -Force -Path \"$env:USERPROFILE\\.ssh\""}</code></div>
|
||||
</div>
|
||||
|
||||
{#if pubkey}
|
||||
<div style="margin-bottom:8px">
|
||||
<div class="card-sub" style="font-size:11.5px;margin-bottom:4px">4. Bosgame-Key eintragen (in Windows PowerShell):</div>
|
||||
<div style="position:relative">
|
||||
<button class="ghost" style="position:absolute;top:6px;right:6px;font-size:11px;padding:2px 8px;z-index:1"
|
||||
onclick={copyKey}>Kopieren</button>
|
||||
<div class="log"><code>{"echo \"" + pubkey + "\" >> \"$env:USERPROFILE\\.ssh\\authorized_keys\""}</code></div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="card-sub" style="font-size:11.5px">SSH-Key nicht gefunden — bitte auf dem Bosgame generieren:
|
||||
<code style="display:block;margin-top:4px">ssh-keygen -t ed25519 -C "hermes-agent@bosgame" -f ~/.ssh/id_ed25519_hermes_agent -N ""</code>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div style="margin-bottom:8px">
|
||||
<div class="card-sub" style="font-size:11.5px;margin-bottom:4px">5. Windows-IP in Mission Control Env-Var setzen:</div>
|
||||
<div class="log"><code>HERMES_WINDOWS_HOST=192.168.178.XXX</code></div>
|
||||
</div>
|
||||
|
||||
<button class="primary" style="margin-top:4px" onclick={() => { loadStatus(); toast('Status aktualisiert') }}>
|
||||
Verbindung testen
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<!-- Senden-Button -->
|
||||
<button
|
||||
class="primary"
|
||||
style="padding:10px 18px;flex-shrink:0"
|
||||
disabled={!input.trim() || !ws || ws.readyState !== 1}
|
||||
onclick={send}>
|
||||
Senden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1 }
|
||||
50% { opacity: .4 }
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user