[backport cloud/1.32] [feat] Add Civitai model upload wizard (#6772)

Backport of #6694 to `cloud/1.32`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6772-backport-cloud-1-32-feat-Add-Civitai-model-upload-wizard-2b16d73d3650814dbeccc12c11427050)
by [Unito](https://www.unito.io)

Co-authored-by: Luke Mino-Altherr <luke@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Comfy Org PR Bot
2025-11-21 08:51:19 +09:00
committed by GitHub
parent c4f7686575
commit b2f2144d51
13 changed files with 799 additions and 2 deletions

View File

@@ -35,6 +35,7 @@ import { ValidationState } from '@/utils/validationUtil'
const props = defineProps<{
modelValue: string
validateUrlFn?: (url: string) => Promise<boolean>
disableValidation?: boolean
}>()
const emit = defineEmits<{
@@ -101,6 +102,8 @@ const defaultValidateUrl = async (url: string): Promise<boolean> => {
}
const validateUrl = async (value: string) => {
if (props.disableValidation) return
if (validationState.value === ValidationState.LOADING) return
const url = cleanInput(value)

View File

@@ -2076,6 +2076,35 @@
"noModelsInFolder": "No {type} available in this folder",
"searchAssetsPlaceholder": "Type to search...",
"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",
"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:",
"modelTypeSelectorLabel": "What type of model is this?",
"modelTypeSelectorPlaceholder": "Select model type",
"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",
@@ -2087,6 +2116,13 @@
"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",
"errorUploadFailed": "Failed to upload asset. Please try again.",
"ariaLabel": {
"assetCard": "{name} - {type} asset",
"loadingAsset": "Loading asset"

View File

@@ -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()
}
}
})
}
</script>

View File

@@ -0,0 +1,58 @@
<template>
<div class="flex flex-col gap-4">
<!-- Model Info Section -->
<div class="flex flex-col gap-2">
<p class="text-sm text-muted m-0">
{{ $t('assetBrowser.modelAssociatedWithLink') }}
</p>
<p class="text-sm mt-0">
{{ metadata?.name || metadata?.filename }}
</p>
</div>
<!-- Model Type Selection -->
<div class="flex flex-col gap-2">
<label class="text-sm text-muted">
{{ $t('assetBrowser.modelTypeSelectorLabel') }}
</label>
<SingleSelect
v-model="selectedModelType"
:label="
isLoading
? $t('g.loading')
: $t('assetBrowser.modelTypeSelectorPlaceholder')
"
:options="modelTypes"
:disabled="isLoading"
/>
<div class="flex items-center gap-2 text-sm text-muted">
<i class="icon-[lucide--info]" />
<span>{{ $t('assetBrowser.notSureLeaveAsIs') }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import SingleSelect from '@/components/input/SingleSelect.vue'
import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
const props = defineProps<{
modelValue: string | undefined
metadata: AssetMetadata | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: string | undefined]
}>()
const { modelTypes, isLoading } = useModelTypes()
const selectedModelType = computed({
get: () => props.modelValue ?? null,
set: (value: string | null) => emit('update:modelValue', value ?? undefined)
})
</script>

View File

@@ -0,0 +1,108 @@
<template>
<div class="upload-model-dialog flex flex-col justify-between gap-6 p-4 pt-6">
<!-- Step 1: Enter URL -->
<UploadModelUrlInput
v-if="currentStep === 1"
v-model="wizardData.url"
:error="uploadError"
/>
<!-- Step 2: Confirm Metadata -->
<UploadModelConfirmation
v-else-if="currentStep === 2"
v-model="selectedModelType"
:metadata="wizardData.metadata"
/>
<!-- Step 3: Upload Progress -->
<UploadModelProgress
v-else-if="currentStep === 3"
:status="uploadStatus"
:error="uploadError"
:metadata="wizardData.metadata"
:model-type="selectedModelType"
/>
<!-- Navigation Footer -->
<UploadModelFooter
:current-step="currentStep"
:is-fetching-metadata="isFetchingMetadata"
:is-uploading="isUploading"
:can-fetch-metadata="canFetchMetadata"
:can-upload-model="canUploadModel"
:upload-status="uploadStatus"
@back="goToPreviousStep"
@fetch-metadata="handleFetchMetadata"
@upload="handleUploadModel"
@close="handleClose"
/>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import UploadModelConfirmation from '@/platform/assets/components/UploadModelConfirmation.vue'
import UploadModelFooter from '@/platform/assets/components/UploadModelFooter.vue'
import UploadModelProgress from '@/platform/assets/components/UploadModelProgress.vue'
import UploadModelUrlInput from '@/platform/assets/components/UploadModelUrlInput.vue'
import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
import { useUploadModelWizard } from '@/platform/assets/composables/useUploadModelWizard'
import { useDialogStore } from '@/stores/dialogStore'
const dialogStore = useDialogStore()
const { modelTypes, fetchModelTypes } = useModelTypes()
const emit = defineEmits<{
'upload-success': []
}>()
const {
currentStep,
isFetchingMetadata,
isUploading,
uploadStatus,
uploadError,
wizardData,
selectedModelType,
canFetchMetadata,
canUploadModel,
fetchMetadata,
uploadModel,
goToPreviousStep
} = useUploadModelWizard(modelTypes)
async function handleFetchMetadata() {
await fetchMetadata()
}
async function handleUploadModel() {
const success = await uploadModel()
if (success) {
emit('upload-success')
}
}
function handleClose() {
dialogStore.closeDialog({ key: 'upload-model' })
}
onMounted(() => {
fetchModelTypes()
})
</script>
<style scoped>
.upload-model-dialog {
width: 90vw;
max-width: 800px;
min-height: 400px;
}
@media (min-width: 640px) {
.upload-model-dialog {
width: auto;
min-width: 600px;
}
}
</style>

