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

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>