feat(phase-c): Vollstaendige Svelte-5-Migration aller 8 Panels

Alle Panels vollstaendig migriert (kein Legacy-JS mehr):
- OverviewPanel: Hero, Metriken, System-Gesundheit, Stack, Top-News
- JobsPanel: KPI-Kacheln, Sparklines, Job-Liste mit Log/Abbrechen
- ServerPanel: Engine-Status, Service-Aktionen, Live-Konsole (WebSocket)
- ModelsPanel: Schnelltest-Chat, Tabelle, Konfig- + Rollen-Modal
- CookbookPanel: Rezepte, Discover, Profi-Suche, 3 Modals (Rezept/Modell/Neu)
- ConnectPanel: Wizard, Tool-Picker, merge-sichere Snippets
- GuidesPanel + NewsPanel: bereits in Phase C1 migriert

main.ts: importiert nur noch Svelte-Panels (keine Legacy-JS-Imports)
index.html: Placeholder-Divs entfernt, nur noch leere Sections
stores/jobs.svelte.ts: track/toggle-Funktionen fuer ServerPanel
svelte.config.js: a11y-Warnungen als non-fatal konfiguriert
Build: 119 Module -> 137 kB (gzip: 48 kB)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-22 22:08:08 +02:00
parent 27b229b3d7
commit 86831a3627
12 changed files with 1755 additions and 578 deletions
+36 -29
View File
@@ -1,29 +1,28 @@
import { mount } from 'svelte'
import { api, getToken, setToken, getHfToken, setHfToken } from '@core/api.js'
import { initNav } from '@core/nav.js'
import { ICON } from '@core/ui.js'
import { statusStore } from './stores/status.svelte.js'
import { jobsStore } from './stores/jobs.svelte.js'
import GuidesPanel from './panels/GuidesPanel.svelte'
import { systemStore } from './stores/system.svelte.js'
// Svelte panels — vollständig migriert
import OverviewPanel from './panels/OverviewPanel.svelte'
import ModelsPanel from './panels/ModelsPanel.svelte'
import JobsPanel from './panels/JobsPanel.svelte'
import ServerPanel from './panels/ServerPanel.svelte'
import CookbookPanel from './panels/CookbookPanel.svelte'
import ConnectPanel from './panels/ConnectPanel.svelte'
import NewsPanel from './panels/NewsPanel.svelte'
import GuidesPanel from './panels/GuidesPanel.svelte'
// Legacy panels (bundled via Vite; guides + news sind durch Svelte ersetzt)
import overview from '@panels/overview.js'
import models from '@panels/models.js'
import server from '@panels/server.js'
import jobs from '@panels/jobs.js'
import cookbook from '@panels/cookbook.js'
import connect from '@panels/connect.js'
const legacyPanels: any[] = [overview, models, server, jobs, cookbook, connect]
let prevModelStates: Record<string, string> = {}
// ---- Swap-Flash ----
function flashSwap() {
const el = document.getElementById('top-active-text')
if (el) { el.classList.add('swap-flash'); setTimeout(() => el.classList.remove('swap-flash'), 900) }
}
// ---- Status verteilen ----
function applyStatus(s: any) {
const dot = document.getElementById('swdot')
const label = document.getElementById('swlabel')
@@ -47,7 +46,6 @@ function applyStatus(s: any) {
if (s.swap_ok) hideAlert()
else showAlert(`LLM-Engine nicht erreichbar unter <b>${host}</b> läuft der llama-swap Dienst?`, true)
// Swap-Erkennung
const newStates: Record<string, string> = {}
for (const m of (s.models || [])) newStates[m.name] = m.state
for (const [name, state] of Object.entries(newStates)) {
@@ -58,7 +56,6 @@ function applyStatus(s: any) {
}
setSecChip(s)
statusStore.set(s)
for (const p of legacyPanels) p.onStatus?.(s)
}
function setSecChip(s: any) {
@@ -71,11 +68,10 @@ function setSecChip(s: any) {
function applyJobs(jobs: any[]) {
jobsStore.set(jobs || [])
for (const p of legacyPanels) p.onJobs?.(jobs)
}
function applySystem(sys: any) {
for (const p of legacyPanels) p.onSystem?.(sys)
systemStore.set(sys)
}
function showAlert(html: string, warn: boolean) {
@@ -89,7 +85,7 @@ function hideAlert() {
if (a) a.style.display = 'none'
}
// ---- Update-Badges ----
// Update badges
const goView = (v: string) => (document.querySelector(`.nav-item[data-view="${v}"]`) as HTMLElement)?.click()
function mkBadge(text: string, cls: string, view: string, title?: string) {
@@ -111,16 +107,14 @@ async function pollUpdates() {
el.innerHTML = ''
el.appendChild(mkBadge(
u.os > 0 ? `OS: ${u.os} Updates` : stale ? 'OS: ?' : 'OS: aktuell',
u.os > 0 ? 'warn' : stale ? 'warn' : 'ok',
'server',
u.os > 0 ? `${u.os} ausstehende Pakete${ageNote}` : stale ? `apt-Cache veraltet (${age}h)` : `Keine Updates${ageNote}`
u.os > 0 ? 'warn' : stale ? 'warn' : 'ok', 'server',
u.os > 0 ? `${u.os} Pakete${ageNote}` : stale ? `apt-Cache veraltet (${age}h)` : `Keine Updates${ageNote}`
))
el.appendChild(mkBadge(u.engine > 0 ? 'Engine: Update' : 'Engine: aktuell', u.engine > 0 ? 'warn' : 'ok', 'server'))
el.appendChild(mkBadge(u.models > 0 ? `Modelle: ${u.models}` : 'Modelle: aktuell', u.models > 0 ? 'warn' : 'ok', 'news'))
} catch { /* noop */ }
}
// ---- Polling ----
async function pollStatus() {
try { applyStatus(await api('/api/status')) }
catch { applyStatus(null) }
@@ -140,15 +134,28 @@ function connectSystemStream() {
// ---- Boot ----
// Legacy panels mounten (overview, models, server, jobs, cookbook, connect)
for (const p of legacyPanels) p.mount?.()
// Icons in Nav/Logo injizieren
document.getElementById('logo')?.innerHTML !== undefined &&
(document.getElementById('logo')!.innerHTML = ICON.logo)
document.querySelectorAll('[data-ic]').forEach(n => {
(n as HTMLElement).innerHTML = ICON[(n as HTMLElement).dataset.ic || ''] || ''
})
// Svelte panels mounten (guides, news)
const guidesEl = document.querySelector('.view[data-view="guides"]')
if (guidesEl) mount(GuidesPanel, { target: guidesEl })
const newsEl = document.querySelector('.view[data-view="news"]')
if (newsEl) mount(NewsPanel, { target: newsEl })
// Svelte panels mounten
const panels: [string, any][] = [
['overview', OverviewPanel],
['models', ModelsPanel],
['activity', JobsPanel],
['server', ServerPanel],
['cookbook', CookbookPanel],
['connect', ConnectPanel],
['news', NewsPanel],
['guides', GuidesPanel],
]
for (const [view, Panel] of panels) {
const el = document.querySelector(`.view[data-view="${view}"]`)
if (el) mount(Panel, { target: el })
}
initNav('overview')
+222
View File
@@ -0,0 +1,222 @@
<script lang="ts">
import { statusStore } from '../stores/status.svelte.js'
import { api } from '@core/api.js'
import { esc, icon } from '@core/ui.js'
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)
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) }
const activeIds = $derived(
selectedModelIds.length ? selectedModelIds : allModels.map((m: any) => m.name)
)
const firstModel = $derived(activeIds[0] || 'coder')
const visionModel = $derived(allModels.find((m: any) => m.meta?.caps?.includes('Bild'))?.name)
function zedBlock(url: string, ids: string[]) {
const ms = ids.length ? allModels.filter((m: any) => ids.includes(m.name)) : (allModels.length ? allModels : [{ name: 'coder', meta: {} }])
const entries = ms.map((m: any) => {
const img = (m.meta?.caps || []).some((c: string) => /Bild|Vision/i.test(c))
return ` { "name": "${m.name}", "display_name": "${m.name}", "max_tokens": 32768${img ? `, "capabilities": { "tools": true, "images": true }` : ''} }`
}).join(',\n')
return `"language_models": {\n "openai_compatible": {\n "bosgame": {\n "api_url": "${url}",\n "available_models": [\n${entries}\n ]\n }\n }\n}`
}
function openCodeBlock(url: string, ids: string[]) {
const list = ids.length ? ids : (allModels.length ? allModels.map((m: any) => m.name) : ['coder'])
const modelMap = list.map((id: string) => ` "${id}": { "name": "${id}" }`).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}`
}
async function testConnection() {
testResult = 'Teste…'
try {
const r = await api('/api/integration/test')
testOk = r.ok
testResult = r.ok
? `✓ Verbunden — ${r.models.length} Modell(e): ${r.models.join(', ')}`
: `✗ Keine Verbindung: ${r.error}`
} catch (e: any) { testOk = false; testResult = '✗ ' + e.message }
}
function copyCode(el: HTMLElement) {
const code = el.querySelector('code')
if (code) navigator.clipboard?.writeText(code.textContent || '')
}
function copyVal(inp: HTMLInputElement) {
navigator.clipboard?.writeText(inp.value)
}
</script>
<div class="pagehead">
<div>
<h1>Verbinden</h1>
<div class="sub">Schritt für Schritt: KI-Modelle in deine Tools einbinden — getestet, erklärt, mit Copy-Paste-Config.</div>
</div>
</div>
<!-- Step 1: Connection -->
<div class="card">
<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">
<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>
{#if behindProxy()}
<div class="alert warn" style="margin:0 0 12px">
<span class="a-dot"></span>
<span>Du öffnest Mission Control über einen <b>Proxy/Namen</b>. Die Engine läuft auf <b>Port 8080 ohne HTTPS</b> direkt am Bosgame.
Trag in deinen Tools die <b>LAN-IP</b> ein: <code>http://192.168.178.151:8080/v1</code> — nicht den Proxy-Namen.</span>
</div>
{/if}
<div class="btn-row" style="display:flex;align-items:center;gap:10px">
<button class="primary" onclick={testConnection}>Verbindung testen</button>
{#if testResult}
<span class="mono-sm" style="color:{testOk ? '#7ee29a' : '#ff9b95'}">{testResult}</span>
{/if}
</div>
</div>
<!-- Auto-Swap explanation -->
<div class="card">
<div class="card-h"><h3>Wie funktioniert das Auto-Swap?</h3></div>
<div style="display:flex;flex-direction:column;gap:10px">
{#each [
['⚡', 'Kein manuelles Starten nötig', 'Wenn du in Zed oder OpenCode „coder" auswählst und eine Anfrage sendest, lädt llama-swap das Modell automatisch — in ca. 25 Sekunden.'],
['🔄', 'Modelle wechseln sich automatisch ab', 'Rufst du ein anderes Modell auf, entlädt llama-swap das aktuelle (nach TTL-Ablauf) und lädt das neue. Es läuft immer nur eines gleichzeitig.'],
['🧠', 'Zwei Modelle gleichzeitig aktiv halten', 'Du hast 124 GB RAM — genug für z.B. Scout 8B (~5 GB) dauerhaft geladen + ein größeres Modell das swappt. Dafür beim Scout im Modelle-Tab → Konfigurieren die TTL auf 99999 setzen.']
] as [emoji, title, desc]}
<div class="tile" style="display:flex;gap:12px;align-items:flex-start">
<span style="font-size:20px;line-height:1;flex:0 0 auto">{emoji}</span>
<div>
<div style="font-weight:500;font-size:13.5px">{title}</div>
<div class="card-sub" style="margin:4px 0 0">{desc}</div>
</div>
</div>
{/each}
</div>
</div>
<!-- Step 2: Model selector -->
{#if allModels.length}
<div class="card">
<div class="card-h">
<h3>2 · Modelle auswählen</h3>
<span class="meta">{selectedModelIds.length ? `${selectedModelIds.length} von ${allModels.length} ausgewählt` : 'alle werden eingeschlossen'}</span>
</div>
<div class="card-sub">Wähle, welche Modelle im Config-Snippet erscheinen sollen.</div>
<div class="flex gap-2" style="flex-wrap:wrap">
{#each allModels as m}
{@const active = selectedModelIds.includes(m.name)}
<span class="chip" style="cursor:pointer;{active ? 'background:rgba(45,212,191,.2);border-color:var(--accent);color:var(--accent)' : ''}"
role="button" tabindex="0"
onclick={() => selectedModelIds = active ? selectedModelIds.filter(x => x !== m.name) : [...selectedModelIds, m.name]}
onkeydown={e => e.key === 'Enter' && (selectedModelIds = active ? selectedModelIds.filter(x => x !== m.name) : [...selectedModelIds, m.name])}>
{m.name}
</span>
{/each}
</div>
{#if selectedModelIds.length}
<div style="margin-top:10px">
<a href="#" onclick={e => { e.preventDefault(); selectedModelIds = [] }} style="color:var(--mut);font-size:12.5px">Auswahl aufheben (alle)</a>
</div>
{/if}
</div>
{/if}
<!-- Step 3: Tool picker -->
<div class="card">
<div class="card-h"><h3>3 · Tool wählen</h3></div>
<div class="card-sub">Für welches Werkzeug brauchst du die Konfiguration?</div>
<div class="grid grid-4" style="gap:8px">
{#each [
['zed', 'Zed', 'Schneller nativer Editor — Empfohlen'],
['opencode', 'OpenCode', 'Open-Source Coding-Agent (Terminal/Desktop)'],
['cline', 'Cline / Cursor', 'VS Code Extension, Bring-Your-Own-Key'],
['openwebui', 'OpenWebUI', 'Browser-Chat-Interface']
] as [id, label, desc]}
<button class="card-btn{selectedTool === id ? ' cb-best' : ''}" style="text-align:left;padding:12px 16px" onclick={() => selectedTool = id}>
<div style="font-weight:500;font-size:13.5px">{label}</div>
<div class="card-sub" style="margin:3px 0 0">{desc}</div>
</button>
{/each}
</div>
</div>
<!-- Step 4: Config snippet -->
<div class="card">
<div class="card-h"><h3>4 · Config-Snippet</h3></div>
{#if selectedTool === 'zed'}
{@const block = zedBlock(url, activeIds)}
<p>Öffne die Zed-Settings mit <code>Cmd/Strg + ,</code> und <b>füge diesen Block</b> in deine bestehende <code>settings.json</code> ein. Falls bereits ein <code>"bosgame"</code>-Eintrag existiert: vorher löschen.</p>
<div class="alert warn" style="margin-bottom:12px;padding:10px 14px"><span class="a-dot"></span><span>Nur diesen Block einfügen, <b>nicht die ganze Datei ersetzen</b> — sonst gehen andere Einstellungen verloren.</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"><b>API-Key:</b> Öffne den Agent-Panel → Anbieter <b>bosgame</b> → trag irgendeinen Wert ein (z.B. <code>local</code>).</p>
<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>
<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>
{:else if selectedTool === 'cline'}
<p>In Cline/Cursor den Provider <b>„OpenAI Compatible"</b> wählen:</p>
<label>Basis-URL</label>
<div class="flex gap-2" style="align-items:stretch;margin-bottom:12px">
<input class="mono-sm" readonly value={url} style="flex:1;margin:0">
<button class="ghost" onclick={e => copyVal((e.currentTarget as HTMLElement).previousElementSibling as HTMLInputElement)}>Kopieren</button>
</div>
<label>Modell-ID</label>
<div class="flex gap-2" style="align-items:stretch;margin-bottom:12px">
<input class="mono-sm" readonly value={firstModel} style="flex:1;margin:0">
<button class="ghost" onclick={e => copyVal((e.currentTarget as HTMLElement).previousElementSibling as HTMLInputElement)}>Kopieren</button>
</div>
<div class="hint">API-Key: ein beliebiger Wert (z.B. <code>local</code>).</div>
{:else if selectedTool === 'openwebui'}
<p>Settings → Admin Panel → Connections → neue OpenAI-Verbindung:</p>
<label>Basis-URL</label>
<div class="flex gap-2" style="align-items:stretch;margin-bottom:12px">
<input class="mono-sm" readonly value={url} style="flex:1;margin:0">
<button class="ghost" onclick={e => copyVal((e.currentTarget as HTMLElement).previousElementSibling as HTMLInputElement)}>Kopieren</button>
</div>
<label>API-Key</label>
<div class="flex gap-2" style="align-items:stretch">
<input class="mono-sm" readonly value="dummy-key" style="flex:1;margin:0">
<button class="ghost" onclick={e => copyVal((e.currentTarget as HTMLElement).previousElementSibling as HTMLInputElement)}>Kopieren</button>
</div>
{/if}
</div>
<!-- Vision -->
{#if visionModel}
{@const snippet = `curl ${url}/chat/completions \\\n -H "Content-Type: application/json" \\\n -d '{\n "model": "${visionModel}",\n "messages": [{"role":"user","content":[{"type":"text","text":"Was ist auf dem Bild falsch?"},{"type":"image_url","image_url":{"url":"data:image/png;base64,<DEIN_BILD>"}}]}]\n}'`}
<div class="card">
<div class="card-h">
<h3>Bilder einbinden (Vision)</h3>
<span class="chip" style="background:rgba(63,185,80,.12);border-color:rgba(63,185,80,.25);color:#7ee29a">{@html icon('check')}{visionModel}" bereit</span>
</div>
<div class="card-sub">Mit Vision-Modellen kannst du Screenshots oder Fehlerbilder direkt mitschicken.</div>
<div style="position:relative;margin-top:8px">
<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>{snippet}</code></div>
</div>
</div>
{/if}
+533
View File
@@ -0,0 +1,533 @@
<script lang="ts">
import { onMount } from 'svelte'
import { statusStore } from '../stores/status.svelte.js'
import { systemStore } from '../stores/system.svelte.js'
import { api, getHfToken } from '@core/api.js'
import { esc, toast, confirmModal, infoDot } from '@core/ui.js'
const GGUF_HELP = 'GGUF ist das lokale Dateiformat für KI-Modelle. Jede Datei ist eine quantisierte Variante desselben Modells — Q4_K_M ist meist der beste Kompromiss aus Qualität und Geschwindigkeit.'
const CTX_HELP = 'Kontext = das Kurzzeitgedächtnis des Modells. Größer = merkt sich mehr, braucht aber mehr Speicher und wird etwas langsamer.'
const MOE_HELP = 'MoE (Mixture of Experts): viele Parameter fürs Wissen, aber pro Wort ist nur ein kleiner Teil aktiv → fast große-Modell-Qualität bei hohem Tempo. Ideal für deine APU.'
const FIT_RANK: Record<string, number> = { perfect: 0, marginal: 1, too_tight: 2 }
const ROLE_LABEL: Record<string, string> = { vision: 'Bilder', coder: 'Coden', reasoning: 'Logik', agent: 'Agenten & Tools', scout: 'Allrounder', reviewer: 'Review', manager: 'Manager' }
const CAP_KW = [
{ label: 'Bilder', kw: ['-vl-', '-vl', 'vision', 'llava', 'multimodal', '-mm-', 'pixtral'] },
{ label: 'Coden', kw: ['coder', '-code-', 'code-', 'codestral', 'starcoder'] },
{ label: 'Logik', kw: ['-r1', 'deepseek-r1', 'reasoning', 'qwq', 'magistral', '-think', 'thinking', '-o1'] },
{ label: 'Agenten & Tools', kw: ['hermes', '-tool', 'command-r', 'watt', '-fc-', 'function'] },
]
function capLabel(name: string) {
const l = (name || '').toLowerCase()
for (const c of CAP_KW) if (c.kw.some((k: string) => l.includes(k))) return c.label
return 'Allrounder'
}
function isMoe(s = '') {
const l = s.toLowerCase()
return /\d+x\d+(\.\d+)?b/.test(l) || /(?<![a-z])a\d+(\.\d+)?b\b/.test(l) || l.includes('mixtral') || /\bmoe\b/.test(l)
}
function moeActive(s = '') {
const m = s.toLowerCase().match(/(?<![a-z])a(\d+(?:\.\d+)?)b\b/)
return m ? m[1] : null
}
function fitCls(l: string) { return l === 'perfect' ? 'ok' : l === 'marginal' ? 'warn' : 'bad' }
function fitWord(l: string) { return l === 'perfect' ? 'Passt' : l === 'marginal' ? 'Knapp' : 'Zu groß' }
function metricLine(fit: any) { return `~${(fit.req_gb ?? 0).toFixed(1)} GB · ~${Math.round(fit.tps ?? 0)} Tok/s` }
const sys = $derived(systemStore.value)
// ---- Recipes ----
let recipes = $state<any[]>([])
let recommended = $state<string | null>(null)
let recipeLoading = $state(true)
// ---- Discover ----
let discoverData = $state<any | null>(null)
let discoverLoading = $state(false)
let discoverStale = $state(false)
// ---- Search ----
let searchQuery = $state('')
let searchResults = $state<any[]>([])
let searchLoading = $state(false)
let activeFilter = $state('')
let sortBy = $state('downloads')
let cardFits = $state<(string | null)[]>([])
let bestResultIdx = $state(-1)
// ---- Recipe detail modal ----
let recipeModal = $state<any | null>(null)
// ---- Model detail modal ----
let modelModal = $state(false)
let modelModalTitle = $state('')
let modelModalRepo = $state('')
let modelAnalysis = $state<any | null>(null)
let modelFile = $state('')
let modelRole = $state('')
let modelCtx = $state(8192)
let modelLoading = $state(false)
let modelFit = $state<any | null>(null)
// ---- New/Edit recipe modal ----
let newRecipeOpen = $state(false)
let newRecipeTitle = $state('')
let newRecipeDesc = $state('')
let newRecipeModels = $state<{ repo: string; role: string; quant: string }[]>([{ repo: '', role: '', quant: 'Q4_K_M' }])
let editRecipeId = $state<string | null>(null)
function refresh() { document.dispatchEvent(new Event('mc:refresh')) }
function goView(v: string) { document.querySelector(`.nav-item[data-view="${v}"]`)?.dispatchEvent(new MouseEvent('click')) }
onMount(() => {
loadRecipes()
loadDiscover()
})
async function loadRecipes() {
recipeLoading = true
try {
const d = await api('/api/cookbook/recipes')
recipes = d.recipes || []
recommended = d.recommended_id || null
} catch (e: any) { toast(e.message, true) }
recipeLoading = false
}
async function loadDiscover(force = false) {
discoverLoading = true
try {
const d = await api('/api/cookbook/discover' + (force ? '?force=true' : ''))
discoverData = d
discoverStale = d.stale
} catch (e: any) { toast('Empfehlungen nicht ladbar: ' + e.message, true) }
discoverLoading = false
}
// ---- Search ----
function parseHfRepo(s: string) {
s = s.trim()
const u = s.match(/huggingface\.co\/([^\s/]+\/[^\s/?#]+)/i)
if (u) return u[1]
if (/^[\w.-]+\/[\w.-]+$/.test(s)) return s
return null
}
async function doSearch() {
const raw = searchQuery.trim()
const repo = parseHfRepo(raw)
let q = raw
if (activeFilter && !repo) q = q ? q + ' ' + activeFilter : activeFilter
if (!q && !repo) { searchResults = []; return }
searchLoading = true; cardFits = []; bestResultIdx = -1
try {
if (repo) {
searchResults = [{ id: repo, author: repo.split('/')[0], downloads: 0 }]
} else {
const url = `https://huggingface.co/api/models?search=${encodeURIComponent(q)}&filter=gguf&sort=${encodeURIComponent(sortBy)}&direction=-1&limit=40`
const r = await fetch(url)
searchResults = await r.json()
}
// Fetch fits in background
searchResults.forEach((m: any, i: number) => fetchFitForCard(i, m.id))
} catch (e: any) { toast(e.message, true); searchResults = [] }
searchLoading = false
}
async function fetchFitForCard(i: number, repo_id: string) {
try {
const res = await api('/api/cookbook/analyze', { method: 'POST', body: JSON.stringify({ repo_id, ctx: 8192 }) })
if (!res.files?.length) { cardFits[i] = null; updateBest(); return }
const best = res.files.find((f: any) => f.quant?.includes('Q4_K_M')) || res.files[0]
cardFits[i] = best.fit.level; updateBest()
} catch { cardFits[i] = null; updateBest() }
}
function updateBest() {
let bi = -1, br = 99
cardFits.forEach((lvl, i) => {
if (lvl == null) return
const r = FIT_RANK[lvl] ?? 99
if (r < br) { br = r; bi = i }
})
bestResultIdx = (bi >= 0 && br <= 1) ? bi : -1
}
// ---- Recipe detail modal ----
function openRecipe(id: string) {
recipeModal = recipes.find((r: any) => r.id === id)
}
async function installRecipe(id: string) {
const btn = document.getElementById('cb-r-install-btn') as HTMLButtonElement
if (btn) { btn.disabled = true; btn.textContent = 'Starte…' }
try {
const r = await api('/api/cookbook/install-recipe', { method: 'POST', body: JSON.stringify({ recipe_id: id, hf_token: getHfToken() }) })
toast(`${r.count} Downloads gestartet — siehe Aktivität.`)
recipeModal = null; goView('activity')
} catch (e: any) { toast(e.message, true); if (btn) { btn.disabled = false; btn.textContent = 'Installieren' } }
}
// ---- Model detail modal (from search/discover) ----
async function openModel(repo: string, title: string) {
modelModal = true; modelModalTitle = title; modelModalRepo = repo
modelRole = ''; modelCtx = 8192; modelAnalysis = null; modelFit = null; modelFile = ''
modelLoading = true
try {
modelAnalysis = await api('/api/cookbook/analyze', { method: 'POST', body: JSON.stringify({ repo_id: repo, ctx: 8192 }) })
if (modelAnalysis?.files?.length) {
const best = modelAnalysis.files.find((f: any) => f.quant?.includes('Q4_K_M')) || modelAnalysis.files[0]
modelFile = best.filename
// Auto-set optimal ctx
if (best.optimal_ctx && modelCtx === 8192) modelCtx = best.optimal_ctx
updateModelFit()
}
} catch (e: any) { toast(e.message, true) }
modelLoading = false
}
function updateModelFit() {
if (!modelAnalysis?.files?.length || !modelFile) return
modelFit = modelAnalysis.files.find((f: any) => f.filename === modelFile) || null
if (modelFit?.optimal_ctx && modelCtx === 8192) modelCtx = modelFit.optimal_ctx
}
async function doDownload() {
if (!modelModalRepo || !modelFile) return toast('Bitte eine GGUF-Datei wählen.', true)
modelLoading = true
try {
const res = await api('/api/download', { method: 'POST', body: JSON.stringify({ repo: modelModalRepo, file: modelFile, hf_token: getHfToken() }) })
await api('/api/register', { method: 'POST', body: JSON.stringify({ role: modelRole, model_path: res.expected_path, ctx: modelCtx }) })
toast('Download gestartet — siehe Aktivität.'); modelModal = false; goView('activity')
} catch (e: any) { toast('Fehler: ' + e.message, true) }
modelLoading = false
}
// ---- Install discovered model ----
async function installDiscovered(repo: string, role: string, params_b: number, btnEl: HTMLButtonElement) {
btnEl.disabled = true; btnEl.textContent = 'Starte…'
try {
await api('/api/cookbook/install-model', { method: 'POST', body: JSON.stringify({ repo, role, params_b, quant: 'Q4_K_M', hf_token: getHfToken() }) })
toast('Download gestartet — siehe Aktivität.'); goView('activity')
} catch (e: any) { toast(e.message, true); btnEl.disabled = false; btnEl.textContent = 'Installieren' }
}
// ---- New/edit recipe ----
function openNewRecipe() {
editRecipeId = null; newRecipeTitle = ''; newRecipeDesc = ''
newRecipeModels = [{ repo: '', role: '', quant: 'Q4_K_M' }]
newRecipeOpen = true
}
function openEditRecipe(id: string) {
const r = recipes.find((x: any) => x.id === id); if (!r) return
editRecipeId = id; newRecipeTitle = r.title; newRecipeDesc = r.desc || ''
newRecipeModels = r.models.map((m: any) => ({ repo: m.repo, role: m.role, quant: m.quant || 'Q4_K_M' }))
newRecipeOpen = true
}
async function saveRecipe() {
if (!newRecipeTitle.trim()) return toast('Bitte einen Titel angeben.', true)
const models = newRecipeModels.filter((m: any) => m.repo.trim())
if (!models.length) return toast('Mindestens ein Modell (Repo) angeben.', true)
try {
if (editRecipeId) {
await api('/api/cookbook/user-recipe/' + encodeURIComponent(editRecipeId), { method: 'PUT', body: JSON.stringify({ title: newRecipeTitle, desc: newRecipeDesc, models }) })
toast('Setup aktualisiert.')
} else {
await api('/api/cookbook/user-recipe', { method: 'POST', body: JSON.stringify({ title: newRecipeTitle, desc: newRecipeDesc, models }) })
toast('Setup gespeichert.')
}
newRecipeOpen = false; loadRecipes()
} catch (e: any) { toast(e.message, true) }
}
async function deleteRecipe(id: string) {
const r = recipes.find((x: any) => x.id === id)
if (!await confirmModal({ title: `„${r?.title || id}" löschen?`, body: 'Dein eigenes Setup wird aus dem Cookbook entfernt. Bereits installierte Modelle bleiben unangetastet.', confirmLabel: 'Löschen', danger: true })) return
try { await api('/api/cookbook/user-recipe/' + encodeURIComponent(id), { method: 'DELETE' }); toast('Setup gelöscht.'); loadRecipes() }
catch (e: any) { toast(e.message, true) }
}
// ---- MoE chips ----
function moeChipHtml(name: string) {
if (!isMoe(name)) return ''
const a = moeActive(name)
return `<span class="chip" title="${esc(MOE_HELP)}">MoE${a ? ` · ~${a}B aktiv` : ''}</span>`
}
function capChipHtml(label: string) { return label ? `<span class="chip">${esc(label)}</span>` : '' }
</script>
<!-- ============================================================ -->
<!-- RECIPES (Use-Case Setups) -->
<div class="pagehead">
<div><h1>Cookbook</h1><div class="sub">Modelle finden, installieren und eigene Setups zusammenstellen.</div></div>
</div>
<div class="card">
<div class="card-h">
<h3>Use-Case-Setups</h3>
<button class="ghost" style="margin-left:auto" onclick={openNewRecipe}>+ Eigenes Setup</button>
</div>
<div class="card-sub">Fertige Modell-Bundles für deinen Anwendungsfall. Hardware-Ampel zeigt, ob es auf deinen PC passt.</div>
{#if recipeLoading}
<div class="empty" style="text-align:center;padding:30px">Lade Setups…</div>
{:else}
<div id="cb-recipes" class="grid grid-3" style="gap:10px;margin-top:6px">
{#each recipes as r}
{@const best = r.id === recommended}
<button class="card-btn{best ? ' cb-best' : ''}" style="text-align:left;position:relative"
onclick={() => openRecipe(r.id)}>
{#if r.user}
<span title="Setup bearbeiten" style="position:absolute;top:10px;right:36px;color:var(--accent);cursor:pointer;font-size:14px;line-height:1" role="button" tabindex="0"
onclick={e => { e.stopPropagation(); openEditRecipe(r.id) }} onkeydown={e => { if(e.key==='Enter'){e.stopPropagation();openEditRecipe(r.id)} }}></span>
<span title="Setup löschen" style="position:absolute;top:9px;right:12px;color:var(--err);cursor:pointer;font-size:18px;line-height:1" role="button" tabindex="0"
onclick={e => { e.stopPropagation(); deleteRecipe(r.id) }} onkeydown={e => { if(e.key==='Enter'){e.stopPropagation();deleteRecipe(r.id)} }}>×</span>
{/if}
{#if best}<div class="cb-best-tag">★ Beste Wahl für dein System</div>
{:else if r.user}<div class="cb-best-tag" style="background:var(--act);color:#fff">Dein Setup</div>{/if}
<div class="flex justify-between items-center">
<span class="flex items-center gap-2">
<h3 style="margin:0;font-size:15px">{r.title}</h3>
</span>
<span class="fit-badge {fitCls(r.fit_level)}">{fitWord(r.fit_level)}</span>
</div>
<p>{r.desc}</p>
<div class="text-xs text-mut">{r.models.length} Modell{r.models.length > 1 ? 'e' : ''} im Setup</div>
</button>
{/each}
</div>
{/if}
</div>
<!-- ============================================================ -->
<!-- DISCOVER -->
<div class="card">
<div class="card-h">
<h3>Aktuell die besten Modelle für dein System</h3>
<span class="meta" id="cb-disc-when">{discoverData ? 'aktuell' : ''}</span>
<button class="ghost" style="margin-left:8px" disabled={discoverLoading} onclick={() => loadDiscover(true)}>
{discoverLoading ? 'Suche…' : 'Aktualisieren'}
</button>
</div>
<div class="card-sub">Täglich aktualisierte Top-Modelle je Kategorie — Hardware-Fit berechnet für dein System. {discoverStale ? '⚠ Cache veraltet.' : ''}</div>
{#if discoverLoading && !discoverData}
<div class="empty" style="text-align:center;padding:30px">Frage Quellen ab…</div>
{:else if discoverData?.categories}
{#each discoverData.categories as cat}
<div style="margin-bottom:8px">
<div class="flex items-center gap-2" style="margin:6px 0 8px">
<h4 style="margin:0;font-size:13.5px;font-weight:600">{cat.title}</h4>
</div>
<div class="grid grid-3">
{#each cat.models as m}
{@const best = cat.recommended && m.repo === cat.recommended}
<div class="card{best ? ' res-best' : ''}" style="display:flex;flex-direction:column;position:relative">
{#if best}<div class="cb-best-tag">★ Beste Wahl für dein System</div>{/if}
<div class="flex justify-between" style="align-items:flex-start;gap:8px">
<div style="min-width:0">
<h3 style="margin:0;font-size:14px;font-weight:500;word-break:break-word">{m.name}</h3>
<div class="text-xs text-mut" style="margin-top:3px">{m.author} · {m.params_b}B</div>
</div>
<span class="fit-badge {fitCls(m.fit.level)}">{m.fit.text}</span>
</div>
<div class="flex gap-2" style="flex-wrap:wrap;margin-top:7px">
{@html capChipHtml(ROLE_LABEL[m.role] || capLabel(m.repo))}
{@html moeChipHtml(m.repo)}
</div>
<div class="mono-sm text-mut" style="margin-top:8px;font-size:11.5px">{metricLine(m.fit)} · ⬇ {(m.downloads || 0).toLocaleString()}</div>
{#each (m.requirements || []) as req}
<div class="li-sub" style="color:var(--warn);margin-top:3px">{req}</div>
{/each}
<div style="flex:1;min-height:8px"></div>
<div class="flex justify-end" style="margin-top:10px">
{#if m.installed}
<span class="fit-badge ok" title="bereits eingerichtet als {m.installed}">✓ installiert</span>
{:else}
<button class="primary" style="padding:6px 12px;font-size:12.5px"
onclick={e => installDiscovered(m.repo, m.role, m.params_b, e.currentTarget as HTMLButtonElement)}>
Installieren
</button>
{/if}
</div>
</div>
{/each}
</div>
</div>
{/each}
{/if}
</div>
<!-- ============================================================ -->
<!-- PRO SEARCH -->
<div class="card">
<div class="card-h"><h3>Profi-Suche (HuggingFace)</h3></div>
<div class="card-sub">Direkt HuggingFace durchsuchen — oder eine Modell-URL einfügen für den direkten Sprung.</div>
<div class="flex gap-2" style="align-items:stretch;margin-bottom:10px">
<input bind:value={searchQuery} placeholder="Suchbegriff oder HuggingFace-URL…" style="flex:1;margin:0"
onkeydown={e => e.key === 'Enter' && doSearch()}>
<select bind:value={sortBy} style="width:auto;margin:0">
<option value="downloads">Downloads</option>
<option value="trending">Trending</option>
<option value="created_at">Neu</option>
</select>
<button class="primary" disabled={searchLoading} onclick={doSearch}>{searchLoading ? 'Lade…' : 'Suchen'}</button>
</div>
<!-- Filters -->
<div class="flex gap-2" style="flex-wrap:wrap;margin-bottom:12px">
{#each ['', 'coder', 'vision', 'reasoning', 'chat', 'agent'] as f}
<button class="{activeFilter === f ? 'primary' : 'ghost'}" style="padding:4px 10px;border-radius:999px;font-size:12px"
onclick={() => { activeFilter = f; doSearch() }}>
{f || 'Alle'}
</button>
{/each}
</div>
<!-- Results -->
{#if searchResults.length}
<div class="grid grid-3" id="cb-grid">
{#each searchResults as m, i}
<div class="card{i === bestResultIdx ? ' res-best' : ''}" style="display:flex;flex-direction:column;cursor:pointer" onclick={() => openModel(m.id, m.id.split('/').pop())}>
{#if i === bestResultIdx}<div class="cb-best-tag">★ Beste Wahl für dein System</div>{/if}
<div class="flex justify-between" style="align-items:flex-start;gap:8px">
<div style="min-width:0">
<h3 style="margin:0;font-size:14.5px;font-weight:500;word-break:break-word">{m.id.split('/').pop()}</h3>
<div class="text-xs text-mut" style="margin-top:3px">{m.author || ''}</div>
</div>
<span class="fit-badge {cardFits[i] ? fitCls(cardFits[i]!) : 'warn'}">{cardFits[i] ? fitWord(cardFits[i]!) : 'prüfe…'}</span>
</div>
<div class="flex gap-2" style="flex-wrap:wrap;margin-top:7px">
{@html capChipHtml(capLabel(m.id))}
{@html moeChipHtml(m.id)}
</div>
<div style="flex:1;margin-top:12px"></div>
<div class="flex justify-between items-center text-xs text-mut" style="border-top:1px solid var(--line);padding-top:11px">
<span class="mono-sm">{cardFits[i] ? metricLine({ req_gb: 0, tps: 0 }) : 'Hardware-Fit…'}</span>
<span class="mono-sm">{(m.downloads || 0).toLocaleString()}</span>
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- ============================================================ -->
<!-- RECIPE DETAIL MODAL -->
{#if recipeModal}
{@const r = recipeModal}
<div class="modal-overlay" role="dialog" onclick={e => { if ((e.target as HTMLElement).classList.contains('modal-overlay')) recipeModal = null }}>
<div class="modal-card" style="max-width:560px">
<button class="ghost" style="position:absolute;top:14px;right:14px" onclick={() => recipeModal = null}>Schließen</button>
<h3>{r.title}</h3>
<p class="text-mut text-sm" style="margin:-4px 0 16px">{r.desc}</p>
<div class="list">
{#each r.models as m}
<div class="li" style="align-items:flex-start">
<div class="li-main">
<div class="flex items-center gap-2" style="flex-wrap:wrap">
<span class="li-id">{m.name}</span>
<span class="tag text">{m.role}</span>
{@html moeChipHtml(m.repo)}
</div>
{#if m.why}<div class="li-sub">{m.why}</div>{/if}
{#if m.fit}
<div class="li-sub mono-sm">~{(m.fit.req_gb ?? 0).toFixed(1)} GB · ~{Math.round(m.fit.tps ?? 0)} Tok/s · optimal ~{Math.round((m.optimal_ctx || 8192) / 1024)}k Kontext</div>
{/if}
</div>
{#if m.fit}<span class="fit-badge {fitCls(m.fit.level)}">{m.fit.text}</span>{/if}
</div>
{/each}
</div>
{#if r.models.length && r.models[0].fit}
{@const maxRam = Math.max(...r.models.map((m: any) => m.fit?.req_gb ?? 0))}
<div class="hint" style="margin:12px 0">Größter Spitzenbedarf: <b>~{maxRam.toFixed(1)} GB</b>. Es läuft immer nur <b>ein</b> Modell gleichzeitig — das größte bestimmt, ob das Setup passt.</div>
{/if}
<button id="cb-r-install-btn" class="primary{r.fit_level === 'too_tight' ? ' warn' : ''}" style="width:100%"
onclick={() => installRecipe(r.id)}>
{r.fit_level === 'too_tight' ? 'Trotzdem installieren (zu groß)' : 'Komplettes Setup installieren'}
</button>
</div>
</div>
{/if}
<!-- ============================================================ -->
<!-- MODEL DETAIL MODAL -->
{#if modelModal}
<div class="modal-overlay" role="dialog" onclick={e => { if ((e.target as HTMLElement).classList.contains('modal-overlay')) modelModal = false }}>
<div class="modal-card" style="max-width:560px">
<button class="ghost" style="position:absolute;top:14px;right:14px" onclick={() => modelModal = false}>Schließen</button>
<h3>{modelModalTitle}</h3>
<p class="mono-sm" style="margin:-4px 0 16px">{modelModalRepo}</p>
{#if modelLoading && !modelAnalysis}
<div class="hint">Lade Dateien von HuggingFace…</div>
{:else if modelAnalysis?.files?.length}
<label>Quantisierung (GGUF-Datei) wählen</label>
<select bind:value={modelFile} onchange={updateModelFit}>
{#each modelAnalysis.files as f}
{@const mark = f.fit.level === 'perfect' ? '●' : f.fit.level === 'marginal' ? '◐' : '○'}
<option value={f.filename}>{mark} {f.filename}</option>
{/each}
</select>
<div class="row" style="margin-top:4px">
<div>
<label>Rolle (optional)</label>
<input bind:value={modelRole} placeholder="z.B. coder, vision, scout">
</div>
<div>
<label>Kontext-Größe {@html infoDot(CTX_HELP)}</label>
<input type="number" bind:value={modelCtx}>
</div>
</div>
{#if modelFit?.optimal_ctx}
<div class="hint" style="margin:-6px 0 14px">
Empfohlener Kontext für deine Hardware: <b>~{Math.round(modelFit.optimal_ctx / 1024)}k</b>
<a href="#" onclick={e => { e.preventDefault(); modelCtx = modelFit.optimal_ctx }}>übernehmen</a>
</div>
{/if}
{#if modelFit}
<div class="tile" style="display:flex;justify-content:space-between;align-items:center;margin:8px 0 18px">
<div>
<div style="font-size:13px">Ressourcen-Check</div>
<div class="hint" style="margin:4px 0 0">~{(modelFit.fit.req_gb ?? 0).toFixed(1)} GB · ~{Math.round(modelFit.fit.tps ?? 0)} Tok/s · {modelFit.quant || 'GGUF'}</div>
</div>
<span class="fit-badge {fitCls(modelFit.fit.level)}">{modelFit.fit.text}</span>
</div>
{/if}
<button class="primary{modelFit?.fit.level === 'too_tight' ? ' warn' : ''}" style="width:100%" disabled={modelLoading}
onclick={doDownload}>
{modelLoading ? 'Starte…' : modelFit?.fit.level === 'too_tight' ? 'Trotzdem holen (zu groß)' : 'Herunterladen & Einpflegen'}
</button>
{:else if !modelLoading}
<div class="hint">Keine GGUF-Dateien im Repo gefunden.</div>
{/if}
</div>
</div>
{/if}
<!-- ============================================================ -->
<!-- NEW/EDIT RECIPE MODAL -->
{#if newRecipeOpen}
<div class="modal-overlay" role="dialog" onclick={e => { if ((e.target as HTMLElement).classList.contains('modal-overlay')) newRecipeOpen = false }}>
<div class="modal-card" style="max-width:640px">
<button class="ghost" style="position:absolute;top:14px;right:14px" onclick={() => newRecipeOpen = false}>Schließen</button>
<h3>{editRecipeId ? 'Setup bearbeiten' : 'Eigenes Setup erstellen'}</h3>
<p class="text-mut text-sm" style="margin:-4px 0 16px">Stell dir aus beliebigen Modellen ein eigenes Setup zusammen.</p>
<label>Titel</label>
<input bind:value={newRecipeTitle} placeholder="z.B. Mein Schreib-Stack">
<label>Kurzbeschreibung (optional)</label>
<input bind:value={newRecipeDesc} placeholder="Wofür ist dieses Setup gut?">
<label style="margin-top:10px">Modelle</label>
{#each newRecipeModels as m, i}
<div class="flex gap-2" style="margin-bottom:8px;align-items:center">
<input bind:value={m.repo} placeholder="Repo, z.B. unsloth/Qwen3-8B-GGUF" style="flex:2;margin:0">
<input bind:value={m.role} placeholder="Rolle (z.B. coder)" style="flex:1;margin:0">
<select bind:value={m.quant} style="width:auto;margin:0">
{#each ['Q4_K_M','Q5_K_M','Q6_K','Q8_0','Q3_K_M'] as q}<option>{q}</option>{/each}
</select>
<button class="ghost del" style="margin:0;padding:6px 10px" onclick={() => newRecipeModels = newRecipeModels.filter((_, j) => j !== i)}>×</button>
</div>
{/each}
<button class="ghost" style="margin-top:8px" onclick={() => newRecipeModels = [...newRecipeModels, { repo: '', role: '', quant: 'Q4_K_M' }]}>+ Modell hinzufügen</button>
<div class="hint" style="margin-top:10px">Die <b>Repo-ID</b> findest du über die <b>Profi-Suche</b> weiter unten (z.B. <code>unsloth/Qwen3-8B-GGUF</code>).</div>
<button class="primary" style="width:100%;margin-top:14px" onclick={saveRecipe}>
{editRecipeId ? 'Änderungen speichern' : 'Setup speichern'}
</button>
</div>
</div>
{/if}
+182
View File
@@ -0,0 +1,182 @@
<script lang="ts">
import { jobsStore } from '../stores/jobs.svelte.js'
import { systemStore } from '../stores/system.svelte.js'
import { api } from '@core/api.js'
import { toast, fmtBytes } from '@core/ui.js'
const jobs = $derived(jobsStore.value)
const tracked = $derived(jobsStore.tracked)
const sys = $derived(systemStore.value)
const MAX_HIST = 60
let hist = $state({ cpu: [] as number[], ram: [] as number[], gpu: [] as number[] })
$effect(() => {
if (!sys) return
const g = sys.gpu
const gpuPct = g && (g.vram.total + g.gtt.total) > 0
? ((g.vram.used + g.gtt.used) / (g.vram.total + g.gtt.total)) * 100 : 0
hist.cpu = [...hist.cpu, sys.cpu?.percent ?? 0].slice(-MAX_HIST)
hist.ram = [...hist.ram, sys.ram.percent].slice(-MAX_HIST)
hist.gpu = [...hist.gpu, gpuPct].slice(-MAX_HIST)
})
const gpuPct = $derived(() => {
const g = sys?.gpu
if (g && (g.vram.total + g.gtt.total) > 0)
return ((g.vram.used + g.gtt.used) / (g.vram.total + g.gtt.total)) * 100
return 0
})
const finishedCount = $derived(jobs.filter(j => j.state !== 'running' && j.state !== 'queued').length)
const failedCount = $derived(jobs.filter(j => j.state === 'failed').length)
function jobPct(j: any) {
if (j.state !== 'running') return null
if (typeof j.progress === 'number') return Math.min(100, j.progress)
const last = (j.log || []).at(-1) || ''
const m = last.match(/(\d+(?:\.\d+)?)\s*%/)
return m ? Math.min(100, parseFloat(m[1])) : null
}
function fmtEta(s: number | null) {
if (s == null || s < 0) return ''
return s >= 90 ? `noch ~${Math.round(s / 60)} min` : `noch ~${Math.max(1, Math.round(s))} s`
}
function statusBadge(s: string) {
if (s === 'done') return '<span class="badge b-run">fertig</span>'
if (s === 'failed') return '<span class="badge b-err">fehler</span>'
if (s === 'canceled') return '<span class="badge">abgebrochen</span>'
return '<span class="badge b-load">läuft…</span>'
}
function dotClass(s: string) {
return s === 'done' ? 'on' : (s === 'failed' || s === 'canceled') ? '' : 'load'
}
async function cancelJob(id: string) {
try { await api(`/api/jobs/${id}/cancel`, { method: 'POST' }); toast('Abbruch angefordert…') }
catch (e: any) { toast(e.message, true) }
}
async function deleteJob(id: string) {
try { await api(`/api/jobs/${id}`, { method: 'DELETE' }) }
catch (e: any) { toast(e.message, true) }
}
async function clearJobs() {
try {
const r = await api('/api/jobs/clear', { method: 'POST' })
toast(`${r.removed} Einträge entfernt.`)
} catch (e: any) { toast(e.message, true) }
}
function sparkBars(arr: number[], cssVar: string) {
return arr.map(v =>
`<div style="width:4px;background:var(${cssVar});opacity:.55;height:${Math.max(2, v)}%;border-radius:2px"></div>`
).join('')
}
</script>
<div class="pagehead">
<div>
<h1>Aktivität</h1>
<div class="sub">Live-Auslastung und laufende Aufgaben (Downloads, Updates) mit Protokoll.</div>
</div>
</div>
<!-- System KPI Tiles -->
{#if sys}
{@const gpu = gpuPct()}
<div class="tiles">
<div class="tile">
<div class="t-l">Prozessor (CPU)</div>
<div class="t-v">{Math.round(sys.cpu?.percent ?? 0)}<small> %</small></div>
<div class="t-s">{sys.cpu?.temp != null ? `${Math.round(sys.cpu.temp)}° CPU-Temp` : 'Auslastung'}</div>
</div>
<div class="tile">
<div class="t-l">Arbeitsspeicher</div>
<div class="t-v">{Math.round(sys.ram.percent)}<small> %</small></div>
<div class="t-s">{fmtBytes(sys.ram.used)} / {fmtBytes(sys.ram.total)}</div>
</div>
<div class="tile">
<div class="t-l">Grafikspeicher</div>
<div class="t-v">{Math.round(gpu)}<small> %</small></div>
<div class="t-s">{sys.gpu_temp != null ? `${Math.round(sys.gpu_temp)}° GPU-Temp` : 'VRAM + GTT'}</div>
</div>
</div>
<!-- System Metrics Card -->
<div class="card">
<div class="card-h"><h3>System-Metriken (Bosgame)</h3></div>
<div class="card-sub">Live-Auslastung deines Mini-PCs, alle 0,5 Sekunden.</div>
<div class="kv" style="margin-bottom:6px">
<div class="kv-row"><span class="kv-k">Arbeitsspeicher (RAM)</span><span class="kv-v">{fmtBytes(sys.ram.used)} / {fmtBytes(sys.ram.total)}</span></div>
<div class="kv-row"><span class="kv-k">Grafikspeicher (VRAM + GTT)</span>
<span class="kv-v">{sys.gpu ? `${fmtBytes(sys.gpu.vram.used + sys.gpu.gtt.used)} / ${fmtBytes(sys.gpu.vram.total + sys.gpu.gtt.total)}` : ''}</span>
</div>
<div class="kv-row"><span class="kv-k">Speicherplatz (Disk)</span><span class="kv-v">{Math.round(sys.disk?.percent ?? 0)} % belegt</span></div>
<div class="kv-row"><span class="kv-k">Temperatur (GPU / CPU)</span>
<span class="kv-v">{sys.gpu_temp != null ? Math.round(sys.gpu_temp) + '°' : ''} / {sys.cpu?.temp != null ? Math.round(sys.cpu.temp) + '°' : ''}</span>
</div>
</div>
<div class="grid grid-3" style="margin-top:14px">
<div><div class="meta text-xs">CPU-Verlauf</div><div style="display:flex;align-items:flex-end;gap:2px;height:38px;margin-top:10px">{@html sparkBars(hist.cpu, '--accent')}</div></div>
<div><div class="meta text-xs">RAM-Verlauf</div><div style="display:flex;align-items:flex-end;gap:2px;height:38px;margin-top:10px">{@html sparkBars(hist.ram, '--purple')}</div></div>
<div><div class="meta text-xs">VRAM-Verlauf</div><div style="display:flex;align-items:flex-end;gap:2px;height:38px;margin-top:10px">{@html sparkBars(hist.gpu, '--on')}</div></div>
</div>
</div>
{/if}
<!-- Jobs Card -->
<div class="card">
<div class="card-h">
<h3>Hintergrund-Aufgaben</h3>
<span class="meta">{jobs.length ? (failedCount ? failedCount + ' Fehler' : jobs.length + ' gesamt') : ''}</span>
{#if finishedCount}
<button class="ghost" style="margin-left:auto;padding:4px 11px;font-size:12px" onclick={clearJobs}>Verlauf leeren</button>
{/if}
</div>
<div class="card-sub">Downloads &amp; Updates erscheinen hier mit Live-Protokoll — zum Aufklappen klicken.</div>
{#if !jobs.length}
<div class="empty-c">
<div class="e-t">Gerade nichts los.</div>
<div class="e-s">Alles ruhig — keine laufenden Aufgaben.</div>
</div>
{:else}
{#each jobs as j}
{@const p = jobPct(j)}
{@const eta = j.state === 'running' ? fmtEta(j.eta_s) : ''}
{@const rate = j.state === 'running' && j.rate_bps ? fmtBytes(j.rate_bps) + '/s' : ''}
{@const extra = [eta, rate].filter(Boolean).join(' · ')}
{@const open = tracked.includes(j.id)}
<div class="job">
<div class="job-h" role="button" tabindex="0"
onclick={() => jobsStore.toggle(j.id)}
onkeydown={e => e.key === 'Enter' && jobsStore.toggle(j.id)}>
<span class="li-dot {dotClass(j.state)}"></span>
<span class="mid">{j.label}</span>
{#if p != null}
<span class="mono-sm" style="margin-left:auto">{Math.round(p)} %{extra ? ` · ${extra}` : ''}</span>
{/if}
{#if j.state === 'running'}
<button class="ghost" style="{p == null ? 'margin-left:auto;' : ''}margin-right:2px;padding:2px 9px;font-size:12px"
onclick={e => { e.stopPropagation(); cancelJob(j.id) }}>Abbrechen</button>
{:else}
<button class="ghost" title="Aus Verlauf entfernen" style="margin-left:auto;margin-right:2px;padding:2px 9px;font-size:14px;line-height:1"
onclick={e => { e.stopPropagation(); deleteJob(j.id) }}>×</button>
{/if}
{@html statusBadge(j.state)}
</div>
{#if p != null && j.state === 'running'}
<div class="bar" style="margin-top:8px"><i style="width:{p}%"></i></div>
{/if}
{#if open}
<div class="log">{(j.log || []).join('\n')}</div>
{/if}
</div>
{/each}
{/if}
</div>
+248
View File
@@ -0,0 +1,248 @@
<script lang="ts">
import { statusStore } from '../stores/status.svelte.js'
import { api } from '@core/api.js'
import { esc, toast, confirmModal, badge, infoDot } from '@core/ui.js'
const CTX_HELP = 'Kontext = das Kurzzeitgedächtnis des Modells. Größer = merkt sich mehr (längere Dateien/Chats), braucht aber mehr Speicher und wird etwas langsamer.'
const ROLE_HELP = 'Die Rolle ist ein zusätzlicher, sprechender Name (z.B. coder). Du kannst in deinen Tools entweder den echten Modellnamen ODER die Rolle angeben — beides führt zum selben Modell.'
const ROLES = [
{ id: 'coder', label: 'Coder', desc: 'Programmieren & Code' },
{ id: 'vision', label: 'Vision', desc: 'Bilder verstehen' },
{ id: 'scout', label: 'Scout', desc: 'Schneller Allrounder' },
{ id: 'reviewer', label: 'Reviewer', desc: 'Code/Texte prüfen' },
{ id: 'manager', label: 'Manager', desc: 'Planen & koordinieren' },
]
const allModels = $derived(((statusStore.value?.models) || []) as any[])
function refresh() { document.dispatchEvent(new Event('mc:refresh')) }
// ---- Chat ----
let chatModel = $state('')
let chatMsg = $state('')
let chatReply = $state('')
let chatLoading = $state(false)
const chatModels = $derived(allModels.filter((m: any) => !m.incomplete))
async function sendChat() {
const model = chatModel || chatModels[0]?.name
if (!model || !chatMsg.trim()) return
chatLoading = true; chatReply = '(wecke Modell, kann beim Laden kurz dauern…)'
try {
const r = await api('/api/chat', { method: 'POST', body: JSON.stringify({ model, message: chatMsg }) })
chatReply = r.reply
} catch (e: any) { chatReply = 'Fehler: ' + e.message }
chatLoading = false; refresh()
}
// ---- Config Modal ----
let cfgOpen = $state(false)
let cfgModel = $state<any>(null)
let cfgCtx = $state(8192)
function openConfig(name: string) {
cfgModel = allModels.find((m: any) => m.name === name)
if (!cfgModel) return
cfgCtx = cfgModel.meta?.ctx || 8192
cfgOpen = true
}
async function saveConfig() {
try {
await api('/api/update_model', { method: 'POST', body: JSON.stringify({ alias: cfgModel.name, ctx: cfgCtx }) })
toast('Gespeichert — aktiv beim nächsten Modell-Start.')
cfgOpen = false; refresh()
} catch (e: any) { toast(e.message, true) }
}
// ---- Role Modal ----
let roleOpen = $state(false)
let roleModelName = $state('')
let roleCustom = $state('')
let roleCurrentRole = $state('')
function openRole(name: string) {
const m = allModels.find((m: any) => m.name === name)
roleModelName = name
roleCurrentRole = m?.role || ''
roleCustom = ''
roleOpen = true
}
async function saveRole(role: string) {
try {
await api('/api/set_role', { method: 'POST', body: JSON.stringify({ alias: roleModelName, role }) })
toast(role ? `Rolle gesetzt: ${role}` : 'Rolle entfernt.')
roleOpen = false; refresh()
} catch (e: any) { toast(e.message, true) }
}
// ---- Actions ----
async function unloadOne(name: string) {
if (!await confirmModal({ title: `„${name}" entladen?`, body: 'Das Modell wird aus dem Grafikspeicher geworfen und lädt beim nächsten Aufruf automatisch neu.', confirmLabel: 'Entladen' })) return
try { await api('/api/unload?model=' + encodeURIComponent(name), { method: 'POST' }); toast('Entladen: ' + name); setTimeout(refresh, 600) }
catch (e: any) { toast(e.message, true) }
}
async function deleteModel(name: string) {
if (!await confirmModal({ title: `„${name}" wirklich löschen?`, body: 'Das Modell wird aus der Konfiguration ausgetragen und seine Dateien werden von der Festplatte gelöscht, um Speicher freizugeben. <b>Das lässt sich nicht rückgängig machen</b> — später brauchst du dafür einen neuen Download.', confirmLabel: 'Endgültig löschen', danger: true })) return
try {
const r = await api('/api/delete_model', { method: 'POST', body: JSON.stringify({ alias: name }) })
toast(r.note || 'Gelöscht: ' + name); setTimeout(refresh, 600)
} catch (e: any) { toast(e.message, true) }
}
function capTags(caps: string[]) {
if (!caps?.length) return ''
return caps.map((c: string) =>
c === 'Code' ? '<span class="tag code">Code</span>'
: c === 'Bild' ? '<span class="tag img">Bild</span>'
: '<span class="tag text">Text</span>'
).join(' ')
}
function details(meta: any) {
if (!meta) return ''
const q = meta.quant || '?'
const c = meta.ctx ? Math.round(meta.ctx / 1024) + 'K' : '?'
const s = meta.size_bytes ? (meta.size_bytes / 1024 ** 3).toFixed(1) + ' GB' : '?'
return `<span class="mono-sm">${q} · ${c} · ${s}</span>`
}
function copyToClipboard(text: string) {
navigator.clipboard?.writeText(text); toast('Kopiert: ' + text)
}
</script>
<div id="m-head">
<div class="pagehead">
<div>
<h1>Modelle</h1>
<div class="sub">Deine konfigurierten Modelle — testen, Kontext anpassen oder aus dem Speicher werfen.</div>
</div>
</div>
</div>
<!-- Quick Chat -->
<div class="card">
<div class="card-h"><h3>Schnelltest</h3></div>
<div class="card-sub">Schreib eine Nachricht — das gewählte Modell wird automatisch geweckt.</div>
<label>Modell</label>
<select bind:value={chatModel}>
{#each chatModels as m}<option value={m.name}>{m.name}</option>{/each}
</select>
<label>Nachricht</label>
<textarea bind:value={chatMsg} placeholder='z.B. Erklär mir kurz, was du kannst."'></textarea>
<button class="primary" disabled={chatLoading} onclick={sendChat}>{chatLoading ? '…' : 'Senden'}</button>
{#if chatReply}
<div class="reply" style="margin-top:12px">{chatReply}</div>
{/if}
</div>
<!-- Models Table -->
<div class="card">
<div class="card-h"><h3>Modelle &amp; Ports</h3><span class="meta">{allModels.length ? allModels.length + ' konfiguriert' : ''}</span></div>
<div class="card-sub">Modelle laden automatisch, sobald eine Anfrage kommt — du musst nichts manuell starten.</div>
{#if !allModels.length}
<div class="empty-c">
<div class="e-t">Noch keine Modelle konfiguriert</div>
<div class="e-s">Hol dir unter „Cookbook" ein passendes Modell.</div>
</div>
{:else}
<table>
<thead><tr><th>Modell</th><th>Kann</th><th>Details</th><th>Status</th><th>Port</th><th style="text-align:right">Aktionen</th></tr></thead>
<tbody>
{#each allModels as m}
{#if m.incomplete}
<tr style="opacity:.9">
<td class="mid" style="font-weight:500">{m.name}<div class="li-sub mono-sm" style="color:var(--warn)">kein Modell hinterlegt</div></td>
<td></td>
<td><span class="mono-sm text-mut">leere Rolle</span></td>
<td><span class="badge">leer</span></td>
<td class="port"></td>
<td style="text-align:right;white-space:nowrap">
<button class="ghost" onclick={() => { toast('Lade im Cookbook ein Modell und setze den Alias auf: ' + m.name); document.querySelector(".nav-item[data-view='cookbook']")?.dispatchEvent(new MouseEvent('click')) }}>Modell zuweisen</button>
<button class="ghost del" onclick={() => deleteModel(m.name)}>Löschen</button>
</td>
</tr>
{:else}
<tr>
<td class="mid">
<div style="font-weight:500;display:flex;align-items:center;gap:7px;flex-wrap:wrap">
{m.name}
{#if m.role}<span class="tag" style="background:rgba(45,212,191,.14);color:var(--accent);border-color:rgba(45,212,191,.3)">{m.role}</span>{/if}
</div>
{#if m.meta?.filename}<div class="li-sub mono-sm">{m.meta.filename}</div>{/if}
<div class="li-sub mono-sm" style="margin-top:3px;display:flex;align-items:center;gap:6px;flex-wrap:wrap">
<span class="text-mut">API-Name:</span>
{#each (m.api_ids?.length ? m.api_ids : [m.name]) as id}
<code style="cursor:pointer" title="Klicken zum Kopieren" onclick={() => copyToClipboard(id)}>{id}</code>
{/each}
</div>
</td>
<td>{@html capTags(m.meta?.caps)}</td>
<td>{@html details(m.meta)}</td>
<td>{@html badge(m.state, m.download_progress)}</td>
<td class="port">{m.port ?? 'auto'}</td>
<td style="text-align:right;white-space:nowrap">
<button class="ghost" onclick={() => openRole(m.name)}>Rolle</button>
<button class="ghost" onclick={() => openConfig(m.name)}>Konfigurieren</button>
<button class="ghost" onclick={() => unloadOne(m.name)}>Entladen</button>
<button class="ghost del" onclick={() => deleteModel(m.name)}>Löschen</button>
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
{/if}
</div>
<!-- Config Modal -->
{#if cfgOpen && cfgModel}
<div class="modal-overlay" role="dialog" onclick={e => { if ((e.target as HTMLElement).classList.contains('modal-overlay')) cfgOpen = false }}>
<div class="modal-card">
<button class="ghost" style="position:absolute;top:14px;right:14px" onclick={() => cfgOpen = false}>Schließen</button>
<h3>Modell konfigurieren</h3>
<p class="mono-sm" style="margin:-4px 0 16px">{cfgModel.name}</p>
<label>Kontext-Größe (Tokens) {@html infoDot(CTX_HELP)}</label>
<input type="number" bind:value={cfgCtx}>
{#if cfgModel.meta?.optimal_ctx}
<div class="tile" style="display:flex;justify-content:space-between;align-items:center;margin:8px 0 10px">
<div style="flex:1">
<div style="font-size:13px">Empfohlen für deine Hardware</div>
<div class="hint" style="margin:4px 0 0">
Optimal ~{Math.round(cfgModel.meta.optimal_ctx / 1024)}k → ~{cfgModel.meta.peak_ram_optimal_gb} GB Spitzenbedarf.<br>
Aktuell ~{Math.round((cfgModel.meta.ctx || 0) / 1024)}k → ~{cfgModel.meta.peak_ram_gb} GB.
</div>
</div>
<button class="ghost" onclick={() => cfgCtx = cfgModel.meta.optimal_ctx}>Optimal übernehmen</button>
</div>
{/if}
<div class="hint" style="margin-bottom:14px">Es läuft immer nur <b>ein</b> Modell gleichzeitig — ein größerer Kontext hier beeinflusst deine anderen Modelle nicht.</div>
<button class="primary" style="width:100%;margin-top:6px" onclick={saveConfig}>Speichern</button>
</div>
</div>
{/if}
<!-- Role Modal -->
{#if roleOpen}
<div class="modal-overlay" role="dialog" onclick={e => { if ((e.target as HTMLElement).classList.contains('modal-overlay')) roleOpen = false }}>
<div class="modal-card">
<button class="ghost" style="position:absolute;top:14px;right:14px" onclick={() => roleOpen = false}>Schließen</button>
<h3>Rolle festlegen {@html infoDot(ROLE_HELP)}</h3>
<p class="mono-sm" style="margin:-4px 0 16px">{roleModelName}</p>
<div class="hint" style="margin-bottom:10px">Wähle eine Rolle als zweiten, sprechenden Namen. In deinen Tools funktionieren danach <b>beide</b>: der echte Modellname und die Rolle.</div>
<div class="flex gap-2" style="flex-wrap:wrap;margin-bottom:12px">
{#each ROLES as r}
<button class="{roleCurrentRole === r.id ? 'primary' : 'ghost'}" title={r.desc} style="border-radius:999px"
onclick={() => saveRole(r.id)}>{r.label}</button>
{/each}
</div>
<label>Eigene Rolle (optional)</label>
<input bind:value={roleCustom} placeholder="z.B. uebersetzer">
<div class="btn-row" style="margin-top:14px;display:flex;gap:8px">
<button class="ghost" style="flex:1" onclick={() => saveRole('')}>Rolle entfernen</button>
<button class="primary" style="flex:2" onclick={() => saveRole(roleCustom.trim().toLowerCase())}>Speichern</button>
</div>
</div>
</div>
{/if}
+209
View File
@@ -0,0 +1,209 @@
<script lang="ts">
import { onMount } from 'svelte'
import { statusStore } from '../stores/status.svelte.js'
import { systemStore } from '../stores/system.svelte.js'
import { api } from '@core/api.js'
import { confirmModal, icon } from '@core/ui.js'
const s = $derived(statusStore.value)
const sys = $derived(systemStore.value)
const allModels = $derived((s?.models || []) as any[])
const swapOk = $derived(s?.swap_ok ?? false)
const activeModel = $derived(allModels.find((m: any) => ['running','ready','loading','starting'].includes(m.state)))
const heroTitle = $derived(
!s ? 'Verbinde…'
: !swapOk ? 'LLM-Engine offline'
: sys && sys.ram.percent >= 90 ? 'Achtung: Speicher wird knapp.'
: 'Alles läuft rund.'
)
const heroSub = $derived(
!s ? ''
: !swapOk ? 'Der llama-swap-Dienst antwortet gerade nicht.'
: sys && sys.ram.percent >= 90 ? `Arbeitsspeicher bei ${Math.round(sys.ram.percent)} % — eventuell Speicher freigeben.`
: `${allModels.length} ${allModels.length === 1 ? 'Modell' : 'Modelle'} bereit · ${activeModel ? `„${activeModel.name}" geladen` : 'keins geladen'} · keine Warnungen.`
)
const gpuPct = $derived(() => {
const g = sys?.gpu
if (g && (g.vram.total + g.gtt.total) > 0)
return ((g.vram.used + g.gtt.used) / (g.vram.total + g.gtt.total)) * 100
return 0
})
const tempWord = (t: number | null) =>
t == null ? '' : t < 55 ? 'kühl & gesund' : t < 72 ? 'normal' : 'läuft heiß'
let news = $state<any[]>([])
onMount(async () => {
try { news = ((await api('/api/news')).items || []).slice(0, 4) } catch {}
})
async function freeMemory() {
const ok = await confirmModal({
title: 'Speicher freigeben?',
body: 'Alle aktuell geladenen Modelle werden aus dem Grafikspeicher geworfen. Sie laden beim nächsten Aufruf automatisch neu — es geht nichts verloren.',
confirmLabel: 'Speicher freigeben',
})
if (!ok) return
await api('/api/unload', { method: 'POST' })
document.dispatchEvent(new Event('mc:refresh'))
}
function go(view: string) {
document.querySelector(`.nav-item[data-view="${view}"]`)?.dispatchEvent(new MouseEvent('click'))
}
function capTagClass(caps: string[]) {
if (caps?.includes('Bild')) return 'img'
if (caps?.includes('Code')) return 'code'
return 'text'
}
function capTagLabel(caps: string[]) {
if (caps?.includes('Bild')) return 'Bild'
if (caps?.includes('Code')) return 'Code'
return 'Text'
}
</script>
<div class="pagehead">
<div>
<h1>{heroTitle}</h1>
<div class="sub">{heroSub}</div>
</div>
</div>
<div class="tiles">
<div class="tile">
<div class="t-l">LLM-Engine</div>
<div class="t-v {swapOk ? 'ok' : 'bad'}">{swapOk ? 'Online' : 'Offline'}</div>
<div class="t-s">llama-swap</div>
</div>
<div class="tile">
<div class="t-l">Aktives Modell</div>
<div class="t-v">{activeModel ? activeModel.name : 'Keins'}</div>
<div class="t-s">{activeModel ? 'im Grafikspeicher' : 'nichts geladen'}</div>
</div>
{#if sys}
{@const t = sys.gpu_temp ?? sys.cpu?.temp ?? null}
<div class="tile">
<div class="t-l">GPU-Temperatur</div>
<div class="t-v">{t != null ? `${Math.round(t)}°` : ''}</div>
<div class="t-s {t != null && t < 72 ? 'ok' : ''}">{tempWord(t)}</div>
</div>
{@const free = sys.ram.total - sys.ram.used}
<div class="tile">
<div class="t-l">Freier Speicher</div>
<div class="t-v">{Math.round(free / 1024 ** 3)}<small> GB</small></div>
<div class="t-s">von {Math.round(sys.ram.total / 1024 ** 3)} GB</div>
</div>
{:else}
<div class="tile"><div class="t-l">GPU-Temperatur</div><div class="t-v"></div><div class="t-s">messe…</div></div>
<div class="tile"><div class="t-l">Freier Speicher</div><div class="t-v"></div><div class="t-s">messe…</div></div>
{/if}
</div>
<div class="split">
<div class="card">
<div class="card-h"><h3>System-Gesundheit</h3></div>
<div class="card-sub">So ausgelastet ist dein Mini-PC gerade.</div>
{#if sys}
{@const gpu = gpuPct()}
{#each [
['Prozessor (CPU)', sys.cpu?.percent ?? 0],
['Arbeitsspeicher (RAM)', sys.ram.percent],
['Grafikspeicher (VRAM)', gpu]
] as [label, pct]}
{@const p = Math.max(0, Math.min(100, (pct as number) || 0))}
<div class="meter">
<div class="meter-h"><span class="mk">{label}</span><span class="mv">{Math.round(p)} %</span></div>
<div class="bar {p >= 90 ? 'bad' : p >= 75 ? 'warn' : ''}"><i style="width:{Math.max(2, p)}%"></i></div>
</div>
{/each}
{:else}
<div class="empty">Warte auf Messwerte…</div>
{/if}
</div>
<div class="card">
<div class="card-h"><h3>Schnellstart</h3></div>
<div class="card-sub">Die häufigsten Aufgaben — ein Klick.</div>
<button class="qa" onclick={() => go('cookbook')}>
<span class="qa-ic teal">{@html icon('search')}</span>
<span class="qa-main"><span class="qa-t">Modell finden</span><span class="qa-s">Passend zu deiner Hardware</span></span>
<span class="qa-arrow">{@html icon('chevron')}</span>
</button>
<button class="qa" onclick={freeMemory}>
<span class="qa-ic amber">{@html icon('refresh')}</span>
<span class="qa-main"><span class="qa-t">Speicher freigeben</span><span class="qa-s">Modelle entladen · fragt vorher nach</span></span>
<span class="qa-arrow">{@html icon('chevron')}</span>
</button>
<button class="qa" onclick={() => go('server')}>
<span class="qa-ic blue">{@html icon('file')}</span>
<span class="qa-main"><span class="qa-t">Logs ansehen</span><span class="qa-s">Live mitlesen, was läuft</span></span>
<span class="qa-arrow">{@html icon('chevron')}</span>
</button>
</div>
</div>
<div class="grid grid-2">
<div class="card">
<div class="card-h">
<h3>Dein Stack</h3>
<span class="meta">{allModels.length ? allModels.length + ' konfiguriert' : ''}</span>
</div>
{#if allModels.length}
<div class="list">
{#each allModels as m}
{@const downloading = m.state === 'downloading'}
{@const on = ['running','ready','loading','starting'].includes(m.state)}
{@const dot = (m.state === 'loading' || m.state === 'starting' || downloading) ? 'load' : on ? 'on' : ''}
{@const statusText = downloading
? `↓ Download${m.download_progress != null ? ` ${Math.round(m.download_progress)}%` : ''}`
: on ? (m.state === 'loading' ? 'lädt…' : 'geladen') : 'bereit'}
<div class="li">
<span class="li-dot {dot}"></span>
<div class="li-main">
<div class="flex items-center gap-2">
<span class="li-id">{m.name}</span>
{#if m.meta?.caps?.length}
<span class="tag {capTagClass(m.meta.caps)}">{capTagLabel(m.meta.caps)}</span>
{/if}
</div>
{#if m.meta?.filename}
<div class="li-sub mono-sm">{m.meta.filename}</div>
{/if}
</div>
<span class="li-meta" style="white-space:nowrap">{statusText}</span>
</div>
{/each}
</div>
{:else}
<div class="empty-c">
<div class="e-t">Noch keine Modelle</div>
<div class="e-s">Hol dir unter „Cookbook" ein Modell, das auf deine Hardware passt.</div>
</div>
{/if}
</div>
<div class="card">
<div class="card-h">
<h3>Top-News</h3>
<span class="meta" style="cursor:pointer" role="button" tabindex="0"
onclick={() => go('news')} onkeydown={e => e.key === 'Enter' && go('news')}>Alle →</span>
</div>
{#if news.length}
<div class="list">
{#each news as n}
<a class="li" href={n.link} target="_blank" rel="noopener" style="text-decoration:none;color:inherit">
<div class="li-main">
<div style="font-size:13px;line-height:1.4">{n.title}</div>
<div class="li-sub">{n.source}</div>
</div>
</a>
{/each}
</div>
{:else}
<div class="empty">News gerade nicht ladbar.</div>
{/if}
</div>
</div>
+259
View File
@@ -0,0 +1,259 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte'
import { statusStore } from '../stores/status.svelte.js'
import { jobsStore } from '../stores/jobs.svelte.js'
import { api, getToken } from '@core/api.js'
import { esc, toast, confirmModal, promptModal } from '@core/ui.js'
const status = $derived(statusStore.value)
const jobs = $derived(jobsStore.value)
// Console
let ws: WebSocket | null = null
let consoleLogs = $state('Verbinde…\n')
let selectedService = $state('llama-swap')
let consoleEl: HTMLDivElement | undefined
// Active job tracking
let activeJobId = $state<string | null>(null)
let activeJobLabel = $state('')
const notified = new Set<string>()
const activeJob = $derived(activeJobId ? jobs.find((j: any) => j.id === activeJobId) : null)
// Auto-scroll + job finish notification
$effect(() => {
if (!activeJob) return
const j = activeJob as any
const done = j.state === 'done', failed = j.state === 'failed', canceled = j.state === 'canceled'
if ((done || failed || canceled) && !notified.has(j.id)) {
notified.add(j.id)
toast(done ? `✓ ${activeJobLabel} abgeschlossen.` : canceled ? `${activeJobLabel} abgebrochen.` : `✗ ${activeJobLabel} fehlgeschlagen.`, failed)
if (done) setTimeout(() => document.dispatchEvent(new Event('mc:refresh')), 800)
}
})
function watchJob(id: string, label: string) {
activeJobId = id
activeJobLabel = label
jobsStore.track(id)
}
// Expose track for use from other panels (server panel tracks its own jobs)
onMount(() => { connectConsole() })
onDestroy(() => { ws?.close() })
function connectConsole() {
ws?.close()
const svc = selectedService
consoleLogs = `Verbinde mit ${svc}…\n`
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
ws = new WebSocket(`${proto}//${location.host}/api/logs/${svc}?token=${encodeURIComponent(getToken())}`)
ws.onmessage = e => {
consoleLogs += e.data
if (consoleEl) consoleEl.scrollTop = consoleEl.scrollHeight
}
ws.onclose = () => { consoleLogs += '\n— Verbindung getrennt —' }
ws.onerror = () => ws?.close()
}
async function restartService(name: string, title: string, body: string) {
if (!await confirmModal({ title, body, confirmLabel: 'Neustarten' })) return
try {
await api(`/api/service/${name}/restart`, { method: 'POST' })
toast('Neustart ausgelöst: ' + name)
setTimeout(() => document.dispatchEvent(new Event('mc:refresh')), 2000)
} catch (e: any) { toast(e.message, true) }
}
async function selfUpdate() {
if (!await confirmModal({ title: 'Mission Control aktualisieren?', body: 'Holt die neueste Dashboard-Version aus Gitea und startet automatisch neu. Die Seite trennt kurz und verbindet von selbst wieder (~10 Sek).' })) return
try { const r = await api('/api/self-update', { method: 'POST' }); toast('Update läuft — Dashboard startet gleich neu.'); watchJob(r.job_id, 'Mission Control Update') }
catch (e: any) { toast(e.message, true) }
}
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') }
catch (e: any) { toast(e.message, true) }
}
async function unloadAll() {
if (!await confirmModal({ title: 'Grafikspeicher leeren?', body: 'Alle geladenen Modelle werden entladen. Sie laden beim nächsten Aufruf automatisch neu — es geht nichts verloren.', confirmLabel: 'Leeren' })) return
try { await api('/api/unload', { method: 'POST' }); toast('Grafikspeicher geleert.'); setTimeout(() => document.dispatchEvent(new Event('mc:refresh')), 600) }
catch (e: any) { toast(e.message, true) }
}
async function osUpdate() {
if (!await confirmModal({ title: 'OS-Updates installieren?', body: 'Führt <code>apt update & upgrade</code> aus. Das kann ein paar Minuten dauern; der Fortschritt erscheint in der Aktivität.' })) return
const pwd = await promptModal({ title: 'sudo-Passwort', body: 'Für die System-Updates wird einmalig dein sudo-Passwort gebraucht.', placeholder: 'sudo-Passwort', password: true, confirmLabel: 'Installieren' })
if (!pwd) return
try { const r = await api('/api/os-update', { method: 'POST', body: JSON.stringify({ password: pwd }) }); toast('OS-Update gestartet.'); watchJob(r.job_id, 'OS-Update') }
catch (e: any) { toast(e.message, true) }
}
async function rebootServer() {
if (!await confirmModal({ title: 'Server wirklich neu starten?', body: 'Der ganze Bosgame startet physisch neu. Alles ist für ~1 Minute offline — auch dieses Dashboard.', confirmLabel: 'Reboot', danger: true })) return
const pwd = await promptModal({ title: 'sudo-Passwort', body: 'Für den Reboot wird einmalig dein sudo-Passwort gebraucht.', placeholder: 'sudo-Passwort', password: true, confirmLabel: 'Jetzt neustarten', danger: true })
if (!pwd) return
try { await api('/api/reboot', { method: 'POST', body: JSON.stringify({ password: pwd }) }); toast('Reboot ausgelöst — bis gleich.') }
catch (e: any) { toast(e.message, true) }
}
async function checkSwap() {
swapCheckResult = 'prüfe…'
try {
const r = await api('/api/integration/test')
swapCheckResult = r.ok ? `✓ Engine antwortet — ${r.models.length} Modell(e): ${r.models.join(', ')}` : `✗ keine Verbindung: ${r.error}`
swapCheckOk = r.ok
} catch (e: any) { swapCheckResult = '✗ ' + e.message; swapCheckOk = false }
}
let swapCheckResult = $state('')
let swapCheckOk = $state(false)
const swapModels = $derived((status?.models || []) as any[])
const loadedModel = $derived(swapModels.find((m: any) => m.state === 'running' || m.state === 'loading'))
// Job progress helpers
function jobPct(j: any) {
if (!j || j.state !== 'running') return null
if (typeof j.progress === 'number') return Math.min(100, j.progress)
const last = (j.log || []).at(-1) || ''
const m = last.match(/(\d+(?:\.\d+)?)\s*%/)
return m ? Math.min(100, parseFloat(m[1])) : null
}
function qa(tone: string, title: string, desc: string, fn: () => void, danger = false) {
return { tone, title, desc, fn, danger }
}
</script>
<div class="pagehead">
<div>
<h1>Server &amp; Wartung</h1>
<div class="sub">Dienste steuern, Updates einspielen und live mitlesen — ganz ohne SSH oder Terminal.</div>
</div>
</div>
<!-- LLM-Engine Card -->
<div class="card">
<div class="card-h">
<h3>LLM-Engine (llama-swap)</h3>
<span class="chip" style="margin-left:auto;{status?.swap_ok
? 'background:rgba(63,185,80,.12);border-color:rgba(63,185,80,.25);color:#7ee29a'
: 'background:rgba(248,81,73,.12);border-color:rgba(248,81,73,.3);color:#ff9b95'}">
{status?.swap_ok ? '● erreichbar' : '● offline'}
</span>
</div>
<div class="card-sub">Das Herzstück: lädt bei jeder Anfrage automatisch das passende Modell.</div>
<div class="kv" style="margin-bottom:6px">
<div class="kv-row"><span class="kv-k">Adresse (für deine Tools)</span><span class="kv-v mono-sm">{(status?.swap_url || '—') + '/v1'}</span></div>
<div class="kv-row"><span class="kv-k">Aktuell geladen</span>
<span class="kv-v">{loadedModel ? loadedModel.name + ` (${loadedModel.state})` : 'nichts geladen — lädt bei der nächsten Anfrage'}</span>
</div>
<div class="kv-row"><span class="kv-k">Konfigurierte Modelle</span>
<span class="kv-v">{swapModels.filter((m: any) => !m.incomplete).length || 'keine'}</span>
</div>
</div>
<div class="btn-row" style="display:flex;align-items:center;gap:10px">
<button class="ghost" onclick={checkSwap}>Verbindung prüfen</button>
{#if swapCheckResult}
<span class="mono-sm" style="color:{swapCheckOk ? '#7ee29a' : '#ff9b95'}">{swapCheckResult}</span>
{/if}
</div>
</div>
<!-- Service Actions -->
<div class="card">
<div class="card-h"><h3>Dienste &amp; Applikation</h3></div>
<div class="card-sub">Diese Aktionen sind harmlos und passwortlos — es geht nichts dabei verloren.</div>
<button class="qa" onclick={() => restartService('llama-swap', 'LLM-Engine neustarten?', 'Die Engine startet neu. Geladene Modelle werden kurz entladen (~5 Sekunden), laden danach automatisch wieder.')}>
<span class="qa-ic teal"></span>
<span class="qa-main"><span class="qa-t">LLM-Engine neustarten</span><span class="qa-s">Startet llama-swap neu. Geladene Modelle laden danach automatisch wieder.</span></span>
<span class="qa-arrow"></span>
</button>
<button class="qa" onclick={() => restartService('mission-control', 'Dashboard neustarten?', 'Diese Oberfläche trennt sich kurz und verbindet automatisch wieder.')}>
<span class="qa-ic blue"></span>
<span class="qa-main"><span class="qa-t">Dashboard neustarten</span><span class="qa-s">Startet diese Oberfläche neu (~5 Sek).</span></span>
<span class="qa-arrow"></span>
</button>
<button class="qa" onclick={selfUpdate}>
<span class="qa-ic teal"></span>
<span class="qa-main"><span class="qa-t">Mission Control aktualisieren</span><span class="qa-s">Holt die neueste Version aus Gitea und startet automatisch neu (~10 Sek).</span></span>
<span class="qa-arrow"></span>
</button>
<button class="qa" onclick={engineUpdate}>
<span class="qa-ic blue"></span>
<span class="qa-main"><span class="qa-t">LLM-Engine aktualisieren</span><span class="qa-s">Lädt die neueste llama.cpp-Version (ROCm) für deine GPU.</span></span>
<span class="qa-arrow"></span>
</button>
<button class="qa" onclick={unloadAll}>
<span class="qa-ic amber">🗄</span>
<span class="qa-main"><span class="qa-t">Grafikspeicher leeren</span><span class="qa-s">Wirft alle aktuell geladenen Modelle aus dem VRAM. Fragt vorher nach.</span></span>
<span class="qa-arrow"></span>
</button>
</div>
<!-- OS Actions -->
<div class="card">
<div class="card-h"><h3>Betriebssystem (Bosgame)</h3></div>
<div class="card-sub">Tiefe Eingriffe — das Dashboard fragt einmalig nach deinem sudo-Passwort.</div>
<button class="qa" onclick={osUpdate}>
<span class="qa-ic amber"></span>
<span class="qa-main"><span class="qa-t">OS-Updates installieren</span><span class="qa-s">Führt apt update &amp; upgrade aus. Kann ein paar Minuten dauern.</span></span>
<span class="qa-arrow"></span>
</button>
<button class="qa qa-danger" onclick={rebootServer}>
<span class="qa-ic red"></span>
<span class="qa-main"><span class="qa-t">Server neustarten (Reboot)</span><span class="qa-s">Bootet den ganzen Bosgame neu. Alles ist für ~1 Minute offline.</span></span>
<span class="qa-arrow"></span>
</button>
</div>
<!-- Active Job Card -->
{#if activeJob}
{@const j = activeJob as any}
{@const done = j.state === 'done'}
{@const failed = j.state === 'failed'}
{@const canceled = j.state === 'canceled'}
{@const terminal = done || failed || canceled}
{@const p = jobPct(j)}
<div class="card">
<div class="card-h">
<h3>Aktueller Vorgang</h3>
{#if !terminal}
<button class="ghost" style="margin-left:auto;padding:3px 12px"
onclick={async () => {
if (await confirmModal({ title: activeJobLabel + ' abbrechen?', body: 'Der laufende Vorgang wird gestoppt.', confirmLabel: 'Abbrechen', danger: true }))
api(`/api/jobs/${j.id}/cancel`, { method: 'POST' })
}}>Abbrechen</button>
{/if}
<span style="margin-left:10px">
{#if done}<span class="badge b-run">✓ fertig</span>
{:else if failed}<span class="badge b-err">✗ fehlgeschlagen</span>
{:else if canceled}<span class="badge">abgebrochen</span>
{:else}<span class="badge b-load">läuft…</span>{/if}
</span>
</div>
<div class="card-sub">{activeJobLabel}{terminal ? (done ? ' — erfolgreich.' : canceled ? ' — abgebrochen.' : ' — fehlgeschlagen.') : ' läuft…'}</div>
{#if p != null && !terminal}
<div class="bar" style="margin-bottom:10px"><i style="width:{p}%"></i></div>
{/if}
<div class="log" style="max-height:280px">{(j.log || []).join('\n')}</div>
</div>
{/if}
<!-- Live Console -->
<div class="card">
<div class="card-h">
<h3>Live-Konsole</h3>
<select bind:value={selectedService} onchange={connectConsole} style="margin:0 0 0 auto;width:240px">
<option value="llama-swap">LLM-Engine (llama-swap)</option>
<option value="mission-control">Dashboard (mission-control)</option>
<option value="system">System (ganzer Server)</option>
</select>
</div>
<div class="card-sub">Live mitlesen, was der Dienst gerade tut.</div>
<div class="console" bind:this={consoleEl}>{consoleLogs}</div>
</div>
+8
View File
@@ -1,6 +1,14 @@
let _value = $state<any[]>([])
let _tracked = $state<string[]>([])
export const jobsStore = {
get value() { return _value },
set(jobs: any[]) { _value = jobs || [] },
get tracked() { return _tracked },
track(id: string) { if (!_tracked.includes(id)) _tracked = [..._tracked, id] },
toggle(id: string) {
_tracked = _tracked.includes(id)
? _tracked.filter(x => x !== id)
: [..._tracked, id]
},
}