mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-26 17:54:14 +00:00
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:
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
"
|
||||
>
|
||||
|
||||
@@ -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",
|
||||
|
||||
254
src/platform/assets/components/AssetExportProgressDialog.vue
Normal file
254
src/platform/assets/components/AssetExportProgressDialog.vue
Normal 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>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */
|
||||
|
||||
199
src/stores/assetExportStore.ts
Normal file
199
src/stores/assetExportStore.ts
Normal 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
|
||||
}
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user