| const messagesEl = document.getElementById("messages"); |
| const questionInput = document.getElementById("question"); |
| const askBtn = document.getElementById("askBtn"); |
|
|
| |
|
|
| 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"; |
| } |
|
|
| |
|
|
| 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) { |
| |
| 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); |
| } |
|
|
| |
|
|
| |
| 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); |
| } |
|
|
| |
|
|
| 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(); |
| } |
| } |
|
|
| |
| questionInput.addEventListener("keydown", event => { |
| if (event.key === "Enter" && !event.shiftKey) { |
| event.preventDefault(); |
| send(); |
| } |
| }); |
|
|
| |
|
|
| 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(); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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; |
|
|
| |
| |
| 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(""); |
| } |
| } |
|
|
| |
|
|
| 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; |
| return pageNo >= start && pageNo <= (Number.isFinite(end) ? end : start); |
| }); |
| if (!entries.length) return false; |
|
|
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
|
|
| 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(); |
| } |
| }); |
| } |