update
This commit is contained in:
26
frontend/package-lock.json
generated
26
frontend/package-lock.json
generated
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"dompurify": "^3.4.7",
|
||||
"highlight.js": "^11.11.1",
|
||||
"katex": "^0.17.0",
|
||||
"marked": "^18.0.4",
|
||||
"marked-highlight": "^2.2.4",
|
||||
"vue": "^3.5.32"
|
||||
@@ -1187,6 +1188,15 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
|
||||
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
@@ -1468,6 +1478,22 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/katex": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/katex/-/katex-0.17.0.tgz",
|
||||
"integrity": "sha512-Vdw0ATsQ9V+LuegM/BTwQqV/6cTl5lbGcIrU+BCgLxyf6bo38ybOr372tuSIxir3CN720flu1meYR6XzNMwQnw==",
|
||||
"funding": [
|
||||
"https://opencollective.com/katex",
|
||||
"https://github.com/sponsors/katex"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"commander": "^8.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"katex": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/kolorist": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"dependencies": {
|
||||
"dompurify": "^3.4.7",
|
||||
"highlight.js": "^11.11.1",
|
||||
"katex": "^0.17.0",
|
||||
"marked": "^18.0.4",
|
||||
"marked-highlight": "^2.2.4",
|
||||
"vue": "^3.5.32"
|
||||
|
||||
@@ -91,11 +91,14 @@ export async function chatBaustein({ topic, baustein, section, messages, provide
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function pruefeBaustein({ topic, baustein, section, messages, provider }) {
|
||||
export async function pruefeBaustein({
|
||||
topic, baustein, section, provider,
|
||||
aktion = 'frage', frage = '', letzte_bewertung = '', frage_schon_gut = false, messages = [],
|
||||
}) {
|
||||
const res = await fetch(`${BASE}/bausteine/pruefung`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, baustein, section, messages, provider }),
|
||||
body: JSON.stringify({ topic, baustein, section, aktion, frage, letzte_bewertung, frage_schon_gut, messages, provider }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,13 @@
|
||||
margin: 0 0 0.5em;
|
||||
}
|
||||
|
||||
/* KaTeX: lange Block-Formeln scrollen statt das Layout zu sprengen */
|
||||
.markdown .katex-display {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
padding: 0.2em 0;
|
||||
}
|
||||
|
||||
.markdown p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ const activeTab = ref(null) // null | 'vertiefung' | 'deepdive' | 'chat' | 'prue
|
||||
function toggle(tab) {
|
||||
activeTab.value = activeTab.value === tab ? null : tab
|
||||
if (activeTab.value === 'vertiefung' || activeTab.value === 'deepdive') openText(activeTab.value)
|
||||
if (activeTab.value === 'pruefung') startPruefung()
|
||||
}
|
||||
|
||||
// --- Vertiefung (kurz) + Deep Dive (lang), beide persistiert ---
|
||||
@@ -68,42 +67,97 @@ const chat = useChat((msgs) => chatBaustein({
|
||||
messages: msgs, provider: props.provider,
|
||||
}))
|
||||
|
||||
// --- Prüfung (Verlauf flüchtig, Zähler serverseitig) ---
|
||||
const pruefung = useChat(async (msgs) => {
|
||||
const res = await pruefeBaustein({
|
||||
topic: props.topic, baustein: props.baustein, section: props.section,
|
||||
messages: msgs, provider: props.provider,
|
||||
})
|
||||
applyPruefung(res)
|
||||
return res
|
||||
})
|
||||
const startLoading = ref(false)
|
||||
// --- 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 frageSchonGut = ref(false) // diese Frage schon "gut" → nicht doppelt zählen
|
||||
const pruefMessagesEl = ref(null)
|
||||
const pruefInputEl = ref(null)
|
||||
let pruefRun = 0
|
||||
|
||||
function applyPruefung(res) {
|
||||
emit('statusChanged', {
|
||||
...st.value,
|
||||
gute_antworten: res.gute_antworten,
|
||||
absolviert: res.absolviert,
|
||||
})
|
||||
emit('statusChanged', { ...st.value, gute_antworten: res.gute_antworten, absolviert: res.absolviert })
|
||||
}
|
||||
|
||||
async function startPruefung() {
|
||||
if (pruefung.messages.value.length || startLoading.value) return
|
||||
startLoading.value = true
|
||||
async function pruefScroll() {
|
||||
await nextTick()
|
||||
if (pruefMessagesEl.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
|
||||
pruefLoading.value = true
|
||||
pruefScroll()
|
||||
try {
|
||||
const res = await pruefeBaustein({
|
||||
topic: props.topic, baustein: props.baustein, section: props.section,
|
||||
messages: [], provider: props.provider,
|
||||
provider: props.provider, messages: pruefDialog(), ...payload,
|
||||
})
|
||||
pruefung.messages.value.push({ role: 'assistant', content: res.frage, feedback: null })
|
||||
if (run !== pruefRun) return
|
||||
onOk(res)
|
||||
applyPruefung(res)
|
||||
nextTick(() => pruefung.inputEl.value?.focus())
|
||||
pruefScroll()
|
||||
nextTick(() => pruefInputEl.value?.focus())
|
||||
} catch {
|
||||
pruefung.messages.value.push({ role: 'assistant', content: 'Fehler beim Start der Prüfung — Tab erneut öffnen.' })
|
||||
if (run === pruefRun) pruefMessages.value.push({ role: 'assistant', kind: 'fehler', content: 'Hat nicht geklappt — bitte erneut.' })
|
||||
} finally {
|
||||
startLoading.value = false
|
||||
if (run === pruefRun) pruefLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function frageAnfordern() {
|
||||
if (pruefLoading.value) return
|
||||
pruefSenden({ aktion: 'frage' }, (res) => {
|
||||
aktuelleFrage.value = res.frage
|
||||
letztesFeedback.value = ''
|
||||
frageSchonGut.value = false
|
||||
pruefMessages.value.push({ role: 'assistant', kind: 'frage', content: res.frage })
|
||||
pruefPhase.value = 'frage_offen'
|
||||
})
|
||||
}
|
||||
|
||||
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 || ''
|
||||
if (res.bewertung === 'gut') frageSchonGut.value = true
|
||||
pruefMessages.value.push({ role: 'assistant', kind: 'feedback', content: res.feedback || '', bewertung: res.bewertung })
|
||||
pruefPhase.value = 'bewertet'
|
||||
}
|
||||
|
||||
function antwortAbgeben() {
|
||||
const text = pruefInput.value.trim()
|
||||
if (!text || pruefLoading.value) return
|
||||
pruefMessages.value.push({ role: 'user', kind: 'antwort', content: text })
|
||||
pruefInput.value = ''
|
||||
pruefSenden({ aktion: 'antwort', frage: aktuelleFrage.value, frage_schon_gut: frageSchonGut.value }, bewerten)
|
||||
}
|
||||
|
||||
function neuBewerten() {
|
||||
if (pruefLoading.value) return
|
||||
pruefSenden({ aktion: 'antwort', frage: aktuelleFrage.value, frage_schon_gut: frageSchonGut.value }, bewerten)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -166,35 +220,50 @@ async function startPruefung() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Prüfung -->
|
||||
<!-- Prüfung: gesteuerter Dialog -->
|
||||
<div v-else>
|
||||
<p class="bp-hint">
|
||||
<template v-if="st.absolviert">✓ Absolviert — du kannst dich weiter prüfen lassen.</template>
|
||||
<template v-else>{{ Math.min(st.gute_antworten, NOETIG) }}/{{ NOETIG }} guten Antworten. Erkläre in eigenen Worten — das Material darfst du nutzen.</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 :ref="pruefung.messagesEl" class="bp-messages">
|
||||
<div v-if="startLoading" class="bp-msg assistant bp-typing">Erste Frage kommt…</div>
|
||||
<template v-for="(m, i) in pruefung.messages.value" :key="i">
|
||||
<template v-if="m.role === 'assistant'">
|
||||
<div v-if="m.feedback" class="bp-feedback" :class="m.bewertung">{{ m.feedback }}</div>
|
||||
<div class="bp-msg assistant markdown" v-html="renderMarkdown(m.content)"></div>
|
||||
</template>
|
||||
|
||||
<div v-if="pruefMessages.length" :ref="pruefMessagesEl" class="bp-messages">
|
||||
<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="pruefung.loading.value" class="bp-msg assistant bp-typing">Bewertet…</div>
|
||||
<div v-if="pruefLoading" class="bp-msg assistant bp-typing">…</div>
|
||||
</div>
|
||||
<div class="bp-input">
|
||||
<textarea
|
||||
:ref="pruefung.inputEl"
|
||||
v-model="pruefung.input.value"
|
||||
rows="2"
|
||||
placeholder="Deine Erklärung…"
|
||||
@keydown.enter.exact.prevent="pruefung.send"
|
||||
></textarea>
|
||||
<button :disabled="!pruefung.input.value.trim() && !pruefung.loading.value" :class="{ cancel: pruefung.loading.value }" @click="pruefung.send">
|
||||
{{ pruefung.loading.value ? '✕' : '➤' }}
|
||||
</button>
|
||||
|
||||
<!-- 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="frageAnfordern">Nächste Frage</button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -249,6 +318,12 @@ async function startPruefung() {
|
||||
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 {
|
||||
|
||||
@@ -2,6 +2,8 @@ import { marked } from 'marked'
|
||||
import { markedHighlight } from 'marked-highlight'
|
||||
import hljs from 'highlight.js'
|
||||
import 'highlight.js/styles/github-dark.css'
|
||||
import katex from 'katex'
|
||||
import 'katex/dist/katex.min.css'
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
marked.use(markedHighlight({
|
||||
@@ -15,6 +17,40 @@ marked.use(markedHighlight({
|
||||
}))
|
||||
marked.setOptions({ breaks: true, gfm: true })
|
||||
|
||||
// LaTeX-Mathe via KaTeX. Eigene marked-Extensions (statt marked-katex-extension,
|
||||
// die marked v18 hinterherhinkt). marked tokenisiert Code zuerst → $…$ in Code-
|
||||
// Blöcken wird NICHT als Mathe erkannt. throwOnError:false zeigt defektes TeX rot.
|
||||
function renderTex(tex, displayMode) {
|
||||
return katex.renderToString(tex, { displayMode, throwOnError: false, output: 'html' })
|
||||
}
|
||||
|
||||
const blockMath = {
|
||||
name: 'blockMath',
|
||||
level: 'block',
|
||||
start(src) { const i = src.indexOf('$$'); return i < 0 ? undefined : i },
|
||||
tokenizer(src) {
|
||||
const m = /^\$\$([\s\S]+?)\$\$/.exec(src)
|
||||
if (m) return { type: 'blockMath', raw: m[0], text: m[1].trim() }
|
||||
},
|
||||
renderer(token) { return renderTex(token.text, true) },
|
||||
}
|
||||
|
||||
const inlineMath = {
|
||||
name: 'inlineMath',
|
||||
level: 'inline',
|
||||
start(src) { const i = src.indexOf('$'); return i < 0 ? undefined : i },
|
||||
tokenizer(src) {
|
||||
// $…$: kein $$, kein Leerzeichen direkt hinter dem öffnenden $ und vor dem
|
||||
// schließenden $ (pandoc-Stil) → mindert Kollisionen mit Fließtext-Dollarzeichen.
|
||||
const m = /^\$(?![\s$])((?:\\\$|[^$])+?)\$/.exec(src)
|
||||
if (!m || /\s$/.test(m[1])) return
|
||||
return { type: 'inlineMath', raw: m[0], text: m[1].trim() }
|
||||
},
|
||||
renderer(token) { return renderTex(token.text, false) },
|
||||
}
|
||||
|
||||
marked.use({ extensions: [blockMath, inlineMath] })
|
||||
|
||||
// Rohes HTML im Markdown (z. B. <p>, <img> ohne Backticks aus Agenten-Output)
|
||||
// als Text anzeigen statt rendern — sonst verschluckt der Browser den Inhalt.
|
||||
marked.use({
|
||||
|
||||
Reference in New Issue
Block a user