/** * Luna Cockpit v3 — Tutor-Mode UI for physio-tutor (Hanna). * * Spaces (top-tabs): Dokumente · Klausuren · Lernen · Steuern * Lernen-Tab: heatmap + topic-picker + 6 minigames (diagnose / klinikfall / * stimmt-das / classic-quiz / flashcards / explain) * Chat-Dock: resizable, MD-rendered, inline quiz/flashcard widgets, source-footer. */ (() => { const API = window.__API_BASE__ || "https://api.qognio.com"; const SLUG = window.__BOT_SLUG__ || "physio-tutor"; const BOT_KEY = window.__LUNA_KEY__; const BOT_ID = window.__BOT_ID__; const LS_TOKEN = "luna.pb.token"; const LS_USER = "luna.pb.user"; const LS_CHAT = "luna.cockpit.chat"; const LS_DOCK_SIZE = "luna.cockpit.dock-size"; const FOLDER_HELP = { curriculum: "Was die Uni / Schule offiziell vorgibt: Studienplan, Lernzielkatalog, " + "Klausurplan. Lädt du hier z.B. inhaltexamen2026.pdf hoch, " + "kann Luna die Klausur-Themen daraus auslesen.", official: "Skripte und Folien deiner Dozent:innen — also alles, was die " + "Lehrenden offiziell ausgegeben haben.", own: "Deine eigenen Notizen, Mitschriften, Markierungen. Luna lernt deinen " + "persönlichen Stand kennen.", role: "Schwerpunkt-Hinweise: aktuelle Lerneinheit, anstehende Termine, " + "Fokus-Themen — beeinflusst, worauf Luna sich konzentriert.", }; const $ = (sel, root) => (root || document).querySelector(sel); const $$ = (sel, root) => Array.from((root || document).querySelectorAll(sel)); // ─── State ────────────────────────────────────────────────────── let token = localStorage.getItem(LS_TOKEN); let user = (() => { try { return JSON.parse(localStorage.getItem(LS_USER) || "null"); } catch { return null; } })(); let activeSpace = "dokumente"; let activeFolder = "curriculum"; let docs = []; let klausuren = []; let mastery = []; let curricula = null; let activeMinigame = null; let chatHistory = (() => { try { return JSON.parse(localStorage.getItem(LS_CHAT) || "[]"); } catch { return []; } })(); // ─── Toasts ───────────────────────────────────────────────────── function toast(msg, kind = "info") { const stack = $("#toast-stack"); const el = document.createElement("div"); el.className = `toast ${kind}`; el.textContent = msg; stack.appendChild(el); setTimeout(() => { el.style.opacity = "0"; setTimeout(() => el.remove(), 200); }, 3500); } // ─── MD renderer (ported from core/app.js) ───────────────────── function renderMD(md) { if (!md) return ""; let s = md; s = s.replace(/&/g, "&").replace(//g, ">"); s = s.replace(/```(\w+)?\n([\s\S]*?)```/g, (_, lang, code) => `
${code}
`); s = s.replace(/`([^`\n]+)`/g, "$1"); // GFM tables 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'; header.forEach((h, i) => { html += ``; }); html += ""; rows.forEach(r => { html += ""; for (let i = 0; i < Math.max(r.length, header.length); i++) { html += ``; } html += ""; }); html += "
${h}
${r[i] || ""}
\n"; return html; }); s = s.replace(/\*\*([^*\n]+)\*\*/g, "$1"); s = s.replace(/__([^_\n]+)__/g, "$1"); s = s.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1$2"); s = s.replace(/^### (.+)$/gm, "

$1

"); s = s.replace(/^## (.+)$/gm, "

$1

"); s = s.replace(/^# (.+)$/gm, "

$1

"); s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '$1'); s = s.replace(/(?:^|\n)((?:[*\-] .+\n?)+)/g, (m) => { const items = m.trim().split("\n").map(ln => ln.replace(/^[*\-] /, "")).map(li => `
  • ${li}
  • `).join(""); return "\n"; }); s = s.replace(/(?:^|\n)((?:\d+\. .+\n?)+)/g, (m) => { const items = m.trim().split("\n").map(ln => ln.replace(/^\d+\. /, "")).map(li => `
  • ${li}
  • `).join(""); return "\n
      " + items + "
    "; }); s = s.split(/\n{2,}/).map(block => { if (/^<(h\d|ul|ol|pre|blockquote|table)/.test(block.trim())) return block; return "

    " + block.replace(/\n/g, "
    ") + "

    "; }).join("\n"); return s; } function escapeHtml(s) { return String(s).replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">","\"":""","'":"'"})[c]); } function escapeAttr(s) { return escapeHtml(s).replace(/\n/g, " "); } // ─── Login ────────────────────────────────────────────────────── function showLogin() { $("#login-screen").hidden = false; $("#main").hidden = true; $("#dock-open").hidden = true; } function showApp() { $("#login-screen").hidden = true; $("#main").hidden = false; $("#dock-open").hidden = false; if (user) { $("#auth-user").textContent = user.email; $("#auth-logout").hidden = false; } } $("#login-form").addEventListener("submit", async (e) => { e.preventDefault(); const email = $("#login-email").value.trim(); const pw = $("#login-pw").value; const errBox = $("#login-error"); errBox.hidden = true; try { const r = await fetch(`${API}/api/collections/users/auth-with-password`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ identity: email, password: pw }), }); if (!r.ok) { const data = await r.json().catch(() => ({})); throw new Error(data.message || `Login fehlgeschlagen (${r.status})`); } const data = await r.json(); token = data.token; user = { id: data.record.id, email: data.record.email, name: data.record.name, organization: data.record.organization }; localStorage.setItem(LS_TOKEN, token); localStorage.setItem(LS_USER, JSON.stringify(user)); showApp(); await refresh(); } catch (e) { errBox.textContent = e.message || "Login fehlgeschlagen"; errBox.hidden = false; } }); $("#auth-logout").addEventListener("click", () => { localStorage.removeItem(LS_TOKEN); localStorage.removeItem(LS_USER); token = null; user = null; showLogin(); }); // ─── API helper ──────────────────────────────────────────────── async function api(path, opts = {}) { const r = await fetch(`${API}${path}`, { ...opts, headers: { ...(opts.headers || {}), Authorization: `Bearer ${token}`, ...(opts.body && !(opts.body instanceof FormData) ? { "Content-Type": "application/json" } : {}), }, }); if (r.status === 401) { showLogin(); throw new Error("session_expired"); } if (!r.ok) { const text = await r.text().catch(() => ""); throw new Error(`API ${path} → ${r.status}: ${text.slice(0, 120)}`); } return r.json(); } // ─── Loaders ─────────────────────────────────────────────────── async function loadCurricula() { if (curricula) return curricula; try { const r = await fetch("/curricula.json", { cache: "no-cache" }); if (r.ok) curricula = await r.json(); } catch (e) { console.warn("curricula", e); } return curricula; } async function loadDocs() { const data = await api(`/api/tutor/documents?bot=${BOT_ID}`); docs = data.items || []; renderCounts(); renderDocList(); renderStatus(); } async function loadKlausuren() { try { const data = await api(`/api/tutor/klausuren?bot=${BOT_ID}`); klausuren = data.items || []; } catch (e) { console.warn("klausuren", e); klausuren = []; } renderKlausuren(); } async function loadMastery() { try { const data = await api(`/api/tutor/mastery?bot=${BOT_ID}`); mastery = data.items || []; } catch (e) { mastery = []; } renderHeatmap(); renderTopicGrid(); // re-render to update star indicators } async function loadPersona() { try { const data = await api(`/api/tutor/persona?bot=${BOT_ID}`); $("#persona-overrides").value = data.overrides || ""; } catch { /* */ } } // ─── Upload ──────────────────────────────────────────────────── async function uploadFile(file) { if (file.size > 20 * 1024 * 1024) { toast(`${file.name}: zu groß (max 20 MB)`, "error"); return; } const mime = file.type || guessMime(file.name); const row = document.createElement("div"); row.className = "upload-progress-row"; row.innerHTML = `${escapeHtml(file.name)}
    `; $("#upload-progress").hidden = false; $("#upload-progress").appendChild(row); const fill = row.querySelector(".upload-bar-fill"); const stat = row.querySelector(".upload-status"); try { const presign = await api("/api/tutor/upload-url", { method: "POST", body: JSON.stringify({ bot: BOT_ID, folder: activeFolder, filename: file.name, mime, size: file.size }), }); fill.style.width = "20%"; stat.textContent = "S3…"; await new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open("PUT", presign.url, true); xhr.setRequestHeader("Content-Type", presign.content_type); xhr.upload.onprogress = (e) => { if (e.lengthComputable) fill.style.width = (20 + (e.loaded / e.total) * 60) + "%"; }; xhr.onload = () => xhr.status >= 200 && xhr.status < 300 ? resolve() : reject(new Error(`PUT ${xhr.status}`)); xhr.onerror = () => reject(new Error("network")); xhr.send(file); }); fill.style.width = "85%"; stat.textContent = "registriere…"; await api("/api/tutor/documents", { method: "POST", body: JSON.stringify({ bot: BOT_ID, folder: activeFolder, filename: file.name, mime, size: file.size, s3_key: presign.key }), }); fill.style.width = "100%"; stat.textContent = "✓"; stat.style.color = "var(--success)"; setTimeout(() => row.remove(), 1500); toast(`${file.name} hochgeladen`, "success"); await loadDocs(); // Re-load klausuren after a delay (auto-parse may take a few seconds) if (activeFolder === "curriculum") setTimeout(() => loadKlausuren(), 6000); } catch (e) { console.error(e); stat.textContent = "✗"; stat.style.color = "var(--danger)"; toast(`Upload fehlgeschlagen: ${e.message}`, "error"); } } async function toggleDoc(id) { const d = docs.find(x => x.id === id); if (!d) return; d.enabled = !d.enabled; renderDocList(); try { const r = await api(`/api/tutor/documents/${id}/toggle`, { method: "POST" }); d.enabled = r.enabled; renderDocList(); renderStatus(); } catch { d.enabled = !d.enabled; renderDocList(); toast("Toggle fehlgeschlagen", "error"); } } async function deleteDoc(id, name) { if (!confirm(`„${name}" wirklich löschen? Die Datei wird auch im Bunker entfernt.`)) return; try { await api(`/api/tutor/documents/${id}`, { method: "DELETE" }); toast("Datei gelöscht", "success"); await loadDocs(); } catch (e) { toast(`Löschen fehlgeschlagen: ${e.message}`, "error"); } } // ─── Render: Documents space ─────────────────────────────────── function renderCounts() { const counts = { curriculum: 0, official: 0, own: 0, role: 0 }; for (const d of docs) counts[d.folder] = (counts[d.folder] || 0) + 1; for (const [k, v] of Object.entries(counts)) { const el = document.querySelector(`.folder-count[data-count="${k}"]`); if (el) el.textContent = v; } } function renderDocList() { const list = $("#doc-list"); const empty = $("#doc-empty"); const filtered = docs.filter(d => d.folder === activeFolder).sort((a, b) => (b.created || "").localeCompare(a.created || "")); list.innerHTML = ""; if (!filtered.length) { empty.hidden = false; return; } empty.hidden = true; for (const d of filtered) { const li = document.createElement("li"); li.className = `doc-row ${d.enabled ? "" : "is-disabled"}`; const ext = d.has_text ? "✓ indexiert" : "⌛ indexiere…"; const sizeKb = d.file_size ? `· ${(d.file_size/1024).toFixed(0)} KB ` : ""; li.innerHTML = `

    ${escapeHtml(d.filename)}

    ${ext} ${sizeKb}· ${relativeTime(d.created)}

    `; list.appendChild(li); } list.querySelectorAll(".toggle").forEach(el => el.addEventListener("click", () => toggleDoc(el.dataset.id))); list.querySelectorAll("[data-del]").forEach(el => el.addEventListener("click", () => deleteDoc(el.dataset.del, el.dataset.name))); } function renderStatus() { const enabled = docs.filter(d => d.enabled); const byFolder = enabled.reduce((acc, d) => { acc[d.folder] = (acc[d.folder] || 0) + 1; return acc; }, {}); const summary = $("#status-summary"); if (!enabled.length && !mastery.length) { summary.innerHTML = `Noch keine Quellen aktiv. Lade ein Klausurplan-Dokument hoch oder starte eine Diagnose im „Lernen"-Tab.`; return; } const parts = []; if (byFolder.curriculum) parts.push(`${byFolder.curriculum} Curriculum`); if (byFolder.official) parts.push(`${byFolder.official} offizielle`); if (byFolder.own) parts.push(`${byFolder.own} eigene`); if (byFolder.role) parts.push(`${byFolder.role} Schwerpunkt`); if (mastery.length) parts.push(`${mastery.length} Themen mit Lernstand`); summary.innerHTML = `Luna nutzt: ${parts.join(" · ")}. Im „Lernen"-Tab kannst du den Stand visualisieren.`; } function setActiveFolder(folder) { activeFolder = folder; $$(".folder-tab").forEach(t => t.setAttribute("aria-selected", t.dataset.folder === folder ? "true" : "false")); $("#folder-help").innerHTML = FOLDER_HELP[folder] || ""; renderDocList(); } // ─── Render: Klausur cards ───────────────────────────────────── function renderKlausuren() { const list = $("#klausur-list"); const empty = $("#klausur-empty"); list.innerHTML = ""; if (!klausuren.length) { list.appendChild(empty); empty.hidden = false; return; } if (empty) empty.hidden = true; for (const k of klausuren) { const card = document.createElement("div"); card.className = "klausur-card"; const topicsHtml = (Array.isArray(k.topics) ? k.topics : []).slice(0, 8).map((t, i) => `${escapeHtml(t)}`).join(""); const notesHtml = k.notes ? `
    ${escapeHtml(k.notes)}
    ` : ""; card.innerHTML = `
    ${escapeHtml(String(k.order_index ?? "?"))} ${escapeHtml(k.name)}
    ${topicsHtml}
    ${notesHtml}
    ▶ Mit Luna durchgehen
    `; // main click → chat-prep card.addEventListener("click", (ev) => { if (ev.target.closest(".klausur-card-mini") || ev.target.closest(".klausur-card-topic")) return; const prompt = `Bereite mich auf "${k.name}" vor. Mach einen knappen Lernplan über die wichtigsten Themen, dann starten wir mit dem ersten — verstanden?`; openDock(prompt, true); }); // topic-pill click → open minigame for that topic card.querySelectorAll(".klausur-card-topic").forEach(el => { el.addEventListener("click", (ev) => { ev.stopPropagation(); openMinigameLauncher(el.dataset.topic, el.dataset.klausur); }); }); // mini action buttons card.querySelectorAll(".klausur-card-mini").forEach(btn => { btn.addEventListener("click", (ev) => { ev.stopPropagation(); const game = btn.dataset.act; const topicLabel = btn.dataset.name; launchMinigame(game, { topic_key: topicKeyFromLabel(topicLabel), topic_label: topicLabel }); }); }); list.appendChild(card); } } function topicKeyFromLabel(label) { return String(label).toLowerCase().replace(/[^a-z0-9äöüß]+/g, "-").replace(/^-|-$/g, ""); } // ─── Render: Heatmap + Topic-Picker ──────────────────────────── function renderHeatmap() { const grid = $("#heatmap-grid"); if (!mastery.length) { grid.innerHTML = `
    Noch keine Daten. Wähle unten ein Thema und mache einen Kompetenz-Check.
    `; return; } grid.innerHTML = ""; // Sort by recently-seen const sorted = [...mastery].sort((a, b) => (b.last_seen_at || "").localeCompare(a.last_seen_at || "")); for (const m of sorted) { const cell = document.createElement("div"); cell.className = "heatmap-cell"; cell.dataset.level = m.level; const accuracy = m.attempts ? Math.round((m.correct / m.attempts) * 100) : null; const accStr = accuracy === null ? "" : `${accuracy}% richtig`; cell.innerHTML = `
    ${escapeHtml(m.topic_label || m.topic_key)}
    Level ${m.level}/5 ${accStr}
    `; cell.addEventListener("click", () => openMinigameLauncher(m.topic_key, m.topic_label || m.topic_key, m)); grid.appendChild(cell); } } function topicGridSource() { const sel = $("#topic-source").value; const data = []; if (sel === "klausur") { // unique topics from klausuren list const seen = new Set(); for (const k of klausuren) { for (const t of (k.topics || [])) { const key = topicKeyFromLabel(t); if (seen.has(key)) continue; seen.add(key); data.push({ topic_key: key, topic_label: t, group: k.name }); } } if (!data.length) data.push({ empty: true, msg: "Noch keine Klausuren erkannt — lade einen Klausurplan im Curriculum-Ordner hoch." }); } else { // From curricula.json const cur = curricula?.curricula?.find(c => c.id === sel); if (!cur) { data.push({ empty: true, msg: "Curriculum nicht geladen." }); } else { for (const mod of cur.modules || []) { data.push({ topic_key: `${cur.id}/${mod.id}`, topic_label: mod.title, group: cur.short || cur.title, curriculum_id: cur.id, module_id: mod.id }); } } } return data; } function renderTopicGrid() { const grid = $("#topic-grid"); const items = topicGridSource(); grid.innerHTML = ""; if (items[0]?.empty) { const div = document.createElement("div"); div.className = "empty-state"; div.textContent = items[0].msg; grid.appendChild(div); return; } const lookup = new Map(mastery.map(m => [m.topic_key, m])); for (const t of items) { const m = lookup.get(t.topic_key); const stars = m ? "★".repeat(m.level) + "☆".repeat(5 - m.level) : "☆☆☆☆☆"; const meta = m ? `Level ${m.level}/5 · ${m.attempts || 0} Versuche` : (t.group || "—"); const pill = document.createElement("button"); pill.className = "topic-pill"; if (m && m.level >= 4) pill.dataset.mastered = "true"; pill.innerHTML = ` ${escapeHtml(t.topic_label)} ${escapeHtml(meta)} ${stars}`; pill.addEventListener("click", () => openMinigameLauncher(t.topic_key, t.topic_label, t)); grid.appendChild(pill); } } // ─── Minigame launcher ───────────────────────────────────────── let pendingTopic = null; function openMinigameLauncher(topicKey, topicLabel, ctx = {}) { pendingTopic = { topic_key: topicKey, topic_label: topicLabel, curriculum_id: ctx.curriculum_id || (typeof topicKey === "string" && topicKey.includes("/") ? topicKey.split("/")[0] : ""), module_id: ctx.module_id || (typeof topicKey === "string" && topicKey.includes("/") ? topicKey.split("/")[1] : ""), }; $("#launcher-topic").textContent = topicLabel; $("#minigame-launcher").hidden = false; $("#minigame-launcher").scrollIntoView({ behavior: "smooth", block: "center" }); setActiveSpace("lernen"); } $("#launcher-close").addEventListener("click", () => { $("#minigame-launcher").hidden = true; pendingTopic = null; }); $$(".minigame-card").forEach(c => c.addEventListener("click", () => { if (!pendingTopic) { toast("Bitte erst ein Thema wählen", "error"); return; } launchMinigame(c.dataset.game, pendingTopic); })); // ─── Minigame runner ──────────────────────────────────────────── async function launchMinigame(game, topic) { activeMinigame = { game, topic, state: {} }; const stage = $("#minigame-stage"); stage.hidden = false; $("#minigame-launcher").hidden = true; stage.innerHTML = `
    Luna bereitet das Spiel vor…
    `; stage.scrollIntoView({ behavior: "smooth", block: "start" }); try { if (game === "diagnose") await runDiagnose(topic); else if (game === "klinikfall") await runKlinikfall(topic); else if (game === "stimmt-das") await runStimmtDas(topic); else if (game === "quiz-classic") await runQuizClassic(topic); else if (game === "flashcards") await runFlashcards(topic); else if (game === "explain") await runExplain(topic); } catch (e) { stage.innerHTML = `
    Fehler: ${escapeHtml(e.message || String(e))}
    `; } } function endMinigame() { activeMinigame = null; $("#minigame-stage").hidden = true; $("#minigame-stage").innerHTML = ""; loadMastery(); // refresh heatmap } // Diagnose: 5 MCQ ─────────────────────────────────────────────── async function runDiagnose(topic) { const data = await api("/api/tutor/minigame/diagnose", { method: "POST", body: JSON.stringify({ bot: BOT_ID, topic: topic.topic_key, topic_label: topic.topic_label, curriculum_id: topic.curriculum_id, module_id: topic.module_id }) }); activeMinigame.state = { questions: data.questions, idx: 0, correct: 0, total: data.questions.length, answers: [] }; renderDiagnoseQuestion(); } function renderDiagnoseQuestion() { const s = activeMinigame.state; const q = s.questions[s.idx]; const stage = $("#minigame-stage"); const progress = ((s.idx) / s.total) * 100; stage.innerHTML = `

    🩺 Kompetenz-Check: ${escapeHtml(activeMinigame.topic.topic_label)}

    Frage ${s.idx + 1} / ${s.total}

    ${escapeHtml(q.q)}

    ${q.options.map((opt, i) => ` `).join("")}
    `; stage.querySelectorAll(".qa-option").forEach(btn => btn.addEventListener("click", () => answerDiagnose(parseInt(btn.dataset.i, 10)))); $("#qa-next").addEventListener("click", () => { s.idx++; if (s.idx >= s.total) finishDiagnose(); else renderDiagnoseQuestion(); }); } function answerDiagnose(picked) { const s = activeMinigame.state; const q = s.questions[s.idx]; const correct = picked === q.correct; if (correct) s.correct++; s.answers.push({ q: q.q, picked, correct: q.correct, was_right: correct }); const stage = $("#minigame-stage"); stage.querySelectorAll(".qa-option").forEach((btn, i) => { btn.disabled = true; if (i === q.correct) btn.dataset.state = "correct"; else if (i === picked) btn.dataset.state = "wrong"; else btn.dataset.state = "reveal-correct"; }); const fb = $("#qa-feedback"); fb.className = `qa-feedback ${correct ? "correct" : "wrong"}`; fb.innerHTML = `${correct ? "✓ Richtig" : "✗ Falsch"}. ${renderMD(q.explain || "")}`; fb.hidden = false; $("#qa-actions").hidden = false; } async function finishDiagnose() { const s = activeMinigame.state; await scoreMinigame(s.correct, s.total); const ratio = s.correct / s.total; const level = ratio >= 0.95 ? 5 : ratio >= 0.85 ? 4 : ratio >= 0.7 ? 3 : ratio >= 0.5 ? 2 : ratio >= 0.3 ? 1 : 0; const stars = "★".repeat(level) + "☆".repeat(5 - level); const verdict = level >= 4 ? "Stark! Du hast das Thema im Griff." : level >= 3 ? "Solide. Ein paar Lücken, gut zum Üben." : level >= 2 ? "Grundlagen vorhanden, Vertiefung nötig." : "Hier lohnt sich gezieltes Üben — mach z.B. einen Klinikfall oder lass dir's erklären."; const stage = $("#minigame-stage"); stage.innerHTML = `
    ${stars}
    ${s.correct} von ${s.total} richtig — Level ${level}/5
    ${escapeHtml(verdict)}
    `; $("#sum-explain").addEventListener("click", () => { const wrongs = s.answers.filter(a => !a.was_right).map(a => a.q).join("\n- "); openDock(`Wir haben gerade "${activeMinigame.topic.topic_label}" geübt. Diese Fragen waren noch unsicher:\n- ${wrongs}\n\nKannst du mir die Lücken nochmal sokratisch erklären?`, true); endMinigame(); }); $("#sum-klinik").addEventListener("click", () => launchMinigame("klinikfall", activeMinigame.topic)); $("#sum-close").addEventListener("click", endMinigame); } // Klinikfall ──────────────────────────────────────────────────── async function runKlinikfall(topic) { const data = await api("/api/tutor/minigame/klinikfall", { method: "POST", body: JSON.stringify({ bot: BOT_ID, topic: topic.topic_key, topic_label: topic.topic_label }) }); activeMinigame.state = { case: data, idx: 0, correct: 0, total: data.stages.length, picks: [] }; renderKlinikIntro(); } function renderKlinikIntro() { const c = activeMinigame.state.case; const stage = $("#minigame-stage"); stage.innerHTML = `

    🏥 ${escapeHtml(c.title)}

    Klinikfall
    ${renderMD(c.patient || "")}
    `; $("#fall-start").addEventListener("click", () => renderKlinikStage()); } function renderKlinikStage() { const s = activeMinigame.state; const stg = s.case.stages[s.idx]; const stage = $("#minigame-stage"); const progress = ((s.idx) / s.total) * 100; stage.innerHTML = `

    🏥 ${escapeHtml(s.case.title)}

    Stage ${s.idx + 1} / ${s.total}
    Entscheidungspunkt ${s.idx + 1}

    ${escapeHtml(stg.prompt)}

    ${stg.options.map((opt, i) => ` `).join("")}
    `; stage.querySelectorAll(".qa-option").forEach(btn => btn.addEventListener("click", () => { const i = parseInt(btn.dataset.i, 10); const opt = stg.options[i]; const wasRight = !!opt.correct; if (wasRight) s.correct++; s.picks.push({ stage: s.idx, picked: i, was_right: wasRight }); stage.querySelectorAll(".qa-option").forEach((b, j) => { b.disabled = true; const oj = stg.options[j]; if (oj.correct) b.dataset.state = "correct"; else if (j === i) b.dataset.state = "wrong"; }); const fb = $("#qa-feedback"); fb.className = `qa-feedback ${wasRight ? "correct" : "wrong"}`; fb.innerHTML = `${wasRight ? "✓ Stimmt" : "✗ Nicht ideal"}. ${renderMD(opt.reason || "")}`; if (stg.learning_point) fb.innerHTML += `
    Take-away: ${escapeHtml(stg.learning_point)}
    `; fb.hidden = false; $("#qa-actions").hidden = false; $("#fall-next").addEventListener("click", () => { s.idx++; if (s.idx >= s.total) finishKlinik(); else renderKlinikStage(); }); })); } async function finishKlinik() { const s = activeMinigame.state; await scoreMinigame(s.correct, s.total); const stage = $("#minigame-stage"); stage.innerHTML = `
    ${s.correct} von ${s.total} Entscheidungen passten
    ${escapeHtml(s.case.synthesis || "Klinikfall abgeschlossen.")}
    `; $("#sum-discuss").addEventListener("click", () => { openDock(`Ich hab gerade einen Klinikfall zum Thema "${activeMinigame.topic.topic_label}" gemacht (${s.correct}/${s.total}). Was sind die wichtigsten Take-aways die ich mir merken sollte?`, true); endMinigame(); }); $("#sum-close").addEventListener("click", endMinigame); } // Stimmt-das? ─────────────────────────────────────────────────── async function runStimmtDas(topic) { const data = await api("/api/tutor/minigame/stimmt-das", { method: "POST", body: JSON.stringify({ bot: BOT_ID, topic: topic.topic_key, topic_label: topic.topic_label }) }); activeMinigame.state = { items: data.items, picks: [], correct: 0, total: data.items.length }; renderStimmtDas(); } function renderStimmtDas() { const s = activeMinigame.state; const stage = $("#minigame-stage"); stage.innerHTML = `

    🤔 Stimmt das? — ${escapeHtml(activeMinigame.topic.topic_label)}

    0 / ${s.total} beantwortet
    `; const grid = $("#stimmt-grid"); s.items.forEach((it, idx) => { const card = document.createElement("div"); card.className = "stimmt-card"; card.innerHTML = `
    ${escapeHtml(it.statement)}
    `; grid.appendChild(card); }); grid.querySelectorAll(".stimmt-btn").forEach(btn => btn.addEventListener("click", () => { const idx = parseInt(btn.dataset.idx, 10); const pickedTrue = btn.dataset.pick === "true"; if (s.picks[idx] !== undefined) return; s.picks[idx] = pickedTrue; const correct = pickedTrue === !!s.items[idx].is_true; if (correct) s.correct++; // Disable both buttons; mark const card = btn.closest(".stimmt-card"); card.querySelectorAll(".stimmt-btn").forEach(b => { b.disabled = true; const isPick = b.dataset.pick === btn.dataset.pick; if (isPick) b.dataset.state = correct ? "correct" : "wrong"; else if (b.dataset.pick === String(!!s.items[idx].is_true)) b.dataset.state = "correct"; }); const ex = $(`#sd-ex-${idx}`); ex.className = `stimmt-explain ${correct ? "correct" : "wrong"}`; const trapTag = s.items[idx].trap_type && !s.items[idx].is_true ? `${escapeHtml(s.items[idx].trap_type)}` : ""; ex.innerHTML = `${trapTag}${escapeHtml(s.items[idx].explanation || "")}`; ex.hidden = false; $("#sd-progress").textContent = `${s.picks.filter(p => p !== undefined).length} / ${s.total} beantwortet`; if (s.picks.filter(p => p !== undefined).length === s.total) $("#sd-actions").hidden = false; })); $("#sd-finish").addEventListener("click", finishStimmtDas); } async function finishStimmtDas() { const s = activeMinigame.state; await scoreMinigame(s.correct, s.total); const ratio = s.correct / s.total; const stage = $("#minigame-stage"); stage.innerHTML = `
    ${s.correct} von ${s.total} richtig erkannt — ${Math.round(ratio*100)}%
    ${ratio >= 0.8 ? "Du hast die Misconceptions gut gefiltert." : "Mehrere Fallen sind durchgerutscht — guter Hinweis, wo's noch wackelt."}
    `; $("#sum-deepen").addEventListener("click", () => { const wrongs = s.items.filter((it, i) => s.picks[i] !== !!it.is_true).map(it => it.statement).slice(0, 3).join("\n- "); openDock(`Beim "Stimmt-das?"-Spiel zu "${activeMinigame.topic.topic_label}" bin ich hier auf Fallen reingefallen:\n- ${wrongs}\n\nLass uns die nochmal sauber durchgehen.`, true); endMinigame(); }); $("#sum-close").addEventListener("click", endMinigame); } // Quiz-classic / Flashcards / Explain — via chat-dock w/ existing engine async function runQuizClassic(topic) { endMinigame(); openDock(`QUIZ_REQUEST topic="${topic.topic_label}" count=10`, true); } async function runFlashcards(topic) { endMinigame(); openDock(`FLASHCARD_REQUEST topic="${topic.topic_label}" count=8`, true); } async function runExplain(topic) { endMinigame(); openDock(`Ich erkläre dir jetzt das Thema "${topic.topic_label}" in meinen eigenen Worten — und du gibst mir Feedback zu Klarheit, Vollständigkeit und ob ich Verständnislücken habe. Bist du bereit? Wenn ja, schreib bitte „Los — fang an" und ich erkläre los.`, true); } async function scoreMinigame(correct, total) { if (!activeMinigame) return; try { await api("/api/tutor/minigame/score", { method: "POST", body: JSON.stringify({ bot: BOT_ID, topic_key: activeMinigame.topic.topic_key, topic_label: activeMinigame.topic.topic_label, curriculum_id: activeMinigame.topic.curriculum_id || "", module_id: activeMinigame.topic.module_id || "", correct, total, }) }); } catch (e) { console.warn("score", e); } } // ─── Drag & drop / file picker ──────────────────────────────── function onFiles(files) { if (!files || !files.length) return; for (const f of files) uploadFile(f); } $("#upload-zone").addEventListener("click", () => $("#file-input").click()); $("#upload-zone").addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); $("#file-input").click(); }}); $("#file-input").addEventListener("change", (e) => { onFiles(e.target.files); e.target.value = ""; }); $("#upload-zone").addEventListener("dragover", (e) => { e.preventDefault(); $("#upload-zone").dataset.dragover = "true"; }); $("#upload-zone").addEventListener("dragleave", () => { $("#upload-zone").dataset.dragover = "false"; }); $("#upload-zone").addEventListener("drop", (e) => { e.preventDefault(); $("#upload-zone").dataset.dragover = "false"; onFiles(e.dataTransfer.files); }); // Folder + Space-Tabs $$(".folder-tab").forEach(t => t.addEventListener("click", () => setActiveFolder(t.dataset.folder))); $$(".space-tab").forEach(t => t.addEventListener("click", () => setActiveSpace(t.dataset.space))); function setActiveSpace(space) { activeSpace = space; $$(".space-tab").forEach(t => t.setAttribute("aria-selected", t.dataset.space === space ? "true" : "false")); $$(".space[data-space]").forEach(s => s.dataset.active = s.dataset.space === space ? "true" : "false"); if (space === "lernen") { renderHeatmap(); renderTopicGrid(); } if (space === "klausuren") renderKlausuren(); if (space === "steuern") { loadPersona(); refreshTgStatus(); } } $("#topic-source").addEventListener("change", renderTopicGrid); // ─── Telegram pairing ───────────────────────────────────────── async function refreshTgStatus() { try { const data = await api(`/api/tutor/telegram/status?bot=${BOT_ID}`); const badge = $("#tg-status-badge"); const unlinkBtn = $("#tg-unlink"); if (data.linked) { badge.textContent = `✓ verbunden ${data.chat_id_preview || ""}`; badge.className = "tg-badge linked"; unlinkBtn.hidden = false; } else { badge.textContent = "noch nicht verbunden"; badge.className = "tg-badge unlinked"; unlinkBtn.hidden = true; } } catch (e) { /* */ } } $("#tg-generate").addEventListener("click", async () => { try { const data = await api("/api/tutor/telegram/link-code", { method: "POST", body: JSON.stringify({ bot: BOT_ID }) }); $("#tg-code-text").textContent = `/link ${data.code}`; $("#tg-code-display").hidden = false; const ttlMin = Math.ceil((data.expires_at - Date.now()) / 60000); $("#tg-code-ttl").textContent = String(ttlMin); // periodic poll: when linked, show success const interval = setInterval(async () => { const s = await api(`/api/tutor/telegram/status?bot=${BOT_ID}`).catch(() => null); if (s?.linked) { clearInterval(interval); $("#tg-code-display").hidden = true; toast("✓ Telegram verbunden!", "success"); refreshTgStatus(); } }, 5000); // Stop polling after TTL setTimeout(() => clearInterval(interval), data.expires_at - Date.now() + 5000); } catch (e) { toast(`Fehler: ${e.message}`, "error"); } }); $("#tg-unlink").addEventListener("click", async () => { if (!confirm("Telegram-Verbindung wirklich lösen?")) return; try { await api("/api/tutor/telegram/link", { method: "DELETE", body: JSON.stringify({ bot: BOT_ID }) }); toast("Verbindung gelöst", "success"); refreshTgStatus(); } catch (e) { toast(`Fehler: ${e.message}`, "error"); } }); // Persona form $("#persona-form").addEventListener("submit", async (e) => { e.preventDefault(); const overrides = $("#persona-overrides").value; $("#persona-status").textContent = "speichere…"; try { await api("/api/tutor/persona", { method: "POST", body: JSON.stringify({ bot: BOT_ID, overrides }) }); const status = $("#persona-status"); status.textContent = "✓ gespeichert"; status.className = "persona-status saved"; setTimeout(() => { status.textContent = "—"; status.className = "persona-status"; }, 3000); toast("Steuerung gespeichert. Luna sieht das ab dem nächsten Chat.", "success"); } catch (err) { $("#persona-status").textContent = "Fehler"; toast(`Speichern fehlgeschlagen: ${err.message}`, "error"); } }); // ─── Chat dock — resizable, MD, structured-output ────────────── const dock = $("#chat-dock"); const dockBox = $("#dock-box"); const dockForm = $("#dock-form"); const dockInput = $("#dock-input"); // restore saved size try { const sz = JSON.parse(localStorage.getItem(LS_DOCK_SIZE) || "null"); if (sz && sz.w && sz.h) { dock.style.width = sz.w + "px"; dock.style.height = sz.h + "px"; } } catch { /* */ } // Resize via top-left corner drag const resizer = $("#dock-resize"); let startX = 0, startY = 0, startW = 0, startH = 0, resizing = false; resizer.addEventListener("mousedown", (e) => { e.preventDefault(); resizing = true; startX = e.clientX; startY = e.clientY; startW = dock.offsetWidth; startH = dock.offsetHeight; document.body.style.userSelect = "none"; }); window.addEventListener("mousemove", (e) => { if (!resizing) return; const dx = startX - e.clientX, dy = startY - e.clientY; const newW = Math.max(340, Math.min(window.innerWidth - 32, startW + dx)); const newH = Math.max(420, Math.min(window.innerHeight - 32, startH + dy)); dock.style.width = newW + "px"; dock.style.height = newH + "px"; }); window.addEventListener("mouseup", () => { if (!resizing) return; resizing = false; document.body.style.userSelect = ""; localStorage.setItem(LS_DOCK_SIZE, JSON.stringify({ w: dock.offsetWidth, h: dock.offsetHeight })); }); function openDock(prefill, autosend) { dock.dataset.open = "true"; $("#dock-open").hidden = true; renderChat(); if (prefill) dockInput.value = prefill; if (autosend) dockForm.requestSubmit(); setTimeout(() => dockInput.focus(), 100); } $("#dock-open").addEventListener("click", () => openDock("", false)); $("#dock-collapse").addEventListener("click", () => { dock.dataset.open = "false"; $("#dock-open").hidden = false; }); $("#dock-reset").addEventListener("click", () => { if (confirm("Chat-Verlauf löschen?")) { chatHistory = []; persistChat(); renderChat(); }}); function renderChat() { dockBox.innerHTML = ""; for (const m of chatHistory) { const el = document.createElement("div"); el.className = `dock-msg ${m.role}`; if (m.role === "assistant") { if (m.typing) { el.classList.add("typing"); el.textContent = "…"; } else { // Try to parse for structured output (quiz/flashcards/case) const struct = tryParseStruct(m.content); if (struct) { el.innerHTML = `
    ${renderMD(stripJsonBlock(m.content))}
    `; el.appendChild(renderStructInline(struct)); } else { el.innerHTML = `
    ${renderMD(m.content)}
    `; } if (m.sources && m.sources.length) { const src = document.createElement("div"); src.className = "sources"; src.innerHTML = "📚 Verwendete Quellen: " + m.sources.map(s => `${escapeHtml(s.folder)}/${escapeHtml(s.filename)}`).join(""); el.appendChild(src); } } } else { el.textContent = m.content; } dockBox.appendChild(el); } dockBox.scrollTop = dockBox.scrollHeight; } function tryParseStruct(text) { if (!text) return null; const m = text.trim().match(/^(\{[\s\S]*\})\s*$/) || text.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/); if (!m) return null; try { const obj = JSON.parse(m[1]); if (obj.type === "quiz" && Array.isArray(obj.questions)) return obj; if (obj.type === "flashcards" && Array.isArray(obj.cards)) return obj; } catch { return null; } return null; } function stripJsonBlock(text) { return text.replace(/```(?:json)?\s*\{[\s\S]*?\}\s*```/g, "").replace(/^\{[\s\S]*\}\s*$/, "").trim(); } function renderStructInline(obj) { const w = document.createElement("div"); w.className = "dock-struct"; if (obj.type === "quiz") { w.innerHTML = `
    📝 Quiz · ${escapeHtml(obj.topic || "")}
    `; let idx = 0, correct = 0; const total = obj.questions.length; const renderQ = () => { const q = obj.questions[idx]; w.innerHTML = `
    📝 Quiz ${idx+1} / ${total}
    ${escapeHtml(q.q)}
    ${q.options.map((opt, i) => ``).join("")}
    `; w.querySelectorAll(".ds-option").forEach(btn => btn.addEventListener("click", () => { const i = parseInt(btn.dataset.i, 10); const c = i === q.correct; if (c) correct++; w.querySelectorAll(".ds-option").forEach((b, j) => { b.disabled = true; if (j === q.correct) b.dataset.state = "correct"; else if (j === i) b.dataset.state = "wrong"; }); const fb = w.querySelector("#ds-fb"); fb.className = `qa-feedback ${c?"correct":"wrong"}`; fb.innerHTML = `${c?"✓":"✗"} ${escapeHtml(q.explain || "")}`; fb.hidden = false; w.querySelector("#ds-actions").hidden = false; w.querySelector("#ds-next").addEventListener("click", () => { idx++; if (idx >= total) { w.innerHTML = `
    ✅ Quiz fertig
    ${correct}/${total} richtig.
    `; } else renderQ(); }); })); }; renderQ(); } else if (obj.type === "flashcards") { w.innerHTML = `
    🃏 Karteikarten · ${escapeHtml(obj.topic || "")}
    `; let idx = 0; const total = obj.cards.length; const renderC = () => { const c = obj.cards[idx]; w.innerHTML = `
    🃏 Karte ${idx+1} / ${total}
    ${escapeHtml(c.front)}
    `; w.querySelector("#ds-flip").addEventListener("click", () => { w.innerHTML = `
    🃏 Karte ${idx+1} / ${total}
    F: ${escapeHtml(c.front)}

    A: ${escapeHtml(c.back)}${c.hint ? `
    Hinweis: ${escapeHtml(c.hint)}` : ""}
    `; w.querySelectorAll("[data-r]").forEach(b => b.addEventListener("click", () => { idx++; if (idx >= total) w.innerHTML = `
    ✅ Stapel durch
    `; else renderC(); })); }); }; renderC(); } return w; } async function sendChat(text) { chatHistory.push({ role: "user", content: text }); persistChat(); renderChat(); const tmp = { role: "assistant", typing: true }; chatHistory.push(tmp); renderChat(); try { const headers = { "Content-Type": "application/json", Authorization: `Bearer ${BOT_KEY}` }; // Pass user-token so backend can inject mastery + persona blocks if (token) headers["X-User-Token"] = token; const r = await fetch(`https://llm.qognio.com/api/bots/${SLUG}/chat`, { method: "POST", headers, body: JSON.stringify({ message: text, history: chatHistory.filter(m => m !== tmp).slice(-10).map(m => ({ role: m.role, content: m.content })), }), }); if (!r.ok) throw new Error(`HTTP ${r.status}`); const data = await r.json(); const idx = chatHistory.indexOf(tmp); chatHistory[idx] = { role: "assistant", content: data.reply || "(leere Antwort)", sources: data.tutor_sources }; } catch (e) { const idx = chatHistory.indexOf(tmp); chatHistory[idx] = { role: "assistant", content: `Fehler: ${e.message}` }; } persistChat(); renderChat(); } function persistChat() { localStorage.setItem(LS_CHAT, JSON.stringify(chatHistory.slice(-30))); } dockForm.addEventListener("submit", (e) => { e.preventDefault(); const text = dockInput.value.trim(); if (!text) return; dockInput.value = ""; dockInput.style.height = "auto"; sendChat(text); }); dockInput.addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); dockForm.requestSubmit(); }}); dockInput.addEventListener("input", () => { dockInput.style.height = "auto"; dockInput.style.height = Math.min(140, dockInput.scrollHeight) + "px"; }); // ─── Helpers ─────────────────────────────────────────────────── function relativeTime(iso) { if (!iso) return ""; const d = new Date(iso); const sec = (Date.now() - d.getTime()) / 1000; if (sec < 60) return "gerade eben"; if (sec < 3600) return `vor ${Math.floor(sec/60)} min`; if (sec < 86400) return `vor ${Math.floor(sec/3600)} h`; return d.toLocaleDateString("de-DE", { day: "2-digit", month: "short" }); } function guessMime(name) { const ext = name.toLowerCase().split(".").pop(); return ({ pdf:"application/pdf", txt:"text/plain", md:"text/markdown", csv:"text/csv", json:"application/json", png:"image/png", jpg:"image/jpeg", jpeg:"image/jpeg", webp:"image/webp" })[ext] || "application/octet-stream"; } async function refresh() { setActiveFolder(activeFolder); setActiveSpace(activeSpace); try { await loadCurricula(); await loadDocs(); await loadKlausuren(); await loadMastery(); } catch (e) { console.warn(e); toast("Lade-Fehler — bist du eingeloggt?", "error"); } } // Boot if (token && user) { showApp(); refresh(); } else showLogin(); })();