feat(llm): "Nur downloaden" im Katalog, Karten-Liste "nur heruntergeladene"
Katalog: "Laden" heisst jetzt "Download + Laden", daneben "Nur downloaden": das Modell kommt auf die Platte der Box (aus der Ziel-Karten-Auswahl), in keine Karte. Liegt es schon dort, ein Hinweis statt Download. llm-adapter: llm_provision_model mit downloadOnly → _download_only: Dateiliste, Plattenpruefung, paralleler Download (strict: ohne Fallback auf llama.cpp, jeder Fehler kommt als Grund zurueck), keine Karte, keine Registry. Download-Fenster: "Heruntergeladen — liegt auf der Box". Karten-Modellwahl: Haken "nur heruntergeladene" in der Liste, Standard an. Zeigt nur, was schon auf dieser Box liegt; "— leer —" und die aktuelle Auswahl bleiben immer sichtbar. Meldet die Box keine Platte (alter Adapter), ist der Haken aus und gesperrt. Getestet: download-only gegen HuggingFace (tiny-llamas) und mit falschem Repo; Haken/Suche/Knoepfe in headless Chromium mit der echten Seite. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+45
-7
@@ -2731,7 +2731,8 @@
|
||||
const p = msg.payload || {};
|
||||
const st = document.getElementById('llm-catalog-status');
|
||||
if (st) {
|
||||
if (p.deleted) { st.textContent = `✓ '${p.key}' von der Box gelöscht` + (p.freedBytes ? ` (${_fmtBytes(p.freedBytes)} frei geworden)` : ''); st.style.color = '#8888AA'; }
|
||||
if (p.downloadOnly && p.ok) { st.textContent = `✓ '${p.key}' heruntergeladen — liegt auf der Box`; st.style.color = '#3FFF3F'; }
|
||||
else if (p.deleted) { st.textContent = `✓ '${p.key}' von der Box gelöscht` + (p.freedBytes ? ` (${_fmtBytes(p.freedBytes)} frei geworden)` : ''); st.style.color = '#8888AA'; }
|
||||
else if (p.removed) { st.textContent = `✓ '${p.key}' entfernt`; st.style.color = '#8888AA'; }
|
||||
else if (p.unloaded) { st.textContent = `✓ Karte ${p.gpu} geleert (${p.key})`; st.style.color = '#8888AA'; }
|
||||
else if (p.ok) { st.textContent = `✓ '${p.key}' geladen/aktiv auf ${p.instanceId || '?'}`; st.style.color = '#3FFF3F'; }
|
||||
@@ -9408,7 +9409,8 @@
|
||||
'<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;">'
|
||||
+ _ctxSteps(ctxPick).map(n => `<option value="${n}"${n === ctxPick ? ' selected' : ''}>${_ctxLabel(n)} Tokens</option>`).join('') + '</select></td>' +
|
||||
'<td ' + td + '"><button class="btn secondary" ' + (boxes.length ? '' : 'disabled') + ' onclick="provisionModel(' + i + ')" style="padding:3px 10px;font-size:11px;">Laden</button></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>'
|
||||
+ ' <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>';
|
||||
}).join('');
|
||||
const diskLine = boxes.filter(b => b.disk).map(b => `${escapeHtml(b.node)}: ${_fmtBytes(b.disk.freeBytes)} frei`).join(' · ');
|
||||
@@ -9496,7 +9498,7 @@
|
||||
} else if (j.phase === 'ready') {
|
||||
pct = 100; color = '#34C759';
|
||||
const card = j.gpu != null && j.gpu !== '' ? `Karte ${escapeHtml(String(j.gpu))}${j.gpuName ? ' (' + escapeHtml(j.gpuName) + ')' : ''}` : 'die Karte';
|
||||
line = `✓ In ${card} geladen — bereit`;
|
||||
line = j.downloadOnly ? '✓ Heruntergeladen — liegt auf der Box, in keine Karte geladen' : `✓ In ${card} geladen — bereit`;
|
||||
const vram = j.vramTotalMB ? ` · VRAM ${(j.vramUsedMB / 1024).toFixed(1)} / ${(j.vramTotalMB / 1024).toFixed(1)} GB belegt` : '';
|
||||
sub = (j.loadSeconds != null ? `Dauer ${_fmtEta(Math.round(j.loadSeconds))}` : '') + vram;
|
||||
} else if (j.phase === 'error') {
|
||||
@@ -9653,19 +9655,35 @@
|
||||
pop.style.cssText = `position:fixed;left:${r.left}px;${below ? 'top:' + (r.bottom + 2) : 'bottom:' + (window.innerHeight - r.top + 2)}px;`
|
||||
+ `width:${Math.max(r.width, 320)}px;z-index:9999;background:#12121E;border:1px solid #444466;border-radius:6px;`
|
||||
+ 'box-shadow:0 6px 20px rgba(0,0,0,.5);padding:6px;';
|
||||
pop.innerHTML = '<input type="search" placeholder="🔍 Modell suchen … (z.B. coder 30b)" style="width:100%;box-sizing:border-box;'
|
||||
// Was liegt schon auf dieser Box? (Repo je Modell aus dem Katalog)
|
||||
const boxId = String(sel.dataset.key || '').slice(0, String(sel.dataset.key || '').lastIndexOf('|'));
|
||||
const wb = onlineLlmBoxes().find(w => w.instanceId === boxId);
|
||||
const onDisk = wb && wb.disk ? new Set((wb.disk.models || []).map(x => x.repo)) : null; // null = Box meldet es nicht
|
||||
const isOnDisk = id => { const m = _llmCatalog.find(x => x.id === id); return !!(m && onDisk && onDisk.has(m.hfRepo)); };
|
||||
pop.innerHTML = '<div style="display:flex;gap:8px;align-items:center;">'
|
||||
+ '<input type="search" placeholder="🔍 Modell suchen … (z.B. coder 30b)" style="flex:1;min-width:0;box-sizing:border-box;'
|
||||
+ 'background:#1E1E2E;border:1px solid #333;border-radius:4px;padding:5px 8px;color:#E0E0F0;font-size:12px;">'
|
||||
+ '<label title="Nur Modelle zeigen, die schon auf dieser Box liegen (kein Download nötig)" style="display:flex;gap:4px;align-items:center;white-space:nowrap;color:#9a9ab5;font-size:11px;cursor:pointer;">'
|
||||
+ '<input type="checkbox"' + (_llmPickerOnlyDisk && onDisk ? ' checked' : '') + (onDisk ? '' : ' disabled') + '> nur heruntergeladene</label></div>'
|
||||
+ '<div style="max-height:300px;overflow-y:auto;margin-top:6px;"></div>';
|
||||
btn.parentElement.appendChild(pop);
|
||||
const inp = pop.querySelector('input'), list = pop.querySelector('div');
|
||||
const inp = pop.querySelector('input[type=search]'), chk = pop.querySelector('input[type=checkbox]');
|
||||
const list = pop.querySelector('div[style*="max-height"]');
|
||||
let items = [], active = 0;
|
||||
const draw = () => {
|
||||
items = [...sel.options].filter(o => !o.value || _llmMatches(o.textContent, inp.value));
|
||||
const only = chk.checked;
|
||||
items = [...sel.options].filter(o => !o.value || o.value === sel.value
|
||||
|| ((!only || isOnDisk(o.value)) && _llmMatches(o.textContent, inp.value)));
|
||||
active = Math.max(0, Math.min(active, items.length - 1));
|
||||
list.innerHTML = items.map((o, i) => `<div data-i="${i}" style="padding:5px 8px;border-radius:4px;cursor:pointer;font-size:12px;`
|
||||
+ `color:${o.style.color || '#E0E0F0'};${i === active ? 'background:#2A2A4A;' : ''}${o.value === sel.value ? 'font-weight:700;' : ''}">`
|
||||
+ (o.value === sel.value ? '✓ ' : '') + escapeHtml(o.textContent) + '</div>').join('')
|
||||
+ (items.length > 1 || !inp.value ? '' : '<div style="color:#6a6a88;padding:5px 8px;font-size:12px;">Kein Modell passt zur Suche.</div>');
|
||||
+ (() => {
|
||||
if (items.some(o => o.value && o.value !== sel.value)) return '';
|
||||
const hint = (inp.value ? 'Kein Modell passt zur Suche.' : (only ? 'Auf dieser Box liegt sonst nichts.' : ''))
|
||||
+ (only ? ' Haken „nur heruntergeladene“ raus, dann siehst du alle.' : '');
|
||||
return hint ? `<div style="color:#6a6a88;padding:5px 8px;font-size:12px;">${hint}</div>` : '';
|
||||
})();
|
||||
const a = list.querySelector(`[data-i="${active}"]`);
|
||||
if (a) a.scrollIntoView({ block: 'nearest' });
|
||||
};
|
||||
@@ -9684,6 +9702,7 @@
|
||||
if (d && +d.dataset.i !== active) { active = +d.dataset.i; draw(); }
|
||||
});
|
||||
inp.addEventListener('input', () => { active = 0; draw(); });
|
||||
chk.addEventListener('change', () => { _llmPickerOnlyDisk = chk.checked; active = 0; draw(); inp.focus(); });
|
||||
inp.addEventListener('keydown', e => {
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); active++; draw(); }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); active--; draw(); }
|
||||
@@ -9696,6 +9715,7 @@
|
||||
setTimeout(() => document.addEventListener('mousedown', _llmPickerOutside), 0);
|
||||
window.addEventListener('scroll', _llmPickerScroll, true);
|
||||
}
|
||||
let _llmPickerOnlyDisk = true; // Haken "nur heruntergeladene" — Standard an, gilt fuer alle Karten
|
||||
function _llmPickerOutside(e) { const p = document.getElementById('llm-picker'); if (p && !p.contains(e.target)) closeLlmPicker(); }
|
||||
function _llmPickerScroll(e) { const p = document.getElementById('llm-picker'); if (p && !p.contains(e.target)) closeLlmPicker(); }
|
||||
function closeLlmPicker() {
|
||||
@@ -10010,6 +10030,24 @@
|
||||
send({ action: 'llm_remove_model', targetInstance: instanceId, key, cancel: true });
|
||||
}
|
||||
|
||||
// Nur auf die Platte der Box (der aus der Ziel-Karten-Auswahl) — in keine Karte
|
||||
function downloadModelOnly(i) {
|
||||
const m = _llmCatalog[i];
|
||||
if (!m) return;
|
||||
const sel = document.getElementById('llm-cat-box-' + i);
|
||||
const target = String((sel && sel.value) || '').split('|')[0];
|
||||
if (!target) return;
|
||||
const b = onlineLlmBoxes().find(w => w.instanceId === target);
|
||||
if (((b && b.disk && b.disk.models) || []).some(x => x.repo === m.hfRepo)) {
|
||||
uiAlert(`${m.id} liegt schon auf ${(b && b.node) || target}.\n\nZum Benutzen: „Download + Laden“ — dann geht es ohne erneuten Download.`);
|
||||
return;
|
||||
}
|
||||
const st = document.getElementById('llm-catalog-status');
|
||||
if (st) { st.textContent = `Lade '${m.id}' auf ${(b && b.node) || target} herunter (ohne Karte)…`; st.style.color = '#FFD60A'; }
|
||||
send({ action: 'llm_provision_model', targetInstance: target, gpu: '', key: m.id, hfRepo: m.hfRepo, quant: m.quant, downloadOnly: true });
|
||||
openLlmDownloads();
|
||||
}
|
||||
|
||||
async function provisionModel(i) {
|
||||
const m = _llmCatalog[i];
|
||||
if (!m) return;
|
||||
|
||||
@@ -3598,13 +3598,14 @@ wss.on("connection", (ws) => {
|
||||
// Sofort als "angefragt" fuehren: kommt danach nie etwas von der Box,
|
||||
// sieht man das im Download-Fenster, statt dass einfach nichts passiert.
|
||||
const queued = llmJobUpdate(msg.targetInstance || "", msg.key || "",
|
||||
{ phase: "queued", requestedAt: Date.now() });
|
||||
{ phase: "queued", requestedAt: Date.now(), downloadOnly: !!msg.downloadOnly });
|
||||
if (queued) broadcast({ type: "llm_provision_progress", payload: queued });
|
||||
sendToRVS_raw({ type: "llm_provision_model", payload: {
|
||||
targetInstance: msg.targetInstance || "",
|
||||
key: msg.key, hfRepo: msg.hfRepo, quant: msg.quant, ctx: msg.ctx, ngl: msg.ngl,
|
||||
cpuMoe: msg.cpuMoe ?? 0, // MoE: 0 Karte, -1 alle Experten in den RAM, -2 automatisch verteilen
|
||||
gpu: msg.gpu ?? "", // Ziel-Karte auf der Box
|
||||
downloadOnly: !!msg.downloadOnly, // nur auf die Platte, in keine Karte
|
||||
}, timestamp: Date.now() });
|
||||
log("info", "llm", `provision '${msg.key}' (${msg.hfRepo}) → ${msg.targetInstance || "?"}`);
|
||||
} else if (msg.action === "llm_unload_model") {
|
||||
|
||||
@@ -1298,7 +1298,7 @@ def _download_parallel(repo: str, commit: str, f: dict, cancel: threading.Event)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
threads = [threading.Thread(target=worker, daemon=True) for _ in range(DOWNLOAD_CONNECTIONS)]
|
||||
threads = [threading.Thread(target=worker, daemon=True) for _ in range(max(1, DOWNLOAD_CONNECTIONS))]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
@@ -1323,18 +1323,22 @@ def _download_parallel(repo: str, commit: str, f: dict, cancel: threading.Event)
|
||||
return ""
|
||||
|
||||
|
||||
async def _prefetch(ws, key: str, repo: str, files: list, card: str, cancel: threading.Event) -> str:
|
||||
async def _prefetch(ws, key: str, repo: str, files: list, card: str, cancel: threading.Event,
|
||||
strict: bool = False) -> str:
|
||||
"""Laedt fehlende Dateien parallel vor. "" = alles da (oder Fallback auf
|
||||
llama.cpp), "abgebrochen" = Benutzer hat abgebrochen."""
|
||||
llama.cpp), "abgebrochen" = Benutzer hat abgebrochen. strict (nur
|
||||
herunterladen): kein Fallback — jeder Fehler kommt als Grund zurueck."""
|
||||
files = [f for f in files if f.get("oid") and f.get("size")]
|
||||
if DOWNLOAD_CONNECTIONS < 2 or not files:
|
||||
if not files:
|
||||
return "keine Dateiliste von HuggingFace" if strict else ""
|
||||
if DOWNLOAD_CONNECTIONS < 2 and not strict:
|
||||
return ""
|
||||
missing = [f for f in files if not os.path.exists(os.path.join(_repo_dir(repo), "blobs", f["oid"]))]
|
||||
if not missing:
|
||||
return ""
|
||||
commit = await _hf_commit(repo)
|
||||
if not commit:
|
||||
return ""
|
||||
return "HuggingFace nicht erreichbar" if strict else ""
|
||||
stop = asyncio.Event()
|
||||
await _emit_progress(ws, key, "start", total=sum(f["size"] for f in files), files=len(files), gpu=card,
|
||||
note=f"Lade mit {DOWNLOAD_CONNECTIONS} Verbindungen")
|
||||
@@ -1354,7 +1358,7 @@ async def _prefetch(ws, key: str, repo: str, files: list, card: str, cancel: thr
|
||||
await watcher
|
||||
except Exception:
|
||||
pass
|
||||
if err and err != "abgebrochen":
|
||||
if err and err != "abgebrochen" and not strict:
|
||||
logger.warning("Paralleler Download von %s fehlgeschlagen (%s) — llama.cpp laedt selbst", repo, err)
|
||||
return ""
|
||||
return err
|
||||
@@ -1493,6 +1497,36 @@ async def _handle_provision(ws, payload: dict) -> None:
|
||||
_provisions.pop(key, None)
|
||||
|
||||
|
||||
async def _download_only(ws, key: str, repo: str, wanted: str, job: dict) -> None:
|
||||
"""Nur auf die Platte der Box laden — keine Karte, keine Registry. Spaeter
|
||||
"Laden" findet die Datei dann als "liegt schon da"."""
|
||||
async def fail(msg: str) -> None:
|
||||
await _emit_progress(ws, key, "error", error=msg, downloadOnly=True)
|
||||
await _provision_result(ws, key, False, error=msg, downloadOnly=True)
|
||||
if not repo:
|
||||
return await fail("hfRepo fehlt")
|
||||
files = await _hf_repo_files(repo)
|
||||
if files is None:
|
||||
return await fail(f"{repo}: nicht gefunden oder HuggingFace nicht erreichbar")
|
||||
quant = _pick_quant(files, wanted) if files else ""
|
||||
if not quant:
|
||||
return await fail(f"In {repo} gibt es keine ladbare GGUF-Datei.")
|
||||
qfiles = _files_for_quant(files, quant)
|
||||
disk_ok, disk_note = await asyncio.to_thread(_check_disk, qfiles)
|
||||
if not disk_ok:
|
||||
return await fail(disk_note)
|
||||
job["phase"] = "download"
|
||||
err = await _prefetch(ws, key, repo, qfiles, "", job["cancel"], strict=True)
|
||||
if err or job["cancel"].is_set():
|
||||
return await fail("Abgebrochen" if (err == "abgebrochen" or job["cancel"].is_set()) else err)
|
||||
await _announce(ws)
|
||||
total = sum(f["size"] for f in qfiles)
|
||||
await _emit_progress(ws, key, "ready", percent=100, bytes=total, total=total, downloadOnly=True, quant=quant,
|
||||
note="; ".join(x for x in (disk_note, f"{quant} liegt auf der Box — in keine Karte geladen") if x))
|
||||
await _provision_result(ws, key, True, downloadOnly=True)
|
||||
logger.info("Nur heruntergeladen: %s (%s, %s)", key, repo, quant)
|
||||
|
||||
|
||||
async def _do_provision(ws, payload: dict, job: dict) -> None:
|
||||
"""Laedt ein Modell in eine LLM-Karte (nur aus der Diagnostic). Ersetzt,
|
||||
was dort lag. Ablauf: Registry + Belegung → Config (llama-swap laedt per
|
||||
@@ -1500,6 +1534,8 @@ async def _do_provision(ws, payload: dict, job: dict) -> None:
|
||||
misst _watch_download den Download."""
|
||||
key = (payload.get("key") or "").strip()
|
||||
repo = (payload.get("hfRepo") or "").strip()
|
||||
if payload.get("downloadOnly"):
|
||||
return await _download_only(ws, key, repo, (payload.get("quant") or "Q4_K_M").strip(), job)
|
||||
cards = _llm_cards()
|
||||
card = str(payload.get("gpu") if payload.get("gpu") is not None else "").strip()
|
||||
if not card and len(cards) == 1:
|
||||
|
||||
Reference in New Issue
Block a user