Source files (src/) and rendered bundle (www/) extracted on 2026-04-29T01:35:48+02:00. Adds nginx:alpine Dockerfile + docker-compose.yml (Caddy-labels) so the bot runs stand-alone or as a per-customer template clone. Parent monorepo commit: d2c816f3edbc9760802a11b29ff4151c7aad4b46 Bot version: 2026-04-21
1099 lines
54 KiB
JavaScript
1099 lines
54 KiB
JavaScript
/**
|
|
* 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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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 => ({"&":"&","<":"<",">":">","\"":""","'":"'"})[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();
|
|
})();
|