Files
bmp-project/packages/summarizer/index.html
2026-06-17 14:19:30 +02:00

296 lines
11 KiB
HTML

<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Summarizer</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0d0d0d; --surface: #161616; --border: #2a2a2a;
--text: #e0e0e0; --muted: #666; --accent: #2563eb; --green: #16a34a; --red: #dc2626;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 14px;
}
body { background: var(--bg); color: var(--text); min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px; }
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 28px; width: 100%; max-width: 520px; display: flex; flex-direction: column; gap: 18px; }
h1 { font-size: 15px; font-weight: 600; }
.sub { font-size: 12px; color: var(--muted); }
label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 6px; }
select, input[type="text"] {
width: 100%; padding: 8px 10px; border-radius: 6px;
border: 1px solid var(--border); background: var(--bg);
color: var(--text); font-size: 13px; outline: none;
transition: border-color 0.12s;
}
select:focus, input[type="text"]:focus { border-color: var(--accent); }
.field { display: flex; flex-direction: column; }
.notice {
border: 1px solid var(--border); border-radius: 6px; padding: 10px 12px;
background: rgba(37,99,235,0.08); color: var(--text); font-size: 12px; line-height: 1.45;
}
.notice strong { display: block; font-size: 12px; margin-bottom: 3px; }
.notice span { color: var(--muted); }
button {
padding: 8px 16px; border-radius: 6px; border: none; background: var(--accent);
color: #fff; font-size: 13px; cursor: pointer; transition: background 0.12s;
}
button:hover:not(:disabled) { background: #1d4ed8; }
button:disabled { opacity: 0.4; cursor: not-allowed; }
.log {
background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
padding: 14px; font-family: "SF Mono", "Fira Mono", monospace; font-size: 11px;
white-space: pre-wrap; word-break: break-all; max-height: 280px; overflow-y: auto;
color: var(--text); display: none;
}
.log.visible { display: block; }
.log.error { border-color: var(--red); color: #f87171; }
.log.success { border-color: var(--green); }
.status { font-size: 12px; color: var(--muted); min-height: 16px; }
.progress { display: none; flex-direction: column; gap: 8px; }
.progress.visible { display: flex; }
.progress-bar {
width: 100%; height: 10px; border-radius: 999px; overflow: hidden;
background: var(--bg); border: 1px solid var(--border);
}
.progress-fill {
height: 100%; width: 0%; background: var(--accent); transition: width 0.2s ease;
}
.progress-meta { font-size: 12px; color: var(--muted); display: flex; justify-content: space-between; gap: 10px; }
.cost {
display: none;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
border: 1px solid var(--border);
border-radius: 6px;
padding: 10px;
background: var(--bg);
}
.cost.visible { display: grid; }
.cost div { min-width: 0; }
.cost .label { color: var(--muted); font-size: 11px; margin-bottom: 4px; }
.cost .value { color: var(--text); font-size: 13px; font-weight: 700; overflow-wrap: anywhere; }
.downloads { display: none; gap: 8px; flex-wrap: wrap; }
.downloads.visible { display: flex; }
.downloads a {
color: #fff; background: var(--accent); border-radius: 6px;
padding: 7px 10px; font-size: 12px; text-decoration: none;
}
.downloads a:hover { background: #1d4ed8; }
.downloads .group {
width: 100%; color: var(--muted); font-size: 11px;
margin-top: 4px;
}
</style>
</head>
<body>
<div class="card">
<div>
<h1>Summarizer</h1>
<p class="sub">Fasst Bewerbungsantworten aus extrahierten JSON-Dateien via LLM zusammen.</p>
</div>
<div class="notice">
<strong>Datenschutz</strong>
<span>LLM-Eingaben werden vor dem Versand minimiert und pseudonymisiert. OpenRouter-Anfragen fordern ZDR-Routing und data_collection: deny an.</span>
</div>
<div class="field">
<label for="company-select">Unternehmen</label>
<select id="company-select">
<option value="">Lade Unternehmen …</option>
</select>
</div>
<button id="run-btn" disabled>Zusammenfassung erstellen</button>
<div class="progress" id="progress">
<div class="progress-bar"><div class="progress-fill" id="progress-fill"></div></div>
<div class="progress-meta">
<span id="progress-text">0 / 0</span>
<span id="progress-current"></span>
</div>
</div>
<p class="status" id="status"></p>
<div class="cost" id="cost">
<div><div class="label">OpenRouter-Kosten</div><div class="value" id="cost-value">-</div></div>
<div><div class="label">Tokens</div><div class="value" id="tokens-value">-</div></div>
<div><div class="label">LLM-Aufrufe</div><div class="value" id="calls-value">-</div></div>
</div>
<div class="downloads" id="downloads"></div>
<pre class="log" id="log"></pre>
</div>
<script type="module">
const select = document.getElementById("company-select");
const runBtn = document.getElementById("run-btn");
const statusEl = document.getElementById("status");
const logEl = document.getElementById("log");
const progressEl = document.getElementById("progress");
const progressFill = document.getElementById("progress-fill");
const progressText = document.getElementById("progress-text");
const progressCurrent = document.getElementById("progress-current");
const downloadsEl = document.getElementById("downloads");
const costEl = document.getElementById("cost");
const costValue = document.getElementById("cost-value");
const tokensValue = document.getElementById("tokens-value");
const callsValue = document.getElementById("calls-value");
function csrfHeaders(extra = {}) {
const csrf = document.cookie
.split("; ")
.find((part) => part.startsWith("bmp_demo_csrf="))
?.slice("bmp_demo_csrf=".length);
return csrf ? { ...extra, "X-BMP-CSRF": decodeURIComponent(csrf) } : extra;
}
function renderProgress(job) {
const total = job.total || 0;
const completed = job.completed || 0;
const percent = total ? Math.round((completed / total) * 100) : 0;
progressEl.classList.add("visible");
progressFill.style.width = `${percent}%`;
progressText.textContent = `${completed} / ${total}`;
progressCurrent.textContent = job.current ? `Aktuell: ${job.current}` : "";
renderUsage(job.usage);
}
function renderUsage(usage) {
if (!usage || !usage.calls) {
costEl.classList.remove("visible");
return;
}
costValue.textContent = `${formatCost(usage.cost)} credits`;
tokensValue.textContent = `${formatInteger(usage.totalTokens)} gesamt`;
callsValue.textContent = `${formatInteger(usage.calls)}`;
costEl.classList.add("visible");
}
function formatCost(value) {
const number = Number(value || 0);
if (!number) return "0";
return number < 0.0001 ? number.toExponential(2) : number.toFixed(6).replace(/0+$/, "").replace(/\.$/, "");
}
function formatInteger(value) {
return new Intl.NumberFormat("de-DE").format(Number(value || 0));
}
async function waitForJob(jobId) {
for (;;) {
const res = await fetch(`/api/summarizer/jobs/${jobId}`);
const job = await res.json();
if (!res.ok) throw new Error(job.error || "Job konnte nicht geladen werden");
renderProgress(job);
if (job.status === "done") return job;
if (job.status === "cancelled") throw new Error("Job wurde abgebrochen");
if (job.status === "error") throw new Error(job.errors?.join("\n") || "Job fehlgeschlagen");
await new Promise((resolve) => setTimeout(resolve, 700));
}
}
async function loadCompanies() {
try {
const res = await fetch("/api/summarizer/companies");
const { companies } = await res.json();
select.innerHTML = "";
if (!companies.length) {
select.innerHTML = '<option value="">Keine Unternehmen gefunden</option>';
return;
}
const all = document.createElement("option");
all.value = "__all__";
all.textContent = "Alle Unternehmen";
select.appendChild(all);
for (const c of companies) {
const opt = document.createElement("option");
opt.value = c;
opt.textContent = c;
select.appendChild(opt);
}
runBtn.disabled = false;
} catch (e) {
statusEl.textContent = "Fehler beim Laden der Unternehmen: " + e.message;
}
}
function renderDownloads(downloads) {
downloadsEl.innerHTML = "";
if (!downloads?.length) {
downloadsEl.className = "downloads";
return;
}
let previousCompany = "";
for (const file of downloads) {
if (file.company && file.company !== previousCompany) {
previousCompany = file.company;
const group = document.createElement("div");
group.className = "group";
group.textContent = file.company;
downloadsEl.appendChild(group);
}
const link = document.createElement("a");
link.href = file.url;
link.textContent = file.label;
downloadsEl.appendChild(link);
}
downloadsEl.className = "downloads visible";
}
runBtn.addEventListener("click", async () => {
const stem = select.value;
if (!stem) return;
runBtn.disabled = true;
logEl.textContent = "";
logEl.className = "log visible";
downloadsEl.innerHTML = "";
downloadsEl.className = "downloads";
costEl.className = "cost";
progressEl.className = "progress visible";
progressFill.style.width = "0%";
progressText.textContent = "0 / 0";
progressCurrent.textContent = "";
statusEl.textContent = "Starte Zusammenfassung …";
try {
const res = await fetch("/api/summarizer/summarize", {
method: "POST",
headers: csrfHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ stem }),
});
const data = await res.json();
if (!data.ok) {
logEl.textContent = data.error ?? "Unbekannter Fehler";
logEl.className = "log visible error";
statusEl.textContent = "Fehler beim Starten.";
return;
}
statusEl.textContent = "Job läuft …";
const job = await waitForJob(data.jobId);
renderUsage(job.usage);
renderDownloads(job.downloads);
logEl.textContent = "Ausgabe bereit.";
logEl.className = "log visible success";
statusEl.textContent = "Fertig.";
} catch (e) {
logEl.textContent = String(e);
logEl.className = "log visible error";
statusEl.textContent = "Fehler beim Zusammenfassen.";
} finally {
runBtn.disabled = false;
}
});
loadCompanies();
</script>
</body>
</html>