From 7c7fcf87d135b2891725c99c8080fe4b3456024b Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Mon, 22 Jun 2026 16:35:40 +0200 Subject: [PATCH] feat(quick-wins): HF-Link-Suche, Download-ETA, Job-Verlauf loeschbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cookbook-Profisuche: ganze HF-URL oder "owner/repo" direkt einfuegbar (parseHfRepo) -> exakter Treffer statt unscharfer Suche; limit 12->40, damit auch grosse/seltenere Modelle (z.B. Llama-4-Scout) erscheinen. - jobengine.attach_download_progress: geglaettete Rate (EMA) + Restzeit (eta_s) je Download; Aktivitaet zeigt "noch ~X min · Y MB/s". - Jobs: erledigte/abgebrochene Eintraege einzeln loeschbar (DELETE /api/jobs/{id}) + "Verlauf leeren" (POST /api/jobs/clear); laufende bleiben geschuetzt. Co-Authored-By: Claude Opus 4.8 --- jobengine.py | 32 +++++++++++++++++++++++++++++ routers/jobs.py | 15 +++++++++++++- static/js/panels/cookbook.js | 39 +++++++++++++++++++++++++++--------- static/js/panels/jobs.js | 35 +++++++++++++++++++++++++++++--- 4 files changed, 108 insertions(+), 13 deletions(-) diff --git a/jobengine.py b/jobengine.py index 326213b..35d353d 100644 --- a/jobengine.py +++ b/jobengine.py @@ -113,9 +113,12 @@ def attach_download_progress(job_id: str, local_dir: str, total_bytes: int) -> N job = JOBS.get(job_id) if job is not None: job["progress"] = 0 + job["total_bytes"] = total_bytes def _watch(): pat = os.path.join(local_dir, ".cache", "huggingface", "download", "*.incomplete") + prev_t, prev_b = None, None + rate = 0.0 # geglaettete Download-Rate (Bytes/s), EMA gegen Zappeln while True: j = JOBS.get(job_id) if not j or j["state"] in ("done", "failed", "canceled"): @@ -125,12 +128,22 @@ def attach_download_progress(job_id: str, local_dir: str, total_bytes: int) -> N 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)) + j["done_bytes"] = cur + now = time.time() + if prev_t is not None and now > prev_t and cur >= prev_b: + inst = (cur - prev_b) / (now - prev_t) + rate = inst if rate == 0 else 0.3 * inst + 0.7 * rate + if rate > 0: + j["rate_bps"] = rate + j["eta_s"] = int((total_bytes - cur) / rate) + prev_t, prev_b = now, cur except Exception: # noqa: BLE001 pass time.sleep(1.0) j = JOBS.get(job_id) if j and j["state"] == "done": j["progress"] = 100 + j.pop("eta_s", None) threading.Thread(target=_watch, daemon=True).start() @@ -156,6 +169,25 @@ def cancel_job(job_id: str) -> bool: return True +def delete_job(job_id: str) -> bool: + """Einen abgeschlossenen Job aus dem Verlauf entfernen. Laufende Jobs werden + NICHT geloescht (erst abbrechen). Liefert False, wenn unbekannt oder noch aktiv.""" + job = JOBS.get(job_id) + if not job or job["state"] not in ("done", "failed", "canceled"): + return False + JOBS.pop(job_id, None) + return True + + +def clear_finished() -> int: + """Alle abgeschlossenen Jobs (fertig/fehler/abgebrochen) aus dem Verlauf raeumen. + Laufende bleiben. Liefert die Anzahl entfernter Eintraege.""" + done = [jid for jid, j in JOBS.items() if j["state"] in ("done", "failed", "canceled")] + for jid in done: + JOBS.pop(jid, None) + return len(done) + + def start_job(args: list[str], label: str, env: dict | None = None, stdin_data: str | None = None, log_cmd: str | None = None) -> str: job_id = uuid.uuid4().hex[:12] diff --git a/routers/jobs.py b/routers/jobs.py index f922c10..e4f6205 100644 --- a/routers/jobs.py +++ b/routers/jobs.py @@ -6,7 +6,7 @@ Liefert die Daten fuer das Aktivitaets-Panel mit Live-Log. from fastapi import APIRouter, Depends, HTTPException from auth import auth -from jobengine import JOBS, cancel_job +from jobengine import JOBS, cancel_job, clear_finished, delete_job router = APIRouter(prefix="/api", dependencies=[Depends(auth)]) @@ -26,6 +26,19 @@ def job_cancel(job_id: str): return {"ok": True} +@router.post("/jobs/clear") +def jobs_clear(): + """Alle abgeschlossenen Jobs aus dem Verlauf entfernen (laufende bleiben).""" + return {"ok": True, "removed": clear_finished()} + + +@router.delete("/jobs/{job_id}") +def job_delete(job_id: str): + if not delete_job(job_id): + raise HTTPException(409, "Job laeuft noch oder ist unbekannt — laufende erst abbrechen.") + return {"ok": True} + + @router.get("/jobs") def jobs_list(): return sorted(JOBS.values(), key=lambda j: j["started_at"], reverse=True)[:20] diff --git a/static/js/panels/cookbook.js b/static/js/panels/cookbook.js index f820390..bd1d2cd 100644 --- a/static/js/panels/cookbook.js +++ b/static/js/panels/cookbook.js @@ -43,7 +43,7 @@ function mount() { Profi-Modus: HuggingFace direkt durchsuchen
- +
@@ -273,18 +273,39 @@ function renderHwChip() { // ---- Profi-Suche (HuggingFace) ---- function metricLine(fit) { return `~${fit.req_gb.toFixed(1)} GB · ~${Math.round(fit.tps)} Tok/s`; } +// Erkennt eine direkte Modell-Angabe: ganze HuggingFace-URL ODER „owner/repo". +// So kann man Links wie https://huggingface.co/unsloth/MiniMax-M3-GGUF einfach +// reinkopieren und landet exakt auf diesem Modell (statt einer unscharfen Suche). +function parseHfRepo(s) { + s = s.trim(); + const u = s.match(/huggingface\.co\/([^\s/]+\/[^\s/?#]+)/i); + if (u) return u[1]; + if (/^[\w.-]+\/[\w.-]+$/.test(s)) return s; + return null; +} + async function doSearch() { - let q = $("#cb-search").value.trim(); - if (activeFilter) q = q ? q + " " + activeFilter : activeFilter; - if (!q) { $("#cb-grid").innerHTML = ""; return; } - const sort = $("#cb-sort")?.value || "downloads"; + const raw = $("#cb-search").value.trim(); + const repo = parseHfRepo(raw); + let q = raw; + if (activeFilter && !repo) q = q ? q + " " + activeFilter : activeFilter; + if (!q && !repo) { $("#cb-grid").innerHTML = ""; return; } const btn = $("#cb-btn-search"); btn.disabled = true; btn.textContent = "Lade…"; $("#cb-grid").innerHTML = `
Suche auf HuggingFace…
`; try { - const url = `https://huggingface.co/api/models?search=${encodeURIComponent(q)}&filter=gguf&sort=${encodeURIComponent(sort)}&direction=-1&limit=12`; - const r = await fetch(url); - currentResults = await r.json(); - renderResults(currentResults); + if (repo) { + // Direkter Treffer per Link/Repo — keine Stichwortsuche, sondern genau dieses Modell. + currentResults = [{ id: repo, author: repo.split("/")[0], downloads: 0 }]; + renderResults(currentResults); + } else { + const sort = $("#cb-sort")?.value || "downloads"; + // limit=40: auch große/seltenere Modelle (z.B. Llama-4-Scout 109B) erscheinen, + // statt von den Top-12-Trends verdrängt zu werden. Fit-Ampel sagt ehrlich, was passt. + const url = `https://huggingface.co/api/models?search=${encodeURIComponent(q)}&filter=gguf&sort=${encodeURIComponent(sort)}&direction=-1&limit=40`; + const r = await fetch(url); + currentResults = await r.json(); + renderResults(currentResults); + } } catch (e) { $("#cb-grid").innerHTML = `
${esc(e.message)}
`; } btn.disabled = false; btn.textContent = "Suchen"; } diff --git a/static/js/panels/jobs.js b/static/js/panels/jobs.js index e0a225c..39a8001 100644 --- a/static/js/panels/jobs.js +++ b/static/js/panels/jobs.js @@ -26,11 +26,28 @@ function jobPct(j) { return m ? Math.min(100, parseFloat(m[1])) : null; } +// Restzeit menschlich: "noch ~2 min" / "noch ~45 s". +function fmtEta(s) { + if (s == null || s < 0) return ""; + if (s >= 90) return `noch ~${Math.round(s / 60)} min`; + return `noch ~${Math.max(1, Math.round(s))} s`; +} + async function cancelJob(id) { try { await api(`/api/jobs/${id}/cancel`, { method: "POST" }); toast("Abbruch angefordert…"); renderJobs(); } catch (e) { toast(e.message, true); } } +async function deleteJob(id) { + try { await api(`/api/jobs/${id}`, { method: "DELETE" }); renderJobs(); } + catch (e) { toast(e.message, true); } +} + +async function clearJobs() { + try { const r = await api("/api/jobs/clear", { method: "POST" }); toast(`${r.removed} Einträge entfernt.`); renderJobs(); } + catch (e) { toast(e.message, true); } +} + function tile(label, value, sub) { return `
${label}
${value}
${sub}
`; } @@ -86,14 +103,18 @@ function mount() {
Live-Auslastung und laufende Aufgaben (Downloads, Updates) mit Protokoll.
`; $("#v-activity").innerHTML = ` -

Hintergrund-Aufgaben

+

Hintergrund-Aufgaben

+
Downloads & Updates erscheinen hier mit Live-Protokoll — zum Aufklappen klicken.
Gerade nichts los.
Alles ruhig — keine laufenden Aufgaben.
`; $("#v-activity").addEventListener("click", e => { + if (e.target.closest("#jobs-clear")) { clearJobs(); return; } const cancel = e.target.closest("[data-cancel]"); if (cancel) { e.stopPropagation(); cancelJob(cancel.getAttribute("data-cancel")); return; } + const del = e.target.closest("[data-del]"); + if (del) { e.stopPropagation(); deleteJob(del.getAttribute("data-del")); return; } const h = e.target.closest(".job-h"); if (!h) return; const id = h.getAttribute("data-id"); tracked.has(id) ? tracked.delete(id) : tracked.add(id); @@ -106,15 +127,23 @@ function renderJobs() { const c = $("#jobs"); if (!c) return; $("#jobs-empty").style.display = JOBS.length ? "none" : "flex"; const failed = JOBS.filter(j => j.state === "failed").length; + const finished = JOBS.filter(j => j.state !== "running" && j.state !== "queued").length; $("#job-count").textContent = JOBS.length ? (failed ? failed + " Fehler" : JOBS.length + " gesamt") : ""; + const clearBtn = $("#jobs-clear"); if (clearBtn) clearBtn.style.display = finished ? "" : "none"; c.innerHTML = JOBS.map(j => { const log = tracked.has(j.id) ? `
${esc((j.log || []).join("\n"))}
` : ""; const p = jobPct(j); const prog = p != null ? `
` : ""; - const pctTxt = p != null ? `${Math.round(p)} %` : ""; + // bei laufenden Downloads: Prozent + Restzeit + Rate + const eta = j.state === "running" ? fmtEta(j.eta_s) : ""; + const rate = j.state === "running" && j.rate_bps ? fmtBytes(j.rate_bps) + "/s" : ""; + const extra = [eta, rate].filter(Boolean).join(" · "); + const pctTxt = p != null + ? `${Math.round(p)} %${extra ? ` · ${extra}` : ""}` + : ""; const cancelBtn = j.state === "running" ? `` - : ""; + : ``; return `
${esc(j.label)}${pctTxt}${cancelBtn}${statusBadge(j.state)}
${prog}${log}
`; }).join("");