diff --git a/src/components/common/UrlInput.vue b/src/components/common/UrlInput.vue index df40ba0c40..b0dfbd60f2 100644 --- a/src/components/common/UrlInput.vue +++ b/src/components/common/UrlInput.vue @@ -35,6 +35,7 @@ import { ValidationState } from '@/utils/validationUtil' const props = defineProps<{ modelValue: string validateUrlFn?: (url: string) => Promise + disableValidation?: boolean }>() const emit = defineEmits<{ @@ -101,6 +102,8 @@ const defaultValidateUrl = async (url: string): Promise => { } const validateUrl = async (value: string) => { + if (props.disableValidation) return + if (validationState.value === ValidationState.LOADING) return const url = cleanInput(value) diff --git a/src/locales/en/main.json b/src/locales/en/main.json index aef2470d2f..27e42b3ed6 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -2076,6 +2076,35 @@ "noModelsInFolder": "No {type} available in this folder", "searchAssetsPlaceholder": "Type to search...", "uploadModel": "Upload model", + "uploadModelFromCivitai": "Upload a model from Civitai", + "uploadModelFailedToRetrieveMetadata": "Failed to retrieve metadata. Please check the link and try again.", + "onlyCivitaiUrlsSupported": "Only Civitai URLs are supported", + "uploadModelDescription1": "Paste a Civitai model download link to add it to your library.", + "uploadModelDescription2": "Only links from https://civitai.com are supported at the moment", + "uploadModelDescription3": "Max file size: 1 GB", + "civitaiLinkLabel": "Civitai model download link", + "civitaiLinkPlaceholder": "Paste link here", + "civitaiLinkExample": "Example: https://civitai.com/api/download/models/833921?type=Model&format=SafeTensor", + "confirmModelDetails": "Confirm Model Details", + "fileName": "File Name", + "fileSize": "File Size", + "modelName": "Model Name", + "modelNamePlaceholder": "Enter a name for this model", + "tags": "Tags", + "tagsPlaceholder": "e.g., models, checkpoint", + "tagsHelp": "Separate tags with commas", + "upload": "Upload", + "uploadingModel": "Uploading model...", + "uploadSuccess": "Model uploaded successfully!", + "uploadFailed": "Upload failed", + "modelAssociatedWithLink": "The model associated with the link you provided:", + "modelTypeSelectorLabel": "What type of model is this?", + "modelTypeSelectorPlaceholder": "Select model type", + "selectModelType": "Select model type", + "notSureLeaveAsIs": "Not sure? Just leave this as is", + "modelUploaded": "Model uploaded! 🎉", + "findInLibrary": "Find it in the {type} section of the models library.", + "finish": "Finish", "allModels": "All Models", "allCategory": "All {category}", "unknown": "Unknown", @@ -2087,6 +2116,13 @@ "sortZA": "Z-A", "sortRecent": "Recent", "sortPopular": "Popular", + "errorFileTooLarge": "File exceeds the maximum allowed size limit", + "errorFormatNotAllowed": "Only SafeTensor format is allowed", + "errorUnsafePickleScan": "CivitAI detected potentially unsafe code in this file", + "errorUnsafeVirusScan": "CivitAI detected malware or suspicious content in this file", + "errorModelTypeNotSupported": "This model type is not supported", + "errorUnknown": "An unexpected error occurred", + "errorUploadFailed": "Failed to upload asset. Please try again.", "ariaLabel": { "assetCard": "{name} - {type} asset", "loadingAsset": "Loading asset" diff --git a/src/platform/assets/components/AssetBrowserModal.vue b/src/platform/assets/components/AssetBrowserModal.vue index 2f0469e38d..0238b43d28 100644 --- a/src/platform/assets/components/AssetBrowserModal.vue +++ b/src/platform/assets/components/AssetBrowserModal.vue @@ -73,11 +73,14 @@ import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue' import { useFeatureFlags } from '@/composables/useFeatureFlags' import AssetFilterBar from '@/platform/assets/components/AssetFilterBar.vue' import AssetGrid from '@/platform/assets/components/AssetGrid.vue' +import UploadModelDialog from '@/platform/assets/components/UploadModelDialog.vue' +import UploadModelDialogHeader from '@/platform/assets/components/UploadModelDialogHeader.vue' import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser' import { useAssetBrowser } from '@/platform/assets/composables/useAssetBrowser' import type { AssetItem } from '@/platform/assets/schemas/assetSchema' import { assetService } from '@/platform/assets/services/assetService' import { formatCategoryLabel } from '@/platform/assets/utils/categoryLabel' +import { useDialogStore } from '@/stores/dialogStore' import { useModelToNodeStore } from '@/stores/modelToNodeStore' import { OnCloseKey } from '@/types/widgetTypes' @@ -92,6 +95,7 @@ const props = defineProps<{ }>() const { t } = useI18n() +const dialogStore = useDialogStore() const emit = defineEmits<{ 'asset-select': [asset: AssetDisplayItem] @@ -189,6 +193,15 @@ const { flags } = useFeatureFlags() const isUploadButtonEnabled = computed(() => flags.modelUploadButtonEnabled) function handleUploadClick() { - // Will be implemented in the future commit + dialogStore.showDialog({ + key: 'upload-model', + headerComponent: UploadModelDialogHeader, + component: UploadModelDialog, + props: { + onUploadSuccess: async () => { + await execute() + } + } + }) } diff --git a/src/platform/assets/components/UploadModelConfirmation.vue b/src/platform/assets/components/UploadModelConfirmation.vue new file mode 100644 index 0000000000..27185262ec --- /dev/null +++ b/src/platform/assets/components/UploadModelConfirmation.vue @@ -0,0 +1,58 @@ + + + diff --git a/src/platform/assets/components/UploadModelDialog.vue b/src/platform/assets/components/UploadModelDialog.vue new file mode 100644 index 0000000000..4ec0e2eaac --- /dev/null +++ b/src/platform/assets/components/UploadModelDialog.vue @@ -0,0 +1,108 @@ + + + + + diff --git a/src/platform/assets/components/UploadModelDialogHeader.vue b/src/platform/assets/components/UploadModelDialogHeader.vue new file mode 100644 index 0000000000..5476beb80f --- /dev/null +++ b/src/platform/assets/components/UploadModelDialogHeader.vue @@ -0,0 +1,12 @@ + + + diff --git a/src/platform/assets/components/UploadModelFooter.vue b/src/platform/assets/components/UploadModelFooter.vue new file mode 100644 index 0000000000..6425d5bbd0 --- /dev/null +++ b/src/platform/assets/components/UploadModelFooter.vue @@ -0,0 +1,72 @@ + + + diff --git a/src/platform/assets/components/UploadModelProgress.vue b/src/platform/assets/components/UploadModelProgress.vue new file mode 100644 index 0000000000..eab447873d --- /dev/null +++ b/src/platform/assets/components/UploadModelProgress.vue @@ -0,0 +1,68 @@ + + + diff --git a/src/platform/assets/components/UploadModelUrlInput.vue b/src/platform/assets/components/UploadModelUrlInput.vue new file mode 100644 index 0000000000..2cd7978e5f --- /dev/null +++ b/src/platform/assets/components/UploadModelUrlInput.vue @@ -0,0 +1,50 @@ + + + diff --git a/src/platform/assets/composables/useModelTypes.ts b/src/platform/assets/composables/useModelTypes.ts new file mode 100644 index 0000000000..e1c0e6b720 --- /dev/null +++ b/src/platform/assets/composables/useModelTypes.ts @@ -0,0 +1,73 @@ +import { createSharedComposable, useAsyncState } from '@vueuse/core' + +import { api } from '@/scripts/api' + +/** + * Format folder name to display name + * Converts "upscale_models" -> "Upscale Models" + * Converts "loras" -> "LoRAs" + */ +function formatDisplayName(folderName: string): string { + // Special cases for acronyms and proper nouns + const specialCases: Record = { + loras: 'LoRAs', + ipadapter: 'IP-Adapter', + sams: 'SAMs', + clip_vision: 'CLIP Vision', + animatediff_motion_lora: 'AnimateDiff Motion LoRA', + animatediff_models: 'AnimateDiff Models', + vae: 'VAE', + sam2: 'SAM 2', + controlnet: 'ControlNet', + gligen: 'GLIGEN' + } + + if (specialCases[folderName]) { + return specialCases[folderName] + } + + return folderName + .split('_') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' ') +} + +interface ModelTypeOption { + name: string // Display name + value: string // Actual tag value +} + +/** + * Composable for fetching and managing model types from the API + * Uses shared state to ensure data is only fetched once + */ +export const useModelTypes = createSharedComposable(() => { + const { + state: modelTypes, + isLoading, + error, + execute: fetchModelTypes + } = useAsyncState( + async (): Promise => { + const response = await api.getModelFolders() + return response.map((folder) => ({ + name: formatDisplayName(folder.name), + value: folder.name + })) + }, + [] as ModelTypeOption[], + { + immediate: false, + onError: (err) => { + console.error('Failed to fetch model types:', err) + } + } + ) + + return { + modelTypes, + isLoading, + error, + fetchModelTypes + } +}) diff --git a/src/platform/assets/composables/useUploadModelWizard.ts b/src/platform/assets/composables/useUploadModelWizard.ts new file mode 100644 index 0000000000..4719d1d540 --- /dev/null +++ b/src/platform/assets/composables/useUploadModelWizard.ts @@ -0,0 +1,175 @@ +import type { Ref } from 'vue' +import { computed, ref, watch } from 'vue' + +import { st } from '@/i18n' +import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema' +import { assetService } from '@/platform/assets/services/assetService' + +interface WizardData { + url: string + metadata: AssetMetadata | null + name: string + tags: string[] +} + +interface ModelTypeOption { + name: string + value: string +} + +export function useUploadModelWizard(modelTypes: Ref) { + const currentStep = ref(1) + const isFetchingMetadata = ref(false) + const isUploading = ref(false) + const uploadStatus = ref<'idle' | 'uploading' | 'success' | 'error'>('idle') + const uploadError = ref('') + + const wizardData = ref({ + url: '', + metadata: null, + name: '', + tags: [] + }) + + const selectedModelType = ref(undefined) + + // Clear error when URL changes + watch( + () => wizardData.value.url, + () => { + uploadError.value = '' + } + ) + + // Validation + const canFetchMetadata = computed(() => { + return wizardData.value.url.trim().length > 0 + }) + + const canUploadModel = computed(() => { + return !!selectedModelType.value + }) + + function isCivitaiUrl(url: string): boolean { + try { + const hostname = new URL(url).hostname.toLowerCase() + return hostname === 'civitai.com' || hostname.endsWith('.civitai.com') + } catch { + return false + } + } + + async function fetchMetadata() { + if (!canFetchMetadata.value) return + + if (!isCivitaiUrl(wizardData.value.url)) { + uploadError.value = st( + 'assetBrowser.onlyCivitaiUrlsSupported', + 'Only Civitai URLs are supported' + ) + return + } + + isFetchingMetadata.value = true + try { + const metadata = await assetService.getAssetMetadata(wizardData.value.url) + wizardData.value.metadata = metadata + + // Pre-fill name from metadata + wizardData.value.name = metadata.filename || metadata.name || '' + + // Pre-fill model type from metadata tags if available + if (metadata.tags && metadata.tags.length > 0) { + wizardData.value.tags = metadata.tags + // Try to detect model type from tags + const typeTag = metadata.tags.find((tag) => + modelTypes.value.some((type) => type.value === tag) + ) + if (typeTag) { + selectedModelType.value = typeTag + } + } + + currentStep.value = 2 + } catch (error) { + console.error('Failed to retrieve metadata:', error) + uploadError.value = + error instanceof Error + ? error.message + : st( + 'assetBrowser.uploadModelFailedToRetrieveMetadata', + 'Failed to retrieve metadata. Please check the link and try again.' + ) + currentStep.value = 1 + } finally { + isFetchingMetadata.value = false + } + } + + async function uploadModel() { + if (!canUploadModel.value) return + + isUploading.value = true + uploadStatus.value = 'uploading' + + try { + const tags = selectedModelType.value + ? ['models', selectedModelType.value] + : ['models'] + const filename = + wizardData.value.metadata?.filename || + wizardData.value.metadata?.name || + 'model' + + await assetService.uploadAssetFromUrl({ + url: wizardData.value.url, + name: filename, + tags, + user_metadata: { + source: 'civitai', + source_url: wizardData.value.url, + model_type: selectedModelType.value + } + }) + + uploadStatus.value = 'success' + 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 + } + } + + function goToPreviousStep() { + if (currentStep.value > 1) { + currentStep.value = currentStep.value - 1 + } + } + + return { + // State + currentStep, + isFetchingMetadata, + isUploading, + uploadStatus, + uploadError, + wizardData, + selectedModelType, + + // Computed + canFetchMetadata, + canUploadModel, + + // Actions + fetchMetadata, + uploadModel, + goToPreviousStep + } +} diff --git a/src/platform/assets/schemas/assetSchema.ts b/src/platform/assets/schemas/assetSchema.ts index fc974a70a6..48d5275516 100644 --- a/src/platform/assets/schemas/assetSchema.ts +++ b/src/platform/assets/schemas/assetSchema.ts @@ -33,6 +33,29 @@ const zModelFile = z.object({ pathIndex: z.number() }) +const zValidationError = z.object({ + code: z.string(), + message: z.string(), + field: z.string() +}) + +const zValidationResult = z.object({ + is_valid: z.boolean(), + errors: z.array(zValidationError).optional(), + warnings: z.array(zValidationError).optional() +}) + +const zAssetMetadata = z.object({ + content_length: z.number(), + final_url: z.string(), + content_type: z.string().optional(), + filename: z.string().optional(), + name: z.string().optional(), + tags: z.array(z.string()).optional(), + preview_url: z.string().optional(), + validation: zValidationResult.optional() +}) + // Filename validation schema export const assetFilenameSchema = z .string() @@ -48,6 +71,7 @@ export const assetResponseSchema = zAssetResponse // Export types derived from Zod schemas export type AssetItem = z.infer export type AssetResponse = z.infer +export type AssetMetadata = z.infer export type ModelFolder = z.infer export type ModelFile = z.infer diff --git a/src/platform/assets/services/assetService.ts b/src/platform/assets/services/assetService.ts index 85023b29ba..379b193dfb 100644 --- a/src/platform/assets/services/assetService.ts +++ b/src/platform/assets/services/assetService.ts @@ -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 = { + 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 { + 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 - Asset object with created_new flag + * @throws Error if upload fails + */ + async function uploadAssetFromUrl(params: { + url: string + name: string + tags?: string[] + user_metadata?: Record + preview_id?: string + }): Promise { + 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 } }