feat(diagnostic): Modell + Kontext erst waehlen, dann "Laden"

LLM-Karten: Modell-Auswahl und Kontext-Dropdown laden nicht mehr sofort.
Die Zeile zeigt die Auswahl, daneben "Laden" (bzw. "Leeren") und "✕" zum
Verwerfen; erst "Laden" fragt nach (MoE-Aufteilung, Ersetzen) und laedt
mit genau diesem Kontext — vorher lud man zweimal (Modell, dann neuer
Kontext). Fuer ein noch nicht geladenes Modell zeigt der Kontext feste
Stufen ohne GB, die Schaetzung kennt die Box erst nach dem ersten Laden.
Die Auswahl ueberlebt das Neuzeichnen durch worker_update.

Modell-Katalog: neue Spalte "Kontext" hinter der Ziel-Karte; Ziel und
Kontext bleiben beim Neuzeichnen erhalten. Neuzeichnen nach Fokusverlust
300 ms spaeter, damit ein Klick auf "Laden" nicht ins Leere geht.

Getestet in headless Chromium mit der echten Seite: waehlen → kein Senden,
Kontext waehlen, Neuzeichnen, Laden → genau ein provision mit dem Kontext,
Verwerfen, Katalog mit Kontext.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 15:40:58 +01:00
co-authored by Claude Opus 5.5
parent 5783076e27
commit 93a3070e5c
+108 -57
View File
@@ -9298,6 +9298,7 @@
// ── Modell-Katalog (Stage D) ────────────────────────────
let _llmCatalog = [];
const _llmCardPending = {}; // Karten: gewaehlt, noch nicht geladen — "instanceId|karte" → {model, ctx}
async function loadLlmCatalog() {
if (typeof requestWorkers === 'function') requestWorkers(); // Flotte aktiv nachziehen
try {
@@ -9332,7 +9333,7 @@
box._llmDeferred = false;
if (_llmRenderDeferred(box, fn)) return; // Fokus nur innerhalb gewechselt → weiter warten
fn();
}, 0), { once: true });
}, 300), { once: true });
}
return true;
}
@@ -9349,6 +9350,16 @@
const none = document.getElementById('llm-catalog-none');
if (none) none.style.display = rows.length && !shown ? '' : 'none';
}
// Kontext-Stufen fuer Modelle, deren Speicherbedarf die Box noch nicht kennt
const _CTX_STEPS = [4096, 8192, 16384, 32768, 65536, 131072];
function _ctxSteps(cur) { return cur && !_CTX_STEPS.includes(cur) ? [cur, ..._CTX_STEPS].sort((a, b) => a - b) : _CTX_STEPS; }
function _ctxLabel(n) { return n >= 1024 ? (n % 1024 ? (n / 1024).toFixed(1) : n / 1024) + 'k' : String(n); }
// Auswahl im Katalog (Ziel-Karte, Kontext) je Modell — ueberlebt das Neuzeichnen
const _llmCatSel = {};
function llmCatPick(i, field, value) {
const m = _llmCatalog[i];
if (m) _llmCatSel[m.id] = { ...(_llmCatSel[m.id] || {}), [field]: value };
}
function renderLlmCatalog() {
const box = document.getElementById('llm-catalog-list');
if (!box) return;
@@ -9378,7 +9389,10 @@
const vramStyle = m.sizeGB >= 12 ? 'color:#FFB020;' : 'color:#AAB;';
// Ziel = Karte. Vorauswahl: eine leere Karte, sonst die erste.
const targets = llmCardTargets();
const pre = (targets.find(t => t.slot && !t.slot.model) || targets[0] || {}).value;
const pick = _llmCatSel[m.id] || {};
const pre = targets.some(t => t.value === pick.target) ? pick.target
: (targets.find(t => t.slot && !t.slot.model) || targets[0] || {}).value;
const ctxPick = pick.ctx || m.ctx || 8192;
const opts = targets.length
? targets.map(t => `<option value="${escapeHtml(t.value)}"${t.value === pre ? ' selected' : ''}>${escapeHtml(t.label)}</option>`).join('')
: '<option value="">(keine Box online)</option>';
@@ -9391,7 +9405,9 @@
+ '</td>' +
'<td ' + td + vramStyle + 'white-space:nowrap;">' + vram + '</td>' +
'<td ' + td + 'white-space:nowrap;">' + haveTxt + '</td>' +
'<td ' + td + '"><select id="llm-cat-box-' + i + '" style="background:#1E1E2E;border:1px solid #333;border-radius:4px;padding:3px 6px;color:#E0E0F0;font-size:11px;">' + opts + '</select></td>' +
'<td ' + td + '"><select id="llm-cat-box-' + i + '" onchange="llmCatPick(' + i + ', \'target\', this.value)" style="background:#1E1E2E;border:1px solid #333;border-radius:4px;padding:3px 6px;color:#E0E0F0;font-size:11px;">' + opts + '</select></td>' +
'<td ' + td + '"><select id="llm-cat-ctx-' + i + '" onchange="llmCatPick(' + i + ', \'ctx\', Number(this.value))" title="Kontextfenster, mit dem das Modell geladen wird — mehr Kontext braucht mehr Grafikspeicher" style="background:#1E1E2E;border:1px solid #333;border-radius:4px;padding:3px 6px;color:#E0E0F0;font-size:11px;">'
+ _ctxSteps(ctxPick).map(n => `<option value="${n}"${n === ctxPick ? ' selected' : ''}>${_ctxLabel(n)} Tokens</option>`).join('') + '</select></td>' +
'<td ' + td + '"><button class="btn secondary" ' + (boxes.length ? '' : 'disabled') + ' onclick="provisionModel(' + i + ')" style="padding:3px 10px;font-size:11px;">Laden</button></td>' +
'</tr>';
}).join('');
@@ -9400,7 +9416,7 @@
'<table style="width:100%;border-collapse:collapse;font-size:12px;min-width:760px;">' +
'<thead><tr style="color:#8888AA;border-bottom:1px solid #2A2A3E;">' +
'<th ' + th + '>Modell</th><th ' + th + '>Kann gut / Beschreibung</th><th ' + th + '>VRAM</th>' +
'<th ' + th + '>Status</th><th ' + th + '>Ziel-Karte</th><th ' + th + '></th>' +
'<th ' + th + '>Status</th><th ' + th + '>Ziel-Karte</th><th ' + th + '>Kontext</th><th ' + th + '></th>' +
'</tr></thead><tbody>' + rows + '</tbody></table>' +
'<div id="llm-catalog-none" style="display:none;color:#6a6a88;padding:10px 8px;">Kein Modell passt zur Suche.</div>';
filterLlmCatalog();
@@ -9542,6 +9558,12 @@
for (const sl of b.slots) {
const vramGB = sl.vramMB ? sl.vramMB / 1024 : 0;
const ids = new Set(_llmCatalog.map(m => m.id));
// Gewaehlt, aber noch nicht geladen? Dann zeigt die Zeile die Auswahl.
const pkey = b.instanceId + '|' + sl.gpu;
const curCtx = sl.ctx || 0;
let pend = _llmCardPending[pkey];
if (pend && pend.model === (sl.model || '') && (!pend.model || pend.ctx === curCtx)) { delete _llmCardPending[pkey]; pend = null; }
const shown = pend ? pend.model : (sl.model || '');
const opts = ['<option value="">— leer —</option>'];
let curLabel = '— leer —';
const list = _llmCatalog.filter(m => !m.unusable);
@@ -9553,8 +9575,8 @@
const tooBig = vramGB && m.sizeGB && m.sizeGB > vramGB * 0.92;
const label = m.id + (m.sizeGB ? ` · ~${m.sizeGB} GB` : '') + (m.quant ? ` · ${m.quant}` : '')
+ (tooBig ? ' · passt nicht' : '');
if (m.id === sl.model) curLabel = label;
opts.push(`<option value="${escapeHtml(m.id)}"${m.id === sl.model ? ' selected' : ''}${tooBig ? ' style="color:#FF9F0A;"' : ''}>${escapeHtml(label)}</option>`);
if (m.id === shown) curLabel = label;
opts.push(`<option value="${escapeHtml(m.id)}"${m.id === shown ? ' selected' : ''}${tooBig ? ' style="color:#FF9F0A;"' : ''}>${escapeHtml(label)}</option>`);
}
let [c, st] = sl.model ? (SLOT_STATE[sl.state] || ['#8888AA', escapeHtml(sl.state || '?')]) : ['#555570', 'leer'];
if (sl.onCpu) { c = '#FF9F0A'; st = '⚠ auf der CPU'; }
@@ -9565,21 +9587,24 @@
// nicht, setzt die Box die Karte von selbst zurueck.
// Liste + Speicherbedarf kommen von der Box (aus der Modelldatei:
// trainiertes Maximum, KV-Cache pro Token). Alte Box: feste Liste.
const curCtx = sl.ctx || 0;
let ctxList = Array.isArray(sl.options) && sl.options.length
// Fuer ein anderes (noch nicht geladenes) Modell kennt die Box den
// Speicherbedarf nicht → feste Stufen ohne GB-Angabe.
const sameModel = shown && shown === sl.model;
const selCtx = pend ? pend.ctx : curCtx;
let ctxList = sameModel && Array.isArray(sl.options) && sl.options.length
? sl.options.map(o => ({ ctx: o.ctx, est: o.estMB }))
: [4096, 8192, 16384, 32768, 65536, 131072].map(n => ({ ctx: n, est: 0 }));
if (curCtx && !ctxList.some(o => o.ctx === curCtx)) ctxList = [{ ctx: curCtx, est: 0 }, ...ctxList];
: _CTX_STEPS.map(n => ({ ctx: n, est: 0 }));
if (selCtx && !ctxList.some(o => o.ctx === selCtx)) ctxList = [{ ctx: selCtx, est: 0 }, ...ctxList];
const vramMB = sl.vramMB || 0;
const ctxOpts = ctxList.map(o => {
const label = o.ctx >= 1024 ? (o.ctx % 1024 ? (o.ctx / 1024).toFixed(1) : o.ctx / 1024) + 'k' : o.ctx;
const gb = o.est ? ` · ca. ${(o.est / 1024).toFixed(1).replace('.', ',')} GB` : '';
const tooBig = o.est && vramMB && o.est > vramMB * 0.97 ? ' ⚠ passt nicht' : '';
return `<option value="${o.ctx}"${o.ctx === curCtx ? ' selected' : ''}>${label} Tokens${gb}${tooBig}</option>`;
return `<option value="${o.ctx}"${o.ctx === selCtx ? ' selected' : ''}>${label} Tokens${gb}${tooBig}</option>`;
}).join('');
const ctxSel = sl.model
? `<select data-box="${escapeHtml(b.instanceId)}" data-gpu="${escapeHtml(sl.gpu)}" data-model="${escapeHtml(sl.model)}" data-current="${curCtx}"
onchange="onLlmCardCtx(this)" ${job ? 'disabled' : ''} title="Kontextfenster (Eingabe + Ausgabe). GB = geschätzter Grafikspeicher für Modell + Kontext auf dieser Karte.${sl.ctxMax ? ' Das Modell kann höchstens ' + sl.ctxMax + ' Tokens.' : ''}"
const ctxSel = shown
? `<select data-key="${escapeHtml(pkey)}" data-model="${escapeHtml(shown)}"
onchange="onLlmCardCtxPick(this)" ${job ? 'disabled' : ''} title="Kontextfenster (Eingabe + Ausgabe).${sameModel ? ' GB = geschätzter Grafikspeicher für Modell + Kontext auf dieser Karte.' : ' Speicherbedarf kennt die Box erst nach dem ersten Laden.'}${sameModel && sl.ctxMax ? ' Das Modell kann höchstens ' + sl.ctxMax + ' Tokens.' : ''}"
style="flex:1;min-width:0;background:#1E1E2E;border:1px solid #333;border-radius:4px;padding:5px 6px;color:#E0E0F0;font-size:12px;">${ctxOpts}</select>`
: '';
const ctxCell = `<div style="display:flex;gap:6px;align-items:center;min-width:0;">${ctxSel}
@@ -9598,14 +9623,19 @@
</select>${sl.ramMB ? ' · ' + (sl.ramMB / 1024).toFixed(1).replace('.', ',') + ' GB im RAM' : ''}${sl.cpuMoe > 0 ? ' (' + sl.cpuMoe + ' Schichten)' : ''}</div>` : ''}
${sl.onCpu ? '<div style="color:#FF9F0A;font-size:11px;" title="llama.cpp findet die Karte nicht und rechnet langsam auf der CPU. Ursache meist: Treiber zu alt fuer das CUDA im llama-swap-Image. Abhilfe: auf der Box sudo ./bootstrap.sh --upgrade-driver">⚠ Modell läuft auf der CPU, Karte leer — Treiber zu alt? (bootstrap.sh --upgrade-driver)</div>' : ''}</div>
<div style="min-width:0;">
<select data-box="${escapeHtml(b.instanceId)}" data-gpu="${escapeHtml(sl.gpu)}" data-current="${escapeHtml(sl.model || '')}"
onchange="onLlmCardSelect(this)" style="display:none;">${opts.join('')}</select>
<select data-key="${escapeHtml(pkey)}" data-current="${escapeHtml(shown)}"
onchange="onLlmCardPick(this)" style="display:none;">${opts.join('')}</select>
<button type="button" onclick="openLlmPicker(this)" ${job ? 'disabled' : ''} title="Modell wählen — mit Suche"
style="width:100%;display:flex;justify-content:space-between;gap:8px;text-align:left;background:#1E1E2E;border:1px solid #333;border-radius:4px;padding:5px 8px;color:#E0E0F0;font-size:12px;cursor:pointer;">
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escapeHtml(curLabel)}</span><span style="color:#8888AA;">▾</span></button>
</div>
${ctxCell}
<span style="color:${job ? '#FFD60A' : c};font-size:12px;">${jobTxt || st}</span>
${pend && !job
? `<span style="display:flex;gap:6px;align-items:center;">
<button class="btn" onclick="applyLlmCard('${escapeHtmlAttr(pkey)}')" style="padding:4px 12px;font-size:12px;"
title="Auswahl auf die Karte laden">${pend.model ? 'Laden' : 'Leeren'}</button>
<button class="btn secondary" onclick="discardLlmCard('${escapeHtmlAttr(pkey)}')" style="padding:4px 8px;font-size:12px;" title="Auswahl verwerfen — Karte bleibt wie sie ist">✕</button></span>`
: `<span style="color:${job ? '#FFD60A' : c};font-size:12px;">${jobTxt || st}</span>`}
</div>`);
}
}
@@ -9643,7 +9673,7 @@
closeLlmPicker();
if (!o || o.value === sel.value) return;
sel.value = o.value;
onLlmCardSelect(sel);
onLlmCardPick(sel);
};
list.addEventListener('mousedown', e => {
const d = e.target.closest('[data-i]');
@@ -9803,49 +9833,68 @@
function openCtxInfo() { document.getElementById('ctx-info-modal').style.display = 'flex'; }
// Kontextfenster einer Karte aendern = dasselbe Modell mit anderem -c neu
// laden. Programme bekommen den neuen Wert automatisch (ctx in
// llm_cluster_models). Passt es nicht in den Grafikspeicher, setzt die Box
// die Karte auf den alten Stand zurueck.
async function onLlmCardCtx(sel) {
const target = sel.dataset.box, gpu = sel.dataset.gpu, model = sel.dataset.model;
sel.blur(); // Fokus weg → aufgeschobenes Neuzeichnen holt den Stand nach
const cur = Number(sel.dataset.current || 0), next = Number(sel.value);
if (next === cur) return;
const m = _llmCatalog.find(x => x.id === model);
if (!m || !m.hfRepo) { uiAlert(`${model} steht nicht im Modell-Katalog — Kontext so nicht änderbar.`); sel.value = cur; return; }
const more = next > cur;
if (!await uiConfirm(`${model} auf ${_boxName(target)} · Karte ${gpu} mit ${next} statt ${cur || '?'} Tokens Kontext neu laden?\n\n`
+ (more ? 'Mehr Kontext braucht mehr Grafikspeicher. Passt es nicht, setzt die Box die Karte von selbst auf den alten Stand zurück.\n\n' : '')
+ 'Die Box startet ihre Modelle dabei kurz neu. Programme bekommen den neuen Wert automatisch.')) { sel.value = cur; return; }
// Karten: Modell und Kontext erst AUSWAEHLEN, geladen wird mit "Laden" —
// sonst laedt man zweimal (erst das Modell, dann nochmal mit anderem Kontext).
function _llmSlotOf(pkey) {
const at = pkey.lastIndexOf('|');
const target = pkey.slice(0, at), gpu = pkey.slice(at + 1);
const sl = (workers.find(w => w.instanceId === target)?.slots || []).find(x => String(x.gpu) === String(gpu)) || {};
const cpuMoe = sl.cpuMoe > 0 ? -2 : (sl.cpuMoe || 0); // teilweise aufgeteilt → fuer den neuen Kontext neu rechnen
send({ action: 'llm_provision_model', targetInstance: target, gpu, key: m.id, hfRepo: m.hfRepo, quant: m.quant, ctx: next, cpuMoe });
openLlmDownloads();
return { target, gpu, sl };
}
async function onLlmCardSelect(sel) {
const target = sel.dataset.box, gpu = sel.dataset.gpu, current = sel.dataset.current, next = sel.value;
sel.blur(); // Fokus weg → aufgeschobenes Neuzeichnen holt den Stand nach
const node = _boxName(target);
if (next === current) return;
if (!next) {
function onLlmCardPick(sel) {
const pkey = sel.dataset.key, model = sel.value;
const { sl } = _llmSlotOf(pkey);
const m = _llmCatalog.find(x => x.id === model);
const ctx = model && model === sl.model ? (sl.ctx || 0) : ((m && m.ctx) || 8192);
_llmCardPending[pkey] = { model, ctx };
renderLlmCardSettings();
}
function onLlmCardCtxPick(sel) {
const pkey = sel.dataset.key;
sel.blur();
_llmCardPending[pkey] = { model: sel.dataset.model, ctx: Number(sel.value) };
renderLlmCardSettings();
}
function discardLlmCard(pkey) {
delete _llmCardPending[pkey];
renderLlmCardSettings();
}
// Kontextfenster aendern = dasselbe Modell mit anderem -c neu laden.
// Passt es nicht in den Grafikspeicher, setzt die Box die Karte zurueck.
async function applyLlmCard(pkey) {
const pend = _llmCardPending[pkey];
if (!pend) return;
const { target, gpu, sl } = _llmSlotOf(pkey);
const node = _boxName(target), current = sl.model || '', cur = sl.ctx || 0;
if (!pend.model) {
if (!await uiConfirm(`Karte ${gpu} auf ${node} leeren?\n\n${current} wird entladen; ARIA und Programme können es dann nicht mehr wählen. `
+ `Die Datei bleibt auf der Box.\n\nDie anderen Karten dieser Box starten dabei kurz neu.`)) { sel.value = current; return; }
+ `Die Datei bleibt auf der Box.\n\nDie anderen Karten dieser Box starten dabei kurz neu.`)) return;
send({ action: 'llm_unload_model', targetInstance: target, gpu });
delete _llmCardPending[pkey];
renderLlmCardSettings();
return;
}
const m = _llmCatalog.find(x => x.id === next);
if (!m || !m.hfRepo) { uiAlert(`${next} steht nicht im Modell-Katalog — Herkunft unbekannt.`); sel.value = current; return; }
const sl = (workers.find(w => w.instanceId === target)?.slots || []).find(x => String(x.gpu) === String(gpu)) || {};
const vramGB = sl.vramMB ? sl.vramMB / 1024 : 0;
const cpuMoe = await askCpuMoe(m, sl.vramMB || 0);
if (cpuMoe === null) { sel.value = current; return; }
const warn = (!cpuMoe && vramGB && m.sizeGB && m.sizeGB > vramGB * 0.92)
? `\n\n⚠ ${next} ist etwa ${m.sizeGB} GB groß, die Karte hat ${vramGB.toFixed(0)} GB — wahrscheinlich passt es nicht.` : '';
const repl = current ? `\n\nErsetzt ${current} — Programme, die es nutzen, verlieren es.` : '';
if (!await uiConfirm(`${next} auf ${node} · Karte ${gpu} laden?${repl}${warn}\n\nDie anderen Karten dieser Box starten dabei kurz neu.`)) { sel.value = current; return; }
send({ action: 'llm_provision_model', targetInstance: target, gpu, key: m.id, hfRepo: m.hfRepo, quant: m.quant, ctx: m.ctx, cpuMoe });
const m = _llmCatalog.find(x => x.id === pend.model);
if (!m || !m.hfRepo) { uiAlert(`${pend.model} steht nicht im Modell-Katalog — Herkunft unbekannt.`); return; }
let cpuMoe;
if (pend.model === current) {
const more = pend.ctx > cur;
if (!await uiConfirm(`${current} auf ${node} · Karte ${gpu} mit ${_ctxLabel(pend.ctx)} statt ${cur ? _ctxLabel(cur) : '?'} Tokens Kontext neu laden?\n\n`
+ (more ? 'Mehr Kontext braucht mehr Grafikspeicher. Passt es nicht, setzt die Box die Karte von selbst auf den alten Stand zurück.\n\n' : '')
+ 'Die Box startet ihre Modelle dabei kurz neu. Programme bekommen den neuen Wert automatisch.')) return;
cpuMoe = sl.cpuMoe > 0 ? -2 : (sl.cpuMoe || 0); // teilweise aufgeteilt → fuer den neuen Kontext neu rechnen
} else {
const vramGB = sl.vramMB ? sl.vramMB / 1024 : 0;
cpuMoe = await askCpuMoe(m, sl.vramMB || 0);
if (cpuMoe === null) return;
const warn = (!cpuMoe && vramGB && m.sizeGB && m.sizeGB > vramGB * 0.92)
? `\n\n⚠ ${m.id} ist etwa ${m.sizeGB} GB groß, die Karte hat ${vramGB.toFixed(0)} GB — wahrscheinlich passt es nicht.` : '';
const repl = current ? `\n\nErsetzt ${current} — Programme, die es nutzen, verlieren es.` : '';
if (!await uiConfirm(`${m.id} mit ${_ctxLabel(pend.ctx)} Tokens Kontext auf ${node} · Karte ${gpu} laden?${repl}${warn}\n\nDie anderen Karten dieser Box starten dabei kurz neu.`)) return;
}
send({ action: 'llm_provision_model', targetInstance: target, gpu, key: m.id, hfRepo: m.hfRepo, quant: m.quant, ctx: pend.ctx, cpuMoe });
delete _llmCardPending[pkey];
renderLlmCardSettings();
openLlmDownloads();
}
@@ -9978,8 +10027,10 @@
const cpuMoe = await askCpuMoe(m, (tgt && tgt.slot && tgt.slot.vramMB) || 0);
if (cpuMoe === null) return;
const st = document.getElementById('llm-catalog-status');
if (st) { st.textContent = `Lade '${m.id}' auf ${target}… (GGUF-Download kann dauern)`; st.style.color = '#FFD60A'; }
send({ action: 'llm_provision_model', targetInstance: target, gpu: gpu || '', key: m.id, hfRepo: m.hfRepo, quant: m.quant, ctx: m.ctx, cpuMoe });
const ctxSel = document.getElementById('llm-cat-ctx-' + i);
const ctx = Number((ctxSel && ctxSel.value) || (_llmCatSel[m.id] || {}).ctx || m.ctx || 8192);
if (st) { st.textContent = `Lade '${m.id}' mit ${_ctxLabel(ctx)} Kontext auf ${target}… (GGUF-Download kann dauern)`; st.style.color = '#FFD60A'; }
send({ action: 'llm_provision_model', targetInstance: target, gpu: gpu || '', key: m.id, hfRepo: m.hfRepo, quant: m.quant, ctx, cpuMoe });
openLlmDownloads();
}
function runLlmTest() {