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:
@@ -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 <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:
|
||||
"""Laufenden Job abbrechen: Prozess terminieren. Liefert False, wenn der Job
|
||||
nicht (mehr) laeuft oder unbekannt ist."""
|
||||
|
||||
+17
-1
@@ -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))
|
||||
|
||||
+3
-1
@@ -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)}
|
||||
|
||||
|
||||
|
||||
@@ -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 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;
|
||||
|
||||
@@ -141,12 +141,20 @@ function onJobs(jobs) {
|
||||
: canceled ? '<span class="badge">abgebrochen</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.
|
||||
let pval = null;
|
||||
if (!terminal) {
|
||||
if (typeof j.progress === "number") {
|
||||
pval = j.progress;
|
||||
} else {
|
||||
const last = (j.log || [])[j.log.length - 1] || "";
|
||||
const pm = !terminal && last.match(/(\d+(?:\.\d+)?)\s*%/);
|
||||
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" : "";
|
||||
|
||||
Reference in New Issue
Block a user