[feat] Add async model upload with WebSocket progress tracking (#7746)

## Summary
- Adds asynchronous model upload support with HTTP 202 responses
- Implements WebSocket-based real-time download progress tracking via
`asset_download` events
- Creates `assetDownloadStore` for centralized download state management
and toast notifications
- Updates upload wizard UI to show "processing" state when downloads
continue in background

## Changes
- **Core**: New `assetDownloadStore` for managing async downloads with
WebSocket events
- **API**: Support for HTTP 202 async upload responses with task
tracking
- **UI**: Upload wizard now shows "processing" state and allows closing
dialog during download
- **Progress**: Periodic toast notifications (every 5s) during active
downloads with completion/error toasts
- **Schema**: Updated task statuses (`created`, `running`, `completed`,
`failed`) and WebSocket message types

## Review Focus
- WebSocket event handling and download state management in
`assetDownloadStore`
- Upload flow UX - users can now close the dialog and download continues
in background
- Toast notification frequency and timing
- Schema alignment with backend async upload API

Fixes #7748

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7746-feat-Add-async-model-upload-with-WebSocket-progress-tracking-2d36d73d3650811cb79ae06f470dcded)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
Luke Mino-Altherr
2026-01-06 11:43:11 -08:00
committed by GitHub
parent fbdaf5d7f3
commit 14d0ec73f6
18 changed files with 490 additions and 88 deletions

View File

