This commit is contained in:
team3
2026-07-10 15:43:11 +02:00
commit 0a41166cca
72 changed files with 7772 additions and 0 deletions

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>creator2</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1211
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
frontend/package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "creator2-frontend",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@fontsource-variable/inter": "^5.2.8",
"dompurify": "^3.4.7",
"highlight.js": "^11.11.1",
"katex": "^0.17.0",
"marked": "^18.0.4",
"marked-highlight": "^2.2.4",
"vue": "^3.5.32"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.6",
"vite": "^8.0.8"
}
}

185
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,185 @@
<script setup>
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { api, wsVerbinden } from './api.js'
import Board from './components/Board.vue'
import Guide from './components/Guide.vue'
import Ueben from './components/Ueben.vue'
import Kennzahlen from './components/Kennzahlen.vue'
const topics = ref([])
const topic = ref('')
const state = ref(null)
const tab = ref('board')
const neuName = ref('')
const neuArt = ref('thema')
const fehler = ref('')
const theme = ref(localStorage.getItem('theme') || 'auto')
const navAuf = ref(localStorage.getItem('navAuf') !== '0')
function navToggle() {
navAuf.value = !navAuf.value
localStorage.setItem('navAuf', navAuf.value ? '1' : '0')
}
watch(navAuf, (a) => document.body.classList.toggle('nav-zu', !a), { immediate: true })
let wsSchliessen = null
let timer = null
const media = window.matchMedia('(prefers-color-scheme: light)')
function themeAnwenden() {
const hell = theme.value === 'hell' || (theme.value === 'auto' && media.matches)
document.documentElement.dataset.theme =
theme.value === 'papier' ? 'sepia' : hell ? 'light' : 'dark'
localStorage.setItem('theme', theme.value)
}
watch(theme, themeAnwenden)
media.addEventListener('change', themeAnwenden)
async function topicsLaden() {
topics.value = await api.topics()
if (!topic.value && topics.value.length) topic.value = topics.value[0].name
}
async function stateLaden() {
if (!topic.value) { state.value = null; return }
try {
state.value = await api.state(topic.value)
} catch (e) { fehler.value = String(e) }
}
function onWs() {
clearTimeout(timer)
timer = setTimeout(stateLaden, 400)
}
async function anlegen() {
if (!neuName.value.trim()) return
try {
await api.topicAnlegen({ name: neuName.value.trim(), art: neuArt.value })
const name = neuName.value.trim()
neuName.value = ''
await topicsLaden()
topic.value = name
await stateLaden()
} catch (e) { fehler.value = String(e) }
}
const menue = ref(null) // Kontextmenü: { x, y, name }
function menueOeffnen(e, name) {
menue.value = { x: e.clientX, y: e.clientY, name }
}
async function menueLoeschen() {
const name = menue.value?.name
menue.value = null
if (!name) return
try {
await api.topicLoeschen(name)
if (topic.value === name) topic.value = ''
await topicsLaden()
await stateLaden()
} catch (e) { fehler.value = String(e) }
}
async function starten() {
try { fehler.value = ''; await api.start(topic.value); await stateLaden() }
catch (e) { fehler.value = String(e) }
}
async function stoppen() {
await api.stop(topic.value)
await stateLaden()
}
async function waehlen(name) {
topic.value = name
await stateLaden()
}
let poll = null // Fallback, falls während langer LLM-Calls keine WS-Deltas kommen
onMounted(async () => {
themeAnwenden()
await topicsLaden()
await stateLaden()
wsSchliessen = wsVerbinden(onWs)
poll = setInterval(() => {
if (state.value?.run?.status === 'running') stateLaden()
}, 5000)
})
onUnmounted(() => {
wsSchliessen && wsSchliessen()
clearInterval(poll)
media.removeEventListener('change', themeAnwenden)
})
const tok = (n) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : `${Math.round(n / 1000)}k`)
function runBadge(run) {
if (!run) return null
const farbe = { running: 'blau', done: 'gruen', paused: 'gelb', budget: 'gelb',
failed: 'rot', stopped: 'rot' }[run.status] || ''
return { farbe, text: run.status + (run.ebene && run.status === 'running' ? ` · ${run.ebene}` : '') }
}
</script>
<template>
<aside class="sidebar">
<div class="topics">
<div v-for="t in topics" :key="t.name" class="topic-eintrag"
:class="{ aktiv: t.name === topic }" @click="waehlen(t.name)"
@contextmenu.prevent="menueOeffnen($event, t.name)">
<span>{{ t.titel }}</span>
<span v-if="t.run?.status === 'running'" class="badge blau">läuft</span>
<span v-else-if="t.status === 'fertig'" class="badge gruen"></span>
</div>
</div>
<div class="neu">
<input v-model="neuName" placeholder="neues Thema…" @keyup.enter="anlegen" />
<div style="display: flex; gap: 6px">
<select v-model="neuArt" style="flex: 1">
<option value="thema">thema</option><option value="uni">uni</option>
</select>
<button class="sekundaer" @click="anlegen">Anlegen</button>
</div>
</div>
</aside>
<div v-if="menue" style="position: fixed; inset: 0; z-index: 99"
@click="menue = null" @contextmenu.prevent="menue = null">
<div class="kontextmenue" :style="{ left: menue.x + 'px', top: menue.y + 'px' }">
<button class="rot" @click.stop="menueLoeschen">Thema {{ menue.name }}" löschen</button>
</div>
</div>
<div class="hauptbereich">
<button class="nav-toggle" :title="navAuf ? 'Navigation einklappen' : 'Navigation anzeigen'"
@click="navToggle"></button>
<div class="topbar">
<div class="tabs">
<button v-for="t in ['board', 'guide', 'üben', 'kennzahlen']" :key="t"
:class="{ aktiv: tab === t }" @click="tab = t">{{ t }}</button>
</div>
<button @click="starten" :disabled="!topic || state?.run?.status === 'running'">Start</button>
<button class="sekundaer" @click="stoppen" :disabled="state?.run?.status !== 'running'">Stop</button>
<span v-if="state?.run" class="badge" :class="runBadge(state.run).farbe">{{ runBadge(state.run).text }}</span>
<span v-if="state" class="badge">{{ tok(state.verbraucht) }} Tokens</span>
<span v-if="state?.run?.grund" class="badge gelb">{{ state.run.grund }}</span>
<span v-if="fehler" class="badge rot">{{ fehler }}</span>
<span style="flex: 1"></span>
<select v-model="theme" title="Farbschema">
<option value="hell">hell</option>
<option value="dunkel">dunkel</option>
<option value="papier">papier</option>
<option value="auto">auto</option>
</select>
</div>
<div class="inhalt">
<Board v-if="tab === 'board'" :state="state" :topic="topic" @reload="stateLaden" />
<Guide v-else-if="tab === 'guide'" :topic="topic" :state="state" />
<Ueben v-else-if="tab === 'üben'" :topic="topic" />
<Kennzahlen v-else :run="state?.run" />
</div>
</div>
</template>

