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: