From e3fe93e0e90cd9e92767612295563a8235e57185 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Sat, 31 Oct 2026 15:44:00 +0100 Subject: [PATCH] feat(llm): "Nur downloaden" im Katalog, Karten-Liste "nur heruntergeladene" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- diagnostic/index.html | 52 ++++++++++++++++++++++++++++++++----- diagnostic/server.js | 3 ++- xtts/llm-adapter/adapter.py | 48 +++++++++++++++++++++++++++++----- 3 files changed, 89 insertions(+), 14 deletions(-) diff --git a/diagnostic/index.html b/diagnostic/index.html index 453a808..5fe7dec 100644 --- a/diagnostic/index.html +++ b/diagnostic/index.html @@ -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 @@ '' + '' + - '' + + '' + + ' ' + ''; }).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 = '' + + '' + + '' + '
'; 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) => `
` + (o.value === sel.value ? '✓ ' : '') + escapeHtml(o.textContent) + '
').join('') - + (items.length > 1 || !inp.value ? '' : '
Kein Modell passt zur Suche.
'); + + (() => { + 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 ? `
${hint}
` : ''; + })(); 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; diff --git a/diagnostic/server.js b/diagnostic/server.js index 1b1bd9f..7f25507 100644 --- a/diagnostic/server.js +++ b/diagnostic/server.js @@ -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") { diff --git a/xtts/llm-adapter/adapter.py b/xtts/llm-adapter/adapter.py index d0a98c7..47e1d9b 100644 --- a/xtts/llm-adapter/adapter.py +++ b/xtts/llm-adapter/adapter.py @@ -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: