const messagesEl = document.getElementById("messages"); const questionInput = document.getElementById("question"); const askBtn = document.getElementById("askBtn"); // ----- Helpers ----- function safeText(value, fallback = "") { if (value === null || value === undefined) return fallback; return String(value); } function normalizeArray(value) { if (!value) return []; return Array.isArray(value) ? value : [value]; } function createEl(tag, className, text) { const el = document.createElement(tag); if (className) el.className = className; if (text !== undefined) el.textContent = text; return el; } function scrollToBottom() { window.scrollTo({ top: document.body.scrollHeight, behavior: "smooth" }); } function setAskLoading(isLoading) { if (!askBtn) return; askBtn.disabled = isLoading; askBtn.textContent = isLoading ? "Antwort wird erstellt …" : "Frage stellen"; } // ----- Source rendering ----- function buildSourceMarker(source) { if (source.source_label) return source.source_label; if (source.source_marker) return source.source_marker; const numbers = normalizeArray(source.source_numbers) .map(Number) .filter(Number.isFinite) .sort((a, b) => a - b); if (numbers.length === 1) { return `[Quelle ${numbers[0]}]`; } if (numbers.length > 1) { return `[Quellen ${numbers.join(", ")}]`; } if (source.source_number !== null && source.source_number !== undefined) { const n = Number(source.source_number); if (Number.isFinite(n)) return `[Quelle ${n}]`; } return ""; } function buildCanonicalRefs(source) { const refs = normalizeArray(source.canonical_refs) .map(String) .map(s => s.trim()) .filter(Boolean); if (refs.length) { return refs.slice(0, 4).join("; "); } const singleRef = safeText(source.canonical_ref).trim(); const section = safeText(source.section).trim(); if (singleRef && singleRef !== section) { return singleRef; } return ""; } function renderSource(source) { // Best case: Backend/Composer already provides a fully curated display title. if (source.display_title) { return safeText(source.display_title); } const marker = buildSourceMarker(source); const container = safeText(source.container, "Unbekannt").trim(); const section = safeText(source.section, "ohne Abschnitt").trim(); const path = safeText(source.path).trim() || safeText(source.section_path).trim() || `${container}::${section}`; const pageRange = safeText(source.page_range).trim() || safeText(source.pages).trim() || "?"; const canonicalRefs = buildCanonicalRefs(source); const refPart = canonicalRefs ? ` (${canonicalRefs})` : ""; const role = Array.isArray(source.retrieval_kinds) && source.retrieval_kinds.length === 1 && source.retrieval_kinds[0] === "neighbor" ? " · Kontext/Nachbar" : ""; return `${marker} ${path}${refPart}, Seiten ${pageRange}${role}`.trim(); } function sourceNumbersOf(source) { const numbers = normalizeArray(source.source_numbers) .map(Number) .filter(Number.isFinite); const single = Number(source.source_number); if (Number.isFinite(single) && !numbers.includes(single)) { numbers.push(single); } return numbers.sort((a, b) => a - b); } function buildSourceNumberMap(sources) { const map = new Map(); for (const source of normalizeArray(sources)) { for (const n of sourceNumbersOf(source)) { if (!map.has(n)) map.set(n, source); } } return map; } function isSourceOpenable(source) { return Boolean(source && source.source_file); } function appendSources(parent, sources) { if (!Array.isArray(sources) || sources.length === 0) return; const srcWrap = createEl("div", "sources"); const strong = createEl("strong", null, "Quellen:"); const ul = document.createElement("ul"); const seen = new Set(); for (const source of sources) { const rendered = renderSource(source); if (!rendered || seen.has(rendered)) continue; seen.add(rendered); const li = createEl("li", "source-item"); if (isSourceOpenable(source)) { const btn = createEl("button", "source-link", rendered); btn.type = "button"; btn.title = "Fundstelle im PDF anzeigen"; btn.addEventListener("click", () => openSourceInViewer(source)); const icon = createEl("span", "source-link-icon", "📄"); icon.setAttribute("aria-hidden", "true"); btn.prepend(icon); li.appendChild(btn); } else { li.textContent = rendered; } ul.appendChild(li); } if (!ul.children.length) return; srcWrap.appendChild(strong); srcWrap.appendChild(ul); parent.appendChild(srcWrap); } // ----- Message rendering ----- // Erfasst [Quelle 3], [Quellen 1, 2] usw. – Zusatztext in der Klammer bleibt erhalten. const INLINE_MARKER_RE = /\[Quellen?\s+\d+(?:[^\]]*)\]/g; function renderAnswerText(container, answerText, sources) { const text = safeText(answerText); const bySourceNumber = buildSourceNumberMap(sources); let lastIndex = 0; for (const match of text.matchAll(INLINE_MARKER_RE)) { if (match.index > lastIndex) { container.appendChild(document.createTextNode(text.slice(lastIndex, match.index))); } const marker = match[0]; const numbers = (marker.match(/\d+/g) || []).map(Number); const source = numbers.map(n => bySourceNumber.get(n)).find(Boolean); if (source && isSourceOpenable(source)) { const btn = createEl("button", "source-ref", marker); btn.type = "button"; btn.title = "Fundstelle im PDF anzeigen"; btn.addEventListener("click", () => openSourceInViewer(source)); container.appendChild(btn); } else { container.appendChild(document.createTextNode(marker)); } lastIndex = match.index + marker.length; } if (lastIndex < text.length) { container.appendChild(document.createTextNode(text.slice(lastIndex))); } } function appendQuestion(text) { const wrap = createEl("div", "msg question"); wrap.textContent = "Frage:\n" + safeText(text); messagesEl.appendChild(wrap); } function appendAnswer(answerText, sources) { const wrap = createEl("div", "msg answer"); const answerDiv = createEl("div", "answer-text"); renderAnswerText(answerDiv, answerText, sources); wrap.appendChild(answerDiv); appendSources(wrap, sources); messagesEl.appendChild(wrap); } function appendError(message) { const wrap = createEl("div", "msg error"); wrap.textContent = safeText(message, "Es ist ein Fehler aufgetreten."); messagesEl.appendChild(wrap); } // ----- Ask flow ----- async function send() { const q = questionInput.value.trim(); if (!q) return; appendQuestion(q); questionInput.value = ""; setAskLoading(true); try { const res = await fetch("/ask", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ question: q }) }); let data = {}; try { data = await res.json(); } catch { throw new Error("Serverantwort war kein gültiges JSON."); } if (!res.ok) { throw new Error(data.error || data.detail || `Serverfehler ${res.status}`); } appendAnswer(data.answer || "", data.sources || []); } catch (err) { appendError( "Die Anfrage konnte nicht verarbeitet werden.\n" + safeText(err && err.message ? err.message : err) ); } finally { setAskLoading(false); scrollToBottom(); questionInput.focus(); } } // Optional: Enter sendet, Shift+Enter erzeugt Zeilenumbruch. questionInput.addEventListener("keydown", event => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); send(); } }); // ----- Prompt Modal ----- const backdrop = document.getElementById("promptModalBackdrop"); const promptTa = document.getElementById("systemPromptTextarea"); const promptStatus = document.getElementById("promptStatus"); let defaultPromptCache = ""; function setStatus(msg, ok = true) { promptStatus.style.color = ok ? "#0a6" : "#b00"; promptStatus.textContent = msg || ""; } function backdropClick(e) { if (e.target === backdrop) { closePromptModal(); } } async function openPromptModal() { setStatus(""); backdrop.style.display = "flex"; try { const res = await fetch("/system-prompt"); const data = await res.json(); if (!res.ok) { throw new Error(data.error || data.detail || `Serverfehler ${res.status}`); } promptTa.value = data.system_prompt || ""; defaultPromptCache = data.default_system_prompt || ""; } catch (err) { setStatus("Konnte System-Prompt nicht laden.", false); } } function closePromptModal() { backdrop.style.display = "none"; setStatus(""); } async function savePrompt() { setStatus(""); const newPrompt = (promptTa.value || "").trim(); if (!newPrompt) { setStatus("Prompt ist leer – bitte Text einfügen.", false); return; } try { const res = await fetch("/system-prompt", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ system_prompt: newPrompt }) }); const data = await res.json(); if (!res.ok) { throw new Error(data.error || data.detail || `Serverfehler ${res.status}`); } promptTa.value = data.system_prompt || newPrompt; setStatus("Gespeichert."); } catch (err) { setStatus("Speichern fehlgeschlagen.", false); } } async function resetPrompt() { if (!defaultPromptCache) { await openPromptModal(); } promptTa.value = defaultPromptCache || ""; await savePrompt(); } // ============================================================================= // PDF-Fundstellen-Viewer // // Öffnet das Quell-PDF in einem Side-Panel, springt zur Seite der Fundstelle // (page_start aus den Quellen-Metadaten) und hebt den Chunk-Text über den // PDF.js-Text-Layer hervor. Das Matching ist bewusst fuzzy (normalisierter // Text, satzweise), weil PDF-Extraktion und Ingest-Text leicht abweichen. // ============================================================================= const pdfPanel = document.getElementById("pdfPanel"); const pdfViewportEl = document.getElementById("pdfViewport"); const pdfPageWrap = document.getElementById("pdfPageWrap"); const pdfCanvas = document.getElementById("pdfCanvas"); const pdfTextLayerEl = document.getElementById("pdfTextLayer"); const pdfStatusEl = document.getElementById("pdfStatus"); const pdfTitleEl = document.getElementById("pdfPanelTitle"); const pdfSubtitleEl = document.getElementById("pdfPanelSubtitle"); const pdfPageIndicator = document.getElementById("pdfPageIndicator"); const pdfPrevBtn = document.getElementById("pdfPrevBtn"); const pdfNextBtn = document.getElementById("pdfNextBtn"); const pdfJumpBtn = document.getElementById("pdfJumpBtn"); const pdfCloseBtn = document.getElementById("pdfCloseBtn"); const pdfOpenTab = document.getElementById("pdfOpenTab"); if (window.pdfjsLib) { pdfjsLib.GlobalWorkerOptions.workerSrc = "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js"; } const pdfState = { doc: null, url: null, pageCount: 0, currentPage: 1, source: null, highlightPages: [], renderSeq: 0 }; function pdfUrlFor(source) { return "/pdf/" + encodeURIComponent(safeText(source.source_file)); } function setPdfStatus(message, isError = false) { pdfStatusEl.textContent = safeText(message); pdfStatusEl.classList.toggle("error", Boolean(isError)); pdfStatusEl.style.display = message ? "block" : "none"; } function setPdfTitles(source) { const refs = normalizeArray(source.canonical_refs).map(String).filter(Boolean); const ref = refs.length ? refs.slice(0, 3).join("; ") : safeText(source.canonical_ref); const path = safeText(source.path) || safeText(source.section); pdfTitleEl.textContent = ref || path || "Fundstelle"; const parts = []; if (path && path !== ref) parts.push(path); if (source.source_file) parts.push(safeText(source.source_file)); const pageInfo = pdfPageRangeLabel(source); if (pageInfo) parts.push(pageInfo); pdfSubtitleEl.textContent = parts.join(" · "); } function pdfPageRangeLabel(source) { const start = Number(source.page_start); const end = Number(source.page_end); if (!Number.isFinite(start)) return ""; if (Number.isFinite(end) && end !== start) return `Seiten ${start}–${end}`; return `Seite ${start}`; } function collectHighlightPages(source) { const pages = new Set(); for (const entry of normalizeArray(source.highlights)) { const start = Number(entry && entry.page_start); const end = Number(entry && entry.page_end); if (Number.isFinite(start)) { const last = Number.isFinite(end) ? end : start; for (let p = start; p <= last && p - start < 20; p++) pages.add(p); } } const srcStart = Number(source.page_start); const srcEnd = Number(source.page_end); if (Number.isFinite(srcStart)) { const last = Number.isFinite(srcEnd) ? srcEnd : srcStart; for (let p = srcStart; p <= last && p - srcStart < 20; p++) pages.add(p); } return [...pages].sort((a, b) => a - b); } function clampPdfPage(page) { const n = Number(page); if (!Number.isFinite(n)) return 1; return Math.min(Math.max(1, Math.round(n)), pdfState.pageCount || 1); } function openPdfPanel() { pdfPanel.classList.add("open"); pdfPanel.setAttribute("aria-hidden", "false"); } function closePdfPanel() { pdfPanel.classList.remove("open"); pdfPanel.setAttribute("aria-hidden", "true"); pdfState.renderSeq++; } function updatePdfNav() { pdfPageIndicator.textContent = pdfState.pageCount ? `Seite ${pdfState.currentPage} / ${pdfState.pageCount}` : "–"; pdfPrevBtn.disabled = pdfState.currentPage <= 1; pdfNextBtn.disabled = pdfState.currentPage >= pdfState.pageCount; pdfJumpBtn.style.display = pdfState.highlightPages.length ? "" : "none"; } async function openSourceInViewer(source) { if (!source || !source.source_file) return; // Fallback ohne PDF.js (z. B. CDN blockiert) oder ohne Panel-Markup // (veraltetes, gecachtes index.html): nativer Browser-Viewer im neuen Tab. if (!window.pdfjsLib || !pdfPanel) { const page = Number(source.page_start); const anchor = Number.isFinite(page) ? `#page=${page}` : ""; window.open(pdfUrlFor(source) + anchor, "_blank", "noopener"); return; } openPdfPanel(); setPdfTitles(source); const url = pdfUrlFor(source); pdfOpenTab.href = url; try { if (!pdfState.doc || pdfState.url !== url) { setPdfStatus("PDF wird geladen …"); pdfState.doc = await pdfjsLib.getDocument({ url }).promise; pdfState.url = url; pdfState.pageCount = pdfState.doc.numPages; } pdfState.source = source; pdfState.highlightPages = collectHighlightPages(source); setPdfStatus(""); const target = clampPdfPage( Number.isFinite(Number(source.page_start)) ? Number(source.page_start) : (pdfState.highlightPages[0] || 1) ); await renderPdfPage(target); } catch (err) { setPdfStatus( "PDF konnte nicht geladen werden: " + safeText(err && err.message ? err.message : err), true ); } } async function renderPdfPage(pageNo) { if (!pdfState.doc) return; const seq = ++pdfState.renderSeq; pdfState.currentPage = clampPdfPage(pageNo); updatePdfNav(); const page = await pdfState.doc.getPage(pdfState.currentPage); if (seq !== pdfState.renderSeq) return; const availableWidth = Math.max(280, pdfViewportEl.clientWidth - 36); const baseViewport = page.getViewport({ scale: 1 }); const scale = Math.min(2.5, availableWidth / baseViewport.width); const viewport = page.getViewport({ scale }); const outputScale = window.devicePixelRatio || 1; pdfCanvas.width = Math.floor(viewport.width * outputScale); pdfCanvas.height = Math.floor(viewport.height * outputScale); pdfCanvas.style.width = `${viewport.width}px`; pdfCanvas.style.height = `${viewport.height}px`; pdfPageWrap.style.width = `${viewport.width}px`; pdfPageWrap.style.height = `${viewport.height}px`; const renderTask = page.render({ canvasContext: pdfCanvas.getContext("2d"), viewport, transform: outputScale !== 1 ? [outputScale, 0, 0, outputScale, 0, 0] : null }); const textContent = await page.getTextContent(); await renderTask.promise; if (seq !== pdfState.renderSeq) return; pdfTextLayerEl.innerHTML = ""; pdfTextLayerEl.style.width = `${viewport.width}px`; pdfTextLayerEl.style.height = `${viewport.height}px`; pdfTextLayerEl.style.setProperty("--scale-factor", String(viewport.scale)); const textDivs = []; await pdfjsLib.renderTextLayer({ textContent, container: pdfTextLayerEl, viewport, textDivs }).promise; if (seq !== pdfState.renderSeq) return; const matched = applyPdfHighlights(pdfState.currentPage, textContent.items, textDivs); if (matched) { setPdfStatus(""); const first = pdfTextLayerEl.querySelector(".pdf-hl"); if (first) { first.scrollIntoView({ block: "center", behavior: "smooth" }); } } else if (pdfState.highlightPages.includes(pdfState.currentPage)) { setPdfStatus( "Fundstelle liegt auf dieser Seite – die genaue Textstelle konnte nicht automatisch markiert werden." ); } else { setPdfStatus(""); } } // ----- Text-Matching für die Hervorhebung ----- function pdfNormText(value) { return safeText(value) .toLowerCase() .replace(/­/g, "") .replace(/[^\p{L}\p{N}§]+/gu, " ") .replace(/\s+/g, " ") .trim(); } function pdfSegmentsOf(text) { const out = []; for (const block of safeText(text).split(/\n{2,}/)) { const flat = block.replace(/\s+/g, " ").trim(); if (!flat) continue; const sentences = flat.split(/(?<=[.;:!?])\s+/); let pushed = false; for (const sentence of sentences) { const s = sentence.trim(); if (s.length >= 12) { out.push(s); pushed = true; } } if (!pushed && flat.length >= 12) out.push(flat); } return out; } function applyPdfHighlights(pageNo, items, textDivs) { const source = pdfState.source; if (!source) return false; const entries = normalizeArray(source.highlights).filter(entry => { if (!entry || !entry.text) return false; const start = Number(entry.page_start); const end = Number(entry.page_end); if (!Number.isFinite(start)) return true; // ohne Seitenangabe: überall versuchen return pageNo >= start && pageNo <= (Number.isFinite(end) ? end : start); }); if (!entries.length) return false; // Seitentext mit Index-Map über die Text-Layer-Items aufbauen. // Leere Items (reine Whitespace-Fragmente) dürfen keine zusätzlichen // Leerzeichen erzeugen, sonst scheitert das Substring-Matching. let pageStr = ""; const spans = []; items.forEach((item, i) => { const t = pdfNormText(item.str); if (!t) { spans.push({ start: pageStr.length, end: pageStr.length, i }); return; } spans.push({ start: pageStr.length, end: pageStr.length + t.length, i }); pageStr += t + " "; }); const markRange = (a, b) => { let any = false; for (const span of spans) { if (span.end <= span.start) continue; if (span.end > a && span.start < b) { const div = textDivs[span.i]; if (div && div.textContent.trim()) { div.classList.add("pdf-hl"); any = true; } } } return any; }; let matchedAny = false; for (const entry of entries) { for (const segment of pdfSegmentsOf(entry.text)) { const needle = pdfNormText(segment); if (needle.length < 8) continue; const idx = pageStr.indexOf(needle); if (idx >= 0) { matchedAny = markRange(idx, idx + needle.length) || matchedAny; continue; } // Fallback für längere Segmente: Anfangs-, Mittel- und End-Sonden, damit // Silbentrennung/Extraktionsunterschiede nicht das ganze Segment kosten. if (needle.length > 40) { const mid = Math.floor(needle.length / 2); for (const probe of [needle.slice(0, 40), needle.slice(mid, mid + 40), needle.slice(-40)]) { const trimmed = probe.trim(); if (trimmed.length < 16) continue; const j = pageStr.indexOf(trimmed); if (j >= 0) { matchedAny = markRange(j, j + trimmed.length) || matchedAny; } } } } } return matchedAny; } // ----- Panel-Bedienung ----- // Guard: Bei veraltetem, gecachtem index.html fehlen die Panel-Elemente; // dann werden keine Listener registriert (openSourceInViewer nutzt den // Neuer-Tab-Fallback) und der restliche Chat bleibt voll funktionsfähig. if (pdfPanel && pdfPrevBtn && pdfNextBtn && pdfJumpBtn && pdfCloseBtn) { pdfPrevBtn.addEventListener("click", () => { if (pdfState.currentPage > 1) renderPdfPage(pdfState.currentPage - 1); }); pdfNextBtn.addEventListener("click", () => { if (pdfState.currentPage < pdfState.pageCount) renderPdfPage(pdfState.currentPage + 1); }); pdfJumpBtn.addEventListener("click", () => { if (pdfState.highlightPages.length) renderPdfPage(pdfState.highlightPages[0]); }); pdfCloseBtn.addEventListener("click", closePdfPanel); document.addEventListener("keydown", event => { if (event.key === "Escape" && pdfPanel.classList.contains("open")) { closePdfPanel(); } }); }