Spaces:
Paused
Paused
Download dist.js from Luca448/APP-Backend: direct link, hf CLI and curl.
- Browser
- Download file 225 kB
-
https://huggingface.co/spaces/Luca448/APP-Backend/resolve/main/dist.js
- Command line
-
hf download hf://spaces/Luca448/APP-Backend/dist.js
-
curl -L -o dist.js https://huggingface.co/spaces/Luca448/APP-Backend/resolve/main/dist.js
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, """)}" | |
| alt="${prompt.replace(/"/g, """)}" | |
| 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, """).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, "<").replace(/>/g, ">") : "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 = ``; | |
| const imageInstruction = ` | |
| [WICHTIG: Verwende exakt den Markdown-Code  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 () 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, "<").replace(/>/g, ">")}</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, "<").replace(/>/g, ">")}</span> | |
| </div> | |
| </div> | |
| <div class="history-preview">${(conv.preview || "Keine Nachrichten").replace(/!\\[(?:SiriHero|KnowledgeCard)[^\\]]*\\]\\([^)]*\\)/gi, "").replace(/!(?:SiriHero|KnowledgeCard):[^\\n]+/gi, "").replace(/[*_#`\\[\\]]/g, "").replace(/</g, "<").replace(/>/g, ">").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 += ` | |
| `; | |
| } | |
| }); | |
| } | |
| 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(/"/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(); | |
| } | |
| } | |