44
frontend/src/api.js Normal file
View File

@@ -0,0 +1,44 @@
const BASE = import.meta.env.DEV ? 'http://localhost:8000' : ''
async function req(pfad, opts = {}) {
const res = await fetch(BASE + pfad, {
headers: { 'Content-Type': 'application/json' },
...opts,
})
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`)
return res.json()
}
export const api = {
topics: () => req('/api/topics'),
topicAnlegen: (daten) => req('/api/topics', { method: 'POST', body: JSON.stringify(daten) }),
start: (topic, budget) =>
req(`/api/topics/${topic}/start`, { method: 'POST', body: JSON.stringify({ budget }) }),
stop: (topic) => req(`/api/topics/${topic}/stop`, { method: 'POST', body: '{}' }),
sollReset: (topic) => req(`/api/topics/${topic}/soll-reset`, { method: 'POST', body: '{}' }),
vollReset: (topic) => req(`/api/topics/${topic}/voll-reset`, { method: 'POST', body: '{}' }),
setAuto: (topic, ebene, an) =>
req(`/api/topics/${topic}/auto`, { method: 'PATCH', body: JSON.stringify({ ebene, an }) }),
ebeneEntfernen: (topic, ebene) =>
req(`/api/topics/${topic}/ebene/${ebene}/entfernen`, { method: 'POST', body: '{}' }),
topicLoeschen: (topic) => req(`/api/topics/${topic}`, { method: 'DELETE' }),
state: (topic) => req(`/api/topics/${topic}/state`),
guide: (topic) => req(`/api/topics/${topic}/guide`),
ueben: (topic) => req(`/api/topics/${topic}/ueben`),
antwort: (artefakt_id, richtig) =>
req('/api/ueben/antwort', { method: 'POST', body: JSON.stringify({ artefakt_id, richtig }) }),
kennzahlen: (runId) => req(`/api/runs/${runId}/kennzahlen`),
}
export function wsVerbinden(onEvent) {
const url = (import.meta.env.DEV ? 'ws://localhost:8000' : `ws://${location.host}`) + '/ws'
let ws
const verbinden = () => {
ws = new WebSocket(url)
ws.onopen = () => onEvent({ typ: 'verbunden' }) // nach Reconnect: Stand neu laden
ws.onmessage = (e) => onEvent(JSON.parse(e.data))
ws.onclose = () => setTimeout(verbinden, 2000)
}
verbinden()
return () => ws && ws.close()
}

View File

