[backport cloud/1.35] feat: Add HuggingFace model import support (#7730)

## Summary
Backport of #7540 to cloud/1.35 branch.

- Adds HuggingFace as a model import source alongside CivitAI
- Implements extensible import source handler pattern
- UTF-8 filename decoding for international characters
- Alphabetically sorted model types
- Feature flag `huggingfaceModelImportEnabled` for gradual rollout

## Conflict Resolution
- `src/platform/remoteConfig/types.ts`: Kept both target branch's stripe
fields and added PR's huggingface field
- `src/platform/assets/components/UploadModelFooter.vue`: Adapted
`Button` component to `IconTextButton`/`TextButton` as the new Button
component is not available in this branch

Original PR: #7540

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7730-backport-cloud-1-35-feat-Add-HuggingFace-model-import-support-2d16d73d36508140a804f8b22883a696)
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
2025-12-22 15:27:59 -05:00
committed by GitHub
parent 5069a4a272
commit 783b8dcd8f
16 changed files with 381 additions and 55 deletions

View File

@@ -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.

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -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
)
)
}
})

View File

@@ -2216,8 +2216,11 @@
"baseModels": "Base models",
"browseAssets": "Browse Assets",
"checkpoints": "Checkpoints",
"civitaiLinkExample": "<strong>Example:</strong> <a href=\"https://civitai.com/models/10706/luisap-z-image-and-qwen-pixel-art-refiner?modelVersionId=2225295\" target=\"_blank\" class=\"text-muted-foreground\">https://civitai.com/models/10706/luisap-z-image-and-qwen-pixel-art-refiner?modelVersionId=2225295</a>",
"civitaiLinkLabel": "Civitai model <span class=\"font-bold italic\">download</span> 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",
@@ -2235,8 +2238,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",
@@ -2251,20 +2257,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",
@@ -2272,10 +2282,15 @@
"uploadingModel": "Importing model...",
"uploadModel": "Import",
"uploadModelDescription1": "Paste a Civitai model download link to add it to your library.",
"uploadModelDescription2": "Only links from <a href=\"https://civitai.com/models\" target=\"_blank\" class=\"text-muted-foreground\">https://civitai.com/models</a> are supported at the moment",
"uploadModelDescription3": "Max file size: <strong>1 GB</strong>",
"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!",

View File

@@ -4,7 +4,13 @@
>
<!-- Step 1: Enter URL -->
<UploadModelUrlInput
v-if="currentStep === 1"
v-if="currentStep === 1 && flags.huggingfaceModelImportEnabled"
v-model="wizardData.url"
:error="uploadError"
class="flex-1"
/>
<UploadModelUrlInputCivitai
v-else-if="currentStep === 1"
v-model="wizardData.url"
:error="uploadError"
/>
@@ -46,14 +52,17 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import UploadModelConfirmation from '@/platform/assets/components/UploadModelConfirmation.vue'
import UploadModelFooter from '@/platform/assets/components/UploadModelFooter.vue'
import UploadModelProgress from '@/platform/assets/components/UploadModelProgress.vue'
import UploadModelUrlInput from '@/platform/assets/components/UploadModelUrlInput.vue'
import UploadModelUrlInputCivitai from '@/platform/assets/components/UploadModelUrlInputCivitai.vue'
import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
import { useUploadModelWizard } from '@/platform/assets/composables/useUploadModelWizard'
import { useDialogStore } from '@/stores/dialogStore'
const { flags } = useFeatureFlags()
const dialogStore = useDialogStore()
const { modelTypes, fetchModelTypes } = useModelTypes()

View File

@@ -1,7 +1,11 @@
<template>
<div class="flex items-center gap-2 p-4 font-bold">
<img src="/assets/images/civitai.svg" class="size-4" />
<span>{{ $t('assetBrowser.uploadModelFromCivitai') }}</span>
<img
v-if="!flags.huggingfaceModelImportEnabled"
src="/assets/images/civitai.svg"
class="size-4"
/>
<span>{{ $t(titleKey) }}</span>
<span
class="rounded-full bg-white px-1.5 py-0 text-xxs font-inter font-semibold uppercase text-black"
>
@@ -9,3 +13,17 @@
</span>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
const { flags } = useFeatureFlags()
const titleKey = computed(() => {
return flags.huggingfaceModelImportEnabled
? 'assetBrowser.uploadModelGeneric'
: 'assetBrowser.uploadModelFromCivitai'
})
</script>

