// ─── 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");
// Settings-Subpage
const settingsBtn = document.getElementById("settings-btn");
const settingsView = document.getElementById("settings-view");
const settingsBackBtn = document.getElementById("settings-back");
const settingsUsername = document.getElementById("settings-username");
const settingsProfile = document.getElementById("settings-profile");
const settingsProfileMsg = document.getElementById("settings-profile-msg");
const settingsModelSection = document.getElementById("settings-model-section");
const settingsModel = document.getElementById("settings-model");
const settingsModelMsg = document.getElementById("settings-model-msg");
const settingsPwForm = document.getElementById("settings-pw-form");
const pwCurrent = document.getElementById("pw-current");
const pwNew = document.getElementById("pw-new");
const pwConfirm = document.getElementById("pw-confirm");
const pwSaveBtn = document.getElementById("pw-save");
const settingsPwMsg = document.getElementById("settings-pw-msg");
const mainView = document.getElementById("main-view");
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);
const now = new Date();
const sameDay = d.toDateString() === now.toDateString();
if (sameDay) {
return d.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
}
return d.toLocaleString("de-DE", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" });
}
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");
// Falls vorher in Settings → wieder Main zeigen
settingsView.classList.add("hidden");
mainView.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 nur sichtbar wenn ≥2 Profile (sonst sinnlos).
const showDropdown = availableProfiles.length > 1;
profileLabel.classList.toggle("hidden", !showDropdown);
for (const sel of [profileSelect, settingsProfile]) {
if (!sel) continue;
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;
settingsProfile.value = 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, Section verstecken — sonst leerer Selector.
const showDropdown = availableModels.length > 0;
settingsModelSection.classList.toggle("hidden", !showDropdown);
if (!showDropdown) return;
settingsModel.innerHTML = "";
const opt0 = document.createElement("option");
opt0.value = "";
opt0.textContent = workerDefaultModel
? `Worker-Default (${workerDefaultModel})`
: "Worker-Default";
settingsModel.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}`;
settingsModel.appendChild(o);
});
settingsModel.value = currentUser.default_model || "";
}
// ─── Settings-Subpage: Toggle + Save-Handler ────────────────────────────────
function showSettings() {
settingsUsername.textContent = currentUser.username;
applyProfileUI();
applyModelUI();
settingsProfileMsg.textContent = "";
settingsModelMsg.textContent = "";
settingsPwMsg.textContent = "";
settingsPwForm.reset();
mainView.classList.add("hidden");
settingsView.classList.remove("hidden");
}
function hideSettings() {
settingsView.classList.add("hidden");
mainView.classList.remove("hidden");
}
function flashMsg(el, text, kind) {
el.textContent = text;
el.className = `msg ${kind}`;
if (kind === "ok") setTimeout(() => { if (el.textContent === text) el.textContent = ""; }, 3000);
}
settingsBtn.addEventListener("click", (ev) => { ev.preventDefault(); showSettings(); });
settingsBackBtn.addEventListener("click", (ev) => { ev.preventDefault(); hideSettings(); });
// Default-Profile: sofort speichern bei change.
settingsProfile.addEventListener("change", async () => {
const v = settingsProfile.value;
settingsProfile.disabled = true;
try {
const r = await api("/api/auth/me", {
method: "PATCH",
body: JSON.stringify({ default_profile: v }),
});
if (!r.ok) throw new Error("save failed");
currentUser = await r.json();
profileSelect.value = currentUser.default_profile;
flashMsg(settingsProfileMsg, `Standard-Profil ist jetzt: ${profileLabelOf(currentUser.default_profile)}`, "ok");
} catch (e) {
flashMsg(settingsProfileMsg, "Konnte Standard-Profil nicht speichern.", "err");
settingsProfile.value = currentUser.default_profile || "meeting";
} finally {
settingsProfile.disabled = false;
}
});
// Default-Modell: sofort speichern bei change.
settingsModel.addEventListener("change", async () => {
const v = settingsModel.value;
settingsModel.disabled = true;
try {
const r = await api("/api/auth/me", {
method: "PATCH",
body: JSON.stringify({ default_model: v }),
});
if (!r.ok) throw new Error("save failed");
currentUser = await r.json();
flashMsg(settingsModelMsg,
`Standard-Modell ist jetzt: ${currentUser.default_model || "(Worker-Default)"}`, "ok");
} catch (e) {
flashMsg(settingsModelMsg, "Konnte Standard-Modell nicht speichern.", "err");
settingsModel.value = currentUser.default_model || "";
} finally {
settingsModel.disabled = false;
}
});
// Passwort-Form: Bestätigung clientseitig prüfen, dann an /me/password.
settingsPwForm.addEventListener("submit", async (ev) => {
ev.preventDefault();
if (pwNew.value !== pwConfirm.value) {
flashMsg(settingsPwMsg, "Neue Passwörter stimmen nicht überein.", "err");
pwConfirm.focus();
return;
}
pwSaveBtn.disabled = true;
try {
const r = await api("/api/auth/me/password", {
method: "POST",
body: JSON.stringify({
current_password: pwCurrent.value,
new_password: pwNew.value,
}),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) {
flashMsg(settingsPwMsg, d.detail || "Konnte Passwort nicht ändern.", "err");
return;
}
flashMsg(settingsPwMsg, "Passwort geändert.", "ok");
settingsPwForm.reset();
} catch (e) {
flashMsg(settingsPwMsg, "Netzwerkfehler beim Speichern.", "err");
} finally {
pwSaveBtn.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");
// Wenn kein Titel gesetzt ist, zeigen wir den Dateinamen nur EINMAL als Titel —
// sonst doppelt (großer Titel + kleine muted-Zeile) und unnötig hässlich.
const hasRealTitle = !!job.title;
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)}
`;
// Dateiname nur dann als zweite Zeile, wenn er sich vom angezeigten Titel unterscheidet.
const fileLine = hasRealTitle
? `
${escapeHtml(job.original_name)}
`
: "";
tr.innerHTML = `
#${job.id}
${escapeHtml(title)}
${profileTag}${modelTag}
${fileLine}
${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 = `
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.