From 60f2d2e0876b2bc670025078fc58055fde737a13 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Mon, 19 Oct 2026 01:22:06 +0200 Subject: [PATCH] =?UTF-8?q?feat(diagnostic):=20pro=20LLM-Karte=20ein=20Dro?= =?UTF-8?q?pdown=20=E2=80=94=20und=20Kontextfenster=20an=20Clients=20melde?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- diagnostic/index.html | 98 ++++++++++++++++++++++++++++++++++++- docs/LLM-SCHNITTSTELLE.md | 5 +- xtts/llm-adapter/adapter.py | 28 ++++++++--- 3 files changed, 120 insertions(+), 11 deletions(-) diff --git a/diagnostic/index.html b/diagnostic/index.html index e028a77..00800a6 100644 --- a/diagnostic/index.html +++ b/diagnostic/index.html @@ -839,6 +839,25 @@ + +
+
+

LLM-Karten

+ +
+
+

+ Jede Grafikkarte, die auf einer KI-Box fürs LLM vorgesehen ist (LLM_GPUS in deren + .env), 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. +

+
+ Keine LLM-Box online. +
+
+
+
@@ -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 = 'Katalog leer.'; 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 = '' + (old + ? 'Die LLM-Box meldet noch keine Karten — llm-adapter auf der Box aktualisieren.' + : 'Keine LLM-Box online.') + ''; + 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 = ['']; + 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(``); + } + 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(`
+
${escapeHtml(b.node)} · Karte ${escapeHtml(sl.gpu)} +
${escapeHtml(sl.gpuName || '')}${vramGB ? ' · ' + vramGB.toFixed(0) + ' GB' : ''}${sl.ctx ? ' · Kontext ' + sl.ctx : ''}
+ + ${jobTxt || st} +
`); + } + } + 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 …'], diff --git a/docs/LLM-SCHNITTSTELLE.md b/docs/LLM-SCHNITTSTELLE.md index 2ba5b5a..f10c90e 100644 --- a/docs/LLM-SCHNITTSTELLE.md +++ b/docs/LLM-SCHNITTSTELLE.md @@ -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 diff --git a/xtts/llm-adapter/adapter.py b/xtts/llm-adapter/adapter.py index 484e926..d6f92f9 100644 --- a/xtts/llm-adapter/adapter.py +++ b/xtts/llm-adapter/adapter.py @@ -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], })