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>
This commit is contained in:
root
2026-05-16 16:48:11 +00:00
co-authored by Claude Opus 4.7
parent dbf7bca6c7
commit c6164d946c
13 changed files with 386 additions and 54 deletions
+141 -21
View File
@@ -36,6 +36,8 @@ 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");
@@ -52,9 +54,11 @@ 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 }
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" },
@@ -184,6 +188,7 @@ function enterApp() {
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();
}
@@ -226,6 +231,68 @@ function applyProfileUI() {
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;
@@ -336,6 +403,11 @@ function row(job) {
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>`
@@ -353,7 +425,7 @@ function row(job) {
tr.innerHTML = `
<td class="col-id">#${job.id}</td>
<td class="col-title">${escapeHtml(title)} ${profileTag}<br><small class="muted">${escapeHtml(job.original_name)}</small>${ownerLine}</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>
@@ -388,6 +460,9 @@ function card(job) {
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>` : "";
@@ -401,7 +476,7 @@ function card(job) {
div.innerHTML = `
<div class="jc-head">
<div>
<div class="jc-title">${escapeHtml(title)} ${profileTag}</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>
@@ -931,37 +1006,83 @@ 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.
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 Mac-Modell ziehen, damit der Bestätigungsdialog ehrlich anzeigt,
// welches Modell jetzt gleich verwendet wird.
let model = "(unbekannt)";
// 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 h = await api("/api/mac/health");
if (h.ok) {
const j = await h.json();
model = j.mac?.ollama_model || model;
}
const r = await api(`/api/jobs/${id}`);
if (r.ok) currentJobModel = (await r.json()).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;
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" });
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.");
@@ -969,7 +1090,6 @@ document.addEventListener("click", async (ev) => {
btn.textContent = oldText;
return;
}
// Verlauf öffnen, damit man den frischen Snapshot direkt sieht.
openRunsIds.add(String(id));
loadJobs();
} catch (e) {
+5
View File
@@ -32,6 +32,11 @@
<span class="muted desktop-only">Profil:</span>
<select id="header-profile" class="header-profile" title="Standard-Profil — wird sofort gespeichert"></select>
</span>
<span id="header-model-wrap" class="header-profile-wrap hidden">
<span class="muted desktop-only">·</span>
<span class="muted desktop-only">Modell:</span>
<select id="header-model" class="header-profile" title="Standard-LLM — wird sofort gespeichert. Leer = Worker-Default."></select>
</span>
<button type="button" id="logout-btn" class="btn-link">Abmelden</button>
</div>
</div>
+74
View File
@@ -561,6 +561,80 @@ select {
vertical-align: middle;
}
/* Modell-Tag — gleicher Stil wie Profile-Tag, andere Farbe (lila) */
.tag-model {
display: inline-block;
padding: 1px 8px;
margin-left: 4px;
border-radius: 4px;
background: rgba(155,89,182,.10);
border: 1px solid rgba(155,89,182,.4);
color: #b07cd6;
font-size: 11px;
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
vertical-align: middle;
}
/* Modal für Modell-Auswahl beim Reprocess */
dialog.model-picker {
border: 1px solid rgba(255,255,255,.15);
border-radius: 8px;
background: #1a1d23;
color: inherit;
padding: 18px 20px;
max-width: 460px;
width: 90vw;
}
dialog.model-picker::backdrop {
background: rgba(0,0,0,.55);
}
dialog.model-picker h3 {
margin: 0 0 6px 0;
font-size: 15px;
}
dialog.model-picker .model-picker-label {
display: block;
margin: 12px 0 4px;
font-size: 13px;
}
dialog.model-picker select {
display: block;
width: 100%;
margin-top: 6px;
padding: 6px 8px;
background: rgba(0,0,0,.30);
border: 1px solid rgba(255,255,255,.20);
color: inherit;
border-radius: 4px;
font-size: 13px;
}
dialog.model-picker .dlg-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 14px;
}
dialog.model-picker button {
padding: 6px 14px;
background: rgba(255,255,255,.06);
border: 1px solid rgba(255,255,255,.18);
color: inherit;
border-radius: 4px;
cursor: pointer;
}
dialog.model-picker button.primary {
background: rgba(155,89,182,.20);
border-color: rgba(155,89,182,.55);
color: #d3afe6;
}
dialog.model-picker button:hover { background: rgba(255,255,255,.12); }
dialog.model-picker code {
background: rgba(255,255,255,.06);
padding: 1px 5px;
border-radius: 3px;
font-size: 11.5px;
}
/* Live-Timing direkt unter dem Status-Pill (läuft seit · letzter Ping vor) */
.status-timing, .jc-timing {
margin-top: 4px;