296 lines
11 KiB
HTML
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>
|