feat: Add model upload functionality (#194)
This commit is contained in:
@@ -41,12 +41,14 @@ utils.download_web_distribution(version)
|
|||||||
from .py import manager
|
from .py import manager
|
||||||
from .py import download
|
from .py import download
|
||||||
from .py import information
|
from .py import information
|
||||||
|
from .py import upload
|
||||||
|
|
||||||
routes = config.routes
|
routes = config.routes
|
||||||
|
|
||||||
manager.ModelManager().add_routes(routes)
|
manager.ModelManager().add_routes(routes)
|
||||||
download.ModelDownload().add_routes(routes)
|
download.ModelDownload().add_routes(routes)
|
||||||
information.Information().add_routes(routes)
|
information.Information().add_routes(routes)
|
||||||
|
upload.ModelUploader().add_routes(routes)
|
||||||
|
|
||||||
|
|
||||||
WEB_DIRECTORY = "web"
|
WEB_DIRECTORY = "web"
|
||||||
|
|||||||
79
py/upload.py
Normal file
79
py/upload.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
import folder_paths
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
from . import utils
|
||||||
|
|
||||||
|
|
||||||
|
class ModelUploader:
|
||||||
|
def add_routes(self, routes):
|
||||||
|
|
||||||
|
@routes.get("/model-manager/supported-extensions")
|
||||||
|
async def fetch_model_exts(request):
|
||||||
|
"""
|
||||||
|
Get model exts
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
supported_extensions = list(folder_paths.supported_pt_extensions)
|
||||||
|
return web.json_response({"success": True, "data": supported_extensions})
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Get model supported extension failed: {str(e)}"
|
||||||
|
utils.print_error(error_msg)
|
||||||
|
return web.json_response({"success": False, "error": error_msg})
|
||||||
|
|
||||||
|
@routes.post("/model-manager/upload")
|
||||||
|
async def upload_model(request):
|
||||||
|
"""
|
||||||
|
Upload model
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
reader = await request.multipart()
|
||||||
|
await self.upload_model(reader)
|
||||||
|
utils.print_info(f"Upload model success")
|
||||||
|
return web.json_response({"success": True, "data": None})
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Upload model failed: {str(e)}"
|
||||||
|
utils.print_error(error_msg)
|
||||||
|
return web.json_response({"success": False, "error": error_msg})
|
||||||
|
|
||||||
|
async def upload_model(self, reader):
|
||||||
|
uploaded_size = 0
|
||||||
|
last_update_time = time.time()
|
||||||
|
interval = 1.0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
part = await reader.next()
|
||||||
|
if part is None:
|
||||||
|
break
|
||||||
|
|
||||||
|
name = part.name
|
||||||
|
if name == "folder":
|
||||||
|
file_folder = await part.text()
|
||||||
|
|
||||||
|
if name == "file":
|
||||||
|
filename = part.filename
|
||||||
|
filepath = f"{file_folder}/{filename}"
|
||||||
|
tmp_filepath = f"{file_folder}/{filename}.tmp"
|
||||||
|
|
||||||
|
with open(tmp_filepath, "wb") as f:
|
||||||
|
while True:
|
||||||
|
chunk = await part.read_chunk()
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
f.write(chunk)
|
||||||
|
uploaded_size += len(chunk)
|
||||||
|
|
||||||
|
if time.time() - last_update_time >= interval:
|
||||||
|
update_upload_progress = {
|
||||||
|
"uploaded_size": uploaded_size,
|
||||||
|
}
|
||||||
|
await utils.send_json("update_upload_progress", update_upload_progress)
|
||||||
|
|
||||||
|
update_upload_progress = {
|
||||||
|
"uploaded_size": uploaded_size,
|
||||||
|
}
|
||||||
|
await utils.send_json("update_upload_progress", update_upload_progress)
|
||||||
|
os.rename(tmp_filepath, filepath)
|
||||||
21
src/App.vue
21
src/App.vue
@@ -10,6 +10,7 @@ import DialogDownload from 'components/DialogDownload.vue'
|
|||||||
import DialogExplorer from 'components/DialogExplorer.vue'
|
import DialogExplorer from 'components/DialogExplorer.vue'
|
||||||
import DialogManager from 'components/DialogManager.vue'
|
import DialogManager from 'components/DialogManager.vue'
|
||||||
import DialogScanning from 'components/DialogScanning.vue'
|
import DialogScanning from 'components/DialogScanning.vue'
|
||||||
|
import DialogUpload from 'components/DialogUpload.vue'
|
||||||
import GlobalDialogStack from 'components/GlobalDialogStack.vue'
|
import GlobalDialogStack from 'components/GlobalDialogStack.vue'
|
||||||
import GlobalLoading from 'components/GlobalLoading.vue'
|
import GlobalLoading from 'components/GlobalLoading.vue'
|
||||||
import GlobalToast from 'components/GlobalToast.vue'
|
import GlobalToast from 'components/GlobalToast.vue'
|
||||||
@@ -64,6 +65,21 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openUploadDialog = () => {
|
||||||
|
dialog.open({
|
||||||
|
key: 'model-manager-upload',
|
||||||
|
title: t('uploadModel'),
|
||||||
|
content: DialogUpload,
|
||||||
|
headerButtons: [
|
||||||
|
{
|
||||||
|
key: 'refresh',
|
||||||
|
icon: 'pi pi-refresh',
|
||||||
|
command: refreshModelsAndConfig,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const openManagerDialog = () => {
|
const openManagerDialog = () => {
|
||||||
const { cardWidth, gutter, aspect, flat } = config
|
const { cardWidth, gutter, aspect, flat } = config
|
||||||
|
|
||||||
@@ -93,6 +109,11 @@ onMounted(() => {
|
|||||||
icon: 'pi pi-download',
|
icon: 'pi pi-download',
|
||||||
command: openDownloadDialog,
|
command: openDownloadDialog,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'upload',
|
||||||
|
icon: 'pi pi-upload',
|
||||||
|
command: openUploadDialog,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
minWidth: cardWidth * 2 + gutter + 42,
|
minWidth: cardWidth * 2 + gutter + 42,
|
||||||
minHeight: (cardWidth / aspect) * 0.5 + 162,
|
minHeight: (cardWidth / aspect) * 0.5 + 162,
|
||||||
|
|||||||
274
src/components/DialogUpload.vue
Normal file
274
src/components/DialogUpload.vue
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
<template>
|
||||||
|
<div class="h-full px-4">
|
||||||
|
<!-- <div v-show="batchScanningStep === 0" class="h-full">
|
||||||
|
<div class="flex h-full items-center px-8">
|
||||||
|
<div class="h-20 w-full opacity-60">
|
||||||
|
<ProgressBar mode="indeterminate" style="height: 6px"></ProgressBar>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div> -->
|
||||||
|
|
||||||
|
<Stepper v-model:value="stepValue" class="flex h-full flex-col" linear>
|
||||||
|
<StepList>
|
||||||
|
<Step :value="1">{{ $t('selectModelType') }}</Step>
|
||||||
|
<Step :value="2">{{ $t('selectSubdirectory') }}</Step>
|
||||||
|
<Step :value="3">{{ $t('chooseFile') }}</Step>
|
||||||
|
</StepList>
|
||||||
|
<StepPanels class="flex-1 overflow-hidden">
|
||||||
|
<StepPanel :value="1" class="h-full">
|
||||||
|
<div class="flex h-full flex-col overflow-hidden">
|
||||||
|
<ResponseScroll>
|
||||||
|
<div class="flex flex-wrap gap-4">
|
||||||
|
<Button
|
||||||
|
v-for="item in typeOptions"
|
||||||
|
:key="item.value"
|
||||||
|
:label="item.label"
|
||||||
|
@click="item.command"
|
||||||
|
></Button>
|
||||||
|
</div>
|
||||||
|
</ResponseScroll>
|
||||||
|
</div>
|
||||||
|
</StepPanel>
|
||||||
|
<StepPanel :value="2" class="h-full">
|
||||||
|
<div class="flex h-full flex-col overflow-hidden">
|
||||||
|
<ResponseScroll class="flex-1">
|
||||||
|
<Tree
|
||||||
|
class="h-full"
|
||||||
|
v-model:selection-keys="selectedKey"
|
||||||
|
:value="pathOptions"
|
||||||
|
selectionMode="single"
|
||||||
|
:pt:nodeLabel:class="'text-ellipsis overflow-hidden'"
|
||||||
|
></Tree>
|
||||||
|
</ResponseScroll>
|
||||||
|
|
||||||
|
<div class="flex justify-between pt-6">
|
||||||
|
<Button
|
||||||
|
:label="$t('back')"
|
||||||
|
severity="secondary"
|
||||||
|
icon="pi pi-arrow-left"
|
||||||
|
@click="handleBackTypeSelect"
|
||||||
|
></Button>
|
||||||
|
<Button
|
||||||
|
:label="$t('next')"
|
||||||
|
icon="pi pi-arrow-right"
|
||||||
|
icon-pos="right"
|
||||||
|
:disabled="!enabledUpload"
|
||||||
|
@click="handleConfirmSubdir"
|
||||||
|
></Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</StepPanel>
|
||||||
|
<StepPanel :value="3" class="h-full">
|
||||||
|
<div class="flex h-full flex-col items-center justify-center">
|
||||||
|
<template v-if="showUploadProgress">
|
||||||
|
<div class="w-4/5">
|
||||||
|
<ProgressBar
|
||||||
|
:value="uploadProgress"
|
||||||
|
:pt:value:style="{ transition: 'width .1s linear' }"
|
||||||
|
></ProgressBar>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="overflow-hidden break-words py-8">
|
||||||
|
<div class="overflow-hidden px-8">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="pb-2">
|
||||||
|
{{ $t('selectedSpecialPath') }}
|
||||||
|
</div>
|
||||||
|
<div class="leading-5 opacity-60">
|
||||||
|
{{ selectedModelFolder }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-center gap-4">
|
||||||
|
<Button
|
||||||
|
v-for="item in uploadActions"
|
||||||
|
:key="item.value"
|
||||||
|
:label="item.label"
|
||||||
|
:icon="item.icon"
|
||||||
|
@click="item.command.call(item)"
|
||||||
|
></Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="h-1/4"></div>
|
||||||
|
</div>
|
||||||
|
</StepPanel>
|
||||||
|
</StepPanels>
|
||||||
|
</Stepper>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import ResponseScroll from 'components/ResponseScroll.vue'
|
||||||
|
import { configSetting } from 'hooks/config'
|
||||||
|
import { useModelFolder, useModels } from 'hooks/model'
|
||||||
|
import { request } from 'hooks/request'
|
||||||
|
import { useToast } from 'hooks/toast'
|
||||||
|
import Button from 'primevue/button'
|
||||||
|
import ProgressBar from 'primevue/progressbar'
|
||||||
|
import Step from 'primevue/step'
|
||||||
|
import StepList from 'primevue/steplist'
|
||||||
|
import StepPanel from 'primevue/steppanel'
|
||||||
|
import StepPanels from 'primevue/steppanels'
|
||||||
|
import Stepper from 'primevue/stepper'
|
||||||
|
import Tree from 'primevue/tree'
|
||||||
|
import { api, app } from 'scripts/comfyAPI'
|
||||||
|
import { computed, onMounted, onUnmounted, ref, toValue } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { toast } = useToast()
|
||||||
|
|
||||||
|
const stepValue = ref(1)
|
||||||
|
|
||||||
|
const { folders } = useModels()
|
||||||
|
|
||||||
|
const currentType = ref<string>()
|
||||||
|
const typeOptions = computed(() => {
|
||||||
|
const excludeScanTypes = app.ui?.settings.getSettingValue<string>(
|
||||||
|
configSetting.excludeScanTypes,
|
||||||
|
)
|
||||||
|
const customBlackList =
|
||||||
|
excludeScanTypes
|
||||||
|
?.split(',')
|
||||||
|
.map((type) => type.trim())
|
||||||
|
.filter(Boolean) ?? []
|
||||||
|
return Object.keys(folders.value)
|
||||||
|
.filter((folder) => !customBlackList.includes(folder))
|
||||||
|
.map((type) => {
|
||||||
|
return {
|
||||||
|
label: type,
|
||||||
|
value: type,
|
||||||
|
command: () => {
|
||||||
|
currentType.value = type
|
||||||
|
stepValue.value++
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const { pathOptions } = useModelFolder({ type: currentType })
|
||||||
|
|
||||||
|
const selectedModelFolder = ref<string>()
|
||||||
|
const selectedKey = computed({
|
||||||
|
get: () => {
|
||||||
|
const key = selectedModelFolder.value
|
||||||
|
return key ? { [key]: true } : {}
|
||||||
|
},
|
||||||
|
set: (val) => {
|
||||||
|
const key = Object.keys(val)[0]
|
||||||
|
selectedModelFolder.value = key
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const enabledUpload = computed(() => {
|
||||||
|
return !!selectedModelFolder.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleBackTypeSelect = () => {
|
||||||
|
selectedModelFolder.value = undefined
|
||||||
|
currentType.value = undefined
|
||||||
|
stepValue.value--
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleConfirmSubdir = () => {
|
||||||
|
stepValue.value++
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadTotalSize = ref<number>()
|
||||||
|
const uploadSize = ref<number>()
|
||||||
|
const uploadProgress = computed(() => {
|
||||||
|
const total = toValue(uploadTotalSize)
|
||||||
|
const size = toValue(uploadSize)
|
||||||
|
if (typeof total === 'number' && typeof size === 'number') {
|
||||||
|
return Math.floor((size / total) * 100)
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
const showUploadProgress = computed(() => {
|
||||||
|
return typeof uploadProgress.value !== 'undefined'
|
||||||
|
})
|
||||||
|
|
||||||
|
const uploadActions = ref([
|
||||||
|
{
|
||||||
|
value: 'back',
|
||||||
|
label: t('back'),
|
||||||
|
icon: 'pi pi-arrow-left',
|
||||||
|
command: () => {
|
||||||
|
stepValue.value--
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'full',
|
||||||
|
label: t('chooseFile'),
|
||||||
|
command: () => {
|
||||||
|
const input = document.createElement('input')
|
||||||
|
input.type = 'file'
|
||||||
|
input.accept = supportedExtensions.value.join(',')
|
||||||
|
input.onchange = async () => {
|
||||||
|
const files = input.files
|
||||||
|
const file = files?.item(0)
|
||||||
|
if (!file) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
uploadTotalSize.value = file.size
|
||||||
|
uploadSize.value = 0
|
||||||
|
const body = new FormData()
|
||||||
|
body.append('folder', toValue(selectedModelFolder)!)
|
||||||
|
body.append('file', file)
|
||||||
|
|
||||||
|
await request('/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: body,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Error',
|
||||||
|
detail: error.message,
|
||||||
|
life: 5000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
input.click()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const supportedExtensions = ref([])
|
||||||
|
|
||||||
|
const fetchSupportedExtensions = async () => {
|
||||||
|
try {
|
||||||
|
const result = await request('/supported-extensions')
|
||||||
|
supportedExtensions.value = result ?? []
|
||||||
|
} catch (error) {
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Error',
|
||||||
|
detail: error.message,
|
||||||
|
life: 5000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const update_process = (event: CustomEvent) => {
|
||||||
|
const detail = event.detail
|
||||||
|
uploadSize.value = detail.uploaded_size
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchSupportedExtensions()
|
||||||
|
|
||||||
|
api.addEventListener('update_upload_progress', update_process)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
api.removeEventListener('update_upload_progress', update_process)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -41,6 +41,8 @@ const messages = {
|
|||||||
scanMissInformation: 'Download missing information',
|
scanMissInformation: 'Download missing information',
|
||||||
scanFullInformation: 'Override full information',
|
scanFullInformation: 'Override full information',
|
||||||
noModelsInCurrentPath: 'There are no models available in the current path',
|
noModelsInCurrentPath: 'There are no models available in the current path',
|
||||||
|
uploadModel: 'Upload Model',
|
||||||
|
chooseFile: 'Choose File',
|
||||||
sort: {
|
sort: {
|
||||||
name: 'Name',
|
name: 'Name',
|
||||||
size: 'Largest',
|
size: 'Largest',
|
||||||
@@ -116,6 +118,8 @@ const messages = {
|
|||||||
scanMissInformation: '下载缺失信息',
|
scanMissInformation: '下载缺失信息',
|
||||||
scanFullInformation: '覆盖所有信息',
|
scanFullInformation: '覆盖所有信息',
|
||||||
noModelsInCurrentPath: '当前路径中没有可用的模型',
|
noModelsInCurrentPath: '当前路径中没有可用的模型',
|
||||||
|
uploadModel: '上传模型',
|
||||||
|
chooseFile: '选择文件',
|
||||||
sort: {
|
sort: {
|
||||||
name: '名称',
|
name: '名称',
|
||||||
size: '最大',
|
size: '最大',
|
||||||
|
|||||||
5
src/types/global.d.ts
vendored
5
src/types/global.d.ts
vendored
@@ -12,6 +12,11 @@ declare namespace ComfyAPI {
|
|||||||
callback: (event: CustomEvent) => void,
|
callback: (event: CustomEvent) => void,
|
||||||
options?: AddEventListenerOptions,
|
options?: AddEventListenerOptions,
|
||||||
) => void
|
) => void
|
||||||
|
removeEventListener: (
|
||||||
|
type: string,
|
||||||
|
callback: (event: CustomEvent) => void,
|
||||||
|
options?: AddEventListenerOptions,
|
||||||
|
) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const api: ComfyApi
|
const api: ComfyApi
|
||||||
|
|||||||
Reference in New Issue
Block a user