@@ -0,0 +1,177 @@
<script setup>
// Ebenen-Kanban: 5 Spalten = Pipeline links→rechts, jede Spalte selbst steuerbar.
// Befunde + laufende Agenten als Leiste ÜBER dem Board.
import { computed, onUnmounted, ref, watch } from 'vue'
import { api } from '../api.js'
const props = defineProps({ state: Object, topic: String })
const emit = defineEmits(['reload'])
// Agenten-Zeit lokal weiterticken: der Server liefert laufzeit nur mit dem
// State-Snapshot — zwischen Deltas (lange LLM-Calls) fröre die Anzeige ein.
const jetzt = ref(Date.now())
const empfangen = ref(Date.now())
const ebenenStart = ref({}) // Ebene → lokaler Start-Anker (epoch ms), monoton
watch(() => props.state, () => {
empfangen.value = Date.now()
const neu = {}
for (const e of props.state?.ebenen || []) {
if (!e.laeuft) continue
// Server-Dauer ist eine Untergrenze (Event-zu-Event); frühester Anker gewinnt,
// damit die Anzeige beim nächsten State-Load nicht auf 0 zurückspringt
const ausServer = Date.now() - (e.dauer_s || 0) * 1000
const bisher = ebenenStart.value[e.name]
neu[e.name] = bisher ? Math.min(bisher, ausServer) : ausServer
}
ebenenStart.value = neu
})
const ticker = setInterval(() => { jetzt.value = Date.now() }, 1000)
onUnmounted(() => clearInterval(ticker))
const extraSek = computed(() => Math.max(0, (jetzt.value - empfangen.value) / 1000))
function ebenenDauer(key) {
const i = info(key)
if (i.laeuft && ebenenStart.value[key])
return Math.max(i.dauer_s || 0, Math.round((jetzt.value - ebenenStart.value[key]) / 1000))
return i.dauer_s || 0
}
const EBENEN = [
{ key: 'korpus', titel: 'Quellen' },
{ key: 'inventar', titel: 'Atome' },
{ key: 'artefakte', titel: 'Artefakte' },
{ key: 'struktur', titel: 'Bausteine' },
{ key: 'guide', titel: 'Guide' },
]
const statusFarbe = (s) => ({
bestaetigt: 'gruen', verifiziert: 'gruen', fertig: 'gruen', done: 'gruen', aktiv: 'gruen',
extrahiert: 'blau', atome: 'blau', kandidat: 'gelb', abgedeckt: 'gelb',
repair: 'gelb', ohne_anker: 'rot', verworfen: 'rot',
}[s] || '')
const info = (key) => props.state?.ebenen?.find((e) => e.name === key) || {}
const aktiveAtome = computed(() =>
(props.state?.atome || []).filter((a) => !['gemerged', 'verworfen'].includes(a.status)))
function karten(key) {
const s = props.state
if (!s) return []
if (key === 'korpus') {
return [
...s.quellen.map((q) => ({ id: `q${q.id}`, text: q.titel,
badges: [[q.status, statusFarbe(q.status)], [q.rolle, q.rolle === 'aufgaben' ? 'gelb' : 'blau']] })),
...s.soll.map((p) => ({ id: `s${p.id}`, text: p.punkt,
badges: [[p.status, statusFarbe(p.status)]] })),
]
}
if (key === 'inventar') {
return aktiveAtome.value.map((a) => ({ id: a.id, text: a.titel,
badges: [[a.status, statusFarbe(a.status)], [`${a.typ} · ${a.level}`, '']] }))
}
if (key === 'artefakte') {
return aktiveAtome.value.map((a) => ({ id: a.id, text: a.titel,
badges: [[`${a.artefakte.verifiziert}/${a.artefakte.gesamt} verifiziert`,
a.artefakte.verifiziert > 0 ? 'gruen' : '']] }))
}
if (key === 'struktur') {
return s.bausteine.map((b) => ({ id: b.id, text: `${b.ord + 1}. ${b.titel}`,
badges: [[b.status, statusFarbe(b.status)]] }))
}
return s.bausteine.map((b) => ({ id: b.id, text: `${b.ord + 1}. ${b.titel}`,
badges: [[b.stage || 'offen', b.stage === 'done' ? 'gruen' : 'blau']] }))
}
function generierenErlaubt(idx) {
if (!props.state || props.state.run?.status === 'running') return false
const e = info(EBENEN[idx].key)
if (e.fertig) return false
return EBENEN.slice(0, idx).every((v) => info(v.key).fertig)
}
const menue = ref(null) // Spalten-Kontextmenü: { x, y, key, titel, idx }
function menueOeffnen(ev, e, idx) {
menue.value = { x: ev.clientX, y: ev.clientY, key: e.key, titel: e.titel, idx }
}
async function menueGenerieren() {
const m = menue.value
menue.value = null
if (!m || !generierenErlaubt(m.idx)) return
await api.start(props.topic)
emit('reload')
}
async function menueEntfernen() {
const m = menue.value
menue.value = null
if (!m) return
await api.ebeneEntfernen(props.topic, m.key)
emit('reload')
}
async function autoSetzen(key, ev) {
await api.setAuto(props.topic, key, ev.target.checked)
emit('reload')
}
const dauer = (s) => (s >= 3600 ? `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`
: s >= 60 ? `${Math.floor(s / 60)}m ${s % 60}s` : `${s}s`)
const mmss = (s) => `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(Math.floor(s % 60)).padStart(2, '0')}`
const k = (n) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : n)
</script>
<template>
<template v-if="state">
<div v-if="state.agenten.length" class="leiste">
<strong style="font-size: 12px; color: var(--dim); white-space: nowrap">
Agenten ({{ state.agenten.length }})
</strong>
<span v-for="a in state.agenten" :key="a.key" class="badge blau"
:title="a.key">{{ mmss(a.laufzeit + extraSek) }}</span>
</div>
<div class="board">
<div v-for="(e, idx) in EBENEN" :key="e.key" class="spalte"
@contextmenu.prevent="menueOeffnen($event, e, idx)">
<div class="spaltekopf">
<div class="zeile">
<span class="titel">{{ e.titel }}
<span class="badge">{{ karten(e.key).length }}</span></span>
<span class="badge" :class="info(e.key).laeuft ? 'blau' : info(e.key).fertig ? 'gruen' : ''">
{{ info(e.key).laeuft ? 'läuft' : info(e.key).fertig ? 'fertig' : 'offen' }}
</span>
</div>
<div class="zeile">
<span class="badge">{{ k(info(e.key).tokens || 0) }} Tok</span>
<span class="badge">{{ dauer(ebenenDauer(e.key)) }}</span>
<label><input type="checkbox" :checked="info(e.key).auto"
@change="autoSetzen(e.key, $event)" /> Auto</label>
</div>
</div>
<div class="liste">
<div v-for="kt in karten(e.key)" :key="kt.id" class="karte">
{{ kt.text }}
<div class="meta">
<span v-for="([txt, farbe], i) in kt.badges" :key="i"
class="badge" :class="farbe">{{ txt }}</span>
</div>
</div>
</div>
</div>
</div>
<div v-if="menue" style="position: fixed; inset: 0; z-index: 99"
@click="menue = null" @contextmenu.prevent="menue = null">
<div class="kontextmenue" :style="{ left: menue.x + 'px', top: menue.y + 'px' }">
<button :disabled="!generierenErlaubt(menue.idx)" @click.stop="menueGenerieren">
Generieren
</button>
<button class="rot" @click.stop="menueEntfernen">
{{ menue.titel }}" + folgende Ebenen entfernen
</button>
</div>
</div>
</template>
<div v-else class="seite">Kein Thema gewählt links anlegen.</div>
</template>

