Source files (src/) and rendered bundle (www/) extracted on 2026-04-29T01:35:46+02:00. Adds nginx:alpine Dockerfile + docker-compose.yml (Caddy-labels) so the bot runs stand-alone or as a per-customer template clone. Parent monorepo commit: d2c816f3edbc9760802a11b29ff4151c7aad4b46 Bot version: 2026-04-21
1750 lines
84 KiB
JavaScript
1750 lines
84 KiB
JavaScript
/* Cora — DSGVO Compliance Companion Widget
|
||
* Vanilla JS, no build, no framework, keine externen Fonts/Analytics.
|
||
* Chat | Quiz | Flashcards | Fortschritt | Artikel-Library — localStorage only.
|
||
*/
|
||
(() => {
|
||
'use strict';
|
||
|
||
// ==== Config ====
|
||
const API = 'https://llm.qognio.com/api/bots/dsgvo-trainer/chat';
|
||
const RAW_KEY = window.__CORA_KEY__ || '';
|
||
const KEY = /^qb_[a-zA-Z0-9]{6,}$/.test(RAW_KEY) ? RAW_KEY : '';
|
||
const LS_KEY = 'cora.state.v1';
|
||
const LS_CHAT = 'cora.chat.v1';
|
||
const LS_FLASH = 'cora.flash.v1';
|
||
|
||
// ==== State ====
|
||
let CURRICULA = null;
|
||
let state = loadState();
|
||
let chatHistory = loadChatHistory();
|
||
let flashCards = loadFlashCards(); // { [topicId]: [{front, back, hint, ef, interval, due, reps}] }
|
||
|
||
// ==== Utils ====
|
||
const $ = (s, r = document) => r.querySelector(s);
|
||
const $$ = (s, r = document) => Array.from(r.querySelectorAll(s));
|
||
const now = () => Date.now();
|
||
const today = () => {
|
||
const d = new Date();
|
||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||
};
|
||
function daysBetween(dateA, dateB) {
|
||
const a = new Date(dateA); const b = new Date(dateB);
|
||
return Math.round((b - a) / 86400000);
|
||
}
|
||
|
||
// ==== Persistence ====
|
||
function loadState() {
|
||
try {
|
||
const s = JSON.parse(localStorage.getItem(LS_KEY) || '{}');
|
||
return {
|
||
xp: s.xp || 0,
|
||
totalAnswers: s.totalAnswers || 0,
|
||
correctAnswers: s.correctAnswers || 0,
|
||
currentStreak: s.currentStreak || 0,
|
||
maxStreak: s.maxStreak || 0,
|
||
lastActive: s.lastActive || null,
|
||
quizStreak: s.quizStreak || 0,
|
||
maxQuizStreak: s.maxQuizStreak || 0,
|
||
mastery: s.mastery || {}, // { [curriculumId]: { correct, total } }
|
||
moduleCorrect: s.moduleCorrect || {}, // { [moduleId]: correctCount }
|
||
moduleTotal: s.moduleTotal || {}, // { [moduleId]: totalAnswered }
|
||
modulePassedFlash: s.modulePassedFlash || {}, // { [moduleId]: true when all cards rated >= "gut"}
|
||
completedQuizzes: s.completedQuizzes || 0,
|
||
flashCardsRated: s.flashCardsRated || 0,
|
||
badges: s.badges || {},
|
||
seenWelcome: s.seenWelcome || false,
|
||
completedCurricula: s.completedCurricula || []
|
||
};
|
||
} catch (e) {
|
||
return {
|
||
xp: 0, totalAnswers: 0, correctAnswers: 0,
|
||
currentStreak: 0, maxStreak: 0, lastActive: null,
|
||
quizStreak: 0, maxQuizStreak: 0, mastery: {},
|
||
moduleCorrect: {}, moduleTotal: {}, modulePassedFlash: {},
|
||
completedQuizzes: 0, flashCardsRated: 0,
|
||
badges: {}, seenWelcome: false, completedCurricula: []
|
||
};
|
||
}
|
||
}
|
||
function saveState() { localStorage.setItem(LS_KEY, JSON.stringify(state)); }
|
||
|
||
function loadChatHistory() {
|
||
try { return JSON.parse(localStorage.getItem(LS_CHAT) || '[]'); } catch (e) { return []; }
|
||
}
|
||
function saveChatHistory() {
|
||
// Cap at last 40 exchanges to keep API payload small
|
||
const trimmed = chatHistory.slice(-40);
|
||
localStorage.setItem(LS_CHAT, JSON.stringify(trimmed));
|
||
}
|
||
|
||
function loadFlashCards() {
|
||
try { return JSON.parse(localStorage.getItem(LS_FLASH) || '{}'); } catch (e) { return {}; }
|
||
}
|
||
function saveFlashCards() { localStorage.setItem(LS_FLASH, JSON.stringify(flashCards)); }
|
||
|
||
// ==== Streak / activity tracking ====
|
||
function touchActivity() {
|
||
const t = today();
|
||
if (state.lastActive === t) return;
|
||
if (state.lastActive) {
|
||
const diff = daysBetween(state.lastActive, t);
|
||
if (diff === 1) state.currentStreak += 1;
|
||
else if (diff > 1) state.currentStreak = 1;
|
||
else state.currentStreak = Math.max(1, state.currentStreak);
|
||
} else {
|
||
state.currentStreak = 1;
|
||
}
|
||
state.lastActive = t;
|
||
if (state.currentStreak > state.maxStreak) state.maxStreak = state.currentStreak;
|
||
checkBadges();
|
||
saveState();
|
||
}
|
||
|
||
// ==== XP / Level ====
|
||
function addXP(n, reason = '') {
|
||
state.xp += n;
|
||
saveState();
|
||
showXPGain(`+${n} XP${reason ? ' · ' + reason : ''}`);
|
||
}
|
||
function levelInfo() {
|
||
const levels = (CURRICULA && CURRICULA.levels) || [
|
||
{ min: 0, title: 'Azubi' }, { min: 50, title: 'Mitarbeiter:in' },
|
||
{ min: 200, title: 'Key-User:in' }, { min: 500, title: 'Compliance-Expert:in' },
|
||
{ min: 1250, title: 'DSB-Kandidat:in' }, { min: 2500, title: 'Datenschutzbeauftragte:r' },
|
||
{ min: 5000, title: 'Senior-DSB' }
|
||
];
|
||
let cur = levels[0];
|
||
for (const l of levels) if (state.xp >= l.min) cur = l;
|
||
const idx = levels.indexOf(cur);
|
||
const next = levels[idx + 1] || null;
|
||
const pct = next ? Math.min(100, ((state.xp - cur.min) / (next.min - cur.min)) * 100) : 100;
|
||
return { levelNum: idx + 1, title: cur.title, pct, next };
|
||
}
|
||
|
||
// ==== Badges ====
|
||
function unlockBadge(id) {
|
||
if (state.badges[id]) return false;
|
||
state.badges[id] = today();
|
||
saveState();
|
||
const badge = (CURRICULA && CURRICULA.badges || []).find(b => b.id === id);
|
||
if (badge) toast('🏆 Neues Abzeichen: ' + badge.title, 'success', 4500);
|
||
return true;
|
||
}
|
||
function checkBadges() {
|
||
// Erste Löschanfrage — 1 korrekte Antwort im Lösch-Modul
|
||
if ((state.moduleCorrect && state.moduleCorrect['loeschung'] >= 1)) unlockBadge('erste_loeschanfrage');
|
||
// AVV-Detektiv — 10 AVV-Fragen korrekt
|
||
if ((state.moduleCorrect && state.moduleCorrect['avv'] >= 10)) unlockBadge('avv_detektiv');
|
||
// 72-Stunden-Held — alle Datenpannen-Flashcards bestanden
|
||
if ((state.modulePassedFlash && state.modulePassedFlash['datenpannen'])) unlockBadge('meldepflicht');
|
||
// Betriebsrat-Flüsterer — HR-Module komplett (3 Quiz korrekt in jedem)
|
||
if ((state.moduleCorrect &&
|
||
(state.moduleCorrect['bewerbung'] || 0) >= 3 &&
|
||
(state.moduleCorrect['personalakte'] || 0) >= 3 &&
|
||
(state.moduleCorrect['betriebsrat'] || 0) >= 3)) unlockBadge('betriebsrat');
|
||
// DSGVO-Master — 20 von 26 Modulen mit ≥80% Quiz-Score abgeschlossen (~77% Master-Coverage)
|
||
if ((state.completedCurricula || []).length >= 20) unlockBadge('dsgvo_master');
|
||
// Compliance-Disziplin — 30-Tage-Streak
|
||
if (state.maxStreak >= 30) unlockBadge('streak_30');
|
||
// Night Owl & Early Bird (beibehalten)
|
||
const h = new Date().getHours();
|
||
if (h >= 22) unlockBadge('night_owl');
|
||
if (h < 7) unlockBadge('early_bird');
|
||
}
|
||
|
||
// ==== Toast ====
|
||
function toast(msg, kind = '', ms = 3200) {
|
||
const stack = $('#toast-stack');
|
||
const t = document.createElement('div');
|
||
t.className = 'toast ' + kind;
|
||
t.textContent = msg;
|
||
stack.appendChild(t);
|
||
setTimeout(() => {
|
||
t.style.opacity = '0';
|
||
t.style.transition = 'opacity .25s';
|
||
setTimeout(() => t.remove(), 260);
|
||
}, ms);
|
||
}
|
||
function showXPGain(txt) {
|
||
const el = document.createElement('div');
|
||
el.className = 'xp-gain';
|
||
el.textContent = txt;
|
||
document.body.appendChild(el);
|
||
setTimeout(() => el.remove(), 1600);
|
||
}
|
||
|
||
// ==== Simple markdown renderer ====
|
||
function renderMD(md) {
|
||
if (!md) return '';
|
||
let s = md;
|
||
// Escape HTML first
|
||
s = s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
// Code fences
|
||
s = s.replace(/```(\w+)?\n([\s\S]*?)```/g, (_, lang, code) =>
|
||
`<pre><code>${code}</code></pre>`);
|
||
// Inline code
|
||
s = s.replace(/`([^`\n]+)`/g, '<code>$1</code>');
|
||
// GFM tables: header / separator / body
|
||
s = s.replace(/(?:^|\n)((?:\|[^\n]*\|[ \t]*\n){2,})/g, (block, content) => {
|
||
const lines = content.trim().split('\n');
|
||
if (lines.length < 2) return block;
|
||
const sep = lines[1];
|
||
if (!/^\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)+\|?\s*$/.test(sep)) return block;
|
||
const parseRow = (ln) => ln.replace(/^\|/, '').replace(/\|\s*$/, '').split('|').map(c => c.trim());
|
||
const header = parseRow(lines[0]);
|
||
const aligns = parseRow(sep).map(s => /^:-+:$/.test(s) ? 'center' : /-+:$/.test(s) ? 'right' : 'left');
|
||
const rows = lines.slice(2).map(parseRow);
|
||
let html = '\n<table class="md-table"><thead><tr>';
|
||
header.forEach((h, i) => { html += `<th style="text-align:${aligns[i]||'left'}">${h}</th>`; });
|
||
html += '</tr></thead><tbody>';
|
||
rows.forEach(r => {
|
||
html += '<tr>';
|
||
for (let i = 0; i < Math.max(r.length, header.length); i++) {
|
||
html += `<td style="text-align:${aligns[i]||'left'}">${r[i] || ''}</td>`;
|
||
}
|
||
html += '</tr>';
|
||
});
|
||
html += '</tbody></table>\n';
|
||
return html;
|
||
});
|
||
// Bold
|
||
s = s.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
|
||
s = s.replace(/__([^_\n]+)__/g, '<strong>$1</strong>');
|
||
// Italic
|
||
s = s.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>');
|
||
// Headings
|
||
s = s.replace(/^### (.+)$/gm, '<h3>$1</h3>');
|
||
s = s.replace(/^## (.+)$/gm, '<h2>$1</h2>');
|
||
s = s.replace(/^# (.+)$/gm, '<h1>$1</h1>');
|
||
// Links
|
||
s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
|
||
// Unordered lists
|
||
s = s.replace(/(?:^|\n)((?:[*\-] .+\n?)+)/g, (m) => {
|
||
const items = m.trim().split('\n').map(ln => ln.replace(/^[*\-] /, '')).map(li => `<li>${li}</li>`).join('');
|
||
return '\n<ul>' + items + '</ul>';
|
||
});
|
||
// Ordered lists
|
||
s = s.replace(/(?:^|\n)((?:\d+\. .+\n?)+)/g, (m) => {
|
||
const items = m.trim().split('\n').map(ln => ln.replace(/^\d+\. /, '')).map(li => `<li>${li}</li>`).join('');
|
||
return '\n<ol>' + items + '</ol>';
|
||
});
|
||
// Paragraphs: split on blank lines
|
||
s = s.split(/\n{2,}/).map(block => {
|
||
if (/^<(h\d|ul|ol|pre|blockquote|table)/.test(block.trim())) return block;
|
||
return '<p>' + block.replace(/\n/g, '<br>') + '</p>';
|
||
}).join('\n');
|
||
return s;
|
||
}
|
||
|
||
// ==== Chat API ====
|
||
async function chatAPI(message, history, attachments) {
|
||
const body = { message, history };
|
||
if (Array.isArray(attachments) && attachments.length > 0) body.attachments = attachments;
|
||
const r = await fetch(API, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': 'Bearer ' + KEY
|
||
},
|
||
body: JSON.stringify(body)
|
||
});
|
||
let data;
|
||
try { data = await r.json(); } catch (e) { throw new Error('Server-Antwort nicht lesbar'); }
|
||
if (!r.ok) throw new Error(data.error || ('HTTP ' + r.status));
|
||
return data;
|
||
}
|
||
|
||
// ==== Attachments (file upload) ====
|
||
const ATTACH_MAX_COUNT = 5;
|
||
const ATTACH_MAX_BYTES = 8 * 1024 * 1024;
|
||
const ATTACH_ACCEPTED_RE = /\.(pdf|txt|md|csv|json|xml|yaml|yml|log|png|jpg|jpeg|webp|gif)$/i;
|
||
let pendingAttachments = []; // [{ name, mimeType, dataUrl, size }]
|
||
|
||
function fmtSize(bytes) {
|
||
if (bytes < 1024) return bytes + ' B';
|
||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||
return (bytes / 1024 / 1024).toFixed(1) + ' MB';
|
||
}
|
||
|
||
function fileToDataUrl(file) {
|
||
return new Promise((resolve, reject) => {
|
||
const fr = new FileReader();
|
||
fr.onload = () => resolve(fr.result);
|
||
fr.onerror = () => reject(new Error('Datei konnte nicht gelesen werden'));
|
||
fr.readAsDataURL(file);
|
||
});
|
||
}
|
||
|
||
function renderAttachStrip() {
|
||
const strip = $('#attach-strip');
|
||
if (!strip) return;
|
||
strip.innerHTML = '';
|
||
pendingAttachments.forEach((a, idx) => {
|
||
const chip = document.createElement('span');
|
||
chip.className = 'attach-chip';
|
||
const ico = a.mimeType.startsWith('image/') ? '🖼' : (a.mimeType === 'application/pdf' || /\.pdf$/i.test(a.name)) ? '📄' : '📝';
|
||
chip.innerHTML = `<span>${ico}</span><span class="attach-chip-name" title="${a.name.replace(/[<>"']/g, '')}">${a.name.replace(/[<>"']/g, '')}</span><span class="attach-chip-size">${fmtSize(a.size)}</span>`;
|
||
const rm = document.createElement('button');
|
||
rm.type = 'button';
|
||
rm.className = 'attach-chip-remove';
|
||
rm.setAttribute('aria-label', 'Anhang entfernen');
|
||
rm.textContent = '×';
|
||
rm.addEventListener('click', () => {
|
||
pendingAttachments.splice(idx, 1);
|
||
renderAttachStrip();
|
||
});
|
||
chip.appendChild(rm);
|
||
strip.appendChild(chip);
|
||
});
|
||
}
|
||
|
||
async function addFiles(fileList) {
|
||
const files = Array.from(fileList || []);
|
||
for (const f of files) {
|
||
if (pendingAttachments.length >= ATTACH_MAX_COUNT) {
|
||
toast(`Max ${ATTACH_MAX_COUNT} Anhänge — weitere ignoriert`, 'warn');
|
||
break;
|
||
}
|
||
if (!ATTACH_ACCEPTED_RE.test(f.name) && !f.type.startsWith('image/') && !/text|json|xml|pdf/i.test(f.type)) {
|
||
toast(`${f.name}: Format nicht unterstützt`, 'error');
|
||
continue;
|
||
}
|
||
if (f.size > ATTACH_MAX_BYTES) {
|
||
toast(`${f.name}: ${fmtSize(f.size)} > Limit ${fmtSize(ATTACH_MAX_BYTES)}`, 'error');
|
||
continue;
|
||
}
|
||
try {
|
||
const dataUrl = await fileToDataUrl(f);
|
||
pendingAttachments.push({
|
||
name: f.name,
|
||
mimeType: f.type || 'application/octet-stream',
|
||
dataUrl,
|
||
size: f.size,
|
||
});
|
||
} catch (e) {
|
||
toast(`${f.name}: ${e.message}`, 'error');
|
||
}
|
||
}
|
||
renderAttachStrip();
|
||
}
|
||
|
||
// ==== Chat UI ====
|
||
function addMsg(role, content, { markdown = false, pending = false, attachments = null } = {}) {
|
||
const box = $('#chat-box');
|
||
const el = document.createElement('div');
|
||
el.className = 'msg ' + role;
|
||
if (pending) {
|
||
el.innerHTML = '<span class="dots"><span></span><span></span><span></span></span>';
|
||
} else if (markdown) {
|
||
el.innerHTML = renderMD(content);
|
||
} else {
|
||
el.textContent = content;
|
||
}
|
||
if (Array.isArray(attachments) && attachments.length > 0) {
|
||
const wrap = document.createElement('div');
|
||
wrap.className = 'msg-attachments';
|
||
attachments.forEach(a => {
|
||
const ico = a.mimeType && a.mimeType.startsWith('image/') ? '🖼' : (a.mimeType === 'application/pdf' || /\.pdf$/i.test(a.name)) ? '📄' : '📝';
|
||
const span = document.createElement('span');
|
||
span.className = 'att-name';
|
||
span.textContent = `${ico} ${a.name} (${fmtSize(a.size)})`;
|
||
wrap.appendChild(span);
|
||
});
|
||
el.appendChild(wrap);
|
||
}
|
||
box.appendChild(el);
|
||
box.scrollTop = box.scrollHeight;
|
||
setTimeout(() => { $('.main').scrollTop = $('.main').scrollHeight; }, 20);
|
||
return el;
|
||
}
|
||
|
||
function clearChatUI() {
|
||
$('#chat-box').innerHTML = '';
|
||
}
|
||
|
||
function renderWelcome() {
|
||
if (state.xp === 0 && chatHistory.length === 0 && !state.seenWelcome) {
|
||
$('#welcome-screen').classList.remove('hidden');
|
||
$('#welcome-screen').setAttribute('aria-hidden', 'false');
|
||
} else {
|
||
$('#welcome-screen').classList.add('hidden');
|
||
$('#welcome-screen').setAttribute('aria-hidden', 'true');
|
||
}
|
||
}
|
||
|
||
function restoreChat() {
|
||
clearChatUI();
|
||
for (const m of chatHistory) {
|
||
addMsg(m.role === 'assistant' ? 'bot' : 'user', m.content, { markdown: m.role === 'assistant' });
|
||
}
|
||
renderWelcome();
|
||
}
|
||
|
||
async function sendChat(text) {
|
||
const attaches = pendingAttachments.slice();
|
||
if (!text.trim() && attaches.length === 0) return;
|
||
$('#welcome-screen').classList.add('hidden');
|
||
state.seenWelcome = true;
|
||
|
||
addMsg('user', text || '(nur Anhang)', { attachments: attaches });
|
||
chatHistory.push({
|
||
role: 'user',
|
||
content: text + (attaches.length ? '\n[Anhänge: ' + attaches.map(a => a.name).join(', ') + ']' : ''),
|
||
});
|
||
pendingAttachments = [];
|
||
renderAttachStrip();
|
||
|
||
const pend = addMsg('bot', '', { pending: true });
|
||
$('#composer-send').disabled = true;
|
||
|
||
try {
|
||
const hist = chatHistory.slice(-20, -1);
|
||
const data = await chatAPI(text, hist, attaches.length ? attaches.map(a => ({ name: a.name, mimeType: a.mimeType, dataUrl: a.dataUrl })) : null);
|
||
pend.classList.remove('pending');
|
||
const structured = _tryParseStructuredReply(data.reply || '');
|
||
if (structured) {
|
||
pend.innerHTML = _renderStructuredInChat(structured);
|
||
} else {
|
||
pend.innerHTML = renderMD(data.reply || '');
|
||
}
|
||
if (Array.isArray(data.attachment_notes) && data.attachment_notes.length) {
|
||
const notice = document.createElement('div');
|
||
notice.className = 'attachment-notice';
|
||
notice.textContent = '📎 ' + data.attachment_notes.join(' · ');
|
||
pend.appendChild(notice);
|
||
}
|
||
chatHistory.push({ role: 'assistant', content: data.reply });
|
||
saveChatHistory();
|
||
touchActivity();
|
||
|
||
// Soft-Gate: postMessage to parent window after N user→assistant
|
||
// exchanges (configurable via ?showcase=1 query param). The outer
|
||
// qognio.com/showcase page listens for this event and triggers the
|
||
// lead-form modal. Sent only when running in an iframe.
|
||
try {
|
||
const isShowcase = new URLSearchParams(window.location.search).get('showcase') === '1';
|
||
if (isShowcase && window.parent && window.parent !== window) {
|
||
const userMessages = chatHistory.filter((m) => m.role === 'user').length;
|
||
const SOFT_GATE_AT = 3;
|
||
if (userMessages === SOFT_GATE_AT) {
|
||
window.parent.postMessage(
|
||
{ type: 'qognio:soft-gate', afterMessages: userMessages, botSlug: window.location.hostname.split('.')[0] },
|
||
'*'
|
||
);
|
||
}
|
||
}
|
||
} catch (sgErr) {
|
||
// non-fatal — swallow
|
||
}
|
||
} catch (e) {
|
||
pend.className = 'msg sys';
|
||
pend.textContent = '⚠ Fehler: ' + (e.message || 'unbekannt');
|
||
toast('Verbindung fehlgeschlagen', 'error');
|
||
} finally {
|
||
$('#composer-send').disabled = false;
|
||
$('#composer').focus();
|
||
}
|
||
}
|
||
|
||
// ==== Structured request helper ====
|
||
function _extractJSON(reply) {
|
||
let s = (reply || '').trim();
|
||
const fence = s.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
|
||
if (fence) s = fence[1].trim();
|
||
const b = s.indexOf('{'); const e = s.lastIndexOf('}');
|
||
if (b >= 0 && e > b) s = s.slice(b, e + 1);
|
||
return s;
|
||
}
|
||
function _repairJSON(s) {
|
||
s = s.replace(/,(\s*[}\]])/g, '$1');
|
||
s = s.replace(/,\s*,/g, ',');
|
||
s = s.replace(/(\})\s*\n\s*(\{)/g, '$1,\n$2');
|
||
s = s.replace(/(")(\s*\n\s*)(")/g, '$1,$2$3');
|
||
return s;
|
||
}
|
||
|
||
// --- Structured-Reply-Fallback fuer Chat (2026-04-25) ---
|
||
// Wenn der Bot versehentlich QUIZ/FLASHCARD/CASE/EXAM-JSON liefert statt Markdown,
|
||
// parse + render hier lesbar statt Raw-JSON im Chat-Bubble anzuzeigen.
|
||
function _tryParseStructuredReply(reply) {
|
||
let s = (reply || '').trim();
|
||
const fence = s.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
|
||
if (fence) s = fence[1].trim();
|
||
if (!s.startsWith('{')) return null;
|
||
const b = s.indexOf('{'), e = s.lastIndexOf('}');
|
||
if (b < 0 || e <= b) return null;
|
||
const raw = s.slice(b, e + 1);
|
||
let obj;
|
||
try { obj = JSON.parse(raw); } catch {
|
||
try { obj = JSON.parse(_repairJSON(raw)); } catch { return null; }
|
||
}
|
||
if (!obj || typeof obj !== 'object') return null;
|
||
const KNOWN = [
|
||
'case','quiz','flashcards','exam','lesson','presentation',
|
||
// Bot-spezifische Strukturen (KURT/VESTIGIA/PAUL/Pia/Otto/Eli/LIMEN/Zita/LIBRA/IDA)
|
||
'audit','privacy_check','mail_check','plan','validate','interview','decode','write','calc','unterweisung'
|
||
];
|
||
if (!KNOWN.includes(obj.type)) return null;
|
||
return obj;
|
||
}
|
||
function _renderStructuredInChat(obj) {
|
||
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||
const label = ['A','B','C','D','E','F','G','H'];
|
||
const TYPE_BADGE = {
|
||
case:'📖 Fallbeispiel', quiz:'🧠 Quiz-Fragen', flashcards:'📇 Karteikarten',
|
||
exam:'📝 Prüfung', lesson:'🎓 Lektion', presentation:'🖼 Präsentation',
|
||
audit:'🛡 AI-Act Audit-Trail', privacy_check:'🩺 Pflege-Datenschutz-Check',
|
||
mail_check:'📧 Mail-Check', plan:'🗓 Plan', validate:'✓ Validierung',
|
||
interview:'🎙 Wissens-Interview', decode:'🔍 Zeugnis-Decoder',
|
||
write:'✍ Zeugnis-Entwurf', calc:'🧮 Kalkulation',
|
||
unterweisung:'🛠 AdA-Unterweisung',
|
||
};
|
||
let html = `<div class="structured-chat"><div class="struct-badge">${TYPE_BADGE[obj.type] || '📄 Strukturierte Antwort'}</div>`;
|
||
if (obj.topic) html += `<div class="struct-topic">${esc(obj.topic)}</div>`;
|
||
if (obj.type === 'case') {
|
||
if (obj.scenario) html += `<div class="struct-scenario"><strong>Szenario:</strong><br>${esc(obj.scenario)}</div>`;
|
||
const fragen = obj.fragen || obj.questions || [];
|
||
fragen.forEach((f, i) => {
|
||
html += `<div class="struct-question"><strong>Frage ${i+1}:</strong> ${esc(f.frage || f.q || '')}`;
|
||
const opts = f.options || [];
|
||
if (opts.length) {
|
||
html += '<ul class="struct-options">';
|
||
opts.forEach((o, j) => {
|
||
const correct = (f.correct === j);
|
||
html += `<li${correct ? ' class="correct"' : ''}><strong>${label[j]||(j+1)})</strong> ${esc(o)}${correct ? ' ✓' : ''}</li>`;
|
||
});
|
||
html += '</ul>';
|
||
}
|
||
const ex = f.explain || f.explanation;
|
||
if (ex) html += `<div class="struct-explain"><em>Erklärung:</em> ${esc(ex)}</div>`;
|
||
html += '</div>';
|
||
});
|
||
const lessons = obj.lessons || [];
|
||
if (lessons.length) {
|
||
html += '<div class="struct-lessons"><strong>Lessons:</strong><ul>';
|
||
lessons.forEach(l => { html += `<li>${esc(l)}</li>`; });
|
||
html += '</ul></div>';
|
||
}
|
||
const norm = obj.paragraphen || obj.normen || obj.artikel || [];
|
||
if (norm.length) {
|
||
html += '<div class="struct-norms"><strong>Rechtsnormen:</strong> ' + norm.map(n => `<code>${esc(n)}</code>`).join(' · ') + '</div>';
|
||
}
|
||
} else if (obj.type === 'quiz' || obj.type === 'exam') {
|
||
// Exam-spezifisch: Dauer-Anzeige + HF-Tag pro Frage (IDA AdA-Schein)
|
||
if (obj.type === 'exam' && obj.duration_min) {
|
||
html += `<div class="struct-row"><strong>Dauer:</strong> ${esc(obj.duration_min)} Min · <strong>Fragen:</strong> ${(obj.questions||[]).length}</div>`;
|
||
}
|
||
const qs = obj.questions || [];
|
||
qs.forEach((q, i) => {
|
||
const hfTag = (obj.type === 'exam' && q.hf != null) ? ` <span style="font-size:.8em;color:var(--accent,#7c3aed);font-weight:600">[HF ${esc(q.hf)}]</span>` : '';
|
||
html += `<div class="struct-question"><strong>Frage ${i+1}:</strong>${hfTag} ${esc(q.q || q.frage || '')}`;
|
||
const opts = q.options || [];
|
||
if (opts.length) {
|
||
html += '<ul class="struct-options">';
|
||
opts.forEach((o, j) => {
|
||
const correct = (q.correct === j);
|
||
html += `<li${correct ? ' class="correct"' : ''}><strong>${label[j]||(j+1)})</strong> ${esc(o)}${correct ? ' ✓' : ''}</li>`;
|
||
});
|
||
html += '</ul>';
|
||
}
|
||
const ex = q.explain || q.explanation;
|
||
if (ex) html += `<div class="struct-explain"><em>Erklärung:</em> ${esc(ex)}</div>`;
|
||
html += '</div>';
|
||
});
|
||
} else if (obj.type === 'flashcards') {
|
||
(obj.cards || []).forEach((c, i) => {
|
||
html += `<div class="struct-flashcard"><div class="fc-front"><strong>Karte ${i+1}:</strong> ${esc(c.front || '')}</div><div class="fc-back">${esc(c.back || '')}</div>`;
|
||
if (c.hint) html += `<div class="fc-hint"><em>Hinweis:</em> ${esc(c.hint)}</div>`;
|
||
html += '</div>';
|
||
});
|
||
} else if (obj.type === 'lesson' || obj.type === 'presentation') {
|
||
if (obj.objectives || obj.learning_objectives) {
|
||
const objs = obj.objectives || obj.learning_objectives;
|
||
html += '<div class="struct-objectives"><strong>Lernziele:</strong><ul>';
|
||
objs.forEach(o => { html += `<li>${esc(o)}</li>`; });
|
||
html += '</ul></div>';
|
||
}
|
||
(obj.slides || []).forEach((s, i) => {
|
||
html += `<div class="struct-slide"><strong>${i+1}. ${esc(s.title || '')}</strong>`;
|
||
if (s.content_md || s.content) html += `<div class="slide-content">${renderMD(s.content_md || s.content || '')}</div>`;
|
||
if (s.key_point) html += `<div class="slide-key">💡 ${esc(s.key_point)}</div>`;
|
||
html += '</div>';
|
||
});
|
||
} else if (obj.type === 'audit') {
|
||
// KURT / VESTIGIA — AI-Act Audit-Trail
|
||
if (obj.system) html += `<div class="struct-row"><strong>System:</strong> ${esc(obj.system)}</div>`;
|
||
const cls = obj.ai_act_risk_class || obj.risk_class;
|
||
if (cls) {
|
||
const clsColor = { prohibited:'#dc2626', high:'#dc2626', limited:'#eab308', minimal:'#22c55e' }[cls] || '#8b8a99';
|
||
html += `<div class="struct-row"><strong>Risiko-Klasse:</strong> <span style="color:${clsColor};font-weight:600;text-transform:uppercase">${esc(cls)}</span></div>`;
|
||
}
|
||
if (obj.role) html += `<div class="struct-row"><strong>Rolle:</strong> ${esc(obj.role)}</div>`;
|
||
if (obj.dsgvo_relevant != null) html += `<div class="struct-row"><strong>DSGVO-relevant:</strong> ${obj.dsgvo_relevant ? 'ja' : 'nein'}</div>`;
|
||
if (obj.art22_check) html += `<div class="struct-row"><strong>Art. 22 DSGVO:</strong> ${esc(obj.art22_check)}</div>`;
|
||
const arts = obj.required_artifacts || [];
|
||
if (arts.length) {
|
||
html += '<div class="struct-section"><strong>Erforderliche Artefakte:</strong><table class="struct-table"><thead><tr><th>Artefakt</th><th>Status</th><th>Basis</th></tr></thead><tbody>';
|
||
arts.forEach(a => {
|
||
const sColor = { required:'#dc2626', optional:'#eab308', 'not-required':'#22c55e' }[a.status] || '#8b8a99';
|
||
html += `<tr><td>${esc(a.name||'')}</td><td style="color:${sColor};font-weight:600">${esc(a.status||'')}</td><td><code>${esc(a.based_on||'')}</code></td></tr>`;
|
||
});
|
||
html += '</tbody></table></div>';
|
||
}
|
||
const cw = obj.crosswalk_savings || [];
|
||
if (cw.length) {
|
||
html += '<div class="struct-section"><strong>Crosswalk-Einsparung:</strong><ul>';
|
||
cw.forEach(c => { html += `<li>${esc(c)}</li>`; });
|
||
html += '</ul></div>';
|
||
}
|
||
const dl = obj.deadlines || [];
|
||
if (dl.length) {
|
||
html += '<div class="struct-section"><strong>Fristen:</strong><ul>';
|
||
dl.forEach(d => { html += `<li><code>${esc(d.date||'')}</code> — ${esc(d.what||'')}</li>`; });
|
||
html += '</ul></div>';
|
||
}
|
||
const ws = obj.warnings || obj.warnung || [];
|
||
if (ws.length) {
|
||
html += '<div class="struct-section" style="color:#dc2626"><strong>⚠ Warnungen:</strong><ul>';
|
||
ws.forEach(w => { html += `<li>${esc(w)}</li>`; });
|
||
html += '</ul></div>';
|
||
}
|
||
} else if (obj.type === 'privacy_check') {
|
||
// PAUL — Pflege-Datenschutz-Ampel
|
||
const ampel = obj.ampel || 'yellow';
|
||
const ampelColor = { green:'#22c55e', yellow:'#eab308', red:'#dc2626' }[ampel] || '#8b8a99';
|
||
const ampelLabel = { green:'🟢 GRÜN — OK', yellow:'🟡 GELB — Vorsicht', red:'🔴 ROT — Akute Gefahr' }[ampel] || ampel;
|
||
html += `<div class="struct-row" style="font-size:1.1rem;font-weight:700;color:${ampelColor}">${ampelLabel}</div>`;
|
||
if (obj.situation) html += `<div class="struct-row"><strong>Situation:</strong> ${esc(obj.situation)}</div>`;
|
||
if (obj.schweigepflicht) html += `<div class="struct-row"><strong>Schweigepflicht (§203 StGB):</strong> ${esc(obj.schweigepflicht)}</div>`;
|
||
if (obj.dsgvo_basis) html += `<div class="struct-row"><strong>DSGVO-Basis:</strong> <code>${esc(obj.dsgvo_basis)}</code></div>`;
|
||
const acts = obj.handlung || obj.handlungen || [];
|
||
if (acts.length) {
|
||
html += '<div class="struct-section"><strong>Handlung:</strong><ol>';
|
||
acts.forEach(a => { html += `<li>${esc(a)}</li>`; });
|
||
html += '</ol></div>';
|
||
}
|
||
const wns = obj.warnung || obj.warnungen || [];
|
||
if (wns.length) {
|
||
html += `<div class="struct-section" style="color:${ampelColor}"><strong>⚠ Achtung:</strong><ul>`;
|
||
wns.forEach(w => { html += `<li>${esc(w)}</li>`; });
|
||
html += '</ul></div>';
|
||
}
|
||
} else if (obj.type === 'mail_check') {
|
||
// Pia — Phishing-Prüferin Mail-Bewertung
|
||
const ampel = obj.ampel || 'yellow';
|
||
const ampelColor = { green:'#22c55e', yellow:'#eab308', red:'#dc2626' }[ampel] || '#8b8a99';
|
||
const ampelLabel = { green:'🟢 GRÜN — vermutlich legitim', yellow:'🟡 GELB — verdächtig, zweite Meinung holen', red:'🔴 ROT — sehr wahrscheinlich Phishing/BEC' }[ampel] || ampel;
|
||
html += `<div class="struct-row" style="font-size:1.1rem;font-weight:700;color:${ampelColor}">${ampelLabel}</div>`;
|
||
if (obj.kurz_begruendung) html += `<div class="struct-row"><strong>Kurz:</strong> ${esc(obj.kurz_begruendung)}</div>`;
|
||
if (obj.pattern) html += `<div class="struct-row"><strong>Pattern:</strong> <code>${esc(obj.pattern)}</code></div>`;
|
||
const flags = obj.red_flags || [];
|
||
if (flags.length) {
|
||
html += '<div class="struct-section"><strong>Red Flags:</strong><ul>';
|
||
flags.forEach(f => { html += `<li>${esc(f)}</li>`; });
|
||
html += '</ul></div>';
|
||
}
|
||
const recs = obj.empfohlene_aktion || obj.aktionen || [];
|
||
if (recs.length) {
|
||
html += '<div class="struct-section"><strong>Empfohlene Aktion:</strong><ol>';
|
||
recs.forEach(a => { html += `<li>${esc(a)}</li>`; });
|
||
html += '</ol></div>';
|
||
}
|
||
if (obj.weiterleiten_an) html += `<div class="struct-row"><strong>Weiterleiten an:</strong> ${esc(obj.weiterleiten_an)}</div>`;
|
||
} else if (obj.type === 'plan') {
|
||
// Otto — 90-Tage-Onboarding-Plan
|
||
if (obj.role) html += `<div class="struct-row"><strong>Rolle:</strong> ${esc(obj.role)}</div>`;
|
||
const items = obj.weeks || obj.days || obj.steps || obj.phases || [];
|
||
if (items.length) {
|
||
items.forEach((it, i) => {
|
||
const label = it.day || it.week || it.phase || `Phase ${i+1}`;
|
||
const focus = it.focus || it.title || '';
|
||
html += `<div class="struct-step"><div class="step-head"><strong>${esc(label)}</strong>${focus?` — ${esc(focus)}`:''}</div>`;
|
||
const tasks = it.tasks || it.aufgaben || [];
|
||
if (tasks.length) {
|
||
html += '<ul>';
|
||
tasks.forEach(t => { html += `<li>${esc(typeof t === 'string' ? t : (t.task || t.text || JSON.stringify(t)))}</li>`; });
|
||
html += '</ul>';
|
||
}
|
||
if (it.success_signal || it.success) html += `<div class="step-success">✓ ${esc(it.success_signal || it.success)}</div>`;
|
||
html += '</div>';
|
||
});
|
||
}
|
||
} else if (obj.type === 'validate') {
|
||
// Eli — E-Rechnungs-Validator
|
||
const status = obj.status || 'ok';
|
||
const statusColor = { ok:'#22c55e', warnings:'#eab308', errors:'#dc2626' }[status] || '#8b8a99';
|
||
const statusLabel = { ok:'✅ Validierung OK', warnings:'⚠ Warnungen', errors:'❌ Fehler — nicht konform' }[status] || status;
|
||
html += `<div class="struct-row" style="font-weight:700;color:${statusColor}">${statusLabel}</div>`;
|
||
if (obj.file || obj.dateiname) html += `<div class="struct-row"><strong>Datei:</strong> <code>${esc(obj.file || obj.dateiname)}</code></div>`;
|
||
if (obj.format) html += `<div class="struct-row"><strong>Format:</strong> ${esc(obj.format)}</div>`;
|
||
const issues = obj.issues || obj.findings || [];
|
||
if (issues.length) {
|
||
html += '<div class="struct-section"><strong>Befunde:</strong><table class="struct-table"><thead><tr><th>Severity</th><th>Meldung</th><th>Fix</th></tr></thead><tbody>';
|
||
issues.forEach(i => {
|
||
const sev = i.severity || 'info';
|
||
const sevColor = { error:'#dc2626', warning:'#eab308', info:'#06b6d4' }[sev] || '#8b8a99';
|
||
html += `<tr><td style="color:${sevColor};font-weight:600">${esc(sev)}</td><td>${esc(i.message||i.msg||'')}</td><td>${esc(i.fix||'')}</td></tr>`;
|
||
});
|
||
html += '</tbody></table></div>';
|
||
}
|
||
} else if (obj.type === 'interview') {
|
||
// LIMEN — Wissens-Interview entlang Achse
|
||
if (obj.achse) html += `<div class="struct-row"><strong>Wissens-Achse:</strong> <code>${esc(obj.achse)}</code></div>`;
|
||
const fs = obj.fragen || obj.questions || [];
|
||
fs.forEach((f, i) => {
|
||
html += `<div class="struct-question"><strong>Frage ${i+1}:</strong> ${esc(f.f || f.frage || f.q || '')}`;
|
||
if (f.tipp_aktiv_zuhören || f.tipp_zuhoeren) html += `<div style="font-size:.85em;color:#8b8a99;margin-top:.25rem"><em>👂 Tipp Aktiv-Zuhören:</em> ${esc(f.tipp_aktiv_zuhören || f.tipp_zuhoeren)}</div>`;
|
||
if (f.tipp_nachfass) html += `<div style="font-size:.85em;color:#8b8a99;margin-top:.25rem"><em>↳ Tipp Nachfass:</em> ${esc(f.tipp_nachfass)}</div>`;
|
||
html += '</div>';
|
||
});
|
||
} else if (obj.type === 'decode') {
|
||
// Zita — Zeugnis-Decoder
|
||
if (obj.zeugnis_text) html += `<div class="struct-row"><strong>Zeugnis-Text:</strong><blockquote style="border-left:3px solid var(--accent);padding-left:.75rem;margin:.5rem 0;color:#cfcedb">${esc(obj.zeugnis_text)}</blockquote></div>`;
|
||
const gradeNum = (g) => {
|
||
const n = typeof g === 'number' ? g : parseFloat(String(g).replace(',','.'));
|
||
return isNaN(n) ? null : n;
|
||
};
|
||
const gradeColor = (g) => {
|
||
const n = gradeNum(g);
|
||
if (n != null) return n <= 2 ? '#22c55e' : n <= 3 ? '#eab308' : '#dc2626';
|
||
return ({ sehr_gut:'#22c55e', gut:'#22c55e', befriedigend:'#eab308', ausreichend:'#eab308', mangelhaft:'#dc2626', ungenuegend:'#dc2626' }[String(g||'')] || '#8b8a99');
|
||
};
|
||
if (obj.overall_grade != null && obj.overall_grade !== '') {
|
||
const ogColor = gradeColor(obj.overall_grade);
|
||
html += `<div class="struct-row" style="font-size:1.1rem;font-weight:700;color:${ogColor}">Gesamt-Note: ${esc(obj.overall_grade)}${obj.grade_label ? ' — ' + esc(obj.grade_label) : ''}</div>`;
|
||
}
|
||
// Sub-Noten (verhalten/schlussformel)
|
||
if (obj.verhalten_grade != null || obj.schlussformel_grade != null) {
|
||
html += '<div class="struct-row" style="font-size:.9em">';
|
||
if (obj.verhalten_grade != null) html += `<strong>Verhalten:</strong> <span style="color:${gradeColor(obj.verhalten_grade)};font-weight:600">${esc(obj.verhalten_grade)}</span> `;
|
||
if (obj.schlussformel_grade != null) html += ` · <strong>Schlussformel:</strong> <span style="color:${gradeColor(obj.schlussformel_grade)};font-weight:600">${esc(obj.schlussformel_grade)}</span>`;
|
||
html += '</div>';
|
||
}
|
||
// Akzeptiere mehrere Schema-Varianten: decodierung/codes/findings + passage/quote, klartext/bedeutung/meaning, note/grade, code/section
|
||
const dec = obj.decodierung || obj.codes || obj.findings || [];
|
||
if (dec.length) {
|
||
html += '<div class="struct-section"><strong>Code-Decodierung:</strong>';
|
||
dec.forEach(d => {
|
||
const passage = d.passage || d.quote || d.text || '';
|
||
const klartext = d.klartext || d.bedeutung || d.meaning || '';
|
||
const code = d.code || d.section || '';
|
||
const note = d.note || d.grade || '';
|
||
const risk = d.risk || '';
|
||
const noteColor = note !== '' ? gradeColor(note) : '#8b8a99';
|
||
html += `<div class="struct-step"><div><strong>"${esc(passage)}"</strong></div>`;
|
||
if (code) html += `<div style="font-size:.85em;color:#8b8a99">${esc(code)}</div>`;
|
||
if (klartext) html += `<div>↳ ${esc(klartext)}</div>`;
|
||
if (note !== '') html += `<div style="color:${noteColor};font-weight:600">Note: ${esc(note)}${risk ? ` · Risiko: ${esc(risk)}` : ''}</div>`;
|
||
html += '</div>';
|
||
});
|
||
html += '</div>';
|
||
}
|
||
const redFlags = obj.red_flags || [];
|
||
if (redFlags.length) {
|
||
html += '<div class="struct-section" style="color:#dc2626"><strong>🚩 Red Flags:</strong><ul>';
|
||
redFlags.forEach(f => { html += `<li>${esc(f)}</li>`; });
|
||
html += '</ul></div>';
|
||
}
|
||
const missing = obj.missing_elements || [];
|
||
if (missing.length) {
|
||
html += '<div class="struct-section" style="color:#eab308"><strong>Fehlende Pflicht-Elemente:</strong><ul>';
|
||
missing.forEach(m => { html += `<li>${esc(m)}</li>`; });
|
||
html += '</ul></div>';
|
||
}
|
||
const rewrites = obj.rewrite_suggestions || [];
|
||
if (rewrites.length) {
|
||
html += '<div class="struct-section"><strong>Umschreib-Vorschläge:</strong><table class="struct-table"><thead><tr><th>Original</th><th>Vorschlag</th><th>Warum</th></tr></thead><tbody>';
|
||
rewrites.forEach(r => {
|
||
html += `<tr><td>${esc(r.original||'')}</td><td>${esc(r.rewrite||'')}</td><td>${esc(r.why||'')}</td></tr>`;
|
||
});
|
||
html += '</tbody></table></div>';
|
||
}
|
||
const dsources = obj.sources || obj.quellen || [];
|
||
if (dsources.length) {
|
||
html += '<div class="struct-row" style="font-size:.85em;color:#8b8a99;margin-top:.4rem"><strong>Quellen:</strong> ' + dsources.map(s => `<code>${esc(s)}</code>`).join(' · ') + '</div>';
|
||
}
|
||
} else if (obj.type === 'write') {
|
||
// Zita — Zeugnis-Schreiber
|
||
if (obj.role) html += `<div class="struct-row"><strong>Rolle:</strong> ${esc(obj.role)}</div>`;
|
||
if (obj.grade != null) {
|
||
const gColor = obj.grade <= 2 ? '#22c55e' : obj.grade <= 3 ? '#eab308' : '#dc2626';
|
||
html += `<div class="struct-row"><strong>Note:</strong> <span style="color:${gColor};font-weight:600">${esc(obj.grade)}</span></div>`;
|
||
}
|
||
const zeugnisText = obj.zeugnis || obj.zeugnis_text || obj.markdown || obj.text;
|
||
if (zeugnisText) {
|
||
html += '<div class="struct-section"><strong>Zeugnis-Entwurf:</strong>';
|
||
html += `<div class="struct-doc" style="background:rgba(255,255,255,.04);padding:.75rem;border-radius:.5rem;border:1px solid rgba(255,255,255,.1);margin-top:.5rem;white-space:pre-wrap;font-family:Georgia,serif">${esc(zeugnisText)}</div>`;
|
||
html += '</div>';
|
||
}
|
||
const notenSignale = obj.noten_signale || [];
|
||
if (notenSignale.length) {
|
||
html += '<div class="struct-section"><strong>Noten-Signale:</strong><table class="struct-table"><thead><tr><th>Satz</th><th>Codiert</th></tr></thead><tbody>';
|
||
notenSignale.forEach(s => {
|
||
html += `<tr><td>${esc(s.satz || '')}</td><td>${esc(s.codiert || '')}</td></tr>`;
|
||
});
|
||
html += '</tbody></table></div>';
|
||
}
|
||
const paragraphen = obj.verwendete_paragraphen || obj.paragraphen || [];
|
||
if (paragraphen.length) {
|
||
html += '<div class="struct-row"><strong>Verwendete Paragraphen:</strong> ' + paragraphen.map(p => `<code>${esc(p)}</code>`).join(' · ') + '</div>';
|
||
}
|
||
const warnings = obj.warnings || obj.warnungen || [];
|
||
if (warnings.length) {
|
||
html += '<div class="struct-section" style="color:#dc2626"><strong>⚠ Warnungen:</strong><ul>';
|
||
warnings.forEach(w => { html += `<li>${esc(w)}</li>`; });
|
||
html += '</ul></div>';
|
||
}
|
||
const notes = obj.notes || obj.hinweise || [];
|
||
if (notes.length) {
|
||
html += '<div class="struct-section"><strong>Hinweise:</strong><ul>';
|
||
notes.forEach(n => { html += `<li>${esc(n)}</li>`; });
|
||
html += '</ul></div>';
|
||
}
|
||
const wsources = obj.sources || obj.quellen || [];
|
||
if (wsources.length) {
|
||
html += '<div class="struct-row" style="font-size:.85em;color:#8b8a99"><strong>Quellen:</strong> ' + wsources.map(s => `<code>${esc(s)}</code>`).join(' · ') + '</div>';
|
||
}
|
||
} else if (obj.type === 'calc') {
|
||
// LIBRA — Kalkulations-Rechner
|
||
if (obj.formel) html += `<div class="struct-row"><strong>Formel:</strong> <code>${esc(obj.formel)}</code></div>`;
|
||
if (obj.inputs && typeof obj.inputs === 'object') {
|
||
html += '<div class="struct-section"><strong>Eingaben:</strong><table class="struct-table"><tbody>';
|
||
Object.entries(obj.inputs).forEach(([k,v]) => { html += `<tr><td><code>${esc(k)}</code></td><td>${esc(v)}</td></tr>`; });
|
||
html += '</tbody></table></div>';
|
||
}
|
||
const steps = obj.schritte || obj.steps || [];
|
||
if (steps.length) {
|
||
html += '<div class="struct-section"><strong>Rechenweg:</strong><ol>';
|
||
steps.forEach(s => { html += `<li>${esc(typeof s === 'string' ? s : (s.text || JSON.stringify(s)))}</li>`; });
|
||
html += '</ol></div>';
|
||
}
|
||
if (obj.ergebnis != null) html += `<div class="struct-row" style="font-size:1.1rem;font-weight:700;color:var(--accent);margin-top:.5rem"><strong>Ergebnis:</strong> ${esc(obj.ergebnis)}</div>`;
|
||
} else if (obj.type === 'unterweisung') {
|
||
// IDA — AdA-Unterweisung (4-Stufen / Lehrgespraech / Leittext)
|
||
if (obj.methode) html += `<div class="struct-row"><strong>Methode:</strong> <code>${esc(obj.methode)}</code></div>`;
|
||
const lz = obj.lernzielanalyse || obj.lernziele || null;
|
||
if (lz && typeof lz === 'object') {
|
||
html += '<div class="struct-section"><strong>Lernzielanalyse:</strong><table class="struct-table"><tbody>';
|
||
if (lz.richtlernziel) html += `<tr><td><strong>Richtlernziel</strong></td><td>${esc(lz.richtlernziel)}</td></tr>`;
|
||
if (lz.groblernziel) html += `<tr><td><strong>Groblernziel</strong></td><td>${esc(lz.groblernziel)}</td></tr>`;
|
||
if (lz.feinlernziel) html += `<tr><td><strong>Feinlernziel</strong></td><td>${esc(lz.feinlernziel)}</td></tr>`;
|
||
if (lz.bereich) html += `<tr><td><strong>Bereich</strong></td><td><code>${esc(lz.bereich)}</code></td></tr>`;
|
||
html += '</tbody></table></div>';
|
||
}
|
||
const phasen = obj.phasen || obj.stufen || obj.phases || [];
|
||
if (phasen.length) {
|
||
let totalMin = 0;
|
||
phasen.forEach(p => { if (typeof p.minuten === 'number') totalMin += p.minuten; });
|
||
html += `<div class="struct-section"><strong>Phasen${totalMin?` (${totalMin} Min gesamt)`:''}:</strong>`;
|
||
phasen.forEach((p, i) => {
|
||
html += `<div class="struct-step"><div class="step-head"><strong>${i+1}. ${esc(p.name || p.stufe || '')}</strong>${p.minuten?` <span style="color:#8b8a99">· ${esc(p.minuten)} Min</span>`:''}</div>`;
|
||
if (p.ausbilder_tut) html += `<div><strong>Ausbilder:in:</strong> ${esc(p.ausbilder_tut)}</div>`;
|
||
if (p.azubi_tut) html += `<div><strong>Azubi:</strong> ${esc(p.azubi_tut)}</div>`;
|
||
if (p.feedback_check) html += `<div style="font-size:.9em;color:#8b8a99"><em>Check:</em> ${esc(p.feedback_check)}</div>`;
|
||
html += '</div>';
|
||
});
|
||
html += '</div>';
|
||
}
|
||
if (obj.erfolgskontrolle) {
|
||
html += `<div class="struct-row"><strong>Erfolgskontrolle:</strong> ${esc(obj.erfolgskontrolle)}</div>`;
|
||
}
|
||
const alt = obj.handlungsalternativen || obj.alternativen || [];
|
||
if (alt.length) {
|
||
html += '<div class="struct-section"><strong>Handlungs-Alternativen:</strong><ul>';
|
||
alt.forEach(a => { html += `<li>${esc(a)}</li>`; });
|
||
html += '</ul></div>';
|
||
}
|
||
}
|
||
const HINT_BY_TYPE = {
|
||
audit:'↳ Crosswalk gespeichert. Brauchst du Tech-Doku Anhang IV (VESTIGIA) oder die DPIA-Tiefe (Cora)?',
|
||
privacy_check:'↳ Bei Unsicherheit: PDL/EL informieren, Schweigepflicht §203 StGB hat Vorrang vor DSGVO.',
|
||
mail_check:'↳ Im Zweifel: nicht klicken, an IT/SOC weiterleiten. Eskalation > Schaden.',
|
||
plan:'↳ Tipp: 1. Tag-Erlebnis ist der wichtigste Baustein — ohne Wow-Moment bröckelt die Probezeit.',
|
||
validate:'↳ Bei "errors": Rechnung nicht buchen, Lieferanten kontaktieren, Validator-Output anhängen.',
|
||
interview:'↳ Story-First: lass die Person erzählen, hör 3 Sekunden nach der Antwort weiter zu.',
|
||
decode:'↳ Bei kritischen Codes: Anwält:in für Arbeitsrecht konsultieren, Zeugnis-Anfechtung prüfen.',
|
||
write:'↳ Vor Übergabe: 4-Augen-Review durch HR-Lead, Datum prüfen, korrekt unterschreiben.',
|
||
calc:'↳ Stichproben mit echten Aufträgen testen — Theorie-Marge ≠ Praxis-Marge.',
|
||
unterweisung:'↳ Methode folgt Lernziel: psychomotorisch → 4-Stufen, kognitiv → Lehrgespräch, komplex → Leittext.',
|
||
};
|
||
const hint = HINT_BY_TYPE[obj.type] || '↳ Nutze den <strong>Quiz</strong>-, <strong>Karteikarten</strong>- oder <strong>Chat</strong>-Tab für die interaktive Version.';
|
||
html += `<div class="struct-hint">${hint}</div>`;
|
||
html += '</div>';
|
||
return html;
|
||
}
|
||
|
||
async function requestStructured(prompt) {
|
||
const data = await chatAPI(prompt, []);
|
||
let raw = _extractJSON(data.reply || '');
|
||
try { return JSON.parse(raw); } catch (e1) {
|
||
try { return JSON.parse(_repairJSON(raw)); } catch (e2) {
|
||
const heal = await chatAPI(prompt + "\n\nHINWEIS: Dein letzter JSON-Output war invalid (Parse-Fehler). Antworte JETZT NUR mit sauberem JSON. Keine Kommentare, kein Markdown-Fence, keine trailing commas, alle String-Werte in doppelten Anf\u00fchrungszeichen.", []);
|
||
const healRaw = _extractJSON(heal.reply || '');
|
||
try { return JSON.parse(healRaw); } catch (e3) {
|
||
try { return JSON.parse(_repairJSON(healRaw)); } catch (e4) {
|
||
const err = new Error('Konnte JSON nicht parsen \u2014 Modell liefert kaputtes Format. Versuch es noch mal.');
|
||
err.original = e1.message; err.raw = raw.slice(0, 300);
|
||
throw err;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ==== Quiz ====
|
||
const quizState = { set: null, idx: 0, correct: 0, topic: null };
|
||
|
||
function renderQuizIntro() {
|
||
const host = $('#quiz-host');
|
||
const topics = CURRICULA.curricula.flatMap(c =>
|
||
c.modules.map(m => ({ id: m.id, curr: c.title, title: m.title, color: c.color }))
|
||
);
|
||
host.innerHTML = `
|
||
<div class="quiz-intro">
|
||
<div class="topic-select">
|
||
<h3>🎯 Quiz-Thema wählen</h3>
|
||
<p style="color:var(--text-dim);font-size:.85rem">Wähle ein Modul — Cora generiert Szenario-Fragen aus dem HR-Alltag.</p>
|
||
<div class="topic-btn-row" id="quiz-topic-pills"></div>
|
||
<div style="margin-top:1rem;display:flex;gap:.5rem;align-items:center">
|
||
<label for="quiz-count" style="color:var(--text-dim);font-size:.85rem">Anzahl:</label>
|
||
<select id="quiz-count" class="btn-sec" style="padding:.4rem .7rem">
|
||
<option value="5">5</option>
|
||
<option value="10" selected>10</option>
|
||
<option value="15">15</option>
|
||
</select>
|
||
<div class="spacer" style="flex:1"></div>
|
||
<button class="btn-primary" id="quiz-start">Quiz starten</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
const pillHost = $('#quiz-topic-pills');
|
||
CURRICULA.curricula.forEach(c => {
|
||
c.modules.forEach(m => {
|
||
const btn = document.createElement('button');
|
||
btn.className = 'topic-pill';
|
||
btn.dataset.curr = c.id;
|
||
btn.dataset.mod = m.id;
|
||
btn.textContent = m.title;
|
||
btn.title = c.title;
|
||
btn.addEventListener('click', () => {
|
||
$$('#quiz-topic-pills .topic-pill').forEach(p => p.setAttribute('aria-selected', 'false'));
|
||
btn.setAttribute('aria-selected', 'true');
|
||
quizState.topic = { curr: c, mod: m };
|
||
});
|
||
pillHost.appendChild(btn);
|
||
});
|
||
});
|
||
$('#quiz-start').addEventListener('click', () => {
|
||
if (!quizState.topic) { toast('Bitte ein Thema wählen.', 'warn'); return; }
|
||
const count = parseInt($('#quiz-count').value, 10);
|
||
startQuiz(quizState.topic, count);
|
||
});
|
||
}
|
||
|
||
async function startQuiz(topic, count) {
|
||
const host = $('#quiz-host');
|
||
host.innerHTML = `<div class="quiz-card"><div class="dots" style="justify-content:center"><span></span><span></span><span></span></div><p style="text-align:center;color:var(--text-dim);margin-top:1rem">Cora erstellt ${count} Szenario-Fragen zu „${topic.mod.title}" …</p></div>`;
|
||
try {
|
||
const topicText = `${topic.curr.title} — ${topic.mod.title}`;
|
||
const data = await requestStructured(`QUIZ_REQUEST topic="${topicText}" count=${count}`);
|
||
if (!data.questions || !Array.isArray(data.questions) || data.questions.length === 0) {
|
||
throw new Error('Keine Fragen erhalten');
|
||
}
|
||
quizState.set = data.questions;
|
||
quizState.idx = 0;
|
||
quizState.correct = 0;
|
||
renderQuizQuestion();
|
||
} catch (e) {
|
||
host.innerHTML = `<div class="quiz-card"><p style="color:var(--danger)">⚠ Konnte Quiz nicht erstellen: ${e.message}</p><div style="margin-top:1rem"><button class="btn-sec" onclick="location.reload()">Neu laden</button></div></div>`;
|
||
toast('Quiz-Generierung fehlgeschlagen', 'error');
|
||
}
|
||
}
|
||
|
||
function renderQuizQuestion() {
|
||
const host = $('#quiz-host');
|
||
const q = quizState.set[quizState.idx];
|
||
if (!q) return renderQuizDone();
|
||
const letters = ['A', 'B', 'C', 'D', 'E', 'F'];
|
||
host.innerHTML = `
|
||
<div class="quiz-card">
|
||
<div class="quiz-progress">
|
||
<span>Frage ${quizState.idx + 1} / ${quizState.set.length}</span>
|
||
<span>✓ ${quizState.correct}</span>
|
||
</div>
|
||
<div class="quiz-q">${escapeHTML(q.q)}</div>
|
||
<div class="quiz-options" id="quiz-opts">
|
||
${q.options.map((opt, i) => `
|
||
<button class="quiz-option" data-idx="${i}" aria-label="Option ${letters[i]}">
|
||
<span class="opt-letter">${letters[i]}</span>
|
||
<span>${escapeHTML(opt)}</span>
|
||
</button>
|
||
`).join('')}
|
||
</div>
|
||
<div class="quiz-explain hidden" id="quiz-explain"></div>
|
||
<div class="quiz-next hidden" id="quiz-next">
|
||
<button class="btn-primary" id="quiz-next-btn">Weiter →</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
$$('#quiz-opts .quiz-option').forEach(btn => {
|
||
btn.addEventListener('click', () => handleQuizAnswer(btn, q));
|
||
});
|
||
}
|
||
|
||
function handleQuizAnswer(btn, q) {
|
||
const chosen = parseInt(btn.dataset.idx, 10);
|
||
const correct = q.correct;
|
||
state.totalAnswers += 1;
|
||
const curId = quizState.topic.curr.id;
|
||
const modId = quizState.topic.mod.id;
|
||
if (!state.mastery[curId]) state.mastery[curId] = { correct: 0, total: 0 };
|
||
state.mastery[curId].total += 1;
|
||
state.moduleTotal[modId] = (state.moduleTotal[modId] || 0) + 1;
|
||
|
||
$$('#quiz-opts .quiz-option').forEach((b, i) => {
|
||
b.disabled = true;
|
||
if (i === correct) b.classList.add('correct');
|
||
else if (i === chosen) b.classList.add('wrong');
|
||
});
|
||
const ex = $('#quiz-explain');
|
||
ex.classList.remove('hidden');
|
||
if (chosen === correct) {
|
||
quizState.correct += 1;
|
||
state.correctAnswers += 1;
|
||
state.quizStreak += 1;
|
||
if (state.quizStreak > state.maxQuizStreak) state.maxQuizStreak = state.quizStreak;
|
||
state.mastery[curId].correct += 1;
|
||
state.moduleCorrect[modId] = (state.moduleCorrect[modId] || 0) + 1;
|
||
addXP(10, 'Richtig!');
|
||
ex.innerHTML = `<strong style="color:var(--success)">✓ Richtig!</strong><br>${escapeHTML(q.explain || '')}
|
||
<div class="deepdive-bar" data-inquiz="1">
|
||
<button class="deepdive-btn" data-kind="more">💡 Mehr dazu</button>
|
||
<button class="deepdive-btn" data-kind="sources">📚 Quellen</button>
|
||
<button class="deepdive-btn" data-kind="why">🤔 Warum?</button>
|
||
<button class="deepdive-btn" data-kind="ask">❓ Frag dazu</button>
|
||
</div>
|
||
<div id="quiz-deepdive" class="deepdive-panel hidden"></div>`;
|
||
$$('#quiz-explain .deepdive-btn').forEach(b => b.addEventListener('click', () => quizDeepdive(b.dataset.kind, q, chosen, correct)));
|
||
} else {
|
||
state.quizStreak = 0;
|
||
ex.innerHTML = `<strong style="color:var(--danger)">✗ Falsch.</strong> Richtig wäre <strong>${['A','B','C','D','E','F'][correct]}</strong>. ${escapeHTML(q.explain || '')}
|
||
<div class="deepdive-bar" data-inquiz="1">
|
||
<button class="deepdive-btn" data-kind="more">💡 Mehr dazu</button>
|
||
<button class="deepdive-btn" data-kind="sources">📚 Quellen</button>
|
||
<button class="deepdive-btn" data-kind="why">🤔 Warum?</button>
|
||
<button class="deepdive-btn" data-kind="ask">❓ Frag dazu</button>
|
||
</div>
|
||
<div id="quiz-deepdive" class="deepdive-panel hidden"></div>`;
|
||
$$('#quiz-explain .deepdive-btn').forEach(b => b.addEventListener('click', () => quizDeepdive(b.dataset.kind, q, chosen, correct)));
|
||
}
|
||
saveState();
|
||
touchActivity();
|
||
checkBadges();
|
||
$('#quiz-next').classList.remove('hidden');
|
||
$('#quiz-next-btn').addEventListener('click', () => {
|
||
quizState.idx += 1;
|
||
if (quizState.idx >= quizState.set.length) renderQuizDone();
|
||
else renderQuizQuestion();
|
||
});
|
||
}
|
||
|
||
function renderQuizDone() {
|
||
const host = $('#quiz-host');
|
||
const pct = Math.round(quizState.correct / quizState.set.length * 100);
|
||
state.completedQuizzes += 1;
|
||
// Module complete (>=80% correct in this quiz) — bonus XP + track for DSGVO-Master
|
||
if (pct >= 80 && quizState.topic) {
|
||
const modId = quizState.topic.mod.id;
|
||
if (!state.completedCurricula.includes(modId)) {
|
||
state.completedCurricula.push(modId);
|
||
addXP(20, 'Modul abgeschlossen: ' + quizState.topic.mod.title);
|
||
}
|
||
// Lernreise: Quiz mit ≥80% beendet → Step kann auto-completed werden.
|
||
// Portal-Listener entscheidet, ob er das übernimmt.
|
||
sendJourneyBeacon('quiz_passed', { module: modId, score_pct: pct });
|
||
}
|
||
saveState();
|
||
checkBadges();
|
||
host.innerHTML = `
|
||
<div class="quiz-card quiz-done">
|
||
<h3>Quiz beendet!</h3>
|
||
<div class="score">${quizState.correct} / ${quizState.set.length}</div>
|
||
<p style="color:var(--text-dim)">${pct}% richtig — ${pct >= 80 ? 'Ausgezeichnet!' : pct >= 60 ? 'Solide!' : 'Probier es noch mal.'}</p>
|
||
<div class="actions">
|
||
<button class="btn-sec" id="quiz-again">Gleiches Thema nochmal</button>
|
||
<button class="btn-primary" id="quiz-new">Anderes Thema</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
$('#quiz-again').addEventListener('click', () => startQuiz(quizState.topic, quizState.set.length));
|
||
$('#quiz-new').addEventListener('click', () => renderQuizIntro());
|
||
}
|
||
|
||
// ==== Flashcards (SM-2 Spaced Repetition) ====
|
||
const flashState = { topic: null, deck: null, cur: null, showBack: false };
|
||
|
||
function renderFlashIntro() {
|
||
const host = $('#flash-host');
|
||
host.innerHTML = `
|
||
<div class="flash-intro">
|
||
<div class="topic-select">
|
||
<h3>🃏 Flashcards</h3>
|
||
<p style="color:var(--text-dim);font-size:.85rem">Cora erstellt Karteikarten zu einem Thema. Bewerte dein Erinnerungsvermögen — das System wiederholt schwere Karten öfter (SM-2).</p>
|
||
<div class="topic-btn-row" id="flash-topic-pills"></div>
|
||
<div style="margin-top:1rem;display:flex;gap:.5rem;align-items:center">
|
||
<label for="flash-count" style="color:var(--text-dim);font-size:.85rem">Neue Karten:</label>
|
||
<select id="flash-count" class="btn-sec" style="padding:.4rem .7rem">
|
||
<option value="5">5</option>
|
||
<option value="10" selected>10</option>
|
||
<option value="20">20</option>
|
||
</select>
|
||
<div class="spacer" style="flex:1"></div>
|
||
<button class="btn-sec" id="flash-review">Fällige üben</button>
|
||
<button class="btn-primary" id="flash-start">Neu laden</button>
|
||
</div>
|
||
<div id="flash-stats" style="margin-top:.75rem;font-size:.8rem;color:var(--text-mute)"></div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
const pillHost = $('#flash-topic-pills');
|
||
CURRICULA.curricula.forEach(c => {
|
||
c.modules.forEach(m => {
|
||
const btn = document.createElement('button');
|
||
btn.className = 'topic-pill';
|
||
btn.textContent = m.title;
|
||
btn.title = c.title;
|
||
btn.addEventListener('click', () => {
|
||
$$('#flash-topic-pills .topic-pill').forEach(p => p.setAttribute('aria-selected', 'false'));
|
||
btn.setAttribute('aria-selected', 'true');
|
||
flashState.topic = { curr: c, mod: m };
|
||
updateFlashStats();
|
||
});
|
||
pillHost.appendChild(btn);
|
||
});
|
||
});
|
||
$('#flash-start').addEventListener('click', () => {
|
||
if (!flashState.topic) { toast('Bitte ein Thema wählen.', 'warn'); return; }
|
||
loadNewFlashCards(flashState.topic, parseInt($('#flash-count').value, 10));
|
||
});
|
||
$('#flash-review').addEventListener('click', () => {
|
||
if (!flashState.topic) { toast('Bitte ein Thema wählen.', 'warn'); return; }
|
||
startReview(flashState.topic);
|
||
});
|
||
updateFlashStats();
|
||
}
|
||
|
||
function updateFlashStats() {
|
||
const el = $('#flash-stats');
|
||
if (!el) return;
|
||
if (!flashState.topic) { el.textContent = ''; return; }
|
||
const tid = flashState.topic.mod.id;
|
||
const cards = flashCards[tid] || [];
|
||
const due = cards.filter(c => !c.due || c.due <= now()).length;
|
||
el.textContent = cards.length === 0
|
||
? 'Noch keine Karten in diesem Thema.'
|
||
: `${cards.length} Karten gespeichert · ${due} fällig heute`;
|
||
}
|
||
|
||
async function loadNewFlashCards(topic, count) {
|
||
const host = $('#flash-host');
|
||
host.innerHTML = `<div class="flashcard"><div class="dots" style="justify-content:center"><span></span><span></span><span></span></div><p style="text-align:center;color:var(--text-dim);margin-top:1rem">Cora erstellt Karten zu „${topic.mod.title}" …</p></div>`;
|
||
try {
|
||
const topicText = `${topic.curr.title} — ${topic.mod.title}`;
|
||
const data = await requestStructured(`FLASHCARD_REQUEST topic="${topicText}" count=${count}`);
|
||
if (!data.cards || !data.cards.length) throw new Error('Keine Karten erhalten');
|
||
const tid = topic.mod.id;
|
||
if (!flashCards[tid]) flashCards[tid] = [];
|
||
for (const c of data.cards) {
|
||
flashCards[tid].push({
|
||
front: c.front, back: c.back, hint: c.hint || '',
|
||
ef: 2.5, interval: 0, reps: 0, due: now()
|
||
});
|
||
}
|
||
saveFlashCards();
|
||
startReview(topic);
|
||
} catch (e) {
|
||
host.innerHTML = `<div class="flashcard"><p style="color:var(--danger)">⚠ Fehler: ${e.message}</p><div style="margin-top:1rem"><button class="btn-sec" onclick="document.querySelector('[data-mode=flash]').click()">Zurück</button></div></div>`;
|
||
toast('Karten-Generierung fehlgeschlagen', 'error');
|
||
}
|
||
}
|
||
|
||
function startReview(topic) {
|
||
flashState.topic = topic;
|
||
const tid = topic.mod.id;
|
||
flashState.deck = (flashCards[tid] || []).filter(c => !c.due || c.due <= now());
|
||
if (flashState.deck.length === 0) {
|
||
const host = $('#flash-host');
|
||
host.innerHTML = `<div class="flashcard"><h3>Keine fälligen Karten in „${escapeHTML(topic.mod.title)}"</h3><p style="color:var(--text-dim);margin-top:.5rem">Lege neue Karten an oder komm später wieder.</p><div style="margin-top:1rem"><button class="btn-primary" id="back-to-flash">Zurück</button></div></div>`;
|
||
$('#back-to-flash').addEventListener('click', renderFlashIntro);
|
||
return;
|
||
}
|
||
flashState.cur = 0;
|
||
flashState.showBack = false;
|
||
renderFlashCard();
|
||
}
|
||
|
||
function renderFlashCard() {
|
||
const host = $('#flash-host');
|
||
const card = flashState.deck[flashState.cur];
|
||
if (!card) {
|
||
host.innerHTML = `<div class="flashcard"><h3>Review beendet 🎉</h3><p style="color:var(--text-dim);margin-top:.5rem">Alle fälligen Karten in „${escapeHTML(flashState.topic.mod.title)}" durch.</p><div style="margin-top:1rem"><button class="btn-primary" id="back-to-flash">Weiter</button></div></div>`;
|
||
$('#back-to-flash').addEventListener('click', renderFlashIntro);
|
||
return;
|
||
}
|
||
host.innerHTML = `
|
||
<div class="flash-meta">
|
||
<span>Karte ${flashState.cur + 1} / ${flashState.deck.length}</span>
|
||
<span>${escapeHTML(flashState.topic.mod.title)}</span>
|
||
</div>
|
||
<div class="flashcard" id="flash-card" tabindex="0" role="button" aria-label="Karte umdrehen">
|
||
${flashState.showBack
|
||
? `<div class="flashcard-back"><strong>${escapeHTML(card.front)}</strong>${escapeHTML(card.back)}</div>`
|
||
: `<div class="flashcard-front">${escapeHTML(card.front)}</div>${card.hint ? `<div class="flashcard-hint">Hinweis: ${escapeHTML(card.hint)}</div>` : ''}<div style="margin-top:1rem;font-size:.75rem;color:var(--text-mute)">Klicken oder Leertaste drücken zum Umdrehen</div>`}
|
||
</div>
|
||
${flashState.showBack ? `
|
||
<div class="flash-controls">
|
||
<button class="flash-btn" data-rating="0">Wieder<span class="label">< 1 Min</span></button>
|
||
<button class="flash-btn" data-rating="1">Schwer<span class="label">~1 Tag</span></button>
|
||
<button class="flash-btn" data-rating="2">Gut<span class="label">~3 Tage</span></button>
|
||
<button class="flash-btn" data-rating="3">Leicht<span class="label">~7 Tage</span></button>
|
||
</div>
|
||
<div class="deepdive-bar">
|
||
<button class="deepdive-btn" data-kind="more" title="Vertiefende Erklärung">💡 Mehr dazu</button>
|
||
<button class="deepdive-btn" data-kind="sources" title="Konkrete Quellen">📚 Quellen</button>
|
||
<button class="deepdive-btn" data-kind="example" title="Praxis-Beispiel">🎯 Beispiel</button>
|
||
<button class="deepdive-btn" data-kind="ask" title="Eigene Frage stellen">❓ Frag dazu</button>
|
||
</div>
|
||
<div id="flash-deepdive" class="deepdive-panel hidden"></div>
|
||
` : ''}
|
||
`;
|
||
$('#flash-card').addEventListener('click', flipCard);
|
||
$('#flash-card').addEventListener('keydown', (ev) => {
|
||
if (ev.key === ' ' || ev.key === 'Enter') { ev.preventDefault(); flipCard(); }
|
||
});
|
||
$('#flash-card').focus();
|
||
$$('.flash-btn').forEach(btn => btn.addEventListener('click', () => rateCard(parseInt(btn.dataset.rating, 10))));
|
||
$$('.deepdive-btn').forEach(btn => btn.addEventListener('click', () => cardDeepdive(btn.dataset.kind, card)));
|
||
}
|
||
async function cardDeepdive(kind, card) {
|
||
const prompts = {
|
||
more: `Zu dieser Lernkarte — gib mir 2-3 vertiefende Informationen, die über die Karte hinausgehen. Keine Wiederholung.\n\nFrage: ${card.front}\nAntwort: ${card.back}${card.hint ? '\nHinweis: ' + card.hint : ''}\n\nMarkdown erlaubt. Kurz halten (≤180 Wörter).`,
|
||
sources: `Für diese Lernkarte — wo kann der Lerner das vertiefen? Nenne 3-5 konkrete öffentliche Quellen (Gesetzestext + Paragraph, Norm-Nummer, Fachbuch mit Autor, Paper, offizielle Webseite, IHK-Leitfaden). Je Quelle EINE Zeile. Keine Allgemeinplätze wie „Fachliteratur".\n\nFrage: ${card.front}\nAntwort: ${card.back}\n\nFormat: Markdown-Liste.`,
|
||
example: `Praxis-Beispiel zu dieser Lernkarte — ein konkretes Szenario aus dem Berufsalltag in 3-5 Sätzen, kein Theorie-Kram.\n\nFrage: ${card.front}\nAntwort: ${card.back}`,
|
||
ask: null,
|
||
};
|
||
const host = $('#flash-deepdive');
|
||
host.classList.remove('hidden');
|
||
if (kind === 'ask') {
|
||
const q = window.prompt('Deine Frage zu dieser Karte:', '');
|
||
if (!q || !q.trim()) { host.classList.add('hidden'); return; }
|
||
host.innerHTML = `<div class="dd-loading"><span class="dots"><span></span><span></span><span></span></span> <span>denkt …</span></div>`;
|
||
try {
|
||
const data = await chatAPI(`Zu dieser Lernkarte:\nFrage: ${card.front}\nAntwort: ${card.back}\n\nMeine Zusatzfrage: ${q.trim()}\n\nBeantworte präzise, Markdown erlaubt, ≤200 Wörter.`, []);
|
||
host.innerHTML = '<div class="dd-body">' + renderMD(data.reply || '') + '</div><button class="btn-secondary close-dd">Schließen</button>';
|
||
} catch (e) { host.innerHTML = `<div class="msg sys">Fehler: ${e.message || 'unbekannt'}</div><button class="btn-secondary close-dd">Schließen</button>`; }
|
||
} else {
|
||
host.innerHTML = `<div class="dd-loading"><span class="dots"><span></span><span></span><span></span></span> <span>${kind === 'sources' ? 'sucht Quellen' : kind === 'example' ? 'baut Beispiel' : 'vertieft'} …</span></div>`;
|
||
try {
|
||
const data = await chatAPI(prompts[kind], []);
|
||
host.innerHTML = '<div class="dd-body">' + renderMD(data.reply || '') + '</div><button class="btn-secondary close-dd">Schließen</button>';
|
||
} catch (e) { host.innerHTML = `<div class="msg sys">Fehler: ${e.message || 'unbekannt'}</div><button class="btn-secondary close-dd">Schließen</button>`; }
|
||
}
|
||
const cb = $('#flash-deepdive .close-dd');
|
||
if (cb) cb.addEventListener('click', () => host.classList.add('hidden'));
|
||
}
|
||
async function quizDeepdive(kind, q, chosenIdx, correctIdx) {
|
||
const host = $('#quiz-deepdive');
|
||
host.classList.remove('hidden');
|
||
const chosenTxt = q.options[chosenIdx], correctTxt = q.options[correctIdx];
|
||
const prompts = {
|
||
more: `Gib mir zu dieser Quiz-Frage 2-3 weiterführende Infos, die über die knappe Erklärung hinausgehen. Kein Wiederholen.\n\nFrage: ${q.q}\nRichtig: ${correctTxt}\nErklärung: ${q.explain || '(—)'}\n\nMarkdown erlaubt, ≤180 Wörter.`,
|
||
sources: `Für diese Quiz-Frage — wo kann der Lerner das vertiefen? 3-5 konkrete öffentliche Quellen (Gesetzestext+Paragraph, Norm-Nummer, Fachbuch mit Autor, Paper, offizielle Webseite). Je Quelle EINE Zeile, keine Allgemeinplätze.\n\nFrage: ${q.q}\nRichtig: ${correctTxt}\n\nMarkdown-Liste.`,
|
||
why: chosenIdx === correctIdx
|
||
? `Ich hab richtig geantwortet — erkläre mir NOCH tiefer warum das die richtige Wahl ist. Was hätte falsch gelegen bei den anderen Optionen? Je Option 1 Satz.\n\nFrage: ${q.q}\nOptionen: ${q.options.map((o,i)=>(i+1)+'. '+o).join(' | ')}\nRichtig: ${correctTxt}`
|
||
: `Ich hab „${chosenTxt}" gewählt, richtig wäre „${correctTxt}". Erkläre mir präzise wo mein Denkfehler lag und wie ich das beim nächsten Mal anders angehe. Sokratisch wenn möglich.\n\nFrage: ${q.q}\nMeine Antwort: ${chosenTxt}\nRichtig: ${correctTxt}`,
|
||
ask: null,
|
||
};
|
||
if (kind === 'ask') {
|
||
const userQ = window.prompt('Deine Frage zu dieser Quiz-Frage:', '');
|
||
if (!userQ || !userQ.trim()) { host.classList.add('hidden'); return; }
|
||
host.innerHTML = `<div class="dd-loading"><span class="dots"><span></span><span></span><span></span></span> <span>denkt …</span></div>`;
|
||
try {
|
||
const data = await chatAPI(`Zur Quiz-Frage: "${q.q}"\nRichtige Antwort: ${correctTxt}\nMeine Antwort: ${chosenTxt}\n\nZusatzfrage: ${userQ.trim()}\n\nBeantworte präzise, Markdown, ≤200 Wörter.`, []);
|
||
host.innerHTML = '<div class="dd-body">' + renderMD(data.reply || '') + '</div><button class="btn-secondary close-dd">Schließen</button>';
|
||
} catch (e) { host.innerHTML = `<div class="msg sys">Fehler: ${e.message || 'unbekannt'}</div><button class="btn-secondary close-dd">Schließen</button>`; }
|
||
} else {
|
||
host.innerHTML = `<div class="dd-loading"><span class="dots"><span></span><span></span><span></span></span> <span>${kind === 'sources' ? 'sucht Quellen' : kind === 'why' ? 'analysiert' : 'vertieft'} …</span></div>`;
|
||
try {
|
||
const data = await chatAPI(prompts[kind], []);
|
||
host.innerHTML = '<div class="dd-body">' + renderMD(data.reply || '') + '</div><button class="btn-secondary close-dd">Schließen</button>';
|
||
} catch (e) { host.innerHTML = `<div class="msg sys">Fehler: ${e.message || 'unbekannt'}</div><button class="btn-secondary close-dd">Schließen</button>`; }
|
||
}
|
||
const cb = $('#quiz-deepdive .close-dd');
|
||
if (cb) cb.addEventListener('click', () => host.classList.add('hidden'));
|
||
}
|
||
function flipCard() {
|
||
if (flashState.showBack) return;
|
||
const el = $('#flash-card');
|
||
if (el) { el.classList.add('flipping'); setTimeout(() => { flashState.showBack = true; renderFlashCard(); }, 120); }
|
||
}
|
||
function rateCard(rating) {
|
||
const card = flashState.deck[flashState.cur];
|
||
// SM-2 algorithm
|
||
const q = rating; // 0..3 (we map: 0→ again, 1→hard, 2→good, 3→easy)
|
||
const qMap = [0, 3, 4, 5]; // SM-2 uses 0..5, we map our 0..3 to subset
|
||
const sm2q = qMap[q];
|
||
if (sm2q < 3) {
|
||
card.reps = 0;
|
||
card.interval = 0;
|
||
card.due = now() + 60 * 1000; // 1 min
|
||
} else {
|
||
card.reps += 1;
|
||
if (card.reps === 1) card.interval = 1;
|
||
else if (card.reps === 2) card.interval = 3;
|
||
else card.interval = Math.round(card.interval * card.ef);
|
||
card.due = now() + card.interval * 86400 * 1000;
|
||
}
|
||
card.ef = Math.max(1.3, card.ef + (0.1 - (5 - sm2q) * (0.08 + (5 - sm2q) * 0.02)));
|
||
saveFlashCards();
|
||
|
||
// XP
|
||
state.flashCardsRated += 1;
|
||
state.totalAnswers += 1;
|
||
if (q === 2) { addXP(5, 'Flashcard Gut'); state.correctAnswers += 1; }
|
||
else if (q === 3) { addXP(2, 'Flashcard Leicht'); state.correctAnswers += 1; }
|
||
|
||
// Track per-run ratings for modulePassedFlash (all cards in deck rated >= Gut)
|
||
flashState.runRatings = flashState.runRatings || [];
|
||
flashState.runRatings.push(q);
|
||
|
||
saveState();
|
||
touchActivity();
|
||
checkBadges();
|
||
|
||
flashState.showBack = false;
|
||
flashState.cur += 1;
|
||
|
||
// If end of deck and topic set, check if all cards rated Gut/Leicht
|
||
if (flashState.cur >= flashState.deck.length && flashState.topic) {
|
||
const allGood = flashState.runRatings.length >= 3 && flashState.runRatings.every(r => r >= 2);
|
||
if (allGood) {
|
||
const modId = flashState.topic.mod.id;
|
||
state.modulePassedFlash[modId] = true;
|
||
saveState();
|
||
checkBadges();
|
||
}
|
||
flashState.runRatings = [];
|
||
}
|
||
|
||
renderFlashCard();
|
||
}
|
||
|
||
// ==== Progress ====
|
||
function renderProgress() {
|
||
const host = $('#progress-host');
|
||
const li = levelInfo();
|
||
const masteryRows = Object.entries(state.mastery)
|
||
.map(([cid, m]) => {
|
||
const c = CURRICULA.curricula.find(x => x.id === cid);
|
||
if (!c || m.total === 0) return null;
|
||
const pct = Math.round(m.correct / m.total * 100);
|
||
return { title: c.title, color: c.color, pct, correct: m.correct, total: m.total };
|
||
})
|
||
.filter(Boolean)
|
||
.sort((a, b) => b.pct - a.pct);
|
||
|
||
const badges = (CURRICULA.badges || []);
|
||
host.innerHTML = `
|
||
<div class="progress-grid">
|
||
<div class="stat-row">
|
||
<div class="stat-card accent">
|
||
<div class="val">${state.xp}</div>
|
||
<div class="lbl">XP gesamt</div>
|
||
</div>
|
||
<div class="stat-card level">
|
||
<div class="val">Lvl ${li.levelNum} · ${li.title}</div>
|
||
<div class="lbl">${li.next ? li.next.min - state.xp + ' XP bis ' + li.next.title : 'Top-Level erreicht'}</div>
|
||
</div>
|
||
<div class="stat-card streak">
|
||
<div class="val">🔥 ${state.currentStreak}</div>
|
||
<div class="lbl">Tage-Streak (max. ${state.maxStreak})</div>
|
||
</div>
|
||
</div>
|
||
<div class="stat-row">
|
||
<div class="stat-card">
|
||
<div class="val">${state.totalAnswers}</div>
|
||
<div class="lbl">Antworten gesamt</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="val">${state.totalAnswers === 0 ? '0%' : Math.round(state.correctAnswers / state.totalAnswers * 100) + '%'}</div>
|
||
<div class="lbl">Trefferquote</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="val">${state.completedQuizzes}</div>
|
||
<div class="lbl">Quizze</div>
|
||
</div>
|
||
</div>
|
||
|
||
${li.next ? `
|
||
<div class="section-card">
|
||
<h3>Fortschritt zu „${li.next.title}"</h3>
|
||
<div class="bar-bg"><div class="bar-fg" style="width:${li.pct}%"></div></div>
|
||
<div style="margin-top:.4rem;font-size:.78rem;color:var(--text-dim)">${Math.round(li.pct)}% · ${state.xp} / ${li.next.min} XP</div>
|
||
</div>` : ''}
|
||
|
||
<div class="section-card">
|
||
<h3>Mastery pro Curriculum</h3>
|
||
${masteryRows.length === 0
|
||
? '<p style="color:var(--text-dim);font-size:.88rem">Noch keine Daten. Mach ein Quiz, um Mastery aufzubauen.</p>'
|
||
: '<div class="mastery-row">' + masteryRows.map(r => `
|
||
<div class="mastery-bar">
|
||
<div class="mastery-head">
|
||
<span>${escapeHTML(r.title)}</span>
|
||
<span class="pct">${r.pct}% <span style="color:var(--text-mute);font-weight:400;margin-left:.4rem">(${r.correct}/${r.total})</span></span>
|
||
</div>
|
||
<div class="bar-bg"><div class="bar-fg" style="width:${r.pct}%"></div></div>
|
||
</div>
|
||
`).join('') + '</div>'
|
||
}
|
||
</div>
|
||
|
||
<div class="section-card">
|
||
<h3>Abzeichen (${Object.keys(state.badges).length}/${badges.length})</h3>
|
||
<div class="badge-grid">
|
||
${badges.map(b => {
|
||
const earned = !!state.badges[b.id];
|
||
const icons = { award:'🏆', flame:'🔥', star:'⭐', calendar:'🗓', crown:'👑', moon:'🌙', sun:'☀️', shield:'🛡️', detective:'🕵️', clock:'⏱️', handshake:'🤝', medal:'🎖️', diamond:'💎' };
|
||
return `<div class="badge ${earned ? 'earned' : 'locked'}">
|
||
<span class="icon">${icons[b.icon] || '🎖'}</span>
|
||
<div class="title">${escapeHTML(b.title)}</div>
|
||
<div class="desc">${escapeHTML(b.description)}</div>
|
||
</div>`;
|
||
}).join('')}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section-card">
|
||
<h3>Daten zurücksetzen</h3>
|
||
<p style="color:var(--text-dim);font-size:.85rem;margin-bottom:.75rem">Lokal gespeichert (kein Server-Tracking).</p>
|
||
<button class="btn-sec" id="reset-data">Alle Fortschritte löschen</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
$('#reset-data').addEventListener('click', () => {
|
||
if (confirm('Alle lokalen Daten (XP, Streaks, Flashcards, Chat) wirklich löschen?')) {
|
||
localStorage.removeItem(LS_KEY);
|
||
localStorage.removeItem(LS_CHAT);
|
||
localStorage.removeItem(LS_FLASH);
|
||
state = loadState();
|
||
chatHistory = [];
|
||
flashCards = {};
|
||
restoreChat();
|
||
renderProgress();
|
||
toast('Daten zurückgesetzt.', 'success');
|
||
}
|
||
});
|
||
}
|
||
|
||
// ==== Curriculum tree ====
|
||
function renderCurriculum() {
|
||
const host = $('#curr-host');
|
||
host.innerHTML = '<div class="curr-tree" id="curr-tree-root"></div>';
|
||
const root = $('#curr-tree-root');
|
||
CURRICULA.curricula.forEach(c => {
|
||
const rootEl = document.createElement('details');
|
||
rootEl.className = 'curr-root';
|
||
rootEl.innerHTML = `
|
||
<summary class="curr-root-head" style="list-style:none">
|
||
<span class="ic" style="background:${c.color}">${c.title.charAt(0)}</span>
|
||
<span class="txt">
|
||
<strong>${escapeHTML(c.title)}</strong>
|
||
<small>${escapeHTML(c.short)} · ${c.modules.length} Module</small>
|
||
</span>
|
||
<span class="chev">▾</span>
|
||
</summary>
|
||
<div class="curr-mods">
|
||
${c.modules.map(m => `
|
||
<div class="curr-mod" data-curr="${c.id}" data-mod="${m.id}">
|
||
<span class="m-title">${escapeHTML(m.title)}</span>
|
||
<span class="m-arrow">→</span>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
`;
|
||
root.appendChild(rootEl);
|
||
});
|
||
$$('.curr-mod').forEach(el => el.addEventListener('click', () => {
|
||
const c = CURRICULA.curricula.find(x => x.id === el.dataset.curr);
|
||
const m = c.modules.find(x => x.id === el.dataset.mod);
|
||
renderModuleDetail(c, m);
|
||
}));
|
||
}
|
||
|
||
function renderModuleDetail(c, m) {
|
||
const host = $('#curr-host');
|
||
host.innerHTML = `
|
||
<div class="mod-detail">
|
||
<div class="breadcrumb">
|
||
<button id="breadcrumb-back">← Artikel-Library</button> / ${escapeHTML(c.title)}
|
||
</div>
|
||
<h3>${escapeHTML(m.title)}</h3>
|
||
<h4>Lernziele</h4>
|
||
<ul>${m.objectives.map(o => `<li>${escapeHTML(o)}</li>`).join('')}</ul>
|
||
<h4>Kernthemen</h4>
|
||
<ul>${(m.topics || []).map(t => `<li>${escapeHTML(t)}</li>`).join('')}</ul>
|
||
${m.hours ? `<p style="margin-top:.5rem;color:var(--text-dim);font-size:.85rem">Umfang: ~${m.hours} h</p>` : ''}
|
||
<div class="mod-actions">
|
||
<button class="btn-primary" data-action="quiz">Quiz zu diesem Thema</button>
|
||
<button class="btn-sec" data-action="flash">Flashcards</button>
|
||
<button class="btn-sec" data-action="ask">Cora fragen</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
$('#breadcrumb-back').addEventListener('click', renderCurriculum);
|
||
$$('.mod-actions button').forEach(b => b.addEventListener('click', () => {
|
||
const act = b.dataset.action;
|
||
if (act === 'quiz') {
|
||
quizState.topic = { curr: c, mod: m };
|
||
switchMode('quiz');
|
||
setTimeout(() => startQuiz({ curr: c, mod: m }, 10), 50);
|
||
} else if (act === 'flash') {
|
||
flashState.topic = { curr: c, mod: m };
|
||
switchMode('flash');
|
||
setTimeout(() => {
|
||
const tid = m.id;
|
||
if (flashCards[tid] && flashCards[tid].length) startReview({ curr: c, mod: m });
|
||
else loadNewFlashCards({ curr: c, mod: m }, 10);
|
||
}, 50);
|
||
} else if (act === 'ask') {
|
||
switchMode('chat');
|
||
$('#composer').value = `Erklär mir kurz und klar: ${m.title} (aus ${c.title}). Fokus auf ${m.topics.slice(0,3).join(', ')}.`;
|
||
$('#composer').focus();
|
||
}
|
||
}));
|
||
}
|
||
|
||
// ==== Mode switching ====
|
||
function switchMode(mode) {
|
||
$$('.tab').forEach(t => {
|
||
const isActive = t.dataset.mode === mode;
|
||
t.setAttribute('aria-selected', isActive ? 'true' : 'false');
|
||
});
|
||
$$('.view').forEach(v => {
|
||
v.dataset.active = (v.id === 'view-' + mode) ? 'true' : 'false';
|
||
});
|
||
// The composer is shown only in chat mode
|
||
$('#composer-form').classList.toggle('hidden', mode !== 'chat');
|
||
|
||
if (mode === 'quiz') renderQuizIntro();
|
||
if (mode === 'flash') renderFlashIntro();
|
||
if (mode === 'progress') renderProgress();
|
||
if (mode === 'curriculum') renderCurriculum();
|
||
if (mode === 'chat') setTimeout(() => $('#composer').focus(), 50);
|
||
}
|
||
|
||
// ==== Escape HTML ====
|
||
function escapeHTML(s) {
|
||
return String(s).replace(/[&<>"']/g, ch => ({
|
||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||
}[ch]));
|
||
}
|
||
|
||
// ==== Boot ====
|
||
// Lernreise-State (URL-Params), einmal beim Boot ausgelesen.
|
||
// Wird von renderJourneyBanner() für die UI und von sendJourneyBeacon()
|
||
// für die postMessage-Kommunikation zurück zum Portal-Tab benutzt.
|
||
const JOURNEY = (() => {
|
||
try {
|
||
const p = new URLSearchParams(window.location.search);
|
||
const slug = p.get('journey');
|
||
if (!slug) return null;
|
||
const step = parseInt(p.get('step') || '0', 10);
|
||
const total = parseInt(p.get('total') || '0', 10);
|
||
let returnOrigin = '';
|
||
try {
|
||
if (p.get('return')) returnOrigin = new URL(p.get('return')).origin;
|
||
} catch {}
|
||
return { slug, step, total, returnOrigin };
|
||
} catch { return null; }
|
||
})();
|
||
|
||
// Beacon zurück an den Portal-Tab. Funktioniert nur wenn das Widget über
|
||
// `target="_blank" rel="opener"` aus der Lernreise-Seite geöffnet wurde
|
||
// (window.opener erreichbar). Bei direktem Aufruf der Bot-URL no-op.
|
||
function sendJourneyBeacon(event, extra = {}) {
|
||
if (!JOURNEY || !window.opener) return;
|
||
if (!JOURNEY.returnOrigin) return;
|
||
try {
|
||
window.opener.postMessage({
|
||
type: 'qognio:journey-step-event',
|
||
journey: JOURNEY.slug,
|
||
step: JOURNEY.step,
|
||
event, // 'quiz_passed' | 'curriculum_completed' | 'manual'
|
||
...extra,
|
||
}, JOURNEY.returnOrigin);
|
||
} catch (e) {
|
||
console.warn('journey beacon failed', e);
|
||
}
|
||
}
|
||
|
||
// Lernreise-Banner: gerendert wenn der Bot via Portal-Lernreise geöffnet wurde
|
||
// (`?journey=<slug>&step=<n>&total=<m>&return=<url>`). Nicht-invasiv, weglinkbar.
|
||
function renderJourneyBanner() {
|
||
try {
|
||
const params = new URLSearchParams(window.location.search);
|
||
const journey = params.get('journey');
|
||
const step = parseInt(params.get('step') || '0', 10);
|
||
const total = parseInt(params.get('total') || '0', 10);
|
||
const ret = params.get('return') || '';
|
||
if (!journey || step <= 0) return;
|
||
// Dismissed for this journey/step combo? (LS sticky)
|
||
const dismissKey = 'qognio.journey-banner.dismiss.v1';
|
||
const dismissed = JSON.parse(localStorage.getItem(dismissKey) || '{}');
|
||
const dKey = journey + ':' + step;
|
||
if (dismissed[dKey]) return;
|
||
const bar = document.createElement('div');
|
||
bar.id = 'qognio-journey-banner';
|
||
bar.setAttribute('role', 'note');
|
||
bar.style.cssText = [
|
||
'position:sticky', 'top:0', 'z-index:100',
|
||
'display:flex', 'align-items:center', 'gap:.75rem',
|
||
'padding:.55rem .9rem',
|
||
'background:linear-gradient(90deg, rgba(124,58,237,.18), rgba(34,211,238,.10))',
|
||
'border-bottom:1px solid rgba(124,58,237,.35)',
|
||
'color:var(--text, #e5e7eb)', 'font-size:.875rem',
|
||
'box-shadow:0 1px 0 rgba(0,0,0,.05)',
|
||
].join(';');
|
||
const human = journey.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||
bar.innerHTML = [
|
||
'<span aria-hidden="true" style="font-size:1.05em;line-height:1">🧭</span>',
|
||
'<span style="font-weight:600">Lernreise: ' + human + '</span>',
|
||
'<span style="opacity:.7">Schritt ' + step + (total > 0 ? ' von ' + total : '') + '</span>',
|
||
'<span style="flex:1"></span>',
|
||
ret ? '<a href="' + ret.replace(/"/g, '"') + '" style="text-decoration:none;padding:.3rem .65rem;border-radius:.4rem;background:rgba(124,58,237,.25);color:inherit;font-weight:500">↩ Zurück zur Reise</a>' : '',
|
||
'<button type="button" id="qognio-journey-dismiss" aria-label="Banner ausblenden" title="Banner ausblenden" style="background:transparent;border:0;color:inherit;font-size:1.1em;line-height:1;cursor:pointer;opacity:.7;padding:.25rem .5rem">×</button>',
|
||
].join('');
|
||
document.body.prepend(bar);
|
||
const btn = bar.querySelector('#qognio-journey-dismiss');
|
||
if (btn) btn.addEventListener('click', () => {
|
||
dismissed[dKey] = Date.now();
|
||
localStorage.setItem(dismissKey, JSON.stringify(dismissed));
|
||
bar.remove();
|
||
});
|
||
} catch (e) {
|
||
console.warn('journey banner failed', e);
|
||
}
|
||
}
|
||
|
||
async function boot() {
|
||
renderJourneyBanner();
|
||
try {
|
||
const r = await fetch('curricula.json?v=2026-04-21');
|
||
if (!r.ok) throw new Error('curricula.json HTTP ' + r.status);
|
||
CURRICULA = await r.json();
|
||
} catch (e) {
|
||
toast('Curriculum konnte nicht geladen werden.', 'error', 6000);
|
||
console.error(e);
|
||
return;
|
||
}
|
||
restoreChat();
|
||
touchActivity();
|
||
|
||
// Tabs
|
||
$$('.tab').forEach(tab => tab.addEventListener('click', () => switchMode(tab.dataset.mode)));
|
||
|
||
// Welcome-card shortcuts
|
||
$$('[data-goto]').forEach(b => b.addEventListener('click', () => {
|
||
state.seenWelcome = true; saveState();
|
||
switchMode(b.dataset.goto);
|
||
}));
|
||
|
||
// Welcome-card prompt-fillers (special *_REQUEST modes)
|
||
$$('[data-prompt]').forEach(b => b.addEventListener('click', () => {
|
||
state.seenWelcome = true; saveState();
|
||
switchMode('chat');
|
||
const composer = $('#composer');
|
||
if (composer) {
|
||
composer.value = b.dataset.prompt;
|
||
autogrow(composer);
|
||
setTimeout(() => composer.focus(), 50);
|
||
}
|
||
}));
|
||
|
||
// Composer
|
||
const form = $('#composer-form');
|
||
const ta = $('#composer');
|
||
form.addEventListener('submit', (ev) => {
|
||
ev.preventDefault();
|
||
const text = ta.value.trim();
|
||
if (!text && pendingAttachments.length === 0) return;
|
||
ta.value = '';
|
||
autogrow(ta);
|
||
sendChat(text);
|
||
});
|
||
ta.addEventListener('keydown', (ev) => {
|
||
if (ev.key === 'Enter' && !ev.shiftKey) {
|
||
ev.preventDefault();
|
||
form.requestSubmit();
|
||
}
|
||
});
|
||
|
||
// Attachments
|
||
const fileInput = $('#composer-file');
|
||
const attachBtn = $('#composer-attach');
|
||
if (attachBtn && fileInput) {
|
||
attachBtn.addEventListener('click', () => fileInput.click());
|
||
fileInput.addEventListener('change', async (ev) => {
|
||
await addFiles(ev.target.files);
|
||
ev.target.value = '';
|
||
});
|
||
}
|
||
// Drag & Drop on composer
|
||
['dragenter', 'dragover'].forEach(evt => form.addEventListener(evt, (ev) => {
|
||
if (ev.dataTransfer && Array.from(ev.dataTransfer.types || []).includes('Files')) {
|
||
ev.preventDefault();
|
||
form.classList.add('dragover');
|
||
}
|
||
}));
|
||
['dragleave', 'drop'].forEach(evt => form.addEventListener(evt, (ev) => {
|
||
if (evt === 'dragleave' && ev.target !== form) return;
|
||
form.classList.remove('dragover');
|
||
}));
|
||
form.addEventListener('drop', async (ev) => {
|
||
if (ev.dataTransfer && ev.dataTransfer.files && ev.dataTransfer.files.length) {
|
||
ev.preventDefault();
|
||
await addFiles(ev.dataTransfer.files);
|
||
}
|
||
});
|
||
// Paste image / file
|
||
ta.addEventListener('paste', async (ev) => {
|
||
const items = ev.clipboardData && ev.clipboardData.files;
|
||
if (items && items.length) {
|
||
ev.preventDefault();
|
||
await addFiles(items);
|
||
}
|
||
});
|
||
ta.addEventListener('input', () => autogrow(ta));
|
||
function autogrow(el) {
|
||
el.style.height = 'auto';
|
||
el.style.height = Math.min(el.scrollHeight, 140) + 'px';
|
||
}
|
||
|
||
// Keyboard shortcuts Ctrl+1..5
|
||
document.addEventListener('keydown', (ev) => {
|
||
if (!(ev.ctrlKey || ev.metaKey)) return;
|
||
const map = { '1': 'chat', '2': 'quiz', '3': 'flash', '4': 'progress', '5': 'curriculum' };
|
||
if (map[ev.key]) { ev.preventDefault(); switchMode(map[ev.key]); }
|
||
});
|
||
|
||
// Default focus
|
||
ta.focus();
|
||
// Update status once per min (visual cue)
|
||
setInterval(() => { /* placeholder for future heartbeat */ }, 60000);
|
||
|
||
console.log('Cora v2026-04-21 ready. XP:', state.xp, 'Streak:', state.currentStreak);
|
||
}
|
||
|
||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
|
||
else boot();
|
||
})();
|