feat(setup): show live download rate + size-remaining, surface HF token as a speed lever (#657)
The first-run Models & Engines page showed only "downloading…" (and, once totals arrived, a bare percent + ETA). Users asked to see the actual download rate, the size remaining, and a way to speed downloads up. The backend already streams per-file byte counts and a windowed rate over SSE — the UI just wasn't surfacing it. Changes (frontend-only): - aggregate() now also returns live rate + bytes-remaining (was pct + ETA only), and is exported so the speed/remaining math is unit-tested. - The download line now reads e.g. "38% · 5.2 MB/s · 1.2 GB left · ~3m", each part shown only once the stream has it (still degrades to "downloading…" early). - New fmtBytes()/fmtRate() helpers (MB/GB, MB/s↔KB/s). The Hugging Face token field already existed but was buried in an "advanced" fold and framed only as "unlocks gated models" — so users hunting for a faster download never found it. Reframed the title/hint to lead with what they want: authenticated downloads are faster, have higher rate limits, and stall less (and still unlock gated models like pyannote diarization). Token persistence and the segmented/faster downloader (segmented_download.py) are unchanged — this just makes the existing speed levers visible. Test: frontend/src/test/wizardLibraryAggregate.test.js — aggregate sums bytes, ignores completed-file rate, returns nulls before totals; fmtBytes/fmtRate formatting + idle blanks. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
mergetest
Claude Opus 4.8
parent
252f0d4fac
commit
e87c13e919
@@ -23,6 +23,24 @@ import { listEngines, selectEngine } from '../api/engines';
|
||||
|
||||
const fmtGB = (gb) => (gb == null ? '' : `${gb.toFixed(gb < 10 ? 1 : 0)} GB`);
|
||||
|
||||
/** Human-readable byte size, e.g. 734003200 -> "700 MB", 1610612736 -> "1.5 GB". */
|
||||
export function fmtBytes(bytes) {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return '';
|
||||
const mb = bytes / (1024 * 1024);
|
||||
if (mb < 1024) return `${mb < 10 ? mb.toFixed(1) : Math.round(mb)} MB`;
|
||||
const gb = mb / 1024;
|
||||
return `${gb < 10 ? gb.toFixed(1) : Math.round(gb)} GB`;
|
||||
}
|
||||
|
||||
/** Instantaneous download rate, e.g. 5452595 -> "5.2 MB/s". Blank when idle. */
|
||||
export function fmtRate(bytesPerSec) {
|
||||
if (!Number.isFinite(bytesPerSec) || bytesPerSec <= 0) return '';
|
||||
const mb = bytesPerSec / (1024 * 1024);
|
||||
if (mb >= 1) return `${mb < 10 ? mb.toFixed(1) : Math.round(mb)} MB/s`;
|
||||
const kb = bytesPerSec / 1024;
|
||||
return `${Math.max(1, Math.round(kb))} KB/s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A model is a "platform pick" when it explicitly targets one of THIS host's
|
||||
* platform tags (the MLX mac-ARM speedups, CUDA-tuned variants, …) — the best
|
||||
@@ -38,19 +56,21 @@ export function isPlatformPick(model, platformTags) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Aggregate one repo's SSE file events: percent done + ETA from rates. */
|
||||
function aggregate(files) {
|
||||
/** Aggregate one repo's SSE file events: percent + ETA + live rate + remaining.
|
||||
* Exported (pure) so the speed/remaining math is unit-testable. */
|
||||
export function aggregate(files) {
|
||||
let done = 0;
|
||||
let total = 0;
|
||||
let rate = 0;
|
||||
for (const f of Object.values(files)) {
|
||||
for (const f of Object.values(files || {})) {
|
||||
done += f.downloaded || 0;
|
||||
total += f.total || 0;
|
||||
if ((f.total || 0) > (f.downloaded || 0)) rate += f.rate || 0;
|
||||
}
|
||||
const pct = total > 0 ? Math.min(100, Math.round((done / total) * 100)) : null;
|
||||
const etaSec = rate > 0 && total > done ? (total - done) / rate : null;
|
||||
return { pct, etaSec };
|
||||
const remaining = total > done ? total - done : null;
|
||||
const etaSec = rate > 0 && remaining ? remaining / rate : null;
|
||||
return { pct, etaSec, rate, remaining };
|
||||
}
|
||||
|
||||
function formatEta(seconds) {
|
||||
@@ -198,8 +218,21 @@ export default function WizardLibrary() {
|
||||
|
||||
const modelRow = (m, chip, chipTone, note) => {
|
||||
const p = progress[m.repo_id];
|
||||
const { pct, etaSec } = p ? aggregate(p.files) : { pct: null, etaSec: null };
|
||||
const { pct, etaSec, rate, remaining } = p
|
||||
? aggregate(p.files)
|
||||
: { pct: null, etaSec: null, rate: 0, remaining: null };
|
||||
const downloading = !!p;
|
||||
// Live telemetry line: "5.2 MB/s · 700 MB left · ~3m". Each part only shows
|
||||
// once the SSE stream has the data, so early on it degrades to "downloading…".
|
||||
const rateStr = fmtRate(rate);
|
||||
const remainStr = fmtBytes(remaining);
|
||||
const etaStr = etaSec != null ? formatEta(etaSec) : '';
|
||||
const statParts = [
|
||||
pct != null ? `${pct}%` : null,
|
||||
rateStr || null,
|
||||
remainStr ? t('firstrun.size_left', { size: remainStr, defaultValue: '{{size}} left' }) : null,
|
||||
etaStr ? t('firstrun.eta_left', { eta: etaStr, defaultValue: '~{{eta}} left' }) : null,
|
||||
].filter(Boolean);
|
||||
return (
|
||||
<Row
|
||||
key={m.repo_id}
|
||||
@@ -215,8 +248,7 @@ export default function WizardLibrary() {
|
||||
<span className="swiz-lib__state">✓</span>
|
||||
) : downloading ? (
|
||||
<span className="swiz-lib__state swiz-lib__state--busy">
|
||||
{pct != null ? `${pct}%` : t('firstrun.lib_downloading', 'downloading…')}
|
||||
{etaSec != null && ` · ${t('firstrun.eta_left', { eta: formatEta(etaSec), defaultValue: '~{{eta}} left' })}`}
|
||||
{statParts.length ? statParts.join(' · ') : t('firstrun.lib_downloading', 'downloading…')}
|
||||
</span>
|
||||
) : (
|
||||
<button type="button" className="frs-btn frs-btn--quiet swiz-lib__act" onClick={() => install(m.repo_id)}>
|
||||
|
||||
@@ -1946,10 +1946,11 @@
|
||||
"trust_line": "Everything runs and stays on this machine — no account, no cloud, no telemetry.",
|
||||
"resume_note": "Interrupted downloads resume automatically — closing the app is safe.",
|
||||
"eta_left": "~{{eta}} left",
|
||||
"size_left": "{{size}} left",
|
||||
"first_sound_text": "Welcome to your studio. Every word you hear was generated on this machine, just now.",
|
||||
"first_sound_done": "That voice? Generated seconds ago, locally. Welcome in.",
|
||||
"hf_token_title": "Hugging Face token (optional)",
|
||||
"hf_token_hint": "Unlocks gated models — e.g. speaker diarization for multi-speaker dubbing (pyannote). Free account token; stays on this machine.",
|
||||
"hf_token_title": "Hugging Face token — faster, more reliable downloads (optional)",
|
||||
"hf_token_hint": "A free account token gives authenticated downloads (faster, higher rate limits, fewer stalls) and unlocks gated models like speaker diarization for multi-speaker dubbing (pyannote). Stays on this machine.",
|
||||
"hf_token_save": "Save",
|
||||
"hf_token_saving": "saving…",
|
||||
"hf_token_saved": "Hugging Face token saved",
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { aggregate, fmtBytes, fmtRate } from '../components/WizardLibrary.jsx';
|
||||
|
||||
describe('aggregate — download telemetry from SSE file events', () => {
|
||||
it('sums bytes, computes pct, remaining, rate and ETA', () => {
|
||||
const files = {
|
||||
'a.bin': { downloaded: 500, total: 1000, rate: 100 },
|
||||
'b.bin': { downloaded: 250, total: 1000, rate: 150 },
|
||||
};
|
||||
const { pct, remaining, rate, etaSec } = aggregate(files);
|
||||
expect(pct).toBe(38); // 750 / 2000
|
||||
expect(remaining).toBe(1250); // 2000 - 750
|
||||
expect(rate).toBe(250); // both still downloading
|
||||
expect(etaSec).toBeCloseTo(5, 5); // 1250 / 250
|
||||
});
|
||||
|
||||
it('drops rate from already-complete files (no negative/idle ETA)', () => {
|
||||
const files = {
|
||||
done: { downloaded: 1000, total: 1000, rate: 999 }, // complete → rate ignored
|
||||
live: { downloaded: 0, total: 1000, rate: 200 },
|
||||
};
|
||||
const { rate, remaining, etaSec } = aggregate(files);
|
||||
expect(rate).toBe(200);
|
||||
expect(remaining).toBe(1000);
|
||||
expect(etaSec).toBeCloseTo(5, 5);
|
||||
});
|
||||
|
||||
it('returns nulls before any totals arrive (degrades to "downloading…")', () => {
|
||||
expect(aggregate({}).pct).toBeNull();
|
||||
expect(aggregate({}).remaining).toBeNull();
|
||||
expect(aggregate(undefined).pct).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('fmtBytes / fmtRate', () => {
|
||||
it('formats remaining size in MB/GB', () => {
|
||||
expect(fmtBytes(700 * 1024 * 1024)).toBe('700 MB');
|
||||
expect(fmtBytes(1.5 * 1024 * 1024 * 1024)).toBe('1.5 GB');
|
||||
expect(fmtBytes(0)).toBe('');
|
||||
expect(fmtBytes(null)).toBe('');
|
||||
});
|
||||
|
||||
it('formats rate in MB/s or KB/s, blank when idle', () => {
|
||||
expect(fmtRate(5.2 * 1024 * 1024)).toBe('5.2 MB/s');
|
||||
expect(fmtRate(512 * 1024)).toBe('512 KB/s');
|
||||
expect(fmtRate(0)).toBe('');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user