feat: bulk asset export with ZIP download (#8712)

## Summary

Adds bulk asset export with ZIP download for cloud users. When selecting
2+ assets and clicking download, the frontend now requests a server-side
ZIP export instead of triggering individual file downloads.

## Changes

### New files
- **`AssetExportProgressDialog.vue`** — HoneyToast-based progress dialog
showing per-job export status with progress percentages, error
indicators, and a manual re-download button for completed exports
- **`assetExportStore.ts`** — Pinia store that tracks export jobs,
handles `asset_export` WebSocket events for real-time progress, polls
stale exports via the task API as a fallback, and auto-triggers ZIP
download on completion

### Modified files
- **`useMediaAssetActions.ts`** — `downloadMultipleAssets` now routes to
ZIP export (via `createAssetExport`) in cloud mode when 2+ assets are
selected; single assets and OSS mode still use direct download
- **`assetService.ts`** — Added `createAssetExport()` and
`getExportDownloadUrl()` endpoints
- **`apiSchema.ts`** — Added `AssetExportWsMessage` type for the
WebSocket event
- **`api.ts`** — Wired up `asset_export` WebSocket event
- **`GraphView.vue`** — Mounted `AssetExportProgressDialog`
- **`main.json`** — Added i18n keys for export toast UI

## How it works

1. User selects multiple assets and clicks download
2. Frontend calls `POST /assets/export` with asset/job IDs
3. Backend creates a ZIP task and streams progress via `asset_export`
WebSocket events
4. `AssetExportProgressDialog` shows real-time progress
5. On completion, the ZIP is auto-downloaded via a presigned URL from
`GET /assets/exports/{name}`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8712-feat-bulk-asset-export-with-ZIP-download-3006d73d365081839ec3dd3e7b0d3b77)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
Alexander Brown
2026-02-18 16:36:59 -08:00
committed by GitHub
parent 27d4a34435
commit 8099cce232
10 changed files with 612 additions and 34 deletions

View File

@@ -67,18 +67,6 @@ describe('HoneyToast', () => {
wrapper.unmount()
})
it('applies collapsed max-height class when collapsed', async () => {
const wrapper = mountComponent({ visible: true, expanded: false })
await nextTick()
const expandableArea = document.body.querySelector(
'[role="status"] > div:first-child'
)
expect(expandableArea?.classList.contains('max-h-0')).toBe(true)
wrapper.unmount()
})
it('has aria-live="polite" for accessibility', async () => {
const wrapper = mountComponent({ visible: true })
await nextTick()
@@ -127,11 +115,6 @@ describe('HoneyToast', () => {
expect(content?.textContent).toBe('expanded')
expect(toggleBtn?.textContent?.trim()).toBe('Collapse')
const expandableArea = document.body.querySelector(
'[role="status"] > div:first-child'
)
expect(expandableArea?.classList.contains('max-h-[400px]')).toBe(true)
wrapper.unmount()
})
})

View File

