physio-tutor/www/cockpit/cockpit.js

1100 lines
54 KiB
JavaScript
Raw Normal View History

/**
* 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. <code>inhaltexamen2026.pdf</code> 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
s = s.replace(/```(\w+)?\n([\s\S]*?)```/g, (_, lang, code) => `<pre><code>${code}</code></pre>`);
s = s.replace(/`([^`\n]+)`/g, "<code>$1</code>");
// 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<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;
});
s = s.replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>");
s = s.replace(/__([^_\n]+)__/g, "<strong>$1</strong>");
s = s.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>");
s = s.replace(/^### (.+)$/gm, "<h3>$1</h3>");
s = s.replace(/^## (.+)$/gm, "<h2>$1</h2>");
s = s.replace(/^# (.+)$/gm, "<h1>$1</h1>");
s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
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>";
});
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>";
});
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;
}
function escapeHtml(s) { return String(s).replace(/[&<>"']/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;"})[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 = `<span style="flex:0 0 50%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escapeHtml(file.name)}</span>
<div class="upload-bar"><div class="upload-bar-fill" style="width:5%"></div></div>
<span class="upload-status"></span>`;
$("#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 ? "<span class=\"ok\">✓ indexiert</span>" : "<span class=\"pending\">⌛ indexiere…</span>";
const sizeKb = d.file_size ? `· ${(d.file_size/1024).toFixed(0)} KB ` : "";
li.innerHTML = `
<div class="doc-meta">
<p class="doc-name">${escapeHtml(d.filename)}</p>
<p class="doc-info">${ext} ${sizeKb}· ${relativeTime(d.created)}</p>
</div>
<div class="doc-actions">
<span class="toggle" data-on="${d.enabled ? "true" : "false"}" data-id="${d.id}" role="switch" aria-checked="${d.enabled}"
title="${d.enabled ? "Aktiv Luna nutzt diese Quelle" : "Aus Luna ignoriert diese Quelle"}"></span>
<button class="btn-icon" data-del="${d.id}" data-name="${escapeAttr(d.filename)}" aria-label="Löschen">🗑</button>
</div>`;
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 = `<span class="dim">Noch keine Quellen aktiv. Lade ein Klausurplan-Dokument hoch oder starte eine Diagnose im „Lernen"-Tab.</span>`;
return;
}
const parts = [];
if (byFolder.curriculum) parts.push(`<strong>${byFolder.curriculum}</strong> Curriculum`);
if (byFolder.official) parts.push(`<strong>${byFolder.official}</strong> offizielle`);
if (byFolder.own) parts.push(`<strong>${byFolder.own}</strong> eigene`);
if (byFolder.role) parts.push(`<strong>${byFolder.role}</strong> Schwerpunkt`);
if (mastery.length) parts.push(`<strong>${mastery.length}</strong> Themen mit Lernstand`);
summary.innerHTML = `Luna nutzt: ${parts.join(" · ")}. <span class="dim">Im „Lernen"-Tab kannst du den Stand visualisieren.</span>`;
}
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) =>
`<span class="klausur-card-topic" data-topic="${escapeAttr(t)}" data-klausur="${escapeAttr(k.name)}">${escapeHtml(t)}</span>`).join("");
const notesHtml = k.notes ? `<div class="klausur-card-notes">${escapeHtml(k.notes)}</div>` : "";
card.innerHTML = `
<div class="klausur-card-header">
<span class="klausur-card-num">${escapeHtml(String(k.order_index ?? "?"))}</span>
<span class="klausur-card-name">${escapeHtml(k.name)}</span>
</div>
<div class="klausur-card-topics">${topicsHtml}</div>
${notesHtml}
<div class="klausur-card-cta"> Mit Luna durchgehen</div>
<div class="klausur-card-actions">
<button class="klausur-card-mini" data-act="diagnose" data-name="${escapeAttr(k.name)}">🩺 Diagnose</button>
<button class="klausur-card-mini" data-act="klinikfall" data-name="${escapeAttr(k.name)}">🏥 Klinikfall</button>
<button class="klausur-card-mini" data-act="stimmt-das" data-name="${escapeAttr(k.name)}">🤔 Stimmt-das?</button>
</div>`;
// 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 = `<div class="empty-state">Noch keine Daten. Wähle unten ein Thema und mache einen <strong>Kompetenz-Check</strong>.</div>`;
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 = `
<div class="hc-name">${escapeHtml(m.topic_label || m.topic_key)}</div>
<div class="hc-meta">
<span>Level ${m.level}/5</span>
<span>${accStr}</span>
</div>
<div class="hc-bar"><div class="hc-bar-fill"></div></div>`;
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 = `
<span class="tp-label">${escapeHtml(t.topic_label)}</span>
<span class="tp-meta">${escapeHtml(meta)}</span>
<span class="tp-stars">${stars}</span>`;
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 = `<div class="stage-loader"><div class="stage-spinner"></div><div>Luna bereitet das Spiel vor…</div></div>`;
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 = `<div class="empty-state">Fehler: ${escapeHtml(e.message || String(e))}</div>`;
}
}
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 = `
<div class="stage-head">
<h3 class="stage-title">🩺 Kompetenz-Check: ${escapeHtml(activeMinigame.topic.topic_label)}</h3>
<span class="stage-progress">Frage ${s.idx + 1} / ${s.total}</span>
</div>
<div class="stage-progress-bar"><div class="stage-progress-bar-fill" style="width:${progress}%"></div></div>
<div class="qa-card">
<p class="qa-q">${escapeHtml(q.q)}</p>
<div class="qa-options">${q.options.map((opt, i) => `
<button class="qa-option" data-i="${i}">
<span class="qa-option-letter">${String.fromCharCode(65 + i)}</span>
<span>${escapeHtml(opt)}</span>
</button>`).join("")}
</div>
<div class="qa-feedback" id="qa-feedback" hidden></div>
<div class="qa-actions" id="qa-actions" hidden>
<button class="btn-primary" id="qa-next">${s.idx + 1 < s.total ? "Nächste Frage →" : "Auswertung →"}</button>
</div>
</div>`;
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 = `<strong>${correct ? "✓ Richtig" : "✗ Falsch"}.</strong> ${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 = `
<div class="summary-card">
<div class="summary-stars">${stars}</div>
<div class="summary-text"><strong>${s.correct} von ${s.total}</strong> richtig Level ${level}/5</div>
<div class="summary-meta">${escapeHtml(verdict)}</div>
<div class="summary-actions">
<button class="btn-secondary" id="sum-explain">Schwächen mit Luna durchgehen</button>
<button class="btn-secondary" id="sum-klinik">Klinikfall versuchen</button>
<button class="btn-ghost" id="sum-close">Schließen</button>
</div>
</div>`;
$("#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 = `
<div class="stage-head">
<h3 class="stage-title">🏥 ${escapeHtml(c.title)}</h3>
<span class="stage-progress">Klinikfall</span>
</div>
<div class="fall-patient">${renderMD(c.patient || "")}</div>
<div class="qa-actions"><button class="btn-primary" id="fall-start">Los geht's </button></div>`;
$("#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 = `
<div class="stage-head">
<h3 class="stage-title">🏥 ${escapeHtml(s.case.title)}</h3>
<span class="stage-progress">Stage ${s.idx + 1} / ${s.total}</span>
</div>
<div class="stage-progress-bar"><div class="stage-progress-bar-fill" style="width:${progress}%"></div></div>
<div class="qa-card">
<div class="fall-stage-num">Entscheidungspunkt ${s.idx + 1}</div>
<p class="qa-q">${escapeHtml(stg.prompt)}</p>
<div class="qa-options">${stg.options.map((opt, i) => `
<button class="qa-option" data-i="${i}">
<span class="qa-option-letter">${String.fromCharCode(65 + i)}</span>
<span>${escapeHtml(opt.label)}</span>
</button>`).join("")}
</div>
<div class="qa-feedback" id="qa-feedback" hidden></div>
<div class="qa-actions" id="qa-actions" hidden>
<button class="btn-primary" id="fall-next">${s.idx + 1 < s.total ? "Nächste Stage →" : "Synthese →"}</button>
</div>
</div>`;
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 = `<strong>${wasRight ? "✓ Stimmt" : "✗ Nicht ideal"}.</strong> ${renderMD(opt.reason || "")}`;
if (stg.learning_point) fb.innerHTML += `<div class="fall-learning"><strong>Take-away:</strong> ${escapeHtml(stg.learning_point)}</div>`;
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 = `
<div class="summary-card">
<div class="summary-text"><strong>${s.correct} von ${s.total}</strong> Entscheidungen passten</div>
<div class="summary-meta">${escapeHtml(s.case.synthesis || "Klinikfall abgeschlossen.")}</div>
<div class="summary-actions">
<button class="btn-secondary" id="sum-discuss">Mit Luna besprechen</button>
<button class="btn-ghost" id="sum-close">Schließen</button>
</div>
</div>`;
$("#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 = `
<div class="stage-head">
<h3 class="stage-title">🤔 Stimmt das? ${escapeHtml(activeMinigame.topic.topic_label)}</h3>
<span class="stage-progress" id="sd-progress">0 / ${s.total} beantwortet</span>
</div>
<div class="stimmt-grid" id="stimmt-grid"></div>
<div class="qa-actions" id="sd-actions" hidden><button class="btn-primary" id="sd-finish">Auswerten </button></div>`;
const grid = $("#stimmt-grid");
s.items.forEach((it, idx) => {
const card = document.createElement("div");
card.className = "stimmt-card";
card.innerHTML = `
<div class="stimmt-statement">${escapeHtml(it.statement)}</div>
<div class="stimmt-buttons">
<button class="stimmt-btn" data-idx="${idx}" data-pick="true"> Stimmt</button>
<button class="stimmt-btn" data-idx="${idx}" data-pick="false"> Stimmt nicht</button>
</div>
<div class="stimmt-explain" id="sd-ex-${idx}" hidden></div>`;
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 ? `<span class="trap-tag">${escapeHtml(s.items[idx].trap_type)}</span>` : "";
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 = `
<div class="summary-card">
<div class="summary-text"><strong>${s.correct} von ${s.total}</strong> richtig erkannt ${Math.round(ratio*100)}%</div>
<div class="summary-meta">${ratio >= 0.8 ? "Du hast die Misconceptions gut gefiltert." : "Mehrere Fallen sind durchgerutscht — guter Hinweis, wo's noch wackelt."}</div>
<div class="summary-actions">
<button class="btn-secondary" id="sum-deepen">Mit Luna vertiefen</button>
<button class="btn-ghost" id="sum-close">Schließen</button>
</div>
</div>`;
$("#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 = `<div class="dock-md">${renderMD(stripJsonBlock(m.content))}</div>`;
el.appendChild(renderStructInline(struct));
} else {
el.innerHTML = `<div class="dock-md">${renderMD(m.content)}</div>`;
}
if (m.sources && m.sources.length) {
const src = document.createElement("div");
src.className = "sources";
src.innerHTML = "📚 Verwendete Quellen: " + m.sources.map(s =>
`<span class="src-tag">${escapeHtml(s.folder)}/${escapeHtml(s.filename)}</span>`).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 = `<div class="ds-head">📝 Quiz · ${escapeHtml(obj.topic || "")}</div>`;
let idx = 0, correct = 0;
const total = obj.questions.length;
const renderQ = () => {
const q = obj.questions[idx];
w.innerHTML = `<div class="ds-head">📝 Quiz ${idx+1} / ${total}</div>
<div class="ds-q">${escapeHtml(q.q)}</div>
<div class="ds-options">${q.options.map((opt, i) => `<button class="ds-option" data-i="${i}">${String.fromCharCode(65+i)}) ${escapeHtml(opt)}</button>`).join("")}</div>
<div class="qa-feedback" id="ds-fb" hidden></div>
<div class="qa-actions" id="ds-actions" hidden><button class="btn-primary" id="ds-next">${idx+1<total?"Nächste →":"Fertig"}</button></div>`;
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 = `<strong>${c?"✓":"✗"}</strong> ${escapeHtml(q.explain || "")}`;
fb.hidden = false;
w.querySelector("#ds-actions").hidden = false;
w.querySelector("#ds-next").addEventListener("click", () => {
idx++;
if (idx >= total) { w.innerHTML = `<div class="ds-head">✅ Quiz fertig</div><div class="ds-q"><strong>${correct}/${total}</strong> richtig.</div>`; }
else renderQ();
});
}));
};
renderQ();
} else if (obj.type === "flashcards") {
w.innerHTML = `<div class="ds-head">🃏 Karteikarten · ${escapeHtml(obj.topic || "")}</div>`;
let idx = 0;
const total = obj.cards.length;
const renderC = () => {
const c = obj.cards[idx];
w.innerHTML = `<div class="ds-head">🃏 Karte ${idx+1} / ${total}</div>
<div class="ds-q">${escapeHtml(c.front)}</div>
<div class="qa-actions"><button class="btn-secondary" id="ds-flip">Antwort zeigen</button></div>`;
w.querySelector("#ds-flip").addEventListener("click", () => {
w.innerHTML = `<div class="ds-head">🃏 Karte ${idx+1} / ${total}</div>
<div class="ds-q"><strong>F:</strong> ${escapeHtml(c.front)}<br><br><strong>A:</strong> ${escapeHtml(c.back)}${c.hint ? `<br><em>Hinweis: ${escapeHtml(c.hint)}</em>` : ""}</div>
<div class="qa-actions">
<button class="btn-secondary" data-r="hard">😬 Schwer</button>
<button class="btn-secondary" data-r="ok">🙂 OK</button>
<button class="btn-secondary" data-r="easy">😎 Leicht</button>
</div>`;
w.querySelectorAll("[data-r]").forEach(b => b.addEventListener("click", () => { idx++; if (idx >= total) w.innerHTML = `<div class="ds-head">✅ Stapel durch</div>`; 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();
})();