From 422d9a7b3ee0ce868c15ae7eead1744c8ed166c2 Mon Sep 17 00:00:00 2001 From: Luke Mino-Altherr Date: Wed, 5 Nov 2025 14:46:36 -0800 Subject: [PATCH 01/10] feat(assets): add Civitai model upload wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement complete model upload flow for importing models from Civitai URLs: - Add multi-step wizard dialog (URL input, metadata confirmation, upload progress) - Create UploadModelDialog with three-step workflow - Add UploadModelUrlInput, UploadModelConfirmation, and UploadModelProgress components - Implement assetService methods for metadata retrieval and URL-based uploads - Add useModelTypes composable for fetching and formatting model type options - Extend UrlInput component with optional validation bypass - Wire up "Upload model" button in AssetBrowserModal - Add 26 i18n strings for upload workflow UI - Display model metadata and allow type selection before upload - Show upload status with success/error states 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/components/common/UrlInput.vue | 3 + src/locales/en/main.json | 26 ++ .../assets/components/AssetBrowserModal.vue | 15 +- .../components/UploadModelConfirmation.vue | 62 +++++ .../assets/components/UploadModelDialog.vue | 224 ++++++++++++++++++ .../components/UploadModelDialogHeader.vue | 12 + .../assets/components/UploadModelProgress.vue | 78 ++++++ .../assets/components/UploadModelUrlInput.vue | 46 ++++ .../assets/composables/useModelTypes.ts | 94 ++++++++ src/platform/assets/services/assetService.ts | 90 ++++++- 10 files changed, 648 insertions(+), 2 deletions(-) create mode 100644 src/platform/assets/components/UploadModelConfirmation.vue create mode 100644 src/platform/assets/components/UploadModelDialog.vue create mode 100644 src/platform/assets/components/UploadModelDialogHeader.vue create mode 100644 src/platform/assets/components/UploadModelProgress.vue create mode 100644 src/platform/assets/components/UploadModelUrlInput.vue create mode 100644 src/platform/assets/composables/useModelTypes.ts 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 aef2470d2f..c9b9eb7413 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -2076,6 +2076,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 } } From 0f0fabaabec72db39561af8e919b5b79f39b4cd0 Mon Sep 17 00:00:00 2001 From: Luke Mino-Altherr Date: Thu, 13 Nov 2025 19:03:03 -0800 Subject: [PATCH 02/10] Validate input URL is from civitai --- .../assets/components/UploadModelDialog.vue | 34 +++++++++++++++++-- .../assets/components/UploadModelUrlInput.vue | 6 +++- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/platform/assets/components/UploadModelDialog.vue b/src/platform/assets/components/UploadModelDialog.vue index 0790bc972d..951077b6ae 100644 --- a/src/platform/assets/components/UploadModelDialog.vue +++ b/src/platform/assets/components/UploadModelDialog.vue @@ -1,7 +1,11 @@ diff --git a/src/platform/assets/schemas/assetSchema.ts b/src/platform/assets/schemas/assetSchema.ts index fc974a70a6..682b8e6bad 100644 --- a/src/platform/assets/schemas/assetSchema.ts +++ b/src/platform/assets/schemas/assetSchema.ts @@ -33,6 +33,17 @@ const zModelFile = z.object({ pathIndex: z.number() }) +// Asset metadata from download URL +const zAssetMetadata = z.object({ + content_length: z.number(), + final_url: z.string(), + content_type: z.string().optional(), + filename: z.string().optional(), + name: z.string().optional(), + tags: z.array(z.string()).optional(), + preview_url: z.string().optional() +}) + // Filename validation schema export const assetFilenameSchema = z .string() @@ -44,10 +55,12 @@ export const assetFilenameSchema = z // Export schemas following repository patterns export const assetItemSchema = zAsset export const assetResponseSchema = zAssetResponse +export const assetMetadataSchema = zAssetMetadata // Export types derived from Zod schemas export type AssetItem = z.infer export type AssetResponse = z.infer +export type AssetMetadata = z.infer export type ModelFolder = z.infer export type ModelFile = z.infer diff --git a/src/platform/assets/services/assetService.ts b/src/platform/assets/services/assetService.ts index 621b412843..e4839fc4b3 100644 --- a/src/platform/assets/services/assetService.ts +++ b/src/platform/assets/services/assetService.ts @@ -3,6 +3,7 @@ import { fromZodError } from 'zod-validation-error' import { assetResponseSchema } from '@/platform/assets/schemas/assetSchema' import type { AssetItem, + AssetMetadata, AssetResponse, ModelFile, ModelFolder @@ -256,14 +257,7 @@ function createAssetService() { * @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[] - }> { + async function getAssetMetadata(url: string): Promise { const encodedUrl = encodeURIComponent(url) const res = await api.fetchApi( `${ASSETS_ENDPOINT}/metadata?url=${encodedUrl}` From 78afe716db5d6a93a44fe7c6295456896e9a34b1 Mon Sep 17 00:00:00 2001 From: Luke Mino-Altherr Date: Thu, 13 Nov 2025 19:42:03 -0800 Subject: [PATCH 04/10] other PR code review fixes --- src/locales/en/main.json | 2 +- .../assets/components/UploadModelConfirmation.vue | 9 +++++++-- src/platform/assets/components/UploadModelProgress.vue | 4 ++-- src/platform/assets/schemas/assetSchema.ts | 1 - 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/locales/en/main.json b/src/locales/en/main.json index fc93c4c4d2..2c5b816322 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -2101,7 +2101,7 @@ "modelTypeSelectorPlaceholder": "Select model type", "selectModelType": "Select model type", "notSureLeaveAsIs": "Not sure? Just leave this as is", - "modelUploaded": "Model uploaded!", + "modelUploaded": "Model uploaded! 🎉", "findInLibrary": "Find it in the {type} section of the models library.", "finish": "Finish", "allModels": "All Models", diff --git a/src/platform/assets/components/UploadModelConfirmation.vue b/src/platform/assets/components/UploadModelConfirmation.vue index 2d085b1c3a..27185262ec 100644 --- a/src/platform/assets/components/UploadModelConfirmation.vue +++ b/src/platform/assets/components/UploadModelConfirmation.vue @@ -17,8 +17,13 @@
@@ -44,7 +49,7 @@ const emit = defineEmits<{ 'update:modelValue': [value: string | undefined] }>() -const { modelTypes } = useModelTypes() +const { modelTypes, isLoading } = useModelTypes() const selectedModelType = computed({ get: () => props.modelValue ?? null, diff --git a/src/platform/assets/components/UploadModelProgress.vue b/src/platform/assets/components/UploadModelProgress.vue index c68ea2c0c1..30879b8e8e 100644 --- a/src/platform/assets/components/UploadModelProgress.vue +++ b/src/platform/assets/components/UploadModelProgress.vue @@ -19,7 +19,7 @@

- {{ $t('assetBrowser.modelUploaded') }} 🎉 + {{ $t('assetBrowser.modelUploaded') }}

{{ $t('assetBrowser.findInLibrary', { type: modelType }) }} @@ -45,7 +45,7 @@ v-else-if="status === 'error'" class="flex flex-1 flex-col items-center justify-center gap-6" > - +

{{ $t('assetBrowser.uploadFailed') }} diff --git a/src/platform/assets/schemas/assetSchema.ts b/src/platform/assets/schemas/assetSchema.ts index 682b8e6bad..cc0aa25fba 100644 --- a/src/platform/assets/schemas/assetSchema.ts +++ b/src/platform/assets/schemas/assetSchema.ts @@ -55,7 +55,6 @@ export const assetFilenameSchema = z // Export schemas following repository patterns export const assetItemSchema = zAsset export const assetResponseSchema = zAssetResponse -export const assetMetadataSchema = zAssetMetadata // Export types derived from Zod schemas export type AssetItem = z.infer From 73ecd06d215d379477763f8fedf44421d9174567 Mon Sep 17 00:00:00 2001 From: Luke Mino-Altherr Date: Wed, 19 Nov 2025 14:30:47 -0800 Subject: [PATCH 05/10] refactor(assets): use createSharedComposable for useModelTypes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrap composable with createSharedComposable from VueUse to: - Fix HMR compatibility (state no longer lost on hot reload) - Remove import side effects from module-scoped variables - Create state lazily on first use while maintaining shared singleton behavior 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../assets/composables/useModelTypes.ts | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/platform/assets/composables/useModelTypes.ts b/src/platform/assets/composables/useModelTypes.ts index d180847f98..e0e020bb53 100644 --- a/src/platform/assets/composables/useModelTypes.ts +++ b/src/platform/assets/composables/useModelTypes.ts @@ -1,6 +1,7 @@ import { ref } from 'vue' +import { createSharedComposable } from '@vueuse/core' -import { assetService } from '@/platform/assets/services/assetService' +import { api } from '@/scripts/api' /** * Format folder name to display name @@ -37,17 +38,16 @@ interface ModelTypeOption { 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() { +export const useModelTypes = createSharedComposable(() => { + const modelTypes = ref([]) + const isLoading = ref(false) + const error = ref(null) + let fetchPromise: Promise | null = null + /** * Fetch model types from the API (only fetches once, subsequent calls reuse the same promise) */ @@ -67,7 +67,7 @@ export function useModelTypes() { fetchPromise = (async () => { try { - const response = await assetService.getModelTypes() + const response = await api.getModelFolders() modelTypes.value = response.map((folder) => ({ name: formatDisplayName(folder.name), value: folder.name @@ -91,4 +91,4 @@ export function useModelTypes() { error, fetchModelTypes } -} +}) From 15d50930036ee535c12332d3879524f6cf5eb655 Mon Sep 17 00:00:00 2001 From: Luke Mino-Altherr Date: Wed, 19 Nov 2025 14:31:52 -0800 Subject: [PATCH 06/10] refactor: remove redunant assetService function --- .../assets/components/UploadModelDialog.vue | 10 +++++++- src/platform/assets/schemas/assetSchema.ts | 15 +++++++++++- src/platform/assets/services/assetService.ts | 23 ++----------------- 3 files changed, 25 insertions(+), 23 deletions(-) diff --git a/src/platform/assets/components/UploadModelDialog.vue b/src/platform/assets/components/UploadModelDialog.vue index edab4429c4..810cd49568 100644 --- a/src/platform/assets/components/UploadModelDialog.vue +++ b/src/platform/assets/components/UploadModelDialog.vue @@ -249,7 +249,15 @@ onMounted(() => { diff --git a/src/platform/assets/schemas/assetSchema.ts b/src/platform/assets/schemas/assetSchema.ts index cc0aa25fba..5bd9ec603e 100644 --- a/src/platform/assets/schemas/assetSchema.ts +++ b/src/platform/assets/schemas/assetSchema.ts @@ -34,6 +34,18 @@ const zModelFile = z.object({ }) // Asset metadata from download URL +const zValidationError = z.object({ + code: z.string(), + message: z.string(), + field: z.string() +}) + +const zValidationResult = z.object({ + is_valid: z.boolean(), + errors: z.array(zValidationError).optional(), + warnings: z.array(zValidationError).optional() +}) + const zAssetMetadata = z.object({ content_length: z.number(), final_url: z.string(), @@ -41,7 +53,8 @@ const zAssetMetadata = z.object({ filename: z.string().optional(), name: z.string().optional(), tags: z.array(z.string()).optional(), - preview_url: z.string().optional() + preview_url: z.string().optional(), + validation: zValidationResult.optional() }) // Filename validation schema diff --git a/src/platform/assets/services/assetService.ts b/src/platform/assets/services/assetService.ts index e4839fc4b3..5ed2de2721 100644 --- a/src/platform/assets/services/assetService.ts +++ b/src/platform/assets/services/assetService.ts @@ -260,7 +260,7 @@ function createAssetService() { async function getAssetMetadata(url: string): Promise { const encodedUrl = encodeURIComponent(url) const res = await api.fetchApi( - `${ASSETS_ENDPOINT}/metadata?url=${encodedUrl}` + `${ASSETS_ENDPOINT}/remote-metadata?url=${encodedUrl}` ) if (!res.ok) { @@ -310,24 +310,6 @@ function createAssetService() { 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, @@ -337,8 +319,7 @@ function createAssetService() { getAssetsByTag, deleteAsset, getAssetMetadata, - uploadAssetFromUrl, - getModelTypes + uploadAssetFromUrl } } From e83b92beace8761f282ee297034cacdbf2fd0ea5 Mon Sep 17 00:00:00 2001 From: Luke Mino-Altherr Date: Wed, 19 Nov 2025 15:05:06 -0800 Subject: [PATCH 07/10] refactor(assets): extract footer component and add localized error messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract navigation footer from UploadModelDialog into UploadModelFooter component. Add localized error messages for CivitAI validation error codes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/locales/en/main.json | 6 ++ .../assets/components/UploadModelDialog.vue | 64 ++++------------- .../assets/components/UploadModelFooter.vue | 69 +++++++++++++++++++ src/platform/assets/services/assetService.ts | 46 ++++++++++++- 4 files changed, 131 insertions(+), 54 deletions(-) create mode 100644 src/platform/assets/components/UploadModelFooter.vue diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 2c5b816322..ed379fb671 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -2115,6 +2115,12 @@ "sortZA": "Z-A", "sortRecent": "Recent", "sortPopular": "Popular", + "errorFileTooLarge": "File exceeds the maximum allowed size limit", + "errorFormatNotAllowed": "Only SafeTensor format is allowed", + "errorUnsafePickleScan": "CivitAI detected potentially unsafe code in this file", + "errorUnsafeVirusScan": "CivitAI detected malware or suspicious content in this file", + "errorModelTypeNotSupported": "This model type is not supported", + "errorUnknown": "An unexpected error occurred", "ariaLabel": { "assetCard": "{name} - {type} asset", "loadingAsset": "Loading asset" diff --git a/src/platform/assets/components/UploadModelDialog.vue b/src/platform/assets/components/UploadModelDialog.vue index 810cd49568..8d1a5e3013 100644 --- a/src/platform/assets/components/UploadModelDialog.vue +++ b/src/platform/assets/components/UploadModelDialog.vue @@ -24,65 +24,27 @@ /> -

- - - - - - - - - - -
+
diff --git a/src/platform/assets/services/assetService.ts b/src/platform/assets/services/assetService.ts index 5ed2de2721..b2abf5b5f6 100644 --- a/src/platform/assets/services/assetService.ts +++ b/src/platform/assets/services/assetService.ts @@ -1,5 +1,6 @@ import { fromZodError } from 'zod-validation-error' +import { st } from '@/i18n' import { assetResponseSchema } from '@/platform/assets/schemas/assetSchema' import type { AssetItem, @@ -11,6 +12,36 @@ import type { import { api } from '@/scripts/api' import { useModelToNodeStore } from '@/stores/modelToNodeStore' +/** + * Maps CivitAI validation error codes to localized error messages + */ +function getLocalizedErrorMessage(errorCode: string): string { + const errorMessages: Record = { + FILE_TOO_LARGE: st('assetBrowser.errorFileTooLarge', 'File too large'), + FORMAT_NOT_ALLOWED: st( + 'assetBrowser.errorFormatNotAllowed', + 'Format not allowed' + ), + UNSAFE_PICKLE_SCAN: st( + 'assetBrowser.errorUnsafePickleScan', + 'Unsafe pickle scan' + ), + UNSAFE_VIRUS_SCAN: st( + 'assetBrowser.errorUnsafeVirusScan', + 'Unsafe virus scan' + ), + MODEL_TYPE_NOT_SUPPORTED: st( + 'assetBrowser.errorModelTypeNotSupported', + 'Model type not supported' + ) + } + return ( + errorMessages[errorCode] || + st('assetBrowser.errorUnknown', 'Unknown error') || + 'Unknown error' + ) +} + const ASSETS_ENDPOINT = '/assets' const EXPERIMENTAL_WARNING = `EXPERIMENTAL: If you are seeing this please make sure "Comfy.Assets.UseAssetAPI" is set to "false" in your ComfyUI Settings.\n` const DEFAULT_LIMIT = 500 @@ -264,13 +295,22 @@ function createAssetService() { ) if (!res.ok) { - const errorText = await res.text().catch(() => 'Unknown error') + const errorData = await res.json().catch(() => ({})) throw new Error( - `Failed to retrieve metadata: Server returned ${res.status}. ${errorText}` + getLocalizedErrorMessage(errorData.code || 'UNKNOWN_ERROR') ) } - return await res.json() + const data: AssetMetadata = await res.json() + if (data.validation?.is_valid === false) { + throw new Error( + getLocalizedErrorMessage( + data.validation?.errors?.[0]?.code || 'UNKNOWN_ERROR' + ) + ) + } + + return data } /** From 624c63ab682a4f452bb72c271e84b528d4390fc6 Mon Sep 17 00:00:00 2001 From: Luke Mino-Altherr Date: Wed, 19 Nov 2025 15:55:34 -0800 Subject: [PATCH 08/10] fix(assets): normalize hostname to lowercase for URL validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/platform/assets/components/UploadModelDialog.vue | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/platform/assets/components/UploadModelDialog.vue b/src/platform/assets/components/UploadModelDialog.vue index 8d1a5e3013..c83bc2d5e7 100644 --- a/src/platform/assets/components/UploadModelDialog.vue +++ b/src/platform/assets/components/UploadModelDialog.vue @@ -103,11 +103,8 @@ async function handleFetchMetadata() { // Validate that URL is from Civitai domain const isCivitaiUrl = (url: string): boolean => { try { - const urlObj = new URL(url) - return ( - urlObj.hostname === 'civitai.com' || - urlObj.hostname.endsWith('.civitai.com') - ) + const hostname = new URL(url).hostname.toLowerCase() + return hostname === 'civitai.com' || hostname.endsWith('.civitai.com') } catch { return false } From f5564fde9b497d10e83e7aee2a346a50e3503fac Mon Sep 17 00:00:00 2001 From: Luke Mino-Altherr Date: Wed, 19 Nov 2025 16:00:34 -0800 Subject: [PATCH 09/10] Add localized error message to upload --- src/locales/en/main.json | 1 + src/platform/assets/services/assetService.ts | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/locales/en/main.json b/src/locales/en/main.json index ed379fb671..9e65c4875d 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -2121,6 +2121,7 @@ "errorUnsafeVirusScan": "CivitAI detected malware or suspicious content in this file", "errorModelTypeNotSupported": "This model type is not supported", "errorUnknown": "An unexpected error occurred", + "errorUploadFailed": "Failed to upload asset. Please try again.", "ariaLabel": { "assetCard": "{name} - {type} asset", "loadingAsset": "Loading asset" diff --git a/src/platform/assets/services/assetService.ts b/src/platform/assets/services/assetService.ts index b2abf5b5f6..379b193dfb 100644 --- a/src/platform/assets/services/assetService.ts +++ b/src/platform/assets/services/assetService.ts @@ -341,9 +341,11 @@ function createAssetService() { }) if (!res.ok) { - const errorText = await res.text().catch(() => 'Unknown error') throw new Error( - `Failed to upload asset: Server returned ${res.status}. ${errorText}` + st( + 'assetBrowser.errorUploadFailed', + 'Failed to upload asset. Please try again.' + ) ) } From 803beb28ce0e2ce84266f0e6965cb91752348bd8 Mon Sep 17 00:00:00 2001 From: Luke Mino-Altherr Date: Wed, 19 Nov 2025 20:11:09 -0800 Subject: [PATCH 10/10] code review comments --- src/locales/en/main.json | 1 + .../assets/components/UploadModelDialog.vue | 162 +++------------- .../assets/components/UploadModelFooter.vue | 19 +- .../assets/components/UploadModelProgress.vue | 4 +- .../assets/composables/useModelTypes.ts | 61 ++---- .../composables/useUploadModelWizard.ts | 175 ++++++++++++++++++ src/platform/assets/schemas/assetSchema.ts | 1 - 7 files changed, 232 insertions(+), 191 deletions(-) create mode 100644 src/platform/assets/composables/useUploadModelWizard.ts diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 9e65c4875d..27e42b3ed6 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -2078,6 +2078,7 @@ "uploadModel": "Upload model", "uploadModelFromCivitai": "Upload a model from Civitai", "uploadModelFailedToRetrieveMetadata": "Failed to retrieve metadata. Please check the link and try again.", + "onlyCivitaiUrlsSupported": "Only Civitai URLs are supported", "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", diff --git a/src/platform/assets/components/UploadModelDialog.vue b/src/platform/assets/components/UploadModelDialog.vue index c83bc2d5e7..4ec0e2eaac 100644 --- a/src/platform/assets/components/UploadModelDialog.vue +++ b/src/platform/assets/components/UploadModelDialog.vue @@ -31,169 +31,55 @@ :can-fetch-metadata="canFetchMetadata" :can-upload-model="canUploadModel" :upload-status="uploadStatus" - :on-back="goToPreviousStep" - :on-fetch-metadata="handleFetchMetadata" - :on-upload="handleUploadModel" - :on-close="handleClose" + @back="goToPreviousStep" + @fetch-metadata="handleFetchMetadata" + @upload="handleUploadModel" + @close="handleClose" />
diff --git a/src/platform/assets/components/UploadModelProgress.vue b/src/platform/assets/components/UploadModelProgress.vue index 30879b8e8e..eab447873d 100644 --- a/src/platform/assets/components/UploadModelProgress.vue +++ b/src/platform/assets/components/UploadModelProgress.vue @@ -26,9 +26,7 @@

-
+

{{ metadata?.name || metadata?.filename }} diff --git a/src/platform/assets/composables/useModelTypes.ts b/src/platform/assets/composables/useModelTypes.ts index e0e020bb53..e1c0e6b720 100644 --- a/src/platform/assets/composables/useModelTypes.ts +++ b/src/platform/assets/composables/useModelTypes.ts @@ -1,5 +1,4 @@ -import { ref } from 'vue' -import { createSharedComposable } from '@vueuse/core' +import { createSharedComposable, useAsyncState } from '@vueuse/core' import { api } from '@/scripts/api' @@ -43,47 +42,27 @@ interface ModelTypeOption { * Uses shared state to ensure data is only fetched once */ export const useModelTypes = createSharedComposable(() => { - const modelTypes = ref([]) - const isLoading = ref(false) - const error = ref(null) - let fetchPromise: Promise | null = null - - /** - * 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 api.getModelFolders() - 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' + const { + state: modelTypes, + isLoading, + error, + execute: fetchModelTypes + } = useAsyncState( + async (): Promise => { + const response = await api.getModelFolders() + return response.map((folder) => ({ + name: formatDisplayName(folder.name), + value: folder.name + })) + }, + [] as ModelTypeOption[], + { + immediate: false, + onError: (err) => { console.error('Failed to fetch model types:', err) - } finally { - isLoading.value = false - fetchPromise = null } - })() - - return fetchPromise - } + } + ) return { modelTypes, diff --git a/src/platform/assets/composables/useUploadModelWizard.ts b/src/platform/assets/composables/useUploadModelWizard.ts new file mode 100644 index 0000000000..4719d1d540 --- /dev/null +++ b/src/platform/assets/composables/useUploadModelWizard.ts @@ -0,0 +1,175 @@ +import type { Ref } from 'vue' +import { computed, ref, watch } from 'vue' + +import { st } from '@/i18n' +import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema' +import { assetService } from '@/platform/assets/services/assetService' + +interface WizardData { + url: string + metadata: AssetMetadata | null + name: string + tags: string[] +} + +interface ModelTypeOption { + name: string + value: string +} + +export function useUploadModelWizard(modelTypes: Ref) { + const currentStep = ref(1) + const isFetchingMetadata = ref(false) + const isUploading = ref(false) + const uploadStatus = ref<'idle' | 'uploading' | 'success' | 'error'>('idle') + const uploadError = ref('') + + const wizardData = ref({ + url: '', + metadata: null, + name: '', + tags: [] + }) + + const selectedModelType = ref(undefined) + + // Clear error when URL changes + watch( + () => wizardData.value.url, + () => { + uploadError.value = '' + } + ) + + // Validation + const canFetchMetadata = computed(() => { + return wizardData.value.url.trim().length > 0 + }) + + const canUploadModel = computed(() => { + 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 + + if (!isCivitaiUrl(wizardData.value.url)) { + uploadError.value = st( + 'assetBrowser.onlyCivitaiUrlsSupported', + 'Only Civitai URLs are supported' + ) + return + } + + isFetchingMetadata.value = true + try { + const metadata = await assetService.getAssetMetadata(wizardData.value.url) + wizardData.value.metadata = metadata + + // Pre-fill name from metadata + wizardData.value.name = metadata.filename || metadata.name || '' + + // Pre-fill model type from metadata tags if available + if (metadata.tags && metadata.tags.length > 0) { + wizardData.value.tags = metadata.tags + // Try to detect model type from tags + const typeTag = metadata.tags.find((tag) => + modelTypes.value.some((type) => type.value === tag) + ) + if (typeTag) { + selectedModelType.value = typeTag + } + } + + currentStep.value = 2 + } catch (error) { + console.error('Failed to retrieve metadata:', error) + uploadError.value = + error instanceof Error + ? error.message + : st( + 'assetBrowser.uploadModelFailedToRetrieveMetadata', + 'Failed to retrieve metadata. Please check the link and try again.' + ) + currentStep.value = 1 + } finally { + isFetchingMetadata.value = false + } + } + + async function uploadModel() { + if (!canUploadModel.value) return + + isUploading.value = true + uploadStatus.value = 'uploading' + + try { + const tags = selectedModelType.value + ? ['models', selectedModelType.value] + : ['models'] + const filename = + wizardData.value.metadata?.filename || + wizardData.value.metadata?.name || + 'model' + + await assetService.uploadAssetFromUrl({ + url: wizardData.value.url, + name: filename, + tags, + user_metadata: { + source: 'civitai', + source_url: wizardData.value.url, + model_type: selectedModelType.value + } + }) + + uploadStatus.value = 'success' + currentStep.value = 3 + return true + } catch (error) { + console.error('Failed to upload asset:', error) + uploadStatus.value = 'error' + uploadError.value = + error instanceof Error ? error.message : 'Failed to upload model' + currentStep.value = 3 + return false + } finally { + isUploading.value = false + } + } + + function goToPreviousStep() { + if (currentStep.value > 1) { + currentStep.value = currentStep.value - 1 + } + } + + return { + // State + currentStep, + isFetchingMetadata, + isUploading, + uploadStatus, + uploadError, + wizardData, + selectedModelType, + + // Computed + canFetchMetadata, + canUploadModel, + + // Actions + fetchMetadata, + uploadModel, + goToPreviousStep + } +} diff --git a/src/platform/assets/schemas/assetSchema.ts b/src/platform/assets/schemas/assetSchema.ts index 5bd9ec603e..48d5275516 100644 --- a/src/platform/assets/schemas/assetSchema.ts +++ b/src/platform/assets/schemas/assetSchema.ts @@ -33,7 +33,6 @@ const zModelFile = z.object({ pathIndex: z.number() }) -// Asset metadata from download URL const zValidationError = z.object({ code: z.string(), message: z.string(),