diff --git a/src/components/common/UrlInput.vue b/src/components/common/UrlInput.vue index df40ba0c40..b0dfbd60f2 100644 --- a/src/components/common/UrlInput.vue +++ b/src/components/common/UrlInput.vue @@ -35,6 +35,7 @@ import { ValidationState } from '@/utils/validationUtil' const props = defineProps<{ modelValue: string validateUrlFn?: (url: string) => Promise + disableValidation?: boolean }>() const emit = defineEmits<{ @@ -101,6 +102,8 @@ const defaultValidateUrl = async (url: string): Promise => { } const validateUrl = async (value: string) => { + if (props.disableValidation) return + if (validationState.value === ValidationState.LOADING) return const url = cleanInput(value) diff --git a/src/locales/en/main.json b/src/locales/en/main.json index d9f04ca4cf..c0ea033ded 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -1984,6 +1984,32 @@ "noModelsInFolder": "No {type} available in this folder", "searchAssetsPlaceholder": "Type to search...", "uploadModel": "Upload model", + "uploadModelFromCivitai": "Upload a model from Civitai", + "uploadModelDescription1": "Paste a Civitai model download link to add it to your library.", + "uploadModelDescription2": "Only links from https://civitai.com are supported at the moment", + "uploadModelDescription3": "Max file size: 1 GB", + "civitaiLinkLabel": "Civitai model download link", + "civitaiLinkPlaceholder": "Paste link here", + "civitaiLinkExample": "Example: https://civitai.com/api/download/models/833921?type=Model&format=SafeTensor", + "confirmModelDetails": "Confirm Model Details", + "fileName": "File Name", + "fileSize": "File Size", + "modelName": "Model Name", + "modelNamePlaceholder": "Enter a name for this model", + "tags": "Tags", + "tagsPlaceholder": "e.g., models, checkpoint", + "tagsHelp": "Separate tags with commas", + "upload": "Upload", + "uploadingModel": "Uploading model...", + "uploadSuccess": "Model uploaded successfully!", + "uploadFailed": "Upload failed", + "modelAssociatedWithLink": "The model associated with the link you provided:", + "whatTypeOfModel": "What type of model is this?", + "selectModelType": "Select model type", + "notSureLeaveAsIs": "Not sure? Just leave this as is", + "modelUploaded": "Model uploaded!", + "findInLibrary": "Find it in the {type} section of the models library.", + "finish": "Finish", "allModels": "All Models", "allCategory": "All {category}", "unknown": "Unknown", diff --git a/src/platform/assets/components/AssetBrowserModal.vue b/src/platform/assets/components/AssetBrowserModal.vue index 2f0469e38d..0238b43d28 100644 --- a/src/platform/assets/components/AssetBrowserModal.vue +++ b/src/platform/assets/components/AssetBrowserModal.vue @@ -73,11 +73,14 @@ import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue' import { useFeatureFlags } from '@/composables/useFeatureFlags' import AssetFilterBar from '@/platform/assets/components/AssetFilterBar.vue' import AssetGrid from '@/platform/assets/components/AssetGrid.vue' +import UploadModelDialog from '@/platform/assets/components/UploadModelDialog.vue' +import UploadModelDialogHeader from '@/platform/assets/components/UploadModelDialogHeader.vue' import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser' import { useAssetBrowser } from '@/platform/assets/composables/useAssetBrowser' import type { AssetItem } from '@/platform/assets/schemas/assetSchema' import { assetService } from '@/platform/assets/services/assetService' import { formatCategoryLabel } from '@/platform/assets/utils/categoryLabel' +import { useDialogStore } from '@/stores/dialogStore' import { useModelToNodeStore } from '@/stores/modelToNodeStore' import { OnCloseKey } from '@/types/widgetTypes' @@ -92,6 +95,7 @@ const props = defineProps<{ }>() const { t } = useI18n() +const dialogStore = useDialogStore() const emit = defineEmits<{ 'asset-select': [asset: AssetDisplayItem] @@ -189,6 +193,15 @@ const { flags } = useFeatureFlags() const isUploadButtonEnabled = computed(() => flags.modelUploadButtonEnabled) function handleUploadClick() { - // Will be implemented in the future commit + dialogStore.showDialog({ + key: 'upload-model', + headerComponent: UploadModelDialogHeader, + component: UploadModelDialog, + props: { + onUploadSuccess: async () => { + await execute() + } + } + }) } diff --git a/src/platform/assets/components/UploadModelConfirmation.vue b/src/platform/assets/components/UploadModelConfirmation.vue new file mode 100644 index 0000000000..2471967f5a --- /dev/null +++ b/src/platform/assets/components/UploadModelConfirmation.vue @@ -0,0 +1,62 @@ + + + diff --git a/src/platform/assets/components/UploadModelDialog.vue b/src/platform/assets/components/UploadModelDialog.vue new file mode 100644 index 0000000000..0790bc972d --- /dev/null +++ b/src/platform/assets/components/UploadModelDialog.vue @@ -0,0 +1,224 @@ + + + + + diff --git a/src/platform/assets/components/UploadModelDialogHeader.vue b/src/platform/assets/components/UploadModelDialogHeader.vue new file mode 100644 index 0000000000..5476beb80f --- /dev/null +++ b/src/platform/assets/components/UploadModelDialogHeader.vue @@ -0,0 +1,12 @@ + + + diff --git a/src/platform/assets/components/UploadModelProgress.vue b/src/platform/assets/components/UploadModelProgress.vue new file mode 100644 index 0000000000..ba7e65b008 --- /dev/null +++ b/src/platform/assets/components/UploadModelProgress.vue @@ -0,0 +1,78 @@ + + + diff --git a/src/platform/assets/components/UploadModelUrlInput.vue b/src/platform/assets/components/UploadModelUrlInput.vue new file mode 100644 index 0000000000..17d681b084 --- /dev/null +++ b/src/platform/assets/components/UploadModelUrlInput.vue @@ -0,0 +1,46 @@ + + + diff --git a/src/platform/assets/composables/useModelTypes.ts b/src/platform/assets/composables/useModelTypes.ts new file mode 100644 index 0000000000..d180847f98 --- /dev/null +++ b/src/platform/assets/composables/useModelTypes.ts @@ -0,0 +1,94 @@ +import { ref } from 'vue' + +import { assetService } from '@/platform/assets/services/assetService' + +/** + * Format folder name to display name + * Converts "upscale_models" -> "Upscale Models" + * Converts "loras" -> "LoRAs" + */ +function formatDisplayName(folderName: string): string { + // Special cases for acronyms and proper nouns + const specialCases: Record = { + loras: 'LoRAs', + ipadapter: 'IP-Adapter', + sams: 'SAMs', + clip_vision: 'CLIP Vision', + animatediff_motion_lora: 'AnimateDiff Motion LoRA', + animatediff_models: 'AnimateDiff Models', + vae: 'VAE', + sam2: 'SAM 2', + controlnet: 'ControlNet', + gligen: 'GLIGEN' + } + + if (specialCases[folderName]) { + return specialCases[folderName] + } + + return folderName + .split('_') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' ') +} + +interface ModelTypeOption { + name: string // Display name + value: string // Actual tag value +} + +// Shared state across all instances +const modelTypes = ref([]) +const isLoading = ref(false) +const error = ref(null) +let fetchPromise: Promise | null = null + +/** + * Composable for fetching and managing model types from the API + * Uses shared state to ensure data is only fetched once + */ +export function useModelTypes() { + /** + * Fetch model types from the API (only fetches once, subsequent calls reuse the same promise) + */ + async function fetchModelTypes() { + // If already loaded, return immediately + if (modelTypes.value.length > 0) { + return + } + + // If currently loading, return the existing promise + if (fetchPromise) { + return fetchPromise + } + + isLoading.value = true + error.value = null + + fetchPromise = (async () => { + try { + const response = await assetService.getModelTypes() + modelTypes.value = response.map((folder) => ({ + name: formatDisplayName(folder.name), + value: folder.name + })) + } catch (err) { + error.value = + err instanceof Error ? err.message : 'Failed to fetch model types' + console.error('Failed to fetch model types:', err) + } finally { + isLoading.value = false + fetchPromise = null + } + })() + + return fetchPromise + } + + return { + modelTypes, + isLoading, + error, + fetchModelTypes + } +} diff --git a/src/platform/assets/services/assetService.ts b/src/platform/assets/services/assetService.ts index 85023b29ba..621b412843 100644 --- a/src/platform/assets/services/assetService.ts +++ b/src/platform/assets/services/assetService.ts @@ -249,6 +249,91 @@ function createAssetService() { } } + /** + * Retrieves metadata from a download URL without downloading the file + * + * @param url - Download URL to retrieve metadata from (will be URL-encoded) + * @returns Promise with metadata including content_length, final_url, filename, etc. + * @throws Error if metadata retrieval fails + */ + async function getAssetMetadata(url: string): Promise<{ + content_length: number + final_url: string + content_type?: string + filename?: string + name?: string + tags?: string[] + }> { + const encodedUrl = encodeURIComponent(url) + const res = await api.fetchApi( + `${ASSETS_ENDPOINT}/metadata?url=${encodedUrl}` + ) + + if (!res.ok) { + const errorText = await res.text().catch(() => 'Unknown error') + throw new Error( + `Failed to retrieve metadata: Server returned ${res.status}. ${errorText}` + ) + } + + return await res.json() + } + + /** + * Uploads an asset by providing a URL to download from + * + * @param params - Upload parameters + * @param params.url - HTTP/HTTPS URL to download from + * @param params.name - Display name (determines extension) + * @param params.tags - Optional freeform tags + * @param params.user_metadata - Optional custom metadata object + * @param params.preview_id - Optional UUID for preview asset + * @returns Promise - Asset object with created_new flag + * @throws Error if upload fails + */ + async function uploadAssetFromUrl(params: { + url: string + name: string + tags?: string[] + user_metadata?: Record + preview_id?: string + }): Promise { + const res = await api.fetchApi(ASSETS_ENDPOINT, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(params) + }) + + if (!res.ok) { + const errorText = await res.text().catch(() => 'Unknown error') + throw new Error( + `Failed to upload asset: Server returned ${res.status}. ${errorText}` + ) + } + + return await res.json() + } + + /** + * Gets available model types from the server + * + * @returns Promise - List of model types with their folder mappings + * @throws Error if request fails + */ + async function getModelTypes(): Promise { + const res = await api.fetchApi('/experiment/models') + + if (!res.ok) { + throw new Error( + `Failed to fetch model types: Server returned ${res.status}` + ) + } + + return await res.json() + } + return { getAssetModelFolders, getAssetModels, @@ -256,7 +341,10 @@ function createAssetService() { getAssetsForNodeType, getAssetDetails, getAssetsByTag, - deleteAsset + deleteAsset, + getAssetMetadata, + uploadAssetFromUrl, + getModelTypes } }