Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/components/common/UrlInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { ValidationState } from '@/utils/validationUtil'
const props = defineProps<{
modelValue: string
validateUrlFn?: (url: string) => Promise<boolean>
disableValidation?: boolean
}>()

const emit = defineEmits<{
Expand Down Expand Up @@ -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)
Expand Down
26 changes: 26 additions & 0 deletions src/locales/en/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 14 additions & 1 deletion src/platform/assets/components/AssetBrowserModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -92,6 +95,7 @@ const props = defineProps<{
}>()

const { t } = useI18n()
const dialogStore = useDialogStore()

const emit = defineEmits<{
'asset-select': [asset: AssetDisplayItem]
Expand Down Expand Up @@ -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>
62 changes: 62 additions & 0 deletions src/platform/assets/components/UploadModelConfirmation.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<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.whatTypeOfModel') }}
</label>
<SingleSelect
v-model="selectedModelType"
:label="$t('assetBrowser.whatTypeOfModel')"
:options="modelTypes"
/>
<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'

interface ModelMetadata {
content_length: number
final_url: string
content_type?: string
filename?: string
name?: string
tags?: string[]
preview_url?: string
}

const props = defineProps<{
modelValue: string
metadata: ModelMetadata | null
}>()

const emit = defineEmits<{
'update:modelValue': [value: string]
}>()

const { modelTypes } = useModelTypes()

const selectedModelType = computed({
get: () => props.modelValue,
set: (value: string) => emit('update:modelValue', value)
})
</script>
224 changes: 224 additions & 0 deletions src/platform/assets/components/UploadModelDialog.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
<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" />

<!-- 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 -->
<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"
:on-click="goToPreviousStep"
/>
<span v-else />

<IconTextButton
v-if="currentStep === 1"
:label="$t('g.continue')"
type="primary"
size="md"
:disabled="!canFetchMetadata || isFetchingMetadata"
:on-click="handleFetchMetadata"
>
<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"
:on-click="handleUploadModel"
>
<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"
:on-click="handleClose"
/>
</div>
</div>
</template>

<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'

import IconTextButton from '@/components/button/IconTextButton.vue'
import TextButton from '@/components/button/TextButton.vue'
import UploadModelConfirmation from '@/platform/assets/components/UploadModelConfirmation.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 { assetService } from '@/platform/assets/services/assetService'
import { useDialogStore } from '@/stores/dialogStore'

const dialogStore = useDialogStore()

const emit = defineEmits<{
'upload-success': []
}>()

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: string
metadata: {
content_length: number
final_url: string
content_type?: string
filename?: string
name?: string
tags?: string[]
preview_url?: string
} | null
name: string
tags: string[]
}>({
url: '',
metadata: null,
name: '',
tags: []
})

const selectedModelType = ref<string>('loras')

const { modelTypes, fetchModelTypes } = useModelTypes()

// Validation
const canFetchMetadata = computed(() => {
return wizardData.value.url.trim().length > 0
})

const canUploadModel = computed(() => {
return !!selectedModelType.value
})

async function handleFetchMetadata() {
if (!canFetchMetadata.value) 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 : 'Failed to retrieve metadata'
// TODO: Show error toast to user
} finally {
isFetchingMetadata.value = false
}
}

async function handleUploadModel() {
if (!canUploadModel.value) return

isUploading.value = true
uploadStatus.value = 'uploading'

try {
const tags = ['models', selectedModelType.value]
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
emit('upload-success')
} 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
} finally {
isUploading.value = false
}
}

function goToPreviousStep() {
if (currentStep.value > 1) {
currentStep.value = currentStep.value - 1
}
}

function handleClose() {
dialogStore.closeDialog({ key: 'upload-model' })
}

onMounted(() => {
fetchModelTypes()
})
</script>

<style scoped>
.upload-model-dialog {
min-width: 600px;
min-height: 400px;
}
</style>
12 changes: 12 additions & 0 deletions src/platform/assets/components/UploadModelDialogHeader.vue
Original file line number Diff line number Diff line change
@@ -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>
Loading