View File

@@ -0,0 +1,12 @@
<template>
<div class="flex items-center gap-3 px-4 py-2 font-bold">
<span>{{ $t('assetBrowser.uploadModelFromCivitai') }}</span>
<span
class="rounded-full bg-white px-1.5 py-0 text-xxs font-medium uppercase text-black"
>
{{ $t('g.beta') }}
</span>
</div>
</template>
<script setup lang="ts"></script>

View File

@@ -0,0 +1,72 @@
<template>
<div class="flex justify-end gap-2">
<TextButton
v-if="currentStep !== 1 && currentStep !== 3"
:label="$t('g.back')"
type="secondary"
size="md"
:disabled="isFetchingMetadata || isUploading"
@click="emit('back')"
/>
<span v-else />
<IconTextButton
v-if="currentStep === 1"
:label="$t('g.continue')"
type="primary"
size="md"
:disabled="!canFetchMetadata || isFetchingMetadata"
@click="emit('fetchMetadata')"
>
<template #icon>
<i
v-if="isFetchingMetadata"
class="icon-[lucide--loader-circle] animate-spin"
/>
</template>
</IconTextButton>
<IconTextButton
v-else-if="currentStep === 2"
:label="$t('assetBrowser.upload')"
type="primary"
size="md"
:disabled="!canUploadModel || isUploading"
@click="emit('upload')"
>
<template #icon>
<i
v-if="isUploading"
class="icon-[lucide--loader-circle] animate-spin"
/>
</template>
</IconTextButton>
<TextButton
v-else-if="currentStep === 3 && uploadStatus === 'success'"
:label="$t('assetBrowser.finish')"
type="primary"
size="md"
@click="emit('close')"
/>
</div>
</template>
<script setup lang="ts">
import IconTextButton from '@/components/button/IconTextButton.vue'
import TextButton from '@/components/button/TextButton.vue'
defineProps<{
currentStep: number
isFetchingMetadata: boolean
isUploading: boolean
canFetchMetadata: boolean
canUploadModel: boolean
uploadStatus: 'idle' | 'uploading' | 'success' | 'error'
}>()
const emit = defineEmits<{
(e: 'back'): void
(e: 'fetchMetadata'): void
(e: 'upload'): void
(e: 'close'): void
}>()
</script>

View File

@@ -0,0 +1,68 @@
<template>
<div class="flex flex-1 flex-col gap-6">
<!-- Uploading State -->
<div
v-if="status === 'uploading'"
class="flex flex-1 flex-col items-center justify-center gap-6"
>
<i
class="icon-[lucide--loader-circle] animate-spin text-6xl text-primary"
/>
<div class="text-center">
<p class="m-0 text-sm font-bold">
{{ $t('assetBrowser.uploadingModel') }}
</p>
</div>
</div>
<!-- Success State -->
<div v-else-if="status === 'success'" class="flex flex-col gap-8">
<div class="flex flex-col gap-4">
<p class="text-sm text-muted m-0 font-bold">
{{ $t('assetBrowser.modelUploaded') }}
</p>
<p class="text-sm text-muted m-0">
{{ $t('assetBrowser.findInLibrary', { type: modelType }) }}
</p>
</div>
<div class="flex flex-row items-start p-8 bg-neutral-800 rounded-lg">
<div class="flex flex-col justify-center items-start gap-1 flex-1">
<p class="text-sm m-0">
{{ metadata?.name || metadata?.filename }}
</p>
<p class="text-sm text-muted m-0">
{{ modelType }}
</p>
</div>
</div>
</div>
<!-- Error State -->
<div
v-else-if="status === 'error'"
class="flex flex-1 flex-col items-center justify-center gap-6"
>
<i class="icon-[lucide--x-circle] text-6xl text-error" />
<div class="text-center">
<p class="m-0 text-sm font-bold">
{{ $t('assetBrowser.uploadFailed') }}
</p>
<p v-if="error" class="text-sm text-muted mb-0">
{{ error }}
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
defineProps<{
status: 'idle' | 'uploading' | 'success' | 'error'
error?: string
metadata: AssetMetadata | null
modelType: string | undefined
}>()
</script>

