feat(dashboard): Dashboard-Redesign + Bugfixes + Pre-load
Bugs behoben:
- Topbar + Server-Panel: 'Kein Modell im VRAM' obwohl geladen → alle
States (running/ready/loading/starting) werden jetzt erkannt
- Topbar zeigt bei geladenem Modell: Name + ctx-Größe + State mit Farbe
Dashboard-Redesign (OverviewPanel):
- KPI-Kacheln mit Farbe: .kpi.green/.red/.blue/.muted statt .tile
(farbige Randlinien + farbige Werte wie im Referenz-Design)
- Icons pro Kachel (server, cpu, thermo, database)
- Stack-Liste: zeigt ctx/quant/GB, 'Laden'-Button wenn Modell bereit
aber nicht geladen, ∞-Chip für immer-aktive Modelle
- 4. Schnellstart-Button: 'Tools verbinden'
Feature: Modell Vorwärmen (Pre-load):
- POST /api/preload?model=<name> — sendet Mini-Request an llama-swap
damit es das Modell vorab lädt (max_tokens=1, kein Output)
- 'Laden'-Button je Modell in der Stack-Liste
Topbar CSS:
- Aktives Modell: teal-hinterlegter Pill wenn Modell geladen
- Kontext-Größe direkt neben Modellname angezeigt
Cookbook:
- Discover: genaue Zeitanzeige wann Quellen zuletzt befragt wurden
('vor X min / h / Tg.') statt nur 'aktuell'
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -37,11 +37,16 @@ function applyStatus(s: any) {
|
|||||||
const host = (s.swap_url || '').replace(/^https?:\/\//, '')
|
const host = (s.swap_url || '').replace(/^https?:\/\//, '')
|
||||||
dot && (dot.className = 'dot ' + (s.swap_ok ? 'on' : 'off'))
|
dot && (dot.className = 'dot ' + (s.swap_ok ? 'on' : 'off'))
|
||||||
label && (label.textContent = (s.swap_ok ? 'LLM-Engine: Online – ' : 'LLM-Engine: Offline – ') + host)
|
label && (label.textContent = (s.swap_ok ? 'LLM-Engine: Online – ' : 'LLM-Engine: Offline – ') + host)
|
||||||
const active = (s.models || []).find((m: any) => m.state === 'running')
|
const LOADED = ['running', 'ready', 'loading', 'starting']
|
||||||
|
const active = (s.models || []).find((m: any) => LOADED.includes(m.state))
|
||||||
if (activeText) {
|
if (activeText) {
|
||||||
activeText.innerHTML = active
|
if (active) {
|
||||||
? `Geladen: <b style="color:var(--teal)">${active.name}</b>`
|
const ctx = active.meta?.ctx ? ` <span style="color:var(--mut);font-size:11.5px">· ${Math.round(active.meta.ctx / 1024)}k ctx</span>` : ''
|
||||||
: 'Kein Modell im VRAM'
|
const stateLabel = active.state === 'loading' || active.state === 'starting' ? ' <span style="color:var(--warn);font-size:11.5px">lädt…</span>' : ''
|
||||||
|
activeText.innerHTML = `<b style="color:var(--accent)">${active.name}</b>${ctx}${stateLabel}`
|
||||||
|
} else {
|
||||||
|
activeText.innerHTML = '<span style="color:var(--mut)">Kein Modell im VRAM</span>'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
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)
|
||||||
|
|||||||
@@ -302,12 +302,24 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-h">
|
<div class="card-h">
|
||||||
<h3>Aktuell die besten Modelle für dein System</h3>
|
<h3>Aktuell die besten Modelle für dein System</h3>
|
||||||
<span class="meta" id="cb-disc-when">{discoverData ? 'aktuell' : ''}</span>
|
<span class="meta" style="margin-left:auto">
|
||||||
|
{#if discoverData?.updated}
|
||||||
|
{@const ageMin = Math.round((Date.now() / 1000 - discoverData.updated) / 60)}
|
||||||
|
{#if ageMin < 60}
|
||||||
|
Quellen befragt vor {ageMin} min
|
||||||
|
{:else if ageMin < 1440}
|
||||||
|
Quellen befragt vor {Math.round(ageMin / 60)} h
|
||||||
|
{:else}
|
||||||
|
Quellen befragt vor {Math.round(ageMin / 1440)} Tg.
|
||||||
|
{/if}
|
||||||
|
{#if discoverStale}<span style="color:var(--warn)"> · ⚠ Cache veraltet</span>{/if}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
<button class="ghost" style="margin-left:8px" disabled={discoverLoading} onclick={() => loadDiscover(true)}>
|
<button class="ghost" style="margin-left:8px" disabled={discoverLoading} onclick={() => loadDiscover(true)}>
|
||||||
{discoverLoading ? 'Suche…' : 'Aktualisieren'}
|
{discoverLoading ? 'Suche…' : 'Aktualisieren'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-sub">Täglich aktualisierte Top-Modelle je Kategorie — Hardware-Fit berechnet für dein System. {discoverStale ? '⚠ Cache veraltet.' : ''}</div>
|
<div class="card-sub">Top-Modelle je Kategorie von HuggingFace — Hardware-Fit wird live für dein System berechnet.</div>
|
||||||
{#if discoverLoading && !discoverData}
|
{#if discoverLoading && !discoverData}
|
||||||
<div class="empty" style="text-align:center;padding:30px">Frage Quellen ab…</div>
|
<div class="empty" style="text-align:center;padding:30px">Frage Quellen ab…</div>
|
||||||
{:else if discoverData?.categories}
|
{:else if discoverData?.categories}
|
||||||
|
|||||||
@@ -3,13 +3,15 @@
|
|||||||
import { statusStore } from '../stores/status.svelte.js'
|
import { statusStore } from '../stores/status.svelte.js'
|
||||||
import { systemStore } from '../stores/system.svelte.js'
|
import { systemStore } from '../stores/system.svelte.js'
|
||||||
import { api } from '@core/api.js'
|
import { api } from '@core/api.js'
|
||||||
import { confirmModal, icon } from '@core/ui.js'
|
import { confirmModal, icon, fmtBytes } from '@core/ui.js'
|
||||||
|
|
||||||
const s = $derived(statusStore.value)
|
const s = $derived(statusStore.value)
|
||||||
const sys = $derived(systemStore.value)
|
const sys = $derived(systemStore.value)
|
||||||
const allModels = $derived((s?.models || []) as any[])
|
const allModels = $derived((s?.models || []) as any[])
|
||||||
const swapOk = $derived(s?.swap_ok ?? false)
|
const swapOk = $derived(s?.swap_ok ?? false)
|
||||||
const activeModel = $derived(allModels.find((m: any) => ['running','ready','loading','starting'].includes(m.state)))
|
const LOADED = ['running', 'ready', 'loading', 'starting']
|
||||||
|
const activeModel = $derived(allModels.find((m: any) => LOADED.includes(m.state)))
|
||||||
|
const configuredModels = $derived(allModels.filter((m: any) => !m.incomplete))
|
||||||
|
|
||||||
const heroTitle = $derived(
|
const heroTitle = $derived(
|
||||||
!s ? 'Verbinde…'
|
!s ? 'Verbinde…'
|
||||||
@@ -21,98 +23,123 @@
|
|||||||
!s ? ''
|
!s ? ''
|
||||||
: !swapOk ? 'Der llama-swap-Dienst antwortet gerade nicht.'
|
: !swapOk ? 'Der llama-swap-Dienst antwortet gerade nicht.'
|
||||||
: sys && sys.ram.percent >= 90 ? `Arbeitsspeicher bei ${Math.round(sys.ram.percent)} % — eventuell Speicher freigeben.`
|
: 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.`
|
: `${configuredModels.length} ${configuredModels.length === 1 ? 'Modell' : 'Modelle'} konfiguriert · ${activeModel ? `„${activeModel.name}" geladen` : 'keines geladen'} · keine Warnungen.`
|
||||||
)
|
)
|
||||||
|
|
||||||
const gpuPct = $derived(() => {
|
const gpuPct = $derived(() => {
|
||||||
const g = sys?.gpu
|
const g = sys?.gpu
|
||||||
if (g && (g.vram.total + g.gtt.total) > 0)
|
if (g && (g.vram.total + g.gtt.total) > 0)
|
||||||
return ((g.vram.used + g.gtt.used) / (g.vram.total + g.gtt.total)) * 100
|
return ((g.vram.used + g.gtt.used) / (g.vram.total + g.gtt.total)) * 100
|
||||||
return 0
|
return 0
|
||||||
})
|
})
|
||||||
const tempWord = (t: number | null) =>
|
|
||||||
t == null ? '' : t < 55 ? 'kühl & gesund' : t < 72 ? 'normal' : 'läuft heiß'
|
|
||||||
|
|
||||||
let news = $state<any[]>([])
|
let news = $state<any[]>([])
|
||||||
|
let preloadingModel = $state<string | null>(null)
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
try { news = ((await api('/api/news')).items || []).slice(0, 4) } catch {}
|
try { news = ((await api('/api/news')).items || []).slice(0, 4) } catch {}
|
||||||
})
|
})
|
||||||
|
|
||||||
async function freeMemory() {
|
async function freeMemory() {
|
||||||
const ok = await confirmModal({
|
if (!await confirmModal({
|
||||||
title: 'Speicher freigeben?',
|
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.',
|
body: 'Alle aktuell geladenen Modelle werden aus dem Grafikspeicher geworfen. Sie laden beim nächsten Aufruf automatisch neu.',
|
||||||
confirmLabel: 'Speicher freigeben',
|
confirmLabel: 'Speicher freigeben',
|
||||||
})
|
})) return
|
||||||
if (!ok) return
|
|
||||||
await api('/api/unload', { method: 'POST' })
|
await api('/api/unload', { method: 'POST' })
|
||||||
document.dispatchEvent(new Event('mc:refresh'))
|
refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function preload(modelName: string) {
|
||||||
|
preloadingModel = modelName
|
||||||
|
try { await api('/api/preload?model=' + encodeURIComponent(modelName), { method: 'POST' }) }
|
||||||
|
catch {}
|
||||||
|
preloadingModel = null
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh() { document.dispatchEvent(new Event('mc:refresh')) }
|
||||||
function go(view: string) {
|
function go(view: string) {
|
||||||
document.querySelector(`.nav-item[data-view="${view}"]`)?.dispatchEvent(new MouseEvent('click'))
|
document.querySelector(`.nav-item[data-view="${view}"]`)?.dispatchEvent(new MouseEvent('click'))
|
||||||
}
|
}
|
||||||
|
|
||||||
function capTagClass(caps: string[]) {
|
function ramColor(pct: number) { return pct >= 90 ? 'red' : pct >= 75 ? '' : 'green' }
|
||||||
if (caps?.includes('Bild')) return 'img'
|
function tempColor(t: number | null) { return t == null ? '' : t >= 75 ? 'red' : t >= 55 ? '' : 'green' }
|
||||||
if (caps?.includes('Code')) return 'code'
|
function gpuColor(pct: number) { return pct >= 90 ? 'red' : pct >= 70 ? '' : 'green' }
|
||||||
return 'text'
|
|
||||||
}
|
|
||||||
function capTagLabel(caps: string[]) {
|
|
||||||
if (caps?.includes('Bild')) return 'Bild'
|
|
||||||
if (caps?.includes('Code')) return 'Code'
|
|
||||||
return 'Text'
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="pagehead">
|
<!-- Hero -->
|
||||||
|
<div class="pagehead" style="margin-bottom:10px">
|
||||||
<div>
|
<div>
|
||||||
<h1>{heroTitle}</h1>
|
<h1 style="display:flex;align-items:center;gap:10px">
|
||||||
|
{heroTitle}
|
||||||
|
{#if swapOk && !activeModel}
|
||||||
|
<span style="font-size:13px;font-weight:400;color:var(--mut)">— bereit für Anfragen</span>
|
||||||
|
{/if}
|
||||||
|
</h1>
|
||||||
<div class="sub">{heroSub}</div>
|
<div class="sub">{heroSub}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- KPI Tiles -->
|
||||||
<div class="tiles">
|
<div class="tiles">
|
||||||
<div class="tile">
|
<div class="kpi {swapOk ? 'green' : 'red'}">
|
||||||
<div class="t-l">LLM-Engine</div>
|
<div class="k-h">
|
||||||
<div class="t-v {swapOk ? 'ok' : 'bad'}">{swapOk ? 'Online' : 'Offline'}</div>
|
<span class="k-t">LLM-Engine</span>
|
||||||
<div class="t-s">llama-swap</div>
|
<span class="k-ic">{@html icon('server')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="tile">
|
<div class="k-v">{swapOk ? 'Online' : 'Offline'}</div>
|
||||||
<div class="t-l">Aktives Modell</div>
|
<div class="k-s">{s?.swap_url?.replace(/^https?:\/\//, '') ?? '—'}</div>
|
||||||
<div class="t-v">{activeModel ? activeModel.name : 'Keins'}</div>
|
|
||||||
<div class="t-s">{activeModel ? 'im Grafikspeicher' : 'nichts geladen'}</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="kpi {activeModel ? 'blue' : 'muted'}">
|
||||||
|
<div class="k-h">
|
||||||
|
<span class="k-t">Aktives Modell</span>
|
||||||
|
<span class="k-ic">{@html icon('cpu')}</span>
|
||||||
|
</div>
|
||||||
|
<div class="k-v" style="font-size:16px;word-break:break-word">{activeModel ? activeModel.name : 'Keins'}</div>
|
||||||
|
<div class="k-s">
|
||||||
|
{#if activeModel}
|
||||||
|
{activeModel.meta?.ctx ? Math.round(activeModel.meta.ctx / 1024) + 'k ctx' : ''} · {activeModel.state}
|
||||||
|
{:else}
|
||||||
|
lädt bei erster Anfrage
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if sys}
|
{#if sys}
|
||||||
{@const t = sys.gpu_temp ?? sys.cpu?.temp ?? null}
|
{@const t = sys.gpu_temp ?? sys.cpu?.temp ?? null}
|
||||||
<div class="tile">
|
{@const tCol = tempColor(t)}
|
||||||
<div class="t-l">GPU-Temperatur</div>
|
<div class="kpi {tCol}">
|
||||||
<div class="t-v">{t != null ? `${Math.round(t)}°` : '–'}</div>
|
<div class="k-h"><span class="k-t">Temperatur (GPU)</span><span class="k-ic">{@html icon('thermo')}</span></div>
|
||||||
<div class="t-s {t != null && t < 72 ? 'ok' : ''}">{tempWord(t)}</div>
|
<div class="k-v">{t != null ? `${Math.round(t)}°C` : '–'}</div>
|
||||||
|
<div class="k-s">{t == null ? 'messe…' : t < 55 ? 'kühl' : t < 75 ? 'normal' : '⚠ heiß'}</div>
|
||||||
</div>
|
</div>
|
||||||
{@const free = sys.ram.total - sys.ram.used}
|
{@const free = sys.ram.total - sys.ram.used}
|
||||||
<div class="tile">
|
{@const freePct = 100 - sys.ram.percent}
|
||||||
<div class="t-l">Freier Speicher</div>
|
<div class="kpi {freePct < 15 ? 'red' : freePct < 30 ? '' : 'green'}">
|
||||||
<div class="t-v">{Math.round(free / 1024 ** 3)}<small> GB</small></div>
|
<div class="k-h"><span class="k-t">Freier Speicher</span><span class="k-ic">{@html icon('database')}</span></div>
|
||||||
<div class="t-s">von {Math.round(sys.ram.total / 1024 ** 3)} GB</div>
|
<div class="k-v">{Math.round(free / 1024 ** 3)}<small> GB</small></div>
|
||||||
|
<div class="k-s">von {Math.round(sys.ram.total / 1024 ** 3)} GB · {Math.round(freePct)} % frei</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{: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="kpi muted"><div class="k-h"><span class="k-t">Temperatur</span></div><div class="k-v">–</div><div class="k-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>
|
<div class="kpi muted"><div class="k-h"><span class="k-t">Freier Speicher</span></div><div class="k-v">–</div><div class="k-s">messe…</div></div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Split: Health + Quickstart -->
|
||||||
<div class="split">
|
<div class="split">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-h"><h3>System-Gesundheit</h3></div>
|
<div class="card-h"><h3>System-Gesundheit</h3></div>
|
||||||
<div class="card-sub">So ausgelastet ist dein Mini-PC gerade.</div>
|
<div class="card-sub">Auslastung in Echtzeit.</div>
|
||||||
{#if sys}
|
{#if sys}
|
||||||
{@const gpu = gpuPct()}
|
{@const gpu = gpuPct()}
|
||||||
{#each [
|
{#each [
|
||||||
['Prozessor (CPU)', sys.cpu?.percent ?? 0],
|
['Prozessor (CPU)', sys.cpu?.percent ?? 0, ''],
|
||||||
['Arbeitsspeicher (RAM)', sys.ram.percent],
|
['Arbeitsspeicher (RAM)', sys.ram.percent, ramColor(sys.ram.percent)],
|
||||||
['Grafikspeicher (VRAM)', gpu]
|
['Grafikspeicher (VRAM)', gpu, gpuColor(gpu)]
|
||||||
] as [label, pct]}
|
] as [label, pct, cls]}
|
||||||
{@const p = Math.max(0, Math.min(100, (pct as number) || 0))}
|
{@const p = Math.max(0, Math.min(100, (pct as number) || 0))}
|
||||||
<div class="meter">
|
<div class="meter">
|
||||||
<div class="meter-h"><span class="mk">{label}</span><span class="mv">{Math.round(p)} %</span></div>
|
<div class="meter-h"><span class="mk">{label}</span><span class="mv">{Math.round(p)} %</span></div>
|
||||||
@@ -134,7 +161,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button class="qa" onclick={freeMemory}>
|
<button class="qa" onclick={freeMemory}>
|
||||||
<span class="qa-ic amber">{@html icon('refresh')}</span>
|
<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-main"><span class="qa-t">Speicher freigeben</span><span class="qa-s">Alle Modelle entladen</span></span>
|
||||||
<span class="qa-arrow">{@html icon('chevron')}</span>
|
<span class="qa-arrow">{@html icon('chevron')}</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="qa" onclick={() => go('server')}>
|
<button class="qa" onclick={() => go('server')}>
|
||||||
@@ -142,38 +169,57 @@
|
|||||||
<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-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>
|
<span class="qa-arrow">{@html icon('chevron')}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="qa" onclick={() => go('connect')}>
|
||||||
|
<span class="qa-ic teal">{@html icon('swap')}</span>
|
||||||
|
<span class="qa-main"><span class="qa-t">Tools verbinden</span><span class="qa-s">Zed, OpenCode, Cline einrichten</span></span>
|
||||||
|
<span class="qa-arrow">{@html icon('chevron')}</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Stack + News -->
|
||||||
<div class="grid grid-2">
|
<div class="grid grid-2">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-h">
|
<div class="card-h">
|
||||||
<h3>Dein Stack</h3>
|
<h3>Dein Stack</h3>
|
||||||
<span class="meta">{allModels.length ? allModels.length + ' konfiguriert' : ''}</span>
|
<span class="meta">{configuredModels.length ? configuredModels.length + ' konfiguriert' : ''}</span>
|
||||||
</div>
|
</div>
|
||||||
{#if allModels.length}
|
{#if configuredModels.length}
|
||||||
<div class="list">
|
<div class="list">
|
||||||
{#each allModels as m}
|
{#each configuredModels as m}
|
||||||
|
{@const isLoaded = LOADED.includes(m.state)}
|
||||||
{@const downloading = m.state === 'downloading'}
|
{@const downloading = m.state === 'downloading'}
|
||||||
{@const on = ['running','ready','loading','starting'].includes(m.state)}
|
{@const dot = (m.state === 'loading' || m.state === 'starting' || downloading) ? 'load' : isLoaded ? 'on' : ''}
|
||||||
{@const dot = (m.state === 'loading' || m.state === 'starting' || downloading) ? 'load' : on ? 'on' : ''}
|
|
||||||
{@const statusText = downloading
|
{@const statusText = downloading
|
||||||
? `↓ Download${m.download_progress != null ? ` ${Math.round(m.download_progress)}%` : ''}`
|
? `↓ ${m.download_progress != null ? Math.round(m.download_progress) + '%' : '...'}`
|
||||||
: on ? (m.state === 'loading' ? 'lädt…' : 'geladen') : 'bereit'}
|
: m.state === 'loading' || m.state === 'starting' ? 'lädt…'
|
||||||
<div class="li">
|
: isLoaded ? 'geladen ✓' : 'bereit'}
|
||||||
|
<div class="li" style="padding:10px 4px">
|
||||||
<span class="li-dot {dot}"></span>
|
<span class="li-dot {dot}"></span>
|
||||||
<div class="li-main">
|
<div class="li-main">
|
||||||
|
<div class="flex items-center gap-2" style="flex-wrap:wrap">
|
||||||
|
<span class="li-id" style="{isLoaded ? 'color:var(--accent)' : ''}">{m.name}</span>
|
||||||
|
{#if m.meta?.caps?.includes('Bild')}<span class="tag img">Bild</span>
|
||||||
|
{:else if m.meta?.caps?.includes('Code')}<span class="tag code">Code</span>
|
||||||
|
{:else}<span class="tag text">Text</span>{/if}
|
||||||
|
{#if m.ttl >= 99999}<span class="chip" style="padding:2px 7px;font-size:11px">∞ aktiv</span>{/if}
|
||||||
|
</div>
|
||||||
|
<div class="li-sub mono-sm">
|
||||||
|
{m.meta?.ctx ? Math.round(m.meta.ctx / 1024) + 'k ctx' : ''}
|
||||||
|
{m.meta?.quant ? ' · ' + m.meta.quant : ''}
|
||||||
|
{m.meta?.size_bytes ? ' · ' + (m.meta.size_bytes / 1024 ** 3).toFixed(1) + ' GB' : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="li-id">{m.name}</span>
|
<span class="li-meta" style="white-space:nowrap;color:{isLoaded ? 'var(--on)' : 'var(--mut)'}">{statusText}</span>
|
||||||
{#if m.meta?.caps?.length}
|
{#if !isLoaded && !downloading && swapOk}
|
||||||
<span class="tag {capTagClass(m.meta.caps)}">{capTagLabel(m.meta.caps)}</span>
|
<button class="ghost" style="padding:3px 9px;font-size:12px;opacity:{preloadingModel === m.name ? '.5' : '1'}"
|
||||||
|
disabled={preloadingModel === m.name}
|
||||||
|
onclick={() => preload(m.name)}>
|
||||||
|
{preloadingModel === m.name ? '…' : 'Laden'}
|
||||||
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -181,6 +227,7 @@
|
|||||||
<div class="empty-c">
|
<div class="empty-c">
|
||||||
<div class="e-t">Noch keine Modelle</div>
|
<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 class="e-s">Hol dir unter „Cookbook" ein Modell, das auf deine Hardware passt.</div>
|
||||||
|
<button class="primary" style="margin-top:12px" onclick={() => go('cookbook')}>Zum Cookbook →</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -197,7 +244,7 @@
|
|||||||
<a class="li" href={n.link} target="_blank" rel="noopener" style="text-decoration:none;color:inherit">
|
<a class="li" href={n.link} target="_blank" rel="noopener" style="text-decoration:none;color:inherit">
|
||||||
<div class="li-main">
|
<div class="li-main">
|
||||||
<div style="font-size:13px;line-height:1.4">{n.title}</div>
|
<div style="font-size:13px;line-height:1.4">{n.title}</div>
|
||||||
<div class="li-sub">{n.source}</div>
|
<div class="li-sub">{n.source}{n.relevant ? ' · für dich' : ''}</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -113,7 +113,8 @@
|
|||||||
let swapCheckOk = $state(false)
|
let swapCheckOk = $state(false)
|
||||||
|
|
||||||
const swapModels = $derived((status?.models || []) as any[])
|
const swapModels = $derived((status?.models || []) as any[])
|
||||||
const loadedModel = $derived(swapModels.find((m: any) => m.state === 'running' || m.state === 'loading'))
|
const LOADED = ['running', 'ready', 'loading', 'starting']
|
||||||
|
const loadedModel = $derived(swapModels.find((m: any) => LOADED.includes(m.state)))
|
||||||
|
|
||||||
// Job progress helpers
|
// Job progress helpers
|
||||||
function jobPct(j: any) {
|
function jobPct(j: any) {
|
||||||
|
|||||||
@@ -342,6 +342,22 @@ def delete_model(req: DeleteReq):
|
|||||||
return {"ok": True, "alias": req.alias, "freed_bytes": freed, "note": note or "Modell entfernt."}
|
return {"ok": True, "alias": req.alias, "freed_bytes": freed, "note": note or "Modell entfernt."}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/preload")
|
||||||
|
def preload(model: str):
|
||||||
|
"""Modell vorwaermen: sendet eine Minimal-Anfrage damit llama-swap es laedt.
|
||||||
|
max_tokens=1 haelt die Antwort kurz; der Token wird verworfen."""
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=60.0) as c:
|
||||||
|
c.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json={
|
||||||
|
"model": model,
|
||||||
|
"messages": [{"role": "user", "content": "!"}],
|
||||||
|
"max_tokens": 1,
|
||||||
|
})
|
||||||
|
return {"ok": True}
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
raise HTTPException(502, f"llama-swap nicht erreichbar: {exc}")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/unload")
|
@router.post("/unload")
|
||||||
def unload(model: str | None = None):
|
def unload(model: str | None = None):
|
||||||
path = f"/api/models/unload/{model}" if model else "/api/models/unload"
|
path = f"/api/models/unload/{model}" if model else "/api/models/unload"
|
||||||
|
|||||||
+10
-2
@@ -91,8 +91,16 @@ a{color:var(--accent);text-decoration:none}
|
|||||||
.dot.on{background:var(--on);box-shadow:0 0 0 0 rgba(63,185,80,.5);animation:pulse 2.2s infinite}
|
.dot.on{background:var(--on);box-shadow:0 0 0 0 rgba(63,185,80,.5);animation:pulse 2.2s infinite}
|
||||||
.dot.off{background:var(--err)}
|
.dot.off{background:var(--err)}
|
||||||
@keyframes pulse{0%{box-shadow:0 0 0 0 rgba(63,185,80,.45)}70%{box-shadow:0 0 0 7px rgba(63,185,80,0)}100%{box-shadow:0 0 0 0 rgba(63,185,80,0)}}
|
@keyframes pulse{0%{box-shadow:0 0 0 0 rgba(63,185,80,.45)}70%{box-shadow:0 0 0 7px rgba(63,185,80,0)}100%{box-shadow:0 0 0 0 rgba(63,185,80,0)}}
|
||||||
.top-stat{font-size:var(--text-sm);color:var(--mut)}
|
.top-stat{font-size:var(--text-sm);color:var(--mut);display:flex;align-items:center;gap:5px}
|
||||||
.top-stat b{color:var(--tx);font-family:var(--mono);font-weight:500;margin-left:4px}
|
.top-stat b{color:var(--accent);font-family:var(--mono);font-weight:600;letter-spacing:.01em}
|
||||||
|
/* Aktives Modell in Topbar — klarer sichtbar wenn geladen */
|
||||||
|
#top-active-text{
|
||||||
|
font-family:var(--mono);font-size:var(--text-sm);
|
||||||
|
padding:4px 10px;border-radius:999px;
|
||||||
|
border:1px solid transparent;transition:border-color .3s,background .3s;
|
||||||
|
}
|
||||||
|
#top-active-text b{font-weight:600}
|
||||||
|
#top-active-text:has(b){border-color:rgba(45,212,191,.25);background:rgba(45,212,191,.07)}
|
||||||
.top-clock{font-family:var(--mono);font-size:var(--text-sm);color:var(--accent)}
|
.top-clock{font-family:var(--mono);font-size:var(--text-sm);color:var(--accent)}
|
||||||
|
|
||||||
/* Security-Chip (zeigt LAN/Token-Status) */
|
/* Security-Chip (zeigt LAN/Token-Status) */
|
||||||
|
|||||||
Vendored
+25
-23
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user