init
This commit is contained in:
177
frontend/src/components/Board.vue
Normal file
177
frontend/src/components/Board.vue
Normal 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>
|
||||
159
frontend/src/components/Guide.vue
Normal file
159
frontend/src/components/Guide.vue
Normal 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>
|
||||
50
frontend/src/components/Kennzahlen.vue
Normal file
50
frontend/src/components/Kennzahlen.vue
Normal 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>
|
||||
49
frontend/src/components/Ueben.vue
Normal file
49
frontend/src/components/Ueben.vue
Normal 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>
|
||||
Reference in New Issue
Block a user