Files
openhuman/app/src/components/LocalAIDownloadSnackbar.tsx
T

222 lines
8.9 KiB
TypeScript

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<LocalAiStatus | null>(null);
const [downloads, setDownloads] = useState<LocalAiDownloadsProgress | null>(null);
const [dismissed, setDismissed] = useState(false);
const [collapsed, setCollapsed] = useState(false);
const timerRef = useRef<ReturnType<typeof setInterval>>(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(
<div className="fixed bottom-4 right-4 z-[9998] animate-fade-up">
<button
onClick={handleToggleCollapse}
className="flex items-center gap-2 bg-stone-900 border border-stone-700/50 rounded-full px-3 py-2 shadow-large hover:border-stone-600 transition-colors"
aria-label={t('app.localAiDownload.expandAria')}>
<svg
className="w-4 h-4 text-primary-400 animate-pulse"
viewBox="0 0 20 20"
fill="currentColor">
<path d="M10.75 2.75a.75.75 0 00-1.5 0v8.614L6.295 8.235a.75.75 0 10-1.09 1.03l4.25 4.5a.75.75 0 001.09 0l4.25-4.5a.75.75 0 00-1.09-1.03l-2.955 3.129V2.75z" />
<path d="M3.5 12.75a.75.75 0 00-1.5 0v2.5A2.75 2.75 0 004.75 18h10.5A2.75 2.75 0 0018 15.25v-2.5a.75.75 0 00-1.5 0v2.5c0 .69-.56 1.25-1.25 1.25H4.75c-.69 0-1.25-.56-1.25-1.25v-2.5z" />
</svg>
<span className="text-xs font-medium text-content-faint">
{percent != null ? `${percent}%` : label}
</span>
</button>
</div>,
document.body
);
}
// Expanded: full snackbar
return createPortal(
<div className="fixed bottom-4 right-4 z-[9998] w-[320px] animate-fade-up">
<div className="bg-stone-900 border border-stone-700/50 rounded-2xl shadow-large overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-4 pt-3 pb-1">
<div className="flex items-center gap-2">
<svg
className="w-4 h-4 text-primary-400 animate-pulse"
viewBox="0 0 20 20"
fill="currentColor">
<path d="M10.75 2.75a.75.75 0 00-1.5 0v8.614L6.295 8.235a.75.75 0 10-1.09 1.03l4.25 4.5a.75.75 0 001.09 0l4.25-4.5a.75.75 0 00-1.09-1.03l-2.955 3.129V2.75z" />
<path d="M3.5 12.75a.75.75 0 00-1.5 0v2.5A2.75 2.75 0 004.75 18h10.5A2.75 2.75 0 0018 15.25v-2.5a.75.75 0 00-1.5 0v2.5c0 .69-.56 1.25-1.25 1.25H4.75c-.69 0-1.25-.56-1.25-1.25v-2.5z" />
</svg>
<span className="text-sm font-medium text-white">{label}</span>
</div>
<div className="flex items-center gap-1">
<Button
iconOnly
variant="tertiary"
size="xs"
onClick={handleToggleCollapse}
aria-label={t('app.localAiDownload.collapseAria')}>
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
<path d="M3.75 7.25a.75.75 0 000 1.5h8.5a.75.75 0 000-1.5h-8.5z" />
</svg>
</Button>
<Button
iconOnly
variant="tertiary"
size="xs"
onClick={handleDismiss}
aria-label={t('app.localAiDownload.dismissAria')}>
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
<path d="M4.28 3.22a.75.75 0 00-1.06 1.06L6.94 8l-3.72 3.72a.75.75 0 101.06 1.06L8 9.06l3.72 3.72a.75.75 0 101.06-1.06L9.06 8l3.72-3.72a.75.75 0 00-1.06-1.06L8 6.94 4.28 3.22z" />
</svg>
</Button>
</div>
</div>
{/* Phase detail */}
{phaseDetail && (
<div className="px-4 pb-1">
<span className="text-[11px] text-content-faint truncate block">{phaseDetail}</span>
</div>
)}
{/* Progress bar */}
<div className="px-4 py-2">
<div className="h-1.5 w-full rounded-full bg-stone-800 overflow-hidden">
<div
className={`h-full rounded-full bg-gradient-to-r from-primary-500 to-primary-400 transition-all duration-500 ${
isInstallingPhase ? 'animate-pulse' : ''
}`}
style={{ width: isInstallingPhase ? '100%' : `${percent ?? 0}%` }}
/>
</div>
</div>
{/* Details */}
<div className="flex items-center justify-between px-4 pb-3 text-xs text-content-faint">
<span>
{isInstallingPhase
? t('app.localAiDownload.installing')
: downloaded != null && total != null
? `${formatBytes(downloaded)} / ${formatBytes(total)}`
: percent != null
? `${percent}%`
: t('app.localAiDownload.preparing')}
</span>
<span>
{speed != null && speed > 0 ? `${formatBytes(speed)}/s` : ''}
{eta != null && eta > 0 ? ` · ${formatEta(eta)}` : ''}
</span>
</div>
</div>
</div>,
document.body
);
};
export default LocalAIDownloadSnackbar;