This commit is contained in:
team3
2026-07-12 16:13:50 +02:00
parent a284e03225
commit 31a42bbf1d
48 changed files with 3625 additions and 253 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -12,9 +12,11 @@
"@fontsource-variable/inter": "^5.2.8",
"dompurify": "^3.4.7",
"highlight.js": "^11.11.1",
"jsdom": "^29.1.1",
"katex": "^0.17.0",
"marked": "^18.0.4",
"marked-highlight": "^2.2.4",
"mermaid": "^11.4.1",
"vue": "^3.5.32"
},
"devDependencies": {

View File

@@ -91,8 +91,8 @@ async function starten() {
}
async function stoppen() {
await api.stop(topic.value)
await stateLaden()
try { fehler.value = ''; await api.stop(topic.value); await stateLaden() }
catch (e) { fehler.value = String(e) } // sonst läuft der Lauf weiter, ohne Signal
}
const transferOk = ref(false) // nur im lokalen Dev-Betrieb (Server: 403)
@@ -205,7 +205,8 @@ function runBadge(run) {
</select>
</div>
<div class="inhalt">
<Board v-if="tab === 'board'" :state="state" :topic="topic" @reload="stateLaden" />
<Board v-if="tab === 'board'" :state="state" :topic="topic" @reload="stateLaden"
@fehler="(e) => (fehler = e)" />
<Guide v-else-if="tab === 'guide'" :topic="topic" :state="state" />
<Ueben v-else-if="tab === 'üben'" :topic="topic" />
<Kennzahlen v-else :run="state?.run" />

View File

@@ -9,28 +9,32 @@ async function req(pfad, opts = {}) {
return res.json()
}
// Topic-Namen in Pfaden IMMER kodieren — sonst zerlegen „#"/„/" im Namen die
// URL und das Topic wird über die UI unbenutzbar/unlöschbar.
const e = encodeURIComponent
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: '{}' }),
req(`/api/topics/${e(topic)}/start`, { method: 'POST', body: JSON.stringify({ budget }) }),
stop: (topic) => req(`/api/topics/${e(topic)}/stop`, { method: 'POST', body: '{}' }),
sollReset: (topic) => req(`/api/topics/${e(topic)}/soll-reset`, { method: 'POST', body: '{}' }),
vollReset: (topic) => req(`/api/topics/${e(topic)}/voll-reset`, { method: 'POST', body: '{}' }),
setAuto: (topic, ebene, an) =>
req(`/api/topics/${topic}/auto`, { method: 'PATCH', body: JSON.stringify({ ebene, an }) }),
req(`/api/topics/${e(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`),
req(`/api/topics/${e(topic)}/ebene/${e(ebene)}/entfernen`, { method: 'POST', body: '{}' }),
topicLoeschen: (topic) => req(`/api/topics/${e(topic)}`, { method: 'DELETE' }),
state: (topic) => req(`/api/topics/${e(topic)}/state`),
guide: (topic) => req(`/api/topics/${e(topic)}/guide`),
ueben: (topic) => req(`/api/topics/${e(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`),
kennzahlen: (runId) => req(`/api/runs/${e(runId)}/kennzahlen`),
transferInfo: () => req('/api/transfer'),
transfer: (topic, richtung) =>
req(`/api/topics/${topic}/transfer/${richtung}`, { method: 'POST', body: '{}' }),
req(`/api/topics/${e(topic)}/transfer/${e(richtung)}`, { method: 'POST', body: '{}' }),
}
export function wsVerbinden(onEvent) {
@@ -38,18 +42,19 @@ export function wsVerbinden(onEvent) {
const wsProto = location.protocol === 'https:' ? 'wss' : 'ws'
const url = (import.meta.env.DEV ? 'ws://localhost:8000' : `${wsProto}://${location.host}`) + '/ws'
let ws
let offen = true // Cleanup-Flag: nach dem Schließen NICHT mehr reconnecten
const verbinden = () => {
try {
ws = new WebSocket(url)
} catch (e) {
} catch (err) {
// nie den Aufrufer mitreißen (der SecurityError killte sonst den Poll)
console.error('WebSocket nicht verfügbar:', e)
console.error('WebSocket nicht verfügbar:', err)
return
}
ws.onopen = () => onEvent({ typ: 'verbunden' }) // nach Reconnect: Stand neu laden
ws.onmessage = (e) => onEvent(JSON.parse(e.data))
ws.onclose = () => setTimeout(verbinden, 2000)
ws.onmessage = (ev) => onEvent(JSON.parse(ev.data))
ws.onclose = () => { if (offen) setTimeout(verbinden, 2000) }
}
verbinden()
return () => ws && ws.close()
return () => { offen = false; if (ws) ws.close() } // sonst Zombie-Reconnect (HMR)
}

View File

@@ -5,7 +5,7 @@ import { computed, onUnmounted, ref, watch } from 'vue'
import { api } from '../api.js'
const props = defineProps({ state: Object, topic: String })
const emit = defineEmits(['reload'])
const emit = defineEmits(['reload', 'fehler'])
// Agenten-Zeit lokal weiterticken: der Server liefert laufzeit nur mit dem
// State-Snapshot — zwischen Deltas (lange LLM-Calls) fröre die Anzeige ein.
@@ -41,6 +41,7 @@ const EBENEN = [
{ key: 'inventar', titel: 'Atome' },
{ key: 'artefakte', titel: 'Artefakte' },
{ key: 'struktur', titel: 'Bausteine' },
{ key: 'diagramme', titel: 'Diagramme' },
{ key: 'guide', titel: 'Guide' },
]
@@ -79,6 +80,10 @@ function karten(key) {
return s.bausteine.map((b) => ({ id: b.id, text: `${b.ord + 1}. ${b.titel}`,
badges: [[b.status, statusFarbe(b.status)]] }))
}
if (key === 'diagramme') {
return (s.diagramme || []).map((d) => ({ id: d.id, text: `${d.ord + 1}. ${d.titel}`,
badges: [[d.status, statusFarbe(d.status)], [`${d.typ} · ${d.quelle}`, 'blau']] }))
}
return s.bausteine.map((b) => ({ id: b.id, text: `${b.ord + 1}. ${b.titel}`,
badges: [[b.stage || 'offen', b.stage === 'done' ? 'gruen' : 'blau']] }))
}
@@ -100,21 +105,21 @@ async function menueGenerieren() {
const m = menue.value
menue.value = null
if (!m || !generierenErlaubt(m.idx)) return
await api.start(props.topic)
emit('reload')
try { await api.start(props.topic); emit('reload') }
catch (e) { emit('fehler', String(e)) } // sonst schlägt der Start still fehl
}
async function menueEntfernen() {
const m = menue.value
menue.value = null
if (!m) return
await api.ebeneEntfernen(props.topic, m.key)
emit('reload')
try { await api.ebeneEntfernen(props.topic, m.key); emit('reload') }
catch (e) { emit('fehler', String(e)) }
}
async function autoSetzen(key, ev) {
await api.setAuto(props.topic, key, ev.target.checked)
emit('reload')
try { await api.setAuto(props.topic, key, ev.target.checked); emit('reload') }
catch (e) { emit('fehler', String(e)) }
}
const dauer = (s) => (s >= 3600 ? `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`

View File

@@ -1,8 +1,14 @@
<script setup>
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
import mermaid from 'mermaid'
import { api } from '../api.js'
import { render, lesestat } from '../markdown.js'
// securityLevel:'strict' → kein foreignObject/HTML-Label (umgeht DOMPurify-Filter),
// keine Klick-Handler. startOnLoad:false → wir rendern selbst nach jedem Einblenden.
mermaid.initialize({ startOnLoad: false, securityLevel: 'strict', theme: 'neutral' })
let mmId = 0
const props = defineProps({ topic: String, state: Object })
const daten = ref(null)
const level = ref('E') // Durchgang-Wahl: jede Stufe ist ein eigener Lese-Durchgang
@@ -15,10 +21,14 @@ watch(fokus, (f) => document.body.classList.toggle('vollbild', f))
onUnmounted(() => document.body.classList.remove('vollbild'))
const aktiv = ref(0)
const kapEls = []
const scrollEl = ref(null)
let ladeSeq = 0
async function laden() {
if (!props.topic) return
daten.value = await api.guide(props.topic)
if (!props.topic) { daten.value = null; return }
const seq = ++ladeSeq
const d = await api.guide(props.topic)
if (seq === ladeSeq) daten.value = d // späte Antwort eines alten Topics verwerfen
}
watch(() => props.topic, laden, { immediate: true })
watch(() => props.state?.run?.status, laden) // nach Lauf-Ende neu ziehen
@@ -36,6 +46,36 @@ function html(s) {
return htmlCache.get(key)
}
// „Prüfe dich"-Antworten ebenfalls cachen — sonst rechnet jeder Scroll-Tick
// Markdown+KaTeX für alle sichtbaren Antworten neu (genau der Kostenpfad oben).
function htmlAntwort(s, fi) {
const key = `a|${s.baustein}|${fi}`
if (!htmlCache.has(key)) htmlCache.set(key, render(s.fragen[fi].antwort))
return htmlCache.get(key)
}
// Mermaid rendert asynchron ins DOM — nach jedem Einblenden (Scroll/Level/Ansicht)
// die noch nicht verarbeiteten ```mermaid-Blöcke im sichtbaren Bereich rendern.
async function renderMermaid() {
await nextTick()
const wurzel = scrollEl.value
if (!wurzel) return
for (const code of wurzel.querySelectorAll('code.language-mermaid:not([data-mm])')) {
code.setAttribute('data-mm', '1')
const pre = code.closest('pre')
try {
const { svg } = await mermaid.render(`mm-${mmId++}`, code.textContent)
const figur = document.createElement('div')
figur.className = 'mermaid-figur'
figur.innerHTML = svg
pre?.replaceWith(figur)
} catch {
pre?.classList.add('mermaid-fehler') // kaputt: als Codeblock stehen lassen
}
}
}
watch([aktiv, ansicht, level, daten], renderMermaid, { flush: 'post' })
// Nur aktives Kapitel ±1 im DOM; der Rest ist ein Platzhalter mit Schätzhöhe
const sichtbar = (i) => Math.abs(i - aktiv.value) <= 1
@@ -49,11 +89,13 @@ function setAnsicht(a) {
ansicht.value = a
}
// Nur Durchgänge anbieten, die es in diesem Thema gibt (ohne E- oder
// S-Bausteine überspringt der Toggle die Stufe)
// Nur Durchgänge anbieten, die WIRKLICH Inhalt haben (≥1 gefüllte Section) —
// sonst schaltet der Toggle in einen leeren Durchgang und läuft in eine Sackgasse
const levels = computed(() => {
const da = new Set((daten.value?.kapitel || []).map((k) => k.level))
const folge = ['E', 'M', 'S'].filter((l) => da.has(l))
const kaps = daten.value?.kapitel || []
const hatInhalt = (l) => kaps.some((k) => k.level === l
&& k.sections.some((s) => ((s.lang || '') + (s.kompakt || '')).trim()))
const folge = ['E', 'M', 'S'].filter(hatInhalt)
return folge.length ? folge : ['E']
})
watch(levels, (l) => { if (!l.includes(level.value)) level.value = l[0] })
@@ -74,7 +116,13 @@ const anzeige = computed(() => {
}
return out
})
watch([level, ansicht, daten], () => { kapEls.length = 0; aktiv.value = 0 })
watch([level, ansicht, daten], () => {
kapEls.length = 0
aktiv.value = 0
// Scrollposition zurücksetzen — sonst steht der Viewport nach dem Wechsel
// mitten in Platzhalter-Divs (echter Inhalt nur oben) und wirkt leer.
nextTick(() => { if (scrollEl.value) scrollEl.value.scrollTop = 0 })
})
// Lesezeit des SICHTBAREN Umfangs: 150 Wörter/min (technischer Text)
// + 5 s je Display-Formel (Medium ~265 wpm gilt nicht für Mathe)
@@ -121,7 +169,7 @@ function onScroll(e) {
<template>
<div class="guide-layout" :class="{ fokus }">
<nav v-if="anzeige.length" class="guide-nav">
<nav v-if="daten" class="guide-nav">
<div class="schalter-zeile">
<div class="schalter">
<button :class="{ aktiv: ansicht === 'erklaerend' }" title="ausführlich (viel Text)"
@@ -154,7 +202,7 @@ function onScroll(e) {
<span class="zeit">{{ zeit(stats.je[i]?.minuten || 0) }}</span>
</a>
</nav>
<div class="seite guide-text" @scroll="onScroll">
<div ref="scrollEl" class="seite guide-text" @scroll="onScroll">
<button v-if="fokus" class="fokus-aus" title="Fokus beenden"
@click="fokus = false"></button>
<template v-if="daten">
@@ -175,7 +223,7 @@ function onScroll(e) {
<summary>Antworten zu Prüfe dich"</summary>
<div v-for="(f, fi) in s.fragen" :key="fi" class="antwort">
<div class="frage">{{ f.frage }}</div>
<div v-html="render(f.antwort)"></div>
<div v-html="htmlAntwort(s, fi)"></div>
</div>
</details>
</div>

View File

@@ -6,7 +6,7 @@ const props = defineProps({ run: Object })
const daten = ref(null)
async function laden() {
if (!props.run) return
if (!props.run) { daten.value = null; return } // sonst bleiben Alt-Daten stehen
daten.value = await api.kennzahlen(props.run.id)
}
watch(() => props.run?.id, laden, { immediate: true })

View File

@@ -7,6 +7,7 @@ const props = defineProps({ topic: String })
const stapel = ref([])
const zeigeAntwort = ref(false)
const fertigGelernt = ref(0)
const sende = ref(false) // Doppelklick-Sperre: sonst POSTet die Karte zweimal
async function laden() {
if (!props.topic) return
@@ -17,11 +18,17 @@ async function laden() {
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
if (sende.value) return
sende.value = true
try {
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
} finally {
sende.value = false
}
}
</script>
@@ -36,8 +43,10 @@ async function antworten(richtig) {
<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>
<button style="background: var(--gruen)" :disabled="sende"
@click="antworten(true)">Richtig</button>
<button style="background: var(--rot)" :disabled="sende"
@click="antworten(false)">Falsch</button>
</div>
</template>
</div>

View File

@@ -27,39 +27,49 @@ function kx(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 _ }
})
// Formeln VOR dem Markdown-Pass ausheben und durch Platzhalter ersetzen. Sonst
// lecken die rohen TeX-Zeichen (*, _, ^) aus den MathML-Annotationen des
// gerenderten KaTeX in marked — z. B. zwei „$\Sigma^*$" machen den Text
// dazwischen kursiv. Nach dem Markdown-Pass die Formeln wieder einsetzen.
function mathAusheben(text) {
const formeln = []
const platz = (roh, tex, display) => {
let html
try { html = kx(tex, display) } catch { html = roh } // KaTeX-Fehler: Rohtext behalten
formeln.push(html)
return `@@MATH${formeln.length - 1}@@`
}
const t = text
.replace(/\$\$([\s\S]+?)\$\$/g, (m, tex) => platz(m, tex, true))
.replace(/\\\[([\s\S]+?)\\\]/g, (m, tex) => platz(m, tex, true))
.replace(/\$([^$\n]+?)\$/g, (m, tex) => platz(m, tex, false))
.replace(/\\\(([\s\S]+?)\\\)/g, (m, tex) => platz(m, tex, false))
return { t, formeln }
}
export function render(text) {
return DOMPurify.sanitize(marked.parse(mathe(text || '')))
const { t, formeln } = mathAusheben(text || '')
const html = marked.parse(t).replace(/@@MATH(\d+)@@/g, (_, i) => formeln[Number(i)] ?? '')
return DOMPurify.sanitize(html)
}
// Atom-Marker sind eine unsichtbare Backend-Schnittstelle — vor der Anzeige
// ausblenden. Die Stufen-Trennung passiert seit dem Umbau über getrennte
// Bausteine je Level, nicht mehr über Marker-Filterung im Text.
const MARKER = /<!--\s*atom:[^>]*-->/g
// ausblenden. non-greedy statt [^>]*, damit ein „>" im Atom-Titel den Match
// nicht abschneidet (spiegelt backend/guide.py _MARKER_VOLL).
const MARKER = /<!--\s*atom:.*?-->/g
// Wortzahl + Display-Formeln des sichtbaren Texts (für die Lesezeit-Schätzung)
// Wortzahl + Display-Formeln des sichtbaren Texts (für die Lesezeit-Schätzung).
// Alle vier Formel-Delimiter wie in mathe() behandeln — sonst zählte eine
// \[…\]-Display-Formel als Wörter und die +5-s-Pauschale fiel weg.
export function lesestat(text) {
let displayFormeln = 0
const t = (text || '')
.replace(/```mermaid[\s\S]*?```/g, ' ') // Diagramm-Quelltext zählt nicht als Text
.replace(MARKER, ' ')
.replace(/\$\$[\s\S]+?\$\$/g, () => { displayFormeln += 1; return ' ' })
.replace(/\\\[[\s\S]+?\\\]/g, () => { displayFormeln += 1; return ' ' })
.replace(/\$[^$\n]+?\$/g, ' Formel ')
.replace(/\\\([\s\S]+?\\\)/g, ' Formel ')
const woerter = t.split(/\s+/).filter(Boolean).length
return { woerter, displayFormeln }
}

View File

@@ -222,7 +222,9 @@ body.nav-zu .hauptbereich { padding-top: 38px; }
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; }
:is(.markdown, .kompakt) .katex-display { overflow-x: auto; overflow-y: hidden; padding: 4px 0; }
.mermaid-figur { overflow-x: auto; text-align: center; margin: 16px 0; }
.mermaid-figur svg { max-width: 100%; height: auto; }
.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);
@@ -248,7 +250,7 @@ table.kennzahlen th:first-child, table.kennzahlen td:first-child { text-align: l
table.kennzahlen th { color: var(--dim); }
/* ── Mobil (≤768px): Navigationen als Drawer, Board mit Spalten-Snap ────────── */
.markdown .katex { max-width: 100%; overflow-x: auto; overflow-y: hidden; }
:is(.markdown, .kompakt) .katex { max-width: 100%; overflow-x: auto; overflow-y: hidden; }
@media (max-width: 768px) {
.sidebar, .guide-nav {
position: fixed; top: 0; bottom: 0; left: 0; z-index: 30;