View File

@@ -0,0 +1,159 @@
<script setup>
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
import { api } from '../api.js'
import { render, gefiltert, lesestat, renderGestuft } from '../markdown.js'
const props = defineProps({ topic: String, state: Object })
const daten = ref(null)
const level = ref('E') // Default: Einstieg — Umfang wächst per Toggle (E→M→S)
const ansicht = ref('erklaerend') // erklaerend = Fließtext | kompakt = Stichpunkte
const LEVEL_ICON = { E: '●○○', M: '●●○', S: '●●●' }
const LEVEL_NAME = { E: 'Umfang: nur Einstieg', M: 'Umfang: bis Mittel', S: 'Umfang: alles' }
const fokus = ref(false) // Fokus: Vollbild — App-Chrome + Navigation weg, Karten aufgelöst
watch(fokus, (f) => document.body.classList.toggle('vollbild', f))
onUnmounted(() => document.body.classList.remove('vollbild'))
const aktiv = ref(0)
const kapEls = []
async function laden() {
if (!props.topic) return
daten.value = await api.guide(props.topic)
}
watch(() => props.topic, laden, { immediate: true })
watch(() => props.state?.run?.status, laden) // nach Lauf-Ende neu ziehen
// Markdown+KaTeX sind teuer (66 Sections, ~3.400 Formeln): einmal rendern,
// dann aus dem Cache — sonst rechnet jeder Scroll-Tick alles neu (~600 ms).
const htmlCache = new Map()
watch(daten, () => htmlCache.clear())
function html(s) {
const key = `${ansicht.value}|${level.value}|${s.baustein}`
if (!htmlCache.has(key)) {
htmlCache.set(key, ansicht.value === 'kompakt'
? render(s.kompakt || s.lang) : renderGestuft(s.lang, level.value))
}
return htmlCache.get(key)
}
// Nur aktives Kapitel ±1 im DOM; der Rest ist ein Platzhalter mit Schätzhöhe
const sichtbar = (i) => Math.abs(i - aktiv.value) <= 1
function hoehe(i) {
const st = stats.value.je[i]
const sections = daten.value?.kapitel?.[i]?.sections?.length || 1
return Math.round((st?.woerter || 200) / 11 * 28 + sections * 180)
}
function setAnsicht(a) {
ansicht.value = a
laden()
}
function levelToggle() {
const folge = ['E', 'M', 'S']
level.value = folge[(folge.indexOf(level.value) + 1) % 3]
laden()
}
// Lesezeit des SICHTBAREN Umfangs: 150 Wörter/min (technischer Text)
// + 5 s je Display-Formel (Medium ~265 wpm gilt nicht für Mathe)
const stats = computed(() => {
const je = (daten.value?.kapitel || []).map((kap) => {
let woerter = ansicht.value === 'kompakt' ? 0 : lesestat(kap.intro).woerter
let formeln = 0
for (const s of kap.sections) {
const st = lesestat(ansicht.value === 'kompakt' ? (s.kompakt || s.lang)
: gefiltert(s.lang, level.value))
woerter += st.woerter
formeln += st.displayFormeln
}
return { woerter, minuten: woerter / 150 + (formeln * 5) / 60 }
})
return {
je,
woerter: je.reduce((a, k) => a + k.woerter, 0),
minuten: je.reduce((a, k) => a + k.minuten, 0),
}
})
function zeit(min) {
const m = Math.max(1, Math.round(min))
return m < 60 ? `~${m} min` : `~${Math.floor(m / 60)} h ${m % 60} min`
}
function setKapEl(i, el) {
if (el) kapEls[i] = el
}
async function springe(i) {
aktiv.value = i // erst rendern (Platzhalter → Inhalt), dann springen
await nextTick()
kapEls[i]?.scrollIntoView({ block: 'start' })
}
function onScroll(e) {
const top = e.target.scrollTop + 130
let a = 0
kapEls.forEach((el, i) => { if (el && el.offsetTop <= top) a = i })
aktiv.value = a
}
</script>
<template>
<div class="guide-layout" :class="{ fokus }">
<nav v-if="daten?.kapitel?.length" class="guide-nav">
<div class="schalter-zeile">
<div class="schalter">
<button :class="{ aktiv: ansicht === 'erklaerend' }" title="ausführlich (viel Text)"
@click="setAnsicht('erklaerend')">
<svg width="14" height="12" viewBox="0 0 14 12">
<path d="M0 1h14M0 3.5h14M0 6h14M0 8.5h14M0 11h9"
stroke="currentColor" stroke-width="1.4" fill="none" />
</svg>
</button>
<button :class="{ aktiv: ansicht === 'kompakt' }" title="kompakt (wenig Text)"
@click="setAnsicht('kompakt')">
<svg width="14" height="12" viewBox="0 0 14 12">
<path d="M0 2.5h14M0 6h8" stroke="currentColor" stroke-width="1.4" fill="none" />
</svg>
</button>
</div>
<button class="schalter-solo" :title="LEVEL_NAME[level]" @click="levelToggle">
{{ LEVEL_ICON[level] }}
</button>
<button class="schalter-solo" title="Fokus-Modus (ablenkungsfrei lesen)"
@click="fokus = true"></button>
</div>
<div class="fortschritt">
<div>Kapitel {{ aktiv + 1 }} / {{ daten.kapitel.length }}</div>
<div>{{ stats.woerter.toLocaleString('de-DE') }} Wörter · {{ zeit(stats.minuten) }}</div>
</div>
<a v-for="(kap, i) in daten.kapitel" :key="i" :class="{ aktiv: i === aktiv }"
@click="springe(i)">
<span>{{ i + 1 }}. {{ kap.titel }}</span>
<span class="zeit">{{ zeit(stats.je[i]?.minuten || 0) }}</span>
</a>
</nav>
<div class="seite guide-text" @scroll="onScroll">
<button v-if="fokus" class="fokus-aus" title="Fokus beenden"
@click="fokus = false"></button>
<template v-if="daten">
<div v-for="(kap, i) in daten.kapitel" :key="i" class="kapitel"
:ref="(el) => setKapEl(i, el)">
<h2 class="kapitel-titel">{{ i + 1 }}. {{ kap.titel }}</h2>
<template v-if="sichtbar(i)">
<p v-if="kap.intro && ansicht === 'erklaerend'"
style="color: var(--dim); margin-top: 0">{{ kap.intro }}</p>
<div v-for="s in kap.sections" :key="s.baustein" class="guide-section">
<h3 style="margin-top: 0">{{ s.titel }}</h3>
<div :class="ansicht === 'kompakt' ? 'kompakt' : 'markdown'" v-html="html(s)"></div>
</div>
</template>
<div v-else class="kapitel-platzhalter" :style="{ height: hoehe(i) + 'px' }"></div>
</div>
<div v-if="!daten.kapitel.length" class="guide-section">Noch kein Guide generiert.</div>
</template>
</div>
</div>
</template>

