[feat] Add Civitai model upload wizard (#6694)

## Summary
Adds a complete model upload workflow that allows users to import models
from Civitai URLs directly into their library.

## Changes
- **Multi-step wizard**: URL input → metadata confirmation → upload
progress
- **Components**: UploadModelDialog, UploadModelUrlInput,
UploadModelConfirmation, UploadModelProgress, UploadModelDialogHeader
- **API integration**: New assetService methods for metadata retrieval
and URL-based uploads
- **Model type management**: useModelTypes composable for fetching and
formatting available model types
- **UX improvements**: Optional validation bypass for UrlInput component
- **Localization**: 26 new i18n strings for the upload workflow

## Review Focus
- Error handling for failed uploads and metadata retrieval
- Model type detection and selection logic
- Dialog state management across multi-step workflow

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6694-feat-Add-Civitai-model-upload-wizard-2ab6d73d36508193b3b1dd67c7cc5a09)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Luke Mino-Altherr
2025-11-19 20:37:22 -08:00
committed by GitHub
parent 423a2e76bc
commit acd855601c
13 changed files with 799 additions and 2 deletions

View File

@@ -1,8 +1,10 @@
import { fromZodError } from 'zod-validation-error'
import { st } from '@/i18n'
import { assetResponseSchema } from '@/platform/assets/schemas/assetSchema'
import type {
AssetItem,
AssetMetadata,
AssetResponse,
ModelFile,
ModelFolder
@@ -10,6 +12,36 @@ import type {
import { api } from '@/scripts/api'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
/**
* Maps CivitAI validation error codes to localized error messages
*/
function getLocalizedErrorMessage(errorCode: string): string {
const errorMessages: Record<string, string> = {
FILE_TOO_LARGE: st('assetBrowser.errorFileTooLarge', 'File too large'),
FORMAT_NOT_ALLOWED: st(
'assetBrowser.errorFormatNotAllowed',
'Format not allowed'
),
UNSAFE_PICKLE_SCAN: st(
'assetBrowser.errorUnsafePickleScan',
'Unsafe pickle scan'
),
UNSAFE_VIRUS_SCAN: st(
'assetBrowser.errorUnsafeVirusScan',
'Unsafe virus scan'
),
MODEL_TYPE_NOT_SUPPORTED: st(
'assetBrowser.errorModelTypeNotSupported',
'Model type not supported'
)
}
return (
errorMessages[errorCode] ||
st('assetBrowser.errorUnknown', 'Unknown error') ||
'Unknown error'
)
}
const ASSETS_ENDPOINT = '/assets'
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
@@ -249,6 +281,77 @@ function createAssetService() {
}
}
/**
* Retrieves metadata from a download URL without downloading the file
*
* @param url - Download URL to retrieve metadata from (will be URL-encoded)
* @returns Promise with metadata including content_length, final_url, filename, etc.
* @throws Error if metadata retrieval fails
*/
async function getAssetMetadata(url: string): Promise<AssetMetadata> {
const encodedUrl = encodeURIComponent(url)
const res = await api.fetchApi(
`${ASSETS_ENDPOINT}/remote-metadata?url=${encodedUrl}`
)
if (!res.ok) {
const errorData = await res.json().catch(() => ({}))
throw new Error(
getLocalizedErrorMessage(errorData.code || 'UNKNOWN_ERROR')
)
}
const data: AssetMetadata = await res.json()
if (data.validation?.is_valid === false) {
throw new Error(
getLocalizedErrorMessage(
data.validation?.errors?.[0]?.code || 'UNKNOWN_ERROR'
)
)
}
return data
}
/**
* Uploads an asset by providing a URL to download from
*
* @param params - Upload parameters
* @param params.url - HTTP/HTTPS URL to download from
* @param params.name - Display name (determines extension)
* @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<AssetItem & { created_new: boolean }> - Asset object with created_new flag
* @throws Error if upload fails
*/
async function uploadAssetFromUrl(params: {
url: string
name: string
tags?: string[]
user_metadata?: Record<string, any>
preview_id?: string
}): Promise<AssetItem & { created_new: boolean }> {
const res = await api.fetchApi(ASSETS_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.'
)
)
}
return await res.json()
}
return {
getAssetModelFolders,
getAssetModels,
@@ -256,7 +359,9 @@ function createAssetService() {
getAssetsForNodeType,
getAssetDetails,
getAssetsByTag,
deleteAsset
deleteAsset,
getAssetMetadata,
uploadAssetFromUrl
}
}