feat(diagnostic): Suche im Modell-Katalog und in den Karten-Dropdowns

- Modell-Katalog: Suchfeld (erscheint, sobald die Liste geladen ist),
  Teilsuche ueber Name, Beschreibung, Repo und Quantisierung; mehrere
  Woerter muessen alle vorkommen.
- LLM-Karten: Suchfeld ueber jedem Modell-Dropdown blendet nicht passende
  Modelle aus; "— leer —" und das geladene Modell bleiben sichtbar.
- Fix: Ein offenes Karten-Dropdown klappte nach ein paar Sekunden zu, weil
  jedes worker_update die Liste neu zeichnete. Solange ein Dropdown oder
  Suchfeld darin den Fokus hat, wird das Neuzeichnen aufgeschoben und beim
  Verlassen nachgeholt (gilt auch fuer die Ziel-Karte im Katalog).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 14:56:03 +01:00
co-authored by Claude Opus 5.5
parent 8576d4676e
commit e219382a51
+60 -5
View File
@@ -956,6 +956,8 @@
den Fortschritt. Danach ist es im Modell-Dropdown oben waehlbar.
<span style="color:#FFD60A;">Achte auf den VRAM der Box (Spalte VRAM).</span>
</p>
<input id="llm-catalog-search" type="search" placeholder="🔍 Modell suchen … (z.B. coder, qwen3, 30b)" oninput="filterLlmCatalog()"
style="display:none;width:100%;max-width:360px;box-sizing:border-box;margin:0 0 8px;background:#1E1E2E;border:1px solid #333;border-radius:4px;padding:6px 8px;color:#E0E0F0;font-size:12px;">
<div id="llm-catalog-list" style="font-size:12px;color:#8888AA;overflow-x:auto;">(lade Katalog…)</div>
<div id="llm-catalog-status" style="font-size:11px;color:#6a6a88;margin-top:6px;min-height:14px;"></div>
</div>
@@ -9254,10 +9256,41 @@
// online LLM-Boxen (fuer die Ziel-Auswahl + Verfuegbarkeit)
function onlineLlmBoxes() { return workers.filter(w => w.service === 'llm' && w.online); }
function boxesServing(id) { return onlineLlmBoxes().filter(w => (w.models || []).includes(id) || w.model === id); }
// Neu zeichnen schliesst ein offenes Dropdown und loescht Eingaben. Solange
// darin etwas den Fokus hat, warten — beim Verlassen wird nachgeholt.
function _llmRenderDeferred(box, fn) {
const a = document.activeElement;
if (!a || !box.contains(a) || !['SELECT', 'INPUT'].includes(a.tagName)) return false;
if (!box._llmDeferred) {
box._llmDeferred = true;
box.addEventListener('focusout', () => setTimeout(() => {
box._llmDeferred = false;
if (_llmRenderDeferred(box, fn)) return; // Fokus nur innerhalb gewechselt → weiter warten
fn();
}, 0), { once: true });
}
return true;
}
// Teilsuche: jedes Wort muss irgendwo in Name/Beschreibung/Repo vorkommen.
function _llmMatches(text, query) {
const t = String(text || '').toLowerCase();
return String(query || '').toLowerCase().split(/\s+/).filter(Boolean).every(w => t.includes(w));
}
function filterLlmCatalog() {
const q = (document.getElementById('llm-catalog-search') || {}).value || '';
const rows = document.querySelectorAll('#llm-catalog-list tbody tr[data-search]');
let shown = 0;
rows.forEach(tr => { const ok = _llmMatches(tr.dataset.search, q); tr.style.display = ok ? '' : 'none'; if (ok) shown++; });
const none = document.getElementById('llm-catalog-none');
if (none) none.style.display = rows.length && !shown ? '' : 'none';
}
function renderLlmCatalog() {
const box = document.getElementById('llm-catalog-list');
if (!box) return;
renderLlmCardSettings();
if (_llmRenderDeferred(box, renderLlmCatalog)) return;
const search = document.getElementById('llm-catalog-search');
if (search) search.style.display = _llmCatalog.length ? '' : 'none';
if (!_llmCatalog.length) { box.innerHTML = '<span style="color:#6a6a88;">Katalog leer.</span>'; return; }
const boxes = onlineLlmBoxes();
const th = 'style="padding:6px 8px;text-align:left;position:sticky;top:0;background:#12122A;"';
@@ -9277,7 +9310,8 @@
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>';
return '<tr style="border-bottom:1px solid #1E1E2E;">' +
const searchText = [m.id, m.description, m.hfRepo, m.quant].filter(Boolean).join(' ');
return '<tr data-search="' + escapeHtml(searchText) + '" style="border-bottom:1px solid #1E1E2E;">' +
'<td ' + td + 'white-space:nowrap;"><b>' + escapeHtml(m.id) + '</b></td>' +
'<td ' + td + 'color:#9a9ab5;min-width:220px;">' + escapeHtml(m.description || m.hfRepo || '')
+ (m.quant ? ' <span style="color:#555570;" title="Quantisierung, die geladen wird' + (m.quants ? ' — vorhanden: ' + escapeHtml(m.quants.join(', ')) : '') + '">· ' + escapeHtml(m.quant) + '</span>' : '')
@@ -9293,7 +9327,9 @@
'<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>' +
'</tr></thead><tbody>' + rows + '</tbody></table>';
'</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();
}
// ── Download-Fenster ──────────────────────────────────────────────
// Zustand kommt vom Diagnostic-Server (llm_provision_progress / llm_jobs);
@@ -9401,6 +9437,7 @@
// Optionen = Modell-Katalog + was gerade geladen ist (falls nicht im
// Katalog). Zu gross fuer die Karte → markiert, aber waehlbar (Groesse ist
// nur eine Schaetzung, der Kontext braucht zusaetzlich Platz).
const _llmCardSearch = {}; // Suchtext je Karte (box|gpu), ueberlebt das Neuzeichnen
function renderLlmCardSettings() {
const box = document.getElementById('llm-card-settings');
if (!box) return;
@@ -9408,6 +9445,7 @@
window._llmCatalogRequested = true;
loadLlmCatalog(); // ruft renderLlmCatalog → renderLlmCardSettings
}
if (_llmRenderDeferred(box, renderLlmCardSettings)) return;
const boxes = onlineLlmBoxes().filter(b => Array.isArray(b.slots));
if (!boxes.length) {
const old = onlineLlmBoxes().length;
@@ -9474,15 +9512,29 @@
<option value="-1"${sl.cpuMoe < 0 ? ' selected' : ''}>alle im Arbeitsspeicher</option>
</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>
<select data-box="${escapeHtml(b.instanceId)}" data-gpu="${escapeHtml(sl.gpu)}" data-current="${escapeHtml(sl.model || '')}"
onchange="onLlmCardSelect(this)" ${job ? 'disabled' : ''}
style="background:#1E1E2E;border:1px solid #333;border-radius:4px;padding:5px 8px;color:#E0E0F0;font-size:12px;">${opts.join('')}</select>
<div style="display:flex;flex-direction:column;gap:4px;min-width:0;">
<input type="search" placeholder="🔍 Modell suchen …" data-key="${escapeHtml(b.instanceId + '|' + sl.gpu)}"
value="${escapeHtml(_llmCardSearch[b.instanceId + '|' + sl.gpu] || '')}" oninput="onLlmCardSearch(this)" ${job ? 'disabled' : ''}
style="background:#1E1E2E;border:1px solid #333;border-radius:4px;padding:4px 8px;color:#E0E0F0;font-size:11px;">
<select data-box="${escapeHtml(b.instanceId)}" data-gpu="${escapeHtml(sl.gpu)}" data-current="${escapeHtml(sl.model || '')}"
onchange="onLlmCardSelect(this)" ${job ? 'disabled' : ''}
style="background:#1E1E2E;border:1px solid #333;border-radius:4px;padding:5px 8px;color:#E0E0F0;font-size:12px;">${opts.join('')}</select>
</div>
${ctxCell}
<span style="color:${job ? '#FFD60A' : c};font-size:12px;">${jobTxt || st}</span>
</div>`);
}
}
box.innerHTML = rows.join('');
box.querySelectorAll('input[data-key]').forEach(onLlmCardSearch);
}
// Suchfeld ueber dem Karten-Dropdown: blendet nicht passende Modelle aus.
// "— leer —" und das geladene Modell bleiben immer sichtbar.
function onLlmCardSearch(inp) {
_llmCardSearch[inp.dataset.key] = inp.value;
const sel = inp.parentElement.querySelector('select');
if (!sel) return;
for (const o of sel.options) o.hidden = !!o.value && o.value !== sel.dataset.current && !_llmMatches(o.textContent, inp.value);
}
// ── Bestenlisten ────────────────────────────────────────
@@ -9616,6 +9668,7 @@
// die Karte auf den alten Stand zurueck.
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);
@@ -9632,6 +9685,7 @@
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) {
@@ -9656,6 +9710,7 @@
// MoE: Experten zwischen Karte und Arbeitsspeicher — gleiches Modell neu laden
function onLlmCardMoe(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);
const m = _llmCatalog.find(x => x.id === model);
if (!m || !m.hfRepo) { alert(`${model} steht nicht im Modell-Katalog.`); sel.value = String(cur); return; }