View File

@@ -0,0 +1,50 @@
<script setup>
import { ref, watch } from 'vue'
import { api } from '../api.js'
const props = defineProps({ run: Object })
const daten = ref(null)
async function laden() {
if (!props.run) return
daten.value = await api.kennzahlen(props.run.id)
}
watch(() => props.run?.id, laden, { immediate: true })
const k = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : n)
const s = (ms) => `${(ms / 1000).toFixed(0)}s`
</script>
<template>
<div class="seite">
<div style="display: flex; gap: 10px; align-items: center; margin-bottom: 12px">
<h2 style="margin: 0; flex: 1">Kennzahlen {{ run ? `(Lauf ${run.id})` : '' }}</h2>
<span v-if="daten" class="badge">{{ k(daten.verbraucht) }} Tokens gesamt</span>
<button class="sekundaer" @click="laden">Neu laden</button>
</div>
<table v-if="daten" class="kennzahlen">
<thead>
<tr>
<th>Ebene / Stage / Template</th><th>Calls</th><th>ok</th><th>Timeout</th>
<th>Fehler</th><th>Tok in</th><th>Tok out</th><th>Cache</th>
<th>Dauer</th><th>Wartezeit</th>
</tr>
</thead>
<tbody>
<tr v-for="z in daten.zeilen" :key="z.ebene + z.stage + z.template">
<td>{{ z.ebene }} / {{ z.stage }} <span style="color: var(--dim)">{{ z.template }}</span></td>
<td>{{ z.calls }}</td>
<td>{{ z.ok }}</td>
<td :style="z.timeouts ? 'color: var(--gelb)' : ''">{{ z.timeouts }}</td>
<td :style="z.fehler ? 'color: var(--rot)' : ''">{{ z.fehler }}</td>
<td>{{ k(z.tok_in || 0) }}</td>
<td>{{ k(z.tok_out || 0) }}</td>
<td>{{ k(z.cache_read || 0) }}</td>
<td>{{ s(z.dur_ms || 0) }}</td>
<td>{{ s(z.wait_ms || 0) }}</td>
</tr>
</tbody>
</table>
<p v-else style="color: var(--dim)">Noch kein Lauf.</p>
</div>
</template>

View File

@@ -0,0 +1,49 @@
<script setup>
import { ref, watch } from 'vue'
import { api } from '../api.js'
import { render } from '../markdown.js'
const props = defineProps({ topic: String })
const stapel = ref([])
const zeigeAntwort = ref(false)
const fertigGelernt = ref(0)
async function laden() {
if (!props.topic) return
stapel.value = await api.ueben(props.topic)
zeigeAntwort.value = false
fertigGelernt.value = 0
}
watch(() => props.topic, laden, { immediate: true })
async function antworten(richtig) {
const karte = stapel.value[0]
await api.antwort(karte.id, richtig)
stapel.value = richtig ? stapel.value.slice(1) : [...stapel.value.slice(1), karte]
if (richtig) fertigGelernt.value++
zeigeAntwort.value = false
}
</script>
<template>
<div class="seite">
<div v-if="stapel.length" class="flash">
<div class="meta" style="color: var(--dim); margin-bottom: 8px">
{{ stapel[0].titel }} · Box {{ stapel[0].box }} · noch {{ stapel.length }} fällig
</div>
<div class="frage" v-html="render(stapel[0].inhalt.frage)"></div>
<button v-if="!zeigeAntwort" @click="zeigeAntwort = true">Antwort zeigen</button>
<template v-else>
<div class="antwort markdown" v-html="render(stapel[0].inhalt.antwort)"></div>
<div style="display: flex; gap: 10px; justify-content: center">
<button style="background: var(--gruen)" @click="antworten(true)">Richtig</button>
<button style="background: var(--rot)" @click="antworten(false)">Falsch</button>
</div>
</template>
</div>
<div v-else class="flash">
<p>Keine fälligen Karten. {{ fertigGelernt ? `${fertigGelernt} gelernt — stark.` : '' }}</p>
<button class="sekundaer" @click="laden">Neu laden</button>
</div>
</div>
</template>

