1804 lines
87 KiB
JavaScript
1804 lines
87 KiB
JavaScript
|
|
/* Luna — PhysioTutor Widget
|
|||
|
|
* Vanilla JS, no build, no framework, keine externen Fonts/Analytics.
|
|||
|
|
* Chat | Quiz | Flashcards | Fortschritt | Lehrplan — localStorage only.
|
|||
|
|
*/
|
|||
|
|
(() => {
|
|||
|
|
'use strict';
|
|||
|
|
|
|||
|
|
// ==== Config ====
|
|||
|
|
const API = 'https://llm.qognio.com/api/bots/physio-tutor/chat';
|
|||
|
|
const RAW_KEY = window.__LUNA_KEY__ || '';
|
|||
|
|
const KEY = /^qb_[a-zA-Z0-9]{6,}$/.test(RAW_KEY) ? RAW_KEY : '';
|
|||
|
|
const LS_KEY = 'luna.state.v1';
|
|||
|
|
const LS_CHAT = 'luna.chat.v1';
|
|||
|
|
const LS_FLASH = 'luna.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: 'Anfänger:in' }, { min: 50, title: 'Einsteiger:in' },
|
|||
|
|
{ min: 200, title: 'Fortgeschrittene:r' }, { min: 500, title: 'Profi' },
|
|||
|
|
{ min: 1250, title: 'Expert:in' }, { min: 2500, title: 'Meister:in' }
|
|||
|
|
];
|
|||
|
|
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() {
|
|||
|
|
if (state.completedQuizzes >= 1) unlockBadge('first_quiz');
|
|||
|
|
if (state.maxQuizStreak >= 10) unlockBadge('10_quiz_streak');
|
|||
|
|
if (state.totalAnswers >= 100) unlockBadge('100_answers');
|
|||
|
|
if (state.maxStreak >= 7) unlockBadge('7_day_streak');
|
|||
|
|
if (state.completedCurricula.length >= 1) unlockBadge('curriculum_complete');
|
|||
|
|
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',
|
|||
|
|
'compliance_report'
|
|||
|
|
];
|
|||
|
|
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',
|
|||
|
|
compliance_report:'🔍 Website-Compliance-Check',
|
|||
|
|
};
|
|||
|
|
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>';
|
|||
|
|
}
|
|||
|
|
} else if (obj.type === 'compliance_report') {
|
|||
|
|
// Vera — Website-Compliance-Check (TMG/DSGVO/TTDSG)
|
|||
|
|
const ampelColor = { green: '#10b981', yellow: '#f59e0b', red: '#ef4444' }[obj.ampel] || '#8b8a99';
|
|||
|
|
const ampelEmoji = { green: '🟢', yellow: '🟡', red: '🔴' }[obj.ampel] || '⚪';
|
|||
|
|
if (obj.url) html += `<div class="struct-row"><strong>Geprüfte URL:</strong> <a href="${esc(obj.url)}" target="_blank" rel="noopener">${esc(obj.url)}</a></div>`;
|
|||
|
|
if (typeof obj.overall_score === 'number') {
|
|||
|
|
html += `<div class="struct-row" style="font-size:1.4rem;font-weight:700;color:${ampelColor};margin:.5rem 0">${ampelEmoji} Score: ${obj.overall_score} %</div>`;
|
|||
|
|
}
|
|||
|
|
if (obj.summary) html += `<div class="struct-row" style="font-style:italic;margin-bottom:.5rem">${esc(obj.summary)}</div>`;
|
|||
|
|
// Pro Section: Impressum, Datenschutz, Cookies
|
|||
|
|
const sections = [
|
|||
|
|
{ key: 'impressum', label: '📄 Impressum (TMG § 5 + § 18 MStV + ODR + VSBG)' },
|
|||
|
|
{ key: 'datenschutz', label: '🛡 Datenschutzerklärung (DSGVO Art. 13)' },
|
|||
|
|
];
|
|||
|
|
for (const s of sections) {
|
|||
|
|
const sec = obj[s.key];
|
|||
|
|
if (!sec) continue;
|
|||
|
|
const sScore = typeof sec.score === 'number' ? ` — ${sec.score} %` : '';
|
|||
|
|
const sUrl = sec.url ? ` <a href="${esc(sec.url)}" target="_blank" rel="noopener" style="font-size:.85em">↗ Quelle</a>` : '';
|
|||
|
|
html += `<div class="struct-section"><strong>${s.label}${sScore}${sUrl}</strong>`;
|
|||
|
|
if (Array.isArray(sec.ok) && sec.ok.length) {
|
|||
|
|
html += '<ul style="list-style:none;padding-left:0">';
|
|||
|
|
sec.ok.forEach(o => { html += `<li style="color:#10b981">✓ ${esc(o)}</li>`; });
|
|||
|
|
html += '</ul>';
|
|||
|
|
}
|
|||
|
|
if (Array.isArray(sec.missing) && sec.missing.length) {
|
|||
|
|
html += '<ul style="list-style:none;padding-left:0">';
|
|||
|
|
sec.missing.forEach(m => { html += `<li style="color:#ef4444">✗ ${esc(m)}</li>`; });
|
|||
|
|
html += '</ul>';
|
|||
|
|
}
|
|||
|
|
if (Array.isArray(sec.warnings) && sec.warnings.length) {
|
|||
|
|
html += '<ul style="list-style:none;padding-left:0">';
|
|||
|
|
sec.warnings.forEach(w => { html += `<li style="color:#f59e0b">⚠ ${esc(w)}</li>`; });
|
|||
|
|
html += '</ul>';
|
|||
|
|
}
|
|||
|
|
html += '</div>';
|
|||
|
|
}
|
|||
|
|
// Cookies
|
|||
|
|
const c = obj.cookies;
|
|||
|
|
if (c && typeof c === 'object') {
|
|||
|
|
const bannerEmoji = c.banner_present ? '✓' : '✗';
|
|||
|
|
const bannerColor = c.banner_present ? '#10b981' : '#ef4444';
|
|||
|
|
html += `<div class="struct-section"><strong>🍪 Cookies (TTDSG § 25)</strong>`;
|
|||
|
|
html += `<div style="color:${bannerColor}">${bannerEmoji} Cookie-Banner: ${c.banner_present ? 'erkannt' : 'NICHT erkannt'}${c.banner_signal ? ` <code style="font-size:.8em">${esc(c.banner_signal)}</code>` : ''}</div>`;
|
|||
|
|
if (Array.isArray(c.third_party_loaders_pre_consent) && c.third_party_loaders_pre_consent.length) {
|
|||
|
|
html += `<div style="color:#f59e0b;margin-top:.3rem">⚠ ${c.third_party_loaders_pre_consent.length} Drittanbieter im HTML — prüfen ob hinter Consent-Gate:</div><ul>`;
|
|||
|
|
c.third_party_loaders_pre_consent.forEach(t => { html += `<li>${esc(t)}</li>`; });
|
|||
|
|
html += '</ul>';
|
|||
|
|
}
|
|||
|
|
if (Array.isArray(c.notes) && c.notes.length) {
|
|||
|
|
html += '<ul style="margin-top:.3rem;font-size:.9em;color:var(--text-mute)">';
|
|||
|
|
c.notes.forEach(n => { html += `<li>${esc(n)}</li>`; });
|
|||
|
|
html += '</ul>';
|
|||
|
|
}
|
|||
|
|
html += '</div>';
|
|||
|
|
}
|
|||
|
|
// Next steps
|
|||
|
|
if (Array.isArray(obj.next_steps) && obj.next_steps.length) {
|
|||
|
|
html += '<div class="struct-section"><strong>Nächste Schritte:</strong><ol>';
|
|||
|
|
obj.next_steps.forEach(n => { html += `<li>${esc(n)}</li>`; });
|
|||
|
|
html += '</ol></div>';
|
|||
|
|
}
|
|||
|
|
if (obj.scanned_at) html += `<div style="font-size:.75em;color:var(--text-mute);margin-top:.5rem">Geprüft am ${esc(obj.scanned_at.replace('T',' ').slice(0,19))} UTC</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 — Luna generiert 10 Multiple-Choice-Fragen.</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">Luna erstellt ${count} 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">Luna 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">Luna 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">← Lehrplan</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">Luna 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('Luna v2026-04-21 ready. XP:', state.xp, 'Streak:', state.currentStreak);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
|
|||
|
|
else boot();
|
|||
|
|
})();
|