BAV_KI / static /js /script.js
AlixJabda's picture
Update Anwendung
86f8f81
Raw
History Blame Contribute Delete
21.6 kB
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();
}
});
}