Files
creator/frontend/src/components/BausteinPanel.vue
2026-06-15 16:09:38 +02:00

447 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { computed, nextTick, ref } from 'vue'
import { chatBaustein, createVertiefung, fetchVertiefung, pruefeBaustein } from '../api.js'
import { renderMarkdown } from '../markdown.js'
import { useChat, istUnten } from '../composables/useChat.js'
const props = defineProps({
topic: { type: String, required: true },
baustein: { type: String, required: true },
section: { type: String, default: '' },
provider: { type: String, default: 'claude' },
status: { type: Object, default: null }, // {gute_antworten, absolviert, verstanden, gemeistert, vertiefung}
tier2: { type: Boolean, default: false }, // Tier 2 frei (ganzer Guide absolviert)
tier3: { type: Boolean, default: false }, // Meisterpfad frei (ganzer Guide verstanden)
})
const emit = defineEmits(['statusChanged'])
const NOETIG = 3 // absolviert
const MAX = 10 // verstanden
const MEISTERN = 25 // gemeistert (Maximum)
const st = computed(() => props.status || { gute_antworten: 0, absolviert: false, verstanden: false, gemeistert: false, vertiefung: false, deepdive: false })
// --- Toggle-Bereich ---
const activeTab = ref(null) // null | 'vertiefung' | 'deepdive' | 'chat' | 'pruefung'
function toggle(tab) {
activeTab.value = activeTab.value === tab ? null : tab
if (activeTab.value === 'vertiefung' || activeTab.value === 'deepdive') openText(activeTab.value)
}
// --- Vertiefung (gleicher Stoff, mehr) + Amateur (gleicher Stoff, für Einsteiger), beide persistiert (intern art 'deepdive') ---
const texte = ref({
vertiefung: { md: null, loading: false, error: '' },
deepdive: { md: null, loading: false, error: '' },
})
async function openText(art) {
const t = texte.value[art]
if (t.md !== null || t.loading || !st.value[art]) return
t.loading = true
t.error = ''
try {
t.md = (await fetchVertiefung(props.topic, props.baustein, art)).md
} catch (e) {
t.error = e.message
} finally {
t.loading = false
}
}
async function generateText(art) {
const t = texte.value[art]
t.loading = true
t.error = ''
try {
t.md = (await createVertiefung({
topic: props.topic, baustein: props.baustein, section: props.section, art, provider: props.provider,
})).md
emit('statusChanged', { ...st.value, [art]: true })
} catch (e) {
t.error = e.message
} finally {
t.loading = false
}
}
// --- Bausteinchat (flüchtig) ---
const chat = useChat((msgs) => chatBaustein({
topic: props.topic, baustein: props.baustein, section: props.section,
messages: msgs, provider: props.provider,
}))
// --- Prüfung: gesteuerter Dialog (Verlauf flüchtig, Zähler serverseitig) ---
// Phasen: 'idle' (Frage anfordern) | 'frage_offen' (antworten/nachfragen) | 'bewertet' (diskutieren/neu bewerten/weiter)
const pruefMessages = ref([]) // {role, kind: 'frage'|'nachfrage'|'antwort'|'feedback'|'diskussion'|'fehler', content, bewertung?}
const pruefInput = ref('')
const pruefPhase = ref('idle')
const pruefLoading = ref(false)
const aktuelleFrage = ref('') // ankert Bewertung/Diskussion
const letztesFeedback = ref('') // Kontext für die Diskussion über eine Bewertung
const scoreVorFrage = ref(0) // Score, als die aktuelle Frage gestellt wurde → driftfreies (Re-)Bewerten
const naechsteFrage = ref(null) // im Hintergrund vorbereitete nächste Frage (Prefetch)
let prefetchPromise = null // laufender Hintergrund-Abruf (verhindert Doppel-Prefetch)
const pruefMessagesEl = ref(null)
const pruefInputEl = ref(null)
const pruefStick = ref(true) // nur auto-scrollen, wenn der Nutzer (fast) unten ist
let pruefRun = 0
function onPruefScroll() {
if (pruefMessagesEl.value) pruefStick.value = istUnten(pruefMessagesEl.value)
}
function applyPruefung(res) {
emit('statusChanged', { ...st.value, gute_antworten: res.gute_antworten, absolviert: res.absolviert, verstanden: res.verstanden, gemeistert: res.gemeistert })
}
async function pruefScroll() {
await nextTick()
if (pruefMessagesEl.value && pruefStick.value) pruefMessagesEl.value.scrollTop = pruefMessagesEl.value.scrollHeight
}
// Nur echte Gesprächs-Turns ans Backend; Feedback bleibt reines UI-Artefakt.
function pruefDialog() {
return pruefMessages.value
.filter((m) => m.kind !== 'feedback' && m.kind !== 'fehler')
.map((m) => ({ role: m.role, content: m.content }))
}
async function pruefSenden(payload, onOk) {
const run = ++pruefRun
pruefStick.value = true // eigene Aktion = ans Ende; Hochscrollen während des Wartens setzt es wieder false
pruefLoading.value = true
pruefScroll()
try {
const res = await pruefeBaustein({
topic: props.topic, baustein: props.baustein, section: props.section,
provider: props.provider, messages: pruefDialog(), ...payload,
})
if (run !== pruefRun) return
onOk(res)
applyPruefung(res)
pruefScroll()
nextTick(() => pruefInputEl.value?.focus())
} catch {
if (run === pruefRun) pruefMessages.value.push({ role: 'assistant', kind: 'fehler', content: 'Hat nicht geklappt — bitte erneut.' })
} finally {
if (run === pruefRun) pruefLoading.value = false
}
}
// Frage anzeigen (frisch oder vorgemerkt). scoreVorFrage = LIVE-Score zur Anzeigezeit (driftfrei).
function frageZeigen(text) {
aktuelleFrage.value = text
letztesFeedback.value = ''
scoreVorFrage.value = st.value.gute_antworten
pruefMessages.value.push({ role: 'assistant', kind: 'frage', content: text })
pruefPhase.value = 'frage_offen'
}
// Nächste Frage im Hintergrund vorbereiten (überbrückt die Wartezeit). Ohne pruefLoading/pruefRun.
function prefetchFrage() {
if (naechsteFrage.value || prefetchPromise) return
prefetchPromise = pruefeBaustein({
topic: props.topic, baustein: props.baustein, section: props.section,
provider: props.provider, aktion: 'frage', messages: pruefDialog(),
})
.then((res) => { naechsteFrage.value = res.frage })
.catch(() => {})
.finally(() => { prefetchPromise = null })
}
// Erste Frage (Phase idle): frisch generieren, dann die nächste vorbereiten.
function frageAnfordern() {
if (pruefLoading.value) return
pruefSenden({ aktion: 'frage' }, (res) => {
frageZeigen(res.frage)
prefetchFrage()
})
}
// „Nächste Frage": vorgemerkte sofort zeigen, sonst auf den Prefetch warten, sonst frisch holen.
async function naechsteFrageZeigen() {
if (pruefLoading.value) return
if (!naechsteFrage.value && prefetchPromise) {
pruefLoading.value = true
try { await prefetchPromise } finally { pruefLoading.value = false }
}
if (naechsteFrage.value) {
const text = naechsteFrage.value
naechsteFrage.value = null
frageZeigen(text)
pruefScroll()
nextTick(() => pruefInputEl.value?.focus())
prefetchFrage()
return
}
// Fallback: kein Cache → frisch generieren.
pruefSenden({ aktion: 'frage' }, (res) => {
frageZeigen(res.frage)
prefetchFrage()
})
}
function nachfragen() {
const text = pruefInput.value.trim()
if (!text || pruefLoading.value) return
pruefMessages.value.push({ role: 'user', kind: 'nachfrage', content: text })
pruefInput.value = ''
pruefSenden(
{ aktion: 'diskussion', frage: aktuelleFrage.value, letzte_bewertung: letztesFeedback.value },
(res) => pruefMessages.value.push({ role: 'assistant', kind: 'diskussion', content: res.reply }),
)
}
function bewerten(res) {
letztesFeedback.value = res.feedback || ''
pruefMessages.value.push({ role: 'assistant', kind: 'feedback', content: res.feedback || '', bewertung: res.bewertung })
pruefPhase.value = 'bewertet'
}
function antwortPayload() {
return {
aktion: 'antwort', frage: aktuelleFrage.value, score_vor_frage: scoreVorFrage.value,
tier2: props.tier2, tier3: props.tier3,
}
}
function antwortAbgeben() {
const text = pruefInput.value.trim()
if (!text || pruefLoading.value) return
pruefMessages.value.push({ role: 'user', kind: 'antwort', content: text })
pruefInput.value = ''
pruefSenden(antwortPayload(), bewerten)
}
function neuBewerten() {
if (pruefLoading.value) return
pruefSenden(antwortPayload(), bewerten)
}
</script>
<template>
<div class="bp">
<div class="bp-toggles">
<button :class="{ active: activeTab === 'vertiefung' }" @click="toggle('vertiefung')">
Vertiefung
</button>
<button :class="{ active: activeTab === 'deepdive' }" @click="toggle('deepdive')">
Amateur
</button>
<button :class="{ active: activeTab === 'chat' }" @click="toggle('chat')">
Chat
</button>
<button :class="{ active: activeTab === 'pruefung' }" @click="toggle('pruefung')">
Prüfung
<span v-if="st.gemeistert" class="bp-chip gold" title="Gemeistert (25/25)"> Max</span>
<span v-else-if="st.verstanden && tier3" class="bp-chip lila" title="Meisterpfad"> {{ st.gute_antworten }}/{{ MEISTERN }}</span>
<span v-else-if="st.verstanden" class="bp-chip lila" title="Verstanden (10/10)"></span>
<span v-else-if="st.absolviert && tier2" class="bp-chip done"> {{ st.gute_antworten }}/{{ MAX }}</span>
<span v-else-if="st.absolviert" class="bp-chip done"></span>
<span v-else-if="st.gute_antworten" class="bp-chip">{{ Math.min(st.gute_antworten, NOETIG) }}/{{ NOETIG }}</span>
</button>
</div>
<div v-if="activeTab" class="bp-panel">
<!-- Vertiefung (gleicher Stoff, mehr) / Amateur (gleicher Stoff, für Einsteiger) -->
<div v-if="activeTab === 'vertiefung' || activeTab === 'deepdive'">
<p v-if="texte[activeTab].loading" class="bp-hint">{{ texte[activeTab].md === null ? 'Generiere' : 'Lade' }}</p>
<template v-else-if="texte[activeTab].md">
<div class="markdown" v-html="renderMarkdown(texte[activeTab].md)"></div>
<button class="bp-action" @click="generateText(activeTab)">Neu generieren</button>
</template>
<template v-else>
<p class="bp-hint">{{ activeTab === 'deepdive' ? 'Noch keine Amateur-Fassung zu diesem Baustein.' : 'Noch keine Vertiefung zu diesem Baustein.' }}</p>
<button class="bp-action" @click="generateText(activeTab)">
{{ activeTab === 'deepdive' ? 'Amateur generieren' : 'Vertiefung generieren' }}
</button>
</template>
<p v-if="texte[activeTab].error" class="bp-error">{{ texte[activeTab].error }}</p>
</div>
<!-- Bausteinchat -->
<div v-else-if="activeTab === 'chat'">
<div :ref="chat.messagesEl" class="bp-messages" @scroll="chat.onScroll">
<p v-if="!chat.messages.value.length" class="bp-hint">Frag etwas zu diesem Baustein. Der Verlauf wird nicht gespeichert.</p>
<template v-for="(m, i) in chat.messages.value" :key="i">
<div v-if="m.role === 'assistant'" class="bp-msg assistant markdown" v-html="renderMarkdown(m.content)"></div>
<div v-else class="bp-msg user">{{ m.content }}</div>
</template>
<div v-if="chat.loading.value" class="bp-msg assistant bp-typing">Denkt</div>
</div>
<div class="bp-input">
<textarea
:ref="chat.inputEl"
v-model="chat.input.value"
rows="2"
placeholder="Frage zum Baustein…"
@keydown.enter.exact.prevent="chat.send"
></textarea>
<button :disabled="!chat.input.value.trim() && !chat.loading.value" :class="{ cancel: chat.loading.value }" @click="chat.send">
{{ chat.loading.value ? '' : '' }}
</button>
</div>
</div>
<!-- Prüfung: gesteuerter Dialog -->
<div v-else>
<p class="bp-hint">
<template v-if="st.gemeistert"> Gemeistert ({{ MEISTERN }}/{{ MEISTERN }}) Max. Du kannst dich weiter prüfen, ohne Punkte.</template>
<template v-else-if="st.verstanden && tier3">Meisterpfad: {{ st.gute_antworten }}/{{ MEISTERN }}. Richtig = +1, falsch = 2 (nicht unter {{ MAX }}). Bei {{ MEISTERN }} gemeistert.</template>
<template v-else-if="st.verstanden"> Verstanden. Der Meisterpfad ({{ MAX }}{{ MEISTERN }}) öffnet, sobald der ganze Guide verstanden ist.</template>
<template v-else-if="st.absolviert && tier2">Mastery: {{ st.gute_antworten }}/{{ MAX }}. Richtig = +1, falsch = 1 (nicht unter {{ NOETIG }}). Bei {{ MAX }} verstanden.</template>
<template v-else-if="st.absolviert"> Absolviert. Mehr ({{ NOETIG }}{{ MAX }}) gibt's, sobald der ganze Guide absolviert ist.</template>
<template v-else>{{ Math.min(st.gute_antworten, NOETIG) }}/{{ NOETIG }} guten Antworten. Frag nach, wenn etwas unklar ist diskutieren ist erlaubt.</template>
</p>
<div v-if="pruefMessages.length" ref="pruefMessagesEl" class="bp-messages" @scroll="onPruefScroll">
<template v-for="(m, i) in pruefMessages" :key="i">
<div v-if="m.kind === 'feedback'" class="bp-feedback" :class="m.bewertung">{{ m.content }}</div>
<div v-else-if="m.kind === 'fehler'" class="bp-error">{{ m.content }}</div>
<div v-else-if="m.role === 'assistant'" class="bp-msg assistant markdown" v-html="renderMarkdown(m.content)"></div>
<div v-else class="bp-msg user">{{ m.content }}</div>
</template>
<div v-if="pruefLoading" class="bp-msg assistant bp-typing"></div>
</div>
<!-- Phase idle: Frage anfordern -->
<div v-if="pruefPhase === 'idle'" class="bp-actions">
<button class="bp-action primary" :disabled="pruefLoading" @click="frageAnfordern">Frage anfordern</button>
</div>
<!-- Phase frage_offen / bewertet: Textfeld + Aktionen -->
<template v-else>
<div class="bp-input">
<textarea
ref="pruefInputEl"
v-model="pruefInput"
rows="2"
:placeholder="pruefPhase === 'frage_offen' ? 'Antwort — oder Nachfrage bei Unklarheit…' : 'Nachhaken oder diskutieren…'"
></textarea>
</div>
<div class="bp-actions">
<template v-if="pruefPhase === 'frage_offen'">
<button class="bp-action" :disabled="pruefLoading || !pruefInput.trim()" @click="nachfragen">Nachfragen</button>
<button class="bp-action primary" :disabled="pruefLoading || !pruefInput.trim()" @click="antwortAbgeben">Antwort abgeben</button>
</template>
<template v-else>
<button class="bp-action" :disabled="pruefLoading || !pruefInput.trim()" @click="nachfragen">Nachhaken</button>
<button class="bp-action" :disabled="pruefLoading" @click="neuBewerten">Neu bewerten</button>
<button class="bp-action primary" :disabled="pruefLoading" @click="naechsteFrageZeigen">Nächste Frage</button>
</template>
</div>
</template>
</div>
</div>
</div>
</template>
<style scoped>
.bp { margin-top: 0.75rem; }
.bp-toggles { display: flex; gap: 0.4rem; }
.bp-toggles button {
display: inline-flex; align-items: center; gap: 0.35rem;
padding: 0.25rem 0.7rem;
font-size: 0.8rem;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--panel-soft);
color: var(--text-muted);
cursor: pointer;
}
.bp-toggles button:hover { border-color: var(--border-strong); color: var(--text); }
.bp-toggles button.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
.bp-chip {
font-size: 0.7rem;
padding: 0 0.35rem;
border-radius: 999px;
background: var(--panel);
border: 1px solid var(--border);
color: var(--text-muted);
}
.bp-chip.done { background: var(--success-soft); border-color: var(--success-border); color: var(--success); }
.bp-chip.lila { background: color-mix(in srgb, #8b5cf6 16%, var(--panel)); border-color: #8b5cf6; color: #6d28d9; }
.bp-chip.gold { background: color-mix(in srgb, #d4af37 20%, var(--panel)); border-color: #d4af37; color: #8a6d12; }
.bp-panel {
margin-top: 0.6rem;
padding: 0.75rem 0.9rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--panel-soft);
}
.bp-hint { font-size: 0.85rem; color: var(--text-muted); margin: 0 0 0.5rem; }
.bp-error { font-size: 0.85rem; color: var(--danger); margin: 0.5rem 0 0; }
.bp-action {
margin-top: 0.5rem;
padding: 0.3rem 0.8rem;
font-size: 0.8rem;
border: 1px solid var(--border-strong);
border-radius: 6px;
background: var(--panel);
color: var(--text);
cursor: pointer;
}
.bp-action:hover { border-color: var(--accent); }
.bp-action:disabled { opacity: 0.5; cursor: default; }
.bp-action.primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
.bp-action.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
.bp-actions { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.5rem; }
.bp-actions .bp-action { margin-top: 0; }
.bp-messages { display: flex; flex-direction: column; gap: 0.4rem; max-height: 320px; overflow-y: auto; }
.bp-msg {
max-width: 88%;
padding: 0.4rem 0.65rem;
border-radius: 10px;
font-size: 0.88rem;
line-height: 1.45;
overflow-wrap: anywhere;
}
.bp-msg.user { align-self: flex-end; background: var(--accent); color: var(--on-accent); white-space: pre-wrap; }
.bp-msg.assistant { align-self: flex-start; background: var(--panel); border: 1px solid var(--border); }
.bp-typing { color: var(--text-faint); font-style: italic; }
/* Bewertung der letzten Antwort — getrennt über der nächsten Frage */
.bp-feedback {
align-self: flex-start;
max-width: 88%;
padding: 0.3rem 0.6rem;
border-radius: 8px;
font-size: 0.82rem;
line-height: 1.4;
border: 1px solid var(--border);
}
.bp-feedback.gut { background: var(--success-soft); border-color: var(--success-border); color: var(--success); }
.bp-feedback.schlecht { background: var(--danger-soft, #fee2e2); border-color: var(--danger-border, #f87171); color: var(--danger); }
.bp-input { display: flex; gap: 0.4rem; margin-top: 0.55rem; align-items: flex-end; }
.bp-input textarea {
flex: 1;
resize: none;
padding: 0.45rem 0.6rem;
font: inherit;
font-size: 0.88rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--panel);
color: var(--text);
}
.bp-input button {
padding: 0.45rem 0.7rem;
border: none;
border-radius: 8px;
background: var(--accent);
color: var(--on-accent);
cursor: pointer;
}
.bp-input button:disabled { opacity: 0.5; cursor: default; }
.bp-input button.cancel { background: var(--danger); }
</style>