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>