@@ -14,7 +14,8 @@ export enum ServerFeatureFlag {
ASSET_UPDATE_OPTIONS_ENABLED = 'asset_update_options_enabled',
PRIVATE_MODELS_ENABLED = 'private_models_enabled',
ONBOARDING_SURVEY_ENABLED = 'onboarding_survey_enabled',
HUGGINGFACE_MODEL_IMPORT_ENABLED = 'huggingface_model_import_enabled'
HUGGINGFACE_MODEL_IMPORT_ENABLED = 'huggingface_model_import_enabled',
ASYNC_MODEL_UPLOAD_ENABLED = 'async_model_upload_enabled'
}
/**
@@ -65,7 +66,6 @@ export function useFeatureFlags() {
)
},
get huggingfaceModelImportEnabled() {
// Check remote config first (from /api/features), fall back to websocket feature flags
return (
remoteConfig.value.huggingface_model_import_enabled ??
api.getServerFeature(
@@ -73,6 +73,15 @@ export function useFeatureFlags() {
false
)
)
},
get asyncModelUploadEnabled() {
return (
remoteConfig.value.async_model_upload_enabled ??
api.getServerFeature(
ServerFeatureFlag.ASYNC_MODEL_UPLOAD_ENABLED,
false
)
)
}
})

View File

@@ -181,6 +181,7 @@
"missing": "Missing",
"inProgress": "In progress",
"completed": "Completed",
"downloading": "Downloading",
"interrupted": "Interrupted",
"queued": "Queued",
"running": "Running",
@@ -2286,14 +2287,16 @@
"noAssetsFound": "No assets found",
"noModelsInFolder": "No {type} available in this folder",
"notSureLeaveAsIs": "Not sure? Just leave this as is",
"noValidSourceDetected": "No valid import source detected",
"onlyCivitaiUrlsSupported": "Only Civitai URLs are supported",
"ownership": "Ownership",
"ownershipAll": "All",
"ownershipMyModels": "My models",
"ownershipPublicModels": "Public models",
"processingModel": "Download started",
"processingModelDescription": "You can close this dialog. The download will continue in the background.",
"providerCivitai": "Civitai",
"providerHuggingFace": "Hugging Face",
"noValidSourceDetected": "No valid import source detected",
"selectFrameworks": "Select Frameworks",
"selectModelType": "Select model type",
"selectProjects": "Select Projects",
@@ -2318,8 +2321,8 @@
"uploadModelDescription1": "Paste a Civitai model download link to add it to your library.",
"uploadModelDescription1Generic": "Paste a model download link to add it to your library.",
"uploadModelDescription2": "Only links from {link} are supported at the moment",
"uploadModelDescription2Link": "https://civitai.com/models",
"uploadModelDescription2Generic": "Only URLs from the following providers are supported:",
"uploadModelDescription2Link": "https://civitai.com/models",
"uploadModelDescription3": "Max file size: {size}",
"uploadModelFailedToRetrieveMetadata": "Failed to retrieve metadata. Please check the link and try again.",
"uploadModelFromCivitai": "Import a model from Civitai",
@@ -2343,6 +2346,11 @@
"complete": "{assetName} has been deleted.",
"failed": "{assetName} could not be deleted."
},
"download": {
"complete": "Download complete",
"failed": "Download failed",
"inProgress": "Downloading {assetName}..."
},
"rename": {
"failed": "Could not rename asset."
}

View File

@@ -83,6 +83,7 @@ import { useModelUpload } from '@/platform/assets/composables/useModelUpload'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { assetService } from '@/platform/assets/services/assetService'
import { formatCategoryLabel } from '@/platform/assets/utils/categoryLabel'
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
import { OnCloseKey } from '@/types/widgetTypes'
@@ -132,6 +133,17 @@ watch(
{ immediate: true }
)
const assetDownloadStore = useAssetDownloadStore()
watch(
() => assetDownloadStore.hasActiveDownloads,
async (currentlyActive, previouslyActive) => {
if (previouslyActive && !currentlyActive) {
await execute()
}
}
)
const {
searchQuery,
selectedCategory,

View File

@@ -25,8 +25,8 @@
<!-- Step 3: Upload Progress -->
<UploadModelProgress
v-else-if="currentStep === 3"
:status="uploadStatus"
v-else-if="currentStep === 3 && uploadStatus != null"
:result="uploadStatus"
:error="uploadError"
:metadata="wizardData.metadata"
:model-type="selectedModelType"

View File

@@ -81,12 +81,19 @@
<span>{{ $t('assetBrowser.upload') }}</span>
</Button>
<Button
v-else-if="currentStep === 3 && uploadStatus === 'success'"
v-else-if="
currentStep === 3 &&
(uploadStatus === 'success' || uploadStatus === 'processing')
"
variant="secondary"
data-attr="upload-model-step3-finish-button"
@click="emit('close')"
>
{{ $t('assetBrowser.finish') }}
{{
uploadStatus === 'processing'
? $t('g.close')
: $t('assetBrowser.finish')
}}
</Button>
<VideoHelpDialog
v-model="showCivitaiHelp"
@@ -119,7 +126,7 @@ defineProps<{
isUploading: boolean
canFetchMetadata: boolean
canUploadModel: boolean
uploadStatus: 'idle' | 'uploading' | 'success' | 'error'
uploadStatus?: 'processing' | 'success' | 'error'
}>()
const emit = defineEmits<{

View File

@@ -1,22 +1,36 @@
<template>
<div class="flex flex-1 flex-col gap-6 text-sm text-muted-foreground">
<!-- Uploading State -->
<div
v-if="status === 'uploading'"
class="flex flex-1 flex-col items-center justify-center gap-2"
>
<i
class="icon-[lucide--loader-circle] animate-spin text-6xl text-muted-foreground"
/>
<div class="text-center">
<p class="m-0 font-bold">
{{ $t('assetBrowser.uploadingModel') }}
</p>
<!-- Processing State (202 async download in progress) -->
<div v-if="result === 'processing'" class="flex flex-col gap-2">
<p class="m-0 font-bold">
{{ $t('assetBrowser.processingModel') }}
</p>
<p class="m-0">
{{ $t('assetBrowser.processingModelDescription') }}
</p>
<div
class="flex flex-row items-center gap-3 p-4 bg-modal-card-background rounded-lg"
>
<img
v-if="previewImage"
:src="previewImage"
:alt="metadata?.filename || metadata?.name || 'Model preview'"
class="w-14 h-14 rounded object-cover flex-shrink-0"
/>
<div class="flex flex-col justify-center items-start gap-1 flex-1">
<p class="text-base-foreground m-0">
{{ metadata?.filename || metadata?.name }}
</p>
<p class="text-sm text-muted m-0">
{{ modelType }}
</p>
</div>
</div>
</div>
<!-- Success State -->
<div v-else-if="status === 'success'" class="flex flex-col gap-2">
<div v-else-if="result === 'success'" class="flex flex-col gap-2">
<p class="m-0 font-bold">
{{ $t('assetBrowser.modelUploaded') }}
</p>
@@ -47,7 +61,7 @@
<!-- Error State -->
<div
v-else-if="status === 'error'"
v-else-if="result === 'error'"
class="flex flex-1 flex-col items-center justify-center gap-6"
>
<i class="icon-[lucide--x-circle] text-6xl text-error" />
@@ -66,8 +80,8 @@
<script setup lang="ts">
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
defineProps<{
status: 'idle' | 'uploading' | 'success' | 'error'
const { result } = defineProps<{
result: 'processing' | 'success' | 'error'
error?: string
metadata?: AssetMetadata
modelType?: string

View File

@@ -55,7 +55,7 @@
<p v-if="error" class="text-xs text-error">
{{ error }}
</p>
<p v-else class="text-foreground">
<p v-else-if="!flags.asyncModelUploadEnabled" class="text-foreground">
<i18n-t keypath="assetBrowser.maxFileSize" tag="span">
<template #size>
<span class="font-bold italic">{{
@@ -77,6 +77,10 @@
import InputText from 'primevue/inputtext'
import { computed } from 'vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
const { flags } = useFeatureFlags()
const props = defineProps<{
modelValue: string
error?: string

View File

@@ -18,7 +18,7 @@
</template>
</i18n-t>
</li>
<li>
<li v-if="!flags.asyncModelUploadEnabled">
<i18n-t keypath="assetBrowser.uploadModelDescription3" tag="span">
<template #size>
<span class="font-bold italic">{{
@@ -74,6 +74,10 @@
<script setup lang="ts">
import InputText from 'primevue/inputtext'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
const { flags } = useFeatureFlags()
defineProps<{
error?: string
}>()

View File

@@ -10,6 +10,7 @@ import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
import { assetService } from '@/platform/assets/services/assetService'
import type { ImportSource } from '@/platform/assets/types/importSource'
import { validateSourceUrl } from '@/platform/assets/utils/importSourceUtil'
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
import { useAssetsStore } from '@/stores/assetsStore'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
@@ -29,12 +30,13 @@ interface ModelTypeOption {
export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
const { t } = useI18n()
const assetsStore = useAssetsStore()
const assetDownloadStore = useAssetDownloadStore()
const modelToNodeStore = useModelToNodeStore()
const { flags } = useFeatureFlags()
const currentStep = ref(1)
const isFetchingMetadata = ref(false)
const isUploading = ref(false)
const uploadStatus = ref<'idle' | 'uploading' | 'success' | 'error'>('idle')
const uploadStatus = ref<'processing' | 'success' | 'error'>()
const uploadError = ref('')
const wizardData = ref<WizardData>({
@@ -154,11 +156,59 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
}
}
async function uploadModel() {
if (!canUploadModel.value) return
async function uploadPreviewImage(
filename: string
): Promise<string | undefined> {
if (!wizardData.value.previewImage) return undefined
try {
const baseFilename = filename.split('.')[0]
let extension = 'png'
const mimeMatch = wizardData.value.previewImage.match(
/^data:image\/([^;]+);/
)
if (mimeMatch) {
extension = mimeMatch[1] === 'jpeg' ? 'jpg' : mimeMatch[1]
}
const previewAsset = await assetService.uploadAssetFromBase64({
data: wizardData.value.previewImage,
name: `${baseFilename}_preview.${extension}`,
tags: ['preview']
})
return previewAsset.id
} catch (error) {
console.error('Failed to upload preview image:', error)
return undefined
}
}
async function refreshModelCaches() {
if (!selectedModelType.value) return
const providers = modelToNodeStore.getAllNodeProviders(
selectedModelType.value
)
const results = await Promise.allSettled(
providers.map((provider) =>
assetsStore.updateModelsForNodeType(provider.nodeDef.name)
)
)
results.forEach((result, index) => {
if (result.status === 'rejected') {
console.error(
`Failed to refresh ${providers[index].nodeDef.name}:`,
result.reason
)
}
})
}
async function uploadModel(): Promise<boolean> {
if (!canUploadModel.value) {
return false
}
// Defensive check: detectedSource should be valid after fetchMetadata validation,
// but guard against edge cases (e.g., URL modified between steps)
const source = detectedSource.value
if (!source) {
uploadError.value = t('assetBrowser.noValidSourceDetected')
@@ -166,7 +216,6 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
}
isUploading.value = true
uploadStatus.value = 'uploading'
try {
const tags = selectedModelType.value
@@ -177,72 +226,56 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
wizardData.value.metadata?.name ||
'model'
let previewId: string | undefined
// Upload preview image first if available
if (wizardData.value.previewImage) {
try {
const baseFilename = filename.split('.')[0]
// Extract extension from data URL MIME type
let extension = 'png'
const mimeMatch = wizardData.value.previewImage.match(
/^data:image\/([^;]+);/
)
if (mimeMatch) {
extension = mimeMatch[1] === 'jpeg' ? 'jpg' : mimeMatch[1]
}
const previewAsset = await assetService.uploadAssetFromBase64({
data: wizardData.value.previewImage,
name: `${baseFilename}_preview.${extension}`,
tags: ['preview']
})
previewId = previewAsset.id
} catch (error) {
console.error('Failed to upload preview image:', error)
// Continue with model upload even if preview fails
}
const previewId = await uploadPreviewImage(filename)
const userMetadata = {
source: source.type,
source_url: wizardData.value.url,
model_type: selectedModelType.value
}
await assetService.uploadAssetFromUrl({
url: wizardData.value.url,
name: filename,
tags,
user_metadata: {
source: source.type,
if (flags.asyncModelUploadEnabled) {
const result = await assetService.uploadAssetAsync({
source_url: wizardData.value.url,
model_type: selectedModelType.value
},
preview_id: previewId
})
tags,
user_metadata: userMetadata,
preview_id: previewId
})
uploadStatus.value = 'success'
currentStep.value = 3
// Refresh model caches for all node types that use this model category
if (selectedModelType.value) {
const providers = modelToNodeStore.getAllNodeProviders(
selectedModelType.value
)
await Promise.all(
providers.map((provider) =>
assetsStore.updateModelsForNodeType(provider.nodeDef.name)
)
)
if (result.type === 'async' && result.task.status !== 'completed') {
if (selectedModelType.value) {
assetDownloadStore.trackDownload(
result.task.task_id,
selectedModelType.value
)
}
uploadStatus.value = 'processing'
} else {
uploadStatus.value = 'success'
await refreshModelCaches()
}
currentStep.value = 3
} else {
await assetService.uploadAssetFromUrl({
url: wizardData.value.url,
name: filename,
tags,
user_metadata: userMetadata,
preview_id: previewId
})
uploadStatus.value = 'success'
await refreshModelCaches()
currentStep.value = 3
}
return true
} catch (error) {
console.error('Failed to upload asset:', error)
uploadStatus.value = 'error'
uploadError.value =
error instanceof Error ? error.message : 'Failed to upload model'
currentStep.value = 3
return false
} finally {
isUploading.value = false
}
return uploadStatus.value !== 'error'
}
function goToPreviousStep() {

View File

@@ -58,6 +58,17 @@ const zAssetMetadata = z.object({
validation: zValidationResult.optional()
})
const zAsyncUploadTask = z.object({
task_id: z.string(),
status: z.enum(['created', 'running', 'completed', 'failed']),
message: z.string().optional()
})
const zAsyncUploadResponse = z.discriminatedUnion('type', [
z.object({ type: z.literal('sync'), asset: zAsset }),
z.object({ type: z.literal('async'), task: zAsyncUploadTask })
])
// Filename validation schema
export const assetFilenameSchema = z
.string()
@@ -69,11 +80,13 @@ export const assetFilenameSchema = z
// Export schemas following repository patterns
export const assetItemSchema = zAsset
export const assetResponseSchema = zAssetResponse
export const asyncUploadResponseSchema = zAsyncUploadResponse
// Export types derived from Zod schemas
export type AssetItem = z.infer<typeof zAsset>
export type AssetResponse = z.infer<typeof zAssetResponse>
export type AssetMetadata = z.infer<typeof zAssetMetadata>
export type AsyncUploadResponse = z.infer<typeof zAsyncUploadResponse>
export type ModelFolder = z.infer<typeof zModelFolder>
export type ModelFile = z.infer<typeof zModelFile>

View File

@@ -3,12 +3,14 @@ import { fromZodError } from 'zod-validation-error'
import { st } from '@/i18n'
import {
assetItemSchema,
assetResponseSchema
assetResponseSchema,
asyncUploadResponseSchema
} from '@/platform/assets/schemas/assetSchema'
import type {
AssetItem,
AssetMetadata,
AssetResponse,
AsyncUploadResponse,
ModelFile,
ModelFolder
} from '@/platform/assets/schemas/assetSchema'
@@ -46,6 +48,7 @@ function getLocalizedErrorMessage(errorCode: string): string {
}
const ASSETS_ENDPOINT = '/assets'
const ASSETS_DOWNLOAD_ENDPOINT = '/assets/download'
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
@@ -445,6 +448,72 @@ function createAssetService() {
return await res.json()
}
/**
* Uploads an asset asynchronously using the /api/assets/download endpoint
* Returns immediately with either the asset (if already exists) or a task to track
*
* @param params - Upload parameters
* @param params.source_url - HTTP/HTTPS URL to download from
* @param params.tags - Optional freeform tags
* @param params.user_metadata - Optional custom metadata object
* @param params.preview_id - Optional UUID for preview asset
* @returns Promise<AsyncUploadResponse> - Either sync asset or async task info
* @throws Error if upload fails
*/
async function uploadAssetAsync(params: {
source_url: string
tags?: string[]
user_metadata?: Record<string, unknown>
preview_id?: string
}): Promise<AsyncUploadResponse> {
const res = await api.fetchApi(ASSETS_DOWNLOAD_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params)
})
if (!res.ok) {
throw new Error(
st(
'assetBrowser.errorUploadFailed',
'Failed to upload asset. Please try again.'
)
)
}
const data = await res.json()
if (res.status === 202) {
const result = asyncUploadResponseSchema.safeParse({
type: 'async',
task: data
})
if (!result.success) {
throw new Error(
st(
'assetBrowser.errorUploadFailed',
'Failed to parse async upload response. Please try again.'
)
)
}
return result.data
}
const result = asyncUploadResponseSchema.safeParse({
type: 'sync',
asset: data
})
if (!result.success) {
throw new Error(
st(
'assetBrowser.errorUploadFailed',
'Failed to parse sync upload response. Please try again.'
)
)
}
return result.data
}
return {
getAssetModelFolders,
getAssetModels,
@@ -456,7 +525,8 @@ function createAssetService() {
updateAsset,
getAssetMetadata,
uploadAssetFromUrl,
uploadAssetFromBase64
uploadAssetFromBase64,
uploadAssetAsync
}
}

