mirror of
http://100.103.83.12:3003/fegger/odoo-at-payroll.git
synced 2026-09-17 16:56:42 +00:00
410 lines
14 KiB
JavaScript
410 lines
14 KiB
JavaScript
"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();
|