Files
VoiceStudio/frontend/src/components/settings/models/RecoBanner.jsx
T
debpalashandClaude Fable 5 63fd497caf feat: TTS-only first run, platform-curated ASR, guided OS permissions, parakeet-mlx
Only the TTS model (~2.4 GB) is required on first run; ASR models are
per-platform curated picks (curated_on in models.yaml) installed on demand.
Every transcription surface returns a typed asr_model_missing error with a
one-click download CTA instead of silently pulling multi-GB Whisper weights.
Settings -> Models is a grouped, platform-aware catalog. New guided
permissions UX (wizard System Check + Settings -> Permissions + mic
pre-flight) with native mic-state checks and OS settings deep-links. New
parakeet-mlx engine brings Parakeet TDT v3 to Apple Silicon (language-gated
capture preference so multilingual dictation never regresses). Docs:
expressive-speech page, Flush/Unload + CPU-fallback triage, clone-length FAQ.
Hardening: preflight fails open for custom model pins, ROCm curation no
longer inherits NVIDIA picks, Windows mic probe reads the NonPackaged
consent key, CaptureWidget setup race fixed, offline-cache CI simulation
fixes so empty-cache runners stay green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 15:20:54 +05:30

184 lines
8.0 KiB
React

import React from 'react';
import { RefreshCw, CheckCircle, Download } from 'lucide-react';
import { toast } from 'react-hot-toast';
import { Button } from '../../../ui';
/**
* "For your system" banner — the device's curated model preset (GET
* /setup/recommendations): device label as title, the preset rationale as
* caption, one-click install per row, plus required-only and
* download-all-remaining bulk actions. Purely presentational; all state and
* mutations are supplied by the host ModelStoreTab.
*/
export default function RecoBanner({
reco,
t,
installMutation,
installingReco,
setInstallingReco,
onInstallRecommended,
// One-click single-model install (host's withBusy flow). Optional — legacy
// callers without it render the rows without the per-row action.
onInstall,
// Live per-repo runtime (SSE download state) so a row's install button flips
// to a busy state while its download runs. Optional.
getRowRuntime,
// Free space (GB) on the model-cache volume, from GET /models — gives the
// download buttons context and warns BEFORE a doomed multi-GB download.
diskFreeGb = null,
}) {
if (!reco) return null;
if (reco.all_installed) {
return (
<div className="mb-[var(--space-2)] flex items-center gap-[var(--space-3)] rounded-[var(--chrome-radius-pill)] [border:1px_solid] [border-left-width:2px] [border-color:color-mix(in_srgb,#8ec07c_30%,transparent)] [border-left-color:#8ec07c] bg-[color-mix(in_srgb,#8ec07c_4%,transparent)] px-[var(--space-4)] py-[var(--space-2)] text-[length:var(--text-xs)] text-[var(--chrome-fg-muted)]">
<CheckCircle size={12} color="#8ec07c" />
<span className="flex-1">
{t('models.reco_installed_for', { device: reco.device.label })}
</span>
<span className="text-[length:var(--text-2xs)] text-[var(--chrome-fg-dim)]">
{reco.total_gb} GB
</span>
</div>
);
}
// Any per-row install in flight? Bulk actions are disabled while one runs
// (and rows are disabled while a bulk runs) so the same repo can't be
// double-installed from both paths.
const anyRowActive =
!!getRowRuntime &&
reco.models.some((m) => {
const rt = getRowRuntime(m);
return !!(rt?.showBar || rt?.isInstalling || rt?.rowBusy);
});
return (
<div className="mb-[var(--space-2)] flex flex-col items-stretch gap-[var(--space-2)] rounded-[var(--chrome-radius-pill)] [border:1px_solid] [border-left-width:2px] [border-color:color-mix(in_srgb,#f3a5b6_25%,transparent)] [border-left-color:#f3a5b6] bg-[linear-gradient(135deg,color-mix(in_srgb,#f3a5b6_4%,transparent),color-mix(in_srgb,#d3869b_2%,transparent))] px-[var(--space-4)] pb-[var(--space-4)] pt-[var(--space-3)] text-[length:var(--text-xs)] text-[var(--chrome-fg-muted)] shadow-[0_0_12px_color-mix(in_srgb,#f3a5b6_6%,transparent)]">
<div className="flex items-center justify-between gap-2">
<span className="text-[length:var(--text-md)] font-semibold text-[var(--chrome-fg)]">
{t('models.reco_for', { device: reco.device.label })}
</span>
<div className="flex flex-shrink-0 gap-1">
{(() => {
const requiredMissing = reco.models.filter((m) => m.required && !m.installed);
const requiredGb = requiredMissing.reduce((s, m) => s + m.size_gb, 0);
if (requiredMissing.length === 0) return null;
return (
<Button
variant="primary"
size="sm"
onClick={async () => {
setInstallingReco(true);
try {
await Promise.all(
requiredMissing.map((m) => installMutation.mutateAsync(m.repo_id)),
);
toast.success(
t('models.started_downloading_required', { count: requiredMissing.length }),
);
} catch (e) {
toast.error(t('models.install_failed', { message: e.message || e }));
} finally {
setInstallingReco(false);
}
}}
disabled={installingReco || anyRowActive}
leading={installingReco ? <RefreshCw size={12} className="spinner" /> : null}
>
{installingReco
? t('models.starting')
: t('models.required_size', { size: requiredGb.toFixed(1) })}
</Button>
);
})()}
<Button
variant="subtle"
size="sm"
onClick={onInstallRecommended}
disabled={installingReco || anyRowActive}
title={t('models.download_all_remaining_title')}
>
{t('models.all_size', { size: reco.download_gb_remaining })}
</Button>
</div>
</div>
{/* Why these picks — the backend's per-platform rationale. */}
{reco.rationale && (
<p
className="m-0 -mt-[2px] text-[length:var(--text-xs)] leading-[1.5] text-[var(--chrome-fg-muted)]"
data-testid="reco-rationale"
>
{reco.rationale}
</p>
)}
{/* Disk context next to the download actions: how much room the
download has, and a plain warning when it won't fit. */}
{diskFreeGb != null && (
<div
className="-mt-[2px] text-[length:var(--text-2xs)] text-[var(--chrome-fg-dim)]"
data-testid="reco-disk-context"
>
{t('models.reco_disk_free', { free: diskFreeGb })}
</div>
)}
{diskFreeGb != null && Number(reco.download_gb_remaining) > Number(diskFreeGb) && (
<div
role="alert"
className="text-[length:var(--text-xs)] text-[var(--chrome-severity-warn)]"
data-testid="reco-low-disk"
>
{t('models.reco_low_disk', {
need: reco.download_gb_remaining,
free: diskFreeGb,
})}
</div>
)}
<div className="grid grid-cols-2 gap-x-[var(--space-5)] gap-y-0 text-[length:var(--text-sm)] leading-[1.6]">
{reco.models.map((m) => {
const rt = getRowRuntime?.(m);
const rowActive = !!(rt?.showBar || rt?.isInstalling || rt?.rowBusy);
return (
<span
key={m.repo_id}
className={`inline-flex items-center gap-1 overflow-hidden text-ellipsis whitespace-nowrap ${
m.installed ? 'text-[var(--chrome-fg)]' : 'text-[var(--chrome-fg-muted)]'
}`}
>
{m.installed ? '✓' : '○'} {m.label}
<span className="font-[family-name:var(--chrome-font-mono)] text-[length:var(--text-2xs)] text-[var(--chrome-fg-dim)]">
{m.size_gb}
</span>
{m.required && (
<span className="rounded-[999px] [border:1px_solid_color-mix(in_srgb,#d3869b_30%,transparent)] px-[3px] py-0 text-[length:var(--text-2xs)] uppercase leading-[1.5] tracking-[0.04em] text-[#d3869b]">
{t('models.req_tag')}
</span>
)}
{/* One-click install for a single missing pick (reuses the host's
install flow — progress shows on the catalog row below too). */}
{!m.installed &&
onInstall &&
(rowActive ? (
<RefreshCw
size={10}
className="spinner shrink-0 text-[var(--chrome-accent)]"
aria-label={t('models.downloading')}
/>
) : (
<Button
variant="icon"
iconSize="sm"
onClick={() => onInstall(m.repo_id)}
disabled={installingReco}
title={t('models.install_btn')}
aria-label={t('models.reco_install_one', { label: m.label })}
data-testid={`reco-install-${m.repo_id}`}
>
<Download size={11} />
</Button>
))}
</span>
);
})}
</div>
</div>
);
}