update
This commit is contained in:
277
frontend/src/components/BausteinPanel.vue
Normal file
277
frontend/src/components/BausteinPanel.vue
Normal file
@@ -0,0 +1,277 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { chatBaustein, createVertiefung, fetchVertiefung, pruefeBaustein } from '../api.js'
|
||||
import { renderMarkdown } from '../markdown.js'
|
||||
import { useChat } 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, vertiefung}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['statusChanged'])
|
||||
|
||||
const NOETIG = 3
|
||||
const st = computed(() => props.status || { gute_antworten: 0, absolviert: false, vertiefung: false })
|
||||
|
||||
// --- Toggle-Bereich ---
|
||||
const activeTab = ref(null) // null | 'vertiefung' | 'chat' | 'pruefung'
|
||||
|
||||
function toggle(tab) {
|
||||
activeTab.value = activeTab.value === tab ? null : tab
|
||||
if (activeTab.value === 'vertiefung') openVertiefung()
|
||||
if (activeTab.value === 'pruefung') startPruefung()
|
||||
}
|
||||
|
||||
// --- Vertiefung (persistiert) ---
|
||||
const vert = ref(null)
|
||||
const vertLoading = ref(false)
|
||||
const vertError = ref('')
|
||||
|
||||
async function openVertiefung() {
|
||||
if (vert.value !== null || vertLoading.value || !st.value.vertiefung) return
|
||||
vertLoading.value = true
|
||||
vertError.value = ''
|
||||
try {
|
||||
vert.value = (await fetchVertiefung(props.topic, props.baustein)).md
|
||||
} catch (e) {
|
||||
vertError.value = e.message
|
||||
} finally {
|
||||
vertLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function generateVertiefung() {
|
||||
vertLoading.value = true
|
||||
vertError.value = ''
|
||||
try {
|
||||
vert.value = (await createVertiefung({
|
||||
topic: props.topic, baustein: props.baustein, section: props.section, provider: props.provider,
|
||||
})).md
|
||||
emit('statusChanged', { ...st.value, vertiefung: true })
|
||||
} catch (e) {
|
||||
vertError.value = e.message
|
||||
} finally {
|
||||
vertLoading.value = 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 (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)
|
||||
|
||||
function applyPruefung(res) {
|
||||
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
|
||||
try {
|
||||
const res = await pruefeBaustein({
|
||||
topic: props.topic, baustein: props.baustein, section: props.section,
|
||||
messages: [], provider: props.provider,
|
||||
})
|
||||
pruefung.messages.value.push({ role: 'assistant', content: res.reply })
|
||||
applyPruefung(res)
|
||||
nextTick(() => pruefung.inputEl.value?.focus())
|
||||
} catch {
|
||||
pruefung.messages.value.push({ role: 'assistant', content: 'Fehler beim Start der Prüfung — Tab erneut öffnen.' })
|
||||
} finally {
|
||||
startLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bp">
|
||||
<div class="bp-toggles">
|
||||
<button :class="{ active: activeTab === 'vertiefung' }" @click="toggle('vertiefung')">
|
||||
Vertiefung
|
||||
</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.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 -->
|
||||
<div v-if="activeTab === 'vertiefung'">
|
||||
<p v-if="vertLoading" class="bp-hint">{{ vert === null ? 'Generiere Vertiefung…' : 'Lade…' }}</p>
|
||||
<template v-else-if="vert">
|
||||
<div class="markdown" v-html="renderMarkdown(vert)"></div>
|
||||
<button class="bp-action" @click="generateVertiefung">Neu generieren</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="bp-hint">Noch keine Vertiefung zu diesem Baustein.</p>
|
||||
<button class="bp-action" @click="generateVertiefung">Vertiefung generieren</button>
|
||||
</template>
|
||||
<p v-if="vertError" class="bp-error">{{ vertError }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Bausteinchat -->
|
||||
<div v-else-if="activeTab === 'chat'">
|
||||
<div :ref="chat.messagesEl" class="bp-messages">
|
||||
<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 -->
|
||||
<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>
|
||||
</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">
|
||||
<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="pruefung.loading.value" class="bp-msg assistant bp-typing">Bewertet…</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>
|
||||
</div>
|
||||
</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-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-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; }
|
||||
|
||||
.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>
|
||||
Reference in New Issue
Block a user