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:
+36
-29
@@ -1,29 +1,28 @@
|
|||||||
import { mount } from 'svelte'
|
import { mount } from 'svelte'
|
||||||
import { api, getToken, setToken, getHfToken, setHfToken } from '@core/api.js'
|
import { api, getToken, setToken, getHfToken, setHfToken } from '@core/api.js'
|
||||||
import { initNav } from '@core/nav.js'
|
import { initNav } from '@core/nav.js'
|
||||||
|
import { ICON } from '@core/ui.js'
|
||||||
import { statusStore } from './stores/status.svelte.js'
|
import { statusStore } from './stores/status.svelte.js'
|
||||||
import { jobsStore } from './stores/jobs.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 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> = {}
|
let prevModelStates: Record<string, string> = {}
|
||||||
|
|
||||||
// ---- Swap-Flash ----
|
|
||||||
function flashSwap() {
|
function flashSwap() {
|
||||||
const el = document.getElementById('top-active-text')
|
const el = document.getElementById('top-active-text')
|
||||||
if (el) { el.classList.add('swap-flash'); setTimeout(() => el.classList.remove('swap-flash'), 900) }
|
if (el) { el.classList.add('swap-flash'); setTimeout(() => el.classList.remove('swap-flash'), 900) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Status verteilen ----
|
|
||||||
function applyStatus(s: any) {
|
function applyStatus(s: any) {
|
||||||
const dot = document.getElementById('swdot')
|
const dot = document.getElementById('swdot')
|
||||||
const label = document.getElementById('swlabel')
|
const label = document.getElementById('swlabel')
|
||||||
@@ -47,7 +46,6 @@ function applyStatus(s: any) {
|
|||||||
if (s.swap_ok) hideAlert()
|
if (s.swap_ok) hideAlert()
|
||||||
else showAlert(`LLM-Engine nicht erreichbar unter <b>${host}</b> – läuft der llama-swap Dienst?`, true)
|
else showAlert(`LLM-Engine nicht erreichbar unter <b>${host}</b> – läuft der llama-swap Dienst?`, true)
|
||||||
|
|
||||||
// Swap-Erkennung
|
|
||||||
const newStates: Record<string, string> = {}
|
const newStates: Record<string, string> = {}
|
||||||
for (const m of (s.models || [])) newStates[m.name] = m.state
|
for (const m of (s.models || [])) newStates[m.name] = m.state
|
||||||
for (const [name, state] of Object.entries(newStates)) {
|
for (const [name, state] of Object.entries(newStates)) {
|
||||||
@@ -58,7 +56,6 @@ function applyStatus(s: any) {
|
|||||||
}
|
}
|
||||||
setSecChip(s)
|
setSecChip(s)
|
||||||
statusStore.set(s)
|
statusStore.set(s)
|
||||||
for (const p of legacyPanels) p.onStatus?.(s)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setSecChip(s: any) {
|
function setSecChip(s: any) {
|
||||||
@@ -71,11 +68,10 @@ function setSecChip(s: any) {
|
|||||||
|
|
||||||
function applyJobs(jobs: any[]) {
|
function applyJobs(jobs: any[]) {
|
||||||
jobsStore.set(jobs || [])
|
jobsStore.set(jobs || [])
|
||||||
for (const p of legacyPanels) p.onJobs?.(jobs)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function applySystem(sys: any) {
|
function applySystem(sys: any) {
|
||||||
for (const p of legacyPanels) p.onSystem?.(sys)
|
systemStore.set(sys)
|
||||||
}
|
}
|
||||||
|
|
||||||
function showAlert(html: string, warn: boolean) {
|
function showAlert(html: string, warn: boolean) {
|
||||||
@@ -89,7 +85,7 @@ function hideAlert() {
|
|||||||
if (a) a.style.display = 'none'
|
if (a) a.style.display = 'none'
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Update-Badges ----
|
// Update badges
|
||||||
const goView = (v: string) => (document.querySelector(`.nav-item[data-view="${v}"]`) as HTMLElement)?.click()
|
const goView = (v: string) => (document.querySelector(`.nav-item[data-view="${v}"]`) as HTMLElement)?.click()
|
||||||
|
|
||||||
function mkBadge(text: string, cls: string, view: string, title?: string) {
|
function mkBadge(text: string, cls: string, view: string, title?: string) {
|
||||||
@@ -111,16 +107,14 @@ async function pollUpdates() {
|
|||||||
el.innerHTML = ''
|
el.innerHTML = ''
|
||||||
el.appendChild(mkBadge(
|
el.appendChild(mkBadge(
|
||||||
u.os > 0 ? `OS: ${u.os} Updates` : stale ? 'OS: ?' : 'OS: aktuell',
|
u.os > 0 ? `OS: ${u.os} Updates` : stale ? 'OS: ?' : 'OS: aktuell',
|
||||||
u.os > 0 ? 'warn' : stale ? 'warn' : 'ok',
|
u.os > 0 ? 'warn' : stale ? 'warn' : 'ok', 'server',
|
||||||
'server',
|
u.os > 0 ? `${u.os} Pakete${ageNote}` : stale ? `apt-Cache veraltet (${age}h)` : `Keine Updates${ageNote}`
|
||||||
u.os > 0 ? `${u.os} ausstehende 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.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'))
|
el.appendChild(mkBadge(u.models > 0 ? `Modelle: ${u.models}` : 'Modelle: aktuell', u.models > 0 ? 'warn' : 'ok', 'news'))
|
||||||
} catch { /* noop */ }
|
} catch { /* noop */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Polling ----
|
|
||||||
async function pollStatus() {
|
async function pollStatus() {
|
||||||
try { applyStatus(await api('/api/status')) }
|
try { applyStatus(await api('/api/status')) }
|
||||||
catch { applyStatus(null) }
|
catch { applyStatus(null) }
|
||||||
@@ -140,15 +134,28 @@ function connectSystemStream() {
|
|||||||
|
|
||||||
// ---- Boot ----
|
// ---- Boot ----
|
||||||
|
|
||||||
// Legacy panels mounten (overview, models, server, jobs, cookbook, connect)
|
// Icons in Nav/Logo injizieren
|
||||||
for (const p of legacyPanels) p.mount?.()
|
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)
|
// Svelte panels mounten
|
||||||
const guidesEl = document.querySelector('.view[data-view="guides"]')
|
const panels: [string, any][] = [
|
||||||
if (guidesEl) mount(GuidesPanel, { target: guidesEl })
|
['overview', OverviewPanel],
|
||||||
|
['models', ModelsPanel],
|
||||||
const newsEl = document.querySelector('.view[data-view="news"]')
|
['activity', JobsPanel],
|
||||||
if (newsEl) mount(NewsPanel, { target: newsEl })
|
['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')
|
initNav('overview')
|
||||||
|
|
||||||
|
|||||||
@@ -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. 2–5 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}
|
||||||
@@ -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}
|
||||||
@@ -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 & 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>
|
||||||
@@ -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 & 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}
|
||||||
@@ -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>
|
||||||
@@ -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 & 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 & 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 & 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>
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
let _value = $state<any[]>([])
|
let _value = $state<any[]>([])
|
||||||
|
let _tracked = $state<string[]>([])
|
||||||
|
|
||||||
export const jobsStore = {
|
export const jobsStore = {
|
||||||
get value() { return _value },
|
get value() { return _value },
|
||||||
set(jobs: any[]) { _value = jobs || [] },
|
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]
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,4 +5,9 @@ export default {
|
|||||||
compilerOptions: {
|
compilerOptions: {
|
||||||
runes: true,
|
runes: true,
|
||||||
},
|
},
|
||||||
|
onwarn: (warning, handler) => {
|
||||||
|
// a11y-Warnungen nicht als Build-Fehler behandeln
|
||||||
|
if (warning.code.startsWith('a11y')) return
|
||||||
|
handler(warning)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+44
-489
File diff suppressed because one or more lines are too long
+9
-60
@@ -11,7 +11,6 @@
|
|||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
|
|
||||||
<!-- Sidebar: beschriftete Bereichs-Navigation -->
|
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="brand"><span class="brand-logo" id="logo"></span><span class="brand-tx">Mission Control</span></div>
|
<div class="brand"><span class="brand-logo" id="logo"></span><span class="brand-tx">Mission Control</span></div>
|
||||||
<nav class="side-nav">
|
<nav class="side-nav">
|
||||||
@@ -30,7 +29,6 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div class="main">
|
<div class="main">
|
||||||
<!-- Topbar -->
|
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<span class="status-pill"><span id="swdot" class="dot"></span><span id="swlabel">verbinde…</span></span>
|
<span class="status-pill"><span id="swdot" class="dot"></span><span id="swlabel">verbinde…</span></span>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
@@ -39,63 +37,21 @@
|
|||||||
<span class="sec-chip" id="sec-chip"><span data-ic="shield"></span><span id="sec-chip-tx">Nur im Heimnetz</span></span>
|
<span class="sec-chip" id="sec-chip"><span data-ic="shield"></span><span id="sec-chip-tx">Nur im Heimnetz</span></span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Alert-Banner (wird per JS ein-/ausgeblendet) -->
|
|
||||||
<div id="alert" class="alert" style="display:none"></div>
|
<div id="alert" class="alert" style="display:none"></div>
|
||||||
|
|
||||||
<!-- Content: genau eine .view ist sichtbar (Hash-Routing) -->
|
|
||||||
<main class="content">
|
<main class="content">
|
||||||
|
<section class="view" data-view="overview"></section>
|
||||||
<section class="view" data-view="overview">
|
<section class="view" data-view="activity" hidden></section>
|
||||||
<div id="ov-hero"></div>
|
<section class="view" data-view="server" hidden></section>
|
||||||
<div class="tiles" id="ov-tiles"></div>
|
<section class="view" data-view="models" hidden></section>
|
||||||
<div class="split">
|
<section class="view" data-view="cookbook" hidden></section>
|
||||||
<div class="card" id="ov-health"></div>
|
<section class="view" data-view="connect" hidden></section>
|
||||||
<div class="card" id="ov-quickstart"></div>
|
<section class="view" data-view="news" hidden></section>
|
||||||
</div>
|
<section class="view" data-view="guides" hidden></section>
|
||||||
<div class="grid grid-2">
|
|
||||||
<div class="card" id="ov-stack"></div>
|
|
||||||
<div class="card" id="ov-news"></div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="view" data-view="activity" hidden>
|
|
||||||
<div id="act-head"></div>
|
|
||||||
<div class="tiles" id="act-kpis"></div>
|
|
||||||
<div class="card" id="act-sys"></div>
|
|
||||||
<div class="card" id="v-activity"></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="view" data-view="server" hidden>
|
|
||||||
<div class="card" id="wartung"></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="view" data-view="models" hidden>
|
|
||||||
<div id="m-head"></div>
|
|
||||||
<div class="card" id="m-chat"></div>
|
|
||||||
<div class="card" id="m-table"></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="view" data-view="cookbook" hidden>
|
|
||||||
<!-- Wird von cookbook.js gerendert -->
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="view" data-view="connect" hidden>
|
|
||||||
<!-- Wird von connect.js gerendert -->
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="view" data-view="news" hidden>
|
|
||||||
<!-- Wird von news.js gerendert -->
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="view" data-view="guides" hidden>
|
|
||||||
<!-- Wird von guides.js gerendert -->
|
|
||||||
</section>
|
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Settings Modal -->
|
|
||||||
<div id="settings-modal" style="display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.62); z-index:100; align-items:center; justify-content:center;">
|
<div id="settings-modal" style="display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.62); z-index:100; align-items:center; justify-content:center;">
|
||||||
<div class="card" style="width:100%; max-width:420px; position:relative">
|
<div class="card" style="width:100%; max-width:420px; position:relative">
|
||||||
<button id="sm-close" class="ghost" style="position:absolute; top:12px; right:12px;">Schließen</button>
|
<button id="sm-close" class="ghost" style="position:absolute; top:12px; right:12px;">Schließen</button>
|
||||||
@@ -108,19 +64,12 @@
|
|||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<label>HuggingFace-Token (optional)</label>
|
<label>HuggingFace-Token (optional)</label>
|
||||||
<input id="hf-token" class="tokin" placeholder="hf_…" autocomplete="off">
|
<input id="hf-token" class="tokin" placeholder="hf_…" autocomplete="off">
|
||||||
<div class="hint mt-2">Nicht nötig für öffentliche Modelle — vermeidet aber Rate-Limits und erlaubt zugangsbeschränkte Modelle. Erstellen: huggingface.co/settings/tokens</div>
|
<div class="hint mt-2">Nicht nötig für öffentliche Modelle — vermeidet aber Rate-Limits und erlaubt zugangsbeschränkte Modelle.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="toast" class="toast"></div>
|
<div id="toast" class="toast"></div>
|
||||||
|
|
||||||
<!-- Icons in Nav/Logo/Chip einsetzen, bevor das Haupt-Modul laedt -->
|
|
||||||
<script type="module">
|
|
||||||
import { ICON } from "/static/js/core/ui.js";
|
|
||||||
document.getElementById("logo").innerHTML = ICON.logo;
|
|
||||||
document.querySelectorAll("[data-ic]").forEach(n => (n.innerHTML = ICON[n.dataset.ic] || ""));
|
|
||||||
</script>
|
|
||||||
<script type="module" src="/static/dist/main.js"></script>
|
<script type="module" src="/static/dist/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user