[feat] Add HuggingFace model import support (#7540)

## 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 <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
Luke Mino-Altherr
2025-12-22 14:34:49 -05:00
committed by GitHub
parent 176c8e110b
commit 47884c623e
16 changed files with 383 additions and 55 deletions

View File

@@ -10,7 +10,7 @@ module.exports = defineConfig({
entryLocale: 'en', entryLocale: 'en',
output: 'src/locales', output: 'src/locales',
outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es', 'ar', 'tr', 'pt-BR'], 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'. 'latent' is the short form of 'latent space'.
'mask' is in the context of image processing. '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', MODEL_UPLOAD_BUTTON_ENABLED = 'model_upload_button_enabled',
ASSET_UPDATE_OPTIONS_ENABLED = 'asset_update_options_enabled', ASSET_UPDATE_OPTIONS_ENABLED = 'asset_update_options_enabled',
PRIVATE_MODELS_ENABLED = 'private_models_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 ?? remoteConfig.value.onboarding_survey_enabled ??
api.getServerFeature(ServerFeatureFlag.ONBOARDING_SURVEY_ENABLED, true) 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

@@ -2233,8 +2233,11 @@
"baseModels": "Base models", "baseModels": "Base models",
"browseAssets": "Browse Assets", "browseAssets": "Browse Assets",
"checkpoints": "Checkpoints", "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>", "civitaiLinkExample": "{example} {link}",
"civitaiLinkLabel": "Civitai model <span class=\"font-bold italic\">download</span> 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", "civitaiLinkPlaceholder": "Paste link here",
"confirmModelDetails": "Confirm Model Details", "confirmModelDetails": "Confirm Model Details",
"connectionError": "Please check your connection and try again", "connectionError": "Please check your connection and try again",
@@ -2252,8 +2255,11 @@
"filterBy": "Filter by", "filterBy": "Filter by",
"findInLibrary": "Find it in the {type} section of the models library.", "findInLibrary": "Find it in the {type} section of the models library.",
"finish": "Finish", "finish": "Finish",
"genericLinkPlaceholder": "Paste link here",
"jobId": "Job ID", "jobId": "Job ID",
"loadingModels": "Loading {type}...", "loadingModels": "Loading {type}...",
"maxFileSize": "Max file size: {size}",
"maxFileSizeValue": "1 GB",
"modelAssociatedWithLink": "The model associated with the link you provided:", "modelAssociatedWithLink": "The model associated with the link you provided:",
"modelName": "Model Name", "modelName": "Model Name",
"modelNamePlaceholder": "Enter a name for this model", "modelNamePlaceholder": "Enter a name for this model",
@@ -2268,20 +2274,24 @@
"ownershipAll": "All", "ownershipAll": "All",
"ownershipMyModels": "My models", "ownershipMyModels": "My models",
"ownershipPublicModels": "Public models", "ownershipPublicModels": "Public models",
"providerCivitai": "Civitai",
"providerHuggingFace": "Hugging Face",
"noValidSourceDetected": "No valid import source detected",
"selectFrameworks": "Select Frameworks", "selectFrameworks": "Select Frameworks",
"selectModelType": "Select model type", "selectModelType": "Select model type",
"selectProjects": "Select Projects", "selectProjects": "Select Projects",
"sortAZ": "A-Z", "sortAZ": "A-Z",
"sortBy": "Sort by", "sortBy": "Sort by",
"sortingType": "Sorting Type",
"sortPopular": "Popular", "sortPopular": "Popular",
"sortRecent": "Recent", "sortRecent": "Recent",
"sortZA": "Z-A", "sortZA": "Z-A",
"sortingType": "Sorting Type",
"tags": "Tags", "tags": "Tags",
"tagsHelp": "Separate tags with commas", "tagsHelp": "Separate tags with commas",
"tagsPlaceholder": "e.g., models, checkpoint", "tagsPlaceholder": "e.g., models, checkpoint",
"tryAdjustingFilters": "Try adjusting your search or filters", "tryAdjustingFilters": "Try adjusting your search or filters",
"unknown": "Unknown", "unknown": "Unknown",
"unsupportedUrlSource": "Only URLs from {sources} are supported",
"upgradeFeatureDescription": "This feature is only available with Creator or Pro plans.", "upgradeFeatureDescription": "This feature is only available with Creator or Pro plans.",
"upgradeToUnlockFeature": "Upgrade to unlock this feature", "upgradeToUnlockFeature": "Upgrade to unlock this feature",
"upload": "Import", "upload": "Import",
@@ -2289,10 +2299,15 @@
"uploadingModel": "Importing model...", "uploadingModel": "Importing model...",
"uploadModel": "Import", "uploadModel": "Import",
"uploadModelDescription1": "Paste a Civitai model download link to add it to your library.", "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", "uploadModelDescription1Generic": "Paste a model download link to add it to your library.",
"uploadModelDescription3": "Max file size: <strong>1 GB</strong>", "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.", "uploadModelFailedToRetrieveMetadata": "Failed to retrieve metadata. Please check the link and try again.",
"uploadModelFromCivitai": "Import a model from Civitai", "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", "uploadModelHelpVideo": "Upload Model Help Video",
"uploadModelHowDoIFindThis": "How do I find this?", "uploadModelHowDoIFindThis": "How do I find this?",
"uploadSuccess": "Model imported successfully!", "uploadSuccess": "Model imported successfully!",

View File

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

View File

@@ -1,7 +1,11 @@
<template> <template>
<div class="flex items-center gap-2 p-4 font-bold"> <div class="flex items-center gap-2 p-4 font-bold">
<img src="/assets/images/civitai.svg" class="size-4" /> <img
<span>{{ $t('assetBrowser.uploadModelFromCivitai') }}</span> v-if="!flags.huggingfaceModelImportEnabled"
src="/assets/images/civitai.svg"
class="size-4"
/>
<span>{{ $t(titleKey) }}</span>
<span <span
class="rounded-full bg-white px-1.5 py-0 text-xxs font-inter font-semibold uppercase text-black" class="rounded-full bg-white px-1.5 py-0 text-xxs font-inter font-semibold uppercase text-black"
> >
@@ -9,3 +13,17 @@
</span> </span>
</div> </div>
</template> </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,12 +1,34 @@
<template> <template>
<div class="flex justify-end gap-2 w-full"> <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" />
<Button <Button
v-if="currentStep === 1" variant="muted-textonly"
size="sm"
data-attr="upload-model-step1-help-civitai"
@click="showCivitaiHelp = true"
>
{{ $t('assetBrowser.providerCivitai') }}
</Button>
<Button
variant="muted-textonly"
size="sm"
data-attr="upload-model-step1-help-huggingface"
@click="showHuggingFaceHelp = true"
>
{{ $t('assetBrowser.providerHuggingFace') }}
</Button>
</div>
<Button
v-else-if="currentStep === 1"
variant="muted-textonly" variant="muted-textonly"
size="lg" size="lg"
class="mr-auto underline" class="mr-auto underline"
data-attr="upload-model-step1-help-link" data-attr="upload-model-step1-help-link"
@click="showVideoHelp = true" @click="showCivitaiHelp = true"
> >
<i class="icon-[lucide--circle-question-mark]" /> <i class="icon-[lucide--circle-question-mark]" />
<span>{{ $t('assetBrowser.uploadModelHowDoIFindThis') }}</span> <span>{{ $t('assetBrowser.uploadModelHowDoIFindThis') }}</span>
@@ -67,10 +89,15 @@
{{ $t('assetBrowser.finish') }} {{ $t('assetBrowser.finish') }}
</Button> </Button>
<VideoHelpDialog <VideoHelpDialog
v-model="showVideoHelp" v-model="showCivitaiHelp"
video-url="https://media.comfy.org/compressed_768/civitai_howto.webm" video-url="https://media.comfy.org/compressed_768/civitai_howto.webm"
:aria-label="$t('assetBrowser.uploadModelHelpVideo')" :aria-label="$t('assetBrowser.uploadModelHelpVideo')"
/> />
<VideoHelpDialog
v-model="showHuggingFaceHelp"
video-url="https://media.comfy.org/byom/huggingfacehowto.mp4"
:aria-label="$t('assetBrowser.uploadModelHelpVideo')"
/>
</div> </div>
</template> </template>
@@ -78,9 +105,13 @@
import { ref } from 'vue' import { ref } from 'vue'
import Button from '@/components/ui/button/Button.vue' import Button from '@/components/ui/button/Button.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import VideoHelpDialog from '@/platform/assets/components/VideoHelpDialog.vue' import VideoHelpDialog from '@/platform/assets/components/VideoHelpDialog.vue'
const showVideoHelp = ref(false) const { flags } = useFeatureFlags()
const showCivitaiHelp = ref(false)
const showHuggingFaceHelp = ref(false)
defineProps<{ defineProps<{
currentStep: number currentStep: number

View File

@@ -1,28 +1,74 @@
<template> <template>
<div class="flex flex-col gap-6 text-sm text-muted-foreground"> <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"> <div class="flex flex-col gap-2">
<p class="m-0"> <p class="m-0 text-foreground">
{{ $t('assetBrowser.uploadModelDescription1') }} {{ $t('assetBrowser.uploadModelDescription1Generic') }}
</p> </p>
<ul class="list-disc space-y-1 pl-5 mt-0"> <div class="m-0">
<li v-html="$t('assetBrowser.uploadModelDescription2')" /> <p class="m-0 text-muted-foreground">
<li v-html="$t('assetBrowser.uploadModelDescription3')" /> {{ $t('assetBrowser.uploadModelDescription2Generic') }}
</ul> </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>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<label class="mb-0" v-html="$t('assetBrowser.civitaiLinkLabel')"> </label>
<InputText <InputText
v-model="url" v-model="url"
autofocus autofocus
:placeholder="$t('assetBrowser.civitaiLinkPlaceholder')" :placeholder="$t('assetBrowser.genericLinkPlaceholder')"
class="w-full bg-secondary-background border-0 p-4" class="w-full bg-secondary-background border-0 p-4"
data-attr="upload-model-step1-url-input" data-attr="upload-model-step1-url-input"
/> />
<p v-if="error" class="text-xs text-error"> <p v-if="error" class="text-xs text-error">
{{ error }} {{ error }}
</p> </p>
<p v-else v-html="$t('assetBrowser.civitaiLinkExample')"></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="text-sm text-muted">
{{ $t('assetBrowser.uploadModelHelpFooterText') }}
</div> </div>
</div> </div>
</template> </template>
@@ -44,4 +90,9 @@ const url = computed({
get: () => props.modelValue, get: () => props.modelValue,
set: (value: string) => emit('update:modelValue', value) 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> </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( } = useAsyncState(
async (): Promise<ModelTypeOption[]> => { async (): Promise<ModelTypeOption[]> => {
const response = await api.getModelFolders() const response = await api.getModelFolders()
return response.map((folder) => ({ return response
.map((folder) => ({
name: formatDisplayName(folder.name), name: formatDisplayName(folder.name),
value: folder.name value: folder.name
})) }))
.sort((a, b) => a.name.localeCompare(b.name))
}, },
[] as ModelTypeOption[], [] as ModelTypeOption[],
{ {

View File

@@ -1,9 +1,15 @@
import type { Ref } from 'vue' import type { Ref } from 'vue'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { st } from '@/i18n' 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 type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
import { assetService } from '@/platform/assets/services/assetService' 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 { useAssetsStore } from '@/stores/assetsStore'
import { useModelToNodeStore } from '@/stores/modelToNodeStore' import { useModelToNodeStore } from '@/stores/modelToNodeStore'
@@ -21,8 +27,10 @@ interface ModelTypeOption {
} }
export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) { export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
const { t } = useI18n()
const assetsStore = useAssetsStore() const assetsStore = useAssetsStore()
const modelToNodeStore = useModelToNodeStore() const modelToNodeStore = useModelToNodeStore()
const { flags } = useFeatureFlags()
const currentStep = ref(1) const currentStep = ref(1)
const isFetchingMetadata = ref(false) const isFetchingMetadata = ref(false)
const isUploading = ref(false) const isUploading = ref(false)
@@ -37,6 +45,20 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
const selectedModelType = ref<string>() 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 // Clear error when URL changes
watch( watch(
() => wizardData.value.url, () => wizardData.value.url,
@@ -54,15 +76,6 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
return !!selectedModelType.value 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() { async function fetchMetadata() {
if (!canFetchMetadata.value) return if (!canFetchMetadata.value) return
@@ -75,17 +88,36 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
} }
wizardData.value.url = cleanedUrl wizardData.value.url = cleanedUrl
if (!isCivitaiUrl(wizardData.value.url)) { // Validate URL belongs to a supported import source
uploadError.value = st( const source = detectedSource.value
'assetBrowser.onlyCivitaiUrlsSupported', if (!source) {
'Only Civitai URLs are supported' const supportedSources = importSources.map((s) => s.name).join(', ')
) uploadError.value = t('assetBrowser.unsupportedUrlSource', {
sources: supportedSources
})
return return
} }
isFetchingMetadata.value = true isFetchingMetadata.value = true
try { try {
const metadata = await assetService.getAssetMetadata(wizardData.value.url) 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 wizardData.value.metadata = metadata
// Pre-fill name from metadata // Pre-fill name from metadata
@@ -125,6 +157,14 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
async function uploadModel() { async function uploadModel() {
if (!canUploadModel.value) return 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 isUploading.value = true
uploadStatus.value = 'uploading' uploadStatus.value = 'uploading'
@@ -170,7 +210,7 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
name: filename, name: filename,
tags, tags,
user_metadata: { user_metadata: {
source: 'civitai', source: source.type,
source_url: wizardData.value.url, source_url: wizardData.value.url,
model_type: selectedModelType.value model_type: selectedModelType.value
}, },
@@ -224,6 +264,7 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
// Computed // Computed
canFetchMetadata, canFetchMetadata,
canUploadModel, canUploadModel,
detectedSource,
// Actions // Actions
fetchMetadata, 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

@@ -38,4 +38,5 @@ export type RemoteConfig = {
asset_update_options_enabled?: boolean asset_update_options_enabled?: boolean
private_models_enabled?: boolean private_models_enabled?: boolean
onboarding_survey_enabled?: boolean onboarding_survey_enabled?: boolean
huggingface_model_import_enabled?: boolean
} }