6
frontend/src/main.js Normal file
View File

@@ -0,0 +1,6 @@
import { createApp } from 'vue'
import App from './App.vue'
import '@fontsource-variable/inter'
import './style.css'
createApp(App).mount('#app')

102
frontend/src/markdown.js Normal file
View File

@@ -0,0 +1,102 @@
import { Marked } from 'marked'
import { markedHighlight } from 'marked-highlight'
import hljs from 'highlight.js'
import katex from 'katex'
import DOMPurify from 'dompurify'
import 'highlight.js/styles/github-dark.css'
import 'katex/dist/katex.min.css'
const marked = new Marked(
markedHighlight({
langPrefix: 'hljs language-',
highlight(code, lang) {
const l = hljs.getLanguage(lang) ? lang : 'plaintext'
return hljs.highlight(code, { language: l }).value
},
})
)
function kx(tex, displayMode) {
// Newlines im KaTeX-HTML falten (\sqrt-SVG ist mehrzeilig): in Markdown-
// Überschriften endet der Block sonst an der Zeile und das SVG zerreißt
return katex.renderToString(tex, { displayMode }).replace(/\n/g, ' ')
}
function mathe(text) {
// $$…$$/\\[…\\] (Display) und $…$/\\(…\\) (Inline) vor dem Markdown-Pass rendern
return text
.replace(/\$\$([\s\S]+?)\$\$/g, (_, tex) => {
try { return kx(tex, true) } catch { return _ }
})
.replace(/\\\[([\s\S]+?)\\\]/g, (_, tex) => {
try { return kx(tex, true) } catch { return _ }
})
.replace(/\$([^$\n]+?)\$/g, (_, tex) => {
try { return kx(tex, false) } catch { return _ }
})
.replace(/\\\(([\s\S]+?)\\\)/g, (_, tex) => {
try { return kx(tex, false) } catch { return _ }
})
}
export function render(text) {
return DOMPurify.sanitize(marked.parse(mathe(text || '')))
}
const RANG = { E: 0, M: 1, S: 2 }
const MARKER = /<!--\s*atom:\s*(\d+)\s*\|\s*([^|]*)\|\s*([EMS])\s*-->/g
// Wortzahl + Display-Formeln des sichtbaren Texts (für die Lesezeit-Schätzung)
export function lesestat(text) {
let displayFormeln = 0
const t = (text || '')
.replace(MARKER, ' ')
.replace(/\$\$[\s\S]+?\$\$/g, () => { displayFormeln += 1; return ' ' })
.replace(/\$[^$\n]+?\$/g, ' Formel ')
const woerter = t.split(/\s+/).filter(Boolean).length
return { woerter, displayFormeln }
}
// Text an Atom-Markern in Segmente schneiden; Filter zeigt Atome bis zum gewählten Level.
export function segmente(lang) {
const out = []
let letzt = 0
let aktuell = { level: null, titel: '', text: '' }
for (const m of lang.matchAll(MARKER)) {
aktuell.text = lang.slice(letzt, m.index)
out.push(aktuell)
aktuell = { level: m[3], titel: m[2].trim(), text: '' }
letzt = m.index + m[0].length
}
aktuell.text = lang.slice(letzt)
out.push(aktuell)
return out
}
export function gefiltert(lang, maxLevel) {
if (!maxLevel || maxLevel === 'S') return lang
return segmente(lang)
.filter((s) => s.level === null || RANG[s.level] <= RANG[maxLevel])
.map((s) => s.text)
.join('\n')
}
// Gestuftes Rendern: Segmente über dem Level fallen weg, Segmente DARUNTER
// werden gedimmt (schon gelernt) — der Blick geht auf die neuen Inhalte.
// Kernpunkte/Prüfe-dich (nach dem letzten Atom) bleiben immer ungedimmt.
export function renderGestuft(lang, maxLevel) {
if (!maxLevel || maxLevel === 'E') return render(gefiltert(lang, maxLevel))
const seg = segmente(lang)
const letzt = seg[seg.length - 1]
const i = letzt.text.indexOf('**Kernpunkte:**')
if (i >= 0) {
seg.push({ level: null, titel: '', text: letzt.text.slice(i) })
letzt.text = letzt.text.slice(0, i)
}
return seg.map((s) => {
if (s.level !== null && RANG[s.level] > RANG[maxLevel]) return ''
const html = render(s.text)
const gelernt = s.level !== null && RANG[s.level] < RANG[maxLevel]
return gelernt ? `<div class="gelernt">${html}</div>` : html
}).join('')
}

243
frontend/src/style.css Normal file
View File

