feat(agent): add test frontend and Docker stack

This commit is contained in:
2026-09-16 21:42:05 +02:00
parent aa0bee340f
commit fc656188cf
16 changed files with 1065 additions and 71 deletions
+345
View File
@@ -0,0 +1,345 @@
"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 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));
}
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();