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, ...).
|
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
@@ -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
@@ -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)}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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.
|
||||||
|
let pval = null;
|
||||||
|
if (!terminal) {
|
||||||
|
if (typeof j.progress === "number") {
|
||||||
|
pval = j.progress;
|
||||||
|
} else {
|
||||||
const last = (j.log || [])[j.log.length - 1] || "";
|
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");
|
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" : "";
|
||||||
|
|||||||
Reference in New Issue
Block a user