View File

@@ -39,4 +39,5 @@ export type RemoteConfig = {
private_models_enabled?: boolean
onboarding_survey_enabled?: boolean
huggingface_model_import_enabled?: boolean
async_model_upload_enabled?: boolean
}

View File

@@ -135,6 +135,17 @@ const zLogRawResponse = z.object({
const zFeatureFlagsWsMessage = z.record(z.string(), z.any())
const zAssetDownloadWsMessage = z.object({
task_id: z.string(),
asset_id: z.string(),
asset_name: z.string(),
bytes_total: z.number(),
bytes_downloaded: 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>
@@ -154,6 +165,7 @@ export type ProgressTextWsMessage = z.infer<typeof zProgressTextWsMessage>
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>
// End of ws messages
export type NotificationWsMessage = z.infer<typeof zNotificationWsMessage>

View File

@@ -17,6 +17,7 @@ import type {
NodeId
} from '@/platform/workflow/validation/schemas/workflowSchema'
import type {
AssetDownloadWsMessage,
EmbeddingsResponse,
ExecutedWsMessage,
ExecutingWsMessage,
@@ -153,6 +154,7 @@ interface BackendApiCalls {
progress_text: ProgressTextWsMessage
progress_state: ProgressStateWsMessage
feature_flags: FeatureFlagsWsMessage
asset_download: AssetDownloadWsMessage
}
/** Dictionary of all api calls */
@@ -664,6 +666,7 @@ export class ComfyApi extends EventTarget {
case 'logs':
case 'b_preview':
case 'notification':
case 'asset_download':
this.dispatchCustomEvent(msg.type, msg.data)
break
case 'feature_flags':

View File

@@ -0,0 +1,171 @@
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 {
taskId: string
assetId: string
assetName: string
bytesTotal: number
bytesDownloaded: number
progress: number
status: 'created' | 'running' | 'completed' | 'failed'
error?: string
}
interface CompletedDownload {
taskId: string
modelType: string
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())
/** 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())
)
/**
* Associates a download task with its model type for later use when the download completes.
* Intended for external callers (e.g., useUploadModelWizard) to register async downloads.
*/
function trackDownload(taskId: string, modelType: string) {
pendingModelTypes.set(taskId, modelType)
}
/**
* Handles asset download WebSocket events. Updates download progress, manages toast notifications,
* and tracks completed downloads. Prevents duplicate processing of terminal states (completed/failed).
*/
function handleAssetDownload(e: CustomEvent<AssetDownloadWsMessage>) {
const data = e.detail
if (data.status === 'completed' || data.status === 'failed') {
if (processedTaskIds.has(data.task_id)) return
processedTaskIds.add(data.task_id)
}
const download: AssetDownload = {
taskId: data.task_id,
assetId: data.asset_id,
assetName: data.asset_name,
bytesTotal: data.bytes_total,
bytesDownloaded: data.bytes_downloaded,
progress: data.progress,
status: data.status,
error: data.error
}
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()
}
completedDownloads.value = updated
pendingModelTypes.delete(data.task_id)
}
setTimeout(
() => 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
function setup() {
stopListener = useEventListener(api, 'asset_download', handleAssetDownload)
}
function teardown() {
stopListener?.()
stopListener = undefined
}
return {
activeDownloads,
hasActiveDownloads,
downloadList,
completedDownloads,
trackDownload,
setup,
teardown
}
})

View File

@@ -15,6 +15,9 @@ vi.mock('@/scripts/api', () => ({
api: {
getHistory: vi.fn(),
internalURL: vi.fn((path) => `http://localhost:3000${path}`),
apiURL: vi.fn((path) => `http://localhost:3000/api${path}`),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
user: 'test-user'
}
}))

View File

@@ -1,6 +1,6 @@
import { useAsyncState } from '@vueuse/core'
import { defineStore } from 'pinia'
import { computed, shallowReactive, ref } from 'vue'
import { computed, shallowReactive, ref, watch } from 'vue'
import {
mapInputFileToAssetItem,
mapTaskOutputToAssetItem
@@ -12,6 +12,8 @@ import type { TaskItem } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { TaskItemImpl } from './queueStore'
import { useAssetDownloadStore } from './assetDownloadStore'
import { useModelToNodeStore } from './modelToNodeStore'
const INPUT_LIMIT = 100
@@ -93,6 +95,9 @@ const BATCH_SIZE = 200
const MAX_HISTORY_ITEMS = 1000 // Maximum items to keep in memory
export const useAssetsStore = defineStore('assets', () => {
const assetDownloadStore = useAssetDownloadStore()
const modelToNodeStore = useModelToNodeStore()
// Pagination state
const historyOffset = ref(0)
const hasMoreHistory = ref(true)
@@ -345,6 +350,35 @@ export const useAssetsStore = defineStore('assets', () => {
updateModelsForNodeType
} = getModelState()
// Watch for completed downloads and refresh model caches
watch(
() => assetDownloadStore.completedDownloads.at(-1),
async (latestDownload) => {
if (!latestDownload) return
const { modelType } = latestDownload
const providers = modelToNodeStore
.getAllNodeProviders(modelType)
.filter((provider) => provider.nodeDef?.name)
const results = await Promise.allSettled(
providers.map((provider) =>
updateModelsForNodeType(provider.nodeDef.name).then(
() => provider.nodeDef.name
)
)
)
for (const result of results) {
if (result.status === 'rejected') {
console.error(
`Failed to refresh model cache for provider: ${result.reason}`
)
}
}
}
)
return {
// States
inputAssets,

View File

@@ -60,6 +60,7 @@ 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'
@@ -87,6 +88,7 @@ const { t } = useI18n()
const toast = useToast()
const settingStore = useSettingStore()
const executionStore = useExecutionStore()
const assetDownloadStore = useAssetDownloadStore()
const colorPaletteStore = useColorPaletteStore()
const queueStore = useQueueStore()
const assetsStore = useAssetsStore()
@@ -254,6 +256,7 @@ onMounted(() => {
api.addEventListener('reconnecting', onReconnecting)
api.addEventListener('reconnected', onReconnected)
executionStore.bindExecutionEvents()
assetDownloadStore.setup()
try {
init()
@@ -270,6 +273,7 @@ onBeforeUnmount(() => {
api.removeEventListener('reconnecting', onReconnecting)
api.removeEventListener('reconnected', onReconnected)
executionStore.unbindExecutionEvents()
assetDownloadStore.teardown()
// Clean up page visibility listener
if (visibilityListener) {