447 lines
18 KiB
Vue
447 lines
18 KiB
Vue
<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>
|