fix(llm): Kontext-Auswahl zeigt wieder Maximum + GB — auch fuer Modelle auf der Platte

Seit "erst waehlen, dann Laden" zeigte die Kontext-Liste fuer ein noch
nicht geladenes Modell nur feste Stufen — ohne Maximum und ohne
Speicherbedarf. Jetzt:

- llm-adapter: disk.models[].profiles — pro heruntergeladener
  Quantisierung Maximum (context_length) und GB je Stufe aus dem Kopf der
  GGUF (gleiche Rechnung wie fuer die geladene Karte, Annahme: alles auf
  der Karte). Gesplittete Modelle ausgenommen.
- Diagnostic (Karte + Katalog-Spalte): Stufen bis zum Maximum, "(Maximum)"
  markiert, GB je Stufe, "⚠ passt nicht" bzw. bei MoE "⚠ nur mit
  Aufteilen". Nicht heruntergeladen: feste Stufen + Hinweis im Tooltip.
  Katalog rechnet mit der gewaehlten Ziel-Karte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 15:49:10 +01:00
co-authored by Claude Opus 5.5
parent e3fe93e0e9
commit 126e33c4c0
2 changed files with 69 additions and 17 deletions
+40 -15
View File
@@ -9353,13 +9353,45 @@
} }
// Kontext-Stufen fuer Modelle, deren Speicherbedarf die Box noch nicht kennt // Kontext-Stufen fuer Modelle, deren Speicherbedarf die Box noch nicht kennt
const _CTX_STEPS = [4096, 8192, 16384, 32768, 65536, 131072]; const _CTX_STEPS = [4096, 8192, 16384, 32768, 65536, 131072];
function _ctxSteps(cur) { return cur && !_CTX_STEPS.includes(cur) ? [cur, ..._CTX_STEPS].sort((a, b) => a - b) : _CTX_STEPS; }
function _ctxLabel(n) { return n >= 1024 ? (n % 1024 ? (n / 1024).toFixed(1) : n / 1024) + 'k' : String(n); } function _ctxLabel(n) { return n >= 1024 ? (n % 1024 ? (n / 1024).toFixed(1) : n / 1024) + 'k' : String(n); }
// Kontext-Auswahl fuer ein Modell auf einer Box: was es maximal kann und was
// jede Stufe an Grafikspeicher kostet. Quelle: das geladene Modell der Karte
// (inkl. MoE-Aufteilung) oder — liegt es nur auf der Platte — die Datei
// dort (Annahme: alles auf der Karte). Nicht heruntergeladen: feste Stufen.
function _ctxChoices(modelId, box, sl) {
if (sl && modelId && modelId === sl.model && Array.isArray(sl.options) && sl.options.length)
return { list: sl.options.map(o => ({ ctx: o.ctx, est: o.estMB })), ctxMax: sl.ctxMax || 0, moe: !!sl.moe, known: true, loaded: true };
const m = _llmCatalog.find(x => x.id === modelId);
const d = m && box && box.disk && (box.disk.models || []).find(x => x.repo === m.hfRepo);
const profs = (d && d.profiles) || [];
const p = profs.find(x => String(x.quant || '').toUpperCase() === String((m && m.quant) || '').toUpperCase()) || profs[0];
if (p && Array.isArray(p.options) && p.options.length)
return { list: p.options.map(o => ({ ctx: o.ctx, est: o.estMB })), ctxMax: p.ctxMax || 0, moe: !!p.moe, known: true };
return { list: _CTX_STEPS.map(n => ({ ctx: n, est: 0 })), ctxMax: 0, moe: false, known: false };
}
function _ctxOptionsHtml(ch, selCtx, vramMB) {
let list = ch.list;
if (selCtx && !list.some(o => o.ctx === selCtx)) list = [{ ctx: selCtx, est: 0 }, ...list];
return list.map(o => {
const gb = o.est ? ` · ca. ${(o.est / 1024).toFixed(1).replace('.', ',')} GB` : '';
const over = o.est && vramMB && o.est > vramMB * 0.97;
const warn = over ? (ch.moe && !ch.loaded ? ' ⚠ nur mit Aufteilen' : ' ⚠ passt nicht') : '';
const max = ch.ctxMax && o.ctx === ch.ctxMax ? ' (Maximum)' : '';
return `<option value="${o.ctx}"${o.ctx === selCtx ? ' selected' : ''}>${_ctxLabel(o.ctx)} Tokens${max}${gb}${warn}</option>`;
}).join('');
}
function _ctxTitle(ch) {
return 'Kontextfenster (Eingabe + Ausgabe).' + (ch.known
? (ch.ctxMax ? ` Das Modell kann höchstens ${ch.ctxMax} Tokens.` : '') + ' GB = geschätzter Grafikspeicher für Modell + Kontext'
+ (ch.loaded ? ' auf dieser Karte.' : (ch.moe ? ' (alles auf der Karte — mit Aufteilen der Experten passt mehr).' : '.'))
: ' Maximum und Speicherbedarf kennt die Box erst, wenn das Modell heruntergeladen ist.');
}
// Auswahl im Katalog (Ziel-Karte, Kontext) je Modell — ueberlebt das Neuzeichnen // Auswahl im Katalog (Ziel-Karte, Kontext) je Modell — ueberlebt das Neuzeichnen
const _llmCatSel = {}; const _llmCatSel = {};
function llmCatPick(i, field, value) { function llmCatPick(i, field, value) {
const m = _llmCatalog[i]; const m = _llmCatalog[i];
if (m) _llmCatSel[m.id] = { ...(_llmCatSel[m.id] || {}), [field]: value }; if (m) _llmCatSel[m.id] = { ...(_llmCatSel[m.id] || {}), [field]: value };
if (field === 'target') renderLlmCatalog(); // Kontext-Liste gilt pro Box (wartet, bis das Dropdown zu ist)
} }
function renderLlmCatalog() { function renderLlmCatalog() {
const box = document.getElementById('llm-catalog-list'); const box = document.getElementById('llm-catalog-list');
@@ -9394,6 +9426,8 @@
const pre = targets.some(t => t.value === pick.target) ? pick.target const pre = targets.some(t => t.value === pick.target) ? pick.target
: (targets.find(t => t.slot && !t.slot.model) || targets[0] || {}).value; : (targets.find(t => t.slot && !t.slot.model) || targets[0] || {}).value;
const ctxPick = pick.ctx || m.ctx || 8192; const ctxPick = pick.ctx || m.ctx || 8192;
const tgtObj = targets.find(t => t.value === pre) || {};
const chCat = _ctxChoices(m.id, tgtObj.box, tgtObj.slot);
const opts = targets.length const opts = targets.length
? targets.map(t => `<option value="${escapeHtml(t.value)}"${t.value === pre ? ' selected' : ''}>${escapeHtml(t.label)}</option>`).join('') ? targets.map(t => `<option value="${escapeHtml(t.value)}"${t.value === pre ? ' selected' : ''}>${escapeHtml(t.label)}</option>`).join('')
: '<option value="">(keine Box online)</option>'; : '<option value="">(keine Box online)</option>';
@@ -9407,8 +9441,8 @@
'<td ' + td + vramStyle + 'white-space:nowrap;">' + vram + '</td>' + '<td ' + td + vramStyle + 'white-space:nowrap;">' + vram + '</td>' +
'<td ' + td + 'white-space:nowrap;">' + haveTxt + '</td>' + '<td ' + td + 'white-space:nowrap;">' + haveTxt + '</td>' +
'<td ' + td + '"><select id="llm-cat-box-' + i + '" onchange="llmCatPick(' + i + ', \'target\', this.value)" style="background:#1E1E2E;border:1px solid #333;border-radius:4px;padding:3px 6px;color:#E0E0F0;font-size:11px;">' + opts + '</select></td>' + '<td ' + td + '"><select id="llm-cat-box-' + i + '" onchange="llmCatPick(' + i + ', \'target\', this.value)" style="background:#1E1E2E;border:1px solid #333;border-radius:4px;padding:3px 6px;color:#E0E0F0;font-size:11px;">' + opts + '</select></td>' +
'<td ' + td + '"><select id="llm-cat-ctx-' + i + '" onchange="llmCatPick(' + i + ', \'ctx\', Number(this.value))" title="Kontextfenster, mit dem das Modell geladen wird — mehr Kontext braucht mehr Grafikspeicher" style="background:#1E1E2E;border:1px solid #333;border-radius:4px;padding:3px 6px;color:#E0E0F0;font-size:11px;">' '<td ' + td + '"><select id="llm-cat-ctx-' + i + '" onchange="llmCatPick(' + i + ', \'ctx\', Number(this.value))" title="' + escapeHtml(_ctxTitle(chCat)) + '" style="background:#1E1E2E;border:1px solid #333;border-radius:4px;padding:3px 6px;color:#E0E0F0;font-size:11px;max-width:230px;">'
+ _ctxSteps(ctxPick).map(n => `<option value="${n}"${n === ctxPick ? ' selected' : ''}>${_ctxLabel(n)} Tokens</option>`).join('') + '</select></td>' + + _ctxOptionsHtml(chCat, ctxPick, (tgtObj.slot && tgtObj.slot.vramMB) || 0) + '</select></td>' +
'<td ' + td + 'white-space:nowrap;"><button class="btn secondary" ' + (boxes.length ? '' : 'disabled') + ' onclick="provisionModel(' + i + ')" title="Herunterladen (falls nötig) und in die Ziel-Karte laden" style="padding:3px 10px;font-size:11px;">Download + Laden</button>' '<td ' + td + 'white-space:nowrap;"><button class="btn secondary" ' + (boxes.length ? '' : 'disabled') + ' onclick="provisionModel(' + i + ')" title="Herunterladen (falls nötig) und in die Ziel-Karte laden" style="padding:3px 10px;font-size:11px;">Download + Laden</button>'
+ ' <button class="btn secondary" ' + (boxes.length ? '' : 'disabled') + ' onclick="downloadModelOnly(' + i + ')" title="Nur auf die Box herunterladen — in keine Karte laden (Ziel-Karte und Kontext spielen dafür keine Rolle)" style="padding:3px 10px;font-size:11px;">Nur downloaden</button></td>' + + ' <button class="btn secondary" ' + (boxes.length ? '' : 'disabled') + ' onclick="downloadModelOnly(' + i + ')" title="Nur auf die Box herunterladen — in keine Karte laden (Ziel-Karte und Kontext spielen dafür keine Rolle)" style="padding:3px 10px;font-size:11px;">Nur downloaden</button></td>' +
'</tr>'; '</tr>';
@@ -9593,20 +9627,11 @@
// Speicherbedarf nicht → feste Stufen ohne GB-Angabe. // Speicherbedarf nicht → feste Stufen ohne GB-Angabe.
const sameModel = shown && shown === sl.model; const sameModel = shown && shown === sl.model;
const selCtx = pend ? pend.ctx : curCtx; const selCtx = pend ? pend.ctx : curCtx;
let ctxList = sameModel && Array.isArray(sl.options) && sl.options.length const ch = _ctxChoices(shown, b, sl);
? sl.options.map(o => ({ ctx: o.ctx, est: o.estMB })) const ctxOpts = _ctxOptionsHtml(ch, selCtx, sl.vramMB || 0);
: _CTX_STEPS.map(n => ({ ctx: n, est: 0 }));
if (selCtx && !ctxList.some(o => o.ctx === selCtx)) ctxList = [{ ctx: selCtx, est: 0 }, ...ctxList];
const vramMB = sl.vramMB || 0;
const ctxOpts = ctxList.map(o => {
const label = o.ctx >= 1024 ? (o.ctx % 1024 ? (o.ctx / 1024).toFixed(1) : o.ctx / 1024) + 'k' : o.ctx;
const gb = o.est ? ` · ca. ${(o.est / 1024).toFixed(1).replace('.', ',')} GB` : '';
const tooBig = o.est && vramMB && o.est > vramMB * 0.97 ? ' ⚠ passt nicht' : '';
return `<option value="${o.ctx}"${o.ctx === selCtx ? ' selected' : ''}>${label} Tokens${gb}${tooBig}</option>`;
}).join('');
const ctxSel = shown const ctxSel = shown
? `<select data-key="${escapeHtml(pkey)}" data-model="${escapeHtml(shown)}" ? `<select data-key="${escapeHtml(pkey)}" data-model="${escapeHtml(shown)}"
onchange="onLlmCardCtxPick(this)" ${job ? 'disabled' : ''} title="Kontextfenster (Eingabe + Ausgabe).${sameModel ? ' GB = geschätzter Grafikspeicher für Modell + Kontext auf dieser Karte.' : ' Speicherbedarf kennt die Box erst nach dem ersten Laden.'}${sameModel && sl.ctxMax ? ' Das Modell kann höchstens ' + sl.ctxMax + ' Tokens.' : ''}" onchange="onLlmCardCtxPick(this)" ${job ? 'disabled' : ''} title="${escapeHtml(_ctxTitle(ch))}"
style="flex:1;min-width:0;background:#1E1E2E;border:1px solid #333;border-radius:4px;padding:5px 6px;color:#E0E0F0;font-size:12px;">${ctxOpts}</select>` style="flex:1;min-width:0;background:#1E1E2E;border:1px solid #333;border-radius:4px;padding:5px 6px;color:#E0E0F0;font-size:12px;">${ctxOpts}</select>`
: ''; : '';
const ctxCell = `<div style="display:flex;gap:6px;align-items:center;min-width:0;">${ctxSel} const ctxCell = `<div style="display:flex;gap:6px;align-items:center;min-width:0;">${ctxSel}
+29 -2
View File
@@ -771,6 +771,13 @@ def _ctx_profile(key: str) -> dict:
weights, mmproj = _model_files(key) weights, mmproj = _model_files(key)
if not weights: if not weights:
return {} return {}
return _profile_of_files(weights, mmproj, lambda: _model_cpu_moe(key))
def _profile_of_files(weights: str, mmproj, cpu_moe_of=lambda: 0) -> dict:
"""Wie _ctx_profile, aber fuer beliebige Dateien — auch heruntergeladene
Modelle, die (noch) auf keiner Karte liegen. cpu_moe_of: Aufteilung der
Experten (0 = Schaetzung "alles auf der Karte")."""
meta = _gguf_meta(weights) meta = _gguf_meta(weights)
arch = meta.get("general.architecture") arch = meta.get("general.architecture")
if not arch: if not arch:
@@ -784,7 +791,7 @@ def _ctx_profile(key: str) -> dict:
ctx_max = int(g("context_length", 0) or 0) ctx_max = int(g("context_length", 0) or 0)
exp_by_layer = meta.get("_expert_bytes_by_layer") or {} exp_by_layer = meta.get("_expert_bytes_by_layer") or {}
moe = bool(exp_by_layer) or int(g("expert_count", 0) or 0) > 0 moe = bool(exp_by_layer) or int(g("expert_count", 0) or 0) > 0
cpu_moe = _model_cpu_moe(key) if moe else 0 cpu_moe = cpu_moe_of() if moe else 0
if cpu_moe: if cpu_moe:
layers = sorted(exp_by_layer) if cpu_moe < 0 else sorted(exp_by_layer)[:cpu_moe] layers = sorted(exp_by_layer) if cpu_moe < 0 else sorted(exp_by_layer)[:cpu_moe]
ram_mb = sum(exp_by_layer[l] for l in layers) / 1048576 ram_mb = sum(exp_by_layer[l] for l in layers) / 1048576
@@ -1364,6 +1371,25 @@ async def _prefetch(ws, key: str, repo: str, files: list, card: str, cancel: thr
return err return err
def _disk_profiles(repo_dir: str) -> list:
"""Kontext-Profil (Maximum + GB je Stufe, Annahme: alles auf der Karte) je
heruntergeladener Quantisierung — damit die Diagnostic auch fuer Modelle,
die auf keiner Karte liegen, zeigen kann, was sie koennen und kosten."""
import glob
files = [p for p in glob.glob(os.path.join(repo_dir, "snapshots", "*", "**", "*.gguf"), recursive=True)
if os.path.isfile(p)]
mmproj = next((p for p in files if "mmproj" in os.path.basename(p).lower()), None)
out = []
for w in files:
if not _is_main_gguf(w) or _SPLIT_RE.search(w):
continue # gesplittete Modelle: Groesse waere nur die des ersten Teils
prof = _profile_of_files(w, mmproj)
if prof:
out.append({"quant": _quant_of(w), **{k: prof[k] for k in ("ctxMax", "options", "moe") if k in prof},
**({"expertsMB": prof["expertsMB"]} if "expertsMB" in prof else {})})
return out
def _disk_info() -> dict: def _disk_info() -> dict:
"""Freier Platz + fertig heruntergeladene Modelle (je Repo) fuer die Diagnostic.""" """Freier Platz + fertig heruntergeladene Modelle (je Repo) fuer die Diagnostic."""
out = {"freeBytes": 0, "models": []} out = {"freeBytes": 0, "models": []}
@@ -1381,7 +1407,8 @@ def _disk_info() -> dict:
except OSError: except OSError:
pass pass
if size: if size:
out["models"].append({"repo": name[len("models--"):].replace("--", "/", 1), "bytes": size}) out["models"].append({"repo": name[len("models--"):].replace("--", "/", 1), "bytes": size,
"profiles": _disk_profiles(os.path.join(MODELS_DIR, name))})
except OSError as e: except OSError as e:
logger.info("Plattenbelegung nicht lesbar: %s", e) logger.info("Plattenbelegung nicht lesbar: %s", e)
return out return out