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:
+141
-21
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user