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:
@@ -20,7 +20,7 @@ from fastapi import FastAPI, HTTPException
|
|||||||
from fastapi.responses import FileResponse, JSONResponse, Response
|
from fastapi.responses import FileResponse, JSONResponse, Response
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from routers import jobs, maintenance, memory, models, system, cookbook, integration, news
|
from routers import jobs, hermes, maintenance, memory, models, system, cookbook, integration, news
|
||||||
|
|
||||||
app = FastAPI(title="Mission Control")
|
app = FastAPI(title="Mission Control")
|
||||||
|
|
||||||
@@ -44,6 +44,7 @@ app.include_router(cookbook.router)
|
|||||||
app.include_router(integration.router)
|
app.include_router(integration.router)
|
||||||
app.include_router(news.router)
|
app.include_router(news.router)
|
||||||
app.include_router(memory.router)
|
app.include_router(memory.router)
|
||||||
|
app.include_router(hermes.router)
|
||||||
|
|
||||||
_STATIC = Path(__file__).parent / "static"
|
_STATIC = Path(__file__).parent / "static"
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,16 @@ UPDATE_CMD = os.environ.get("MC_UPDATE_CMD", "")
|
|||||||
DEFAULT_TTL = int(os.environ.get("MC_DEFAULT_TTL", "300"))
|
DEFAULT_TTL = int(os.environ.get("MC_DEFAULT_TTL", "300"))
|
||||||
TOKEN = os.environ.get("MC_TOKEN", "") # leer = keine Auth (nur LAN!)
|
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.
|
# Self-Update ("Mission Control aktualisieren"): Quelle = git-Repo, Prod = laufende Installation.
|
||||||
SOURCE_DIR = os.path.expanduser(os.environ.get("MC_SOURCE_DIR", "~/mission-control"))
|
SOURCE_DIR = os.path.expanduser(os.environ.get("MC_SOURCE_DIR", "~/mission-control"))
|
||||||
PROD_DIR = str(Path(__file__).resolve().parent)
|
PROD_DIR = str(Path(__file__).resolve().parent)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import ConnectPanel from './panels/ConnectPanel.svelte'
|
|||||||
import NewsPanel from './panels/NewsPanel.svelte'
|
import NewsPanel from './panels/NewsPanel.svelte'
|
||||||
import GuidesPanel from './panels/GuidesPanel.svelte'
|
import GuidesPanel from './panels/GuidesPanel.svelte'
|
||||||
import MemoryPanel from './panels/MemoryPanel.svelte'
|
import MemoryPanel from './panels/MemoryPanel.svelte'
|
||||||
|
import HermesPanel from './panels/HermesPanel.svelte'
|
||||||
|
|
||||||
let prevModelStates: Record<string, string> = {}
|
let prevModelStates: Record<string, string> = {}
|
||||||
|
|
||||||
@@ -158,6 +159,7 @@ const panels: [string, any][] = [
|
|||||||
['news', NewsPanel],
|
['news', NewsPanel],
|
||||||
['guides', GuidesPanel],
|
['guides', GuidesPanel],
|
||||||
['memory', MemoryPanel],
|
['memory', MemoryPanel],
|
||||||
|
['hermes', HermesPanel],
|
||||||
]
|
]
|
||||||
for (const [view, Panel] of panels) {
|
for (const [view, Panel] of panels) {
|
||||||
const el = document.querySelector(`.view[data-view="${view}"]`)
|
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>
|
||||||
+412
@@ -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"})
|
||||||
@@ -5,3 +5,5 @@ ruamel.yaml>=0.18
|
|||||||
psutil>=5.9.0
|
psutil>=5.9.0
|
||||||
huggingface_hub>=0.34 # liefert die `hf`-CLI fuer Modell-Downloads
|
huggingface_hub>=0.34 # liefert die `hf`-CLI fuer Modell-Downloads
|
||||||
mcp>=1.0 # MCP Python SDK fuer mcp_memory.py stdio-Server
|
mcp>=1.0 # MCP Python SDK fuer mcp_memory.py stdio-Server
|
||||||
|
faster-whisper>=1.0 # Lokale Sprach-zu-Text Transkription (Hermes Voice)
|
||||||
|
paramiko>=3.0 # SSH-Client fuer Hermes → Windows-PC Zugriff
|
||||||
|
|||||||
@@ -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()}
|
||||||
Vendored
+1
-1
@@ -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}}
|
||||||
|
|||||||
Vendored
+46
-31
File diff suppressed because one or more lines are too long
@@ -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="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="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="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>
|
</nav>
|
||||||
<div class="side-foot">
|
<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>
|
<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="news" hidden></section>
|
||||||
<section class="view" data-view="guides" hidden></section>
|
<section class="view" data-view="guides" hidden></section>
|
||||||
<section class="view" data-view="memory" hidden></section>
|
<section class="view" data-view="memory" hidden></section>
|
||||||
|
<section class="view" data-view="hermes" hidden></section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user