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:
Hitonabi
2026-06-23 14:18:48 +02:00
parent e02a1889b2
commit 3679465956
10 changed files with 1140 additions and 33 deletions
+2 -1
View File
@@ -20,7 +20,7 @@ from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse, JSONResponse, Response
from fastapi.staticfiles import StaticFiles
from routers import jobs, maintenance, memory, models, system, cookbook, integration, news
from routers import jobs, hermes, maintenance, memory, models, system, cookbook, integration, news
app = FastAPI(title="Mission Control")
@@ -44,6 +44,7 @@ app.include_router(cookbook.router)
app.include_router(integration.router)
app.include_router(news.router)
app.include_router(memory.router)
app.include_router(hermes.router)
_STATIC = Path(__file__).parent / "static"
+10
View File
@@ -44,6 +44,16 @@ UPDATE_CMD = os.environ.get("MC_UPDATE_CMD", "")
DEFAULT_TTL = int(os.environ.get("MC_DEFAULT_TTL", "300"))
TOKEN = os.environ.get("MC_TOKEN", "") # leer = keine Auth (nur LAN!)
# Hermes Agent
HERMES_SIMPLE_MODEL = os.environ.get("HERMES_SIMPLE_MODEL", "scout")
HERMES_COMPLEX_MODEL = os.environ.get("HERMES_COMPLEX_MODEL", "coder")
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")))
PIPER_BIN = Path(os.environ.get("PIPER_BIN", "/opt/mission-control/piper/piper"))
PIPER_VOICE = Path(os.environ.get("PIPER_VOICE", "/opt/mission-control/piper/voices/de_DE-thorsten-medium.onnx"))
WHISPER_MODEL_SIZE = os.environ.get("WHISPER_MODEL", "medium")
# Self-Update ("Mission Control aktualisieren"): Quelle = git-Repo, Prod = laufende Installation.
SOURCE_DIR = os.path.expanduser(os.environ.get("MC_SOURCE_DIR", "~/mission-control"))
PROD_DIR = str(Path(__file__).resolve().parent)
+2
View File
@@ -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}"]`)
+441
View File
@@ -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>
+412
View File
@@ -0,0 +1,412 @@
"""
Hermes Agent — lokaler KI-Assistent fuer das Agentic OS.
Empfaengt Aufgaben per Text (und Voice), nutzt llama-swap als LLM-Backend,
kann per Tools auf das Bosgame und spaeter per SSH auf den Windows-PC zugreifen.
"""
import asyncio
import json
import subprocess
from pathlib import Path
from typing import Callable, Awaitable
import httpx
from config import LLAMA_SWAP_URL, HERMES_SIMPLE_MODEL, HERMES_COMPLEX_MODEL
# ---------------------------------------------------------------------------
# Tool-Definitionen (OpenAI Function Calling Format)
# ---------------------------------------------------------------------------
TOOL_DEFS = [
{
"type": "function",
"function": {
"name": "read_file",
"description": "Liest eine Textdatei auf dem Bosgame.",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Absoluter Pfad zur Datei"}
},
"required": ["path"],
},
},
},
{
"type": "function",
"function": {
"name": "list_directory",
"description": "Listet Dateien und Ordner eines Verzeichnisses auf dem Bosgame.",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Absoluter Pfad zum Verzeichnis"}
},
"required": ["path"],
},
},
},
{
"type": "function",
"function": {
"name": "run_command",
"description": (
"Fuehrt einen Lese-Befehl auf dem Bosgame aus (kein sudo, kein rm). "
"Geeignet fuer: ls, cat, ps, df, free, journalctl, systemctl status, ip, curl, etc."
),
"parameters": {
"type": "object",
"properties": {
"command": {"type": "string", "description": "Shell-Befehl"}
},
"required": ["command"],
},
},
},
{
"type": "function",
"function": {
"name": "get_system_status",
"description": "Gibt aktuellen System-Status zurueck: CPU, RAM, GPU, laufende Modelle.",
"parameters": {"type": "object", "properties": {}},
},
},
{
"type": "function",
"function": {
"name": "get_memories",
"description": "Laedt alle gespeicherten Fakten und Entscheidungen.",
"parameters": {"type": "object", "properties": {}},
},
},
{
"type": "function",
"function": {
"name": "add_memory",
"description": "Speichert eine neue Information dauerhaft ins Gedaechtnis.",
"parameters": {
"type": "object",
"properties": {
"content": {"type": "string", "description": "Der zu speichernde Fakt"},
"category": {
"type": "string",
"enum": ["stable", "versioned", "ephemeral"],
"description": "stable=Projektfakten, versioned=Tech-Versionen, ephemeral=temporaer (7 Tage)",
},
},
"required": ["content"],
},
},
},
{
"type": "function",
"function": {
"name": "web_search",
"description": "Sucht im Internet nach aktuellen Informationen.",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Suchbegriff"}
},
"required": ["query"],
},
},
},
]
# ---------------------------------------------------------------------------
# Tool-Implementierungen
# ---------------------------------------------------------------------------
_BLOCKED_CMDS = {
"rm", "rmdir", "mv", "cp", "dd", "mkfs", "fdisk",
"parted", "shutdown", "reboot", "halt", "poweroff",
"chmod", "chown", "passwd", "userdel", "useradd",
}
def _exec_read_file(path: str) -> str:
try:
p = Path(path)
if not p.exists():
return f"Datei nicht gefunden: {path}"
content = p.read_text(errors="replace")
if len(content) > 8000:
return content[:8000] + f"\n\n... (gekuerzt, gesamt {len(content):,} Zeichen)"
return content
except Exception as e:
return f"Fehler beim Lesen: {e}"
def _exec_list_directory(path: str) -> str:
try:
p = Path(path)
if not p.exists():
return f"Pfad nicht gefunden: {path}"
items = sorted(p.iterdir(), key=lambda x: (x.is_file(), x.name.lower()))
lines = []
for item in items[:80]:
if item.is_dir():
lines.append(f"📁 {item.name}/")
else:
size = item.stat().st_size
sz = f"{size/1024/1024:.1f} MB" if size > 1024*1024 else f"{size/1024:.1f} KB" if size > 1024 else f"{size} B"
lines.append(f"📄 {item.name} ({sz})")
total = sum(1 for _ in p.iterdir())
if total > 80:
lines.append(f"... und {total - 80} weitere")
return "\n".join(lines) if lines else "(leer)"
except Exception as e:
return f"Fehler: {e}"
def _exec_run_command(command: str) -> str:
first = command.strip().split()[0] if command.strip() else ""
if first in _BLOCKED_CMDS or "sudo" in command:
return f"Befehl '{first}' blockiert. Nutze Mission Control fuer Systemoperationen."
try:
result = subprocess.run(
command, shell=True, capture_output=True, text=True, timeout=30
)
out = (result.stdout + result.stderr).strip()
if len(out) > 4000:
out = out[:4000] + "\n... (Ausgabe gekuerzt)"
return out or "(kein Output)"
except subprocess.TimeoutExpired:
return "Timeout nach 30 Sekunden."
except Exception as e:
return f"Fehler: {e}"
def _exec_get_system_status() -> str:
base = "http://127.0.0.1:9000"
try:
s = httpx.get(f"{base}/api/status", timeout=5).json()
models = s.get("models", [])
running = [m for m in models if m.get("state") in ("running", "ready", "loading")]
model_str = ", ".join(m["name"] for m in running) if running else "keines"
except Exception:
model_str = "unbekannt"
try:
sys = httpx.get(f"{base}/api/system/status", timeout=5).json()
return (
f"Aktives Modell: {model_str}\n"
f"CPU: {sys.get('cpu_pct', '?')}% | "
f"RAM: {sys.get('ram_used_gb', '?')} / {sys.get('ram_total_gb', '?')} GB | "
f"GPU: {sys.get('gpu_mem_used_gb', '?')} / {sys.get('gpu_mem_total_gb', '?')} GB GTT\n"
f"Temp: CPU {sys.get('cpu_temp_c', '?')}°C"
)
except Exception as e:
return f"Aktives Modell: {model_str}\nSystem-Metriken nicht verfuegbar: {e}"
def _exec_get_memories() -> str:
try:
items = httpx.get("http://127.0.0.1:9000/api/memory", timeout=5).json()
if not items:
return "Kein Gedaechtnis vorhanden."
return "\n".join(f"[{m['category']}] {m['content']}" for m in items)
except Exception as e:
return f"Fehler: {e}"
def _exec_add_memory(content: str, category: str = "stable") -> str:
try:
r = httpx.post(
"http://127.0.0.1:9000/api/memory",
json={"content": content, "category": category, "source": "hermes"},
timeout=5,
)
r.raise_for_status()
return f"Gespeichert: {content}"
except Exception as e:
return f"Fehler: {e}"
def _exec_web_search(query: str) -> str:
try:
r = httpx.get(
"https://api.duckduckgo.com/",
params={"q": query, "format": "json", "no_html": 1, "skip_disambig": 1},
timeout=10,
headers={"User-Agent": "HermesAgent/1.0"},
follow_redirects=True,
)
data = r.json()
results = []
if data.get("AbstractText"):
results.append(data["AbstractText"])
for rt in data.get("RelatedTopics", [])[:4]:
if isinstance(rt, dict) and rt.get("Text"):
results.append(rt["Text"])
return "\n\n".join(results) if results else "Keine direkten Ergebnisse. Versuche eine genauere Suchanfrage."
except Exception as e:
return f"Websuche fehlgeschlagen: {e}"
def execute_tool(name: str, args: dict) -> str:
if name == "read_file":
return _exec_read_file(args.get("path", ""))
if name == "list_directory":
return _exec_list_directory(args.get("path", ""))
if name == "run_command":
return _exec_run_command(args.get("command", ""))
if name == "get_system_status":
return _exec_get_system_status()
if name == "get_memories":
return _exec_get_memories()
if name == "add_memory":
return _exec_add_memory(args.get("content", ""), args.get("category", "stable"))
if name == "web_search":
return _exec_web_search(args.get("query", ""))
return f"Unbekanntes Tool: {name}"
# ---------------------------------------------------------------------------
# Modell-Auswahl
# ---------------------------------------------------------------------------
_COMPLEX_KW = {
"schreib", "erstell", "baue", "plan", "refactor", "debug", "analysier",
"implementier", "entwickl", "code", "programm", "erklaer ausfuehrlich",
"erstelle", "generier",
}
def choose_model(message: str) -> str:
lower = message.lower()
if any(kw in lower for kw in _COMPLEX_KW):
return HERMES_COMPLEX_MODEL
return HERMES_SIMPLE_MODEL
# ---------------------------------------------------------------------------
# System-Prompt
# ---------------------------------------------------------------------------
SYSTEM_PROMPT = """\
Du bist Hermes, ein lokaler KI-Assistent der dauerhaft auf dem Bosgame M5 laeuft.
Dein Zuhause (Bosgame M5):
- AMD Strix Halo, ~124 GB GTT-Speicher, Ubuntu 26.04, Kernel 7.0
- LAN-IP: 192.168.178.151
- Dienste: llama-swap :8080 (Inference), Mission Control :9000 (Dashboard)
- Modelle: coder (Qwen3-30B-A3B), scout (Qwen3-8B), vision (Qwen3-VL)
- Du laeuft als Teil von Mission Control
Gespeichertes Wissen:
{memories}
Verhaltenregeln:
- Antworte praegnant auf Deutsch (max 2-3 Saetze wenn nicht ausdruecklich mehr gewuenscht)
- Kuendige an was du tust bevor du es tust
- Frag nach bevor du Dateien ueberschreibst oder Dienste neustartest
- Nutze get_system_status() wenn du nicht sicher bist was gerade laeuft
- Speichere wichtige Entscheidungen und Fakten per add_memory() ins Gedaechtnis
- Bei einfachen Fragen: keine Tools, direkt antworten
"""
# ---------------------------------------------------------------------------
# LLM-Aufruf (synchron, in Executor ausfuehren)
# ---------------------------------------------------------------------------
def _call_llm(model: str, messages: list) -> dict:
r = httpx.post(
f"{LLAMA_SWAP_URL}/v1/chat/completions",
json={
"model": model,
"messages": messages,
"tools": TOOL_DEFS,
"tool_choice": "auto",
"temperature": 0.3,
},
timeout=120,
)
r.raise_for_status()
return r.json()
# ---------------------------------------------------------------------------
# Haupt-Agent-Loop
# ---------------------------------------------------------------------------
SendFn = Callable[[dict], Awaitable[None]]
async def run_agent(message: str, send: SendFn) -> None:
"""
Fuehrt den Agenten-Loop aus und streamt Ergebnisse via send()-Callback.
Nachrichten-Typen:
{"type": "info", "content": str} — Status-Meldung
{"type": "thinking"} — LLM denkt nach
{"type": "token", "content": str} — Wort der Antwort
{"type": "tool_call", "name": str, "args": dict}
{"type": "tool_result", "name": str, "content": str}
{"type": "error", "content": str}
{"type": "done"}
"""
memories = _exec_get_memories()
model = choose_model(message)
await send({"type": "info", "content": f"Modell: {model}"})
messages = [
{"role": "system", "content": SYSTEM_PROMPT.format(memories=memories)},
{"role": "user", "content": message},
]
for _round in range(8):
await send({"type": "thinking"})
try:
result = await asyncio.get_event_loop().run_in_executor(
None, lambda: _call_llm(model, messages)
)
except Exception as exc:
await send({"type": "error", "content": f"LLM nicht erreichbar: {exc}"})
return
choice = result["choices"][0]
msg = choice["message"]
finish = choice.get("finish_reason", "")
tool_calls = msg.get("tool_calls") or []
# Text-Antwort: Wort fuer Wort streamen
if msg.get("content"):
words = msg["content"].split(" ")
for i, word in enumerate(words):
token = word + (" " if i < len(words) - 1 else "")
await send({"type": "token", "content": token})
await asyncio.sleep(0.012)
if not tool_calls or finish == "stop":
break
# Tool-Calls ausfuehren
messages.append(msg)
for tc in tool_calls:
fn = tc["function"]
name = fn["name"]
try:
args = json.loads(fn.get("arguments", "{}"))
except Exception:
args = {}
await send({"type": "tool_call", "name": name, "args": args})
tool_out = await asyncio.get_event_loop().run_in_executor(
None, lambda n=name, a=args: execute_tool(n, a)
)
preview = tool_out[:300] + "..." if len(tool_out) > 300 else tool_out
await send({"type": "tool_result", "name": name, "content": preview})
messages.append({
"role": "tool",
"tool_call_id": tc["id"],
"content": tool_out,
})
await send({"type": "done"})
+2
View File
@@ -5,3 +5,5 @@ ruamel.yaml>=0.18
psutil>=5.9.0
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
+222
View File
@@ -0,0 +1,222 @@
"""
Hermes Router — Chat-WebSocket, Voice-Endpoints, Setup-Status.
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/pubkey — Bosgame SSH-Public-Key fuer Windows-Setup
"""
import asyncio
import hashlib
import json
import subprocess
import tempfile
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Depends, UploadFile, File, WebSocket, WebSocketDisconnect
from fastapi.responses import Response, JSONResponse
from auth import auth
from config import (
PIPER_BIN, PIPER_VOICE, WHISPER_MODEL_SIZE,
HERMES_WINDOWS_HOST, HERMES_SSH_KEY,
)
from hermes_agent import run_agent
router = APIRouter(prefix="/api")
# ---------------------------------------------------------------------------
# Whisper (lazy-loaded, einmalig in RAM)
# ---------------------------------------------------------------------------
_whisper_model = None
_whisper_loading = False
def _get_whisper():
global _whisper_model, _whisper_loading
if _whisper_model is not None:
return _whisper_model
if _whisper_loading:
return None
_whisper_loading = True
try:
from faster_whisper import WhisperModel
_whisper_model = WhisperModel(WHISPER_MODEL_SIZE, device="cpu", compute_type="int8")
except Exception:
_whisper_model = None
finally:
_whisper_loading = False
return _whisper_model
# ---------------------------------------------------------------------------
# TTS-Cache (in-memory, max 64 Eintraege)
# ---------------------------------------------------------------------------
_tts_cache: dict[str, bytes] = {}
def _tts(text: str) -> Optional[bytes]:
key = hashlib.md5(text.encode()).hexdigest()
if key in _tts_cache:
return _tts_cache[key]
if not PIPER_BIN.exists() or not PIPER_VOICE.exists():
return None
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
wav_path = f.name
try:
result = subprocess.run(
[str(PIPER_BIN), "--model", str(PIPER_VOICE), "--output_file", wav_path],
input=text, capture_output=True, text=True, timeout=30
)
if result.returncode != 0:
return None
audio = Path(wav_path).read_bytes()
if len(_tts_cache) >= 64:
_tts_cache.pop(next(iter(_tts_cache)))
_tts_cache[key] = audio
return audio
except Exception:
return None
finally:
Path(wav_path).unlink(missing_ok=True)
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.websocket("/hermes/chat")
async def hermes_chat(websocket: WebSocket):
"""Streaming Chat mit dem Hermes Agent via WebSocket."""
# 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
async def send(obj: dict) -> None:
await websocket.send_json(obj)
await run_agent(message, send)
except WebSocketDisconnect:
pass
except Exception as exc:
try:
await websocket.send_json({"type": "error", "content": str(exc)})
except Exception:
pass
@router.post("/hermes/transcribe", dependencies=[Depends(auth)])
async def transcribe_audio(audio: UploadFile = File(...)):
"""Audio-Datei (webm/ogg/wav) per Whisper transkribieren."""
model = _get_whisper()
if model is None:
return JSONResponse(
status_code=503,
content={"error": "Whisper nicht verfuegbar. Bitte faster-whisper installieren."}
)
with tempfile.NamedTemporaryFile(suffix=".webm", delete=False) as f:
tmp = Path(f.name)
tmp.write_bytes(await audio.read())
try:
segments, _ = await asyncio.get_event_loop().run_in_executor(
None, lambda: model.transcribe(str(tmp), language="de")
)
text = " ".join(s.text.strip() for s in segments).strip()
return {"text": text}
except Exception as exc:
return JSONResponse(status_code=500, content={"error": str(exc)})
finally:
tmp.unlink(missing_ok=True)
@router.post("/hermes/tts", dependencies=[Depends(auth)])
async def text_to_speech(body: dict):
"""Text zu WAV-Audio per Piper TTS konvertieren."""
text = (body.get("text") or "").strip()
if not text:
return JSONResponse(status_code=400, content={"error": "Kein Text angegeben."})
audio = await asyncio.get_event_loop().run_in_executor(None, lambda: _tts(text))
if audio is None:
return JSONResponse(
status_code=503,
content={"error": "Piper TTS nicht verfuegbar. Bitte Piper Binary + Stimme installieren."}
)
return Response(content=audio, media_type="audio/wav")
@router.get("/hermes/status", dependencies=[Depends(auth)])
def hermes_status():
"""Status aller Hermes-Komponenten."""
# Whisper
try:
from faster_whisper import WhisperModel # noqa
whisper = "ready" if _whisper_model else "installed"
except ImportError:
whisper = "not_installed"
# Piper
piper = "ready" if (PIPER_BIN.exists() and PIPER_VOICE.exists()) else "not_installed"
# SSH
ssh_configured = bool(HERMES_WINDOWS_HOST) and HERMES_SSH_KEY.exists()
ssh_ok = False
if ssh_configured:
try:
import paramiko
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect(
HERMES_WINDOWS_HOST,
username=__import__("config").HERMES_WINDOWS_USER,
key_filename=str(HERMES_SSH_KEY),
timeout=3,
)
c.close()
ssh_ok = True
except Exception:
ssh_ok = False
return {
"whisper": whisper,
"piper": piper,
"ssh_configured": ssh_configured,
"ssh_ok": ssh_ok,
"windows_host": HERMES_WINDOWS_HOST or None,
}
@router.get("/hermes/pubkey", dependencies=[Depends(auth)])
def hermes_pubkey():
"""Gibt den SSH-Public-Key des Hermes Agent zurueck (fuer Windows authorized_keys)."""
pub = Path(str(HERMES_SSH_KEY) + ".pub")
if not pub.exists():
return JSONResponse(
status_code=404,
content={"error": "Kein SSH-Key gefunden. Bitte auf dem Bosgame generieren: "
"ssh-keygen -t ed25519 -C 'hermes-agent@bosgame' "
f"-f {HERMES_SSH_KEY} -N ''"}
)
return {"pubkey": pub.read_text().strip()}
+1 -1
View File
@@ -1 +1 @@
.guide-card.svelte-112sh7i{padding:0;overflow:hidden;margin-bottom:8px}.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)}
.guide-card.svelte-112sh7i{padding:0;overflow:hidden;margin-bottom:8px}.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}}
+46 -31
View File
File diff suppressed because one or more lines are too long
+2
View File
@@ -23,6 +23,7 @@
<a class="nav-item" data-view="news"><span class="ni-ic" data-ic="info"></span><span class="ni-tx">News</span></a>
<a class="nav-item" data-view="guides"><span class="ni-ic" data-ic="help"></span><span class="ni-tx">Guides</span></a>
<a class="nav-item" data-view="memory"><span class="ni-ic" data-ic="database"></span><span class="ni-tx">Gedächtnis</span></a>
<a class="nav-item" data-view="hermes"><span class="ni-ic" data-ic="bolt"></span><span class="ni-tx">Hermes</span></a>
</nav>
<div class="side-foot">
<span class="nav-item" id="nav-settings"><span class="ni-ic" data-ic="settings"></span><span class="ni-tx">Einstellungen</span></span>
@@ -50,6 +51,7 @@
<section class="view" data-view="news" hidden></section>
<section class="view" data-view="guides" hidden></section>
<section class="view" data-view="memory" hidden></section>
<section class="view" data-view="hermes" hidden></section>
</main>
</div>
</div>