feat(agent): add test frontend and Docker stack

This commit is contained in:
2026-09-16 21:42:05 +02:00
parent c8b55fbd38
commit 4f5107a46a
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();
+96 -64
View File
@@ -1,69 +1,101 @@
<!DOCTYPE html>
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PV RAG Agent — Test-Chat</title>
<style>
:root { color-scheme: light dark; }
body { font-family: system-ui, sans-serif; max-width: 780px; margin: 2rem auto; padding: 0 1rem; }
h1 { font-size: 1.2rem; }
#log { display: flex; flex-direction: column; gap: 0.8rem; margin: 1.5rem 0; }
.msg { border: 1px solid rgba(128,128,128,.35); border-radius: 8px; padding: .7rem .9rem; white-space: pre-wrap; }
.q { background: rgba(128,128,128,.12); }
.src { font-size: .78rem; margin-top: .4rem; display: flex; flex-wrap: wrap; gap: .3rem; }
.chip { border-radius: 999px; padding: .1rem .5rem; background: rgba(128,128,128,.15); }
.warn { border-color: #c08020; }
form { display: flex; gap: .5rem; }
input { flex: 1; padding: .5rem; border-radius: 6px; border: 1px solid rgba(128,128,128,.5); }
button { padding: .5rem 1rem; border-radius: 6px; cursor: pointer; }
.meta { font-size: .75rem; opacity: .7; }
</style>
<meta charset="utf-8">
<meta</head> name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="Testoberfläche für den PV RAG Agent">
<title>PV Wissen — Test-Chat</title>
<link rel="stylesheet" href="/assets/styles.css">
<script src="/assets/app.js" defer></script>
</head>
<body>
<h1>PV RAG Agent — Wissensbasis Personalverrechnung</h1>
<p class="meta">Antworten ausschließlich aus der kuratierten Wissensbasis, mit ID- und Stand-Beleg.</p>
<div id="log"></div>
<form id="f">
<input id="q" placeholder="Frage zur österreichischen Personalverrechnung …" autocomplete="off" required>
<button>Senden</button>
</form>
<script>
const log = document.getElementById("log");
function add(cls, html) {
const d = document.createElement("div");
d.className = "msg " + cls;
d.innerHTML = html;
log.appendChild(d);
}
document.getElementById("f").addEventListener("submit", async (e) => {
e.preventDefault();
const q = document.getElementById("q").value.trim();
if (!q) return;
add("q", q);
document.getElementById("q").value = "";
try {
const r = await fetch("/ask", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ question: q }),
});
if (!r.ok) {
add("warn", "Fehler " + r.status + ": " + (await r.text()));
return;
}
const d = await r.json();
const chips = (d.sources || []).map(s =>
`<span class="chip">${s.id} · ${s.stand || ""}</span>`).join("");
add(d.refused ? "warn" : "",
`${d.answer.replace(/</g, "&lt;")}` +
(d.sources && d.sources.length ? `<div class="src">${chips}</div>` : "") +
`<div class="meta">Modell: ${d.model} · ${d.latency_ms} ms · ` +
`${d.verified ? "zitiergeprüft" : "UNVERIFIZIERT"}${d.refused ? " · verweigert" : ""}</div>`);
} catch (err) {
add("warn", "Netzwerkfehler: " + err);
}
});
</script>
<div class="shell">
<header class="topbar">
<a class="brand" href="/" aria-label="PV Wissen Startseite">
<span class="brand-mark" aria-hidden="true">PV</span>
<span>
<strong>PV Wissen</strong>
<small>Agent für österreichische Personalverrechnung</small>
</span>
</a>
<div id="health" class="health health-loading" role="status" aria-live="polite">
<span class="health-dot" aria-hidden="true"></span>
<span>Verbindung wird geprüft</span>
</div>
</header>
<main>
<section class="hero" aria-labelledby="page-title">
<p class="eyebrow">Belegt. Nachvollziehbar. Lokal.</p>
<h1 id="page-title">Was möchtest du zur Personalverrechnung wissen?</h1>
<p>Antworten stammen ausschließlich aus der kuratierten Wissensbasis und führen ihre Quellen direkt an.</p>
</section>
<section class="workspace">
<aside class="settings" aria-labelledby="settings-title">
<div class="settings-heading">
<h2 id="settings-title">Verbindung</h2>
<button id="settings-toggle" class="icon-button" type="button" aria-expanded="true" aria-controls="settings-body" title="Einstellungen ein- oder ausblenden"></button>
</div>
<div id="settings-body">
<label for="api-key">Service-Key</label>
<div class="key-row">
<input id="api-key" type="password" autocomplete="off" spellcheck="false" placeholder="Bearer-Key, falls aktiviert">
<button id="key-visibility" class="secondary compact" type="button" aria-label="Service-Key anzeigen">Anzeigen</button>
</div>
<label class="check-row" for="remember-key">
<input id="remember-key" type="checkbox">
<span>Nur für diesen Tab merken</span>
</label>
<label for="top-k">Kontextumfang</label>
<select id="top-k">
<option value="">Automatisch</option>
<option value="6">6 Quellenblöcke</option>
<option value="8">8 Quellenblöcke</option>
<option value="12">12 Quellenblöcke</option>
<option value="16">16 Quellenblöcke</option>
</select>
<div class="privacy-note">
<strong>Datenschutzgrenze</strong>
<span>Keine Namen, Personalnummern oder Lohndaten eingeben. Diese Testversion verarbeitet nur Fachfragen.</span>
</div>
</div>
</aside>
<section class="chat" aria-label="Chat mit dem PV Agenten">
<div id="empty-state" class="empty-state">
<div class="empty-icon" aria-hidden="true">§</div>
<h2>Mit einer Fachfrage starten</h2>
<p>Zum Beispiel zu Reisekosten, Abgaben, Kollektivverträgen oder arbeitsrechtlichen Ansprüchen.</p>
<div class="suggestions" aria-label="Beispielfragen">
<button type="button" data-question="Ich will meinem Mitarbeiter 500 Euro zusätzlich auszahlen. Was ist die günstigste Lösung?">500 Euro zusätzlich auszahlen</button>
<button type="button" data-question="Wie hoch ist der steuerfreie Tagesgeldsatz bei einer Inlandsdienstreise?">Steuerfreies Tagesgeld</button>
<button type="button" data-question="Welche Voraussetzungen gelten für die Mitarbeiterprämie 2026?">Mitarbeiterprämie 2026</button>
</div>
</div>
<div id="messages" class="messages" aria-live="polite" aria-label="Nachrichtenverlauf"></div>
<form id="ask-form" class="composer">
<label class="sr-only" for="question">Frage</label>
<textarea id="question" rows="3" maxlength="2000" required placeholder="Frage zur österreichischen Personalverrechnung …"></textarea>
<div class="composer-footer">
<span id="char-count" class="character-count">0 / 2000</span>
<div class="composer-actions">
<button id="cancel" class="secondary" type="button" hidden>Abbrechen</button>
<button id="submit" class="primary" type="submit">
<span>Frage senden</span>
<span class="send-icon" aria-hidden="true"></span>
</button>
</div>
</div>
</form>
<p class="footnote">Der Agent kann Fehler machen. Fachliche Entscheidungen anhand der angeführten Quellen prüfen.</p>
</section>
</section>
</main>
</div>
</body>
</html>
</html>
+170
View File
@@ -0,0 +1,170 @@
:root {
color-scheme: light;
--ink: #17211b;
--muted: #607066;
--paper: #f4f3ec;
--surface: #fffef9;
--line: #d9ddd6;
--green: #176b4d;
--green-dark: #0f4e38;
--green-soft: #e4f2ea;
--amber: #9a5a08;
--amber-soft: #fff4d9;
--red: #9b2c2c;
--red-soft: #fdeaea;
--shadow: 0 20px 60px rgba(34, 51, 40, 0.09);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
color: var(--ink);
background:
radial-gradient(circle at 15% -10%, rgba(63, 137, 103, 0.16), transparent 34rem),
linear-gradient(180deg, #fbfaf5 0%, var(--paper) 100%);
}
button, input, select, textarea { font: inherit; }
button { color: inherit; }
.shell { width: min(1180px, calc(100% - 32px)); margin: 0 auto; }
.topbar {
min-height: 80px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
border-bottom: 1px solid rgba(23, 33, 27, 0.1);
}
.brand { display: inline-flex; align-items: center; gap: 12px; color: inherit; text-decoration: none; }
.brand-mark {
display: grid;
width: 42px;
height: 42px;
place-items: center;
border-radius: 12px;
color: white;
background: var(--green-dark);
font-family: Georgia, serif;
font-weight: 700;
letter-spacing: -0.04em;
}
.brand strong, .brand small { display: block; }
.brand strong { font-size: 1rem; letter-spacing: 0.01em; }
.brand small { margin-top: 2px; color: var(--muted); font-size: 0.76rem; }
.health { display: inline-flex; align-items: center; gap: 8px; color: var(--muted); font-size: 0.82rem; }
.health-dot { width: 9px; height: 9px; border-radius: 50%; background: #a0aaa3; box-shadow: 0 0 0 4px rgba(160, 170, 163, 0.14); }
.health-ok .health-dot { background: #239666; box-shadow: 0 0 0 4px rgba(35, 150, 102, 0.14); }
.health-error .health-dot { background: #c4553e; box-shadow: 0 0 0 4px rgba(196, 85, 62, 0.14); }
.hero { max-width: 780px; padding: 64px 0 38px; }
.eyebrow { margin: 0 0 12px; color: var(--green); font-size: 0.77rem; font-weight: 750; letter-spacing: 0.14em; text-transform: uppercase; }
h1 { max-width: 720px; margin: 0; font-family: Georgia, "Times New Roman", serif; font-size: clamp(2.25rem, 5vw, 4.25rem); font-weight: 500; line-height: 1.02; letter-spacing: -0.035em; }
.hero > p:last-child { max-width: 660px; margin: 22px 0 0; color: var(--muted); font-size: 1.04rem; line-height: 1.65; }
.workspace { display: grid; grid-template-columns: 260px minmax(0, 1fr); gap: 22px; align-items: start; padding-bottom: 64px; }
.settings, .chat { border: 1px solid var(--line); border-radius: 18px; background: rgba(255, 254, 249, 0.94); box-shadow: var(--shadow); }
.settings { padding: 18px; position: sticky; top: 18px; }
.settings-heading { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
.settings h2 { margin: 0; font-size: 0.94rem; }
.settings label:not(.check-row) { display: block; margin: 15px 0 6px; color: var(--muted); font-size: 0.74rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; }
.settings input[type="password"], .settings input[type="text"], .settings select {
width: 100%; min-width: 0; padding: 10px 11px; border: 1px solid var(--line); border-radius: 9px; color: var(--ink); background: white;
}
.key-row { display: flex; gap: 6px; }
.key-row input { flex: 1; }
.check-row { display: flex; align-items: center; gap: 8px; margin-top: 9px; color: var(--muted); font-size: 0.78rem; cursor: pointer; }
.check-row input { accent-color: var(--green); }
.icon-button { display: none; border: 0; background: transparent; cursor: pointer; }
.privacy-note { display: grid; gap: 5px; margin-top: 20px; padding: 12px; border-radius: 10px; color: #6f541b; background: var(--amber-soft); font-size: 0.76rem; line-height: 1.45; }
.chat { min-height: 580px; overflow: hidden; }
.empty-state { display: grid; justify-items: center; padding: 58px 32px 42px; text-align: center; }
.empty-icon { display: grid; width: 52px; height: 52px; place-items: center; margin-bottom: 18px; border: 1px solid #bed3c6; border-radius: 16px; color: var(--green-dark); background: var(--green-soft); font-family: Georgia, serif; font-size: 1.6rem; }
.empty-state h2 { margin: 0; font-family: Georgia, serif; font-size: 1.55rem; font-weight: 500; }
.empty-state p { max-width: 540px; margin: 10px 0 22px; color: var(--muted); line-height: 1.55; }
.suggestions { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; }
.suggestions button { padding: 9px 12px; border: 1px solid var(--line); border-radius: 999px; background: white; cursor: pointer; transition: border-color 0.15s, transform 0.15s; }
.suggestions button:hover { border-color: var(--green); transform: translateY(-1px); }
.messages { display: flex; flex-direction: column; gap: 20px; padding: 30px 28px 8px; }
.message { display: grid; gap: 8px; }
.message-label { color: var(--muted); font-size: 0.72rem; font-weight: 750; letter-spacing: 0.08em; text-transform: uppercase; }
.message-user { align-self: end; width: min(82%, 680px); }
.message-user .message-body { padding: 13px 16px; border-radius: 16px 16px 4px 16px; color: white; background: var(--green-dark); white-space: pre-wrap; line-height: 1.55; }
.message-agent .message-body { padding: 18px; border: 1px solid var(--line); border-radius: 4px 16px 16px 16px; background: white; }
.message-agent.is-refused .message-body { border-color: #e2c377; background: #fffbef; }
.message-agent.is-uncertain .message-body, .message-error .message-body { border-color: #e4aaaa; background: var(--red-soft); }
.answer-text { white-space: pre-wrap; line-height: 1.68; overflow-wrap: anywhere; }
.status-line { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; margin-bottom: 14px; }
.badge { display: inline-flex; align-items: center; min-height: 24px; padding: 3px 8px; border-radius: 999px; color: var(--green-dark); background: var(--green-soft); font-size: 0.72rem; font-weight: 750; }
.badge-warning { color: #7b4c0b; background: var(--amber-soft); }
.badge-error { color: var(--red); background: var(--red-soft); }
.answer-meta { margin-top: 14px; color: var(--muted); font-size: 0.72rem; }
.source-panel, .technical-panel { margin-top: 16px; border-top: 1px solid var(--line); padding-top: 13px; }
details summary { color: var(--green-dark); font-size: 0.83rem; font-weight: 700; cursor: pointer; }
.source-list { display: grid; gap: 8px; margin-top: 10px; }
.source-card { padding: 10px 11px; border-radius: 9px; background: #f5f7f3; }
.source-id { color: var(--green-dark); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 0.75rem; font-weight: 800; }
.source-title { margin-top: 3px; font-size: 0.83rem; font-weight: 650; }
.source-meta { margin-top: 3px; color: var(--muted); font-size: 0.72rem; }
.conflict-box { margin-top: 14px; padding: 12px; border-left: 3px solid #d58a18; border-radius: 7px; background: var(--amber-soft); }
.conflict-box strong { display: block; margin-bottom: 5px; color: #744606; font-size: 0.78rem; }
.conflict-box p { margin: 0; color: #664d23; font-size: 0.82rem; line-height: 1.5; }
.follow-up { margin-top: 14px; padding: 12px; border-radius: 9px; background: var(--green-soft); }
.follow-up p { margin: 0 0 8px; font-size: 0.84rem; }
.query-list { margin: 8px 0 0; padding-left: 20px; color: var(--muted); font-size: 0.76rem; line-height: 1.5; }
.loading-row { display: flex; align-items: center; gap: 10px; color: var(--muted); }
.loader { width: 18px; height: 18px; border: 2px solid #cbd3cd; border-top-color: var(--green); border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.composer { margin: 22px 20px 0; padding: 12px; border: 1px solid #cbd2cc; border-radius: 14px; background: white; box-shadow: 0 8px 30px rgba(38, 52, 43, 0.07); }
.composer:focus-within { border-color: var(--green); box-shadow: 0 0 0 3px rgba(23, 107, 77, 0.1); }
.composer textarea { width: 100%; resize: vertical; border: 0; outline: 0; color: var(--ink); background: transparent; line-height: 1.5; }
.composer textarea::placeholder { color: #909a93; }
.composer-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 8px; }
.character-count { color: var(--muted); font-size: 0.69rem; }
.composer-actions { display: flex; gap: 8px; }
.primary, .secondary { min-height: 38px; padding: 8px 13px; border-radius: 9px; font-weight: 700; cursor: pointer; }
.primary { display: inline-flex; align-items: center; gap: 12px; border: 1px solid var(--green-dark); color: white; background: var(--green-dark); }
.primary:hover { background: var(--green); }
.primary:disabled { cursor: not-allowed; opacity: 0.55; }
.secondary { border: 1px solid var(--line); background: white; }
.secondary:hover { border-color: #9bac9f; }
.compact { min-height: auto; padding: 7px 9px; font-size: 0.72rem; }
.send-icon { font-size: 1.15rem; }
.footnote { margin: 12px 22px 20px; color: var(--muted); font-size: 0.7rem; text-align: center; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
[hidden] { display: none !important; }
@media (max-width: 800px) {
.shell { width: min(100% - 20px, 720px); }
.topbar { min-height: 68px; }
.brand small { display: none; }
.health span:last-child { max-width: 150px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.hero { padding: 42px 4px 26px; }
.workspace { grid-template-columns: 1fr; }
.settings { position: static; padding: 13px 16px; }
.settings-heading { margin: 0; }
.icon-button { display: block; }
#settings-body { margin-top: 14px; }
.chat { min-height: 520px; }
.empty-state { padding: 42px 18px 30px; }
.messages { padding: 22px 14px 6px; }
.message-user { width: 92%; }
.composer { margin: 18px 10px 0; }
.footnote { margin-inline: 14px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; }
}