@@ -0,0 +1,243 @@
:root {
--bg: #10141a;
--panel: #171d26;
--karte: #1f2733;
--rand: #2c3646;
--text: #dce3ec;
--dim: #8b98a9;
--akzent: #4da3ff;
--gruen: #3fc380;
--gelb: #e8c34a;
--rot: #e06060;
--badge-gruen-bg: #1d3a2c;
--badge-gelb-bg: #3a331d;
--badge-rot-bg: #3a1d1d;
--badge-blau-bg: #1d2b3a;
--code-bg: #0d1117;
}
:root { --lese-text: #c9d2df; } /* Guide-Fließtext: weicher als --text */
:root[data-theme="light"] { --lese-text: #333747; }
:root[data-theme="sepia"] {
--bg: #f0e7d3;
--panel: #f9f2df;
--karte: #efe4c9;
--rand: #d9cba8;
--text: #443b2c;
--dim: #83765c;
--akzent: #7a5c2e;
--gruen: #4f7a42;
--gelb: #8f6b14;
--rot: #a6442e;
--badge-gruen-bg: #e2ecd5;
--badge-gelb-bg: #f0e6c2;
--badge-rot-bg: #f0d8cd;
--badge-blau-bg: #e8e0c8;
--code-bg: #ede3ca;
--lese-text: #443b2c;
}
:root[data-theme="light"] {
--bg: #f3f5f8;
--panel: #ffffff;
--karte: #eef1f5;
--rand: #d4dae2;
--text: #1c2430;
--dim: #5c6a7c;
--akzent: #1a73d6;
--gruen: #1d8a55;
--gelb: #9a7b12;
--rot: #c23b3b;
--badge-gruen-bg: #dcf2e6;
--badge-gelb-bg: #f6eecb;
--badge-rot-bg: #f6d9d9;
--badge-blau-bg: #dcebfa;
--code-bg: #f0f2f6;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font: 14px/1.5 system-ui, sans-serif;
}
#app { height: 100vh; display: flex; }
button {
background: var(--akzent); color: #fff; border: 0; border-radius: 6px;
padding: 6px 14px; cursor: pointer; font: inherit;
}
button.sekundaer { background: var(--karte); color: var(--text); border: 1px solid var(--rand); }
button.klein { padding: 2px 8px; font-size: 12px; }
button:disabled { opacity: 0.4; cursor: default; }
input, select {
background: var(--karte); color: var(--text); border: 1px solid var(--rand);
border-radius: 6px; padding: 6px 10px; font: inherit;
}
/* Sidebar-Navigation */
.sidebar {
width: 220px; min-width: 220px; background: var(--panel);
border-right: 1px solid var(--rand); display: flex; flex-direction: column;
}
.sidebar .topics { flex: 1; overflow-y: auto; padding: 8px; }
.sidebar .topic-eintrag {
display: flex; align-items: center; gap: 6px; padding: 7px 10px;
border-radius: 6px; cursor: pointer; color: var(--dim);
}
.sidebar .topic-eintrag.aktiv { background: var(--karte); color: var(--text); }
.sidebar .neu { padding: 10px; border-top: 1px solid var(--rand); display: grid; gap: 6px; }
.kontextmenue {
position: fixed; background: var(--panel); border: 1px solid var(--rand);
border-radius: 8px; padding: 4px; box-shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
}
.kontextmenue button {
display: block; width: 100%; text-align: left; background: none;
color: var(--text); border: 0; padding: 7px 12px; border-radius: 6px;
}
.kontextmenue button:hover:not(:disabled) { background: var(--karte); }
.kontextmenue button.rot { color: var(--rot); }
.hauptbereich { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.topbar {
display: flex; gap: 10px; align-items: center; flex-wrap: wrap;
padding: 8px 16px; background: var(--panel); border-bottom: 1px solid var(--rand);
}
.tabs { display: flex; gap: 4px; }
.tabs button { background: transparent; color: var(--dim); }
.tabs button.aktiv { background: var(--karte); color: var(--text); }
.inhalt { flex: 1; overflow: hidden; display: flex; flex-direction: column; }
.badge {
display: inline-block; padding: 1px 8px; border-radius: 10px;
font-size: 11px; background: var(--karte); color: var(--dim);
}
.badge.gruen { background: var(--badge-gruen-bg); color: var(--gruen); }
.badge.gelb { background: var(--badge-gelb-bg); color: var(--gelb); }
.badge.rot { background: var(--badge-rot-bg); color: var(--rot); }
.badge.blau { background: var(--badge-blau-bg); color: var(--akzent); }
/* Befunde/Agenten-Leiste über dem Board */
.leiste {
display: flex; gap: 6px; align-items: center; flex-wrap: wrap;
padding: 8px 12px; border-bottom: 1px solid var(--rand); background: var(--panel);
min-height: 40px; max-height: 130px; overflow-y: auto;
}
/* Board: Ebenen-Spalten links→rechts, Listen VOLLSTÄNDIG scrollbar */
.board { display: flex; gap: 10px; padding: 12px; overflow-x: auto; flex: 1; min-height: 0; }
.spalte {
min-width: 265px; max-width: 320px; flex: 1; display: flex; flex-direction: column;
background: var(--panel); border: 1px solid var(--rand); border-radius: 8px;
}
.spaltekopf { padding: 8px 10px; border-bottom: 1px solid var(--rand); display: grid; gap: 6px; }
.spaltekopf .zeile { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
.spaltekopf .titel { font-weight: 600; font-size: 13px; flex: 1; }
.spaltekopf label { font-size: 12px; color: var(--dim); display: flex; gap: 4px; align-items: center; }
.spalte .liste { overflow-y: auto; flex: 1; padding: 8px; }
.karte {
background: var(--karte); border: 1px solid var(--rand); border-radius: 6px;
padding: 6px 9px; margin-bottom: 6px; font-size: 13px;
}
.karte .meta { color: var(--dim); font-size: 11px; margin-top: 2px; }
.seite { flex: 1; overflow-y: auto; padding: 20px; }
.seite.schmal { max-width: 900px; margin: 0 auto; }
/* Guide: Kapitel-Navigation + Lesetypografie (~70 Zeichen/Zeile, 17px) */
.guide-layout { flex: 1; display: flex; overflow: hidden; min-height: 0; }
.guide-nav {
width: 260px; min-width: 220px; overflow-y: auto; padding: 14px 10px;
border-right: 1px solid var(--rand); background: var(--panel); font-size: 13px;
}
.guide-nav .schalter-zeile {
display: flex; gap: 6px; align-items: center; padding: 2px 4px 10px;
}
.schalter { display: flex; border: 1px solid var(--rand); border-radius: 6px; overflow: hidden; }
.schalter button {
background: none; color: var(--dim); border: 0; border-radius: 0;
padding: 4px 9px; font-size: 12px; line-height: 1;
display: flex; align-items: center;
}
.schalter button.aktiv { background: var(--karte); color: var(--text); }
.schalter-solo {
background: none; color: var(--dim); border: 1px solid var(--rand);
border-radius: 6px; padding: 3px 9px; font-size: 12px;
}
.guide-nav .fortschritt {
color: var(--dim); font-size: 12px; padding: 0 8px 10px;
border-bottom: 1px solid var(--rand); margin-bottom: 8px;
}
.guide-nav a {
display: flex; justify-content: space-between; gap: 8px; align-items: baseline;
padding: 5px 8px; border-radius: 6px;
color: var(--dim); cursor: pointer; line-height: 1.35;
}
.guide-nav a .zeit { font-size: 11px; white-space: nowrap; opacity: 0.75; }
.guide-nav a:hover { color: var(--text); }
.guide-nav a.aktiv { background: var(--karte); color: var(--text); }
.seite.guide-text {
position: relative; max-width: 680px; margin: 0 auto;
scrollbar-width: none; /* Scrollbalken unsichtbar, Scrollen bleibt */
font-family: 'Inter Variable', system-ui, sans-serif;
font-size: 19px; line-height: 1.55; color: var(--lese-text);
hyphens: auto; -webkit-hyphens: auto;
}
/* Fokus-Modus: Navigation weg, Karten-Rahmen aufgelöst — reiner Artikel-Fluss */
.guide-layout.fokus .guide-nav { display: none; }
.guide-layout.fokus .guide-section {
border: 0; background: none; padding: 0 0 6px; margin-bottom: 4px;
}
body.vollbild .sidebar, body.vollbild .topbar { display: none; }
body.vollbild .nav-toggle { display: none; }
/* Eingeklappt: Themen-Sidebar + Topbar weg — die Kapitelübersicht bleibt */
body.nav-zu .sidebar, body.nav-zu .topbar { display: none; }
body.nav-zu .guide-nav { padding-top: 44px; }
.nav-toggle {
position: fixed; top: 6px; left: 8px; z-index: 20;
background: none; color: var(--dim); border: 0; padding: 4px 8px; font-size: 16px;
}
.nav-toggle:hover { color: var(--text); }
.sidebar .topics { padding-top: 40px; } /* Platz für das fixe ☰ links oben */
.fokus-aus {
position: fixed; top: 10px; right: 16px; z-index: 5;
background: none; color: var(--dim); border: 0;
padding: 4px 8px; font-size: 16px; opacity: 0.6;
}
.fokus-aus:hover { opacity: 1; }
.seite.guide-text::-webkit-scrollbar { display: none; }
.markdown .gelernt { opacity: 0.45; } /* niedrigere Level = schon gelernt */
.guide-text .kapitel { scroll-margin-top: 8px; }
.guide-text .kapitel-titel { margin: 30px 0 6px; }
.kapitel-platzhalter { border-left: 2px dashed var(--rand); margin: 8px 0 8px 4px; }
/* Kompakt-Ansicht: gleiche Lesetypografie wie der Fließtext, kein Grau-Kasten */
.guide-text .kompakt {
background: none; padding: 0; color: var(--lese-text);
font-size: 17px; line-height: 1.55;
}
.guide-text .kompakt li { margin: 5px 0; }
.guide-section {
background: var(--panel); border: 1px solid var(--rand); border-radius: 8px;
padding: 16px 22px; margin-bottom: 16px;
}
.guide-section .ziel { color: var(--akzent); font-size: 13px; }
.markdown .katex-display { overflow-x: auto; overflow-y: hidden; padding: 4px 0; }
.markdown blockquote {
margin: 12px 0; padding: 8px 14px; border-left: 3px solid var(--akzent);
background: var(--karte); border-radius: 0 6px 6px 0; color: var(--text);
}
.markdown blockquote p { margin: 4px 0; }
.markdown :is(code):not(.hljs) { background: var(--karte); padding: 1px 5px; border-radius: 4px; }
.markdown pre { background: var(--code-bg); padding: 10px; border-radius: 6px; overflow-x: auto; }
.markdown table { border-collapse: collapse; }
.markdown td, .markdown th { border: 1px solid var(--rand); padding: 4px 10px; }
.flash {
background: var(--panel); border: 1px solid var(--rand); border-radius: 10px;
padding: 28px; max-width: 620px; margin: 40px auto; text-align: center;
}
.flash .frage { font-size: 18px; margin-bottom: 18px; }
.flash .antwort { background: var(--karte); border-radius: 8px; padding: 14px; margin: 14px 0; }
table.kennzahlen { border-collapse: collapse; width: 100%; font-size: 13px; }
table.kennzahlen th, table.kennzahlen td {
border-bottom: 1px solid var(--rand); padding: 5px 10px; text-align: right;
}
table.kennzahlen th:first-child, table.kennzahlen td:first-child { text-align: left; }
table.kennzahlen th { color: var(--dim); }

6
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,6 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
})