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 `
`;
}
@@ -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("");