feat(jobs): "Mit anderem Modell verarbeiten" + Verlauf pro Job
Neuer Endpoint POST /api/jobs/{id}/reprocess: erlaubt einen LLM-Lauf
auch auf done/failed-Jobs, sichert Summary/Protokoll/DOCX/PDF in
results/<id>/runs/<ts>-<modell>/ als Snapshot, leert die DB-Pfade,
startet die Pipeline mit dem aktuell auf dem Mac konfigurierten
Ollama-Modell. Transkript bleibt erhalten.
GET /api/jobs/{id}/runs liefert die Snapshot-Liste,
GET /api/jobs/{id}/runs/{run_id}/{file} den Einzeldownload.
UI: zwei neue Buttons auf done/failed-Jobs — "↻ Mit anderem Modell"
und "Verlauf ▾". Reprocess-Dialog zeigt das aktuell konfigurierte
Mac-Modell an, bevor er startet, damit kein Versehen passiert.
Verlauf-Panel listet pro Lauf Modellname + Zeitstempel + Download-
Links für die Snapshot-Dateien.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -85,9 +85,12 @@ function fmtSecs(sec) {
|
||||
|
||||
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"]);
|
||||
|
||||
// Welche Jobs haben aktuell ein offenes Diag-Panel? Über Re-Renders hinweg merken.
|
||||
// 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 "";
|
||||
@@ -108,6 +111,17 @@ function diagButtonHtml(job) {
|
||||
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",
|
||||
@@ -332,7 +346,9 @@ function row(job) {
|
||||
${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 = `
|
||||
@@ -355,6 +371,15 @@ function diagRow(job) {
|
||||
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";
|
||||
@@ -390,11 +415,16 @@ function card(job) {
|
||||
${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>
|
||||
`;
|
||||
@@ -417,13 +447,18 @@ async function loadJobs() {
|
||||
jobs.forEach((j) => {
|
||||
jobsBody.appendChild(row(j));
|
||||
jobsBody.appendChild(diagRow(j));
|
||||
jobsBody.appendChild(runsRow(j));
|
||||
jobsCards.appendChild(card(j));
|
||||
});
|
||||
// Vorher offene Diagnosen wieder aufklappen + frisch nachladen.
|
||||
// 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);
|
||||
}
|
||||
@@ -456,6 +491,67 @@ function renderDiag(diag) {
|
||||
`;
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -493,6 +589,25 @@ document.addEventListener("click", (ev) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 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
|
||||
@@ -816,6 +931,54 @@ document.addEventListener("click", async (ev) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Reprocess-Button: erlaubt LLM-Lauf auch auf done/failed-Jobs, sichert vorheriges
|
||||
// Ergebnis als Snapshot und nutzt das aktuell auf dem Mac konfigurierte Modell.
|
||||
document.addEventListener("click", async (ev) => {
|
||||
const btn = ev.target.closest(".btn-reprocess");
|
||||
if (!btn) return;
|
||||
ev.preventDefault();
|
||||
const id = btn.dataset.id;
|
||||
|
||||
// Aktuelles Mac-Modell ziehen, damit der Bestätigungsdialog ehrlich anzeigt,
|
||||
// welches Modell jetzt gleich verwendet wird.
|
||||
let model = "(unbekannt)";
|
||||
try {
|
||||
const h = await api("/api/mac/health");
|
||||
if (h.ok) {
|
||||
const j = await h.json();
|
||||
model = j.mac?.ollama_model || model;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const ok = confirm(
|
||||
`Job #${id} erneut mit dem aktuellen Mac-Modell "${model}" verarbeiten?\n\n` +
|
||||
`Das bisherige Ergebnis (Summary, Protokoll, DOCX, PDF) wird im Verlauf als Snapshot gesichert. ` +
|
||||
`Das Transkript bleibt unverändert — nur die LLM-Schritte laufen neu.`
|
||||
);
|
||||
if (!ok) return;
|
||||
|
||||
btn.disabled = true;
|
||||
const oldText = btn.textContent;
|
||||
btn.textContent = "wird gestartet …";
|
||||
try {
|
||||
const r = await api(`/api/jobs/${id}/reprocess`, { method: "POST" });
|
||||
if (!r.ok) {
|
||||
const d = await r.json().catch(() => ({}));
|
||||
alert(d.detail || "Konnte nicht neu verarbeiten.");
|
||||
btn.disabled = false;
|
||||
btn.textContent = oldText;
|
||||
return;
|
||||
}
|
||||
// Verlauf öffnen, damit man den frischen Snapshot direkt sieht.
|
||||
openRunsIds.add(String(id));
|
||||
loadJobs();
|
||||
} catch (e) {
|
||||
alert("Netzwerkfehler beim Reprocess.");
|
||||
btn.disabled = false;
|
||||
btn.textContent = oldText;
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Init ──────────────────────────────────────────────────────────────────
|
||||
bootstrap();
|
||||
checkMac();
|
||||
|
||||
@@ -569,6 +569,72 @@ select {
|
||||
}
|
||||
.live-timing { color: var(--muted); }
|
||||
|
||||
/* Reprocess-Button (mit anderem Modell neu verarbeiten) – violetter Akzent,
|
||||
um ihn klar vom warning-gelben "Erneut versuchen" zu trennen */
|
||||
.btn-reprocess {
|
||||
display: inline-block;
|
||||
margin-top: 6px;
|
||||
margin-left: 4px;
|
||||
padding: 3px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
background: rgba(155,89,182,.14);
|
||||
color: #b07cd6;
|
||||
border: 1px solid rgba(155,89,182,.45);
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-reprocess:hover { background: rgba(155,89,182,.24); }
|
||||
.btn-reprocess:disabled { opacity: .55; cursor: not-allowed; }
|
||||
|
||||
/* Verlauf-Toggle – wie Diagnose, aber leicht andere Farbe */
|
||||
.btn-runs {
|
||||
display: inline-block;
|
||||
margin-top: 6px;
|
||||
margin-left: 4px;
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
background: rgba(52,152,219,.10);
|
||||
border: 1px solid rgba(52,152,219,.35);
|
||||
color: #7fbce8;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-runs:hover { background: rgba(52,152,219,.20); }
|
||||
|
||||
/* Verlauf-Panel: gleiches Grundlayout wie diag, separates Styling für Listen-Items */
|
||||
.runs-row > td {
|
||||
background: rgba(52,152,219,.04);
|
||||
border-top: 0;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.runs-panel {
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
background: rgba(52,152,219,.05);
|
||||
border: 1px solid rgba(52,152,219,.18);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.runs-meta { font-size: 12px; margin-bottom: 8px; }
|
||||
.runs-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.run-item {
|
||||
padding: 8px 10px;
|
||||
background: rgba(255,255,255,.03);
|
||||
border: 1px solid rgba(255,255,255,.08);
|
||||
border-radius: 5px;
|
||||
}
|
||||
.run-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
align-items: baseline;
|
||||
font-size: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.run-model code { font-size: 12px; padding: 1px 5px; }
|
||||
.run-stamp { font-size: 11.5px; color: var(--muted); }
|
||||
.run-files { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
|
||||
/* Diagnose-Button – wie btn-retry, aber neutraler Ton */
|
||||
.btn-diag {
|
||||
display: inline-block;
|
||||
|
||||
Reference in New Issue
Block a user