mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-29 14:02:19 +00:00
222 lines
8.9 KiB
TypeScript
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;
|