mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 19:09:52 +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
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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':
|
||||
|
||||
171
src/stores/assetDownloadStore.ts
Normal file
171
src/stores/assetDownloadStore.ts
Normal 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
|
||||
}
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user