import { Popover, PopoverContent, PopoverTrigger, } from '@/components/ui/popover' import { Progress } from '@/components/ui/progress' import { useDownloadStore } from '@/hooks/useDownloadStore' import { useLeftPanel } from '@/hooks/useLeftPanel' import { useAppUpdater } from '@/hooks/useAppUpdater' import { abortDownload } from '@/services/models' import { DownloadEvent, DownloadState, events, AppEvent } from '@janhq/core' import { IconDownload, IconX } from '@tabler/icons-react' import { useCallback, useEffect, useMemo, useState } from 'react' import { toast } from 'sonner' import { useTranslation } from '@/i18n/react-i18next-compat' export function DownloadManagement() { const { t } = useTranslation() const { open: isLeftPanelOpen } = useLeftPanel() const [isPopoverOpen, setIsPopoverOpen] = useState(false) const { downloads, updateProgress, localDownloadingModels, removeDownload, removeLocalDownloadingModel, } = useDownloadStore() const { updateState } = useAppUpdater() const [appUpdateState, setAppUpdateState] = useState({ isDownloading: false, downloadProgress: 0, downloadedBytes: 0, totalBytes: 0, }) useEffect(() => { setAppUpdateState({ isDownloading: updateState.isDownloading, downloadProgress: updateState.downloadProgress, downloadedBytes: updateState.downloadedBytes, totalBytes: updateState.totalBytes, }) }, [updateState]) const onAppUpdateDownloadUpdate = useCallback( (data: { progress?: number downloadedBytes?: number totalBytes?: number }) => { setAppUpdateState((prev) => ({ ...prev, isDownloading: true, downloadProgress: data.progress || 0, downloadedBytes: data.downloadedBytes || 0, totalBytes: data.totalBytes || 0, })) }, [] ) const onAppUpdateDownloadSuccess = useCallback(() => { setAppUpdateState((prev) => ({ ...prev, isDownloading: false, downloadProgress: 1, })) toast.success(t('common:toast.appUpdateDownloaded.title'), { description: t('common:toast.appUpdateDownloaded.description'), }) }, [t]) const onAppUpdateDownloadError = useCallback(() => { setAppUpdateState((prev) => ({ ...prev, isDownloading: false, })) toast.error(t('common:toast.appUpdateDownloadFailed.title'), { description: t('common:toast.appUpdateDownloadFailed.description'), }) }, [t]) const downloadProcesses = useMemo(() => { // Get downloads with progress data const downloadsWithProgress = Object.values(downloads).map((download) => ({ id: download.name, name: download.name, progress: download.progress, current: download.current, total: download.total, })) // Add local downloading models that don't have progress data yet const localDownloadsWithoutProgress = Array.from(localDownloadingModels) .filter((modelId) => !downloads[modelId]) // Only include models not in downloads .map((modelId) => ({ id: modelId, name: modelId, progress: 0, current: 0, total: 0, })) return [...downloadsWithProgress, ...localDownloadsWithoutProgress] }, [downloads, localDownloadingModels]) const downloadCount = useMemo(() => { const modelDownloads = downloadProcesses.length const appUpdateDownload = appUpdateState.isDownloading ? 1 : 0 const total = modelDownloads + appUpdateDownload return total }, [downloadProcesses, appUpdateState.isDownloading]) const overallProgress = useMemo(() => { const modelTotal = downloadProcesses.reduce((acc, download) => { return acc + download.total }, 0) const modelCurrent = downloadProcesses.reduce((acc, download) => { return acc + download.current }, 0) // Include app update progress in overall calculation const appUpdateTotal = appUpdateState.isDownloading ? appUpdateState.totalBytes : 0 const appUpdateCurrent = appUpdateState.isDownloading ? appUpdateState.downloadedBytes : 0 const total = modelTotal + appUpdateTotal const current = modelCurrent + appUpdateCurrent return total > 0 ? current / total : 0 }, [ downloadProcesses, appUpdateState.isDownloading, appUpdateState.totalBytes, appUpdateState.downloadedBytes, ]) const onFileDownloadUpdate = useCallback( async (state: DownloadState) => { console.debug('onFileDownloadUpdate', state) updateProgress( state.modelId, state.percent, state.modelId, state.size?.transferred, state.size?.total ) }, [updateProgress] ) const onFileDownloadError = useCallback( (state: DownloadState) => { console.debug('onFileDownloadError', state) removeDownload(state.modelId) removeLocalDownloadingModel(state.modelId) toast.error(t('common:toast.downloadFailed.title'), { id: 'download-failed', description: t('common:toast.downloadFailed.description', { item: state.modelId, }), }) }, [removeDownload, removeLocalDownloadingModel, t] ) const onModelValidationStarted = useCallback( (event: { modelId: string; downloadType: string }) => { console.debug('onModelValidationStarted', event) // Show validation in progress toast toast.info(t('common:toast.modelValidationStarted.title'), { id: `model-validation-started-${event.modelId}`, description: t('common:toast.modelValidationStarted.description', { modelId: event.modelId, }), duration: Infinity, }) }, [t] ) const onModelValidationFailed = useCallback( (event: { modelId: string; error: string; reason: string }) => { console.debug('onModelValidationFailed', event) // Dismiss the validation started toast toast.dismiss(`model-validation-started-${event.modelId}`) removeDownload(event.modelId) removeLocalDownloadingModel(event.modelId) // Show specific toast for validation failure toast.error(t('common:toast.modelValidationFailed.title'), { description: t('common:toast.modelValidationFailed.description', { modelId: event.modelId, }), duration: 30000, }) }, [removeDownload, removeLocalDownloadingModel, t] ) const onFileDownloadStopped = useCallback( (state: DownloadState) => { console.debug('onFileDownloadStopped', state) removeDownload(state.modelId) removeLocalDownloadingModel(state.modelId) }, [removeDownload, removeLocalDownloadingModel] ) const onFileDownloadSuccess = useCallback( async (state: DownloadState) => { console.debug('onFileDownloadSuccess', state) // Dismiss any validation started toast when download completes successfully toast.dismiss(`model-validation-started-${state.modelId}`) removeDownload(state.modelId) removeLocalDownloadingModel(state.modelId) toast.success(t('common:toast.downloadComplete.title'), { id: 'download-complete', description: t('common:toast.downloadComplete.description', { item: state.modelId, }), }) }, [removeDownload, removeLocalDownloadingModel, t] ) const onFileDownloadAndVerificationSuccess = useCallback( async (state: DownloadState) => { console.debug('onFileDownloadAndVerificationSuccess', state) // Dismiss any validation started toast when download and verification complete successfully toast.dismiss(`model-validation-started-${state.modelId}`) removeDownload(state.modelId) removeLocalDownloadingModel(state.modelId) toast.success(t('common:toast.downloadAndVerificationComplete.title'), { id: 'download-complete', description: t( 'common:toast.downloadAndVerificationComplete.description', { item: state.modelId, } ), }) }, [removeDownload, removeLocalDownloadingModel, t] ) useEffect(() => { console.debug('DownloadListener: registering event listeners...') events.on(DownloadEvent.onFileDownloadUpdate, onFileDownloadUpdate) events.on(DownloadEvent.onFileDownloadError, onFileDownloadError) events.on(DownloadEvent.onFileDownloadSuccess, onFileDownloadSuccess) events.on(DownloadEvent.onFileDownloadStopped, onFileDownloadStopped) events.on(DownloadEvent.onModelValidationStarted, onModelValidationStarted) events.on(DownloadEvent.onModelValidationFailed, onModelValidationFailed) events.on( DownloadEvent.onFileDownloadAndVerificationSuccess, onFileDownloadAndVerificationSuccess ) // Register app update event listeners events.on(AppEvent.onAppUpdateDownloadUpdate, onAppUpdateDownloadUpdate) events.on(AppEvent.onAppUpdateDownloadSuccess, onAppUpdateDownloadSuccess) events.on(AppEvent.onAppUpdateDownloadError, onAppUpdateDownloadError) return () => { console.debug('DownloadListener: unregistering event listeners...') events.off(DownloadEvent.onFileDownloadUpdate, onFileDownloadUpdate) events.off(DownloadEvent.onFileDownloadError, onFileDownloadError) events.off(DownloadEvent.onFileDownloadSuccess, onFileDownloadSuccess) events.off(DownloadEvent.onFileDownloadStopped, onFileDownloadStopped) events.off( DownloadEvent.onModelValidationStarted, onModelValidationStarted ) events.off(DownloadEvent.onModelValidationFailed, onModelValidationFailed) events.off( DownloadEvent.onFileDownloadAndVerificationSuccess, onFileDownloadAndVerificationSuccess ) // Unregister app update event listeners events.off(AppEvent.onAppUpdateDownloadUpdate, onAppUpdateDownloadUpdate) events.off( AppEvent.onAppUpdateDownloadSuccess, onAppUpdateDownloadSuccess ) events.off(AppEvent.onAppUpdateDownloadError, onAppUpdateDownloadError) } }, [ onFileDownloadUpdate, onFileDownloadError, onFileDownloadSuccess, onFileDownloadStopped, onModelValidationStarted, onModelValidationFailed, onFileDownloadAndVerificationSuccess, onAppUpdateDownloadUpdate, onAppUpdateDownloadSuccess, onAppUpdateDownloadError, ]) function renderGB(bytes: number): string { const gb = bytes / 1024 ** 3 return ((gb * 100) / 100).toFixed(2) } return ( <> {downloadCount > 0 && ( {isLeftPanelOpen ? (
{t('downloads')}
{downloadCount}
{Math.round(overallProgress * 100)}%
) : (
{downloadCount}
)}
e.preventDefault} >

{t('downloading')}

{appUpdateState.isDownloading && (

App Update

{`${renderGB(appUpdateState.downloadedBytes)} / ${renderGB(appUpdateState.totalBytes)}`}{' '} GB ({Math.round(appUpdateState.downloadProgress * 100)} %)

)} {downloadProcesses.map((download) => (

{download.name}

{ abortDownload(download.name).then(() => { toast.info( t('common:toast.downloadCancelled.title'), { id: 'cancel-download', description: t( 'common:toast.downloadCancelled.description' ), } ) if (downloadProcesses.length === 0) { setIsPopoverOpen(false) } }) }} />

{download.total > 0 ? `${renderGB(download.current)} / ${renderGB(download.total)} GB (${Math.round(download.progress * 100)}%)` : 'Initializing download...'}

))}
)} ) }