// ─── 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'", }[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 `${parts.join(" · ")}`; } function diagButtonHtml(job) { if (!DIAG_STATES.has(job.status)) return ""; const open = openDiagIds.has(String(job.id)); return ``; } function runsButtonHtml(job) { if (!REPROCESS_STATES.has(job.status)) return ""; const open = openRunsIds.has(String(job.id)); return ``; } function reprocessButtonHtml(job) { if (!REPROCESS_STATES.has(job.status)) return ""; return ``; } 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 ? `
von ${escapeHtml(job.owner_username)}` : ""; // 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 ? `${escapeHtml(profileLabelOf(job.profile || "meeting"))}` : ""; // 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 ? `${escapeHtml(job.model)}` : ""; const retryBtn = job.status !== "done" ? `` : ""; const timing = liveTimingHtml(job); const statusCell = ` ${escapeHtml(job.status)} ${timing ? `
${timing}
` : ""} ${job.error ? `
${escapeHtml(job.error)}
` : ""} ${retryBtn} ${reprocessButtonHtml(job)} ${diagButtonHtml(job)} ${runsButtonHtml(job)} `; tr.innerHTML = ` #${job.id} ${escapeHtml(title)} ${profileTag} ${modelTag}
${escapeHtml(job.original_name)}${ownerLine} ${statusCell}
${job.progress}% ${fmtDate(job.updated_at)} `; 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 = `
Lade Diagnose …
`; 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 = `
Lade Verlauf …
`; 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 ? `${escapeHtml(profileLabelOf(job.profile || "meeting"))}` : ""; const modelTag = job.model ? `${escapeHtml(job.model)}` : ""; const ownerLine = currentUser?.is_admin && job.owner_username ? `
von ${escapeHtml(job.owner_username)}
` : ""; const dlButtons = DOWNLOADS.map(({ kind, label, title: tip }) => { const dis = !job.has[kind]; const href = dis ? "" : `href="/api/jobs/${job.id}/download/${kind}"`; return `${label}`; }).join(""); const timing = liveTimingHtml(job); div.innerHTML = `
${escapeHtml(title)} ${profileTag} ${modelTag}
${escapeHtml(job.original_name)} · #${job.id}
${ownerLine}
${escapeHtml(job.status)}
${job.progress}%
${timing ? `
${timing}
` : ""} ${job.error ? `
${escapeHtml(job.error)}
` : ""}
${job.status !== "done" ? `` : ""} ${reprocessButtonHtml(job)} ${diagButtonHtml(job)} ${runsButtonHtml(job)}
aktualisiert: ${escapeHtml(fmtDate(job.updated_at))}
${dlButtons}
`; 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 = 'Noch keine Jobs.'; jobsCards.innerHTML = '
Noch keine Jobs.
'; 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 ${escapeHtml(job.status)} 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 ? `
Fehler: ${escapeHtml(job.error)}
` : ""; const logBlock = lines.length ? `
${escapeHtml(lines.join("\n"))}
` : `
Keine Mac-Worker-Logzeilen für diesen Job (Buffer leer oder Mac nicht erreichbar).
`; return `
${stepInfo}
${beatInfo}
Profil: ${escapeHtml(job.profile)}
${errBlock}
Mac-Worker-Log (letzte ${lines.length} Zeilen)
${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- — 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 `
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.
`; } 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 `${escapeHtml(f.name)}`; }).join(""); return `
${escapeHtml(r.model)} ${escapeHtml(fmtRunStamp(r.run_id))} Profil: ${escapeHtml(r.profile)}
${files || '(keine Dateien)'}
`; }).join(""); return `
${runs.length} ${runs.length === 1 ? "vergangener Lauf" : "vergangene Läufe"} — neuestes Ergebnis siehst du wie gewohnt im Job selbst.
${blocks}
`; } 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 = `
Verlauf konnte nicht geladen werden: ${escapeHtml(e.message)}
`; }); 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 = `
Diagnose konnte nicht geladen werden: ${escapeHtml(e.message)}
`; }); 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 = ` #${u.id} ${escapeHtml(u.username)}${isMe ? ' (du)' : ""} ${u.is_admin ? 'Admin' : 'User'} ${fmtDate(u.created_at)} ${isMe ? "" : ``} `; 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 . 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 = [``] .concat(availableModels.map((m) => { const sel = m.name === currentJobModel ? "selected" : ""; const sz = m.size ? ` — ${(m.size / 1024 / 1024 / 1024).toFixed(1)} GB` : ""; return ``; })).join(""); const hint = currentJobModel ? `Bisher: ${escapeHtml(currentJobModel)}` : `Bisher: (Worker-Default)`; dlg.innerHTML = `

Job #${jobId} mit anderem Modell verarbeiten

${hint}. Bisheriges Ergebnis wird im Verlauf gesichert, Transkript bleibt erhalten.

`; 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);