import { useCallback, useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { useT } from '../lib/i18n/I18nContext'; import { formatBytes, formatEta, progressFromDownloads, progressFromStatus, statusLabel, } from '../utils/localAiHelpers'; import { isTauri, type LocalAiDownloadsProgress, type LocalAiStatus, openhumanLocalAiDownloadsProgress, openhumanLocalAiStatus, } from '../utils/tauriCommands'; import Button from './ui/Button'; const POLL_INTERVAL = 2000; /** * Persistent snackbar that shows local AI download progress. * Anchored bottom-right. * Dismiss hides the UI but does NOT cancel the download. */ const LocalAIDownloadSnackbar = () => { const { t } = useT(); const [status, setStatus] = useState(null); const [downloads, setDownloads] = useState(null); const [dismissed, setDismissed] = useState(false); const [collapsed, setCollapsed] = useState(false); const timerRef = useRef>(undefined); // Track previous isDownloading in state so we can reset the dismiss flag on a // not-downloading โ†’ downloading transition during render (render-phase update, // the officially recommended React pattern for adjusting state on derived-value changes). const [prevIsDownloading, setPrevIsDownloading] = useState(false); // Check Tauri availability once at init const tauriAvailable = (() => { try { return isTauri(); } catch { return false; } })(); // Poll download status useEffect(() => { if (!tauriAvailable) return; const poll = async () => { try { const [statusRes, downloadsRes] = await Promise.all([ openhumanLocalAiStatus(), openhumanLocalAiDownloadsProgress(), ]); if (statusRes.result) setStatus(statusRes.result); if (downloadsRes.result) setDownloads(downloadsRes.result); } catch { // Silently ignore โ€” core may not be ready } }; void poll(); timerRef.current = setInterval(poll, POLL_INTERVAL); return () => clearInterval(timerRef.current); }, [tauriAvailable]); const downloadState = downloads?.state; const currentState = downloadState === 'loading' || downloadState === 'downloading' || downloadState === 'installing' ? downloadState : (status?.state ?? downloadState ?? 'idle'); const isDownloading = currentState === 'loading' || currentState === 'downloading' || currentState === 'installing' || (downloads?.progress != null && downloads.progress > 0 && downloads.progress < 1); // Render-phase update: when a new download cycle starts (not-downloading โ†’ downloading), // reset the dismiss/collapsed flags so the snackbar reappears automatically. if (!!isDownloading !== prevIsDownloading) { setPrevIsDownloading(!!isDownloading); if (isDownloading && !prevIsDownloading) { setDismissed(false); setCollapsed(false); } } const handleDismiss = useCallback(() => setDismissed(true), []); const handleToggleCollapse = useCallback(() => setCollapsed(prev => !prev), []); if (!tauriAvailable || !isDownloading || dismissed) return null; // Use currentState as the source of truth for the fallback sentinel so the // label (derived from currentState) and the progress bar stay in sync. // We still forward download_progress from status so a real numeric value // isn't lost when the downloads object has no progress field. // When status is absent, progressFromStatus(null) returns 0, which is the // correct baseline while data hasn't arrived yet. const statusForProgress: LocalAiStatus | null = status ? { ...status, state: currentState } : null; const progress = progressFromDownloads(downloads) ?? progressFromStatus(statusForProgress); const percent = progress != null ? Math.round(progress * 100) : null; const speed = downloads?.speed_bps ?? status?.download_speed_bps; const eta = downloads?.eta_seconds ?? status?.eta_seconds; const downloaded = downloads?.downloaded_bytes ?? status?.downloaded_bytes; const total = downloads?.total_bytes ?? status?.total_bytes; const label = statusLabel(currentState); const isInstallingPhase = currentState === 'installing'; const phaseDetail = downloads?.warning ?? status?.warning; // Collapsed: small pill if (collapsed) { return createPortal(
, document.body ); } // Expanded: full snackbar return createPortal(
{/* Header */}
{label}
{/* Phase detail */} {phaseDetail && (
{phaseDetail}
)} {/* Progress bar */}
{/* Details */}
{isInstallingPhase ? t('app.localAiDownload.installing') : downloaded != null && total != null ? `${formatBytes(downloaded)} / ${formatBytes(total)}` : percent != null ? `${percent}%` : t('app.localAiDownload.preparing')} {speed != null && speed > 0 ? `${formatBytes(speed)}/s` : ''} {eta != null && eta > 0 ? ` ยท ${formatEta(eta)}` : ''}
, document.body ); }; export default LocalAIDownloadSnackbar;