feat(quick-wins): HF-Link-Suche, Download-ETA, Job-Verlauf loeschbar
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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]
|
||||
|
||||
+14
-1
@@ -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]
|
||||
|
||||
@@ -43,7 +43,7 @@ function mount() {
|
||||
<summary>Profi-Modus: HuggingFace direkt durchsuchen</summary>
|
||||
<div class="acc-body">
|
||||
<div class="flex gap-3" style="margin-bottom:8px">
|
||||
<input id="cb-search" placeholder="Modell suchen, z.B. „Llama 3"…" style="margin:0;flex:1">
|
||||
<input id="cb-search" placeholder="Suchen oder HuggingFace-Link/Repo einfügen (z.B. unsloth/MiniMax-M3-GGUF)…" style="margin:0;flex:1">
|
||||
<button class="primary" id="cb-btn-search" style="white-space:nowrap">Suchen</button>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center" style="flex-wrap:wrap">
|
||||
@@ -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 = `<div class="empty" style="grid-column:1/-1;text-align:center">Suche auf HuggingFace…</div>`;
|
||||
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 = `<div class="alert err" style="grid-column:1/-1">${esc(e.message)}</div>`; }
|
||||
btn.disabled = false; btn.textContent = "Suchen";
|
||||
}
|
||||
|
||||
@@ -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 `<div class="tile"><div class="t-l">${label}</div><div class="t-v">${value}</div><div class="t-s">${sub}</div></div>`;
|
||||
}
|
||||
@@ -86,14 +103,18 @@ function mount() {
|
||||
<div class="sub">Live-Auslastung und laufende Aufgaben (Downloads, Updates) mit Protokoll.</div></div></div>`;
|
||||
|
||||
$("#v-activity").innerHTML = `
|
||||
<div class="card-h"><h3>Hintergrund-Aufgaben</h3><span class="meta" id="job-count"></span></div>
|
||||
<div class="card-h"><h3>Hintergrund-Aufgaben</h3><span class="meta" id="job-count"></span>
|
||||
<button class="ghost" id="jobs-clear" style="margin-left:auto;display:none;padding:4px 11px;font-size:12px">Verlauf leeren</button></div>
|
||||
<div class="card-sub">Downloads & Updates erscheinen hier mit Live-Protokoll — zum Aufklappen klicken.</div>
|
||||
<div id="jobs"></div>
|
||||
<div id="jobs-empty" class="empty-c"><div class="e-t">Gerade nichts los.</div><div class="e-s">Alles ruhig — keine laufenden Aufgaben.</div></div>`;
|
||||
|
||||
$("#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) ? `<div class="log">${esc((j.log || []).join("\n"))}</div>` : "";
|
||||
const p = jobPct(j);
|
||||
const prog = p != null ? `<div class="bar" style="margin-top:8px"><i style="width:${p}%"></i></div>` : "";
|
||||
const pctTxt = p != null ? `<span class="mono-sm" style="margin-left:auto">${Math.round(p)} %</span>` : "";
|
||||
// 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
|
||||
? `<span class="mono-sm" style="margin-left:auto">${Math.round(p)} %${extra ? ` · ${extra}` : ""}</span>`
|
||||
: "";
|
||||
const cancelBtn = j.state === "running"
|
||||
? `<button class="ghost" data-cancel="${esc(j.id)}" style="${p != null ? "" : "margin-left:auto;"}margin-right:2px;padding:2px 9px;font-size:12px">Abbrechen</button>`
|
||||
: "";
|
||||
: `<button class="ghost" data-del="${esc(j.id)}" title="Aus Verlauf entfernen" style="margin-left:auto;margin-right:2px;padding:2px 9px;font-size:14px;line-height:1">×</button>`;
|
||||
return `<div class="job"><div class="job-h" data-id="${esc(j.id)}">
|
||||
<span class="li-dot ${dotClass(j.state)}"></span><span class="mid">${esc(j.label)}</span>${pctTxt}${cancelBtn}${statusBadge(j.state)}</div>${prog}${log}</div>`;
|
||||
}).join("");
|
||||
|
||||
Reference in New Issue
Block a user