feat(diagnostic): pro LLM-Karte ein Dropdown — und Kontextfenster an Clients melden

Einstellungen → Modelle & KI → "LLM-Karten": fuer jede Karte, die auf einer
Box fuers LLM vorgesehen ist, ein Dropdown mit den Katalog-Modellen und
"— leer —" (entlaedt die Karte). Nur echte LLM-Karten erscheinen. Modelle,
die auf derselben Box schon auf einer anderen Karte liegen, werden nicht
angeboten; zu grosse (Katalog-Groesse > VRAM der Karte) sind als "passt
nicht" markiert. Waehrend eines Ladevorgangs ist das Dropdown gesperrt und
zeigt den Fortschritt.

Box meldet dafuer pro Karte den VRAM (nvidia-smi memory.total) und das
Kontextfenster, mit dem llama-server das Modell startet (-c).

Kontext an Clients: llm_models enthaelt jetzt "ctx". Anlass: die Software-
Schmiede rechnete mit 32768 Tokens, die Box startet die Modelle mit 8192 —
lange Auftraege waeren gescheitert oder falsch gekuerzt worden, und der Client
konnte es nicht wissen. Doku entsprechend ergaenzt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 01:22:06 +02:00
co-authored by Claude Opus 5.5
parent a1aa90177d
commit 60f2d2e087
3 changed files with 120 additions and 11 deletions
+97 -1
View File
@@ -839,6 +839,25 @@
</div>
</div>
<!-- LLM-Karten: pro Karte ein Dropdown — welches Modell dort liegt -->
<div class="settings-section">
<div style="display:flex;align-items:center;gap:14px;margin-bottom:8px;">
<h2 style="margin:0;">LLM-Karten</h2>
<button class="btn secondary" onclick="openLlmDownloads()" style="padding:4px 10px;font-size:11px;">⬇ Downloads</button>
</div>
<div class="card" style="max-width:1100px;">
<p style="color:#8888AA;font-size:12px;margin:0 0 10px;line-height:1.5;">
Jede Grafikkarte, die auf einer KI-Box fürs LLM vorgesehen ist (<code>LLM_GPUS</code> in deren
<code>.env</code>), hält genau ein Modell — dauerhaft geladen. Hier legst du fest, welches.
ARIA und angebundene Programme wählen nur unter diesen Modellen.
„— leer —" entlädt die Karte. Beim Wechsel starten die anderen Karten derselben Box kurz neu.
</p>
<div id="llm-card-settings" style="display:flex;flex-direction:column;gap:8px;">
<span style="color:#6a6a88;">Keine LLM-Box online.</span>
</div>
</div>
</div>
<!-- Modell-Katalog: GGUF-Modelle herunterladen/aktivieren (Stage D) -->
<div class="settings-section">
<div style="display:flex;align-items:center;gap:14px;margin-bottom:8px;">
@@ -2587,7 +2606,7 @@
if (p.ok) setTimeout(closeDeviceCreds, 700);
return;
}
if (msg.type === 'worker_update') { workers = msg.workers || []; renderWorkers(); if (typeof refreshLocalLlmModelChoices === 'function') refreshLocalLlmModelChoices(); if (typeof renderLlmCatalog === 'function') renderLlmCatalog(); return; }
if (msg.type === 'worker_update') { workers = msg.workers || []; renderWorkers(); renderLlmCardSettings(); if (typeof refreshLocalLlmModelChoices === 'function') refreshLocalLlmModelChoices(); if (typeof renderLlmCatalog === 'function') renderLlmCatalog(); return; }
if (msg.type === 'llm_response') {
const p = msg.payload || {};
const out = document.getElementById('llm-test-result');
@@ -8972,6 +8991,7 @@
function renderLlmCatalog() {
const box = document.getElementById('llm-catalog-list');
if (!box) return;
renderLlmCardSettings();
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;"';
@@ -9035,6 +9055,7 @@
if (!job || !job.key) return;
_llmJobs[_llmJobId(job)] = job;
renderLlmDownloads();
renderLlmCardSettings();
// Fertig geladen → Katalog-Status ("✓ ki-box") nachziehen.
if (job.phase === 'ready' && typeof requestWorkers === 'function') requestWorkers();
}
@@ -9110,6 +9131,81 @@
if (Object.values(_llmJobs).some(j => j.phase === 'queued')) renderLlmDownloads();
}, 5000);
// ── LLM-Karten: Dropdown pro Karte (Einstellungen → Modelle & KI) ──
// 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).
function renderLlmCardSettings() {
const box = document.getElementById('llm-card-settings');
if (!box) return;
if (!_llmCatalog.length && !window._llmCatalogRequested) {
window._llmCatalogRequested = true;
loadLlmCatalog(); // ruft renderLlmCatalog → renderLlmCardSettings
}
const boxes = onlineLlmBoxes().filter(b => Array.isArray(b.slots));
if (!boxes.length) {
const old = onlineLlmBoxes().length;
box.innerHTML = '<span style="color:#6a6a88;">' + (old
? 'Die LLM-Box meldet noch keine Karten — llm-adapter auf der Box aktualisieren.'
: 'Keine LLM-Box online.') + '</span>';
return;
}
const rows = [];
for (const b of boxes) {
for (const sl of b.slots) {
const vramGB = sl.vramMB ? sl.vramMB / 1024 : 0;
const ids = new Set(_llmCatalog.map(m => m.id));
const opts = ['<option value="">— leer —</option>'];
const list = _llmCatalog.filter(m => !m.unusable);
if (sl.model && !ids.has(sl.model)) list.unshift({ id: sl.model });
for (const m of list) {
// Liegt das Modell schon auf einer ANDEREN Karte dieser Box? → nicht anbieten.
const elsewhere = (b.slots || []).find(x => x.model === m.id && x.gpu !== sl.gpu);
if (elsewhere) continue;
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' : '');
opts.push(`<option value="${escapeHtml(m.id)}"${m.id === sl.model ? ' selected' : ''}${tooBig ? ' style="color:#FF9F0A;"' : ''}>${escapeHtml(label)}</option>`);
}
const [c, st] = sl.model ? (SLOT_STATE[sl.state] || ['#8888AA', escapeHtml(sl.state || '?')]) : ['#555570', 'leer'];
const job = Object.values(_llmJobs || {}).find(j => j.instanceId === b.instanceId && String(j.gpu) === String(sl.gpu)
&& !['ready', 'error'].includes(j.phase));
const jobTxt = job ? (job.phase === 'download' && job.percent != null ? `lädt ${job.percent.toFixed(0)} %` : 'lädt …') : '';
rows.push(`<div style="display:grid;grid-template-columns:minmax(220px,1fr) minmax(260px,2fr) 160px;gap:10px;align-items:center;">
<div><b style="color:#E0E0F0;">${escapeHtml(b.node)}</b> · Karte ${escapeHtml(sl.gpu)}
<div style="color:#555570;font-size:11px;">${escapeHtml(sl.gpuName || '')}${vramGB ? ' · ' + vramGB.toFixed(0) + ' GB' : ''}${sl.ctx ? ' · Kontext ' + sl.ctx : ''}</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>
<span style="color:${job ? '#FFD60A' : c};font-size:12px;">${jobTxt || st}</span>
</div>`);
}
}
box.innerHTML = rows.join('');
}
function onLlmCardSelect(sel) {
const target = sel.dataset.box, gpu = sel.dataset.gpu, current = sel.dataset.current, next = sel.value;
const node = _boxName(target);
if (next === current) return;
if (!next) {
if (!confirm(`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; }
send({ action: 'llm_unload_model', targetInstance: target, gpu });
return;
}
const m = _llmCatalog.find(x => x.id === next);
if (!m || !m.hfRepo) { alert(`${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 warn = (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 (!confirm(`${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 });
openLlmDownloads();
}
// ── LLM-Karten ────────────────────────────────────────────────────
const SLOT_STATE = {
ready: ['#3FFF3F', '● geladen'], starting: ['#FFD60A', '◐ startet …'],
+3 -2
View File
@@ -80,8 +80,8 @@ ein paar Sekunden lang ein (2–3 s reichen):
"instanceId": "llm@ki-box",
"node": "ki-box",
"models": [
{ "model": "qwen3-8b", "gpu": "1", "gpuName": "NVIDIA GeForce RTX 3060", "state": "ready" },
{ "model": "qwen3-4b", "gpu": "2", "gpuName": "NVIDIA GeForce RTX 3060", "state": "ready" }
{ "model": "qwen3-8b", "gpu": "1", "gpuName": "NVIDIA GeForce RTX 3060", "ctx": 8192, "state": "ready" },
{ "model": "qwen3-4b", "gpu": "2", "gpuName": "NVIDIA GeForce RTX 3060", "ctx": 8192, "state": "ready" }
]
}
}
@@ -93,6 +93,7 @@ ein paar Sekunden lang ein (2–3 s reichen):
| `node` | lesbarer Name der Box |
| `models[].model` | Modellname, so in der Anfrage angeben |
| `models[].gpu` | Karte, auf der es läuft |
| `models[].ctx` | **Kontextfenster in Tokens**, mit dem das Modell gestartet ist — Eingabe und Ausgabe zusammen. Diesen Wert verwenden, nicht den, den das Modell theoretisch kann: Die Box gibt nur so viel her, wie hier steht. `0` = unbekannt. |
| `models[].state` | `ready` = bereit, `starting` = startet gerade neu (Anfragen warten dann kurz), `unknown` = Box konnte es nicht prüfen |
Es stehen **nur geladene** Modelle in der Liste. Was dort nicht steht, kannst
+20 -8
View File
@@ -417,23 +417,33 @@ _gpu_name_cache: dict = {}
async def _gpu_names() -> dict:
"""{index: name} aller Karten, einmal per nvidia-smi geholt."""
"""{index: {"name", "vramMB"}} aller Karten, einmal per nvidia-smi geholt.
Der VRAM steht fest — die Diagnostic warnt damit vor Modellen, die nicht
auf die Karte passen."""
if _gpu_name_cache:
return _gpu_name_cache
try:
proc = await asyncio.create_subprocess_exec(
"nvidia-smi", "--query-gpu=index,name", "--format=csv,noheader",
"nvidia-smi", "--query-gpu=index,name,memory.total", "--format=csv,noheader,nounits",
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL)
out, _ = await asyncio.wait_for(proc.communicate(), 10)
for line in out.decode().splitlines():
if "," in line:
i, n = line.split(",", 1)
_gpu_name_cache[i.strip()] = n.strip()
parts = [x.strip() for x in line.split(",")]
if len(parts) >= 3:
_gpu_name_cache[parts[0]] = {"name": parts[1], "vramMB": int(float(parts[2]))}
except Exception:
pass
return _gpu_name_cache
def _model_ctx(key: str) -> int:
"""Kontextfenster, mit dem llama-server das Modell startet (-c). Clients
MUESSEN das wissen: rechnen sie mit mehr, scheitern lange Auftraege oder
werden falsch gekuerzt."""
m = re.search(r"(?:^|\s)(?:-c|--ctx-size)\s+(\d+)", _model_cmd(key) or "")
return int(m.group(1)) if m else 0
async def _slots_info() -> list:
"""Pro LLM-Karte: [{gpu, gpuName, model, state}]. state aus llama-swap
/running: ready | starting | stopping | stopped; "unknown", wenn llama-swap
@@ -441,12 +451,14 @@ async def _slots_info() -> list:
slots = _load_slots()
running = await _fetch_loaded()
rmap = {r["model"]: r["state"] for r in running} if running is not None else None
names = await _gpu_names()
gpus = await _gpu_names()
out = []
for card in _llm_cards():
m = slots.get(card, "")
state = "" if not m else ("unknown" if rmap is None else rmap.get(m, "stopped"))
out.append({"gpu": card, "gpuName": names.get(card, ""), "model": m, "state": state})
g = gpus.get(card) or {}
out.append({"gpu": card, "gpuName": g.get("name", ""), "vramMB": g.get("vramMB", 0),
"model": m, "state": state, "ctx": _model_ctx(m) if m else 0})
return out
@@ -973,7 +985,7 @@ async def _handle_models_request(ws, payload: dict) -> None:
await _send(ws, "llm_models", {
"requestId": payload.get("requestId", ""),
"instanceId": INSTANCE_ID, "node": NODE_NAME,
"models": [{"model": x["model"], "gpu": x["gpu"], "gpuName": x["gpuName"],
"models": [{"model": x["model"], "gpu": x["gpu"], "gpuName": x["gpuName"], "ctx": x["ctx"],
"state": x["state"]} for x in info if x["model"] in sel],
})