mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-01 03:31:58 +00:00
[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:
committed by
GitHub
parent
176c8e110b
commit
47884c623e
@@ -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.
|
||||||
|
|
||||||
|
|||||||
8
public/assets/images/hf-logo.svg
Normal file
8
public/assets/images/hf-logo.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 34 KiB |
@@ -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
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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!",
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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[],
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
10
src/platform/assets/importSources/civitaiImportSource.ts
Normal file
10
src/platform/assets/importSources/civitaiImportSource.ts
Normal 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']
|
||||||
|
}
|
||||||
10
src/platform/assets/importSources/huggingfaceImportSource.ts
Normal file
10
src/platform/assets/importSources/huggingfaceImportSource.ts
Normal 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']
|
||||||
|
}
|
||||||
24
src/platform/assets/types/importSource.ts
Normal file
24
src/platform/assets/types/importSource.ts
Normal 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[]
|
||||||
|
}
|
||||||
15
src/platform/assets/utils/importSourceUtil.ts
Normal file
15
src/platform/assets/utils/importSourceUtil.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user