release: v0.2.37 — zero-argument startup, first-run wizard, real download progress
Startup no longer needs arguments: the config is created on first launch, a busy port is swapped for a free one, the console is open by default, and the browser lands on a setup wizard that downloads a ready-made .cmf model and wires it into routing (managed server + routing.default) when you press Finish. Download progress was reported from curl's own meter, which prints a full 0→100% bar for the -L redirect hop before the transfer starts — so every job showed 100% and then hung. Progress now comes from bytes on disk against the size from the HuggingFace tree (or a HEAD request). The UI and the gateway also disagreed about the output file name, so a finished download never marked its card as downloaded; the gateway now returns the exact stem and the UI sends it back verbatim for download, progress and delete. Featured models are read live from the infosave HF account in both the wizard and Import, and all 432 UI strings now exist in every one of the 7 languages.
This commit is contained in:
Vendored
+191
@@ -0,0 +1,191 @@
|
||||
// Ready-to-run .cmf models, read live from the infosave HuggingFace account via
|
||||
// /setup/featured — so a newly published model shows up without a code change.
|
||||
// Shared by the first-run wizard and the Import view: same cards, same download
|
||||
// / progress / delete behaviour in both places.
|
||||
import { h, mount, toast } from "../ui.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { api } from "../api.js";
|
||||
|
||||
export async function loadFeatured() {
|
||||
try {
|
||||
const r = await api.setupFeatured();
|
||||
return r.models || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function quantFromVariant(name) {
|
||||
const s = (name || "").toLowerCase();
|
||||
if (s.includes("q1t")) return "Q1T";
|
||||
if (s.includes("q2tp")) return "Q2TP";
|
||||
if (s.includes("q4tp")) return "Q4TP";
|
||||
if (s.includes("q4t")) return "Q4T";
|
||||
if (s.includes("q1")) return "Q1";
|
||||
if (s.includes("q4")) return "Q4";
|
||||
if (s.includes("q8")) return "Q8";
|
||||
return "";
|
||||
}
|
||||
|
||||
// Same thresholds as the backend's format_bytes, so card and job agree.
|
||||
export function fmtBytes(n) {
|
||||
if (!n && n !== 0) return "";
|
||||
if (n >= 1e9) return (n / 1e9).toFixed(1) + " GB";
|
||||
if (n >= 1e6) return Math.round(n / 1e6) + " MB";
|
||||
if (n >= 1e3) return Math.round(n / 1e3) + " KB";
|
||||
return n + " B";
|
||||
}
|
||||
|
||||
export function jobPct(j) {
|
||||
if (j.progress == null) return null;
|
||||
return Math.max(0, Math.min(100, Math.round(j.progress * 100)));
|
||||
}
|
||||
|
||||
/// "downloading · 42% · 1.4 / 3.2 GB" — whatever of it the job actually knows.
|
||||
/// A settled job shows its result, not the phase it stopped in.
|
||||
export function jobStatusText(j) {
|
||||
const parts = [];
|
||||
if (j.state === "running") {
|
||||
if (j.phase) parts.push(j.phase);
|
||||
const pct = jobPct(j);
|
||||
if (pct != null) parts.push(pct + "%");
|
||||
if (j.total_bytes) parts.push(`${fmtBytes(j.done_bytes || 0)} / ${fmtBytes(j.total_bytes)}`);
|
||||
} else if (j.state === "done" && j.size_bytes) {
|
||||
parts.push(fmtBytes(j.size_bytes));
|
||||
}
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
export function jobBar(j) {
|
||||
const pct = jobPct(j);
|
||||
if (j.state === "done") return h("div", { class: "pbar" }, h("div", { class: "pbar-fill ok", style: "width:100%" }));
|
||||
if (j.state !== "running") return h("div", { class: "pbar" }, h("div", { class: "pbar-fill err", style: "width:100%" }));
|
||||
// known percentage → determinate fill; unknown (listing files) → animated slider
|
||||
return h("div", { class: "pbar" },
|
||||
pct != null
|
||||
? h("div", { class: "pbar-fill det", style: `width:${Math.max(pct, 1)}%` })
|
||||
: h("div", { class: "pbar-fill run" })
|
||||
);
|
||||
}
|
||||
|
||||
/// HF gives `desc` as a bare pipeline tag ("text-generation") for live entries and
|
||||
/// a real sentence for curated ones. A one-word tag belongs in the badge row, not
|
||||
/// on a line of its own under every card.
|
||||
function descBadge(desc) {
|
||||
return desc && !desc.includes(" ") ? h("span", { class: "badge" }, desc) : null;
|
||||
}
|
||||
function descLine(desc) {
|
||||
return desc && desc.includes(" ") ? h("div", { class: "muted small", style: "margin:6px 0" }, desc) : null;
|
||||
}
|
||||
|
||||
/// One model. `ctx` = { hosts: Map(jobId → element), onDownload(m, v), onDelete(m, v) }.
|
||||
/// Live progress lands in the per-variant host so a poll can repaint it in place,
|
||||
/// without re-rendering the grid under the user's cursor.
|
||||
export function featuredCard(m, ctx) {
|
||||
const badge = m.badge || "CMF";
|
||||
const label = m.label || m.id;
|
||||
const desc = m.desc || "";
|
||||
const variants = m.variants && m.variants.length ? m.variants : null;
|
||||
const size = m.size || (m.size_bytes ? fmtBytes(m.size_bytes) : "");
|
||||
const isDownloaded = !!m.downloaded;
|
||||
const progressHost = (v) => {
|
||||
const host = h("div", { class: "card-prog" });
|
||||
const jid = (v && v.job) || m.job;
|
||||
if (jid) ctx.hosts.set(jid, host);
|
||||
return host;
|
||||
};
|
||||
// multi-variant (e.g. DeepSeek with parts-q2tp / parts-q4tp) → separate buttons per variant
|
||||
if (variants && variants.length > 1) {
|
||||
return h("div", { class: "hf-card featured-card" + (isDownloaded ? " sel" : "") },
|
||||
h("div", { class: "hf-id" }, label, isDownloaded ? h("span", { class: "badge ok", style: "margin-left:6px" }, "✓") : null),
|
||||
h("div", { class: "hf-meta" },
|
||||
h("span", { class: "badge" }, badge),
|
||||
descBadge(desc),
|
||||
isDownloaded ? h("span", { class: "badge ok" }, "✓ " + t("onboarding.downloaded")) : null),
|
||||
descLine(desc),
|
||||
h("div", { class: "muted small" }, m.id),
|
||||
h("div", { style: "display:flex; flex-direction:column; gap:6px; margin-top:8px" },
|
||||
...variants.map((v) => {
|
||||
const q = quantFromVariant(v.name) || badge;
|
||||
const done = !!v.downloaded;
|
||||
const upd = !!v.update_available;
|
||||
const lab = done
|
||||
? (upd ? `↻ ${t("onboarding.update")} ${q} · ${v.size}` : `✓ ${q} · ${v.size}`)
|
||||
: `⬇ ${q} · ${v.size}`;
|
||||
const btn = h("button", {
|
||||
class: "btn sm " + (done && !upd ? "ghost" : "primary"),
|
||||
disabled: done && !upd ? true : null,
|
||||
onClick: () => ctx.onDownload(m, v),
|
||||
title: v.name + (done ? " — " + t("onboarding.downloaded") : ""),
|
||||
}, lab);
|
||||
const row = h("div", { style: "display:flex; gap:6px; align-items:center; flex-wrap:wrap" }, btn);
|
||||
if (done) {
|
||||
const del = h("button", { class: "btn sm ghost danger", title: t("onboarding.deleteFile") }, "🗑");
|
||||
del.addEventListener("click", () => ctx.onDelete(m, v));
|
||||
row.appendChild(del);
|
||||
}
|
||||
return h("div", {}, row, progressHost(v));
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
const v0 = variants && variants[0];
|
||||
const done0 = !!(v0 && v0.downloaded) || isDownloaded;
|
||||
const upd0 = !!(v0 && v0.update_available) || !!m.update_available;
|
||||
const q0 = quantFromVariant((v0 && v0.name) || "") || badge;
|
||||
const size0 = (v0 && v0.size) || size;
|
||||
const btn0 = h("button", {
|
||||
class: "btn sm " + (done0 && !upd0 ? "ghost" : "primary"),
|
||||
disabled: done0 && !upd0 ? true : null,
|
||||
onClick: () => ctx.onDownload(m, v0),
|
||||
title: done0 && !upd0 ? t("onboarding.downloaded") : ""
|
||||
}, done0
|
||||
? (upd0 ? `↻ ${t("onboarding.update")} ${q0} · ${size0}` : `✓ ${q0} · ${size0}`)
|
||||
: `⬇ ${q0}${size0 ? " · " + size0 : ""}`);
|
||||
const del0 = done0 ? h("button", { class: "btn sm ghost danger", title: t("onboarding.deleteFile") }, "🗑 " + t("import.delete")) : null;
|
||||
if (del0) del0.addEventListener("click", () => ctx.onDelete(m, v0));
|
||||
return h("div", { class: "hf-card featured-card" + (done0 ? " sel" : "") },
|
||||
h("div", { class: "hf-id" }, label, done0 ? h("span", { class: "badge ok", style: "margin-left:6px" }, "✓") : null),
|
||||
h("div", { class: "hf-meta" },
|
||||
h("span", { class: "badge" }, badge),
|
||||
size0 ? h("span", { class: "badge", style: "background:var(--bg); border-style:dashed" + (done0 ? "; opacity:.7" : "") }, (done0 ? "✓ " : "⬇ ") + size0) : null,
|
||||
descBadge(desc)
|
||||
),
|
||||
descLine(desc),
|
||||
h("div", { class: "muted small" }, m.id),
|
||||
h("div", { style: "display:flex; gap:6px; margin-top:8px; align-items:center; flex-wrap:wrap" }, btn0, del0),
|
||||
progressHost(v0)
|
||||
);
|
||||
}
|
||||
|
||||
/// Repaint the per-variant progress hosts from the current job list.
|
||||
export function paintFeaturedHosts(hosts, jobs, onCancel) {
|
||||
const byId = new Map(jobs.map((j) => [j.id, j]));
|
||||
for (const [id, host] of hosts) {
|
||||
const j = byId.get(id);
|
||||
if (!j || j.state !== "running") { mount(host); continue; }
|
||||
const cancel = h("button", { class: "btn sm ghost", style: "margin-left:auto", title: t("import.cancel") }, "✕");
|
||||
cancel.addEventListener("click", async () => {
|
||||
cancel.disabled = true;
|
||||
try { await onCancel(j); } catch (e) { toast(e.message, "err"); cancel.disabled = false; }
|
||||
});
|
||||
mount(host,
|
||||
jobBar(j),
|
||||
h("div", { style: "display:flex; align-items:center; gap:8px" },
|
||||
h("span", { class: "muted small" }, jobStatusText(j)),
|
||||
cancel
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the download for a featured variant. `local_name` is the stem the
|
||||
/// gateway itself derived, sent back verbatim so download / progress / delete
|
||||
/// can never disagree about which file this is.
|
||||
export async function startFeaturedDownload(m, v) {
|
||||
const fallbackBase = m.id.split("/").pop().toLowerCase().replace(/[^a-z0-9_-]/g, "-");
|
||||
const name = (v && v.local_name) || m.local_name || fallbackBase;
|
||||
const variant = v && v.name ? v.name : undefined;
|
||||
await api.startImport({ repo: m.id, quant: "Q1", name, variant });
|
||||
return variant;
|
||||
}
|
||||
Reference in New Issue
Block a user