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:
+97
-1
@@ -839,6 +839,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</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) -->
|
<!-- Modell-Katalog: GGUF-Modelle herunterladen/aktivieren (Stage D) -->
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<div style="display:flex;align-items:center;gap:14px;margin-bottom:8px;">
|
<div style="display:flex;align-items:center;gap:14px;margin-bottom:8px;">
|
||||||
@@ -2587,7 +2606,7 @@
|
|||||||
if (p.ok) setTimeout(closeDeviceCreds, 700);
|
if (p.ok) setTimeout(closeDeviceCreds, 700);
|
||||||
return;
|
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') {
|
if (msg.type === 'llm_response') {
|
||||||
const p = msg.payload || {};
|
const p = msg.payload || {};
|
||||||
const out = document.getElementById('llm-test-result');
|
const out = document.getElementById('llm-test-result');
|
||||||
@@ -8972,6 +8991,7 @@
|
|||||||
function renderLlmCatalog() {
|
function renderLlmCatalog() {
|
||||||
const box = document.getElementById('llm-catalog-list');
|
const box = document.getElementById('llm-catalog-list');
|
||||||
if (!box) return;
|
if (!box) return;
|
||||||
|
renderLlmCardSettings();
|
||||||
if (!_llmCatalog.length) { box.innerHTML = '<span style="color:#6a6a88;">Katalog leer.</span>'; return; }
|
if (!_llmCatalog.length) { box.innerHTML = '<span style="color:#6a6a88;">Katalog leer.</span>'; return; }
|
||||||
const boxes = onlineLlmBoxes();
|
const boxes = onlineLlmBoxes();
|
||||||
const th = 'style="padding:6px 8px;text-align:left;position:sticky;top:0;background:#12122A;"';
|
const th = 'style="padding:6px 8px;text-align:left;position:sticky;top:0;background:#12122A;"';
|
||||||
@@ -9035,6 +9055,7 @@
|
|||||||
if (!job || !job.key) return;
|
if (!job || !job.key) return;
|
||||||
_llmJobs[_llmJobId(job)] = job;
|
_llmJobs[_llmJobId(job)] = job;
|
||||||
renderLlmDownloads();
|
renderLlmDownloads();
|
||||||
|
renderLlmCardSettings();
|
||||||
// Fertig geladen → Katalog-Status ("✓ ki-box") nachziehen.
|
// Fertig geladen → Katalog-Status ("✓ ki-box") nachziehen.
|
||||||
if (job.phase === 'ready' && typeof requestWorkers === 'function') requestWorkers();
|
if (job.phase === 'ready' && typeof requestWorkers === 'function') requestWorkers();
|
||||||
}
|
}
|
||||||
@@ -9110,6 +9131,81 @@
|
|||||||
if (Object.values(_llmJobs).some(j => j.phase === 'queued')) renderLlmDownloads();
|
if (Object.values(_llmJobs).some(j => j.phase === 'queued')) renderLlmDownloads();
|
||||||
}, 5000);
|
}, 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 ────────────────────────────────────────────────────
|
// ── LLM-Karten ────────────────────────────────────────────────────
|
||||||
const SLOT_STATE = {
|
const SLOT_STATE = {
|
||||||
ready: ['#3FFF3F', '● geladen'], starting: ['#FFD60A', '◐ startet …'],
|
ready: ['#3FFF3F', '● geladen'], starting: ['#FFD60A', '◐ startet …'],
|
||||||
|
|||||||
@@ -80,8 +80,8 @@ ein paar Sekunden lang ein (2–3 s reichen):
|
|||||||
"instanceId": "llm@ki-box",
|
"instanceId": "llm@ki-box",
|
||||||
"node": "ki-box",
|
"node": "ki-box",
|
||||||
"models": [
|
"models": [
|
||||||
{ "model": "qwen3-8b", "gpu": "1", "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", "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 |
|
| `node` | lesbarer Name der Box |
|
||||||
| `models[].model` | Modellname, so in der Anfrage angeben |
|
| `models[].model` | Modellname, so in der Anfrage angeben |
|
||||||
| `models[].gpu` | Karte, auf der es läuft |
|
| `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 |
|
| `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
|
Es stehen **nur geladene** Modelle in der Liste. Was dort nicht steht, kannst
|
||||||
|
|||||||
@@ -417,23 +417,33 @@ _gpu_name_cache: dict = {}
|
|||||||
|
|
||||||
|
|
||||||
async def _gpu_names() -> 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:
|
if _gpu_name_cache:
|
||||||
return _gpu_name_cache
|
return _gpu_name_cache
|
||||||
try:
|
try:
|
||||||
proc = await asyncio.create_subprocess_exec(
|
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)
|
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL)
|
||||||
out, _ = await asyncio.wait_for(proc.communicate(), 10)
|
out, _ = await asyncio.wait_for(proc.communicate(), 10)
|
||||||
for line in out.decode().splitlines():
|
for line in out.decode().splitlines():
|
||||||
if "," in line:
|
parts = [x.strip() for x in line.split(",")]
|
||||||
i, n = line.split(",", 1)
|
if len(parts) >= 3:
|
||||||
_gpu_name_cache[i.strip()] = n.strip()
|
_gpu_name_cache[parts[0]] = {"name": parts[1], "vramMB": int(float(parts[2]))}
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return _gpu_name_cache
|
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:
|
async def _slots_info() -> list:
|
||||||
"""Pro LLM-Karte: [{gpu, gpuName, model, state}]. state aus llama-swap
|
"""Pro LLM-Karte: [{gpu, gpuName, model, state}]. state aus llama-swap
|
||||||
/running: ready | starting | stopping | stopped; "unknown", wenn llama-swap
|
/running: ready | starting | stopping | stopped; "unknown", wenn llama-swap
|
||||||
@@ -441,12 +451,14 @@ async def _slots_info() -> list:
|
|||||||
slots = _load_slots()
|
slots = _load_slots()
|
||||||
running = await _fetch_loaded()
|
running = await _fetch_loaded()
|
||||||
rmap = {r["model"]: r["state"] for r in running} if running is not None else None
|
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 = []
|
out = []
|
||||||
for card in _llm_cards():
|
for card in _llm_cards():
|
||||||
m = slots.get(card, "")
|
m = slots.get(card, "")
|
||||||
state = "" if not m else ("unknown" if rmap is None else rmap.get(m, "stopped"))
|
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
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -973,7 +985,7 @@ async def _handle_models_request(ws, payload: dict) -> None:
|
|||||||
await _send(ws, "llm_models", {
|
await _send(ws, "llm_models", {
|
||||||
"requestId": payload.get("requestId", ""),
|
"requestId": payload.get("requestId", ""),
|
||||||
"instanceId": INSTANCE_ID, "node": NODE_NAME,
|
"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],
|
"state": x["state"]} for x in info if x["model"] in sel],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user