Files
pv-agent/web/app.js
T

425 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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", "", "Antwort bewerten oder kommentieren"));
const actions = node("div", "rating-actions");
const up = node("button", "secondary rating-choice", "👍 Hilfreich");
const down = node("button", "secondary rating-choice", "👎 Nicht hilfreich");
const ratingStatus = node("span", "rating-status");
up.type = "button";
down.type = "button";
actions.append(up, down, ratingStatus);
panel.append(actions);
const sendRating = async (value, selected, other) => {
up.disabled = true;
down.disabled = true;
ratingStatus.textContent = "Wird gespeichert …";
try {
const response = await fetch("/v1/ratings", {
method: "POST",
headers: apiHeaders(),
body: JSON.stringify({ request_id: answerRequestId, rating: value }),
});
if (!response.ok) throw new Error(await errorDetail(response));
selected.classList.add("is-selected");
other.classList.remove("is-selected");
ratingStatus.textContent = "Bewertung gespeichert.";
} catch (error) {
ratingStatus.textContent = error.message || "Bewertung konnte nicht gespeichert werden.";
} finally {
up.disabled = false;
down.disabled = false;
}
};
up.addEventListener("click", () => sendRating("up", up, down));
down.addEventListener("click", () => sendRating("down", down, up));
const commentForm = node("div", "comment-form");
const comment = node("textarea", "comment-input");
comment.rows = 3;
comment.maxLength = 2000;
comment.placeholder = "Kommentar zu dieser Antwort …";
comment.setAttribute("aria-label", "Kommentar zu dieser Antwort");
const sendComment = node("button", "primary", "Kommentar speichern");
sendComment.type = "button";
const commentStatus = node("span", "comment-status");
commentForm.append(comment, sendComment, commentStatus);
panel.append(commentForm);
sendComment.addEventListener("click", async () => {
const text = comment.value.trim();
if (!text) {
commentStatus.textContent = "Bitte zuerst einen Kommentar eingeben.";
comment.focus();
return;
}
sendComment.disabled = true;
commentStatus.textContent = "Wird gespeichert …";
try {
const response = await fetch("/v1/comments", {
method: "POST",
headers: apiHeaders(),
body: JSON.stringify({ request_id: answerRequestId, comment: text }),
});
if (!response.ok) throw new Error(await errorDetail(response));
comment.value = "";
commentStatus.textContent = "Kommentar gespeichert. Weitere Kommentare sind möglich.";
} catch (error) {
commentStatus.textContent = error.message || "Kommentar konnte nicht gespeichert werden.";
} finally {
sendComment.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();