feat(phase-c): Vite + Svelte 5 Infrastruktur + erste 2 Panels migriert

- frontend/ eingerichtet: Vite 6, Svelte 5 (Runes), TypeScript
- Stores: status/jobs/system mit $state-Runes (status.svelte.ts etc.)
- main.ts ersetzt main.js: bundelt Legacy-Panels (overview/models/server/
  jobs/cookbook/connect) + mountet neue Svelte-Panels (guides/news)
- GuidesPanel.svelte: statischer Guide mit Akkordeon-Tutorials + Konzept-Grid
- NewsPanel.svelte: reaktives Newsboard mit API-Fetch + Upgrade-Vorschlaegen
- Vite-Output: static/dist/main.js (committet, kein Build auf dem Server)
- index.html: laedt jetzt static/dist/main.js statt static/js/main.js
- Build: 118 Module → 134 kB (gzip: 44 kB)

Naechste Schritte: Overview → Jobs → Server → Models → Cookbook migrieren.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-22 21:50:12 +02:00
parent ee4e3c1dd4
commit e2fc1d24cf
15 changed files with 2512 additions and 1 deletions
+1
View File
@@ -2,3 +2,4 @@
__pycache__/
*.pyc
.claude/launch.json
frontend/node_modules/
+1449
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
{
"name": "mission-control-frontend",
"type": "module",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"svelte": "^5.28.0"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"typescript": "^5.7.3",
"vite": "^6.3.5"
}
}
+185
View File
@@ -0,0 +1,185 @@
import { mount } from 'svelte'
import { api, getToken, setToken, getHfToken, setHfToken } from '@core/api.js'
import { initNav } from '@core/nav.js'
import { statusStore } from './stores/status.svelte.js'
import { jobsStore } from './stores/jobs.svelte.js'
import GuidesPanel from './panels/GuidesPanel.svelte'
import NewsPanel from './panels/NewsPanel.svelte'
// Legacy panels (bundled via Vite; guides + news sind durch Svelte ersetzt)
import overview from '@panels/overview.js'
import models from '@panels/models.js'
import server from '@panels/server.js'
import jobs from '@panels/jobs.js'
import cookbook from '@panels/cookbook.js'
import connect from '@panels/connect.js'
const legacyPanels: any[] = [overview, models, server, jobs, cookbook, connect]
let prevModelStates: Record<string, string> = {}
// ---- Swap-Flash ----
function flashSwap() {
const el = document.getElementById('top-active-text')
if (el) { el.classList.add('swap-flash'); setTimeout(() => el.classList.remove('swap-flash'), 900) }
}
// ---- Status verteilen ----
function applyStatus(s: any) {
const dot = document.getElementById('swdot')
const label = document.getElementById('swlabel')
const activeText = document.getElementById('top-active-text')
if (!s) {
dot && (dot.className = 'dot off')
label && (label.textContent = 'Backend nicht erreichbar')
activeText && (activeText.textContent = 'Backend offline')
showAlert('Backend nicht erreichbar läuft uvicorn?', false)
} else {
const host = (s.swap_url || '').replace(/^https?:\/\//, '')
dot && (dot.className = 'dot ' + (s.swap_ok ? 'on' : 'off'))
label && (label.textContent = (s.swap_ok ? 'LLM-Engine: Online ' : 'LLM-Engine: Offline ') + host)
const active = (s.models || []).find((m: any) => m.state === 'running')
if (activeText) {
activeText.innerHTML = active
? `Geladen: <b style="color:var(--teal)">${active.name}</b>`
: 'Kein Modell im VRAM'
}
if (s.swap_ok) hideAlert()
else showAlert(`LLM-Engine nicht erreichbar unter <b>${host}</b> läuft der llama-swap Dienst?`, true)
// Swap-Erkennung
const newStates: Record<string, string> = {}
for (const m of (s.models || [])) newStates[m.name] = m.state
for (const [name, state] of Object.entries(newStates)) {
const prev = prevModelStates[name]
if (prev && prev !== state && (state === 'running' || state === 'loading')) { flashSwap(); break }
}
prevModelStates = newStates
}
setSecChip(s)
statusStore.set(s)
for (const p of legacyPanels) p.onStatus?.(s)
}
function setSecChip(s: any) {
const chip = document.getElementById('sec-chip')
const tx = document.getElementById('sec-chip-tx')
if (!chip || !tx) return
chip.classList.toggle('ok', !!s?.secured)
tx.textContent = s?.secured ? 'Geschützt · Heimnetz' : 'Nur im Heimnetz (offen)'
}
function applyJobs(jobs: any[]) {
jobsStore.set(jobs || [])
for (const p of legacyPanels) p.onJobs?.(jobs)
}
function applySystem(sys: any) {
for (const p of legacyPanels) p.onSystem?.(sys)
}
function showAlert(html: string, warn: boolean) {
const a = document.getElementById('alert')!
a.className = 'alert' + (warn ? ' warn' : '')
a.innerHTML = `<span class="a-dot"></span><span>${html}</span>`
a.style.display = 'flex'
}
function hideAlert() {
const a = document.getElementById('alert')
if (a) a.style.display = 'none'
}
// ---- Update-Badges ----
const goView = (v: string) => (document.querySelector(`.nav-item[data-view="${v}"]`) as HTMLElement)?.click()
function mkBadge(text: string, cls: string, view: string, title?: string) {
const s = document.createElement('span')
s.className = 'upd-badge' + (cls ? ' ' + cls : '')
s.textContent = text
if (title) s.title = title
s.onclick = () => goView(view)
return s
}
async function pollUpdates() {
try {
const u = await api('/api/updates')
const el = document.getElementById('update-badges'); if (!el) return
const age = u.apt_cache_age_h as number | null
const stale = age != null && age > 24
const ageNote = age != null ? ` · apt-Cache: vor ${age}h` : ''
el.innerHTML = ''
el.appendChild(mkBadge(
u.os > 0 ? `OS: ${u.os} Updates` : stale ? 'OS: ?' : 'OS: aktuell',
u.os > 0 ? 'warn' : stale ? 'warn' : 'ok',
'server',
u.os > 0 ? `${u.os} ausstehende Pakete${ageNote}` : stale ? `apt-Cache veraltet (${age}h)` : `Keine Updates${ageNote}`
))
el.appendChild(mkBadge(u.engine > 0 ? 'Engine: Update' : 'Engine: aktuell', u.engine > 0 ? 'warn' : 'ok', 'server'))
el.appendChild(mkBadge(u.models > 0 ? `Modelle: ${u.models}` : 'Modelle: aktuell', u.models > 0 ? 'warn' : 'ok', 'news'))
} catch { /* noop */ }
}
// ---- Polling ----
async function pollStatus() {
try { applyStatus(await api('/api/status')) }
catch { applyStatus(null) }
}
async function pollJobs() {
try { applyJobs(await api('/api/jobs')) }
catch { /* noop */ }
}
function connectSystemStream() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const ws = new WebSocket(`${protocol}//${window.location.host}/api/system/stream`)
ws.onmessage = e => { try { applySystem(JSON.parse(e.data)) } catch {} }
ws.onclose = () => { setTimeout(connectSystemStream, 3000) }
ws.onerror = () => ws.close()
}
// ---- Boot ----
// Legacy panels mounten (overview, models, server, jobs, cookbook, connect)
for (const p of legacyPanels) p.mount?.()
// Svelte panels mounten (guides, news)
const guidesEl = document.querySelector('.view[data-view="guides"]')
if (guidesEl) mount(GuidesPanel, { target: guidesEl })
const newsEl = document.querySelector('.view[data-view="news"]')
if (newsEl) mount(NewsPanel, { target: newsEl })
initNav('overview')
// Token / Settings
const tokenInput = document.getElementById('token') as HTMLInputElement | null
if (tokenInput) {
tokenInput.value = getToken()
tokenInput.addEventListener('change', e => { setToken((e.target as HTMLInputElement).value); pollStatus() })
}
const hfInput = document.getElementById('hf-token') as HTMLInputElement | null
if (hfInput) {
hfInput.value = getHfToken()
hfInput.addEventListener('change', e => setHfToken((e.target as HTMLInputElement).value))
}
const sbtn = document.getElementById('nav-settings')
const smod = document.getElementById('settings-modal')
const scls = document.getElementById('sm-close')
if (sbtn && smod && scls) {
sbtn.addEventListener('click', () => (smod!.style.display = 'flex'))
scls.addEventListener('click', () => (smod!.style.display = 'none'))
}
function tickClock() {
const c = document.getElementById('clock')
if (c) c.textContent = new Date().toTimeString().slice(0, 5)
}
document.addEventListener('mc:refresh', pollStatus as EventListener)
pollStatus(); pollJobs(); connectSystemStream()
setInterval(tickClock, 1000)
setInterval(pollStatus, 3000)
setInterval(pollJobs, 1500)
pollUpdates()
setInterval(pollUpdates, 60000)
+30
View File
@@ -0,0 +1,30 @@
<script lang="ts">
import { TUTORIALS, CONCEPTS } from './guides-data.js'
</script>
<div class="pagehead">
<div>
<h1>Guide für Einsteiger</h1>
<div class="sub">Praktische Workflows + die Local-LLM-Welt in Klartext — ohne Vorwissen.</div>
</div>
</div>
<div class="card-h"><h3>Tutorials &amp; Workflows</h3></div>
<div class="card" style="padding:0;overflow:hidden">
{#each TUTORIALS as [title, body], i}
<details class="guide-acc" open={i === 0}>
<summary>{title}</summary>
<div class="acc-body">{@html body}</div>
</details>
{/each}
</div>
<div class="card-h" style="margin-top:8px"><h3>Konzepte kurz erklärt</h3></div>
<div class="concept-grid">
{#each CONCEPTS as [term, text]}
<div class="concept">
<h4>{term}</h4>
<p>{text}</p>
</div>
{/each}
</div>
+114
View File
@@ -0,0 +1,114 @@
<script lang="ts">
import { onMount } from 'svelte'
import { api, getHfToken } from '@core/api.js'
let items = $state<any[]>([])
let upgrades = $state<any[]>([])
let loadingNews = $state(true)
let newsError = $state('')
const fitCls = (l: string) => l === 'perfect' ? 'ok' : l === 'marginal' ? 'warn' : 'bad'
function ago(ts: number) {
const s = Math.max(0, Date.now() / 1000 - ts)
if (s < 60) return s + 's'
if (s < 3600) return Math.floor(s / 60) + 'm'
if (s < 86400) return Math.floor(s / 3600) + 'h'
return Math.floor(s / 86400) + 'd'
}
async function loadNews() {
loadingNews = true; newsError = ''
try {
items = (await api('/api/news')).items || []
} catch (e: any) {
newsError = e.message || 'Fehler beim Laden'
} finally {
loadingNews = false
}
}
async function loadUpgrades() {
try {
upgrades = (await api('/api/cookbook/upgrades')).upgrades || []
} catch {
upgrades = []
}
}
async function installUpgrade(u: any, btn: HTMLButtonElement) {
btn.disabled = true; btn.textContent = 'Starte…'
try {
await api('/api/cookbook/install-model', {
method: 'POST',
body: JSON.stringify({ repo: u.repo, role: u.role, params_b: u.params_b, quant: u.quant, hf_token: getHfToken() })
})
document.querySelector(".nav-item[data-view='activity']")?.dispatchEvent(new MouseEvent('click'))
} catch (e: any) {
btn.disabled = false; btn.textContent = 'Installieren'
alert('Fehler: ' + e.message)
}
}
function refresh() { loadNews(); loadUpgrades() }
onMount(() => { loadNews(); loadUpgrades() })
</script>
<div class="pagehead">
<div>
<h1>News</h1>
<div class="sub">Aktuelles aus der Local-LLM-Welt — aus kuratierten Quellen.</div>
</div>
<button class="ghost" onclick={refresh}>Aktualisieren</button>
</div>
{#if upgrades.length}
<div class="card" style="margin-bottom:18px;border-color:rgba(45,212,191,.25)">
<div class="card-h">
<h3>Für dich: Upgrades</h3>
<span class="meta">{upgrades.length}</span>
</div>
<div class="card-sub">Neuere Modelle, die ein installiertes ersetzen können.</div>
<div class="list">
{#each upgrades as u}
<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" style="font-family:var(--sans);font-weight:500">{u.name}</span>
<span class="fit-badge {fitCls(u.fit.level)}">{u.fit.text}</span>
</div>
<div class="li-sub">Ersetzt dein „{u.old}". {u.why}</div>
</div>
<button class="primary" onclick={(e) => installUpgrade(u, e.currentTarget as HTMLButtonElement)}>
Installieren
</button>
</div>
{/each}
</div>
</div>
{/if}
{#if loadingNews}
<div class="empty" style="text-align:center;padding:40px">Lade News… (erstes Laden kann kurz dauern)</div>
{:else if newsError}
<div class="alert err" style="margin:0">{newsError}</div>
{:else if !items.length}
<div class="empty-c">
<div class="e-t">Keine News geladen</div>
<div class="e-s">Die Quellen sind gerade nicht erreichbar — später nochmal probieren.</div>
</div>
{:else}
<div class="news-grid">
{#each items as n, i}
<a href={n.link} target="_blank" rel="noopener" class="news-card{i === 0 ? ' featured' : ''}">
<div class="news-meta">
<span class="news-src">{n.source}</span>
{#if n.relevant}<span class="news-rel">für dich</span>{/if}
<span class="news-date">{n.ts ? ago(n.ts) : ''}</span>
</div>
<div class="news-title">{n.title}</div>
</a>
{/each}
</div>
{/if}
+86
View File
@@ -0,0 +1,86 @@
export const TUTORIALS: [string, string][] = [
["Dein erster Workflow — in 3 Schritten", `
<ol class="tut-steps">
<li><b>Modell holen:</b> Tab <b>Cookbook</b> → wähle deinen Anwendungsfall (z.B. „Coden") →
<b>Komplettes Setup installieren</b>. Der Download läuft im Tab <b>Aktivität</b>.</li>
<li><b>Verbinden:</b> Tab <b>Verbinden</b> → Basis-URL kopieren → in deinem Tool (z.B. OpenCode)
als „OpenAI-kompatibel" einfügen. <b>Verbindung testen</b> bestätigt, dass es läuft.</li>
<li><b>Arbeiten:</b> Im Tool das Modell (z.B. <code>coder</code>) wählen und loslegen. Das passende
Modell wird <b>automatisch geladen</b> — du musst nichts starten.</li>
</ol>`],
["Wie leere ich den Kontext? Machen die Tools das selbst?", `
<p><b>Kurz: meistens ja, automatisch.</b> Der Kontext (das „Gedächtnis") gilt pro Unterhaltung.
Startest du im Tool einen <b>neuen Chat / eine neue Session</b>, ist der Kontext wieder leer —
das Tool verwaltet das Fenster für dich.</p>
<p>Du musst nur selten eingreifen: wird ein Gespräch sehr lang, fang einen neuen an. <b>Wichtig:</b>
„Grafikspeicher leeren" (Tab Server) ist etwas anderes — das wirft das <i>Modell</i> aus dem Speicher,
nicht den Gesprächsverlauf.</p>`],
["Was passiert, wenn der Kontext volläuft?", `
<p>Ist das Kontextfenster voll, passiert je nach Tool eins von dreien: das Modell <b>vergisst den
Anfang</b> (älteste Nachrichten fallen raus), das Tool <b>kürzt automatisch</b>, oder du bekommst
einen Fehler wie „context length exceeded".</p>
<p><b>So gehst du vor:</b></p>
<ol class="tut-steps">
<li><b>Neuen Chat starten</b> — der einfachste Reset, Kontext ist wieder leer.</li>
<li><b>Zusammenfassen lassen:</b> „Fasse unseren Stand kurz zusammen", dann im neuen Chat einfügen.</li>
<li><b>Kontext vergrößern:</b> Tab <b>Modelle</b> → <b>Konfigurieren</b> → wir zeigen das Optimum
für deine Hardware (mehr Kontext = mehr Speicherbedarf).</li>
</ol>`],
["Was ist ein Agent — und wann brauche ich einen?", `
<p>Ein nacktes Modell <b>schreibt nur Text zurück</b>. Ein <b>Agent</b> ist ein Programm drumherum,
das das Modell <b>handeln</b> lässt: Dateien lesen und ändern, Befehle ausführen, im Web suchen —
Schritt für Schritt in einer Schleife, bis die Aufgabe erledigt ist.</p>
<p><b>Wann lohnt sich was?</b></p>
<ul class="tut-steps" style="list-style:disc">
<li><b>Fragen, Texte, Erklärungen, Brainstorming</b> → kein Agent nötig, eine Chat-Oberfläche
wie <b>OpenWebUI</b> reicht völlig.</li>
<li><b>Coden über mehrere Dateien, Befehle ausführen, recherchieren + umsetzen</b>
→ <b>Agent</b> (OpenCode, Cline, Continue).</li>
</ul>
<p><b>Gut zu wissen:</b> Diese Agenten <b>laufen lokal auf deinem PC</b> — du brauchst
<b>keinen</b> Cloud-Dienst.</p>`],
["Was sind Skills — und wie installiere ich sie?", `
<p>Ein <b>Skill</b> ist <b>verpacktes Können</b>: eine kurze Anleitung, die dem Agenten beibringt,
eine wiederkehrende Aufgabe jedes Mal so zu erledigen, wie <i>du</i> es willst.</p>
<p><b>Skill vs. MCP:</b> <b>MCP</b> gibt dem Agenten neue <b>Werkzeuge</b>. <b>Skills</b> geben
ihm <b>Know-how</b>. Kurz: <b>Werkzeuge = MCP, Anleitungen = Skills.</b></p>
<p><b>Installieren:</b> meist nur eine Datei ablegen — kein Build, kein Neustart:</p>
<ol class="tut-steps">
<li><b>Pro Projekt:</b> in den Projektordner (z.B. <code>AGENTS.md</code>, <code>.clinerules/</code>).</li>
<li><b>Global:</b> in den Konfig-Ordner des Tools — gilt überall.</li>
</ol>`],
["MCP — deinem Agenten echte Werkzeuge geben", `
<p><b>MCP</b> (Model Context Protocol) ist ein einheitlicher „Stecker", über den ein Agent zusätzliche
<b>Werkzeuge</b> bekommt: das Web durchsuchen, Dateien lesen, eine Datenbank abfragen.</p>
<p>Du fügst in deinem Agenten einen „MCP-Server" hinzu (eine Zeile in der Konfiguration je Werkzeug).
Danach entscheidet der Agent selbst, wann er das Werkzeug einsetzt.</p>
<ul class="tut-steps" style="list-style:disc">
<li><b>Faustregel:</b> Fehlt dem Agenten eine <b>Fähigkeit</b> → MCP. Soll er etwas nur
<b>besser machen</b> → Skill.</li>
</ul>`],
["Auto-Swap — wie das Laden funktioniert", `
<p>llama-swap lädt automatisch das Modell, das gerade angefragt wird, und entlädt es nach einer
Leerlaufzeit wieder. <b>Es läuft immer nur eines gleichzeitig.</b></p>
<p>Du richtest beliebig viele Modelle ein und wählst im Tool das passende —
das Laden/Entladen passiert im Hintergrund, ohne dass du etwas tun musst.</p>`],
["Die empfohlenen Tools optimal nutzen", `
<ul class="tut-steps" style="list-style:disc">
<li><b>OpenCode:</b> Autonomes Coden im Terminal/Editor — ideal für „bau mir Feature X".</li>
<li><b>Cline:</b> Dasselbe als VS-Code-Erweiterung, mit Bestätigung vor jeder Aktion.</li>
<li><b>OpenWebUI:</b> Klassische Chat-Oberfläche für Fragen/Texte.</li>
</ul>
<p><b>Tipp:</b> starkes Modell (<code>coder</code>) für schwere Aufgaben, schnelles
(<code>scout</code>) für einfache — du wählst es einfach im Tool aus.</p>`],
]
export const CONCEPTS: [string, string][] = [
["LLM / Modell", "Die KI selbst — versteht und schreibt Text (wie ChatGPT, nur lokal auf deinem PC). Ein Modell ist eine konkrete Datei davon, oft auf etwas spezialisiert (Code, Bilder, Chat)."],
["GGUF", "Das lokal lauffähige Dateiformat. Unsere Engine lädt nur GGUF — Repos ohne GGUF kann sie nicht nutzen."],
["Quantisierung (Q4_K_M)", "Verkleinert ein Modell (16-Bit → 4-Bit), damit es in den Speicher passt — bei kaum Qualitätsverlust. Q4_K_M ist der beliebte Sweetspot."],
["Kontextfenster", "Das Kurzzeitgedächtnis: wie viel Text sich das Modell gleichzeitig merkt. Größer = mehr Überblick, mehr Speicher, etwas langsamer."],
["Auto-Swap", "llama-swap lädt/entlädt Modelle automatisch — immer nur eins gleichzeitig. Du managst nichts."],
["Agent", "Ein Programm (OpenCode, Cline, Continue), das das Modell handeln lässt — Dateien lesen/ändern, Befehle ausführen, planen, im Loop bis die Aufgabe erledigt ist."],
["MCP", "Model Context Protocol — der Anschluss, über den ein Agent zusätzliche Werkzeuge bekommt (Web, Dateien, Datenbank). MCP = Werkzeuge."],
["Skills", "Verpackte Anleitung für wiederkehrende Aufgaben — der Agent liest sie und macht es jedes Mal wie du willst. Skills = Anleitungen."],
["Rules / Commands", "Die konkrete Form von Skills je Tool: AGENTS.md (OpenCode), .clinerules (Cline), Rules/Prompts (Continue)."],
]
+6
View File
@@ -0,0 +1,6 @@
let _value = $state<any[]>([])
export const jobsStore = {
get value() { return _value },
set(jobs: any[]) { _value = jobs || [] },
}
+6
View File
@@ -0,0 +1,6 @@
let _value = $state<any>(null)
export const statusStore = {
get value() { return _value },
set(s: any) { _value = s },
}
+6
View File
@@ -0,0 +1,6 @@
let _value = $state<any>(null)
export const systemStore = {
get value() { return _value },
set(sys: any) { _value = sys },
}
+8
View File
@@ -0,0 +1,8 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
export default {
preprocess: vitePreprocess(),
compilerOptions: {
runes: true,
},
}
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"strict": false,
"noEmit": true,
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"allowJs": true,
"checkJs": false
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
+28
View File
@@ -0,0 +1,28 @@
import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
import { resolve } from 'path'
export default defineConfig({
plugins: [svelte()],
base: '/static/dist/',
build: {
outDir: resolve(__dirname, '../static/dist'),
emptyOutDir: true,
rollupOptions: {
input: resolve(__dirname, 'src/main.ts'),
output: {
entryFileNames: 'main.js',
chunkFileNames: '[name].js',
assetFileNames: '[name][extname]',
// Alles in eine Datei — kein dynamisches Chunking für einfaches Deploy
manualChunks: undefined,
},
},
},
resolve: {
alias: {
'@core': resolve(__dirname, '../static/js/core'),
'@panels': resolve(__dirname, '../static/js/panels'),
},
},
})
+557
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -121,6 +121,6 @@
document.getElementById("logo").innerHTML = ICON.logo;
document.querySelectorAll("[data-ic]").forEach(n => (n.innerHTML = ICON[n.dataset.ic] || ""));
</script>
<script type="module" src="/static/js/main.js"></script>
<script type="module" src="/static/dist/main.js"></script>
</body>
</html>