From 47884c623e1fa816a83a92a8ad6531c3c0dbf35e Mon Sep 17 00:00:00 2001 From: Luke Mino-Altherr Date: Mon, 22 Dec 2025 14:34:49 -0500 Subject: [PATCH] [feat] Add HuggingFace model import support (#7540) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds HuggingFace as a model import source alongside CivitAI, with improved UX for model type selection and UTF-8 filename support. ## Changes - **Import Sources**: Implemented extensible import source handler pattern supporting both CivitAI and HuggingFace - **UTF-8 Support**: Decode URL-encoded filenames to properly display international characters (e.g., Chinese) - **UX**: Sort model types alphabetically for easier selection - **Feature Flag**: Added `huggingfaceModelImportEnabled` flag for gradual rollout - **i18n**: Use proper template parameters for localized error messages ## Technical Details - Created `ImportSourceHandler` interface for extensibility - Refactored existing CivitAI logic into handler pattern - Added URL validation per source - Filename decoding handles malformed URLs gracefully ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-7540-feat-Add-HuggingFace-model-import-support-2cb6d73d3650818f966cca89244e8c36) by [Unito](https://www.unito.io) --------- Co-authored-by: Claude Co-authored-by: GitHub Action --- .i18nrc.cjs | 2 +- public/assets/images/hf-logo.svg | 8 ++ src/composables/useFeatureFlags.ts | 13 ++- src/locales/en/main.json | 25 ++++- .../assets/components/UploadModelDialog.vue | 11 ++- .../components/UploadModelDialogHeader.vue | 22 ++++- .../assets/components/UploadModelFooter.vue | 39 +++++++- .../assets/components/UploadModelUrlInput.vue | 95 ++++++++++++++----- .../components/UploadModelUrlInputCivitai.vue | 82 ++++++++++++++++ .../assets/composables/useModelTypes.ts | 10 +- .../composables/useUploadModelWizard.ts | 71 +++++++++++--- .../importSources/civitaiImportSource.ts | 10 ++ .../importSources/huggingfaceImportSource.ts | 10 ++ src/platform/assets/types/importSource.ts | 24 +++++ src/platform/assets/utils/importSourceUtil.ts | 15 +++ src/platform/remoteConfig/types.ts | 1 + 16 files changed, 383 insertions(+), 55 deletions(-) create mode 100644 public/assets/images/hf-logo.svg create mode 100644 src/platform/assets/components/UploadModelUrlInputCivitai.vue create mode 100644 src/platform/assets/importSources/civitaiImportSource.ts create mode 100644 src/platform/assets/importSources/huggingfaceImportSource.ts create mode 100644 src/platform/assets/types/importSource.ts create mode 100644 src/platform/assets/utils/importSourceUtil.ts diff --git a/.i18nrc.cjs b/.i18nrc.cjs index 53acf0546..14c958591 100644 --- a/.i18nrc.cjs +++ b/.i18nrc.cjs @@ -10,7 +10,7 @@ module.exports = defineConfig({ entryLocale: 'en', output: 'src/locales', outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es', 'ar', 'tr', 'pt-BR'], - reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora, HiDream. + reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora, HiDream, Civitai, Hugging Face. 'latent' is the short form of 'latent space'. 'mask' is in the context of image processing. diff --git a/public/assets/images/hf-logo.svg b/public/assets/images/hf-logo.svg new file mode 100644 index 000000000..ab959d165 --- /dev/null +++ b/public/assets/images/hf-logo.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/composables/useFeatureFlags.ts b/src/composables/useFeatureFlags.ts index 4f2e65abd..697818c4e 100644 --- a/src/composables/useFeatureFlags.ts +++ b/src/composables/useFeatureFlags.ts @@ -13,7 +13,8 @@ export enum ServerFeatureFlag { MODEL_UPLOAD_BUTTON_ENABLED = 'model_upload_button_enabled', ASSET_UPDATE_OPTIONS_ENABLED = 'asset_update_options_enabled', PRIVATE_MODELS_ENABLED = 'private_models_enabled', - ONBOARDING_SURVEY_ENABLED = 'onboarding_survey_enabled' + ONBOARDING_SURVEY_ENABLED = 'onboarding_survey_enabled', + HUGGINGFACE_MODEL_IMPORT_ENABLED = 'huggingface_model_import_enabled' } /** @@ -62,6 +63,16 @@ export function useFeatureFlags() { remoteConfig.value.onboarding_survey_enabled ?? api.getServerFeature(ServerFeatureFlag.ONBOARDING_SURVEY_ENABLED, true) ) + }, + get huggingfaceModelImportEnabled() { + // Check remote config first (from /api/features), fall back to websocket feature flags + return ( + remoteConfig.value.huggingface_model_import_enabled ?? + api.getServerFeature( + ServerFeatureFlag.HUGGINGFACE_MODEL_IMPORT_ENABLED, + false + ) + ) } }) diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 665a9ba57..3d87dbd94 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -2233,8 +2233,11 @@ "baseModels": "Base models", "browseAssets": "Browse Assets", "checkpoints": "Checkpoints", - "civitaiLinkExample": "Example: https://civitai.com/models/10706/luisap-z-image-and-qwen-pixel-art-refiner?modelVersionId=2225295", - "civitaiLinkLabel": "Civitai model download link", + "civitaiLinkExample": "{example} {link}", + "civitaiLinkExampleStrong": "Example:", + "civitaiLinkExampleUrl": "https://civitai.com/models/10706/luisap-z-image-and-qwen-pixel-art-refiner?modelVersionId=2225295", + "civitaiLinkLabel": "Civitai model {download} link", + "civitaiLinkLabelDownload": "download", "civitaiLinkPlaceholder": "Paste link here", "confirmModelDetails": "Confirm Model Details", "connectionError": "Please check your connection and try again", @@ -2252,8 +2255,11 @@ "filterBy": "Filter by", "findInLibrary": "Find it in the {type} section of the models library.", "finish": "Finish", + "genericLinkPlaceholder": "Paste link here", "jobId": "Job ID", "loadingModels": "Loading {type}...", + "maxFileSize": "Max file size: {size}", + "maxFileSizeValue": "1 GB", "modelAssociatedWithLink": "The model associated with the link you provided:", "modelName": "Model Name", "modelNamePlaceholder": "Enter a name for this model", @@ -2268,20 +2274,24 @@ "ownershipAll": "All", "ownershipMyModels": "My models", "ownershipPublicModels": "Public models", + "providerCivitai": "Civitai", + "providerHuggingFace": "Hugging Face", + "noValidSourceDetected": "No valid import source detected", "selectFrameworks": "Select Frameworks", "selectModelType": "Select model type", "selectProjects": "Select Projects", "sortAZ": "A-Z", "sortBy": "Sort by", + "sortingType": "Sorting Type", "sortPopular": "Popular", "sortRecent": "Recent", "sortZA": "Z-A", - "sortingType": "Sorting Type", "tags": "Tags", "tagsHelp": "Separate tags with commas", "tagsPlaceholder": "e.g., models, checkpoint", "tryAdjustingFilters": "Try adjusting your search or filters", "unknown": "Unknown", + "unsupportedUrlSource": "Only URLs from {sources} are supported", "upgradeFeatureDescription": "This feature is only available with Creator or Pro plans.", "upgradeToUnlockFeature": "Upgrade to unlock this feature", "upload": "Import", @@ -2289,10 +2299,15 @@ "uploadingModel": "Importing model...", "uploadModel": "Import", "uploadModelDescription1": "Paste a Civitai model download link to add it to your library.", - "uploadModelDescription2": "Only links from https://civitai.com/models are supported at the moment", - "uploadModelDescription3": "Max file size: 1 GB", + "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:", + "uploadModelDescription3": "Max file size: {size}", "uploadModelFailedToRetrieveMetadata": "Failed to retrieve metadata. Please check the link and try again.", "uploadModelFromCivitai": "Import a model from Civitai", + "uploadModelGeneric": "Import a model", + "uploadModelHelpFooterText": "Need help finding the URLs? Click on a provider below to see a how-to video.", "uploadModelHelpVideo": "Upload Model Help Video", "uploadModelHowDoIFindThis": "How do I find this?", "uploadSuccess": "Model imported successfully!", diff --git a/src/platform/assets/components/UploadModelDialog.vue b/src/platform/assets/components/UploadModelDialog.vue index 5d1e6cdde..d6be9e97e 100644 --- a/src/platform/assets/components/UploadModelDialog.vue +++ b/src/platform/assets/components/UploadModelDialog.vue @@ -4,7 +4,13 @@ > + @@ -46,14 +52,17 @@ diff --git a/src/platform/assets/components/UploadModelFooter.vue b/src/platform/assets/components/UploadModelFooter.vue index 7fadaff1f..607484d7c 100644 --- a/src/platform/assets/components/UploadModelFooter.vue +++ b/src/platform/assets/components/UploadModelFooter.vue @@ -1,12 +1,34 @@ @@ -78,9 +105,13 @@ import { ref } from 'vue' import Button from '@/components/ui/button/Button.vue' +import { useFeatureFlags } from '@/composables/useFeatureFlags' import VideoHelpDialog from '@/platform/assets/components/VideoHelpDialog.vue' -const showVideoHelp = ref(false) +const { flags } = useFeatureFlags() + +const showCivitaiHelp = ref(false) +const showHuggingFaceHelp = ref(false) defineProps<{ currentStep: number diff --git a/src/platform/assets/components/UploadModelUrlInput.vue b/src/platform/assets/components/UploadModelUrlInput.vue index c7e94a8be..635d28c02 100644 --- a/src/platform/assets/components/UploadModelUrlInput.vue +++ b/src/platform/assets/components/UploadModelUrlInput.vue @@ -1,28 +1,74 @@ @@ -44,4 +90,9 @@ const url = computed({ get: () => props.modelValue, set: (value: string) => emit('update:modelValue', value) }) + +const civitaiIcon = '/assets/images/civitai.svg' +const civitaiUrl = 'https://civitai.com/models' +const huggingFaceIcon = '/assets/images/hf-logo.svg' +const huggingFaceUrl = 'https://huggingface.co' diff --git a/src/platform/assets/components/UploadModelUrlInputCivitai.vue b/src/platform/assets/components/UploadModelUrlInputCivitai.vue new file mode 100644 index 000000000..b89ee5329 --- /dev/null +++ b/src/platform/assets/components/UploadModelUrlInputCivitai.vue @@ -0,0 +1,82 @@ + + + diff --git a/src/platform/assets/composables/useModelTypes.ts b/src/platform/assets/composables/useModelTypes.ts index 12aa8d419..8f578c926 100644 --- a/src/platform/assets/composables/useModelTypes.ts +++ b/src/platform/assets/composables/useModelTypes.ts @@ -50,10 +50,12 @@ export const useModelTypes = createSharedComposable(() => { } = useAsyncState( async (): Promise => { const response = await api.getModelFolders() - return response.map((folder) => ({ - name: formatDisplayName(folder.name), - value: folder.name - })) + return response + .map((folder) => ({ + name: formatDisplayName(folder.name), + value: folder.name + })) + .sort((a, b) => a.name.localeCompare(b.name)) }, [] as ModelTypeOption[], { diff --git a/src/platform/assets/composables/useUploadModelWizard.ts b/src/platform/assets/composables/useUploadModelWizard.ts index 73341c0f2..2d97efd9c 100644 --- a/src/platform/assets/composables/useUploadModelWizard.ts +++ b/src/platform/assets/composables/useUploadModelWizard.ts @@ -1,9 +1,15 @@ import type { Ref } from 'vue' import { computed, ref, watch } from 'vue' +import { useI18n } from 'vue-i18n' +import { useFeatureFlags } from '@/composables/useFeatureFlags' import { st } from '@/i18n' +import { civitaiImportSource } from '@/platform/assets/importSources/civitaiImportSource' +import { huggingfaceImportSource } from '@/platform/assets/importSources/huggingfaceImportSource' 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 { useAssetsStore } from '@/stores/assetsStore' import { useModelToNodeStore } from '@/stores/modelToNodeStore' @@ -21,8 +27,10 @@ interface ModelTypeOption { } export function useUploadModelWizard(modelTypes: Ref) { + const { t } = useI18n() const assetsStore = useAssetsStore() const modelToNodeStore = useModelToNodeStore() + const { flags } = useFeatureFlags() const currentStep = ref(1) const isFetchingMetadata = ref(false) const isUploading = ref(false) @@ -37,6 +45,20 @@ export function useUploadModelWizard(modelTypes: Ref) { const selectedModelType = ref() + // Available import sources + const importSources: ImportSource[] = flags.huggingfaceModelImportEnabled + ? [civitaiImportSource, huggingfaceImportSource] + : [civitaiImportSource] + + // Detected import source based on URL + const detectedSource = computed(() => { + const url = wizardData.value.url.trim() + if (!url) return null + return ( + importSources.find((source) => validateSourceUrl(url, source)) ?? null + ) + }) + // Clear error when URL changes watch( () => wizardData.value.url, @@ -54,15 +76,6 @@ export function useUploadModelWizard(modelTypes: Ref) { 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 @@ -75,17 +88,36 @@ export function useUploadModelWizard(modelTypes: Ref) { } wizardData.value.url = cleanedUrl - if (!isCivitaiUrl(wizardData.value.url)) { - uploadError.value = st( - 'assetBrowser.onlyCivitaiUrlsSupported', - 'Only Civitai URLs are supported' - ) + // Validate URL belongs to a supported import source + const source = detectedSource.value + if (!source) { + const supportedSources = importSources.map((s) => s.name).join(', ') + uploadError.value = t('assetBrowser.unsupportedUrlSource', { + sources: supportedSources + }) return } isFetchingMetadata.value = true try { const metadata = await assetService.getAssetMetadata(wizardData.value.url) + + // Decode URL-encoded filenames (e.g., Chinese characters) + if (metadata.filename) { + try { + metadata.filename = decodeURIComponent(metadata.filename) + } catch { + // Keep original if decoding fails + } + } + if (metadata.name) { + try { + metadata.name = decodeURIComponent(metadata.name) + } catch { + // Keep original if decoding fails + } + } + wizardData.value.metadata = metadata // Pre-fill name from metadata @@ -125,6 +157,14 @@ export function useUploadModelWizard(modelTypes: Ref) { async function uploadModel() { if (!canUploadModel.value) return + // 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') + return false + } + isUploading.value = true uploadStatus.value = 'uploading' @@ -170,7 +210,7 @@ export function useUploadModelWizard(modelTypes: Ref) { name: filename, tags, user_metadata: { - source: 'civitai', + source: source.type, source_url: wizardData.value.url, model_type: selectedModelType.value }, @@ -224,6 +264,7 @@ export function useUploadModelWizard(modelTypes: Ref) { // Computed canFetchMetadata, canUploadModel, + detectedSource, // Actions fetchMetadata, diff --git a/src/platform/assets/importSources/civitaiImportSource.ts b/src/platform/assets/importSources/civitaiImportSource.ts new file mode 100644 index 000000000..5ff324d00 --- /dev/null +++ b/src/platform/assets/importSources/civitaiImportSource.ts @@ -0,0 +1,10 @@ +import type { ImportSource } from '@/platform/assets/types/importSource' + +/** + * Civitai model import source configuration + */ +export const civitaiImportSource: ImportSource = { + type: 'civitai', + name: 'Civitai', + hostnames: ['civitai.com'] +} diff --git a/src/platform/assets/importSources/huggingfaceImportSource.ts b/src/platform/assets/importSources/huggingfaceImportSource.ts new file mode 100644 index 000000000..310e170af --- /dev/null +++ b/src/platform/assets/importSources/huggingfaceImportSource.ts @@ -0,0 +1,10 @@ +import type { ImportSource } from '@/platform/assets/types/importSource' + +/** + * Hugging Face model import source configuration + */ +export const huggingfaceImportSource: ImportSource = { + type: 'huggingface', + name: 'Hugging Face', + hostnames: ['huggingface.co'] +} diff --git a/src/platform/assets/types/importSource.ts b/src/platform/assets/types/importSource.ts new file mode 100644 index 000000000..12aa5e3db --- /dev/null +++ b/src/platform/assets/types/importSource.ts @@ -0,0 +1,24 @@ +/** + * Supported model import sources + */ +type ImportSourceType = 'civitai' | 'huggingface' + +/** + * Configuration for a model import source + */ +export interface ImportSource { + /** + * Unique identifier for this import source + */ + readonly type: ImportSourceType + + /** + * Display name for the source + */ + readonly name: string + + /** + * Hostname(s) that identify this source + */ + readonly hostnames: readonly string[] +} diff --git a/src/platform/assets/utils/importSourceUtil.ts b/src/platform/assets/utils/importSourceUtil.ts new file mode 100644 index 000000000..2628593cc --- /dev/null +++ b/src/platform/assets/utils/importSourceUtil.ts @@ -0,0 +1,15 @@ +import type { ImportSource } from '@/platform/assets/types/importSource' + +/** + * Check if a URL belongs to a specific import source + */ +export function validateSourceUrl(url: string, source: ImportSource): boolean { + try { + const hostname = new URL(url).hostname.toLowerCase() + return source.hostnames.some( + (h) => hostname === h || hostname.endsWith(`.${h}`) + ) + } catch { + return false + } +} diff --git a/src/platform/remoteConfig/types.ts b/src/platform/remoteConfig/types.ts index 1a4ef1261..cbca526bf 100644 --- a/src/platform/remoteConfig/types.ts +++ b/src/platform/remoteConfig/types.ts @@ -38,4 +38,5 @@ export type RemoteConfig = { asset_update_options_enabled?: boolean private_models_enabled?: boolean onboarding_survey_enabled?: boolean + huggingface_model_import_enabled?: boolean }