root c6164d946c feat(model): wählbares LLM-Modell pro User & pro Job
Mac-Worker:
- /api/summarize, /api/protocol, /api/preload akzeptieren optionales
  model-Feld → überschreibt den .env-Default pro Call, kein Restart nötig
- /api/models listet die lokal verfügbaren Ollama-Modelle + Default

LXC:
- User.default_model + Job.model (Migration für SQLite)
- /api/auth/me liefert default_model, PATCH speichert ihn sofort
- /api/mac/models proxiet die Liste an die UI
- Pipeline reicht job.model an Mac-Worker durch; leer = Worker-Default
- create_job & reprocess akzeptieren model-Override
- Snapshot-Meta nutzt job.model (genauer als /health-Snapshot)

UI:
- Header-Dropdown "Modell:" neben "Profil:" — sofort-speichert User-Default
- Jeder Job zeigt sein Modell als Tag (lila); leer bedeutet Worker-Default
- Reprocess öffnet einen Modal-Picker (statt simplem confirm), Auswahl
  aller verfügbaren Modelle inkl. "Worker-Default"

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 16:48:11 +00:00

1107 lines
41 KiB
JavaScript

// ─── DOM-Refs ──────────────────────────────────────────────────────────────
const form = document.getElementById("upload-form");
const titleInput = document.getElementById("title");
const audioInput = document.getElementById("audio");
const submitBtn = document.getElementById("submit-btn");
const uploadProgress = document.getElementById("upload-progress");
const uploadBar = uploadProgress.querySelector(".bar-fill");
const uploadMsg = document.getElementById("upload-msg");
const jobsBody = document.getElementById("jobs-body");
const macStatus = document.getElementById("mac-status");
const appMain = document.getElementById("app");
const authOverlay = document.getElementById("auth-overlay");
const authTitle = document.getElementById("auth-title");
const authInfo = document.getElementById("auth-info");
const authForm = document.getElementById("auth-form");
const authUsername = document.getElementById("auth-username");
const authPassword = document.getElementById("auth-password");
const authSubmit = document.getElementById("auth-submit");
const authMsg = document.getElementById("auth-msg");
const userBar = document.getElementById("user-bar");
const userName = document.getElementById("user-name");
const userAdminBadge = document.getElementById("user-admin-badge");
const logoutBtn = document.getElementById("logout-btn");
const adminCard = document.getElementById("admin-card");
const userForm = document.getElementById("user-form");
const newUsername = document.getElementById("new-username");
const newPassword = document.getElementById("new-password");
const newIsAdmin = document.getElementById("new-is-admin");
const userMsg = document.getElementById("user-msg");
const usersBody = document.getElementById("users-body");
const profileLabel = document.getElementById("profile-label");
const profileSelect = document.getElementById("profile-select");
const defaultProfileName = document.getElementById("default-profile-name");
const headerProfileWrap = document.getElementById("header-profile-wrap");
const headerProfile = document.getElementById("header-profile");
const headerModelWrap = document.getElementById("header-model-wrap");
const headerModel = document.getElementById("header-model");
const jobsCards = document.getElementById("jobs-cards");
const adminToggle = document.getElementById("admin-toggle");
const adminCollapsible = document.getElementById("admin-card");
const recordToggle = document.getElementById("record-toggle");
const recordToggleLabel = document.getElementById("record-toggle-label");
const recordPane = document.getElementById("record-pane");
const recordTimer = document.getElementById("record-timer");
const recordStatus = document.getElementById("record-status");
const recPauseBtn = document.getElementById("rec-pause");
const recStopBtn = document.getElementById("rec-stop");
const recordResult = document.getElementById("record-result");
const recordFilename = document.getElementById("record-filename");
const recordDiscardBtn = document.getElementById("record-discard");
let currentUser = null; // { id, username, is_admin, default_profile, default_model }
let mode = "login"; // "login" | "setup"
let availableProfiles = []; // [{ name, display_name, language }, ...]
let availableModels = []; // [{ name, size, modified_at }, ...]
let workerDefaultModel = ""; // Mac-Worker .env-Default (Fallback wenn User nichts wählt)
const DOWNLOADS = [
{ kind: "pdf", label: "PDF", title: "Protokoll als PDF" },
{ kind: "docx", label: "DOCX", title: "Protokoll als Word-Dokument" },
{ kind: "protocol", label: "Protokoll", title: "Strukturiertes Protokoll (JSON)" },
{ kind: "summary", label: "Summary", title: "Zusammenfassung (JSON)" },
{ kind: "transcript", label: "Transkript", title: "Rohes Transkript (JSON)" },
];
// ─── Helpers ───────────────────────────────────────────────────────────────
function escapeHtml(s) {
return String(s ?? "").replace(/[&<>"']/g, (c) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
}[c]));
}
function fmtDate(s) {
const d = new Date(s);
return d.toLocaleString("de-DE", { dateStyle: "short", timeStyle: "medium" });
}
function fmtSecs(sec) {
if (!isFinite(sec) || sec < 0) return "?";
if (sec < 60) return `${Math.floor(sec)}s`;
const m = Math.floor(sec / 60), s = Math.floor(sec % 60);
return `${m}m ${s}s`;
}
const RUNNING_STATES = new Set(["queued", "transcribing", "summarizing", "protocolling", "exporting"]);
const DIAG_STATES = new Set([...RUNNING_STATES, "failed"]);
// Auf welchen Jobs darf das LLM mit aktuellem Mac-Modell neu angefordert werden?
const REPROCESS_STATES = new Set(["done", "failed"]);
// Offene Panels über Re-Renders hinweg merken — pro Panel eine Set-Instanz.
const openDiagIds = new Set();
const openRunsIds = new Set();
function liveTimingHtml(job) {
if (!RUNNING_STATES.has(job.status) || !job.step_started_at) return "";
const now = Date.now();
const stepSec = (now - new Date(job.step_started_at).getTime()) / 1000;
let parts = [`läuft seit ${fmtSecs(stepSec)}`];
if (job.last_heartbeat_at) {
const beatSec = (now - new Date(job.last_heartbeat_at).getTime()) / 1000;
const stale = beatSec > 10;
parts.push(`letzter Ping vor ${fmtSecs(beatSec)}${stale ? " ⚠" : ""}`);
}
return `<small class="muted live-timing">${parts.join(" · ")}</small>`;
}
function diagButtonHtml(job) {
if (!DIAG_STATES.has(job.status)) return "";
const open = openDiagIds.has(String(job.id));
return `<button type="button" class="btn-link btn-diag" data-id="${job.id}">Diagnose ${open ? "▴" : "▾"}</button>`;
}
function runsButtonHtml(job) {
if (!REPROCESS_STATES.has(job.status)) return "";
const open = openRunsIds.has(String(job.id));
return `<button type="button" class="btn-link btn-runs" data-id="${job.id}" title="Verlauf der LLM-Läufe (Snapshots) für diesen Job">Verlauf ${open ? "▴" : "▾"}</button>`;
}
function reprocessButtonHtml(job) {
if (!REPROCESS_STATES.has(job.status)) return "";
return `<button type="button" class="btn-link btn-reprocess" data-id="${job.id}" title="Mit dem aktuell auf dem Mac konfigurierten Ollama-Modell neu verarbeiten — bisheriges Ergebnis wird im Verlauf gesichert">↻ Mit anderem Modell</button>`;
}
async function api(path, opts = {}) {
const r = await fetch(path, {
credentials: "same-origin",
headers: { "Content-Type": "application/json", ...(opts.headers || {}) },
...opts,
});
return r;
}
// ─── Auth-Flow ─────────────────────────────────────────────────────────────
async function bootstrap() {
const me = await api("/api/auth/me");
if (me.ok) {
currentUser = await me.json();
enterApp();
return;
}
// Nicht eingeloggt — Setup nötig?
const setupCheck = await api("/api/auth/needs-setup");
const setupData = await setupCheck.json();
if (setupData.needs_setup) {
showAuth("setup");
} else {
showAuth("login");
}
}
function showAuth(which) {
mode = which;
authMsg.textContent = "";
authMsg.className = "msg";
authForm.reset();
if (which === "setup") {
authTitle.textContent = "Initialen Admin anlegen";
authInfo.textContent =
"Noch kein Benutzer vorhanden. Lege jetzt den ersten Admin an. Bestehende Jobs werden diesem Konto zugeordnet.";
authInfo.classList.remove("hidden");
authPassword.autocomplete = "new-password";
authPassword.minLength = 8;
authSubmit.textContent = "Admin anlegen";
} else {
authTitle.textContent = "Anmelden";
authInfo.classList.add("hidden");
authInfo.textContent = "";
authPassword.autocomplete = "current-password";
authPassword.minLength = 1;
authSubmit.textContent = "Anmelden";
}
authOverlay.classList.remove("hidden");
appMain.classList.add("hidden");
userBar.classList.add("hidden");
setTimeout(() => authUsername.focus(), 50);
}
function enterApp() {
authOverlay.classList.add("hidden");
appMain.classList.remove("hidden");
userBar.classList.remove("hidden");
userName.textContent = currentUser.username;
userAdminBadge.classList.toggle("hidden", !currentUser.is_admin);
adminCard.classList.toggle("hidden", !currentUser.is_admin);
loadProfiles().then(applyProfileUI);
loadModels().then(applyModelUI);
loadJobs();
if (currentUser.is_admin) loadUsers();
}
async function loadProfiles() {
try {
const r = await api("/api/profiles");
if (!r.ok) return;
const data = await r.json();
availableProfiles = data.profiles || [];
} catch (e) {
console.error("Profile laden fehlgeschlagen", e);
availableProfiles = [];
}
}
function profileLabelOf(name) {
const p = availableProfiles.find((x) => x.name === name);
return p ? p.display_name : name;
}
function applyProfileUI() {
// Upload-Dropdown und Header-Selector nur sichtbar wenn ≥2 Profile.
const showDropdown = availableProfiles.length > 1;
profileLabel.classList.toggle("hidden", !showDropdown);
headerProfileWrap.classList.toggle("hidden", !showDropdown);
for (const sel of [profileSelect, headerProfile]) {
sel.innerHTML = "";
availableProfiles.forEach((p) => {
const o = document.createElement("option");
o.value = p.name;
o.textContent = p.display_name;
sel.appendChild(o);
});
}
const def = currentUser.default_profile || "meeting";
profileSelect.value = def;
headerProfile.value = def;
defaultProfileName.textContent = profileLabelOf(def);
}
async function loadModels() {
try {
const r = await api("/api/mac/models");
const data = await r.json().catch(() => ({}));
availableModels = data.models || [];
workerDefaultModel = data.default || "";
} catch (e) {
console.error("Modelle laden fehlgeschlagen", e);
availableModels = [];
workerDefaultModel = "";
}
}
function modelLabelOf(name) {
if (!name) return workerDefaultModel ? `Standard (${workerDefaultModel})` : "Standard";
return name;
}
function applyModelUI() {
// Wenn der Mac keine Modelle meldet, Dropdown verstecken — wir wollen
// keinen leeren Selector zeigen, der dem User vortäuscht, er hätte Wahl.
const showDropdown = availableModels.length > 0;
headerModelWrap.classList.toggle("hidden", !showDropdown);
if (!showDropdown) return;
headerModel.innerHTML = "";
// Erste Option: kein Override → Worker-Default
const opt0 = document.createElement("option");
opt0.value = "";
opt0.textContent = workerDefaultModel
? `Worker-Default (${workerDefaultModel})`
: "Worker-Default";
headerModel.appendChild(opt0);
availableModels.forEach((m) => {
const o = document.createElement("option");
o.value = m.name;
const sizeGb = m.size ? `${(m.size / 1024 / 1024 / 1024).toFixed(1)} GB` : "";
o.textContent = `${m.name}${sizeGb}`;
headerModel.appendChild(o);
});
headerModel.value = currentUser.default_model || "";
}
headerModel.addEventListener("change", async () => {
const newDefault = headerModel.value;
headerModel.disabled = true;
try {
const r = await api("/api/auth/me", {
method: "PATCH",
body: JSON.stringify({ default_model: newDefault }),
});
if (!r.ok) throw new Error("save failed");
currentUser = await r.json();
} catch (e) {
alert("Konnte Standard-Modell nicht speichern.");
headerModel.value = currentUser.default_model || "";
} finally {
headerModel.disabled = false;
}
});
// Sofort-Speichern, sobald der User im Header das Standard-Profil ändert.
headerProfile.addEventListener("change", async () => {
const newDefault = headerProfile.value;
headerProfile.disabled = true;
try {
const r = await api("/api/auth/me", {
method: "PATCH",
body: JSON.stringify({ default_profile: newDefault }),
});
if (!r.ok) throw new Error("save failed");
currentUser = await r.json();
// Upload-Dropdown synchron mitziehen
profileSelect.value = currentUser.default_profile;
defaultProfileName.textContent = profileLabelOf(currentUser.default_profile);
} catch (e) {
alert("Konnte Standard-Profil nicht speichern.");
headerProfile.value = currentUser.default_profile || "meeting";
} finally {
headerProfile.disabled = false;
}
});
authForm.addEventListener("submit", async (ev) => {
ev.preventDefault();
authSubmit.disabled = true;
authMsg.textContent = "";
authMsg.className = "msg";
const body = JSON.stringify({
username: authUsername.value.trim(),
password: authPassword.value,
});
const url = mode === "setup" ? "/api/auth/setup" : "/api/auth/login";
try {
const r = await api(url, { method: "POST", body });
const data = await r.json();
if (!r.ok) {
authMsg.textContent = data.detail || "Fehler bei der Anmeldung";
authMsg.className = "msg err";
return;
}
currentUser = data;
if (mode === "setup" && typeof data.migrated_jobs === "number" && data.migrated_jobs > 0) {
console.info(`${data.migrated_jobs} Bestand-Jobs wurden zugeordnet.`);
}
enterApp();
} catch (e) {
authMsg.textContent = "Netzwerkfehler";
authMsg.className = "msg err";
} finally {
authSubmit.disabled = false;
}
});
logoutBtn.addEventListener("click", async () => {
await api("/api/auth/logout", { method: "POST" });
currentUser = null;
jobsBody.innerHTML = "";
showAuth("login");
});
// ─── Mac-Status ────────────────────────────────────────────────────────────
async function checkMac() {
try {
const r = await fetch("/api/mac/health");
const j = await r.json();
if (r.ok && j.reachable) {
macStatus.textContent = "Mac-Backend: online";
macStatus.classList.add("ok");
macStatus.classList.remove("err");
} else {
macStatus.textContent = "Mac-Backend: nicht erreichbar";
macStatus.classList.add("err");
macStatus.classList.remove("ok");
}
} catch {
macStatus.textContent = "Mac-Backend: Fehler";
macStatus.classList.add("err");
}
}
// ─── Jobs ──────────────────────────────────────────────────────────────────
function row(job) {
const tr = document.createElement("tr");
const title = job.title || job.original_name;
const dlCell = document.createElement("td");
dlCell.className = "dl";
DOWNLOADS.forEach(({ kind, label, title: tip }) => {
const a = document.createElement("a");
a.className = "btn-dl";
a.textContent = label;
a.title = tip;
a.href = `/api/jobs/${job.id}/download/${kind}`;
if (!job.has[kind]) {
a.classList.add("disabled");
a.removeAttribute("href");
a.title = `${tip} — noch nicht verfügbar`;
}
dlCell.appendChild(a);
});
const ownerLine = currentUser?.is_admin && job.owner_username
? `<br><small class="muted">von ${escapeHtml(job.owner_username)}</small>`
: "";
// Profile-Tag nur sichtbar wenn ≥2 Profile in der Auswahl ODER es ein anderes als "meeting" ist.
const showProfileTag = availableProfiles.length > 1 || (job.profile && job.profile !== "meeting");
const profileTag = showProfileTag
? `<span class="tag-profile" title="Verarbeitungs-Profil">${escapeHtml(profileLabelOf(job.profile || "meeting"))}</span>`
: "";
// Modell-Tag erscheint nur bei expliziter Auswahl — leeres job.model heißt
// "Worker-Default", und das wollen wir nicht jedem Job anhängen.
const modelTag = job.model
? `<span class="tag-model" title="LLM-Modell für diesen Job">${escapeHtml(job.model)}</span>`
: "";
const retryBtn = job.status !== "done"
? `<button type="button" class="btn-link btn-retry" data-id="${job.id}" title="Verarbeitung wiederholen (Transkript bleibt erhalten)">↻ Erneut versuchen</button>`
: "";
const timing = liveTimingHtml(job);
const statusCell = `
<span class="status-pill status-${job.status}">${escapeHtml(job.status)}</span>
${timing ? `<div class="status-timing">${timing}</div>` : ""}
${job.error ? `<div class="status-error" title="${escapeHtml(job.error)}">${escapeHtml(job.error)}</div>` : ""}
${retryBtn}
${reprocessButtonHtml(job)}
${diagButtonHtml(job)}
${runsButtonHtml(job)}
`;
tr.innerHTML = `
<td class="col-id">#${job.id}</td>
<td class="col-title">${escapeHtml(title)} ${profileTag} ${modelTag}<br><small class="muted">${escapeHtml(job.original_name)}</small>${ownerLine}</td>
<td class="col-status">${statusCell}</td>
<td class="col-progress"><div class="bar"><div class="bar-fill" style="width:${job.progress}%"></div></div><small class="muted">${job.progress}%</small></td>
<td class="col-updated"><small class="muted">${fmtDate(job.updated_at)}</small></td>
`;
tr.appendChild(dlCell);
return tr;
}
// Zweite Tabellenzeile direkt unter `tr`, in die das Diagnose-Panel rutscht.
function diagRow(job) {
const tr = document.createElement("tr");
tr.className = "diag-row hidden";
tr.dataset.diagId = String(job.id);
tr.innerHTML = `<td colspan="6"><div class="diag-content"><div class="muted">Lade Diagnose …</div></div></td>`;
return tr;
}
// Dritte Tabellenzeile für das Verlauf-Panel (vergangene LLM-Läufe).
function runsRow(job) {
const tr = document.createElement("tr");
tr.className = "runs-row hidden";
tr.dataset.runsId = String(job.id);
tr.innerHTML = `<td colspan="6"><div class="runs-content"><div class="muted">Lade Verlauf …</div></div></td>`;
return tr;
}
function card(job) {
const div = document.createElement("div");
div.className = "jc";
const title = job.title || job.original_name;
const showProfileTag = availableProfiles.length > 1 || (job.profile && job.profile !== "meeting");
const profileTag = showProfileTag
? `<span class="tag-profile">${escapeHtml(profileLabelOf(job.profile || "meeting"))}</span>`
: "";
const modelTag = job.model
? `<span class="tag-model" title="LLM-Modell">${escapeHtml(job.model)}</span>`
: "";
const ownerLine = currentUser?.is_admin && job.owner_username
? `<div class="jc-sub">von ${escapeHtml(job.owner_username)}</div>` : "";
const dlButtons = DOWNLOADS.map(({ kind, label, title: tip }) => {
const dis = !job.has[kind];
const href = dis ? "" : `href="/api/jobs/${job.id}/download/${kind}"`;
return `<a class="btn-dl ${dis ? "disabled" : ""}" ${href} title="${escapeHtml(tip)}">${label}</a>`;
}).join("");
const timing = liveTimingHtml(job);
div.innerHTML = `
<div class="jc-head">
<div>
<div class="jc-title">${escapeHtml(title)} ${profileTag} ${modelTag}</div>
<div class="jc-sub">${escapeHtml(job.original_name)} · <span class="jc-id">#${job.id}</span></div>
${ownerLine}
</div>
<span class="status-pill status-${job.status}">${escapeHtml(job.status)}</span>
</div>
<div class="jc-status-row">
<div class="jc-progress"><div class="bar"><div class="bar-fill" style="width:${job.progress}%"></div></div></div>
<small class="muted">${job.progress}%</small>
</div>
${timing ? `<div class="jc-timing">${timing}</div>` : ""}
${job.error ? `<div class="jc-error" title="${escapeHtml(job.error)}">${escapeHtml(job.error)}</div>` : ""}
<div class="jc-actions">
${job.status !== "done" ? `<button type="button" class="btn-link btn-retry" data-id="${job.id}">↻ Erneut versuchen</button>` : ""}
${reprocessButtonHtml(job)}
${diagButtonHtml(job)}
${runsButtonHtml(job)}
</div>
<div class="diag-panel hidden" data-diag-id="${job.id}">
<div class="diag-content"><div class="muted">Lade Diagnose …</div></div>
</div>
<div class="runs-panel hidden" data-runs-id="${job.id}">
<div class="runs-content"><div class="muted">Lade Verlauf …</div></div>
</div>
<div class="jc-sub">aktualisiert: ${escapeHtml(fmtDate(job.updated_at))}</div>
<div class="jc-dl">${dlButtons}</div>
`;
return div;
}
async function loadJobs() {
if (!currentUser) return;
try {
const r = await api("/api/jobs");
if (r.status === 401) { showAuth("login"); return; }
const jobs = await r.json();
jobsBody.innerHTML = "";
jobsCards.innerHTML = "";
if (!jobs.length) {
jobsBody.innerHTML = '<tr><td colspan="6" class="muted">Noch keine Jobs.</td></tr>';
jobsCards.innerHTML = '<div class="muted">Noch keine Jobs.</div>';
return;
}
jobs.forEach((j) => {
jobsBody.appendChild(row(j));
jobsBody.appendChild(diagRow(j));
jobsBody.appendChild(runsRow(j));
jobsCards.appendChild(card(j));
});
// Vorher offene Panels wieder aufklappen + frisch nachladen.
openDiagIds.forEach((id) => {
document.querySelectorAll(`[data-diag-id="${id}"]`).forEach((el) => el.classList.remove("hidden"));
refreshDiag(id);
});
openRunsIds.forEach((id) => {
document.querySelectorAll(`[data-runs-id="${id}"]`).forEach((el) => el.classList.remove("hidden"));
refreshRuns(id);
});
} catch (e) {
console.error(e);
}
}
function renderDiag(diag) {
const job = diag.job;
const lines = (diag.mac_log || []);
const stepInfo = diag.step_started_at
? `Stage <strong>${escapeHtml(job.status)}</strong> seit ${fmtSecs((Date.now() - new Date(diag.step_started_at).getTime()) / 1000)}`
: "kein Stage-Start gespeichert";
const beatInfo = diag.last_heartbeat_at
? `Letzter Heartbeat vor ${fmtSecs((Date.now() - new Date(diag.last_heartbeat_at).getTime()) / 1000)}`
: "noch kein Heartbeat";
const errBlock = job.error
? `<div class="diag-err"><strong>Fehler:</strong> ${escapeHtml(job.error)}</div>`
: "";
const logBlock = lines.length
? `<pre class="diag-log">${escapeHtml(lines.join("\n"))}</pre>`
: `<div class="muted">Keine Mac-Worker-Logzeilen für diesen Job (Buffer leer oder Mac nicht erreichbar).</div>`;
return `
<div class="diag-meta">
<div>${stepInfo}</div>
<div>${beatInfo}</div>
<div>Profil: <code>${escapeHtml(job.profile)}</code></div>
</div>
${errBlock}
<div class="diag-loghead">Mac-Worker-Log (letzte ${lines.length} Zeilen)</div>
${logBlock}
`;
}
function fmtBytes(n) {
if (!isFinite(n) || n < 0) return "?";
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / 1024 / 1024).toFixed(2)} MB`;
}
function fmtRunStamp(s) {
// run-IDs haben das Format YYYYMMDDTHHMMSSZ-<modell> — wir machen daraus eine
// lesbare Zeit (UTC → lokal).
const m = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z-/.exec(s || "");
if (!m) return s || "";
const [, y, mo, d, h, mi, se] = m;
const iso = `${y}-${mo}-${d}T${h}:${mi}:${se}Z`;
return new Date(iso).toLocaleString("de-DE", { dateStyle: "short", timeStyle: "medium" });
}
function renderRuns(jobId, data) {
const runs = data.runs || [];
if (!runs.length) {
return `<div class="muted">Noch keine alternativen Läufe gespeichert. Nutze „↻ Mit anderem Modell" um den aktuellen Mac-Modell-Output als neuen Lauf zu erzeugen — der bisherige bleibt dann im Verlauf erhalten.</div>`;
}
const blocks = runs.map((r) => {
const files = (r.files || []).map((f) => {
const url = `/api/jobs/${jobId}/runs/${encodeURIComponent(r.run_id)}/${encodeURIComponent(f.name)}`;
return `<a class="btn-dl" href="${url}" title="${escapeHtml(f.name)} (${fmtBytes(f.size)}) herunterladen">${escapeHtml(f.name)}</a>`;
}).join("");
return `
<div class="run-item">
<div class="run-head">
<span class="run-model"><code>${escapeHtml(r.model)}</code></span>
<span class="run-stamp">${escapeHtml(fmtRunStamp(r.run_id))}</span>
<span class="muted">Profil: <code>${escapeHtml(r.profile)}</code></span>
</div>
<div class="run-files">${files || '<span class="muted">(keine Dateien)</span>'}</div>
</div>`;
}).join("");
return `
<div class="runs-meta muted">${runs.length} ${runs.length === 1 ? "vergangener Lauf" : "vergangene Läufe"} — neuestes Ergebnis siehst du wie gewohnt im Job selbst.</div>
<div class="runs-list">${blocks}</div>
`;
}
async function refreshRuns(jobId) {
let data;
try {
const r = await api(`/api/jobs/${jobId}/runs`);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
data = await r.json();
} catch (e) {
document.querySelectorAll(`[data-runs-id="${jobId}"] .runs-content`).forEach((el) => {
el.innerHTML = `<div class="diag-err">Verlauf konnte nicht geladen werden: ${escapeHtml(e.message)}</div>`;
});
return;
}
const html = renderRuns(jobId, data);
document.querySelectorAll(`[data-runs-id="${jobId}"] .runs-content`).forEach((el) => {
el.innerHTML = html;
});
}
async function refreshDiag(jobId) {
let data;
try {
const r = await api(`/api/jobs/${jobId}/diag`);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
data = await r.json();
} catch (e) {
document.querySelectorAll(`[data-diag-id="${jobId}"] .diag-content`).forEach((el) => {
el.innerHTML = `<div class="diag-err">Diagnose konnte nicht geladen werden: ${escapeHtml(e.message)}</div>`;
});
return;
}
const html = renderDiag(data);
document.querySelectorAll(`[data-diag-id="${jobId}"] .diag-content`).forEach((el) => {
el.innerHTML = html;
});
}
// Diagnose-Toggle (Tabelle und Karten gemeinsam, Event-Delegation)
document.addEventListener("click", (ev) => {
const btn = ev.target.closest(".btn-diag");
if (!btn) return;
ev.preventDefault();
const id = btn.dataset.id;
const wasOpen = openDiagIds.has(id);
if (wasOpen) {
openDiagIds.delete(id);
document.querySelectorAll(`[data-diag-id="${id}"]`).forEach((el) => el.classList.add("hidden"));
btn.textContent = "Diagnose ▾";
} else {
openDiagIds.add(id);
document.querySelectorAll(`[data-diag-id="${id}"]`).forEach((el) => el.classList.remove("hidden"));
btn.textContent = "Diagnose ▴";
refreshDiag(id);
}
});
// Verlauf-Toggle (Snapshot-Liste vergangener LLM-Läufe)
document.addEventListener("click", (ev) => {
const btn = ev.target.closest(".btn-runs");
if (!btn) return;
ev.preventDefault();
const id = btn.dataset.id;
const wasOpen = openRunsIds.has(id);
if (wasOpen) {
openRunsIds.delete(id);
document.querySelectorAll(`[data-runs-id="${id}"]`).forEach((el) => el.classList.add("hidden"));
btn.textContent = "Verlauf ▾";
} else {
openRunsIds.add(id);
document.querySelectorAll(`[data-runs-id="${id}"]`).forEach((el) => el.classList.remove("hidden"));
btn.textContent = "Verlauf ▴";
refreshRuns(id);
}
});
form.addEventListener("submit", (ev) => {
ev.preventDefault();
// Quelle: Aufnahme bevorzugt, sonst File-Input
const file = recordedFile || (audioInput.files.length ? audioInput.files[0] : null);
if (!file) {
uploadMsg.textContent = "Bitte Datei wählen oder Aufnahme erstellen.";
uploadMsg.className = "msg err";
return;
}
const fd = new FormData();
fd.append("audio", file, file.name);
fd.append("title", titleInput.value);
// Profile mitgeben — leer = Server nimmt user.default_profile
fd.append("profile", profileSelect.value || "");
submitBtn.disabled = true;
uploadProgress.classList.remove("hidden");
uploadBar.style.width = "0%";
uploadMsg.textContent = "";
uploadMsg.className = "msg";
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/jobs");
xhr.withCredentials = true;
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
const p = Math.round((e.loaded / e.total) * 100);
uploadBar.style.width = p + "%";
}
};
xhr.onload = () => {
submitBtn.disabled = false;
uploadProgress.classList.add("hidden");
if (xhr.status === 401) { showAuth("login"); return; }
if (xhr.status >= 200 && xhr.status < 300) {
const j = JSON.parse(xhr.responseText);
uploadMsg.textContent = `Job #${j.id} erstellt. Verarbeitung läuft …`;
uploadMsg.className = "msg ok";
form.reset();
discardRecording();
loadJobs();
} else {
let err = "Upload fehlgeschlagen";
try { err = JSON.parse(xhr.responseText).detail || err; } catch {}
uploadMsg.textContent = err;
uploadMsg.className = "msg err";
}
};
xhr.onerror = () => {
submitBtn.disabled = false;
uploadProgress.classList.add("hidden");
uploadMsg.textContent = "Netzwerkfehler beim Upload";
uploadMsg.className = "msg err";
};
xhr.send(fd);
});
// ─── Admin: User-Verwaltung ────────────────────────────────────────────────
async function loadUsers() {
if (!currentUser?.is_admin) return;
try {
const r = await api("/api/admin/users");
if (!r.ok) return;
const users = await r.json();
usersBody.innerHTML = "";
users.forEach((u) => {
const tr = document.createElement("tr");
const isMe = u.id === currentUser.id;
tr.innerHTML = `
<td>#${u.id}</td>
<td><strong>${escapeHtml(u.username)}</strong>${isMe ? ' <span class="muted">(du)</span>' : ""}</td>
<td>${u.is_admin ? '<span class="badge admin-badge">Admin</span>' : '<span class="muted">User</span>'}</td>
<td><small class="muted">${fmtDate(u.created_at)}</small></td>
<td class="user-actions">
<button type="button" class="btn-link reset-pw" data-id="${u.id}" data-name="${escapeHtml(u.username)}">Passwort zurücksetzen</button>
${isMe ? "" : `<button type="button" class="btn-link btn-danger del-user" data-id="${u.id}" data-name="${escapeHtml(u.username)}">Löschen</button>`}
</td>
`;
usersBody.appendChild(tr);
});
} catch (e) {
console.error(e);
}
}
userForm.addEventListener("submit", async (ev) => {
ev.preventDefault();
userMsg.textContent = "";
userMsg.className = "msg";
const body = JSON.stringify({
username: newUsername.value.trim(),
password: newPassword.value,
is_admin: newIsAdmin.checked,
});
const r = await api("/api/admin/users", { method: "POST", body });
const data = await r.json();
if (!r.ok) {
userMsg.textContent = data.detail || "Fehler beim Anlegen";
userMsg.className = "msg err";
return;
}
userMsg.textContent = `Benutzer '${data.username}' angelegt.`;
userMsg.className = "msg ok";
userForm.reset();
loadUsers();
});
usersBody.addEventListener("click", async (ev) => {
const t = ev.target;
if (t.matches(".del-user")) {
if (!confirm(`Benutzer '${t.dataset.name}' wirklich löschen?\nDie Jobs des Benutzers bleiben erhalten (gehören dann keinem mehr).`)) return;
const r = await api(`/api/admin/users/${t.dataset.id}`, { method: "DELETE" });
if (!r.ok) {
const d = await r.json().catch(() => ({}));
alert(d.detail || "Löschen fehlgeschlagen");
}
loadUsers();
} else if (t.matches(".reset-pw")) {
const pw = prompt(`Neues Passwort für '${t.dataset.name}' (min. 8 Zeichen):`);
if (!pw) return;
if (pw.length < 8) { alert("Passwort muss min. 8 Zeichen haben"); return; }
const r = await api(`/api/admin/users/${t.dataset.id}/password`, {
method: "POST", body: JSON.stringify({ password: pw }),
});
if (!r.ok) {
const d = await r.json().catch(() => ({}));
alert(d.detail || "Zurücksetzen fehlgeschlagen");
} else {
alert("Passwort gesetzt.");
}
}
});
// ─── Audio-Aufnahme (MediaRecorder) ────────────────────────────────────────
let mediaRecorder = null;
let recordedChunks = [];
let recordedFile = null; // File-Objekt nach Stop
let recStream = null; // MediaStream zum sauberen Schließen
let recStartedAt = 0;
let recPausedAt = 0; // wenn paused: Zeitpunkt des Pausierens
let recElapsedBeforePause = 0; // aufaddierte Aufnahme-Sekunden vor letztem Resume
let timerInterval = null;
let wakeLock = null;
function pickMimeType() {
const candidates = [
"audio/webm;codecs=opus",
"audio/webm",
"audio/mp4", // Safari iOS
"audio/mpeg",
"", // Browser-Default
];
for (const m of candidates) {
if (m === "" || (window.MediaRecorder && MediaRecorder.isTypeSupported(m))) return m;
}
return "";
}
function extFromMime(mime) {
if (!mime) return "webm";
if (mime.includes("webm")) return "webm";
if (mime.includes("mp4")) return "m4a";
if (mime.includes("mpeg")) return "mp3";
if (mime.includes("ogg")) return "ogg";
return "webm";
}
function fmtTimer(s) {
const mm = String(Math.floor(s / 60)).padStart(2, "0");
const ss = String(Math.floor(s % 60)).padStart(2, "0");
return `${mm}:${ss}`;
}
function tickTimer() {
if (!mediaRecorder) return;
let elapsed = recElapsedBeforePause;
if (mediaRecorder.state === "recording") elapsed += (Date.now() - recStartedAt) / 1000;
recordTimer.textContent = fmtTimer(elapsed);
}
async function acquireWakeLock() {
try {
if ("wakeLock" in navigator) {
wakeLock = await navigator.wakeLock.request("screen");
}
} catch { /* nicht kritisch */ }
}
async function releaseWakeLock() {
try { if (wakeLock) await wakeLock.release(); } catch {}
wakeLock = null;
}
async function startRecording() {
if (!navigator.mediaDevices?.getUserMedia) {
alert("Dein Browser unterstützt kein Mikrofon — oder die Seite läuft nicht über HTTPS.");
return;
}
try {
recStream = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
});
} catch (e) {
alert("Mikrofon-Zugriff verweigert oder fehlgeschlagen: " + e.message);
return;
}
const mime = pickMimeType();
recordedChunks = [];
try {
mediaRecorder = mime ? new MediaRecorder(recStream, { mimeType: mime }) : new MediaRecorder(recStream);
} catch (e) {
alert("MediaRecorder konnte nicht starten: " + e.message);
cleanupStream();
return;
}
mediaRecorder.ondataavailable = (e) => { if (e.data && e.data.size > 0) recordedChunks.push(e.data); };
mediaRecorder.onstop = () => {
const type = mediaRecorder.mimeType || mime || "audio/webm";
const ext = extFromMime(type);
const blob = new Blob(recordedChunks, { type });
const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
recordedFile = new File([blob], `aufnahme-${ts}.${ext}`, { type });
onRecordingFinalized();
};
mediaRecorder.start(1000); // alle 1s ein dataavailable-Event
recStartedAt = Date.now();
recPausedAt = 0;
recElapsedBeforePause = 0;
recordTimer.textContent = "00:00";
recordStatus.textContent = "Aufnahme läuft";
recPauseBtn.textContent = "Pause";
recordPane.classList.remove("hidden");
recordResult.classList.add("hidden");
recordToggle.classList.add("hidden");
audioInput.value = ""; // konkurrierende Datei-Auswahl löschen
timerInterval = setInterval(tickTimer, 250);
acquireWakeLock();
}
function pauseRecording() {
if (!mediaRecorder) return;
if (mediaRecorder.state === "recording") {
mediaRecorder.pause();
recElapsedBeforePause += (Date.now() - recStartedAt) / 1000;
recPausedAt = Date.now();
recordStatus.textContent = "Pausiert";
recPauseBtn.textContent = "Fortsetzen";
} else if (mediaRecorder.state === "paused") {
mediaRecorder.resume();
recStartedAt = Date.now();
recordStatus.textContent = "Aufnahme läuft";
recPauseBtn.textContent = "Pause";
}
}
function stopRecording() {
if (!mediaRecorder) return;
if (mediaRecorder.state !== "inactive") mediaRecorder.stop();
if (timerInterval) { clearInterval(timerInterval); timerInterval = null; }
cleanupStream();
releaseWakeLock();
}
function cleanupStream() {
if (recStream) {
recStream.getTracks().forEach((t) => t.stop());
recStream = null;
}
}
function onRecordingFinalized() {
recordPane.classList.add("hidden");
recordResult.classList.remove("hidden");
recordToggle.classList.remove("hidden");
recordToggleLabel.textContent = "Neue Aufnahme starten";
const sizeKB = Math.round(recordedFile.size / 1024);
recordFilename.textContent = `${recordedFile.name} (${sizeKB} KB)`;
}
function discardRecording() {
recordedFile = null;
recordedChunks = [];
recordResult.classList.add("hidden");
recordToggleLabel.textContent = "Aufnahme starten";
recordFilename.textContent = "";
}
recordToggle.addEventListener("click", startRecording);
recPauseBtn.addEventListener("click", pauseRecording);
recStopBtn.addEventListener("click", stopRecording);
recordDiscardBtn.addEventListener("click", discardRecording);
// Admin-Toggle (klappbar auf Mobile, immer offen auf Desktop)
adminToggle.addEventListener("click", () => {
if (window.matchMedia("(max-width: 700px)").matches) {
adminCollapsible.classList.toggle("open");
}
});
// Retry-Button: funktioniert für Tabelle und Karten gleichzeitig (Event-Delegation)
document.addEventListener("click", async (ev) => {
const btn = ev.target.closest(".btn-retry");
if (!btn) return;
ev.preventDefault();
const id = btn.dataset.id;
if (!confirm("Verarbeitung erneut starten?\nDas Transkript bleibt erhalten — nur Zusammenfassung, Protokoll und DOCX werden neu erzeugt.")) return;
btn.disabled = true;
btn.textContent = "wird gestartet …";
try {
const r = await api(`/api/jobs/${id}/retry`, { method: "POST" });
if (!r.ok) {
const d = await r.json().catch(() => ({}));
alert(d.detail || "Konnte nicht neu starten.");
return;
}
loadJobs();
} catch (e) {
alert("Netzwerkfehler beim Neustart.");
}
});
function askModelChoice(jobId, currentJobModel) {
// Minimal-Modal mit nativem <dialog>. Resolved auf `null` (Abbruch) oder
// den Modellnamen ("" = Worker-Default).
return new Promise((resolve) => {
if (!availableModels.length) {
// Wenn Mac keine Liste meldet → klassischer Fallback-Bestätigungsdialog.
const ok = confirm(
`Job #${jobId} erneut verarbeiten?\n\n` +
`Bisheriges Ergebnis wird im Verlauf gesichert. Das Transkript bleibt unverändert.`
);
resolve(ok ? (currentJobModel || "") : null);
return;
}
const dlg = document.createElement("dialog");
dlg.className = "model-picker";
const opts = [`<option value="">— Worker-Default${workerDefaultModel ? ` (${escapeHtml(workerDefaultModel)})` : ""} —</option>`]
.concat(availableModels.map((m) => {
const sel = m.name === currentJobModel ? "selected" : "";
const sz = m.size ? `${(m.size / 1024 / 1024 / 1024).toFixed(1)} GB` : "";
return `<option value="${escapeHtml(m.name)}" ${sel}>${escapeHtml(m.name)}${sz}</option>`;
})).join("");
const hint = currentJobModel
? `Bisher: <code>${escapeHtml(currentJobModel)}</code>`
: `Bisher: <code>(Worker-Default)</code>`;
dlg.innerHTML = `
<form method="dialog">
<h3>Job #${jobId} mit anderem Modell verarbeiten</h3>
<p class="muted">${hint}. Bisheriges Ergebnis wird im Verlauf gesichert, Transkript bleibt erhalten.</p>
<label class="model-picker-label">Modell für den nächsten Lauf
<select name="model">${opts}</select>
</label>
<div class="dlg-actions">
<button type="submit" value="cancel">Abbrechen</button>
<button type="submit" value="ok" class="primary">Starten</button>
</div>
</form>
`;
document.body.appendChild(dlg);
dlg.addEventListener("close", () => {
const action = dlg.returnValue;
const sel = dlg.querySelector("select[name=model]");
const value = sel ? sel.value : "";
dlg.remove();
resolve(action === "ok" ? value : null);
});
dlg.showModal();
});
}
// Reprocess-Button: erlaubt LLM-Lauf auch auf done/failed-Jobs. Öffnet einen
// Modell-Picker — Snapshot des bisherigen Outputs wird automatisch gesichert.
document.addEventListener("click", async (ev) => {
const btn = ev.target.closest(".btn-reprocess");
if (!btn) return;
ev.preventDefault();
const id = btn.dataset.id;
// Aktuelles job.model aus der gerenderten Zeile lesen (Tag-Inhalt) —
// notfalls aus letztem Job-Render-Stand greifen. Simpel: nochmal /api/jobs/{id}.
let currentJobModel = "";
try {
const r = await api(`/api/jobs/${id}`);
if (r.ok) currentJobModel = (await r.json()).model || "";
} catch {}
const chosen = await askModelChoice(id, currentJobModel);
if (chosen === null) return; // Abbruch
btn.disabled = true;
const oldText = btn.textContent;
btn.textContent = "wird gestartet …";
try {
const r = await api(`/api/jobs/${id}/reprocess`, {
method: "POST",
body: JSON.stringify({ model: chosen }),
});
if (!r.ok) {
const d = await r.json().catch(() => ({}));
alert(d.detail || "Konnte nicht neu verarbeiten.");
btn.disabled = false;
btn.textContent = oldText;
return;
}
openRunsIds.add(String(id));
loadJobs();
} catch (e) {
alert("Netzwerkfehler beim Reprocess.");
btn.disabled = false;
btn.textContent = oldText;
}
});
// ─── Init ──────────────────────────────────────────────────────────────────
bootstrap();
checkMac();
setInterval(() => { if (currentUser) loadJobs(); }, 3000);
setInterval(checkMac, 15000);