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.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"
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -16,6 +16,7 @@ import ConnectPanel from './panels/ConnectPanel.svelte'
|
||||
import NewsPanel from './panels/NewsPanel.svelte'
|
||||
import GuidesPanel from './panels/GuidesPanel.svelte'
|
||||
import MemoryPanel from './panels/MemoryPanel.svelte'
|
||||
import HermesPanel from './panels/HermesPanel.svelte'
|
||||
|
||||
let prevModelStates: Record<string, string> = {}
|
||||
|
||||
@@ -158,6 +159,7 @@ const panels: [string, any][] = [
|
||||
['news', NewsPanel],
|
||||
['guides', GuidesPanel],
|
||||
['memory', MemoryPanel],
|
||||
['hermes', HermesPanel],
|
||||
]
|
||||
for (const [view, Panel] of panels) {
|
||||
const el = document.querySelector(`.view[data-view="${view}"]`)
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
<script lang="ts">
|
||||
import { api } from '@core/api.js'
|
||||
import { toast } from '@core/ui.js'
|
||||
|
||||
// ---- Typen ----
|
||||
type MsgRole = 'user' | 'hermes' | 'info' | 'tool' | 'error'
|
||||
type Msg = { role: MsgRole; content: string; tool?: string; args?: any }
|
||||
|
||||
// ---- State ----
|
||||
let messages = $state<Msg[]>([])
|
||||
let input = $state('')
|
||||
let thinking = $state(false)
|
||||
let voiceOn = $state(false)
|
||||
let recording = $state(false)
|
||||
let status = $state<any>(null)
|
||||
let setupOpen = $state(false)
|
||||
let pubkey = $state('')
|
||||
let ws = $state<WebSocket | null>(null)
|
||||
let currentBot = $state('') // aktuell gestreamter Hermes-Token
|
||||
|
||||
// ---- WebSocket ----
|
||||
function connect() {
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const token = localStorage.getItem('mc_token') || ''
|
||||
const u = `${proto}//${location.host}/api/hermes/chat${token ? '?token=' + token : ''}`
|
||||
const sock = new WebSocket(u)
|
||||
|
||||
sock.onmessage = (e) => {
|
||||
const m = JSON.parse(e.data)
|
||||
if (m.type === 'thinking') {
|
||||
thinking = true
|
||||
} else if (m.type === 'info') {
|
||||
// kleine Info-Meldung (Modell-Anzeige)
|
||||
} else if (m.type === 'token') {
|
||||
thinking = false
|
||||
currentBot += m.content
|
||||
// letzter Eintrag aktualisieren oder neu anlegen
|
||||
const last = messages[messages.length - 1]
|
||||
if (last?.role === 'hermes') {
|
||||
messages = [...messages.slice(0, -1), { role: 'hermes', content: currentBot }]
|
||||
} else {
|
||||
messages = [...messages, { role: 'hermes', content: currentBot }]
|
||||
}
|
||||
} else if (m.type === 'tool_call') {
|
||||
thinking = false
|
||||
const args = m.args || {}
|
||||
const preview = Object.values(args).join(' ').slice(0, 60)
|
||||
messages = [...messages, { role: 'tool', content: preview, tool: m.name }]
|
||||
} else if (m.type === 'tool_result') {
|
||||
// Tool-Result in letzten Tool-Eintrag schreiben
|
||||
const last = messages.findLast(x => x.role === 'tool' && x.tool === m.name)
|
||||
if (last) last.content = m.content
|
||||
messages = [...messages]
|
||||
} else if (m.type === 'error') {
|
||||
thinking = false
|
||||
messages = [...messages, { role: 'error', content: m.content }]
|
||||
} else if (m.type === 'done') {
|
||||
thinking = false
|
||||
currentBot = ''
|
||||
if (voiceOn) speakLast()
|
||||
}
|
||||
}
|
||||
|
||||
sock.onclose = () => {
|
||||
ws = null
|
||||
setTimeout(connect, 3000)
|
||||
}
|
||||
sock.onerror = () => sock.close()
|
||||
ws = sock
|
||||
}
|
||||
|
||||
// ---- Senden ----
|
||||
async function send() {
|
||||
const text = input.trim()
|
||||
if (!text || !ws || ws.readyState !== 1) return
|
||||
messages = [...messages, { role: 'user', content: text }]
|
||||
input = ''
|
||||
currentBot = ''
|
||||
ws.send(JSON.stringify({ message: text }))
|
||||
}
|
||||
|
||||
// ---- Voice: Aufnahme ----
|
||||
let mediaRecorder: MediaRecorder | null = null
|
||||
let audioChunks: Blob[] = []
|
||||
|
||||
async function startRecording() {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
audioChunks = []
|
||||
mediaRecorder = new MediaRecorder(stream)
|
||||
mediaRecorder.ondataavailable = (e) => audioChunks.push(e.data)
|
||||
mediaRecorder.onstop = () => {
|
||||
stream.getTracks().forEach(t => t.stop())
|
||||
sendAudio()
|
||||
}
|
||||
mediaRecorder.start()
|
||||
recording = true
|
||||
} catch (e: any) {
|
||||
toast('Mikrofon-Zugriff verweigert: ' + e.message, true)
|
||||
}
|
||||
}
|
||||
|
||||
function stopRecording() {
|
||||
mediaRecorder?.stop()
|
||||
recording = false
|
||||
}
|
||||
|
||||
async function sendAudio() {
|
||||
const blob = new Blob(audioChunks, { type: 'audio/webm' })
|
||||
const form = new FormData()
|
||||
form.append('audio', blob, 'voice.webm')
|
||||
try {
|
||||
const r = await fetch('/api/hermes/transcribe', {
|
||||
method: 'POST',
|
||||
headers: localStorage.getItem('mc_token')
|
||||
? { 'X-MC-Token': localStorage.getItem('mc_token')! }
|
||||
: {},
|
||||
body: form,
|
||||
})
|
||||
const data = await r.json()
|
||||
if (data.text) {
|
||||
input = data.text
|
||||
send()
|
||||
} else {
|
||||
toast('Konnte Audio nicht verstehen.', true)
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast('Transkription fehlgeschlagen: ' + e.message, true)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Voice: Ausgabe ----
|
||||
async function speakLast() {
|
||||
const last = [...messages].reverse().find(m => m.role === 'hermes')
|
||||
if (!last) return
|
||||
try {
|
||||
const r = await fetch('/api/hermes/tts', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(localStorage.getItem('mc_token') ? { 'X-MC-Token': localStorage.getItem('mc_token')! } : {})
|
||||
},
|
||||
body: JSON.stringify({ text: last.content }),
|
||||
})
|
||||
if (!r.ok) return
|
||||
const blob = await r.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const audio = new Audio(url)
|
||||
audio.onended = () => URL.revokeObjectURL(url)
|
||||
audio.play()
|
||||
} catch { /* TTS optional */ }
|
||||
}
|
||||
|
||||
// ---- Status laden ----
|
||||
async function loadStatus() {
|
||||
try { status = await api('/api/hermes/status') }
|
||||
catch { status = null }
|
||||
}
|
||||
|
||||
async function loadPubkey() {
|
||||
try {
|
||||
const r = await api('/api/hermes/pubkey')
|
||||
pubkey = r.pubkey
|
||||
} catch { pubkey = '' }
|
||||
}
|
||||
|
||||
// ---- Hilfsfunktionen ----
|
||||
function toolLabel(name: string) {
|
||||
const map: Record<string, string> = {
|
||||
read_file: '📄 Lese Datei',
|
||||
list_directory: '📁 Liste Verzeichnis',
|
||||
run_command: '⚡ Befehl',
|
||||
get_system_status: '📊 System-Status',
|
||||
get_memories: '🧠 Gedächtnis',
|
||||
add_memory: '💾 Speichern',
|
||||
web_search: '🌐 Websuche',
|
||||
}
|
||||
return map[name] || name
|
||||
}
|
||||
|
||||
function copyKey() {
|
||||
navigator.clipboard?.writeText(pubkey)
|
||||
toast('SSH-Key kopiert')
|
||||
}
|
||||
|
||||
// ---- Boot ----
|
||||
$effect(() => {
|
||||
connect()
|
||||
loadStatus()
|
||||
})
|
||||
</script>
|
||||
|
||||
<!-- ==================== HEADER ==================== -->
|
||||
<div class="pagehead" style="display:flex;justify-content:space-between;align-items:flex-start">
|
||||
<div>
|
||||
<h1>Hermes</h1>
|
||||
<div class="sub">Dein lokaler KI-Assistent — kennt den Bosgame, lernt mit.</div>
|
||||
</div>
|
||||
<div class="flex gap-2" style="flex-shrink:0;margin-top:6px;flex-wrap:wrap;align-items:center">
|
||||
<!-- Whisper-Status -->
|
||||
<span class="chip" style="font-size:11px;{status?.whisper === 'ready' || status?.whisper === 'installed'
|
||||
? 'color:var(--accent);border-color:var(--accent)'
|
||||
: 'color:var(--mut)'}">
|
||||
🎙 Whisper: {status?.whisper ?? '…'}
|
||||
</span>
|
||||
<!-- SSH-Status -->
|
||||
<span class="chip" style="font-size:11px;{status?.ssh_ok
|
||||
? 'color:var(--accent);border-color:var(--accent)'
|
||||
: 'color:var(--mut)'}">
|
||||
🔑 SSH: {status?.ssh_ok ? 'verbunden' : status?.ssh_configured ? 'Fehler' : 'nicht eingerichtet'}
|
||||
</span>
|
||||
<!-- Voice Toggle -->
|
||||
<button class="ghost" style="padding:4px 12px;font-size:12px;{voiceOn ? 'border-color:var(--accent);color:var(--accent)' : ''}"
|
||||
onclick={() => voiceOn = !voiceOn}>
|
||||
🔊 Voice {voiceOn ? 'an' : 'aus'}
|
||||
</button>
|
||||
<!-- Setup -->
|
||||
<button class="ghost" style="padding:4px 10px;font-size:12px"
|
||||
onclick={() => { setupOpen = !setupOpen; if (setupOpen) { loadStatus(); loadPubkey() } }}>
|
||||
⚙ Setup
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== SETUP-WIZARD ==================== -->
|
||||
{#if setupOpen}
|
||||
<div class="card" style="border-color:rgba(45,212,191,.3)">
|
||||
<div class="card-h"><h3>Hermes Setup-Wizard</h3></div>
|
||||
|
||||
<!-- Whisper -->
|
||||
<div class="tile" style="margin-bottom:10px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<div>
|
||||
<b>Whisper (Sprache → Text)</b>
|
||||
<div class="card-sub" style="font-size:12px">faster-whisper muss auf dem Bosgame installiert sein</div>
|
||||
</div>
|
||||
<span class="chip" style="font-size:11px;{status?.whisper !== 'not_installed' ? 'color:#7ee29a' : 'color:#ff9b95'}">
|
||||
{status?.whisper !== 'not_installed' ? '✓ OK' : '✗ fehlt'}
|
||||
</span>
|
||||
</div>
|
||||
{#if status?.whisper === 'not_installed'}
|
||||
<div class="log" style="margin-top:8px"><code>pip install faster-whisper</code></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Piper -->
|
||||
<div class="tile" style="margin-bottom:10px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<div>
|
||||
<b>Piper TTS (Text → Sprache)</b>
|
||||
<div class="card-sub" style="font-size:12px">Piper-Binary + deutsche Stimme</div>
|
||||
</div>
|
||||
<span class="chip" style="font-size:11px;{status?.piper === 'ready' ? 'color:#7ee29a' : 'color:#ff9b95'}">
|
||||
{status?.piper === 'ready' ? '✓ OK' : '✗ fehlt'}
|
||||
</span>
|
||||
</div>
|
||||
{#if status?.piper !== 'ready'}
|
||||
<div class="log" style="margin-top:8px"><code>{"# Auf dem Bosgame:\nmkdir -p /opt/mission-control/piper/voices\n# Piper Binary + Thorsten-Stimme (Anleitung in Guides)"}</code></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Windows SSH -->
|
||||
<div class="tile">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<div>
|
||||
<b>Windows SSH-Verbindung</b>
|
||||
<div class="card-sub" style="font-size:12px">Damit Hermes Dateien auf deinem Windows-PC ändern kann</div>
|
||||
</div>
|
||||
<span class="chip" style="font-size:11px;{status?.ssh_ok ? 'color:#7ee29a' : 'color:var(--mut)'}">
|
||||
{status?.ssh_ok ? '✓ verbunden' : 'optional'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if !status?.ssh_ok}
|
||||
<div style="margin-top:12px">
|
||||
<div style="font-size:12.5px;font-weight:500;margin-bottom:8px">Einrichtung (einmalig, ~3 Minuten):</div>
|
||||
|
||||
<div style="margin-bottom:8px">
|
||||
<div class="card-sub" style="font-size:11.5px;margin-bottom:4px">1. PowerShell als Admin — OpenSSH Server installieren:</div>
|
||||
<div class="log"><code>Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0</code></div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:8px">
|
||||
<div class="card-sub" style="font-size:11.5px;margin-bottom:4px">2. Dienst starten und dauerhaft aktivieren:</div>
|
||||
<div class="log"><code>{"Start-Service sshd\nSet-Service -Name sshd -StartupType Automatic"}</code></div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:8px">
|
||||
<div class="card-sub" style="font-size:11.5px;margin-bottom:4px">3. SSH-Ordner erstellen:</div>
|
||||
<div class="log"><code>{"New-Item -ItemType Directory -Force -Path \"$env:USERPROFILE\\.ssh\""}</code></div>
|
||||
</div>
|
||||
|
||||
{#if pubkey}
|
||||
<div style="margin-bottom:8px">
|
||||
<div class="card-sub" style="font-size:11.5px;margin-bottom:4px">4. Bosgame-Key eintragen (in Windows PowerShell):</div>
|
||||
<div style="position:relative">
|
||||
<button class="ghost" style="position:absolute;top:6px;right:6px;font-size:11px;padding:2px 8px;z-index:1"
|
||||
onclick={copyKey}>Kopieren</button>
|
||||
<div class="log"><code>{"echo \"" + pubkey + "\" >> \"$env:USERPROFILE\\.ssh\\authorized_keys\""}</code></div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="card-sub" style="font-size:11.5px">SSH-Key nicht gefunden — bitte auf dem Bosgame generieren:
|
||||
<code style="display:block;margin-top:4px">ssh-keygen -t ed25519 -C "hermes-agent@bosgame" -f ~/.ssh/id_ed25519_hermes_agent -N ""</code>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div style="margin-bottom:8px">
|
||||
<div class="card-sub" style="font-size:11.5px;margin-bottom:4px">5. Windows-IP in Mission Control Env-Var setzen:</div>
|
||||
<div class="log"><code>HERMES_WINDOWS_HOST=192.168.178.XXX</code></div>
|
||||
</div>
|
||||
|
||||
<button class="primary" style="margin-top:4px" onclick={() => { loadStatus(); toast('Status aktualisiert') }}>
|
||||
Verbindung testen
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ==================== CHAT ==================== -->
|
||||
<div class="card" style="padding:0;display:flex;flex-direction:column;min-height:420px">
|
||||
|
||||
<!-- Nachrichten-Liste -->
|
||||
<div style="flex:1;overflow-y:auto;padding:16px;display:flex;flex-direction:column;gap:10px"
|
||||
id="hermes-msgs">
|
||||
{#if messages.length === 0}
|
||||
<div class="empty-c" style="margin:auto">
|
||||
<div class="e-t">Hermes ist bereit</div>
|
||||
<div class="e-s">Stelle eine Frage oder gib einen Auftrag — per Text oder Sprache.</div>
|
||||
<div style="margin-top:16px;display:flex;flex-direction:column;gap:6px">
|
||||
{#each [
|
||||
'Was läuft gerade auf dem System?',
|
||||
'Zeig mir den Inhalt von /etc/llama-swap/config.yaml',
|
||||
'Suche nach den neuesten Qwen3-Modellen',
|
||||
] as suggestion}
|
||||
<button class="ghost" style="font-size:12.5px;text-align:left"
|
||||
onclick={() => { input = suggestion; send() }}>
|
||||
{suggestion}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#each messages as msg (msg)}
|
||||
{#if msg.role === 'user'}
|
||||
<div style="display:flex;justify-content:flex-end">
|
||||
<div style="background:rgba(45,212,191,.15);border:1px solid rgba(45,212,191,.3);
|
||||
border-radius:12px 12px 2px 12px;padding:10px 14px;max-width:75%;
|
||||
font-size:13.5px;line-height:1.5;word-break:break-word">
|
||||
{msg.content}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if msg.role === 'hermes'}
|
||||
<div style="display:flex;gap:8px;align-items:flex-start">
|
||||
<div style="width:28px;height:28px;border-radius:50%;background:rgba(45,212,191,.2);
|
||||
border:1px solid rgba(45,212,191,.4);display:flex;align-items:center;
|
||||
justify-content:center;font-size:14px;flex-shrink:0">H</div>
|
||||
<div style="background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.08);
|
||||
border-radius:2px 12px 12px 12px;padding:10px 14px;max-width:80%;
|
||||
font-size:13.5px;line-height:1.6;word-break:break-word;white-space:pre-wrap">
|
||||
{msg.content}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if msg.role === 'tool'}
|
||||
<div style="display:flex;align-items:center;gap:8px;color:var(--mut);font-size:11.5px;
|
||||
padding:4px 8px;background:rgba(255,255,255,.02);border-radius:6px">
|
||||
<span>{toolLabel(msg.tool || '')}</span>
|
||||
{#if msg.content}
|
||||
<span style="font-family:monospace;font-size:11px;opacity:.7;overflow:hidden;
|
||||
text-overflow:ellipsis;white-space:nowrap;max-width:400px">
|
||||
{msg.content}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{:else if msg.role === 'info'}
|
||||
<div style="text-align:center;color:var(--mut);font-size:11px">{msg.content}</div>
|
||||
|
||||
{:else if msg.role === 'error'}
|
||||
<div style="background:rgba(240,87,63,.1);border:1px solid rgba(240,87,63,.3);
|
||||
border-radius:8px;padding:8px 12px;color:#ff9b95;font-size:12.5px">
|
||||
⚠ {msg.content}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#if thinking}
|
||||
<div style="display:flex;gap:8px;align-items:center;color:var(--mut);font-size:12px">
|
||||
<div style="width:28px;height:28px;border-radius:50%;background:rgba(45,212,191,.1);
|
||||
border:1px solid rgba(45,212,191,.2);display:flex;align-items:center;
|
||||
justify-content:center;font-size:14px">H</div>
|
||||
<span style="animation:pulse 1.2s ease-in-out infinite">denkt nach…</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Input-Bereich -->
|
||||
<div style="border-top:1px solid rgba(255,255,255,.07);padding:12px 16px;display:flex;gap:8px;align-items:flex-end">
|
||||
<textarea
|
||||
rows="2"
|
||||
placeholder="Frage stellen oder Aufgabe beschreiben…"
|
||||
style="flex:1;resize:none;font-size:13.5px;line-height:1.5;min-height:44px;max-height:160px"
|
||||
bind:value={input}
|
||||
onkeydown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() } }}
|
||||
></textarea>
|
||||
|
||||
<!-- Mikrofon-Button -->
|
||||
<button
|
||||
class="ghost"
|
||||
style="padding:10px 12px;flex-shrink:0;font-size:18px;line-height:1;
|
||||
{recording ? 'border-color:var(--err);color:var(--err)' : ''}"
|
||||
title={recording ? 'Aufnahme stoppen' : 'Spracheingabe'}
|
||||
onmousedown={startRecording}
|
||||
onmouseup={stopRecording}
|
||||
ontouchstart={startRecording}
|
||||
ontouchend={stopRecording}>
|
||||
{recording ? '⏹' : '🎤'}
|
||||
</button>
|
||||
|
||||
<!-- Senden-Button -->
|
||||
<button
|
||||
class="primary"
|
||||
style="padding:10px 18px;flex-shrink:0"
|
||||
disabled={!input.trim() || !ws || ws.readyState !== 1}
|
||||
onclick={send}>
|
||||
Senden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1 }
|
||||
50% { opacity: .4 }
|
||||
}
|
||||
</style>
|
||||
+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
|
||||
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
|
||||
|
||||
@@ -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="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>
|
||||
|
||||
Reference in New Issue
Block a user