View File

@@ -1,13 +1,33 @@
<template>
<div class="flex justify-end gap-2 w-full">
<div
v-if="currentStep === 1 && flags.huggingfaceModelImportEnabled"
class="mr-auto flex items-center gap-2"
>
<i class="icon-[lucide--circle-question-mark] text-muted-foreground" />
<TextButton
:label="$t('assetBrowser.providerCivitai')"
type="transparent"
size="sm"
data-attr="upload-model-step1-help-civitai"
@click="showCivitaiHelp = true"
/>
<TextButton
:label="$t('assetBrowser.providerHuggingFace')"
type="transparent"
size="sm"
data-attr="upload-model-step1-help-huggingface"
@click="showHuggingFaceHelp = true"
/>
</div>
<IconTextButton
v-if="currentStep === 1"
v-else-if="currentStep === 1"
:label="$t('assetBrowser.uploadModelHowDoIFindThis')"
type="transparent"
size="md"
class="mr-auto underline text-muted-foreground"
data-attr="upload-model-step1-help-link"
@click="showVideoHelp = true"
@click="showCivitaiHelp = true"
>
<template #icon>
<i class="icon-[lucide--circle-question-mark]" />
@@ -74,10 +94,15 @@
@click="emit('close')"
/>
<VideoHelpDialog
v-model="showVideoHelp"
v-model="showCivitaiHelp"
video-url="https://media.comfy.org/compressed_768/civitai_howto.webm"
:aria-label="$t('assetBrowser.uploadModelHelpVideo')"
/>
<VideoHelpDialog
v-model="showHuggingFaceHelp"
video-url="https://media.comfy.org/byom/huggingfacehowto.mp4"
:aria-label="$t('assetBrowser.uploadModelHelpVideo')"
/>
</div>
</template>
@@ -86,9 +111,13 @@ import { ref } from 'vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import TextButton from '@/components/button/TextButton.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

View File

@@ -1,28 +1,74 @@
<template>
<div class="flex flex-col gap-6 text-sm text-muted-foreground">
<div class="flex flex-col gap-2">
<p class="m-0">
{{ $t('assetBrowser.uploadModelDescription1') }}
</p>
<ul class="list-disc space-y-1 pl-5 mt-0">
<li v-html="$t('assetBrowser.uploadModelDescription2')" />
<li v-html="$t('assetBrowser.uploadModelDescription3')" />
</ul>
<div class="flex flex-col justify-between h-full gap-6 text-sm">
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2">
<p class="m-0 text-foreground">
{{ $t('assetBrowser.uploadModelDescription1Generic') }}
</p>
<div class="m-0">
<p class="m-0 text-muted-foreground">
{{ $t('assetBrowser.uploadModelDescription2Generic') }}
</p>
<span class="inline-flex items-center gap-1 flex-wrap mt-2">
<span class="inline-flex items-center gap-1">
<img
:src="civitaiIcon"
:alt="$t('assetBrowser.providerCivitai')"
class="w-4 h-4"
/>
<a
:href="civitaiUrl"
target="_blank"
rel="noopener noreferrer"
class="text-muted underline"
>
{{ $t('assetBrowser.providerCivitai') }}</a
><span>,</span>
</span>
<span class="inline-flex items-center gap-1">
<img
:src="huggingFaceIcon"
:alt="$t('assetBrowser.providerHuggingFace')"
class="w-4 h-4"
/>
<a
:href="huggingFaceUrl"
target="_blank"
rel="noopener noreferrer"
class="text-muted underline"
>
{{ $t('assetBrowser.providerHuggingFace') }}
</a>
</span>
</span>
</div>
</div>
<div class="flex flex-col gap-2">
<InputText
v-model="url"
autofocus
:placeholder="$t('assetBrowser.genericLinkPlaceholder')"
class="w-full bg-secondary-background border-0 p-4"
data-attr="upload-model-step1-url-input"
/>
<p v-if="error" class="text-xs text-error">
{{ error }}
</p>
<p v-else class="text-foreground">
<i18n-t keypath="assetBrowser.maxFileSize" tag="span">
<template #size>
<span class="font-bold italic">{{
$t('assetBrowser.maxFileSizeValue')
}}</span>
</template>
</i18n-t>
</p>
</div>
</div>
<div class="flex flex-col gap-2">
<label class="mb-0" v-html="$t('assetBrowser.civitaiLinkLabel')"> </label>
<InputText
v-model="url"
autofocus
:placeholder="$t('assetBrowser.civitaiLinkPlaceholder')"
class="w-full bg-secondary-background border-0 p-4"
data-attr="upload-model-step1-url-input"
/>
<p v-if="error" class="text-xs text-error">
{{ error }}
</p>
<p v-else v-html="$t('assetBrowser.civitaiLinkExample')"></p>
<div class="text-sm text-muted">
{{ $t('assetBrowser.uploadModelHelpFooterText') }}
</div>
</div>
</template>
@@ -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'
</script>

