jan/web-app/src/containers/DownloadManegement.tsx
2025-08-28 13:47:46 +07:00

439 lines
16 KiB
TypeScript

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 && (
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
<PopoverTrigger asChild>
{isLeftPanelOpen ? (
<div className="bg-left-panel-fg/10 hover:bg-left-panel-fg/12 p-2 rounded-md my-1 relative border border-left-panel-fg/10 cursor-pointer text-left">
<div className="text-left-panel-fg/80 font-medium flex gap-2">
<span>{t('downloads')}</span>
<span>
<div className="bg-primary font-bold size-5 rounded-full flex items-center justify-center text-primary-fg">
{downloadCount}
</div>
</span>
</div>
<div className="mt-2 flex items-center justify-between space-x-2">
<Progress value={overallProgress * 100} />
<span className="text-xs font-medium text-left-panel-fg/80 shrink-0">
{Math.round(overallProgress * 100)}%
</span>
</div>
</div>
) : (
<div className="fixed bottom-4 left-4 z-50 size-10 bg-main-view border-2 border-main-view-fg/10 rounded-full shadow-md cursor-pointer flex items-center justify-center">
<div className="relative">
<IconDownload
className="text-main-view-fg/50 -mt-1"
size={20}
/>
<div className="bg-primary font-bold size-5 rounded-full absolute -top-4 -right-4 flex items-center justify-center text-primary-fg">
{downloadCount}
</div>
</div>
</div>
)}
</PopoverTrigger>
<PopoverContent
side="right"
align="end"
className="p-0 overflow-hidden text-sm select-none"
sideOffset={6}
onFocusOutside={(e) => e.preventDefault}
>
<div className="flex flex-col">
<div className="p-2 py-1.5 bg-main-view-fg/5 border-b border-main-view-fg/6">
<p className="text-xs text-main-view-fg/70">
{t('downloading')}
</p>
</div>
<div className="p-2 max-h-[300px] overflow-y-auto space-y-2">
{appUpdateState.isDownloading && (
<div className="bg-main-view-fg/4 rounded-md p-2">
<div className="flex items-center justify-between">
<p className="truncate text-main-view-fg/80">
App Update
</p>
</div>
<Progress
value={appUpdateState.downloadProgress * 100}
className="my-2"
/>
<p className="text-main-view-fg/60 text-xs">
{`${renderGB(appUpdateState.downloadedBytes)} / ${renderGB(appUpdateState.totalBytes)}`}{' '}
GB ({Math.round(appUpdateState.downloadProgress * 100)}
%)
</p>
</div>
)}
{downloadProcesses.map((download) => (
<div
key={download.id}
className="bg-main-view-fg/4 rounded-md p-2"
>
<div className="flex items-center justify-between">
<p className="truncate text-main-view-fg/80">
{download.name}
</p>
<div className="shrink-0 flex items-center space-x-0.5">
<IconX
size={16}
className="text-main-view-fg/70 cursor-pointer"
title="Cancel download"
onClick={() => {
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)
}
})
}}
/>
</div>
</div>
<Progress
value={download.progress * 100}
className="my-2"
/>
<p className="text-main-view-fg/60 text-xs">
{download.total > 0
? `${renderGB(download.current)} / ${renderGB(download.total)} GB (${Math.round(download.progress * 100)}%)`
: 'Initializing download...'}
</p>
</div>
))}
</div>
</div>
</PopoverContent>
</Popover>
)}
</>
)
}