Files
creator/frontend/src/components/BausteinPanel.vue
2026-06-18 11:46:09 +02:00

620 lines
27 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, onMounted, onUnmounted, ref, watch } from 'vue'
import { chatBaustein, createVertiefung, fetchVertiefung, pruefeBaustein } from '../api.js'
import { getPruef, setPruef } from '../pruefungCache.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)
// 'trigger' (Liste): nur Tab-Leiste, Klick öffnet den Vollbild-Fokus.
// 'full' (Fokus): Tabs + Panel-Inhalt wie gehabt.
mode: { type: String, default: 'trigger' },
initialTab: { type: String, default: null },
})
const emit = defineEmits(['statusChanged', 'openFokus'])
const NOETIG = 3 // absolviert
const MAX = 10 // verstanden
const MEISTERN = 25 // gemeistert (Maximum)
const st = computed(() => props.status || { gute_antworten: 0, streak: 0, absolviert: false, verstanden: false, gemeistert: false, vertiefung: false, deepdive: false })
// --- Toggle-Bereich ---
const activeTab = ref(props.mode === 'full' ? props.initialTab : null) // null | 'vertiefung' | 'deepdive' | 'chat' | 'pruefung'
// Klick auf einen Tab: in der Liste den Fokus öffnen, im Fokus den Tab umschalten.
function tabKlick(tab) {
if (props.mode === 'trigger') { emit('openFokus', tab); return }
toggle(tab)
}
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 streakVorFrage = ref(0) // Streak, als die aktuelle Frage gestellt wurde (driftfrei)
// Die aktuelle Streak lebt persistent in st.value.streak (DB → props.status, round-trip via applyPruefung).
const naechsteFrage = ref(null) // im Hintergrund vorbereitete nächste Frage (Prefetch)
let prefetchPromise = null // laufender Hintergrund-Abruf (verhindert Doppel-Prefetch)
// Session-Cache: Prüfungs-Verlauf pro Baustein über Remounts hinweg halten (nur im Fokus,
// dort läuft die Prüfung). Wiederherstellen beim Mount, bei jeder Änderung sichern.
if (props.mode === 'full') {
const pruefKey = `${props.topic}::${props.baustein}`
const c = getPruef(pruefKey)
if (c) {
pruefMessages.value = c.messages
pruefPhase.value = c.phase
aktuelleFrage.value = c.aktuelleFrage
letztesFeedback.value = c.letztesFeedback
scoreVorFrage.value = c.scoreVorFrage
streakVorFrage.value = c.streakVorFrage
}
watch(
[pruefMessages, pruefPhase, aktuelleFrage, letztesFeedback, scoreVorFrage, streakVorFrage],
() => setPruef(pruefKey, {
messages: pruefMessages.value, phase: pruefPhase.value,
aktuelleFrage: aktuelleFrage.value, letztesFeedback: letztesFeedback.value,
scoreVorFrage: scoreVorFrage.value, streakVorFrage: streakVorFrage.value,
}),
{ deep: true },
)
}
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) {
// res.streak nur bei antwort_pruefen gesetzt (verbindlich, persistiert); sonst Streak unverändert lassen.
emit('statusChanged', { ...st.value, gute_antworten: res.gute_antworten, streak: res.streak ?? st.value.streak, 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
streakVorFrage.value = st.value.streak
pruefMessages.value.push({ role: 'assistant', kind: 'frage', content: text })
pruefPhase.value = 'frage_offen'
}
// Dedup: schon gestellte Fragen erkennen (Generator wiederholt sich auf kleinen Bausteinen).
function normFrage(t) { return (t || '').toLowerCase().replace(/\s+/g, ' ').trim() }
function gestellteFragen() {
return new Set(pruefMessages.value.filter((m) => m.kind === 'frage').map((m) => normFrage(m.content)))
}
// Holt eine Frage; bis zu 3 Versuche, eine NICHT wiederholte zu bekommen. Sonst best-effort die letzte.
async function frageHolen() {
const gestellt = gestellteFragen()
let letzte = null
for (let i = 0; i < 3; i++) {
const res = await pruefeBaustein({
topic: props.topic, baustein: props.baustein, section: props.section,
provider: props.provider, aktion: 'frage', messages: pruefDialog(),
})
letzte = res.frage
if (!gestellt.has(normFrage(res.frage))) return res.frage
}
return letzte
}
// Nächste Frage im Hintergrund vorbereiten (überbrückt die Wartezeit). Ohne pruefLoading/pruefRun.
function prefetchFrage() {
if (naechsteFrage.value || prefetchPromise) return
prefetchPromise = frageHolen()
.then((text) => { naechsteFrage.value = text })
.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 holen (mit Dedup).
pruefLoading.value = true
try {
const text = await frageHolen()
if (text) {
frageZeigen(text)
pruefScroll()
nextTick(() => pruefInputEl.value?.focus())
prefetchFrage()
} else {
pruefMessages.value.push({ role: 'assistant', kind: 'fehler', content: 'Keine neue Frage — bitte erneut.' })
}
} catch {
pruefMessages.value.push({ role: 'assistant', kind: 'fehler', content: 'Hat nicht geklappt — bitte erneut.' })
} finally {
pruefLoading.value = false
}
}
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 }),
)
}
let letzteFeedbackMsg = null // Referenz auf die zuletzt gezeigte (provisorische) Bewertungs-Bubble
let pruefenRun = 0 // nur die jüngste Hintergrund-Prüfung darf die Anzeige korrigieren
function bewerten(res) {
letztesFeedback.value = res.feedback || ''
pruefMessages.value.push({ role: 'assistant', kind: 'feedback', content: res.feedback || '', bewertung: res.bewertung, geprueft: false })
letzteFeedbackMsg = pruefMessages.value[pruefMessages.value.length - 1]
pruefPhase.value = 'bewertet'
verdictPruefen() // im Hintergrund verbindlich prüfen lassen
}
function antwortPayload() {
return {
aktion: 'antwort', frage: aktuelleFrage.value, score_vor_frage: scoreVorFrage.value,
streak: streakVorFrage.value, tier2: props.tier2, tier3: props.tier3,
}
}
// Hintergrund: voller Bewerter+Kritiker. Persistiert serverseitig; korrigiert die Anzeige bei Abweichung.
// gruendlich=true: starkes Modell (role guide), manuell ausgelöst → sichtbarer Ladezustand.
async function verdictPruefen(gruendlich = false) {
const meine = ++pruefenRun
const ziel = letzteFeedbackMsg
if (gruendlich) pruefLoading.value = true
try {
const res = await pruefeBaustein({
topic: props.topic, baustein: props.baustein, section: props.section,
provider: props.provider, messages: pruefDialog(), ...antwortPayload(), aktion: 'antwort_pruefen', gruendlich,
})
if (meine !== pruefenRun) return // eine neuere Bewertung läuft → diese verwerfen
// Streak kommt verbindlich vom Backend (persistiert) und fließt über applyPruefung in st.
applyPruefung(res)
if (ziel) {
if (res.bewertung !== ziel.bewertung || (res.feedback && res.feedback !== ziel.content)) {
ziel.content = res.feedback || ziel.content
ziel.bewertung = res.bewertung
}
ziel.geprueft = true
}
} catch {
if (meine === pruefenRun && ziel) ziel.geprueft = true
} finally {
if (gruendlich && meine === pruefenRun) pruefLoading.value = false
}
}
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)
}
// Lief schon eine Diskussion? Dann darf der Verlauf direkt bewertet werden.
const hatDiskussion = computed(() => pruefMessages.value.some((m) => m.kind === 'nachfrage' || m.kind === 'diskussion'))
// „Verlauf bewerten": den bisherigen Dialog als Antwort werten (Feld wird ignoriert).
function verlaufBewerten() {
if (pruefLoading.value) return
pruefSenden(antwortPayload(), bewerten)
}
// „Gründlich prüfen": dieselbe Antwort, aber vom starken Modell (role guide) verbindlich neu bewerten.
function gruendlichPruefen() {
if (pruefLoading.value) return
verdictPruefen(true)
}
// Abbruch wie im Chat: Run-Counter hochzählen → laufendes Ergebnis (pruefSenden
// und verdictPruefen) wird verworfen, Buttons sofort frei. Agent läuft serverseitig aus.
function pruefAbbrechen() {
if (!pruefLoading.value) return
pruefRun++
pruefenRun++
pruefLoading.value = false
pruefMessages.value.push({ role: 'assistant', kind: 'fehler', content: 'Abgebrochen.' })
}
// ESC bricht ab — window-Listener, da nach Button-Klick der Fokus das Panel verlässt.
function onWindowKey(e) {
if (e.key === 'Escape' && pruefLoading.value) { e.preventDefault(); pruefAbbrechen() }
}
onMounted(() => {
if (props.mode !== 'full') return // Trigger-Modus (Liste): nur Tab-Leiste, keine Prüfungs-/Text-Logik
window.addEventListener('keydown', onWindowKey)
window.addEventListener('keydown', onPruefKey)
// Direkt im Fokus geöffneter Vertiefung-/Amateur-Tab: Text laden (sonst nur via toggle).
if (activeTab.value === 'vertiefung' || activeTab.value === 'deepdive') openText(activeTab.value)
})
onUnmounted(() => {
window.removeEventListener('keydown', onWindowKey)
window.removeEventListener('keydown', onPruefKey)
})
// ALT+1 Nachfragen/Nachhaken · ALT+2 primärer Button je Phase (idle: Frage anfordern,
// frage_offen: Antwort abgeben, bewertet: Nächste Frage) · ALT+3 Verlauf bewerten / Gründlich.
// window-Listener (nicht div), damit es auch ohne Fokus im Feld greift + den Browser-Default
// (Tab-Wechsel) unterdrückt. Nur im Prüfungs-Tab.
function onPruefKey(e) {
if (!e.altKey || e.repeat) return
if (props.mode !== 'full' || activeTab.value !== 'pruefung') return
if (!['1', '2', '3'].includes(e.key)) return
e.preventDefault()
if (pruefLoading.value) return
if (e.key === '1') {
if (pruefPhase.value !== 'idle' && pruefInput.value.trim()) nachfragen()
} else if (e.key === '2') {
if (pruefPhase.value === 'idle') frageAnfordern()
else if (pruefPhase.value === 'frage_offen') { if (pruefInput.value.trim()) antwortAbgeben() }
else naechsteFrageZeigen()
} else if (e.key === '3') {
if (pruefPhase.value === 'bewertet') gruendlichPruefen()
else if (pruefPhase.value === 'frage_offen' && hatDiskussion.value) verlaufBewerten()
}
}
</script>
<template>
<div class="bp">
<div class="bp-toggles">
<button :class="{ active: activeTab === 'vertiefung' }" @click="tabKlick('vertiefung')">
Vertiefung
</button>
<button :class="{ active: activeTab === 'deepdive' }" @click="tabKlick('deepdive')">
Amateur
</button>
<button :class="{ active: activeTab === 'chat' }" @click="tabKlick('chat')">
Chat
</button>
<button :class="{ active: activeTab === 'pruefung' }" @click="tabKlick('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="mode === 'full' && 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 }}<span v-if="!m.geprueft" class="bp-pruefend"> · wird geprüft</span></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 v-if="pruefLoading" class="bp-action cancel" title="ESC" @click="pruefAbbrechen">Abbrechen</button>
<button v-else class="bp-action primary" @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">
<button v-if="pruefLoading" class="bp-action cancel" title="ESC" @click="pruefAbbrechen">Abbrechen</button>
<template v-else-if="pruefPhase === 'frage_offen'">
<button class="bp-action" title="Alt+1" :disabled="!pruefInput.trim()" @click="nachfragen"><span class="bp-kbd">1</span>Nachfragen</button>
<button class="bp-action primary" title="Alt+2" :disabled="!pruefInput.trim()" @click="antwortAbgeben"><span class="bp-kbd">2</span>Antwort abgeben</button>
<button v-if="hatDiskussion" class="bp-action" title="Alt+3" @click="verlaufBewerten"><span class="bp-kbd">3</span>Verlauf bewerten</button>
</template>
<template v-else>
<button class="bp-action" title="Alt+1" :disabled="!pruefInput.trim()" @click="nachfragen"><span class="bp-kbd">1</span>Nachhaken</button>
<button class="bp-action primary" title="Alt+2" @click="naechsteFrageZeigen"><span class="bp-kbd">2</span>Nächste Frage</button>
<button class="bp-action" title="Alt+3" @click="gruendlichPruefen"><span class="bp-kbd">3</span>Gründlich prüfen</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-action.cancel { background: var(--danger); border-color: var(--danger); color: var(--on-accent); }
.bp-actions { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.5rem; }
.bp-actions .bp-action { margin-top: 0; }
.bp-kbd {
display: inline-flex; align-items: center; justify-content: center;
min-width: 1rem; height: 1rem; padding: 0 0.2rem; margin-right: 0.35rem;
font-size: 0.65rem; font-weight: 600; border-radius: 3px;
background: var(--panel); border: 1px solid var(--border); color: var(--text-muted);
}
.bp-action.primary .bp-kbd { background: color-mix(in srgb, var(--on-accent) 20%, transparent); border-color: transparent; color: var(--on-accent); }
.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-pruefend { font-style: italic; opacity: 0.7; font-size: 0.92em; }
.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>