[backport cloud/1.36] feat: add model download progress dialog (#7917)

Backport of #7897 to `cloud/1.36`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7917-backport-cloud-1-36-feat-add-model-download-progress-dialog-2e36d73d365081b18bddeb4835f4d706)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Comfy Org PR Bot
2026-01-09 10:29:03 +09:00
committed by GitHub
parent aecb841cc0
commit e912b42fff
7 changed files with 413 additions and 65 deletions

View File

@@ -63,6 +63,9 @@ The project uses **Nx** for build orchestration and task management
- Imports:
- sorted/grouped by plugin
- run `pnpm format` before committing
- use separate `import type` statements, not inline `type` in mixed imports
-`import type { Foo } from './foo'` + `import { bar } from './foo'`
-`import { bar, type Foo } from './foo'`
- ESLint:
- Vue + TS rules
- no floating promises
@@ -137,7 +140,7 @@ The project uses **Nx** for build orchestration and task management
8. Implement proper error handling
9. Follow Vue 3 style guide and naming conventions
10. Use Vite for fast development and building
11. Use vue-i18n in composition API for any string literals. Place new translation entries in src/locales/en/main.json
11. Use vue-i18n in composition API for any string literals. Place new translation entries in src/locales/en/main.json. Use the plurals system in i18n instead of hardcoding pluralization in templates.
12. Avoid new usage of PrimeVue components
13. Write tests for all changes, especially bug fixes to catch future regressions
14. Write code that is expressive and self-documenting to the furthest degree possible. This reduces the need for code comments which can get out of sync with the code itself. Try to avoid comments unless absolutely necessary

View File

@@ -0,0 +1,30 @@
<script setup lang="ts">
type Severity = 'default' | 'secondary' | 'warn' | 'danger' | 'contrast'
const { label, severity = 'default' } = defineProps<{
label: string
severity?: Severity
}>()
function badgeClasses(sev: Severity): string {
const baseClasses =
'inline-flex h-3.5 items-center justify-center rounded-full px-1 text-xxxs font-semibold uppercase'
switch (sev) {
case 'danger':
return `${baseClasses} bg-destructive-background text-white`
case 'contrast':
return `${baseClasses} bg-base-foreground text-base-background`
case 'warn':
return `${baseClasses} bg-warning-background text-base-background`
case 'secondary':
return `${baseClasses} bg-secondary-background text-base-foreground`
default:
return `${baseClasses} bg-primary-background text-base-foreground`
}
}
</script>
<template>
<span :class="badgeClasses(severity)">{{ label }}</span>
</template>

View File

@@ -0,0 +1,66 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import StatusBadge from '@/components/common/StatusBadge.vue'
import type { AssetDownload } from '@/stores/assetDownloadStore'
import { cn } from '@/utils/tailwindUtil'
const { job } = defineProps<{
job: AssetDownload
}>()
const { t } = useI18n()
const progressPercent = computed(() => Math.round(job.progress * 100))
const isCompleted = computed(() => job.status === 'completed')
const isFailed = computed(() => job.status === 'failed')
const isRunning = computed(() => job.status === 'running')
const isPending = computed(() => job.status === 'created')
</script>
<template>
<div
:class="
cn(
'flex items-center justify-between rounded-lg bg-modal-card-background px-4 py-3',
isCompleted && 'opacity-50'
)
"
>
<div class="flex flex-col">
<span class="text-sm text-base-foreground">{{ job.assetName }}</span>
<span v-if="isRunning" class="text-xs text-muted-foreground">
{{ progressPercent }}%
</span>
</div>
<div class="flex items-center gap-2">
<template v-if="isFailed">
<i
class="icon-[lucide--circle-alert] size-4 text-destructive-background"
/>
<StatusBadge :label="t('progressToast.failed')" severity="danger" />
</template>
<template v-else-if="isCompleted">
<StatusBadge :label="t('progressToast.finished')" severity="contrast" />
</template>
<template v-else-if="isRunning">
<i
class="icon-[lucide--loader-circle] size-4 animate-spin text-primary-background"
/>
<span class="text-xs text-primary-background">
{{ progressPercent }}%
</span>
</template>
<template v-else-if="isPending">
<span class="text-xs text-muted-foreground">
{{ t('progressToast.pending') }}
</span>
</template>
</div>
</div>
</template>

View File

@@ -2456,5 +2456,21 @@
"help": {
"recentReleases": "Recent releases",
"helpCenterMenu": "Help Center Menu"
},
"progressToast": {
"importingModels": "Importing Models",
"downloadingModel": "Downloading model...",
"downloadsFailed": "{count} downloads failed | {count} download failed | {count} downloads failed",
"allDownloadsCompleted": "All downloads completed",
"noImportsInQueue": "No {filter} in queue",
"failed": "Failed",
"finished": "Finished",
"pending": "Pending",
"progressCount": "{completed} of {total}",
"filter": {
"all": "All",
"completed": "Completed",
"failed": "Failed"
}
}
}

View File

@@ -0,0 +1,271 @@
<script setup lang="ts">
import Popover from 'primevue/popover'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ProgressToastItem from '@/components/toast/ProgressToastItem.vue'
import Button from '@/components/ui/button/Button.vue'
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
import { cn } from '@/utils/tailwindUtil'
const { t } = useI18n()
const assetDownloadStore = useAssetDownloadStore()
const visible = computed(() => assetDownloadStore.hasDownloads)
const isExpanded = ref(false)
const activeFilter = ref<'all' | 'completed' | 'failed'>('all')
const filterPopoverRef = ref<InstanceType<typeof Popover> | null>(null)
function toggle() {
isExpanded.value = !isExpanded.value
if (!isExpanded.value) {
filterPopoverRef.value?.hide()
}
}
const filterOptions = [
{ value: 'all', label: 'all' },
{ value: 'completed', label: 'completed' },
{ value: 'failed', label: 'failed' }
] as const
function onFilterClick(event: Event) {
filterPopoverRef.value?.toggle(event)
}
function setFilter(filter: typeof activeFilter.value) {
activeFilter.value = filter
filterPopoverRef.value?.hide()
}
const downloadJobs = computed(() => assetDownloadStore.downloadList)
const completedJobs = computed(() =>
assetDownloadStore.finishedDownloads.filter((d) => d.status === 'completed')
)
const failedJobs = computed(() =>
assetDownloadStore.finishedDownloads.filter((d) => d.status === 'failed')
)
const isInProgress = computed(() => assetDownloadStore.hasActiveDownloads)
const currentJobName = computed(() => {
const activeJob = downloadJobs.value.find((job) => job.status === 'running')
return activeJob?.assetName || t('progressToast.downloadingModel')
})
const completedCount = computed(
() => completedJobs.value.length + failedJobs.value.length
)
const totalCount = computed(() => downloadJobs.value.length)
const filteredJobs = computed(() => {
switch (activeFilter.value) {
case 'completed':
return completedJobs.value
case 'failed':
return failedJobs.value
default:
return downloadJobs.value
}
})
const activeFilterLabel = computed(() => {
const option = filterOptions.find((f) => f.value === activeFilter.value)
return option
? t(`progressToast.filter.${option.label}`)
: t('progressToast.filter.all')
})
function closeDialog() {
assetDownloadStore.clearFinishedDownloads()
isExpanded.value = false
}
</script>
<template>
<Teleport to="body">
<Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="translate-y-full opacity-0"
enter-to-class="translate-y-0 opacity-100"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="translate-y-0 opacity-100"
leave-to-class="translate-y-full opacity-0"
>
<div
v-if="visible"
class="fixed inset-x-0 bottom-6 z-50 mx-auto w-[80%] max-w-3xl overflow-hidden rounded-lg border border-border-default bg-base-background shadow-lg"
>
<div
:class="
cn(
'overflow-hidden transition-all duration-300',
isExpanded ? 'max-h-[400px]' : 'max-h-0'
)
"
>
<div
class="flex h-12 items-center justify-between border-b border-border-default px-4"
>
<h3 class="text-sm font-bold text-base-foreground">
{{ t('progressToast.importingModels') }}
</h3>
<div class="flex items-center gap-2">
<Button
variant="secondary"
size="md"
class="gap-1.5 px-2"
@click="onFilterClick"
>
<i class="icon-[lucide--list-filter] size-4" />
<span>{{ activeFilterLabel }}</span>
<i class="icon-[lucide--chevron-down] size-3" />
</Button>
<Popover
ref="filterPopoverRef"
append-to="body"
:dismissable="true"
:close-on-escape="true"
unstyled
:pt="{
root: { class: 'absolute z-50' },
content: {
class:
'bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg'
}
}"
>
<div
class="flex min-w-[120px] flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3"
>
<Button
v-for="option in filterOptions"
:key="option.value"
variant="textonly"
size="sm"
:class="
cn(
'w-full justify-start bg-transparent',
activeFilter === option.value &&
'bg-secondary-background-selected'
)
"
@click="setFilter(option.value)"
>
{{ t(`progressToast.filter.${option.label}`) }}
</Button>
</div>
</Popover>
</div>
</div>
<div class="relative max-h-[300px] overflow-y-auto px-4 py-4">
<div
v-if="filteredJobs.length > 3"
class="absolute right-1 top-4 h-12 w-1 rounded-full bg-muted-foreground"
/>
<div class="flex flex-col gap-2">
<ProgressToastItem
v-for="job in filteredJobs"
:key="job.taskId"
:job="job"
/>
</div>
<div
v-if="filteredJobs.length === 0"
class="flex flex-col items-center justify-center py-6 text-center"
>
<span class="text-sm text-muted-foreground">
{{
t('progressToast.noImportsInQueue', {
filter: activeFilterLabel
})
}}
</span>
</div>
</div>
</div>
<div
class="flex h-12 items-center justify-between border-t border-border-default px-4"
>
<div class="flex items-center gap-2 text-sm">
<template v-if="isInProgress">
<i
class="icon-[lucide--loader-circle] size-4 animate-spin text-muted-foreground"
/>
<span class="font-bold text-base-foreground">{{
currentJobName
}}</span>
</template>
<template v-else-if="failedJobs.length > 0">
<i
class="icon-[lucide--circle-alert] size-4 text-destructive-background"
/>
<span class="font-bold text-base-foreground">
{{
t('progressToast.downloadsFailed', {
count: failedJobs.length
})
}}
</span>
</template>
<template v-else>
<i class="icon-[lucide--check-circle] size-4 text-jade-600" />
<span class="font-bold text-base-foreground">
{{ t('progressToast.allDownloadsCompleted') }}
</span>
</template>
</div>
<div class="flex items-center gap-2">
<span v-if="isInProgress" class="text-sm text-muted-foreground">
{{
t('progressToast.progressCount', {
completed: completedCount,
total: totalCount
})
}}
</span>
<div class="flex items-center">
<Button
variant="muted-textonly"
size="icon"
:aria-label="
isExpanded
? t('contextMenu.Collapse')
: t('contextMenu.Expand')
"
@click.stop="toggle"
>
<i
:class="
cn(
'size-4',
isExpanded
? 'icon-[lucide--chevron-down]'
: 'icon-[lucide--chevron-up]'
)
"
/>
</Button>
<Button
v-if="!isInProgress"
variant="muted-textonly"
size="icon"
:aria-label="t('g.close')"
@click.stop="closeDialog"
>
<i class="icon-[lucide--x] size-4" />
</Button>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>

View File

@@ -1,13 +1,10 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { useEventListener } from '@vueuse/core'
import { st } from '@/i18n'
import { useToastStore } from '@/platform/updates/common/toastStore'
import type { AssetDownloadWsMessage } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
interface AssetDownload {
export interface AssetDownload {
taskId: string
assetId: string
assetName: string
@@ -24,32 +21,35 @@ interface CompletedDownload {
timestamp: number
}
const PROGRESS_TOAST_INTERVAL_MS = 5000
const PROCESSED_TASK_CLEANUP_MS = 60000
const MAX_COMPLETED_DOWNLOADS = 10
export const useAssetDownloadStore = defineStore('assetDownload', () => {
const toastStore = useToastStore()
/** Map of task IDs to their download progress data */
const activeDownloads = ref<Map<string, AssetDownload>>(new Map())
const downloads = ref<Map<string, AssetDownload>>(new Map())
/** Map of task IDs to model types, used to track which model type to refresh after download completes */
const pendingModelTypes = new Map<string, string>()
/** Map of task IDs to timestamps, used to throttle progress toast notifications */
const lastToastTime = new Map<string, number>()
/** Set of task IDs that have reached a terminal state (completed/failed), prevents duplicate processing */
const processedTaskIds = new Set<string>()
/** Reactive signal for completed downloads */
const completedDownloads = ref<CompletedDownload[]>([])
const hasActiveDownloads = computed(() => activeDownloads.value.size > 0)
const downloadList = computed(() =>
Array.from(activeDownloads.value.values())
const downloadList = computed(() => Array.from(downloads.value.values()))
const activeDownloads = computed(() =>
downloadList.value.filter(
(d) => d.status === 'created' || d.status === 'running'
)
)
const finishedDownloads = computed(() =>
downloadList.value.filter(
(d) => d.status === 'completed' || d.status === 'failed'
)
)
const hasActiveDownloads = computed(() => activeDownloads.value.length > 0)
const hasDownloads = computed(() => downloads.value.size > 0)
/**
* Associates a download task with its model type for later use when the download completes.
@@ -82,19 +82,17 @@ export const useAssetDownloadStore = defineStore('assetDownload', () => {
error: data.error
}
downloads.value.set(data.task_id, download)
if (data.status === 'completed') {
activeDownloads.value.delete(data.task_id)
lastToastTime.delete(data.task_id)
const modelType = pendingModelTypes.get(data.task_id)
if (modelType) {
// Emit completed download signal for other stores to react to
const newDownload: CompletedDownload = {
taskId: data.task_id,
modelType,
timestamp: Date.now()
}
// Keep only the last MAX_COMPLETED_DOWNLOADS items (FIFO)
const updated = [...completedDownloads.value, newDownload]
if (updated.length > MAX_COMPLETED_DOWNLOADS) {
updated.shift()
@@ -107,65 +105,31 @@ export const useAssetDownloadStore = defineStore('assetDownload', () => {
() => processedTaskIds.delete(data.task_id),
PROCESSED_TASK_CLEANUP_MS
)
toastStore.add({
severity: 'success',
summary: st('assetBrowser.download.complete', 'Download complete'),
detail: data.asset_name,
life: 5000
})
} else if (data.status === 'failed') {
activeDownloads.value.delete(data.task_id)
lastToastTime.delete(data.task_id)
pendingModelTypes.delete(data.task_id)
setTimeout(
() => processedTaskIds.delete(data.task_id),
PROCESSED_TASK_CLEANUP_MS
)
toastStore.add({
severity: 'error',
summary: st('assetBrowser.download.failed', 'Download failed'),
detail: data.error || data.asset_name,
life: 8000
})
} else {
activeDownloads.value.set(data.task_id, download)
const now = Date.now()
const lastTime = lastToastTime.get(data.task_id) ?? 0
const shouldShowToast = now - lastTime >= PROGRESS_TOAST_INTERVAL_MS
if (shouldShowToast) {
lastToastTime.set(data.task_id, now)
const progressPercent = Math.round(data.progress * 100)
toastStore.add({
severity: 'info',
summary: st('assetBrowser.download.inProgress', 'Downloading...'),
detail: `${data.asset_name} (${progressPercent}%)`,
life: PROGRESS_TOAST_INTERVAL_MS,
closable: true
})
}
}
}
let stopListener: (() => void) | undefined
api.addEventListener('asset_download', handleAssetDownload)
function setup() {
stopListener = useEventListener(api, 'asset_download', handleAssetDownload)
}
function teardown() {
stopListener?.()
stopListener = undefined
function clearFinishedDownloads() {
for (const download of finishedDownloads.value) {
downloads.value.delete(download.taskId)
}
}
return {
activeDownloads,
finishedDownloads,
hasActiveDownloads,
hasDownloads,
downloadList,
completedDownloads,
trackDownload,
setup,
teardown
clearFinishedDownloads
}
})

View File

@@ -17,6 +17,7 @@
<GlobalToast />
<RerouteMigrationToast />
<ModelImportProgressDialog />
<UnloadWindowConfirmDialog v-if="!isElectron()" />
<MenuHamburger />
</template>
@@ -49,6 +50,7 @@ import { useErrorHandling } from '@/composables/useErrorHandling'
import { useProgressFavicon } from '@/composables/useProgressFavicon'
import { SERVER_CONFIG_ITEMS } from '@/constants/serverConfig'
import { i18n, loadLocale } from '@/i18n'
import ModelImportProgressDialog from '@/platform/assets/components/ModelImportProgressDialog.vue'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
@@ -60,7 +62,6 @@ import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { setupAutoQueueHandler } from '@/services/autoQueueService'
import { useKeybindingService } from '@/services/keybindingService'
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
import { useAssetsStore } from '@/stores/assetsStore'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
@@ -88,7 +89,6 @@ const { t } = useI18n()
const toast = useToast()
const settingStore = useSettingStore()
const executionStore = useExecutionStore()
const assetDownloadStore = useAssetDownloadStore()
const colorPaletteStore = useColorPaletteStore()
const queueStore = useQueueStore()
const assetsStore = useAssetsStore()
@@ -256,7 +256,6 @@ onMounted(() => {
api.addEventListener('reconnecting', onReconnecting)
api.addEventListener('reconnected', onReconnected)
executionStore.bindExecutionEvents()
assetDownloadStore.setup()
try {
init()
@@ -273,7 +272,6 @@ onBeforeUnmount(() => {
api.removeEventListener('reconnecting', onReconnecting)
api.removeEventListener('reconnected', onReconnected)
executionStore.unbindExecutionEvents()
assetDownloadStore.teardown()
// Clean up page visibility listener
if (visibilityListener) {