View File

@@ -0,0 +1,82 @@
<template>
<div class="flex flex-col gap-6 text-sm text-muted-foreground">
<div class="flex flex-col gap-2">
<p class="m-0">
{{ $t('assetBrowser.uploadModelDescription1') }}
</p>
<ul class="list-disc space-y-1 pl-5 mt-0">
<li>
<i18n-t keypath="assetBrowser.uploadModelDescription2" tag="span">
<template #link>
<a
href="https://civitai.com/models"
target="_blank"
class="text-muted-foreground"
>
{{ $t('assetBrowser.uploadModelDescription2Link') }}
</a>
</template>
</i18n-t>
</li>
<li>
<i18n-t keypath="assetBrowser.uploadModelDescription3" tag="span">
<template #size>
<span class="font-bold italic">{{
$t('assetBrowser.maxFileSizeValue')
}}</span>
</template>
</i18n-t>
</li>
</ul>
</div>
<div class="flex flex-col gap-2">
<i18n-t keypath="assetBrowser.civitaiLinkLabel" tag="label" class="mb-0">
<template #download>
<span class="font-bold italic">{{
$t('assetBrowser.civitaiLinkLabelDownload')
}}</span>
</template>
</i18n-t>
<InputText
v-model="url"
autofocus
:placeholder="$t('assetBrowser.civitaiLinkPlaceholder')"
class="w-full bg-secondary-background border-0 p-4"
data-attr="upload-model-step1-url-input"
/>
<p v-if="error" class="text-xs text-error">
{{ error }}
</p>
<i18n-t
v-else
keypath="assetBrowser.civitaiLinkExample"
tag="p"
class="text-xs"
>
<template #example>
<strong>{{ $t('assetBrowser.civitaiLinkExampleStrong') }}</strong>
</template>
<template #link>
<a
href="https://civitai.com/models/10706/luisap-z-image-and-qwen-pixel-art-refiner?modelVersionId=2225295"
target="_blank"
class="text-muted-foreground"
>
{{ $t('assetBrowser.civitaiLinkExampleUrl') }}
</a>
</template>
</i18n-t>
</div>
</div>
</template>
<script setup lang="ts">
import InputText from 'primevue/inputtext'
defineProps<{
error?: string
}>()
const url = defineModel<string>({ required: true })
</script>

View File

@@ -50,10 +50,12 @@ export const useModelTypes = createSharedComposable(() => {
} = useAsyncState(
async (): Promise<ModelTypeOption[]> => {
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[],
{

View File

@@ -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<ModelTypeOption[]>) {
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<ModelTypeOption[]>) {
const selectedModelType = ref<string>()
// 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<ModelTypeOption[]>) {
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<ModelTypeOption[]>) {
}
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<ModelTypeOption[]>) {
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<ModelTypeOption[]>) {
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<ModelTypeOption[]>) {
// Computed
canFetchMetadata,
canUploadModel,
detectedSource,
// Actions
fetchMetadata,

View File

@@ -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']
}

View File

@@ -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']
}

View File

@@ -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[]
}

View File

@@ -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
}
}

View File

@@ -40,4 +40,5 @@ export type RemoteConfig = {
onboarding_survey_enabled?: boolean
stripe_publishable_key?: string
stripe_pricing_table_id?: string
huggingface_model_import_enabled?: boolean
}