APP-Backend / dist.js
Luca448's picture
Savestate: Inject global error handler to debug UI freeze
aa987ab
Raw History Blame Contribute Delete
225 kB
import { db } from "../../db/database.js";
import { aiRouter } from "../../ai/router.js";
import { marked } from "marked";
import { PresentationRenderer } from "../../utils/presentation-renderer.js";
import { deepResearchLoader } from "../../utils/deep-research-loader.js";
import { presentationLoader } from "../../utils/presentation-loader.js";
import { getApiUrl } from "../../config.js";
import { syncManager } from "../../services/sync-service.js";
import schoolmindSimSource from "../../../public/schoolmind-sim.js?raw";
import { initCanvas, addNode, addEdge } from "../../ai/research-canvas.js";
import { initImageEditor } from "./image-editor.js";
window.youtubeSearchCache = window.youtubeSearchCache || {};
try {
if (document.readyState === "complete" || document.readyState === "interactive") {
initImageEditor();
} else {
document.addEventListener("DOMContentLoaded", initImageEditor);
}
} catch (e) {
console.error("Failed to init image editor", e);
}
function formatImageGeneration(prompt) {
const encodedPrompt = encodeURIComponent(prompt.trim());
return `
<div class="generated-image-container" style="position: relative; margin: 16px 0; width: 100%; border-radius: 12px; overflow: hidden; background: rgba(0,0,0,0.2); aspect-ratio: 1/1;">
<div class="skel-image" style="position: absolute; top:0; left:0; width: 100%; height: 100%; background: linear-gradient(90deg, rgba(255,255,255,0.05) 25%, rgba(255,255,255,0.1) 50%, rgba(255,255,255,0.05) 75%); background-size: 200% 100%; animation: skel-loading 1.5s infinite;"></div>
<img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
class="gemini-async-image"
data-prompt="${prompt.replace(/"/g, "&quot;")}"
alt="${prompt.replace(/"/g, "&quot;")}"
style="width: 100%; height: auto; display: block; opacity: 0; transition: opacity 0.5s ease; position: relative; z-index: 1;"
onload="if(this.src.length > 100 && this.previousElementSibling) this.previousElementSibling.style.display='none'; this.style.opacity='1';" />
<button class="edit-btn" onclick="if(window.openImageEditor) window.openImageEditor(this.previousElementSibling.src, '${encodedPrompt}')" style="position: absolute; bottom: 12px; right: 12px; z-index: 2; background: rgba(0,0,0,0.6); color: white; border: 1px solid rgba(255,255,255,0.2); padding: 8px 12px; border-radius: 8px; cursor: pointer; font-size: 13px; backdrop-filter: blur(10px); display: flex; align-items: center; gap: 6px; opacity: 0; transition: opacity 0.3s ease;">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path></svg>
Bearbeiten
</button>
<style>
.generated-image-container:hover .edit-btn { opacity: 1; }
@keyframes skel-loading { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
</style>
</div>
`.trim();
}
function formatYoutubeSearch(query) {
const cachedId = window.youtubeSearchCache[query];
if (cachedId) {
if (cachedId === "NOT_FOUND") {
return `<div class="yt-embed-container" style="margin: 16px 0; padding: 16px; background: rgba(255,255,255,0.05); border-radius: 8px; text-align: center; color: #888;">Kein Video gefunden f\xFCr "${query}"</div>`;
}
return `<div class="yt-embed-container" style="margin: 16px 0;">
<iframe width="100%" height="315" src="https://www.youtube.com/embed/${cachedId}?autoplay=1&mute=1" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen" allowfullscreen="allowfullscreen" style="border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);"></iframe>
<div style="font-size: 9pt; color: #888; margin-top: 4px; text-align: right; font-family: var(--font-ui);">Suchergebnis f\xFCr: "${query}"</div>
</div>`;
}
if (!window.youtubeSearchCache.hasOwnProperty(query)) {
window.youtubeSearchCache[query] = null;
fetch(getApiUrl(`/api/youtube-search?q=${encodeURIComponent(query)}&json=1`)).then((res) => res.json()).then((data) => {
window.youtubeSearchCache[query] = data.videoId || "NOT_FOUND";
document.dispatchEvent(new CustomEvent("youtube-search-resolved"));
}).catch(() => {
window.youtubeSearchCache[query] = "NOT_FOUND";
document.dispatchEvent(new CustomEvent("youtube-search-resolved"));
});
}
return `<div class="yt-embed-container yt-loading-placeholder" data-query="${encodeURIComponent(query)}" style="margin: 16px 0; padding: 32px 16px; background: rgba(255,255,255,0.05); border-radius: 8px; text-align: center; color: #888; display: flex; flex-direction: column; align-items: center; justify-content: center;">
<div style="width: 24px; height: 24px; border: 2px solid rgba(255,255,255,0.1); border-top-color: #007aff; border-radius: 50%; animation: spin 1s linear infinite; margin-bottom: 8px;"></div>
Suche Video: "${query}"...
</div>`;
}
function formatWorksheetInpainting(jsonStr) {
const safeJson = jsonStr.replace(/"/g, "&quot;").replace(/\n/g, " ");
return `
<div class="generated-image-container" style="position: relative; margin: 16px 0; width: 100%; border-radius: 12px; overflow: hidden; background: rgba(0,0,0,0.2); aspect-ratio: 1/1;">
<div class="skel-image" style="position: absolute; top:0; left:0; width: 100%; height: 100%; background: linear-gradient(90deg, rgba(255,255,255,0.05) 25%, rgba(255,255,255,0.1) 50%, rgba(255,255,255,0.05) 75%); background-size: 200% 100%; animation: skel-loading 1.5s infinite;"></div>
<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: var(--text-secondary); font-family: var(--font-ui); font-size: 13px; text-align: center; z-index: 2;" class="loading-text">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin: 0 auto 8px auto; display: block; animation: pulse 2s infinite;"><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/></svg>
FLUX.1 Inpainting aktiv...
</div>
<img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
class="worksheet-inpainting-async"
data-json="${safeJson}"
style="width: 100%; height: auto; display: block; opacity: 0; transition: opacity 0.5s ease; position: relative; z-index: 1;"
onload="if(this.src.length > 100 && this.previousElementSibling) { this.previousElementSibling.style.display='none'; this.previousElementSibling.previousElementSibling.style.display='none'; } this.style.opacity='1';" />
</div>
`;
}
document.addEventListener("youtube-search-resolved", () => {
document.querySelectorAll(".yt-loading-placeholder").forEach((el) => {
const encodedQuery = el.getAttribute("data-query");
if (!encodedQuery) return;
const query = decodeURIComponent(encodedQuery);
const cachedId = window.youtubeSearchCache[query];
if (cachedId) {
if (cachedId === "NOT_FOUND") {
el.outerHTML = `<div class="yt-embed-container" style="margin: 16px 0; padding: 16px; background: rgba(255,255,255,0.05); border-radius: 8px; text-align: center; color: #888;">Kein Video gefunden f\xFCr "${query}"</div>`;
} else {
el.outerHTML = `<div class="yt-embed-container" style="margin: 16px 0;">
<iframe width="100%" height="315" src="https://www.youtube.com/embed/${cachedId}?autoplay=1&mute=1" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen" allowfullscreen="allowfullscreen" style="border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);"></iframe>
<div style="font-size: 9pt; color: #888; margin-top: 4px; text-align: right; font-family: var(--font-ui);">Suchergebnis f\xFCr: "${query}"</div>
</div>`;
}
}
});
});
const renderer = new marked.Renderer();
const originalImage = renderer.image.bind(renderer);
renderer.image = (href, title, text) => {
let h = href, t = title, tx = text;
if (typeof href === "object" && href !== null) {
h = href.href;
t = href.title;
tx = href.text;
}
if (tx && (tx.startsWith("KnowledgeCard:") || tx.startsWith("SiriHero:"))) {
const isSiriHero = tx.startsWith("SiriHero:");
let rawTitle = tx.replace(/KnowledgeCard:|SiriHero:/, "").trim().replace(/_/g, " ");
let parts = rawTitle.split("|");
let cardTitle = parts[0].trim();
let keywords = parts.length > 1 ? parts[1].trim() : "";
const forbiddenMathKeywords = ["koordinatensystem", "gleichung", "funktion", "graph", "wurzel", "mathematik", "rechnung", "vektor", "polynom", "ableitung"];
if (forbiddenMathKeywords.some((kw) => cardTitle.toLowerCase().includes(kw))) {
return "";
}
if (isSiriHero) {
const titleParts = cardTitle.split(" ");
if (titleParts.length > 1) {
cardTitle = titleParts[0] + "<br>" + titleParts.slice(1).join(" ");
}
const imgTag = h === "WIKIPEDIA" ? `<img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" class="siri-hero-image wiki-async-image" data-wiki-query="${cardTitle.replace(/<br>/g, " ")}" data-wiki-keywords="${keywords}" alt="${cardTitle}" loading="lazy" />` : `<img src="${h}" class="siri-hero-image" alt="${cardTitle}" loading="lazy" onerror="this.style.display='none'; const c=this.closest('.siri-hero-card'); if(c){ const t=c.querySelector('.siri-hero-toggle-icon'); if(t) t.style.display='none'; const h=c.querySelector('.siri-hero-header'); if(h) h.style.cursor='default'; }" />`;
return `
<div class="siri-hero-card">
<div class="siri-hero-header" onclick="this.parentElement.classList.toggle('collapsed')" title="Bild ein-/ausklappen">
<div class="siri-hero-title">${cardTitle}</div>
<svg class="siri-hero-toggle-icon" viewBox="0 0 24 24" width="28" height="28" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"></polyline></svg>
</div>
<div class="siri-hero-image-wrapper">
${imgTag}
</div>
</div>
`;
}
return `
<div class="siri-knowledge-card">
<div class="skc-image-wrapper">
<img src="${h}" alt="${cardTitle}" loading="lazy" onerror="this.parentElement.style.display='none'" />
</div>
<div class="skc-content">
<div class="skc-title">${cardTitle}</div>
<div class="skc-subtitle">KI-Wissen</div>
</div>
</div>
`;
}
return typeof href === "object" ? originalImage(href) : originalImage(h, t, tx);
};
const originalCode = renderer.code.bind(renderer);
renderer.code = function(code, language, isEscaped) {
let c = code, l = language, e = isEscaped;
if (typeof code === "object" && code !== null) {
c = code.text;
l = code.lang;
e = code.escaped;
}
if (l === "plot") {
const encodedData = encodeURIComponent(c);
return `<div class="function-plot-container" data-plot="${encodedData}"></div>`;
}
if (l === "plot3d") {
const encodedData = encodeURIComponent(c);
return `<div class="plotly-container" data-plot3d="${encodedData}"></div>`;
}
if (l === "mermaid") {
return `<div class="mermaid" style="background: rgba(255,255,255,0.02); padding: 16px; border-radius: 8px; margin: 16px 0; display: flex; justify-content: center; overflow-x: auto;">${c}</div>`;
}
if (l === "app") {
const injectedScript = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<script>
window.addEventListener('error', function(event) {
window.parent.postMessage({ type: 'SIM_ERROR', message: event.message, lineno: event.lineno }, '*');
});
<\/script>
<script>
${schoolmindSimSource}
<\/script>
</head>
<body>
${c}
</body>
</html>`;
const encodedData = encodeURIComponent(injectedScript);
return `<div class="artifact-container" style="margin: 16px 0; border-radius: 12px; overflow: hidden; border: 1px solid rgba(255,255,255,0.1); background: #000; box-shadow: 0 4px 12px rgba(0,0,0,0.5);">
<div style="background: rgba(255,255,255,0.05); padding: 8px 12px; font-size: 12px; color: #888; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid rgba(255,255,255,0.05);">
<span><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: middle; margin-right: 6px;"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg> SchoolMind Interactive App</span>
<button onclick="this.parentElement.nextElementSibling.src = this.parentElement.nextElementSibling.src" style="background: none; border: none; color: #aaa; cursor: pointer; display: flex; align-items: center; gap: 4px; font-size: 11px;"><svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 4v6h-6M1 20v-6h6M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg> Neustart</button>
</div>
<iframe src="data:text/html;charset=utf-8,${encodedData}" sandbox="allow-scripts" style="width: 100%; height: 400px; border: none; display: block;"></iframe>
</div>`;
}
return typeof code === "object" ? originalCode(code) : originalCode(c, l, e);
};
marked.use({ renderer });
export class ChatView {
constructor(containerId) {
this.container = document.getElementById(containerId);
this.currentConversationId = null;
this.isGenerating = false;
this.setupLightbox();
this.setupTimelineObserver();
this.setupAnnotations();
window.addEventListener("cloud_sync_updated", () => {
if (typeof this.loadHistory === "function") {
this.loadHistory();
}
});
this.appRetryCount = 0;
window.addEventListener("message", async (e) => {
if (e.data.type === "SIM_ERROR") {
console.warn("Sandbox Error:", e.data);
if (!this.isGenerating && this.appRetryCount < 3) {
this.handleAutoFix(e.data.message, e.data.lineno);
}
}
});
if (!document.getElementById("dynamic-source-styles")) {
const style = document.createElement("style");
style.id = "dynamic-source-styles";
style.textContent = `
/* Alle Links (inklusive Quellenverzeichnis unten) */
.article-document a, .pdf-document a {
color: #89CFF0 !important; /* Baby blue */
text-decoration: none !important;
transition: all 0.2s ease;
cursor: pointer;
}
.article-document a:hover, .pdf-document a:hover {
text-decoration: underline !important;
text-shadow: 0 0 8px rgba(137, 207, 240, 0.4);
}
/* Hervorhebung f\xFCr alte Recherchen (ganzer Absatz) */
.source-highlight {
background: rgba(137, 207, 240, 0.15) !important;
border-left: 4px solid #89CFF0 !important;
padding-left: 12px !important;
border-radius: 4px;
transition: all 0.5s ease !important;
}
/* Pr\xE4zise Inline-Quellen Hervorhebung [1] */
.inline-citation {
color: #89CFF0;
font-weight: bold;
padding: 0 4px;
border-radius: 4px;
transition: all 0.5s ease;
}
.inline-highlight {
background: rgba(137, 207, 240, 0.4) !important;
box-shadow: 0 0 12px #89CFF0 !important;
color: #fff !important;
}
/* Mermaid Diagramm Anpassungen */
.mermaid svg {
max-width: 100%;
height: auto;
}
/* Plotly Textfarben Dynamisch (Light/Dark Mode) */
/* Article-Bericht = immer dunkler Hintergrund = wei\xDFe Schrift */
.article-document .plotly-graph text {
fill: #ffffff !important;
}
/* PDF Preview: Dark Mode = wei\xDFe Schrift */
.printable-document.dark-mode-doc .plotly-graph text {
fill: #ffffff !important;
}
/* PDF Preview: Light Mode (Standard) = schwarze Schrift */
.printable-document:not(.dark-mode-doc) .plotly-graph text {
fill: #121212 !important;
}
.printable-document:not(.dark-mode-doc) .plotly-graph {
background: rgba(0, 0, 0, 0.03) !important;
}
/* Plotly Container Overflow verhindern */
.plotly-graph {
overflow: hidden !important;
}
.history-item {
padding: 0 !important;
background: transparent !important;
border: none !important;
box-shadow: none !important;
min-height: min-content !important;
flex-shrink: 0 !important;
position: relative;
z-index: 1;
}
.history-item:hover {
transform: none !important;
filter: none !important;
}
/* Move visual styling to .history-item-content */
.history-item-content {
padding: 14px;
border-radius: 20px;
background: rgba(25, 25, 30, 0.95);
border: 1px solid rgba(255, 255, 255, 0.05);
transition: transform 0.1s ease, filter 0.1s ease, border-color 0.1s ease;
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
min-height: 100px;
}
.history-item:hover .history-item-content {
transform: translateY(-2px);
filter: brightness(1.2);
border-color: rgba(255,255,255,0.15);
}
.history-item.active .history-item-content {
border-color: rgba(182, 120, 242, 0.4);
box-shadow: inset 0 1px 2px rgba(255,255,255,0.2), 0 4px 20px rgba(182, 120, 242, 0.2);
}
.history-item.show-dropdown {
z-index: 10 !important;
}
/* The Dropdown slides out of the BOTTOM of the card */
.history-dropdown-wrapper {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.3s cubic-bezier(0.2, 0.8, 0.2, 1);
margin-top: 0;
z-index: 1;
}
.history-item.show-dropdown .history-dropdown-wrapper {
grid-template-rows: 1fr;
margin-top: 8px; /* Space between card and dropdown */
}
.history-dropdown {
overflow: hidden;
min-height: 0;
}
.history-dropdown-inner {
display: flex;
flex-direction: row;
width: fit-content;
margin-left: 14px; /* Align with card content padding */
justify-content: flex-start;
gap: 16px;
padding: 6px 16px;
background: rgba(40, 40, 45, 0.95);
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 16px 32px rgba(0,0,0,0.6);
/* Initially pushed up behind the card */
transform: translateY(-100%);
transition: transform 0.3s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.history-item.show-dropdown .history-dropdown-inner {
transform: translateY(0);
}
.history-dropdown-btn {
background: transparent;
border: none;
color: var(--text-primary) !important;
padding: 8px;
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.history-dropdown-btn svg {
opacity: 0.7;
}
.history-dropdown-btn:hover {
background: rgba(255, 255, 255, 0.08);
}
.history-dropdown-btn.danger {
color: #ff5555 !important;
}
.history-dropdown-btn.danger svg {
opacity: 0.9;
}
.history-dropdown-btn.danger:hover {
background: rgba(255, 60, 60, 0.15);
}
.history-actions {
display: none !important;
}
`;
document.head.appendChild(style);
}
}
setupLightbox() {
document.addEventListener("dblclick", (e) => {
if (e.target.tagName === "IMG" && (e.target.classList.contains("siri-hero-image") || e.target.classList.contains("siri-hero-image-top") || e.target.classList.contains("chat-image"))) {
if (!e.target.src) return;
const overlay = document.createElement("div");
overlay.className = "lightbox-overlay";
const imgClone = document.createElement("img");
imgClone.src = e.target.src;
if (e.target.classList.contains("wiki-svg-image")) {
imgClone.style.filter = e.target.style.filter;
}
overlay.appendChild(imgClone);
overlay.onclick = () => overlay.remove();
document.body.appendChild(overlay);
}
});
}
setupTimelineObserver() {
this.visibleTimelineElements = /* @__PURE__ */ new Set();
this.timelineObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
this.visibleTimelineElements.add(entry.target);
} else {
this.visibleTimelineElements.delete(entry.target);
}
});
this.updateActiveTimelineMarker();
}, { root: null, rootMargin: "-10% 0px -10% 0px", threshold: 0 });
}
updateActiveTimelineMarker() {
if (!this.visibleTimelineElements || this.visibleTimelineElements.size === 0) return;
const centerY = window.innerHeight / 2;
let closestId = null;
let minDistance = Infinity;
this.visibleTimelineElements.forEach((el) => {
const rect = el.getBoundingClientRect();
const elCenterY = rect.top + rect.height / 2;
const distance = Math.abs(centerY - elCenterY);
if (distance < minDistance) {
minDistance = distance;
closestId = el.dataset.timelineId;
}
});
if (closestId) {
document.querySelectorAll(".timeline-marker-wrapper").forEach((marker) => {
if (marker.dataset.timelineId === closestId) {
marker.classList.add("active");
} else {
marker.classList.remove("active");
}
});
}
}
buildTimeline() {
const timelineContainer = document.getElementById("chat-timeline");
if (!timelineContainer) return;
timelineContainer.innerHTML = "";
if (this.timelineObserver) {
this.timelineObserver.disconnect();
if (this.visibleTimelineElements) this.visibleTimelineElements.clear();
}
const userBubbles = document.querySelectorAll("#chat-messages .chat-bubble-user");
userBubbles.forEach((bubble, index) => {
const msgElement = bubble.closest(".chat-message-wrapper");
if (!msgElement) return;
const id = "msg-" + index;
msgElement.dataset.timelineId = id;
const content = bubble.innerText || "Prompt";
const previewText = content.substring(0, 40).replace(/\n/g, " ") + (content.length > 40 ? "..." : "");
const marker = document.createElement("div");
marker.className = "timeline-marker-wrapper";
marker.dataset.timelineId = id;
marker.innerHTML = `
<div class="timeline-tooltip">${previewText}</div>
<div class="timeline-marker"></div>
`;
marker.onclick = () => {
msgElement.scrollIntoView({ behavior: "smooth", block: "center" });
msgElement.classList.add("source-highlight");
setTimeout(() => {
msgElement.classList.remove("source-highlight");
}, 3e3);
};
timelineContainer.appendChild(marker);
if (this.timelineObserver) this.timelineObserver.observe(msgElement);
});
}
async init() {
try {
this.container.innerHTML = '<div style="color:lime;padding:20px;font-size:16px;">\u23F3 Waiting for DB...</div>';
await db.initPromise;
this.container.innerHTML += '<div style="color:lime;padding:5px 20px;font-size:14px;">\u2705 DB ready: ' + db.isReady + "</div>";
} catch (e) {
this.container.innerHTML += '<div style="color:red;padding:5px 20px;font-size:14px;">\u274C DB Error: ' + e.message + "</div>";
}
let startBehavior = "new";
if (db && db.isReady) {
startBehavior = db.getSetting("start_behavior") || "new";
if (startBehavior === "last") {
const convos = db.exec("SELECT * FROM conversations ORDER BY updated_at DESC LIMIT 1");
if (convos.length === 0) {
this.currentConversationId = "conv_" + Date.now();
} else {
this.currentConversationId = convos[0].id;
}
} else {
this.currentConversationId = "conv_" + Date.now();
}
} else {
this.currentConversationId = "conv_" + Date.now();
}
this.render();
this.loadMessages();
}
async renderPlotlyDiagrams(containerElement) {
if (typeof Plotly === "undefined") return;
const codeBlocks = containerElement.querySelectorAll("code.language-plotly");
codeBlocks.forEach((block) => {
const pre = block.parentElement;
if (pre && pre.tagName === "PRE") {
const wrapper = document.createElement("div");
wrapper.className = "plotly-graph-wrapper";
wrapper.style.margin = "32px auto";
wrapper.style.background = "rgba(255, 255, 255, 0.02)";
wrapper.style.borderRadius = "12px";
wrapper.style.padding = "20px";
wrapper.style.boxShadow = "0 4px 15px rgba(0, 0, 0, 0.05)";
wrapper.style.width = "100%";
wrapper.style.boxSizing = "border-box";
const div = document.createElement("div");
div.className = "plotly-graph";
div.style.width = "100%";
div.style.minHeight = "350px";
try {
const jsonText = block.textContent.trim();
const chartConfig = JSON.parse(jsonText);
let titleText = "";
if (chartConfig.layout && chartConfig.layout.title) {
if (typeof chartConfig.layout.title === "string") {
titleText = chartConfig.layout.title;
} else if (typeof chartConfig.layout.title === "object" && chartConfig.layout.title.text) {
titleText = chartConfig.layout.title.text;
}
delete chartConfig.layout.title;
}
if (titleText) {
const titleEl = document.createElement("h3");
titleEl.textContent = titleText;
titleEl.style.marginTop = "0";
titleEl.style.marginBottom = "16px";
titleEl.style.fontSize = "16px";
titleEl.style.fontWeight = "600";
wrapper.appendChild(titleEl);
}
wrapper.appendChild(div);
const baseLayout = {
paper_bgcolor: "rgba(0,0,0,0)",
plot_bgcolor: "rgba(0,0,0,0)",
margin: { t: 10, b: 80, l: 10, r: 10 },
// Top margin reduced since title is external
autosize: true,
legend: { orientation: "h", y: -0.25, x: 0.5, xanchor: "center" }
};
const finalLayout = Object.assign(baseLayout, chartConfig.layout || {});
pre.parentNode.replaceChild(wrapper, pre);
Plotly.newPlot(div, chartConfig.data || [], finalLayout, { responsive: true, displayModeBar: false });
} catch (e) {
console.error("Plotly JSON parse error:", e);
const errorMsg = document.createElement("div");
errorMsg.style.color = "#ff6b6b";
errorMsg.textContent = "Fehler beim Rendern des Diagramms: " + e.message;
pre.parentNode.replaceChild(errorMsg, pre);
}
}
});
}
attachSourceLinkInteractions(containerElement) {
const links = containerElement.querySelectorAll("details a");
links.forEach((link) => {
link.addEventListener("click", (e) => {
e.preventDefault();
const existingHighlights = containerElement.querySelectorAll(".source-highlight, .inline-highlight");
existingHighlights.forEach((el) => {
el.classList.remove("source-highlight");
el.classList.remove("inline-highlight");
});
const liElement = link.closest("li");
const refId = liElement ? liElement.getAttribute("data-ref") : null;
if (refId) {
const citationSpans = containerElement.querySelectorAll(`span.inline-citation[data-ref="${refId}"]`);
if (citationSpans.length > 0) {
citationSpans.forEach((span) => {
span.classList.add("inline-highlight");
span.scrollIntoView({ behavior: "smooth", block: "center" });
setTimeout(() => {
span.classList.remove("inline-highlight");
}, 3e3);
});
return;
}
}
const detailsEl = link.closest("details");
if (detailsEl) {
let targetEl = detailsEl.previousElementSibling;
while (targetEl && targetEl.tagName === "BR") {
targetEl = targetEl.previousElementSibling;
}
if (targetEl) {
targetEl.classList.add("source-highlight");
targetEl.scrollIntoView({ behavior: "smooth", block: "center" });
setTimeout(() => {
targetEl.classList.remove("source-highlight");
}, 3e3);
}
}
});
link.addEventListener("dblclick", (e) => {
window.open(link.href, "_blank");
});
});
}
setupAnnotations() {
this.pendingAnnotations = [];
this.annotationModal = document.createElement("div");
this.annotationModal.className = "annotation-modal-overlay";
this.annotationModal.style.display = "none";
this.annotationModal.innerHTML = `
<div class="annotation-modal">
<h3>Markierung kommentieren</h3>
<blockquote id="annotation-quote-text"></blockquote>
<textarea id="annotation-input" placeholder="Dein Kommentar..."></textarea>
<div class="annotation-modal-actions">
<button id="annotation-cancel" class="btn secondary">Abbrechen</button>
<button id="annotation-save" class="btn primary">Speichern</button>
</div>
</div>
`;
document.body.appendChild(this.annotationModal);
const inputContainer = document.querySelector(".input-capsule-container");
if (inputContainer) {
this.annotationsWrapper = document.createElement("div");
this.annotationsWrapper.className = "annotations-wrapper";
this.annotationsWrapper.style.cssText = "display: flex; flex-direction: column; align-items: center; margin-bottom: 12px; gap: 8px; width: 100%;";
this.annotationBtn = document.createElement("button");
this.annotationBtn.className = "annotation-floating-btn";
this.annotationBtn.style.cssText = "position: relative; bottom: auto; left: auto; transform: none; display: none; transition: opacity 0.3s; z-index: 200;";
this.annotationBtn.innerHTML = '<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M21.99 4c0-1.1-.89-2-1.99-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h14l4 4-.01-18zM18 14H6v-2h12v2zm0-3H6V9h12v2zm0-3H6V6h12v2z"/></svg> Kommentieren';
this.chipsContainer = document.createElement("div");
this.chipsContainer.className = "annotation-chips-container";
this.chipsContainer.style.cssText = "display: flex; flex-direction: column; gap: 8px; width: 100%;";
this.annotationsWrapper.appendChild(this.annotationBtn);
this.annotationsWrapper.appendChild(this.chipsContainer);
const inputCapsule = inputContainer.querySelector(".input-capsule");
if (inputCapsule) {
inputContainer.insertBefore(this.annotationsWrapper, inputCapsule);
} else {
inputContainer.appendChild(this.annotationsWrapper);
}
}
let currentSelection = null;
let currentRange = null;
document.addEventListener("selectionchange", () => {
const sel = window.getSelection();
if (!sel || sel.isCollapsed) {
if (this.annotationBtn) {
this.annotationBtn.classList.remove("visible");
this.annotationBtn.style.display = "none";
}
return;
}
const anchorNode = sel.anchorNode;
if (!anchorNode) {
if (this.annotationBtn) {
this.annotationBtn.classList.remove("visible");
this.annotationBtn.style.display = "none";
}
return;
}
const isInsideAI = anchorNode.parentElement && anchorNode.parentElement.closest(".chat-bubble-ai");
if (isInsideAI) {
currentSelection = sel.toString().trim();
if (currentSelection.length > 0) {
currentRange = sel.getRangeAt(0).cloneRange();
if (this.annotationBtn) {
this.annotationBtn.style.display = "flex";
requestAnimationFrame(() => {
this.annotationBtn.classList.add("visible");
});
}
}
} else {
if (this.annotationBtn) {
this.annotationBtn.classList.remove("visible");
this.annotationBtn.style.display = "none";
}
}
});
document.addEventListener("pointerdown", (e) => {
if (this.annotationBtn && e.target !== this.annotationBtn && !this.annotationBtn.contains(e.target)) {
setTimeout(() => {
const sel = window.getSelection();
if (!sel || sel.isCollapsed) {
this.annotationBtn.classList.remove("visible");
this.annotationBtn.style.display = "none";
}
}, 100);
}
});
this.annotationBtn.addEventListener("click", () => {
if (!currentSelection || !currentRange) return;
this.annotationBtn.classList.remove("visible");
document.getElementById("annotation-quote-text").textContent = currentSelection;
document.getElementById("annotation-input").value = "";
this.annotationModal.style.display = "flex";
setTimeout(() => document.getElementById("annotation-input").focus(), 100);
});
document.getElementById("annotation-cancel").addEventListener("click", () => {
this.annotationModal.style.display = "none";
});
document.getElementById("annotation-save").addEventListener("click", () => {
const comment = document.getElementById("annotation-input").value.trim();
if (comment) {
let contextSnippet = currentSelection;
try {
const aiBubble = currentRange.commonAncestorContainer.parentElement?.closest(".chat-bubble-ai") || currentRange.startContainer.parentElement?.closest(".chat-bubble-ai");
if (aiBubble) {
const preRange = document.createRange();
preRange.selectNodeContents(aiBubble);
preRange.setEnd(currentRange.startContainer, currentRange.startOffset);
const preText = preRange.toString();
const postRange = document.createRange();
postRange.selectNodeContents(aiBubble);
postRange.setStart(currentRange.endContainer, currentRange.endOffset);
const postText = postRange.toString();
const preSnippet = preText.length > 40 ? "..." + preText.slice(-40) : preText;
const postSnippet = postText.length > 40 ? postText.slice(0, 40) + "..." : postText;
contextSnippet = `${preSnippet}[${currentSelection}]${postSnippet}`.replace(/\n/g, " ").replace(/\s+/g, " ");
}
} catch (e) {
console.warn("Context extraction failed", e);
}
this.pendingAnnotations.push({
quote: currentSelection,
context: contextSnippet,
comment
});
try {
const mark = document.createElement("mark");
mark.className = "ai-text-highlight";
currentRange.surroundContents(mark);
} catch (e) {
console.warn("Could not wrap selection, might cross DOM nodes", e);
}
this.renderAnnotationChips();
}
this.annotationModal.style.display = "none";
window.getSelection().removeAllRanges();
});
}
renderAnnotationChips() {
if (!this.chipsContainer) return;
this.chipsContainer.innerHTML = "";
this.pendingAnnotations.forEach((ann, index) => {
const chip = document.createElement("div");
chip.className = "annotation-chip";
let shortQuote = ann.quote.length > 15 ? ann.quote.substring(0, 15) + "..." : ann.quote;
chip.innerHTML = `
<span>\u{1F4AC} "${shortQuote}"</span>
<button class="remove-chip" data-index="${index}">\u2715</button>
`;
this.chipsContainer.appendChild(chip);
});
this.chipsContainer.querySelectorAll(".remove-chip").forEach((btn) => {
btn.addEventListener("click", (e) => {
const idx = parseInt(e.currentTarget.getAttribute("data-index"));
this.pendingAnnotations.splice(idx, 1);
this.renderAnnotationChips();
});
});
}
async openArticleModal(rawText) {
const modal = document.getElementById("article-view-modal");
if (!modal) {
console.warn("Article modal not found in DOM, falling back to PDF modal. Please hard refresh.");
return this.openPdfModal(rawText);
}
const closeBtn = document.getElementById("article-modal-close");
const docPane = document.getElementById("article-document");
const pdfBtn = document.getElementById("article-pdf-btn");
const presBtn = document.getElementById("article-pres-btn");
modal.style.display = "block";
void modal.offsetWidth;
const processedContent = rawText.replace(/<details>([\s\S]*?)<\/details>/gi, (match, inner) => {
let newInner = inner.replace(/<summary>(.*?)<\/summary>/i, "<summary>$1</summary>\n\n");
newInner = newInner.replace(/\s*-\s*\[/g, "\n- [");
return `<details>
${newInner}
</details>`;
}).replace(/\\\[(.*?)\\\]/g, "$$$$$1$$$$").replace(/\\\((.*?)\\\)/g, "$$$1$$").replace(/\[(\d+)\](?!\()/g, '<span class="inline-citation" data-ref="$1">[$1]</span>');
docPane.innerHTML = marked.parse(processedContent);
this.attachSourceLinkInteractions(docPane);
this.renderPlotlyDiagrams(docPane);
if (window.MathJax) {
window.MathJax.typesetPromise([docPane]).catch((err) => console.log("MathJax error: ", err));
}
const closeHandler = () => {
modal.style.display = "none";
closeBtn.removeEventListener("click", closeHandler);
pdfBtn.removeEventListener("click", pdfHandler);
presBtn.removeEventListener("click", presHandler);
};
const pdfHandler = () => {
closeHandler();
this.openPdfModal(rawText);
};
const presHandler = () => {
closeHandler();
this.openPresentationModal(rawText);
};
closeBtn.addEventListener("click", closeHandler);
pdfBtn.addEventListener("click", pdfHandler);
presBtn.addEventListener("click", presHandler);
}
async openPresentationModal(rawText) {
const modal = document.getElementById("pres-modal");
if (!modal) return;
modal.style.display = "flex";
void modal.offsetWidth;
const closeBtn = document.getElementById("pres-modal-close");
const stage = document.getElementById("pres-slide-stage");
const thumbnailsPane = document.getElementById("pres-thumbnails");
const themeSelect = document.getElementById("pres-theme-select");
const presentBtn = document.getElementById("pres-present-btn");
const downloadBtn = document.getElementById("pres-download-btn");
const chatHistory = document.getElementById("pres-chat-history");
const chatInput = document.getElementById("pres-chat-input");
const chatSendBtn = document.getElementById("pres-chat-send");
let currentPresentationData = null;
let currentRawResponse = null;
let currentSlideIndex = 0;
let presChatHistory = [];
chatHistory.innerHTML = "";
chatInput.value = "";
const closeHandler = () => {
modal.style.display = "none";
document.body.classList.remove("pres-fullscreen");
document.removeEventListener("keydown", keydownHandler);
};
closeBtn.onclick = closeHandler;
const showLoading = (text) => {
stage.innerHTML = `<div class="pdf-loading-overlay"><div class="pdf-loading-skeleton" style="width:100%;height:100%;"></div><div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);z-index:10;color:var(--text-primary);font-weight:600;display:flex;flex-direction:column;align-items:center;gap:16px;"><div class="iserv-spinner"></div>${text}</div></div>`;
};
const applyTheme = (themeData) => {
if (!themeData) {
modal.style.setProperty("--pres-bg", "radial-gradient(circle at top right, #2a0845, #6441A5)");
modal.style.setProperty("--pres-accent", "#c183f0");
modal.style.setProperty("--text-primary", "#ffffff");
return;
}
if (themeData.background) modal.style.setProperty("--pres-bg", themeData.background);
if (themeData.accent) modal.style.setProperty("--pres-accent", themeData.accent);
if (themeData.font) modal.style.fontFamily = `"${themeData.font}", "Inter", sans-serif`;
};
const applyStaticTheme = (themeName) => {
if (themeName === "schoolmind") {
applyTheme({ background: "radial-gradient(circle at top right, #1a1a2e, #16213e, #0f3460)", accent: "#e94560", font: "Inter" });
} else if (themeName === "light") {
applyTheme({ background: "linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%)", accent: "#2c3e50", font: "Inter" });
stage.style.setProperty("--text-primary", "#121212");
} else if (themeName === "ocean") {
applyTheme({ background: "linear-gradient(to right, #434343 0%, black 100%)", accent: "#4ade80", font: "Inter" });
stage.style.setProperty("--text-primary", "#ffffff");
}
};
const renderThumbnails = () => {
thumbnailsPane.innerHTML = "";
if (!currentPresentationData || !currentPresentationData.slides) return;
currentPresentationData.slides.forEach((slide, idx) => {
const wrap = document.createElement("div");
wrap.className = "pres-thumbnail-wrapper" + (idx === currentSlideIndex ? " active" : "");
wrap.onclick = () => {
if (idx !== currentSlideIndex) goToSlide(idx);
};
const scaleWrap = document.createElement("div");
scaleWrap.className = "pres-thumbnail-scale";
scaleWrap.style.transform = `scale(${208 / 1920})`;
scaleWrap.style.background = "var(--pres-bg)";
const dom = PresentationRenderer.renderSlide(slide);
scaleWrap.appendChild(dom);
wrap.appendChild(scaleWrap);
thumbnailsPane.appendChild(wrap);
});
};
const renderActiveSlide = () => {
if (!currentPresentationData || !currentPresentationData.slides || currentPresentationData.slides.length === 0) {
stage.innerHTML = `<div style="padding:40px;text-align:center;color:#ff4444;overflow-y:auto;height:100%;display:flex;flex-direction:column;gap:20px;box-sizing:border-box;">
<div>Fehler bei der Generierung: Die KI hat ein ung\xFCltiges Format gesendet.</div>
<div style="font-family:monospace;font-size:12px;text-align:left;color:#aaa;background:#111;padding:16px;border-radius:8px;flex:1;overflow-y:auto;white-space:pre-wrap;line-height:1.4;border:1px solid rgba(255,255,255,0.1);">RAW OUTPUT DUMP:
${currentRawResponse ? currentRawResponse.replace(/</g, "&lt;").replace(/>/g, "&gt;") : "N/A"}</div>
</div>`;
return;
}
const slide = currentPresentationData.slides[currentSlideIndex];
if (!slide) return;
const dom = PresentationRenderer.renderSlide(slide);
if (document.startViewTransition) {
document.startViewTransition(() => {
stage.innerHTML = "";
stage.appendChild(dom);
stage.classList.remove("animating-in");
void stage.offsetWidth;
stage.classList.add("animating-in");
if (slide.mermaid) {
import("https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs").then((m) => {
m.default.init(void 0, dom.querySelectorAll(".mermaid"));
});
}
});
} else {
stage.innerHTML = "";
stage.appendChild(dom);
stage.classList.remove("animating-in");
void stage.offsetWidth;
stage.classList.add("animating-in");
if (slide.mermaid) {
import("https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs").then((m) => {
m.default.init(void 0, dom.querySelectorAll(".mermaid"));
});
}
}
const thumbs = thumbnailsPane.querySelectorAll(".pres-thumbnail-wrapper");
thumbs.forEach((t, idx) => {
if (idx === currentSlideIndex) t.classList.add("active");
else t.classList.remove("active");
});
};
const goToSlide = (idx) => {
if (idx < 0 || idx >= currentPresentationData.slides.length) return;
currentSlideIndex = idx;
renderActiveSlide();
};
const keydownHandler = (e) => {
if (modal.style.display === "none") return;
if (e.key === "ArrowRight" || e.key === "Space") {
goToSlide(currentSlideIndex + 1);
} else if (e.key === "ArrowLeft") {
goToSlide(currentSlideIndex - 1);
} else if (e.key === "Escape") {
document.body.classList.remove("pres-fullscreen");
}
};
document.addEventListener("keydown", keydownHandler);
presentBtn.onclick = () => {
document.body.classList.add("pres-fullscreen");
stage.requestFullscreen?.();
};
const generatePresentation = async (instruction, contextText) => {
presentationLoader.show();
const currentModel = db.getSetting("model") || "gemini-1.5-flash";
const themeInstruction = themeSelect.value === "dynamic" ? "W\xE4hle passend zum Thema ein wundersch\xF6nes Farbdesign. Gib daf\xFCr den <theme> Block (mit <background> CSS gradient, <accent> Hex, <font> eine echte Google Font wie 'Playfair Display', 'Space Mono', 'Outfit' etc.) aus." : "Du musst keinen <theme> Block ausgeben.";
const sysPrompt = `Du bist ein brillanter Pr\xE4sentations-Designer im Apple Keynote-Stil.
Nutze die Information aus dem Kontext, um eine wundersch\xF6n strukturierte Pr\xE4sentation im folgenden XML Format auszugeben.
GIB NUR XML AUS! Keine Markdown-Formatierung um das XML.
<presentation>
${themeSelect.value === "dynamic" ? "<theme><background>linear-gradient(...)</background><accent>#HEX</accent><font>Playfair Display</font></theme>" : ""}
<slide layout="cover">
<visual><image>english search term for stunning background photo</image></visual>
<title>Gro\xDFe Headline (z.B. f\xFCr den Start)</title>
</slide>
<slide layout="title">
<icon>zap (use valid lucide icon names)</icon>
<title>Headline</title>
<subtitle>Sub</subtitle>
</slide>
<slide layout="image-split">
<title>Optional Title</title>
<content>
<item>Bullet 1</item>
<item>Bullet 2</item>
</content>
<visual>
<image>english search term for real photo</image>
</visual>
</slide>
<slide layout="split">
<title>Optional Title</title>
<content>
<item>Bullet 1</item>
</content>
<visual>
<mermaid>pie title X ...</mermaid>
</visual>
</slide>
<slide layout="quote">
<quote>Inspiring text</quote>
</slide>
<slide layout="bullets">
<title>Summary</title>
<content>
<item>Point</item>
</content>
</slide>
</presentation>
${themeInstruction}
WICHTIG: Begrenze dich auf die wichtigsten 4-8 Slides. Formuliere kurz und pr\xE4gnant f\xFCr Pr\xE4sentationen.`;
const prompt = `INSTRUCTION: ${instruction}
CONTEXT:
${contextText}`;
try {
const { aiRouter: aiRouter2 } = await import("../../ai/router.js");
const response = await aiRouter2.sendMessage(prompt, {
model: currentModel,
systemInstruction: sysPrompt,
history: presChatHistory
});
currentRawResponse = response;
currentPresentationData = PresentationRenderer.parseXML(response);
currentSlideIndex = 0;
if (themeSelect.value === "dynamic" && currentPresentationData.theme) {
applyTheme(currentPresentationData.theme);
} else {
applyStaticTheme(themeSelect.value);
}
renderThumbnails();
renderActiveSlide();
presentationLoader.hide();
} catch (err) {
console.error(err);
presentationLoader.hide();
stage.innerHTML = `<div style="padding:40px;text-align:center;color:#ff4444;">Fehler bei der Generierung: ${err.message}</div>`;
}
};
const addChatMsg = (role, text) => {
const wrapper = document.createElement("div");
wrapper.className = `pdf-chat-msg pdf-chat-${role}`;
wrapper.innerHTML = marked.parse(text);
chatHistory.appendChild(wrapper);
chatHistory.scrollTop = chatHistory.scrollHeight;
};
chatSendBtn.onclick = () => {
const userText = chatInput.value.trim();
if (!userText) return;
chatInput.value = "";
addChatMsg("user", userText);
presChatHistory.push({ role: "user", content: userText });
generatePresentation(`\xDCberarbeite die Pr\xE4sentation anhand dieser Anweisung: ${userText}`, rawText).then(() => {
addChatMsg("model", "Die Pr\xE4sentation wurde entsprechend deiner W\xFCnsche aktualisiert!");
presChatHistory.push({ role: "model", content: "Die Pr\xE4sentation wurde aktualisiert." });
});
};
chatInput.onkeydown = (e) => {
if (e.key === "Enter") chatSendBtn.click();
};
themeSelect.onchange = () => {
if (themeSelect.value !== "dynamic") {
applyStaticTheme(themeSelect.value);
} else if (currentPresentationData && currentPresentationData.theme) {
applyTheme(currentPresentationData.theme);
}
};
downloadBtn.onclick = () => {
if (!currentPresentationData) return;
showLoading("Bereite Download vor...");
fetch("/src/styles/presentation.css").then((r) => r.text()).then((presCss) => {
fetch("/src/styles/global.css").then((r) => r.text()).then((globalCss) => {
let staticHtml = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>SchoolMind Presentation</title>
<style>
${globalCss}
${presCss}
body { margin: 0; padding: 0; background: #000; overflow: hidden; display: flex; align-items: center; justify-content: center; height: 100vh; }
#stage { width: 100vw; height: 100vh; max-width: none; border-radius: 0; transition: all 0.5s; }
.controls { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%) translateZ(0); z-index: 1000; display: flex; gap: 10px; background: rgba(0,0,0,0.5); padding: 10px 20px; border-radius: 30px; backdrop-filter: blur(10px); will-change: transform; backface-visibility: hidden; }
.controls button { background: rgba(255,255,255,0.1); border: none; color: white; padding: 8px 16px; border-radius: 20px; cursor: pointer; }
.controls button:hover { background: rgba(255,255,255,0.3); }
</style>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"><\/script>
<script type="module">
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs';
window.mermaid = mermaid;
<\/script>
</head>
<body>
<div id="stage" class="pres-slide-container" style="--pres-bg: ${stage.style.getPropertyValue("--pres-bg")}; --pres-accent: ${stage.style.getPropertyValue("--pres-accent")}; --text-primary: ${stage.style.getPropertyValue("--text-primary")};"></div>
<div class="controls">
<button id="prevBtn">Zur\xFCck (\u2190)</button>
<button id="nextBtn">Weiter (\u2192)</button>
</div>
<script>
// Simplified renderer embedded
const presentationData = ${JSON.stringify(currentPresentationData)};
let currentIndex = 0;
function renderSlide(slideData) {
const container = document.createElement('div');
container.className = 'slide-content-wrapper slide-layout-' + slideData.layout;
if (slideData.layout === 'title') {
if (slideData.title) container.insertAdjacentHTML('beforeend', '<h1>'+slideData.title+'</h1>');
if (slideData.subtitle) container.insertAdjacentHTML('beforeend', '<h2>'+slideData.subtitle+'</h2>');
}
else if (slideData.layout === 'quote') {
if (slideData.quote) container.insertAdjacentHTML('beforeend', '<blockquote>'+slideData.quote+'</blockquote>');
}
else if (slideData.layout === 'split') {
const textSide = document.createElement('div'); textSide.className = 'slide-text-side';
if (slideData.title) textSide.insertAdjacentHTML('beforeend', '<h2 class="slide-title">'+slideData.title+'</h2>');
if (slideData.bullets) {
const ul = document.createElement('ul'); ul.className = 'slide-bullets';
ul.innerHTML = slideData.bullets.map(b => '<li>'+marked.parseInline(b)+'</li>').join('');
textSide.appendChild(ul);
} else if (slideData.htmlContent) textSide.insertAdjacentHTML('beforeend', slideData.htmlContent);
const visualSide = document.createElement('div'); visualSide.className = 'slide-visual-side';
if (slideData.mermaid) visualSide.insertAdjacentHTML('beforeend', '<div class="mermaid">'+slideData.mermaid+'</div>');
container.appendChild(textSide); container.appendChild(visualSide);
}
else {
if (slideData.title) container.insertAdjacentHTML('beforeend', '<h2 class="slide-title" style="text-align: center; margin-bottom: 4rem;">'+slideData.title+'</h2>');
if (slideData.bullets) {
const ul = document.createElement('ul'); ul.className = 'slide-bullets';
ul.innerHTML = slideData.bullets.map(b => '<li>'+marked.parseInline(b)+'</li>').join('');
container.appendChild(ul);
} else if (slideData.htmlContent) container.insertAdjacentHTML('beforeend', slideData.htmlContent);
}
return container;
}
function update() {
const stage = document.getElementById('stage');
const dom = renderSlide(presentationData.slides[currentIndex]);
if (document.startViewTransition) {
document.startViewTransition(() => {
stage.innerHTML = '';
stage.appendChild(dom);
stage.classList.remove('animating-in');
void stage.offsetWidth;
stage.classList.add('animating-in');
if (window.mermaid && dom.querySelector('.mermaid')) window.mermaid.init(undefined, dom.querySelectorAll('.mermaid'));
});
} else {
stage.innerHTML = ''; stage.appendChild(dom);
stage.classList.remove('animating-in'); void stage.offsetWidth; stage.classList.add('animating-in');
if (window.mermaid && dom.querySelector('.mermaid')) window.mermaid.init(undefined, dom.querySelectorAll('.mermaid'));
}
}
document.getElementById('prevBtn').onclick = () => { if(currentIndex > 0) { currentIndex--; update(); } };
document.getElementById('nextBtn').onclick = () => { if(currentIndex < presentationData.slides.length-1) { currentIndex++; update(); } };
document.addEventListener('keydown', (e) => {
if (e.key === 'ArrowRight' || e.key === 'Space') document.getElementById('nextBtn').click();
if (e.key === 'ArrowLeft') document.getElementById('prevBtn').click();
});
// Initial render
setTimeout(() => update(), 500);
<\/script>
</body>
</html>`;
const blob = new Blob([staticHtml], { type: "text/html" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "SchoolMind_Praesentation.html";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
renderActiveSlide();
});
});
};
addChatMsg("model", "Ich analysiere den Artikel und erstelle eine strukturierte Pr\xE4sentation...");
generatePresentation("Erstelle eine Pr\xE4sentation basierend auf dem Text.", rawText);
}
async openPdfModal(rawText, initialGraphStates = null, graphBlocks = [], skipInitialFormat = false, iservAttachments = null) {
const modal = document.getElementById("pdf-modal");
const closeBtn = document.getElementById("pdf-modal-close");
const docPane = document.getElementById("pdf-document");
const chatHistory = document.getElementById("pdf-chat-history");
const chatInput = document.getElementById("pdf-chat-input");
const chatSendBtn = document.getElementById("pdf-chat-send");
const undoBtn = document.getElementById("pdf-undo-btn");
const redoBtn = document.getElementById("pdf-redo-btn");
const downloadBtn = document.getElementById("pdf-download-btn");
const darkmodeBtn = document.getElementById("pdf-darkmode-btn");
const filenameInput = document.getElementById("pdf-filename-input");
const getLoadingHtml = (statusText) => `
<div class="pdf-loading-overlay">
<div class="pdf-loading-status">${statusText}</div>
<div class="pdf-loading-skeleton">
<div class="skel-line" style="width: 70%; animation-delay: 0s, 0.2s;"></div>
<div class="skel-line" style="width: 95%; animation-delay: 0s, 0.4s;"></div>
<div class="skel-line" style="width: 85%; animation-delay: 0s, 0.6s;"></div>
<div class="skel-line" style="width: 40%; margin-bottom: 16px; animation-delay: 0s, 0.8s;"></div>
<div class="skel-line" style="width: 60%; height: 20px; animation-delay: 0s, 1.2s;"></div>
<div class="skel-line" style="width: 90%; animation-delay: 0s, 1.4s;"></div>
<div class="skel-line" style="width: 85%; animation-delay: 0s, 1.6s;"></div>
<div class="skel-line" style="width: 95%; animation-delay: 0s, 1.8s;"></div>
<div class="skel-line" style="width: 50%; margin-bottom: 16px; animation-delay: 0s, 2.0s;"></div>
<div class="skel-line" style="width: 50%; height: 20px; animation-delay: 0s, 2.4s;"></div>
<div class="skel-line" style="width: 85%; animation-delay: 0s, 2.6s;"></div>
<div class="skel-line" style="width: 90%; animation-delay: 0s, 2.8s;"></div>
<div class="skel-line" style="width: 30%; animation-delay: 0s, 3.0s;"></div>
</div>
</div>
`;
modal.style.display = "flex";
void modal.offsetWidth;
docPane.innerHTML = getLoadingHtml("Generiere formelles Dokument...");
chatHistory.innerHTML = "";
let currentDocText = "";
let pdfHistory = [];
let pdfRedoStack = [];
let isDarkMode = false;
const updateUndoRedoBtns = () => {
undoBtn.style.display = pdfHistory.length > 2 ? "flex" : "none";
redoBtn.style.display = pdfRedoStack.length > 0 ? "flex" : "none";
};
const rebuildChat = () => {
chatHistory.innerHTML = "";
if (pdfHistory.length >= 2) {
addChatMsg("ai", "Das Dokument wurde generiert. Was m\xF6chtest du daran anpassen?");
}
for (let i = 2; i < pdfHistory.length; i += 2) {
if (pdfHistory[i]) {
const msg = pdfHistory[i];
const base64 = msg._attachmentKey && window.pdfAttachments ? window.pdfAttachments[msg._attachmentKey] : null;
addChatMsg("user", msg.content, base64);
}
if (pdfHistory[i + 1]) {
addChatMsg("ai", "Dokument wurde aktualisiert.");
}
}
};
const updateDocument = (markdown) => {
let normalizedMarkdown = markdown.replace(/^[ \t]*[•\-\*][ \t]+/gm, "* ");
if (graphBlocks && graphBlocks.length > 0) {
normalizedMarkdown = normalizedMarkdown.replace(/%%GRAPH_(\d+)%%/g, (match, index) => {
return graphBlocks[parseInt(index, 10)] || match;
});
}
currentDocText = normalizedMarkdown;
let displayMarkdown = normalizedMarkdown;
if (window.pdfAttachments) {
for (const [key, base64] of Object.entries(window.pdfAttachments)) {
displayMarkdown = displayMarkdown.replace(new RegExp(key, "g"), base64);
}
}
displayMarkdown = displayMarkdown.replace(/\\?\[YT-SEARCH:\s*(.+?)\\?\]/g, (match, query) => {
query = query.replace(/\\$/, "").trim();
return formatYoutubeSearch(query);
});
displayMarkdown = displayMarkdown.replace(/\[WORKSHEET_JSON_START\]([\s\S]+?)\[WORKSHEET_JSON_END\]/g, (match, jsonStr) => {
return formatWorksheetInpainting(jsonStr);
});
displayMarkdown = displayMarkdown.replace(/\\?\[BILD:\s*(.+?)\\?\]/g, (match, prompt) => {
prompt = prompt.replace(/\\$/, "").trim();
return formatImageGeneration(prompt);
});
const mathBlocks = [];
let processedContent = displayMarkdown.replace(/\$\$([\s\S]+?)\$\$/g, (match) => {
mathBlocks.push(match);
return `MATHBLOCK${mathBlocks.length - 1}MATHBLOCK`;
});
processedContent = processedContent.replace(/\$(?!\s)([^$\n]+?)(?<!\s)\$/g, (match) => {
mathBlocks.push(match);
return `MATHBLOCK${mathBlocks.length - 1}MATHBLOCK`;
});
let parsedHtml = marked.parse(processedContent);
mathBlocks.forEach((block, index) => {
const id = `MATHBLOCK${index}MATHBLOCK`;
if (block.startsWith("$$")) {
const rawMath = block.substring(2, block.length - 2).trim();
const encodedMath = encodeURIComponent(rawMath);
const wrappedBlock = `
<div class="math-display-wrapper" style="position: relative;" data-raw-math="${encodedMath}">
${block}
</div>
`;
parsedHtml = parsedHtml.replace(id, () => wrappedBlock);
} else {
parsedHtml = parsedHtml.replace(id, () => block);
}
});
docPane.innerHTML = parsedHtml;
this.attachSourceLinkInteractions(docPane);
this.renderPlotlyDiagrams(docPane);
const images = docPane.querySelectorAll("img");
const links = docPane.querySelectorAll("a");
images.forEach((img) => {
const wrapper = document.createElement("div");
wrapper.style.position = "relative";
wrapper.style.cursor = "grab";
wrapper.className = "pdf-img-wrapper";
wrapper.style.touchAction = "none";
if (img.classList.contains("align-left")) {
wrapper.classList.add("align-left");
img.classList.remove("align-left");
}
if (img.classList.contains("align-right")) {
wrapper.classList.add("align-right");
img.classList.remove("align-right");
}
if (img.classList.contains("align-center")) {
wrapper.classList.add("align-center");
img.classList.remove("align-center");
}
img.parentNode.insertBefore(wrapper, img);
wrapper.appendChild(img);
img.draggable = false;
const trash = document.createElement("div");
trash.className = "pdf-img-trash";
trash.innerHTML = '<svg viewBox="0 0 24 24" width="18" height="18" stroke="white" stroke-width="2" fill="none"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>';
trash.style.position = "absolute";
trash.style.top = "8px";
trash.style.right = "8px";
trash.style.width = "32px";
trash.style.height = "32px";
trash.style.background = "rgba(239, 68, 68, 0.9)";
trash.style.display = "flex";
trash.style.alignItems = "center";
trash.style.justifyContent = "center";
trash.style.opacity = "0";
trash.style.transition = "0.2s";
trash.style.cursor = "pointer";
trash.style.borderRadius = "50%";
trash.style.boxShadow = "0 4px 12px rgba(0,0,0,0.3)";
trash.style.zIndex = "10";
wrapper.appendChild(trash);
wrapper.onmouseenter = () => trash.style.opacity = "1";
wrapper.onmouseleave = () => trash.style.opacity = "0";
const getSearchSrc = () => {
const src = img.getAttribute("src");
let searchSrc = src;
if (window.pdfAttachments) {
for (const [key, base64] of Object.entries(window.pdfAttachments)) {
if (base64 === src) {
searchSrc = key;
break;
}
}
}
return searchSrc;
};
trash.onclick = (e) => {
e.stopPropagation();
const searchSrc = getSearchSrc();
const escapedSrc = searchSrc.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const mdImgRegex = new RegExp(`!\\[[^\\]]*\\]\\(${escapedSrc}\\)\\n?`, "g");
const htmlImgRegex = new RegExp(`<img[^>]*src=["']${escapedSrc}["'][^>]*>\\n?`, "g");
let newMarkdown = currentDocText.replace(mdImgRegex, "").replace(htmlImgRegex, "");
pdfRedoStack = [];
pdfHistory.push({ role: "user", content: "Ich habe ein Bild gel\xF6scht." });
pdfHistory.push({ role: "ai", content: newMarkdown });
updateUndoRedoBtns();
updateDocument(newMarkdown);
};
let isDragging = false;
let ghost = null;
let currentTarget = null;
let currentAlignment = "center";
const removeHighlights = () => {
docPane.querySelectorAll(".drop-target-left, .drop-target-right, .drop-target-center").forEach((el) => {
el.classList.remove("drop-target-left", "drop-target-right", "drop-target-center");
});
};
const onPointerDown = (e) => {
if (e.target.closest(".pdf-img-trash")) return;
e.preventDefault();
wrapper.setPointerCapture(e.pointerId);
isDragging = true;
wrapper.style.opacity = "0.3";
wrapper.style.cursor = "grabbing";
currentTarget = wrapper.closest("#pdf-document > *");
currentAlignment = "center";
if (wrapper.classList.contains("align-left")) currentAlignment = "left";
if (wrapper.classList.contains("align-right")) currentAlignment = "right";
ghost = img.cloneNode(true);
ghost.className = "pdf-drag-ghost";
ghost.style.left = e.clientX + "px";
ghost.style.top = e.clientY + "px";
ghost.style.width = Math.max(Math.min(img.clientWidth, 250), 100) + "px";
document.getElementById("pdf-modal").appendChild(ghost);
wrapper.addEventListener("pointermove", onPointerMove);
wrapper.addEventListener("pointerup", onPointerUp);
wrapper.addEventListener("pointercancel", onPointerUp);
};
const onPointerMove = (e) => {
if (!isDragging || !ghost) return;
ghost.style.left = e.clientX + "px";
ghost.style.top = e.clientY + "px";
ghost.style.display = "none";
const elUnder = document.elementFromPoint(e.clientX, e.clientY);
ghost.style.display = "block";
const target = elUnder ? elUnder.closest("#pdf-document > *") : null;
if (target) {
currentTarget = target;
}
removeHighlights();
if (currentTarget) {
const rect = currentTarget.getBoundingClientRect();
const relX = e.clientX - rect.left;
if (relX < rect.width / 3) currentAlignment = "left";
else if (relX > rect.width * 2 / 3) currentAlignment = "right";
else currentAlignment = "center";
currentTarget.classList.add(`drop-target-${currentAlignment}`);
}
};
const onPointerUp = (e) => {
if (!isDragging) return;
isDragging = false;
wrapper.style.opacity = "1";
wrapper.style.cursor = "grab";
try {
wrapper.releasePointerCapture(e.pointerId);
} catch (err) {
}
if (ghost) {
ghost.remove();
ghost = null;
}
wrapper.removeEventListener("pointermove", onPointerMove);
wrapper.removeEventListener("pointerup", onPointerUp);
wrapper.removeEventListener("pointercancel", onPointerUp);
removeHighlights();
if (currentTarget) {
const searchSrc = getSearchSrc();
const escapedSrc = searchSrc.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const mdImgRegex = new RegExp(`!\\[[^\\]]*\\]\\(${escapedSrc}\\)\\n?`, "g");
const htmlImgRegex = new RegExp(`<img[^>]*src=["']${escapedSrc}["'][^>]*>\\n?`, "g");
const alignClass = "align-" + currentAlignment;
const newImgTag = `<img src="${searchSrc}" class="${alignClass}" alt="Bild">`;
let newMarkdown = currentDocText;
if (currentTarget.contains(wrapper) || currentTarget === wrapper) {
if (mdImgRegex.test(newMarkdown)) {
newMarkdown = newMarkdown.replace(mdImgRegex, "\n\n" + newImgTag + "\n\n");
} else if (htmlImgRegex.test(newMarkdown)) {
newMarkdown = newMarkdown.replace(htmlImgRegex, "\n\n" + newImgTag + "\n\n");
} else {
newMarkdown = newMarkdown.replace(mdImgRegex, "").replace(htmlImgRegex, "");
newMarkdown += "\n\n" + newImgTag + "\n\n";
}
} else {
const initialLen = newMarkdown.length;
newMarkdown = newMarkdown.replace(mdImgRegex, "").replace(htmlImgRegex, "");
const removed = initialLen > newMarkdown.length;
const alphanumericSnippet = currentTarget.textContent.replace(/[^a-zA-Z0-9äöüÄÖÜß]/g, "").substring(0, 20);
let blocks = newMarkdown.split(/\n\n+/);
let targetLineIdx = -1;
if (alphanumericSnippet.length > 0) {
for (let i = 0; i < blocks.length; i++) {
if (blocks[i].replace(/[^a-zA-Z0-9äöüÄÖÜß]/g, "").includes(alphanumericSnippet)) {
targetLineIdx = i;
break;
}
}
}
if (targetLineIdx !== -1) {
blocks.splice(targetLineIdx, 0, newImgTag);
newMarkdown = blocks.join("\n\n");
} else {
newMarkdown += "\n\n" + newImgTag;
}
pdfHistory.push({ role: "ai", content: `DEBUG: Removed=${removed}, Target snippet=${alphanumericSnippet}, targetLineIdx=${targetLineIdx}` });
}
pdfRedoStack = [];
pdfHistory.push({ role: "user", content: `Ich habe das Bild nach ${currentAlignment} verschoben.` });
pdfHistory.push({ role: "ai", content: newMarkdown });
updateUndoRedoBtns();
updateDocument(newMarkdown);
if (typeof renderPdfHistory === "function") {
renderPdfHistory();
}
}
};
wrapper.addEventListener("pointerdown", onPointerDown);
});
if (window.MathJax) {
window.MathJax.typesetPromise([docPane]).then(() => {
this.initializeGraphs(docPane, initialGraphStates);
}).catch((err) => {
console.error("MathJax error:", err);
this.initializeGraphs(docPane, initialGraphStates);
});
} else {
this.initializeGraphs(docPane, initialGraphStates);
}
updateUndoRedoBtns();
};
const addChatMsg = (role, text, imageBase64 = null) => {
const msg = document.createElement("div");
msg.className = `chat-msg ${role}`;
if (imageBase64) {
const img = document.createElement("img");
img.src = imageBase64;
img.style.maxWidth = "100%";
img.style.borderRadius = "8px";
img.style.marginBottom = "8px";
img.style.display = "block";
msg.appendChild(img);
}
if (text) {
const txt = document.createElement("div");
txt.textContent = text;
msg.appendChild(txt);
}
chatHistory.appendChild(msg);
chatHistory.scrollTop = chatHistory.scrollHeight;
};
const handleAiFallback = (e) => {
addChatMsg("ai", `*(Hinweis: Automatischer Fallback auf ${e.detail.fallbackModel} genutzt, da das Modell \xFCberlastet war)*`);
};
window.addEventListener("ai-fallback", handleAiFallback);
closeBtn.onclick = () => {
window.removeEventListener("ai-fallback", handleAiFallback);
modal.style.display = "none";
};
undoBtn.onclick = () => {
if (pdfHistory.length > 2) {
const aiMsg = pdfHistory.pop();
const userMsg = pdfHistory.pop();
pdfRedoStack.push(userMsg, aiMsg);
rebuildChat();
const previousAiMessage = pdfHistory[pdfHistory.length - 1].content;
updateDocument(previousAiMessage);
}
};
redoBtn.onclick = () => {
if (pdfRedoStack.length > 0) {
const aiMsg = pdfRedoStack.pop();
const userMsg = pdfRedoStack.pop();
pdfHistory.push(userMsg, aiMsg);
rebuildChat();
updateDocument(aiMsg.content);
}
};
darkmodeBtn.onclick = () => {
isDarkMode = !isDarkMode;
if (isDarkMode) {
docPane.classList.add("dark-mode-doc");
darkmodeBtn.innerHTML = '<svg viewBox="0 0 24 24" width="20" height="20" stroke="currentColor" stroke-width="2" fill="none"><circle cx="12" cy="12" r="5"></circle><line x1="12" y1="1" x2="12" y2="3"></line><line x1="12" y1="21" x2="12" y2="23"></line><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line><line x1="1" y1="12" x2="3" y2="12"></line><line x1="21" y1="12" x2="23" y2="12"></line><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line></svg>';
darkmodeBtn.title = "Light Mode";
} else {
docPane.classList.remove("dark-mode-doc");
darkmodeBtn.innerHTML = '<svg viewBox="0 0 24 24" width="20" height="20" stroke="currentColor" stroke-width="2" fill="none"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path></svg>';
darkmodeBtn.title = "Dark Mode";
}
const fontColor = isDarkMode ? "#ffffff" : "#121212";
const plotlyDivs = docPane.querySelectorAll(".plotly-graph");
plotlyDivs.forEach((div) => {
if (div.data) {
try {
Plotly.relayout(div, { "font.color": fontColor });
} catch (e) {
}
}
});
};
downloadBtn.onclick = () => {
const cover = document.getElementById("pdf-download-cover");
if (cover) cover.style.display = "flex";
setTimeout(() => {
if (isDarkMode) docPane.classList.remove("dark-mode-doc");
const trashIcons = docPane.querySelectorAll(".pdf-img-trash");
trashIcons.forEach((el) => el.style.display = "none");
const resetBtns = docPane.querySelectorAll(".plot-reset-btn");
resetBtns.forEach((el) => el.style.display = "none");
const assistiveMmls = [];
const mmlNodes = docPane.querySelectorAll("mjx-assistive-mml");
mmlNodes.forEach((el) => {
assistiveMmls.push({ parent: el.parentNode, el, nextSibling: el.nextSibling });
el.parentNode.removeChild(el);
});
const mathSvgs = [];
const mjxContainers = docPane.querySelectorAll("mjx-container > svg");
mjxContainers.forEach((svg) => {
const rect = svg.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
const svgData = new XMLSerializer().serializeToString(svg);
const svgBase64 = btoa(unescape(encodeURIComponent(svgData)));
const img = document.createElement("img");
img.src = "data:image/svg+xml;base64," + svgBase64;
img.style.width = rect.width + "px";
img.style.height = rect.height + "px";
img.style.verticalAlign = svg.style.verticalAlign || "middle";
img.style.margin = svg.style.margin || "0";
img.style.display = "inline-block";
img.className = "math-svg-img-temp";
const parent = svg.parentNode;
parent.replaceChild(img, svg);
mathSvgs.push({ parent, svg, img });
});
const originalPadding = docPane.style.padding;
const originalWidth = docPane.style.width;
const originalMinHeight = docPane.style.minHeight;
docPane.style.padding = "0";
docPane.style.width = "170mm";
docPane.style.minHeight = "auto";
const opt = {
margin: 20,
// 20mm margin applied by jsPDF to EVERY page
filename: (filenameInput.value.trim() || "Schul-Dokument") + ".pdf",
image: { type: "jpeg", quality: 1 },
html2canvas: { scale: 2, useCORS: true },
jsPDF: { unit: "mm", format: "a4", orientation: "portrait" },
pagebreak: { mode: ["avoid-all", "css", "legacy"] }
};
const plotlyImages = [];
const plotlyPromises = [];
const plotlyContainers = docPane.querySelectorAll(".plotly-container");
plotlyContainers.forEach((container) => {
if (window.Plotly) {
plotlyPromises.push(window.Plotly.toImage(container, { format: "png", width: container.clientWidth || 642, height: container.clientHeight || 400 }));
}
});
Promise.all(plotlyPromises).then((dataUrls) => {
plotlyContainers.forEach((container, idx) => {
const dataUrl = dataUrls[idx];
if (!dataUrl) return;
const img = document.createElement("img");
img.src = dataUrl;
img.style.width = "100%";
img.style.height = "100%";
img.style.objectFit = "contain";
img.className = "plotly-static-img-temp";
const originalChildren = Array.from(container.childNodes);
originalChildren.forEach((child) => {
if (child.nodeType === 1) child.style.display = "none";
});
container.appendChild(img);
plotlyImages.push({ container, originalChildren, img });
});
html2pdf().set(opt).from(docPane).save().then(() => {
mathSvgs.forEach(({ parent, svg, img }) => {
if (parent.contains(img)) {
parent.replaceChild(svg, img);
}
});
plotlyImages.forEach(({ container, originalChildren, img }) => {
if (container.contains(img)) {
container.removeChild(img);
}
originalChildren.forEach((child) => {
if (child.nodeType === 1) child.style.display = "";
});
});
assistiveMmls.forEach(({ parent, el, nextSibling }) => {
parent.insertBefore(el, nextSibling);
});
docPane.style.padding = originalPadding;
docPane.style.width = originalWidth;
docPane.style.minHeight = originalMinHeight;
if (isDarkMode) docPane.classList.add("dark-mode-doc");
if (cover) cover.style.display = "none";
trashIcons.forEach((el) => el.style.display = "flex");
resetBtns.forEach((el) => el.style.display = "flex");
});
}).catch((err) => {
console.error("Error converting Plotly to image:", err);
html2pdf().set(opt).from(docPane).save().then(() => {
mathSvgs.forEach(({ parent, svg, img }) => {
if (parent.contains(img)) {
parent.replaceChild(svg, img);
}
});
assistiveMmls.forEach(({ parent, el, nextSibling }) => {
parent.insertBefore(el, nextSibling);
});
docPane.style.padding = originalPadding;
docPane.style.width = originalWidth;
docPane.style.minHeight = originalMinHeight;
if (isDarkMode) docPane.classList.add("dark-mode-doc");
if (cover) cover.style.display = "none";
trashIcons.forEach((el) => el.style.display = "flex");
resetBtns.forEach((el) => el.style.display = "flex");
});
});
}, 50);
};
const pdfAttachBtn = document.getElementById("pdf-attach-image-btn");
const pdfFileInput = document.getElementById("pdf-image-upload-input");
const pdfPreviewContainer = document.getElementById("pdf-image-preview-container");
const pdfPreviewImg = document.getElementById("pdf-image-preview-img");
const pdfClearImgBtn = document.getElementById("pdf-clear-image-btn");
let currentPdfImageBase64 = null;
if (pdfAttachBtn) pdfAttachBtn.onclick = () => pdfFileInput.click();
if (pdfClearImgBtn) {
pdfClearImgBtn.onclick = () => {
currentPdfImageBase64 = null;
pdfPreviewContainer.style.display = "none";
if (pdfFileInput) pdfFileInput.value = "";
};
}
if (pdfFileInput) {
pdfFileInput.onchange = (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (ev) => {
currentPdfImageBase64 = ev.target.result;
if (pdfPreviewImg) pdfPreviewImg.src = currentPdfImageBase64;
if (pdfPreviewContainer) pdfPreviewContainer.style.display = "block";
};
reader.readAsDataURL(file);
}
};
}
const requestChange = async (userFeedback) => {
chatInput.disabled = true;
chatSendBtn.disabled = true;
chatSendBtn.classList.add("sending");
chatSendBtn.innerHTML = '<span style="display:inline-block; animation: rotate 1s linear infinite;">\u21BB</span>';
if (pdfAttachBtn) pdfAttachBtn.disabled = true;
docPane.innerHTML = getLoadingHtml("\xDCberarbeite Dokument...");
pdfRedoStack = [];
updateUndoRedoBtns();
const hasImage = !!currentPdfImageBase64;
let finalFeedback = userFeedback;
let attachmentKeyToSave = null;
if (hasImage) {
attachmentKeyToSave = "ATTACHMENT_" + Date.now();
window.pdfAttachments = window.pdfAttachments || {};
window.pdfAttachments[attachmentKeyToSave] = currentPdfImageBase64;
const imageMarker = `![image](${currentPdfImageBase64})`;
const imageInstruction = `
[WICHTIG: Verwende exakt den Markdown-Code ![Bild](${attachmentKeyToSave}) um das angeh\xE4ngte Bild im Dokument zu platzieren!]
`;
finalFeedback = `${imageMarker}${imageInstruction}
${userFeedback}`;
}
addChatMsg("user", userFeedback, currentPdfImageBase64);
pdfHistory.push({ role: "user", content: userFeedback, _attachmentKey: attachmentKeyToSave });
if (hasImage) {
currentPdfImageBase64 = null;
if (pdfPreviewContainer) pdfPreviewContainer.style.display = "none";
if (pdfFileInput) pdfFileInput.value = "";
}
const prompt = "\xDCberarbeite das aktuelle Dokument anhand der folgenden Anweisung. Behalte das formelle Markdown-Format (inklusive Tabellen, falls n\xFCtzlich) bei und gib NUR den neuen Inhalt des Dokuments aus (ohne Begr\xFC\xDFung etc.). Hier ist das aktuelle Dokument:\n\n" + currentDocText + "\n\n\xC4nderungswunsch des Nutzers:\n" + finalFeedback;
try {
const currentModel2 = db.getSetting("model") || "gemini-2.5-flash";
const response = await aiRouter.sendMessage(prompt, { model: currentModel2, history: pdfHistory });
updateDocument(response);
pdfHistory.push({ role: "ai", content: response });
addChatMsg("ai", "Dokument wurde aktualisiert.");
rebuildChat();
} catch (e) {
addChatMsg("ai", "Fehler: " + e.message);
} finally {
chatInput.disabled = false;
chatSendBtn.disabled = false;
if (pdfAttachBtn) pdfAttachBtn.disabled = false;
chatSendBtn.classList.remove("sending");
chatSendBtn.innerHTML = '<svg viewBox="0 0 24 24" width="20" height="20" stroke="currentColor" stroke-width="2" fill="none"><line x1="22" y1="2" x2="11" y2="13"></line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon></svg>';
chatInput.focus();
}
};
chatSendBtn.onclick = () => {
if (chatInput.value.trim() || currentPdfImageBase64) {
const val = chatInput.value.trim();
chatInput.value = "";
requestChange(val);
}
};
chatInput.onkeypress = (e) => {
if (e.key === "Enter") chatSendBtn.onclick();
};
const currentModel = db.getSetting("model") || "gemini-2.5-flash";
const titlePrompt = "Generiere einen sehr kurzen, passenden Dateinamen (max 3-4 Worte, keine Dateiendung, keine Sonderzeichen) f\xFCr diesen Text:\n\n" + rawText;
aiRouter.sendMessage(titlePrompt, { model: currentModel, history: [] }).then((title) => {
filenameInput.value = title.trim().replace(/ /g, "_").replace(/[^a-zA-Z0-9äöüÄÖÜß_-]/g, "");
}).catch((e) => console.error("Title generation failed", e));
if (iservAttachments) {
window.pdfAttachments = {};
iservAttachments.forEach((att, idx) => {
window.pdfAttachments[`iserv_att_${idx}`] = `data:${att.mimeType};base64,${att.data}`;
});
}
if (skipInitialFormat) {
pdfHistory.push({ role: "user", content: "Erstelle ein Dokument mit dieser L\xF6sung." });
pdfHistory.push({ role: "ai", content: rawText });
updateDocument(rawText);
addChatMsg("ai", "Ich habe die L\xF6sung in das Dokument \xFCbernommen. Was m\xF6chtest du noch anpassen?");
} else {
const initialPrompt = "Schreibe den folgenden Text in ein formelles, sauberes Schul-Dokument um. Entferne jegliche Konversations-Elemente, Begr\xFC\xDFungen und KI-Floskeln. Optimiere die Struktur (Titel, Abs\xE4tze, Aufz\xE4hlungen). ERSTELLE KEINE TABELLEN AUS NORMALEM TEXT, es sei denn, der Ursprungstext enth\xE4lt bereits Tabellen! WICHTIG: Verwende UNTER KEINEN UMST\xC4NDEN die 'SiriHero'- oder 'KnowledgeCard'-Syntax! Wenn der Ursprungstext Platzhalter wie %%GRAPH_0%% enth\xE4lt, \xFCbernimm diese EXAKT 1:1 an der passenden Stelle in das Dokument. Das Dokument darf nur reines Standard-Markdown enthalten (\xDCberschriften mit #). \xDCbernimm echte Bild-URLs (![Bild](url)) exakt so in dein Dokument. Entferne st\xF6rende Wiki-Elemente (wie Pfeile oder [1] Zitate). Gib AUSSCHLIESSLICH den Markdown-Code zur\xFCck.\n\nText:\n" + rawText;
try {
const response = await aiRouter.sendMessage(initialPrompt, { model: currentModel, history: [] });
pdfHistory.push({ role: "user", content: initialPrompt });
pdfHistory.push({ role: "ai", content: response });
updateDocument(response);
addChatMsg("ai", "Das Dokument wurde generiert. Was m\xF6chtest du daran anpassen?");
} catch (e) {
docPane.innerHTML = `<div style="color: red;">Fehler: ${e.message}</div>`;
}
}
}
render() {
this.isDestroyed = false;
if (!db || !db.isReady) {
this.container.innerHTML = `
<div style="padding: 40px; text-align: center; color: var(--text-secondary); display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%;">
<svg viewBox="0 0 24 24" width="48" height="48" fill="none" stroke="currentColor" stroke-width="2" style="margin-bottom: 16px; opacity: 0.5;"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
<h2 style="margin-bottom: 8px;">Datenbank-Fehler</h2>
<p style="font-size: 14px; max-width: 300px;">Die lokale Datenbank konnte nicht geladen werden. Bitte lade die Seite neu oder l\xF6sche den Cache deines Browsers.</p>
</div>
`;
return;
}
const currentModel = db.getSetting("model") || "gemini-3.5-flash";
const useProfileStr = db.getSetting("use_profile");
const useProfile = useProfileStr === null ? true : useProfileStr === "true";
const modelNames = {
// Gemini
"gemini-3.6-flash": "Gemini 3.6 Flash",
"gemini-3.5-flash": "Gemini 3.5 Flash",
"gemini-3.5-flash-lite": "Gemini 3.5 Flash Lite",
"gemini-3.1-flash-lite": "Gemini 3.1 Flash Lite",
"gemini-3-flash-preview": "Gemini 3 Flash Preview",
"gemini-2.5-flash": "Gemini 2.5 Flash",
"gemini-2.5-flash-lite": "Gemini 2.5 Flash Lite",
// Gemma & Groq
"gemma-4-31b-it": "Gemma 4 31B IT",
"gemma-4-26b-a4b-it": "Gemma 4 26B IT",
"groq-llama-3.3-70b-versatile": "Llama 3.3 70B (Groq)",
"groq-llama3-70b-8192": "llama3-70b-8192",
// OpenRouter (Mobile App)
"openrouter-meta-llama-3.1-70b-instruct": "Meta-Llama-3.1-70B-Instruct",
"openrouter-codestral-latest": "codestral-latest",
"openrouter-qwen/qwen-2.5-coder-32b-instruct:free": "qwen/qwen-2.5-coder-32b-instruct:free",
"openrouter-gemma-4-31b": "gemma-4-31b",
// DeepSeek V4
"deepseek-v4-pro": "DeepSeek V4 Pro",
"deepseek-v4-flash": "DeepSeek V4 Flash",
// Lokale Ausführung
"ollama-llama3": "Ollama Llama 3",
"local-phone-qwen": "Lokal Handy (Qwen)",
"local-pc-llama": "Lokal PC (Llama 3)"
};
const displayModel = modelNames[currentModel] || currentModel;
const msgs = db.getMessages(this.currentConversationId);
const hasMessages = msgs && msgs.length > 0;
const btnDisplay = hasMessages ? "flex" : "none";
this.container.innerHTML = `
<!-- Floating Top Controls (No Background) -->
<div id="chat-overlay-header" class="chat-overlay-header">
<!-- Left: Menu & Tools -->
<div style="display: flex; flex-direction: column; gap: 8px; align-items: flex-start; pointer-events: auto; position: relative;">
<button id="open-sidebar-btn" class="icon-btn pdf-top-btn gpu-accelerated" style="background: rgba(255,255,255,0.05); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); border: 1px solid rgba(255,255,255,0.05); border-radius: 50%; width: 50px; height: 50px; display: flex; align-items: center; justify-content: center; transition: all 0.3s ease;">
<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor"><path d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z"/></svg>
</button>
<button id="pdf-theme-toggle-btn" class="icon-btn pdf-top-btn gpu-accelerated" title="PDF Lese-Modus" style="background: rgba(255,255,255,0.05); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); border: 1px solid rgba(255,255,255,0.05); border-radius: 50%; width: 50px; height: 50px; display: ${btnDisplay}; align-items: center; justify-content: center; transition: all 0.3s ease;">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>
</button>
<button id="pdf-color-mode-btn" class="icon-btn pdf-top-btn gpu-accelerated" title="Dark/Light Mode" style="background: rgba(255,255,255,0.05); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); border: 1px solid rgba(255,255,255,0.05); border-radius: 50%; width: 34px; height: 34px; display: ${btnDisplay}; align-items: center; justify-content: center; transition: all 0.3s ease; margin-left: 20px;">
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M12 21a9 9 0 1 1 0-18 9 9 0 0 1 0 18zm0-2a7 7 0 1 0 0-14v14z"/></svg>
</button>
</div>
<!-- Center: Dynamic Island -->
<div style="pointer-events: auto; position: relative; flex-grow: 1; display: flex; justify-content: center; z-index: 1000; height: 40px;">
<div id="dynamic-island" class="dynamic-island">
<div class="di-collapsed">
<span id="current-model-display">${displayModel}</span>
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor" style="opacity: 0.6;"><path d="M7 10l5 5 5-5z"/></svg>
</div>
<div id="model-dropdown-menu" class="di-expanded">
<!-- Dynamically generated pages -->
${[
{
title: "Gemini",
models: [
{ id: "gemini-3.6-flash", desc: "" },
{ id: "gemini-3.5-flash", desc: "" },
{ id: "gemini-3.5-flash-lite", desc: "" },
{ id: "gemini-3.1-flash-lite", desc: "" },
{ id: "gemini-3-flash-preview", desc: "" },
{ id: "gemini-2.5-flash", desc: "" }
]
},
{
title: "Gemma & Groq",
models: [
{ id: "gemma-4-31b-it", desc: "" },
{ id: "gemma-4-26b-a4b-it", desc: "" },
{ id: "groq-llama-3.3-70b-versatile", desc: "" },
{ id: "groq-llama3-70b-8192", desc: "" }
]
},
{
title: "OpenRouter (Mobile App)",
models: [
{ id: "openrouter-meta-llama-3.1-70b-instruct", desc: "" },
{ id: "openrouter-codestral-latest", desc: "" },
{ id: "openrouter-qwen/qwen-2.5-coder-32b-instruct:free", desc: "" },
{ id: "openrouter-gemma-4-31b", desc: "" }
]
},
{
title: "DeepSeek V4",
models: [
{ id: "deepseek-v4-pro", desc: "" },
{ id: "deepseek-v4-flash", desc: "" }
]
}
].map((page) => `
<div class="dropdown-page">
<div style="text-align: center; font-size: 11px; font-weight: 600; color: #888; text-transform: uppercase; margin-bottom: 8px; letter-spacing: 0.5px;">${page.title}</div>
<div class="model-list-container" style="max-height: 250px; overflow-y: auto;">
${page.models.map((m) => `
<div class="dropdown-item ${currentModel === m.id ? "selected" : ""}" data-model="${m.id}">
<div>${modelNames[m.id]}${m.desc ? `<div style="font-size: 12px; color: #888; font-weight: 400; margin-top: 2px;">${m.desc}</div>` : ""}</div>
</div>
`).join("")}
</div>
</div>
`).join("")}
</div>
</div>
</div>
<!-- Right: New Chat -->
<button id="new-chat-btn" class="icon-btn pdf-top-btn gpu-accelerated" style="pointer-events: auto; background: rgba(255,255,255,0.05); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); border: 1px solid rgba(255,255,255,0.05); border-radius: 50%; width: 50px; height: 50px; display: flex; align-items: center; justify-content: center;">
<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a.996.996 0 0 0 0-1.41l-2.34-2.34a.996.996 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
</button>
</div>
<style>
#chat-messages {
box-sizing: border-box;
padding: 80px 56px 180px 24px;
transition: padding-right 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
}
#chat-messages.timeline-collapsed,
body.chat-is-empty #chat-messages {
padding-right: 24px !important;
}
.chat-message-wrapper {
max-width: 85%;
transition: max-width 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
}
#chat-messages.timeline-collapsed .chat-message-wrapper {
max-width: 100% !important;
}
</style>
<div id="research-canvas-overlay" style="display: none; position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 50; background: rgba(0,0,0,0.5);"></div>
<div id="chat-messages" class="contain-paint timeline-collapsed" style="display: flex; flex-direction: column; gap: 16px; margin-top: 8px; min-height: 100%;">
<!-- Messages will be injected here -->
</div>
`;
const existingBtn = document.getElementById("timeline-toggle-btn");
if (existingBtn) existingBtn.remove();
const existingWrapper = document.getElementById("timeline-wrapper");
if (existingWrapper) existingWrapper.remove();
const timelineHTML = `
<!-- Timeline Minimap Toggle Button -->
<button id="timeline-toggle-btn" class="timeline-toggle-btn collapsed" title="Timeline ein-/ausklappen">
<svg viewBox="0 0 24 24" width="14" height="14" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg>
</button>
<!-- Timeline Minimap -->
<div id="timeline-wrapper" class="timeline-wrapper collapsed">
<div id="chat-timeline" class="chat-timeline-container"></div>
</div>
`;
document.body.insertAdjacentHTML("beforeend", timelineHTML);
setTimeout(() => {
const toggleBtn = document.getElementById("timeline-toggle-btn");
const wrapper = document.getElementById("timeline-wrapper");
if (toggleBtn && wrapper) {
toggleBtn.addEventListener("click", () => {
wrapper.classList.toggle("collapsed");
toggleBtn.classList.toggle("collapsed");
const messages = document.getElementById("chat-messages");
if (messages) {
messages.classList.toggle("timeline-collapsed");
}
});
}
}, 0);
const dynamicIsland = document.getElementById("dynamic-island");
const dropdownMenu = document.getElementById("model-dropdown-menu");
let lastIslandHeight = 0;
const updateIslandHeight = () => {
if (!dynamicIsland.classList.contains("expanded")) {
dynamicIsland.style.height = "";
lastIslandHeight = 0;
return;
}
const pages = Array.from(dropdownMenu.querySelectorAll(".dropdown-page"));
const clientWidth = dropdownMenu.clientWidth || 250;
const pageIndex = Math.max(0, Math.min(pages.length - 1, Math.round(dropdownMenu.scrollLeft / clientWidth)));
const activePage = pages[pageIndex];
if (activePage) {
const targetHeight = activePage.scrollHeight + 32;
if (targetHeight !== lastIslandHeight) {
dynamicIsland.style.height = targetHeight + "px";
lastIslandHeight = targetHeight;
}
}
};
dropdownMenu.addEventListener("scroll", updateIslandHeight);
dynamicIsland.addEventListener("click", (e) => {
if (window._hasDraggedDropdown) return;
if (dynamicIsland.classList.contains("expanded") && dropdownMenu.contains(e.target)) {
if (!e.target.closest(".dropdown-item") && !e.target.closest(".ios-toggle")) {
return;
}
}
e.stopPropagation();
dynamicIsland.classList.toggle("expanded");
if (dynamicIsland.classList.contains("expanded")) {
const pages = Array.from(dropdownMenu.querySelectorAll(".dropdown-page"));
const activeModel = db.getSetting("model") || "gemini-1.5-flash";
const pageIndex = pages.findIndex((page) => page.querySelector(`.dropdown-item[data-model="${activeModel}"]`));
if (pageIndex !== -1) {
dropdownMenu.scrollLeft = pageIndex * dropdownMenu.clientWidth;
currentPage = pageIndex;
}
setTimeout(updateIslandHeight, 10);
} else {
updateIslandHeight();
}
});
document.addEventListener("click", (e) => {
if (window._isDraggingDropdown) return;
if (dynamicIsland && !dynamicIsland.contains(e.target) && dynamicIsland.classList.contains("expanded")) {
dynamicIsland.classList.remove("expanded");
updateIslandHeight();
}
});
dropdownMenu.querySelectorAll(".dropdown-item[data-model]").forEach((item) => {
item.addEventListener("click", (e) => {
if (window._hasDraggedDropdown) {
e.preventDefault();
e.stopPropagation();
return;
}
db.setSetting("model", item.dataset.model);
dynamicIsland.classList.remove("expanded");
setTimeout(() => {
this.render();
this.loadMessages();
if (window.updateChatPlaceholder) window.updateChatPlaceholder();
}, 0);
});
});
let isDown = false;
let startX;
let scrollLeft;
let currentPage = 0;
window._hasDraggedDropdown = false;
dropdownMenu.addEventListener("mousedown", (e) => {
isDown = true;
window._hasDraggedDropdown = false;
startX = e.pageX - dropdownMenu.offsetLeft;
scrollLeft = dropdownMenu.scrollLeft;
currentPage = Math.round(scrollLeft / dropdownMenu.clientWidth);
dropdownMenu.style.cursor = "grabbing";
dropdownMenu.style.scrollSnapType = "none";
document.body.style.userSelect = "none";
});
window.addEventListener("mouseup", (e) => {
if (!isDown) return;
isDown = false;
dropdownMenu.style.cursor = "auto";
dropdownMenu.style.scrollSnapType = "x mandatory";
document.body.style.userSelect = "";
if (window._hasDraggedDropdown) {
window._isDraggingDropdown = true;
const walk = startX - (e.pageX - dropdownMenu.offsetLeft);
if (walk > 30) {
currentPage++;
} else if (walk < -30) {
currentPage--;
}
const numPages = dropdownMenu.querySelectorAll(".dropdown-page").length;
if (currentPage < 0) currentPage = 0;
if (currentPage >= numPages) currentPage = numPages - 1;
dropdownMenu.scrollTo({
left: currentPage * dropdownMenu.clientWidth,
behavior: "smooth"
});
setTimeout(() => {
window._isDraggingDropdown = false;
window._hasDraggedDropdown = false;
}, 100);
}
});
window.addEventListener("mousemove", (e) => {
if (!isDown) return;
e.preventDefault();
const x = e.pageX - dropdownMenu.offsetLeft;
let walk = (startX - x) * 1.5;
const maxDrag = dropdownMenu.clientWidth;
if (walk > maxDrag) walk = maxDrag;
if (walk < -maxDrag) walk = -maxDrag;
if (Math.abs(walk) > 15) {
window._hasDraggedDropdown = true;
}
dropdownMenu.scrollLeft = scrollLeft + walk;
});
const profileToggles = document.querySelectorAll(".chat-profile-toggle");
profileToggles.forEach((toggle) => {
toggle.addEventListener("change", (e) => {
const isChecked = e.target.checked;
db.setSetting("use_profile", isChecked ? "true" : "false");
profileToggles.forEach((t) => t.checked = isChecked);
if (window.updateChatPlaceholder) window.updateChatPlaceholder();
});
});
document.getElementById("open-sidebar-btn").addEventListener("click", (e) => {
e.preventDefault();
this.openSidebar();
});
document.getElementById("new-chat-btn").addEventListener("click", (e) => {
e.preventDefault();
this.createNewChat();
});
const pdfToggleBtn = document.getElementById("pdf-theme-toggle-btn");
if (pdfToggleBtn) {
pdfToggleBtn.addEventListener("click", () => {
document.body.classList.toggle("pdf-reading-mode");
if (document.body.classList.contains("pdf-reading-mode")) {
document.body.classList.add("pdf-input-collapsed");
} else {
document.body.classList.remove("pdf-input-collapsed");
}
});
}
const pdfColorBtn = document.getElementById("pdf-color-mode-btn");
if (pdfColorBtn) {
pdfColorBtn.addEventListener("click", () => {
document.body.classList.toggle("pdf-light-mode");
});
}
const overlay = document.getElementById("sidebar-overlay");
const closeBtn = document.getElementById("close-sidebar-btn");
const fullscreenBtn = document.getElementById("fullscreen-sidebar-btn");
const sidebar = document.getElementById("history-sidebar");
if (overlay) overlay.onclick = () => this.closeSidebar();
if (closeBtn) closeBtn.onclick = () => this.closeSidebar();
if (fullscreenBtn) {
fullscreenBtn.onclick = () => {
sidebar.classList.toggle("fullscreen");
};
}
this.loadHistory();
this.scrollToBottom();
}
openSidebar() {
const sidebar = document.getElementById("history-sidebar");
const overlay = document.getElementById("sidebar-overlay");
document.body.classList.add("sidebar-open");
sidebar.classList.add("active");
overlay.classList.add("active");
setTimeout(() => {
this.loadHistory();
}, 50);
}
closeSidebar() {
const sidebar = document.getElementById("history-sidebar");
document.getElementById("sidebar-overlay").classList.remove("active");
sidebar.classList.remove("fullscreen", "active");
document.body.classList.remove("sidebar-open");
}
createNewChat() {
this.currentConversationId = "conv_" + Date.now();
this.render();
this.loadMessages();
this.closeSidebar();
}
switchChat(conversationId, targetMessageId = null, searchQuery = null) {
this.currentConversationId = conversationId;
this.render();
this.loadMessages(targetMessageId, searchQuery);
this.closeSidebar();
}
showCustomPrompt(title, defaultValue, onConfirm) {
const overlay = document.createElement("div");
overlay.className = "custom-modal-overlay";
overlay.innerHTML = `
<div class="custom-modal">
<h3>${title}</h3>
<input type="text" id="modal-input" value="${defaultValue}" />
<div class="modal-actions">
<button class="modal-btn cancel">Abbrechen</button>
<button class="modal-btn confirm">Speichern</button>
</div>
</div>
`;
document.body.appendChild(overlay);
const input = overlay.querySelector("#modal-input");
input.focus();
input.select();
const close = () => {
overlay.classList.add("fade-out");
setTimeout(() => overlay.remove(), 200);
};
overlay.querySelector(".cancel").onclick = close;
overlay.querySelector(".confirm").onclick = () => {
onConfirm(input.value);
close();
};
input.onkeydown = (e) => {
if (e.key === "Enter") {
onConfirm(input.value);
close();
}
};
}
showCustomConfirm(title, text, onConfirm) {
const overlay = document.createElement("div");
overlay.className = "custom-modal-overlay";
overlay.innerHTML = `
<div class="custom-modal">
<h3>${title}</h3>
<p>${text}</p>
<div class="modal-actions">
<button class="modal-btn cancel">Abbrechen</button>
<button class="modal-btn confirm danger">L\xF6schen</button>
</div>
</div>
`;
document.body.appendChild(overlay);
const close = () => {
overlay.classList.add("fade-out");
setTimeout(() => overlay.remove(), 200);
};
overlay.querySelector(".cancel").onclick = close;
overlay.querySelector(".confirm").onclick = () => {
onConfirm();
close();
};
}
loadHistory() {
const listContainer = document.getElementById("history-list");
const searchInput = document.getElementById("history-search-input");
if (!listContainer) return;
if (searchInput && !searchInput.dataset.bound) {
searchInput.dataset.bound = "true";
let timeout = null;
searchInput.addEventListener("input", () => {
if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => this.loadHistory(), 300);
});
}
const query = searchInput ? searchInput.value.trim() : "";
listContainer.innerHTML = "";
if (query) {
const results = db.searchMessages(query);
if (results.length === 0) {
listContainer.innerHTML = '<div style="padding: 16px; text-align: center; color: #888; font-size: 14px;">Keine Treffer gefunden.</div>';
return;
}
const header = document.createElement("div");
header.className = "history-group-header";
header.innerText = "Suchergebnisse";
listContainer.appendChild(header);
results.forEach((res) => {
const getSnippet = (contentStr, q) => {
contentStr = contentStr || "";
const matchIndex = contentStr.toLowerCase().indexOf(q.toLowerCase());
let snippet = "";
if (matchIndex !== -1) {
const start = Math.max(0, matchIndex - 40);
const end = Math.min(contentStr.length, matchIndex + q.length + 80);
let rawSnippet = contentStr.substring(start, end);
rawSnippet = rawSnippet.replace(/[*_#`\[\]]/g, "").replace(/\n/g, " ");
rawSnippet = rawSnippet.replace(/!(?:SiriHero|KnowledgeCard):\s*[^(]+\([^)]+\)\s*(?:[^\s]+\s*)?/gi, "");
const snippetLower = rawSnippet.toLowerCase();
const qLower = q.toLowerCase();
const localMatch = snippetLower.indexOf(qLower);
if (localMatch !== -1) {
snippet = rawSnippet.substring(0, localMatch) + `<strong style="color: #fff; background: rgba(255,255,255,0.15); padding: 0 2px; border-radius: 3px;">${rawSnippet.substring(localMatch, localMatch + q.length)}</strong>` + rawSnippet.substring(localMatch + q.length);
} else {
snippet = rawSnippet;
}
if (start > 0) snippet = "..." + snippet;
if (end < contentStr.length) snippet = snippet + "...";
} else {
snippet = contentStr.substring(0, 80) + "...";
}
return snippet;
};
const firstMatch = res.matches[0];
const firstSnippet = getSnippet(firstMatch.content, query);
let additionalMatchesHTML = "";
if (res.matches.length > 1) {
const others = res.matches.slice(1);
const othersItems = others.map((m) => {
const snip = getSnippet(m.content, query);
const dateStr = new Date(m.created_at).toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "2-digit", hour: "2-digit", minute: "2-digit" });
return `<div class="additional-match" data-msg-id="${m.message_id}" style="margin-top: 8px; padding-top: 8px; border-top: 1px solid rgba(255,255,255,0.05); padding-bottom: 4px; padding-left: 4px; border-radius: 4px; cursor: pointer; transition: background 0.2s;" onmouseover="this.style.background='rgba(255,255,255,0.1)'" onmouseout="this.style.background='transparent'">
<div style="font-size: 10px; color: #888; margin-bottom: 2px;">${m.role === "user" ? "\u{1F464} Du" : "\u{1F916} KI"} \u2022 ${dateStr}</div>
<div>${snip}</div>
</div>`;
}).join("");
additionalMatchesHTML = `
<details class="history-search-details" style="margin-top: 8px;">
<summary style="font-size: 12px; color: #aaa; cursor: pointer; user-select: none; padding: 4px 0; outline: none;">
${others.length} weitere Treffer \u25BE
</summary>
<div style="font-size: 13px; color: #bbb; padding: 4px 0 4px 8px; border-left: 2px solid rgba(255,255,255,0.1); margin-top: 4px; overflow: hidden;">
${othersItems}
</div>
</details>
`;
}
const isActive = res.conversation_id === this.currentConversationId;
const item = document.createElement("div");
item.className = `history-item ${isActive ? "active" : ""}`;
item.style.height = "auto";
item.style.minHeight = "min-content";
item.style.cursor = "pointer";
const firstDateStr = new Date(firstMatch.created_at).toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "2-digit", hour: "2-digit", minute: "2-digit" });
item.innerHTML = `
<div class="history-title" style="pointer-events: none;">
<div style="display: flex; align-items: center; gap: 8px; overflow: hidden;">
<span>${res.emoji || "\u{1F4AC}"}</span>
<span style="overflow: hidden; text-overflow: ellipsis;">${(res.title || "").replace(/</g, "&lt;").replace(/>/g, "&gt;")}</span>
</div>
</div>
<div class="history-preview" style="line-height: 1.4; margin-top: 6px; pointer-events: none;">
<div style="font-size: 10px; color: #888; margin-bottom: 2px;">${firstMatch.role === "user" ? "\u{1F464} Du" : "\u{1F916} KI"} \u2022 ${firstDateStr}</div>
${firstSnippet}
</div>
<div style="pointer-events: auto;">${additionalMatchesHTML}</div>
`;
item.onclick = (e) => {
const matchDiv = e.target.closest(".additional-match");
if (matchDiv) {
this.switchChat(res.conversation_id, matchDiv.dataset.msgId, query);
return;
}
if (e.target.closest("summary")) return;
this.switchChat(res.conversation_id, firstMatch.message_id, query);
};
listContainer.appendChild(item);
});
} else {
const convos = db.getConversations();
const now = /* @__PURE__ */ new Date();
const grouped = {};
convos.forEach((conv) => {
const d = new Date(conv.updated_at);
let groupName = d.toLocaleDateString("de-DE", { weekday: "long" });
const diffTime = Math.abs(now - d);
const diffDays = Math.ceil(diffTime / (1e3 * 60 * 60 * 24));
if (d.toDateString() === now.toDateString()) groupName = "Heute";
else if (diffDays === 1 || diffDays === 2 && d.getDay() === now.getDay() - 1) groupName = "Gestern";
else if (diffDays > 7) groupName = "\xC4lter";
if (!grouped[groupName]) grouped[groupName] = [];
grouped[groupName].push(conv);
});
const orderedGroups = ["Heute", "Gestern"];
const allGroups = [...orderedGroups, ...Object.keys(grouped).filter((k) => !orderedGroups.includes(k) && k !== "\xC4lter"), "\xC4lter"];
allGroups.forEach((groupName) => {
const chats = grouped[groupName];
if (!chats) return;
const header = document.createElement("div");
header.className = "history-group-header";
header.innerText = groupName;
listContainer.appendChild(header);
chats.forEach((conv) => {
const isActive = conv.id === this.currentConversationId;
const item = document.createElement("div");
item.className = `history-item ${isActive ? "active" : ""}`;
item.innerHTML = `
<div class="history-item-content" style="min-width: 0; display: flex; flex-direction: column;">
<div class="history-title" style="min-width: 0; flex-shrink: 0;">
<div style="display: flex; align-items: center; gap: 8px; overflow: hidden; width: 100%;">
<span style="flex-shrink: 0;">${conv.emoji || "\u{1F4AC}"}</span>
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex-grow: 1;">${(conv.title || "").replace(/</g, "&lt;").replace(/>/g, "&gt;")}</span>
</div>
</div>
<div class="history-preview">${(conv.preview || "Keine Nachrichten").replace(/!\\[(?:SiriHero|KnowledgeCard)[^\\]]*\\]\\([^)]*\\)/gi, "").replace(/!(?:SiriHero|KnowledgeCard):[^\\n]+/gi, "").replace(/[*_#`\\[\\]]/g, "").replace(/</g, "&lt;").replace(/>/g, "&gt;").trim()}</div>
</div>
<div class="history-dropdown-wrapper">
<div class="history-dropdown">
<div class="history-dropdown-inner">
<button class="history-dropdown-btn edit-btn" title="Umbenennen">
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
</button>
<button class="history-dropdown-btn danger delete-btn" title="L\xF6schen">
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
</button>
</div>
</div>
</div>
`;
let pressTimer;
let isLongPress = false;
const showDropdown = () => {
document.querySelectorAll(".history-item.show-dropdown").forEach((el) => el.classList.remove("show-dropdown"));
item.classList.add("show-dropdown");
};
const startPress = (e) => {
if (e.target.closest(".history-dropdown")) return;
if (e.type === "contextmenu") {
e.preventDefault();
showDropdown();
return;
}
isLongPress = false;
pressTimer = setTimeout(() => {
isLongPress = true;
showDropdown();
if (navigator.vibrate) navigator.vibrate(50);
}, 1e3);
};
const cancelPress = () => clearTimeout(pressTimer);
item.addEventListener("mousedown", startPress);
item.addEventListener("touchstart", startPress, { passive: true });
item.addEventListener("mouseup", cancelPress);
item.addEventListener("mouseleave", cancelPress);
item.addEventListener("touchend", cancelPress);
item.addEventListener("touchmove", cancelPress);
item.addEventListener("contextmenu", startPress);
item.onclick = (e) => {
if (isLongPress) {
e.preventDefault();
return;
}
if (item.classList.contains("show-dropdown")) {
if (!e.target.closest(".history-dropdown")) {
item.classList.remove("show-dropdown");
e.stopPropagation();
}
return;
}
this.switchChat(conv.id);
};
const editBtn = item.querySelector(".edit-btn");
const deleteBtn = item.querySelector(".delete-btn");
if (editBtn) {
editBtn.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
this.showCustomPrompt("Neuer Name f\xFCr diesen Chat:", conv.title, (newTitle) => {
if (newTitle && newTitle.trim()) {
db.updateConversationTitle(conv.id, newTitle.trim());
this.loadHistory();
}
});
};
}
if (deleteBtn) {
deleteBtn.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
this.showCustomConfirm("Chat l\xF6schen", "M\xF6chtest du diesen Chat wirklich endg\xFCltig l\xF6schen?", () => {
db.deleteConversation(conv.id);
if (this.currentConversationId === conv.id) {
this.createNewChat();
} else {
this.loadHistory();
}
});
};
}
listContainer.appendChild(item);
});
});
}
}
loadMessages(targetMessageId = null, searchQuery = null) {
this.lastShownHeading = null;
if (!db || !db.isReady) {
console.warn("loadMessages aborted: DB not ready");
return;
}
const messages = db.getMessages(this.currentConversationId);
const messagesContainer = document.getElementById("chat-messages");
if (messages.length === 0) {
document.body.classList.add("chat-is-empty");
const hour = (/* @__PURE__ */ new Date()).getHours();
let greetings = [];
if (hour >= 5 && hour < 11) {
greetings = [
"Guten Morgen! Sch\xF6n, dich so fr\xFCh schon zu sehen.",
"Hallo! Bist du bereit f\xFCr einen produktiven Tag?",
"Moin! Wie kann ich dir heute behilflich sein?",
"Guten Morgen! Ich hoffe, du hattest einen guten Start in den Tag.",
"Hallo! Starten wir gemeinsam in den Tag \u2013 was steht an?"
];
} else if (hour >= 11 && hour < 18) {
greetings = [
"Hallo! Ich hoffe, dein Tag l\xE4uft bisher klasse.",
"Sch\xF6n, dass du da bist!",
"Hi! Ich bin bereit \u2013 was darf ich f\xFCr dich tun?",
"Sch\xF6n dich zu sehen! Wie kann ich dir heute weiterhelfen?"
];
} else if (hour >= 18 && hour < 22) {
greetings = [
"Guten Abend! Sch\xF6n, dass du den Weg hierher gefunden hast.",
"Hi! Der Feierabend naht (oder ist da) \u2013 wie kann ich dich unterst\xFCtzen?",
"Guten Abend! Wie war dein Tag bisher?",
"Hi! Ich bin bereit f\xFCr deine Fragen."
];
} else {
greetings = [
"Nachtaktiv? Sch\xF6n, dass du vorbeischaust!",
"Hi! Ich bin hellwach. Was kann ich f\xFCr dich tun?",
"Hallo! Die Welt schl\xE4ft, aber ich bin bereit f\xFCr dich."
];
}
const dynamicGreeting = greetings[Math.floor(Math.random() * greetings.length)];
messagesContainer.innerHTML = `
<div class="empty-state-container" style="position: relative; flex: 1; display: flex; flex-direction: column; justify-content: center; align-items: center; text-align: center; color: var(--text-secondary); pointer-events: none; padding-top: 0vh; padding-bottom: 14vh;">
<div id="particle-logo-placeholder" class="empty-state-emoji" style="position: relative; margin-bottom: 24px; width: 150px; height: 150px; max-width: 25vh; max-height: 25vh; z-index: 1;">
</div>
<h2 style="position: relative; margin-bottom: 8px; font-size: 20px; font-weight: 500; color: var(--text-primary); max-width: 80%; line-height: 1.4; z-index: 10; text-shadow: 0 2px 10px rgba(0,0,0,0.5);">${dynamicGreeting}</h2>
</div>
`;
if (this.particleInitTimeout) clearTimeout(this.particleInitTimeout);
this.particleInitTimeout = setTimeout(() => {
if (!this.isDestroyed) this.initParticleLogo();
}, 350);
return;
}
document.body.classList.remove("chat-is-empty");
this.cleanupParticleLogo(true);
messagesContainer.innerHTML = "";
messages.forEach((msg) => {
this.appendMessageElement(msg.role, msg.content, false, false, null, msg.id);
});
this.buildTimeline();
if (targetMessageId) {
setTimeout(() => {
const targetEl = document.querySelector(`.chat-message-wrapper[data-message-id="${targetMessageId}"]`);
if (targetEl) {
const scrollContainer = document.getElementById("router-view") || document.querySelector(".view-container");
if (scrollContainer) {
const containerRect = scrollContainer.getBoundingClientRect();
const elRect = targetEl.getBoundingClientRect();
const scrollOffset = elRect.top - containerRect.top + scrollContainer.scrollTop - containerRect.height / 2 + elRect.height / 2;
scrollContainer.scrollTo({ top: scrollOffset, behavior: "smooth" });
} else {
targetEl.scrollIntoView({ behavior: "smooth", block: "center" });
}
const bubble = targetEl.querySelector(".chat-bubble-user, .chat-bubble-ai, .chat-bubble-system");
if (bubble) {
bubble.style.transition = "box-shadow 0.3s ease, filter 0.3s ease";
const originalShadow = bubble.style.boxShadow;
const originalFilter = bubble.style.filter;
bubble.style.boxShadow = "0 0 0 2px rgba(255, 255, 255, 0.8), 0 0 20px rgba(255, 255, 255, 0.4)";
bubble.style.filter = "brightness(1.2)";
setTimeout(() => {
bubble.style.boxShadow = originalShadow;
bubble.style.filter = originalFilter;
}, 3e3);
if (searchQuery) {
const textNodes = [];
const walker = document.createTreeWalker(bubble, NodeFilter.SHOW_TEXT, null, false);
let node;
while (node = walker.nextNode()) textNodes.push(node);
textNodes.forEach((textNode) => {
if (textNode.nodeValue.toLowerCase().includes(searchQuery.toLowerCase())) {
const fragment = document.createDocumentFragment();
let lastIndex = 0;
const replaceRegex = new RegExp(`(${searchQuery.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")})`, "gi");
textNode.nodeValue.replace(replaceRegex, (match, p1, offset) => {
if (offset > lastIndex) {
fragment.appendChild(document.createTextNode(textNode.nodeValue.substring(lastIndex, offset)));
}
const mark = document.createElement("mark");
mark.style.backgroundColor = "rgba(193, 131, 240, 0.6)";
mark.style.color = "#fff";
mark.style.borderRadius = "3px";
mark.style.padding = "2px 4px";
mark.style.transition = "background-color 1s ease";
mark.textContent = match;
setTimeout(() => {
mark.style.backgroundColor = "transparent";
}, 3e3);
fragment.appendChild(mark);
lastIndex = offset + match.length;
});
if (lastIndex < textNode.nodeValue.length) {
fragment.appendChild(document.createTextNode(textNode.nodeValue.substring(lastIndex)));
}
textNode.parentNode.replaceChild(fragment, textNode);
}
});
}
}
} else {
this.scrollToBottom();
}
}, 100);
} else {
this.scrollToBottom();
}
}
transitionToLoading(placeholder) {
this.particleState = "loading";
const canvas = document.getElementById("particle-logo-canvas");
if (!canvas) return;
const dpr = window.devicePixelRatio || 1;
let targetX = 60 * dpr;
let targetY = canvas.height - 120 * dpr;
if (placeholder) {
const pRect = placeholder.getBoundingClientRect();
const canvasRect = canvas.getBoundingClientRect();
targetX = (pRect.left - canvasRect.left + 20) * dpr;
targetY = (pRect.top - canvasRect.top + 12) * dpr;
}
const dotSpacing = 16 * dpr;
const dotRadius = 3.5 * dpr;
for (let i = 0; i < this.logoParticles.length; i++) {
const p = this.logoParticles[i];
const dotIndex = i % 3;
const dotCenterX = targetX + dotIndex * dotSpacing;
const dotCenterY = targetY;
const angle = Math.random() * Math.PI * 2;
const radius = Math.random() * dotRadius;
p.baseX = dotCenterX + Math.cos(angle) * radius;
p.baseY = dotCenterY + Math.sin(angle) * radius;
p.isFace = false;
p.dotIndex = dotIndex;
p.color = `rgba(193, 131, 240, ${Math.random() * 0.4 + 0.4})`;
p.size = Math.random() * 1.5 + 1;
p.vx += (Math.random() - 0.5) * 80;
p.vy += (Math.random() - 0.5) * 80;
}
}
initParticleLogo() {
this.cleanupParticleLogo();
if (!document.getElementById("particle-logo-placeholder")) return;
let canvas = document.getElementById("particle-logo-canvas");
if (!canvas) {
canvas = document.createElement("canvas");
canvas.id = "particle-logo-canvas";
canvas.className = "gpu-accelerated";
canvas.style.cssText = `position: fixed; top: 0; left: 0; width: ${window.innerWidth}px; height: ${window.innerHeight}px; pointer-events: none; z-index: 0; filter: drop-shadow(0 8px 24px rgba(193, 131, 240, 0.4));`;
const appContainer = document.getElementById("app");
if (appContainer) {
document.body.insertBefore(canvas, appContainer);
} else {
document.body.appendChild(canvas);
}
}
const ctx = canvas.getContext("2d", { willReadFrequently: true });
const img = new Image();
img.src = "/schoolmind-logo-gemini.png";
img.onload = () => {
const currentCanvas = document.getElementById("particle-logo-canvas");
if (!currentCanvas) return;
const placeholder = document.getElementById("particle-logo-placeholder");
if (!placeholder) return;
const dpr = window.devicePixelRatio || 1;
const canvasRect = currentCanvas.getBoundingClientRect();
currentCanvas.width = canvasRect.width * dpr;
currentCanvas.height = canvasRect.height * dpr;
const rect = placeholder.getBoundingClientRect();
const logoWidth = rect.width * dpr;
const logoHeight = rect.height * dpr;
const xOffset = rect.left * dpr;
const yOffset = rect.top * dpr;
const offscreen = document.createElement("canvas");
offscreen.width = logoWidth;
offscreen.height = logoHeight;
const octx = offscreen.getContext("2d");
octx.drawImage(img, 0, 0, logoWidth, logoHeight);
const imgData = octx.getImageData(0, 0, offscreen.width, offscreen.height);
const data = imgData.data;
this.logoParticles = [];
const step = 3;
for (let y = 0; y < offscreen.height; y += step) {
for (let x = 0; x < offscreen.width; x += step) {
const index = (y * offscreen.width + x) * 4;
const alpha = data[index + 3];
if (alpha > 128) {
const r = data[index];
const g = data[index + 1];
const b = data[index + 2];
const isDark = (r + g + b) / 3 < 100;
const particleSize = isDark ? Math.random() * 1.5 + 2 : Math.random() * 1.5 + 0.8;
const particleColor = isDark ? `rgba(${r},${g},${b},1)` : `rgba(${r},${g},${b},${alpha / 255})`;
const numParticles = isDark ? 4 : 1;
for (let i = 0; i < numParticles; i++) {
const offsetX = isDark ? (Math.random() - 0.5) * 2 : 0;
const offsetY = isDark ? (Math.random() - 0.5) * 2 : 0;
const spawnAngle = Math.random() * Math.PI * 2;
const screenDiagonal = Math.sqrt(canvas.width * canvas.width + canvas.height * canvas.height);
const spawnRadius = screenDiagonal / 2 + 200 + Math.random() * 600;
const cx = canvas.width / 2;
const cy = canvas.height / 2;
this.logoParticles.push({
x: cx + Math.cos(spawnAngle) * spawnRadius,
y: cy + Math.sin(spawnAngle) * spawnRadius,
baseX: x + xOffset + offsetX,
baseY: y + yOffset + offsetY,
vx: (Math.random() - 0.5) * 20,
// Swirl velocity
vy: (Math.random() - 0.5) * 20,
color: particleColor,
size: particleSize,
isFace: isDark
});
}
}
}
}
this.particleState = "idle";
this.animationStartTime = Date.now();
this.animateParticleLogo();
};
}
animateParticleLogo() {
const canvas = document.getElementById("particle-logo-canvas");
if (!canvas) {
this.cleanupParticleLogo();
return;
}
const ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, canvas.width, canvas.height);
const time = Date.now() * 1e-3;
const globalFloatY = Math.sin(time) * 5;
const isIdle = this.particleState !== "loading";
const cycleTime = time % 2;
const isDancing = !isIdle && cycleTime > 1.6;
if (isIdle && Math.random() < 0.2) {
const randomIdx = Math.floor(Math.random() * this.logoParticles.length);
const rp = this.logoParticles[randomIdx];
if (!rp.isFace) {
rp.vx += (Math.random() - 0.5) * 40;
rp.vy += (Math.random() - 0.5) * 40;
}
}
for (let p of this.logoParticles) {
let currentTargetX = p.baseX;
let currentTargetY = p.baseY;
if (isIdle) {
currentTargetY += globalFloatY;
} else {
if (isDancing) {
currentTargetX += (Math.random() - 0.5) * 15;
currentTargetY += (Math.random() - 0.5) * 15;
} else {
const rotSpeed = 4;
currentTargetX += Math.sin(time * rotSpeed + p.dotIndex) * 4;
currentTargetY += Math.cos(time * rotSpeed + p.dotIndex) * 4;
}
}
const dx = currentTargetX - p.x;
const dy = currentTargetY - p.y;
let dynamicStiffness = 4e-3;
if (this.animationStartTime && isIdle) {
const elapsedSecs = (Date.now() - this.animationStartTime) / 1e3;
dynamicStiffness = Math.min(4e-3 + elapsedSecs * 2e-3, 0.025);
}
const stiffness = isIdle ? dynamicStiffness : 0.12;
p.vx += dx * stiffness;
p.vy += dy * stiffness;
p.vx *= 0.88;
p.vy *= 0.88;
p.x += p.vx;
p.y += p.vy;
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fill();
}
this.particleAnimationId = requestAnimationFrame(() => this.animateParticleLogo());
}
cleanupParticleLogo(removeCanvas = false) {
if (this.particleInitTimeout) {
clearTimeout(this.particleInitTimeout);
this.particleInitTimeout = null;
}
if (this.particleAnimationId) {
cancelAnimationFrame(this.particleAnimationId);
this.particleAnimationId = null;
}
this.logoParticles = [];
this.particleState = "off";
if (removeCanvas) {
const canvas = document.getElementById("particle-logo-canvas");
if (canvas) canvas.remove();
}
}
cleanupThemeParticles() {
if (this.themeAnimationId) {
cancelAnimationFrame(this.themeAnimationId);
this.themeAnimationId = null;
}
this.themeParticles = [];
const canvas = document.getElementById("theme-particle-canvas");
if (canvas) canvas.remove();
}
renderThemeParticles(svgString, parentElement) {
let canvas = document.createElement("canvas");
canvas.className = "gpu-accelerated theme-particle-canvas";
canvas.style.cssText = `width: 100%; height: 250px; opacity: 0; transition: opacity 2s ease-in-out; border-radius: 12px; margin-top: 12px; border: 1px solid rgba(255,255,255,0.05); background: rgba(0,0,0,0.2); display: block;`;
if (parentElement) {
parentElement.appendChild(canvas);
} else {
return;
}
setTimeout(() => {
canvas.style.opacity = "1";
}, 100);
const ctx = canvas.getContext("2d", { willReadFrequently: true });
const dpr = window.devicePixelRatio || 1;
const containerWidth = parentElement.clientWidth || window.innerWidth;
canvas.width = containerWidth * dpr;
canvas.height = 250 * dpr;
let safeSvg = svgString;
if (!safeSvg.includes("width=")) {
safeSvg = safeSvg.replace("<svg ", '<svg width="100%" height="100%" ');
}
const svgBlob = new Blob([safeSvg], { type: "image/svg+xml;charset=utf-8" });
const url = URL.createObjectURL(svgBlob);
const img = new Image();
let localParticles = [];
img.onload = () => {
URL.revokeObjectURL(url);
const logoWidth = Math.min(canvas.width * 0.8, 400 * dpr);
const logoHeight = logoWidth;
const xOffset = (canvas.width - logoWidth) / 2;
const yOffset = (canvas.height - logoHeight) / 2;
const offscreen = document.createElement("canvas");
offscreen.width = logoWidth;
offscreen.height = logoHeight;
const octx = offscreen.getContext("2d");
octx.drawImage(img, 0, 0, logoWidth, logoHeight);
const imgData = octx.getImageData(0, 0, offscreen.width, offscreen.height);
const data = imgData.data;
const step = 3;
for (let y = 0; y < offscreen.height; y += step) {
for (let x = 0; x < offscreen.width; x += step) {
const index = (y * offscreen.width + x) * 4;
const alpha = data[index + 3];
if (alpha > 30) {
const particleColor = `rgba(193, 131, 240, 0.85)`;
localParticles.push({
x: x + xOffset + (Math.random() - 0.5) * 5,
y: y + yOffset + (Math.random() - 0.5) * 5,
baseX: x + xOffset,
baseY: y + yOffset,
vx: 0,
vy: 0,
color: particleColor,
size: (Math.random() * 1.5 + 1.5) * dpr,
// Multiply by dpr so they are visible on Retina
dotIndex: Math.random() * Math.PI * 2
// For wobbling
});
}
}
}
console.log(`Generated ${localParticles.length} theme particles`);
this.animateLocalThemeParticles(canvas, localParticles);
};
img.onerror = (e) => {
console.error("Failed to load Theme SVG", e);
URL.revokeObjectURL(url);
};
img.src = url;
}
animateLocalThemeParticles(canvas, particles) {
if (!document.body.contains(canvas)) return;
const ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, canvas.width, canvas.height);
const time = Date.now() * 3e-4;
for (let p of particles) {
p.x = p.baseX + Math.sin(time + p.dotIndex) * 8;
p.y = p.baseY + Math.cos(time * 0.8 + p.dotIndex) * 8;
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fill();
}
requestAnimationFrame(() => this.animateLocalThemeParticles(canvas, particles));
}
animateThemeParticles() {
const canvas = document.getElementById("theme-particle-canvas");
if (!canvas) return;
const ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, canvas.width, canvas.height);
const time = Date.now() * 3e-4;
for (let p of this.themeParticles) {
p.x = p.baseX + Math.sin(time + p.dotIndex) * 8;
p.y = p.baseY + Math.cos(time * 0.8 + p.dotIndex) * 8;
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fill();
}
this.themeAnimationId = requestAnimationFrame(() => this.animateThemeParticles());
}
appendMessageElement(role, content, animate = false, isNewGeneration = false, promptText = null, messageId = null) {
const themeSvgRegex = /\[THEME_PARTICLE_SVG:\s*(<svg[\s\S]*?<\/svg>)\s*\]/gi;
let themeMatch = themeSvgRegex.exec(content);
let themeSvgContent = null;
if (themeMatch) {
themeSvgContent = themeMatch[1];
content = content.replace(themeMatch[0], "");
}
const toggle1 = document.getElementById("pdf-theme-toggle-btn");
const toggle2 = document.getElementById("pdf-color-mode-btn");
if (toggle1) toggle1.style.display = "flex";
if (toggle2) toggle2.style.display = "flex";
if (role === "ai" && content && content.includes("decodeURIComponent(") && content.includes("<button")) {
const tempDiv = document.createElement("div");
tempDiv.innerHTML = content;
const btn = tempDiv.querySelector("button");
if (btn && btn.getAttribute("onclick") && btn.getAttribute("onclick").includes("decodeURIComponent")) {
const onclickStr = btn.getAttribute("onclick");
let match = onclickStr.match(/decodeURIComponent\('([\s\S]+)'\)/);
if (match) {
let rawEncoded = match[1];
rawEncoded = rawEncoded.replace(/'/g, "%27");
btn.setAttribute("onclick", `window.openArticleModal(decodeURIComponent('${rawEncoded}'))`);
btn.innerHTML = btn.innerHTML.replace("Bericht \xF6ffnen", "Bericht lesen");
content = tempDiv.innerHTML;
}
}
}
const messagesContainer = document.getElementById("chat-messages");
if (messagesContainer.querySelector("h2")) {
messagesContainer.innerHTML = "";
}
const wrapper = document.createElement("div");
wrapper.className = `chat-message-wrapper role-${role}`;
if (messageId) wrapper.dataset.messageId = messageId;
wrapper.style.display = "flex";
wrapper.style.flexDirection = "column";
if (role === "user") {
wrapper.style.alignSelf = "flex-end";
const animClass = animate ? "message-drop-anim" : "";
let parsedContent = content.replace(/\n/g, "<br>");
const imageRegex = /!\[.*?\]\((data:image\/[^)]+)\)/g;
const contentWithoutImagesAndBrs = parsedContent.replace(imageRegex, "").replace(/<br>/g, "").trim();
const isOnlyImages = contentWithoutImagesAndBrs === "";
if (isOnlyImages) {
parsedContent = parsedContent.replace(/<br>/g, "");
}
parsedContent = parsedContent.replace(imageRegex, (match, dataUrl) => {
const marginBottom = isOnlyImages ? "-12px" : "-4px";
const borderRadius = isOnlyImages ? "19px 19px 3px 19px" : "19px 19px 0 0";
const borderBottom = isOnlyImages ? "none" : "none";
const maskStyle = isOnlyImages ? "" : "-webkit-mask-image: linear-gradient(to bottom, black 80%, transparent 100%); mask-image: linear-gradient(to bottom, black 80%, transparent 100%);";
return `<img src="${dataUrl}" style="width: calc(100% + 32px); max-width: calc(100% + 32px); min-width: 250px; margin: -12px -16px ${marginBottom} -16px; border-radius: ${borderRadius}; display: block; border-bottom: ${borderBottom}; ${maskStyle}" />`;
});
parsedContent = parsedContent.replace(/(<img[^>]+>)(?:<br>\s*)+/g, "$1");
const pdfRegex = /!\[(.*?)\]\((data:application\/pdf[^)]+)\)/g;
parsedContent = parsedContent.replace(pdfRegex, (match, name, dataUrl) => {
return `
<div style="display: flex; align-items: center; gap: 12px; padding: 8px 12px; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); border-radius: 12px; margin-bottom: 8px; max-width: 250px;">
<div style="background: #eb4d4b; padding: 6px; border-radius: 8px; display: flex;">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>
</div>
<div style="font-size: 13px; color: rgba(255,255,255,0.9); font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
${name || "PDF Dokument"}
</div>
</div>
`;
});
wrapper.innerHTML = `
<div style="display: flex; flex-direction: column; align-items: flex-end;">
<div class="chat-bubble-user selectable-text ${animClass}">
${parsedContent}
</div>
<div class="user-msg-actions" style="margin-top: 4px; display: flex; justify-content: flex-end; gap: 8px; opacity: 0.6;">
<button class="icon-btn" title="Kopieren" data-raw="${encodeURIComponent(content)}" style="width: 32px; height: 32px;" onclick="window.copyToClipboard(decodeURIComponent(this.getAttribute('data-raw')), this)">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
</button>
</div>
</div>
`;
messagesContainer.appendChild(wrapper);
if (animate) {
requestAnimationFrame(() => {
const bubble = wrapper.querySelector(".chat-bubble-user");
const inputEl = document.getElementById("chat-input");
if (bubble && inputEl) {
const bubbleRect = bubble.getBoundingClientRect();
const inputRect = inputEl.getBoundingClientRect();
const deltaX = inputRect.left - bubbleRect.left + 20;
const deltaY = inputRect.top + inputRect.height / 2 - (bubbleRect.top + bubbleRect.height / 2);
bubble.style.setProperty("--start-x", `${deltaX}px`);
bubble.style.setProperty("--start-y", `${deltaY}px`);
}
});
}
this.scrollToBottom();
return;
} else {
wrapper.style.alignSelf = "flex-start";
let cleanContent = content.replace(/!\[.*?\]\((data:image\/[^)]+)\)/g, "[Bild]");
cleanContent = cleanContent.replace(/!\[.*?\]\((data:application\/pdf[^)]+)\)/g, "[PDF]");
const headingMatch = cleanContent.match(/^\s*#\s+(.+?)(?:\r?\n|$)/);
if (headingMatch) {
const currentHeading = headingMatch[1].trim();
if (this.lastShownHeading === currentHeading) {
cleanContent = cleanContent.replace(/^\s*#\s+(.+?)(?:\r?\n|$)/, "").trimStart();
} else {
this.lastShownHeading = currentHeading;
}
}
cleanContent = cleanContent.replace(/\\?\[YT-SEARCH:\s*(.+?)\\?\]/g, (match, query) => {
query = query.replace(/\\$/, "").trim();
return formatYoutubeSearch(query);
});
cleanContent = cleanContent.replace(/\[WORKSHEET_JSON_START\]([\s\S]+?)\[WORKSHEET_JSON_END\]/g, (match, jsonStr) => {
return formatWorksheetInpainting(jsonStr);
});
cleanContent = cleanContent.replace(/\\?\[BILD:\s*(.+?)\\?\]/g, (match, prompt) => {
prompt = prompt.replace(/\\$/, "").trim();
return formatImageGeneration(prompt);
});
const thinkTag = "think";
const thinkStart = "\\x3c" + thinkTag + "\\x3e";
const thinkEnd = "\\x3c/" + thinkTag + "\\x3e";
cleanContent = cleanContent.replace(new RegExp(thinkStart + "([\\s\\S]*?)" + thinkEnd, "gi"), (match, inner) => {
const wordCount = inner.trim().split(/\s+/).length;
const estimatedSeconds = Math.max(1, Math.round(wordCount / 15));
const innerHtml = marked.parse(inner);
return `
<details class="ai-thought-process">
<summary>
<span style="display: flex; align-items: center; gap: 8px;">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 1 1 7.072 0l-.548.547A3.374 3.374 0 0 0 14 18.469V19a2 2 0 1 1-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"></path></svg>
Gedankengang (~${estimatedSeconds}s)
</span>
<svg class="chevron" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>
</summary>
<div class="thought-content">${innerHtml}</div>
</details>
`;
});
cleanContent = cleanContent.replace(new RegExp(thinkStart + "([\\s\\S]*)$", "i"), (match, inner) => {
const wordCount = inner.trim().split(/\s+/).length;
const estimatedSeconds = Math.max(1, Math.round(wordCount / 15));
const innerHtml = marked.parse(inner + " \u258C");
return `
<details class="ai-thought-process">
<summary>
<span style="display: flex; align-items: center; gap: 8px;">
<div class="typing-indicator" style="display:inline-flex; padding:0; background:transparent;"><span></span><span></span><span></span></div>
Denkt nach... (~${estimatedSeconds}s)
</span>
<svg class="chevron" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>
</summary>
<div class="thought-content">${innerHtml}</div>
</details>
`;
});
const mathBlocks = [];
let processedContent = cleanContent.replace(/\$\$([\s\S]+?)\$\$/g, (match) => {
mathBlocks.push(match);
return `MATHBLOCK${mathBlocks.length - 1}MATHBLOCK`;
});
processedContent = processedContent.replace(/\$(?!\s)([^$\n]+?)(?<!\s)\$/g, (match) => {
mathBlocks.push(match);
return `MATHBLOCK${mathBlocks.length - 1}MATHBLOCK`;
});
let parsedHtml = marked.parse(processedContent);
mathBlocks.forEach((block, index) => {
const id = `MATHBLOCK${index}MATHBLOCK`;
if (block.startsWith("$$")) {
const rawMath = block.substring(2, block.length - 2).trim();
const encodedMath = encodeURIComponent(rawMath);
const wrappedBlock = `
<div class="math-display-wrapper" style="position: relative;" data-raw-math="${encodedMath}">
<button class="math-copy-btn" onclick="navigator.clipboard.writeText(decodeURIComponent(this.parentElement.getAttribute('data-raw-math'))).then(() => { this.classList.add('copied'); setTimeout(() => this.classList.remove('copied'), 2000); })" title="LaTeX Formel kopieren">
<svg class="icon-copy" viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
<svg class="icon-check" viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round" style="display: none;"><polyline points="20 6 9 17 4 12"></polyline></svg>
</button>
${block}
</div>
`;
parsedHtml = parsedHtml.replace(id, () => wrappedBlock);
} else {
parsedHtml = parsedHtml.replace(id, () => block);
}
});
wrapper.innerHTML = `
<div style="position: relative; width: 100%;">
<div class="chat-bubble-ai selectable-text markdown-body">
${parsedHtml}
</div>
<div class="ai-msg-actions" style="margin-top: 4px; display: flex; justify-content: flex-end; gap: 8px;">
${promptText ? `<button class="icon-btn" title="Wiederholen" data-prompt="${encodeURIComponent(promptText)}" style="width: 32px; height: 32px;" onclick="document.getElementById('chat-input').value = decodeURIComponent(this.getAttribute('data-prompt')); document.getElementById('send-btn').click();">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><path d="M3 3v5h5"></path><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"></path><path d="M16 21v-5h5"></path></svg>
</button>` : ""}
<button class="icon-btn" title="Vorlesen" data-raw="${encodeURIComponent(cleanContent)}" style="width: 32px; height: 32px;" onclick="window.playTTSMessage && window.playTTSMessage(this, decodeURIComponent(this.getAttribute('data-raw')))">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon><path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"></path></svg>
</button>
<button class="icon-btn" title="Kopieren" data-raw="${encodeURIComponent(cleanContent)}" style="width: 32px; height: 32px;" onclick="window.copyToClipboard(decodeURIComponent(this.getAttribute('data-raw')), this)">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
</button>
<div class="msg-more-actions" style="position: relative; display: flex; align-items: center;">
<button class="icon-btn msg-more-btn" title="Mehr Optionen" style="width: 32px; height: 32px;" onclick="
const menu = this.nextElementSibling;
const isVisible = menu.style.display === 'flex';
document.querySelectorAll('.msg-dropdown-menu').forEach(el => el.style.display = 'none');
if (!isVisible) {
menu.style.display = 'flex';
event.stopPropagation();
const closeMenu = (e) => {
if (!e.target.closest('.msg-more-btn')) {
menu.style.display = 'none';
document.removeEventListener('click', closeMenu);
}
};
document.addEventListener('click', closeMenu);
}
">
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg>
</button>
<div class="msg-dropdown-menu" style="display: none; position: absolute; bottom: 100%; right: 0; margin-bottom: 8px; background: rgba(30,30,30,0.95); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); border: 1px solid rgba(255,255,255,0.1); border-radius: 12px; padding: 6px; box-shadow: 0 8px 24px rgba(0,0,0,0.5); z-index: 100; flex-direction: column; gap: 4px;">
<button class="icon-btn vault-save-btn" title="In Vault speichern" data-raw="${encodeURIComponent(cleanContent)}" style="width: 32px; height: 32px; display: flex; align-items: center; justify-content: center;">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path><polyline points="17 21 17 13 7 13 7 21"></polyline><polyline points="7 3 7 8 15 8"></polyline></svg>
</button>
<button class="icon-btn pdf-export-btn" title="Als PDF exportieren" data-raw="${encodeURIComponent(cleanContent)}" style="width: 32px; height: 32px; display: flex; align-items: center; justify-content: center;">
<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2" fill="none"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>
</button>
<button class="icon-btn feedback-style-btn" title="Schreibstil trainieren" data-raw="${encodeURIComponent(cleanContent)}" style="width: 32px; height: 32px; display: flex; align-items: center; justify-content: center;">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>
</button>
</div>
</div>
</div>
</div>
`;
messagesContainer.appendChild(wrapper);
const pdfBtn = wrapper.querySelector(".pdf-export-btn");
if (pdfBtn) {
pdfBtn.onclick = () => {
let rawText = decodeURIComponent(pdfBtn.getAttribute("data-raw"));
let finalMarkdown = "";
const states = { functionPlots: [], plotly: [] };
const fpContainers = wrapper.querySelectorAll(".function-plot-container");
fpContainers.forEach((container) => {
if (container.__fpInstance && container.__fpInstance.meta) {
states.functionPlots.push({
xDomain: container.__fpInstance.meta.xScale.domain(),
yDomain: container.__fpInstance.meta.yScale.domain()
});
} else {
states.functionPlots.push(null);
}
});
const plContainers = wrapper.querySelectorAll(".plotly-container");
plContainers.forEach((container) => {
if (container.layout && container.layout.scene && container.layout.scene.camera) {
states.plotly.push({ camera: container.layout.scene.camera });
} else {
states.plotly.push(null);
}
});
const bubble = wrapper.querySelector(".chat-bubble-ai");
if (bubble) {
const images = bubble.querySelectorAll("img");
images.forEach((img) => {
if (img.src && !img.src.startsWith("data:")) {
finalMarkdown += `![Bild](${img.src})
`;
}
});
}
const graphBlocks = [];
let textOnly = rawText.replace(/```(plot|plot3d)[\s\S]*?```/g, (match) => {
graphBlocks.push(match);
return `%%GRAPH_${graphBlocks.length - 1}%%`;
});
textOnly = textOnly.replace(/!\[.*?\]\(.*?\)/g, "");
textOnly = textOnly.replace(/<[^>]*>?/gm, "");
textOnly = textOnly.replace(/SiriHero:.*?(\n|$)/g, "");
textOnly = textOnly.replace(/KnowledgeCard:.*?(\n|$)/g, "");
textOnly = textOnly.replace(/\s*\^\s*/g, " ");
finalMarkdown += textOnly;
this.openPdfModal(finalMarkdown, states, graphBlocks);
};
}
const vaultBtn = wrapper.querySelector(".vault-save-btn");
if (vaultBtn) {
vaultBtn.onclick = async () => {
try {
const { folderAgent } = await import("../../ai/folder-agent.js");
const { fsService } = await import("../../services/file-system.js");
const rawText = decodeURIComponent(vaultBtn.getAttribute("data-raw"));
vaultBtn.style.opacity = "0.5";
const suggestedFolders = await folderAgent.suggestFolders(rawText);
const filename = await folderAgent.generateFilename(rawText);
const modalHtml = `
<div id="vault-save-modal" style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0,0,0,0.6); backdrop-filter: blur(10px); z-index: 1000; display: flex; align-items: center; justify-content: center; padding: 20px;">
<div class="liquid-glass-heavy" style="width: 100%; max-width: 400px; padding: 24px; border-radius: 24px; border: 1px solid rgba(255,255,255,0.1);">
<h3 style="margin-bottom: 16px; font-size: 20px;">In Vault speichern</h3>
<label style="display: block; font-size: 14px; color: var(--text-secondary); margin-bottom: 8px;">Dateiname</label>
<input type="text" id="vault-filename" value="${filename}.md" style="width: 100%; padding: 12px; border-radius: 12px; background: rgba(0,0,0,0.4); border: 1px solid rgba(255,255,255,0.1); color: white; margin-bottom: 20px; outline: none;">
<label style="display: block; font-size: 14px; color: var(--text-secondary); margin-bottom: 8px;">Vorgeschlagene Ordner</label>
<div style="display: flex; flex-direction: column; gap: 8px; margin-bottom: 24px;" id="folder-options">
${suggestedFolders.map((f) => `
<button class="liquid-glass folder-option-btn" data-folder="${f}" style="padding: 12px; border-radius: 12px; border: 1px solid rgba(255,255,255,0.1); text-align: left; cursor: pointer; transition: background 0.2s; display: flex; align-items: center; gap: 8px;">
<span style="font-size: 18px;">\u{1F4C1}</span> ${f}
</button>
`).join("")}
<button class="liquid-glass folder-option-btn" data-folder="" style="padding: 12px; border-radius: 12px; border: 1px dashed rgba(255,255,255,0.3); text-align: left; cursor: pointer; transition: background 0.2s; display: flex; align-items: center; gap: 8px;">
<span style="font-size: 18px;">\u{1F3E0}</span> Kein Ordner (Hauptverzeichnis)
</button>
</div>
<button id="cancel-vault-btn" style="width: 100%; padding: 12px; border-radius: 12px; background: transparent; border: none; color: var(--text-secondary); cursor: pointer;">Abbrechen</button>
</div>
</div>
`;
document.body.insertAdjacentHTML("beforeend", modalHtml);
document.getElementById("cancel-vault-btn").onclick = () => {
document.getElementById("vault-save-modal").remove();
vaultBtn.style.opacity = "1";
};
document.querySelectorAll(".folder-option-btn").forEach((btn) => {
btn.onclick = async () => {
const folder = btn.getAttribute("data-folder");
const name = document.getElementById("vault-filename").value;
const path = folder ? `${folder}/${name}` : name;
btn.style.background = "rgba(255,255,255,0.1)";
if (folder) {
const { db: db2 } = await import("../../db/database.js");
if (!db2.getFolderMetadata(folder)) {
try {
const visuals = await folderAgent.generateVisualsForFolder(folder);
db2.saveFolderMetadata(folder, visuals.emoji, visuals.color);
} catch (err) {
console.error("Failed to generate icon for new folder", err);
}
}
}
await fsService.writeFile(path, rawText);
document.getElementById("vault-save-modal").remove();
vaultBtn.style.opacity = "1";
vaultBtn.style.color = "#30d158";
vaultBtn.innerHTML = '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>';
};
});
} catch (e) {
console.error("Vault save error:", e);
vaultBtn.style.opacity = "1";
alert("Fehler beim Speichern in den Vault.");
}
};
}
const feedbackBtn = wrapper.querySelector(".feedback-style-btn");
if (feedbackBtn) {
feedbackBtn.onclick = () => {
const rawText = decodeURIComponent(feedbackBtn.getAttribute("data-raw"));
this.showFeedbackModal(rawText, messageId);
};
}
this.initializeGraphs(wrapper);
this.processWikiImages(wrapper);
this.processGeminiImages(wrapper);
this.processWorksheetInpainting(wrapper);
const imgs = wrapper.querySelectorAll("img");
imgs.forEach((img) => {
img.addEventListener("load", () => {
if (isNewGeneration) this.scrollToNewMessage(wrapper);
else this.scrollToBottom();
});
});
if (window.MathJax) {
window.MathJax.typesetPromise([wrapper]).then(() => {
if (isNewGeneration) this.scrollToNewMessage(wrapper);
}).catch((err) => console.error("MathJax error:", err));
} else if (isNewGeneration) {
this.scrollToNewMessage(wrapper);
}
}
if (themeSvgContent) {
this.renderThemeParticles(themeSvgContent, wrapper);
}
return wrapper;
}
showFeedbackModal(originalText, messageId) {
const overlay = document.createElement("div");
overlay.className = "custom-modal-overlay";
overlay.style.zIndex = "99999";
overlay.innerHTML = `
<div class="custom-modal liquid-glass-heavy" style="width: 90vw; max-width: 800px; max-height: 90vh; display: flex; flex-direction: column; border-radius: 24px; padding: 24px; box-sizing: border-box;">
<h3 style="margin-bottom: 8px; font-size: 20px;">Schreibstil trainieren</h3>
<p style="color: var(--text-secondary); font-size: 14px; margin-bottom: 16px; line-height: 1.5;">Du kannst den Text der KI bearbeiten und Stellen markieren, um konkretes Feedback zu hinterlassen. Die KI wird anhand deiner Korrekturen lernen.</p>
<div style="display: flex; gap: 16px; flex: 1; overflow: hidden;">
<div style="flex: 2; display: flex; flex-direction: column; background: rgba(0,0,0,0.4); border-radius: 12px; border: 1px solid rgba(255,255,255,0.1); overflow: hidden; position: relative;">
<div id="feedback-editor" contenteditable="true" class="markdown-body" style="padding: 16px; font-size: 14px; line-height: 1.6; color: white; outline: none; flex: 1; overflow-y: auto; font-family: var(--font-body);">${marked.parse(originalText)}</div>
<button id="add-comment-btn" class="liquid-glass" style="position: absolute; bottom: 16px; left: 50%; transform: translateX(-50%); padding: 8px 16px; border-radius: 20px; font-size: 12px; display: none; cursor: pointer; border: 1px solid rgba(255,255,255,0.2); z-index: 10;">
Auswahl kommentieren
</button>
</div>
<div style="flex: 1; display: flex; flex-direction: column; gap: 12px; overflow-y: auto; padding-right: 4px;" id="comments-container">
<div style="font-size: 12px; color: var(--text-secondary); text-align: center; margin-top: 20px;" id="no-comments-placeholder">Noch keine Kommentare. Markiere Text, um einen hinzuzuf\xFCgen.</div>
</div>
</div>
<div style="margin-top: 16px;">
<label style="display: block; font-size: 12px; color: var(--text-secondary); margin-bottom: 8px;">Allgemeines Feedback (optional)</label>
<textarea id="feedback-general" placeholder="Was soll die KI generell an ihrem Stil \xE4ndern? (z.B. 'K\xFCrzere S\xE4tze')" style="width: 100%; height: 60px; padding: 12px; border-radius: 12px; background: rgba(0,0,0,0.4); border: 1px solid rgba(255,255,255,0.1); color: white; font-family: var(--font-ui); font-size: 14px; outline: none; resize: none;"></textarea>
</div>
<div class="modal-actions" style="margin-top: 24px; justify-content: space-between;">
<button class="modal-btn cancel" id="fb-cancel">Abbrechen</button>
<button class="modal-btn confirm" id="fb-save" style="background: var(--primary-accent); border: none; color: white;">Speichern & Analysieren</button>
</div>
</div>
`;
document.body.appendChild(overlay);
const editor = overlay.querySelector("#feedback-editor");
const addCommentBtn = overlay.querySelector("#add-comment-btn");
const commentsContainer = overlay.querySelector("#comments-container");
const placeholder = overlay.querySelector("#no-comments-placeholder");
const saveBtn = overlay.querySelector("#fb-save");
let comments = [];
let currentSelectionRange = null;
let currentSelectionText = "";
const selectionHandler = () => {
if (!overlay.parentNode) return;
const sel = window.getSelection();
if (sel.rangeCount > 0 && sel.toString().trim().length > 0 && editor.contains(sel.anchorNode)) {
addCommentBtn.style.display = "block";
currentSelectionRange = sel.getRangeAt(0).cloneRange();
currentSelectionText = sel.toString();
} else {
setTimeout(() => {
if (!addCommentBtn.matches(":hover")) {
addCommentBtn.style.display = "none";
}
}, 150);
}
};
document.addEventListener("selectionchange", selectionHandler);
const renderComments = () => {
commentsContainer.innerHTML = "";
if (comments.length === 0) {
commentsContainer.appendChild(placeholder);
placeholder.style.display = "block";
} else {
placeholder.style.display = "none";
comments.forEach((c, idx) => {
const div = document.createElement("div");
div.className = "liquid-glass";
div.style.padding = "12px";
div.style.borderRadius = "12px";
div.style.border = "1px solid rgba(255,255,255,0.1)";
div.innerHTML = `
<div style="font-size: 11px; color: var(--primary-accent); margin-bottom: 4px; font-family: monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">"${c.selection}"</div>
<textarea style="width: 100%; min-height: 40px; background: rgba(0,0,0,0.3); border: 1px solid rgba(255,255,255,0.05); border-radius: 6px; padding: 6px; color: white; font-size: 13px; outline: none; resize: vertical;" placeholder="Kommentar..."></textarea>
<div style="display: flex; justify-content: flex-end; margin-top: 4px;">
<button class="remove-comment-btn" style="background: transparent; border: none; color: #ff3b30; font-size: 10px; cursor: pointer;">Entfernen</button>
</div>
`;
const ta = div.querySelector("textarea");
ta.value = c.comment;
ta.addEventListener("input", (e) => {
c.comment = e.target.value;
});
div.querySelector(".remove-comment-btn").onclick = () => {
comments.splice(idx, 1);
renderComments();
};
commentsContainer.appendChild(div);
});
}
};
addCommentBtn.onclick = () => {
if (!currentSelectionText) return;
comments.push({ selection: currentSelectionText, comment: "" });
renderComments();
addCommentBtn.style.display = "none";
window.getSelection().removeAllRanges();
};
const close = () => {
document.removeEventListener("selectionchange", selectionHandler);
overlay.classList.add("fade-out");
setTimeout(() => overlay.remove(), 200);
};
overlay.querySelector("#fb-cancel").onclick = close;
saveBtn.onclick = async () => {
const editedText = editor.innerHTML;
const generalFeedback = overlay.querySelector("#feedback-general").value.trim();
saveBtn.innerText = "Analysiere...";
saveBtn.style.opacity = "0.7";
saveBtn.style.pointerEvents = "none";
try {
const { styleAgent } = await import("../../ai/style-agent.js");
const result = await styleAgent.processFeedback(originalText, editedText, generalFeedback, comments);
saveBtn.innerText = "Fertig!";
saveBtn.style.background = "var(--success)";
overlay.querySelector(".custom-modal").innerHTML = `
<h3 style="margin-bottom: 12px; font-size: 20px; color: var(--success);">Schreibstil aktualisiert!</h3>
<p style="color: var(--text-secondary); font-size: 14px; margin-bottom: 16px; line-height: 1.5;">Die KI hat dein Profil erfolgreich an deinen gew\xFCnschten Schreibstil angepasst. Das hat sich ge\xE4ndert:</p>
<div style="background: rgba(0,0,0,0.4); border-radius: 12px; padding: 16px; border: 1px solid rgba(255,255,255,0.1); color: white; font-size: 14px; white-space: pre-wrap; font-family: var(--font-body); line-height: 1.6;">${result.summary}</div>
<div class="modal-actions" style="margin-top: 24px; justify-content: flex-end;">
<button class="modal-btn confirm" id="fb-close-success" style="background: var(--primary-accent); color: #000; border: none;">Schlie\xDFen</button>
</div>
`;
overlay.querySelector("#fb-close-success").onclick = close;
} catch (err) {
console.error("Style Agent Error:", err);
alert("Fehler bei der Analyse: " + err.message);
saveBtn.innerText = "Speichern & Analysieren";
saveBtn.style.opacity = "1";
saveBtn.style.pointerEvents = "auto";
}
};
}
scrollToNewMessage(wrapper) {
const container = document.getElementById("router-view");
if (!container || !wrapper) return;
const containerRect = container.getBoundingClientRect();
const wrapperRect = wrapper.getBoundingClientRect();
const gap = window.innerHeight * 0.25;
const targetTop = container.scrollTop + (wrapperRect.top - containerRect.top) - gap;
container.scrollTo({
top: Math.max(0, targetTop),
behavior: "smooth"
});
}
initializeGraphs(wrapper, initialStates = null) {
const plotContainers = wrapper.querySelectorAll(".function-plot-container");
plotContainers.forEach((container, idx) => {
if (!container.hasAttribute("data-plot-initialized") && window.functionPlot) {
container.setAttribute("data-plot-initialized", "true");
const plotDataRaw = decodeURIComponent(container.getAttribute("data-plot"));
const lines = plotDataRaw.split("\n").filter((l) => l.trim().length > 0);
const colors = ["#0a84ff", "#ff375f", "#30d158", "#ffd60a"];
const dataPoints = [];
lines.forEach((line) => {
const parts = line.split(/(?:\s+und\s+|\s+and\s+|,|&)/i);
parts.forEach((part) => {
let fnBody = part.trim();
if (!fnBody) return;
if (fnBody.includes("=")) {
fnBody = fnBody.split("=").pop().trim();
}
fnBody = fnBody.replace(/`/g, "");
if (fnBody.length > 0) {
dataPoints.push({ fn: fnBody });
}
});
});
dataPoints.forEach((pt, idx2) => pt.color = colors[idx2 % colors.length]);
if (dataPoints.length === 0) {
dataPoints.push({ fn: "0", color: "#ff375f" });
}
const isPdf = !!wrapper.closest(".printable-document");
const renderPlot = (forcedWidth) => {
try {
container.innerHTML = "";
const targetDiv = document.createElement("div");
const uniqueId = "fp-target-" + Math.random().toString(36).substr(2, 9);
targetDiv.id = uniqueId;
targetDiv.style.width = "100%";
targetDiv.style.height = "100%";
container.appendChild(targetDiv);
const plotWidth = isPdf ? 642 : forcedWidth || container.clientWidth || Math.min(window.innerWidth - 40, 600);
const plotConfig = {
target: "#" + uniqueId,
width: plotWidth,
height: 350,
grid: true,
data: JSON.parse(JSON.stringify(dataPoints)),
tip: { xLine: true, yLine: true }
};
if (initialStates && initialStates.functionPlots && initialStates.functionPlots[idx]) {
let xDom = initialStates.functionPlots[idx].xDomain;
let yDom = initialStates.functionPlots[idx].yDomain;
if (yDom && Array.isArray(yDom) && !isNaN(yDom[0]) && !isNaN(yDom[1]) && Math.abs(yDom[1] - yDom[0]) > 0.1) {
plotConfig.yAxis = { domain: yDom };
if (xDom && Array.isArray(xDom) && !isNaN(xDom[0]) && !isNaN(xDom[1])) {
const yRange = yDom[1] - yDom[0];
const expectedXRange = yRange * (plotWidth / 350);
const xCenter = (xDom[1] + xDom[0]) / 2;
plotConfig.xAxis = { domain: [xCenter - expectedXRange / 2, xCenter + expectedXRange / 2] };
}
}
} else {
const yRange = 20;
const expectedXRange = yRange * (plotWidth / 350);
plotConfig.xAxis = { domain: [-expectedXRange / 2, expectedXRange / 2] };
plotConfig.yAxis = { domain: [-10, 10] };
}
const chart = window.functionPlot(plotConfig);
const svgNode = container.querySelector("svg");
if (svgNode) {
svgNode.setAttribute("viewBox", `0 0 ${plotWidth} 350`);
svgNode.setAttribute("preserveAspectRatio", "xMidYMid meet");
}
container.__fpInstance = chart;
const clipRects = container.querySelectorAll("clipPath rect");
clipRects.forEach((rect) => {
const currentY = parseFloat(rect.getAttribute("y") || "0");
const currentHeight = parseFloat(rect.getAttribute("height") || "350");
rect.setAttribute("y", (currentY + 2).toString());
rect.setAttribute("height", (currentHeight - 4).toString());
});
const highlightIntegers = () => {
const ticks = targetDiv.querySelectorAll(".tick");
ticks.forEach((tick) => {
const val = tick.__data__;
if (val !== void 0 && Math.abs(val % 1) < 1e-5 && Math.abs(val) > 1e-5) {
tick.classList.add("integer-tick");
} else {
tick.classList.remove("integer-tick");
}
});
};
if (chart && chart.on) {
chart.on("after:draw", highlightIntegers);
}
setTimeout(highlightIntegers, 50);
const resetBtn = document.createElement("button");
resetBtn.className = "plot-reset-btn";
resetBtn.title = "Ursprung zentrieren / Zoom zur\xFCcksetzen";
resetBtn.innerHTML = '<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2" fill="none"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>';
resetBtn.onclick = (e) => {
e.stopPropagation();
initialStates = null;
renderPlot(container.clientWidth);
};
container.appendChild(resetBtn);
} catch (e) {
console.error("Function Plot Error", e);
container.innerHTML = `<div style="padding: 20px; color: #ff375f; font-family: monospace;">Syntax-Fehler im Graphen: ${e.message}</div>`;
}
};
if (isPdf) {
renderPlot(642);
} else {
renderPlot(container.clientWidth || Math.min(window.innerWidth - 40, 600));
const ro = new ResizeObserver((entries) => {
for (let entry of entries) {
const newWidth = entry.contentRect.width;
if (newWidth > 0 && Math.abs(newWidth - (container.__lastWidth || 0)) > 5) {
container.__lastWidth = newWidth;
renderPlot(newWidth);
}
}
});
ro.observe(container);
}
}
});
const plotlyContainers = wrapper.querySelectorAll(".plotly-container");
plotlyContainers.forEach((container, idx) => {
if (!container.hasAttribute("data-plot-initialized") && window.Plotly && window.math) {
container.setAttribute("data-plot-initialized", "true");
const plotDataRaw = decodeURIComponent(container.getAttribute("data-plot3d"));
const lines = plotDataRaw.split("\n").filter((l) => l.trim().length > 0);
if (lines.length === 0) return;
let fnBody = lines[0].trim();
if (fnBody.includes("=")) {
fnBody = fnBody.split("=").pop().trim();
}
fnBody = fnBody.replace(/`/g, "");
setTimeout(() => {
try {
const expr = window.math.compile(fnBody);
const xRange = window.math.range(-10, 10.1, 0.5).toArray();
const yRange = window.math.range(-10, 10.1, 0.5).toArray();
const zData = [];
for (let i = 0; i < yRange.length; i++) {
const zRow = [];
for (let j = 0; j < xRange.length; j++) {
try {
let val = expr.evaluate({ x: xRange[j], y: yRange[i] });
if (typeof val === "object") {
val = val.re;
}
zRow.push(val);
} catch (e) {
zRow.push(null);
}
}
zData.push(zRow);
}
const data = [{
z: zData,
x: xRange,
y: yRange,
type: "surface",
colorscale: "Viridis",
showscale: false
}];
let camera = { eye: { x: 1.5, y: 1.5, z: 1.2 } };
if (initialStates && initialStates.plotly && initialStates.plotly[idx]) {
camera = initialStates.plotly[idx].camera;
}
const printableDoc = wrapper.closest(".printable-document") || (wrapper.classList && wrapper.classList.contains("printable-document") ? wrapper : null);
const isLightModePdf = printableDoc && !printableDoc.classList.contains("dark-mode-doc");
const textColor = isLightModePdf ? "#000000" : "#ffffff";
const gridColor = isLightModePdf ? "rgba(0,0,0,0.2)" : "rgba(255,255,255,0.2)";
const zeroLineColor = isLightModePdf ? "#000000" : "#ffffff";
const layout = {
title: false,
autosize: true,
height: 400,
margin: { l: 0, r: 0, b: 0, t: 0 },
paper_bgcolor: "rgba(0,0,0,0)",
plot_bgcolor: "rgba(0,0,0,0)",
font: { color: textColor },
scene: {
xaxis: { title: "x", gridcolor: gridColor, zerolinecolor: zeroLineColor },
yaxis: { title: "y", gridcolor: gridColor, zerolinecolor: zeroLineColor },
zaxis: { title: "z", gridcolor: gridColor, zerolinecolor: zeroLineColor },
camera
}
};
const config = { responsive: true, displayModeBar: false };
window.Plotly.newPlot(container, data, layout, config);
const resetBtn = document.createElement("button");
resetBtn.className = "plot-reset-btn";
resetBtn.title = "Ursprung zentrieren / Kamerawinkel zur\xFCcksetzen";
resetBtn.innerHTML = '<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2" fill="none"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>';
resetBtn.onclick = (e) => {
e.stopPropagation();
window.Plotly.relayout(container, {
"scene.camera": { eye: { x: 1.5, y: 1.5, z: 1.2 } }
});
};
container.appendChild(resetBtn);
} catch (e) {
console.error("Plotly 3D Error", e);
container.innerHTML = `<div style="padding: 20px; color: #ff375f; font-family: monospace;">Syntax-Fehler im 3D-Graphen: ${e.message}</div>`;
}
}, 50);
}
});
}
async processGeminiImages(container) {
const asyncImages = container.querySelectorAll(".gemini-async-image:not(.processed)");
if (asyncImages.length === 0) return;
let lastUserBase64 = null;
const msgs = db.getMessages(this.currentConversationId);
for (let i = msgs.length - 1; i >= 0; i--) {
if (msgs[i].role === "user") {
const match = msgs[i].content.match(/data:image\/[^;]+;base64,[^)]+/);
if (match) {
lastUserBase64 = match[0].includes("base64,") ? match[0].split("base64,")[1] : match[0];
break;
}
}
}
const apiKey = db.getSetting("gemini_api_key");
for (const img of asyncImages) {
img.classList.add("processed");
const prompt = img.getAttribute("data-prompt");
let success = false;
if (apiKey) {
try {
const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-image:generateContent?key=${apiKey}`;
const parts = [{ text: prompt }];
if (lastUserBase64) {
parts.push({
inlineData: {
mimeType: "image/jpeg",
data: lastUserBase64
}
});
}
const body = {
contents: [{ role: "user", parts }]
};
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
if (response.ok) {
const data = await response.json();
let b64 = null;
if (data.candidates && data.candidates[0].content && data.candidates[0].content.parts) {
const resParts = data.candidates[0].content.parts;
for (const p of resParts) {
if (p.inlineData && p.inlineData.data) {
b64 = p.inlineData.data;
break;
}
}
if (!b64 && resParts[0].text && resParts[0].text.length > 1e3) {
b64 = resParts[0].text;
}
}
if (b64) {
img.src = `data:image/jpeg;base64,${b64.replace(/[^a-zA-Z0-9+/=]/g, "")}`;
success = true;
} else {
throw new Error("No image data found in generateContent response: " + JSON.stringify(data).substring(0, 200));
}
} else {
const errText = await response.text();
console.warn("Gemini API Error for Image Generation:", errText);
const errDiv = document.createElement("div");
errDiv.style = "position: absolute; top: 8px; left: 8px; right: 8px; color: #ff6b6b; font-size: 10px; padding: 6px; background: rgba(255,0,0,0.8); border-radius: 6px; font-family: monospace; z-index: 10; backdrop-filter: blur(4px); word-break: break-all;";
errDiv.innerText = `Gemini API Error ${response.status}: ${errText.substring(0, 100)}... (Fallback zu Pollinations)`;
img.parentElement.appendChild(errDiv);
setTimeout(() => errDiv.remove(), 6e3);
}
} catch (e) {
console.warn("Gemini Image generation failed, falling back to Pollinations:", e);
const errDiv = document.createElement("div");
errDiv.style = "position: absolute; top: 8px; left: 8px; right: 8px; color: #ff6b6b; font-size: 10px; padding: 6px; background: rgba(255,0,0,0.8); border-radius: 6px; font-family: monospace; z-index: 10; backdrop-filter: blur(4px); word-break: break-all;";
errDiv.innerText = `Gemini API Exception: ${e.message} (Fallback zu Pollinations)`;
img.parentElement.appendChild(errDiv);
setTimeout(() => errDiv.remove(), 6e3);
}
}
if (!success) {
const encodedPrompt = encodeURIComponent(prompt.trim());
img.src = `https://image.pollinations.ai/prompt/${encodedPrompt}?width=1024&height=1024&nologo=true`;
}
}
}
async processWorksheetInpainting(container) {
const asyncImages = container.querySelectorAll(".worksheet-inpainting-async:not(.processed)");
if (asyncImages.length === 0) return;
let lastUserBase64 = null;
const msgs = db.getMessages(this.currentConversationId);
for (let i = msgs.length - 1; i >= 0; i--) {
if (msgs[i].role === "user") {
const match = msgs[i].content.match(/data:image\/[^;]+;base64,[^)]+/);
if (match) {
lastUserBase64 = match[0].includes("base64,") ? match[0].split("base64,")[1] : match[0];
break;
}
}
}
if (!lastUserBase64) {
for (const img of asyncImages) {
img.classList.add("processed");
const errDiv = document.createElement("div");
errDiv.style = "position: absolute; top: 8px; left: 8px; right: 8px; color: #ff6b6b; font-size: 10px; padding: 6px; background: rgba(255,0,0,0.8); border-radius: 6px; font-family: monospace; z-index: 10; backdrop-filter: blur(4px); word-break: break-all;";
errDiv.innerText = "Fehler: Kein Originalbild im Chatverlauf gefunden.";
img.parentElement.appendChild(errDiv);
if (img.previousElementSibling) img.previousElementSibling.style.display = "none";
if (img.previousElementSibling?.previousElementSibling) img.previousElementSibling.previousElementSibling.style.display = "none";
}
return;
}
const hfKey = db.getSetting("huggingface_api_key");
for (const img of asyncImages) {
img.classList.add("processed");
const jsonStr = img.getAttribute("data-json");
if (!jsonStr) continue;
try {
const jsonData = JSON.parse(jsonStr.replace(/&quot;/g, '"'));
const { fluxClient } = await import("../../ai/flux-client.js");
const sourceImage = new Image();
sourceImage.src = "data:image/jpeg;base64," + lastUserBase64;
await new Promise((resolve, reject) => {
sourceImage.onload = resolve;
sourceImage.onerror = () => reject(new Error("Quellbild konnte nicht geladen werden (evtl. defektes Base64)."));
});
const width = sourceImage.width;
const height = sourceImage.height;
const finalCanvas = document.createElement("canvas");
finalCanvas.width = width;
finalCanvas.height = height;
const fCtx = finalCanvas.getContext("2d");
fCtx.drawImage(sourceImage, 0, 0);
jsonData.forEach((item) => {
const x = item.x / 1e3 * width;
const y = item.y / 1e3 * height;
const w = item.width / 1e3 * width || 100;
const h = item.height / 1e3 * height || 30;
fCtx.fillStyle = "rgba(255, 255, 255, 0.6)";
fCtx.fillRect(x, y, w, h);
fCtx.fillStyle = "#0f172a";
fCtx.textAlign = "center";
fCtx.textBaseline = "middle";
let fontSize = Math.max(12, Math.min(22, h * 0.7));
fCtx.font = `italic ${fontSize}px "Comic Sans MS", "Chalkboard SE", "Segoe UI", sans-serif`;
fCtx.fillText(item.text, x + w / 2, y + h / 2);
});
const resultBase64 = finalCanvas.toDataURL("image/jpeg", 0.95);
img.src = resultBase64;
const loadingText = img.previousElementSibling;
if (loadingText) {
loadingText.innerHTML = "Arbeitsblatt erfolgreich ausgef\xFCllt!";
setTimeout(() => {
loadingText.style.display = "none";
}, 2e3);
}
} catch (e) {
console.error("Inpainting failed", e);
const errDiv = document.createElement("div");
errDiv.style = "position: absolute; top: 8px; left: 8px; right: 8px; color: #ff6b6b; font-size: 10px; padding: 6px; background: rgba(255,0,0,0.8); border-radius: 6px; font-family: monospace; z-index: 10; backdrop-filter: blur(4px); word-break: break-all;";
errDiv.innerText = `Inpainting Fehler: ${e.message}`;
img.parentElement.appendChild(errDiv);
if (img.previousElementSibling) img.previousElementSibling.style.display = "none";
if (img.previousElementSibling?.previousElementSibling) img.previousElementSibling.previousElementSibling.style.display = "none";
}
}
}
async processWikiImages(container) {
const asyncImages = container.querySelectorAll(".wiki-async-image");
for (const img of asyncImages) {
const originalQuery = img.getAttribute("data-wiki-query").replace(/<br>/g, " ");
try {
const searchRes = await fetch(`https://de.wikipedia.org/w/api.php?action=query&generator=search&gsrsearch=${encodeURIComponent(originalQuery)}&gsrlimit=1&prop=pageimages&format=json&pithumbsize=1000&origin=*`);
const searchData = await searchRes.json();
if (!searchData.query || !searchData.query.pages) throw new Error("Not found");
const pageId = Object.keys(searchData.query.pages)[0];
const page = searchData.query.pages[pageId];
const resolvedTitle = page.title;
let validImageUrls = [];
const badWords = ["icon", "logo", "flag", "flagge", "coat_of_arms", "wappen", "map", "karte", "locator", "chart", "diagram", "symbol", "pictogram", "stub", "question", "disambig", "eurolight", "seal", "emblem", "insignia", "plan", "schema", "sketch", "drawing", "blason"];
if (page.thumbnail) {
const src = page.thumbnail.source;
const isBad = badWords.some((w) => src.toLowerCase().includes(w));
if (!isBad) validImageUrls.push({ url: src, keyword: null });
}
const extraKeywordsStr = img.getAttribute("data-wiki-keywords") || "";
if (extraKeywordsStr) {
const words = [...new Set(extraKeywordsStr.split(/\s*,\s*/))].filter((w) => w.length >= 3);
const fetchPromises = words.map(async (keyword) => {
try {
const res = await fetch(`https://de.wikipedia.org/w/api.php?action=query&generator=search&gsrsearch=${encodeURIComponent(keyword)}&gsrlimit=1&prop=pageimages&format=json&pithumbsize=1000&origin=*`);
const data = await res.json();
if (data.query && data.query.pages) {
const pageId2 = Object.keys(data.query.pages)[0];
const p = data.query.pages[pageId2];
if (p.thumbnail && p.thumbnail.source) {
const src = p.thumbnail.source;
const isBad = badWords.some((w) => src.toLowerCase().includes(w));
if (!isBad) return { url: src, keyword };
}
}
} catch (e) {
console.error("Keyword fetch failed", keyword, e);
}
return null;
});
const results = await Promise.all(fetchPromises);
const validKeywordImages = results.filter((r) => r !== null);
const seenUrls = new Set(validImageUrls.map((img2) => img2.url));
for (const imgObj of validKeywordImages) {
if (!seenUrls.has(imgObj.url)) {
validImageUrls.push(imgObj);
seenUrls.add(imgObj.url);
}
}
}
if (validImageUrls.length < 5) {
try {
const parseRes = await fetch(`https://de.wikipedia.org/w/api.php?action=parse&page=${encodeURIComponent(resolvedTitle)}&prop=images&format=json&origin=*`);
const parseData = await parseRes.json();
if (parseData.parse && parseData.parse.images) {
let validImageNames = parseData.parse.images.filter((imgName) => {
const s = imgName.toLowerCase();
const isBad = [...badWords, "sturmflut", "graph", "zeichnung", "skizze", "portrait"].some((w) => s.includes(w));
if (isBad) return false;
if (!(s.endsWith(".jpg") || s.endsWith(".jpeg") || s.endsWith(".png") || s.endsWith(".webp"))) return false;
return true;
});
const goodKeywords = ["skyline", "panorama", "view", "luftbild", "stadtansicht", "ansicht", "mitte", "zentrum", "cityscape"];
validImageNames.sort((a, b) => {
const aGood = goodKeywords.some((w) => a.toLowerCase().includes(w));
const bGood = goodKeywords.some((w) => b.toLowerCase().includes(w));
if (aGood && !bGood) return -1;
if (!aGood && bGood) return 1;
return 0;
});
validImageNames = validImageNames.slice(0, 5);
if (validImageNames.length > 0) {
const titles = validImageNames.map((i) => "File:" + i).join("|");
const infoRes = await fetch(`https://de.wikipedia.org/w/api.php?action=query&titles=${encodeURIComponent(titles)}&prop=imageinfo&iiprop=url&iiurlwidth=1000&format=json&origin=*`);
const infoData = await infoRes.json();
if (infoData.query && infoData.query.pages) {
const fetchedUrls = Object.values(infoData.query.pages).filter((p) => p.imageinfo && p.imageinfo[0]).map((p) => ({ url: p.imageinfo[0].thumburl, keyword: null }));
const seenUrls = new Set(validImageUrls.map((img2) => img2.url));
for (const imgObj of fetchedUrls) {
if (!seenUrls.has(imgObj.url)) {
validImageUrls.push(imgObj);
seenUrls.add(imgObj.url);
}
}
}
}
}
} catch (e) {
console.error("Parse fetch failed", e);
}
}
if (validImageUrls.length > 0) {
const isBlack = await this.isBlackAndTransparent(validImageUrls[0].url);
let targetGallery = null;
const messageElement = img.closest(".chat-message") || img.closest(".chat-bubble-ai");
const textContainer = messageElement ? messageElement.querySelector(".chat-message-text, .chat-bubble-ai") || messageElement : null;
let highlightTarget = null;
if (validImageUrls.length === 1) {
img.src = validImageUrls[0].url;
highlightTarget = img;
if (isBlack) {
img.classList.add("wiki-svg-image");
img.style.filter = "invert(1) hue-rotate(180deg)";
}
} else {
const gallery = document.createElement("div");
gallery.className = "siri-hero-gallery";
targetGallery = gallery;
validImageUrls.forEach((imgObj, i) => {
const galleryImg = document.createElement("img");
galleryImg.src = imgObj.url;
galleryImg.className = "siri-hero-image-top";
galleryImg.draggable = false;
if (i === 0 && isBlack) {
galleryImg.classList.add("wiki-svg-image");
galleryImg.style.filter = "invert(1) hue-rotate(180deg)";
}
gallery.appendChild(galleryImg);
if (i === 0) highlightTarget = galleryImg;
});
const wrapper = img.closest(".siri-hero-image-wrapper");
if (wrapper) {
wrapper.replaceChild(gallery, img);
}
const card = gallery.closest(".siri-hero-card");
if (card) {
card.classList.add("has-gallery");
}
this.makeGallerySwipeable(gallery);
}
if (textContainer) {
validImageUrls.forEach((imgObj, i) => {
if (imgObj.keyword) {
this.highlightKeywordInElement(textContainer, imgObj.keyword, i, targetGallery || highlightTarget);
}
});
}
} else {
img.style.display = "none";
const card = img.closest(".siri-hero-card");
if (card) {
const toggle = card.querySelector(".siri-hero-toggle-icon");
if (toggle) toggle.style.display = "none";
const header = card.querySelector(".siri-hero-header");
if (header) header.style.cursor = "default";
}
}
} catch (e) {
img.style.display = "none";
const card = img.closest(".siri-hero-card");
if (card) {
const toggle = card.querySelector(".siri-hero-toggle-icon");
if (toggle) toggle.style.display = "none";
const header = card.querySelector(".siri-hero-header");
if (header) header.style.cursor = "default";
}
}
}
}
makeGallerySwipeable(gallery) {
const card = gallery.closest(".siri-hero-card");
if (card) {
card.style.maxWidth = "100%";
}
let isDown = false;
let startX;
let scrollLeft;
gallery.addEventListener("mousedown", (e) => {
isDown = true;
startX = e.pageX - gallery.offsetLeft;
scrollLeft = gallery.scrollLeft;
gallery.style.scrollSnapType = "none";
});
const endDrag = () => {
if (!isDown) return;
isDown = false;
gallery.style.scrollSnapType = "x mandatory";
const clientWidth = gallery.clientWidth;
const currentPage = Math.round(gallery.scrollLeft / clientWidth);
gallery.scrollTo({ left: currentPage * clientWidth, behavior: "smooth" });
};
gallery.addEventListener("mouseleave", endDrag);
gallery.addEventListener("mouseup", endDrag);
gallery.addEventListener("mousemove", (e) => {
if (!isDown) return;
e.preventDefault();
const x = e.pageX - gallery.offsetLeft;
const walk = (x - startX) * 1.5;
gallery.scrollLeft = scrollLeft - walk;
});
}
highlightKeywordInElement(element, keyword, index, gallery) {
if (!keyword || keyword.length < 3) return;
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null, false);
const nodesToReplace = [];
let node;
while (node = walker.nextNode()) {
if (node.parentElement && node.parentElement.tagName === "A") continue;
if (node.parentElement && node.parentElement.classList.contains("wiki-image-link")) continue;
if (node.parentElement && node.parentElement.tagName === "CODE") continue;
const escapeRegExp = (string) => string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const escapedKeyword = escapeRegExp(keyword);
const regex = new RegExp(`(^|[^a-zA-Z\xE4\xF6\xFC\xC4\xD6\xDC\xDF])(${escapedKeyword})(s|es|en|e|er|n)?(?=[^a-zA-Z\xE4\xF6\xFC\xC4\xD6\xDC\xDF]|$)`, "i");
if (regex.test(node.nodeValue)) {
nodesToReplace.push({ node, regex });
}
}
nodesToReplace.forEach((item) => {
const textNode = item.node;
const regex = new RegExp(item.regex.source, "gi");
const span = document.createElement("span");
span.innerHTML = textNode.nodeValue.replace(regex, `$1<span class="wiki-image-link" data-index="${index}">$2$3</span>`);
const links = span.querySelectorAll(".wiki-image-link");
links.forEach((link) => {
link.addEventListener("click", () => {
const targetIndex = parseInt(link.getAttribute("data-index"), 10);
if (gallery && gallery.classList && gallery.classList.contains("siri-hero-gallery")) {
const targetImg = gallery.children[targetIndex];
if (targetImg) {
targetImg.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center" });
targetImg.style.transform = "scale(0.95)";
setTimeout(() => targetImg.style.transform = "scale(1)", 200);
}
} else if (gallery) {
gallery.style.transform = "scale(0.95)";
setTimeout(() => gallery.style.transform = "scale(1)", 200);
gallery.scrollIntoView({ behavior: "smooth", block: "center" });
}
});
});
textNode.replaceWith(...span.childNodes);
});
}
isBlackAndTransparent(url) {
return new Promise((resolve) => {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
const canvas = document.createElement("canvas");
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext("2d");
try {
ctx.drawImage(img, 0, 0);
const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
let darkPixels = 0;
let totalVisible = 0;
for (let i = 0; i < data.length; i += 64) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
const a = data[i + 3];
if (a > 50) {
totalVisible++;
const brightness = (r + g + b) / 3;
const variance = Math.max(r, g, b) - Math.min(r, g, b);
if (brightness < 80 && variance < 40) {
darkPixels++;
}
}
}
if (totalVisible === 0) {
resolve(false);
return;
}
const isBlack = darkPixels / totalVisible > 0.9;
resolve(isBlack);
} catch (e) {
console.warn("Canvas CORS check failed:", e);
resolve(false);
}
};
img.onerror = () => resolve(false);
img.src = url;
});
}
showTypingIndicator() {
const canvas = document.getElementById("particle-logo-canvas");
if (canvas && this.logoParticles && this.logoParticles.length > 0 && this.particleState !== "off") {
const messagesContainer2 = document.getElementById("chat-messages");
const placeholder = document.createElement("div");
placeholder.id = "typing-indicator";
placeholder.style.alignSelf = "flex-start";
placeholder.style.padding = "12px 0";
placeholder.style.height = "24px";
placeholder.style.visibility = "hidden";
messagesContainer2.appendChild(placeholder);
this.scrollToBottom();
this.transitionToLoading(placeholder);
return;
}
const messagesContainer = document.getElementById("chat-messages");
const wrapper = document.createElement("div");
wrapper.id = "typing-indicator";
wrapper.style.alignSelf = "flex-start";
wrapper.style.padding = "12px 0";
wrapper.style.color = "var(--text-secondary)";
wrapper.style.fontSize = "10px";
wrapper.innerHTML = `
<div style="display: flex; gap: 3px; align-items: center; height: 24px;">
<span class="dot-bounce" style="animation-delay: 0s;">\u25CF</span>
<span class="dot-bounce" style="animation-delay: 0.2s;">\u25CF</span>
<span class="dot-bounce" style="animation-delay: 0.4s;">\u25CF</span>
</div>
`;
messagesContainer.appendChild(wrapper);
this.scrollToBottom();
}
hideTypingIndicator() {
return new Promise((resolve) => {
let delayNeeded = 0;
if (this.particleState === "loading" && this.animationStartTime) {
const elapsed = Date.now() - this.animationStartTime;
const minRequiredTime = 1600;
if (elapsed < minRequiredTime) {
delayNeeded = minRequiredTime - elapsed;
}
}
setTimeout(() => {
if (this.particleState === "loading" || this.particleState === "loading_fadeout" || this.particleState === "idle") {
this.particleState = "fadeout";
const canvas = document.getElementById("particle-logo-canvas");
if (canvas) {
canvas.style.transition = "opacity 0.4s ease-out";
canvas.style.opacity = "0";
setTimeout(() => {
this.cleanupParticleLogo(true);
}, 400);
}
} else {
this.cleanupParticleLogo(true);
}
const indicator = document.getElementById("typing-indicator");
if (indicator) indicator.remove();
resolve();
}, delayNeeded);
});
}
showToolStatus(msg) {
this.hideTypingIndicator();
let indicator = document.getElementById("tool-status-indicator");
if (!indicator) {
const messagesContainer = document.getElementById("chat-messages");
indicator = document.createElement("div");
indicator.id = "tool-status-indicator";
indicator.className = "tool-status-indicator";
messagesContainer.appendChild(indicator);
}
indicator.innerHTML = `
<div class="gemini-sparkle-container">
<svg class="gemini-sparkle" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 2v20M17 5l-10 14M22 12H2M19 17L5 7"></path></svg>
</div>
<span class="tool-status-text">${msg}</span>
`;
this.scrollToBottom();
}
hideToolStatus() {
const indicator = document.getElementById("tool-status-indicator");
if (indicator) indicator.remove();
}
scrollToBottom() {
const viewContainer = document.getElementById("router-view");
if (viewContainer) {
viewContainer.scrollTop = viewContainer.scrollHeight;
}
}
async handleSend(text) {
if (!text.trim() || this.isGenerating) return;
this.appRetryCount = 0;
this.isGenerating = true;
window.setGeneratingState?.(true);
this.currentAbortController = new AbortController();
const sendBtn = document.getElementById("send-btn");
if (sendBtn) {
sendBtn.innerHTML = '<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"></rect></svg>';
}
const model = db.getSetting("model") || "gemini-3-flash";
const history = db.getMessages(this.currentConversationId);
const convos = db.getConversations();
const exists = convos.find((c) => c.id === this.currentConversationId);
if (!exists) {
db.createConversation(this.currentConversationId, "Neuer Chat", "\u{1F4AC}");
this.loadHistory();
}
const userMsgId = "msg_" + Date.now();
db.addMessage(userMsgId, this.currentConversationId, "user", text, null, null);
document.body.classList.remove("chat-is-empty");
const emptyState = document.querySelector(".empty-state-container");
if (emptyState) emptyState.remove();
this.appendMessageElement("user", text, true);
this.buildTimeline();
syncManager.sync();
const msgs = db.getMessages(this.currentConversationId);
if (msgs.length === 1) {
let cleanTitleText = text.replace(/!\[.*?\]\((data:image\/[^)]+)\)/g, "");
cleanTitleText = cleanTitleText.replace(/!\[.*?\]\((data:application\/pdf[^)]+)\)/g, "").trim();
const finalTitleText = cleanTitleText || "Anh\xE4nge hochgeladen";
const title = finalTitleText.split(" ").slice(0, 4).join(" ") + (finalTitleText.split(" ").length > 4 ? "..." : "");
db.exec("UPDATE conversations SET title = ? WHERE id = ?", [title, this.currentConversationId]);
this.loadHistory();
}
this.showTypingIndicator();
let aiPrompt = text;
if (this.pendingAnnotations && this.pendingAnnotations.length > 0) {
let annotationContext = "\n\n[Kontext-Anmerkungen des Nutzers zu deinen vorherigen Antworten]\n";
this.pendingAnnotations.forEach((ann, index) => {
annotationContext += `${index + 1}. Zitat/Kontext: "${ann.context || ann.quote}"
Kommentar: "${ann.comment}"
`;
});
annotationContext += "\nBitte beziehe diese Anmerkungen in deine n\xE4chste Antwort mit ein.\n";
aiPrompt += annotationContext;
this.pendingAnnotations = [];
this.renderAnnotationChips();
document.querySelectorAll(".ai-text-highlight").forEach((el) => {
const parent = el.parentNode;
while (el.firstChild) {
parent.insertBefore(el.firstChild, el);
}
parent.removeChild(el);
});
}
try {
const ytMatch = text.match(/(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?\/\s]{11})/i);
if (ytMatch && ytMatch[1]) {
const videoId = ytMatch[1];
const res = await fetch(getApiUrl(`/api/youtube-transcript?v=${videoId}`));
if (res.ok) {
const transcriptData = await res.json();
const fullText = transcriptData.map((t) => t.text).join(" ");
aiPrompt += `
<video_transcript video_id="${videoId}">
${fullText}
</video_transcript>
`;
console.log("Appended YouTube transcript to prompt.");
}
}
} catch (e) {
console.warn("YT Transcript fetch error", e);
}
const isDeepResearch = document.getElementById("deep-research-toggle")?.checked || false;
let researchTime = 3;
if (isDeepResearch) {
const select = document.getElementById("deep-research-time-select");
if (select) researchTime = parseInt(select.value, 10);
}
const isExtendedThinking = document.getElementById("extended-thinking-toggle")?.checked || false;
const isDetectiveBoard = document.getElementById("detective-board-toggle")?.checked || false;
const overlay = document.getElementById("research-canvas-overlay");
if (isDeepResearch) {
if (isDetectiveBoard) {
const board = document.getElementById("detective-board-fullscreen");
if (board) {
board.classList.add("visible");
await initCanvas();
addNode("root", "Forschungsthema", "query");
document.getElementById("db-back-btn").onclick = () => {
board.classList.remove("visible");
};
const btnMsg = document.createElement("div");
btnMsg.className = "message-wrapper system-message";
btnMsg.id = "reopen-db-msg";
btnMsg.innerHTML = `
<div class="message-content" style="display: flex; justify-content: center; width: 100%; margin: 20px 0;">
<button class="liquid-glass" style="padding: 10px 20px; border-radius: 30px; font-size: 14px; font-weight: bold; color: #fff; cursor: pointer; border: 1px solid rgba(0,229,255,0.4); background: rgba(10,10,20,0.8); backdrop-filter: blur(10px); box-shadow: 0 4px 15px rgba(0,229,255,0.2); transition: all 0.3s;" onmouseover="this.style.transform='scale(1.05)'" onmouseout="this.style.transform='scale(1)'">
\u{1F575}\uFE0F Detective Board ansehen
</button>
</div>
`;
btnMsg.querySelector("button").onclick = () => board.classList.add("visible");
document.getElementById("chat-messages").appendChild(btnMsg);
this.scrollToBottom();
}
} else {
deepResearchLoader.show();
}
}
try {
const responseText = await aiRouter.sendMessage(aiPrompt, {
model,
history,
deepResearch: isDeepResearch,
researchTime,
extendedThinking: isExtendedThinking,
vaultSearch: document.getElementById("vault-search-toggle")?.checked || false,
abortSignal: this.currentAbortController ? this.currentAbortController.signal : null,
onStatus: (msg, phase) => {
if (isDeepResearch) {
if (phase) deepResearchLoader.setPhase(phase);
deepResearchLoader.setStatus(msg);
} else {
this.showToolStatus(msg);
}
},
onGraphEvent: (evt) => {
if (isDeepResearch && isDetectiveBoard) {
if (evt.type === "node") addNode(evt.id, evt.label, evt.group);
if (evt.type === "edge") addEdge(evt.from, evt.to);
}
}
});
await this.hideTypingIndicator();
this.hideToolStatus();
if (isDeepResearch) {
deepResearchLoader.hide();
const reopenBtn = document.getElementById("reopen-db-btn");
if (reopenBtn) reopenBtn.style.display = "none";
if (isDetectiveBoard) {
const board = document.getElementById("detective-board-fullscreen");
if (board) board.classList.remove("visible");
}
}
let processedResponseText = responseText;
const createDeckRegex = /\[FLASHCARD_CREATE_DECK:\s*["']?([^"']+)["']?\]/gi;
let deckMatch;
while ((deckMatch = createDeckRegex.exec(responseText)) !== null) {
const deckTitle = deckMatch[1].trim();
const existingDeck = db.getDecks ? db.getDecks().find((d) => d.title.toLowerCase() === deckTitle.toLowerCase()) : null;
if (!existingDeck) {
db.createDeck(deckTitle, "Automatisch erstellt von KI", "#32d74b");
console.log("KI hat Stapel erstellt:", deckTitle);
}
processedResponseText = processedResponseText.replace(deckMatch[0], `
> \u2705 **Erfolg:** Stapel "${deckTitle}" wurde erstellt! Du findest ihn unter 'Lernen'.
`);
}
const addCardRegex = /\[FLASHCARD_ADD_CARD:\s*(.+?)\]/gi;
let cardMatch;
let addedCardsSummary = {};
let cardPromises = [];
while ((cardMatch = addCardRegex.exec(responseText)) !== null) {
const fullMatch = cardMatch[0];
const innerContent = cardMatch[1];
const parts = innerContent.split("|").map((s) => s.trim().replace(/^["']|["']$/g, ""));
if (parts.length >= 6) {
const deckTitle = parts[0];
const front = parts[1];
const rawBack = parts[2];
const explanation = parts[3];
const example = parts[4];
const imageQuery = parts[5];
let targetDeck = null;
if (db.getDecks) {
targetDeck = db.getDecks().find((d) => d.title.toLowerCase() === deckTitle.toLowerCase());
}
if (!targetDeck) {
const all = db.exec("SELECT * FROM flashcard_decks");
targetDeck = all.find((d) => d.title.toLowerCase() === deckTitle.toLowerCase());
}
if (targetDeck) {
if (!addedCardsSummary[deckTitle]) addedCardsSummary[deckTitle] = 0;
addedCardsSummary[deckTitle]++;
const back = `${rawBack}
**Erkl\xE4rung:** ${explanation}
**Beispiel:** ${example}`;
const p = (async () => {
let imageUrl = null;
try {
const encodedQuery = encodeURIComponent(imageQuery);
imageUrl = `https://tse1.mm.bing.net/th?q=${encodedQuery}&w=800&h=1000&c=7&rs=1`;
} catch (e) {
}
db.addFlashcard(targetDeck.id, front, back, imageUrl);
})();
cardPromises.push(p);
}
}
processedResponseText = processedResponseText.replace(fullMatch, "");
}
for (const [deckTitle, count] of Object.entries(addedCardsSummary)) {
processedResponseText += `
> \u2705 **Erfolg:** ${count} Karte(n) wurden zum Stapel "${deckTitle}" hinzugef\xFCgt!`;
}
await Promise.allSettled(cardPromises);
if (text.toLowerCase().includes("debug prag")) {
processedResponseText += `
[THEME_PARTICLE_SVG: <svg viewBox="0 0 500 500" xmlns="http://www.w3.org/2000/svg"><rect x="230" y="50" width="40" height="400"/><rect x="210" y="150" width="80" height="30"/><rect x="210" y="250" width="80" height="30"/><circle cx="250" cy="120" r="40"/><circle cx="210" cy="220" r="30"/><circle cx="290" cy="280" r="25"/></svg>]`;
}
const aiMsgId = "msg_" + Date.now();
db.addMessage(aiMsgId, this.currentConversationId, "assistant", processedResponseText, model, "default");
const msgWrapper = this.appendMessageElement("assistant", processedResponseText, false, true, text);
syncManager.sync();
const currentMsgs = db.getMessages(this.currentConversationId);
if (currentMsgs.length === 2) {
const titlePrompt = `Lies die folgende kurze Interaktion und generiere einen extrem kurzen, pr\xE4gnanten Titel (maximal 2 bis 4 W\xF6rter) sowie ein exakt passendes Emoji. Antworte AUSSCHLIESSLICH in diesem Format: EMOJI|TITEL (z.B. \u{1F680}|Mondlandung). Keine Anf\xFChrungszeichen, keine Satzzeichen am Ende. WICHTIG: Erkl\xE4re nichts! Wiederhole nicht den Prompt! Gib NUR Emoji und Titel aus.
User: ${text}
KI: ${responseText.substring(0, 300)}`;
aiRouter.sendMessage(titlePrompt, { model, history: [] }).then((response) => {
const parts = response.split("|");
let emoji = "\u{1F4AC}";
let cleanTitle = "Neuer Chat";
if (parts.length >= 2) {
emoji = parts[0].trim();
cleanTitle = parts[1].replace(/["'*`]/g, "").trim();
} else {
cleanTitle = response.replace(/["'*`]/g, "").trim();
}
if (cleanTitle.length > 50 || cleanTitle.toLowerCase().startsWith("input:") || cleanTitle.includes("Interaction Content:")) {
cleanTitle = text.substring(0, 30) + "...";
}
db.exec("UPDATE conversations SET title = ?, emoji = ? WHERE id = ?", [cleanTitle, emoji, this.currentConversationId]);
this.loadHistory();
}).catch((err) => console.error("Dynamic title generation failed", err));
}
} catch (e) {
await this.hideTypingIndicator();
if (isDeepResearch) {
deepResearchLoader.hide();
const reopenBtn = document.getElementById("reopen-db-btn");
if (reopenBtn) reopenBtn.style.display = "none";
if (isDetectiveBoard) {
const board = document.getElementById("detective-board-fullscreen");
if (board) board.classList.remove("visible");
}
}
if (e.name === "AbortError") {
this.appendMessageElement("assistant", "**Anfrage wurde abgebrochen.**", false, true, text);
} else {
this.appendMessageElement("assistant", `**Fehler:** ${e.message}`, false, true, text);
}
} finally {
this.isGenerating = false;
window.setGeneratingState?.(false);
this.currentAbortController = null;
const sendBtn2 = document.getElementById("send-btn");
if (sendBtn2) {
sendBtn2.innerHTML = '<svg viewBox="0 0 24 24" width="26" height="26" stroke="currentColor" stroke-width="2" fill="none" style="margin-left: -2px; margin-top: 1px;"><line x1="22" y1="2" x2="11" y2="13"></line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon></svg>';
}
}
}
async handleAutoFix(errorMsg, lineno) {
if (this.appRetryCount >= 3) {
console.warn("Auto-fix max retries reached.");
return;
}
this.appRetryCount++;
console.log(`Auto-Fix Versuch ${this.appRetryCount}/3...`);
const msgs = db.getMessages(this.currentConversationId);
if (!msgs || msgs.length === 0) return;
const lastMsg = msgs[msgs.length - 1];
if (lastMsg.role !== "assistant" && lastMsg.role !== "ai") return;
const modelUsed = lastMsg.model_used;
const styleUsed = lastMsg.style_used;
db.exec("DELETE FROM messages WHERE id = ?", [lastMsg.id]);
this.render();
this.isGenerating = true;
window.setGeneratingState?.(true);
this.currentAbortController = new AbortController();
this.showTypingIndicator();
const ind = document.getElementById("typing-indicator-text");
if (ind) ind.innerText = `Auto-Fix (${this.appRetryCount}/3)...`;
const fixPrompt = `[SYSTEM: AUTO-FIX SCHLEIFE]
Beim Ausf\xFChren deines vorherigen Codes (\`\`\`app) ist im Sandbox-iFrame folgender JavaScript-Fehler aufgetreten:
Fehler: ${errorMsg}
Zeile: ${lineno}
Dieser Fehler bedeutet meistens, dass du versuchst auf ein HTML-Element zuzugreifen (z.B. per getElementById), das noch gar nicht existiert.
L\xD6SUNG:
1. Packe deinen JavaScript-Code IMMER in ein \`window.onload = function() { ... }\` oder platziere das \`<script>\` Tag GANZ ANS ENDE des Codes!
2. \xDCberpr\xFCfe, ob die IDs \xFCbereinstimmen.
3. Gib NIEMALS den Fehlertext im HTML aus! Du musst den Code stillschweigend reparieren.
Bitte analysiere den Fehler, korrigiere deinen Code und antworte AUSSCHLIESSLICH mit dem verbesserten \`\`\`app Block.`;
const history = db.getMessages(this.currentConversationId);
const model = modelUsed || db.getSetting("model") || "gemini-3.5-flash";
try {
const responseText = await aiRouter.sendMessage(fixPrompt, {
model,
history,
deepResearch: false,
extendedThinking: false,
vaultSearch: false,
abortSignal: this.currentAbortController.signal,
onStatus: (msg) => {
if (ind) ind.innerText = `Auto-Fix (${this.appRetryCount}/3): ${msg}...`;
}
});
await this.hideTypingIndicator();
if (this.currentAbortController && this.currentAbortController.signal.aborted) {
return;
}
const newMsgId = "msg_" + Date.now();
db.addMessage(newMsgId, this.currentConversationId, "assistant", responseText, modelUsed, styleUsed);
this.appendMessageElement("assistant", responseText, false, true, "Sandbox Auto-Fix Triggered");
this.buildTimeline();
} catch (err) {
await this.hideTypingIndicator();
if (err.name === "AbortError") return;
this.appendMessageElement("assistant", `*Auto-Fix fehlgeschlagen: ${err.message}*`, false, true, "Sandbox Auto-Fix Triggered");
} finally {
this.isGenerating = false;
window.setGeneratingState?.(false);
this.currentAbortController = null;
if (ind) ind.innerText = "Denkt nach...";
}
}
destroy() {
this.isDestroyed = true;
this.cleanupParticleLogo(true);
this.cleanupThemeParticles();
const toggleBtn = document.getElementById("timeline-toggle-btn");
if (toggleBtn) toggleBtn.remove();
const wrapper = document.getElementById("timeline-wrapper");
if (wrapper) wrapper.remove();
}
}