"use strict"; const elements = { form: document.getElementById("ask-form"), question: document.getElementById("question"), submit: document.getElementById("submit"), cancel: document.getElementById("cancel"), messages: document.getElementById("messages"), empty: document.getElementById("empty-state"), health: document.getElementById("health"), key: document.getElementById("api-key"), rememberKey: document.getElementById("remember-key"), keyVisibility: document.getElementById("key-visibility"), topK: document.getElementById("top-k"), charCount: document.getElementById("char-count"), settingsToggle: document.getElementById("settings-toggle"), settingsBody: document.getElementById("settings-body"), }; let activeController = null; let loadingMessage = null; let loadingTimer = null; let loadingStartedAt = 0; function node(tag, className, text) { const item = document.createElement(tag); if (className) item.className = className; if (text !== undefined && text !== null) item.textContent = text; return item; } function requestId() { if (globalThis.crypto && typeof globalThis.crypto.randomUUID === "function") { return `web-${globalThis.crypto.randomUUID()}`; } return `web-${Date.now()}-${Math.random().toString(16).slice(2)}`; } function apiHeaders() { const headers = { "Content-Type": "application/json", "X-Request-ID": requestId(), }; const key = elements.key.value.trim(); if (key) headers.Authorization = `Bearer ${key}`; return headers; } function updateCharacterCount() { elements.charCount.textContent = `${elements.question.value.length} / 2000`; } function scrollToLatest() { elements.messages.lastElementChild?.scrollIntoView({ behavior: "smooth", block: "end" }); } function addUserMessage(question) { elements.empty.hidden = true; const wrapper = node("article", "message message-user"); wrapper.append(node("div", "message-label", "Deine Frage")); wrapper.append(node("div", "message-body", question)); elements.messages.append(wrapper); scrollToLatest(); } function addLoadingMessage() { const wrapper = node("article", "message message-agent"); const label = node("div", "message-label", "PV Agent"); const body = node("div", "message-body"); const row = node("div", "loading-row"); row.append(node("span", "loader")); const text = node("span", "loading-text", "Wissensbasis wird durchsucht … 0 s"); row.append(text); body.append(row); wrapper.append(label, body); elements.messages.append(wrapper); loadingStartedAt = Date.now(); loadingTimer = window.setInterval(() => { const seconds = Math.round((Date.now() - loadingStartedAt) / 1000); text.textContent = `Wissensbasis wird durchsucht … ${seconds} s`; }, 1000); loadingMessage = wrapper; scrollToLatest(); } function removeLoadingMessage() { if (loadingTimer) window.clearInterval(loadingTimer); loadingTimer = null; loadingMessage?.remove(); loadingMessage = null; } function badge(text, variant = "") { return node("span", `badge${variant ? ` badge-${variant}` : ""}`, text); } function sourcePanel(sources) { const details = node("details", "source-panel"); const summary = node("summary", "", `${sources.length} zitierte ${sources.length === 1 ? "Quelle" : "Quellen"}`); const list = node("div", "source-list"); for (const source of sources) { const card = node("div", "source-card"); card.append(node("div", "source-id", source.id || "Unbekannte ID")); card.append(node("div", "source-title", source.title || "Ohne Titel")); const meta = [source.section, source.stand ? `Stand ${source.stand}` : null, source.work] .filter(Boolean) .join(" · "); if (meta) card.append(node("div", "source-meta", meta)); list.append(card); } details.append(summary, list); return details; } function conflictPanel(conflicts) { const container = node("div"); for (const conflict of conflicts) { const box = node("div", "conflict-box"); box.append(node("strong", "", `Quellenkonflikt · ${(conflict.source_ids || []).join(", ")}`)); box.append(node("p", "", conflict.summary)); container.append(box); } return container; } function followUpPanel(question) { const box = node("div", "follow-up"); box.append(node("p", "", question)); const button = node("button", "secondary", "Rückfrage übernehmen"); button.type = "button"; button.addEventListener("click", () => { elements.question.value = question; updateCharacterCount(); elements.question.focus(); }); box.append(button); return box; } function ratingPanel(answerRequestId) { const panel = node("div", "rating-panel"); panel.append(node("strong", "", "War diese Antwort hilfreich?")); const actions = node("div", "rating-actions"); const up = node("button", "secondary rating-choice", "👍 Ja"); const down = node("button", "secondary rating-choice", "👎 Nein"); up.type = "button"; down.type = "button"; actions.append(up, down); panel.append(actions); const form = node("div", "rating-form"); form.hidden = true; const feedback = node("textarea", "rating-feedback"); feedback.rows = 2; feedback.maxLength = 1000; feedback.placeholder = "Optional: Was war hilfreich oder was hat gefehlt?"; const send = node("button", "primary", "Bewertung senden"); send.type = "button"; const status = node("span", "rating-status"); form.append(feedback, send, status); panel.append(form); let selection = null; const select = (value, selected, other) => { selection = value; selected.classList.add("is-selected"); other.classList.remove("is-selected"); form.hidden = false; feedback.focus(); }; up.addEventListener("click", () => select("up", up, down)); down.addEventListener("click", () => select("down", down, up)); send.addEventListener("click", async () => { if (!selection) return; send.disabled = true; status.textContent = "Wird gespeichert …"; try { const response = await fetch("/v1/ratings", { method: "POST", headers: apiHeaders(), body: JSON.stringify({ request_id: answerRequestId, rating: selection, feedback: feedback.value.trim() || null, }), }); if (!response.ok) throw new Error(await errorDetail(response)); status.textContent = "Danke, Bewertung gespeichert."; up.disabled = true; down.disabled = true; feedback.disabled = true; send.hidden = true; } catch (error) { status.textContent = error.message || "Bewertung konnte nicht gespeichert werden."; send.disabled = false; } }); return panel; } function technicalPanel(data) { const details = node("details", "technical-panel"); details.append(node("summary", "", "Technische Details")); const list = node("ul", "query-list"); const facts = [ `Request-ID: ${data.request_id || "–"}`, `Antworttyp: ${data.answer_type || "specific"}`, `Kontextblöcke: ${data.grounding?.context_count ?? data.n_context ?? "–"}`, `Regenerierungen: ${data.regenerations ?? 0}`, `Datenumfang: ${data.grounding?.data_scope || "–"}`, ]; for (const fact of facts) list.append(node("li", "", fact)); if (Array.isArray(data.planned_queries) && data.planned_queries.length) { list.append(node("li", "", "Suchplan:")); for (const query of data.planned_queries) { const suffix = [query.scope, query.stand_year].filter(Boolean).join(", "); list.append(node("li", "", `${query.text}${suffix ? ` (${suffix})` : ""}`)); } } details.append(list); return details; } function addAgentMessage(data) { const status = data.status || (data.verified ? (data.refused ? "refused" : "answered") : "uncertain"); const wrapper = node("article", `message message-agent is-${status}`); wrapper.append(node("div", "message-label", "PV Agent")); const body = node("div", "message-body"); const statusLine = node("div", "status-line"); if (status === "answered") statusLine.append(badge("Zitiergeprüft")); if (status === "refused") statusLine.append(badge("Nicht in der Wissensbasis", "warning")); if (status === "uncertain") statusLine.append(badge("Nicht verlässlich belegt", "error")); if (data.answer_type === "survey") statusLine.append(badge("Überblick")); body.append(statusLine); body.append(node("div", "answer-text", data.answer || "Keine Antwort erhalten.")); if (Array.isArray(data.conflicts) && data.conflicts.length) { body.append(conflictPanel(data.conflicts)); } if (data.clarification_question) { body.append(followUpPanel(data.clarification_question)); } if (Array.isArray(data.sources) && data.sources.length) { body.append(sourcePanel(data.sources)); } if (data.ratings_enabled && data.request_id) { body.append(ratingPanel(data.request_id)); } const seconds = typeof data.latency_ms === "number" ? `${(data.latency_ms / 1000).toFixed(1)} s` : "–"; body.append(node("div", "answer-meta", `${data.model || "Modell"} · ${seconds} · ${data.citations?.length || 0} Zitate`)); body.append(technicalPanel(data)); wrapper.append(body); elements.messages.append(wrapper); scrollToLatest(); } function addErrorMessage(message, requestIdValue = "") { const wrapper = node("article", "message message-agent message-error"); wrapper.append(node("div", "message-label", "Verbindungsfehler")); const body = node("div", "message-body"); body.append(badge("Anfrage fehlgeschlagen", "error")); body.append(node("div", "answer-text", message)); if (requestIdValue) body.append(node("div", "answer-meta", `Request-ID: ${requestIdValue}`)); wrapper.append(body); elements.messages.append(wrapper); scrollToLatest(); } async function errorDetail(response) { try { const payload = await response.json(); if (typeof payload.detail === "string") return payload.detail; if (Array.isArray(payload.detail)) return "Die Anfrage entspricht nicht dem API-Vertrag."; } catch (_) { // Absichtlich neutral: keine unstrukturierte Serverantwort in die UI übernehmen. } return `HTTP ${response.status}`; } function setBusy(busy) { elements.submit.disabled = busy; elements.cancel.hidden = !busy; elements.question.disabled = busy; } async function ask(question) { if (activeController) return; addUserMessage(question); addLoadingMessage(); setBusy(true); activeController = new AbortController(); const topK = elements.topK.value ? Number(elements.topK.value) : undefined; const payload = { question, mode: "knowledge" }; if (topK) payload.top_k = topK; try { const response = await fetch("/v1/ask", { method: "POST", headers: apiHeaders(), body: JSON.stringify(payload), signal: activeController.signal, }); const responseRequestId = response.headers.get("X-Request-ID") || ""; removeLoadingMessage(); if (!response.ok) { const detail = await errorDetail(response); if (response.status === 401) { addErrorMessage("Service-Key fehlt oder ist ungültig. Bitte die Verbindungseinstellungen prüfen.", responseRequestId); elements.key.focus(); } else { addErrorMessage(detail, responseRequestId); } return; } addAgentMessage(await response.json()); } catch (error) { removeLoadingMessage(); if (error.name === "AbortError") { addErrorMessage("Die Anzeige wurde abgebrochen. Die serverseitige Verarbeitung kann bereits begonnen haben."); } else { addErrorMessage("Der Wissensdienst ist nicht erreichbar. Netzwerk und Dienststatus prüfen."); } } finally { activeController = null; setBusy(false); elements.question.focus(); } } async function checkHealth() { elements.health.className = "health health-loading"; try { const response = await fetch("/v1/health", { headers: { "X-Request-ID": requestId() } }); if (!response.ok) throw new Error("health request failed"); const data = await response.json(); const ok = data.status === "ok"; elements.health.className = `health ${ok ? "health-ok" : "health-error"}`; elements.health.lastElementChild.textContent = ok ? `${data.index?.n_entries ?? "–"} Quellen · bereit` : "Dienst eingeschränkt"; if (data.authentication_enabled && !elements.key.value) { elements.key.placeholder = "Service-Key erforderlich"; } } catch (_) { elements.health.className = "health health-error"; elements.health.lastElementChild.textContent = "Dienst nicht erreichbar"; } } elements.form.addEventListener("submit", (event) => { event.preventDefault(); const question = elements.question.value.trim(); if (question.length < 3) return; elements.question.value = ""; updateCharacterCount(); ask(question); }); elements.question.addEventListener("input", updateCharacterCount); elements.question.addEventListener("keydown", (event) => { if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) { event.preventDefault(); elements.form.requestSubmit(); } }); elements.cancel.addEventListener("click", () => activeController?.abort()); document.querySelectorAll("[data-question]").forEach((button) => { button.addEventListener("click", () => { elements.question.value = button.dataset.question || ""; updateCharacterCount(); elements.question.focus(); }); }); elements.keyVisibility.addEventListener("click", () => { const visible = elements.key.type === "text"; elements.key.type = visible ? "password" : "text"; elements.keyVisibility.textContent = visible ? "Anzeigen" : "Verbergen"; elements.keyVisibility.setAttribute("aria-label", visible ? "Service-Key anzeigen" : "Service-Key verbergen"); }); elements.rememberKey.addEventListener("change", () => { if (elements.rememberKey.checked) { sessionStorage.setItem("pv-api-key", elements.key.value); } else { sessionStorage.removeItem("pv-api-key"); } }); elements.key.addEventListener("input", () => { if (elements.rememberKey.checked) sessionStorage.setItem("pv-api-key", elements.key.value); }); elements.settingsToggle.addEventListener("click", () => { const expanded = elements.settingsToggle.getAttribute("aria-expanded") === "true"; elements.settingsToggle.setAttribute("aria-expanded", String(!expanded)); elements.settingsBody.hidden = expanded; }); const savedKey = sessionStorage.getItem("pv-api-key"); if (savedKey) { elements.key.value = savedKey; elements.rememberKey.checked = true; } updateCharacterCount(); checkHealth();