From 3cf36d436b40fe82b80d6486e6454962a11d7411 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Sun, 21 Jun 2026 18:26:27 +0200 Subject: [PATCH] feat: echter Download-Fortschritt via Datei-Polling `hf` gibt im Nicht-TTY-Modus keinen Fortschritt aus (am Bosgame verifiziert: 0 CR-Frames). Stattdessen pollt jobengine.attach_download_progress die wachsende /.cache/huggingface/download/*.incomplete-Datei gegen die Gesamtgroesse aus der HF-Tree-API (cookbook.hf_file_size) -> exaktes %. - attach_download_progress an /api/download, install-recipe, install-model - Frontend (Aktivitaet + Server-Karte): nutzt job.progress bevorzugt, Log-%-Parsing bleibt Fallback fuer Tools, die selbst Prozente ausgeben Co-Authored-By: Claude Opus 4.8 --- jobengine.py | 33 +++++++++++++++++++++++++++++++++ routers/cookbook.py | 18 +++++++++++++++++- routers/models.py | 4 +++- static/js/panels/jobs.js | 4 +++- static/js/panels/server.js | 16 ++++++++++++---- 5 files changed, 68 insertions(+), 7 deletions(-) diff --git a/jobengine.py b/jobengine.py index 43c7ea9..326213b 100644 --- a/jobengine.py +++ b/jobengine.py @@ -6,6 +6,7 @@ zeilenweisem Log-Capture. Keine Persistenz, kein Broker. Genutzt von allen Routern, die laenger laufende Shell-Befehle anstossen (Download, Update, ...). """ +import glob import os import shlex import subprocess @@ -102,6 +103,38 @@ def _run_job(job_id: str, args: list[str], env: dict | None = None, stdin_data: job["finished_at"] = time.time() +def attach_download_progress(job_id: str, local_dir: str, total_bytes: int) -> None: + """Echten Download-Fortschritt (in %) auf den Job legen. `hf` gibt im Nicht-TTY- + Modus keinen Fortschritt aus, schreibt aber in /.cache/huggingface/ + download/*.incomplete (waechst). Wir vergleichen dessen Groesse mit total_bytes + (aus der HF-Tree-API). Ein Daemon-Thread aktualisiert job["progress"].""" + if not total_bytes or total_bytes <= 0: + return + job = JOBS.get(job_id) + if job is not None: + job["progress"] = 0 + + def _watch(): + pat = os.path.join(local_dir, ".cache", "huggingface", "download", "*.incomplete") + while True: + j = JOBS.get(job_id) + if not j or j["state"] in ("done", "failed", "canceled"): + break + try: + inc = glob.glob(pat) + cur = sum(os.path.getsize(f) for f in inc) if inc else 0 + if cur: + j["progress"] = min(99, int(cur * 100 / total_bytes)) + except Exception: # noqa: BLE001 + pass + time.sleep(1.0) + j = JOBS.get(job_id) + if j and j["state"] == "done": + j["progress"] = 100 + + threading.Thread(target=_watch, daemon=True).start() + + def cancel_job(job_id: str) -> bool: """Laufenden Job abbrechen: Prozess terminieren. Liefert False, wenn der Job nicht (mehr) laeuft oder unbekannt ist.""" diff --git a/routers/cookbook.py b/routers/cookbook.py index b23edac..df2395a 100644 --- a/routers/cookbook.py +++ b/routers/cookbook.py @@ -14,7 +14,7 @@ from auth import auth from hw_math import evaluate_fit, max_ctx_for from config import MODELS_DIR, CMD_TEMPLATE, DEFAULT_TTL, HF_DOWNLOAD_ENV, hf_bin from llamaswap import read_config, write_config -from jobengine import start_job, JOBS +from jobengine import start_job, JOBS, attach_download_progress from recipes import RECIPES, UPGRADES router = APIRouter(prefix="/api/cookbook", dependencies=[Depends(auth)]) @@ -163,6 +163,7 @@ def install_recipe(req: InstallRecipeReq): args = [hf_bin(), "download", m["repo"], file, "--local-dir", str(target)] jid = start_job(args, f"download {m['name']}", env=env) JOBS[jid]["result_path"] = str(target / file) + attach_download_progress(jid, str(target), hf_file_size(m["repo"], file)) job_ids.append(jid) # Eintrag jetzt schon schreiben — optimaler Kontext, aber gedeckelt fuer schnellen Erststart. ctx = min(max_ctx_for(m["params_b"], m["quant"], ram_gb), 32768) @@ -173,6 +174,20 @@ def install_recipe(req: InstallRecipeReq): return {"job_ids": job_ids, "count": len(job_ids)} +def hf_file_size(repo: str, file: str) -> int: + """Groesse einer Datei im HF-Repo (Bytes) fuer die Fortschrittsanzeige. 0 wenn unbekannt. + GGUFs sind LFS -> ggf. unter 'lfs.size'.""" + try: + with httpx.Client(timeout=10.0) as c: + tree = c.get(f"https://huggingface.co/api/models/{repo}/tree/main").json() + for f in tree: + if isinstance(f, dict) and f.get("path") == file: + return int(f.get("size") or (f.get("lfs") or {}).get("size") or 0) + except Exception: # noqa: BLE001 + return 0 + return 0 + + def _pick_gguf(repo: str, quant: str = "Q4_K_M") -> str | None: """Beste GGUF-Datei eines Repos auflösen: bevorzugt gewünschten Quant, keine Split-Teile.""" try: @@ -234,6 +249,7 @@ def install_model(req: InstallModelReq): jid = start_job([hf_bin(), "download", req.repo, file, "--local-dir", str(target)], f"download {req.repo.split('/')[-1]}", env=env) JOBS[jid]["result_path"] = str(target / file) + attach_download_progress(jid, str(target), hf_file_size(req.repo, file)) cfg = read_config() ctx = min(max_ctx_for(req.params_b, req.quant, ram_gb), 32768) cmd = CMD_TEMPLATE.replace("{model}", str(target / file)).replace("{ctx}", str(ctx)) diff --git a/routers/models.py b/routers/models.py index daac96a..d4943db 100644 --- a/routers/models.py +++ b/routers/models.py @@ -15,7 +15,8 @@ from ruamel.yaml.scalarstring import LiteralScalarString from auth import auth from config import (CMD_TEMPLATE, CONFIG_PATH, DEFAULT_TTL, HF_DOWNLOAD_ENV, LLAMA_SWAP_URL, MODELS_DIR, TOKEN, hf_bin) -from jobengine import JOBS, start_job +from jobengine import JOBS, start_job, attach_download_progress +from routers.cookbook import hf_file_size from llamaswap import _swap_get, read_config, write_config from hw_math import extract_params_b, max_ctx_for, estimate_memory_gb import re @@ -146,6 +147,7 @@ def download(req: DownloadReq): env["HF_TOKEN"] = req.hf_token job_id = start_job(args, f"download {req.repo}/{req.file}", env=env) JOBS[job_id]["result_path"] = str(target / req.file) + attach_download_progress(job_id, str(target), hf_file_size(req.repo, req.file)) return {"job_id": job_id, "expected_path": str(target / req.file)} diff --git a/static/js/panels/jobs.js b/static/js/panels/jobs.js index 457d70b..e0a225c 100644 --- a/static/js/panels/jobs.js +++ b/static/js/panels/jobs.js @@ -16,9 +16,11 @@ const MAX_HIST = 60; function statusBadge(s) { return s === "done" ? 'fertig' : s === "failed" ? 'fehler' : s === "canceled" ? 'abgebrochen' : 'läuft…'; } function dotClass(s) { return s === "done" ? "on" : (s === "failed" || s === "canceled") ? "" : "load"; } -// Fortschritt in % aus der letzten Log-Zeile ziehen (tqdm schreibt z.B. " 45%|…"). +// Fortschritt in %: bevorzugt der exakte Wert vom Backend (Download-Datei-Polling), +// sonst Fallback auf eine %-Angabe in der letzten Log-Zeile. function jobPct(j) { if (j.state !== "running") return null; + if (typeof j.progress === "number") return Math.min(100, j.progress); const last = (j.log || [])[j.log.length - 1] || ""; const m = last.match(/(\d+(?:\.\d+)?)\s*%/); return m ? Math.min(100, parseFloat(m[1])) : null; diff --git a/static/js/panels/server.js b/static/js/panels/server.js index ce17f50..c0ee8c0 100644 --- a/static/js/panels/server.js +++ b/static/js/panels/server.js @@ -141,12 +141,20 @@ function onJobs(jobs) { : canceled ? 'abgebrochen' : 'läuft…'; - // Fortschrittsbalken aus der letzten Log-Zeile (tqdm: " 45%|…") - const last = (j.log || [])[j.log.length - 1] || ""; - const pm = !terminal && last.match(/(\d+(?:\.\d+)?)\s*%/); + // Fortschrittsbalken: exakter Backend-Wert (Download) bevorzugt, sonst %-Angabe im Log. + let pval = null; + if (!terminal) { + if (typeof j.progress === "number") { + pval = j.progress; + } else { + const last = (j.log || [])[j.log.length - 1] || ""; + const pm = last.match(/(\d+(?:\.\d+)?)\s*%/); + if (pm) pval = parseFloat(pm[1]); + } + } const bar = $("#w-update-bar"); if (bar) { - if (pm) { bar.style.display = ""; bar.querySelector("i").style.width = Math.min(100, parseFloat(pm[1])) + "%"; } + if (pval != null) { bar.style.display = ""; bar.querySelector("i").style.width = Math.min(100, pval) + "%"; } else bar.style.display = "none"; } $("#w-update-cancel").style.display = terminal ? "none" : "";