@@ -26,13 +26,13 @@ function toggle() {
v-if="visible"
role="status"
aria-live="polite"
class="fixed inset-x-0 bottom-6 z-9999 mx-auto w-4/5 max-w-3xl overflow-hidden rounded-lg border border-border-default bg-base-background shadow-lg"
class="fixed inset-x-0 bottom-6 z-9999 mx-auto max-w-3xl overflow-hidden rounded-lg border border-border-default bg-base-background shadow-lg min-w-0 w-min transition-all duration-300"
>
<div
:class="
cn(
'overflow-hidden transition-all duration-300',
isExpanded ? 'max-h-[400px]' : 'max-h-0'
'overflow-hidden transition-all duration-300 min-w-0 max-w-full',
isExpanded ? 'w-[max(400px,40vw)] max-h-100' : 'w-0 max-h-0'
)
"
>

View File

@@ -2817,9 +2817,10 @@
"insertAllAssetsAsNodes": "Insert all assets as nodes",
"openWorkflowAll": "Open all workflows",
"exportWorkflowAll": "Export all workflows",
"downloadStarted": "Downloading {count} files...",
"downloadsStarted": "Started downloading {count} file(s)",
"assetsDeletedSuccessfully": "{count} asset(s) deleted successfully",
"downloadStarted": "Downloading {count} file... | Downloading {count} files...",
"downloadsStarted": "Started downloading {count} file | Started downloading {count} files",
"exportStarted": "Preparing ZIP export for {count} file | Preparing ZIP export for {count} files",
"assetsDeletedSuccessfully": "{count} asset deleted successfully | {count} assets deleted successfully",
"failedToDeleteAssets": "Failed to delete selected assets",
"partialDeleteSuccess": "{succeeded} deleted successfully, {failed} failed",
"nodesAddedToWorkflow": "{count} node(s) added to workflow",
@@ -3015,6 +3016,20 @@
"failed": "Failed"
}
},
"exportToast": {
"exportingAssets": "Exporting Assets",
"preparingExport": "Preparing export...",
"exportError": "Export failed",
"exportFailed": "{count} export failed | {count} export failed | {count} exports failed",
"allExportsCompleted": "All exports completed",
"noExportsInQueue": "No {filter} exports in queue",
"exportStarted": "Preparing ZIP download...",
"exportCompleted": "ZIP download ready",
"exportFailedSingle": "Failed to create ZIP export",
"downloadExport": "Download export",
"downloadFailed": "Failed to download \"{name}\"",
"retryDownload": "Retry download"
},
"workspace": {
"unsavedChanges": {
"title": "Unsaved Changes",

View File

@@ -0,0 +1,254 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import HoneyToast from '@/components/honeyToast/HoneyToast.vue'
import Button from '@/components/ui/button/Button.vue'
import type { AssetExport } from '@/stores/assetExportStore'
import { useAssetExportStore } from '@/stores/assetExportStore'
import { cn } from '@/utils/tailwindUtil'
const { t } = useI18n()
const assetExportStore = useAssetExportStore()
const visible = computed(() => assetExportStore.hasExports)
const isExpanded = ref(false)
const exportJobs = computed(() => assetExportStore.exportList)
const failedJobs = computed(() =>
assetExportStore.finishedExports.filter((e) => e.status === 'failed')
)
const isInProgress = computed(() => assetExportStore.hasActiveExports)
const currentJobName = computed(() => {
const activeJob = exportJobs.value.find((job) => job.status === 'running')
return activeJob?.exportName || t('exportToast.preparingExport')
})
function jobDisplayName(job: AssetExport): string {
if (job.status === 'failed') return job.error || t('exportToast.exportError')
return job.exportName || t('exportToast.preparingExport')
}
const completedCount = computed(() => assetExportStore.finishedExports.length)
const totalCount = computed(() => exportJobs.value.length)
const footerLabel = computed(() => {
if (isInProgress.value) return currentJobName.value
if (failedJobs.value.length > 0)
return t('exportToast.exportFailed', { count: failedJobs.value.length })
return t('exportToast.allExportsCompleted')
})
const footerIconClass = computed(() => {
if (isInProgress.value)
return 'icon-[lucide--loader-circle] animate-spin text-muted-foreground'
if (failedJobs.value.length > 0)
return 'icon-[lucide--circle-alert] text-destructive-background'
return 'icon-[lucide--check-circle] text-jade-600'
})
const tooltipConfig = computed(() => ({
value: footerLabel.value,
disabled: isExpanded.value,
pt: { root: { class: 'z-10000!' } }
}))
function progressPercent(job: AssetExport): number {
return Math.round(job.progress * 100)
}
function closeDialog() {
assetExportStore.clearFinishedExports()
isExpanded.value = false
}
</script>
<template>
<HoneyToast v-model:expanded="isExpanded" :visible>
<template #default>
<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('exportToast.exportingAssets') }}
</h3>
</div>
<div class="relative max-h-75 overflow-y-auto px-4 py-4">
<div class="flex flex-col gap-2">
<div
v-for="job in exportJobs"
:key="job.taskId"
:class="
cn(
'flex items-center justify-between rounded-lg bg-modal-card-background px-4 py-3',
job.status === 'completed' && 'opacity-50'
)
"
>
<div class="min-w-0 flex-1">
<span
:class="
cn(
'block truncate text-sm',
job.status === 'failed'
? 'text-destructive-background'
: 'text-base-foreground'
)
"
>
{{ jobDisplayName(job) }}
</span>
<span
v-if="job.assetsTotal > 0"
class="text-xs text-muted-foreground"
>
{{ job.assetsAttempted }}/{{ job.assetsTotal }}
</span>
</div>
<div class="flex items-center gap-2">
<template v-if="job.status === 'failed'">
<i
class="icon-[lucide--circle-alert] size-4 text-destructive-background"
/>
</template>
<template
v-else-if="job.status === 'completed' && job.downloadError"
>
<span
class="text-xs text-destructive-background truncate max-w-32"
>
{{ job.downloadError }}
</span>
<Button
variant="muted-textonly"
size="icon"
:aria-label="t('exportToast.retryDownload')"
@click.stop="assetExportStore.triggerDownload(job, true)"
>
<i
class="icon-[lucide--rotate-ccw] size-4 text-destructive-background"
/>
</Button>
</template>
<template v-else-if="job.status === 'completed'">
<Button
variant="muted-textonly"
size="icon"
:aria-label="t('exportToast.downloadExport')"
@click.stop="assetExportStore.triggerDownload(job, true)"
>
<i
class="icon-[lucide--download] size-4 text-success-background"
/>
</Button>
</template>
<template v-else-if="job.status === 'running'">
<i
class="icon-[lucide--loader-circle] size-4 animate-spin text-base-foreground"
/>
<span class="text-xs text-base-foreground">
{{ progressPercent(job) }}%
</span>
</template>
<template v-else>
<span class="text-xs text-muted-foreground">
{{ t('progressToast.pending') }}
</span>
</template>
</div>
</div>
</div>
<div
v-if="exportJobs.length === 0"
class="flex flex-col items-center justify-center py-6 text-center"
>
<span class="text-sm text-muted-foreground">
{{
t('exportToast.noExportsInQueue', {
filter: t('progressToast.filter.all')
})
}}
</span>
</div>
</div>
</template>
<template #footer="{ toggle }">
<div
class="flex flex-1 min-w-0 h-12 items-center justify-between gap-2 border-t border-border-default px-4"
>
<div class="flex min-w-0 flex-1 items-center gap-2 text-sm">
<i
v-tooltip.top="tooltipConfig"
:class="cn('size-4 shrink-0', footerIconClass)"
/>
<span
:class="
cn(
'truncate font-bold text-base-foreground transition-all duration-300 overflow-hidden',
isExpanded ? 'min-w-0 flex-1' : 'w-0'
)
"
>
{{ footerLabel }}
</span>
</div>
<div class="flex items-center gap-2">
<span
v-if="isInProgress"
:class="
cn(
'text-sm text-muted-foreground transition-all duration-300 overflow-hidden',
isExpanded ? 'whitespace-nowrap' : 'w-0'
)
"
>
{{
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>
</template>
</HoneyToast>
</template>

View File

@@ -20,6 +20,8 @@ import { createAnnotatedPath } from '@/utils/createAnnotatedPath'
import { detectNodeTypeFromFilename } from '@/utils/loaderNodeUtil'
import { isResultItemType } from '@/utils/typeGuardUtil'
import { useAssetExportStore } from '@/stores/assetExportStore'
import type { AssetItem } from '../schemas/assetSchema'
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
import { assetService } from '../services/assetService'
@@ -73,7 +75,7 @@ export function useMediaAssetActions() {
toast.add({
severity: 'success',
summary: t('g.success'),
detail: t('mediaAsset.selection.downloadsStarted', { count: 1 }),
detail: t('mediaAsset.selection.downloadsStarted', 1),
life: 2000
})
} catch (error) {
@@ -87,16 +89,26 @@ export function useMediaAssetActions() {
}
/**
* Download multiple assets at once
* @param assets Array of assets to download
* Download multiple assets at once.
* In cloud mode with 2+ assets, creates a ZIP export via the backend.
* Falls back to individual downloads in OSS mode or for single assets.
*/
const downloadMultipleAssets = (assets: AssetItem[]) => {
if (!assets || assets.length === 0) return
const hasMultiOutputJobs = assets.some((a) => {
const count = getOutputAssetMetadata(a.user_metadata)?.outputCount
return typeof count === 'number' && count > 1
})
if (isCloud && (assets.length > 1 || hasMultiOutputJobs)) {
void downloadMultipleAssetsAsZip(assets)
return
}
try {
assets.forEach((asset) => {
const filename = asset.name
// Prefer preview_url (already includes subfolder) with getAssetUrl as fallback
const downloadUrl = asset.preview_url || getAssetUrl(asset)
downloadFile(downloadUrl, filename)
})
@@ -104,9 +116,7 @@ export function useMediaAssetActions() {
toast.add({
severity: 'success',
summary: t('g.success'),
detail: t('mediaAsset.selection.downloadsStarted', {
count: assets.length
}),
detail: t('mediaAsset.selection.downloadsStarted', assets.length),
life: 2000
})
} catch (error) {
@@ -120,6 +130,62 @@ export function useMediaAssetActions() {
}
}
async function downloadMultipleAssetsAsZip(assets: AssetItem[]) {
const assetExportStore = useAssetExportStore()
try {
const jobIds: string[] = []
const assetIds: string[] = []
const jobAssetNameFilters: Record<string, string[]> = {}
for (const asset of assets) {
if (getAssetType(asset) === 'output') {
const metadata = getOutputAssetMetadata(asset.user_metadata)
const promptId = metadata?.promptId || asset.id
if (!jobIds.includes(promptId)) {
jobIds.push(promptId)
}
if (metadata?.promptId && asset.name) {
if (!jobAssetNameFilters[metadata.promptId]) {
jobAssetNameFilters[metadata.promptId] = []
}
if (!jobAssetNameFilters[metadata.promptId].includes(asset.name)) {
jobAssetNameFilters[metadata.promptId].push(asset.name)
}
}
} else {
assetIds.push(asset.id)
}
}
const result = await assetService.createAssetExport({
...(jobIds.length > 0 ? { job_ids: jobIds } : {}),
...(assetIds.length > 0 ? { asset_ids: assetIds } : {}),
...(Object.keys(jobAssetNameFilters).length > 0
? { job_asset_name_filters: jobAssetNameFilters }
: {}),
naming_strategy: 'preserve'
})
assetExportStore.trackExport(result.task_id)
toast.add({
severity: 'info',
summary: t('exportToast.exportStarted'),
detail: t('mediaAsset.selection.exportStarted', assets.length),
life: 3000
})
} catch (error) {
console.error('Failed to create asset export:', error)
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('exportToast.exportFailedSingle'),
life: 3000
})
}
}
const copyJobId = async (asset?: AssetItem) => {
const targetAsset = asset ?? mediaContext?.asset.value
if (!targetAsset) return
@@ -580,9 +646,10 @@ export function useMediaAssetActions() {
summary: t('g.success'),
detail: isSingle
? t('mediaAsset.assetDeletedSuccessfully')
: t('mediaAsset.selection.assetsDeletedSuccessfully', {
count: succeeded
}),
: t(
'mediaAsset.selection.assetsDeletedSuccessfully',
succeeded
),
life: 2000
})
} else if (succeeded === 0) {

View File

@@ -31,6 +31,17 @@ interface AssetRequestOptions extends PaginationOptions {
includePublic?: boolean
}
interface AssetExportOptions {
job_ids?: string[]
asset_ids?: string[]
naming_strategy?:
| 'group_by_job_id'
| 'prepend_job_id'
| 'preserve'
| 'asset_id'
job_asset_name_filters?: Record<string, string[]>
}
/**
* Maps CivitAI validation error codes to localized error messages
*/
@@ -153,6 +164,7 @@ function getLocalizedErrorMessage(errorCode: string): string {
const ASSETS_ENDPOINT = '/assets'
const ASSETS_DOWNLOAD_ENDPOINT = '/assets/download'
const ASSETS_EXPORT_ENDPOINT = '/assets/export'
const EXPERIMENTAL_WARNING = `EXPERIMENTAL: If you are seeing this please make sure "Comfy.Assets.UseAssetAPI" is set to "false" in your ComfyUI Settings.\n`
const DEFAULT_LIMIT = 500
@@ -689,6 +701,34 @@ function createAssetService() {
return result.data
}
async function createAssetExport(
params: AssetExportOptions
): Promise<{ task_id: string; status: string; message?: string }> {
const res = await api.fetchApi(ASSETS_EXPORT_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params)
})
if (!res.ok) {
throw new Error(`Failed to create asset export: ${res.status}`)
}
return await res.json()
}
async function getExportDownloadUrl(
exportName: string
): Promise<{ url: string; expires_at?: string }> {
const res = await api.fetchApi(`/assets/exports/${exportName}`)
if (!res.ok) {
throw new Error(`Failed to get export download URL: ${res.status}`)
}
return await res.json()
}
return {
getAssetModelFolders,
getAssetModels,
@@ -703,7 +743,9 @@ function createAssetService() {
getAssetMetadata,
uploadAssetFromUrl,
uploadAssetFromBase64,
uploadAssetAsync
uploadAssetAsync,
createAssetExport,
getExportDownloadUrl
}
}

View File

@@ -148,6 +148,19 @@ const zAssetDownloadWsMessage = z.object({
error: z.string().optional()
})
const zAssetExportWsMessage = z.object({
task_id: z.string(),
export_name: z.string().optional(),
assets_total: z.number(),
assets_attempted: z.number(),
assets_failed: z.number(),
bytes_total: z.number(),
bytes_processed: z.number(),
progress: z.number(),
status: z.enum(['created', 'running', 'completed', 'failed']),
error: z.string().optional()
})
export type StatusWsMessageStatus = z.infer<typeof zStatusWsMessageStatus>
export type StatusWsMessage = z.infer<typeof zStatusWsMessage>
export type ProgressWsMessage = z.infer<typeof zProgressWsMessage>
@@ -168,6 +181,7 @@ export type NodeProgressState = z.infer<typeof zNodeProgressState>
export type ProgressStateWsMessage = z.infer<typeof zProgressStateWsMessage>
export type FeatureFlagsWsMessage = z.infer<typeof zFeatureFlagsWsMessage>
export type AssetDownloadWsMessage = z.infer<typeof zAssetDownloadWsMessage>
export type AssetExportWsMessage = z.infer<typeof zAssetExportWsMessage>
// End of ws messages
export type NotificationWsMessage = z.infer<typeof zNotificationWsMessage>

View File

@@ -22,6 +22,7 @@ import type {
} from '@/platform/workflow/validation/schemas/workflowSchema'
import type {
AssetDownloadWsMessage,
AssetExportWsMessage,
CustomNodesI18n,
EmbeddingsResponse,
ExecutedWsMessage,
@@ -169,6 +170,7 @@ interface BackendApiCalls {
progress_state: ProgressStateWsMessage
feature_flags: FeatureFlagsWsMessage
asset_download: AssetDownloadWsMessage
asset_export: AssetExportWsMessage
}
/** Dictionary of all api calls */

View File

@@ -0,0 +1,199 @@
import { useIntervalFn } from '@vueuse/core'
import { defineStore } from 'pinia'
import { computed, ref, watch } from 'vue'
import { assetService } from '@/platform/assets/services/assetService'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { taskService } from '@/platform/tasks/services/taskService'
import type { AssetExportWsMessage } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { t } from '@/i18n'
export interface AssetExport {
taskId: string
exportName: string
assetsTotal: number
assetsAttempted: number
assetsFailed: number
bytesTotal: number
bytesProcessed: number
progress: number
status: 'created' | 'running' | 'completed' | 'failed'
error?: string
downloadError?: string
lastUpdate: number
downloadTriggered: boolean
}
const STALE_THRESHOLD_MS = 10_000
const POLL_INTERVAL_MS = 10_000
export const useAssetExportStore = defineStore('assetExport', () => {
const exports = ref<Map<string, AssetExport>>(new Map())
const exportList = computed(() => Array.from(exports.value.values()))
const activeExports = computed(() =>
exportList.value.filter(
(e) => e.status === 'created' || e.status === 'running'
)
)
const finishedExports = computed(() =>
exportList.value.filter(
(e) => e.status === 'completed' || e.status === 'failed'
)
)
const hasActiveExports = computed(() => activeExports.value.length > 0)
const hasExports = computed(() => exports.value.size > 0)
function trackExport(taskId: string) {
if (exports.value.has(taskId)) return
exports.value.set(taskId, {
taskId,
exportName: '',
assetsTotal: 0,
assetsAttempted: 0,
assetsFailed: 0,
bytesTotal: 0,
bytesProcessed: 0,
progress: 0,
status: 'created',
lastUpdate: Date.now(),
downloadTriggered: false
})
}
async function triggerDownload(exp: AssetExport, force = false) {
if (!force && (exp.downloadTriggered || !exp.exportName)) return
exp.downloadTriggered = true
try {
exp.downloadError = undefined
const { url } = await assetService.getExportDownloadUrl(exp.exportName)
const link = document.createElement('a')
link.href = url
link.download = exp.exportName
link.style.display = 'none'
link.target = '_blank'
link.rel = 'noopener noreferrer'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
exp.downloadError = message
exp.downloadTriggered = false
useToastStore().add({
severity: 'error',
summary: t('exportToast.downloadFailed', {
name: exp.exportName
}),
detail: message
})
}
}
function handleAssetExport(data: AssetExportWsMessage) {
const existing = exports.value.get(data.task_id)
if (
(existing?.status === 'completed' || existing?.status === 'failed') &&
existing?.downloadTriggered
) {
return
}
const exp: AssetExport = {
taskId: data.task_id,
exportName: data.export_name ?? existing?.exportName ?? '',
assetsTotal: data.assets_total,
assetsAttempted: data.assets_attempted,
assetsFailed: data.assets_failed,
bytesTotal: data.bytes_total,
bytesProcessed: data.bytes_processed,
progress: data.progress,
status: data.status,
error: data.error,
lastUpdate: Date.now(),
downloadTriggered: existing?.downloadTriggered ?? false
}
exports.value.set(data.task_id, exp)
if (data.status === 'completed') {
void triggerDownload(exp)
}
}
async function pollStaleExports() {
const now = Date.now()
const staleExports = activeExports.value.filter(
(e) => now - e.lastUpdate >= STALE_THRESHOLD_MS
)
if (staleExports.length === 0) return
async function pollSingleExport(exp: AssetExport) {
try {
const task = await taskService.getTask(exp.taskId)
if (task.status === 'completed' || task.status === 'failed') {
const result = task.result as Record<string, unknown> | undefined
handleAssetExport({
task_id: exp.taskId,
export_name: (result?.export_name as string) ?? exp.exportName,
assets_total: (result?.assets_total as number) ?? exp.assetsTotal,
assets_attempted:
(result?.assets_attempted as number) ?? exp.assetsAttempted,
assets_failed:
(result?.assets_failed as number) ?? exp.assetsFailed,
bytes_total: exp.bytesTotal,
bytes_processed: exp.bytesTotal,
progress: task.status === 'completed' ? 1 : exp.progress,
status: task.status as 'completed' | 'failed',
error: task.error_message ?? (result?.error as string)
})
}
} catch {
// Task not ready or not found
}
}
await Promise.all(staleExports.map(pollSingleExport))
}
const { pause, resume } = useIntervalFn(
() => void pollStaleExports(),
POLL_INTERVAL_MS,
{ immediate: false }
)
watch(
hasActiveExports,
(hasActive) => {
if (hasActive) resume()
else pause()
},
{ immediate: true }
)
api.addEventListener('asset_export', (e) => handleAssetExport(e.detail))
function clearFinishedExports() {
for (const exp of finishedExports.value) {
exports.value.delete(exp.taskId)
}
}
return {
activeExports,
finishedExports,
hasActiveExports,
hasExports,
exportList,
trackExport,
triggerDownload,
clearFinishedExports
}
})

View File

@@ -19,6 +19,7 @@
<InviteAcceptedToast />
<RerouteMigrationToast />
<ModelImportProgressDialog />
<AssetExportProgressDialog />
<ManagerProgressToast />
<UnloadWindowConfirmDialog v-if="!isDesktop" />
<MenuHamburger />
@@ -54,6 +55,7 @@ import { useProgressFavicon } from '@/composables/useProgressFavicon'
import { SERVER_CONFIG_ITEMS } from '@/constants/serverConfig'
import type { ServerConfig, ServerConfigValue } from '@/constants/serverConfig'
import { i18n, loadLocale } from '@/i18n'
import AssetExportProgressDialog from '@/platform/assets/components/AssetExportProgressDialog.vue'
import ModelImportProgressDialog from '@/platform/assets/components/ModelImportProgressDialog.vue'
import { isCloud, isDesktop } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'