[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

@@ -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() {