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 <local-dir>/.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 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-21 18:26:27 +02:00
parent 2cde6ba5a5
commit 3cf36d436b
5 changed files with 68 additions and 7 deletions
+33
View File
@@ -6,6 +6,7 @@ zeilenweisem Log-Capture. Keine Persistenz, kein Broker. Genutzt von allen
Routern, die laenger laufende Shell-Befehle anstossen (Download, Update, ...). Routern, die laenger laufende Shell-Befehle anstossen (Download, Update, ...).
""" """
import glob
import os import os
import shlex import shlex
import subprocess 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() 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 <local_dir>/.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: def cancel_job(job_id: str) -> bool:
"""Laufenden Job abbrechen: Prozess terminieren. Liefert False, wenn der Job """Laufenden Job abbrechen: Prozess terminieren. Liefert False, wenn der Job
nicht (mehr) laeuft oder unbekannt ist.""" nicht (mehr) laeuft oder unbekannt ist."""
+17 -1
View File
@@ -14,7 +14,7 @@ from auth import auth
from hw_math import evaluate_fit, max_ctx_for from hw_math import evaluate_fit, max_ctx_for
from config import MODELS_DIR, CMD_TEMPLATE, DEFAULT_TTL, HF_DOWNLOAD_ENV, hf_bin from config import MODELS_DIR, CMD_TEMPLATE, DEFAULT_TTL, HF_DOWNLOAD_ENV, hf_bin
from llamaswap import read_config, write_config 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 from recipes import RECIPES, UPGRADES
router = APIRouter(prefix="/api/cookbook", dependencies=[Depends(auth)]) 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)] args = [hf_bin(), "download", m["repo"], file, "--local-dir", str(target)]
jid = start_job(args, f"download {m['name']}", env=env) jid = start_job(args, f"download {m['name']}", env=env)
JOBS[jid]["result_path"] = str(target / file) JOBS[jid]["result_path"] = str(target / file)
attach_download_progress(jid, str(target), hf_file_size(m["repo"], file))
job_ids.append(jid) job_ids.append(jid)
# Eintrag jetzt schon schreiben — optimaler Kontext, aber gedeckelt fuer schnellen Erststart. # Eintrag jetzt schon schreiben — optimaler Kontext, aber gedeckelt fuer schnellen Erststart.
ctx = min(max_ctx_for(m["params_b"], m["quant"], ram_gb), 32768) 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)} 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: 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.""" """Beste GGUF-Datei eines Repos auflösen: bevorzugt gewünschten Quant, keine Split-Teile."""
try: try:
@@ -234,6 +249,7 @@ def install_model(req: InstallModelReq):
jid = start_job([hf_bin(), "download", req.repo, file, "--local-dir", str(target)], jid = start_job([hf_bin(), "download", req.repo, file, "--local-dir", str(target)],
f"download {req.repo.split('/')[-1]}", env=env) f"download {req.repo.split('/')[-1]}", env=env)
JOBS[jid]["result_path"] = str(target / file) JOBS[jid]["result_path"] = str(target / file)
attach_download_progress(jid, str(target), hf_file_size(req.repo, file))
cfg = read_config() cfg = read_config()
ctx = min(max_ctx_for(req.params_b, req.quant, ram_gb), 32768) 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)) cmd = CMD_TEMPLATE.replace("{model}", str(target / file)).replace("{ctx}", str(ctx))
+3 -1
View File
@@ -15,7 +15,8 @@ from ruamel.yaml.scalarstring import LiteralScalarString
from auth import auth from auth import auth
from config import (CMD_TEMPLATE, CONFIG_PATH, DEFAULT_TTL, HF_DOWNLOAD_ENV, LLAMA_SWAP_URL, from config import (CMD_TEMPLATE, CONFIG_PATH, DEFAULT_TTL, HF_DOWNLOAD_ENV, LLAMA_SWAP_URL,
MODELS_DIR, TOKEN, hf_bin) 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 llamaswap import _swap_get, read_config, write_config
from hw_math import extract_params_b, max_ctx_for, estimate_memory_gb from hw_math import extract_params_b, max_ctx_for, estimate_memory_gb
import re import re
@@ -146,6 +147,7 @@ def download(req: DownloadReq):
env["HF_TOKEN"] = req.hf_token env["HF_TOKEN"] = req.hf_token
job_id = start_job(args, f"download {req.repo}/{req.file}", env=env) job_id = start_job(args, f"download {req.repo}/{req.file}", env=env)
JOBS[job_id]["result_path"] = str(target / req.file) 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)} return {"job_id": job_id, "expected_path": str(target / req.file)}
+3 -1
View File
@@ -16,9 +16,11 @@ const MAX_HIST = 60;
function statusBadge(s) { return s === "done" ? '<span class="badge b-run">fertig</span>' : s === "failed" ? '<span class="badge b-err">fehler</span>' : s === "canceled" ? '<span class="badge">abgebrochen</span>' : '<span class="badge b-load">läuft…</span>'; } function statusBadge(s) { return s === "done" ? '<span class="badge b-run">fertig</span>' : s === "failed" ? '<span class="badge b-err">fehler</span>' : s === "canceled" ? '<span class="badge">abgebrochen</span>' : '<span class="badge b-load">läuft…</span>'; }
function dotClass(s) { return s === "done" ? "on" : (s === "failed" || s === "canceled") ? "" : "load"; } 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) { function jobPct(j) {
if (j.state !== "running") return null; 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 last = (j.log || [])[j.log.length - 1] || "";
const m = last.match(/(\d+(?:\.\d+)?)\s*%/); const m = last.match(/(\d+(?:\.\d+)?)\s*%/);
return m ? Math.min(100, parseFloat(m[1])) : null; return m ? Math.min(100, parseFloat(m[1])) : null;
+12 -4
View File
@@ -141,12 +141,20 @@ function onJobs(jobs) {
: canceled ? '<span class="badge">abgebrochen</span>' : canceled ? '<span class="badge">abgebrochen</span>'
: '<span class="badge b-load">läuft…</span>'; : '<span class="badge b-load">läuft…</span>';
// Fortschrittsbalken aus der letzten Log-Zeile (tqdm: " 45%|…") // Fortschrittsbalken: exakter Backend-Wert (Download) bevorzugt, sonst %-Angabe im Log.
const last = (j.log || [])[j.log.length - 1] || ""; let pval = null;
const pm = !terminal && last.match(/(\d+(?:\.\d+)?)\s*%/); 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"); const bar = $("#w-update-bar");
if (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"; else bar.style.display = "none";
} }
$("#w-update-cancel").style.display = terminal ? "none" : ""; $("#w-update-cancel").style.display = terminal ? "none" : "";