+ 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(`
`);
+ }
+ }
+ 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],
})