View File

@@ -0,0 +1,50 @@
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<p class="text-sm text-muted m-0">
{{ $t('assetBrowser.uploadModelDescription1') }}
</p>
<ul class="list-disc space-y-1 pl-5 mt-0 text-sm text-muted">
<li>{{ $t('assetBrowser.uploadModelDescription2') }}</li>
<li>{{ $t('assetBrowser.uploadModelDescription3') }}</li>
</ul>
</div>
<div class="flex flex-col gap-2">
<label class="text-sm text-muted mb-0">
{{ $t('assetBrowser.civitaiLinkLabel') }}
</label>
<UrlInput
v-model="url"
:placeholder="$t('assetBrowser.civitaiLinkPlaceholder')"
:disable-validation="true"
/>
<p v-if="error" class="text-xs text-error">
{{ error }}
</p>
<p v-else class="text-xs text-muted">
{{ $t('assetBrowser.civitaiLinkExample') }}
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import UrlInput from '@/components/common/UrlInput.vue'
const props = defineProps<{
modelValue: string
error?: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const url = computed({
get: () => props.modelValue,
set: (value: string) => emit('update:modelValue', value)
})
</script>

View File

@@ -0,0 +1,73 @@
import { createSharedComposable, useAsyncState } from '@vueuse/core'
import { api } from '@/scripts/api'
/**
* 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<string, string> = {
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
}
/**
* Composable for fetching and managing model types from the API
* Uses shared state to ensure data is only fetched once
*/
export const useModelTypes = createSharedComposable(() => {
const {
state: modelTypes,
isLoading,
error,
execute: fetchModelTypes
} = useAsyncState(
async (): Promise<ModelTypeOption[]> => {
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)
}
}
)
return {
modelTypes,
isLoading,
error,
fetchModelTypes
}
})

View File

@@ -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<ModelTypeOption[]>) {
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<WizardData>({
url: '',
metadata: null,
name: '',
tags: []
})
const selectedModelType = ref<string | undefined>(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
}
}

View File

@@ -33,6 +33,29 @@ const zModelFile = z.object({
pathIndex: z.number()
})
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(),
content_type: z.string().optional(),
filename: z.string().optional(),
name: z.string().optional(),
tags: z.array(z.string()).optional(),
preview_url: z.string().optional(),
validation: zValidationResult.optional()
})
// Filename validation schema
export const assetFilenameSchema = z
.string()
@@ -48,6 +71,7 @@ export const assetResponseSchema = zAssetResponse
// Export types derived from Zod schemas
export type AssetItem = z.infer<typeof zAsset>
export type AssetResponse = z.infer<typeof zAssetResponse>
export type AssetMetadata = z.infer<typeof zAssetMetadata>
export type ModelFolder = z.infer<typeof zModelFolder>
export type ModelFile = z.infer<typeof zModelFile>

View File

@@ -1,8 +1,10 @@
import { fromZodError } from 'zod-validation-error'
import { st } from '@/i18n'
import { assetResponseSchema } from '@/platform/assets/schemas/assetSchema'
import type {
AssetItem,
AssetMetadata,
AssetResponse,
ModelFile,
ModelFolder
@@ -10,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<string, string> = {
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
@@ -249,6 +281,77 @@ 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<AssetMetadata> {
const encodedUrl = encodeURIComponent(url)
const res = await api.fetchApi(
`${ASSETS_ENDPOINT}/remote-metadata?url=${encodedUrl}`
)
if (!res.ok) {
const errorData = await res.json().catch(() => ({}))
throw new Error(
getLocalizedErrorMessage(errorData.code || 'UNKNOWN_ERROR')
)
}
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
}
/**
* 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<AssetItem & { created_new: boolean }> - Asset object with created_new flag
* @throws Error if upload fails
*/
async function uploadAssetFromUrl(params: {
url: string
name: string
tags?: string[]
user_metadata?: Record<string, any>
preview_id?: string
}): Promise<AssetItem & { created_new: boolean }> {
const res = await api.fetchApi(ASSETS_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(params)
})
if (!res.ok) {
throw new Error(
st(
'assetBrowser.errorUploadFailed',
'Failed to upload asset. Please try again.'
)
)
}
return await res.json()
}
return {
getAssetModelFolders,
getAssetModels,
@@ -256,7 +359,9 @@ function createAssetService() {
getAssetsForNodeType,
getAssetDetails,
getAssetsByTag,
deleteAsset
deleteAsset,
getAssetMetadata,
uploadAssetFromUrl
}
}