fix(v8.1): Kritische Bugs behoben — system_status, model-routing, UI

P0:
- hermes_agent: system_status Key-Mismatch behoben (cpu_pct→cpu.percent,
  ram_used_gb→ram.used/1024³, gtt analog) — zeigt jetzt echte Werte statt '?'
- guides-data: & in 4 Titel-Strings → & (Svelte text-node rendert keine HTML-Entities)

P1:
- hermes_agent: choose_model() um recherchier/such/vergleich/etc. erweitert;
  kein Downgrade wenn komplexes Modell bereits geladen
- routers/hermes: asyncio.get_event_loop() → get_running_loop() (Whisper+TTS)
- maintenance: llama-swap Update akzeptiert optionales sudo-PW via stdin;
  import os hinzugefügt (fix NameError in updates()); UpdateReq-Model
- ServerPanel: Engine-Update fragt jetzt nach sudo-PW (analog OS-Update)
- HermesPanel: Chat-History in sessionStorage (überlebt Reload);
  Stopp-Button während Hermes antwortet; Verlauf-löschen-Button
- ConnectPanel: opencode.json → opencode.jsonc; provider → providers;
  LAN-IP Override (localStorage) für NPM-Proxy-Setups

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-23 18:19:01 +02:00
parent 8539999627
commit e8971fbf0c
8 changed files with 204 additions and 89 deletions
+27 -8
View File
@@ -6,17 +6,29 @@
const s = $derived(statusStore.value)
const allModels = $derived(((s?.models || []) as any[]).filter((m: any) => !m.incomplete))
const url = $derived(baseUrl())
let selectedTool = $state('zed')
let selectedModelIds = $state<string[]>([])
let testResult = $state('')
let testOk = $state(false)
let selectedMemTool = $state('cline')
const memBlock = $derived(memMcpBlock(selectedMemTool))
let ipOverride = $state<string>(localStorage.getItem('mc_ip_override') || '')
function baseUrl() { return `http://${location.hostname}:8080/v1` }
function isLanIp(h: string) { return /^\d{1,3}(\.\d{1,3}){3}$/.test(h) }
function behindProxy() { return location.protocol === 'https:' || !isLanIp(location.hostname) }
function baseUrl() {
const host = ipOverride.trim() || location.hostname
return `http://${host}:8080/v1`
}
const url = $derived(baseUrl())
function saveIpOverride() {
const v = ipOverride.trim()
if (v) localStorage.setItem('mc_ip_override', v)
else localStorage.removeItem('mc_ip_override')
}
const memBlock = $derived(memMcpBlock(selectedMemTool))
const activeIds = $derived(
selectedModelIds.length ? selectedModelIds : allModels.map((m: any) => m.name)
@@ -42,7 +54,7 @@
const ctx = m.meta?.ctx || 131072
return ` "${m.name}": { "name": "${m.name}", "contextWindowSize": ${ctx} }`
}).join(',\n')
return `"provider": {\n "llama-swap": {\n "npm": "@ai-sdk/openai-compatible",\n "name": "Bosgame (llama-swap)",\n "options": {\n "baseURL": "${url}",\n "apiKey": "local"\n },\n "models": {\n${modelMap}\n }\n }\n}`
return `"providers": {\n "llama-swap": {\n "npm": "@ai-sdk/openai-compatible",\n "name": "Bosgame (llama-swap)",\n "options": {\n "baseURL": "${url}",\n "apiKey": "local"\n },\n "models": {\n${modelMap}\n }\n }\n}`
}
function continueBlock(url: string, ids: string[]) {
@@ -97,10 +109,16 @@
<div class="card-h"><h3>1 · Verbindung prüfen</h3></div>
<div class="card-sub">Stelle sicher, dass die LLM-Engine erreichbar ist, bevor du Tools konfigurierst.</div>
<label>Engine-Adresse (für alle Tools)</label>
<div class="flex gap-2" style="align-items:stretch;margin-bottom:12px">
<div class="flex gap-2" style="align-items:stretch;margin-bottom:4px">
<input class="mono-sm" readonly value={url} style="flex:1;margin:0">
<button class="ghost" onclick={e => { const inp = (e.currentTarget as HTMLElement).previousElementSibling as HTMLInputElement; copyVal(inp) }}>Kopieren</button>
</div>
<div class="flex gap-2" style="align-items:stretch;margin-bottom:12px">
<input class="mono-sm" placeholder="LAN-IP überschreiben, z.B. 192.168.178.151 (leer = auto)"
bind:value={ipOverride}
oninput={saveIpOverride}
style="flex:1;margin:0;font-size:12px;opacity:.7">
</div>
{#if behindProxy()}
<div class="alert warn" style="margin:0 0 12px">
<span class="a-dot"></span>
@@ -254,14 +272,15 @@
<div class="hint">Danach im Agent-Panel das Modell <b>bosgame / {firstModel}</b> auswählen.</div>
{:else if selectedTool === 'opencode'}
{@const block = openCodeBlock(url, activeIds)}
<p>Datei anlegen oder öffnen:</p>
<p class="mono-sm" style="line-height:1.6">Windows: <code>%USERPROFILE%\.config\opencode\opencode.json</code></p>
<div class="alert warn" style="margin:8px 0 12px;padding:10px 14px"><span class="a-dot"></span><span>Nur den <code>"provider"</code>-Block einfügen. Falls bereits ein <code>"llama-swap"</code>-Eintrag vorhanden: <b>vorher löschen</b>, sonst erscheinen Modelle doppelt.</span></div>
<p>Datei anlegen oder öffnen (OpenCode Desktop nutzt <b>.jsonc</b> — JSON mit Kommentaren):</p>
<p class="mono-sm" style="line-height:1.6">Windows: <code>%USERPROFILE%\.config\opencode\opencode.jsonc</code></p>
<div class="alert warn" style="margin:8px 0 12px;padding:10px 14px"><span class="a-dot"></span><span>Den <code>"providers"</code>-Block in die bestehende <code>opencode.jsonc</code> einfügen (nicht die ganze Datei ersetzen). Falls bereits ein <code>"llama-swap"</code>-Eintrag existiert: <b>vorher löschen</b>.</span></div>
<div style="position:relative">
<button class="ghost" style="position:absolute;top:8px;right:8px;z-index:1" onclick={e => copyCode(e.currentTarget!.parentElement!)}>Kopieren</button>
<div class="log" style="max-height:none"><code>{block}</code></div>
</div>
<p style="margin-top:12px">Danach in OpenCode <code>/models</code><b>llama-swap/{firstModel}</b> wählen.</p>
<div class="hint">OpenCode Desktop: <a href="https://opencode.ai" target="_blank" rel="noopener">opencode.ai</a></div>
{:else if selectedTool === 'cline'}
<p>In Cline/Cursor den Provider <b>„OpenAI Compatible"</b> wählen:</p>
<label>Basis-URL</label>
+56 -9
View File
@@ -6,10 +6,15 @@
type MsgRole = 'user' | 'hermes' | 'info' | 'tool' | 'error'
type Msg = { role: MsgRole; content: string; tool?: string; args?: any }
const SESSION_KEY = 'hermes_chat_history'
// ---- State ----
let messages = $state<Msg[]>([])
let messages = $state<Msg[]>((() => {
try { return JSON.parse(sessionStorage.getItem(SESSION_KEY) || '[]') } catch { return [] }
})())
let input = $state('')
let thinking = $state(false)
let stopping = $state(false)
let voiceOn = $state(false)
let recording = $state(false)
let status = $state<any>(null)
@@ -18,6 +23,11 @@
let ws = $state<WebSocket | null>(null)
let currentBot = $state('') // aktuell gestreamter Hermes-Token
// Nachrichten in sessionStorage sichern (lebt nur für aktive Browser-Tab-Session)
$effect(() => {
try { sessionStorage.setItem(SESSION_KEY, JSON.stringify(messages)) } catch {}
})
// ---- WebSocket ----
function connect() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
@@ -56,6 +66,7 @@
messages = [...messages, { role: 'error', content: m.content }]
} else if (m.type === 'done') {
thinking = false
stopping = false
currentBot = ''
if (voiceOn) speakLast()
}
@@ -69,6 +80,18 @@
ws = sock
}
// ---- Stopp ----
function stopGeneration() {
stopping = true
thinking = false
if (currentBot) {
messages = [...messages.slice(0, -1),
{ role: 'hermes', content: currentBot + ' ✋' }]
}
currentBot = ''
ws?.close() // onclose reconnects nach 3s automatisch
}
// ---- Senden ----
async function send() {
const text = input.trim()
@@ -76,9 +99,17 @@
messages = [...messages, { role: 'user', content: text }]
input = ''
currentBot = ''
stopping = false
ws.send(JSON.stringify({ message: text }))
}
// ---- Verlauf löschen ----
function clearHistory() {
messages = []
currentBot = ''
try { sessionStorage.removeItem(SESSION_KEY) } catch {}
}
// ---- Voice: Aufnahme ----
let mediaRecorder: MediaRecorder | null = null
let audioChunks: Blob[] = []
@@ -433,15 +464,31 @@
{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>
{#if thinking || stopping}
<!-- Stopp-Button während Hermes antwortet -->
<button class="ghost" style="padding:10px 18px;flex-shrink:0;border-color:var(--err);color:var(--err)"
onclick={stopGeneration}>
⏹ Stopp
</button>
{:else}
<!-- Senden-Button -->
<button
class="primary"
style="padding:10px 18px;flex-shrink:0"
disabled={!input.trim() || !ws || ws.readyState !== 1}
onclick={send}>
Senden
</button>
{/if}
</div>
<!-- Fußzeile: Verlauf löschen -->
{#if messages.length > 0 && !thinking}
<div style="border-top:1px solid rgba(255,255,255,.04);padding:6px 16px;display:flex;justify-content:flex-end">
<button class="ghost" style="font-size:11px;padding:2px 8px;color:var(--mut)"
onclick={clearHistory}>Verlauf löschen</button>
</div>
{/if}
</div>
<style>
+3 -1
View File
@@ -74,7 +74,9 @@
async function engineUpdate() {
if (!await confirmModal({ title: 'LLM-Engine aktualisieren?', body: 'Lädt die neueste llama.cpp-ROCm-Version und installiert sie. Die Engine ist während des Updates kurz nicht erreichbar.' })) return
try { const r = await api('/api/update', { method: 'POST' }); toast('LLM-Engine-Update läuft.'); watchJob(r.job_id, 'LLM-Engine-Update') }
const pwd = await promptModal({ title: 'sudo-Passwort', body: 'Für das Engine-Update wird einmalig dein sudo-Passwort gebraucht (für den Update-Befehl).', placeholder: 'sudo-Passwort', password: true, confirmLabel: 'Update starten' })
if (pwd === null) return
try { const r = await api('/api/update', { method: 'POST', body: JSON.stringify({ password: pwd }) }); toast('LLM-Engine-Update läuft.'); watchJob(r.job_id, 'LLM-Engine-Update') }
catch (e: any) { toast(e.message, true) }
}
+4 -4
View File
@@ -370,7 +370,7 @@ export const GUIDE_GROUPS: GuideGroup[] = [
<p><b>Merksatz:</b> Fehlt dem Agenten eine <em>Fähigkeit</em> MCP.
Soll er etwas nur <em>besser machen</em> Skill (nächster Abschnitt).</p>`],
["Skills &amp; Rules — Agenten Gewohnheiten beibringen", `
["Skills & Rules — Agenten Gewohnheiten beibringen", `
<p><b>Skills</b> (auch <em>Rules</em>, <em>AGENTS.md</em> oder <em>Instructions</em> genannt)
sind kurze Texte, die dem Agenten beibringen wie er in deinem Projekt arbeiten soll
immer, ohne dass du es jedes Mal neu sagen musst.</p>
@@ -418,7 +418,7 @@ Bevor du Dateien änderst: erkläre kurz was du vorhast.</pre>
kritische Probleme (Prompt Injection, exponierte Secrets). Regel: Skill-Datei kurz lesen bevor du sie einsetzt.</p>
<p style="margin-top:8px">Oder einfach fragen: <em>Schreib mir eine AGENTS.md für ein React + TypeScript Projekt mit Tailwind."</em></p>`],
["Die Persona — Rolle, Beziehung &amp; Onboarding", `
["Die Persona — Rolle, Beziehung & Onboarding", `
<p>Eine Persona hat <b>zwei Dimensionen</b> beide sind wichtig:</p>
<div class="tut-table">
<div class="tut-row">
@@ -503,7 +503,7 @@ Wenn du alle Antworten hast, erstelle daraus:
</div>
<p class="tut-sources" style="margin-top:10px">Fertige Personas: <a href="https://skills.sh" target="_blank" rel="noopener">skills.sh</a> · <a href="https://cursor.directory" target="_blank" rel="noopener">cursor.directory</a></p>`],
["AGENTS.MD &amp; SAVEPOINT.MD — Gedächtnis für den Agenten", `
["AGENTS.MD & SAVEPOINT.MD — Gedächtnis für den Agenten", `
<p>Jedes Mal wenn du einen neuen Chat startest, weiß der Agent <b>nichts</b> über dein Projekt.
Diese zwei Dateien lösen das Problem.</p>
<p><b>AGENTS.md ist 2026 der plattformübergreifende Standard</b> nativ gelesen von:
@@ -593,7 +593,7 @@ src/
{
label: 'Wenn etwas nicht klappt',
tutorials: [
["Häufige Probleme &amp; Lösungen", `
["Häufige Probleme & Lösungen", `
<div class="tut-table">
<div class="tut-row">
<div class="tut-cell-hd">🔴 Engine offline, rotes Licht in der Topbar</div>
+47 -11
View File
@@ -197,13 +197,22 @@ def _exec_get_system_status() -> str:
model_str = "unbekannt"
try:
sys = httpx.get(f"{base}/api/system/status", timeout=5).json()
d = httpx.get(f"{base}/api/system/status", timeout=5).json()
cpu = d.get("cpu", {})
ram = d.get("ram", {})
gtt = d.get("gpu", {}).get("gtt", {})
cpu_pct = round(cpu.get("percent", 0), 1)
cpu_temp = cpu.get("temp", "?")
ram_used = round(ram.get("used", 0) / (1024 ** 3), 1)
ram_total = round(ram.get("total", 0) / (1024 ** 3), 1)
gtt_used = round(gtt.get("used", 0) / (1024 ** 3), 1)
gtt_total = round(gtt.get("total", 0) / (1024 ** 3), 1)
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"
f"CPU: {cpu_pct}% | "
f"RAM: {ram_used} / {ram_total} GB | "
f"GPU: {gtt_used} / {gtt_total} GB GTT\n"
f"Temp: CPU {cpu_temp}°C"
)
except Exception as e:
return f"Aktives Modell: {model_str}\nSystem-Metriken nicht verfuegbar: {e}"
@@ -275,17 +284,44 @@ def execute_tool(name: str, args: dict) -> str:
# ---------------------------------------------------------------------------
_COMPLEX_KW = {
"schreib", "erstell", "baue", "plan", "refactor", "debug", "analysier",
"implementier", "entwickl", "code", "programm", "erklaer ausfuehrlich",
"erstelle", "generier",
# Erstellen / Schreiben
"schreib", "schreibe", "erstell", "erstelle", "generier", "generiere",
"baue", "bau",
# Planen / Analysieren / Recherchieren
"plan", "analysier", "analysiere", "recherchier", "recherchiere",
"vergleich", "vergleiche", "bewert", "bewerte", "untersuche", "untersuchen",
# Entwicklung / Code
"refactor", "debug", "implementier", "implementiere", "entwickl", "entwickle",
"code", "programm", "programmier", "programmiere",
# Web-Suche / Recherche
"such im internet", "suche im internet", "such online", "suche online",
"such nach aktuell", "suche nach aktuell",
# Erklären (ausführlich)
"erklaer ausfuehrlich", "erklaere ausfuehrlich", "erklaer detailliert",
"erklaere detailliert", "erklaer mir genau", "was ist der unterschied",
# Optimieren / Verbessern
"optimier", "optimiere", "verbessere", "verbessern", "reparier", "repariere",
# Multi-Step / Komplex
"konfiguriere", "konfigurieren", "installier", "installiere",
"wie kann ich", "schritt fuer schritt", "schritt für schritt",
}
def choose_model(message: str) -> str:
"""Waehlt simple/complex Modell — upgraded wenn noetig, degradiert nie."""
lower = message.lower()
if any(kw in lower for kw in _COMPLEX_KW):
return HERMES_COMPLEX_MODEL
return HERMES_SIMPLE_MODEL
wants_complex = any(kw in lower for kw in _COMPLEX_KW)
# Nicht downgraden wenn komplexes Modell bereits geladen ist
try:
s = httpx.get("http://127.0.0.1:9000/api/status", timeout=3).json()
loaded = {m["name"] for m in s.get("models", []) if m.get("state") in ("running", "ready")}
if HERMES_COMPLEX_MODEL in loaded:
return HERMES_COMPLEX_MODEL
except Exception:
pass
return HERMES_COMPLEX_MODEL if wants_complex else HERMES_SIMPLE_MODEL
# ---------------------------------------------------------------------------
# System-Prompt
+2 -2
View File
@@ -148,7 +148,7 @@ async def transcribe_audio(audio: UploadFile = File(...)):
tmp = Path(f.name)
tmp.write_bytes(await audio.read())
try:
segments, _ = await asyncio.get_event_loop().run_in_executor(
segments, _ = await asyncio.get_running_loop().run_in_executor(
None, lambda: model.transcribe(str(tmp), language="de")
)
text = " ".join(s.text.strip() for s in segments).strip()
@@ -165,7 +165,7 @@ async def text_to_speech(body: dict):
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))
audio = await asyncio.get_running_loop().run_in_executor(None, lambda: _tts(text))
if audio is None:
return JSONResponse(
status_code=503,
+15 -4
View File
@@ -6,6 +6,7 @@ laeuft als Hintergrund-Job mit Live-Log. Spaeter wandert hier ggf. mehr
Server-Wartung hinein (siehe Roadmap: Server-Management).
"""
import os
import shlex
import subprocess
import asyncio
@@ -22,12 +23,22 @@ router = APIRouter(prefix="/api", dependencies=[Depends(auth)])
class PwdReq(BaseModel):
password: str
class UpdateReq(BaseModel):
password: str = ""
@router.post("/update")
def update():
"""LLM-Engine (llama.cpp) aktualisieren — Befehl aus MC_UPDATE_CMD."""
def update(req: UpdateReq = None):
"""LLM-Engine (llama.cpp) aktualisieren — Befehl aus MC_UPDATE_CMD.
Optionales Passwort fuer sudo-Befehle (via stdin, nicht in argv)."""
if not UPDATE_CMD:
raise HTTPException(400, "Kein Update-Befehl gesetzt (MC_UPDATE_CMD).")
job_id = start_job(shlex.split(UPDATE_CMD), "llama.cpp-Update")
pwd = (req.password if req else "").strip()
if pwd:
args = ["sudo", "-S", "bash", "-c", UPDATE_CMD]
job_id = start_job(args, "llama.cpp-Update", stdin_data=pwd + "\n",
log_cmd=f"$ sudo {UPDATE_CMD}")
else:
job_id = start_job(shlex.split(UPDATE_CMD), "llama.cpp-Update")
return {"job_id": job_id}
@router.post("/self-update")
@@ -118,7 +129,7 @@ def updates():
apt_cache_age_h = None
try:
import time as _time
apt_cache_age_h = round((_time.time() - os.path.getmtime("/var/lib/apt/lists")) / 3600, 1)
apt_cache_age_h = round((_time.time() - os.path.getmtime("/var/lib/apt/lists")) / 3600, 1) # noqa: PTH
except Exception: # noqa: BLE001
pass
return {"os": os_count, "models": models, "engine": 1 if _engine_update_available() else 0,
+50 -50
View File
File diff suppressed because one or more lines are too long