Files
VoiceStudio/frontend/src/components/WizardLibrary.jsx
T
e2c4ea93b0 fix(models-settings): surface async install errors, disk-space guard, cancel wiring, honest restart (#908)
Live-audit fixes for the Models settings surface — the P1s were cases where
the feature silently didn't work for the user.

P1-A — Async install errors were invisible. The `install_error` SSE event
carries excellent mirror-aware text (#890 core/failure.py), but the Model
Store auto-purged the errored row ~800ms later (same as a success) and the
first-run WizardLibrary DELETED the row without ever reading `ev.error`. The
SSE→rowState reduction is now a pure, tested reducer (downloadReducer.js /
reduceWizardDownloadEvent); only SUCCESS terminals auto-purge
(isAutoPurgeTerminal), an error persists on the row with inline text + Retry +
Dismiss (Model Store) / a Retry (wizard).

P1-B — No disk-space check on install. `POST /models/install` now compares the
FDL-05 plan's exact `to_download_bytes` (+ MIN_FREE_GB headroom) against
`shutil.disk_usage(cache).free` BEFORE downloading and emits an actionable
install_error naming the sizes (needs X, headroom Y, have Z) instead of failing
mid-download. `/models` also surfaces `disk_free_gb` in the header. MIN_FREE_GB
+ disk_free_bytes are single-sourced in setup/models.py (wizard delegates).

P2-A — Wired the orphaned cancel. `POST /models/install/cancel` (FDL-11) had
zero frontend refs; the in-progress row now shows a Cancel button that calls it
and transitions the row to install_cancelled.

P2-B — Honest restart_required. The HF-mirror PUT returned restart_required:true
unconditionally; it now returns true only when the persisted value actually
changed, with accurate copy (Model Store downloads use the new mirror
immediately — resolved per-call; only transformers model loads need a restart).

P3 — i18n the un-localized panels (HFMirrorPanel, ApiKeysPanel source
labels/help/status, MODEL_ROLE_LABEL) via new en.json keys; other locales fall
back to en.

Tests: new tests/test_install_disk_space.py (reject-when-over-budget incl. the
worker wiring; allow-when-fits; degrade on unknown size/unprobeable volume),
updated tests/test_hf_mirror_settings.py (change-only restart_required), and new
frontend reducer + column-render tests for install_error persistence, Retry,
Dismiss, and Cancel.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:40:15 +05:30

438 lines
16 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* WizardLibrary — the first-run "stock the studio" act as ONE unified list.
*
* Models and engines are different things (weights vs backends), but the
* user's question is singular — "what do I need to get?" — so every
* installable is a row of the same grammar:
*
* LED · name · chip (required / engine / optional) · size · one action
*
* Required models lead (they gate the wizard's continue), the TTS engines
* follow (Use = switch, heavy installs deferred to Settings), and the long
* tail of optional models folds behind a quiet count. Live download
* progress rides the same SSE stream the Settings model store uses; the
* full management surface (search, HF token, deletes) stays in Settings —
* a first run needs a checklist, not a store.
*
* Built on standard shadcn primitives (Badge / Button / Progress) + Tailwind
* utilities themed by the palette tokens.
*/
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-hot-toast';
import { Check, ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useModels, useInstallModel } from '../api/hooks';
import { setupDownloadStreamUrl } from '../api/setup';
import { listEngines, selectEngine } from '../api/engines';
import { notifyEngineSelected } from '../utils/engineSelectToast';
import { Badge, Button } from '../ui';
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
* optional models for this machine, surfaced by default instead of buried in
* the tail. Models with no `platforms` field are universal (not a pick — they
* ride the fold). Pure + exported so the split is unit-testable.
*/
export function isPlatformPick(model, platformTags) {
return (
Array.isArray(model?.platforms) &&
Array.isArray(platformTags) &&
model.platforms.some((p) => platformTags.includes(p))
);
}
/** Overall progress from the backend's authoritative `aggregate` SSE event.
* This is the TRUSTWORTHY source: under parallel/segmented fetch the per-file
* tqdm events are unreliable (big weight shards may report total/rate as 0),
* so summing them on the frontend gave wrong numbers — e.g. "8% · 1 KB/s ·
* 0.0 MB left". The backend computes one windowed rate + ETA + bytes_done /
* total_bytes (seeded by the dry-run preflight), which is what we render. Pure
* + exported for unit tests. Returns null until totals are known. */
export function progressFromAgg(agg) {
if (!agg || !(agg.totalBytes > 0)) return null;
const pct = Math.min(100, Math.round((agg.bytesDone / agg.totalBytes) * 100));
const remaining = agg.totalBytes > agg.bytesDone ? agg.totalBytes - agg.bytesDone : null;
return { pct, remaining, rate: agg.rate || 0, etaSec: agg.etaSeconds ?? null };
}
/** Fallback: aggregate one repo's per-file SSE events when the authoritative
* `aggregate` event hasn't arrived yet. Exported (pure) for unit tests. */
export function aggregate(files) {
let done = 0;
let total = 0;
let rate = 0;
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 remaining = total > done ? total - done : null;
const etaSec = rate > 0 && remaining ? remaining / rate : null;
return { pct, etaSec, rate, remaining };
}
function formatEta(seconds) {
if (!Number.isFinite(seconds) || seconds <= 0) return '';
if (seconds < 60) return '<1m';
return `${Math.round(seconds / 60)}m`;
}
/**
* Fold one download-stream SSE event into the wizard's per-repo progress map.
* Pure + exported for unit tests. Mirrors the Settings store's transitions, but
* with the wizard's leaner shape ({ phase, files, agg }).
*
* Key fix (P1-A): an `install_error` event is STORED with its `ev.error` text
* (the mirror-aware failure hint) and the row PERSISTS — previously the wizard
* deleted the row on error exactly like a success, so the user saw the download
* vanish with no reason. `install_done` still drops the row (it reverts to the
* authoritative `installed` flag); the caller does the list refetch.
*/
export function reduceWizardDownloadEvent(prev, ev) {
if (!ev || !ev.repo_id) return prev;
const cur = prev[ev.repo_id] || { phase: 'active', files: {} };
// Lifecycle markers gate reset; a file-level 'done' must NOT clear the repo.
if (ev.phase === 'install_start') {
return { ...prev, [ev.repo_id]: { phase: 'active', files: {} } };
}
// Success terminal → drop the transient row.
if (ev.phase === 'install_done') {
const next = { ...prev };
delete next[ev.repo_id];
return next;
}
// Error terminal → KEEP the row + its message so it renders with a Retry.
if (ev.phase === 'install_error') {
return { ...prev, [ev.repo_id]: { ...cur, phase: 'install_error', error: ev.error } };
}
// Authoritative overall progress (download_aggregator).
if (ev.phase === 'aggregate') {
return {
...prev,
[ev.repo_id]: {
...cur,
agg: {
bytesDone: ev.bytes_done || 0,
totalBytes: ev.total_bytes || 0,
rate: ev.rate || 0,
etaSeconds: ev.eta_seconds ?? null,
filesDone: ev.files_done || 0,
filesTotal: ev.files_total || 0,
},
},
};
}
if (!ev.filename) return prev;
const files = {
...cur.files,
[ev.filename]: {
downloaded: ev.downloaded || 0,
total: ev.total || 0,
rate: ev.rate || 0,
},
};
return { ...prev, [ev.repo_id]: { ...cur, files } };
}
// LED dot tone per row state.
const LED_TONE = {
ok: 'bg-success shadow-[0_0_5px_1px_color-mix(in_srgb,var(--color-success)_50%,transparent)]',
active: 'bg-primary shadow-[0_0_6px_1px_var(--color-brand-glow)]',
busy: 'bg-primary fr-pulse',
off: 'bg-fg-subtle/40',
};
// Chip Badge tone per chip category.
const CHIP_TONE = { req: 'brand', rec: 'success', eng: 'neutral', opt: 'neutral' };
function Row({ led, name, chip, chipTone, size, action, sub }) {
return (
<div className="flex items-center gap-3 rounded-md px-3 py-2 transition-colors hover:bg-bg-elev-3">
<span
className={cn('h-1.5 w-1.5 shrink-0 rounded-full', LED_TONE[led] || LED_TONE.off)}
aria-hidden="true"
/>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="flex items-center gap-2 text-sm font-semibold">
{name}
{chip && (
<Badge tone={CHIP_TONE[chipTone] || 'neutral'} size="xs">
{chip}
</Badge>
)}
</span>
{sub && <span className="block min-w-0">{sub}</span>}
</div>
<span className="shrink-0 font-mono text-[0.64rem] tabular-nums text-fg-muted">{size}</span>
{action}
</div>
);
}
export default function WizardLibrary() {
const { t } = useTranslation();
const modelsQuery = useModels();
const installMutation = useInstallModel();
const [engines, setEngines] = useState(null);
const [progress, setProgress] = useState({}); // { repo_id: { phase, files } }
const [showTail, setShowTail] = useState(false);
const [switching, setSwitching] = useState(null);
const esRef = useRef(null);
const models = useMemo(() => {
const list = modelsQuery.data;
return Array.isArray(list) ? list : (list?.models ?? []);
}, [modelsQuery.data]);
// Host platform tags (e.g. ['darwin', 'darwin-arm64']) so we can surface the
// models tuned for THIS machine by default instead of burying them.
const platformTags = useMemo(() => {
const d = modelsQuery.data;
return Array.isArray(d) ? [] : (d?.platform_tags ?? []);
}, [modelsQuery.data]);
// Engines: TTS family only on first run — the family the studio speaks with.
useEffect(() => {
let cancelled = false;
(async () => {
try {
const all = await listEngines();
if (!cancelled) setEngines(all?.tts ?? null);
} catch {
/* backend mid-boot — the wizard polls models anyway */
}
})();
return () => {
cancelled = true;
};
}, []);
// One SSE stream for all rows (same channel the Settings store uses).
useEffect(() => {
const es = new EventSource(setupDownloadStreamUrl());
esRef.current = es;
es.onmessage = (evt) => {
try {
const ev = JSON.parse(evt.data);
if (!ev?.repo_id) return;
// Refetch the list once the repo finishes so the row flips to installed.
// (The reducer is pure — the side-effect stays here.)
if (ev.phase === 'install_done') modelsQuery.refetch();
// Pure reducer (exported for tests). Full phase taxonomy:
// SetupProgressEvent in api/setup.ts. install_error now PERSISTS with
// its message instead of the row silently vanishing (P1-A).
setProgress((prev) => reduceWizardDownloadEvent(prev, ev));
} catch {
/* keepalive */
}
};
return () => es.close();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const install = (repoId) => {
setProgress((p) => ({ ...p, [repoId]: { phase: 'active', files: {} } }));
installMutation.mutate(repoId, {
onError: (e) => {
toast.error(e?.message || 'install failed');
setProgress((p) => {
const n = { ...p };
delete n[repoId];
return n;
});
},
});
};
const useEngine = async (id) => {
setSwitching(id);
try {
const r = await selectEngine('tts', id);
setEngines((e) => (e ? { ...e, active: r.active } : e));
// Consume the routing echo: warn when the pick lands on a CPU fallback
// on this host, otherwise confirm the switch. See notifyEngineSelected.
notifyEngineSelected(r, t, 'tts');
} catch (e) {
toast.error(e?.message || 'switch failed');
} finally {
setSwitching(null);
}
};
const supported = models.filter((m) => m.supported !== false);
const required = supported.filter((m) => m.required);
const optionalAll = supported.filter((m) => !m.required);
// Platform-tuned optionals lead (shown by default); the universal long tail
// still folds behind a quiet count.
const platformPicks = optionalAll.filter((m) => isPlatformPick(m, platformTags));
const tail = optionalAll.filter((m) => !isPlatformPick(m, platformTags));
const modelRow = (m, chip, chipTone, note) => {
const p = progress[m.repo_id];
// A failed install PERSISTS (P1-A): show the mirror-aware reason + a Retry
// instead of the row silently vanishing.
const errored = p?.phase === 'install_error';
// Prefer the backend's authoritative aggregate; fall back to per-file sums
// only until that event arrives (then to nulls when nothing's streaming).
const { pct, etaSec, rate, remaining } =
progressFromAgg(p?.agg) ||
(p ? aggregate(p.files) : { pct: null, etaSec: null, rate: 0, remaining: null });
const downloading = !!p && !errored;
// 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}
led={m.installed ? 'ok' : downloading ? 'busy' : 'off'}
name={m.label}
chip={chip}
chipTone={chipTone}
size={fmtGB(m.size_gb)}
sub={
errored ? (
<span className="block max-w-[280px] font-mono text-[0.64rem] leading-snug text-danger">
{t('firstrun.lib_install_failed', {
error: p.error,
defaultValue: 'Install failed: {{error}}',
})}
</span>
) : downloading ? (
<span className="block h-[3px] max-w-[280px] overflow-hidden rounded-full bg-fg/[0.08]">
<span
className="block h-full rounded-full bg-primary transition-[width] duration-300"
style={{ width: `${pct ?? 4}%` }}
/>
</span>
) : (
note || null
)
}
action={
m.installed ? (
<Check size={14} className="shrink-0 text-success" aria-hidden="true" />
) : errored ? (
<Button variant="ghost" size="sm" onClick={() => install(m.repo_id)}>
{t('firstrun.lib_retry', 'Retry')}
</Button>
) : downloading ? (
<span className="shrink-0 font-mono text-[0.64rem] tabular-nums text-primary">
{statParts.length
? statParts.join(' · ')
: t('firstrun.lib_downloading', 'downloading…')}
</span>
) : (
<Button variant="ghost" size="sm" onClick={() => install(m.repo_id)}>
{t('firstrun.lib_download', 'Download')}
</Button>
)
}
/>
);
};
return (
<div className="flex max-h-[min(56vh,620px)] flex-col gap-1 overflow-y-auto">
{required.map((m) => modelRow(m, t('firstrun.chip_required', 'required'), 'req'))}
{/* Optional models tuned for THIS machine — shown by default with the
catalog note explaining why (e.g. "5× faster on Apple Silicon"). */}
{platformPicks.map((m) =>
modelRow(m, t('firstrun.chip_recommended', 'recommended'), 'rec', m.note),
)}
{(engines?.backends ?? []).map((b) => (
<Row
key={b.id}
led={b.id === engines.active ? 'active' : b.available ? 'ok' : 'off'}
name={b.display_name}
chip={t('firstrun.chip_engine', 'engine')}
chipTone="eng"
size=""
action={
b.id === engines.active ? (
<span className="shrink-0 font-mono text-[0.64rem] text-primary">
{t('firstrun.lib_active', 'active')}
</span>
) : b.available ? (
<Button
variant="ghost"
size="sm"
disabled={switching === b.id}
// eslint-disable-next-line react-hooks/rules-of-hooks -- useEngine is an action fn, not a React hook
onClick={() => useEngine(b.id)}
>
{t('firstrun.lib_use', 'Use')}
</Button>
) : (
<span
className="shrink-0 font-mono text-[0.64rem] text-fg-muted"
title={b.reason || undefined}
>
{t('firstrun.lib_in_settings', 'install later in Settings')}
</span>
)
}
/>
))}
{tail.length > 0 && !showTail && (
<Button
variant="ghost"
size="sm"
className="self-start"
onClick={() => setShowTail(true)}
leading={<ChevronRight size={12} />}
>
{t('firstrun.lib_show_all', {
count: tail.length,
defaultValue: 'Show {{count}} more models',
})}
</Button>
)}
{showTail && tail.map((m) => modelRow(m, t('firstrun.chip_optional', 'optional'), 'opt'))}
{Object.keys(progress).length > 0 && (
<p className="m-0 text-xs text-fg-subtle">
{t(
'firstrun.resume_note',
'Interrupted downloads resume automatically — closing the app is safe.',
)}
</p>
)}
</div>
);
}