Skip to content

Commit 547aa7d

Browse files
feat(assets): add Civitai model upload wizard
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 <noreply@anthropic.com>
1 parent 14d94da commit 547aa7d

File tree

10 files changed

+648
-2
lines changed

10 files changed

+648
-2
lines changed

src/components/common/UrlInput.vue

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { ValidationState } from '@/utils/validationUtil'
3535
const props = defineProps<{
3636
modelValue: string
3737
validateUrlFn?: (url: string) => Promise<boolean>
38+
disableValidation?: boolean
3839
}>()
3940
4041
const emit = defineEmits<{
@@ -101,6 +102,8 @@ const defaultValidateUrl = async (url: string): Promise<boolean> => {
101102
}
102103
103104
const validateUrl = async (value: string) => {
105+
if (props.disableValidation) return
106+
104107
if (validationState.value === ValidationState.LOADING) return
105108
106109
const url = cleanInput(value)

src/locales/en/main.json

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2076,6 +2076,32 @@
20762076
"noModelsInFolder": "No {type} available in this folder",
20772077
"searchAssetsPlaceholder": "Type to search...",
20782078
"uploadModel": "Upload model",
2079+
"uploadModelFromCivitai": "Upload a model from Civitai",
2080+
"uploadModelDescription1": "Paste a Civitai model download link to add it to your library.",
2081+
"uploadModelDescription2": "Only links from https://civitai.com are supported at the moment",
2082+
"uploadModelDescription3": "Max file size: 1 GB",
2083+
"civitaiLinkLabel": "Civitai model download link",
2084+
"civitaiLinkPlaceholder": "Paste link here",
2085+
"civitaiLinkExample": "Example: https://civitai.com/api/download/models/833921?type=Model&format=SafeTensor",
2086+
"confirmModelDetails": "Confirm Model Details",
2087+
"fileName": "File Name",
2088+
"fileSize": "File Size",
2089+
"modelName": "Model Name",
2090+
"modelNamePlaceholder": "Enter a name for this model",
2091+
"tags": "Tags",
2092+
"tagsPlaceholder": "e.g., models, checkpoint",
2093+
"tagsHelp": "Separate tags with commas",
2094+
"upload": "Upload",
2095+
"uploadingModel": "Uploading model...",
2096+
"uploadSuccess": "Model uploaded successfully!",
2097+
"uploadFailed": "Upload failed",
2098+
"modelAssociatedWithLink": "The model associated with the link you provided:",
2099+
"whatTypeOfModel": "What type of model is this?",
2100+
"selectModelType": "Select model type",
2101+
"notSureLeaveAsIs": "Not sure? Just leave this as is",
2102+
"modelUploaded": "Model uploaded!",
2103+
"findInLibrary": "Find it in the {type} section of the models library.",
2104+
"finish": "Finish",
20792105
"allModels": "All Models",
20802106
"allCategory": "All {category}",
20812107
"unknown": "Unknown",

src/platform/assets/components/AssetBrowserModal.vue

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,11 +73,14 @@ import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
7373
import { useFeatureFlags } from '@/composables/useFeatureFlags'
7474
import AssetFilterBar from '@/platform/assets/components/AssetFilterBar.vue'
7575
import AssetGrid from '@/platform/assets/components/AssetGrid.vue'
76+
import UploadModelDialog from '@/platform/assets/components/UploadModelDialog.vue'
77+
import UploadModelDialogHeader from '@/platform/assets/components/UploadModelDialogHeader.vue'
7678
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
7779
import { useAssetBrowser } from '@/platform/assets/composables/useAssetBrowser'
7880
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
7981
import { assetService } from '@/platform/assets/services/assetService'
8082
import { formatCategoryLabel } from '@/platform/assets/utils/categoryLabel'
83+
import { useDialogStore } from '@/stores/dialogStore'
8184
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
8285
import { OnCloseKey } from '@/types/widgetTypes'
8386
@@ -92,6 +95,7 @@ const props = defineProps<{
9295
}>()
9396
9497
const { t } = useI18n()
98+
const dialogStore = useDialogStore()
9599
96100
const emit = defineEmits<{
97101
'asset-select': [asset: AssetDisplayItem]
@@ -189,6 +193,15 @@ const { flags } = useFeatureFlags()
189193
const isUploadButtonEnabled = computed(() => flags.modelUploadButtonEnabled)
190194
191195
function handleUploadClick() {
192-
// Will be implemented in the future commit
196+
dialogStore.showDialog({
197+
key: 'upload-model',
198+
headerComponent: UploadModelDialogHeader,
199+
component: UploadModelDialog,
200+
props: {
201+
onUploadSuccess: async () => {
202+
await execute()
203+
}
204+
}
205+
})
193206
}
194207
</script>
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<template>
2+
<div class="flex flex-col gap-4">
3+
<!-- Model Info Section -->
4+
<div class="flex flex-col gap-2">
5+
<p class="text-sm text-muted m-0">
6+
{{ $t('assetBrowser.modelAssociatedWithLink') }}
7+
</p>
8+
<p class="text-sm mt-0">
9+
{{ metadata?.name || metadata?.filename }}
10+
</p>
11+
</div>
12+
13+
<!-- Model Type Selection -->
14+
<div class="flex flex-col gap-2">
15+
<label class="text-sm text-muted">
16+
{{ $t('assetBrowser.whatTypeOfModel') }}
17+
</label>
18+
<SingleSelect
19+
v-model="selectedModelType"
20+
:label="$t('assetBrowser.whatTypeOfModel')"
21+
:options="modelTypes"
22+
/>
23+
<div class="flex items-center gap-2 text-sm text-muted">
24+
<i class="icon-[lucide--info]" />
25+
<span>{{ $t('assetBrowser.notSureLeaveAsIs') }}</span>
26+
</div>
27+
</div>
28+
</div>
29+
</template>
30+
31+
<script setup lang="ts">
32+
import { computed } from 'vue'
33+
34+
import SingleSelect from '@/components/input/SingleSelect.vue'
35+
import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
36+
37+
interface ModelMetadata {
38+
content_length: number
39+
final_url: string
40+
content_type?: string
41+
filename?: string
42+
name?: string
43+
tags?: string[]
44+
preview_url?: string
45+
}
46+
47+
const props = defineProps<{
48+
modelValue: string
49+
metadata: ModelMetadata | null
50+
}>()
51+
52+
const emit = defineEmits<{
53+
'update:modelValue': [value: string]
54+
}>()
55+
56+
const { modelTypes } = useModelTypes()
57+
58+
const selectedModelType = computed({
59+
get: () => props.modelValue,
60+
set: (value: string) => emit('update:modelValue', value)
61+
})
62+
</script>
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
<template>
2+
<div class="upload-model-dialog flex flex-col justify-between gap-6 p-4 pt-6">
3+
<!-- Step 1: Enter URL -->
4+
<UploadModelUrlInput v-if="currentStep === 1" v-model="wizardData.url" />
5+
6+
<!-- Step 2: Confirm Metadata -->
7+
<UploadModelConfirmation
8+
v-else-if="currentStep === 2"
9+
v-model="selectedModelType"
10+
:metadata="wizardData.metadata"
11+
/>
12+
13+
<!-- Step 3: Upload Progress -->
14+
<UploadModelProgress
15+
v-else-if="currentStep === 3"
16+
:status="uploadStatus"
17+
:error="uploadError"
18+
:metadata="wizardData.metadata"
19+
:model-type="selectedModelType"
20+
/>
21+
22+
<!-- Navigation Footer -->
23+
<div class="flex justify-end gap-2">
24+
<TextButton
25+
v-if="currentStep !== 1 && currentStep !== 3"
26+
:label="$t('g.back')"
27+
type="secondary"
28+
size="md"
29+
:disabled="isFetchingMetadata || isUploading"
30+
:on-click="goToPreviousStep"
31+
/>
32+
<span v-else />
33+
34+
<IconTextButton
35+
v-if="currentStep === 1"
36+
:label="$t('g.continue')"
37+
type="primary"
38+
size="md"
39+
:disabled="!canFetchMetadata || isFetchingMetadata"
40+
:on-click="handleFetchMetadata"
41+
>
42+
<template #icon>
43+
<i
44+
v-if="isFetchingMetadata"
45+
class="icon-[lucide--loader-circle] animate-spin"
46+
/>
47+
</template>
48+
</IconTextButton>
49+
<IconTextButton
50+
v-else-if="currentStep === 2"
51+
:label="$t('assetBrowser.upload')"
52+
type="primary"
53+
size="md"
54+
:disabled="!canUploadModel || isUploading"
55+
:on-click="handleUploadModel"
56+
>
57+
<template #icon>
58+
<i
59+
v-if="isUploading"
60+
class="icon-[lucide--loader-circle] animate-spin"
61+
/>
62+
</template>
63+
</IconTextButton>
64+
<TextButton
65+
v-else-if="currentStep === 3 && uploadStatus === 'success'"
66+
:label="$t('assetBrowser.finish')"
67+
type="primary"
68+
size="md"
69+
:on-click="handleClose"
70+
/>
71+
</div>
72+
</div>
73+
</template>
74+
75+
<script setup lang="ts">
76+
import { computed, onMounted, ref } from 'vue'
77+
78+
import IconTextButton from '@/components/button/IconTextButton.vue'
79+
import TextButton from '@/components/button/TextButton.vue'
80+
import UploadModelConfirmation from '@/platform/assets/components/UploadModelConfirmation.vue'
81+
import UploadModelProgress from '@/platform/assets/components/UploadModelProgress.vue'
82+
import UploadModelUrlInput from '@/platform/assets/components/UploadModelUrlInput.vue'
83+
import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
84+
import { assetService } from '@/platform/assets/services/assetService'
85+
import { useDialogStore } from '@/stores/dialogStore'
86+
87+
const dialogStore = useDialogStore()
88+
89+
const emit = defineEmits<{
90+
'upload-success': []
91+
}>()
92+
93+
const currentStep = ref(1)
94+
const isFetchingMetadata = ref(false)
95+
const isUploading = ref(false)
96+
const uploadStatus = ref<'idle' | 'uploading' | 'success' | 'error'>('idle')
97+
const uploadError = ref('')
98+
99+
const wizardData = ref<{
100+
url: string
101+
metadata: {
102+
content_length: number
103+
final_url: string
104+
content_type?: string
105+
filename?: string
106+
name?: string
107+
tags?: string[]
108+
preview_url?: string
109+
} | null
110+
name: string
111+
tags: string[]
112+
}>({
113+
url: '',
114+
metadata: null,
115+
name: '',
116+
tags: []
117+
})
118+
119+
const selectedModelType = ref<string>('loras')
120+
121+
const { modelTypes, fetchModelTypes } = useModelTypes()
122+
123+
// Validation
124+
const canFetchMetadata = computed(() => {
125+
return wizardData.value.url.trim().length > 0
126+
})
127+
128+
const canUploadModel = computed(() => {
129+
return !!selectedModelType.value
130+
})
131+
132+
async function handleFetchMetadata() {
133+
if (!canFetchMetadata.value) return
134+
135+
isFetchingMetadata.value = true
136+
try {
137+
const metadata = await assetService.getAssetMetadata(wizardData.value.url)
138+
wizardData.value.metadata = metadata
139+
140+
// Pre-fill name from metadata
141+
wizardData.value.name = metadata.filename || metadata.name || ''
142+
143+
// Pre-fill model type from metadata tags if available
144+
if (metadata.tags && metadata.tags.length > 0) {
145+
wizardData.value.tags = metadata.tags
146+
// Try to detect model type from tags
147+
const typeTag = metadata.tags.find((tag) =>
148+
modelTypes.value.some((type) => type.value === tag)
149+
)
150+
if (typeTag) {
151+
selectedModelType.value = typeTag
152+
}
153+
}
154+
155+
currentStep.value = 2
156+
} catch (error) {
157+
console.error('Failed to retrieve metadata:', error)
158+
uploadError.value =
159+
error instanceof Error ? error.message : 'Failed to retrieve metadata'
160+
// TODO: Show error toast to user
161+
} finally {
162+
isFetchingMetadata.value = false
163+
}
164+
}
165+
166+
async function handleUploadModel() {
167+
if (!canUploadModel.value) return
168+
169+
isUploading.value = true
170+
uploadStatus.value = 'uploading'
171+
172+
try {
173+
const tags = ['models', selectedModelType.value]
174+
const filename =
175+
wizardData.value.metadata?.filename ||
176+
wizardData.value.metadata?.name ||
177+
'model'
178+
179+
await assetService.uploadAssetFromUrl({
180+
url: wizardData.value.url,
181+
name: filename,
182+
tags,
183+
user_metadata: {
184+
source: 'civitai',
185+
source_url: wizardData.value.url,
186+
model_type: selectedModelType.value
187+
}
188+
})
189+
190+
uploadStatus.value = 'success'
191+
currentStep.value = 3
192+
emit('upload-success')
193+
} catch (error) {
194+
console.error('Failed to upload asset:', error)
195+
uploadStatus.value = 'error'
196+
uploadError.value =
197+
error instanceof Error ? error.message : 'Failed to upload model'
198+
currentStep.value = 3
199+
} finally {
200+
isUploading.value = false
201+
}
202+
}
203+
204+
function goToPreviousStep() {
205+
if (currentStep.value > 1) {
206+
currentStep.value = currentStep.value - 1
207+
}
208+
}
209+
210+
function handleClose() {
211+
dialogStore.closeDialog({ key: 'upload-model' })
212+
}
213+
214+
onMounted(() => {
215+
fetchModelTypes()
216+
})
217+
</script>
218+
219+
<style scoped>
220+
.upload-model-dialog {
221+
min-width: 600px;
222+
min-height: 400px;
223+
}
224+
</style>
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<template>
2+
<div class="flex items-center gap-3 px-4 py-2 font-bold">
3+
<span>{{ $t('assetBrowser.uploadModelFromCivitai') }}</span>
4+
<span
5+
class="rounded-full bg-white px-1.5 py-0 text-xxs font-medium uppercase text-black"
6+
>
7+
{{ $t('g.beta') }}
8+
</span>
9+
</div>
10+
</template>
11+
12+
<script setup lang="ts"></script>

0 commit comments

Comments
 (0)