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:
2026-10-31 15:44:00 +01:00
co-authored by Claude Opus 5.5
parent 93a3070e5c
commit e3fe93e0e9
3 changed files with 89 additions and 14 deletions
+42 -6
View File
@@ -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: