mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-29 02:32:18 +00:00
feat: add model download progress dialog (#7897)
## Summary Add a progress dialog for model downloads that appears when downloads are active. ## Changes - Add `ModelImportProgressDialog` component for showing download progress - Add `ProgressToastItem` component for individual download job display - Add `StatusBadge` component for status indicators - Extend `assetDownloadStore` with: - `finishedDownloads` computed for completed/failed jobs - `hasDownloads` computed for dialog visibility - `clearFinishedDownloads()` to dismiss finished downloads - Dialog visibility driven by store state - Closing dialog clears finished downloads - Filter dropdown to show all/completed/failed downloads - Expandable/collapsible UI with animated transitions - Update AGENTS.md with import type convention and pluralization note ## Testing - Start a model download and verify the dialog appears - Verify expand/collapse animation works - Verify filter dropdown works - Verify closing the dialog clears finished downloads - Verify dialog hides when no downloads remain ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-7897-feat-add-model-download-progress-dialog-2e26d73d36508116960eff6fbe7dc392) by [Unito](https://www.unito.io) --------- Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
@@ -63,6 +63,9 @@ The project uses **Nx** for build orchestration and task management
|
|||||||
- Imports:
|
- Imports:
|
||||||
- sorted/grouped by plugin
|
- sorted/grouped by plugin
|
||||||
- run `pnpm format` before committing
|
- 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:
|
- ESLint:
|
||||||
- Vue + TS rules
|
- Vue + TS rules
|
||||||
- no floating promises
|
- no floating promises
|
||||||
@@ -137,7 +140,7 @@ The project uses **Nx** for build orchestration and task management
|
|||||||
8. Implement proper error handling
|
8. Implement proper error handling
|
||||||
9. Follow Vue 3 style guide and naming conventions
|
9. Follow Vue 3 style guide and naming conventions
|
||||||
10. Use Vite for fast development and building
|
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
|
12. Avoid new usage of PrimeVue components
|
||||||
13. Write tests for all changes, especially bug fixes to catch future regressions
|
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
|
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
|
||||||
|
|||||||
30
src/components/common/StatusBadge.vue
Normal file
30
src/components/common/StatusBadge.vue
Normal 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>
|
||||||
66
src/components/toast/ProgressToastItem.vue
Normal file
66
src/components/toast/ProgressToastItem.vue
Normal 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>
|
||||||
@@ -2480,5 +2480,21 @@
|
|||||||
"help": {
|
"help": {
|
||||||
"recentReleases": "Recent releases",
|
"recentReleases": "Recent releases",
|
||||||
"helpCenterMenu": "Help Center Menu"
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
271
src/platform/assets/components/ModelImportProgressDialog.vue
Normal file
271
src/platform/assets/components/ModelImportProgressDialog.vue
Normal 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>
|
||||||
@@ -1,13 +1,10 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { computed, ref } from 'vue'
|
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 type { AssetDownloadWsMessage } from '@/schemas/apiSchema'
|
||||||
import { api } from '@/scripts/api'
|
import { api } from '@/scripts/api'
|
||||||
|
|
||||||
interface AssetDownload {
|
export interface AssetDownload {
|
||||||
taskId: string
|
taskId: string
|
||||||
assetId: string
|
assetId: string
|
||||||
assetName: string
|
assetName: string
|
||||||
@@ -24,32 +21,35 @@ interface CompletedDownload {
|
|||||||
timestamp: number
|
timestamp: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const PROGRESS_TOAST_INTERVAL_MS = 5000
|
|
||||||
const PROCESSED_TASK_CLEANUP_MS = 60000
|
const PROCESSED_TASK_CLEANUP_MS = 60000
|
||||||
const MAX_COMPLETED_DOWNLOADS = 10
|
const MAX_COMPLETED_DOWNLOADS = 10
|
||||||
|
|
||||||
export const useAssetDownloadStore = defineStore('assetDownload', () => {
|
export const useAssetDownloadStore = defineStore('assetDownload', () => {
|
||||||
const toastStore = useToastStore()
|
|
||||||
|
|
||||||
/** Map of task IDs to their download progress data */
|
/** 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 */
|
/** Map of task IDs to model types, used to track which model type to refresh after download completes */
|
||||||
const pendingModelTypes = new Map<string, string>()
|
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 */
|
/** Set of task IDs that have reached a terminal state (completed/failed), prevents duplicate processing */
|
||||||
const processedTaskIds = new Set<string>()
|
const processedTaskIds = new Set<string>()
|
||||||
|
|
||||||
/** Reactive signal for completed downloads */
|
/** Reactive signal for completed downloads */
|
||||||
const completedDownloads = ref<CompletedDownload[]>([])
|
const completedDownloads = ref<CompletedDownload[]>([])
|
||||||
|
|
||||||
const hasActiveDownloads = computed(() => activeDownloads.value.size > 0)
|
const downloadList = computed(() => Array.from(downloads.value.values()))
|
||||||
const downloadList = computed(() =>
|
const activeDownloads = computed(() =>
|
||||||
Array.from(activeDownloads.value.values())
|
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.
|
* 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
|
error: data.error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
downloads.value.set(data.task_id, download)
|
||||||
|
|
||||||
if (data.status === 'completed') {
|
if (data.status === 'completed') {
|
||||||
activeDownloads.value.delete(data.task_id)
|
|
||||||
lastToastTime.delete(data.task_id)
|
|
||||||
const modelType = pendingModelTypes.get(data.task_id)
|
const modelType = pendingModelTypes.get(data.task_id)
|
||||||
if (modelType) {
|
if (modelType) {
|
||||||
// Emit completed download signal for other stores to react to
|
|
||||||
const newDownload: CompletedDownload = {
|
const newDownload: CompletedDownload = {
|
||||||
taskId: data.task_id,
|
taskId: data.task_id,
|
||||||
modelType,
|
modelType,
|
||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep only the last MAX_COMPLETED_DOWNLOADS items (FIFO)
|
|
||||||
const updated = [...completedDownloads.value, newDownload]
|
const updated = [...completedDownloads.value, newDownload]
|
||||||
if (updated.length > MAX_COMPLETED_DOWNLOADS) {
|
if (updated.length > MAX_COMPLETED_DOWNLOADS) {
|
||||||
updated.shift()
|
updated.shift()
|
||||||
@@ -107,65 +105,31 @@ export const useAssetDownloadStore = defineStore('assetDownload', () => {
|
|||||||
() => processedTaskIds.delete(data.task_id),
|
() => processedTaskIds.delete(data.task_id),
|
||||||
PROCESSED_TASK_CLEANUP_MS
|
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') {
|
} else if (data.status === 'failed') {
|
||||||
activeDownloads.value.delete(data.task_id)
|
|
||||||
lastToastTime.delete(data.task_id)
|
|
||||||
pendingModelTypes.delete(data.task_id)
|
pendingModelTypes.delete(data.task_id)
|
||||||
setTimeout(
|
setTimeout(
|
||||||
() => processedTaskIds.delete(data.task_id),
|
() => processedTaskIds.delete(data.task_id),
|
||||||
PROCESSED_TASK_CLEANUP_MS
|
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() {
|
function clearFinishedDownloads() {
|
||||||
stopListener = useEventListener(api, 'asset_download', handleAssetDownload)
|
for (const download of finishedDownloads.value) {
|
||||||
}
|
downloads.value.delete(download.taskId)
|
||||||
|
}
|
||||||
function teardown() {
|
|
||||||
stopListener?.()
|
|
||||||
stopListener = undefined
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
activeDownloads,
|
activeDownloads,
|
||||||
|
finishedDownloads,
|
||||||
hasActiveDownloads,
|
hasActiveDownloads,
|
||||||
|
hasDownloads,
|
||||||
downloadList,
|
downloadList,
|
||||||
completedDownloads,
|
completedDownloads,
|
||||||
trackDownload,
|
trackDownload,
|
||||||
setup,
|
clearFinishedDownloads
|
||||||
teardown
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
|
|
||||||
<GlobalToast />
|
<GlobalToast />
|
||||||
<RerouteMigrationToast />
|
<RerouteMigrationToast />
|
||||||
|
<ModelImportProgressDialog />
|
||||||
<UnloadWindowConfirmDialog v-if="!isElectron()" />
|
<UnloadWindowConfirmDialog v-if="!isElectron()" />
|
||||||
<MenuHamburger />
|
<MenuHamburger />
|
||||||
</template>
|
</template>
|
||||||
@@ -49,6 +50,7 @@ import { useErrorHandling } from '@/composables/useErrorHandling'
|
|||||||
import { useProgressFavicon } from '@/composables/useProgressFavicon'
|
import { useProgressFavicon } from '@/composables/useProgressFavicon'
|
||||||
import { SERVER_CONFIG_ITEMS } from '@/constants/serverConfig'
|
import { SERVER_CONFIG_ITEMS } from '@/constants/serverConfig'
|
||||||
import { i18n, loadLocale } from '@/i18n'
|
import { i18n, loadLocale } from '@/i18n'
|
||||||
|
import ModelImportProgressDialog from '@/platform/assets/components/ModelImportProgressDialog.vue'
|
||||||
import { isCloud } from '@/platform/distribution/types'
|
import { isCloud } from '@/platform/distribution/types'
|
||||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||||
import { useTelemetry } from '@/platform/telemetry'
|
import { useTelemetry } from '@/platform/telemetry'
|
||||||
@@ -60,7 +62,6 @@ import { api } from '@/scripts/api'
|
|||||||
import { app } from '@/scripts/app'
|
import { app } from '@/scripts/app'
|
||||||
import { setupAutoQueueHandler } from '@/services/autoQueueService'
|
import { setupAutoQueueHandler } from '@/services/autoQueueService'
|
||||||
import { useKeybindingService } from '@/services/keybindingService'
|
import { useKeybindingService } from '@/services/keybindingService'
|
||||||
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
|
|
||||||
import { useAssetsStore } from '@/stores/assetsStore'
|
import { useAssetsStore } from '@/stores/assetsStore'
|
||||||
import { useCommandStore } from '@/stores/commandStore'
|
import { useCommandStore } from '@/stores/commandStore'
|
||||||
import { useExecutionStore } from '@/stores/executionStore'
|
import { useExecutionStore } from '@/stores/executionStore'
|
||||||
@@ -88,7 +89,6 @@ const { t } = useI18n()
|
|||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const settingStore = useSettingStore()
|
const settingStore = useSettingStore()
|
||||||
const executionStore = useExecutionStore()
|
const executionStore = useExecutionStore()
|
||||||
const assetDownloadStore = useAssetDownloadStore()
|
|
||||||
const colorPaletteStore = useColorPaletteStore()
|
const colorPaletteStore = useColorPaletteStore()
|
||||||
const queueStore = useQueueStore()
|
const queueStore = useQueueStore()
|
||||||
const assetsStore = useAssetsStore()
|
const assetsStore = useAssetsStore()
|
||||||
@@ -256,7 +256,6 @@ onMounted(() => {
|
|||||||
api.addEventListener('reconnecting', onReconnecting)
|
api.addEventListener('reconnecting', onReconnecting)
|
||||||
api.addEventListener('reconnected', onReconnected)
|
api.addEventListener('reconnected', onReconnected)
|
||||||
executionStore.bindExecutionEvents()
|
executionStore.bindExecutionEvents()
|
||||||
assetDownloadStore.setup()
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
init()
|
init()
|
||||||
@@ -273,7 +272,6 @@ onBeforeUnmount(() => {
|
|||||||
api.removeEventListener('reconnecting', onReconnecting)
|
api.removeEventListener('reconnecting', onReconnecting)
|
||||||
api.removeEventListener('reconnected', onReconnected)
|
api.removeEventListener('reconnected', onReconnected)
|
||||||
executionStore.unbindExecutionEvents()
|
executionStore.unbindExecutionEvents()
|
||||||
assetDownloadStore.teardown()
|
|
||||||
|
|
||||||
// Clean up page visibility listener
|
// Clean up page visibility listener
|
||||||
if (visibilityListener) {
|
if (visibilityListener) {
|
||||||
|
|||||||
Reference in New Issue
Block a user