mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-03 04:00:31 +00:00
[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:
committed by
GitHub
parent
fbdaf5d7f3
commit
14d0ec73f6
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}>()
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user